diff --git a/cmd/nucleus/internal/migrate/command.go b/cmd/nucleus/internal/migrate/command.go new file mode 100644 index 0000000..7cd5cb5 --- /dev/null +++ b/cmd/nucleus/internal/migrate/command.go @@ -0,0 +1,73 @@ +package migrate + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" +) + +// Config carries root-level flag values used by the migrate command. +type Config struct { + Dir *string +} + +type options struct { + fromVersion string + toVersion string + check bool + reportPath string + json bool + pretty bool +} + +// ErrMigrateFailed is returned when migration planning or checks fail. +var ErrMigrateFailed = errors.New("migrate failed") + +// NewCommand creates to migrate subcommand. +func NewCommand(config Config) *cobra.Command { + opts := &options{} + cmd := &cobra.Command{ + Use: commandUseMigrate, + Short: commandShortMigrate, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + result, err := run(config, opts) + if err != nil { + return err + } + if opts.reportPath != "" { + if err := writeReport(stringValue(config.Dir, defaultDir), opts.reportPath, result); err != nil { + return err + } + } + if opts.json { + if err := renderJSON(cmd.OutOrStdout(), result, opts.pretty); err != nil { + return err + } + } else { + renderHuman(cmd.OutOrStdout(), cmd.ErrOrStderr(), result) + } + if !result.OK { + return fmt.Errorf("%w: migration diagnostics contain errors", ErrMigrateFailed) + } + return nil + }, + } + cmd.Flags().StringVar(&opts.fromVersion, flagFromVersion, "", flagHelpFromVersion) + cmd.Flags().StringVar(&opts.toVersion, flagToVersion, "", flagHelpToVersion) + cmd.Flags().BoolVar(&opts.check, flagCheck, false, flagHelpCheck) + cmd.Flags().StringVar(&opts.reportPath, flagReport, "", flagHelpReport) + cmd.Flags().BoolVar(&opts.json, flagJSON, false, flagHelpJSON) + cmd.Flags().BoolVar(&opts.pretty, flagPretty, false, flagHelpPretty) + return cmd +} + +func stringValue(value *string, fallback string) string { + if value == nil { + return fallback + } + return *value +} diff --git a/cmd/nucleus/internal/migrate/command_test.go b/cmd/nucleus/internal/migrate/command_test.go new file mode 100644 index 0000000..2af994a --- /dev/null +++ b/cmd/nucleus/internal/migrate/command_test.go @@ -0,0 +1,301 @@ +package migrate + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMigrateCommandEmitsJSONPlan(t *testing.T) { + serviceDir := t.TempDir() + writeMigrateService(t, serviceDir) + + dir := serviceDir + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--from-version", "v0.1.0", "--to-version", "v0.2.0", "--json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute migrate: %v", err) + } + + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode migrate output: %v\n%s", err, stdout.String()) + } + assertMigrateString(t, output, "result_kind", resultKindMigrate) + assertMigrateString(t, output, "schema_version", schemaVersionMigrate) + assertMigrateString(t, output, "schema_ref", schemaRefMigrate) + assertMigrateBool(t, output, "ok", true) + assertMigrateString(t, output, "mode", modePlan) + summary := assertMigrateMap(t, output, "summary") + assertMigrateString(t, summary, "service", "demo") + assertMigrateString(t, summary, "compatibility", compatibilitySupported) + assertMigrateNumber(t, summary, "steps", 6) + assertMigrateNumber(t, summary, "contract_surfaces", 3) + migration := assertMigrateMap(t, output, "migration") + assertMigrateString(t, migration, "write_policy", writePolicyReport) + commands := assertMigrateSlice(t, migration, "commands") + if len(commands) < 3 { + t.Fatalf("commands len = %d, want at least 3", len(commands)) + } +} + +func TestMigrateCheckFailsOnStaleGeneratedTargets(t *testing.T) { + serviceDir := t.TempDir() + writeMigrateService(t, serviceDir) + writeMigrateFile(t, serviceDir, "nucleus.yaml", `schema_version: "1.0" +service: + name: demo + version: "0.1.0" +ai: + intent: Exercise migrate checks. + generated: + - contract/gen +`) + + dir := serviceDir + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--from-version", "v0.1.0", "--to-version", "v0.2.0", "--check", "--json"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("execute migrate succeeded, want failure") + } + if !errors.Is(err, ErrMigrateFailed) { + t.Fatalf("error = %v, want ErrMigrateFailed", err) + } + var output map[string]any + if decodeErr := json.Unmarshal(stdout.Bytes(), &output); decodeErr != nil { + t.Fatalf("decode migrate output: %v\n%s", decodeErr, stdout.String()) + } + assertMigrateBool(t, output, "ok", false) + diagnostics := assertMigrateSlice(t, output, "diagnostics") + assertMigrateContainsDiagnostic(t, diagnostics, diagnosticGeneratedStale) +} + +func TestMigrateCommandWritesReportForRelativePath(t *testing.T) { + serviceDir := t.TempDir() + writeMigrateService(t, serviceDir) + + dir := serviceDir + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--from-version", "v0.1.0", "--to-version", "v0.2.0", "--report", "artifacts/nucleus/migrate.json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute migrate: %v", err) + } + reportPath := filepath.Join(serviceDir, "artifacts", "nucleus", "migrate.json") + data, err := os.ReadFile(reportPath) + if err != nil { + t.Fatalf("read report: %v", err) + } + var output map[string]any + if err := json.Unmarshal(data, &output); err != nil { + t.Fatalf("decode report: %v\n%s", err, string(data)) + } + assertMigrateString(t, output, "result_kind", resultKindMigrate) + if !strings.Contains(stdout.String(), "OK") { + t.Fatalf("human stdout = %q, want OK", stdout.String()) + } +} + +func TestMigrateCommandRejectsMissingVersions(t *testing.T) { + dir := t.TempDir() + cmd := NewCommand(Config{Dir: &dir}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--to-version", "v0.2.0"}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "--from-version is required") { + t.Fatalf("error = %v, want missing from-version", err) + } +} + +func TestMigrateCommandRejectsRelativeReportTraversal(t *testing.T) { + serviceDir := t.TempDir() + writeMigrateService(t, serviceDir) + + dir := serviceDir + cmd := NewCommand(Config{Dir: &dir}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--from-version", "v0.1.0", "--to-version", "v0.2.0", "--report", "../migrate.json"}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "--report must resolve inside the service directory") { + t.Fatalf("error = %v, want report traversal rejection", err) + } +} + +func TestMigrateCommandRejectsAbsoluteReportOutsideServiceDir(t *testing.T) { + serviceDir := t.TempDir() + writeMigrateService(t, serviceDir) + outsidePath := filepath.Join(t.TempDir(), "migrate.json") + + dir := serviceDir + cmd := NewCommand(Config{Dir: &dir}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--from-version", "v0.1.0", "--to-version", "v0.2.0", "--report", outsidePath}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "--report must resolve inside the service directory") { + t.Fatalf("error = %v, want absolute report rejection", err) + } +} + +func TestMigrateCommandRedactsInspectErrors(t *testing.T) { + serviceDir := filepath.Join(t.TempDir(), "missing-service") + dir := serviceDir + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--from-version", "v0.1.0", "--to-version", "v0.2.0", "--json"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("execute migrate succeeded, want inspect failure") + } + var output map[string]any + if decodeErr := json.Unmarshal(stdout.Bytes(), &output); decodeErr != nil { + t.Fatalf("decode migrate output: %v\n%s", decodeErr, stdout.String()) + } + diagnostics := assertMigrateSlice(t, output, "diagnostics") + assertMigrateContainsDiagnostic(t, diagnostics, diagnosticInspectFailed) + for _, raw := range diagnostics { + item, ok := raw.(map[string]any) + if !ok { + continue + } + for _, key := range []string{"path", "message"} { + text, _ := item[key].(string) + if strings.Contains(text, serviceDir) { + t.Fatalf("diagnostic %s leaked absolute path %q in %#v", key, serviceDir, item) + } + } + } +} + +func writeMigrateService(t *testing.T, dir string) { + t.Helper() + writeMigrateFile(t, dir, "nucleus.yaml", `schema_version: "1.0" +service: + name: demo + version: "0.1.0" +capabilities: + - log +ai: + intent: Exercise migrate against a contract-first service. + allowed_changes: + - api/** + - internal/** + - contract/gen + generated: [] +nucleus: + providers: + log: + provider: noop +`) + writeMigrateFile(t, dir, "api/openapi.yaml", `openapi: 3.0.3 +paths: + /hello: + get: + operationId: sayHello + responses: + "200": + description: ok +`) + writeMigrateFile(t, dir, "api/errors.yaml", `errors: + - code: 1001 + message: hello failed + http_status: 500 +`) +} + +func writeMigrateFile(t *testing.T, dir string, name string, data string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + t.Fatal(err) + } +} + +func assertMigrateMap(t *testing.T, value map[string]any, key string) map[string]any { + t.Helper() + item, ok := value[key].(map[string]any) + if !ok { + t.Fatalf("%s has type %T, want map[string]any", key, value[key]) + } + return item +} + +func assertMigrateSlice(t *testing.T, value map[string]any, key string) []any { + t.Helper() + item, ok := value[key].([]any) + if !ok { + t.Fatalf("%s has type %T, want []any", key, value[key]) + } + return item +} + +func assertMigrateString(t *testing.T, value map[string]any, key string, want string) { + t.Helper() + got, ok := value[key].(string) + if !ok { + t.Fatalf("%s has type %T, want string", key, value[key]) + } + if got != want { + t.Fatalf("%s = %q, want %q", key, got, want) + } +} + +func assertMigrateBool(t *testing.T, value map[string]any, key string, want bool) { + t.Helper() + got, ok := value[key].(bool) + if !ok { + t.Fatalf("%s has type %T, want bool", key, value[key]) + } + if got != want { + t.Fatalf("%s = %v, want %v", key, got, want) + } +} + +func assertMigrateNumber(t *testing.T, value map[string]any, key string, want float64) { + t.Helper() + got, ok := value[key].(float64) + if !ok { + t.Fatalf("%s has type %T, want number", key, value[key]) + } + if got != want { + t.Fatalf("%s = %v, want %v", key, got, want) + } +} + +func assertMigrateContainsDiagnostic(t *testing.T, diagnostics []any, code string) { + t.Helper() + for _, value := range diagnostics { + item, ok := value.(map[string]any) + if ok && item["code"] == code { + return + } + } + t.Fatalf("no diagnostic %q in %#v", code, diagnostics) +} diff --git a/cmd/nucleus/internal/migrate/constants.go b/cmd/nucleus/internal/migrate/constants.go new file mode 100644 index 0000000..061872d --- /dev/null +++ b/cmd/nucleus/internal/migrate/constants.go @@ -0,0 +1,80 @@ +package migrate + +const ( + commandUseMigrate = "migrate" + commandShortMigrate = "plan Nucleus version migrations and readiness checks" + defaultDir = "." +) + +const ( + flagFromVersion = "from-version" + flagToVersion = "to-version" + flagCheck = "check" + flagReport = "report" + flagJSON = "json" + flagPretty = "pretty" +) + +const ( + flagHelpFromVersion = "source Nucleus or manifest schema version" + flagHelpToVersion = "target Nucleus or manifest schema version" + flagHelpCheck = "fail when migration readiness checks do not pass" + flagHelpReport = "write the migration result JSON to this path" + flagHelpJSON = "emit machine-readable migration result" + flagHelpPretty = "pretty-print JSON output" +) + +const ( + resultKindMigrate = "nucleus.migrate_result" + schemaVersionMigrate = "migrate.v1" + schemaRefMigrate = "contract/schema/migrate.schema.json" + jsonIndentPrefix = "" + jsonIndentValue = " " +) + +const ( + modePlan = "plan" + modeCheck = "check" +) + +const ( + compatibilityNoChange = "no_change" + compatibilitySupported = "supported" + compatibilityGenericForward = "generic_forward" + compatibilityUnsupported = "unsupported" +) + +const ( + diagnosticInspectFailed = "migrate.inspect_failed" + diagnosticInvalidVersion = "migrate.version_invalid" + diagnosticDowngrade = "migrate.downgrade_unsupported" + diagnosticGenericTransition = "migrate.generic_transition" + diagnosticGeneratedStale = "migrate.generated_stale" + diagnosticVerificationMissing = "migrate.verification_missing" + diagnosticValidationFailed = "migrate.validation_failed" +) + +const ( + checkVersionOrder = "version_order" + checkRuleRegistry = "rule_registry" + checkManifestValidation = "manifest_validation" + checkContractInspection = "contract_inspection" + checkGeneratedFreshness = "generated_freshness" + checkVerificationCommands = "verification_commands" +) + +const ( + stepInventory = "inventory" + stepManifest = "manifest" + stepContracts = "contracts" + stepGenerated = "generated" + stepCapabilities = "capabilities" + stepVerification = "verification" + stepNoChange = "no_change" + commandWorkingDir = "." + writePolicyReport = "report_only" + scopeLocalService = "local_service" + severityInfo = "info" + severityWarning = "warning" + severityError = "error" +) diff --git a/cmd/nucleus/internal/migrate/doc.go b/cmd/nucleus/internal/migrate/doc.go new file mode 100644 index 0000000..b294ae5 --- /dev/null +++ b/cmd/nucleus/internal/migrate/doc.go @@ -0,0 +1,6 @@ +// Package migrate implements the Nucleus version migration planning command. +// +// The package is intentionally CLI-internal: it composes contract inspection, +// validation diagnostics, and local migration rules into auditable output, but +// it does not expose a public migration engine or mutate service code. +package migrate diff --git a/cmd/nucleus/internal/migrate/output.go b/cmd/nucleus/internal/migrate/output.go new file mode 100644 index 0000000..11ad3cc --- /dev/null +++ b/cmd/nucleus/internal/migrate/output.go @@ -0,0 +1,173 @@ +package migrate + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/nucleuskit/contract/diagnostic" + "github.com/nucleuskit/contract/inspect" + "github.com/nucleuskit/contract/manifest" +) + +type migrateResult struct { + ResultKind string `json:"result_kind"` + SchemaVersion string `json:"schema_version"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Mode string `json:"mode"` + Summary migrateSummary `json:"summary"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics"` + Migration *migrationPlan `json:"migration,omitempty"` +} + +type migrateSummary struct { + Service string `json:"service,omitempty"` + FromVersion string `json:"from_version"` + ToVersion string `json:"to_version"` + Compatibility string `json:"compatibility"` + Steps int `json:"steps"` + RequiredEdits int `json:"required_edits"` + AdvisoryEdits int `json:"advisory_edits"` + Checks int `json:"checks"` + Commands int `json:"commands"` + Errors int `json:"errors"` + Warnings int `json:"warnings"` + GeneratedFresh bool `json:"generated_fresh"` + ContractSurfaces int `json:"contract_surfaces"` + CapabilityCount int `json:"capability_count"` +} + +type migrationPlan struct { + Service manifest.Service `json:"service"` + Scope string `json:"scope"` + WritePolicy string `json:"write_policy"` + FromVersion versionInfo `json:"from_version"` + ToVersion versionInfo `json:"to_version"` + Compatibility string `json:"compatibility"` + ContractFirst bool `json:"contract_first"` + DescriptionSchema string `json:"description_schema"` + RequiredEdits []migrationEdit `json:"required_edits"` + AdvisoryEdits []migrationEdit `json:"advisory_edits"` + Checks []migrationCheck `json:"checks"` + Steps []migrationStep `json:"steps"` + Commands []migrationCommand `json:"commands"` + Risks []migrationRisk `json:"risks"` + GeneratedFreshness []inspect.GeneratedFreshness `json:"generated_freshness"` + DeclaredCapabilities []string `json:"declared_capabilities"` +} + +type versionInfo struct { + Raw string `json:"raw"` + Normalized string `json:"normalized"` + Known bool `json:"known"` +} + +type migrationEdit struct { + Path string `json:"path"` + Kind string `json:"kind"` + Required bool `json:"required"` + Reason string `json:"reason"` +} + +type migrationCheck struct { + ID string `json:"id"` + Pass bool `json:"pass"` + Severity string `json:"severity"` + Subject string `json:"subject,omitempty"` + Reason string `json:"reason"` +} + +type migrationStep struct { + ID string `json:"id"` + Sequence int `json:"sequence"` + Title string `json:"title"` + Purpose string `json:"purpose"` + Edits []string `json:"edits,omitempty"` + Commands []string `json:"commands,omitempty"` +} + +type migrationCommand struct { + Phase string `json:"phase"` + Command string `json:"command"` + WorkingDir string `json:"working_dir"` + Reason string `json:"reason"` +} + +type migrationRisk struct { + ID string `json:"id"` + Severity string `json:"severity"` + Reason string `json:"reason"` + Mitigation string `json:"mitigation"` +} + +func renderHuman(stdout io.Writer, stderr io.Writer, result migrateResult) { + for _, item := range result.Diagnostics { + _, _ = fmt.Fprintf(stderr, "%s %s %s: %s\n", item.Severity, item.Path, item.Code, item.Message) + } + if result.OK { + _, _ = fmt.Fprintln(stdout, "OK") + } else { + _, _ = fmt.Fprintln(stderr, "FAILED") + } + _, _ = fmt.Fprintf(stdout, "mode: %s\n", result.Mode) + _, _ = fmt.Fprintf(stdout, "migration: %s -> %s\n", result.Summary.FromVersion, result.Summary.ToVersion) + if result.Summary.Service != "" { + _, _ = fmt.Fprintf(stdout, "service: %s\n", result.Summary.Service) + } + _, _ = fmt.Fprintf(stdout, "compatibility: %s\n", result.Summary.Compatibility) + _, _ = fmt.Fprintf(stdout, "steps: %d\n", result.Summary.Steps) + _, _ = fmt.Fprintf(stdout, "edits: %d required, %d advisory\n", result.Summary.RequiredEdits, result.Summary.AdvisoryEdits) + _, _ = fmt.Fprintf(stdout, "checks: %d\n", result.Summary.Checks) + if result.Migration != nil && len(result.Migration.Commands) > 0 { + commands := make([]string, 0, len(result.Migration.Commands)) + for _, command := range result.Migration.Commands { + commands = append(commands, command.Command) + } + _, _ = fmt.Fprintf(stdout, "commands: %s\n", strings.Join(commands, " -> ")) + } + _, _ = fmt.Fprintf(stdout, "diagnostics: %d errors, %d warnings\n", result.Summary.Errors, result.Summary.Warnings) +} + +func renderJSON(writer io.Writer, result migrateResult, pretty bool) error { + result = finalizeResult(result) + encoder := json.NewEncoder(writer) + encoder.SetEscapeHTML(false) + if pretty { + encoder.SetIndent(jsonIndentPrefix, jsonIndentValue) + } + return encoder.Encode(result) +} + +func finalizeResult(result migrateResult) migrateResult { + result.ResultKind = resultKindMigrate + result.SchemaVersion = schemaVersionMigrate + result.SchemaRef = schemaRefMigrate + if result.Diagnostics == nil { + result.Diagnostics = diagnostic.Diagnostics{} + } + result.Diagnostics.Sort() + result.OK = !result.Diagnostics.Failed() + result.Summary.Errors = result.Diagnostics.Count(diagnostic.SeverityError) + result.Summary.Warnings = result.Diagnostics.Count(diagnostic.SeverityWarning) + return result +} + +func errorDiagnostic(path string, code string, message string) diagnostic.Diagnostic { + return diagnostic.Diagnostic{ + Severity: diagnostic.SeverityError, + Code: code, + Path: path, + Message: message, + } +} + +func warningDiagnostic(path string, code string, message string) diagnostic.Diagnostic { + return diagnostic.Diagnostic{ + Severity: diagnostic.SeverityWarning, + Code: code, + Path: path, + Message: message, + } +} diff --git a/cmd/nucleus/internal/migrate/paths.go b/cmd/nucleus/internal/migrate/paths.go new file mode 100644 index 0000000..978ee06 --- /dev/null +++ b/cmd/nucleus/internal/migrate/paths.go @@ -0,0 +1,94 @@ +package migrate + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +func writeReport(serviceDir string, reportPath string, result migrateResult) error { + path, err := resolveReportPath(serviceDir, reportPath) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + var buffer strings.Builder + encoder := json.NewEncoder(&buffer) + encoder.SetEscapeHTML(false) + encoder.SetIndent(jsonIndentPrefix, jsonIndentValue) + if err := encoder.Encode(finalizeResult(result)); err != nil { + return err + } + return os.WriteFile(path, []byte(buffer.String()), 0o644) +} + +func resolveReportPath(serviceDir string, reportPath string) (string, error) { + reportPath = strings.TrimSpace(reportPath) + serviceAbs, err := filepath.Abs(serviceDir) + if err != nil { + return "", err + } + var target string + if filepath.IsAbs(reportPath) { + target = filepath.Clean(reportPath) + } else { + cleaned := filepath.Clean(reportPath) + if cleaned == "." { + return "", fmt.Errorf("--%s must resolve inside the service directory", flagReport) + } + target = filepath.Join(serviceAbs, cleaned) + } + targetAbs, err := filepath.Abs(target) + if err != nil { + return "", err + } + rel, err := filepath.Rel(serviceAbs, targetAbs) + if err != nil || !isContainedRelativePath(rel) { + return "", fmt.Errorf("--%s must resolve inside the service directory", flagReport) + } + return targetAbs, nil +} + +func safeMigratePath(path string) string { + if strings.TrimSpace(path) == "" { + return "" + } + clean := filepath.Clean(path) + if !filepath.IsAbs(clean) { + return filepath.ToSlash(clean) + } + if cwd, err := os.Getwd(); err == nil { + if rel, err := filepath.Rel(cwd, clean); err == nil && isContainedRelativePath(rel) { + return filepath.ToSlash(rel) + } + } + base := filepath.Base(clean) + parent := filepath.Base(filepath.Dir(clean)) + if parent != "." && parent != string(filepath.Separator) && parent != "" { + return filepath.ToSlash(filepath.Join("", parent, base)) + } + return filepath.ToSlash(filepath.Join("", base)) +} + +func isContainedRelativePath(path string) bool { + if path == "." { + return true + } + if path == ".." { + return false + } + return !strings.HasPrefix(path, ".."+string(filepath.Separator)) +} + +func safeErrorMessage(err error) string { + var pathErr *os.PathError + if errors.As(err, &pathErr) { + return pathErr.Op + " failed: " + pathErr.Err.Error() + } + return err.Error() +} diff --git a/cmd/nucleus/internal/migrate/run.go b/cmd/nucleus/internal/migrate/run.go new file mode 100644 index 0000000..c12f570 --- /dev/null +++ b/cmd/nucleus/internal/migrate/run.go @@ -0,0 +1,536 @@ +package migrate + +import ( + "fmt" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/nucleuskit/contract/diagnostic" + "github.com/nucleuskit/contract/inspect" + "github.com/nucleuskit/contract/validation" +) + +type parsedVersion struct { + raw string + normalized string + major int + minor int + patch int + known bool +} + +type migrationRule struct { + fromMajor int + fromMinor int + toMajor int + toMinor int +} + +var knownRules = []migrationRule{ + {fromMajor: 0, fromMinor: 1, toMajor: 0, toMinor: 2}, + {fromMajor: 0, fromMinor: 2, toMajor: 0, toMinor: 3}, + {fromMajor: 1, fromMinor: 0, toMajor: 1, toMinor: 1}, +} + +func run(config Config, opts *options) (migrateResult, error) { + if err := validateOptions(opts); err != nil { + return migrateResult{}, err + } + dir := stringValue(config.Dir, defaultDir) + mode := modePlan + if opts.check { + mode = modeCheck + } + from, fromErr := parseVersion(opts.fromVersion) + to, toErr := parseVersion(opts.toVersion) + + description, inspectErr := inspect.Describe(dir) + if inspectErr != nil { + result := migrateResult{ + Mode: mode, + Summary: migrateSummary{ + FromVersion: strings.TrimSpace(opts.fromVersion), + ToVersion: strings.TrimSpace(opts.toVersion), + Compatibility: compatibilityUnsupported, + }, + Diagnostics: diagnostic.Diagnostics{ + errorDiagnostic(safeMigratePath(dir), diagnosticInspectFailed, safeErrorMessage(inspectErr)), + }, + } + return finalizeResult(result), nil + } + + validationPassed, diagnostics := validationDiagnostics(dir, mode) + if fromErr != nil { + diagnostics = append(diagnostics, errorDiagnostic(flagFromVersion, diagnosticInvalidVersion, fromErr.Error())) + } + if toErr != nil { + diagnostics = append(diagnostics, errorDiagnostic(flagToVersion, diagnosticInvalidVersion, toErr.Error())) + } + if fromErr == nil && toErr == nil { + diagnostics = append(diagnostics, versionDiagnostics(from, to)...) + } + + compatibility := compatibilityForVersions(from, to, fromErr, toErr) + plan := buildMigrationPlan(description, from, to, compatibility, mode, validationPassed) + diagnostics = append(diagnostics, checkDiagnostics(plan.Checks, mode)...) + result := migrateResult{ + Mode: mode, + Summary: buildSummary(plan, diagnostics), + Diagnostics: diagnostics, + Migration: &plan, + } + return finalizeResult(result), nil +} + +func validateOptions(opts *options) error { + if strings.TrimSpace(opts.fromVersion) == "" { + return fmt.Errorf("--%s is required", flagFromVersion) + } + if strings.TrimSpace(opts.toVersion) == "" { + return fmt.Errorf("--%s is required", flagToVersion) + } + return nil +} + +func parseVersion(value string) (parsedVersion, error) { + raw := strings.TrimSpace(value) + trimmed := strings.TrimPrefix(raw, "v") + if trimmed == "" { + return parsedVersion{raw: raw, normalized: raw}, fmt.Errorf("version %q is empty", raw) + } + parts := strings.Split(trimmed, ".") + if len(parts) < 2 || len(parts) > 3 { + return parsedVersion{raw: raw, normalized: raw}, fmt.Errorf("version %q must look like MAJOR.MINOR or MAJOR.MINOR.PATCH", raw) + } + numbers := []int{0, 0, 0} + for index, part := range parts { + if part == "" { + return parsedVersion{raw: raw, normalized: raw}, fmt.Errorf("version %q contains an empty component", raw) + } + number, err := strconv.Atoi(part) + if err != nil || number < 0 { + return parsedVersion{raw: raw, normalized: raw}, fmt.Errorf("version %q must contain only non-negative numeric components", raw) + } + numbers[index] = number + } + normalized := fmt.Sprintf("%d.%d.%d", numbers[0], numbers[1], numbers[2]) + return parsedVersion{ + raw: raw, + normalized: normalized, + major: numbers[0], + minor: numbers[1], + patch: numbers[2], + known: isKnownVersion(numbers[0], numbers[1]), + }, nil +} + +func isKnownVersion(major int, minor int) bool { + for _, rule := range knownRules { + if rule.fromMajor == major && rule.fromMinor == minor { + return true + } + if rule.toMajor == major && rule.toMinor == minor { + return true + } + } + return false +} + +func compareVersions(from parsedVersion, to parsedVersion) int { + for _, pair := range [][2]int{{from.major, to.major}, {from.minor, to.minor}, {from.patch, to.patch}} { + if pair[0] < pair[1] { + return -1 + } + if pair[0] > pair[1] { + return 1 + } + } + return 0 +} + +func compatibilityForVersions(from parsedVersion, to parsedVersion, fromErr error, toErr error) string { + if fromErr != nil || toErr != nil { + return compatibilityUnsupported + } + switch compareVersions(from, to) { + case 0: + return compatibilityNoChange + case 1: + return compatibilityUnsupported + } + if hasExactRule(from, to) { + return compatibilitySupported + } + return compatibilityGenericForward +} + +func hasExactRule(from parsedVersion, to parsedVersion) bool { + for _, rule := range knownRules { + if rule.fromMajor == from.major && rule.fromMinor == from.minor && rule.toMajor == to.major && rule.toMinor == to.minor { + return true + } + } + return false +} + +func versionDiagnostics(from parsedVersion, to parsedVersion) diagnostic.Diagnostics { + switch compareVersions(from, to) { + case 1: + return diagnostic.Diagnostics{ + errorDiagnostic(flagToVersion, diagnosticDowngrade, "downgrade migrations are not supported"), + } + case 0: + return nil + } + if hasExactRule(from, to) { + return nil + } + return diagnostic.Diagnostics{ + warningDiagnostic(flagToVersion, diagnosticGenericTransition, "no exact migration rule is registered; using the generic forward migration checklist"), + } +} + +func validationDiagnostics(dir string, mode string) (bool, diagnostic.Diagnostics) { + diagnostics := validation.ValidateService(dir) + passed := !diagnostics.Failed() + var mapped diagnostic.Diagnostics + for _, item := range diagnostics { + severity := item.Severity + if mode == modePlan && severity == diagnostic.SeverityError { + severity = diagnostic.SeverityWarning + } + mapped = append(mapped, diagnostic.Diagnostic{ + Severity: severity, + Code: diagnosticValidationFailed, + Path: item.Path, + Message: item.Code + ": " + item.Message, + }) + } + return passed, mapped +} + +func buildMigrationPlan(description inspect.Description, from parsedVersion, to parsedVersion, compatibility string, mode string, validationPassed bool) migrationPlan { + requiredEdits, advisoryEdits := migrationEdits(description, compatibility) + commands := migrationCommands(description) + checks := migrationChecks(description, compatibility, mode, validationPassed) + steps := migrationSteps(requiredEdits, advisoryEdits, commands, compatibility) + return migrationPlan{ + Service: description.Service, + Scope: scopeLocalService, + WritePolicy: writePolicyReport, + FromVersion: toVersionInfo(from), + ToVersion: toVersionInfo(to), + Compatibility: compatibility, + ContractFirst: true, + DescriptionSchema: description.SchemaVersion, + RequiredEdits: nonNilEdits(requiredEdits), + AdvisoryEdits: nonNilEdits(advisoryEdits), + Checks: nonNilChecks(checks), + Steps: nonNilSteps(steps), + Commands: nonNilCommands(commands), + Risks: nonNilRisks(migrationRisks(description, compatibility)), + GeneratedFreshness: append([]inspect.GeneratedFreshness{}, description.GeneratedFreshness...), + DeclaredCapabilities: append([]string{}, description.Capabilities...), + } +} + +func toVersionInfo(version parsedVersion) versionInfo { + return versionInfo{ + Raw: version.raw, + Normalized: version.normalized, + Known: version.known, + } +} + +func migrationEdits(description inspect.Description, compatibility string) ([]migrationEdit, []migrationEdit) { + if compatibility == compatibilityNoChange { + return nil, []migrationEdit{ + {Path: "nucleus.yaml", Kind: "manifest", Required: false, Reason: "confirm service metadata already matches the target version"}, + } + } + required := []migrationEdit{ + {Path: "nucleus.yaml", Kind: "manifest", Required: true, Reason: "record target Nucleus compatibility and service metadata changes"}, + {Path: "AGENTS.md", Kind: "agent_contract", Required: true, Reason: "keep AI edit boundaries and verification instructions aligned with the target version"}, + } + if len(description.Endpoints) > 0 { + required = append(required, migrationEdit{Path: "api/openapi.yaml", Kind: "http_contract", Required: true, Reason: "review HTTP contract compatibility before regenerating service code"}) + } + for _, service := range description.GRPCServices { + required = append(required, migrationEdit{Path: service.Source, Kind: "grpc_contract", Required: true, Reason: "review gRPC contract compatibility before regenerating service code"}) + } + if len(description.ErrorCodes) > 0 { + required = append(required, migrationEdit{Path: "api/errors.yaml", Kind: "error_contract", Required: true, Reason: "preserve stable external error mapping during migration"}) + } + for _, item := range description.GeneratedFreshness { + required = append(required, migrationEdit{Path: item.Target, Kind: "generated", Required: true, Reason: "regenerate after contract or manifest changes"}) + } + advisory := []migrationEdit{ + {Path: "docs/**", Kind: "documentation", Required: false, Reason: "document behavior or compatibility changes visible to service owners"}, + } + if len(description.Capabilities) > 0 { + advisory = append(advisory, + migrationEdit{Path: "go.mod", Kind: "dependency", Required: false, Reason: "confirm module requirements match capability adapters used by the target version"}, + migrationEdit{Path: "configs/**", Kind: "configuration", Required: false, Reason: "review provider configuration keys and defaults"}, + ) + } + return uniqueEdits(required), uniqueEdits(advisory) +} + +func migrationChecks(description inspect.Description, compatibility string, mode string, validationPassed bool) []migrationCheck { + generatedFresh := generatedFreshnessPass(description.GeneratedFreshness) + commandsDeclared := len(description.Verification.Commands) > 0 + rulePass := compatibility == compatibilitySupported || compatibility == compatibilityNoChange + return []migrationCheck{ + {ID: checkContractInspection, Pass: true, Severity: severityInfo, Subject: "contract/inspect", Reason: "service manifest and contract facts loaded"}, + {ID: checkVersionOrder, Pass: compatibility != compatibilityUnsupported, Severity: checkSeverity(compatibility != compatibilityUnsupported, severityError), Subject: "version", Reason: chooseCheckReason(compatibility != compatibilityUnsupported, "target version is not older than source version", "target version is older than source version or invalid")}, + {ID: checkRuleRegistry, Pass: rulePass, Severity: checkSeverity(rulePass, severityWarning), Subject: "migration_rules", Reason: chooseCheckReason(rulePass, "exact migration rule is registered", "using generic forward migration checklist")}, + {ID: checkManifestValidation, Pass: validationPassed, Severity: checkSeverity(validationPassed, chooseModeSeverity(mode)), Subject: "nucleus.yaml", Reason: chooseCheckReason(validationPassed, "manifest and contract validation passed", "manifest or contract validation diagnostics are emitted separately")}, + {ID: checkGeneratedFreshness, Pass: generatedFresh, Severity: checkSeverity(generatedFresh, chooseModeSeverity(mode)), Subject: "ai.generated", Reason: chooseCheckReason(generatedFresh, "generated targets are fresh or not declared", "generated targets are stale or missing freshness markers")}, + {ID: checkVerificationCommands, Pass: commandsDeclared, Severity: checkSeverity(commandsDeclared, chooseModeSeverity(mode)), Subject: "verification", Reason: chooseCheckReason(commandsDeclared, "verification commands are declared", "verification commands are missing")}, + } +} + +func migrationSteps(requiredEdits []migrationEdit, advisoryEdits []migrationEdit, commands []migrationCommand, compatibility string) []migrationStep { + if compatibility == compatibilityNoChange { + return []migrationStep{ + {ID: stepNoChange, Sequence: 1, Title: "Confirm no-op migration", Purpose: "Verify source and target versions already match.", Commands: commandStrings(commands)}, + } + } + contractEdits := editPathsByKind(requiredEdits, "http_contract", "grpc_contract", "error_contract") + generatedEdits := editPathsByKind(requiredEdits, "generated") + capabilityEdits := editPathsByKind(advisoryEdits, "dependency", "configuration") + return []migrationStep{ + {ID: stepInventory, Sequence: 1, Title: "Inventory current service facts", Purpose: "Load manifest, contracts, capabilities, generated freshness, and verification metadata before editing.", Commands: []string{"nucleus describe --dir . --json"}}, + {ID: stepManifest, Sequence: 2, Title: "Update manifest and agent contract", Purpose: "Move version metadata and AI-safe edit boundaries first so subsequent edits remain auditable.", Edits: editPathsByKind(requiredEdits, "manifest", "agent_contract")}, + {ID: stepContracts, Sequence: 3, Title: "Review contract surfaces", Purpose: "Apply HTTP, gRPC, and error catalog compatibility changes before generated code refresh.", Edits: contractEdits}, + {ID: stepGenerated, Sequence: 4, Title: "Refresh generated artifacts", Purpose: "Regenerate derived code and metadata after contract-first changes.", Edits: generatedEdits, Commands: []string{"nucleus gen --dir ."}}, + {ID: stepCapabilities, Sequence: 5, Title: "Review capability wiring", Purpose: "Confirm optional provider and configuration wiring without leaking provider SDKs into kernel layers.", Edits: capabilityEdits}, + {ID: stepVerification, Sequence: 6, Title: "Run AI-safe verification loop", Purpose: "Validate, lint, and verify the migrated service with stable evidence.", Commands: commandStrings(commands)}, + } +} + +func migrationCommands(description inspect.Description) []migrationCommand { + commands := []migrationCommand{ + {Phase: "validate", Command: "nucleus validate --dir . --json", WorkingDir: commandWorkingDir, Reason: "validate manifest and contract sources"}, + {Phase: "lint", Command: "nucleus lint --dir . --strict --json", WorkingDir: commandWorkingDir, Reason: "enforce strict contract and capability rules"}, + {Phase: "verify", Command: "nucleus verify --dir . --json", WorkingDir: commandWorkingDir, Reason: "run the full verification gate and emit evidence"}, + } + for _, command := range description.Verification.Commands { + if !containsCommand(commands, command) { + commands = append(commands, migrationCommand{Phase: "declared", Command: command, WorkingDir: commandWorkingDir, Reason: "declared by service inspection metadata"}) + } + } + return commands +} + +func migrationRisks(description inspect.Description, compatibility string) []migrationRisk { + risks := []migrationRisk{ + {ID: "manual_code_changes", Severity: severityInfo, Reason: "migrate is report-only and does not rewrite service code", Mitigation: "apply edits through describe -> plan -> gen -> lint -> verify"}, + {ID: "version_rule_coverage", Severity: chooseRiskSeverity(compatibility == compatibilityGenericForward), Reason: chooseCheckReason(compatibility == compatibilityGenericForward, "no exact version rule is registered", "exact or no-op migration path is available"), Mitigation: "review generic checklist before treating the result as release evidence"}, + } + if !generatedFreshnessPass(description.GeneratedFreshness) { + risks = append(risks, migrationRisk{ID: "stale_generated_artifacts", Severity: severityWarning, Reason: "one or more generated targets are stale or missing freshness metadata", Mitigation: "run nucleus gen and nucleus verify before migration sign-off"}) + } + if len(description.Capabilities) > 0 { + risks = append(risks, migrationRisk{ID: "capability_provider_wiring", Severity: severityInfo, Reason: "capabilities may involve optional provider adapters outside core contracts", Mitigation: "keep provider-specific options in bridge or service wiring"}) + } + return risks +} + +func buildSummary(plan migrationPlan, diagnostics diagnostic.Diagnostics) migrateSummary { + contractSurfaces := 0 + for _, edit := range plan.RequiredEdits { + if strings.Contains(edit.Kind, "contract") { + contractSurfaces++ + } + } + return migrateSummary{ + Service: plan.Service.Name, + FromVersion: plan.FromVersion.Normalized, + ToVersion: plan.ToVersion.Normalized, + Compatibility: plan.Compatibility, + Steps: len(plan.Steps), + RequiredEdits: len(plan.RequiredEdits), + AdvisoryEdits: len(plan.AdvisoryEdits), + Checks: len(plan.Checks), + Commands: len(plan.Commands), + Errors: diagnostics.Count(diagnostic.SeverityError), + Warnings: diagnostics.Count(diagnostic.SeverityWarning), + GeneratedFresh: generatedFreshnessPass(plan.GeneratedFreshness), + ContractSurfaces: contractSurfaces, + CapabilityCount: len(plan.DeclaredCapabilities), + } +} + +func checkDiagnostics(checks []migrationCheck, mode string) diagnostic.Diagnostics { + var diagnostics diagnostic.Diagnostics + for _, check := range checks { + if check.Pass { + continue + } + switch check.ID { + case checkGeneratedFreshness: + if mode == modeCheck { + diagnostics = append(diagnostics, errorDiagnostic(check.Subject, diagnosticGeneratedStale, check.Reason)) + } else { + diagnostics = append(diagnostics, warningDiagnostic(check.Subject, diagnosticGeneratedStale, check.Reason)) + } + case checkVerificationCommands: + if mode == modeCheck { + diagnostics = append(diagnostics, errorDiagnostic(check.Subject, diagnosticVerificationMissing, check.Reason)) + } else { + diagnostics = append(diagnostics, warningDiagnostic(check.Subject, diagnosticVerificationMissing, check.Reason)) + } + } + } + return diagnostics +} + +func generatedFreshnessPass(items []inspect.GeneratedFreshness) bool { + for _, item := range items { + if !item.Fresh { + return false + } + } + return true +} + +func chooseModeSeverity(mode string) string { + if mode == modeCheck { + return severityError + } + return severityWarning +} + +func checkSeverity(pass bool, failureSeverity string) string { + if pass { + return severityInfo + } + return failureSeverity +} + +func chooseRiskSeverity(condition bool) string { + if condition { + return severityWarning + } + return severityInfo +} + +func chooseCheckReason(ok bool, pass string, fail string) string { + if ok { + return pass + } + return fail +} + +func uniqueEdits(values []migrationEdit) []migrationEdit { + seen := map[string]struct{}{} + var unique []migrationEdit + for _, value := range values { + path := strings.TrimSpace(value.Path) + if path == "" { + continue + } + path = filepath.ToSlash(path) + key := value.Kind + "\x00" + path + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + value.Path = path + unique = append(unique, value) + } + sort.SliceStable(unique, func(i, j int) bool { + if unique[i].Kind == unique[j].Kind { + return unique[i].Path < unique[j].Path + } + return unique[i].Kind < unique[j].Kind + }) + return unique +} + +func editPathsByKind(edits []migrationEdit, kinds ...string) []string { + allowed := map[string]struct{}{} + for _, kind := range kinds { + allowed[kind] = struct{}{} + } + var paths []string + for _, edit := range edits { + if _, ok := allowed[edit.Kind]; ok { + paths = append(paths, edit.Path) + } + } + return uniqueStrings(paths) +} + +func commandStrings(commands []migrationCommand) []string { + values := make([]string, 0, len(commands)) + for _, command := range commands { + values = append(values, command.Command) + } + return uniqueStrings(values) +} + +func uniqueStrings(values []string) []string { + seen := map[string]struct{}{} + var unique []string + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + unique = append(unique, value) + } + return unique +} + +func containsCommand(commands []migrationCommand, command string) bool { + for _, existing := range commands { + if existing.Command == command { + return true + } + } + return false +} + +func nonNilEdits(values []migrationEdit) []migrationEdit { + if values == nil { + return []migrationEdit{} + } + return values +} + +func nonNilChecks(values []migrationCheck) []migrationCheck { + if values == nil { + return []migrationCheck{} + } + return values +} + +func nonNilSteps(values []migrationStep) []migrationStep { + if values == nil { + return []migrationStep{} + } + return values +} + +func nonNilCommands(values []migrationCommand) []migrationCommand { + if values == nil { + return []migrationCommand{} + } + return values +} + +func nonNilRisks(values []migrationRisk) []migrationRisk { + if values == nil { + return []migrationRisk{} + } + return values +} diff --git a/cmd/nucleus/internal/root/migrate_example_test.go b/cmd/nucleus/internal/root/migrate_example_test.go new file mode 100644 index 0000000..2f02154 --- /dev/null +++ b/cmd/nucleus/internal/root/migrate_example_test.go @@ -0,0 +1,56 @@ +package root + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestMigrateCommandJSONRootWiring(t *testing.T) { + dir := t.TempDir() + writeRootMigrateFile(t, dir, "nucleus.yaml", `schema_version: "1.0" +service: + name: demo + version: "0.1.0" +ai: + intent: Exercise root migrate wiring. + generated: [] +`) + writeRootMigrateFile(t, dir, "api/openapi.yaml", "openapi: 3.0.3\npaths: {}\n") + writeRootMigrateFile(t, dir, "api/errors.yaml", "errors: []\n") + + cmd := New() + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--dir", dir, "migrate", "--from-version", "v0.1.0", "--to-version", "v0.2.0", "--json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute migrate: %v\nstderr=%s", err, stderr.String()) + } + + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode migrate output: %v\n%s", err, stdout.String()) + } + assertString(t, output, "result_kind", "nucleus.migrate_result") + assertString(t, output, "schema_ref", "contract/schema/migrate.schema.json") + assertString(t, output, "mode", "plan") + assertBool(t, output, "ok", true) + summary := assertMap(t, output, "summary") + assertString(t, summary, "service", "demo") +} + +func writeRootMigrateFile(t *testing.T, dir string, name string, data string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/nucleus/internal/root/root.go b/cmd/nucleus/internal/root/root.go index bc4ad1a..a04998d 100644 --- a/cmd/nucleus/internal/root/root.go +++ b/cmd/nucleus/internal/root/root.go @@ -8,6 +8,7 @@ import ( "github.com/nucleuskit/nucleus/cmd/nucleus/internal/gen" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/initcmd" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/lint" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/migrate" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/plan" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/repair" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/report" @@ -77,5 +78,8 @@ func New() *cobra.Command { cmd.AddCommand(capability.NewCommand(capability.Config{ Dir: &opts.dir, })) + cmd.AddCommand(migrate.NewCommand(migrate.Config{ + Dir: &opts.dir, + })) return cmd } diff --git a/contract b/contract index 00bebe1..982786b 160000 --- a/contract +++ b/contract @@ -1 +1 @@ -Subproject commit 00bebe1b7808fee591920ebfb8a3b63f516c9853 +Subproject commit 982786b0fc77e8196141bd2c8bc84df3d221e539 diff --git a/docs/concepts/migrate-command.md b/docs/concepts/migrate-command.md new file mode 100644 index 0000000..08a14c3 --- /dev/null +++ b/docs/concepts/migrate-command.md @@ -0,0 +1,63 @@ +# Migrate Command + +`nucleus migrate` produces a local migration plan for moving a service between +Nucleus or manifest schema versions. It is intentionally report-only: the +command does not rewrite service files, call provider SDKs, or contact a control +plane. + +## Modes + +The default mode is `plan`. It loads the service through `contract/inspect`, +combines the discovered manifest, contract, capability, generated freshness, and +verification metadata with CLI-local migration rules, then emits an auditable +plan. + +`--check` switches to readiness checking. The output shape remains the same, but +readiness failures such as stale generated artifacts or missing verification +commands become errors and produce a non-zero exit code. + +Both modes require `--from-version` and `--to-version`. Versions use +`MAJOR.MINOR` or `MAJOR.MINOR.PATCH` format with an optional leading `v`. + +## Output + +Human output is the default. `--json` emits a stable CLI result envelope: + +```json +{ + "result_kind": "nucleus.migrate_result", + "schema_version": "migrate.v1", + "schema_ref": "contract/schema/migrate.schema.json", + "ok": true, + "mode": "plan", + "summary": {}, + "diagnostics": [], + "migration": {} +} +``` + +Use `--pretty` with `--json` for indented JSON. Use `--report ` to write +the same JSON envelope to a report file. Report paths must resolve inside the +service directory; relative paths are resolved from that directory. + +## Migration Scope + +The migration plan is contract-first. Manifest and agent instructions are listed +before contract surfaces, generated artifacts, capability wiring, and +verification commands. Exact version rules are recorded in the CLI command; when +no exact rule is registered, the command emits a warning and falls back to a +generic forward migration checklist. + +`migrate` does not replace the AI-safe change loop. Service owners should apply +the planned edits through: + +```bash +nucleus describe --dir . --json +nucleus plan --dir . --task "" --json +nucleus gen --dir . +nucleus lint --dir . --strict +nucleus verify --dir . --json +``` + +This keeps migration behavior auditable without expanding Nucleus into a +runtime framework or hidden auto-upgrader.