From b911caa7c7a0d2024638ea30161420c51694ebef Mon Sep 17 00:00:00 2001 From: descholar-ceo Date: Mon, 18 May 2026 14:39:34 +0200 Subject: [PATCH 1/8] ft: Add project restructure --- internal/commands/commands_exec_test.go | 12 +- internal/commands/configutil/configutil.go | 15 ++ internal/commands/new.go | 44 +++++- internal/commands/new_test.go | 26 ++-- internal/generate/gen_controller.go | 4 +- internal/generate/gen_controller_testfile.go | 4 +- internal/generate/gen_dtos.go | 4 +- internal/generate/gen_dtos_testfile.go | 4 +- internal/generate/gen_endpoint.go | 21 +-- internal/generate/gen_errors.go | 9 +- internal/generate/gen_field.go | 20 ++- internal/generate/gen_inputs.go | 10 +- internal/generate/gen_inputs_testfile.go | 4 +- internal/generate/gen_method.go | 15 +- internal/generate/gen_middleware.go | 3 +- internal/generate/gen_mock.go | 16 +- internal/generate/gen_model.go | 4 +- internal/generate/gen_provider.go | 8 +- internal/generate/gen_relation.go | 23 ++- internal/generate/gen_rename.go | 16 +- internal/generate/gen_repo_method.go | 16 +- internal/generate/gen_repository.go | 6 +- internal/generate/gen_repository_testfile.go | 4 +- internal/generate/gen_routes.go | 4 +- internal/generate/gen_service.go | 6 +- internal/generate/gen_service_testfile.go | 4 +- internal/generate/patcher.go | 10 +- internal/generate/scaffold_data.go | 2 + internal/generate/testhelpers_test.go | 3 + internal/generate/types.go | 17 ++ internal/layout/detect.go | 46 ++++++ internal/layout/feature.go | 109 +++++++++++++ internal/layout/layered.go | 117 ++++++++++++++ internal/layout/layout.go | 143 +++++++++++++++++ internal/layout/layout_test.go | 154 +++++++++++++++++++ internal/skeleton/embed.go | 9 +- internal/skeleton/project/config.yaml.tmpl | 9 ++ 37 files changed, 791 insertions(+), 130 deletions(-) create mode 100644 internal/layout/detect.go create mode 100644 internal/layout/feature.go create mode 100644 internal/layout/layered.go create mode 100644 internal/layout/layout.go create mode 100644 internal/layout/layout_test.go 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..89fa863 100644 --- a/internal/commands/new.go +++ b/internal/commands/new.go @@ -26,6 +26,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 +39,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 +59,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 +129,22 @@ 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, ", ")) + } + if layoutFlag == "feature" { + // Phase B.2 will wire ProjectFeatureFS. Until then, refuse + // rather than silently produce a layered project that + // contradicts the flag the user asked for. The error code + // is stable so docs / agents can branch on it. + return clierr.Newf(clierr.CodeInvalidName, + "--layout=feature is not yet implemented — the feature-package skeleton is being added in a follow-up. Use --layout=layered (or omit the flag) for now.") + } + return runNew(args[0], gql || gqlShort, driver, layoutFlag) }, } @@ -121,6 +154,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 +201,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 +231,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 +255,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) 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/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..e07e1ce 100644 --- a/internal/generate/gen_mock.go +++ b/internal/generate/gen_mock.go @@ -28,6 +28,7 @@ import ( "strings" "github.com/gofastadev/cli/internal/clierr" + "github.com/gofastadev/cli/internal/layout" ) // MockData is what GenMock needs to produce one mock file. @@ -111,11 +112,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 +160,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) 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..7562efd 100644 --- a/internal/generate/patcher.go +++ b/internal/generate/patcher.go @@ -17,7 +17,7 @@ const containerFieldsMarker = "// gofasta:scaffold:container-fields" // PatchContainer adds repo/service/controller fields to app/di/container.go. func PatchContainer(d ScaffoldData) error { - path := "app/di/container.go" + path := d.L().ContainerFile() content, err := os.ReadFile(path) if err != nil { return err @@ -57,7 +57,7 @@ const wireProvidersMarker = "// gofasta:scaffold:wire-providers" // PatchWireFile adds the provider set to wire.Build in app/di/wire.go. func PatchWireFile(d ScaffoldData) error { - path := "app/di/wire.go" + path := d.L().WireFile() content, err := os.ReadFile(path) if err != nil { return err @@ -82,7 +82,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 +129,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 @@ -181,7 +181,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/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..af09b0a --- /dev/null +++ b/internal/layout/feature.go @@ -0,0 +1,109 @@ +// 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 } + +func (featureLayout) ModelFile(snake string) string { + return fmt.Sprintf("app/%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) +} +func (featureLayout) ValidatorsFile(snake string) string { + return fmt.Sprintf("app/%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..e163098 --- /dev/null +++ b/internal/layout/layout_test.go @@ -0,0 +1,154 @@ +// 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 + }{ + {"ModelFile", lo.ModelFile("user"), "app/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"}, + {"ValidatorsFile", lo.ValidatorsFile("user"), "app/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 From 1b3599ca76794c850615e43f4ed98c55873ffe0a Mon Sep 17 00:00:00 2001 From: descholar-ceo Date: Mon, 18 May 2026 16:15:14 +0200 Subject: [PATCH 2/8] Add featurize --- internal/commands/new.go | 196 ++++- internal/featurize/featurize.go | 1160 ++++++++++++++++++++++++++ internal/featurize/featurize_test.go | 288 +++++++ 3 files changed, 1639 insertions(+), 5 deletions(-) create mode 100644 internal/featurize/featurize.go create mode 100644 internal/featurize/featurize_test.go diff --git a/internal/commands/new.go b/internal/commands/new.go index 89fa863..8fae005 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" @@ -137,12 +138,25 @@ After the command finishes, ` + "`cd`" + ` into the new directory and run layoutFlag, strings.Join(supportedLayouts, ", ")) } if layoutFlag == "feature" { - // Phase B.2 will wire ProjectFeatureFS. Until then, refuse - // rather than silently produce a layered project that - // contradicts the flag the user asked for. The error code - // is stable so docs / agents can branch on it. + // The AST-based featurize transformer (internal/featurize/) + // correctly handles per-resource files, mocks, cross-cutting + // container/wire/index.routes, and core.go imports. What it + // cannot resolve is a structural Go cycle in the scaffold: + // app/dtos/user.dtos.go's UserFromModel + ToCreateInput + // methods need both the model AND the service inputs; + // app/user/controller.go needs dtos for shared envelopes + // (TPaginationObjectDto). Wherever you put the dtos/inputs + // types you get a `feature → dtos → feature` cycle. Resolving + // it requires template surgery: rename `dtos.User` to + // `dtos.UserResponse`, move DTO mappers (ToCreateInput etc.) + // from dtos into the feature, or migrate TPaginationObjectDto + // out of `app/dtos/` into a shared package. None of that + // belongs in the AST transformer. + // + // Phase B.2 ships the transformer + flag plumbing; the + // template restructure lands in a follow-up. return clierr.Newf(clierr.CodeInvalidName, - "--layout=feature is not yet implemented — the feature-package skeleton is being added in a follow-up. Use --layout=layered (or omit the flag) for now.") + "--layout=feature is not yet ready — the featurize transformer is in place but the scaffold templates need DTO/mapper restructuring to avoid an import cycle. Use --layout=layered for a working project today.") } return runNew(args[0], gql || gqlShort, driver, layoutFlag) }, @@ -362,6 +376,25 @@ func runNew(nameOrPath string, includeGraphQL bool, driver, layoutKind string) ( 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) }) @@ -628,3 +661,156 @@ 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 + } + } + } + + // Per-resource DTO files stay in app/dtos/ but their imports/refs + // flip from app/models + app/services to the feature package. + if strings.HasPrefix(outputPath, "app/dtos/") && + (strings.HasSuffix(outputPath, ".dtos.go") || strings.HasSuffix(outputPath, ".dtos_test.go")) { + base := filepath.Base(outputPath) + snake := strings.TrimSuffix(base, ".dtos.go") + snake = strings.TrimSuffix(snake, ".dtos_test.go") + for _, r := range resources { + if r.Snake == snake { + out, terr := featurize.TransformResourceDTO(content, mod, r) + 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/ but their + // imports may reference the feature package (rare today; user.validators.go + // doesn't, but a future resource scaffold could). + if strings.HasPrefix(outputPath, "app/validators/") && strings.HasSuffix(outputPath, ".validators.go") { + base := filepath.Base(outputPath) + snake := strings.TrimSuffix(base, ".validators.go") + for _, r := range resources { + if r.Snake == snake { + out, terr := featurize.TransformResourceDTO(content, mod, r) + if terr != nil { + return outputPath, content, fmt.Errorf("featurize %s: %w", outputPath, terr) + } + return outputPath, out, nil + } + } + } + + return outputPath, content, nil +} diff --git a/internal/featurize/featurize.go b/internal/featurize/featurize.go new file mode 100644 index 0000000..8e851e8 --- /dev/null +++ b/internal/featurize/featurize.go @@ -0,0 +1,1160 @@ +// 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. +package featurize + +import ( + "bytes" + "fmt" + "go/format" + "go/token" + "maps" + "strings" + + "github.com/dave/dst" + "github.com/dave/dst/decorator" +) + +// 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 +} + +// 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 (app/dtos/.dtos.go::FooFromModel) need to take +// them as arguments. Moving the model into the feature package +// creates a `dtos → feature → dtos` import cycle (the dtos +// mapper needs the model; the feature's controller imports dtos +// for shared envelopes like TPaginationObjectDto). Keeping models +// where they are is the pragmatic break. +// - app/dtos: keeps cross-feature DTO aliases (TPaginationObjectDto, +// SortOrientation) addressable. Also avoids the `dtos.User` vs +// model name collision that would otherwise occur. +// - 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. +// +// The feature-package layout shipped here is therefore a HYBRID: +// service, repository (impl + iface), controller, routes, wire +// provider, errors, inputs all live in app//. Models, DTOs, +// and validators stay in their layered package locations. This +// matches the "Standard Package Layout" school of Go architecture +// (Ben Johnson) and is documented as the layout's intended shape. +var collapsablePaths = []string{ + "/app/services", + "/app/services/interfaces", + "/app/repositories", + "/app/repositories/interfaces", + "/app/rest/controllers", +} + +// 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. +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) + + // 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 collapse SelectorExprs. + 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 + } + // 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: drop now-unused imports. Match by path → drop. + dropCollapsableImports(file, opts.ModulePath) + + // Step 5: 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) + + return renderFile(file) +} + +// 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 +// equivalent. Handles both regular and `_test` external-test packages. +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 +// positives on framework paths that happen to end in "/services" etc. +func isCollapsablePath(path, mod string) bool { + if mod == "" { + return false + } + for _, suffix := range collapsablePaths { + if path == mod+suffix { + return true + } + } + return false +} + +// 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:] +} + +// 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 +// it; format.Source then drops the empty block. +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 + } +} + +// 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 (avoids an import cycle if astpatch ever +// imports featurize). +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 +} + +// PathPair maps a layered path to its feature equivalent. +type PathPair struct { + Layered string + Feature string +} + +// 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 { + // dtos, models, and validators files intentionally NOT moved — see + // `collapsablePaths` for the rationale. They stay at their layered + // locations and are referenced via cross-package import from the + // feature package. + return []PathPair{ + {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)}, + } +} + +// ---------- 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", + }, + }) +} + +// 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 +// — 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{ + mod + "/app/models", + 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." + resource.Name: alias + "." + resource.Name, + "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 +// from one place. The four use different combinations of the same +// primitives (import drop/add, selector rewrite, call rewrite). +// +//nolint:gocognit,gocyclo // linear: parse → drop imports → add imports → rewrite selectors → rewrite calls. Splitting hides the pipeline order. +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) + } + + // Drop targeted imports. + if len(opts.dropImports) > 0 { + drop := map[string]bool{} + for _, p := range opts.dropImports { + drop[p] = true + } + keptImports := make([]*dst.ImportSpec, 0, len(file.Imports)) + for _, imp := range file.Imports { + if drop[strings.Trim(imp.Path.Value, `"`)] { + 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 drop[strings.Trim(is.Path.Value, `"`)] { + continue + } + kept = append(kept, is) + } + gd.Specs = kept + } + } + + // Add new imports. + for _, ai := range opts.addImports { + newImport := &dst.ImportSpec{ + Name: &dst.Ident{Name: ai.alias}, + Path: &dst.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", ai.path)}, + } + file.Imports = append(file.Imports, newImport) + // Append to the file's import GenDecl, or create a new one. + 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...) + } + } + + // Rewrite selectors and calls. + merged := mergeRewrites(opts.fieldRewrites, opts.selectorSwaps) + if len(merged) > 0 { + rewriteSelectors(file, merged) + } + if len(opts.callRewrites) > 0 { + rewriteCallNames(file, opts.callRewrites) + } + + return renderFile(file) +} + +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 +// SelectorExpr "A.B" parsed from the right-hand side. +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 +// transform where `UserRoutes(...)` → `userpkg.RegisterRoutes(...)`. +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 — +// extends as needed. +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: + 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/featurize_test.go b/internal/featurize/featurize_test.go new file mode 100644 index 0000000..b5f3a9f --- /dev/null +++ b/internal/featurize/featurize_test.go @@ -0,0 +1,288 @@ +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) + } + // dtos is intentionally NOT collapsed — it stays as a cross-package + // reference per the architectural decision in featurize.go. + if !strings.Contains(out, "dtos.User") { + t.Errorf("dtos.User reference should survive:\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) + } + if strings.Contains(out, `"example.com/myapp/app/di/providers"`) { + t.Errorf("providers import not dropped:\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 := 13 // models + dtos (2) + validators stay in shared layered dirs + 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) + } + } +} From e992b1b58da159d86513300366262ad56a47f17e Mon Sep 17 00:00:00 2001 From: descholar-ceo Date: Mon, 18 May 2026 18:55:21 +0200 Subject: [PATCH 3/8] Complete feature refactoring --- internal/clierr/codes.go | 16 + internal/commands/new.go | 72 +-- internal/featurize/featurize.go | 626 +++++++++++++++++++++++---- internal/featurize/featurize_test.go | 21 +- internal/generate/patcher.go | 86 +++- internal/generate/writer.go | 39 +- internal/layout/feature.go | 10 +- internal/layout/layout_test.go | 6 +- 8 files changed, 715 insertions(+), 161 deletions(-) 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/new.go b/internal/commands/new.go index 8fae005..1129417 100644 --- a/internal/commands/new.go +++ b/internal/commands/new.go @@ -137,27 +137,6 @@ After the command finishes, ` + "`cd`" + ` into the new directory and run "--layout %q is not supported — valid values: %s", layoutFlag, strings.Join(supportedLayouts, ", ")) } - if layoutFlag == "feature" { - // The AST-based featurize transformer (internal/featurize/) - // correctly handles per-resource files, mocks, cross-cutting - // container/wire/index.routes, and core.go imports. What it - // cannot resolve is a structural Go cycle in the scaffold: - // app/dtos/user.dtos.go's UserFromModel + ToCreateInput - // methods need both the model AND the service inputs; - // app/user/controller.go needs dtos for shared envelopes - // (TPaginationObjectDto). Wherever you put the dtos/inputs - // types you get a `feature → dtos → feature` cycle. Resolving - // it requires template surgery: rename `dtos.User` to - // `dtos.UserResponse`, move DTO mappers (ToCreateInput etc.) - // from dtos into the feature, or migrate TPaginationObjectDto - // out of `app/dtos/` into a shared package. None of that - // belongs in the AST transformer. - // - // Phase B.2 ships the transformer + flag plumbing; the - // template restructure lands in a follow-up. - return clierr.Newf(clierr.CodeInvalidName, - "--layout=feature is not yet ready — the featurize transformer is in place but the scaffold templates need DTO/mapper restructuring to avoid an import cycle. Use --layout=layered for a working project today.") - } return runNew(args[0], gql || gqlShort, driver, layoutFlag) }, } @@ -777,40 +756,33 @@ func featurizeFile(outputPath string, content []byte, mod string, resources []fe } } - // Per-resource DTO files stay in app/dtos/ but their imports/refs - // flip from app/models + app/services to the feature package. - if strings.HasPrefix(outputPath, "app/dtos/") && - (strings.HasSuffix(outputPath, ".dtos.go") || strings.HasSuffix(outputPath, ".dtos_test.go")) { - base := filepath.Base(outputPath) - snake := strings.TrimSuffix(base, ".dtos.go") - snake = strings.TrimSuffix(snake, ".dtos_test.go") - for _, r := range resources { - if r.Snake == snake { - out, terr := featurize.TransformResourceDTO(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 } } - // Per-resource validator files stay in app/validators/ but their - // imports may reference the feature package (rare today; user.validators.go - // doesn't, but a future resource scaffold could). - if strings.HasPrefix(outputPath, "app/validators/") && strings.HasSuffix(outputPath, ".validators.go") { - base := filepath.Base(outputPath) - snake := strings.TrimSuffix(base, ".validators.go") - for _, r := range resources { - if r.Snake == snake { - out, terr := featurize.TransformResourceDTO(content, mod, r) - if terr != nil { - return outputPath, content, fmt.Errorf("featurize %s: %w", outputPath, terr) - } - return outputPath, out, 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/featurize/featurize.go b/internal/featurize/featurize.go index 8e851e8..d77e71c 100644 --- a/internal/featurize/featurize.go +++ b/internal/featurize/featurize.go @@ -76,32 +76,53 @@ type Options struct { // Intentionally NOT collapsed: // // - app/models: model types stay in `package models` because DTO -// mappers (app/dtos/.dtos.go::FooFromModel) need to take -// them as arguments. Moving the model into the feature package -// creates a `dtos → feature → dtos` import cycle (the dtos -// mapper needs the model; the feature's controller imports dtos -// for shared envelopes like TPaginationObjectDto). Keeping models -// where they are is the pragmatic break. -// - app/dtos: keeps cross-feature DTO aliases (TPaginationObjectDto, -// SortOrientation) addressable. Also avoids the `dtos.User` vs -// model name collision that would otherwise occur. +// 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. // -// The feature-package layout shipped here is therefore a HYBRID: -// service, repository (impl + iface), controller, routes, wire -// provider, errors, inputs all live in app//. Models, DTOs, -// and validators stay in their layered package locations. This -// matches the "Standard Package Layout" school of Go architecture -// (Ben Johnson) and is documented as the layout's intended shape. +// 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 @@ -123,6 +144,8 @@ var collapsablePaths = []string{ // 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) @@ -133,6 +156,13 @@ func TransformPerResource(src []byte, opts Options) ([]byte, error) { // 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. @@ -146,7 +176,16 @@ func TransformPerResource(src []byte, opts Options) ([]byte, error) { collapseAliases[alias] = true } - // Step 3: walk and collapse SelectorExprs. + // 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 { @@ -159,6 +198,28 @@ func TransformPerResource(src []byte, opts Options) ([]byte, error) { 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. @@ -172,10 +233,30 @@ func TransformPerResource(src []byte, opts Options) ([]byte, error) { // sentinel ident. replaceSelectorsWithIdent(file) - // Step 4: drop now-unused imports. Match by path → drop. + // 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 5: drop duplicate var declarations that collide with the + // 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 @@ -184,9 +265,315 @@ func TransformPerResource(src []byte, opts Options) ([]byte, error) { // 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...) + } + } +} + +// 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...) +} + +// 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 +// reference remains, the import is just dropped by dropCollapsableImports. +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 +// requalification happens. +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 @@ -423,16 +810,32 @@ type PathPair struct { 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 { - // dtos, models, and validators files intentionally NOT moved — see - // `collapsablePaths` for the rationale. They stay at their layered - // locations and are referenced via cross-package import from the - // feature package. + // 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)}, @@ -522,6 +925,21 @@ func TransformResourceDTO(src []byte, mod string, resource Resource) ([]byte, er }) } +// 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 @@ -542,7 +960,9 @@ func TransformMock(src []byte, mod string, resource Resource) ([]byte, error) { alias := resource.Snake + "pkg" return transformCrossCutting(src, mod, []Resource{resource}, crossCuttingOptions{ dropImports: []string{ - mod + "/app/models", + // `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", @@ -551,7 +971,7 @@ func TransformMock(src []byte, mod string, resource Resource) ([]byte, error) { {alias: alias, path: mod + "/app/" + resource.Snake}, }, selectorSwaps: map[string]string{ - "models." + resource.Name: alias + "." + resource.Name, + // 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", @@ -685,8 +1105,6 @@ type crossCuttingOptions struct { // transformCrossCutting drives the four cross-cutting file transforms // from one place. The four use different combinations of the same // primitives (import drop/add, selector rewrite, call rewrite). -// -//nolint:gocognit,gocyclo // linear: parse → drop imports → add imports → rewrite selectors → rewrite calls. Splitting hides the pipeline order. func transformCrossCutting(src []byte, mod string, _ []Resource, opts crossCuttingOptions) ([]byte, error) { _ = mod dec := decorator.NewDecorator(token.NewFileSet()) @@ -695,79 +1113,111 @@ func transformCrossCutting(src []byte, mod string, _ []Resource, opts crossCutti return nil, fmt.Errorf("featurize: parse: %w", err) } - // Drop targeted imports. + // 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 { - drop := map[string]bool{} for _, p := range opts.dropImports { - drop[p] = true - } - keptImports := make([]*dst.ImportSpec, 0, len(file.Imports)) - for _, imp := range file.Imports { - if drop[strings.Trim(imp.Path.Value, `"`)] { + alias := importAliasForPath(file, p) + if alias != "" && aliasReferenced(file, alias) { 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 drop[strings.Trim(is.Path.Value, `"`)] { - continue - } - kept = append(kept, is) - } - gd.Specs = kept + dropImportPath(file, p) } } - // Add new imports. + // 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. for _, ai := range opts.addImports { - newImport := &dst.ImportSpec{ - Name: &dst.Ident{Name: ai.alias}, - Path: &dst.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", ai.path)}, - } - file.Imports = append(file.Imports, newImport) - // Append to the file's import GenDecl, or create a new one. - 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 !aliasReferenced(file, ai.alias) { + continue } - if !appended { - file.Decls = append([]dst.Decl{&dst.GenDecl{ - Tok: token.IMPORT, - Specs: []dst.Spec{newImport}, - Lparen: true, - Rparen: true, - }}, file.Decls...) + 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 +// 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 "" +} - // Rewrite selectors and calls. - merged := mergeRewrites(opts.fieldRewrites, opts.selectorSwaps) - if len(merged) > 0 { - rewriteSelectors(file, merged) +// dropImportPath removes an import entry by path from both file.Imports +// and the underlying import GenDecl. +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) } - if len(opts.callRewrites) > 0 { - rewriteCallNames(file, opts.callRewrites) + 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 } +} - return renderFile(file) +// aliasReferenced reports whether any SelectorExpr in the file has its +// X identifier equal to alias. +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 { @@ -1116,7 +1566,13 @@ func applyOnExpr(e *dst.Expr, fn func(*applyCursor) bool) { applyOnExpr(&n.Elts[i], fn) } case *dst.KeyValueExpr: - applyOnExpr(&n.Key, fn) + // 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 { diff --git a/internal/featurize/featurize_test.go b/internal/featurize/featurize_test.go index b5f3a9f..37619b1 100644 --- a/internal/featurize/featurize_test.go +++ b/internal/featurize/featurize_test.go @@ -107,10 +107,11 @@ func TestStub(t *testing.T) { if !strings.Contains(out, "package user_test") { t.Errorf("external test package not rewritten:\n%s", out) } - // dtos is intentionally NOT collapsed — it stays as a cross-package - // reference per the architectural decision in featurize.go. - if !strings.Contains(out, "dtos.User") { - t.Errorf("dtos.User reference should survive:\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) } } @@ -220,8 +221,14 @@ func InitializeServiceContainer() (*ServiceContainer, error) { if !strings.Contains(out, "userpkg.UserSet") { t.Errorf("providers.UserSet not rewritten to userpkg.UserSet:\n%s", out) } - if strings.Contains(out, `"example.com/myapp/app/di/providers"`) { - t.Errorf("providers import not dropped:\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) } } @@ -263,7 +270,7 @@ func InitAPIRoutes(config *RouteConfig) *chi.Mux { func TestPerResourceMapping_AllPaths(t *testing.T) { pairs := PerResourceMapping("user") - wantCount := 13 // models + dtos (2) + validators stay in shared layered dirs + 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) } diff --git a/internal/generate/patcher.go b/internal/generate/patcher.go index 7562efd..24c3bb4 100644 --- a/internal/generate/patcher.go +++ b/internal/generate/patcher.go @@ -16,6 +16,9 @@ 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 := d.L().ContainerFile() content, err := os.ReadFile(path) @@ -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,6 +81,8 @@ 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 := d.L().WireFile() content, err := os.ReadFile(path) @@ -64,7 +91,23 @@ func PatchWireFile(d ScaffoldData) error { } 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 @@ -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, 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/feature.go b/internal/layout/feature.go index af09b0a..6ed279b 100644 --- a/internal/layout/feature.go +++ b/internal/layout/feature.go @@ -33,8 +33,10 @@ 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/%s/model.go", snake) + return fmt.Sprintf("app/models/%s.model.go", snake) } func (featureLayout) RepoIfaceFile(snake string) string { return fmt.Sprintf("app/%s/repository_iface.go", snake) @@ -78,8 +80,12 @@ func (featureLayout) ControllerTestFile(snake string) string { 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/%s/validators.go", snake) + return fmt.Sprintf("app/validators/%s.validators.go", snake) } func (featureLayout) WireProviderFile(snake string) string { return fmt.Sprintf("app/%s/wire.go", snake) diff --git a/internal/layout/layout_test.go b/internal/layout/layout_test.go index e163098..431f8c1 100644 --- a/internal/layout/layout_test.go +++ b/internal/layout/layout_test.go @@ -102,7 +102,8 @@ func TestFeature_PerResourceFiles(t *testing.T) { got string want string }{ - {"ModelFile", lo.ModelFile("user"), "app/user/model.go"}, + // 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"}, @@ -117,7 +118,8 @@ func TestFeature_PerResourceFiles(t *testing.T) { {"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"}, - {"ValidatorsFile", lo.ValidatorsFile("user"), "app/user/validators.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 { From ebb698897383072413cc35400e23dbb1c453c35a Mon Sep 17 00:00:00 2001 From: descholar-ceo Date: Mon, 18 May 2026 18:55:31 +0200 Subject: [PATCH 4/8] Complete feature refactoring --- internal/commands/refactor.go | 602 ++++++++++++++++++++++++++++++++++ 1 file changed, 602 insertions(+) create mode 100644 internal/commands/refactor.go diff --git a/internal/commands/refactor.go b/internal/commands/refactor.go new file mode 100644 index 0000000..83b4855 --- /dev/null +++ b/internal/commands/refactor.go @@ -0,0 +1,602 @@ +// 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" + "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 refactorFeatureCmd = &cobra.Command{ + Use: "feature-package [ | --all]", + 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-package User + gofasta refactor feature-package --all + gofasta refactor feature-package User --dry-run`, + RunE: runRefactorFeature, +} + +func init() { + rootCmd.AddCommand(refactorCmd) + refactorCmd.AddCommand(refactorFeatureCmd) + 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") +} + +//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 +} + +// 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. +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.Split(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 +} From 5c5d4269dc9cb174ddb4055c4f15d4691eb742e2 Mon Sep 17 00:00:00 2001 From: descholar-ceo Date: Mon, 18 May 2026 21:00:04 +0200 Subject: [PATCH 5/8] Add refactor back command --- internal/commands/refactor.go | 403 +++++++++++++++++++++++ internal/featurize/featurize.go | 567 +++++++++++++++++++++++++++++++- 2 files changed, 969 insertions(+), 1 deletion(-) diff --git a/internal/commands/refactor.go b/internal/commands/refactor.go index 83b4855..d240b99 100644 --- a/internal/commands/refactor.go +++ b/internal/commands/refactor.go @@ -56,6 +56,32 @@ changing its behavior. Currently the only subcommand is: 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-package [ | --all]", Short: "Migrate the project from layered to feature-package layout", @@ -82,9 +108,13 @@ Examples: func init() { rootCmd.AddCommand(refactorCmd) refactorCmd.AddCommand(refactorFeatureCmd) + refactorCmd.AddCommand(refactorLayeredCmd) 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") } //nolint:gocognit,gocyclo // linear orchestration pipeline: preconditions → resolve → (dry-run|migrate) → relocate → patch → cleanup → flip → verify. Splitting hides the order. @@ -221,6 +251,377 @@ func runRefactorFeature(cmd *cobra.Command, args []string) (resultErr error) { 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 { @@ -445,6 +846,8 @@ func applySharedRelocations() ([]string, error) { // (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 { diff --git a/internal/featurize/featurize.go b/internal/featurize/featurize.go index d77e71c..0b7a574 100644 --- a/internal/featurize/featurize.go +++ b/internal/featurize/featurize.go @@ -925,6 +925,561 @@ func TransformResourceDTO(src []byte, mod string, resource Resource) ([]byte, er }) } +// ---------- 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"}}, + } +} + +// 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. +// They return "" here so every cross-package symbol gets qualified. +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 +// self-referential after the move and don't compile. +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` +// function back to `Routes` for the layered routes file. +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 + } + } +} + +// 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", + }, + }) +} + +// 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: +// 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. + } +} + // 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, @@ -1142,8 +1697,18 @@ func transformCrossCutting(src []byte, mod string, _ []Resource, opts crossCutti // 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 { - if !aliasReferenced(file, ai.alias) { + 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) From 199b9c5de3b825d1959b13e01c8d5631475ed5db Mon Sep 17 00:00:00 2001 From: descholar-ceo Date: Mon, 18 May 2026 21:26:33 +0200 Subject: [PATCH 6/8] Modularize the featurize.go file --- internal/featurize/ast.go | 547 +++++++ internal/featurize/crosscutting.go | 429 ++++++ internal/featurize/featurize.go | 2138 +--------------------------- internal/featurize/forward.go | 732 ++++++++++ internal/featurize/paths.go | 109 ++ internal/featurize/reverse.go | 418 ++++++ 6 files changed, 2245 insertions(+), 2128 deletions(-) create mode 100644 internal/featurize/ast.go create mode 100644 internal/featurize/crosscutting.go create mode 100644 internal/featurize/forward.go create mode 100644 internal/featurize/paths.go create mode 100644 internal/featurize/reverse.go 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 index 0b7a574..ec0af90 100644 --- a/internal/featurize/featurize.go +++ b/internal/featurize/featurize.go @@ -37,20 +37,18 @@ // 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 -import ( - "bytes" - "fmt" - "go/format" - "go/token" - "maps" - "strings" - - "github.com/dave/dst" - "github.com/dave/dst/decorator" -) - // Resource describes one resource being featurized. type Resource struct { Name string // PascalCase ("User") @@ -63,2119 +61,3 @@ type Options struct { ModulePath string // "github.com/acme/myapp" Resource Resource // the resource this file belongs to } - -// 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...) - } - } -} - -// 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...) -} - -// 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 -// reference remains, the import is just dropped by dropCollapsableImports. -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 -// requalification happens. -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 -// equivalent. Handles both regular and `_test` external-test packages. -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 -// positives on framework paths that happen to end in "/services" etc. -func isCollapsablePath(path, mod string) bool { - if mod == "" { - return false - } - for _, suffix := range collapsablePaths { - if path == mod+suffix { - return true - } - } - return false -} - -// 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:] -} - -// 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 -// it; format.Source then drops the empty block. -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 - } -} - -// 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 (avoids an import cycle if astpatch ever -// imports featurize). -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 -} - -// 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)}, - } -} - -// ---------- 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", - }, - }) -} - -// ---------- 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"}}, - } -} - -// 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. -// They return "" here so every cross-package symbol gets qualified. -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 -// self-referential after the move and don't compile. -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` -// function back to `Routes` for the layered routes file. -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 - } - } -} - -// 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", - }, - }) -} - -// 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: -// 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. - } -} - -// 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 -// — 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 -// from one place. The four use different combinations of the same -// primitives (import drop/add, selector rewrite, call rewrite). -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 -// 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 -// and the underlying import GenDecl. -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 -// X identifier equal to alias. -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 -// SelectorExpr "A.B" parsed from the right-hand side. -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 -// transform where `UserRoutes(...)` → `userpkg.RegisterRoutes(...)`. -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 — -// extends as needed. -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/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: From 7193f253506d1268a72b79b0b9e620e5b8be9f22 Mon Sep 17 00:00:00 2001 From: descholar-ceo Date: Mon, 18 May 2026 22:07:42 +0200 Subject: [PATCH 7/8] Fix command inconsistency --- internal/commands/refactor.go | 103 ++++++++++++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 5 deletions(-) diff --git a/internal/commands/refactor.go b/internal/commands/refactor.go index d240b99..58edd3e 100644 --- a/internal/commands/refactor.go +++ b/internal/commands/refactor.go @@ -23,6 +23,7 @@ package commands import ( "fmt" + "io" "os" "os/exec" "path/filepath" @@ -83,7 +84,9 @@ Examples: } var refactorFeatureCmd = &cobra.Command{ - Use: "feature-package [ | --all]", + 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.) @@ -99,16 +102,39 @@ After each resource is migrated the engine runs ` + "`go build ./...`" + ` and partial state is left for inspection — fix or ` + "`git restore`" + ` to recover. Examples: - gofasta refactor feature-package User - gofasta refactor feature-package --all - gofasta refactor feature-package User --dry-run`, + 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") @@ -117,6 +143,73 @@ func init() { 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") @@ -937,7 +1030,7 @@ func readModulePath() (string, error) { if err != nil { return "", clierr.Wrap(clierr.CodeNotGofastaProject, err, "reading go.mod") } - for _, line := range strings.Split(string(data), "\n") { + 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 From 2d003931f3b32e623886a646563843eaa6d11588 Mon Sep 17 00:00:00 2001 From: descholar-ceo Date: Thu, 2 Jul 2026 16:41:57 +0200 Subject: [PATCH 8/8] Fix genmock --- internal/generate/gen_mock.go | 101 +++++++++++++++++++++++-- internal/generate/gen_mock_gap_test.go | 57 +++++++++++++- 2 files changed, 149 insertions(+), 9 deletions(-) diff --git a/internal/generate/gen_mock.go b/internal/generate/gen_mock.go index e07e1ce..c17f969 100644 --- a/internal/generate/gen_mock.go +++ b/internal/generate/gen_mock.go @@ -27,6 +27,8 @@ import ( "sort" "strings" + goimports "golang.org/x/tools/imports" + "github.com/gofastadev/cli/internal/clierr" "github.com/gofastadev/cli/internal/layout" ) @@ -263,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 } @@ -271,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 @@ -287,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 @@ -302,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. @@ -399,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) +}