From 4debc130f43fbec64995d2ecca1c781ccd6eca5d Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 24 Jul 2026 10:27:27 +0800 Subject: [PATCH 1/3] refactor(g4): extract wire protocol into sidecarproto Introduce driver/internal/sidecarproto as the single source of truth for the Go/TS wire protocol: asset filenames, dispatch modes, override env vars, the per-mode argument vectors, and the sidecar summary parsers. Delegate the orchestrator's TS-owned summary scraping to sidecarproto.ParsePipelineSummary / ParseLintSummary while its Go-report scraping stays put (G6 retires it). --- .../driver/internal/orchestrator/summarize.go | 46 +++--- .../driver/internal/sidecarproto/command.go | 83 +++++++++++ .../internal/sidecarproto/command_test.go | 135 ++++++++++++++++++ .../internal/sidecarproto/sidecarproto.go | 88 ++++++++++++ .../sidecarproto/sidecarproto_test.go | 83 +++++++++++ .../driver/internal/sidecarproto/summary.go | 112 +++++++++++++++ .../internal/sidecarproto/summary_test.go | 58 ++++++++ 7 files changed, 576 insertions(+), 29 deletions(-) create mode 100644 packages/go/driver/internal/sidecarproto/command.go create mode 100644 packages/go/driver/internal/sidecarproto/command_test.go create mode 100644 packages/go/driver/internal/sidecarproto/sidecarproto.go create mode 100644 packages/go/driver/internal/sidecarproto/sidecarproto_test.go create mode 100644 packages/go/driver/internal/sidecarproto/summary.go create mode 100644 packages/go/driver/internal/sidecarproto/summary_test.go diff --git a/packages/go/driver/internal/orchestrator/summarize.go b/packages/go/driver/internal/orchestrator/summarize.go index 9bc9451..9508784 100644 --- a/packages/go/driver/internal/orchestrator/summarize.go +++ b/packages/go/driver/internal/orchestrator/summarize.go @@ -4,20 +4,18 @@ import ( "fmt" "regexp" "strings" + + "go.ollin.sh/fmtkit/driver/internal/sidecarproto" ) // The summarizers distill a step's captured output into the aligned detail -// lines shown under its section header. +// lines shown under its section header. Lines the TS sidecar emits are parsed +// by sidecarproto; the Go-report scraping below stays here (G6 retires it). var ( - lintResultPattern = regexp.MustCompile(`Found [0-9]+ warning|[0-9]+ error`) goFileSummaryPattern = regexp.MustCompile(`^ (Formatted|Checked) [0-9]+ file\(s\)\.$|^ No Go files found\.$`) goVetSummaryPattern = regexp.MustCompile(`^ go vet \./\.\.\. passed\.$|^ Skipped automatic go vet `) sourcesMissingPrefix = "[sources] path not found, skipping:" - blankLinesPrefix = "[blank-lines] processed " - fluentChainsPrefix = "[fluent-chains] processed " - oxfmtFinishedPrefix = "Finished in " - validateSyntaxPrefix = "[validate-syntax] checked " lintNothingToLintLine = "[lint] no TS/Vue files to lint." goResultPrefix = " Result: " ) @@ -39,55 +37,45 @@ func lastWithPrefix(logLines []string, prefix string) string { } func summarizeTSFormat(log string, l *logger) { - logLines := lines(log) + summary := sidecarproto.ParsePipelineSummary(log) missing := 0 - for _, line := range logLines { + for _, line := range lines(log) { if strings.HasPrefix(line, sourcesMissingPrefix) { missing++ } } - if line := lastWithPrefix(logLines, blankLinesPrefix); line != "" { - l.detail("blank-lines", strings.TrimPrefix(line, "[blank-lines] ")) + if summary.BlankLines != "" { + l.detail("blank-lines", summary.BlankLines) } if missing > 0 { l.detail("skipped", fmt.Sprintf("%d missing tracked file(s)", missing)) } - if line := lastWithPrefix(logLines, oxfmtFinishedPrefix); line != "" { - l.detail("oxfmt", line) + if summary.Oxfmt != "" { + l.detail("oxfmt", summary.Oxfmt) } - if line := lastWithPrefix(logLines, fluentChainsPrefix); line != "" { - l.detail("fluent", strings.TrimPrefix(line, "[fluent-chains] ")) + if summary.FluentChains != "" { + l.detail("fluent", summary.FluentChains) } - if line := lastWithPrefix(logLines, validateSyntaxPrefix); line != "" { - l.detail("validated", strings.TrimPrefix(line, "[validate-syntax] ")) + if summary.ValidateSyntax != "" { + l.detail("validated", summary.ValidateSyntax) } } func summarizeTSLint(log string, l *logger) { - logLines := lines(log) - - if lastWithPrefix(logLines, lintNothingToLintLine) != "" { + if lastWithPrefix(lines(log), lintNothingToLintLine) != "" { l.detail("oxlint", strings.TrimPrefix(lintNothingToLintLine, "[lint] ")) return } - var match string - - for _, line := range logLines { - if lintResultPattern.MatchString(line) { - match = line - } - } - - if match != "" { - l.detail("oxlint", match) + if result := sidecarproto.ParseLintSummary(log).Result; result != "" { + l.detail("oxlint", result) return } diff --git a/packages/go/driver/internal/sidecarproto/command.go b/packages/go/driver/internal/sidecarproto/command.go new file mode 100644 index 0000000..2a069f3 --- /dev/null +++ b/packages/go/driver/internal/sidecarproto/command.go @@ -0,0 +1,83 @@ +package sidecarproto + +// The command types below build the exact argument vectors each sidecar mode +// expects. The bin resolution (which executable to spawn) is the caller's +// concern; these types own only the argv the sidecar itself parses. + +// PipelineCommand describes a full-pipeline invocation. OxfmtBin is the +// already-resolved oxfmt executable the sidecar shells out to, and OxfmtConfig +// is the resolved config path, or "" to let oxfmt auto-discover. +type PipelineCommand struct { + OxfmtBin string + OxfmtConfig string + FormatFiles []string + SyntaxFiles []string +} + +// Argv returns the pipeline mode's argument vector. +func (c PipelineCommand) Argv() []string { + args := []string{ModePipeline} + + args = append(args, "--oxfmt-bin", c.OxfmtBin) + + if c.OxfmtConfig != "" { + args = append(args, "--oxfmt-config", c.OxfmtConfig) + } + + args = append(args, "--format-files") + args = append(args, c.FormatFiles...) + args = append(args, "--syntax-files") + args = append(args, c.SyntaxFiles...) + + return args +} + +// OxlintCommand describes an oxlint invocation. ViaSidecar is set when the +// sidecar dispatches oxlint (and so must be told the mode); a direct OXLINT_BIN +// override clears it. Config is the resolved config path, or "" for +// auto-discovery. +type OxlintCommand struct { + ViaSidecar bool + Fix bool + Config string + Files []string +} + +// Argv returns oxlint's argument vector. +func (c OxlintCommand) Argv() []string { + var args []string + + if c.ViaSidecar { + args = append(args, ModeOxlint) + } + + if c.Fix { + args = append(args, "--fix") + } + + if c.Config != "" { + args = append(args, "--config", c.Config) + } + + args = append(args, c.Files...) + + return args +} + +// MigrateCommand describes an `oxfmt --migrate=prettier` invocation. ViaSidecar +// is set when the sidecar dispatches oxfmt; a direct OXFMT_BIN override clears +// it. +type MigrateCommand struct { + ViaSidecar bool +} + +// Argv returns the migration argument vector. +func (c MigrateCommand) Argv() (args []string) { + if c.ViaSidecar { + args = append(args, ModeOxfmt) + } + + args = append(args, "--migrate=prettier") + + return args +} diff --git a/packages/go/driver/internal/sidecarproto/command_test.go b/packages/go/driver/internal/sidecarproto/command_test.go new file mode 100644 index 0000000..1d2c62a --- /dev/null +++ b/packages/go/driver/internal/sidecarproto/command_test.go @@ -0,0 +1,135 @@ +package sidecarproto + +import ( + "reflect" + "testing" +) + +func TestPipelineCommandArgv(t *testing.T) { + cmd := PipelineCommand{ + OxfmtBin: "/tools/fmtkit-ts-sidecar", + OxfmtConfig: "/cfg/.oxfmtrc.json", + FormatFiles: []string{"/work/app.ts", "/work/types.ts"}, + SyntaxFiles: []string{"/work/app.ts", "/work/decl.d.ts", "/work/types.ts"}, + } + + want := []string{ + "pipeline", + "--oxfmt-bin", "/tools/fmtkit-ts-sidecar", + "--oxfmt-config", "/cfg/.oxfmtrc.json", + "--format-files", + "/work/app.ts", "/work/types.ts", + "--syntax-files", + "/work/app.ts", "/work/decl.d.ts", "/work/types.ts", + } + + if got := cmd.Argv(); !reflect.DeepEqual(got, want) { + t.Fatalf("Argv() = %q, want %q", got, want) + } +} + +func TestPipelineCommandArgvOmitsEmptyConfig(t *testing.T) { + cmd := PipelineCommand{ + OxfmtBin: "/tools/fmtkit-ts-sidecar", + FormatFiles: []string{"/work/app.ts"}, + SyntaxFiles: []string{"/work/app.ts"}, + } + + want := []string{ + "pipeline", + "--oxfmt-bin", "/tools/fmtkit-ts-sidecar", + "--format-files", + "/work/app.ts", + "--syntax-files", + "/work/app.ts", + } + + if got := cmd.Argv(); !reflect.DeepEqual(got, want) { + t.Fatalf("Argv() = %q, want %q", got, want) + } +} + +func TestPipelineCommandArgvAlwaysCarriesFileSentinels(t *testing.T) { + // Even with no files, the --format-files/--syntax-files markers are present + // so the sidecar's parser sees empty lists rather than a missing section. + cmd := PipelineCommand{OxfmtBin: "sidecar"} + + want := []string{ + "pipeline", + "--oxfmt-bin", "sidecar", + "--format-files", + "--syntax-files", + } + + if got := cmd.Argv(); !reflect.DeepEqual(got, want) { + t.Fatalf("Argv() = %q, want %q", got, want) + } +} + +func TestOxlintCommandArgvViaSidecar(t *testing.T) { + cmd := OxlintCommand{ + ViaSidecar: true, + Config: "/cfg/.oxlintrc.json", + Files: []string{"/work/app.ts"}, + } + + want := []string{ + "oxlint", + "--config", "/cfg/.oxlintrc.json", + "/work/app.ts", + } + + if got := cmd.Argv(); !reflect.DeepEqual(got, want) { + t.Fatalf("Argv() = %q, want %q", got, want) + } +} + +func TestOxlintCommandArgvWithFix(t *testing.T) { + cmd := OxlintCommand{ + ViaSidecar: true, + Fix: true, + Config: "/cfg/.oxlintrc.json", + Files: []string{"/work/app.ts"}, + } + + want := []string{ + "oxlint", + "--fix", + "--config", "/cfg/.oxlintrc.json", + "/work/app.ts", + } + + if got := cmd.Argv(); !reflect.DeepEqual(got, want) { + t.Fatalf("Argv() = %q, want %q", got, want) + } +} + +func TestOxlintCommandArgvDirectBinOmitsMode(t *testing.T) { + // A direct OXLINT_BIN override runs oxlint without the sidecar's mode word. + cmd := OxlintCommand{ + ViaSidecar: false, + Files: []string{"/work/app.ts"}, + } + + want := []string{"/work/app.ts"} + + if got := cmd.Argv(); !reflect.DeepEqual(got, want) { + t.Fatalf("Argv() = %q, want %q", got, want) + } +} + +func TestMigrateCommandArgvViaSidecar(t *testing.T) { + want := []string{"oxfmt", "--migrate=prettier"} + + if got := (MigrateCommand{ViaSidecar: true}).Argv(); !reflect.DeepEqual(got, want) { + t.Fatalf("Argv() = %q, want %q", got, want) + } +} + +func TestMigrateCommandArgvDirectBin(t *testing.T) { + want := []string{"--migrate=prettier"} + + if got := (MigrateCommand{ViaSidecar: false}).Argv(); !reflect.DeepEqual(got, want) { + t.Fatalf("Argv() = %q, want %q", got, want) + } +} diff --git a/packages/go/driver/internal/sidecarproto/sidecarproto.go b/packages/go/driver/internal/sidecarproto/sidecarproto.go new file mode 100644 index 0000000..42ad60c --- /dev/null +++ b/packages/go/driver/internal/sidecarproto/sidecarproto.go @@ -0,0 +1,88 @@ +// Package sidecarproto is the single source of truth for the stringly-typed +// wire protocol between the Go driver and the bun-compiled TS sidecar: the +// asset filenames, the sidecar's dispatch modes, the environment variables that +// override toolchain resolution, the exact argument vectors each mode expects, +// and the summary lines the sidecar prints back. +// +// Every value here is frozen byte-for-byte: the TS sidecar parses these argv +// forms and reads these environment names, and CI's smoke test plus the Go +// fake-bin tests prove both ends agree. Change a constant here only in lockstep +// with packages/ts/sidecar. +package sidecarproto + +import "os" + +// Asset filenames staged alongside the sidecar and read by both ends. +const ( + // SidecarName is the multiplexed toolchain executable's filename. + SidecarName = "fmtkit-ts-sidecar" + + // OxfmtRCName is the bundled oxfmt configuration filename. + OxfmtRCName = ".oxfmtrc.json" + + // OxlintRCName is the bundled oxlint configuration filename. + OxlintRCName = ".oxlintrc.json" +) + +// Dispatch modes: the sidecar selects a toolchain by its first positional +// argument (process.argv[2]) or, equivalently, by SidecarModeEnv. +const ( + ModePipeline = "pipeline" + ModeOxfmt = "oxfmt" + ModeOxlint = "oxlint" +) + +// Environment variable names that cross the Go/TS boundary or steer toolchain +// resolution. These are the complete set the driver honours; ReadOverrides is +// the only place the process environment is consulted for the override subset. +const ( + // SupportDirEnv points at a pre-extracted toolchain directory, skipping + // both the embedded assets and the per-version cache. + SupportDirEnv = "FMTKIT_SUPPORT_DIR" + + // SidecarModeEnv is the sidecar's alternate mode selector, read by the TS + // entrypoint when no positional mode is supplied. + SidecarModeEnv = "FMTKIT_SIDECAR_MODE" + + // PipelineBinEnv overrides the executable spawned for the pipeline mode. + PipelineBinEnv = "FMTKIT_TS_PIPELINE_BIN" + + // OxfmtBinEnv runs oxfmt directly instead of through the sidecar. + OxfmtBinEnv = "OXFMT_BIN" + + // OxlintBinEnv runs oxlint directly instead of through the sidecar. + OxlintBinEnv = "OXLINT_BIN" + + // OxfmtConfigEnv forces a specific oxfmt configuration path. + OxfmtConfigEnv = "FMTKIT_OXFMTRC" + + // OxlintConfigEnv forces a specific oxlint configuration path. + OxlintConfigEnv = "FMTKIT_OXLINTRC" + + // SourcesCwdEnv overrides the working directory file collection runs in. + SourcesCwdEnv = "FMTKIT_SOURCES_CWD" +) + +// Overrides carries every environment override a TS toolchain invocation +// honours, resolved once rather than ad hoc deep in the call paths. +type Overrides struct { + PipelineBin string + OxfmtBin string + OxlintBin string + OxfmtConfig string + OxlintConfig string + SourcesCwd string +} + +// ReadOverrides gathers every environment override in one place. It is the sole +// os.Getenv site for the override variables above. +func ReadOverrides() Overrides { + return Overrides{ + PipelineBin: os.Getenv(PipelineBinEnv), + OxfmtBin: os.Getenv(OxfmtBinEnv), + OxlintBin: os.Getenv(OxlintBinEnv), + OxfmtConfig: os.Getenv(OxfmtConfigEnv), + OxlintConfig: os.Getenv(OxlintConfigEnv), + SourcesCwd: os.Getenv(SourcesCwdEnv), + } +} diff --git a/packages/go/driver/internal/sidecarproto/sidecarproto_test.go b/packages/go/driver/internal/sidecarproto/sidecarproto_test.go new file mode 100644 index 0000000..6e5ed3b --- /dev/null +++ b/packages/go/driver/internal/sidecarproto/sidecarproto_test.go @@ -0,0 +1,83 @@ +package sidecarproto + +import "testing" + +// TestWireConstantsAreFrozen pins every wire value byte-for-byte. The TS sidecar +// parses these; a change here that is not mirrored in packages/ts/sidecar breaks +// compatibility silently, so this test is the tripwire. +func TestWireConstantsAreFrozen(t *testing.T) { + cases := map[string]string{ + "SidecarName": SidecarName, + "OxfmtRCName": OxfmtRCName, + "OxlintRCName": OxlintRCName, + "ModePipeline": ModePipeline, + "ModeOxfmt": ModeOxfmt, + "ModeOxlint": ModeOxlint, + "SupportDirEnv": SupportDirEnv, + "SidecarModeEnv": SidecarModeEnv, + "PipelineBinEnv": PipelineBinEnv, + "OxfmtBinEnv": OxfmtBinEnv, + "OxlintBinEnv": OxlintBinEnv, + "OxfmtConfigEnv": OxfmtConfigEnv, + "OxlintConfigEnv": OxlintConfigEnv, + "SourcesCwdEnv": SourcesCwdEnv, + } + + want := map[string]string{ + "SidecarName": "fmtkit-ts-sidecar", + "OxfmtRCName": ".oxfmtrc.json", + "OxlintRCName": ".oxlintrc.json", + "ModePipeline": "pipeline", + "ModeOxfmt": "oxfmt", + "ModeOxlint": "oxlint", + "SupportDirEnv": "FMTKIT_SUPPORT_DIR", + "SidecarModeEnv": "FMTKIT_SIDECAR_MODE", + "PipelineBinEnv": "FMTKIT_TS_PIPELINE_BIN", + "OxfmtBinEnv": "OXFMT_BIN", + "OxlintBinEnv": "OXLINT_BIN", + "OxfmtConfigEnv": "FMTKIT_OXFMTRC", + "OxlintConfigEnv": "FMTKIT_OXLINTRC", + "SourcesCwdEnv": "FMTKIT_SOURCES_CWD", + } + + for name, got := range cases { + if got != want[name] { + t.Errorf("%s = %q, want %q", name, got, want[name]) + } + } +} + +func TestReadOverridesReadsEveryVar(t *testing.T) { + t.Setenv(PipelineBinEnv, "/bin/pipeline") + t.Setenv(OxfmtBinEnv, "/bin/oxfmt") + t.Setenv(OxlintBinEnv, "/bin/oxlint") + t.Setenv(OxfmtConfigEnv, "/cfg/oxfmt.json") + t.Setenv(OxlintConfigEnv, "/cfg/oxlint.json") + t.Setenv(SourcesCwdEnv, "/work") + + want := Overrides{ + PipelineBin: "/bin/pipeline", + OxfmtBin: "/bin/oxfmt", + OxlintBin: "/bin/oxlint", + OxfmtConfig: "/cfg/oxfmt.json", + OxlintConfig: "/cfg/oxlint.json", + SourcesCwd: "/work", + } + + if got := ReadOverrides(); got != want { + t.Fatalf("ReadOverrides() = %+v, want %+v", got, want) + } +} + +func TestReadOverridesDefaultsToEmpty(t *testing.T) { + for _, name := range []string{ + PipelineBinEnv, OxfmtBinEnv, OxlintBinEnv, + OxfmtConfigEnv, OxlintConfigEnv, SourcesCwdEnv, + } { + t.Setenv(name, "") + } + + if got := ReadOverrides(); got != (Overrides{}) { + t.Fatalf("ReadOverrides() = %+v, want zero value", got) + } +} diff --git a/packages/go/driver/internal/sidecarproto/summary.go b/packages/go/driver/internal/sidecarproto/summary.go new file mode 100644 index 0000000..d7096ef --- /dev/null +++ b/packages/go/driver/internal/sidecarproto/summary.go @@ -0,0 +1,112 @@ +package sidecarproto + +import ( + "regexp" + "strings" +) + +// The sidecar prints progress lines the driver scrapes into a step summary. +// These prefixes and the oxlint result pattern are the sidecar's output +// contract; only the lines the TS toolchain itself emits live here. Lines the +// Go driver prints about its own bookkeeping (source-collection warnings, the +// no-files notice, the Go formatter report) stay with the orchestrator. +const ( + blankLinesMatch = "[blank-lines] processed " + blankLinesTrim = "[blank-lines] " + + oxfmtFinishedMatch = "Finished in " + + fluentChainsMatch = "[fluent-chains] processed " + fluentChainsTrim = "[fluent-chains] " + + validateSyntaxMatch = "[validate-syntax] checked " + validateSyntaxTrim = "[validate-syntax] " +) + +// lintResultPattern matches oxlint's summary line, e.g. +// "Found 0 warnings and 0 errors." +var lintResultPattern = regexp.MustCompile(`Found [0-9]+ warning|[0-9]+ error`) + +// PipelineSummary holds the sidecar's pipeline progress, each field carrying the +// detail text the caller shows (already stripped of its scrape prefix, except +// Oxfmt which is oxfmt's own full line). Empty fields mean the sidecar printed +// no such line. +type PipelineSummary struct { + // BlankLines is the last "[blank-lines] processed ..." line, without its + // "[blank-lines] " prefix. + BlankLines string + + // Oxfmt is the last "Finished in ..." line oxfmt printed, verbatim. + Oxfmt string + + // FluentChains is the last "[fluent-chains] processed ..." line, without its + // "[fluent-chains] " prefix. + FluentChains string + + // ValidateSyntax is the last "[validate-syntax] checked ..." line, without + // its "[validate-syntax] " prefix. + ValidateSyntax string +} + +// LintSummary holds the sidecar's oxlint result line. +type LintSummary struct { + // Result is the last line matching oxlint's summary pattern, verbatim, or + // "" when the log carries none. + Result string +} + +// ParsePipelineSummary scrapes the sidecar's pipeline output, taking the last +// occurrence of each progress line. +func ParsePipelineSummary(log string) PipelineSummary { + logLines := lines(log) + + summary := PipelineSummary{} + + if line := lastWithPrefix(logLines, blankLinesMatch); line != "" { + summary.BlankLines = strings.TrimPrefix(line, blankLinesTrim) + } + + if line := lastWithPrefix(logLines, oxfmtFinishedMatch); line != "" { + summary.Oxfmt = line + } + + if line := lastWithPrefix(logLines, fluentChainsMatch); line != "" { + summary.FluentChains = strings.TrimPrefix(line, fluentChainsTrim) + } + + if line := lastWithPrefix(logLines, validateSyntaxMatch); line != "" { + summary.ValidateSyntax = strings.TrimPrefix(line, validateSyntaxTrim) + } + + return summary +} + +// ParseLintSummary scrapes the sidecar's oxlint output, taking the last line +// matching oxlint's summary pattern. +func ParseLintSummary(log string) LintSummary { + var match string + + for _, line := range lines(log) { + if lintResultPattern.MatchString(line) { + match = line + } + } + + return LintSummary{Result: match} +} + +func lines(log string) []string { + return strings.Split(log, "\n") +} + +func lastWithPrefix(logLines []string, prefix string) string { + var match string + + for _, line := range logLines { + if strings.HasPrefix(line, prefix) { + match = line + } + } + + return match +} diff --git a/packages/go/driver/internal/sidecarproto/summary_test.go b/packages/go/driver/internal/sidecarproto/summary_test.go new file mode 100644 index 0000000..752a92e --- /dev/null +++ b/packages/go/driver/internal/sidecarproto/summary_test.go @@ -0,0 +1,58 @@ +package sidecarproto + +import "testing" + +// sampleTSOutput mirrors the sidecar's pipeline stdout, lifted from the +// orchestrator's fake-tool fixtures. +const sampleTSOutput = "[blank-lines] processed 3 file(s) in /work, 0 changed\n" + + "Finished in 10ms on 3 files using 8 threads.\n" + + "[fluent-chains] processed 3 file(s) in /work, 1 changed\n" + + "[validate-syntax] checked 3 file(s), all valid\n" + +func TestParsePipelineSummary(t *testing.T) { + got := ParsePipelineSummary(sampleTSOutput) + + want := PipelineSummary{ + BlankLines: "processed 3 file(s) in /work, 0 changed", + Oxfmt: "Finished in 10ms on 3 files using 8 threads.", + FluentChains: "processed 3 file(s) in /work, 1 changed", + ValidateSyntax: "checked 3 file(s), all valid", + } + + if got != want { + t.Fatalf("ParsePipelineSummary() = %+v, want %+v", got, want) + } +} + +func TestParsePipelineSummaryTakesLastOccurrence(t *testing.T) { + log := "[blank-lines] processed 1 file(s) in /work, 0 changed\n" + + "[blank-lines] processed 2 file(s) in /work, 1 changed\n" + + if got := ParsePipelineSummary(log).BlankLines; got != "processed 2 file(s) in /work, 1 changed" { + t.Fatalf("BlankLines = %q, want the last occurrence", got) + } +} + +func TestParsePipelineSummaryEmptyLog(t *testing.T) { + if got := ParsePipelineSummary(""); got != (PipelineSummary{}) { + t.Fatalf("ParsePipelineSummary(\"\") = %+v, want zero value", got) + } +} + +func TestParseLintSummary(t *testing.T) { + if got := ParseLintSummary("Found 0 warnings and 0 errors.\n").Result; got != "Found 0 warnings and 0 errors." { + t.Fatalf("Result = %q, want the oxlint summary line", got) + } +} + +func TestParseLintSummaryMatchesErrorLine(t *testing.T) { + if got := ParseLintSummary("noise\nFound 2 warnings and 1 error.\n").Result; got != "Found 2 warnings and 1 error." { + t.Fatalf("Result = %q, want the matching line", got) + } +} + +func TestParseLintSummaryNoMatch(t *testing.T) { + if got := ParseLintSummary("nothing interesting\n").Result; got != "" { + t.Fatalf("Result = %q, want empty when no summary line", got) + } +} From 14572664160d9785e751ab2cbd0193d83eea9cf9 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 24 Jul 2026 10:36:29 +0800 Subject: [PATCH 2/3] refactor(g4): reshape tsruntime Support into Assets/Invoker/PrettierMigration Split the Support god-type into three cohesive types in one package: Assets owns the extracted toolchain directory, Invoker spawns the pipeline and lint, and PrettierMigration derives an oxfmt config from a project's Prettier setup. All argv/env construction now flows through sidecarproto; the sole os.Getenv site for the override vars is sidecarproto.ReadOverrides, called once by NewInvoker. Fix the three pre-existing errcheck warnings in prettier.go by discarding the Fprintf results per house style. Callers in app/ switch to NewInvoker(assets).Run*/Request with no behavioural change. --- packages/go/driver/internal/app/format.go | 8 +- packages/go/driver/internal/app/ts.go | 8 +- .../tsruntime/{support.go => assets.go} | 66 ++--- .../go/driver/internal/tsruntime/invoker.go | 233 ++++++++++++++++ .../go/driver/internal/tsruntime/prettier.go | 57 ++-- .../tsruntime/prettier_internal_test.go | 38 +-- .../internal/tsruntime/prettier_test.go | 42 +-- packages/go/driver/internal/tsruntime/run.go | 255 ------------------ .../go/driver/internal/tsruntime/run_test.go | 50 ++-- .../driver/internal/tsruntime/support_test.go | 22 +- 10 files changed, 390 insertions(+), 389 deletions(-) rename packages/go/driver/internal/tsruntime/{support.go => assets.go} (73%) create mode 100644 packages/go/driver/internal/tsruntime/invoker.go delete mode 100644 packages/go/driver/internal/tsruntime/run.go diff --git a/packages/go/driver/internal/app/format.go b/packages/go/driver/internal/app/format.go index 72c03a4..fe6fe03 100644 --- a/packages/go/driver/internal/app/format.go +++ b/packages/go/driver/internal/app/format.go @@ -51,22 +51,22 @@ func (a App) runPipeline(ctx context.Context, paths []string, opts formatOptions pipeline := orchestrator.Pipeline{ Tools: orchestrator.Tools{ TS: func(ctx context.Context, scopes []string, output io.Writer) error { - support, err := tsruntime.Resolve(a.version) + assets, err := tsruntime.Resolve(a.version) if err != nil { return err } - return support.RunPipeline(ctx, tsruntime.RunOptions{Scopes: scopes, Selection: selection, Stdout: output, Stderr: output}) + return tsruntime.NewInvoker(assets).RunPipeline(ctx, tsruntime.Request{Scopes: scopes, Selection: selection, Stdout: output, Stderr: output}) }, Lint: func(ctx context.Context, scopes []string, output io.Writer) error { - support, err := tsruntime.Resolve(a.version) + assets, err := tsruntime.Resolve(a.version) if err != nil { return err } - return support.RunLint(ctx, tsruntime.RunOptions{Scopes: scopes, Selection: selection, Fix: true, Stdout: output, Stderr: output}) + return tsruntime.NewInvoker(assets).RunLint(ctx, tsruntime.Request{Scopes: scopes, Selection: selection, Fix: true, Stdout: output, Stderr: output}) }, Go: func(ctx context.Context, args []string, output io.Writer) int { return cli. diff --git a/packages/go/driver/internal/app/ts.go b/packages/go/driver/internal/app/ts.go index 2c25359..f5353d6 100644 --- a/packages/go/driver/internal/app/ts.go +++ b/packages/go/driver/internal/app/ts.go @@ -7,21 +7,21 @@ import ( ) func (a App) runTS(ctx context.Context, paths []string) int { - support, err := tsruntime.Resolve(a.version) + assets, err := tsruntime.Resolve(a.version) if err != nil { return a.reportError(err) } - return a.reportError(support.RunPipeline(ctx, tsruntime.RunOptions{Scopes: paths, Stdout: a.stdout, Stderr: a.stderr})) + return a.reportError(tsruntime.NewInvoker(assets).RunPipeline(ctx, tsruntime.Request{Scopes: paths, Stdout: a.stdout, Stderr: a.stderr})) } func (a App) runLint(ctx context.Context, paths []string) int { - support, err := tsruntime.Resolve(a.version) + assets, err := tsruntime.Resolve(a.version) if err != nil { return a.reportError(err) } - return a.reportError(support.RunLint(ctx, tsruntime.RunOptions{Scopes: paths, Stdout: a.stdout, Stderr: a.stderr})) + return a.reportError(tsruntime.NewInvoker(assets).RunLint(ctx, tsruntime.Request{Scopes: paths, Stdout: a.stdout, Stderr: a.stderr})) } diff --git a/packages/go/driver/internal/tsruntime/support.go b/packages/go/driver/internal/tsruntime/assets.go similarity index 73% rename from packages/go/driver/internal/tsruntime/support.go rename to packages/go/driver/internal/tsruntime/assets.go index 081f783..71c9763 100644 --- a/packages/go/driver/internal/tsruntime/support.go +++ b/packages/go/driver/internal/tsruntime/assets.go @@ -2,6 +2,11 @@ // release binaries: a bun-compiled sidecar plus the oxc-parser, oxfmt, and // oxlint napi bindings. On first use the embedded assets are extracted to a // per-version cache directory and spawned as child processes from there. +// +// The type split mirrors the three responsibilities: Assets owns the extracted +// directory (extraction, caching, lookup); Invoker spawns the toolchain; and +// PrettierMigration derives an oxfmt config from a project's Prettier setup. All +// argv and environment construction goes through the sidecarproto package. package tsruntime import ( @@ -16,38 +21,33 @@ import ( "sort" "go.ollin.sh/fmtkit/driver/internal/embedded" + "go.ollin.sh/fmtkit/driver/internal/sidecarproto" ) -// SupportDirEnv points at a pre-extracted toolchain directory and skips -// both the embedded assets and the cache. +// sentinelName marks a completed extraction; it is tsruntime's own bookkeeping, +// not part of the sidecar wire protocol. +const sentinelName = ".fmtkit-complete" -// Support locates the extracted TS toolchain on disk. -type Support struct { +// Assets locates the extracted TS toolchain on disk. +type Assets struct { Dir string } -const ( - SupportDirEnv = "FMTKIT_SUPPORT_DIR" - - sidecarName = "fmtkit-ts-sidecar" - sentinelName = ".fmtkit-complete" -) - // Sidecar returns the path of the multiplexed toolchain executable. -func (s Support) Sidecar() string { - return filepath.Join(s.Dir, sidecarName) +func (a Assets) Sidecar() string { + return filepath.Join(a.Dir, sidecarproto.SidecarName) } // OxfmtConfig returns the bundled oxfmt configuration path, or "" when the // support directory carries none. -func (s Support) OxfmtConfig() string { - return existingFile(filepath.Join(s.Dir, ".oxfmtrc.json")) +func (a Assets) OxfmtConfig() string { + return existingFile(filepath.Join(a.Dir, sidecarproto.OxfmtRCName)) } // OxlintConfig returns the bundled oxlint configuration path, or "" when the // support directory carries none. -func (s Support) OxlintConfig() string { - return existingFile(filepath.Join(s.Dir, ".oxlintrc.json")) +func (a Assets) OxlintConfig() string { + return existingFile(filepath.Join(a.Dir, sidecarproto.OxlintRCName)) } func existingFile(path string) string { @@ -61,32 +61,32 @@ func existingFile(path string) string { // Resolve locates the TS toolchain, extracting the embedded assets into the // user cache on first use. version tells extractions of different releases // apart; dev builds derive a digest from the assets instead. -func Resolve(version string) (Support, error) { - if dir := os.Getenv(SupportDirEnv); dir != "" { - support := Support{Dir: dir} +func Resolve(version string) (Assets, error) { + if dir := os.Getenv(sidecarproto.SupportDirEnv); dir != "" { + assets := Assets{Dir: dir} - if existingFile(support.Sidecar()) == "" { - return Support{}, fmt.Errorf("%s (%s) does not contain %s", SupportDirEnv, dir, sidecarName) + if existingFile(assets.Sidecar()) == "" { + return Assets{}, fmt.Errorf("%s (%s) does not contain %s", sidecarproto.SupportDirEnv, dir, sidecarproto.SidecarName) } - return support, nil + return assets, nil } - assets, ok := embedded.SidecarAssets() + embeddedAssets, ok := embedded.SidecarAssets() if !ok { - return Support{}, errors.New( + return Assets{}, errors.New( "this fmtkit build carries no TS toolchain (built without the fmtkit_sidecar tag); " + - "point " + SupportDirEnv + " at a staged toolchain directory " + + "point " + sidecarproto.SupportDirEnv + " at a staged toolchain directory " + "(see packages/ts/infra/stage-ts-assets.sh), or use a release binary", ) } if version == "" || version == "dev" { - digest, err := assetsDigest(assets) + digest, err := assetsDigest(embeddedAssets) if err != nil { - return Support{}, err + return Assets{}, err } version = "dev-" + digest @@ -95,16 +95,16 @@ func Resolve(version string) (Support, error) { cacheRoot, err := os.UserCacheDir() if err != nil { - return Support{}, fmt.Errorf("resolve user cache dir: %w", err) + return Assets{}, fmt.Errorf("resolve user cache dir: %w", err) } dir := filepath.Join(cacheRoot, "fmtkit", version) - if err := extractOnce(dir, assets); err != nil { - return Support{}, err + if err := extractOnce(dir, embeddedAssets); err != nil { + return Assets{}, err } - return Support{Dir: dir}, nil + return Assets{Dir: dir}, nil } // extractOnce materializes the toolchain into dir unless a completed @@ -160,7 +160,7 @@ func extract(dst string, assets fs.FS) error { mode := os.FileMode(0o644) - if entry.Name() == sidecarName { + if entry.Name() == sidecarproto.SidecarName { mode = 0o755 } diff --git a/packages/go/driver/internal/tsruntime/invoker.go b/packages/go/driver/internal/tsruntime/invoker.go new file mode 100644 index 0000000..958e97a --- /dev/null +++ b/packages/go/driver/internal/tsruntime/invoker.go @@ -0,0 +1,233 @@ +package tsruntime + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + + "go.ollin.sh/fmtkit/driver/internal/sidecarproto" + "go.ollin.sh/fmtkit/driver/internal/sourcefiles" +) + +// Request describes one TS toolchain invocation. +type Request struct { + // Scopes are the paths to process, defaulting to ".". + Scopes []string + + // Selection is how much of the working tree to cover within Scopes. It + // defaults to sourcefiles.SelectionAll. + Selection sourcefiles.Selection + + // Fix, when set, lets RunLint apply oxlint's safe fixes (--fix) rather + // than only reporting violations. + Fix bool + + Stdout io.Writer + Stderr io.Writer +} + +// Invoker spawns the TS toolchain against an extracted Assets directory. It +// resolves the environment overrides once at construction rather than ad hoc +// deep in the call paths. +type Invoker struct { + Assets Assets + Env sidecarproto.Overrides +} + +// NewInvoker builds an Invoker for the given assets, reading the environment +// overrides once. +func NewInvoker(a Assets) Invoker { + return Invoker{Assets: a, Env: sidecarproto.ReadOverrides()} +} + +// RunPipeline runs the full TS/Vue formatting pipeline (blank-lines -> oxfmt +// -> fluent-chains -> blank-lines -> validate-syntax). oxfmt is an internal +// normalising step, not the last word: the project passes run after it and +// own the final style. +func (i Invoker) RunPipeline(ctx context.Context, req Request) error { + cwd, err := i.sourcesCwd() + + if err != nil { + return err + } + + formatFiles, warnings, err := collect(ctx, cwd, req.Scopes, false, req.Selection) + + if err != nil { + return err + } + + for _, warning := range warnings { + _, _ = fmt.Fprintf(req.Stderr, "[sources] %s\n", warning) + } + + syntaxFiles, _, err := collect(ctx, cwd, req.Scopes, true, req.Selection) + + if err != nil { + return err + } + + oxfmtBin := i.Env.OxfmtBin + + if oxfmtBin == "" { + oxfmtBin = i.Assets.Sidecar() + } + + command := sidecarproto.PipelineCommand{ + OxfmtBin: oxfmtBin, + OxfmtConfig: i.oxfmtConfigFor(ctx, cwd, req.Stderr), + FormatFiles: formatFiles, + SyntaxFiles: syntaxFiles, + } + + return i.spawn(ctx, i.pipelineExecutable(), command.Argv(), req) +} + +// RunLint lints the collected TS/Vue files with oxlint. With req.Fix it applies +// oxlint's safe fixes in place; otherwise it only reports violations. +func (i Invoker) RunLint(ctx context.Context, req Request) error { + cwd, err := i.sourcesCwd() + + if err != nil { + return err + } + + files, warnings, err := collectLintable(ctx, cwd, req.Scopes, false, req.Selection) + + if err != nil { + return err + } + + for _, warning := range warnings { + _, _ = fmt.Fprintf(req.Stderr, "[sources] %s\n", warning) + } + + if len(files) == 0 { + _, _ = fmt.Fprintln(req.Stdout, "[lint] no TS/Vue files to lint.") + + return nil + } + + bin := i.Env.OxlintBin + viaSidecar := bin == "" + + if viaSidecar { + bin = i.Assets.Sidecar() + } + + command := sidecarproto.OxlintCommand{ + ViaSidecar: viaSidecar, + Fix: req.Fix, + Config: i.oxlintConfigFor(cwd), + Files: files, + } + + return i.spawn(ctx, bin, command.Argv(), req) +} + +// pipelineExecutable resolves the executable spawned for the pipeline: a +// FMTKIT_TS_PIPELINE_BIN override, otherwise the sidecar. +func (i Invoker) pipelineExecutable() string { + if i.Env.PipelineBin != "" { + return i.Env.PipelineBin + } + + return i.Assets.Sidecar() +} + +func (i Invoker) sourcesCwd() (string, error) { + if i.Env.SourcesCwd != "" { + return i.Env.SourcesCwd, nil + } + + cwd, err := os.Getwd() + + if err != nil { + return "", fmt.Errorf("resolve cwd: %w", err) + } + + return cwd, nil +} + +func collect(ctx context.Context, cwd string, scopes []string, includeDeclarations bool, selection sourcefiles.Selection) ([]string, []string, error) { + return sourcefiles.Collect(ctx, sourcefiles.Options{ + Cwd: cwd, + IncludeDeclarations: includeDeclarations, + Scopes: scopes, + Selection: selection, + }) +} + +func collectLintable(ctx context.Context, cwd string, scopes []string, includeDeclarations bool, selection sourcefiles.Selection) ([]string, []string, error) { + return sourcefiles.CollectLintable(ctx, sourcefiles.Options{ + Cwd: cwd, + IncludeDeclarations: includeDeclarations, + Scopes: scopes, + Selection: selection, + }) +} + +// oxfmtConfigFor resolves the oxfmt config by precedence: the FMTKIT_OXFMTRC +// override, then a project-local .oxfmtrc.* (via oxfmt's own auto-discovery, +// signalled by ""), then a config derived from the project's Prettier +// configuration, and finally the bundled default. +func (i Invoker) oxfmtConfigFor(ctx context.Context, cwd string, stderr io.Writer) string { + if i.Env.OxfmtConfig != "" { + return existingFile(i.Env.OxfmtConfig) + } + + if matches, err := filepath.Glob(filepath.Join(cwd, ".oxfmtrc.*")); err == nil && len(matches) > 0 { + return "" + } + + if derived := i.migration().DerivedConfig(ctx, cwd, stderr); derived != "" { + return derived + } + + return i.Assets.OxfmtConfig() +} + +// oxlintConfigFor treats both the extensionless .oxlintrc and .oxlintrc.* as +// project configuration. +func (i Invoker) oxlintConfigFor(cwd string) string { + if i.Env.OxlintConfig != "" { + return existingFile(i.Env.OxlintConfig) + } + + if existingFile(filepath.Join(cwd, ".oxlintrc")) != "" { + return "" + } + + if matches, err := filepath.Glob(filepath.Join(cwd, ".oxlintrc.*")); err == nil && len(matches) > 0 { + return "" + } + + return i.Assets.OxlintConfig() +} + +// migration views this invoker as the PrettierMigration that shares its assets +// and environment. The two carry the same data; if their fields ever diverge +// the compiler rejects this conversion, which is the intended tripwire. +func (i Invoker) migration() PrettierMigration { + return PrettierMigration(i) +} + +func (i Invoker) spawn(ctx context.Context, bin string, args []string, req Request) error { + cmd := exec.CommandContext(ctx, bin, args...) + + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + + // Match the container entrypoints: let git treat any working tree as safe + // so file collection inside bind mounts and caches works. + cmd.Env = append(os.Environ(), + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=safe.directory", + "GIT_CONFIG_VALUE_0=*", + ) + + return cmd.Run() +} diff --git a/packages/go/driver/internal/tsruntime/prettier.go b/packages/go/driver/internal/tsruntime/prettier.go index 2c5509a..326604c 100644 --- a/packages/go/driver/internal/tsruntime/prettier.go +++ b/packages/go/driver/internal/tsruntime/prettier.go @@ -10,8 +10,19 @@ import ( "os" "os/exec" "path/filepath" + + "go.ollin.sh/fmtkit/driver/internal/sidecarproto" ) +// PrettierMigration derives an oxfmt config from a project's Prettier setup by +// running oxfmt's own --migrate=prettier translator. It shares the invoker's +// assets (for the sidecar path and the cache directory) and environment (for an +// OXFMT_BIN override). +type PrettierMigration struct { + Assets Assets + Env sidecarproto.Overrides +} + // prettierConfigNames are the standalone Prettier configuration filenames, in // the order Prettier itself resolves them. package.json's "prettier" key is // checked separately, after these. @@ -70,12 +81,12 @@ func packageJSONHasPrettierKey(path string) bool { return ok && string(value) != "null" } -// prettierDerivedConfig returns the path of an oxfmt config derived from cwd's -// Prettier configuration, or "" when there is no Prettier config or the -// migration fails. Failures print a one-line warning to stderr and leave the -// caller to fall back to the bundled config; a translated config is cached by -// the source config's content hash so migration runs at most once per config. -func (s Support) prettierDerivedConfig(ctx context.Context, cwd string, env overrides, stderr io.Writer) string { +// DerivedConfig returns the path of an oxfmt config derived from cwd's Prettier +// configuration, or "" when there is no Prettier config or the migration fails. +// Failures print a one-line warning to stderr and leave the caller to fall back +// to the bundled config; a translated config is cached by the source config's +// content hash so migration runs at most once per config. +func (m PrettierMigration) DerivedConfig(ctx context.Context, cwd string, stderr io.Writer) string { source := detectPrettierConfig(cwd) if source == "" { @@ -91,13 +102,13 @@ func (s Support) prettierDerivedConfig(ctx context.Context, cwd string, env over } sum := sha256.Sum256(data) - cachePath := filepath.Join(s.Dir, "prettier-derived", hex.EncodeToString(sum[:])+".json") + cachePath := filepath.Join(m.Assets.Dir, "prettier-derived", hex.EncodeToString(sum[:])+".json") if existingFile(cachePath) != "" { return cachePath } - derived, err := s.migratePrettierConfig(ctx, source, env) + derived, err := m.migrate(ctx, source) if err != nil { _, _ = fmt.Fprintf(stderr, "[oxfmt] could not derive oxfmt config from %s: %v; using bundled config\n", source, err) @@ -114,11 +125,11 @@ func (s Support) prettierDerivedConfig(ctx context.Context, cwd string, env over return cachePath } -// migratePrettierConfig copies the Prettier config into a private temp dir, -// runs oxfmt --migrate=prettier there, and returns the resulting .oxfmtrc.json -// bytes. The temp dir starts empty so the migrator never trips over a -// pre-existing oxfmt config. -func (s Support) migratePrettierConfig(ctx context.Context, source string, env overrides) ([]byte, error) { +// migrate copies the Prettier config into a private temp dir, runs oxfmt +// --migrate=prettier there, and returns the resulting .oxfmtrc.json bytes. The +// temp dir starts empty so the migrator never trips over a pre-existing oxfmt +// config. +func (m PrettierMigration) migrate(ctx context.Context, source string) ([]byte, error) { dir, err := os.MkdirTemp("", "fmtkit-prettier-migrate-") if err != nil { @@ -133,8 +144,8 @@ func (s Support) migratePrettierConfig(ctx context.Context, source string, env o return nil, err } - bin, args := s.migrateCommand(env) - args = append(args, "--migrate=prettier") + bin, viaSidecar := m.oxfmtExecutable() + args := sidecarproto.MigrateCommand{ViaSidecar: viaSidecar}.Argv() cmd := exec.CommandContext(ctx, bin, args...) cmd.Dir = dir @@ -145,7 +156,7 @@ func (s Support) migratePrettierConfig(ctx context.Context, source string, env o return nil, fmt.Errorf("oxfmt --migrate=prettier: %w", err) } - derived, err := os.ReadFile(filepath.Join(dir, ".oxfmtrc.json")) + derived, err := os.ReadFile(filepath.Join(dir, sidecarproto.OxfmtRCName)) if err != nil { return nil, fmt.Errorf("read migrated config: %w", err) @@ -154,15 +165,15 @@ func (s Support) migratePrettierConfig(ctx context.Context, source string, env o return derived, nil } -// migrateCommand resolves the oxfmt invocation for a migration, mirroring -// RunPipeline: an OXFMT_BIN override runs directly, otherwise the sidecar runs -// in its oxfmt pass-through mode. -func (s Support) migrateCommand(env overrides) (string, []string) { - if env.oxfmtBin != "" { - return env.oxfmtBin, nil +// oxfmtExecutable resolves the oxfmt invocation for a migration, mirroring +// Invoker.RunPipeline: an OXFMT_BIN override runs directly, otherwise the +// sidecar runs in its oxfmt pass-through mode. +func (m PrettierMigration) oxfmtExecutable() (bin string, viaSidecar bool) { + if m.Env.OxfmtBin != "" { + return m.Env.OxfmtBin, false } - return s.Sidecar(), []string{"oxfmt"} + return m.Assets.Sidecar(), true } func copyFileContents(source, dst string) error { diff --git a/packages/go/driver/internal/tsruntime/prettier_internal_test.go b/packages/go/driver/internal/tsruntime/prettier_internal_test.go index 7d9e61c..b909fe3 100644 --- a/packages/go/driver/internal/tsruntime/prettier_internal_test.go +++ b/packages/go/driver/internal/tsruntime/prettier_internal_test.go @@ -6,27 +6,31 @@ import ( "path/filepath" "strings" "testing" + + "go.ollin.sh/fmtkit/driver/internal/sidecarproto" ) -func TestMigrateCommandDefaultsToSidecar(t *testing.T) { - support := Support{Dir: filepath.Join("some", "dir")} +func TestOxfmtExecutableDefaultsToSidecar(t *testing.T) { + migration := PrettierMigration{Assets: Assets{Dir: filepath.Join("some", "dir")}} - bin, args := support.migrateCommand(overrides{}) + bin, viaSidecar := migration.oxfmtExecutable() - if bin != support.Sidecar() { - t.Fatalf("bin = %q, want sidecar %q", bin, support.Sidecar()) + if bin != migration.Assets.Sidecar() { + t.Fatalf("bin = %q, want sidecar %q", bin, migration.Assets.Sidecar()) } - if len(args) != 1 || args[0] != "oxfmt" { - t.Fatalf("args = %q, want [oxfmt]", args) + if !viaSidecar { + t.Fatal("expected the sidecar dispatch path (viaSidecar = true)") } } -func TestMigrateCommandHonorsOxfmtBin(t *testing.T) { - bin, args := Support{}.migrateCommand(overrides{oxfmtBin: "/usr/bin/oxfmt"}) +func TestOxfmtExecutableHonorsOxfmtBin(t *testing.T) { + migration := PrettierMigration{Env: sidecarproto.Overrides{OxfmtBin: "/usr/bin/oxfmt"}} + + bin, viaSidecar := migration.oxfmtExecutable() - if bin != "/usr/bin/oxfmt" || len(args) != 0 { - t.Fatalf("migrateCommand = (%q, %q), want (/usr/bin/oxfmt, [])", bin, args) + if bin != "/usr/bin/oxfmt" || viaSidecar { + t.Fatalf("oxfmtExecutable = (%q, %v), want (/usr/bin/oxfmt, false)", bin, viaSidecar) } } @@ -61,7 +65,7 @@ func TestPackageJSONHasPrettierKeyMissingFile(t *testing.T) { } } -func TestPrettierDerivedConfigWarnsWhenCacheWriteFails(t *testing.T) { +func TestDerivedConfigWarnsWhenCacheWriteFails(t *testing.T) { support := supportWithStub(t) oxfmt := filepath.Join(t.TempDir(), "oxfmt") @@ -78,11 +82,11 @@ func TestPrettierDerivedConfigWarnsWhenCacheWriteFails(t *testing.T) { t.Fatalf("write prettier config: %v", err) } - env := overrides{oxfmtBin: oxfmt} + migration := PrettierMigration{Assets: support, Env: sidecarproto.Overrides{OxfmtBin: oxfmt}} var stderr strings.Builder - if got := support.prettierDerivedConfig(context.Background(), cwd, env, &stderr); got != "" { + if got := migration.DerivedConfig(context.Background(), cwd, &stderr); got != "" { t.Fatalf("expected empty result on cache failure, got %q", got) } @@ -91,7 +95,7 @@ func TestPrettierDerivedConfigWarnsWhenCacheWriteFails(t *testing.T) { } } -func TestPrettierDerivedConfigWarnsWhenMigrationWritesNoConfig(t *testing.T) { +func TestDerivedConfigWarnsWhenMigrationWritesNoConfig(t *testing.T) { support := supportWithStub(t) // A stub that exits 0 but writes no .oxfmtrc.json: the read-back must fail. @@ -107,9 +111,11 @@ func TestPrettierDerivedConfigWarnsWhenMigrationWritesNoConfig(t *testing.T) { t.Fatalf("write prettier config: %v", err) } + migration := PrettierMigration{Assets: support, Env: sidecarproto.Overrides{OxfmtBin: silent}} + var stderr strings.Builder - got := support.prettierDerivedConfig(context.Background(), cwd, overrides{oxfmtBin: silent}, &stderr) + got := migration.DerivedConfig(context.Background(), cwd, &stderr) if got != "" { t.Fatalf("expected empty result when no config is produced, got %q", got) diff --git a/packages/go/driver/internal/tsruntime/prettier_test.go b/packages/go/driver/internal/tsruntime/prettier_test.go index 7851cfc..517465a 100644 --- a/packages/go/driver/internal/tsruntime/prettier_test.go +++ b/packages/go/driver/internal/tsruntime/prettier_test.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "testing" + + "go.ollin.sh/fmtkit/driver/internal/sidecarproto" ) // writeMigrateStub creates a fake oxfmt that, on --migrate=prettier, writes an @@ -152,13 +154,13 @@ func TestOxfmtConfigForDerivesFromPrettier(t *testing.T) { t.Fatalf("write prettier config: %v", err) } - t.Setenv(OxfmtBinEnv, oxfmt) + t.Setenv(sidecarproto.OxfmtBinEnv, oxfmt) - env := readOverrides() + env := sidecarproto.ReadOverrides() var stderr bytes.Buffer - config := support.oxfmtConfigFor(context.Background(), cwd, env, &stderr) + config := Invoker{Assets: support, Env: env}.oxfmtConfigFor(context.Background(), cwd, &stderr) if config == "" || existingFile(config) == "" { t.Fatalf("expected a derived config path, got %q (stderr: %s)", config, stderr.String()) @@ -191,14 +193,14 @@ func TestOxfmtConfigForCachesDerivedConfig(t *testing.T) { t.Fatalf("write prettier config: %v", err) } - t.Setenv(OxfmtBinEnv, oxfmt) + t.Setenv(sidecarproto.OxfmtBinEnv, oxfmt) - env := readOverrides() + env := sidecarproto.ReadOverrides() var stderr bytes.Buffer - first := support.oxfmtConfigFor(context.Background(), cwd, env, &stderr) - second := support.oxfmtConfigFor(context.Background(), cwd, env, &stderr) + first := Invoker{Assets: support, Env: env}.oxfmtConfigFor(context.Background(), cwd, &stderr) + second := Invoker{Assets: support, Env: env}.oxfmtConfigFor(context.Background(), cwd, &stderr) if first != second { t.Fatalf("cache miss on second call: %q vs %q", first, second) @@ -223,19 +225,19 @@ func TestOxfmtConfigForRemigratesWhenConfigChanges(t *testing.T) { t.Fatalf("write prettier config: %v", err) } - t.Setenv(OxfmtBinEnv, oxfmt) + t.Setenv(sidecarproto.OxfmtBinEnv, oxfmt) - env := readOverrides() + env := sidecarproto.ReadOverrides() var stderr bytes.Buffer - support.oxfmtConfigFor(context.Background(), cwd, env, &stderr) + Invoker{Assets: support, Env: env}.oxfmtConfigFor(context.Background(), cwd, &stderr) if err := os.WriteFile(prettier, []byte(`{"semi":true}`), 0o644); err != nil { t.Fatalf("rewrite prettier config: %v", err) } - support.oxfmtConfigFor(context.Background(), cwd, env, &stderr) + Invoker{Assets: support, Env: env}.oxfmtConfigFor(context.Background(), cwd, &stderr) if got := migrateInvocations(t, filepath.Dir(oxfmt)); got != 2 { t.Fatalf("migration ran %d times, want 2 (content hash should change)", got) @@ -252,7 +254,7 @@ func TestOxfmtConfigForPrecedence(t *testing.T) { oxfmt := filepath.Join(t.TempDir(), "oxfmt") writeMigrateStub(t, oxfmt) - t.Setenv(OxfmtBinEnv, oxfmt) + t.Setenv(sidecarproto.OxfmtBinEnv, oxfmt) t.Run("project .oxfmtrc beats prettier", func(t *testing.T) { cwd := t.TempDir() @@ -267,7 +269,7 @@ func TestOxfmtConfigForPrecedence(t *testing.T) { var stderr bytes.Buffer - if got := support.oxfmtConfigFor(context.Background(), cwd, readOverrides(), &stderr); got != "" { + if got := (Invoker{Assets: support, Env: sidecarproto.ReadOverrides()}).oxfmtConfigFor(context.Background(), cwd, &stderr); got != "" { t.Fatalf("expected auto-discovery signal for project config, got %q", got) } }) @@ -281,7 +283,7 @@ func TestOxfmtConfigForPrecedence(t *testing.T) { var stderr bytes.Buffer - got := support.oxfmtConfigFor(context.Background(), cwd, readOverrides(), &stderr) + got := Invoker{Assets: support, Env: sidecarproto.ReadOverrides()}.oxfmtConfigFor(context.Background(), cwd, &stderr) if !strings.HasPrefix(got, filepath.Join(support.Dir, "prettier-derived")) { t.Fatalf("expected derived config, got %q", got) @@ -293,7 +295,7 @@ func TestOxfmtConfigForPrecedence(t *testing.T) { var stderr bytes.Buffer - if got := support.oxfmtConfigFor(context.Background(), cwd, readOverrides(), &stderr); got != support.OxfmtConfig() { + if got := (Invoker{Assets: support, Env: sidecarproto.ReadOverrides()}).oxfmtConfigFor(context.Background(), cwd, &stderr); got != support.OxfmtConfig() { t.Fatalf("expected bundled config %q, got %q", support.OxfmtConfig(), got) } }) @@ -311,10 +313,10 @@ func TestOxfmtConfigForPrecedence(t *testing.T) { t.Fatalf("write override config: %v", err) } - env := readOverrides() - env.oxfmtConfig = override + env := sidecarproto.ReadOverrides() + env.OxfmtConfig = override - if got := support.oxfmtConfigFor(context.Background(), cwd, env, &bytes.Buffer{}); got != override { + if got := (Invoker{Assets: support, Env: env}).oxfmtConfigFor(context.Background(), cwd, &bytes.Buffer{}); got != override { t.Fatalf("expected override %q, got %q", override, got) } }) @@ -340,11 +342,11 @@ func TestOxfmtConfigForFallsBackWhenMigrationFails(t *testing.T) { t.Fatalf("write prettier config: %v", err) } - t.Setenv(OxfmtBinEnv, failing) + t.Setenv(sidecarproto.OxfmtBinEnv, failing) var stderr bytes.Buffer - got := support.oxfmtConfigFor(context.Background(), cwd, readOverrides(), &stderr) + got := Invoker{Assets: support, Env: sidecarproto.ReadOverrides()}.oxfmtConfigFor(context.Background(), cwd, &stderr) if got != support.OxfmtConfig() { t.Fatalf("expected bundled fallback %q, got %q", support.OxfmtConfig(), got) diff --git a/packages/go/driver/internal/tsruntime/run.go b/packages/go/driver/internal/tsruntime/run.go deleted file mode 100644 index 33d0340..0000000 --- a/packages/go/driver/internal/tsruntime/run.go +++ /dev/null @@ -1,255 +0,0 @@ -package tsruntime - -import ( - "context" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - - "go.ollin.sh/fmtkit/driver/internal/sourcefiles" -) - -// RunOptions describes one TS toolchain invocation. -type RunOptions struct { - // Scopes are the paths to process, defaulting to ".". - Scopes []string - - // Selection is how much of the working tree to cover within Scopes. It - // defaults to sourcefiles.SelectionAll. - Selection sourcefiles.Selection - - // Fix, when set, lets RunLint apply oxlint's safe fixes (--fix) rather - // than only reporting violations. - Fix bool - - Stdout io.Writer - Stderr io.Writer -} - -// overrides carries the environment overrides a TS toolchain invocation -// honours, resolved once at the entry points rather than ad hoc deep in the -// call paths. -type overrides struct { - pipelineBin string - oxfmtBin string - oxlintBin string - oxfmtConfig string - oxlintConfig string - sourcesCwd string -} - -const ( - PipelineBinEnv = "FMTKIT_TS_PIPELINE_BIN" - OxfmtBinEnv = "OXFMT_BIN" - OxlintBinEnv = "OXLINT_BIN" - OxfmtConfigEnv = "FMTKIT_OXFMTRC" - OxlintConfigEnv = "FMTKIT_OXLINTRC" - SourcesCwdEnv = "FMTKIT_SOURCES_CWD" -) - -// readOverrides gathers every environment override in one place. -func readOverrides() overrides { - return overrides{ - pipelineBin: os.Getenv(PipelineBinEnv), - oxfmtBin: os.Getenv(OxfmtBinEnv), - oxlintBin: os.Getenv(OxlintBinEnv), - oxfmtConfig: os.Getenv(OxfmtConfigEnv), - oxlintConfig: os.Getenv(OxlintConfigEnv), - sourcesCwd: os.Getenv(SourcesCwdEnv), - } -} - -// RunPipeline runs the full TS/Vue formatting pipeline (blank-lines -> oxfmt -// -> fluent-chains -> blank-lines -> validate-syntax). oxfmt is an internal -// normalising step, not the last word: the project passes run after it and -// own the final style. -func (s Support) RunPipeline(ctx context.Context, opts RunOptions) error { - env := readOverrides() - - cwd, err := sourcesCwd(env) - - if err != nil { - return err - } - - formatFiles, warnings, err := collect(ctx, cwd, opts.Scopes, false, opts.Selection) - - if err != nil { - return err - } - - for _, warning := range warnings { - _, _ = fmt.Fprintf(opts.Stderr, "[sources] %s\n", warning) - } - - syntaxFiles, _, err := collect(ctx, cwd, opts.Scopes, true, opts.Selection) - - if err != nil { - return err - } - - args := []string{"pipeline"} - - if env.oxfmtBin != "" { - args = append(args, "--oxfmt-bin", env.oxfmtBin) - } else { - args = append(args, "--oxfmt-bin", s.Sidecar()) - } - - if config := s.oxfmtConfigFor(ctx, cwd, env, opts.Stderr); config != "" { - args = append(args, "--oxfmt-config", config) - } - - args = append(args, "--format-files") - args = append(args, formatFiles...) - args = append(args, "--syntax-files") - args = append(args, syntaxFiles...) - - return s.spawn(ctx, pipelineBin(env, s.Sidecar()), args, opts) -} - -// RunLint lints the collected TS/Vue files with oxlint. With opts.Fix it applies -// oxlint's safe fixes in place; otherwise it only reports violations. -func (s Support) RunLint(ctx context.Context, opts RunOptions) error { - env := readOverrides() - - cwd, err := sourcesCwd(env) - - if err != nil { - return err - } - - files, warnings, err := collectLintable(ctx, cwd, opts.Scopes, false, opts.Selection) - - if err != nil { - return err - } - - for _, warning := range warnings { - _, _ = fmt.Fprintf(opts.Stderr, "[sources] %s\n", warning) - } - - if len(files) == 0 { - _, _ = fmt.Fprintln(opts.Stdout, "[lint] no TS/Vue files to lint.") - - return nil - } - - var args []string - - bin := env.oxlintBin - - if bin == "" { - bin = s.Sidecar() - args = append(args, "oxlint") - } - - if opts.Fix { - args = append(args, "--fix") - } - - if config := s.oxlintConfigFor(cwd, env); config != "" { - args = append(args, "--config", config) - } - - args = append(args, files...) - - return s.spawn(ctx, bin, args, opts) -} - -func pipelineBin(env overrides, sidecar string) string { - if env.pipelineBin != "" { - return env.pipelineBin - } - - return sidecar -} - -func sourcesCwd(env overrides) (string, error) { - if env.sourcesCwd != "" { - return env.sourcesCwd, nil - } - - cwd, err := os.Getwd() - - if err != nil { - return "", fmt.Errorf("resolve cwd: %w", err) - } - - return cwd, nil -} - -func collect(ctx context.Context, cwd string, scopes []string, includeDeclarations bool, selection sourcefiles.Selection) ([]string, []string, error) { - return sourcefiles.Collect(ctx, sourcefiles.Options{ - Cwd: cwd, - IncludeDeclarations: includeDeclarations, - Scopes: scopes, - Selection: selection, - }) -} - -func collectLintable(ctx context.Context, cwd string, scopes []string, includeDeclarations bool, selection sourcefiles.Selection) ([]string, []string, error) { - return sourcefiles.CollectLintable(ctx, sourcefiles.Options{ - Cwd: cwd, - IncludeDeclarations: includeDeclarations, - Scopes: scopes, - Selection: selection, - }) -} - -// oxfmtConfigFor resolves the oxfmt config by precedence: the FMTKIT_OXFMTRC -// override, then a project-local .oxfmtrc.* (via oxfmt's own auto-discovery, -// signalled by ""), then a config derived from the project's Prettier -// configuration, and finally the bundled default. -func (s Support) oxfmtConfigFor(ctx context.Context, cwd string, env overrides, stderr io.Writer) string { - if env.oxfmtConfig != "" { - return existingFile(env.oxfmtConfig) - } - - if matches, err := filepath.Glob(filepath.Join(cwd, ".oxfmtrc.*")); err == nil && len(matches) > 0 { - return "" - } - - if derived := s.prettierDerivedConfig(ctx, cwd, env, stderr); derived != "" { - return derived - } - - return s.OxfmtConfig() -} - -// oxlintConfigFor treats both the extensionless .oxlintrc and .oxlintrc.* as -// project configuration. -func (s Support) oxlintConfigFor(cwd string, env overrides) string { - if env.oxlintConfig != "" { - return existingFile(env.oxlintConfig) - } - - if existingFile(filepath.Join(cwd, ".oxlintrc")) != "" { - return "" - } - - if matches, err := filepath.Glob(filepath.Join(cwd, ".oxlintrc.*")); err == nil && len(matches) > 0 { - return "" - } - - return s.OxlintConfig() -} - -func (s Support) spawn(ctx context.Context, bin string, args []string, opts RunOptions) error { - cmd := exec.CommandContext(ctx, bin, args...) - - cmd.Stdout = opts.Stdout - cmd.Stderr = opts.Stderr - - // Match the container entrypoints: let git treat any working tree as safe - // so file collection inside bind mounts and caches works. - cmd.Env = append(os.Environ(), - "GIT_CONFIG_COUNT=1", - "GIT_CONFIG_KEY_0=safe.directory", - "GIT_CONFIG_VALUE_0=*", - ) - - return cmd.Run() -} diff --git a/packages/go/driver/internal/tsruntime/run_test.go b/packages/go/driver/internal/tsruntime/run_test.go index 621e37f..0faabae 100644 --- a/packages/go/driver/internal/tsruntime/run_test.go +++ b/packages/go/driver/internal/tsruntime/run_test.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" "testing" + + "go.ollin.sh/fmtkit/driver/internal/sidecarproto" ) // writeStub creates an executable that echoes its argv, one per line, so @@ -48,14 +50,14 @@ func gitScratchRepo(t *testing.T, files map[string]string) string { return dir } -func supportWithStub(t *testing.T) Support { +func supportWithStub(t *testing.T) Assets { t.Helper() dir := t.TempDir() - writeStub(t, filepath.Join(dir, sidecarName)) + writeStub(t, filepath.Join(dir, sidecarproto.SidecarName)) - return Support{Dir: dir} + return Assets{Dir: dir} } func TestRunPipelineInvokesSidecar(t *testing.T) { @@ -72,11 +74,11 @@ func TestRunPipelineInvokesSidecar(t *testing.T) { t.Fatalf("write bundled config: %v", err) } - t.Setenv(SourcesCwdEnv, repo) + t.Setenv(sidecarproto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer - err := support.RunPipeline(context.Background(), RunOptions{Stdout: &stdout, Stderr: &stderr}) + err := NewInvoker(support).RunPipeline(context.Background(), Request{Stdout: &stdout, Stderr: &stderr}) if err != nil { t.Fatalf("RunPipeline: %v\nstderr: %s", err, stderr.String()) @@ -120,11 +122,11 @@ func TestRunPipelineSkipsBundledConfigWhenProjectHasOne(t *testing.T) { t.Fatalf("write bundled config: %v", err) } - t.Setenv(SourcesCwdEnv, repo) + t.Setenv(sidecarproto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer - if err := support.RunPipeline(context.Background(), RunOptions{Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := NewInvoker(support).RunPipeline(context.Background(), Request{Stdout: &stdout, Stderr: &stderr}); err != nil { t.Fatalf("RunPipeline: %v\nstderr: %s", err, stderr.String()) } @@ -138,11 +140,11 @@ func TestRunPipelineReportsMissingScopes(t *testing.T) { support := supportWithStub(t) - t.Setenv(SourcesCwdEnv, repo) + t.Setenv(sidecarproto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer - err := support.RunPipeline(context.Background(), RunOptions{ + err := NewInvoker(support).RunPipeline(context.Background(), Request{ Scopes: []string{"missing-dir"}, Stdout: &stdout, Stderr: &stderr, @@ -168,11 +170,11 @@ func TestRunLintInvokesOxlintMode(t *testing.T) { t.Fatalf("write bundled config: %v", err) } - t.Setenv(SourcesCwdEnv, repo) + t.Setenv(sidecarproto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer - if err := support.RunLint(context.Background(), RunOptions{Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := NewInvoker(support).RunLint(context.Background(), Request{Stdout: &stdout, Stderr: &stderr}); err != nil { t.Fatalf("RunLint: %v\nstderr: %s", err, stderr.String()) } @@ -204,11 +206,11 @@ func TestRunLintFixPassesFixFlag(t *testing.T) { t.Fatalf("write bundled config: %v", err) } - t.Setenv(SourcesCwdEnv, repo) + t.Setenv(sidecarproto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer - if err := support.RunLint(context.Background(), RunOptions{Fix: true, Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := NewInvoker(support).RunLint(context.Background(), Request{Fix: true, Stdout: &stdout, Stderr: &stderr}); err != nil { t.Fatalf("RunLint: %v\nstderr: %s", err, stderr.String()) } @@ -244,11 +246,11 @@ func TestRunLintSkipsBundledConfigWhenProjectHasOne(t *testing.T) { t.Fatalf("write bundled config: %v", err) } - t.Setenv(SourcesCwdEnv, repo) + t.Setenv(sidecarproto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer - if err := support.RunLint(context.Background(), RunOptions{Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := NewInvoker(support).RunLint(context.Background(), Request{Stdout: &stdout, Stderr: &stderr}); err != nil { t.Fatalf("RunLint: %v\nstderr: %s", err, stderr.String()) } @@ -260,13 +262,13 @@ func TestRunLintSkipsBundledConfigWhenProjectHasOne(t *testing.T) { func TestRunLintSkipsSpawnWithoutFiles(t *testing.T) { repo := gitScratchRepo(t, map[string]string{"main.go": "package main\n"}) - support := Support{Dir: t.TempDir()} // no sidecar: spawning would fail + support := Assets{Dir: t.TempDir()} // no sidecar: spawning would fail - t.Setenv(SourcesCwdEnv, repo) + t.Setenv(sidecarproto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer - if err := support.RunLint(context.Background(), RunOptions{Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := NewInvoker(support).RunLint(context.Background(), Request{Stdout: &stdout, Stderr: &stderr}); err != nil { t.Fatalf("RunLint: %v", err) } @@ -284,13 +286,13 @@ func TestRunLintSkipsSpawnForFormatOnlyDocuments(t *testing.T) { "notes.md": "# Notes\n", }) - support := Support{Dir: t.TempDir()} // no sidecar: spawning would fail + support := Assets{Dir: t.TempDir()} // no sidecar: spawning would fail - t.Setenv(SourcesCwdEnv, repo) + t.Setenv(sidecarproto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer - if err := support.RunLint(context.Background(), RunOptions{Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := NewInvoker(support).RunLint(context.Background(), Request{Stdout: &stdout, Stderr: &stderr}); err != nil { t.Fatalf("RunLint: %v", err) } @@ -308,12 +310,12 @@ func TestRunLintHonorsOxlintBinOverride(t *testing.T) { writeStub(t, override) - t.Setenv(SourcesCwdEnv, repo) - t.Setenv(OxlintBinEnv, override) + t.Setenv(sidecarproto.SourcesCwdEnv, repo) + t.Setenv(sidecarproto.OxlintBinEnv, override) var stdout, stderr bytes.Buffer - if err := support.RunLint(context.Background(), RunOptions{Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := NewInvoker(support).RunLint(context.Background(), Request{Stdout: &stdout, Stderr: &stderr}); err != nil { t.Fatalf("RunLint: %v\nstderr: %s", err, stderr.String()) } diff --git a/packages/go/driver/internal/tsruntime/support_test.go b/packages/go/driver/internal/tsruntime/support_test.go index 210d7cd..89af442 100644 --- a/packages/go/driver/internal/tsruntime/support_test.go +++ b/packages/go/driver/internal/tsruntime/support_test.go @@ -5,18 +5,20 @@ import ( "path/filepath" "testing" "testing/fstest" + + "go.ollin.sh/fmtkit/driver/internal/sidecarproto" ) // fakeAssets mirrors a directory staged by stage-ts-assets.sh: the bindings // and the sidecar, plus the configs that ride along with them. func fakeAssets() fstest.MapFS { return fstest.MapFS{ - sidecarName: &fstest.MapFile{Data: []byte("#!/bin/sh\n"), Mode: 0o755}, - "oxc-parser.node": &fstest.MapFile{Data: []byte("parser")}, - "oxfmt.node": &fstest.MapFile{Data: []byte("fmt")}, - "oxlint.node": &fstest.MapFile{Data: []byte("lint")}, - ".oxfmtrc.json": &fstest.MapFile{Data: []byte("{}")}, - ".oxlintrc.json": &fstest.MapFile{Data: []byte("{}")}, + sidecarproto.SidecarName: &fstest.MapFile{Data: []byte("#!/bin/sh\n"), Mode: 0o755}, + "oxc-parser.node": &fstest.MapFile{Data: []byte("parser")}, + "oxfmt.node": &fstest.MapFile{Data: []byte("fmt")}, + "oxlint.node": &fstest.MapFile{Data: []byte("lint")}, + ".oxfmtrc.json": &fstest.MapFile{Data: []byte("{}")}, + ".oxlintrc.json": &fstest.MapFile{Data: []byte("{}")}, } } @@ -27,7 +29,7 @@ func TestExtractOncePopulatesSupportDir(t *testing.T) { t.Fatalf("extractOnce: %v", err) } - support := Support{Dir: dir} + support := Assets{Dir: dir} if _, err := os.Stat(support.Sidecar()); err != nil { t.Fatalf("sidecar missing: %v", err) @@ -97,11 +99,11 @@ func TestExtractOnceLosingRaceKeepsWinner(t *testing.T) { func TestResolvePrefersSupportDirEnv(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, sidecarName), []byte("#!/bin/sh\n"), 0o755); err != nil { + if err := os.WriteFile(filepath.Join(dir, sidecarproto.SidecarName), []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatalf("write sidecar: %v", err) } - t.Setenv(SupportDirEnv, dir) + t.Setenv(sidecarproto.SupportDirEnv, dir) support, err := Resolve("v1.0.0") @@ -115,7 +117,7 @@ func TestResolvePrefersSupportDirEnv(t *testing.T) { } func TestResolveRejectsSupportDirWithoutSidecar(t *testing.T) { - t.Setenv(SupportDirEnv, t.TempDir()) + t.Setenv(sidecarproto.SupportDirEnv, t.TempDir()) if _, err := Resolve("v1.0.0"); err == nil { t.Fatal("expected error for support dir without sidecar") From ef60b0273e9bb8899ccd308af06afb8769659057 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 24 Jul 2026 10:37:32 +0800 Subject: [PATCH 3/3] style(g4): apply fmtkit self-formatting to new sidecarproto/tsruntime files The spacing rule hoists type definitions to the top of each file; apply it to the newly added sidecarproto sources and the reshaped assets.go so the repo's own formatter is a fixed point. --- .../driver/internal/sidecarproto/command.go | 36 +++++++-------- .../internal/sidecarproto/sidecarproto.go | 22 +++++----- .../driver/internal/sidecarproto/summary.go | 44 +++++++++---------- .../go/driver/internal/tsruntime/assets.go | 8 ++-- 4 files changed, 55 insertions(+), 55 deletions(-) diff --git a/packages/go/driver/internal/sidecarproto/command.go b/packages/go/driver/internal/sidecarproto/command.go index 2a069f3..b2934b3 100644 --- a/packages/go/driver/internal/sidecarproto/command.go +++ b/packages/go/driver/internal/sidecarproto/command.go @@ -14,6 +14,24 @@ type PipelineCommand struct { SyntaxFiles []string } +// OxlintCommand describes an oxlint invocation. ViaSidecar is set when the +// sidecar dispatches oxlint (and so must be told the mode); a direct OXLINT_BIN +// override clears it. Config is the resolved config path, or "" for +// auto-discovery. +type OxlintCommand struct { + ViaSidecar bool + Fix bool + Config string + Files []string +} + +// MigrateCommand describes an `oxfmt --migrate=prettier` invocation. ViaSidecar +// is set when the sidecar dispatches oxfmt; a direct OXFMT_BIN override clears +// it. +type MigrateCommand struct { + ViaSidecar bool +} + // Argv returns the pipeline mode's argument vector. func (c PipelineCommand) Argv() []string { args := []string{ModePipeline} @@ -32,17 +50,6 @@ func (c PipelineCommand) Argv() []string { return args } -// OxlintCommand describes an oxlint invocation. ViaSidecar is set when the -// sidecar dispatches oxlint (and so must be told the mode); a direct OXLINT_BIN -// override clears it. Config is the resolved config path, or "" for -// auto-discovery. -type OxlintCommand struct { - ViaSidecar bool - Fix bool - Config string - Files []string -} - // Argv returns oxlint's argument vector. func (c OxlintCommand) Argv() []string { var args []string @@ -64,13 +71,6 @@ func (c OxlintCommand) Argv() []string { return args } -// MigrateCommand describes an `oxfmt --migrate=prettier` invocation. ViaSidecar -// is set when the sidecar dispatches oxfmt; a direct OXFMT_BIN override clears -// it. -type MigrateCommand struct { - ViaSidecar bool -} - // Argv returns the migration argument vector. func (c MigrateCommand) Argv() (args []string) { if c.ViaSidecar { diff --git a/packages/go/driver/internal/sidecarproto/sidecarproto.go b/packages/go/driver/internal/sidecarproto/sidecarproto.go index 42ad60c..4cc3679 100644 --- a/packages/go/driver/internal/sidecarproto/sidecarproto.go +++ b/packages/go/driver/internal/sidecarproto/sidecarproto.go @@ -12,6 +12,17 @@ package sidecarproto import "os" +// Overrides carries every environment override a TS toolchain invocation +// honours, resolved once rather than ad hoc deep in the call paths. +type Overrides struct { + PipelineBin string + OxfmtBin string + OxlintBin string + OxfmtConfig string + OxlintConfig string + SourcesCwd string +} + // Asset filenames staged alongside the sidecar and read by both ends. const ( // SidecarName is the multiplexed toolchain executable's filename. @@ -63,17 +74,6 @@ const ( SourcesCwdEnv = "FMTKIT_SOURCES_CWD" ) -// Overrides carries every environment override a TS toolchain invocation -// honours, resolved once rather than ad hoc deep in the call paths. -type Overrides struct { - PipelineBin string - OxfmtBin string - OxlintBin string - OxfmtConfig string - OxlintConfig string - SourcesCwd string -} - // ReadOverrides gathers every environment override in one place. It is the sole // os.Getenv site for the override variables above. func ReadOverrides() Overrides { diff --git a/packages/go/driver/internal/sidecarproto/summary.go b/packages/go/driver/internal/sidecarproto/summary.go index d7096ef..19c3219 100644 --- a/packages/go/driver/internal/sidecarproto/summary.go +++ b/packages/go/driver/internal/sidecarproto/summary.go @@ -5,28 +5,6 @@ import ( "strings" ) -// The sidecar prints progress lines the driver scrapes into a step summary. -// These prefixes and the oxlint result pattern are the sidecar's output -// contract; only the lines the TS toolchain itself emits live here. Lines the -// Go driver prints about its own bookkeeping (source-collection warnings, the -// no-files notice, the Go formatter report) stay with the orchestrator. -const ( - blankLinesMatch = "[blank-lines] processed " - blankLinesTrim = "[blank-lines] " - - oxfmtFinishedMatch = "Finished in " - - fluentChainsMatch = "[fluent-chains] processed " - fluentChainsTrim = "[fluent-chains] " - - validateSyntaxMatch = "[validate-syntax] checked " - validateSyntaxTrim = "[validate-syntax] " -) - -// lintResultPattern matches oxlint's summary line, e.g. -// "Found 0 warnings and 0 errors." -var lintResultPattern = regexp.MustCompile(`Found [0-9]+ warning|[0-9]+ error`) - // PipelineSummary holds the sidecar's pipeline progress, each field carrying the // detail text the caller shows (already stripped of its scrape prefix, except // Oxfmt which is oxfmt's own full line). Empty fields mean the sidecar printed @@ -55,6 +33,28 @@ type LintSummary struct { Result string } +// The sidecar prints progress lines the driver scrapes into a step summary. +// These prefixes and the oxlint result pattern are the sidecar's output +// contract; only the lines the TS toolchain itself emits live here. Lines the +// Go driver prints about its own bookkeeping (source-collection warnings, the +// no-files notice, the Go formatter report) stay with the orchestrator. +const ( + blankLinesMatch = "[blank-lines] processed " + blankLinesTrim = "[blank-lines] " + + oxfmtFinishedMatch = "Finished in " + + fluentChainsMatch = "[fluent-chains] processed " + fluentChainsTrim = "[fluent-chains] " + + validateSyntaxMatch = "[validate-syntax] checked " + validateSyntaxTrim = "[validate-syntax] " +) + +// lintResultPattern matches oxlint's summary line, e.g. +// "Found 0 warnings and 0 errors." +var lintResultPattern = regexp.MustCompile(`Found [0-9]+ warning|[0-9]+ error`) + // ParsePipelineSummary scrapes the sidecar's pipeline output, taking the last // occurrence of each progress line. func ParsePipelineSummary(log string) PipelineSummary { diff --git a/packages/go/driver/internal/tsruntime/assets.go b/packages/go/driver/internal/tsruntime/assets.go index 71c9763..e737431 100644 --- a/packages/go/driver/internal/tsruntime/assets.go +++ b/packages/go/driver/internal/tsruntime/assets.go @@ -24,15 +24,15 @@ import ( "go.ollin.sh/fmtkit/driver/internal/sidecarproto" ) -// sentinelName marks a completed extraction; it is tsruntime's own bookkeeping, -// not part of the sidecar wire protocol. -const sentinelName = ".fmtkit-complete" - // Assets locates the extracted TS toolchain on disk. type Assets struct { Dir string } +// sentinelName marks a completed extraction; it is tsruntime's own bookkeeping, +// not part of the sidecar wire protocol. +const sentinelName = ".fmtkit-complete" + // Sidecar returns the path of the multiplexed toolchain executable. func (a Assets) Sidecar() string { return filepath.Join(a.Dir, sidecarproto.SidecarName)