From 95439444c107edd669434a933c0e1f14bcef0993 Mon Sep 17 00:00:00 2001 From: Joe Corall Date: Mon, 23 Mar 2026 19:15:50 -0400 Subject: [PATCH] [debug] add module versions --- cmd/extensions.go | 326 +++++++++++++++++++++++++++++++++++++++-- cmd/extensions_test.go | 207 ++++++++++++++++++++++++++ 2 files changed, 524 insertions(+), 9 deletions(-) diff --git a/cmd/extensions.go b/cmd/extensions.go index 4ad04ef..7206096 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -3,6 +3,7 @@ package cmd import ( "bytes" "context" + "encoding/json" "fmt" "log/slog" "os" @@ -28,6 +29,88 @@ const ( pageCacheExclusionURL = "https://www.drupal.org/project/page_cache_exclusion" ) +var drupalCoreModules = map[string]struct{}{ + "action": {}, + "announcements_feed": {}, + "automated_cron": {}, + "ban": {}, + "basic_auth": {}, + "big_pipe": {}, + "block": {}, + "block_content": {}, + "book": {}, + "breakpoint": {}, + "ckeditor5": {}, + "comment": {}, + "config": {}, + "config_translation": {}, + "contact": {}, + "content_moderation": {}, + "content_translation": {}, + "contextual": {}, + "datetime": {}, + "datetime_range": {}, + "dblog": {}, + "dynamic_page_cache": {}, + "editor": {}, + "field": {}, + "field_layout": {}, + "field_ui": {}, + "file": {}, + "filter": {}, + "forum": {}, + "help": {}, + "help_topics": {}, + "history": {}, + "image": {}, + "inline_form_errors": {}, + "jsonapi": {}, + "language": {}, + "layout_builder": {}, + "layout_discovery": {}, + "link": {}, + "locale": {}, + "media": {}, + "media_library": {}, + "menu_link_content": {}, + "menu_ui": {}, + "migrate": {}, + "migrate_drupal": {}, + "migrate_drupal_ui": {}, + "mysql": {}, + "navigation": {}, + "node": {}, + "options": {}, + "page_cache": {}, + "path": {}, + "path_alias": {}, + "pgsql": {}, + "phpass": {}, + "responsive_image": {}, + "rest": {}, + "sdc": {}, + "search": {}, + "serialization": {}, + "settings_tray": {}, + "shortcut": {}, + "sqlite": {}, + "statistics": {}, + "syslog": {}, + "system": {}, + "taxonomy": {}, + "telephone": {}, + "text": {}, + "toolbar": {}, + "tour": {}, + "tracker": {}, + "update": {}, + "user": {}, + "views": {}, + "views_ui": {}, + "workflows": {}, + "workspaces": {}, +} + var ( debugPanelStyle = lipgloss.NewStyle(). Background(lipgloss.Color("#112235")). @@ -161,6 +244,14 @@ func renderDrupalDebug(runCtx context.Context) (string, error) { if err != nil { return "", err } + moduleVersionInfo, err := readComposerLockModuleVersions(runCtx, files, drupalRoot, ctx.ProjectDir) + if err != nil { + return "", err + } + composerPatches, err := readComposerPatches(runCtx, files, drupalRoot, ctx.ProjectDir) + if err != nil { + return "", err + } slog.Debug("read installed extensions", "plugin", "drupal", "modules", len(modules), "themes", len(themes)) slog.Debug("rendering cache_page summary", "plugin", "drupal") cachePageSummary, err := renderCachePageSummary(runCtx) @@ -173,13 +264,26 @@ func renderDrupalDebug(runCtx context.Context) (string, error) { body = append(body, "", debugDivider(), "", debugTitleStyle.Render("Cache Page"), "", cachePageSummary) } + moduleLines, err := renderModuleList(runCtx, files, drupalRoot, modules, moduleVersionInfo) + if err != nil { + return "", err + } + configLines := []string{debugDivider(), "", debugTitleStyle.Render("Installed Extensions"), "", fmt.Sprintf("Installed modules (%d):", len(modules))} - configLines = append(configLines, formatListLines(modules, 3)...) + configLines = append(configLines, moduleLines...) configLines = append(configLines, "") configLines = append(configLines, fmt.Sprintf("Installed themes (%d):", len(themes))) configLines = append(configLines, formatListLines(themes, 3)...) body = append(body, "", strings.Join(configLines, "\n")) + patchLines := []string{debugDivider(), "", debugTitleStyle.Render("Composer Patches"), ""} + if strings.TrimSpace(composerPatches) == "" { + patchLines = append(patchLines, " none") + } else { + patchLines = append(patchLines, indentLines(composerPatches, " ")) + } + body = append(body, "", strings.Join(patchLines, "\n")) + slog.Debug("finished plugin debug", "plugin", "drupal") return renderDebugPanel("drupal", strings.Join(body, "\n")), nil } @@ -191,28 +295,38 @@ func renderCachePageSummary(runCtx context.Context) (string, error) { } defer cli.Close() - query := "SELECT COALESCE(data_length + index_length, 0) FROM information_schema.TABLES WHERE table_schema = DATABASE() AND table_name = 'cache_page';" - output, err := execDrupalCommandCapture(runCtx, cli, containerName, ctx.EffectiveDrupalContainerRoot(), []string{"drush", "sql:query", query, "--extra=--batch", "--extra=--skip-column-names"}) + cachePageSize, err := readDrupalCacheTableSize(runCtx, cli, containerName, ctx.EffectiveDrupalContainerRoot(), "cache_page") if err != nil { return "", err } - - size, err := parseFirstInt(output) + cacheRenderSize, err := readDrupalCacheTableSize(runCtx, cli, containerName, ctx.EffectiveDrupalContainerRoot(), "cache_render") if err != nil { return "", err } rows := []debugRow{ {Label: "Status", Value: renderStatus("ok")}, - {Label: "cache_page", Value: humanBytes(size)}, + {Label: "cache_page", Value: humanBytes(cachePageSize)}, + {Label: "cache_render", Value: humanBytes(cacheRenderSize)}, } - if size >= cachePageWarningThreshold { + if cachePageSize >= cachePageWarningThreshold || cacheRenderSize >= cachePageWarningThreshold { rows[0].Value = renderStatus("warning") + } + if cachePageSize >= cachePageWarningThreshold { rows = append(rows, debugRow{Label: "Recommendation", Value: pageCacheExclusionURL}) } return formatDebugRows(rows), nil } +func readDrupalCacheTableSize(runCtx context.Context, cli *docker.DockerClient, containerName, containerRoot, tableName string) (int64, error) { + query := fmt.Sprintf("SELECT COALESCE(data_length + index_length, 0) FROM information_schema.TABLES WHERE table_schema = DATABASE() AND table_name = '%s';", strings.TrimSpace(tableName)) + output, err := execDrupalCommandCapture(runCtx, cli, containerName, containerRoot, []string{"drush", "sql:query", query, "--extra=--batch", "--extra=--skip-column-names"}) + if err != nil { + return 0, err + } + return parseFirstInt(output) +} + func getDrupalContainerForSDK(runCtx context.Context) (ctx *config.Context, cli *docker.DockerClient, containerName string, err error) { if sdk == nil { return nil, nil, "", fmt.Errorf("plugin sdk is not initialized") @@ -302,8 +416,8 @@ func readCoreExtension(runCtx context.Context, files *plugin.FileAccessor, path } var extension struct { - Module map[string]int `yaml:"module"` - Theme map[string]int `yaml:"theme"` + Module map[string]any `yaml:"module"` + Theme map[string]any `yaml:"theme"` } if err := yaml.Unmarshal(data, &extension); err != nil { return nil, nil, err @@ -324,6 +438,200 @@ func readCoreExtension(runCtx context.Context, files *plugin.FileAccessor, path return modules, themes, nil } +func readComposerLockModuleVersions(runCtx context.Context, files *plugin.FileAccessor, drupalRoot, projectDir string) (composerLockVersionInfo, error) { + path, err := findComposerLockPath(runCtx, files, drupalRoot, projectDir) + if err != nil { + return composerLockVersionInfo{}, err + } + if path == "" { + return composerLockVersionInfo{}, nil + } + + data, err := files.ReadFileContext(runCtx, path) + if err != nil { + if os.IsNotExist(err) { + return composerLockVersionInfo{}, nil + } + return composerLockVersionInfo{}, err + } + + var lock struct { + Packages []composerLockPackage `json:"packages"` + PackagesDev []composerLockPackage `json:"packages-dev"` + } + if err := json.Unmarshal(data, &lock); err != nil { + return composerLockVersionInfo{}, err + } + + info := composerLockVersionInfo{ModuleVersions: make(map[string]string)} + for _, pkg := range append(lock.Packages, lock.PackagesDev...) { + name, version := strings.TrimSpace(pkg.Name), strings.TrimSpace(pkg.Version) + if version == "" { + continue + } + switch name { + case "drupal/core", "drupal/drupal": + if info.CoreVersion == "" { + info.CoreVersion = version + } + } + if pkg.Type != "drupal-module" { + continue + } + moduleName := strings.TrimSpace(pkg.ModuleName()) + if moduleName == "" { + continue + } + info.ModuleVersions[moduleName] = version + } + return info, nil +} + +func readComposerPatches(runCtx context.Context, files *plugin.FileAccessor, drupalRoot, projectDir string) (string, error) { + path, err := findComposerFilePath(runCtx, files, drupalRoot, projectDir, "composer.json") + if err != nil { + return "", err + } + if path == "" { + return "", nil + } + + data, err := files.ReadFileContext(runCtx, path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + + var composer struct { + Extra struct { + Patches json.RawMessage `json:"patches"` + } `json:"extra"` + } + if err := json.Unmarshal(data, &composer); err != nil { + return "", err + } + if len(composer.Extra.Patches) == 0 || string(composer.Extra.Patches) == "null" { + return "", nil + } + + var pretty bytes.Buffer + if err := json.Indent(&pretty, composer.Extra.Patches, "", " "); err != nil { + return "", err + } + return pretty.String(), nil +} + +func findComposerLockPath(runCtx context.Context, files *plugin.FileAccessor, drupalRoot, projectDir string) (string, error) { + return findComposerFilePath(runCtx, files, drupalRoot, projectDir, "composer.lock") +} + +func findComposerFilePath(runCtx context.Context, files *plugin.FileAccessor, drupalRoot, projectDir, fileName string) (string, error) { + candidates := []string{ + filepath.Join(strings.TrimSpace(drupalRoot), fileName), + } + if projectDir != drupalRoot { + candidates = append(candidates, filepath.Join(strings.TrimSpace(projectDir), fileName)) + } + + for _, candidate := range candidates { + if strings.TrimSpace(candidate) == "" { + continue + } + if _, err := files.ReadFileContext(runCtx, candidate); err == nil { + return candidate, nil + } else if !os.IsNotExist(err) { + return "", err + } + } + return "", nil +} + +type composerLockVersionInfo struct { + CoreVersion string + ModuleVersions map[string]string +} + +type composerLockPackage struct { + Name string `json:"name"` + Version string `json:"version"` + Type string `json:"type"` +} + +func (p composerLockPackage) ModuleName() string { + name := strings.TrimSpace(p.Name) + if !strings.HasPrefix(name, "drupal/") { + return "" + } + return strings.TrimPrefix(name, "drupal/") +} + +func renderModuleList(runCtx context.Context, files *plugin.FileAccessor, drupalRoot string, modules []string, versionInfo composerLockVersionInfo) ([]string, error) { + coreModules := make([]string, 0) + contribModules := make([]string, 0) + unknownModules := make([]string, 0) + + for _, module := range modules { + module = strings.TrimSpace(module) + if module == "" { + continue + } + if isStaticCoreDrupalModule(module) { + if version := strings.TrimSpace(versionInfo.CoreVersion); version != "" { + coreModules = append(coreModules, fmt.Sprintf("%s@%s", module, version)) + } else { + coreModules = append(coreModules, module) + } + continue + } + if version := strings.TrimSpace(versionInfo.ModuleVersions[module]); version != "" { + contribModules = append(contribModules, fmt.Sprintf("%s@%s", module, version)) + continue + } + unknownModules = append(unknownModules, module) + } + + sort.Strings(coreModules) + sort.Strings(contribModules) + sort.Strings(unknownModules) + + sections := []struct { + Title string + Values []string + }{ + {Title: "Core modules:", Values: coreModules}, + {Title: "Contrib modules:", Values: contribModules}, + {Title: "Custom or submodules:", Values: unknownModules}, + } + + lines := make([]string, 0) + for idx, section := range sections { + lines = append(lines, " "+section.Title) + lines = append(lines, formatListLines(section.Values, 3)...) + if idx < len(sections)-1 { + lines = append(lines, "") + } + } + return lines, nil +} + +func isStaticCoreDrupalModule(module string) bool { + _, ok := drupalCoreModules[strings.TrimSpace(module)] + return ok +} + +func indentLines(value, prefix string) string { + if strings.TrimSpace(value) == "" { + return strings.TrimSpace(prefix) + } + parts := strings.Split(value, "\n") + for i, part := range parts { + parts[i] = prefix + part + } + return strings.Join(parts, "\n") +} + func formatListLines(values []string, perLine int) []string { if len(values) == 0 { return []string{" none"} diff --git a/cmd/extensions_test.go b/cmd/extensions_test.go index 17bef3d..0d4d538 100644 --- a/cmd/extensions_test.go +++ b/cmd/extensions_test.go @@ -51,6 +51,45 @@ theme: } } +func TestReadCoreExtensionParsesNonIntegerValues(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "core.extension.yml") + data := `_core: + default_config_hash: abc +module: + pathauto: enabled + system: 0 + views: true +theme: + claro: default + olivero: false +` + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + files, err := plugin.NewFileAccessor(&config.Context{DockerHostType: config.ContextLocal}) + if err != nil { + t.Fatalf("NewFileAccessor() error = %v", err) + } + defer files.Close() + + modules, themes, err := readCoreExtension(context.Background(), files, path) + if err != nil { + t.Fatalf("readCoreExtension() error = %v", err) + } + + wantModules := []string{"pathauto", "system", "views"} + if !reflect.DeepEqual(modules, wantModules) { + t.Fatalf("modules = %v, want %v", modules, wantModules) + } + + wantThemes := []string{"claro", "olivero"} + if !reflect.DeepEqual(themes, wantThemes) { + t.Fatalf("themes = %v, want %v", themes, wantThemes) + } +} + func TestReadCoreExtensionMissingFileReturnsNilSlices(t *testing.T) { files, err := plugin.NewFileAccessor(&config.Context{DockerHostType: config.ContextLocal}) if err != nil { @@ -165,3 +204,171 @@ func TestRenderDrupalDebugRequiresSDK(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + +func TestReadComposerLockModuleVersionsPrefersDrupalRootAndParsesContribModules(t *testing.T) { + projectDir := t.TempDir() + drupalRoot := filepath.Join(projectDir, "web") + if err := os.MkdirAll(drupalRoot, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + rootLock := `{ + "packages": [ + {"name": "drupal/pathauto", "version": "1.12.0", "type": "drupal-module"}, + {"name": "drupal/token", "version": "1.15.0", "type": "drupal-module"}, + {"name": "drupal/core", "version": "11.1.0", "type": "metapackage"} + ], + "packages-dev": [ + {"name": "drupal/devel", "version": "5.3.0", "type": "drupal-module"} + ] + }` + if err := os.WriteFile(filepath.Join(drupalRoot, "composer.lock"), []byte(rootLock), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + projectLock := `{"packages": [{"name": "drupal/pathauto", "version": "9.9.9", "type": "drupal-module"}]}` + if err := os.WriteFile(filepath.Join(projectDir, "composer.lock"), []byte(projectLock), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + files, err := plugin.NewFileAccessor(&config.Context{DockerHostType: config.ContextLocal}) + if err != nil { + t.Fatalf("NewFileAccessor() error = %v", err) + } + defer files.Close() + + got, err := readComposerLockModuleVersions(context.Background(), files, drupalRoot, projectDir) + if err != nil { + t.Fatalf("readComposerLockModuleVersions() error = %v", err) + } + + want := composerLockVersionInfo{ + CoreVersion: "11.1.0", + ModuleVersions: map[string]string{"devel": "5.3.0", "pathauto": "1.12.0", "token": "1.15.0"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("versions = %v, want %v", got, want) + } +} + +func TestReadComposerLockModuleVersionsFallsBackToProjectDir(t *testing.T) { + projectDir := t.TempDir() + drupalRoot := filepath.Join(projectDir, "web") + if err := os.MkdirAll(drupalRoot, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + lock := `{"packages": [{"name": "drupal/admin_toolbar", "version": "3.5.0", "type": "drupal-module"}]}` + if err := os.WriteFile(filepath.Join(projectDir, "composer.lock"), []byte(lock), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + files, err := plugin.NewFileAccessor(&config.Context{DockerHostType: config.ContextLocal}) + if err != nil { + t.Fatalf("NewFileAccessor() error = %v", err) + } + defer files.Close() + + got, err := readComposerLockModuleVersions(context.Background(), files, drupalRoot, projectDir) + if err != nil { + t.Fatalf("readComposerLockModuleVersions() error = %v", err) + } + + want := composerLockVersionInfo{ModuleVersions: map[string]string{"admin_toolbar": "3.5.0"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("versions = %v, want %v", got, want) + } +} + +func TestReadComposerPatchesPrettyPrintsExtraPatches(t *testing.T) { + projectDir := t.TempDir() + drupalRoot := filepath.Join(projectDir, "web") + if err := os.MkdirAll(drupalRoot, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + composerJSON := `{ + "extra": { + "patches": { + "drupal/core": { + "Example patch": "https://example.com/core.patch" + }, + "drupal/pathauto": { + "Another patch": "https://example.com/pathauto.patch" + } + } + } + }` + if err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + files, err := plugin.NewFileAccessor(&config.Context{DockerHostType: config.ContextLocal}) + if err != nil { + t.Fatalf("NewFileAccessor() error = %v", err) + } + defer files.Close() + + got, err := readComposerPatches(context.Background(), files, drupalRoot, projectDir) + if err != nil { + t.Fatalf("readComposerPatches() error = %v", err) + } + + want := strings.Join([]string{ + "{", + " \"drupal/core\": {", + " \"Example patch\": \"https://example.com/core.patch\"", + " },", + " \"drupal/pathauto\": {", + " \"Another patch\": \"https://example.com/pathauto.patch\"", + " }", + "}", + }, "\n") + if got != want { + t.Fatalf("readComposerPatches() = %q, want %q", got, want) + } +} + +func TestRenderModuleListGroupsAndSortsModules(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "web", "core"), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(filepath.Join(root, "web", "core", "core.services.yml"), []byte("parameters: {}\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + for _, module := range []string{"node", "system"} { + moduleDir := filepath.Join(root, "web", "core", "modules", module) + if err := os.MkdirAll(moduleDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(filepath.Join(moduleDir, module+".info.yml"), []byte("name: "+module+"\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + } + + files, err := plugin.NewFileAccessor(&config.Context{DockerHostType: config.ContextLocal}) + if err != nil { + t.Fatalf("NewFileAccessor() error = %v", err) + } + defer files.Close() + + got, err := renderModuleList(context.Background(), files, root, []string{"token", "system", "aaa_custom", "pathauto", "node", "zzz_custom"}, composerLockVersionInfo{CoreVersion: "11.1.0", ModuleVersions: map[string]string{"pathauto": "1.12.0", "token": "1.15.0"}}) + if err != nil { + t.Fatalf("renderModuleList() error = %v", err) + } + + want := []string{ + " Core modules:", + " node@11.1.0, system@11.1.0", + "", + " Contrib modules:", + " pathauto@1.12.0, token@1.15.0", + "", + " Custom or submodules:", + " aaa_custom, zzz_custom", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("renderModuleList() = %v, want %v", got, want) + } +}