@pi-in-go/pigpen-extension-equivalence 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/extensions/extension-equivalence/cmd/pigeq/go.mod +9 -0
  4. package/extensions/extension-equivalence/cmd/pigeq/main.go +504 -0
  5. package/extensions/extension-equivalence/cmd/pigeq/main_test.go +162 -0
  6. package/extensions/extension-equivalence/eq/bench_test.go +24 -0
  7. package/extensions/extension-equivalence/eq/compare.go +560 -0
  8. package/extensions/extension-equivalence/eq/driver/eq-driver.ts +29 -0
  9. package/extensions/extension-equivalence/eq/env.go +290 -0
  10. package/extensions/extension-equivalence/eq/env_test.go +253 -0
  11. package/extensions/extension-equivalence/eq/eq_test.go +719 -0
  12. package/extensions/extension-equivalence/eq/execscan.go +199 -0
  13. package/extensions/extension-equivalence/eq/gaps.go +568 -0
  14. package/extensions/extension-equivalence/eq/gaps_cache_test.go +18 -0
  15. package/extensions/extension-equivalence/eq/gogaps.go +166 -0
  16. package/extensions/extension-equivalence/eq/gogaps_test.go +510 -0
  17. package/extensions/extension-equivalence/eq/hostexit_test.go +54 -0
  18. package/extensions/extension-equivalence/eq/llm.go +230 -0
  19. package/extensions/extension-equivalence/eq/mutate.go +385 -0
  20. package/extensions/extension-equivalence/eq/normalize.go +240 -0
  21. package/extensions/extension-equivalence/eq/reap.go +21 -0
  22. package/extensions/extension-equivalence/eq/reap_linux.go +46 -0
  23. package/extensions/extension-equivalence/eq/reap_other.go +7 -0
  24. package/extensions/extension-equivalence/eq/reap_test.go +78 -0
  25. package/extensions/extension-equivalence/eq/run.go +712 -0
  26. package/extensions/extension-equivalence/eq/scenario.go +298 -0
  27. package/extensions/extension-equivalence/eq/scratch_test.go +75 -0
  28. package/extensions/extension-equivalence/eq/shim/eqshim.go.txt +73 -0
  29. package/extensions/extension-equivalence/eq/shim.go +175 -0
  30. package/extensions/extension-equivalence/eq/surface/go-gaps.md +145 -0
  31. package/extensions/extension-equivalence/eq/testdata/gaps/tool-renderer.ts +17 -0
  32. package/extensions/extension-equivalence/eq/testdata/surface-pig-0.3.0.md +144 -0
  33. package/extensions/extension-equivalence/eq/trace.go +176 -0
  34. package/extensions/extension-equivalence/eq/tsimports.go +82 -0
  35. package/extensions/extension-equivalence/eq/twins.go +521 -0
  36. package/extensions/extension-equivalence/eq/twins_test.go +271 -0
  37. package/extensions/extension-equivalence/eq/upstream.go +289 -0
  38. package/extensions/extension-equivalence/eq/upstream_test.go +281 -0
  39. package/extensions/extension-equivalence/extension.go +333 -0
  40. package/extensions/extension-equivalence/extension_test.go +178 -0
  41. package/extensions/extension-equivalence/fakehost_selftest_test.go +116 -0
  42. package/extensions/extension-equivalence/fakehost_test.go +548 -0
  43. package/extensions/extension-equivalence/fakehost_toolrenderer_selftest_test.go +118 -0
  44. package/extensions/extension-equivalence/go.mod +6 -0
  45. package/extensions/extension-equivalence/go.sum +2 -0
  46. package/extensions/extension-equivalence/schema_test.go +47 -0
  47. package/package.json +37 -0
  48. package/provenance.json +7 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Kinsy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # Extension equivalence harness
2
+
3
+ A Go extension Package that proves a Go port of a Pi extension behaves like the
4
+ TypeScript original. It is what the
5
+ [`pig-extension-porter`](../../piglets/pig-extension-porter/README.md) Piglet calls,
6
+ and it works on its own: `pig install ./components/extension-equivalence`.
7
+
8
+ It provides four model tools and one command-line program (`pigeq`, the same
9
+ code):
10
+
11
+ | Tool / command | Use |
12
+ |---|---|
13
+ | `equivalence_gaps` / `pigeq gaps` | Static: list the Pi APIs the TypeScript uses that the Go SDK lacks (`ctx.newSession({ setup })`, `pi.on("cache_warming_decision")`, ...). A missing API cannot show up in any scenario, because the Go lane cannot call it, so only a static check sees it. Rows come from PiG's `docs/extension-sdk-surface.md`; a snapshot of its non-implemented rows and its `pi.*` rows (PiG 0.4.1, Pi 1.0.1) is embedded, and `--surface` reads a fresh copy. A `pi.<member>` the table has no row for is an API that PiG predates (`pi.registerToolRenderer` before PiG 0.4.1) and is reported missing. `--ts` takes a file (read with the local modules it imports or re-exports) or a multi-file extension's directory; on a table where `pi.events` is missing for Go (PiG 0.3.x), the bus reached through an alias, destructuring or an index is reported too; `--accept-gaps` lists owner-approved exclusions. |
14
+ | `equivalence_run` mode `run` / `pigeq run` | Live: the original under **Pi**, the original under **PiG's Node runtime**, and the Go port under **PiG**, with the same scripted scenario. The port passes when its trace equals Pi's. The PiG-Node lane proves the trace is host-neutral. |
15
+ | mode `record` / `pigeq record` | Write golden traces from Pi (and refuse a scenario on which Pi and PiG's Node lane disagree). Commit them. |
16
+ | mode `check` / `pigeq check` | Compare only the Go port with the golden traces. Needs no Pi installation, so it can run wherever `pig` does. |
17
+ | `pigeq check --builtin --pig <Binary>` | Prove a built Piglet Binary against the goldens: the port is compiled in, so no extension directory is loaded with `-e`. Not available for `mutate`. |
18
+ | `equivalence_run` mode `mutate` / `pigeq mutate` | Apply deliberate defects to a copy of the port; every one must make a scenario fail (or, with the SDK directory, the port's own Go tests). `--unit-only` (tool: `unitOnly`) judges by the port's own tests alone, for a port with no scenarios; `--jobs N` runs mutants in parallel; each unit run is bounded by `-timeout=180s`; a mutant is judged only after the unmutated port's tests pass, and only a reported test failure is a kill. |
19
+ | (in `run`, `record`, `check`) | Two pseudo-scenarios: `sdk-gaps` (above) and `exec-coverage`: every command the original or the port starts by a literal name must be declared in some scenario's `commands` and must be a bare name, because an undeclared command runs unshimmed and unrecorded. A command in code no scenario reaches (a development launcher, a deferred feature, an SQL statement handed to a driver's `exec`) is excused by the owner with an `"exec:<name>": "<reason>"` entry in the `--accept-gaps` file; it stays listed with its reason, and a path is never excused. |
20
+ | `equivalence_diff` / `pigeq diff` | First difference between two traces. |
21
+ | `equivalence_twins` / `pigeq twins list\|check` | The twin contract: `list` writes the ledger of upstream test titles per file; `check` requires each title to have a Go `tw(...)` twin or a named `tskip(...)` (for a Go original: a test function of the same name, or one that skips first, directly or through a skip helper, as a named skip), rejects unknown titles and one reason shared by many skips, and prints the `N exact twins, M skipped` line for `PORT.md`. Helpers: the Skill's `references/twin_test.go.txt`. |
22
+ | `pigeq llm --script turns.json [--log F]` | Serves the scenarios' scripted OpenAI-compatible model on a loopback port (prints its base URL; stops when stdin closes) and logs each request as a JSON line. For a port's own end-to-end run, for example an adapter driven by a real client. |
23
+ | `pigeq env`, `pigeq source` | Session setup (CLI only). `env --root DIR --pig PIG --pi PI --source DIR --module EXTDIR` prints an isolated shell environment to source (temporary `HOME`, `PIG_HOME`, agent directories; the real `go` and `node` first on `PATH`; `GOCACHE` and `GOMODCACHE` kept; `PIG_SDK_GO_ROOT` unset) and, with `--pig` and `--module`, writes a `go.work` that resolves PiG's SDK. `source --from PIG-REPO --rev COMMIT --out DIR` exports a PiG revision as a one-commit git checkout for `PIG_SOURCE_ROOT`. |
24
+
25
+ **Go source** is scanned too: `pigeq gaps --go DIR` (tool: `equivalence_gaps` with `go`) reports
26
+ `MISSING` for an import of a PiG package other than the public SDK and for the process-global
27
+ calls PiG's fuse check rejects (`os.Exit`, `os.Chdir`, `os.Stdout`, `log.Fatal*`, `fmt.Print*`), and
28
+ `PARTIAL` for SDK stand-ins the surface table records. `run` and `check` repeat it on the port
29
+ as the `port-gaps` pseudo-scenario. Test files and `package main` files are not scanned.
30
+
31
+ **Oracles.** `record` takes exactly one of `--ts` (a TypeScript original, run under Pi: the
32
+ proof of equivalence), `--go-oracle DIR` (an original that is already a Go extension, run
33
+ under PiG) or `--self` (no original exists: the port is recorded as its own regression
34
+ baseline). The golden trace's header names the kind and `check` labels a self-recorded trace
35
+ as a baseline, not equivalence. `run` needs an original.
36
+
37
+ ## Go originals (relocations)
38
+
39
+ A Go extension moved from another repository has no Pi oracle. Pass `--go-oracle <dir>` (the
40
+ unmodified original, named with its exact package directory when it lives inside a larger
41
+ module) instead of `--ts` and `--pi`:
42
+
43
+ ```sh
44
+ pigeq record --scenarios port/scenarios --golden port/golden --go-oracle <original-dir> --pig $PIGEQ_PIG
45
+ pigeq check --scenarios port/scenarios --golden port/golden --go extensions/<name> --pig $PIGEQ_PIG
46
+ pigeq run --scenarios port/scenarios --go-oracle <original-dir> --go extensions/<name> --pig $PIGEQ_PIG
47
+ ```
48
+
49
+ To get a runnable original from a monorepo whose module replaces the SDK by a relative path,
50
+ export it and point that path at the staged SDK:
51
+
52
+ ```sh
53
+ git -C <repo> archive <full-commit> piglets/standard | tar -x -C X
54
+ mkdir -p X/extensions && ln -s "$(pig reload --sdk-path | tail -1)" X/extensions/sdk
55
+ # --go-oracle X/piglets/standard/extensions/<name>
56
+ ```
57
+
58
+ The original runs under PiG as the lane `pig-go-upstream` and is what the relocated code must
59
+ equal; there is no SDK gap scan and no host check (both lanes are the same host). Mark the
60
+ port with `port/relocation.json` so the repository's layout test expects that lane name.
61
+ `ui.custom` overlays and other terminal-only effects are still invisible to the trace.
62
+
63
+ ## How a scenario runs
64
+
65
+ Each lane starts a real host in RPC mode (`pi --mode rpc` or `pig --mode rpc`) in a
66
+ scratch workspace with a hermetic environment (no inherited HOME, credentials or
67
+ configuration). The scenario is data (see [scenarios](extensions/extension-equivalence/eq/scenario.go)):
68
+
69
+ - `files`, `setup`: the workspace, including a git repository;
70
+ - `commands`: child processes the extension may start, each `real` (run the real tool
71
+ and record the call), `canned` (fixed stdout, stderr, exit) or `missing` (not on PATH);
72
+ - `llm`: scripted model turns served by a local OpenAI-compatible server (so tool calls,
73
+ `tool_call`/`tool_result`/`context` events and turn order are exercised without a model);
74
+ - `agentFiles`: files written under the lane's agent directory before the host starts (an extension's user-level config; `{{server:NAME}}` expanded).
75
+ - `servers`, `env`: fake HTTP upstreams for an extension that calls an API (a search provider, a judge
76
+ model, a remote agent). Each server has routes (method, exact or prefix path, required query
77
+ parameters, status, headers, `body` or `json`, `delayMs`, `times`, `drop`) and gets its own loopback
78
+ port per lane. `{{server:NAME}}` in `env`, `files` and `args` is its base URL, so provider base URLs
79
+ and fake credentials can point at it. Every request it receives enters the trace (`http`: method,
80
+ path, query, body decoded, content type and the headers listed in `recordHeaders`; user agents and
81
+ other runtime-specific headers stay out), and a URL an extension echoes back is normalized to
82
+ `<server:NAME>`. Verified live: a TypeScript extension under Pi and its Go port under PiG send the
83
+ same request and produce identical traces, and a port that sends another header fails at the request.
84
+ Parallel requests arrive in a nondeterministic order. A port whose SSRF guard refuses loopback needs the
85
+ extension's own allow-list for the fake server's range, written with `agentFiles` (pigpen-websearch: `ssrf.allowRanges` in its `web-search.json`).
86
+ - `env` alone (no servers) switches an opt-in extension on: `"env": {"PIGPEN_WARDEN_ENABLED": "1"}` reaches the host process of every lane, the original under Pi and the port under PiG alike, on top of the hermetic environment (the harness's own variables and the test process's environment do not leak in). `PATH`, `HOME`, `TMPDIR`, `LANG`, `TERM`, `NO_COLOR`, the Go toolchain variables (`GOROOT`, `GOCACHE`, `GOMODCACHE`, `GOPATH`, `GOFLAGS`, `GOPROXY`, `GONOSUMDB`, `GONOSUMCHECK`, `GOSUMDB`, `GOTOOLCHAIN`), `GOWORK`, `GOENV` and every `EQ_*`, `PIG_*`, `PI_*` and `GIT_*` name are the harness's and are rejected. Tested with a fake host that records its environment (`TestScenarioEnvReachesEveryLaneAndKeepsTheHostHermetic`).
87
+ - `steps`: RPC commands, and the answers to the dialogs they raise. Session operations
88
+ that only an extension command context can start (`new session`, `fork`) go through a
89
+ small driver extension loaded in every lane (`/eq-new`, `/eq-fork`).
90
+
91
+ Recorded per step, in arrival order: UI requests (dialogs, notifications, status,
92
+ widgets), exec calls and exits (via PATH shims), requests that reached the model (the
93
+ messages, every tool's full definition, and the extension's part of the system prompt:
94
+ its difference from the same host's baseline prompt without the extension), tool
95
+ executions and results, agent and turn lifecycle, `extension_error`, command responses,
96
+ and the host exit. After the last step a final quiet period (`tailMs`, default 1000 ms)
97
+ records late effects instead of losing them at shutdown. The normalizer removes only values that differ on every run
98
+ (paths, UUIDs, timestamps, host session ids, the bash tool's measured wall time); its rules N1 to N6 are listed in
99
+ [normalize.go](extensions/extension-equivalence/eq/normalize.go) and versioned, and a
100
+ golden trace recorded under another version is refused.
101
+
102
+ ## Limits
103
+
104
+ - The trace holds what an extension can influence through RPC. Terminal cells and
105
+ TUI-only behavior (custom components, overlays) are not compared.
106
+ - The `hasUI == false` branch cannot be reached through RPC. Test it with the fake host
107
+ described in the Skill (`references/fakehost_test.go.txt`).
108
+ - Effects the extension starts without awaiting are collected during a quiet period
109
+ (`settleMs`, default 150 ms, plus `tailMs` after the last step). An effect later than
110
+ that is not seen. Parallel exec calls arrive in a nondeterministic order.
111
+ - Only declared commands are recorded. `exec-coverage` catches literal command names;
112
+ a computed name (`pi.exec(tool, ...)`) must be declared by hand and reviewed.
113
+ - The host's own system prompt wording is not compared, only the extension's difference
114
+ from it. An extension that removes host text shows the removed text, which may differ
115
+ between Pi and PiG (a host-check failure to record, not to normalize).
116
+ - The extension's stderr and log output are not in the trace.
117
+ - Linux and macOS. The shims are POSIX symlinks; Windows is covered by the Skill's
118
+ Windows notes and `go vet`, not by this harness.
119
+ - The harness needs `go` (it builds its shim and PiG builds Go extensions on first use)
120
+ and `node` (TypeScript lanes).
121
+
122
+ ## Use from a checkout
123
+
124
+ ```sh
125
+ PIG_BIN=/path/to/pig npm run build:pigeq # dist/bin/pigeq (a go.work resolves PiG's SDK)
126
+ dist/bin/pigeq env --root /tmp/porting --pig /path/to/pig --pi /path/to/pi > /tmp/porting/env.sh && . /tmp/porting/env.sh
127
+ pigeq run --scenarios <port>/port/scenarios --ts <original>.ts --go <port-dir>
128
+ ```
129
+
130
+ MIT. The harness is original work; PiG's SDK surface table is quoted as data (rows
131
+ listed with their revision in `eq/surface/go-gaps.md`).
@@ -0,0 +1,9 @@
1
+ module github.com/MichaelKinsy/pigpen/extension-equivalence/cmd/pigeq
2
+
3
+ go 1.26
4
+
5
+ // A module of its own: PiG rejects an extension directory that holds both a factory and a
6
+ // standalone main package, so the command line sits beside the factory, not in its module.
7
+ require github.com/MichaelKinsy/pigpen/extension-equivalence v0.0.0
8
+
9
+ replace github.com/MichaelKinsy/pigpen/extension-equivalence => ../..
@@ -0,0 +1,504 @@
1
+ // Command pigeq is the extension equivalence harness.
2
+ //
3
+ // pigeq run --scenarios DIR --ts FILE --go DIR --pi PI --pig PIG oracle and port, live
4
+ // pigeq record --scenarios DIR --golden DIR --ts FILE --pi PI [--pig PIG]
5
+ // pigeq check --scenarios DIR --golden DIR --go DIR --pig PIG port against golden traces
6
+ // pigeq mutate --scenarios DIR --golden DIR --go DIR --pig PIG --mutations FILE [--unit --sdk-dir DIR]
7
+ // pigeq lane --scenarios DIR --host pi|pig --bin EXE --ext PATH --out DIR one lane, traces only
8
+ // pigeq gaps --ts FILE|DIR | --go DIR [--surface FILE] [--accept-gaps FILE]
9
+ // Pi APIs the Go SDK lacks (TS), or PiG-internal
10
+ // imports, fuse hazards and SDK stand-ins (Go)
11
+ //
12
+ // The oracle is one of --ts (TypeScript original under Pi), --go-oracle (a Go original under
13
+ // PiG) or --self (no original: record the port as its own regression baseline, never proof).
14
+ //
15
+ // pigeq diff WANT.jsonl GOT.jsonl
16
+ // pigeq env --root DIR [--pig PIG] [--pi PI] [--source DIR] [--module DIR]...
17
+ // print an isolated shell environment (source it);
18
+ // with --pig and --module also writes DIR/go.work
19
+ // pigeq twins list --tests DIR > ledger.json upstream test titles per file
20
+ // pigeq twins check --ledger ledger.json --go DIR [--files a,b] every title has a tw() twin or a named tskip()
21
+ // pigeq llm --script turns.json [--log requests.jsonl] serve the scripted model (prints its base URL)
22
+ // pigeq source --from PIG-REPO --rev REV --out DIR export a PiG revision as a one-commit git checkout
23
+ // (PIG_SOURCE_ROOT for `pig piglet build`)
24
+ //
25
+ // PIGEQ_PI and PIGEQ_PIG supply the host executables. Exit status is 0 only when
26
+ // every scenario passes (or, for mutate, every mutation is caught).
27
+ package main
28
+
29
+ import (
30
+ "encoding/json"
31
+ "flag"
32
+ "fmt"
33
+ "io"
34
+ "os"
35
+ "os/signal"
36
+ "path/filepath"
37
+ "strings"
38
+ "syscall"
39
+ "time"
40
+
41
+ "github.com/MichaelKinsy/pigpen/extension-equivalence/eq"
42
+ )
43
+
44
+ func main() {
45
+ os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
46
+ }
47
+
48
+ func run(args []string, stdout, stderr io.Writer) int {
49
+ if len(args) == 0 {
50
+ fmt.Fprintln(stderr, "usage: pigeq run|record|check|mutate|lane|gaps|diff|env|source|twins|llm [flags]")
51
+ return 2
52
+ }
53
+ cmd, rest := args[0], args[1:]
54
+ if cmd == "env" || cmd == "source" {
55
+ return session(cmd, rest, stdout, stderr)
56
+ }
57
+ if cmd == "twins" {
58
+ return twins(rest, stdout, stderr)
59
+ }
60
+ if cmd == "llm" {
61
+ return llm(rest, stdout, stderr)
62
+ }
63
+ fs := flag.NewFlagSet("pigeq "+cmd, flag.ContinueOnError)
64
+ fs.SetOutput(stderr)
65
+ var (
66
+ scenarios = fs.String("scenarios", "", "scenario file or directory")
67
+ golden = fs.String("golden", "", "directory of golden traces")
68
+ ts = fs.String("ts", "", "the original TypeScript extension (oracle)")
69
+ goDir = fs.String("go", "", "the Go port directory (gaps: the Go source to scan)")
70
+ goOracle = fs.String("go-oracle", "", "an original that is already a Go extension (oracle, run under PiG)")
71
+ self = fs.Bool("self", false, "no original exists: record the port as its own regression baseline (not equivalence)")
72
+ pi = fs.String("pi", os.Getenv("PIGEQ_PI"), "pi executable (default $PIGEQ_PI)")
73
+ pig = fs.String("pig", os.Getenv("PIGEQ_PIG"), "pig executable (default $PIGEQ_PIG)")
74
+ out = fs.String("out", "", "directory for recorded traces")
75
+ mutations = fs.String("mutations", "", "mutation list (JSON)")
76
+ skipHost = fs.Bool("skip-host-check", false, "do not run the original under PiG")
77
+ host = fs.String("host", "", "lane host: pi or pig")
78
+ bin = fs.String("bin", "", "lane host executable")
79
+ ext = fs.String("ext", "", "lane extension (TypeScript file or Go directory)")
80
+ surface = fs.String("surface", os.Getenv("PIGEQ_SURFACE"), "PiG docs/extension-sdk-surface.md (default: embedded snapshot)")
81
+ accept = fs.String("accept-gaps", "", "JSON object mapping an accepted gap symbol to its owner approval")
82
+ sdkDir = fs.String("sdk-dir", os.Getenv("PIG_SDK_DIR"), "PiG's staged Go SDK (mutate: enables the unit-test layer)")
83
+ unitTests = fs.Bool("unit", false, "mutate: also run the port's go tests on each mutant first (needs --sdk-dir)")
84
+ builtin = fs.Bool("builtin", false, "check, record --self: the port is compiled into --pig (a built Piglet Binary); load no extension directory")
85
+ unitOnly = fs.Bool("unit-only", false, "mutate: judge mutants by the port's own go tests alone; no scenarios, golden traces or pig (for an original adapter)")
86
+ jobs = fs.Int("jobs", 1, "mutate: how many mutants to run at once (each has its own copy)")
87
+ keep = fs.Bool("keep", false, "keep each lane's run directory")
88
+ stepSecs = fs.Int("step-timeout", 0, "seconds to wait for each scenario step (default 60; 20 for mutants)")
89
+ )
90
+ if err := fs.Parse(rest); err != nil {
91
+ return 2
92
+ }
93
+ var accepted map[string]string
94
+ if *accept != "" {
95
+ b, err := os.ReadFile(*accept)
96
+ if err == nil {
97
+ err = json.Unmarshal(b, &accepted)
98
+ }
99
+ if err != nil {
100
+ fmt.Fprintln(stderr, "pigeq: --accept-gaps:", err)
101
+ return 2
102
+ }
103
+ }
104
+ cfg := eq.Config{Surface: *surface, AcceptedGaps: accepted, Pi: *pi, Pig: *pig, TS: *ts, Go: *goDir, GoOracle: *goOracle, Self: *self, Builtin: *builtin, Out: *out, SkipHostCheck: *skipHost,
105
+ Options: eq.Options{Keep: *keep, Stderr: stderr, StepTimeout: time.Duration(*stepSecs) * time.Second}}
106
+ need := func(pairs ...string) bool {
107
+ for i := 0; i < len(pairs); i += 2 {
108
+ if pairs[i+1] == "" {
109
+ fmt.Fprintf(stderr, "pigeq %s: %s is required\n", cmd, pairs[i])
110
+ return false
111
+ }
112
+ }
113
+ return true
114
+ }
115
+ if cmd == "gaps" {
116
+ if (*ts == "") == (*goDir == "") {
117
+ fmt.Fprintln(stderr, "pigeq gaps: give --ts or --go (a TypeScript original or Go source), not both and not neither")
118
+ return 2
119
+ }
120
+ surf := ""
121
+ if *surface != "" {
122
+ b, err := os.ReadFile(*surface)
123
+ if err != nil {
124
+ fmt.Fprintln(stderr, err)
125
+ return 2
126
+ }
127
+ surf = string(b)
128
+ }
129
+ var gaps []eq.Gap
130
+ var err error
131
+ if *ts != "" {
132
+ if ok, oerr := eq.PathLooksLikeExtension(*ts); oerr == nil && !ok {
133
+ fmt.Fprint(stdout, eq.NotAnExtensionNote())
134
+ return 1
135
+ }
136
+ gaps, err = eq.ScanGapsPath(*ts, surf)
137
+ } else {
138
+ gaps, err = eq.ScanGoPath(*goDir, surf)
139
+ }
140
+ if err != nil {
141
+ fmt.Fprintln(stderr, err)
142
+ return 2
143
+ }
144
+ fmt.Fprint(stdout, eq.FormatGaps(gaps))
145
+ // An owner-approved exclusion (--accept-gaps) does not block; it is still listed.
146
+ if open := eq.Unaccepted(gaps, accepted); len(open) > 0 {
147
+ return 1
148
+ }
149
+ for _, g := range gaps {
150
+ if reason, ok := accepted[g.Symbol]; ok {
151
+ fmt.Fprintf(stdout, "ACCEPTED %s: %s\n", g.Symbol, reason)
152
+ }
153
+ }
154
+ return 0
155
+ }
156
+ if cmd == "diff" {
157
+ if fs.NArg() != 2 {
158
+ fmt.Fprintln(stderr, "usage: pigeq diff WANT.jsonl GOT.jsonl")
159
+ return 2
160
+ }
161
+ want, err := eq.ReadTrace(fs.Arg(0))
162
+ if err != nil {
163
+ fmt.Fprintln(stderr, err)
164
+ return 2
165
+ }
166
+ got, err := eq.ReadTrace(fs.Arg(1))
167
+ if err != nil {
168
+ fmt.Fprintln(stderr, err)
169
+ return 2
170
+ }
171
+ if d := eq.Diff(want, got); d != nil {
172
+ fmt.Fprintln(stdout, "DIFFERENT:", d)
173
+ return 1
174
+ }
175
+ fmt.Fprintf(stdout, "IDENTICAL (%d events)\n", len(want.Events))
176
+ return 0
177
+ }
178
+ if cmd == "mutate" && *builtin {
179
+ fmt.Fprintln(stderr, "pigeq mutate: cannot mutate a built binary; mutate the port's source directory (--go)")
180
+ return 2
181
+ }
182
+ if cmd == "mutate" && *unitOnly {
183
+ return mutateUnitOnly(cfg, *mutations, *sdkDir, *jobs, stdout, stderr, need)
184
+ }
185
+ if !need("--scenarios", *scenarios) {
186
+ return 2
187
+ }
188
+ scs, err := eq.LoadScenarios(*scenarios)
189
+ if err != nil {
190
+ fmt.Fprintln(stderr, err)
191
+ return 2
192
+ }
193
+ var results []eq.Result
194
+ switch cmd {
195
+ case "run":
196
+ if !need("--go", *goDir, "--pig", *pig) {
197
+ return 2
198
+ }
199
+ results, err = eq.Run(scs, cfg)
200
+ case "lane":
201
+ if !need("--host", *host, "--bin", *bin, "--ext", *ext) {
202
+ return 2
203
+ }
204
+ results, err = eq.LaneTraces(scs, cfg, eq.Lane{Name: *host + "-lane", Host: *host, Bin: *bin, Ext: *ext})
205
+ case "record":
206
+ if !need("--golden", *golden) {
207
+ return 2
208
+ }
209
+ if *ts == "" && *goOracle == "" && !*self {
210
+ fmt.Fprintln(stderr, "pigeq record: give the oracle: --ts, --go-oracle or --self")
211
+ return 2
212
+ }
213
+ results, err = eq.Record(scs, cfg, *golden)
214
+ case "check":
215
+ if *builtin {
216
+ cfg.Builtin = true
217
+ if !need("--golden", *golden, "--pig", *pig) {
218
+ return 2
219
+ }
220
+ } else if !need("--golden", *golden, "--go", *goDir, "--pig", *pig) {
221
+ return 2
222
+ }
223
+ results, err = eq.Check(scs, cfg, *golden)
224
+ case "mutate":
225
+ if !need("--golden", *golden, "--go", *goDir, "--pig", *pig, "--mutations", *mutations) {
226
+ return 2
227
+ }
228
+ list, lerr := eq.LoadMutations(*mutations)
229
+ if lerr != nil {
230
+ fmt.Fprintln(stderr, lerr)
231
+ return 2
232
+ }
233
+ var unit *eq.UnitTest
234
+ if *unitTests {
235
+ if *sdkDir == "" {
236
+ fmt.Fprintln(stderr, "pigeq mutate: --unit needs --sdk-dir (or $PIG_SDK_DIR)")
237
+ return 2
238
+ }
239
+ unit = eq.NewUnitTest(*sdkDir)
240
+ }
241
+ cfg.Jobs = *jobs
242
+ mres, merr := eq.Mutate(scs, cfg, *golden, list, unit)
243
+ if merr != nil {
244
+ fmt.Fprintln(stderr, merr)
245
+ return 2
246
+ }
247
+ return reportMutations(stdout, mres)
248
+ default:
249
+ fmt.Fprintf(stderr, "pigeq: unknown command %q\n", cmd)
250
+ return 2
251
+ }
252
+ if err != nil {
253
+ fmt.Fprintln(stderr, err)
254
+ return 2
255
+ }
256
+ if !eq.WriteReport(stdout, results) {
257
+ return 1
258
+ }
259
+ return 0
260
+ }
261
+
262
+ // session implements the two commands that prepare a porting session.
263
+ func session(cmd string, args []string, stdout, stderr io.Writer) int {
264
+ fs := flag.NewFlagSet("pigeq "+cmd, flag.ContinueOnError)
265
+ fs.SetOutput(stderr)
266
+ root := fs.String("root", "", "env: scratch root; home, pighome and agent live under it (default: a new private directory)")
267
+ pig := fs.String("pig", os.Getenv("PIGEQ_PIG"), "env: pig executable")
268
+ pi := fs.String("pi", os.Getenv("PIGEQ_PI"), "env: pi executable")
269
+ source := fs.String("source", os.Getenv("PIG_SOURCE_ROOT"), "env: git checkout of the PiG source the pig was built from")
270
+ var modules []string
271
+ fs.Func("module", "env: a Go module directory of the port (repeatable); needs --pig", func(v string) error {
272
+ abs, err := filepath.Abs(v)
273
+ modules = append(modules, abs)
274
+ return err
275
+ })
276
+ from := fs.String("from", "", "source: a PiG git repository")
277
+ rev := fs.String("rev", "", "source: the revision to export (a full commit)")
278
+ out := fs.String("out", "", "source: the new directory")
279
+ if err := fs.Parse(args); err != nil {
280
+ return 2
281
+ }
282
+ if cmd == "source" {
283
+ if *from == "" || *rev == "" || *out == "" {
284
+ fmt.Fprintln(stderr, "pigeq source: --from, --rev and --out are required")
285
+ return 2
286
+ }
287
+ abs, err := filepath.Abs(*out)
288
+ if err == nil {
289
+ err = eq.SnapshotSource(*from, *rev, abs)
290
+ }
291
+ if err != nil {
292
+ fmt.Fprintln(stderr, "pigeq source:", err)
293
+ return 2
294
+ }
295
+ fmt.Fprintf(stdout, "export PIG_SOURCE_ROOT=%s\n", abs)
296
+ return 0
297
+ }
298
+ if *root == "" {
299
+ // A private directory per call: lanes that each picked /tmp/<name> overwrote one another's env.sh.
300
+ dir, err := os.MkdirTemp("", "pigeq-")
301
+ if err != nil {
302
+ fmt.Fprintln(stderr, "pigeq env:", err)
303
+ return 2
304
+ }
305
+ *root = dir
306
+ }
307
+ abs, err := filepath.Abs(*root)
308
+ if err != nil {
309
+ fmt.Fprintln(stderr, "pigeq env:", err)
310
+ return 2
311
+ }
312
+ if *pi != "" && *pig != "" {
313
+ if err := eq.CheckPiVersion(*pi, *pig); err != nil {
314
+ fmt.Fprintln(stderr, "pigeq env:", err)
315
+ return 2
316
+ }
317
+ }
318
+ spec, err := eq.DiscoverToolchain()
319
+ if err != nil {
320
+ fmt.Fprintln(stderr, "pigeq env:", err)
321
+ return 2
322
+ }
323
+ spec.Root, spec.Pig, spec.Pi, spec.Source = abs, *pig, *pi, *source
324
+ if *pig != "" && len(modules) > 0 {
325
+ sdk, err := eq.SDKPath(*pig, abs)
326
+ if err == nil {
327
+ spec.SDKDir, spec.GoWork = sdk, filepath.Join(abs, "go.work")
328
+ err = eq.WriteGoWork(spec.GoWork, sdk, modules)
329
+ }
330
+ if err != nil {
331
+ fmt.Fprintln(stderr, "pigeq env:", err)
332
+ return 2
333
+ }
334
+ }
335
+ fmt.Fprint(stdout, eq.EnvScript(spec))
336
+ return 0
337
+ }
338
+
339
+ // twins implements `pigeq twins list|check`.
340
+ func twins(args []string, stdout, stderr io.Writer) int {
341
+ if len(args) == 0 || (args[0] != "list" && args[0] != "check") {
342
+ fmt.Fprintln(stderr, "usage: pigeq twins list --tests DIR | pigeq twins check --ledger FILE --go DIR [--files a,b] [--deferred slices.json] [--max-same-reason N]")
343
+ return 2
344
+ }
345
+ sub := args[0]
346
+ fs := flag.NewFlagSet("pigeq twins "+sub, flag.ContinueOnError)
347
+ fs.SetOutput(stderr)
348
+ tests := fs.String("tests", "", "list: the upstream test directory")
349
+ ledgerPath := fs.String("ledger", "", "check: the ledger written by list")
350
+ goDir := fs.String("go", "", "check: the port directory with the Go twins")
351
+ files := fs.String("files", "", "check: only these ledger files (the slice shipped so far), comma separated")
352
+ deferred := fs.String("deferred", "", "check: JSON map of ledger file -> reason; the file's cases without a twin or named skip are deferred to a later slice (stated once)")
353
+ same := fs.Int("max-same-reason", 0, "check: most skips that may share one reason (default 3)")
354
+ twinFn := fs.String("twin", "tw", "check: name of the twin helper")
355
+ skipFn := fs.String("skip", "tskip", "check: name of the named-skip helper")
356
+ if err := fs.Parse(args[1:]); err != nil {
357
+ return 2
358
+ }
359
+ if sub == "list" {
360
+ if *tests == "" {
361
+ fmt.Fprintln(stderr, "pigeq twins list: --tests is required")
362
+ return 2
363
+ }
364
+ ledger, err := eq.ListUpstreamTitles(*tests)
365
+ if err != nil {
366
+ fmt.Fprintln(stderr, "pigeq twins list:", err)
367
+ return 2
368
+ }
369
+ enc := json.NewEncoder(stdout)
370
+ enc.SetIndent("", " ")
371
+ if err := enc.Encode(ledger); err != nil {
372
+ fmt.Fprintln(stderr, err)
373
+ return 2
374
+ }
375
+ return 0
376
+ }
377
+ if *ledgerPath == "" || *goDir == "" {
378
+ fmt.Fprintln(stderr, "pigeq twins check: --ledger and --go are required")
379
+ return 2
380
+ }
381
+ var ledger map[string][]string
382
+ b, err := os.ReadFile(*ledgerPath)
383
+ if err == nil {
384
+ err = json.Unmarshal(b, &ledger)
385
+ }
386
+ if err != nil {
387
+ fmt.Fprintln(stderr, "pigeq twins check:", err)
388
+ return 2
389
+ }
390
+ opts := eq.TwinOptions{MaxSameReason: *same, TwinFunc: *twinFn, SkipFunc: *skipFn}
391
+ if *deferred != "" {
392
+ db, derr := os.ReadFile(*deferred)
393
+ if derr == nil {
394
+ derr = json.Unmarshal(db, &opts.Deferred)
395
+ }
396
+ if derr != nil {
397
+ fmt.Fprintln(stderr, "pigeq twins check:", derr)
398
+ return 2
399
+ }
400
+ }
401
+ if *files != "" {
402
+ opts.Files = strings.Split(*files, ",")
403
+ }
404
+ rep, err := eq.CheckTwins(ledger, *goDir, opts)
405
+ if err != nil {
406
+ fmt.Fprintln(stderr, "pigeq twins check:", err)
407
+ return 2
408
+ }
409
+ fmt.Fprint(stdout, rep.Summary())
410
+ if !rep.OK() {
411
+ return 1
412
+ }
413
+ return 0
414
+ }
415
+
416
+ // llm serves the scripted OpenAI-compatible model until stdin closes or a signal arrives.
417
+ func llm(args []string, stdout, stderr io.Writer) int {
418
+ fs := flag.NewFlagSet("pigeq llm", flag.ContinueOnError)
419
+ fs.SetOutput(stderr)
420
+ script := fs.String("script", "", "JSON array of turns: [{\"text\":\"...\"},{\"toolCalls\":[{\"name\":\"bash\",\"arguments\":{}}]}]")
421
+ logPath := fs.String("log", "", "write each request as a JSON line to this file (default: none)")
422
+ if err := fs.Parse(args); err != nil {
423
+ return 2
424
+ }
425
+ if *script == "" {
426
+ fmt.Fprintln(stderr, "pigeq llm: --script is required")
427
+ return 2
428
+ }
429
+ var turns []eq.Turn
430
+ b, err := os.ReadFile(*script)
431
+ if err == nil {
432
+ err = json.Unmarshal(b, &turns)
433
+ }
434
+ if err != nil {
435
+ fmt.Fprintln(stderr, "pigeq llm:", err)
436
+ return 2
437
+ }
438
+ var log io.Writer
439
+ if *logPath != "" {
440
+ f, err := os.Create(*logPath)
441
+ if err != nil {
442
+ fmt.Fprintln(stderr, "pigeq llm:", err)
443
+ return 2
444
+ }
445
+ defer f.Close()
446
+ log = f
447
+ }
448
+ url, stop, err := eq.ServeLLM(turns, log)
449
+ if err != nil {
450
+ fmt.Fprintln(stderr, "pigeq llm:", err)
451
+ return 2
452
+ }
453
+ defer stop()
454
+ fmt.Fprintln(stdout, url)
455
+ sig := make(chan os.Signal, 1)
456
+ signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
457
+ done := make(chan struct{})
458
+ go func() { _, _ = io.Copy(io.Discard, os.Stdin); close(done) }()
459
+ select {
460
+ case <-sig:
461
+ case <-done:
462
+ }
463
+ return 0
464
+ }
465
+
466
+ // reportMutations prints one status per mutant and the summary line; it returns the exit status.
467
+ func reportMutations(stdout io.Writer, mres []eq.MutationResult) int {
468
+ survived := 0
469
+ for _, m := range mres {
470
+ status := "KILLED "
471
+ if m.Invalid {
472
+ status = "INVALID "
473
+ survived++
474
+ } else if !m.Killed {
475
+ status = "SURVIVED"
476
+ survived++
477
+ }
478
+ fmt.Fprintf(stdout, "%s %s\n %s\n", status, m.Mutation.Name, m.Detail)
479
+ }
480
+ fmt.Fprintf(stdout, "%d mutation(s), %d not killed\n", len(mres), survived)
481
+ if survived > 0 {
482
+ return 1
483
+ }
484
+ return 0
485
+ }
486
+
487
+ // mutateUnitOnly runs mutants against the port's own tests only.
488
+ func mutateUnitOnly(cfg eq.Config, mutations, sdkDir string, jobs int, stdout, stderr io.Writer, need func(pairs ...string) bool) int {
489
+ if !need("--go", cfg.Go, "--mutations", mutations, "--sdk-dir", sdkDir) {
490
+ return 2
491
+ }
492
+ list, err := eq.LoadMutations(mutations)
493
+ if err != nil {
494
+ fmt.Fprintln(stderr, err)
495
+ return 2
496
+ }
497
+ cfg.UnitOnly, cfg.Jobs = true, jobs
498
+ mres, err := eq.Mutate(nil, cfg, "", list, eq.NewUnitTest(sdkDir))
499
+ if err != nil {
500
+ fmt.Fprintln(stderr, err)
501
+ return 2
502
+ }
503
+ return reportMutations(stdout, mres)
504
+ }