diff --git a/internal/clierr/codes.go b/internal/clierr/codes.go index fd3989a..549e28d 100644 --- a/internal/clierr/codes.go +++ b/internal/clierr/codes.go @@ -168,6 +168,22 @@ const ( // --- Debug stack resolver (gofasta debug stack) --- CodeDebugStackParseFailed Code = "DEBUG_STACK_PARSE_FAILED" CodeDebugSourceUnavailable Code = "DEBUG_SOURCE_UNAVAILABLE" + + // --- Refactor (gofasta refactor feature-package) --- + // + // Ineligible — the project isn't in layered layout, so there's + // nothing to convert. + CodeRefactorIneligible Code = "REFACTOR_INELIGIBLE" + // Aborted — the migration ran the per-resource compile-gate and + // found a failure. The partial state is left for the user to + // inspect (or `git restore`). + CodeRefactorAborted Code = "REFACTOR_ABORTED" + // DirtyTree — the working tree has uncommitted changes; pass + // --force to proceed anyway. + CodeRefactorDirtyTree Code = "REFACTOR_DIRTY_TREE" + // ResourceNotFound — the named resource doesn't have a model file + // at the expected layered path. + CodeRefactorResourceNotFound Code = "REFACTOR_RESOURCE_NOT_FOUND" ) // meta carries the remediation hint and docs URL for a code. Looked up diff --git a/internal/commands/commands_exec_test.go b/internal/commands/commands_exec_test.go index 4009bc4..b96e709 100644 --- a/internal/commands/commands_exec_test.go +++ b/internal/commands/commands_exec_test.go @@ -493,7 +493,7 @@ func TestRunExecute_UnknownSubcommand(t *testing.T) { func TestRunNew_FakeSuccess(t *testing.T) { chdirTemp(t) withFakeExec(t, 0) - err := runNew("testapp", false, "postgres") + err := runNew("testapp", false, "postgres", "layered") assert.NoError(t, err) // The project dir should have been created _, err = os.Stat(filepath.Join("testapp", "config.yaml")) @@ -504,14 +504,14 @@ func TestRunNew_FakeSuccess(t *testing.T) { func TestRunNew_FakeSuccess_GraphQL(t *testing.T) { chdirTemp(t) withFakeExec(t, 0) - err := runNew("gqlapp", true, "postgres") + err := runNew("gqlapp", true, "postgres", "layered") assert.NoError(t, err) } func TestRunNew_GoModInitFails(t *testing.T) { chdirTemp(t) withFakeExec(t, 1) - err := runNew("failapp", false, "postgres") + err := runNew("failapp", false, "postgres", "layered") assert.Error(t, err) assert.Contains(t, err.Error(), "go mod init") } @@ -524,14 +524,14 @@ func TestRunNew_WarningBranches(t *testing.T) { chdirTemp(t) // mod init ok, mod edit -go ok, gofasta install ok, everything else fails stagedFakeExec(t, 0, 0, 0, 1) - err := runNew("warnapp", false, "postgres") + err := runNew("warnapp", false, "postgres", "layered") assert.NoError(t, err) } func TestRunNew_WarningBranches_GraphQL(t *testing.T) { chdirTemp(t) stagedFakeExec(t, 0, 0, 0, 1) - err := runNew("warnapp", true, "postgres") + err := runNew("warnapp", true, "postgres", "layered") assert.NoError(t, err) } @@ -543,7 +543,7 @@ func TestRunNew_WarningBranches_GraphQL(t *testing.T) { func TestRunNew_GofastaInstallFails(t *testing.T) { chdirTemp(t) stagedFakeExec(t, 0, 1) // go mod init ok, go get gofasta fails - err := runNew("failapp", false, "postgres") + err := runNew("failapp", false, "postgres", "layered") assert.Error(t, err) assert.Contains(t, err.Error(), "github.com/gofastadev/gofasta") assert.Contains(t, err.Error(), "failed to install") diff --git a/internal/commands/configutil/configutil.go b/internal/commands/configutil/configutil.go index e45fa73..afaab66 100644 --- a/internal/commands/configutil/configutil.go +++ b/internal/commands/configutil/configutil.go @@ -131,6 +131,21 @@ func ReadDBDriver() string { return driver } +// ReadLayout reads the project.layout key from config.yaml. Returns +// "layered" or "feature" verbatim when set; returns "" when the key is +// missing so callers can fall back to filesystem detection without +// confusing an absent value with an explicit "layered" choice. +// +// The layered/feature dichotomy is the layout package's concern; this +// helper just surfaces the raw string. `gofasta new` writes this key at +// scaffold time so every project created with a layout-aware CLI +// version carries an authoritative value; projects scaffolded before +// the feature shipped get the empty-string return and fall back to +// filesystem detection in layout.Detect(). +func ReadLayout() string { + return loadConfig().String("project.layout") +} + // BuildCacheEndpoint reads cache.* from config.yaml + env vars and // returns the cache backend's host:port endpoint, plus an `enabled` // flag indicating whether the app actually wants a network cache. diff --git a/internal/commands/new.go b/internal/commands/new.go index 214d8f7..1129417 100644 --- a/internal/commands/new.go +++ b/internal/commands/new.go @@ -10,6 +10,7 @@ import ( "github.com/gofastadev/cli/internal/clierr" "github.com/gofastadev/cli/internal/cliout" + "github.com/gofastadev/cli/internal/featurize" "github.com/gofastadev/cli/internal/skeleton" "github.com/gofastadev/cli/internal/termcolor" "github.com/spf13/cobra" @@ -26,6 +27,7 @@ type newResult struct { ModulePath string `json:"module_path"` GraphQL bool `json:"graphql"` DBDriver string `json:"db_driver"` + Layout string `json:"layout"` Success bool `json:"success"` Error string `json:"error,omitempty"` } @@ -38,6 +40,7 @@ type ProjectData struct { ModulePath string // Go module path: "github.com/myorg/myapp" GraphQL bool // true when --graphql flag is passed DBDriver string // "postgres" | "mysql" | "sqlite" | "sqlserver" | "clickhouse" + Layout string // "layered" | "feature" } // supportedDrivers is the canonical set of --driver values. The first @@ -57,6 +60,22 @@ func isSupportedDriver(v string) bool { return false } +// supportedLayouts is the canonical set of --layout values. The first +// entry is the default. Values match what configutil.ReadLayout returns +// and what layout.ParseKind accepts. +var supportedLayouts = []string{"layered", "feature"} + +// isSupportedLayout reports whether v is one of the canonical layout +// strings. Used to validate the --layout flag before scaffolding. +func isSupportedLayout(v string) bool { + for _, l := range supportedLayouts { + if l == v { + return true + } + } + return false +} + // graphqlOnlyPaths lists skeleton paths that should be skipped for REST-only projects. var graphqlOnlyPaths = []string{ "app/graphql/", @@ -111,7 +130,14 @@ After the command finishes, ` + "`cd`" + ` into the new directory and run "--driver %q is not supported — valid values: %s", driver, strings.Join(supportedDrivers, ", ")) } - return runNew(args[0], gql || gqlShort, driver) + layoutFlag, _ := cmd.Flags().GetString("layout") + layoutFlag = strings.ToLower(strings.TrimSpace(layoutFlag)) + if !isSupportedLayout(layoutFlag) { + return clierr.Newf(clierr.CodeInvalidName, + "--layout %q is not supported — valid values: %s", + layoutFlag, strings.Join(supportedLayouts, ", ")) + } + return runNew(args[0], gql || gqlShort, driver, layoutFlag) }, } @@ -121,6 +147,8 @@ func init() { newCmd.Flags().Bool("gql", false, "Shorthand for --graphql") newCmd.Flags().String("driver", "postgres", "Database driver: "+strings.Join(supportedDrivers, "|")) + newCmd.Flags().String("layout", "layered", + "Project layout: "+strings.Join(supportedLayouts, "|")) } // dotfileRenames maps embedded names to actual dotfile names. @@ -166,11 +194,14 @@ var osChdir = os.Chdir var migrationsFSOverride fs.FS //nolint:gocognit,gocyclo // linear scaffold pipeline; refactoring would obscure the flow. -func runNew(nameOrPath string, includeGraphQL bool, driver string) (resultErr error) { +func runNew(nameOrPath string, includeGraphQL bool, driver, layoutKind string) (resultErr error) { projectDir, projectName, modulePath := resolveProjectPaths(nameOrPath) if driver == "" { driver = "postgres" } + if layoutKind == "" { + layoutKind = "layered" + } // In --json mode, redirect stdout to stderr for the duration of // the scaffold so the dozens of decorative `termcolor.Print*` and @@ -193,6 +224,7 @@ func runNew(nameOrPath string, includeGraphQL bool, driver string) (resultErr er ModulePath: modulePath, GraphQL: includeGraphQL, DBDriver: driver, + Layout: layoutKind, Success: resultErr == nil, Error: errString(resultErr), }, nil) @@ -216,6 +248,7 @@ func runNew(nameOrPath string, includeGraphQL bool, driver string) (resultErr er ModulePath: modulePath, GraphQL: includeGraphQL, DBDriver: driver, + Layout: layoutKind, } cliout.Header("🚀 Creating new gofasta project: %s", projectName) @@ -322,6 +355,25 @@ func runNew(nameOrPath string, includeGraphQL bool, driver string) (resultErr er output = content } + // --layout=feature: route per-resource files through the + // featurize transformer and emit them at app// paths + // instead of the layered locations. Cross-cutting files + // (container.go, wire.go, index.routes.go) get a separate + // transform that adjusts imports + symbol references to + // reference the per-feature packages. + if data.Layout == "feature" { + rerouted, transformed, ferr := featurizeFile(outputPath, output, data.ModulePath, starterResources()) + if ferr != nil { + return ferr + } + outputPath = rerouted + output = transformed + // Ensure new parent directory exists after potential reroute. + if dir := filepath.Dir(outputPath); dir != "." { + _ = os.MkdirAll(dir, 0o755) + } + } + cliout.Path(outputPath) return os.WriteFile(outputPath, output, 0o644) }) @@ -588,3 +640,149 @@ func runCmdSilent(name string, args ...string) error { cmd.Stderr = os.Stderr return cmd.Run() } + +// starterResources is the list of resources the User starter scaffold +// produces. Only one — "User" — today. When the scaffold ships a +// multi-resource starter, extend this slice. +func starterResources() []featurize.Resource { + return []featurize.Resource{ + {Name: "User", Snake: "user", Plural: "Users"}, + } +} + +// featurizeFile re-routes and transforms one already-rendered template +// file when `--layout=feature` is active. Returns: +// +// rerouted — new output path (== outputPath when no reroute needed) +// transformed— possibly-rewritten content (== output when no transform) +// skip — true when the file shouldn't be written at all (the +// layered template is dead in feature mode and has no +// feature counterpart, e.g. layered-only orphan dirs). +// +// Three categories of files: +// +// 1. Per-resource: app/models/.model.go, app/services/.service.go, +// etc. — re-routed to app//* and content collapsed via +// featurize.TransformPerResource. +// +// 2. Cross-cutting per-layout: app/di/container.go, app/di/wire.go, +// app/rest/routes/index.routes.go — stay at their paths, but +// content rewritten to reference per-feature packages. +// +// 3. Shared infra that exists in `app/services/` but isn't a resource +// file: `password_generator.go` — moved into the feature package +// it serves (currently `app/user/`). +// +// Files that match none of these pass through unchanged (they're shared +// infra that lives in the same place in both layouts). +// +//nolint:gocognit,gocyclo // dispatch over 5 distinct file categories — splitting into helpers would just move the conditional tree without reducing branches. +func featurizeFile(outputPath string, content []byte, mod string, resources []featurize.Resource) (rerouted string, transformed []byte, err error) { + // Category 1: per-resource reroute + featurize. + for _, r := range resources { + for _, pair := range featurize.PerResourceMapping(r.Snake) { + if outputPath == pair.Layered { + out, terr := featurize.TransformPerResource(content, featurize.Options{ + ModulePath: mod, + Resource: r, + }) + if terr != nil { + return outputPath, content, fmt.Errorf("featurize %s: %w", outputPath, terr) + } + return pair.Feature, out, nil + } + } + } + + // Category 3: password_generator.go follows the user feature (only + // consumer today). Move into app/user/ with the package rewritten. + if outputPath == "app/services/password_generator.go" { + out, terr := featurize.TransformPerResource(content, featurize.Options{ + ModulePath: mod, + Resource: featurize.Resource{Name: "User", Snake: "user", Plural: "Users"}, + }) + if terr != nil { + return outputPath, content, fmt.Errorf("featurize %s: %w", outputPath, terr) + } + return "app/user/password_generator.go", out, nil + } + + // Category 2: cross-cutting per-layout files. + switch outputPath { + case "app/di/container.go": + out, terr := featurize.TransformContainer(content, mod, resources) + if terr != nil { + return outputPath, content, fmt.Errorf("featurize %s: %w", outputPath, terr) + } + return outputPath, out, nil + case "app/di/wire.go": + out, terr := featurize.TransformWire(content, mod, resources) + if terr != nil { + return outputPath, content, fmt.Errorf("featurize %s: %w", outputPath, terr) + } + return outputPath, out, nil + case "app/rest/routes/index.routes.go": + out, terr := featurize.TransformIndexRoutes(content, mod, resources) + if terr != nil { + return outputPath, content, fmt.Errorf("featurize %s: %w", outputPath, terr) + } + return outputPath, out, nil + case "app/di/providers/core.go": + out, terr := featurize.TransformCoreProviders(content, mod, resources) + if terr != nil { + return outputPath, content, fmt.Errorf("featurize %s: %w", outputPath, terr) + } + return outputPath, out, nil + } + + // Mock files in testutil/mocks/ — reroute the import block to + // the per-feature package. Match by filename suffix so future + // per-feature mocks (Order, Product, etc.) get the same treatment. + if strings.HasPrefix(outputPath, "testutil/mocks/") && + (strings.HasSuffix(outputPath, "_repository_mock.go") || + strings.HasSuffix(outputPath, "_service_mock.go")) { + base := filepath.Base(outputPath) + // extract from "_repository_mock.go" / "_service_mock.go" + snake := strings.TrimSuffix(base, "_repository_mock.go") + snake = strings.TrimSuffix(snake, "_service_mock.go") + for _, r := range resources { + if r.Snake == snake { + out, terr := featurize.TransformMock(content, mod, r) + if terr != nil { + return outputPath, content, fmt.Errorf("featurize %s: %w", outputPath, terr) + } + return outputPath, out, nil + } + } + } + + // Shared relocations — files that just move path with no source + // rewrites (e.g. app/dtos/aliases.go → app/shared/dtos/aliases.go). + for _, pair := range featurize.SharedRelocations() { + if outputPath == pair.Layered { + return pair.Feature, content, nil + } + } + + // Shared infra files that stay in their layered location but + // reference the relocated shared dtos package. Only their import + // path needs flipping — no other source rewrite. + switch outputPath { + case "app/validators/app_validator.go", + "app/rest/controllers/validator.go", + "app/graphql/resolvers/user.resolvers.go": + out, terr := featurize.FixDtosImportPath(content, mod) + if terr != nil { + return outputPath, content, fmt.Errorf("featurize %s: %w", outputPath, terr) + } + return outputPath, out, nil + } + + // Per-resource validator files stay in app/validators/ as-is. + // The user.validators.go template only references gorm + validator + // + slog (no per-resource types), so no transformation needed. + // If a future per-resource validator references the feature + // package, add a transformer case here. + + return outputPath, content, nil +} diff --git a/internal/commands/new_test.go b/internal/commands/new_test.go index d91a7d8..20762e7 100644 --- a/internal/commands/new_test.go +++ b/internal/commands/new_test.go @@ -79,7 +79,7 @@ func TestRunNew_DirectoryAlreadyExists(t *testing.T) { os.Chdir(dir) os.Mkdir("myapp", 0755) - err := runNew("myapp", false, "postgres") + err := runNew("myapp", false, "postgres", "layered") assert.Error(t, err) assert.Contains(t, err.Error(), "already exists") } @@ -138,7 +138,7 @@ func TestRunNew_MkdirAllError(t *testing.T) { parentFile := filepath.Join(dir, "parent") require.NoError(t, os.WriteFile(parentFile, []byte("x"), 0o644)) - err := runNew(filepath.Join(parentFile, "proj"), false, "postgres") + err := runNew(filepath.Join(parentFile, "proj"), false, "postgres", "layered") assert.Error(t, err) } @@ -158,7 +158,7 @@ func TestRunNew_ChdirError(t *testing.T) { require.NoError(t, os.Chmod(parent, 0o600)) t.Cleanup(func() { _ = os.Chmod(parent, 0o755) }) - err := runNew(filepath.Join(parent, "proj"), false, "postgres") + err := runNew(filepath.Join(parent, "proj"), false, "postgres", "layered") assert.Error(t, err) } @@ -201,7 +201,7 @@ func TestRunNew_ChdirFails(t *testing.T) { osChdir = func(path string) error { return os.ErrPermission } t.Cleanup(func() { osChdir = origOS }) withFakeExec(t, 0) - err := runNew("chdir-fail-app", false, "postgres") + err := runNew("chdir-fail-app", false, "postgres", "layered") require.Error(t, err) } @@ -218,7 +218,7 @@ func TestRunNew_BadTemplate(t *testing.T) { } projectFSOverride = fsys t.Cleanup(func() { projectFSOverride = nil }) - err := runNew("bad-tmpl-app", false, "postgres") + err := runNew("bad-tmpl-app", false, "postgres", "layered") require.Error(t, err) assert.Contains(t, err.Error(), "parsing template") } @@ -234,7 +234,7 @@ func TestRunNew_TemplateExecFails(t *testing.T) { } projectFSOverride = fsys t.Cleanup(func() { projectFSOverride = nil }) - err := runNew("bad-exec-app", false, "postgres") + err := runNew("bad-exec-app", false, "postgres", "layered") require.Error(t, err) assert.Contains(t, err.Error(), "executing template") } @@ -263,7 +263,7 @@ func TestRunNew_ReadFileFails(t *testing.T) { } projectFSOverride = errFS{base: base} t.Cleanup(func() { projectFSOverride = nil }) - err := runNew("read-fail-app", false, "postgres") + err := runNew("read-fail-app", false, "postgres", "layered") require.Error(t, err) assert.Contains(t, err.Error(), "reading") } @@ -278,7 +278,7 @@ func TestRunNew_WalkCallbackReceivesError(t *testing.T) { // with an fs.PathError → the first branch in the callback fires. projectFSOverride = fstest.MapFS{} t.Cleanup(func() { projectFSOverride = nil }) - err := runNew("walkerr-app", false, "postgres") + err := runNew("walkerr-app", false, "postgres", "layered") require.Error(t, err) } @@ -288,7 +288,7 @@ func TestRunNew_UnreadableDir(t *testing.T) { chdirTemp(t) withFakeExec(t, 0) require.NoError(t, os.WriteFile("conflict", []byte{}, 0o644)) - err := runNew("conflict", false, "postgres") + err := runNew("conflict", false, "postgres", "layered") require.Error(t, err) } @@ -310,7 +310,7 @@ func TestRunNew_JSON_EmitsResultOnEarlyReturn(t *testing.T) { require.NoError(t, os.MkdirAll("collision-app", 0o755)) out := captureStdout(t, func() { - err := runNew("collision-app", false, "postgres") + err := runNew("collision-app", false, "postgres", "layered") require.Error(t, err) }) @@ -371,7 +371,7 @@ func TestRunNew_PerDriverMigrationsCopied(t *testing.T) { projectName := driver + "app" _ = captureStdout(t, func() { - err := runNew(projectName, false, driver) + err := runNew(projectName, false, driver, "layered") // runNew may fail later (no real go mod tidy possible // against the synthetic FS), but the migrations copy // happens BEFORE any of that. Tolerate the trailing @@ -439,7 +439,7 @@ func TestRunNew_DriverEmptyDefaultsToPostgres(t *testing.T) { // Best-effort: runNew may fail later because the synthetic FS // doesn't carry a full project tree, but the empty-driver // branch executes BEFORE any of that. - _ = runNew("emptydrivertest", false, "") + _ = runNew("emptydrivertest", false, "", "layered") }) // db/migrations should contain the postgres set (5 up + 5 down) @@ -465,7 +465,7 @@ func TestRunNew_CopyMigrationsErrorPropagates(t *testing.T) { withFakeExec(t, 0) _ = captureStdout(t, func() { - err := runNew("copyfailapp", false, "postgres") + err := runNew("copyfailapp", false, "postgres", "layered") require.Error(t, err) assert.Contains(t, err.Error(), "copying postgres foundational migrations") }) diff --git a/internal/commands/refactor.go b/internal/commands/refactor.go new file mode 100644 index 0000000..58edd3e --- /dev/null +++ b/internal/commands/refactor.go @@ -0,0 +1,1098 @@ +// Package commands — refactor command. +// +// `gofasta refactor feature-package` migrates a layered project to +// the feature-package layout in-place. Two modes: +// +// gofasta refactor feature-package # one resource +// gofasta refactor feature-package --all # every resource +// gofasta refactor feature-package --dry-run +// gofasta refactor feature-package --force # proceed on dirty tree +// +// The refactor engine reuses the same `internal/featurize/` AST +// transformer that powers `gofasta new --layout=feature`. The two +// callers differ only in inputs: scaffolding starts from the embedded +// skeleton, refactor starts from a user's on-disk project. +// +// Per-resource compile gate: after migrating each resource the engine +// runs `go build ./...` + `go tool wire ./app/di/`. On failure it +// aborts loudly and leaves the partial state in place. Matches the +// docs' contract "moving one resource at a time keeps the app +// compilable throughout" (project-structure.mdx:212). + +package commands + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/gofastadev/cli/internal/clierr" + "github.com/gofastadev/cli/internal/cliout" + "github.com/gofastadev/cli/internal/commands/configutil" + "github.com/gofastadev/cli/internal/featurize" + "github.com/spf13/cobra" +) + +// refactorResult is the JSON envelope for `gofasta refactor feature-package --json`. +type refactorResult struct { + Action string `json:"action"` + Resources []string `json:"resources"` + FilesMoved []string `json:"files_moved"` + FilesPatched []string `json:"files_patched"` + DryRun bool `json:"dry_run"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +var refactorCmd = &cobra.Command{ + Use: "refactor", + Short: "Refactor an existing project (layout migrations, structural moves)", + Long: `Refactor commands migrate an existing project's structure without +changing its behavior. Currently the only subcommand is: + + gofasta refactor feature-package Migrate from layered to feature-package layout`, + Args: cobra.MinimumNArgs(1), +} + +var refactorLayeredCmd = &cobra.Command{ + Use: "layered [ | --all]", + Short: "Migrate the project from feature-package back to layered layout", + Long: `Inverse of ` + "`gofasta refactor feature-package`" + `. Moves per-resource +files out of app// back to their layered locations (app/services/.service.go, +app/repositories/.repository.go, etc.), re-qualifies the bare in-feature +identifiers with their layered package aliases, restores shared aliases.go +to app/dtos/, and patches container/wire/index.routes back to the layered shape. + +Same safety contract as the forward direction: refuses on a dirty git tree +unless --force; --dry-run previews the plan without writing. After all moves +the engine regenerates Wire and runs ` + "`go build ./...`" + ` — on failure it +aborts with the partial state in place. + +Caveat: the reverse direction assumes the feature project follows scaffold- +shaped conventions. Heavy hand-edits (renamed types, custom files, alternate +layouts) may not unwind cleanly — in those cases split the migration into +smaller pieces or manually adjust the result. + +Examples: + gofasta refactor layered User + gofasta refactor layered --all + gofasta refactor layered User --dry-run`, + RunE: runRefactorLayered, +} + +var refactorFeatureCmd = &cobra.Command{ + Use: "feature [ | --all]", + Aliases: []string{"feature-package"}, // back-compat alias for the pre-rename name + //revive:disable:exported // command-style verbiage; cobra owns the doc surface + Short: "Migrate the project from layered to feature-package layout", + Long: `Move per-resource files from layered locations (app/services/.service.go, +app/repositories/.repository.go, app/rest/controllers/.controller.go, etc.) +into app//, rewriting imports and selectors so the result compiles. + +Shared aliases (TPaginationObjectDto, SortOrientation) relocate to +app/shared/dtos/. Models stay in app/models/ and validators stay in +app/validators/ for the reasons documented in +` + "`internal/featurize/featurize.go`" + ` and ` + "`docs/getting-started/project-structure.mdx`" + `. + +After each resource is migrated the engine runs ` + "`go build ./...`" + ` and +` + "`go tool wire ./app/di/`" + `. On failure the migration aborts and the +partial state is left for inspection — fix or ` + "`git restore`" + ` to recover. + +Examples: + gofasta refactor feature User + gofasta refactor feature --all + gofasta refactor feature User --dry-run`, + RunE: runRefactorFeature, +} + +// refactorStatusCmd is a read-only inspector: tells the user which +// layout the current project is in, how many resources it has, and +// which migration target is available. No writes — safe to run +// anywhere, including on a dirty tree. +var refactorStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show the current project's layout and available migrations", + Long: `Inspects ` + "`config.yaml`" + ` and the on-disk layout to report: + + • current layout (layered | feature) per ` + "`project.layout`" + ` + • number of resources detected (and their names) + • which migration target is available + +Read-only — touches nothing. Run before a refactor to confirm you're +about to move in the direction you expected. + +Examples: + gofasta refactor status + gofasta --json refactor status`, + RunE: runRefactorStatus, +} + +func init() { + rootCmd.AddCommand(refactorCmd) + refactorCmd.AddCommand(refactorFeatureCmd) + refactorCmd.AddCommand(refactorLayeredCmd) + refactorCmd.AddCommand(refactorStatusCmd) + refactorFeatureCmd.Flags().Bool("all", false, "Migrate every resource detected in app/models/") + refactorFeatureCmd.Flags().Bool("dry-run", false, "Show the migration plan without writing any files") + refactorFeatureCmd.Flags().Bool("force", false, "Proceed even when the working tree is dirty") + refactorLayeredCmd.Flags().Bool("all", false, "Migrate every feature directory detected under app/") + refactorLayeredCmd.Flags().Bool("dry-run", false, "Show the migration plan without writing any files") + refactorLayeredCmd.Flags().Bool("force", false, "Proceed even when the working tree is dirty") +} + +// refactorStatusResult is the JSON envelope for `gofasta refactor status --json`. +type refactorStatusResult struct { + Action string `json:"action"` + Layout string `json:"layout"` // "layered" | "feature" + LayoutSource string `json:"layout_source"` // "config.yaml" | "filesystem-detect" + Resources []string `json:"resources"` // detected resource names + MigrationTarget string `json:"migration_target"` // the layout you'd migrate TO + MigrationCmd string `json:"migration_command"` // exact command to run + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +func runRefactorStatus(_ *cobra.Command, _ []string) (resultErr error) { + result := refactorStatusResult{Action: "refactor.status"} + defer func() { + result.Success = resultErr == nil + if resultErr != nil { + result.Error = resultErr.Error() + } + cliout.Print(result, func(w io.Writer) { + fprintf(w, "Layout: %s (from %s)\n", result.Layout, result.LayoutSource) + fprintf(w, "Resources: %d\n", len(result.Resources)) + for _, r := range result.Resources { + fprintf(w, " • %s\n", r) + } + fprintf(w, "Migration target: %s\n", result.MigrationTarget) + fprintf(w, "Command: %s\n", result.MigrationCmd) + }) + }() + + // Detect layout. configutil.ReadLayout returns "" when the key is + // absent in config.yaml — fall back to filesystem detection so users + // of projects that pre-date the layout key still get a useful answer. + cfgLayout := configutil.ReadLayout() + if cfgLayout != "" { + result.Layout = cfgLayout + result.LayoutSource = "config.yaml" + } else if _, err := os.Stat("app/models"); err == nil { + result.Layout = "layered" + result.LayoutSource = "filesystem-detect (no project.layout in config.yaml)" + } else { + result.Layout = "unknown" + result.LayoutSource = "no signals found" + return clierr.New(clierr.CodeRefactorIneligible, + "not in a gofasta project root (no config.yaml `project.layout` and no app/models/)") + } + + // Discover resources based on layout. + switch result.Layout { + case "layered": + rs, _ := discoverResourcesFromModels() + for _, r := range rs { + result.Resources = append(result.Resources, r.Name) + } + result.MigrationTarget = "feature" + result.MigrationCmd = "gofasta refactor feature --all" + case "feature": + rs, _ := discoverFeatureResources() + for _, r := range rs { + result.Resources = append(result.Resources, r.Name) + } + result.MigrationTarget = "layered" + result.MigrationCmd = "gofasta refactor layered --all" + } + return nil +} + +//nolint:gocognit,gocyclo // linear orchestration pipeline: preconditions → resolve → (dry-run|migrate) → relocate → patch → cleanup → flip → verify. Splitting hides the order. +func runRefactorFeature(cmd *cobra.Command, args []string) (resultErr error) { + allFlag, _ := cmd.Flags().GetBool("all") + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + result := refactorResult{Action: "refactor.feature-package", DryRun: dryRun} + defer func() { + result.Success = resultErr == nil + if resultErr != nil { + result.Error = resultErr.Error() + } + cliout.Print(result, nil) + }() + + // Preconditions. + if err := requireLayeredProject(); err != nil { + return err + } + if !force { + if err := requireCleanGitTree(); err != nil { + return err + } + } + + // Resolve the resources to migrate. + resources, err := resolveRefactorResources(args, allFlag) + if err != nil { + return err + } + for _, r := range resources { + result.Resources = append(result.Resources, r.Name) + } + + mod, err := readModulePath() + if err != nil { + return err + } + + if dryRun { + cliout.Header("📋 Dry-run plan:") + for _, r := range resources { + cliout.Info("Would migrate %s:", r.Name) + for _, pair := range featurize.PerResourceMapping(r.Snake) { + if _, err := os.Stat(pair.Layered); err == nil { + cliout.Plainln(fmt.Sprintf(" move: %s → %s", pair.Layered, pair.Feature)) + } + } + } + for _, pair := range featurize.SharedRelocations() { + if _, err := os.Stat(pair.Layered); err == nil { + cliout.Plainln(fmt.Sprintf(" move: %s → %s", pair.Layered, pair.Feature)) + } + } + cliout.Info("Would patch: app/di/container.go, app/di/wire.go, app/rest/routes/index.routes.go, app/di/providers/core.go") + cliout.Info("Would patch testutil/mocks/_*.go for each resource") + cliout.Info("Would update config.yaml: project.layout: layered → feature") + return nil + } + + // Migrate each resource. The per-resource compile gate runs after + // each one — abort if a build fails so the user can inspect the + // partial state. + for _, r := range resources { + cliout.Header("🔧 Migrating %s", r.Name) + moved, patched, err := migrateResource(r, mod, resources) + result.FilesMoved = append(result.FilesMoved, moved...) + result.FilesPatched = append(result.FilesPatched, patched...) + if err != nil { + return err + } + } + + // After all resources, relocate shared files (aliases.go). + cliout.Step("📦 Relocating shared aliases") + relocated, err := applySharedRelocations() + if err != nil { + return err + } + result.FilesMoved = append(result.FilesMoved, relocated...) + + // Move app/services/password_generator.go → app/user/password_generator.go. + // PasswordGenerator follows the user feature (only consumer in the + // bootstrap scaffold). If the user feature doesn't exist in this + // refactor (e.g. refactoring a project that already deleted User) + // the file stays put. + cliout.Step("📦 Relocating password_generator.go") + pwMoved, err := relocatePasswordGenerator(mod, resources) + if err != nil { + return err + } + result.FilesMoved = append(result.FilesMoved, pwMoved...) + + // Patch cross-cutting files (container, wire, index.routes, core, validator infra). + cliout.Step("🔌 Patching cross-cutting files") + patched, err := applyCrossCuttingPatches(mod, resources) + if err != nil { + return err + } + result.FilesPatched = append(result.FilesPatched, patched...) + + // Clean up the now-empty layered directories so the project tree + // matches the feature-layout doc shape. + cliout.Step("🧹 Cleaning empty layered directories") + pruneEmptyLayeredDirs() + + // Flip config.yaml. + cliout.Step("📝 Updating config.yaml") + if err := flipLayoutInConfig(); err != nil { + return err + } + + // Wire goes FIRST so the stale wire_gen.go (which still references + // layered imports like app/repositories) is regenerated against the + // new layout. Then go build can verify the whole tree. + cliout.Step("✓ Regenerating Wire") + // Delete the stale wire_gen.go before regenerating — wire's tool + // invocation needs ALL Go files in the package to compile, and the + // stale generated file references packages that no longer exist. + _ = os.Remove("app/di/wire_gen.go") + if err := runGoCommand("tool", "wire", "./app/di/"); err != nil { + return clierr.Wrap(clierr.CodeRefactorAborted, err, + "wire generation failed — inspect the partial state and `git restore` to revert") + } + cliout.Step("✓ Verifying go build ./...") + if err := runGoCommand("build", "./..."); err != nil { + return clierr.Wrap(clierr.CodeRefactorAborted, err, + "go build failed after migration — inspect the partial state and `git restore` to revert") + } + + cliout.Success("Migration complete — project is now feature-package layout") + return nil +} + +//nolint:gocognit,gocyclo // inverse orchestration: preconditions → resolve → (dry-run|migrate-each) → relocate shared → patch cross-cutting → cleanup → flip config → wire → build. Same shape as runRefactorFeature. +func runRefactorLayered(cmd *cobra.Command, args []string) (resultErr error) { + allFlag, _ := cmd.Flags().GetBool("all") + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + result := refactorResult{Action: "refactor.layered", DryRun: dryRun} + defer func() { + result.Success = resultErr == nil + if resultErr != nil { + result.Error = resultErr.Error() + } + cliout.Print(result, nil) + }() + + if err := requireFeatureProject(); err != nil { + return err + } + if !force { + if err := requireCleanGitTree(); err != nil { + return err + } + } + + resources, err := resolveLayeredRevertResources(args, allFlag) + if err != nil { + return err + } + for _, r := range resources { + result.Resources = append(result.Resources, r.Name) + } + + mod, err := readModulePath() + if err != nil { + return err + } + + if dryRun { + cliout.Header("📋 Dry-run plan:") + for _, r := range resources { + cliout.Info("Would unwind %s:", r.Name) + for _, p := range featurize.ReversePerResourceMapping(r.Snake) { + if _, err := os.Stat(p.Feature); err == nil { + cliout.Plainln(fmt.Sprintf(" move: %s → %s", p.Feature, p.Dest.Path)) + } + } + } + for _, pair := range featurize.SharedRelocationsReverse() { + if _, err := os.Stat(pair.Layered); err == nil { + cliout.Plainln(fmt.Sprintf(" move: %s → %s", pair.Layered, pair.Feature)) + } + } + cliout.Info("Would patch: app/di/container.go, app/di/wire.go, app/rest/routes/index.routes.go, app/di/providers/core.go") + cliout.Info("Would patch testutil/mocks/_*.go for each resource") + cliout.Info("Would update config.yaml: project.layout: feature → layered") + return nil + } + + for _, r := range resources { + cliout.Header("🔧 Unwinding %s back to layered", r.Name) + moved, patched, err := revertResource(r, mod) + result.FilesMoved = append(result.FilesMoved, moved...) + result.FilesPatched = append(result.FilesPatched, patched...) + if err != nil { + return err + } + } + + // Relocate aliases.go back: app/shared/dtos/aliases.go → app/dtos/. + cliout.Step("📦 Restoring shared aliases to app/dtos/") + relocated, err := applySharedRelocationsReverse() + if err != nil { + return err + } + result.FilesMoved = append(result.FilesMoved, relocated...) + + // Move password_generator.go back: app/user/password_generator.go → app/services/. + cliout.Step("📦 Restoring password_generator.go to app/services/") + pwMoved, err := revertPasswordGenerator() + if err != nil { + return err + } + result.FilesMoved = append(result.FilesMoved, pwMoved...) + + // Patch cross-cutting files back to layered shape. + cliout.Step("🔌 Patching cross-cutting files back to layered") + patched, err := applyCrossCuttingPatchesReverse(mod, resources) + if err != nil { + return err + } + result.FilesPatched = append(result.FilesPatched, patched...) + + // Clean up empty feature directories. + cliout.Step("🧹 Cleaning empty feature directories") + pruneEmptyFeatureDirs(resources) + + // Flip config.yaml: feature → layered. + cliout.Step("📝 Updating config.yaml") + if err := flipLayoutInConfigReverse(); err != nil { + return err + } + + cliout.Step("✓ Regenerating Wire") + _ = os.Remove("app/di/wire_gen.go") + if err := runGoCommand("tool", "wire", "./app/di/"); err != nil { + return clierr.Wrap(clierr.CodeRefactorAborted, err, + "wire generation failed — inspect the partial state and `git restore` to revert") + } + cliout.Step("✓ Verifying go build ./...") + if err := runGoCommand("build", "./..."); err != nil { + return clierr.Wrap(clierr.CodeRefactorAborted, err, + "go build failed after unwind — inspect the partial state and `git restore` to revert") + } + + cliout.Success("Unwind complete — project is back to layered layout") + return nil +} + +// requireFeatureProject errors out if the project isn't currently in +// feature layout. +func requireFeatureProject() error { + v := configutil.ReadLayout() + if v != "feature" { + return clierr.New(clierr.CodeRefactorIneligible, + "project is not in feature-package layout — nothing to unwind") + } + return nil +} + +// resolveLayeredRevertResources resolves resources from CLI args or +// discovers them by walking app/ for feature-shaped directories. +func resolveLayeredRevertResources(args []string, allFlag bool) ([]featurize.Resource, error) { + if allFlag { + return discoverFeatureResources() + } + if len(args) == 0 { + return nil, clierr.New(clierr.CodeInvalidName, + "specify a resource name (e.g. `gofasta refactor layered User`) or use --all") + } + pascal := args[0] + snake := toSnakeCaseSimple(pascal) + plural := pluralizeSimple(pascal) + if _, err := os.Stat("app/" + snake); err != nil { + return nil, clierr.Newf(clierr.CodeRefactorResourceNotFound, + "feature %q not found (expected app/%s/)", pascal, snake) + } + return []featurize.Resource{{Name: pascal, Snake: snake, Plural: plural}}, nil +} + +// discoverFeatureResources walks app/ looking for per-resource feature +// directories. Skips shared dirs (di, jobs, tasks, graphql, main, +// devtools, shared, validators, rest, models, dtos). +func discoverFeatureResources() ([]featurize.Resource, error) { + entries, err := os.ReadDir("app") + if err != nil { + return nil, clierr.Wrap(clierr.CodeFileIO, err, "reading app/") + } + shared := map[string]bool{ + "di": true, "jobs": true, "tasks": true, "graphql": true, + "main": true, "devtools": true, "shared": true, "validators": true, + "rest": true, "models": true, "dtos": true, + } + var out []featurize.Resource + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if shared[name] { + continue + } + // Heuristic: feature dir contains a model.go or service.go file. + // Skipping is fine if absent — the user may have other dirs. + if _, err := os.Stat(filepath.Join("app", name, "service.go")); err != nil { + continue + } + pascal := toPascalCaseSimple(name) + out = append(out, featurize.Resource{ + Name: pascal, Snake: name, Plural: pluralizeSimple(pascal), + }) + } + if len(out) == 0 { + return nil, clierr.New(clierr.CodeRefactorResourceNotFound, + "no feature directories detected under app/") + } + return out, nil +} + +// revertResource unwinds one resource from feature layout to layered. +func revertResource(r featurize.Resource, mod string) (moved, patched []string, err error) { + for _, p := range featurize.ReversePerResourceMapping(r.Snake) { + content, readErr := os.ReadFile(p.Feature) + if readErr != nil { + continue + } + transformed, terr := featurize.TransformPerResourceReverse(content, p.Dest, featurize.Options{ + ModulePath: mod, Resource: r, + }) + if terr != nil { + return moved, patched, fmt.Errorf("featurize reverse %s: %w", p.Feature, terr) + } + if err := os.MkdirAll(filepath.Dir(p.Dest.Path), 0o755); err != nil { + return moved, patched, clierr.Wrap(clierr.CodeFileIO, err, "mkdir "+filepath.Dir(p.Dest.Path)) + } + if err := os.WriteFile(p.Dest.Path, transformed, 0o644); err != nil { + return moved, patched, clierr.Wrap(clierr.CodeFileIO, err, "writing "+p.Dest.Path) + } + if err := os.Remove(p.Feature); err != nil { + return moved, patched, clierr.Wrap(clierr.CodeFileIO, err, "removing "+p.Feature) + } + cliout.Path(p.Dest.Path) + moved = append(moved, p.Feature+" → "+p.Dest.Path) + } + + // Per-resource mocks: reverse-transform. + for _, suffix := range []string{"_repository_mock.go", "_service_mock.go"} { + mockPath := filepath.Join("testutil", "mocks", r.Snake+suffix) + content, readErr := os.ReadFile(mockPath) + if readErr != nil { + continue + } + transformed, terr := featurize.TransformMockReverse(content, mod, r) + if terr != nil { + return moved, patched, fmt.Errorf("featurize reverse %s: %w", mockPath, terr) + } + if err := os.WriteFile(mockPath, transformed, 0o644); err != nil { + return moved, patched, clierr.Wrap(clierr.CodeFileIO, err, "writing "+mockPath) + } + patched = append(patched, mockPath) + } + return moved, patched, nil +} + +// applySharedRelocationsReverse moves aliases.go back from +// app/shared/dtos/ to app/dtos/. +func applySharedRelocationsReverse() ([]string, error) { + var moved []string + for _, pair := range featurize.SharedRelocationsReverse() { + content, err := os.ReadFile(pair.Layered) + if err != nil { + continue + } + if err := os.MkdirAll(filepath.Dir(pair.Feature), 0o755); err != nil { + return moved, clierr.Wrap(clierr.CodeFileIO, err, "mkdir "+filepath.Dir(pair.Feature)) + } + if err := os.WriteFile(pair.Feature, content, 0o644); err != nil { + return moved, clierr.Wrap(clierr.CodeFileIO, err, "writing "+pair.Feature) + } + if err := os.Remove(pair.Layered); err != nil { + return moved, clierr.Wrap(clierr.CodeFileIO, err, "removing "+pair.Layered) + } + cliout.Path(pair.Feature) + moved = append(moved, pair.Layered+" → "+pair.Feature) + } + // Also prune app/shared/dtos/ + app/shared/ if empty. + _ = os.Remove("app/shared/dtos") + _ = os.Remove("app/shared") + return moved, nil +} + +// revertPasswordGenerator moves app/user/password_generator.go back to +// app/services/password_generator.go, restoring `package services`. +func revertPasswordGenerator() ([]string, error) { + const featurePath = "app/user/password_generator.go" + content, err := os.ReadFile(featurePath) + if err != nil { + return nil, nil + } + transformed, terr := featurize.TransformPerResourceReverse(content, + featurize.LayeredDestination{Path: "app/services/password_generator.go", PackageName: "services"}, + featurize.Options{ModulePath: "", Resource: featurize.Resource{Name: "User", Snake: "user", Plural: "Users"}}) + if terr != nil { + return nil, fmt.Errorf("featurize reverse password_generator.go: %w", terr) + } + const target = "app/services/password_generator.go" + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return nil, clierr.Wrap(clierr.CodeFileIO, err, "mkdir "+filepath.Dir(target)) + } + if err := os.WriteFile(target, transformed, 0o644); err != nil { + return nil, clierr.Wrap(clierr.CodeFileIO, err, "writing "+target) + } + if err := os.Remove(featurePath); err != nil { + return nil, clierr.Wrap(clierr.CodeFileIO, err, "removing "+featurePath) + } + cliout.Path(target) + return []string{featurePath + " → " + target}, nil +} + +// applyCrossCuttingPatchesReverse rewrites container/wire/index.routes/ +// core back to the layered shape, and flips the shared infra files' +// `/app/shared/dtos` imports back to `/app/dtos`. +// +//nolint:dupl // structurally mirrors applyCrossCuttingPatches but every step uses the *Reverse transformer + different dtos consumer list — merging would obscure direction. +func applyCrossCuttingPatchesReverse(mod string, resources []featurize.Resource) ([]string, error) { + var patched []string + type job struct { + path string + fn func([]byte, string, []featurize.Resource) ([]byte, error) + } + jobs := []job{ + {"app/di/container.go", featurize.TransformContainerReverse}, + {"app/di/wire.go", featurize.TransformWireReverse}, + {"app/rest/routes/index.routes.go", featurize.TransformIndexRoutesReverse}, + {"app/di/providers/core.go", featurize.TransformCoreProvidersReverse}, + } + for _, j := range jobs { + content, err := os.ReadFile(j.path) + if err != nil { + continue + } + out, terr := j.fn(content, mod, resources) + if terr != nil { + return patched, fmt.Errorf("transform reverse %s: %w", j.path, terr) + } + if err := os.WriteFile(j.path, out, 0o644); err != nil { + return patched, clierr.Wrap(clierr.CodeFileIO, err, "writing "+j.path) + } + patched = append(patched, j.path) + } + + dtosImportConsumers := []string{ + "app/validators/app_validator.go", + "app/rest/controllers/validator.go", + "app/graphql/resolvers/user.resolvers.go", + } + for _, path := range dtosImportConsumers { + content, err := os.ReadFile(path) + if err != nil { + continue + } + out, terr := featurize.FixSharedDtosImportPathReverse(content, mod) + if terr != nil { + return patched, fmt.Errorf("fix shared dtos import reverse %s: %w", path, terr) + } + if err := os.WriteFile(path, out, 0o644); err != nil { + return patched, clierr.Wrap(clierr.CodeFileIO, err, "writing "+path) + } + patched = append(patched, path) + } + + return patched, nil +} + +// pruneEmptyFeatureDirs removes per-feature directories that are +// empty after the unwind (e.g. app/user/ once every file has moved). +func pruneEmptyFeatureDirs(resources []featurize.Resource) { + for _, r := range resources { + dir := "app/" + r.Snake + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + if len(entries) == 0 { + _ = os.Remove(dir) + } + } +} + +// flipLayoutInConfigReverse rewrites project.layout: feature → +// project.layout: layered in config.yaml. +func flipLayoutInConfigReverse() error { + const path = "config.yaml" + content, err := os.ReadFile(path) + if err != nil { + return clierr.Wrap(clierr.CodeFileIO, err, "reading "+path) + } + s := string(content) + s = strings.Replace(s, "layout: feature", "layout: layered", 1) + return os.WriteFile(path, []byte(s), 0o644) +} + +// requireLayeredProject errors out if the project isn't currently in +// layered layout (nothing to migrate). +func requireLayeredProject() error { + v := configutil.ReadLayout() + if v == "feature" { + return clierr.New(clierr.CodeRefactorIneligible, + "project is already feature-package layout — nothing to migrate") + } + // Empty or "layered" is the migrate-eligible case. Also do a + // filesystem sanity check. + if _, err := os.Stat("app/models"); err != nil { + return clierr.Newf(clierr.CodeRefactorIneligible, + "app/models/ not found — not in a layered gofasta project") + } + return nil +} + +// requireCleanGitTree errors out if the working tree has uncommitted +// changes. Skipped by --force. +func requireCleanGitTree() error { + cmd := exec.Command("git", "status", "--porcelain") + out, err := cmd.Output() + if err != nil { + // No git repo, or git not installed — be permissive. + return nil + } + if strings.TrimSpace(string(out)) != "" { + return clierr.New(clierr.CodeRefactorDirtyTree, + "working tree has uncommitted changes — commit/stash first, or pass --force to proceed anyway") + } + return nil +} + +// resolveRefactorResources resolves the list of resources to migrate +// from the CLI args and the --all flag. +func resolveRefactorResources(args []string, allFlag bool) ([]featurize.Resource, error) { + if allFlag { + return discoverResourcesFromModels() + } + if len(args) == 0 { + return nil, clierr.New(clierr.CodeInvalidName, + "specify a resource name (e.g. `gofasta refactor feature-package User`) or use --all") + } + pascal := args[0] + snake := toSnakeCaseSimple(pascal) + plural := pluralizeSimple(pascal) + // Verify the layered model file exists for this resource. + modelPath := fmt.Sprintf("app/models/%s.model.go", snake) + if _, err := os.Stat(modelPath); err != nil { + return nil, clierr.Newf(clierr.CodeRefactorResourceNotFound, + "resource %q not found (expected %s)", pascal, modelPath) + } + return []featurize.Resource{{Name: pascal, Snake: snake, Plural: plural}}, nil +} + +// discoverResourcesFromModels walks app/models/ to find every layered +// resource by its model file naming convention (.model.go). +func discoverResourcesFromModels() ([]featurize.Resource, error) { + entries, err := os.ReadDir("app/models") + if err != nil { + return nil, clierr.Wrap(clierr.CodeFileIO, err, "reading app/models") + } + var out []featurize.Resource + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".model.go") { + continue + } + snake := strings.TrimSuffix(e.Name(), ".model.go") + pascal := toPascalCaseSimple(snake) + plural := pluralizeSimple(pascal) + out = append(out, featurize.Resource{Name: pascal, Snake: snake, Plural: plural}) + } + if len(out) == 0 { + return nil, clierr.New(clierr.CodeRefactorResourceNotFound, + "no layered resources detected in app/models/") + } + return out, nil +} + +// migrateResource performs the file moves + featurize transforms for +// a single resource. Returns the lists of moved/patched paths. +func migrateResource(r featurize.Resource, mod string, allResources []featurize.Resource) (moved, patched []string, err error) { + _ = allResources + for _, pair := range featurize.PerResourceMapping(r.Snake) { + content, readErr := os.ReadFile(pair.Layered) + if readErr != nil { + // Layered file may not exist (e.g. some resources don't + // have a test file). Skip silently. + continue + } + transformed, terr := featurize.TransformPerResource(content, featurize.Options{ + ModulePath: mod, Resource: r, + }) + if terr != nil { + return moved, patched, fmt.Errorf("featurize %s: %w", pair.Layered, terr) + } + if err := os.MkdirAll(filepath.Dir(pair.Feature), 0o755); err != nil { + return moved, patched, clierr.Wrap(clierr.CodeFileIO, err, "mkdir "+filepath.Dir(pair.Feature)) + } + if err := os.WriteFile(pair.Feature, transformed, 0o644); err != nil { + return moved, patched, clierr.Wrap(clierr.CodeFileIO, err, "writing "+pair.Feature) + } + if err := os.Remove(pair.Layered); err != nil { + return moved, patched, clierr.Wrap(clierr.CodeFileIO, err, "removing "+pair.Layered) + } + cliout.Path(pair.Feature) + moved = append(moved, pair.Layered+" → "+pair.Feature) + } + + // Per-resource mocks under testutil/mocks/. + for _, suffix := range []string{"_repository_mock.go", "_service_mock.go"} { + mockPath := filepath.Join("testutil", "mocks", r.Snake+suffix) + content, readErr := os.ReadFile(mockPath) + if readErr != nil { + continue + } + transformed, terr := featurize.TransformMock(content, mod, r) + if terr != nil { + return moved, patched, fmt.Errorf("featurize %s: %w", mockPath, terr) + } + if err := os.WriteFile(mockPath, transformed, 0o644); err != nil { + return moved, patched, clierr.Wrap(clierr.CodeFileIO, err, "writing "+mockPath) + } + patched = append(patched, mockPath) + } + return moved, patched, nil +} + +// relocatePasswordGenerator moves app/services/password_generator.go +// into the user feature package (app/user/password_generator.go), +// rewriting `package services` → `package user`. The file's external +// references (`utils.GeneratePassword`) survive unchanged. +func relocatePasswordGenerator(mod string, resources []featurize.Resource) ([]string, error) { + const layered = "app/services/password_generator.go" + content, err := os.ReadFile(layered) + if err != nil { + return nil, nil // already moved or never existed + } + // Find the user resource. If absent, leave the file where it is + // (the package will just be a tiny one-file layered remnant). + var user *featurize.Resource + for i := range resources { + if resources[i].Snake == "user" { + user = &resources[i] + break + } + } + if user == nil { + return nil, nil + } + transformed, terr := featurize.TransformPerResource(content, featurize.Options{ + ModulePath: mod, Resource: *user, + }) + if terr != nil { + return nil, fmt.Errorf("featurize password_generator.go: %w", terr) + } + target := "app/user/password_generator.go" + if err := os.WriteFile(target, transformed, 0o644); err != nil { + return nil, clierr.Wrap(clierr.CodeFileIO, err, "writing "+target) + } + if err := os.Remove(layered); err != nil { + return nil, clierr.Wrap(clierr.CodeFileIO, err, "removing "+layered) + } + cliout.Path(target) + return []string{layered + " → " + target}, nil +} + +// pruneEmptyLayeredDirs removes layered directories that have no Go +// files left after the refactor. Best-effort — silently skips dirs +// that still contain files (e.g. `app/rest/routes/` still has +// `index.routes.go`, `app/services/interfaces/` is empty if every +// resource migrated). +func pruneEmptyLayeredDirs() { + candidates := []string{ + "app/services/interfaces", + "app/services", + "app/repositories/interfaces", + "app/repositories", + "app/rest/controllers", + "app/di/providers", + "app/dtos", + } + for _, dir := range candidates { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + // Special case: app/dtos may still hold non-resource files + // (like aliases.go) — but those moved to app/shared/dtos/. If + // only the directory itself is left, remove it. + if len(entries) == 0 { + _ = os.Remove(dir) + } + } +} + +// applySharedRelocations moves files that only relocate path +// (aliases.go → app/shared/dtos/aliases.go) without source rewrites. +func applySharedRelocations() ([]string, error) { + var moved []string + for _, pair := range featurize.SharedRelocations() { + content, err := os.ReadFile(pair.Layered) + if err != nil { + continue + } + if err := os.MkdirAll(filepath.Dir(pair.Feature), 0o755); err != nil { + return moved, clierr.Wrap(clierr.CodeFileIO, err, "mkdir "+filepath.Dir(pair.Feature)) + } + if err := os.WriteFile(pair.Feature, content, 0o644); err != nil { + return moved, clierr.Wrap(clierr.CodeFileIO, err, "writing "+pair.Feature) + } + if err := os.Remove(pair.Layered); err != nil { + return moved, clierr.Wrap(clierr.CodeFileIO, err, "removing "+pair.Layered) + } + cliout.Path(pair.Feature) + moved = append(moved, pair.Layered+" → "+pair.Feature) + } + return moved, nil +} + +// applyCrossCuttingPatches rewrites the cross-cutting files +// (container.go, wire.go, index.routes.go, core.go) and the shared +// infra files (app_validator.go, validator.go) that need import-path +// updates after the relocations. +// +//nolint:dupl // structurally mirrors applyCrossCuttingPatchesReverse but uses the forward transformer — merging would obscure direction. +func applyCrossCuttingPatches(mod string, resources []featurize.Resource) ([]string, error) { + var patched []string + type job struct { + path string + fn func([]byte, string, []featurize.Resource) ([]byte, error) + } + jobs := []job{ + {"app/di/container.go", featurize.TransformContainer}, + {"app/di/wire.go", featurize.TransformWire}, + {"app/rest/routes/index.routes.go", featurize.TransformIndexRoutes}, + {"app/di/providers/core.go", featurize.TransformCoreProviders}, + } + for _, j := range jobs { + content, err := os.ReadFile(j.path) + if err != nil { + continue + } + out, terr := j.fn(content, mod, resources) + if terr != nil { + return patched, fmt.Errorf("transform %s: %w", j.path, terr) + } + if err := os.WriteFile(j.path, out, 0o644); err != nil { + return patched, clierr.Wrap(clierr.CodeFileIO, err, "writing "+j.path) + } + patched = append(patched, j.path) + } + + // Files that only need their `/app/dtos` import flipped to + // `/app/shared/dtos` — no other source rewrites. + dtosImportConsumers := []string{ + "app/validators/app_validator.go", + "app/rest/controllers/validator.go", + "app/graphql/resolvers/user.resolvers.go", + } + for _, path := range dtosImportConsumers { + content, err := os.ReadFile(path) + if err != nil { + continue + } + out, terr := featurize.FixDtosImportPath(content, mod) + if terr != nil { + return patched, fmt.Errorf("fix dtos import %s: %w", path, terr) + } + if err := os.WriteFile(path, out, 0o644); err != nil { + return patched, clierr.Wrap(clierr.CodeFileIO, err, "writing "+path) + } + patched = append(patched, path) + } + + return patched, nil +} + +// flipLayoutInConfig rewrites `project: layout: layered` (or adds it +// if missing) to `project: layout: feature` in config.yaml. +func flipLayoutInConfig() error { + const path = "config.yaml" + content, err := os.ReadFile(path) + if err != nil { + return clierr.Wrap(clierr.CodeFileIO, err, "reading "+path) + } + s := string(content) + switch { + case strings.Contains(s, "layout: layered"): + s = strings.Replace(s, "layout: layered", "layout: feature", 1) + case strings.Contains(s, "layout: feature"): + // already correct + default: + // No project block — prepend one. + s = "project:\n layout: feature\n\n" + s + } + return os.WriteFile(path, []byte(s), 0o644) +} + +// runGoCommand invokes `go ` in the current working directory, +// streaming output to stdout/stderr. +func runGoCommand(args ...string) error { + cmd := exec.Command("go", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// readModulePath reads the module path from go.mod in the current +// working directory. +func readModulePath() (string, error) { + data, err := os.ReadFile("go.mod") + if err != nil { + return "", clierr.Wrap(clierr.CodeNotGofastaProject, err, "reading go.mod") + } + for line := range strings.SplitSeq(string(data), "\n") { + line = strings.TrimSpace(line) + if after, ok := strings.CutPrefix(line, "module "); ok { + return strings.TrimSpace(after), nil + } + } + return "", clierr.New(clierr.CodeNotGofastaProject, "go.mod has no module directive") +} + +// toSnakeCaseSimple is a minimal PascalCase → snake_case for the +// refactor command's resource-name resolution. Mirrors the convention +// the scaffold uses: word boundaries on capital letters. +func toSnakeCaseSimple(s string) string { + var b strings.Builder + for i, r := range s { + if i > 0 && r >= 'A' && r <= 'Z' { + b.WriteByte('_') + } + if r >= 'A' && r <= 'Z' { + r += 'a' - 'A' + } + b.WriteRune(r) + } + return b.String() +} + +// toPascalCaseSimple converts snake_case → PascalCase. +func toPascalCaseSimple(s string) string { + parts := strings.Split(s, "_") + var b strings.Builder + for _, p := range parts { + if p == "" { + continue + } + first := p[0] + if first >= 'a' && first <= 'z' { + first -= 'a' - 'A' + } + b.WriteByte(first) + b.WriteString(p[1:]) + } + return b.String() +} + +// pluralizeSimple appends "s" to a PascalCase name. Sufficient for +// most English plurals the scaffold targets; complex rules (woman → +// women) aren't supported and aren't needed for the refactor flow. +func pluralizeSimple(s string) string { + switch { + case strings.HasSuffix(s, "y") && len(s) > 1 && !isVowel(rune(s[len(s)-2])): + return s[:len(s)-1] + "ies" + case strings.HasSuffix(s, "s"), strings.HasSuffix(s, "x"), strings.HasSuffix(s, "z"), + strings.HasSuffix(s, "ch"), strings.HasSuffix(s, "sh"): + return s + "es" + default: + return s + "s" + } +} + +func isVowel(r rune) bool { + switch r { + case 'a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U': + return true + } + return false +} diff --git a/internal/featurize/ast.go b/internal/featurize/ast.go new file mode 100644 index 0000000..1364b5d --- /dev/null +++ b/internal/featurize/ast.go @@ -0,0 +1,547 @@ +// Package featurize — AST manipulation helpers. +// +// Two layers of utilities: +// +// 1. Import block: ensureImport, dropImportPath, importAlias, +// importAliasForPath, aliasReferenced. Used by every transformer. +// +// 2. Generic walker: applyCursor + applyOn* recursive descent over +// dst.File / dst.Decl / dst.Stmt / dst.Expr. The walker exists +// because dst.Inspect doesn't give parent context — callers need +// to replace a Node, not just inspect it. +// +// 3. Selector/call rewrite primitives: rewriteSelectors, rewriteCallNames, +// mergeRewrites. Consumed by the cross-cutting and reverse engines. +// +// Plus renderFile (gofmt-aware dst → []byte). + +package featurize + +import ( + "bytes" + "fmt" + "go/format" + "go/token" + "maps" + "strings" + + "github.com/dave/dst" + "github.com/dave/dst/decorator" +) + +// ensureImport adds an import with optional alias if it isn't already +// present in the file. +func ensureImport(file *dst.File, path, alias string) { + for _, imp := range file.Imports { + if strings.Trim(imp.Path.Value, `"`) == path { + return + } + } + spec := &dst.ImportSpec{ + Path: &dst.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", path)}, + } + if alias != "" { + spec.Name = &dst.Ident{Name: alias} + } + file.Imports = append(file.Imports, spec) + for _, decl := range file.Decls { + gd, ok := decl.(*dst.GenDecl) + if !ok || gd.Tok != token.IMPORT { + continue + } + gd.Specs = append(gd.Specs, spec) + return + } + file.Decls = append([]dst.Decl{&dst.GenDecl{ + Tok: token.IMPORT, Specs: []dst.Spec{spec}, Lparen: true, Rparen: true, + }}, file.Decls...) +} + +// importAlias returns the local name an import is referenced by inside +// the file — the explicit alias when one is set, otherwise the last +// segment of the path. Mirrors how Go's compiler resolves identifiers. +func importAlias(imp *dst.ImportSpec, path string) string { + if imp.Name != nil && imp.Name.Name != "" && imp.Name.Name != "_" { + return imp.Name.Name + } + idx := strings.LastIndex(path, "/") + if idx == -1 { + return path + } + return path[idx+1:] +} + +// renderFile restores a dst.File to []byte and runs gofmt. Mirrors +// astpatch.Render's contract but inlined to keep this package free of +// the astpatch dependency. +func renderFile(file *dst.File) ([]byte, error) { + var buf bytes.Buffer + if err := decorator.NewRestorer().Fprint(&buf, file); err != nil { + return nil, fmt.Errorf("featurize: restore: %w", err) + } + out, err := format.Source(buf.Bytes()) + if err != nil { + // Return un-formatted source rather than failing — downstream + // callers can still inspect the output, and the failure is + // usually a transient gofmt input quirk. + return buf.Bytes(), nil //nolint:nilerr // intentional fallback + } + return out, nil +} + +// importAliasForPath returns the alias used to reference the given +// import path in the file — the explicit alias when one is set, +// otherwise the path's last segment. Returns "" if the import is not +// present. +func importAliasForPath(file *dst.File, path string) string { + for _, imp := range file.Imports { + if strings.Trim(imp.Path.Value, `"`) != path { + continue + } + return importAlias(imp, path) + } + return "" +} + +// dropImportPath removes an import entry by path from both file.Imports +func dropImportPath(file *dst.File, path string) { + keptImports := make([]*dst.ImportSpec, 0, len(file.Imports)) + for _, imp := range file.Imports { + if strings.Trim(imp.Path.Value, `"`) == path { + continue + } + keptImports = append(keptImports, imp) + } + file.Imports = keptImports + for _, decl := range file.Decls { + gd, ok := decl.(*dst.GenDecl) + if !ok || gd.Tok != token.IMPORT { + continue + } + kept := make([]dst.Spec, 0, len(gd.Specs)) + for _, spec := range gd.Specs { + is, ok := spec.(*dst.ImportSpec) + if !ok { + kept = append(kept, spec) + continue + } + if strings.Trim(is.Path.Value, `"`) == path { + continue + } + kept = append(kept, is) + } + gd.Specs = kept + } +} + +// aliasReferenced reports whether any SelectorExpr in the file has its +func aliasReferenced(file *dst.File, alias string) bool { + found := false + dst.Inspect(file, func(n dst.Node) bool { + sel, ok := n.(*dst.SelectorExpr) + if !ok { + return true + } + ident, ok := sel.X.(*dst.Ident) + if !ok { + return true + } + if ident.Name == alias { + found = true + return false + } + return true + }) + return found +} +func mergeRewrites(a, b map[string]string) map[string]string { + if len(a) == 0 { + return b + } + if len(b) == 0 { + return a + } + out := make(map[string]string, len(a)+len(b)) + maps.Copy(out, a) + maps.Copy(out, b) + return out +} + +// rewriteSelectors walks the file and rewrites every SelectorExpr that +// matches "X.Y" in the rewrites map. The replacement is itself a +func rewriteSelectors(file *dst.File, rewrites map[string]string) { + applyOnFile(file, func(c *applyCursor) bool { + sel, ok := c.Node.(*dst.SelectorExpr) + if !ok { + return true + } + ident, ok := sel.X.(*dst.Ident) + if !ok { + return true + } + key := ident.Name + "." + sel.Sel.Name + repl, ok := rewrites[key] + if !ok { + return true + } + dot := strings.LastIndex(repl, ".") + if dot == -1 { + c.Replace(&dst.Ident{Name: repl}) + return true + } + c.Replace(&dst.SelectorExpr{ + X: &dst.Ident{Name: repl[:dot]}, + Sel: &dst.Ident{Name: repl[dot+1:]}, + }) + return true + }) +} + +// rewriteCallNames walks the file and rewrites every bare Ident that +// matches the rewrites map and is in CALLEE position +// (i.e. CallExpr.Fun) into a SelectorExpr. Used for the index.routes +func rewriteCallNames(file *dst.File, rewrites map[string]string) { + applyOnFile(file, func(c *applyCursor) bool { + call, ok := c.Node.(*dst.CallExpr) + if !ok { + return true + } + ident, ok := call.Fun.(*dst.Ident) + if !ok { + return true + } + repl, ok := rewrites[ident.Name] + if !ok { + return true + } + dot := strings.LastIndex(repl, ".") + if dot == -1 { + call.Fun = &dst.Ident{Name: repl} + return true + } + call.Fun = &dst.SelectorExpr{ + X: &dst.Ident{Name: repl[:dot]}, + Sel: &dst.Ident{Name: repl[dot+1:]}, + } + return true + }) +} + +// ---------- dstutil.Apply mini-wrapper ---------- +// +// dst doesn't ship dstutil.Apply (yet) — github.com/dave/dst/dstutil +// exists but the parent-context cursor isn't part of dst v0.27.3. +// Instead we use a hand-rolled walker that gives us a Replace seam at +// every Expr container. + +type applyCursor struct { + Node dst.Node + replace func(dst.Node) +} + +// Replace substitutes the current node with the given node via the +// cursor's parent-aware setter. +func (c *applyCursor) Replace(n dst.Node) { + if c.replace != nil { + c.replace(n) + } +} + +// applyOnFile walks every meaningful node in the file (declarations, +// expressions, statements) and invokes fn with a cursor at each. +// Replacements landed via cursor.Replace are written back at the +// parent slot they belong to. +// +// Coverage: function declarations + their bodies, top-level var/const +// initializer expressions, struct field types, import specs. Anything +// useful for featurize. Doesn't try to cover every dst node shape — +func applyOnFile(file *dst.File, fn func(*applyCursor) bool) { + for _, decl := range file.Decls { + applyOnDecl(decl, fn) + } +} + +func applyOnDecl(decl dst.Decl, fn func(*applyCursor) bool) { + switch d := decl.(type) { + case *dst.FuncDecl: + if d.Type != nil { + applyOnFuncType(d.Type, fn) + } + if d.Body != nil { + applyOnStmtList(d.Body.List, fn) + } + case *dst.GenDecl: + for _, spec := range d.Specs { + applyOnSpec(spec, fn) + } + } +} + +func applyOnSpec(spec dst.Spec, fn func(*applyCursor) bool) { + switch s := spec.(type) { + case *dst.ValueSpec: + for i := range s.Values { + applyOnExpr(&s.Values[i], fn) + } + for i := range s.Names { + _ = s.Names[i] // identifiers aren't selectors + } + if s.Type != nil { + applyOnExpr(&s.Type, fn) + } + case *dst.TypeSpec: + if s.Type != nil { + applyOnExpr(&s.Type, fn) + } + } +} + +func applyOnFuncType(ft *dst.FuncType, fn func(*applyCursor) bool) { + if ft.Params != nil { + applyOnFieldList(ft.Params, fn) + } + if ft.Results != nil { + applyOnFieldList(ft.Results, fn) + } +} + +func applyOnFieldList(fl *dst.FieldList, fn func(*applyCursor) bool) { + for _, f := range fl.List { + if f.Type != nil { + applyOnExpr(&f.Type, fn) + } + } +} + +func applyOnStmtList(stmts []dst.Stmt, fn func(*applyCursor) bool) { + for i := range stmts { + applyOnStmt(&stmts[i], fn) + } +} + +//nolint:gocognit,gocyclo,gocritic // ptrToRefParam: the *dst.Stmt is intentional so callers can replace the statement in-place via *s = ... +func applyOnStmt(s *dst.Stmt, fn func(*applyCursor) bool) { + switch st := (*s).(type) { + case *dst.ExprStmt: + applyOnExpr(&st.X, fn) + case *dst.AssignStmt: + for i := range st.Lhs { + applyOnExpr(&st.Lhs[i], fn) + } + for i := range st.Rhs { + applyOnExpr(&st.Rhs[i], fn) + } + case *dst.ReturnStmt: + for i := range st.Results { + applyOnExpr(&st.Results[i], fn) + } + case *dst.IfStmt: + if st.Init != nil { + applyOnStmt(&st.Init, fn) + } + if st.Cond != nil { + applyOnExpr(&st.Cond, fn) + } + if st.Body != nil { + applyOnStmtList(st.Body.List, fn) + } + if st.Else != nil { + applyOnStmt(&st.Else, fn) + } + case *dst.ForStmt: + if st.Init != nil { + applyOnStmt(&st.Init, fn) + } + if st.Cond != nil { + applyOnExpr(&st.Cond, fn) + } + if st.Post != nil { + applyOnStmt(&st.Post, fn) + } + if st.Body != nil { + applyOnStmtList(st.Body.List, fn) + } + case *dst.RangeStmt: + if st.Key != nil { + applyOnExpr(&st.Key, fn) + } + if st.Value != nil { + applyOnExpr(&st.Value, fn) + } + if st.X != nil { + applyOnExpr(&st.X, fn) + } + if st.Body != nil { + applyOnStmtList(st.Body.List, fn) + } + case *dst.SwitchStmt: + if st.Init != nil { + applyOnStmt(&st.Init, fn) + } + if st.Tag != nil { + applyOnExpr(&st.Tag, fn) + } + if st.Body != nil { + for _, c := range st.Body.List { + if cc, ok := c.(*dst.CaseClause); ok { + for i := range cc.List { + applyOnExpr(&cc.List[i], fn) + } + applyOnStmtList(cc.Body, fn) + } + } + } + case *dst.TypeSwitchStmt: + if st.Init != nil { + applyOnStmt(&st.Init, fn) + } + if st.Assign != nil { + applyOnStmt(&st.Assign, fn) + } + if st.Body != nil { + for _, c := range st.Body.List { + if cc, ok := c.(*dst.CaseClause); ok { + for i := range cc.List { + applyOnExpr(&cc.List[i], fn) + } + applyOnStmtList(cc.Body, fn) + } + } + } + case *dst.BlockStmt: + applyOnStmtList(st.List, fn) + case *dst.DeferStmt: + if st.Call != nil { + expr := dst.Expr(st.Call) + applyOnExpr(&expr, fn) + st.Call = expr.(*dst.CallExpr) + } + case *dst.GoStmt: + if st.Call != nil { + expr := dst.Expr(st.Call) + applyOnExpr(&expr, fn) + st.Call = expr.(*dst.CallExpr) + } + case *dst.DeclStmt: + if st.Decl != nil { + applyOnDecl(st.Decl, fn) + } + case *dst.SendStmt: + applyOnExpr(&st.Chan, fn) + applyOnExpr(&st.Value, fn) + case *dst.IncDecStmt: + applyOnExpr(&st.X, fn) + case *dst.LabeledStmt: + applyOnStmt(&st.Stmt, fn) + case *dst.SelectStmt: + if st.Body != nil { + for _, c := range st.Body.List { + if cc, ok := c.(*dst.CommClause); ok { + if cc.Comm != nil { + applyOnStmt(&cc.Comm, fn) + } + applyOnStmtList(cc.Body, fn) + } + } + } + } +} + +//nolint:gocognit,gocyclo,gocritic // ptrToRefParam: the *dst.Expr is intentional so callers can replace the expression in-place via *e = ... +func applyOnExpr(e *dst.Expr, fn func(*applyCursor) bool) { + if *e == nil { + return + } + // Recurse into children first (post-order rewrite). + switch n := (*e).(type) { + case *dst.SelectorExpr: + applyOnExpr(&n.X, fn) + case *dst.CallExpr: + applyOnExpr(&n.Fun, fn) + for i := range n.Args { + applyOnExpr(&n.Args[i], fn) + } + case *dst.UnaryExpr: + applyOnExpr(&n.X, fn) + case *dst.BinaryExpr: + applyOnExpr(&n.X, fn) + applyOnExpr(&n.Y, fn) + case *dst.IndexExpr: + applyOnExpr(&n.X, fn) + applyOnExpr(&n.Index, fn) + case *dst.SliceExpr: + applyOnExpr(&n.X, fn) + if n.Low != nil { + applyOnExpr(&n.Low, fn) + } + if n.High != nil { + applyOnExpr(&n.High, fn) + } + if n.Max != nil { + applyOnExpr(&n.Max, fn) + } + case *dst.TypeAssertExpr: + applyOnExpr(&n.X, fn) + if n.Type != nil { + applyOnExpr(&n.Type, fn) + } + case *dst.ParenExpr: + applyOnExpr(&n.X, fn) + case *dst.StarExpr: + applyOnExpr(&n.X, fn) + case *dst.CompositeLit: + if n.Type != nil { + applyOnExpr(&n.Type, fn) + } + for i := range n.Elts { + applyOnExpr(&n.Elts[i], fn) + } + case *dst.KeyValueExpr: + // Skip walking a bare-Ident Key — in struct literals the Key + // is a field NAME, not an identifier reference. Rewriting it + // would emit `dtos.SortOrientation: &asc` which is invalid + // (`invalid field name X.Y in struct literal`). + if _, isIdent := n.Key.(*dst.Ident); !isIdent { + applyOnExpr(&n.Key, fn) + } + applyOnExpr(&n.Value, fn) + case *dst.FuncLit: + if n.Type != nil { + applyOnFuncType(n.Type, fn) + } + if n.Body != nil { + applyOnStmtList(n.Body.List, fn) + } + case *dst.ArrayType: + if n.Len != nil { + applyOnExpr(&n.Len, fn) + } + applyOnExpr(&n.Elt, fn) + case *dst.MapType: + applyOnExpr(&n.Key, fn) + applyOnExpr(&n.Value, fn) + case *dst.ChanType: + applyOnExpr(&n.Value, fn) + case *dst.StructType: + if n.Fields != nil { + applyOnFieldList(n.Fields, fn) + } + case *dst.InterfaceType: + if n.Methods != nil { + for _, m := range n.Methods.List { + if m.Type != nil { + applyOnExpr(&m.Type, fn) + } + } + } + case *dst.FuncType: + applyOnFuncType(n, fn) + } + // Visit the current node. + c := &applyCursor{Node: *e, replace: func(n dst.Node) { + if expr, ok := n.(dst.Expr); ok { + *e = expr + } + }} + fn(c) +} diff --git a/internal/featurize/crosscutting.go b/internal/featurize/crosscutting.go new file mode 100644 index 0000000..7eb0ec8 --- /dev/null +++ b/internal/featurize/crosscutting.go @@ -0,0 +1,429 @@ +// Package featurize — cross-cutting file transformations. +// +// container.go / wire.go / app/rest/routes/index.routes.go / +// app/di/providers/core.go are NOT per-resource files — they're +// shared infrastructure that knows about every resource. They get a +// different transformer than TransformPerResource: only their import +// shape + per-resource selector/field/call references change. +// +// The transformCrossCutting engine at the bottom of this file is the +// shared driver — each forward/reverse Transform{X} function builds +// a crossCuttingOptions struct (drop imports, add imports, rewrite +// selectors, rewrite calls) and hands it off. + +package featurize + +import ( + "fmt" + "go/token" + "strings" + + "github.com/dave/dst/decorator" +) + +// ---------- Cross-cutting per-layout file transformations ---------- + +// TransformContainer rewrites app/di/container.go: replaces the +// layered cross-package imports (repoInterfaces, svcInterfaces, +// controllers) with per-feature alias imports, and rewrites every +// resource's field types to point at the feature packages. +func TransformContainer(src []byte, mod string, resources []Resource) ([]byte, error) { + return transformCrossCutting(src, mod, resources, crossCuttingOptions{ + dropImports: []string{ + mod + "/app/repositories/interfaces", + mod + "/app/services/interfaces", + mod + "/app/rest/controllers", + }, + addImports: perFeatureImports(mod, resources), + fieldRewrites: containerFieldRewrites(resources), + }) +} + +// TransformWire rewrites app/di/wire.go: replaces +// `providers.Set` with `pkg.Set` and updates the import. +func TransformWire(src []byte, mod string, resources []Resource) ([]byte, error) { + return transformCrossCutting(src, mod, resources, crossCuttingOptions{ + dropImports: []string{mod + "/app/di/providers"}, + addImports: perFeatureImports(mod, resources), + selectorSwaps: wireSelectorSwaps(resources), + }) +} + +// TransformIndexRoutes rewrites app/rest/routes/index.routes.go: +// replaces `Routes(api, ...)` with `pkg.RegisterRoutes(api, ...)` +// and updates the controller field types in RouteConfig. +func TransformIndexRoutes(src []byte, mod string, resources []Resource) ([]byte, error) { + return transformCrossCutting(src, mod, resources, crossCuttingOptions{ + dropImports: []string{mod + "/app/rest/controllers"}, + addImports: perFeatureImports(mod, resources), + fieldRewrites: indexRoutesFieldRewrites(resources), + callRewrites: indexRoutesCallRewrites(resources), + }) +} + +// TransformResourceDTO rewrites a per-resource dtos file (e.g. +// app/dtos/user.dtos.go) which STAYS in the dtos package in feature +// layout. The file imports the model and service packages — those have +// moved to the feature package, so the imports and selectors need to +// flip: +// +// models.User → userpkg.User +// services.CreateUserInput → userpkg.CreateUserInput +// services.UpdateUserPatch → userpkg.UpdateUserPatch +// services.ListUsersFilter → userpkg.ListUsersFilter +// +// Same applies to validator files staying in app/validators/ that +// reference per-feature types (rare in the user scaffold but possible +// for resources that build cross-resource validators). +func TransformResourceDTO(src []byte, mod string, resource Resource) ([]byte, error) { + alias := resource.Snake + "pkg" + return transformCrossCutting(src, mod, []Resource{resource}, crossCuttingOptions{ + dropImports: []string{ + mod + "/app/models", + mod + "/app/services", + }, + addImports: []importSpec{ + {alias: alias, path: mod + "/app/" + resource.Snake}, + }, + selectorSwaps: map[string]string{ + "models." + resource.Name: alias + "." + resource.Name, + "services.Create" + resource.Name + "Input": alias + ".Create" + resource.Name + "Input", + "services.Update" + resource.Name + "Patch": alias + ".Update" + resource.Name + "Patch", + "services.List" + resource.Plural + "Filter": alias + ".List" + resource.Plural + "Filter", + }, + }) +} + +// TransformContainerReverse rewrites app/di/container.go from feature +// layout (per-feature alias imports) back to layered (repoInterfaces, +// svcInterfaces, controllers). +func TransformContainerReverse(src []byte, mod string, resources []Resource) ([]byte, error) { + dropImports := make([]string, 0, len(resources)) + swaps := map[string]string{} + for _, r := range resources { + alias := r.Snake + "pkg" + dropImports = append(dropImports, mod+"/app/"+r.Snake) + swaps[alias+"."+r.Name+"RepositoryInterface"] = "repoInterfaces." + r.Name + "RepositoryInterface" + swaps[alias+"."+r.Name+"ServiceInterface"] = "svcInterfaces." + r.Name + "ServiceInterface" + swaps[alias+"."+r.Name+"Controller"] = "controllers." + r.Name + "Controller" + } + return transformCrossCutting(src, mod, resources, crossCuttingOptions{ + dropImports: dropImports, + addImports: []importSpec{ + {alias: "repoInterfaces", path: mod + "/app/repositories/interfaces"}, + {alias: "svcInterfaces", path: mod + "/app/services/interfaces"}, + {alias: "", path: mod + "/app/rest/controllers"}, + }, + fieldRewrites: swaps, + }) +} + +// TransformWireReverse rewrites app/di/wire.go back to layered: +// `pkg.UserSet` → `providers.UserSet`. The providers import +// is added (or kept — it might already be there for CoreSet). +func TransformWireReverse(src []byte, mod string, resources []Resource) ([]byte, error) { + dropImports := make([]string, 0, len(resources)) + swaps := map[string]string{} + for _, r := range resources { + alias := r.Snake + "pkg" + dropImports = append(dropImports, mod+"/app/"+r.Snake) + swaps[alias+"."+r.Name+"Set"] = "providers." + r.Name + "Set" + } + return transformCrossCutting(src, mod, resources, crossCuttingOptions{ + dropImports: dropImports, + addImports: []importSpec{ + {alias: "", path: mod + "/app/di/providers"}, + }, + selectorSwaps: swaps, + }) +} + +// TransformIndexRoutesReverse rewrites app/rest/routes/index.routes.go +// back to layered. `pkg.RegisterRoutes(...)` becomes +// `Routes(...)`, and `*pkg.UserController` fields become +// `*controllers.UserController`. +func TransformIndexRoutesReverse(src []byte, mod string, resources []Resource) ([]byte, error) { + dropImports := make([]string, 0, len(resources)) + fieldSwaps := map[string]string{} + callSwaps := map[string]string{} + for _, r := range resources { + alias := r.Snake + "pkg" + dropImports = append(dropImports, mod+"/app/"+r.Snake) + fieldSwaps[alias+"."+r.Name+"Controller"] = "controllers." + r.Name + "Controller" + // Reverse the routes call: pkg.RegisterRoutes → Routes + // This is a SelectorExpr swap where the result is a bare Ident. + // Use selectorSwaps with a bare-name destination — the rewrite + // engine handles single-word destinations as Ident replacements. + callSwaps[alias+".RegisterRoutes"] = r.Name + "Routes" + } + return transformCrossCutting(src, mod, resources, crossCuttingOptions{ + dropImports: dropImports, + addImports: []importSpec{ + {alias: "", path: mod + "/app/rest/controllers"}, + }, + fieldRewrites: fieldSwaps, + selectorSwaps: callSwaps, + }) +} + +// TransformCoreProvidersReverse rewrites app/di/providers/core.go back +// to layered. `userpkg.NewDefaultPasswordGenerator` → +// `services.NewDefaultPasswordGenerator` (PasswordGenerator moves +// back to app/services/). +func TransformCoreProvidersReverse(src []byte, mod string, resources []Resource) ([]byte, error) { + user := findResource(resources, "user") + if user == nil { + return src, nil + } + return transformCrossCutting(src, mod, resources, crossCuttingOptions{ + dropImports: []string{mod + "/app/" + user.Snake}, + addImports: []importSpec{ + {alias: "", path: mod + "/app/services"}, + }, + selectorSwaps: map[string]string{ + user.Snake + "pkg.NewDefaultPasswordGenerator": "services.NewDefaultPasswordGenerator", + }, + }) +} + +// TransformMockReverse rewrites a testutil/mocks/_*_mock.go +// file from feature-package back to layered: the `pkg` import +// + qualifier flip to `repoInterfaces` / `svcInterfaces` / `services`. +func TransformMockReverse(src []byte, mod string, resource Resource) ([]byte, error) { + alias := resource.Snake + "pkg" + return transformCrossCutting(src, mod, []Resource{resource}, crossCuttingOptions{ + dropImports: []string{mod + "/app/" + resource.Snake}, + addImports: []importSpec{ + {alias: "repoInterfaces", path: mod + "/app/repositories/interfaces"}, + {alias: "svcInterfaces", path: mod + "/app/services/interfaces"}, + {alias: "services", path: mod + "/app/services"}, + }, + selectorSwaps: map[string]string{ + alias + "." + resource.Name + "RepositoryInterface": "repoInterfaces." + resource.Name + "RepositoryInterface", + alias + "." + resource.Name + "ServiceInterface": "svcInterfaces." + resource.Name + "ServiceInterface", + alias + ".Err" + resource.Name + "NotDeletable": "repoInterfaces.Err" + resource.Name + "NotDeletable", + alias + ".Create" + resource.Name + "Input": "services.Create" + resource.Name + "Input", + alias + ".Update" + resource.Name + "Patch": "services.Update" + resource.Name + "Patch", + alias + ".List" + resource.Plural + "Filter": "services.List" + resource.Plural + "Filter", + }, + }) +} + +// TransformMock rewrites a testutil/mocks/_*_mock.go file: the +// mock stays in `package mocks` but the imports flip from the layered +// `/app/models` + `/app/repositories/interfaces` (or +// `/app/services/interfaces`) to a single per-feature alias +// import `pkg "/app/"`, and every selector +// referencing the layered packages is rewritten to point at the +// feature package. +// +// The mock impl is in a separate package from the feature it mocks +// (testify/mock convention), so the references stay as SelectorExprs +// — only the X identifier changes. +func TransformMock(src []byte, mod string, resource Resource) ([]byte, error) { + alias := resource.Snake + "pkg" + return transformCrossCutting(src, mod, []Resource{resource}, crossCuttingOptions{ + dropImports: []string{ + // `app/models` is intentionally NOT dropped — under Option B + // the model stays in package models, so the mock keeps + // `*models.User` as a cross-package reference. + mod + "/app/services", + mod + "/app/repositories/interfaces", + mod + "/app/services/interfaces", + }, + addImports: []importSpec{ + {alias: alias, path: mod + "/app/" + resource.Snake}, + }, + selectorSwaps: map[string]string{ + // models.X stays as-is. + "repoInterfaces." + resource.Name + "RepositoryInterface": alias + "." + resource.Name + "RepositoryInterface", + "svcInterfaces." + resource.Name + "ServiceInterface": alias + "." + resource.Name + "ServiceInterface", + "repoInterfaces.Err" + resource.Name + "NotDeletable": alias + ".Err" + resource.Name + "NotDeletable", + // Domain input types live in /inputs.go in feature + // layout. The mock methods reference these by name. + "services.Create" + resource.Name + "Input": alias + ".Create" + resource.Name + "Input", + "services.Update" + resource.Name + "Patch": alias + ".Update" + resource.Name + "Patch", + "services.List" + resource.Plural + "Filter": alias + ".List" + resource.Plural + "Filter", + }, + }) +} + +// TransformCoreProviders rewrites app/di/providers/core.go: replaces +// the `app/services` import + `services.NewDefaultPasswordGenerator` +// reference with the user feature's equivalent. PasswordGenerator +// follows the user feature (only consumer in the bootstrap scaffold). +func TransformCoreProviders(src []byte, mod string, resources []Resource) ([]byte, error) { + // Only rewrite the password generator binding — the rest of core.go + // stays valid (it imports app/validators, app/rest/controllers, + // app/devtools — all still present in feature layout). + userResource := findResource(resources, "user") + if userResource == nil { + return src, nil // no user feature → nothing to rewrite + } + return transformCrossCutting(src, mod, resources, crossCuttingOptions{ + dropImports: []string{mod + "/app/services"}, + addImports: []importSpec{ + {alias: userResource.Snake + "pkg", path: mod + "/app/" + userResource.Snake}, + }, + selectorSwaps: map[string]string{ + "services.NewDefaultPasswordGenerator": userResource.Snake + "pkg.NewDefaultPasswordGenerator", + }, + }) +} + +// findResource looks up a resource by snake name. +func findResource(resources []Resource, snake string) *Resource { + for i := range resources { + if resources[i].Snake == snake { + return &resources[i] + } + } + return nil +} + +// perFeatureImports builds one alias import per resource, e.g. +// `userpkg "/app/user"`. The alias avoids name collision with +// dst's existing identifier resolution (e.g. when a function param is +// also named `user`). +func perFeatureImports(mod string, resources []Resource) []importSpec { + out := make([]importSpec, 0, len(resources)) + for _, r := range resources { + out = append(out, importSpec{ + alias: r.Snake + "pkg", + path: fmt.Sprintf("%s/app/%s", mod, r.Snake), + }) + } + return out +} + +// containerFieldRewrites maps layered RouteConfig/ServiceContainer +// field types to feature equivalents. +// +// repoInterfaces.UserRepositoryInterface → userpkg.UserRepositoryInterface +// svcInterfaces.UserServiceInterface → userpkg.UserServiceInterface +// *controllers.UserController → *userpkg.UserController +// +// Because we keep type names as-is (`UserService` stays `UserService`, +// not collapsed to `Service`), the cross-cutting file only needs to +// rewrite the package qualifier — the suffix is untouched. +func containerFieldRewrites(resources []Resource) map[string]string { + out := map[string]string{} + for _, r := range resources { + alias := r.Snake + "pkg" + out["repoInterfaces."+r.Name+"RepositoryInterface"] = alias + "." + r.Name + "RepositoryInterface" + out["svcInterfaces."+r.Name+"ServiceInterface"] = alias + "." + r.Name + "ServiceInterface" + out["controllers."+r.Name+"Controller"] = alias + "." + r.Name + "Controller" + } + return out +} + +// indexRoutesFieldRewrites: same as container but only the controller +// field (RouteConfig has no repo/service fields). +func indexRoutesFieldRewrites(resources []Resource) map[string]string { + out := map[string]string{} + for _, r := range resources { + alias := r.Snake + "pkg" + out["controllers."+r.Name+"Controller"] = alias + "." + r.Name + "Controller" + } + return out +} + +// indexRoutesCallRewrites: `Routes(api, config.Controller)` → +// `pkg.RegisterRoutes(api, config.Controller)`. +func indexRoutesCallRewrites(resources []Resource) map[string]string { + out := map[string]string{} + for _, r := range resources { + alias := r.Snake + "pkg" + out[r.Name+"Routes"] = alias + ".RegisterRoutes" + } + return out +} + +// wireSelectorSwaps: `providers.Set` → `pkg.Set`. +func wireSelectorSwaps(resources []Resource) map[string]string { + out := map[string]string{} + for _, r := range resources { + alias := r.Snake + "pkg" + out["providers."+r.Name+"Set"] = alias + "." + r.Name + "Set" + } + return out +} + +// importSpec is the minimal info needed to insert an import. Kept as a +// local type to avoid leaking dst details out of featurize's API. +type importSpec struct { + alias string + path string +} + +// crossCuttingOptions describes the work for one cross-cutting file +// transformation. All fields are optional — omit any that don't apply. +type crossCuttingOptions struct { + dropImports []string // import paths to drop + addImports []importSpec // imports to add + fieldRewrites map[string]string // SelectorExpr "X.Y" → "A.B" for field types + selectorSwaps map[string]string // SelectorExpr "X.Y" → "A.B" in expression position (calls, refs) + callRewrites map[string]string // bare Ident "Foo" → SelectorExpr "alias.Bar" in call position +} + +// transformCrossCutting drives the four cross-cutting file transforms +func transformCrossCutting(src []byte, mod string, _ []Resource, opts crossCuttingOptions) ([]byte, error) { + _ = mod + dec := decorator.NewDecorator(token.NewFileSet()) + file, err := dec.Parse(src) + if err != nil { + return nil, fmt.Errorf("featurize: parse: %w", err) + } + + // Rewrite selectors and calls FIRST so we know which import aliases + // the swapped references will actually need. + merged := mergeRewrites(opts.fieldRewrites, opts.selectorSwaps) + if len(merged) > 0 { + rewriteSelectors(file, merged) + } + if len(opts.callRewrites) > 0 { + rewriteCallNames(file, opts.callRewrites) + } + + // Drop targeted imports — but only when no reference to the + // import's alias survives in the file after the rewrites. The + // scaffolded wire.go references both `providers.UserSet` (which + // the swap moves to `userpkg.UserSet`) AND `providers.CoreSet` + // (which stays). If we dropped the providers import unconditionally + // the file wouldn't compile. + if len(opts.dropImports) > 0 { + for _, p := range opts.dropImports { + alias := importAliasForPath(file, p) + if alias != "" && aliasReferenced(file, alias) { + continue + } + dropImportPath(file, p) + } + } + + // Add new imports ONLY if their alias is actually referenced in + // the file after the rewrites. Otherwise the generated file ends + // up with an unused import that fails to compile. + // + // When the importSpec has an empty alias, resolve to the path's + // last segment (Go's default import name) and check for THAT. + for _, ai := range opts.addImports { + checkAlias := ai.alias + if checkAlias == "" { + idx := strings.LastIndex(ai.path, "/") + if idx >= 0 && idx+1 < len(ai.path) { + checkAlias = ai.path[idx+1:] + } + } + if !aliasReferenced(file, checkAlias) { + continue + } + ensureImport(file, ai.path, ai.alias) + } + + return renderFile(file) +} + +// importAliasForPath returns the alias used to reference the given +// import path in the file — the explicit alias when one is set, +// otherwise the path's last segment. Returns "" if the import is not diff --git a/internal/featurize/featurize.go b/internal/featurize/featurize.go new file mode 100644 index 0000000..ec0af90 --- /dev/null +++ b/internal/featurize/featurize.go @@ -0,0 +1,63 @@ +// Package featurize converts a layered project's per-resource source +// files into the feature-package shape: +// +// app/models/.model.go → app//model.go +// app/dtos/.dtos.go → app//dtos.go +// app/repositories/.repository.go → app//repository.go +// app/repositories/interfaces/_repository.go → app//repository_iface.go +// app/services/.service.go → app//service.go +// app/services/interfaces/_service.go → app//service_iface.go +// app/services/_errors.go → app//errors.go +// app/services/_inputs.go → app//inputs.go +// app/rest/controllers/.controller.go → app//controller.go +// app/rest/routes/.routes.go → app//routes.go +// app/validators/.validators.go → app//validators.go +// app/di/providers/.go → app//wire.go +// +// Plus cross-cutting per-layout files (container.go, wire.go, +// index.routes.go, di/providers/core.go) whose import shape changes. +// +// # AST-based transformation +// +// The transformer uses github.com/dave/dst (decorated syntax tree) +// rather than regex so that: +// +// - aliased imports (`alias "path"`) round-trip correctly +// - SelectorExpr collapse is identifier-aware (won't rewrite a +// constant string that happens to contain "models.User") +// - external test packages (`package controllers_test`) are +// recognized by their syntax shape, not a fragile regex +// - the rendered output is gofmt-clean +// +// The transformer operates on RENDERED Go source — text/template +// placeholders must be resolved by the caller before passing the bytes +// in. `gofasta new --layout=feature` renders the .tmpl file with +// ProjectData first, then routes the resulting source through here. +// +// Phase D's `gofasta refactor feature-package` command reuses this +// same engine on a user's project source. The transformations are +// identical — only the input source path / output destination differ. +// +// # File map +// +// featurize.go — public types (Resource, Options) and package doc +// paths.go — path-mapping tables (PerResourceMapping etc.) +// forward.go — TransformPerResource (layered → feature) +// reverse.go — TransformPerResourceReverse (feature → layered) +// crosscutting.go — Transform{Container,Wire,IndexRoutes,Mock,…} + +// the shared transformCrossCutting engine +// ast.go — generic dst utilities: imports, walker, rewrite +package featurize + +// Resource describes one resource being featurized. +type Resource struct { + Name string // PascalCase ("User") + Snake string // snake_case ("user") + Plural string // PascalCase plural ("Users") +} + +// Options describes the transformation's environment. +type Options struct { + ModulePath string // "github.com/acme/myapp" + Resource Resource // the resource this file belongs to +} diff --git a/internal/featurize/featurize_test.go b/internal/featurize/featurize_test.go new file mode 100644 index 0000000..37619b1 --- /dev/null +++ b/internal/featurize/featurize_test.go @@ -0,0 +1,295 @@ +package featurize + +import ( + "strings" + "testing" +) + +func TestTransformPerResource_Model(t *testing.T) { + src := `package models + +import "github.com/gofastadev/gofasta/pkg/models" + +type User struct { + models.BaseModelImpl + FirstName string ` + "`gorm:\"not null\"`" + ` +} +` + got, err := TransformPerResource([]byte(src), Options{ + ModulePath: "example.com/myapp", + Resource: Resource{Name: "User", Snake: "user", Plural: "Users"}, + }) + if err != nil { + t.Fatalf("transform error: %v", err) + } + // package decl rewritten + if !strings.Contains(string(got), "package user\n") { + t.Errorf("package decl not rewritten:\n%s", got) + } + // framework BaseModelImpl preserved (it's an external `github.com/gofastadev/gofasta/pkg/models` reference) + if !strings.Contains(string(got), "models.BaseModelImpl") { + t.Errorf("framework reference models.BaseModelImpl was stripped — must be preserved:\n%s", got) + } +} + +func TestTransformPerResource_Service(t *testing.T) { + src := `package services + +import ( + "github.com/google/uuid" + + "example.com/myapp/app/models" + repoInterfaces "example.com/myapp/app/repositories/interfaces" +) + +type UserService struct { + repo repoInterfaces.UserRepositoryInterface + pwGen PasswordGenerator +} + +func NewUserService(repo repoInterfaces.UserRepositoryInterface, pwGen PasswordGenerator) *UserService { + return &UserService{repo: repo, pwGen: pwGen} +} + +func (s *UserService) Get(ctx uuid.UUID) (*models.User, error) { + return nil, ErrUserNotFound +} +` + got, err := TransformPerResource([]byte(src), Options{ + ModulePath: "example.com/myapp", + Resource: Resource{Name: "User", Snake: "user", Plural: "Users"}, + }) + if err != nil { + t.Fatalf("transform error: %v", err) + } + out := string(got) + if !strings.Contains(out, "package user") { + t.Errorf("package decl not rewritten:\n%s", out) + } + // Models intentionally stay in app/models/ — see featurize.go's + // `collapsablePaths` comment for the architectural reason. + if !strings.Contains(out, "models.User") { + t.Errorf("models.User reference should survive (model stays in app/models/):\n%s", out) + } + if strings.Contains(out, `repoInterfaces "example.com/myapp/app/repositories/interfaces"`) { + t.Errorf("repoInterfaces import not stripped:\n%s", out) + } + if strings.Contains(out, "repoInterfaces.UserRepositoryInterface") { + t.Errorf("repoInterfaces.UserRepositoryInterface was not collapsed:\n%s", out) + } + if !strings.Contains(out, `"github.com/google/uuid"`) { + t.Errorf("framework uuid import was stripped:\n%s", out) + } +} + +func TestTransformPerResource_ExternalTestPackage(t *testing.T) { + // External test pattern: `package controllers_test` instead of + // `package controllers`. Must be rewritten to `package user_test`. + src := `package controllers_test + +import ( + "testing" + "example.com/myapp/app/dtos" +) + +func TestStub(t *testing.T) { + _ = dtos.User{} +} +` + got, err := TransformPerResource([]byte(src), Options{ + ModulePath: "example.com/myapp", + Resource: Resource{Name: "User", Snake: "user", Plural: "Users"}, + }) + if err != nil { + t.Fatalf("transform error: %v", err) + } + out := string(got) + if !strings.Contains(out, "package user_test") { + t.Errorf("external test package not rewritten:\n%s", out) + } + // Per Option B: dtos.User (per-resource) collapses to bare User + // because per-resource DTOs move into the feature package. Only + // shared aliases (TPaginationObjectDto etc.) survive as `dtos.X`. + if strings.Contains(out, "dtos.User") { + t.Errorf("dtos.User should be collapsed to bare User in feature mode:\n%s", out) + } +} + +func TestTransformPerResource_Routes(t *testing.T) { + // Per-resource routes file: keep as-is for now — the + // `func UserRoutes(...)` rename happens at the cross-cutting level + // (TransformIndexRoutes wires the call differently). Within the + // routes.go file itself, the function name stays UserRoutes for + // API compat with the existing codebase; renaming to RegisterRoutes + // is a future polish pass. + src := `package routes + +import ( + "github.com/go-chi/chi/v5" + "github.com/gofastadev/gofasta/pkg/httputil" + + "example.com/myapp/app/rest/controllers" +) + +func UserRoutes(r chi.Router, uc *controllers.UserController) { + r.Get("/users", httputil.Handle(uc.ListUsers)) +} +` + got, err := TransformPerResource([]byte(src), Options{ + ModulePath: "example.com/myapp", + Resource: Resource{Name: "User", Snake: "user", Plural: "Users"}, + }) + if err != nil { + t.Fatalf("transform error: %v", err) + } + out := string(got) + if !strings.Contains(out, "package user") { + t.Errorf("package not rewritten:\n%s", out) + } + if strings.Contains(out, "controllers.UserController") { + t.Errorf("controllers.UserController not collapsed:\n%s", out) + } + if !strings.Contains(out, "*UserController") { + t.Errorf("collapsed UserController reference missing:\n%s", out) + } +} + +func TestTransformContainer(t *testing.T) { + src := `package di + +import ( + "gorm.io/gorm" + repoInterfaces "example.com/myapp/app/repositories/interfaces" + svcInterfaces "example.com/myapp/app/services/interfaces" + "example.com/myapp/app/rest/controllers" +) + +type ServiceContainer struct { + DB *gorm.DB + UserRepo repoInterfaces.UserRepositoryInterface + UserService svcInterfaces.UserServiceInterface + UserController *controllers.UserController +} +` + got, err := TransformContainer([]byte(src), "example.com/myapp", []Resource{ + {Name: "User", Snake: "user", Plural: "Users"}, + }) + if err != nil { + t.Fatalf("transform error: %v", err) + } + out := string(got) + if strings.Contains(out, "repoInterfaces") { + t.Errorf("repoInterfaces alias still present:\n%s", out) + } + if !strings.Contains(out, `userpkg "example.com/myapp/app/user"`) { + t.Errorf("userpkg import not added:\n%s", out) + } + if !strings.Contains(out, "userpkg.UserRepositoryInterface") { + t.Errorf("UserRepositoryInterface not rewritten to userpkg.UserRepositoryInterface:\n%s", out) + } + if !strings.Contains(out, "*userpkg.UserController") { + t.Errorf("*userpkg.UserController not produced:\n%s", out) + } +} + +func TestTransformWire(t *testing.T) { + src := `//go:build wireinject + +package di + +import ( + "github.com/google/wire" + "example.com/myapp/app/di/providers" +) + +func InitializeServiceContainer() (*ServiceContainer, error) { + wire.Build( + providers.CoreSet, + providers.UserSet, + wire.Struct(new(ServiceContainer), "*"), + ) + return nil, nil +} +` + got, err := TransformWire([]byte(src), "example.com/myapp", []Resource{ + {Name: "User", Snake: "user", Plural: "Users"}, + }) + if err != nil { + t.Fatalf("transform error: %v", err) + } + out := string(got) + if !strings.Contains(out, "userpkg.UserSet") { + t.Errorf("providers.UserSet not rewritten to userpkg.UserSet:\n%s", out) + } + // providers.CoreSet still references the providers package, so + // the import must stay (conservative drop — never strip an import + // whose alias still has live references). + if !strings.Contains(out, "providers.CoreSet") { + t.Errorf("providers.CoreSet should still be referenced (CoreSet doesn't move):\n%s", out) + } + if !strings.Contains(out, `"example.com/myapp/app/di/providers"`) { + t.Errorf("providers import should stay because providers.CoreSet still references it:\n%s", out) + } +} + +func TestTransformIndexRoutes(t *testing.T) { + src := `package routes + +import ( + "github.com/go-chi/chi/v5" + + "example.com/myapp/app/rest/controllers" +) + +type RouteConfig struct { + UserController *controllers.UserController +} + +func InitAPIRoutes(config *RouteConfig) *chi.Mux { + r := chi.NewRouter() + api := chi.NewRouter() + UserRoutes(api, config.UserController) + r.Mount("/api/v1", api) + return r +} +` + got, err := TransformIndexRoutes([]byte(src), "example.com/myapp", []Resource{ + {Name: "User", Snake: "user", Plural: "Users"}, + }) + if err != nil { + t.Fatalf("transform error: %v", err) + } + out := string(got) + if !strings.Contains(out, "*userpkg.UserController") { + t.Errorf("RouteConfig field type not rewritten:\n%s", out) + } + if !strings.Contains(out, "userpkg.RegisterRoutes(api") { + t.Errorf("UserRoutes call not rewritten to userpkg.RegisterRoutes:\n%s", out) + } +} + +func TestPerResourceMapping_AllPaths(t *testing.T) { + pairs := PerResourceMapping("user") + wantCount := 15 // models + validators stay in shared layered dirs; dtos collapse into feature per Option B + if len(pairs) != wantCount { + t.Errorf("PerResourceMapping returned %d pairs, want %d", len(pairs), wantCount) + } + expectedFeaturePaths := []string{ + "app/user/service.go", + "app/user/service_iface.go", + "app/user/controller.go", + "app/user/routes.go", + "app/user/wire.go", + "app/user/errors.go", + "app/user/repository_iface.go", + } + got := make(map[string]bool) + for _, p := range pairs { + got[p.Feature] = true + } + for _, want := range expectedFeaturePaths { + if !got[want] { + t.Errorf("PerResourceMapping missing feature path: %s", want) + } + } +} diff --git a/internal/featurize/forward.go b/internal/featurize/forward.go new file mode 100644 index 0000000..d43092f --- /dev/null +++ b/internal/featurize/forward.go @@ -0,0 +1,732 @@ +// Package featurize — forward transform (layered → feature). +// +// TransformPerResource is the entry point; everything else in this +// file is a private helper called by the 10-step pipeline: +// +// 1. Rewrite package decl +// 2. Identify collapsable imports +// 3. Walk + rewrite SelectorExprs (collapse to bare, or qualify +// for external test packages) +// 4. Rewrite dtos import path to app/shared/dtos +// 5. Re-qualify bare shared-alias references +// 6. Drop now-unused collapsable imports +// 6b. Add `/app/` import for external test files +// 7. Drop duplicate ErrNotDeletable var (repo iface vs services) +// 8. Re-qualify bare shared types (Validator → controllers.Validator) +// 9. Drop orphaned imports (e.g. unused `errors`) +// 10. Rename Routes → RegisterRoutes (routes.go only) + +package featurize + +import ( + "fmt" + "go/token" + "strings" + + "github.com/dave/dst" + "github.com/dave/dst/decorator" +) + +// collapsablePaths are the layered packages whose selector references +// collapse to bare identifiers when the file moves into the feature +// package. E.g. `services.UserService` → `UserService` because the +// service impl now lives in package alongside the caller. +// +// The transformer only collapses imports whose path STARTS with the +// project module path + one of these suffixes. Framework imports like +// `github.com/gofastadev/gofasta/pkg/models` are left alone. +// +// Intentionally NOT collapsed: +// +// - app/models: model types stay in `package models` because DTO +// mappers in the feature's dtos.go (UserFromModel etc.) need to +// reference *models.User. The model stays as a cross-package +// reference from the feature. +// - app/validators: keeps the shared registration function set +// (register.go calls isRecordExistByEmailForConflict etc., which +// are package-private). Scattering per-resource validators would +// break that wiring. +// +// Treated specially (Option B layout): +// +// - app/dtos: split into two packages in feature mode. The shared +// aliases (TPaginationObjectDto, SortOrientation, TCommon* etc.) +// move to `app/shared/dtos/aliases.go` and stay in `package dtos`. +// The per-resource DTOs (TCreateUserDto, UserFromModel, TUserResponseDto) +// move into the feature package at `app//dtos.go`. Bare +// references to shared aliases inside the moved per-resource files +// are qualified with `dtos.` and the import is rewritten to +// `/app/shared/dtos`. Cross-package references from other +// feature files retain the `dtos.` qualifier with the new path. +var collapsablePaths = []string{ + "/app/dtos", + "/app/services", + "/app/services/interfaces", + "/app/repositories", + "/app/repositories/interfaces", + "/app/rest/controllers", + "/app/rest/routes", +} + +// sharedDtoSymbols are the identifiers exported by `app/shared/dtos/ +// aliases.go` (formerly `app/dtos/aliases.go`). When the transformer +// collapses `dtos.X` references in feature mode, it keeps these as +// `dtos.X` (only the import path changes) while collapsing every other +// `dtos.Y` (per-resource types) to bare `Y`. +// +// Inside the moved per-resource dtos file these symbols were referenced +// bare (same package); after the move they need re-qualifying with +// `dtos.` since the file is now in the feature package. +var sharedDtoSymbols = map[string]bool{ + "TPaginationInputDto": true, + "TSortingInputDto": true, + "TPaginationObjectDto": true, + "TCommonAPIErrorDto": true, + "TCommonResponseDto": true, + "SortOrientation": true, + "SortOrientationAsc": true, + "SortOrientationDesc": true, +} + +// TransformPerResource rewrites a single per-resource layered Go source +// file into its feature-package equivalent. Returns the rewritten +// source as bytes (gofmt'd). +// +// Operations: +// +// 1. Package declaration: `package ` → `package `. +// Also handles external test packages: `package _test` +// → `package _test`. +// +// 2. Selector collapse: `X.Y` where X resolves to one of the +// collapsable layered packages becomes just `Y`. +// +// 3. Drop imports that are no longer referenced after the collapse. +// +// The transformer is package-aware via the import block — it doesn't +// guess which identifier means what. An aliased import like +// `repoInterfaces "/app/repositories/interfaces"` correctly +// collapses every `repoInterfaces.X` reference, regardless of name. +// +//nolint:gocyclo // 10-step linear pipeline; splitting hides the pipeline order. +func TransformPerResource(src []byte, opts Options) ([]byte, error) { + dec := decorator.NewDecorator(token.NewFileSet()) + file, err := dec.Parse(src) + if err != nil { + return nil, fmt.Errorf("featurize: parse: %w", err) + } + + // Step 1: rewrite package declaration. + file.Name.Name = rewritePackageName(file.Name.Name, opts.Resource.Snake) + + // Determine whether this is an external test package — `_test`. + // External tests can only access exported symbols of the feature + // package via the package qualifier, not bare. So we route the + // SelectorExpr collapse differently for these files: rewrite + // `services.UserService` → `.UserService` instead of bare. + isExternalTest := strings.HasSuffix(file.Name.Name, "_test") + + // Step 2: identify which imports are collapsable. + // collapseAliases maps import alias → true for imports that should + // be removed and whose selector references should collapse. + collapseAliases := map[string]bool{} + for _, imp := range file.Imports { + path := strings.Trim(imp.Path.Value, `"`) + if !isCollapsablePath(path, opts.ModulePath) { + continue + } + alias := importAlias(imp, path) + collapseAliases[alias] = true + } + + // Step 3: walk and rewrite SelectorExprs. + // + // Special case for the dtos alias: shared symbols (TPaginationObjectDto + // etc.) stay qualified as `dtos.X` because they live in the relocated + // `app/shared/dtos` package; only per-resource symbols collapse. + // + // Same-package files (e.g. user/service.go in `package user`) + // collapse `services.X` → `X`. External test files (e.g. + // user/controller_test.go in `package user_test`) qualify with the + // feature package: `services.X` → `.X`. + dst.Inspect(file, func(n dst.Node) bool { + sel, ok := n.(*dst.SelectorExpr) + if !ok { + return true + } + ident, ok := sel.X.(*dst.Ident) + if !ok { + return true + } + if !collapseAliases[ident.Name] { + return true + } + // Don't collapse shared dtos symbols — they're cross-package + // references after the move. + if ident.Name == "dtos" && sharedDtoSymbols[sel.Sel.Name] { + return true + } + if isExternalTest { + // Qualify with feature package name so external tests can + // reach the moved symbol: services.X → user.X. + // Special case: `routes.Routes(r, c)` becomes + // `.RegisterRoutes(r, c)` because the routes func + // is renamed at move time. + if ident.Name == "routes" && sel.Sel.Name == opts.Resource.Name+"Routes" { + sel.Sel.Name = "RegisterRoutes" + } + ident.Name = opts.Resource.Snake + return true + } + // Same-package collapse: `Routes(...)` → `RegisterRoutes(...)` + // when collapsing routes refs from outside the routes file. + if ident.Name == "routes" && sel.Sel.Name == opts.Resource.Name+"Routes" { + sel.Sel.Name = "RegisterRoutes" + } + // Mark the X identifier as the bare Y — the parent node will + // replace this SelectorExpr with the bare Sel ident in the + // post-process pass below. + ident.Name = "" // sentinel; handled in Replace pass + return true + }) + + // We can't replace SelectorExpr with Ident directly inside Inspect + // without parent context, so do a structural rewrite pass: walk + // every field/list/expr container and substitute SelectorExpr with + // sentinel ident. + replaceSelectorsWithIdent(file) + + // Step 4: handle the dtos package specially. If the file imports + // `/app/dtos` AND any reference to `dtos.X` survived the + // collapse (i.e. X is a shared alias), rewrite the import path to + // `/app/shared/dtos`. The package qualifier `dtos.` stays. + rewriteDtosImportPath(file, opts.ModulePath) + + // Step 5: requalify bare shared-alias references. The per-resource + // dtos file referenced `TPaginationObjectDto` bare (in-package in + // layered) — after moving to the feature package, those refs need + // `dtos.` prefix + the shared-dtos import. + requalifyBareSharedAliases(file, opts.ModulePath) + + // Step 6: drop now-unused imports. Match by path → drop. + dropCollapsableImports(file, opts.ModulePath) + + // Step 6b: for external test packages, add `/app/` + // import so the qualified references introduced in Step 3 + // resolve. Skipped for same-package files (they reach symbols + // in-package). + if isExternalTest && opts.ModulePath != "" { + ensureImport(file, opts.ModulePath+"/app/"+opts.Resource.Snake, "") + } + + // Step 7: drop duplicate var declarations that collide with the + // canonical version in another feature file. The layered code has + // ErrNotDeletable declared in BOTH repositories/interfaces + // AND services — separate packages, both legitimate. Once collapsed + // into one package, the two declarations conflict. The service's + // errors.go is the canonical version (it's documented as the + // service's domain sentinel error); drop the repo iface duplicate. + dropDuplicateErrorVars(file, opts.Resource.Name) + + // Step 8: requalify bare references to types that live in shared + // packages but were bare in the layered scaffold (because the + // source file was IN that package). Example: `Validator` is defined + // in app/rest/controllers/validator.go; the layered user.controller.go + // references it bare. After the move to app/user/, the bare ref + // becomes undefined — featurize qualifies with `controllers.` and + // adds the import. + requalifyBareSharedTypes(file, opts.ModulePath) + + // Step 9: drop now-orphaned imports — primarily the `errors` import + // left dangling when dropDuplicateErrorVars removed the only + // `errors.New(...)` call site. + dropOrphanedImports(file) + + // Step 10: rename the per-resource routes function from + // `Routes(r chi.Router, ...)` to `RegisterRoutes(r chi.Router, ...)`. + // The convention drops the redundant `` prefix once inside + // the feature package — and TransformIndexRoutes rewrites the + // caller to use `pkg.RegisterRoutes(...)`. + renameRoutesFunc(file, opts.Resource.Name) + + return renderFile(file) +} + +// renameRoutesFunc renames `Routes` (a top-level FuncDecl) to +// `RegisterRoutes`. No-op if the source file doesn't declare the +// function — most per-resource files don't (only routes.go does). +func renameRoutesFunc(file *dst.File, resourceName string) { + target := resourceName + "Routes" + for _, decl := range file.Decls { + fd, ok := decl.(*dst.FuncDecl) + if !ok { + continue + } + if fd.Name.Name == target { + fd.Name.Name = "RegisterRoutes" + return + } + } +} + +// sharedBareTypes maps bare identifier names to (package alias, import +// path) for types that were referenced bare in layered (because the +// source file was IN that package) but need qualifying when the source +// file moves to a different package. +// +// `Validator` → controllers.Validator from app/rest/controllers +// +// Add new entries when you observe a "bare reference undefined" build +// failure on a feature scaffold. Each entry costs one Walk over the +// file in Step 8 — keep the list small. +type sharedBareType struct { + pkgAlias string + pathSuffix string +} + +var sharedBareTypes = map[string]sharedBareType{ + "Validator": {pkgAlias: "controllers", pathSuffix: "/app/rest/controllers"}, +} + +func requalifyBareSharedTypes(file *dst.File, mod string) { + if mod == "" { + return + } + addedAliases := map[string]string{} // alias → fullPath + applyOnFile(file, func(c *applyCursor) bool { + ident, ok := c.Node.(*dst.Ident) + if !ok { + return true + } + entry, ok := sharedBareTypes[ident.Name] + if !ok { + return true + } + c.Replace(&dst.SelectorExpr{ + X: &dst.Ident{Name: entry.pkgAlias}, + Sel: &dst.Ident{Name: ident.Name}, + }) + addedAliases[entry.pkgAlias] = mod + entry.pathSuffix + return true + }) + for alias, path := range addedAliases { + alreadyImported := false + for _, imp := range file.Imports { + if strings.Trim(imp.Path.Value, `"`) == path { + alreadyImported = true + break + } + } + if alreadyImported { + continue + } + newImport := &dst.ImportSpec{ + Name: &dst.Ident{Name: alias}, + Path: &dst.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", path)}, + } + file.Imports = append(file.Imports, newImport) + appended := false + for _, decl := range file.Decls { + gd, ok := decl.(*dst.GenDecl) + if !ok || gd.Tok != token.IMPORT { + continue + } + gd.Specs = append(gd.Specs, newImport) + appended = true + break + } + if !appended { + file.Decls = append([]dst.Decl{&dst.GenDecl{ + Tok: token.IMPORT, Specs: []dst.Spec{newImport}, Lparen: true, Rparen: true, + }}, file.Decls...) + } + } +} + +// dropOrphanedImports removes imports that have no remaining references +// in the file. Currently scoped to a small allowlist of imports that +// transformer steps are known to leave dangling (e.g. `errors` after +// dropDuplicateErrorVars). A broader unused-import detector would +// require type info; that's overkill for the specific cases we hit. +// +//nolint:gocognit // 4-layer walk (decls → genDecl → specs → ident match) is the natural shape; helpers don't reduce branches. +func dropOrphanedImports(file *dst.File) { + candidates := map[string]string{ + "errors": "errors", // alias → import path + } + for alias, path := range candidates { + referenced := false + dst.Inspect(file, func(n dst.Node) bool { + sel, ok := n.(*dst.SelectorExpr) + if !ok { + return true + } + ident, ok := sel.X.(*dst.Ident) + if !ok { + return true + } + if ident.Name == alias { + referenced = true + return false + } + return true + }) + if referenced { + continue + } + // Drop the import. + keptImports := make([]*dst.ImportSpec, 0, len(file.Imports)) + for _, imp := range file.Imports { + if strings.Trim(imp.Path.Value, `"`) == path { + continue + } + keptImports = append(keptImports, imp) + } + file.Imports = keptImports + for _, decl := range file.Decls { + gd, ok := decl.(*dst.GenDecl) + if !ok || gd.Tok != token.IMPORT { + continue + } + kept := make([]dst.Spec, 0, len(gd.Specs)) + for _, spec := range gd.Specs { + is, ok := spec.(*dst.ImportSpec) + if !ok { + kept = append(kept, spec) + continue + } + if strings.Trim(is.Path.Value, `"`) == path { + continue + } + kept = append(kept, is) + } + gd.Specs = kept + } + } +} + +// rewriteDtosImportPath flips `/app/dtos` to `/app/shared/dtos` +// IF the file still references `dtos.X` after the collapse (i.e. the +// references are shared aliases that didn't collapse). When no such +func rewriteDtosImportPath(file *dst.File, mod string) { + if mod == "" { + return + } + hasDtosRef := false + dst.Inspect(file, func(n dst.Node) bool { + sel, ok := n.(*dst.SelectorExpr) + if !ok { + return true + } + ident, ok := sel.X.(*dst.Ident) + if !ok { + return true + } + if ident.Name == "dtos" { + hasDtosRef = true + return false + } + return true + }) + if !hasDtosRef { + return + } + oldPath := mod + "/app/dtos" + newPath := mod + "/app/shared/dtos" + for _, imp := range file.Imports { + if strings.Trim(imp.Path.Value, `"`) == oldPath { + imp.Path.Value = fmt.Sprintf("%q", newPath) + } + } + for _, decl := range file.Decls { + gd, ok := decl.(*dst.GenDecl) + if !ok || gd.Tok != token.IMPORT { + continue + } + for _, spec := range gd.Specs { + is, ok := spec.(*dst.ImportSpec) + if !ok { + continue + } + if strings.Trim(is.Path.Value, `"`) == oldPath { + is.Path.Value = fmt.Sprintf("%q", newPath) + } + } + } +} + +// requalifyBareSharedAliases walks the file for bare Ident nodes whose +// name matches a shared dtos alias, and converts them into +// `dtos.` SelectorExprs. Used when a per-resource dtos file +// (which referenced these symbols bare in layered) moves into the +// feature package. Adds the `/app/shared/dtos` import if any +func requalifyBareSharedAliases(file *dst.File, mod string) { + if mod == "" { + return + } + addedImport := false + applyOnFile(file, func(c *applyCursor) bool { + ident, ok := c.Node.(*dst.Ident) + if !ok { + return true + } + if !sharedDtoSymbols[ident.Name] { + return true + } + c.Replace(&dst.SelectorExpr{ + X: &dst.Ident{Name: "dtos"}, + Sel: &dst.Ident{Name: ident.Name}, + }) + addedImport = true + return true + }) + if !addedImport { + return + } + // Add `/app/shared/dtos` import if not already present. + target := mod + "/app/shared/dtos" + for _, imp := range file.Imports { + if strings.Trim(imp.Path.Value, `"`) == target { + return + } + } + newImport := &dst.ImportSpec{ + Path: &dst.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", target)}, + } + file.Imports = append(file.Imports, newImport) + for _, decl := range file.Decls { + gd, ok := decl.(*dst.GenDecl) + if !ok || gd.Tok != token.IMPORT { + continue + } + gd.Specs = append(gd.Specs, newImport) + return + } + file.Decls = append([]dst.Decl{&dst.GenDecl{ + Tok: token.IMPORT, Specs: []dst.Spec{newImport}, Lparen: true, Rparen: true, + }}, file.Decls...) +} + +// dropDuplicateErrorVars removes the `var ErrXNotDeletable = errors.New(...)` +// declaration that the layered repository_iface.go ships as a re-export +// for callers that import `repoInterfaces`. In feature mode the same +// variable is declared in errors.go, so the duplicate must go. +// +//nolint:gocognit,gocyclo // small dispatch over GenDecl/ValueSpec/Names; flattening adds indirection without simplifying control flow. +func dropDuplicateErrorVars(file *dst.File, resourceName string) { + target := "Err" + resourceName + "NotDeletable" + // Only drop the duplicate when this file is ALSO importing + // "errors" AND defines an interface — the heuristic identifies a + // repository_iface.go (the source of the duplicate). The service + // errors.go has no interface decl. + hasInterface := false + for _, decl := range file.Decls { + gd, ok := decl.(*dst.GenDecl) + if !ok || gd.Tok != token.TYPE { + continue + } + for _, spec := range gd.Specs { + ts, ok := spec.(*dst.TypeSpec) + if !ok { + continue + } + if _, ok := ts.Type.(*dst.InterfaceType); ok { + hasInterface = true + break + } + } + } + if !hasInterface { + return + } + keptDecls := make([]dst.Decl, 0, len(file.Decls)) + for _, decl := range file.Decls { + gd, ok := decl.(*dst.GenDecl) + if !ok || gd.Tok != token.VAR { + keptDecls = append(keptDecls, decl) + continue + } + // Filter specs: drop the matching name. + filteredSpecs := make([]dst.Spec, 0, len(gd.Specs)) + for _, spec := range gd.Specs { + vs, ok := spec.(*dst.ValueSpec) + if !ok { + filteredSpecs = append(filteredSpecs, spec) + continue + } + drop := false + for _, n := range vs.Names { + if n.Name == target { + drop = true + break + } + } + if !drop { + filteredSpecs = append(filteredSpecs, spec) + } + } + if len(filteredSpecs) == 0 { + continue // drop the whole GenDecl + } + gd.Specs = filteredSpecs + keptDecls = append(keptDecls, gd) + } + file.Decls = keptDecls +} + +// rewritePackageName maps a layered package decl to its feature +func rewritePackageName(layered, snake string) string { + for _, pkg := range layeredPackageNames { + if layered == pkg { + return snake + } + if layered == pkg+"_test" { + return snake + "_test" + } + } + // Already feature-shaped (idempotent) or unrelated — leave alone. + return layered +} + +// layeredPackageNames is the closed set of layer package names we +// recognize. Adding a layer to the project structure requires adding +// it here (and to collapsablePaths). +var layeredPackageNames = []string{ + "models", + "dtos", + "repositories", + "interfaces", // matches both repositories/interfaces and services/interfaces + "services", + "controllers", + "routes", + "validators", + "providers", +} + +// isCollapsablePath reports whether the import path points at one of +// the project's layered packages (vs. a framework or third-party path). +// The match anchors on `` + collapsable suffix to avoid false +func isCollapsablePath(path, mod string) bool { + if mod == "" { + return false + } + for _, suffix := range collapsablePaths { + if path == mod+suffix { + return true + } + } + return false +} + +// replaceSelectorsWithIdent walks the file and substitutes every +// SelectorExpr whose X.Name is the sentinel "" (set by the prior +// inspection pass) with a bare Ident carrying the Sel's name. The +// two-pass approach exists because dst.Inspect doesn't give parent +// context — we can mark the SelectorExpr for replacement in the first +// pass, then surgically replace at every container site in the second. +func replaceSelectorsWithIdent(file *dst.File) { + replaceInExprList := func(list []dst.Expr) { + for i, expr := range list { + if sel, ok := expr.(*dst.SelectorExpr); ok { + if ident, ok := sel.X.(*dst.Ident); ok && ident.Name == "" { + list[i] = &dst.Ident{Name: sel.Sel.Name} + } + } + } + } + _ = replaceInExprList + // The Apply API gives parent context — use it to replace the + // SelectorExpr in any slot a Node can sit in. + post := func(c *applyCursor) bool { + sel, ok := c.Node.(*dst.SelectorExpr) + if !ok { + return true + } + ident, ok := sel.X.(*dst.Ident) + if !ok { + return true + } + if ident.Name != "" { + return true + } + // Replace the SelectorExpr in-place with a bare Ident. + c.Replace(&dst.Ident{Name: sel.Sel.Name}) + return true + } + applyOnFile(file, post) +} + +// dropCollapsableImports removes the import block entries that point at +// the project's layered packages — after the selector collapse pass, +// they're unused. +// +// Imports are dropped from both file.Imports and the import GenDecl's +// Specs slice so the rendered output omits the line entirely. dst's +// gofmt pass cleans up an empty import block to `import ()` or removes +func dropCollapsableImports(file *dst.File, mod string) { + keptImports := make([]*dst.ImportSpec, 0, len(file.Imports)) + dropPaths := map[string]bool{} + for _, imp := range file.Imports { + path := strings.Trim(imp.Path.Value, `"`) + if isCollapsablePath(path, mod) { + dropPaths[path] = true + continue + } + keptImports = append(keptImports, imp) + } + file.Imports = keptImports + + // Walk file.Decls and rebuild any import GenDecl, dropping the + // matching specs. + for _, decl := range file.Decls { + gd, ok := decl.(*dst.GenDecl) + if !ok || gd.Tok != token.IMPORT { + continue + } + kept := make([]dst.Spec, 0, len(gd.Specs)) + for _, spec := range gd.Specs { + is, ok := spec.(*dst.ImportSpec) + if !ok { + kept = append(kept, spec) + continue + } + path := strings.Trim(is.Path.Value, `"`) + if dropPaths[path] { + continue + } + kept = append(kept, is) + } + gd.Specs = kept + } +} + +// FixDtosImportPath rewrites a file's `/app/dtos` import to +// `/app/shared/dtos`. No-op if the file doesn't import dtos. +// Used by shared infra files (app/validators/app_validator.go, +// app/rest/controllers/validator.go, etc.) that stay in their layered +// location but reference the shared dtos package which has moved. +func FixDtosImportPath(src []byte, mod string) ([]byte, error) { + dec := decorator.NewDecorator(token.NewFileSet()) + file, err := dec.Parse(src) + if err != nil { + return nil, fmt.Errorf("featurize: parse: %w", err) + } + rewriteDtosImportPath(file, mod) + return renderFile(file) +} + +// TransformMock rewrites a testutil/mocks/_*_mock.go file: the +// mock stays in `package mocks` but the imports flip from the layered +// `/app/models` + `/app/repositories/interfaces` (or +// `/app/services/interfaces`) to a single per-feature alias +// import `pkg "/app/"`, and every selector +// referencing the layered packages is rewritten to point at the +// feature package. +// +// models.User → userpkg.User +// repoInterfaces.UserRepositoryInterface → userpkg.UserRepositoryInterface +// svcInterfaces.UserServiceInterface → userpkg.UserServiceInterface +// repoInterfaces.ErrUserNotDeletable → userpkg.ErrUserNotDeletable +// +// The mock impl is in a separate package from the feature it mocks +// (testify/mock convention), so the references stay as SelectorExprs diff --git a/internal/featurize/paths.go b/internal/featurize/paths.go new file mode 100644 index 0000000..5cb2899 --- /dev/null +++ b/internal/featurize/paths.go @@ -0,0 +1,109 @@ +// Package featurize — path mapping tables. +// +// Source-of-truth for "where does each layered file go in feature +// layout" (and the inverse). The transformer engines in forward.go, +// reverse.go, and crosscutting.go consume these tables; the refactor +// command and `new --layout=feature` walker do too. + +package featurize + +import "fmt" + +// PathPair maps a layered path to its feature equivalent. +type PathPair struct { + Layered string + Feature string +} + +// SharedRelocations returns the (layered, feature) path pairs for files +// that simply move location in feature mode without any source-level +// rewriting. Per Option B: +// +// - app/dtos/aliases.go → app/shared/dtos/aliases.go (package stays +// `dtos`, but the import path callers use becomes +// `/app/shared/dtos` — featurize handles that rewrite on the +// caller side via rewriteDtosImportPath). +func SharedRelocations() []PathPair { + return []PathPair{ + {Layered: "app/dtos/aliases.go", Feature: "app/shared/dtos/aliases.go"}, + } +} + +// PerResourceMapping returns the (sourcePath, destPath) pairs for a +// given resource — i.e. which layered files become which feature files. +// Used by both `gofasta new --layout=feature` (to know which layered +// templates to transform) and Phase D's refactor command. +func PerResourceMapping(snake string) []PathPair { + // Models and validators files intentionally NOT moved — see + // `collapsablePaths` for the rationale. Per-resource dtos files + // DO move into the feature (Option B). Shared aliases relocate + // to app/shared/dtos/ via SharedRelocations() instead. + return []PathPair{ + {Layered: fmt.Sprintf("app/dtos/%s.dtos.go", snake), Feature: fmt.Sprintf("app/%s/dtos.go", snake)}, + {Layered: fmt.Sprintf("app/dtos/%s.dtos_test.go", snake), Feature: fmt.Sprintf("app/%s/dtos_test.go", snake)}, + {Layered: fmt.Sprintf("app/repositories/%s.repository.go", snake), Feature: fmt.Sprintf("app/%s/repository.go", snake)}, + {Layered: fmt.Sprintf("app/repositories/interfaces/%s_repository.go", snake), Feature: fmt.Sprintf("app/%s/repository_iface.go", snake)}, + {Layered: fmt.Sprintf("app/repositories/%s.repository_test.go", snake), Feature: fmt.Sprintf("app/%s/repository_test.go", snake)}, + {Layered: fmt.Sprintf("app/services/%s.service.go", snake), Feature: fmt.Sprintf("app/%s/service.go", snake)}, + {Layered: fmt.Sprintf("app/services/interfaces/%s_service.go", snake), Feature: fmt.Sprintf("app/%s/service_iface.go", snake)}, + {Layered: fmt.Sprintf("app/services/%s.service_test.go", snake), Feature: fmt.Sprintf("app/%s/service_test.go", snake)}, + {Layered: fmt.Sprintf("app/services/%s_errors.go", snake), Feature: fmt.Sprintf("app/%s/errors.go", snake)}, + {Layered: fmt.Sprintf("app/services/%s_inputs.go", snake), Feature: fmt.Sprintf("app/%s/inputs.go", snake)}, + {Layered: fmt.Sprintf("app/services/%s_inputs_test.go", snake), Feature: fmt.Sprintf("app/%s/inputs_test.go", snake)}, + {Layered: fmt.Sprintf("app/rest/controllers/%s.controller.go", snake), Feature: fmt.Sprintf("app/%s/controller.go", snake)}, + {Layered: fmt.Sprintf("app/rest/controllers/%s.controller_test.go", snake), Feature: fmt.Sprintf("app/%s/controller_test.go", snake)}, + {Layered: fmt.Sprintf("app/rest/routes/%s.routes.go", snake), Feature: fmt.Sprintf("app/%s/routes.go", snake)}, + {Layered: fmt.Sprintf("app/di/providers/%s.go", snake), Feature: fmt.Sprintf("app/%s/wire.go", snake)}, + } +} + +// ---------- Reverse direction (feature → layered) ---------- + +// LayeredDestination describes where a feature file belongs in the +// layered layout, plus the package name its package decl must take. +type LayeredDestination struct { + Path string // layered path (e.g. "app/services/user.service.go") + PackageName string // target package decl ("services", "interfaces", "controllers", ...) +} + +// ReversePerResourceMapping returns the (feature_path → layered_path) +// pairs for unwinding one resource back to layered. Inverse of +// PerResourceMapping. The PackageName field tells the reverse +// transformer which `package X` decl to write. +func ReversePerResourceMapping(snake string) []struct { + Feature string + Dest LayeredDestination +} { + return []struct { + Feature string + Dest LayeredDestination + }{ + {fmt.Sprintf("app/%s/dtos.go", snake), LayeredDestination{Path: fmt.Sprintf("app/dtos/%s.dtos.go", snake), PackageName: "dtos"}}, + {fmt.Sprintf("app/%s/dtos_test.go", snake), LayeredDestination{Path: fmt.Sprintf("app/dtos/%s.dtos_test.go", snake), PackageName: "dtos_test"}}, + {fmt.Sprintf("app/%s/repository.go", snake), LayeredDestination{Path: fmt.Sprintf("app/repositories/%s.repository.go", snake), PackageName: "repositories"}}, + {fmt.Sprintf("app/%s/repository_iface.go", snake), LayeredDestination{Path: fmt.Sprintf("app/repositories/interfaces/%s_repository.go", snake), PackageName: "interfaces"}}, + {fmt.Sprintf("app/%s/repository_test.go", snake), LayeredDestination{Path: fmt.Sprintf("app/repositories/%s.repository_test.go", snake), PackageName: "repositories_test"}}, + {fmt.Sprintf("app/%s/service.go", snake), LayeredDestination{Path: fmt.Sprintf("app/services/%s.service.go", snake), PackageName: "services"}}, + {fmt.Sprintf("app/%s/service_iface.go", snake), LayeredDestination{Path: fmt.Sprintf("app/services/interfaces/%s_service.go", snake), PackageName: "interfaces"}}, + {fmt.Sprintf("app/%s/service_test.go", snake), LayeredDestination{Path: fmt.Sprintf("app/services/%s.service_test.go", snake), PackageName: "services_test"}}, + {fmt.Sprintf("app/%s/errors.go", snake), LayeredDestination{Path: fmt.Sprintf("app/services/%s_errors.go", snake), PackageName: "services"}}, + {fmt.Sprintf("app/%s/inputs.go", snake), LayeredDestination{Path: fmt.Sprintf("app/services/%s_inputs.go", snake), PackageName: "services"}}, + {fmt.Sprintf("app/%s/inputs_test.go", snake), LayeredDestination{Path: fmt.Sprintf("app/services/%s_inputs_test.go", snake), PackageName: "services_test"}}, + {fmt.Sprintf("app/%s/controller.go", snake), LayeredDestination{Path: fmt.Sprintf("app/rest/controllers/%s.controller.go", snake), PackageName: "controllers"}}, + {fmt.Sprintf("app/%s/controller_test.go", snake), LayeredDestination{Path: fmt.Sprintf("app/rest/controllers/%s.controller_test.go", snake), PackageName: "controllers_test"}}, + {fmt.Sprintf("app/%s/routes.go", snake), LayeredDestination{Path: fmt.Sprintf("app/rest/routes/%s.routes.go", snake), PackageName: "routes"}}, + {fmt.Sprintf("app/%s/wire.go", snake), LayeredDestination{Path: fmt.Sprintf("app/di/providers/%s.go", snake), PackageName: "providers"}}, + } +} + +// SharedRelocationsReverse mirrors SharedRelocations but inverted: +// app/shared/dtos/aliases.go → app/dtos/aliases.go. +func SharedRelocationsReverse() []PathPair { + return []PathPair{ + {Layered: "app/shared/dtos/aliases.go", Feature: "app/dtos/aliases.go"}, + // Field naming reuses PathPair's Layered/Feature slots — Layered + // here is "the source path during reverse" (i.e. the feature + // location); Feature is "the destination" (i.e. the layered + // location). Refactor's reverse caller treats them accordingly. + } +} diff --git a/internal/featurize/reverse.go b/internal/featurize/reverse.go new file mode 100644 index 0000000..80b4e4f --- /dev/null +++ b/internal/featurize/reverse.go @@ -0,0 +1,418 @@ +// Package featurize — reverse transform (feature → layered). +// +// TransformPerResourceReverse is the entry point; the rest of this +// file is the reverse-direction helpers (symbol map, package-alias +// resolution, self-qualifier dropping, routes-function rename back). +// +// Pipeline: +// +// 1. Rewrite package decl to layered destination +// 2. Build symbol-to-layered-package map for this resource +// 3a. Walk SelectorExprs — flip `.X` to right alias or bare +// 3b. Walk bare Idents — qualify if cross-package +// 3c. Drop `/app/` import +// 4. Drop self-qualifier (controllers.Validator inside package controllers) +// 5. Rename RegisterRoutes back to Routes (routes file only) +// 6. dtos files moving back to `package dtos` drop the shared-dtos +// qualifier; other destinations flip the import path back +// 7. Drop the same-dir controllers import added during forward +// 8. Add imports the requalification needs + +package featurize + +import ( + "fmt" + "go/token" + "strings" + + "github.com/dave/dst" + "github.com/dave/dst/decorator" +) + +// reverseSymbolPackage describes which layered package + import path a +// given bare identifier belongs to in the layered layout. Used by the +// reverse transformer to re-qualify bare in-feature references. +type reverseSymbolPackage struct { + alias string // package alias to qualify with (e.g. "repoInterfaces") + pathTPL string // import path template — joined with module path at apply time +} + +// buildReverseSymbolMap returns the bare-identifier-to-layered-package +// map for one resource. Keys are the in-feature bare names; values are +// the layered package alias + import path. +func buildReverseSymbolMap(r Resource) map[string]reverseSymbolPackage { + repoInterfaces := reverseSymbolPackage{alias: "repoInterfaces", pathTPL: "/app/repositories/interfaces"} + repositories := reverseSymbolPackage{alias: "repositories", pathTPL: "/app/repositories"} + svcInterfaces := reverseSymbolPackage{alias: "svcInterfaces", pathTPL: "/app/services/interfaces"} + services := reverseSymbolPackage{alias: "services", pathTPL: "/app/services"} + controllers := reverseSymbolPackage{alias: "controllers", pathTPL: "/app/rest/controllers"} + dtos := reverseSymbolPackage{alias: "dtos", pathTPL: "/app/dtos"} + + return map[string]reverseSymbolPackage{ + // repository-interfaces + r.Name + "RepositoryInterface": repoInterfaces, + // repository impls + r.Name + "Repository": repositories, + "New" + r.Name + "Repository": repositories, + // service-interfaces + r.Name + "ServiceInterface": svcInterfaces, + // service impls + domain types + r.Name + "Service": services, + "New" + r.Name + "Service": services, + "Create" + r.Name + "Input": services, + "Update" + r.Name + "Patch": services, + "List" + r.Plural + "Filter": services, + "Err" + r.Name + "NotFound": services, + "Err" + r.Name + "VersionConflict": services, + "Err" + r.Name + "NotDeletable": services, + "PasswordGenerator": services, + "NewDefaultPasswordGenerator": services, + "DefaultPasswordGenerator": services, + // controllers + r.Name + "Controller": controllers, + "New" + r.Name + "ControllerInstance": controllers, + // dtos (per-resource types — moved into feature, going back to dtos) + r.Name: dtos, // the DTO User type + "T" + r.Name + "ResponseDto": dtos, + "T" + r.Plural + "ResponseDto": dtos, + "TCreate" + r.Name + "Dto": dtos, + "TUpdate" + r.Name + "Dto": dtos, + "TArchive" + r.Name + "Dto": dtos, + "TFind" + r.Name + "ByIDDto": dtos, + "T" + r.Name + "FiltersQueryParamsDto": dtos, + "TUpdate" + r.Name + "GraphQLInput": dtos, + r.Name + "FromModel": dtos, + r.Plural + "FromModels": dtos, + } +} + +// TransformPerResourceReverse migrates a single per-resource source +// file from feature-package layout back to its layered location. It's +// the inverse of TransformPerResource: +// +// 1. Package decl: `package ` → `package ` (or +// `package _test` for external test files). +// 2. Bare refs to in-feature symbols that belong to OTHER layered +// packages get qualified — e.g. `UserRepositoryInterface` → +// `repoInterfaces.UserRepositoryInterface`. Required imports +// auto-added. +// 3. Refs already qualified by package decl (e.g. `controllers.Validator` +// when the destination is `package controllers`) shed the +// redundant self-qualifier. +// 4. `dtos.X` for shared aliases (TPaginationObjectDto etc.) collapses +// to bare `X` because the destination is `package dtos`. The +// `/app/shared/dtos` import flips back to `/app/dtos`. +// 5. `RegisterRoutes` in routes files renames back to `Routes`. +// +// Caveat: the reverse direction assumes the feature project follows +// scaffold-shaped conventions. Heavy hand-edits (renamed types, +// custom files, alternate folder layouts) may not unwind cleanly — +// in those cases the user should split the migration into smaller +// pieces or manually adjust the result. +// +//nolint:gocognit,gocyclo // 8-step linear pipeline; splitting hides the pipeline order. +func TransformPerResourceReverse(src []byte, dest LayeredDestination, opts Options) ([]byte, error) { + dec := decorator.NewDecorator(token.NewFileSet()) + file, err := dec.Parse(src) + if err != nil { + return nil, fmt.Errorf("featurize reverse: parse: %w", err) + } + + // Step 1: rewrite package decl to the layered destination. + file.Name.Name = dest.PackageName + + // Step 2: build the symbol-to-layered-package map for this resource. + symMap := buildReverseSymbolMap(opts.Resource) + + // Step 3a: walk SelectorExprs first — external test files have + // `.X` references (added by forward featurize) that need to + // flip to `.X` or collapse to bare `X` depending on + // the destination package. Same-package services symbols in a + // `services_test` file collapse to bare; cross-package symbols + // rewrite to the right alias. + destAlias := destPackageAlias(dest.PackageName) + addedImports := map[string]string{} + applyOnFile(file, func(c *applyCursor) bool { + sel, ok := c.Node.(*dst.SelectorExpr) + if !ok { + return true + } + ident, ok := sel.X.(*dst.Ident) + if !ok { + return true + } + if ident.Name != opts.Resource.Snake { + return true + } + // Special case: `.RegisterRoutes` → `routes.Routes`. + // Forward featurize renamed the routes function AND qualified + // the external test's call with the snake alias; reverse must + // undo both. + if sel.Sel.Name == "RegisterRoutes" { + c.Replace(&dst.SelectorExpr{ + X: &dst.Ident{Name: "routes"}, + Sel: &dst.Ident{Name: opts.Resource.Name + "Routes"}, + }) + addedImports["routes"] = opts.ModulePath + "/app/rest/routes" + return true + } + entry, ok := symMap[sel.Sel.Name] + if !ok { + return true + } + if entry.alias == destAlias { + // Same package — collapse to bare. + c.Replace(&dst.Ident{Name: sel.Sel.Name}) + return true + } + // Different package — rewrite the qualifier. + c.Replace(&dst.SelectorExpr{ + X: &dst.Ident{Name: entry.alias}, + Sel: &dst.Ident{Name: sel.Sel.Name}, + }) + addedImports[entry.alias] = opts.ModulePath + entry.pathTPL + return true + }) + + // Step 3b: walk bare Ident references. If the symbol maps to a + // layered package DIFFERENT from this file's destination, qualify. + // The destination's own package alias is "" (no qualifier needed). + applyOnFile(file, func(c *applyCursor) bool { + ident, ok := c.Node.(*dst.Ident) + if !ok { + return true + } + entry, ok := symMap[ident.Name] + if !ok { + return true + } + // Same package: stays bare. + if entry.alias == destAlias { + return true + } + c.Replace(&dst.SelectorExpr{ + X: &dst.Ident{Name: entry.alias}, + Sel: &dst.Ident{Name: ident.Name}, + }) + addedImports[entry.alias] = opts.ModulePath + entry.pathTPL + return true + }) + + // Step 3c: drop the `/app/` import — it was added by + // forward featurize for external test files; after the reverse + // nothing references it. + dropImportPath(file, opts.ModulePath+"/app/"+opts.Resource.Snake) + + // Step 4: drop self-qualifier when present — controller.go in + // `package controllers` with `controllers.Validator` should become + // bare `Validator`. + dropSelfQualifier(file, destAlias) + + // Step 5: routes function rename back: `RegisterRoutes` → + // `Routes`. Only in the routes file itself. + if strings.HasSuffix(dest.Path, ".routes.go") { + renameRegisterRoutesBack(file, opts.Resource.Name) + } + + // Step 6: dtos files moving back to `package dtos` need their + // shared-aliases qualifier dropped — `dtos.TPaginationObjectDto` + // becomes bare `TPaginationObjectDto` again. + if dest.PackageName == "dtos" { + dropSelfQualifier(file, "dtos") + dropImportPath(file, opts.ModulePath+"/app/shared/dtos") + } else { + // All other destination packages (controllers, services, etc.) + // keep the `dtos.` qualifier but the import path needs to flip + // from `/app/shared/dtos` back to `/app/dtos`. + oldPath := opts.ModulePath + "/app/shared/dtos" + newPath := opts.ModulePath + "/app/dtos" + for _, imp := range file.Imports { + if strings.Trim(imp.Path.Value, `"`) == oldPath { + imp.Path.Value = fmt.Sprintf("%q", newPath) + } + } + for _, decl := range file.Decls { + gd, ok := decl.(*dst.GenDecl) + if !ok || gd.Tok != token.IMPORT { + continue + } + for _, spec := range gd.Specs { + is, ok := spec.(*dst.ImportSpec) + if !ok { + continue + } + if strings.Trim(is.Path.Value, `"`) == oldPath { + is.Path.Value = fmt.Sprintf("%q", newPath) + } + } + } + } + + // Step 7: drop the `userpkg "/app/"`-style imports + // added by Phase A's requalifyBareSharedTypes pass (Validator + // qualification) when reversing into the controllers package. + if destAlias == "controllers" { + // The feature controller imported `controllers` to reach + // `controllers.Validator` — that import is now self-referential + // and must go. + dropImportPath(file, opts.ModulePath+"/app/rest/controllers") + } + + // Step 8: add the imports the requalification needs. + for alias, path := range addedImports { + // Skip self-imports (would be circular). destAlias is "" for + // external test packages — they CAN import the same-directory + // underlying package via its qualifier, so the path-suffix + // check (`isSelfImport`) is wrong here. The destAlias match + // is sufficient for true self-references. + if alias == destAlias { + continue + } + // dtos files re-qualifying services symbols — same as forward, + // just add the import. + alreadyImported := false + for _, imp := range file.Imports { + if strings.Trim(imp.Path.Value, `"`) == path { + alreadyImported = true + break + } + } + if alreadyImported { + continue + } + newImport := &dst.ImportSpec{ + Path: &dst.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", path)}, + } + // Add an explicit alias when the path's last segment differs + // from the local alias we use (repoInterfaces vs the path's + // last segment "interfaces"; svcInterfaces likewise). + if alias == "repoInterfaces" || alias == "svcInterfaces" { + newImport.Name = &dst.Ident{Name: alias} + } + file.Imports = append(file.Imports, newImport) + appended := false + for _, decl := range file.Decls { + gd, ok := decl.(*dst.GenDecl) + if !ok || gd.Tok != token.IMPORT { + continue + } + gd.Specs = append(gd.Specs, newImport) + appended = true + break + } + if !appended { + file.Decls = append([]dst.Decl{&dst.GenDecl{ + Tok: token.IMPORT, Specs: []dst.Spec{newImport}, Lparen: true, Rparen: true, + }}, file.Decls...) + } + } + + return renderFile(file) +} + +// destPackageAlias maps a layered destination package name to the +// alias used to qualify symbols pointing at it. Most map 1:1 to the +// package name; `interfaces` is ambiguous (could be repo or service +// interfaces) and is intentionally returned as "" so cross-package +// refs always qualify. +// +// External test packages (`services_test`, `controllers_test`, etc.) +// are NOT the same as their underlying package — Go treats them as +// separate units that access the underlying package via its qualifier. +func destPackageAlias(pkgName string) string { + if strings.HasSuffix(pkgName, "_test") { + return "" + } + switch pkgName { + case "repositories": + return "repositories" + case "services": + return "services" + case "controllers": + return "controllers" + case "dtos": + return "dtos" + case "routes": + return "routes" + case "providers": + return "providers" + case "interfaces": + // Can't distinguish repo vs service interfaces from package + // name alone — both layered packages are named `interfaces`. + // Return "" so symbols always qualify. + return "" + } + return "" +} + +// dropSelfQualifier walks the file and drops `X.Y` SelectorExprs +// where X matches the destination package alias — those would be +func dropSelfQualifier(file *dst.File, alias string) { + if alias == "" { + return + } + applyOnFile(file, func(c *applyCursor) bool { + sel, ok := c.Node.(*dst.SelectorExpr) + if !ok { + return true + } + ident, ok := sel.X.(*dst.Ident) + if !ok { + return true + } + if ident.Name == alias { + c.Replace(&dst.Ident{Name: sel.Sel.Name}) + } + return true + }) +} + +// renameRegisterRoutesBack renames the per-feature `RegisterRoutes` +func renameRegisterRoutesBack(file *dst.File, resourceName string) { + for _, decl := range file.Decls { + fd, ok := decl.(*dst.FuncDecl) + if !ok { + continue + } + if fd.Name.Name == "RegisterRoutes" { + fd.Name.Name = resourceName + "Routes" + return + } + } +} + +// FixSharedDtosImportPathReverse flips `/app/shared/dtos` back to +// `/app/dtos` in shared infra files (app_validator.go, +// validator.go, resolver.go) — inverse of FixDtosImportPath. +func FixSharedDtosImportPathReverse(src []byte, mod string) ([]byte, error) { + dec := decorator.NewDecorator(token.NewFileSet()) + file, err := dec.Parse(src) + if err != nil { + return nil, fmt.Errorf("featurize reverse: parse: %w", err) + } + oldPath := mod + "/app/shared/dtos" + newPath := mod + "/app/dtos" + for _, imp := range file.Imports { + if strings.Trim(imp.Path.Value, `"`) == oldPath { + imp.Path.Value = fmt.Sprintf("%q", newPath) + } + } + for _, decl := range file.Decls { + gd, ok := decl.(*dst.GenDecl) + if !ok || gd.Tok != token.IMPORT { + continue + } + for _, spec := range gd.Specs { + is, ok := spec.(*dst.ImportSpec) + if !ok { + continue + } + if strings.Trim(is.Path.Value, `"`) == oldPath { + is.Path.Value = fmt.Sprintf("%q", newPath) + } + } + } + return renderFile(file) +} + +// SharedRelocationsReverse mirrors SharedRelocations but inverted: diff --git a/internal/generate/gen_controller.go b/internal/generate/gen_controller.go index e26f1ff..b0c6146 100644 --- a/internal/generate/gen_controller.go +++ b/internal/generate/gen_controller.go @@ -1,12 +1,10 @@ package generate import ( - "fmt" - "github.com/gofastadev/cli/internal/generate/templates" ) // GenController writes a REST controller file for the scaffolded resource. func GenController(d ScaffoldData) error { - return WriteTemplate(fmt.Sprintf("app/rest/controllers/%s.controller.go", d.SnakeName), "controller", templates.Controller, d) + return WriteTemplate(d.L().ControllerFile(d.SnakeName), "controller", templates.Controller, d) } diff --git a/internal/generate/gen_controller_testfile.go b/internal/generate/gen_controller_testfile.go index 68601d2..3f816b6 100644 --- a/internal/generate/gen_controller_testfile.go +++ b/internal/generate/gen_controller_testfile.go @@ -1,8 +1,6 @@ package generate import ( - "fmt" - "github.com/gofastadev/cli/internal/generate/templates" ) @@ -16,7 +14,7 @@ import ( // we currently add it to every flow that produces a controller. func GenControllerTestFile(d ScaffoldData) error { return WriteTemplate( - fmt.Sprintf("app/rest/controllers/%s.controller_test.go", d.SnakeName), + d.L().ControllerTestFile(d.SnakeName), "controller_test", templates.ControllerTest, d, diff --git a/internal/generate/gen_dtos.go b/internal/generate/gen_dtos.go index d94fcd4..204d7cb 100644 --- a/internal/generate/gen_dtos.go +++ b/internal/generate/gen_dtos.go @@ -1,12 +1,10 @@ package generate import ( - "fmt" - "github.com/gofastadev/cli/internal/generate/templates" ) // GenDTOs writes DTO structs for the scaffolded resource. func GenDTOs(d ScaffoldData) error { - return WriteTemplate(fmt.Sprintf("app/dtos/%s.dtos.go", d.SnakeName), "dtos", templates.DTOs, d) + return WriteTemplate(d.L().DTOsFile(d.SnakeName), "dtos", templates.DTOs, d) } diff --git a/internal/generate/gen_dtos_testfile.go b/internal/generate/gen_dtos_testfile.go index 02b5768..d891472 100644 --- a/internal/generate/gen_dtos_testfile.go +++ b/internal/generate/gen_dtos_testfile.go @@ -1,8 +1,6 @@ package generate import ( - "fmt" - "github.com/gofastadev/cli/internal/generate/templates" ) @@ -11,7 +9,7 @@ import ( // stubs — see templates.DTOsTest for the contract. func GenDTOsTestFile(d ScaffoldData) error { return WriteTemplate( - fmt.Sprintf("app/dtos/%s.dtos_test.go", d.SnakeName), + d.L().DTOsTestFile(d.SnakeName), "dtos_test", templates.DTOsTest, d, ) } diff --git a/internal/generate/gen_endpoint.go b/internal/generate/gen_endpoint.go index ddbfc5c..dd39143 100644 --- a/internal/generate/gen_endpoint.go +++ b/internal/generate/gen_endpoint.go @@ -15,12 +15,12 @@ package generate import ( "fmt" "os" - "path/filepath" "regexp" "strings" "github.com/gofastadev/cli/internal/clierr" "github.com/gofastadev/cli/internal/generate/astpatch" + "github.com/gofastadev/cli/internal/layout" ) // EndpointData is the resolved input for the endpoint generator. @@ -141,14 +141,17 @@ func endpointDataDefaults(d EndpointData) EndpointData { if d.HandlerName == "" { d.HandlerName = deriveHandlerName(d.HTTPMethod, d.Path, d.Resource) } - if d.ControllerFile == "" { - d.ControllerFile = filepath.Join("app", "rest", "controllers", d.Snake+".controller.go") - } - if d.RoutesFile == "" { - d.RoutesFile = filepath.Join("app", "rest", "routes", d.Snake+".routes.go") - } - if d.ServiceFile == "" { - d.ServiceFile = filepath.Join("app", "services", "interfaces", d.Snake+"_service.go") + if d.ControllerFile == "" || d.RoutesFile == "" || d.ServiceFile == "" { + lo := layout.Detect() + if d.ControllerFile == "" { + d.ControllerFile = lo.ControllerFile(d.Snake) + } + if d.RoutesFile == "" { + d.RoutesFile = lo.RoutesFile(d.Snake) + } + if d.ServiceFile == "" { + d.ServiceFile = lo.SvcIfaceFile(d.Snake) + } } return d } diff --git a/internal/generate/gen_errors.go b/internal/generate/gen_errors.go index ebc72ac..0fdca39 100644 --- a/internal/generate/gen_errors.go +++ b/internal/generate/gen_errors.go @@ -1,16 +1,15 @@ package generate import ( - "fmt" - "github.com/gofastadev/cli/internal/generate/templates" ) -// GenErrors writes the per-resource sentinel-errors file ( -// `app/services/_errors.go`) for the scaffolded resource. +// GenErrors writes the per-resource sentinel-errors file for the +// scaffolded resource. Path is layout-dependent — layered puts it under +// app/services/, feature puts it under app//. func GenErrors(d ScaffoldData) error { return WriteTemplate( - fmt.Sprintf("app/services/%s_errors.go", d.SnakeName), + d.L().ErrorsFile(d.SnakeName), "errors", templates.Errors, d, ) } diff --git a/internal/generate/gen_field.go b/internal/generate/gen_field.go index d794d01..ced332f 100644 --- a/internal/generate/gen_field.go +++ b/internal/generate/gen_field.go @@ -20,6 +20,7 @@ import ( "github.com/gofastadev/cli/internal/clierr" "github.com/gofastadev/cli/internal/generate/astpatch" + "github.com/gofastadev/cli/internal/layout" ) // FieldData is the resolved input for the field generator. @@ -94,14 +95,17 @@ func fieldDataDefaults(d FieldData) FieldData { if d.PluralSnake == "" && d.Resource != "" { d.PluralSnake = toSnakeCase(pluralize(toPascalCase(d.Resource))) } - if d.ModelFile == "" { - d.ModelFile = filepath.Join("app", "models", d.Snake+".model.go") - } - if d.DTOFile == "" { - d.DTOFile = filepath.Join("app", "dtos", d.Snake+".dtos.go") - } - if d.MigrationDir == "" { - d.MigrationDir = filepath.Join("db", "migrations") + if d.ModelFile == "" || d.DTOFile == "" || d.MigrationDir == "" { + lo := layout.Detect() + if d.ModelFile == "" { + d.ModelFile = lo.ModelFile(d.Snake) + } + if d.DTOFile == "" { + d.DTOFile = lo.DTOsFile(d.Snake) + } + if d.MigrationDir == "" { + d.MigrationDir = lo.MigrationsDir() + } } if d.MigrationVer == "" { d.MigrationVer = nextMigrationNumber() diff --git a/internal/generate/gen_inputs.go b/internal/generate/gen_inputs.go index dab7f2a..b1753e2 100644 --- a/internal/generate/gen_inputs.go +++ b/internal/generate/gen_inputs.go @@ -1,17 +1,15 @@ package generate import ( - "fmt" - "github.com/gofastadev/cli/internal/generate/templates" ) -// GenInputs writes the per-resource domain-inputs file ( -// `app/services/_inputs.go`) for the scaffolded resource. -// Contains CreateXInput, UpdateXPatch (with AsMap), and ListXFilter. +// GenInputs writes the per-resource domain-inputs file for the +// scaffolded resource. Contains CreateXInput, UpdateXPatch (with +// AsMap), and ListXFilter. Path is layout-dependent. func GenInputs(d ScaffoldData) error { return WriteTemplate( - fmt.Sprintf("app/services/%s_inputs.go", d.SnakeName), + d.L().InputsFile(d.SnakeName), "inputs", templates.Inputs, d, ) } diff --git a/internal/generate/gen_inputs_testfile.go b/internal/generate/gen_inputs_testfile.go index ae9f24a..143bdd2 100644 --- a/internal/generate/gen_inputs_testfile.go +++ b/internal/generate/gen_inputs_testfile.go @@ -1,8 +1,6 @@ package generate import ( - "fmt" - "github.com/gofastadev/cli/internal/generate/templates" ) @@ -10,7 +8,7 @@ import ( // generated inputs file (AsMap negative-space + AsRepoFilter empty-map). func GenInputsTestFile(d ScaffoldData) error { return WriteTemplate( - fmt.Sprintf("app/services/%s_inputs_test.go", d.SnakeName), + d.L().InputsTestFile(d.SnakeName), "inputs_test", templates.InputsTest, d, ) } diff --git a/internal/generate/gen_method.go b/internal/generate/gen_method.go index 8b7582c..458f6da 100644 --- a/internal/generate/gen_method.go +++ b/internal/generate/gen_method.go @@ -13,11 +13,11 @@ package generate import ( "fmt" "os" - "path/filepath" "strings" "github.com/gofastadev/cli/internal/clierr" "github.com/gofastadev/cli/internal/generate/astpatch" + "github.com/gofastadev/cli/internal/layout" ) // MethodData is the resolved input for the method generator. @@ -106,11 +106,14 @@ func methodDataDefaults(d MethodData) MethodData { if d.ImplStructName == "" { d.ImplStructName = strings.ToLower(d.Resource[:1]) + d.Resource[1:] + "Service" } - if d.InterfaceFile == "" { - d.InterfaceFile = filepath.Join("app", "services", "interfaces", d.Snake+"_service.go") - } - if d.ImplFile == "" { - d.ImplFile = filepath.Join("app", "services", d.Snake+".service.go") + if d.InterfaceFile == "" || d.ImplFile == "" { + lo := layout.Detect() + if d.InterfaceFile == "" { + d.InterfaceFile = lo.SvcIfaceFile(d.Snake) + } + if d.ImplFile == "" { + d.ImplFile = lo.SvcImplFile(d.Snake) + } } return d } diff --git a/internal/generate/gen_middleware.go b/internal/generate/gen_middleware.go index cc0b5e1..11d81fe 100644 --- a/internal/generate/gen_middleware.go +++ b/internal/generate/gen_middleware.go @@ -15,6 +15,7 @@ import ( "strings" "github.com/gofastadev/cli/internal/clierr" + "github.com/gofastadev/cli/internal/layout" ) // MiddlewareData is the resolved input. @@ -67,7 +68,7 @@ func GenMiddleware(d MiddlewareData) error { func middlewareDataDefaults(d MiddlewareData) MiddlewareData { if d.RoutesDir == "" { - d.RoutesDir = filepath.Join("app", "rest", "routes") + d.RoutesDir = layout.Detect().RoutesDir() } return d } diff --git a/internal/generate/gen_mock.go b/internal/generate/gen_mock.go index b726fe4..c17f969 100644 --- a/internal/generate/gen_mock.go +++ b/internal/generate/gen_mock.go @@ -27,7 +27,10 @@ import ( "sort" "strings" + goimports "golang.org/x/tools/imports" + "github.com/gofastadev/cli/internal/clierr" + "github.com/gofastadev/cli/internal/layout" ) // MockData is what GenMock needs to produce one mock file. @@ -111,11 +114,11 @@ func GenMock(interfaceName string, opts GenMockOpts) error { // regenAllMocks walks the standard interfaces directories and regenerates // every mock file. Errors on individual interfaces don't kill the run — // each is reported via the normal cliout channels by writeMockForTarget. +// Directories scanned depend on layout (layered scans +// app/{services,repositories}/interfaces; feature walks per-resource +// app// for *_iface.go). func regenAllMocks(module string, opts GenMockOpts) error { - dirs := []string{ - filepath.Join("app", "services", "interfaces"), - filepath.Join("app", "repositories", "interfaces"), - } + dirs := layout.Detect().InterfaceDirs() anyHit := false for _, dir := range dirs { entries, err := os.ReadDir(dir) @@ -159,12 +162,9 @@ type interfaceTarget struct { // findInterface walks the standard interfaces dirs and returns the first // interface declaration matching name. Returns CodeInterfaceNotFound when // the name isn't found, CodeAmbiguousSymbol when two files define the -// same interface name. +// same interface name. Directories scanned depend on layout. func findInterface(name string) (interfaceTarget, error) { - dirs := []string{ - filepath.Join("app", "services", "interfaces"), - filepath.Join("app", "repositories", "interfaces"), - } + dirs := layout.Detect().InterfaceDirs() var hits []interfaceTarget for _, dir := range dirs { entries, err := os.ReadDir(dir) @@ -265,7 +265,7 @@ func buildInterfaceTarget(name, sourcePath, pkgName string, imports []MockImport // Embedded interfaces — skip for now. continue } - t.Methods = append(t.Methods, buildMockMethod(fld.Names[0].Name, ft)) + t.Methods = append(t.Methods, buildMockMethod(fld.Names[0].Name, ft, pkgName)) } return t } @@ -273,13 +273,16 @@ func buildInterfaceTarget(name, sourcePath, pkgName string, imports []MockImport // buildMockMethod produces one MockMethod from a method's FuncType, // flattening parameter lists (multi-name fields like `a, b int` become // two MockParam entries) and detecting whether ctx.Context is first. -func buildMockMethod(name string, ft *ast.FuncType) MockMethod { +// pkgName qualifies package-local type identifiers (see +// qualifyLocalTypes) so the emitted expressions compile inside +// package mocks. +func buildMockMethod(name string, ft *ast.FuncType, pkgName string) MockMethod { m := MockMethod{Name: name} if ft.Params != nil { - m.Params = flattenFuncFieldList(ft.Params) + m.Params = flattenFuncFieldList(ft.Params, pkgName) } if ft.Results != nil { - m.Returns = flattenFuncFieldList(ft.Results) + m.Returns = flattenFuncFieldList(ft.Results, pkgName) } if len(m.Params) > 0 && strings.HasSuffix(m.Params[0].Type, "context.Context") { m.HasContext = true @@ -289,10 +292,10 @@ func buildMockMethod(name string, ft *ast.FuncType) MockMethod { // flattenFuncFieldList turns Go's grouped-name field list (e.g. a, b int) // into a flat sequence of MockParam{Name, Type} entries. -func flattenFuncFieldList(fl *ast.FieldList) []MockParam { +func flattenFuncFieldList(fl *ast.FieldList, pkgName string) []MockParam { var out []MockParam for _, fld := range fl.List { - typ := exprString(fld.Type) + typ := exprString(qualifyLocalTypes(fld.Type, pkgName)) if len(fld.Names) == 0 { out = append(out, MockParam{Type: typ}) continue @@ -304,6 +307,79 @@ func flattenFuncFieldList(fl *ast.FieldList) []MockParam { return out } +// qualifyLocalTypes rewrites a type expression so it compiles from +// OUTSIDE the interface's package: a bare exported identifier +// (`InboxAttachment`) resolves to the interface package's own scope in +// the source file, so the mock (package mocks) must say +// `interfaces.InboxAttachment`. Bare exported idents are exactly the +// package-local case — every predeclared type is lowercase, and +// cross-package references are already SelectorExprs. Unexported bare +// idents are left alone: a mock in another package could never +// reference them anyway, and qualifying wouldn't fix that. +// +// The rewrite returns fresh nodes for the paths it touches and never +// mutates the parsed file's AST (other consumers may still read it). +func qualifyLocalTypes(e ast.Expr, pkgName string) ast.Expr { + if e == nil || pkgName == "" { + return e + } + switch n := e.(type) { + case *ast.Ident: + if n.IsExported() { + return &ast.SelectorExpr{X: ast.NewIdent(pkgName), Sel: ast.NewIdent(n.Name)} + } + return n + case *ast.SelectorExpr: + return n // already qualified (other package) — leave verbatim + case *ast.StarExpr: + return &ast.StarExpr{X: qualifyLocalTypes(n.X, pkgName)} + case *ast.ArrayType: + return &ast.ArrayType{Len: n.Len, Elt: qualifyLocalTypes(n.Elt, pkgName)} + case *ast.Ellipsis: + return &ast.Ellipsis{Elt: qualifyLocalTypes(n.Elt, pkgName)} + case *ast.MapType: + return &ast.MapType{ + Key: qualifyLocalTypes(n.Key, pkgName), + Value: qualifyLocalTypes(n.Value, pkgName), + } + case *ast.ChanType: + return &ast.ChanType{Dir: n.Dir, Value: qualifyLocalTypes(n.Value, pkgName)} + case *ast.IndexExpr: // generic instantiation with one type arg + return &ast.IndexExpr{ + X: qualifyLocalTypes(n.X, pkgName), + Index: qualifyLocalTypes(n.Index, pkgName), + } + case *ast.IndexListExpr: // generic instantiation with several type args + indices := make([]ast.Expr, len(n.Indices)) + for i, idx := range n.Indices { + indices[i] = qualifyLocalTypes(idx, pkgName) + } + return &ast.IndexListExpr{X: qualifyLocalTypes(n.X, pkgName), Indices: indices} + case *ast.FuncType: + return &ast.FuncType{ + Params: qualifyFieldList(n.Params, pkgName), + Results: qualifyFieldList(n.Results, pkgName), + } + case *ast.ParenExpr: + return &ast.ParenExpr{X: qualifyLocalTypes(n.X, pkgName)} + } + // Struct/interface literals and anything exotic pass through + // verbatim — same behavior as before this fix. + return e +} + +// qualifyFieldList maps qualifyLocalTypes over a func-type field list. +func qualifyFieldList(fl *ast.FieldList, pkgName string) *ast.FieldList { + if fl == nil { + return nil + } + fields := make([]*ast.Field, len(fl.List)) + for i, f := range fl.List { + fields[i] = &ast.Field{Names: f.Names, Type: qualifyLocalTypes(f.Type, pkgName)} + } + return &ast.FieldList{List: fields} +} + // writeMockForTarget renders and writes the mock for one resolved // interface. Honors opts.Check (drift detection) and the package-wide // dry-run state. @@ -401,8 +477,17 @@ func renderMock(d MockData) []byte { emitMockMethod(&b, mockType, m) } - formatted, err := format.Source(b.Bytes()) + // imports.Process (goimports) both formats and PRUNES unused + // imports: the mock re-emits every import from the interface's + // source file, but many (errors for sentinel docs, packages used + // only by other declarations in that file) never appear in the + // method signatures. format.Source alone would leave them and the + // build would fail. + formatted, err := goimports.Process(d.OutPath, b.Bytes(), nil) if err != nil { + if fallback, ferr := format.Source(b.Bytes()); ferr == nil { + return fallback + } return b.Bytes() } return formatted diff --git a/internal/generate/gen_mock_gap_test.go b/internal/generate/gen_mock_gap_test.go index 8a432b5..800449b 100644 --- a/internal/generate/gen_mock_gap_test.go +++ b/internal/generate/gen_mock_gap_test.go @@ -304,14 +304,22 @@ func TestDeriveImportPath_NormalDir(t *testing.T) { // — renderMock: ensure imports with no alias use bare-string form. func TestRenderMock_BareImport(t *testing.T) { + // A bare (unaliased) import referenced by a method signature renders + // without an alias; imports the signatures never use are PRUNED + // (goimports pass) — the interface's source file routinely imports + // packages for its other declarations. body := renderMock(MockData{ Interface: "I", PackageImport: "ex/m/p", PackageAlias: "p", - ExtraImports: []MockImport{{Path: "fmt"}}, - Methods: []MockMethod{{Name: "F"}}, + ExtraImports: []MockImport{{Path: "fmt"}, {Path: "errors"}}, + Methods: []MockMethod{{ + Name: "F", + Returns: []MockParam{{Type: "fmt.Stringer"}}, + }}, }) require.Contains(t, string(body), `"fmt"`) + require.NotContains(t, string(body), `"errors"`, "unused source-file imports must be pruned") } // — scanFileForInterfaces: non-GenDecl skipped (line 217-218). @@ -416,3 +424,48 @@ func TestRenderMock_FormatFallsBackOnInvalidSource(t *testing.T) { // Doc-comment marker still present even though gofmt failed. require.True(t, bytes.HasPrefix(body, []byte("// Code generated"))) } + +// — qualifyLocalTypes: package-local exported types must be qualified +// with the interface package name so the mock compiles from package +// mocks. Regression for the senda-v2 Inbox/Metrics alias bug. + +func TestBuildInterfaceTarget_QualifiesPackageLocalTypes(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "iface.go") + require.NoError(t, os.WriteFile(path, []byte(`package interfaces +import "context" + +type Filters struct{ Channel string } +type Attachment struct{ Name string } + +type MetricsSvc interface { + Summary(ctx context.Context, f Filters) ([]*Attachment, error) + ByNames(ctx context.Context, names []string, byKey map[string]Filters) (*Attachment, error) + Stream(ctx context.Context, ch chan Filters, fn func(Filters) (*Attachment, error)) error + Variadic(ctx context.Context, fs ...Filters) error +} +`), 0o644)) + targets, err := scanFileForInterfaces(path) + require.NoError(t, err) + require.Equal(t, 1, len(targets)) + + m := targets[0].Methods[0] // Summary + require.Equal(t, "interfaces.Filters", m.Params[1].Type) + require.Equal(t, "[]*interfaces.Attachment", m.Returns[0].Type) + require.Equal(t, "error", m.Returns[1].Type, "predeclared types stay bare") + + m = targets[0].Methods[1] // ByNames + require.Equal(t, "[]string", m.Params[1].Type, "builtin element types stay bare") + require.Equal(t, "map[string]interfaces.Filters", m.Params[2].Type) + require.Equal(t, "*interfaces.Attachment", m.Returns[0].Type) + + m = targets[0].Methods[2] // Stream + require.Equal(t, "chan interfaces.Filters", m.Params[1].Type) + require.Equal(t, "func(interfaces.Filters) (*interfaces.Attachment, error)", m.Params[2].Type) + + m = targets[0].Methods[3] // Variadic + require.Equal(t, "...interfaces.Filters", m.Params[1].Type) + + // Cross-package references stay verbatim. + require.Equal(t, "context.Context", targets[0].Methods[0].Params[0].Type) +} diff --git a/internal/generate/gen_model.go b/internal/generate/gen_model.go index f3106d2..d3f6744 100644 --- a/internal/generate/gen_model.go +++ b/internal/generate/gen_model.go @@ -1,12 +1,10 @@ package generate import ( - "fmt" - "github.com/gofastadev/cli/internal/generate/templates" ) // GenModel writes a GORM model file for the scaffolded resource. func GenModel(d ScaffoldData) error { - return WriteTemplate(fmt.Sprintf("app/models/%s.model.go", d.SnakeName), "model", templates.Model, d) + return WriteTemplate(d.L().ModelFile(d.SnakeName), "model", templates.Model, d) } diff --git a/internal/generate/gen_provider.go b/internal/generate/gen_provider.go index 4a232de..5037a17 100644 --- a/internal/generate/gen_provider.go +++ b/internal/generate/gen_provider.go @@ -1,12 +1,12 @@ package generate import ( - "fmt" - "github.com/gofastadev/cli/internal/generate/templates" ) -// GenWireProvider writes a Wire provider file for the scaffolded resource. +// GenWireProvider writes a Wire provider file for the scaffolded +// resource. Layered puts it under app/di/providers/; feature puts it +// inside the per-resource directory at app//wire.go. func GenWireProvider(d ScaffoldData) error { - return WriteTemplate(fmt.Sprintf("app/di/providers/%s.go", d.SnakeName), "wire_provider", templates.WireProvider, d) + return WriteTemplate(d.L().WireProviderFile(d.SnakeName), "wire_provider", templates.WireProvider, d) } diff --git a/internal/generate/gen_relation.go b/internal/generate/gen_relation.go index 5838b27..2c8f8c2 100644 --- a/internal/generate/gen_relation.go +++ b/internal/generate/gen_relation.go @@ -23,6 +23,7 @@ import ( "github.com/gofastadev/cli/internal/clierr" "github.com/gofastadev/cli/internal/generate/astpatch" + "github.com/gofastadev/cli/internal/layout" ) // RelationKind enumerates the supported gorm/sql relationship shapes. @@ -93,14 +94,20 @@ func GenRelation(d RelationData) error { } func relationDataDefaults(d RelationData) RelationData { - if d.Resource != "" && d.ResourceModel == "" { - d.ResourceModel = filepath.Join("app", "models", toSnakeCase(d.Resource)+".model.go") - } - if d.Other != "" && d.OtherModel == "" { - d.OtherModel = filepath.Join("app", "models", toSnakeCase(d.Other)+".model.go") - } - if d.MigrationDir == "" { - d.MigrationDir = filepath.Join("db", "migrations") + needsLayout := (d.Resource != "" && d.ResourceModel == "") || + (d.Other != "" && d.OtherModel == "") || + d.MigrationDir == "" + if needsLayout { + lo := layout.Detect() + if d.Resource != "" && d.ResourceModel == "" { + d.ResourceModel = lo.ModelFile(toSnakeCase(d.Resource)) + } + if d.Other != "" && d.OtherModel == "" { + d.OtherModel = lo.ModelFile(toSnakeCase(d.Other)) + } + if d.MigrationDir == "" { + d.MigrationDir = lo.MigrationsDir() + } } if d.MigrationVer == "" { d.MigrationVer = nextMigrationNumber() diff --git a/internal/generate/gen_rename.go b/internal/generate/gen_rename.go index 4ef4659..9c3726f 100644 --- a/internal/generate/gen_rename.go +++ b/internal/generate/gen_rename.go @@ -28,6 +28,7 @@ import ( "strings" "github.com/gofastadev/cli/internal/clierr" + "github.com/gofastadev/cli/internal/layout" ) // RenameData is the resolved input. @@ -117,15 +118,18 @@ func validateRename(d RenameData) error { // renameTargets returns the file paths a rename can touch for a given // resource. Missing files are skipped at apply time so model-only -// resources work fine. +// resources work fine. Paths are layout-dependent — layered scatters +// the resource's files across layer dirs; feature collapses them into +// app//. func renameTargets(resource string) []string { snake := toSnakeCase(resource) + lo := layout.Detect() return []string{ - filepath.Join("app", "models", snake+".model.go"), - filepath.Join("app", "dtos", snake+".dtos.go"), - filepath.Join("app", "services", snake+".service.go"), - filepath.Join("app", "services", snake+".service_test.go"), - filepath.Join("app", "repositories", snake+".repository.go"), + lo.ModelFile(snake), + lo.DTOsFile(snake), + lo.SvcImplFile(snake), + lo.SvcTestFile(snake), + lo.RepoImplFile(snake), } } diff --git a/internal/generate/gen_repo_method.go b/internal/generate/gen_repo_method.go index 8a7de8e..8b3cff2 100644 --- a/internal/generate/gen_repo_method.go +++ b/internal/generate/gen_repo_method.go @@ -11,8 +11,9 @@ package generate import ( - "path/filepath" "strings" + + "github.com/gofastadev/cli/internal/layout" ) // GenRepoMethod is a thin wrapper that fills in repo-specific defaults @@ -30,11 +31,14 @@ func GenRepoMethod(d MethodData) error { if d.ImplStructName == "" { d.ImplStructName = strings.ToLower(d.Resource[:1]) + d.Resource[1:] + "Repository" } - if d.InterfaceFile == "" { - d.InterfaceFile = filepath.Join("app", "repositories", "interfaces", snake+"_repository.go") - } - if d.ImplFile == "" { - d.ImplFile = filepath.Join("app", "repositories", snake+".repository.go") + if d.InterfaceFile == "" || d.ImplFile == "" { + lo := layout.Detect() + if d.InterfaceFile == "" { + d.InterfaceFile = lo.RepoIfaceFile(snake) + } + if d.ImplFile == "" { + d.ImplFile = lo.RepoImplFile(snake) + } } d.Snake = snake return GenMethod(d) diff --git a/internal/generate/gen_repository.go b/internal/generate/gen_repository.go index e436ac0..4c28500 100644 --- a/internal/generate/gen_repository.go +++ b/internal/generate/gen_repository.go @@ -1,17 +1,15 @@ package generate import ( - "fmt" - "github.com/gofastadev/cli/internal/generate/templates" ) // GenRepoInterface writes the repository interface file for the scaffolded resource. func GenRepoInterface(d ScaffoldData) error { - return WriteTemplate(fmt.Sprintf("app/repositories/interfaces/%s_repository.go", d.SnakeName), "repo_iface", templates.RepoInterface, d) + return WriteTemplate(d.L().RepoIfaceFile(d.SnakeName), "repo_iface", templates.RepoInterface, d) } // GenRepo writes the repository implementation file for the scaffolded resource. func GenRepo(d ScaffoldData) error { - return WriteTemplate(fmt.Sprintf("app/repositories/%s.repository.go", d.SnakeName), "repo", templates.Repo, d) + return WriteTemplate(d.L().RepoImplFile(d.SnakeName), "repo", templates.Repo, d) } diff --git a/internal/generate/gen_repository_testfile.go b/internal/generate/gen_repository_testfile.go index 1794967..548baa5 100644 --- a/internal/generate/gen_repository_testfile.go +++ b/internal/generate/gen_repository_testfile.go @@ -1,8 +1,6 @@ package generate import ( - "fmt" - "github.com/gofastadev/cli/internal/generate/templates" ) @@ -12,7 +10,7 @@ import ( // SoftDeleteIfDeletable, List pagination. func GenRepoTestFile(d ScaffoldData) error { return WriteTemplate( - fmt.Sprintf("app/repositories/%s.repository_test.go", d.SnakeName), + d.L().RepoTestFile(d.SnakeName), "repo_test", templates.RepoTest, d, ) } diff --git a/internal/generate/gen_routes.go b/internal/generate/gen_routes.go index 4a690c5..232fa50 100644 --- a/internal/generate/gen_routes.go +++ b/internal/generate/gen_routes.go @@ -1,12 +1,10 @@ package generate import ( - "fmt" - "github.com/gofastadev/cli/internal/generate/templates" ) // GenRoutes writes the route-registration file for the scaffolded resource. func GenRoutes(d ScaffoldData) error { - return WriteTemplate(fmt.Sprintf("app/rest/routes/%s.routes.go", d.SnakeName), "routes", templates.Routes, d) + return WriteTemplate(d.L().RoutesFile(d.SnakeName), "routes", templates.Routes, d) } diff --git a/internal/generate/gen_service.go b/internal/generate/gen_service.go index a78cfb3..ca6daba 100644 --- a/internal/generate/gen_service.go +++ b/internal/generate/gen_service.go @@ -1,17 +1,15 @@ package generate import ( - "fmt" - "github.com/gofastadev/cli/internal/generate/templates" ) // GenSvcInterface writes the service interface file for the scaffolded resource. func GenSvcInterface(d ScaffoldData) error { - return WriteTemplate(fmt.Sprintf("app/services/interfaces/%s_service.go", d.SnakeName), "svc_iface", templates.SvcInterface, d) + return WriteTemplate(d.L().SvcIfaceFile(d.SnakeName), "svc_iface", templates.SvcInterface, d) } // GenSvc writes the service implementation file for the scaffolded resource. func GenSvc(d ScaffoldData) error { - return WriteTemplate(fmt.Sprintf("app/services/%s.service.go", d.SnakeName), "svc", templates.Svc, d) + return WriteTemplate(d.L().SvcImplFile(d.SnakeName), "svc", templates.Svc, d) } diff --git a/internal/generate/gen_service_testfile.go b/internal/generate/gen_service_testfile.go index 50bfe4a..59d5a3a 100644 --- a/internal/generate/gen_service_testfile.go +++ b/internal/generate/gen_service_testfile.go @@ -1,8 +1,6 @@ package generate import ( - "fmt" - "github.com/gofastadev/cli/internal/generate/templates" ) @@ -11,7 +9,7 @@ import ( // Update, Archive sentinel paths + happy paths. func GenSvcTestFile(d ScaffoldData) error { return WriteTemplate( - fmt.Sprintf("app/services/%s.service_test.go", d.SnakeName), + d.L().SvcTestFile(d.SnakeName), "svc_test", templates.SvcTest, d, ) } diff --git a/internal/generate/patcher.go b/internal/generate/patcher.go index 0c1aa12..24c3bb4 100644 --- a/internal/generate/patcher.go +++ b/internal/generate/patcher.go @@ -16,8 +16,11 @@ import ( const containerFieldsMarker = "// gofasta:scaffold:container-fields" // PatchContainer adds repo/service/controller fields to app/di/container.go. +// Field type qualifiers depend on the project layout — in layered mode +// the fields reference repoInterfaces/svcInterfaces/controllers, in +// feature mode they reference the per-feature package alias. func PatchContainer(d ScaffoldData) error { - path := "app/di/container.go" + path := d.L().ContainerFile() content, err := os.ReadFile(path) if err != nil { return err @@ -29,16 +32,38 @@ func PatchContainer(d ScaffoldData) error { return nil } - repoImport := fmt.Sprintf("\trepoInterfaces \"%s/app/repositories/interfaces\"", d.ModulePath) - controllersImport := fmt.Sprintf("\"%s/app/rest/controllers\"", d.ModulePath) - if !strings.Contains(s, "repoInterfaces") { - s = strings.Replace(s, "\t"+controllersImport, repoImport+"\n\t"+controllersImport, 1) - } - - fields := fmt.Sprintf("\t%sRepo repoInterfaces.%sRepositoryInterface\n\t%sService svcInterfaces.%sServiceInterface\n", - d.Name, d.Name, d.Name, d.Name) - if d.IncludeController { - fields += fmt.Sprintf("\t%sController *controllers.%sController\n", d.Name, d.Name) + var fields string + if d.L().IsFeature() { + // Feature layout: ensure `pkg "/app/"` is + // imported, then reference fields via the alias. + alias := d.SnakeName + "pkg" + featureImport := fmt.Sprintf("\t%s \"%s/app/%s\"", alias, d.ModulePath, d.SnakeName) + if !strings.Contains(s, featureImport) { + // Insert into the existing import block — anchored on the + // closing `)` of the first import GenDecl. + closeIdx := strings.Index(s, "\n)") + if closeIdx == -1 { + return fmt.Errorf("%s: could not locate import block close", path) + } + s = s[:closeIdx] + "\n" + featureImport + s[closeIdx:] + } + fields = fmt.Sprintf("\t%sRepo %s.%sRepositoryInterface\n\t%sService %s.%sServiceInterface\n", + d.Name, alias, d.Name, d.Name, alias, d.Name) + if d.IncludeController { + fields += fmt.Sprintf("\t%sController *%s.%sController\n", d.Name, alias, d.Name) + } + } else { + // Layered layout: original behavior. + repoImport := fmt.Sprintf("\trepoInterfaces \"%s/app/repositories/interfaces\"", d.ModulePath) + controllersImport := fmt.Sprintf("\"%s/app/rest/controllers\"", d.ModulePath) + if !strings.Contains(s, "repoInterfaces") { + s = strings.Replace(s, "\t"+controllersImport, repoImport+"\n\t"+controllersImport, 1) + } + fields = fmt.Sprintf("\t%sRepo repoInterfaces.%sRepositoryInterface\n\t%sService svcInterfaces.%sServiceInterface\n", + d.Name, d.Name, d.Name, d.Name) + if d.IncludeController { + fields += fmt.Sprintf("\t%sController *controllers.%sController\n", d.Name, d.Name) + } } if !strings.Contains(s, containerFieldsMarker) { @@ -56,15 +81,33 @@ func PatchContainer(d ScaffoldData) error { const wireProvidersMarker = "// gofasta:scaffold:wire-providers" // PatchWireFile adds the provider set to wire.Build in app/di/wire.go. +// Provider reference depends on layout: `providers.Set` in +// layered, `pkg.Set` in feature. func PatchWireFile(d ScaffoldData) error { - path := "app/di/wire.go" + path := d.L().WireFile() content, err := os.ReadFile(path) if err != nil { return err } s := string(content) - providerRef := fmt.Sprintf("providers.%sSet", d.Name) + var providerRef string + if d.L().IsFeature() { + alias := d.SnakeName + "pkg" + providerRef = fmt.Sprintf("%s.%sSet", alias, d.Name) + // Ensure the feature package import is present. + featureImport := fmt.Sprintf("\t%s \"%s/app/%s\"", alias, d.ModulePath, d.SnakeName) + if !strings.Contains(s, featureImport) { + closeIdx := strings.Index(s, "\n)") + if closeIdx == -1 { + return fmt.Errorf("%s: could not locate import block close", path) + } + s = s[:closeIdx] + "\n" + featureImport + s[closeIdx:] + } + } else { + providerRef = fmt.Sprintf("providers.%sSet", d.Name) + } + if strings.Contains(s, providerRef) { cliout.Skip(path, "already wired") return nil @@ -82,7 +125,7 @@ func PatchWireFile(d ScaffoldData) error { // PatchResolver adds a service field and constructor param to app/graphql/resolvers/resolver.go. func PatchResolver(d ScaffoldData) error { - path := "app/graphql/resolvers/resolver.go" + path := d.L().ResolverFile() content, err := os.ReadFile(path) if err != nil { return err @@ -129,7 +172,7 @@ func PatchResolver(d ScaffoldData) error { // PatchRouteConfig adds controller to RouteConfig and registers routes in app/rest/routes/index.routes.go. func PatchRouteConfig(d ScaffoldData) error { - path := "app/rest/routes/index.routes.go" + path := d.L().RouteIndexFile() content, err := os.ReadFile(path) if err != nil { return err @@ -149,13 +192,28 @@ func PatchRouteConfig(d ScaffoldData) error { return fmt.Errorf("%s is missing the %q marker — restore the marker comment to enable code generation", path, routeRegistrationsMarker) } - newField := fmt.Sprintf("\t%s *controllers.%sController", controllerField, d.Name) + var newField, routeCall string + if d.L().IsFeature() { + alias := d.SnakeName + "pkg" + // Ensure the feature import is in the import block. + featureImport := fmt.Sprintf("\t%s \"%s/app/%s\"", alias, d.ModulePath, d.SnakeName) + if !strings.Contains(s, featureImport) { + closeIdx := strings.Index(s, "\n)") + if closeIdx == -1 { + return fmt.Errorf("%s: could not locate import block close", path) + } + s = s[:closeIdx] + "\n" + featureImport + s[closeIdx:] + } + newField = fmt.Sprintf("\t%s *%s.%sController", controllerField, alias, d.Name) + routeCall = fmt.Sprintf("\t%s.RegisterRoutes(api, config.%s)", alias, controllerField) + } else { + newField = fmt.Sprintf("\t%s *controllers.%sController", controllerField, d.Name) + routeCall = fmt.Sprintf("\t%sRoutes(api, config.%s)", d.Name, controllerField) + } s = strings.Replace(s, "\t"+routeConfigFieldsMarker, newField+"\n\t"+routeConfigFieldsMarker, 1) - - routeCall := fmt.Sprintf("\t%sRoutes(api, config.%s)", d.Name, controllerField) s = strings.Replace(s, "\t"+routeRegistrationsMarker, routeCall+"\n\t"+routeRegistrationsMarker, @@ -181,7 +239,7 @@ const routeConfigInitMarker = "// gofasta:scaffold:routeconfig-init" // PatchServeFile adds the controller to RouteConfig initialization in cmd/serve.go. func PatchServeFile(d ScaffoldData) error { - path := "cmd/serve.go" + path := d.L().ServeFile() content, err := os.ReadFile(path) if err != nil { return err diff --git a/internal/generate/scaffold_data.go b/internal/generate/scaffold_data.go index 8a367b0..a8b811a 100644 --- a/internal/generate/scaffold_data.go +++ b/internal/generate/scaffold_data.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/gofastadev/cli/internal/commands/configutil" + "github.com/gofastadev/cli/internal/layout" ) // BuildScaffoldData converts a resource name and fields into fully computed ScaffoldData. @@ -30,6 +31,7 @@ func BuildScaffoldData(name string, fields []Field) ScaffoldData { MigrationNum: nextMigrationNumber(), DBDriver: driver, ModulePath: readModulePath(), + Layout: layout.Detect(), } } diff --git a/internal/generate/testhelpers_test.go b/internal/generate/testhelpers_test.go index 55e26d3..61ca6b5 100644 --- a/internal/generate/testhelpers_test.go +++ b/internal/generate/testhelpers_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/gofastadev/cli/internal/layout" ) // setupTempProject creates a temp dir with a minimal go.mod and db/migrations dir, @@ -41,6 +43,7 @@ func sampleScaffoldData() ScaffoldData { IncludeGraphQL: false, DBDriver: "postgres", ModulePath: "github.com/testorg/testapp", + Layout: layout.For(layout.Layered), } } diff --git a/internal/generate/types.go b/internal/generate/types.go index f3db41c..35b6b6d 100644 --- a/internal/generate/types.go +++ b/internal/generate/types.go @@ -1,5 +1,7 @@ package generate +import "github.com/gofastadev/cli/internal/layout" + // Field represents a single field in a resource (parsed from "name:type" CLI args). type Field struct { Name string // PascalCase: ProductName @@ -33,6 +35,7 @@ type ScaffoldData struct { Schedule string // cron expression for job generator DBDriver string // database driver from config (postgres, mysql, sqlite, sqlserver, clickhouse) ModulePath string // Go module path read from go.mod (e.g., "github.com/myorg/myapp") + Layout layout.Layout } // HasTimeField reports whether any field on this resource is `time.Time`. @@ -47,6 +50,20 @@ func (s ScaffoldData) HasTimeField() bool { return false } +// L returns the layout for this scaffold operation, defaulting to +// layered when ScaffoldData was constructed without one. Production +// callers go through BuildScaffoldData which always sets Layout; this +// defaulting exists so test code that constructs ScaffoldData literals +// (and any future caller that does the same) keeps working without +// every test having to set the field. The default matches the historical +// behavior — every gen_*.go used to hardcode the layered paths. +func (s ScaffoldData) L() layout.Layout { + if s.Layout == nil { + return layout.For(layout.Layered) + } + return s.Layout +} + // Step is a single unit of work in a generator pipeline. type Step struct { Label string diff --git a/internal/generate/writer.go b/internal/generate/writer.go index 4e3108c..6d22ef2 100644 --- a/internal/generate/writer.go +++ b/internal/generate/writer.go @@ -8,6 +8,7 @@ import ( "time" "github.com/gofastadev/cli/internal/cliout" + "github.com/gofastadev/cli/internal/featurize" ) // tokenPair is one __TOKEN__ → value substitution for renderAndEmit. @@ -42,6 +43,12 @@ func renderAndEmit(path, tmpl string, substitutions []tokenPair) error { // the file already exists. In dry-run mode (see planner.go) the render // still happens — so template errors surface identically — but the file // is recorded in the plan instead of written to disk. +// +// When the project's layout is feature, the rendered Go source is +// piped through featurize so per-resource symbols collapse and the +// package decl matches the destination directory. Non-.go paths (SQL +// migrations, .gql schemas, HTML email templates) pass through +// unchanged because featurize only operates on parseable Go source. func WriteTemplate(path, name, tmpl string, data ScaffoldData) error { if _, err := os.Stat(path); err == nil { cliout.Skip(path, "exists") @@ -60,5 +67,35 @@ func WriteTemplate(path, name, tmpl string, data ScaffoldData) error { if err := t.Execute(&buf, data); err != nil { return err } - return writeOrRecordCreate(path, buf.Bytes()) + output := buf.Bytes() + if data.L().IsFeature() && shouldFeaturizeGenOutput(path, data.SnakeName) { + transformed, terr := featurize.TransformPerResource(output, featurize.Options{ + ModulePath: data.ModulePath, + Resource: featurize.Resource{ + Name: data.Name, + Snake: data.SnakeName, + Plural: data.PluralName, + }, + }) + if terr == nil { + output = transformed + } + // On transform error fall back to the un-transformed bytes so + // the user at least sees a file; the build error will point at + // the specific symbol resolution failure. + } + return writeOrRecordCreate(path, output) +} + +// shouldFeaturizeGenOutput reports whether a generated file's output +// should be piped through featurize. Only Go files inside the feature's +// own directory (`app//`) get the transform. Files emitted to +// shared locations (app/models/, app/validators/, etc.) skip it +// because their package decl and references are intentionally +// untouched in feature mode. +func shouldFeaturizeGenOutput(path, snake string) bool { + if !strings.HasSuffix(path, ".go") { + return false + } + return strings.HasPrefix(path, "app/"+snake+"/") } diff --git a/internal/layout/detect.go b/internal/layout/detect.go new file mode 100644 index 0000000..d8aeace --- /dev/null +++ b/internal/layout/detect.go @@ -0,0 +1,46 @@ +// detect.go — runtime layout resolution for generators. +// +// Generators call Detect() once per invocation to learn which layout the +// current project uses. Priority order: +// +// 1. configutil.ReadLayout() — the authoritative source. `gofasta new` +// writes `project.layout: layered|feature` to config.yaml at scaffold +// time, so any project created with a layout-aware CLI version has +// this field set. +// +// 2. Filesystem detection — if config.yaml doesn't carry a value (a +// project scaffolded before this feature shipped, or a config.yaml +// that was hand-written), look for `app/models/`. Its presence is +// the signature of the layered layout; absence implies feature. +// +// 3. Fall back to Layered as a safe default. Layered has always been +// the only output and remains the default for `gofasta new` without +// a flag. + +package layout + +import ( + "os" + + "github.com/gofastadev/cli/internal/commands/configutil" +) + +// Detect resolves the layout for the project rooted at the current +// working directory. See the package comment for the resolution order. +func Detect() Layout { + switch configutil.ReadLayout() { + case "feature": + return For(Feature) + case "layered": + return For(Layered) + } + // No explicit value in config.yaml — fall back to filesystem. + if _, err := os.Stat("app/models"); err == nil { + return For(Layered) + } + // app/models/ is absent but we have no positive feature signal + // either. Default Layered — the historical behavior — so an empty or + // unrelated directory doesn't suddenly route to a layout that won't + // produce a working project. + return For(Layered) +} diff --git a/internal/layout/feature.go b/internal/layout/feature.go new file mode 100644 index 0000000..6ed279b --- /dev/null +++ b/internal/layout/feature.go @@ -0,0 +1,115 @@ +// feature.go — the feature-package layout. +// +// Resource files collapse into a single per-resource directory: +// +// app//model.go +// app//dtos.go +// app//repository.go + repository_iface.go +// app//service.go + service_iface.go +// app//errors.go + inputs.go +// app//controller.go +// app//routes.go +// app//validators.go +// app//wire.go (per-feature Wire set) +// +// Shared concerns stay layered: app/di/, app/jobs/, app/tasks/, +// app/shared/, app/graphql/, app/validators/ (infra only), cmd/. +// app/rest/routes/index.routes.go also remains and is per-layout aware +// (it calls userpkg.RegisterRoutes(api, config.UserController) under +// feature, UserRoutes(api, config.UserController) under layered). + +package layout + +import ( + "fmt" + "path/filepath" +) + +// featureLayout is the feature-package implementation. +// +//revive:disable:exported // method docs live on the Layout interface +type featureLayout struct{} + +func (featureLayout) Kind() Kind { return Feature } +func (featureLayout) IsFeature() bool { return true } + +// Under Option B, models stay in app/models/ because DTO mappers in +// the feature need to reference *models.X without creating a cycle. +func (featureLayout) ModelFile(snake string) string { + return fmt.Sprintf("app/models/%s.model.go", snake) +} +func (featureLayout) RepoIfaceFile(snake string) string { + return fmt.Sprintf("app/%s/repository_iface.go", snake) +} +func (featureLayout) RepoImplFile(snake string) string { + return fmt.Sprintf("app/%s/repository.go", snake) +} +func (featureLayout) RepoTestFile(snake string) string { + return fmt.Sprintf("app/%s/repository_test.go", snake) +} +func (featureLayout) SvcIfaceFile(snake string) string { + return fmt.Sprintf("app/%s/service_iface.go", snake) +} +func (featureLayout) SvcImplFile(snake string) string { + return fmt.Sprintf("app/%s/service.go", snake) +} +func (featureLayout) SvcTestFile(snake string) string { + return fmt.Sprintf("app/%s/service_test.go", snake) +} +func (featureLayout) ErrorsFile(snake string) string { + return fmt.Sprintf("app/%s/errors.go", snake) +} +func (featureLayout) InputsFile(snake string) string { + return fmt.Sprintf("app/%s/inputs.go", snake) +} +func (featureLayout) InputsTestFile(snake string) string { + return fmt.Sprintf("app/%s/inputs_test.go", snake) +} +func (featureLayout) DTOsFile(snake string) string { + return fmt.Sprintf("app/%s/dtos.go", snake) +} +func (featureLayout) DTOsTestFile(snake string) string { + return fmt.Sprintf("app/%s/dtos_test.go", snake) +} +func (featureLayout) ControllerFile(snake string) string { + return fmt.Sprintf("app/%s/controller.go", snake) +} +func (featureLayout) ControllerTestFile(snake string) string { + return fmt.Sprintf("app/%s/controller_test.go", snake) +} +func (featureLayout) RoutesFile(snake string) string { + return fmt.Sprintf("app/%s/routes.go", snake) +} + +// Under Option B, per-resource validators stay in app/validators/ +// because register.go calls package-private helpers (isRecordExist*, +// isValidPhoneNumber) that would break if scattered into features. +func (featureLayout) ValidatorsFile(snake string) string { + return fmt.Sprintf("app/validators/%s.validators.go", snake) +} +func (featureLayout) WireProviderFile(snake string) string { + return fmt.Sprintf("app/%s/wire.go", snake) +} + +func (featureLayout) ContainerFile() string { return "app/di/container.go" } +func (featureLayout) WireFile() string { return "app/di/wire.go" } +func (featureLayout) RouteIndexFile() string { return "app/rest/routes/index.routes.go" } +func (featureLayout) ServeFile() string { return "cmd/serve.go" } +func (featureLayout) ResolverFile() string { return "app/graphql/resolvers/resolver.go" } + +// InterfaceDirs returns the per-feature directories `g mock --all` +// should walk. Excludes shared concern directories (di, jobs, tasks, +// graphql, main, devtools, shared, validators, rest). +// +// Implementation in Phase A is a placeholder — the feature layout isn't +// reachable from configutil.ReadLayout yet because the `--layout=feature` +// flag is wired in Phase B. The full directory-walking implementation +// lands when Phase C extends gen_mock.go. +func (featureLayout) InterfaceDirs() []string { + // Phase-C TODO: walk app/*/ filtered by IsFeatureResourceDir. + // For Phase A this is unreachable (Detect always returns Layered). + return nil +} + +func (featureLayout) RoutesDir() string { return filepath.Join("app", "rest", "routes") } +func (featureLayout) MigrationsDir() string { return filepath.Join("db", "migrations") } diff --git a/internal/layout/layered.go b/internal/layout/layered.go new file mode 100644 index 0000000..15e7dc7 --- /dev/null +++ b/internal/layout/layered.go @@ -0,0 +1,117 @@ +// layered.go — the historical "one directory per layer" layout. +// +// Every literal string this file returns is what the generators +// previously hardcoded via `fmt.Sprintf("app/models/%s.model.go", snake)` +// and friends. The byte-for-byte regression test in layout_test.go pins +// these so a refactor of the call sites can't drift the on-disk shape. + +package layout + +import ( + "fmt" + "path/filepath" +) + +// layeredLayout is the layered-architecture implementation. +// +// Resource files spread across layer directories: +// +// app/models/.model.go +// app/dtos/.dtos.go +// app/repositories/.repository.go + interfaces/_repository.go +// app/services/.service.go + interfaces/_service.go +// app/services/_errors.go + _inputs.go +// app/rest/controllers/.controller.go +// app/rest/routes/.routes.go +// app/validators/.validators.go +// app/di/providers/.go +// +//revive:disable:exported // method docs live on the Layout interface +type layeredLayout struct{} + +func (layeredLayout) Kind() Kind { return Layered } +func (layeredLayout) IsFeature() bool { return false } + +func (layeredLayout) ModelFile(snake string) string { + return fmt.Sprintf("app/models/%s.model.go", snake) +} + +func (layeredLayout) RepoIfaceFile(snake string) string { + return fmt.Sprintf("app/repositories/interfaces/%s_repository.go", snake) +} + +func (layeredLayout) RepoImplFile(snake string) string { + return fmt.Sprintf("app/repositories/%s.repository.go", snake) +} + +func (layeredLayout) RepoTestFile(snake string) string { + return fmt.Sprintf("app/repositories/%s.repository_test.go", snake) +} + +func (layeredLayout) SvcIfaceFile(snake string) string { + return fmt.Sprintf("app/services/interfaces/%s_service.go", snake) +} + +func (layeredLayout) SvcImplFile(snake string) string { + return fmt.Sprintf("app/services/%s.service.go", snake) +} + +func (layeredLayout) SvcTestFile(snake string) string { + return fmt.Sprintf("app/services/%s.service_test.go", snake) +} + +func (layeredLayout) ErrorsFile(snake string) string { + return fmt.Sprintf("app/services/%s_errors.go", snake) +} + +func (layeredLayout) InputsFile(snake string) string { + return fmt.Sprintf("app/services/%s_inputs.go", snake) +} + +func (layeredLayout) InputsTestFile(snake string) string { + return fmt.Sprintf("app/services/%s_inputs_test.go", snake) +} + +func (layeredLayout) DTOsFile(snake string) string { + return fmt.Sprintf("app/dtos/%s.dtos.go", snake) +} + +func (layeredLayout) DTOsTestFile(snake string) string { + return fmt.Sprintf("app/dtos/%s.dtos_test.go", snake) +} + +func (layeredLayout) ControllerFile(snake string) string { + return fmt.Sprintf("app/rest/controllers/%s.controller.go", snake) +} + +func (layeredLayout) ControllerTestFile(snake string) string { + return fmt.Sprintf("app/rest/controllers/%s.controller_test.go", snake) +} + +func (layeredLayout) RoutesFile(snake string) string { + return fmt.Sprintf("app/rest/routes/%s.routes.go", snake) +} + +func (layeredLayout) ValidatorsFile(snake string) string { + return fmt.Sprintf("app/validators/%s.validators.go", snake) +} + +func (layeredLayout) WireProviderFile(snake string) string { + return fmt.Sprintf("app/di/providers/%s.go", snake) +} + +func (layeredLayout) ContainerFile() string { return "app/di/container.go" } +func (layeredLayout) WireFile() string { return "app/di/wire.go" } +func (layeredLayout) RouteIndexFile() string { return "app/rest/routes/index.routes.go" } +func (layeredLayout) ServeFile() string { return "cmd/serve.go" } +func (layeredLayout) ResolverFile() string { return "app/graphql/resolvers/resolver.go" } + +func (layeredLayout) InterfaceDirs() []string { + return []string{ + filepath.Join("app", "services", "interfaces"), + filepath.Join("app", "repositories", "interfaces"), + } +} + +func (layeredLayout) RoutesDir() string { return filepath.Join("app", "rest", "routes") } +func (layeredLayout) MigrationsDir() string { return filepath.Join("db", "migrations") } diff --git a/internal/layout/layout.go b/internal/layout/layout.go new file mode 100644 index 0000000..f72b9cf --- /dev/null +++ b/internal/layout/layout.go @@ -0,0 +1,143 @@ +// Package layout owns every project-structure-dependent path, import, +// and symbol used by the gofasta generators. It exists so the generators +// can be layout-agnostic — `Layout.ModelFile("user")` returns +// "app/models/user.model.go" under the layered layout and +// "app/user/model.go" under the feature layout, and the caller doesn't +// know the difference. +// +// Two implementations: +// +// - Layered (the historical default): one directory per layer +// (app/models/, app/services/, app/repositories/, app/dtos/, +// app/rest/controllers/, app/rest/routes/, app/validators/, +// app/di/providers/), with separate `interfaces` sub-packages. +// +// - Feature: one directory per resource (app//) containing +// every layer's file for that resource. Shared concerns +// (jobs/tasks/graphql/validators-infra/di) stay layered. +// +// The active layout for a project is recorded in config.yaml under +// `project.layout` and read via configutil.ReadLayout. Generators call +// Detect() once per invocation to resolve which Layout to use. +package layout + +// Kind enumerates the supported project layouts. +// +// Adding a new layout (e.g. "hexagonal") is a matter of (a) implementing +// the Layout interface, (b) extending For(), and (c) registering the +// flag value in cli/internal/commands/new.go's supportedLayouts. +type Kind int + +const ( + // Layered is the historical layout: one directory per layer. + Layered Kind = iota + + // Feature is the alternative layout: one directory per resource. + Feature +) + +// String returns the lowercase name of the layout, matching the value +// stored in config.yaml's `project.layout` field. +func (k Kind) String() string { + switch k { + case Feature: + return "feature" + default: + return "layered" + } +} + +// ParseKind converts the config.yaml value into a Kind. Unknown or empty +// values fall back to Layered — this keeps projects that pre-date the +// feature working without a config migration. +func ParseKind(s string) Kind { + switch s { + case "feature": + return Feature + default: + return Layered + } +} + +// Layout is the contract every project layout must satisfy. Methods are +// grouped by purpose: +// +// - Per-resource files: take a snake_case resource name and return the +// absolute-from-project-root path the generator should write to. +// +// - Per-layout single files: paths that exist once per project +// (container, wire, route index, serve, resolver). +// +// - Marker constants: scaffold templates ship marker comments that +// patchers use as insertion points. Layered and feature scaffolds +// ship the same markers; this method exists so future layouts can +// diverge without changing every patcher call site. +// +// - Import paths + symbol refs: templates use these to render +// layout-appropriate import blocks and identifier prefixes +// (e.g. `models.User` in layered, bare `User` in feature). +// +// - InterfaceDirs(): the directories `gofasta g mock --all` scans for +// interface declarations. +type Layout interface { + // Kind reports which layout this implementation represents. + Kind() Kind + + // IsFeature reports whether this layout is the feature-package layout. + // Templates use this as a shorthand for the most common branch. + IsFeature() bool + + // ----- Per-resource file paths ----- + + ModelFile(snake string) string + RepoIfaceFile(snake string) string + RepoImplFile(snake string) string + RepoTestFile(snake string) string + SvcIfaceFile(snake string) string + SvcImplFile(snake string) string + SvcTestFile(snake string) string + ErrorsFile(snake string) string + InputsFile(snake string) string + InputsTestFile(snake string) string + DTOsFile(snake string) string + DTOsTestFile(snake string) string + ControllerFile(snake string) string + ControllerTestFile(snake string) string + RoutesFile(snake string) string + ValidatorsFile(snake string) string + WireProviderFile(snake string) string + + // ----- Per-layout single files ----- + + ContainerFile() string + WireFile() string + RouteIndexFile() string + ServeFile() string + ResolverFile() string + + // ----- Directories scanned by tooling ----- + + // InterfaceDirs returns the directories `gofasta g mock --all` walks + // looking for interface declarations. + InterfaceDirs() []string + + // RoutesDir returns the directory `gofasta g middleware` scans for + // route registration files. + RoutesDir() string + + // MigrationsDir returns the path to the SQL migrations directory. + // Stable across layouts (always "db/migrations") but exposed via the + // interface so future layouts can override. + MigrationsDir() string +} + +// For returns the Layout implementation matching the given Kind. Unknown +// kinds fall back to layered. +func For(k Kind) Layout { + switch k { + case Feature: + return featureLayout{} + default: + return layeredLayout{} + } +} diff --git a/internal/layout/layout_test.go b/internal/layout/layout_test.go new file mode 100644 index 0000000..431f8c1 --- /dev/null +++ b/internal/layout/layout_test.go @@ -0,0 +1,156 @@ +// layout_test.go — snapshot tests pinning the path strings each layout +// returns. The layered snapshot in particular is the regression test +// promised by Phase A ("byte-for-byte equivalent to the pre-refactor +// hardcoded paths"); a failure here means a generator that used to +// write `app/models/.model.go` would now write somewhere else. + +package layout + +import "testing" + +func TestLayered_PerResourceFiles(t *testing.T) { + lo := For(Layered) + if got, want := lo.Kind(), Layered; got != want { + t.Fatalf("Kind() = %v, want %v", got, want) + } + if lo.IsFeature() { + t.Fatal("IsFeature() = true, want false for layered") + } + cases := []struct { + name string + got string + want string + }{ + {"ModelFile", lo.ModelFile("user"), "app/models/user.model.go"}, + {"RepoIfaceFile", lo.RepoIfaceFile("user"), "app/repositories/interfaces/user_repository.go"}, + {"RepoImplFile", lo.RepoImplFile("user"), "app/repositories/user.repository.go"}, + {"RepoTestFile", lo.RepoTestFile("user"), "app/repositories/user.repository_test.go"}, + {"SvcIfaceFile", lo.SvcIfaceFile("user"), "app/services/interfaces/user_service.go"}, + {"SvcImplFile", lo.SvcImplFile("user"), "app/services/user.service.go"}, + {"SvcTestFile", lo.SvcTestFile("user"), "app/services/user.service_test.go"}, + {"ErrorsFile", lo.ErrorsFile("user"), "app/services/user_errors.go"}, + {"InputsFile", lo.InputsFile("user"), "app/services/user_inputs.go"}, + {"InputsTestFile", lo.InputsTestFile("user"), "app/services/user_inputs_test.go"}, + {"DTOsFile", lo.DTOsFile("user"), "app/dtos/user.dtos.go"}, + {"DTOsTestFile", lo.DTOsTestFile("user"), "app/dtos/user.dtos_test.go"}, + {"ControllerFile", lo.ControllerFile("user"), "app/rest/controllers/user.controller.go"}, + {"ControllerTestFile", lo.ControllerTestFile("user"), "app/rest/controllers/user.controller_test.go"}, + {"RoutesFile", lo.RoutesFile("user"), "app/rest/routes/user.routes.go"}, + {"ValidatorsFile", lo.ValidatorsFile("user"), "app/validators/user.validators.go"}, + {"WireProviderFile", lo.WireProviderFile("user"), "app/di/providers/user.go"}, + } + for _, c := range cases { + if c.got != c.want { + t.Errorf("%s: got %q, want %q", c.name, c.got, c.want) + } + } +} + +func TestLayered_SingleFiles(t *testing.T) { + lo := For(Layered) + cases := []struct { + name string + got string + want string + }{ + {"ContainerFile", lo.ContainerFile(), "app/di/container.go"}, + {"WireFile", lo.WireFile(), "app/di/wire.go"}, + {"RouteIndexFile", lo.RouteIndexFile(), "app/rest/routes/index.routes.go"}, + {"ServeFile", lo.ServeFile(), "cmd/serve.go"}, + {"ResolverFile", lo.ResolverFile(), "app/graphql/resolvers/resolver.go"}, + } + for _, c := range cases { + if c.got != c.want { + t.Errorf("%s: got %q, want %q", c.name, c.got, c.want) + } + } +} + +func TestLayered_InterfaceDirs(t *testing.T) { + got := For(Layered).InterfaceDirs() + want := []string{"app/services/interfaces", "app/repositories/interfaces"} + if len(got) != len(want) { + t.Fatalf("InterfaceDirs(): got %v (len=%d), want %v (len=%d)", got, len(got), want, len(want)) + } + for i := range got { + if got[i] != want[i] { + t.Errorf("InterfaceDirs[%d]: got %q, want %q", i, got[i], want[i]) + } + } +} + +func TestLayered_OtherDirs(t *testing.T) { + lo := For(Layered) + if got, want := lo.RoutesDir(), "app/rest/routes"; got != want { + t.Errorf("RoutesDir(): got %q, want %q", got, want) + } + if got, want := lo.MigrationsDir(), "db/migrations"; got != want { + t.Errorf("MigrationsDir(): got %q, want %q", got, want) + } +} + +func TestFeature_PerResourceFiles(t *testing.T) { + lo := For(Feature) + if got, want := lo.Kind(), Feature; got != want { + t.Fatalf("Kind() = %v, want %v", got, want) + } + if !lo.IsFeature() { + t.Fatal("IsFeature() = false, want true for feature") + } + cases := []struct { + name string + got string + want string + }{ + // Under Option B, models stay in app/models/ in feature mode. + {"ModelFile", lo.ModelFile("user"), "app/models/user.model.go"}, + {"RepoIfaceFile", lo.RepoIfaceFile("user"), "app/user/repository_iface.go"}, + {"RepoImplFile", lo.RepoImplFile("user"), "app/user/repository.go"}, + {"RepoTestFile", lo.RepoTestFile("user"), "app/user/repository_test.go"}, + {"SvcIfaceFile", lo.SvcIfaceFile("user"), "app/user/service_iface.go"}, + {"SvcImplFile", lo.SvcImplFile("user"), "app/user/service.go"}, + {"SvcTestFile", lo.SvcTestFile("user"), "app/user/service_test.go"}, + {"ErrorsFile", lo.ErrorsFile("user"), "app/user/errors.go"}, + {"InputsFile", lo.InputsFile("user"), "app/user/inputs.go"}, + {"InputsTestFile", lo.InputsTestFile("user"), "app/user/inputs_test.go"}, + {"DTOsFile", lo.DTOsFile("user"), "app/user/dtos.go"}, + {"DTOsTestFile", lo.DTOsTestFile("user"), "app/user/dtos_test.go"}, + {"ControllerFile", lo.ControllerFile("user"), "app/user/controller.go"}, + {"ControllerTestFile", lo.ControllerTestFile("user"), "app/user/controller_test.go"}, + {"RoutesFile", lo.RoutesFile("user"), "app/user/routes.go"}, + // Under Option B, per-resource validators stay in app/validators/. + {"ValidatorsFile", lo.ValidatorsFile("user"), "app/validators/user.validators.go"}, + {"WireProviderFile", lo.WireProviderFile("user"), "app/user/wire.go"}, + } + for _, c := range cases { + if c.got != c.want { + t.Errorf("%s: got %q, want %q", c.name, c.got, c.want) + } + } +} + +func TestParseKind(t *testing.T) { + cases := []struct { + in string + want Kind + }{ + {"layered", Layered}, + {"feature", Feature}, + {"", Layered}, + {"unknown", Layered}, + } + for _, c := range cases { + if got := ParseKind(c.in); got != c.want { + t.Errorf("ParseKind(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +func TestKindString(t *testing.T) { + if got, want := Layered.String(), "layered"; got != want { + t.Errorf("Layered.String() = %q, want %q", got, want) + } + if got, want := Feature.String(), "feature"; got != want { + t.Errorf("Feature.String() = %q, want %q", got, want) + } +} diff --git a/internal/skeleton/embed.go b/internal/skeleton/embed.go index 2f373a3..0dc737d 100644 --- a/internal/skeleton/embed.go +++ b/internal/skeleton/embed.go @@ -3,7 +3,8 @@ package skeleton import "embed" -// ProjectFS holds the embedded skeleton project used by `gofasta new`. +// ProjectFS holds the embedded skeleton project used by `gofasta new` +// when --layout=layered (the default). // // The project tree intentionally ships NO db/migrations directory of // its own; the per-driver foundational migrations live in MigrationsFS @@ -12,6 +13,12 @@ import "embed" //go:embed all:project var ProjectFS embed.FS +// ProjectLayeredFS is a clearer-named alias for ProjectFS — new code +// (Phase B onward) should use this name to make layout selection +// explicit; the bare `ProjectFS` is kept for older callers and the +// embed_test.go invariant suite. +var ProjectLayeredFS = ProjectFS + // MigrationsFS holds the per-driver foundational migration sets that // `gofasta new --driver X` copies into the new project's db/migrations // directory. One subdirectory per supported driver: diff --git a/internal/skeleton/project/config.yaml.tmpl b/internal/skeleton/project/config.yaml.tmpl index db9e6f9..ff9d8c4 100644 --- a/internal/skeleton/project/config.yaml.tmpl +++ b/internal/skeleton/project/config.yaml.tmpl @@ -1,3 +1,12 @@ +project: + # Layout chosen at scaffold time. Read by `gofasta g *` and + # `gofasta refactor` to know whether to emit files into layered + # directories (app/models/, app/services/, …) or per-feature + # directories (app//). Don't change this by hand — use + # `gofasta refactor feature-package` to migrate an existing project + # between layouts. + layout: {{.Layout}} + server: # Bind address. The default 127.0.0.1 keeps the dev server on loopback, # which on macOS skips the firewall popup that would otherwise fire on