Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions cmd/terrain/cmd_convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,11 +399,12 @@ func runListConversions(jsonOutput bool) error {
direction.From,
direction.To,
strings.Join(direction.Shorthands, ", "),
humanizeGoNativeState(direction.GoNativeState),
tierLabelForState(direction.GoNativeState),
)
}
fmt.Println()
}
fmt.Println("Tiers: Stable = conversion-corpus calibrated; Experimental = end-to-end but expect hand cleanup; Preview = next up for implementation; Cataloged = metadata only.")
fmt.Println("Use `terrain convert <source> --from <framework> --to <framework>` to run a Go-native conversion, or add `--plan` to preview.")
return nil
}
Expand All @@ -420,10 +421,10 @@ func runShorthands(jsonOutput bool) error {

fmt.Println("Shorthand command aliases")
fmt.Println()
fmt.Printf(" %-18s %-14s %-14s %s\n", "Alias", "From", "To", "State")
fmt.Printf(" %-18s %-14s %-14s %s\n", strings.Repeat("-", 18), strings.Repeat("-", 14), strings.Repeat("-", 14), strings.Repeat("-", 11))
fmt.Printf(" %-18s %-14s %-14s %s\n", "Alias", "From", "To", "Tier")
fmt.Printf(" %-18s %-14s %-14s %s\n", strings.Repeat("-", 18), strings.Repeat("-", 14), strings.Repeat("-", 14), strings.Repeat("-", 12))
for _, entry := range entries {
fmt.Printf(" %-18s %-14s %-14s %s\n", entry.Alias, entry.From, entry.To, humanizeGoNativeState(entry.GoNativeState))
fmt.Printf(" %-18s %-14s %-14s %s\n", entry.Alias, entry.From, entry.To, tierLabelForState(entry.GoNativeState))
}
fmt.Println()
fmt.Println("Use a shorthand directly to run the Go-native converter, or add `--plan`/`--dry-run` to preview.")
Expand Down Expand Up @@ -605,6 +606,33 @@ func humanizeGoNativeState(state conv.GoNativeState) string {
}
}

// tierLabelForState renders a conversion direction's GoNativeState as
// the Tier-badge vocabulary used elsewhere in 0.2 (Stable /
// Experimental / Preview / Cataloged). Track 6.6 of the parity plan
// surfaces this in `terrain migrate list` so adopters see the trust
// posture per direction at a glance, not just the raw state name.
//
// The mapping:
// - implemented → "Stable" (top-3 + conversion-corpus calibrated)
// - experimental → "Experimental" (works end-to-end; hand cleanup expected)
// - prioritized → "Preview" (next in line for implementation)
// - cataloged → "Cataloged" (metadata only; no converter today)
//
// Returned without surrounding brackets so callers can wrap as needed
// (the list renderer adds `[ ]`; JSON consumers get the bare label).
func tierLabelForState(state conv.GoNativeState) string {
switch state {
case conv.GoNativeStateImplemented:
return "Stable"
case conv.GoNativeStateExperimental:
return "Experimental"
case conv.GoNativeStatePrioritized:
return "Preview"
default:
return "Cataloged"
}
}

// printExperimentalWarning emits a stderr notice when an experimental
// conversion direction is invoked. It is suppressed when JSON output is
// requested so machine consumers see clean structured output; in that case
Expand Down
34 changes: 34 additions & 0 deletions cmd/terrain/cmd_convert_tier_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package main

import (
"testing"

conv "github.com/pmclSF/terrain/internal/convert"
)

// TestTierLabelForState locks the GoNativeState → Tier-label mapping
// that surfaces in `terrain migrate list` output. Track 6.6 of the
// 0.2.0 release plan defines this as the canonical user-facing
// vocabulary; renaming a label here is a public-facing change and
// requires updating docs/product/alignment-first-migration.md too.
func TestTierLabelForState(t *testing.T) {
t.Parallel()
tests := []struct {
state conv.GoNativeState
want string
}{
{conv.GoNativeStateImplemented, "Stable"},
{conv.GoNativeStateExperimental, "Experimental"},
{conv.GoNativeStatePrioritized, "Preview"},
{conv.GoNativeStateCataloged, "Cataloged"},
{conv.GoNativeState("unknown"), "Cataloged"},
}
for _, tt := range tests {
t.Run(string(tt.state), func(t *testing.T) {
t.Parallel()
if got := tierLabelForState(tt.state); got != tt.want {
t.Errorf("tierLabelForState(%q) = %q, want %q", tt.state, got, tt.want)
}
})
}
}
173 changes: 173 additions & 0 deletions docs/product/alignment-first-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# Migration is alignment-first, not conversion-first

How Terrain frames test framework migration in 0.2 — and why
"converge on a framework of record" is a more useful question than
"convert N files from framework A to framework B."

## The framing shift

Pre-0.2 Terrain talked about framework migration as conversion: a
mechanical transform from Mocha to Jest, from unittest to pytest,
from JUnit 4 to JUnit 5. That framing is correct but incomplete. It
optimizes for the engineer running a single conversion, not for the
team trying to bring a 50-repo portfolio into a coherent shape.

The 0.2 launch-readiness review surfaced the disconnect: most teams
care less about *converting* and more about *aligning*. They have
six different test frameworks across twelve services, the new hires
don't know which one their team uses, the CI templates fork along
framework lines, and the cost of the inconsistency dwarfs the cost
of any individual conversion.

So 0.2's migration framing leads with alignment:

> **Step 1: declare a framework of record per repo.**
> **Step 2: see where each repo drifts from its declared framework.**
> **Step 3: converge gradually — Terrain helps you sequence the work.**

Conversion (the mechanical transform side) is one of the tools you
use *during* convergence. It's not the headline; the headline is
the convergence itself.

## What this looks like in practice

### Single repo

```bash
# Declare what this repo officially uses:
cat > .terrain/frameworks.yaml <<EOF
frameworksOfRecord:
- jest # primary unit
- playwright # primary e2e
EOF

# Run analyze. The output now includes a Framework Drift section:
terrain analyze
```

```
Framework Drift
------------------------------------------------------------
Of-record: jest, playwright
Detected: jest (842 files), mocha (47 files), cypress (12 files)

⚠ 47 mocha test files (5.4% of suite) drift from of-record framework
⚠ 12 cypress test files in e2e/ — possibly intentional but undeclared

See: terrain migrate convergence-plan --target jest --source mocha
```

The drift section is alignment-first: it answers "where am I
inconsistent" before it answers "what conversion do I run."

### Multi-repo

```bash
# Declare the portfolio:
cat > .terrain/repos.yaml <<EOF
version: 1
description: Acme engineering test alignment
repos:
- name: web-app
path: ../web-app
frameworksOfRecord: [jest, playwright]
- name: api-service
path: ../api-service
frameworksOfRecord: [pytest]
- name: legacy-portal
path: ../legacy-portal
frameworksOfRecord: [mocha, cypress] # legacy stack, not migrating yet
EOF

# Run portfolio over the manifest:
terrain portfolio --from .terrain/repos.yaml
```

The portfolio output ranks repos by drift magnitude, surfaces
shared blockers, and proposes a convergence sequence. The single-
file converters are still there — they're step three, not step one.

## Why this matters for tier framing

Each conversion direction (Mocha → Jest, unittest → pytest, etc.)
ships with tier metadata indicating how confident the conversion
is in 0.2.0:

| Conversion direction | Tier in 0.2.0 | Notes |
|----------------------------|---------------|-------|
| Mocha → Jest | Stable | Top-3; conversion-corpus calibrated |
| Jasmine → Jest | Stable | Top-3; conversion-corpus calibrated |
| Vitest → Jest | Stable | Top-3; near-trivial transform |
| unittest → pytest | Experimental | Common case works; class-based fixtures partial |
| JUnit 4 → JUnit 5 | Experimental | `@Test` swap solid; `@RunWith` cases partial |
| Mocha → Vitest | Experimental | Lower demand; smaller corpus |
| Cypress → Playwright | Experimental | E2E selectors don't transform 1:1 |
| Other | Tier 3 / preview | Use at your own risk; preview only |

`terrain migrate list` surfaces the tier per direction so adopters
see the trust posture before they start a convergence run.

## What changes in CLI output

Two surfaces gain alignment-first framing:

### `terrain analyze`

The Framework section now includes a "Drift" subsection when a
`frameworksOfRecord` declaration is present in the repo. Without
the declaration, the legacy framework-distribution section
unchanged.

### `terrain migrate list`

Each conversion direction prints with a tier badge:

```
Available conversions
jest ← mocha [Stable] conversion-corpus calibrated
jest ← jasmine [Stable] conversion-corpus calibrated
jest ← vitest [Stable] near-trivial transform
pytest ← unittest [Experimental] class-based fixtures partial
junit5 ← junit4 [Experimental] @RunWith cases partial
vitest ← mocha [Experimental] lower demand, smaller corpus
...
```

## What's still in flight (0.2.x and 0.3)

- **Per-direction conversion-corpus calibration to A-grade** for
the top-3 stable directions (Track 6.7 of the parity plan)
- **`terrain portfolio --from <manifest>`** end-to-end aggregation
of multi-repo drift — manifest format ships in 0.2 (Track 6.1);
the aggregator lands in 0.2.x (Track 6.2/6.3)
- **Cross-repo policy aggregation** — apply one policy to N repos
via the portfolio manifest. 0.3 work.

Until those land, the alignment-first reframing is a **doc and CLI
output change**, not a brand-new aggregator. The single-repo
framework-drift section ships in 0.2; the multi-repo aggregator
that consumes the manifest ships when it ships.

## Anti-goals

- We do not auto-convert files in 0.2. `terrain migrate run` is
preview-by-default; users review the diff and apply manually
(or with their own tooling).
- We do not declare a "best" framework. The framework-of-record is
a per-team declaration; Terrain doesn't have an opinion about
Jest vs. Vitest vs. Mocha. We surface drift relative to your
declaration, not relative to ours.
- We do not block convergence on calibration. Adopters can run
experimental-tier conversions today; the tier badge is honest
signaling, not a gate.

## Related reading

- [`docs/product/vision.md`](vision.md) — full pillar narrative
(Align is the secondary pillar in 0.2)
- [`docs/release/feature-status.md`](../release/feature-status.md) —
per-capability tier matrix
- [`internal/portfolio/manifest.go`](../../internal/portfolio/manifest.go) —
the multi-repo manifest schema
- [`docs/architecture/27-go-native-conversion-migration.md`](../architecture/27-go-native-conversion-migration.md) —
conversion engine architecture (the mechanical transform side)
Loading
Loading