diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..756bdf8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,107 @@ +# LLAR Project Guide + +## Design Rules + +1. No useless abstraction - don't abstract if not necessary +2. Abstracted modules must have wide usage - only extract when broadly used +3. All modules must be clean - each module should only handle its own responsibility + +## Project Overview + +LLAR is a multi-language module manager built with XGo (gop) and xgo. It uses classfile mechanism for defining build formulas. + +## XGO Classfile Mechanism + +### What is Classfile? + +Classfile is a DSL (Domain Specific Language) mechanism in xgo that allows defining custom file extensions with specific behavior. Each classfile extension maps to a Go struct that acts as a "class". + +### How Classfile Works + +1. **Registration**: Classfiles are registered via `xgobuild.RegisterProject()` in `internal/ixgo/classfile.go` + +2. **File Extension Mapping**: + - `_llar.gox` -> `ModuleF` class (formula/classfile.go) + - `_cmp.gox` -> `CmpApp` class (formula/classfile.go) + +3. **Code Generation**: When a `.gox` file is processed: + - The filename prefix (before `_`) becomes the struct name + - Example: `hello_llar.gox` generates struct `hello` embedding `ModuleF` + - A `MainEntry()` method is generated containing the DSL code + - A `Main()` method calls `Gopt_ModuleF_Main(this)` + +### Example + +Source file `hello_llar.gox`: +```gox +id "DaveGamble/cJSON" +fromVer "v1.0.0" + +onRequire (proj, deps) => { + echo "hello" +} + +onBuild (ctx, proj, out) => { + echo "hello" +} +``` + +Generated Go code: +```go +package main + +import ( + "fmt" + "github.com/goplus/llar/formula" +) + +type hello struct { + formula.ModuleF +} + +func (this *hello) MainEntry() { + this.Id("DaveGamble/cJSON") + this.FromVer("v1.0.0") + this.OnRequire(func(proj *formula.Project, deps *formula.ModuleDeps) { + fmt.Println("hello") + }) + this.OnBuild(func(ctx *formula.Context, proj *formula.Project, out *formula.BuildResult) { + fmt.Println("hello") + }) +} + +func (this *hello) Main() { + formula.Gopt_ModuleF_Main(this) +} + +func main() { + new(hello).Main() +} +``` + +### Key Points + +1. **Struct Name Derivation**: The struct name comes from `strings.Cut(filename, "_")` - the part before the first underscore + +2. **Class Entry Point**: `Gopt__Main` is the classfile entry point that: + - Calls `MainEntry()` to execute DSL code + - Initializes the embedded `gsh.App` + +3. **Error Handling**: If a file doesn't match any registered classfile pattern (wrong suffix), `BuildFile` returns "undefined" errors for DSL functions + +## Build & Test + +```bash +# Run tests with required ldflags (Go 1.24+) +go test -ldflags="-checklinkname=0" ./... + +# Run specific package tests with coverage +go test -ldflags="-checklinkname=0" -cover ./internal/formula/... +``` + +## Project Structure + +- `formula/` - Classfile definitions (ModuleF, CmpApp, Context, Project, etc.) +- `internal/formula/` - Formula loading and interpretation logic +- `internal/ixgo/` - ixgo classfile registration +- `mod/` - Module and version handling diff --git a/cmd/llar/internal/make.go b/cmd/llar/internal/make.go index d631edd..8498e30 100644 --- a/cmd/llar/internal/make.go +++ b/cmd/llar/internal/make.go @@ -23,6 +23,7 @@ import ( var makeVerbose bool var makeOutput string +var makeMatrix string // newRemoteStore creates the remote formula store. Overridable for testing. var newRemoteStore = func() (repo.Store, error) { @@ -48,6 +49,8 @@ var makeCmd = &cobra.Command{ func init() { makeCmd.Flags().BoolVarP(&makeVerbose, "verbose", "v", false, "Enable verbose build output") makeCmd.Flags().StringVarP(&makeOutput, "output", "o", "", "Output path (directory or .zip file)") + makeCmd.Flags().StringVar(&makeMatrix, "matrix", "", "Override matrix combination (internal)") + _ = makeCmd.Flags().MarkHidden("matrix") rootCmd.AddCommand(makeCmd) } @@ -68,13 +71,16 @@ func runMake(cmd *cobra.Command, args []string) error { makeOutput = abs } - matrix := formula.Matrix{ - Require: map[string][]string{ - "os": {runtime.GOOS}, - "arch": {runtime.GOARCH}, - }, + matrixStr := makeMatrix + if matrixStr == "" { + matrix := formula.Matrix{ + Require: map[string][]string{ + "os": {runtime.GOOS}, + "arch": {runtime.GOARCH}, + }, + } + matrixStr = matrix.Combinations()[0] } - matrixStr := matrix.Combinations()[0] // Set up remote formula store (always needed for deps) remoteStore, err := newRemoteStore() @@ -83,7 +89,7 @@ func runMake(cmd *cobra.Command, args []string) error { } if !isLocal { - return buildModule(ctx, remoteStore, pattern, version, matrixStr) + return buildModuleWithRunTest(ctx, remoteStore, pattern, version, matrixStr, false) } // Resolve local pattern @@ -109,7 +115,7 @@ func runMake(cmd *cobra.Command, args []string) error { if ver == "" { ver = version // global @version from arg } - if err := buildModule(ctx, store, m.Path, ver, matrixStr); err != nil { + if err := buildModuleWithRunTest(ctx, store, m.Path, ver, matrixStr, false); err != nil { return err } } @@ -118,6 +124,11 @@ func runMake(cmd *cobra.Command, args []string) error { // buildModule loads and builds a single module. func buildModule(ctx context.Context, store repo.Store, modPath, version, matrixStr string) error { + return buildModuleWithRunTest(ctx, store, modPath, version, matrixStr, false) +} + +// buildModuleWithRunTest loads and builds a single module. +func buildModuleWithRunTest(ctx context.Context, store repo.Store, modPath, version, matrixStr string, runTest bool) error { mods, err := modules.Load(ctx, module.Version{Path: modPath, Version: version}, modules.Options{ FormulaStore: store, }) @@ -151,6 +162,7 @@ func buildModule(ctx context.Context, store repo.Store, modPath, version, matrix buildOpts := build.Options{ Store: store, MatrixStr: matrixStr, + RunTest: runTest, } if makeOutput != "" { tmpDir, err := os.MkdirTemp("", "llar-make-*") diff --git a/cmd/llar/internal/test.go b/cmd/llar/internal/test.go new file mode 100644 index 0000000..3672c8d --- /dev/null +++ b/cmd/llar/internal/test.go @@ -0,0 +1,322 @@ +package internal + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "runtime" + "slices" + "strings" + + "github.com/goplus/llar/formula" + "github.com/goplus/llar/internal/build" + "github.com/goplus/llar/internal/evaluator" + "github.com/goplus/llar/internal/formula/repo" + "github.com/goplus/llar/internal/modules" + "github.com/goplus/llar/internal/modules/modlocal" + "github.com/goplus/llar/internal/trace" + "github.com/goplus/llar/mod/module" + "github.com/spf13/cobra" +) + +var testVerbose bool +var testAuto bool +var testTraceDump bool + +var testCmd = &cobra.Command{ + Use: "test [module@version]", + Short: "Build a module and run onTest", + Long: `Test builds a module and executes onTest callbacks. Use --auto to evaluate which matrix combinations must run tests.`, + Args: cobra.ExactArgs(1), + RunE: runTest, +} + +func init() { + testCmd.Flags().BoolVarP(&testVerbose, "verbose", "v", false, "Enable verbose build/test output") + testCmd.Flags().BoolVar(&testAuto, "auto", false, "Automatically evaluate matrix combinations before running onTest") + testCmd.Flags().BoolVar(&testTraceDump, "trace-dump", false, "Print intercepted build trace for each auto probe") + rootCmd.AddCommand(testCmd) +} + +func runTest(cmd *cobra.Command, args []string) error { + pattern, version, isLocal, err := parseModuleArg(args[0]) + if err != nil { + return err + } + if testAuto && isLocal { + return fmt.Errorf("--auto does not support local patterns yet") + } + if testTraceDump && !testAuto { + return fmt.Errorf("--trace-dump requires --auto") + } + + ctx := context.Background() + remoteStore, err := newRemoteStore() + if err != nil { + return err + } + + if !isLocal { + return testModule(ctx, remoteStore, pattern, version) + } + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + localMods, err := modlocal.Resolve(cwd, pattern) + if err != nil { + return err + } + + locals := make(map[string]string, len(localMods)) + for _, m := range localMods { + locals[m.Path] = m.Dir + } + store := repo.NewOverlayStore(remoteStore, locals) + for _, m := range localMods { + ver := m.Version + if ver == "" { + ver = version + } + if err := testModule(ctx, store, m.Path, ver); err != nil { + return err + } + } + return nil +} + +func testModule(ctx context.Context, store repo.Store, modPath, version string) error { + combos := []string{defaultMatrixCombo()} + if testAuto { + matrix, err := loadModuleMatrix(ctx, store, modPath, version) + if err != nil { + return err + } + if matrix.CombinationCount() == 0 { + matrix = defaultRuntimeMatrix() + } + moduleArg := modPath + if version != "" { + moduleArg = modPath + "@" + version + } + var trusted bool + combos, trusted, err = evaluator.WatchWithOptions(ctx, matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + return collectAutoProbe(ctx, store, moduleArg, modPath, version, combo) + }, evaluator.WatchOptions{ + ValidateSynthesizedPair: func(ctx context.Context, combo string, synthesized evaluator.OutputSynthesisResult) (bool, error) { + return validateSynthesizedPairTest(ctx, store, modPath, version, combo, synthesized) + }, + }) + if err != nil { + return fmt.Errorf("automatic matrix evaluation failed: %w", err) + } + if !trusted { + fmt.Fprintln(os.Stderr, "warning: automatic matrix evaluation is not trusted") + } + } + + savedVerbose, savedOutput := makeVerbose, makeOutput + makeVerbose, makeOutput = testVerbose, "" + defer func() { + makeVerbose, makeOutput = savedVerbose, savedOutput + }() + + for _, combo := range combos { + if err := buildModuleWithRunTest(ctx, store, modPath, version, combo, true); err != nil { + return fmt.Errorf("test failed for %s@%s [%s]: %w", modPath, version, combo, err) + } + if testVerbose { + fmt.Printf("ok %s@%s [%s]\n", modPath, version, combo) + } + } + return nil +} + +func loadModuleMatrix(ctx context.Context, store repo.Store, modPath, version string) (formula.Matrix, error) { + mods, err := modules.Load(ctx, module.Version{Path: modPath, Version: version}, modules.Options{FormulaStore: store}) + if err != nil { + return formula.Matrix{}, fmt.Errorf("failed to load modules: %w", err) + } + for _, mod := range mods { + if mod.Path != modPath { + continue + } + if version != "" && mod.Version != version { + continue + } + return mod.Matrix, nil + } + return formula.Matrix{}, nil +} + +func defaultRuntimeMatrix() formula.Matrix { + return formula.Matrix{ + Require: map[string][]string{ + "os": {runtime.GOOS}, + "arch": {runtime.GOARCH}, + }, + } +} + +func defaultMatrixCombo() string { + matrix := defaultRuntimeMatrix() + return matrix.Combinations()[0] +} + +func collectAutoProbe(ctx context.Context, store repo.Store, moduleArg, modPath, version, combo string) (evaluator.ProbeResult, error) { + mods, err := modules.Load(ctx, module.Version{Path: modPath, Version: version}, modules.Options{ + FormulaStore: store, + }) + if err != nil { + return evaluator.ProbeResult{}, fmt.Errorf("failed to load modules for %s: %w", moduleArg, err) + } + + for _, mod := range mods { + mod.SetStdout(io.Discard) + mod.SetStderr(io.Discard) + } + + // Probe builds must stay quiet because formulas may write directly to process stdout/stderr. + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, fmt.Errorf("failed to open devnull for %s: %w", moduleArg, err) + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + builder, err := build.NewBuilder(build.Options{ + Store: store, + MatrixStr: combo, + Trace: true, + }) + if err != nil { + return evaluator.ProbeResult{}, fmt.Errorf("failed to create auto probe builder for %s: %w", moduleArg, err) + } + + results, err := builder.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, fmt.Errorf("failed to build auto probe for %s [%s]: %w", moduleArg, combo, err) + } + if len(results) == 0 { + return evaluator.ProbeResult{}, nil + } + result := results[len(results)-1] + records := result.Trace + if testTraceDump { + if _, err := io.WriteString(savedStderr, formatTraceDump(moduleArg, combo, records, result.InputDigests)); err != nil { + return evaluator.ProbeResult{}, fmt.Errorf("failed to write trace dump for %s [%s]: %w", moduleArg, combo, err) + } + } + outputManifest, err := evaluator.BuildOutputManifest(result.OutputDir, result.Metadata) + if err != nil { + return evaluator.ProbeResult{}, fmt.Errorf("failed to build output manifest for %s [%s]: %w", moduleArg, combo, err) + } + return evaluator.ProbeResult{ + Records: records, + Events: result.TraceEvents, + OutputDir: result.OutputDir, + Scope: result.TraceScope, + TraceDiagnostics: result.TraceDiagnostics, + InputDigests: result.InputDigests, + OutputManifest: outputManifest, + ReplayReady: result.ReplayReady, + }, nil +} + +func validateSynthesizedPairTest(ctx context.Context, store repo.Store, modPath, version, combo string, synthesized evaluator.OutputSynthesisResult) (bool, error) { + mods, err := modules.Load(ctx, module.Version{Path: modPath, Version: version}, modules.Options{ + FormulaStore: store, + }) + if err != nil { + return false, fmt.Errorf("failed to load modules for merged validation %s@%s [%s]: %w", modPath, version, combo, err) + } + + var savedStdout, savedStderr *os.File + if !testVerbose { + for _, mod := range mods { + mod.SetStdout(io.Discard) + mod.SetStderr(io.Discard) + } + savedStdout = os.Stdout + savedStderr = os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return false, fmt.Errorf("failed to open devnull for synthesized validation %s@%s [%s]: %w", modPath, version, combo, err) + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + } + + builder, err := build.NewBuilder(build.Options{ + Store: store, + MatrixStr: combo, + }) + if err != nil { + return false, fmt.Errorf("failed to create merged validation builder for %s@%s [%s]: %w", modPath, version, combo, err) + } + if err := builder.RunOnTest(ctx, mods, synthesized.Root); err != nil { + var onTestErr *build.OnTestFailureError + if errors.As(err, &onTestErr) { + return false, nil + } + return false, fmt.Errorf("synthesized validation failed for %s@%s [%s]: %w", modPath, version, combo, err) + } + return true, nil +} + +func formatTraceDump(moduleArg, combo string, records []trace.Record, inputDigests map[string]string) string { + var b strings.Builder + fmt.Fprintf(&b, "TRACE %s [%s]\n", moduleArg, combo) + if len(inputDigests) > 0 { + b.WriteString("DIGESTS\n") + for _, path := range sortedDigestPaths(inputDigests) { + fmt.Fprintf(&b, " %s = %s\n", path, inputDigests[path]) + } + } + if len(records) == 0 { + b.WriteString("(no records)\n") + return b.String() + } + for i, rec := range records { + fmt.Fprintf(&b, "%d. argv: %s\n", i+1, strings.Join(rec.Argv, " ")) + if rec.Cwd != "" { + fmt.Fprintf(&b, " cwd: %s\n", rec.Cwd) + } + if len(rec.Env) > 0 { + fmt.Fprintf(&b, " env: %s\n", strings.Join(rec.Env, ", ")) + } + if len(rec.Inputs) > 0 { + fmt.Fprintf(&b, " inputs: %s\n", strings.Join(rec.Inputs, ", ")) + } + if len(rec.Changes) > 0 { + fmt.Fprintf(&b, " changes: %s\n", strings.Join(rec.Changes, ", ")) + } + } + return b.String() +} + +func sortedDigestPaths(inputDigests map[string]string) []string { + if len(inputDigests) == 0 { + return nil + } + paths := make([]string, 0, len(inputDigests)) + for path := range inputDigests { + paths = append(paths, path) + } + slices.Sort(paths) + return paths +} diff --git a/cmd/llar/internal/test_trace_dump_test.go b/cmd/llar/internal/test_trace_dump_test.go new file mode 100644 index 0000000..936477c --- /dev/null +++ b/cmd/llar/internal/test_trace_dump_test.go @@ -0,0 +1,43 @@ +package internal + +import ( + "strings" + "testing" + + "github.com/goplus/llar/internal/trace" +) + +func TestFormatTraceDump(t *testing.T) { + got := formatTraceDump("madler/zlib@v1.3.1", "linux-amd64", []trace.Record{ + { + Argv: []string{"cmake", "--build", "build"}, + Cwd: "/tmp/zlib", + Inputs: []string{"/tmp/zlib/CMakeLists.txt"}, + Changes: []string{"/tmp/zlib/build/libz.a"}, + }, + }, map[string]string{ + "/tmp/zlib/build/config.h": "aaaaaaaaaaaaaaaa", + }) + + checks := []string{ + "TRACE madler/zlib@v1.3.1 [linux-amd64]", + "DIGESTS", + "/tmp/zlib/build/config.h = aaaaaaaaaaaaaaaa", + "1. argv: cmake --build build", + "cwd: /tmp/zlib", + "inputs: /tmp/zlib/CMakeLists.txt", + "changes: /tmp/zlib/build/libz.a", + } + for _, want := range checks { + if !strings.Contains(got, want) { + t.Fatalf("formatTraceDump() missing %q in %q", want, got) + } + } +} + +func TestFormatTraceDumpEmpty(t *testing.T) { + got := formatTraceDump("mod", "combo", nil, nil) + if !strings.Contains(got, "(no records)") { + t.Fatalf("formatTraceDump() = %q, want empty marker", got) + } +} diff --git a/doc/conan-testing-system-research.md b/doc/conan-testing-system-research.md new file mode 100644 index 0000000..8e63572 --- /dev/null +++ b/doc/conan-testing-system-research.md @@ -0,0 +1,304 @@ +# Conan 测试系统深度调研(含测试平台、复杂矩阵与测试例子) + +调研日期:2026-02-28 +文档范围:Conan 2.x 官方文档、ConanCenter Index(CCI)官方规范与 FAQ、Conan 官方 examples2 示例 + +--- + +## 1. 先回答你最关心的问题 + +### 1.1 Conan 是“全量测试”吗? + +不是。 + +- 在 ConanCenter(公共二进制服务)侧,做的是“固定 profile 列表 + `package_id` 去重后构建”,不是 options 全组合穷举。 +- 在组织自建 CI(Conan 官方 CI 教程)侧,推荐“增量构建 + 产品级集成验证”,而不是全库全反向依赖全量重测。 + +### 1.2 Conan 面对庞大构建矩阵,核心策略是什么? + +核心是 5 个动作: + +1. 固定平台/配置集(profiles)而不是全组合。 +2. 用 `package_id` 去重,避免重复构建。 +3. 用 `conan graph build-order` 只重建需要重建的包,并按拓扑分层并行。 +4. 用 `conan graph build-order-merge` 合并“多产品 x 多配置”构建计划,消除重复。 +5. 用 lockfile 保证多机器并行时依赖版本一致。 + +### 1.3 Conan 有没有官方 pairwise(两两组合)测试方案? + +公开文档里没有把 pairwise 作为官方策略。 +公开推荐路线是 `package_id`/binary model + build-order + lockfile + 产品流水线验证。 + +--- + +## 2. Conan 的“整个测试系统”到底包含哪些层 + +Conan 生态里“测试系统”要分三层看: + +1. 配方级验证(`conan create` + `test_package` / `conan test`) +作用:验证“这个包能被消费者正确使用”(smoke/consumer check)。 + +2. 包构建服务层(ConanCenter) +作用:按既定平台矩阵批量产出公共二进制,不承担上游完整测试套件回归。 + +3. 组织 CI 集成层(官方 CI tutorial 的 packages/products pipeline) +作用:验证“变更是否破坏组织关键产品”,并把通过验证的包逐级晋升(promote)。 + +这三层组合起来,才是 Conan 的完整测试平台思路。 + +--- + +## 3. ConanCenter(CCI)如何测试大矩阵 + +## 3.1 CCI 跑什么 + +CCI 文档明确写了流程: + +- 对每个 Conan reference 遍历固定 profile 列表; +- 计算每个 profile 的 `packageID`; +- 去掉重复后,只构建剩余组合; +- PR 合并后再晋升到 ConanCenter。 + +文档还给了量级:当前一个 C++ 库可生成约 30 个二进制包。 + +## 3.2 CCI 不跑什么 + +CCI FAQ 明确:不在 recipe 里构建/执行上游 testsuite。主要理由: + +- 100+ 配置下成本太高; +- CCI 的定位是二进制构建服务,不是上游库集成测试系统。 + +## 3.3 CCI 如何控制 options 维度 + +CCI 对常见 options 有明确约束: + +- `shared`:默认建议 `False`,CI 会生成 `True/False` 组合。 +- `fPIC`:默认建议 `True`,CI 会生成 `True/False` 组合(适用场景下)。 +- `header_only`:默认 `False`,若存在该选项,CI 会额外加 `header_only=True`,但只产出一个 header-only 包。 + +CCI 还明确建议不要加 `build_testing` 这类选项,建议使用 `skip_test` 相关配置。 + +## 3.4 平台实现细节公开度 + +CCI FAQ 同时明确:其 Jenkins orchestration 库目前不公开。 +所以“内部调度代码怎么实现”无法从公开资料完整复原。 + +--- + +## 4. Conan CLI 的测试机制(配方作者层) + +## 4.1 `conan create` + +`conan create` 默认行为是: + +- 创建包; +- 若存在 `test_package`,执行消费者测试工程验证包可用性。 + +常见控制项: + +- `--test-folder=""`:跳过测试阶段。 +- `--build=missing`:只有缺二进制时才从源码构建。 +- `--test-missing`:配合 `--build=missing`,仅当本次确实源码构建时才跑 `test_package`。 + +## 4.2 `conan test` + +`conan test` 可以独立执行 `test_package`,用于验证指定 reference。 +也支持 `--build=missing` 和 lockfile 参数体系。 + +## 4.3 官方 `test_package` 示例(可直接参考) + +来源:Conan 官方 `examples2` + +```python +from conan import ConanFile +from conan.tools.cmake import CMake, cmake_layout +from conan.tools.build import can_run +import os + +class helloTestConan(ConanFile): + settings = "os", "compiler", "build_type", "arch" + generators = "CMakeDeps", "CMakeToolchain" + + def requirements(self): + self.requires(self.tested_reference_str) + + def build(self): + cmake = CMake(self) + cmake.configure() + cmake.build() + + def layout(self): + cmake_layout(self) + + def test(self): + if can_run(self): + cmd = os.path.join(self.cpp.build.bindir, "example") + self.run(cmd, env="conanrun") +``` + +重点: + +- 这是消费者视角 smoke test,不是上游完整单测回归。 +- 官方也建议 `test_package` 尽量只依赖被测包本身,不要引入复杂额外依赖。 + +--- + +## 5. Conan 官方 CI 方案如何做“规模化快速测试” + +这里是你最关心的部分:不是只讲命令,而是讲系统。 + +## 5.1 两条流水线分工(packages / products) + +官方 CI 教程把系统拆成两条线: + +- packages pipeline:某个包变更后,先把该包在多配置下构建出来。 +- products pipeline:再验证组织关键产品是否还能正确集成(必要时重建中间消费者)。 + +这比“全量反向依赖全跑”更可控,也比“只测当前包”更安全。 + +## 5.2 三仓库晋升模型(测试平台核心) + +官方推荐至少三类仓库: + +- `packages`:暂存 package pipeline 产物; +- `products`:暂存 products pipeline 验证产物; +- `develop`:对开发者和常规 CI 暴露的稳定仓库。 + +只有通过前一阶段验证,才 promote 到下一仓库。 +本质上是“分阶段放行”,避免坏包直接污染主仓库。 + +## 5.3 build-order:只重建必要包,而非全图重建 + +`conan graph build-order` 用于算“哪些包要重建、顺序是什么”。 + +典型命令(官方教程): + +```bash +conan graph build-order --requires=game/1.0 --build=missing --order-by=recipe --format=json > game_release.json +conan graph build-order --requires=game/1.0 --build=missing --order-by=recipe -s build_type=Debug --format=json > game_debug.json +``` + +输出 `order` 是“列表中的列表”(按层): + +- 同一层可以并行构建; +- 下一层必须等上一层完成。 + +这就是大图并行调度的关键。 + +## 5.4 多产品多配置去重:build-order-merge + +多产品(如 `game`、`mapviewer`)+ 多配置(Release/Debug)时,官方建议: + +1. 先分别计算每个 build-order(不要先 `--reduce`)。 +2. 再统一 `build-order-merge`,最后 `--reduce` 得到最终“仅需构建项”。 + +```bash +conan graph build-order-merge \ + --file=game_release.json \ + --file=game_debug.json \ + --file=mapviewer_release.json \ + --file=mapviewer_debug.json \ + --reduce --format=json > build_order.json +``` + +这样可以减少重复构建,避免“多流水线各自重复打包同一二进制”。 + +## 5.5 lockfile:分布式并行时防止依赖漂移 + +官方教程强调:多机并行时若不锁依赖,可能不同配置拉到不同依赖版本,导致同版本包行为不一致。 +做法是先聚合 lockfile,再把同一个 lockfile 分发给各构建 agent。 + +示例: + +```bash +conan lock create --requires=game/1.0 --lockfile-out=conan.lock +conan lock create --requires=game/1.0 -s build_type=Debug --lockfile=conan.lock --lockfile-out=conan.lock +conan lock create --requires=mapviewer/1.0 --lockfile=conan.lock --lockfile-out=conan.lock +conan lock create --requires=mapviewer/1.0 -s build_type=Debug --lockfile=conan.lock --lockfile-out=conan.lock +``` + +--- + +## 6. 你问的“改 zlib 怎么测”在 Conan 里的可落地流程 + +假设 `zlib` 改了,且你有很多下游,不想全跑到爆炸: + +1. 先跑 `zlib` 自身矩阵(你定义的默认 options + 目标 profiles)。 +2. 产物先放 `packages`(不直接进 `develop`)。 +3. 选定“关键产品集”(例如你真实交付的 top-level app/lib,不是全仓所有包)。 +4. 对每个产品 x 配置计算 `build-order`,再 `build-order-merge --reduce`。 +5. 按 build-order 层级并行重建必要消费者(二进制缺失/失配的那些),并跑产品级测试。 +6. 全通过后再 promote 到 `develop`。 + +效果: + +- 不是“全量所有依赖全跑”; +- 也不是“只测 zlib 本包”; +- 而是“只重建对你交付产品有影响的必要路径”。 + +--- + +## 7. 关于 pairwise 可行性:基于证据的结论 + +官方文档没有提供 pairwise 作为推荐主策略。 +从公开机制看(此处为基于文档的工程推断): + +- `package_id` 由 settings/options/依赖信息共同决定; +- 依赖版本/修订变化会改变消费者二进制判定; +- 依赖关系与是否嵌入(embed/non-embed)也会影响是否必须重建; +- 逆向依赖在分布式仓库里本就难以完整建模。 + +所以 pairwise 在 Conan 这类 C/C++ 二进制图里,很难单独承担“正确性保证”的角色。 +Conan 官方路线更偏向:binary model + build-order + lockfile + 产品级集成验证。 + +--- + +## 8. 给 LLAR 的直接启发(贴近你当前 default-options + require 矩阵) + +如果 LLAR 当前是“default options + require 矩阵”,建议借鉴 Conan 的不是“全量跑”,而是: + +1. 保留包级矩阵(default options + 必要 profiles)作为第一层。 +2. 增加“产品集”概念(只对关键交付物做集成守门)。 +3. 增加 build-order 计算与层级并行执行,避免全图重建。 +4. 增加 lockfile/快照机制,保证多机并行依赖一致。 +5. 增加 staged promote(`packages -> products -> develop`),避免坏包直达主仓。 + +这套组合比“全量穷举”现实,也比“纯增量本包测试”更不容易把错误转嫁给用户。 + +--- + +## 9. 参考来源(均为官方/一手) + +### ConanCenter Index(CCI) + +- [Supported platforms and configurations](https://raw.githubusercontent.com/conan-io/conan-center-index/master/docs/supported_platforms_and_configurations.md) +- [Conanfile attributes(CCI policy)](https://raw.githubusercontent.com/conan-io/conan-center-index/master/docs/adding_packages/conanfile_attributes.md) +- [CCI FAQs](https://raw.githubusercontent.com/conan-io/conan-center-index/master/docs/faqs.md) + +### Conan 官方命令与模型 + +- [`conan create`](https://docs.conan.io/2/reference/commands/create.html) +- [`conan test`](https://docs.conan.io/2/reference/commands/test.html) +- [`conan graph build-order`](https://docs.conan.io/2/reference/commands/graph/build_order.html) +- [`conan graph build-order-merge`](https://docs.conan.io/2/reference/commands/graph/build_order_merge.html) +- [`conan graph explain`](https://docs.conan.io/2/reference/commands/graph/explain.html) +- [Binary model: package_id](https://docs.conan.io/2/reference/binary_model/package_id.html) +- [Binary model: dependencies effect](https://docs.conan.io/2/reference/binary_model/dependencies.html) +- [global.conf(含 `tools.build:skip_test`)](https://docs.conan.io/2/reference/config_files/global_conf.html) + +### Conan 官方 CI 教程 + +- [CI tutorial overview](https://docs.conan.io/2/ci_tutorial.html) +- [Packages pipeline](https://docs.conan.io/2/ci_tutorial/packages_pipeline.html) +- [Package pipeline: multi configuration](https://docs.conan.io/2/ci_tutorial/packages_pipeline/multi_configuration.html) +- [Package pipeline: multi configuration using lockfiles](https://docs.conan.io/2/ci_tutorial/packages_pipeline/multi_configuration_lockfile.html) +- [Products pipeline](https://docs.conan.io/2/ci_tutorial/products_pipeline.html) +- [Products pipeline: build-order](https://docs.conan.io/2/ci_tutorial/products_pipeline/build_order.html) +- [Products pipeline: multi-product multi-configuration](https://docs.conan.io/2/ci_tutorial/products_pipeline/multi_product.html) +- [Products pipeline: distributed full pipeline with lockfiles](https://docs.conan.io/2/ci_tutorial/products_pipeline/full_pipeline.html) + +### 官方测试示例 + +- [examples2: test_package/conanfile.py](https://raw.githubusercontent.com/conan-io/examples2/master/tutorial/creating_packages/testing_packages/test_package/conanfile.py) +- [examples2: test_package/CMakeLists.txt](https://raw.githubusercontent.com/conan-io/examples2/master/tutorial/creating_packages/testing_packages/test_package/CMakeLists.txt) + diff --git a/doc/llar-evaluator-design.md b/doc/llar-evaluator-design.md new file mode 100644 index 0000000..ef38993 --- /dev/null +++ b/doc/llar-evaluator-design.md @@ -0,0 +1,1359 @@ +# LLAR Evaluator 设计稿:三段式教材版 + +## 1. 文档定位 + +这份文档不是实现说明,不是代码迁移计划,也不是测试产品说明。 + +这份文档只定义 evaluator 的核心算法设计,目标是回答下面这个问题: + +> 在黑盒构建条件下,如何只凭 trace、路径内容证据和有限的产物证据,保守地判断哪些 option 组合必须真实执行,哪些可以被证明为可跳过。 + +本文的写法刻意采用教材式结构: + +1. 先讲总览 +2. 再讲三项核心创新 +3. 再讲对象定义 +4. 再讲单图构造 +5. 再讲 baseline 对 singleton 的单边分析 +6. 最后讲双边判定与重放合并 + +如果一个工程师或一个 AI 从零实现 evaluator,应该先读完第 2 章,再按第 6 到第 10 章的顺序实现。 + +--- + +## 2. 总览:evaluator 的三项核心创新 + +这套 evaluator 的核心不止一个点,而是三段式创新链条: + +1. `Trace SSA` +2. `起火比较` +3. `重放合并` + +这三者不是并列的 feature,而是一条前后相接的推理链。 + +```mermaid +flowchart TD + T["创新一
Trace SSA"] --> F["创新二
起火比较"] + F --> H["双边 hazard 判定"] + H --> R["创新三
重放合并"] +``` + +### 2.1 创新一:Trace SSA + +Trace SSA 解决的问题是: + +> 怎么把黑盒构建 trace 从“命令列表”升级成“路径状态传播图”。 + +它的核心思想是: + +- 每次路径写入都产生新的路径状态版本 +- 每次路径读取都绑定到 reaching-def +- 传播分析不再基于“路径有没有出现”,而是基于“状态从哪里定义、流向哪里” + +Trace SSA 解决的是**表示问题**。 + +### 2.2 创新二:起火比较 + +起火比较解决的问题是: + +> option 的变化到底是从哪里开始点火的,哪些是直接变化,哪些只是被上游带着变化。 + +它不直接比较“两张图哪里不一样”,而是先做 baseline 对 singleton 的单边分析,抽出: + +- `MutationRoot` +- `SeedDef` +- `Need` +- `Flow` +- `Frontier` + +起火比较解决的是**归因问题**。 + +### 2.3 创新三:重放合并 + +重放合并解决的问题是: + +> 当两个 option 在图上看起来共享上游根时,哪些共享传播应当判死,哪些共享传播可以被提升到 replay root 上吸收。 + +它的核心思想不是“强行 merge 文件”,而是: + +- 识别最近、清晰、可参数化的 replay root +- 在 root 处合并参数差异 +- 只重放最小必要子图 + +重放合并解决的是**可吸收冲突的证明问题**。 + +### 2.4 三项创新各自回答什么 + +把三者放在一起,可以得到一个非常清晰的职责划分: + +- `Trace SSA` 回答:状态图怎么建 +- `起火比较` 回答:变化从哪里开始,传播到哪里 +- `重放合并` 回答:共享传播能否在更高层被吸收 + +缺任何一个,这套 evaluator 都不完整: + +- 没有 Trace SSA,就没有可靠的状态化传播模型 +- 没有起火比较,就分不清直接变化和传播变化 +- 没有重放合并,就会把一切共享根传播都过早判成死碰撞 + +### 2.5 本文后续章节怎么对应三项创新 + +后面各章和这三项创新的关系如下: + +- 第 6 章讲 `Trace SSA` 单图构造 +- 第 7 章到第 9 章讲 `起火比较` +- 第 11 章讲 `重放合并` + +也就是说: + +> 本文不是单纯讲 SSA,而是在讲“Trace SSA -> 起火比较 -> 重放合并”这一整条 evaluator 核心方法链。 + +--- + +## 3. 先看一个贯穿全文的最小例子 + +后面的定义都围绕这组最小例子解释。 + +### 3.1 Baseline + +```text +E1: cc -c server.c -o server.o +E2: cc -c utils.c -o utils.o +E3: cc server.o utils.o -o app +``` + +### 3.2 打开 option A + +`A` 会开启代码生成: + +```text +EA1: protoc api.proto -> api.h api.c +EA2: cc -c server.c -o server.o +EA3: cc -c api.c -o api.o +EA4: cc server.o utils.o api.o -o app +``` + +注意: + +- `EA2` 现在可能会读 `api.h` +- `EA4` 现在会读 `api.o` + +### 3.3 打开 option B + +`B` 会改变日志宏: + +```text +EB1: cc -DLOG_ON -c utils.c -o utils.o +EB2: cc server.o utils.o -o app +``` + +### 3.4 我们真正想知道什么 + +读者在心里一直带着下面五个问题即可: + +1. `A` 的直接起火点是 `EA1/EA3`,还是连 `EA2` 也算 +2. `A` 的传播是否会波及 `server.o`、`app` +3. `B` 的传播是否会污染 `A` 的前提 +4. 两边是否只在最终 `app` 上汇聚 +5. 如果只在 `app` 上汇聚,这是不是一个可以后移处理的相遇 + +后面所有算法,都是为了把这五个问题机械化。 + +--- + +## 4. 问题边界 + +在进入算法之前,先把边界讲死。 + +### 4.1 本文要解决什么 + +本文只解决: + +- 执行节点级 trace 的状态化建模 +- baseline 对 singleton 的单边影响提取 +- singleton pair 的双边 hazard 判定 +- 共享 replay root 的可吸收性判定框架 + +### 4.2 本文不解决什么 + +本文不解决: + +- 源码语义分析 +- ABI 静态证明 +- 目录枚举语义恢复 +- 用户测试语义推断 +- 高阶组合递归认证 + +### 4.3 为什么边界必须先写死 + +因为 evaluator 最大的风险不是“不会分析”,而是“分析得太像自己很懂”。 + +这份设计要求: + +> 没有观测到的能力,就明确承认缺口,不允许伪精确。 + +--- + +## 5. 输入模型与基本术语 + +### 5.1 原始观测记录 `ExecRecord` + +最小输入单位是执行级记录: + +```text +ExecRecord { + Argv + Cwd + Env? + Inputs[] + Changes[] + Parent? + Sequence? +} +``` + +含义: + +- `Argv`:实际执行命令 +- `Cwd`:工作目录 +- `Inputs[]`:该命令显式读过的路径 +- `Changes[]`:该命令显式写出、改名、删除、创建过的路径 +- `Parent?`:父子进程关系证据 +- `Sequence?`:顺序证据 + +### 5.2 作用域 `Scope` + +每个样本都需要作用域根: + +```text +Scope { + SourceRoot + BuildRoot + OutputRoot + KeepRoots? +} +``` + +`Scope` 的唯一作用是: + +> 把样本私有绝对路径归一化到统一路径空间。 + +### 5.3 内容证据 `InputDigests` + +对 build-root 关键路径,需要内容摘要: + +```text +InputDigests[path] = digest +``` + +这是后面判定“路径是否真的变化”的硬证据。 + +### 5.4 `StablePath` + +所有分析都基于 stable path,而不是原始绝对路径。 + +例如: + +```text +/tmp/build-1234/server.o +-> $BUILD/server.o +``` + +一个路径只有满足以下条件时,才算 stable: + +1. 不依赖随机临时目录名 +2. 不依赖样本私有绝对根 +3. 在不同样本里能映射到同一个逻辑键 + +### 5.5 `ExecNode` + +SSA 图中的执行节点记为 `E`。 + +它是不透明的黑盒执行事实,只携带: + +- `argv` +- `cwd` +- `read paths` +- `write paths` +- 顺序证据 + +`ExecNode` 不是: + +- compile 节点 +- link 节点 +- install 节点 +- configure 节点 + +这些语义标签如果需要,只能作为后续旁路标签出现。 + +### 5.6 `PathState` + +路径状态版本记为: + +```text +P(path, n) +``` + +含义是: + +> 路径 `path` 在第 `n` 次定义之后的状态。 + +如果路径被删除,则定义: + +```text +P(path, n, tombstone=true) +``` + +它表示: + +> 当前构建状态下,这个路径不存在。 + +需要牢记: + +1. `n` 是该路径自己的局部定义编号 +2. `n` 不是全局时间 +3. 跨图比较时不能直接比较 `n` + +### 5.7 `StateClass` + +跨图比较时,不能直接比较本地图里的状态编号,所以要引入状态类键: + +```text +StateClass = (StablePath, Tombstone) +``` + +图内传播用精确状态对象。 +跨图比较用 `StateClass`。 + +--- + +## 6. 创新一:Trace SSA 单图构造 + +现在进入第一项核心创新:Trace SSA 本身怎么构建。 + +### 6.1 最终图的形状 + +Trace SSA 是一张二分图,只允许两类核心边: + +- `P -> E`:读取 +- `E -> P`:写入 + +也就是: + +```text +PathState -> ExecNode -> PathState +``` + +为什么不需要显式 join 节点? + +因为多个来源的汇合天然发生在执行节点的多输入边上,例如: + +```text +P(server.o,1) -> E(link) <- P(utils.o,1) +``` + +汇合已经在读取节点本身表达出来,不需要再造一类核心对象。 + +### 6.2 正确顺序:先构造单图,再谈差分 + +实现者最容易犯的错误是: + +> 一开始就想 baseline 和 singleton 怎么比。 + +这是错的。 + +正确顺序是: + +1. 先把单个样本构造成完整的 Path-SSA +2. 再定义 baseline 与 singleton 的节点对应 +3. 再从对应关系中提取单边影响 +4. 最后才做双边 hazard + +所以这里先只讲“单图怎么建”。 + +### 6.3 第 0 步:观测归一化 + +输入:原始 `ExecRecord[]` +输出:`NormalizedExecNode[]` + +目标: + +> 把原始 trace 变成一组稳定、可比较的执行节点观测。 + +必须做四件事: + +1. 路径 canonicalization +2. `cwd/env/argv` 规范化 +3. 作用域 token 化 +4. 去掉纯瞬时噪音路径 + +#### 6.3.1 路径 canonicalization + +把样本私有路径映射成 stable path: + +```text +/private/tmp/build-abc123/libfoo.a +-> $BUILD/libfoo.a +``` + +后面 baseline 与 singleton 的一切比较,都建立在这个统一路径空间之上。 + +#### 6.3.2 `argv` 规范化 + +`argv` 规范化的目标不是理解语义,而是去掉不稳定 token,例如: + +- 绝对根路径 +- 临时 build 目录 +- 随机后缀 + +禁止在这一步做: + +- compile/link/install 分类 +- 业务启发式语义推理 + +#### 6.3.3 读写集合规范化 + +归一化后,每个节点至少应变成: + +```text +NormalizedExecNode { + Id + Argv + Cwd + ReadPaths[] + WritePaths[] + DeletePaths[] + Parent? + Sequence? +} +``` + +删除路径应单独保留,不要混入普通写集合。 + +#### 6.3.4 归一化阶段绝对不能做什么 + +不能在归一化阶段做: + +- `MutationRoot` 判定 +- `Need / Flow / Frontier` 提取 +- pair hazard 判定 +- 角色归类 + +因为这些都依赖后面的 SSA 结构。 + +#### 6.3.5 归一化伪代码 + +```text +NormalizeTrace(records, scope): + nodes = [] + for each record in records: + node = new NormalizedExecNode + node.Argv = NormalizeArgv(record.Argv, scope) + node.Cwd = NormalizePath(record.Cwd, scope) + node.ReadPaths = NormalizePaths(record.Inputs, scope) + node.WritePaths = NormalizeWrittenPaths(record.Changes, scope) + node.DeletePaths = NormalizeDeletedPaths(record.Changes, scope) + node.Parent = record.Parent + node.Sequence = record.Sequence + node = RemoveEphemeralNoise(node) + nodes.append(node) + return nodes +``` + +### 6.4 第 1 步:建立保守因果偏序 + +真实构建往往不是全局串行的,所以不能假设一个全局线性时间。 + +正确做法是建立: + +> **保守因果偏序。** + +#### 6.4.1 可比较 + +如果当前证据足以证明: + +```text +E1 一定先于 E2 +``` + +则 `E1` 与 `E2` 可比较。 + +#### 6.4.2 不可比较 + +如果当前证据不足以决定: + +```text +E1 在 E2 前 +还是 +E2 在 E1 前 +``` + +那它们就是不可比较的。 + +绝不能强行猜顺序。 + +#### 6.4.3 允许使用哪些先后证据 + +一个实现可以使用: + +1. 同一逻辑执行流中的顺序 +2. 显式事件序号 +3. 父子进程的包围关系 +4. 已观测到的 def-use 证据 + +只要证据不足,就保持不可比较。 + +#### 6.4.4 为什么偏序不是可选项 + +如果偷懒用 total order: + +- 会把并发定义错误排出先后 +- 会错误绑定读取 +- 会把本应保留 ambiguity 的情况误判成确定传播 + +这会直接破坏 soundness。 + +#### 6.4.5 偏序接口 + +实现层最好提供一个基础谓词: + +```text +CausallyBefore(E1, E2) -> bool +``` + +它的含义是: + +> 当前证据足以证明 `E1` 一定发生在 `E2` 之前。 + +### 6.5 第 2 步:为每条路径生成状态版本 + +现在我们已经有: + +- 归一化执行节点 +- 保守因果偏序 + +下一步是把“路径被写过”变成“路径状态被定义”。 + +#### 6.5.1 初始状态 `P(path,0)` + +只要一条路径被读取,但在图内还没有任何先行定义,就为它引入: + +```text +P(path, 0) +``` + +它表示: + +> 构建开始前外部世界提供的基线状态。 + +没有这一步,源文件、系统头、已有 build-root 文件的读取都没有合法来源。 + +#### 6.5.2 普通写入产生新状态 + +如果执行节点 `E` 写了路径 `p`,则定义: + +```text +E -> P(p, next) +``` + +其中 `next` 是路径 `p` 的下一个局部编号。 + +#### 6.5.3 删除产生 tombstone + +如果执行节点删除了路径 `p`,则定义: + +```text +E -> P(p, next, tombstone=true) +``` + +删除不是特殊例外,而是一种正式状态。 + +#### 6.5.4 同一节点对同一路径多次底层写怎么办 + +在执行节点级 SSA 里: + +> 把它们折叠成该节点对该路径的一个最终定义。 + +因为我们分析的是执行节点级 trace,不是 syscall 级 SSA。 + +#### 6.5.5 版本号不是时间戳 + +再次强调: + +```text +P(path, 2) +``` + +里的 `2` 只说明: + +- 这是该路径的第三个局部定义身份 + +它不说明: + +- 它在全局时间上一定比别的路径的 `1` 晚 + +跨路径、跨图都不能直接拿版本号大小比较先后。 + +#### 6.5.6 版本生成伪代码 + +```text +CreateStateVersions(nodes): + nextVersion[path] = 0 + defsByNode = {} + for each node in nodes: + defs = [] + for each path in node.WritePaths: + nextVersion[path] += 1 + defs.append(P(path, nextVersion[path], tombstone=false)) + for each path in node.DeletePaths: + nextVersion[path] += 1 + defs.append(P(path, nextVersion[path], tombstone=true)) + defsByNode[node] = defs + return defsByNode +``` + +### 6.6 第 3 步:把每个读取绑定到 reaching-def + +这一步是 Trace SSA 的核心。 + +写入版本化只是第一半;真正重要的是: + +> 每一次读取,到底依赖哪一个路径状态版本。 + +#### 6.6.1 读取绑定问题 + +给定执行节点 `E` 读取路径 `p`,我们要计算: + +```text +ReachDef(p, E) +``` + +它是这样一个集合: + +- 定义的是同一路径 `p` +- 对 `E` 因果可达 +- 在所有可达定义中属于“最近的一层” + +#### 6.6.2 什么叫“最近” + +这里的“最近”不是 wall clock 最近,而是偏序意义上的 maximal defs: + +> 在所有先于 `E` 的路径定义中,选择那些不存在另一个更晚且仍然先于 `E` 的定义。 + +如果 maximal def 只有一个,则读取是确定的。 + +如果 maximal def 有多个且互相不可比较,则读取是 ambiguous。 + +#### 6.6.3 什么情况下会出现 ambiguity + +例如: + +- 两个并发命令都写了同一路径 +- 一个后续命令读取它 +- 但当前证据不足以决定读取实际看到哪个版本 + +这时不能“挑一个更像的”,必须保留整个候选集合。 + +#### 6.6.4 如果路径没有任何先行定义 + +那该读取绑定到: + +```text +P(path, 0) +``` + +也就是外部初始状态。 + +#### 6.6.5 读取绑定算法 + +对每个 `(E, p)`: + +1. 找出所有定义 `D`,满足: + - `D.Path == p` + - `Writer(D)` 因果上先于 `E` + +2. 在这些定义里取 maximal defs: + - 若 `D1` 先于 `D2`,则 `D1` 不是 maximal + +3. 如果 maximal 集为空: + - 使用 `P(path,0)` + +4. 如果 maximal 集只有一个: + - 读取确定绑定到该状态 + +5. 如果 maximal 集有多个: + - 记录 ambiguity + +#### 6.6.6 读取绑定伪代码 + +```text +BindRead(path, reader, allDefs, order): + candidates = [] + for each def in allDefs[path]: + if order.CausallyBefore(def.Writer, reader): + candidates.append(def) + + maximal = [] + for each d in candidates: + dominated = false + for each e in candidates: + if d != e and order.CausallyBefore(d.Writer, e.Writer): + dominated = true + break + if not dominated: + maximal.append(d) + + if maximal is empty: + return { InitialState(path) }, ambiguous=false + if size(maximal) == 1: + return maximal, ambiguous=false + return maximal, ambiguous=true +``` + +### 6.7 第 4 步:建立 def-use 与 use-def 索引 + +有了图还不够,后面传播分析至少还需要四类索引: + +1. `DefsByPath[path] -> PathState[]` +2. `UsersByState[state] -> ExecNode[]` +3. `DefsByExec[node] -> PathState[]` +4. `ReadsByExec[node] -> PathStateSet[]` + +后面的 `Flow / Need / Frontier` 全部建立在这些索引之上。 + +### 6.8 第 5 步:角色投影 + +核心图建好之后,才允许叠加旁路标签。 + +这一步的作用是隔离主线分析域与噪音子图。 + +#### 6.8.1 为什么角色不是核心图对象 + +因为角色分类是启发式,而 SSA 核心图必须是证据驱动的。 + +正确顺序是: + +1. 先建无语义核心图 +2. 再叠加 `tooling / probe / mainline / delivery` + +#### 6.8.2 角色投影至少需要哪些角色 + +第一版至少要有: + +1. `tooling` +2. `probe` +3. `mainline` +4. `delivery` + +#### 6.8.3 角色投影只负责两件事 + +1. 把 configure / probe / tooling 小岛从主线分析域中剥离 +2. 标出哪些路径属于最终允许汇聚的 merge surface + +到这里为止,我们只完成了: + +> 单个样本的 Path-SSA 构造。 + +--- + +## 7. 创新二:起火比较 + +现在开始第二项核心创新:起火比较。 + +起火比较的核心不是“两张图的结构差分”,而是: + +> 先找 option 的最小直接起火点,再区分直接变化和传播变化。 + +### 7.1 为什么不能直接做图差分 + +如果直接比较 baseline 图和 singleton 图: + +- 你会看到大量节点“看起来都不一样” +- 但你分不清哪些是 option 直接造成的 +- 哪些只是因为上游状态变了,被动重跑的 + +起火比较就是用来解决这个归因问题的。 + +### 7.2 第 6 步:baseline 与 singleton 的节点对应 + +要找 `MutationRoot`,必须先定义: + +> 两张图里的哪些执行节点是在“对应同一个构建位点”。 + +#### 7.2.1 为什么要先做节点对应 + +因为我们要区分: + +1. option 直接引入或直接改写的节点 +2. 只是被上游状态带着变化的节点 + +#### 7.2.2 两种键 + +建议为每个节点准备两种键。 + +##### `ExactKey` + +用于判断“这两个节点是不是完全一样”,应至少包含: + +- 规范化后的 `argv` +- 规范化后的 `cwd` +- 稳定写路径集合 +- 稳定读路径集合 +- 对 build-root 关键读取路径的 digest token + +##### `ShapeKey` + +用于判断“这两个节点是不是同一个构建位点,但内容可能不同”,应至少包含: + +- 规范化后的 `argv skeleton` +- 规范化后的 `cwd` +- 稳定写路径集合 + +#### 7.2.3 对应算法 + +推荐按两轮做: + +1. 先用 `ExactKey` 做精确匹配 +2. 再对剩余节点用 `ShapeKey` 做结构匹配 + +若 `ShapeKey` 下仍存在多对多歧义,不要强配,直接保守留下 unmatched。 + +#### 7.2.4 对应结果的三种类型 + +对 singleton 一侧节点,最终只落入三类: + +1. `ExactMatched` +2. `StructureMatched` +3. `Unmatched` + +含义分别是: + +- `ExactMatched`:可以视为未变 +- `StructureMatched`:像同一个位点,但可能发生了直接变化或传播变化 +- `Unmatched`:singleton 新增节点 + +### 7.3 第 7 步:提取 `MutationRoot` + +现在终于可以定义: + +> 哪些 singleton 节点是 option 的直接起火点。 + +#### 7.3.1 候选变化节点 + +候选变化节点包括: + +1. 所有 `Unmatched` 节点 +2. 所有 `StructureMatched` 且结构骨架发生直接变化的节点 + +这里的结构骨架变化至少包括: + +- `argv skeleton` 变化 +- `cwd` 变化 +- 稳定写路径集合变化 +- 外部稳定读路径集合变化 + +#### 7.3.2 哪些不算 root + +如果一个 `StructureMatched` 节点满足: + +- 结构骨架没变 +- 唯一变化只是它读取到的上游状态版本变了 + +那么它不是 `MutationRoot`,它只是传播路径上的被动节点。 + +#### 7.3.3 最小根过滤 + +候选变化节点还要再过一层“最小根过滤”。 + +如果候选节点 `E2` 的所有直接变化,都已经可以由另一个候选节点 `E1` 定义出的变化状态解释,并且 `E1` 在传播上先于 `E2`,那么 `E2` 不应再被提升为 root。 + +也就是说: + +> root 必须是最小直接起火点,而不是所有被点燃的节点。 + +### 7.4 第 8 步:提取 `SeedDef` + +`SeedDef(X)` 定义为: + +> `MutationRoot(X)` 直接定义出的、相对 baseline 真正变化的状态版本集合。 + +#### 7.4.1 什么叫“真正变化” + +一个路径不能仅因为: + +- 被重写过 +- 出现在 write 集里 +- 触发了下游重编 + +就进入 `SeedDef`。 + +它必须满足: + +> 相对 baseline,这个路径的内容或存在性真的发生了变化。 + +#### 7.4.2 build-root 路径必须优先用 digest 判定 + +规则是: + +1. digest 与 baseline 相同:不是真实变化 +2. digest 与 baseline 不同:是真实变化 +3. 路径被删除:tombstone 也是真实变化 + +绝不能把“动作重跑过”等价为“路径内容已变”。 + +### 7.5 第 9 步:提取 `Flow` + +`Flow(X)` 定义为: + +> 从 `SeedDef(X)` 出发,经由 `def -> use -> downstream def` 传播后,所有被影响到的状态版本集合。 + +传播有两种跳: + +1. `def -> use` +2. `use -> downstream def` + +`SeedDef` 是起火点,`Flow` 是火势范围,两者不能混。 + +### 7.6 第 10 步:提取 `Need` + +`Need(X)` 定义为: + +> `MutationRoot(X)` 及其传播闭包成立所需、但不由该闭包内部定义的外部状态集合。 + +它来自两类地方: + +1. 根节点的直接外部输入 +2. 下游受影响节点中新引入的外部输入 + +实现时必须遵守两条规则: + +1. 对有 baseline 对应位点的节点,只保留 singleton 相对 baseline 新出现的外部依赖 +2. 纯 delivery / install-only 节点的读取不能机械计入 `Need` + +### 7.7 第 11 步:提取 `Frontier` + +`Frontier(X)` 不是“所有下游混合节点”,而是: + +> `Flow(X)` 第一次与分析域内外部状态发生混合消费的最小边界执行节点集合。 + +先定义: + +1. `ReachState(X)`:从 `SeedDef(X)` 可达的全部状态 +2. `ReachExec(X)`:在同一传播闭包中可达的全部执行节点 +3. `TrackedExternal(X)`:不属于 `ReachState(X)`、但仍在分析域内的外部状态 + +若某执行节点: + +1. 读取至少一个来自 `ReachState(X)` 的状态 +2. 同时还读取至少一个来自 `TrackedExternal(X)` 的状态 + +则它属于 `MixedExec(X)`。 + +`Frontier(X)` 则是 `MixedExec(X)` 中的最小元集合。 + +--- + +## 8. 单边分析的完整结果 + +把第 7 章合起来,一个 singleton 的单边分析最终产出: + +```text +ImpactProfile { + MutationRoot + SeedDef + Need + Flow + Frontier + Ambiguous +} +``` + +到这里,第二项创新“起火比较”才算真正完成。 + +--- + +## 9. 双边 hazard 判定 + +现在才进入 pair 级别的判定。 + +### 9.1 跨图比较时比较什么 + +不能直接比较两个 singleton 图里的本地版本号。 + +跨图比较必须投影到: + +```text +StateClass = (StablePath, Tombstone) +``` + +必要时再结合 surface 信息。 + +### 9.2 第一类冒险:WAW + +如果: + +- `SeedDef(A)` 与 `SeedDef(B)` 都为同一路径产生了新状态 +- 且该路径不属于允许的 merge surface + +那么这是硬碰撞。 + +公式: + +```text +SeedClass(A) ∩ SeedClass(B) ≠ ∅ +且交集不在允许面上 +``` + +### 9.3 第二类冒险:RAW + +如果: + +- `A` 的传播进入 `B` 的前提 +或 +- `B` 的传播进入 `A` 的前提 + +则表示一边改写了另一边的起火条件。 + +公式: + +```text +FlowClass(A) ∩ NeedClass(B) ≠ ∅ +或 +FlowClass(B) ∩ NeedClass(A) ≠ ∅ +``` + +只要任一成立,就是硬碰撞。 + +### 9.4 第三类冒险:共享传播面 + +如果: + +```text +FlowClass(A) ∩ FlowClass(B) ≠ ∅ +``` + +不能立刻判死,必须继续区分: + +1. 交集只落在 merge surface 上 + 可以后移到后续层处理 + +2. 交集落在 merge surface 之外 + Stage 2 直接判硬碰撞 + +### 9.5 第四类冒险:不可消解歧义 + +如果任一侧存在 critical ambiguity,且它会影响: + +- `MutationRoot` +- `SeedDef` +- `Need / Flow` + +则必须保守回退。 + +### 9.6 Pair 输出 + +最终,Stage 2 至少给出: + +```text +PairAssessment { + Orthogonal + HardHazards[] + Ambiguous + SharedSurface[] +} +``` + +其中: + +- `Orthogonal = true` 表示允许继续进入后续层 +- `SharedSurface[]` 记录那些被允许后移处理的汇聚面 + +--- + +## 10. 回到最小例子 + +现在回到第 3 章的例子,重新走一遍。 + +### 10.1 对 option A + +`A` 的直接根更像是: + +- `protoc api.proto -> api.h, api.c` +- `cc api.c -> api.o` + +而: + +- `cc server.c -> server.o` + +如果它只是因为多读了 `api.h` 而重编,那么它更像传播节点,不是直接根。 + +于是: + +```text +SeedDef(A) ≈ { api.h, api.c, api.o } +Flow(A) ≈ { api.h, api.c, api.o, server.o, app } +Need(A) ≈ { api.proto } +``` + +### 10.2 对 option B + +`B` 的直接根是: + +- `cc -DLOG_ON utils.c -> utils.o` + +于是: + +```text +SeedDef(B) ≈ { utils.o } +Flow(B) ≈ { utils.o, app } +Need(B) ≈ { utils.c } +``` + +### 10.3 Pair 判定 + +先看 WAW: + +```text +Seed(A) ∩ Seed(B) = ∅ +``` + +再看 RAW: + +```text +Flow(A) ∩ Need(B) = ∅ +Flow(B) ∩ Need(A) = ∅ +``` + +再看共享传播: + +```text +Flow(A) ∩ Flow(B) = { app } +``` + +如果 `app` 属于 merge surface,那么 Stage 2 允许通过。 + +这就是整套模型在最小例子上的工作方式。 + +--- + +## 11. 创新三:重放合并 + +现在回到第 2 章提到的第三项创新。 + +前两项创新解决的是: + +- 图怎么建 +- 起火怎么比 +- hazard 怎么判 + +但它们还没有回答一个更难的问题: + +> 如果两个 option 共享同一个上游 replay root,这种共享传播究竟应该判死,还是应该提升到 root 层吸收。 + +这就是重放合并的职责。 + +### 11.1 为什么重放合并是独立创新 + +如果没有重放合并,系统往往只能在两个极端之间摇摆: + +1. 过早判死 + 只要看到共享根传播,就一律当硬碰撞 + +2. 过度乐观 + 只要最终输出能 merge,就忽略共享上游根 + +这两个极端都不对。 + +重放合并提供的是第三条路: + +> 把冲突提升到 replay root 这一层处理,而不是在中间传播面直接拍死,或在最终产物面强行掩盖。 + +### 11.2 重放合并到底做什么 + +重放合并不是简单的文件 merge,而是: + +1. 找到最近、清晰、可参数化的 replay root +2. 合并左右 option 在 root 上的参数差异 +3. 只重放最小必要子图 +4. 用重放结果吸收那部分共享传播 + +### 11.3 它和前两项创新怎么衔接 + +三者的关系是: + +- `Trace SSA` 提供状态化图模型 +- `起火比较` 提供 `MutationRoot / SeedDef / Need / Flow / Frontier` +- `重放合并` 只消费其中一部分边界信息: + - 哪些共享传播来自同一个 replay root + - 哪些 frontier 是混合且可最小重放的 + +### 11.4 哪些共享传播才值得进入重放合并 + +至少应满足: + +1. replay root 清晰且唯一 +2. 参数差异落在可合并域 +3. 共享传播集中在有限 frontier +4. 不是整库级共享核心对象脏化 + +换句话说: + +> 重放合并不是“补救一切冲突”,而是“吸收那类本质上发生在 replay root 参数层的共享传播”。 + +### 11.5 为什么总览里必须提它 + +因为如果总览里不把它立成单独章节,读者会误以为 evaluator 只有: + +- Trace SSA +- hazard 判定 + +然后自然得出一个错误结论: + +> 只要图上共享传播,就只能硬判碰撞。 + +而这恰恰漏掉了 evaluator 的第三项创新。 + +--- + +## 12. 这份模型明确不做什么 + +好的教材稿不仅要告诉实现者做什么,也要告诉他不要做什么。 + +### 12.1 不做目录集合语义 + +如果 trace 没有稳定目录枚举证据: + +- 不要发明 `D(dir)` 之类目录集合状态 +- 不要自动把读 `dir/x` 解释成读整个目录 + +否则会制造伪精确。 + +### 12.2 不做动作语义分类 + +不要让 SSA 核心图依赖: + +- compile +- link +- install +- configure + +这类内建动作类型。 + +需要角色标签时,在图外叠加。 + +### 12.3 不把“动作重跑”当“路径已变” + +一条路径只有在: + +- 内容真的变了 +或 +- 存在性真的变了 + +时,才能进入 `SeedDef`。 + +仅仅因为命令重跑过,绝不能推出“路径一定变化”。 + +### 12.4 不把重放合并提前塞进单图构造 + +Trace SSA 和起火比较必须先独立成立。 + +不能在: + +- 路径版本化 +- reaching-def +- `MutationRoot / SeedDef / Need / Flow / Frontier` + +这些基础定义里,提前混入 replay 规则。 + +因为那会把基础分析层重新污染成策略层。 + +--- + +## 13. 推荐实现顺序 + +如果有人要按本文从零实现,推荐严格按下面顺序来: + +1. 先实现 stable path 归一化 +2. 再实现单样本 Path-SSA 构图 +3. 再实现 reaching-def 与 ambiguity +4. 再实现 baseline/singleton 节点对应 +5. 再实现 `MutationRoot` +6. 再实现 `SeedDef / Flow / Need / Frontier` +7. 再实现 pair hazard +8. 最后再实现重放合并 + +不要一开始就直接写 pair 判定,更不要一开始就写 replay。 + +因为: + +- pair 判定只是单边分析之上的薄壳 +- replay 又只是 hazard 之上的更高层吸收逻辑 + +--- + +## 14. 最后一段伪代码:完整单边分析与双边判定 + +把整条主线压缩成伪代码,就是: + +```text +AnalyzeSingleton(baseSample, probeSample): + baseObs = NormalizeTrace(baseSample.Trace, baseSample.Scope) + probeObs = NormalizeTrace(probeSample.Trace, probeSample.Scope) + + baseSSA = BuildPathSSA(baseObs) + probeSSA = BuildPathSSA(probeObs) + + baseSSA = ProjectRoles(baseSSA) + probeSSA = ProjectRoles(probeSSA) + + pairs = MatchNodes(baseSSA.ExecNodes, probeSSA.ExecNodes) + + roots = ExtractMutationRoots(baseSSA, probeSSA, pairs) + seed = ExtractSeedDefs(baseSSA, probeSSA, roots, probeSample.InputDigests) + flow = ComputeFlow(probeSSA, seed) + need = ComputeNeed(baseSSA, probeSSA, pairs, roots, flow) + front = ComputeFrontier(probeSSA, flow) + + return { + MutationRoot = roots, + SeedDef = seed, + Flow = flow, + Need = need, + Frontier = front + } +``` + +双边判定则只是: + +```text +AssessPair(profileA, profileB, mergeSurface): + if WAW(profileA.SeedDef, profileB.SeedDef, mergeSurface): + return HardCollision + if RAW(profileA.Flow, profileB.Need): + return HardCollision + if RAW(profileB.Flow, profileA.Need): + return HardCollision + if SharedFlowOutsideSurface(profileA.Flow, profileB.Flow, mergeSurface): + return HardCollision + if profileA.Ambiguous or profileB.Ambiguous: + return Ambiguous + return Orthogonal +``` + +而重放合并则建立在它之后: + +```text +ReplayMerge(pairAssessment, profiles, replayCandidates): + if pairAssessment is HardCollision and not root-share-absorbable: + return Reject + root = SelectReplayRoot(replayCandidates) + mergedParams = MergeRootParams(root.left, root.right) + frontier = SelectMinimalMixedFrontier(root) + return Replay(root, mergedParams, frontier) +``` + +--- + +## 15. 一句话总结 + +> LLAR evaluator 的核心不是“看两组命令差了什么”,而是先用 Trace SSA 把黑盒构建提升成路径状态传播图,再用起火比较把直接变化和传播变化剥开,最后把一部分共享 replay root 的冲突交给重放合并吸收。只有把这三项创新同时立住,整套算法才成立。 diff --git a/doc/llar-evaluator-refactor-plan.md b/doc/llar-evaluator-refactor-plan.md new file mode 100644 index 0000000..0aa7a52 --- /dev/null +++ b/doc/llar-evaluator-refactor-plan.md @@ -0,0 +1,488 @@ +# LLAR Evaluator 重构方案:以 Trace SSA 为中心的彻底收口 + +## 1. 目标 + +这份方案解决的不是“再修几个 case”,而是把当前 `internal/evaluator/` 从反复叠补丁后的混层状态,收口成一套可以继续演进的分层架构。 + +推荐目标只有一个: + +> **把 Trace SSA 明确成 Stage 2 的唯一核心 IR,把 planner / synthesis / validator 从当前混层里彻底剥离。** + +这不是抽象洁癖,而是为了停止以下几类反复返工: + +- Stage 2 规则散落在 `evaluator.go`、`impact.go`、`graph.go`、`debug.go` +- Stage 3 的 direct merge / root replay 和 planner 决策缠在一起 +- `Watch` 与 `WatchWithOptions` 走出两套不同的证据链 +- debug helper 混进生产主路径,导致职责边界持续漂移 + +--- + +## 2. 当前问题,不靠感觉,先看代码分布 + +当前 `internal/evaluator/` 的大文件已经直接说明混层问题: + +- `evaluator.go`: 1158 行 + 同时承担 public facade、singleton 采样、碰撞判定、component planner、Stage 3 hook 编排。 + +- `replay.go`: 1045 行 + 同时承担 replay root 扫描、argv/env 参数合并、工作区 clone、build-root 准备、命令执行。 + +- `debug.go`: 857 行 + 里面不仅有调试输出,还有生产路径依赖的 `matchActionFingerprints`。 + +- `impact.go`: 802 行 + 同时承担 baseline/probe 对齐、mutation root 分类、Path-SSA 构建、传播分析、`Need/FS` 提取。 + +- `graph_roles.go`: 708 行 + 角色启发式单独很大,但它的输入和输出边界没有被 Trace SSA 核心层明确隔开。 + +- `graph.go`: 605 行 + 同时承担 raw graph 构建、action kind 分类、fingerprint/structure key 生成、路径噪音策略。 + +- `graph_input.go`: 487 行 + 负责事件折叠与 record 归一化,但当前没有被正式定义成“Stage 2 观测层”。 + +这几个文件的共同问题不是“代码长”,而是: + +1. 核心 IR 没有独立所有权 + `Path-SSA` 逻辑在 `impact.go`,matching 在 `debug.go`,graph identity 在 `graph.go`,planner 却又直接吃这些内部结构。 + +2. planner 和分析器互相穿透 + `evaluator.go` 既知道 Stage 2 profile 细节,也知道 Stage 3 synthesis 细节,还直接决定 pair/component 组合。 + +3. 生产逻辑和调试逻辑没有硬边界 + 例如 `matchActionFingerprints` 在 `debug.go`,但它是 `analyzeImpactWithEvidence` 的正式依赖。 + +4. Stage 3 没有内部再分层 + replay root 识别、参数合并、工作目录 materialize、执行策略全挤在 `replay.go`。 + +--- + +## 3. 重构原则 + +这次重构建议遵守四条硬原则: + +### 3.1 保留一个 facade,拆内部,不先拆 API + +对外仍保留: + +- `Watch` +- `WatchWithOptions` + +第一阶段不先改外部调用面,而是先把内部职责切开。 +否则会先把调用层打散,再在一堆移动的接口上继续加补丁。 + +### 3.2 Trace SSA 是 Stage 2 的唯一正式 IR + +Stage 2 不再允许同时存在: + +- 一套“graph/path 集合”主逻辑 +- 一套“SSA”补充逻辑 + +应改成: + +- 观测归一化 -> Path-SSA -> 角色投影 -> impact/hazard 摘要 -> planner 消费摘要 + +planner 不再直接理解 Path-SSA 内部结构。 + +### 3.3 Stage 3 拆成 merge、replay-plan、replay-exec 三段 + +当前 `replay.go` 最大的问题不是功能多,而是没有中间边界。 +重构后必须拆成: + +- root 识别与对齐 +- 参数域合并 +- replay 工作区 materialize 与执行 + +这样才能分别测试,也才能让 planner 只依赖 synthesis 结果,而不是依赖 replay 内部启发式。 + +### 3.4 debug/report 只能做旁路,不得承载生产算法 + +调试输出和 explain/report 可以依赖生产数据结构。 +生产路径不能反过来依赖 debug 文件里的实现。 + +--- + +## 4. 目标架构 + +```mermaid +flowchart TD + F["evaluator facade
Watch / WatchWithOptions"] --> P["planner"] + P --> O["observation"] + O --> S["trace ssa core"] + S --> R["role projection"] + R --> A["stage2 analyzer"] + A --> P + P --> M["manifest / direct merge"] + P --> RP["root replay planner"] + RP --> RX["root replay executor"] + M --> P + RX --> P + P --> V["validator / coverage hooks"] + A --> D["debug / report"] + M --> D + RP --> D +``` + +这张图要表达三个硬边界: + +1. planner 只消费摘要结果 + 不直接操作 SSA 细节,不直接执行 merge/replay。 + +2. Stage 2 只负责构建正交证明 + 不碰 output tree,不碰 validator。 + +3. synthesis 是独立子系统 + direct merge 和 root replay 都是 Stage 3 的实现路径,不是 planner 的内嵌细节。 + +--- + +## 5. 目标模块边界 + +### 5.1 `facade` + +保留在 `internal/evaluator` 根包: + +- `Watch` +- `WatchWithOptions` +- `ProbeResult` +- `WatchOptions` + +职责: + +- 组织入口参数 +- 调用 planner +- 维持对外兼容 API + +不再承担: + +- Stage 2 profile 计算 +- pair 碰撞算法 +- synthesis 细节编排 + +### 5.2 `observation` + +来源: + +- 当前 `graph_input.go` +- 当前 `graph.go` 里的路径 canonicalization、scope token 归一化、原始观测建图入口 + +职责: + +- 把 `Trace/Events` 归一化成稳定执行观测 +- 保留 `argv/cwd/env/inputs/changes/parent` 等黑盒证据 +- 提供 build-root digest 证据入口 + +不负责: + +- `tooling/probe/mainline` 判定 +- hazard 分析 +- planner 决策 + +### 5.3 `ssa` + +来源: + +- 当前 `impact.go` 的 `pathSSA`、`pathSSADef`、`pathSSARead` +- 当前 `impact.go` 的 `causalOrder` +- 当前 `impact.go` 的 `reachingDefsForRead` + +职责: + +- Path-SSA 图对象 +- 路径状态版本 +- tombstone +- 保守因果偏序 +- reaching-def / def-use / use-def + +不负责: + +- option mutation root 识别 +- `Need/FS` 业务摘要 +- planner / merge / replay + +### 5.4 `role` + +来源: + +- 当前 `graph_roles.go` +- 当前 `graph.go` / `evaluator.go` 里零散的 `probe/tooling/delivery` 判定 + +职责: + +- 在 SSA 图或其执行节点视图上叠加旁路角色标签 +- 生成主线投影与允许面标签 + +不负责: + +- 修改 SSA 核心对象定义 +- 直接决定 pair 是否可跳过 + +### 5.5 `stage2` + +来源: + +- 当前 `impact.go` +- 当前 `evaluator.go` 的 `optionProfile` / `profilesCollide` +- 当前 `debug.go` 里实为生产逻辑的 matching 代码 + +职责: + +- baseline/probe 对齐 +- mutation root 识别 +- `SeedDef/Need/Flow/Frontier` 提取 +- RAW / WAW / merge-surface hazard 判定 +- 输出统一的 `ImpactProfile` + +不负责: + +- 物化输出目录 +- root replay +- 最终执行矩阵展开 + +### 5.6 `synth` + +来源: + +- 当前 `manifest.go` +- 当前 `merge.go` +- 当前 `synthesis.go` +- 当前 `replay.go` + +职责: + +- output manifest diff / materialization +- direct merge +- root replay plan +- root replay exec +- 统一输出 `SynthesisResult` + +内部再拆成三个子模块: + +1. `synth/merge` +2. `synth/replayplan` +3. `synth/replayexec` + +### 5.7 `planner` + +来源: + +- 当前 `evaluator.go` 的 component 生成、pair 连接、`validatedCollisionComponents` + +职责: + +- 调度 baseline / singleton probe +- 汇总 Stage 2 / Stage 3 / Stage 4 结果 +- 构造最终需要物理执行的 combos + +不负责: + +- 自己实现 Stage 2 算法 +- 自己实现 merge/replay + +### 5.8 `report` + +来源: + +- 当前 `debug.go` +- 当前 `report.go` + +职责: + +- explain +- trace / graph / synthesis 调试输出 +- human-readable 诊断 + +要求: + +- 不得再被生产路径反向依赖 + +--- + +## 6. 当前文件到目标模块的映射 + +| 当前文件 | 当前问题 | 目标归属 | +| --- | --- | --- | +| `evaluator.go` | facade、planner、Stage 2/3 编排混在一起 | `facade` + `planner` | +| `graph_input.go` | 已承担观测归一化,但没有正式层级 | `observation` | +| `graph.go` | raw graph、identity、噪音策略混在一起 | `observation` + 少量 `stage2` 辅助 | +| `graph_roles.go` | 角色层独立但未被正式定义 | `role` | +| `impact.go` | SSA core 与 impact summary 混在一起 | `ssa` + `stage2` | +| `debug.go` | debug 与生产 helper 混在一起 | `report` + 抽走生产逻辑 | +| `manifest.go` | manifest 逻辑相对纯 | `synth/merge` | +| `merge.go` | direct merge 相对纯,但与 planner 边界需拉直 | `synth/merge` | +| `synthesis.go` | orchestration 过薄但决策顺序错误风险高 | `synth` | +| `replay.go` | replay plan / exec / workspace 操作全耦合 | `synth/replayplan` + `synth/replayexec` | + +--- + +## 7. 推荐迁移顺序 + +推荐走 **两阶段、七步**,而不是一次性大搬家。 + +### 阶段一:先切职责,不切 import 面 + +#### 第 1 步:冻结语义边界 + +先补测试,不先搬代码。至少锁住: + +- sqlite `json1` digest no-op 这类 Stage 2 证据规则 +- Stage 2 hard collision 与 Stage 3 synthesis 的边界 +- replay 准入和失败回退规则 +- plain `Watch` 与 `WatchWithOptions` 的预期差异 + +目标不是让老代码变漂亮,而是防止重构期间语义继续漂。 + +#### 第 2 步:把 `evaluator.go` 缩成 facade + planner + +先迁出: + +- `optionProfile` +- pair/component 构图 +- `validatedCollisionComponents` +- `sampleUnit` 相关展开逻辑 + +完成标准: + +- `evaluator.go` 只剩入口 API、参数组装和极少量 glue + +#### 第 3 步:抽出 `observation` + +把: + +- `buildObservationFromProbe` +- event 折叠 +- record normalizer +- scope/path canonicalization + +统一收进观测层。 +这一步做完后,Stage 2 不再直接面对原始 `Trace/Event`。 + +#### 第 4 步:抽出 `ssa` + +把: + +- `pathSSA` +- `causalOrder` +- `reachingDefsForRead` +- def-use helpers + +从 `impact.go` 移到独立核心层。 +`stage2` 从此只消费 SSA API,不再自己拼内部结构。 + +### 阶段二:再切 planner / synthesis 的硬边界 + +#### 第 5 步:重写 `stage2` + +在 `ssa + role` 之上重建: + +- mutation root 识别 +- `SeedDef/Need/Flow/Frontier` +- hazard assessment +- `ImpactProfile` + +同时把 `debug.go` 里的生产 helper 挪走。 +完成后,planner 只认识 `ImpactProfile`,不认识底层 graph/SSA 细节。 + +#### 第 6 步:拆 `synth` + +按下面顺序拆: + +1. 先把 `manifest + direct merge` 独立出来 +2. 再把 replay root 扫描和 argv/env merge 拆成 `replayplan` +3. 最后把 clone/build-root/exec 拆成 `replayexec` + +这一步必须同时修正文档与实现边界: + +- Stage 2 hard collision 是否允许被 softening,取决于 `root replay` +- direct merge 不能再越权修改 Stage 2 语义 +- replay 候选规划和 replay 执行结果要分开表达 + +#### 第 7 步:统一 planner 证据链 + +最终 planner 只允许有一套主流程: + +```text +baseline + singletons +-> Stage 2 summary +-> Stage 3 synthesis certification +-> Stage 4 validator +-> execution graph +``` + +`Watch` 可以保留简化策略,但不能再绕开这条正式证据链,至少不能在 pair 判定上走完全不同的语义。 + +--- + +## 8. 这次重构里最应该删掉的东西 + +不是所有代码都值得迁移。有三类东西应该主动删除,而不是原样搬运: + +### 8.1 debug 文件里的生产 helper + +例如: + +- `matchActionFingerprints` + +这类函数必须迁回 `stage2` 或 `observation`。 +`debug.go` 只保留 explain 和 dump。 + +### 8.2 planner 对底层 graph 细节的直连 + +planner 不应再直接碰: + +- raw graph indexes +- path writer/reader sets +- role 判定细节 + +否则拆了文件也还是同一个泥团。 + +### 8.3 `replay.go` 里的大一统过程函数 + +像“扫描 root -> 合并参数 -> clone 目录 -> 准备 build root -> 执行命令 -> 汇总结果”这种大流程,必须拆成多个可测步骤。 +否则 replay 还会继续成为新的补丁黑洞。 + +--- + +## 9. 完成标准 + +这次重构完成,不看“是否换了很多文件”,只看下面几个标准: + +1. `evaluator.go` 收缩成薄 facade + 不再承载 Stage 2/3 主算法。 + +2. Stage 2 有唯一正式 IR + 即 Trace SSA,而不是 graph/path set/SSA 三套并存。 + +3. planner 只消费摘要 + 不再理解 SSA 内部细节,不再操作 output tree。 + +4. debug/report 不再承载生产算法 + 生产路径从依赖上与 `debug.go` 断开。 + +5. replay plan 与 replay exec 分离 + 允许单测只验证 replay 规划,不必真的跑命令。 + +6. `Watch` 与 `WatchWithOptions` 共享同一条正式证据链 + 差异只能体现在策略钩子和认证级别上,不能体现在核心语义上。 + +--- + +## 10. 推荐落地方式 + +推荐先做: + +1. 文档补齐 + 先把 Trace SSA 在总设计里的位置写死。 + +2. 测试补齐 + 先把要保留的语义钉住。 + +3. 语义拆分 + 先在同一个 Go package 内按模块拆文件。 + +4. 再评估是否升成子包 + 只有当边界稳定后,再把 `ssa`、`stage2`、`synth` 等提升成子包。 + +这是更稳的路线。 +如果一开始就跨包大搬家,当前这些混层会直接变成跨包耦合,后面更难收。 diff --git a/doc/llar-matrix-reduction-design.md b/doc/llar-matrix-reduction-design.md new file mode 100644 index 0000000..f563ddd --- /dev/null +++ b/doc/llar-matrix-reduction-design.md @@ -0,0 +1,1068 @@ +# LLAR 矩阵降维设计方案:以正交证明、产物合并与测试覆盖为证据链 + +## 1. 设计目标 + +LLAR 要解决的不是“把所有 options 组合都跑完”,而是在黑盒条件下,**保守地减少必须物理执行的组合数**。 + +本文只讨论最核心的一步:当两个选项 `A` 和 `B` 同时出现时,系统在什么条件下可以安全跳过真实的 `A+B` 组合构建。 + +本方案不依赖源码语义分析,不尝试静态理解 ABI,也不假设特定语言工具链。系统只使用三类可观测证据: + +1. 构建过程证据:trace 形成的稳定路径依赖图,以及基于它提取的 `M(X) / Need(X) / FS(X)` 单边影响摘要 +2. 产物证据:输出目录 manifest 与三方合并结果 +3. 语义证据:用户为 `A+B` 组合显式编写并声明覆盖的测试 + +--- + +## 2. 核心结论 + +如果要跳过真实的 `Build(A+B)`,必须同时满足下面三个条件: + +1. `A` 与 `B` 在构建过程中被证明正交 +2. `A` 与 `B` 的产物相对 baseline 可以被自动合并并物化 +3. 用户测试明确覆盖 `A+B` 组合,并且该测试在合并产物上通过 + +换句话说,系统最终放行的不是“图上看起来没撞”,而是下面这条完整证据链: + +```text +单项构建可观测 + -> 构建过程正交 + -> 产物三方合并可自动完成 + -> A+B 组合测试在合并产物上通过 + -> 才允许跳过真实的 A+B 组合构建 +``` + +这三层证据缺一不可: + +- 只有图上正交,不足以证明最终产物可组合 +- 只有产物能 merge,不足以证明组合语义正确 +- 只有用户测试,不足以证明可以跳过真实构建过程 + +--- + +## 3. 基本记号 + +对同一个 baseline 环境,定义四个构建结果: + +- `O0 = Build(Ø)`:默认选项或 baseline 组合 +- `OA = Build(A)`:只打开选项 `A` +- `OB = Build(B)`:只打开选项 `B` +- `OAB = Build(A+B)`:同时打开 `A` 和 `B` + +系统真正想验证的是下面这个理想关系: + +```text +如果 A 与 B 在构建过程上正交, +那么理想的组合产物应满足: + +OAB == Merge(O0, OA, OB) +``` + +这里的 `Merge(O0, OA, OB)` 不是抽象概念,而是对应 `internal/evaluator/merge.go` 的三方合并语义: + +- `O0` 是 base +- `OA` 是 left +- `OB` 是 right + +如果三方合并无法自动物化 merged result,系统就不能声称 `A+B` 可以被安全跳过。 + +--- + +## 4. 总体流程 + +```mermaid +flowchart TD + S1["Stage 1
构建 O0 / OA / OB"] --> S2{"Stage 2
构建过程正交?"} + S2 -- 否 --> R1["必须真实执行 A+B"] + S2 -- 是 --> S3{"Stage 3
Synthesize(O0, OA, OB) feasible?"} + S3 -- 否 --> R1 + S3 -- 是 --> S4{"Stage 4
用户测试覆盖 A+B 并在合并产物上通过?"} + S4 -- 否 --> R1 + S4 -- 是 --> R2["允许跳过真实 A+B 构建"] +``` + +这个流程的关键点是: + +- Stage 2 解决“构建过程有没有耦合” +- Stage 3 解决“最终产物能不能被直接合成,或被受限 replay 合成” +- Stage 4 解决“组合语义有没有被用户真正测试” + +--- + +## 5. Stage 1:单项构建采样 + +### 5.1 目标 + +对每个候选选项,先拿到它相对于 baseline 的单变量变化,而不是直接跑大笛卡尔积。 + +### 5.2 输入与输出 + +输入: + +- baseline 组合 `Ø` +- 单项组合 `A` +- 单项组合 `B` + +输出: + +- `O0 / OA / OB` 的 trace 记录 +- 基于 trace 构建的 action graph +- 输出目录的 `OutputManifest` +- 相对 baseline 的 `output diff` + +### 5.3 设计理由 + +矩阵降维的最小可信单位不是“猜测哪个选项可能独立”,而是“先把每个单项选项在真实构建里的影响量采出来”。没有这一步,后面的正交分析和产物合并都没有证据基础。 + +--- + +## 6. Stage 2:构建过程正交证明 + +Stage 2 只回答一个问题: + +> `A` 和 `B` 在构建链路里是不是互相干扰? + +这里只看构建过程,不看最终测试语义。 + +### 6.1 核心思路:单边影响分析,而不是双边差分图对比 + +Stage 2 不再把问题建模成: + +```text +先得到 Δ(A) 图 和 Δ(B) 图 +再去做两张差分图的结构对比 +``` + +这条路会自然滑向节点对齐、锚点、错序、语义归属等复杂问题。 + +本方案改用更保守的单边分析模型: + +1. 每个 option 先各自相对 baseline 做影响分析 +2. 分别提取它的直接变更源与前向波及区 +3. 最后只判断两边是否存在依赖干涉,以及它们的交集是否只落在允许合并的汇聚面 + +也就是说,Stage 2 的核心不是“图对图匹配”,而是: + +```text +单边影响分析 + -> 双边干涉判定 + -> 仅允许在 merge surface 上汇聚 +``` + +### 6.2 基础抽象 + +对任意单项 option `X`,系统只定义四个对象: + +- `M(X)`:直接变更源(mutation sources) +- `SeedW(X)`:由 `M(X)` 直接写出的稳定路径集合 +- `Need(X)`:`M(X)` 为了成立而读取的稳定前提路径集合 +- `FS(X)`:从 `SeedW(X)` 出发,在稳定路径依赖图上的前向波及闭包(forward slice) + +这里要强调两点: + +1. `M(X)` 不是“所有变了的记录”,而是最小的直接起火点 +2. `FS(X)` 才负责覆盖那些“命令本身没改,但因为上游输入变化而被带着重跑”的节点和路径 + +因此,Stage 2 要回答的不是: + +```text +A 和 B 的差分图怎么精确配准? +``` + +而是: + +```text +A 从哪里开始产生直接变更? +A 的影响沿依赖传播到了哪里? +这些影响是否在中途干涉了 B 的直接变更前提? +如果两边最终相遇,相遇点是否属于允许交给 Stage 3 的 merge surface? +``` + +### 6.3 稳定路径依赖图 + +Stage 2 的坐标系是稳定路径,而不是动作语义类型。 + +系统只使用 trace 中原始可观测的: + +- `Inputs` +- `Changes` +- `Cwd` +- `Argv` + +来构造稳定路径依赖图: + +- 节点不按 compile、link、archive 之类的语义分类 +- 每条 trace record 只被视为一个普通的读写 tuple +- 边由“某条稳定路径被写出后,又被后续记录读取”来定义 + +这里的“稳定路径”是: + +- 保留工作区、构建目录、最终输出中的持久路径 +- 过滤纯 tmp 噪音和只造成观测抖动的瞬时路径 +- 不因为并发时序变化而改变身份 + +因此,Stage 2 仍然是图分析,但它分析的是: + +```text +路径依赖图 +``` + +而不是: + +```text +动作语义图 +``` + +这里还需要补一条此前文档里缺失、但实现和重构都必须对齐的约束: + +> **Stage 2 的证明对象仍然是稳定路径,但它在工程实现上的正式核心 IR 应是 Trace SSA V5,而不是直接在坍缩 action graph 上做路径集合启发式。** + +也就是说: + +- 从证明语义上,Stage 2 依然围绕稳定路径、`M/SeedW/Need/FS` 和 hazard 判定展开 +- 从内部表示上,应该先把 trace 归一化成执行节点 `E` 与路径状态版本 `P(path, n)` 组成的 Path-SSA +- `Need/FS/frontier/hazard` 都应从这张 SSA 图上导出,而不是作为独立的 ad-hoc BFS 逻辑散落在 planner 里 + +`doc/llar-trace-ssa-v5-design.md` 定义的是这层正式 IR;本文定义的是它在矩阵降维总证据链里的职责边界。 + +### 6.3.1 Trace SSA 在 Stage 2 中的位置 + +Stage 2 的正确实现流水线应该是: + +```mermaid +flowchart TD + O0["ProbeResult (baseline / singleton)"] --> N0["观测归一化"] + OX["Trace / Events / Digests"] --> N0 + N0 --> SSA["Path-SSA 核心图
E / P(path,n) / reaching-def"] + SSA --> ROLE["角色投影
tooling / probe / mainline / delivery"] + ROLE --> IMPACT["单边影响分析
M / SeedW / Need / FS / Frontier"] + IMPACT --> HAZARD["双边 hazard 判定
RAW / WAW / merge-surface"] +``` + +这条流水线里,四层职责不能混: + +1. 观测归一化层 + 只负责把 `Trace/Events` 变成稳定的执行节点观测,做路径 canonicalization、scope token 归一化、父子进程折叠。 + +2. Path-SSA 核心层 + 只负责构造 `E -> P -> E` 的路径状态版本图、reaching-def、tombstone 与保守因果偏序。 + +3. 角色投影层 + 只负责在 SSA 图上叠加 `tooling/probe/mainline/delivery` 等旁路标签;标签不是核心图对象本体。 + +4. Impact / Hazard 层 + 只负责把 baseline 与 singleton 的 SSA 差异压成 `M/SeedW/Need/FS/Frontier`,并据此做双边干涉判定。 + +### 6.3.2 本文与 Trace SSA 文档的关系 + +两份文档的分工应明确写死: + +- `llar-matrix-reduction-design.md` 负责定义 Stage 1/2/3/4 的总证据链,以及 planner 在什么条件下允许跳过真实 `A+B` +- `llar-trace-ssa-v5-design.md` 负责定义 Stage 2 的正式内部模型、核心对象与数据流算法 + +因此: + +- Stage 2 的算法、IR 和边界,以 Trace SSA 文档为准 +- 跳过组合所需的总放行条件,以本文为准 +- Stage 3/4 不能回写成 Stage 2 的核心图语义;它们只能消费 Stage 2 的摘要结果 + +换句话说,Trace SSA 不是“另一个平行方案”,而是本文 Stage 2 的正式实现模型。 + +### 6.4 如何提取 `M(X)` 与 `FS(X)` + +对 `OX` 相对 `O0` 的单边分析,系统按下面的顺序工作。 + +#### 6.4.1 先找直接变更源 `M(X)` + +把 `OX` 与 `O0` 比较时,系统首先要区分两类变化: + +1. option 直接引入的新记录或直接改写过的记录 +2. 因为上游路径变了,被动重跑的记录 + +只有第 1 类进入 `M(X)`。 + +文档上的定义是: + +> 如果一条记录在 `OX` 中是新增的,或者它自身的结构骨架已经变化,并且这种变化不能仅由某条已经受影响的上游路径解释,那么它属于 `M(X)`。 + +这里的“结构骨架”只允许依赖黑盒可观测项,例如: + +- 归一化后的 `Argv` +- `Cwd` +- 稳定读路径集合 +- 稳定写路径集合 + +本方案不要求系统识别“这是不是编译”“这是不是链接”,也不引入 compile/link/archive 语义分类器。 + +#### 6.4.2 再取 `SeedW(X)` 与 `Need(X)` + +一旦 `M(X)` 确定下来,就投影出两类路径: + +- `SeedW(X)`:这些直接变更源写出的稳定路径 +- `Need(X)`:这些直接变更源依赖的稳定前提路径,以及从 `SeedW(X)` 向前传播时、由下游受影响动作新引入的外部稳定依赖 + +这一步的意义是把“记录层的直接变更”压成“路径层的可比较对象”。 + +这里有两个实现约束需要写死: + +- `SeedW/Need/FS` 一律存成 scope-canonical 路径键(例如 `$BUILD/...`、`$INSTALL/...`),不能直接保留每个 combo 私有的临时根路径。 +- “被重写但内容与 base 相同”的路径不能进入 `SeedW(X)`;否则像 `trace_options.h` 这类由 configure 反复改写、但内容未变的 build-root 文件会制造伪碰撞。 +- 对 build-root 中间产物,不能再把“动作重跑过”当成“内容一定变了”。Stage 2 必须优先使用 trace 观测到的 build-root 路径 digest 证据(`Inputs + Changes` 的并集)来判断 `_build/*` 路径是否真的变化;否则像 `expat_config.h` 变化触发重编、但 `xmlparse.c.o` / `libexpat.a` 内容实际未变的 case,会被错误传播成整库硬碰撞。 +- `Need(X)` 不能机械地把所有下游 reader 的输入都收进来;对于与 base 有同一路径输出基线的下游动作,只应保留“相对 base 新出现的外部依赖”,否则像最终链接动作里本来就存在的兄弟输入会制造伪干涉。 +- `Need(X)` 里的“下游受影响动作”只限非 delivery / install-only reader;安装复制这类动作会重新读共享库、头文件和 install 控制文件,但它们不构成上游 feature 的真实组合前提,不能反向污染 `Need(X)`。 +- configure / probe 子图里的自检噪音不进入 impact 域;判定依据不是固定工具或固定目录名,而是“两重 tooling 识别”: + - 第一重先标出显式 tooling / configure action。 + - 第二重再从这些 action 出发,提升由其派生出来、读集封闭在 control-plane / probe-island 内、且不依赖仓库内真实源码输入的 probe 子动作。这里不能只看“是否直接被 tooling 启动”,还要接受一种更弱但必要的证据:如果某个 `gmake/cc/ld` generic 动作的 `cwd` 已经落在 probe/control-plane 子树里,且它的读写没有逃逸到主线,那么它也属于 probe island,必须整体吸进 tooling;否则像 CMake `try_compile` 这种隔着一层 generic wrapper 的自检小岛会漏回 impact 域。 + 只有当一条路径的所有 writer / reader 都落在这类 probe / tooling 子图里,且没有非 tooling 主线消费时,它才会被视为噪音;这类路径既不能作为 `SeedW(X)`,也不能沿 `FS(X)` 传播,否则会把原本只共享 configure root 的宏开关误连成一个全互撞分量。 + +这一条是后面真实 case 校正出来的,不是抽象上的洁癖。 + +### 6.4.2.1 一个真实误判:`api + cli` + +`traceoptions` 这个真实样本里: + +- `api-on` 会改 `trace_options.h` +- `trace_options.h` 会波及 `core.o` +- `core.o` 会进一步波及 `libtracecore.a` +- `cli-on` 会新增 `tracecli` +- `tracecli` 的链接动作会读取 `libtracecore.a` + +如果 `Need(cli)` 只记录 mutation root 的直接输入,那么它只能看到: + +```text +Need(cli) = { cli.c, trace.h } +``` + +此时即使: + +```text +FS(api) = { trace_options.h, core.o, libtracecore.a, ... } +``` + +也会因为: + +```text +FS(api) ∩ Need(cli) = ∅ +``` + +而把 `api` 和 `cli` 误判成图上正交。 + +但真实依赖关系并不是这样。`cli` 的新增最终要靠 `tracecli` 的链接动作成立,而那个链接动作对 `libtracecore.a` 有新的外部依赖。因此这里真正应该被记录的是: + +```text +Need(cli) = { cli.c, trace.h, libtracecore.a } +``` + +这样才会得到: + +```text +FS(api) ∩ Need(cli) = { libtracecore.a } +``` + +从而在 Stage 2 直接判碰撞。 + +### 6.4.2.2 为什么不能把所有下游输入都塞进 `Need(X)` + +反过来,如果把所有受影响下游动作的输入都机械塞进 `Need(X)`,又会制造新的伪干涉。 + +还是看一个最小例子: + +- `api` 通过 `protoc` 新增了 `api.c/api.h` +- `server.o` 因为读到新的 `api.h` 被波及重编 +- 最终链接动作从: + +```text +cc server.o utils.o -o app.exe +``` + +变成: + +```text +cc server.o utils.o api.o -o app.exe +``` + +这里真正新增的外部依赖是: + +```text +api.o +``` + +而不是: + +```text +utils.o +``` + +因为 `utils.o` 在 base 的同一个 `app.exe` 链接动作里本来就已经存在。 +如果把 `utils.o` 也放进 `Need(api)`,那任何会波及 `utils.o` 的另一边 option 都会被错误判成干涉。 + +因此实现上必须再加一层基线对齐: + +- 如果某个受影响下游动作在 base 中有“同一路径输出”的唯一 writer +- 就用那个 base writer 的读集作为 baseline +- 只把 probe 相对 base **新出现的外部依赖** 记入 `Need(X)` +- 同时跳过纯 delivery / install-only reader 的读集 + +也就是说,`Need(X)` 的正确语义不是: + +```text +所有下游动作读过的东西 +``` + +而是: + +```text +direct root prerequisites ++ downstream newly introduced external dependencies +- baseline-existing sibling inputs +``` + +#### 6.4.3 最后算前向波及区 `FS(X)` + +从 `SeedW(X)` 出发,沿稳定路径依赖图做前向闭包,得到: + +```text +FS(X) = 所有会因为 SeedW(X) 的变化而被影响到的稳定路径集合 +``` + +`FS(X)` 既包含: + +- 直接新增或改写产生的路径 + +也包含: + +- 因为上游输入变化而被带着重跑的下游路径 + +因此,`FS(X)` 不是“起火点”,而是“火势范围”。 + +### 6.5 双边干涉判定 + +拿到 `A` 和 `B` 的单边分析结果后,Stage 2 只做三条判定。 + +#### 6.5.1 第一定律:直接变更源不能撞在一起 + +如果两边的直接变更源在路径层立刻发生重叠,就说明它们在同一个局部位点上同时点火。 + +路径层可以写成: + +```text +SeedW(A) ∩ SeedW(B) = ∅ +``` + +如果这里非空,Stage 2 直接判碰撞。 + +#### 6.5.2 第二定律:一边的波及区不能污染另一边的前提 + +如果 `A` 的前向波及区已经进入 `B` 的直接变更前提,说明 `B` 的“起火条件”本身被 `A` 改写了;反之亦然。 + +判定式为: + +```text +FS(A) ∩ Need(B) = ∅ +FS(B) ∩ Need(A) = ∅ +``` + +只要任一非空,就说明两边存在依赖干涉,Stage 2 必须判碰撞。 + +#### 6.5.3 第三定律:允许汇聚,但只能汇聚到 merge surface + +两边的波及区不要求完全不相交。 + +下面这种情况是允许的: + +- `A` 和 `B` 在中途没有依赖干涉 +- 但它们最终都汇聚到了某个允许交给 Stage 3 的输出面 + +因此,`FS(A) ∩ FS(B)` 非空并不自动判撞。只有当交集落在 merge surface 之外,才算碰撞。 + +文档中把 merge surface 定义为: + +- 最终输出 manifest 中的稳定输出路径 +- 以及与 Stage 3 三方合并同级的 metadata surface + +也就是说: + +- 中途传播路径上的交集:Stage 2 直接拦截 +- 最终汇聚面上的交集:允许交给 Stage 3 判断能否 clean merge + +### 6.6 一个最小例子 + +假设 baseline `O0` 是: + +```text +gcc -c server.c -o server.o +gcc -c utils.c -o utils.o +gcc server.o utils.o -o app.exe +``` + +单开 `A` 后,引入 protobuf 代码生成: + +```text +protoc api.proto -> api.c, api.h +gcc -c server.c -o server.o +gcc -c api.c -o api.o +gcc server.o utils.o api.o -o app.exe +``` + +在这个模型里: + +- `M(A)` 的最小直接变更源是新增的 protobuf 生成与新增的 `api.c -> api.o` 编译 +- `SeedW(A) = { api.c, api.h, api.o }` +- `Need(A) = { api.proto }` +- `FS(A) = { api.c, api.h, api.o, server.o, app.exe }` + +其中: + +- `server.o` 虽然发生了变化,但它不是 `M(A)`,而是因为读到了新的 `api.h` 被波及 +- `app.exe` 也属于最终波及结果,而不是独立的起火点 + +单开 `B` 后,打开日志宏: + +```text +gcc -DLOG_ON -c utils.c -o utils.o +gcc server.o utils.o -o app.exe +``` + +则: + +- `M(B)` 是被直接改写的 `utils.c -> utils.o` +- `SeedW(B) = { utils.o }` +- `Need(B) = { utils.c }` +- `FS(B) = { utils.o, app.exe }` + +现在套用三条判定: + +1. `SeedW(A) ∩ SeedW(B) = ∅` +2. `FS(A) ∩ Need(B) = ∅` +3. `FS(B) ∩ Need(A) = ∅` +4. `FS(A) ∩ FS(B) = { app.exe }` + +前 3 条都通过,说明两边没有中途依赖干涉。 + +第 4 条虽然有交集,但交集只落在最终输出 `app.exe` 上,因此它属于 merge surface,允许进入 Stage 3。 + +如果 Stage 3 发现 `app.exe` 是无法三方合并的二进制,就在那里回退真实执行;Stage 2 不需要提前把这种“最终汇聚但尚未证明不可合并”的场景判死。 + +### 6.7 Stage 2 的结论 + +Stage 2 的职责被明确收窄为: + +> 证明 `A` 与 `B` 在构建传播链路中没有中途依赖干涉,并且它们的交集只可能出现在允许交给 Stage 3 的汇聚面上。 + +因此,Stage 2 通过的含义不是“`A+B` 一定成立”,而只是: + +- 两边没有在中途互相污染对方的直接变更前提 +- 如果存在汇聚,汇聚只发生在 merge surface + +这仍然只是前置条件,而不是最终放行条件。 + +--- + +## 7. Stage 3:产物合成验证(Merge or Root Replay) + +这是本方案区别于“只靠双图判定”的核心增强。 + +### 7.1 核心思想 + +如果 Stage 2 已经证明 `A` 和 `B` 构建正交,那么系统对最终产物应当有一个更强的期望: + +```text +理想的 A+B 产物 +== +把 OA 和 OB 相对 O0 的变化做受限合成后的结果 +``` + +因此,Stage 3 不再停留在“图上可推导”,而是把这个推导落到产物层: + +```text +SynthesizedAB = Synthesize(O0, OA, OB) +``` + +这里的 `Synthesize` 不是单一操作,而是两条受限路径: + +1. `direct merge`:只对已有显式组合算子的产物面直接做三方合并 +2. `root replay`:对无法直接 merge、但 root 清晰且参数域简单的生成器/构建根,做参数合并与最小必要重放 + +只要 `SynthesizedAB` 不能被自动生成并物化,`A+B` 就不能被认证为可跳过组合。 + +### 7.2 为什么 merge 是必要的 + +图上正交解决的是“过程看起来没撞”,但真实系统里还存在另一类问题: + +- 两边最终都改了同一份普通文本配置,但可以自动三方合并 +- 两边都改了同一份生成器产物,但冲突真正发生在上游参数域,应该回到 root 做受限 replay +- 两边都改了同一个 archive,但成员级别互不冲突 +- 两边 metadata 都变化了,但 flag 追加顺序仍然可收敛 +- 或者反过来,图上看似没明显传播冲突,但最终产物层面无法收敛 + +这些都只能在产物层回答,不能只靠构建过程回答。 + +### 7.3 Stage 3 的两条合成路径 + +Stage 3 不再被理解成“只有 `merge.go` 一条路”,而是: + +#### 7.3.1 Direct Merge + +`internal/evaluator/merge.go` 继续负责那些已经有显式组合算子的产物面: + +- metadata 仍然做三方合并 +- 普通文本文件仍然走 three-way merge +- archive 仍然走 member 级别 merge + +但这里要明确一个边界: + +> `config.h`、`expat_config.h` 这类生成器产物,不应再被视为“优先靠文本 merge 解决”的对象。 + +如果它们的冲突来自上游 generator/configure 参数,那么它们应优先进入 replay 路径,而不是把“同行冲突但语义可能可并”的问题硬塞给文本 merge。 + +#### 7.3.2 Root Replay + +对于 direct merge 做不到、但满足 replay 准入条件的产物,Stage 3 允许走 root replay: + +- 先识别变化路径最近、且可参数化的 replay root +- 再把 `A` 与 `B` 相对 `O0` 的 root 参数差异做组合 +- 最后只重放真正同时依赖 A/B 信息的 mixed frontier + +这条路径的目标不是替代全量 `Build(A+B)`,而是: + +```text +只重放最小必要子图 +``` + +也就是说,Stage 3 的目标从“纯 merge”扩展成: + +```text +能 direct merge 的先 merge +不能 direct merge 但 root replay 成本足够低的,走受限 replay +其余情况全部回退 +``` + +### 7.4 `merge.go` 在这里的职责 + +`internal/evaluator/merge.go` 提供的不是“简单目录拼接”,而是相对 base 的三方合并: + +- metadata 必须可合并 +- 每个输出路径必须可合并 +- 普通文本文件走 three-way merge +- archive 走 member 级别 merge +- 无法自动合并并物化的变化必须降级为 `needs-rebuild` + +最终它会重新生成 merged tree,并重算 manifest。 + +因此,Stage 3 的 direct merge 认证条件不是“目录里没重名文件”,而是: + +1. `MergeOutputTrees(O0, OA, OB)` 返回 `merged` +2. merged tree 可以被重新 materialize +3. merged manifest 是稳定可计算的 + +如果 direct merge 失败,系统还可以进一步尝试 root replay;只有两条路径都失败,系统才必须回退到真实的 `A+B` 组合执行。 + +### 7.5 Stage 3 的含义 + +Stage 3 验证的是: + +> `A` 与 `B` 的产物变化是否满足“相对 baseline 可交换,并且能通过 direct merge 或受限 replay 被物化成候选组合产物”。 + +这一步通过后,系统才有资格进入最后的测试覆盖判定。 + +### 7.6 `WatchWithOptions` 的执行图如何使用 Stage 3 + +带 `ValidateSynthesizedPair` 的 `WatchWithOptions` 不能再简单地沿用 “Stage 2 hard collision component -> 全部展开” 这一套规则。否则像 Expat 这类 case 会出现: + +- Stage 2 从 `expat_config.h -> xmlparse/xmlrole/xmltok -> libexpat.a` 看到大面积共享传播 +- 但其中一部分共享传播其实正是同一个 replay root 可以吸收的传播面 +- 如果仍按 Stage 2 硬边直接展开,就会把本来能被 Stage 3 消化掉的 pair 也强行加入执行矩阵 + +因此,`WatchWithOptions` 在有 synthesis validator 时,实际采用的是: + +```text +baseline + singletons +-> 对 singleton pairs 逐个执行 Stage 3 synthesis + Stage 4 validator +-> 只有 synthesis 失败或 validator 失败的 pair 才形成执行图硬边 +-> 再从这些“失败 pair”构造 component combos +``` + +这里还有一个重要边界: + +> 只有 `root replay` 才允许软化 Stage 2 的硬碰撞;`direct merge` 虽然可以验证产物可合成,但不能反过来修改 Stage 2 的硬碰撞语义。 + +也就是说: + +- `direct merge` 只负责跳过本来就允许进入验证路径的 pair +- `root replay` 才负责吸收“图上看似共享 configure-root 传播、但实际上可由 replay root 合成”的那类重叠 + +--- + +## 8. Stage 4:用户测试覆盖 `A+B` 组合 + +这是最终放行条件,不是附属建议。 + +### 8.1 为什么系统不能替代用户测试 + +前面三步最多证明: + +- 构建过程没有观察到耦合 +- 产物可以线性合并 + +但系统仍然不知道: + +- `A+B` 的运行时行为是不是符合公式作者预期 +- `A+B` 的接口、插件加载、功能联动是否正确 +- `A+B` 是否满足包真正承诺给外部用户的语义 + +这些只能由用户写测试来承担。 + +### 8.2 测试要求 + +如果想跳过真实的 `A+B` 构建,用户必须提供明确覆盖 `A+B` 的测试。 + +这里的“覆盖”不是模糊语义,而应满足: + +1. 测试声明自己验证的是 `A+B` 组合能力 +2. 测试执行目标是 Stage 3 合成后得到的 `SynthesizedAB` +3. 测试断言的是组合语义,而不是只重复测 `A` 或只重复测 `B` + +### 8.3 放行规则 + +只有当下面三件事同时成立时,`A+B` 才能从真实执行矩阵中移除: + +1. Stage 2 正交通过 +2. Stage 3 合成通过 +3. Stage 4 的 `A+B` 组合测试在 `SynthesizedAB` 上通过 + +否则: + +- 没有覆盖测试:不能跳过 +- 有覆盖测试但失败:不能跳过 +- 有覆盖测试但无法在 merge 产物上运行:不能跳过 + +系统的保守回退策略是:**真实执行 `A+B` 组合**。 + +--- + +## 9. 决策规则 + +对于任意候选组合 `A+B`,使用下面的判定: + +```text +Skip(A+B) 当且仅当: + +OrthogonalBuild(A, B) +&& FeasibleSynthesis(O0, OA, OB) +&& TestCovered(A+B) +&& TestPass(SynthesizedAB) +``` + +否则: + +```text +Execute(A+B) +``` + +这一定义有两个重要性质: + +1. 它是保守的:任何一步证据不足,都不会误跳过真实组合 +2. 它是分层的:构建证明、产物验证、语义测试分别承担不同责任 + +--- + +## 10. 模块边界 + +为了避免架构污染,这个方案需要严格区分五类职责: + +### 10.1 Probe / Observation 层 + +职责: + +- 采集单项构建 trace +- 归一化 `Trace/Events` +- 提供 digest、manifest、scope 等观测证据 + +不负责: + +- Stage 2 hazard 判定 +- 最终产物合并 + +### 10.2 Trace SSA / Impact 层 + +职责: + +- 构造 Path-SSA 核心图 +- 在 SSA 图上叠加 `tooling/probe/mainline/delivery` 角色 +- 计算 `M/SeedW/Need/FS/Frontier` +- 给出 Stage 2 的 RAW / WAW / merge-surface 干涉结论 + +不负责: + +- 决定最终执行矩阵 +- 直接操作输出目录或执行 replay +- 推断用户测试语义 + +### 10.3 Manifest / Synthesis 层 + +职责: + +- 计算输出目录 manifest +- 先尝试 direct merge +- 在满足准入条件时规划并执行受限 replay +- 给出 `merged`、`replayed` 或 `needs-rebuild` 的产物级结论 + +不负责: + +- 推断用户要测试什么功能 +- 解释 option 的业务语义 + +### 10.4 Test Coverage 层 + +职责: + +- 接受 `SynthesizedAB` +- 运行用户显式声明覆盖 `A+B` 的测试 +- 给出语义验证结果 + +不负责: + +- 判断构建链路正交 +- 改写 merge 规则 + +### 10.5 Planner 层 + +职责: + +- 汇总 Stage 2 / 3 / 4 的结果 +- 决定 `A+B` 是跳过还是进入真实执行矩阵 + +不负责: + +- 自己实现 Stage 2 图算法 +- 自己执行 direct merge / root replay +- 自己定义用户测试覆盖语义 + +这样划分的目的很明确: + +- 观测层只解决“证据如何稳定落盘与归一化” +- Trace SSA / impact 只解决“构建是否正交” +- synthesis 只解决“产物是否可被直接合成,或被受限 replay 合成” +- 测试只解决“组合语义是否被证明” +- planner 只负责汇总证据并下执行决策 + +任何一层都不应该越权替代另一层。 + +--- + +## 11. MVP 范围 + +MVP 只做一件事: + +> 基于 baseline 与单项构建结果,为二元组合 `A+B` 发放“可跳过真实组合构建”的认证。 + +暂不在本文解决: + +- 三元及更高阶组合的递归推导 +- 跨 package 传播后的全局认证 +- 语言级 ABI 静态分析 +- 用户未声明组合测试时的自动语义推断 + +先把二元组合的证据链做硬,再考虑更高维度。 + +--- + +## 12. Root Replay 准入规则 + +前面的主证据链在 Stage 3 已经扩展为: + +```text +Stage 2 正交 + -> Stage 3 产物合成(direct merge 或 root replay) + -> Stage 4 组合测试 +``` + +也就是说,文档当前主线不再是“只有纯 merge 一条路”,而是: + +- 能 direct merge 的先 merge +- direct merge 做不到时,再判断是否允许进入 root replay +- 两条路径都不成立时,回退真实组合构建 + +但在真实库日志里,还存在一类比纯 merge 更强、比全量 `Build(A+B)` 更便宜的中间路径: + +> 在 Stage 2 已经证明两边没有中途依赖干涉后,不对全图 replay,而只对极少数可参数化的 root 做参数合并与最小必要重放。 + +### 12.1 核心思路 + +root replay 不再追求“完全不执行任何构建动作”,而是把目标改成: + +```text +避免全量 A+B 构建 +``` + +具体做法是: + +1. 用 Stage 2 的单边影响分析先证明 `A` 与 `B` 无坏干涉 +2. 把变化路径聚类到少数几个最近、且可参数化的 replay root +3. 只对真正同时依赖 A/B 信息的 mixed frontier 节点做参数合并和最小重放 +4. 其余节点直接复用 `O0 / OA / OB` 的现成产物 + +这里最重要的复杂度控制点是: + +- 不追每个产物到最终源码源头 +- 不重放整张图 +- 只重放 mixed frontier + +### 12.2 何时允许进入 root replay + +root replay 不是无条件路径,必须同时满足下面条件: + +1. Stage 2 已经通过 + 也就是: + - `SeedW(A) ∩ SeedW(B) = ∅` + - `FS(A) ∩ Need(B) = ∅` + - `FS(B) ∩ Need(A) = ∅` + - 汇聚只落在允许面上 + +2. root 身份清晰且唯一 + 也就是变化路径能够稳定归类到一个最近 producer root,而不是多个难以区分的 writer。 + +3. 参数差异落在简单可合并域 + 第一版只考虑: + - `-DKEY=VALUE` + - `--key=value` + - `KEY=VALUE` + - append-only 选择参数,例如 `--with-foo` + +4. root 的下游 fanout 足够窄 + 也就是变化主要落在独立 target 或有限子图,而不是把整个核心库和大面积共享对象全部打脏。 + +当前第一版实现会用一组保守阈值近似这个条件,例如: + +- changed replay root 的数量不能过多 +- selected replay frontier root 的数量不能过多 +- selected writes 只按最终 materialized / delivery surface 计数,而不是把 configure/build root 的全部中间写路径都算进去 +- replay 执行前总是清空并重建目标 build root,避免基线 clone 中遗留的 `CMakeCache.txt`、对象文件或中间缓存污染 replay +- selected frontier 写出的稳定路径数量不能过多 + +这些阈值的作用是控制 replay 成本,而不是定义语义边界。 +因此它们必须允许中等规模的 `configure -> build -> install` root 继续进入 replay;如果阈值过紧,系统会把 Expat 这类本应由 root replay 吸收的 pair 重新打回 Stage 2 硬碰撞。 + +一旦超过阈值,系统直接判定 replay frontier 过宽并回退,不继续尝试 replay。 + +任何一项不满足,都不应该进入 replay,而应直接回到当前主路径: + +```text +merge 做不到 + -> needs-rebuild +``` + +### 12.3 真实库样本给出的边界 + +这个扩展不是凭空猜测,而是受真实公式和现有 E2E 日志约束出来的。 + +#### 12.3.1 适合 replay 的样本:Boost + +`boost` 的 option materialization 是典型 additive root: + +- `./b2 ... --with-timer` +- `./b2 ... --with-program_options` + +对应公式见: + +- [internal/build/testdata/formulas/boostorg/boost/1.0.0/Boost_llar.gox](/Users/haolan/project/llar/internal/build/testdata/formulas/boostorg/boost/1.0.0/Boost_llar.gox) + +对应真实日志见: + +- [TestE2E_Watch_RealOptionClassification_BoostProgramOptionsTimer-trace-1773765899771102586.log](/Users/haolan/project/llar/.llar-e2e-logs/TestE2E_Watch_RealOptionClassification_BoostProgramOptionsTimer-trace-1773765899771102586.log) +- [TestE2E_Watch_RealOptionClassification_BoostProgramOptionsTimer-graph-1773765899602612419.log](/Users/haolan/project/llar/.llar-e2e-logs/TestE2E_Watch_RealOptionClassification_BoostProgramOptionsTimer-graph-1773765899602612419.log) + +它们的特点是: + +- root 明确 +- 参数域简单 +- 下游主要落到独立子库 `libboost_timer.a`、`libboost_program_options.a` + +这类样本非常适合 root replay。 + +#### 12.3.2 不适合 replay 的样本:OpenSSL + +`openssl` 的两个 option 都打在同一个全局 `Configure` 根上: + +- `asm` 变成 `no-asm` +- `zlib` 变成 `zlib --with-zlib-include=... --with-zlib-lib=...` + +对应公式见: + +- [internal/build/testdata/formulas/openssl/openssl/1.0.0/Openssl_llar.gox](/Users/haolan/project/llar/internal/build/testdata/formulas/openssl/openssl/1.0.0/Openssl_llar.gox) + +对应真实日志见: + +- [TestE2E_Watch_RealOptionClassification_OpenSSLAsmZlib-trace-1773760725033344052.log](/Users/haolan/project/llar/.llar-e2e-logs/TestE2E_Watch_RealOptionClassification_OpenSSLAsmZlib-trace-1773760725033344052.log) +- [TestE2E_Watch_RealOptionClassification_OpenSSLAsmZlib-graph-1773760724477037093.log](/Users/haolan/project/llar/.llar-e2e-logs/TestE2E_Watch_RealOptionClassification_OpenSSLAsmZlib-graph-1773760724477037093.log) + +这些日志表明: + +- root 虽然清晰,但它是全局 root +- 一旦参数变化,就会打脏 `/configdata.pm`、`/crypto/`、`/ssl/`、`/providers/legacy` 等大片共享核心路径 +- 下游 frontier 非常宽,接近整库重建 + +因此 OpenSSL 不适合作为“低复杂度、低成本”的 replay 样本。 + +#### 12.3.3 边界型样本:Expat + +`expat` 也是单个 CMake configure root,但它的变化会快速穿过 `_build/expat_config.h`,落到核心对象: + +- `xmlparse.c.o` +- `xmlrole.c.o` +- `xmltok.c.o` +- `libexpat.a` + +对应公式见: + +- [internal/build/testdata/formulas/libexpat/libexpat/1.0.0/Expat_llar.gox](/Users/haolan/project/llar/internal/build/testdata/formulas/libexpat/libexpat/1.0.0/Expat_llar.gox) + +对应真实日志见: + +- [TestE2E_Watch_RealOptionClassification_ExpatCoreMacros-trace-1773832965108811509.log](/Users/haolan/project/llar/.llar-e2e-logs/TestE2E_Watch_RealOptionClassification_ExpatCoreMacros-trace-1773832965108811509.log) +- [TestE2E_Watch_RealOptionClassification_ExpatCoreMacros-graph-1773832965103237217.log](/Users/haolan/project/llar/.llar-e2e-logs/TestE2E_Watch_RealOptionClassification_ExpatCoreMacros-graph-1773832965103237217.log) + +它说明: + +- root replay 不能只看“root 是否统一” +- 还必须看这个 root 是否把共享核心对象大面积打脏 + +Expat 因此属于边界型样本:技术上可以 replay,但收益未必好。 + +### 12.4 实现前提与边界 + +如果要把这条路径真正落到实现,这一层至少还需要补两个能力: + +1. 更窄的 replay root identity + 当前 `structureKey` 更适合 Stage 2 diff,不适合 replay root 归类。 + +2. 更完整的命令输入观测 + 当前 trace 记录只有: + - `Argv` + - `Cwd` + - `Inputs` + - `Changes` + + 见 [internal/trace/trace.go](/Users/haolan/project/llar/internal/trace/trace.go)。 + 如果某些关键参数只通过环境变量传递,单靠现有 trace 还不足以做安全 replay。 + +因此,本文把 replay 明确定义成: + +> Stage 3 的正式第二合成路径, +> 但它只适用于 root 清晰、参数域简单、fanout 窄的库; +> 在工程实现上可以分期落地,不要求与 direct merge 同时完成。 + +--- + +## 13. 最终结论 + +LLAR 的矩阵降维不能只靠“图上没撞”就放行。 + +真正可落地的放行条件必须是四段式: + +1. 先构建 `A` 和 `B` 的单项样本 +2. 再证明它们在构建过程中正交 +3. 再证明它们的产物相对 baseline 可以被 direct merge 或受限 replay 自动物化 +4. 最后要求用户提供并运行覆盖 `A+B` 的组合测试 + +只有这样,`跳过 A+B` 才不是拍脑袋的启发式,而是一条完整、保守、可审计的证据链。 diff --git a/doc/llar-path-centric-diff-design.md b/doc/llar-path-centric-diff-design.md new file mode 100644 index 0000000..59c0b0f --- /dev/null +++ b/doc/llar-path-centric-diff-design.md @@ -0,0 +1,93 @@ +# LLAR 矩阵降维优化方案:基于稳定路径的确定性差分 + +## 1. 现有方案的局限性:时间序列与锚点对齐的脆弱性 + +在现有的矩阵降维设计中(参考 `doc/llar-matrix-reduction-design.md` 6.2节),系统判断两个 Option 是否正交的核心方法是:在 Action Graph 中寻找 Baseline 与 Probe 完全相同的动作节点作为“精确锚点”,然后在锚点之间的 Gap 中分析差异。 + +这种基于“动作执行时间序列”的对齐机制存在两个致命的脆弱点: + +1. **并发乱序 (Concurrency Jitter)**:在使用 `make -j` 或 `ninja` 等并发构建工具时,节点被记录到 trace 的顺序是随机的。物理上完全相同的两个图,因为 trace 记录顺序不同,可能导致 Gap 计算逻辑极其复杂甚至错乱。 +2. **全局参数注入导致锚点全军覆没**:如果 Option A 的作用是在 CFLAGS 中增加一个 `-O3` 或者 `-g`,这会导致构建图中的**每一个编译节点**的 fingerprint 都发生变化。此时系统将找不到任何“完全一致”的锚点,触发“歧义必须保守回退”的安全机制,导致原本可以跳过的矩阵被强制真实执行。 + +**结论**:在黑盒观测中,动作的发生顺序(时间)和全局指纹是极度不稳定的,不适合作为对比差分图的坐标系。 + +--- + +## 2. 核心思想:以“稳定产物路径”为绝对坐标系 + +什么是绝对稳定的?在同一个代码库的构建中,**物理文件的路径(如 `main.o`, `lib/utils.a`)是绝对的契约。** + +我们不需要强行去推导“Option A 里的第 3 个动作对应 Baseline 里的哪个动作”,我们只需要问一个确定性的问题: +> **“生成 `main.o` 的那个动作,相对 Baseline 发生改变了吗?”** + +本方案将对比视角从 **“序列对比”** 翻转为 **“状态对比”**: +把 Action Graph 转化为一张哈希表:`Map[产物路径] -> 生成该产物的动作指纹 (Action Fingerprint)`。 + +--- + +## 3. 算法重构步骤 + +### 3.1 步骤一:Tooling 降噪(复用现有机制) +保留现有的 `evaluator.go` 中的降噪能力。通过过滤 `bash`, `make`, `cmake` 等纯调度工具,提取出真正产生物理读写和数据流传播的 Mainline Actions。 + +### 3.2 步骤二:构建产物生成表 (Path-to-Action Map) +对 Baseline (`O0`)、Option A (`OA`) 和 Option B (`OB`) 的 Mainline 节点进行遍历,以文件的写入路径(Writes)作为 Key,动作指纹作为 Value。 + +```text +Map_O0 = { + "build/main.o": "gcc -c main.c", + "build/utils.o": "gcc -c utils.c" +} + +Map_OA = { + "build/main.o": "gcc -c main.c -O3", + "build/utils.o": "gcc -c utils.c -O3" +} + +Map_OB = { + "build/main.o": "gcc -c main.c", + "build/utils.o": "gcc -c utils.c", + "build/docs.pdf": "pandoc ..." +} +``` +*注:如果一个节点产出多个文件,则这多个路径都映射到同一个动作指纹。如果文件是被一系列宏动作(比如中间生成了临时文件 `/tmp/1.s`)生成,则将瞬态路径隐藏,只保留最终持久化写入的工作空间路径。* + +### 3.3 步骤三:确定性求取差分 ($\Delta$) +对比 Baseline 的 Map 和 Option 的 Map,不需要任何编辑距离(GED)或启发式猜想。直接通过 Map 遍历求差异: + +定义 **$\Delta(Option)$ = Option 相对 Baseline,改变了哪些稳定路径的生成逻辑**(包括新增、删除或指纹修改)。 + +在上述例子中: +- `OA` 修改了 `main.o` 和 `utils.o` 的生成命令,因此:`Δ(A) = { "build/main.o", "build/utils.o" }` +- `OB` 新增了 `docs.pdf` 的生成,原有的 `main.o` 和 `utils.o` 命令指纹和 O0 完全一致被对消。因此:`Δ(B) = { "build/docs.pdf" }` + +此时,我们获得了两个极度干净、100% 确定且不受乱序影响的差分集合 $\Delta(A)$ 和 $\Delta(B)$。 + +### 3.4 步骤四:双重碰撞判定 + +在拿到了 $\Delta(A)$ 和 $\Delta(B)$ 这两个“被篡改生成逻辑的文件集合”后,正交性判断化简为纯粹的集合论运算: + +#### 1. 逻辑环境碰撞(写-写冲突) +```text +Collide_Logic = (Δ(A) ∩ Δ(B)) ≠ ∅ +``` +如果 A 和 B 都试图改变同一个文件(如 `main.o`)的生成逻辑(不管它们是怎么改的),立即判定逻辑碰撞。 + +#### 2. 物理传播碰撞(读-写冲突) +为了防止 A 的产物变成了 B 的输入,需要检查物理传播链路: +假设 $R_A$ 为 Option A 中所有 Mainline 节点的读路径集合,$R_B$ 同理。 +```text +Collide_Physic = (Δ(A) ∩ R_B) ≠ ∅ OR (Δ(B) ∩ R_A) ≠ ∅ +``` +如果 A 篡改了某个文件的生成逻辑,而 B 恰好读取了这个文件,则发生物理串联碰撞。 + +**放行条件**: +只要 `Collide_Logic` 和 `Collide_Physic` 皆为 false,则在构建图层面上,A 与 B 被**数学意义上 100% 证明为正交**,可以安全进入下一阶段(产物三方 Merge)。 + +--- + +## 4. 架构收益总结 + +1. **绝对确定性 (Zero Heuristics)**:彻底消灭了图匹配中的启发式“猜想”和“模糊归属”。路径不会骗人,哈希表寻址是 $O(1)$ 且绝对确定的。这完美契合了系统“保守证据链”的最高准则。 +2. **完美免疫乱序 (Immune to Concurrency)**:以空间(文件路径)代替时间(节点出现顺序),从根本上无视了并发构建带来的 Trace 时序抖动。 +3. **更强的降维放行率 (Higher Skip Rate)**:面对全局参数注入(如添加全局 `-g`),旧方案会因为丢失锚点而全部降级执行;新方案只会精准标出被 `-g` 影响的 `.o` 文件,只要 Option B 没有碰到这些 `.o`,依然可以完美发放正交认证,大幅提升测试矩阵的剪枝率。 diff --git a/doc/llar-stage2-rewrite-design.md b/doc/llar-stage2-rewrite-design.md new file mode 100644 index 0000000..e96de19 --- /dev/null +++ b/doc/llar-stage2-rewrite-design.md @@ -0,0 +1,1404 @@ +# LLAR TraceSSA + Evaluator 重构方案设计 + +## 1. 目的 + +这份文档定义的是 **LLAR Stage 2 的重构方案**。 + +目标不是修补当前 `internal/evaluator` 里的旧实现,而是给出一套新的、可以直接照着实现的设计: + +- 只保留两个模块: + - `tracessa` + - `evaluator` +- 讲清楚每个模块负责什么 +- 讲清楚每个模块内部具体怎么做 +- 讲清楚从输入到输出的完整流程 + +这份文档的读者包括: + +- 人类工程师 +- 未来参与实现的 AI + +因此文档必须满足两个要求: + +1. 不依赖旧代码上下文也能读懂 +2. 读完之后可以直接开始拆文件和实现 + +--- + +## 2. 设计目标 + +Stage 2 要解决的问题只有一个: + +> **给定 baseline 和单项 option 的构建观测,提取这个 option 的结构影响摘要,用于后续组合正交判定。** + +这里的“结构影响摘要”指的是: + +- 直接起火点写了什么 +- 这些变化向下波及到了哪里 +- 这些变化依赖了哪些前提状态 +- 当前证据是否足以给出 sound 结论 + +最终需要的输出是 `ImpactProfile`,供 `evaluator` 上层做碰撞判定。 + +--- + +## 3. 非目标 + +这次重构不负责: + +- Stage 3 的产物合并 +- Stage 4 的组合测试 +- 源码语义分析 +- ABI 推断 +- 针对特定语言的编译语义识别 + +这些能力不属于 Stage 2。 + +--- + +## 4. 总体架构 + +重构后只保留两个模块: + +```text +internal/trace/ssa -> tracessa +internal/evaluator -> evaluator +``` + +依赖方向固定为: + +```text +evaluator -> tracessa +``` + +`tracessa` 不能依赖 `evaluator`。 + +--- + +## 5. 两个模块各自负责什么 + +## 5.1 `tracessa` 模块 + +`tracessa` 是 **纯分析引擎**。 + +它负责把 baseline/probe 的黑盒 trace 观测转换成 `ImpactProfile`。 + +它内部完成完整的五阶段流水线: + +1. 观测归一化 +2. Path-SSA 建图 +3. 角色投影 +4. Wavefront 差分 +5. Impact 提取 + +`tracessa` 不负责: + +- 组合矩阵决策 +- 多个 option 两两比较 +- 合并产物 +- 测试执行 + +它的责任边界很简单: + +> **输入一对 baseline/probe 观测,输出一个单项 option 的 `ImpactProfile`。** + +## 5.2 `evaluator` 模块 + +`evaluator` 是 **Stage 2 的编排器和判定器**。 + +它负责: + +- 从 `ProbeResult` 组装出 `tracessa` 需要的输入 +- 调用 `tracessa` 得到每个 option 的 `ImpactProfile` +- 用两个 `ImpactProfile` 做碰撞判定 +- 把 Stage 2 结果交给上层 + +`evaluator` 不再负责: + +- 自己维护一套独立的路径图 +- 自己做角色分类 +- 自己做节点配对式差分 +- 自己重新定义 `Need / SeedW / FS` + +`evaluator` 的原则是: + +> **只消费 `tracessa` 的结果,不重复实现 `tracessa` 的内部逻辑。** + +--- + +## 6. `tracessa` 模块设计 + +## 6.1 `tracessa` 的输入 + +`tracessa` 处理的是一个 baseline/probe 对。 + +建议定义输入对象: + +```text +AnalysisInput + Base: + Records + Events + Scope + InputDigests + Probe: + Records + Events + Scope + InputDigests +``` + +这里不要求 baseline/probe 的采样方式一致: + +- 可以都是 `Records` +- 可以都是 `Events` +- 也可以是 `Events + fallback Records` + +但进入分析前都必须先归一化成统一观测。 + +## 6.2 `tracessa` 的输出 + +建议定义输出对象: + +```text +AnalysisResult + Profile: ImpactProfile + Debug: + BaseGraph + ProbeGraph + BaseRoles + ProbeRoles + Wavefront +``` + +其中对 `evaluator` 真正必需的是: + +- `Profile` + +调试字段只用于调试和测试,不作为上层业务逻辑的依赖。 + +## 6.3 `tracessa` 的核心数据结构 + +下面这些结构是整个引擎的稳定 IR。 + +### 6.3.1 `NormalizedExecNode` + +表示归一化后的黑盒执行节点。 + +字段建议包含: + +- `PID` +- `ParentPID` +- `Argv` +- `Cwd` +- `Env` +- `Reads` +- `ReadMisses` +- `Writes` +- `Deletes` + +约束: + +- 所有路径都已经处于统一 canonical scope +- `Env` 必须是去噪、排序后的稳定环境视图,不能把样本私有噪音变量直接原样带入 +- `ReadMisses` 必须保留失败 `open/stat/access` 一类负面依赖事实;不能在归一化时静默丢失 +- 不携带 compile/configure/install 等动作语义 + +### 6.3.2 `PathState` + +表示路径的一个版本状态。 + +字段建议包含: + +- `Path` +- `Version` +- `Writer` +- `Tombstone` +- `Missing` + +语义: + +- 同一路径每次定义产生一个新版本 +- 删除产生墓碑版本 +- 失败读取或查找未命中可以绑定到负面状态 `P(path, n, missing=true)` +- `Tombstone` 和 `Missing` 不是一回事: + - `Tombstone` 表示图内某个动作显式删除了该路径 + - `Missing` 表示该动作观测到了“这里不存在可读对象”的负面前提 +- `Path` 是统一的分析 key,不限于真实文件系统路径;保留字命名空间如 `$ENV/CFLAGS` 也属于合法状态路径 + +### 6.3.3 `ReadBinding` + +表示一次读取绑定到哪些 reaching-def。 + +字段建议包含: + +- `Path` +- `Defs` + +约束: + +- `Defs` 可能为空以外不能被偷偷折叠 +- 若存在多个不可比较定义,必须显式保留多个 `Defs` + +### 6.3.4 `Graph` + +表示 Path-SSA 图。 + +图上只有两种对象: + +- 执行节点 +- 路径状态 + +建议结构至少包含: + +- `Actions` +- `ActionReads` +- `ActionWrites` +- `InitialDefs` +- `DefsByPath` +- `ReadersByDef` +- `ParentAction` +- `Out` / `In` 或任何足以恢复偏序的边信息 + +### 6.3.5 `RoleProjection` + +表示投影到 SSA 图上的角色视图。 + +建议结构包含: + +- `ActionClass` +- `DefClass` +- `ActionNoise` +- `DefNoise` +- `ActionDeliveryOnly` + +这个结构是**附加视图**,不是 Graph 的一部分。 + +### 6.3.6 `WavefrontResult` + +表示第四阶段的分类结果。 + +建议结构包含: + +- `ProbeClass` +- `DivergedDefs` +- `MutationRoots` +- `FlowActions` +- `UnchangedActions` +- `JoinSet` +- `ReadAmbiguous` + +### 6.3.7 `ImpactProfile` + +这是 `tracessa` 的正式输出。 + +建议字段: + +- `SeedW` +- `Need` +- `FS` +- `JoinSet` +- `SeedStates` +- `NeedStates` +- `FlowStates` +- `Ambiguous` + +含义固定: + +- `SeedW`:直接起火点写出的 diverged 路径 +- `Need`:发散闭包之外、且被发散动作真实依赖的前提路径 +- `FS`:所有 diverged 状态对应路径的总和 +- `JoinSet`:所有同时消费“闭包内发散状态”和“闭包外稳定前提”的动作集合 + +--- + +## 7. `tracessa` 的五阶段流程 + +下面是 `tracessa` 内部必须实现的完整流程。 + +## 7.1 第一阶段:观测归一化 + +### 作用 + +把原始 trace 观测清洗成可比较的黑盒执行节点。 + +### 输入 + +- `Records` +- `Events` +- `Scope` +- `InputDigests` + +### 输出 + +- `[]NormalizedExecNode` + +### 具体要做什么 + +#### 1. 路径归一化 + +把路径映射到统一 canonical 空间: + +- `/tmp/build-abc/lib.a` -> `$BUILD/lib.a` +- `/tmp/install-xyz/bin/foo` -> `$INSTALL/bin/foo` + +路径归一化必须稳定,不能因为不同样本的临时根不同而改变身份。 + +#### 2. `argv / cwd / env` 去噪 + +去掉不影响结构判定的噪音: + +- 绝对临时根 +- 随机后缀 +- 时间戳 +- 样本私有工作目录前缀 + +但不能把有语义的环境变量直接抹掉。 + +像下面这类会改变构建行为的环境输入必须保留下来并规范化: + +- `CFLAGS` +- `CPPFLAGS` +- `LDFLAGS` +- `PKG_CONFIG_PATH` +- `LD_LIBRARY_PATH` +- 任何被当前动作真实继承并可能改变产物的环境项 + +#### 3. 事件折叠 + +如果底层输入是 syscall 事件序列,则要折叠成执行节点级摘要: + +- 一条 exec 对应一个黑盒节点 +- exec 时继承的环境快照归入 `Env` +- 读事件归入 `Reads` +- 失败 `open/stat/access` 事件归入 `ReadMisses` +- 写事件归入 `Writes` +- 删除事件归入 `Deletes` + +#### 4. 进程树关联 + +保留: + +- `PID` +- `ParentPID` + +并恢复稳定的父子关系和基础先后事实。 + +### 这一步绝对不能做什么 + +- 不得判断“这是 configure / compile / install” +- 不得给路径打 mainline/tooling/probe 标签 +- 不得提前推导 `Need / SeedW / FS` + +### 为什么必须这样 + +因为这一层的任务只是“把事实洗干净”。 +一旦在这里混入语义判断,后面所有阶段都会被污染。 + +## 7.2 第二阶段:构建 Path-SSA + +### 作用 + +把归一化后的黑盒命令列表升级成路径状态版本图。 + +### 输入 + +- `[]NormalizedExecNode` + +### 输出 + +- `Graph` + +### 图中有哪些对象 + +只有两类对象: + +- 执行节点 `E` +- 路径状态 `P(path, n)` + +边只有两类: + +- `P -> E` +- `E -> P` + +### 具体怎么建 + +#### 1. 初始状态 + +每个被读取但尚未在图内定义过的路径,都隐式拥有一个基线状态: + +```text +P(path, 0) +``` + +它表示构建开始前外部世界的状态。 + +环境变量也按同样方式进入图,但使用保留字路径空间: + +```text +P($ENV/NAME, 0) +``` + +也就是说,`NormalizedExecNode.Env` 在建图时必须展开成该动作的一组只读输入状态。 +这样环境变化才能进入数据流,也才能参与 ready 判定、等价检查和 root 判定。 + +#### 2. 写入建新版本 + +当某个节点写路径 `p` 时,产生一个新版本: + +```text +P(p, k+1) +``` + +#### 3. 删除建 tombstone + +当某个节点删除路径 `p` 时,产生: + +```text +P(p, k+1, tombstone=true) +``` + +删除必须进入数据流,不能靠“路径不存在”这种隐式含义表示。 + +#### 4. 读取绑定 reaching-def + +当节点读取路径 `p` 时,绑定到在当前保守因果偏序下对它可达的 reaching-def 集合。 + +读取有四种情况: + +1. 唯一前置定义 +2. 没有图内定义,回退到 `P(path, 0)` +3. 若底层明确观测到失败读取或查找未命中,则绑定到负面状态,例如 `P(path, 0, missing=true)` +4. 多个不可比较定义,形成 `ambiguous read` + +这里必须区分三件事: + +- `P(path, 0)`:表示“外部世界里存在一个初始状态,但当前图内没有更晚定义” +- `P(path, k, tombstone=true)`:表示“图内某个动作显式删除了它” +- `P(path, 0, missing=true)`:表示“该动作真实探测过这个路径,并观测到这里不存在可读对象” + +如果 trace 已经记录了失败探测,就不能把第 3 种情况偷换成第 2 种情况。 +否则后续另一个 option 新建同一路径时,SSA 图上会丢掉这条真实的负面依赖。 + +### 偏序怎么定义 + +这里只允许使用当前明确可观测的证据: + +- 同一 pid 的稳定顺序 +- `pid / parent` 关系 +- 已观测读写依赖 + +不允许假设一个虚假的全局线性时间轴。 + +### 这一步不能做什么 + +- 不得写入动作语义 +- 不得区分主线或探针 +- 不得基于目录语义猜测隐式依赖 + +## 7.3 第三阶段:角色投影 + +### 作用 + +把不应参与主线碰撞判定的控制面噪音从 SSA 图上隔离出去。 + +### 输入 + +- `Graph` + +### 输出 + +- `RoleProjection` + +### 角色投影的原则 + +角色是**图上的投影结果**,不是图本体。 + +也就是说: + +- `Graph` 永远保持纯 Path-SSA +- 角色信息只存在于 `RoleProjection` + +### 角色投影具体怎么做 + +#### 1. Hard sinks + +角色投影的主判断不应是“路径长得像什么”,而应是: + +> **这个状态最终有没有进入真实产物链。** + +但这里不能直接把 sink 定义成“被非噪音动作继续消费的产物”,因为第三阶段本身正是在判定谁是噪音、谁不是噪音。 + +因此第三阶段必须先从**与角色无关的硬事实**出发,建立一组不会自举循环的锚点,也就是 **hard sinks**。 + +原则: + +- 任何最终逃到 `$INSTALL` 或等价交付面的状态,都属于 hard sink +- 出现在 output manifest 中的状态,属于 hard sink +- evaluator 显式给出的外部产物根,属于 hard sink + +这一步的目标不是“先把所有路径分类完”,而是先定义: + +> **哪些东西代表真实产物链的终点。** + +#### 2. Tooling / Probe 种子 + +再从动作图的可观测行为中识别控制面种子,例如: + +- 长出大批 control-plane 写入、但这些写入无法进入 hard sinks 的动作 +- 只生成被子进程继续执行、却不进入真实产物链的 produced-exec 动作 +- 位于明显的自检 / 自举 / 临时子图入口处的动作 +- 已知 delivery plane 外、但其全部写出最终都只流向工具子图的动作 + +对当前 Path-SSA 实现,推荐把这里进一步落成两层图证据: + +1. `seed actions` + - configure/tool bootstrap action 只能作为弱 hint + - 真正进入种子集的,还必须满足至少一种图关系证据: + - 子进程链由已知 tooling action 派生 + - `execPath` 来自 tooling writer + - 写出只被局部工具子图继续消费/执行 +2. `workspace roots` + - 不再用 `TryCompile` / `cmTC` / 特定目录名直接判 probe + - 而是从 action 的 `cwd/read/write` 关系中,推断一组局部 tooling workspace roots + - 这些 roots 只是 island membership 的证据,不是路径角色本身 + +这里必须区分 3 个层次,不能混用: + +- `hint` + - 例如 `KindConfigure`、configure/tool bootstrap 父子链、明显的局部自举入口 + - 它们只能把 action 提名为候选,不能直接把 action/path 判成 tooling/probe +- `evidence` + - 例如 `execPath` 来自 tooling writer、写出只被局部子图继续消费/执行、`cwd/read/write` 收敛到同一局部 workspace + - 这些证据用来支持 island 候选,但仍不是最终角色 +- `membership` + - 最终的 tooling/probe island membership 必须由后续不动点解出 + - 不能因为 action 恰好位于某个 workspace root 下,或路径名看起来像 probe artifact,就直接判定 membership + +也就是说,`workspace root` 只是“这个 action/def 可能属于局部 tooling island”的结构证据; +真正的 island membership,必须等到 Step 3 的联合不动点之后才能成立。 + +这里的识别仍然只是投影起点,不是给 Graph 节点改类型。 + +这一步的种子识别也应尽量来自图上的行为证据,而不是工具链名字或路径外观。 +如果某个候选种子没有足够图证据支撑,就不应硬判成 tooling/probe,而应保留给后续 `Ambiguous` / mainline-visible 处理。 + +#### 3. SSA 逃逸分析 + +这里不能把 Step 2 理解成“从 tooling 种子做一次无约束的前向闭包,然后凡是到不了 hard sinks 的都切掉”。 + +那样在 `hard sinks = ∅` 的裸构建里会直接出错。例如: + +```text +A1(configure) -> config.cache +A1(configure) -> config.h +A2(cc) -> 读 main.c, config.h -> 写 app +``` + +如果把 A1 当 tooling seed,并从 A1 做无约束前向传播,那么 `config.h -> A2 -> app` 整条链都会被 seed 感染;由于此时没有 hard sinks,整片图都会被误判成 `NoEscape`,真实主线被整块吞掉。 + +因此这里必须做的是: + +> **definite-tooling-only 的 must-analysis,而不是 tooling 污染的 may-analysis。** + +也就是说,第三阶段只能剔除那些**被证明一定只停留在 tooling/probe 子图内部**的状态;凡是存在主线逃逸可能的状态,都不能在这一步切掉。 + +这里的不动点对象不能只是一组 path,也不能只是一组 action,而应当是当前 Path-SSA 图上的二部节点: + +- `PathState` +- `Action` + +换句话说,Stage 3 需要解的是一个 **联合 island membership**: + +- 某个 `Action` 只有在其 `exec/read/write` 都仍满足 tooling-only 条件时,才可以留在候选 island 内 +- 某个 `PathState` 只有在其 writer 和全部消费者都仍留在候选 island 内时,才可以留在候选 island 内 +- 一旦 `Action` 或 `PathState` 任一侧越过边界,对侧也必须在后续迭代中同步退出 + +因此,generic wrapper / `gmake` / `cc` / `ld` 这类动作是否属于 probe island,不能靠“动作名看起来像工具”来判断, +也不能靠旧 action-graph 的目录规则直接提升;必须通过它在 `def -> action -> def` 图上的联合 membership 来证明。 + +从这些种子出发时,判断规则应当是: + +- 只有当某个 `PathState` 的所有消费者都仍然停留在 tooling/probe 候选子图内部时,它才有资格留在 `NoEscape` 候选集中 +- 只要某个 `PathState` 被一个混合消费者读取,这个状态就必须立即退出 `NoEscape` 候选集 +- 这里的“混合消费者”指:某个 action 同时读取了 seed-reachable control-plane def 和 seed 外的其他输入(例如源码、主线编译输入、未证实为 tooling 的 def) +- 到达 hard sinks 当然也是逃逸,但不是唯一逃逸条件 + +因此,真正的 `NoEscape` 不是“从 seed 能走到哪里”,而是: + +> **从 seed 出发、且始终不跨越 mixed-consumer frontier 的那部分最大不动点。** + +在上面的例子里: + +- `config.cache` 若只在 configure/probe 子图内部使用,可以保留在 `NoEscape` +- `config.h` 一旦被 `A2(cc)` 读取,而 `A2` 同时还读取了 `main.c`,它就已经跨越了 mixed-consumer frontier,不能再算 tooling-only +- `A2` 和 `app` 因此会保留在残余图中,留给 derived sinks 和 backward slice 处理 + +#### 3.1 这里说的“稀疏逃逸分析”是什么意思 + +这里借鉴的是传统 SSA / points-to 分析里的 sparse escape 思路,但对象不是“堆对象是否逃出函数”,而是“某个 `PathState` 是否逃出 tooling/probe 子图”。 + +传统 sparse escape 的核心不是按每个 basic block 维护 dense 的 IN/OUT 集合,而是: + +1. 先建立 SSA 值或对象节点 +2. 再建立真实的 use-def / points-to 边 +3. 最后在这张稀疏图上用 worklist 跑到不动点 + +只有沿真实依赖边传播,才叫 sparse。 + +映射到这里时,对应关系应当是: + +- 传统分析里的“对象节点 / allocation site” -> 这里的 `PathState = P(path, version)` +- 传统分析里的“值流边” -> 这里的 `def -> action read` +- 传统分析里的“对象可达边 / store-load 传播” -> 这里的 `action -> written defs` +- 传统分析里的“逃逸汇点” -> 这里的 hard sinks + +因此,这里的稀疏逃逸分析本质上就是一个带 frontier 的稀疏不动点: + +1. 以 configure / probe / tooling seeds 写出的 `PathState` 作为初始候选 +2. 以这些 `PathState` 可达的 `Action` 作为 action 侧初始候选,但它们仍需经过后续删减 +3. 沿 `def -> action -> def` 的真实依赖链传播,但只允许穿过仍满足 tooling-only 条件的 action +4. 若某个候选 `Action` 的 `exec/read/write` 越过局部 island 边界,则它记为 `EscapeResidual` +5. 若某个候选 `PathState` 被 mixed consumer 读取,则它记为 `EscapeResidual` +6. 若某个候选 `PathState` 到达 hard sinks,则它记为 `EscapeHardSink` +7. 只有那些既不到达 hard sinks、也不跨越 mixed-consumer frontier 的 action/def 候选,才留在 `NoEscape` +8. 最终切掉的只是 `NoEscape` 子图,而不是整片 seed 前向闭包 + +在实现上,这里的 `mixed-consumer frontier` 不应再由“路径外观看起来像 probe artifact”来豁免。 +如果某条状态需要特殊对待,必须通过它所属的 `tooling workspace root / probe island membership` 来证明,而不是通过 `looks*` 函数直接打补丁。 + +同样,`KindConfigure` 在实现里最多只能作为初始 hint: + +- 它可以帮助把某些 action 放入初始候选 +- 但不能单独决定某个 action/def 一定属于 tooling/probe +- 如果图上没有额外 relation evidence 支撑,这些候选必须在不动点中自然淘汰,而不是被硬保留 + +这一步的关键不是路径名长得像不像 sidecar,而是状态沿真实 SSA 依赖链最终流到了哪里。 + +例如: + +- `_build/CMakeFiles/pkgRedirects` 若只在 configure/probe 子图内部闭环,且到不了任何 hard sink,则它属于 `NoEscape` +- `_build/cmake_install.cmake` 若会流到 install/control-plane,则它不属于 `NoEscape` + +所以,Stage 3 的第一层推荐实现形态应当是一个小格上的稀疏不动点: + +- `NoEscape` +- `EscapeResidual` +- `EscapeHardSink` + +#### 4. Derived sinks + +仅靠 hard sinks 还不够,因为很多裸构建根本没有: + +- `$INSTALL` +- output manifest +- evaluator 提供的外部产物根 + +例如一个纯 `make` 构建可能只有: + +```text +cc -> build/main.o +ld -> build/app +``` + +这时如果没有第二层 sink,自举会卡死:既没有 hard sink,也还没算出 mainline closure。 + +因此,在只剔除**已证明为 `NoEscape`** 的 tooling/probe islands 之后,还必须从**残余图**中推断一组 **derived sinks**。 + +这组 sinks 的定义不能再依赖“谁是 non-noise”,而应来自残余图本身的结构事实。 + +推荐做法: + +1. 在剔除 `NoEscape` 后的残余图上计算 condensation DAG +2. 找出其中的 terminal components +3. 从 terminal components 中选出真实物化前沿,作为 derived sinks +4. 这些 derived sinks 代表“trace 结束时仍存活、且未被证明属于 tooling/probe 的终端产物前沿” + +例如: + +- `build/app` 是裸构建里的 derived sink +- `build/config.cache` 若已在前一层被证明属于 `NoEscape` tooling island,则它不参与 derived sink 候选 +- `config.h` 若跨越了 mixed-consumer frontier,则它不会在前一层被切掉,而会继续留在残余图里参与主线闭包推断 + +#### 5. 主线反向闭包推断 + +最后从: + +- `hard sinks` +- `derived sinks` + +一起出发,在 Path-SSA 图上做一层 **backward slice / backward reachability**: + +- `sink path -> writer action` +- `writer action -> read defs / initial defs` +- `read def -> producer action` +- 再从 `producer action -> written defs` 继续向上回溯 + +直到到达不动点。 + +得到的闭包不是“最终产物集合”,而是: + +> **所有会真实影响最终产物链的 mainline-relevant 状态和动作集合。** + +这一步的结果就是主判断本身,不允许再回退到 `looks*` 风格的路径启发式。 + +例如: + +- `_build/trace_options.h` 若被真实 compile 读取,并进一步影响 `libtracecore.a` / install 输出,则它属于主线反向闭包 +- `_build/CMakeFiles/pkgRedirects` 若只在 configure/probe 子图内部循环,且无法反向连接到任何 hard sink 或 derived sink,则它不属于主线闭包 + +也就是说,第三阶段推荐采用的主判断应当是: + +> **从真实产物反推主线闭包。** + +这和传统 SSA / PDG 分析里的 `backward slice from observable outputs` 是同一类思路,只是这里的节点不是局部变量,而是 `PathState` 和黑盒 `ExecNode`。 + +#### 6. 剩余路径的残差分类 + +经过“hard sinks + `NoEscape` tooling/probe islands + derived sinks + 主线反向闭包推断”之后,仍然可能剩下一小部分既不明显属于主线,也不明显属于噪音的残差状态。 + +这些状态可以再按以下顺序处理: + +1. 若它们完全停留在 install/copy/delivery 平面,则归入 delivery-side +2. 若它们完全停留在 configure/probe/tooling 子图,则归入 tooling/probe-side +3. 若证据不足以做 sound 分类,则保留为 `Ambiguous`,或保守提升为 mainline-visible + +这里不允许再引入路径外观 fallback。 + +也就是说: + +- 不能因为路径名“看起来像 sidecar”就把它压成 tooling/probe +- 不能因为路径名“看起来像产物”就把它抬成 mainline +- 不能重新退化成工具链 catalog,比如硬编码 `TryCompile` / `cmTC` / 特定构建系统文件名列表 + +#### 7. 这一阶段的设计原则 + +角色投影的目标不是把每条路径都“猜成某个名字类目”,而是把 Path-SSA 图切成三层: + +- 可反向连到真实产物 sink 的 mainline closure +- 明确不逃逸、且不跨越 mixed-consumer frontier 的 tooling/probe islands +- 只停留在交付面的 delivery-side states + +推荐顺序应当是: + +1. infer `hard sinks` +2. infer `seed actions` 与 `workspace roots` +3. 在 `PathState + Action` 二部图上跑 sparse must-analysis,得到 `NoEscape` tooling/probe islands +4. 从残余图 infer `derived sinks` +5. backward slice 得到 mainline closure +6. delivery-plane 残差隔离 +7. 无法证明的残差保留为 `Ambiguous` 或保守视作 mainline-visible + +这意味着 `looks*` 一类函数不应出现在 Stage 3 的正式设计里;一旦还需要它们,说明图推断本身还不够强。 + +当前实现若需要判断“某条状态是否属于 probe workspace”,也必须先从图关系推断 `workspace roots / island membership`,再据此分类;不能直接按路径名或目录名做角色决策。 + +### 结果是什么 + +最后得到: + +- 哪些 action 是 noise +- 哪些 state 是 noise +- 哪些 action 是 delivery-only +- 哪些 action/state 仍属于主线 + +### 这一步不能做什么 + +- 不得修改 Graph 里的读取绑定 +- 不得删除节点 +- 不得根据角色回写版本结构 + +## 7.4 第四阶段:Wavefront Diff + +### 作用 + +在 baseline 和 probe 两张 role-aware SSA 图上推进差分波面,把 probe 侧动作分成: + +- `MutationRoot` +- `Flow` +- `Unchanged` + +### 输入 + +- baseline `Graph` +- probe `Graph` +- baseline `RoleProjection` +- probe `RoleProjection` +- 路径 digest 证据 + +### 输出 + +- `WavefrontResult` + +### 具体怎么做 + +#### 1. 定义内在签名 + +每个动作提取内在签名: + +```text +Hash(normalized argv + normalized cwd + normalized env footprint + read path set + write path set) +``` + +这里故意不带版本号,因为签名描述的是行为形状,不是世界线位置。 + +环境变量不能只停留在归一化文本里而不进入签名。 +否则像 `CFLAGS=-O0` 与 `CFLAGS=-O3` 这类直接改变动作行为的差异,会在波面入口被错误压平。 + +#### 2. 找 ready 起点 + +从那些输入都绑定到等价 `P(path, 0)` 的动作开始推进。 + +如果一个动作只读到等价前提,它就有资格进入 baseline ready pool / probe ready pool。 + +#### 3. 噪音拦截 + +若动作或状态已经被第三阶段标成噪音: + +- 它不进入主线差分 +- 它写出的状态不进入主线下游 + +#### 4. Flow 判定 + +如果 probe 动作的任一可见输入已经是 diverged,则: + +- 该动作一定是 `Flow` +- 它写出的所有可见状态都变为 diverged + +#### 5. MutationRoot 判定 + +只有当 probe 动作的全部可见输入都仍是 equivalent 时,才允许做 root 判定。 + +做法是: + +1. 去 baseline ready pool 中查找相同内在签名的候选 +2. 在整个候选集合内继续做等价检查 +3. 若存在唯一等价候选,则该 probe 动作为 `Unchanged` +4. 若不存在等价候选,则该 probe 动作为 `MutationRoot` +5. 若候选集合本身无法唯一决断,则记为 `Ambiguous` + +### 这里必须特别强调 + +不能复用旧实现里那种: + +> “同签名 bucket 里拿第一个候选就定生死” + +这种做法会把候选顺序错误地当成起火证据。 + +正确做法必须是: + +> **bucket 内完整检查,再做分类。** + +## 7.5 第五阶段:Impact 提取 + +### 作用 + +把第四阶段的动作分类压成 `ImpactProfile`。 + +### 输入 + +- probe `Graph` +- probe `RoleProjection` +- `WavefrontResult` +- baseline 对齐结果 + +### 输出 + +- `ImpactProfile` + +### 具体怎么提取 + +#### 1. `SeedW` + +由 `MutationRoot` 写出的、且实际被标为 diverged 的物理路径。 + +#### 2. `FS` + +所有 diverged 状态对应路径的总和,包括: + +- `SeedW` +- `Flow` 写出的路径 +- 删除产生的 tombstone 路径 + +#### 3. `Need` + +`Need` 只表示 **发散动作实际依赖、且不由当前发散闭包自己产出的前提状态**。 + +这里的“前提状态”是指: + +- 不由当前发散闭包内部定义 +- 但被 `MutationRoot` 或 `Flow` 动作真实读取 +- 并且能够影响这些动作输出的状态 + +`Need` 不是所有下游输入的全集,但也绝不能为了压制伪碰撞而删掉真实前提。 + +正确规则是: + +1. 遍历所有 `MutationRoot` 和 `Flow` 动作的读取 +2. 只保留不由当前发散闭包产出的前提状态;它既可能是图外 `P(path, 0)`,也可能是图内但稳定的中间状态 +3. 只要某个前提状态被发散动作实际读取,它就必须进入 `Need` +4. 即使 baseline 对应动作原本也读取了这个前提状态,也不能因此把它从 `Need` 中删除 +5. baseline 对齐只能用于识别观测噪音、delivery 污染和无关 ambient 依赖,不能用来删除真实前提 +6. `delivery-only` 或纯安装复制动作的读取不进入 `Need` +7. 若某个读取命中的状态已经由同一发散闭包内部产出,则它不进入 `Need`;这类状态已经属于闭包内部传播,应由 `FS` 描述 + +### 实现要求 + +为了避免把真实 RAW 冲突漏掉,`Need` 的实现必须按下面的顺序做: + +1. 第四阶段先完整算出 `MutationRoot`、`Flow` 和整条 diverged 闭包 +2. 第五阶段再统一遍历所有 `MutationRoot` 和 `Flow` 动作的读取 +3. 对每个读取命中的状态,先判断它是否已经在当前 diverged 闭包里 +4. 若该状态已经属于当前闭包内部传播,则跳过,不进入 `Need` +5. 若该状态不属于当前闭包,但这个动作真实读取了它,则把它加入 `Need` +6. 只有角色噪音、`delivery-only` 读取、观测歧义这几类情况允许阻止加入 `Need` + +实现时必须明确禁止两类旧错误: + +1. 不能在 root 阶段提前把读取直接塞进 `Need` + 因为此时还不知道哪些读取其实会在后续 flow 闭包里变成内部传播,提取得太早会把边界画错 +2. 不能因为 baseline 对应动作本来也读取了这个前提,就把它从 `Need` 删除 + 这会把“baseline 原本就读、但组合时仍会被另一边 option 污染”的真实前提静默漏掉 + +### 例子 1:图内稳定中间件不能漏 + +基线: + +```text +compile a.c -> a.o +link a.o -> app +``` + +两个 option: + +- `X` 修改 `compile` +- `Y` 修改 `link` + +此时: + +- `X` 会写出新的 `a.o` +- `Y` 虽然只改了 `link`,但 `link` 仍然真实读取 `a.o` + +实现上如果把 `Need(Y)` 限成“只收图外前提”,就会把 `a.o` 漏掉,最后错误判成 `X` 与 `Y` 正交。 + +正确做法是: + +- `link` 读取了 `a.o` +- `a.o` 不是 `Y` 自己这条发散闭包产出的状态 +- 所以 `a.o` 必须进入 `Need(Y)` + +这样 `X` 写出的 `a.o` 才会在 Stage 2 的 RAW 判定里撞上 `Y` 的前提。 + +### 例子 2:baseline 本来也读过,仍然不能删 + +基线: + +```text +gen flags.in -> flags.txt +compile main.c common.h flags.txt -> main.o +link main.o -> app +``` + +两个 option: + +- `A` 修改 `gen`,导致 `flags.txt` 改变 +- `B` 修改 `common.h` + +在 `A` 的单项分析里: + +- `compile` 会因为 `flags.txt` 被污染而变成 `Flow` +- 这个 `Flow compile` 同时仍然读取 `common.h` + +如果实现里用了“baseline 对应动作本来也读 `common.h`,所以不用记进 `Need`”这条过滤,系统就会把 `common.h` 错删掉,最终误判 `A` 与 `B` 正交。 + +正确做法是: + +- 只要 `Flow compile` 真实读取了 `common.h` +- 且 `common.h` 不是 `A` 自己的发散闭包内部产物 +- 那么 `common.h` 就必须进入 `Need(A)` + +这样另一边 option `B` 对 `common.h` 的改动才能在 RAW 判定里被看见。 + +### 为什么要这么做 + +因为 `Need` 最容易被错误实现成第一种形式: + +> “所有受影响动作的所有输入” + +那会直接制造大量伪碰撞。 + +第二种同样错误的形式是: + +> “只保留 probe 相对 baseline 新出现的外部依赖” + +这会把那些 baseline 本来就读取、但在组合时仍然会被另一边 option 修改的真实前提错误删掉,进而漏掉真实 RAW 冲突。 + +#### 4. `JoinSet` + +`JoinSet` 表示 **所有同时消费“闭包内发散状态”和“闭包外稳定前提”的动作集合**。 + +它不是碰撞判定字段,而是给后续 replay / merge 规划提供 join 信息。 + +先定义: + +- `ReachState`:从 `MutationRoot` 写出的 diverged 状态出发,在 probe 图上沿 `def -> use -> def` 可达的全部状态 +- `ReachExec`:在同一传播闭包中可达的全部动作 +- `StablePrereq`:不属于当前 `ReachState`,但属于当前分析域且可见的稳定前提状态 +- `MixedExec`:所有同时满足以下条件的动作: + 1. 该动作属于 `ReachExec` + 2. 它至少读取一个来自 `ReachState` 的输入 + 3. 它还读取了至少一个来自 `StablePrereq` 的输入 + +则: + +> `JoinSet = MixedExec` + +更直白地说: + +- 只要一个动作同时消费了“已经发散的输入”和“闭包外仍稳定的前提”,它就属于 `JoinSet` +- 后续是否只取最靠前的一层,是 `JoinSet` 之上的派生操作,不是 `JoinSet` 自身定义的一部分 + +这里的 `StablePrereq` 包括两类来源: + +- 图外初始状态,例如 `P(path, 0)` +- 图内但稳定、且不由当前发散闭包产出的中间状态,例如未变化的 `b.o` + +因此,像下面这种 fan-in: + +```text +a.o (diverged) \ + -> link -> bin +b.o (stable) / +``` + +链接动作必须进入 `JoinSet`,因为它同时读取了发散输入和稳定前提。 + +提取规则固定: + +1. 只在 `MutationRoot + Flow` 诱导出的传播闭包里找 join +2. 纯噪音动作、`delivery-only` 动作不进入 `JoinSet` +3. 只要一个动作满足 mixed 条件,就进入 `JoinSet` +4. 若 Stage 3 需要最小 replay 起点,再从 `JoinSet` 派生 `min(JoinSet)` +5. 证据不足以判断某个动作是否属于 `JoinSet` 时,直接标记 `Ambiguous` + +--- + +## 8. `evaluator` 模块设计 + +## 8.1 `evaluator` 的作用 + +`evaluator` 不再自己分析 trace。 + +它只做四件事: + +1. 组织 baseline 和 singleton 的输入 +2. 调用 `tracessa` +3. 比较两个 `ImpactProfile` +4. 把 Stage 2 的结论交给上层 + +## 8.2 `evaluator` 的输入 + +仍然使用现有上层采样得到的 `ProbeResult` 或等价对象。 + +每个 `ProbeResult` 至少提供: + +- `Records` +- `Events` +- `Scope` +- `InputDigests` + +## 8.3 `evaluator` 的输出 + +对单项 option: + +- 返回 `ImpactProfile` + +对两项 option 的正交判定: + +- 返回 `OrthogonalityResult` + +建议 `OrthogonalityResult` 至少包含: + +- `Orthogonal` +- `Hazards` +- `LeftProfile` +- `RightProfile` +- `Ambiguous` + +## 8.4 `evaluator` 具体流程 + +### 单项分析流程 + +1. 取 baseline `ProbeResult` +2. 取 option `X` 的 singleton `ProbeResult` +3. 组装成 `tracessa` 的 `AnalysisInput` +4. 调用 `tracessa.AnalyzeImpact` +5. 得到 `ImpactProfile(X)` + +### 双项碰撞流程 + +1. 分别得到 `ImpactProfile(A)` 和 `ImpactProfile(B)` +2. 判定 `SeedW(A) ∩ SeedW(B)` +3. 判定 `FS(A) ∩ Need(B)` +4. 判定 `FS(B) ∩ Need(A)` +5. 判定 `FS(A) ∩ FS(B)` +6. 记录所有 hazard +7. 若任一侧 `Ambiguous=true`,直接判定不能跳过真实组合构建 + +四类判定的语义必须固定: + +#### `SeedW(A) ∩ SeedW(B)` + +这是直接写写冲突。 +两边都在定义同一路径,属于最直接的 WAW。 + +#### `FS(A) ∩ Need(B)` + +这是从 A 指向 B 的 RAW。 +表示 A 的发散结果会污染 B 的真实前提状态。 + +#### `FS(B) ∩ Need(A)` + +这是从 B 指向 A 的 RAW。 +表示 B 的发散结果会污染 A 的真实前提状态。 + +#### `FS(A) ∩ FS(B)` + +这是汇聚型共享产物冲突检查,不能省略。 + +它捕获的是: + +- 两边沿不同路径传播 +- 最终在某个 fan-in 节点或共享输出路径上汇聚 +- 从而共同改写同一个中间产物或最终产物 + +如果没有这一步,凡是依赖图内内部状态汇聚到同一输出的场景,都会被错误地当成正交。 + +### `FS ∩ FS` 命中的处理规则 + +`FS(A) ∩ FS(B)` 非空后,不能直接忽略,也不能简单一刀切。 + +正确规则是: + +1. 若交集路径不在任何显式允许的 replay / merge surface 上,则直接判为冲突 +2. 若交集路径位于显式允许的 replay surface 上,则记录为 `join hazard`,交由 Stage 3 判断是否能通过 replay 吸收 +3. 只有当 Stage 3 明确声明该交汇可被吸收时,才允许继续放行 + +也就是说: + +> `FS ∩ FS` 至少必须进入 hazard 集,绝不能因为它不属于 `Need` 就被静默忽略。 + +### `JoinSet` 的用途 + +`JoinSet` 不参与 Stage 2 的正交矩阵判定。 + +它只用于后续 Stage 3: + +1. 从 `JoinSet` 派生 `min(JoinSet)` 作为 replay roots +2. 限制 replay 只从真正需要重新混合的那一层开始 +3. 避免因为缺少边界信息而把整段下游主线全部重放 + +换句话说: + +- `Need / FS / SeedW` 回答的是“会不会撞” +- `JoinSet` 回答的是“哪里发生了发散流与稳定前提的 join” +- `min(JoinSet)` 回答的是“如果要吸收这次交汇,最小该从哪里开始重放” + +## 8.5 `evaluator` 绝对不能再做什么 + +以下逻辑必须从 `evaluator` 中彻底删除: + +- 自己构图 +- 自己做角色分类 +- 自己实现 wavefront +- 自己从 action graph 直接推导 `Need / SeedW / FS` +- 自己维护第二套“看起来差不多”的 Stage 2 启发式 + +`evaluator` 一旦保留这些逻辑,重构就会再次退化成旧结构。 + +--- + +## 9. 端到端完整流程 + +下面是重构后的完整执行流程。 + +## 9.1 单项 option 分析 + +```text +baseline ProbeResult +probe ProbeResult + -> evaluator 组装 AnalysisInput + -> tracessa 阶段 1:观测归一化 + -> tracessa 阶段 2:建 Path-SSA + -> tracessa 阶段 3:角色投影 + -> tracessa 阶段 4:Wavefront 差分 + -> tracessa 阶段 5:Impact 提取 + -> 返回 ImpactProfile +``` + +## 9.2 两个 option 的正交判定 + +```text +ImpactProfile(A) +ImpactProfile(B) + -> evaluator 判定 Seed-WAW + -> evaluator 判定 Flow-Need RAW + -> evaluator 判定 FS-FS 汇聚冲突 + -> evaluator 记录是否 Ambiguous + -> 输出 Stage 2 正交结论 +``` + +## 9.3 Stage 3 合成入口 + +```text +ImpactProfile(A) +ImpactProfile(B) + -> synthesis / replay planner 读取 JoinSet + -> 派生 min(JoinSet) 作为 replay 起点 + -> 若 min(JoinSet) 过宽或 hazard 不可吸收,则回退真实组合构建 +``` + +--- + +## 10. 文件组织建议 + +虽然只保留两个模块,但每个模块内部仍然要按文件职责拆开,避免再次长成一坨。 + +## 10.1 `internal/trace/ssa` + +建议文件划分: + +- `normalize.go` + 观测归一化 +- `graph.go` + Graph / PathState / ReadBinding 等核心结构 +- `build.go` + Path-SSA 建图 +- `roles.go` + 角色投影 +- `wavefront.go` + Wavefront 差分 +- `impact.go` + Impact 提取 +- `analyze.go` + 对外总入口 + +这些文件仍属于同一个模块:`tracessa`。 + +## 10.2 `internal/evaluator` + +建议文件划分: + +- `evaluator.go` + 调用 `tracessa` 的总流程 +- `impact_compare.go` + 两个 `ImpactProfile` 的碰撞判定 +- `debug.go` + 调试输出 + +如果现有文件名更适合复用,可以保留文件名,但职责要按上面收敛。 + +--- + +## 11. 重构实施顺序 + +为了避免旧代码继续影响新实现,这次重构应按下面顺序推进。 + +## 11.1 第一步:先冻结接口和测试 + +先补足或保留这些测试: + +- 真实 case 回归 +- `Need` 提取回归 +- duplicate signature 匹配回归 +- tombstone 传播回归 +- probe/tooling island 隔离回归 +- ambiguous read 回归 + +这一步的目的不是保留旧逻辑,而是保留**外部行为要求**。 + +## 11.2 第二步:在 `tracessa` 内平行实现新引擎 + +先不切换 `evaluator`,只把新的五阶段引擎写出来并跑通测试。 + +## 11.3 第三步:让 `evaluator` 改为只消费 `tracessa` + +一旦 `tracessa` 稳定,就把 `evaluator` 改造成纯编排器。 + +## 11.4 第四步:删除旧 Stage 2 逻辑 + +当且仅当: + +- 新测试通过 +- 回归样本通过 +- `evaluator` 已不再依赖旧内部逻辑 + +才删除旧的 Stage 2 代码。 + +删除不是目标,删除只是为了确保后续实现不再被旧结构反向污染。 + +--- + +## 12. 风险和边界 + +## 12.1 目录枚举 + +当前核心模型不显式表达目录集合语义。 + +因此: + +- `readdir` +- `getdents` +- `glob` + +一类依赖若没有明确观测证据,必须保守回退。 + +## 12.2 失败探测 + +若底层稳定记录了失败 `open/stat/access`,则建图时必须显式生成负面状态并绑定到对应读取。 + +例如: + +```text +probe read A.h -> miss +fallback read /usr/include/A.h -> success +``` + +这里第一步必须落成类似: + +```text +P($BUILD/include/A.h, 0, missing=true) +``` + +否则如果另一个 option 恰好新建了这个 `A.h`,Stage 2 就看不见“原本这里不存在、但某个动作真实探测过它”这条依赖,后续可能漏掉 RAW。 + +若底层没有稳定记录失败探测,则系统不能假装已经恢复了这类负面前提。 +这种情况下必须: + +- 要么显式降级,不对 negative dependency 作精确判定 +- 要么上抛 `Ambiguous` + +## 12.3 歧义 + +以下情况必须上抛 `Ambiguous`: + +- 多 reaching-def +- baseline ready pool 候选无法唯一对齐 +- 结论依赖未观测能力 + +## 12.4 性能 + +新的 SSA 引擎一定会比旧的路径集合逻辑更重。 +这是可以接受的,因为目标首先是结构正确性和可维护性。 + +性能优化只能在不破坏模块边界的前提下进行。 + +--- + +## 13. 一句话总结 + +这次重构的本质不是“整理旧 `evaluator`”,而是: + +> **让 `tracessa` 成为唯一的 Stage 2 分析引擎,让 `evaluator` 只做编排和判定。** + +这样整个 Stage 2 才会重新变得可理解、可维护、可验证。 diff --git a/doc/llar-test-product-design.en.md b/doc/llar-test-product-design.en.md new file mode 100644 index 0000000..b048279 --- /dev/null +++ b/doc/llar-test-product-design.en.md @@ -0,0 +1,276 @@ +# LLAR Test Product Design + +## 1. Document Scope + +This document defines the base product semantics of `llar test`. It aims to answer three questions: + +- Why LLAR needs a dedicated test command. +- How Formula authors should describe artifact verification through `onTest`. +- What stable behavior users should expect when running `llar test`. + +This document does not cover matrix orthogonality, collision analysis, or automatic reduction strategies inside `--auto`. Those topics should live in a separate design document for test plan generation. + +## 2. Background + +`llar make` answers the question "can this package be built", but that is not the same as "is the delivered artifact usable". + +For a package manager, a successful build is still not enough. At minimum, the system must also answer questions like: + +- Can the installed executable start? +- Can the installed library be linked or loaded by a minimal consumer? +- Do the installed headers, config files, and runtime layout satisfy the most basic usage path? + +Without a unified verification entry point, these checks are scattered across external scripts, CI jobs, or tribal knowledge, and cannot become a first-class product capability of LLAR. + +Therefore, LLAR needs a first-class testing entry point that: + +- Lets Formula authors describe minimal usability verification. +- Lets users run verification through one consistent command. +- Lets future automatic test planning reuse the same verification logic. + +## 3. Product Goals + +The goals of `llar test` are: + +1. Verify minimal artifact usability after build and installation complete. +2. Provide a unified, stable, language-agnostic verification hook for Formula. +3. Make testing part of the official LLAR workflow instead of an external side script. +4. Provide a single final verification entry point for more advanced test planning features in the future. + +## 4. Non-Goals + +In its first stage, `llar test` does not attempt to solve the following: + +- It does not design matrix reduction strategies. +- It does not replace full functional test suites or upstream test mirrors. +- It does not promise performance, stress, benchmark, or long-run stability validation. +- It does not attempt to understand language semantics, ABI rules, or source-level internals. + +In other words, `llar test` focuses on "is this installed result minimally usable from a consumer perspective", not "has the project passed complete acceptance testing". + +## 5. Core Concepts + +### 5.1 `onBuild` + +`onBuild` is responsible for building and installing artifacts. It answers the question "how do we produce the deliverable". + +### 5.2 `onTest` + +`onTest` is responsible for verifying the artifact after the build completes. It answers the question "has the deliverable reached minimal usability". + +`onTest` does not participate in test plan generation or matrix analysis. It is the final verification action itself. + +### 5.3 Matrix Combination + +Testing always runs on one concrete matrix combination. For base `llar test`, the system only needs one concrete combination and then executes: + +- `build` +- `install` +- `onTest` + +Questions like "which combination should be selected" or "should the number of combinations be reduced" belong to another layer and are outside the scope of this document. + +## 6. Users and Use Cases + +### 6.1 Formula Authors + +Formula authors implement `onTest` in the formula to describe minimal verification actions, for example: + +- Compile a minimal example and link it against the installed library. +- Invoke an installed executable and check its return behavior. +- Load an installed extension module through an interpreter. + +### 6.2 Package Maintainers + +Package maintainers use `llar test` to verify that a module version is actually deliverable, not merely that the build script did not fail. + +### 6.3 CI and Automation Systems + +CI can use `llar test` as the standard verification step. Even if LLAR later adds `--auto` or other planning mechanisms, the final real verification step should still be `onTest`. + +## 7. External Product Semantics + +### 7.1 Command Entry + +The base commands are: + +```bash +llar test [module@version] +llar test --full [module@version] +``` + +At the command-semantics level, `llar test` is responsible for: + +- Parsing the target module. +- Loading the module and its dependencies. +- Selecting which option combinations need verification. +- Building and installing for each selected combination. +- Running `onTest` after the main module build completes for each combination. + +### 7.2 Success Semantics + +`llar test` is considered successful only when all of the following are true: + +1. Dependencies and the target module complete their builds successfully. +2. The target module is installed successfully. +3. If `onTest` is defined, `onTest` succeeds. + +This means: + +- If the build fails, the test fails. +- If `onTest` fails, the test fails. + +### 7.3 Default Behavior When `onTest` Is Missing + +The current implementation allows a Formula to omit `onTest`. In that case: + +- `llar test` still performs the build. +- No extra artifact verification step is executed. + +This is a compatibility behavior in the current stage. It should not be treated as the long-term ideal product shape. A later design can decide whether a missing `onTest` should become a warning or a stricter requirement. + +### 7.4 Matrix Selection + +At the product-design level, `llar test` has two explicit modes on the option axis: + +- Without `--full`, it runs only the test combination that corresponds to the default options. +- With `--full`, it expands and runs the full matrix. + +The key distinction is: + +- Plain `llar test` answers whether the default delivery configuration is usable. +- `llar test --full` answers whether every declared option combination is actually verified. + +The current implementation does not fully match this product semantics yet. Implementation progress should be tracked in the implementation-status document. + +### 7.5 Output and Failure Model + +From a product perspective, `llar test` must provide at least the following behavior: + +- Return exit code zero on success. +- Return a non-zero exit code on failure. +- Treat both build failure and `onTest` failure as test failure. + +The current implementation also provides the following additional behavior: + +- `--verbose` enables more detailed build and test logs. +- If the build produces metadata, that metadata is printed at the end of the command. + +## 8. Formula Interface Design + +### 8.1 Interface Shape + +Formula provides verification logic through `onTest`: + +```gox +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + _ = installDir + _ = combo + _ = proj +} +``` + +The recommended direction is for `onTest` to keep the same parameter shape as `onBuild`: + +- `ctx` provides the context that is actually needed in the test stage. +- `proj` provides project and dependency information, plus access to project-side files when needed. +- `out` is used to report test-stage errors or additional results. + +### 8.2 Design Principles for `onTest` + +`onTest` should follow these principles: + +1. Verify from the consumer perspective instead of repeating the build process. +2. Prefer a minimal verification loop instead of long-running or resource-heavy integration tests. +3. Prefer installed artifact paths over temporary build-directory internals. +4. Report failures explicitly and avoid silent false positives. + +### 8.3 Context Capabilities + +The available `ctx` / `proj` capabilities can be understood directly as these interfaces: + +```gox +installDir, err := ctx.outputDir() +depDir, err := ctx.outputDir(dep) +combo := ctx.currentMatrix() +result, ok := ctx.buildResult(dep) +data, err := proj.readFile("testdata/case.txt") +``` + +They correspond to: + +- The current module's install output directory. +- The output directory of a specific dependency module. +- The currently active matrix combination. +- Optional dependency build results and metadata. +- Access to test resources or config files stored inside the project. + +These capabilities are already enough for most "minimal consumer verification" scenarios. + +## 9. Execution Model + +The base execution flow of `llar test` can be described as: + +```mermaid +flowchart TD + A["User runs llar test"] --> B["Parse module and version"] + B --> C["Load module and dependencies"] + C --> D["Run build/install for the selected combination(s)"] + D --> E["Run onTest for the main module"] + E --> F["Return success or failure"] +``` + +There are two key boundaries here: + +- `onTest` runs only after the main module build completes. +- `onTest` is part of the verification stage, not an extension of the build stage. + +## 10. Boundary with `--auto` + +The relationship among `llar test`, `llar test --full`, and `llar test --auto` should be defined as: + +- `llar test` is the default-configuration verification mode. +- `llar test --full` is the exhaustive full-matrix verification mode. +- `llar test --auto` is an extended capability that adds test plan generation on top of them. + +No matter what matrix analysis strategy automatic mode uses in the future, it should not change the following base contract: + +- The final real verification entry point remains `onTest`. +- The way authors write `onTest` should not be dictated by a specific reduction algorithm. +- Users should be able to understand the basic behavior of `llar test` without understanding automatic analysis internals. + +## 11. Known Constraints in the First Stage + +Based on the current implementation, the first stage explicitly accepts the following constraints: + +- The `default options` and `--full` semantics are already part of the design, but the implementation is not fully aligned yet. +- The internal strategy of `--auto` is outside the scope of this document. +- `--auto` does not currently support local patterns. +- `--trace-dump` is a debugging feature for automatic mode, not part of the base product semantics. + +These are not defect statements. They are boundary statements: first make the stable verification entry point solid, then evolve the more complex test planning logic separately. + +## 12. Future Discussion Topics + +This document does not make decisions on these topics yet, but they should be discussed later: + +- Whether missing `onTest` should produce a warning or a stricter requirement. +- Whether the command should emit a more structured test report. + +## 13. Conclusion + +`llar test` is not fundamentally a matrix analysis tool. It is LLAR's unified deliverable verification entry point. + +It should first define one simple but critical thing clearly: + +- Formula authors use `onTest` to describe minimal usability verification. +- Users run `llar test` to execute build plus verification for one concrete combination. + +Only after this base product boundary is clear should automatic reduction, collision analysis, and orthogonality deduction be allowed to evolve without polluting the core semantics of testing. diff --git a/doc/llar-test-product-design.md b/doc/llar-test-product-design.md new file mode 100644 index 0000000..52ea998 --- /dev/null +++ b/doc/llar-test-product-design.md @@ -0,0 +1,276 @@ +# LLAR Test 产品设计 + +## 1. 文档定位 + +本文定义 `llar test` 的基础产品语义,目标是回答三个问题: + +- LLAR 为什么需要一个独立的测试命令。 +- Formula 作者应如何通过 `onTest` 描述产物验证。 +- 用户执行 `llar test` 时,系统应提供什么稳定行为。 + +本文不讨论矩阵正交、碰撞分析、自动缩减策略等 `--auto` 内部算法。这些内容应单独放在测试计划生成的专题设计中。 + +## 2. 问题背景 + +`llar make` 解决的是“能不能把包构建出来”,但这还不等于“交付物已经可用”。 + +对包管理器来说,构建成功之后至少还需要回答下面这些问题: + +- 安装出的二进制能否启动。 +- 安装出的库能否被最小消费者链接或加载。 +- 安装后的头文件、配置文件和运行时布局是否满足最基本使用方式。 + +如果没有一个统一的验证入口,这些检查就会散落在外部脚本、CI job 或人工经验里,无法成为 LLAR 的正式产品能力。 + +因此,LLAR 需要一个一等公民的测试入口: + +- 让 Formula 作者描述“最小可用性验证”。 +- 让用户用统一命令执行验证。 +- 让未来的自动测试计划仍然复用同一套验证逻辑。 + +## 3. 产品目标 + +`llar test` 的目标是: + +1. 在包构建并安装完成后,对交付物执行最小可用性验证。 +2. 为 Formula 提供统一、稳定、语言无关的验证钩子。 +3. 让测试成为 LLAR 正式工作流的一部分,而不是外部附加脚本。 +4. 为后续更复杂的测试计划生成能力提供统一的最终验证入口。 + +## 4. 非目标 + +`llar test` 在第一阶段不解决以下问题: + +- 不负责做矩阵缩减策略设计。 +- 不负责做全量功能测试或上游测试套件镜像。 +- 不承诺覆盖性能、压力、基准或长期稳定性验证。 +- 不试图理解语言语义、ABI 规则或源码内部结构。 + +换句话说,`llar test` 关注的是“消费者视角下,这个安装结果最起码能不能用”,而不是“是否已经完成完整项目验收”。 + +## 5. 核心概念 + +### 5.1 `onBuild` + +`onBuild` 负责构建和安装产物。它回答的是“如何产出交付物”。 + +### 5.2 `onTest` + +`onTest` 负责在构建完成后验证交付物。它回答的是“交付物是否达到最小可用状态”。 + +`onTest` 不参与测试计划生成,也不参与矩阵分析。它是最终验证动作本身。 + +### 5.3 矩阵组合 + +测试始终发生在某一个确定的矩阵组合上。对基础 `llar test` 而言,系统只需要拿到一个确定组合,然后执行: + +- `build` +- `install` +- `onTest` + +至于“该选哪个组合”“要不要减少组合数”,属于另一层能力,不属于本文主题。 + +## 6. 用户与使用场景 + +### 6.1 Formula 作者 + +Formula 作者在配方中实现 `onTest`,描述最小验证动作,例如: + +- 编译一个最小示例并链接安装后的库。 +- 调用安装出的可执行文件并检查返回值。 +- 用解释器加载安装出的扩展模块。 + +### 6.2 包维护者 + +包维护者使用 `llar test` 验证某个模块版本是否真的可交付,而不仅仅是“构建脚本没报错”。 + +### 6.3 CI 或自动化系统 + +CI 可以把 `llar test` 作为标准验证步骤。未来即使引入 `--auto` 或其他测试计划机制,最终真正执行的验证仍应落到 `onTest`。 + +## 7. 对外产品语义 + +### 7.1 命令入口 + +基础命令为: + +```bash +llar test [module@version] +llar test --full [module@version] +``` + +从命令职责上,`llar test` 需要: + +- 解析目标模块。 +- 加载模块及依赖。 +- 选择需要验证的 option 组合。 +- 对每个被选中的组合执行构建与安装。 +- 在每个组合的主模块构建完成后执行 `onTest`。 + +### 7.2 成功语义 + +当以下条件同时满足时,`llar test` 视为成功: + +1. 依赖和目标模块能成功完成构建。 +2. 目标模块安装完成。 +3. 若定义了 `onTest`,则 `onTest` 执行成功。 + +这意味着: + +- 构建失败,测试失败。 +- `onTest` 失败,测试失败。 + +### 7.3 `onTest` 缺省行为 + +当前实现允许 Formula 不定义 `onTest`。在这种情况下: + +- `llar test` 仍会执行构建。 +- 不会有额外的产物验证步骤。 + +这是当前兼容性行为,不代表长期最优产品形态。后续可以单独讨论是否要把“缺失 `onTest`”提升为 warning 或更严格约束。 + +### 7.4 矩阵选择 + +在产品设计上,`llar test` 在 option 维度上有两种明确模式: + +- 未指定 `--full` 时,只运行 default options 对应的测试组合。 +- 指定 `--full` 时,展开并运行全矩阵测试。 + +这条语义的重点是: + +- 普通 `llar test` 面向“默认交付配置是否可用”。 +- `llar test --full` 面向“所有声明的 option 组合都要被真实验证”。 + +当前实现尚未完全对齐这条产品语义,具体落地进度应以实现状态文档为准。 + +### 7.5 输出与失败方式 + +从产品角度,`llar test` 至少应提供以下行为: + +- 成功时返回零退出码。 +- 失败时返回非零退出码。 +- 将构建失败和 `onTest` 失败统一视为测试失败。 + +当前实现还具备以下附加行为: + +- `--verbose` 打开后会输出更详细的 build/test 日志。 +- 若 build 产出了 metadata,会在命令结束时输出该 metadata。 + +## 8. Formula 接口设计 + +### 8.1 接口形式 + +Formula 通过 `onTest` 提供验证逻辑: + +```gox +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + _ = installDir + _ = combo + _ = proj +} +``` + +这里建议 `onTest` 保持与 `onBuild` 一致的参数形态: + +- `ctx` 提供测试阶段真正需要的上下文。 +- `proj` 提供项目与依赖相关信息,以及项目侧文件访问能力。 +- `out` 用于报告测试阶段错误或附加结果。 + +### 8.2 `onTest` 的设计原则 + +`onTest` 应遵循以下原则: + +1. 从消费者视角验证,而不是重复构建过程。 +2. 尽量验证最小闭环,不写成长时间、重资源的集成测试。 +3. 优先使用安装后的产物路径,而不是临时构建目录内部细节。 +4. 对失败给出明确错误,避免“silent false positive”。 + +### 8.3 上下文能力 + +当前 `ctx` / `proj` 的可用能力可以直接理解为这些接口: + +```gox +installDir, err := ctx.outputDir() +depDir, err := ctx.outputDir(dep) +combo := ctx.currentMatrix() +result, ok := ctx.buildResult(dep) +data, err := proj.readFile("testdata/case.txt") +``` + +它们分别对应: + +- 当前模块的安装输出目录。 +- 指定依赖模块的输出目录。 +- 当前生效的矩阵组合。 +- 可选的依赖构建结果与元信息。 +- 项目内测试资源或配置文件的读取能力。 + +这些能力已经足够覆盖大多数“最小消费者验证”场景。 + +## 9. 执行模型 + +基础 `llar test` 的产品执行流可以描述为: + +```mermaid +flowchart TD + A["用户执行 llar test"] --> B["解析模块与版本"] + B --> C["加载模块与依赖"] + C --> D["按选定组合执行 build/install"] + D --> E["对主模块执行 onTest"] + E --> F["返回成功或失败"] +``` + +这里有两个关键边界: + +- `onTest` 只在主模块构建完成后执行。 +- `onTest` 是验证阶段,不是构建阶段的延伸。 + +## 10. 与 `--auto` 的边界 + +`llar test`、`llar test --full` 和 `llar test --auto` 的关系应被定义为: + +- `llar test` 是默认配置验证模式。 +- `llar test --full` 是全矩阵穷举验证模式。 +- `llar test --auto` 是在此基础上增加“测试计划生成”的扩展能力。 + +无论自动模式将来使用何种矩阵分析策略,都不应改变以下基础契约: + +- 最终真实验证入口仍然是 `onTest`。 +- `onTest` 的编写方式不应被特定缩减算法绑架。 +- 用户可以不理解自动分析细节,仍然理解 `llar test` 的基本行为。 + +## 11. 第一阶段已知约束 + +基于当前实现,第一阶段需要明确接受以下约束: + +- `llar test` 的 default options / `--full` 语义已经进入设计,但当前实现尚未完全补齐。 +- `--auto` 的内部策略不属于本文范围。 +- `--auto` 当前还不支持本地 pattern。 +- `--trace-dump` 是自动模式调试能力,不属于基础产品语义。 + +这些约束不是缺陷定义,而是产品边界定义:先把“稳定的验证入口”做实,再把“复杂的测试计划生成”单独演进。 + +## 12. 后续演进问题 + +本文暂不做决策,但建议后续继续讨论: + +- 是否要对缺失 `onTest` 给出 warning 或更严格要求。 +- 是否要输出更结构化的测试报告。 + +## 13. 结论 + +`llar test` 的产品本质不是“矩阵分析工具”,而是 LLAR 的统一交付验证入口。 + +它要先稳定定义一件简单但关键的事情: + +- Formula 作者用 `onTest` 描述最小可用性验证。 +- 用户用 `llar test` 执行一次确定组合上的 build + verify。 + +只有把这个基础产品边界写清楚,后续自动缩减、碰撞分析、正交推导等能力才不会反过来污染最核心的测试语义。 diff --git a/doc/llar-trace-ssa-design.md b/doc/llar-trace-ssa-design.md new file mode 100644 index 0000000..8eb1380 --- /dev/null +++ b/doc/llar-trace-ssa-design.md @@ -0,0 +1,352 @@ +# LLAR Trace SSA 设计方案:面向汇合的路径版本化数据流模型 + +## 1. 目标 + +本文档描述一种用于重构 LLAR Stage 2 的 **SSA-inspired Trace 数据流模型**。 + +核心目标不是把构建图改造成编译器意义上的严格 SSA,而是把当前基于 + +- `seedWrites` +- `needPaths` +- `slicePaths` + +的路径集合启发式,收敛成一个更正规的**路径版本化数据流分析**。 + +这套方案的核心抽象只有三个: + +1. 每次写入形成一个新的**路径版本** +2. 下游读取消费某个路径版本 +3. 多路数据流在图上发生**汇合(join)** + +一旦这三个对象成立,Stage 2 就可以借用大量 SSA / 数据流分析思想: + +- reaching definitions +- def-use / use-def chains +- forward / backward slicing +- join frontier +- RAW / WAW hazard analysis + +--- + +## 2. 这不是“严格 SSA” + +必须先明确边界: + +LLAR 当前可观测到的是黑盒 trace 的**执行节点摘要**,而不是编译器 IR。 + +当前稳定输入主要是: + +- `Argv` +- `Env` +- `Cwd` +- `Inputs` +- `Changes` + +见 [trace.go](/Users/haolan/project/llar/internal/trace/trace.go)。 + +因此本文档定义的 Trace SSA: + +- **不是** 编译器里的 strict SSA +- **不是** syscall 级别的精确版本链 +- **不是** 基于程序 CFG/basic block/dominance frontier 的标准 phi 放置 + +它是一个更轻的模型: + +> **在现有 action graph 之上,对 canonical path 做 action-level 的版本化与数据流分析。** + +也就是说: + +- 一个执行节点对某路径的**最终写入**形成一个新版本 +- 一个执行节点对某路径的读取,绑定到一个保守可判定的上游版本 +- 图上的“汇合”承担类似 phi/join 的分析角色,但不要求严格遵循经典 SSA 语法 + +--- + +## 3. 核心对象 + +### 3.1 执行节点 `E` + +执行节点就是 trace 中的一条原始执行记录。 + +它是**不透明节点**,不依赖动作语义识别。 + +也就是说,这里不区分: + +- compile +- configure +- link +- install + +节点只携带观测属性: + +- `argv` +- `cwd` +- `env` +- `inputs` +- `changes` + +本文档后续所有分析都建立在“执行节点是不透明的”这个前提上。 + +### 3.2 路径版本 `V(path, n)` + +对任意 canonical path `p`,每一次写入都会产生一个新版本: + +- `p@0`:基线起始版本,表示构建开始前外部可见的输入状态 +- `p@1` +- `p@2` +- ... + +这里的版本递增单位是: + +> **执行节点级最终写入** + +不是 syscall 级写入。 + +### 3.3 汇合点 `J` + +构建图不是程序 CFG,但它依然存在大量**数据流汇合**。 + +本文档统一把这类现象称为 **join / 汇合**。 + +有两类汇合: + +1. **多输入汇合** + - 一个执行节点同时消费多个上游版本 + - 例如多个输入共同产生一个新输出 + +2. **版本选择/竞争汇合** + - 同一路径的多个候选版本在某个分析边界上汇合 + - 后续需要判断哪个版本支配 downstream use + +这里不强行使用经典 phi 术语,但**join 在分析中承担与 phi/join 类似的角色**。 + +--- + +## 4. 基本数据流规则 + +### 4.1 定义(Def) + +若执行节点 `E_i` 写入路径 `p`,则产生一个新的版本: + +`Def(E_i, p) = p@i` + +更准确地说,是 `p` 的下一个可用版本。 + +### 4.2 使用(Use) + +若执行节点 `E_j` 读取路径 `p`,则它消费的是某个可达版本: + +`Use(E_j, p) -> p@k` + +这里的 `k` 不是编译器 SSA 那种绝对精确唯一值,而是: + +- 能精确判定时,绑定到唯一版本 +- 不能精确判定时,绑定到保守汇合态 + +### 4.3 传播 + +数据流传播不再是“路径集合 BFS”,而是: + +`changed def -> reaching use -> downstream def` + +也就是: + +1. 某个变化定义版本出现 +2. 找出消费该版本的 use +3. 找出这些 use 所在执行节点产生的新定义 +4. 继续向下传播 + +--- + +## 5. Stage 2 在 Trace SSA 上的重新定义 + +### 5.1 `M(A)`:变更执行区 + +对于 option `A`,`M(A)` 是相对 baseline 发生变化的执行节点集合。 + +变化来源包括: + +- 执行节点内部属性变化 + 例如 `argv/cwd/env` 变化 +- 新执行节点出现 +- 关键输入版本变化 + +### 5.2 `SeedDef(A)`:变化定义 + +`M(A)` 产生的、相对 baseline 真正变化的路径版本集合。 + +它替代当前较粗的 `seedWrites` 概念。 + +### 5.3 `Need(A)`:外部前提版本 + +`M(A)` 及其传播闭包消费的、但并非由 `M(A)` 自己产生的那些版本。 + +它表达的是: + +> 这条变化流要成立,依赖了哪些外部版本输入 + +这比当前“路径前提集合”更接近真正的数据流含义。 + +### 5.4 `Flow(A)`:传播闭包 + +从 `SeedDef(A)` 出发,沿 def-use 链传播所能到达的全部受影响版本。 + +它替代当前的 `slicePaths`。 + +### 5.5 `JoinFrontier(A)`:汇合前沿 + +`A` 的变化流第一次与以下对象汇合的位置: + +- 外部未变化流 +- 另一 option 的变化流 +- 允许的 merge/replay surface + +这个前沿非常重要,因为它决定: + +- 哪里是无害汇合 +- 哪里是危险汇合 +- 哪里应当视为 Stage 2 的分析边界 + +--- + +## 6. 碰撞的重新定义 + +当前 Stage 2 的碰撞仍主要靠路径集合交叉: + +- `seedWrites` overlap +- `slicePaths` vs `needPaths` +- `slicePaths` shared overlap + +在 Trace SSA 下,碰撞应改写成更明确的数据流冒险: + +### 6.1 读后写污染(RAW Hazard) + +若 `A` 的变化版本会在不允许的汇合前,遮蔽 `B` 原本依赖的外部版本,则发生硬碰撞。 + +直观地说: + +- `B` 原本要消费 `p@k` +- 但与 `A` 组合后,`B` 被迫看到 `p@m` +- 且 `p@m` 是由 `A` 的变化流引入的新版本 + +这就是 build-graph 上的 RAW hazard。 + +### 6.2 写写竞争(WAW Hazard) + +如果 `A` 和 `B` 都试图为同一路径产生新的不兼容版本,且这种竞争无法在允许汇合面上被吸收,则发生硬碰撞。 + +### 6.3 允许的汇合 + +若两条变化流只在允许的 surface 上汇合,例如: + +- direct merge surface +- root replay 可吸收的 replay-root 汇合 + +则不视为 Stage 2 硬碰撞。 + +--- + +## 7. 这套模型能借用哪些 SSA / 数据流算法 + +最值得借用的不是“SSA 语法”,而是这些分析算法: + +### 7.1 Reaching Definitions + +回答: + +> 某个 use 实际看到的是哪个定义版本 + +这是整个模型最核心的能力。 + +### 7.2 Def-Use / Use-Def Chains + +回答: + +> 某个变化定义具体影响了哪些 use +> 哪些 use 又产生了哪些 downstream definitions + +### 7.3 Forward / Backward Slice + +回答: + +> 一个变化定义会向前影响到哪里 +> 一个碰撞点/输出点向后依赖了哪些外部版本 + +### 7.4 Join Frontier + +回答: + +> 某条变化流第一次与其他流汇合的边界在哪里 + +### 7.5 Hazard Analysis + +回答: + +> 两条变化流是否在不允许的汇合前发生 RAW / WAW 冲突 + +--- + +## 8. 为什么这比当前方案更强 + +当前 Stage 2 本质上已经在做半数据流分析,但问题是: + +- 传播单位还是路径集合 +- 汇合没有被显式建模 +- `Need` 和 `FS` 的语义还不够统一 +- 很多边界只能靠启发式修补 + +Trace SSA 的提升在于: + +1. **路径不再是单一模糊节点,而是版本链** +2. **读取不再只是“读过这个路径”,而是读过这个路径的某个版本** +3. **波及区不再只是 BFS,而是 def-use 传播** +4. **碰撞不再只是路径重叠,而是危险汇合/版本遮蔽** + +--- + +## 9. 这套模型不解决什么 + +边界必须写清楚: + +1. 不解决 `A+B-only` 幽灵路径 +2. 不解决 Stage 3 的 object/member merge +3. 不直接证明运行时语义正确性 +4. 不替代 tooling / probe / mainline / delivery 的图角色分类 +5. 不要求动作语义识别 + +也就是说: + +> Trace SSA 只负责把 Stage 2 的结构干涉判定正规化为数据流分析问题。 + +--- + +## 10. 与当前实现的关系 + +这套模型不是推翻现有实现,而是为现有 Stage 2 提供一个更正规、可逐步迁移的解释框架。 + +当前代码中的: + +- `seedWrites` +- `needPaths` +- `slicePaths` +- `classifyMutationRoots` +- `propagateForwardSlice` + +都可以理解为这套数据流模型的粗粒度近似。 + +未来若推进 Trace SSA,优先方向不是重写整个 evaluator,而是: + +1. 保留现有 action graph 与图角色分类 +2. 在其上叠一层 path-version / def-use overlay +3. 用该 overlay 重写 Stage 2 的传播与碰撞判定 + +--- + +## 11. 最终定义 + +一句话总结: + +> **LLAR Trace SSA 是一种面向汇合的路径版本化数据流模型。** +> 它把黑盒执行节点的路径写入版本化,把构建图重写为“定义版本、使用关系、汇合边界”组成的数据流图,并在这个图上做传播、前提与碰撞分析。 + +它不是编译器级 strict SSA,但足够让 Stage 2 从经验启发式,收敛成更系统的数据流分析框架。 diff --git a/doc/llar-trace-ssa-v2-design.md b/doc/llar-trace-ssa-v2-design.md new file mode 100644 index 0000000..39cac97 --- /dev/null +++ b/doc/llar-trace-ssa-v2-design.md @@ -0,0 +1,87 @@ +# LLAR Trace SSA 设计方案:局部展开式数据流与单边爆炸半径增强 + +## 1. 设计定位与理论背景 + +本方案旨在重构 LLAR 底层分析引擎(`internal/evaluator/`)的动作图(Action Graph)数据结构。 + +**理论支撑**:在《Build Systems à la Carte》(ICFP 2018)等现代构建系统理论以及数据溯源(Data Provenance)研究中指出,为了避免依赖图出现假连通和循环(Cycles),构建图必须符合 **SSA(静态单赋值)属性**。对于运行时的黑盒 Trace,主流解法是引入**文件版本化(File Versioning)**来切断时序上的混叠。 + +**在 LLAR 中的边界**: +本方案**不涉及**跨图(Cross-Trace)的节点对齐,也**不依赖**全局文件哈希。它被严格限制在“单次构建(单条 Trace)”的内部。 +它的唯一使命是:用带版本的有向无环图(DAG)替代当前含有“重复读写环路”的粗粒度图,从而为现有的“爆炸半径求交集”宏观框架提供一个**绝对精确、无噪音的单边寻路引擎**。 + +--- + +## 2. 核心数据结构的降维与升维 + +当前 `graph.go` 的核心是 `map[string]pathFacts`,它把时间线上对同一路径的所有读写坍缩在了一个字符串节点上,导致了严重的因果混淆。 + +本方案在单边图生成中引入时间维度(升维),建立严格的二分 DAG: + +### 2.1 动作节点 (`ssaActionNode`) +维持现状,代表一次进程执行(包含 PID, Argv, Cwd 等特征)。但其边属性发生改变: +* **`reads`**:不再是字符串切片,而是该动作读取的 **SSA 版本节点 ID** 列表。 +* **`writes`**:该动作写出的、全新生成的 **SSA 版本节点 ID** 列表。 + +### 2.2 版本化产物节点 (`ssaArtifactNode`) +代表一个文件在特定时间切片下的不可变状态。 +* **`id`**:图内全局递增的整数索引。 +* **`path`**:规范化后的物理路径(如 `$BUILD/config.h`)。 +* **`writerID`**:产生该版本的动作索引。若为外部输入(源码等),记为 `-1`。 +* **`readerIDs`**:后续读取了该**特定版本**的动作索引列表。 + +--- + +## 3. 构图算法:时间线单向展开 + +不需要复杂的 `isTooling` 或 `isProbe` 启发式规则。通过单遍扫描 Trace 记录,自动生成严格无环的 DAG: + +1. **环境状态机**:维护一个映射表 `LatestVersion[string]int`,记录每个路径当前的最新版本 ID。 +2. **处理读 (Use)**: + * 当动作 $A$ 读取路径 $P$ 时,查找 `LatestVersion[P]` 得到最新版本 ID $V_k$。 + * 若未找到,则隐式创建一个 `writerID` 为 -1 的初始版本节点。 + * 建立输入边:将 $V_k$ 关联到动作 $A$ 的读取列表中。 +3. **处理写 (Def) - 核心规则**: + * 只要动作 $A$ 对路径 $P$ 发生了写入,**无条件**分配一个全新的版本节点 $V_{new}$,其 `writerID` 指向 $A$。 + * 建立输出边:动作 $A$ 指向 $V_{new}$。 + * **更新状态机**:`LatestVersion[P] = V_{new}`。 + +**拓扑结果**:即使探针脚本反复覆写 `config.h` 产生 `@1, @2, @3`,读取 `@1` 的旧动作和读取 `@3` 的新动作在图上也会被完美分配到不连通的平行分支上。 + +--- + +## 4. 增强版爆炸半径提取算法 + +在无环的 SSA 图上,计算单边选项 $O_X$ 的影响($M(X)$, $FS(X)$, $Need(X)$)变得极其确定。 + +### 4.1 提取直接变更源 $M(X)$ +**不动摇现状**:通过比对 $O_X$ 与基线 $O_0$ 的 `StructureKey`(动作骨架),找出 $O_X$ 中发生变异或新增的动作索引集合。这一步纯看动作基因,不依赖图结构。 + +### 4.2 计算波及区 $FS(X)$ (前向寻路) +1. 把 $M(X)$ 中所有动作写出的**新版本节点 ID** 放入波及队列。 +2. 顺着 DAG 的有向边(写出版本 -> 读取该版本的下游动作 -> 该下游新写出的版本)做 BFS 遍历。 +3. **脱壳降维**:将遍历到的所有受波及版本节点,剥离其版本号,只把纯粹的**路径字符串**放入最终的 $FS(X)$ 集合中。 +* **抗噪优势**:未被 $M(X)$ 波及的探针覆写,在拓扑上属于孤立的子树。寻路算法物理上不可能走到那里,彻底杜绝了探针假波及。 + +### 4.3 提取依赖底座 $Need(X)$ (血统追溯) +取代原设计 `6.4.2.2` 节中为了剔除“基线兄弟文件”而进行的跨图 Diff 比对。 + +1. 遍历所有属于波及区(含起火点本身)的动作 $A_i$。 +2. 检查 $A_i$ 读取的每一个版本节点 $V_{in}$。 +3. **查血统**:向上追溯产生 $V_{in}$ 的 `writerID`。 + * 如果 `writerID` 属于波及区动作:说明这是本次改动“自产自销”的中间产物,忽略。 + * 如果 `writerID` **不属于**波及区动作(即基线老动作或外部输入):说明它是维系火势的**外部干柴**。 +4. **脱壳降维**:将这些外部干柴版本节点的**路径字符串**加入 $Need(X)$ 集合。 + +--- + +## 5. Stage 2 终极干涉判定 + +单次 Trace 内的 SSA 历史使命至此结束。系统拿着由极其精确的寻路算法跑出来的两份**纯字符串集合**($FS$ 和 $Need$),回到原有的宏观碰撞逻辑。 + +对于选项 A 和选项 B,在全局路径坐标系下计算: +$$ FS(A) \cap Need(B) \neq \emptyset $$ +若有交集且落在不允许合并的中间产物上,则宣告硬碰撞。 + +## 6. 总结 +“局部展开式 Trace SSA”用极其轻量的数据结构变更,在不引入跨图对齐噩梦和哈希计算的前提下,将 LLAR 从启发式推断的泥潭中彻底解救出来。它用纯粹的拓扑学物理隔离了黑盒 Trace 中的重复读写噪音,是当前“单边爆炸半径”理论的一块完美基石。 \ No newline at end of file diff --git a/doc/llar-trace-ssa-v3-design.md b/doc/llar-trace-ssa-v3-design.md new file mode 100644 index 0000000..137057d --- /dev/null +++ b/doc/llar-trace-ssa-v3-design.md @@ -0,0 +1,111 @@ +# LLAR Trace SSA 设计方案:带生命周期的时序展开数据流模型 (V3 详述版) + +## 1. 理论定位与痛点回顾 + +本方案旨在为 LLAR 的底层动作图确立最终的**数据流拓扑模型**。 + +**当前痛点:空间坍缩** +在旧模型中,只要路径名相同(如 `config.h`),所有的读写动作全都会连在同一个节点上。 +> **灾难案例**: +> 1. 业务代码写 `config.h` +> 2. CMake 探针为了测试环境,用废代码**覆盖**写了 `config.h` +> 3. CMake 探针编译废代码 +> +> 在旧图里,业务代码和探针由于“共用”了 `config.h` 这个节点,图里就出现了死环。导致我们在算业务代码的波及区时,顺着边爬到了探针那里,产生了**假连通(污染)**。 + +V3 方案通过引入**“时间展开”**、**“同动作写折叠”**和**“墓碑机制”**三大核心概念,彻底粉碎这种空间坍缩,将构建过程重塑为一张极其清晰的有向无环二分图 (Bipartite DAG)。 + +--- + +## 2. 核心领域概念 + +在这个模型中,“文件路径”不再是图里的节点。图只由两类实体构成: + +### 2.1 动作节点 (Action Node) +代表单次进程执行(一条 Trace 记录),是数据的加工器。 +* **Use (读)**:动作对某一个“历史状态”的观察。 +* **Def (写)**:动作对世界施加影响后,创造的新状态。 + +### 2.2 版本化状态节点 (Versioned State Node) +代表某个路径在**绝对时间线上的一个极短切片内的状态**。它是数据流的真正载体。 +* **空间**:对应真实的脱壳路径(如 `$BUILD/config.h`)。 +* **时间**:第几代版本(如 `#1`, `#2`),由谁创造。 +* **极性**:是“实体内容”,还是“虚无状态(文件不存在)”。 + +--- + +## 3. 时序展开与构图法则 (附案例解释) + +我们抛弃所有的“猜探针”启发式代码,仅靠单遍扫描时间线,严格按以下三条因果律连边。 + +### 3.1 读法则:快照绑定 (Snapshot Binding) +**法则**:动作读取时,只能绑定到该路径在当前时间点的**最新状态节点**。 + +> **案例解释**: +> 动作 A 读了 `main.c`。系统发现 `main.c` 之前没有任何动作动过它。 +> 此时系统隐式生成一个 `[main.c#0]`(第 0 代,代表外部基线前提),让动作 A 连向它。 + +### 3.2 写法则:跨动作裂变与同动作折叠 +当动作对路径写入时,状态必须演进。但这里有一个极易导致内存爆炸的工程陷阱。 + +**法则 A:跨动作裂变(时间展开的核心)** +如果当前最新状态是由**其他动作**产生的,本次写入就是一次“覆盖”。必须裂变出一个全新节点。 + +> **案例解释(解决探针污染)**: +> 1. 业务动作写 `config.h` $\rightarrow$ 裂变产生 `[config.h#1]`。 +> 2. 探针写 `config.h` $\rightarrow$ 发现是被覆盖了,裂变产生全新节点 `[config.h#2]`。 +> 3. 探针后续去读 $\rightarrow$ 它只能读到最新的 `[config.h#2]`。 +> **结果**:业务的 `#1` 和探针的 `#2` 在拓扑上**物理断开,完全绝缘**。波及区分析再也不会迷路了! + +**法则 B:同动作折叠 (Action-level Folding)** +如果当前最新状态**就是当前动作自己**刚刚产生的,则**不发生裂变**,直接复用当前节点。 + +> **案例解释(解决内存爆炸)**: +> `gcc` 编译一个大文件时,可能会在极短时间内对底层的临时文件调用 1000 次 `write()` 系统调用。 +> 如果严格按“逢写必生新节点”,图里会出现 `#1` 到 `#1000` 共一千个节点! +> 引入折叠法则后,这 1000 次 `write()` 全都归属于同一个 `gcc` 动作,它们只会产生唯一的一个节点 `[temp.s#1]`。大幅压缩了图的体积。 + +### 3.3 销毁法则:虚无状态显式建模 (Tombstone) +构建系统经常清理临时文件(`rm -f /tmp/test`)。删除不是结束,而是创造了新的环境状态。 + +**法则**:删除操作等同于写入,会裂变出新节点,但该节点被标记为**“墓碑 (Tombstone)”**。 + +> **案例解释(解决幽灵依赖)**: +> 1. 动作 A 写入 `/tmp/a` $\rightarrow$ 产生 `[/tmp/a#1]`。 +> 2. 动作 B 把 `/tmp/a` 删了 $\rightarrow$ **产生墓碑节点 `[/tmp/a#2 (墓碑)]`**。 +> 3. 动作 C (探针) 用 `stat` 命令检查 `/tmp/a` 在不在 $\rightarrow$ 连向 `[/tmp/a#2 (墓碑)]`。 +> **结果**:动作 C 连向的是墓碑,证明它依赖的是“文件不存在的客观事实”。如果没有墓碑机制,动作 C 就会诡异地连向 `#1`,导致系统误认为动作 C 依赖了动作 A 产生的代码内容! + +--- + +## 4. 结构干涉的降维提取定理 + +建图完成后,我们得到了一张纯净无环的 DAG。接下来如何计算 Option 的外部前提 $Need$ 和波及区 $FS$ 呢? + +假设 Option $X$ 的起火动作集合为 **$M(X)$**。 + +### 4.1 前向可达子图 $Reach\_F(X)$ +从 $M(X)$ 出发,顺着图里的箭头(动作 $\rightarrow$ 产出状态 $\rightarrow$ 下游动作)往前走,把能走到的动作都收集起来。 +*这就像墨水顺着水管流,因为探针在平行的管子里,墨水根本流不过去。* + +### 4.2 提取波及区表面 $FS(X)$ (体积) +把 $Reach\_F(X)$ 里所有动作制造出来的**状态节点(含墓碑)**收集起来,**脱去版本号**,只留文件路径。 +这就得到了纯字符串集合 $FS(X)$(例如 `{"$BUILD/config.h", "$BUILD/main.o"}`)。 + +### 4.3 提取依赖底座 $Need(X)$ (神仙剪枝) +怎么知道哪些是这个 Option 必须的“外部干柴”? +把 $Reach\_F(X)$ 里所有动作**读取过的输入节点**找出来,做个简单的血统判断: +* **如果这个状态是波及区内部自己产的**:说明这是内部消化(比如自己写了 `.s` 又自己读了 `.s`),丢弃。 +* **如果这个状态是波及区外面(或者是基线 0 代)产的**:说明这就是维系火势的外部前提。脱去版本号保留路径,就得到了 $Need(X)$。 + +--- + +## 5. 宏观碰撞判定 + +至此,单次 Trace 内极其复杂的时序缠绕,被完美降维成了两个极其干净的纯字符串集合:$FS(A)$ 和 $Need(A)$。 + +拿着它们回到宏观调度层。当我们需要判断 Option A 和 Option B 组合是否会爆炸时,只需进行一行优雅的集合运算: + +$$ FS(A) \cap Need(B) \neq \emptyset $$ + +如果 A 的波及区(它改变的物理世界),击穿了 B 的依赖底座(B 所期待的外部环境前提),这就是确凿无疑的**硬碰撞 (Hard Collision)**。整个过程**没有任何启发式猜测,全部基于确定的数学拓扑**。 \ No newline at end of file diff --git a/doc/llar-trace-ssa-v4-design.md b/doc/llar-trace-ssa-v4-design.md new file mode 100644 index 0000000..0b29dfc --- /dev/null +++ b/doc/llar-trace-ssa-v4-design.md @@ -0,0 +1,450 @@ +# LLAR Trace SSA 设计方案 V4:保守的双状态版本化模型 + +## 1. 目标 + +本文档给出一版新的 Trace SSA 方案,用于重构 LLAR 的 Stage 2。 + +这版 V4 明确参考了 V3 的核心方向,但做了两类收敛: + +1. 保留 V3 的极简骨架 + - 不透明执行节点 + - 写入版本化 + - 墓碑 + - 先建 SSA 图,再做语义打标 + +2. 补上 V3 没写透的现实边界 + - 目录项成员状态必须显式建模 + - 当前 trace 对目录枚举的观测不完整 + - probe/tooling 角色分类仍然需要,但必须发生在 SSA 图之上 + +一句话概括: + +> LLAR Stage 2 要从“路径集合传播”升级为“执行节点驱动的状态版本传播”,但对象只保留三类:执行节点、文件状态、目录项成员状态。 + +--- + +## 2. 设计边界 + +V4 必须先明确自己**不是**什么: + +- 不是编译器意义上的严格 SSA +- 不是 syscall 级逐条写入的强版本链 +- 不是基于动作语义识别的 IR +- 不是要消灭所有启发式 + +V4 只做一件事: + +> 在当前黑盒 trace 之上,为“状态”建立版本,并用这些版本之间的 def-use 关系重写 Stage 2 的传播和碰撞判定。 + +这里的“状态”只包括两种: + +1. 文件内容状态 +2. 目录项成员状态 + +--- + +## 3. 基本对象 + +V4 只保留三类对象。 + +### 3.1 执行节点 `E` + +执行节点对应 trace 中的一条原始执行记录。 + +节点保持不透明,不识别它是不是: + +- compile +- configure +- link +- install + +它只携带黑盒观测属性,例如: + +- `argv` +- `cwd` +- `env` +- `inputs` +- `changes` +- 可选事件流 + +### 3.2 文件状态版本 `F(path, n)` + +表示文件路径 `path` 的第 `n` 个可见状态版本。 + +例如: + +- `F($BUILD/config.h, 0)` +- `F($BUILD/config.h, 1)` +- `F($BUILD/libexpat.a, 1)` + +如果文件被删除,则产生一个墓碑版本: + +- `F(path, n, tombstone=true)` + +墓碑表示“该文件当前不存在”这一事实。 + +### 3.3 目录项成员状态版本 `M(dir, name, n)` + +表示目录 `dir` 下名字为 `name` 的目录项成员状态在第 `n` 次演进后的版本。 + +这里建模的不是整个目录的全量成员集合,而是某个具体目录项名的存在性/成员关系: + +- `name` 当前是否存在于 `dir` 中 +- `name` 是否被创建 +- `name` 是否被删除 +- `name` 是否通过 rename 进入或离开该目录 + +例如: + +- `M($BUILD/tmp, a.o, 0)` +- `M($BUILD/tmp, a.o, 1)` + +这一步是 V4 相对 V3 的核心补强: + +> 目录不能被当成普通文件路径,也不能被完全忽略;但也不能把“整个目录状态”粗粒度挂到每一次子路径访问上。 + +--- + +## 4. 版本演进规则 + +### 4.1 文件状态演进 + +如果执行节点写入文件 `p`,则产生新的文件状态版本: + +- `F(p, k) -> F(p, k+1)` + +如果执行节点删除文件 `p`,则产生新的墓碑版本: + +- `F(p, k) -> F(p, k+1, tombstone=true)` + +第一版实现中,版本粒度不要求细到 syscall 级,而是以: + +> **执行节点级最终写入** + +作为稳定单位。 + +### 4.2 目录项成员状态演进 + +如果目录 `d` 下名字为 `x` 的成员关系发生变化,则产生新的成员状态版本: + +- 创建 `d/x` +- 删除 `d/x` +- `rename` 使 `x` 进入或离开 `d` + +都会推动: + +- `M(d, x, k) -> M(d, x, k+1)` + +注意: + +- 仅覆盖 `d/x` 的文件内容,而成员关系不变时,不应推动 `M(d, x, k)` 递增。 +- 因此文件状态和目录项成员状态必须严格分开。 + +### 4.3 读取绑定 + +一个执行节点读取路径时,绑定到当前可达的最新相关状态。 + +分两类: + +1. 读取具体文件 `d/x` + - 至少绑定到 `F(d/x, latest)` + - 同时绑定到 `M(d, x, latest)` + +2. 行为依赖整个目录的枚举结果 + - 这类读取不是通过普通文件访问隐式注入的 + - 它必须依赖显式目录枚举观测,或进入 observation-incomplete 回退 + +V4 不引入显式 `join` 节点。 +当一个执行节点消费多个上游状态版本时,这种“多来源汇合”天然体现在它的多输入依赖上,不需要再额外造图对象。 + +--- + +## 5. 正确的流水线:先建 SSA 图,再做角色打标 + +V4 必须避免一个关键陷阱: + +> 不能先在旧的坍缩 Action Graph 上做 `tooling/probe/mainline` 过滤,再拿过滤后的节点去建 Trace SSA。 + +原因是: + +- 旧图里的很多假连通和探针污染,本来就是因为同路径没有版本化而产生的 +- 如果先在这种坍缩图上打标签,那么标签本身就可能被污染 +- 再用这些污染标签过滤节点,就会形成因果倒置:用假图裁剪真图 + +因此,V4 的正确流水线是: + +### 第 0 层:观测归一化层 + +这一层只做无语义归一化,例如: + +- 路径 canonicalization +- `cwd/env/argv` 规范化 +- `pid/parent` 关联 +- scope token 归一化 +- 基于 syscall 结果的显式文件/目录成员变化采集 + +这一层不能做: + +- `tooling/probe/mainline` 判定 +- 基于旧图的节点裁剪 +- 提前丢弃疑似 probe 子图 + +### 第 1 层:原始 Trace SSA 图构建层 + +在**不做角色过滤**的前提下,对全部观测到的执行节点和状态建原始 Trace SSA 图。 + +这一层直接生成: + +- 执行节点 `E` +- 文件状态版本 `F` +- 目录项成员状态版本 `M` +- `def -> use -> def` 关系 + +目标是: + +> 先把“同路径重名导致的空间坍缩”问题消掉,得到一张干净的原始数据流图。 + +### 第 2 层:SSA 图上的角色打标层 + +只有在原始 Trace SSA 图建立之后,才允许做角色分类: + +- mainline +- probe/tooling +- delivery/control-plane + +此时角色分类的准确率会更高,因为: + +- probe 和业务不再因共用未版本化路径而假连通 +- 同一路径的不同历史版本已经物理分离 +- sidecar / probe / 主线的状态传播可以在版本图上真实区分 + +### 第 3 层:主线投影与分析层 + +在 SSA 图上完成角色打标后,再剥离: + +- probe/tooling +- delivery/control-plane + +得到主线投影图,然后在投影图上做: + +- reaching definitions +- def-use / use-def +- forward slice +- backward slice +- frontier +- RAW / WAW hazard + +--- + +## 6. 目录问题:V4 的现实补丁 + +这是 V4 最关键的现实修正。 + +当前 LLAR 的 trace 事实是: + +- 抓到了 `open/openat/openat2/creat/rename/unlink/mkdir/symlink/...` +- 没有 `getdents/getdents64` +- 没有 `stat*` +- 失败的 `open/openat/openat2/creat` 当前不会进入事件流 + +因此,目录依赖不能假装被完整观测到了。 + +V4 对目录依赖采用三层策略,但先明确一个核心约束: + +> **普通子路径访问只绑定“该目录项名的成员状态” `M(dir, name, latest)`,绝不自动绑定“整个目录的全量状态”。** + +否则像公共 `$BUILD/obj` 目录下的并发编译,会被错误地超级串行化。 + +### 6.1 强观测 + +如果底层明确给出: + +- 成功访问具体文件 +- `rename/unlink/mkdir` 等成员变化事件 + +则直接建: + +- 文件状态边 +- 目录项成员状态边 + +这部分是强证据。 + +### 6.2 保守注入 + +在缺少 `getdents` 的情况下,V4 只引入一条**局部**保守规则: + +> 当执行节点访问子路径 `d/x` 时,除绑定 `F(d/x, latest)` 外,也额外绑定 `M(d, x, latest)`。 + +这不是说“我们精确知道它枚举了目录”,而是说: + +> 任何对目录成员 `x` 的可见访问,都至少依赖 `x` 在目录 `d` 中的成员关系存在性。 + +类似地: + +> 当执行节点创建、删除、rename 子路径 `d/x` 时,除更新文件状态外,也同时更新 `M(d, x, latest)`。 + +这条规则的意义是: + +- 让“读取/修改具体目录项 `d/x`”与该目录项的存在性正确连通 +- 避免把同目录下其他无关名字强行串进依赖链 + +它**不能**用来恢复“整个目录枚举”的依赖。 + +### 6.3 观测不足回退 + +即使做了保守注入,当前 trace 仍然无法 sound 处理这些场景: + +- 纯目录扫描后不访问具体成员 +- 空匹配和非空匹配之间的行为分叉 +- 只通过 `stat` 或失败 `open` 体现的探测 + +对这类场景,V4 不宣称完美恢复,而是要求: + +> 一旦分析发现依赖只能通过“未观测目录行为”解释,则标记为 observation-incomplete,并保守回退。 + +--- + +## 7. Stage 2 的重新定义 + +### 7.1 `M(A)`:变更执行区 + +对于 option `A`,`M(A)` 是相对 baseline 发生变化的执行节点集合。 + +变化来源包括: + +- 节点新增或消失 +- 节点属性变化 +- 节点绑定到的上游状态版本变化 + +### 7.2 `SeedDef(A)`:变化定义 + +`M(A)` 产生的、相对 baseline 真正变化的状态版本集合。 + +这里的状态包括: + +- 文件状态版本 `F` +- 目录项成员状态版本 `M` + +### 7.3 `Need(A)`:外部前提 + +`M(A)` 以及其传播闭包中所消费、但不是由闭包内部定义的那些状态版本。 + +它表达的是: + +> A 的变化流成立所依赖的外部状态底座。 + +### 7.4 `Flow(A)`:传播闭包 + +从 `SeedDef(A)` 出发,沿: + +- `def -> use` +- `use -> downstream def` + +传播得到的全部受影响状态版本集合。 + +### 7.5 `Frontier(A)`:传播边界 + +`A` 的变化流第一次遇到: + +- 外部未变化状态 +- 另一 option 的变化流 +- 允许的 merge/replay surface + +的位置,称为 `Frontier(A)`。 + +这里不再引入显式 `join` 对象;frontier 只是从 def-use 图中提取出的边界性质。 + +--- + +## 8. 碰撞的重新定义 + +V4 下,碰撞不再是简单的路径集合交集,而是状态版本上的数据冒险。 + +### 8.1 RAW Hazard + +若 `A` 的变化状态版本会在不允许的边界前,替代 `B` 原本依赖的外部状态版本,则发生硬碰撞。 + +### 8.2 WAW Hazard + +若 `A` 和 `B` 都试图为同一状态单元产生新的不兼容版本,且这种竞争不能在允许 surface 上被吸收,则发生硬碰撞。 + +### 8.3 允许的汇合面 + +若两条变化流只在允许的 surface 上汇合,例如: + +- Stage 3 direct merge surface +- root replay 可吸收的 replay-root 汇合 + +则不记为 Stage 2 硬碰撞。 + +--- + +## 9. 最值得借用的 SSA / 数据流算法 + +V4 最值得借的不是经典 SSA 语法,而是这些分析算法: + +1. **Reaching Definitions** + - 回答某个 use 依赖哪个状态版本 + +2. **Def-Use / Use-Def Chains** + - 从变化定义找到直接受影响 use 和新的 def + +3. **Forward Slice** + - 计算 `Flow(A)` + +4. **Backward Slice** + - 计算 `Need(A)` + +5. **Frontier 提取** + - 识别变化流第一次遇到外部流或另一变化流的边界 + +6. **RAW / WAW Hazard Analysis** + - 直接用于碰撞判定 + +--- + +## 10. 为什么这比当前 Stage 2 更好 + +当前 Stage 2 本质上已经是“半数据流分析”: + +- `seedWrites` +- `needPaths` +- `slicePaths` + +它的问题是: + +- 传播单位还是粗粒度路径集合 +- 同一路径多次覆写没有显式版本 +- 目录成员关系没有一等状态 +- 旧图上的角色分类会被坍缩图污染 + +V4 的提升在于: + +1. 把“路径被改了”改写成“状态版本发生变化” +2. 把“路径 BFS”改写成“def-use 传播” +3. 把“路径交集碰撞”改写成“版本冒险碰撞” +4. 把目录依赖从隐含假设提升成显式的目录项成员状态和保守边界 +5. 把角色分类从旧坍缩图迁移到 SSA 图之后 + +--- + +## 11. 边界与不解决的问题 + +V4 必须明确承认以下边界: + +1. 它不是严格编译器 SSA。 +2. 它不消灭 probe/tooling/mainline/delivery 的角色分类需求。 +3. 它不解决 Stage 3 的 object merge。 +4. 它不覆盖 `A+B-only` 幽灵路径。 +5. 它对目录依赖的 soundness 仍受底层 trace 能力限制。 + +更准确地说: + +> V4 的目标不是“完全证明构建语义”,而是把 Stage 2 收敛成一个更正规的、双状态版本化的保守数据流分析。 + +--- + +## 12. 一句话总结 + +> **LLAR Trace SSA V4 是一套保守的双状态版本化模型:** +> 它把黑盒执行节点上的文件内容状态和目录项成员状态同时版本化,在不引入动作语义识别和显式 join 节点的前提下,用 def-use、切片、frontier 和 hazard 分析重写 Stage 2,并在目录枚举观测不足处显式保守回退。 diff --git a/doc/llar-trace-ssa-v5-design.md b/doc/llar-trace-ssa-v5-design.md new file mode 100644 index 0000000..d035137 --- /dev/null +++ b/doc/llar-trace-ssa-v5-design.md @@ -0,0 +1,609 @@ +# LLAR Trace SSA 设计方案 V5:面向当前项目证据边界的路径状态 SSA + +## 1. 设计目标 + +本文档给出一版**适合当前 LLAR 项目现状**的 Trace SSA 方案。 + +目标不是追求“最纯”的 SSA 理论形式,而是基于当前仓库已经稳定拥有的观测能力,重构 Stage 2 的核心分析模型。 + +这版设计刻意抛开此前争议最大的几个点: + +- 不引入动作语义识别 +- 不引入显式 `join` 节点 +- 不把目录成员关系硬塞进第一版核心模型 +- 不宣称当前 trace 已经足够恢复所有隐式依赖 + +一句话概括: + +> **V5 用不透明执行节点和路径状态版本重写 Stage 2。** +> 它只分析当前 trace 明确观测到的文件级状态传播,把目录枚举等未观测能力明确降级为“能力缺口”,而不是伪精确建模。 + +--- + +## 2. 为什么这版更适合当前项目 + +当前项目的关键事实是: + +1. trace 层稳定暴露的是: + - `Record{Argv, Env, Cwd, Inputs, Changes}` + - 可选 `Event` +2. 当前 `strace` 捕获的 syscall 集包括: + - `execve/execveat` + - `chdir` + - `open/openat/openat2/creat` + - `rename/renameat/renameat2` + - `unlink/unlinkat` + - `mkdir/mkdirat` + - `symlink/symlinkat` + - `clone/fork/vfork` +3. 当前没有: + - `getdents/getdents64` + - `stat*` + - 失败 `open` 的稳定事件保留 +4. 当前 `graph_input.go` 会把事件重新折叠回**执行节点级**的读写摘要。 + +因此,适合本项目的方案必须接受这三个现实: + +### 2.1 分析粒度应是执行节点级 + +不能设计成 syscall 级严格 SSA,因为当前 Stage 2 真正稳定消费的是执行节点级汇总,而不是原始 syscall IR。 + +### 2.2 核心对象应是“路径状态”,不是“目录语义” + +当前 trace 对文件路径的显式读写证据足够稳定;对目录枚举则不够。 + +所以第一版核心模型应建立在**路径状态版本**之上,而不是建立在半猜测的目录集合语义之上。 + +### 2.3 未观测能力必须显式承认 + +如果底层没抓到目录枚举,就不能靠设计文本假装已经恢复了这类依赖。 + +--- + +## 3. 核心对象 + +V5 只保留两类核心对象。 + +### 3.1 执行节点 `E` + +执行节点是 trace 中的一条原始执行记录。 + +节点保持不透明,不识别它是不是: + +- compile +- configure +- link +- install + +它只携带黑盒属性: + +- `argv` +- `cwd` +- `env` +- `inputs` +- `changes` +- 可选事件序号/父子关系 + +这里“不透明”的精确定义是: + +> **SSA 核心图不会把节点提升成 compile/configure/link/install 之类的内建语义类型。** + +但这不意味着后续分析器不能读取节点附带的黑盒属性。 + +更准确地说: + +- `E` 作为图对象是无语义类型的 +- probe/tooling/mainline 等角色分类器可以在图构建完成后,读取 `argv/cwd/env/inputs/changes` 等外部特征做旁路打标 +- 这些语义标签是**叠加分析结果**,不是核心图对象本体的一部分 + +### 3.2 路径状态版本 `P(path, n)` + +表示路径 `path` 在第 `n` 次定义后的状态版本。 + +例如: + +- `P($BUILD/config.h, 0)` +- `P($BUILD/config.h, 1)` +- `P($BUILD/libexpat.a, 1)` + +如果路径被删除,则产生一个墓碑版本: + +- `P(path, n, tombstone=true)` + +它表示: + +> 该路径在当前构建状态下不存在。 + +这版设计**不额外引入**: + +- 显式 `join` 节点 +- 目录项成员状态 `M(dir,name)` +- 整目录集合状态 `D(dir)` + +原因不是这些概念永远没价值,而是: + +> 在当前项目的证据边界下,把它们塞进核心模型只会制造过强前提和伪精确。 + +--- + +## 4. 基本图结构 + +V5 的图是一张由执行节点和路径状态版本构成的二分图: + +- `P -> E`:执行节点读取某个路径状态版本 +- `E -> P`:执行节点写出某个新的路径状态版本 + +这已经足够表达: + +- 写入版本化 +- 读绑定到具体历史状态 +- 同路径多次覆写后的隔离 + +不需要额外的 `join` 节点。 + +当一个执行节点读取多个路径状态时,这种“多来源汇合”天然体现在它的多输入边上。 + +--- + +## 5. 因果顺序与版本可达性 + +V5 不依赖“全局线性 latest”假设。 + +当前项目真实拥有的是: + +- 执行节点顺序摘要 +- `pid/parent` 关系 +- 可选事件顺序 + +但这不足以在并发构建下恢复一个严格的全局总时序。 + +因此,V5 采用的是: + +> **保守因果偏序(conservative causal partial order)** + +而不是 wall-clock total order。 + +### 5.1 可比较定义 + +若两个路径状态版本之间存在当前证据足以支撑的先后约束,则称它们在偏序下可比较。 + +例如: + +- 同一执行节点内部的最终输出 +- 同一 pid 的稳定后继关系 +- 由已观测读写链明确诱导出的先后关系 + +### 5.2 不可比较定义 + +若两个对同一路径的定义在当前证据下无法确定先后,则它们不是“谁更新”的问题,而是: + +> **并发或证据不足下的歧义定义集合** + +### 5.3 Reaching-Def 绑定 + +因此,读取绑定规则应表述为: + +> 一个执行节点读取路径 `p` 时,绑定到在当前保守因果偏序下对它可达的 reaching-def 集合。 + +如果该集合唯一,则可视为唯一 reaching-def。 +如果该集合包含多个不可比较的候选定义,则该读取是 **ambiguous read**,分析必须显式携带这种歧义,并在必要时保守回退。 + +--- + +## 6. 版本演进规则 + +### 5.1 初始版本 + +每个被读取但尚未被图内任何执行节点定义过的路径,隐式拥有一个基线版本: + +- `P(path, 0)` + +它代表构建开始前外部可见的前提状态。 + +### 5.2 写入产生新版本 + +如果执行节点 `E_i` 写入路径 `p`,则产生一个新版本: + +- `P(p, k) -> E_i -> P(p, k+1)` + +这里的 `k+1` 不是 syscall 级计数,而是**执行节点级最终定义版本**。 + +### 5.3 同执行节点写折叠 + +同一个执行节点内,对同一路径的多次底层写入,不在图里继续裂变。 + +它们在 Stage 2 图中折叠成该执行节点对该路径的一个最终输出版本。 + +这一步是必须的,因为当前仓库的事件最终也会折叠回执行节点级摘要。 + +### 5.4 删除产生墓碑版本 + +如果执行节点删除路径 `p`,则定义: + +- `P(p, k+1, tombstone=true)` + +墓碑不是“无节点”,而是一个显式的状态版本。 + +这样: + +- 后续对该路径的观测 +- 与 baseline 的对比 +- “不存在”这一事实本身 + +都能进入数据流分析。 + +--- + +## 7. 读取绑定规则 + +一个执行节点读取路径 `p` 时,绑定到该路径当前可达的最新版本。 + +这条规则是 V5 的核心。 + +### 6.1 成功文件读取 + +如果 trace 明确观测到对路径 `p` 的成功读取,则执行节点绑定: + +- `ReachDef(p, E)` + +这里的 `ReachDef(p, E)` 指的是: + +- 在当前保守因果偏序下 +- 对执行节点 `E` 可达的 +- 路径 `p` 的 reaching-def 集合 + +若集合唯一,可简写为唯一 `P(p, latest)`;若不唯一,则必须保留歧义。 + +### 6.2 删除后的读取 + +如果未来 trace 能稳定观测失败探测,则可绑定到墓碑版本。 + +但在当前项目里: + +- 失败 `open` 不稳定保留 +- `stat*` 也没有抓取 + +所以 V5 不把“失败探测 -> 墓碑读取”写成当前 guaranteed 能力,只保留为未来能力扩展位。 + +### 6.3 不做隐式目录注入 + +V5 明确拒绝在第一版核心模型里引入: + +- “读 `dir/x` 时,自动绑定整个目录状态” +- “读 `dir/x` 时,再额外绑定某个目录成员状态” + +原因是这两类做法都会在当前项目里引入伪精确风险。 + +更具体地说: + +- 绑定整目录状态会把公共目录变成超级串行化黑洞 +- 绑定目录成员状态在当前模型下又与路径状态+tombstone 高度冗余 + +所以 V5 第一版核心模型只有: + +> **读具体路径,只绑定该具体路径的状态版本。** + +--- + +## 8. 目录依赖在 V5 里的处理方式 + +V5 对目录问题采取一个非常现实的立场: + +### 7.1 当前核心模型不直接表达“目录集合语义” + +也就是说: + +- `file(GLOB ...)` +- `getdents/readdir` +- “目录是否为空” + +这类依赖,当前不进入 V5 的第一版核心图模型。 + +### 8.2 这不是忽略问题,而是显式承认能力缺口 + +V5 认为: + +> 当前 trace 没有目录枚举证据时,最正确的做法不是发明一个伪精确模型,而是明确承认这里是观测盲区。 + +这里必须再加一条更严格的声明: + +> **仅凭当前图本身,分析器不能“自动发现”缺失的目录枚举边。** + +因此,V5 不把 `observation-incomplete` 定义成一种“图内自动检测结果”,而定义成一种: + +> **模型适用性边界状态** + +也就是说: + +- 对只依赖当前显式文件读写证据的场景,V5 可以给出结论 +- 对需要目录枚举能力才能 sound 判定的场景,V5 只能在**图外证据**已经表明其目录敏感时,拒绝给出 sound 结论 + +这些图外证据可以包括: + +- 明确的构建系统元信息 +- 已知使用目录枚举的构建原语 +- 后续扩展后的更强 trace 信号 + +如果没有这类图外证据,则 V5 的结论必须被理解为: + +> **仅对“显式文件读写语义”成立,而不是对“所有潜在目录语义”成立。** + +### 8.3 未来扩展位 + +如果将来 trace 真的补上: + +- `getdents/getdents64` +- `stat*` +- 失败路径探测 + +那时再扩展目录状态对象也不迟。 + +但 V5 不把这种未来能力预埋成当前核心模型的一部分。 + +--- + +## 9. 正确的流水线 + +V5 的流水线是: + +### 第 0 层:观测归一化 + +仅做无语义归一化: + +- 路径 canonicalization +- `cwd/env/argv` 规范化 +- `pid/parent` 关联 +- scope token 归一化 + +这一层不能做: + +- compile/configure/link/install 识别 +- probe/tooling/mainline 判定 +- 基于旧坍缩图的过滤 + +### 第 1 层:原始 Path-SSA 图构建 + +对全部执行节点和路径状态版本构建原始 SSA 图: + +- 执行节点 `E` +- 路径状态版本 `P` +- `P -> E -> P` 边 + +这一步的目标是: + +> 先把“同路径多次覆写造成的空间坍缩”打散。 + +### 第 2 层:SSA 图上的角色打标 + +只有在原始 Path-SSA 图完成之后,才允许做: + +- probe/tooling/mainline +- delivery/control-plane + +这一步仍然需要启发式,但启发式必须运行在**版本已经展开**的图上,而不是旧坍缩图上。 + +这里再次强调边界: + +- 核心图对象 `E/P` 本身不带动作语义类型 +- 角色打标器可以读取节点附带的黑盒特征做分析 +- 打标结果是附加层,不回写成核心图对象的结构定义 + +### 第 3 层:主线投影与 Stage 2 分析 + +在 SSA 图上完成角色打标后,再做主线投影,并计算: + +- `M(A)` +- `SeedDef(A)` +- `Need(A)` +- `Flow(A)` +- `Frontier(A)` + +### 9.1 它在总设计里的正式位置 + +这里需要把 V5 和总设计稿的关系写死: + +- `llar-matrix-reduction-design.md` 定义的是 Stage 1/2/3/4 的总证据链 +- V5 定义的是其中 Stage 2 的正式内部引擎 + +因此,V5 的输入输出边界应明确为: + +输入: + +- baseline / singleton 的归一化观测 +- 路径 digest 等“路径是否真的变化”的证据 +- scope-canonical 路径空间 + +输出: + +- `M(A)` +- `SeedDef(A)` +- `Need(A)` +- `Flow(A)` +- `Frontier(A)` +- `RAW/WAW` hazard 结论 + +它不负责: + +- output manifest diff +- direct merge +- root replay +- validator / test coverage +- 最终矩阵展开 + +也就是说,V5 不是一个独立于矩阵降维的平行方案,而是: + +> **矩阵降维总设计中,Stage 2 的正式核心模型。** + +--- + +## 10. Stage 2 的重新定义 + +### 9.1 `M(A)`:变更执行区 + +对于 option `A`,`M(A)` 是相对 baseline 发生变化的执行节点集合。 + +变化来源包括: + +- 节点新增/消失 +- 节点属性变化 +- 节点绑定的上游路径状态版本变化 + +### 9.2 `SeedDef(A)`:变化定义 + +`M(A)` 产生的、相对 baseline 真正变化的路径状态版本集合。 + +它替代当前的粗粒度 `seedWrites`。 + +### 9.3 `Need(A)`:外部前提 + +`M(A)` 及其传播闭包中所消费、但不是由该闭包内部定义的那些路径状态版本。 + +它替代当前较粗的 `needPaths`。 + +这里必须进一步区分两类外部前提: + +1. **Tracked External State** + - 落在当前分析域内的外部路径状态 + - 例如 source/build/install/keep-roots 中、但不属于 `ReachState(A)` 的路径状态 + +2. **Ambient Prerequisite** + - 构建过程中长期存在、但不属于当前分析域的环境性前提 + - 例如系统编译器、系统头、宿主 shell、本机工具链路径 + +V5 的 `Need(A)` 与 `Frontier(A)` 只围绕 **Tracked External State** 定义。 +`Ambient Prerequisite` 可以作为解释信息保留,但不应把几乎所有下游节点都膨胀成 frontier。 + +### 9.4 `Flow(A)`:传播闭包 + +从 `SeedDef(A)` 出发,沿: + +- `def -> use` +- `use -> downstream def` + +传播得到的全部受影响路径状态版本集合。 + +它替代当前较粗的 `slicePaths`。 + +### 10.5 `Frontier(A)`:传播边界 + +`Frontier(A)` 不再用“第一次遇到”这种感性表述,而定义为一个**最小边界消费节点集合**。 + +先定义: + +- `ReachState(A)`:从 `SeedDef(A)` 出发沿 `def -> use -> def` 可达的全部路径状态版本集合 +- `ReachExec(A)`:在同一传播闭包中可达的全部执行节点集合 +- `TrackedExternal(A)`:所有不属于 `ReachState(A)`、但属于当前分析域的外部路径状态版本集合 +- `MixedExec(A)`:所有满足以下条件的执行节点 `E` 的集合: + 1. `E ∈ ReachExec(A)` + 2. `E` 至少消费一个来自 `ReachState(A)` 的输入状态 + 3. `E` 还消费了至少一个来自 `TrackedExternal(A)` 的输入状态 + +则: + +> `Frontier(A)` 是 `MixedExec(A)` 在 `ReachExec(A)` 诱导偏序下的最小元集合。 + +也就是说,`Frontier(A)` 表示: + +> **A 的变化流与分析域内外部状态流第一次发生混合消费的那一层边界节点。** + +这里的“最小元”是指: + +> 若 `E ∈ Frontier(A)`,则不存在另一个 `E' ∈ MixedExec(A)`,使得 `E'` 位于 `E` 的上游,且从某个 `SeedDef(A)` 到 `E` 的传播路径必须先经过 `E'`。 + +因此: + +- 第一次同时消费“变化状态 + 分析域内未变化状态”的节点,会进入 frontier +- 在它之后继续传递该混合结果的下游节点,不会继续全部被算进 frontier + +若该边界节点同时落在允许的 merge/replay surface 上,则该相遇可以被吸收;否则需要进入碰撞判断。 + +--- + +## 11. 碰撞定义 + +在 V5 下,Stage 2 的硬碰撞不再是简单路径交集,而是路径状态版本上的数据冒险。 + +### 10.1 RAW Hazard + +若 `A` 的变化状态版本会在不允许的边界前,替代 `B` 原本依赖的外部路径状态版本,则发生硬碰撞。 + +### 10.2 WAW Hazard + +若 `A` 和 `B` 都为同一路径定义出新的不兼容状态版本,且这种竞争不能在允许 surface 上被吸收,则发生硬碰撞。 + +### 10.3 允许的汇合面 + +若两条变化流只在允许的 surface 上相遇,例如: + +- Stage 3 direct merge surface +- root replay 可吸收的 replay-root 汇合 + +则不视为 Stage 2 硬碰撞。 + +--- + +## 12. 最适合借用的算法 + +V5 最值得借的不是经典 SSA 语法,而是这些数据流算法: + +1. **Reaching Definitions** + - 回答某个 use 依赖哪个路径状态版本 + +2. **Def-Use / Use-Def Chains** + - 从变化定义找到受影响 use 和 downstream def + +3. **Forward Slice** + - 计算 `Flow(A)` + +4. **Backward Slice** + - 计算 `Need(A)` + +5. **Frontier 提取** + - 识别 `ReachState(A)` 与外部状态流在消费节点上的边界相遇 + +6. **RAW / WAW Hazard Analysis** + - 直接用于碰撞判定 + +--- + +## 13. 为什么这版比现状更适合项目 + +当前 `impact.go` 的主逻辑仍是: + +- `seedWrites` +- `needPaths` +- `slicePaths` + +它的问题是: + +- 传播单位还是粗粒度路径集合 +- 同路径多次覆写没有显式版本 +- 旧图的角色分类容易被坍缩图污染 + +V5 的优势是: + +1. 只引入当前项目真正能稳定支撑的对象 +2. 把“路径被写过”升级成“路径状态版本发生变化” +3. 把“路径 BFS”升级成“def-use 传播” +4. 把“路径交集碰撞”升级成“版本冒险碰撞” +5. 不用伪精确目录模型把系统复杂度提前拉爆 + +--- + +## 14. 明确边界 + +V5 必须明确承认这些边界: + +1. 它不是严格编译器 SSA。 +2. 它仍然需要 probe/tooling/mainline/delivery 的角色分类。 +3. 它不解决 Stage 3 的 object merge。 +4. 它不覆盖 `A+B-only` 幽灵路径。 +5. 它对目录枚举依赖只做到“显式承认能力缺口”,而不是当前就完美恢复。 +6. 它的 reaching-def 绑定基于保守因果偏序,不是假设一个全局线性 `latest`。 + +--- + +## 15. 一句话总结 + +> **LLAR Trace SSA V5 是一套面向当前项目证据边界的 Path-SSA:** +> 它把黑盒执行节点上的路径状态版本化,用 tombstone 表达删除,用保守因果偏序下的 reaching-def、切片、frontier 和 hazard 分析重写 Stage 2;对目录枚举这类当前 trace 观测不到的能力,明确列为模型外能力边界和未来扩展,而不是在第一版核心模型里伪精确建模。 diff --git a/doc/nix-testing-system-research.md b/doc/nix-testing-system-research.md new file mode 100644 index 0000000..4f2cba5 --- /dev/null +++ b/doc/nix-testing-system-research.md @@ -0,0 +1,348 @@ +# Nix 全链路测试系统调研(含测试平台) + +调研日期:2026-02-28 +范围:Nixpkgs / NixOS / Hydra / ofborg 的完整测试系统与执行平台 + +--- + +## 1. 你关心的问题,先直接回答 + +### 1.1 Nix 有没有“全量跑完所有组合”? + +没有。Nix 公开实现不是“options 全组合穷举”,而是: + +- PR 阶段:以增量触发为主(ofborg)。 +- 主线/发布:跑预定义的大规模关键集合(Hydra + `release.nix` / `nixos/release.nix`)。 +- 平台与测试范围通过白名单和门控机制控制(`supportedSystems`、`hydraPlatforms`、`runTestOn`)。 + +### 1.2 Nix 的“整个测试系统”长什么样? + +可拆成 4 层: + +1. 触发层:GitHub PR、定时/轮询 jobset、手动命令触发。 +2. 评估层:把 Nix 表达式求值成可执行 job/build 集合。 +3. 执行层:构建与测试在可用 builder 平台上实际跑。 +4. 产物与反馈层:日志、状态、二进制缓存、Web/API 反馈。 + +### 1.3 测试平台具体是什么? + +从公开资料看,核心平台组件是: + +- **ofborg 平台**(PR 自动 eval/build/test 协助) +- **Hydra 平台**(持续评估、调度、构建、发布聚合) +- **Nix builders 平台**(按 system/features 选择可执行机器) +- **Nix store / binary cache**(构建输入输出与复用) + +--- + +## 2. 全链路架构(系统视角) + +```mermaid +flowchart LR + A["GitHub PR / Commit"] --> B["ofborg: eval/build/test trigger"] + B --> C["Nix eval\n(pkgs/top-level/release.nix\n+nixos/release.nix)"] + C --> D["Hydra Evaluator\n(jobset evaluation)"] + D --> E["Hydra Queue Runner\n(schedule builds/tests)"] + E --> F["Builders\n(x86_64-linux / aarch64-linux / darwin ...)"] + F --> G["Nix Store + Build Products"] + G --> H["Binary Cache / Channels / Hydra UI/API"] + H --> I["Maintainer / User feedback"] +``` + +说明: + +- 这是“控制面(触发/评估/调度) + 执行面(builder) + 产物面(store/cache/UI)”分离架构。 +- Nix 的规模控制主要在“评估输出集合”和“执行平台门控”两个位置完成。 + +--- + +## 3. 测试平台拆解(你说的“平台”) + +## 3.1 Hydra 平台(主线/发布核心) + +Hydra 文档与架构说明给出的核心组件: + +- `hydra-server`:Web 前端/API +- `hydra-evaluator`:拉源码、评估 jobset、入队 build +- `hydra-queue-runner`:消费队列、执行构建/测试、上传结果 +- PostgreSQL:配置、队列、状态元数据 +- Nix store:`.drv` 与输出 +- destination store / binary cache:发布产物分发 + +Hydra installation 文档明确:三进程都要运行系统才完整可用。 +Hydra 还支持把构建调度到“配置好的 Nix hosts”(即远程 build 机器)。 + +## 3.2 ofborg 平台(PR 自动化核心) + +ofborg README 明确: + +- PR 自动构建触发依赖 commit 标题中的 attrpath。 +- PR 自动 eval 在创建和后续 commit 变化时执行。 +- 支持 `@ofborg eval` / `@ofborg build ...` / `@ofborg test ...` 手动扩展。 + +Trusted users 段落(当前文档状态)还给出: + +- 功能当前禁用说明(darwin builder 原因) +- 但仍列出其设计目标支持平台:`x86_64-linux` / `aarch64-linux` / `x86_64-darwin` / `aarch64-darwin` + +## 3.3 Builder 平台(真正跑测试/构建的地方) + +### Hydra 侧 + +NixOS Hydra 模块(nixpkgs)显示: + +- 通过 `buildMachinesFiles` 配置构建机器文件 +- `NIX_REMOTE_SYSTEMS` 从该配置注入,供 queue-runner 调度 +- `hydra-queue-runner` 负责实际执行构建 + +### NixOS VM 测试侧 + +`nixos/lib/testing/run.nix` 显示测试 derivation 需要系统特性: + +- 基础特性:`nixos-test` +- Linux:`kvm` +- Darwin host:`apple-virt` + +`nixos/lib/testing/meta.nix` 显示 NixOS tests 默认 `hydraPlatforms` 为 Linux,并写明 `hydra.nixos.org` 当前不支持 Darwin 虚拟化。 + +--- + +## 4. 测试系统的“范围控制”机制(避免爆炸) + +## 4.1 平台白名单:`supportedSystems` + +`release-supported-systems.json`(当前公开)为: + +- `aarch64-linux` +- `aarch64-darwin` +- `x86_64-linux` +- `x86_64-darwin` + +`pkgs/top-level/release.nix` 把该白名单作为发布评估入口。 + +## 4.2 包级平台裁剪:`meta.hydraPlatforms` + +`meta.chapter.md`: + +- `meta.hydraPlatforms` 默认等于 `meta.platforms` +- 可以改成子集,甚至空列表 `[]` + +`release-lib.nix` 的 `getPlatforms` 逻辑: + +- 优先 `drv.meta.hydraPlatforms` +- 否则 `meta.platforms - meta.badPlatforms` + +## 4.3 测试级平台门控:`runTestOn` + +`nixos/tests/all-tests.nix` 中: + +- `runTestOn = systems: arg: if elem system systems then runTest arg else { }` + +即每个 test 可定义“只在哪些系统跑”,不是全系统跑。 + +## 4.4 测试分层:把重测试从主构建解耦 + +`passthru.tests` 文档明确: + +- Hydra/nixpkgs-review 默认不构建 +- ofborg 只在相关 PR 或手动触发时跑 + +这就是“主构建门禁”与“扩展重测试”解耦。 + +## 4.5 缓存复用 + +nix.dev 明确:成功测试进入 Nix store 缓存,语义输入不变则不会重复执行。 + +--- + +## 5. 端到端执行流程(按场景) + +## 5.1 PR 场景 + +1. 开发者提交 PR。 +2. ofborg 自动 eval(PR 创建与 commit 变化)。 +3. 根据 commit 标题 attrpath 自动触发 build;需要时手动 `@ofborg test` 扩展。 +4. 结果反馈到 PR 评论/状态。 + +特点:增量快反馈,不追求全量覆盖。 + +## 5.2 主线/发布场景 + +1. Hydra evaluator 周期性评估 jobsets。 +2. 新/变更构建任务入队。 +3. queue-runner 调度到 Nix hosts 执行。 +4. 产物进入 store/cache,状态出现在 Hydra UI/API。 +5. release 聚合(例如 `nixpkgs` unstable 的 release-critical constituents)。 + +特点:规模大、集合预定义、偏稳定性和发布质量。 + +## 5.3 NixOS 集成测试场景 + +1. tests 由 `nixos/tests/all-tests.nix` 聚合。 +2. 按 system 映射生成 job(可 `runTestOn` 裁剪)。 +3. VM driver 执行 Python `testScript`。 +4. 产物/日志回传 Hydra。 + +--- + +## 6. 测试类型与写法(示例) + +## 6.1 包内测试(`checkPhase`) + +```nix +stdenv.mkDerivation { + pname = "demo"; + version = "1.0.0"; + + doCheck = true; + nativeCheckInputs = [ ctest ]; + checkTarget = "test"; +} +``` + +## 6.2 独立包测试(`passthru.tests`) + +```nix +stdenv.mkDerivation { + pname = "my-tool"; + version = "1.0.0"; + + passthru.tests = { + smoke = runCommand "my-tool-smoke" { nativeBuildInputs = [ my-tool ]; } '' + my-tool --help >/dev/null + touch $out + ''; + }; +} +``` + +## 6.3 NixOS VM 测试(`runNixOSTest`) + +```nix +pkgs.testers.runNixOSTest { + name = "nginx-smoke"; + + nodes.server = { ... }: { + services.nginx.enable = true; + }; + + nodes.client = { pkgs, ... }: { + environment.systemPackages = [ pkgs.curl ]; + }; + + testScript = '' + start_all() + server.wait_for_unit("nginx") + client.succeed("curl -f http://server/") + ''; +} +``` + +## 6.4 本地运行入口(官方文档) + +`pkgs/README.md` 给了主要入口: + +- `nix-build --attr pkgs.PACKAGE.passthru.tests` +- `nix-build --attr nixosTests.NAME` +- `nix-build --attr tests.PACKAGE` + +--- + +## 7. 对你当前需求的直接结论 + +如果你要“完整系统 + 测试平台”的评估标准,Nix 给你的启发是: + +1. **把系统拆成控制面和执行面**:触发/评估/调度与 builder 运行分离。 +2. **平台先收敛后扩展**:`supportedSystems` 与 `hydraPlatforms` 是一等控制点。 +3. **测试分层**:主构建硬门禁 + 重测试按需执行,不做无限全量。 +4. **每层都保留可操作接口**:ofborg 命令、Hydra jobset/release、flake check、本地 test 入口。 + +--- + +## 8. 证据索引(核心) + +### ofborg + +- 自动构建触发与 commit 标题规则: + https://github.com/NixOS/ofborg/blob/master/README.md#automatic-building +- PR 自动 eval: + https://github.com/NixOS/ofborg/blob/master/README.md#eval +- Trusted users 当前禁用说明与平台列表: + https://github.com/NixOS/ofborg/blob/master/README.md#trusted-users-currently-disabled +- 公开配置(`disable_trusted_users`、runner 等): + https://github.com/NixOS/ofborg/blob/master/config.public.json + +### Hydra + +- Hydra 组件(server/evaluator/queue-runner/store/cache): + https://github.com/NixOS/hydra/blob/master/doc/architecture.md +- Hydra 安装与三进程职责: + https://github.com/NixOS/hydra/blob/master/doc/manual/src/installation.md +- Hydra 介绍与 CI/发布定位: + https://github.com/NixOS/hydra/blob/master/doc/manual/src/introduction.md + +### Nixpkgs / NixOS 测试体系 + +- `passthru.tests` 默认 CI 行为: + https://github.com/NixOS/nixpkgs/blob/master/doc/stdenv/passthru.chapter.md +- `meta.hydraPlatforms`: + https://github.com/NixOS/nixpkgs/blob/master/doc/stdenv/meta.chapter.md +- Nixpkgs release 与 `supportedSystems`: + https://github.com/NixOS/nixpkgs/blob/master/pkgs/top-level/release.nix + https://github.com/NixOS/nixpkgs/blob/master/pkgs/top-level/release-supported-systems.json +- NixOS release tests 聚合: + https://github.com/NixOS/nixpkgs/blob/master/nixos/release.nix +- `runTestOn`: + https://github.com/NixOS/nixpkgs/blob/master/nixos/tests/all-tests.nix +- NixOS test driver/system features: + https://github.com/NixOS/nixpkgs/blob/master/nixos/lib/testing/run.nix + https://github.com/NixOS/nixpkgs/blob/master/nixos/lib/testing/driver.nix + https://github.com/NixOS/nixpkgs/blob/master/nixos/lib/testing/meta.nix +- Hydra NixOS module(buildMachinesFiles / queue-runner 等): + https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/services/continuous-integration/hydra/default.nix + +### Flake 与本地测试入口 + +- `nix flake check`: + https://nixos.org/manual/nix/stable/command-ref/new-cli/nix3-flake-check.html +- `pkgs/README` 的本地测试入口与示例: + https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md +- NixOS VM 测试教程(含缓存行为): + https://nix.dev/tutorials/nixos/integration-testing-using-virtual-machines.html + +--- + +## 9. 行号级证据(关键断言) + +- Hydra 三进程职责(`hydra-server` / `hydra-evaluator` / `hydra-queue-runner`): + https://github.com/NixOS/hydra/blob/master/doc/manual/src/installation.md#L144-L164 +- Hydra 平台组件(DB、queue、store、destination store/cache): + https://github.com/NixOS/hydra/blob/master/doc/architecture.md#L6-L37 +- ofborg 自动构建规则(commit 标题 attrpath): + https://github.com/NixOS/ofborg/blob/master/README.md#L9-L31 +- ofborg PR 自动 eval: + https://github.com/NixOS/ofborg/blob/master/README.md#L67-L69 +- ofborg trusted-users 当前禁用与平台列表: + https://github.com/NixOS/ofborg/blob/master/README.md#L125-L144 +- `passthru.tests` 默认不被 Hydra/nixpkgs-review 构建: + https://github.com/NixOS/nixpkgs/blob/master/doc/stdenv/passthru.chapter.md#L73-L75 +- `meta.hydraPlatforms` 默认与可裁剪: + https://github.com/NixOS/nixpkgs/blob/master/doc/stdenv/meta.chapter.md#L143-L151 +- release 平台白名单(`supportedSystems`): + https://github.com/NixOS/nixpkgs/blob/master/pkgs/top-level/release-supported-systems.json +- `runTestOn` 系统门控: + https://github.com/NixOS/nixpkgs/blob/master/nixos/tests/all-tests.nix#L131 +- NixOS tests `requiredSystemFeatures`(`nixos-test` / `kvm` / `apple-virt`): + https://github.com/NixOS/nixpkgs/blob/master/nixos/lib/testing/run.nix#L96-L100 +- NixOS tests 默认 `hydraPlatforms = linux` 的说明: + https://github.com/NixOS/nixpkgs/blob/master/nixos/lib/testing/meta.nix#L46-L53 +- Hydra 模块中的构建机入口(`buildMachinesFiles`): + https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/services/continuous-integration/hydra/default.nix#L226-L235 + +--- + +## 10. 边界说明(避免误读) + +1. 本文所有事实项都来自公开文档或源码。 +2. “Hydra 安装手册中 Linux 支持描述”与“README 强调 NixOS 模块部署路径”语境不同:一个讲运行条件,一个讲推荐部署方式。 +3. “Nix 不做 options 全组合穷举”是基于 release/jobset/runTestOn/hydraPlatforms 的公开实现推断,非内部私有策略猜测。 diff --git a/doc/testing-research-process-summary.md b/doc/testing-research-process-summary.md new file mode 100644 index 0000000..f5f9247 --- /dev/null +++ b/doc/testing-research-process-summary.md @@ -0,0 +1,490 @@ +# LLAR 测试系统调研过程总结 + +调研日期:2026-03-06 +目的:总结本轮围绕“大规模构建矩阵测试”开展的调研路径、关键结论,以及 LLAR 设计是如何逐步收敛到当前形态的。 + +--- + +## 1. 调研起点 + +本轮调研的起点,不是一般意义上的“怎么写测试”,而是一个更具体的问题: + +- LLAR 当前面对的是 `default options + require` 带来的大规模构建矩阵。 +- 这个矩阵既不能全量跑完,也不能简单依赖随机抽样。 +- 我们不希望把“配方错误”最终转嫁给用户侧运行时才暴露。 + +因此,调研目标被明确为: + +1. 现有主流包管理器到底如何处理大规模矩阵。 +2. 它们是全量测试、增量测试,还是分层测试。 +3. 是否存在可直接借鉴的 pairwise 或其他大规模缩减方案。 +4. LLAR 在“黑盒、多语言、云端”约束下,能够采用什么更现实的设计。 + +--- + +## 2. 第一阶段:外部生态事实调研 + +### 2.1 Nix / NixOS / Hydra / ofborg + +我们先完整调研了 Nix 的测试系统和测试平台,重点不是单个命令,而是整条链路: + +- PR 阶段主要依赖 ofborg 做增量评估和有限构建。 +- 主线和发布依赖 Hydra 做持续评估、调度、构建和产物发布。 +- 测试范围不是“全组合穷举”,而是通过 `supportedSystems`、`hydraPlatforms`、`runTestOn` 等机制控制。 +- NixOS 测试系统本质上是“控制面和执行面分离”的平台化设计。 + +这一步的核心结论是: + +- Nix 没有尝试把所有组合跑完。 +- 它解决问题的方法是“预定义关键集合 + 平台门控 + 分层执行平台”。 + +更具体的业界例子包括: + +- 在 Nixpkgs 的 PR 流程中,维护者可以通过 `@ofborg eval`、`@ofborg build ...`、`@ofborg test ...` 触发对应的评估、构建和测试流程。ofborg 只会在允许的机器和允许的目标上执行,而不是对所有平台做无差别全跑。 +- Nixpkgs 中的包可以通过 `meta.platforms` 声明逻辑支持的平台,也可以通过 `meta.hydraPlatforms = [ ]` 等方式控制 Hydra 是否为其产出官方二进制。这意味着“逻辑支持”和“官方承诺构建交付”是两层不同语义。 + +这一类做法的本质是: + +- 先缩小系统承诺的矩阵。 +- 对没有被纳入承诺范围的组合,不给出官方构建或官方验证承诺。 + +### 2.2 Conan / ConanCenter + +随后调研了 Conan,重点补齐了两类问题: + +- 复杂构建矩阵到底怎么跑。 +- 测试例子怎么写。 + +调研结论包括: + +- ConanCenter 使用“固定 profile 列表 + `package_id` 去重”来构建二进制,不做 options 全组合穷举。 +- ConanCenter 明确不负责跑上游完整 testsuite,而是侧重二进制构建与消费者验证。 +- 面对多产品、多配置场景,Conan 通过 `build-order` 和 `build-order-merge` 降低重复构建。 + +这一步的核心结论是: + +- Conan 也没有采用 pairwise 作为主策略。 +- 它依赖的是“固定矩阵 + 去重 + 受影响产品重建 + lockfile 一致性”。 + +更具体的业界例子包括: + +- Conan 官方文档在 `libpng` 的例子中,用 `conan graph build-order --requires=libpng/1.5.30 --order-by=recipe` 展示了依赖重建顺序。结果会先列出 `zlib`,再列出 `libpng`;如果 `zlib` 的二进制已经存在于 cache 中,则不会重复重建。 +- Conan 的 header-only 教程中,`sum/0.1` 通过 `package_id()` 里的 `self.info.clear()` 明确声明 Debug、C++ 标准等维度不影响二进制身份,因此这些维度不会生成新的 package id。 + +这一类做法的本质是: + +- 系统不去测所有组合。 +- 而是由 recipe 作者明确声明“哪些维度真正影响二进制身份”,并据此做构建与缓存去重。 + +### 2.3 Bazel + +虽然 Bazel 不是包管理器,但它的测试系统值得纳入调研,因为它展示了另一种成熟的“测试组织方式”: + +- Bazel 把测试定义为一等目标,使用 `bazel test` 统一执行。 +- 测试可以通过 `test_suite` 聚合,而不是按目录或脚本零散触发。 +- 测试规则有明确的标签与约束系统,例如 `small`、`medium`、`large`、`smoke`、`manual`、`exclusive`。 +- 测试动作的执行平台由 test toolchain 和 execution platform 共同决定,而不是简单继承构建平台。 + +更具体的例子包括: + +- Bazel 的 `test_suite` 可以显式列出要跑的测试,也可以按标签筛选测试;例如可以只组织 `smoke_tests`,或通过 `-flaky` 排除不稳定测试。 +- Bazel 官方文档明确把 `smoke` 解释为“应在提交代码前运行的测试”,把 `manual` 解释为“不自动包含进通配符测试”,把 `exclusive` 解释为“运行时不与其他测试并发”。 +- Bazel 的 test encyclopedia 还明确规定了 test action 的执行平台由测试工具链和平台约束共同决定,避免例如“在 Windows 上执行 Linux 测试二进制”这类错误配置。 + +这一步的核心结论是: + +- Bazel 解决的重点不是“如何缩减包管理器的 options 矩阵”,而是“如何把测试目标、测试集合、测试资源约束和执行平台一起纳入统一图模型”。 +- 它更像是一个“测试组织系统”,而不是一个“矩阵缩减系统”。 + +### 2.4 Homebrew + +Homebrew 这一条线的价值在于观察“社区包仓库如何把 PR 测试、构建和合并流程绑定在一起”。 + +调研结论包括: + +- Homebrew 的 BrewTestBot 就是其自动化 review 与测试系统。 +- 它在固定的 macOS / Linux 机器池上执行 bottle 构建和自动化检查,并把结果直接反馈到 PR。 +- 对 formula 贡献者来说,官方要求的本地验证流程也很明确:先 `brew install --build-from-source`,再 `brew test`,再 `brew audit`。 +- 对 maintainer 来说,CI 是否通过直接决定 PR 是否能被 BrewTestBot 自动合并或进入手动发布流程。 + +更具体的例子包括: + +- BrewTestBot 文档明确写到,`brew test-bot` 负责对 Homebrew 或其 taps 的变更执行 bottle builds 和自动测试,并自动更新 PR 状态。 +- Homebrew 的 pull request 文档明确要求,formula 变更在提交前要运行 `brew install --build-from-source `、`brew test ` 和 `brew audit --strict --online `。 +- BrewTestBot for Maintainers 文档则明确说明:只有在 CI 通过且 PR 获得 maintainer 审批后,BrewTestBot 才会自动合并;否则需要进入更保守的人工发布流程。 + +这一步的核心结论是: + +- Homebrew 的策略不是缩减所有理论组合,而是围绕“固定支持平台 + PR 生命周期 + bottle 构建 + formula smoke test”建立一套强约束流程。 +- 它的测试系统更像“仓库准入与交付流水线”,而不是“通用组合矩阵缩减算法”。 + +### 2.5 Debian / autopkgtest / debci / britney + +Debian 这一条线真正相关的不是 reproducible builds,而是 `autopkgtest` 及其与迁移系统的联动。 + +调研结论包括: + +- Debian 包可以声明 `autopkgtest` 测试。 +- 这些测试运行在 Debian CI 基础设施上,由 `debci` 提供执行框架。 +- `britney` 会在包从 unstable 迁移到 testing 的过程中触发相关测试,并使用结果影响迁移决策。 +- 但 Debian 并不会因此对所有受影响包做“全库全矩阵重测”;对于库迁移之类的场景,它只运行由该库包所触发的相关测试,而不是所有可能依赖该库的新版本包的 autopkgtest。 + +更具体的例子包括: + +- Debian Wiki 明确写到,维护者可以为包添加 autopkgtest,这些测试会在 `ci.debian.net` 上运行。 +- 同一份说明还明确指出,`britney` 会调用 `debci` 的 API 来测试 migration candidate,并用结果影响 unstable 向 testing 的迁移。 +- 文档也明确举例说明:对于 library transition,Debian 只运行由该库触发的测试,而不会对所有用到新库版本构建出来的包重新跑一遍 autopkgtest。 + +这一步的核心结论是: + +- Debian 的策略不是全矩阵,而是“包声明测试 + CI 平台执行 + 迁移系统按候选触发相关测试”。 +- 它很强调“测试结果服务于发行迁移决策”,而不是把所有组合都视为同等优先级。 + +--- + +## 3. 第二阶段:对主流做横向归纳 + +在 Nix、Conan、Bazel、Homebrew 和 Debian 这几条线都跑通之后,我们得到了一个比较稳定的外部事实: + +1. 没有看到主流包管理器把 pairwise 当作主发布门禁。 +2. 没有看到任何生态尝试对 options 做全量组合穷举。 +3. 主流系统更常用的是: + - 固定平台矩阵; + - 固定测试入口与测试骨架; + - 测试集合分层; + - 标签、白名单或显式约束; + - 增量触发; + - 关键产品或关键测试集合; + - 构建/测试分层; + - 二进制去重; + - 锁依赖版本的一致性机制。 + +这一步把问题界定清楚了: + +- “为什么别人不做 pairwise”不是偶然,因为它不具备工程上的确定性,很难承担发布正确性的主责任。 +- “为什么别人不全跑”也不是偷懒,而是因为大规模矩阵在工程上本来就不可全量覆盖。 + +如果把这些例子进一步压缩成一句话,可以得到一个更清楚的模式: + +- Nix:先缩小平台与测试承诺范围。 +- Conan:用 binary identity 去重,只为真正不同的二进制身份付费。 +- Bazel:把测试本身组织成图中的一等目标,并用 test suite、标签和平台约束来控制执行。 +- Homebrew:把测试嵌进 PR 准入和 bottle 交付流程。 +- Debian:把测试结果接入迁移决策,只对相关候选和相关依赖触发测试。 + +这也帮助我们明确了一个边界: + +- 业界有很多“缩范围、做缓存、做 provenance”的成熟做法。 +- 但没有现成方案能在黑盒、跨语言、拒绝抽样、且要求对未物理测试组合给出绝对放行证明的前提下,直接解决 LLAR 的问题。 + +--- + +## 4. 第三阶段:研究路线补充调研 + +在调研主流工程实践之外,我们也补充看了一些常见研究路线,目的是明确哪些思路值得借鉴,哪些不适合作为 LLAR 的主线。 + +### 4.1 Pairwise / covering arrays + +这条路线的典型论点是: + +- 许多缺陷往往由少量参数交互触发。 +- 因此用 pairwise 或更高阶的 t-wise 覆盖,就可以用很少测试覆盖大量交互情形。 + +典型例子包括: + +- NIST 的材料会用类似 `if (A && B)` 的布尔分支说明:很多错误由少数维度交互触发,因此组合覆盖能以很小代价获得很高的缺陷发现率。 +- 但 NIST 自己也明确提到,真实故障的触发交互强度可能达到 6,pairwise 并不能保证覆盖所有关键故障。 + +因此这条路线适合: + +- 高性价比找 bug。 + +但它不适合: + +- 对未测试组合给出绝对放行证明。 + +### 4.2 Family-based / product-line model checking + +这条路线的核心思路是: + +- 先建立正式的 feature model 和行为模型。 +- 再把整个产品族作为一个统一模型进行 SAT / IC3 / IMC 等验证,而不是逐个配置跑。 + +典型例子是: + +- 研究会把一个 feature family 编译成单个 SMV 模型,然后对整个产品族一次性做模型检测。 + +这条路线很强,但它的前提是: + +- 你必须拥有正式 feature model。 +- 你必须拥有行为模型或可供验证的语义对象。 + +对于只调度 shell 构建、且不理解语言语义的 LLAR 来说,这一前提并不成立。 + +### 4.3 Variational execution + +这条路线的核心思路是: + +- 在同一次执行里共享不同配置的公共路径,减少重复执行成本。 + +典型例子是: + +- OOPSLA 2018 的 variational execution 研究通过改写 JVM bytecode,在 7 个高可配置系统上实现了 2 到 46 倍的提速。 + +但它的前提是: + +- 需要深入具体语言或 runtime 的执行模型。 +- 通常需要字节码或解释器层面的改造。 + +因此它不符合 LLAR 对语言无关、黑盒调度器角色的要求。 + +--- + +## 5. 第四阶段:把问题拉回 LLAR 真实约束 + +在调研中,一个重要的转折点是,我们逐步把问题从“外部生态怎么做”拉回到了 LLAR 自己的约束上: + +- LLAR 是多语言包管理器,不适合把某一门语言的 ABI 规则(如 C/C++ 的 DWARF 分析)变成平台基础设施。 +- LLAR 是黑盒调度系统,不应该要求配方作者理解复杂的分析模型。 +- LLAR 采用全云端构建,需要一种在 CI 阶段就能产生普适认证模型的方法,而不是依赖本地验证。 + +也正是在这个阶段,我们逐步确认:单纯谈“增量触发”不够,因为这并未解决 options 爆炸带来的隐性耦合风险(例如 C 语言中的结构体布局联动陷阱)。LLAR 需要的是一套更贴近“黑盒矩阵缩减”的方案。 + +--- + +## 6. 第五阶段:探索 LLAR 自己的矩阵缩减思路 + +在这个阶段里,我们从“物理足迹”、“正交分析”、“ABI 安全网”等思路开始,在遭遇多个极端工程反例后,逐步推演出一套成熟的黑盒自动化缩减模型。 + +### 6.1 初始思路:文件足迹与正交推导(被否决) + +初始想法非常直观: +- 对每个 option 做单变量 probe(探测构建); +- 观察其读取路径、写入路径的变化; +- 如果两个 option 修改的源码文件完全没有交集,就判定为正交,从而将 option 划分成若干“独立岛屿”。 + +发现的致命漏洞(The `struct S` Padding 陷阱): +这种粗粒度的文件级观测无法防范隐性 ABI 破坏。典型反例是“结构体布局联动”:如果 A 和 B 都修改了同一个头文件中的结构体 `struct S` 的不同条件编译分支,单看增量,它们似乎各自平行增加了不同字段。但如果在 A+B 组合下,C 语言的内存对齐(Padding)规则会导致整个结构体的大小和内存偏移量发生非线性突变(`Size(A+B) != Size(A) + Size(B)`)。如果仅仅因为它们修改了不同字段就误判为“正交安全”而不去跑 A+B 的组合测试,将导致极其严重的运行时崩溃。 + +### 6.2 修正思路:基于语言 ABI 的底层语义分析(被否决) + +为了解决上述布局联动问题,方案一度转向使用 `abidiff` 等工具,通过直接对比二进制的 DWARF 调试信息来分析内存布局的物理变化。 + +发现的工程阻碍: +这种方案虽然精准,但深度绑定了 C/C++ 语言环境。LLAR 的核心定位是“语言无关的通用黑盒调度器”,引入 `abidiff` 违背了这一初衷,它无法处理 Python 的动态扩展、Go 模块或纯二进制包的分发冲突。 + +### 6.3 进阶思路:构建动作图碰撞 (Action Graph Collision) + +在“保持黑盒”与“确保绝对安全”的双重挤压下,我们借鉴了 Google Bazel 的核心思想,将视角从“理解代码语义”转向**“观测构建流水线的物理行为”**。 +- 将构建过程拆解为原子动作集合(如单条 `gcc` 编译命令)。 +- 关注每个 option 改变了哪些原子动作的“输入指纹”(包括命令行参数、环境变量、输入文件哈希)。 +- 解决 `struct S` 陷阱:在 `struct S` 案例中,尽管 A 和 B 增加的是不同字段,但它们都会试图修改“编译该头文件所在的 `.c` 文件”这一原子动作的输入参数(如传入了不同的宏 `-DA` 和 `-DB`)。系统在动作层面敏锐地捕捉到了交叠,立即判定发生**“动作碰撞”**,强制进行组合测试。 + +### 6.4 终极收敛:动作图路线暴露出的两大工程陷阱 + +在将“动作图”应用于实际 C 语言项目(如 libcurl)时,我们又遭遇了传统静态分析难以跨越的两个鸿沟。这一步的意义,不是这些问题已经被彻底攻克,而是我们明确识别出了这条路线最容易失效的边界: + +难题 A:“合并类动作”导致的构建漏斗陷阱 (The Linker Funnel) +- 现象:无论前期的编译动作多么正交,所有产生的 `.o` 文件最终都会汇聚到同一个链接器动作(如 `ld -o lib.so a.o b.o`)中。如果只要触碰同一个动作就算碰撞,所有选项都会因为最后的 `ld` 连在一起,大矩阵降维彻底失败。 +- 讨论结论:如果后续仍沿动作图方向深入,这里就必须区分**“内容变换动作(如编译)”**和**“仅合并动作(如链接、打包归档)”**。否则所有选项都会因为最终链接步骤而被误并为一个大碰撞岛。这个问题在讨论中被识别出来,但当前实现还没有把它作为独立规则完全解决。 + +难题 B:“虚假碰撞”带来的降维失效 (The `config.h` Problem) +- 现象:在 C/C++ 中,几乎所有的选项都会向一个全局的 `config.h` 文件中写入宏定义(如 `#define HAVE_ZLIB`)。由于这个文件被所有编译动作读取,这会导致整个动作图的输入指纹全面变化,系统会误判所有选项都发生了严重的“干涉”。 +- 讨论结论:单纯依赖共享输入路径会导致严重的保守过度。讨论中曾经设想过通过更强的产物级证明来识别“伪碰撞”,但这仍然属于后续可能继续探索的方向,并不是当前已经落地的能力。 + +这一步真正稳定下来的,不是“所有难题都已经解决”,而是:我们确认了动作图路线依然是最合理的主方向,同时也明确了它在工程上最需要谨慎对待的两个边界问题。 + +### 6.5 Linux 实证:libarchive 真实 CMake 项目 + +在设计讨论基本收敛后,我们又在 Linux 机器上用真实 CMake 项目做了一轮实证,目的是验证: + +- 当前实现到底能把真实矩阵从多少缩到多少。 +- “工具类 option 可能可跳过”的判断,是否能在真实项目里观察到。 + +实验对象选择了 `libarchive/libarchive@v3.8.2`,因为它同时包含两类 option: + +- 更像附加工具的选项:`ENABLE_TAR`、`ENABLE_CPIO`、`ENABLE_CAT` +- 更像核心能力的选项:`ENABLE_ACL`、`ENABLE_ZLIB`、`ENABLE_ZSTD` + +实验方法保持与当前实现一致: + +- 用真实 `llar make --matrix ...` 执行构建。 +- 用 Linux 下的 `strace` 产生 `trace.Record`。 +- evaluator 仍然按 `baseline + 单变量 probe + diff surface + collision components` 的当前逻辑运行。 +- baseline 设为六个选项全关,以便最大化观察“纯新增、独立输出”的可能性。 + +矩阵规模与结果: + +- 总组合数:`64` +- baseline:`arm64-linux|acl-off-cat-off-cpio-off-tar-off-zlib-off-zstd-off` +- 当前 evaluator 返回的必测组合数:`64` +- 缩减比例:`0%` + +碰撞结构非常激进: + +- 6 个 option 两两全部碰撞,共 `15/15` 对。 +- 最终只形成了一个连通分量: + - `{acl, cat, cpio, tar, zlib, zstd}` + +进一步看 surface 的来源,可以看到当前实现为什么会完全保守: + +- 所有 probe 的公共交集里包含大量 `/tmp/$TMP/.git/objects/*` + - 这说明当前 trace 把源码同步过程也计入了观察面。 +- 即使去掉 `.git` 与系统库噪音,pairwise overlap 仍然存在: + - `CMakeTmp` + - `.ninja_deps` + - `.ninja_log` + - `_build/libarchive/CMakeFiles/archive*.o.d` +- 也就是说,除了 source sync 之外,CMake configure 与共享 `libarchive` 构建路径本身也足以把选项重新汇成一个大碰撞岛。 + +但如果把视角从 trace 切换到**最终安装产物**,可以看到一个更细的事实: + +- `ENABLE_CAT` + - 只新增 `bin/bsdcat` 和 `share/man/man1/bsdcat.1` + - `libarchive.a`、`libarchive.so*` 哈希与 baseline 完全一致 +- `ENABLE_CPIO` + - 只新增 `bin/bsdcpio` 和 `share/man/man1/bsdcpio.1` + - `libarchive.a`、`libarchive.so*` 哈希与 baseline 完全一致 +- `ENABLE_TAR` + - 只新增 `bin/bsdtar` 和 `share/man/man1/bsdtar.1` + - `libarchive.a`、`libarchive.so*` 哈希与 baseline 完全一致 + +这三项非常接近我们讨论中所说的“purely additive, isolated outputs”。 + +与之相对: + +- `ENABLE_ACL` + - 没有新增独立交付文件 + - `libarchive.a`、`libarchive.so*` 全部变化 + - `libarchive.pc` 新增 `-lacl` +- `ENABLE_ZLIB` + - `libarchive.a`、`libarchive.so*` 全部变化 + - `libarchive.pc` 新增 `-lz` +- `ENABLE_ZSTD` + - `libarchive.a`、`libarchive.so*` 全部变化 + - `libarchive.pc` 新增 `-lzstd` + +这说明: + +- 从**真实交付物语义**看,工具类 option 与核心 feature option 的行为确实不同。 +- 但从**当前 trace + simplified action graph** 看,它们仍会因为 source sync、configure 噪音和共享构建路径被全部并岛。 + +这轮实证把一个关键边界彻底坐实了: + +- “实际存在可跳过测试的 option” 与 “当前 evaluator 能识别出来的 option” 不是一回事。 +- 当前实现的保守程度,在真实 CMake 项目上足以把本应更像附加物的工具类选项也吞进大碰撞岛。 + +为了避免把上述判断建立在“读完整 report 后的主观解释”上,我们又补了一轮**定向正式测试**,只观察 `baseline` 与 `cat-on`: + +- baseline:`arm64-linux|acl-off-cat-off-cpio-off-tar-off-zlib-off-zstd-off` +- probe:`arm64-linux|acl-off-cat-on-cpio-off-tar-off-zlib-off-zstd-off` + +这轮定向测试里直接观察到: + +- `baseline_actions = 9536` +- `probe_actions = 9407` +- `seed_count = 7578` + +也就是说,`cat-on` 相对 baseline 的差异并不是在最后生成 `bsdcat` 时才出现,而是在极早阶段就已经扩散开。 + +前几个 seed action 具体包括: + +- 顶层 `evaluator.test make --matrix ...` +- `git index-pack` +- `ninja --version` +- `ld --help` +- `CMakeScratch` 目录中的 `ninja -t recompact` +- `CMakeScratch` 目录中的 `ninja -t restat` +- `ninja cmTC_*` +- `as ...` + +这说明在正式测试中,`cat-on` 的 diff surface 很早就已经吸收了: + +- source sync 行为 +- CMake try-compile 行为 +- Ninja bookkeeping 行为 + +我们还做了另一轮更窄的正式测试,试图抓出“第一个未匹配的 `cmake -S` configure action”,结果是: + +- 没有观察到未匹配的 `cmake -S` action + +这意味着,至少在这轮正式测试里,不能把“未缩减成功”简单归因到某一条独立的 configure 命令本身。 + +同时,我们把 `cat` 与其他 probe 的 overlap 里最显眼的 `.git/objects/*` 去掉后,仍然观察到大量共享路径: + +- `cat` vs `cpio`:仍有 `117` 个共享路径 +- `cat` vs `tar`:仍有 `78` 个共享路径 +- `cat` vs `zlib`:仍有 `114` 个共享路径 + +这些共享路径里,正式测试明确出现了: + +- `_build/.ninja_deps` +- `_build/.ninja_log` +- `_build/CMakeFiles/CMakeTmp/*` +- `_build/build.ninja` +- `_build/config.h` +- `_build/libarchive/CMakeFiles/archive*.o.d` + +因此,当前能被正式测试支持的表述是: + +- `.git/objects/*` 确实是碰撞来源之一; +- 但即使去掉这一层,共享 build graph 路径仍然足以让 `cat` 与其他 option 保持碰撞; +- 当前还不能把问题收缩成单一的某条 configure 命令或单一的某个机制。 + +--- + +## 7. 当前收敛到的判断 + +本轮调研最终形成了以下较稳定的共识: + +1. LLAR 不应追求“全量矩阵跑完”。 +2. LLAR 也不应把 pairwise 当作正确性来源。 +3. LLAR 更适合采用“黑盒 probe + 动作图碰撞分析 + 岛屿化缩减”的自动模式。 +4. `onTest` 应继续作为统一的产物验证入口。 +5. 在 llarhub 的 CI 阶段,系统职责不是跑完所有组合,而是通过动作图自动找出“必须真正测试”的碰撞组合,并为正交组合发放认证指纹。 +6. 对无法证明正交的部分,系统必须保持保守,进行物理组合测试,而不是强行推导。 + +--- + +## 8. 这轮调研最大的产出 + +本轮调研最有价值的产出,不只是几份外部生态文档,而是把讨论从“泛泛而谈的测试策略”收敛到了 LLAR 自己的问题定义上。 + +具体来说,产出有三类: + +1. 外部事实文档 + - Nix 全链路测试系统调研 + - Conan 测试系统深度调研 + +2. LLAR 内部设计文档 + - 基于动作图碰撞的测试系统全面设计方案 + - 当前实现状态说明 + +3. 一个更清楚的问题边界 + - 我们要解决的是“大规模矩阵如何在黑盒条件下保守缩减”,不是“证明所有组合永远安全”,也不是“依赖特定语言的 ABI 分析”。 + +--- + +## 9. 当前仍未闭合的问题 + +虽然设计已经明显收敛,但仍有几类问题没有完全闭合: + +- 原子动作指纹的提取(Action Tracing)在跨平台环境下的工程落地细节。 +- evaluator 目前还偏保守,分析结果需要更可审计的输出。 +- 最终产物哈希叠加模型的严格界定,以及云端交付闭环还未完全落地。 +- 对于极端耦合的包,如何避免“全岛爆炸”仍需进一步的工程化隔离策略。 + +这些问题已经不再是“方向不明确”的问题,而是“如何把当前方向做稳”的问题。 + +--- + +## 10. 总结 + +这轮调研的过程,本质上经历了多次认知收敛: + +1. 先从外部生态确认:主流包管理器都不做全量穷举,也不把 pairwise 当主门禁。 +2. 再从研究路线确认:pairwise、family-based、variational execution 各自有价值,但都不满足 LLAR 所需的工程确定性或黑盒边界。 +3. 最后回到 LLAR 自身约束:多语言、黑盒、云端,使得通用 ABI 模型(如 `abidiff`)或源码文件比对不可行。 +4. 最终收敛到当前方向:以黑盒可观察证据为基础,用单变量 probe、动作图指纹和碰撞岛屿来自动缩减必须执行的测试矩阵,并在 CI 阶段发放认证。 + +当前设计并不是从“理论最优”直接推出的,而是从一轮轮现实约束与极限反例(如隐式宏耦合)中逐步锤炼出来的结果,这使得它在工程上具备了极高的稳妥性与可落地性。 diff --git a/doc/testing-system-architecture.md b/doc/testing-system-architecture.md new file mode 100644 index 0000000..f573430 --- /dev/null +++ b/doc/testing-system-architecture.md @@ -0,0 +1,185 @@ +# LLAR 测试系统:基于双图模型的大矩阵降维提案 (MVP阶段) + +## 1. 背景与目标 + +LLAR 作为一个云端、多语言的包管理器,目前正处于 MVP(最小可行性产品)阶段。在验证构建配方(Formula)时,我们面临着**大矩阵(Build Matrix)测试**的挑战:当一个底层库有几十个配置选项时,全量组合测试(笛卡尔积)在算力上是不可能的,而随机抽样又无法保证包管理器的绝对可靠性。 + +**本方案目标**:提出一套适用于 LLAR MVP 阶段的**矩阵降维策略**。在把 Package 视为“黑盒”(不解析代码语法)的前提下,通过引入**“文件变化图”**和**“参数变化图”**(双图模型),从物理和环境两个层面证明选项之间的正交性。一旦证明正交,即可安全跳过组合测试,从而将指数级爆炸的测试量坍缩为线性级。 + +--- + +## 2. 核心推导逻辑:为什么能跳过组合测试? + +大矩阵降维的理论基石是**“正交性推导(Orthogonality Deduction)”**。 + +如果我们要跳过“选项 A + 选项 B”的组合测试,我们必须在数学和物理上证明:**A 和 B 互不干涉**。为了在黑盒下完成这个证明,系统会收集 A 和 B 各自单开时的构建动作,并把它们放进两张图里求“交集”。 + +### 2.1 视图一:文件变化图 (防物理传播与覆盖) +我们提取选项 A 和 B 的读写动作: +- $W_A, W_B$: 各自写入的文件集合(中间件、最终产物)。 +- $R_A, R_B$: 各自读取的文件集合(源码、头文件)。 + +**文件图正交证明**: +如果同时满足以下三个条件(交集为空): +1. **无覆盖**:$W_A \cap W_B = \emptyset$ (A 和 B 没有修改同一个文件) +2. **A 不传给 B**:$W_A \cap R_B = \emptyset$ (B 没有读取 A 产生的文件) +3. **B 不传给 A**:$R_A \cap W_B = \emptyset$ (A 没有读取 B 产生的文件) +则判定:A 和 B 在物理流水线上是**文件正交**的。 + +```mermaid +graph TD + subgraph 文件图交集判断 + A_Write[A 写入集 WA] + B_Write[B 写入集 WB] + B_Read[B 读取集 RB] + A_Read[A 读取集 RA] + + A_Write -. 必须为∅ .- B_Write + A_Write -. 必须为∅ .- B_Read + A_Read -. 必须为∅ .- B_Write + end + style A_Write fill:none,stroke:#d9534f,stroke-width:2px + style B_Write fill:none,stroke:#5bc0de,stroke-width:2px + style A_Read fill:none,stroke:#d9534f,stroke-width:2px + style B_Read fill:none,stroke:#5bc0de,stroke-width:2px +``` + +### 2.2 视图二:参数变化图 (防宏污染与环境联动) +有些时候,A 和 B 没碰同一个文件,但它们往同一个编译命令里塞了不同的参数(比如 A 塞了 `-DA`,B 塞了 `-DB`)。如果不拦住,这种“共享动作”极易引发 C 语言结构体大小改变等隐性崩溃(`struct S` 联动陷阱)。 + +- $P_A, P_B$: 各自修改了哪些**Baseline 已经存在的共享原子动作**(例如原本就有的 `gcc core.c`)。 + +**参数图正交证明**: +- 如果 $P_A \cap P_B = \emptyset$ (A 和 B 没有试图修改同一个核心动作的环境)。 +则判定:A 和 B 在逻辑环境上是**参数正交**的。 + +```mermaid +graph LR + subgraph 参数图交集判断 + Param_A[A 注入环境 PA] + Param_B[B 注入环境 PB] + Shared_Action((Baseline 共享动作)) + + Param_A -. 必须不交汇 .- Shared_Action + Param_B -. 必须不交汇 .- Shared_Action + end + style Shared_Action fill:none,stroke:#f0ad4e,stroke-width:2px + style Param_A fill:none,stroke:#d9534f,stroke-width:2px + style Param_B fill:none,stroke:#5bc0de,stroke-width:2px +``` + +### 2.3 降维结论推导 +**推导定理**: +如果 `文件图交集 == ∅` **且** `参数图交集 == ∅`,说明选项 A 和选项 B 就像在两条完全隔离的流水线上运行。 +既然它们物理不碰头,环境不污染,那么把它们组合在一起(A+B),其结果必然等于 A 产物加 B 产物的简单合并。**因此,只要单测 A 通过,单测 B 通过,我们就能确定性地跳过 A+B 的组合测试。** + +--- + +## 3. Step-by-Step 实战演练:双图推导过程 + +假设网络库 `LibNet` 有三个选项:**A (HTTP2)**, **B (IPV6)**, **C (ZLIB 独立插件)**。 + +### 第一步:探测记录 ($O(N)$ 扫描) +系统分别单开 A, B, C,记录它们的动作指纹。 + +```mermaid +graph TD + subgraph OptionC ["选项 C (ZLIB) 的动作图"] + C_Action1["gcc -c zip_plugin.c"] + C_Read1("读: zip_plugin.c") -.-> C_Action1 + C_Action1 -.-> C_Write1("写: zip_plugin.o") + end + + subgraph OptionB ["选项 B (IPV6) 的动作图"] + B_Action1["gcc -DB -c core.c"] + B_Read1("读: core.c") -.-> B_Action1 + B_Action1 -.-> B_Write1("写: core.o") + end + + subgraph OptionA ["选项 A (HTTP2) 的动作图"] + A_Action1["gcc -DA -c core.c"] + A_Read1("读: core.c") -.-> A_Action1 + A_Action1 -.-> A_Write1("写: core.o") + end + + style A_Action1 fill:none,stroke:#d9534f,stroke-width:2px + style B_Action1 fill:none,stroke:#5bc0de,stroke-width:2px + style C_Action1 fill:none,stroke:#5cb85c,stroke-width:2px +``` + +* **A (HTTP2)**:修改了共享动作 `gcc -c core.c`(加了参数 `-DA`),产出了 `core.o`。 +* **B (IPV6)**:修改了共享动作 `gcc -c core.c`(加了参数 `-DB`),产出了 `core.o`。 +* **C (ZLIB)**:新增了全新动作 `gcc -c zip_plugin.c`,产出了独立的 `zip_plugin.o`。 + +### 第二步:双图求交与降维决策 + +现在,系统通过计算文件图和参数图的交集,来决定哪些组合必须测试,哪些可以安全跳过。 + +#### 决策 1:A 与 B 是否正交?(发生碰撞) + +```mermaid +graph TD + subgraph 判定 A 与 B + direction LR + A_W[WA: core.o] + B_W[WB: core.o] + A_P[PA: 修改 gcc core.c] + B_P[PB: 修改 gcc core.c] + + A_W <-->|文件图: 写冲突| B_W + A_P <-->|参数图: 环境干涉| B_P + end + style A_W fill:none,stroke:#d9534f + style B_W fill:none,stroke:#5bc0de + style A_P fill:none,stroke:#d9534f + style B_P fill:none,stroke:#5bc0de +``` + +* **查文件图**:$W_A$ 包含 `core.o`,$W_B$ 包含 `core.o`。$W_A \cap W_B \neq \emptyset$。**发生写冲突。** +* **查参数图**:$P_A$ 包含了 `gcc core.c`,$P_B$ 也包含了 `gcc core.c`。$P_A \cap P_B \neq \emptyset$。**发生环境干涉。** +* **结论**:A 与 B 双图均有交集。系统判定它们发生**“碰撞 (Collision)”**,**不能跳过组合测试**。CI 必须硬扛跑完 `A+B`。 + +#### 决策 2:A 与 C 是否正交?(完美放行) + +```mermaid +graph TD + subgraph 判定 A 与 C + direction LR + A_W2[WA: core.o] + C_W[WC: zip_plugin.o] + A_P2[PA: 修改 gcc core.c] + C_P[PC: 新增 gcc zip_plugin.c] + + A_W2 -.->|文件图: ∅ 无交集| C_W + A_P2 -.->|参数图: ∅ 无交集| C_P + end + style A_W2 fill:none,stroke:#d9534f + style C_W fill:none,stroke:#5cb85c + style A_P2 fill:none,stroke:#d9534f + style C_P fill:none,stroke:#5cb85c +``` + +* **查文件图**:$W_A$ 是 `core.o`,$W_C$ 是 `zip_plugin.o`,无写冲突(最终的 `ld` 属于仅合并动作,已被系统降噪)。C 没有读 A 的输出,反之亦然。$W_A \cap W_C = \emptyset$,$W_A \cap R_C = \emptyset$。 +* **查参数图**:$P_A$ 修改了 `core.c` 的环境,$P_C$ 仅仅是给自己的新文件 `zip_plugin.c` 配了环境,没有修改任何共享主干动作。$P_A \cap P_C = \emptyset$。 +* **结论**:A 与 C 双图交集全部为空!系统完成证明,判定它们**“完全正交”**。**安全跳过 `A+C` 组合测试。** + +--- + +## 4. MVP 阶段的工程噪音过滤 + +理想的空集很难在真实工程(如 CMake/Boost)中出现。为了保证降维策略在 MVP 阶段的实用性,系统引入了过滤机制以消除“伪交集”: + +1. **降噪 Install 拷贝(解开 Boost 假死锁)** + 如果 A 和 B 都拷贝了 `include/common.h` 到最终目录,文件图会产生 $W_A \cap W_B \neq \emptyset$。 + **规则**:对于纯 `copy`/`install` 且没有任何后续动作读取它的叶子节点,系统将其从文件图的干涉计算中强行剥离。 +2. **剔除构建账本(解开 CMake 假死锁)** + 不论改什么,CMake 必写 `CMakeCache.txt`。 + **规则**:建立系统黑名单,强行剔除这类仅仅是构建工具私有账本的写入记录,不让它们污染求交集的过程。 + +--- + +## 5. 远期防线:产物终极核对 + +尽管双图推导在逻辑上很完美,但在黑盒模式下,为了防范极端魔法(比如隐式全局状态),系统在最终交付时仍需一道防线。 + +- **兜底校验**:当云端决定放行被判定为正交的组合 `A+C` 时,它会检查 `A+C` 产出的安装目录指纹。如果发现它**不等于**单开 A 加上单开 C 的逻辑合并(即出现了非线性的文件或哈希突变),系统立刻熔断,判定隐性联动发生,强制补跑测试。 diff --git a/doc/testing-system-design-revised.md b/doc/testing-system-design-revised.md new file mode 100644 index 0000000..a13c858 --- /dev/null +++ b/doc/testing-system-design-revised.md @@ -0,0 +1,380 @@ +# LLAR 测试系统设计稿 + +## 1. 背景 + +LLAR 是一个全云端、多语言、黑盒式的包管理器。它不理解源码语义,只负责按 Formula 调度构建命令,并产出可交付结果。 + +这使测试系统面临一个典型矛盾: + +- 包的配置矩阵可能极大,无法做全量物理测试。 +- 包管理器又不能依赖随机抽样来放行未充分验证的组合。 +- 系统还必须保持语言无关,不能把某一种语言的 ABI 或运行时规则提升为平台基础设施。 + +因此,LLAR 需要的不是“更聪明的抽样”,而是一套基于黑盒可观察证据的、保守的、可自动缩减矩阵的测试体系。 + +## 2. 设计目标 + +本设计面向以下目标: + +1. 在大规模构建矩阵下,自动缩减必须物理执行的测试组合。 +2. 缩减依据来自可观察证据,而不是随机抽样或经验假设。 +3. 保持对 C/C++、Python 扩展、Go、纯二进制解压分发等场景的统一适用性。 +4. 保持 Formula DSL 稳定,不把分析复杂度转移给配方作者。 +5. 将“构建”和“验证”区分为两个清晰阶段,但保持统一的用户入口。 + +## 3. 核心思想 + +系统的核心判断不是“这个包属于什么语言”,而是: + +- 一个 option 变化后,会影响哪些构建动作。 +- 这些影响是否会与其他 option 的影响发生碰撞。 +- 如果不会碰撞,是否可以只测试代表性组合。 +- 如果会碰撞,就必须在对应碰撞岛内部展开必要组合。 + +这里的“碰撞”不是源码级冲突,而是黑盒层面的构建影响重叠。 + +## 4. 用户模型 + +### 4.1 Formula 侧 + +Formula 通过 `onTest` 描述产物验证逻辑。 + +`onTest` 的目标不是参与构建,而是从消费者视角验证已安装产物的最小可用性,例如: + +- 二进制是否能启动。 +- 动态库是否能被最小程序链接并运行。 +- 解释型扩展是否能被加载。 + +### 4.2 命令侧 + +系统对外提供三个测试入口: + +- `llar test` +- `llar test --full` +- `llar test --auto` + +其中: + +- `llar test` 默认面向 default options 组合,执行构建并验证产物。 +- `llar test --full` 面向整个显式矩阵,执行全量真实测试。 +- `llar test --auto` 面向整个配置空间,自动判断必须执行哪些组合,并只对这些组合运行真正的验证。 + +## 5. 系统模型 + +整个测试系统由四个角色构成: + +- 命令编排层:负责组织测试流程。 +- 构建执行层:负责执行构建和产物验证。 +- Trace 观察层:负责观察单个组合的 `build-only` 行为。 +- Evaluator 分析层:负责根据观察结果缩减矩阵。 + +### 5.1 模块关系 + +```mermaid +flowchart TD + CLI["llar test / llar test --full / llar test --auto"] --> ORCH["命令编排层"] + ORCH --> BUILD["构建执行层"] + ORCH --> TRACE["Trace 观察层"] + ORCH --> EVAL["Evaluator 分析层"] + TRACE --> EVAL + EVAL --> ORCH + BUILD --> ORCH +``` + +### 5.2 职责边界 + +命令编排层负责: + +- 解析目标模块和矩阵。 +- 选择 default、full 或 auto 模式。 +- 调用 evaluator 获得测试计划。 +- 执行最终需要验证的组合。 + +构建执行层负责: + +- 执行 Formula 的构建逻辑。 +- 安装产物。 +- 运行 `onTest`。 + +Trace 观察层负责: + +- 在 `build-only` 条件下观察构建命令。 +- 归纳出命令级记录。 + +Evaluator 分析层负责: + +- 选择 baseline 和 probe。 +- 归一化观察记录。 +- 分析不同 option 的影响面。 +- 输出必须真正执行测试的组合。 + +## 6. Trace 观察模型 + +自动模式下,系统不会先执行所有组合的 `onTest`。它会先观察一小组代表性构建。 + +Trace 观察层只关心四类事实: + +- 命令是什么。 +- 命令在哪个目录执行。 +- 命令读取了哪些路径。 +- 命令修改了哪些路径。 + +这一步只观察 `build-only`,不执行 `onTest`。原因是自动分析的目标是识别 option 对构建过程的影响,而不是先做功能验证。 + +## 7. 归一化 + +如果不做归一化,同一条逻辑构建动作会因为临时目录、随机文件名、工作目录差异而被误判为不同动作。 + +因此 evaluator 在分析前需要对 trace 记录做归一化。归一化遵循两条原则: + +1. 只消除噪声,不消除真实配置差异。 +2. 先做通用归一化,再做命令族归一化。 + +通用归一化处理: + +- 临时目录。 +- 工作空间根路径。 +- 构建输出根路径。 +- 随机后缀。 + +命令族归一化处理: + +- 编译器与链接器命令。 +- `cmake`、`ninja`、`make` 等构建系统命令。 +- `python -m ...`、`pip` 等解释器入口命令。 +- 其他可识别的通用工具链命令。 + +归一化的目标不是理解语言语义,而是把“同一个逻辑动作”重新对齐。 + +## 8. 动作图与碰撞分析 + +### 8.1 动作图 + +在 evaluator 看来,一条经过归一化的命令记录可以视作一个构建动作。 + +如果某个动作修改了路径 `X`,后续另一个动作读取了路径 `X`,则这两个动作之间存在依赖。 + +由此可以得到一个构建动作图。 + +```mermaid +flowchart LR + A["动作 A: 编译 core.c"] --> B["动作 B: 链接 libfoo"] + C["动作 C: 生成 cli.o"] --> D["动作 D: 链接 foo"] + B --> D +``` + +动作图回答的问题是: + +- 一个 option 改变后,最先影响了哪些动作。 +- 这些影响会沿着依赖关系继续传播到哪些动作和产物。 + +### 8.2 影响面 + +每个 option 相对 baseline 都会形成自己的影响面。 + +影响面不仅包含“直接变化的动作”,也包含这些变化通过动作图向下游传播后触及的动作和产物。 + +### 8.3 碰撞 + +如果两个 option 的影响面发生重叠,则认为这两个 option 发生碰撞。 + +碰撞意味着: + +- 不能仅凭两个单变量 probe 的结果推导它们的组合。 +- 必须把它们放入同一个碰撞岛中处理。 + +如果两个 option 的影响面互不相交,则它们可视为正交。 + +## 9. 矩阵缩减策略 + +系统不直接全量展开矩阵,而是采用如下策略: + +1. 选择 baseline。 +2. 对每个 option 做单变量 probe。 +3. 观察每个 option 相对 baseline 的影响面。 +4. 根据碰撞关系构造碰撞图。 +5. 将碰撞图划分为若干连通分量。 +6. 对每个连通分量单独决定需要测试的组合。 + +### 9.1 直观含义 + +- 单独的独立项,只需要最小覆盖。 +- 发生碰撞的一组项,必须在组内展开必要组合。 + +这意味着: + +- 系统不会承诺“任何矩阵都能降到线性”。 +- 系统只会把真正正交的部分降下来。 +- 对无法证明正交的部分,系统保持保守。 + +### 9.2 缩减示意 + +```mermaid +flowchart TD + M["原始矩阵"] --> B["baseline"] + M --> P["单变量 probe"] + B --> E["Evaluator"] + P --> E + E --> G["碰撞图"] + G --> I1["独立项"] + G --> I2["碰撞岛"] + I1 --> T1["最小测试集合"] + I2 --> T2["岛内必要组合"] + T1 --> OUT["最终测试计划"] + T2 --> OUT +``` + +## 10. 自动模式工作流 + +自动模式的目标不是“把所有组合跑一遍”,而是“对整个配置空间给出测试结论”。 + +工作流如下: + +```mermaid +sequenceDiagram + participant U as User + participant C as llar test --auto + participant T as Trace + participant E as Evaluator + participant B as Build/Test + + U->>C: 发起自动测试 + C->>E: 提供矩阵定义 + E-->>C: 请求 baseline 与 probe + C->>T: 对 probe 组合做 build-only 观察 + T-->>C: 返回命令级记录 + C->>E: 提交观察结果 + E-->>C: 返回必须真正测试的组合 + C->>B: 对这些组合执行 build + onTest + B-->>C: 返回测试结果 + C-->>U: 输出矩阵级结论 +``` + +这里有一个关键边界: + +- probe 阶段只负责观察。 +- verify 阶段才真正执行 `onTest`。 + +## 11. 示例 + +假设一个包有四个 option: + +- `tls` +- `shared` +- `cli` +- `doc` + +总组合数是 16。 + +系统先选择以下 probe: + +- baseline +- `tls=on` +- `shared=on` +- `cli=on` +- `doc=on` + +若观察到: + +- `doc` 只影响文档生成相关动作。 +- `cli` 只影响命令行工具构建相关动作。 +- `tls` 改动主库编译与链接。 +- `shared` 也改动主库编译与链接。 + +则 evaluator 会形成如下碰撞结构: + +```mermaid +graph TD + DOC["doc"] + CLI["cli"] + TLS["tls"] --- SHARED["shared"] +``` + +由此得到三个岛: + +- `{doc}` +- `{cli}` +- `{tls, shared}` + +最终测试计划可以缩减为: + +- baseline +- `doc=on` +- `cli=on` +- `tls/shared` 岛内部的必要组合 + +因此,系统不需要对全部 16 个组合都执行 `onTest`。 + +### 11.1 真实项目观测备注 + +上述例子表达的是**理想化碰撞结构**,不是当前实现对所有项目都已经具备的识别能力。 + +在一轮 Linux 实证中,我们用真实 CMake 项目 `libarchive/libarchive@v3.8.2` 测了 6 个 option: + +- 工具类:`ENABLE_TAR`、`ENABLE_CPIO`、`ENABLE_CAT` +- 核心能力类:`ENABLE_ACL`、`ENABLE_ZLIB`、`ENABLE_ZSTD` + +矩阵总规模是 `64`,baseline 为六项全关。当前 evaluator 的结果是: + +- 必测组合数:`64` +- 缩减比例:`0%` +- 6 个 option 在当前 trace 模型下全部落入同一个碰撞岛 + +但如果直接比较最终安装产物,可以看到另一层事实: + +- `ENABLE_TAR`、`ENABLE_CPIO`、`ENABLE_CAT` + - 主要只是新增各自的可执行文件和 man page + - `libarchive.a`、`libarchive.so*` 与 baseline 保持一致 +- `ENABLE_ACL`、`ENABLE_ZLIB`、`ENABLE_ZSTD` + - 会直接改变 `libarchive.a`、`libarchive.so*` + - 并改变 `libarchive.pc` 的依赖语义 + +这说明: + +- “工具类 option 可能形成可跳过测试候选”这一判断,在真实项目里是能观察到的。 +- 但当前实现在正式诊断测试中,确实观察到了这些 option 会很早卷入共享路径: + - `git index-pack` + - `CMakeScratch` 下的 `ninja`/`ld`/`as` + - `_build/.ninja_*` + - `_build/CMakeFiles/CMakeTmp/*` + - `_build/config.h` + - `_build/libarchive/CMakeFiles/archive*.o.d` +- 而且在额外的定向正式测试中,并没有抓到“未匹配的 `cmake -S` configure action”,因此当前证据只能支持“共享 build graph 很早就把这些 option 并岛”,不能支持把原因简化成某一条单独 configure 命令。 + +因此,本设计文档里关于“独立项可最小覆盖”的描述,应理解为**目标语义**,不是当前实现已经在所有真实项目上稳定达成的效果。 + +## 12. 设计取舍 + +本设计选择的是“保守缩减”,而不是“激进推断”。 + +这意味着: + +- 如果系统能证明正交,就缩减测试量。 +- 如果系统不能证明正交,就扩大测试范围。 + +这种取舍的直接结果是: + +- 可能会多跑一些测试。 +- 但不会为了追求缩减率而对不确定组合做乐观放行。 + +## 13. 非目标 + +本设计明确不以以下路线作为核心方案: + +- 随机抽样或 pairwise 覆盖作为放行依据。 +- 基于特定语言 ABI、符号表或调试信息的分析。 +- 依赖特定语言运行时改造的执行模型。 +- 将矩阵缩减责任主要转移给 Formula 作者。 + +这些路线要么不满足黑盒要求,要么无法为未观察组合提供工程上足够稳妥的结论。 + +## 14. 结论 + +LLAR 测试系统的核心,不是追求“全量物理执行”,而是在黑盒、多语言和大矩阵约束下,通过构建动作观察、归一化、碰撞分析和保守缩减,找出真正必须执行的测试组合。 + +它的价值在于: + +- 保持语言无关。 +- 保持接口稳定。 +- 在大矩阵下维持工程可执行性。 +- 对无法证明安全的情况保持保守。 diff --git a/doc/testing-system-dual-graph-refactor.md b/doc/testing-system-dual-graph-refactor.md new file mode 100644 index 0000000..abce9e0 --- /dev/null +++ b/doc/testing-system-dual-graph-refactor.md @@ -0,0 +1,594 @@ +# LLAR 测试系统完整设计稿:构建图与产物合并的混合验证 + +## 1. 设计目标 + +LLAR 测试系统要解决的不是“如何做更聪明的抽样”,而是一个更具体的问题: + +- 给定一个带 option 矩阵的 Formula +- 如何在不跑全矩阵的前提下 +- 尽可能少跑组合 +- 但仍然对“哪些组合必须真实验证”给出可信结论 + +这里“降低维度”的真正目的不是追求数学上的覆盖率,而是: + +1. 降低真实构建和测试成本 +2. 尽可能把明显独立的 option 从全矩阵里拿掉 +3. 让真正高风险的 pair 和组合重新回到真实执行集合 +4. 维持语言无关和构建系统无关,不把某种上游语义写死到 LLAR 基础设施里 + +系统最终服务的对象是: + +- Formula 作者 +- `llar test --auto` +- 自动生成 Formula 的 AI + +因此,方案必须足够稳定、足够可解释,而且维护成本不能无限膨胀。 + +## 2. 核心约束 + +设计过程中,有几条约束是一直不变的: + +1. **不能依赖统计抽样来放行未验证组合** + 包验证是强约束场景,pairwise 这类统计覆盖不能作为主判据。 + +2. **不能指望系统自动理解所有构建语义** + CMake、Autotools、shell 脚本、Python 包装器、自定义生成器混在一起时,试图恢复完整构建语义会迅速失控。 + +3. **不能要求 Formula 作者人工声明“哪些 option 正交”** + 这种信息既难保证正确,也无法长期维护。 + +4. **不能把 singleton 观察误当成对 `A+B` 的形式证明** + 只看 `baseline / A / B`,永远无法证明 `A&&B` 不会引入新分支。 + +这几条约束,决定了我们最后不可能得到“完美正交证明器”,只能得到一套工程上可用的反证和验证系统。 + +## 3. 第一阶段:构建图驱动方案 + +### 3.1 最初设想 + +最初的设计路线是: + +1. 跑 baseline +2. 对每个 option 跑 singleton probe +3. 通过 trace 观察构建过程 +4. 做路径归一化和 `tooling` 降噪 +5. 建动作图和路径传播图 +6. 比较不同 option 的影响面是否相交 +7. 相交则认为不正交,不相交则尝试跳过对应组合 + +最初我们想让系统回答的是: + +- option 改了哪些构建动作 +- 这些变化是否沿构建图继续传播 +- 两个 option 的传播链是否明确交汇 + +如果这些问题都能稳定回答,矩阵理论上就能大幅缩减。 + +### 3.2 这条路为什么一开始看起来合理 + +原因很简单: + +- singleton probe 成本比全矩阵低得多 +- trace 是顺手能得到的证据 +- 很多明显的 configure/probe/install 噪声,确实需要剥掉 +- 对很多小库而言,构建影响面看起来确实比较规整 + +所以一开始,“先理解构建过程,再缩减矩阵”是非常自然的路线。 + +## 4. 第一阶段遇到的困难 + +### 4.1 `tooling` 降噪复杂度迅速膨胀 + +为了不让 configure/probe/install 污染主图,我们引入了: + +- `tooling` 降噪 +- sidecar/control-plane 识别 +- shell 包装器识别 +- 生成路径和中间文件的特殊处理 + +问题在于,这些逻辑很快从“剥离噪声”变成了“尝试理解业务构建动作”。 + +系统开始不断面临这些问题: + +- 这条 shell 命令到底是不是 compile wrapper +- 这个 Python/perl/sh 动作到底算 configure 还是业务生成 +- 这个 `.d` / `.tmp` / `TryCompile-*` 文件到底算 noise 还是业务输入 +- 这个 archive 改动到底是 feature 差异还是构建噪声 + +维护成本越来越高,而且每加一层规则,都在把 evaluator 推向“半个构建语义恢复器”。 + +### 4.2 动作语义识别收益越来越差 + +我们一度扩展过: + +- compile +- link +- archive +- codegen +- wrapper 识别 + +后面又回退成: + +- `configure` +- `copy` +- `install` +- `generic` + +回退的原因很明确: + +- 复杂动作分类并没有真正解决核心问题 +- 反而把系统复杂度推高 +- 很多复杂库最终仍然无法给出可信裁决 + +### 4.3 singleton 构建图无法解决 `A&&B` + +这是整个第一阶段最重要的逻辑缺陷。 + +构建图再细,也只能观察: + +- `baseline -> A` +- `baseline -> B` + +它永远无法仅凭 singleton 观察证明: + +- `A+B` 不会引入新的构建分支 +- `A+B` 不会新增只有双开才出现的生成步骤 + +一个最小反例就是: + +```c +#if defined(A) && defined(B) +#error unsupported combination +#endif +``` + +在这种情况下: + +- `A` 单开 build 成功 +- `B` 单开 build 成功 +- singleton 图看不出任何问题 +- 真实 `A+B` 却可能直接在构建期失败 + +这说明构建图最多只能做: + +- fast reject +- debug 解释 + +不能承担“最终证明可以跳过 `A+B`”的职责。 + +### 4.4 事件级 trace 只能修时序误判,不能修组合涌现 + +后面我们还往前走了一步: + +- 把 trace 从聚合 `Record` 扩到 `Event` +- 用 event 重建更准确的输入 +- 修掉了 `ar` 临时文件、路径归一化、`InputDigests`、compiler child 传播等问题 + +这一步有价值,它解决了很多: + +- 乱序导致的假冲突 +- archive temp 噪声 +- build-root 输入内容变化丢失 + +但它仍然没法解决 `A&&B` 才出现的新逻辑。 + +因此,event 化证明了“更准确的 trace 有价值”,但也同时证明了“即使 trace 更准确,构建图也依然不是最终裁决器”。 + +## 5. 第一阶段得出的结论 + +第一阶段最终留下了一个很清楚的结论: + +**构建图有用,但它的价值主要是辅助价值,不是最终验证价值。** + +它能做的是: + +- 发现明显不正交 +- 解释为什么某个 pair 看起来有风险 +- 作为 debug 图帮助分析 trace 和路径传播 + +它做不到的是: + +- 从 singleton 观察形式化证明 `A+B` 安全可跳过 + +这也是后面整个方案转向的起点。 + +## 6. 第二阶段:引入产物合并 + +在意识到构建图不能承担最终职责之后,设计问题被重新定义了: + +我们真正关心的不是: + +- `A+B` 的构建过程是否和 `A`、`B` 各自过程可加 + +而是: + +- `A` 和 `B` 的**交付结果**是否可加 +- 合并后的交付结果是否真的满足 `A+B` 的预期行为 + +这就是第二阶段的核心转向: + +- 从**构建过程推理** +- 转向**产物合并验证** + +### 6.1 为什么产物是更合理的主判据 + +因为用户真正交付和消费的是: + +- 头文件 +- 静态库/动态库 +- pkg-config/CMake metadata +- 可执行文件 +- 安装树 + +如果最终交付树本身无法安全相加,那么前面的构建图再漂亮也没有意义。 + +反过来,如果交付树能够相对于 baseline clean merge,并且 merged 结果能通过 `A+B` 的测试,那对当前产品定义来说,已经是非常强的通过证据了。 + +### 6.2 从“构建过程正交”到“结果可加”的理论推导 + +这一步是整个方案转向的理论基础。 + +最初我们追求的是: + +- **构建过程正交** + +它的直观含义是: + +- option A 对构建过程的影响闭包,与 option B 对构建过程的影响闭包,不发生交叉 + +这里的“影响闭包”必须理解成**传递闭包**,而不是“直接碰到的第一批文件”: + +- A 改了某个输入 +- 后续哪些动作会读取它 +- 这些动作又会写出哪些中间物和最终产物 + +只要把这整条传播链都算进去,构建过程正交就意味着: + +- A 和 B 不会共同影响同一个下游交付结果 +- 也不会在同一条下游传播链上互相依赖 + +在固定环境下,如果再附加三条常规假设: + +1. **构建是确定性的** + 同一输入、同一工具链、同一环境下,构建结果稳定。 + +2. **交付结果只由其依赖闭包决定** + 一个最终产物的内容,只取决于到达它的输入、动作和中间结果。 + +3. **不存在未观测的共享全局状态** + 例如某种 singleton 中不可见、但会同时影响 A 和 B 的隐藏输入。 + +那么就可以推出: + +- 任意一个最终交付路径 `p` +- 要么只受 A 影响 +- 要么只受 B 影响 +- 要么完全不受 A/B 影响 + +于是相对于 baseline,最终交付差异必然满足: + +- `p` 只在 A 侧变化 +- 或 `p` 只在 B 侧变化 +- 或 `p` 两边都不变 + +在这种理想模型下,`A+B` 的交付结果就应当等价于: + +- 先在 baseline 上施加 A 的交付差异 +- 再施加 B 的交付差异 + +也就是: + +- **构建过程正交 ⇒ 交付结果可加** + +这就是我们后来转向 merge 判据的理论来源。 + +需要强调两点: + +1. 这里推出的是: + - `构建过程正交 => 结果可加` + 不是: + - `结果可加 => 构建过程正交` + +2. 这条推导只在“构建过程正交本身成立并且可被可靠验证”时成立 + 问题恰恰在于:在黑盒条件下,这个前提本身太难被稳定证明。 + +所以我们的设计演进不是“定义偷换”,而是: + +- 最初试图直接验证一个更强命题:构建过程正交 +- 后来发现这个强命题在工程上难以可靠验证 +- 因此转而直接验证它推导出来的、更弱但可落地的命题:交付结果可加 + +## 7. 第二阶段的主方案 + +当前最终采用的主方案是: + +1. baseline probe +2. singleton probes +3. 先用构建图做第一层反证,得到图层已经认为必须真实执行的组合 +4. 再对代表性的 singleton pair 做 `base / A / B` 三方产物合并 +5. merge clean 则在 merged output tree 上跑 `A+B` 的 `onTest` +6. merge 冲突或 merged `onTest` 失败,则把该 pair 加回真实执行集合 + +也就是说,当前主流程不是“纯图”也不是“纯产物”,而是: + +- **构建图做第一层反证** +- **产物 merge + merged `onTest` 做第二层补充验证** + +## 8. 当前自动模式的真实行为 + +### 8.1 默认行为 + +现在 `llar test --auto` 默认: + +- 会采集 trace +- 会构建图层证据 +- 同时保留产物 merge 验证路径 + +`--trace-dump` 只是额外把原始 trace 打印出来,不改变自动模式本身依赖构建图这一事实。 + +这意味着当前系统仍然保留: + +- **图层证据** +- **产物层证据** + +### 8.2 probe 组合 + +自动模式先执行: + +- baseline +- 每个 option key 的 singleton + +图层基于这些 probe 先做第一层判断: + +- 哪些 option 已经在构建影响面上发生碰撞 +- 哪些组合因此必须先进入真实执行集合 + +随后,pair 层验证使用的是: + +- 每个 option key 的第一个非默认值,作为代表值 + +因此当前实现更准确地说是: + +- baseline +- all singletons +- graph-required combos +- representative singleton pairs that failed artifact validation + +对于现在大量布尔 option 的配方,这与常见的 pair 层验证基本一致。 + +## 9. 当前三方合并规则 + +当前合并逻辑位于 `internal/evaluator/merge.go`,采用 `base / left / right` 三方合并。 + +### 9.1 metadata + +metadata 先走普通三方合并: + +- `left == right` +- `left == base` +- `right == base` + +如果失败,再尝试一层保守的 flag-append 合并: + +- 只处理 `-lfoo -lbar` 这类 token 化 flags +- 只允许左右相对 `base` 做追加 + +### 9.2 文本文件 + +文本文件走三方文本 merge: + +- `left == right` +- `left == base` +- `right == base` +- 否则做 `diff3` 风格三方合并 + +### 9.3 `.a` 静态库 + +`.a` 不按整个文件粗暴覆盖,而是拆成 archive members 比较: + +- member 相同,取任一边 +- 一边等于 `base`,取另一边 +- 同一 member 左右都改了且内容不同,冲突 + +这里日志里出现的 `xmlparse.c.o`、`ActiveDispatcher.cpp.o` 之类,不是因为系统顶层在对比 `.o` 产物,而是: + +- 真正冲突的是 `.a` +- 为了说明 `.a` 为什么冲突,才把里面冲突的 member 名展示出来 + +### 9.4 其他文件 + +其他文件目前只接受保守情况: + +- `left == right` +- `left == base` +- `right == base` + +否则直接冲突。 + +## 10. merged `A+B` 测试 + +产物 merge clean 后,系统不会直接宣布通过,而是继续跑: + +- merged output tree 上的 `A+B` `onTest` + +这一步有两个作用: + +1. 验证合并后的交付物是否仍满足联合行为 +2. 把“纯产物 merge clean 但语义不成立”的情况重新打回执行集合 + +它能发现: + +- 某个能力被另一边覆盖 +- 两边都在但联合行为不成立 +- 文本 merge clean 但运行时不正确 + +它不能证明: + +- 真实 `A+B` 从源码构建一定成功 +- 真实 `A+B` 不会走到 singleton 里未观察到的构建分支 + +但这已经符合当前产品定义: + +- 我们验证的是**交付结果是否可加** +- 以及**联合行为是否成立** + +而不是**构建过程是否形式化正交** + +## 11. 当前方案经历过的几个关键困难点 + +### 11.1 metadata 冲突过多 + +一开始很多 pair 并不是产物本身冲突,而是 metadata 冲突,比如: + +- `-lPocoFoundation` +- `-lPocoJSON` +- `-lPocoXML` + +最后的取舍是: + +- 不做通用智能 merge +- 只做非常保守的 flag-append merge + +这解决了大量“只是 link flags 追加”的误报,同时没有把 metadata 变成黑盒猜测系统。 + +### 11.2 archive 冲突信息不可读 + +最开始日志只会给出: + +- archive digest 不同 + +这对定位问题几乎没帮助。 + +后来逐步改成: + +- 先显示哪个 `.a` 冲突 +- 再显示内部哪些 member 冲突 +- 再尽量显示这些 member 里有哪些定义符号 + +最终的取舍是: + +- 默认展示人能读懂的 member 名和符号上下文 +- 不把大量 digest 明细当成主展示 + +### 11.3 `.o` 里可能有时间戳或构建噪声 + +这是产物驱动方案天然会遇到的问题。 + +当前处理方式是分层保守: + +- archive 容器头部的 `mtime/uid/gid/mode` 已经忽略 +- `.o` body 里的时间/version stamp 目前**不做通用归一化** + +原因是: + +- object-level 智能归一化代价很高 +- 很容易重新掉回“理解编译产物语义”的复杂泥潭 + +最终取舍是: + +- 先接受这类情况会造成保守误报 +- 只有在确证某类噪声大量出现时,再考虑非常窄的豁免 + +### 11.4 构建图是否还能帮助降低 merge 噪声 + +我们后面专门反思过这个问题。 + +结论是: + +- 构建图可以帮助**解释**为什么某个 pair 有风险 +- 也可以帮助**提前筛掉明显不行的 pair** +- 但它很难帮助**原谅一个 merge 冲突** + +因为 merge 噪声本质是: + +- 产物内容问题 + +而不是: + +- 依赖传播问题 + +所以最终取舍是: + +- 图只能用来“拦”和“解释” +- 不能用来“原谅”一个 merge 冲突 + +## 12. 为什么最终没有走“纯构建图”或“纯产物”两条极端路线 + +### 12.1 为什么没有继续走纯构建图 + +因为它做不到: + +- 仅凭 singleton 观察证明 `A&&B` 不会引入新分支 + +但它仍然有价值: + +- 发现明显不正交 +- 提供第一层反证 +- 解释为什么某个 pair 已经高风险 + +### 12.2 为什么也不能走纯产物 + +因为如果完全抛弃构建图,只保留 merge + merged `onTest`,那系统会退化成: + +- 不再关心构建影响面 +- 不再提前发现明显碰撞 +- 也无法解释为什么某个 pair 看起来危险 + +更重要的是,从理论上讲: + +- 我们之所以把“结果可加”作为工程判据 +- 正是因为它来自于“构建过程正交 ⇒ 结果可加”这条推导 + +如果完全不再观察构建过程,这条推导链本身就失去支撑了。 + +因此,当前明确的产品取舍是: + +- **构建图保留为第一层反证** +- **产物 merge + merged `onTest` 保留为第二层验证** +- **debug 输出只是构建图的额外用途,不是它的唯一用途** + +## 13. 当前方案真正解决了什么 + +当前方案真正能稳定回答的是: + +- 哪些 pair 的交付结果不能相加 +- 哪些 pair 的 merged 结果无法通过联合行为测试 +- 因此哪些 pair 必须重新进入真实执行集合 + +这已经足以支撑 LLAR 当前的自动矩阵缩减能力。 + +## 14. 当前方案明确不解决什么 + +当前方案不试图解决: + +1. **真实 `A+B` 构建过程的形式证明** +2. **只在 `A&&B` 时出现的新构建逻辑的静态推导** +3. **把每一个 archive 冲突精确定位到具体函数体差异** + +如果用户需要这些保证,就不应仅依赖 `--auto`,而应: + +- 真实跑相应 pair +- 或直接跑更完整的矩阵 + +## 15. 最终设计结论 + +LLAR 当前测试系统最终收敛成: + +- **构建图反证 + 产物合并验证的混合系统** + +它的最终定义是: + +1. baseline 和 singleton 产物是基本观测面 +2. 构建图是第一层反证器 +3. `base / A / B` 三方 merge 是交付层裁决器 +4. merged `A+B` `onTest` 是行为层裁决器 +5. debug 输出是构建图的附加解释层 + +一句话总结: + +LLAR 当前既不再试图只靠构建图证明“过程正交”,也不接受只靠产物 merge 判断一切,而是采用: + +- **先用构建图发现明显不正交** +- **再用产物 merge 和 merged `A+B` 测试验证结果可加性** + +这就是我们在完整权衡维护成本、验证能力、复杂度和实际收益之后,做出的最终设计取舍。 diff --git a/doc/testing-system-implementation-status.md b/doc/testing-system-implementation-status.md new file mode 100644 index 0000000..45d4f96 --- /dev/null +++ b/doc/testing-system-implementation-status.md @@ -0,0 +1,53 @@ +# LLAR 测试系统实现状态说明 + +这份文档用于说明测试系统当前已经落地到什么程度,以及哪些问题仍未闭合。 +它与设计稿分离,避免将阶段性实现状态混入系统设计本身。 + +## 1. 已实现能力 + +当前版本已经具备: + +- `onTest` 作为 Formula 的验证入口 +- `llar test` +- `llar test --auto` +- `build-only` probe +- 命令级 trace 记录 +- baseline 与单变量 probe +- evaluator 内部的最小动作图建模 +- 基于碰撞连通分量的矩阵缩减 + +## 2. 当前行为 + +### 2.1 `llar test` + +- 使用默认组合执行 `build + onTest` + +### 2.2 `llar test --auto` + +- 读取矩阵 +- 生成 baseline 与单变量 probe +- 对 probe 组合执行 `build-only` 观察 +- evaluator 计算需要真正测试的组合 +- 对这些组合执行 `build + onTest` + +## 3. 当前限制 + +当前版本仍存在以下限制: + +- 自动模式尚未覆盖所有调用场景 +- 设计上的 `--full` 语义尚未落地,普通模式还没有明确区分 default options 与全矩阵 +- trace 的 lineage 模型还比较保守 +- evaluator 不输出独立的分析报告 +- build cache 与 test 语义尚未分离,`onTest` 仍然挂在 build 流程尾部 +- 当前通过绕过 build cache 避免 `onTest` 被跳过,但这只是暂时实现,不是最终设计 + +## 4. 尚未落地的能力 + +以下能力仍未实现: + +- artifact manifest +- 可审计分析输出 +- 测试结果缓存 +- 构建缓存与测试状态分离 +- 更强的认证模型 +- 云端交付闭环 diff --git a/formula/classfile.go b/formula/classfile.go index 3a298ef..b1746cb 100644 --- a/formula/classfile.go +++ b/formula/classfile.go @@ -22,6 +22,7 @@ type ModuleF struct { fOnRequire func(proj *Project, deps *ModuleDeps) fOnBuild func(ctx *Context, proj *Project, out *BuildResult) + fOnTest func(ctx *Context, proj *Project, out *BuildResult) modPath string modFromVer string @@ -198,6 +199,11 @@ func (p *ModuleF) OnBuild(f func(ctx *Context, proj *Project, out *BuildResult)) p.fOnBuild = f } +// OnTest event is used to run post-build verification for a project. +func (p *ModuleF) OnTest(f func(ctx *Context, proj *Project, out *BuildResult)) { + p.fOnTest = f +} + // ----------------------------------------------------------------------------- // Gopt_ModuleF_Main is main entry of this classfile. diff --git a/internal/build/build.go b/internal/build/build.go index ea13257..2781005 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -2,17 +2,21 @@ package build import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "io/fs" "os" "path/filepath" + "slices" "strings" "time" classfile "github.com/goplus/llar/formula" "github.com/goplus/llar/internal/formula/repo" "github.com/goplus/llar/internal/modules" + "github.com/goplus/llar/internal/trace" "github.com/goplus/llar/internal/vcs" "github.com/goplus/llar/mod/module" ) @@ -20,21 +24,45 @@ import ( type Builder struct { store repo.Store matrix string + runTest bool + trace bool workspaceDir string - newRepo func(repoPath string) (vcs.Repo, error) // defaults to vcs.NewRepo + newRepo func(repoPath string) (vcs.Repo, error) // defaults to vcs.NewRepo } type Result struct { - Metadata string - OutputDir string + Metadata string + OutputDir string + Trace []trace.Record + TraceEvents []trace.Event + TraceScope trace.Scope + TraceDiagnostics trace.ParseDiagnostics + InputDigests map[string]string + ReplayReady bool } type Options struct { Store repo.Store MatrixStr string + RunTest bool + Trace bool WorkspaceDir string } +var captureOnBuildTrace = trace.CaptureLockedThread + +func (b *Builder) traceRoots(targets []*modules.Module, mod *modules.Module, sourceDir, installDir string) ([]string, error) { + roots := []string{sourceDir, installDir} + for _, dep := range b.resolveModTransitiveDeps(targets, mod) { + dir, err := b.installDir(dep.Path, dep.Version) + if err != nil { + return nil, err + } + roots = append(roots, dir) + } + return roots, nil +} + func defaultWorkspaceDir() (string, error) { userCacheDir, err := os.UserCacheDir() if err != nil { @@ -61,6 +89,8 @@ func NewBuilder(opts Options) (*Builder, error) { return &Builder{ store: opts.Store, matrix: opts.MatrixStr, + runTest: opts.RunTest, + trace: opts.Trace, workspaceDir: workspaceDir, newRepo: vcs.NewRepo, }, nil @@ -179,29 +209,39 @@ func (b *Builder) resolveModTransitiveDeps(targets []*modules.Module, mod *modul func (b *Builder) Build(ctx context.Context, targets []*modules.Module) ([]Result, error) { builtResults := make(map[module.Version]classfile.BuildResult) + traceTarget := module.Version{} + if b.trace && len(targets) > 0 { + traceTarget = module.Version{Path: targets[0].Path, Version: targets[0].Version} + } build := func(mod *modules.Module) (Result, error) { + traceEnabled := b.trace && mod.Path == traceTarget.Path && mod.Version == traceTarget.Version + unlock, err := b.store.LockModule(mod.Path) if err != nil { return Result{}, err } defer unlock() - // Check cache - cache, err := b.loadCache(mod.Path) - if err == nil { - if entry, ok := cache.get(mod.Version, b.matrix); ok { - dir, _ := b.installDir(mod.Path, mod.Version) - return Result{Metadata: entry.Metadata, OutputDir: dir}, nil + // When onTest is requested, bypass the build cache so test execution + // cannot be skipped by a cached build hit. + var cache *buildCache + if !b.runTest && !traceEnabled { + cache, err = b.loadCache(mod.Path) + if err == nil { + if entry, ok := cache.get(mod.Version, b.matrix); ok { + dir, _ := b.installDir(mod.Path, mod.Version) + return Result{Metadata: entry.Metadata, OutputDir: dir}, nil + } } } // TODO(MeteorsLiu): Source cache dir - tmpSourceDir, err := os.MkdirTemp("", fmt.Sprintf("source-%s-%s*", strings.ReplaceAll(mod.Path, "/", "-"), mod.Version)) + tmpSourceDir, cleanupSourceDir, err := b.sourceDir(mod.Path, mod.Version, traceEnabled) if err != nil { return Result{}, err } - defer os.RemoveAll(tmpSourceDir) + defer cleanupSourceDir() // Before we start to build, clone source to tmpSourceDir // And switch current dir to it. @@ -235,30 +275,88 @@ func (b *Builder) Build(ctx context.Context, targets []*modules.Module) ([]Resul project := &classfile.Project{Deps: b.resolveModTransitiveDeps(targets, mod), SourceFS: mod.FS.(fs.ReadFileFS)} // Ready! Go! + cwd, err := os.Getwd() + if err != nil { + return Result{}, err + } + defer func() { + _ = os.Chdir(cwd) + }() if err := os.Chdir(tmpSourceDir); err != nil { return Result{}, err } var out classfile.BuildResult - mod.OnBuild(buildContext, project, &out) + var records []trace.Record + var events []trace.Event + var traceDiagnostics trace.ParseDiagnostics + traceScope := trace.Scope{ + SourceRoot: tmpSourceDir, + BuildRoot: filepath.Join(tmpSourceDir, "_build"), + InstallRoot: installDir, + } + runOnBuild := func() error { + mod.OnBuild(buildContext, project, &out) + return nil + } + if traceEnabled { + traceRoots, err := b.traceRoots(targets, mod, tmpSourceDir, installDir) + if err != nil { + return Result{}, err + } + traceResult, err := captureOnBuildTrace(ctx, trace.CaptureOptions{ + RootCwd: tmpSourceDir, + KeepRoots: traceRoots, + }, runOnBuild) + if err != nil { + return Result{}, err + } + records = traceResult.Records + events = traceResult.Events + traceDiagnostics = traceResult.Diagnostics + traceScope.BuildRoot = inferTraceBuildRoot(records, traceScope) + traceScope.KeepRoots = slices.Clone(traceRoots) + } else { + if err := runOnBuild(); err != nil { + return Result{}, err + } + } if len(out.Errs()) > 0 { return Result{}, errors.Join(out.Errs()...) } + if b.runTest && mod.OnTest != nil { + var testOut classfile.BuildResult + mod.OnTest(buildContext, project, &testOut) + if len(testOut.Errs()) > 0 { + return Result{}, fmt.Errorf("onTest failed for %s@%s: %w", mod.Path, mod.Version, errors.Join(testOut.Errs()...)) + } + } // Save to cache - if cache == nil { - cache = &buildCache{} - } - cache.set(mod.Version, b.matrix, &buildEntry{ - Metadata: out.Metadata(), - BuildTime: time.Now(), - }) - if err := b.saveCache(mod.Path, cache); err != nil { - return Result{}, err + if !b.runTest { + if cache == nil { + cache = &buildCache{} + } + cache.set(mod.Version, b.matrix, &buildEntry{ + Metadata: out.Metadata(), + BuildTime: time.Now(), + }) + if err := b.saveCache(mod.Path, cache); err != nil { + return Result{}, err + } } - return Result{Metadata: out.Metadata(), OutputDir: installDir}, nil + return Result{ + Metadata: out.Metadata(), + OutputDir: installDir, + Trace: records, + TraceEvents: events, + TraceScope: traceScope, + TraceDiagnostics: traceDiagnostics, + InputDigests: collectTraceInputDigests(records, traceScope), + ReplayReady: traceEnabled, + }, nil } var results []Result @@ -294,3 +392,205 @@ func (b *Builder) Build(ctx context.Context, targets []*modules.Module) ([]Resul } return results, nil } + +func inferTraceBuildRoot(records []trace.Record, scope trace.Scope) string { + sourceRoot := filepath.Clean(scope.SourceRoot) + if sourceRoot == "" { + return filepath.Clean(scope.BuildRoot) + } + installRoot := filepath.Clean(scope.InstallRoot) + candidates := make([]string, 0, len(records)) + for _, rec := range records { + for _, path := range rec.Changes { + dir, ok := traceBuildCandidateDir(path, sourceRoot, installRoot) + if !ok { + continue + } + candidates = append(candidates, dir) + } + } + if len(candidates) == 0 { + return filepath.Clean(scope.BuildRoot) + } + root := filepath.Clean(candidates[0]) + for _, dir := range candidates[1:] { + root = commonPathPrefix(root, filepath.Clean(dir)) + if root == sourceRoot { + break + } + } + if root == "" || root == "." || root == sourceRoot { + return filepath.Clean(scope.BuildRoot) + } + return root +} + +func traceBuildCandidateDir(path, sourceRoot, installRoot string) (string, bool) { + path = filepath.Clean(path) + if path == "" { + return "", false + } + if path == sourceRoot || path == installRoot { + return "", false + } + if !isWithinRoot(path, sourceRoot) { + return "", false + } + if installRoot != "" && isWithinRoot(path, installRoot) { + return "", false + } + info, err := os.Stat(path) + switch { + case err == nil && info.IsDir(): + return path, true + case err == nil: + return filepath.Dir(path), true + case errors.Is(err, os.ErrNotExist): + if strings.HasSuffix(path, string(filepath.Separator)) { + return strings.TrimSuffix(path, string(filepath.Separator)), true + } + return filepath.Dir(path), true + default: + return "", false + } +} + +func commonPathPrefix(left, right string) string { + left = filepath.Clean(left) + right = filepath.Clean(right) + if left == right { + return left + } + leftParts := splitPathParts(left) + rightParts := splitPathParts(right) + n := minInt(len(leftParts), len(rightParts)) + parts := make([]string, 0, n) + for i := 0; i < n; i++ { + if leftParts[i] != rightParts[i] { + break + } + parts = append(parts, leftParts[i]) + } + if len(parts) == 0 { + return "" + } + if filepath.IsAbs(left) { + return string(filepath.Separator) + filepath.Join(parts...) + } + return filepath.Join(parts...) +} + +func splitPathParts(path string) []string { + path = filepath.Clean(path) + if path == "." || path == string(filepath.Separator) { + return nil + } + if filepath.IsAbs(path) { + path = strings.TrimPrefix(path, string(filepath.Separator)) + } + return strings.Split(path, string(filepath.Separator)) +} + +func minInt(left, right int) int { + if left < right { + return left + } + return right +} + +func isWithinRoot(path, root string) bool { + if path == "" || root == "" { + return false + } + path = filepath.Clean(path) + root = filepath.Clean(root) + if path == root { + return true + } + return strings.HasPrefix(path, root+string(filepath.Separator)) +} + +func (b *Builder) sourceDir(modPath, version string, preserve bool) (string, func(), error) { + if !preserve { + dir, err := os.MkdirTemp("", fmt.Sprintf("source-%s-%s*", strings.ReplaceAll(modPath, "/", "-"), version)) + if err != nil { + return "", nil, err + } + return dir, func() { + _ = os.RemoveAll(dir) + }, nil + } + + escaped, err := module.EscapePath(modPath) + if err != nil { + return "", nil, err + } + dir := filepath.Join(b.workspaceDir, ".trace-src", fmt.Sprintf("%s@%s-%s", escaped, version, b.matrix)) + if err := os.RemoveAll(dir); err != nil && !os.IsNotExist(err) { + return "", nil, err + } + if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil { + return "", nil, err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", nil, err + } + return dir, func() {}, nil +} + +func collectTraceInputDigests(records []trace.Record, scope trace.Scope) map[string]string { + buildRoot := filepath.Clean(scope.BuildRoot) + if buildRoot == "" || len(records) == 0 { + return nil + } + + paths := make(map[string]struct{}) + for _, rec := range records { + for _, path := range rec.Inputs { + if !pathWithinRoot(path, buildRoot) { + continue + } + paths[path] = struct{}{} + } + for _, path := range rec.Changes { + if !pathWithinRoot(path, buildRoot) { + continue + } + paths[path] = struct{}{} + } + } + + digests := make(map[string]string, len(paths)) + for path := range paths { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + continue + } + sum, err := fileDigest(path) + if err != nil { + continue + } + digests[path] = sum + } + if len(digests) == 0 { + return nil + } + return digests +} + +func pathWithinRoot(path, root string) bool { + rel, err := filepath.Rel(root, path) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +func fileDigest(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:8]), nil +} diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 0c36fbb..27eee5e 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -8,12 +8,14 @@ import ( "io/fs" "os" "path/filepath" + "reflect" "strings" "testing" "time" "github.com/goplus/llar/internal/formula/repo" "github.com/goplus/llar/internal/modules" + "github.com/goplus/llar/internal/trace" "github.com/goplus/llar/internal/vcs" "github.com/goplus/llar/mod/module" ) @@ -270,6 +272,42 @@ func TestResolveModTransitiveDeps(t *testing.T) { }) } +func TestRealOptionFormulasLoad(t *testing.T) { + store := setupTestStore(t) + cases := []module.Version{ + {Path: "openssl/openssl", Version: "openssl-3.6.1"}, + {Path: "FFmpeg/FFmpeg", Version: "n8.0.1"}, + {Path: "opencv/opencv", Version: "4.9.0"}, + {Path: "boostorg/boost", Version: "boost-1.90.0"}, + {Path: "pocoproject/poco", Version: "poco-1.14.2-release"}, + {Path: "PCRE2Project/pcre2", Version: "pcre2-10.45"}, + {Path: "fmtlib/fmt", Version: "11.1.4"}, + {Path: "libjpeg-turbo/libjpeg-turbo", Version: "3.1.3"}, + {Path: "sqlite/sqlite", Version: "3.45.3"}, + {Path: "zeux/pugixml", Version: "1.15"}, + {Path: "libexpat/libexpat", Version: "2.6.4"}, + {Path: "DaveGamble/cJSON", Version: "1.7.19"}, + {Path: "c-ares/c-ares", Version: "1.34.5"}, + {Path: "webmproject/libwebp", Version: "1.5.0"}, + {Path: "libsdl-org/libtiff", Version: "4.7.1"}, + {Path: "facebook/zstd", Version: "1.5.7"}, + {Path: "uriparser/uriparser", Version: "0.9.8"}, + {Path: "jbeder/yaml-cpp", Version: "0.9.0"}, + {Path: "gabime/spdlog", Version: "1.17.0"}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.Path+"@"+tc.Version, func(t *testing.T) { + t.Parallel() + _, err := modules.Load(context.Background(), tc, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load(%s@%s) failed: %v", tc.Path, tc.Version, err) + } + }) + } +} + // testFormulaDir and testSourceDir are resolved once at init to avoid // issues with os.Chdir in Build() changing the working directory. var ( @@ -326,6 +364,83 @@ func loadAndBuild(t *testing.T, b *Builder, store repo.Store, main module.Versio return results, mods } +func TestBuilderRunOnTest_UsesProvidedOutputDir(t *testing.T) { + store := setupTestStore(t) + b := setupBuilder(t, store, "amd64-linux|a-on-b-on") + + mods, err := modules.Load(context.Background(), module.Version{Path: "test/mergedtest", Version: "1.0.0"}, modules.Options{ + FormulaStore: store, + }) + if err != nil { + t.Fatalf("modules.Load() failed: %v", err) + } + + mergedDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(mergedDir, "include"), 0o755); err != nil { + t.Fatalf("MkdirAll() failed: %v", err) + } + for _, name := range []string{"base.h", "a.h", "b.h"} { + if err := os.WriteFile(filepath.Join(mergedDir, "include", name), []byte(name), 0o644); err != nil { + t.Fatalf("WriteFile(%s) failed: %v", name, err) + } + } + + if err := b.RunOnTest(context.Background(), mods, mergedDir); err != nil { + t.Fatalf("RunOnTest() unexpected error: %v", err) + } +} + +func TestBuilderRunOnTest_FailsWhenMergedOutputMissingExpectedFile(t *testing.T) { + store := setupTestStore(t) + b := setupBuilder(t, store, "amd64-linux|a-on-b-on") + + mods, err := modules.Load(context.Background(), module.Version{Path: "test/mergedtest", Version: "1.0.0"}, modules.Options{ + FormulaStore: store, + }) + if err != nil { + t.Fatalf("modules.Load() failed: %v", err) + } + + mergedDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(mergedDir, "include"), 0o755); err != nil { + t.Fatalf("MkdirAll() failed: %v", err) + } + for _, name := range []string{"base.h", "a.h"} { + if err := os.WriteFile(filepath.Join(mergedDir, "include", name), []byte(name), 0o644); err != nil { + t.Fatalf("WriteFile(%s) failed: %v", name, err) + } + } + + err = b.RunOnTest(context.Background(), mods, mergedDir) + if err == nil { + t.Fatal("RunOnTest() expected error, got nil") + } + var onTestErr *OnTestFailureError + if !errors.As(err, &onTestErr) { + t.Fatalf("RunOnTest() error = %T, want *OnTestFailureError", err) + } +} + +func TestBuild_RestoresWorkingDirectory(t *testing.T) { + store := setupTestStore(t) + b := setupBuilder(t, store, "amd64-linux") + + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() failed: %v", err) + } + + _, _ = loadAndBuild(t, b, store, module.Version{Path: "test/liba", Version: "1.0.0"}) + + gotDir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() after Build failed: %v", err) + } + if gotDir != origDir { + t.Fatalf("cwd after Build = %q, want %q", gotDir, origDir) + } +} + // findResult returns the Result for a given module path. // Results are in constructBuildList order, so we match via build order. func findResult(results []Result, b *Builder, mods []*modules.Module, path string) (Result, bool) { @@ -388,6 +503,165 @@ func TestNewBuilder(t *testing.T) { }) } +func TestBuild_TraceCapturesOnlyMainModule(t *testing.T) { + store := setupTestStore(t) + b := setupBuilder(t, store, "amd64-linux") + b.trace = true + + oldCapture := captureOnBuildTrace + defer func() { + captureOnBuildTrace = oldCapture + }() + + var calls int + var gotOpts trace.CaptureOptions + wantTrace := []trace.Record{{Argv: []string{"trace", "main"}}} + wantEvents := []trace.Event{{Seq: 1, Kind: trace.EventExec, Argv: []string{"trace", "main"}}} + captureOnBuildTrace = func(ctx context.Context, opts trace.CaptureOptions, run func() error) (trace.CaptureResult, error) { + calls++ + gotOpts = opts + if err := run(); err != nil { + return trace.CaptureResult{}, err + } + return trace.CaptureResult{Records: wantTrace, Events: wantEvents}, nil + } + + main := module.Version{Path: "test/depresult", Version: "1.0.0"} + results, mods := loadAndBuild(t, b, store, main) + + if calls != 1 { + t.Fatalf("captureOnBuildTrace call count = %d, want 1", calls) + } + + root, ok := findResult(results, b, mods, "test/depresult") + if !ok { + t.Fatal("missing result for test/depresult") + } + if !reflect.DeepEqual(root.Trace, wantTrace) { + t.Fatalf("root trace = %#v, want %#v", root.Trace, wantTrace) + } + if !reflect.DeepEqual(root.TraceEvents, wantEvents) { + t.Fatalf("root trace events = %#v, want %#v", root.TraceEvents, wantEvents) + } + if !root.TraceDiagnostics.Trusted() { + t.Fatalf("root trace diagnostics = %#v, want trusted", root.TraceDiagnostics) + } + if !root.ReplayReady { + t.Fatal("root ReplayReady = false, want true") + } + if gotOpts.RootCwd == "" { + t.Fatal("RootCwd should not be empty") + } + if len(gotOpts.KeepRoots) < 2 { + t.Fatalf("KeepRoots = %#v, want source root plus install roots", gotOpts.KeepRoots) + } + if !strings.Contains(root.TraceScope.SourceRoot, filepath.Join(b.workspaceDir, ".trace-src")) { + t.Fatalf("trace source root = %q, want under %q", root.TraceScope.SourceRoot, filepath.Join(b.workspaceDir, ".trace-src")) + } + if _, err := os.Stat(root.TraceScope.SourceRoot); err != nil { + t.Fatalf("trace source root %q should exist: %v", root.TraceScope.SourceRoot, err) + } + + dep, ok := findResult(results, b, mods, "test/liba") + if !ok { + t.Fatal("missing result for test/liba") + } + if len(dep.Trace) != 0 { + t.Fatalf("dependency trace = %#v, want empty", dep.Trace) + } + if len(dep.TraceEvents) != 0 { + t.Fatalf("dependency trace events = %#v, want empty", dep.TraceEvents) + } + if dep.ReplayReady { + t.Fatal("dependency ReplayReady = true, want false") + } +} + +func TestBuild_TraceBypassesMainModuleCache(t *testing.T) { + store := setupTestStore(t) + b := setupBuilder(t, store, "amd64-linux") + b.trace = true + + cache := &buildCache{} + cache.set("1.0.0", "amd64-linux", &buildEntry{ + Metadata: "-lPRECACHED", + BuildTime: time.Now(), + }) + if err := b.saveCache("test/liba", cache); err != nil { + t.Fatalf("saveCache() failed: %v", err) + } + + oldCapture := captureOnBuildTrace + defer func() { + captureOnBuildTrace = oldCapture + }() + captureOnBuildTrace = func(ctx context.Context, opts trace.CaptureOptions, run func() error) (trace.CaptureResult, error) { + if err := run(); err != nil { + return trace.CaptureResult{}, err + } + return trace.CaptureResult{ + Records: []trace.Record{{Argv: []string{"trace", "cache-bypass"}}}, + Events: []trace.Event{{Seq: 1, Kind: trace.EventExec, Argv: []string{"trace", "cache-bypass"}}}, + }, nil + } + + main := module.Version{Path: "test/liba", Version: "1.0.0"} + results, _ := loadAndBuild(t, b, store, main) + + if results[0].Metadata != "-lA" { + t.Fatalf("metadata = %q, want %q", results[0].Metadata, "-lA") + } + if len(results[0].Trace) != 1 { + t.Fatalf("trace len = %d, want 1", len(results[0].Trace)) + } + if len(results[0].TraceEvents) != 1 { + t.Fatalf("trace events len = %d, want 1", len(results[0].TraceEvents)) + } +} + +func TestBuild_TraceInfersNestedBuildRootFromTrace(t *testing.T) { + store := setupTestStore(t) + b := setupBuilder(t, store, "amd64-linux") + b.trace = true + + oldCapture := captureOnBuildTrace + defer func() { + captureOnBuildTrace = oldCapture + }() + var gotOpts trace.CaptureOptions + captureOnBuildTrace = func(ctx context.Context, opts trace.CaptureOptions, run func() error) (trace.CaptureResult, error) { + gotOpts = opts + if err := run(); err != nil { + return trace.CaptureResult{}, err + } + nestedBuild := filepath.Join(opts.RootCwd, "expat", "_build") + return trace.CaptureResult{ + Records: []trace.Record{{ + PID: 1, + ParentPID: 0, + Argv: []string{"cmake", "-S", filepath.Join(opts.RootCwd, "expat"), "-B", nestedBuild}, + Cwd: filepath.Join(opts.RootCwd, "expat"), + Changes: []string{ + filepath.Join(nestedBuild, "CMakeCache.txt"), + filepath.Join(nestedBuild, "libexpat.a"), + }, + }}, + }, nil + } + + main := module.Version{Path: "test/liba", Version: "1.0.0"} + results, mods := loadAndBuild(t, b, store, main) + + root, ok := findResult(results, b, mods, "test/liba") + if !ok { + t.Fatal("missing result for test/liba") + } + wantBuildRoot := filepath.Join(gotOpts.RootCwd, "expat", "_build") + if root.TraceScope.BuildRoot != wantBuildRoot { + t.Fatalf("trace build root = %q, want %q", root.TraceScope.BuildRoot, wantBuildRoot) + } +} + // --------------------------------------------------------------------------- // Build error path tests // --------------------------------------------------------------------------- @@ -675,6 +949,48 @@ func TestResolveModTransitiveDeps_ModNotInTargets(t *testing.T) { } } +func TestCollectTraceInputDigestsIncludesBuildOutputs(t *testing.T) { + buildRoot := t.TempDir() + generatedHeader := filepath.Join(buildRoot, "generated.h") + objectFile := filepath.Join(buildRoot, "core.o") + sourceFile := filepath.Join(buildRoot, "core.c") + + if err := os.WriteFile(sourceFile, []byte("int core(void) { return 0; }\n"), 0o644); err != nil { + t.Fatalf("WriteFile(source): %v", err) + } + if err := os.WriteFile(generatedHeader, []byte("#define FLAG 1\n"), 0o644); err != nil { + t.Fatalf("WriteFile(generatedHeader): %v", err) + } + if err := os.WriteFile(objectFile, []byte("object-bytes"), 0o644); err != nil { + t.Fatalf("WriteFile(objectFile): %v", err) + } + + records := []trace.Record{ + { + Cwd: buildRoot, + Argv: []string{"generator"}, + Inputs: []string{sourceFile}, + Changes: []string{generatedHeader}, + }, + { + Cwd: buildRoot, + Argv: []string{"cc", "-c", sourceFile, "-o", objectFile}, + Inputs: []string{sourceFile, generatedHeader}, + Changes: []string{objectFile}, + }, + } + + got := collectTraceInputDigests(records, trace.Scope{BuildRoot: buildRoot}) + if got == nil { + t.Fatal("collectTraceInputDigests() = nil, want digests") + } + for _, path := range []string{generatedHeader, objectFile} { + if got[path] == "" { + t.Fatalf("collectTraceInputDigests() missing digest for %q: %#v", path, got) + } + } +} + // --------------------------------------------------------------------------- // Mock types for error testing // --------------------------------------------------------------------------- diff --git a/internal/build/e2e_test.go b/internal/build/e2e_test.go index 7de110c..802e324 100644 --- a/internal/build/e2e_test.go +++ b/internal/build/e2e_test.go @@ -1,15 +1,32 @@ package build import ( + "archive/tar" + "archive/zip" + "compress/gzip" "context" + "errors" + "fmt" + "io" + "io/fs" + "maps" + "net/http" "os" "os/exec" "path/filepath" + "reflect" "runtime" + "slices" + "strconv" "strings" + "sync" "testing" + "time" + "github.com/goplus/llar/formula" + "github.com/goplus/llar/internal/evaluator" "github.com/goplus/llar/internal/modules" + "github.com/goplus/llar/internal/trace" "github.com/goplus/llar/internal/vcs" "github.com/goplus/llar/mod/module" ) @@ -298,18 +315,19 @@ func TestE2E_RealZlibBuild(t *testing.T) { } } -// TestE2E_RealLibpngBuild builds libpng with its zlib dependency using cmake.use. -// Verifies: formula dep resolution → zlib built first → cmake.use injects zlib → -// libpng configure/build/install succeeds → artifacts exist. -func TestE2E_RealLibpngBuild(t *testing.T) { - if testing.Short() { - t.Skip("skipping real build test in short mode") +// TestE2E_TraceCapture_RealZlibBuild logs the captured OnBuild trace so it can +// be inspected directly via `go test -v`. +func TestE2E_TraceCapture_RealZlibBuild(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("trace capture test requires Linux") } - if _, err := exec.LookPath("cmake"); err != nil { - t.Skip("cmake not found, skipping real build test") + if testing.Short() { + t.Skip("skipping trace capture test in short mode") } - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not found, skipping real build test") + for _, tool := range []string{"cmake", "git", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping trace capture test", tool) + } } store := setupTestStore(t) @@ -318,166 +336,4927 @@ func TestE2E_RealLibpngBuild(t *testing.T) { b := &Builder{ store: store, matrix: matrix, + trace: true, workspaceDir: t.TempDir(), newRepo: func(repoPath string) (vcs.Repo, error) { return vcs.NewRepo(repoPath) }, } - main := module.Version{Path: "pnggroup/libpng", Version: "v1.6.47"} + main := module.Version{Path: "madler/zlib", Version: "v1.3.1"} ctx := context.Background() mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) if err != nil { t.Fatalf("modules.Load() failed: %v", err) } - // Should have 2 modules: zlib + libpng - if len(mods) != 2 { - t.Fatalf("got %d modules, want 2", len(mods)) - } - results, err := b.Build(ctx, mods) if err != nil { t.Fatalf("Build() failed: %v", err) } - - if len(results) != 2 { - t.Fatalf("got %d results, want 2", len(results)) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + if len(results[0].Trace) == 0 { + t.Fatal("trace is empty") } - // Verify zlib was built (first in order) - zlibR, ok := findResult(results, b, mods, "madler/zlib") - if !ok { - t.Fatal("missing result for madler/zlib") + dump := formatTraceRecordsForTest(results[0].Trace) + logPath := writeTraceLogForTest(t, dump) + + t.Logf("captured %d trace records", len(results[0].Trace)) + t.Logf("trace log written to %s", logPath) +} + +func TestE2E_LocalTracecmakeBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping local build test in short mode") } - if zlibR.Metadata != "-lz" { - t.Errorf("zlib metadata = %q, want %q", zlibR.Metadata, "-lz") + if _, err := exec.LookPath("cmake"); err != nil { + t.Skip("cmake not found, skipping local build test") } - // Verify libpng was built - pngR, ok := findResult(results, b, mods, "pnggroup/libpng") - if !ok { - t.Fatal("missing result for pnggroup/libpng") + store := setupTestStore(t) + b := setupBuilder(t, store, runtime.GOARCH+"-"+runtime.GOOS) + + main := module.Version{Path: "test/tracecmake", Version: "1.0.0"} + results, _ := loadAndBuild(t, b, store, main) + + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) } - if pngR.Metadata != "-lpng" { - t.Errorf("libpng metadata = %q, want %q", pngR.Metadata, "-lpng") + if results[0].Metadata != "-ltracecore" { + t.Fatalf("metadata = %q, want %q", results[0].Metadata, "-ltracecore") } - // Verify libpng build artifacts - pngInstallDir, _ := b.installDir("pnggroup/libpng", "v1.6.47") - - // Check library - libDir := filepath.Join(pngInstallDir, "lib") - libEntries, err := os.ReadDir(libDir) + installDir, _ := b.installDir("test/tracecmake", "1.0.0") + if _, err := os.Stat(filepath.Join(installDir, "include", "trace.h")); err != nil { + t.Fatalf("trace.h not found at %s: %v", installDir, err) + } + if _, err := os.Stat(filepath.Join(installDir, "include", "trace_config.h")); err != nil { + t.Fatalf("trace_config.h not found at %s: %v", installDir, err) + } + if _, err := os.Stat(filepath.Join(installDir, "bin", "tracecli")); err != nil { + if _, err2 := os.Stat(filepath.Join(installDir, "bin", "tracecli.exe")); err2 != nil { + t.Fatalf("tracecli binary not found at %s: %v", installDir, err) + } + } + libEntries, err := os.ReadDir(filepath.Join(installDir, "lib")) if err != nil { - t.Fatalf("lib dir not found at %s: %v", libDir, err) + t.Fatalf("lib dir not found at %s: %v", installDir, err) } hasLib := false for _, e := range libEntries { - if strings.HasPrefix(e.Name(), "libpng") { + if strings.HasPrefix(e.Name(), "libtracecore") { hasLib = true break } } if !hasLib { - t.Errorf("no libpng* found in %s", libDir) + t.Fatalf("no libtracecore* artifact found in %s/lib", installDir) } +} - // Check header - headerPath := filepath.Join(pngInstallDir, "include", "libpng16", "png.h") - if _, err := os.Stat(headerPath); err != nil { - // Some cmake configs install directly to include/ - headerPath = filepath.Join(pngInstallDir, "include", "png.h") - if _, err := os.Stat(headerPath); err != nil { - t.Errorf("png.h not found in include/ or include/libpng16/") +func TestE2E_TraceAnalyze_LocalTracecmakeBuild(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("trace analysis test requires Linux") + } + if testing.Short() { + t.Skip("skipping trace analysis test in short mode") + } + for _, tool := range []string{"cmake", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping trace analysis test", tool) } } + + store := setupTestStore(t) + b := setupBuilder(t, store, runtime.GOARCH+"-"+runtime.GOOS) + b.trace = true + + main := module.Version{Path: "test/tracecmake", Version: "1.0.0"} + results, _ := loadAndBuild(t, b, store, main) + + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + if results[0].Metadata != "-ltracecore" { + t.Fatalf("metadata = %q, want %q", results[0].Metadata, "-ltracecore") + } + if len(results[0].Trace) == 0 { + t.Fatal("trace is empty") + } + + summary := evaluator.DebugSummary(results[0].Trace, evaluator.DebugSummaryOptions{ + Scope: results[0].TraceScope, + RoleSampleLimit: 12, + InterestingLimit: 12, + InterestingTokens: []string{ + "/_build/trace_config.h", + "/TryCompile-", + "/lib/libtracecore.a", + "/include/trace.h", + "/include/trace_config.h", + }, + }) + logPath := writeGraphLogForTest(t, summary) + + t.Logf("graph summary written to %s", logPath) + if !summaryHasTokenRole(summary, "/_build/trace_config.h", "propagating") { + t.Fatalf("expected generated header to be propagating, summary:\n%s", summary) + } + if !summaryHasTokenRole(summary, "/TryCompile-", "tooling") { + t.Fatalf("expected try_compile subtree to be tooling, summary:\n%s", summary) + } + if !summaryHasTokenRole(summary, "/include/trace.h", "delivery") { + t.Fatalf("expected installed header to be delivery, summary:\n%s", summary) + } + if !summaryHasTokenRole(summary, "/include/trace_config.h", "delivery") { + t.Fatalf("expected installed generated header to be delivery, summary:\n%s", summary) + } } -// TestE2E_RealFreetypeBuild builds freetype with its transitive dependencies: -// freetype -> {libpng, zlib}, libpng -> zlib (diamond). -// Demonstrates: onRequire dynamic dep extraction from meson wrap files → -// diamond dep resolution → cmake.use injection → pkg-config metadata extraction. -func TestE2E_RealFreetypeBuild(t *testing.T) { +func probeResultFromBuildResult(result Result) evaluator.ProbeResult { + outputManifest, err := evaluator.BuildOutputManifest(result.OutputDir, result.Metadata) + if err != nil { + panic(fmt.Sprintf("BuildOutputManifest(%q): %v", result.OutputDir, err)) + } + return evaluator.ProbeResult{ + Records: result.Trace, + Events: result.TraceEvents, + OutputDir: result.OutputDir, + Scope: result.TraceScope, + InputDigests: maps.Clone(result.InputDigests), + OutputManifest: outputManifest, + ReplayReady: result.ReplayReady, + } +} + +func synthesizedPairOnTestValidatorForTest( + t *testing.T, + loadMods func(context.Context) ([]*modules.Module, error), + newBuilder func(string) *Builder, +) evaluator.SynthesizedPairValidator { + t.Helper() + return func(ctx context.Context, combo string, synthesized evaluator.OutputSynthesisResult) (bool, error) { + mods, err := loadMods(ctx) + if err != nil { + return false, err + } + b := newBuilder(combo) + if err := b.RunOnTest(ctx, mods, synthesized.Root); err != nil { + var onTestErr *OnTestFailureError + if errors.As(err, &onTestErr) { + t.Logf("synthesized pair %s validator rejected output: %v", combo, onTestErr) + if synthesized.Replay != nil && len(synthesized.Replay.SelectedCommands) != 0 { + t.Logf("synthesized pair %s replay commands:\n%s", combo, strings.Join(synthesized.Replay.SelectedCommands, "\n")) + } + if snapshot := expatConfigSnapshot(synthesized.Root); snapshot != "" { + t.Logf("synthesized pair %s expat_config.h snapshot:\n%s", combo, snapshot) + } + return false, nil + } + return false, err + } + return true, nil + } +} + +func expatConfigSnapshot(root string) string { + if root == "" { + return "" + } + path := filepath.Join(root, "include", "expat_config.h") + data, err := os.ReadFile(path) + if err != nil { + return "" + } + lines := strings.Split(string(data), "\n") + var out []string + for _, line := range lines { + line = strings.TrimSpace(line) + if !strings.Contains(line, "XML_") { + continue + } + if strings.HasPrefix(line, "#define XML_GE") || + strings.HasPrefix(line, "#define XML_LARGE_SIZE") || + strings.HasPrefix(line, "#define XML_MIN_SIZE") || + strings.HasPrefix(line, "#define XML_NS") { + out = append(out, line) + } + } + return strings.Join(out, "\n") +} + +func synthesizedPairObservationForCombo(observed []evaluator.SynthesizedPairObservation, combo string) (evaluator.SynthesizedPairObservation, bool) { + for _, observation := range observed { + if observation.Combo == combo { + return observation, true + } + } + return evaluator.SynthesizedPairObservation{}, false +} + +func TestE2E_Watch_RealOptionClassification_LocalTraceoptions(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("option classification test requires Linux") + } if testing.Short() { - t.Skip("skipping real build test in short mode") + t.Skip("skipping option classification test in short mode") } - for _, tool := range []string{"cmake", "git", "pkg-config"} { + for _, tool := range []string{"cmake", "strace"} { if _, err := exec.LookPath(tool); err != nil { - t.Skipf("%s not found, skipping real build test", tool) + t.Skipf("%s not found, skipping option classification test", tool) } } store := setupTestStore(t) - matrix := runtime.GOARCH + "-" + runtime.GOOS - - b := &Builder{ - store: store, - matrix: matrix, - workspaceDir: t.TempDir(), - newRepo: func(repoPath string) (vcs.Repo, error) { - return vcs.NewRepo(repoPath) + matrix := formula.Matrix{ + Options: map[string][]string{ + "api": {"api-off", "api-on"}, + "cli": {"cli-off", "cli-on"}, + "ship": {"ship-off", "ship-on"}, + }, + DefaultOptions: map[string][]string{ + "api": {"api-off"}, + "cli": {"cli-off"}, + "ship": {"ship-off"}, }, } - main := module.Version{Path: "freetype/freetype", Version: "VER-2-13-3"} - ctx := context.Background() - mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + combos, _, err := evaluator.Watch(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + b := setupBuilder(t, store, combo) + b.trace = true + + main := module.Version{Path: "test/traceoptions", Version: "1.0.0"} + mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + if err != nil { + return evaluator.ProbeResult{}, err + } + + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + report.AddCombo(combo, probeResultFromBuildResult(result), evaluator.DebugSummaryOptions{ + RoleSampleLimit: 6, + InterestingLimit: 6, + InterestingTokens: []string{ + "/_build/trace_options.h", + "/TryCompile-", + "/lib/libtracecore.a", + "/include/trace_alias.h", + "/bin/tracecli", + }, + }) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + return probeResult, nil + }) if err != nil { - t.Fatalf("modules.Load() failed: %v", err) + t.Fatalf("Watch() failed: %v", err) } - // Should have 3 modules: zlib + libpng + freetype - if len(mods) != 3 { - t.Fatalf("got %d modules, want 3 (zlib, libpng, freetype)", len(mods)) + baselineCombo := "api-off-cli-off-ship-off" + if base, ok := resultsByCombo[baselineCombo]; ok { + singletons := []string{ + "api-on-cli-off-ship-off", + "api-off-cli-on-ship-off", + "api-off-cli-off-ship-on", + } + for _, combo := range singletons { + probe, ok := resultsByCombo[combo] + if !ok { + continue + } + report.AddDiff(base, probe, evaluator.DebugDiffSummaryOptions{ + BaseLabel: baselineCombo, + ProbeLabel: combo, + ActionSampleLimit: 8, + }) + } + pairs := [][2]string{ + {"api-on-cli-off-ship-off", "api-off-cli-on-ship-off"}, + {"api-on-cli-off-ship-off", "api-off-cli-off-ship-on"}, + {"api-off-cli-on-ship-off", "api-off-cli-off-ship-on"}, + } + for _, pair := range pairs { + left, leftOK := resultsByCombo[pair[0]] + right, rightOK := resultsByCombo[pair[1]] + if !leftOK || !rightOK { + continue + } + report.AddCollision(base, left, right, evaluator.DebugCollisionSummaryOptions{ + BaseLabel: baselineCombo, + LeftLabel: pair[0], + RightLabel: pair[1], + PathSampleLimit: 8, + }) + } } - t.Logf("resolved modules: %v", mods) - results, err := b.Build(ctx, mods) + evidenceTokens := []string{ + "/_build/trace_options.h", + "/_build/libtracecore.a", + "/_build/tracecli", + "/install/bin/tracecli", + "/install/include/trace_alias.h", + } + var evidence strings.Builder + for _, combo := range []string{ + "api-off-cli-off-ship-off", + "api-on-cli-off-ship-off", + "api-off-cli-on-ship-off", + "api-off-cli-off-ship-on", + } { + probe, ok := resultsByCombo[combo] + if !ok { + continue + } + if evidence.Len() > 0 { + evidence.WriteString("\n\n") + } + evidence.WriteString(formatProbeEvidenceForTest(combo, probe, evidenceTokens, 12)) + } + + graphDump := report.String() + if evidence.Len() > 0 { + graphDump += "\n\n" + evidence.String() + } + logPath := writeGraphLogForTest(t, graphDump) + t.Logf("option classification summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("option trace records written to %s", traceLogPath) + + want := []string{ + "api-off-cli-off-ship-off", + "api-off-cli-off-ship-on", + "api-off-cli-on-ship-off", + "api-on-cli-off-ship-off", + "api-on-cli-on-ship-off", + } + if !slices.Equal(combos, want) { + t.Fatalf("Watch() combos = %v, want %v", combos, want) + } +} + +func TestE2E_Watch_RealStage3Precision_PocoJsonXML(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Poco stage3 precision test requires Linux") + } + if testing.Short() { + t.Skip("skipping Poco stage3 precision test in short mode") + } + for _, tool := range []string{"cmake", "c++", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping Poco stage3 precision test", tool) + } + } + + store := setupTestStore(t) + matrix := formula.Matrix{ + Options: map[string][]string{ + "json": {"json-off", "json-on"}, + "xml": {"xml-off", "xml-on"}, + }, + DefaultOptions: map[string][]string{ + "json": {"json-off"}, + "xml": {"xml-off"}, + }, + } + + releaseRepo := newPocoReleaseRepo(t, "poco-1.14.2-release") + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + var observed []evaluator.SynthesizedPairObservation + main := module.Version{Path: "pocoproject/poco", Version: "poco-1.14.2-release"} + loadMods := func(ctx context.Context) ([]*modules.Module, error) { + return modules.Load(ctx, main, modules.Options{FormulaStore: store}) + } + newProbeBuilder := func(combo string) *Builder { + return &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/pocoproject/poco" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + } + + combos, _, err := evaluator.WatchWithOptions(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("Poco stage3 probe start: %s", combo) + b := newProbeBuilder(combo) + mods, err := loadMods(ctx) + if err != nil { + return evaluator.ProbeResult{}, err + } + + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + t.Logf("Poco stage3 probe done: %s (%d trace records)", combo, len(result.Trace)) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + report.AddCombo(combo, probeResult, evaluator.DebugSummaryOptions{ + RoleSampleLimit: 8, + InterestingLimit: 8, + InterestingTokens: []string{ + "/JSON/", + "/XML/", + "/libPocoJSON", + "/libPocoXML", + "/include/Poco/JSON", + "/include/Poco/DOM", + }, + }) + return probeResult, nil + }, evaluator.WatchOptions{ + ValidateSynthesizedPair: synthesizedPairOnTestValidatorForTest(t, loadMods, newProbeBuilder), + ObserveSynthesizedPair: func(observation evaluator.SynthesizedPairObservation) { + observed = append(observed, observation) + report.AddSection(evaluator.DebugSynthesizedPairSummary(observation)) + }, + }) if err != nil { - t.Fatalf("Build() failed: %v", err) + t.Fatalf("WatchWithOptions() failed: %v", err) } - if len(results) != 3 { - t.Fatalf("got %d results, want 3", len(results)) + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("Poco stage3 precision summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("Poco stage3 precision traces written to %s", traceLogPath) + + // direct merge can validate the pair output, but it must not relax a + // stage2 hard collision; only root replay is allowed to do that. + want := []string{ + "json-off-xml-off", + "json-off-xml-on", + "json-on-xml-off", + "json-on-xml-on", + } + if !slices.Equal(combos, want) { + t.Fatalf("WatchWithOptions() combos = %v, want %v", combos, want) + } + if len(observed) != 1 { + t.Fatalf("observed synthesized pairs = %d, want 1", len(observed)) } - // Verify freetype metadata from pkg-config contains -lfreetype - ftR, ok := findResult(results, b, mods, "freetype/freetype") + observation, ok := synthesizedPairObservationForCombo(observed, "json-on-xml-on") if !ok { - t.Fatal("missing result for freetype/freetype") + t.Fatalf("missing synthesized observation for %q", "json-on-xml-on") } - if !strings.Contains(ftR.Metadata, "-lfreetype") { - t.Errorf("freetype metadata = %q, want it to contain %q", ftR.Metadata, "-lfreetype") + if !observation.ValidationAttempted { + t.Fatalf("validationAttempted = false, want true") } - t.Logf("freetype metadata (from pkg-config): %s", strings.TrimSpace(ftR.Metadata)) + if !observation.Validated { + t.Fatalf("validated = false, want true") + } + if !observation.SynthesisResult.Clean() { + t.Fatalf("synthesis status = %q, want clean merged output", observation.SynthesisResult.Status) + } + if observation.SynthesisResult.Mode != evaluator.OutputSynthesisModeDirectMerge { + t.Fatalf("synthesis mode = %q, want %q", observation.SynthesisResult.Mode, evaluator.OutputSynthesisModeDirectMerge) + } + if observation.SynthesisResult.Replay != nil { + t.Fatalf("replay summary = %#v, want nil for direct merge", observation.SynthesisResult.Replay) + } +} - // Verify freetype build artifacts - ftInstallDir, _ := b.installDir("freetype/freetype", "VER-2-13-3") +func TestE2E_Watch_RealStage3Precision_ExpatGeNs(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Expat stage3 precision test requires Linux") + } + if testing.Short() { + t.Skip("skipping Expat stage3 precision test in short mode") + } + for _, tool := range []string{"cmake", "cc", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping Expat stage3 precision test", tool) + } + } - // Check library - libDir := filepath.Join(ftInstallDir, "lib") - libEntries, err := os.ReadDir(libDir) - if err != nil { - t.Fatalf("lib dir not found at %s: %v", libDir, err) + store := setupTestStore(t) + matrix := formula.Matrix{ + Options: map[string][]string{ + "ge": {"ge-off", "ge-on"}, + "ns": {"ns-off", "ns-on"}, + }, + DefaultOptions: map[string][]string{ + "ge": {"ge-off"}, + "ns": {"ns-off"}, + }, } - hasLib := false - for _, e := range libEntries { - if strings.HasPrefix(e.Name(), "libfreetype") { - hasLib = true - break + + releaseRepo := newExpatReleaseRepo(t, "2.6.4") + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + var observed []evaluator.SynthesizedPairObservation + main := module.Version{Path: "libexpat/libexpat", Version: "2.6.4"} + loadMods := func(ctx context.Context) ([]*modules.Module, error) { + return modules.Load(ctx, main, modules.Options{FormulaStore: store}) + } + newProbeBuilder := func(combo string) *Builder { + return &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/libexpat/libexpat" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, } } - if !hasLib { - t.Errorf("no libfreetype* found in %s", libDir) + + combos, _, err := evaluator.WatchWithOptions(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("Expat stage3 probe start: %s", combo) + b := newProbeBuilder(combo) + mods, err := loadMods(ctx) + if err != nil { + return evaluator.ProbeResult{}, err + } + + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + t.Logf("Expat stage3 probe done: %s (%d trace records)", combo, len(result.Trace)) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + report.AddCombo(combo, probeResult, evaluator.DebugSummaryOptions{ + RoleSampleLimit: 8, + InterestingLimit: 8, + InterestingTokens: []string{ + "/_build/expat_config.h", + "/_build/libexpat.a", + "/libexpat", + "/include/expat.h", + "/include/expat_config.h", + }, + }) + report.AddTraceMatches(probeResult, []string{ + "expat_config.h", + "xmlparse.c", + "xmlrole.c", + "xmltok.c", + }, 12) + return probeResult, nil + }, evaluator.WatchOptions{ + ValidateSynthesizedPair: synthesizedPairOnTestValidatorForTest(t, loadMods, newProbeBuilder), + ObserveSynthesizedPair: func(observation evaluator.SynthesizedPairObservation) { + observed = append(observed, observation) + report.AddSection(evaluator.DebugSynthesizedPairSummary(observation)) + }, + }) + if err != nil { + t.Fatalf("WatchWithOptions() failed: %v", err) } - // Check header - headerPath := filepath.Join(ftInstallDir, "include", "freetype2", "freetype", "freetype.h") - if _, err := os.Stat(headerPath); err != nil { - headerPath = filepath.Join(ftInstallDir, "include", "freetype2", "ft2build.h") - if _, err := os.Stat(headerPath); err != nil { - t.Errorf("freetype headers not found in include/freetype2/") + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("Expat stage3 precision summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("Expat stage3 precision traces written to %s", traceLogPath) + + want := []string{ + "ge-off-ns-off", + "ge-off-ns-on", + "ge-on-ns-off", + } + if !slices.Equal(combos, want) { + t.Fatalf("WatchWithOptions() combos = %v, want %v", combos, want) + } + if len(observed) != 1 { + t.Fatalf("observed synthesized pairs = %d, want 1", len(observed)) + } + + observation, ok := synthesizedPairObservationForCombo(observed, "ge-on-ns-on") + if !ok { + t.Fatalf("missing synthesized observation for %q", "ge-on-ns-on") + } + if !observation.ValidationAttempted { + t.Fatalf("validationAttempted = false, want true") + } + if !observation.Validated { + t.Fatalf("validated = false, want true") + } + if !observation.SynthesisResult.Clean() { + t.Fatalf("synthesis status = %q, want clean replay output", observation.SynthesisResult.Status) + } + if observation.SynthesisResult.Mode != evaluator.OutputSynthesisModeRootReplay { + t.Fatalf("synthesis mode = %q, want %q", observation.SynthesisResult.Mode, evaluator.OutputSynthesisModeRootReplay) + } + if observation.SynthesisResult.Replay == nil { + t.Fatal("replay summary = nil, want replay summary") + } + if observation.SynthesisResult.Replay.Unavailable != "" { + t.Fatalf("replay unavailable = %q, want empty string", observation.SynthesisResult.Replay.Unavailable) + } +} + +func TestE2E_WatchMergedPairValidationSkipsCleanOrthogonalPair(t *testing.T) { + store := setupTestStore(t) + matrix := formula.Matrix{ + Require: map[string][]string{ + "arch": {"amd64"}, + "os": {"linux"}, + }, + Options: map[string][]string{ + "a": {"a-off", "a-on"}, + "b": {"b-off", "b-on"}, + }, + DefaultOptions: map[string][]string{ + "a": {"a-off"}, + "b": {"b-off"}, + }, + } + + main := module.Version{Path: "test/mergedtest", Version: "1.0.0"} + resultsByCombo := make(map[string]Result) + loadProbe := func(t *testing.T, combo string) evaluator.ProbeResult { + t.Helper() + if result, ok := resultsByCombo[combo]; ok { + return probeResultFromBuildResult(result) + } + b := setupBuilder(t, store, combo) + results, _ := loadAndBuild(t, b, store, main) + result := results[len(results)-1] + resultsByCombo[combo] = result + return probeResultFromBuildResult(result) + } + + validate := func(ctx context.Context, combo string, synthesized evaluator.OutputSynthesisResult) (bool, error) { + b := setupBuilder(t, store, combo) + mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + if err != nil { + return false, err + } + if err := b.RunOnTest(ctx, mods, synthesized.Root); err != nil { + return false, err + } + return true, nil + } + + got, trusted, err := evaluator.WatchWithOptions(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + return loadProbe(t, combo), nil + }, evaluator.WatchOptions{ + ValidateSynthesizedPair: validate, + }) + if err != nil { + t.Fatalf("WatchWithOptions() unexpected error: %v", err) + } + if !trusted { + t.Fatalf("WatchWithOptions() trusted = false, want true") + } + + want := []string{ + "amd64-linux|a-off-b-off", + "amd64-linux|a-off-b-on", + "amd64-linux|a-on-b-off", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("WatchWithOptions() = %v, want %v", got, want) + } +} + +func TestE2E_Watch_RealOptionClassification(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("real option classification test requires Linux") + } + if testing.Short() { + t.Skip("skipping real option classification test in short mode") + } + + type testCase struct { + name string + tools []string + main module.Version + matrix formula.Matrix + interestingTokens []string + assert func(t *testing.T, combos []string, trusted bool, matrix formula.Matrix) + } + + cases := []testCase{ + { + name: "OpenSSLAsmZlib", + tools: []string{"git", "perl", "make", "cc", "strace"}, + main: module.Version{Path: "openssl/openssl", Version: "openssl-3.6.1"}, + matrix: formula.Matrix{ + Options: map[string][]string{ + "asm": {"asm-off", "asm-on"}, + "zlib": {"zlib-off", "zlib-on"}, + }, + DefaultOptions: map[string][]string{ + "asm": {"asm-off"}, + "zlib": {"zlib-off"}, + }, + }, + interestingTokens: []string{ + "/configdata.pm", + "/include/openssl/", + "/providers/legacy", + "/crypto/", + "/ssl/", + }, + assert: func(t *testing.T, combos []string, trusted bool, matrix formula.Matrix) { + t.Helper() + _ = trusted + _ = combos + _ = matrix + }, + }, + { + name: "FFmpegNetworkZlib", + tools: []string{"git", "make", "cc", "strace"}, + main: module.Version{Path: "FFmpeg/FFmpeg", Version: "n8.0.1"}, + matrix: formula.Matrix{ + Options: map[string][]string{ + "network": {"network-off", "network-on"}, + "zlib": {"zlib-off", "zlib-on"}, + }, + DefaultOptions: map[string][]string{ + "network": {"network-off"}, + "zlib": {"zlib-off"}, + }, + }, + interestingTokens: []string{ + "/config.h", + "/libavcodec/", + "/libavformat/", + "/libavutil/", + "/libswscale/", + }, + assert: func(t *testing.T, combos []string, trusted bool, matrix formula.Matrix) { + t.Helper() + want := matrix.Combinations() + if !slices.Equal(combos, want) { + t.Fatalf("Watch() combos = %v, want full matrix %v", combos, want) + } + }, + }, + { + name: "OpenCVDnnCalib3d", + tools: []string{"git", "cmake", "c++", "python3", "strace"}, + main: module.Version{Path: "opencv/opencv", Version: "4.9.0"}, + matrix: formula.Matrix{ + Options: map[string][]string{ + "calib3d": {"calib3d-off", "calib3d-on"}, + "dnn": {"dnn-off", "dnn-on"}, + }, + DefaultOptions: map[string][]string{ + "calib3d": {"calib3d-off"}, + "dnn": {"dnn-off"}, + }, + }, + interestingTokens: []string{ + "/opencv_modules.hpp", + "/opencv2/opencv_modules.hpp", + "/modules/dnn/", + "/modules/calib3d/", + "/python/", + }, + assert: func(t *testing.T, combos []string, trusted bool, matrix formula.Matrix) { + t.Helper() + _ = trusted + _ = combos + _ = matrix + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + for _, tool := range tc.tools { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping %s", tool, tc.name) + } + } + + store := setupTestStore(t) + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + loadMods := func(ctx context.Context) ([]*modules.Module, error) { + return modules.Load(ctx, tc.main, modules.Options{FormulaStore: store}) + } + newProbeBuilder := func(combo string) *Builder { + return &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: vcs.NewRepo, + } + } + + combos, trusted, err := evaluator.WatchWithOptions(context.Background(), tc.matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("%s probe start: %s", tc.name, combo) + b := newProbeBuilder(combo) + + mods, err := loadMods(ctx) + if err != nil { + return evaluator.ProbeResult{}, err + } + + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + probeResult := probeResultFromBuildResult(result) + report.AddCombo(combo, probeResult, evaluator.DebugSummaryOptions{ + RoleSampleLimit: 10, + InterestingLimit: 10, + InterestingTokens: tc.interestingTokens, + }) + resultsByCombo[combo] = probeResult + return probeResult, nil + }, evaluator.WatchOptions{ + ValidateSynthesizedPair: synthesizedPairOnTestValidatorForTest(t, loadMods, newProbeBuilder), + ObserveSynthesizedPair: func(observation evaluator.SynthesizedPairObservation) { + report.AddSection(evaluator.DebugSynthesizedPairSummary(observation)) + }, + }) + if err != nil { + t.Fatalf("WatchWithOptions() failed: %v", err) + } + + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("%s option classification summary written to %s", tc.name, logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("%s option trace records written to %s", tc.name, traceLogPath) + + tc.assert(t, combos, trusted, tc.matrix) + }) + } +} + +func formatTraceCombosForTest(results map[string]evaluator.ProbeResult) string { + if len(results) == 0 { + return "" + } + combos := slices.Sorted(maps.Keys(results)) + var b strings.Builder + for i, combo := range combos { + if i > 0 { + b.WriteString("\n\n") + } + b.WriteString("COMBO ") + b.WriteString(combo) + b.WriteByte('\n') + if diagnostics := formatTraceDiagnosticsForTest(results[combo].TraceDiagnostics); diagnostics != "" { + b.WriteString("DIAGNOSTICS\n") + b.WriteString(diagnostics) + } + if len(results[combo].InputDigests) > 0 { + b.WriteString("DIGESTS\n") + for _, path := range slices.Sorted(maps.Keys(results[combo].InputDigests)) { + b.WriteString(" ") + b.WriteString(path) + b.WriteString(" = ") + b.WriteString(results[combo].InputDigests[path]) + b.WriteByte('\n') + } + } + b.WriteString(formatTraceRecordsForTest(results[combo].Records)) + } + return b.String() +} + +func formatTraceDiagnosticsForTest(d trace.ParseDiagnostics) string { + if d.UnrecognizedLines == 0 && + d.ResumedMismatches == 0 && + d.InvalidCalls == 0 && + d.MissingPIDLines == 0 && + d.PIDStateResets == 0 { + return "" + } + + var b strings.Builder + if d.UnrecognizedLines != 0 { + b.WriteString(" unrecognized_lines = ") + b.WriteString(strconv.Itoa(d.UnrecognizedLines)) + b.WriteByte('\n') + } + if d.ResumedMismatches != 0 { + b.WriteString(" resumed_mismatches = ") + b.WriteString(strconv.Itoa(d.ResumedMismatches)) + b.WriteByte('\n') + } + if d.InvalidCalls != 0 { + b.WriteString(" invalid_calls = ") + b.WriteString(strconv.Itoa(d.InvalidCalls)) + b.WriteByte('\n') + } + if d.MissingPIDLines != 0 { + b.WriteString(" missing_pid_lines = ") + b.WriteString(strconv.Itoa(d.MissingPIDLines)) + b.WriteByte('\n') + } + if d.PIDStateResets != 0 { + b.WriteString(" pid_state_resets = ") + b.WriteString(strconv.Itoa(d.PIDStateResets)) + b.WriteByte('\n') + } + return b.String() +} + +func formatProbeEvidenceForTest(label string, probe evaluator.ProbeResult, tokens []string, limit int) string { + var b strings.Builder + b.WriteString("PROBE EVIDENCE ") + b.WriteString(label) + b.WriteString(":\n") + if diagnostics := formatTraceDiagnosticsForTest(probe.TraceDiagnostics); diagnostics != "" { + b.WriteString("trace diagnostics:\n") + b.WriteString(diagnostics) + } else { + b.WriteString("trace diagnostics: clean\n") + } + b.WriteString("digest matches:\n") + matchedDigests := 0 + for _, path := range slices.Sorted(maps.Keys(probe.InputDigests)) { + for _, token := range tokens { + if token == "" || !strings.Contains(path, token) { + continue + } + b.WriteString(" ") + b.WriteString(path) + b.WriteString(" = ") + b.WriteString(probe.InputDigests[path]) + b.WriteByte('\n') + matchedDigests++ + break + } + } + if matchedDigests == 0 { + b.WriteString(" absent\n") + } + b.WriteString(strings.TrimRight(evaluator.DebugProbeTraceMatches(probe, tokens, limit), "\n")) + return b.String() +} + +func formatTraceRecordsForTest(records []trace.Record) string { + var b strings.Builder + for i, rec := range records { + b.WriteString(strconv.Itoa(i + 1)) + b.WriteString(". argv: ") + b.WriteString(strings.Join(rec.Argv, " ")) + b.WriteByte('\n') + if rec.Cwd != "" { + b.WriteString(" cwd: ") + b.WriteString(rec.Cwd) + b.WriteByte('\n') + } + if rec.PID != 0 { + b.WriteString(" pid: ") + b.WriteString(strconv.FormatInt(rec.PID, 10)) + b.WriteByte('\n') + } + if rec.ParentPID != 0 { + b.WriteString(" ppid: ") + b.WriteString(strconv.FormatInt(rec.ParentPID, 10)) + b.WriteByte('\n') + } + if len(rec.Inputs) > 0 { + b.WriteString(" inputs: ") + b.WriteString(strings.Join(rec.Inputs, ", ")) + b.WriteByte('\n') + } + if len(rec.Changes) > 0 { + b.WriteString(" changes: ") + b.WriteString(strings.Join(rec.Changes, ", ")) + b.WriteByte('\n') + } + } + return b.String() +} + +func writeTraceLogForTest(t *testing.T, dump string) string { + t.Helper() + + path := os.Getenv("LLAR_TRACE_LOG") + if path == "" { + path = defaultTestLogPath(t, "trace") + } + + if err := os.WriteFile(path, []byte(dump), 0o644); err != nil { + t.Fatalf("write trace log %s: %v", path, err) + } + return path +} + +func writeGraphLogForTest(t *testing.T, dump string) string { + t.Helper() + + path := os.Getenv("LLAR_GRAPH_LOG") + if path == "" { + path = defaultTestLogPath(t, "graph") + } + + if err := os.WriteFile(path, []byte(dump), 0o644); err != nil { + t.Fatalf("write graph log %s: %v", path, err) + } + return path +} + +func defaultTestLogPath(t *testing.T, kind string) string { + t.Helper() + + root := projectRootForTest(t) + dir := filepath.Join(root, ".llar-e2e-logs") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create test log dir %s: %v", dir, err) + } + filename := fmt.Sprintf("%s-%s-%d.log", sanitizeTestLogName(t.Name()), kind, time.Now().UnixNano()) + return filepath.Join(dir, filename) +} + +func projectRootForTest(t *testing.T) string { + t.Helper() + + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate e2e_test.go: runtime.Caller failed") + } + for dir := filepath.Dir(file); ; dir = filepath.Dir(dir) { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("find project root from %s: go.mod not found", file) + } + } +} + +func sanitizeTestLogName(name string) string { + var b strings.Builder + b.Grow(len(name)) + for _, r := range name { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + case r >= 'A' && r <= 'Z': + b.WriteRune(r) + case r >= '0' && r <= '9': + b.WriteRune(r) + case r == '.' || r == '-' || r == '_': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + return b.String() +} + +func summaryHasTokenRole(summary, token, role string) bool { + for _, line := range strings.Split(summary, "\n") { + if strings.Contains(line, token) && strings.Contains(line, "=> "+role) { + return true + } + } + return false +} + +// TestE2E_RealLibpngBuild builds libpng with its zlib dependency using cmake.use. +// Verifies: formula dep resolution → zlib built first → cmake.use injects zlib → +// libpng configure/build/install succeeds → artifacts exist. +func TestE2E_RealLibpngBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping real build test in short mode") + } + if _, err := exec.LookPath("cmake"); err != nil { + t.Skip("cmake not found, skipping real build test") + } + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not found, skipping real build test") + } + + store := setupTestStore(t) + matrix := runtime.GOARCH + "-" + runtime.GOOS + + b := &Builder{ + store: store, + matrix: matrix, + workspaceDir: t.TempDir(), + newRepo: func(repoPath string) (vcs.Repo, error) { + return vcs.NewRepo(repoPath) + }, + } + + main := module.Version{Path: "pnggroup/libpng", Version: "v1.6.47"} + ctx := context.Background() + mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load() failed: %v", err) + } + + // Should have 2 modules: zlib + libpng + if len(mods) != 2 { + t.Fatalf("got %d modules, want 2", len(mods)) + } + + results, err := b.Build(ctx, mods) + if err != nil { + t.Fatalf("Build() failed: %v", err) + } + + if len(results) != 2 { + t.Fatalf("got %d results, want 2", len(results)) + } + + // Verify zlib was built (first in order) + zlibR, ok := findResult(results, b, mods, "madler/zlib") + if !ok { + t.Fatal("missing result for madler/zlib") + } + if zlibR.Metadata != "-lz" { + t.Errorf("zlib metadata = %q, want %q", zlibR.Metadata, "-lz") + } + + // Verify libpng was built + pngR, ok := findResult(results, b, mods, "pnggroup/libpng") + if !ok { + t.Fatal("missing result for pnggroup/libpng") + } + if pngR.Metadata != "-lpng" { + t.Errorf("libpng metadata = %q, want %q", pngR.Metadata, "-lpng") + } + + // Verify libpng build artifacts + pngInstallDir, _ := b.installDir("pnggroup/libpng", "v1.6.47") + + // Check library + libDir := filepath.Join(pngInstallDir, "lib") + libEntries, err := os.ReadDir(libDir) + if err != nil { + t.Fatalf("lib dir not found at %s: %v", libDir, err) + } + hasLib := false + for _, e := range libEntries { + if strings.HasPrefix(e.Name(), "libpng") { + hasLib = true + break + } + } + if !hasLib { + t.Errorf("no libpng* found in %s", libDir) + } + + // Check header + headerPath := filepath.Join(pngInstallDir, "include", "libpng16", "png.h") + if _, err := os.Stat(headerPath); err != nil { + // Some cmake configs install directly to include/ + headerPath = filepath.Join(pngInstallDir, "include", "png.h") + if _, err := os.Stat(headerPath); err != nil { + t.Errorf("png.h not found in include/ or include/libpng16/") + } + } +} + +// TestE2E_RealFreetypeBuild builds freetype with its transitive dependencies: +// freetype -> {libpng, zlib}, libpng -> zlib (diamond). +// Demonstrates: onRequire dynamic dep extraction from meson wrap files → +// diamond dep resolution → cmake.use injection → pkg-config metadata extraction. +func TestE2E_RealFreetypeBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping real build test in short mode") + } + for _, tool := range []string{"cmake", "git", "pkg-config"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real build test", tool) + } + } + + store := setupTestStore(t) + matrix := runtime.GOARCH + "-" + runtime.GOOS + + b := &Builder{ + store: store, + matrix: matrix, + workspaceDir: t.TempDir(), + newRepo: func(repoPath string) (vcs.Repo, error) { + return vcs.NewRepo(repoPath) + }, + } + + main := module.Version{Path: "freetype/freetype", Version: "VER-2-13-3"} + ctx := context.Background() + mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load() failed: %v", err) + } + + // Should have 3 modules: zlib + libpng + freetype + if len(mods) != 3 { + t.Fatalf("got %d modules, want 3 (zlib, libpng, freetype)", len(mods)) + } + t.Logf("resolved modules: %v", mods) + + results, err := b.Build(ctx, mods) + if err != nil { + t.Fatalf("Build() failed: %v", err) + } + + if len(results) != 3 { + t.Fatalf("got %d results, want 3", len(results)) + } + + // Verify freetype metadata from pkg-config contains -lfreetype + ftR, ok := findResult(results, b, mods, "freetype/freetype") + if !ok { + t.Fatal("missing result for freetype/freetype") + } + if !strings.Contains(ftR.Metadata, "-lfreetype") { + t.Errorf("freetype metadata = %q, want it to contain %q", ftR.Metadata, "-lfreetype") + } + 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") + + // Check library + libDir := filepath.Join(ftInstallDir, "lib") + libEntries, err := os.ReadDir(libDir) + if err != nil { + t.Fatalf("lib dir not found at %s: %v", libDir, err) + } + hasLib := false + for _, e := range libEntries { + if strings.HasPrefix(e.Name(), "libfreetype") { + hasLib = true + break + } + } + if !hasLib { + t.Errorf("no libfreetype* found in %s", libDir) + } + + // Check header + headerPath := filepath.Join(ftInstallDir, "include", "freetype2", "freetype", "freetype.h") + if _, err := os.Stat(headerPath); err != nil { + headerPath = filepath.Join(ftInstallDir, "include", "freetype2", "ft2build.h") + if _, err := os.Stat(headerPath); err != nil { + t.Errorf("freetype headers not found in include/freetype2/") + } + } +} + +func TestE2E_LoadBoostFormula(t *testing.T) { + store := setupTestStore(t) + _, err := modules.Load(context.Background(), module.Version{ + Path: "boostorg/boost", + Version: "boost-1.90.0", + }, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load(boostorg/boost) failed: %v", err) + } +} + +func TestE2E_LoadFmtFormula(t *testing.T) { + store := setupTestStore(t) + _, err := modules.Load(context.Background(), module.Version{ + Path: "fmtlib/fmt", + Version: "11.1.4", + }, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load(fmtlib/fmt) failed: %v", err) + } +} + +func TestE2E_LoadLibjpegTurboFormula(t *testing.T) { + store := setupTestStore(t) + _, err := modules.Load(context.Background(), module.Version{ + Path: "libjpeg-turbo/libjpeg-turbo", + Version: "3.1.3", + }, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load(libjpeg-turbo/libjpeg-turbo) failed: %v", err) + } +} + +func TestE2E_LoadSqliteFormula(t *testing.T) { + store := setupTestStore(t) + _, err := modules.Load(context.Background(), module.Version{ + Path: "sqlite/sqlite", + Version: "3.45.3", + }, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load(sqlite/sqlite) failed: %v", err) + } +} + +func TestE2E_LoadPocoFormula(t *testing.T) { + store := setupTestStore(t) + _, err := modules.Load(context.Background(), module.Version{ + Path: "pocoproject/poco", + Version: "poco-1.14.2-release", + }, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load(pocoproject/poco) failed: %v", err) + } +} + +func TestE2E_LoadPCRE2Formula(t *testing.T) { + store := setupTestStore(t) + _, err := modules.Load(context.Background(), module.Version{ + Path: "PCRE2Project/pcre2", + Version: "pcre2-10.45", + }, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load(PCRE2Project/pcre2) failed: %v", err) + } +} + +func TestE2E_LoadPugixmlFormula(t *testing.T) { + store := setupTestStore(t) + _, err := modules.Load(context.Background(), module.Version{ + Path: "zeux/pugixml", + Version: "1.15", + }, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load(zeux/pugixml) failed: %v", err) + } +} + +func TestE2E_LoadExpatFormula(t *testing.T) { + store := setupTestStore(t) + _, err := modules.Load(context.Background(), module.Version{ + Path: "libexpat/libexpat", + Version: "2.6.4", + }, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load(libexpat/libexpat) failed: %v", err) + } +} + +func TestE2E_LoadCjsonFormula(t *testing.T) { + store := setupTestStore(t) + _, err := modules.Load(context.Background(), module.Version{ + Path: "DaveGamble/cJSON", + Version: "1.7.19", + }, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load(DaveGamble/cJSON) failed: %v", err) + } +} + +func TestE2E_LoadCAresFormula(t *testing.T) { + store := setupTestStore(t) + _, err := modules.Load(context.Background(), module.Version{ + Path: "c-ares/c-ares", + Version: "1.34.5", + }, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load(c-ares/c-ares) failed: %v", err) + } +} + +func TestE2E_LoadLibwebpFormula(t *testing.T) { + store := setupTestStore(t) + _, err := modules.Load(context.Background(), module.Version{ + Path: "webmproject/libwebp", + Version: "1.5.0", + }, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load(webmproject/libwebp) failed: %v", err) + } +} + +func TestE2E_LoadLibtiffFormula(t *testing.T) { + store := setupTestStore(t) + _, err := modules.Load(context.Background(), module.Version{ + Path: "libsdl-org/libtiff", + Version: "4.7.1", + }, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load(libsdl-org/libtiff) failed: %v", err) + } +} + +func TestE2E_LoadZstdFormula(t *testing.T) { + store := setupTestStore(t) + _, err := modules.Load(context.Background(), module.Version{ + Path: "facebook/zstd", + Version: "1.5.7", + }, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load(facebook/zstd) failed: %v", err) + } +} + +func TestE2E_LoadYamlCppFormula(t *testing.T) { + store := setupTestStore(t) + + mods, err := modules.Load(context.Background(), module.Version{ + Path: "jbeder/yaml-cpp", + Version: "0.9.0", + }, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load(jbeder/yaml-cpp) failed: %v", err) + } + if len(mods) != 1 { + t.Fatalf("got %d modules, want 1", len(mods)) + } +} + +func TestE2E_LoadSpdlogFormula(t *testing.T) { + store := setupTestStore(t) + + mods, err := modules.Load(context.Background(), module.Version{ + Path: "gabime/spdlog", + Version: "1.17.0", + }, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load(gabime/spdlog) failed: %v", err) + } + if len(mods) != 1 { + t.Fatalf("got %d modules, want 1", len(mods)) + } +} + +func TestE2E_RealFmtBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping real fmt build test in short mode") + } + for _, tool := range []string{"cmake", "c++"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real fmt build test", tool) + } + } + + store := setupTestStore(t) + releaseRepo := newFmtReleaseRepo(t, "11.1.4") + b := &Builder{ + store: store, + matrix: "osapi-on-unicode-on", + workspaceDir: t.TempDir(), + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/fmtlib/fmt" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "fmtlib/fmt", Version: "11.1.4"} + results, mods := loadAndBuild(t, b, store, main) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + + fmtR, ok := findResult(results, b, mods, "fmtlib/fmt") + if !ok { + t.Fatal("missing result for fmtlib/fmt") + } + if !strings.Contains(fmtR.Metadata, "-lfmt") { + t.Fatalf("metadata = %q, want it to contain -lfmt", fmtR.Metadata) + } + + installDir, _ := b.installDir("fmtlib/fmt", "11.1.4") + if !dirHasPrefix(filepath.Join(installDir, "lib"), "libfmt") { + t.Fatalf("missing libfmt* under %s", filepath.Join(installDir, "lib")) + } +} + +func TestE2E_RealLibjpegTurboBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping real libjpeg-turbo build test in short mode") + } + for _, tool := range []string{"cmake", "cc"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real libjpeg-turbo build test", tool) + } + } + + store := setupTestStore(t) + releaseRepo := newLibjpegTurboReleaseRepo(t, "3.1.3") + b := &Builder{ + store: store, + matrix: "arithdec-on-arithenc-on-tools-on", + workspaceDir: t.TempDir(), + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/libjpeg-turbo/libjpeg-turbo" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "libjpeg-turbo/libjpeg-turbo", Version: "3.1.3"} + results, mods := loadAndBuild(t, b, store, main) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + + jpegR, ok := findResult(results, b, mods, "libjpeg-turbo/libjpeg-turbo") + if !ok { + t.Fatal("missing result for libjpeg-turbo/libjpeg-turbo") + } + if !strings.Contains(jpegR.Metadata, "-ljpeg") { + t.Fatalf("metadata = %q, want it to contain -ljpeg", jpegR.Metadata) + } + + installDir, _ := b.installDir("libjpeg-turbo/libjpeg-turbo", "3.1.3") + if !dirHasPrefix(filepath.Join(installDir, "lib"), "libjpeg") { + t.Fatalf("missing libjpeg* under %s", filepath.Join(installDir, "lib")) + } + if !dirHasPrefix(filepath.Join(installDir, "bin"), "cjpeg") { + t.Fatalf("missing cjpeg* under %s", filepath.Join(installDir, "bin")) + } +} + +func TestE2E_RealSqliteBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping real sqlite build test in short mode") + } + for _, tool := range []string{"cc", "ar"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real sqlite build test", tool) + } + } + + store := setupTestStore(t) + releaseRepo := newSqliteReleaseRepo(t, "3.45.3") + b := &Builder{ + store: store, + matrix: "dbstat-on-json1-on-rtree-on-soundex-on", + workspaceDir: t.TempDir(), + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/sqlite/sqlite" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "sqlite/sqlite", Version: "3.45.3"} + results, mods := loadAndBuild(t, b, store, main) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + + sqliteR, ok := findResult(results, b, mods, "sqlite/sqlite") + if !ok { + t.Fatal("missing result for sqlite/sqlite") + } + if !strings.Contains(sqliteR.Metadata, "-lsqlite3") { + t.Fatalf("metadata = %q, want it to contain -lsqlite3", sqliteR.Metadata) + } + + installDir, _ := b.installDir("sqlite/sqlite", "3.45.3") + if !dirHasPrefix(filepath.Join(installDir, "lib"), "libsqlite3") { + t.Fatalf("missing libsqlite3* under %s", filepath.Join(installDir, "lib")) + } + for _, hdr := range []string{"sqlite3.h", "sqlite3ext.h"} { + if _, err := os.Stat(filepath.Join(installDir, "include", hdr)); err != nil { + t.Fatalf("missing %s under %s/include", hdr, installDir) + } + } +} + +func TestE2E_RealPocoBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping real Poco build test in short mode") + } + for _, tool := range []string{"cmake", "c++"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real Poco build test", tool) + } + } + + store := setupTestStore(t) + releaseRepo := newPocoReleaseRepo(t, "poco-1.14.2-release") + b := &Builder{ + store: store, + matrix: "encodings-on-json-on-net-on-xml-on", + workspaceDir: t.TempDir(), + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/pocoproject/poco" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "pocoproject/poco", Version: "poco-1.14.2-release"} + results, mods := loadAndBuild(t, b, store, main) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + + pocoR, ok := findResult(results, b, mods, "pocoproject/poco") + if !ok { + t.Fatal("missing result for pocoproject/poco") + } + for _, lib := range []string{"-lPocoFoundation", "-lPocoEncodings", "-lPocoJSON", "-lPocoNet", "-lPocoXML"} { + if !strings.Contains(pocoR.Metadata, lib) { + t.Fatalf("metadata = %q, want it to contain %q", pocoR.Metadata, lib) + } + } + + installDir, _ := b.installDir("pocoproject/poco", "poco-1.14.2-release") + for _, lib := range []string{"libPocoFoundation", "libPocoEncodings", "libPocoJSON", "libPocoNet", "libPocoXML"} { + if !dirHasPrefix(filepath.Join(installDir, "lib"), lib) { + t.Fatalf("missing %s* under %s", lib, filepath.Join(installDir, "lib")) + } + } +} + +func TestE2E_RealPCRE2Build(t *testing.T) { + if testing.Short() { + t.Skip("skipping real PCRE2 build test in short mode") + } + for _, tool := range []string{"cmake", "cc"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real PCRE2 build test", tool) + } + } + + store := setupTestStore(t) + releaseRepo := newPcre2ReleaseRepo(t, "pcre2-10.45") + b := &Builder{ + store: store, + matrix: "grep-on-width16-on-width32-on", + workspaceDir: t.TempDir(), + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/PCRE2Project/pcre2" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "PCRE2Project/pcre2", Version: "pcre2-10.45"} + results, mods := loadAndBuild(t, b, store, main) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + + pcre2R, ok := findResult(results, b, mods, "PCRE2Project/pcre2") + if !ok { + t.Fatal("missing result for PCRE2Project/pcre2") + } + for _, lib := range []string{"-lpcre2-8", "-lpcre2-posix", "-lpcre2-16", "-lpcre2-32"} { + if !strings.Contains(pcre2R.Metadata, lib) { + t.Fatalf("metadata = %q, want it to contain %q", pcre2R.Metadata, lib) + } + } + + installDir, _ := b.installDir("PCRE2Project/pcre2", "pcre2-10.45") + for _, lib := range []string{"libpcre2-8", "libpcre2-posix", "libpcre2-16", "libpcre2-32"} { + if !dirHasPrefix(filepath.Join(installDir, "lib"), lib) { + t.Fatalf("missing %s* under %s", lib, filepath.Join(installDir, "lib")) + } + } + if !dirHasPrefix(filepath.Join(installDir, "bin"), "pcre2grep") { + t.Fatalf("missing pcre2grep* under %s", filepath.Join(installDir, "bin")) + } +} + +func TestE2E_RealPugixmlBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping real pugixml build test in short mode") + } + for _, tool := range []string{"cmake", "c++"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real pugixml build test", tool) + } + } + + store := setupTestStore(t) + releaseRepo := newPugixmlReleaseRepo(t, "1.15") + b := &Builder{ + store: store, + matrix: "compact-on-noexceptions-on-noxpath-on-wchar-on", + workspaceDir: t.TempDir(), + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/zeux/pugixml" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "zeux/pugixml", Version: "1.15"} + results, mods := loadAndBuild(t, b, store, main) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + + pugiR, ok := findResult(results, b, mods, "zeux/pugixml") + if !ok { + t.Fatal("missing result for zeux/pugixml") + } + if !strings.Contains(pugiR.Metadata, "-lpugixml") { + t.Fatalf("metadata = %q, want it to contain -lpugixml", pugiR.Metadata) + } + + installDir, _ := b.installDir("zeux/pugixml", "1.15") + if !dirHasPrefix(filepath.Join(installDir, "lib"), "libpugixml") { + t.Fatalf("missing libpugixml* under %s", filepath.Join(installDir, "lib")) + } + for _, hdr := range []string{"pugixml.hpp", "pugiconfig.hpp"} { + if _, err := os.Stat(filepath.Join(installDir, "include", hdr)); err != nil { + t.Fatalf("missing %s under %s/include", hdr, installDir) + } + } +} + +func TestE2E_RealExpatBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping real expat build test in short mode") + } + for _, tool := range []string{"cmake", "cc"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real expat build test", tool) + } + } + + store := setupTestStore(t) + releaseRepo := newExpatReleaseRepo(t, "2.6.4") + b := &Builder{ + store: store, + matrix: "ge-on-large_size-on-min_size-on-ns-on", + workspaceDir: t.TempDir(), + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/libexpat/libexpat" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "libexpat/libexpat", Version: "2.6.4"} + results, mods := loadAndBuild(t, b, store, main) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + + expatR, ok := findResult(results, b, mods, "libexpat/libexpat") + if !ok { + t.Fatal("missing result for libexpat/libexpat") + } + if !strings.Contains(expatR.Metadata, "-lexpat") { + t.Fatalf("metadata = %q, want it to contain -lexpat", expatR.Metadata) + } + + installDir, _ := b.installDir("libexpat/libexpat", "2.6.4") + if !dirHasPrefix(filepath.Join(installDir, "lib"), "libexpat") { + t.Fatalf("missing libexpat* under %s", filepath.Join(installDir, "lib")) + } + for _, hdr := range []string{"expat.h", "expat_external.h", "expat_config.h"} { + if _, err := os.Stat(filepath.Join(installDir, "include", hdr)); err != nil { + t.Fatalf("missing %s under %s/include", hdr, installDir) + } + } +} + +func TestE2E_RealCjsonBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping real cJSON build test in short mode") + } + for _, tool := range []string{"cmake", "cc"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real cJSON build test", tool) + } + } + + store := setupTestStore(t) + releaseRepo := newCjsonReleaseRepo(t, "1.7.19") + b := &Builder{ + store: store, + matrix: "locales-on-utils-on", + workspaceDir: t.TempDir(), + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/DaveGamble/cJSON" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "DaveGamble/cJSON", Version: "1.7.19"} + results, mods := loadAndBuild(t, b, store, main) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + + modR, ok := findResult(results, b, mods, "DaveGamble/cJSON") + if !ok { + t.Fatal("missing result for DaveGamble/cJSON") + } + if !strings.Contains(modR.Metadata, "-lcjson") || !strings.Contains(modR.Metadata, "-lcjson_utils") { + t.Fatalf("metadata = %q, want it to contain -lcjson and -lcjson_utils", modR.Metadata) + } + + installDir, _ := b.installDir("DaveGamble/cJSON", "1.7.19") + if !dirHasPrefix(filepath.Join(installDir, "lib"), "libcjson") { + t.Fatalf("missing libcjson* under %s", filepath.Join(installDir, "lib")) + } + if !dirHasPrefix(filepath.Join(installDir, "lib"), "libcjson_utils") { + t.Fatalf("missing libcjson_utils* under %s", filepath.Join(installDir, "lib")) + } +} + +func TestE2E_RealCAresBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping real c-ares build test in short mode") + } + for _, tool := range []string{"cmake", "cc"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real c-ares build test", tool) + } + } + + store := setupTestStore(t) + releaseRepo := newCAresReleaseRepo(t, "1.34.5") + b := &Builder{ + store: store, + matrix: "threads-on-tools-on", + workspaceDir: t.TempDir(), + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/c-ares/c-ares" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "c-ares/c-ares", Version: "1.34.5"} + results, mods := loadAndBuild(t, b, store, main) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + + modR, ok := findResult(results, b, mods, "c-ares/c-ares") + if !ok { + t.Fatal("missing result for c-ares/c-ares") + } + if !strings.Contains(modR.Metadata, "-lcares") { + t.Fatalf("metadata = %q, want it to contain -lcares", modR.Metadata) + } + + installDir, _ := b.installDir("c-ares/c-ares", "1.34.5") + if !dirHasPrefix(filepath.Join(installDir, "lib"), "libcares") { + t.Fatalf("missing libcares* under %s", filepath.Join(installDir, "lib")) + } + if !dirHasPrefix(filepath.Join(installDir, "bin"), "adig") && !dirHasPrefix(filepath.Join(installDir, "bin"), "ahost") { + t.Fatalf("missing c-ares tools under %s", filepath.Join(installDir, "bin")) + } +} + +func TestE2E_RealLibwebpBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping real libwebp build test in short mode") + } + for _, tool := range []string{"cmake", "cc", "c++"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real libwebp build test", tool) + } + } + + store := setupTestStore(t) + releaseRepo := newLibwebpReleaseRepo(t, "1.5.0") + b := &Builder{ + store: store, + matrix: "cwebp-on-mux-on", + workspaceDir: t.TempDir(), + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/webmproject/libwebp" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "webmproject/libwebp", Version: "1.5.0"} + results, mods := loadAndBuild(t, b, store, main) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + + modR, ok := findResult(results, b, mods, "webmproject/libwebp") + if !ok { + t.Fatal("missing result for webmproject/libwebp") + } + if !strings.Contains(modR.Metadata, "-lwebp") || !strings.Contains(modR.Metadata, "-lwebpmux") { + t.Fatalf("metadata = %q, want it to contain -lwebp and -lwebpmux", modR.Metadata) + } + + installDir, _ := b.installDir("webmproject/libwebp", "1.5.0") + if !dirHasPrefix(filepath.Join(installDir, "lib"), "libwebp") { + t.Fatalf("missing libwebp* under %s", filepath.Join(installDir, "lib")) + } + if !dirHasPrefix(filepath.Join(installDir, "lib"), "libwebpmux") { + t.Fatalf("missing libwebpmux* under %s", filepath.Join(installDir, "lib")) + } + if !dirHasPrefix(filepath.Join(installDir, "bin"), "cwebp") { + t.Fatalf("missing cwebp under %s", filepath.Join(installDir, "bin")) + } +} + +func TestE2E_RealLibtiffBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping real libtiff build test in short mode") + } + for _, tool := range []string{"cmake", "cc", "c++"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real libtiff build test", tool) + } + } + + store := setupTestStore(t) + releaseRepo := newLibtiffReleaseRepo(t, "4.7.1") + b := &Builder{ + store: store, + matrix: "cxx-on-tools-on", + workspaceDir: t.TempDir(), + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/libsdl-org/libtiff" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "libsdl-org/libtiff", Version: "4.7.1"} + results, mods := loadAndBuild(t, b, store, main) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + + modR, ok := findResult(results, b, mods, "libsdl-org/libtiff") + if !ok { + t.Fatal("missing result for libsdl-org/libtiff") + } + if !strings.Contains(modR.Metadata, "-ltiff") || !strings.Contains(modR.Metadata, "-ltiffxx") { + t.Fatalf("metadata = %q, want it to contain -ltiff and -ltiffxx", modR.Metadata) + } + + installDir, _ := b.installDir("libsdl-org/libtiff", "4.7.1") + if !dirHasPrefix(filepath.Join(installDir, "lib"), "libtiff") { + t.Fatalf("missing libtiff* under %s", filepath.Join(installDir, "lib")) + } + if !dirHasPrefix(filepath.Join(installDir, "lib"), "libtiffxx") { + t.Fatalf("missing libtiffxx* under %s", filepath.Join(installDir, "lib")) + } + if !dirHasPrefix(filepath.Join(installDir, "bin"), "tiffinfo") { + t.Fatalf("missing tiff tools under %s", filepath.Join(installDir, "bin")) + } +} + +func TestE2E_RealZstdBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping real zstd build test in short mode") + } + for _, tool := range []string{"cmake", "cc"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real zstd build test", tool) + } + } + + store := setupTestStore(t) + releaseRepo := newZstdReleaseRepo(t, "1.5.7") + b := &Builder{ + store: store, + matrix: "programs-on-threading-on", + workspaceDir: t.TempDir(), + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/facebook/zstd" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "facebook/zstd", Version: "1.5.7"} + results, mods := loadAndBuild(t, b, store, main) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + + modR, ok := findResult(results, b, mods, "facebook/zstd") + if !ok { + t.Fatal("missing result for facebook/zstd") + } + if !strings.Contains(modR.Metadata, "-lzstd") { + t.Fatalf("metadata = %q, want it to contain -lzstd", modR.Metadata) + } + + installDir, _ := b.installDir("facebook/zstd", "1.5.7") + if !dirHasPrefix(filepath.Join(installDir, "lib"), "libzstd") { + t.Fatalf("missing libzstd* under %s", filepath.Join(installDir, "lib")) + } + if !dirHasPrefix(filepath.Join(installDir, "bin"), "zstd") { + t.Fatalf("missing zstd under %s", filepath.Join(installDir, "bin")) + } +} + +func TestE2E_RealYamlCppBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping real yaml-cpp build test in short mode") + } + for _, tool := range []string{"cmake", "c++"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real yaml-cpp build test", tool) + } + } + + store := setupTestStore(t) + releaseRepo := newYamlCppReleaseRepo(t, "0.9.0") + b := &Builder{ + store: store, + matrix: "contrib-on-tools-on", + workspaceDir: t.TempDir(), + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/jbeder/yaml-cpp" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "jbeder/yaml-cpp", Version: "0.9.0"} + results, mods := loadAndBuild(t, b, store, main) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + + modR, ok := findResult(results, b, mods, "jbeder/yaml-cpp") + if !ok { + t.Fatal("missing result for jbeder/yaml-cpp") + } + if !strings.Contains(modR.Metadata, "-lyaml-cpp") { + t.Fatalf("metadata = %q, want it to contain -lyaml-cpp", modR.Metadata) + } + + installDir, _ := b.installDir("jbeder/yaml-cpp", "0.9.0") + if !dirHasPrefix(filepath.Join(installDir, "lib"), "libyaml-cpp") { + t.Fatalf("missing libyaml-cpp* under %s", filepath.Join(installDir, "lib")) + } + // Upstream builds parse/read/sandbox when YAML_CPP_BUILD_TOOLS=ON, but 0.9.0 does + // not install those binaries. Smoke test only asserts the installed library. +} + +func TestE2E_RealSpdlogBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping real spdlog build test in short mode") + } + for _, tool := range []string{"cmake", "c++"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real spdlog build test", tool) + } + } + + store := setupTestStore(t) + releaseRepo := newSpdlogReleaseRepo(t, "1.17.0") + b := &Builder{ + store: store, + matrix: "noexceptions-on-wchar-on", + workspaceDir: t.TempDir(), + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/gabime/spdlog" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "gabime/spdlog", Version: "1.17.0"} + results, mods := loadAndBuild(t, b, store, main) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + + modR, ok := findResult(results, b, mods, "gabime/spdlog") + if !ok { + t.Fatal("missing result for gabime/spdlog") + } + if !strings.Contains(modR.Metadata, "-lspdlog") { + t.Fatalf("metadata = %q, want it to contain -lspdlog", modR.Metadata) + } + + installDir, _ := b.installDir("gabime/spdlog", "1.17.0") + if !dirHasPrefix(filepath.Join(installDir, "lib"), "libspdlog") { + t.Fatalf("missing libspdlog* under %s", filepath.Join(installDir, "lib")) + } +} +func TestE2E_LoadUriparserFormula(t *testing.T) { + store := setupTestStore(t) + _, err := modules.Load(context.Background(), module.Version{ + Path: "uriparser/uriparser", + Version: "0.9.8", + }, modules.Options{FormulaStore: store}) + if err != nil { + t.Fatalf("modules.Load(uriparser/uriparser) failed: %v", err) + } +} + +func TestE2E_RealUriparserBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping real uriparser build test in short mode") + } + for _, tool := range []string{"cmake", "cc"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real uriparser build test", tool) + } + } + + store := setupTestStore(t) + releaseRepo := newUriparserReleaseRepo(t, "0.9.8") + b := &Builder{ + store: store, + matrix: "tools-on-wchar-on", + workspaceDir: t.TempDir(), + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/uriparser/uriparser" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "uriparser/uriparser", Version: "0.9.8"} + results, mods := loadAndBuild(t, b, store, main) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + + modR, ok := findResult(results, b, mods, "uriparser/uriparser") + if !ok { + t.Fatal("missing result for uriparser/uriparser") + } + if !strings.Contains(modR.Metadata, "-luriparser") { + t.Fatalf("metadata = %q, want it to contain -luriparser", modR.Metadata) + } + + installDir, _ := b.installDir("uriparser/uriparser", "0.9.8") + if !dirHasPrefix(filepath.Join(installDir, "lib"), "liburiparser") { + t.Fatalf("missing liburiparser* under %s", filepath.Join(installDir, "lib")) + } + if _, err := os.Stat(filepath.Join(installDir, "bin", "uriparse")); err != nil { + t.Fatalf("missing uriparse under %s", filepath.Join(installDir, "bin")) + } +} + +// TestE2E_Watch_RealOptionClassification_BoostProgramOptionsTimer validates +// the current graph reduction logic against a real install-heavy library. +// It uses the official Boost release tarball because the boostorg/boost git +// superproject is submodule-based and cannot be built from the current VCS sync +// behavior used by Builder. +func TestE2E_Watch_RealOptionClassification_BoostProgramOptionsTimer(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Boost option classification test requires Linux") + } + if testing.Short() { + t.Skip("skipping heavy Boost option classification test in short mode") + } + for _, tool := range []string{"c++", "cp", "mkdir", "sh", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping heavy Boost option classification test", tool) + } + } + + store := setupTestStore(t) + matrix := formula.Matrix{ + Options: map[string][]string{ + "program_options": {"program_options-off", "program_options-on"}, + "timer": {"timer-off", "timer-on"}, + }, + DefaultOptions: map[string][]string{ + "program_options": {"program_options-off"}, + "timer": {"timer-off"}, + }, + } + + releaseRepo := newBoostReleaseRepo(t, "boost-1.90.0") + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + + combos, _, err := evaluator.Watch(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("Boost probe start: %s", combo) + b := &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/boostorg/boost" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "boostorg/boost", Version: "boost-1.90.0"} + mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + if err != nil { + return evaluator.ProbeResult{}, err + } + + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + records := result.Trace + t.Logf("Boost probe done: %s (%d trace records)", combo, len(records)) + report.AddCombo(combo, probeResultFromBuildResult(result), evaluator.DebugSummaryOptions{ + RoleSampleLimit: 8, + InterestingLimit: 8, + InterestingTokens: []string{ + "/libs/timer/", + "/libs/program_options/", + "/libboost_timer", + "/libboost_program_options", + "/include/boost/timer", + "/include/boost/program_options", + }, + }) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + return probeResult, nil + }) + if err != nil { + t.Fatalf("Watch() failed: %v", err) + } + + baselineCombo := "program_options-off-timer-off" + if base, ok := resultsByCombo[baselineCombo]; ok { + for _, combo := range []string{"program_options-on-timer-off", "program_options-off-timer-on"} { + probe, ok := resultsByCombo[combo] + if !ok { + continue + } + report.AddDiff(base, probe, evaluator.DebugDiffSummaryOptions{ + BaseLabel: baselineCombo, + ProbeLabel: combo, + ActionSampleLimit: 8, + }) + } + left, leftOK := resultsByCombo["program_options-on-timer-off"] + right, rightOK := resultsByCombo["program_options-off-timer-on"] + if leftOK && rightOK { + report.AddCollision(base, left, right, evaluator.DebugCollisionSummaryOptions{ + BaseLabel: baselineCombo, + LeftLabel: "program_options-on-timer-off", + RightLabel: "program_options-off-timer-on", + PathSampleLimit: 8, + }) + } + } + + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("Boost option classification summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("Boost option trace records written to %s", traceLogPath) + + want := []string{ + "program_options-off-timer-off", + "program_options-off-timer-on", + "program_options-on-timer-off", + } + if !slices.Equal(combos, want) { + t.Fatalf("Watch() combos = %v, want %v", combos, want) + } +} + +func TestE2E_Watch_RealOptionClassification_PocoJsonEncodings(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Poco option classification test requires Linux") + } + if testing.Short() { + t.Skip("skipping real Poco option classification test in short mode") + } + for _, tool := range []string{"cmake", "c++", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real Poco option classification test", tool) + } + } + + store := setupTestStore(t) + matrix := formula.Matrix{ + Options: map[string][]string{ + "encodings": {"encodings-off", "encodings-on"}, + "json": {"json-off", "json-on"}, + "net": {"net-off", "net-on"}, + "xml": {"xml-off", "xml-on"}, + }, + DefaultOptions: map[string][]string{ + "encodings": {"encodings-off"}, + "json": {"json-off"}, + "net": {"net-off"}, + "xml": {"xml-off"}, + }, + } + + releaseRepo := newPocoReleaseRepo(t, "poco-1.14.2-release") + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + main := module.Version{Path: "pocoproject/poco", Version: "poco-1.14.2-release"} + loadMods := func(ctx context.Context) ([]*modules.Module, error) { + return modules.Load(ctx, main, modules.Options{FormulaStore: store}) + } + newProbeBuilder := func(combo string) *Builder { + return &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/pocoproject/poco" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + } + + combos, _, err := evaluator.WatchWithOptions(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("Poco probe start: %s", combo) + b := newProbeBuilder(combo) + mods, err := loadMods(ctx) + if err != nil { + return evaluator.ProbeResult{}, err + } + + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + t.Logf("Poco probe done: %s (%d trace records)", combo, len(result.Trace)) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + + report.AddCombo(combo, probeResult, evaluator.DebugSummaryOptions{ + RoleSampleLimit: 10, + InterestingLimit: 10, + InterestingTokens: []string{ + "/JSON/", + "/Encodings/", + "/XML/", + "/Net/", + "/libPocoJSON", + "/libPocoEncodings", + "/libPocoXML", + "/libPocoNet", + "/include/Poco/JSON", + "/include/Poco/TextEncoding", + "/include/Poco/DOM", + "/include/Poco/Net", + }, + }) + return probeResult, nil + }, evaluator.WatchOptions{ + ValidateSynthesizedPair: synthesizedPairOnTestValidatorForTest(t, loadMods, newProbeBuilder), + ObserveSynthesizedPair: func(observation evaluator.SynthesizedPairObservation) { + report.AddSection(evaluator.DebugSynthesizedPairSummary(observation)) + }, + }) + if err != nil { + t.Fatalf("WatchWithOptions() failed: %v", err) + } + + baselineCombo := "encodings-off-json-off-net-off-xml-off" + if base, ok := resultsByCombo[baselineCombo]; ok { + singletons := []string{ + "encodings-on-json-off-net-off-xml-off", + "encodings-off-json-on-net-off-xml-off", + "encodings-off-json-off-net-on-xml-off", + "encodings-off-json-off-net-off-xml-on", + } + for _, combo := range singletons { + probe, ok := resultsByCombo[combo] + if !ok { + continue + } + report.AddDiff(base, probe, evaluator.DebugDiffSummaryOptions{ + BaseLabel: baselineCombo, + ProbeLabel: combo, + ActionSampleLimit: 8, + }) + } + for i := 0; i < len(singletons); i++ { + for j := i + 1; j < len(singletons); j++ { + left, leftOK := resultsByCombo[singletons[i]] + right, rightOK := resultsByCombo[singletons[j]] + if leftOK && rightOK { + report.AddCollision(base, left, right, evaluator.DebugCollisionSummaryOptions{ + BaseLabel: baselineCombo, + LeftLabel: singletons[i], + RightLabel: singletons[j], + PathSampleLimit: 8, + }) + } + } + } + } + + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("Poco option classification summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("Poco option trace records written to %s", traceLogPath) + + want := []string{ + "encodings-off-json-off-net-off-xml-off", + "encodings-off-json-off-net-off-xml-on", + "encodings-off-json-off-net-on-xml-off", + "encodings-off-json-on-net-off-xml-off", + "encodings-on-json-off-net-off-xml-off", + } + if !slices.Equal(combos, want) { + t.Fatalf("Watch() combos = %v, want %v", combos, want) + } +} + +func TestE2E_Watch_RealOptionClassification_PCRE2WidthsAndGrep(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("PCRE2 option classification test requires Linux") + } + if testing.Short() { + t.Skip("skipping real PCRE2 option classification test in short mode") + } + for _, tool := range []string{"cmake", "cc", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real PCRE2 option classification test", tool) + } + } + + store := setupTestStore(t) + matrix := formula.Matrix{ + Options: map[string][]string{ + "grep": {"grep-off", "grep-on"}, + "width16": {"width16-off", "width16-on"}, + "width32": {"width32-off", "width32-on"}, + }, + DefaultOptions: map[string][]string{ + "grep": {"grep-off"}, + "width16": {"width16-off"}, + "width32": {"width32-off"}, + }, + } + + releaseRepo := newPcre2ReleaseRepo(t, "pcre2-10.45") + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + + combos, _, err := evaluator.Watch(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("PCRE2 probe start: %s", combo) + b := &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/PCRE2Project/pcre2" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "PCRE2Project/pcre2", Version: "pcre2-10.45"} + mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + if err != nil { + return evaluator.ProbeResult{}, err + } + + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + t.Logf("PCRE2 probe done: %s (%d trace records)", combo, len(result.Trace)) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + + report.AddCombo(combo, probeResult, evaluator.DebugSummaryOptions{ + RoleSampleLimit: 10, + InterestingLimit: 10, + InterestingTokens: []string{ + "/src/pcre2grep.c", + "/libpcre2-8", + "/libpcre2-16", + "/libpcre2-32", + "/bin/pcre2grep", + }, + }) + return probeResult, nil + }) + if err != nil { + t.Fatalf("Watch() failed: %v", err) + } + + baselineCombo := "grep-off-width16-off-width32-off" + if base, ok := resultsByCombo[baselineCombo]; ok { + singletons := []string{ + "grep-on-width16-off-width32-off", + "grep-off-width16-on-width32-off", + "grep-off-width16-off-width32-on", + } + for _, combo := range singletons { + probe, ok := resultsByCombo[combo] + if !ok { + continue + } + report.AddDiff(base, probe, evaluator.DebugDiffSummaryOptions{ + BaseLabel: baselineCombo, + ProbeLabel: combo, + ActionSampleLimit: 8, + }) + } + for i := 0; i < len(singletons); i++ { + for j := i + 1; j < len(singletons); j++ { + left, leftOK := resultsByCombo[singletons[i]] + right, rightOK := resultsByCombo[singletons[j]] + if leftOK && rightOK { + report.AddCollision(base, left, right, evaluator.DebugCollisionSummaryOptions{ + BaseLabel: baselineCombo, + LeftLabel: singletons[i], + RightLabel: singletons[j], + PathSampleLimit: 8, + }) + } + } + } + } + + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("PCRE2 option classification summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("PCRE2 option trace records written to %s", traceLogPath) + + want := []string{ + "grep-off-width16-off-width32-off", + "grep-off-width16-off-width32-on", + "grep-off-width16-on-width32-off", + "grep-off-width16-on-width32-on", + "grep-on-width16-off-width32-off", + } + if !slices.Equal(combos, want) { + t.Fatalf("Watch() combos = %v, want %v", combos, want) + } +} + +func TestE2E_Watch_RealOptionClassification_FmtOsUnicode(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("fmt option classification test requires Linux") + } + if testing.Short() { + t.Skip("skipping real fmt option classification test in short mode") + } + for _, tool := range []string{"cmake", "c++", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real fmt option classification test", tool) + } + } + + store := setupTestStore(t) + matrix := formula.Matrix{ + Options: map[string][]string{ + "osapi": {"osapi-off", "osapi-on"}, + "unicode": {"unicode-off", "unicode-on"}, + }, + DefaultOptions: map[string][]string{ + "osapi": {"osapi-off"}, + "unicode": {"unicode-off"}, + }, + } + + releaseRepo := newFmtReleaseRepo(t, "11.1.4") + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + + combos, _, err := evaluator.Watch(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("fmt probe start: %s", combo) + b := &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/fmtlib/fmt" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "fmtlib/fmt", Version: "11.1.4"} + mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + if err != nil { + return evaluator.ProbeResult{}, err + } + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + t.Logf("fmt probe done: %s (%d trace records)", combo, len(result.Trace)) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + report.AddCombo(combo, probeResult, evaluator.DebugSummaryOptions{ + RoleSampleLimit: 8, + InterestingLimit: 8, + InterestingTokens: []string{ + "/include/fmt/", + "/libfmt", + }, + }) + return probeResult, nil + }) + if err != nil { + t.Fatalf("Watch() failed: %v", err) + } + + baselineCombo := "osapi-off-unicode-off" + if base, ok := resultsByCombo[baselineCombo]; ok { + singletons := []string{ + "osapi-on-unicode-off", + "osapi-off-unicode-on", + } + for _, combo := range singletons { + probe, ok := resultsByCombo[combo] + if !ok { + continue + } + report.AddDiff(base, probe, evaluator.DebugDiffSummaryOptions{ + BaseLabel: baselineCombo, + ProbeLabel: combo, + ActionSampleLimit: 8, + }) + } + if left, leftOK := resultsByCombo[singletons[0]]; leftOK { + if right, rightOK := resultsByCombo[singletons[1]]; rightOK { + report.AddCollision(base, left, right, evaluator.DebugCollisionSummaryOptions{ + BaseLabel: baselineCombo, + LeftLabel: singletons[0], + RightLabel: singletons[1], + PathSampleLimit: 8, + }) + } + } + } + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("fmt option classification summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("fmt option trace records written to %s", traceLogPath) + + want := []string{ + "osapi-off-unicode-off", + "osapi-off-unicode-on", + "osapi-on-unicode-off", + } + if !slices.Equal(combos, want) { + t.Fatalf("Watch() combos = %v, want %v", combos, want) + } +} + +func TestE2E_Watch_RealOptionClassification_LibjpegTurboArithmeticAndTools(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("libjpeg-turbo option classification test requires Linux") + } + if testing.Short() { + t.Skip("skipping real libjpeg-turbo option classification test in short mode") + } + for _, tool := range []string{"cmake", "cc", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real libjpeg-turbo option classification test", tool) + } + } + + store := setupTestStore(t) + matrix := formula.Matrix{ + Options: map[string][]string{ + "arithdec": {"arithdec-off", "arithdec-on"}, + "arithenc": {"arithenc-off", "arithenc-on"}, + "tools": {"tools-off", "tools-on"}, + }, + DefaultOptions: map[string][]string{ + "arithdec": {"arithdec-off"}, + "arithenc": {"arithenc-off"}, + "tools": {"tools-off"}, + }, + } + + releaseRepo := newLibjpegTurboReleaseRepo(t, "3.1.3") + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + + combos, _, err := evaluator.Watch(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("libjpeg-turbo probe start: %s", combo) + b := &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/libjpeg-turbo/libjpeg-turbo" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "libjpeg-turbo/libjpeg-turbo", Version: "3.1.3"} + mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + if err != nil { + return evaluator.ProbeResult{}, err + } + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + t.Logf("libjpeg-turbo probe done: %s (%d trace records)", combo, len(result.Trace)) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + report.AddCombo(combo, probeResult, evaluator.DebugSummaryOptions{ + RoleSampleLimit: 8, + InterestingLimit: 8, + InterestingTokens: []string{ + "/libjpeg", + "/include/jpeglib.h", + "/bin/cjpeg", + }, + }) + return probeResult, nil + }) + if err != nil { + t.Fatalf("Watch() failed: %v", err) + } + + baselineCombo := "arithdec-off-arithenc-off-tools-off" + if base, ok := resultsByCombo[baselineCombo]; ok { + singletons := []string{ + "arithdec-on-arithenc-off-tools-off", + "arithdec-off-arithenc-on-tools-off", + "arithdec-off-arithenc-off-tools-on", + } + for _, combo := range singletons { + probe, ok := resultsByCombo[combo] + if !ok { + continue + } + report.AddDiff(base, probe, evaluator.DebugDiffSummaryOptions{ + BaseLabel: baselineCombo, + ProbeLabel: combo, + ActionSampleLimit: 8, + }) + } + for i := 0; i < len(singletons); i++ { + for j := i + 1; j < len(singletons); j++ { + left, leftOK := resultsByCombo[singletons[i]] + right, rightOK := resultsByCombo[singletons[j]] + if leftOK && rightOK { + report.AddCollision(base, left, right, evaluator.DebugCollisionSummaryOptions{ + BaseLabel: baselineCombo, + LeftLabel: singletons[i], + RightLabel: singletons[j], + PathSampleLimit: 8, + }) + } + } + } + } + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("libjpeg-turbo option classification summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("libjpeg-turbo option trace records written to %s", traceLogPath) + + want := []string{ + "arithdec-off-arithenc-off-tools-off", + "arithdec-off-arithenc-off-tools-on", + "arithdec-off-arithenc-on-tools-off", + "arithdec-on-arithenc-off-tools-off", + "arithdec-on-arithenc-on-tools-off", + } + if !slices.Equal(combos, want) { + t.Fatalf("Watch() combos = %v, want %v", combos, want) + } +} + +func TestE2E_Watch_RealOptionClassification_SqliteFeatureMacros(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("sqlite option classification test requires Linux") + } + if testing.Short() { + t.Skip("skipping real sqlite option classification test in short mode") + } + for _, tool := range []string{"cc", "ar", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real sqlite option classification test", tool) + } + } + + store := setupTestStore(t) + matrix := formula.Matrix{ + Options: map[string][]string{ + "dbstat": {"dbstat-off", "dbstat-on"}, + "json1": {"json1-off", "json1-on"}, + "rtree": {"rtree-off", "rtree-on"}, + "soundex": {"soundex-off", "soundex-on"}, + }, + DefaultOptions: map[string][]string{ + "dbstat": {"dbstat-off"}, + "json1": {"json1-off"}, + "rtree": {"rtree-off"}, + "soundex": {"soundex-off"}, + }, + } + + releaseRepo := newSqliteReleaseRepo(t, "3.45.3") + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + + combos, _, err := evaluator.Watch(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("sqlite probe start: %s", combo) + b := &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/sqlite/sqlite" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "sqlite/sqlite", Version: "3.45.3"} + mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + if err != nil { + return evaluator.ProbeResult{}, err + } + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + t.Logf("sqlite probe done: %s (%d trace records)", combo, len(result.Trace)) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + report.AddCombo(combo, probeResult, evaluator.DebugSummaryOptions{ + RoleSampleLimit: 8, + InterestingLimit: 8, + InterestingTokens: []string{ + "/sqlite3.c", + "/sqlite3.o", + "/libsqlite3.a", + "/include/sqlite3.h", + }, + }) + return probeResult, nil + }) + if err != nil { + t.Fatalf("Watch() failed: %v", err) + } + + baselineCombo := "dbstat-off-json1-off-rtree-off-soundex-off" + if base, ok := resultsByCombo[baselineCombo]; ok { + singletons := []string{ + "dbstat-on-json1-off-rtree-off-soundex-off", + "dbstat-off-json1-on-rtree-off-soundex-off", + "dbstat-off-json1-off-rtree-on-soundex-off", + "dbstat-off-json1-off-rtree-off-soundex-on", + } + for _, combo := range singletons { + probe, ok := resultsByCombo[combo] + if !ok { + continue + } + report.AddDiff(base, probe, evaluator.DebugDiffSummaryOptions{ + BaseLabel: baselineCombo, + ProbeLabel: combo, + ActionSampleLimit: 8, + }) + } + for i := 0; i < len(singletons); i++ { + for j := i + 1; j < len(singletons); j++ { + left, leftOK := resultsByCombo[singletons[i]] + right, rightOK := resultsByCombo[singletons[j]] + if leftOK && rightOK { + report.AddCollision(base, left, right, evaluator.DebugCollisionSummaryOptions{ + BaseLabel: baselineCombo, + LeftLabel: singletons[i], + RightLabel: singletons[j], + PathSampleLimit: 8, + }) + } + } + } + } + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("sqlite option classification summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("sqlite option trace records written to %s", traceLogPath) + + want := []string{ + "dbstat-off-json1-off-rtree-off-soundex-off", + "dbstat-off-json1-off-rtree-off-soundex-on", + "dbstat-off-json1-off-rtree-on-soundex-off", + "dbstat-off-json1-off-rtree-on-soundex-on", + "dbstat-off-json1-on-rtree-off-soundex-off", + "dbstat-on-json1-off-rtree-off-soundex-off", + "dbstat-on-json1-off-rtree-off-soundex-on", + "dbstat-on-json1-off-rtree-on-soundex-off", + "dbstat-on-json1-off-rtree-on-soundex-on", + } + if !slices.Equal(combos, want) { + t.Fatalf("Watch() combos = %v, want %v", combos, want) + } +} + +func TestE2E_Watch_RealOptionClassification_PugixmlCoreMacros(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("pugixml option classification test requires Linux") + } + if testing.Short() { + t.Skip("skipping real pugixml option classification test in short mode") + } + for _, tool := range []string{"cmake", "c++", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real pugixml option classification test", tool) + } + } + + store := setupTestStore(t) + matrix := formula.Matrix{ + Options: map[string][]string{ + "compact": {"compact-off", "compact-on"}, + "noexceptions": {"noexceptions-off", "noexceptions-on"}, + "noxpath": {"noxpath-off", "noxpath-on"}, + "wchar": {"wchar-off", "wchar-on"}, + }, + DefaultOptions: map[string][]string{ + "compact": {"compact-off"}, + "noexceptions": {"noexceptions-off"}, + "noxpath": {"noxpath-off"}, + "wchar": {"wchar-off"}, + }, + } + + releaseRepo := newPugixmlReleaseRepo(t, "1.15") + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + + combos, _, err := evaluator.Watch(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("pugixml probe start: %s", combo) + b := &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/zeux/pugixml" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "zeux/pugixml", Version: "1.15"} + mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + if err != nil { + return evaluator.ProbeResult{}, err + } + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + t.Logf("pugixml probe done: %s (%d trace records)", combo, len(result.Trace)) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + report.AddCombo(combo, probeResult, evaluator.DebugSummaryOptions{ + RoleSampleLimit: 8, + InterestingLimit: 8, + InterestingTokens: []string{ + "/src/pugixml.cpp", + "/libpugixml", + "/include/pugixml.hpp", + "/include/pugiconfig.hpp", + }, + }) + return probeResult, nil + }) + if err != nil { + t.Fatalf("Watch() failed: %v", err) + } + + baselineCombo := "compact-off-noexceptions-off-noxpath-off-wchar-off" + if base, ok := resultsByCombo[baselineCombo]; ok { + singletons := []string{ + "compact-on-noexceptions-off-noxpath-off-wchar-off", + "compact-off-noexceptions-on-noxpath-off-wchar-off", + "compact-off-noexceptions-off-noxpath-on-wchar-off", + "compact-off-noexceptions-off-noxpath-off-wchar-on", + } + for _, combo := range singletons { + probe, ok := resultsByCombo[combo] + if !ok { + continue + } + report.AddDiff(base, probe, evaluator.DebugDiffSummaryOptions{ + BaseLabel: baselineCombo, + ProbeLabel: combo, + ActionSampleLimit: 8, + }) + } + for i := 0; i < len(singletons); i++ { + for j := i + 1; j < len(singletons); j++ { + left, leftOK := resultsByCombo[singletons[i]] + right, rightOK := resultsByCombo[singletons[j]] + if leftOK && rightOK { + report.AddCollision(base, left, right, evaluator.DebugCollisionSummaryOptions{ + BaseLabel: baselineCombo, + LeftLabel: singletons[i], + RightLabel: singletons[j], + PathSampleLimit: 8, + }) + } + } + } + } + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("pugixml option classification summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("pugixml option trace records written to %s", traceLogPath) + + want := matrix.Combinations() + if !slices.Equal(combos, want) { + t.Fatalf("Watch() combos = %v, want %v", combos, want) + } +} + +func TestE2E_Watch_RealOptionClassification_ExpatCoreMacros(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("expat option classification test requires Linux") + } + if testing.Short() { + t.Skip("skipping real expat option classification test in short mode") + } + for _, tool := range []string{"cmake", "cc", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real expat option classification test", tool) + } + } + + store := setupTestStore(t) + matrix := formula.Matrix{ + Options: map[string][]string{ + "ge": {"ge-off", "ge-on"}, + "large_size": {"large_size-off", "large_size-on"}, + "min_size": {"min_size-off", "min_size-on"}, + "ns": {"ns-off", "ns-on"}, + }, + DefaultOptions: map[string][]string{ + "ge": {"ge-off"}, + "large_size": {"large_size-off"}, + "min_size": {"min_size-off"}, + "ns": {"ns-off"}, + }, + } + + releaseRepo := newExpatReleaseRepo(t, "2.6.4") + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + main := module.Version{Path: "libexpat/libexpat", Version: "2.6.4"} + loadMods := func(ctx context.Context) ([]*modules.Module, error) { + return modules.Load(ctx, main, modules.Options{FormulaStore: store}) + } + newProbeBuilder := func(combo string) *Builder { + return &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/libexpat/libexpat" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + } + + combos, _, err := evaluator.WatchWithOptions(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("expat probe start: %s", combo) + b := newProbeBuilder(combo) + mods, err := loadMods(ctx) + if err != nil { + return evaluator.ProbeResult{}, err + } + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + t.Logf("expat probe done: %s (%d trace records)", combo, len(result.Trace)) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + report.AddCombo(combo, probeResult, evaluator.DebugSummaryOptions{ + RoleSampleLimit: 8, + InterestingLimit: 8, + InterestingTokens: []string{ + "/_build/expat_config.h", + "/_build/libexpat.a", + "/_build/CMakeFiles/expat.dir/lib/xmlparse.c.o", + "/_build/CMakeFiles/expat.dir/lib/xmlrole.c.o", + "/_build/CMakeFiles/expat.dir/lib/xmltok.c.o", + "/lib/xmlparse.c", + "/lib/xmltok.c", + "/libexpat", + "/include/expat.h", + "/include/expat_config.h", + }, + }) + report.AddTraceMatches(probeResult, []string{ + "expat_config.h", + "xmlparse.c", + "xmlrole.c", + "xmltok.c", + }, 12) + return probeResult, nil + }, evaluator.WatchOptions{ + ValidateSynthesizedPair: synthesizedPairOnTestValidatorForTest(t, loadMods, newProbeBuilder), + ObserveSynthesizedPair: func(observation evaluator.SynthesizedPairObservation) { + report.AddSection(evaluator.DebugSynthesizedPairSummary(observation)) + }, + }) + if err != nil { + t.Fatalf("WatchWithOptions() failed: %v", err) + } + + baselineCombo := "ge-off-large_size-off-min_size-off-ns-off" + if base, ok := resultsByCombo[baselineCombo]; ok { + singletons := []string{ + "ge-on-large_size-off-min_size-off-ns-off", + "ge-off-large_size-on-min_size-off-ns-off", + "ge-off-large_size-off-min_size-on-ns-off", + "ge-off-large_size-off-min_size-off-ns-on", + } + for _, combo := range singletons { + probe, ok := resultsByCombo[combo] + if !ok { + continue + } + report.AddDiff(base, probe, evaluator.DebugDiffSummaryOptions{ + BaseLabel: baselineCombo, + ProbeLabel: combo, + ActionSampleLimit: 8, + }) + } + for i := 0; i < len(singletons); i++ { + for j := i + 1; j < len(singletons); j++ { + left, leftOK := resultsByCombo[singletons[i]] + right, rightOK := resultsByCombo[singletons[j]] + if leftOK && rightOK { + report.AddCollision(base, left, right, evaluator.DebugCollisionSummaryOptions{ + BaseLabel: baselineCombo, + LeftLabel: singletons[i], + RightLabel: singletons[j], + PathSampleLimit: 8, + }) + } + } + } + } + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("expat option classification summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("expat option trace records written to %s", traceLogPath) + + want := []string{ + "ge-off-large_size-off-min_size-off-ns-off", + "ge-off-large_size-off-min_size-off-ns-on", + "ge-off-large_size-off-min_size-on-ns-off", + "ge-off-large_size-on-min_size-off-ns-off", + "ge-on-large_size-off-min_size-off-ns-off", + } + if !slices.Equal(combos, want) { + t.Fatalf("Watch() combos = %v, want %v", combos, want) + } +} + +func TestE2E_Watch_RealOptionClassification_CjsonLocalesUtils(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("cJSON option classification test requires Linux") + } + if testing.Short() { + t.Skip("skipping real cJSON option classification test in short mode") + } + for _, tool := range []string{"cmake", "cc", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real cJSON option classification test", tool) + } + } + + store := setupTestStore(t) + matrix := formula.Matrix{ + Options: map[string][]string{ + "locales": {"locales-off", "locales-on"}, + "utils": {"utils-off", "utils-on"}, + }, + DefaultOptions: map[string][]string{ + "locales": {"locales-off"}, + "utils": {"utils-off"}, + }, + } + + releaseRepo := newCjsonReleaseRepo(t, "1.7.19") + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + + combos, _, err := evaluator.Watch(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("cJSON probe start: %s", combo) + b := &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/DaveGamble/cJSON" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "DaveGamble/cJSON", Version: "1.7.19"} + mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + if err != nil { + return evaluator.ProbeResult{}, err + } + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + t.Logf("cJSON probe done: %s (%d trace records)", combo, len(result.Trace)) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + report.AddCombo(combo, probeResult, evaluator.DebugSummaryOptions{ + RoleSampleLimit: 8, + InterestingLimit: 8, + InterestingTokens: []string{ + "/cJSON.c", + "/cJSON_Utils.c", + "/libcjson", + "/libcjson_utils", + "/include/cjson", + }, + }) + return probeResult, nil + }) + if err != nil { + t.Fatalf("Watch() failed: %v", err) + } + + baselineCombo := "locales-off-utils-off" + if base, ok := resultsByCombo[baselineCombo]; ok { + singletons := []string{ + "locales-on-utils-off", + "locales-off-utils-on", + } + for _, combo := range singletons { + probe, ok := resultsByCombo[combo] + if !ok { + continue + } + report.AddDiff(base, probe, evaluator.DebugDiffSummaryOptions{ + BaseLabel: baselineCombo, + ProbeLabel: combo, + ActionSampleLimit: 8, + }) + } + if left, leftOK := resultsByCombo[singletons[0]]; leftOK { + if right, rightOK := resultsByCombo[singletons[1]]; rightOK { + report.AddCollision(base, left, right, evaluator.DebugCollisionSummaryOptions{ + BaseLabel: baselineCombo, + LeftLabel: singletons[0], + RightLabel: singletons[1], + PathSampleLimit: 8, + }) + } + } + } + + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("cJSON option classification summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("cJSON option trace records written to %s", traceLogPath) + + want := []string{ + "locales-off-utils-off", + "locales-off-utils-on", + "locales-on-utils-off", + } + if !slices.Equal(combos, want) { + t.Fatalf("Watch() combos = %v, want %v", combos, want) + } +} + +func TestE2E_Watch_RealOptionClassification_CAresThreadsTools(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("c-ares option classification test requires Linux") + } + if testing.Short() { + t.Skip("skipping real c-ares option classification test in short mode") + } + for _, tool := range []string{"cmake", "cc", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real c-ares option classification test", tool) + } + } + + store := setupTestStore(t) + matrix := formula.Matrix{ + Options: map[string][]string{ + "threads": {"threads-off", "threads-on"}, + "tools": {"tools-off", "tools-on"}, + }, + DefaultOptions: map[string][]string{ + "threads": {"threads-off"}, + "tools": {"tools-off"}, + }, + } + + releaseRepo := newCAresReleaseRepo(t, "1.34.5") + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + + combos, _, err := evaluator.Watch(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("c-ares probe start: %s", combo) + b := &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/c-ares/c-ares" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "c-ares/c-ares", Version: "1.34.5"} + mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + if err != nil { + return evaluator.ProbeResult{}, err + } + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + t.Logf("c-ares probe done: %s (%d trace records)", combo, len(result.Trace)) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + report.AddCombo(combo, probeResult, evaluator.DebugSummaryOptions{ + RoleSampleLimit: 8, + InterestingLimit: 8, + InterestingTokens: []string{ + "/src/lib", + "/libcares", + "/bin/adig", + "/bin/ahost", + }, + }) + return probeResult, nil + }) + if err != nil { + t.Fatalf("Watch() failed: %v", err) + } + + baselineCombo := "threads-off-tools-off" + if base, ok := resultsByCombo[baselineCombo]; ok { + singletons := []string{ + "threads-on-tools-off", + "threads-off-tools-on", + } + for _, combo := range singletons { + probe, ok := resultsByCombo[combo] + if !ok { + continue + } + report.AddDiff(base, probe, evaluator.DebugDiffSummaryOptions{ + BaseLabel: baselineCombo, + ProbeLabel: combo, + ActionSampleLimit: 8, + }) + } + if left, leftOK := resultsByCombo[singletons[0]]; leftOK { + if right, rightOK := resultsByCombo[singletons[1]]; rightOK { + report.AddCollision(base, left, right, evaluator.DebugCollisionSummaryOptions{ + BaseLabel: baselineCombo, + LeftLabel: singletons[0], + RightLabel: singletons[1], + PathSampleLimit: 8, + }) + } + } + } + + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("c-ares option classification summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("c-ares option trace records written to %s", traceLogPath) + + want := []string{ + "threads-off-tools-off", + "threads-off-tools-on", + "threads-on-tools-off", + "threads-on-tools-on", + } + if !slices.Equal(combos, want) { + t.Fatalf("Watch() combos = %v, want %v", combos, want) + } +} + +func TestE2E_Watch_RealOptionClassification_LibwebpCwebpMux(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("libwebp option classification test requires Linux") + } + if testing.Short() { + t.Skip("skipping real libwebp option classification test in short mode") + } + for _, tool := range []string{"cmake", "cc", "c++", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real libwebp option classification test", tool) + } + } + + store := setupTestStore(t) + matrix := formula.Matrix{ + Options: map[string][]string{ + "cwebp": {"cwebp-off", "cwebp-on"}, + "mux": {"mux-off", "mux-on"}, + }, + DefaultOptions: map[string][]string{ + "cwebp": {"cwebp-off"}, + "mux": {"mux-off"}, + }, + } + + releaseRepo := newLibwebpReleaseRepo(t, "1.5.0") + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + + combos, _, err := evaluator.Watch(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("libwebp probe start: %s", combo) + b := &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/webmproject/libwebp" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "webmproject/libwebp", Version: "1.5.0"} + mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + if err != nil { + return evaluator.ProbeResult{}, err + } + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + t.Logf("libwebp probe done: %s (%d trace records)", combo, len(result.Trace)) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + report.AddCombo(combo, probeResult, evaluator.DebugSummaryOptions{ + RoleSampleLimit: 8, + InterestingLimit: 8, + InterestingTokens: []string{ + "/src/mux/", + "/examples/cwebp", + "/libwebp", + "/libwebpmux", + "/bin/cwebp", + }, + }) + return probeResult, nil + }) + if err != nil { + t.Fatalf("Watch() failed: %v", err) + } + + baselineCombo := "cwebp-off-mux-off" + if base, ok := resultsByCombo[baselineCombo]; ok { + singletons := []string{ + "cwebp-on-mux-off", + "cwebp-off-mux-on", + } + for _, combo := range singletons { + probe, ok := resultsByCombo[combo] + if !ok { + continue + } + report.AddDiff(base, probe, evaluator.DebugDiffSummaryOptions{ + BaseLabel: baselineCombo, + ProbeLabel: combo, + ActionSampleLimit: 8, + }) + } + if left, leftOK := resultsByCombo[singletons[0]]; leftOK { + if right, rightOK := resultsByCombo[singletons[1]]; rightOK { + report.AddCollision(base, left, right, evaluator.DebugCollisionSummaryOptions{ + BaseLabel: baselineCombo, + LeftLabel: singletons[0], + RightLabel: singletons[1], + PathSampleLimit: 8, + }) + } + } + } + + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("libwebp option classification summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("libwebp option trace records written to %s", traceLogPath) + + want := []string{ + "cwebp-off-mux-off", + "cwebp-off-mux-on", + "cwebp-on-mux-off", + } + if !slices.Equal(combos, want) { + t.Fatalf("Watch() combos = %v, want %v", combos, want) + } +} + +func TestE2E_Watch_RealOptionClassification_LibtiffCxxTools(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("libtiff option classification test requires Linux") + } + if testing.Short() { + t.Skip("skipping real libtiff option classification test in short mode") + } + for _, tool := range []string{"cmake", "cc", "c++", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real libtiff option classification test", tool) + } + } + + store := setupTestStore(t) + matrix := formula.Matrix{ + Options: map[string][]string{ + "cxx": {"cxx-off", "cxx-on"}, + "tools": {"tools-off", "tools-on"}, + }, + DefaultOptions: map[string][]string{ + "cxx": {"cxx-off"}, + "tools": {"tools-off"}, + }, + } + + releaseRepo := newLibtiffReleaseRepo(t, "4.7.1") + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + + combos, _, err := evaluator.Watch(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("libtiff probe start: %s", combo) + b := &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/libsdl-org/libtiff" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "libsdl-org/libtiff", Version: "4.7.1"} + mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + if err != nil { + return evaluator.ProbeResult{}, err + } + + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + t.Logf("libtiff probe done: %s (%d trace records)", combo, len(result.Trace)) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + report.AddCombo(combo, probeResult, evaluator.DebugSummaryOptions{ + RoleSampleLimit: 8, + InterestingLimit: 8, + InterestingTokens: []string{ + "/libtiffxx", + "/tools/", + "/bin/tiff", + "/libtiff", + }, + }) + return probeResult, nil + }) + if err != nil { + t.Fatalf("Watch() failed: %v", err) + } + + baselineCombo := "cxx-off-tools-off" + if base, ok := resultsByCombo[baselineCombo]; ok { + singletons := []string{ + "cxx-on-tools-off", + "cxx-off-tools-on", + } + for _, combo := range singletons { + probe, ok := resultsByCombo[combo] + if !ok { + continue + } + report.AddDiff(base, probe, evaluator.DebugDiffSummaryOptions{ + BaseLabel: baselineCombo, + ProbeLabel: combo, + ActionSampleLimit: 8, + }) + } + if left, leftOK := resultsByCombo[singletons[0]]; leftOK { + if right, rightOK := resultsByCombo[singletons[1]]; rightOK { + report.AddCollision(base, left, right, evaluator.DebugCollisionSummaryOptions{ + BaseLabel: baselineCombo, + LeftLabel: singletons[0], + RightLabel: singletons[1], + PathSampleLimit: 8, + }) + } + } + } + + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("libtiff option classification summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("libtiff option trace records written to %s", traceLogPath) + + want := []string{ + "cxx-off-tools-off", + "cxx-off-tools-on", + "cxx-on-tools-off", + } + if !slices.Equal(combos, want) { + t.Fatalf("Watch() combos = %v, want %v", combos, want) + } +} + +func TestE2E_Watch_RealOptionClassification_ZstdProgramsThreading(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("zstd option classification test requires Linux") + } + if testing.Short() { + t.Skip("skipping real zstd option classification test in short mode") + } + for _, tool := range []string{"cmake", "cc", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real zstd option classification test", tool) + } + } + + store := setupTestStore(t) + matrix := formula.Matrix{ + Options: map[string][]string{ + "programs": {"programs-off", "programs-on"}, + "threading": {"threading-off", "threading-on"}, + }, + DefaultOptions: map[string][]string{ + "programs": {"programs-off"}, + "threading": {"threading-off"}, + }, + } + + releaseRepo := newZstdReleaseRepo(t, "1.5.7") + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + + combos, _, err := evaluator.Watch(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("zstd probe start: %s", combo) + b := &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/facebook/zstd" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "facebook/zstd", Version: "1.5.7"} + mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + if err != nil { + return evaluator.ProbeResult{}, err + } + + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + t.Logf("zstd probe done: %s (%d trace records)", combo, len(result.Trace)) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + report.AddCombo(combo, probeResult, evaluator.DebugSummaryOptions{ + RoleSampleLimit: 8, + InterestingLimit: 8, + InterestingTokens: []string{ + "/programs/", + "/libzstd", + "/bin/zstd", + }, + }) + return probeResult, nil + }) + if err != nil { + t.Fatalf("Watch() failed: %v", err) + } + + baselineCombo := "programs-off-threading-off" + if base, ok := resultsByCombo[baselineCombo]; ok { + singletons := []string{ + "programs-on-threading-off", + "programs-off-threading-on", + } + for _, combo := range singletons { + probe, ok := resultsByCombo[combo] + if !ok { + continue + } + report.AddDiff(base, probe, evaluator.DebugDiffSummaryOptions{ + BaseLabel: baselineCombo, + ProbeLabel: combo, + ActionSampleLimit: 8, + }) + } + if left, leftOK := resultsByCombo[singletons[0]]; leftOK { + if right, rightOK := resultsByCombo[singletons[1]]; rightOK { + report.AddCollision(base, left, right, evaluator.DebugCollisionSummaryOptions{ + BaseLabel: baselineCombo, + LeftLabel: singletons[0], + RightLabel: singletons[1], + PathSampleLimit: 8, + }) + } + } + } + + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("zstd option classification summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("zstd option trace records written to %s", traceLogPath) + + want := matrix.Combinations() + if !slices.Equal(combos, want) { + t.Fatalf("Watch() combos = %v, want %v", combos, want) + } +} + +func TestE2E_Watch_RealOptionClassification_UriparserWcharTools(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("uriparser option classification test requires Linux") + } + if testing.Short() { + t.Skip("skipping real uriparser option classification test in short mode") + } + for _, tool := range []string{"cmake", "cc", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real uriparser option classification test", tool) + } + } + + store := setupTestStore(t) + matrix := formula.Matrix{ + Options: map[string][]string{ + "tools": {"tools-off", "tools-on"}, + "wchar": {"wchar-off", "wchar-on"}, + }, + DefaultOptions: map[string][]string{ + "tools": {"tools-off"}, + "wchar": {"wchar-off"}, + }, + } + + releaseRepo := newUriparserReleaseRepo(t, "0.9.8") + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + + combos, _, err := evaluator.Watch(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("uriparser probe start: %s", combo) + b := &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/uriparser/uriparser" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "uriparser/uriparser", Version: "0.9.8"} + mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + if err != nil { + return evaluator.ProbeResult{}, err + } + + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + t.Logf("uriparser probe done: %s (%d trace records)", combo, len(result.Trace)) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + report.AddCombo(combo, probeResult, evaluator.DebugSummaryOptions{ + RoleSampleLimit: 8, + InterestingLimit: 8, + InterestingTokens: []string{ + "/uriparser", + "/bin/uriparse", + }, + }) + return probeResult, nil + }) + if err != nil { + t.Fatalf("Watch() failed: %v", err) + } + + baselineCombo := "tools-off-wchar-off" + if base, ok := resultsByCombo[baselineCombo]; ok { + singletons := []string{ + "tools-on-wchar-off", + "tools-off-wchar-on", + } + for _, combo := range singletons { + probe, ok := resultsByCombo[combo] + if !ok { + continue + } + report.AddDiff(base, probe, evaluator.DebugDiffSummaryOptions{ + BaseLabel: baselineCombo, + ProbeLabel: combo, + ActionSampleLimit: 8, + }) + } + if left, leftOK := resultsByCombo[singletons[0]]; leftOK { + if right, rightOK := resultsByCombo[singletons[1]]; rightOK { + report.AddCollision(base, left, right, evaluator.DebugCollisionSummaryOptions{ + BaseLabel: baselineCombo, + LeftLabel: singletons[0], + RightLabel: singletons[1], + PathSampleLimit: 8, + }) + } + } + } + + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("uriparser option classification summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("uriparser option trace records written to %s", traceLogPath) + + want := matrix.Combinations() + if !slices.Equal(combos, want) { + t.Fatalf("Watch() combos = %v, want %v", combos, want) + } +} + +func TestE2E_Watch_RealOptionClassification_YamlCppContribTools(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("yaml-cpp option classification test requires Linux") + } + if testing.Short() { + t.Skip("skipping real yaml-cpp option classification test in short mode") + } + for _, tool := range []string{"cmake", "c++", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real yaml-cpp option classification test", tool) + } + } + + store := setupTestStore(t) + matrix := formula.Matrix{ + Options: map[string][]string{ + "contrib": {"contrib-off", "contrib-on"}, + "tools": {"tools-off", "tools-on"}, + }, + DefaultOptions: map[string][]string{ + "contrib": {"contrib-off"}, + "tools": {"tools-off"}, + }, + } + + releaseRepo := newYamlCppReleaseRepo(t, "0.9.0") + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + + combos, _, err := evaluator.Watch(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("yaml-cpp probe start: %s", combo) + b := &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/jbeder/yaml-cpp" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "jbeder/yaml-cpp", Version: "0.9.0"} + mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + if err != nil { + return evaluator.ProbeResult{}, err + } + + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + t.Logf("yaml-cpp probe done: %s (%d trace records)", combo, len(result.Trace)) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + report.AddCombo(combo, probeResult, evaluator.DebugSummaryOptions{ + RoleSampleLimit: 8, + InterestingLimit: 8, + InterestingTokens: []string{ + "/yaml-cpp", + "/bin/parse", + "/bin/read", + "/bin/sandbox", + }, + }) + return probeResult, nil + }) + if err != nil { + t.Fatalf("Watch() failed: %v", err) + } + + baselineCombo := "contrib-off-tools-off" + if base, ok := resultsByCombo[baselineCombo]; ok { + singletons := []string{ + "contrib-on-tools-off", + "contrib-off-tools-on", + } + for _, combo := range singletons { + probe, ok := resultsByCombo[combo] + if !ok { + continue + } + report.AddDiff(base, probe, evaluator.DebugDiffSummaryOptions{ + BaseLabel: baselineCombo, + ProbeLabel: combo, + ActionSampleLimit: 8, + }) + } + if left, leftOK := resultsByCombo[singletons[0]]; leftOK { + if right, rightOK := resultsByCombo[singletons[1]]; rightOK { + report.AddCollision(base, left, right, evaluator.DebugCollisionSummaryOptions{ + BaseLabel: baselineCombo, + LeftLabel: singletons[0], + RightLabel: singletons[1], + PathSampleLimit: 8, + }) + } + } + } + + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("yaml-cpp option classification summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("yaml-cpp option trace records written to %s", traceLogPath) + + want := matrix.Combinations() + if !slices.Equal(combos, want) { + t.Fatalf("Watch() combos = %v, want %v", combos, want) + } +} + +func TestE2E_Watch_RealOptionClassification_SpdlogNoexceptionsWchar(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("spdlog option classification test requires Linux") + } + if testing.Short() { + t.Skip("skipping real spdlog option classification test in short mode") + } + for _, tool := range []string{"cmake", "c++", "strace"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not found, skipping real spdlog option classification test", tool) + } + } + + store := setupTestStore(t) + matrix := formula.Matrix{ + Options: map[string][]string{ + "noexceptions": {"noexceptions-off", "noexceptions-on"}, + "wchar": {"wchar-off", "wchar-on"}, + }, + DefaultOptions: map[string][]string{ + "noexceptions": {"noexceptions-off"}, + "wchar": {"wchar-off"}, + }, + } + + releaseRepo := newSpdlogReleaseRepo(t, "1.17.0") + workspaceDir := t.TempDir() + var report evaluator.DebugReport + resultsByCombo := make(map[string]evaluator.ProbeResult) + + combos, _, err := evaluator.Watch(context.Background(), matrix, func(ctx context.Context, combo string) (evaluator.ProbeResult, error) { + t.Logf("spdlog probe start: %s", combo) + b := &Builder{ + store: store, + matrix: combo, + trace: true, + workspaceDir: workspaceDir, + newRepo: func(repoPath string) (vcs.Repo, error) { + if repoPath != "github.com/gabime/spdlog" { + return nil, fmt.Errorf("unexpected repo path %q", repoPath) + } + return releaseRepo, nil + }, + } + + main := module.Version{Path: "gabime/spdlog", Version: "1.17.0"} + mods, err := modules.Load(ctx, main, modules.Options{FormulaStore: store}) + if err != nil { + return evaluator.ProbeResult{}, err + } + + savedStdout, savedStderr := os.Stdout, os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return evaluator.ProbeResult{}, err + } + defer func() { + devNull.Close() + os.Stdout = savedStdout + os.Stderr = savedStderr + }() + os.Stdout = devNull + os.Stderr = devNull + + results, err := b.Build(ctx, mods) + if err != nil { + return evaluator.ProbeResult{}, err + } + result := results[len(results)-1] + t.Logf("spdlog probe done: %s (%d trace records)", combo, len(result.Trace)) + probeResult := probeResultFromBuildResult(result) + resultsByCombo[combo] = probeResult + report.AddCombo(combo, probeResult, evaluator.DebugSummaryOptions{ + RoleSampleLimit: 8, + InterestingLimit: 8, + InterestingTokens: []string{ + "/spdlog", + "/libspdlog", + }, + }) + return probeResult, nil + }) + if err != nil { + t.Fatalf("Watch() failed: %v", err) + } + + baselineCombo := "noexceptions-off-wchar-off" + if base, ok := resultsByCombo[baselineCombo]; ok { + singletons := []string{ + "noexceptions-on-wchar-off", + "noexceptions-off-wchar-on", + } + for _, combo := range singletons { + probe, ok := resultsByCombo[combo] + if !ok { + continue + } + report.AddDiff(base, probe, evaluator.DebugDiffSummaryOptions{ + BaseLabel: baselineCombo, + ProbeLabel: combo, + ActionSampleLimit: 8, + }) + } + if left, leftOK := resultsByCombo[singletons[0]]; leftOK { + if right, rightOK := resultsByCombo[singletons[1]]; rightOK { + report.AddCollision(base, left, right, evaluator.DebugCollisionSummaryOptions{ + BaseLabel: baselineCombo, + LeftLabel: singletons[0], + RightLabel: singletons[1], + PathSampleLimit: 8, + }) + } + } + } + + logPath := writeGraphLogForTest(t, report.String()) + t.Logf("spdlog option classification summary written to %s", logPath) + traceLogPath := writeTraceLogForTest(t, formatTraceCombosForTest(resultsByCombo)) + t.Logf("spdlog option trace records written to %s", traceLogPath) + + want := []string{ + "noexceptions-off-wchar-off", + "noexceptions-off-wchar-on", + "noexceptions-on-wchar-off", + } + if !slices.Equal(combos, want) { + t.Fatalf("Watch() combos = %v, want %v", combos, want) + } +} + +type archiveReleaseRepo struct { + ref string + archiveURL string + workDir string + + once sync.Once + sourceDir string + initErr error +} + +func newArchiveReleaseRepo(t *testing.T, ref, archiveURL string) vcs.Repo { + t.Helper() + return &archiveReleaseRepo{ + ref: ref, + archiveURL: archiveURL, + workDir: t.TempDir(), + } +} + +func newBoostReleaseRepo(t *testing.T, ref string) vcs.Repo { + t.Helper() + return newArchiveReleaseRepo(t, ref, boostReleaseArchiveURL(ref)) +} + +func newFmtReleaseRepo(t *testing.T, ref string) vcs.Repo { + t.Helper() + return newArchiveReleaseRepo(t, ref, fmtReleaseArchiveURL(ref)) +} + +func newLibjpegTurboReleaseRepo(t *testing.T, ref string) vcs.Repo { + t.Helper() + return newArchiveReleaseRepo(t, ref, libjpegTurboReleaseArchiveURL(ref)) +} + +func newSqliteReleaseRepo(t *testing.T, ref string) vcs.Repo { + t.Helper() + return newArchiveReleaseRepo(t, ref, sqliteReleaseArchiveURL(ref)) +} + +func newPocoReleaseRepo(t *testing.T, ref string) vcs.Repo { + t.Helper() + return newArchiveReleaseRepo(t, ref, pocoReleaseArchiveURL(ref)) +} + +func newPcre2ReleaseRepo(t *testing.T, ref string) vcs.Repo { + t.Helper() + return newArchiveReleaseRepo(t, ref, pcre2ReleaseArchiveURL(ref)) +} + +func newPugixmlReleaseRepo(t *testing.T, ref string) vcs.Repo { + t.Helper() + return newArchiveReleaseRepo(t, ref, pugixmlReleaseArchiveURL(ref)) +} + +func newExpatReleaseRepo(t *testing.T, ref string) vcs.Repo { + t.Helper() + return newArchiveReleaseRepo(t, ref, expatReleaseArchiveURL(ref)) +} + +func newCjsonReleaseRepo(t *testing.T, ref string) vcs.Repo { + t.Helper() + return newArchiveReleaseRepo(t, ref, cjsonReleaseArchiveURL(ref)) +} + +func newCAresReleaseRepo(t *testing.T, ref string) vcs.Repo { + t.Helper() + return newArchiveReleaseRepo(t, ref, cAresReleaseArchiveURL(ref)) +} + +func newLibwebpReleaseRepo(t *testing.T, ref string) vcs.Repo { + t.Helper() + return newArchiveReleaseRepo(t, ref, libwebpReleaseArchiveURL(ref)) +} + +func newLibtiffReleaseRepo(t *testing.T, ref string) vcs.Repo { + t.Helper() + return newArchiveReleaseRepo(t, ref, libtiffReleaseArchiveURL(ref)) +} + +func newZstdReleaseRepo(t *testing.T, ref string) vcs.Repo { + t.Helper() + return newArchiveReleaseRepo(t, ref, zstdReleaseArchiveURL(ref)) +} + +func newYamlCppReleaseRepo(t *testing.T, ref string) vcs.Repo { + t.Helper() + return newArchiveReleaseRepo(t, ref, yamlCppReleaseArchiveURL(ref)) +} + +func newSpdlogReleaseRepo(t *testing.T, ref string) vcs.Repo { + t.Helper() + return newArchiveReleaseRepo(t, ref, spdlogReleaseArchiveURL(ref)) +} + +func newUriparserReleaseRepo(t *testing.T, ref string) vcs.Repo { + t.Helper() + return newArchiveReleaseRepo(t, ref, uriparserReleaseArchiveURL(ref)) +} + +func (r *archiveReleaseRepo) Tags(ctx context.Context) ([]string, error) { + return []string{r.ref}, nil +} + +func (r *archiveReleaseRepo) Latest(ctx context.Context) (string, error) { + return r.ref, nil +} + +func (r *archiveReleaseRepo) At(ref, localDir string) fs.FS { + if err := r.prepare(context.Background()); err != nil { + return os.DirFS(".") + } + return os.DirFS(r.sourceDir) +} + +func (r *archiveReleaseRepo) Sync(ctx context.Context, ref, path, destDir string) error { + if ref != "" && ref != r.ref { + return fmt.Errorf("unsupported archive ref %q, want %q", ref, r.ref) + } + if path != "" { + return fmt.Errorf("archive release repo does not support subdir sync: %q", path) + } + if err := r.prepare(ctx); err != nil { + return err + } + if err := os.MkdirAll(destDir, 0o755); err != nil { + return err + } + return copyTreePreserveLinks(r.sourceDir, destDir) +} + +func (r *archiveReleaseRepo) prepare(ctx context.Context) error { + r.once.Do(func() { + archiveName := "release.tar.gz" + if strings.HasSuffix(strings.ToLower(r.archiveURL), ".zip") { + archiveName = "release.zip" + } + archivePath := filepath.Join(r.workDir, archiveName) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.archiveURL, nil) + if err != nil { + r.initErr = err + return + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + r.initErr = fmt.Errorf("download release archive: %w", err) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + r.initErr = fmt.Errorf("download release archive: unexpected HTTP %d", resp.StatusCode) + return + } + + f, err := os.Create(archivePath) + if err != nil { + r.initErr = err + return + } + if _, err := io.Copy(f, resp.Body); err != nil { + f.Close() + r.initErr = err + return + } + if err := f.Close(); err != nil { + r.initErr = err + return + } + + extractDir := filepath.Join(r.workDir, "extract") + if err := os.MkdirAll(extractDir, 0o755); err != nil { + r.initErr = err + return + } + rootDir, err := extractArchive(archivePath, extractDir) + if err != nil { + r.initErr = err + return + } + r.sourceDir = rootDir + }) + return r.initErr +} + +func boostReleaseArchiveURL(ref string) string { + version := strings.TrimPrefix(ref, "boost-") + archiveVersion := strings.ReplaceAll(version, ".", "_") + return fmt.Sprintf("https://archives.boost.io/release/%s/source/boost_%s.tar.gz", version, archiveVersion) +} + +func fmtReleaseArchiveURL(ref string) string { + return fmt.Sprintf("https://github.com/fmtlib/fmt/archive/refs/tags/%s.tar.gz", ref) +} + +func libjpegTurboReleaseArchiveURL(ref string) string { + return fmt.Sprintf("https://github.com/libjpeg-turbo/libjpeg-turbo/releases/download/%s/libjpeg-turbo-%s.tar.gz", ref, ref) +} + +func sqliteReleaseArchiveURL(ref string) string { + parts := strings.Split(ref, ".") + if len(parts) != 3 { + panic(fmt.Sprintf("unexpected sqlite version %q", ref)) + } + minor, err := strconv.Atoi(parts[1]) + if err != nil { + panic(fmt.Sprintf("unexpected sqlite version %q", ref)) + } + patch, err := strconv.Atoi(parts[2]) + if err != nil { + panic(fmt.Sprintf("unexpected sqlite version %q", ref)) + } + return fmt.Sprintf("https://sqlite.org/2024/sqlite-amalgamation-%s%02d%02d00.zip", parts[0], minor, patch) +} + +func pocoReleaseArchiveURL(ref string) string { + return fmt.Sprintf("https://github.com/pocoproject/poco/archive/refs/tags/%s.tar.gz", ref) +} + +func pcre2ReleaseArchiveURL(ref string) string { + return fmt.Sprintf("https://github.com/PCRE2Project/pcre2/archive/refs/tags/%s.tar.gz", ref) +} + +func pugixmlReleaseArchiveURL(ref string) string { + version := strings.TrimPrefix(ref, "v") + return fmt.Sprintf("https://github.com/zeux/pugixml/releases/download/v%s/pugixml-%s.tar.gz", version, version) +} + +func expatReleaseArchiveURL(ref string) string { + tag := ref + if !strings.HasPrefix(tag, "R_") { + tag = "R_" + strings.ReplaceAll(ref, ".", "_") + } + return fmt.Sprintf("https://github.com/libexpat/libexpat/archive/refs/tags/%s.tar.gz", tag) +} + +func cjsonReleaseArchiveURL(ref string) string { + tag := ref + if !strings.HasPrefix(tag, "v") { + tag = "v" + ref + } + return fmt.Sprintf("https://github.com/DaveGamble/cJSON/archive/refs/tags/%s.tar.gz", tag) +} + +func cAresReleaseArchiveURL(ref string) string { + return fmt.Sprintf("https://github.com/c-ares/c-ares/releases/download/v%s/c-ares-%s.tar.gz", ref, ref) +} + +func libwebpReleaseArchiveURL(ref string) string { + tag := ref + if !strings.HasPrefix(tag, "v") { + tag = "v" + ref + } + return fmt.Sprintf("https://github.com/webmproject/libwebp/archive/refs/tags/%s.tar.gz", tag) +} + +func libtiffReleaseArchiveURL(ref string) string { + tag := ref + if !strings.HasPrefix(tag, "v") { + tag = "v" + ref + } + return fmt.Sprintf("https://github.com/libsdl-org/libtiff/archive/refs/tags/%s.tar.gz", tag) +} + +func zstdReleaseArchiveURL(ref string) string { + tag := ref + if !strings.HasPrefix(tag, "v") { + tag = "v" + ref + } + return fmt.Sprintf("https://github.com/facebook/zstd/archive/refs/tags/%s.tar.gz", tag) +} + +func yamlCppReleaseArchiveURL(ref string) string { + tag := ref + if !strings.HasPrefix(tag, "yaml-cpp-") { + tag = "yaml-cpp-" + ref + } + return fmt.Sprintf("https://github.com/jbeder/yaml-cpp/archive/refs/tags/%s.tar.gz", tag) +} + +func spdlogReleaseArchiveURL(ref string) string { + tag := ref + if !strings.HasPrefix(tag, "v") { + tag = "v" + ref + } + return fmt.Sprintf("https://github.com/gabime/spdlog/archive/refs/tags/%s.tar.gz", tag) +} + +func uriparserReleaseArchiveURL(ref string) string { + tag := ref + if !strings.HasPrefix(tag, "uriparser-") { + tag = "uriparser-" + ref + } + return fmt.Sprintf("https://github.com/uriparser/uriparser/archive/refs/tags/%s.tar.gz", tag) +} + +func extractArchive(archivePath, destDir string) (string, error) { + if strings.HasSuffix(strings.ToLower(archivePath), ".zip") { + return extractZip(archivePath, destDir) + } + return extractTarGz(archivePath, destDir) +} + +func copyTreePreserveLinks(srcRoot, dstRoot string) error { + return filepath.WalkDir(srcRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + rel, err := filepath.Rel(srcRoot, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + dstPath := filepath.Join(dstRoot, rel) + + info, err := os.Lstat(path) + if err != nil { + return err + } + switch mode := info.Mode(); { + case mode.IsDir(): + return os.MkdirAll(dstPath, mode.Perm()) + case mode&os.ModeSymlink != 0: + target, err := os.Readlink(path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { + return err + } + return os.Symlink(target, dstPath) + case mode.IsRegular(): + if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { + return err + } + return copyRegularFile(path, dstPath, mode.Perm()) + default: + return nil + } + }) +} + +func copyRegularFile(srcPath, dstPath string, perm fs.FileMode) error { + src, err := os.Open(srcPath) + if err != nil { + return err + } + defer src.Close() + + dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm) + if err != nil { + return err + } + defer dst.Close() + + _, err = io.Copy(dst, src) + return err +} + +func extractTarGz(archivePath, destDir string) (string, error) { + f, err := os.Open(archivePath) + if err != nil { + return "", err + } + defer f.Close() + + gzr, err := gzip.NewReader(f) + if err != nil { + return "", err + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + var rootName string + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return "", err + } + + name := filepath.Clean(hdr.Name) + if name == "." { + continue + } + if hdr.Typeflag == tar.TypeXGlobalHeader || name == "pax_global_header" { + continue + } + parts := strings.Split(name, string(filepath.Separator)) + if len(parts) > 0 && rootName == "" && parts[0] != "pax_global_header" { + rootName = parts[0] + } + target := filepath.Join(destDir, name) + + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, hdr.FileInfo().Mode().Perm()); err != nil { + return "", err + } + case tar.TypeReg, tar.TypeRegA: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return "", err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode().Perm()) + if err != nil { + return "", err + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return "", err + } + if err := out.Close(); err != nil { + return "", err + } + case tar.TypeSymlink: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return "", err + } + if err := os.Symlink(hdr.Linkname, target); err != nil && !os.IsExist(err) { + return "", err + } + } + } + + if rootName == "" { + return "", fmt.Errorf("empty archive") + } + return filepath.Join(destDir, rootName), nil +} + +func extractZip(archivePath, destDir string) (string, error) { + zr, err := zip.OpenReader(archivePath) + if err != nil { + return "", err + } + defer zr.Close() + + var rootName string + for _, f := range zr.File { + name := filepath.Clean(f.Name) + if name == "." { + continue + } + parts := strings.Split(name, string(filepath.Separator)) + if len(parts) > 0 && rootName == "" { + rootName = parts[0] + } + target := filepath.Join(destDir, name) + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return "", err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return "", err + } + rc, err := f.Open() + if err != nil { + return "", err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, f.Mode()) + if err != nil { + rc.Close() + return "", err + } + if _, err := io.Copy(out, rc); err != nil { + out.Close() + rc.Close() + return "", err + } + if err := out.Close(); err != nil { + rc.Close() + return "", err + } + if err := rc.Close(); err != nil { + return "", err + } + } + if rootName == "" { + return "", fmt.Errorf("empty archive") + } + return filepath.Join(destDir, rootName), nil +} + +func dirHasPrefix(dir, prefix string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), prefix) { + return true } } + return false } diff --git a/internal/build/test_runner.go b/internal/build/test_runner.go new file mode 100644 index 0000000..15cef68 --- /dev/null +++ b/internal/build/test_runner.go @@ -0,0 +1,111 @@ +package build + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "strings" + + classfile "github.com/goplus/llar/formula" + "github.com/goplus/llar/internal/modules" + "github.com/goplus/llar/mod/module" +) + +type OnTestFailureError struct { + Module module.Version + Err error +} + +func (e *OnTestFailureError) Error() string { + return fmt.Sprintf("onTest failed for %s@%s: %v", e.Module.Path, e.Module.Version, e.Err) +} + +func (e *OnTestFailureError) Unwrap() error { + return e.Err +} + +// RunOnTest executes the main module's OnTest callback against an existing +// output tree. It reuses the builder's matrix and workspace to preserve +// dependency output-dir and cached metadata lookups. +func (b *Builder) RunOnTest(ctx context.Context, targets []*modules.Module, outputDir string) error { + if len(targets) == 0 { + return nil + } + mod := targets[0] + if mod.OnTest == nil { + return nil + } + + tmpSourceDir, err := os.MkdirTemp("", fmt.Sprintf("source-%s-%s*", strings.ReplaceAll(mod.Path, "/", "-"), mod.Version)) + if err != nil { + return err + } + defer os.RemoveAll(tmpSourceDir) + + repo, err := b.newRepo(fmt.Sprintf("github.com/%s", mod.Path)) + if err != nil { + return err + } + if err := repo.Sync(ctx, mod.Version, "", tmpSourceDir); err != nil { + return err + } + + getOutputDir := func(_ string, m module.Version) (string, error) { + return b.installDir(m.Path, m.Version) + } + testContext := classfile.NewContext(tmpSourceDir, outputDir, b.matrix, getOutputDir) + for _, dep := range b.resolveModTransitiveDeps(targets, mod) { + testContext.AddBuildResult(dep, b.cachedBuildResult(dep)) + } + + project := &classfile.Project{ + Deps: b.resolveModTransitiveDeps(targets, mod), + SourceFS: mod.FS.(fs.ReadFileFS), + } + + savedEnv := os.Environ() + defer func() { + os.Clearenv() + for _, env := range savedEnv { + k, v, _ := strings.Cut(env, "=") + _ = os.Setenv(k, v) + } + }() + + cwd, err := os.Getwd() + if err != nil { + return err + } + defer func() { + _ = os.Chdir(cwd) + }() + if err := os.Chdir(tmpSourceDir); err != nil { + return err + } + + var out classfile.BuildResult + mod.OnTest(testContext, project, &out) + if len(out.Errs()) > 0 { + return &OnTestFailureError{ + Module: module.Version{Path: mod.Path, Version: mod.Version}, + Err: errors.Join(out.Errs()...), + } + } + return nil +} + +func (b *Builder) cachedBuildResult(mod module.Version) classfile.BuildResult { + var result classfile.BuildResult + cache, err := b.loadCache(mod.Path) + if err != nil { + return result + } + entry, ok := cache.get(mod.Version, b.matrix) + if !ok || entry == nil || entry.Metadata == "" { + return result + } + result.SetMetadata(entry.Metadata) + return result +} diff --git a/internal/build/testdata/formulas/DaveGamble/cJSON/1.0.0/Cjson_llar.gox b/internal/build/testdata/formulas/DaveGamble/cJSON/1.0.0/Cjson_llar.gox new file mode 100644 index 0000000..5f3a656 --- /dev/null +++ b/internal/build/testdata/formulas/DaveGamble/cJSON/1.0.0/Cjson_llar.gox @@ -0,0 +1,79 @@ +import "strings" + +id "DaveGamble/cJSON" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + localesOn := strings.contains(combo, "locales-on") + utilsOn := strings.contains(combo, "utils-on") + + c := cmake.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + c.buildType "Release" + c.define "CMAKE_POLICY_VERSION_MINIMUM", "3.5" + c.defineBool "BUILD_SHARED_LIBS", false + c.defineBool "ENABLE_CJSON_UTILS", utilsOn + c.defineBool "ENABLE_LOCALES", localesOn + c.defineBool "ENABLE_CJSON_TEST", false + c.defineBool "ENABLE_SANITIZERS", false + c.defineBool "ENABLE_SAFE_STACK", false + c.defineBool "ENABLE_TARGET_EXPORT", false + c.defineBool "ENABLE_CUSTOM_COMPILER_FLAGS", false + + err = c.configure() + if err != nil { + out.addErr err + return + } + err = c.build() + if err != nil { + out.addErr err + return + } + err = c.install() + if err != nil { + out.addErr err + return + } + + meta := "-lcjson" + if utilsOn { + meta += " -lcjson_utils" + } + out.setMetadata meta +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + script := "set -eu\n" + + "test -f \"$1/lib/libcjson.a\"\n" + + "test -f \"$1/include/cjson/cJSON.h\"\n" + + "if echo \"$2\" | grep -q 'utils-on'; then\n" + + " test -f \"$1/lib/libcjson_utils.a\"\n" + + "else\n" + + " test ! -e \"$1/lib/libcjson_utils.a\"\n" + + "fi\n" + + "if echo \"$2\" | grep -q 'locales-on'; then\n" + + " grep -q 'localeconv' \"$1/lib/libcjson.a\"\n" + + "else\n" + + " if grep -q 'localeconv' \"$1/lib/libcjson.a\"; then exit 1; fi\n" + + "fi\n" + err = exec("sh", "-c", script, "sh", installDir, combo) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/DaveGamble/cJSON/versions.json b/internal/build/testdata/formulas/DaveGamble/cJSON/versions.json new file mode 100644 index 0000000..719c6d0 --- /dev/null +++ b/internal/build/testdata/formulas/DaveGamble/cJSON/versions.json @@ -0,0 +1,4 @@ +{ + "path": "DaveGamble/cJSON", + "deps": {} +} diff --git a/internal/build/testdata/formulas/FFmpeg/FFmpeg/1.0.0/Ffmpeg_llar.gox b/internal/build/testdata/formulas/FFmpeg/FFmpeg/1.0.0/Ffmpeg_llar.gox new file mode 100644 index 0000000..85f4867 --- /dev/null +++ b/internal/build/testdata/formulas/FFmpeg/FFmpeg/1.0.0/Ffmpeg_llar.gox @@ -0,0 +1,107 @@ +import "fmt" +import "strings" + +id "FFmpeg/FFmpeg" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + networkOn := strings.contains(combo, "network-on") + zlibOn := strings.contains(combo, "zlib-on") + + a := autotools.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + if zlibOn { + depDir := "" + for _, dep := range proj.Deps { + if dep.Path != "madler/zlib" { + continue + } + depDir, err = ctx.outputDir(dep) + if err != nil { + out.addErr err + return + } + break + } + if depDir == "" { + out.addErr fmt.errorf("zlib-on requires madler/zlib dependency") + return + } + a.use depDir + } + + args := [ + "--disable-autodetect", + "--disable-shared", + "--enable-static", + "--disable-programs", + "--disable-doc", + "--disable-debug", + "--disable-everything", + "--enable-avutil", + "--enable-avcodec", + "--enable-avformat", + "--disable-x86asm", + ] + if !networkOn { + args <- "--disable-network" + } + if !zlibOn { + args <- "--disable-zlib" + } + + err = a.configure(args...) + if err != nil { + out.addErr err + return + } + err = a.build() + if err != nil { + out.addErr err + return + } + err = a.install() + if err != nil { + out.addErr err + return + } + + out.setMetadata "-lavformat -lavcodec -lavutil" +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + script := "set -eu\n" + + "test -f \"$1/lib/libavformat.a\"\n" + + "test -f \"$1/lib/libavcodec.a\"\n" + + "test -f \"$1/lib/libavutil.a\"\n" + + "if echo \"$2\" | grep -q 'network-on'; then\n" + + " grep -R -q '^#define CONFIG_NETWORK 1' \"$1/include\"\n" + + "else\n" + + " grep -R -q '^#define CONFIG_NETWORK 0' \"$1/include\"\n" + + "fi\n" + + "if echo \"$2\" | grep -q 'zlib-on'; then\n" + + " grep -R -q '^#define CONFIG_ZLIB 1' \"$1/include\"\n" + + " grep -R -q -- ' -lz' \"$1/lib/pkgconfig\"\n" + + "else\n" + + " grep -R -q '^#define CONFIG_ZLIB 0' \"$1/include\"\n" + + "fi\n" + err = exec("sh", "-c", script, "sh", installDir, combo) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/FFmpeg/FFmpeg/versions.json b/internal/build/testdata/formulas/FFmpeg/FFmpeg/versions.json new file mode 100644 index 0000000..0c57809 --- /dev/null +++ b/internal/build/testdata/formulas/FFmpeg/FFmpeg/versions.json @@ -0,0 +1,11 @@ +{ + "path": "FFmpeg/FFmpeg", + "deps": { + "n8.0.1": [ + {"path": "madler/zlib", "version": "v1.2.11"} + ], + "1.0.0": [ + {"path": "madler/zlib", "version": "v1.2.11"} + ] + } +} diff --git a/internal/build/testdata/formulas/PCRE2Project/pcre2/1.0.0/PCRE2_llar.gox b/internal/build/testdata/formulas/PCRE2Project/pcre2/1.0.0/PCRE2_llar.gox new file mode 100644 index 0000000..86eaa7f --- /dev/null +++ b/internal/build/testdata/formulas/PCRE2Project/pcre2/1.0.0/PCRE2_llar.gox @@ -0,0 +1,89 @@ +import "strings" + +id "PCRE2Project/pcre2" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + grepOn := strings.contains(combo, "grep-on") + width16On := strings.contains(combo, "width16-on") + width32On := strings.contains(combo, "width32-on") + + c := cmake.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + c.buildType "Release" + c.defineBool "BUILD_SHARED_LIBS", false + c.defineBool "BUILD_STATIC_LIBS", true + c.defineBool "PCRE2_BUILD_PCRE2_8", true + c.defineBool "PCRE2_BUILD_PCRE2_16", width16On + c.defineBool "PCRE2_BUILD_PCRE2_32", width32On + c.defineBool "PCRE2_BUILD_PCRE2GREP", grepOn + c.defineBool "PCRE2_BUILD_TESTS", false + c.defineBool "PCRE2_SUPPORT_LIBZ", false + c.defineBool "PCRE2_SUPPORT_LIBBZ2", false + c.defineBool "PCRE2_SUPPORT_JIT", false + + err = c.configure() + if err != nil { + out.addErr err + return + } + err = c.build() + if err != nil { + out.addErr err + return + } + err = c.install() + if err != nil { + out.addErr err + return + } + + meta := "-lpcre2-8 -lpcre2-posix" + if width16On { + meta += " -lpcre2-16" + } + if width32On { + meta += " -lpcre2-32" + } + out.setMetadata meta +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + script := "set -eu\n" + + "test -f \"$1/lib/libpcre2-8.a\"\n" + + "test -f \"$1/lib/libpcre2-posix.a\"\n" + + "if echo \"$2\" | grep -q 'width16-on'; then\n" + + " test -f \"$1/lib/libpcre2-16.a\"\n" + + "else\n" + + " test ! -e \"$1/lib/libpcre2-16.a\"\n" + + "fi\n" + + "if echo \"$2\" | grep -q 'width32-on'; then\n" + + " test -f \"$1/lib/libpcre2-32.a\"\n" + + "else\n" + + " test ! -e \"$1/lib/libpcre2-32.a\"\n" + + "fi\n" + + "if echo \"$2\" | grep -q 'grep-on'; then\n" + + " test -f \"$1/bin/pcre2grep\"\n" + + "else\n" + + " test ! -e \"$1/bin/pcre2grep\"\n" + + "fi\n" + err = exec("sh", "-c", script, "sh", installDir, combo) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/PCRE2Project/pcre2/versions.json b/internal/build/testdata/formulas/PCRE2Project/pcre2/versions.json new file mode 100644 index 0000000..df0ff5e --- /dev/null +++ b/internal/build/testdata/formulas/PCRE2Project/pcre2/versions.json @@ -0,0 +1,4 @@ +{ + "path": "PCRE2Project/pcre2", + "deps": {} +} diff --git a/internal/build/testdata/formulas/boostorg/boost/1.0.0/Boost_llar.gox b/internal/build/testdata/formulas/boostorg/boost/1.0.0/Boost_llar.gox new file mode 100644 index 0000000..ea2b69f --- /dev/null +++ b/internal/build/testdata/formulas/boostorg/boost/1.0.0/Boost_llar.gox @@ -0,0 +1,84 @@ +import "strings" + +id "boostorg/boost" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + timerOn := strings.contains(combo, "timer-on") + programOptionsOn := strings.contains(combo, "program_options-on") + + err = exec("sh", "./bootstrap.sh", "--with-libraries=timer,program_options", "--prefix="+installDir) + if err != nil { + out.addErr err + return + } + + err = exec("mkdir", "-p", installDir+"/include") + if err != nil { + out.addErr err + return + } + err = exec("cp", "-R", ctx.SourceDir+"/boost", installDir+"/include/") + if err != nil { + out.addErr err + return + } + + buildDir := "--build-dir=" + ctx.SourceDir + "/_build" + prefix := "--prefix=" + installDir + if timerOn && programOptionsOn { + err = exec("./b2", buildDir, prefix, "variant=release", "link=static", "threading=multi", "install", "--with-timer", "--with-program_options") + } else if timerOn { + err = exec("./b2", buildDir, prefix, "variant=release", "link=static", "threading=multi", "install", "--with-timer") + } else if programOptionsOn { + err = exec("./b2", buildDir, prefix, "variant=release", "link=static", "threading=multi", "install", "--with-program_options") + } + if err != nil { + out.addErr err + return + } + + meta := "" + if programOptionsOn { + meta += " -lboost_program_options" + } + if timerOn { + meta += " -lboost_timer" + } + out.setMetadata strings.trimSpace(meta) +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + script := "set -eu\n" + + "test -f \"$1/include/boost/config.hpp\"\n" + + "if echo \"$2\" | grep -q 'program_options-on'; then\n" + + " test -f \"$1/lib/libboost_program_options.a\"\n" + + "else\n" + + " test ! -e \"$1/lib/libboost_program_options.a\"\n" + + "fi\n" + + "if echo \"$2\" | grep -q 'timer-on'; then\n" + + " test -f \"$1/lib/libboost_timer.a\"\n" + + "else\n" + + " test ! -e \"$1/lib/libboost_timer.a\"\n" + + "fi\n" + err = exec("sh", "-c", script, "sh", installDir, combo) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/boostorg/boost/versions.json b/internal/build/testdata/formulas/boostorg/boost/versions.json new file mode 100644 index 0000000..b00cb47 --- /dev/null +++ b/internal/build/testdata/formulas/boostorg/boost/versions.json @@ -0,0 +1,4 @@ +{ + "path": "boostorg/boost", + "deps": {} +} diff --git a/internal/build/testdata/formulas/c-ares/c-ares/1.0.0/Cares_llar.gox b/internal/build/testdata/formulas/c-ares/c-ares/1.0.0/Cares_llar.gox new file mode 100644 index 0000000..e662cd5 --- /dev/null +++ b/internal/build/testdata/formulas/c-ares/c-ares/1.0.0/Cares_llar.gox @@ -0,0 +1,86 @@ +import "strings" + +id "c-ares/c-ares" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + threadsOn := strings.contains(combo, "threads-on") + toolsOn := strings.contains(combo, "tools-on") + + c := cmake.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + c.buildType "Release" + c.defineBool "BUILD_SHARED_LIBS", false + c.defineBool "CARES_STATIC", true + c.defineBool "CARES_SHARED", false + c.defineBool "CARES_BUILD_TESTS", false + c.defineBool "CARES_BUILD_TOOLS", toolsOn + c.defineBool "CARES_THREADS", threadsOn + + err = c.configure() + if err != nil { + out.addErr err + return + } + err = c.build() + if err != nil { + out.addErr err + return + } + err = c.install() + if err != nil { + out.addErr err + return + } + + out.setMetadata "-lcares" +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + testDir := ctx.SourceDir + "/_ontest" + err = exec("mkdir", "-p", testDir) + if err != nil { + out.addErr err + return + } + + script := "set -eu\n" + + "test -f \"$1/lib/libcares.a\"\n" + + "test -f \"$1/include/ares.h\"\n" + + "if echo \"$2\" | grep -q 'tools-on'; then\n" + + " test -f \"$1/bin/adig\"\n" + + " test -f \"$1/bin/ahost\"\n" + + "else\n" + + " test ! -e \"$1/bin/adig\"\n" + + " test ! -e \"$1/bin/ahost\"\n" + + "fi\n" + + "cat > \"$3/cares_threads_test.c\" <<'EOF'\n" + + "#include \n" + + "int main(void) { return ares_threadsafety() ? 0 : 1; }\n" + + "EOF\n" + + "cc -I\"$1/include\" \"$3/cares_threads_test.c\" -L\"$1/lib\" -lcares -o \"$3/cares_threads_test\"\n" + + "if echo \"$2\" | grep -q 'threads-on'; then\n" + + " \"$3/cares_threads_test\"\n" + + "else\n" + + " if \"$3/cares_threads_test\"; then exit 1; fi\n" + + "fi\n" + err = exec("sh", "-c", script, "sh", installDir, combo, testDir) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/c-ares/c-ares/versions.json b/internal/build/testdata/formulas/c-ares/c-ares/versions.json new file mode 100644 index 0000000..d90caa5 --- /dev/null +++ b/internal/build/testdata/formulas/c-ares/c-ares/versions.json @@ -0,0 +1,4 @@ +{ + "path": "c-ares/c-ares", + "deps": {} +} diff --git a/internal/build/testdata/formulas/facebook/zstd/1.0.0/Zstd_llar.gox b/internal/build/testdata/formulas/facebook/zstd/1.0.0/Zstd_llar.gox new file mode 100644 index 0000000..3431db7 --- /dev/null +++ b/internal/build/testdata/formulas/facebook/zstd/1.0.0/Zstd_llar.gox @@ -0,0 +1,91 @@ +import "strings" + +id "facebook/zstd" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + programsOn := strings.contains(combo, "programs-on") + threadingOn := strings.contains(combo, "threading-on") + + c := cmake.new(ctx.SourceDir+"/build/cmake", ctx.SourceDir+"/_build", installDir) + c.buildType "Release" + c.defineBool "ZSTD_BUILD_SHARED", false + c.defineBool "ZSTD_BUILD_STATIC", true + c.defineBool "ZSTD_BUILD_PROGRAMS", programsOn + c.defineBool "ZSTD_PROGRAMS_LINK_SHARED", false + c.defineBool "ZSTD_MULTITHREAD_SUPPORT", threadingOn + c.defineBool "ZSTD_BUILD_TESTS", false + c.defineBool "ZSTD_BUILD_CONTRIB", false + + err = c.configure() + if err != nil { + out.addErr err + return + } + err = c.build() + if err != nil { + out.addErr err + return + } + err = c.install() + if err != nil { + out.addErr err + return + } + + out.setMetadata "-lzstd" +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + testDir := ctx.SourceDir + "/_ontest" + err = exec("mkdir", "-p", testDir) + if err != nil { + out.addErr err + return + } + + script := "set -eu\n" + + "test -f \"$1/lib/libzstd.a\"\n" + + "test -f \"$1/include/zstd.h\"\n" + + "if echo \"$2\" | grep -q 'programs-on'; then\n" + + " test -f \"$1/bin/zstd\"\n" + + "else\n" + + " test ! -e \"$1/bin/zstd\"\n" + + "fi\n" + + "cat > \"$3/zstd_threads_test.c\" <<'EOF'\n" + + "#include \n" + + "int main(void) {\n" + + " ZSTD_CCtx *cctx = ZSTD_createCCtx();\n" + + " if (cctx == 0) return 2;\n" + + " size_t rc = ZSTD_CCtx_setParameter(cctx, ZSTD_c_nbWorkers, 1);\n" + + " ZSTD_freeCCtx(cctx);\n" + + " return ZSTD_isError(rc) ? 1 : 0;\n" + + "}\n" + + "EOF\n" + + "cc -I\"$1/include\" \"$3/zstd_threads_test.c\" -L\"$1/lib\" -lzstd -o \"$3/zstd_threads_test\"\n" + + "if echo \"$2\" | grep -q 'threading-on'; then\n" + + " \"$3/zstd_threads_test\"\n" + + "else\n" + + " if \"$3/zstd_threads_test\"; then exit 1; fi\n" + + "fi\n" + err = exec("sh", "-c", script, "sh", installDir, combo, testDir) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/facebook/zstd/versions.json b/internal/build/testdata/formulas/facebook/zstd/versions.json new file mode 100644 index 0000000..a69e6da --- /dev/null +++ b/internal/build/testdata/formulas/facebook/zstd/versions.json @@ -0,0 +1 @@ +{"1.0.0":"1.5.7"} diff --git a/internal/build/testdata/formulas/fmtlib/fmt/1.0.0/Fmt_llar.gox b/internal/build/testdata/formulas/fmtlib/fmt/1.0.0/Fmt_llar.gox new file mode 100644 index 0000000..d3d1ea5 --- /dev/null +++ b/internal/build/testdata/formulas/fmtlib/fmt/1.0.0/Fmt_llar.gox @@ -0,0 +1,91 @@ +import "strings" + +id "fmtlib/fmt" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + osAPIOn := strings.contains(combo, "osapi-on") + unicodeOn := strings.contains(combo, "unicode-on") + + c := cmake.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + c.buildType "Release" + c.defineBool "BUILD_SHARED_LIBS", false + c.defineBool "FMT_DOC", false + c.defineBool "FMT_TEST", false + c.defineBool "FMT_OS", osAPIOn + c.defineBool "FMT_UNICODE", unicodeOn + + err = c.configure() + if err != nil { + out.addErr err + return + } + err = c.build() + if err != nil { + out.addErr err + return + } + err = c.install() + if err != nil { + out.addErr err + return + } + + out.setMetadata "-lfmt" +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + testDir := ctx.SourceDir + "/_ontest" + err = exec("mkdir", "-p", testDir) + if err != nil { + out.addErr err + return + } + + script := "set -eu\n" + + "test -f \"$1/lib/libfmt.a\"\n" + + "test -f \"$1/include/fmt/format.h\"\n" + + "if echo \"$2\" | grep -q 'osapi-on'; then\n" + + " cat > \"$3/fmt_os_test.cpp\" <<'EOF'\n" + + " #include \n" + + " int main() {\n" + + " auto out = fmt::output_file(\"fmt-os-smoke.txt\");\n" + + " out.print(\"ok\");\n" + + " return 0;\n" + + " }\n" + + "EOF\n" + + " c++ -std=c++17 -I\"$1/include\" \"$3/fmt_os_test.cpp\" -L\"$1/lib\" -lfmt -o \"$3/fmt_os_test\"\n" + + " \"$3/fmt_os_test\"\n" + + "fi\n" + + "if echo \"$2\" | grep -q 'unicode-on'; then\n" + + " cat > \"$3/fmt_unicode_test.cpp\" <<'EOF'\n" + + " #include \n" + + " int main() {\n" + + " auto s = fmt::format(L\"{}\", 42);\n" + + " return s.empty() ? 1 : 0;\n" + + " }\n" + + "EOF\n" + + " c++ -std=c++17 -I\"$1/include\" \"$3/fmt_unicode_test.cpp\" -L\"$1/lib\" -lfmt -o \"$3/fmt_unicode_test\"\n" + + " \"$3/fmt_unicode_test\"\n" + + "fi\n" + err = exec("sh", "-c", script, "sh", installDir, combo, testDir) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/fmtlib/fmt/versions.json b/internal/build/testdata/formulas/fmtlib/fmt/versions.json new file mode 100644 index 0000000..c5ec3eb --- /dev/null +++ b/internal/build/testdata/formulas/fmtlib/fmt/versions.json @@ -0,0 +1,4 @@ +{ + "path": "fmtlib/fmt", + "deps": {} +} diff --git a/internal/build/testdata/formulas/gabime/spdlog/1.0.0/Spdlog_llar.gox b/internal/build/testdata/formulas/gabime/spdlog/1.0.0/Spdlog_llar.gox new file mode 100644 index 0000000..ca33dc7 --- /dev/null +++ b/internal/build/testdata/formulas/gabime/spdlog/1.0.0/Spdlog_llar.gox @@ -0,0 +1,84 @@ +import "strings" + +id "gabime/spdlog" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + noExceptionsOn := strings.contains(combo, "noexceptions-on") + wcharOn := strings.contains(combo, "wchar-on") + + c := cmake.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + c.buildType "Release" + c.defineBool "BUILD_SHARED_LIBS", false + c.defineBool "SPDLOG_BUILD_SHARED", false + c.defineBool "SPDLOG_BUILD_EXAMPLE", false + c.defineBool "SPDLOG_BUILD_EXAMPLE_HO", false + c.defineBool "SPDLOG_BUILD_TESTS", false + c.defineBool "SPDLOG_BUILD_TESTS_HO", false + c.defineBool "SPDLOG_BUILD_BENCH", false + c.defineBool "SPDLOG_WCHAR_SUPPORT", wcharOn + c.defineBool "SPDLOG_WCHAR_FILENAMES", false + c.defineBool "SPDLOG_WCHAR_CONSOLE", false + c.defineBool "SPDLOG_NO_EXCEPTIONS", noExceptionsOn + + err = c.configure() + if err != nil { + out.addErr err + return + } + err = c.build() + if err != nil { + out.addErr err + return + } + err = c.install() + if err != nil { + out.addErr err + return + } + + out.setMetadata "-lspdlog" +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + testDir := ctx.SourceDir + "/_ontest" + err = exec("mkdir", "-p", testDir) + if err != nil { + out.addErr err + return + } + + script := "set -eu\n" + + "test -f \"$1/lib/libspdlog.a\"\n" + + "test -f \"$1/include/spdlog/spdlog.h\"\n" + + "if echo \"$2\" | grep -q 'noexceptions-on'; then\n" + + " grep -R -q 'SPDLOG_NO_EXCEPTIONS' \"$1/include\" \"$1/lib/cmake\"\n" + + "fi\n" + + "if echo \"$2\" | grep -q 'wchar-on'; then\n" + + " cat > \"$3/spdlog_wchar_test.cpp\" <<'EOF'\n" + + " #include \n" + + " int main() { spdlog::info(L\"{}\", 42); return 0; }\n" + + "EOF\n" + + " c++ -std=c++17 -I\"$1/include\" \"$3/spdlog_wchar_test.cpp\" -L\"$1/lib\" -lspdlog -o \"$3/spdlog_wchar_test\"\n" + + "fi\n" + err = exec("sh", "-c", script, "sh", installDir, combo, testDir) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/gabime/spdlog/versions.json b/internal/build/testdata/formulas/gabime/spdlog/versions.json new file mode 100644 index 0000000..2d15f28 --- /dev/null +++ b/internal/build/testdata/formulas/gabime/spdlog/versions.json @@ -0,0 +1,6 @@ +{ + "path": "gabime/spdlog", + "versions": { + "1.0.0": "1.17.0" + } +} diff --git a/internal/build/testdata/formulas/jbeder/yaml-cpp/1.0.0/Yamlcpp_llar.gox b/internal/build/testdata/formulas/jbeder/yaml-cpp/1.0.0/Yamlcpp_llar.gox new file mode 100644 index 0000000..a112ff8 --- /dev/null +++ b/internal/build/testdata/formulas/jbeder/yaml-cpp/1.0.0/Yamlcpp_llar.gox @@ -0,0 +1,76 @@ +import "strings" + +id "jbeder/yaml-cpp" + +fromVer "0.9.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + contribOn := strings.contains(combo, "contrib-on") + toolsOn := strings.contains(combo, "tools-on") + + c := cmake.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + c.buildType "Release" + c.defineBool "BUILD_SHARED_LIBS", false + c.defineBool "YAML_BUILD_SHARED_LIBS", false + c.defineBool "YAML_CPP_BUILD_TESTS", false + c.defineBool "YAML_CPP_BUILD_CONTRIB", contribOn + c.defineBool "YAML_CPP_BUILD_TOOLS", toolsOn + c.defineBool "YAML_CPP_INSTALL", true + + err = c.configure() + if err != nil { + out.addErr err + return + } + err = c.build() + if err != nil { + out.addErr err + return + } + err = c.install() + if err != nil { + out.addErr err + return + } + + out.setMetadata "-lyaml-cpp" +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + script := "set -eu\n" + + "test -f \"$1/lib/libyaml-cpp.a\"\n" + + "test -f \"$1/include/yaml-cpp/yaml.h\"\n" + + "if echo \"$2\" | grep -q 'contrib-on'; then\n" + + " test -d \"$1/include/yaml-cpp/contrib\"\n" + + "else\n" + + " test ! -e \"$1/include/yaml-cpp/contrib\"\n" + + "fi\n" + + "if echo \"$2\" | grep -q 'tools-on'; then\n" + + " test -f \"$1/bin/parse\"\n" + + " test -f \"$1/bin/read\"\n" + + " test -f \"$1/bin/sandbox\"\n" + + "else\n" + + " test ! -e \"$1/bin/parse\"\n" + + " test ! -e \"$1/bin/read\"\n" + + " test ! -e \"$1/bin/sandbox\"\n" + + "fi\n" + err = exec("sh", "-c", script, "sh", installDir, combo) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/jbeder/yaml-cpp/versions.json b/internal/build/testdata/formulas/jbeder/yaml-cpp/versions.json new file mode 100644 index 0000000..368345a --- /dev/null +++ b/internal/build/testdata/formulas/jbeder/yaml-cpp/versions.json @@ -0,0 +1,6 @@ +{ + "path": "jbeder/yaml-cpp", + "versions": { + "1.0.0": "0.9.0" + } +} diff --git a/internal/build/testdata/formulas/libexpat/libexpat/1.0.0/Expat_llar.gox b/internal/build/testdata/formulas/libexpat/libexpat/1.0.0/Expat_llar.gox new file mode 100644 index 0000000..46f15ec --- /dev/null +++ b/internal/build/testdata/formulas/libexpat/libexpat/1.0.0/Expat_llar.gox @@ -0,0 +1,101 @@ +import "strings" + +id "libexpat/libexpat" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + geOn := strings.contains(combo, "ge-on") + largeSizeOn := strings.contains(combo, "large_size-on") + minSizeOn := strings.contains(combo, "min_size-on") + nsOn := strings.contains(combo, "ns-on") + + sourceDir := ctx.SourceDir + "/expat" + c := cmake.new(sourceDir, sourceDir+"/_build", installDir) + c.buildType "Release" + c.defineBool "EXPAT_SHARED_LIBS", false + c.defineBool "EXPAT_BUILD_TESTS", false + c.defineBool "EXPAT_BUILD_EXAMPLES", false + c.defineBool "EXPAT_BUILD_TOOLS", false + c.defineBool "EXPAT_BUILD_PKGCONFIG", false + c.defineBool "EXPAT_DTD", false + c.defineBool "EXPAT_GE", geOn + c.defineBool "EXPAT_LARGE_SIZE", largeSizeOn + c.defineBool "EXPAT_MIN_SIZE", minSizeOn + c.defineBool "EXPAT_NS", nsOn + + err = c.configure() + if err != nil { + out.addErr err + return + } + err = c.build() + if err != nil { + out.addErr err + return + } + err = c.install() + if err != nil { + out.addErr err + return + } + + out.setMetadata "-lexpat" +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + testDir := ctx.SourceDir + "/_ontest" + err = exec("mkdir", "-p", testDir) + if err != nil { + out.addErr err + return + } + + script := "set -eu\n" + + "test -f \"$1/lib/libexpat.a\"\n" + + "test -f \"$1/include/expat.h\"\n" + + "cat > \"$3/expat_feature_test.c\" <<'EOF'\n" + + "#include \n" + + "#include \n" + + "static int has_feature(enum XML_FeatureEnum want) {\n" + + " const XML_Feature *features = XML_GetFeatureList();\n" + + " for (; features->feature != XML_FEATURE_END; ++features) {\n" + + " if (features->feature == want) return 1;\n" + + " }\n" + + " return 0;\n" + + "}\n" + + "int main(int argc, char **argv) {\n" + + " const char *combo = argv[1];\n" + + " const int have_ge = has_feature(XML_FEATURE_GE);\n" + + " const int have_large_size = has_feature(XML_FEATURE_LARGE_SIZE);\n" + + " const int have_min_size = has_feature(XML_FEATURE_MIN_SIZE);\n" + + " const int have_ns = has_feature(XML_FEATURE_NS);\n" + + " if (have_ge != (strstr(combo, \"ge-on\") != 0)) return 1;\n" + + " if (have_large_size != (strstr(combo, \"large_size-on\") != 0)) return 2;\n" + + " if (have_min_size != (strstr(combo, \"min_size-on\") != 0)) return 3;\n" + + " if (have_ns != (strstr(combo, \"ns-on\") != 0)) return 4;\n" + + " return 0;\n" + + "}\n" + + "EOF\n" + + "cc -I\"$1/include\" \"$3/expat_feature_test.c\" -L\"$1/lib\" -lexpat -o \"$3/expat_feature_test\"\n" + + "\"$3/expat_feature_test\" \"$2\"\n" + err = exec("sh", "-c", script, "sh", installDir, combo, testDir) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/libexpat/libexpat/versions.json b/internal/build/testdata/formulas/libexpat/libexpat/versions.json new file mode 100644 index 0000000..ced64f3 --- /dev/null +++ b/internal/build/testdata/formulas/libexpat/libexpat/versions.json @@ -0,0 +1,4 @@ +{ + "path": "libexpat/libexpat", + "deps": {} +} diff --git a/internal/build/testdata/formulas/libjpeg-turbo/libjpeg-turbo/1.0.0/Libjpegturbo_llar.gox b/internal/build/testdata/formulas/libjpeg-turbo/libjpeg-turbo/1.0.0/Libjpegturbo_llar.gox new file mode 100644 index 0000000..9fa3f8b --- /dev/null +++ b/internal/build/testdata/formulas/libjpeg-turbo/libjpeg-turbo/1.0.0/Libjpegturbo_llar.gox @@ -0,0 +1,82 @@ +import "strings" + +id "libjpeg-turbo/libjpeg-turbo" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + arithDecOn := strings.contains(combo, "arithdec-on") + arithEncOn := strings.contains(combo, "arithenc-on") + toolsOn := strings.contains(combo, "tools-on") + + c := cmake.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + c.buildType "Release" + c.defineBool "ENABLE_SHARED", false + c.defineBool "ENABLE_STATIC", true + c.defineBool "WITH_SIMD", false + c.defineBool "WITH_ARITH_DEC", arithDecOn + c.defineBool "WITH_ARITH_ENC", arithEncOn + c.defineBool "WITH_TURBOJPEG", false + c.defineBool "WITH_JAVA", false + c.defineBool "WITH_TOOLS", toolsOn + c.defineBool "WITH_JPEG7", false + c.defineBool "WITH_JPEG8", false + + err = c.configure() + if err != nil { + out.addErr err + return + } + err = c.build() + if err != nil { + out.addErr err + return + } + err = c.install() + if err != nil { + out.addErr err + return + } + + out.setMetadata "-ljpeg" +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + script := "set -eu\n" + + "test -f \"$1/lib/libjpeg.a\"\n" + + "test -f \"$1/include/jconfig.h\"\n" + + "if echo \"$2\" | grep -q 'arithdec-on'; then\n" + + " grep -q '^#define D_ARITH_CODING_SUPPORTED' \"$1/include/jconfig.h\"\n" + + "else\n" + + " if grep -q '^#define D_ARITH_CODING_SUPPORTED' \"$1/include/jconfig.h\"; then exit 1; fi\n" + + "fi\n" + + "if echo \"$2\" | grep -q 'arithenc-on'; then\n" + + " grep -q '^#define C_ARITH_CODING_SUPPORTED' \"$1/include/jconfig.h\"\n" + + "else\n" + + " if grep -q '^#define C_ARITH_CODING_SUPPORTED' \"$1/include/jconfig.h\"; then exit 1; fi\n" + + "fi\n" + + "if echo \"$2\" | grep -q 'tools-on'; then\n" + + " test -f \"$1/bin/cjpeg\"\n" + + "else\n" + + " test ! -e \"$1/bin/cjpeg\"\n" + + "fi\n" + err = exec("sh", "-c", script, "sh", installDir, combo) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/libjpeg-turbo/libjpeg-turbo/versions.json b/internal/build/testdata/formulas/libjpeg-turbo/libjpeg-turbo/versions.json new file mode 100644 index 0000000..fea7cb7 --- /dev/null +++ b/internal/build/testdata/formulas/libjpeg-turbo/libjpeg-turbo/versions.json @@ -0,0 +1,4 @@ +{ + "path": "libjpeg-turbo/libjpeg-turbo", + "deps": {} +} diff --git a/internal/build/testdata/formulas/libsdl-org/libtiff/1.0.0/Libtiff_llar.gox b/internal/build/testdata/formulas/libsdl-org/libtiff/1.0.0/Libtiff_llar.gox new file mode 100644 index 0000000..fe2902b --- /dev/null +++ b/internal/build/testdata/formulas/libsdl-org/libtiff/1.0.0/Libtiff_llar.gox @@ -0,0 +1,90 @@ +import "strings" + +id "libsdl-org/libtiff" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + cxxOn := strings.contains(combo, "cxx-on") + toolsOn := strings.contains(combo, "tools-on") + + c := cmake.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + c.buildType "Release" + c.defineBool "BUILD_SHARED_LIBS", false + c.defineBool "tiff-static", true + c.defineBool "tiff-tools", toolsOn + c.defineBool "tiff-tests", false + c.defineBool "tiff-contrib", false + c.defineBool "tiff-docs", false + c.defineBool "tiff-install", true + c.defineBool "tiff-cxx", cxxOn + c.defineBool "zlib", false + c.defineBool "libdeflate", false + c.defineBool "jpeg", false + c.defineBool "jpeg12", false + c.defineBool "old-jpeg", false + c.defineBool "jbig", false + c.defineBool "lerc", false + c.defineBool "lzma", false + c.defineBool "pixarlog", false + c.defineBool "webp", false + c.defineBool "zstd", false + c.defineBool "tiff-opengl", false + + err = c.configure() + if err != nil { + out.addErr err + return + } + err = c.build() + if err != nil { + out.addErr err + return + } + err = c.install() + if err != nil { + out.addErr err + return + } + + meta := "-ltiff" + if cxxOn { + meta = "-ltiffxx " + meta + } + out.setMetadata meta +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + script := "set -eu\n" + + "test -f \"$1/lib/libtiff.a\"\n" + + "test -f \"$1/include/tiffio.h\"\n" + + "if echo \"$2\" | grep -q 'cxx-on'; then\n" + + " test -f \"$1/lib/libtiffxx.a\"\n" + + "else\n" + + " test ! -e \"$1/lib/libtiffxx.a\"\n" + + "fi\n" + + "if echo \"$2\" | grep -q 'tools-on'; then\n" + + " find \"$1/bin\" -maxdepth 1 -type f -name 'tiff*' | grep -q .\n" + + "else\n" + + " if test -d \"$1/bin\" && find \"$1/bin\" -maxdepth 1 -type f -name 'tiff*' | grep -q .; then exit 1; fi\n" + + "fi\n" + err = exec("sh", "-c", script, "sh", installDir, combo) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/libsdl-org/libtiff/versions.json b/internal/build/testdata/formulas/libsdl-org/libtiff/versions.json new file mode 100644 index 0000000..a9cf657 --- /dev/null +++ b/internal/build/testdata/formulas/libsdl-org/libtiff/versions.json @@ -0,0 +1 @@ +{"1.0.0":"4.7.1"} diff --git a/internal/build/testdata/formulas/opencv/opencv/1.0.0/Opencv_llar.gox b/internal/build/testdata/formulas/opencv/opencv/1.0.0/Opencv_llar.gox new file mode 100644 index 0000000..a6251c5 --- /dev/null +++ b/internal/build/testdata/formulas/opencv/opencv/1.0.0/Opencv_llar.gox @@ -0,0 +1,93 @@ +import "strings" + +id "opencv/opencv" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + calib3dOn := strings.contains(combo, "calib3d-on") + dnnOn := strings.contains(combo, "dnn-on") + + buildList := "core,imgproc" + if calib3dOn { + buildList += ",calib3d" + } + if dnnOn { + buildList += ",dnn" + } + + c := cmake.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + c.buildType "Release" + c.defineBool "BUILD_SHARED_LIBS", false + c.defineBool "BUILD_TESTS", false + c.defineBool "BUILD_PERF_TESTS", false + c.defineBool "BUILD_EXAMPLES", false + c.defineBool "BUILD_opencv_apps", false + c.defineBool "WITH_FFMPEG", false + c.defineBool "WITH_JPEG", false + c.defineBool "WITH_PNG", false + c.defineBool "WITH_TIFF", false + c.defineBool "WITH_WEBP", false + c.defineBool "WITH_OPENEXR", false + c.define "BUILD_LIST", buildList + + err = c.configure() + if err != nil { + out.addErr err + return + } + err = c.build() + if err != nil { + out.addErr err + return + } + err = c.install() + if err != nil { + out.addErr err + return + } + + meta := "-lopencv_core -lopencv_imgproc" + if calib3dOn { + meta += " -lopencv_calib3d" + } + if dnnOn { + meta += " -lopencv_dnn" + } + out.setMetadata meta +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + script := "set -eu\n" + + "test -f \"$1/lib/libopencv_core.a\"\n" + + "test -f \"$1/lib/libopencv_imgproc.a\"\n" + + "if echo \"$2\" | grep -q 'calib3d-on'; then\n" + + " test -f \"$1/lib/libopencv_calib3d.a\"\n" + + "else\n" + + " test ! -e \"$1/lib/libopencv_calib3d.a\"\n" + + "fi\n" + + "if echo \"$2\" | grep -q 'dnn-on'; then\n" + + " test -f \"$1/lib/libopencv_dnn.a\"\n" + + "else\n" + + " test ! -e \"$1/lib/libopencv_dnn.a\"\n" + + "fi\n" + err = exec("sh", "-c", script, "sh", installDir, combo) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/opencv/opencv/versions.json b/internal/build/testdata/formulas/opencv/opencv/versions.json new file mode 100644 index 0000000..517df36 --- /dev/null +++ b/internal/build/testdata/formulas/opencv/opencv/versions.json @@ -0,0 +1,4 @@ +{ + "path": "opencv/opencv", + "deps": {} +} diff --git a/internal/build/testdata/formulas/openssl/openssl/1.0.0/Openssl_llar.gox b/internal/build/testdata/formulas/openssl/openssl/1.0.0/Openssl_llar.gox new file mode 100644 index 0000000..05425ed --- /dev/null +++ b/internal/build/testdata/formulas/openssl/openssl/1.0.0/Openssl_llar.gox @@ -0,0 +1,95 @@ +import "fmt" +import "runtime" +import "strings" + +id "openssl/openssl" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + asmOn := strings.contains(combo, "asm-on") + zlibOn := strings.contains(combo, "zlib-on") + + target := "" + if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" { + target = "linux-x86_64" + } else if runtime.GOOS == "linux" && runtime.GOARCH == "arm64" { + target = "linux-aarch64" + } else { + out.addErr fmt.errorf("unsupported OpenSSL host %s/%s", runtime.GOOS, runtime.GOARCH) + return + } + + script := "cd \"$1\" && perl ./Configure " + target + " no-shared no-tests --prefix=\"$2\"" + if !asmOn { + script += " no-asm" + } + if zlibOn { + script += " zlib --with-zlib-include=\"$3/include\" --with-zlib-lib=\"$3/lib\"" + } + script += " && make -j2 && make install_sw" + + if zlibOn { + depDir := "" + for _, dep := range proj.Deps { + if dep.Path != "madler/zlib" { + continue + } + depDir, err = ctx.outputDir(dep) + if err != nil { + out.addErr err + return + } + break + } + if depDir == "" { + out.addErr fmt.errorf("zlib-on requires madler/zlib dependency") + return + } + exec "sh", "-c", script, "sh", ctx.SourceDir, installDir, depDir + } else { + exec "sh", "-c", script, "sh", ctx.SourceDir, installDir + } + if lastErr != nil { + out.addErr lastErr + return + } + + out.setMetadata "-lssl -lcrypto" +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + script := "set -eu\n" + + "test -f \"$1/lib/libssl.a\"\n" + + "test -f \"$1/lib/libcrypto.a\"\n" + + "test -f \"$1/include/openssl/ssl.h\"\n" + + "if echo \"$2\" | grep -q 'asm-on'; then\n" + + " if grep -R -q 'OPENSSL_NO_ASM' \"$1/include/openssl\"; then exit 1; fi\n" + + "else\n" + + " grep -R -q 'OPENSSL_NO_ASM' \"$1/include/openssl\"\n" + + "fi\n" + + "if echo \"$2\" | grep -q 'zlib-on'; then\n" + + " if grep -R -q 'OPENSSL_NO_ZLIB' \"$1/include/openssl\"; then exit 1; fi\n" + + "else\n" + + " grep -R -q 'OPENSSL_NO_ZLIB' \"$1/include/openssl\"\n" + + "fi\n" + err = exec("sh", "-c", script, "sh", installDir, combo) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/openssl/openssl/versions.json b/internal/build/testdata/formulas/openssl/openssl/versions.json new file mode 100644 index 0000000..212fe34 --- /dev/null +++ b/internal/build/testdata/formulas/openssl/openssl/versions.json @@ -0,0 +1,11 @@ +{ + "path": "openssl/openssl", + "deps": { + "openssl-3.6.1": [ + {"path": "madler/zlib", "version": "v1.2.11"} + ], + "1.0.0": [ + {"path": "madler/zlib", "version": "v1.2.11"} + ] + } +} diff --git a/internal/build/testdata/formulas/pocoproject/poco/1.0.0/Poco_llar.gox b/internal/build/testdata/formulas/pocoproject/poco/1.0.0/Poco_llar.gox new file mode 100644 index 0000000..2e29d3c --- /dev/null +++ b/internal/build/testdata/formulas/pocoproject/poco/1.0.0/Poco_llar.gox @@ -0,0 +1,118 @@ +import "strings" + +id "pocoproject/poco" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + encodingsOn := strings.contains(combo, "encodings-on") + jsonOn := strings.contains(combo, "json-on") + netOn := strings.contains(combo, "net-on") + xmlOn := strings.contains(combo, "xml-on") + + c := cmake.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + c.buildType "Release" + c.defineBool "BUILD_SHARED_LIBS", false + c.defineBool "POCO_UNBUNDLED", false + c.defineBool "ENABLE_FOUNDATION", true + c.defineBool "ENABLE_ENCODINGS", encodingsOn + c.defineBool "ENABLE_JSON", jsonOn + c.defineBool "ENABLE_XML", xmlOn + c.defineBool "ENABLE_UTIL", false + c.defineBool "ENABLE_NET", netOn + c.defineBool "ENABLE_CRYPTO", false + c.defineBool "ENABLE_NETSSL", false + c.defineBool "ENABLE_NETSSL_WIN", false + c.defineBool "ENABLE_JWT", false + c.defineBool "ENABLE_DATA", false + c.defineBool "ENABLE_DATA_SQLITE", false + c.defineBool "ENABLE_MONGODB", false + c.defineBool "ENABLE_REDIS", false + c.defineBool "ENABLE_PROMETHEUS", false + c.defineBool "ENABLE_ZIP", false + c.defineBool "ENABLE_PDF", false + c.defineBool "ENABLE_PAGECOMPILER", false + c.defineBool "ENABLE_PAGECOMPILER_FILE2PAGE", false + c.defineBool "ENABLE_ACTIVERECORD", false + c.defineBool "ENABLE_ACTIVERECORD_COMPILER", false + c.defineBool "ENABLE_POCODOC", false + c.defineBool "ENABLE_CPPPARSER", false + c.defineBool "ENABLE_SAMPLES", false + c.defineBool "ENABLE_TESTS", false + c.defineBool "ENABLE_FUZZING", false + + err = c.configure() + if err != nil { + out.addErr err + return + } + err = c.build() + if err != nil { + out.addErr err + return + } + err = c.install() + if err != nil { + out.addErr err + return + } + + meta := "-lPocoFoundation" + if encodingsOn { + meta += " -lPocoEncodings" + } + if jsonOn { + meta += " -lPocoJSON" + } + if netOn { + meta += " -lPocoNet" + } + if xmlOn { + meta += " -lPocoXML" + } + out.setMetadata meta +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + script := "set -eu\n" + + "test -f \"$1/lib/libPocoFoundation.a\"\n" + + "if echo \"$2\" | grep -q 'encodings-on'; then\n" + + " test -f \"$1/lib/libPocoEncodings.a\"\n" + + "else\n" + + " test ! -e \"$1/lib/libPocoEncodings.a\"\n" + + "fi\n" + + "if echo \"$2\" | grep -q 'json-on'; then\n" + + " test -f \"$1/lib/libPocoJSON.a\"\n" + + "else\n" + + " test ! -e \"$1/lib/libPocoJSON.a\"\n" + + "fi\n" + + "if echo \"$2\" | grep -q 'net-on'; then\n" + + " test -f \"$1/lib/libPocoNet.a\"\n" + + "else\n" + + " test ! -e \"$1/lib/libPocoNet.a\"\n" + + "fi\n" + + "if echo \"$2\" | grep -q 'xml-on'; then\n" + + " test -f \"$1/lib/libPocoXML.a\"\n" + + "else\n" + + " test ! -e \"$1/lib/libPocoXML.a\"\n" + + "fi\n" + err = exec("sh", "-c", script, "sh", installDir, combo) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/pocoproject/poco/versions.json b/internal/build/testdata/formulas/pocoproject/poco/versions.json new file mode 100644 index 0000000..9cd6f93 --- /dev/null +++ b/internal/build/testdata/formulas/pocoproject/poco/versions.json @@ -0,0 +1,4 @@ +{ + "path": "pocoproject/poco", + "deps": {} +} diff --git a/internal/build/testdata/formulas/sqlite/sqlite/1.0.0/Sqlite_llar.gox b/internal/build/testdata/formulas/sqlite/sqlite/1.0.0/Sqlite_llar.gox new file mode 100644 index 0000000..6e84795 --- /dev/null +++ b/internal/build/testdata/formulas/sqlite/sqlite/1.0.0/Sqlite_llar.gox @@ -0,0 +1,112 @@ +import "strings" + +id "sqlite/sqlite" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + buildDir := ctx.SourceDir + "/_build" + err = exec("mkdir", "-p", buildDir, installDir+"/include", installDir+"/lib") + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + cflags := "-O2 -DSQLITE_THREADSAFE=1" + if strings.contains(combo, "dbstat-on") { + cflags += " -DSQLITE_ENABLE_DBSTAT_VTAB" + } + if strings.contains(combo, "json1-on") { + cflags += " -DSQLITE_ENABLE_JSON1" + } + if strings.contains(combo, "rtree-on") { + cflags += " -DSQLITE_ENABLE_RTREE" + } + if strings.contains(combo, "soundex-on") { + cflags += " -DSQLITE_SOUNDEX" + } + + err = exec("sh", "-c", "cc "+cflags+" -c "+ctx.SourceDir+"/sqlite3.c -o "+buildDir+"/sqlite3.o") + if err != nil { + out.addErr err + return + } + err = exec("ar", "rcs", buildDir+"/libsqlite3.a", buildDir+"/sqlite3.o") + if err != nil { + out.addErr err + return + } + err = exec("cp", ctx.SourceDir+"/sqlite3.h", installDir+"/include/") + if err != nil { + out.addErr err + return + } + err = exec("cp", ctx.SourceDir+"/sqlite3ext.h", installDir+"/include/") + if err != nil { + out.addErr err + return + } + err = exec("cp", buildDir+"/libsqlite3.a", installDir+"/lib/") + if err != nil { + out.addErr err + return + } + + out.setMetadata "-lsqlite3" +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + testDir := ctx.SourceDir + "/_ontest" + err = exec("mkdir", "-p", testDir) + if err != nil { + out.addErr err + return + } + + script := "set -eu\n" + + "test -f \"$1/lib/libsqlite3.a\"\n" + + "test -f \"$1/include/sqlite3.h\"\n" + + "cat > \"$3/sqlite_feature_test.c\" <<'EOF'\n" + + "#include \n" + + "#include \n" + + "#include \n" + + "static int ok(sqlite3 *db, const char *sql) {\n" + + " char *errmsg = 0;\n" + + " int rc = sqlite3_exec(db, sql, 0, 0, &errmsg);\n" + + " if (errmsg != 0) sqlite3_free(errmsg);\n" + + " return rc == SQLITE_OK;\n" + + "}\n" + + "int main(int argc, char **argv) {\n" + + " const char *combo = argv[1];\n" + + " sqlite3 *db = 0;\n" + + " if (sqlite3_open(\":memory:\", &db) != SQLITE_OK) return 10;\n" + + " if (ok(db, \"SELECT json('{\\\"a\\\":1}');\") != (strstr(combo, \"json1-on\") != 0)) return 11;\n" + + " if (ok(db, \"CREATE VIRTUAL TABLE temp.rt USING rtree(id, minX, maxX, minY, maxY);\") != (strstr(combo, \"rtree-on\") != 0)) return 12;\n" + + " if (ok(db, \"SELECT soundex('test');\") != (strstr(combo, \"soundex-on\") != 0)) return 13;\n" + + " if (ok(db, \"CREATE VIRTUAL TABLE temp.stat USING dbstat(main);\") != (strstr(combo, \"dbstat-on\") != 0)) return 14;\n" + + " sqlite3_close(db);\n" + + " return 0;\n" + + "}\n" + + "EOF\n" + + "cc -I\"$1/include\" \"$3/sqlite_feature_test.c\" -L\"$1/lib\" -lsqlite3 -o \"$3/sqlite_feature_test\"\n" + + "\"$3/sqlite_feature_test\" \"$2\"\n" + err = exec("sh", "-c", script, "sh", installDir, combo, testDir) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/sqlite/sqlite/versions.json b/internal/build/testdata/formulas/sqlite/sqlite/versions.json new file mode 100644 index 0000000..91c5a95 --- /dev/null +++ b/internal/build/testdata/formulas/sqlite/sqlite/versions.json @@ -0,0 +1,4 @@ +{ + "path": "sqlite/sqlite", + "deps": {} +} diff --git a/internal/build/testdata/formulas/test/mergedtest/1.0.0/Mergedtest_llar.gox b/internal/build/testdata/formulas/test/mergedtest/1.0.0/Mergedtest_llar.gox new file mode 100644 index 0000000..62a9268 --- /dev/null +++ b/internal/build/testdata/formulas/test/mergedtest/1.0.0/Mergedtest_llar.gox @@ -0,0 +1,49 @@ +import "strings" + +id "test/mergedtest" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + script := "mkdir -p \"$1/include\" && printf '#define BASE 1\\n' > \"$1/include/base.h\"" + if strings.contains(combo, "a-on") { + script += " && printf '#define A 1\\n' > \"$1/include/a.h\"" + } + if strings.contains(combo, "b-on") { + script += " && printf '#define B 1\\n' > \"$1/include/b.h\"" + } + + exec "sh", "-c", script, "sh", installDir + if lastErr != nil { + out.addErr lastErr + } +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + script := "test -f \"$1/include/base.h\"" + if strings.contains(combo, "a-on") { + script += " && test -f \"$1/include/a.h\"" + } + if strings.contains(combo, "b-on") { + script += " && test -f \"$1/include/b.h\"" + } + + exec "sh", "-c", script, "sh", installDir + if lastErr != nil { + out.addErr lastErr + } +} diff --git a/internal/build/testdata/formulas/test/mergedtest/versions.json b/internal/build/testdata/formulas/test/mergedtest/versions.json new file mode 100644 index 0000000..280f949 --- /dev/null +++ b/internal/build/testdata/formulas/test/mergedtest/versions.json @@ -0,0 +1,4 @@ +{ + "path": "test/mergedtest", + "deps": {} +} diff --git a/internal/build/testdata/formulas/test/tracecmake/1.0.0/Tracecmake_llar.gox b/internal/build/testdata/formulas/test/tracecmake/1.0.0/Tracecmake_llar.gox new file mode 100644 index 0000000..8675811 --- /dev/null +++ b/internal/build/testdata/formulas/test/tracecmake/1.0.0/Tracecmake_llar.gox @@ -0,0 +1,33 @@ +id "test/tracecmake" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + c := cmake.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + c.buildType "Release" + c.define "CMAKE_POLICY_VERSION_MINIMUM", "3.5" + + err = c.configure() + if err != nil { + out.addErr err + return + } + err = c.build() + if err != nil { + out.addErr err + return + } + err = c.install() + if err != nil { + out.addErr err + return + } + + out.setMetadata "-ltracecore" +} diff --git a/internal/build/testdata/formulas/test/tracecmake/versions.json b/internal/build/testdata/formulas/test/tracecmake/versions.json new file mode 100644 index 0000000..f2ed1f1 --- /dev/null +++ b/internal/build/testdata/formulas/test/tracecmake/versions.json @@ -0,0 +1,4 @@ +{ + "path": "test/tracecmake", + "deps": {} +} diff --git a/internal/build/testdata/formulas/test/traceoptions/1.0.0/Traceoptions_llar.gox b/internal/build/testdata/formulas/test/traceoptions/1.0.0/Traceoptions_llar.gox new file mode 100644 index 0000000..61d619e --- /dev/null +++ b/internal/build/testdata/formulas/test/traceoptions/1.0.0/Traceoptions_llar.gox @@ -0,0 +1,43 @@ +import "strings" + +id "test/traceoptions" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + apiOn := strings.contains(combo, "api-on") + cliOn := strings.contains(combo, "cli-on") + shipOn := strings.contains(combo, "ship-on") + + c := cmake.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + c.buildType "Release" + c.define "CMAKE_POLICY_VERSION_MINIMUM", "3.5" + c.defineBool "TRACE_FEATURE_API", apiOn + c.defineBool "TRACE_BUILD_CLI", cliOn + c.defineBool "TRACE_INSTALL_ALIAS", shipOn + + err = c.configure() + if err != nil { + out.addErr err + return + } + err = c.build() + if err != nil { + out.addErr err + return + } + err = c.install() + if err != nil { + out.addErr err + return + } + + out.setMetadata "-ltracecore" +} diff --git a/internal/build/testdata/formulas/test/traceoptions/versions.json b/internal/build/testdata/formulas/test/traceoptions/versions.json new file mode 100644 index 0000000..0b79f6b --- /dev/null +++ b/internal/build/testdata/formulas/test/traceoptions/versions.json @@ -0,0 +1,4 @@ +{ + "path": "test/traceoptions", + "deps": {} +} diff --git a/internal/build/testdata/formulas/uriparser/uriparser/1.0.0/Uriparser_llar.gox b/internal/build/testdata/formulas/uriparser/uriparser/1.0.0/Uriparser_llar.gox new file mode 100644 index 0000000..0fbdf1b --- /dev/null +++ b/internal/build/testdata/formulas/uriparser/uriparser/1.0.0/Uriparser_llar.gox @@ -0,0 +1,93 @@ +import "strings" + +id "uriparser/uriparser" + +fromVer "0.9.8" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + toolsOn := strings.contains(combo, "tools-on") + wcharOn := strings.contains(combo, "wchar-on") + + c := cmake.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + c.buildType "Release" + c.defineBool "BUILD_SHARED_LIBS", false + c.defineBool "URIPARSER_SHARED_LIBS", false + c.defineBool "URIPARSER_BUILD_DOCS", false + c.defineBool "URIPARSER_BUILD_TESTS", false + c.defineBool "URIPARSER_BUILD_TOOLS", toolsOn + c.defineBool "URIPARSER_BUILD_CHAR", true + c.defineBool "URIPARSER_BUILD_WCHAR_T", wcharOn + c.defineBool "URIPARSER_ENABLE_INSTALL", true + + err = c.configure() + if err != nil { + out.addErr err + return + } + err = c.build() + if err != nil { + out.addErr err + return + } + err = c.install() + if err != nil { + out.addErr err + return + } + + out.setMetadata "-luriparser" +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + testDir := ctx.SourceDir + "/_ontest" + err = exec("mkdir", "-p", testDir) + if err != nil { + out.addErr err + return + } + + script := "set -eu\n" + + "test -f \"$1/lib/liburiparser.a\"\n" + + "test -f \"$1/include/uriparser/Uri.h\"\n" + + "if echo \"$2\" | grep -q 'tools-on'; then\n" + + " test -f \"$1/bin/uriparse\"\n" + + "else\n" + + " test ! -e \"$1/bin/uriparse\"\n" + + "fi\n" + + "cat > \"$3/uriparser_wchar_test.c\" <<'EOF'\n" + + "#include \n" + + "#include \n" + + "int main(void) {\n" + + " UriUriW uri;\n" + + " const wchar_t *errorPos = 0;\n" + + " int rc = uriParseSingleUriW(&uri, L\"https://example.com/path\", &errorPos);\n" + + " if (rc == URI_SUCCESS) uriFreeUriMembersW(&uri);\n" + + " return rc == URI_SUCCESS ? 0 : 1;\n" + + "}\n" + + "EOF\n" + + "if echo \"$2\" | grep -q 'wchar-on'; then\n" + + " cc -I\"$1/include\" \"$3/uriparser_wchar_test.c\" -L\"$1/lib\" -luriparser -o \"$3/uriparser_wchar_test\"\n" + + " \"$3/uriparser_wchar_test\"\n" + + "else\n" + + " if cc -I\"$1/include\" \"$3/uriparser_wchar_test.c\" -L\"$1/lib\" -luriparser -o \"$3/uriparser_wchar_test\"; then exit 1; fi\n" + + "fi\n" + err = exec("sh", "-c", script, "sh", installDir, combo, testDir) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/uriparser/uriparser/versions.json b/internal/build/testdata/formulas/uriparser/uriparser/versions.json new file mode 100644 index 0000000..f55e294 --- /dev/null +++ b/internal/build/testdata/formulas/uriparser/uriparser/versions.json @@ -0,0 +1,6 @@ +{ + "path": "uriparser/uriparser", + "versions": { + "0.9.8": "1.0.0" + } +} diff --git a/internal/build/testdata/formulas/webmproject/libwebp/1.0.0/Libwebp_llar.gox b/internal/build/testdata/formulas/webmproject/libwebp/1.0.0/Libwebp_llar.gox new file mode 100644 index 0000000..efd41fe --- /dev/null +++ b/internal/build/testdata/formulas/webmproject/libwebp/1.0.0/Libwebp_llar.gox @@ -0,0 +1,87 @@ +import "strings" + +id "webmproject/libwebp" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + cwebpOn := strings.contains(combo, "cwebp-on") + muxOn := strings.contains(combo, "mux-on") + + c := cmake.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + c.buildType "Release" + c.defineBool "BUILD_SHARED_LIBS", false + c.defineBool "WEBP_BUILD_CWEBP", cwebpOn + c.defineBool "WEBP_BUILD_DWEBP", false + c.defineBool "WEBP_BUILD_IMG2WEBP", false + c.defineBool "WEBP_BUILD_GIF2WEBP", false + c.defineBool "WEBP_BUILD_VWEBP", false + c.defineBool "WEBP_BUILD_EXTRAS", false + c.defineBool "WEBP_BUILD_WEBPINFO", false + c.defineBool "WEBP_BUILD_LIBWEBPMUX", muxOn + c.defineBool "WEBP_BUILD_WEBPMUX", false + c.defineBool "WEBP_ENABLE_SIMD", false + c.defineBool "WEBP_NEAR_LOSSLESS", false + c.defineBool "WEBP_ENABLE_SWAP_16BIT_CSP", false + c.defineBool "CMAKE_DISABLE_FIND_PACKAGE_GIF", true + c.defineBool "CMAKE_DISABLE_FIND_PACKAGE_JPEG", true + c.defineBool "CMAKE_DISABLE_FIND_PACKAGE_PNG", true + c.defineBool "CMAKE_DISABLE_FIND_PACKAGE_TIFF", true + + err = c.configure() + if err != nil { + out.addErr err + return + } + err = c.build() + if err != nil { + out.addErr err + return + } + err = c.install() + if err != nil { + out.addErr err + return + } + + meta := "-lwebp" + if muxOn { + meta += " -lwebpmux" + } + out.setMetadata meta +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + script := "set -eu\n" + + "test -f \"$1/lib/libwebp.a\"\n" + + "test -f \"$1/include/webp/decode.h\"\n" + + "if echo \"$2\" | grep -q 'cwebp-on'; then\n" + + " test -f \"$1/bin/cwebp\"\n" + + "else\n" + + " test ! -e \"$1/bin/cwebp\"\n" + + "fi\n" + + "if echo \"$2\" | grep -q 'mux-on'; then\n" + + " test -f \"$1/lib/libwebpmux.a\"\n" + + "else\n" + + " test ! -e \"$1/lib/libwebpmux.a\"\n" + + "fi\n" + err = exec("sh", "-c", script, "sh", installDir, combo) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/webmproject/libwebp/versions.json b/internal/build/testdata/formulas/webmproject/libwebp/versions.json new file mode 100644 index 0000000..49d53ea --- /dev/null +++ b/internal/build/testdata/formulas/webmproject/libwebp/versions.json @@ -0,0 +1,4 @@ +{ + "path": "webmproject/libwebp", + "deps": {} +} diff --git a/internal/build/testdata/formulas/zeux/pugixml/1.0.0/Pugixml_llar.gox b/internal/build/testdata/formulas/zeux/pugixml/1.0.0/Pugixml_llar.gox new file mode 100644 index 0000000..ff0d50c --- /dev/null +++ b/internal/build/testdata/formulas/zeux/pugixml/1.0.0/Pugixml_llar.gox @@ -0,0 +1,103 @@ +import "strings" + +id "zeux/pugixml" + +fromVer "1.0.0" + +onBuild (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + compactOn := strings.contains(combo, "compact-on") + noExceptionsOn := strings.contains(combo, "noexceptions-on") + noXPathOn := strings.contains(combo, "noxpath-on") + wcharOn := strings.contains(combo, "wchar-on") + + c := cmake.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + c.buildType "Release" + c.defineBool "BUILD_SHARED_LIBS", false + c.defineBool "PUGIXML_BUILD_SHARED_AND_STATIC_LIBS", false + c.defineBool "PUGIXML_BUILD_TESTS", false + c.defineBool "PUGIXML_INSTALL", true + c.defineBool "PUGIXML_COMPACT", compactOn + c.defineBool "PUGIXML_NO_EXCEPTIONS", noExceptionsOn + c.defineBool "PUGIXML_NO_XPATH", noXPathOn + c.defineBool "PUGIXML_WCHAR_MODE", wcharOn + + err = c.configure() + if err != nil { + out.addErr err + return + } + err = c.build() + if err != nil { + out.addErr err + return + } + err = c.install() + if err != nil { + out.addErr err + return + } + + out.setMetadata "-lpugixml" +} + +onTest (ctx, proj, out) => { + installDir, err := ctx.outputDir() + if err != nil { + out.addErr err + return + } + + combo := ctx.currentMatrix() + testDir := ctx.SourceDir + "/_ontest" + err = exec("mkdir", "-p", testDir) + if err != nil { + out.addErr err + return + } + + script := "set -eu\n" + + "test -f \"$1/lib/libpugixml.a\"\n" + + "test -f \"$1/include/pugixml.hpp\"\n" + + "test -f \"$1/include/pugiconfig.hpp\"\n" + + "if echo \"$2\" | grep -q 'compact-on'; then\n" + + " grep -q '^#define PUGIXML_COMPACT' \"$1/include/pugiconfig.hpp\"\n" + + "else\n" + + " if grep -q '^#define PUGIXML_COMPACT' \"$1/include/pugiconfig.hpp\"; then exit 1; fi\n" + + "fi\n" + + "if echo \"$2\" | grep -q 'noexceptions-on'; then\n" + + " grep -q '^#define PUGIXML_NO_EXCEPTIONS' \"$1/include/pugiconfig.hpp\"\n" + + "else\n" + + " if grep -q '^#define PUGIXML_NO_EXCEPTIONS' \"$1/include/pugiconfig.hpp\"; then exit 1; fi\n" + + "fi\n" + + "cat > \"$3/pugixml_xpath_test.cpp\" <<'EOF'\n" + + "#include \n" + + "int main() { pugi::xml_document doc; doc.load_string(\"\"); pugi::xpath_query q(\"/x\"); return q.return_type() == pugi::xpath_type_boolean; }\n" + + "EOF\n" + + "if echo \"$2\" | grep -q 'noxpath-on'; then\n" + + " if c++ -std=c++17 -I\"$1/include\" \"$3/pugixml_xpath_test.cpp\" -L\"$1/lib\" -lpugixml -o \"$3/pugixml_xpath_test\"; then exit 1; fi\n" + + "else\n" + + " c++ -std=c++17 -I\"$1/include\" \"$3/pugixml_xpath_test.cpp\" -L\"$1/lib\" -lpugixml -o \"$3/pugixml_xpath_test\"\n" + + "fi\n" + + "cat > \"$3/pugixml_wchar_test.cpp\" <<'EOF'\n" + + "#include \n" + + "int main() { pugi::xml_document doc; return doc.load_string(L\"\") ? 0 : 1; }\n" + + "EOF\n" + + "if echo \"$2\" | grep -q 'wchar-on'; then\n" + + " c++ -std=c++17 -I\"$1/include\" \"$3/pugixml_wchar_test.cpp\" -L\"$1/lib\" -lpugixml -o \"$3/pugixml_wchar_test\"\n" + + " \"$3/pugixml_wchar_test\"\n" + + "else\n" + + " if c++ -std=c++17 -I\"$1/include\" \"$3/pugixml_wchar_test.cpp\" -L\"$1/lib\" -lpugixml -o \"$3/pugixml_wchar_test\"; then exit 1; fi\n" + + "fi\n" + err = exec("sh", "-c", script, "sh", installDir, combo, testDir) + if err != nil { + out.addErr err + return + } +} diff --git a/internal/build/testdata/formulas/zeux/pugixml/versions.json b/internal/build/testdata/formulas/zeux/pugixml/versions.json new file mode 100644 index 0000000..b1df779 --- /dev/null +++ b/internal/build/testdata/formulas/zeux/pugixml/versions.json @@ -0,0 +1,4 @@ +{ + "path": "zeux/pugixml", + "deps": {} +} diff --git a/internal/build/testdata/sources/test/mergedtest/README.md b/internal/build/testdata/sources/test/mergedtest/README.md new file mode 100644 index 0000000..478eed0 --- /dev/null +++ b/internal/build/testdata/sources/test/mergedtest/README.md @@ -0,0 +1 @@ +merged test source diff --git a/internal/build/testdata/sources/test/tracecmake/CMakeLists.txt b/internal/build/testdata/sources/test/tracecmake/CMakeLists.txt new file mode 100644 index 0000000..27a4c55 --- /dev/null +++ b/internal/build/testdata/sources/test/tracecmake/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.16) +project(tracecmake C) + +include(CheckIncludeFile) +check_include_file("unistd.h" HAVE_UNISTD_H) + +configure_file(trace_config.h.in trace_config.h @ONLY) + +add_library(tracecore STATIC core.c) +target_include_directories(tracecore PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}") + +add_executable(tracecli cli.c) +target_link_libraries(tracecli PRIVATE tracecore) + +install(TARGETS tracecore tracecli + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib + RUNTIME DESTINATION bin) +install(FILES + "${CMAKE_CURRENT_SOURCE_DIR}/trace.h" + "${CMAKE_CURRENT_BINARY_DIR}/trace_config.h" + DESTINATION include) diff --git a/internal/build/testdata/sources/test/tracecmake/cli.c b/internal/build/testdata/sources/test/tracecmake/cli.c new file mode 100644 index 0000000..08a40d1 --- /dev/null +++ b/internal/build/testdata/sources/test/tracecmake/cli.c @@ -0,0 +1,5 @@ +#include "trace.h" + +int main(void) { + return trace_value() == 7 ? 0 : 1; +} diff --git a/internal/build/testdata/sources/test/tracecmake/core.c b/internal/build/testdata/sources/test/tracecmake/core.c new file mode 100644 index 0000000..3a47f91 --- /dev/null +++ b/internal/build/testdata/sources/test/tracecmake/core.c @@ -0,0 +1,10 @@ +#include "trace.h" +#include "trace_config.h" + +int trace_value(void) { +#ifdef HAVE_UNISTD_H + return 7; +#else + return 3; +#endif +} diff --git a/internal/build/testdata/sources/test/tracecmake/trace.h b/internal/build/testdata/sources/test/tracecmake/trace.h new file mode 100644 index 0000000..d437f69 --- /dev/null +++ b/internal/build/testdata/sources/test/tracecmake/trace.h @@ -0,0 +1,3 @@ +#pragma once + +int trace_value(void); diff --git a/internal/build/testdata/sources/test/tracecmake/trace_config.h.in b/internal/build/testdata/sources/test/tracecmake/trace_config.h.in new file mode 100644 index 0000000..7d0db17 --- /dev/null +++ b/internal/build/testdata/sources/test/tracecmake/trace_config.h.in @@ -0,0 +1 @@ +#cmakedefine HAVE_UNISTD_H 1 diff --git a/internal/build/testdata/sources/test/traceoptions/CMakeLists.txt b/internal/build/testdata/sources/test/traceoptions/CMakeLists.txt new file mode 100644 index 0000000..3b15845 --- /dev/null +++ b/internal/build/testdata/sources/test/traceoptions/CMakeLists.txt @@ -0,0 +1,33 @@ +cmake_minimum_required(VERSION 3.16) +project(traceoptions C) + +include(CheckIncludeFile) +check_include_file("unistd.h" HAVE_UNISTD_H) + +option(TRACE_FEATURE_API "toggle generated header consumed by compile" OFF) +option(TRACE_BUILD_CLI "build consumer executable" OFF) +option(TRACE_INSTALL_ALIAS "install extra delivery-only alias" OFF) + +configure_file(trace_options.h.in trace_options.h @ONLY) + +add_library(tracecore STATIC core.c) +target_include_directories(tracecore PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}") + +if(TRACE_BUILD_CLI) + add_executable(tracecli cli.c) + target_link_libraries(tracecli PRIVATE tracecore) +endif() + +install(TARGETS tracecore + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib) +if(TRACE_BUILD_CLI) + install(TARGETS tracecli RUNTIME DESTINATION bin) +endif() +install(FILES + "${CMAKE_CURRENT_SOURCE_DIR}/trace.h" + "${CMAKE_CURRENT_BINARY_DIR}/trace_options.h" + DESTINATION include) +if(TRACE_INSTALL_ALIAS) + install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/trace.h" DESTINATION include RENAME trace_alias.h) +endif() diff --git a/internal/build/testdata/sources/test/traceoptions/cli.c b/internal/build/testdata/sources/test/traceoptions/cli.c new file mode 100644 index 0000000..93050ec --- /dev/null +++ b/internal/build/testdata/sources/test/traceoptions/cli.c @@ -0,0 +1,5 @@ +#include "trace.h" + +int main(void) { + return trace_value() == 11 ? 0 : 1; +} diff --git a/internal/build/testdata/sources/test/traceoptions/core.c b/internal/build/testdata/sources/test/traceoptions/core.c new file mode 100644 index 0000000..4e34f92 --- /dev/null +++ b/internal/build/testdata/sources/test/traceoptions/core.c @@ -0,0 +1,12 @@ +#include "trace.h" +#include "trace_options.h" + +int trace_value(void) { +#if defined(HAVE_UNISTD_H) && defined(TRACE_FEATURE_API) + return 11; +#elif defined(HAVE_UNISTD_H) + return 7; +#else + return 3; +#endif +} diff --git a/internal/build/testdata/sources/test/traceoptions/trace.h b/internal/build/testdata/sources/test/traceoptions/trace.h new file mode 100644 index 0000000..d437f69 --- /dev/null +++ b/internal/build/testdata/sources/test/traceoptions/trace.h @@ -0,0 +1,3 @@ +#pragma once + +int trace_value(void); diff --git a/internal/build/testdata/sources/test/traceoptions/trace_options.h.in b/internal/build/testdata/sources/test/traceoptions/trace_options.h.in new file mode 100644 index 0000000..3786b55 --- /dev/null +++ b/internal/build/testdata/sources/test/traceoptions/trace_options.h.in @@ -0,0 +1,2 @@ +#cmakedefine HAVE_UNISTD_H 1 +#cmakedefine TRACE_FEATURE_API 1 diff --git a/internal/evaluator/debug_report.go b/internal/evaluator/debug_report.go new file mode 100644 index 0000000..a0dfe68 --- /dev/null +++ b/internal/evaluator/debug_report.go @@ -0,0 +1,131 @@ +package evaluator + +import ( + "strconv" + "strings" + + "github.com/goplus/llar/internal/trace" +) + +type DebugReport struct { + builder strings.Builder +} + +func (report *DebugReport) AddCombo(combo string, probe ProbeResult, opts DebugSummaryOptions) { + report.appendSection("COMBO " + combo + "\n" + debugSummaryProbe(probe, opts)) +} + +func (report *DebugReport) AddDiff(base, probe ProbeResult, opts DebugDiffSummaryOptions) { + report.appendSection(DebugDiffSummary(base, probe, opts)) +} + +func (report *DebugReport) AddCollision(base, left, right ProbeResult, opts DebugCollisionSummaryOptions) { + report.appendSection(DebugCollisionSummary(base, left, right, opts)) +} + +func (report *DebugReport) AddTraceMatches(probe ProbeResult, tokens []string, limit int) { + report.appendSection(DebugProbeTraceMatches(probe, tokens, limit)) +} + +func (report *DebugReport) AddSection(section string) { + report.appendSection(section) +} + +func (report *DebugReport) AddSynthesizedPair(observation SynthesizedPairObservation) { + report.appendSection(DebugSynthesizedPairSummary(observation)) +} + +func (report *DebugReport) String() string { + return report.builder.String() +} + +func (report *DebugReport) appendSection(section string) { + section = strings.TrimRight(section, "\n") + if section == "" { + return + } + if report.builder.Len() > 0 { + report.builder.WriteString("\n\n") + } + report.builder.WriteString(section) + report.builder.WriteByte('\n') +} + +func DebugTraceMatches(records []trace.Record, tokens []string, limit int) string { + if limit <= 0 { + limit = 8 + } + var b strings.Builder + b.WriteString("trace matches:\n") + + matched := 0 + for _, rec := range records { + found := false + for _, token := range tokens { + if token == "" { + continue + } + for _, arg := range rec.Argv { + if strings.Contains(arg, token) { + found = true + break + } + } + if found { + break + } + for _, path := range rec.Inputs { + if strings.Contains(path, token) { + found = true + break + } + } + if found { + break + } + for _, path := range rec.Changes { + if strings.Contains(path, token) { + found = true + break + } + } + if found { + break + } + } + if !found { + continue + } + b.WriteString(" argv: ") + b.WriteString(strings.Join(rec.Argv, " ")) + b.WriteByte('\n') + if rec.PID != 0 { + b.WriteString(" pid: ") + b.WriteString(strconv.FormatInt(rec.PID, 10)) + b.WriteByte('\n') + } + if rec.ParentPID != 0 { + b.WriteString(" ppid: ") + b.WriteString(strconv.FormatInt(rec.ParentPID, 10)) + b.WriteByte('\n') + } + if len(rec.Inputs) > 0 { + b.WriteString(" inputs: ") + b.WriteString(strings.Join(rec.Inputs, ", ")) + b.WriteByte('\n') + } + if len(rec.Changes) > 0 { + b.WriteString(" changes: ") + b.WriteString(strings.Join(rec.Changes, ", ")) + b.WriteByte('\n') + } + matched++ + if matched >= limit { + break + } + } + if matched == 0 { + b.WriteString(" absent\n") + } + return b.String() +} diff --git a/internal/evaluator/debug_summary.go b/internal/evaluator/debug_summary.go new file mode 100644 index 0000000..22ac041 --- /dev/null +++ b/internal/evaluator/debug_summary.go @@ -0,0 +1,890 @@ +package evaluator + +import ( + "maps" + "slices" + "strconv" + "strings" + + "github.com/goplus/llar/internal/trace" + tracessa "github.com/goplus/llar/internal/trace/ssa" +) + +type DebugSummaryOptions struct { + RoleSampleLimit int + InterestingLimit int + InterestingTokens []string + Scope trace.Scope +} + +type DebugDiffSummaryOptions struct { + BaseLabel string + ProbeLabel string + ActionSampleLimit int +} + +type DebugCollisionSummaryOptions struct { + BaseLabel string + LeftLabel string + RightLabel string + PathSampleLimit int + AllowMergeSurface bool +} + +func DebugSummary(records []trace.Record, opts DebugSummaryOptions) string { + graph := buildGraphWithScope(records, opts.Scope) + return formatGraphSummary(graph, opts) +} + +func debugSummaryProbe(probe ProbeResult, opts DebugSummaryOptions) string { + merged := opts + if merged.Scope.SourceRoot == "" && merged.Scope.BuildRoot == "" && merged.Scope.InstallRoot == "" && len(merged.Scope.KeepRoots) == 0 { + merged.Scope = probe.Scope + } + probe.Scope = merged.Scope + graph := buildGraphForProbe(probe) + return formatGraphSummary(graph, merged) +} + +func formatGraphSummary(graph tracessa.Graph, opts DebugSummaryOptions) string { + roles := tracessa.ProjectRoles(graph) + roleLimit := opts.RoleSampleLimit + if roleLimit <= 0 { + roleLimit = 12 + } + interestingLimit := opts.InterestingLimit + if interestingLimit <= 0 { + interestingLimit = 12 + } + + var b strings.Builder + b.WriteString("observations: source=") + b.WriteString(graph.Source.String()) + b.WriteString(", records=") + b.WriteString(strconv.Itoa(graph.Records)) + b.WriteString(", events=") + b.WriteString(strconv.Itoa(graph.Events)) + b.WriteString(" actions=") + b.WriteString(strconv.Itoa(len(graph.Actions))) + b.WriteByte('\n') + b.WriteString("action counts: ") + b.WriteString(formatActionSummary(graph, roles)) + b.WriteByte('\n') + b.WriteString("path role counts: ") + b.WriteString(formatPathRoleSummary(graph, roles)) + b.WriteByte('\n') + + writeRoleSection(&b, graph, roles, tracessa.RoleTooling, roleLimit) + writeRoleSection(&b, graph, roles, tracessa.RolePropagating, roleLimit) + writeRoleSection(&b, graph, roles, tracessa.RoleDelivery, roleLimit) + + for _, token := range opts.InterestingTokens { + writeInterestingSection(&b, graph, roles, token, interestingLimit) + } + return b.String() +} + +func DebugDiffSummary(base ProbeResult, probe ProbeResult, opts DebugDiffSummaryOptions) string { + analysis := tracessa.AnalyzeWithEvidence(tracessa.AnalysisInput{ + Base: tracessa.AnalysisSideInput{ + Records: base.Records, + Events: base.Events, + Scope: base.Scope, + InputDigests: base.InputDigests, + }, + Probe: tracessa.AnalysisSideInput{ + Records: probe.Records, + Events: probe.Events, + Scope: probe.Scope, + InputDigests: probe.InputDigests, + }, + }, buildImpactEvidence(base, probe)) + baseGraph := analysis.Debug.BaseGraph + probeGraph := analysis.Debug.ProbeGraph + profile := analysis.Profile + + sampleLimit := opts.ActionSampleLimit + if sampleLimit <= 0 { + sampleLimit = 8 + } + + var b strings.Builder + b.WriteString("match ") + if opts.BaseLabel != "" { + b.WriteString(opts.BaseLabel) + } else { + b.WriteString("base") + } + b.WriteString(" -> ") + if opts.ProbeLabel != "" { + b.WriteString(opts.ProbeLabel) + } else { + b.WriteString("probe") + } + b.WriteString(":\n") + b.WriteString(" actions: base=") + b.WriteString(strconv.Itoa(len(baseGraph.Actions))) + b.WriteString(", probe=") + b.WriteString(strconv.Itoa(len(probeGraph.Actions))) + b.WriteString(", matched=") + b.WriteString(strconv.Itoa(analysis.Debug.Wavefront.Matched)) + b.WriteString(", base-only=") + b.WriteString(strconv.Itoa(len(analysis.Debug.Wavefront.BaseOnly))) + b.WriteString(", probe-only=") + b.WriteString(strconv.Itoa(len(analysis.Debug.Wavefront.ProbeOnly))) + b.WriteByte('\n') + b.WriteString(" impact: affected-pairs=") + b.WriteString(strconv.Itoa(len(analysis.Debug.AffectedPairs))) + b.WriteString(", mutation-roots=") + b.WriteString(strconv.Itoa(len(analysis.Debug.RootProbe))) + b.WriteString(", flow-actions=") + b.WriteString(strconv.Itoa(len(analysis.Debug.FlowProbe))) + b.WriteString(", diverged-actions=") + b.WriteString(strconv.Itoa(len(analysis.Debug.DivergedProbe))) + b.WriteString(", frontier-actions=") + b.WriteString(strconv.Itoa(len(analysis.Debug.FrontierProbe))) + b.WriteString(", seed-writes=") + b.WriteString(strconv.Itoa(len(profile.SeedWrites))) + b.WriteString(", seed-states=") + b.WriteString(strconv.Itoa(len(profile.SeedStates))) + b.WriteString(", need-paths=") + b.WriteString(strconv.Itoa(len(profile.NeedPaths))) + b.WriteString(", need-states=") + b.WriteString(strconv.Itoa(len(profile.NeedStates))) + b.WriteString(", slice-paths=") + b.WriteString(strconv.Itoa(len(profile.SlicePaths))) + b.WriteString(", flow-states=") + b.WriteString(strconv.Itoa(len(profile.FlowStates))) + b.WriteString(", ambiguous=") + b.WriteString(strconv.FormatBool(profile.Ambiguous)) + b.WriteByte('\n') + + writeActionPairSamples(&b, "affected-pairs", baseGraph, probeGraph, analysis.Debug.AffectedPairs, sampleLimit) + writeActionSamples(&b, "mutation-roots", probeGraph, analysis.Debug.RootProbe, sampleLimit) + writeActionSamples(&b, "flow-actions", probeGraph, analysis.Debug.FlowProbe, sampleLimit) + writeActionSamples(&b, "diverged-actions", probeGraph, analysis.Debug.DivergedProbe, sampleLimit) + writeActionSamples(&b, "frontier-actions", probeGraph, analysis.Debug.FrontierProbe, sampleLimit) + writeActionSamples(&b, "base-only", baseGraph, analysis.Debug.Wavefront.BaseOnly, sampleLimit) + writeActionSamples(&b, "probe-only", probeGraph, analysis.Debug.Wavefront.ProbeOnly, sampleLimit) + writePathSamples(&b, "seed-writes", sampleMapKeys(profile.SeedWrites, sampleLimit)) + writePathSamples(&b, "seed-states", sampleStateKeys(profile.SeedStates, sampleLimit)) + writePathSamples(&b, "need-paths", sampleMapKeys(profile.NeedPaths, sampleLimit)) + writePathSamples(&b, "need-states", sampleStateKeys(profile.NeedStates, sampleLimit)) + writePathSamples(&b, "slice-paths", sampleMapKeys(profile.SlicePaths, sampleLimit)) + writePathSamples(&b, "flow-states", sampleStateKeys(profile.FlowStates, sampleLimit)) + return b.String() +} + +func DebugCollisionSummary(base ProbeResult, left ProbeResult, right ProbeResult, opts DebugCollisionSummaryOptions) string { + leftAnalysis := tracessa.AnalyzeWithEvidence(tracessa.AnalysisInput{ + Base: tracessa.AnalysisSideInput{ + Records: base.Records, + Events: base.Events, + Scope: base.Scope, + InputDigests: base.InputDigests, + }, + Probe: tracessa.AnalysisSideInput{ + Records: left.Records, + Events: left.Events, + Scope: left.Scope, + InputDigests: left.InputDigests, + }, + }, buildImpactEvidence(base, left)) + rightAnalysis := tracessa.AnalyzeWithEvidence(tracessa.AnalysisInput{ + Base: tracessa.AnalysisSideInput{ + Records: base.Records, + Events: base.Events, + Scope: base.Scope, + InputDigests: base.InputDigests, + }, + Probe: tracessa.AnalysisSideInput{ + Records: right.Records, + Events: right.Events, + Scope: right.Scope, + InputDigests: right.InputDigests, + }, + }, buildImpactEvidence(base, right)) + leftProfile := leftAnalysis.Profile + rightProfile := rightAnalysis.Profile + leftVariant := optionVariant{ + profile: leftProfile, + outputDiff: diffOutputManifest(base.OutputManifest, left.OutputManifest), + mergeSurfacePaths: mergeSurfacePaths(left.Scope, base.OutputManifest, left.OutputManifest), + } + rightVariant := optionVariant{ + profile: rightProfile, + outputDiff: diffOutputManifest(base.OutputManifest, right.OutputManifest), + mergeSurfacePaths: mergeSurfacePaths(right.Scope, base.OutputManifest, right.OutputManifest), + } + + limit := opts.PathSampleLimit + if limit <= 0 { + limit = 8 + } + + seed := sampleMapOverlap(leftProfile.SeedWrites, rightProfile.SeedWrites, limit) + seedStates := sampleStateOverlap(leftProfile.SeedStates, rightProfile.SeedStates, limit) + leftNeed := sampleMapOverlap(leftProfile.SlicePaths, rightProfile.NeedPaths, limit) + leftNeedStates := sampleStateOverlap(leftProfile.FlowStates, rightProfile.NeedStates, limit) + rightNeed := sampleMapOverlap(rightProfile.SlicePaths, leftProfile.NeedPaths, limit) + rightNeedStates := sampleStateOverlap(rightProfile.FlowStates, leftProfile.NeedStates, limit) + shared := sampleMapOverlap(leftProfile.SlicePaths, rightProfile.SlicePaths, limit*4) + onMerge, offMerge := partitionSharedPaths(shared, leftVariant.mergeSurfacePaths, rightVariant.mergeSurfacePaths, limit) + strictAssessment := assessOptionVariantCollision(leftVariant, rightVariant, false) + mergeAwareAssessment := assessOptionVariantCollision(leftVariant, rightVariant, true) + selectedAssessment := assessOptionVariantCollision(leftVariant, rightVariant, opts.AllowMergeSurface) + strict := strictAssessment.collide() + mergeAware := mergeAwareAssessment.collide() + selected := selectedAssessment.collide() + + var b strings.Builder + b.WriteString("collision ") + if opts.LeftLabel != "" { + b.WriteString(opts.LeftLabel) + } else { + b.WriteString("left") + } + b.WriteString(" vs ") + if opts.RightLabel != "" { + b.WriteString(opts.RightLabel) + } else { + b.WriteString("right") + } + b.WriteString(" (base=") + if opts.BaseLabel != "" { + b.WriteString(opts.BaseLabel) + } else { + b.WriteString("base") + } + b.WriteString("):\n") + b.WriteString(" collide=") + b.WriteString(strconv.FormatBool(selected)) + b.WriteByte('\n') + b.WriteString(" strict-collide=") + b.WriteString(strconv.FormatBool(strict)) + b.WriteString(", merge-aware-collide=") + b.WriteString(strconv.FormatBool(mergeAware)) + b.WriteByte('\n') + writePathSamples(&b, "strict-hazards", formatHazards(strictAssessment.hazards)) + writePathSamples(&b, "merge-aware-hazards", formatHazards(mergeAwareAssessment.hazards)) + writePathSamples(&b, "selected-hazards", formatHazards(selectedAssessment.hazards)) + b.WriteString(" ambiguous: left=") + b.WriteString(strconv.FormatBool(leftProfile.Ambiguous)) + b.WriteString(", right=") + b.WriteString(strconv.FormatBool(rightProfile.Ambiguous)) + b.WriteByte('\n') + writePathSamples(&b, "seed-overlap", seed) + writePathSamples(&b, "seed-state-overlap", seedStates) + writePathSamples(&b, "left-slice/right-need", leftNeed) + writePathSamples(&b, "left-flow/right-need-states", leftNeedStates) + writePathSamples(&b, "right-slice/left-need", rightNeed) + writePathSamples(&b, "right-flow/left-need-states", rightNeedStates) + writePathSamples(&b, "shared-slice/off-merge-surface", offMerge) + writePathSamples(&b, "shared-slice/on-merge-surface", onMerge) + return b.String() +} + +func DebugProbeTraceMatches(probe ProbeResult, tokens []string, limit int) string { + if len(probe.Events) > 0 { + return DebugTraceMatchesEvents(probe.Events, tokens, limit) + } + return DebugTraceMatches(probe.Records, tokens, limit) +} + +func DebugSynthesizedPairSummary(observation SynthesizedPairObservation) string { + var b strings.Builder + b.WriteString("synthesized pair ") + b.WriteString(observation.Combo) + b.WriteString(":\n") + b.WriteString(" mode=") + b.WriteString(string(observation.SynthesisResult.Mode)) + b.WriteByte('\n') + b.WriteString(" status=") + b.WriteString(string(observation.SynthesisResult.Status)) + b.WriteByte('\n') + b.WriteString(" clean=") + if observation.SynthesisResult.Clean() { + b.WriteString("true\n") + } else { + b.WriteString("false\n") + } + if observation.ValidationAttempted { + b.WriteString(" validated=") + if observation.Validated { + b.WriteString("true\n") + } else { + b.WriteString("false\n") + if observation.ValidationDetail != "" { + b.WriteString(" validation-detail: ") + b.WriteString(observation.ValidationDetail) + b.WriteByte('\n') + } + } + } + if observation.SynthesisResult.Replay != nil { + replay := observation.SynthesisResult.Replay + b.WriteString(" replay: candidates=") + b.WriteString(strconv.Itoa(replay.CandidateRoots)) + b.WriteString(", eligible=") + b.WriteString(strconv.Itoa(replay.EligibleRoots)) + b.WriteString(", changed=") + b.WriteString(strconv.Itoa(len(replay.ChangedRoots))) + b.WriteString(", selected=") + b.WriteString(strconv.Itoa(len(replay.SelectedRoots))) + b.WriteString(", selected-writes=") + b.WriteString(strconv.Itoa(replay.SelectedWrites)) + b.WriteByte('\n') + if replay.Unavailable != "" { + b.WriteString(" replay-unavailable: ") + b.WriteString(replay.Unavailable) + b.WriteByte('\n') + } + writeDebugList(&b, "replay-changed-roots", replay.ChangedRoots) + writeDebugList(&b, "replay-selected-roots", replay.SelectedRoots) + writeDebugList(&b, "replay-selected-commands", replay.SelectedCommands) + } + if len(observation.SynthesisResult.Issues) == 0 { + b.WriteString(" issues: none\n") + return b.String() + } + b.WriteString(" issues (") + b.WriteString(strconv.Itoa(len(observation.SynthesisResult.Issues))) + b.WriteString("):\n") + for _, issue := range observation.SynthesisResult.Issues { + b.WriteString(" ") + b.WriteString(issue.Path) + b.WriteString(" :: ") + b.WriteString(issue.Reason) + if issue.Kind != "" { + b.WriteString(" [") + b.WriteString(string(issue.Kind)) + b.WriteString("]") + } + b.WriteByte('\n') + if issue.Detail != "" { + lines := strings.Split(issue.Detail, "\n") + for i, line := range lines { + if i == 0 { + b.WriteString(" detail: ") + } else { + b.WriteString(" ") + } + b.WriteString(line) + b.WriteByte('\n') + } + } + if issue.Base != "" { + b.WriteString(" base: ") + b.WriteString(issue.Base) + b.WriteByte('\n') + } + if issue.Left != "" { + b.WriteString(" left: ") + b.WriteString(issue.Left) + b.WriteByte('\n') + } + if issue.Right != "" { + b.WriteString(" right: ") + b.WriteString(issue.Right) + b.WriteByte('\n') + } + } + return strings.TrimRight(b.String(), "\n") +} + +func writeDebugList(b *strings.Builder, label string, values []string) { + if len(values) == 0 { + return + } + b.WriteString(" ") + b.WriteString(label) + b.WriteString(":\n") + for _, value := range values { + b.WriteString(" ") + b.WriteString(value) + b.WriteByte('\n') + } +} + +func DebugMergedPairSummary(observation MergedPairObservation) string { + var b strings.Builder + b.WriteString("merged pair ") + b.WriteString(observation.Combo) + b.WriteString(":\n") + b.WriteString(" status=") + b.WriteString(string(observation.MergeResult.Status)) + b.WriteByte('\n') + b.WriteString(" clean=") + if observation.MergeResult.Clean() { + b.WriteString("true\n") + } else { + b.WriteString("false\n") + } + if observation.ValidationAttempted { + b.WriteString(" validated=") + if observation.Validated { + b.WriteString("true\n") + } else { + b.WriteString("false\n") + } + } + if len(observation.MergeResult.Issues) == 0 { + b.WriteString(" issues: none\n") + return b.String() + } + b.WriteString(" issues (") + b.WriteString(strconv.Itoa(len(observation.MergeResult.Issues))) + b.WriteString("):\n") + for _, issue := range observation.MergeResult.Issues { + b.WriteString(" ") + b.WriteString(issue.Path) + b.WriteString(" :: ") + b.WriteString(issue.Reason) + if issue.Kind != "" { + b.WriteString(" [") + b.WriteString(string(issue.Kind)) + b.WriteString("]") + } + b.WriteByte('\n') + if issue.Detail != "" { + lines := strings.Split(issue.Detail, "\n") + for i, line := range lines { + if i == 0 { + b.WriteString(" detail: ") + } else { + b.WriteString(" ") + } + b.WriteString(line) + b.WriteByte('\n') + } + } + if issue.Base != "" { + b.WriteString(" base: ") + b.WriteString(issue.Base) + b.WriteByte('\n') + } + if issue.Left != "" { + b.WriteString(" left: ") + b.WriteString(issue.Left) + b.WriteByte('\n') + } + if issue.Right != "" { + b.WriteString(" right: ") + b.WriteString(issue.Right) + b.WriteByte('\n') + } + } + return strings.TrimRight(b.String(), "\n") +} + +func formatActionSummary(graph tracessa.Graph, roles tracessa.RoleProjection) string { + counts := map[string]int{} + tooling := 0 + for i, action := range graph.Actions { + counts[action.Kind.String()]++ + if tracessa.RoleActionClass(roles, i) == tracessa.ActionRoleTooling { + tooling++ + } + } + + keys := make([]string, 0, len(counts)) + for key := range counts { + keys = append(keys, key) + } + slices.Sort(keys) + + parts := make([]string, 0, len(keys)+1) + for _, key := range keys { + parts = append(parts, key+"="+strconv.Itoa(counts[key])) + } + parts = append(parts, "tooling="+strconv.Itoa(tooling)) + return strings.Join(parts, ", ") +} + +func formatPathRoleSummary(graph tracessa.Graph, roles tracessa.RoleProjection) string { + counts := map[string]int{} + for path := range graph.Paths { + counts[debugPathRole(graph, roles, path).String()]++ + } + keys := []string{"tooling", "propagating", "delivery"} + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, key+"="+strconv.Itoa(counts[key])) + } + return strings.Join(parts, ", ") +} + +func writeRoleSection(b *strings.Builder, graph tracessa.Graph, roles tracessa.RoleProjection, role tracessa.PathRole, limit int) { + paths := samplePathsByRole(graph, roles, role, limit) + b.WriteString(role.String()) + b.WriteString(" sample (") + b.WriteString(strconv.Itoa(len(paths))) + b.WriteString("):\n") + for _, path := range paths { + b.WriteString(" ") + b.WriteString(path) + b.WriteByte('\n') + } +} + +func writeInterestingSection(b *strings.Builder, graph tracessa.Graph, roles tracessa.RoleProjection, token string, limit int) { + paths := sampleInterestingPaths(graph, roles, token, limit) + b.WriteString("match ") + b.WriteString(token) + b.WriteString(":\n") + if len(paths) == 0 { + b.WriteString(" absent\n") + return + } + for _, path := range paths { + b.WriteString(" ") + b.WriteString(path) + b.WriteByte('\n') + } +} + +func writeActionSamples(b *strings.Builder, label string, graph tracessa.Graph, indexes []int, limit int) { + b.WriteString(" ") + b.WriteString(label) + b.WriteString(" sample (") + if len(indexes) < limit { + b.WriteString(strconv.Itoa(len(indexes))) + } else { + b.WriteString(strconv.Itoa(limit)) + } + b.WriteString("):\n") + for _, idx := range sampleActionIndexes(graph, indexes, limit) { + action := graph.Actions[idx] + argv := action.Argv + if len(argv) > 4 { + argv = argv[:4] + } + skeleton := make([]string, 0, len(argv)) + for _, arg := range argv { + skeleton = append(skeleton, normalizeScopeToken(arg, trace.Scope{})) + } + b.WriteString(" ") + b.WriteString(action.ActionKey) + b.WriteString(" :: ") + b.WriteString(strings.Join(skeleton, " ")) + b.WriteByte('\n') + } +} + +func writeActionPairSamples(b *strings.Builder, label string, base, probe tracessa.Graph, pairs []tracessa.ActionPair, limit int) { + if limit <= 0 { + limit = 8 + } + b.WriteString(" ") + b.WriteString(label) + b.WriteString(" sample (") + if len(pairs) < limit { + b.WriteString(strconv.Itoa(len(pairs))) + } else { + b.WriteString(strconv.Itoa(limit)) + } + b.WriteString("):\n") + for _, pair := range sampleActionPairs(base, probe, pairs, limit) { + baseAction := base.Actions[pair.BaseIdx] + probeAction := probe.Actions[pair.ProbeIdx] + b.WriteString(" ") + b.WriteString(baseAction.ActionKey) + b.WriteString(" => ") + b.WriteString(probeAction.ActionKey) + b.WriteByte('\n') + } +} + +func writePathSamples(b *strings.Builder, label string, paths []string) { + b.WriteString(" ") + b.WriteString(label) + b.WriteString(" (") + b.WriteString(strconv.Itoa(len(paths))) + b.WriteString("):\n") + for _, path := range paths { + b.WriteString(" ") + b.WriteString(path) + b.WriteByte('\n') + } +} + +func samplePathsByRole(graph tracessa.Graph, roles tracessa.RoleProjection, role tracessa.PathRole, limit int) []string { + paths := make([]string, 0, len(graph.Paths)) + for path := range graph.Paths { + if debugPathRole(graph, roles, path) == role { + paths = append(paths, path) + } + } + slices.Sort(paths) + if len(paths) > limit { + paths = paths[:limit] + } + return paths +} + +func sampleInterestingPaths(graph tracessa.Graph, roles tracessa.RoleProjection, token string, limit int) []string { + paths := make([]string, 0, len(graph.Paths)) + for path := range graph.Paths { + if !strings.Contains(path, token) { + continue + } + paths = append(paths, path+" => "+debugPathRole(graph, roles, path).String()) + } + slices.Sort(paths) + if len(paths) > limit { + paths = paths[:limit] + } + return paths +} + +func debugPathRole(graph tracessa.Graph, roles tracessa.RoleProjection, path string) tracessa.PathRole { + if tracessa.PathLooksDelivery(graph, path) { + return tracessa.RoleDelivery + } + if !tracessa.ImpactPathAllowed(graph, roles, path) { + return tracessa.RoleTooling + } + return tracessa.RolePropagating +} + +func sampleActionIndexes(graph tracessa.Graph, indexes []int, limit int) []int { + if len(indexes) == 0 { + return nil + } + sorted := slices.Clone(indexes) + slices.SortFunc(sorted, func(leftIdx, rightIdx int) int { + left := graph.Actions[leftIdx] + right := graph.Actions[rightIdx] + if left.ActionKey != right.ActionKey { + if left.ActionKey < right.ActionKey { + return -1 + } + return 1 + } + leftArgv := strings.Join(left.Argv, "\x1f") + rightArgv := strings.Join(right.Argv, "\x1f") + switch { + case leftArgv < rightArgv: + return -1 + case leftArgv > rightArgv: + return 1 + default: + return 0 + } + }) + if limit > 0 && len(sorted) > limit { + sorted = sorted[:limit] + } + return sorted +} + +func sampleActionPairs(base, probe tracessa.Graph, pairs []tracessa.ActionPair, limit int) []tracessa.ActionPair { + if len(pairs) == 0 { + return nil + } + sorted := slices.Clone(pairs) + slices.SortFunc(sorted, func(left, right tracessa.ActionPair) int { + leftBase := base.Actions[left.BaseIdx].ActionKey + rightBase := base.Actions[right.BaseIdx].ActionKey + if leftBase != rightBase { + if leftBase < rightBase { + return -1 + } + return 1 + } + leftProbe := probe.Actions[left.ProbeIdx].ActionKey + rightProbe := probe.Actions[right.ProbeIdx].ActionKey + switch { + case leftProbe < rightProbe: + return -1 + case leftProbe > rightProbe: + return 1 + default: + return 0 + } + }) + if limit > 0 && len(sorted) > limit { + sorted = sorted[:limit] + } + return sorted +} + +func sampleMapKeys(values map[string]struct{}, limit int) []string { + out := slices.Collect(maps.Keys(values)) + slices.Sort(out) + if len(out) > limit { + out = out[:limit] + } + return out +} + +func sampleMapOverlap(left, right map[string]struct{}, limit int) []string { + paths := make([]string, 0) + for path := range left { + if _, ok := right[path]; ok { + paths = append(paths, path) + } + } + slices.Sort(paths) + if len(paths) > limit { + paths = paths[:limit] + } + return paths +} + +func sampleStateKeys(values map[tracessa.ImpactStateKey]struct{}, limit int) []string { + out := make([]string, 0, len(values)) + for state := range values { + out = append(out, formatStateKey(state)) + } + slices.Sort(out) + if len(out) > limit { + out = out[:limit] + } + return out +} + +func sampleStateOverlap(left, right map[tracessa.ImpactStateKey]struct{}, limit int) []string { + out := make([]string, 0) + for state := range left { + if _, ok := right[state]; ok { + out = append(out, formatStateKey(state)) + } + } + slices.Sort(out) + if len(out) > limit { + out = out[:limit] + } + return out +} + +func formatStateKey(state tracessa.ImpactStateKey) string { + if state.Tombstone { + return state.Path + " [tombstone]" + } + return state.Path + " [live]" +} + +func formatHazards(hazards []collisionHazardKind) []string { + if len(hazards) == 0 { + return nil + } + out := make([]string, 0, len(hazards)) + for _, hazard := range hazards { + out = append(out, string(hazard)) + } + return out +} + +func sampleMapOverlapSets(leftSets, rightSets []map[string]struct{}, limit int) []string { + paths := make(map[string]struct{}) + for _, left := range leftSets { + for _, right := range rightSets { + for _, path := range sampleMapOverlap(left, right, limit) { + paths[path] = struct{}{} + } + } + } + sorted := slices.Collect(maps.Keys(paths)) + slices.Sort(sorted) + if len(sorted) > limit { + sorted = sorted[:limit] + } + return sorted +} + +func partitionSharedPaths(paths []string, leftMergeSurface, rightMergeSurface map[string]struct{}, limit int) (onMerge []string, offMerge []string) { + for _, path := range paths { + _, leftOK := leftMergeSurface[path] + _, rightOK := rightMergeSurface[path] + if leftOK && rightOK { + onMerge = append(onMerge, path) + continue + } + offMerge = append(offMerge, path) + } + slices.Sort(onMerge) + slices.Sort(offMerge) + if len(onMerge) > limit { + onMerge = onMerge[:limit] + } + if len(offMerge) > limit { + offMerge = offMerge[:limit] + } + return onMerge, offMerge +} + +func DebugTraceMatchesEvents(events []trace.Event, tokens []string, limit int) string { + if limit <= 0 { + limit = 8 + } + var b strings.Builder + b.WriteString("trace matches:\n") + + matched := 0 + for _, event := range events { + if !eventMatchesTokens(event, tokens) { + continue + } + b.WriteString(" seq: ") + b.WriteString(strconv.FormatInt(event.Seq, 10)) + b.WriteString(" kind: ") + b.WriteString(event.Kind.String()) + b.WriteByte('\n') + if event.PID != 0 { + b.WriteString(" pid: ") + b.WriteString(strconv.FormatInt(event.PID, 10)) + b.WriteByte('\n') + } + if event.ParentPID != 0 { + b.WriteString(" ppid: ") + b.WriteString(strconv.FormatInt(event.ParentPID, 10)) + b.WriteByte('\n') + } + if event.Cwd != "" { + b.WriteString(" cwd: ") + b.WriteString(event.Cwd) + b.WriteByte('\n') + } + if event.Path != "" { + b.WriteString(" path: ") + b.WriteString(event.Path) + b.WriteByte('\n') + } + if event.RelatedPath != "" { + b.WriteString(" related: ") + b.WriteString(event.RelatedPath) + b.WriteByte('\n') + } + if len(event.Argv) > 0 { + b.WriteString(" argv: ") + b.WriteString(strings.Join(event.Argv, " ")) + b.WriteByte('\n') + } + matched++ + if matched >= limit { + break + } + } + if matched == 0 { + b.WriteString(" absent\n") + } + return b.String() +} + +func eventMatchesTokens(event trace.Event, tokens []string) bool { + for _, token := range tokens { + if token == "" { + continue + } + if strings.Contains(event.Path, token) || strings.Contains(event.RelatedPath, token) { + return true + } + for _, arg := range event.Argv { + if strings.Contains(arg, token) { + return true + } + } + } + return false +} diff --git a/internal/evaluator/doc.go b/internal/evaluator/doc.go new file mode 100644 index 0000000..b3840ad --- /dev/null +++ b/internal/evaluator/doc.go @@ -0,0 +1,7 @@ +// Package evaluator owns Stage 2 matrix planning on top of tracessa and the +// artifact-side utilities needed by synthesis, replay, and debug reporting. +// +// The package deliberately does not implement trace SSA internals. All trace +// normalization, SSA construction, role projection, and wavefront impact +// analysis live under internal/trace/ssa. +package evaluator diff --git a/internal/evaluator/graph_bridge.go b/internal/evaluator/graph_bridge.go new file mode 100644 index 0000000..8e9d39f --- /dev/null +++ b/internal/evaluator/graph_bridge.go @@ -0,0 +1,81 @@ +package evaluator + +import ( + "strings" + + "github.com/goplus/llar/internal/trace" + tracessa "github.com/goplus/llar/internal/trace/ssa" +) + +func buildGraph(records []trace.Record) tracessa.Graph { + return buildGraphWithScope(records, trace.Scope{}) +} + +func buildGraphWithScope(records []trace.Record, scope trace.Scope) tracessa.Graph { + return buildGraphWithScopeAndDigests(records, scope, nil) +} + +func buildGraphWithScopeAndDigests(records []trace.Record, scope trace.Scope, inputDigests map[string]string) tracessa.Graph { + return tracessa.BuildGraph(tracessa.BuildInput{ + Records: records, + Scope: scope, + InputDigests: inputDigests, + }) +} + +func buildGraphWithEvents(events []trace.Event) tracessa.Graph { + return buildGraphWithEventsAndDigests(events, trace.Scope{}, nil) +} + +func buildGraphWithEventsAndDigests(events []trace.Event, scope trace.Scope, inputDigests map[string]string) tracessa.Graph { + return tracessa.BuildGraph(tracessa.BuildInput{ + Events: events, + Scope: scope, + InputDigests: inputDigests, + }) +} + +func collectExecPaths(actions []tracessa.ExecNode) map[string]struct{} { + executed := make(map[string]struct{}) + for _, action := range actions { + if action.ExecPath == "" { + continue + } + executed[action.ExecPath] = struct{}{} + } + return executed +} + +func actionWritesExecutedPath(action tracessa.ExecNode, executedPaths map[string]struct{}) bool { + if len(executedPaths) == 0 { + return false + } + for _, path := range action.Writes { + if _, ok := executedPaths[path]; ok { + return true + } + } + return false +} + +func isExplicitDeliveryPath(path string, scope trace.Scope) bool { + root := strings.TrimSuffix(normalizePath(scope.InstallRoot), "/") + if root == "" { + return false + } + path = normalizePath(path) + return path == root || strings.HasPrefix(path, root+"/") +} + +func isDeliveryPath(actions []tracessa.ExecNode, outdeg []int, executedPaths map[string]struct{}, facts tracessa.PathInfo) bool { + for _, writer := range facts.Writers { + if writer < 0 || writer >= len(actions) { + continue + } + action := actions[writer] + if (action.Kind == tracessa.KindCopy || action.Kind == tracessa.KindInstall) && outdeg[writer] == 0 && !actionWritesExecutedPath(action, executedPaths) { + return true + } + } + return false +} diff --git a/internal/evaluator/manifest_test.go b/internal/evaluator/manifest_test.go new file mode 100644 index 0000000..0414ec8 --- /dev/null +++ b/internal/evaluator/manifest_test.go @@ -0,0 +1,150 @@ +package evaluator + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuildOutputManifest_BasicEntries(t *testing.T) { + root := t.TempDir() + + toolPath := filepath.Join(root, "bin", "tool") + if err := os.MkdirAll(filepath.Dir(toolPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(toolPath, []byte("hello"), 0o755); err != nil { + t.Fatal(err) + } + + headerPath := filepath.Join(root, "include", "foo.h") + if err := os.MkdirAll(filepath.Dir(headerPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(headerPath, []byte("#define FOO 1\n"), 0o644); err != nil { + t.Fatal(err) + } + + linkPath := filepath.Join(root, "lib", "libfoo.so") + if err := os.MkdirAll(filepath.Dir(linkPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("../bin/tool", linkPath); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + manifest, err := BuildOutputManifest(root, "-lfoo") + if err != nil { + t.Fatalf("BuildOutputManifest() error: %v", err) + } + + if got, want := manifest.Metadata, "-lfoo"; got != want { + t.Fatalf("manifest.Metadata = %q, want %q", got, want) + } + if got := manifest.Entries["bin/tool"]; got.Kind != "file" || got.Digest == "" || !got.Executable { + t.Fatalf("manifest entry bin/tool = %+v", got) + } + if got := manifest.Entries["include/foo.h"]; got.Kind != "file" || got.Digest == "" || got.Executable { + t.Fatalf("manifest entry include/foo.h = %+v", got) + } + if got := manifest.Entries["lib/libfoo.so"]; got.Kind != "symlink" || got.Target != "../bin/tool" { + t.Fatalf("manifest entry lib/libfoo.so = %+v", got) + } +} + +func TestBuildOutputManifest_ArchiveDigestIgnoresHeaderNoise(t *testing.T) { + rootA := t.TempDir() + rootB := t.TempDir() + rootC := t.TempDir() + + membersA := []testArchiveMember{ + {Name: "foo.o", Data: []byte("foo-object"), Mtime: 111, UID: 1, GID: 2, Mode: 0o644}, + {Name: "bar.o", Data: []byte("bar-object"), Mtime: 222, UID: 3, GID: 4, Mode: 0o644}, + } + membersB := []testArchiveMember{ + {Name: "foo.o", Data: []byte("foo-object"), Mtime: 9999, UID: 42, GID: 24, Mode: 0o600}, + {Name: "bar.o", Data: []byte("bar-object"), Mtime: 8888, UID: 7, GID: 8, Mode: 0o777}, + } + membersC := []testArchiveMember{ + {Name: "foo.o", Data: []byte("foo-object-changed"), Mtime: 111, UID: 1, GID: 2, Mode: 0o644}, + {Name: "bar.o", Data: []byte("bar-object"), Mtime: 222, UID: 3, GID: 4, Mode: 0o644}, + } + + pathA := filepath.Join(rootA, "lib", "libfoo.a") + pathB := filepath.Join(rootB, "lib", "libfoo.a") + pathC := filepath.Join(rootC, "lib", "libfoo.a") + for _, path := range []string{pathA, pathB, pathC} { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + } + if err := writeTestArchive(pathA, membersA); err != nil { + t.Fatal(err) + } + if err := writeTestArchive(pathB, membersB); err != nil { + t.Fatal(err) + } + if err := writeTestArchive(pathC, membersC); err != nil { + t.Fatal(err) + } + + manifestA, err := BuildOutputManifest(rootA, "") + if err != nil { + t.Fatalf("BuildOutputManifest(rootA) error: %v", err) + } + manifestB, err := BuildOutputManifest(rootB, "") + if err != nil { + t.Fatalf("BuildOutputManifest(rootB) error: %v", err) + } + manifestC, err := BuildOutputManifest(rootC, "") + if err != nil { + t.Fatalf("BuildOutputManifest(rootC) error: %v", err) + } + + digestA := manifestA.Entries["lib/libfoo.a"].Digest + digestB := manifestB.Entries["lib/libfoo.a"].Digest + digestC := manifestC.Entries["lib/libfoo.a"].Digest + if digestA == "" || digestB == "" || digestC == "" { + t.Fatalf("archive digests missing: A=%q B=%q C=%q", digestA, digestB, digestC) + } + if digestA != digestB { + t.Fatalf("archive digest should ignore header noise: A=%q B=%q", digestA, digestB) + } + if digestA == digestC { + t.Fatalf("archive digest should change when member content changes: A=%q C=%q", digestA, digestC) + } +} + +type testArchiveMember struct { + Name string + Data []byte + Mtime int64 + UID int + GID int + Mode int +} + +func writeTestArchive(path string, members []testArchiveMember) error { + var buf bytes.Buffer + buf.WriteString("!\n") + for _, member := range members { + name := member.Name + if !strings.HasSuffix(name, "/") { + name += "/" + } + size := len(member.Data) + header := fmt.Sprintf("%-16s%-12d%-6d%-6d%-8o%-10d`\n", name, member.Mtime, member.UID, member.GID, member.Mode, size) + if len(header) != 60 { + return fmt.Errorf("unexpected archive header length %d", len(header)) + } + buf.WriteString(header) + buf.Write(member.Data) + if size%2 != 0 { + buf.WriteByte('\n') + } + } + return os.WriteFile(path, buf.Bytes(), 0o644) +} diff --git a/internal/evaluator/merge_test.go b/internal/evaluator/merge_test.go new file mode 100644 index 0000000..05e6fc1 --- /dev/null +++ b/internal/evaluator/merge_test.go @@ -0,0 +1,378 @@ +package evaluator + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestMergeOutputTrees_TextFileThreeWayMerge(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skipf("git not available: %v", err) + } + + baseDir := t.TempDir() + leftDir := t.TempDir() + rightDir := t.TempDir() + + baseText := []byte("#define WITH_A 0\n#define KEEP 1\n#define WITH_B 0\n") + leftText := []byte("#define WITH_A 1\n#define KEEP 1\n#define WITH_B 0\n") + rightText := []byte("#define WITH_A 0\n#define KEEP 1\n#define WITH_B 1\n") + + writeMergeFile(t, filepath.Join(baseDir, "include", "config.h"), baseText, 0o644) + writeMergeFile(t, filepath.Join(leftDir, "include", "config.h"), leftText, 0o644) + writeMergeFile(t, filepath.Join(rightDir, "include", "config.h"), rightText, 0o644) + + baseManifest, err := BuildOutputManifest(baseDir, "-lbase") + if err != nil { + t.Fatal(err) + } + leftManifest, err := BuildOutputManifest(leftDir, "-lbase") + if err != nil { + t.Fatal(err) + } + rightManifest, err := BuildOutputManifest(rightDir, "-lbase") + if err != nil { + t.Fatal(err) + } + + result, err := MergeOutputTrees(baseDir, baseManifest, leftDir, leftManifest, rightDir, rightManifest) + if err != nil { + t.Fatalf("MergeOutputTrees() error: %v", err) + } + if !result.Clean() { + t.Fatalf("MergeOutputTrees() issues = %#v, want clean", result.Issues) + } + + got, err := os.ReadFile(filepath.Join(result.Root, "include", "config.h")) + if err != nil { + t.Fatal(err) + } + want := "#define WITH_A 1\n#define KEEP 1\n#define WITH_B 1\n" + if string(got) != want { + t.Fatalf("merged config.h = %q, want %q", string(got), want) + } +} + +func TestMergeOutputTrees_ArchiveMemberMerge(t *testing.T) { + oldPostProcess := postProcessMergedArchive + postProcessMergedArchive = func(string) error { return nil } + defer func() { + postProcessMergedArchive = oldPostProcess + }() + + baseDir := t.TempDir() + leftDir := t.TempDir() + rightDir := t.TempDir() + + baseMembers := []testArchiveMember{ + {Name: "foo.o", Data: []byte("foo-base"), Mtime: 1, UID: 1, GID: 1, Mode: 0o644}, + {Name: "bar.o", Data: []byte("bar-base"), Mtime: 1, UID: 1, GID: 1, Mode: 0o644}, + } + leftMembers := []testArchiveMember{ + {Name: "foo.o", Data: []byte("foo-left"), Mtime: 2, UID: 2, GID: 2, Mode: 0o644}, + {Name: "bar.o", Data: []byte("bar-base"), Mtime: 2, UID: 2, GID: 2, Mode: 0o644}, + } + rightMembers := []testArchiveMember{ + {Name: "foo.o", Data: []byte("foo-base"), Mtime: 3, UID: 3, GID: 3, Mode: 0o644}, + {Name: "bar.o", Data: []byte("bar-right"), Mtime: 3, UID: 3, GID: 3, Mode: 0o644}, + } + + basePath := filepath.Join(baseDir, "lib", "libfoo.a") + leftPath := filepath.Join(leftDir, "lib", "libfoo.a") + rightPath := filepath.Join(rightDir, "lib", "libfoo.a") + for _, path := range []string{basePath, leftPath, rightPath} { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + } + if err := writeTestArchive(basePath, baseMembers); err != nil { + t.Fatal(err) + } + if err := writeTestArchive(leftPath, leftMembers); err != nil { + t.Fatal(err) + } + if err := writeTestArchive(rightPath, rightMembers); err != nil { + t.Fatal(err) + } + + baseManifest, err := BuildOutputManifest(baseDir, "") + if err != nil { + t.Fatal(err) + } + leftManifest, err := BuildOutputManifest(leftDir, "") + if err != nil { + t.Fatal(err) + } + rightManifest, err := BuildOutputManifest(rightDir, "") + if err != nil { + t.Fatal(err) + } + + result, err := MergeOutputTrees(baseDir, baseManifest, leftDir, leftManifest, rightDir, rightManifest) + if err != nil { + t.Fatalf("MergeOutputTrees() error: %v", err) + } + if !result.Clean() { + t.Fatalf("MergeOutputTrees() issues = %#v, want clean", result.Issues) + } + + mergedMembers, err := readArchiveMembers(filepath.Join(result.Root, "lib", "libfoo.a")) + if err != nil { + t.Fatal(err) + } + memberMap := archiveMemberMap(mergedMembers) + if got := string(memberMap["foo.o"].Body); got != "foo-left" { + t.Fatalf("merged foo.o = %q, want %q", got, "foo-left") + } + if got := string(memberMap["bar.o"].Body); got != "bar-right" { + t.Fatalf("merged bar.o = %q, want %q", got, "bar-right") + } +} + +func TestMergeOutputTrees_BinaryConflict(t *testing.T) { + baseDir := t.TempDir() + leftDir := t.TempDir() + rightDir := t.TempDir() + + writeMergeFile(t, filepath.Join(baseDir, "lib", "blob.bin"), []byte{0x00, 0x02}, 0o644) + writeMergeFile(t, filepath.Join(leftDir, "lib", "blob.bin"), []byte{0x00, 0x03}, 0o644) + writeMergeFile(t, filepath.Join(rightDir, "lib", "blob.bin"), []byte{0x00, 0x04}, 0o644) + + baseManifest, err := BuildOutputManifest(baseDir, "") + if err != nil { + t.Fatal(err) + } + leftManifest, err := BuildOutputManifest(leftDir, "") + if err != nil { + t.Fatal(err) + } + rightManifest, err := BuildOutputManifest(rightDir, "") + if err != nil { + t.Fatal(err) + } + + result, err := MergeOutputTrees(baseDir, baseManifest, leftDir, leftManifest, rightDir, rightManifest) + if err != nil { + t.Fatalf("MergeOutputTrees() error: %v", err) + } + if result.Clean() { + t.Fatal("MergeOutputTrees() = clean, want rebuild issue") + } + if result.Status != OutputMergeStatusNeedsRebuild { + t.Fatalf("status = %q, want %q", result.Status, OutputMergeStatusNeedsRebuild) + } + if len(result.Issues) != 1 || result.Issues[0].Path != "lib/blob.bin" { + t.Fatalf("issues = %#v, want lib/blob.bin issue", result.Issues) + } + if result.Issues[0].Kind != OutputMergeIssueKindFileBinaryUnmergeable { + t.Fatalf("issue kind = %q, want %q", result.Issues[0].Kind, OutputMergeIssueKindFileBinaryUnmergeable) + } +} + +func TestMergeOutputTrees_ArchiveRebuildIssueIncludesMemberNames(t *testing.T) { + oldPostProcess := postProcessMergedArchive + postProcessMergedArchive = func(string) error { return nil } + defer func() { + postProcessMergedArchive = oldPostProcess + }() + + baseDir := t.TempDir() + leftDir := t.TempDir() + rightDir := t.TempDir() + + baseMembers := []testArchiveMember{ + {Name: "shared.o", Data: []byte("shared-base"), Mtime: 1, UID: 1, GID: 1, Mode: 0o644}, + } + leftMembers := []testArchiveMember{ + {Name: "shared.o", Data: []byte("shared-left"), Mtime: 2, UID: 2, GID: 2, Mode: 0o644}, + } + rightMembers := []testArchiveMember{ + {Name: "shared.o", Data: []byte("shared-right"), Mtime: 3, UID: 3, GID: 3, Mode: 0o644}, + } + + basePath := filepath.Join(baseDir, "lib", "libfoo.a") + leftPath := filepath.Join(leftDir, "lib", "libfoo.a") + rightPath := filepath.Join(rightDir, "lib", "libfoo.a") + for _, path := range []string{basePath, leftPath, rightPath} { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + } + if err := writeTestArchive(basePath, baseMembers); err != nil { + t.Fatal(err) + } + if err := writeTestArchive(leftPath, leftMembers); err != nil { + t.Fatal(err) + } + if err := writeTestArchive(rightPath, rightMembers); err != nil { + t.Fatal(err) + } + + baseManifest, err := BuildOutputManifest(baseDir, "") + if err != nil { + t.Fatal(err) + } + leftManifest, err := BuildOutputManifest(leftDir, "") + if err != nil { + t.Fatal(err) + } + rightManifest, err := BuildOutputManifest(rightDir, "") + if err != nil { + t.Fatal(err) + } + + result, err := MergeOutputTrees(baseDir, baseManifest, leftDir, leftManifest, rightDir, rightManifest) + if err != nil { + t.Fatalf("MergeOutputTrees() error: %v", err) + } + if result.Clean() { + t.Fatal("MergeOutputTrees() = clean, want archive rebuild issue") + } + if result.Status != OutputMergeStatusNeedsRebuild { + t.Fatalf("status = %q, want %q", result.Status, OutputMergeStatusNeedsRebuild) + } + if len(result.Issues) != 1 || result.Issues[0].Path != "lib/libfoo.a" { + t.Fatalf("issues = %#v, want archive issue", result.Issues) + } + if result.Issues[0].Kind != OutputMergeIssueKindArchiveUnmergeable { + t.Fatalf("issue kind = %q, want %q", result.Issues[0].Kind, OutputMergeIssueKindArchiveUnmergeable) + } + if !strings.Contains(result.Issues[0].Detail, "shared.o") { + t.Fatalf("archive issue detail = %q, want member detail", result.Issues[0].Detail) + } + for _, token := range []string{ + "automatic archive merge cannot materialize a combined output", + "conflicting members (1):", + "shared.o", + } { + if !strings.Contains(result.Issues[0].Detail, token) { + t.Fatalf("archive issue detail = %q, missing %q", result.Issues[0].Detail, token) + } + } + if strings.Contains(result.Issues[0].Detail, "digest=") { + t.Fatalf("archive issue detail = %q, unexpectedly contains digest details", result.Issues[0].Detail) + } +} + +func TestMergeOutputTrees_MetadataFlagAppendMerge(t *testing.T) { + baseDir := t.TempDir() + leftDir := t.TempDir() + rightDir := t.TempDir() + + baseManifest, err := BuildOutputManifest(baseDir, "-lPocoFoundation") + if err != nil { + t.Fatal(err) + } + leftManifest, err := BuildOutputManifest(leftDir, "-lPocoFoundation -lPocoJSON") + if err != nil { + t.Fatal(err) + } + rightManifest, err := BuildOutputManifest(rightDir, "-lPocoFoundation -lPocoXML") + if err != nil { + t.Fatal(err) + } + + result, err := MergeOutputTrees(baseDir, baseManifest, leftDir, leftManifest, rightDir, rightManifest) + if err != nil { + t.Fatalf("MergeOutputTrees() error: %v", err) + } + if !result.Clean() { + t.Fatalf("MergeOutputTrees() issues = %#v, want clean", result.Issues) + } + if result.Metadata != "-lPocoFoundation -lPocoJSON -lPocoXML" { + t.Fatalf("merged metadata = %q, want %q", result.Metadata, "-lPocoFoundation -lPocoJSON -lPocoXML") + } +} + +func TestMergeOutputTrees_MetadataConflictIncludesProcess(t *testing.T) { + baseDir := t.TempDir() + leftDir := t.TempDir() + rightDir := t.TempDir() + + baseManifest, err := BuildOutputManifest(baseDir, "-lbase") + if err != nil { + t.Fatal(err) + } + leftManifest, err := BuildOutputManifest(leftDir, "-ljson -lbase") + if err != nil { + t.Fatal(err) + } + rightManifest, err := BuildOutputManifest(rightDir, "-lxml -lbase") + if err != nil { + t.Fatal(err) + } + + result, err := MergeOutputTrees(baseDir, baseManifest, leftDir, leftManifest, rightDir, rightManifest) + if err != nil { + t.Fatalf("MergeOutputTrees() error: %v", err) + } + if result.Clean() { + t.Fatal("MergeOutputTrees() = clean, want metadata rebuild issue") + } + if result.Status != OutputMergeStatusNeedsRebuild { + t.Fatalf("status = %q, want %q", result.Status, OutputMergeStatusNeedsRebuild) + } + if len(result.Issues) != 1 || result.Issues[0].Path != "" { + t.Fatalf("issues = %#v, want metadata issue", result.Issues) + } + if result.Issues[0].Kind != OutputMergeIssueKindMetadataUnmergeable { + t.Fatalf("issue kind = %q, want %q", result.Issues[0].Kind, OutputMergeIssueKindMetadataUnmergeable) + } + for _, token := range []string{ + "both sides changed metadata", + "shared base flags are not a common prefix", + } { + if !strings.Contains(result.Issues[0].Detail, token) { + t.Fatalf("metadata issue detail = %q, missing %q", result.Issues[0].Detail, token) + } + } +} + +func TestMergeOutputTrees_MetadataNonFlagConflict(t *testing.T) { + baseDir := t.TempDir() + leftDir := t.TempDir() + rightDir := t.TempDir() + + baseManifest, err := BuildOutputManifest(baseDir, "matrix-off") + if err != nil { + t.Fatal(err) + } + leftManifest, err := BuildOutputManifest(leftDir, "matrix-left") + if err != nil { + t.Fatal(err) + } + rightManifest, err := BuildOutputManifest(rightDir, "matrix-right") + if err != nil { + t.Fatal(err) + } + + result, err := MergeOutputTrees(baseDir, baseManifest, leftDir, leftManifest, rightDir, rightManifest) + if err != nil { + t.Fatalf("MergeOutputTrees() error: %v", err) + } + if result.Clean() { + t.Fatal("MergeOutputTrees() = clean, want metadata rebuild issue") + } + if result.Status != OutputMergeStatusNeedsRebuild { + t.Fatalf("status = %q, want %q", result.Status, OutputMergeStatusNeedsRebuild) + } + if len(result.Issues) != 1 || result.Issues[0].Path != "" { + t.Fatalf("issues = %#v, want metadata issue", result.Issues) + } + if result.Issues[0].Kind != OutputMergeIssueKindMetadataUnmergeable { + t.Fatalf("issue kind = %q, want %q", result.Issues[0].Kind, OutputMergeIssueKindMetadataUnmergeable) + } +} + +func writeMergeFile(t *testing.T, path string, data []byte, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, mode); err != nil { + t.Fatal(err) + } +} diff --git a/internal/evaluator/output_manifest.go b/internal/evaluator/output_manifest.go new file mode 100644 index 0000000..4f47353 --- /dev/null +++ b/internal/evaluator/output_manifest.go @@ -0,0 +1,368 @@ +package evaluator + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" +) + +type OutputManifest struct { + Metadata string + Entries map[string]OutputEntry +} + +type OutputEntry struct { + Kind string + Digest string + Target string + Executable bool +} + +type outputManifestDiff struct { + metadataChanged bool + metadata string + entries map[string]outputManifestDiffEntry +} + +type outputManifestDiffEntry struct { + present bool + entry OutputEntry +} + +func diffOutputManifest(base, probe OutputManifest) outputManifestDiff { + diff := outputManifestDiff{} + if base.Metadata != probe.Metadata { + diff.metadataChanged = true + diff.metadata = probe.Metadata + } + + allPaths := make(map[string]struct{}, len(base.Entries)+len(probe.Entries)) + for path := range base.Entries { + allPaths[path] = struct{}{} + } + for path := range probe.Entries { + allPaths[path] = struct{}{} + } + if len(allPaths) == 0 { + return diff + } + + entries := make(map[string]outputManifestDiffEntry) + for path := range allPaths { + baseEntry, baseOK := base.Entries[path] + probeEntry, probeOK := probe.Entries[path] + switch { + case baseOK && probeOK && baseEntry == probeEntry: + continue + case probeOK: + entries[path] = outputManifestDiffEntry{present: true, entry: probeEntry} + default: + entries[path] = outputManifestDiffEntry{present: false} + } + } + if len(entries) > 0 { + diff.entries = entries + } + return diff +} + +func (diff outputManifestDiff) empty() bool { + return !diff.metadataChanged && len(diff.entries) == 0 +} + +func outputManifestDiffsCollide(left, right outputManifestDiff) bool { + if left.metadataChanged && right.metadataChanged && left.metadata != right.metadata { + return true + } + if len(left.entries) == 0 || len(right.entries) == 0 { + return false + } + for path, leftEntry := range left.entries { + rightEntry, ok := right.entries[path] + if !ok { + continue + } + if leftEntry != rightEntry { + return true + } + } + return false +} + +type manifestCalculator interface { + Calculate(path string, info fs.FileInfo) (OutputEntry, error) +} + +func BuildOutputManifest(outputDir, metadata string) (OutputManifest, error) { + manifest := OutputManifest{Metadata: metadata} + if outputDir == "" { + return manifest, nil + } + + entries := make(map[string]OutputEntry) + err := filepath.WalkDir(outputDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if path == outputDir { + return nil + } + + rel, err := filepath.Rel(outputDir, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if rel == "." { + return nil + } + + info, err := d.Info() + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + entry, err := buildManifestEntry(path, info) + if err != nil { + return err + } + entries[rel] = entry + return nil + }) + if err != nil { + return OutputManifest{}, err + } + if len(entries) > 0 { + manifest.Entries = entries + } + return manifest, nil +} + +func buildManifestEntry(path string, info fs.FileInfo) (OutputEntry, error) { + calculator, err := newManifestCalculator(path, info) + if err != nil { + return OutputEntry{}, err + } + entry, err := calculator.Calculate(path, info) + if err != nil { + return OutputEntry{}, err + } + entry.Executable = info.Mode()&0o111 != 0 + return entry, nil +} + +func newManifestCalculator(path string, info fs.FileInfo) (manifestCalculator, error) { + switch { + case info.Mode()&os.ModeSymlink != 0: + return symlinkManifestCalculator{}, nil + case info.Mode()&os.ModeType == 0 && strings.HasSuffix(strings.ToLower(path), ".a"): + return archiveManifestCalculator{}, nil + case info.Mode()&os.ModeType == 0: + return rawFileManifestCalculator{}, nil + default: + return nil, fmt.Errorf("no manifest calculator for %q", path) + } +} + +type symlinkManifestCalculator struct{} + +func (symlinkManifestCalculator) Calculate(path string, _ fs.FileInfo) (OutputEntry, error) { + target, err := os.Readlink(path) + if err != nil { + return OutputEntry{}, err + } + target = filepath.ToSlash(target) + return OutputEntry{ + Kind: "symlink", + Target: target, + Digest: digestBytesShort([]byte(target)), + }, nil +} + +type archiveManifestCalculator struct{} + +func (archiveManifestCalculator) Calculate(path string, _ fs.FileInfo) (OutputEntry, error) { + digest, err := archiveDigest(path) + if err != nil { + return OutputEntry{}, err + } + return OutputEntry{ + Kind: "archive", + Digest: digest, + }, nil +} + +type rawFileManifestCalculator struct{} + +func (rawFileManifestCalculator) Calculate(path string, _ fs.FileInfo) (OutputEntry, error) { + digest, err := fileDigestShort(path) + if err != nil { + return OutputEntry{}, err + } + return OutputEntry{ + Kind: "file", + Digest: digest, + }, nil +} + +func fileDigestShort(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return digestBytesShort(data), nil +} + +func digestBytesShort(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:8]) +} + +type archiveMemberDigest struct { + Name string + Digest string +} + +func archiveDigest(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + members, err := parseArchiveMembers(data) + if err != nil { + return "", err + } + if len(members) == 0 { + return digestBytesShort(nil), nil + } + var buf bytes.Buffer + for _, member := range members { + buf.WriteString(member.Name) + buf.WriteByte(0) + buf.WriteString(member.Digest) + buf.WriteByte('\n') + } + return digestBytesShort(buf.Bytes()), nil +} + +func parseArchiveMembers(data []byte) ([]archiveMemberDigest, error) { + const globalHeader = "!\n" + const fileHeaderLen = 60 + if len(data) < len(globalHeader) || string(data[:len(globalHeader)]) != globalHeader { + return nil, fmt.Errorf("invalid archive header") + } + + offset := len(globalHeader) + var stringTable []byte + members := make([]archiveMemberDigest, 0) + for offset < len(data) { + if len(data)-offset < fileHeaderLen { + return nil, fmt.Errorf("truncated archive header") + } + header := data[offset : offset+fileHeaderLen] + offset += fileHeaderLen + + if string(header[58:60]) != "`\n" { + return nil, fmt.Errorf("invalid archive file trailer") + } + + nameField := strings.TrimSpace(string(header[:16])) + sizeField := strings.TrimSpace(string(header[48:58])) + size, err := strconv.Atoi(sizeField) + if err != nil || size < 0 { + return nil, fmt.Errorf("invalid archive member size %q", sizeField) + } + if len(data)-offset < size { + return nil, fmt.Errorf("truncated archive member data") + } + + payload := data[offset : offset+size] + offset += size + if offset%2 != 0 { + offset++ + } + + if isArchiveSpecialName(nameField) { + if nameField == "//" { + stringTable = append(stringTable[:0], payload...) + } + continue + } + + name, body, err := resolveArchiveMember(nameField, payload, stringTable) + if err != nil { + return nil, err + } + members = append(members, archiveMemberDigest{ + Name: name, + Digest: digestBytesShort(body), + }) + } + return members, nil +} + +func isArchiveSpecialName(name string) bool { + switch name { + case "/", "/SYM64/", "__.SYMDEF", "__.SYMDEF SORTED", "__.SYMDEF_64", "__.SYMDEF SORTED_64", "//": + return true + default: + return false + } +} + +func resolveArchiveMember(name string, payload, stringTable []byte) (string, []byte, error) { + switch { + case strings.HasPrefix(name, "#1/"): + n, err := strconv.Atoi(strings.TrimPrefix(name, "#1/")) + if err != nil || n < 0 || n > len(payload) { + return "", nil, fmt.Errorf("invalid BSD archive name %q", name) + } + return strings.TrimRight(string(payload[:n]), "\x00"), payload[n:], nil + case strings.HasPrefix(name, "/") && len(name) > 1 && isDecimal(name[1:]): + if len(stringTable) == 0 { + return "", nil, fmt.Errorf("archive member %q missing string table", name) + } + idx, _ := strconv.Atoi(name[1:]) + resolved, err := archiveStringTableName(stringTable, idx) + if err != nil { + return "", nil, err + } + return resolved, payload, nil + default: + return strings.TrimSuffix(name, "/"), payload, nil + } +} + +func archiveStringTableName(table []byte, idx int) (string, error) { + if idx < 0 || idx >= len(table) { + return "", fmt.Errorf("archive string table offset %d out of range", idx) + } + rest := table[idx:] + end := bytes.IndexByte(rest, '\n') + if end < 0 { + end = len(rest) + } + return strings.TrimSuffix(string(rest[:end]), "/"), nil +} + +func isDecimal(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/internal/evaluator/output_merge.go b/internal/evaluator/output_merge.go new file mode 100644 index 0000000..9459e9b --- /dev/null +++ b/internal/evaluator/output_merge.go @@ -0,0 +1,875 @@ +package evaluator + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "maps" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "unicode/utf8" +) + +type OutputMergeStatus string + +const ( + OutputMergeStatusMerged OutputMergeStatus = "merged" + OutputMergeStatusNeedsRebuild OutputMergeStatus = "needs-rebuild" +) + +type OutputMergeIssueKind string + +const ( + OutputMergeIssueKindMetadataUnmergeable OutputMergeIssueKind = "metadata-unmergeable" + OutputMergeIssueKindPathAddedDifferently OutputMergeIssueKind = "path-added-differently" + OutputMergeIssueKindPathDeleteChange OutputMergeIssueKind = "path-delete-change" + OutputMergeIssueKindPathKindMismatch OutputMergeIssueKind = "path-kind-mismatch" + OutputMergeIssueKindArchiveUnmergeable OutputMergeIssueKind = "archive-unmergeable" + OutputMergeIssueKindFileTextUnmergeable OutputMergeIssueKind = "file-text-unmergeable" + OutputMergeIssueKindFileBinaryUnmergeable OutputMergeIssueKind = "file-binary-unmergeable" + OutputMergeIssueKindRootReplayUnavailable OutputMergeIssueKind = "root-replay-unavailable" + OutputMergeIssueKindRootReplayFailed OutputMergeIssueKind = "root-replay-failed" + OutputMergeIssueKindUnsupportedKind OutputMergeIssueKind = "unsupported-kind" +) + +type OutputMergeIssue struct { + Kind OutputMergeIssueKind + Path string + Reason string + Detail string + Base string + Left string + Right string +} + +type OutputMergeResult struct { + Status OutputMergeStatus + Root string + Metadata string + Manifest OutputManifest + Issues []OutputMergeIssue +} + +var postProcessMergedArchive = runRanlib + +func (r OutputMergeResult) Clean() bool { + return r.Status == OutputMergeStatusMerged +} + +func (r OutputMergeResult) NeedsRebuild() bool { + return r.Status == OutputMergeStatusNeedsRebuild +} + +func MergeOutputTrees(baseDir string, base OutputManifest, leftDir string, left OutputManifest, rightDir string, right OutputManifest) (OutputMergeResult, error) { + result := OutputMergeResult{Status: OutputMergeStatusMerged} + + metadata, ok := mergeMetadata(base.Metadata, left.Metadata, right.Metadata) + if !ok { + result.Status = OutputMergeStatusNeedsRebuild + result.Issues = append(result.Issues, OutputMergeIssue{ + Kind: OutputMergeIssueKindMetadataUnmergeable, + Path: "", + Reason: "metadata requires real pair build", + Detail: describeMetadataConflictDetail(base.Metadata, left.Metadata, right.Metadata), + Base: summarizeMetadata(base.Metadata), + Left: summarizeMetadata(left.Metadata), + Right: summarizeMetadata(right.Metadata), + }) + return result, nil + } + result.Metadata = metadata + + mergedEntries, content, issues, err := mergeManifestEntries(baseDir, base, leftDir, left, rightDir, right) + if err != nil { + return OutputMergeResult{}, err + } + if len(issues) > 0 { + result.Status = OutputMergeStatusNeedsRebuild + result.Issues = issues + return result, nil + } + + root, err := os.MkdirTemp("", "llar-merge-*") + if err != nil { + return OutputMergeResult{}, err + } + cleanup := true + defer func() { + if cleanup { + _ = os.RemoveAll(root) + } + }() + + for path, data := range content { + dst := filepath.Join(root, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return OutputMergeResult{}, err + } + if data.symlink { + if err := os.Symlink(data.target, dst); err != nil { + return OutputMergeResult{}, err + } + continue + } + mode := fs.FileMode(0o644) + if data.executable { + mode = 0o755 + } + if err := os.WriteFile(dst, data.bytes, mode); err != nil { + return OutputMergeResult{}, err + } + if data.ranlib { + if err := postProcessMergedArchive(dst); err != nil { + return OutputMergeResult{}, err + } + } + } + + manifest, err := BuildOutputManifest(root, metadata) + if err != nil { + return OutputMergeResult{}, err + } + if len(mergedEntries) == 0 { + manifest.Entries = nil + } + result.Root = root + result.Manifest = manifest + cleanup = false + return result, nil +} + +type outputContent struct { + bytes []byte + executable bool + symlink bool + target string + ranlib bool +} + +type manifestState struct { + present bool + entry OutputEntry +} + +func mergeManifestEntries(baseDir string, base OutputManifest, leftDir string, left OutputManifest, rightDir string, right OutputManifest) (map[string]OutputEntry, map[string]outputContent, []OutputMergeIssue, error) { + allPaths := make(map[string]struct{}, len(base.Entries)+len(left.Entries)+len(right.Entries)) + for path := range base.Entries { + allPaths[path] = struct{}{} + } + for path := range left.Entries { + allPaths[path] = struct{}{} + } + for path := range right.Entries { + allPaths[path] = struct{}{} + } + + mergedEntries := make(map[string]OutputEntry, len(allPaths)) + content := make(map[string]outputContent, len(allPaths)) + issues := make([]OutputMergeIssue, 0) + paths := slices.Sorted(maps.Keys(allPaths)) + for _, path := range paths { + baseState := manifestStateForPath(base.Entries, path) + leftState := manifestStateForPath(left.Entries, path) + rightState := manifestStateForPath(right.Entries, path) + + mergedState, data, ok, err := mergeManifestPath(path, baseDir, baseState, leftDir, leftState, rightDir, rightState) + if err != nil { + return nil, nil, nil, err + } + if !ok { + kind, reason, detail := describeManifestIssue(path, baseDir, baseState, leftDir, leftState, rightDir, rightState) + issues = append(issues, OutputMergeIssue{ + Kind: kind, + Path: path, + Reason: reason, + Detail: detail, + Base: summarizeManifestState(baseState), + Left: summarizeManifestState(leftState), + Right: summarizeManifestState(rightState), + }) + continue + } + if !mergedState.present { + continue + } + mergedEntries[path] = mergedState.entry + content[path] = data + } + return mergedEntries, content, issues, nil +} + +func mergeManifestPath(path, baseDir string, baseState manifestState, leftDir string, leftState manifestState, rightDir string, rightState manifestState) (manifestState, outputContent, bool, error) { + switch { + case equalManifestState(leftState, rightState): + data, err := materializeState(path, leftDir, leftState) + return leftState, data, true, err + case equalManifestState(leftState, baseState): + data, err := materializeState(path, rightDir, rightState) + return rightState, data, true, err + case equalManifestState(rightState, baseState): + data, err := materializeState(path, leftDir, leftState) + return leftState, data, true, err + } + + if !baseState.present || !leftState.present || !rightState.present { + return manifestState{}, outputContent{}, false, nil + } + if leftState.entry.Kind != rightState.entry.Kind || leftState.entry.Kind != baseState.entry.Kind { + return manifestState{}, outputContent{}, false, nil + } + + switch leftState.entry.Kind { + case "archive": + entry, data, ok, err := mergeArchiveState(path, baseDir, baseState, leftDir, leftState, rightDir, rightState) + return entry, data, ok, err + case "file": + entry, data, ok, err := mergeRegularFileState(path, baseDir, baseState, leftDir, leftState, rightDir, rightState) + return entry, data, ok, err + default: + return manifestState{}, outputContent{}, false, nil + } +} + +func mergeRegularFileState(path, baseDir string, baseState manifestState, leftDir string, leftState manifestState, rightDir string, rightState manifestState) (manifestState, outputContent, bool, error) { + baseData, err := os.ReadFile(filepath.Join(baseDir, filepath.FromSlash(path))) + if err != nil { + return manifestState{}, outputContent{}, false, err + } + leftData, err := os.ReadFile(filepath.Join(leftDir, filepath.FromSlash(path))) + if err != nil { + return manifestState{}, outputContent{}, false, err + } + rightData, err := os.ReadFile(filepath.Join(rightDir, filepath.FromSlash(path))) + if err != nil { + return manifestState{}, outputContent{}, false, err + } + if !isTextData(baseData) || !isTextData(leftData) || !isTextData(rightData) { + return manifestState{}, outputContent{}, false, nil + } + + merged, ok, err := mergeTextData(baseData, leftData, rightData) + if err != nil || !ok { + return manifestState{}, outputContent{}, ok, err + } + executable, ok := mergeBool(baseState.entry.Executable, leftState.entry.Executable, rightState.entry.Executable) + if !ok { + return manifestState{}, outputContent{}, false, nil + } + entry := OutputEntry{ + Kind: "file", + Digest: digestBytesShort(merged), + Executable: executable, + } + return manifestState{present: true, entry: entry}, outputContent{ + bytes: merged, + executable: executable, + }, true, nil +} + +func mergeArchiveState(path, baseDir string, baseState manifestState, leftDir string, leftState manifestState, rightDir string, rightState manifestState) (manifestState, outputContent, bool, error) { + baseMembers, err := readArchiveMembers(filepath.Join(baseDir, filepath.FromSlash(path))) + if err != nil { + return manifestState{}, outputContent{}, false, err + } + leftMembers, err := readArchiveMembers(filepath.Join(leftDir, filepath.FromSlash(path))) + if err != nil { + return manifestState{}, outputContent{}, false, err + } + rightMembers, err := readArchiveMembers(filepath.Join(rightDir, filepath.FromSlash(path))) + if err != nil { + return manifestState{}, outputContent{}, false, err + } + + mergedMembers, ok := mergeArchiveMembers(baseMembers, leftMembers, rightMembers) + if !ok { + return manifestState{}, outputContent{}, false, nil + } + data, err := encodeArchiveMembers(mergedMembers) + if err != nil { + return manifestState{}, outputContent{}, false, err + } + executable, ok := mergeBool(baseState.entry.Executable, leftState.entry.Executable, rightState.entry.Executable) + if !ok { + return manifestState{}, outputContent{}, false, nil + } + entry := OutputEntry{ + Kind: "archive", + Digest: digestBytesShort(archiveDigestBytes(mergedMembers)), + Executable: executable, + } + return manifestState{present: true, entry: entry}, outputContent{ + bytes: data, + executable: executable, + ranlib: true, + }, true, nil +} + +func mergeArchiveMembers(baseMembers, leftMembers, rightMembers []archiveMember) ([]archiveMember, bool) { + baseByName := archiveMemberMap(baseMembers) + leftByName := archiveMemberMap(leftMembers) + rightByName := archiveMemberMap(rightMembers) + + orderedNames := archiveMemberOrder(baseMembers, leftMembers, rightMembers) + merged := make([]archiveMember, 0, len(orderedNames)) + for _, name := range orderedNames { + baseState := archiveMemberStateForName(baseByName, name) + leftState := archiveMemberStateForName(leftByName, name) + rightState := archiveMemberStateForName(rightByName, name) + member, ok := mergeArchiveMember(baseState, leftState, rightState) + if !ok { + return nil, false + } + if member == nil { + continue + } + merged = append(merged, *member) + } + return merged, true +} + +type archiveMember struct { + Name string + Body []byte +} + +type archiveMemberState struct { + present bool + member archiveMember +} + +func mergeArchiveMember(baseState, leftState, rightState archiveMemberState) (*archiveMember, bool) { + switch { + case equalArchiveMemberState(leftState, rightState): + if !leftState.present { + return nil, true + } + member := leftState.member + return &member, true + case equalArchiveMemberState(leftState, baseState): + if !rightState.present { + return nil, true + } + member := rightState.member + return &member, true + case equalArchiveMemberState(rightState, baseState): + if !leftState.present { + return nil, true + } + member := leftState.member + return &member, true + default: + return nil, false + } +} + +func readArchiveMembers(path string) ([]archiveMember, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return parseArchiveMemberBodies(data) +} + +func parseArchiveMemberBodies(data []byte) ([]archiveMember, error) { + const globalHeader = "!\n" + const fileHeaderLen = 60 + if len(data) < len(globalHeader) || string(data[:len(globalHeader)]) != globalHeader { + return nil, fmt.Errorf("invalid archive header") + } + + offset := len(globalHeader) + var stringTable []byte + members := make([]archiveMember, 0) + for offset < len(data) { + if len(data)-offset < fileHeaderLen { + return nil, fmt.Errorf("truncated archive header") + } + header := data[offset : offset+fileHeaderLen] + offset += fileHeaderLen + + if string(header[58:60]) != "`\n" { + return nil, fmt.Errorf("invalid archive file trailer") + } + + nameField := strings.TrimSpace(string(header[:16])) + sizeField := strings.TrimSpace(string(header[48:58])) + size, err := strconv.Atoi(sizeField) + if err != nil || size < 0 { + return nil, fmt.Errorf("invalid archive member size %q", sizeField) + } + if len(data)-offset < size { + return nil, fmt.Errorf("truncated archive member data") + } + + payload := data[offset : offset+size] + offset += size + if offset%2 != 0 { + offset++ + } + + if isArchiveSpecialName(nameField) { + if nameField == "//" { + stringTable = append(stringTable[:0], payload...) + } + continue + } + + name, body, err := resolveArchiveMember(nameField, payload, stringTable) + if err != nil { + return nil, err + } + members = append(members, archiveMember{Name: name, Body: bytes.Clone(body)}) + } + return members, nil +} + +func encodeArchiveMembers(members []archiveMember) ([]byte, error) { + var buf bytes.Buffer + buf.WriteString("!\n") + for _, member := range members { + headerName := member.Name + payload := member.Body + if len(headerName) > 15 || strings.Contains(headerName, " ") || strings.Contains(headerName, "/") { + headerName = fmt.Sprintf("#1/%d", len(member.Name)) + payload = append([]byte(member.Name), payload...) + } else if !strings.HasSuffix(headerName, "/") { + headerName += "/" + } + header := fmt.Sprintf("%-16s%-12d%-6d%-6d%-8o%-10d`\n", headerName, 0, 0, 0, 0o644, len(payload)) + if len(header) != 60 { + return nil, fmt.Errorf("unexpected archive header length %d", len(header)) + } + buf.WriteString(header) + buf.Write(payload) + if len(payload)%2 != 0 { + buf.WriteByte('\n') + } + } + return buf.Bytes(), nil +} + +func archiveDigestBytes(members []archiveMember) []byte { + if len(members) == 0 { + return nil + } + var buf bytes.Buffer + for _, member := range members { + buf.WriteString(member.Name) + buf.WriteByte(0) + buf.WriteString(digestBytesShort(member.Body)) + buf.WriteByte('\n') + } + return buf.Bytes() +} + +func archiveMemberMap(members []archiveMember) map[string]archiveMember { + out := make(map[string]archiveMember, len(members)) + for _, member := range members { + out[member.Name] = member + } + return out +} + +func archiveMemberOrder(baseMembers, leftMembers, rightMembers []archiveMember) []string { + seen := make(map[string]struct{}, len(baseMembers)+len(leftMembers)+len(rightMembers)) + out := make([]string, 0, len(seen)) + appendNames := func(members []archiveMember) { + for _, member := range members { + if _, ok := seen[member.Name]; ok { + continue + } + seen[member.Name] = struct{}{} + out = append(out, member.Name) + } + } + appendNames(baseMembers) + appendNames(leftMembers) + appendNames(rightMembers) + return out +} + +func materializeState(path, root string, state manifestState) (outputContent, error) { + if !state.present { + return outputContent{}, nil + } + if state.entry.Kind == "symlink" { + return outputContent{ + symlink: true, + target: state.entry.Target, + }, nil + } + data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) + if err != nil { + return outputContent{}, err + } + return outputContent{ + bytes: data, + executable: state.entry.Executable, + ranlib: state.entry.Kind == "archive", + }, nil +} + +func mergeTextData(base, left, right []byte) ([]byte, bool, error) { + if bytes.Equal(left, right) { + return bytes.Clone(left), true, nil + } + if bytes.Equal(left, base) { + return bytes.Clone(right), true, nil + } + if bytes.Equal(right, base) { + return bytes.Clone(left), true, nil + } + + if _, err := exec.LookPath("git"); err != nil { + return nil, false, nil + } + + tmpDir, err := os.MkdirTemp("", "llar-diff3-*") + if err != nil { + return nil, false, err + } + defer os.RemoveAll(tmpDir) + + basePath := filepath.Join(tmpDir, "base") + leftPath := filepath.Join(tmpDir, "left") + rightPath := filepath.Join(tmpDir, "right") + if err := os.WriteFile(basePath, base, 0o644); err != nil { + return nil, false, err + } + if err := os.WriteFile(leftPath, left, 0o644); err != nil { + return nil, false, err + } + if err := os.WriteFile(rightPath, right, 0o644); err != nil { + return nil, false, err + } + + cmd := exec.Command("git", "merge-file", "-p", leftPath, basePath, rightPath) + out, err := cmd.Output() + if err == nil { + return out, true, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return nil, false, nil + } + return nil, false, err +} + +func runRanlib(path string) error { + if _, err := exec.LookPath("ranlib"); err != nil { + return nil + } + cmd := exec.Command("ranlib", path) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("ranlib %s: %w (%s)", path, err, strings.TrimSpace(string(out))) + } + return nil +} + +func isTextData(data []byte) bool { + if bytes.IndexByte(data, 0) >= 0 { + return false + } + return utf8.Valid(data) +} + +func equalManifestState(left, right manifestState) bool { + if left.present != right.present { + return false + } + if !left.present { + return true + } + return left.entry == right.entry +} + +func equalArchiveMemberState(left, right archiveMemberState) bool { + if left.present != right.present { + return false + } + if !left.present { + return true + } + return left.member.Name == right.member.Name && bytes.Equal(left.member.Body, right.member.Body) +} + +func manifestStateForPath(entries map[string]OutputEntry, path string) manifestState { + entry, ok := entries[path] + return manifestState{present: ok, entry: entry} +} + +func archiveMemberStateForName(entries map[string]archiveMember, name string) archiveMemberState { + member, ok := entries[name] + return archiveMemberState{present: ok, member: member} +} + +func mergeScalar(base, left, right string) (string, bool) { + switch { + case left == right: + return left, true + case left == base: + return right, true + case right == base: + return left, true + default: + return "", false + } +} + +func mergeMetadata(base, left, right string) (string, bool) { + if merged, ok := mergeScalar(base, left, right); ok { + return merged, true + } + + baseTokens, ok := tokenizeMergeableMetadata(base) + if !ok { + return "", false + } + leftTokens, ok := tokenizeMergeableMetadata(left) + if !ok { + return "", false + } + rightTokens, ok := tokenizeMergeableMetadata(right) + if !ok { + return "", false + } + if !hasTokenPrefix(leftTokens, baseTokens) || !hasTokenPrefix(rightTokens, baseTokens) { + return "", false + } + + merged := append([]string{}, baseTokens...) + seen := make(map[string]struct{}, len(baseTokens)) + for _, token := range baseTokens { + seen[token] = struct{}{} + } + for _, token := range leftTokens[len(baseTokens):] { + if _, ok := seen[token]; ok { + continue + } + merged = append(merged, token) + seen[token] = struct{}{} + } + for _, token := range rightTokens[len(baseTokens):] { + if _, ok := seen[token]; ok { + continue + } + merged = append(merged, token) + seen[token] = struct{}{} + } + return strings.Join(merged, " "), true +} + +func tokenizeMergeableMetadata(metadata string) ([]string, bool) { + trimmed := strings.TrimSpace(metadata) + if trimmed == "" { + return nil, true + } + if strings.ContainsAny(trimmed, "\"'\\") { + return nil, false + } + tokens := strings.Fields(trimmed) + if len(tokens) == 0 { + return nil, true + } + expectValue := false + for _, token := range tokens { + if expectValue { + expectValue = false + continue + } + if !strings.HasPrefix(token, "-") { + return nil, false + } + switch token { + case "-framework", "-include", "-isystem", "-Xlinker", "-u": + expectValue = true + } + } + if expectValue { + return nil, false + } + return tokens, true +} + +func hasTokenPrefix(tokens, prefix []string) bool { + if len(prefix) > len(tokens) { + return false + } + for i := range prefix { + if tokens[i] != prefix[i] { + return false + } + } + return true +} + +func mergeBool(base, left, right bool) (bool, bool) { + switch { + case left == right: + return left, true + case left == base: + return right, true + case right == base: + return left, true + default: + return false, false + } +} + +func describeManifestIssue(path, baseDir string, baseState manifestState, leftDir string, leftState manifestState, rightDir string, rightState manifestState) (OutputMergeIssueKind, string, string) { + switch { + case !baseState.present: + return OutputMergeIssueKindPathAddedDifferently, + "path added differently; automatic merge unavailable", + "path was added on both sides with different outputs, so this pair needs a real combined build" + case !leftState.present || !rightState.present: + return OutputMergeIssueKindPathDeleteChange, + "path deleted on one side and changed on the other; automatic merge unavailable", + "path is missing on one or more sides, so this pair needs a real combined build" + case leftState.entry.Kind != rightState.entry.Kind || leftState.entry.Kind != baseState.entry.Kind: + return OutputMergeIssueKindPathKindMismatch, + "path kind changed incompatibly; automatic merge unavailable", + "path kind differs across sides, so this pair needs a real combined build" + } + switch baseState.entry.Kind { + case "archive": + baseMembers, err := readArchiveMembers(filepath.Join(baseDir, filepath.FromSlash(path))) + if err != nil { + return OutputMergeIssueKindArchiveUnmergeable, "archive changed on both sides; automatic merge unavailable", "failed to read base archive members" + } + leftMembers, err := readArchiveMembers(filepath.Join(leftDir, filepath.FromSlash(path))) + if err != nil { + return OutputMergeIssueKindArchiveUnmergeable, "archive changed on both sides; automatic merge unavailable", "failed to read left archive members" + } + rightMembers, err := readArchiveMembers(filepath.Join(rightDir, filepath.FromSlash(path))) + if err != nil { + return OutputMergeIssueKindArchiveUnmergeable, "archive changed on both sides; automatic merge unavailable", "failed to read right archive members" + } + summary := summarizeArchiveConflictMembers(baseMembers, leftMembers, rightMembers) + if summary == "" { + return OutputMergeIssueKindArchiveUnmergeable, + "archive changed on both sides; automatic merge unavailable", + "both sides changed this archive relative to base, so automatic archive merge cannot materialize a combined output" + } + return OutputMergeIssueKindArchiveUnmergeable, + "archive changed on both sides; automatic merge unavailable", + "both sides changed this archive relative to base, so automatic archive merge cannot materialize a combined output\n" + summary + case "file": + return summarizeRegularFileIssue(path, baseDir, leftDir, rightDir) + default: + return OutputMergeIssueKindUnsupportedKind, + "unsupported output kind; automatic merge unavailable", + "unsupported output kind " + baseState.entry.Kind + } +} + +func summarizeArchiveConflictMembers(baseMembers, leftMembers, rightMembers []archiveMember) string { + baseByName := archiveMemberMap(baseMembers) + leftByName := archiveMemberMap(leftMembers) + rightByName := archiveMemberMap(rightMembers) + orderedNames := archiveMemberOrder(baseMembers, leftMembers, rightMembers) + conflicts := make([]string, 0) + for _, name := range orderedNames { + baseState := archiveMemberStateForName(baseByName, name) + leftState := archiveMemberStateForName(leftByName, name) + rightState := archiveMemberStateForName(rightByName, name) + if _, ok := mergeArchiveMember(baseState, leftState, rightState); ok { + continue + } + conflicts = append(conflicts, name) + } + if len(conflicts) == 0 { + return "" + } + const limit = 6 + if len(conflicts) > limit { + lines := []string{fmt.Sprintf("conflicting members (%d):", len(conflicts))} + lines = append(lines, conflicts[:limit]...) + lines = append(lines, fmt.Sprintf("(+%d more)", len(conflicts)-limit)) + return strings.Join(lines, "\n") + } + lines := []string{fmt.Sprintf("conflicting members (%d):", len(conflicts))} + lines = append(lines, conflicts...) + return strings.Join(lines, "\n") +} + +func summarizeRegularFileIssue(path, baseDir, leftDir, rightDir string) (OutputMergeIssueKind, string, string) { + baseData, err := os.ReadFile(filepath.Join(baseDir, filepath.FromSlash(path))) + if err != nil { + return OutputMergeIssueKindFileBinaryUnmergeable, "file changed on both sides; automatic merge unavailable", "failed to read base file" + } + leftData, err := os.ReadFile(filepath.Join(leftDir, filepath.FromSlash(path))) + if err != nil { + return OutputMergeIssueKindFileBinaryUnmergeable, "file changed on both sides; automatic merge unavailable", "failed to read left file" + } + rightData, err := os.ReadFile(filepath.Join(rightDir, filepath.FromSlash(path))) + if err != nil { + return OutputMergeIssueKindFileBinaryUnmergeable, "file changed on both sides; automatic merge unavailable", "failed to read right file" + } + if !isTextData(baseData) || !isTextData(leftData) || !isTextData(rightData) { + return OutputMergeIssueKindFileBinaryUnmergeable, + "non-text file changed on both sides; automatic merge unavailable", + "both sides changed this non-text file relative to base, so a real combined build is required" + } + _, ok, err := mergeTextData(baseData, leftData, rightData) + if err != nil { + return OutputMergeIssueKindFileTextUnmergeable, + "text file changed on both sides; automatic merge unavailable", + "both sides changed this text file, and automatic three-way merge failed with an error" + } + if !ok { + return OutputMergeIssueKindFileTextUnmergeable, + "text file changed on both sides; automatic merge unavailable", + "both sides changed this text file, and automatic three-way merge reported overlapping edits" + } + return OutputMergeIssueKindFileTextUnmergeable, + "text file changed on both sides; automatic merge unavailable", + "both sides changed this text file relative to base, so a real combined build is required" +} + +func describeMetadataConflictDetail(base, left, right string) string { + baseTokens, baseOK := tokenizeMergeableMetadata(base) + leftTokens, leftOK := tokenizeMergeableMetadata(left) + rightTokens, rightOK := tokenizeMergeableMetadata(right) + if !baseOK || !leftOK || !rightOK { + return "both sides changed metadata, and automatic flag merge does not support this syntax; a real combined build is required" + } + if !hasTokenPrefix(leftTokens, baseTokens) || !hasTokenPrefix(rightTokens, baseTokens) { + return "both sides changed metadata, and the shared base flags are not a common prefix; a real combined build is required" + } + return "both sides changed metadata, and automatic flag merge could not reconcile them" +} + +func summarizeMetadata(metadata string) string { + trimmed := strings.TrimSpace(metadata) + if trimmed == "" { + return "" + } + if len(trimmed) > 160 { + return trimmed[:157] + "..." + } + return trimmed +} + +func summarizeManifestState(state manifestState) string { + if !state.present { + return "" + } + entry := state.entry + parts := []string{"kind=" + entry.Kind} + if entry.Digest != "" { + parts = append(parts, "digest="+entry.Digest) + } + if entry.Target != "" { + parts = append(parts, "target="+entry.Target) + } + if entry.Executable { + parts = append(parts, "executable=true") + } + return strings.Join(parts, ", ") +} diff --git a/internal/evaluator/output_replay.go b/internal/evaluator/output_replay.go new file mode 100644 index 0000000..d15b003 --- /dev/null +++ b/internal/evaluator/output_replay.go @@ -0,0 +1,1304 @@ +package evaluator + +import ( + "bytes" + "context" + "fmt" + "io" + "io/fs" + "maps" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/goplus/llar/internal/trace" + tracessa "github.com/goplus/llar/internal/trace/ssa" +) + +var replayEnvKeyRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +const ( + replayMaxChangedRoots = 2 + replayMaxSelectedRoots = 4 + replayMaxSelectedWrites = 128 +) + +type replayRoot struct { + identity string + siteKey string + pid int64 + cwd string + argv []string + env []string + reads []string + writes []string +} + +type replayRootScan struct { + candidates int + roots []replayRoot + graph tracessa.Graph +} + +type replayParsedArgv struct { + program string + opaque []string + keyedOrder []string + keyed map[string]string + additiveOrder []string + additive map[string]struct{} +} + +type replayEnvSpec struct { + order []string + values map[string]string +} + +type replayPaths struct { + sourceRoot string + buildRoot string + installRoot string +} + +func synthesizeByRootReplay(ctx context.Context, base, left, right ProbeResult) (OutputSynthesisResult, error) { + result := OutputSynthesisResult{ + Mode: OutputSynthesisModeRootReplay, + Status: OutputMergeStatusNeedsRebuild, + } + metadata, ok := mergeMetadata(base.OutputManifest.Metadata, left.OutputManifest.Metadata, right.OutputManifest.Metadata) + if !ok { + result.Issues = append(result.Issues, replayIssue( + OutputMergeIssueKindRootReplayUnavailable, + "metadata requires real pair build", + "metadata cannot be merged before replay", + )) + return result, nil + } + result.Metadata = metadata + + plan, unavailable := planRootReplay(base, left, right) + result.Replay = plan.summary + if unavailable != "" { + if result.Replay == nil { + result.Replay = &RootReplaySummary{Unavailable: unavailable} + } else { + result.Replay.Unavailable = unavailable + } + result.Issues = append(result.Issues, replayIssue( + OutputMergeIssueKindRootReplayUnavailable, + "root replay is unavailable", + unavailable, + )) + return result, nil + } + + replayResult, err := executeRootReplay(ctx, plan, metadata) + if err != nil { + return OutputSynthesisResult{}, err + } + return replayResult, nil +} + +type replayPlan struct { + steps []replayRoot + base ProbeResult + summary *RootReplaySummary +} + +type alignedReplayRoots struct { + base []replayRoot + left []replayRoot + right []replayRoot +} + +func planRootReplay(base, left, right ProbeResult) (replayPlan, string) { + switch { + case !base.ReplayReady || !left.ReplayReady || !right.ReplayReady: + return replayPlan{summary: &RootReplaySummary{Unavailable: "replay-ready trace scope is required on base, left, and right probes"}}, "replay-ready trace scope is required on base, left, and right probes" + case base.Scope.SourceRoot == "" || left.Scope.SourceRoot == "" || right.Scope.SourceRoot == "": + return replayPlan{summary: &RootReplaySummary{Unavailable: "missing preserved source root for replay"}}, "missing preserved source root for replay" + case base.OutputDir == "" || left.OutputDir == "" || right.OutputDir == "": + return replayPlan{summary: &RootReplaySummary{Unavailable: "missing output directory for replay planning"}}, "missing output directory for replay planning" + } + + baseScan := replayRoots(base) + leftScan := replayRoots(left) + rightScan := replayRoots(right) + summary := &RootReplaySummary{ + CandidateRoots: maxInt(baseScan.candidates, leftScan.candidates, rightScan.candidates), + EligibleRoots: maxInt(len(baseScan.roots), len(leftScan.roots), len(rightScan.roots)), + } + if len(baseScan.roots) == 0 || len(leftScan.roots) == 0 || len(rightScan.roots) == 0 { + summary.Unavailable = "no replayable top-level roots found" + return replayPlan{summary: summary}, summary.Unavailable + } + aligned, unavailable := alignReplayRoots(baseScan.roots, leftScan.roots, rightScan.roots) + if unavailable != "" { + summary.Unavailable = unavailable + return replayPlan{summary: summary}, unavailable + } + leftJoinRoots := replayJoinRootIndexes(base, left, leftScan) + rightJoinRoots := replayJoinRootIndexes(base, right, rightScan) + joinIndexes := make(map[int]struct{}, len(leftJoinRoots)+len(rightJoinRoots)) + for idx := range leftJoinRoots { + joinIndexes[idx] = struct{}{} + } + for idx := range rightJoinRoots { + joinIndexes[idx] = struct{}{} + } + + steps := make([]replayRoot, 0, len(aligned.base)) + changedIndexes := make(map[int]struct{}) + for i := range aligned.base { + baseStep := aligned.base[i] + leftStep := aligned.left[i] + rightStep := aligned.right[i] + if baseStep.siteKey != leftStep.siteKey || baseStep.siteKey != rightStep.siteKey { + summary.Unavailable = "eligible replay root sites do not align across probes" + return replayPlan{summary: summary}, summary.Unavailable + } + if replayUsesShellCommand(baseStep.argv) || replayUsesShellCommand(leftStep.argv) || replayUsesShellCommand(rightStep.argv) { + summary.Unavailable = fmt.Sprintf("shell command wrapper at replay root %q is unsupported", baseStep.siteKey) + return replayPlan{summary: summary}, summary.Unavailable + } + merged, stepChanged, err := mergeReplayRoot(baseStep, leftStep, rightStep) + if err != nil { + summary.Unavailable = err.Error() + return replayPlan{summary: summary}, summary.Unavailable + } + if stepChanged { + changedIndexes[i] = struct{}{} + summary.ChangedRoots = append(summary.ChangedRoots, merged.siteKey) + } + steps = append(steps, merged) + } + if len(changedIndexes) == 0 { + summary.Unavailable = "no replay root parameters changed across probes" + return replayPlan{summary: summary}, summary.Unavailable + } + selected := selectReplayFrontier(steps, changedIndexes) + if len(joinIndexes) != 0 { + selected = selectReplayJoinFrontier(steps, changedIndexes, joinIndexes) + } + if len(selected) == 0 { + summary.Unavailable = "no replay roots selected after frontier planning" + return replayPlan{summary: summary}, summary.Unavailable + } + for _, idx := range selected { + summary.SelectedRoots = append(summary.SelectedRoots, steps[idx].siteKey) + summary.SelectedCommands = append(summary.SelectedCommands, strings.Join(steps[idx].argv, " ")) + } + summary.SelectedWrites = countReplayFrontierWrites(steps, selected) + switch { + case len(summary.ChangedRoots) > replayMaxChangedRoots: + summary.Unavailable = fmt.Sprintf("replay frontier too wide: changed roots=%d exceeds limit %d", len(summary.ChangedRoots), replayMaxChangedRoots) + return replayPlan{summary: summary}, summary.Unavailable + case len(selected) > replayMaxSelectedRoots: + summary.Unavailable = fmt.Sprintf("replay frontier too wide: selected roots=%d exceeds limit %d", len(selected), replayMaxSelectedRoots) + return replayPlan{summary: summary}, summary.Unavailable + case summary.SelectedWrites > replayMaxSelectedWrites: + summary.Unavailable = fmt.Sprintf("replay frontier too wide: selected writes=%d exceeds limit %d", summary.SelectedWrites, replayMaxSelectedWrites) + return replayPlan{summary: summary}, summary.Unavailable + } + selectedSteps := make([]replayRoot, 0, len(selected)) + for _, idx := range selected { + selectedSteps = append(selectedSteps, steps[idx]) + } + return replayPlan{steps: selectedSteps, base: base, summary: summary}, "" +} + +func executeRootReplay(ctx context.Context, plan replayPlan, metadata string) (OutputSynthesisResult, error) { + base := plan.base + sourceRoot, cleanupSource, err := cloneReplaySource(base.Scope.SourceRoot) + if err != nil { + return OutputSynthesisResult{}, err + } + defer cleanupSource() + + buildRoot, cleanupBuild, err := deriveReplayBuildRoot(base.Scope, sourceRoot) + if err != nil { + return OutputSynthesisResult{}, err + } + defer cleanupBuild() + if err := prepareReplayBuildRoot(buildRoot, plan.steps); err != nil { + return OutputSynthesisResult{}, err + } + + installRoot, cleanupInstall, err := cloneReplayOutput(base.OutputDir) + if err != nil { + return OutputSynthesisResult{}, err + } + keepInstall := false + defer func() { + if !keepInstall { + cleanupInstall() + } + }() + + paths := replayPaths{ + sourceRoot: sourceRoot, + buildRoot: buildRoot, + installRoot: installRoot, + } + for _, step := range plan.steps { + if err := runReplayStep(ctx, step, paths); err != nil { + return OutputSynthesisResult{ + Mode: OutputSynthesisModeRootReplay, + Status: OutputMergeStatusNeedsRebuild, + Metadata: metadata, + Replay: plan.summary, + Issues: []OutputSynthesisIssue{replayIssue( + OutputMergeIssueKindRootReplayFailed, + "root replay execution failed", + err.Error(), + )}, + }, nil + } + } + + manifest, err := BuildOutputManifest(installRoot, metadata) + if err != nil { + return OutputSynthesisResult{}, err + } + keepInstall = true + return OutputSynthesisResult{ + Mode: OutputSynthesisModeRootReplay, + Status: OutputMergeStatusMerged, + Root: installRoot, + Metadata: metadata, + Manifest: manifest, + Replay: plan.summary, + }, nil +} + +func replayRoots(probe ProbeResult) replayRootScan { + if len(probe.Records) == 0 { + return replayRootScan{} + } + graph := buildGraphForProbe(probe) + roles := tracessa.ProjectRoles(graph) + pids := make(map[int64]struct{}, len(probe.Records)) + for _, rec := range probe.Records { + if rec.PID != 0 { + pids[rec.PID] = struct{}{} + } + } + candidates := 0 + roots := make([]replayRoot, 0, len(probe.Records)) + for _, rec := range probe.Records { + if len(rec.Argv) == 0 { + continue + } + if rec.ParentPID != 0 { + if _, ok := pids[rec.ParentPID]; ok { + continue + } + } + candidates++ + argv := normalizeReplayTokens(rec.Argv, probe.Scope) + env := normalizeReplayEnv(rec.Env, probe.Scope) + cwd := normalizeScopeToken(rec.Cwd, probe.Scope) + reads := filterReplayRelevantPaths(rec.Inputs, probe.Scope, graph, roles) + writes := filterReplayRelevantPaths(rec.Changes, probe.Scope, graph, roles) + if !replayRootRelevant(reads, writes) { + continue + } + roots = append(roots, replayRoot{ + identity: replayRootIdentity(argv, cwd), + siteKey: replaySiteKey(argv, cwd), + pid: rec.PID, + cwd: cwd, + argv: argv, + env: env, + reads: reads, + writes: writes, + }) + } + return replayRootScan{candidates: candidates, roots: roots, graph: graph} +} + +func selectReplayFrontier(steps []replayRoot, changed map[int]struct{}) []int { + selected := make(map[int]struct{}, len(changed)) + for idx := range changed { + selected[idx] = struct{}{} + } + + queue := make([]int, 0, len(changed)) + for idx := range changed { + queue = append(queue, idx) + } + slices.Sort(queue) + + for len(queue) > 0 { + idx := queue[0] + queue = queue[1:] + writes := pathSet(steps[idx].writes) + if len(writes) == 0 { + continue + } + for next := idx + 1; next < len(steps); next++ { + if _, ok := selected[next]; ok { + continue + } + if !pathsOverlap(writes, steps[next].reads) { + continue + } + selected[next] = struct{}{} + queue = append(queue, next) + } + } + + order := make([]int, 0, len(selected)) + for idx := range selected { + order = append(order, idx) + } + slices.Sort(order) + return order +} + +func selectReplayJoinFrontier(steps []replayRoot, changed, join map[int]struct{}) []int { + if len(join) == 0 { + return selectReplayFrontier(steps, changed) + } + selected := make(map[int]struct{}, len(join)+len(changed)) + for idx := range join { + selected[idx] = struct{}{} + } + for { + changedAdded := false + for target := range selected { + if target < 0 || target >= len(steps) { + continue + } + for prev := 0; prev < target; prev++ { + if _, ok := changed[prev]; !ok { + continue + } + if _, ok := selected[prev]; ok { + continue + } + if !pathsOverlap(pathSet(steps[prev].writes), steps[target].reads) { + continue + } + selected[prev] = struct{}{} + changedAdded = true + } + } + if !changedAdded { + break + } + } + + queue := make([]int, 0, len(join)) + for idx := range join { + queue = append(queue, idx) + } + slices.Sort(queue) + for len(queue) > 0 { + idx := queue[0] + queue = queue[1:] + writes := pathSet(steps[idx].writes) + if len(writes) == 0 { + continue + } + for next := idx + 1; next < len(steps); next++ { + if _, ok := selected[next]; ok { + continue + } + if !pathsOverlap(writes, steps[next].reads) { + continue + } + selected[next] = struct{}{} + queue = append(queue, next) + } + } + order := make([]int, 0, len(selected)) + for idx := range selected { + order = append(order, idx) + } + slices.Sort(order) + return order +} + +func countReplayFrontierWrites(steps []replayRoot, selected []int) int { + if len(selected) == 0 { + return 0 + } + materialized := make(map[string]struct{}) + seen := make(map[string]struct{}) + for _, idx := range selected { + for _, path := range steps[idx].writes { + if isReplayMaterializedPath(path) { + materialized[path] = struct{}{} + } + seen[path] = struct{}{} + } + } + if len(materialized) != 0 { + return len(materialized) + } + return len(seen) +} + +func isReplayMaterializedPath(path string) bool { + path = normalizePath(path) + return path == "$INSTALL" || strings.HasPrefix(path, "$INSTALL/") +} + +func pathSet(paths []string) map[string]struct{} { + if len(paths) == 0 { + return nil + } + out := make(map[string]struct{}, len(paths)) + for _, path := range paths { + out[path] = struct{}{} + } + return out +} + +func pathsOverlap(left map[string]struct{}, right []string) bool { + if len(left) == 0 || len(right) == 0 { + return false + } + for _, path := range right { + if _, ok := left[path]; ok { + return true + } + } + return false +} + +func filterReplayRelevantPaths(paths []string, scope trace.Scope, graph tracessa.Graph, roles tracessa.RoleProjection) []string { + if len(paths) == 0 { + return nil + } + out := make([]string, 0, len(paths)) + seen := make(map[string]struct{}, len(paths)) + for _, path := range paths { + if !replayPathRelevant(path, graph, roles) { + continue + } + token := normalizeScopeToken(path, scope) + if token == "" { + continue + } + if _, ok := seen[token]; ok { + continue + } + seen[token] = struct{}{} + out = append(out, token) + } + return out +} + +func replayPathRelevant(path string, graph tracessa.Graph, roles tracessa.RoleProjection) bool { + if path == "" { + return false + } + token := normalizeScopeToken(path, graph.Scope) + if !strings.Contains(token, "$SRC") && !strings.Contains(token, "$BUILD") && !strings.Contains(token, "$INSTALL") { + return false + } + path = normalizePath(path) + if _, ok := graph.Paths[path]; !ok { + return false + } + if isExplicitDeliveryPath(path, graph.Scope) { + return true + } + if len(graph.DefsByPath[path]) != 0 { + for _, def := range graph.DefsByPath[path] { + class := tracessa.RoleDefClass(roles, def) + if class != tracessa.DefRoleProbe && class != tracessa.DefRoleTooling { + return true + } + } + return false + } + if tracessa.IsProbeOnlyNoisePathProjected(graph, roles, path) { + return false + } + if tracessa.PathLooksToolingProjected(graph, roles, path) { + return false + } + return true +} + +func replayRootRelevant(reads, writes []string) bool { + return len(reads) != 0 || len(writes) != 0 +} + +func normalizeReplayTokens(tokens []string, scope trace.Scope) []string { + out := make([]string, 0, len(tokens)) + for _, token := range tokens { + out = append(out, normalizeScopeToken(token, scope)) + } + return out +} + +func normalizeReplayEnv(env []string, scope trace.Scope) []string { + return tracessa.NormalizeExecEnv(env, scope) +} + +func replayJoinRootIndexes(base, probe ProbeResult, scan replayRootScan) map[int]struct{} { + if len(scan.roots) == 0 { + return nil + } + analysis := tracessa.AnalyzeWithEvidence(tracessa.AnalysisInput{ + Base: tracessa.AnalysisSideInput{ + Records: base.Records, + Events: base.Events, + Scope: base.Scope, + InputDigests: base.InputDigests, + }, + Probe: tracessa.AnalysisSideInput{ + Records: probe.Records, + Events: probe.Events, + Scope: probe.Scope, + InputDigests: probe.InputDigests, + }, + }, buildImpactEvidence(base, probe)) + joinActions := replayMinJoinActionIndexes(scan.graph, analysis.Profile.JoinSet) + if len(joinActions) == 0 { + return nil + } + rootByPID, ambiguousRootPID := replayRootIndexesByPID(scan.roots) + recordPIDs, parentByPID := replayProcessTree(probe.Records) + out := make(map[int]struct{}, len(joinActions)) + for _, actionIdx := range joinActions { + if actionIdx < 0 || actionIdx >= len(scan.graph.Actions) { + continue + } + action := scan.graph.Actions[actionIdx] + rootPID, ok := replayTopLevelPID(action.PID, recordPIDs, parentByPID) + if !ok { + continue + } + if _, ambiguous := ambiguousRootPID[rootPID]; ambiguous { + continue + } + rootIdx, ok := rootByPID[rootPID] + if !ok { + continue + } + out[rootIdx] = struct{}{} + } + return out +} + +func replayMinJoinActionIndexes(graph tracessa.Graph, joinSet []int) []int { + if len(joinSet) == 0 || len(graph.Actions) == 0 { + return nil + } + join := make(map[int]struct{}, len(joinSet)) + for _, idx := range joinSet { + if idx < 0 || idx >= len(graph.Actions) { + continue + } + join[idx] = struct{}{} + } + if len(join) == 0 { + return nil + } + order := make([]int, 0, len(join)) + for idx := range join { + order = append(order, idx) + } + slices.Sort(order) + minimal := make([]int, 0, len(order)) + for _, idx := range order { + if replayHasJoinAncestor(graph, idx, join) { + continue + } + minimal = append(minimal, idx) + } + return minimal +} + +func replayHasJoinAncestor(graph tracessa.Graph, idx int, join map[int]struct{}) bool { + if idx < 0 || idx >= len(graph.In) { + return false + } + visited := map[int]struct{}{idx: {}} + stack := []int{idx} + for len(stack) > 0 { + cur := stack[len(stack)-1] + stack = stack[:len(stack)-1] + for _, edge := range graph.In[cur] { + pred := edge.From + if pred < 0 || pred >= len(graph.Actions) { + continue + } + if _, seen := visited[pred]; seen { + continue + } + if _, ok := join[pred]; ok { + return true + } + visited[pred] = struct{}{} + stack = append(stack, pred) + } + } + return false +} + +func replayRootIndexesByPID(roots []replayRoot) (map[int64]int, map[int64]struct{}) { + indexes := make(map[int64]int, len(roots)) + ambiguous := make(map[int64]struct{}) + for idx, root := range roots { + if root.pid == 0 { + continue + } + if prev, ok := indexes[root.pid]; ok && prev != idx { + ambiguous[root.pid] = struct{}{} + delete(indexes, root.pid) + continue + } + if _, ok := ambiguous[root.pid]; ok { + continue + } + indexes[root.pid] = idx + } + return indexes, ambiguous +} + +func replayProcessTree(records []trace.Record) (map[int64]struct{}, map[int64]int64) { + pids := make(map[int64]struct{}, len(records)) + parentByPID := make(map[int64]int64, len(records)) + for _, rec := range records { + if rec.PID == 0 { + continue + } + pids[rec.PID] = struct{}{} + if _, ok := parentByPID[rec.PID]; !ok { + parentByPID[rec.PID] = rec.ParentPID + } + } + return pids, parentByPID +} + +func replayTopLevelPID(pid int64, pids map[int64]struct{}, parentByPID map[int64]int64) (int64, bool) { + if pid == 0 { + return 0, false + } + if _, ok := pids[pid]; !ok { + return 0, false + } + cur := pid + for { + parent, ok := parentByPID[cur] + if !ok || parent == 0 { + return cur, true + } + if _, ok := pids[parent]; !ok { + return cur, true + } + cur = parent + } +} + +func replaySiteKey(argv []string, cwd string) string { + if spec, ok := parseReplayArgv(argv); ok { + var b strings.Builder + b.WriteString(spec.program) + for _, token := range spec.opaque { + b.WriteByte(' ') + b.WriteString(token) + } + if len(spec.keyedOrder) != 0 { + b.WriteString(" [") + for i, key := range spec.keyedOrder { + if i != 0 { + b.WriteString(", ") + } + b.WriteString(key) + } + b.WriteByte(']') + } + if len(spec.additiveOrder) != 0 { + b.WriteString(" {") + for i, token := range spec.additiveOrder { + if i != 0 { + b.WriteString(", ") + } + b.WriteString(token) + } + b.WriteByte('}') + } + if cwd != "" { + b.WriteString(" @ ") + b.WriteString(cwd) + } + return b.String() + } + if len(argv) == 0 { + return cwd + } + if cwd == "" { + return strings.Join(argv, " ") + } + return strings.Join(argv, " ") + " @ " + cwd +} + +func replayRootIdentity(argv []string, cwd string) string { + spec, ok := parseReplayArgv(argv) + if !ok { + return replaySiteKey(argv, cwd) + } + var parts []string + parts = append(parts, spec.program, "cwd="+cwd) + if len(spec.opaque) != 0 { + parts = append(parts, "opaque="+strings.Join(spec.opaque, "\x1f")) + } + if len(spec.keyedOrder) != 0 { + parts = append(parts, "keys="+strings.Join(spec.keyedOrder, "\x1f")) + } + if len(spec.additiveOrder) != 0 { + parts = append(parts, "add="+strings.Join(spec.additiveOrder, "\x1f")) + } + return strings.Join(parts, "|") +} + +func replayUsesShellCommand(argv []string) bool { + if len(argv) < 2 { + return false + } + tool := filepath.Base(argv[0]) + switch tool { + case "sh", "bash", "dash", "zsh": + return argv[1] == "-c" || argv[1] == "-lc" + default: + return false + } +} + +func mergeReplayRoot(base, left, right replayRoot) (replayRoot, bool, error) { + argv, argvChanged, err := mergeReplayArgv(base.argv, left.argv, right.argv) + if err != nil { + return replayRoot{}, false, err + } + env, envChanged, err := mergeReplayEnv(base.env, left.env, right.env) + if err != nil { + return replayRoot{}, false, err + } + return replayRoot{ + identity: base.identity, + siteKey: base.siteKey, + cwd: base.cwd, + argv: argv, + env: env, + reads: mergeReplayPaths(base.reads, left.reads, right.reads), + writes: mergeReplayPaths(base.writes, left.writes, right.writes), + }, argvChanged || envChanged, nil +} + +func alignReplayRoots(base, left, right []replayRoot) (alignedReplayRoots, string) { + baseByID, err := replayRootIndex(base) + if err != nil { + return alignedReplayRoots{}, err.Error() + } + leftByID, err := replayRootIndex(left) + if err != nil { + return alignedReplayRoots{}, err.Error() + } + rightByID, err := replayRootIndex(right) + if err != nil { + return alignedReplayRoots{}, err.Error() + } + baseIDs := replayRootIdentities(base) + leftIDs := replayRootIdentities(left) + rightIDs := replayRootIdentities(right) + if !slices.Equal(baseIDs, leftIDs) || !slices.Equal(baseIDs, rightIDs) { + return alignedReplayRoots{}, "eligible replay root identities differ across probes" + } + aligned := alignedReplayRoots{ + base: make([]replayRoot, 0, len(baseIDs)), + left: make([]replayRoot, 0, len(baseIDs)), + right: make([]replayRoot, 0, len(baseIDs)), + } + for _, id := range baseIDs { + aligned.base = append(aligned.base, baseByID[id]) + aligned.left = append(aligned.left, leftByID[id]) + aligned.right = append(aligned.right, rightByID[id]) + } + return aligned, "" +} + +func replayRootIndex(roots []replayRoot) (map[string]replayRoot, error) { + out := make(map[string]replayRoot, len(roots)) + for _, root := range roots { + if root.identity == "" { + return nil, fmt.Errorf("replay root %q is missing identity", root.siteKey) + } + if _, ok := out[root.identity]; ok { + return nil, fmt.Errorf("replay root identity %q is ambiguous", root.siteKey) + } + out[root.identity] = root + } + return out, nil +} + +func replayRootIdentities(roots []replayRoot) []string { + out := make([]string, 0, len(roots)) + for _, root := range roots { + out = append(out, root.identity) + } + return out +} + +func maxInt(values ...int) int { + out := 0 + for _, value := range values { + if value > out { + out = value + } + } + return out +} + +func mergeReplayPaths(base, left, right []string) []string { + seen := make(map[string]struct{}, len(base)+len(left)+len(right)) + out := make([]string, 0, len(base)+len(left)+len(right)) + for _, values := range [][]string{base, left, right} { + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + } + return out +} + +func mergeReplayArgv(base, left, right []string) ([]string, bool, error) { + baseSpec, ok := parseReplayArgv(base) + if !ok { + return nil, false, fmt.Errorf("unmergeable base argv %q", strings.Join(base, " ")) + } + leftSpec, ok := parseReplayArgv(left) + if !ok { + return nil, false, fmt.Errorf("unmergeable left argv %q", strings.Join(left, " ")) + } + rightSpec, ok := parseReplayArgv(right) + if !ok { + return nil, false, fmt.Errorf("unmergeable right argv %q", strings.Join(right, " ")) + } + if baseSpec.program != leftSpec.program || baseSpec.program != rightSpec.program { + return nil, false, fmt.Errorf("replay root executable differs across probes") + } + if !slices.Equal(baseSpec.opaque, leftSpec.opaque) || !slices.Equal(baseSpec.opaque, rightSpec.opaque) { + return nil, false, fmt.Errorf("opaque replay argv tokens differ across probes") + } + + mergedKeyed, keyedChanged, err := mergeReplayAssignments(baseSpec.keyed, leftSpec.keyed, rightSpec.keyed) + if err != nil { + return nil, false, err + } + mergedAdditive, additiveChanged, err := mergeReplayAdditive(baseSpec.additive, leftSpec.additive, rightSpec.additive) + if err != nil { + return nil, false, err + } + + merged := make([]string, 0, 1+len(baseSpec.opaque)+len(mergedKeyed)+len(mergedAdditive)) + merged = append(merged, baseSpec.program) + merged = append(merged, baseSpec.opaque...) + keyOrder := mergedAssignmentOrder(baseSpec.keyedOrder, leftSpec.keyedOrder, rightSpec.keyedOrder) + for _, key := range keyOrder { + value, ok := mergedKeyed[key] + if !ok { + continue + } + merged = append(merged, key+"="+value) + } + additiveOrder := mergedAssignmentOrder(baseSpec.additiveOrder, leftSpec.additiveOrder, rightSpec.additiveOrder) + for _, token := range additiveOrder { + if _, ok := mergedAdditive[token]; ok { + merged = append(merged, token) + } + } + return merged, keyedChanged || additiveChanged, nil +} + +func parseReplayArgv(argv []string) (replayParsedArgv, bool) { + if len(argv) == 0 { + return replayParsedArgv{}, false + } + spec := replayParsedArgv{ + program: argv[0], + keyed: make(map[string]string), + additive: make(map[string]struct{}), + } + for _, token := range argv[1:] { + if key, value, ok := parseReplayKeyedToken(token); ok { + if _, exists := spec.keyed[key]; exists { + return replayParsedArgv{}, false + } + spec.keyed[key] = value + spec.keyedOrder = append(spec.keyedOrder, key) + continue + } + if isReplayAdditiveToken(token) { + if _, exists := spec.additive[token]; !exists { + spec.additive[token] = struct{}{} + spec.additiveOrder = append(spec.additiveOrder, token) + } + continue + } + spec.opaque = append(spec.opaque, token) + } + return spec, true +} + +func parseReplayKeyedToken(token string) (string, string, bool) { + switch { + case strings.HasPrefix(token, "--") && strings.Contains(token, "="): + key, value, _ := strings.Cut(token, "=") + return key, value, true + case strings.HasPrefix(token, "-D") && strings.Contains(token[2:], "="): + key, value, _ := strings.Cut(token, "=") + return key, value, true + } + key, value, ok := strings.Cut(token, "=") + if !ok || key == "" || !replayEnvKeyRE.MatchString(key) { + return "", "", false + } + return key, value, true +} + +func isReplayAdditiveToken(token string) bool { + return strings.HasPrefix(token, "--with-") && !strings.Contains(token, "=") +} + +func parseReplayEnvSpec(env []string) (replayEnvSpec, bool) { + spec := replayEnvSpec{ + order: make([]string, 0, len(env)), + values: make(map[string]string, len(env)), + } + for _, entry := range env { + key, value, ok := strings.Cut(entry, "=") + if !ok || key == "" || !replayEnvKeyRE.MatchString(key) { + return replayEnvSpec{}, false + } + if _, exists := spec.values[key]; exists { + return replayEnvSpec{}, false + } + spec.values[key] = value + spec.order = append(spec.order, key) + } + return spec, true +} + +func mergeReplayEnv(base, left, right []string) ([]string, bool, error) { + baseSpec, ok := parseReplayEnvSpec(base) + if !ok { + return nil, false, fmt.Errorf("unmergeable base environment") + } + leftSpec, ok := parseReplayEnvSpec(left) + if !ok { + return nil, false, fmt.Errorf("unmergeable left environment") + } + rightSpec, ok := parseReplayEnvSpec(right) + if !ok { + return nil, false, fmt.Errorf("unmergeable right environment") + } + mergedValues, changed, err := mergeReplayAssignments(baseSpec.values, leftSpec.values, rightSpec.values) + if err != nil { + return nil, false, err + } + order := mergedAssignmentOrder(baseSpec.order, leftSpec.order, rightSpec.order) + merged := make([]string, 0, len(mergedValues)) + for _, key := range order { + value, ok := mergedValues[key] + if !ok { + continue + } + merged = append(merged, key+"="+value) + } + return merged, changed, nil +} + +func mergeReplayAssignments(base, left, right map[string]string) (map[string]string, bool, error) { + keys := make(map[string]struct{}, len(base)+len(left)+len(right)) + for key := range base { + keys[key] = struct{}{} + } + for key := range left { + keys[key] = struct{}{} + } + for key := range right { + keys[key] = struct{}{} + } + merged := make(map[string]string, len(keys)) + changed := false + for _, key := range slices.Collect(maps.Keys(keys)) { + baseValue, baseOK := base[key] + leftValue, leftOK := left[key] + rightValue, rightOK := right[key] + value, present, localChanged, ok := mergeReplayState(baseValue, baseOK, leftValue, leftOK, rightValue, rightOK) + if !ok { + return nil, false, fmt.Errorf("conflicting replay parameter for %q", key) + } + if present { + merged[key] = value + } + changed = changed || localChanged + } + return merged, changed, nil +} + +func mergeReplayAdditive(base, left, right map[string]struct{}) (map[string]struct{}, bool, error) { + merged := make(map[string]struct{}, len(base)+len(left)+len(right)) + for token := range base { + merged[token] = struct{}{} + } + changed := false + for token := range left { + merged[token] = struct{}{} + if _, ok := base[token]; !ok { + changed = true + } + } + for token := range right { + merged[token] = struct{}{} + if _, ok := base[token]; !ok { + changed = true + } + } + for token := range base { + if _, ok := left[token]; !ok { + return nil, false, fmt.Errorf("left replay root removed additive token %q", token) + } + if _, ok := right[token]; !ok { + return nil, false, fmt.Errorf("right replay root removed additive token %q", token) + } + } + return merged, changed, nil +} + +func mergeReplayState(baseValue string, baseOK bool, leftValue string, leftOK bool, rightValue string, rightOK bool) (string, bool, bool, bool) { + leftChanged := leftOK != baseOK || leftValue != baseValue + rightChanged := rightOK != baseOK || rightValue != baseValue + switch { + case !leftChanged && !rightChanged: + return baseValue, baseOK, false, true + case leftChanged && !rightChanged: + return leftValue, leftOK, true, true + case !leftChanged && rightChanged: + return rightValue, rightOK, true, true + case leftOK == rightOK && leftValue == rightValue: + return leftValue, leftOK, true, true + default: + return "", false, false, false + } +} + +func mergedAssignmentOrder(base, left, right []string) []string { + seen := make(map[string]struct{}, len(base)+len(left)+len(right)) + order := make([]string, 0, len(base)+len(left)+len(right)) + for _, values := range [][]string{base, left, right} { + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + order = append(order, value) + } + } + return order +} + +func cloneReplaySource(srcRoot string) (string, func(), error) { + dstRoot, err := os.MkdirTemp("", "llar-replay-src-*") + if err != nil { + return "", nil, err + } + if err := copyTreePreserveLinks(srcRoot, dstRoot); err != nil { + _ = os.RemoveAll(dstRoot) + return "", nil, err + } + return dstRoot, func() { _ = os.RemoveAll(dstRoot) }, nil +} + +func cloneReplayOutput(baseOutputDir string) (string, func(), error) { + dstRoot, err := os.MkdirTemp("", "llar-replay-out-*") + if err != nil { + return "", nil, err + } + if err := copyTreePreserveLinks(baseOutputDir, dstRoot); err != nil { + _ = os.RemoveAll(dstRoot) + return "", nil, err + } + return dstRoot, func() { _ = os.RemoveAll(dstRoot) }, nil +} + +func deriveReplayBuildRoot(scope trace.Scope, sourceRoot string) (string, func(), error) { + if scope.BuildRoot == "" { + return filepath.Join(sourceRoot, "_build"), func() {}, nil + } + rel, err := filepath.Rel(scope.SourceRoot, scope.BuildRoot) + if err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return filepath.Join(sourceRoot, rel), func() {}, nil + } + buildRoot, err := os.MkdirTemp("", "llar-replay-build-*") + if err != nil { + return "", nil, err + } + return buildRoot, func() { _ = os.RemoveAll(buildRoot) }, nil +} + +func prepareReplayBuildRoot(buildRoot string, steps []replayRoot) error { + if buildRoot == "" { + return nil + } + if err := os.RemoveAll(buildRoot); err != nil { + return err + } + return os.MkdirAll(buildRoot, 0o755) +} + +func replayHasBuildRootInitializer(steps []replayRoot) bool { + for _, step := range steps { + if replayStepInitializesBuildRoot(step) { + return true + } + } + return false +} + +func replayStepInitializesBuildRoot(step replayRoot) bool { + writesBuild := false + for _, path := range step.writes { + if isReplayBuildPath(path) { + writesBuild = true + break + } + } + if !writesBuild { + return false + } + for _, path := range step.reads { + if isReplayBuildPath(path) { + return false + } + } + return true +} + +func isReplayBuildPath(path string) bool { + path = normalizePath(path) + return path == "$BUILD" || strings.HasPrefix(path, "$BUILD/") +} + +func runReplayStep(ctx context.Context, step replayRoot, paths replayPaths) error { + cwd := materializeReplayToken(step.cwd, paths) + argv := materializeReplayTokens(step.argv, paths) + env := materializeReplayTokens(step.env, paths) + if len(argv) == 0 { + return fmt.Errorf("empty replay argv") + } + if strings.Contains(argv[0], "/") && !filepath.IsAbs(argv[0]) { + argv[0] = filepath.Join(cwd, filepath.FromSlash(argv[0])) + } + + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + cmd.Dir = filepath.Clean(filepath.FromSlash(cwd)) + if len(env) > 0 { + cmd.Env = env + } + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + if err := cmd.Run(); err != nil { + detail := strings.TrimSpace(out.String()) + if detail == "" { + return fmt.Errorf("%s: %w", strings.Join(argv, " "), err) + } + return fmt.Errorf("%s: %w\n%s", strings.Join(argv, " "), err, detail) + } + return nil +} + +func materializeReplayTokens(tokens []string, paths replayPaths) []string { + out := make([]string, 0, len(tokens)) + for _, token := range tokens { + out = append(out, materializeReplayToken(token, paths)) + } + return out +} + +func materializeReplayToken(token string, paths replayPaths) string { + token = strings.ReplaceAll(token, "$BUILD", paths.buildRoot) + token = strings.ReplaceAll(token, "$INSTALL", paths.installRoot) + token = strings.ReplaceAll(token, "$SRC", paths.sourceRoot) + return filepath.FromSlash(token) +} + +func replayIssue(kind OutputMergeIssueKind, reason, detail string) OutputSynthesisIssue { + return OutputSynthesisIssue{ + Kind: kind, + Path: "", + Reason: reason, + Detail: detail, + } +} + +func copyTreePreserveLinks(srcRoot, dstRoot string) error { + return filepath.WalkDir(srcRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(srcRoot, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + dstPath := filepath.Join(dstRoot, rel) + info, err := os.Lstat(path) + if err != nil { + return err + } + switch mode := info.Mode(); { + case mode.IsDir(): + return os.MkdirAll(dstPath, mode.Perm()) + case mode&os.ModeSymlink != 0: + target, err := os.Readlink(path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { + return err + } + return os.Symlink(target, dstPath) + case mode.IsRegular(): + if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { + return err + } + return copyRegularFile(path, dstPath, mode.Perm()) + default: + return nil + } + }) +} + +func copyRegularFile(srcPath, dstPath string, perm fs.FileMode) error { + src, err := os.Open(srcPath) + if err != nil { + return err + } + defer src.Close() + + dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm) + if err != nil { + return err + } + defer dst.Close() + + _, err = io.Copy(dst, src) + return err +} diff --git a/internal/evaluator/output_synthesis.go b/internal/evaluator/output_synthesis.go new file mode 100644 index 0000000..a41fdac --- /dev/null +++ b/internal/evaluator/output_synthesis.go @@ -0,0 +1,95 @@ +package evaluator + +import ( + "context" + "slices" +) + +type OutputSynthesisMode string + +const ( + OutputSynthesisModeDirectMerge OutputSynthesisMode = "direct-merge" + OutputSynthesisModeRootReplay OutputSynthesisMode = "root-replay" +) + +type OutputSynthesisIssue = OutputMergeIssue +type OutputSynthesisIssueKind = OutputMergeIssueKind + +type RootReplaySummary struct { + CandidateRoots int + EligibleRoots int + ChangedRoots []string + SelectedRoots []string + SelectedCommands []string + SelectedWrites int + Unavailable string +} + +type OutputSynthesisResult struct { + Mode OutputSynthesisMode + Status OutputMergeStatus + Root string + Metadata string + Manifest OutputManifest + Issues []OutputSynthesisIssue + Replay *RootReplaySummary +} + +func (r OutputSynthesisResult) Clean() bool { + return r.Status == OutputMergeStatusMerged +} + +func (r OutputSynthesisResult) NeedsRebuild() bool { + return r.Status == OutputMergeStatusNeedsRebuild +} + +func (r OutputSynthesisResult) AsMergeResult() (OutputMergeResult, bool) { + if r.Mode != OutputSynthesisModeDirectMerge { + return OutputMergeResult{}, false + } + return OutputMergeResult{ + Status: r.Status, + Root: r.Root, + Metadata: r.Metadata, + Manifest: r.Manifest, + Issues: r.Issues, + }, true +} + +func synthesizeOutputTrees(ctx context.Context, base, left, right ProbeResult) (OutputSynthesisResult, error) { + mergeResult, err := MergeOutputTrees( + base.OutputDir, + base.OutputManifest, + left.OutputDir, + left.OutputManifest, + right.OutputDir, + right.OutputManifest, + ) + if err != nil { + return OutputSynthesisResult{}, err + } + if mergeResult.Clean() { + return synthesisResultFromMerge(mergeResult), nil + } + + replayResult, err := synthesizeByRootReplay(ctx, base, left, right) + if err != nil { + return OutputSynthesisResult{}, err + } + if replayResult.Clean() { + return replayResult, nil + } + replayResult.Issues = append(slices.Clone(mergeResult.Issues), replayResult.Issues...) + return replayResult, nil +} + +func synthesisResultFromMerge(mergeResult OutputMergeResult) OutputSynthesisResult { + return OutputSynthesisResult{ + Mode: OutputSynthesisModeDirectMerge, + Status: mergeResult.Status, + Root: mergeResult.Root, + Metadata: mergeResult.Metadata, + Manifest: mergeResult.Manifest, + Issues: mergeResult.Issues, + } +} diff --git a/internal/evaluator/planner.go b/internal/evaluator/planner.go new file mode 100644 index 0000000..8d2c8ce --- /dev/null +++ b/internal/evaluator/planner.go @@ -0,0 +1,1202 @@ +package evaluator + +import ( + "context" + "maps" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/goplus/llar/formula" + "github.com/goplus/llar/internal/trace" + tracessa "github.com/goplus/llar/internal/trace/ssa" +) + +type ProbeResult struct { + Records []trace.Record + Events []trace.Event + OutputDir string + Scope trace.Scope + TraceDiagnostics trace.ParseDiagnostics + InputDigests map[string]string + OutputManifest OutputManifest + ReplayReady bool +} + +type ProbeFunc func(context.Context, string) (ProbeResult, error) +type SynthesizedPairValidator func(context.Context, string, OutputSynthesisResult) (bool, error) +type SynthesizedPairObserver func(SynthesizedPairObservation) +type MergedPairValidator func(context.Context, string, OutputMergeResult) (bool, error) +type MergedPairObserver func(MergedPairObservation) + +type SynthesizedPairObservation struct { + Combo string + LeftID string + RightID string + SynthesisResult OutputSynthesisResult + ValidationAttempted bool + Validated bool + ValidationDetail string +} + +type MergedPairObservation struct { + Combo string + LeftID string + RightID string + MergeResult OutputMergeResult + ValidationAttempted bool + Validated bool +} + +type WatchOptions struct { + ValidateSynthesizedPair SynthesizedPairValidator + ObserveSynthesizedPair SynthesizedPairObserver + ValidateMergedPair MergedPairValidator + ObserveMergedPair MergedPairObserver +} + +func buildGraphForProbe(probe ProbeResult) tracessa.Graph { + return tracessa.BuildGraph(tracessa.BuildInput{ + Records: probe.Records, + Events: probe.Events, + Scope: probe.Scope, + InputDigests: probe.InputDigests, + }) +} + +type optionVariant struct { + profile tracessa.ImpactProfile + outputDiff outputManifestDiff + mergeSurfacePaths map[string]struct{} +} + +type collisionHazardKind string + +const ( + collisionHazardAmbiguous collisionHazardKind = "ambiguous" + collisionHazardSeedWAW collisionHazardKind = "seed-waw" + collisionHazardLeftFlowRightNeedRAW collisionHazardKind = "left-flow-right-need-raw" + collisionHazardRightFlowLeftNeedRAW collisionHazardKind = "right-flow-left-need-raw" + collisionHazardSharedFlowWAW collisionHazardKind = "shared-flow-waw" +) + +type collisionAssessment struct { + hazards []collisionHazardKind +} + +func (assessment collisionAssessment) collide() bool { + return len(assessment.hazards) != 0 +} + +var ( + reTmpUnix = regexp.MustCompile(`^/tmp/[^/]+`) + reTmpMac = regexp.MustCompile(`^/var/folders/[^/]+/[^/]+/[^/]+`) + reBuildTmpPIDNoise = regexp.MustCompile(`\.tmp\.[0-9]+$`) +) + +const ( + buildTransientDirToken = "$TMPDIR" + buildGeneratedIDToken = "$ID" +) + +func Watch(ctx context.Context, matrix formula.Matrix, probe ProbeFunc) ([]string, bool, error) { + return WatchWithOptions(ctx, matrix, probe, WatchOptions{}) +} + +func WatchWithOptions(ctx context.Context, matrix formula.Matrix, probe ProbeFunc, opts WatchOptions) ([]string, bool, error) { + validateSynthesized, observeSynthesized := normalizeSynthesisHooks(opts) + requireCombos := expandRequireCombos(matrix.Require) + if len(requireCombos) == 0 { + requireCombos = []string{""} + } + + defaults := defaultOptions(matrix) + optionKeys := slices.Sorted(maps.Keys(matrix.Options)) + execute := make(map[string]struct{}) + trusted := true + + for _, requireCombo := range requireCombos { + baselineCombo := composeCombo(requireCombo, defaults, optionKeys) + baseResult, err := probe(ctx, baselineCombo) + if err != nil { + return nil, false, err + } + trusted = trusted && baseResult.TraceDiagnostics.Trusted() + if len(optionKeys) == 0 { + execute[baselineCombo] = struct{}{} + continue + } + + profiles := make(map[string][]optionVariant, len(optionKeys)) + singletons := make(map[string]map[string]ProbeResult, len(optionKeys)) + for _, key := range optionKeys { + values := slices.Clone(matrix.Options[key]) + for _, value := range values { + if value == defaults[key] { + continue + } + override := maps.Clone(defaults) + override[key] = value + combo := composeCombo(requireCombo, override, optionKeys) + result, err := probe(ctx, combo) + if err != nil { + return nil, false, err + } + trusted = trusted && result.TraceDiagnostics.Trusted() + profiles[key] = append(profiles[key], optionVariant{ + profile: diffProfileForProbes(baseResult, result), + outputDiff: diffOutputManifest(baseResult.OutputManifest, result.OutputManifest), + mergeSurfacePaths: mergeSurfacePaths(result.Scope, baseResult.OutputManifest, result.OutputManifest), + }) + if singletons[key] == nil { + singletons[key] = make(map[string]ProbeResult, len(values)) + } + singletons[key][value] = result + } + } + + zeroDiff := zeroDiffOptionKeys(profiles) + if validateSynthesized != nil || observeSynthesized != nil { + components, err := validatedCollisionComponents( + ctx, + requireCombo, + defaults, + matrix.Options, + optionKeys, + profiles, + baseResult, + singletons, + validateSynthesized, + observeSynthesized, + ) + if err != nil { + return nil, false, err + } + for _, combo := range componentCombos( + requireCombo, + matrix.Options, + defaults, + optionKeys, + components, + nil, + ) { + execute[combo] = struct{}{} + } + continue + } + + components := collisionComponents(optionKeys, profiles, zeroDiff, false) + orthogonalKeys := orthogonalOptionKeys(optionKeys, zeroDiff) + for _, combo := range componentCombos( + requireCombo, + matrix.Options, + defaults, + optionKeys, + components, + orthogonalKeys, + ) { + execute[combo] = struct{}{} + } + } + + return slices.Sorted(maps.Keys(execute)), trusted, nil +} + +func diffProfileForProbes(baseProbe, probeProbe ProbeResult) tracessa.ImpactProfile { + return tracessa.AnalyzeWithEvidence(tracessa.AnalysisInput{ + Base: tracessa.AnalysisSideInput{ + Records: baseProbe.Records, + Events: baseProbe.Events, + Scope: baseProbe.Scope, + InputDigests: baseProbe.InputDigests, + }, + Probe: tracessa.AnalysisSideInput{ + Records: probeProbe.Records, + Events: probeProbe.Events, + Scope: probeProbe.Scope, + InputDigests: probeProbe.InputDigests, + }, + }, buildImpactEvidence(baseProbe, probeProbe)).Profile +} + +func buildImpactEvidence(baseProbe, probeProbe ProbeResult) *tracessa.ImpactEvidence { + changed := make(map[string]bool) + addDigestEvidence := func(scope trace.Scope, digests map[string]string) map[string]string { + out := make(map[string]string, len(digests)) + for path, sum := range digests { + key := normalizeScopeToken(path, scope) + if key == "" { + continue + } + out[key] = sum + } + return out + } + baseDigests := addDigestEvidence(baseProbe.Scope, baseProbe.InputDigests) + probeDigests := addDigestEvidence(probeProbe.Scope, probeProbe.InputDigests) + for key, left := range baseDigests { + right, ok := probeDigests[key] + if !ok || left != right { + changed[key] = true + continue + } + changed[key] = false + } + for key, right := range probeDigests { + if left, ok := baseDigests[key]; ok { + changed[key] = left != right + continue + } + changed[key] = true + } + + for _, key := range slices.Sorted(maps.Keys(baseProbe.OutputManifest.Entries)) { + baseEntry := baseProbe.OutputManifest.Entries[key] + probeEntry, ok := probeProbe.OutputManifest.Entries[key] + if !ok || baseEntry != probeEntry { + changed[outputManifestKey(baseProbe.Scope, key)] = true + continue + } + changed[outputManifestKey(baseProbe.Scope, key)] = false + } + for key, probeEntry := range probeProbe.OutputManifest.Entries { + baseEntry, ok := baseProbe.OutputManifest.Entries[key] + if !ok { + changed[outputManifestKey(probeProbe.Scope, key)] = true + continue + } + changed[outputManifestKey(probeProbe.Scope, key)] = baseEntry != probeEntry + } + if len(changed) == 0 { + return nil + } + return &tracessa.ImpactEvidence{Changed: changed} +} + +func outputManifestKey(scope trace.Scope, path string) string { + if path == "" { + return "" + } + if scope.InstallRoot == "" { + return normalizePath(path) + } + return normalizeScopeToken(filepath.Join(scope.InstallRoot, filepath.FromSlash(path)), scope) +} + +func isDeliveryOnlyAction(graph tracessa.Graph, idx int) bool { + action := graph.Actions[idx] + if len(action.Writes) == 0 { + return false + } + explicitDeliveryOnly := true + for _, changed := range action.Writes { + if !tracessa.PathLooksDelivery(graph, changed) { + return false + } + if !isExplicitDeliveryPath(changed, graph.Scope) { + explicitDeliveryOnly = false + } + } + if action.Kind == tracessa.KindCopy || action.Kind == tracessa.KindInstall { + return true + } + return explicitDeliveryOnly +} + +func mergeSurfacePaths(scope trace.Scope, base, probe OutputManifest) map[string]struct{} { + paths := make(map[string]struct{}, len(base.Entries)+len(probe.Entries)) + for path := range base.Entries { + addMergeSurfacePath(paths, scope, path) + } + for path := range probe.Entries { + addMergeSurfacePath(paths, scope, path) + } + return paths +} + +func addMergeSurfacePath(paths map[string]struct{}, scope trace.Scope, path string) { + if path == "" { + return + } + path = normalizePath(path) + paths[path] = struct{}{} + if scope.InstallRoot == "" { + return + } + full := filepath.Join(scope.InstallRoot, filepath.FromSlash(path)) + paths[normalizeScopeToken(full, scope)] = struct{}{} +} + +func zeroDiffOptionKeys(profiles map[string][]optionVariant) map[string]struct{} { + tainted := make(map[string]struct{}) + for key, variants := range profiles { + if len(variants) == 0 { + continue + } + allEmpty := true + for _, variant := range variants { + if variant.empty() { + continue + } + allEmpty = false + break + } + if allEmpty { + tainted[key] = struct{}{} + } + } + return tainted +} + +func collisionComponents(optionKeys []string, profiles map[string][]optionVariant, zeroDiff map[string]struct{}, allowMergeSurface bool) [][]string { + keys := make([]string, 0, len(optionKeys)) + for _, key := range optionKeys { + if _, ok := zeroDiff[key]; ok { + continue + } + keys = append(keys, key) + } + adj := make(map[string]map[string]struct{}, len(keys)) + for _, key := range keys { + adj[key] = make(map[string]struct{}) + } + for i := 0; i < len(keys); i++ { + for j := i + 1; j < len(keys); j++ { + left, right := keys[i], keys[j] + if !profilesCollide(profiles[left], profiles[right], allowMergeSurface) { + continue + } + adj[left][right] = struct{}{} + adj[right][left] = struct{}{} + } + } + + visited := make(map[string]bool, len(keys)) + var components [][]string + for _, key := range keys { + if visited[key] { + continue + } + component := []string{} + stack := []string{key} + for len(stack) > 0 { + node := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if visited[node] { + continue + } + visited[node] = true + component = append(component, node) + for next := range adj[node] { + if !visited[next] { + stack = append(stack, next) + } + } + } + slices.Sort(component) + components = append(components, component) + } + return components +} + +func orthogonalOptionKeys(optionKeys []string, zeroDiff map[string]struct{}) []string { + keys := make([]string, 0, len(zeroDiff)) + for _, key := range optionKeys { + if _, ok := zeroDiff[key]; ok { + keys = append(keys, key) + } + } + return keys +} + +func profilesCollide(left, right []optionVariant, allowMergeSurface bool) bool { + for _, l := range left { + for _, r := range right { + if optionVariantsCollide(l, r, allowMergeSurface) { + return true + } + } + } + return false +} + +func optionVariantsCollide(left, right optionVariant, allowMergeSurface bool) bool { + return assessOptionVariantCollision(left, right, allowMergeSurface).collide() +} + +func assessOptionVariantCollision(left, right optionVariant, allowMergeSurface bool) collisionAssessment { + var hazards []collisionHazardKind + if left.profile.Ambiguous || right.profile.Ambiguous { + hazards = append(hazards, collisionHazardAmbiguous) + } + if conservativeStateOrPathOverlap(left.profile.SeedStates, right.profile.SeedStates, left.profile.SeedWrites, right.profile.SeedWrites) { + hazards = append(hazards, collisionHazardSeedWAW) + } + if conservativeStateOrPathOverlap(left.profile.FlowStates, right.profile.NeedStates, left.profile.SlicePaths, right.profile.NeedPaths) || + conservativeStateOrPathOverlap(right.profile.FlowStates, left.profile.NeedStates, right.profile.SlicePaths, left.profile.NeedPaths) { + if conservativeStateOrPathOverlap(left.profile.FlowStates, right.profile.NeedStates, left.profile.SlicePaths, right.profile.NeedPaths) { + hazards = append(hazards, collisionHazardLeftFlowRightNeedRAW) + } + if conservativeStateOrPathOverlap(right.profile.FlowStates, left.profile.NeedStates, right.profile.SlicePaths, left.profile.NeedPaths) { + hazards = append(hazards, collisionHazardRightFlowLeftNeedRAW) + } + } + shared := compatibleAwareSharedPaths(left.profile.FlowStates, right.profile.FlowStates, left.profile.SlicePaths, right.profile.SlicePaths) + if len(shared) == 0 { + return collisionAssessment{hazards: uniqueHazards(hazards)} + } + if !allowMergeSurface { + hazards = append(hazards, collisionHazardSharedFlowWAW) + return collisionAssessment{hazards: uniqueHazards(hazards)} + } + for path := range shared { + if _, ok := left.mergeSurfacePaths[path]; !ok { + hazards = append(hazards, collisionHazardSharedFlowWAW) + return collisionAssessment{hazards: uniqueHazards(hazards)} + } + if _, ok := right.mergeSurfacePaths[path]; !ok { + hazards = append(hazards, collisionHazardSharedFlowWAW) + return collisionAssessment{hazards: uniqueHazards(hazards)} + } + } + return collisionAssessment{hazards: uniqueHazards(hazards)} +} + +func uniqueHazards(hazards []collisionHazardKind) []collisionHazardKind { + if len(hazards) <= 1 { + return hazards + } + seen := make(map[collisionHazardKind]struct{}, len(hazards)) + out := make([]collisionHazardKind, 0, len(hazards)) + for _, hazard := range hazards { + if _, ok := seen[hazard]; ok { + continue + } + seen[hazard] = struct{}{} + out = append(out, hazard) + } + return out +} + +func statesConflict(left, right tracessa.ImpactStateKey) bool { + if left.Path != right.Path { + return false + } + if left.Tombstone && right.Tombstone { + return false + } + return true +} + +func conservativeStateOrPathOverlap(leftStates, rightStates map[tracessa.ImpactStateKey]struct{}, leftPaths, rightPaths map[string]struct{}) bool { + for path := range sharedPaths(leftPaths, rightPaths) { + if statePathConflicts(leftStates, rightStates, path) { + return true + } + } + return false +} + +func compatibleAwareSharedPaths(leftStates, rightStates map[tracessa.ImpactStateKey]struct{}, leftPaths, rightPaths map[string]struct{}) map[string]struct{} { + out := make(map[string]struct{}) + for path := range sharedPaths(leftPaths, rightPaths) { + if statePathConflicts(leftStates, rightStates, path) { + out[path] = struct{}{} + } + } + return out +} + +func statePathConflicts(leftStates, rightStates map[tracessa.ImpactStateKey]struct{}, path string) bool { + left := statesForPath(leftStates, path) + right := statesForPath(rightStates, path) + if len(left) == 0 || len(right) == 0 { + return true + } + for _, leftState := range left { + for _, rightState := range right { + if statesConflict(leftState, rightState) { + return true + } + } + } + return false +} + +func statesForPath(states map[tracessa.ImpactStateKey]struct{}, path string) []tracessa.ImpactStateKey { + out := make([]tracessa.ImpactStateKey, 0, 1) + for state := range states { + if state.Path == path { + out = append(out, state) + } + } + return out +} + +func (variant optionVariant) empty() bool { + return !variant.profile.Ambiguous && + len(variant.profile.SeedWrites) == 0 && + len(variant.profile.NeedPaths) == 0 && + len(variant.profile.SlicePaths) == 0 && + len(variant.profile.SeedStates) == 0 && + len(variant.profile.NeedStates) == 0 && + len(variant.profile.FlowStates) == 0 && + variant.outputDiff.empty() +} + +func overlap(left, right map[string]struct{}) bool { + for path := range left { + if _, ok := right[path]; ok { + return true + } + } + return false +} + +func sharedPaths(left, right map[string]struct{}) map[string]struct{} { + out := make(map[string]struct{}) + for path := range left { + if _, ok := right[path]; ok { + out[path] = struct{}{} + } + } + return out +} + +func componentCombos( + requireCombo string, + options map[string][]string, + defaults map[string]string, + optionKeys []string, + components [][]string, + orthogonalKeys []string, +) []string { + seen := make(map[string]struct{}) + seen[composeCombo(requireCombo, defaults, optionKeys)] = struct{}{} + for _, component := range components { + for _, selection := range expandComponentSelections(component, options) { + merged := maps.Clone(defaults) + for key, value := range selection { + merged[key] = value + } + seen[composeCombo(requireCombo, merged, optionKeys)] = struct{}{} + } + } + for _, key := range orthogonalKeys { + for _, value := range options[key] { + merged := maps.Clone(defaults) + merged[key] = value + seen[composeCombo(requireCombo, merged, optionKeys)] = struct{}{} + } + } + return slices.Sorted(maps.Keys(seen)) +} + +func singletonUnitComponents(units []sampleUnit) [][]string { + keys := make([]string, 0, len(units)) + adj := make(map[string]map[string]struct{}, len(units)) + for _, unit := range units { + if len(unit.keys) != 1 { + continue + } + key := unit.keys[0] + keys = append(keys, key) + adj[key] = make(map[string]struct{}) + } + return buildComponentsFromAdj(keys, adj) +} + +func connectUnitToFollowing(adj map[string]map[string]struct{}, units []sampleUnit, idx int) { + for next := idx + 1; next < len(units); next++ { + connectUnits(adj, units[idx], units[next]) + } +} + +func connectUnits(adj map[string]map[string]struct{}, left, right sampleUnit) { + for _, leftKey := range left.keys { + leftAdj := adj[leftKey] + if leftAdj == nil { + leftAdj = make(map[string]struct{}) + adj[leftKey] = leftAdj + } + for _, rightKey := range right.keys { + if leftKey == rightKey { + continue + } + rightAdj := adj[rightKey] + if rightAdj == nil { + rightAdj = make(map[string]struct{}) + adj[rightKey] = rightAdj + } + leftAdj[rightKey] = struct{}{} + rightAdj[leftKey] = struct{}{} + } + } +} + +func buildComponentsFromAdj(keys []string, adj map[string]map[string]struct{}) [][]string { + visited := make(map[string]bool, len(keys)) + var components [][]string + for _, key := range keys { + if visited[key] { + continue + } + component := []string{} + stack := []string{key} + for len(stack) > 0 { + node := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if visited[node] { + continue + } + visited[node] = true + component = append(component, node) + for next := range adj[node] { + if !visited[next] { + stack = append(stack, next) + } + } + } + slices.Sort(component) + components = append(components, component) + } + return components +} + +func rootReplayAvailability(base, left, right ProbeResult) (bool, *RootReplaySummary, string) { + plan, unavailable := planRootReplay(base, left, right) + return unavailable == "", plan.summary, unavailable +} + +func expandComponentSelections(component []string, options map[string][]string) []map[string]string { + if len(component) == 0 { + return []map[string]string{{}} + } + var out []map[string]string + var expand func(int, map[string]string) + expand = func(i int, selected map[string]string) { + if i == len(component) { + out = append(out, maps.Clone(selected)) + return + } + key := component[i] + for _, value := range options[key] { + selected[key] = value + expand(i+1, selected) + } + delete(selected, key) + } + expand(0, make(map[string]string, len(component))) + return out +} + +type sampleUnit struct { + id string + keys []string + selection map[string]string +} + +func validatedCollisionComponents( + ctx context.Context, + requireCombo string, + defaults map[string]string, + options map[string][]string, + optionKeys []string, + profiles map[string][]optionVariant, + base ProbeResult, + singletons map[string]map[string]ProbeResult, + validate SynthesizedPairValidator, + observe SynthesizedPairObserver, +) ([][]string, error) { + units := singletonSampleUnits(optionKeys, options, defaults) + if base.OutputDir == "" { + return singletonUnitComponents(units), nil + } + keys := make([]string, 0, len(units)) + adj := make(map[string]map[string]struct{}, len(units)) + for _, unit := range units { + if len(unit.keys) != 1 { + continue + } + key := unit.keys[0] + keys = append(keys, key) + if _, ok := adj[key]; !ok { + adj[key] = make(map[string]struct{}) + } + } + for i := 0; i < len(units); i++ { + leftProbe, ok := sampleUnitProbe(units[i], singletons) + if !ok { + connectUnitToFollowing(adj, units, i) + continue + } + for j := i + 1; j < len(units); j++ { + pairCombo := composePairCombo(requireCombo, defaults, optionKeys, units[i], units[j]) + stage2Collide := sampleUnitsCollide(units[i], units[j], profiles) + rightProbe, ok := sampleUnitProbe(units[j], singletons) + if !ok { + connectUnits(adj, units[i], units[j]) + continue + } + replayAvailable, replaySummary, replayUnavailable := rootReplayAvailability(base, leftProbe, rightProbe) + if stage2Collide && !replayAvailable { + if observe != nil { + observe(SynthesizedPairObservation{ + Combo: pairCombo, + LeftID: units[i].id, + RightID: units[j].id, + SynthesisResult: OutputSynthesisResult{ + Mode: OutputSynthesisModeRootReplay, + Status: OutputMergeStatusNeedsRebuild, + Replay: replaySummary, + Issues: []OutputSynthesisIssue{replayIssue( + OutputMergeIssueKindRootReplayUnavailable, + "root replay is unavailable", + replayUnavailable, + )}, + }, + }) + } + connectUnits(adj, units[i], units[j]) + continue + } + synthesisResult, err := synthesizeOutputTrees(ctx, base, leftProbe, rightProbe) + if err != nil { + return nil, err + } + observation := SynthesizedPairObservation{ + Combo: pairCombo, + LeftID: units[i].id, + RightID: units[j].id, + SynthesisResult: synthesisResult, + } + if synthesisResult.Clean() { + validated := true + if validate != nil { + observation.ValidationAttempted = true + validated, err = validate(ctx, pairCombo, synthesisResult) + observation.Validated = validated + if !validated && err == nil { + observation.ValidationDetail = "validator rejected synthesized output" + } + _ = os.RemoveAll(synthesisResult.Root) + if err != nil { + return nil, err + } + } else { + _ = os.RemoveAll(synthesisResult.Root) + } + if observe != nil { + observe(observation) + } + if !validated || (stage2Collide && synthesisResult.Mode != OutputSynthesisModeRootReplay) { + connectUnits(adj, units[i], units[j]) + } + continue + } + if observe != nil { + observe(observation) + } + connectUnits(adj, units[i], units[j]) + } + } + return buildComponentsFromAdj(keys, adj), nil +} + +func sampleUnitsCollide(left, right sampleUnit, profiles map[string][]optionVariant) bool { + for _, leftKey := range left.keys { + leftVariants, ok := profiles[leftKey] + if !ok { + continue + } + for _, rightKey := range right.keys { + rightVariants, ok := profiles[rightKey] + if !ok { + continue + } + if profilesCollide(leftVariants, rightVariants, true) { + return true + } + } + } + return false +} + +func singletonSampleUnits(optionKeys []string, options map[string][]string, defaults map[string]string) []sampleUnit { + units := make([]sampleUnit, 0, len(optionKeys)) + for _, key := range optionKeys { + selection := representativeSelection([]string{key}, options, defaults) + if len(selection) == 0 { + continue + } + units = append(units, sampleUnit{ + id: "key:" + key, + keys: []string{key}, + selection: selection, + }) + } + return units +} + +func pairCombos(requireCombo string, defaults map[string]string, optionKeys []string, units []sampleUnit) []string { + var out []string + for i := 0; i < len(units); i++ { + out = append(out, pairCombosFrom(requireCombo, defaults, optionKeys, units, i)...) + } + result := uniqueStrings(out) + slices.Sort(result) + return result +} + +func pairCombosFrom(requireCombo string, defaults map[string]string, optionKeys []string, units []sampleUnit, i int) []string { + var out []string + for j := i + 1; j < len(units); j++ { + out = append(out, composePairCombo(requireCombo, defaults, optionKeys, units[i], units[j])) + } + return out +} + +func composePairCombo(requireCombo string, defaults map[string]string, optionKeys []string, left, right sampleUnit) string { + selection := maps.Clone(defaults) + for key, value := range left.selection { + selection[key] = value + } + for key, value := range right.selection { + selection[key] = value + } + return composeCombo(requireCombo, selection, optionKeys) +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func normalizeSynthesisHooks(opts WatchOptions) (SynthesizedPairValidator, SynthesizedPairObserver) { + validate := opts.ValidateSynthesizedPair + if validate == nil && opts.ValidateMergedPair != nil { + validate = func(ctx context.Context, combo string, synthesized OutputSynthesisResult) (bool, error) { + mergeResult, ok := synthesized.AsMergeResult() + if !ok { + return false, nil + } + return opts.ValidateMergedPair(ctx, combo, mergeResult) + } + } + + observe := opts.ObserveSynthesizedPair + if observe == nil && opts.ObserveMergedPair != nil { + observe = func(observation SynthesizedPairObservation) { + mergeResult, ok := observation.SynthesisResult.AsMergeResult() + if !ok { + return + } + opts.ObserveMergedPair(MergedPairObservation{ + Combo: observation.Combo, + LeftID: observation.LeftID, + RightID: observation.RightID, + MergeResult: mergeResult, + ValidationAttempted: observation.ValidationAttempted, + Validated: observation.Validated, + }) + } + } + return validate, observe +} + +func sampleUnitProbe(unit sampleUnit, singletons map[string]map[string]ProbeResult) (ProbeResult, bool) { + if len(unit.keys) != 1 { + return ProbeResult{}, false + } + key := unit.keys[0] + value, ok := unit.selection[key] + if !ok { + return ProbeResult{}, false + } + variants, ok := singletons[key] + if !ok { + return ProbeResult{}, false + } + probe, ok := variants[value] + if !ok || probe.OutputDir == "" { + return ProbeResult{}, false + } + return probe, true +} + +func representativeSelection(keys []string, options map[string][]string, defaults map[string]string) map[string]string { + selection := make(map[string]string, len(keys)) + for _, key := range keys { + value, ok := firstNonDefaultValue(options[key], defaults[key]) + if !ok { + return nil + } + selection[key] = value + } + return selection +} + +func firstNonDefaultValue(values []string, def string) (string, bool) { + for _, value := range values { + if value == "" || value == def { + continue + } + return value, true + } + return "", false +} + +func defaultOptions(matrix formula.Matrix) map[string]string { + out := make(map[string]string, len(matrix.Options)) + for _, key := range slices.Sorted(maps.Keys(matrix.Options)) { + values := matrix.Options[key] + if len(values) == 0 { + continue + } + def := values[0] + if defaults := matrix.DefaultOptions[key]; len(defaults) > 0 && slices.Contains(values, defaults[0]) { + def = defaults[0] + } + out[key] = def + } + return out +} + +func expandRequireCombos(require map[string][]string) []string { + keys := slices.Sorted(maps.Keys(require)) + if len(keys) == 0 { + return nil + } + combos := []string{""} + for _, key := range keys { + values := require[key] + next := make([]string, 0, len(combos)*len(values)) + for _, prefix := range combos { + for _, value := range values { + if prefix == "" { + next = append(next, value) + continue + } + next = append(next, prefix+"-"+value) + } + } + combos = next + } + return combos +} + +func composeCombo(requireCombo string, options map[string]string, optionKeys []string) string { + optionParts := make([]string, 0, len(optionKeys)) + for _, key := range optionKeys { + if value, ok := options[key]; ok && value != "" { + optionParts = append(optionParts, value) + } + } + optionCombo := strings.Join(optionParts, "-") + switch { + case requireCombo == "": + return optionCombo + case optionCombo == "": + return requireCombo + default: + return requireCombo + "|" + optionCombo + } +} + +func normalizePath(path string) string { + if path == "" { + return "" + } + path = filepath.ToSlash(path) + if strings.HasPrefix(path, "/tmp/$$TMP") || strings.HasPrefix(path, "/var/folders/$$TMP") { + return path + } + path = reTmpUnix.ReplaceAllString(path, "/tmp/$$$$TMP") + path = reTmpMac.ReplaceAllString(path, "/var/folders/$$$$TMP") + return path +} + +func normalizeScopeToken(token string, scope trace.Scope) string { + if token == "" { + return "" + } + replacements := []struct { + root string + placeholder string + }{ + {scope.BuildRoot, "$BUILD"}, + {scope.InstallRoot, "$INSTALL"}, + {scope.SourceRoot, "$SRC"}, + } + slices.SortFunc(replacements, func(left, right struct { + root string + placeholder string + }) int { + if len(left.root) != len(right.root) { + return len(right.root) - len(left.root) + } + return strings.Compare(left.placeholder, right.placeholder) + }) + for _, item := range replacements { + if item.root == "" { + continue + } + token = replaceScopeRootToken(token, item.root, item.placeholder) + } + token = normalizePath(token) + for _, item := range replacements { + root := normalizePath(item.root) + if root == "" { + continue + } + token = replaceScopeRootToken(token, root, item.placeholder) + } + return normalizeScopedBuildNoise(token) +} + +func replaceScopeRootToken(token, root, placeholder string) string { + if !strings.Contains(root, "$$TMP") { + idx := strings.Index(token, root) + if !validScopedRootMatch(token, idx, len(root)) { + return token + } + return token[:idx] + placeholder + token[idx+len(root):] + } + pattern := regexp.QuoteMeta(root) + pattern = strings.ReplaceAll(pattern, `\$\$TMP`, `[^/]+`) + re := regexp.MustCompile(pattern) + loc := re.FindStringIndex(token) + if loc == nil || !validScopedRootMatch(token, loc[0], loc[1]-loc[0]) { + return token + } + return token[:loc[0]] + placeholder + token[loc[1]:] +} + +func validScopedRootMatch(token string, start, length int) bool { + if start < 0 { + return false + } + if start != 0 { + firstSlash := strings.IndexByte(token, '/') + if firstSlash != start { + return false + } + } + end := start + length + return end == len(token) || token[end] == '/' +} + +func normalizeScopedBuildNoise(token string) string { + if !strings.Contains(token, "$BUILD") { + return token + } + parts := strings.Split(token, "/") + transientDepth := -1 + for idx, part := range parts { + if part == "" || part == "$BUILD" { + continue + } + part = normalizeBuildTempPIDPart(part) + if looksTransientBuildDir(part) { + parts[idx] = buildTransientDirToken + transientDepth = 0 + continue + } + if transientDepth >= 0 { + parts[idx] = normalizeTransientBuildPart(part, transientDepth == 0) + transientDepth++ + continue + } + parts[idx] = part + } + return strings.Join(parts, "/") +} + +func normalizeBuildTempPIDPart(part string) string { + if !reBuildTmpPIDNoise.MatchString(part) { + return part + } + loc := strings.LastIndex(part, ".tmp.") + if loc < 0 { + return part + } + return part[:loc] + ".tmp." + buildGeneratedIDToken +} + +func looksTransientBuildDir(part string) bool { + if part == "" || strings.Contains(part, ".") { + return false + } + part = strings.ToLower(part) + switch { + case part == "tmp", part == "temp": + return true + case strings.Contains(part, "scratch"): + return true + case strings.HasSuffix(part, "tmp"), strings.HasSuffix(part, "temp"): + return true + default: + return false + } +} + +func normalizeTransientBuildPart(part string, firstChild bool) string { + if part == "" || strings.HasPrefix(part, "$") { + return part + } + base := part + ext := "" + if suffix := filepath.Ext(part); suffix == ".dir" { + base = strings.TrimSuffix(part, suffix) + ext = suffix + } + prefix, sep, suffix, ok := splitGeneratedSuffix(base) + if ok && (firstChild || looksGeneratedBuildID(suffix)) { + return prefix + sep + buildGeneratedIDToken + ext + } + if !firstChild && looksGeneratedBuildID(base) { + return buildGeneratedIDToken + ext + } + return part +} + +func splitGeneratedSuffix(part string) (prefix, sep, suffix string, ok bool) { + idx := strings.LastIndexAny(part, "-_") + if idx <= 0 || idx >= len(part)-1 { + return "", "", "", false + } + return part[:idx], part[idx : idx+1], part[idx+1:], true +} + +func looksGeneratedBuildID(part string) bool { + if len(part) < 6 { + return false + } + hasLetter := false + hasDigit := false + hexOnly := true + for _, r := range part { + switch { + case r >= '0' && r <= '9': + hasDigit = true + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z': + hasLetter = true + if !(r >= 'a' && r <= 'f' || r >= 'A' && r <= 'F') { + hexOnly = false + } + default: + return false + } + } + return (hasDigit && hasLetter) || (hexOnly && len(part) >= 8) +} diff --git a/internal/evaluator/planner_normalize_test.go b/internal/evaluator/planner_normalize_test.go new file mode 100644 index 0000000..af68b3c --- /dev/null +++ b/internal/evaluator/planner_normalize_test.go @@ -0,0 +1,54 @@ +package evaluator + +import ( + "testing" + + "github.com/goplus/llar/internal/trace" +) + +func TestNormalizeScopeTokenHeuristicBuildNoise(t *testing.T) { + scope := trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/_build", + InstallRoot: "/tmp/work/install", + } + tests := []struct { + name string + token string + want string + }{ + { + name: "scratch workspace child", + token: "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CMakeFiles/pkgRedirects", + want: "$BUILD/CMakeFiles/$TMPDIR/TryCompile-$ID/CMakeFiles/pkgRedirects", + }, + { + name: "generated scratch artifact", + token: "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/cmTC_deadbeef", + want: "$BUILD/CMakeFiles/$TMPDIR/TryCompile-$ID/cmTC_$ID", + }, + { + name: "generic temp subtree", + token: "/tmp/work/_build/probe/tmp/job-doc/result_4f3e2d1c.dir", + want: "$BUILD/probe/$TMPDIR/job-$ID/result_$ID.dir", + }, + { + name: "tmp pid suffix", + token: "/tmp/work/_build/cache/output.tmp.12345", + want: "$BUILD/cache/output.tmp.$ID", + }, + { + name: "stable build artifact", + token: "/tmp/work/_build/libtracecore.a", + want: "$BUILD/libtracecore.a", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := normalizeScopeToken(tc.token, scope); got != tc.want { + t.Fatalf("normalizeScopeToken(%q) = %q, want %q", tc.token, got, tc.want) + } + }) + } +} diff --git a/internal/evaluator/planner_test.go b/internal/evaluator/planner_test.go new file mode 100644 index 0000000..64cecc0 --- /dev/null +++ b/internal/evaluator/planner_test.go @@ -0,0 +1,527 @@ +package evaluator + +import ( + "context" + "reflect" + "testing" + + "github.com/goplus/llar/formula" + "github.com/goplus/llar/internal/trace" +) + +func testMatrix() formula.Matrix { + return formula.Matrix{ + Require: map[string][]string{ + "arch": {"amd64"}, + "os": {"linux"}, + }, + Options: map[string][]string{ + "doc": {"doc-off", "doc-on"}, + "tls": {"tls-off", "tls-on"}, + }, + DefaultOptions: map[string][]string{ + "doc": {"doc-off"}, + "tls": {"tls-off"}, + }, + } +} + +func record(argv []string, cwd string, inputs, changes []string) trace.Record { + return trace.Record{ + Argv: argv, + Cwd: cwd, + Inputs: inputs, + Changes: changes, + } +} + +func event(seq *int64, pid, parent int64, cwd string, kind trace.EventKind, path string, argv ...string) trace.Event { + current := *seq + *seq = current + 1 + return trace.Event{ + Seq: current, + PID: pid, + ParentPID: parent, + Cwd: cwd, + Kind: kind, + Path: path, + Argv: argv, + } +} + +func traceoptionsEventProbe(apiOn, cliOn, shipOn bool) ProbeResult { + scope := trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/_build", + InstallRoot: "/tmp/work/install", + } + configureArgv := []string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"} + if apiOn { + configureArgv = append(configureArgv, "-DTRACE_FEATURE_API=ON") + } + arTemp, ranlibTemp := traceoptionsArchiveTemps(apiOn, cliOn, shipOn) + if cliOn { + configureArgv = append(configureArgv, "-DTRACE_BUILD_CLI=ON") + } + if shipOn { + configureArgv = append(configureArgv, "-DTRACE_INSTALL_ALIAS=ON") + } + traceOptionsDigest := "trace-options-off" + libDigest := "libtracecore-off" + if apiOn { + traceOptionsDigest = "trace-options-api-on" + libDigest = "libtracecore-api-on" + } + installScriptDigest := "cmake-install-default" + if cliOn { + installScriptDigest = "cmake-install-cli" + } + if shipOn { + installScriptDigest = "cmake-install-ship" + } + compileArgv := []string{ + "/usr/bin/cc", + "-I/tmp/work", + "-I/tmp/work/_build", + "-o", "/tmp/work/_build/CMakeFiles/tracecore.dir/core.c.o", + "-c", "/tmp/work/core.c", + } + if apiOn { + compileArgv = []string{ + "/usr/bin/cc", + "-DTRACE_FEATURE_API", + "-I/tmp/work", + "-I/tmp/work/_build", + "-o", "/tmp/work/_build/CMakeFiles/tracecore.dir/core.c.o", + "-c", "/tmp/work/core.c", + } + } + + seq := int64(1) + events := []trace.Event{ + event(&seq, 100, 1, "/tmp/work", trace.EventExec, "", configureArgv...), + event(&seq, 100, 0, "/tmp/work", trace.EventRead, "/tmp/work/CMakeLists.txt"), + event(&seq, 100, 0, "/tmp/work", trace.EventRead, "/tmp/work/trace_options.h.in"), + event(&seq, 100, 0, "/tmp/work", trace.EventRead, "/tmp/work/_build/CMakeFiles/pkgRedirects"), + event(&seq, 100, 0, "/tmp/work", trace.EventRead, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CMakeFiles/pkgRedirects"), + event(&seq, 100, 0, "/tmp/work", trace.EventWrite, "/tmp/work/_build/trace_options.h"), + event(&seq, 100, 0, "/tmp/work", trace.EventWrite, "/tmp/work/_build/CMakeFiles/pkgRedirects"), + event(&seq, 100, 0, "/tmp/work", trace.EventWrite, "/tmp/work/_build/cmake_install.cmake"), + + event(&seq, 110, 100, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", trace.EventExec, "", "/usr/bin/gmake", "-f", "Makefile", "cmTC_deadbeef/fast"), + event(&seq, 110, 0, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", trace.EventRead, "/tmp/work/_build/CMakeFiles/pkgRedirects"), + event(&seq, 110, 0, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", trace.EventRead, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/Makefile"), + event(&seq, 111, 110, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", trace.EventExec, "", "/usr/bin/cc", "-o", "CMakeFiles/cmTC_deadbeef.dir/CheckIncludeFile.c.o", "-c", "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CheckIncludeFile.c"), + event(&seq, 111, 0, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", trace.EventRead, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CheckIncludeFile.c"), + event(&seq, 111, 0, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", trace.EventWrite, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CMakeFiles/pkgRedirects"), + event(&seq, 111, 0, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", trace.EventWrite, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/cmTC_deadbeef"), + + event(&seq, 200, 1, "/tmp/work", trace.EventExec, "", "cmake", "--build", "/tmp/work/_build", "--config", "Release"), + event(&seq, 200, 0, "/tmp/work", trace.EventRead, "/tmp/work/_build/CMakeCache.txt"), + event(&seq, 201, 200, "/tmp/work/_build", trace.EventExec, "", "/usr/bin/gmake", "-f", "Makefile"), + event(&seq, 201, 0, "/tmp/work/_build", trace.EventRead, "/tmp/work/_build/Makefile"), + event(&seq, 202, 201, "/tmp/work/_build", trace.EventExec, "", "/usr/bin/gmake", "-s", "-f", "CMakeFiles/tracecore.dir/build.make", "CMakeFiles/tracecore.dir/build"), + event(&seq, 202, 0, "/tmp/work/_build", trace.EventRead, "/tmp/work/_build/CMakeFiles/tracecore.dir/build.make"), + event(&seq, 203, 202, "/tmp/work/_build", trace.EventExec, "", compileArgv...), + event(&seq, 203, 0, "/tmp/work/_build", trace.EventRead, "/tmp/work/core.c"), + event(&seq, 203, 0, "/tmp/work/_build", trace.EventRead, "/tmp/work/trace.h"), + event(&seq, 203, 0, "/tmp/work/_build", trace.EventRead, "/tmp/work/_build/trace_options.h"), + event(&seq, 203, 0, "/tmp/work/_build", trace.EventWrite, "/tmp/work/_build/CMakeFiles/tracecore.dir/core.c.o"), + event(&seq, 204, 202, "/tmp/work/_build", trace.EventExec, "", "/usr/bin/ar", "qc", "/tmp/work/_build/libtracecore.a", "/tmp/work/_build/CMakeFiles/tracecore.dir/core.c.o"), + event(&seq, 204, 0, "/tmp/work/_build", trace.EventRead, "/tmp/work/_build/CMakeFiles/tracecore.dir/core.c.o"), + event(&seq, 204, 0, "/tmp/work/_build", trace.EventWrite, "/tmp/work/_build/libtracecore.a"), + event(&seq, 204, 0, "/tmp/work/_build", trace.EventWrite, "/tmp/work/_build/"+arTemp), + event(&seq, 205, 202, "/tmp/work/_build", trace.EventExec, "", "/usr/bin/ranlib", "/tmp/work/_build/libtracecore.a"), + event(&seq, 205, 0, "/tmp/work/_build", trace.EventRead, "/tmp/work/_build/libtracecore.a"), + event(&seq, 205, 0, "/tmp/work/_build", trace.EventWrite, "/tmp/work/_build/"+ranlibTemp), + event(&seq, 205, 0, "/tmp/work/_build", trace.EventWrite, "/tmp/work/_build/libtracecore.a"), + } + + if cliOn { + events = append(events, + event(&seq, 206, 201, "/tmp/work/_build", trace.EventExec, "", "/usr/bin/gmake", "-s", "-f", "CMakeFiles/tracecli.dir/build.make", "CMakeFiles/tracecli.dir/build"), + event(&seq, 206, 0, "/tmp/work/_build", trace.EventRead, "/tmp/work/_build/CMakeFiles/tracecli.dir/build.make"), + event(&seq, 207, 206, "/tmp/work/_build", trace.EventExec, "", "/usr/bin/cc", "/tmp/work/cli.c", "/tmp/work/_build/libtracecore.a", "-o", "/tmp/work/_build/tracecli"), + event(&seq, 207, 0, "/tmp/work/_build", trace.EventRead, "/tmp/work/cli.c"), + event(&seq, 207, 0, "/tmp/work/_build", trace.EventRead, "/tmp/work/_build/libtracecore.a"), + event(&seq, 207, 0, "/tmp/work/_build", trace.EventWrite, "/tmp/work/_build/tracecli"), + ) + } + + events = append(events, + event(&seq, 300, 1, "/tmp/work", trace.EventExec, "", "cmake", "--install", "/tmp/work/_build", "--prefix", "/tmp/work/install"), + event(&seq, 300, 0, "/tmp/work", trace.EventRead, "/tmp/work/_build/cmake_install.cmake"), + event(&seq, 300, 0, "/tmp/work", trace.EventRead, "/tmp/work/_build/libtracecore.a"), + event(&seq, 300, 0, "/tmp/work", trace.EventRead, "/tmp/work/trace.h"), + event(&seq, 300, 0, "/tmp/work", trace.EventRead, "/tmp/work/_build/trace_options.h"), + ) + if cliOn { + events = append(events, event(&seq, 300, 0, "/tmp/work", trace.EventRead, "/tmp/work/_build/tracecli")) + } + events = append(events, + event(&seq, 300, 0, "/tmp/work", trace.EventWrite, "/tmp/work/install/lib/libtracecore.a"), + event(&seq, 300, 0, "/tmp/work", trace.EventWrite, "/tmp/work/install/include/trace.h"), + event(&seq, 300, 0, "/tmp/work", trace.EventWrite, "/tmp/work/install/include/trace_options.h"), + event(&seq, 300, 0, "/tmp/work", trace.EventWrite, "/tmp/work/_build/install_manifest.txt"), + ) + if cliOn { + events = append(events, event(&seq, 300, 0, "/tmp/work", trace.EventWrite, "/tmp/work/install/bin/tracecli")) + } + if shipOn { + events = append(events, event(&seq, 300, 0, "/tmp/work", trace.EventWrite, "/tmp/work/install/include/trace_alias.h")) + } + inputDigests := map[string]string{ + "/tmp/work/CMakeLists.txt": "src-cmakelists", + "/tmp/work/trace_options.h.in": "src-trace-options-in", + "/tmp/work/core.c": "src-core", + "/tmp/work/trace.h": "src-trace-h", + "/tmp/work/_build/CMakeCache.txt": "build-cmake-cache", + "/tmp/work/_build/Makefile": "build-makefile", + "/tmp/work/_build/CMakeFiles/pkgRedirects": "build-pkgredirects", + "/tmp/work/_build/CMakeFiles/tracecore.dir/build.make": "build-tracecore-make", + "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/Makefile": "try-makefile", + "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CheckIncludeFile.c": "try-source", + "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CMakeFiles/pkgRedirects": "try-pkgredirects", + "/tmp/work/_build/trace_options.h": traceOptionsDigest, + "/tmp/work/_build/CMakeFiles/tracecore.dir/core.c.o": libDigest + "-obj", + "/tmp/work/_build/libtracecore.a": libDigest, + "/tmp/work/_build/cmake_install.cmake": installScriptDigest, + } + if cliOn { + inputDigests["/tmp/work/cli.c"] = "src-cli" + inputDigests["/tmp/work/_build/CMakeFiles/tracecli.dir/build.make"] = "build-tracecli-make" + inputDigests["/tmp/work/_build/tracecli"] = "tracecli-on" + } + outputManifest := OutputManifest{ + Entries: map[string]OutputEntry{ + "include/trace.h": {Kind: "file", Digest: "install-trace-h"}, + "include/trace_options.h": {Kind: "file", Digest: traceOptionsDigest}, + "lib/libtracecore.a": {Kind: "archive", Digest: libDigest}, + }, + } + if cliOn { + outputManifest.Entries["bin/tracecli"] = OutputEntry{Kind: "file", Digest: "tracecli-on", Executable: true} + } + if shipOn { + outputManifest.Entries["include/trace_alias.h"] = OutputEntry{Kind: "file", Digest: "trace-alias-on"} + } + return ProbeResult{ + Events: events, + Scope: scope, + InputDigests: inputDigests, + OutputManifest: outputManifest, + } +} + +func traceoptionsArchiveTemps(apiOn, cliOn, shipOn bool) (string, string) { + switch { + case apiOn: + return "stCXhzz0", "stQugPSt" + case cliOn: + return "stizVeGp", "stiZkO0K" + case shipOn: + return "stNMeD5X", "stdwbVyX" + default: + return "stNjnHgT", "stvgaB7q" + } +} + +func TestWatchIndependentOptions(t *testing.T) { + matrix := testMatrix() + traces := map[string][]trace.Record{ + "amd64-linux|doc-off-tls-off": { + record([]string{"cc", "-c", "core.c"}, "/tmp/work", []string{"/tmp/work/core.c"}, []string{"/tmp/work/build/core.o"}), + record([]string{"ar", "rcs", "libfoo.a", "core.o"}, "/tmp/work", []string{"/tmp/work/build/core.o"}, []string{"/tmp/work/out/lib/libfoo.a"}), + }, + "amd64-linux|doc-on-tls-off": { + record([]string{"cc", "-c", "core.c"}, "/tmp/work", []string{"/tmp/work/core.c"}, []string{"/tmp/work/build/core.o"}), + record([]string{"ar", "rcs", "libfoo.a", "core.o"}, "/tmp/work", []string{"/tmp/work/build/core.o"}, []string{"/tmp/work/out/lib/libfoo.a"}), + record([]string{"sphinx-build", "docs", "out/share/doc"}, "/tmp/work", []string{"/tmp/work/docs/index.md"}, []string{"/tmp/work/out/share/doc/index.html"}), + }, + "amd64-linux|doc-off-tls-on": { + record([]string{"cc", "-c", "core.c"}, "/tmp/work", []string{"/tmp/work/core.c"}, []string{"/tmp/work/build/core.o"}), + record([]string{"ar", "rcs", "libfoo.a", "core.o"}, "/tmp/work", []string{"/tmp/work/build/core.o"}, []string{"/tmp/work/out/lib/libfoo.a"}), + record([]string{"python", "gen_tls.py"}, "/tmp/work", []string{"/tmp/work/gen_tls.py"}, []string{"/tmp/work/out/bin/tls-helper"}), + }, + } + + got, trusted, err := Watch(context.Background(), matrix, func(_ context.Context, combo string) (ProbeResult, error) { + return ProbeResult{Records: traces[combo]}, nil + }) + if err != nil { + t.Fatalf("Watch() unexpected error: %v", err) + } + if !trusted { + t.Fatalf("Watch() trusted = false, want true") + } + + want := []string{ + "amd64-linux|doc-off-tls-off", + "amd64-linux|doc-off-tls-on", + "amd64-linux|doc-on-tls-off", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Watch() = %v, want %v", got, want) + } +} + +func TestWatchStopsReducingZeroDiffOption(t *testing.T) { + matrix := testMatrix() + traces := map[string][]trace.Record{ + "amd64-linux|doc-off-tls-off": { + record([]string{"cc", "-c", "core.c"}, "/tmp/work", []string{"/tmp/work/core.c"}, []string{"/tmp/work/build/core.o"}), + record([]string{"ar", "rcs", "libfoo.a", "core.o"}, "/tmp/work", []string{"/tmp/work/build/core.o"}, []string{"/tmp/work/out/lib/libfoo.a"}), + }, + "amd64-linux|doc-on-tls-off": { + record([]string{"cc", "-c", "core.c"}, "/tmp/work", []string{"/tmp/work/core.c"}, []string{"/tmp/work/build/core.o"}), + record([]string{"ar", "rcs", "libfoo.a", "core.o"}, "/tmp/work", []string{"/tmp/work/build/core.o"}, []string{"/tmp/work/out/lib/libfoo.a"}), + }, + "amd64-linux|doc-off-tls-on": { + record([]string{"cc", "-c", "core.c"}, "/tmp/work", []string{"/tmp/work/core.c"}, []string{"/tmp/work/build/core.o"}), + record([]string{"ar", "rcs", "libfoo.a", "core.o"}, "/tmp/work", []string{"/tmp/work/build/core.o"}, []string{"/tmp/work/out/lib/libfoo.a"}), + record([]string{"python", "gen_tls.py"}, "/tmp/work", []string{"/tmp/work/gen_tls.py"}, []string{"/tmp/work/out/bin/tls-helper"}), + }, + } + + got, trusted, err := Watch(context.Background(), matrix, func(_ context.Context, combo string) (ProbeResult, error) { + return ProbeResult{Records: traces[combo]}, nil + }) + if err != nil { + t.Fatalf("Watch() unexpected error: %v", err) + } + if !trusted { + t.Fatalf("Watch() trusted = false, want true") + } + + want := []string{ + "amd64-linux|doc-off-tls-off", + "amd64-linux|doc-off-tls-on", + "amd64-linux|doc-on-tls-off", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Watch() = %v, want %v", got, want) + } +} + +func TestWatchTreatsDownstreamLinkDependencyAsCollision(t *testing.T) { + matrix := formula.Matrix{ + Require: map[string][]string{ + "arch": {"amd64"}, + "os": {"linux"}, + }, + Options: map[string][]string{ + "api": {"api-off", "api-on"}, + "cli": {"cli-off", "cli-on"}, + }, + DefaultOptions: map[string][]string{ + "api": {"api-off"}, + "cli": {"cli-off"}, + }, + } + traces := map[string][]trace.Record{ + "amd64-linux|api-off-cli-off": { + record([]string{"cc", "-c", "core.c", "-o", "build/core.o"}, "/tmp/work", []string{"/tmp/work/core.c"}, []string{"/tmp/work/build/core.o"}), + record([]string{"ar", "rcs", "out/lib/libtracecore.a", "build/core.o"}, "/tmp/work", []string{"/tmp/work/build/core.o"}, []string{"/tmp/work/out/lib/libtracecore.a"}), + }, + "amd64-linux|api-on-cli-off": { + record([]string{"cc", "-DAPI", "-c", "core.c", "-o", "build/core.o"}, "/tmp/work", []string{"/tmp/work/core.c"}, []string{"/tmp/work/build/core.o"}), + record([]string{"ar", "rcs", "out/lib/libtracecore.a", "build/core.o"}, "/tmp/work", []string{"/tmp/work/build/core.o"}, []string{"/tmp/work/out/lib/libtracecore.a"}), + }, + "amd64-linux|api-off-cli-on": { + record([]string{"cc", "-c", "core.c", "-o", "build/core.o"}, "/tmp/work", []string{"/tmp/work/core.c"}, []string{"/tmp/work/build/core.o"}), + record([]string{"ar", "rcs", "out/lib/libtracecore.a", "build/core.o"}, "/tmp/work", []string{"/tmp/work/build/core.o"}, []string{"/tmp/work/out/lib/libtracecore.a"}), + record([]string{"cc", "-c", "cli.c", "-o", "build/cli.o"}, "/tmp/work", []string{"/tmp/work/cli.c"}, []string{"/tmp/work/build/cli.o"}), + record([]string{"cc", "build/cli.o", "out/lib/libtracecore.a", "-o", "out/bin/tracecli"}, "/tmp/work", []string{"/tmp/work/build/cli.o", "/tmp/work/out/lib/libtracecore.a"}, []string{"/tmp/work/out/bin/tracecli"}), + }, + } + + got, trusted, err := Watch(context.Background(), matrix, func(_ context.Context, combo string) (ProbeResult, error) { + return ProbeResult{Records: traces[combo]}, nil + }) + if err != nil { + t.Fatalf("Watch() unexpected error: %v", err) + } + if !trusted { + t.Fatalf("Watch() trusted = false, want true") + } + + want := []string{ + "amd64-linux|api-off-cli-off", + "amd64-linux|api-off-cli-on", + "amd64-linux|api-on-cli-off", + "amd64-linux|api-on-cli-on", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Watch() = %v, want %v", got, want) + } +} + +func TestWatchIgnoresConfigureSidecarsForShipOnlyOption(t *testing.T) { + matrix := formula.Matrix{ + Options: map[string][]string{ + "api": {"api-off", "api-on"}, + "cli": {"cli-off", "cli-on"}, + "ship": {"ship-off", "ship-on"}, + }, + DefaultOptions: map[string][]string{ + "api": {"api-off"}, + "cli": {"cli-off"}, + "ship": {"ship-off"}, + }, + } + scope := trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/_build", + InstallRoot: "/tmp/work/install", + } + traces := map[string][]trace.Record{ + "api-off-cli-off-ship-off": { + record([]string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/CMakeLists.txt"}, + []string{ + "/tmp/work/_build/trace_options.h", + "/tmp/work/_build/CMakeFiles/pkgRedirects", + "/tmp/work/_build/cmake_install.cmake", + }), + record([]string{"cmake", "-E", "echo", "probe"}, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", + []string{"/tmp/work/_build/CMakeFiles/pkgRedirects"}, + []string{"/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CMakeFiles/pkgRedirects"}), + record([]string{"cc", "-c", "/tmp/work/src/core.c", "-o", "/tmp/work/_build/core.o"}, "/tmp/work/_build", + []string{"/tmp/work/src/core.c", "/tmp/work/_build/trace_options.h"}, + []string{"/tmp/work/_build/core.o"}), + record([]string{"ar", "rcs", "/tmp/work/_build/libtracecore.a", "/tmp/work/_build/core.o"}, "/tmp/work/_build", + []string{"/tmp/work/_build/core.o"}, + []string{"/tmp/work/_build/libtracecore.a"}), + }, + "api-on-cli-off-ship-off": { + record([]string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build", "-DAPI=ON"}, "/tmp/work", + []string{"/tmp/work/CMakeLists.txt"}, + []string{ + "/tmp/work/_build/trace_options.h", + "/tmp/work/_build/CMakeFiles/pkgRedirects", + "/tmp/work/_build/cmake_install.cmake", + }), + record([]string{"cmake", "-E", "echo", "probe"}, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", + []string{"/tmp/work/_build/CMakeFiles/pkgRedirects"}, + []string{"/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CMakeFiles/pkgRedirects"}), + record([]string{"cc", "-DAPI", "-c", "/tmp/work/src/core.c", "-o", "/tmp/work/_build/core.o"}, "/tmp/work/_build", + []string{"/tmp/work/src/core.c", "/tmp/work/_build/trace_options.h"}, + []string{"/tmp/work/_build/core.o"}), + record([]string{"ar", "rcs", "/tmp/work/_build/libtracecore.a", "/tmp/work/_build/core.o"}, "/tmp/work/_build", + []string{"/tmp/work/_build/core.o"}, + []string{"/tmp/work/_build/libtracecore.a"}), + }, + "api-off-cli-on-ship-off": { + record([]string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/CMakeLists.txt"}, + []string{ + "/tmp/work/_build/trace_options.h", + "/tmp/work/_build/CMakeFiles/pkgRedirects", + "/tmp/work/_build/cmake_install.cmake", + }), + record([]string{"cmake", "-E", "echo", "probe"}, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", + []string{"/tmp/work/_build/CMakeFiles/pkgRedirects"}, + []string{"/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CMakeFiles/pkgRedirects"}), + record([]string{"cc", "-c", "/tmp/work/src/core.c", "-o", "/tmp/work/_build/core.o"}, "/tmp/work/_build", + []string{"/tmp/work/src/core.c", "/tmp/work/_build/trace_options.h"}, + []string{"/tmp/work/_build/core.o"}), + record([]string{"ar", "rcs", "/tmp/work/_build/libtracecore.a", "/tmp/work/_build/core.o"}, "/tmp/work/_build", + []string{"/tmp/work/_build/core.o"}, + []string{"/tmp/work/_build/libtracecore.a"}), + record([]string{"cc", "/tmp/work/src/cli.c", "/tmp/work/_build/libtracecore.a", "-o", "/tmp/work/_build/tracecli"}, "/tmp/work/_build", + []string{"/tmp/work/src/cli.c", "/tmp/work/_build/libtracecore.a"}, + []string{"/tmp/work/_build/tracecli"}), + }, + "api-off-cli-off-ship-on": { + record([]string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/CMakeLists.txt"}, + []string{ + "/tmp/work/_build/trace_options.h", + "/tmp/work/_build/CMakeFiles/pkgRedirects", + "/tmp/work/_build/cmake_install.cmake", + }), + record([]string{"cmake", "-E", "echo", "probe"}, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", + []string{"/tmp/work/_build/CMakeFiles/pkgRedirects"}, + []string{"/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CMakeFiles/pkgRedirects"}), + record([]string{"cc", "-c", "/tmp/work/src/core.c", "-o", "/tmp/work/_build/core.o"}, "/tmp/work/_build", + []string{"/tmp/work/src/core.c", "/tmp/work/_build/trace_options.h"}, + []string{"/tmp/work/_build/core.o"}), + record([]string{"ar", "rcs", "/tmp/work/_build/libtracecore.a", "/tmp/work/_build/core.o"}, "/tmp/work/_build", + []string{"/tmp/work/_build/core.o"}, + []string{"/tmp/work/_build/libtracecore.a"}), + record([]string{"cmake", "--install", "/tmp/work/_build"}, "/tmp/work", + []string{ + "/tmp/work/_build/cmake_install.cmake", + "/tmp/work/_build/libtracecore.a", + "/tmp/work/_build/trace_options.h", + }, + []string{ + "/tmp/work/install/lib/libtracecore.a", + "/tmp/work/install/include/trace_alias.h", + }), + }, + } + + got, trusted, err := Watch(context.Background(), matrix, func(_ context.Context, combo string) (ProbeResult, error) { + return ProbeResult{Records: traces[combo], Scope: scope}, nil + }) + if err != nil { + t.Fatalf("Watch() unexpected error: %v", err) + } + if !trusted { + t.Fatalf("Watch() trusted = false, want true") + } + + want := []string{ + "api-off-cli-off-ship-off", + "api-off-cli-off-ship-on", + "api-off-cli-on-ship-off", + "api-on-cli-off-ship-off", + "api-on-cli-on-ship-off", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Watch() = %v, want %v", got, want) + } +} + +func TestWatchIgnoresConfigureSidecarsForShipOnlyOptionWithEvents(t *testing.T) { + matrix := formula.Matrix{ + Options: map[string][]string{ + "api": {"api-off", "api-on"}, + "cli": {"cli-off", "cli-on"}, + "ship": {"ship-off", "ship-on"}, + }, + DefaultOptions: map[string][]string{ + "api": {"api-off"}, + "cli": {"cli-off"}, + "ship": {"ship-off"}, + }, + } + probes := map[string]ProbeResult{ + "api-off-cli-off-ship-off": traceoptionsEventProbe(false, false, false), + "api-on-cli-off-ship-off": traceoptionsEventProbe(true, false, false), + "api-off-cli-on-ship-off": traceoptionsEventProbe(false, true, false), + "api-off-cli-off-ship-on": traceoptionsEventProbe(false, false, true), + } + + got, trusted, err := Watch(context.Background(), matrix, func(_ context.Context, combo string) (ProbeResult, error) { + return probes[combo], nil + }) + if err != nil { + t.Fatalf("Watch() unexpected error: %v", err) + } + if !trusted { + t.Fatalf("Watch() trusted = false, want true") + } + + want := []string{ + "api-off-cli-off-ship-off", + "api-off-cli-off-ship-on", + "api-off-cli-on-ship-off", + "api-on-cli-off-ship-off", + "api-on-cli-on-ship-off", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Watch() = %v, want %v", got, want) + } +} diff --git a/internal/evaluator/replay_test.go b/internal/evaluator/replay_test.go new file mode 100644 index 0000000..be574ba --- /dev/null +++ b/internal/evaluator/replay_test.go @@ -0,0 +1,320 @@ +package evaluator + +import ( + "os" + "path/filepath" + "testing" + + "github.com/goplus/llar/internal/trace" +) + +func TestReplayStepInitializesBuildRoot(t *testing.T) { + if !replayStepInitializesBuildRoot(replayRoot{ + reads: []string{"$SRC/CMakeLists.txt"}, + writes: []string{"$BUILD/CMakeCache.txt", "$BUILD/config.h"}, + }) { + t.Fatal("configure-style replay root should initialize build root") + } + if replayStepInitializesBuildRoot(replayRoot{ + reads: []string{"$BUILD/config.h", "$SRC/lib/xmlparse.c"}, + writes: []string{"$BUILD/CMakeFiles/expat.dir/lib/xmlparse.c.o"}, + }) { + t.Fatal("build-style replay root should not initialize build root") + } +} + +func TestPrepareReplayBuildRootClearsInitializerState(t *testing.T) { + buildRoot := filepath.Join(t.TempDir(), "_build") + if err := os.MkdirAll(filepath.Join(buildRoot, "nested"), 0o755); err != nil { + t.Fatalf("MkdirAll(buildRoot): %v", err) + } + stale := filepath.Join(buildRoot, "CMakeCache.txt") + if err := os.WriteFile(stale, []byte("stale"), 0o644); err != nil { + t.Fatalf("WriteFile(stale): %v", err) + } + if err := os.WriteFile(filepath.Join(buildRoot, "nested", "keep.txt"), []byte("stale"), 0o644); err != nil { + t.Fatalf("WriteFile(nested stale): %v", err) + } + + err := prepareReplayBuildRoot(buildRoot, []replayRoot{{ + reads: []string{"$SRC/CMakeLists.txt"}, + writes: []string{"$BUILD/CMakeCache.txt"}, + }}) + if err != nil { + t.Fatalf("prepareReplayBuildRoot() error: %v", err) + } + if _, err := os.Stat(buildRoot); err != nil { + t.Fatalf("build root missing after prepare: %v", err) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Fatalf("stale CMakeCache.txt still exists, err=%v", err) + } + if _, err := os.Stat(filepath.Join(buildRoot, "nested", "keep.txt")); !os.IsNotExist(err) { + t.Fatalf("nested stale file still exists, err=%v", err) + } +} + +func TestNormalizeReplayEnvMatchesTraceSSARules(t *testing.T) { + scope := trace.Scope{ + SourceRoot: "/tmp/src", + BuildRoot: "/tmp/src/_build", + } + got := normalizeReplayEnv([]string{ + "PWD=/tmp/src", + "SHLVL=2", + "TERM=xterm-256color", + "CFLAGS=-O2", + "TMPDIR=/tmp/src/_tmp", + }, scope) + want := []string{ + "CFLAGS=-O2", + "TMPDIR=$SRC/_tmp", + } + if len(got) != len(want) { + t.Fatalf("normalizeReplayEnv() len = %d, want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("normalizeReplayEnv()[%d] = %q, want %q (full=%v)", i, got[i], want[i], got) + } + } +} + +func TestPlanRootReplayIgnoresNoiseOnlyEnvChanges(t *testing.T) { + makeProbe := func(sourceRoot, outputDir string, pid int64, pwd string) ProbeResult { + return ProbeResult{ + Records: []trace.Record{{ + PID: pid, + ParentPID: 0, + Argv: []string{ + filepath.Join(sourceRoot, "emit.sh"), + "--out=" + filepath.Join(outputDir, "share", "config.txt"), + }, + Cwd: sourceRoot, + Env: []string{ + "PWD=" + pwd, + "SHLVL=2", + "TERM=xterm-256color", + }, + Changes: []string{ + filepath.Join(outputDir, "share", "config.txt"), + }, + }}, + OutputDir: outputDir, + ReplayReady: true, + Scope: trace.Scope{ + SourceRoot: sourceRoot, + BuildRoot: filepath.Join(sourceRoot, "_build"), + InstallRoot: outputDir, + }, + } + } + + baseSource := t.TempDir() + leftSource := t.TempDir() + rightSource := t.TempDir() + baseOut := t.TempDir() + leftOut := t.TempDir() + rightOut := t.TempDir() + + plan, unavailable := planRootReplay( + makeProbe(baseSource, baseOut, 100, filepath.Join(baseSource, "work")), + makeProbe(leftSource, leftOut, 200, filepath.Join(leftSource, "nested", "cwd")), + makeProbe(rightSource, rightOut, 300, filepath.Join(rightSource, "other", "cwd")), + ) + if unavailable != "no replay root parameters changed across probes" { + t.Fatalf("planRootReplay() unavailable = %q, want noise-only env differences to be ignored", unavailable) + } + if plan.summary == nil || len(plan.summary.ChangedRoots) != 0 { + t.Fatalf("planRootReplay() changed roots = %v, want none", plan.summary) + } +} + +func TestSelectReplayJoinFrontierPrunesPreJoinSideBranch(t *testing.T) { + steps := []replayRoot{ + {writes: []string{"$BUILD/a.txt"}}, + {reads: []string{"$BUILD/a.txt"}, writes: []string{"$BUILD/side.txt"}}, + {writes: []string{"$BUILD/b.txt"}}, + {reads: []string{"$BUILD/a.txt", "$BUILD/b.txt"}, writes: []string{"$INSTALL/out.txt"}}, + } + changed := map[int]struct{}{0: {}, 2: {}} + join := map[int]struct{}{3: {}} + + got := selectReplayJoinFrontier(steps, changed, join) + want := []int{0, 2, 3} + if len(got) != len(want) { + t.Fatalf("selectReplayJoinFrontier() len = %d, want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("selectReplayJoinFrontier()[%d] = %d, want %d (full=%v)", i, got[i], want[i], got) + } + } +} + +func TestReplayJoinRootIndexesMapsJoinSetToReplayRoots(t *testing.T) { + makeProbe := func(sourceRoot string, variant string) ProbeResult { + buildRoot := filepath.Join(sourceRoot, "build") + installRoot := filepath.Join(sourceRoot, "out") + compileAArgv := []string{"cc", "-c", filepath.Join(sourceRoot, "a.c"), "-o", filepath.Join(buildRoot, "a.o")} + if variant != "" { + compileAArgv = []string{"cc", "-DFEATURE", "-c", filepath.Join(sourceRoot, "a.c"), "-o", filepath.Join(buildRoot, "a.o")} + } + return ProbeResult{ + Records: []trace.Record{ + { + PID: 100, + ParentPID: 0, + Argv: compileAArgv, + Cwd: sourceRoot, + Inputs: []string{filepath.Join(sourceRoot, "a.c")}, + Changes: []string{filepath.Join(buildRoot, "a.o")}, + }, + { + PID: 200, + ParentPID: 0, + Argv: []string{"cc", "-c", filepath.Join(sourceRoot, "b.c"), "-o", filepath.Join(buildRoot, "b.o")}, + Cwd: sourceRoot, + Inputs: []string{filepath.Join(sourceRoot, "b.c")}, + Changes: []string{filepath.Join(buildRoot, "b.o")}, + }, + { + PID: 300, + ParentPID: 0, + Argv: []string{ + "cc", + filepath.Join(buildRoot, "a.o"), + filepath.Join(buildRoot, "b.o"), + "-o", + filepath.Join(buildRoot, "app"), + }, + Cwd: sourceRoot, + Inputs: []string{ + filepath.Join(buildRoot, "a.o"), + filepath.Join(buildRoot, "b.o"), + }, + Changes: []string{ + filepath.Join(buildRoot, "app"), + }, + }, + }, + Scope: trace.Scope{ + SourceRoot: sourceRoot, + BuildRoot: buildRoot, + InstallRoot: installRoot, + }, + } + } + + base := makeProbe(t.TempDir(), "") + probe := makeProbe(t.TempDir(), "left") + scan := replayRoots(probe) + got := replayJoinRootIndexes(base, probe, scan) + if len(got) != 1 { + t.Fatalf("replayJoinRootIndexes() len = %d, want 1 (%v)", len(got), got) + } + if _, ok := got[2]; !ok { + t.Fatalf("replayJoinRootIndexes() = %v, want root index 2", got) + } +} + +func TestReplayJoinRootIndexesUsesMinJoinRoots(t *testing.T) { + makeProbe := func(sourceRoot string, feature bool) ProbeResult { + buildRoot := filepath.Join(sourceRoot, "build") + installRoot := filepath.Join(sourceRoot, "out") + compileAArgv := []string{"cc", "-c", filepath.Join(sourceRoot, "a.c"), "-o", filepath.Join(buildRoot, "a.o")} + if feature { + compileAArgv = []string{"cc", "-DFEATURE", "-c", filepath.Join(sourceRoot, "a.c"), "-o", filepath.Join(buildRoot, "a.o")} + } + return ProbeResult{ + Records: []trace.Record{ + { + PID: 100, + ParentPID: 0, + Argv: compileAArgv, + Cwd: sourceRoot, + Inputs: []string{filepath.Join(sourceRoot, "a.c")}, + Changes: []string{filepath.Join(buildRoot, "a.o")}, + }, + { + PID: 200, + ParentPID: 0, + Argv: []string{"cc", "-c", filepath.Join(sourceRoot, "b.c"), "-o", filepath.Join(buildRoot, "b.o")}, + Cwd: sourceRoot, + Inputs: []string{filepath.Join(sourceRoot, "b.c")}, + Changes: []string{filepath.Join(buildRoot, "b.o")}, + }, + { + PID: 300, + ParentPID: 0, + Argv: []string{ + "cc", + filepath.Join(buildRoot, "a.o"), + filepath.Join(buildRoot, "b.o"), + "-o", + filepath.Join(buildRoot, "app"), + }, + Cwd: sourceRoot, + Inputs: []string{ + filepath.Join(buildRoot, "a.o"), + filepath.Join(buildRoot, "b.o"), + }, + Changes: []string{ + filepath.Join(buildRoot, "app"), + }, + }, + { + PID: 400, + ParentPID: 0, + Argv: []string{ + "pkg", + filepath.Join(buildRoot, "app"), + filepath.Join(sourceRoot, "manifest.txt"), + "-o", + filepath.Join(buildRoot, "app.pkg"), + }, + Cwd: sourceRoot, + Inputs: []string{ + filepath.Join(buildRoot, "app"), + filepath.Join(sourceRoot, "manifest.txt"), + }, + Changes: []string{ + filepath.Join(buildRoot, "app.pkg"), + }, + }, + { + PID: 500, + ParentPID: 0, + Argv: []string{ + "/bin/cp", + filepath.Join(buildRoot, "app.pkg"), + filepath.Join(installRoot, "app.pkg"), + }, + Cwd: sourceRoot, + Inputs: []string{ + filepath.Join(buildRoot, "app.pkg"), + }, + Changes: []string{ + filepath.Join(installRoot, "app.pkg"), + }, + }, + }, + Scope: trace.Scope{ + SourceRoot: sourceRoot, + BuildRoot: buildRoot, + InstallRoot: installRoot, + }, + } + } + + base := makeProbe(t.TempDir(), false) + probe := makeProbe(t.TempDir(), true) + scan := replayRoots(probe) + got := replayJoinRootIndexes(base, probe, scan) + if len(got) != 1 { + t.Fatalf("replayJoinRootIndexes() len = %d, want 1 (%v)", len(got), got) + } + if _, ok := got[2]; !ok { + t.Fatalf("replayJoinRootIndexes() = %v, want only minimal join root index 2", got) + } +} diff --git a/internal/evaluator/synthesis_test.go b/internal/evaluator/synthesis_test.go new file mode 100644 index 0000000..cc0f21c --- /dev/null +++ b/internal/evaluator/synthesis_test.go @@ -0,0 +1,390 @@ +package evaluator + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/goplus/llar/internal/trace" +) + +func TestSynthesizeOutputTrees_UsesDirectMergeMode(t *testing.T) { + baseDir := t.TempDir() + leftDir := t.TempDir() + rightDir := t.TempDir() + + writeMergeFile(t, filepath.Join(baseDir, "share", "config.txt"), []byte("base\n"), 0o644) + writeMergeFile(t, filepath.Join(leftDir, "share", "config.txt"), []byte("left\n"), 0o644) + writeMergeFile(t, filepath.Join(rightDir, "share", "config.txt"), []byte("base\n"), 0o644) + + baseManifest, err := BuildOutputManifest(baseDir, "") + if err != nil { + t.Fatalf("BuildOutputManifest(base) error: %v", err) + } + leftManifest, err := BuildOutputManifest(leftDir, "") + if err != nil { + t.Fatalf("BuildOutputManifest(left) error: %v", err) + } + rightManifest, err := BuildOutputManifest(rightDir, "") + if err != nil { + t.Fatalf("BuildOutputManifest(right) error: %v", err) + } + + result, err := synthesizeOutputTrees( + context.Background(), + ProbeResult{OutputDir: baseDir, OutputManifest: baseManifest}, + ProbeResult{OutputDir: leftDir, OutputManifest: leftManifest}, + ProbeResult{OutputDir: rightDir, OutputManifest: rightManifest}, + ) + if err != nil { + t.Fatalf("synthesizeOutputTrees() error: %v", err) + } + if result.Mode != OutputSynthesisModeDirectMerge { + t.Fatalf("mode = %q, want %q", result.Mode, OutputSynthesisModeDirectMerge) + } + if !result.Clean() { + t.Fatalf("synthesis issues = %#v, want clean", result.Issues) + } + if _, ok := result.AsMergeResult(); !ok { + t.Fatal("AsMergeResult() = !ok, want direct merge result") + } + _ = os.RemoveAll(result.Root) +} + +func TestSynthesizeOutputTrees_FallsBackToRootReplay(t *testing.T) { + baseSource := t.TempDir() + leftSource := t.TempDir() + rightSource := t.TempDir() + script := "#!/bin/sh\nset -eu\nproto=0\nlog=0\nout=\nfor arg in \"$@\"; do\n case \"$arg\" in\n --proto=*) proto=${arg#--proto=} ;;\n --log=*) log=${arg#--log=} ;;\n --out=*) out=${arg#--out=} ;;\n esac\ndone\nmkdir -p \"$(dirname \"$out\")\"\nprintf 'state=proto:%s,log:%s\\n' \"$proto\" \"$log\" > \"$out\"\n" + for _, root := range []string{baseSource, leftSource, rightSource} { + scriptPath := filepath.Join(root, "emit-config.sh") + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatalf("WriteFile(script): %v", err) + } + } + + baseDir := t.TempDir() + leftDir := t.TempDir() + rightDir := t.TempDir() + writeMergeFile(t, filepath.Join(baseDir, "share", "config.txt"), []byte("state=proto:0,log:0\n"), 0o644) + writeMergeFile(t, filepath.Join(leftDir, "share", "config.txt"), []byte("state=proto:1,log:0\n"), 0o644) + writeMergeFile(t, filepath.Join(rightDir, "share", "config.txt"), []byte("state=proto:0,log:1\n"), 0o644) + + baseManifest, err := BuildOutputManifest(baseDir, "") + if err != nil { + t.Fatalf("BuildOutputManifest(base) error: %v", err) + } + leftManifest, err := BuildOutputManifest(leftDir, "") + if err != nil { + t.Fatalf("BuildOutputManifest(left) error: %v", err) + } + rightManifest, err := BuildOutputManifest(rightDir, "") + if err != nil { + t.Fatalf("BuildOutputManifest(right) error: %v", err) + } + + baseScript := filepath.Join(baseSource, "emit-config.sh") + leftScript := filepath.Join(leftSource, "emit-config.sh") + rightScript := filepath.Join(rightSource, "emit-config.sh") + baseProbe := ProbeResult{ + Records: []trace.Record{{ + PID: 100, + ParentPID: 0, + Argv: []string{ + baseScript, + "--proto=0", + "--log=0", + "--out=" + filepath.Join(baseDir, "share", "config.txt"), + }, + Cwd: baseSource, + Env: []string{"PATH=" + os.Getenv("PATH")}, + Changes: []string{ + filepath.Join(baseDir, "share", "config.txt"), + }, + }}, + OutputDir: baseDir, + OutputManifest: baseManifest, + Scope: trace.Scope{ + SourceRoot: baseSource, + BuildRoot: filepath.Join(baseSource, "_build"), + InstallRoot: baseDir, + }, + ReplayReady: true, + } + leftProbe := ProbeResult{ + Records: []trace.Record{{ + PID: 200, + ParentPID: 0, + Argv: []string{ + leftScript, + "--proto=1", + "--log=0", + "--out=" + filepath.Join(leftDir, "share", "config.txt"), + }, + Cwd: leftSource, + Env: []string{"PATH=" + os.Getenv("PATH")}, + Changes: []string{ + filepath.Join(leftDir, "share", "config.txt"), + }, + }}, + OutputDir: leftDir, + OutputManifest: leftManifest, + Scope: trace.Scope{ + SourceRoot: leftSource, + BuildRoot: filepath.Join(leftSource, "_build"), + InstallRoot: leftDir, + }, + ReplayReady: true, + } + rightProbe := ProbeResult{ + Records: []trace.Record{{ + PID: 300, + ParentPID: 0, + Argv: []string{ + rightScript, + "--proto=0", + "--log=1", + "--out=" + filepath.Join(rightDir, "share", "config.txt"), + }, + Cwd: rightSource, + Env: []string{"PATH=" + os.Getenv("PATH")}, + Changes: []string{ + filepath.Join(rightDir, "share", "config.txt"), + }, + }}, + OutputDir: rightDir, + OutputManifest: rightManifest, + Scope: trace.Scope{ + SourceRoot: rightSource, + BuildRoot: filepath.Join(rightSource, "_build"), + InstallRoot: rightDir, + }, + ReplayReady: true, + } + + result, err := synthesizeOutputTrees(context.Background(), baseProbe, leftProbe, rightProbe) + if err != nil { + t.Fatalf("synthesizeOutputTrees() error: %v", err) + } + if result.Mode != OutputSynthesisModeRootReplay { + t.Fatalf("mode = %q, want %q", result.Mode, OutputSynthesisModeRootReplay) + } + if !result.Clean() { + t.Fatalf("synthesis issues = %#v, want clean replay", result.Issues) + } + got, err := os.ReadFile(filepath.Join(result.Root, "share", "config.txt")) + if err != nil { + t.Fatalf("ReadFile(replay output): %v", err) + } + if string(got) != "state=proto:1,log:1\n" { + t.Fatalf("replay output = %q, want combined state", string(got)) + } + _ = os.RemoveAll(result.Root) +} + +func TestSynthesizeOutputTrees_RootReplayPreservesBaseOutputsForUnselectedRoots(t *testing.T) { + baseSource := t.TempDir() + leftSource := t.TempDir() + rightSource := t.TempDir() + + staticScript := "#!/bin/sh\nset -eu\nout=\nfor arg in \"$@\"; do\n case \"$arg\" in\n --out=*) out=${arg#--out=} ;;\n esac\ndone\nmkdir -p \"$(dirname \"$out\")\"\nprintf 'static=base\\n' > \"$out\"\n" + configScript := "#!/bin/sh\nset -eu\nproto=0\nlog=0\nout=\nfor arg in \"$@\"; do\n case \"$arg\" in\n --proto=*) proto=${arg#--proto=} ;;\n --log=*) log=${arg#--log=} ;;\n --out=*) out=${arg#--out=} ;;\n esac\ndone\nmkdir -p \"$(dirname \"$out\")\"\nprintf 'config=proto:%s,log:%s\\n' \"$proto\" \"$log\" > \"$out\"\n" + for _, root := range []string{baseSource, leftSource, rightSource} { + if err := os.WriteFile(filepath.Join(root, "emit-static.sh"), []byte(staticScript), 0o755); err != nil { + t.Fatalf("WriteFile(static script): %v", err) + } + if err := os.WriteFile(filepath.Join(root, "emit-config.sh"), []byte(configScript), 0o755); err != nil { + t.Fatalf("WriteFile(config script): %v", err) + } + } + + baseDir := t.TempDir() + leftDir := t.TempDir() + rightDir := t.TempDir() + writeMergeFile(t, filepath.Join(baseDir, "share", "static.txt"), []byte("static=base\n"), 0o644) + writeMergeFile(t, filepath.Join(baseDir, "share", "config.txt"), []byte("config=proto:0,log:0\n"), 0o644) + writeMergeFile(t, filepath.Join(leftDir, "share", "static.txt"), []byte("static=base\n"), 0o644) + writeMergeFile(t, filepath.Join(leftDir, "share", "config.txt"), []byte("config=proto:1,log:0\n"), 0o644) + writeMergeFile(t, filepath.Join(rightDir, "share", "static.txt"), []byte("static=base\n"), 0o644) + writeMergeFile(t, filepath.Join(rightDir, "share", "config.txt"), []byte("config=proto:0,log:1\n"), 0o644) + + baseManifest, _ := BuildOutputManifest(baseDir, "") + leftManifest, _ := BuildOutputManifest(leftDir, "") + rightManifest, _ := BuildOutputManifest(rightDir, "") + + makeProbe := func(sourceRoot, outputDir string, manifest OutputManifest, proto, log string, pidBase int64) ProbeResult { + return ProbeResult{ + Records: []trace.Record{ + { + PID: pidBase, + ParentPID: 0, + Argv: []string{ + filepath.Join(sourceRoot, "emit-static.sh"), + "--out=" + filepath.Join(outputDir, "share", "static.txt"), + }, + Cwd: sourceRoot, + Env: []string{"PATH=" + os.Getenv("PATH")}, + Changes: []string{ + filepath.Join(outputDir, "share", "static.txt"), + }, + }, + { + PID: pidBase + 1, + ParentPID: 0, + Argv: []string{ + filepath.Join(sourceRoot, "emit-config.sh"), + "--proto=" + proto, + "--log=" + log, + "--out=" + filepath.Join(outputDir, "share", "config.txt"), + }, + Cwd: sourceRoot, + Env: []string{"PATH=" + os.Getenv("PATH")}, + Changes: []string{ + filepath.Join(outputDir, "share", "config.txt"), + }, + }, + }, + OutputDir: outputDir, + OutputManifest: manifest, + Scope: trace.Scope{ + SourceRoot: sourceRoot, + BuildRoot: filepath.Join(sourceRoot, "_build"), + InstallRoot: outputDir, + }, + ReplayReady: true, + } + } + + result, err := synthesizeOutputTrees( + context.Background(), + makeProbe(baseSource, baseDir, baseManifest, "0", "0", 1000), + makeProbe(leftSource, leftDir, leftManifest, "1", "0", 2000), + makeProbe(rightSource, rightDir, rightManifest, "0", "1", 3000), + ) + if err != nil { + t.Fatalf("synthesizeOutputTrees() error: %v", err) + } + if result.Mode != OutputSynthesisModeRootReplay || !result.Clean() { + t.Fatalf("result = %#v, want clean root replay", result) + } + staticData, err := os.ReadFile(filepath.Join(result.Root, "share", "static.txt")) + if err != nil { + t.Fatalf("ReadFile(static.txt): %v", err) + } + if string(staticData) != "static=base\n" { + t.Fatalf("static.txt = %q, want preserved base output", string(staticData)) + } + configData, err := os.ReadFile(filepath.Join(result.Root, "share", "config.txt")) + if err != nil { + t.Fatalf("ReadFile(config.txt): %v", err) + } + if string(configData) != "config=proto:1,log:1\n" { + t.Fatalf("config.txt = %q, want merged replay output", string(configData)) + } + _ = os.RemoveAll(result.Root) +} + +func TestSynthesizeOutputTrees_RootReplaySelectsDependentRoots(t *testing.T) { + baseSource := t.TempDir() + leftSource := t.TempDir() + rightSource := t.TempDir() + + genScript := "#!/bin/sh\nset -eu\nproto=0\nlog=0\nout=\nfor arg in \"$@\"; do\n case \"$arg\" in\n --proto=*) proto=${arg#--proto=} ;;\n --log=*) log=${arg#--log=} ;;\n --out=*) out=${arg#--out=} ;;\n esac\ndone\nmkdir -p \"$(dirname \"$out\")\"\nprintf 'proto=%s,log=%s\\n' \"$proto\" \"$log\" > \"$out\"\n" + for _, root := range []string{baseSource, leftSource, rightSource} { + if err := os.WriteFile(filepath.Join(root, "gen.sh"), []byte(genScript), 0o755); err != nil { + t.Fatalf("WriteFile(gen script): %v", err) + } + } + + baseDir := t.TempDir() + leftDir := t.TempDir() + rightDir := t.TempDir() + baseBuild := filepath.Join(baseSource, "_build") + leftBuild := filepath.Join(leftSource, "_build") + rightBuild := filepath.Join(rightSource, "_build") + if err := os.MkdirAll(baseBuild, 0o755); err != nil { + t.Fatalf("MkdirAll(base build): %v", err) + } + if err := os.MkdirAll(leftBuild, 0o755); err != nil { + t.Fatalf("MkdirAll(left build): %v", err) + } + if err := os.MkdirAll(rightBuild, 0o755); err != nil { + t.Fatalf("MkdirAll(right build): %v", err) + } + writeMergeFile(t, filepath.Join(baseBuild, "generated.txt"), []byte("proto=0,log=0\n"), 0o644) + writeMergeFile(t, filepath.Join(leftBuild, "generated.txt"), []byte("proto=1,log=0\n"), 0o644) + writeMergeFile(t, filepath.Join(rightBuild, "generated.txt"), []byte("proto=0,log=1\n"), 0o644) + writeMergeFile(t, filepath.Join(baseDir, "share", "config.txt"), []byte("proto=0,log=0\n"), 0o644) + writeMergeFile(t, filepath.Join(leftDir, "share", "config.txt"), []byte("proto=1,log=0\n"), 0o644) + writeMergeFile(t, filepath.Join(rightDir, "share", "config.txt"), []byte("proto=0,log=1\n"), 0o644) + + baseManifest, _ := BuildOutputManifest(baseDir, "") + leftManifest, _ := BuildOutputManifest(leftDir, "") + rightManifest, _ := BuildOutputManifest(rightDir, "") + + makeProbe := func(sourceRoot, buildRoot, outputDir string, manifest OutputManifest, proto, log string, pidBase int64) ProbeResult { + generatedPath := filepath.Join(buildRoot, "generated.txt") + return ProbeResult{ + Records: []trace.Record{ + { + PID: pidBase, + ParentPID: 0, + Argv: []string{ + filepath.Join(sourceRoot, "gen.sh"), + "--proto=" + proto, + "--log=" + log, + "--out=" + generatedPath, + }, + Cwd: sourceRoot, + Env: []string{"PATH=" + os.Getenv("PATH")}, + Changes: []string{generatedPath}, + }, + { + PID: pidBase + 1, + ParentPID: 0, + Argv: []string{ + "/bin/cp", + generatedPath, + filepath.Join(outputDir, "share", "config.txt"), + }, + Cwd: sourceRoot, + Env: []string{"PATH=" + os.Getenv("PATH")}, + Inputs: []string{generatedPath}, + Changes: []string{ + filepath.Join(outputDir, "share", "config.txt"), + }, + }, + }, + OutputDir: outputDir, + OutputManifest: manifest, + Scope: trace.Scope{ + SourceRoot: sourceRoot, + BuildRoot: buildRoot, + InstallRoot: outputDir, + }, + ReplayReady: true, + } + } + + result, err := synthesizeOutputTrees( + context.Background(), + makeProbe(baseSource, baseBuild, baseDir, baseManifest, "0", "0", 4000), + makeProbe(leftSource, leftBuild, leftDir, leftManifest, "1", "0", 5000), + makeProbe(rightSource, rightBuild, rightDir, rightManifest, "0", "1", 6000), + ) + if err != nil { + t.Fatalf("synthesizeOutputTrees() error: %v", err) + } + if result.Mode != OutputSynthesisModeRootReplay || !result.Clean() { + t.Fatalf("result = %#v, want clean dependent root replay", result) + } + got, err := os.ReadFile(filepath.Join(result.Root, "share", "config.txt")) + if err != nil { + t.Fatalf("ReadFile(config.txt): %v", err) + } + if string(got) != "proto=1,log=1\n" { + t.Fatalf("config.txt = %q, want replayed downstream output", string(got)) + } + _ = os.RemoveAll(result.Root) +} diff --git a/internal/formula/formula.go b/internal/formula/formula.go index 263c6e1..31ba7a2 100644 --- a/internal/formula/formula.go +++ b/internal/formula/formula.go @@ -28,8 +28,10 @@ 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.BuildResult) } // loadFS is the internal implementation for loading a formula from a filesystem. @@ -134,7 +136,9 @@ func loadFS(fs fs.ReadFileFS, path string) (*Formula, error) { structElem: class, ModPath: valueOf(class, "modPath").(string), FromVer: valueOf(class, "modFromVer").(string), + Matrix: valueOf(class, "matrix").(formula.Matrix), OnBuild: valueOf(class, "fOnBuild").(func(*formula.Context, *formula.Project, *formula.BuildResult)), + OnTest: valueOf(class, "fOnTest").(func(*formula.Context, *formula.Project, *formula.BuildResult)), OnRequire: valueOf(class, "fOnRequire").(func(*formula.Project, *formula.ModuleDeps)), }, nil } diff --git a/internal/formula/formula_test.go b/internal/formula/formula_test.go index 53d8e2f..37ab3ea 100644 --- a/internal/formula/formula_test.go +++ b/internal/formula/formula_test.go @@ -32,10 +32,14 @@ func TestLoadFS(t *testing.T) { if f.OnRequire == nil { t.Error("OnRequire is nil") } + if f.OnTest == nil { + t.Error("OnTest is nil") + } // Functional test: verify callbacks can be invoked without panic f.OnRequire(&formulapkg.Project{}, &formulapkg.ModuleDeps{}) f.OnBuild(&formulapkg.Context{}, &formulapkg.Project{}, &formulapkg.BuildResult{}) + f.OnTest(&formulapkg.Context{}, &formulapkg.Project{}, &formulapkg.BuildResult{}) }) t.Run("NonExistentFile", func(t *testing.T) { diff --git a/internal/formula/testdata/formula/hello_llar.gox b/internal/formula/testdata/formula/hello_llar.gox index 65ea8aa..30cdbe9 100644 --- a/internal/formula/testdata/formula/hello_llar.gox +++ b/internal/formula/testdata/formula/hello_llar.gox @@ -9,3 +9,7 @@ onRequire (proj, deps) => { onBuild (ctx, proj, out) => { echo "hello" } + +onTest (ctx, proj, out) => { + echo "test" +} diff --git a/internal/trace/capture_linux.go b/internal/trace/capture_linux.go new file mode 100644 index 0000000..4ce0acf --- /dev/null +++ b/internal/trace/capture_linux.go @@ -0,0 +1,171 @@ +//go:build linux + +package trace + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +const attachReadyTimeout = 2 * time.Second + +type attachedTracer struct { + cmd *exec.Cmd + statusFile string +} + +// CaptureLockedThread traces the current goroutine on a dedicated OS thread. +// It is intended for best-effort OnBuild tracing rather than whole-process capture. +func CaptureLockedThread(ctx context.Context, opts CaptureOptions, run func() error) (CaptureResult, error) { + if _, err := exec.LookPath("strace"); err != nil { + return CaptureResult{}, fmt.Errorf("strace not found: %w", err) + } + + tmpDir, err := os.MkdirTemp("", "llar-trace-*") + if err != nil { + return CaptureResult{}, err + } + defer os.RemoveAll(tmpDir) + + outFile := tmpDir + "/trace.log" + + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + clearPtracer, err := allowPtraceAttach() + if err != nil { + return CaptureResult{}, err + } + defer clearPtracer() + + tid := unix.Gettid() + tracer, err := startAttachedTracer(ctx, tid, outFile) + if err != nil { + return CaptureResult{}, err + } + if err := waitForTracerReady(tracer, tid); err != nil { + _ = stopAttachedTracer(tracer) + return CaptureResult{}, err + } + + runErr := run() + stopErr := stopAttachedTracer(tracer) + data, readErr := os.ReadFile(outFile) + + if runErr != nil { + return CaptureResult{}, runErr + } + if stopErr != nil { + return CaptureResult{}, stopErr + } + if readErr != nil { + return CaptureResult{}, readErr + } + parsed := parseStraceOutputDetailed(string(data), parseOptions{ + rootCwd: opts.RootCwd, + keepRoots: opts.KeepRoots, + }) + return CaptureResult{Records: parsed.records, Events: parsed.events, Diagnostics: parsed.diagnostics}, nil +} + +func allowPtraceAttach() (func(), error) { + err := unix.Prctl(unix.PR_SET_PTRACER, uintptr(unix.PR_SET_PTRACER_ANY), 0, 0, 0) + switch { + case err == nil: + return func() { + _ = unix.Prctl(unix.PR_SET_PTRACER, 0, 0, 0, 0) + }, nil + case errors.Is(err, unix.EINVAL): + // PR_SET_PTRACER is only meaningful when Yama restricted ptrace is enabled. + // On kernels without that support, classic same-UID ptrace rules still apply. + return func() {}, nil + default: + return nil, fmt.Errorf("enable ptrace attach: %w", err) + } +} + +func startAttachedTracer(ctx context.Context, tid int, outFile string) (*attachedTracer, error) { + statusFile := outFile + ".status" + status, err := os.Create(statusFile) + if err != nil { + return nil, err + } + defer status.Close() + + cmd := exec.CommandContext(ctx, "strace", + "-f", + "-ttt", + "-yy", + "-v", + "-s", "65535", + "-e", "trace=execve,execveat,chdir,open,openat,openat2,creat,rename,renameat,renameat2,unlink,unlinkat,mkdir,mkdirat,symlink,symlinkat,clone,fork,vfork", + "-o", outFile, + "-p", strconv.Itoa(tid), + ) + cmd.Stdout = io.Discard + cmd.Stderr = status + if err := cmd.Start(); err != nil { + return nil, err + } + return &attachedTracer{cmd: cmd, statusFile: statusFile}, nil +} + +func waitForTracerReady(tracer *attachedTracer, tid int) error { + want := fmt.Sprintf("Process %d attached", tid) + deadline := time.Now().Add(attachReadyTimeout) + for time.Now().Before(deadline) { + data, _ := os.ReadFile(tracer.statusFile) + text := string(data) + if strings.Contains(text, want) { + return nil + } + if attachFailure(text) { + return fmt.Errorf("strace attach failed: %s", strings.TrimSpace(text)) + } + if tracer.cmd.Process == nil || unix.Kill(tracer.cmd.Process.Pid, 0) != nil { + if strings.TrimSpace(text) == "" { + return fmt.Errorf("strace exited before attaching to tid %d", tid) + } + return fmt.Errorf("strace exited before attaching to tid %d: %s", tid, strings.TrimSpace(text)) + } + time.Sleep(10 * time.Millisecond) + } + data, _ := os.ReadFile(tracer.statusFile) + return fmt.Errorf("timed out waiting for strace to attach to tid %d: %s", tid, strings.TrimSpace(string(data))) +} + +func attachFailure(text string) bool { + lower := strings.ToLower(text) + return strings.Contains(lower, "operation not permitted") || + strings.Contains(lower, "no such process") || + strings.Contains(lower, "ptrace") +} + +func stopAttachedTracer(tracer *attachedTracer) error { + if tracer == nil || tracer.cmd == nil || tracer.cmd.Process == nil { + return nil + } + _ = tracer.cmd.Process.Signal(os.Interrupt) + err := tracer.cmd.Wait() + if err == nil { + return nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if status, ok := exitErr.Sys().(syscall.WaitStatus); ok && status.Signaled() && status.Signal() == syscall.SIGINT { + return nil + } + } + return err +} diff --git a/internal/trace/capture_unsupported.go b/internal/trace/capture_unsupported.go new file mode 100644 index 0000000..b9ef62d --- /dev/null +++ b/internal/trace/capture_unsupported.go @@ -0,0 +1,13 @@ +//go:build !linux + +package trace + +import ( + "context" + "fmt" + "runtime" +) + +func CaptureLockedThread(context.Context, CaptureOptions, func() error) (CaptureResult, error) { + return CaptureResult{}, fmt.Errorf("trace is unsupported on %s", runtime.GOOS) +} diff --git a/internal/trace/ssa/analysis_api.go b/internal/trace/ssa/analysis_api.go new file mode 100644 index 0000000..6ae5623 --- /dev/null +++ b/internal/trace/ssa/analysis_api.go @@ -0,0 +1,427 @@ +package ssa + +import ( + "maps" + + "github.com/goplus/llar/internal/trace" +) + +type ImpactEvidence struct { + Changed map[string]bool +} + +type ImpactStateKey struct { + Path string + Tombstone bool + Missing bool +} + +type ImpactProfile struct { + SeedWrites map[string]struct{} + NeedPaths map[string]struct{} + SlicePaths map[string]struct{} + JoinSet []int + SeedStates map[ImpactStateKey]struct{} + NeedStates map[ImpactStateKey]struct{} + FlowStates map[ImpactStateKey]struct{} + Ambiguous bool +} + +type ActionPair struct { + BaseIdx int + ProbeIdx int +} + +type WavefrontProbeClass uint8 + +const ( + WavefrontProbeUnknown WavefrontProbeClass = iota + WavefrontProbeUnchanged + WavefrontProbeMutationRoot + WavefrontProbeFlow +) + +type WavefrontStageResult struct { + Matched int + BaseOnly []int + ProbeOnly []int + Pairs []ActionPair + RemainingBase []int + RemainingProbe []int + ProbeClass []WavefrontProbeClass + DivergedDefs map[PathState]struct{} + Ambiguous bool + ReadAmbiguous bool +} + +type ActionRole uint8 + +const ( + ActionRoleMainline ActionRole = iota + ActionRoleTooling + ActionRoleProbe + ActionRoleDelivery +) + +type DefRole uint8 + +const ( + DefRoleMainline DefRole = iota + DefRoleTooling + DefRoleProbe + DefRoleDelivery +) + +type RoleProjection struct { + ActionNoise []bool + ActionDeliveryOnly []bool + DefNoise map[PathState]struct{} + ActionClass []ActionRole + DefClass map[PathState]DefRole +} + +type PathSSAFlow struct { + ReachedDefs map[PathState]struct{} + ReachedActions map[int]struct{} + JoinActions []int + FlowActions []int + FrontierActions []int + ExternalReads map[int]map[string]struct{} + ExternalDefs map[int]map[PathState]struct{} + AmbiguousReads bool +} + +type AnalysisSideInput struct { + Records []trace.Record + Events []trace.Event + Scope trace.Scope + InputDigests map[string]string +} + +type AnalysisInput struct { + Base AnalysisSideInput + Probe AnalysisSideInput +} + +type AnalysisDebug struct { + BaseGraph Graph + ProbeGraph Graph + BaseRoles RoleProjection + ProbeRoles RoleProjection + Wavefront WavefrontStageResult + Flow PathSSAFlow + AffectedPairs []ActionPair + UnchangedProbe []int + RootProbe []int + FlowProbe []int + DivergedProbe []int + FrontierProbe []int +} + +type AnalysisResult struct { + Profile ImpactProfile + Debug AnalysisDebug +} + +func Analyze(input AnalysisInput) AnalysisResult { + return AnalyzeWithEvidence(input, nil) +} + +func AnalyzeWithEvidence(input AnalysisInput, evidence *ImpactEvidence) AnalysisResult { + base := BuildGraph(BuildInput{ + Records: input.Base.Records, + Events: input.Base.Events, + Scope: input.Base.Scope, + InputDigests: input.Base.InputDigests, + }) + probe := BuildGraph(BuildInput{ + Records: input.Probe.Records, + Events: input.Probe.Events, + Scope: input.Probe.Scope, + InputDigests: input.Probe.InputDigests, + }) + pipeline := runTraceSSAImpactPipeline(base, probe, importImpactEvidence(evidence)) + baseRoles := exportRoleProjection(pipeline.baseRoles) + probeRoles := exportRoleProjection(pipeline.probeRoles) + diff := exportWavefrontStageResult(pipeline.diff) + profile := exportImpactProfile(pipeline.profile) + flow := exportPathSSAFlow(pipeline.flow) + return AnalysisResult{ + Profile: profile, + Debug: AnalysisDebug{ + BaseGraph: base, + ProbeGraph: probe, + BaseRoles: baseRoles, + ProbeRoles: probeRoles, + Wavefront: diff, + Flow: flow, + AffectedPairs: exportActionPairs(collectAffectedPairs(base, pipeline.baseRoles, probe, pipeline.probeRoles, pipeline.diff)), + UnchangedProbe: wavefrontProbeIndexes(pipeline.diff, wavefrontProbeUnchanged), + RootProbe: wavefrontVisibleMutationRoots(pipeline.diff, pipeline.probeRoles), + FlowProbe: wavefrontProbeIndexes(pipeline.diff, wavefrontProbeFlow), + DivergedProbe: wavefrontDivergedProbe(pipeline.diff), + FrontierProbe: append([]int(nil), pipeline.flow.frontierActions...), + }, + } +} + +func ProjectRoles(graph Graph) RoleProjection { + return exportRoleProjection(projectRoles(graph)) +} + +func WavefrontDiff(base, probe Graph, baseRoles, probeRoles RoleProjection) WavefrontStageResult { + return exportWavefrontStageResult(wavefrontDiff(base, probe, importRoleProjection(baseRoles), importRoleProjection(probeRoles))) +} + +func WavefrontDiffWithEvidence(base, probe Graph, baseRoles, probeRoles RoleProjection, evidence *ImpactEvidence) WavefrontStageResult { + return exportWavefrontStageResult(wavefrontDiffWithEvidence(base, probe, importRoleProjection(baseRoles), importRoleProjection(probeRoles), importImpactEvidence(evidence))) +} + +func ExtractWavefrontImpact(base Graph, baseRoles RoleProjection, probe Graph, probeRoles RoleProjection, diff WavefrontStageResult, evidence *ImpactEvidence) (ImpactProfile, PathSSAFlow) { + profile, flow := extractWavefrontImpact( + base, + importRoleProjection(baseRoles), + probe, + importRoleProjection(probeRoles), + importWavefrontStageResult(diff), + importImpactEvidence(evidence), + ) + return exportImpactProfile(profile), exportPathSSAFlow(flow) +} + +func RoleActionClass(roles RoleProjection, idx int) ActionRole { + return ActionRole(roleActionClass(importRoleProjection(roles), idx)) +} + +func RoleDefClass(roles RoleProjection, def PathState) DefRole { + return DefRole(roleDefClass(importRoleProjection(roles), def)) +} + +func ImpactPathAllowed(graph Graph, roles RoleProjection, path string) bool { + return impactPathAllowed(graph, importRoleProjection(roles), path) +} + +func ImpactTrackedPathAllowed(graph Graph, roles RoleProjection, path string) bool { + return impactTrackedPathAllowed(graph, importRoleProjection(roles), path) +} + +func PathLooksToolingProjected(graph Graph, roles RoleProjection, path string) bool { + return pathLooksToolingProjected(graph, importRoleProjection(roles), path) +} + +func PathLooksDelivery(graph Graph, path string) bool { + return pathLooksDelivery(graph, path) +} + +func IsProbeOnlyNoisePathProjected(graph Graph, roles RoleProjection, path string) bool { + return isProbeOnlyNoisePathProjected(graph, importRoleProjection(roles), path) +} + +func ActionReadAmbiguousVisible(graph Graph, roles RoleProjection, idx int) bool { + return actionReadAmbiguousVisible(graph, importRoleProjection(roles), idx) +} + +func CanonicalImpactPath(graph Graph, path string) string { + return canonicalImpactPath(graph, path) +} + +func PathChanged(evidence *ImpactEvidence, graph Graph, path string) bool { + return pathChanged(importImpactEvidence(evidence), graph, path) +} + +func NormalizeExecEnv(env []string, scope trace.Scope) []string { + return normalizeEnvEntries(env, scope) +} + +func importImpactEvidence(evidence *ImpactEvidence) *impactEvidence { + if evidence == nil { + return nil + } + return &impactEvidence{changed: maps.Clone(evidence.Changed)} +} + +func exportImpactProfile(in optionProfile) ImpactProfile { + return ImpactProfile{ + SeedWrites: maps.Clone(in.seedWrites), + NeedPaths: maps.Clone(in.needPaths), + SlicePaths: maps.Clone(in.slicePaths), + JoinSet: append([]int(nil), in.joinSet...), + SeedStates: exportStateKeys(in.seedStates), + NeedStates: exportStateKeys(in.needStates), + FlowStates: exportStateKeys(in.flowStates), + Ambiguous: in.ambiguous, + } +} + +func exportStateKeys(in map[pathStateKey]struct{}) map[ImpactStateKey]struct{} { + if len(in) == 0 { + return nil + } + out := make(map[ImpactStateKey]struct{}, len(in)) + for key := range in { + out[ImpactStateKey{Path: key.path, Tombstone: key.tombstone, Missing: key.missing}] = struct{}{} + } + return out +} + +func exportActionPairs(in []actionPair) []ActionPair { + if len(in) == 0 { + return nil + } + out := make([]ActionPair, 0, len(in)) + for _, pair := range in { + out = append(out, ActionPair{BaseIdx: pair.baseIdx, ProbeIdx: pair.probeIdx}) + } + return out +} + +func exportProbeClasses(in []wavefrontProbeClass) []WavefrontProbeClass { + if len(in) == 0 { + return nil + } + out := make([]WavefrontProbeClass, 0, len(in)) + for _, class := range in { + out = append(out, WavefrontProbeClass(class)) + } + return out +} + +func exportRoleProjection(in roleProjection) RoleProjection { + out := RoleProjection{ + ActionNoise: append([]bool(nil), in.ActionNoise...), + ActionDeliveryOnly: append([]bool(nil), in.ActionDeliveryOnly...), + ActionClass: make([]ActionRole, len(in.ActionClass)), + } + if len(in.DefNoise) != 0 { + out.DefNoise = make(map[PathState]struct{}, len(in.DefNoise)) + for def := range in.DefNoise { + out.DefNoise[def] = struct{}{} + } + } + if len(in.DefClass) != 0 { + out.DefClass = make(map[PathState]DefRole, len(in.DefClass)) + for def, class := range in.DefClass { + out.DefClass[def] = DefRole(class) + } + } + for i, class := range in.ActionClass { + out.ActionClass[i] = ActionRole(class) + } + return out +} + +func importRoleProjection(in RoleProjection) roleProjection { + out := roleProjection{ + ActionNoise: append([]bool(nil), in.ActionNoise...), + ActionDeliveryOnly: append([]bool(nil), in.ActionDeliveryOnly...), + ActionClass: make([]actionRole, len(in.ActionClass)), + } + if len(in.DefNoise) != 0 { + out.DefNoise = make(map[PathState]struct{}, len(in.DefNoise)) + for def := range in.DefNoise { + out.DefNoise[def] = struct{}{} + } + } else { + out.DefNoise = make(map[PathState]struct{}) + } + if len(in.DefClass) != 0 { + out.DefClass = make(map[PathState]defRole, len(in.DefClass)) + for def, class := range in.DefClass { + out.DefClass[def] = defRole(class) + } + } else { + out.DefClass = make(map[PathState]defRole) + } + for i, class := range in.ActionClass { + out.ActionClass[i] = actionRole(class) + } + return out +} + +func exportWavefrontStageResult(in wavefrontStageResult) WavefrontStageResult { + out := WavefrontStageResult{ + Matched: in.matched, + BaseOnly: append([]int(nil), in.baseOnly...), + ProbeOnly: append([]int(nil), in.probeOnly...), + Pairs: exportActionPairs(in.pairs), + RemainingBase: append([]int(nil), in.remainingBase...), + RemainingProbe: append([]int(nil), in.remainingProbe...), + ProbeClass: exportProbeClasses(in.probeClass), + Ambiguous: in.ambiguous, + ReadAmbiguous: in.readAmbiguous, + } + if len(in.divergedDefs) != 0 { + out.DivergedDefs = make(map[PathState]struct{}, len(in.divergedDefs)) + for def := range in.divergedDefs { + out.DivergedDefs[def] = struct{}{} + } + } + return out +} + +func importWavefrontStageResult(in WavefrontStageResult) wavefrontStageResult { + out := wavefrontStageResult{ + matched: in.Matched, + baseOnly: append([]int(nil), in.BaseOnly...), + probeOnly: append([]int(nil), in.ProbeOnly...), + remainingBase: append([]int(nil), in.RemainingBase...), + remainingProbe: append([]int(nil), in.RemainingProbe...), + probeClass: make([]wavefrontProbeClass, len(in.ProbeClass)), + ambiguous: in.Ambiguous, + readAmbiguous: in.ReadAmbiguous, + } + for i, class := range in.ProbeClass { + out.probeClass[i] = wavefrontProbeClass(class) + } + if len(in.Pairs) != 0 { + out.pairs = make([]actionPair, 0, len(in.Pairs)) + for _, pair := range in.Pairs { + out.pairs = append(out.pairs, actionPair{baseIdx: pair.BaseIdx, probeIdx: pair.ProbeIdx}) + } + } + if len(in.DivergedDefs) != 0 { + out.divergedDefs = make(map[PathState]struct{}, len(in.DivergedDefs)) + for def := range in.DivergedDefs { + out.divergedDefs[def] = struct{}{} + } + } + return out +} + +func exportPathSSAFlow(in pathSSAFlow) PathSSAFlow { + out := PathSSAFlow{ + JoinActions: append([]int(nil), in.joinActions...), + FlowActions: append([]int(nil), in.flowActions...), + FrontierActions: append([]int(nil), in.frontierActions...), + AmbiguousReads: in.ambiguousReads, + } + if len(in.reachedDefs) != 0 { + out.ReachedDefs = make(map[PathState]struct{}, len(in.reachedDefs)) + for def := range in.reachedDefs { + out.ReachedDefs[def] = struct{}{} + } + } + if len(in.reachedActions) != 0 { + out.ReachedActions = make(map[int]struct{}, len(in.reachedActions)) + for idx := range in.reachedActions { + out.ReachedActions[idx] = struct{}{} + } + } + if len(in.externalReads) != 0 { + out.ExternalReads = make(map[int]map[string]struct{}, len(in.externalReads)) + for idx, reads := range in.externalReads { + out.ExternalReads[idx] = maps.Clone(reads) + } + } + if len(in.externalDefs) != 0 { + out.ExternalDefs = make(map[int]map[PathState]struct{}, len(in.externalDefs)) + for idx, defs := range in.externalDefs { + copied := make(map[PathState]struct{}, len(defs)) + for def := range defs { + copied[def] = struct{}{} + } + out.ExternalDefs[idx] = copied + } + } + return out +} diff --git a/internal/trace/ssa/build.go b/internal/trace/ssa/build.go new file mode 100644 index 0000000..8f9ba31 --- /dev/null +++ b/internal/trace/ssa/build.go @@ -0,0 +1,348 @@ +package ssa + +import ( + "slices" + "sort" + + "github.com/goplus/llar/internal/trace" +) + +func Build(records []trace.Record, events []trace.Event) Graph { + return BuildGraph(BuildInput{ + Records: records, + Events: events, + }) +} + +func buildFromObservation(observation observation) Graph { + graph := Graph{ + Nodes: cloneNodes(observation.Nodes), + Parent: slices.Clone(observation.Parent), + Paths: clonePaths(observation.Paths), + Deps: cloneDeps(observation.Deps), + ActionReads: make([][]Read, len(observation.Nodes)), + ActionWrites: make([][]PathState, len(observation.Nodes)), + ReadersByDef: make(map[PathState][]int), + InitialDefs: make(map[string]PathState), + DefsByPath: make(map[string][]PathState), + } + order := newCausalOrder(graph) + versionByPath := make(map[string]int) + missingDefByPath := make(map[string]PathState) + for i, node := range graph.Nodes { + deletes := actionDeleteSet(node) + for _, entry := range node.Env { + path := envStatePathFromEntry(entry) + if !pathAllowed(graph.Paths, path) || path == "" { + continue + } + initial, ok := graph.InitialDefs[path] + if !ok { + initial = PathState{Writer: -1, Path: path, Version: 0} + graph.InitialDefs[path] = initial + } + graph.ActionReads[i] = append(graph.ActionReads[i], Read{ + Path: path, + Defs: []PathState{initial}, + }) + graph.ReadersByDef[initial] = append(graph.ReadersByDef[initial], i) + } + for _, read := range node.Reads { + if !pathAllowed(graph.Paths, read) || read == "" { + continue + } + defs := reachingDefsForRead(&order, graph.DefsByPath[read], i) + if len(defs) == 0 { + initial, ok := graph.InitialDefs[read] + if !ok { + initial = PathState{Writer: -1, Path: read, Version: 0} + graph.InitialDefs[read] = initial + } + defs = []PathState{initial} + } + binding := Read{Path: read, Defs: slices.Clone(defs)} + graph.ActionReads[i] = append(graph.ActionReads[i], binding) + for _, def := range defs { + graph.ReadersByDef[def] = append(graph.ReadersByDef[def], i) + } + } + for _, miss := range node.ReadMisses { + if !pathAllowed(graph.Paths, miss) || miss == "" { + continue + } + def, ok := missingDefByPath[miss] + if !ok { + def = PathState{Writer: -1, Path: miss, Version: 0, Missing: true} + missingDefByPath[miss] = def + graph.DefsByPath[miss] = append(graph.DefsByPath[miss], def) + } + graph.ActionReads[i] = append(graph.ActionReads[i], Read{ + Path: miss, + Defs: []PathState{def}, + }) + graph.ReadersByDef[def] = append(graph.ReadersByDef[def], i) + } + seenWrites := make(map[string]int) + for _, write := range node.Writes { + if !pathAllowed(graph.Paths, write) || write == "" { + continue + } + if pos, exists := seenWrites[write]; exists { + if _, tombstone := deletes[write]; tombstone { + def := graph.ActionWrites[i][pos] + def.Tombstone = true + graph.ActionWrites[i][pos] = def + defs := graph.DefsByPath[write] + for j := range defs { + if defs[j].Writer == def.Writer && defs[j].Version == def.Version { + defs[j].Tombstone = true + break + } + } + graph.DefsByPath[write] = defs + } + continue + } + versionByPath[write]++ + def := PathState{ + Writer: i, + Path: write, + Version: versionByPath[write], + Tombstone: hasPath(deletes, write), + } + seenWrites[write] = len(graph.ActionWrites[i]) + graph.ActionWrites[i] = append(graph.ActionWrites[i], def) + graph.DefsByPath[write] = append(graph.DefsByPath[write], def) + } + } + return graph +} + +type causalOrder struct { + graph Graph + descCache map[int]map[int]struct{} +} + +func newCausalOrder(graph Graph) causalOrder { + return causalOrder{ + graph: graph, + descCache: make(map[int]map[int]struct{}), + } +} + +func (order *causalOrder) causallyBefore(left, right int) bool { + if left < 0 || right < 0 || left >= right { + return false + } + leftNode := order.graph.Nodes[left] + rightNode := order.graph.Nodes[right] + if leftNode.PID == 0 || rightNode.PID == 0 { + return false + } + if leftNode.PID == rightNode.PID { + return true + } + for parent := order.parent(right); parent >= 0; parent = order.parent(parent) { + if parent == left { + return true + } + } + if _, ok := order.descendants(left)[right]; ok { + return true + } + return false +} + +func (order *causalOrder) descendants(idx int) map[int]struct{} { + if out, ok := order.descCache[idx]; ok { + return out + } + seen := make(map[int]struct{}) + stack := []int{idx} + for len(stack) > 0 { + cur := stack[len(stack)-1] + stack = stack[:len(stack)-1] + for _, edge := range order.graph.Deps[cur] { + if _, ok := seen[edge.To]; ok { + continue + } + seen[edge.To] = struct{}{} + stack = append(stack, edge.To) + } + } + order.descCache[idx] = seen + return seen +} + +func (order *causalOrder) parent(idx int) int { + if idx < 0 || idx >= len(order.graph.Parent) { + return -1 + } + return order.graph.Parent[idx] +} + +func reachingDefsForRead(order *causalOrder, defs []PathState, reader int) []PathState { + if len(defs) == 0 { + return nil + } + candidates := make([]PathState, 0, len(defs)) + writerIndexes := make([]int, 0, len(defs)) + for _, def := range defs { + if def.Writer < 0 || def.Writer >= reader { + continue + } + candidates = append(candidates, def) + writerIndexes = append(writerIndexes, def.Writer) + } + if len(candidates) == 0 { + return nil + } + if readUsesLinearLatest(order.graph, writerIndexes, reader) { + latest := candidates[0] + for _, candidate := range candidates[1:] { + if candidate.Writer > latest.Writer { + latest = candidate + continue + } + if candidate.Writer == latest.Writer && candidate.Version > latest.Version { + latest = candidate + } + } + return []PathState{latest} + } + latest := make([]PathState, 0, len(candidates)) + for _, candidate := range candidates { + superseded := false + for _, other := range candidates { + if candidate == other { + continue + } + if order.causallyBefore(candidate.Writer, other.Writer) { + superseded = true + break + } + } + if superseded { + continue + } + latest = append(latest, candidate) + } + if len(latest) == 0 { + return nil + } + sort.Slice(latest, func(i, j int) bool { + if latest[i].Writer != latest[j].Writer { + return latest[i].Writer < latest[j].Writer + } + if latest[i].Version != latest[j].Version { + return latest[i].Version < latest[j].Version + } + if latest[i].Path != latest[j].Path { + return latest[i].Path < latest[j].Path + } + if latest[i].Tombstone == latest[j].Tombstone { + return false + } + return !latest[i].Tombstone && latest[j].Tombstone + }) + return latest +} + +func readUsesLinearLatest(graph Graph, candidates []int, reader int) bool { + if reader < 0 || reader >= len(graph.Nodes) { + return true + } + if graph.Nodes[reader].PID == 0 { + return true + } + for _, writer := range candidates { + if writer < 0 || writer >= len(graph.Nodes) || graph.Nodes[writer].PID == 0 { + return true + } + } + return false +} + +func pathAllowed(paths map[string]PathInfo, path string) bool { + if len(paths) == 0 { + return false + } + _, ok := paths[path] + return ok +} + +func actionDeleteSet(node ExecNode) map[string]struct{} { + if len(node.Deletes) == 0 { + return nil + } + out := make(map[string]struct{}, len(node.Deletes)) + for _, path := range node.Deletes { + if path == "" { + continue + } + out[path] = struct{}{} + } + return out +} + +func hasPath(paths map[string]struct{}, path string) bool { + if len(paths) == 0 { + return false + } + _, ok := paths[path] + return ok +} + +func cloneNodes(src []ExecNode) []ExecNode { + if len(src) == 0 { + return nil + } + out := make([]ExecNode, len(src)) + for i, node := range src { + out[i] = ExecNode{ + PID: node.PID, + ParentPID: node.ParentPID, + Argv: slices.Clone(node.Argv), + Cwd: node.Cwd, + Env: slices.Clone(node.Env), + Reads: slices.Clone(node.Reads), + ReadMisses: slices.Clone(node.ReadMisses), + Writes: slices.Clone(node.Writes), + Deletes: slices.Clone(node.Deletes), + ExecPath: node.ExecPath, + Tool: node.Tool, + Kind: node.Kind, + ActionKey: node.ActionKey, + StructureKey: node.StructureKey, + Fingerprint: node.Fingerprint, + } + } + return out +} + +func clonePaths(src map[string]PathInfo) map[string]PathInfo { + if len(src) == 0 { + return nil + } + out := make(map[string]PathInfo, len(src)) + for path, info := range src { + out[path] = PathInfo{ + Path: info.Path, + Writers: slices.Clone(info.Writers), + Readers: slices.Clone(info.Readers), + Role: info.Role, + } + } + return out +} + +func cloneDeps(src [][]ExecEdge) [][]ExecEdge { + if len(src) == 0 { + return nil + } + out := make([][]ExecEdge, len(src)) + for i, edges := range src { + out[i] = slices.Clone(edges) + } + return out +} diff --git a/internal/trace/ssa/build_noise_test.go b/internal/trace/ssa/build_noise_test.go new file mode 100644 index 0000000..12e6be3 --- /dev/null +++ b/internal/trace/ssa/build_noise_test.go @@ -0,0 +1,170 @@ +package ssa + +import ( + "testing" + + "github.com/goplus/llar/internal/trace" +) + +func TestNormalizeScopeTokenHeuristicBuildNoise(t *testing.T) { + scope := trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/_build", + InstallRoot: "/tmp/work/install", + } + tests := []struct { + name string + token string + want string + }{ + { + name: "scratch workspace child", + token: "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CMakeFiles/pkgRedirects", + want: "$BUILD/CMakeFiles/$TMPDIR/TryCompile-$ID/CMakeFiles/pkgRedirects", + }, + { + name: "generated scratch artifact", + token: "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/cmTC_deadbeef", + want: "$BUILD/CMakeFiles/$TMPDIR/TryCompile-$ID/cmTC_$ID", + }, + { + name: "generic temp subtree", + token: "/tmp/work/_build/probe/tmp/job-doc/result_4f3e2d1c.dir", + want: "$BUILD/probe/$TMPDIR/job-$ID/result_$ID.dir", + }, + { + name: "tmp pid suffix", + token: "/tmp/work/_build/cache/output.tmp.12345", + want: "$BUILD/cache/output.tmp.$ID", + }, + { + name: "stable build artifact", + token: "/tmp/work/_build/libtracecore.a", + want: "$BUILD/libtracecore.a", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := normalizeScopeToken(tc.token, scope); got != tc.want { + t.Fatalf("normalizeScopeToken(%q) = %q, want %q", tc.token, got, tc.want) + } + }) + } +} + +func TestPathLooksDeliveryExcludesTransientWorkspaceCopy(t *testing.T) { + scope := trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/_build", + } + records := []trace.Record{ + recordWithProc(100, 1, []string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/CMakeLists.txt"}, + []string{"/tmp/work/_build/probe-checks/input.txt"}), + recordWithProc(101, 100, []string{"cp", "input.txt", "status.txt"}, "/tmp/work/_build/probe-checks", + []string{"/tmp/work/_build/probe-checks/input.txt"}, + []string{"/tmp/work/_build/probe-checks/status.txt"}), + } + + graph := BuildGraph(BuildInput{Records: records, Scope: scope}) + if got := pathLooksDelivery(graph, "/tmp/work/_build/probe-checks/status.txt"); got { + t.Fatal("probe workspace leaf copy should not be classified as delivery") + } +} + +func TestInferMainlineVisibleDefsUsesObservableConsumers(t *testing.T) { + scope := trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/_build", + InstallRoot: "/tmp/work/install", + } + records := []trace.Record{ + recordWithProc(100, 1, []string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/CMakeLists.txt"}, + []string{"/tmp/work/_build/control-plane"}), + recordWithProc(110, 100, []string{"gmake", "-f", "/tmp/work/_build/control-plane"}, "/tmp/work/_build", + []string{"/tmp/work/_build/control-plane"}, + nil), + recordWithProc(200, 1, []string{"cc", "/tmp/work/cli.c", "-o", "/tmp/work/_build/tracecli"}, "/tmp/work/_build", + []string{"/tmp/work/cli.c"}, + []string{"/tmp/work/_build/tracecli"}), + recordWithProc(300, 1, []string{"cmake", "--install", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/_build/tracecli"}, + []string{"/tmp/work/install/bin/tracecli"}), + } + + graph := BuildGraph(BuildInput{Records: records, Scope: scope}) + deliveryOnly := make([]bool, len(graph.Actions)) + for idx := range graph.Actions { + deliveryOnly[idx] = isDeliveryOnlyAction(graph, idx) + } + toolingFamily := classifyToolingFamily(graph) + toolingFamily = expandToolingFamily(graph, toolingFamily, deliveryOnly) + toolingWorkspaceRoots := inferToolingWorkspaceRoots(graph, toolingFamily, deliveryOnly) + nonEscapingToolingDefs := classifyNonEscapingToolingDefs(graph, toolingFamily, deliveryOnly, toolingWorkspaceRoots) + mainlineVisibleDefs := inferMainlineVisibleDefs(graph, toolingFamily, toolingWorkspaceRoots, nonEscapingToolingDefs) + + controlPlaneVisible := false + for _, def := range graph.DefsByPath[normalizePath("/tmp/work/_build/control-plane")] { + if defBelongsToMainlineVisibleClosure(mainlineVisibleDefs, def) { + controlPlaneVisible = true + } + } + if controlPlaneVisible { + t.Fatal("configure control-plane leaf should stay outside the mainline-visible closure") + } + + traceCLIVisible := false + for _, def := range graph.DefsByPath[normalizePath("/tmp/work/_build/tracecli")] { + if defBelongsToMainlineVisibleClosure(mainlineVisibleDefs, def) { + traceCLIVisible = true + } + } + if !traceCLIVisible { + t.Fatal("installed top-level build leaf should enter the mainline-visible closure") + } +} + +func TestInferToolingWorkspaceRootsUsesActionEvidence(t *testing.T) { + scope := trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/_build", + } + records := []trace.Record{ + recordWithProc(100, 1, []string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/CMakeLists.txt"}, + []string{ + "/tmp/work/_build/probe-checks/CheckFeature.c", + "/tmp/work/_build/config.h", + }), + recordWithProc(110, 100, []string{"cc", "-c", "CheckFeature.c", "-o", "CheckFeature.c.o"}, "/tmp/work/_build/probe-checks", + []string{"/tmp/work/_build/probe-checks/CheckFeature.c", "/usr/include/stdio.h"}, + []string{"/tmp/work/_build/probe-checks/CheckFeature.c.o"}), + recordWithProc(200, 100, []string{"cc", "/tmp/work/main.c", "/tmp/work/_build/config.h", "-o", "/tmp/work/_build/app"}, "/tmp/work/_build", + []string{ + "/tmp/work/main.c", + "/tmp/work/_build/config.h", + }, + []string{"/tmp/work/_build/app"}), + } + + graph := BuildGraph(BuildInput{Records: records, Scope: scope}) + deliveryOnly := make([]bool, len(graph.Actions)) + for idx := range graph.Actions { + deliveryOnly[idx] = isDeliveryOnlyAction(graph, idx) + } + toolingFamily := classifyToolingFamily(graph) + toolingFamily = expandToolingFamily(graph, toolingFamily, deliveryOnly) + roots := inferToolingWorkspaceRoots(graph, toolingFamily, deliveryOnly) + + if !pathBelongsToToolingWorkspace(roots, "/tmp/work/_build/probe-checks/CheckFeature.c.o") { + t.Fatal("probe-checks object should belong to an inferred tooling workspace root") + } + if pathBelongsToToolingWorkspace(roots, "/tmp/work/_build/app") { + t.Fatal("mainline app should not belong to an inferred tooling workspace root") + } + if pathBelongsToToolingWorkspace(roots, "/tmp/work/_build/config.h") { + t.Fatal("shared configure output at build root should not become a tooling workspace root member") + } +} diff --git a/internal/trace/ssa/builder.go b/internal/trace/ssa/builder.go new file mode 100644 index 0000000..81851e1 --- /dev/null +++ b/internal/trace/ssa/builder.go @@ -0,0 +1,622 @@ +package ssa + +import ( + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/goplus/llar/internal/trace" +) + +var ( + reBuildTmpPIDNoise = regexp.MustCompile(`\.tmp\.[0-9]+$`) +) + +const ( + buildTransientDirToken = "$TMPDIR" + buildGeneratedIDToken = "$ID" +) + +type BuildInput struct { + Records []trace.Record + Events []trace.Event + Scope trace.Scope + InputDigests map[string]string +} + +func BuildGraph(input BuildInput) Graph { + scope := normalizeScope(input.Scope) + var obs observation + source := SourceRecords + if len(input.Events) != 0 { + source = SourceEvents + obs = observationFromEvents(input.Events, input.Records, scope, input.InputDigests) + } else { + obs = observationFromRecords(input.Records, scope, input.InputDigests) + } + + graph := buildFromObservation(obs) + graph.Source = source + graph.Records = len(obs.Nodes) + graph.Events = len(input.Events) + graph.Scope = scope + graph.InputDigests = normalizeInputDigests(input.InputDigests) + graph.RawRecords = slices.Clone(input.Records) + graph.RawEvents = slices.Clone(input.Events) + graph.Actions = cloneNodes(graph.Nodes) + graph.ParentAction = slices.Clone(graph.Parent) + graph.Out = cloneDeps(graph.Deps) + graph.In = invertDeps(graph.Deps) + graph.Indeg = edgeCounts(graph.In) + graph.Outdeg = edgeCounts(graph.Out) + graph.RawPaths = clonePaths(graph.Paths) + return graph +} + +func invertDeps(out [][]ExecEdge) [][]ExecEdge { + if len(out) == 0 { + return nil + } + in := make([][]ExecEdge, len(out)) + for from, edges := range out { + for _, edge := range edges { + if edge.To < 0 || edge.To >= len(in) { + continue + } + in[edge.To] = append(in[edge.To], ExecEdge{From: from, To: edge.To, Path: edge.Path}) + } + } + return in +} + +func edgeCounts(edges [][]ExecEdge) []int { + if len(edges) == 0 { + return nil + } + out := make([]int, len(edges)) + for i := range edges { + out[i] = len(edges[i]) + } + return out +} + +func normalizeInputDigests(inputDigests map[string]string) map[string]string { + if len(inputDigests) == 0 { + return nil + } + out := make(map[string]string, len(inputDigests)) + for path, sum := range inputDigests { + normalized := normalizePath(path) + if normalized == "" { + continue + } + out[normalized] = sum + } + return out +} + +func buildExecNode(record normalizedRecord, scope trace.Scope, inputDigests map[string]string) ExecNode { + kind := classifyActionKindWithScope(record, scope) + tool := "" + if len(record.argv) != 0 { + tool = filepath.Base(record.argv[0]) + } + return ExecNode{ + PID: record.pid, + ParentPID: record.parentPID, + Argv: slices.Clone(record.argv), + Cwd: record.cwd, + Env: normalizeEnvEntries(record.env, scope), + Reads: slices.Clone(record.inputs), + ReadMisses: slices.Clone(record.readMisses), + Writes: slices.Clone(record.changes), + Deletes: slices.Clone(record.deletions), + ExecPath: resolveExecPath(record.cwd, record.argv), + Tool: tool, + Kind: kind, + ActionKey: buildActionKey(kind, tool, record, scope), + StructureKey: scopedStructureKey(record, scope), + Fingerprint: scopedFingerprint(record, scope, inputDigests), + } +} + +func classifyActionKindWithScope(record normalizedRecord, scope trace.Scope) ActionKind { + if len(record.argv) == 0 { + return KindGeneric + } + + tool := filepath.Base(record.argv[0]) + switch { + case tool == "cp": + if len(record.inputs) != 0 && len(record.changes) != 0 { + return KindCopy + } + case tool == "install": + if len(record.changes) != 0 { + return KindInstall + } + case tool == "cmake" && len(record.argv) > 1 && record.argv[1] == "--install": + if len(record.changes) != 0 { + return KindInstall + } + case tool == "cmake" || tool == "ninja" || tool == "make" || tool == "gmake": + return KindConfigure + } + + if kind, ok := classifyScriptWrapperAction(record, scope); ok { + return kind + } + return KindGeneric +} + +func classifyScriptWrapperAction(record normalizedRecord, scope trace.Scope) (ActionKind, bool) { + if len(record.argv) == 0 { + return KindGeneric, false + } + switch filepath.Base(record.argv[0]) { + case "perl", "sh", "bash", "dash": + default: + return KindGeneric, false + } + + for _, path := range record.changes { + if isExplicitDeliveryPath(path, scope) { + return KindInstall, true + } + } + if scriptWrapperLooksConfigureLike(record, scope) { + return KindConfigure, true + } + return KindGeneric, false +} + +func scriptWrapperLooksConfigureLike(record normalizedRecord, scope trace.Scope) bool { + if len(record.changes) == 0 { + return false + } + for _, path := range record.changes { + if isExplicitDeliveryPath(path, scope) || isArtifactPath(path) || pathLooksLikeCompilationInput(path) { + return false + } + } + for _, path := range record.inputs { + if isExplicitDeliveryPath(path, scope) { + return false + } + } + return true +} + +func buildActionKey(kind ActionKind, tool string, record normalizedRecord, scope trace.Scope) string { + switch kind { + case KindCopy: + for _, dst := range record.changes { + if dst == "" { + continue + } + return "copy|" + tool + "|dst=" + normalizeScopeToken(dst, scope) + } + case KindInstall: + for _, dst := range record.changes { + if dst == "" { + continue + } + return "install|" + tool + "|dst=" + normalizeScopeToken(dst, scope) + } + case KindConfigure: + return "configure|" + tool + "|cwd=" + normalizeScopeToken(record.cwd, scope) + "|argv=" + argvSkeletonScoped(record.argv, scope) + } + return "generic|" + tool + "|cwd=" + normalizeScopeToken(record.cwd, scope) + "|argv=" + argvFullScoped(record.argv, scope) +} + +func scopedFingerprint(record normalizedRecord, scope trace.Scope, inputDigests map[string]string) string { + argv := make([]string, 0, len(record.argv)) + for _, arg := range record.argv { + argv = append(argv, normalizeScopeToken(arg, scope)) + } + env := normalizeEnvEntries(record.env, scope) + inputs := make([]string, 0, len(record.inputs)) + for _, path := range record.inputs { + inputs = append(inputs, fingerprintInputToken(path, record.inputOrigin[path], scope, inputDigests)) + } + changes := make([]string, 0, len(record.changes)) + for _, path := range fingerprintChanges(record) { + changes = append(changes, normalizeScopeToken(path, scope)) + } + parts := append([]string{}, argv...) + parts = append(parts, "@", normalizeScopeToken(record.cwd, scope), "@") + parts = append(parts, env...) + parts = append(parts, "@") + parts = append(parts, inputs...) + parts = append(parts, "@") + parts = append(parts, changes...) + parts = append(parts, "@") + for _, path := range record.deletions { + parts = append(parts, "!"+normalizeScopeToken(path, scope)) + } + return strings.Join(parts, "\x1f") +} + +func scopedStructureKey(record normalizedRecord, scope trace.Scope) string { + argv := make([]string, 0, len(record.argv)) + for _, arg := range record.argv { + argv = append(argv, normalizeScopeToken(arg, scope)) + } + env := normalizeEnvEntries(record.env, scope) + inputs := make([]string, 0, len(record.inputs)) + for _, path := range record.inputs { + inputs = append(inputs, normalizeScopeToken(path, scope)) + } + changes := make([]string, 0, len(record.changes)) + for _, path := range fingerprintChanges(record) { + changes = append(changes, normalizeScopeToken(path, scope)) + } + parts := append([]string{}, argv...) + parts = append(parts, "@", normalizeScopeToken(record.cwd, scope), "@") + parts = append(parts, env...) + parts = append(parts, "@") + parts = append(parts, inputs...) + parts = append(parts, "@") + parts = append(parts, changes...) + parts = append(parts, "@") + for _, path := range record.deletions { + parts = append(parts, "!"+normalizeScopeToken(path, scope)) + } + return strings.Join(parts, "\x1f") +} + +func fingerprintInputToken(path, origin string, scope trace.Scope, inputDigests map[string]string) string { + token := normalizeScopeToken(path, scope) + if !shouldHashFingerprintInput(path, origin, scope, inputDigests) { + return token + } + if sum, ok := inputDigests[origin]; ok && sum != "" { + return token + "#" + sum + } + if origin == "" { + return token + "#missing-origin" + } + return token + "#missing-digest" +} + +func shouldHashFingerprintInput(path, origin string, scope trace.Scope, inputDigests map[string]string) bool { + if len(inputDigests) == 0 || scope.BuildRoot == "" { + return false + } + if origin == "" { + origin = path + } + origin = normalizePath(origin) + buildRoot := strings.TrimSuffix(normalizePath(scope.BuildRoot), "/") + if buildRoot == "" { + return false + } + return origin == buildRoot || strings.HasPrefix(origin, buildRoot+"/") +} + +func fingerprintChanges(record normalizedRecord) []string { + if len(record.argv) != 0 { + switch filepath.Base(record.argv[0]) { + case "ar", "ranlib": + filtered := make([]string, 0, len(record.changes)) + for _, path := range record.changes { + if isArchivePath(path) { + filtered = append(filtered, path) + } + } + if len(filtered) != 0 { + return filtered + } + } + } + return record.changes +} + +func normalizeScope(scope trace.Scope) trace.Scope { + scope.SourceRoot = normalizeRootPath(scope.SourceRoot) + scope.BuildRoot = normalizeRootPath(scope.BuildRoot) + scope.InstallRoot = normalizeRootPath(scope.InstallRoot) + scope.KeepRoots = slices.Clone(scope.KeepRoots) + for i := range scope.KeepRoots { + scope.KeepRoots[i] = normalizeRootPath(scope.KeepRoots[i]) + } + return scope +} + +func normalizeRootPath(path string) string { + path = normalizePath(path) + if path == "" { + return "" + } + return strings.TrimSuffix(path, "/") +} + +func resolveExecPath(cwd string, argv []string) string { + if len(argv) == 0 { + return "" + } + execPath := argv[0] + if !strings.Contains(execPath, "/") { + return "" + } + if !filepath.IsAbs(execPath) && cwd != "" { + execPath = filepath.Join(cwd, execPath) + } + return normalizePath(execPath) +} + +func argvSkeletonScoped(argv []string, scope trace.Scope) string { + if len(argv) == 0 { + return "" + } + limit := min(len(argv), 4) + out := make([]string, 0, limit) + for _, arg := range argv[:limit] { + out = append(out, normalizeScopeToken(arg, scope)) + } + return strings.Join(out, " ") +} + +func argvFullScoped(argv []string, scope trace.Scope) string { + if len(argv) == 0 { + return "" + } + out := make([]string, 0, len(argv)) + for _, arg := range argv { + out = append(out, normalizeScopeToken(arg, scope)) + } + return strings.Join(out, " ") +} + +func normalizeScopeToken(token string, scope trace.Scope) string { + if token == "" { + return "" + } + replacements := []struct { + root string + placeholder string + }{ + {scope.BuildRoot, "$BUILD"}, + {scope.InstallRoot, "$INSTALL"}, + {scope.SourceRoot, "$SRC"}, + } + slices.SortFunc(replacements, func(left, right struct { + root string + placeholder string + }) int { + if len(left.root) != len(right.root) { + return len(right.root) - len(left.root) + } + return strings.Compare(left.placeholder, right.placeholder) + }) + for _, item := range replacements { + if item.root == "" { + continue + } + token = replaceScopeRootToken(token, item.root, item.placeholder) + } + token = normalizePath(token) + for _, item := range replacements { + root := normalizePath(item.root) + if root == "" { + continue + } + token = replaceScopeRootToken(token, root, item.placeholder) + } + return normalizeScopedBuildNoise(token) +} + +func replaceScopeRootToken(token, root, placeholder string) string { + if !strings.Contains(root, "$$TMP") { + idx := strings.Index(token, root) + if !validScopedRootMatch(token, idx, len(root)) { + return token + } + return token[:idx] + placeholder + token[idx+len(root):] + } + pattern := regexp.QuoteMeta(root) + pattern = strings.ReplaceAll(pattern, `\$\$TMP`, `[^/]+`) + re := regexp.MustCompile(pattern) + loc := re.FindStringIndex(token) + if loc == nil || !validScopedRootMatch(token, loc[0], loc[1]-loc[0]) { + return token + } + return token[:loc[0]] + placeholder + token[loc[1]:] +} + +func normalizeScopedBuildNoise(token string) string { + if !strings.Contains(token, "$BUILD") { + return token + } + parts := strings.Split(token, "/") + transientDepth := -1 + for idx, part := range parts { + if part == "" || part == "$BUILD" { + continue + } + part = normalizeBuildTempPIDPart(part) + if looksTransientBuildDir(part) { + parts[idx] = buildTransientDirToken + transientDepth = 0 + continue + } + if transientDepth >= 0 { + parts[idx] = normalizeTransientBuildPart(part, transientDepth == 0) + transientDepth++ + continue + } + parts[idx] = part + } + return strings.Join(parts, "/") +} + +func validScopedRootMatch(token string, start, length int) bool { + if start < 0 { + return false + } + if start != 0 { + firstSlash := strings.IndexByte(token, '/') + if firstSlash != start { + return false + } + } + end := start + length + return end == len(token) || token[end] == '/' +} + +func normalizeBuildTempPIDPart(part string) string { + if !reBuildTmpPIDNoise.MatchString(part) { + return part + } + loc := strings.LastIndex(part, ".tmp.") + if loc < 0 { + return part + } + return part[:loc] + ".tmp." + buildGeneratedIDToken +} + +func looksTransientBuildDir(part string) bool { + if part == "" || strings.Contains(part, ".") { + return false + } + part = strings.ToLower(part) + switch { + case part == "tmp", part == "temp": + return true + case strings.Contains(part, "scratch"): + return true + case strings.HasSuffix(part, "tmp"), strings.HasSuffix(part, "temp"): + return true + default: + return false + } +} + +func normalizeTransientBuildPart(part string, firstChild bool) string { + if part == "" || strings.HasPrefix(part, "$") { + return part + } + base := part + ext := "" + if suffix := filepath.Ext(part); suffix == ".dir" { + base = strings.TrimSuffix(part, suffix) + ext = suffix + } + prefix, sep, suffix, ok := splitGeneratedSuffix(base) + if ok && (firstChild || looksGeneratedBuildID(suffix)) { + return prefix + sep + buildGeneratedIDToken + ext + } + if !firstChild && looksGeneratedBuildID(base) { + return buildGeneratedIDToken + ext + } + return part +} + +func splitGeneratedSuffix(part string) (prefix, sep, suffix string, ok bool) { + idx := strings.LastIndexAny(part, "-_") + if idx <= 0 || idx >= len(part)-1 { + return "", "", "", false + } + return part[:idx], part[idx : idx+1], part[idx+1:], true +} + +func looksGeneratedBuildID(part string) bool { + if len(part) < 6 { + return false + } + hasLetter := false + hasDigit := false + hexOnly := true + for _, r := range part { + switch { + case r >= '0' && r <= '9': + hasDigit = true + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z': + hasLetter = true + if !(r >= 'a' && r <= 'f' || r >= 'A' && r <= 'F') { + hexOnly = false + } + default: + return false + } + } + return (hasDigit && hasLetter) || (hexOnly && len(part) >= 8) +} + +const envNamespacePrefix = "$ENV/" + +func envStatePath(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + return envNamespacePrefix + name +} + +func envStatePathFromEntry(entry string) string { + name, _, ok := strings.Cut(entry, "=") + if !ok { + return "" + } + return envStatePath(name) +} + +func normalizeEnvEntries(env []string, scope trace.Scope) []string { + if len(env) == 0 { + return nil + } + normalized := make([]string, 0, len(env)) + for _, entry := range env { + key, value, ok := strings.Cut(entry, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + if key == "" || ignoredExecEnvKey(key) { + continue + } + normalized = append(normalized, key+"="+normalizeScopeToken(value, scope)) + } + return uniqueSorted(normalized) +} + +func ignoredExecEnvKey(key string) bool { + switch key { + case "_", "OLDPWD", "PWD", "SHLVL", "TERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION", "COLORTERM": + return true + default: + return false + } +} + +func isExplicitDeliveryPath(path string, scope trace.Scope) bool { + root := strings.TrimSuffix(normalizePath(scope.InstallRoot), "/") + if root == "" { + return false + } + path = normalizePath(path) + return path == root || strings.HasPrefix(path, root+"/") +} + +func pathLooksLikeCompilationInput(path string) bool { + switch strings.ToLower(filepath.Ext(path)) { + case ".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx", ".inc", ".inl", ".s", ".S", ".asm": + return true + default: + return false + } +} + +func isArtifactPath(path string) bool { + switch strings.ToLower(filepath.Ext(path)) { + case ".o", ".obj", ".a", ".so", ".dylib", ".dll", ".exe": + return true + default: + return false + } +} + +func isArchivePath(path string) bool { + return strings.EqualFold(filepath.Ext(path), ".a") +} diff --git a/internal/trace/ssa/env_test.go b/internal/trace/ssa/env_test.go new file mode 100644 index 0000000..cda6f8a --- /dev/null +++ b/internal/trace/ssa/env_test.go @@ -0,0 +1,88 @@ +package ssa + +import ( + "slices" + "testing" + + "github.com/goplus/llar/internal/trace" +) + +func TestBuildGraphExpandsEnvIntoInitialReadStates(t *testing.T) { + graph := BuildGraph(BuildInput{ + Records: []trace.Record{{ + Argv: []string{"cc", "-c", "core.c", "-o", "build/core.o"}, + Env: []string{"PWD=/tmp/work", "CFLAGS=-O2", "TMPDIR=/tmp/work/.tmp"}, + Cwd: "/tmp/work", + Inputs: []string{"/tmp/work/core.c"}, + Changes: []string{"/tmp/work/build/core.o"}, + }}, + Scope: trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/build", + }, + }) + + if len(graph.Actions) != 1 { + t.Fatalf("len(graph.Actions) = %d, want 1", len(graph.Actions)) + } + if got := graph.Actions[0].Env; !slices.Equal(got, []string{"CFLAGS=-O2", "TMPDIR=$SRC/.tmp"}) { + t.Fatalf("graph.Actions[0].Env = %v, want [CFLAGS=-O2 TMPDIR=$SRC/.tmp]", got) + } + + envPath := envStatePath("CFLAGS") + facts, ok := graph.Paths[envPath] + if !ok { + t.Fatalf("graph.Paths missing %q", envPath) + } + if len(facts.Readers) != 1 || facts.Readers[0] != 0 { + t.Fatalf("graph.Paths[%q].Readers = %v, want [0]", envPath, facts.Readers) + } + + found := false + for _, read := range graph.ActionReads[0] { + if read.Path != envPath { + continue + } + found = true + if len(read.Defs) != 1 { + t.Fatalf("env read defs = %v, want one initial def", read.Defs) + } + if read.Defs[0].Writer != -1 || read.Defs[0].Path != envPath || read.Defs[0].Missing || read.Defs[0].Tombstone { + t.Fatalf("env read def = %+v, want initial non-missing def", read.Defs[0]) + } + } + if !found { + t.Fatalf("graph.ActionReads[0] missing env binding for %q: %v", envPath, graph.ActionReads[0]) + } +} + +func TestAnalyzeTreatsEnvFootprintAsWavefrontInput(t *testing.T) { + base := []trace.Record{{ + Argv: []string{"cc", "-c", "core.c", "-o", "build/core.o"}, + Env: []string{"CFLAGS=-O0"}, + Cwd: "/tmp/work", + Inputs: []string{"/tmp/work/core.c"}, + Changes: []string{"/tmp/work/build/core.o"}, + }} + probe := []trace.Record{{ + Argv: []string{"cc", "-c", "core.c", "-o", "build/core.o"}, + Env: []string{"CFLAGS=-O3"}, + Cwd: "/tmp/work", + Inputs: []string{"/tmp/work/core.c"}, + Changes: []string{"/tmp/work/build/core.o"}, + }} + + result := AnalyzeWithEvidence(AnalysisInput{ + Base: AnalysisSideInput{Records: base}, + Probe: AnalysisSideInput{Records: probe}, + }, &ImpactEvidence{ + Changed: map[string]bool{normalizePath("/tmp/work/build/core.o"): false}, + }) + + if len(result.Debug.Wavefront.ProbeClass) != 1 { + t.Fatalf("len(result.Debug.Wavefront.ProbeClass) = %d, want 1", len(result.Debug.Wavefront.ProbeClass)) + } + if result.Debug.Wavefront.ProbeClass[0] != WavefrontProbeMutationRoot { + t.Fatalf("result.Debug.Wavefront.ProbeClass[0] = %v, want %v", result.Debug.Wavefront.ProbeClass[0], WavefrontProbeMutationRoot) + } +} diff --git a/internal/trace/ssa/graph.go b/internal/trace/ssa/graph.go new file mode 100644 index 0000000..4508a33 --- /dev/null +++ b/internal/trace/ssa/graph.go @@ -0,0 +1,147 @@ +package ssa + +import "github.com/goplus/llar/internal/trace" + +type Source uint8 + +const ( + SourceRecords Source = iota + SourceEvents +) + +func (source Source) String() string { + switch source { + case SourceEvents: + return "events" + default: + return "records" + } +} + +type ActionKind uint8 + +const ( + KindGeneric ActionKind = iota + KindCopy + KindInstall + KindConfigure +) + +type PathRole uint8 + +const ( + RoleUnknown PathRole = iota + RoleTooling + RolePropagating + RoleDelivery +) + +func (kind ActionKind) String() string { + switch kind { + case KindCopy: + return "copy" + case KindInstall: + return "install" + case KindConfigure: + return "configure" + default: + return "generic" + } +} + +func (role PathRole) String() string { + switch role { + case RoleTooling: + return "tooling" + case RolePropagating: + return "propagating" + case RoleDelivery: + return "delivery" + default: + return "propagating" + } +} + +type ExecEdge struct { + From int + To int + Path string +} + +type ExecNode struct { + PID int64 + ParentPID int64 + Argv []string + Cwd string + Env []string + Reads []string + ReadMisses []string + Writes []string + Deletes []string + ExecPath string + Tool string + Kind ActionKind + ActionKey string + // StructureKey and Fingerprint remain on the graph so passes can compare + // intrinsic structure without reconstructing normalization context. + StructureKey string + Fingerprint string +} + +type PathInfo struct { + Path string + Writers []int + Readers []int + Role PathRole +} + +type PathState struct { + Writer int + Path string + Version int + Tombstone bool + Missing bool +} + +type Read struct { + Path string + Defs []PathState +} + +type observation struct { + Nodes []ExecNode + Parent []int + Paths map[string]PathInfo + Deps [][]ExecEdge +} + +type Graph struct { + Source Source + Records int + Events int + Scope trace.Scope + InputDigests map[string]string + RawRecords []trace.Record + RawEvents []trace.Event + + Actions []ExecNode + ParentAction []int + Out [][]ExecEdge + In [][]ExecEdge + Indeg []int + Outdeg []int + Tooling []bool + Probe []bool + Mainline []bool + RawPaths map[string]PathInfo + + Nodes []ExecNode + Parent []int + Paths map[string]PathInfo + Deps [][]ExecEdge + ActionReads [][]Read + ActionWrites [][]PathState + ReadersByDef map[PathState][]int + InitialDefs map[string]PathState + DefsByPath map[string][]PathState +} diff --git a/internal/trace/ssa/graph_test.go b/internal/trace/ssa/graph_test.go new file mode 100644 index 0000000..2599996 --- /dev/null +++ b/internal/trace/ssa/graph_test.go @@ -0,0 +1,1147 @@ +package ssa + +import ( + "testing" + + "github.com/goplus/llar/internal/trace" +) + +func record(argv []string, cwd string, inputs, changes []string) trace.Record { + return trace.Record{ + Argv: argv, + Cwd: cwd, + Inputs: inputs, + Changes: changes, + } +} + +func recordWithProc(pid, parentPID int64, argv []string, cwd string, inputs, changes []string) trace.Record { + rec := record(argv, cwd, inputs, changes) + rec.PID = pid + rec.ParentPID = parentPID + return rec +} + +func traceoptionsEventTrace(apiOn bool) []trace.Event { + return traceoptionsMatrixEventTrace(apiOn, false, false) +} + +func traceoptionsTryCompileProbeEventTrace(apiOn bool, trySuffix, cmTC string) []trace.Event { + configureArgv := []string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"} + if apiOn { + configureArgv = append(configureArgv, "-DTRACE_FEATURE_API=ON") + } + compileArgv := []string{ + "/usr/bin/cc", + "-I/tmp/work", + "-I/tmp/work/_build", + "-o", "/tmp/work/_build/CMakeFiles/tracecore.dir/core.c.o", + "-c", "/tmp/work/core.c", + } + if apiOn { + compileArgv = []string{ + "/usr/bin/cc", + "-DTRACE_FEATURE_API", + "-I/tmp/work", + "-I/tmp/work/_build", + "-o", "/tmp/work/_build/CMakeFiles/tracecore.dir/core.c.o", + "-c", "/tmp/work/core.c", + } + } + tryDir := "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-" + trySuffix + objectPath := tryDir + "/CMakeFiles/" + cmTC + ".dir/CheckIncludeFile.c.o" + execPath := tryDir + "/" + cmTC + replyPath := tryDir + "/.cmake/api/v1/reply" + + events := make([]trace.Event, 0, 32) + seq := int64(1) + addExec := func(pid, parent int64, cwd string, argv ...string) { + events = append(events, trace.Event{Seq: seq, PID: pid, ParentPID: parent, Cwd: cwd, Kind: trace.EventExec, Argv: argv}) + seq++ + } + addRead := func(pid int64, cwd, path string) { + events = append(events, trace.Event{Seq: seq, PID: pid, Cwd: cwd, Kind: trace.EventRead, Path: path}) + seq++ + } + addWrite := func(pid int64, cwd, path string) { + events = append(events, trace.Event{Seq: seq, PID: pid, Cwd: cwd, Kind: trace.EventWrite, Path: path}) + seq++ + } + + addExec(100, 1, "/tmp/work", configureArgv...) + addRead(100, "/tmp/work", "/tmp/work/CMakeLists.txt") + addWrite(100, "/tmp/work", tryDir+"/CheckIncludeFile.c") + + addExec(110, 100, tryDir, "/usr/bin/cc", "-o", "CMakeFiles/"+cmTC+".dir/CheckIncludeFile.c.o", "-c", tryDir+"/CheckIncludeFile.c") + addRead(110, tryDir, tryDir+"/CheckIncludeFile.c") + addWrite(110, tryDir, objectPath) + + addExec(111, 110, tryDir, "/usr/bin/ld", "-o", cmTC, "CMakeFiles/"+cmTC+".dir/CheckIncludeFile.c.o") + addRead(111, tryDir, objectPath) + addWrite(111, tryDir, execPath) + + addExec(112, 110, tryDir, "/usr/bin/cmake", "-E", "echo", "reply") + addRead(112, tryDir, execPath) + addWrite(112, tryDir, replyPath) + + addRead(100, "/tmp/work", execPath) + addRead(100, "/tmp/work", replyPath) + addRead(100, "/tmp/work", "/tmp/work/trace_options.h.in") + addWrite(100, "/tmp/work", "/tmp/work/_build/trace_options.h") + addWrite(100, "/tmp/work", "/tmp/work/_build/cmake_install.cmake") + + addExec(200, 1, "/tmp/work", "cmake", "--build", "/tmp/work/_build", "--config", "Release") + addExec(201, 200, "/tmp/work/_build", compileArgv...) + addRead(201, "/tmp/work/_build", "/tmp/work/core.c") + addRead(201, "/tmp/work/_build", "/tmp/work/trace.h") + addRead(201, "/tmp/work/_build", "/tmp/work/_build/trace_options.h") + addWrite(201, "/tmp/work/_build", "/tmp/work/_build/CMakeFiles/tracecore.dir/core.c.o") + + addExec(202, 200, "/tmp/work/_build", "/usr/bin/ar", "qc", "/tmp/work/_build/libtracecore.a", "/tmp/work/_build/CMakeFiles/tracecore.dir/core.c.o") + addRead(202, "/tmp/work/_build", "/tmp/work/_build/CMakeFiles/tracecore.dir/core.c.o") + addWrite(202, "/tmp/work/_build", "/tmp/work/_build/libtracecore.a") + + addExec(300, 1, "/tmp/work", "cmake", "--install", "/tmp/work/_build") + addRead(300, "/tmp/work", "/tmp/work/_build/cmake_install.cmake") + addRead(300, "/tmp/work", "/tmp/work/_build/libtracecore.a") + addRead(300, "/tmp/work", "/tmp/work/_build/trace_options.h") + addWrite(300, "/tmp/work", "/tmp/work/install/lib/libtracecore.a") + addWrite(300, "/tmp/work", "/tmp/work/install/include/trace_options.h") + + return events +} + +func traceoptionsMatrixEventTrace(apiOn, cliOn, shipOn bool) []trace.Event { + configureArgv := []string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"} + if apiOn { + configureArgv = append(configureArgv, "-DTRACE_FEATURE_API=ON") + } + if cliOn { + configureArgv = append(configureArgv, "-DTRACE_BUILD_CLI=ON") + } + if shipOn { + configureArgv = append(configureArgv, "-DTRACE_INSTALL_ALIAS=ON") + } + compileArgv := []string{ + "/usr/bin/cc", + "-I/tmp/work", + "-I/tmp/work/_build", + "-o", "/tmp/work/_build/CMakeFiles/tracecore.dir/core.c.o", + "-c", "/tmp/work/core.c", + } + if apiOn { + compileArgv = []string{ + "/usr/bin/cc", + "-DTRACE_FEATURE_API", + "-I/tmp/work", + "-I/tmp/work/_build", + "-o", "/tmp/work/_build/CMakeFiles/tracecore.dir/core.c.o", + "-c", "/tmp/work/core.c", + } + } + arTemp, ranlibTemp := traceoptionsArchiveTemps(apiOn, cliOn, shipOn) + events := make([]trace.Event, 0, 32) + seq := int64(1) + addExec := func(pid, parent int64, cwd string, argv ...string) { + events = append(events, trace.Event{Seq: seq, PID: pid, ParentPID: parent, Cwd: cwd, Kind: trace.EventExec, Argv: argv}) + seq++ + } + addRead := func(pid int64, cwd, path string) { + events = append(events, trace.Event{Seq: seq, PID: pid, Cwd: cwd, Kind: trace.EventRead, Path: path}) + seq++ + } + addWrite := func(pid int64, cwd, path string) { + events = append(events, trace.Event{Seq: seq, PID: pid, Cwd: cwd, Kind: trace.EventWrite, Path: path}) + seq++ + } + + addExec(100, 1, "/tmp/work", configureArgv...) + addRead(100, "/tmp/work", "/tmp/work/CMakeLists.txt") + addRead(100, "/tmp/work", "/tmp/work/trace_options.h.in") + addRead(100, "/tmp/work", "/tmp/work/_build/CMakeFiles/pkgRedirects") + addRead(100, "/tmp/work", "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CMakeFiles/pkgRedirects") + addWrite(100, "/tmp/work", "/tmp/work/_build/trace_options.h") + addWrite(100, "/tmp/work", "/tmp/work/_build/CMakeFiles/pkgRedirects") + addWrite(100, "/tmp/work", "/tmp/work/_build/cmake_install.cmake") + + addExec(110, 100, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", "/usr/bin/gmake", "-f", "Makefile", "cmTC_deadbeef/fast") + addRead(110, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", "/tmp/work/_build/CMakeFiles/pkgRedirects") + addRead(110, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/Makefile") + addExec(111, 110, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", "/usr/bin/cc", "-o", "CMakeFiles/cmTC_deadbeef.dir/CheckIncludeFile.c.o", "-c", "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CheckIncludeFile.c") + addRead(111, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CheckIncludeFile.c") + addWrite(111, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CMakeFiles/pkgRedirects") + addWrite(111, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/cmTC_deadbeef") + + addExec(200, 1, "/tmp/work", "cmake", "--build", "/tmp/work/_build", "--config", "Release") + addRead(200, "/tmp/work", "/tmp/work/_build/CMakeCache.txt") + addExec(201, 200, "/tmp/work/_build", "/usr/bin/gmake", "-f", "Makefile") + addRead(201, "/tmp/work/_build", "/tmp/work/_build/Makefile") + addExec(202, 201, "/tmp/work/_build", "/usr/bin/gmake", "-s", "-f", "CMakeFiles/tracecore.dir/build.make", "CMakeFiles/tracecore.dir/build") + addRead(202, "/tmp/work/_build", "/tmp/work/_build/CMakeFiles/tracecore.dir/build.make") + addExec(203, 202, "/tmp/work/_build", compileArgv...) + addRead(203, "/tmp/work/_build", "/tmp/work/core.c") + addRead(203, "/tmp/work/_build", "/tmp/work/trace.h") + addRead(203, "/tmp/work/_build", "/tmp/work/_build/trace_options.h") + addWrite(203, "/tmp/work/_build", "/tmp/work/_build/CMakeFiles/tracecore.dir/core.c.o") + addExec(204, 202, "/tmp/work/_build", "/usr/bin/ar", "qc", "/tmp/work/_build/libtracecore.a", "/tmp/work/_build/CMakeFiles/tracecore.dir/core.c.o") + addRead(204, "/tmp/work/_build", "/tmp/work/_build/CMakeFiles/tracecore.dir/core.c.o") + addWrite(204, "/tmp/work/_build", "/tmp/work/_build/libtracecore.a") + addWrite(204, "/tmp/work/_build", "/tmp/work/_build/"+arTemp) + addExec(205, 202, "/tmp/work/_build", "/usr/bin/ranlib", "/tmp/work/_build/libtracecore.a") + addRead(205, "/tmp/work/_build", "/tmp/work/_build/libtracecore.a") + addWrite(205, "/tmp/work/_build", "/tmp/work/_build/"+ranlibTemp) + addWrite(205, "/tmp/work/_build", "/tmp/work/_build/libtracecore.a") + + if cliOn { + addExec(206, 201, "/tmp/work/_build", "/usr/bin/gmake", "-s", "-f", "CMakeFiles/tracecli.dir/build.make", "CMakeFiles/tracecli.dir/build") + addRead(206, "/tmp/work/_build", "/tmp/work/_build/CMakeFiles/tracecli.dir/build.make") + addExec(207, 206, "/tmp/work/_build", "/usr/bin/cc", "/tmp/work/cli.c", "/tmp/work/_build/libtracecore.a", "-o", "/tmp/work/_build/tracecli") + addRead(207, "/tmp/work/_build", "/tmp/work/cli.c") + addRead(207, "/tmp/work/_build", "/tmp/work/_build/libtracecore.a") + addWrite(207, "/tmp/work/_build", "/tmp/work/_build/tracecli") + } + + addExec(300, 1, "/tmp/work", "cmake", "--install", "/tmp/work/_build", "--prefix", "/tmp/work/install") + addRead(300, "/tmp/work", "/tmp/work/_build/cmake_install.cmake") + addRead(300, "/tmp/work", "/tmp/work/_build/libtracecore.a") + addRead(300, "/tmp/work", "/tmp/work/trace.h") + addRead(300, "/tmp/work", "/tmp/work/_build/trace_options.h") + if cliOn { + addRead(300, "/tmp/work", "/tmp/work/_build/tracecli") + } + addWrite(300, "/tmp/work", "/tmp/work/install/lib/libtracecore.a") + addWrite(300, "/tmp/work", "/tmp/work/install/include/trace.h") + addWrite(300, "/tmp/work", "/tmp/work/install/include/trace_options.h") + addWrite(300, "/tmp/work", "/tmp/work/_build/install_manifest.txt") + if cliOn { + addWrite(300, "/tmp/work", "/tmp/work/install/bin/tracecli") + } + if shipOn { + addWrite(300, "/tmp/work", "/tmp/work/install/include/trace_alias.h") + } + return events +} + +func traceoptionsArchiveTemps(apiOn, cliOn, shipOn bool) (string, string) { + switch { + case apiOn: + return "stCXhzz0", "stQugPSt" + case cliOn: + return "stizVeGp", "stiZkO0K" + case shipOn: + return "stNMeD5X", "stdwbVyX" + default: + return "stNjnHgT", "stvgaB7q" + } +} + +func projectedPathRole(graph Graph, roles RoleProjection, path string) PathRole { + path = normalizePath(path) + if PathLooksDelivery(graph, path) { + return RoleDelivery + } + if !ImpactPathAllowed(graph, roles, path) { + return RoleTooling + } + return RolePropagating +} + +func TestBuildGraphTracksWriterReaderEdges(t *testing.T) { + records := []trace.Record{ + record([]string{"cc", "-c", "core.c", "-o", "build/core.o"}, "/tmp/work", []string{"/tmp/work/core.c"}, []string{"/tmp/work/build/core.o"}), + record([]string{"ar", "rcs", "out/lib/libfoo.a", "build/core.o"}, "/tmp/work", []string{"/tmp/work/build/core.o"}, []string{"/tmp/work/out/lib/libfoo.a"}), + record([]string{"cc", "-c", "cli.c", "-o", "build/cli.o"}, "/tmp/work", []string{"/tmp/work/cli.c"}, []string{"/tmp/work/build/cli.o"}), + record([]string{"cc", "build/cli.o", "out/lib/libfoo.a", "-o", "out/bin/foo"}, "/tmp/work", []string{"/tmp/work/build/cli.o", "/tmp/work/out/lib/libfoo.a"}, []string{"/tmp/work/out/bin/foo"}), + } + + graph := BuildGraph(BuildInput{Records: records}) + + assertEdge := func(from, to int, path string) { + t.Helper() + for _, edge := range graph.Out[from] { + if edge.To == to && edge.Path == normalizePath(path) { + return + } + } + t.Fatalf("missing edge %d -> %d via %s", from, to, normalizePath(path)) + } + + assertEdge(0, 1, "/tmp/work/build/core.o") + assertEdge(1, 3, "/tmp/work/out/lib/libfoo.a") + assertEdge(2, 3, "/tmp/work/build/cli.o") +} + +func TestBuildGraphWithEventsTracksWriterReaderEdges(t *testing.T) { + events := []trace.Event{ + {Seq: 1, PID: 100, Cwd: "/tmp/work", Kind: trace.EventExec, Argv: []string{"cc", "-c", "core.c", "-o", "build/core.o"}}, + {Seq: 2, PID: 100, Cwd: "/tmp/work", Kind: trace.EventRead, Path: "/tmp/work/core.c"}, + {Seq: 3, PID: 100, Cwd: "/tmp/work", Kind: trace.EventWrite, Path: "/tmp/work/build/core.o"}, + {Seq: 4, PID: 101, Cwd: "/tmp/work", Kind: trace.EventExec, Argv: []string{"ar", "rcs", "out/lib/libfoo.a", "build/core.o"}}, + {Seq: 5, PID: 101, Cwd: "/tmp/work", Kind: trace.EventRead, Path: "/tmp/work/build/core.o"}, + {Seq: 6, PID: 101, Cwd: "/tmp/work", Kind: trace.EventWrite, Path: "/tmp/work/out/lib/libfoo.a"}, + } + + graph := BuildGraph(BuildInput{Events: events}) + + if graph.Source != SourceEvents { + t.Fatalf("graph.Source = %v, want %v", graph.Source, SourceEvents) + } + if graph.Events != len(events) { + t.Fatalf("graph.Events = %d, want %d", graph.Events, len(events)) + } + if len(graph.Actions) != 2 { + t.Fatalf("len(graph.Actions) = %d, want 2", len(graph.Actions)) + } + found := false + for _, edge := range graph.Out[0] { + if edge.To == 1 && edge.Path == normalizePath("/tmp/work/build/core.o") { + found = true + break + } + } + if !found { + t.Fatalf("missing event-derived edge 0 -> 1 via %s", normalizePath("/tmp/work/build/core.o")) + } +} + +func TestBuildGraphTreatsEventUnlinkAsTombstoneDef(t *testing.T) { + events := []trace.Event{ + {Seq: 1, PID: 100, Cwd: "/tmp/work", Kind: trace.EventExec, Argv: []string{"rm", "-f", "build/api.h"}}, + {Seq: 2, PID: 100, Cwd: "/tmp/work", Kind: trace.EventUnlink, Path: "/tmp/work/build/api.h"}, + {Seq: 3, PID: 101, Cwd: "/tmp/work", Kind: trace.EventExec, Argv: []string{"cc", "-c", "server.c", "-o", "build/server.o"}}, + {Seq: 4, PID: 101, Cwd: "/tmp/work", Kind: trace.EventRead, Path: "/tmp/work/build/api.h"}, + {Seq: 5, PID: 101, Cwd: "/tmp/work", Kind: trace.EventWrite, Path: "/tmp/work/build/server.o"}, + } + + graph := BuildGraph(BuildInput{Events: events}) + if len(graph.ActionWrites) < 2 { + t.Fatalf("len(graph.ActionWrites) = %d, want >= 2", len(graph.ActionWrites)) + } + if len(graph.ActionWrites[0]) != 1 { + t.Fatalf("graph.ActionWrites[0] = %v, want single tombstone write", graph.ActionWrites[0]) + } + tombstone := graph.ActionWrites[0][0] + if !tombstone.Tombstone { + t.Fatalf("graph.ActionWrites[0][0].Tombstone = false, want true") + } + if tombstone.Path != normalizePath("/tmp/work/build/api.h") { + t.Fatalf("graph.ActionWrites[0][0].Path = %q, want %q", tombstone.Path, normalizePath("/tmp/work/build/api.h")) + } + if len(graph.ActionReads[1]) != 1 { + t.Fatalf("graph.ActionReads[1] = %v, want single tombstone-backed read", graph.ActionReads[1]) + } + if len(graph.ActionReads[1][0].Defs) != 1 || graph.ActionReads[1][0].Defs[0] != tombstone { + t.Fatalf("graph.ActionReads[1][0].Defs = %v, want %v", graph.ActionReads[1][0].Defs, tombstone) + } +} + +func TestBuildGraphTreatsRenameSourceAsTombstoneDef(t *testing.T) { + events := []trace.Event{ + {Seq: 1, PID: 100, Cwd: "/tmp/work", Kind: trace.EventExec, Argv: []string{"mv", "build/api.h", "build/api-renamed.h"}}, + {Seq: 2, PID: 100, Cwd: "/tmp/work", Kind: trace.EventRename, RelatedPath: "/tmp/work/build/api.h", Path: "/tmp/work/build/api-renamed.h"}, + {Seq: 3, PID: 101, Cwd: "/tmp/work", Kind: trace.EventExec, Argv: []string{"cc", "-c", "server.c", "-o", "build/server.o"}}, + {Seq: 4, PID: 101, Cwd: "/tmp/work", Kind: trace.EventRead, Path: "/tmp/work/build/api.h"}, + {Seq: 5, PID: 101, Cwd: "/tmp/work", Kind: trace.EventWrite, Path: "/tmp/work/build/server.o"}, + } + + graph := BuildGraph(BuildInput{Events: events}) + if len(graph.ActionWrites[0]) != 2 { + t.Fatalf("graph.ActionWrites[0] = %v, want rename source tombstone plus destination write", graph.ActionWrites[0]) + } + var sourceDef PathState + foundSource := false + foundDest := false + for _, def := range graph.ActionWrites[0] { + switch def.Path { + case normalizePath("/tmp/work/build/api.h"): + sourceDef = def + foundSource = true + if !def.Tombstone { + t.Fatalf("rename source Tombstone = false, want true") + } + case normalizePath("/tmp/work/build/api-renamed.h"): + foundDest = true + if def.Tombstone { + t.Fatalf("rename destination Tombstone = true, want false") + } + } + } + if !foundSource || !foundDest { + t.Fatalf("graph.ActionWrites[0] = %v, want both source and destination defs", graph.ActionWrites[0]) + } + if len(graph.ActionReads[1]) != 1 || len(graph.ActionReads[1][0].Defs) != 1 || graph.ActionReads[1][0].Defs[0] != sourceDef { + t.Fatalf("graph.ActionReads[1] = %v, want rename-source tombstone binding", graph.ActionReads[1]) + } +} + +func TestBuildGraphBindsReadMissToMissingState(t *testing.T) { + events := []trace.Event{ + {Seq: 1, PID: 100, Cwd: "/tmp/work", Kind: trace.EventExec, Argv: []string{"cc", "-c", "server.c", "-o", "build/server.o"}}, + {Seq: 2, PID: 100, Cwd: "/tmp/work", Kind: trace.EventReadMiss, Path: "/tmp/work/build/api.h"}, + {Seq: 3, PID: 100, Cwd: "/tmp/work", Kind: trace.EventRead, Path: "/tmp/work/server.c"}, + {Seq: 4, PID: 100, Cwd: "/tmp/work", Kind: trace.EventWrite, Path: "/tmp/work/build/server.o"}, + } + + graph := BuildGraph(BuildInput{Events: events}) + if len(graph.Actions) != 1 { + t.Fatalf("len(graph.Actions) = %d, want 1", len(graph.Actions)) + } + if got := graph.Actions[0].ReadMisses; len(got) != 1 || got[0] != normalizePath("/tmp/work/build/api.h") { + t.Fatalf("ReadMisses = %v, want [%q]", got, normalizePath("/tmp/work/build/api.h")) + } + if len(graph.ActionReads[0]) != 2 { + t.Fatalf("graph.ActionReads[0] = %v, want 2 bindings", graph.ActionReads[0]) + } + var missing Read + found := false + for _, binding := range graph.ActionReads[0] { + if binding.Path != normalizePath("/tmp/work/build/api.h") { + continue + } + missing = binding + found = true + } + if !found { + t.Fatalf("missing binding for read miss path: %v", graph.ActionReads[0]) + } + if len(missing.Defs) != 1 || !missing.Defs[0].Missing { + t.Fatalf("missing.Defs = %v, want single missing state", missing.Defs) + } +} + +func TestBuildGraphClassifiesNoiseActionsAndKeys(t *testing.T) { + records := []trace.Record{ + record([]string{"cc", "-c", "core.c", "-o", "build/core.o"}, "/tmp/work", []string{"/tmp/work/core.c"}, []string{"/tmp/work/build/core.o"}), + record([]string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"}, "/tmp/work", []string{"/tmp/work/CMakeLists.txt"}, []string{"/tmp/work/_build/CMakeCache.txt"}), + record([]string{"cmake", "--install", "/tmp/work/_build"}, "/tmp/work", []string{"/tmp/work/_build/libfoo.a"}, []string{"/tmp/work/out/lib/libfoo.a"}), + record([]string{"cp", "libfoo.a", "out/lib/libfoo.a"}, "/tmp/work", []string{"/tmp/work/libfoo.a"}, []string{"/tmp/work/out/lib/libfoo.a"}), + } + + graph := BuildGraph(BuildInput{Records: records}) + + if graph.Actions[0].Kind != KindGeneric { + t.Fatalf("generic action kind = %v, want %v", graph.Actions[0].Kind, KindGeneric) + } + wantGenericKey := "generic|cc|cwd=" + normalizePath("/tmp/work") + "|argv=cc -c core.c -o build/core.o" + if got := graph.Actions[0].ActionKey; got != wantGenericKey { + t.Fatalf("generic ActionKey = %q, want %q", got, wantGenericKey) + } + if graph.Actions[1].Kind != KindConfigure { + t.Fatalf("cmake configure kind = %v, want %v", graph.Actions[1].Kind, KindConfigure) + } + if graph.Actions[2].Kind != KindInstall { + t.Fatalf("cmake --install kind = %v, want %v", graph.Actions[2].Kind, KindInstall) + } + if graph.Actions[3].Kind != KindCopy { + t.Fatalf("cp kind = %v, want %v", graph.Actions[3].Kind, KindCopy) + } +} + +func TestBuildGraphClassifiesPerlConfigureAndShellInstall(t *testing.T) { + scope := trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/_build", + InstallRoot: "/tmp/work/out", + } + records := []trace.Record{ + record( + []string{"perl", "configdata.pm"}, + "/tmp/work", + []string{"/tmp/work/configdata.pm", "/tmp/work/Makefile.in"}, + []string{"/tmp/work/Makefile.new", "/tmp/work/Makefile"}, + ), + record( + []string{"sh", "-c", "cp apps/openssl /tmp/work/out/bin/openssl.new && mv -f /tmp/work/out/bin/openssl.new /tmp/work/out/bin/openssl"}, + "/tmp/work", + []string{"/tmp/work/apps/openssl"}, + []string{"/tmp/work/out/bin/openssl.new", "/tmp/work/out/bin/openssl"}, + ), + } + + graph := BuildGraph(BuildInput{Records: records, Scope: scope}) + + if graph.Actions[0].Kind != KindConfigure { + t.Fatalf("perl configure kind = %v, want %v", graph.Actions[0].Kind, KindConfigure) + } + if graph.Actions[1].Kind != KindInstall { + t.Fatalf("shell install kind = %v, want %v", graph.Actions[1].Kind, KindInstall) + } +} + +func TestBuildGraphKeepsArtifactWritingShellGeneric(t *testing.T) { + graph := BuildGraph(BuildInput{ + Records: []trace.Record{ + record( + []string{"sh", "-c", "cc -c foo.c -o build/foo.o"}, + "/tmp/work", + []string{"/tmp/work/foo.c"}, + []string{"/tmp/work/build/foo.o"}, + ), + }, + }) + + if graph.Actions[0].Kind != KindGeneric { + t.Fatalf("shell artifact writer kind = %v, want %v", graph.Actions[0].Kind, KindGeneric) + } +} + +func TestBuildGraphDropsDirectoryPaths(t *testing.T) { + records := []trace.Record{ + record([]string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work", "/tmp/work/CMakeLists.txt"}, + []string{"/tmp/work/_build", "/tmp/work/_build/CMakeCache.txt"}), + record([]string{"cc", "-I", "/tmp/work/include", "-c", "core.c", "-o", "build/core.o"}, "/tmp/work", + []string{"/tmp/work/include", "/tmp/work/include/foo.h", "/tmp/work/core.c"}, + []string{"/tmp/work/build", "/tmp/work/build/core.o"}), + } + + graph := BuildGraph(BuildInput{Records: records}) + + for _, dir := range []string{"/tmp/work/include", "/tmp/work/build", "/tmp/work/_build"} { + if _, ok := graph.Paths[normalizePath(dir)]; ok { + t.Fatalf("directory path %q unexpectedly retained", normalizePath(dir)) + } + } +} + +func TestProjectRolesClassifiesProducedExecutableChainAsTooling(t *testing.T) { + records := []trace.Record{ + record([]string{"sh", "bootstrap.sh"}, "/tmp/work", + []string{"/tmp/work/bootstrap.sh"}, + []string{"/tmp/work/tools/b2"}), + record([]string{"./tools/b2", "headers"}, "/tmp/work", + []string{"/tmp/work/project-config.jam"}, + []string{"/tmp/work/_build/meta/status.txt", "/tmp/work/_build/meta/cache.db"}), + } + + graph := BuildGraph(BuildInput{Records: records}) + roles := ProjectRoles(graph) + + for i := range graph.Actions { + if RoleActionClass(roles, i) != ActionRoleTooling { + t.Fatalf("RoleActionClass(%d) = %v, want %v", i, RoleActionClass(roles, i), ActionRoleTooling) + } + } + for _, path := range []string{"/tmp/work/tools/b2", "/tmp/work/_build/meta/status.txt", "/tmp/work/_build/meta/cache.db"} { + if got := projectedPathRole(graph, roles, path); got != RoleTooling { + t.Fatalf("role(%s) = %v, want %v", normalizePath(path), got, RoleTooling) + } + } +} + +func TestProjectRolesClassifiesCopiedProducedExecutableChainAsTooling(t *testing.T) { + records := []trace.Record{ + record([]string{"sh", "bootstrap.sh"}, "/tmp/work", + []string{"/tmp/work/bootstrap.sh"}, + []string{"/tmp/work/tools/build/src/engine/b2"}), + record([]string{"cp", "./tools/build/src/engine/b2", "./b2"}, "/tmp/work", + []string{"/tmp/work/tools/build/src/engine/b2"}, + []string{"/tmp/work/b2"}), + record([]string{"./b2", "headers"}, "/tmp/work", + []string{"/tmp/work/project-config.jam"}, + []string{"/tmp/work/_build/meta/status.txt", "/tmp/work/_build/meta/cache.db"}), + } + + graph := BuildGraph(BuildInput{Records: records}) + roles := ProjectRoles(graph) + + for i := range graph.Actions { + if RoleActionClass(roles, i) != ActionRoleTooling { + t.Fatalf("RoleActionClass(%d) = %v, want %v", i, RoleActionClass(roles, i), ActionRoleTooling) + } + } + for _, path := range []string{ + "/tmp/work/tools/build/src/engine/b2", + "/tmp/work/b2", + "/tmp/work/_build/meta/status.txt", + "/tmp/work/_build/meta/cache.db", + } { + if got := projectedPathRole(graph, roles, path); got != RoleTooling { + t.Fatalf("role(%s) = %v, want %v", normalizePath(path), got, RoleTooling) + } + } +} + +func TestProjectRolesClassifiesCopiedProducedExecutableLeafAsTooling(t *testing.T) { + records := []trace.Record{ + record([]string{"sh", "bootstrap.sh"}, "/tmp/work", + []string{"/tmp/work/bootstrap.sh"}, + []string{"/tmp/work/tools/build/src/engine/b2"}), + record([]string{"cp", "./tools/build/src/engine/b2", "./b2"}, "/tmp/work", + []string{"/tmp/work/tools/build/src/engine/b2"}, + []string{"/tmp/work/b2"}), + } + + graph := BuildGraph(BuildInput{Records: records}) + roles := ProjectRoles(graph) + + if RoleActionClass(roles, 0) != ActionRoleTooling { + t.Fatalf("RoleActionClass(0) = %v, want %v", RoleActionClass(roles, 0), ActionRoleTooling) + } + if got := projectedPathRole(graph, roles, "/tmp/work/tools/build/src/engine/b2"); got != RoleTooling { + t.Fatalf("role(%s) = %v, want %v", normalizePath("/tmp/work/tools/build/src/engine/b2"), got, RoleTooling) + } +} + +func TestProjectRolesKeepsConfigureControlPlanePathsAsTooling(t *testing.T) { + records := []trace.Record{ + recordWithProc(100, 1, []string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/CMakeLists.txt"}, + []string{"/tmp/work/_build/meta/config.state", "/tmp/work/_build/meta/progress.count"}), + recordWithProc(101, 100, []string{"cmake", "-E", "echo", "progress"}, "/tmp/work", + []string{"/tmp/work/_build/meta/progress.count"}, + []string{"/tmp/work/_build/meta/progress.1"}), + } + + graph := BuildGraph(BuildInput{Records: records}) + roles := ProjectRoles(graph) + + for _, path := range []string{"/tmp/work/_build/meta/config.state", "/tmp/work/_build/meta/progress.count", "/tmp/work/_build/meta/progress.1"} { + if got := projectedPathRole(graph, roles, path); got != RoleTooling { + t.Fatalf("role(%s) = %v, want %v", normalizePath(path), got, RoleTooling) + } + } +} + +func TestProjectRolesPromotesProbeIslandChildrenToTooling(t *testing.T) { + records := []trace.Record{ + recordWithProc(100, 1, []string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/CMakeLists.txt"}, + []string{"/tmp/work/_build/probe-checks/CheckFeature.c"}), + recordWithProc(101, 100, []string{"cc", "-c", "CheckFeature.c", "-o", "CheckFeature.c.o"}, "/tmp/work/_build/probe-checks", + []string{"/tmp/work/_build/probe-checks/CheckFeature.c", "/usr/include/stdio.h"}, + []string{"/tmp/work/_build/probe-checks/CheckFeature.c.o"}), + recordWithProc(102, 101, []string{"cc", "CheckFeature.c.o", "-o", "probe-check"}, "/tmp/work/_build/probe-checks", + []string{"/tmp/work/_build/probe-checks/CheckFeature.c.o"}, + []string{"/tmp/work/_build/probe-checks/probe-check"}), + recordWithProc(200, 2, []string{"cc", "-c", "/tmp/work/src/core.c", "-o", "/tmp/work/_build/core.o"}, "/tmp/work/_build", + []string{"/tmp/work/src/core.c", "/usr/include/stdio.h"}, + []string{"/tmp/work/_build/core.o"}), + } + + graph := BuildGraph(BuildInput{Records: records}) + roles := ProjectRoles(graph) + + for _, path := range []string{ + "/tmp/work/_build/probe-checks/CheckFeature.c", + "/tmp/work/_build/probe-checks/CheckFeature.c.o", + "/tmp/work/_build/probe-checks/probe-check", + } { + if got := projectedPathRole(graph, roles, path); got != RoleTooling { + t.Fatalf("role(%s) = %v, want %v", normalizePath(path), got, RoleTooling) + } + } + if got := projectedPathRole(graph, roles, "/tmp/work/_build/core.o"); got != RolePropagating { + t.Fatalf("role(core.o) = %v, want %v", got, RolePropagating) + } +} + +func TestProjectRolesPromotesProbeIslandChildrenWrappedByGenericMake(t *testing.T) { + records := []trace.Record{ + recordWithProc(100, 1, []string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/CMakeLists.txt"}, + []string{"/tmp/work/_build/probe-checks/CheckFeature.c", "/tmp/work/_build/probe-checks/Makefile"}), + recordWithProc(101, 100, []string{"gmake", "-f", "Makefile"}, "/tmp/work/_build/probe-checks", + []string{"/tmp/work/_build/probe-checks/Makefile"}, + nil), + recordWithProc(102, 101, []string{"cc", "-c", "CheckFeature.c", "-o", "CheckFeature.c.o"}, "/tmp/work/_build/probe-checks", + []string{"/tmp/work/_build/probe-checks/CheckFeature.c", "/usr/include/stdio.h"}, + []string{"/tmp/work/_build/probe-checks/CheckFeature.c.o"}), + recordWithProc(103, 101, []string{"cc", "CheckFeature.c.o", "-o", "probe-check"}, "/tmp/work/_build/probe-checks", + []string{"/tmp/work/_build/probe-checks/CheckFeature.c.o"}, + []string{"/tmp/work/_build/probe-checks/probe-check"}), + recordWithProc(200, 2, []string{"cc", "-c", "/tmp/work/src/core.c", "-o", "/tmp/work/_build/core.o"}, "/tmp/work/_build", + []string{"/tmp/work/src/core.c", "/usr/include/stdio.h"}, + []string{"/tmp/work/_build/core.o"}), + } + + graph := BuildGraph(BuildInput{Records: records}) + roles := ProjectRoles(graph) + + for _, path := range []string{ + "/tmp/work/_build/probe-checks/CheckFeature.c", + "/tmp/work/_build/probe-checks/Makefile", + "/tmp/work/_build/probe-checks/CheckFeature.c.o", + "/tmp/work/_build/probe-checks/probe-check", + } { + if got := projectedPathRole(graph, roles, path); got != RoleTooling { + t.Fatalf("role(%s) = %v, want %v", normalizePath(path), got, RoleTooling) + } + } + if got := projectedPathRole(graph, roles, "/tmp/work/_build/core.o"); got != RolePropagating { + t.Fatalf("role(core.o) = %v, want %v", got, RolePropagating) + } +} + +func TestProjectRolesClassifiesInstallLeafPathAsDelivery(t *testing.T) { + records := []trace.Record{ + record([]string{"ar", "rcs", "out/lib/libfoo.a", "build/core.o"}, "/tmp/work", + []string{"/tmp/work/build/core.o"}, + []string{"/tmp/work/out/lib/libfoo.a"}), + record([]string{"cmake", "--install", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/out/lib/libfoo.a"}, + []string{"/tmp/work/install/lib/libfoo.a"}), + } + + graph := BuildGraph(BuildInput{ + Records: records, + Scope: trace.Scope{InstallRoot: "/tmp/work/install"}, + }) + roles := ProjectRoles(graph) + if got := projectedPathRole(graph, roles, "/tmp/work/install/lib/libfoo.a"); got != RoleDelivery { + t.Fatalf("role(install libfoo.a) = %v, want %v", got, RoleDelivery) + } +} + +func TestProjectRolesClassifiesLeafCopyAsDelivery(t *testing.T) { + records := []trace.Record{ + record([]string{"ar", "rcs", "_build/libfoo.a", "_build/core.o"}, "/tmp/work", + []string{"/tmp/work/_build/core.o"}, + []string{"/tmp/work/_build/libfoo.a"}), + record([]string{"cp", "_build/libfoo.a", "stage/libfoo.a"}, "/tmp/work", + []string{"/tmp/work/_build/libfoo.a"}, + []string{"/tmp/work/stage/libfoo.a"}), + } + + graph := BuildGraph(BuildInput{Records: records}) + roles := ProjectRoles(graph) + if got := projectedPathRole(graph, roles, "/tmp/work/stage/libfoo.a"); got != RoleDelivery { + t.Fatalf("role(stage libfoo.a) = %v, want %v", got, RoleDelivery) + } +} + +func TestProjectRolesClassifiesLeafHeaderCopyAsDelivery(t *testing.T) { + records := []trace.Record{ + record([]string{"cp", "_build/generated_config.h", "stage/generated_config.h"}, "/tmp/work", + []string{"/tmp/work/_build/generated_config.h"}, + []string{"/tmp/work/stage/generated_config.h"}), + } + + graph := BuildGraph(BuildInput{Records: records}) + roles := ProjectRoles(graph) + if got := projectedPathRole(graph, roles, "/tmp/work/stage/generated_config.h"); got != RoleDelivery { + t.Fatalf("role(stage generated_config.h) = %v, want %v", got, RoleDelivery) + } +} + +func TestProjectRolesTreatsConfigureSidecarsAsTooling(t *testing.T) { + scope := trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/_build", + InstallRoot: "/tmp/work/install", + } + records := []trace.Record{ + recordWithProc(100, 1, []string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/CMakeLists.txt"}, + []string{ + "/tmp/work/_build/trace_options.h", + "/tmp/work/_build/CMakeFiles/pkgRedirects", + "/tmp/work/_build/cmake_install.cmake", + }), + recordWithProc(101, 100, []string{"cmake", "-E", "echo", "probe"}, "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc", + []string{"/tmp/work/_build/CMakeFiles/pkgRedirects"}, + []string{"/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CMakeFiles/pkgRedirects"}), + recordWithProc(200, 2, []string{"cc", "-c", "/tmp/work/src/core.c", "-o", "/tmp/work/_build/core.o"}, "/tmp/work/_build", + []string{"/tmp/work/src/core.c", "/tmp/work/_build/trace_options.h"}, + []string{"/tmp/work/_build/core.o"}), + recordWithProc(201, 200, []string{"ar", "rcs", "/tmp/work/_build/libtracecore.a", "/tmp/work/_build/core.o"}, "/tmp/work/_build", + []string{"/tmp/work/_build/core.o"}, + []string{"/tmp/work/_build/libtracecore.a"}), + recordWithProc(300, 3, []string{"cmake", "--install", "/tmp/work/_build"}, "/tmp/work", + []string{ + "/tmp/work/_build/cmake_install.cmake", + "/tmp/work/_build/libtracecore.a", + "/tmp/work/_build/trace_options.h", + }, + []string{ + "/tmp/work/install/lib/libtracecore.a", + "/tmp/work/install/include/trace_alias.h", + }), + } + + graph := BuildGraph(BuildInput{Records: records, Scope: scope}) + roles := ProjectRoles(graph) + + for _, path := range []string{ + "/tmp/work/_build/CMakeFiles/pkgRedirects", + "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CMakeFiles/pkgRedirects", + "/tmp/work/_build/cmake_install.cmake", + } { + if got := projectedPathRole(graph, roles, path); got != RoleTooling { + t.Fatalf("role(%s) = %v, want %v", normalizePath(path), got, RoleTooling) + } + } + if got := projectedPathRole(graph, roles, "/tmp/work/_build/trace_options.h"); got != RolePropagating { + t.Fatalf("role(trace_options.h) = %v, want %v", got, RolePropagating) + } + if got := projectedPathRole(graph, roles, "/tmp/work/install/lib/libtracecore.a"); got != RoleDelivery { + t.Fatalf("role(install/libtracecore.a) = %v, want %v", got, RoleDelivery) + } +} + +func TestProjectRolesTreatsEventConfigureSidecarsAsTooling(t *testing.T) { + scope := trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/_build", + InstallRoot: "/tmp/work/install", + } + graph := BuildGraph(BuildInput{Events: traceoptionsEventTrace(false), Scope: scope}) + roles := ProjectRoles(graph) + + for _, path := range []string{ + "/tmp/work/_build/CMakeFiles/pkgRedirects", + "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CMakeFiles/pkgRedirects", + "/tmp/work/_build/cmake_install.cmake", + } { + if got := projectedPathRole(graph, roles, path); got != RoleTooling { + t.Fatalf("role(%s) = %v, want %v", normalizePath(path), got, RoleTooling) + } + } + if got := projectedPathRole(graph, roles, "/tmp/work/_build/trace_options.h"); got != RolePropagating { + t.Fatalf("role(trace_options.h) = %v, want %v", got, RolePropagating) + } +} + +func TestProjectRolesTreatsTryCompileProbeArtifactsAsTooling(t *testing.T) { + scope := trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/_build", + InstallRoot: "/tmp/work/install", + } + tryDir := "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-ProbeKeep" + graph := BuildGraph(BuildInput{ + Events: traceoptionsTryCompileProbeEventTrace(false, "ProbeKeep", "cmTC_probe"), + Scope: scope, + }) + roles := ProjectRoles(graph) + + for _, path := range []string{ + tryDir + "/CheckIncludeFile.c", + tryDir + "/CMakeFiles/cmTC_probe.dir/CheckIncludeFile.c.o", + tryDir + "/cmTC_probe", + tryDir + "/.cmake/api/v1/reply", + } { + if got := projectedPathRole(graph, roles, path); got != RoleTooling { + t.Fatalf("role(%s) = %v, want %v", normalizePath(path), got, RoleTooling) + } + } + if got := projectedPathRole(graph, roles, "/tmp/work/_build/trace_options.h"); got != RolePropagating { + t.Fatalf("role(trace_options.h) = %v, want %v", got, RolePropagating) + } +} + +func TestProjectRolesTreatsInstallManifestAsDelivery(t *testing.T) { + scope := trace.Scope{ + BuildRoot: "/tmp/work/_build", + InstallRoot: "/tmp/work/install", + } + records := []trace.Record{ + recordWithProc(200, 1, []string{"ar", "rcs", "/tmp/work/_build/libtracecore.a", "/tmp/work/_build/core.o"}, "/tmp/work/_build", + []string{"/tmp/work/_build/core.o"}, + []string{"/tmp/work/_build/libtracecore.a"}), + recordWithProc(300, 2, []string{"cmake", "--install", "/tmp/work/_build"}, "/tmp/work", + []string{ + "/tmp/work/_build/cmake_install.cmake", + "/tmp/work/_build/libtracecore.a", + }, + []string{ + "/tmp/work/install/lib/libtracecore.a", + "/tmp/work/_build/install_manifest.txt", + }), + } + + graph := BuildGraph(BuildInput{Records: records, Scope: scope}) + roles := ProjectRoles(graph) + + if got := projectedPathRole(graph, roles, "/tmp/work/_build/install_manifest.txt"); got != RoleDelivery { + t.Fatalf("role(install_manifest.txt) = %v, want %v", got, RoleDelivery) + } + if ImpactPathAllowed(graph, roles, "/tmp/work/_build/install_manifest.txt") { + t.Fatal("install_manifest.txt should stay outside the Stage 2 impact domain") + } +} + +func TestProjectRolesTreatsArchiveTempSidecarsAsTooling(t *testing.T) { + scope := trace.Scope{BuildRoot: "/tmp/work/_build"} + records := []trace.Record{ + recordWithProc(200, 1, []string{"ar", "qc", "/tmp/work/_build/libtracecore.a", "/tmp/work/_build/core.o"}, "/tmp/work/_build", + []string{"/tmp/work/_build/core.o"}, + []string{"/tmp/work/_build/libtracecore.a", "/tmp/work/_build/stNMeD5X"}), + recordWithProc(201, 200, []string{"ranlib", "/tmp/work/_build/libtracecore.a"}, "/tmp/work/_build", + []string{"/tmp/work/_build/libtracecore.a"}, + []string{"/tmp/work/_build/libtracecore.a", "/tmp/work/_build/stdwbVyX"}), + recordWithProc(300, 2, []string{"cmake", "--install", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/_build/libtracecore.a"}, + []string{"/tmp/work/install/lib/libtracecore.a"}), + } + + graph := BuildGraph(BuildInput{Records: records, Scope: scope}) + roles := ProjectRoles(graph) + + for _, path := range []string{ + "/tmp/work/_build/stNMeD5X", + "/tmp/work/_build/stdwbVyX", + } { + if got := projectedPathRole(graph, roles, path); got != RoleTooling { + t.Fatalf("role(%s) = %v, want %v", normalizePath(path), got, RoleTooling) + } + } + if got := projectedPathRole(graph, roles, "/tmp/work/_build/libtracecore.a"); got != RolePropagating { + t.Fatalf("role(libtracecore.a) = %v, want %v", got, RolePropagating) + } +} + +func TestProjectRolesKeepsMainlineVisibleAcrossConfigureChildFrontier(t *testing.T) { + scope := trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/_build", + } + records := []trace.Record{ + recordWithProc(100, 1, []string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/CMakeLists.txt"}, + []string{ + "/tmp/work/_build/config.cache", + "/tmp/work/_build/config.h", + }), + recordWithProc(200, 100, []string{"cc", "/tmp/work/main.c", "/tmp/work/_build/config.h", "-o", "/tmp/work/_build/app"}, "/tmp/work/_build", + []string{ + "/tmp/work/main.c", + "/tmp/work/_build/config.h", + }, + []string{"/tmp/work/_build/app"}), + } + + graph := BuildGraph(BuildInput{Records: records, Scope: scope}) + roles := ProjectRoles(graph) + + if got := projectedPathRole(graph, roles, "/tmp/work/_build/config.cache"); got != RoleTooling { + t.Fatalf("role(config.cache) = %v, want %v", got, RoleTooling) + } + if got := projectedPathRole(graph, roles, "/tmp/work/_build/config.h"); got != RolePropagating { + t.Fatalf("role(config.h) = %v, want %v", got, RolePropagating) + } + if got := projectedPathRole(graph, roles, "/tmp/work/_build/app"); got != RolePropagating { + t.Fatalf("role(app) = %v, want %v", got, RolePropagating) + } + if got := RoleActionClass(roles, 1); got != ActionRoleMainline { + t.Fatalf("action role(cc) = %v, want %v", got, ActionRoleMainline) + } +} + +func TestInferMainlineVisibleDefsUsesDerivedSinkWithoutInstall(t *testing.T) { + scope := trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/_build", + } + records := []trace.Record{ + recordWithProc(100, 1, []string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/CMakeLists.txt"}, + []string{ + "/tmp/work/_build/config.cache", + "/tmp/work/_build/config.h", + }), + recordWithProc(200, 100, []string{"cc", "/tmp/work/main.c", "/tmp/work/_build/config.h", "-o", "/tmp/work/_build/app"}, "/tmp/work/_build", + []string{ + "/tmp/work/main.c", + "/tmp/work/_build/config.h", + }, + []string{"/tmp/work/_build/app"}), + } + + graph := BuildGraph(BuildInput{Records: records, Scope: scope}) + tooling := classifyToolingFamily(graph) + deliveryOnly := make([]bool, len(graph.Actions)) + for idx := range graph.Actions { + deliveryOnly[idx] = isDeliveryOnlyAction(graph, idx) + } + toolingWorkspaceRoots := inferToolingWorkspaceRoots(graph, tooling, deliveryOnly) + nonEscaping := classifyNonEscapingToolingDefs(graph, tooling, deliveryOnly, toolingWorkspaceRoots) + visible := inferMainlineVisibleDefs(graph, tooling, toolingWorkspaceRoots, nonEscaping) + + for _, def := range []PathState{ + graph.ActionWrites[0][1], // config.h + graph.ActionWrites[1][0], // app + } { + if _, ok := visible[def]; !ok { + t.Fatalf("fallback closure missing %v", def) + } + } + if _, ok := visible[graph.ActionWrites[0][0]]; ok { // config.cache + t.Fatalf("fallback closure unexpectedly retained %v", graph.ActionWrites[0][0]) + } +} + +func TestInferMainlineVisibleDefsUsesHardSinksWithInstall(t *testing.T) { + scope := trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/_build", + InstallRoot: "/tmp/work/install", + } + records := []trace.Record{ + recordWithProc(100, 1, []string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/CMakeLists.txt"}, + []string{ + "/tmp/work/_build/config.cache", + "/tmp/work/_build/config.h", + }), + recordWithProc(200, 100, []string{"cc", "/tmp/work/main.c", "/tmp/work/_build/config.h", "-o", "/tmp/work/_build/app"}, "/tmp/work/_build", + []string{ + "/tmp/work/main.c", + "/tmp/work/_build/config.h", + }, + []string{"/tmp/work/_build/app"}), + recordWithProc(300, 1, []string{"cmake", "--install", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/_build/app"}, + []string{"/tmp/work/install/bin/app"}), + } + + graph := BuildGraph(BuildInput{Records: records, Scope: scope}) + tooling := classifyToolingFamily(graph) + deliveryOnly := make([]bool, len(graph.Actions)) + for idx := range graph.Actions { + deliveryOnly[idx] = isDeliveryOnlyAction(graph, idx) + } + toolingWorkspaceRoots := inferToolingWorkspaceRoots(graph, tooling, deliveryOnly) + nonEscaping := classifyNonEscapingToolingDefs(graph, tooling, deliveryOnly, toolingWorkspaceRoots) + visible := inferMainlineVisibleDefs(graph, tooling, toolingWorkspaceRoots, nonEscaping) + + for _, def := range []PathState{ + graph.ActionWrites[0][1], // config.h + graph.ActionWrites[1][0], // app + graph.ActionWrites[2][0], // install/bin/app + } { + if _, ok := visible[def]; !ok { + t.Fatalf("hard-sink closure missing %v", def) + } + } + if _, ok := visible[graph.ActionWrites[0][0]]; ok { // config.cache + t.Fatalf("hard-sink closure unexpectedly retained %v", graph.ActionWrites[0][0]) + } +} + +func TestProjectRolesDoNotPromoteUnusedHeaderWhenHardSinkClosureExists(t *testing.T) { + scope := trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/_build", + InstallRoot: "/tmp/work/install", + } + records := []trace.Record{ + recordWithProc(100, 1, []string{"cmake", "-S", "/tmp/work", "-B", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/CMakeLists.txt"}, + []string{ + "/tmp/work/_build/config.cache", + "/tmp/work/_build/config.h", + "/tmp/work/_build/unused.h", + }), + recordWithProc(200, 100, []string{"cc", "/tmp/work/main.c", "/tmp/work/_build/config.h", "-o", "/tmp/work/_build/app"}, "/tmp/work/_build", + []string{ + "/tmp/work/main.c", + "/tmp/work/_build/config.h", + }, + []string{"/tmp/work/_build/app"}), + recordWithProc(300, 1, []string{"cmake", "--install", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/_build/app"}, + []string{"/tmp/work/install/bin/app"}), + } + + graph := BuildGraph(BuildInput{Records: records, Scope: scope}) + roles := ProjectRoles(graph) + + if got := projectedPathRole(graph, roles, "/tmp/work/_build/config.h"); got != RolePropagating { + t.Fatalf("role(config.h) = %v, want %v", got, RolePropagating) + } + if got := projectedPathRole(graph, roles, "/tmp/work/_build/unused.h"); got != RoleTooling { + t.Fatalf("role(unused.h) = %v, want %v", got, RoleTooling) + } + if got := projectedPathRole(graph, roles, "/tmp/work/_build/config.cache"); got != RoleTooling { + t.Fatalf("role(config.cache) = %v, want %v", got, RoleTooling) + } +} + +func TestBuildGraphStabilizesInstallRootInFingerprint(t *testing.T) { + recA := record( + []string{"sh", "./bootstrap.sh", "--prefix=/tmp/work/out-a"}, + "/tmp/work", + []string{"/tmp/work/bootstrap.sh"}, + []string{"/tmp/work/b2"}, + ) + recB := record( + []string{"sh", "./bootstrap.sh", "--prefix=/tmp/work/out-b"}, + "/tmp/work", + []string{"/tmp/work/bootstrap.sh"}, + []string{"/tmp/work/b2"}, + ) + + graphA := BuildGraph(BuildInput{ + Records: []trace.Record{recA}, + Scope: trace.Scope{ + SourceRoot: "/tmp/work", + InstallRoot: "/tmp/work/out-a", + }, + }) + graphB := BuildGraph(BuildInput{ + Records: []trace.Record{recB}, + Scope: trace.Scope{ + SourceRoot: "/tmp/work", + InstallRoot: "/tmp/work/out-b", + }, + }) + + if got, want := graphA.Actions[0].Fingerprint, graphB.Actions[0].Fingerprint; got != want { + t.Fatalf("fingerprint mismatch:\nA=%q\nB=%q", got, want) + } + if got, want := graphA.Actions[0].ActionKey, graphB.Actions[0].ActionKey; got != want { + t.Fatalf("actionKey mismatch:\nA=%q\nB=%q", got, want) + } +} + +func TestBuildGraphTreatsTrailingSlashInstallRootAsExplicitDelivery(t *testing.T) { + records := []trace.Record{ + record([]string{"ar", "rcs", "out/lib/libfoo.a", "build/core.o"}, "/tmp/work", + []string{"/tmp/work/build/core.o"}, + []string{"/tmp/work/out/lib/libfoo.a"}), + record([]string{"cmake", "--install", "/tmp/work/_build"}, "/tmp/work", + []string{"/tmp/work/out/lib/libfoo.a"}, + []string{"/tmp/work/install/lib/libfoo.a"}), + } + + graph := BuildGraph(BuildInput{ + Records: records, + Scope: trace.Scope{InstallRoot: "/tmp/work/install/"}, + }) + roles := ProjectRoles(graph) + if got := projectedPathRole(graph, roles, "/tmp/work/install/lib/libfoo.a"); got != RoleDelivery { + t.Fatalf("role(install/libfoo.a) = %v, want %v", got, RoleDelivery) + } +} + +func TestAnalyzeExposesJoinSetThroughAnalysisInput(t *testing.T) { + base := []trace.Record{ + record([]string{"cc", "-c", "a.c", "-o", "build/a.o"}, "/tmp/work", []string{"/tmp/work/a.c"}, []string{"/tmp/work/build/a.o"}), + record([]string{"cc", "-c", "b.c", "-o", "build/b.o"}, "/tmp/work", []string{"/tmp/work/b.c"}, []string{"/tmp/work/build/b.o"}), + record([]string{"cc", "build/a.o", "build/b.o", "-o", "out/bin/app"}, "/tmp/work", []string{"/tmp/work/build/a.o", "/tmp/work/build/b.o"}, []string{"/tmp/work/out/bin/app"}), + } + probe := []trace.Record{ + record([]string{"cc", "-DFEATURE", "-c", "a.c", "-o", "build/a.o"}, "/tmp/work", []string{"/tmp/work/a.c"}, []string{"/tmp/work/build/a.o"}), + record([]string{"cc", "-c", "b.c", "-o", "build/b.o"}, "/tmp/work", []string{"/tmp/work/b.c"}, []string{"/tmp/work/build/b.o"}), + record([]string{"cc", "build/a.o", "build/b.o", "-o", "out/bin/app"}, "/tmp/work", []string{"/tmp/work/build/a.o", "/tmp/work/build/b.o"}, []string{"/tmp/work/out/bin/app"}), + } + + result := Analyze(AnalysisInput{ + Base: AnalysisSideInput{Records: base}, + Probe: AnalysisSideInput{Records: probe}, + }) + + if len(result.Debug.BaseGraph.Actions) != 3 || len(result.Debug.ProbeGraph.Actions) != 3 { + t.Fatalf("Analyze() graphs = %d/%d actions, want 3/3", len(result.Debug.BaseGraph.Actions), len(result.Debug.ProbeGraph.Actions)) + } + if len(result.Profile.JoinSet) != 1 || result.Profile.JoinSet[0] != 2 { + t.Fatalf("JoinSet = %v, want [2]", result.Profile.JoinSet) + } + if len(result.Debug.Flow.JoinActions) != 1 || result.Debug.Flow.JoinActions[0] != 2 { + t.Fatalf("Flow.JoinActions = %v, want [2]", result.Debug.Flow.JoinActions) + } + if len(result.Debug.Flow.FrontierActions) != 1 || result.Debug.Flow.FrontierActions[0] != 2 { + t.Fatalf("Flow.FrontierActions = %v, want [2]", result.Debug.Flow.FrontierActions) + } +} diff --git a/internal/trace/ssa/impact_internal.go b/internal/trace/ssa/impact_internal.go new file mode 100644 index 0000000..38e0e88 --- /dev/null +++ b/internal/trace/ssa/impact_internal.go @@ -0,0 +1,67 @@ +package ssa + +type pathStateKey struct { + path string + tombstone bool + missing bool +} + +type optionProfile struct { + seedWrites map[string]struct{} + needPaths map[string]struct{} + slicePaths map[string]struct{} + joinSet []int + seedStates map[pathStateKey]struct{} + needStates map[pathStateKey]struct{} + flowStates map[pathStateKey]struct{} + ambiguous bool +} + +type actionPair struct { + baseIdx int + probeIdx int +} + +type impactEvidence struct { + changed map[string]bool +} + +type pathSSAFlow struct { + reachedDefs map[PathState]struct{} + reachedActions map[int]struct{} + joinActions []int + flowActions []int + frontierActions []int + externalReads map[int]map[string]struct{} + externalDefs map[int]map[PathState]struct{} + ambiguousReads bool +} + +type deletedSeedSet map[string]struct{} + +func initOptionProfile() optionProfile { + return optionProfile{ + seedWrites: make(map[string]struct{}), + needPaths: make(map[string]struct{}), + slicePaths: make(map[string]struct{}), + seedStates: make(map[pathStateKey]struct{}), + needStates: make(map[pathStateKey]struct{}), + flowStates: make(map[pathStateKey]struct{}), + } +} + +func canonicalImpactPath(graph Graph, path string) string { + return normalizeScopeToken(path, graph.Scope) +} + +func pathChanged(evidence *impactEvidence, graph Graph, path string) bool { + if evidence == nil { + return true + } + key := canonicalImpactPath(graph, path) + changed, ok := evidence.changed[key] + if !ok { + return true + } + return changed +} diff --git a/internal/trace/ssa/impact_support.go b/internal/trace/ssa/impact_support.go new file mode 100644 index 0000000..1bc938f --- /dev/null +++ b/internal/trace/ssa/impact_support.go @@ -0,0 +1,432 @@ +package ssa + +import ( + "slices" + "sort" + "strings" + + "github.com/goplus/llar/internal/trace" +) + +func isDeliveryOnlyAction(graph Graph, idx int) bool { + if idx < 0 || idx >= len(graph.Actions) { + return false + } + action := graph.Actions[idx] + if len(action.Writes) == 0 { + return false + } + explicitDeliveryOnly := true + for _, changed := range action.Writes { + if !pathLooksDelivery(graph, changed) { + return false + } + if !isExplicitDeliveryPath(changed, graph.Scope) { + explicitDeliveryOnly = false + } + } + if action.Kind == KindCopy || action.Kind == KindInstall { + return true + } + return explicitDeliveryOnly +} + +func collectExecPaths(actions []ExecNode) map[string]struct{} { + executed := make(map[string]struct{}) + for _, action := range actions { + if action.ExecPath == "" { + continue + } + executed[action.ExecPath] = struct{}{} + } + return executed +} + +func actionWritesExecutedPath(action ExecNode, executedPaths map[string]struct{}) bool { + if len(executedPaths) == 0 { + return false + } + for _, path := range action.Writes { + if _, ok := executedPaths[path]; ok { + return true + } + } + return false +} + +func isDeliveryPath(actions []ExecNode, outdeg []int, executedPaths map[string]struct{}, facts PathInfo) bool { + for _, writer := range facts.Writers { + if writer < 0 || writer >= len(actions) { + continue + } + action := actions[writer] + if (action.Kind == KindCopy || action.Kind == KindInstall) && outdeg[writer] == 0 && !actionWritesExecutedPath(action, executedPaths) { + return true + } + } + return false +} + +func pathWithinObservedScope(path string, scope trace.Scope) bool { + path = normalizePath(path) + if path == "" { + return false + } + if strings.HasPrefix(path, envNamespacePrefix) { + return true + } + roots := make([]string, 0, 3+len(scope.KeepRoots)) + if scope.SourceRoot != "" { + roots = append(roots, normalizePath(scope.SourceRoot)) + } + if scope.BuildRoot != "" { + roots = append(roots, normalizePath(scope.BuildRoot)) + } + if scope.InstallRoot != "" { + roots = append(roots, normalizePath(scope.InstallRoot)) + } + for _, root := range scope.KeepRoots { + if root = normalizePath(root); root != "" { + roots = append(roots, root) + } + } + for _, root := range roots { + if path == root || strings.HasPrefix(path, root+"/") { + return true + } + } + return false +} + +func roleActionNoise(roles roleProjection, idx int) bool { + return idx >= 0 && idx < len(roles.ActionNoise) && roles.ActionNoise[idx] +} + +func roleActionDeliveryOnly(roles roleProjection, idx int) bool { + return idx >= 0 && idx < len(roles.ActionDeliveryOnly) && roles.ActionDeliveryOnly[idx] +} + +func roleActionClass(roles roleProjection, idx int) actionRole { + if idx >= 0 && idx < len(roles.ActionClass) { + return roles.ActionClass[idx] + } + if roleActionDeliveryOnly(roles, idx) { + return actionRoleDelivery + } + if roleActionNoise(roles, idx) { + return actionRoleProbe + } + return actionRoleMainline +} + +func roleDefClass(roles roleProjection, def PathState) defRole { + if class, ok := roles.DefClass[def]; ok { + return class + } + if _, noise := roles.DefNoise[def]; noise { + return defRoleProbe + } + return defRoleMainline +} + +func visibleBindingDefs(defs []PathState, roles roleProjection) []PathState { + out := make([]PathState, 0, len(defs)) + for _, def := range defs { + if _, noise := roles.DefNoise[def]; noise { + continue + } + out = append(out, def) + } + return out +} + +func impactTrackedPathAllowed(graph Graph, roles roleProjection, path string) bool { + if !impactPathAllowed(graph, roles, path) { + return false + } + if graph.Scope.SourceRoot == "" && graph.Scope.BuildRoot == "" && graph.Scope.InstallRoot == "" && len(graph.Scope.KeepRoots) == 0 { + return true + } + return pathWithinObservedScope(path, graph.Scope) +} + +func impactPathAllowed(graph Graph, roles roleProjection, path string) bool { + path = normalizePath(path) + if path == "" { + return false + } + if _, ok := graph.Paths[path]; !ok { + return false + } + if pathLooksDelivery(graph, path) && !isExplicitDeliveryPath(path, graph.Scope) { + return false + } + if pathLooksConfigureSidecarProjected(graph, roles, path) { + return false + } + visibleDef := false + for _, def := range graph.DefsByPath[path] { + if _, noise := roles.DefNoise[def]; noise { + continue + } + visibleDef = true + break + } + if visibleDef { + return true + } + if len(graph.DefsByPath[path]) != 0 { + return false + } + if isProbeOnlyNoisePathProjected(graph, roles, path) { + return false + } + if pathLooksToolingProjected(graph, roles, path) { + return false + } + return true +} + +func isProbeOnlyNoisePathProjected(graph Graph, roles roleProjection, path string) bool { + if path == "" || isExplicitDeliveryPath(path, graph.Scope) { + return false + } + facts, ok := graph.Paths[path] + if !ok { + return false + } + if len(graph.DefsByPath[path]) != 0 { + return false + } + sawEndpoint := false + for _, idx := range facts.Writers { + class := roleActionClass(roles, idx) + if class != actionRoleTooling && class != actionRoleProbe { + return false + } + sawEndpoint = true + } + for _, idx := range facts.Readers { + class := roleActionClass(roles, idx) + if class != actionRoleTooling && class != actionRoleProbe { + return false + } + sawEndpoint = true + } + return sawEndpoint +} + +func pathLooksToolingProjected(graph Graph, roles roleProjection, path string) bool { + path = normalizePath(path) + if path == "" || len(graph.DefsByPath[path]) != 0 { + return false + } + facts, ok := graph.Paths[path] + if !ok { + return false + } + sawEndpoint := false + for _, idx := range facts.Writers { + class := roleActionClass(roles, idx) + if class != actionRoleTooling && class != actionRoleProbe { + return false + } + sawEndpoint = true + } + for _, idx := range facts.Readers { + class := roleActionClass(roles, idx) + if class != actionRoleTooling && class != actionRoleProbe { + return false + } + sawEndpoint = true + } + return sawEndpoint +} + +func pathLooksConfigureSidecarProjected(graph Graph, roles roleProjection, path string) bool { + path = normalizePath(path) + if path == "" || isExplicitDeliveryPath(path, graph.Scope) { + return false + } + facts, ok := graph.Paths[path] + if !ok || len(facts.Writers) == 0 { + return false + } + for _, writer := range facts.Writers { + if writer < 0 || writer >= len(graph.Actions) { + return false + } + if roleActionClass(roles, writer) != actionRoleMainline { + return false + } + if graph.Actions[writer].Kind != KindConfigure { + return false + } + if len(graph.Actions[writer].Writes) <= 1 { + return false + } + } + for _, reader := range facts.Readers { + if reader < 0 || reader >= len(graph.Actions) { + continue + } + switch roleActionClass(roles, reader) { + case actionRoleTooling, actionRoleProbe, actionRoleDelivery: + continue + case actionRoleMainline: + if actionConsumesMainlineData(graph.Actions[reader]) { + return false + } + default: + return false + } + } + return true +} + +func actionConsumesMainlineData(action ExecNode) bool { + switch action.Kind { + case KindCopy, KindInstall, KindGeneric: + return true + default: + return false + } +} + +func actionReadAmbiguousVisible(graph Graph, roles roleProjection, idx int) bool { + if idx < 0 || idx >= len(graph.ActionReads) { + return false + } + for _, read := range graph.ActionReads[idx] { + if !impactTrackedPathAllowed(graph, roles, read.Path) { + continue + } + if len(visibleBindingDefs(read.Defs, roles)) > 1 { + return true + } + } + return false +} + +func actionDependsOnDivergedRead(graph Graph, roles roleProjection, diverged map[PathState]struct{}, idx int) bool { + if idx < 0 || idx >= len(graph.ActionReads) { + return false + } + for _, read := range graph.ActionReads[idx] { + if !impactTrackedPathAllowed(graph, roles, read.Path) { + continue + } + for _, def := range read.Defs { + if _, noise := roles.DefNoise[def]; noise { + continue + } + if _, ok := diverged[def]; ok { + return true + } + } + } + return false +} + +func canonicalActionWriteSet(graph Graph, roles roleProjection, idx int) []string { + if idx < 0 || idx >= len(graph.Actions) { + return nil + } + out := make([]string, 0, len(graph.Actions[idx].Writes)) + for _, path := range graph.Actions[idx].Writes { + if !impactPathAllowed(graph, roles, path) { + continue + } + out = append(out, canonicalImpactPath(graph, path)) + } + sort.Strings(out) + return out +} + +func wavefrontActionEquivalentWithChanged(base, probe Graph, baseRoles, probeRoles roleProjection, baseIdx, probeIdx int, changed map[string]bool) bool { + if baseIdx < 0 || baseIdx >= len(base.Actions) || probeIdx < 0 || probeIdx >= len(probe.Actions) { + return false + } + if intrinsicBehaviorSignature(base.Actions[baseIdx]) != intrinsicBehaviorSignature(probe.Actions[probeIdx]) { + return false + } + return actionOutputsEquivalent(base, probe, baseRoles, probeRoles, baseIdx, probeIdx, changed) +} + +func intrinsicBehaviorSignature(action ExecNode) string { + return action.ActionKey + "\x1f" + action.StructureKey +} + +func actionOutputsEquivalent(base, probe Graph, baseRoles, probeRoles roleProjection, baseIdx, probeIdx int, changed map[string]bool) bool { + baseWrites := canonicalActionWriteSet(base, baseRoles, baseIdx) + probeWrites := canonicalActionWriteSet(probe, probeRoles, probeIdx) + if !slices.Equal(baseWrites, probeWrites) { + return false + } + if len(baseWrites) == 0 && len(probeWrites) == 0 { + return true + } + fallbackEquivalent := canAssumeOutputEquivalentWithoutEvidence(base, baseRoles, baseIdx) && + canAssumeOutputEquivalentWithoutEvidence(probe, probeRoles, probeIdx) + if changed == nil { + return fallbackEquivalent + } + sawEvidence := false + for _, path := range probe.Actions[probeIdx].Writes { + if !impactPathAllowed(probe, probeRoles, path) { + continue + } + key := canonicalImpactPath(probe, path) + pathChanged, ok := changed[key] + if !ok { + continue + } + sawEvidence = true + if pathChanged { + return false + } + } + if sawEvidence { + return true + } + return fallbackEquivalent +} + +func canAssumeOutputEquivalentWithoutEvidence(graph Graph, roles roleProjection, idx int) bool { + return actionHasVisibleReaders(graph, roles, idx) || actionHasVisibleNonInitialInput(graph, roles, idx) +} + +func actionHasVisibleReaders(graph Graph, roles roleProjection, idx int) bool { + if idx < 0 || idx >= len(graph.ActionWrites) { + return false + } + for _, def := range graph.ActionWrites[idx] { + if _, noise := roles.DefNoise[def]; noise { + continue + } + for _, reader := range roleReadersForDef(graph, def) { + if roleActionNoise(roles, reader) || roleActionDeliveryOnly(roles, reader) { + continue + } + return true + } + } + return false +} + +func actionHasVisibleNonInitialInput(graph Graph, roles roleProjection, idx int) bool { + if idx < 0 || idx >= len(graph.ActionReads) { + return false + } + for _, read := range graph.ActionReads[idx] { + if !impactPathAllowed(graph, roles, read.Path) { + continue + } + for _, def := range visibleBindingDefs(read.Defs, roles) { + if def.Writer >= 0 { + return true + } + } + } + return false +} diff --git a/internal/trace/ssa/impact_wavefront.go b/internal/trace/ssa/impact_wavefront.go new file mode 100644 index 0000000..30cc9af --- /dev/null +++ b/internal/trace/ssa/impact_wavefront.go @@ -0,0 +1,1062 @@ +package ssa + +import ( + "sort" + "strings" +) + +type wavefrontProbeClass uint8 + +const ( + wavefrontProbeUnknown wavefrontProbeClass = iota + wavefrontProbeUnchanged + wavefrontProbeMutationRoot + wavefrontProbeFlow +) + +type wavefrontStageResult struct { + matched int + baseOnly []int + probeOnly []int + pairs []actionPair + remainingBase []int + remainingProbe []int + probeClass []wavefrontProbeClass + divergedDefs map[PathState]struct{} + ambiguous bool + readAmbiguous bool +} + +// traceSSAImpactPipeline keeps the post-build impact stages strictly one-way: +// graph -> role projection -> wavefront diff -> impact profile. +type traceSSAImpactPipeline struct { + base Graph + probe Graph + evidence *impactEvidence + baseRoles roleProjection + probeRoles roleProjection + diff wavefrontStageResult + profile optionProfile + flow pathSSAFlow +} + +func runTraceSSAImpactPipeline(base, probe Graph, evidence *impactEvidence) traceSSAImpactPipeline { + pipeline := traceSSAImpactPipeline{ + base: base, + probe: probe, + evidence: evidence, + } + + // Stage 3: project roles onto SSA nodes and path versions. + pipeline.baseRoles = projectRoles(base) + pipeline.probeRoles = projectRoles(probe) + + // Stage 4: wavefront diff over the two role-aware SSA graphs. + pipeline.diff = wavefrontDiffWithEvidence( + base, + probe, + pipeline.baseRoles, + pipeline.probeRoles, + evidence, + ) + + // Stage 5: compress the labeled probe graph into the impact summary. + pipeline.profile, pipeline.flow = extractWavefrontImpact( + base, + pipeline.baseRoles, + probe, + pipeline.probeRoles, + pipeline.diff, + evidence, + ) + return pipeline +} + +func wavefrontDiff(base, probe Graph, baseRoles, probeRoles roleProjection) wavefrontStageResult { + return wavefrontDiffWithEvidence(base, probe, baseRoles, probeRoles, nil) +} + +func wavefrontDiffWithEvidence(base, probe Graph, baseRoles, probeRoles roleProjection, evidence *impactEvidence) wavefrontStageResult { + equivalentBaseDefs := collectEquivalentInitialDefs(base, baseRoles) + equivalentProbeDefs := collectEquivalentInitialDefs(probe, probeRoles) + readyBase := make(map[int]struct{}, len(base.Actions)) + readyBaseBySig := make(map[string][]int) + usedBase := make(map[int]struct{}, len(base.Actions)) + unchangedSet := make(map[int]struct{}, len(probe.Actions)) + directMutations := make(map[int]struct{}, len(probe.Actions)) + divergedActions := make(map[int]struct{}, len(probe.Actions)) + divergedDefs := make(map[PathState]struct{}) + pairs := make([]actionPair, 0, len(probe.Actions)) + matched := 0 + readAmbiguous := false + + markEquivalentPair := func(baseIdx, probeIdx int) { + usedBase[baseIdx] = struct{}{} + delete(readyBase, baseIdx) + markEquivalentActionWrites(base, baseRoles, equivalentBaseDefs, []int{baseIdx}) + markEquivalentActionWrites(probe, probeRoles, equivalentProbeDefs, []int{probeIdx}) + unchangedSet[probeIdx] = struct{}{} + matched++ + } + markDivergedAction := func(probeIdx int, directMutation bool) { + if _, ok := divergedActions[probeIdx]; ok { + return + } + divergedActions[probeIdx] = struct{}{} + if directMutation { + directMutations[probeIdx] = struct{}{} + } + if actionReadAmbiguousVisible(probe, probeRoles, probeIdx) { + readAmbiguous = true + } + if probeIdx < 0 || probeIdx >= len(probe.ActionWrites) { + return + } + for _, def := range probe.ActionWrites[probeIdx] { + if _, noise := probeRoles.DefNoise[def]; noise { + continue + } + if !impactTrackedPathAllowed(probe, probeRoles, def.Path) { + continue + } + divergedDefs[def] = struct{}{} + } + } + + ambiguous := false + progress := true + for progress { + progress = false + + for baseIdx := range base.Actions { + if _, used := usedBase[baseIdx]; used { + continue + } + if _, ready := readyBase[baseIdx]; ready { + continue + } + if roleActionNoise(baseRoles, baseIdx) { + continue + } + if actionReadAmbiguousVisible(base, baseRoles, baseIdx) { + continue + } + if !actionInputsEquivalent(base, baseRoles, equivalentBaseDefs, baseIdx) { + continue + } + readyBase[baseIdx] = struct{}{} + sig := intrinsicActionSignature(base, baseRoles, baseIdx) + readyBaseBySig[sig] = append(readyBaseBySig[sig], baseIdx) + } + + nextRound: + for probeIdx := range probe.Actions { + if roleActionNoise(probeRoles, probeIdx) { + continue + } + if _, ok := unchangedSet[probeIdx]; ok { + continue + } + if _, ok := divergedActions[probeIdx]; ok { + continue + } + + readiness := classifyProbeWavefrontReadiness(probe, probeRoles, equivalentProbeDefs, divergedDefs, probeIdx) + switch readiness { + case wavefrontProbeReadinessPending: + continue + case wavefrontProbeReadinessAmbiguous: + readAmbiguous = true + continue + case wavefrontProbeReadinessFlow: + markDivergedAction(probeIdx, false) + progress = true + break nextRound + case wavefrontProbeReadinessEquivalent: + sig := intrinsicActionSignature(probe, probeRoles, probeIdx) + baseIdx, decision := selectReadyBaselineCandidate(base, probe, baseRoles, probeRoles, readyBaseBySig, readyBase, usedBase, sig, probeIdx, evidence) + switch decision { + case readyBaselineMatchAmbiguous: + ambiguous = true + continue + case readyBaselineMatchNone: + markDivergedAction(probeIdx, true) + progress = true + break nextRound + case readyBaselineMatchUnique: + markEquivalentPair(baseIdx, probeIdx) + progress = true + break nextRound + } + } + } + } + + for probeIdx := range probe.Actions { + if roleActionNoise(probeRoles, probeIdx) { + continue + } + if _, ok := unchangedSet[probeIdx]; ok { + continue + } + if _, ok := divergedActions[probeIdx]; ok { + continue + } + if actionReadAmbiguousVisible(probe, probeRoles, probeIdx) { + readAmbiguous = true + } + } + + sort.Slice(pairs, func(i, j int) bool { + if pairs[i].probeIdx != pairs[j].probeIdx { + return pairs[i].probeIdx < pairs[j].probeIdx + } + return pairs[i].baseIdx < pairs[j].baseIdx + }) + + pairedBase := make(map[int]struct{}, len(usedBase)) + for baseIdx := range usedBase { + pairedBase[baseIdx] = struct{}{} + } + pairedProbe := make(map[int]struct{}, len(unchangedSet)+len(pairs)) + for probeIdx := range unchangedSet { + pairedProbe[probeIdx] = struct{}{} + } + for _, pair := range pairs { + pairedProbe[pair.probeIdx] = struct{}{} + } + + baseOnly := collectUnpairedIndexes(indexRange(len(base.Actions)), pairedBase) + probeOnly := collectUnpairedIndexes(indexRange(len(probe.Actions)), pairedProbe) + probeClass, _, _, _, _, _ := finalizeWavefrontProbeClassification( + probeRoles, + len(probe.Actions), + unchangedSet, + directMutations, + divergedActions, + ) + + return wavefrontStageResult{ + matched: matched, + baseOnly: baseOnly, + probeOnly: probeOnly, + pairs: pairs, + remainingBase: baseOnly, + remainingProbe: probeOnly, + probeClass: probeClass, + divergedDefs: divergedDefs, + ambiguous: ambiguous, + readAmbiguous: readAmbiguous, + } +} + +type wavefrontProbeReadiness uint8 + +const ( + wavefrontProbeReadinessPending wavefrontProbeReadiness = iota + wavefrontProbeReadinessEquivalent + wavefrontProbeReadinessFlow + wavefrontProbeReadinessAmbiguous +) + +func classifyProbeWavefrontReadiness(graph Graph, roles roleProjection, equivalentDefs, divergedDefs map[PathState]struct{}, idx int) wavefrontProbeReadiness { + if idx < 0 || idx >= len(graph.ActionReads) { + return wavefrontProbeReadinessEquivalent + } + sawDiverged := false + for _, read := range graph.ActionReads[idx] { + if !impactTrackedPathAllowed(graph, roles, read.Path) { + continue + } + defs := visibleBindingDefs(read.Defs, roles) + if len(defs) == 0 { + continue + } + if len(defs) > 1 { + return wavefrontProbeReadinessAmbiguous + } + def := defs[0] + if _, ok := equivalentDefs[def]; ok { + continue + } + if _, ok := divergedDefs[def]; ok { + sawDiverged = true + continue + } + return wavefrontProbeReadinessPending + } + if sawDiverged { + return wavefrontProbeReadinessFlow + } + return wavefrontProbeReadinessEquivalent +} + +type readyBaselineMatch uint8 + +const ( + readyBaselineMatchNone readyBaselineMatch = iota + readyBaselineMatchUnique + readyBaselineMatchAmbiguous +) + +func selectReadyBaselineCandidate(base, probe Graph, baseRoles, probeRoles roleProjection, readyBySig map[string][]int, readyBase, usedBase map[int]struct{}, sig string, probeIdx int, evidence *impactEvidence) (int, readyBaselineMatch) { + matched := -1 + matchCount := 0 + changed := map[string]bool(nil) + if evidence != nil { + changed = evidence.changed + } + for _, idx := range readyBySig[sig] { + if _, ok := readyBase[idx]; !ok { + continue + } + if _, ok := usedBase[idx]; ok { + continue + } + if !wavefrontActionEquivalentWithChanged(base, probe, baseRoles, probeRoles, idx, probeIdx, changed) { + continue + } + matched = idx + matchCount++ + if matchCount > 1 { + return -1, readyBaselineMatchAmbiguous + } + } + if matchCount == 1 { + return matched, readyBaselineMatchUnique + } + if relaxed, decision := selectReadyBaselineConfigureFallback(base, probe, baseRoles, probeRoles, readyBase, usedBase, probeIdx, changed); decision != readyBaselineMatchNone { + return relaxed, decision + } + return -1, readyBaselineMatchNone +} + +func selectReadyBaselineConfigureFallback(base, probe Graph, baseRoles, probeRoles roleProjection, readyBase, usedBase map[int]struct{}, probeIdx int, changed map[string]bool) (int, readyBaselineMatch) { + if changed == nil { + return -1, readyBaselineMatchNone + } + if probeIdx < 0 || probeIdx >= len(probe.Actions) { + return -1, readyBaselineMatchNone + } + probeAction := probe.Actions[probeIdx] + if probeAction.Kind != KindConfigure { + return -1, readyBaselineMatchNone + } + if len(canonicalActionWriteSet(probe, probeRoles, probeIdx)) == 0 { + return -1, readyBaselineMatchNone + } + matched := -1 + matchCount := 0 + probeCwd := normalizeScopeToken(probeAction.Cwd, probe.Scope) + for idx := range readyBase { + if _, ok := usedBase[idx]; ok { + continue + } + if idx < 0 || idx >= len(base.Actions) { + continue + } + baseAction := base.Actions[idx] + if baseAction.Kind != KindConfigure || baseAction.Tool != probeAction.Tool { + continue + } + if normalizeScopeToken(baseAction.Cwd, base.Scope) != probeCwd { + continue + } + if len(canonicalActionWriteSet(base, baseRoles, idx)) == 0 { + continue + } + if !actionOutputsEquivalent(base, probe, baseRoles, probeRoles, idx, probeIdx, changed) { + continue + } + matched = idx + matchCount++ + if matchCount > 1 { + return -1, readyBaselineMatchAmbiguous + } + } + if matchCount == 1 { + return matched, readyBaselineMatchUnique + } + return -1, readyBaselineMatchNone +} + +func intrinsicActionSignature(graph Graph, roles roleProjection, idx int) string { + if idx < 0 || idx >= len(graph.Actions) { + return "" + } + action := graph.Actions[idx] + argv := make([]string, 0, len(action.Argv)) + for _, arg := range action.Argv { + argv = append(argv, normalizeScopeToken(arg, graph.Scope)) + } + reads := intrinsicActionPathSet(graph, roles, action.Reads) + writes := intrinsicActionPathSet(graph, roles, action.Writes) + parts := []string{behaviorActionSignature(action)} + parts = append(parts, argv...) + parts = append(parts, "@", normalizeScopeToken(action.Cwd, graph.Scope), "@") + parts = append(parts, action.Env...) + parts = append(parts, "@") + parts = append(parts, reads...) + parts = append(parts, "@") + parts = append(parts, writes...) + return strings.Join(parts, "\x1f") +} + +func intrinsicActionPathSet(graph Graph, roles roleProjection, paths []string) []string { + if len(paths) == 0 { + return nil + } + set := make(map[string]struct{}, len(paths)) + for _, path := range paths { + if !impactPathAllowed(graph, roles, path) { + continue + } + set[canonicalImpactPath(graph, path)] = struct{}{} + } + out := make([]string, 0, len(set)) + for path := range set { + out = append(out, path) + } + sort.Strings(out) + return out +} + +func indexRange(n int) []int { + out := make([]int, n) + for i := range out { + out[i] = i + } + return out +} + +func collectAffectedPairs(base Graph, baseRoles roleProjection, probe Graph, probeRoles roleProjection, diff wavefrontStageResult) []actionPair { + baselineByProbe := collectProbeBaselineMatchesWithRoles(base, baseRoles, probe, probeRoles, diff) + divergedProbe := wavefrontDivergedProbe(diff) + seen := make(map[actionPair]struct{}, len(diff.pairs)+len(divergedProbe)) + out := make([]actionPair, 0, len(diff.pairs)+len(divergedProbe)) + add := func(baseIdx, probeIdx int) { + if baseIdx < 0 || baseIdx >= len(base.Actions) || probeIdx < 0 || probeIdx >= len(probe.Actions) { + return + } + pair := actionPair{baseIdx: baseIdx, probeIdx: probeIdx} + if _, ok := seen[pair]; ok { + return + } + seen[pair] = struct{}{} + out = append(out, pair) + } + for _, pair := range diff.pairs { + add(pair.baseIdx, pair.probeIdx) + } + for _, probeIdx := range divergedProbe { + baseIdx, ok := baselineByProbe[probeIdx] + if !ok { + continue + } + add(baseIdx, probeIdx) + } + sort.Slice(out, func(i, j int) bool { + if out[i].probeIdx != out[j].probeIdx { + return out[i].probeIdx < out[j].probeIdx + } + return out[i].baseIdx < out[j].baseIdx + }) + return out +} + +func collectEquivalentInitialDefs(graph Graph, roles roleProjection) map[PathState]struct{} { + out := make(map[PathState]struct{}, len(graph.InitialDefs)) + for _, def := range graph.InitialDefs { + if _, noise := roles.DefNoise[def]; noise { + continue + } + if !impactTrackedPathAllowed(graph, roles, def.Path) { + continue + } + out[def] = struct{}{} + } + for _, reads := range graph.ActionReads { + for _, read := range reads { + if !impactTrackedPathAllowed(graph, roles, read.Path) { + continue + } + for _, def := range visibleBindingDefs(read.Defs, roles) { + if def.Writer >= 0 { + continue + } + out[def] = struct{}{} + } + } + } + return out +} + +func markEquivalentActionWrites(graph Graph, roles roleProjection, equivalentDefs map[PathState]struct{}, indexes []int) { + for _, idx := range indexes { + if idx < 0 || idx >= len(graph.ActionWrites) { + continue + } + for _, def := range graph.ActionWrites[idx] { + if _, noise := roles.DefNoise[def]; noise { + continue + } + equivalentDefs[def] = struct{}{} + } + } +} + +func actionInputsEquivalent(graph Graph, roles roleProjection, equivalentDefs map[PathState]struct{}, idx int) bool { + if idx < 0 || idx >= len(graph.ActionReads) { + return true + } + for _, read := range graph.ActionReads[idx] { + if !impactTrackedPathAllowed(graph, roles, read.Path) { + continue + } + defs := visibleBindingDefs(read.Defs, roles) + if len(defs) == 0 { + continue + } + if len(defs) != 1 { + return false + } + if _, ok := equivalentDefs[defs[0]]; !ok { + return false + } + } + return true +} + +func collectUnpairedIndexes(indexes []int, paired map[int]struct{}) []int { + out := make([]int, 0, len(indexes)) + for _, idx := range indexes { + if _, ok := paired[idx]; ok { + continue + } + out = append(out, idx) + } + return out +} + +func sortedIndexSet(values map[int]struct{}) []int { + out := make([]int, 0, len(values)) + for idx := range values { + out = append(out, idx) + } + sort.Ints(out) + return out +} + +func finalizeWavefrontProbeClassification(roles roleProjection, actionCount int, unchangedSet, directMutations, divergedActions map[int]struct{}) ([]wavefrontProbeClass, []int, []int, []int, []int, []int) { + unchangedProbe := sortedIndexSet(unchangedSet) + mutationProbe := sortedIndexSet(directMutations) + divergedProbe := sortedIndexSet(divergedActions) + probeClass := make([]wavefrontProbeClass, actionCount) + for _, probeIdx := range unchangedProbe { + if probeIdx < 0 || probeIdx >= len(probeClass) { + continue + } + probeClass[probeIdx] = wavefrontProbeUnchanged + } + for _, probeIdx := range mutationProbe { + if probeIdx < 0 || probeIdx >= len(probeClass) { + continue + } + probeClass[probeIdx] = wavefrontProbeMutationRoot + } + flowProbe := make([]int, 0, len(divergedProbe)) + for _, probeIdx := range divergedProbe { + if _, ok := directMutations[probeIdx]; ok { + continue + } + if probeIdx < 0 || probeIdx >= len(probeClass) { + continue + } + probeClass[probeIdx] = wavefrontProbeFlow + flowProbe = append(flowProbe, probeIdx) + } + rootProbe := visibleProbeIndexes(roles, mutationProbe) + return probeClass, unchangedProbe, mutationProbe, flowProbe, divergedProbe, rootProbe +} + +func visibleProbeIndexes(roles roleProjection, indexes []int) []int { + out := make([]int, 0, len(indexes)) + for _, idx := range indexes { + if idx < 0 { + continue + } + if roleActionNoise(roles, idx) || roleActionDeliveryOnly(roles, idx) { + continue + } + out = append(out, idx) + } + return out +} + +func extractWavefrontImpact(base Graph, baseRoles roleProjection, probe Graph, roles roleProjection, diff wavefrontStageResult, evidence *impactEvidence) (optionProfile, pathSSAFlow) { + profile := initOptionProfile() + if diff.ambiguous || diff.readAmbiguous { + profile.ambiguous = true + } + + mutationRoots := wavefrontVisibleMutationRoots(diff, roles) + seedQueue := make(map[string]struct{}) + for _, idx := range mutationRoots { + if idx < 0 || idx >= len(probe.Actions) || roleActionDeliveryOnly(roles, idx) { + continue + } + for _, def := range probe.ActionWrites[idx] { + if !pathChanged(evidence, probe, def.Path) { + continue + } + if addImpactPathWithRoles(profile.seedWrites, probe, roles, def.Path) { + seedQueue[def.Path] = struct{}{} + } + addImpactStateWithRoles(profile.seedStates, probe, roles, def) + addImpactStateWithRoles(profile.flowStates, probe, roles, def) + } + } + + deletedSeeds := addDeletedSeedWritesWithRoles(profile.seedWrites, profile.seedStates, profile.flowStates, seedQueue, base, baseRoles, probe, roles, diff.remainingBase, evidence) + for path := range profile.seedWrites { + profile.slicePaths[path] = struct{}{} + } + flow := analyzePathSSAFlowV5(probe, roles, seedQueue, deletedSeeds, mutationRoots, evidence) + if flow.ambiguousReads { + profile.ambiguous = true + } + for def := range flow.reachedDefs { + addImpactPathWithRoles(profile.slicePaths, probe, roles, def.Path) + addImpactStateWithRoles(profile.flowStates, probe, roles, def) + } + profile.joinSet = append(profile.joinSet, flow.joinActions...) + for idx, defs := range flow.externalDefs { + if shouldIgnoreNeedReadsForAction(probe.Actions[idx], roles, idx) { + continue + } + for def := range defs { + addImpactPathWithRoles(profile.needPaths, probe, roles, def.Path) + addImpactStateWithRoles(profile.needStates, probe, roles, def) + } + } + + return profile, flow +} + +func collectProbeBaselineMatchesWithRoles(base Graph, baseRoles roleProjection, probe Graph, probeRoles roleProjection, diff wavefrontStageResult) map[int]int { + out := make(map[int]int, len(diff.pairs)) + usedBase := make(map[int]struct{}, len(base.Actions)) + for _, pair := range diff.pairs { + if pair.baseIdx < 0 || pair.baseIdx >= len(base.Actions) || pair.probeIdx < 0 || pair.probeIdx >= len(probe.Actions) { + continue + } + out[pair.probeIdx] = pair.baseIdx + usedBase[pair.baseIdx] = struct{}{} + } + + baseBySig := make(map[string][]int) + for baseIdx := range base.Actions { + if _, ok := usedBase[baseIdx]; ok { + continue + } + if roleActionNoise(baseRoles, baseIdx) { + continue + } + sig := intrinsicActionSignature(base, baseRoles, baseIdx) + baseBySig[sig] = append(baseBySig[sig], baseIdx) + } + baseWriterByPath := make(map[string]int) + ambiguousBaseWritePath := make(map[string]struct{}) + for baseIdx := range base.Actions { + if roleActionNoise(baseRoles, baseIdx) { + continue + } + visibleWrites := canonicalActionWriteSet(base, baseRoles, baseIdx) + if len(visibleWrites) != 1 { + continue + } + key := visibleWrites[0] + if prev, ok := baseWriterByPath[key]; ok && prev != baseIdx { + ambiguousBaseWritePath[key] = struct{}{} + continue + } + baseWriterByPath[key] = baseIdx + } + for probeIdx := range probe.Actions { + if _, ok := out[probeIdx]; ok { + continue + } + if roleActionNoise(probeRoles, probeIdx) { + continue + } + sig := intrinsicActionSignature(probe, probeRoles, probeIdx) + candidates := baseBySig[sig] + if len(candidates) == 0 { + visibleWrites := canonicalActionWriteSet(probe, probeRoles, probeIdx) + if len(visibleWrites) != 1 { + continue + } + key := visibleWrites[0] + if _, ambiguous := ambiguousBaseWritePath[key]; ambiguous { + continue + } + baseIdx, ok := baseWriterByPath[key] + if !ok { + continue + } + if _, ok := usedBase[baseIdx]; ok { + continue + } + out[probeIdx] = baseIdx + usedBase[baseIdx] = struct{}{} + continue + } + baseIdx := candidates[0] + baseBySig[sig] = candidates[1:] + out[probeIdx] = baseIdx + usedBase[baseIdx] = struct{}{} + } + return out +} + +func addDeletedSeedWritesWithRoles(seedWrites map[string]struct{}, seedStates map[pathStateKey]struct{}, flowStates map[pathStateKey]struct{}, queue map[string]struct{}, base Graph, baseRoles roleProjection, probe Graph, probeRoles roleProjection, remainingBase []int, evidence *impactEvidence) deletedSeedSet { + deleted := make(deletedSeedSet) + probePaths := canonicalImpactWrittenPathSetWithRoles(probe, probeRoles) + for _, idx := range remainingBase { + if idx < 0 || idx >= len(base.Actions) { + continue + } + for _, path := range base.Actions[idx].Writes { + if !impactPathAllowed(base, baseRoles, path) { + continue + } + key := canonicalImpactPath(base, path) + if _, ok := probePaths[key]; ok { + continue + } + if !pathChanged(evidence, base, path) { + continue + } + seedWrites[key] = struct{}{} + tombstone := pathStateKey{path: key, tombstone: true} + seedStates[tombstone] = struct{}{} + flowStates[tombstone] = struct{}{} + queue[path] = struct{}{} + deleted[path] = struct{}{} + } + } + return deleted +} + +func canonicalImpactWrittenPathSetWithRoles(graph Graph, roles roleProjection) map[string]struct{} { + out := make(map[string]struct{}) + for _, action := range graph.Actions { + for _, path := range action.Writes { + if !impactPathAllowed(graph, roles, path) { + continue + } + out[canonicalImpactPath(graph, path)] = struct{}{} + } + } + return out +} + +func wavefrontProbeIndexes(diff wavefrontStageResult, class wavefrontProbeClass) []int { + out := make([]int, 0, len(diff.probeClass)) + for idx, current := range diff.probeClass { + if current != class { + continue + } + out = append(out, idx) + } + return out +} + +func wavefrontDivergedProbe(diff wavefrontStageResult) []int { + out := make([]int, 0, len(diff.probeClass)) + for idx, class := range diff.probeClass { + if class != wavefrontProbeMutationRoot && class != wavefrontProbeFlow { + continue + } + out = append(out, idx) + } + return out +} + +func wavefrontVisibleMutationRoots(diff wavefrontStageResult, roles roleProjection) []int { + return visibleProbeIndexes(roles, wavefrontProbeIndexes(diff, wavefrontProbeMutationRoot)) +} + +func analyzePathSSAFlowV5(graph Graph, roles roleProjection, seeds map[string]struct{}, deletedSeeds deletedSeedSet, mutationRoots []int, evidence *impactEvidence) pathSSAFlow { + flow := pathSSAFlow{ + reachedDefs: make(map[PathState]struct{}), + reachedActions: make(map[int]struct{}), + externalReads: make(map[int]map[string]struct{}), + externalDefs: make(map[int]map[PathState]struct{}), + } + if len(seeds) == 0 && len(mutationRoots) == 0 { + return flow + } + queue := make([]PathState, 0, len(seeds)) + predecessors := make(map[int]map[int]struct{}) + concreteSeedPaths := make(map[string]struct{}) + + for _, idx := range mutationRoots { + if idx < 0 || idx >= len(graph.Actions) || roleActionDeliveryOnly(roles, idx) || roleActionNoise(roles, idx) { + continue + } + rootSeeded := false + for _, def := range graph.ActionWrites[idx] { + if _, noise := roles.DefNoise[def]; noise { + continue + } + if _, seeded := seeds[def.Path]; !seeded { + continue + } + if !pathChanged(evidence, graph, def.Path) { + continue + } + rootSeeded = true + if _, ok := flow.reachedDefs[def]; ok { + continue + } + flow.reachedDefs[def] = struct{}{} + queue = append(queue, def) + concreteSeedPaths[def.Path] = struct{}{} + } + if rootSeeded { + flow.reachedActions[idx] = struct{}{} + } + } + for path := range seeds { + if _, concrete := concreteSeedPaths[path]; concrete { + continue + } + def := PathState{Writer: -1, Path: path} + if _, ok := deletedSeeds[path]; ok { + def.Tombstone = true + } + if _, ok := flow.reachedDefs[def]; ok { + continue + } + if len(seedReadersWithRoles(graph, roles, def)) == 0 { + continue + } + flow.reachedDefs[def] = struct{}{} + queue = append(queue, def) + } + for len(queue) > 0 { + def := queue[len(queue)-1] + queue = queue[:len(queue)-1] + for _, reader := range seedReadersWithRoles(graph, roles, def) { + if reader < 0 || reader >= len(graph.Actions) || roleActionNoise(roles, reader) || roleActionDeliveryOnly(roles, reader) { + continue + } + if def.Writer >= 0 { + owners := predecessors[reader] + if owners == nil { + owners = make(map[int]struct{}) + predecessors[reader] = owners + } + owners[def.Writer] = struct{}{} + } + if _, ok := flow.reachedActions[reader]; ok { + continue + } + flow.reachedActions[reader] = struct{}{} + for _, nextDef := range graph.ActionWrites[reader] { + if _, noise := roles.DefNoise[nextDef]; noise { + continue + } + if !pathChanged(evidence, graph, nextDef.Path) { + continue + } + if _, ok := flow.reachedDefs[nextDef]; ok { + continue + } + flow.reachedDefs[nextDef] = struct{}{} + queue = append(queue, nextDef) + } + } + } + + join := make(map[int]bool) + hasJoinAncestor := make(map[int]bool) + reachedOrder := make([]int, 0, len(flow.reachedActions)) + for reader := range flow.reachedActions { + reachedOrder = append(reachedOrder, reader) + } + sort.Ints(reachedOrder) + flow.flowActions = reachedOrder + for _, reader := range reachedOrder { + external := make(map[string]struct{}) + sawReachedInput := false + sawStablePrereq := false + for _, binding := range graph.ActionReads[reader] { + defs := visibleBindingDefs(binding.Defs, roles) + if len(defs) == 0 { + continue + } + if len(defs) > 1 { + flow.ambiguousReads = true + } + hasInternal := false + for _, def := range defs { + if _, ok := flow.reachedDefs[def]; ok { + hasInternal = true + sawReachedInput = true + } + if def.Writer < 0 { + tombstone := def + tombstone.Tombstone = true + if _, ok := flow.reachedDefs[tombstone]; ok { + hasInternal = true + sawReachedInput = true + } + } + } + if hasInternal && len(defs) == 1 { + continue + } + if !impactTrackedPathAllowed(graph, roles, binding.Path) { + continue + } + key := canonicalImpactPath(graph, binding.Path) + for _, def := range defs { + if _, ok := flow.reachedDefs[def]; ok { + continue + } + if def.Writer < 0 { + tombstone := def + tombstone.Tombstone = true + if _, ok := flow.reachedDefs[tombstone]; ok { + continue + } + } + sawStablePrereq = true + external[key] = struct{}{} + defsSet := flow.externalDefs[reader] + if defsSet == nil { + defsSet = make(map[PathState]struct{}) + flow.externalDefs[reader] = defsSet + } + defsSet[def] = struct{}{} + } + } + if len(external) != 0 { + flow.externalReads[reader] = external + } + if sawReachedInput && sawStablePrereq { + join[reader] = true + } + for pred := range predecessors[reader] { + if join[pred] || hasJoinAncestor[pred] { + hasJoinAncestor[reader] = true + break + } + } + if join[reader] { + flow.joinActions = append(flow.joinActions, reader) + } + if join[reader] && !hasJoinAncestor[reader] { + flow.frontierActions = append(flow.frontierActions, reader) + } + } + return flow +} + +func shouldIgnoreNeedReadsForAction(action ExecNode, roles roleProjection, idx int) bool { + if !roleActionDeliveryOnly(roles, idx) { + return false + } + return action.Kind == KindCopy || action.Kind == KindInstall +} + +func seedReadersWithRoles(graph Graph, roles roleProjection, def PathState) []int { + if _, noise := roles.DefNoise[def]; noise { + return nil + } + readers := readersForDef(graph, def) + if len(readers) != 0 || !def.Tombstone { + return readers + } + baseline := PathState{Writer: def.Writer, Path: def.Path, Version: def.Version} + baselineReaders := readersForDef(graph, baseline) + filtered := make([]int, 0, len(baselineReaders)) + for _, reader := range baselineReaders { + if def.Writer >= 0 && reader <= def.Writer { + continue + } + filtered = append(filtered, reader) + } + return filtered +} + +func readersForDef(graph Graph, def PathState) []int { + if len(graph.ReadersByDef) != 0 { + if readers := graph.ReadersByDef[def]; len(readers) != 0 { + return readers + } + } + readers := make([]int, 0) + for reader, bindings := range graph.ActionReads { + found := false + for _, binding := range bindings { + for _, candidate := range binding.Defs { + if candidate != def { + continue + } + found = true + break + } + if found { + readers = append(readers, reader) + break + } + } + } + return readers +} + +func addImpactPathWithRoles(set map[string]struct{}, graph Graph, roles roleProjection, path string) bool { + if !impactPathAllowed(graph, roles, path) { + return false + } + key := canonicalImpactPath(graph, path) + if _, ok := set[key]; ok { + return false + } + set[key] = struct{}{} + return true +} + +func addImpactStateWithRoles(set map[pathStateKey]struct{}, graph Graph, roles roleProjection, def PathState) { + if !impactTrackedPathAllowed(graph, roles, def.Path) { + return + } + set[pathStateKey{ + path: canonicalImpactPath(graph, def.Path), + tombstone: def.Tombstone, + missing: def.Missing, + }] = struct{}{} +} + +func wavefrontActionEquivalent(base, probe Graph, baseRoles, probeRoles roleProjection, baseIdx, probeIdx int, evidence *impactEvidence) bool { + changed := map[string]bool(nil) + if evidence != nil { + changed = evidence.changed + } + return wavefrontActionEquivalentWithChanged( + base, + probe, + baseRoles, + probeRoles, + baseIdx, + probeIdx, + changed, + ) +} + +func behaviorActionSignature(action ExecNode) string { + return action.ActionKey + "\x1f" + action.StructureKey +} diff --git a/internal/trace/ssa/impact_wavefront_test.go b/internal/trace/ssa/impact_wavefront_test.go new file mode 100644 index 0000000..9b3e4fe --- /dev/null +++ b/internal/trace/ssa/impact_wavefront_test.go @@ -0,0 +1,218 @@ +package ssa + +import ( + "strings" + "testing" + + "github.com/goplus/llar/internal/trace" +) + +func TestWavefrontDiffMarksDuplicateReadyCandidatesAmbiguous(t *testing.T) { + baseRecords := []trace.Record{ + record([]string{"touch", "build/generated.stamp"}, "/tmp/work", nil, []string{"/tmp/work/build/generated.stamp"}), + record([]string{"touch", "build/generated.stamp"}, "/tmp/work", nil, []string{"/tmp/work/build/generated.stamp"}), + } + probeRecords := []trace.Record{ + record([]string{"touch", "build/generated.stamp"}, "/tmp/work", nil, []string{"/tmp/work/build/generated.stamp"}), + } + + base := BuildGraph(BuildInput{Records: baseRecords}) + probe := BuildGraph(BuildInput{Records: probeRecords}) + baseRoles := ProjectRoles(base) + probeRoles := ProjectRoles(probe) + path := normalizePath("/tmp/work/build/generated.stamp") + + diff := WavefrontDiffWithEvidence(base, probe, baseRoles, probeRoles, &ImpactEvidence{ + Changed: map[string]bool{path: false}, + }) + if !diff.Ambiguous { + t.Fatalf("WavefrontDiffWithEvidence().Ambiguous = false, want true") + } + if diff.Matched != 0 { + t.Fatalf("WavefrontDiffWithEvidence().Matched = %d, want 0", diff.Matched) + } + if len(diff.Pairs) != 0 { + t.Fatalf("WavefrontDiffWithEvidence().Pairs = %v, want no forced pairing", diff.Pairs) + } + if len(diff.ProbeClass) != 1 { + t.Fatalf("len(WavefrontDiffWithEvidence().ProbeClass) = %d, want 1", len(diff.ProbeClass)) + } + if diff.ProbeClass[0] != WavefrontProbeUnknown { + t.Fatalf("WavefrontDiffWithEvidence().ProbeClass[0] = %v, want %v", diff.ProbeClass[0], WavefrontProbeUnknown) + } + + result := AnalyzeWithEvidence(AnalysisInput{ + Base: AnalysisSideInput{Records: baseRecords}, + Probe: AnalysisSideInput{Records: probeRecords}, + }, &ImpactEvidence{ + Changed: map[string]bool{path: false}, + }) + if !result.Profile.Ambiguous { + t.Fatalf("AnalyzeWithEvidence().Profile.Ambiguous = false, want true") + } +} + +func TestAnalyzeKeepsBaselineSharedStablePrereqInNeed(t *testing.T) { + baseRecords := []trace.Record{ + record([]string{"gen", "flags.in", "-o", "build/flags.txt"}, "/tmp/work", []string{"/tmp/work/flags.in"}, []string{"/tmp/work/build/flags.txt"}), + record( + []string{"cc", "-c", "main.c", "-include", "common.h", "build/flags.txt", "-o", "build/main.o"}, + "/tmp/work", + []string{"/tmp/work/main.c", "/tmp/work/common.h", "/tmp/work/build/flags.txt"}, + []string{"/tmp/work/build/main.o"}, + ), + } + probeRecords := []trace.Record{ + record([]string{"gen", "--variant=A", "flags.in", "-o", "build/flags.txt"}, "/tmp/work", []string{"/tmp/work/flags.in"}, []string{"/tmp/work/build/flags.txt"}), + record( + []string{"cc", "-c", "main.c", "-include", "common.h", "build/flags.txt", "-o", "build/main.o"}, + "/tmp/work", + []string{"/tmp/work/main.c", "/tmp/work/common.h", "/tmp/work/build/flags.txt"}, + []string{"/tmp/work/build/main.o"}, + ), + } + + result := Analyze(AnalysisInput{ + Base: AnalysisSideInput{Records: baseRecords}, + Probe: AnalysisSideInput{Records: probeRecords}, + }) + + if len(result.Debug.Wavefront.ProbeClass) != 2 { + t.Fatalf("len(result.Debug.Wavefront.ProbeClass) = %d, want 2", len(result.Debug.Wavefront.ProbeClass)) + } + if result.Debug.Wavefront.ProbeClass[0] != WavefrontProbeMutationRoot { + t.Fatalf("probe class[0] = %v, want %v", result.Debug.Wavefront.ProbeClass[0], WavefrontProbeMutationRoot) + } + if result.Debug.Wavefront.ProbeClass[1] != WavefrontProbeFlow { + t.Fatalf("probe class[1] = %v, want %v", result.Debug.Wavefront.ProbeClass[1], WavefrontProbeFlow) + } + + commonH := normalizePath("/tmp/work/common.h") + if _, ok := result.Profile.NeedPaths[commonH]; !ok { + t.Fatalf("NeedPaths missing shared stable prereq %q: %v", commonH, result.Profile.NeedPaths) + } + commonHState := ImpactStateKey{Path: commonH} + if _, ok := result.Profile.NeedStates[commonHState]; !ok { + t.Fatalf("NeedStates missing shared stable prereq state %+v: %v", commonHState, result.Profile.NeedStates) + } +} + +func TestAnalyzeWithEventsIgnoresConfigureSidecarsInImpact(t *testing.T) { + scope := trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/_build", + InstallRoot: "/tmp/work/install", + } + result := Analyze(AnalysisInput{ + Base: AnalysisSideInput{ + Events: traceoptionsEventTrace(false), + Scope: scope, + }, + Probe: AnalysisSideInput{ + Events: traceoptionsEventTrace(true), + Scope: scope, + }, + }) + + if _, ok := result.Profile.SeedWrites[normalizeScopeToken("/tmp/work/_build/trace_options.h", scope)]; !ok { + t.Fatalf("SeedWrites missing trace_options.h: %v", result.Profile.SeedWrites) + } + if _, ok := result.Profile.SlicePaths[normalizeScopeToken("/tmp/work/_build/libtracecore.a", scope)]; !ok { + t.Fatalf("SlicePaths missing libtracecore.a propagation: %v", result.Profile.SlicePaths) + } + for _, path := range []string{ + "/tmp/work/_build/CMakeFiles/pkgRedirects", + "/tmp/work/_build/CMakeFiles/CMakeScratch/TryCompile-doc/CMakeFiles/pkgRedirects", + "/tmp/work/_build/cmake_install.cmake", + } { + key := normalizeScopeToken(path, scope) + if _, ok := result.Profile.SeedWrites[key]; ok { + t.Fatalf("SeedWrites unexpectedly contains configure sidecar %q: %v", key, result.Profile.SeedWrites) + } + if _, ok := result.Profile.SlicePaths[key]; ok { + t.Fatalf("SlicePaths unexpectedly contains configure sidecar %q: %v", key, result.Profile.SlicePaths) + } + if _, ok := result.Profile.NeedPaths[key]; ok { + t.Fatalf("NeedPaths unexpectedly contains configure sidecar %q: %v", key, result.Profile.NeedPaths) + } + } +} + +func TestAnalyzeWithEventsIgnoresArchiveTempSidecarsInImpact(t *testing.T) { + scope := trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/_build", + InstallRoot: "/tmp/work/install", + } + result := Analyze(AnalysisInput{ + Base: AnalysisSideInput{ + Events: traceoptionsMatrixEventTrace(false, false, false), + Scope: scope, + }, + Probe: AnalysisSideInput{ + Events: traceoptionsMatrixEventTrace(false, false, true), + Scope: scope, + }, + }) + + for _, path := range []string{ + "/tmp/work/_build/stNjnHgT", + "/tmp/work/_build/stvgaB7q", + "/tmp/work/_build/stNMeD5X", + "/tmp/work/_build/stdwbVyX", + } { + key := normalizeScopeToken(path, scope) + if _, ok := result.Profile.SeedWrites[key]; ok { + t.Fatalf("SeedWrites unexpectedly contains %q: %v", key, result.Profile.SeedWrites) + } + if _, ok := result.Profile.NeedPaths[key]; ok { + t.Fatalf("NeedPaths unexpectedly contains %q: %v", key, result.Profile.NeedPaths) + } + if _, ok := result.Profile.SlicePaths[key]; ok { + t.Fatalf("SlicePaths unexpectedly contains %q: %v", key, result.Profile.SlicePaths) + } + } +} + +func TestAnalyzeWithEventsIgnoresTryCompileProbeArtifactsInImpact(t *testing.T) { + scope := trace.Scope{ + SourceRoot: "/tmp/work", + BuildRoot: "/tmp/work/_build", + InstallRoot: "/tmp/work/install", + } + result := Analyze(AnalysisInput{ + Base: AnalysisSideInput{ + Events: traceoptionsTryCompileProbeEventTrace(false, "8L498d", "cmTC_10eef"), + Scope: scope, + }, + Probe: AnalysisSideInput{ + Events: traceoptionsTryCompileProbeEventTrace(true, "T24hDi", "cmTC_42d75"), + Scope: scope, + }, + }) + + if _, ok := result.Profile.SeedWrites[normalizeScopeToken("/tmp/work/_build/trace_options.h", scope)]; !ok { + t.Fatalf("SeedWrites missing trace_options.h: %v", result.Profile.SeedWrites) + } + if _, ok := result.Profile.SlicePaths[normalizeScopeToken("/tmp/work/_build/libtracecore.a", scope)]; !ok { + t.Fatalf("SlicePaths missing libtracecore.a propagation: %v", result.Profile.SlicePaths) + } + hasProbeKey := func(key string) bool { + return strings.Contains(key, "TryCompile-") || strings.Contains(key, "cmTC_") || strings.Contains(key, ".cmake/api/v1/reply") + } + for key := range result.Profile.SeedWrites { + if hasProbeKey(key) { + t.Fatalf("SeedWrites unexpectedly contains try-compile probe artifact %q: %v", key, result.Profile.SeedWrites) + } + } + for key := range result.Profile.SlicePaths { + if hasProbeKey(key) { + t.Fatalf("SlicePaths unexpectedly contains try-compile probe artifact %q: %v", key, result.Profile.SlicePaths) + } + } + for key := range result.Profile.NeedPaths { + if hasProbeKey(key) { + t.Fatalf("NeedPaths unexpectedly contains try-compile probe artifact %q: %v", key, result.Profile.NeedPaths) + } + } +} diff --git a/internal/trace/ssa/normalize.go b/internal/trace/ssa/normalize.go new file mode 100644 index 0000000..9cd99bd --- /dev/null +++ b/internal/trace/ssa/normalize.go @@ -0,0 +1,600 @@ +package ssa + +import ( + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/goplus/llar/internal/trace" +) + +var ( + reTmpUnix = regexp.MustCompile(`^/tmp/[^/]+`) + reTmpMac = regexp.MustCompile(`^/var/folders/[^/]+/[^/]+/[^/]+`) +) + +type normalizedRecord struct { + pid int64 + parentPID int64 + argv []string + cwd string + env []string + inputs []string + readMisses []string + changes []string + deletions []string + inputOrigin map[string]string +} + +func observationFromRecords(records []trace.Record, scope trace.Scope, inputDigests map[string]string) observation { + normalized := make([]normalizedRecord, 0, len(records)) + for _, record := range records { + normalized = append(normalized, normalizeRecordWithFacts(record, nil, nil)) + } + return observationFromNormalized(normalized, scope, inputDigests) +} + +func observationFromEvents(events []trace.Event, fallbackRecords []trace.Record, scope trace.Scope, inputDigests map[string]string) observation { + detailed := recordsFromEventsDetailed(events, fallbackRecords) + normalized := make([]normalizedRecord, 0, len(detailed)) + for _, record := range detailed { + normalized = append(normalized, normalizeRecordWithFacts(record.record, record.deletions, record.readMisses)) + } + return observationFromNormalized(normalized, scope, inputDigests) +} + +func observationFromNormalized(records []normalizedRecord, scope trace.Scope, inputDigests map[string]string) observation { + directories := inferDirectoryLikePaths(records) + nodes := make([]ExecNode, 0, len(records)) + for _, record := range records { + filtered := filterDirectoryPaths(record, directories) + nodes = append(nodes, buildExecNode(filtered, scope, inputDigests)) + } + + deps := make([][]ExecEdge, len(nodes)) + paths := make(map[string]PathInfo) + lastWriter := make(map[string]int) + for i, node := range nodes { + for _, entry := range node.Env { + path := envStatePathFromEntry(entry) + if path == "" { + continue + } + facts := paths[path] + facts.Path = path + facts.Readers = append(facts.Readers, i) + paths[path] = facts + } + for _, read := range node.Reads { + facts := paths[read] + facts.Path = read + facts.Readers = append(facts.Readers, i) + paths[read] = facts + } + for _, miss := range node.ReadMisses { + facts := paths[miss] + facts.Path = miss + facts.Readers = append(facts.Readers, i) + paths[miss] = facts + } + for _, write := range node.Writes { + facts := paths[write] + facts.Path = write + facts.Writers = append(facts.Writers, i) + paths[write] = facts + } + for _, read := range node.Reads { + writer, ok := lastWriter[read] + if !ok { + continue + } + deps[writer] = append(deps[writer], ExecEdge{From: writer, To: i, Path: read}) + } + for _, write := range node.Writes { + lastWriter[write] = i + } + } + + parent := make([]int, len(nodes)) + for i := range parent { + parent[i] = -1 + } + lastByPID := make(map[int64]int, len(nodes)) + for i, node := range nodes { + if node.ParentPID != 0 { + if idx, ok := lastByPID[node.ParentPID]; ok { + parent[i] = idx + } + } + if node.PID != 0 { + lastByPID[node.PID] = i + } + } + + return observation{ + Nodes: nodes, + Parent: parent, + Paths: paths, + Deps: deps, + } +} + +type eventRecord struct { + record trace.Record + deletions []string + readMisses []string +} + +type eventProcState struct { + parentPID int64 + cwd string + current *eventRecord +} + +type rawExecKey struct { + pid int64 + cwd string + argv string +} + +func recordsFromEventsDetailed(events []trace.Event, fallbackRecords []trace.Record) []eventRecord { + states := map[int64]*eventProcState{} + ordered := make([]*eventRecord, 0, len(events)) + rawRecords := indexRawExecRecords(fallbackRecords) + + stateOf := func(pid int64) *eventProcState { + if state, ok := states[pid]; ok { + return state + } + state := &eventProcState{} + states[pid] = state + return state + } + + for _, event := range events { + if event.PID == 0 { + continue + } + state := stateOf(event.PID) + if event.ParentPID != 0 && state.parentPID == 0 { + state.parentPID = event.ParentPID + } + switch event.Kind { + case trace.EventClone: + if event.ChildPID == 0 { + continue + } + child := stateOf(event.ChildPID) + if child.parentPID == 0 { + child.parentPID = event.PID + } + if child.cwd == "" { + child.cwd = firstNonEmpty(event.Cwd, state.cwd) + } + case trace.EventChdir: + state.cwd = event.Path + case trace.EventExec: + cwd := firstNonEmpty(event.Cwd, state.cwd) + rawRecord, hasRawRecord := consumeRawExecRecord(rawRecords, event.PID, cwd, event.Argv) + parentPID := firstNonZero(event.ParentPID, state.parentPID) + if hasRawRecord { + parentPID = firstNonZero(parentPID, rawRecord.ParentPID) + } + if parentPID != 0 { + if parent, ok := states[parentPID]; ok && parent.current != nil && shouldCollapseExecIntoParent(parent.current.record.Argv, event.Argv) { + state.parentPID = parentPID + state.cwd = cwd + if hasRawRecord { + mergeFallbackExecRecord(&parent.current.record, rawRecord) + } + state.current = parent.current + continue + } + } + rec := &eventRecord{ + record: trace.Record{ + PID: event.PID, + ParentPID: parentPID, + Argv: slices.Clone(event.Argv), + Cwd: cwd, + }, + } + if hasRawRecord { + mergeFallbackExecRecord(&rec.record, rawRecord) + } + state.parentPID = rec.record.ParentPID + state.cwd = cwd + state.current = rec + ordered = append(ordered, rec) + case trace.EventRead: + if state.current == nil { + continue + } + path := event.Path + if path == "" || slices.Contains(state.current.record.Inputs, path) { + continue + } + state.current.record.Inputs = append(state.current.record.Inputs, path) + case trace.EventReadMiss: + if state.current == nil { + continue + } + path := event.Path + if path == "" || slices.Contains(state.current.readMisses, path) { + continue + } + state.current.readMisses = append(state.current.readMisses, path) + case trace.EventWrite: + if state.current == nil { + continue + } + path := event.Path + if path == "" || slices.Contains(state.current.record.Changes, path) { + continue + } + state.current.record.Changes = append(state.current.record.Changes, path) + case trace.EventRename: + if state.current == nil { + continue + } + for _, path := range []string{event.RelatedPath, event.Path} { + if path == "" || slices.Contains(state.current.record.Changes, path) { + continue + } + state.current.record.Changes = append(state.current.record.Changes, path) + } + if event.RelatedPath != "" && !slices.Contains(state.current.deletions, event.RelatedPath) { + state.current.deletions = append(state.current.deletions, event.RelatedPath) + } + case trace.EventUnlink, trace.EventMkdir, trace.EventSymlink: + if state.current == nil { + continue + } + path := event.Path + if path == "" || slices.Contains(state.current.record.Changes, path) { + continue + } + state.current.record.Changes = append(state.current.record.Changes, path) + if event.Kind == trace.EventUnlink && !slices.Contains(state.current.deletions, path) { + state.current.deletions = append(state.current.deletions, path) + } + } + } + + out := make([]eventRecord, 0, len(ordered)) + for _, rec := range ordered { + if rec == nil || len(rec.record.Argv) == 0 { + continue + } + out = append(out, *rec) + } + return out +} + +func indexRawExecRecords(records []trace.Record) map[rawExecKey][]trace.Record { + if len(records) == 0 { + return nil + } + byKey := make(map[rawExecKey][]trace.Record, len(records)) + for _, record := range records { + if len(record.Argv) == 0 || record.PID == 0 { + continue + } + key := rawExecKey{ + pid: record.PID, + cwd: normalizePath(record.Cwd), + argv: strings.Join(record.Argv, "\x1f"), + } + byKey[key] = append(byKey[key], record) + } + return byKey +} + +func consumeRawExecRecord(records map[rawExecKey][]trace.Record, pid int64, cwd string, argv []string) (trace.Record, bool) { + if len(records) == 0 || pid == 0 || len(argv) == 0 { + return trace.Record{}, false + } + key := rawExecKey{ + pid: pid, + cwd: normalizePath(cwd), + argv: strings.Join(argv, "\x1f"), + } + queue := records[key] + if len(queue) == 0 { + return trace.Record{}, false + } + record := queue[0] + if len(queue) == 1 { + delete(records, key) + } else { + records[key] = queue[1:] + } + return record, true +} + +func mergeFallbackExecRecord(dst *trace.Record, fallback trace.Record) { + if dst == nil { + return + } + if dst.ParentPID == 0 { + dst.ParentPID = fallback.ParentPID + } + if dst.Cwd == "" { + dst.Cwd = fallback.Cwd + } + if len(dst.Env) == 0 && len(fallback.Env) != 0 { + dst.Env = slices.Clone(fallback.Env) + } + for _, path := range fallback.Inputs { + if path == "" || slices.Contains(dst.Inputs, path) { + continue + } + dst.Inputs = append(dst.Inputs, path) + } + for _, path := range fallback.Changes { + if path == "" || slices.Contains(dst.Changes, path) { + continue + } + dst.Changes = append(dst.Changes, path) + } +} + +func shouldCollapseExecIntoParent(parentArgv, childArgv []string) bool { + if !isCompilerDriverArgv(parentArgv) || len(childArgv) == 0 { + return false + } + switch filepath.Base(childArgv[0]) { + case "cc1", "cc1plus", "as": + return true + default: + return false + } +} + +func isCompilerDriverArgv(argv []string) bool { + if len(argv) == 0 { + return false + } + switch filepath.Base(argv[0]) { + case "cc", "c++", "gcc", "g++", "clang", "clang++": + return true + default: + return false + } +} + +func normalizeRecordWithFacts(record trace.Record, deletions, readMisses []string) normalizedRecord { + normalizer := resolveNormalizer(record) + argv, cwd, inputs, changes := normalizer.normalize(record) + inputOrigin := make(map[string]string, len(record.Inputs)) + for _, path := range record.Inputs { + normalized := normalizePath(path) + if normalized == "" { + continue + } + if _, ok := inputOrigin[normalized]; !ok { + inputOrigin[normalized] = path + } + } + inputs = uniqueSorted(inputs) + normalizedMisses := make([]string, 0, len(readMisses)) + for _, path := range readMisses { + normalizedMisses = append(normalizedMisses, normalizePath(path)) + } + normalizedMisses = uniqueSorted(normalizedMisses) + changes = uniqueSorted(changes) + normalizedDeletes := make([]string, 0, len(deletions)) + for _, path := range deletions { + normalizedDeletes = append(normalizedDeletes, normalizePath(path)) + } + normalizedDeletes = uniqueSorted(normalizedDeletes) + return normalizedRecord{ + pid: record.PID, + parentPID: record.ParentPID, + argv: argv, + cwd: cwd, + env: slices.Clone(record.Env), + inputs: inputs, + readMisses: normalizedMisses, + changes: changes, + deletions: normalizedDeletes, + inputOrigin: inputOrigin, + } +} + +type recordNormalizer interface { + match(trace.Record) bool + normalize(trace.Record) ([]string, string, []string, []string) +} + +func resolveNormalizer(record trace.Record) recordNormalizer { + for _, normalizer := range []recordNormalizer{ + ccNormalizer{}, + cmakeNormalizer{}, + pythonNormalizer{}, + goNormalizer{}, + genericNormalizer{}, + } { + if normalizer.match(record) { + return normalizer + } + } + return genericNormalizer{} +} + +type genericNormalizer struct{} + +func (genericNormalizer) match(trace.Record) bool { return true } + +func (genericNormalizer) normalize(record trace.Record) ([]string, string, []string, []string) { + argv := make([]string, 0, len(record.Argv)) + for _, arg := range record.Argv { + argv = append(argv, strings.ReplaceAll(normalizePath(arg), `\`, `/`)) + } + inputs := make([]string, 0, len(record.Inputs)) + for _, path := range record.Inputs { + inputs = append(inputs, normalizePath(path)) + } + changes := make([]string, 0, len(record.Changes)) + for _, path := range record.Changes { + changes = append(changes, normalizePath(path)) + } + return argv, normalizePath(record.Cwd), inputs, changes +} + +type ccNormalizer struct{ genericNormalizer } + +func (ccNormalizer) match(record trace.Record) bool { + tool := "" + if len(record.Argv) > 0 { + tool = filepath.Base(record.Argv[0]) + } + switch tool { + case "cc", "c++", "gcc", "g++", "clang", "clang++", "ld", "ar": + return true + default: + return false + } +} + +type cmakeNormalizer struct{ genericNormalizer } + +func (cmakeNormalizer) match(record trace.Record) bool { + tool := "" + if len(record.Argv) > 0 { + tool = filepath.Base(record.Argv[0]) + } + return tool == "cmake" || tool == "ninja" || tool == "make" +} + +type pythonNormalizer struct{ genericNormalizer } + +func (pythonNormalizer) match(record trace.Record) bool { + tool := "" + if len(record.Argv) > 0 { + tool = filepath.Base(record.Argv[0]) + } + if strings.HasPrefix(tool, "python") { + return true + } + return tool == "pip" +} + +type goNormalizer struct{ genericNormalizer } + +func (goNormalizer) match(record trace.Record) bool { + if len(record.Argv) == 0 { + return false + } + return filepath.Base(record.Argv[0]) == "go" +} + +func normalizePath(path string) string { + if path == "" { + return "" + } + path = filepath.ToSlash(path) + if strings.HasPrefix(path, "/tmp/$$TMP") || strings.HasPrefix(path, "/var/folders/$$TMP") { + return path + } + path = reTmpUnix.ReplaceAllString(path, "/tmp/$$$$TMP") + path = reTmpMac.ReplaceAllString(path, "/var/folders/$$$$TMP") + return path +} + +func inferDirectoryLikePaths(records []normalizedRecord) map[string]struct{} { + seen := make(map[string]struct{}) + paths := make([]string, 0) + add := func(path string) { + if path == "" { + return + } + if _, ok := seen[path]; ok { + return + } + seen[path] = struct{}{} + paths = append(paths, path) + } + for _, record := range records { + for _, path := range record.inputs { + add(path) + } + for _, path := range record.readMisses { + add(path) + } + for _, path := range record.changes { + add(path) + } + } + slices.Sort(paths) + directories := make(map[string]struct{}) + for i := 0; i < len(paths); i++ { + path := paths[i] + prefix := path + "/" + for j := i + 1; j < len(paths); j++ { + next := paths[j] + if !strings.HasPrefix(next, path) { + break + } + if strings.HasPrefix(next, prefix) { + directories[path] = struct{}{} + break + } + } + } + return directories +} + +func filterDirectoryPaths(record normalizedRecord, directories map[string]struct{}) normalizedRecord { + if len(directories) == 0 { + return record + } + filter := func(paths []string) []string { + if len(paths) == 0 { + return paths + } + filtered := make([]string, 0, len(paths)) + for _, path := range paths { + if _, ok := directories[path]; ok { + continue + } + filtered = append(filtered, path) + } + return filtered + } + record.inputs = filter(record.inputs) + record.readMisses = filter(record.readMisses) + record.changes = filter(record.changes) + return record +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func firstNonZero(values ...int64) int64 { + for _, value := range values { + if value != 0 { + return value + } + } + return 0 +} + +func uniqueSorted(values []string) []string { + out := slices.Clone(values) + for i := range out { + out[i] = strings.TrimSpace(out[i]) + } + out = slices.DeleteFunc(out, func(value string) bool { + return value == "" + }) + slices.Sort(out) + return slices.Compact(out) +} diff --git a/internal/trace/ssa/roles_analysis.go b/internal/trace/ssa/roles_analysis.go new file mode 100644 index 0000000..62af5fd --- /dev/null +++ b/internal/trace/ssa/roles_analysis.go @@ -0,0 +1,1515 @@ +package ssa + +import ( + "slices" + "strings" + + "github.com/goplus/llar/internal/trace" +) + +type roleProjection struct { + ActionNoise []bool + ActionDeliveryOnly []bool + DefNoise map[PathState]struct{} + ActionClass []actionRole + DefClass map[PathState]defRole +} + +type actionRole uint8 + +const ( + actionRoleMainline actionRole = iota + actionRoleTooling + actionRoleProbe + actionRoleDelivery +) + +type defRole uint8 + +const ( + defRoleMainline defRole = iota + defRoleTooling + defRoleProbe + defRoleDelivery +) + +func projectRoles(graph Graph) roleProjection { + projection := roleProjection{ + ActionNoise: make([]bool, len(graph.Actions)), + ActionDeliveryOnly: make([]bool, len(graph.Actions)), + DefNoise: make(map[PathState]struct{}), + ActionClass: make([]actionRole, len(graph.Actions)), + DefClass: make(map[PathState]defRole), + } + for idx := range graph.Actions { + projection.ActionDeliveryOnly[idx] = isDeliveryOnlyAction(graph, idx) + } + toolingFamily := classifyToolingFamily(graph) + toolingFamily = expandToolingFamily(graph, toolingFamily, projection.ActionDeliveryOnly) + toolingWorkspaceRoots := inferToolingWorkspaceRoots(graph, toolingFamily, projection.ActionDeliveryOnly) + nonEscapingToolingDefs := classifyNonEscapingToolingDefs(graph, toolingFamily, projection.ActionDeliveryOnly, toolingWorkspaceRoots) + mainlineVisibleDefs := inferMainlineVisibleDefs(graph, toolingFamily, toolingWorkspaceRoots, nonEscapingToolingDefs) + for idx := range graph.Actions { + if toolingFamily[idx] && !projection.ActionDeliveryOnly[idx] && !actionHasEscapingWrite(graph, nonEscapingToolingDefs, idx) { + projection.ActionNoise[idx] = true + } + if !projection.ActionNoise[idx] { + projection.ActionNoise[idx] = !projection.ActionDeliveryOnly[idx] && + !actionHasMainlineWrites(graph, toolingFamily, toolingWorkspaceRoots, nonEscapingToolingDefs, mainlineVisibleDefs, idx) && + actionTouchesOnlyToolingPaths(graph, toolingFamily, toolingWorkspaceRoots, nonEscapingToolingDefs, idx) + } + for _, def := range graph.ActionWrites[idx] { + if projection.ActionDeliveryOnly[idx] || projection.ActionNoise[idx] { + projection.DefNoise[def] = struct{}{} + continue + } + if _, ok := nonEscapingToolingDefs[def]; ok { + projection.DefNoise[def] = struct{}{} + continue + } + } + } + for def := range classifyNonEscapingSiblingDefs(graph, mainlineVisibleDefs, projection.ActionNoise, projection.ActionDeliveryOnly) { + projection.DefNoise[def] = struct{}{} + } + for idx := range graph.Actions { + switch { + case projection.ActionDeliveryOnly[idx]: + projection.ActionClass[idx] = actionRoleDelivery + case projection.ActionNoise[idx]: + if idx < len(toolingFamily) && toolingFamily[idx] { + projection.ActionClass[idx] = actionRoleTooling + } else { + projection.ActionClass[idx] = actionRoleProbe + } + default: + projection.ActionClass[idx] = actionRoleMainline + } + } + for idx, defs := range graph.ActionWrites { + for _, def := range defs { + class := defRoleMainline + switch { + case idx < len(projection.ActionDeliveryOnly) && projection.ActionDeliveryOnly[idx]: + class = defRoleDelivery + case hasProjectionDef(projection.DefNoise, def): + if idx < len(toolingFamily) && toolingFamily[idx] { + class = defRoleTooling + } else if pathLooksToolingForFamily(graph, toolingFamily, toolingWorkspaceRoots, def.Path) { + class = defRoleTooling + } else { + class = defRoleProbe + } + case pathLooksDelivery(graph, def.Path): + class = defRoleDelivery + } + projection.DefClass[def] = class + } + } + return projection +} + +func classifyNonEscapingSiblingDefs(graph Graph, mainlineVisibleDefs map[PathState]struct{}, actionNoise, actionDeliveryOnly []bool) map[PathState]struct{} { + out := make(map[PathState]struct{}) + for idx, defs := range graph.ActionWrites { + if idx < len(actionNoise) && actionNoise[idx] { + continue + } + if idx < len(actionDeliveryOnly) && actionDeliveryOnly[idx] { + continue + } + if len(defs) < 2 { + continue + } + escaping := make([]bool, len(defs)) + sawEscaping := false + sawIsolated := false + for i, def := range defs { + escaping[i] = mainlineDefEscapes(graph, mainlineVisibleDefs, actionNoise, actionDeliveryOnly, def) + if escaping[i] { + sawEscaping = true + } else { + sawIsolated = true + } + } + if !sawEscaping || !sawIsolated { + continue + } + for i, def := range defs { + if escaping[i] { + continue + } + out[def] = struct{}{} + } + } + return out +} + +func inferMainlineVisibleDefs(graph Graph, toolingFamily []bool, toolingWorkspaceRoots map[string]struct{}, nonEscapingToolingDefs map[PathState]struct{}) map[PathState]struct{} { + sinks := collectHardSinkDefs(graph, nonEscapingToolingDefs) + if len(sinks) == 0 { + sinks = collectDerivedSinkDefs(graph, toolingFamily, toolingWorkspaceRoots, nonEscapingToolingDefs) + } + if len(sinks) == 0 { + return nil + } + order := newCausalOrder(graph) + visible := make(map[PathState]struct{}, len(sinks)) + queue := make([]PathState, 0, len(sinks)) + for def := range sinks { + visible[def] = struct{}{} + queue = append(queue, def) + } + for len(queue) > 0 { + def := queue[len(queue)-1] + queue = queue[:len(queue)-1] + writer := def.Writer + if writer < 0 || writer >= len(graph.Actions) { + continue + } + for _, read := range graph.ActionReads[writer] { + for _, input := range read.Defs { + if !defEligibleForMainlineClosure(graph, nonEscapingToolingDefs, input) { + continue + } + if _, ok := visible[input]; ok { + continue + } + visible[input] = struct{}{} + queue = append(queue, input) + } + } + for _, input := range actionExecPathDefs(graph, &order, writer) { + if !defEligibleForMainlineClosure(graph, nonEscapingToolingDefs, input) { + continue + } + if _, ok := visible[input]; ok { + continue + } + visible[input] = struct{}{} + queue = append(queue, input) + } + } + return visible +} + +func collectHardSinkDefs(graph Graph, nonEscapingToolingDefs map[PathState]struct{}) map[PathState]struct{} { + out := make(map[PathState]struct{}) + for _, defs := range graph.ActionWrites { + for _, def := range defs { + if !isExplicitDeliveryPath(def.Path, graph.Scope) { + continue + } + if !defEligibleForMainlineClosure(graph, nonEscapingToolingDefs, def) { + continue + } + out[def] = struct{}{} + } + } + return out +} + +func collectDerivedSinkDefs(graph Graph, toolingFamily []bool, toolingWorkspaceRoots map[string]struct{}, nonEscapingToolingDefs map[PathState]struct{}) map[PathState]struct{} { + out := make(map[PathState]struct{}) + residualDescendants := makeResidualDescendantWriteIndex(graph, nonEscapingToolingDefs) + continues := make(map[PathState]bool) + for _, defs := range graph.ActionWrites { + for _, def := range defs { + if !defEligibleForMainlineClosure(graph, nonEscapingToolingDefs, def) { + continue + } + continues[def] = defHasResidualContinuation(graph, residualDescendants, nonEscapingToolingDefs, def) + } + } + for idx, defs := range graph.ActionWrites { + if idx < 0 || idx >= len(graph.Actions) { + continue + } + if actionTouchesOnlyToolingPaths(graph, toolingFamily, toolingWorkspaceRoots, nonEscapingToolingDefs, idx) { + continue + } + sawContinuingSibling := false + for _, def := range defs { + if continues[def] { + sawContinuingSibling = true + break + } + } + for _, def := range defs { + if !defEligibleForMainlineClosure(graph, nonEscapingToolingDefs, def) { + continue + } + if continues[def] { + continue + } + if sawContinuingSibling { + continue + } + out[def] = struct{}{} + } + } + return out +} + +func defEligibleForMainlineClosure(graph Graph, nonEscapingToolingDefs map[PathState]struct{}, def PathState) bool { + if def.Path == "" { + return false + } + if _, ok := nonEscapingToolingDefs[def]; ok { + return false + } + if def.Missing { + return false + } + if !pathWithinObservedScope(def.Path, graph.Scope) { + return false + } + if pathLooksDelivery(graph, def.Path) && !isExplicitDeliveryPath(def.Path, graph.Scope) { + return false + } + return true +} + +func makeResidualDescendantWriteIndex(graph Graph, nonEscapingToolingDefs map[PathState]struct{}) []bool { + children := buildActionChildren(graph.ParentAction, len(graph.Actions)) + memo := make([]uint8, len(graph.Actions)) + var visit func(int) bool + visit = func(idx int) bool { + if idx < 0 || idx >= len(graph.Actions) { + return false + } + switch memo[idx] { + case 1: + return false + case 2: + return true + } + if actionWritesResidualDef(graph, nonEscapingToolingDefs, idx) { + memo[idx] = 2 + return true + } + memo[idx] = 1 + for _, child := range children[idx] { + if visit(child) { + memo[idx] = 2 + return true + } + } + return false + } + out := make([]bool, len(graph.Actions)) + for idx := range graph.Actions { + out[idx] = visit(idx) + } + return out +} + +func buildActionChildren(parent []int, n int) [][]int { + children := make([][]int, n) + for idx, p := range parent { + if p < 0 || p >= n { + continue + } + children[p] = append(children[p], idx) + } + return children +} + +func actionWritesResidualDef(graph Graph, nonEscapingToolingDefs map[PathState]struct{}, idx int) bool { + if idx < 0 || idx >= len(graph.ActionWrites) { + return false + } + for _, def := range graph.ActionWrites[idx] { + if defEligibleForMainlineClosure(graph, nonEscapingToolingDefs, def) { + return true + } + } + return false +} + +func defHasResidualContinuation(graph Graph, residualDescendants []bool, nonEscapingToolingDefs map[PathState]struct{}, def PathState) bool { + for _, reader := range roleReadersForDef(graph, def) { + if reader < 0 || reader >= len(graph.Actions) { + continue + } + if actionHasContinuationWrite(graph, nonEscapingToolingDefs, reader) { + return true + } + if reader < len(residualDescendants) && residualDescendants[reader] { + return true + } + if actionReadsResidualExecPath(graph, nonEscapingToolingDefs, reader) { + return true + } + } + return false +} + +func actionHasContinuationWrite(graph Graph, nonEscapingToolingDefs map[PathState]struct{}, idx int) bool { + if idx < 0 || idx >= len(graph.ActionWrites) { + return false + } + for _, def := range graph.ActionWrites[idx] { + if _, ok := nonEscapingToolingDefs[def]; ok { + continue + } + return true + } + return false +} + +func actionReadsResidualExecPath(graph Graph, nonEscapingToolingDefs map[PathState]struct{}, idx int) bool { + if idx < 0 || idx >= len(graph.Actions) { + return false + } + path := normalizePath(graph.Actions[idx].ExecPath) + if path == "" { + return false + } + for _, def := range graph.DefsByPath[path] { + if defEligibleForMainlineClosure(graph, nonEscapingToolingDefs, def) { + return true + } + } + return false +} + +func actionExecPathDefs(graph Graph, order *causalOrder, idx int) []PathState { + if idx < 0 || idx >= len(graph.Actions) { + return nil + } + path := normalizePath(graph.Actions[idx].ExecPath) + if path == "" || !pathWithinObservedScope(path, graph.Scope) { + return nil + } + if defs := reachingDefsForRead(order, graph.DefsByPath[path], idx); len(defs) != 0 { + return defs + } + if initial, ok := graph.InitialDefs[path]; ok { + return []PathState{initial} + } + return nil +} + +func defBelongsToMainlineVisibleClosure(mainlineVisibleDefs map[PathState]struct{}, def PathState) bool { + _, ok := mainlineVisibleDefs[def] + return ok +} + +func hasMainlineVisibleClosure(mainlineVisibleDefs map[PathState]struct{}) bool { + return len(mainlineVisibleDefs) != 0 +} + +func mainlineDefEscapes(graph Graph, mainlineVisibleDefs map[PathState]struct{}, actionNoise, actionDeliveryOnly []bool, start PathState) bool { + if defBelongsToMainlineVisibleClosure(mainlineVisibleDefs, start) { + return true + } + seenDefs := map[PathState]struct{}{start: {}} + seenActions := make(map[int]struct{}) + queue := []PathState{start} + for len(queue) > 0 { + def := queue[len(queue)-1] + queue = queue[:len(queue)-1] + for _, reader := range roleReadersForDef(graph, def) { + if reader < 0 || reader >= len(graph.Actions) { + continue + } + if idx := reader; idx >= len(actionNoise) || !actionNoise[idx] { + if idx >= len(actionDeliveryOnly) || !actionDeliveryOnly[idx] { + if actionConsumesMainlineData(graph.Actions[idx]) { + return true + } + } else { + // Delivery-only consumers are observable sinks even when the + // install root is not explicitly scoped. + return true + } + } + if idx := reader; idx < len(actionDeliveryOnly) && actionDeliveryOnly[idx] { + continue + } + if _, ok := seenActions[reader]; ok { + continue + } + seenActions[reader] = struct{}{} + if reader >= len(graph.ActionWrites) { + continue + } + for _, next := range graph.ActionWrites[reader] { + if defBelongsToMainlineVisibleClosure(mainlineVisibleDefs, next) { + return true + } + if _, ok := seenDefs[next]; ok { + continue + } + seenDefs[next] = struct{}{} + queue = append(queue, next) + } + } + } + return false +} + +func hasProjectionDef(defs map[PathState]struct{}, def PathState) bool { + _, ok := defs[def] + return ok +} + +func classifyToolingFamily(graph Graph) []bool { + toolingFamily := inferToolingSeedActions(graph, classifyToolingSeedHints(graph)) + for { + changed := false + for idx := range graph.Actions { + if markProducedExecToolingFamily(graph, toolingFamily, idx) { + changed = true + } + if !toolingFamily[idx] && actionCopiesToolingInputs(graph, toolingFamily, idx) { + toolingFamily[idx] = true + changed = true + continue + } + if toolingFamily[idx] || !actionWritesConsumedOnlyByToolingFamily(graph, toolingFamily, idx) { + continue + } + toolingFamily[idx] = true + changed = true + } + if !changed { + return toolingFamily + } + } +} + +func classifyToolingSeedHints(graph Graph) []bool { + hints := make([]bool, len(graph.Actions)) + for idx, action := range graph.Actions { + if action.Kind == KindConfigure { + hints[idx] = true + } + } + return hints +} + +func inferToolingSeedActions(graph Graph, hints []bool) []bool { + seeds := make([]bool, len(graph.Actions)) + for { + changed := false + for idx := range graph.Actions { + if idx < len(seeds) && seeds[idx] { + continue + } + if !actionHasToolingSeedHint(graph, hints, seeds, idx) { + continue + } + if !actionHasToolingSeedEvidence(graph, hints, seeds, idx) { + continue + } + seeds[idx] = true + changed = true + } + if !changed { + return seeds + } + } +} + +func actionHasToolingSeedHint(graph Graph, hints, seeds []bool, idx int) bool { + if idx < 0 || idx >= len(graph.Actions) { + return false + } + if idx < len(hints) && hints[idx] { + return true + } + if actionLaunchedByToolingCandidate(graph.ParentAction, hints, seeds, idx) { + return true + } + return actionExecPathHasToolingCandidateWriter(graph, hints, seeds, idx) +} + +func actionHasToolingSeedEvidence(graph Graph, hints, seeds []bool, idx int) bool { + if idx < 0 || idx >= len(graph.Actions) { + return false + } + if actionExecPathHasToolingCandidateWriter(graph, hints, seeds, idx) { + return true + } + if actionWritesConsumedOnlyByToolingCandidates(graph, hints, seeds, idx) { + return true + } + if actionHasLocalToolingWorkspaceEvidence(graph, hints, seeds, idx) { + return true + } + if idx < len(hints) && hints[idx] && (actionWritesObservedOutputs(graph, idx) || actionHasChild(graph.ParentAction, idx)) { + return true + } + return false +} + +func actionLaunchedByToolingCandidate(parentAction []int, hints, seeds []bool, idx int) bool { + if idx < 0 || idx >= len(parentAction) { + return false + } + parent := parentAction[idx] + if parent < 0 { + return false + } + if parent < len(seeds) && seeds[parent] { + return true + } + return parent < len(hints) && hints[parent] +} + +func actionExecPathHasToolingCandidateWriter(graph Graph, hints, seeds []bool, idx int) bool { + if idx < 0 || idx >= len(graph.Actions) { + return false + } + execPath := normalizePath(graph.Actions[idx].ExecPath) + if execPath == "" { + return false + } + facts, ok := graph.Paths[execPath] + if !ok { + return false + } + for _, writer := range facts.Writers { + if writer < 0 { + continue + } + if writer < len(seeds) && seeds[writer] { + return true + } + if writer < len(hints) && hints[writer] { + return true + } + } + return false +} + +func actionWritesConsumedOnlyByToolingCandidates(graph Graph, hints, seeds []bool, idx int) bool { + if idx < 0 || idx >= len(graph.Actions) { + return false + } + sawCandidateConsumer := false + for _, path := range graph.Actions[idx].Writes { + facts, ok := graph.Paths[path] + if !ok { + return false + } + for _, reader := range facts.Readers { + if !actionIsToolingCandidate(hints, seeds, reader) { + return false + } + sawCandidateConsumer = true + } + for consumer, action := range graph.Actions { + if action.ExecPath != path { + continue + } + if !actionIsToolingCandidate(hints, seeds, consumer) { + return false + } + sawCandidateConsumer = true + } + } + return sawCandidateConsumer +} + +func actionHasLocalToolingWorkspaceEvidence(graph Graph, hints, seeds []bool, idx int) bool { + if idx < 0 || idx >= len(graph.Actions) { + return false + } + action := graph.Actions[idx] + cwd := normalizePath(action.Cwd) + scopedWorkspace := toolingWorkspaceRootCandidate(graph.Scope, cwd) + unscopedWorkspace := !scopedWorkspace && scopeRootsEmpty(graph.Scope) && actionLaunchedByToolingCandidate(graph.ParentAction, hints, seeds, idx) && cwd != "" + if !scopedWorkspace && !unscopedWorkspace { + return false + } + hasLocalTouch := false + for _, path := range action.Writes { + path = normalizePath(path) + if path == "" || !pathCountsForToolingEvidence(path, graph.Scope) { + continue + } + if isExplicitDeliveryPath(path, graph.Scope) || !pathWithinDir(path, cwd) { + return false + } + hasLocalTouch = true + } + if idx < len(graph.ActionReads) { + for _, read := range graph.ActionReads[idx] { + path := normalizePath(read.Path) + if path == "" || !pathCountsForToolingEvidence(path, graph.Scope) || strings.HasPrefix(path, envNamespacePrefix) { + continue + } + if pathWithinDir(path, cwd) { + hasLocalTouch = true + continue + } + if !readDefsAllToolingCandidates(read, hints, seeds) { + if unscopedWorkspace && unscopedExternalToolingReadAllowed(graph, idx, path) { + continue + } + return false + } + } + } + if hasLocalTouch { + return true + } + return actionHasChildWithinDir(graph, idx, cwd) +} + +func actionWritesObservedOutputs(graph Graph, idx int) bool { + if idx < 0 || idx >= len(graph.Actions) { + return false + } + for _, path := range graph.Actions[idx].Writes { + if pathCountsForToolingEvidence(path, graph.Scope) { + return true + } + } + return false +} + +func pathCountsForToolingEvidence(path string, scope trace.Scope) bool { + path = normalizePath(path) + if path == "" || strings.HasPrefix(path, envNamespacePrefix) { + return false + } + if pathWithinObservedScope(path, scope) { + return true + } + return scopeRootsEmpty(scope) +} + +func scopeRootsEmpty(scope trace.Scope) bool { + return scope.SourceRoot == "" && scope.BuildRoot == "" && scope.InstallRoot == "" && len(scope.KeepRoots) == 0 +} + +func actionHasChild(parentAction []int, idx int) bool { + for _, parent := range parentAction { + if parent == idx { + return true + } + } + return false +} + +func actionHasChildWithinDir(graph Graph, idx int, dir string) bool { + for child, parent := range graph.ParentAction { + if parent != idx || child < 0 || child >= len(graph.Actions) { + continue + } + if pathWithinDir(graph.Actions[child].Cwd, dir) { + return true + } + } + return false +} + +func unscopedExternalToolingReadAllowed(graph Graph, idx int, path string) bool { + if idx < 0 || idx >= len(graph.ParentAction) { + return false + } + parent := graph.ParentAction[idx] + if parent < 0 || parent >= len(graph.Actions) { + return false + } + parentCwd := normalizePath(graph.Actions[parent].Cwd) + if parentCwd == "" { + return false + } + return !pathWithinDir(path, parentCwd) +} + +func readDefsAllToolingCandidates(read Read, hints, seeds []bool) bool { + if len(read.Defs) == 0 { + return false + } + for _, def := range read.Defs { + if !actionIsToolingCandidate(hints, seeds, def.Writer) { + return false + } + } + return true +} + +func actionIsToolingCandidate(hints, seeds []bool, idx int) bool { + if idx < 0 { + return false + } + if idx < len(seeds) && seeds[idx] { + return true + } + return idx < len(hints) && hints[idx] +} + +func actionCopiesToolingInputs(graph Graph, toolingFamily []bool, idx int) bool { + if idx < 0 || idx >= len(graph.Actions) { + return false + } + action := graph.Actions[idx] + if action.Kind != KindCopy || len(action.Reads) == 0 || len(action.Writes) == 0 { + return false + } + for _, path := range action.Reads { + facts, ok := graph.Paths[path] + if !ok || !writersAllTooling(facts.Writers, toolingFamily) { + return false + } + } + return true +} + +func writersAllTooling(writers []int, toolingFamily []bool) bool { + if len(writers) == 0 { + return false + } + for _, writer := range writers { + if writer < 0 || writer >= len(toolingFamily) || !toolingFamily[writer] { + return false + } + } + return true +} + +func inferToolingWorkspaceRoots(graph Graph, toolingFamily, deliveryOnly []bool) map[string]struct{} { + roots := make(map[string]struct{}) + for idx := range graph.Actions { + root := inferToolingWorkspaceRoot(graph, toolingFamily, deliveryOnly, idx) + if root == "" { + continue + } + roots[root] = struct{}{} + } + return roots +} + +func inferToolingWorkspaceRoot(graph Graph, toolingFamily, deliveryOnly []bool, idx int) string { + if idx < 0 || idx >= len(graph.Actions) || idx >= len(toolingFamily) || !toolingFamily[idx] { + return "" + } + if idx < len(deliveryOnly) && deliveryOnly[idx] { + return "" + } + action := graph.Actions[idx] + cwd := normalizePath(action.Cwd) + if !toolingWorkspaceRootCandidate(graph.Scope, cwd) { + return "" + } + hasLocalWrite := false + hasToolingProducedLocalPath := false + for _, path := range action.Writes { + path = normalizePath(path) + if path == "" || !pathWithinObservedScope(path, graph.Scope) { + continue + } + if isExplicitDeliveryPath(path, graph.Scope) || !pathWithinDir(path, cwd) { + return "" + } + hasLocalWrite = true + if pathHasToolingWriter(graph, toolingFamily, path) { + hasToolingProducedLocalPath = true + } + } + if !hasLocalWrite || !hasToolingProducedLocalPath { + return "" + } + if idx < len(graph.ActionReads) { + for _, read := range graph.ActionReads[idx] { + path := normalizePath(read.Path) + if path == "" || !pathWithinObservedScope(path, graph.Scope) || strings.HasPrefix(path, envNamespacePrefix) { + continue + } + if pathWithinDir(path, cwd) { + continue + } + if !readDefsAllToolingWriters(read, toolingFamily) { + return "" + } + } + } + return cwd +} + +func toolingWorkspaceRootCandidate(scope trace.Scope, cwd string) bool { + cwd = normalizePath(cwd) + if cwd == "" || !pathWithinObservedScope(cwd, scope) { + return false + } + buildRoot := strings.TrimSuffix(normalizePath(scope.BuildRoot), "/") + if buildRoot == "" || cwd == buildRoot || !strings.HasPrefix(cwd, buildRoot+"/") { + return false + } + installRoot := strings.TrimSuffix(normalizePath(scope.InstallRoot), "/") + if installRoot != "" && (cwd == installRoot || strings.HasPrefix(cwd, installRoot+"/")) { + return false + } + return true +} + +func pathWithinDir(path, dir string) bool { + path = normalizePath(path) + dir = strings.TrimSuffix(normalizePath(dir), "/") + if path == "" || dir == "" { + return false + } + return path == dir || strings.HasPrefix(path, dir+"/") +} + +func pathBelongsToToolingWorkspace(workspaceRoots map[string]struct{}, path string) bool { + path = normalizePath(path) + if path == "" { + return false + } + for root := range workspaceRoots { + if pathWithinDir(path, root) { + return true + } + } + return false +} + +func pathHasToolingWriter(graph Graph, toolingFamily []bool, path string) bool { + facts, ok := graph.Paths[normalizePath(path)] + if !ok { + return false + } + for _, writer := range facts.Writers { + if writer >= 0 && writer < len(toolingFamily) && toolingFamily[writer] { + return true + } + } + return false +} + +func readDefsAllToolingWriters(read Read, toolingFamily []bool) bool { + if len(read.Defs) == 0 { + return false + } + for _, def := range read.Defs { + if def.Writer < 0 || def.Writer >= len(toolingFamily) || !toolingFamily[def.Writer] { + return false + } + } + return true +} + +func expandToolingFamily(graph Graph, toolingFamily, deliveryOnly []bool) []bool { + expanded := slices.Clone(toolingFamily) + for { + changed := false + toolingDefs := collectToolingFamilyDefs(graph, expanded) + for idx := range graph.Actions { + if idx >= len(expanded) || expanded[idx] { + continue + } + if idx < len(deliveryOnly) && deliveryOnly[idx] { + continue + } + if !actionConsumesOnlyToolingDefs(graph, toolingDefs, expanded, idx) && + !actionWritesConsumedOnlyByToolingFamily(graph, expanded, idx) && + !actionWritesToolingExecPath(graph, expanded, idx) { + continue + } + expanded[idx] = true + changed = true + } + if !changed { + return expanded + } + } +} + +func markProducedExecToolingFamily(graph Graph, toolingFamily []bool, idx int) bool { + if idx < 0 || idx >= len(graph.Actions) { + return false + } + execPath := normalizePath(graph.Actions[idx].ExecPath) + if execPath == "" { + return false + } + facts, ok := graph.Paths[execPath] + if !ok || len(facts.Writers) == 0 { + return false + } + changed := false + if !toolingFamily[idx] { + toolingFamily[idx] = true + changed = true + } + for _, writer := range facts.Writers { + if writer < 0 || writer >= len(toolingFamily) || toolingFamily[writer] { + continue + } + toolingFamily[writer] = true + changed = true + } + return changed +} + +func actionWritesConsumedOnlyByToolingFamily(graph Graph, toolingFamily []bool, idx int) bool { + if idx < 0 || idx >= len(graph.Actions) { + return false + } + sawToolingConsumer := false + for _, path := range graph.Actions[idx].Writes { + facts, ok := graph.Paths[path] + if !ok { + return false + } + for _, reader := range facts.Readers { + if reader < 0 || reader >= len(toolingFamily) || !toolingFamily[reader] { + return false + } + sawToolingConsumer = true + } + for consumer, action := range graph.Actions { + if consumer < 0 || consumer >= len(toolingFamily) || !toolingFamily[consumer] { + continue + } + if action.ExecPath != path { + continue + } + sawToolingConsumer = true + } + } + return sawToolingConsumer +} + +func actionWritesToolingExecPath(graph Graph, toolingFamily []bool, idx int) bool { + if idx < 0 || idx >= len(graph.Actions) { + return false + } + for _, path := range graph.Actions[idx].Writes { + for consumer, action := range graph.Actions { + if consumer < 0 || consumer >= len(toolingFamily) || !toolingFamily[consumer] { + continue + } + if action.ExecPath == path { + return true + } + } + } + return false +} + +func collectToolingFamilyDefs(graph Graph, toolingFamily []bool) map[PathState]struct{} { + out := make(map[PathState]struct{}) + for idx := range toolingFamily { + if !toolingFamily[idx] || idx >= len(graph.ActionWrites) { + continue + } + for _, def := range graph.ActionWrites[idx] { + out[def] = struct{}{} + } + } + return out +} + +func actionConsumesOnlyToolingDefs(graph Graph, toolingDefs map[PathState]struct{}, toolingFamily []bool, idx int) bool { + if idx < 0 || idx >= len(graph.ActionReads) { + return false + } + sawToolingRead := false + for _, read := range graph.ActionReads[idx] { + if len(read.Defs) == 0 { + if pathLooksToolingForFamily(graph, toolingFamily, nil, read.Path) { + sawToolingRead = true + continue + } + return false + } + for _, def := range read.Defs { + if _, ok := toolingDefs[def]; ok { + sawToolingRead = true + continue + } + return false + } + } + return sawToolingRead +} + +func classifyNonEscapingToolingDefs(graph Graph, toolingFamily, deliveryOnly []bool, toolingWorkspaceRoots map[string]struct{}) map[PathState]struct{} { + candidates := make(map[PathState]struct{}) + for idx := range graph.Actions { + if idx >= len(toolingFamily) || !toolingFamily[idx] || idx >= len(graph.ActionWrites) { + continue + } + for _, def := range graph.ActionWrites[idx] { + candidates[def] = struct{}{} + } + } + for { + escaping := make([]PathState, 0) + for def := range candidates { + if toolingDefEscapes(graph, toolingFamily, deliveryOnly, toolingWorkspaceRoots, candidates, def) { + escaping = append(escaping, def) + } + } + if len(escaping) == 0 { + return candidates + } + for _, def := range escaping { + delete(candidates, def) + } + } +} + +func toolingDefEscapes(graph Graph, toolingFamily, deliveryOnly []bool, toolingWorkspaceRoots map[string]struct{}, candidates map[PathState]struct{}, start PathState) bool { + workspaceMember := pathBelongsToToolingWorkspace(toolingWorkspaceRoots, start.Path) + if isExplicitDeliveryPath(start.Path, graph.Scope) { + return true + } + if !workspaceMember && start.Writer >= 0 && actionCrossesMixedConsumerFrontier(graph, toolingFamily, candidates, toolingWorkspaceRoots, start.Writer) { + return true + } + seenDefs := map[PathState]struct{}{start: {}} + seenActions := make(map[int]struct{}) + queue := []PathState{start} + for len(queue) > 0 { + def := queue[len(queue)-1] + queue = queue[:len(queue)-1] + for _, reader := range roleReadersForDef(graph, def) { + if reader < 0 || reader >= len(graph.Actions) { + continue + } + if reader >= len(toolingFamily) || !toolingFamily[reader] { + return true + } + if !workspaceMember && actionCrossesMixedConsumerFrontier(graph, toolingFamily, candidates, toolingWorkspaceRoots, reader) { + return true + } + if _, ok := seenActions[reader]; ok { + continue + } + seenActions[reader] = struct{}{} + if !workspaceMember && !actionWritesOnlyToolingCandidates(graph, candidates, reader) { + return true + } + if reader >= len(graph.ActionWrites) { + continue + } + for _, next := range graph.ActionWrites[reader] { + if _, ok := candidates[next]; !ok { + if workspaceMember { + continue + } + return true + } + if _, ok := seenDefs[next]; ok { + continue + } + seenDefs[next] = struct{}{} + queue = append(queue, next) + } + } + } + return false +} + +func actionCrossesMixedConsumerFrontier(graph Graph, toolingFamily []bool, candidates map[PathState]struct{}, toolingWorkspaceRoots map[string]struct{}, idx int) bool { + if idx < 0 || idx >= len(graph.ActionReads) { + return false + } + sawTooling := false + sawResidual := false + for _, read := range graph.ActionReads[idx] { + if readBelongsToToolingCandidates(graph, toolingFamily, candidates, toolingWorkspaceRoots, read) { + sawTooling = true + } else if readCountsAsResidualFrontier(graph, read) { + sawResidual = true + } + if sawTooling && sawResidual { + return true + } + } + return false +} + +func readBelongsToToolingCandidates(graph Graph, toolingFamily []bool, candidates map[PathState]struct{}, toolingWorkspaceRoots map[string]struct{}, read Read) bool { + if len(read.Defs) == 0 { + return pathLooksToolingForFamily(graph, toolingFamily, toolingWorkspaceRoots, read.Path) + } + sawCandidate := false + for _, def := range read.Defs { + if _, ok := candidates[def]; !ok { + return false + } + sawCandidate = true + } + return sawCandidate +} + +func readCountsAsResidualFrontier(graph Graph, read Read) bool { + path := normalizePath(read.Path) + if path == "" { + return false + } + if !pathWithinObservedScope(path, graph.Scope) { + return false + } + if len(read.Defs) == 0 { + return true + } + for _, def := range read.Defs { + if def.Writer >= 0 || def.Missing { + return true + } + } + return true +} + +func actionWritesOnlyToolingCandidates(graph Graph, candidates map[PathState]struct{}, idx int) bool { + if idx < 0 || idx >= len(graph.ActionWrites) { + return true + } + for _, def := range graph.ActionWrites[idx] { + if isExplicitDeliveryPath(def.Path, graph.Scope) { + return false + } + if _, ok := candidates[def]; !ok { + return false + } + } + return true +} + +func actionHasEscapingWrite(graph Graph, nonEscapingToolingDefs map[PathState]struct{}, idx int) bool { + if idx < 0 || idx >= len(graph.ActionWrites) { + return false + } + for _, def := range graph.ActionWrites[idx] { + if _, ok := nonEscapingToolingDefs[def]; ok { + continue + } + return true + } + return false +} + +func actionHasMainlineWrites(graph Graph, toolingFamily []bool, toolingWorkspaceRoots map[string]struct{}, nonEscapingToolingDefs, mainlineVisibleDefs map[PathState]struct{}, idx int) bool { + if idx < 0 || idx >= len(graph.Actions) || idx >= len(graph.ActionWrites) { + return false + } + closureAvailable := hasMainlineVisibleClosure(mainlineVisibleDefs) + for _, def := range graph.ActionWrites[idx] { + if defBelongsToMainlineVisibleClosure(mainlineVisibleDefs, def) { + return true + } + if defLooksTooling(graph, toolingFamily, toolingWorkspaceRoots, nonEscapingToolingDefs, def) || pathLooksDelivery(graph, def.Path) { + continue + } + if closureAvailable { + continue + } + return true + } + return false +} + +func actionTouchesOnlyToolingPaths(graph Graph, toolingFamily []bool, toolingWorkspaceRoots map[string]struct{}, nonEscapingToolingDefs map[PathState]struct{}, idx int) bool { + if idx < 0 || idx >= len(graph.Actions) { + return false + } + touched := false + if idx < len(graph.ActionReads) { + for _, read := range graph.ActionReads[idx] { + if len(read.Defs) == 0 { + if pathLooksToolingForFamily(graph, toolingFamily, toolingWorkspaceRoots, read.Path) { + touched = true + continue + } + return false + } + for _, def := range read.Defs { + if defLooksTooling(graph, toolingFamily, toolingWorkspaceRoots, nonEscapingToolingDefs, def) { + touched = true + continue + } + return false + } + } + } + if idx < len(graph.ActionWrites) { + for _, def := range graph.ActionWrites[idx] { + if defLooksTooling(graph, toolingFamily, toolingWorkspaceRoots, nonEscapingToolingDefs, def) { + touched = true + } else { + return false + } + } + } + return touched +} + +func defLooksTooling(graph Graph, toolingFamily []bool, toolingWorkspaceRoots map[string]struct{}, nonEscapingToolingDefs map[PathState]struct{}, def PathState) bool { + if _, ok := nonEscapingToolingDefs[def]; ok { + return true + } + if def.Writer >= 0 { + return false + } + return pathLooksToolingForFamily(graph, toolingFamily, toolingWorkspaceRoots, def.Path) +} + +func pathLooksToolingForFamily(graph Graph, toolingFamily []bool, toolingWorkspaceRoots map[string]struct{}, path string) bool { + path = normalizePath(path) + if path == "" || pathLooksDelivery(graph, path) { + return false + } + facts, ok := graph.RawPaths[path] + if !ok { + facts, ok = graph.Paths[path] + if !ok { + return false + } + } + sawEndpoint := false + sawProducer := false + for _, writer := range facts.Writers { + if writer < 0 || writer >= len(toolingFamily) || !toolingFamily[writer] { + return false + } + sawEndpoint = true + sawProducer = true + } + for _, reader := range facts.Readers { + if reader < 0 || reader >= len(toolingFamily) || !toolingFamily[reader] { + return false + } + sawEndpoint = true + } + for idx, action := range graph.Actions { + if action.ExecPath != path { + continue + } + if idx < 0 || idx >= len(toolingFamily) || !toolingFamily[idx] { + return false + } + sawEndpoint = true + sawProducer = true + } + if !sawProducer && !strings.HasPrefix(path, envNamespacePrefix) && !pathBelongsToToolingWorkspace(toolingWorkspaceRoots, path) { + return false + } + return sawEndpoint +} + +func pathLooksDelivery(graph Graph, path string) bool { + path = normalizePath(path) + if path == "" { + return false + } + if isExplicitDeliveryPath(path, graph.Scope) { + return true + } + facts, ok := graph.Paths[path] + if !ok { + return false + } + if pathConfinedToTransientWorkspace(graph, facts) { + return false + } + executedPaths := collectExecPaths(graph.Actions) + if isDeliveryPath(graph.Actions, graph.Outdeg, executedPaths, facts) { + return true + } + if pathFeedsExplicitDelivery(graph, facts) { + return true + } + return pathStaysInDeliveryPlane(graph, facts, executedPaths) +} + +func pathStaysInDeliveryPlane(graph Graph, facts PathInfo, executedPaths map[string]struct{}) bool { + path := normalizePath(facts.Path) + if path == "" || isExplicitDeliveryPath(path, graph.Scope) { + return false + } + if len(facts.Writers) == 0 { + return false + } + if pathConfinedToTransientWorkspace(graph, facts) { + return false + } + for _, writer := range facts.Writers { + if !actionBelongsToDeliveryPlane(graph, writer, executedPaths) { + return false + } + } + for _, reader := range facts.Readers { + if !actionBelongsToDeliveryPlane(graph, reader, executedPaths) { + return false + } + } + return true +} + +func pathConfinedToTransientWorkspace(graph Graph, facts PathInfo) bool { + path := normalizePath(facts.Path) + if path == "" || !pathWithinObservedScope(path, graph.Scope) { + return false + } + bestRoot := "" + for _, idx := range slices.Concat(facts.Writers, facts.Readers) { + root := actionTransientWorkspaceRoot(graph, idx, path) + if root == "" { + continue + } + if bestRoot == "" || len(root) > len(bestRoot) { + bestRoot = root + } + } + if bestRoot == "" { + return false + } + for _, idx := range slices.Concat(facts.Writers, facts.Readers) { + if !actionConfinedToWorkspace(graph, idx, bestRoot) { + return false + } + } + return true +} + +func actionTransientWorkspaceRoot(graph Graph, idx int, path string) string { + if idx < 0 || idx >= len(graph.Actions) { + return "" + } + cwd := normalizePath(graph.Actions[idx].Cwd) + if !toolingWorkspaceRootCandidate(graph.Scope, cwd) || !pathWithinDir(path, cwd) { + return "" + } + if !actionConfinedToWorkspace(graph, idx, cwd) { + return "" + } + return cwd +} + +func actionConfinedToWorkspace(graph Graph, idx int, root string) bool { + if idx < 0 || idx >= len(graph.Actions) || root == "" { + return false + } + action := graph.Actions[idx] + if cwd := normalizePath(action.Cwd); cwd != "" && pathWithinObservedScope(cwd, graph.Scope) && !pathWithinDir(cwd, root) { + return false + } + sawObserved := false + for _, path := range action.Reads { + if !workspacePathAllowed(graph, path, root) { + return false + } + if pathWithinObservedScope(path, graph.Scope) { + sawObserved = true + } + } + for _, path := range action.Writes { + if !workspacePathAllowed(graph, path, root) { + return false + } + if pathWithinObservedScope(path, graph.Scope) { + sawObserved = true + } + } + if execPath := normalizePath(action.ExecPath); execPath != "" && pathWithinObservedScope(execPath, graph.Scope) { + if !pathWithinDir(execPath, root) { + return false + } + sawObserved = true + } + return sawObserved +} + +func workspacePathAllowed(graph Graph, path, root string) bool { + path = normalizePath(path) + if path == "" || strings.HasPrefix(path, envNamespacePrefix) { + return true + } + if !pathWithinObservedScope(path, graph.Scope) { + return true + } + return pathWithinDir(path, root) +} + +func actionBelongsToDeliveryPlane(graph Graph, idx int, executedPaths map[string]struct{}) bool { + if idx < 0 || idx >= len(graph.Actions) { + return false + } + action := graph.Actions[idx] + if actionWritesExecutedPath(action, executedPaths) { + return false + } + switch action.Kind { + case KindCopy, KindInstall: + return true + } + if len(action.Writes) == 0 { + return false + } + for _, path := range action.Writes { + if !isExplicitDeliveryPath(path, graph.Scope) { + return false + } + } + return true +} + +func pathFeedsExplicitDelivery(graph Graph, facts PathInfo) bool { + if len(facts.Writers) == 0 || len(facts.Readers) == 0 { + return false + } + for _, writer := range facts.Writers { + if writer < 0 || writer >= len(graph.Actions) { + return false + } + kind := graph.Actions[writer].Kind + if kind != KindCopy && kind != KindInstall { + return false + } + } + sawExplicitDeliveryReader := false + for _, reader := range facts.Readers { + if reader < 0 || reader >= len(graph.Actions) { + return false + } + explicitDelivery := false + for _, out := range graph.Actions[reader].Writes { + if isExplicitDeliveryPath(out, graph.Scope) { + explicitDelivery = true + break + } + } + if !explicitDelivery { + return false + } + sawExplicitDeliveryReader = true + } + return sawExplicitDeliveryReader +} + +func roleReadersForDef(graph Graph, def PathState) []int { + if len(graph.ReadersByDef) != 0 { + if readers := graph.ReadersByDef[def]; len(readers) != 0 { + return readers + } + } + readers := make([]int, 0) + for reader, bindings := range graph.ActionReads { + found := false + for _, binding := range bindings { + for _, candidate := range binding.Defs { + if candidate != def { + continue + } + found = true + break + } + if found { + readers = append(readers, reader) + break + } + } + } + return readers +} diff --git a/internal/trace/trace.go b/internal/trace/trace.go new file mode 100644 index 0000000..b74e2d1 --- /dev/null +++ b/internal/trace/trace.go @@ -0,0 +1,907 @@ +package trace + +import ( + "bufio" + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "slices" + "strconv" + "strings" +) + +type Record struct { + PID int64 `json:"pid,omitempty"` + ParentPID int64 `json:"parent_pid,omitempty"` + + Argv []string `json:"argv"` + Env []string `json:"env,omitempty"` + Cwd string `json:"cwd"` + + Inputs []string `json:"inputs,omitempty"` + Changes []string `json:"changes,omitempty"` +} + +type EventKind uint8 + +const ( + EventExec EventKind = iota + EventChdir + EventRead + EventReadMiss + EventWrite + EventRename + EventUnlink + EventMkdir + EventSymlink + EventClone +) + +type Event struct { + Seq int64 `json:"seq"` + PID int64 `json:"pid,omitempty"` + ParentPID int64 `json:"parent_pid,omitempty"` + Cwd string `json:"cwd,omitempty"` + Kind EventKind `json:"kind"` + Path string `json:"path,omitempty"` + RelatedPath string `json:"related_path,omitempty"` + Argv []string `json:"argv,omitempty"` + ChildPID int64 `json:"child_pid,omitempty"` +} + +func (kind EventKind) String() string { + switch kind { + case EventExec: + return "exec" + case EventChdir: + return "chdir" + case EventRead: + return "read" + case EventReadMiss: + return "read-miss" + case EventWrite: + return "write" + case EventRename: + return "rename" + case EventUnlink: + return "unlink" + case EventMkdir: + return "mkdir" + case EventSymlink: + return "symlink" + case EventClone: + return "clone" + default: + return "unknown" + } +} + +type ParseDiagnostics struct { + UnrecognizedLines int `json:"unrecognized_lines,omitempty"` + ResumedMismatches int `json:"resumed_mismatches,omitempty"` + InvalidCalls int `json:"invalid_calls,omitempty"` + MissingPIDLines int `json:"missing_pid_lines,omitempty"` + PIDStateResets int `json:"pid_state_resets,omitempty"` +} + +func (d ParseDiagnostics) Trusted() bool { + return d.UnrecognizedLines == 0 && + d.ResumedMismatches == 0 && + d.InvalidCalls == 0 && + d.MissingPIDLines == 0 +} + +type CaptureResult struct { + Records []Record + Events []Event + Diagnostics ParseDiagnostics +} + +type Scope struct { + SourceRoot string `json:"source_root,omitempty"` + BuildRoot string `json:"build_root,omitempty"` + InstallRoot string `json:"install_root,omitempty"` + KeepRoots []string `json:"keep_roots,omitempty"` +} + +type CaptureOptions struct { + RootCwd string + KeepRoots []string +} + +type parseOptions struct { + rootCwd string + keepRoots []string +} + +type procState struct { + parentPID int64 + cwd string + current *Record +} + +type parseResult struct { + records []Record + events []Event + diagnostics ParseDiagnostics +} + +type parsedCall struct { + name string + args []string + ret string +} + +const syntheticMainPID int64 = 0 + +var ( + straceLinePrefixRE = regexp.MustCompile(`^\s*(?:(?:\[pid\s+)?(\d+)(?:\])?\s+)?(?:\d+\.\d+\s+)?(.*)$`) + resumedCallRE = regexp.MustCompile(`^<\.\.\.\s+([A-Za-z_][A-Za-z0-9_]*)\s+resumed>\s*(.*)$`) +) + +const unfinishedSuffix = " " + +// Watch observes a build-only execution for one module/matrix combination and +// returns command-level records in execution order. +func Watch(ctx context.Context, moduleArg, combo string) ([]Record, error) { + switch runtime.GOOS { + case "linux": + result, err := watchWithStrace(ctx, moduleArg, combo) + return result.Records, err + default: + return nil, fmt.Errorf("trace is unsupported on %s", runtime.GOOS) + } +} + +func watchWithStrace(ctx context.Context, moduleArg, combo string) (CaptureResult, error) { + if _, err := exec.LookPath("strace"); err != nil { + return CaptureResult{}, fmt.Errorf("strace not found: %w", err) + } + exe, err := os.Executable() + if err != nil { + return CaptureResult{}, err + } + wd, err := os.Getwd() + if err != nil { + return CaptureResult{}, err + } + + tmpDir, err := os.MkdirTemp("", "llar-trace-*") + if err != nil { + return CaptureResult{}, err + } + defer os.RemoveAll(tmpDir) + + outFile := filepath.Join(tmpDir, "trace.log") + args := []string{ + "-f", + "-ttt", + "-yy", + "-v", + "-s", "65535", + "-e", "trace=execve,execveat,chdir,open,openat,openat2,creat,rename,renameat,renameat2,unlink,unlinkat,mkdir,mkdirat,symlink,symlinkat,clone,fork,vfork", + "-o", outFile, + exe, + "make", + "--matrix", combo, + moduleArg, + } + cmd := exec.CommandContext(ctx, "strace", args...) + cmd.Dir = wd + + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + if err := cmd.Run(); err != nil { + return CaptureResult{}, fmt.Errorf("strace failed: %w, output: %s", err, out.String()) + } + + data, err := os.ReadFile(outFile) + if err != nil { + return CaptureResult{}, err + } + parsed := parseStraceOutputDetailed(string(data), parseOptions{rootCwd: wd}) + return CaptureResult{Records: parsed.records, Events: parsed.events, Diagnostics: parsed.diagnostics}, nil +} + +func parseStraceRecords(content string, opts parseOptions) []Record { + return parseStraceOutputDetailed(content, opts).records +} + +func parseStraceEvents(content string, opts parseOptions) []Event { + return parseStraceOutputDetailed(content, opts).events +} + +func parseStraceRecordsDetailed(content string, opts parseOptions) ([]Record, ParseDiagnostics) { + parsed := parseStraceOutputDetailed(content, opts) + return parsed.records, parsed.diagnostics +} + +func parseStraceOutputDetailed(content string, opts parseOptions) parseResult { + states := map[int64]*procState{} + unfinished := map[int64]string{} + var ordered []*Record + var events []Event + var diagnostics ParseDiagnostics + var fallbackPID int64 = syntheticMainPID + var nextSeq int64 = 1 + opts.keepRoots = normalizeKeepRoots(opts.keepRoots) + + stateOf := func(pid int64) *procState { + if st, ok := states[pid]; ok { + return st + } + st := &procState{cwd: opts.rootCwd} + states[pid] = st + return st + } + + scanner := bufio.NewScanner(strings.NewReader(content)) + scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + pid, hasPID, rawCall, ok := splitStraceLine(line) + if !ok { + diagnostics.UnrecognizedLines++ + continue + } + if !hasPID { + diagnostics.MissingPIDLines++ + pid = fallbackPID + } else { + fallbackPID = pid + } + if strings.HasSuffix(rawCall, unfinishedSuffix) { + unfinished[pid] = strings.TrimSuffix(rawCall, unfinishedSuffix) + continue + } + if resumed, ok := mergeResumedCall(rawCall, unfinished[pid]); ok { + rawCall = resumed + delete(unfinished, pid) + } else if isResumedCall(rawCall) { + delete(unfinished, pid) + diagnostics.ResumedMismatches++ + continue + } + call, ok := parseCall(rawCall) + if !ok { + diagnostics.InvalidCalls++ + continue + } + + state := stateOf(pid) + switch call.name { + case "clone", "fork", "vfork": + if !callSucceeded(call) { + continue + } + fields := strings.Fields(call.ret) + if len(fields) == 0 { + continue + } + childPID, err := strconv.ParseInt(fields[0], 10, 64) + if err == nil && childPID > 0 { + events = appendEvent(events, &nextSeq, Event{ + PID: pid, + ParentPID: state.parentPID, + Cwd: state.cwd, + Kind: EventClone, + ChildPID: childPID, + }) + childState, ok := states[childPID] + _, childHasUnfinished := unfinished[childPID] + if ok && !childHasUnfinished { + diagnostics.PIDStateResets++ + childState = &procState{} + states[childPID] = childState + } + if !ok { + childState = &procState{} + states[childPID] = childState + } + if childState.parentPID == 0 { + childState.parentPID = pid + } + if childState.cwd == "" || childState.cwd == opts.rootCwd { + childState.cwd = state.cwd + } + if childState.current != nil { + if childState.current.ParentPID == 0 { + childState.current.ParentPID = pid + } + if childState.current.Cwd == "" || childState.current.Cwd == opts.rootCwd { + childState.current.Cwd = state.cwd + } + } + } + case "chdir": + if callSucceeded(call) && len(call.args) > 0 { + state.cwd = resolvePath(state.cwd, parseQuoted(call.args[0])) + events = appendEvent(events, &nextSeq, Event{ + PID: pid, + ParentPID: state.parentPID, + Cwd: state.cwd, + Kind: EventChdir, + Path: state.cwd, + }) + } + case "execve", "execveat": + if !callSucceeded(call) { + continue + } + path, argv, env := parseExecArgs(call) + if len(argv) == 0 && path != "" { + argv = []string{path} + } + if len(argv) == 0 { + continue + } + rec := &Record{ + PID: pid, + ParentPID: state.parentPID, + Argv: argv, + Env: env, + Cwd: state.cwd, + } + state.current = rec + ordered = append(ordered, rec) + events = appendEvent(events, &nextSeq, Event{ + PID: pid, + ParentPID: state.parentPID, + Cwd: state.cwd, + Kind: EventExec, + Path: resolvePath(state.cwd, path), + Argv: slices.Clone(argv), + }) + case "open", "openat", "openat2", "creat": + path := parseResolvedOpenPath(state.cwd, call) + if path == "" { + continue + } + if !shouldKeepPath(path, opts.keepRoots) { + continue + } + write := isWriteOpen(call) + if !callSucceeded(call) { + if write { + continue + } + events = appendEvent(events, &nextSeq, Event{ + PID: pid, + ParentPID: state.parentPID, + Cwd: state.cwd, + Kind: EventReadMiss, + Path: path, + }) + continue + } + if write { + events = appendEvent(events, &nextSeq, Event{ + PID: pid, + ParentPID: state.parentPID, + Cwd: state.cwd, + Kind: EventWrite, + Path: path, + }) + } else { + events = appendEvent(events, &nextSeq, Event{ + PID: pid, + ParentPID: state.parentPID, + Cwd: state.cwd, + Kind: EventRead, + Path: path, + }) + } + if state.current == nil { + continue + } + if write { + if !slices.Contains(state.current.Changes, path) { + state.current.Changes = append(state.current.Changes, path) + } + } else { + if !slices.Contains(state.current.Inputs, path) { + state.current.Inputs = append(state.current.Inputs, path) + } + } + case "rename", "renameat", "renameat2": + if !callSucceeded(call) { + continue + } + paths := parseResolvedRenamePaths(state.cwd, call) + if len(paths) >= 2 && (shouldKeepPath(paths[0], opts.keepRoots) || shouldKeepPath(paths[1], opts.keepRoots)) { + events = appendEvent(events, &nextSeq, Event{ + PID: pid, + ParentPID: state.parentPID, + Cwd: state.cwd, + Kind: EventRename, + Path: paths[1], + RelatedPath: paths[0], + }) + } + if state.current == nil { + continue + } + for _, path := range paths { + if path == "" || !shouldKeepPath(path, opts.keepRoots) { + continue + } + if !slices.Contains(state.current.Changes, path) { + state.current.Changes = append(state.current.Changes, path) + } + } + case "unlink", "unlinkat", "mkdir", "mkdirat", "symlink", "symlinkat": + if !callSucceeded(call) { + continue + } + path := parseResolvedChangePath(state.cwd, call) + if path == "" { + continue + } + if !shouldKeepPath(path, opts.keepRoots) { + continue + } + events = appendEvent(events, &nextSeq, Event{ + PID: pid, + ParentPID: state.parentPID, + Cwd: state.cwd, + Kind: eventKindForChangeCall(call.name), + Path: path, + RelatedPath: parseRelatedChangePath(call), + }) + if state.current == nil { + continue + } + if !slices.Contains(state.current.Changes, path) { + state.current.Changes = append(state.current.Changes, path) + } + } + } + + out := make([]Record, 0, len(ordered)) + for _, rec := range ordered { + if rec == nil || len(rec.Argv) == 0 { + continue + } + out = append(out, *rec) + } + return parseResult{ + records: out, + events: events, + diagnostics: diagnostics, + } +} + +func appendEvent(events []Event, nextSeq *int64, event Event) []Event { + event.Seq = *nextSeq + *nextSeq = *nextSeq + 1 + return append(events, event) +} + +func splitStraceLine(line string) (int64, bool, string, bool) { + matches := straceLinePrefixRE.FindStringSubmatch(line) + if len(matches) != 3 { + return 0, false, "", false + } + var pid int64 + hasPID := matches[1] != "" + if matches[1] != "" { + pid, _ = strconv.ParseInt(matches[1], 10, 64) + } + raw := strings.TrimSpace(matches[2]) + if raw == "" { + return 0, hasPID, "", false + } + return pid, hasPID, raw, true +} + +func parseCall(line string) (parsedCall, bool) { + open := strings.IndexByte(line, '(') + if open <= 0 { + return parsedCall{}, false + } + name := strings.TrimSpace(line[:open]) + closeIdx := findClosingParen(line, open) + if closeIdx < 0 { + return parsedCall{}, false + } + argsBody := line[open+1 : closeIdx] + ret := "" + if eq := strings.LastIndex(line[closeIdx+1:], "="); eq >= 0 { + ret = strings.TrimSpace(line[closeIdx+1+eq+1:]) + } + return parsedCall{ + name: name, + args: splitTopLevel(argsBody), + ret: ret, + }, true +} + +func isResumedCall(raw string) bool { + return resumedCallRE.MatchString(raw) +} + +func mergeResumedCall(raw, partial string) (string, bool) { + matches := resumedCallRE.FindStringSubmatch(raw) + if len(matches) != 3 || partial == "" { + return "", false + } + if name := callName(partial); name != "" && name != matches[1] { + return "", false + } + return partial + matches[2], true +} + +func callName(line string) string { + if open := strings.IndexByte(line, '('); open > 0 { + return strings.TrimSpace(line[:open]) + } + return "" +} + +func findClosingParen(line string, open int) int { + depth := 0 + inQuote := false + escaped := false + for i := open; i < len(line); i++ { + ch := line[i] + if inQuote { + if escaped { + escaped = false + continue + } + if ch == '\\' { + escaped = true + continue + } + if ch == '"' { + inQuote = false + } + continue + } + switch ch { + case '"': + inQuote = true + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + return i + } + } + } + return -1 +} + +func splitTopLevel(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + var parts []string + start := 0 + depthParen, depthBracket, depthBrace := 0, 0, 0 + inQuote := false + escaped := false + for i := 0; i < len(s); i++ { + ch := s[i] + if inQuote { + if escaped { + escaped = false + continue + } + if ch == '\\' { + escaped = true + continue + } + if ch == '"' { + inQuote = false + } + continue + } + switch ch { + case '"': + inQuote = true + case '(': + depthParen++ + case ')': + depthParen-- + case '[': + depthBracket++ + case ']': + depthBracket-- + case '{': + depthBrace++ + case '}': + depthBrace-- + case ',': + if depthParen == 0 && depthBracket == 0 && depthBrace == 0 { + parts = append(parts, strings.TrimSpace(s[start:i])) + start = i + 1 + } + } + } + parts = append(parts, strings.TrimSpace(s[start:])) + return parts +} + +func parseExecArgs(call parsedCall) (string, []string, []string) { + switch call.name { + case "execve": + path := "" + if len(call.args) > 0 { + path = parseQuoted(call.args[0]) + } + var argv []string + if len(call.args) > 1 { + argv = parseStringArray(call.args[1]) + } + var env []string + if len(call.args) > 2 { + env = parseStringArray(call.args[2]) + } + return path, argv, env + case "execveat": + path := "" + if len(call.args) > 1 { + path = parseQuoted(call.args[1]) + } + var argv []string + if len(call.args) > 2 { + argv = parseStringArray(call.args[2]) + } + var env []string + if len(call.args) > 3 { + env = parseStringArray(call.args[3]) + } + return path, argv, env + default: + return "", nil, nil + } +} + +func parseOpenPath(call parsedCall) string { + switch call.name { + case "open", "creat": + if len(call.args) > 0 { + return parseQuoted(call.args[0]) + } + case "openat", "openat2": + if len(call.args) > 1 { + return parseQuoted(call.args[1]) + } + } + return "" +} + +func parseResolvedOpenPath(cwd string, call parsedCall) string { + if resolved := parseReturnedFDPath(call.ret); resolved != "" { + return resolved + } + switch call.name { + case "open", "creat": + return resolvePath(cwd, parseOpenPath(call)) + case "openat", "openat2": + if len(call.args) <= 1 { + return "" + } + return resolveDirfdRelativePath(cwd, call.args[0], call.args[1]) + default: + return "" + } +} + +func parseReturnedFDPath(ret string) string { + ret = strings.TrimSpace(ret) + start := strings.IndexByte(ret, '<') + end := strings.LastIndexByte(ret, '>') + if start < 0 || end <= start+1 { + return "" + } + path := strings.TrimSpace(ret[start+1 : end]) + if path == "" || !filepath.IsAbs(path) { + return "" + } + return filepath.Clean(path) +} + +func parseDirfdBasePath(arg string) string { + arg = strings.TrimSpace(arg) + if arg == "" || arg == "AT_FDCWD" { + return "" + } + start := strings.IndexByte(arg, '<') + end := strings.LastIndexByte(arg, '>') + if start < 0 || end <= start+1 { + return "" + } + base := filepath.Clean(arg[start+1 : end]) + if base == "." || base == "" { + return "" + } + return base +} + +func resolveDirfdRelativePath(cwd, dirfdArg, pathArg string) string { + path := parseQuoted(pathArg) + if path == "" { + return "" + } + if filepath.IsAbs(path) { + return filepath.Clean(path) + } + if base := parseDirfdBasePath(dirfdArg); base != "" { + return filepath.Clean(filepath.Join(base, path)) + } + return resolvePath(cwd, path) +} + +func parseResolvedRenamePaths(cwd string, call parsedCall) []string { + switch call.name { + case "rename": + if len(call.args) >= 2 { + return []string{ + resolvePath(cwd, parseQuoted(call.args[0])), + resolvePath(cwd, parseQuoted(call.args[1])), + } + } + case "renameat", "renameat2": + if len(call.args) >= 4 { + return []string{ + resolveDirfdRelativePath(cwd, call.args[0], call.args[1]), + resolveDirfdRelativePath(cwd, call.args[2], call.args[3]), + } + } + } + return nil +} + +func parseResolvedChangePath(cwd string, call parsedCall) string { + switch call.name { + case "unlink", "mkdir": + if len(call.args) > 0 { + return resolvePath(cwd, parseQuoted(call.args[0])) + } + case "symlink": + if len(call.args) > 1 { + return resolvePath(cwd, parseQuoted(call.args[1])) + } + case "unlinkat", "mkdirat": + if len(call.args) > 1 { + return resolveDirfdRelativePath(cwd, call.args[0], call.args[1]) + } + case "symlinkat": + if len(call.args) > 2 { + return resolveDirfdRelativePath(cwd, call.args[1], call.args[2]) + } + } + return "" +} + +func parseRelatedChangePath(call parsedCall) string { + switch call.name { + case "symlink", "symlinkat": + if len(call.args) > 0 { + return parseQuoted(call.args[0]) + } + } + return "" +} + +func eventKindForChangeCall(name string) EventKind { + switch name { + case "mkdir", "mkdirat": + return EventMkdir + case "symlink", "symlinkat": + return EventSymlink + default: + return EventUnlink + } +} + +func callSucceeded(call parsedCall) bool { + ret := strings.TrimSpace(call.ret) + return ret != "" && !strings.HasPrefix(ret, "-1") +} + +func isWriteOpen(call parsedCall) bool { + if call.name == "creat" { + return true + } + flags := strings.ToUpper(strings.Join(call.args, " ")) + return strings.Contains(flags, "O_WRONLY") || + strings.Contains(flags, "O_RDWR") || + strings.Contains(flags, "O_TRUNC") || + strings.Contains(flags, "O_APPEND") || + (strings.Contains(flags, "O_CREAT") && strings.Contains(flags, "O_EXCL")) +} + +func parseStringArray(arg string) []string { + arg = strings.TrimSpace(arg) + if !strings.HasPrefix(arg, "[") || !strings.HasSuffix(arg, "]") { + return nil + } + parts := splitTopLevel(strings.TrimSpace(arg[1 : len(arg)-1])) + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = parseQuoted(part) + if part == "" || part == "NULL" || part == "0x0" { + continue + } + out = append(out, part) + } + return out +} + +func parseQuoted(arg string) string { + arg = strings.TrimSpace(arg) + if arg == "" || arg == "NULL" || arg == "0x0" { + return "" + } + if strings.HasPrefix(arg, "\"") { + if end := strings.LastIndex(arg, "\""); end > 0 { + quoted := arg[:end+1] + if unquoted, err := strconv.Unquote(quoted); err == nil { + return unquoted + } + return strings.Trim(quoted, "\"") + } + } + return arg +} + +func resolvePath(cwd, path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + if filepath.IsAbs(path) { + return filepath.Clean(path) + } + if cwd == "" { + return filepath.Clean(path) + } + return filepath.Clean(filepath.Join(cwd, path)) +} + +func normalizeKeepRoots(roots []string) []string { + if len(roots) == 0 { + return nil + } + out := make([]string, 0, len(roots)) + for _, root := range roots { + root = filepath.Clean(strings.TrimSpace(root)) + if root == "" || root == "." { + continue + } + if slices.Contains(out, root) { + continue + } + out = append(out, root) + } + return out +} + +func shouldKeepPath(path string, keepRoots []string) bool { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "" { + return false + } + if len(keepRoots) == 0 { + return true + } + for _, root := range keepRoots { + if path == root || strings.HasPrefix(path, root+string(filepath.Separator)) { + return true + } + } + return false +} diff --git a/internal/trace/trace_test.go b/internal/trace/trace_test.go new file mode 100644 index 0000000..120481b --- /dev/null +++ b/internal/trace/trace_test.go @@ -0,0 +1,508 @@ +package trace + +import ( + "reflect" + "testing" +) + +func TestParseStraceRecords(t *testing.T) { + content := ` +1234 1741260000.000001 chdir("/tmp/work") = 0 +1234 1741260000.000002 execve("/usr/bin/cc", ["cc", "-c", "core.c", "-o", "core.o"], 0x0) = 0 +1234 1741260000.000003 openat(AT_FDCWD, "core.c", O_RDONLY|O_CLOEXEC) = 3 +1234 1741260000.000004 openat(AT_FDCWD, "core.o", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 4 +1234 1741260000.000005 execve("/usr/bin/ar", ["ar", "rcs", "libfoo.a", "core.o"], 0x0) = 0 +1234 1741260000.000006 openat(AT_FDCWD, "core.o", O_RDONLY|O_CLOEXEC) = 3 +1234 1741260000.000007 openat(AT_FDCWD, "libfoo.a", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 4 +` + + got := parseStraceRecords(content, parseOptions{rootCwd: "/repo"}) + want := []Record{ + { + PID: 1234, + Argv: []string{"cc", "-c", "core.c", "-o", "core.o"}, + Cwd: "/tmp/work", + Inputs: []string{"/tmp/work/core.c"}, + Changes: []string{"/tmp/work/core.o"}, + }, + { + PID: 1234, + Argv: []string{"ar", "rcs", "libfoo.a", "core.o"}, + Cwd: "/tmp/work", + Inputs: []string{"/tmp/work/core.o"}, + Changes: []string{"/tmp/work/libfoo.a"}, + }, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseStraceRecords() = %#v, want %#v", got, want) + } +} + +func TestParseStraceEvents_CapturesSyscallSequence(t *testing.T) { + content := ` +1234 1741260000.000001 chdir("/tmp/work") = 0 +1234 1741260000.000002 execve("/usr/bin/cc", ["cc", "-c", "core.c", "-o", "core.o"], 0x0) = 0 +1234 1741260000.000003 openat(AT_FDCWD, "core.c", O_RDONLY|O_CLOEXEC) = 3 +1234 1741260000.000004 openat(AT_FDCWD, "core.o", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 4 +1234 1741260000.000005 rename("core.o.tmp", "core.o") = 0 +1234 1741260000.000006 unlink("core.o.tmp") = 0 +` + + got := parseStraceEvents(content, parseOptions{rootCwd: "/repo"}) + want := []Event{ + {Seq: 1, PID: 1234, Cwd: "/tmp/work", Kind: EventChdir, Path: "/tmp/work"}, + {Seq: 2, PID: 1234, Cwd: "/tmp/work", Kind: EventExec, Path: "/usr/bin/cc", Argv: []string{"cc", "-c", "core.c", "-o", "core.o"}}, + {Seq: 3, PID: 1234, Cwd: "/tmp/work", Kind: EventRead, Path: "/tmp/work/core.c"}, + {Seq: 4, PID: 1234, Cwd: "/tmp/work", Kind: EventWrite, Path: "/tmp/work/core.o"}, + {Seq: 5, PID: 1234, Cwd: "/tmp/work", Kind: EventRename, Path: "/tmp/work/core.o", RelatedPath: "/tmp/work/core.o.tmp"}, + {Seq: 6, PID: 1234, Cwd: "/tmp/work", Kind: EventUnlink, Path: "/tmp/work/core.o.tmp"}, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseStraceEvents() = %#v, want %#v", got, want) + } +} + +func TestParseStraceEvents_TracksCloneAndPidFallback(t *testing.T) { + content := ` +1234 1741260000.000001 chdir("/tmp/work") = 0 +1234 1741260000.000002 clone(child_stack=NULL, flags=CLONE_VM|CLONE_VFORK|SIGCHLD) = 5678 +5678 1741260000.000003 execve("/usr/bin/ld", ["ld", "-o", "tracecli"], 0x0) = 0 +1741260000.000004 openat(AT_FDCWD, "tracecli", O_WRONLY|O_CREAT|O_TRUNC, 0777) = 5 +` + + got := parseStraceEvents(content, parseOptions{rootCwd: "/repo"}) + want := []Event{ + {Seq: 1, PID: 1234, Cwd: "/tmp/work", Kind: EventChdir, Path: "/tmp/work"}, + {Seq: 2, PID: 1234, Cwd: "/tmp/work", Kind: EventClone, ChildPID: 5678}, + {Seq: 3, PID: 5678, ParentPID: 1234, Cwd: "/tmp/work", Kind: EventExec, Path: "/usr/bin/ld", Argv: []string{"ld", "-o", "tracecli"}}, + {Seq: 4, PID: 5678, ParentPID: 1234, Cwd: "/tmp/work", Kind: EventWrite, Path: "/tmp/work/tracecli"}, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseStraceEvents() = %#v, want %#v", got, want) + } +} + +func TestParseStraceEvents_CapturesReadMiss(t *testing.T) { + content := ` +1234 1741260000.000001 chdir("/tmp/work") = 0 +1234 1741260000.000002 execve("/usr/bin/cc", ["cc", "-c", "core.c", "-o", "core.o"], 0x0) = 0 +1234 1741260000.000003 openat(AT_FDCWD, "missing.h", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) +1234 1741260000.000004 openat(AT_FDCWD, "core.c", O_RDONLY|O_CLOEXEC) = 3 +` + + got := parseStraceEvents(content, parseOptions{rootCwd: "/repo"}) + want := []Event{ + {Seq: 1, PID: 1234, Cwd: "/tmp/work", Kind: EventChdir, Path: "/tmp/work"}, + {Seq: 2, PID: 1234, Cwd: "/tmp/work", Kind: EventExec, Path: "/usr/bin/cc", Argv: []string{"cc", "-c", "core.c", "-o", "core.o"}}, + {Seq: 3, PID: 1234, Cwd: "/tmp/work", Kind: EventReadMiss, Path: "/tmp/work/missing.h"}, + {Seq: 4, PID: 1234, Cwd: "/tmp/work", Kind: EventRead, Path: "/tmp/work/core.c"}, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseStraceEvents() = %#v, want %#v", got, want) + } +} + +func TestParseStraceRecords_IgnoresFailedSyscalls(t *testing.T) { + content := ` +1234 1741260000.000001 chdir("/tmp/work") = 0 +1234 1741260000.000002 execve("/usr/bin/cc", ["cc", "-c", "core.c", "-o", "core.o"], 0x0) = -1 ENOENT (No such file or directory) +1234 1741260000.000003 execve("/usr/bin/cc", ["cc", "-c", "core.c", "-o", "core.o"], 0x0) = 0 +1234 1741260000.000004 openat(AT_FDCWD, "missing.h", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) +1234 1741260000.000005 openat(AT_FDCWD, "core.c", O_RDONLY|O_CLOEXEC) = 3 +1234 1741260000.000006 openat(AT_FDCWD, "core.o", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 4 +1234 1741260000.000007 rename("tmp.o", "core.o") = -1 ENOENT (No such file or directory) +1234 1741260000.000008 unlink("ghost.o") = -1 ENOENT (No such file or directory) +` + + got := parseStraceRecords(content, parseOptions{rootCwd: "/repo"}) + want := []Record{ + { + PID: 1234, + Argv: []string{"cc", "-c", "core.c", "-o", "core.o"}, + Cwd: "/tmp/work", + Inputs: []string{"/tmp/work/core.c"}, + Changes: []string{"/tmp/work/core.o"}, + }, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseStraceRecords() = %#v, want %#v", got, want) + } +} + +func TestParseStraceRecords_MergesUnfinishedExecve(t *testing.T) { + content := ` +1234 1741260000.000001 chdir("/tmp/work") = 0 +1234 1741260000.000002 execve("/usr/bin/cmake", ["cmake", "-S", "/src", "-B", "/tmp/work/_build"], 0x0 +1234 1741260000.000003 <... execve resumed>) = 0 +1234 1741260000.000004 openat(AT_FDCWD, "CMakeLists.txt", O_RDONLY|O_CLOEXEC) = 3 +1234 1741260000.000005 openat(AT_FDCWD, "_build/CMakeCache.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 4 +` + + got := parseStraceRecords(content, parseOptions{rootCwd: "/repo"}) + want := []Record{ + { + PID: 1234, + Argv: []string{"cmake", "-S", "/src", "-B", "/tmp/work/_build"}, + Cwd: "/tmp/work", + Inputs: []string{"/tmp/work/CMakeLists.txt"}, + Changes: []string{"/tmp/work/_build/CMakeCache.txt"}, + }, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseStraceRecords() = %#v, want %#v", got, want) + } +} + +func TestParseStraceRecords_CapturesExecEnvironment(t *testing.T) { + content := ` +1234 1741260000.000001 chdir("/tmp/work") = 0 +1234 1741260000.000002 execve("/usr/bin/cmake", ["cmake", "-S", "/src", "-B", "/tmp/work/_build"], ["PWD=/tmp/work", "CFLAGS=-O2", "TRACE_FEATURE=ON"]) = 0 +1234 1741260000.000003 openat(AT_FDCWD, "CMakeLists.txt", O_RDONLY|O_CLOEXEC) = 3 +1234 1741260000.000004 openat(AT_FDCWD, "_build/CMakeCache.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 4 +` + + got := parseStraceRecords(content, parseOptions{rootCwd: "/repo"}) + want := []Record{ + { + PID: 1234, + Argv: []string{"cmake", "-S", "/src", "-B", "/tmp/work/_build"}, + Env: []string{"PWD=/tmp/work", "CFLAGS=-O2", "TRACE_FEATURE=ON"}, + Cwd: "/tmp/work", + Inputs: []string{"/tmp/work/CMakeLists.txt"}, + Changes: []string{"/tmp/work/_build/CMakeCache.txt"}, + }, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseStraceRecords() = %#v, want %#v", got, want) + } +} + +func TestParseStraceRecords_MergesNestedChildExecve(t *testing.T) { + content := ` +30553 1773631102.254672 openat(AT_FDCWD, "CMakeFiles/tracecli.dir/link.txt", O_RDONLY) = 3 +30553 1773631102.257471 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0xffff981650f0) = 30556 +30556 1773631102.260829 execve("/usr/bin/cc", ["/usr/bin/cc", "-O3", "-DNDEBUG", "CMakeFiles/tracecli.dir/cli.c.o", "-o", "tracecli", "libtracecore.a"], 0xffffe0b6ddf8 /* 46 vars */) = 0 +30556 1773631102.276776 clone(child_stack=0xfffff27a8d20, flags=CLONE_VM|CLONE_VFORK|SIGCHLD +30557 1773631102.276967 execve("/usr/lib/gcc/aarch64-linux-gnu/12/collect2", ["/usr/lib/gcc/aarch64-linux-gnu/12/collect2", "-o", "tracecli", "CMakeFiles/tracecli.dir/cli.c.o", "libtracecore.a"], 0x29f864f0 /* 51 vars */ +30556 1773631102.277085 <... clone resumed>) = 30557 +30557 1773631102.277114 <... execve resumed>) = 0 +30557 1773631102.296022 clone(child_stack=0xffffccc53150, flags=CLONE_VM|CLONE_VFORK|SIGCHLD +30558 1773631102.296216 execve("/usr/bin/ld", ["/usr/bin/ld", "-o", "tracecli", "CMakeFiles/tracecli.dir/cli.c.o", "libtracecore.a"], 0xffffccc53748 /* 51 vars */ +30557 1773631102.296334 <... clone resumed>) = 30558 +30558 1773631102.296365 <... execve resumed>) = 0 +30558 1773631102.309236 openat(AT_FDCWD, "tracecli", O_RDWR|O_CREAT|O_TRUNC, 0666) = 4 +30558 1773631102.315389 openat(AT_FDCWD, "CMakeFiles/tracecli.dir/cli.c.o", O_RDONLY) = 8 +30558 1773631102.316671 openat(AT_FDCWD, "libtracecore.a", O_RDONLY) = 9 +` + + got, diagnostics := parseStraceRecordsDetailed(content, parseOptions{ + rootCwd: "/tmp/work", + keepRoots: []string{"/tmp/work"}, + }) + if !diagnostics.Trusted() { + t.Fatalf("parse diagnostics = %#v, want trusted", diagnostics) + } + want := []Record{ + { + PID: 30556, + ParentPID: 30553, + Argv: []string{"/usr/bin/cc", "-O3", "-DNDEBUG", "CMakeFiles/tracecli.dir/cli.c.o", "-o", "tracecli", "libtracecore.a"}, + Cwd: "/tmp/work", + }, + { + PID: 30557, + ParentPID: 30556, + Argv: []string{"/usr/lib/gcc/aarch64-linux-gnu/12/collect2", "-o", "tracecli", "CMakeFiles/tracecli.dir/cli.c.o", "libtracecore.a"}, + Cwd: "/tmp/work", + }, + { + PID: 30558, + ParentPID: 30557, + Argv: []string{"/usr/bin/ld", "-o", "tracecli", "CMakeFiles/tracecli.dir/cli.c.o", "libtracecore.a"}, + Cwd: "/tmp/work", + Inputs: []string{ + "/tmp/work/_build/CMakeFiles/tracecli.dir/cli.c.o", + "/tmp/work/_build/libtracecore.a", + }, + Changes: []string{"/tmp/work/_build/tracecli"}, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseStraceRecordsDetailed() = %#v, want %#v", got, want) + } +} + +func TestParseStraceRecords_PreservesInstallExecveAndCopies(t *testing.T) { + content := ` +30565 1773631102.575121 execve("/usr/bin/cmake", ["cmake", "--install", "/tmp/work/_build", "--prefix", "/tmp/out"], 0x400228f760 /* 43 vars */ +30565 1773631102.575242 <... execve resumed>) = 0 +30565 1773631102.630849 openat(AT_FDCWD, "/tmp/work/_build/cmake_install.cmake", O_RDONLY) = 3 +30565 1773631102.632845 mkdirat(AT_FDCWD, "/tmp/out/lib", 0777) = 0 +30565 1773631102.634396 openat(AT_FDCWD, "/tmp/work/_build/libtracecore.a", O_RDONLY) = 3 +30565 1773631102.634600 openat(AT_FDCWD, "/tmp/out/lib/libtracecore.a", O_WRONLY|O_CREAT|O_TRUNC, 0600) = 4 +30565 1773631102.638920 openat(AT_FDCWD, "/tmp/work/_build/tracecli", O_RDONLY) = 3 +30565 1773631102.639102 openat(AT_FDCWD, "/tmp/out/bin/tracecli", O_WRONLY|O_CREAT|O_TRUNC, 0600) = 4 +30565 1773631102.650974 openat(AT_FDCWD, "/tmp/work/_build/trace_options.h", O_RDONLY) = 3 +30565 1773631102.651193 openat(AT_FDCWD, "/tmp/out/include/trace_options.h", O_WRONLY|O_CREAT|O_TRUNC, 0600) = 4 +` + + got, diagnostics := parseStraceRecordsDetailed(content, parseOptions{ + rootCwd: "/tmp/work", + keepRoots: []string{"/tmp/work", "/tmp/out"}, + }) + if !diagnostics.Trusted() { + t.Fatalf("parse diagnostics = %#v, want trusted", diagnostics) + } + want := []Record{ + { + PID: 30565, + Argv: []string{"cmake", "--install", "/tmp/work/_build", "--prefix", "/tmp/out"}, + Cwd: "/tmp/work", + Inputs: []string{ + "/tmp/work/_build/cmake_install.cmake", + "/tmp/work/_build/libtracecore.a", + "/tmp/work/_build/tracecli", + "/tmp/work/_build/trace_options.h", + }, + Changes: []string{ + "/tmp/out/lib", + "/tmp/out/lib/libtracecore.a", + "/tmp/out/bin/tracecli", + "/tmp/out/include/trace_options.h", + }, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseStraceRecordsDetailed() = %#v, want %#v", got, want) + } +} + +func TestParseStraceRecords_KeepRootsFilter(t *testing.T) { + content := ` +1234 1741260000.000001 chdir("/tmp/work") = 0 +1234 1741260000.000002 execve("/usr/bin/cc", ["cc", "-c", "core.c", "-o", "core.o"], 0x0) = 0 +1234 1741260000.000003 openat(AT_FDCWD, "/usr/share/cmake-3.25/Modules/CMakeCCompiler.cmake.in", O_RDONLY|O_CLOEXEC) = 3 +1234 1741260000.000004 openat(AT_FDCWD, "/opt/deps/include/dep.h", O_RDONLY|O_CLOEXEC) = 3 +1234 1741260000.000005 openat(AT_FDCWD, "core.c", O_RDONLY|O_CLOEXEC) = 3 +1234 1741260000.000006 openat(AT_FDCWD, "CMakeLists.txt", O_RDONLY|O_CLOEXEC) = 3 +1234 1741260000.000007 openat(AT_FDCWD, "notes.txt", O_RDONLY|O_CLOEXEC) = 3 +1234 1741260000.000008 openat(AT_FDCWD, "/tmp/cc-temp.s", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 4 +1234 1741260000.000009 openat(AT_FDCWD, "core.o", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 4 +1234 1741260000.000010 openat(AT_FDCWD, "libfoo.so.1.2.3", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 4 +` + + got := parseStraceRecords(content, parseOptions{ + rootCwd: "/repo", + keepRoots: []string{"/tmp/work", "/opt/deps"}, + }) + want := []Record{ + { + PID: 1234, + Argv: []string{"cc", "-c", "core.c", "-o", "core.o"}, + Cwd: "/tmp/work", + Inputs: []string{"/opt/deps/include/dep.h", "/tmp/work/core.c", "/tmp/work/CMakeLists.txt", "/tmp/work/notes.txt"}, + Changes: []string{"/tmp/work/core.o", "/tmp/work/libfoo.so.1.2.3"}, + }, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseStraceRecords() = %#v, want %#v", got, want) + } +} + +func TestParseStraceRecords_ResolvesOpenatDirfdPath(t *testing.T) { + content := ` +1234 1741260000.000001 chdir("/tmp/work") = 0 +1234 1741260000.000002 execve("/usr/lib/gcc/aarch64-linux-gnu/12/cc1", ["cc1", "xmlparse.c"], 0x0) = 0 +1234 1741260000.000003 openat(AT_FDCWD, "lib/xmlparse.c", O_RDONLY|O_CLOEXEC) = 3 +1234 1741260000.000004 openat(3, "expat_config.h", O_RDONLY|O_CLOEXEC) = 4 +` + + got := parseStraceRecords(content, parseOptions{ + rootCwd: "/repo", + keepRoots: []string{"/tmp/work"}, + }) + want := []Record{ + { + PID: 1234, + Argv: []string{"cc1", "xmlparse.c"}, + Cwd: "/tmp/work", + Inputs: []string{"/tmp/work/lib/xmlparse.c", "/tmp/work/_build/expat_config.h"}, + }, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseStraceRecords() = %#v, want %#v", got, want) + } +} + +func TestParseStraceRecords_ResolvesRenameatDirfdPaths(t *testing.T) { + content := ` +1234 1741260000.000001 chdir("/tmp/work") = 0 +1234 1741260000.000002 execve("/usr/bin/cmake", ["cmake", "--install", "."], 0x0) = 0 +1234 1741260000.000003 renameat(3, "pkg/libfoo.a", 4, "libfoo.a") = 0 +` + + got := parseStraceRecords(content, parseOptions{ + rootCwd: "/repo", + keepRoots: []string{"/tmp/work"}, + }) + want := []Record{ + { + PID: 1234, + Argv: []string{"cmake", "--install", "."}, + Cwd: "/tmp/work", + Changes: []string{"/tmp/work/_build/stage/pkg/libfoo.a", "/tmp/work/install/lib/libfoo.a"}, + }, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseStraceRecords() = %#v, want %#v", got, want) + } +} + +func TestParseStraceRecords_TreatsCreateReadOnlyOpenAsInput(t *testing.T) { + content := ` +1234 1741260000.000001 chdir("/tmp/work") = 0 +1234 1741260000.000002 execve("/usr/bin/cmake", ["cmake", "-P", "probe.cmake"], 0x0) = 0 +1234 1741260000.000003 openat(AT_FDCWD, "_build/probe.cache", O_RDONLY|O_CREAT|O_CLOEXEC, 0666) = 3 +` + + got := parseStraceRecords(content, parseOptions{ + rootCwd: "/repo", + keepRoots: []string{"/tmp/work"}, + }) + want := []Record{ + { + PID: 1234, + Argv: []string{"cmake", "-P", "probe.cmake"}, + Cwd: "/tmp/work", + Inputs: []string{"/tmp/work/_build/probe.cache"}, + }, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseStraceRecords() = %#v, want %#v", got, want) + } +} + +func TestParseStraceRecords_PrefersReturnedFDPathForSymlinkTargets(t *testing.T) { + content := ` +1234 1741260000.000001 chdir("/tmp/work") = 0 +1234 1741260000.000002 execve("/usr/bin/cc", ["cc", "-c", "core.c"], 0x0) = 0 +1234 1741260000.000003 openat(AT_FDCWD, "include/config.h", O_RDONLY|O_CLOEXEC) = 3 +` + + got := parseStraceRecords(content, parseOptions{ + rootCwd: "/repo", + keepRoots: []string{"/tmp/work"}, + }) + want := []Record{ + { + PID: 1234, + Argv: []string{"cc", "-c", "core.c"}, + Cwd: "/tmp/work", + Inputs: []string{"/tmp/work/_build/generated/config.h"}, + }, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseStraceRecords() = %#v, want %#v", got, want) + } +} + +func TestParseStraceRecordsDetailed_ReportsParseDiagnostics(t *testing.T) { + content := ` +execve("/usr/bin/cc", ["cc"], 0x0) = 0 +1234 1741260000.000002 <... openat resumed>) = 3 +1234 1741260000.000003 this is not a syscall +` + + got, diagnostics := parseStraceRecordsDetailed(content, parseOptions{rootCwd: "/repo"}) + want := []Record{ + { + Argv: []string{"cc"}, + Cwd: "/repo", + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseStraceRecordsDetailed() records = %#v, want %#v", got, want) + } + if diagnostics.MissingPIDLines != 1 { + t.Fatalf("MissingPIDLines = %d, want 1", diagnostics.MissingPIDLines) + } + if diagnostics.ResumedMismatches != 1 { + t.Fatalf("ResumedMismatches = %d, want 1", diagnostics.ResumedMismatches) + } + if diagnostics.InvalidCalls != 1 { + t.Fatalf("InvalidCalls = %d, want 1", diagnostics.InvalidCalls) + } + if diagnostics.Trusted() { + t.Fatalf("Trusted() = true, want false") + } +} + +func TestParseStraceRecordsDetailed_FallbacksMissingPIDLinesToLastSeenPID(t *testing.T) { + content := ` +1234 1741260000.000001 chdir("/tmp/work") = 0 +1234 1741260000.000002 execve("/usr/bin/cc", ["cc", "cli.o", "-o", "tracecli", "libtracecore.a"], 0x0) = 0 +1741260000.000003 openat(AT_FDCWD, "cli.o", O_RDONLY|O_CLOEXEC) = 3 +1741260000.000004 openat(AT_FDCWD, "libtracecore.a", O_RDONLY|O_CLOEXEC) = 4 +1741260000.000005 openat(AT_FDCWD, "tracecli", O_WRONLY|O_CREAT|O_TRUNC, 0777) = 5 +` + + got, diagnostics := parseStraceRecordsDetailed(content, parseOptions{rootCwd: "/repo"}) + want := []Record{ + { + PID: 1234, + Argv: []string{"cc", "cli.o", "-o", "tracecli", "libtracecore.a"}, + Cwd: "/tmp/work", + Inputs: []string{"/tmp/work/cli.o", "/tmp/work/libtracecore.a"}, + Changes: []string{"/tmp/work/tracecli"}, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseStraceRecordsDetailed() records = %#v, want %#v", got, want) + } + if diagnostics.MissingPIDLines != 3 { + t.Fatalf("MissingPIDLines = %d, want 3", diagnostics.MissingPIDLines) + } + if diagnostics.Trusted() { + t.Fatalf("Trusted() = true, want false") + } +} + +func TestParseStraceRecordsDetailed_ResetsReusedChildPIDState(t *testing.T) { + content := ` +5678 1741260000.000001 chdir("/stale") = 0 +5678 1741260000.000002 execve("/bin/true", ["true"], 0x0) = 0 +1234 1741260000.000003 chdir("/fresh") = 0 +1234 1741260000.000004 clone(child_stack=NULL, flags=CLONE_VM|CLONE_VFORK|SIGCHLD) = 5678 +5678 1741260000.000005 execve("/usr/bin/cc", ["cc", "-c", "core.c"], 0x0) = 0 +5678 1741260000.000006 openat(AT_FDCWD, "core.c", O_RDONLY|O_CLOEXEC) = 3 +` + + got, diagnostics := parseStraceRecordsDetailed(content, parseOptions{rootCwd: "/repo"}) + if len(got) != 2 { + t.Fatalf("parseStraceRecordsDetailed() len = %d, want 2", len(got)) + } + if got[1].ParentPID != 1234 { + t.Fatalf("reused pid ParentPID = %d, want %d", got[1].ParentPID, 1234) + } + if got[1].Cwd != "/fresh" { + t.Fatalf("reused pid Cwd = %q, want %q", got[1].Cwd, "/fresh") + } + if !reflect.DeepEqual(got[1].Inputs, []string{"/fresh/core.c"}) { + t.Fatalf("reused pid Inputs = %#v, want %#v", got[1].Inputs, []string{"/fresh/core.c"}) + } + if diagnostics.PIDStateResets != 1 { + t.Fatalf("PIDStateResets = %d, want 1", diagnostics.PIDStateResets) + } +}