From 9541ea1bf36a306c86cf57ab45161f848be0592d Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 24 Jul 2026 11:21:04 +0800 Subject: [PATCH 1/4] refactor(console): extract the ANSI progress logger into a console package Move the orchestrator's inline logger (section/detail/failure/stream rendering plus FORCE_COLOR/NO_COLOR/tty detection) into a dedicated driver/internal/console package. DetectColor now resolves the color mode once and NewPrinter takes the resolved ColorMode, so the printer never reads the environment inline. The orchestrator delegates to console.Printer; rendering is byte-identical (transcript goldens unchanged). --- .../go/driver/internal/console/printer.go | 144 ++++++++++++++++++ .../driver/internal/console/printer_test.go | 103 +++++++++++++ .../driver/internal/orchestrator/logging.go | 114 -------------- .../driver/internal/orchestrator/pipeline.go | 26 ++-- .../internal/orchestrator/pipeline_test.go | 6 +- .../driver/internal/orchestrator/summarize.go | 31 ++-- 6 files changed, 283 insertions(+), 141 deletions(-) create mode 100644 packages/go/driver/internal/console/printer.go create mode 100644 packages/go/driver/internal/console/printer_test.go delete mode 100644 packages/go/driver/internal/orchestrator/logging.go diff --git a/packages/go/driver/internal/console/printer.go b/packages/go/driver/internal/console/printer.go new file mode 100644 index 0000000..cd1de50 --- /dev/null +++ b/packages/go/driver/internal/console/printer.go @@ -0,0 +1,144 @@ +// Package console renders the pipeline's sectioned, ANSI-colored progress +// output: section headers, aligned detail lines, failure banners, and the +// indented live stream of a child tool's output. Color detection is resolved +// once by the caller (see DetectColor) and handed to NewPrinter, so the printer +// itself never reads the environment. +package console + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/mattn/go-isatty" +) + +// ColorMode is whether a Printer emits ANSI escape sequences. +type ColorMode int + +const ( + // ColorAuto defers the decision to DetectColor. NewPrinter treats it as + // no-color, so callers resolve it through DetectColor before constructing a + // Printer rather than passing it through. + ColorAuto ColorMode = iota + + // ColorAlways forces ANSI color on. + ColorAlways + + // ColorNever forces ANSI color off. + ColorNever +) + +// DetectColor resolves whether color should be used when writing to w. It +// honors FORCE_COLOR (always on) and NO_COLOR (always off) before falling back +// to whether w is a terminal. This is the single place the environment is read; +// callers resolve it once and pass the result to NewPrinter. +func DetectColor(w io.Writer) ColorMode { + if os.Getenv("FORCE_COLOR") != "" { + return ColorAlways + } + + if os.Getenv("NO_COLOR") != "" { + return ColorNever + } + + if file, ok := w.(*os.File); ok && isatty.IsTerminal(file.Fd()) { + return ColorAlways + } + + return ColorNever +} + +// Printer renders progress output to a writer. The palette fields are empty +// strings when color is off, so the same format strings render plain text. +type Printer struct { + w io.Writer + + bold string + dim string + cyan string + green string + red string + reset string +} + +// NewPrinter builds a Printer writing to w. ANSI color is enabled only for +// ColorAlways; ColorAuto and ColorNever both render plain text, so callers pass +// the resolved result of DetectColor. +func NewPrinter(w io.Writer, mode ColorMode) *Printer { + p := &Printer{w: w} + + if mode == ColorAlways { + p.bold = "\033[1m" + p.dim = "\033[2m" + p.cyan = "\033[36m" + p.green = "\033[32m" + p.red = "\033[31m" + p.reset = "\033[0m" + } + + return p +} + +// Section prints a bold, cyan-arrowed section header preceded by a blank line. +func (p *Printer) Section(msg string) { + _, _ = fmt.Fprintf(p.w, "\n%s==>%s %s%s%s\n", p.cyan, p.reset, p.bold, msg, p.reset) +} + +// Detail prints an aligned label/value line under the current section. +func (p *Printer) Detail(label, value string) { + _, _ = fmt.Fprintf(p.w, " %s%-12s%s %s\n", p.dim, label, p.reset, value) +} + +// SuccessDetail prints an aligned label/value line in green. +func (p *Printer) SuccessDetail(label, value string) { + _, _ = fmt.Fprintf(p.w, " %s%-12s%s %s%s%s\n", p.green, label, p.reset, p.green, value, p.reset) +} + +// Failure prints a red, banged failure banner preceded by a blank line. +func (p *Printer) Failure(msg string) { + _, _ = fmt.Fprintf(p.w, "\n%s!!%s %s%s%s\n", p.red, p.reset, p.bold, msg, p.reset) +} + +// Stream returns a writer that renders a child tool's output live, dimmed and +// indented under the current section. Callers must Close it to flush a trailing +// partial line. +func (p *Printer) Stream() io.WriteCloser { + return &indentWriter{printer: p} +} + +type indentWriter struct { + printer *Printer + partial strings.Builder +} + +func (w *indentWriter) Write(p []byte) (int, error) { + for _, b := range p { + if b != '\n' { + w.partial.WriteByte(b) + + continue + } + + w.flushLine() + } + + return len(p), nil +} + +func (w *indentWriter) Close() error { + if w.partial.Len() > 0 { + w.flushLine() + } + + return nil +} + +func (w *indentWriter) flushLine() { + p := w.printer + + _, _ = fmt.Fprintf(p.w, " %s%s%s\n", p.dim, w.partial.String(), p.reset) + + w.partial.Reset() +} diff --git a/packages/go/driver/internal/console/printer_test.go b/packages/go/driver/internal/console/printer_test.go new file mode 100644 index 0000000..2182c1f --- /dev/null +++ b/packages/go/driver/internal/console/printer_test.go @@ -0,0 +1,103 @@ +package console + +import ( + "strings" + "testing" +) + +func TestDetectColorHonorsForceColor(t *testing.T) { + t.Setenv("NO_COLOR", "1") + t.Setenv("FORCE_COLOR", "1") + + if got := DetectColor(&strings.Builder{}); got != ColorAlways { + t.Fatalf("DetectColor with FORCE_COLOR = %v, want ColorAlways", got) + } +} + +func TestDetectColorHonorsNoColor(t *testing.T) { + t.Setenv("FORCE_COLOR", "") + t.Setenv("NO_COLOR", "1") + + if got := DetectColor(&strings.Builder{}); got != ColorNever { + t.Fatalf("DetectColor with NO_COLOR = %v, want ColorNever", got) + } +} + +func TestDetectColorNonTerminalIsNever(t *testing.T) { + t.Setenv("FORCE_COLOR", "") + t.Setenv("NO_COLOR", "") + + // A strings.Builder is not an *os.File, so it is never a terminal. + if got := DetectColor(&strings.Builder{}); got != ColorNever { + t.Fatalf("DetectColor for non-tty = %v, want ColorNever", got) + } +} + +func TestPrinterPlainRendering(t *testing.T) { + var buf strings.Builder + + p := NewPrinter(&buf, ColorNever) + + p.Section("Running Go formatting") + p.Detail("fmtkit", "Formatted 2 file(s).") + p.SuccessDetail("status", "done") + p.Failure("Running Go formatting failed") + + want := "\n==> Running Go formatting\n" + + " fmtkit Formatted 2 file(s).\n" + + " status done\n" + + "\n!! Running Go formatting failed\n" + + if buf.String() != want { + t.Fatalf("plain rendering mismatch\n--- got ---\n%q\n--- want ---\n%q", buf.String(), want) + } +} + +func TestPrinterColorRendering(t *testing.T) { + var buf strings.Builder + + p := NewPrinter(&buf, ColorAlways) + + p.Section("Formatting complete") + + got := buf.String() + + for _, want := range []string{"\033[36m", "\033[1m", "\033[0m", "Formatting complete"} { + if !strings.Contains(got, want) { + t.Fatalf("color section missing %q:\n%q", want, got) + } + } +} + +func TestPrinterColorAutoRendersPlain(t *testing.T) { + var buf strings.Builder + + NewPrinter(&buf, ColorAuto).Detail("label", "value") + + if strings.Contains(buf.String(), "\033[") { + t.Fatalf("ColorAuto emitted ANSI escapes: %q", buf.String()) + } +} + +func TestStreamIndentsAndFlushesPartialLine(t *testing.T) { + var buf strings.Builder + + p := NewPrinter(&buf, ColorNever) + + stream := p.Stream() + + _, _ = stream.Write([]byte("first line\nsecond ")) + _, _ = stream.Write([]byte("half\ntrailing")) + + if err := stream.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + want := " first line\n" + + " second half\n" + + " trailing\n" + + if buf.String() != want { + t.Fatalf("stream mismatch\n--- got ---\n%q\n--- want ---\n%q", buf.String(), want) + } +} diff --git a/packages/go/driver/internal/orchestrator/logging.go b/packages/go/driver/internal/orchestrator/logging.go deleted file mode 100644 index c93f71c..0000000 --- a/packages/go/driver/internal/orchestrator/logging.go +++ /dev/null @@ -1,114 +0,0 @@ -// Package orchestrator drives the full fmtkit formatting pipeline (TS/Vue -// formatting, TS/Vue lint, Go formatting) with sectioned, colorized progress -// output: each step's tool output streams live, indented under its section -// header, and is followed by the condensed summary lines. -package orchestrator - -import ( - "fmt" - "io" - "os" - "strings" - - "github.com/mattn/go-isatty" -) - -type logger struct { - w io.Writer - quiet bool - - bold string - dim string - cyan string - green string - red string - reset string -} - -// stream returns a writer that renders tool output live, dimmed and indented -// under the current section. Callers must Close it to flush a trailing -// partial line. - -type indentWriter struct { - logger *logger - partial strings.Builder -} - -func newLogger(w io.Writer, quiet bool) *logger { - l := &logger{w: w, quiet: quiet} - - if colorEnabled(w) { - l.bold = "\033[1m" - l.dim = "\033[2m" - l.cyan = "\033[36m" - l.green = "\033[32m" - l.red = "\033[31m" - l.reset = "\033[0m" - } - - return l -} - -func colorEnabled(w io.Writer) bool { - if os.Getenv("FORCE_COLOR") != "" { - return true - } - - if os.Getenv("NO_COLOR") != "" { - return false - } - - file, ok := w.(*os.File) - - return ok && isatty.IsTerminal(file.Fd()) -} - -func (l *logger) section(msg string) { - _, _ = fmt.Fprintf(l.w, "\n%s==>%s %s%s%s\n", l.cyan, l.reset, l.bold, msg, l.reset) -} - -func (l *logger) detail(label, value string) { - _, _ = fmt.Fprintf(l.w, " %s%-12s%s %s\n", l.dim, label, l.reset, value) -} - -func (l *logger) successDetail(label, value string) { - _, _ = fmt.Fprintf(l.w, " %s%-12s%s %s%s%s\n", l.green, label, l.reset, l.green, value, l.reset) -} - -func (l *logger) failure(msg string) { - _, _ = fmt.Fprintf(l.w, "\n%s!!%s %s%s%s\n", l.red, l.reset, l.bold, msg, l.reset) -} - -func (l *logger) stream() io.WriteCloser { - return &indentWriter{logger: l} -} - -func (w *indentWriter) Write(p []byte) (int, error) { - for _, b := range p { - if b != '\n' { - w.partial.WriteByte(b) - - continue - } - - w.flushLine() - } - - return len(p), nil -} - -func (w *indentWriter) Close() error { - if w.partial.Len() > 0 { - w.flushLine() - } - - return nil -} - -func (w *indentWriter) flushLine() { - l := w.logger - - _, _ = fmt.Fprintf(l.w, " %s%s%s\n", l.dim, w.partial.String(), l.reset) - - w.partial.Reset() -} diff --git a/packages/go/driver/internal/orchestrator/pipeline.go b/packages/go/driver/internal/orchestrator/pipeline.go index 0aedfe3..f410811 100644 --- a/packages/go/driver/internal/orchestrator/pipeline.go +++ b/packages/go/driver/internal/orchestrator/pipeline.go @@ -1,3 +1,7 @@ +// Package orchestrator drives the full fmtkit formatting pipeline (TS/Vue +// formatting, TS/Vue lint, Go formatting) with sectioned, colorized progress +// output: each step's tool output streams live, indented under its section +// header, and is followed by the condensed summary lines. package orchestrator import ( @@ -7,6 +11,8 @@ import ( "io" "os/exec" "strings" + + "go.ollin.sh/fmtkit/driver/internal/console" ) // Tools carries the three pipeline steps. The TS steps return an error whose @@ -52,16 +58,16 @@ func (p Pipeline) RunFormat(ctx context.Context, paths []string) int { paths = []string{"."} } - log := newLogger(p.Stderr, p.Quiet) + log := console.NewPrinter(p.Stderr, console.DetectColor(p.Stderr)) - log.section("Formatting target(s)") - log.detail("paths", strings.Join(paths, " ")) + log.Section("Formatting target(s)") + log.Detail("paths", strings.Join(paths, " ")) selected := p.Steps.normalized() type step struct { label string - summarize func(string, *logger) + summarize func(string, *console.Printer) run func(ctx context.Context, output io.Writer) int } @@ -102,16 +108,16 @@ func (p Pipeline) RunFormat(ctx context.Context, paths []string) int { } } - log.section("Formatting complete") - log.successDetail("status", "done") + log.Section("Formatting complete") + log.SuccessDetail("status", "done") return 0 } // runStep captures a step's combined output, streaming it live unless quiet, // and prints either its summary details or (on failure) the captured log. -func (p Pipeline) runStep(ctx context.Context, log *logger, label string, summarize func(string, *logger), run func(context.Context, io.Writer) int) int { - log.section(label) +func (p Pipeline) runStep(ctx context.Context, log *console.Printer, label string, summarize func(string, *console.Printer), run func(context.Context, io.Writer) int) int { + log.Section(label) var captured bytes.Buffer @@ -120,7 +126,7 @@ func (p Pipeline) runStep(ctx context.Context, log *logger, label string, summar var live io.WriteCloser if !p.Quiet { - live = log.stream() + live = log.Stream() output = io.MultiWriter(&captured, live) } @@ -131,7 +137,7 @@ func (p Pipeline) runStep(ctx context.Context, log *logger, label string, summar } if code != 0 { - log.failure(label + " failed") + log.Failure(label + " failed") if p.Quiet { _, _ = io.Copy(p.Stderr, bytes.NewReader(captured.Bytes())) diff --git a/packages/go/driver/internal/orchestrator/pipeline_test.go b/packages/go/driver/internal/orchestrator/pipeline_test.go index d09f62b..724fb88 100644 --- a/packages/go/driver/internal/orchestrator/pipeline_test.go +++ b/packages/go/driver/internal/orchestrator/pipeline_test.go @@ -11,6 +11,8 @@ import ( "path/filepath" "strings" "testing" + + "go.ollin.sh/fmtkit/driver/internal/console" ) type invocation struct { @@ -277,7 +279,7 @@ func TestRunFormatQuietDumpsLogOnFailure(t *testing.T) { func TestSummarizeTSLintFallbacks(t *testing.T) { var out bytes.Buffer - log := newLogger(&out, true) + log := console.NewPrinter(&out, console.ColorNever) summarizeTSLint("[lint] no TS/Vue files to lint.\n", log) @@ -297,7 +299,7 @@ func TestSummarizeTSLintFallbacks(t *testing.T) { func TestSummarizeTSFormatCountsMissing(t *testing.T) { var out bytes.Buffer - log := newLogger(&out, true) + log := console.NewPrinter(&out, console.ColorNever) summarizeTSFormat("[sources] path not found, skipping: /work/a\n[sources] path not found, skipping: /work/b\n", log) diff --git a/packages/go/driver/internal/orchestrator/summarize.go b/packages/go/driver/internal/orchestrator/summarize.go index 9508784..d295f66 100644 --- a/packages/go/driver/internal/orchestrator/summarize.go +++ b/packages/go/driver/internal/orchestrator/summarize.go @@ -5,6 +5,7 @@ import ( "regexp" "strings" + "go.ollin.sh/fmtkit/driver/internal/console" "go.ollin.sh/fmtkit/driver/internal/sidecarproto" ) @@ -36,7 +37,7 @@ func lastWithPrefix(logLines []string, prefix string) string { return match } -func summarizeTSFormat(log string, l *logger) { +func summarizeTSFormat(log string, l *console.Printer) { summary := sidecarproto.ParsePipelineSummary(log) missing := 0 @@ -47,43 +48,43 @@ func summarizeTSFormat(log string, l *logger) { } if summary.BlankLines != "" { - l.detail("blank-lines", summary.BlankLines) + l.Detail("blank-lines", summary.BlankLines) } if missing > 0 { - l.detail("skipped", fmt.Sprintf("%d missing tracked file(s)", missing)) + l.Detail("skipped", fmt.Sprintf("%d missing tracked file(s)", missing)) } if summary.Oxfmt != "" { - l.detail("oxfmt", summary.Oxfmt) + l.Detail("oxfmt", summary.Oxfmt) } if summary.FluentChains != "" { - l.detail("fluent", summary.FluentChains) + l.Detail("fluent", summary.FluentChains) } if summary.ValidateSyntax != "" { - l.detail("validated", summary.ValidateSyntax) + l.Detail("validated", summary.ValidateSyntax) } } -func summarizeTSLint(log string, l *logger) { +func summarizeTSLint(log string, l *console.Printer) { if lastWithPrefix(lines(log), lintNothingToLintLine) != "" { - l.detail("oxlint", strings.TrimPrefix(lintNothingToLintLine, "[lint] ")) + l.Detail("oxlint", strings.TrimPrefix(lintNothingToLintLine, "[lint] ")) return } if result := sidecarproto.ParseLintSummary(log).Result; result != "" { - l.detail("oxlint", result) + l.Detail("oxlint", result) return } - l.detail("oxlint", "no issues found") + l.Detail("oxlint", "no issues found") } -func summarizeGoFormat(log string, l *logger) { +func summarizeGoFormat(log string, l *console.Printer) { var fileSummary, formatterResult, vetSummary, vetResult string for _, line := range lines(log) { @@ -105,18 +106,18 @@ func summarizeGoFormat(log string, l *logger) { } if fileSummary != "" { - l.detail("fmtkit", fileSummary) + l.Detail("fmtkit", fileSummary) } if formatterResult != "" { - l.detail("result", formatterResult) + l.Detail("result", formatterResult) } if vetSummary != "" { - l.detail("vet", vetSummary) + l.Detail("vet", vetSummary) } if vetResult != "" && vetResult != formatterResult { - l.detail("vet result", vetResult) + l.Detail("vet result", vetResult) } } From b2e8d770707c962a2ff347fae8c912d32d541a08 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 24 Jul 2026 11:37:48 +0800 Subject: [PATCH 2/4] refactor(pipeline): give the pipeline typed steps, delete stdout scraping Replace the orchestrator's Tools func-triple and RunFormat with a generic Step/Result/Detail abstraction: Pipeline runs an ordered []Step, owning only the section/tee/quiet-failure-dump mechanics. The concrete steps (TS lint, TS format, Go format) live in the app composition root, which builds them and frames the run (target header, completion footer), resolving color once via console.DetectColor. The Go step derives its summary details from the typed gotool.Outcome (new Runner.RunReport returns it) instead of scraping the rendered report text; summarize.go and its Go-report regexes are deleted. The TS steps parse their captured output through sidecarproto plus the driver's own [sources]/[lint] bookkeeping notices, as before. Transcript goldens are unchanged and still pass byte-identical, now driven by fake Steps in the orchestrator test. --- packages/go/driver/internal/app/format.go | 59 ++-- packages/go/driver/internal/app/options.go | 4 +- packages/go/driver/internal/app/steps.go | 309 ++++++++++++++++++ packages/go/driver/internal/app/steps_test.go | 232 +++++++++++++ packages/go/driver/internal/gotool/runner.go | 23 +- .../driver/internal/orchestrator/pipeline.go | 165 +++------- .../internal/orchestrator/pipeline_test.go | 267 ++++++++------- .../driver/internal/orchestrator/summarize.go | 123 ------- 8 files changed, 763 insertions(+), 419 deletions(-) create mode 100644 packages/go/driver/internal/app/steps.go create mode 100644 packages/go/driver/internal/app/steps_test.go delete mode 100644 packages/go/driver/internal/orchestrator/summarize.go diff --git a/packages/go/driver/internal/app/format.go b/packages/go/driver/internal/app/format.go index 8433b11..69e90ef 100644 --- a/packages/go/driver/internal/app/format.go +++ b/packages/go/driver/internal/app/format.go @@ -3,13 +3,11 @@ package app import ( "context" "fmt" - "io" + "strings" + "go.ollin.sh/fmtkit/driver/internal/console" "go.ollin.sh/fmtkit/driver/internal/gitfiles" - "go.ollin.sh/fmtkit/driver/internal/gotool" "go.ollin.sh/fmtkit/driver/internal/orchestrator" - "go.ollin.sh/fmtkit/driver/internal/tsruntime" - report "go.ollin.sh/fmtkit/driver/report" ) // runFormat formats what diverges from HEAD — modified files, staged or not, @@ -48,37 +46,32 @@ func (d *deps) runFormatAll(ctx context.Context, args []string) int { return d.runPipeline(ctx, []string{"."}, opts, gitfiles.SelectionAll) } +// runPipeline frames the format run (target header, completion footer) around +// the typed steps it builds for the selection, handing them to the generic +// orchestrator. Color is resolved once here, at the composition root. func (d *deps) runPipeline(ctx context.Context, paths []string, opts formatOptions, selection gitfiles.Selection) int { + if len(paths) == 0 { + paths = []string{"."} + } + + printer := console.NewPrinter(d.stderr, console.DetectColor(d.stderr)) + + printer.Section("Formatting target(s)") + printer.Detail("paths", strings.Join(paths, " ")) + pipeline := orchestrator.Pipeline{ - Tools: orchestrator.Tools{ - TS: func(ctx context.Context, scopes []string, output io.Writer) error { - assets, err := tsruntime.Resolve(d.version) - - if err != nil { - return err - } - - 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 { - assets, err := tsruntime.Resolve(d.version) - - if err != nil { - return err - } - - 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 gotool. - Runner{Stdout: output, Stderr: output, Scope: selection}. - Run(ctx, report.ModeFormat, args[1:]) - }, - }, - Steps: opts.steps, - Quiet: opts.quiet, - Stderr: d.stderr, + Steps: d.formatSteps(paths, opts.steps, selection), + Quiet: opts.quiet, + Printer: printer, + Stderr: d.stderr, } - return pipeline.RunFormat(ctx, paths) + if code := pipeline.Run(ctx); code != 0 { + return code + } + + printer.Section("Formatting complete") + printer.SuccessDetail("status", "done") + + return 0 } diff --git a/packages/go/driver/internal/app/options.go b/packages/go/driver/internal/app/options.go index 75d548e..3bec3f0 100644 --- a/packages/go/driver/internal/app/options.go +++ b/packages/go/driver/internal/app/options.go @@ -3,12 +3,10 @@ package app import ( "fmt" "strings" - - "go.ollin.sh/fmtkit/driver/internal/orchestrator" ) type formatOptions struct { - steps orchestrator.Steps + steps stepSelection quiet bool } diff --git a/packages/go/driver/internal/app/steps.go b/packages/go/driver/internal/app/steps.go new file mode 100644 index 0000000..2906a86 --- /dev/null +++ b/packages/go/driver/internal/app/steps.go @@ -0,0 +1,309 @@ +package app + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os/exec" + "strings" + + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + "go.ollin.sh/fmtkit/driver/internal/gotool" + "go.ollin.sh/fmtkit/driver/internal/orchestrator" + "go.ollin.sh/fmtkit/driver/internal/sidecarproto" + "go.ollin.sh/fmtkit/driver/internal/tsruntime" + report "go.ollin.sh/fmtkit/driver/report" + formatterengine "go.ollin.sh/fmtkit/formatter/engine" + "go.ollin.sh/fmtkit/vet" +) + +// stepSelection selects which parts of the format pipeline run; the zero value +// (no --ts/--go flags) runs everything. +type stepSelection struct { + TS bool + Go bool +} + +func (s stepSelection) normalized() stepSelection { + if !s.TS && !s.Go { + return stepSelection{TS: true, Go: true} + } + + return s +} + +// formatSteps builds the ordered pipeline steps for the selection. Lint runs +// first so the formatting passes normalize whatever oxlint rewrites. +func (d *deps) formatSteps(paths []string, selected stepSelection, selection gitfiles.Selection) []orchestrator.Step { + selected = selected.normalized() + + var steps []orchestrator.Step + + if selected.TS { + steps = append(steps, + tsLintStep{version: d.version, paths: paths, selection: selection}, + tsFormatStep{version: d.version, paths: paths, selection: selection}, + ) + } + + if selected.Go { + steps = append(steps, goFormatStep{paths: paths, selection: selection}) + } + + return steps +} + +// Driver-owned bookkeeping lines the TS steps recognize in their captured +// output. The sidecar's own wire lines are parsed by sidecarproto; these are +// notices the Go driver prints around the sidecar, so they stay here. +const ( + sourcesMissingPrefix = "[sources] path not found, skipping:" + lintNothingToLintLine = "[lint] no TS/Vue files to lint." +) + +// tsLintStep lints TS/Vue files, applying oxlint's safe fixes (--fix). +type tsLintStep struct { + version string + paths []string + selection gitfiles.Selection +} + +func (s tsLintStep) Label() string { return "Running TS/Vue lint" } + +func (s tsLintStep) Run(ctx context.Context, output io.Writer) orchestrator.Result { + var captured bytes.Buffer + + err := invokeTS(s.version, io.MultiWriter(output, &captured), func(invoker tsruntime.Invoker, w io.Writer) error { + return invoker.RunLint(ctx, tsruntime.Request{Scopes: s.paths, Selection: s.selection, Fix: true, Stdout: w, Stderr: w}) + }) + + if code := tsExitCode(err, output); code != 0 { + return orchestrator.Result{ExitCode: code} + } + + return orchestrator.Result{Details: tsLintDetails(captured.String())} +} + +// tsFormatStep runs the full TS/Vue formatting pipeline (oxfmt plus the project +// passes). +type tsFormatStep struct { + version string + paths []string + selection gitfiles.Selection +} + +func (s tsFormatStep) Label() string { return "Running TS/Vue formatting" } + +func (s tsFormatStep) Run(ctx context.Context, output io.Writer) orchestrator.Result { + var captured bytes.Buffer + + err := invokeTS(s.version, io.MultiWriter(output, &captured), func(invoker tsruntime.Invoker, w io.Writer) error { + return invoker.RunPipeline(ctx, tsruntime.Request{Scopes: s.paths, Selection: s.selection, Stdout: w, Stderr: w}) + }) + + if code := tsExitCode(err, output); code != 0 { + return orchestrator.Result{ExitCode: code} + } + + return orchestrator.Result{Details: tsFormatDetails(captured.String())} +} + +// goFormatStep formats Go files and runs go vet, deriving its details from the +// typed outcome rather than the rendered report text. +type goFormatStep struct { + paths []string + selection gitfiles.Selection +} + +func (s goFormatStep) Label() string { return "Running Go formatting" } + +func (s goFormatStep) Run(ctx context.Context, output io.Writer) orchestrator.Result { + outcome, code := gotool. + Runner{Stdout: output, Stderr: output, Scope: s.selection}. + RunReport(ctx, report.ModeFormat, s.paths) + + if code != 0 { + return orchestrator.Result{ExitCode: code} + } + + return orchestrator.Result{Details: goFormatDetails(outcome)} +} + +// invokeTS resolves the TS toolchain and invokes it through spawn, which +// receives the constructed Invoker and the writer to stream tool output to. +func invokeTS(version string, output io.Writer, spawn func(tsruntime.Invoker, io.Writer) error) error { + assets, err := tsruntime.Resolve(version) + + if err != nil { + return err + } + + return spawn(tsruntime.NewInvoker(assets), output) +} + +// tsExitCode maps a TS step error to its exit code. Failures that never +// produced tool output (a missing sidecar, an unreadable working tree) surface +// their message through output so they are visible both live and in the quiet +// failure dump. +func tsExitCode(err error, output io.Writer) int { + if err == nil { + return 0 + } + + var exit *exec.ExitError + + if errors.As(err, &exit) { + return exit.ExitCode() + } + + _, _ = io.WriteString(output, err.Error()+"\n") + + return 1 +} + +// tsLintDetails derives the oxlint summary line. A driver "no files" notice +// wins; otherwise oxlint's own result line; otherwise a clean fallback. +func tsLintDetails(log string) []orchestrator.Detail { + for _, line := range strings.Split(log, "\n") { + if strings.HasPrefix(line, lintNothingToLintLine) { + return []orchestrator.Detail{{Label: "oxlint", Value: strings.TrimPrefix(lintNothingToLintLine, "[lint] ")}} + } + } + + if result := sidecarproto.ParseLintSummary(log).Result; result != "" { + return []orchestrator.Detail{{Label: "oxlint", Value: result}} + } + + return []orchestrator.Detail{{Label: "oxlint", Value: "no issues found"}} +} + +// tsFormatDetails derives the TS pipeline's detail lines from the sidecar's +// progress output plus the driver's missing-source notices. +func tsFormatDetails(log string) []orchestrator.Detail { + summary := sidecarproto.ParsePipelineSummary(log) + + var details []orchestrator.Detail + + if summary.BlankLines != "" { + details = append(details, orchestrator.Detail{Label: "blank-lines", Value: summary.BlankLines}) + } + + missing := 0 + + for _, line := range strings.Split(log, "\n") { + if strings.HasPrefix(line, sourcesMissingPrefix) { + missing++ + } + } + + if missing > 0 { + details = append(details, orchestrator.Detail{Label: "skipped", Value: fmt.Sprintf("%d missing tracked file(s)", missing)}) + } + + if summary.Oxfmt != "" { + details = append(details, orchestrator.Detail{Label: "oxfmt", Value: summary.Oxfmt}) + } + + if summary.FluentChains != "" { + details = append(details, orchestrator.Detail{Label: "fluent", Value: summary.FluentChains}) + } + + if summary.ValidateSyntax != "" { + details = append(details, orchestrator.Detail{Label: "validated", Value: summary.ValidateSyntax}) + } + + return details +} + +// goFormatDetails computes the Go step's detail lines from the typed outcome, +// reproducing the exact strings the text report renders (which the pipeline +// previously scraped back out of that rendered text). +func goFormatDetails(outcome gotool.Outcome) []orchestrator.Detail { + fm := outcome.Combined.Formatter + vt := outcome.Combined.Vet + + var details []orchestrator.Detail + + if summary := goFileSummary(fm, outcome.Mode); summary != "" { + details = append(details, orchestrator.Detail{Label: "fmtkit", Value: summary}) + } + + // The formatter renders a Result line unless it found no files and hit no + // errors; the vet Result line always renders. The "result" detail is the + // first Result line (the formatter's when present, else the vet's), matching + // the text report's top-to-bottom order. + formatterResult := "" + + if fm.Files != 0 || len(fm.Errors) != 0 { + formatterResult = fmt.Sprintf("%s. %d changed, %d violation(s), %d error(s).", fm.Result, fm.Changed, fm.ViolationCount(), fm.ErrorCount()) + } + + vetResult := fmt.Sprintf("%s. %d error(s).", goVetStatus(vt), vt.ErrorCount()) + + resultLine := formatterResult + + if resultLine == "" { + resultLine = vetResult + } + + details = append(details, orchestrator.Detail{Label: "result", Value: resultLine}) + + if summary := goVetSummary(vt); summary != "" { + details = append(details, orchestrator.Detail{Label: "vet", Value: summary}) + } + + if vetResult != resultLine { + details = append(details, orchestrator.Detail{Label: "vet result", Value: vetResult}) + } + + return details +} + +// goFileSummary is the formatter's file-count line: "No Go files found." when it +// owns none, otherwise the mode's verb and count. +func goFileSummary(fm formatterengine.Report, mode report.Mode) string { + if fm.Files == 0 { + return "No Go files found." + } + + action := "Checked" + + if mode == report.ModeFormat { + action = "Formatted" + } + + return fmt.Sprintf("%s %d file(s).", action, fm.Files) +} + +// goVetStatus classifies the vet report the same way the text report does. +func goVetStatus(vt vet.Report) string { + switch { + case vt.Skipped || vt.Root == "": + return "skipped" + case vt.ErrorCount() > 0: + return "fail" + default: + return "pass" + } +} + +// goVetSummary is the vet status line, or "" for a failure (whose per-error +// lines the text report shows instead of a one-line summary). +func goVetSummary(vt vet.Report) string { + switch goVetStatus(vt) { + case "skipped": + reason := "no Go module or workspace was detected" + + if vt.Skipped { + reason = "the Go toolchain is not available" + } + + return "Skipped automatic go vet ./... because " + reason + "." + case "pass": + return "go vet ./... passed." + default: + return "" + } +} diff --git a/packages/go/driver/internal/app/steps_test.go b/packages/go/driver/internal/app/steps_test.go new file mode 100644 index 0000000..dc28770 --- /dev/null +++ b/packages/go/driver/internal/app/steps_test.go @@ -0,0 +1,232 @@ +package app + +import ( + "bytes" + "errors" + "fmt" + "testing" + + "go.ollin.sh/fmtkit/driver/internal/gotool" + "go.ollin.sh/fmtkit/driver/internal/orchestrator" + report "go.ollin.sh/fmtkit/driver/report" + formatterengine "go.ollin.sh/fmtkit/formatter/engine" + "go.ollin.sh/fmtkit/vet" +) + +func detailStrings(details []orchestrator.Detail) []string { + out := make([]string, 0, len(details)) + + for _, d := range details { + out = append(out, d.Label+"|"+d.Value) + } + + return out +} + +func assertDetails(t *testing.T, got []orchestrator.Detail, want ...string) { + t.Helper() + + if g := fmt.Sprint(detailStrings(got)); g != fmt.Sprint(want) { + t.Fatalf("details mismatch\n--- got ---\n%s\n--- want ---\n%s", g, fmt.Sprint(want)) + } +} + +// goOutcome builds a Go outcome for the given mode, formatter report, and vet +// report. +func goOutcome(mode report.Mode, fm formatterengine.Report, vt vet.Report) gotool.Outcome { + return gotool.Outcome{Mode: mode, Combined: report.Combined{Formatter: fm, Vet: vt}} +} + +func TestGoFormatDetailsPass(t *testing.T) { + outcome := goOutcome( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 2}, + vet.Report{Root: "/work"}, + ) + + assertDetails(t, goFormatDetails(outcome), + "fmtkit|Formatted 2 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|go vet ./... passed.", + "vet result|pass. 0 error(s).", + ) +} + +func TestGoFormatDetailsCheckModeVerb(t *testing.T) { + outcome := goOutcome( + report.ModeCheck, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 3}, + vet.Report{Root: "/work"}, + ) + + assertDetails(t, goFormatDetails(outcome), + "fmtkit|Checked 3 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|go vet ./... passed.", + "vet result|pass. 0 error(s).", + ) +} + +// TestGoFormatDetailsNoFiles reproduces the scraper's quirk: with no formatter +// Result line rendered, the "result" detail borrows the vet Result line and the +// separate "vet result" line is suppressed (they are identical). +func TestGoFormatDetailsNoFiles(t *testing.T) { + outcome := goOutcome( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 0}, + vet.Report{Root: "/work"}, + ) + + assertDetails(t, goFormatDetails(outcome), + "fmtkit|No Go files found.", + "result|pass. 0 error(s).", + "vet|go vet ./... passed.", + ) +} + +func TestGoFormatDetailsVetSkippedNoModule(t *testing.T) { + outcome := goOutcome( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 1}, + vet.Report{Root: ""}, + ) + + assertDetails(t, goFormatDetails(outcome), + "fmtkit|Formatted 1 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|Skipped automatic go vet ./... because no Go module or workspace was detected.", + "vet result|skipped. 0 error(s).", + ) +} + +func TestGoFormatDetailsVetSkippedToolchain(t *testing.T) { + outcome := goOutcome( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 1}, + vet.Report{Root: "/work", Skipped: true}, + ) + + assertDetails(t, goFormatDetails(outcome), + "fmtkit|Formatted 1 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|Skipped automatic go vet ./... because the Go toolchain is not available.", + "vet result|skipped. 0 error(s).", + ) +} + +// TestGoFormatDetailsVetFailure: a vet failure renders per-error lines instead +// of a status summary, so there is no "vet" detail, but the differing vet Result +// line still appears. +func TestGoFormatDetailsVetFailure(t *testing.T) { + outcome := goOutcome( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 2}, + vet.Report{Root: "/work", Errors: []vet.ErrorResult{{File: "a.go", Message: "boom"}}}, + ) + + assertDetails(t, goFormatDetails(outcome), + "fmtkit|Formatted 2 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet result|fail. 1 error(s).", + ) +} + +func TestTSFormatDetails(t *testing.T) { + log := "[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).\n" + + assertDetails(t, tsFormatDetails(log), + "blank-lines|processed 3 file(s) in /work, 0 changed", + "oxfmt|Finished in 10ms on 3 files using 8 threads.", + "fluent|processed 3 file(s) in /work, 1 changed", + "validated|checked 3 file(s).", + ) +} + +func TestTSFormatDetailsCountsMissing(t *testing.T) { + log := "[sources] path not found, skipping: /work/a\n" + + "[sources] path not found, skipping: /work/b\n" + + assertDetails(t, tsFormatDetails(log), "skipped|2 missing tracked file(s)") +} + +func TestTSLintDetailsResult(t *testing.T) { + assertDetails(t, tsLintDetails("Found 0 warnings and 0 errors.\n"), "oxlint|Found 0 warnings and 0 errors.") +} + +func TestTSLintDetailsNoFiles(t *testing.T) { + assertDetails(t, tsLintDetails("[lint] no TS/Vue files to lint.\n"), "oxlint|no TS/Vue files to lint.") +} + +func TestTSLintDetailsFallback(t *testing.T) { + assertDetails(t, tsLintDetails("nothing interesting\n"), "oxlint|no issues found") +} + +func TestTSExitCodePlainErrorWritesToOutput(t *testing.T) { + var buf bytes.Buffer + + if code := tsExitCode(errors.New("boom"), &buf); code != 1 { + t.Fatalf("tsExitCode = %d, want 1", code) + } + + if buf.String() != "boom\n" { + t.Fatalf("tsExitCode output = %q, want %q", buf.String(), "boom\n") + } +} + +func TestTSExitCodeNil(t *testing.T) { + var buf bytes.Buffer + + if code := tsExitCode(nil, &buf); code != 0 { + t.Fatalf("tsExitCode(nil) = %d, want 0", code) + } + + if buf.Len() != 0 { + t.Fatalf("tsExitCode(nil) wrote %q", buf.String()) + } +} + +func TestStepSelectionNormalized(t *testing.T) { + if got := (stepSelection{}).normalized(); !got.TS || !got.Go { + t.Fatalf("zero selection = %+v, want both set", got) + } + + if got := (stepSelection{TS: true}).normalized(); !got.TS || got.Go { + t.Fatalf("TS-only selection = %+v, want TS only", got) + } + + if got := (stepSelection{Go: true}).normalized(); got.TS || !got.Go { + t.Fatalf("Go-only selection = %+v, want Go only", got) + } +} + +func TestFormatStepsSelection(t *testing.T) { + d := &deps{version: "dev"} + + labels := func(steps []orchestrator.Step) []string { + out := make([]string, 0, len(steps)) + + for _, s := range steps { + out = append(out, s.Label()) + } + + return out + } + + all := labels(d.formatSteps([]string{"."}, stepSelection{}, 0)) + if fmt.Sprint(all) != fmt.Sprint([]string{"Running TS/Vue lint", "Running TS/Vue formatting", "Running Go formatting"}) { + t.Fatalf("default steps = %v", all) + } + + tsOnly := labels(d.formatSteps([]string{"."}, stepSelection{TS: true}, 0)) + if fmt.Sprint(tsOnly) != fmt.Sprint([]string{"Running TS/Vue lint", "Running TS/Vue formatting"}) { + t.Fatalf("--ts steps = %v", tsOnly) + } + + goOnly := labels(d.formatSteps([]string{"."}, stepSelection{Go: true}, 0)) + if fmt.Sprint(goOnly) != fmt.Sprint([]string{"Running Go formatting"}) { + t.Fatalf("--go steps = %v", goOnly) + } +} diff --git a/packages/go/driver/internal/gotool/runner.go b/packages/go/driver/internal/gotool/runner.go index 0b6bb2d..8895dca 100644 --- a/packages/go/driver/internal/gotool/runner.go +++ b/packages/go/driver/internal/gotool/runner.go @@ -27,10 +27,21 @@ type Runner struct { // Run parses args for mode, executes the Go formatter and vet, renders the // report, and returns the process exit code. func (r Runner) Run(ctx context.Context, mode report.Mode, args []string) int { + _, code := r.RunReport(ctx, mode, args) + + return code +} + +// RunReport is Run that also returns the typed outcome so pipeline callers can +// derive their summary details from it rather than scraping the rendered text. +// On a setup failure it returns the zero Outcome and a non-zero code after +// reporting the problem to Stderr, so the outcome is only meaningful when the +// returned code is zero. +func (r Runner) RunReport(ctx context.Context, mode report.Mode, args []string) (Outcome, int) { inv, err := ParseInvocation(mode, args, r.Stderr) if err != nil { - return 1 + return Outcome{}, 1 } workRoot, err := os.Getwd() @@ -38,7 +49,7 @@ func (r Runner) Run(ctx context.Context, mode report.Mode, args []string) int { if err != nil { r.errf("resolve cwd: %v\n", err) - return 1 + return Outcome{}, 1 } reportRoot := workRoot @@ -52,7 +63,7 @@ func (r Runner) Run(ctx context.Context, mode report.Mode, args []string) int { if err != nil { r.errf("%v\n", err) - return 1 + return Outcome{}, 1 } outcome, err := Execute(ctx, Request{ @@ -66,7 +77,7 @@ func (r Runner) Run(ctx context.Context, mode report.Mode, args []string) int { if err != nil { r.errf("%v\n", err) - return 1 + return Outcome{}, 1 } renderer := report.Renderer{Root: reportRoot, Mode: mode} @@ -74,10 +85,10 @@ func (r Runner) Run(ctx context.Context, mode report.Mode, args []string) int { if err := renderer.Render(r.Stdout, inv.Output, outcome.Combined); err != nil { r.errf("render report: %v\n", err) - return 1 + return Outcome{}, 1 } - return outcome.ExitCode() + return outcome, outcome.ExitCode() } func (r Runner) errf(format string, args ...any) { diff --git a/packages/go/driver/internal/orchestrator/pipeline.go b/packages/go/driver/internal/orchestrator/pipeline.go index f410811..733696c 100644 --- a/packages/go/driver/internal/orchestrator/pipeline.go +++ b/packages/go/driver/internal/orchestrator/pipeline.go @@ -1,123 +1,72 @@ -// Package orchestrator drives the full fmtkit formatting pipeline (TS/Vue -// formatting, TS/Vue lint, Go formatting) with sectioned, colorized progress -// output: each step's tool output streams live, indented under its section -// header, and is followed by the condensed summary lines. +// Package orchestrator drives a sequence of typed pipeline steps, rendering +// sectioned, colorized progress: each step's tool output streams live, indented +// under its section header, followed by the condensed detail lines the step +// derives from its typed result. It owns only the section/tee/quiet-failure-dump +// mechanics; the concrete steps (and their detail computation) live with the +// composition root that builds them. package orchestrator import ( "bytes" "context" - "errors" "io" - "os/exec" - "strings" "go.ollin.sh/fmtkit/driver/internal/console" ) -// Tools carries the three pipeline steps. The TS steps return an error whose -// exec.ExitError code propagates; the Go step reports its exit code directly. -type Tools struct { - TS func(ctx context.Context, scopes []string, output io.Writer) error - Lint func(ctx context.Context, scopes []string, output io.Writer) error - Go func(ctx context.Context, args []string, output io.Writer) int +// Detail is one aligned label/value line shown under a step's section header. +type Detail struct { + Label string + Value string } -// Steps selects which parts of the pipeline run; the zero value (no -// selection flags) runs everything. -type Steps struct { - TS bool - Go bool +// Result is what a Step reports: the process exit code it wants (0 on success) +// and, on success, the detail lines to render under its section. +type Result struct { + ExitCode int + Details []Detail } -// Pipeline renders sectioned progress on Stderr while running the steps. +// Step is one unit of pipeline work. Label is the section header; Run writes the +// tool's live output to output (a tee of the live stream and, when a step needs +// it, its own capture) and returns the typed Result. +type Step interface { + Label() string + Run(ctx context.Context, output io.Writer) Result +} + +// Pipeline renders sectioned progress on Stderr while running the steps in +// order, short-circuiting on the first non-zero exit code. type Pipeline struct { - Tools Tools - Steps Steps + Steps []Step - // Quiet restores the entrypoint's summary-only output; tool logs then - // only appear when a step fails. + // Quiet restores the summary-only output; a step's live tool log then only + // appears when it fails. Quiet bool - Stderr io.Writer -} + // Printer renders every section, detail, and failure banner. The caller + // constructs it once (resolving color at the boundary) and shares it. + Printer *console.Printer -func (s Steps) normalized() Steps { - if !s.TS && !s.Go { - return Steps{TS: true, Go: true} - } - - return s + Stderr io.Writer } -// RunFormat runs TS/Vue lint (applying oxlint's safe fixes), TS/Vue formatting, -// and Go formatting against the given paths. Lint runs first so the formatting -// passes normalize whatever oxlint rewrites. -func (p Pipeline) RunFormat(ctx context.Context, paths []string) int { - if len(paths) == 0 { - paths = []string{"."} - } - - log := console.NewPrinter(p.Stderr, console.DetectColor(p.Stderr)) - - log.Section("Formatting target(s)") - log.Detail("paths", strings.Join(paths, " ")) - - selected := p.Steps.normalized() - - type step struct { - label string - summarize func(string, *console.Printer) - run func(ctx context.Context, output io.Writer) int - } - - var steps []step - - if selected.TS { - steps = append(steps, - step{ - label: "Running TS/Vue lint", - summarize: summarizeTSLint, - run: func(ctx context.Context, output io.Writer) int { - return exitCode(p.Tools.Lint(ctx, paths, output), output) - }, - }, - step{ - label: "Running TS/Vue formatting", - summarize: summarizeTSFormat, - run: func(ctx context.Context, output io.Writer) int { - return exitCode(p.Tools.TS(ctx, paths, output), output) - }, - }, - ) - } - - if selected.Go { - steps = append(steps, step{ - label: "Running Go formatting", - summarize: summarizeGoFormat, - run: func(ctx context.Context, output io.Writer) int { - return p.Tools.Go(ctx, append([]string{"format"}, paths...), output) - }, - }) - } - - for _, step := range steps { - if code := p.runStep(ctx, log, step.label, step.summarize, step.run); code != 0 { +// Run executes the steps in order, returning the first non-zero exit code or 0 +// when they all pass. +func (p Pipeline) Run(ctx context.Context) int { + for _, step := range p.Steps { + if code := p.runStep(ctx, step); code != 0 { return code } } - log.Section("Formatting complete") - log.SuccessDetail("status", "done") - return 0 } // runStep captures a step's combined output, streaming it live unless quiet, -// and prints either its summary details or (on failure) the captured log. -func (p Pipeline) runStep(ctx context.Context, log *console.Printer, label string, summarize func(string, *console.Printer), run func(context.Context, io.Writer) int) int { - log.Section(label) +// and prints either the step's detail lines or (on failure) the captured log. +func (p Pipeline) runStep(ctx context.Context, step Step) int { + p.Printer.Section(step.Label()) var captured bytes.Buffer @@ -126,47 +75,29 @@ func (p Pipeline) runStep(ctx context.Context, log *console.Printer, label strin var live io.WriteCloser if !p.Quiet { - live = log.Stream() + live = p.Printer.Stream() output = io.MultiWriter(&captured, live) } - code := run(ctx, output) + result := step.Run(ctx, output) if live != nil { _ = live.Close() } - if code != 0 { - log.Failure(label + " failed") + if result.ExitCode != 0 { + p.Printer.Failure(step.Label() + " failed") if p.Quiet { _, _ = io.Copy(p.Stderr, bytes.NewReader(captured.Bytes())) } - return code - } - - summarize(captured.String(), log) - - return 0 -} - -// exitCode maps a step error to its exit code. Failures that never produced -// tool output (a missing sidecar, an unreadable working tree) surface their -// message through the step's output writer so they are visible both live and -// in the failure dump. -func exitCode(err error, output io.Writer) int { - if err == nil { - return 0 + return result.ExitCode } - var exit *exec.ExitError - - if errors.As(err, &exit) { - return exit.ExitCode() + for _, detail := range result.Details { + p.Printer.Detail(detail.Label, detail.Value) } - _, _ = io.WriteString(output, err.Error()+"\n") - - return 1 + return 0 } diff --git a/packages/go/driver/internal/orchestrator/pipeline_test.go b/packages/go/driver/internal/orchestrator/pipeline_test.go index 724fb88..2c63bbe 100644 --- a/packages/go/driver/internal/orchestrator/pipeline_test.go +++ b/packages/go/driver/internal/orchestrator/pipeline_test.go @@ -3,7 +3,6 @@ package orchestrator import ( "bytes" "context" - "errors" "flag" "fmt" "io" @@ -15,24 +14,40 @@ import ( "go.ollin.sh/fmtkit/driver/internal/console" ) -type invocation struct { - tool string - args []string +var updateGolden = flag.Bool("update", false, "rewrite pipeline transcript golden files") + +// fakeStep is a scripted Step: it streams a canned tool log to output, appends +// an optional trailing message (a non-exec error the real steps surface through +// output), and returns a fixed Result. It mirrors the tool stubs the earlier +// func-triple fakes used, now expressed against the Step interface. +type fakeStep struct { + label string + output string + trailing string + details []Detail + code int + + log *[]string } -var updateGolden = flag.Bool("update", false, "rewrite pipeline transcript golden files") +func (s fakeStep) Label() string { return s.label } -// TestMain pins a color-free environment: CI task runners export FORCE_COLOR, -// which would inject ANSI codes into the captured output these tests assert. +func (s fakeStep) Run(_ context.Context, output io.Writer) Result { + if s.log != nil { + *s.log = append(*s.log, s.label) + } -// The stub outputs mirror infra/test-binary-smoke.sh so -// the Go orchestrator preserves the entrypoint's summary contract. + _, _ = io.WriteString(output, s.output) -func TestMain(m *testing.M) { - _ = os.Unsetenv("FORCE_COLOR") - _ = os.Setenv("NO_COLOR", "1") + if s.trailing != "" { + _, _ = io.WriteString(output, s.trailing) + } + + if s.code != 0 { + return Result{ExitCode: s.code} + } - os.Exit(m.Run()) + return Result{Details: s.details} } const ( @@ -50,68 +65,103 @@ const ( " Result: pass. 0 error(s).\n" ) -func stubTools(log *[]invocation, tsErr, lintErr error, goCode int) Tools { - return Tools{ - TS: func(_ context.Context, scopes []string, output io.Writer) error { - *log = append(*log, invocation{"ts", scopes}) +var ( + lintDetails = []Detail{{"oxlint", "Found 0 warnings and 0 errors."}} - _, _ = io.WriteString(output, stubTSOutput) + tsDetails = []Detail{ + {"blank-lines", "processed 3 file(s) in /work, 0 changed"}, + {"oxfmt", "Finished in 10ms on 3 files using 8 threads."}, + {"fluent", "processed 3 file(s) in /work, 1 changed"}, + } - return tsErr - }, - Lint: func(_ context.Context, scopes []string, output io.Writer) error { - *log = append(*log, invocation{"lint", scopes}) + goDetails = []Detail{ + {"fmtkit", "Formatted 2 file(s)."}, + {"result", "pass. 0 changed, 0 violation(s), 0 error(s)."}, + {"vet", "go vet ./... passed."}, + {"vet result", "pass. 0 error(s)."}, + } +) - _, _ = io.WriteString(output, stubLintOutput) +// runFormat frames the three scripted steps exactly as the app composition root +// does (target header, completion footer), so the transcript the goldens pin is +// reproduced end to end without importing the app package. +func runFormat(t *testing.T, stderr io.Writer, quiet bool, steps []Step) int { + t.Helper() - return lintErr - }, - Go: func(_ context.Context, args []string, output io.Writer) int { - *log = append(*log, invocation{"go", args}) + printer := console.NewPrinter(stderr, console.ColorNever) - _, _ = io.WriteString(output, stubGoOutput) + printer.Section("Formatting target(s)") + printer.Detail("paths", ".") - return goCode - }, + code := Pipeline{Steps: steps, Quiet: quiet, Printer: printer, Stderr: stderr}.Run(context.Background()) + + if code == 0 { + printer.Section("Formatting complete") + printer.SuccessDetail("status", "done") + } + + return code +} + +// successSteps are the three passing steps in pipeline order (lint, TS, Go). +func successSteps(log *[]string) []Step { + return []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails, log: log}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, details: tsDetails, log: log}, + fakeStep{label: "Running Go formatting", output: stubGoOutput, details: goDetails, log: log}, } } // TestRunFormatTranscriptGoldens pins the complete stderr transcript the // pipeline renders, byte for byte, across the success and failure paths in both -// streaming and quiet modes. Color is forced off by TestMain, so the golden -// files carry no ANSI escapes. These goldens characterize the current -// rendering so later refactor stages cannot silently change it; regenerate with +// streaming and quiet modes. Color is forced off, so the golden files carry no +// ANSI escapes. These goldens characterize the rendering so refactors cannot +// silently change it; regenerate with // `go test ./driver/internal/orchestrator -run TestRunFormatTranscriptGoldens -update`. func TestRunFormatTranscriptGoldens(t *testing.T) { cases := []struct { name string quiet bool - tsErr error - goCode int + steps []Step golden string }{ - {"success", false, nil, 0, "transcript_success.txt"}, - {"success_quiet", true, nil, 0, "transcript_success_quiet.txt"}, - {"go_failure", false, nil, 3, "transcript_go_failure.txt"}, - {"go_failure_quiet", true, nil, 3, "transcript_go_failure_quiet.txt"}, - {"ts_failure", false, errors.New("sidecar exploded"), 0, "transcript_ts_failure.txt"}, + {"success", false, successSteps(nil), "transcript_success.txt"}, + {"success_quiet", true, successSteps(nil), "transcript_success_quiet.txt"}, + { + "go_failure", false, + []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, details: tsDetails}, + fakeStep{label: "Running Go formatting", output: stubGoOutput, code: 3}, + }, + "transcript_go_failure.txt", + }, + { + "go_failure_quiet", true, + []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, details: tsDetails}, + fakeStep{label: "Running Go formatting", output: stubGoOutput, code: 3}, + }, + "transcript_go_failure_quiet.txt", + }, + { + "ts_failure", false, + []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, trailing: "sidecar exploded\n", code: 1}, + }, + "transcript_ts_failure.txt", + }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { - var log []invocation - var stderr bytes.Buffer - pipeline := Pipeline{ - Tools: stubTools(&log, tc.tsErr, nil, tc.goCode), - Quiet: tc.quiet, - Stderr: &stderr, - } - - pipeline.RunFormat(context.Background(), []string{"."}) + runFormat(t, &stderr, tc.quiet, tc.steps) path := filepath.Join("testdata", tc.golden) @@ -136,28 +186,19 @@ func TestRunFormatTranscriptGoldens(t *testing.T) { } } -func TestRunFormatRunsStepsInOrder(t *testing.T) { - var log []invocation +func TestRunRunsStepsInOrder(t *testing.T) { + var log []string var stderr bytes.Buffer - pipeline := Pipeline{ - Tools: stubTools(&log, nil, nil, 0), - Stderr: &stderr, - } - - if code := pipeline.RunFormat(context.Background(), []string{"."}); code != 0 { - t.Fatalf("RunFormat = %d, want 0\n%s", code, stderr.String()) + if code := runFormat(t, &stderr, false, successSteps(&log)); code != 0 { + t.Fatalf("Run = %d, want 0\n%s", code, stderr.String()) } - want := []invocation{ - {"lint", []string{"."}}, - {"ts", []string{"."}}, - {"go", []string{"format", "."}}, - } + want := []string{"Running TS/Vue lint", "Running TS/Vue formatting", "Running Go formatting"} if fmt.Sprint(log) != fmt.Sprint(want) { - t.Fatalf("invocations = %v, want %v", log, want) + t.Fatalf("step order = %v, want %v", log, want) } for _, needle := range []string{ @@ -184,40 +225,25 @@ func TestRunFormatRunsStepsInOrder(t *testing.T) { } } -func TestRunFormatStreamsToolOutputLive(t *testing.T) { - var log []invocation - +func TestRunStreamsToolOutputLive(t *testing.T) { var stderr bytes.Buffer - pipeline := Pipeline{ - Tools: stubTools(&log, nil, nil, 0), - Stderr: &stderr, - } - - if code := pipeline.RunFormat(context.Background(), nil); code != 0 { - t.Fatalf("RunFormat = %d, want 0", code) + if code := runFormat(t, &stderr, false, successSteps(nil)); code != 0 { + t.Fatalf("Run = %d, want 0", code) } // The raw tool line appears indented (live stream) in addition to the - // condensed summary line. + // condensed detail line. if !strings.Contains(stderr.String(), " [blank-lines] processed 3 file(s) in /work, 0 changed") { t.Fatalf("stderr missing live-streamed tool output:\n%s", stderr.String()) } } -func TestRunFormatQuietHidesToolOutput(t *testing.T) { - var log []invocation - +func TestRunQuietHidesToolOutput(t *testing.T) { var stderr bytes.Buffer - pipeline := Pipeline{ - Tools: stubTools(&log, nil, nil, 0), - Quiet: true, - Stderr: &stderr, - } - - if code := pipeline.RunFormat(context.Background(), nil); code != 0 { - t.Fatalf("RunFormat = %d, want 0", code) + if code := runFormat(t, &stderr, true, successSteps(nil)); code != 0 { + t.Fatalf("Run = %d, want 0", code) } if strings.Contains(stderr.String(), " [blank-lines]") { @@ -225,26 +251,29 @@ func TestRunFormatQuietHidesToolOutput(t *testing.T) { } if !strings.Contains(stderr.String(), "blank-lines processed 3 file(s) in /work, 0 changed") { - t.Fatalf("quiet mode lost summary:\n%s", stderr.String()) + t.Fatalf("quiet mode lost detail:\n%s", stderr.String()) } } -func TestRunFormatShortCircuitsOnTSFailure(t *testing.T) { - var log []invocation +func TestRunShortCircuitsOnFailure(t *testing.T) { + var log []string var stderr bytes.Buffer - pipeline := Pipeline{ - Tools: stubTools(&log, errors.New("sidecar exploded"), nil, 0), - Stderr: &stderr, + steps := []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails, log: &log}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, trailing: "sidecar exploded\n", code: 1, log: &log}, + fakeStep{label: "Running Go formatting", output: stubGoOutput, details: goDetails, log: &log}, } - if code := pipeline.RunFormat(context.Background(), nil); code != 1 { - t.Fatalf("RunFormat = %d, want 1", code) + if code := runFormat(t, &stderr, false, steps); code != 1 { + t.Fatalf("Run = %d, want 1", code) } - if len(log) != 2 || log[0].tool != "lint" || log[1].tool != "ts" { - t.Fatalf("invocations = %v, want lint then ts (Go short-circuited)", log) + want := []string{"Running TS/Vue lint", "Running TS/Vue formatting"} + + if fmt.Sprint(log) != fmt.Sprint(want) { + t.Fatalf("step order = %v, want %v (Go should have been skipped)", log, want) } if !strings.Contains(stderr.String(), "!! Running TS/Vue formatting failed") { @@ -256,54 +285,18 @@ func TestRunFormatShortCircuitsOnTSFailure(t *testing.T) { } } -func TestRunFormatQuietDumpsLogOnFailure(t *testing.T) { - var log []invocation - +func TestRunQuietDumpsLogOnFailure(t *testing.T) { var stderr bytes.Buffer - pipeline := Pipeline{ - Tools: stubTools(&log, nil, nil, 3), - Quiet: true, - Stderr: &stderr, + steps := []Step{ + fakeStep{label: "Running Go formatting", output: stubGoOutput, code: 3}, } - if code := pipeline.RunFormat(context.Background(), nil); code != 3 { - t.Fatalf("RunFormat = %d, want 3", code) + if code := runFormat(t, &stderr, true, steps); code != 3 { + t.Fatalf("Run = %d, want 3", code) } if !strings.Contains(stderr.String(), "Formatted 2 file(s).") { t.Fatalf("quiet failure did not dump captured log:\n%s", stderr.String()) } } - -func TestSummarizeTSLintFallbacks(t *testing.T) { - var out bytes.Buffer - - log := console.NewPrinter(&out, console.ColorNever) - - summarizeTSLint("[lint] no TS/Vue files to lint.\n", log) - - if !strings.Contains(out.String(), "oxlint no TS/Vue files to lint.") { - t.Fatalf("missing skip summary: %q", out.String()) - } - - out.Reset() - - summarizeTSLint("nothing interesting\n", log) - - if !strings.Contains(out.String(), "oxlint no issues found") { - t.Fatalf("missing fallback summary: %q", out.String()) - } -} - -func TestSummarizeTSFormatCountsMissing(t *testing.T) { - var out bytes.Buffer - - log := console.NewPrinter(&out, console.ColorNever) - - summarizeTSFormat("[sources] path not found, skipping: /work/a\n[sources] path not found, skipping: /work/b\n", log) - - if !strings.Contains(out.String(), "skipped 2 missing tracked file(s)") { - t.Fatalf("missing skipped summary: %q", out.String()) - } -} diff --git a/packages/go/driver/internal/orchestrator/summarize.go b/packages/go/driver/internal/orchestrator/summarize.go deleted file mode 100644 index d295f66..0000000 --- a/packages/go/driver/internal/orchestrator/summarize.go +++ /dev/null @@ -1,123 +0,0 @@ -package orchestrator - -import ( - "fmt" - "regexp" - "strings" - - "go.ollin.sh/fmtkit/driver/internal/console" - "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 the TS sidecar emits are parsed -// by sidecarproto; the Go-report scraping below stays here (G6 retires it). - -var ( - 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:" - lintNothingToLintLine = "[lint] no TS/Vue files to lint." - goResultPrefix = " Result: " -) - -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 -} - -func summarizeTSFormat(log string, l *console.Printer) { - summary := sidecarproto.ParsePipelineSummary(log) - missing := 0 - - for _, line := range lines(log) { - if strings.HasPrefix(line, sourcesMissingPrefix) { - missing++ - } - } - - if summary.BlankLines != "" { - l.Detail("blank-lines", summary.BlankLines) - } - - if missing > 0 { - l.Detail("skipped", fmt.Sprintf("%d missing tracked file(s)", missing)) - } - - if summary.Oxfmt != "" { - l.Detail("oxfmt", summary.Oxfmt) - } - - if summary.FluentChains != "" { - l.Detail("fluent", summary.FluentChains) - } - - if summary.ValidateSyntax != "" { - l.Detail("validated", summary.ValidateSyntax) - } -} - -func summarizeTSLint(log string, l *console.Printer) { - if lastWithPrefix(lines(log), lintNothingToLintLine) != "" { - l.Detail("oxlint", strings.TrimPrefix(lintNothingToLintLine, "[lint] ")) - - return - } - - if result := sidecarproto.ParseLintSummary(log).Result; result != "" { - l.Detail("oxlint", result) - - return - } - - l.Detail("oxlint", "no issues found") -} - -func summarizeGoFormat(log string, l *console.Printer) { - var fileSummary, formatterResult, vetSummary, vetResult string - - for _, line := range lines(log) { - if fileSummary == "" && goFileSummaryPattern.MatchString(line) { - fileSummary = strings.TrimPrefix(line, " ") - } - - if vetSummary == "" && goVetSummaryPattern.MatchString(line) { - vetSummary = strings.TrimPrefix(line, " ") - } - - if strings.HasPrefix(line, goResultPrefix) { - if formatterResult == "" { - formatterResult = strings.TrimPrefix(line, goResultPrefix) - } - - vetResult = strings.TrimPrefix(line, goResultPrefix) - } - } - - if fileSummary != "" { - l.Detail("fmtkit", fileSummary) - } - - if formatterResult != "" { - l.Detail("result", formatterResult) - } - - if vetSummary != "" { - l.Detail("vet", vetSummary) - } - - if vetResult != "" && vetResult != formatterResult { - l.Detail("vet result", vetResult) - } -} From 25ed1dd557b0083acdccf95545d2be831a2acd39 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 24 Jul 2026 11:39:15 +0800 Subject: [PATCH 3/4] refactor(pipeline): rename the orchestrator package to pipeline Pure rename now that the package is the generic step runner rather than the format-specific orchestrator: git mv the directory (carrying the transcript goldens unchanged) and rename the package identifier and its importers in app. No behavior change. --- packages/go/driver/internal/app/format.go | 8 +-- packages/go/driver/internal/app/steps.go | 58 +++++++++---------- packages/go/driver/internal/app/steps_test.go | 8 +-- .../{orchestrator => pipeline}/pipeline.go | 4 +- .../pipeline_test.go | 4 +- .../testdata/transcript_go_failure.txt | 0 .../testdata/transcript_go_failure_quiet.txt | 0 .../testdata/transcript_success.txt | 0 .../testdata/transcript_success_quiet.txt | 0 .../testdata/transcript_ts_failure.txt | 0 10 files changed, 41 insertions(+), 41 deletions(-) rename packages/go/driver/internal/{orchestrator => pipeline}/pipeline.go (96%) rename packages/go/driver/internal/{orchestrator => pipeline}/pipeline_test.go (98%) rename packages/go/driver/internal/{orchestrator => pipeline}/testdata/transcript_go_failure.txt (100%) rename packages/go/driver/internal/{orchestrator => pipeline}/testdata/transcript_go_failure_quiet.txt (100%) rename packages/go/driver/internal/{orchestrator => pipeline}/testdata/transcript_success.txt (100%) rename packages/go/driver/internal/{orchestrator => pipeline}/testdata/transcript_success_quiet.txt (100%) rename packages/go/driver/internal/{orchestrator => pipeline}/testdata/transcript_ts_failure.txt (100%) diff --git a/packages/go/driver/internal/app/format.go b/packages/go/driver/internal/app/format.go index 69e90ef..83aaf66 100644 --- a/packages/go/driver/internal/app/format.go +++ b/packages/go/driver/internal/app/format.go @@ -7,7 +7,7 @@ import ( "go.ollin.sh/fmtkit/driver/internal/console" "go.ollin.sh/fmtkit/driver/internal/gitfiles" - "go.ollin.sh/fmtkit/driver/internal/orchestrator" + "go.ollin.sh/fmtkit/driver/internal/pipeline" ) // runFormat formats what diverges from HEAD — modified files, staged or not, @@ -48,7 +48,7 @@ func (d *deps) runFormatAll(ctx context.Context, args []string) int { // runPipeline frames the format run (target header, completion footer) around // the typed steps it builds for the selection, handing them to the generic -// orchestrator. Color is resolved once here, at the composition root. +// pipeline. Color is resolved once here, at the composition root. func (d *deps) runPipeline(ctx context.Context, paths []string, opts formatOptions, selection gitfiles.Selection) int { if len(paths) == 0 { paths = []string{"."} @@ -59,14 +59,14 @@ func (d *deps) runPipeline(ctx context.Context, paths []string, opts formatOptio printer.Section("Formatting target(s)") printer.Detail("paths", strings.Join(paths, " ")) - pipeline := orchestrator.Pipeline{ + pipe := pipeline.Pipeline{ Steps: d.formatSteps(paths, opts.steps, selection), Quiet: opts.quiet, Printer: printer, Stderr: d.stderr, } - if code := pipeline.Run(ctx); code != 0 { + if code := pipe.Run(ctx); code != 0 { return code } diff --git a/packages/go/driver/internal/app/steps.go b/packages/go/driver/internal/app/steps.go index 2906a86..117db02 100644 --- a/packages/go/driver/internal/app/steps.go +++ b/packages/go/driver/internal/app/steps.go @@ -11,7 +11,7 @@ import ( "go.ollin.sh/fmtkit/driver/internal/gitfiles" "go.ollin.sh/fmtkit/driver/internal/gotool" - "go.ollin.sh/fmtkit/driver/internal/orchestrator" + "go.ollin.sh/fmtkit/driver/internal/pipeline" "go.ollin.sh/fmtkit/driver/internal/sidecarproto" "go.ollin.sh/fmtkit/driver/internal/tsruntime" report "go.ollin.sh/fmtkit/driver/report" @@ -36,10 +36,10 @@ func (s stepSelection) normalized() stepSelection { // formatSteps builds the ordered pipeline steps for the selection. Lint runs // first so the formatting passes normalize whatever oxlint rewrites. -func (d *deps) formatSteps(paths []string, selected stepSelection, selection gitfiles.Selection) []orchestrator.Step { +func (d *deps) formatSteps(paths []string, selected stepSelection, selection gitfiles.Selection) []pipeline.Step { selected = selected.normalized() - var steps []orchestrator.Step + var steps []pipeline.Step if selected.TS { steps = append(steps, @@ -72,7 +72,7 @@ type tsLintStep struct { func (s tsLintStep) Label() string { return "Running TS/Vue lint" } -func (s tsLintStep) Run(ctx context.Context, output io.Writer) orchestrator.Result { +func (s tsLintStep) Run(ctx context.Context, output io.Writer) pipeline.Result { var captured bytes.Buffer err := invokeTS(s.version, io.MultiWriter(output, &captured), func(invoker tsruntime.Invoker, w io.Writer) error { @@ -80,10 +80,10 @@ func (s tsLintStep) Run(ctx context.Context, output io.Writer) orchestrator.Resu }) if code := tsExitCode(err, output); code != 0 { - return orchestrator.Result{ExitCode: code} + return pipeline.Result{ExitCode: code} } - return orchestrator.Result{Details: tsLintDetails(captured.String())} + return pipeline.Result{Details: tsLintDetails(captured.String())} } // tsFormatStep runs the full TS/Vue formatting pipeline (oxfmt plus the project @@ -96,7 +96,7 @@ type tsFormatStep struct { func (s tsFormatStep) Label() string { return "Running TS/Vue formatting" } -func (s tsFormatStep) Run(ctx context.Context, output io.Writer) orchestrator.Result { +func (s tsFormatStep) Run(ctx context.Context, output io.Writer) pipeline.Result { var captured bytes.Buffer err := invokeTS(s.version, io.MultiWriter(output, &captured), func(invoker tsruntime.Invoker, w io.Writer) error { @@ -104,10 +104,10 @@ func (s tsFormatStep) Run(ctx context.Context, output io.Writer) orchestrator.Re }) if code := tsExitCode(err, output); code != 0 { - return orchestrator.Result{ExitCode: code} + return pipeline.Result{ExitCode: code} } - return orchestrator.Result{Details: tsFormatDetails(captured.String())} + return pipeline.Result{Details: tsFormatDetails(captured.String())} } // goFormatStep formats Go files and runs go vet, deriving its details from the @@ -119,16 +119,16 @@ type goFormatStep struct { func (s goFormatStep) Label() string { return "Running Go formatting" } -func (s goFormatStep) Run(ctx context.Context, output io.Writer) orchestrator.Result { +func (s goFormatStep) Run(ctx context.Context, output io.Writer) pipeline.Result { outcome, code := gotool. Runner{Stdout: output, Stderr: output, Scope: s.selection}. RunReport(ctx, report.ModeFormat, s.paths) if code != 0 { - return orchestrator.Result{ExitCode: code} + return pipeline.Result{ExitCode: code} } - return orchestrator.Result{Details: goFormatDetails(outcome)} + return pipeline.Result{Details: goFormatDetails(outcome)} } // invokeTS resolves the TS toolchain and invokes it through spawn, which @@ -165,29 +165,29 @@ func tsExitCode(err error, output io.Writer) int { // tsLintDetails derives the oxlint summary line. A driver "no files" notice // wins; otherwise oxlint's own result line; otherwise a clean fallback. -func tsLintDetails(log string) []orchestrator.Detail { +func tsLintDetails(log string) []pipeline.Detail { for _, line := range strings.Split(log, "\n") { if strings.HasPrefix(line, lintNothingToLintLine) { - return []orchestrator.Detail{{Label: "oxlint", Value: strings.TrimPrefix(lintNothingToLintLine, "[lint] ")}} + return []pipeline.Detail{{Label: "oxlint", Value: strings.TrimPrefix(lintNothingToLintLine, "[lint] ")}} } } if result := sidecarproto.ParseLintSummary(log).Result; result != "" { - return []orchestrator.Detail{{Label: "oxlint", Value: result}} + return []pipeline.Detail{{Label: "oxlint", Value: result}} } - return []orchestrator.Detail{{Label: "oxlint", Value: "no issues found"}} + return []pipeline.Detail{{Label: "oxlint", Value: "no issues found"}} } // tsFormatDetails derives the TS pipeline's detail lines from the sidecar's // progress output plus the driver's missing-source notices. -func tsFormatDetails(log string) []orchestrator.Detail { +func tsFormatDetails(log string) []pipeline.Detail { summary := sidecarproto.ParsePipelineSummary(log) - var details []orchestrator.Detail + var details []pipeline.Detail if summary.BlankLines != "" { - details = append(details, orchestrator.Detail{Label: "blank-lines", Value: summary.BlankLines}) + details = append(details, pipeline.Detail{Label: "blank-lines", Value: summary.BlankLines}) } missing := 0 @@ -199,19 +199,19 @@ func tsFormatDetails(log string) []orchestrator.Detail { } if missing > 0 { - details = append(details, orchestrator.Detail{Label: "skipped", Value: fmt.Sprintf("%d missing tracked file(s)", missing)}) + details = append(details, pipeline.Detail{Label: "skipped", Value: fmt.Sprintf("%d missing tracked file(s)", missing)}) } if summary.Oxfmt != "" { - details = append(details, orchestrator.Detail{Label: "oxfmt", Value: summary.Oxfmt}) + details = append(details, pipeline.Detail{Label: "oxfmt", Value: summary.Oxfmt}) } if summary.FluentChains != "" { - details = append(details, orchestrator.Detail{Label: "fluent", Value: summary.FluentChains}) + details = append(details, pipeline.Detail{Label: "fluent", Value: summary.FluentChains}) } if summary.ValidateSyntax != "" { - details = append(details, orchestrator.Detail{Label: "validated", Value: summary.ValidateSyntax}) + details = append(details, pipeline.Detail{Label: "validated", Value: summary.ValidateSyntax}) } return details @@ -220,14 +220,14 @@ func tsFormatDetails(log string) []orchestrator.Detail { // goFormatDetails computes the Go step's detail lines from the typed outcome, // reproducing the exact strings the text report renders (which the pipeline // previously scraped back out of that rendered text). -func goFormatDetails(outcome gotool.Outcome) []orchestrator.Detail { +func goFormatDetails(outcome gotool.Outcome) []pipeline.Detail { fm := outcome.Combined.Formatter vt := outcome.Combined.Vet - var details []orchestrator.Detail + var details []pipeline.Detail if summary := goFileSummary(fm, outcome.Mode); summary != "" { - details = append(details, orchestrator.Detail{Label: "fmtkit", Value: summary}) + details = append(details, pipeline.Detail{Label: "fmtkit", Value: summary}) } // The formatter renders a Result line unless it found no files and hit no @@ -248,14 +248,14 @@ func goFormatDetails(outcome gotool.Outcome) []orchestrator.Detail { resultLine = vetResult } - details = append(details, orchestrator.Detail{Label: "result", Value: resultLine}) + details = append(details, pipeline.Detail{Label: "result", Value: resultLine}) if summary := goVetSummary(vt); summary != "" { - details = append(details, orchestrator.Detail{Label: "vet", Value: summary}) + details = append(details, pipeline.Detail{Label: "vet", Value: summary}) } if vetResult != resultLine { - details = append(details, orchestrator.Detail{Label: "vet result", Value: vetResult}) + details = append(details, pipeline.Detail{Label: "vet result", Value: vetResult}) } return details diff --git a/packages/go/driver/internal/app/steps_test.go b/packages/go/driver/internal/app/steps_test.go index dc28770..cff53f8 100644 --- a/packages/go/driver/internal/app/steps_test.go +++ b/packages/go/driver/internal/app/steps_test.go @@ -7,13 +7,13 @@ import ( "testing" "go.ollin.sh/fmtkit/driver/internal/gotool" - "go.ollin.sh/fmtkit/driver/internal/orchestrator" + "go.ollin.sh/fmtkit/driver/internal/pipeline" report "go.ollin.sh/fmtkit/driver/report" formatterengine "go.ollin.sh/fmtkit/formatter/engine" "go.ollin.sh/fmtkit/vet" ) -func detailStrings(details []orchestrator.Detail) []string { +func detailStrings(details []pipeline.Detail) []string { out := make([]string, 0, len(details)) for _, d := range details { @@ -23,7 +23,7 @@ func detailStrings(details []orchestrator.Detail) []string { return out } -func assertDetails(t *testing.T, got []orchestrator.Detail, want ...string) { +func assertDetails(t *testing.T, got []pipeline.Detail, want ...string) { t.Helper() if g := fmt.Sprint(detailStrings(got)); g != fmt.Sprint(want) { @@ -205,7 +205,7 @@ func TestStepSelectionNormalized(t *testing.T) { func TestFormatStepsSelection(t *testing.T) { d := &deps{version: "dev"} - labels := func(steps []orchestrator.Step) []string { + labels := func(steps []pipeline.Step) []string { out := make([]string, 0, len(steps)) for _, s := range steps { diff --git a/packages/go/driver/internal/orchestrator/pipeline.go b/packages/go/driver/internal/pipeline/pipeline.go similarity index 96% rename from packages/go/driver/internal/orchestrator/pipeline.go rename to packages/go/driver/internal/pipeline/pipeline.go index 733696c..cbf6b6b 100644 --- a/packages/go/driver/internal/orchestrator/pipeline.go +++ b/packages/go/driver/internal/pipeline/pipeline.go @@ -1,10 +1,10 @@ -// Package orchestrator drives a sequence of typed pipeline steps, rendering +// Package pipeline drives a sequence of typed pipeline steps, rendering // sectioned, colorized progress: each step's tool output streams live, indented // under its section header, followed by the condensed detail lines the step // derives from its typed result. It owns only the section/tee/quiet-failure-dump // mechanics; the concrete steps (and their detail computation) live with the // composition root that builds them. -package orchestrator +package pipeline import ( "bytes" diff --git a/packages/go/driver/internal/orchestrator/pipeline_test.go b/packages/go/driver/internal/pipeline/pipeline_test.go similarity index 98% rename from packages/go/driver/internal/orchestrator/pipeline_test.go rename to packages/go/driver/internal/pipeline/pipeline_test.go index 2c63bbe..d6a09d6 100644 --- a/packages/go/driver/internal/orchestrator/pipeline_test.go +++ b/packages/go/driver/internal/pipeline/pipeline_test.go @@ -1,4 +1,4 @@ -package orchestrator +package pipeline import ( "bytes" @@ -117,7 +117,7 @@ func successSteps(log *[]string) []Step { // streaming and quiet modes. Color is forced off, so the golden files carry no // ANSI escapes. These goldens characterize the rendering so refactors cannot // silently change it; regenerate with -// `go test ./driver/internal/orchestrator -run TestRunFormatTranscriptGoldens -update`. +// `go test ./driver/internal/pipeline -run TestRunFormatTranscriptGoldens -update`. func TestRunFormatTranscriptGoldens(t *testing.T) { cases := []struct { name string diff --git a/packages/go/driver/internal/orchestrator/testdata/transcript_go_failure.txt b/packages/go/driver/internal/pipeline/testdata/transcript_go_failure.txt similarity index 100% rename from packages/go/driver/internal/orchestrator/testdata/transcript_go_failure.txt rename to packages/go/driver/internal/pipeline/testdata/transcript_go_failure.txt diff --git a/packages/go/driver/internal/orchestrator/testdata/transcript_go_failure_quiet.txt b/packages/go/driver/internal/pipeline/testdata/transcript_go_failure_quiet.txt similarity index 100% rename from packages/go/driver/internal/orchestrator/testdata/transcript_go_failure_quiet.txt rename to packages/go/driver/internal/pipeline/testdata/transcript_go_failure_quiet.txt diff --git a/packages/go/driver/internal/orchestrator/testdata/transcript_success.txt b/packages/go/driver/internal/pipeline/testdata/transcript_success.txt similarity index 100% rename from packages/go/driver/internal/orchestrator/testdata/transcript_success.txt rename to packages/go/driver/internal/pipeline/testdata/transcript_success.txt diff --git a/packages/go/driver/internal/orchestrator/testdata/transcript_success_quiet.txt b/packages/go/driver/internal/pipeline/testdata/transcript_success_quiet.txt similarity index 100% rename from packages/go/driver/internal/orchestrator/testdata/transcript_success_quiet.txt rename to packages/go/driver/internal/pipeline/testdata/transcript_success_quiet.txt diff --git a/packages/go/driver/internal/orchestrator/testdata/transcript_ts_failure.txt b/packages/go/driver/internal/pipeline/testdata/transcript_ts_failure.txt similarity index 100% rename from packages/go/driver/internal/orchestrator/testdata/transcript_ts_failure.txt rename to packages/go/driver/internal/pipeline/testdata/transcript_ts_failure.txt From b8be11d6727f10fbd75d947c4dc356b9d40569b1 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 24 Jul 2026 11:42:30 +0800 Subject: [PATCH 4/4] style: apply fmtkit self-formatting and fix stale rename references Running the real pipeline over the tree (make format-all) reorders the new files to fmtkit's canonical form: type declarations hoisted to the top of the file, blank lines before statements following assignments. Pure reordering, no behavior change. Also updates two sidecarproto doc comments that still named the old orchestrator package to point at the pipeline steps that now own the Go-report bookkeeping. --- packages/go/driver/internal/app/steps.go | 44 +++++++++---------- packages/go/driver/internal/app/steps_test.go | 3 ++ .../go/driver/internal/console/printer.go | 36 +++++++-------- .../driver/internal/pipeline/pipeline_test.go | 4 +- .../driver/internal/sidecarproto/summary.go | 2 +- .../internal/sidecarproto/summary_test.go | 2 +- 6 files changed, 47 insertions(+), 44 deletions(-) diff --git a/packages/go/driver/internal/app/steps.go b/packages/go/driver/internal/app/steps.go index 117db02..e8bb8fe 100644 --- a/packages/go/driver/internal/app/steps.go +++ b/packages/go/driver/internal/app/steps.go @@ -26,6 +26,28 @@ type stepSelection struct { Go bool } +// tsLintStep lints TS/Vue files, applying oxlint's safe fixes (--fix). +type tsLintStep struct { + version string + paths []string + selection gitfiles.Selection +} + +// tsFormatStep runs the full TS/Vue formatting pipeline (oxfmt plus the project +// passes). +type tsFormatStep struct { + version string + paths []string + selection gitfiles.Selection +} + +// goFormatStep formats Go files and runs go vet, deriving its details from the +// typed outcome rather than the rendered report text. +type goFormatStep struct { + paths []string + selection gitfiles.Selection +} + func (s stepSelection) normalized() stepSelection { if !s.TS && !s.Go { return stepSelection{TS: true, Go: true} @@ -63,13 +85,6 @@ const ( lintNothingToLintLine = "[lint] no TS/Vue files to lint." ) -// tsLintStep lints TS/Vue files, applying oxlint's safe fixes (--fix). -type tsLintStep struct { - version string - paths []string - selection gitfiles.Selection -} - func (s tsLintStep) Label() string { return "Running TS/Vue lint" } func (s tsLintStep) Run(ctx context.Context, output io.Writer) pipeline.Result { @@ -86,14 +101,6 @@ func (s tsLintStep) Run(ctx context.Context, output io.Writer) pipeline.Result { return pipeline.Result{Details: tsLintDetails(captured.String())} } -// tsFormatStep runs the full TS/Vue formatting pipeline (oxfmt plus the project -// passes). -type tsFormatStep struct { - version string - paths []string - selection gitfiles.Selection -} - func (s tsFormatStep) Label() string { return "Running TS/Vue formatting" } func (s tsFormatStep) Run(ctx context.Context, output io.Writer) pipeline.Result { @@ -110,13 +117,6 @@ func (s tsFormatStep) Run(ctx context.Context, output io.Writer) pipeline.Result return pipeline.Result{Details: tsFormatDetails(captured.String())} } -// goFormatStep formats Go files and runs go vet, deriving its details from the -// typed outcome rather than the rendered report text. -type goFormatStep struct { - paths []string - selection gitfiles.Selection -} - func (s goFormatStep) Label() string { return "Running Go formatting" } func (s goFormatStep) Run(ctx context.Context, output io.Writer) pipeline.Result { diff --git a/packages/go/driver/internal/app/steps_test.go b/packages/go/driver/internal/app/steps_test.go index cff53f8..c9d5725 100644 --- a/packages/go/driver/internal/app/steps_test.go +++ b/packages/go/driver/internal/app/steps_test.go @@ -216,16 +216,19 @@ func TestFormatStepsSelection(t *testing.T) { } all := labels(d.formatSteps([]string{"."}, stepSelection{}, 0)) + if fmt.Sprint(all) != fmt.Sprint([]string{"Running TS/Vue lint", "Running TS/Vue formatting", "Running Go formatting"}) { t.Fatalf("default steps = %v", all) } tsOnly := labels(d.formatSteps([]string{"."}, stepSelection{TS: true}, 0)) + if fmt.Sprint(tsOnly) != fmt.Sprint([]string{"Running TS/Vue lint", "Running TS/Vue formatting"}) { t.Fatalf("--ts steps = %v", tsOnly) } goOnly := labels(d.formatSteps([]string{"."}, stepSelection{Go: true}, 0)) + if fmt.Sprint(goOnly) != fmt.Sprint([]string{"Running Go formatting"}) { t.Fatalf("--go steps = %v", goOnly) } diff --git a/packages/go/driver/internal/console/printer.go b/packages/go/driver/internal/console/printer.go index cd1de50..117da12 100644 --- a/packages/go/driver/internal/console/printer.go +++ b/packages/go/driver/internal/console/printer.go @@ -17,6 +17,24 @@ import ( // ColorMode is whether a Printer emits ANSI escape sequences. type ColorMode int +// Printer renders progress output to a writer. The palette fields are empty +// strings when color is off, so the same format strings render plain text. +type Printer struct { + w io.Writer + + bold string + dim string + cyan string + green string + red string + reset string +} + +type indentWriter struct { + printer *Printer + partial strings.Builder +} + const ( // ColorAuto defers the decision to DetectColor. NewPrinter treats it as // no-color, so callers resolve it through DetectColor before constructing a @@ -50,19 +68,6 @@ func DetectColor(w io.Writer) ColorMode { return ColorNever } -// Printer renders progress output to a writer. The palette fields are empty -// strings when color is off, so the same format strings render plain text. -type Printer struct { - w io.Writer - - bold string - dim string - cyan string - green string - red string - reset string -} - // NewPrinter builds a Printer writing to w. ANSI color is enabled only for // ColorAlways; ColorAuto and ColorNever both render plain text, so callers pass // the resolved result of DetectColor. @@ -108,11 +113,6 @@ func (p *Printer) Stream() io.WriteCloser { return &indentWriter{printer: p} } -type indentWriter struct { - printer *Printer - partial strings.Builder -} - func (w *indentWriter) Write(p []byte) (int, error) { for _, b := range p { if b != '\n' { diff --git a/packages/go/driver/internal/pipeline/pipeline_test.go b/packages/go/driver/internal/pipeline/pipeline_test.go index d6a09d6..c4d682c 100644 --- a/packages/go/driver/internal/pipeline/pipeline_test.go +++ b/packages/go/driver/internal/pipeline/pipeline_test.go @@ -14,8 +14,6 @@ import ( "go.ollin.sh/fmtkit/driver/internal/console" ) -var updateGolden = flag.Bool("update", false, "rewrite pipeline transcript golden files") - // fakeStep is a scripted Step: it streams a canned tool log to output, appends // an optional trailing message (a non-exec error the real steps surface through // output), and returns a fixed Result. It mirrors the tool stubs the earlier @@ -30,6 +28,8 @@ type fakeStep struct { log *[]string } +var updateGolden = flag.Bool("update", false, "rewrite pipeline transcript golden files") + func (s fakeStep) Label() string { return s.label } func (s fakeStep) Run(_ context.Context, output io.Writer) Result { diff --git a/packages/go/driver/internal/sidecarproto/summary.go b/packages/go/driver/internal/sidecarproto/summary.go index 19c3219..25dc007 100644 --- a/packages/go/driver/internal/sidecarproto/summary.go +++ b/packages/go/driver/internal/sidecarproto/summary.go @@ -37,7 +37,7 @@ type LintSummary struct { // 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. +// no-files notice, the Go formatter report) stay with the pipeline steps. const ( blankLinesMatch = "[blank-lines] processed " blankLinesTrim = "[blank-lines] " diff --git a/packages/go/driver/internal/sidecarproto/summary_test.go b/packages/go/driver/internal/sidecarproto/summary_test.go index 752a92e..7b2db58 100644 --- a/packages/go/driver/internal/sidecarproto/summary_test.go +++ b/packages/go/driver/internal/sidecarproto/summary_test.go @@ -3,7 +3,7 @@ package sidecarproto import "testing" // sampleTSOutput mirrors the sidecar's pipeline stdout, lifted from the -// orchestrator's fake-tool fixtures. +// pipeline'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" +