From 5e63faf229b47673d0bcd6edea95952792988397 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Mon, 13 Apr 2026 06:12:04 +0000 Subject: [PATCH] Initial: MokoStandards Enterprise API extracted from MokoStandards Standalone Composer package (mokoconsulting-tech/enterprise). Source: api/, bin/, lib/ directories from MokoStandards main repo. Autoload paths updated for standalone layout. --- .editorconfig | 80 + .gitignore | 1064 ++++++++++++ .script-registry.json | 575 +++++++ PLUGIN_SCRIPTS.md | 82 + README.md | 42 +- analysis/index.md | 20 + automation/bulk_sync.php | 1374 ++++++++++++++++ .../file-distributor-config-example.json | 12 + automation/index.md | 21 + automation/migrate_to_gitea.php | 296 ++++ automation/push_files.php | 695 ++++++++ automation/repo_cleanup.php | 517 ++++++ bin/moko | 229 +++ composer.json | 108 ++ definitions/default/crm-module.tf | 1249 +++++++++++++++ definitions/default/crm-platform.tf | 306 ++++ definitions/default/default-repository.json | 187 +++ definitions/default/default-repository.tf | 640 ++++++++ definitions/default/generic-repository.tf | 1165 ++++++++++++++ .../default/github-private-repository.tf | 331 ++++ definitions/default/standards-repository.tf | 705 ++++++++ definitions/default/waas-component.tf | 1253 +++++++++++++++ definitions/index.md | 20 + definitions/sync/.github-private.def.tf | 683 ++++++++ definitions/sync/.github.def.tf | 734 +++++++++ definitions/sync/.gitkeep | 0 definitions/sync/Copy-PortablePath.def.tf | 735 +++++++++ definitions/sync/DoliMods.def.tf | 1334 ++++++++++++++++ definitions/sync/MokoCRM.def.tf | 1336 ++++++++++++++++ definitions/sync/MokoCassiopeia.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliAdInsights.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliArt.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliAuth.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliCare.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliChimp.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliClaude.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliCredits.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliDymo.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliForm.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliG.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliGithub.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliHRM.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliMods.def.tf | 401 +++++ definitions/sync/MokoDoliMulti.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliOffline.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliPhone.com.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliProjTemplate.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliRelease.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliSign.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDoliTools.def.tf | 1256 +++++++++++++++ definitions/sync/MokoDoliTraining.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoDolibarr.def.tf | 401 +++++ definitions/sync/MokoISOUpdatePortable.def.tf | 735 +++++++++ definitions/sync/MokoJoomHero.def.tf | 1334 ++++++++++++++++ definitions/sync/MokoJoomTOS.def.tf | 1334 ++++++++++++++++ .../sync/MokoPerfectPublisher-Discord.def.tf | 735 +++++++++ .../sync/MokoStandards-Template-Client.def.tf | 735 +++++++++ .../MokoStandards-Template-Dolibarr.def.tf | 1335 ++++++++++++++++ .../MokoStandards-Template-Generic.def.tf | 735 +++++++++ ...Standards-Template-Joomla-Component.def.tf | 1335 ++++++++++++++++ ...koStandards-Template-Joomla-Library.def.tf | 1335 ++++++++++++++++ ...okoStandards-Template-Joomla-Module.def.tf | 1335 ++++++++++++++++ ...koStandards-Template-Joomla-Package.def.tf | 1335 ++++++++++++++++ ...okoStandards-Template-Joomla-Plugin.def.tf | 1335 ++++++++++++++++ ...oStandards-Template-Joomla-Template.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoTesting.def.tf | 735 +++++++++ definitions/sync/MokoWaaS.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoWaaSAnnounce.def.tf | 1335 ++++++++++++++++ definitions/sync/MokoWaaSBrand.def.tf | 1287 +++++++++++++++ definitions/sync/MokoWinSetup.def.tf | 735 +++++++++ .../sync/PLG_FINDER_MOKOVMSMARTSEARCH.def.tf | 735 +++++++++ .../sync/client-clarksvillefurs.def.tf | 776 +++++++++ definitions/sync/client-kiddieland.def.tf | 735 +++++++++ definitions/sync/client-vexcreations.def.tf | 1335 ++++++++++++++++ deploy/deploy-joomla.php | 579 +++++++ deploy/deploy-sftp.php | 794 +++++++++ docs/ARCHITECTURE.md | 362 +++++ docs/AUTO_CREATE_ORG_PROJECTS.md | 383 +++++ docs/DRY_RUN_PATTERN.md | 98 ++ docs/LEGAL_DOC_GENERATOR_WEB_README.md | 138 ++ docs/NEW_SCRIPTS.md | 392 +++++ docs/QUICKSTART_ORG_PROJECTS.md | 178 +++ docs/index.md | 32 + docs/legal_doc_generator.html | 887 ++++++++++ fix/.gitkeep | 0 fix/fix_line_endings.php | 64 + fix/fix_permissions.php | 64 + fix/fix_tabs.php | 73 + fix/fix_trailing_spaces.php | 72 + fix/index.md | 20 + index.md | 89 ++ lib/.gitkeep | 0 lib/CliBase.php | 591 +++++++ lib/Common.php | 298 ++++ lib/Enterprise/AbstractProjectPlugin.php | 259 +++ lib/Enterprise/ApiClient.php | 525 ++++++ lib/Enterprise/AuditLogger.php | 459 ++++++ lib/Enterprise/CheckpointManager.php | 152 ++ lib/Enterprise/CliFramework.php | 1422 +++++++++++++++++ lib/Enterprise/Config.php | 445 ++++++ lib/Enterprise/DefinitionParser.php | 499 ++++++ .../EnterpriseReadinessValidator.php | 259 +++ lib/Enterprise/ErrorRecovery.php | 58 + lib/Enterprise/FileFixUtility.php | 283 ++++ lib/Enterprise/GitHubAdapter.php | 398 +++++ lib/Enterprise/GitPlatformAdapter.php | 405 +++++ lib/Enterprise/GiteaAdapter.php | 443 +++++ lib/Enterprise/InputValidator.php | 497 ++++++ lib/Enterprise/MetricsCollector.php | 330 ++++ lib/Enterprise/PackageBuilder.php | 292 ++++ lib/Enterprise/PlatformAdapterFactory.php | 131 ++ lib/Enterprise/PluginFactory.php | 319 ++++ lib/Enterprise/PluginRegistry.php | 266 +++ lib/Enterprise/Plugins/ApiPlugin.php | 802 ++++++++++ .../Plugins/DocumentationPlugin.php | 625 ++++++++ lib/Enterprise/Plugins/DolibarrPlugin.php | 448 ++++++ lib/Enterprise/Plugins/GenericPlugin.php | 521 ++++++ lib/Enterprise/Plugins/JoomlaPlugin.php | 457 ++++++ lib/Enterprise/Plugins/MobilePlugin.php | 660 ++++++++ lib/Enterprise/Plugins/NodeJsPlugin.php | 578 +++++++ lib/Enterprise/Plugins/PythonPlugin.php | 625 ++++++++ lib/Enterprise/Plugins/TerraformPlugin.php | 584 +++++++ lib/Enterprise/Plugins/WordPressPlugin.php | 677 ++++++++ lib/Enterprise/ProjectConfigValidator.php | 355 ++++ lib/Enterprise/ProjectMetricsCollector.php | 303 ++++ lib/Enterprise/ProjectPluginInterface.php | 118 ++ lib/Enterprise/ProjectTypeDetector.php | 374 +++++ lib/Enterprise/RecoveryError.php | 27 + lib/Enterprise/RecoveryManager.php | 124 ++ lib/Enterprise/RepositoryHealthChecker.php | 317 ++++ lib/Enterprise/RepositorySynchronizer.php | 1193 ++++++++++++++ lib/Enterprise/RetryHelper.php | 140 ++ lib/Enterprise/SecurityValidator.php | 402 +++++ lib/Enterprise/SynchronizationException.php | 42 + lib/Enterprise/TransactionManager.php | 331 ++++ lib/Enterprise/UnifiedValidation.php | 532 ++++++ lib/index.md | 20 + lib/plugins/Joomla/UpdateXmlGenerator.php | 392 +++++ maintenance/index.md | 20 + maintenance/pin_action_shas.php | 318 ++++ maintenance/repo_inventory.php | 219 +++ maintenance/rotate_secrets.php | 214 +++ maintenance/setup_labels.php | 252 +++ maintenance/sync_dolibarr_readmes.php | 325 ++++ maintenance/update_repo_inventory.php | 311 ++++ maintenance/update_sha_hashes.php | 196 +++ maintenance/update_version_from_readme.php | 483 ++++++ phpcs.xml | 54 + phpstan.neon | 35 + plugin_health_check.php | 287 ++++ plugin_list.php | 292 ++++ plugin_metrics.php | 317 ++++ plugin_readiness.php | 272 ++++ plugin_validate.php | 266 +++ psalm.xml | 34 + run/index.md | 20 + src/functions.php | 39 + tests/Enterprise/GitPlatformAdapterTest.php | 199 +++ tests/index.md | 17 + tests/test_circuit_breaker_handling.php | 81 + tests/test_enterprise_libraries.php | 101 ++ validate/.gitkeep | 0 validate/SECURITY_SCANNING.md | 331 ++++ validate/auto_detect_platform.php | 772 +++++++++ validate/check_changelog.php | 133 ++ validate/check_composer_deps.php | 186 +++ validate/check_dolibarr_module.php | 96 ++ validate/check_enterprise_readiness.php | 250 +++ validate/check_joomla_manifest.php | 91 ++ validate/check_language_structure.php | 85 + validate/check_license_headers.php | 96 ++ validate/check_no_secrets.php | 113 ++ validate/check_paths.php | 85 + validate/check_php_syntax.php | 81 + validate/check_repo_health.php | 835 ++++++++++ validate/check_structure.php | 139 ++ validate/check_tabs.php | 80 + validate/check_version_consistency.php | 242 +++ validate/check_xml_wellformed.php | 86 + validate/index.md | 21 + validate/scan_drift.php | 596 +++++++ wrappers/auto_detect_platform.php | 109 ++ wrappers/bulk_sync.php | 109 ++ wrappers/check_changelog.php | 109 ++ wrappers/check_dolibarr_module.php | 109 ++ wrappers/check_enterprise_readiness.php | 109 ++ wrappers/check_joomla_manifest.php | 109 ++ wrappers/check_language_structure.php | 109 ++ wrappers/check_license_headers.php | 109 ++ wrappers/check_no_secrets.php | 109 ++ wrappers/check_paths.php | 109 ++ wrappers/check_php_syntax.php | 109 ++ wrappers/check_repo_health.php | 109 ++ wrappers/check_structure.php | 109 ++ wrappers/check_tabs.php | 109 ++ wrappers/check_version_consistency.php | 109 ++ wrappers/check_xml_wellformed.php | 109 ++ wrappers/deploy_sftp.php | 109 ++ wrappers/fix_line_endings.php | 109 ++ wrappers/fix_permissions.php | 109 ++ wrappers/fix_tabs.php | 109 ++ wrappers/fix_trailing_spaces.php | 109 ++ wrappers/gen_wrappers.php | 205 +++ wrappers/index.md | 121 ++ wrappers/pin_action_shas.php | 109 ++ wrappers/plugin_health_check.php | 109 ++ wrappers/plugin_list.php | 109 ++ wrappers/plugin_metrics.php | 109 ++ wrappers/plugin_readiness.php | 109 ++ wrappers/plugin_validate.php | 109 ++ wrappers/scan_drift.php | 109 ++ wrappers/setup_labels.php | 109 ++ wrappers/sync_dolibarr_readmes.php | 109 ++ wrappers/update_sha_hashes.php | 109 ++ wrappers/update_version_from_readme.php | 109 ++ 215 files changed, 104197 insertions(+), 2 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .script-registry.json create mode 100644 PLUGIN_SCRIPTS.md create mode 100644 analysis/index.md create mode 100755 automation/bulk_sync.php create mode 100644 automation/file-distributor-config-example.json create mode 100644 automation/index.md create mode 100644 automation/migrate_to_gitea.php create mode 100644 automation/push_files.php create mode 100644 automation/repo_cleanup.php create mode 100644 bin/moko create mode 100644 composer.json create mode 100644 definitions/default/crm-module.tf create mode 100644 definitions/default/crm-platform.tf create mode 100644 definitions/default/default-repository.json create mode 100644 definitions/default/default-repository.tf create mode 100644 definitions/default/generic-repository.tf create mode 100644 definitions/default/github-private-repository.tf create mode 100644 definitions/default/standards-repository.tf create mode 100644 definitions/default/waas-component.tf create mode 100644 definitions/index.md create mode 100644 definitions/sync/.github-private.def.tf create mode 100644 definitions/sync/.github.def.tf create mode 100644 definitions/sync/.gitkeep create mode 100644 definitions/sync/Copy-PortablePath.def.tf create mode 100644 definitions/sync/DoliMods.def.tf create mode 100644 definitions/sync/MokoCRM.def.tf create mode 100644 definitions/sync/MokoCassiopeia.def.tf create mode 100644 definitions/sync/MokoDoliAdInsights.def.tf create mode 100644 definitions/sync/MokoDoliArt.def.tf create mode 100644 definitions/sync/MokoDoliAuth.def.tf create mode 100644 definitions/sync/MokoDoliCare.def.tf create mode 100644 definitions/sync/MokoDoliChimp.def.tf create mode 100644 definitions/sync/MokoDoliClaude.def.tf create mode 100644 definitions/sync/MokoDoliCredits.def.tf create mode 100644 definitions/sync/MokoDoliDymo.def.tf create mode 100644 definitions/sync/MokoDoliForm.def.tf create mode 100644 definitions/sync/MokoDoliG.def.tf create mode 100644 definitions/sync/MokoDoliGithub.def.tf create mode 100644 definitions/sync/MokoDoliHRM.def.tf create mode 100644 definitions/sync/MokoDoliMods.def.tf create mode 100644 definitions/sync/MokoDoliMulti.def.tf create mode 100644 definitions/sync/MokoDoliOffline.def.tf create mode 100644 definitions/sync/MokoDoliPhone.com.def.tf create mode 100644 definitions/sync/MokoDoliProjTemplate.def.tf create mode 100644 definitions/sync/MokoDoliRelease.def.tf create mode 100644 definitions/sync/MokoDoliSign.def.tf create mode 100644 definitions/sync/MokoDoliTools.def.tf create mode 100644 definitions/sync/MokoDoliTraining.def.tf create mode 100644 definitions/sync/MokoDolibarr.def.tf create mode 100644 definitions/sync/MokoISOUpdatePortable.def.tf create mode 100644 definitions/sync/MokoJoomHero.def.tf create mode 100644 definitions/sync/MokoJoomTOS.def.tf create mode 100644 definitions/sync/MokoPerfectPublisher-Discord.def.tf create mode 100644 definitions/sync/MokoStandards-Template-Client.def.tf create mode 100644 definitions/sync/MokoStandards-Template-Dolibarr.def.tf create mode 100644 definitions/sync/MokoStandards-Template-Generic.def.tf create mode 100644 definitions/sync/MokoStandards-Template-Joomla-Component.def.tf create mode 100644 definitions/sync/MokoStandards-Template-Joomla-Library.def.tf create mode 100644 definitions/sync/MokoStandards-Template-Joomla-Module.def.tf create mode 100644 definitions/sync/MokoStandards-Template-Joomla-Package.def.tf create mode 100644 definitions/sync/MokoStandards-Template-Joomla-Plugin.def.tf create mode 100644 definitions/sync/MokoStandards-Template-Joomla-Template.def.tf create mode 100644 definitions/sync/MokoTesting.def.tf create mode 100644 definitions/sync/MokoWaaS.def.tf create mode 100644 definitions/sync/MokoWaaSAnnounce.def.tf create mode 100644 definitions/sync/MokoWaaSBrand.def.tf create mode 100644 definitions/sync/MokoWinSetup.def.tf create mode 100644 definitions/sync/PLG_FINDER_MOKOVMSMARTSEARCH.def.tf create mode 100644 definitions/sync/client-clarksvillefurs.def.tf create mode 100644 definitions/sync/client-kiddieland.def.tf create mode 100644 definitions/sync/client-vexcreations.def.tf create mode 100644 deploy/deploy-joomla.php create mode 100644 deploy/deploy-sftp.php create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/AUTO_CREATE_ORG_PROJECTS.md create mode 100644 docs/DRY_RUN_PATTERN.md create mode 100644 docs/LEGAL_DOC_GENERATOR_WEB_README.md create mode 100644 docs/NEW_SCRIPTS.md create mode 100644 docs/QUICKSTART_ORG_PROJECTS.md create mode 100644 docs/index.md create mode 100644 docs/legal_doc_generator.html create mode 100644 fix/.gitkeep create mode 100644 fix/fix_line_endings.php create mode 100644 fix/fix_permissions.php create mode 100644 fix/fix_tabs.php create mode 100644 fix/fix_trailing_spaces.php create mode 100644 fix/index.md create mode 100644 index.md create mode 100644 lib/.gitkeep create mode 100644 lib/CliBase.php create mode 100644 lib/Common.php create mode 100644 lib/Enterprise/AbstractProjectPlugin.php create mode 100644 lib/Enterprise/ApiClient.php create mode 100644 lib/Enterprise/AuditLogger.php create mode 100644 lib/Enterprise/CheckpointManager.php create mode 100644 lib/Enterprise/CliFramework.php create mode 100644 lib/Enterprise/Config.php create mode 100644 lib/Enterprise/DefinitionParser.php create mode 100644 lib/Enterprise/EnterpriseReadinessValidator.php create mode 100644 lib/Enterprise/ErrorRecovery.php create mode 100644 lib/Enterprise/FileFixUtility.php create mode 100644 lib/Enterprise/GitHubAdapter.php create mode 100644 lib/Enterprise/GitPlatformAdapter.php create mode 100644 lib/Enterprise/GiteaAdapter.php create mode 100644 lib/Enterprise/InputValidator.php create mode 100644 lib/Enterprise/MetricsCollector.php create mode 100644 lib/Enterprise/PackageBuilder.php create mode 100644 lib/Enterprise/PlatformAdapterFactory.php create mode 100644 lib/Enterprise/PluginFactory.php create mode 100644 lib/Enterprise/PluginRegistry.php create mode 100644 lib/Enterprise/Plugins/ApiPlugin.php create mode 100644 lib/Enterprise/Plugins/DocumentationPlugin.php create mode 100644 lib/Enterprise/Plugins/DolibarrPlugin.php create mode 100644 lib/Enterprise/Plugins/GenericPlugin.php create mode 100644 lib/Enterprise/Plugins/JoomlaPlugin.php create mode 100644 lib/Enterprise/Plugins/MobilePlugin.php create mode 100644 lib/Enterprise/Plugins/NodeJsPlugin.php create mode 100644 lib/Enterprise/Plugins/PythonPlugin.php create mode 100644 lib/Enterprise/Plugins/TerraformPlugin.php create mode 100644 lib/Enterprise/Plugins/WordPressPlugin.php create mode 100644 lib/Enterprise/ProjectConfigValidator.php create mode 100644 lib/Enterprise/ProjectMetricsCollector.php create mode 100644 lib/Enterprise/ProjectPluginInterface.php create mode 100644 lib/Enterprise/ProjectTypeDetector.php create mode 100644 lib/Enterprise/RecoveryError.php create mode 100644 lib/Enterprise/RecoveryManager.php create mode 100644 lib/Enterprise/RepositoryHealthChecker.php create mode 100644 lib/Enterprise/RepositorySynchronizer.php create mode 100644 lib/Enterprise/RetryHelper.php create mode 100644 lib/Enterprise/SecurityValidator.php create mode 100644 lib/Enterprise/SynchronizationException.php create mode 100644 lib/Enterprise/TransactionManager.php create mode 100644 lib/Enterprise/UnifiedValidation.php create mode 100644 lib/index.md create mode 100644 lib/plugins/Joomla/UpdateXmlGenerator.php create mode 100644 maintenance/index.md create mode 100644 maintenance/pin_action_shas.php create mode 100644 maintenance/repo_inventory.php create mode 100644 maintenance/rotate_secrets.php create mode 100644 maintenance/setup_labels.php create mode 100644 maintenance/sync_dolibarr_readmes.php create mode 100644 maintenance/update_repo_inventory.php create mode 100755 maintenance/update_sha_hashes.php create mode 100644 maintenance/update_version_from_readme.php create mode 100644 phpcs.xml create mode 100644 phpstan.neon create mode 100755 plugin_health_check.php create mode 100755 plugin_list.php create mode 100755 plugin_metrics.php create mode 100755 plugin_readiness.php create mode 100755 plugin_validate.php create mode 100644 psalm.xml create mode 100644 run/index.md create mode 100644 src/functions.php create mode 100644 tests/Enterprise/GitPlatformAdapterTest.php create mode 100644 tests/index.md create mode 100644 tests/test_circuit_breaker_handling.php create mode 100644 tests/test_enterprise_libraries.php create mode 100644 validate/.gitkeep create mode 100644 validate/SECURITY_SCANNING.md create mode 100755 validate/auto_detect_platform.php create mode 100644 validate/check_changelog.php create mode 100644 validate/check_composer_deps.php create mode 100644 validate/check_dolibarr_module.php create mode 100755 validate/check_enterprise_readiness.php create mode 100644 validate/check_joomla_manifest.php create mode 100644 validate/check_language_structure.php create mode 100644 validate/check_license_headers.php create mode 100644 validate/check_no_secrets.php create mode 100644 validate/check_paths.php create mode 100644 validate/check_php_syntax.php create mode 100755 validate/check_repo_health.php create mode 100644 validate/check_structure.php create mode 100644 validate/check_tabs.php create mode 100755 validate/check_version_consistency.php create mode 100644 validate/check_xml_wellformed.php create mode 100644 validate/index.md create mode 100755 validate/scan_drift.php create mode 100644 wrappers/auto_detect_platform.php create mode 100644 wrappers/bulk_sync.php create mode 100644 wrappers/check_changelog.php create mode 100644 wrappers/check_dolibarr_module.php create mode 100644 wrappers/check_enterprise_readiness.php create mode 100644 wrappers/check_joomla_manifest.php create mode 100644 wrappers/check_language_structure.php create mode 100644 wrappers/check_license_headers.php create mode 100644 wrappers/check_no_secrets.php create mode 100644 wrappers/check_paths.php create mode 100644 wrappers/check_php_syntax.php create mode 100644 wrappers/check_repo_health.php create mode 100644 wrappers/check_structure.php create mode 100644 wrappers/check_tabs.php create mode 100644 wrappers/check_version_consistency.php create mode 100644 wrappers/check_xml_wellformed.php create mode 100644 wrappers/deploy_sftp.php create mode 100644 wrappers/fix_line_endings.php create mode 100644 wrappers/fix_permissions.php create mode 100644 wrappers/fix_tabs.php create mode 100644 wrappers/fix_trailing_spaces.php create mode 100644 wrappers/gen_wrappers.php create mode 100644 wrappers/index.md create mode 100644 wrappers/pin_action_shas.php create mode 100644 wrappers/plugin_health_check.php create mode 100644 wrappers/plugin_list.php create mode 100644 wrappers/plugin_metrics.php create mode 100644 wrappers/plugin_readiness.php create mode 100644 wrappers/plugin_validate.php create mode 100644 wrappers/scan_drift.php create mode 100644 wrappers/setup_labels.php create mode 100644 wrappers/sync_dolibarr_readmes.php create mode 100644 wrappers/update_sha_hashes.php create mode 100644 wrappers/update_version_from_readme.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..28d4d99 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,80 @@ +# EditorConfig helps maintain consistent coding styles across different editors and IDEs +# https://editorconfig.org/ + +root = true + +# Default settings — Tabs preferred, width = 2 spaces +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab +tab_width = 2 + +# PowerShell scripts — tabs, 2-space visual width +[*.ps1] +indent_style = tab +tab_width = 2 +end_of_line = crlf + +# Markdown files — keep trailing whitespace for line breaks, use tabs for indentation +# No max_line_length specified - Markdown lines can be any length +[*.md] +trim_trailing_whitespace = false +indent_style = tab +tab_width = 2 +max_line_length = off + +# YAML files — spaces only (YAML spec forbids tabs) +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +# Python files — spaces only (PEP 8 standard) +[*.py] +indent_style = space +indent_size = 4 + +# Haskell files — spaces only (layout rules) +[*.{hs,lhs}] +indent_style = space +indent_size = 2 + +# F# files — spaces only (indentation-sensitive syntax) +[*.{fs,fsx,fsi}] +indent_style = space +indent_size = 4 + +# CoffeeScript files — spaces only (whitespace-significant) +[*.coffee] +indent_style = space +indent_size = 2 + +# Nim files — spaces only (style guide) +[*.{nim,nims,nimble}] +indent_style = space +indent_size = 2 + +# JSON files — spaces only (parser compatibility) +[*.json] +indent_style = space +indent_size = 2 + +# reStructuredText files — spaces only (indentation requirement) +[*.rst] +indent_style = space +indent_size = 3 + +# Makefiles — always tabs, default width +[Makefile] +indent_style = tab +tab_width = 2 + +# Windows batch scripts — keep CRLF endings +[*.{bat,cmd}] +end_of_line = crlf + +# Shell scripts — ensure LF endings +[*.sh] +end_of_line = lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0c54d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,1064 @@ +# ============================================================ +# Environment and secrets +# ============================================================ +.env +.env.local +.env.*.local +*.local.php +*.secret.php +configuration.php +configuration.*.php +configuration.local.php +conf/conf.php +conf/conf*.php +secrets/ +*.secrets.* +*.key +*.pem +*.p12 +*.pfx +auth.json + +# ============================================================ +# Logs, dumps and databases +# ============================================================ +*.dump +*.log +*.pid +*.seed + +# ============================================================ +# OS / Editor / IDE cruft +# ============================================================ + +# macOS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +Icon? + +# Windows +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db +desktop.ini +$RECYCLE.BIN/ +System Volume Information/ +*.lnk + +# Microsoft Office lock files (temp files created while a doc is open) +~$* + +# JetBrains IDEs +.idea/ + +# Eclipse / generic Java IDEs +.settings/ +.project +.buildpath +.classpath + +# VS Code — personal settings; commit tasks/extensions/example settings only +.vscode/* +!.vscode/tasks.json +!.vscode/settings.json.example +!.vscode/extensions.json +*.code-workspace + +# Sublime Text +*.sublime* + +# Vim / Emacs swap files +*.swp +*.swo +*~ + +# Generic temp / backup +*.bak +*.tmp +*.old +*.orig + +# ============================================================ +# AI coding tools (local/session data — never commit) +# ============================================================ + +# Claude Code — entire local state dir (settings, history, cache) +# Project instructions go in .github/CLAUDE.md, not here +.claude/ + +# Anthropic CLI +.anthropic/ + +# Cursor IDE — personal MCP config and local state +# Shared team rules can live in .cursor/rules/ if desired +.cursor/ +.cursorignore + +# Aider +.aider.conf.yml +.aider.tags.cache.v3/ +.aider.chat.history.md +.aider.input.history + +# Continue.dev +.continue/ + +# Codeium / Windsurf +.codeium/ +.windsurf/ + +# Tabnine +.tabnine/ + +# GitHub Copilot (workspace-local overrides) +.copilot-ignore + +# Gemini Code Assist / Google IDX +.gemini/ +.idx/ + +# ============================================================ +# Runtime / generated data +# ============================================================ +# Checkpoint files written by bulk_sync.php and other long-running scripts +.checkpoints/ + +# ============================================================ +# Dev scripts and scratch +# ============================================================ +TODO.md +todo* +*ffs* + +# ============================================================ +# SFTP / sync tools +# ============================================================ +# Runtime directories — never committed; create locally from templates/scripts/deploy/ +scripts/sftp-config/ +scripts/keys/ +# Catch stray key or config files placed outside the above directories +*.ppk +sftp-* +!docs/visual/sftp-deployment.md + +# ============================================================ +# Replit / cloud IDE +# ============================================================ +.replit +replit.md + +# ============================================================ +# Archives / release artifacts +# ============================================================ +*.7z +*.rar +*.tar +*.tar.gz +*.tgz +*.zip +artifacts/ +release/ +!logs/release/ +!templates/scripts/release/ +releases/ + +# ============================================================ +# Build outputs and site generators +# ============================================================ +.mkdocs-build/ +.cache/ +.parcel-cache/ +build/ +dist/ +out/ +site/ +*.map +*.css.map +*.js.map +*.tsbuildinfo + +# ============================================================ +# CI / test artifacts +# ============================================================ +.coverage +.coverage.* +coverage/ +coverage.xml +htmlcov/ +junit.xml +reports/ +!docs/reports/ +test-results/ +tests/_output/ +.github/local/ +.github/workflows/*.log + +# ============================================================ +# Node / JavaScript +# ============================================================ +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.pnpm-store/ +.yarn/ +.npmrc +.eslintcache +package-lock.json + +# ============================================================ +# PHP / Composer tooling +# ============================================================ +vendor/ +composer.lock +*.phar +codeception.phar +.phpunit.result.cache +.phpunit.cache/ +.php_cs.cache +.php-cs-fixer.cache +.phpstan.cache +.phpstan/ +.phplint-cache +phpmd-cache/ +.psalm/ +.rector/ +.php_rector.cache + +# ============================================================ +# Python +# ============================================================ +__pycache__/ +*.py[cod] +*.pyc +*$py.class +*.so +.Python +.eggs/ +*.egg +*.egg-info/ +.installed.cfg +MANIFEST +develop-eggs/ +downloads/ +eggs/ +parts/ +sdist/ +var/ +wheels/ +ENV/ +env/ +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.pyright/ +.tox/ +.nox/ +*.cover +*.coverage +hypothesis/ + +# ============================================================ +# Dolibarr (base + runtime) +# ============================================================ +documents/ +dolibarr_documents/ +custom/ +uploads/ +thumbs/ +data/ +cache/ +temp/ +tmp/ +logs/*.log +logs/**/*.log +!logs/README.md +!logs/**/.gitkeep +htdocs/documents/ +htdocs/custom/ +htdocs/cache/ +htdocs/tmp/ +htdocs/logs/ +modulebuilder.txt + +# ============================================================ +# Joomla Core +# ============================================================ +/.htaccess +/administrator/cache/* +/administrator/components/com_actionlogs/* +/administrator/components/com_admin/* +/administrator/components/com_ajax/* +/administrator/components/com_associations/* +/administrator/components/com_banners/* +/administrator/components/com_cache/* +/administrator/components/com_categories/* +/administrator/components/com_checkin/* +/administrator/components/com_config/* +/administrator/components/com_contact/* +/administrator/components/com_content/* +/administrator/components/com_contenthistory/* +/administrator/components/com_cpanel/* +/administrator/components/com_fields/* +/administrator/components/com_finder/* +/administrator/components/com_installer/* +/administrator/components/com_joomlaupdate/* +/administrator/components/com_languages/* +/administrator/components/com_login/* +/administrator/components/com_media/* +/administrator/components/com_menus/* +/administrator/components/com_messages/* +/administrator/components/com_modules/* +/administrator/components/com_newsfeeds/* +/administrator/components/com_plugins/* +/administrator/components/com_postinstall/* +/administrator/components/com_privacy/* +/administrator/components/com_redirect/* +/administrator/components/com_search/* +/administrator/components/com_tags/* +/administrator/components/com_templates/* +/administrator/components/com_users/* +/administrator/help/* +/administrator/includes/* +/administrator/index.php +/administrator/language/en-GB/en-GB.com_actionlogs.ini +/administrator/language/en-GB/en-GB.com_actionlogs.sys.ini +/administrator/language/en-GB/en-GB.com_admin.ini +/administrator/language/en-GB/en-GB.com_admin.sys.ini +/administrator/language/en-GB/en-GB.com_ajax.ini +/administrator/language/en-GB/en-GB.com_ajax.sys.ini +/administrator/language/en-GB/en-GB.com_associations.ini +/administrator/language/en-GB/en-GB.com_associations.sys.ini +/administrator/language/en-GB/en-GB.com_banners.ini +/administrator/language/en-GB/en-GB.com_banners.sys.ini +/administrator/language/en-GB/en-GB.com_cache.ini +/administrator/language/en-GB/en-GB.com_cache.sys.ini +/administrator/language/en-GB/en-GB.com_categories.ini +/administrator/language/en-GB/en-GB.com_categories.sys.ini +/administrator/language/en-GB/en-GB.com_checkin.ini +/administrator/language/en-GB/en-GB.com_checkin.sys.ini +/administrator/language/en-GB/en-GB.com_config.ini +/administrator/language/en-GB/en-GB.com_config.sys.ini +/administrator/language/en-GB/en-GB.com_contact.ini +/administrator/language/en-GB/en-GB.com_contact.sys.ini +/administrator/language/en-GB/en-GB.com_content.ini +/administrator/language/en-GB/en-GB.com_content.sys.ini +/administrator/language/en-GB/en-GB.com_contenthistory.ini +/administrator/language/en-GB/en-GB.com_contenthistory.sys.ini +/administrator/language/en-GB/en-GB.com_cpanel.ini +/administrator/language/en-GB/en-GB.com_cpanel.sys.ini +/administrator/language/en-GB/en-GB.com_fields.ini +/administrator/language/en-GB/en-GB.com_fields.sys.ini +/administrator/language/en-GB/en-GB.com_finder.ini +/administrator/language/en-GB/en-GB.com_finder.sys.ini +/administrator/language/en-GB/en-GB.com_installer.ini +/administrator/language/en-GB/en-GB.com_installer.sys.ini +/administrator/language/en-GB/en-GB.com_joomlaupdate.ini +/administrator/language/en-GB/en-GB.com_joomlaupdate.sys.ini +/administrator/language/en-GB/en-GB.com_languages.ini +/administrator/language/en-GB/en-GB.com_languages.sys.ini +/administrator/language/en-GB/en-GB.com_login.ini +/administrator/language/en-GB/en-GB.com_login.sys.ini +/administrator/language/en-GB/en-GB.com_mailto.sys.ini +/administrator/language/en-GB/en-GB.com_media.ini +/administrator/language/en-GB/en-GB.com_media.sys.ini +/administrator/language/en-GB/en-GB.com_menus.ini +/administrator/language/en-GB/en-GB.com_menus.sys.ini +/administrator/language/en-GB/en-GB.com_messages.ini +/administrator/language/en-GB/en-GB.com_messages.sys.ini +/administrator/language/en-GB/en-GB.com_modules.ini +/administrator/language/en-GB/en-GB.com_modules.sys.ini +/administrator/language/en-GB/en-GB.com_newsfeeds.ini +/administrator/language/en-GB/en-GB.com_newsfeeds.sys.ini +/administrator/language/en-GB/en-GB.com_plugins.ini +/administrator/language/en-GB/en-GB.com_plugins.sys.ini +/administrator/language/en-GB/en-GB.com_postinstall.ini +/administrator/language/en-GB/en-GB.com_postinstall.sys.ini +/administrator/language/en-GB/en-GB.com_privacy.ini +/administrator/language/en-GB/en-GB.com_privacy.sys.ini +/administrator/language/en-GB/en-GB.com_redirect.ini +/administrator/language/en-GB/en-GB.com_redirect.sys.ini +/administrator/language/en-GB/en-GB.com_search.ini +/administrator/language/en-GB/en-GB.com_search.sys.ini +/administrator/language/en-GB/en-GB.com_tags.ini +/administrator/language/en-GB/en-GB.com_tags.sys.ini +/administrator/language/en-GB/en-GB.com_templates.ini +/administrator/language/en-GB/en-GB.com_templates.sys.ini +/administrator/language/en-GB/en-GB.com_users.ini +/administrator/language/en-GB/en-GB.com_users.sys.ini +/administrator/language/en-GB/en-GB.com_weblinks.ini +/administrator/language/en-GB/en-GB.com_weblinks.sys.ini +/administrator/language/en-GB/en-GB.com_wrapper.ini +/administrator/language/en-GB/en-GB.com_wrapper.sys.ini +/administrator/language/en-GB/en-GB.ini +/administrator/language/en-GB/en-GB.lib_joomla.ini +/administrator/language/en-GB/en-GB.localise.php +/administrator/language/en-GB/en-GB.mod_custom.ini +/administrator/language/en-GB/en-GB.mod_custom.sys.ini +/administrator/language/en-GB/en-GB.mod_feed.ini +/administrator/language/en-GB/en-GB.mod_feed.sys.ini +/administrator/language/en-GB/en-GB.mod_latest.ini +/administrator/language/en-GB/en-GB.mod_latest.sys.ini +/administrator/language/en-GB/en-GB.mod_latestactions.ini +/administrator/language/en-GB/en-GB.mod_latestactions.sys.ini +/administrator/language/en-GB/en-GB.mod_logged.ini +/administrator/language/en-GB/en-GB.mod_logged.sys.ini +/administrator/language/en-GB/en-GB.mod_login.ini +/administrator/language/en-GB/en-GB.mod_login.sys.ini +/administrator/language/en-GB/en-GB.mod_menu.ini +/administrator/language/en-GB/en-GB.mod_menu.sys.ini +/administrator/language/en-GB/en-GB.mod_multilangstatus.ini +/administrator/language/en-GB/en-GB.mod_multilangstatus.sys.ini +/administrator/language/en-GB/en-GB.mod_online.ini +/administrator/language/en-GB/en-GB.mod_online.sys.ini +/administrator/language/en-GB/en-GB.mod_popular.ini +/administrator/language/en-GB/en-GB.mod_popular.sys.ini +/administrator/language/en-GB/en-GB.mod_privacy_dashboard.ini +/administrator/language/en-GB/en-GB.mod_privacy_dashboard.sys.ini +/administrator/language/en-GB/en-GB.mod_quickicon.ini +/administrator/language/en-GB/en-GB.mod_quickicon.sys.ini +/administrator/language/en-GB/en-GB.mod_sampledata.ini +/administrator/language/en-GB/en-GB.mod_sampledata.sys.ini +/administrator/language/en-GB/en-GB.mod_stats_admin.ini +/administrator/language/en-GB/en-GB.mod_stats_admin.sys.ini +/administrator/language/en-GB/en-GB.mod_status.ini +/administrator/language/en-GB/en-GB.mod_status.sys.ini +/administrator/language/en-GB/en-GB.mod_submenu.ini +/administrator/language/en-GB/en-GB.mod_submenu.sys.ini +/administrator/language/en-GB/en-GB.mod_title.ini +/administrator/language/en-GB/en-GB.mod_title.sys.ini +/administrator/language/en-GB/en-GB.mod_toolbar.ini +/administrator/language/en-GB/en-GB.mod_toolbar.sys.ini +/administrator/language/en-GB/en-GB.mod_unread.ini +/administrator/language/en-GB/en-GB.mod_unread.sys.ini +/administrator/language/en-GB/en-GB.mod_version.ini +/administrator/language/en-GB/en-GB.mod_version.sys.ini +/administrator/language/en-GB/en-GB.plg_actionlog_joomla.ini +/administrator/language/en-GB/en-GB.plg_actionlog_joomla.sys.ini +/administrator/language/en-GB/en-GB.plg_authentication_cookie.ini +/administrator/language/en-GB/en-GB.plg_authentication_cookie.sys.ini +/administrator/language/en-GB/en-GB.plg_authentication_example.ini +/administrator/language/en-GB/en-GB.plg_authentication_example.sys.ini +/administrator/language/en-GB/en-GB.plg_authentication_gmail.ini +/administrator/language/en-GB/en-GB.plg_authentication_gmail.sys.ini +/administrator/language/en-GB/en-GB.plg_authentication_joomla.ini +/administrator/language/en-GB/en-GB.plg_authentication_joomla.sys.ini +/administrator/language/en-GB/en-GB.plg_authentication_ldap.ini +/administrator/language/en-GB/en-GB.plg_authentication_ldap.sys.ini +/administrator/language/en-GB/en-GB.plg_captcha_recaptcha.ini +/administrator/language/en-GB/en-GB.plg_captcha_recaptcha.sys.ini +/administrator/language/en-GB/en-GB.plg_captcha_recaptcha_invisible.ini +/administrator/language/en-GB/en-GB.plg_captcha_recaptcha_invisible.sys.ini +/administrator/language/en-GB/en-GB.plg_content_confirmconsent.ini +/administrator/language/en-GB/en-GB.plg_content_confirmconsent.sys.ini +/administrator/language/en-GB/en-GB.plg_content_contact.ini +/administrator/language/en-GB/en-GB.plg_content_contact.sys.ini +/administrator/language/en-GB/en-GB.plg_content_emailcloak.ini +/administrator/language/en-GB/en-GB.plg_content_emailcloak.sys.ini +/administrator/language/en-GB/en-GB.plg_content_fields.ini +/administrator/language/en-GB/en-GB.plg_content_fields.sys.ini +/administrator/language/en-GB/en-GB.plg_content_finder.ini +/administrator/language/en-GB/en-GB.plg_content_finder.sys.ini +/administrator/language/en-GB/en-GB.plg_content_geshi.ini +/administrator/language/en-GB/en-GB.plg_content_geshi.sys.ini +/administrator/language/en-GB/en-GB.plg_content_joomla.ini +/administrator/language/en-GB/en-GB.plg_content_joomla.sys.ini +/administrator/language/en-GB/en-GB.plg_content_loadmodule.ini +/administrator/language/en-GB/en-GB.plg_content_loadmodule.sys.ini +/administrator/language/en-GB/en-GB.plg_content_pagebreak.ini +/administrator/language/en-GB/en-GB.plg_content_pagebreak.sys.ini +/administrator/language/en-GB/en-GB.plg_content_pagenavigation.ini +/administrator/language/en-GB/en-GB.plg_content_pagenavigation.sys.ini +/administrator/language/en-GB/en-GB.plg_content_vote.ini +/administrator/language/en-GB/en-GB.plg_content_vote.sys.ini +/administrator/language/en-GB/en-GB.plg_editors-xtd_article.ini +/administrator/language/en-GB/en-GB.plg_editors-xtd_article.sys.ini +/administrator/language/en-GB/en-GB.plg_editors-xtd_contact.ini +/administrator/language/en-GB/en-GB.plg_editors-xtd_contact.sys.ini +/administrator/language/en-GB/en-GB.plg_editors-xtd_fields.ini +/administrator/language/en-GB/en-GB.plg_editors-xtd_fields.sys.ini +/administrator/language/en-GB/en-GB.plg_editors-xtd_image.ini +/administrator/language/en-GB/en-GB.plg_editors-xtd_image.sys.ini +/administrator/language/en-GB/en-GB.plg_editors-xtd_menu.ini +/administrator/language/en-GB/en-GB.plg_editors-xtd_menu.sys.ini +/administrator/language/en-GB/en-GB.plg_editors-xtd_module.ini +/administrator/language/en-GB/en-GB.plg_editors-xtd_module.sys.ini +/administrator/language/en-GB/en-GB.plg_editors-xtd_pagebreak.ini +/administrator/language/en-GB/en-GB.plg_editors-xtd_pagebreak.sys.ini +/administrator/language/en-GB/en-GB.plg_editors-xtd_readmore.ini +/administrator/language/en-GB/en-GB.plg_editors-xtd_readmore.sys.ini +/administrator/language/en-GB/en-GB.plg_editors_codemirror.ini +/administrator/language/en-GB/en-GB.plg_editors_codemirror.sys.ini +/administrator/language/en-GB/en-GB.plg_editors_none.ini +/administrator/language/en-GB/en-GB.plg_editors_none.sys.ini +/administrator/language/en-GB/en-GB.plg_editors_tinymce.ini +/administrator/language/en-GB/en-GB.plg_editors_tinymce.sys.ini +/administrator/language/en-GB/en-GB.plg_extension_joomla.ini +/administrator/language/en-GB/en-GB.plg_extension_joomla.sys.ini +/administrator/language/en-GB/en-GB.plg_fields_calendar.ini +/administrator/language/en-GB/en-GB.plg_fields_calendar.sys.ini +/administrator/language/en-GB/en-GB.plg_fields_checkboxes.ini +/administrator/language/en-GB/en-GB.plg_fields_checkboxes.sys.ini +/administrator/language/en-GB/en-GB.plg_fields_color.ini +/administrator/language/en-GB/en-GB.plg_fields_color.sys.ini +/administrator/language/en-GB/en-GB.plg_fields_editor.ini +/administrator/language/en-GB/en-GB.plg_fields_editor.sys.ini +/administrator/language/en-GB/en-GB.plg_fields_image.ini +/administrator/language/en-GB/en-GB.plg_fields_image.sys.ini +/administrator/language/en-GB/en-GB.plg_fields_imagelist.ini +/administrator/language/en-GB/en-GB.plg_fields_imagelist.sys.ini +/administrator/language/en-GB/en-GB.plg_fields_integer.ini +/administrator/language/en-GB/en-GB.plg_fields_integer.sys.ini +/administrator/language/en-GB/en-GB.plg_fields_list.ini +/administrator/language/en-GB/en-GB.plg_fields_list.sys.ini +/administrator/language/en-GB/en-GB.plg_fields_media.ini +/administrator/language/en-GB/en-GB.plg_fields_media.sys.ini +/administrator/language/en-GB/en-GB.plg_fields_radio.ini +/administrator/language/en-GB/en-GB.plg_fields_radio.sys.ini +/administrator/language/en-GB/en-GB.plg_fields_repeatable.ini +/administrator/language/en-GB/en-GB.plg_fields_repeatable.sys.ini +/administrator/language/en-GB/en-GB.plg_fields_sql.ini +/administrator/language/en-GB/en-GB.plg_fields_sql.sys.ini +/administrator/language/en-GB/en-GB.plg_fields_text.ini +/administrator/language/en-GB/en-GB.plg_fields_text.sys.ini +/administrator/language/en-GB/en-GB.plg_fields_textarea.ini +/administrator/language/en-GB/en-GB.plg_fields_textarea.sys.ini +/administrator/language/en-GB/en-GB.plg_fields_url.ini +/administrator/language/en-GB/en-GB.plg_fields_url.sys.ini +/administrator/language/en-GB/en-GB.plg_fields_user.ini +/administrator/language/en-GB/en-GB.plg_fields_user.sys.ini +/administrator/language/en-GB/en-GB.plg_fields_usergrouplist.ini +/administrator/language/en-GB/en-GB.plg_fields_usergrouplist.sys.ini +/administrator/language/en-GB/en-GB.plg_finder_categories.ini +/administrator/language/en-GB/en-GB.plg_finder_categories.sys.ini +/administrator/language/en-GB/en-GB.plg_finder_contacts.ini +/administrator/language/en-GB/en-GB.plg_finder_contacts.sys.ini +/administrator/language/en-GB/en-GB.plg_finder_content.ini +/administrator/language/en-GB/en-GB.plg_finder_content.sys.ini +/administrator/language/en-GB/en-GB.plg_finder_newsfeeds.ini +/administrator/language/en-GB/en-GB.plg_finder_newsfeeds.sys.ini +/administrator/language/en-GB/en-GB.plg_finder_tags.ini +/administrator/language/en-GB/en-GB.plg_finder_tags.sys.ini +/administrator/language/en-GB/en-GB.plg_finder_weblinks.ini +/administrator/language/en-GB/en-GB.plg_finder_weblinks.sys.ini +/administrator/language/en-GB/en-GB.plg_installer_folderinstaller.ini +/administrator/language/en-GB/en-GB.plg_installer_folderinstaller.sys.ini +/administrator/language/en-GB/en-GB.plg_installer_packageinstaller.ini +/administrator/language/en-GB/en-GB.plg_installer_packageinstaller.sys.ini +/administrator/language/en-GB/en-GB.plg_installer_urlinstaller.ini +/administrator/language/en-GB/en-GB.plg_installer_urlinstaller.sys.ini +/administrator/language/en-GB/en-GB.plg_installer_webinstaller.ini +/administrator/language/en-GB/en-GB.plg_installer_webinstaller.sys.ini +/administrator/language/en-GB/en-GB.plg_privacy_actionlogs.ini +/administrator/language/en-GB/en-GB.plg_privacy_actionlogs.sys.ini +/administrator/language/en-GB/en-GB.plg_privacy_consents.ini +/administrator/language/en-GB/en-GB.plg_privacy_consents.sys.ini +/administrator/language/en-GB/en-GB.plg_privacy_contact.ini +/administrator/language/en-GB/en-GB.plg_privacy_contact.sys.ini +/administrator/language/en-GB/en-GB.plg_privacy_content.ini +/administrator/language/en-GB/en-GB.plg_privacy_content.sys.ini +/administrator/language/en-GB/en-GB.plg_privacy_message.ini +/administrator/language/en-GB/en-GB.plg_privacy_message.sys.ini +/administrator/language/en-GB/en-GB.plg_privacy_user.ini +/administrator/language/en-GB/en-GB.plg_privacy_user.sys.ini +/administrator/language/en-GB/en-GB.plg_quickicon_extensionupdate.ini +/administrator/language/en-GB/en-GB.plg_quickicon_extensionupdate.sys.ini +/administrator/language/en-GB/en-GB.plg_quickicon_joomlaupdate.ini +/administrator/language/en-GB/en-GB.plg_quickicon_joomlaupdate.sys.ini +/administrator/language/en-GB/en-GB.plg_quickicon_phpversioncheck.ini +/administrator/language/en-GB/en-GB.plg_quickicon_phpversioncheck.sys.ini +/administrator/language/en-GB/en-GB.plg_quickicon_privacycheck.ini +/administrator/language/en-GB/en-GB.plg_quickicon_privacycheck.sys.ini +/administrator/language/en-GB/en-GB.plg_sampledata_blog.ini +/administrator/language/en-GB/en-GB.plg_sampledata_blog.sys.ini +/administrator/language/en-GB/en-GB.plg_search_categories.ini +/administrator/language/en-GB/en-GB.plg_search_categories.sys.ini +/administrator/language/en-GB/en-GB.plg_search_contacts.ini +/administrator/language/en-GB/en-GB.plg_search_contacts.sys.ini +/administrator/language/en-GB/en-GB.plg_search_content.ini +/administrator/language/en-GB/en-GB.plg_search_content.sys.ini +/administrator/language/en-GB/en-GB.plg_search_newsfeeds.ini +/administrator/language/en-GB/en-GB.plg_search_newsfeeds.sys.ini +/administrator/language/en-GB/en-GB.plg_search_tags.ini +/administrator/language/en-GB/en-GB.plg_search_tags.sys.ini +/administrator/language/en-GB/en-GB.plg_search_weblinks.ini +/administrator/language/en-GB/en-GB.plg_search_weblinks.sys.ini +/administrator/language/en-GB/en-GB.plg_system_actionlogs.ini +/administrator/language/en-GB/en-GB.plg_system_actionlogs.sys.ini +/administrator/language/en-GB/en-GB.plg_system_cache.ini +/administrator/language/en-GB/en-GB.plg_system_cache.sys.ini +/administrator/language/en-GB/en-GB.plg_system_debug.ini +/administrator/language/en-GB/en-GB.plg_system_debug.sys.ini +/administrator/language/en-GB/en-GB.plg_system_fields.ini +/administrator/language/en-GB/en-GB.plg_system_fields.sys.ini +/administrator/language/en-GB/en-GB.plg_system_highlight.ini +/administrator/language/en-GB/en-GB.plg_system_highlight.sys.ini +/administrator/language/en-GB/en-GB.plg_system_languagecode.ini +/administrator/language/en-GB/en-GB.plg_system_languagecode.sys.ini +/administrator/language/en-GB/en-GB.plg_system_languagefilter.ini +/administrator/language/en-GB/en-GB.plg_system_languagefilter.sys.ini +/administrator/language/en-GB/en-GB.plg_system_log.ini +/administrator/language/en-GB/en-GB.plg_system_log.sys.ini +/administrator/language/en-GB/en-GB.plg_system_logout.ini +/administrator/language/en-GB/en-GB.plg_system_logout.sys.ini +/administrator/language/en-GB/en-GB.plg_system_logrotation.ini +/administrator/language/en-GB/en-GB.plg_system_logrotation.sys.ini +/administrator/language/en-GB/en-GB.plg_system_p3p.ini +/administrator/language/en-GB/en-GB.plg_system_p3p.sys.ini +/administrator/language/en-GB/en-GB.plg_system_privacyconsent.ini +/administrator/language/en-GB/en-GB.plg_system_privacyconsent.sys.ini +/administrator/language/en-GB/en-GB.plg_system_redirect.ini +/administrator/language/en-GB/en-GB.plg_system_redirect.sys.ini +/administrator/language/en-GB/en-GB.plg_system_remember.ini +/administrator/language/en-GB/en-GB.plg_system_remember.sys.ini +/administrator/language/en-GB/en-GB.plg_system_sef.ini +/administrator/language/en-GB/en-GB.plg_system_sef.sys.ini +/administrator/language/en-GB/en-GB.plg_system_sessiongc.ini +/administrator/language/en-GB/en-GB.plg_system_sessiongc.sys.ini +/administrator/language/en-GB/en-GB.plg_system_stats.ini +/administrator/language/en-GB/en-GB.plg_system_stats.sys.ini +/administrator/language/en-GB/en-GB.plg_system_updatenotification.ini +/administrator/language/en-GB/en-GB.plg_system_updatenotification.sys.ini +/administrator/language/en-GB/en-GB.plg_twofactorauth_totp.ini +/administrator/language/en-GB/en-GB.plg_twofactorauth_totp.sys.ini +/administrator/language/en-GB/en-GB.plg_twofactorauth_yubikey.ini +/administrator/language/en-GB/en-GB.plg_twofactorauth_yubikey.sys.ini +/administrator/language/en-GB/en-GB.plg_user_contactcreator.ini +/administrator/language/en-GB/en-GB.plg_user_contactcreator.sys.ini +/administrator/language/en-GB/en-GB.plg_user_joomla.ini +/administrator/language/en-GB/en-GB.plg_user_joomla.sys.ini +/administrator/language/en-GB/en-GB.plg_user_profile.ini +/administrator/language/en-GB/en-GB.plg_user_profile.sys.ini +/administrator/language/en-GB/en-GB.plg_user_terms.ini +/administrator/language/en-GB/en-GB.plg_user_terms.sys.ini +/administrator/language/en-GB/en-GB.tpl_hathor.ini +/administrator/language/en-GB/en-GB.tpl_hathor.sys.ini +/administrator/language/en-GB/en-GB.tpl_isis.ini +/administrator/language/en-GB/en-GB.tpl_isis.sys.ini +/administrator/language/en-GB/en-GB.xml +/administrator/language/en-GB/install.xml +/administrator/language/overrides/* +/administrator/language/index.html +/administrator/logs/* +/administrator/manifests/files/joomla.xml +/administrator/manifests/libraries/fof.xml +/administrator/manifests/libraries/idna_convert.xml +/administrator/manifests/libraries/joomla.xml +/administrator/manifests/libraries/phpass.xml +/administrator/manifests/libraries/phputf8.xml +/administrator/manifests/packages/pkg_en-GB.xml +/administrator/manifests/packages/index.html +/administrator/modules/mod_custom/* +/administrator/modules/mod_feed/* +/administrator/modules/mod_latest/* +/administrator/modules/mod_latestactions/* +/administrator/modules/mod_logged/* +/administrator/modules/mod_login/* +/administrator/modules/mod_menu/* +/administrator/modules/mod_multilangstatus/* +/administrator/modules/mod_online/* +/administrator/modules/mod_popular/* +/administrator/modules/mod_privacy_dashboard/* +/administrator/modules/mod_quickicon/* +/administrator/modules/mod_sampledata/* +/administrator/modules/mod_stats_admin/* +/administrator/modules/mod_status/* +/administrator/modules/mod_submenu/* +/administrator/modules/mod_title/* +/administrator/modules/mod_toolbar/* +/administrator/modules/mod_unread/* +/administrator/modules/mod_version/* +/administrator/templates/hathor/* +/administrator/templates/isis/* +/administrator/templates/system/* +/bin/* +!/bin/moko +/cache/* +/cli/* +/components/com_ajax/* +/components/com_banners/* +/components/com_config/* +/components/com_contact/* +/components/com_content/* +/components/com_contenthistory/* +/components/com_fields/* +/components/com_finder/* +/components/com_mailto/* +/components/com_media/* +/components/com_menus/* +/components/com_modules/* +/components/com_newsfeeds/* +/components/com_privacy/* +/components/com_search/* +/components/com_tags/* +/components/com_users/* +/components/com_wrapper/* +/components/index.html +/images/banners/* +/images/headers/* +/images/sampledata/* +/images/index.html +/images/joomla* +/images/powered_by.png +/includes/* +/installation/* +/language/en-GB/en-GB.com_ajax.ini +/language/en-GB/en-GB.com_config.ini +/language/en-GB/en-GB.com_contact.ini +/language/en-GB/en-GB.com_content.ini +/language/en-GB/en-GB.com_finder.ini +/language/en-GB/en-GB.com_mailto.ini +/language/en-GB/en-GB.com_media.ini +/language/en-GB/en-GB.com_messages.ini +/language/en-GB/en-GB.com_newsfeeds.ini +/language/en-GB/en-GB.com_privacy.ini +/language/en-GB/en-GB.com_search.ini +/language/en-GB/en-GB.com_tags.ini +/language/en-GB/en-GB.com_users.ini +/language/en-GB/en-GB.com_weblinks.ini +/language/en-GB/en-GB.com_wrapper.ini +/language/en-GB/en-GB.files_joomla.sys.ini +/language/en-GB/en-GB.finder_cli.ini +/language/en-GB/en-GB.ini +/language/en-GB/en-GB.lib_fof.ini +/language/en-GB/en-GB.lib_fof.sys.ini +/language/en-GB/en-GB.lib_idna_convert.sys.ini +/language/en-GB/en-GB.lib_joomla.ini +/language/en-GB/en-GB.lib_joomla.sys.ini +/language/en-GB/en-GB.lib_phpass.sys.ini +/language/en-GB/en-GB.lib_phpmailer.sys.ini +/language/en-GB/en-GB.lib_phputf8.sys.ini +/language/en-GB/en-GB.lib_simplepie.sys.ini +/language/en-GB/en-GB.localise.php +/language/en-GB/en-GB.mod_articles_archive.ini +/language/en-GB/en-GB.mod_articles_archive.sys.ini +/language/en-GB/en-GB.mod_articles_categories.ini +/language/en-GB/en-GB.mod_articles_categories.sys.ini +/language/en-GB/en-GB.mod_articles_category.ini +/language/en-GB/en-GB.mod_articles_category.sys.ini +/language/en-GB/en-GB.mod_articles_latest.ini +/language/en-GB/en-GB.mod_articles_latest.sys.ini +/language/en-GB/en-GB.mod_articles_news.ini +/language/en-GB/en-GB.mod_articles_news.sys.ini +/language/en-GB/en-GB.mod_articles_popular.ini +/language/en-GB/en-GB.mod_articles_popular.sys.ini +/language/en-GB/en-GB.mod_banners.ini +/language/en-GB/en-GB.mod_banners.sys.ini +/language/en-GB/en-GB.mod_breadcrumbs.ini +/language/en-GB/en-GB.mod_breadcrumbs.sys.ini +/language/en-GB/en-GB.mod_custom.ini +/language/en-GB/en-GB.mod_custom.sys.ini +/language/en-GB/en-GB.mod_feed.ini +/language/en-GB/en-GB.mod_feed.sys.ini +/language/en-GB/en-GB.mod_finder.ini +/language/en-GB/en-GB.mod_finder.sys.ini +/language/en-GB/en-GB.mod_footer.ini +/language/en-GB/en-GB.mod_footer.sys.ini +/language/en-GB/en-GB.mod_languages.ini +/language/en-GB/en-GB.mod_languages.sys.ini +/language/en-GB/en-GB.mod_login.ini +/language/en-GB/en-GB.mod_login.sys.ini +/language/en-GB/en-GB.mod_menu.ini +/language/en-GB/en-GB.mod_menu.sys.ini +/language/en-GB/en-GB.mod_random_image.ini +/language/en-GB/en-GB.mod_random_image.sys.ini +/language/en-GB/en-GB.mod_related_items.ini +/language/en-GB/en-GB.mod_related_items.sys.ini +/language/en-GB/en-GB.mod_search.ini +/language/en-GB/en-GB.mod_search.sys.ini +/language/en-GB/en-GB.mod_stats.ini +/language/en-GB/en-GB.mod_stats.sys.ini +/language/en-GB/en-GB.mod_syndicate.ini +/language/en-GB/en-GB.mod_syndicate.sys.ini +/language/en-GB/en-GB.mod_tags_popular.ini +/language/en-GB/en-GB.mod_tags_popular.sys.ini +/language/en-GB/en-GB.mod_tags_similar.ini +/language/en-GB/en-GB.mod_tags_similar.sys.ini +/language/en-GB/en-GB.mod_users_latest.ini +/language/en-GB/en-GB.mod_users_latest.sys.ini +/language/en-GB/en-GB.mod_weblinks.ini +/language/en-GB/en-GB.mod_weblinks.sys.ini +/language/en-GB/en-GB.mod_whosonline.ini +/language/en-GB/en-GB.mod_whosonline.sys.ini +/language/en-GB/en-GB.mod_wrapper.ini +/language/en-GB/en-GB.mod_wrapper.sys.ini +/language/en-GB/en-GB.tpl_atomic.ini +/language/en-GB/en-GB.tpl_atomic.sys.ini +/language/en-GB/en-GB.tpl_beez3.ini +/language/en-GB/en-GB.tpl_beez3.sys.ini +/language/en-GB/en-GB.tpl_beez5.ini +/language/en-GB/en-GB.tpl_beez5.sys.ini +/language/en-GB/en-GB.tpl_beez_20.ini +/language/en-GB/en-GB.tpl_beez_20.sys.ini +/language/en-GB/en-GB.tpl_protostar.ini +/language/en-GB/en-GB.tpl_protostar.sys.ini +/language/en-GB/en-GB.xml +/language/en-GB/install.xml +/language/overrides/* +/language/index.html +/layouts/joomla/* +/layouts/libraries/* +/layouts/plugins/* +/layouts/index.html +/libraries/cms/* +/libraries/fof/* +/libraries/idna_convert/* +/libraries/joomla/* +/libraries/legacy/* +/libraries/php-encryption/* +/libraries/phpass/* +/libraries/phpmailer/* +/libraries/phputf8/* +/libraries/simplepie/* +/libraries/src/* +/libraries/vendor/* +/libraries/classmap.php +/libraries/cms.php +/libraries/import.legacy.php +/libraries/import.php +/libraries/index.html +/libraries/loader.php +/media/cms/* +/media/com_associations/* +/media/com_contact/* +/media/com_content/* +/media/com_contenthistory/* +/media/com_fields/* +/media/com_finder/* +/media/com_joomlaupdate/* +/media/com_menus/* +/media/com_modules/* +/media/com_wrapper/* +/media/contacts/* +/media/editors/* +/media/jui/* +/media/mailto/* +/media/media/* +/media/mod_languages/* +/media/mod_sampledata/* +/media/overrider/* +/media/plg_captcha_recaptcha/* +/media/plg_captcha_recaptcha_invisible/* +/media/plg_quickicon_extensionupdate/* +/media/plg_quickicon_joomlaupdate/* +/media/plg_quickicon_privacycheck/* +/media/plg_system_highlight/* +/media/plg_system_stats/* +/media/plg_twofactorauth_totp/* +/media/system/* +/media/index.html +/modules/mod_articles_archive/* +/modules/mod_articles_categories/* +/modules/mod_articles_category/* +/modules/mod_articles_latest/* +/modules/mod_articles_news/* +/modules/mod_articles_popular/* +/modules/mod_banners/* +/modules/mod_breadcrumbs/* +/modules/mod_custom/* +/modules/mod_feed/* +/modules/mod_finder/* +/modules/mod_footer/* +/modules/mod_languages/* +/modules/mod_login/* +/modules/mod_menu/* +/modules/mod_random_image/* +/modules/mod_related_items/* +/modules/mod_search/* +/modules/mod_stats/* +/modules/mod_syndicate/* +/modules/mod_tags_popular/* +/modules/mod_tags_similar/* +/modules/mod_users_latest/* +/modules/mod_whosonline/* +/modules/mod_wrapper/* +/modules/index.html +/plugins/actionlog/joomla/* +/plugins/authentication/cookie/* +/plugins/authentication/example/* +/plugins/authentication/gmail/* +/plugins/authentication/joomla/* +/plugins/authentication/ldap/* +/plugins/captcha/recaptcha/* +/plugins/captcha/recaptcha_invisible/* +/plugins/content/confirmconsent/* +/plugins/content/contact/* +/plugins/content/emailcloak/* +/plugins/content/example/* +/plugins/content/fields/* +/plugins/content/finder/* +/plugins/content/geshi/* +/plugins/content/joomla/* +/plugins/content/loadmodule/* +/plugins/content/pagebreak/* +/plugins/content/pagenavigation/* +/plugins/content/vote/* +/plugins/editors/codemirror/* +/plugins/editors/none/* +/plugins/editors/tinymce/* +/plugins/editors-xtd/article/* +/plugins/editors-xtd/contact/* +/plugins/editors-xtd/fields/* +/plugins/editors-xtd/image/* +/plugins/editors-xtd/menu/* +/plugins/editors-xtd/module/* +/plugins/editors-xtd/pagebreak/* +/plugins/editors-xtd/readmore/* +/plugins/extension/example/* +/plugins/extension/joomla/* +/plugins/fields/calendar/* +/plugins/fields/checkboxes/* +/plugins/fields/color/* +/plugins/fields/editor/* +/plugins/fields/imagelist/* +/plugins/fields/integer/* +/plugins/fields/list/* +/plugins/fields/media/* +/plugins/fields/radio/* +/plugins/fields/repeatable/* +/plugins/fields/sql/* +/plugins/fields/text/* +/plugins/fields/textarea/* +/plugins/fields/url/* +/plugins/fields/user/* +/plugins/fields/usergrouplist/* +/plugins/finder/categories/* +/plugins/finder/contacts/* +/plugins/finder/content/* +/plugins/finder/newsfeeds/* +/plugins/finder/tags/* +/plugins/installer/folderinstaller/* +/plugins/installer/packageinstaller/* +/plugins/installer/urlinstaller/* +/plugins/privacy/actionlogs/* +/plugins/privacy/consents/* +/plugins/privacy/contact/* +/plugins/privacy/content/* +/plugins/privacy/message/* +/plugins/privacy/user/* +/plugins/quickicon/extensionupdate/* +/plugins/quickicon/joomlaupdate/* +/plugins/quickicon/phpversioncheck/* +/plugins/quickicon/privacycheck/* +/plugins/quickicon/index.html +/plugins/sampledata/blog/* +/plugins/search/categories/* +/plugins/search/contacts/* +/plugins/search/content/* +/plugins/search/newsfeeds/* +/plugins/search/tags/* +/plugins/search/weblinks/* +/plugins/search/index.html +/plugins/system/actionlogs/* +/plugins/system/cache/* +/plugins/system/debug/* +/plugins/system/fields/* +/plugins/system/highlight/* +/plugins/system/languagecode/* +/plugins/system/languagefilter/* +/plugins/system/log/* +/plugins/system/logout/* +/plugins/system/logrotation/* +/plugins/system/p3p/* +/plugins/system/privacyconsent/* +/plugins/system/redirect/* +/plugins/system/remember/* +/plugins/system/sef/* +/plugins/system/sessiongc/* +/plugins/system/stats/* +/plugins/system/updatenotification/* +/plugins/system/index.html +/plugins/twofactorauth/totp/* +/plugins/twofactorauth/yubikey/* +/plugins/user/contactcreator/* +/plugins/user/example/* +/plugins/user/joomla/* +/plugins/user/profile/* +/plugins/user/terms/* +/plugins/user/index.html +/plugins/user/index.html +/plugins/index.html +/templates/beez3/* +/templates/protostar/* +/templates/system/* +/templates/index.html +/tmp/* +/configuration.php +/htaccess.txt +/index.php +/joomla.xml +/LICENSE.txt +/README.txt +/robots.txt.dist +/web.config.txt + +# ============================================================ +# Keep-empty folders helper +# ============================================================ +!.gitkeep +validation-reports/ +actionlint + +# ============================================================ +# Docker +# ============================================================ +# Local environment overrides — contain personal ports/volumes/credentials +docker-compose.override.yml +docker-compose.local.yml +.docker/ + +# ============================================================ +# Databases / SQLite +# ============================================================ +*.sqlite +*.sqlite3 +*.db +*.db3 +*.mdb +*.accdb + +# ============================================================ +# Terraform +# ============================================================ +# Local .terraform directories +**/.terraform/* + +# .tfstate files +*.tfstate +*.tfstate.* +*.tfstate.backup + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data +*.tfvars +*.tfvars.json + +# Ignore override files as they are usually used to override resources locally +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Ignore CLI configuration files +.terraformrc +terraform.rc + +# Ignore plan files +*.tfplan +*.tfplan.* + +# Ignore lock file changes for now (uncomment to track lock file) +# Note: .terraform.lock.hcl should generally be committed for version control +# but can be ignored if you want flexibility across different platforms +# !.terraform.lock.hcl +logs/validation/*.md diff --git a/.script-registry.json b/.script-registry.json new file mode 100644 index 0000000..618c467 --- /dev/null +++ b/.script-registry.json @@ -0,0 +1,575 @@ +{ + "metadata": { + "generated_at": "2026-03-10T19:51:42.238134Z", + "repository": "mokoconsulting-tech/MokoStandards", + "version": "1.0.0" + }, + "scripts": [ + { + "path": "scripts/analysis/analyze_dependencies.py", + "sha256": "7d282416b124d8cf908118e309667ace5e65d63202800df69f4f317208f42a52", + "category": "analysis", + "priority": "medium", + "size_bytes": 10186 + }, + { + "path": "scripts/analysis/analyze_pr_conflicts.py", + "sha256": "84a26f39470acd502e4288d24dfa17215ffbb90dfeb12e58a1cbb45fcdc72c48", + "category": "analysis", + "priority": "medium", + "size_bytes": 8678 + }, + { + "path": "scripts/analysis/code_metrics.py", + "sha256": "6f5008294b89ca2c393dd3dab7c6eb23808a3e078aede3ed48f8a0e4ffb55975", + "category": "analysis", + "priority": "medium", + "size_bytes": 8176 + }, + { + "path": "scripts/analysis/generate_canonical_config.py", + "sha256": "d7867a61200d904f08c7efa7e388734d7aa0c6d84aefb263facbc823ab14ab1c", + "category": "analysis", + "priority": "medium", + "size_bytes": 29163 + }, + { + "path": "scripts/automation/Invoke-BulkUpdateGUI.ps1", + "sha256": "95b506917ed12032c05bc76ae461a71882a1c2e52b64a0f5d48957ca9f33b841", + "category": "automation", + "priority": "high", + "size_bytes": 10464 + }, + { + "path": "scripts/automation/Update-BulkRepositories.ps1", + "sha256": "b7a67bead5cea84cc8826f6c02d7167d50797f1f08d5bf8dd8742cdbc697f874", + "category": "automation", + "priority": "high", + "size_bytes": 29695 + }, + { + "path": "scripts/automation/auto_create_org_projects.py", + "sha256": "3055340618b7d1bd9d428a525c9273b0fec7e41aa42a82e5957ebf7aef2c20db", + "category": "automation", + "priority": "high", + "size_bytes": 23108 + }, + { + "path": "scripts/automation/bulk_deploy_labels.sh", + "sha256": "b39c435a99d8b40c2f85b27d68c1547cd9833436cef5b9ff4e3e2a19a6b154b9", + "category": "automation", + "priority": "high", + "size_bytes": 7181 + }, + { + "path": "scripts/automation/bulk_update_repos.php", + "sha256": "6e47cce8a5528db0081dc246367ef1b5da876f591d43b34664fbb608ef26cecd", + "category": "automation", + "priority": "high", + "size_bytes": 61417 + }, + { + "path": "scripts/automation/check_outdated_actions.py", + "sha256": "f2fefc08a679c477cf4b3e031a93cc45261fdff4fd0165196058400b35f21451", + "category": "automation", + "priority": "high", + "size_bytes": 8292 + }, + { + "path": "scripts/automation/create_repo_project.py", + "sha256": "718240fdb232a606142eca6a7fca774acae19400a5fce6f1fa42eb7567ae6d0a", + "category": "automation", + "priority": "high", + "size_bytes": 8295 + }, + { + "path": "scripts/automation/dev-workstation-provisioner.ps1", + "sha256": "567062f6f513ea49616fb098732f9aa934505983d789fd8b29f9c1c1d5356d08", + "category": "automation", + "priority": "high", + "size_bytes": 5747 + }, + { + "path": "scripts/automation/file-distributor.ps1", + "sha256": "afc991e0e1a48c032da31d863fd95e6ebcfffaac12ae8b5ce03455818602a8da", + "category": "automation", + "priority": "high", + "size_bytes": 43979 + }, + { + "path": "scripts/automation/file-distributor.py", + "sha256": "a844784832928b427c5e9cd2b30d2e7c5dfb08894f6b8fb46cc2244e5a180be1", + "category": "automation", + "priority": "high", + "size_bytes": 22203 + }, + { + "path": "scripts/automation/generate_wrappers.py", + "sha256": "0077ffb71062345da9e81757715699bfd4eb601cc750408957635e1c7747ef76", + "category": "automation", + "priority": "high", + "size_bytes": 6769 + }, + { + "path": "scripts/automation/setup_dev_environment.py", + "sha256": "f9ec448c809b83c1af1606044ea27786dd0a8abfdfb58f59b7a3d30bc570b54f", + "category": "automation", + "priority": "high", + "size_bytes": 9025 + }, + { + "path": "scripts/automation/sync_dolibarr_changelog.py", + "sha256": "98e935800ac8d4289b3fdd2d31fc12ea993f25cf78bc5ac84817233985e98a4d", + "category": "automation", + "priority": "high", + "size_bytes": 14336 + }, + { + "path": "scripts/automation/sync_file_to_project.py", + "sha256": "2c7b385500d0327fe41421e30d8a04b905b4141e58eb629908924242e4e911e2", + "category": "automation", + "priority": "high", + "size_bytes": 16352 + }, + { + "path": "scripts/automation/ubuntu-dev-workstation-provisioner.sh", + "sha256": "7be2264da6ae7ef94bf0536a29e768308ab4af91e6ed37ab2c4e329d9349cec5", + "category": "automation", + "priority": "high", + "size_bytes": 16825 + }, + { + "path": "scripts/build/resolve_makefile.py", + "sha256": "980dee07bbc9a0d53496aea33bd1711495f3f599ed742e3ec1565a5f132cf99a", + "category": "build", + "priority": "high", + "size_bytes": 5498 + }, + { + "path": "scripts/docs/check_doc_coverage.py", + "sha256": "a60e66f4b5878e6c70ebda8044b9573c0d457ae228da09b21b51fccdf1c0b3e4", + "category": "docs", + "priority": "medium", + "size_bytes": 8923 + }, + { + "path": "scripts/docs/generate_script_catalog.py", + "sha256": "c99c33f2fdc1c7d3c51d22bec0648555505f5caed7cec5fa4586622bde6e00f0", + "category": "docs", + "priority": "medium", + "size_bytes": 7974 + }, + { + "path": "scripts/docs/rebuild_indexes.py", + "sha256": "4e5331fa56105eca71c61f9d3aa74dd96de2b93f73d30aa8e502aec3b2bfa399", + "category": "docs", + "priority": "medium", + "size_bytes": 15022 + }, + { + "path": "scripts/docs/update_metadata.py", + "sha256": "9a868f856ee8beb5b28713c619476cab3ead6b460cb6013f736e6c1d668ced18", + "category": "docs", + "priority": "medium", + "size_bytes": 11038 + }, + { + "path": "scripts/fix/file_headers.py", + "sha256": "d7345296a0f51f9e2c5b8affd71955bb09f19a27ec4b65c975de52f7745fcfac", + "category": "fix", + "priority": "high", + "size_bytes": 10981 + }, + { + "path": "scripts/fix/tabs.py", + "sha256": "d5db5d2ca3ef74a7399e77ee6181eae0ad6d5ed52d2ee08b03a413ce75b38fd3", + "category": "fix", + "priority": "high", + "size_bytes": 10332 + }, + { + "path": "scripts/fix/trailing_spaces.py", + "sha256": "9812f026c6136e33c19216466bdc1f26a12c01b0564abc3413eed5909068f345", + "category": "fix", + "priority": "high", + "size_bytes": 8156 + }, + { + "path": "scripts/maintenance/add_dry_run_support.py", + "sha256": "d892d4bfc7dba2122c4ca2908f74fbc8f2f65f4131ad93a63af945f051d170ee", + "category": "maintenance", + "priority": "critical", + "size_bytes": 7731 + }, + { + "path": "scripts/maintenance/add_terraform_metadata.py", + "sha256": "e8a46c37c737ed92b05e8ce60fd9ecb2de4154ce0f75eab6d52ce5bc6863b222", + "category": "maintenance", + "priority": "critical", + "size_bytes": 6316 + }, + { + "path": "scripts/maintenance/clean_old_branches.py", + "sha256": "5d98e59d502162c451ed5b0a656b15155d7226889d5416a5fb82faa9b29b348f", + "category": "maintenance", + "priority": "critical", + "size_bytes": 9079 + }, + { + "path": "scripts/maintenance/flush_actions_cache.py", + "sha256": "d1acdbba58e5ec01965317e67010f16a36b52ca00f49ec6cf3a83359fbc15404", + "category": "maintenance", + "priority": "critical", + "size_bytes": 8640 + }, + { + "path": "scripts/maintenance/generate_script_registry.py", + "sha256": "1c385c746e9803098a5eb7b702ca16b2a281c5f362c1de726c8fedd782b52074", + "category": "maintenance", + "priority": "critical", + "size_bytes": 10109 + }, + { + "path": "scripts/maintenance/release_version.py", + "sha256": "e2eaca49f632752aa53bce5d8c040171bb7130e9b33da18465f71c58aaf681f2", + "category": "maintenance", + "priority": "critical", + "size_bytes": 15455 + }, + { + "path": "scripts/maintenance/setup-labels.sh", + "sha256": "3a1996a3d2052b184e20cf9a729ff232249ad9f0796fd08b11febf983b9bdf29", + "category": "maintenance", + "priority": "critical", + "size_bytes": 7057 + }, + { + "path": "scripts/maintenance/update_changelog.py", + "sha256": "133685b81fe4a096a7987aeade3854bc5dc42a8005dacba3f62bc94116b14948", + "category": "maintenance", + "priority": "critical", + "size_bytes": 10624 + }, + { + "path": "scripts/maintenance/update_copyright_year.py", + "sha256": "f09e7790f9bd7d6ed94903891909feffb223a012abb3520768666e076c52570b", + "category": "maintenance", + "priority": "critical", + "size_bytes": 7062 + }, + { + "path": "scripts/maintenance/update_gitignore_patterns.sh", + "sha256": "ce33dbd74998bf9708eb41fee4999a8d233b8f4211e53b415b04d1d4c3077900", + "category": "maintenance", + "priority": "critical", + "size_bytes": 7192 + }, + { + "path": "scripts/maintenance/update_sha_hashes.py", + "sha256": "e917b438405d86c1cbeff7a3f265e0181e266935fd9aaa2e66c21b86a5e7b266", + "category": "maintenance", + "priority": "critical", + "size_bytes": 5462 + }, + { + "path": "scripts/maintenance/validate_file_headers.py", + "sha256": "f9aafe4982704f21f74583d3362ede8eaa9cda3cbf7eaf7064ac22072a046dc7", + "category": "maintenance", + "priority": "critical", + "size_bytes": 9065 + }, + { + "path": "scripts/maintenance/validate_script_registry.py", + "sha256": "8653a2fd2493a2b4659236d13b502a2630305c1832190ea97afcc879fd9b3455", + "category": "maintenance", + "priority": "critical", + "size_bytes": 7964 + }, + { + "path": "scripts/maintenance/validate_terraform_drift.py", + "sha256": "0397fd9d89b5d0bece9702c276644431f95a2ecbe4db91eb6bfd214d49eebd67", + "category": "maintenance", + "priority": "critical", + "size_bytes": 11484 + }, + { + "path": "scripts/release/detect_platform.py", + "sha256": "a27ed3d086aa9984b9d0a593705499e76fdb8b784c3ba4663989d312d5ab8745", + "category": "release", + "priority": "high", + "size_bytes": 2784 + }, + { + "path": "scripts/release/dolibarr_release.py", + "sha256": "4f966b4ad10e14dd65f85ab39ab4784bf3588620de2002c4c8c4d83c1d76d036", + "category": "release", + "priority": "high", + "size_bytes": 11637 + }, + { + "path": "scripts/release/package_extension.py", + "sha256": "d03fe260f21378ee6b2a92ff93bda4b34970908cf2d784aae50bf51ea706f847", + "category": "release", + "priority": "high", + "size_bytes": 9215 + }, + { + "path": "scripts/release/unified_release.py", + "sha256": "fa67e1e15b1570fd2c6d7a78b13050cbcc7aeb010bd612f738560fa6b7b9d9f9", + "category": "release", + "priority": "high", + "size_bytes": 17687 + }, + { + "path": "scripts/release/update_dates.sh", + "sha256": "77a7cd946cecfcbd9a6fa2fd4f736b297c6953bd035aa652a8ea105f9bba0d00", + "category": "release", + "priority": "high", + "size_bytes": 3724 + }, + { + "path": "scripts/run/Invoke-DemoDataLoaderGUI.ps1", + "sha256": "6f5ade6e9e52771a6e43f37050a3c854510f8ed33295016083080e96dcb8a499", + "category": "run", + "priority": "medium", + "size_bytes": 12596 + }, + { + "path": "scripts/run/Load-DemoData.ps1", + "sha256": "faccb2413d3c1de5124f26ff2977769398a07c430386f4a3b38dba158fea11e7", + "category": "run", + "priority": "medium", + "size_bytes": 9835 + }, + { + "path": "scripts/run/git_helper.sh", + "sha256": "56c520472707a9406285e911876e90cf5c385624e135f730671ccb07400ddaeb", + "category": "run", + "priority": "medium", + "size_bytes": 7960 + }, + { + "path": "scripts/run/load_demo_data.py", + "sha256": "9e5ee55f8eec3b3015cafe502b1f2b5bc7f832870d2eda93bf8450cbfff5bbac", + "category": "run", + "priority": "medium", + "size_bytes": 11125 + }, + { + "path": "scripts/run/setup_github_project_v2.py", + "sha256": "a0f9cb4dad755baec4311fa6164053047f894337bd831bfc06fba92eff7d96bc", + "category": "run", + "priority": "medium", + "size_bytes": 29687 + }, + { + "path": "scripts/tests/test_bulk_update_repos.php", + "sha256": "0794f2d9bc2020c741b427f9a8fcbd767120d00c97a040ebbff13b2427cb6833", + "category": "tests", + "priority": "medium", + "size_bytes": 4506 + }, + { + "path": "scripts/tests/test_dry_run.py", + "sha256": "2e99f3ea896d98692bcdd4d8d6c6fa7e117fa96e2518cf3a1f173f11e721e76f", + "category": "tests", + "priority": "medium", + "size_bytes": 6327 + }, + { + "path": "scripts/validate/Invoke-PlatformDetection.ps1", + "sha256": "cd0f25245465849b21aec5a74366f756ef1de79185d822a0190441a5577100d3", + "category": "validate", + "priority": "critical", + "size_bytes": 19116 + }, + { + "path": "scripts/validate/Invoke-RepoHealthCheckGUI.ps1", + "sha256": "c952922e37e1e6f1448f7620b88b1ff896597be46a0205acf1de0b96299091d6", + "category": "validate", + "priority": "critical", + "size_bytes": 9455 + }, + { + "path": "scripts/validate/auto_detect_platform.php", + "sha256": "82c6b47117cadecfcdee8c63f9c9566a7c27db34c19456ce49297e666840aa3b", + "category": "validate", + "priority": "critical", + "size_bytes": 13466 + }, + { + "path": "scripts/validate/check_all_files.py", + "sha256": "e708aae24271eb2d0457acb922809d5a51e4a7fb5f1fd4cd45783dea42a63cc6", + "category": "validate", + "priority": "critical", + "size_bytes": 32318 + }, + { + "path": "scripts/validate/check_license_headers.py", + "sha256": "cc5f4ba9539da8fbe0a397893571bfc9a18e46da1e2fb275389cc53737fdee89", + "category": "validate", + "priority": "critical", + "size_bytes": 9091 + }, + { + "path": "scripts/validate/check_markdown_links.py", + "sha256": "821277627cbaa681cff5aa0f668a738a3337f6e7227bf8bfd951442418407b24", + "category": "validate", + "priority": "critical", + "size_bytes": 6950 + }, + { + "path": "scripts/validate/check_repo_health.py", + "sha256": "d7b8aa256c2eb6f400b653d6b7a9d9bc51a31292bee822827d6aedb2ca690b88", + "category": "validate", + "priority": "critical", + "size_bytes": 23616 + }, + { + "path": "scripts/validate/check_script_security.py", + "sha256": "9e98e9fd750a4c0e4095b8c1951cfba3e2ceed004d056a53b58f21fd176f881b", + "category": "validate", + "priority": "critical", + "size_bytes": 12192 + }, + { + "path": "scripts/validate/check_version_consistency.php", + "sha256": "c33fc6823b92b26dd932f67d2c20817a9815ed3c993c0617b6a322548f6e8cd3", + "category": "validate", + "priority": "high", + "size_bytes": 10522 + }, + { + "path": "scripts/validate/find_todos.py", + "sha256": "9e064323374bce029466234433ec6eedaa46afe4027b8cdd90048ba972eb7274", + "category": "validate", + "priority": "critical", + "size_bytes": 8070 + }, + { + "path": "scripts/validate/generate_stubs.py", + "sha256": "e1c1bf1dd10449cfbef4678e1e09d12b7cc7e5e38f9c6fa3819d9c63600c597e", + "category": "validate", + "priority": "critical", + "size_bytes": 14350 + }, + { + "path": "scripts/validate/manifest.py", + "sha256": "a65779bcb4d184ef8583713f1b1f18399864bd2a8bf17a692ba2241566fc2b1d", + "category": "validate", + "priority": "critical", + "size_bytes": 5083 + }, + { + "path": "scripts/validate/no_secrets.py", + "sha256": "c514a64477c535dfd7c16b7a722253e601c51b3ec723efcb3ce933a169b427af", + "category": "validate", + "priority": "critical", + "size_bytes": 6784 + }, + { + "path": "scripts/validate/paths.py", + "sha256": "ae5500bb1d595af4a15e8c5e15e4d6ea500939f166dc6b14c4bcfc4d597dfe77", + "category": "validate", + "priority": "critical", + "size_bytes": 4792 + }, + { + "path": "scripts/validate/php_syntax.py", + "sha256": "9a99ce519688694fd0840d2515cc43b563be97382236750a72bef1c5364d16b7", + "category": "validate", + "priority": "critical", + "size_bytes": 6116 + }, + { + "path": "scripts/validate/schema_aware_health_check.py", + "sha256": "2e53e0ad48cb1303af08ca87256759369a625d52019b3d641bc24703f0a8559c", + "category": "validate", + "priority": "critical", + "size_bytes": 22482 + }, + { + "path": "scripts/validate/security_scan.py", + "sha256": "25ff80b8382546460b1c08fa05d4bf482de3f1a5c4afef4d568c25cd2a900077", + "category": "validate", + "priority": "critical", + "size_bytes": 17377 + }, + { + "path": "scripts/validate/tabs.py", + "sha256": "16ee38094e517642d76d2bd2de71b816bd24716e170cc70c6f9710e2231b6a52", + "category": "validate", + "priority": "critical", + "size_bytes": 10328 + }, + { + "path": "scripts/validate/validate_codeql_config.py", + "sha256": "b79bf71adc8968805fac1a36a764ffde543099d110a93593767bdc9fa890673d", + "category": "validate", + "priority": "critical", + "size_bytes": 8210 + }, + { + "path": "scripts/validate/validate_repo_health.py", + "sha256": "7908092192e4d09e18b424bae978c1c5fc84340413b1be9538bf1b11763ab3a0", + "category": "validate", + "priority": "critical", + "size_bytes": 12597 + }, + { + "path": "scripts/validate/validate_structure.py", + "sha256": "1de72b1b605fa60c138426fbdbae8fc15ec0320e999b02b54f30c5b185aa0e2b", + "category": "validate", + "priority": "critical", + "size_bytes": 15000 + }, + { + "path": "scripts/validate/validate_structure_terraform.py", + "sha256": "73d44fc2567c091c65b8fd8b2bfcf0c6f1925f2772420d6b1e8f71409820a40b", + "category": "validate", + "priority": "critical", + "size_bytes": 17188 + }, + { + "path": "scripts/validate/validate_structure_v2.py", + "sha256": "b0cdf73293b16777e0714eb978d3cd636655c9659903eed45cf8125706eaaf20", + "category": "validate", + "priority": "critical", + "size_bytes": 15938 + }, + { + "path": "scripts/validate/workflows.py", + "sha256": "31a4470ef5bfa7212c1ed1e0c707f840dfb9c06b2e199bac1a5fbab1b78dda1f", + "category": "validate", + "priority": "critical", + "size_bytes": 6571 + }, + { + "path": "scripts/validate/xml_wellformed.py", + "sha256": "8559a70cda76b08eddbe616256897a59ace643e274879c7ebd1998c8294868f8", + "category": "validate", + "priority": "critical", + "size_bytes": 5848 + } + ], + "summary": { + "total_scripts": 77, + "by_priority": { + "medium": 15, + "high": 24, + "critical": 38 + }, + "by_category": { + "analysis": 4, + "automation": 15, + "build": 1, + "docs": 4, + "fix": 3, + "maintenance": 14, + "release": 5, + "run": 5, + "tests": 2, + "validate": 24 + } + } +} \ No newline at end of file diff --git a/PLUGIN_SCRIPTS.md b/PLUGIN_SCRIPTS.md new file mode 100644 index 0000000..d2da37c --- /dev/null +++ b/PLUGIN_SCRIPTS.md @@ -0,0 +1,82 @@ +# Plugin System CLI Scripts + +Command-line scripts for validating, health checking, and managing projects using the MokoStandards plugin system. + +## Available Scripts + +| Script | Purpose | +|--------|---------| +| `plugin_validate.php` | Validate project structure and configuration | +| `plugin_health_check.php` | Run comprehensive health checks | +| `plugin_metrics.php` | Collect project metrics | +| `plugin_readiness.php` | Check release readiness | +| `plugin_list.php` | List all available plugins | + +## Quick Examples + +```bash +# List all available plugins +php api/plugin_list.php + +# Validate a project (auto-detect type) +php api/plugin_validate.php --project-path /path/to/project + +# Run health check +php api/plugin_health_check.php --project-path /path/to/project + +# Collect metrics +php api/plugin_metrics.php --project-path /path/to/project --format table + +# Check release readiness +php api/plugin_readiness.php --project-path /path/to/project +``` + +## Supported Project Types + +- **joomla** - Joomla CMS projects and extensions +- **wordpress** - WordPress themes and plugins +- **nodejs** - Node.js applications and packages +- **python** - Python applications and packages +- **terraform** - Infrastructure as Code +- **mobile** - Mobile applications (iOS/Android) +- **api** - REST API and GraphQL services +- **dolibarr** - Dolibarr ERP/CRM modules +- **documentation** - Documentation projects +- **generic** - Generic project types + +## Exit Codes + +- **0** - Success +- **1** - Validation/check failed +- **2** - Script error (invalid arguments, plugin not found) + +## Documentation + +For detailed documentation, see: +- [Plugin Validation Workflow Templates](../templates/workflows/README.md) +- [Plugin System Implementation](lib/Enterprise/README.md) +- Script help: `php api/plugin_*.php --help` + +## Integration + +These scripts integrate with: +- GitHub Actions workflows (see `templates/workflows/`) +- Plugin system (see `lib/Enterprise/`) +- CI/CD pipelines (GitLab CI, Jenkins, etc.) + +## Usage in CI/CD + +```yaml +# GitHub Actions example +- name: Validate project + run: | + php api/plugin_validate.php --project-path . --json > validation.json + if jq -e '.valid == false' validation.json > /dev/null; then + exit 1 + fi +``` + +For complete usage examples and documentation, run any script with `--help`: +```bash +php api/plugin_validate.php --help +``` diff --git a/README.md b/README.md index e79c4fd..68b1da6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,41 @@ -# MokoStandards-API +# MokoStandards Enterprise API -MokoStandards Enterprise API — PHP implementation (Composer package: mokoconsulting-tech/enterprise) \ No newline at end of file +PHP implementation of MokoStandards — enterprise standards and automation framework. + +## Installation + +```bash +composer require mokoconsulting-tech/enterprise +``` + +### Composer Registry + +This package is served from Gitea package registry. Add this to your `composer.json`: + +```json +{ + "repositories": [ + { + "type": "composer", + "url": "https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer" + } + ] +} +``` + +## CLI Tools + +```bash +# Health check +vendor/bin/moko health -- --path . + +# Sync standards +vendor/bin/moko sync + +# Inventory +vendor/bin/moko inventory -- --path . +``` + +## License + +GPL-3.0-or-later — See [LICENSE.md](LICENSE.md) diff --git a/analysis/index.md b/analysis/index.md new file mode 100644 index 0000000..51855d1 --- /dev/null +++ b/analysis/index.md @@ -0,0 +1,20 @@ +# Docs Index: /api/analysis + +## Purpose + +This index provides navigation to documentation within this folder. + +## Documents + +- [README](./README.md) + +## Metadata + +- **Document Type:** index +- **Auto-generated:** This file is automatically generated by rebuild_indexes.py + +## Revision History + +| Date | Author | Change | Notes | +| ---------- | ------------------ | ----------------- | ------------------------------------------ | +| Auto | rebuild_indexes.py | Automated update | Generated by documentation index automation | diff --git a/automation/bulk_sync.php b/automation/bulk_sync.php new file mode 100755 index 0000000..9d88119 --- /dev/null +++ b/automation/bulk_sync.php @@ -0,0 +1,1374 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Automation + * INGROUP: MokoStandards.Scripts + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/automation/bulk_sync.php + * VERSION: 04.06.00 + * BRIEF: Enterprise-grade bulk repository synchronization + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\{ + ApiClient, + AuditLogger, + CheckpointManager, + CircuitBreakerOpen, + CLIApp, + Config, + MetricsCollector, + PluginFactory, + ProjectTypeDetector, + RateLimitExceeded, + RepositorySynchronizer, + SecurityValidator, + SynchronizationNotImplementedException +}; + +/** + * Bulk Repository Synchronization Tool + * + * Synchronizes MokoStandards files across multiple repositories using + * the Enterprise library for robust, audited operations. + */ +class BulkSync extends CLIApp +{ + /** + * Default organization for bulk sync operations + * Public to allow script instantiation with class constants + */ + public const DEFAULT_ORG = 'mokoconsulting-tech'; + + /** + * Script version number + * Public to allow script instantiation with class constants + */ + public const VERSION = '04.06.00'; + public const VERSION_MINOR = '04.05'; + + private ApiClient $api; + private RepositorySynchronizer $synchronizer; + private AuditLogger $logger; + private CheckpointManager $checkpoints; + private SecurityValidator $security; + private PluginFactory $pluginFactory; + private ProjectTypeDetector $typeDetector; + + /** Set to true by signal handler or rate-limit detection to abort the sync loop gracefully. */ + private bool $interrupted = false; + + /** + * Setup command-line arguments + */ + protected function setupArguments(): array + { + return [ + 'org:' => 'GitHub organization (default: mokoconsulting-tech)', + 'repos:' => 'Specific repositories to sync (space-separated)', + 'exclude:' => 'Repositories to exclude (space-separated)', + 'skip-archived' => 'Skip archived repositories', + 'yes' => 'Auto-confirm prompts', + 'resume' => 'Resume from last checkpoint, skipping already-processed repositories', + 'force' => 'Force overwrite of protected files (always_overwrite=false), except truly protected files', + 'protect' => 'Apply/enforce main branch protection rules on all synced repositories', + 'no-issue' => 'Skip creating a tracking issue in each target repository', + 'update-branches' => 'After sync, merge main into all other open PR branches in each repo', + 'health' => 'Run repo health checks after sync and include results in the report', + ]; + } + + /** + * Main execution + */ + protected function run(): int + { + $this->log("🚀 MokoStandards Bulk Synchronization v" . self::VERSION, 'INFO'); + + // Initialize enterprise components + if (!$this->initializeComponents()) { + return 1; + } + + // Get configuration + $org = $this->getOption('org', self::DEFAULT_ORG); + $skipArchived = $this->hasOption('skip-archived'); + $autoConfirm = $this->hasOption('yes'); + + // Get repository filters + $specificRepos = $this->parseRepositoryList($this->getOption('repos', '')); + $excludeRepos = $this->parseRepositoryList($this->getOption('exclude', '')); + + $this->log("Organization: {$org}", 'INFO'); + if (!empty($specificRepos)) { + $this->log("Repositories: " . implode(', ', $specificRepos), 'INFO'); + } + if (!empty($excludeRepos)) { + $this->log("Excluding: " . implode(', ', $excludeRepos), 'INFO'); + } + + // Get repositories + $this->log("📋 Fetching repositories...", 'INFO'); + $repositories = $this->synchronizer->getRepositories($org, $skipArchived); + + // Apply filters + $repositories = $this->filterRepositories($repositories, $specificRepos, $excludeRepos); + + $count = count($repositories); + $this->log("Found {$count} repositories to sync", 'INFO'); + + if ($count === 0) { + $this->log("No repositories to process", 'WARN'); + return 0; + } + + // Load resume checkpoint if --resume is set + $alreadyProcessed = []; + if ($this->hasOption('resume')) { + $checkpoint = $this->checkpoints->loadCheckpoint('bulk_sync'); + if ($checkpoint !== null) { + $alreadyProcessed = array_keys($checkpoint['results']['repositories'] ?? []); + $skipCount = count($alreadyProcessed); + $stoppedAt = $checkpoint['stopped_at'] ?? 'unknown'; + $reason = $checkpoint['stopped_reason'] ?? 'unknown'; + $this->log("▶ Resuming from checkpoint ({$reason} at '{$stoppedAt}') — skipping {$skipCount} already-processed repositories", 'INFO'); + } else { + $this->log("⚠️ No checkpoint found, starting from scratch", 'WARN'); + } + } + + // Confirm before proceeding + $remaining = $count - count($alreadyProcessed); + if (!$autoConfirm && !$this->confirmSync($remaining > 0 ? $remaining : $count)) { + $this->log("❌ Sync cancelled by user", 'INFO'); + return 0; + } + + // Execute synchronization + $this->log("🔄 Starting synchronization...", 'INFO'); + $results = $this->executeSynchronization($org, $repositories, $alreadyProcessed); + + // Display results + $this->displayResults($results); + + // Apply branch protection if --protect flag is set + if (isset($this->options['protect'])) { + $this->log("🔒 Applying branch protection rules...", 'INFO'); + $results['protection'] = $this->applyBranchProtectionAll($org, $repositories); + } + + // Run repo health checks if --health flag is set + if (isset($this->options['health'])) { + $this->log("🩺 Running repository health checks...", 'INFO'); + $results['health'] = $this->runHealthChecksAll($org, $repositories); + } + + // Create/update tracking issue in MokoStandards + $this->createSyncIssue($org, $results); + + // Create/update a failure issue when any repos failed + if ($results['failed'] > 0) { + $this->createFailureIssue($org, $results); + } + + return $results['failed'] > 0 ? 1 : 0; + } + + /** + * Initialize enterprise components + */ + private function initializeComponents(): bool + { + // Token resolved by Config::load() — env vars first, then gh auth token fallback + $config = Config::load(); + $token = $config->getString('github.token', ''); + + if (empty($token)) { + $this->log("❌ GitHub token not configured", 'ERROR'); + $this->log("Set GH_TOKEN or GITHUB_TOKEN, or run: gh auth login", 'ERROR'); + return false; + } + + try { + $this->api = new ApiClient( + 'https://api.github.com', + $token, + circuitBreakerThreshold: 50, + circuitBreakerTimeout: 10, + ); + $this->logger = new AuditLogger('bulk_sync'); + $this->metrics = new MetricsCollector(); + $this->checkpoints = new CheckpointManager('.checkpoints'); + $this->security = new SecurityValidator(); + $this->synchronizer = new RepositorySynchronizer( + $this->api, + $this->logger, + $this->metrics, + $this->checkpoints + ); + + // Initialize plugin system + $this->pluginFactory = new PluginFactory($this->logger, $this->metrics); + $this->typeDetector = new ProjectTypeDetector($this->logger); + + $this->log("✓ Enterprise components initialized with plugin system", 'INFO'); + return true; + + } catch (\Exception $e) { + $this->log("❌ Failed to initialize: " . $e->getMessage(), 'ERROR'); + return false; + } + } + + /** + * Parse repository list from string + */ + private function parseRepositoryList(string $input): array + { + if (empty($input)) { + return []; + } + + return array_filter( + array_map('trim', preg_split('/[\s,]+/', $input)), + fn($r) => !empty($r) + ); + } + + /** + * Filter repositories based on include/exclude lists + */ + /** Repositories that are permanently excluded from bulk sync. */ + private const ALWAYS_EXCLUDE = ['MokoStandards', '.github-private']; + + private function filterRepositories(array $repositories, array $include, array $exclude): array + { + // Apply include filter if specified (but never override permanent exclusions) + if (!empty($include)) { + $repositories = array_filter( + $repositories, + fn($repo) => in_array($repo['name'], $include) + ); + } + + // Merge user excludes with permanent excludes + $allExclude = array_unique(array_merge($exclude, self::ALWAYS_EXCLUDE)); + + $repositories = array_filter( + $repositories, + fn($repo) => !in_array($repo['name'], $allExclude) + ); + + return array_values($repositories); + } + + /** + * Sort repositories so that .github-private is always processed first. + * All other repositories retain their original relative order. + * + * @param array $repositories + * @return array + */ + private function prioritizeRepositories(array $repositories): array + { + $priority = []; + $rest = []; + + foreach ($repositories as $repo) { + if ($repo['name'] === '.github-private') { + $priority[] = $repo; + } else { + $rest[] = $repo; + } + } + + return array_values(array_merge($priority, $rest)); + } + + /** + * Confirm synchronization with user + */ + private function confirmSync(int $count): bool + { + if ($this->quiet) { + return true; + } + + echo "\n⚠️ About to synchronize {$count} repositories.\n"; + echo "This will update files across all repositories.\n"; + echo "\nContinue? [y/N]: "; + + $handle = fopen("php://stdin", "r"); + $line = fgets($handle); + if ($handle) { + fclose($handle); + } + + // fgets() returns false when stdin is not a TTY (e.g. CI, piped input); + // treat that as a non-confirmation rather than crashing. + return is_string($line) && strtolower(trim($line)) === 'y'; + } + + /** + * Execute synchronization across repositories + * + * @param array $alreadyProcessed Repo names to skip (from a resumed checkpoint) + */ + private function executeSynchronization(string $org, array $repositories, array $alreadyProcessed = []): array + { + $results = [ + 'total' => count($repositories), + 'success' => 0, + 'skipped' => 0, + 'failed' => 0, + 'repositories' => [], + 'prs' => [], + 'issues' => [], + ]; + + // Seed results with repos that were already processed so the final + // summary and issue reflect the full run, not just the resumed portion. + foreach ($alreadyProcessed as $name) { + $results['repositories'][$name] = 'skipped (resumed)'; + $results['skipped']++; + } + + // Register signal handlers so Ctrl-C / SIGTERM saves a resume checkpoint + // instead of leaving the run in an unknown state. + if (function_exists('pcntl_async_signals')) { + pcntl_async_signals(true); + pcntl_signal(SIGINT, function () { $this->interrupted = true; }); + pcntl_signal(SIGTERM, function () { $this->interrupted = true; }); + } + + $startTime = microtime(true); + + foreach ($repositories as $index => $repo) { + $repoName = $repo['name']; + $progress = $index + 1; + $total = $results['total']; + + // Skip repos already covered by a previous partial run + if (in_array($repoName, $alreadyProcessed, true)) { + $this->log("[{$progress}/{$total}] ⊘ {$repoName} (already processed — skipping)", 'INFO'); + continue; + } + + // Check for Ctrl-C / SIGTERM before starting each repo + if ($this->interrupted) { + $this->log("⚡ Interrupted before {$repoName} — saving checkpoint", 'WARN'); + $this->saveInterruptCheckpoint($results, $repoName, 'interrupted'); + break; + } + + $this->log("[{$progress}/{$total}] Processing {$repoName}...", 'INFO'); + + // Reset circuit breaker before processing each repository + // This prevents failures on one repo from blocking subsequent repos + $this->api->resetCircuitBreaker(); + + // Ensure standard labels exist on the target repo before syncing. + // Label provisioning is non-critical — if the circuit breaker trips + // (e.g. 54 new labels on a fresh repo), reset it so file sync proceeds. + if (!$this->dryRun) { + $this->ensureRepoLabels($org, $repoName); + $this->api->resetCircuitBreaker(); + } + + try { + $updated = $this->synchronizer->processRepository( + $org, + $repoName, + $this->dryRun, + isset($this->options['force']) + ); + + if ($updated !== false && $updated > 0) { + $results['success']++; + $results['repositories'][$repoName] = 'success'; + $results['prs'][$repoName] = $updated; + $this->log(" ✓ {$repoName} updated", 'INFO'); + if (!isset($this->options['no-issue']) && !$this->dryRun) { + $issueNum = $this->createTargetRepoIssue($org, $repoName, $updated); + if ($issueNum !== null) { + $results['issues'][$repoName] = $issueNum; + } + } + if (isset($this->options['update-branches']) && !$this->dryRun) { + $this->updateOpenBranches($org, $repoName); + } + } else { + $results['skipped']++; + $results['repositories'][$repoName] = 'skipped'; + $this->log(" ⊘ {$repoName} skipped", 'INFO'); + } + + } catch (SynchronizationNotImplementedException $e) { + $this->log("", 'ERROR'); + $this->log("╔══════════════════════════════════════════════════════════════════════════╗", 'ERROR'); + $this->log("║ CRITICAL ERROR: Repository Synchronization Not Implemented ║", 'ERROR'); + $this->log("╚══════════════════════════════════════════════════════════════════════════╝", 'ERROR'); + $this->log("", 'ERROR'); + $this->log("The bulk repository sync is failing silently because the core", 'ERROR'); + $this->log("synchronization logic has not been implemented yet.", 'ERROR'); + $this->log("", 'ERROR'); + $this->log("Location: api/lib/Enterprise/RepositorySynchronizer.php", 'ERROR'); + $this->log("Method: processRepository()", 'ERROR'); + $this->log("", 'ERROR'); + $this->log("Required Implementation:", 'ERROR'); + $this->log(" 1. Clone/fetch target repository", 'ERROR'); + $this->log(" 2. Apply file updates based on MokoStandards configuration", 'ERROR'); + $this->log(" 3. Create pull request with changes", 'ERROR'); + $this->log(" 4. Handle merge conflicts and validation", 'ERROR'); + $this->log("", 'ERROR'); + $this->log("Until this is implemented, bulk sync will not function.", 'ERROR'); + $this->log("", 'ERROR'); + throw $e; + + } catch (CircuitBreakerOpen $e) { + $results['failed']++; + $results['repositories'][$repoName] = 'failed'; + $this->log(" ✗ {$repoName} failed: Circuit breaker open - " . $e->getMessage(), 'ERROR'); + + } catch (RateLimitExceeded $e) { + // Rate limit hit — abort immediately so we don't burn retries on 403s + $results['failed']++; + $results['repositories'][$repoName] = 'failed'; + $this->log(" ✗ {$repoName} rate-limited: " . $e->getMessage(), 'ERROR'); + $this->saveInterruptCheckpoint($results, $repoName, 'rate_limited'); + break; + + } catch (\Exception $e) { + // Also catch rate limits surfaced as generic exceptions by ApiClient retries + if ($this->isRateLimitError($e)) { + $results['failed']++; + $results['repositories'][$repoName] = 'failed'; + $this->log(" ✗ {$repoName} rate-limited — stopping sync", 'ERROR'); + $this->saveInterruptCheckpoint($results, $repoName, 'rate_limited'); + break; + } + $results['failed']++; + $results['repositories'][$repoName] = 'failed'; + $this->log(" ✗ {$repoName} failed: " . $e->getMessage(), 'ERROR'); + } + + // Save rolling checkpoint after each repo (skipped in dry-run) + if (!$this->dryRun) { + $this->checkpoints->saveCheckpoint('bulk_sync', [ + 'processed' => $progress, + 'total' => $total, + 'results' => $results, + 'stopped_at' => $repoName, + 'stopped_reason' => 'checkpoint', + ]); + } + } + + $duration = microtime(true) - $startTime; + $results['duration'] = $duration; + + return $results; + } + + /** + * Return true when an exception message indicates a GitHub rate-limit response. + * Catches 403 rate-limit errors that ApiClient wraps as generic exceptions. + */ + private function isRateLimitError(\Exception $e): bool + { + $msg = strtolower($e->getMessage()); + return str_contains($msg, 'rate limit') || str_contains($msg, '429') + || (str_contains($msg, '403') && str_contains($msg, 'rate')); + } + + /** + * Save a checkpoint that records where the sync was interrupted, then print + * a hint showing the exact command needed to resume. + */ + private function saveInterruptCheckpoint(array $results, string $stoppedAt, string $reason): void + { + if ($this->dryRun) { + return; + } + + try { + $this->checkpoints->saveCheckpoint('bulk_sync', [ + 'processed' => count($results['repositories']), + 'total' => $results['total'], + 'results' => $results, + 'stopped_at' => $stoppedAt, + 'stopped_reason' => $reason, + ]); + $script = basename(__FILE__); + $this->log("💾 Checkpoint saved. To resume once the issue is resolved, run:", 'INFO'); + $this->log(" php api/automation/{$script} --resume [same flags as before]", 'INFO'); + } catch (\Exception $e) { + $this->log("⚠️ Failed to save interrupt checkpoint: " . $e->getMessage(), 'WARN'); + } + } + + /** + * Display synchronization results + */ + private function displayResults(array $results): void + { + $this->log("\n" . str_repeat('=', 60), 'INFO'); + $this->log("📊 Synchronization Complete", 'INFO'); + $this->log(str_repeat('=', 60), 'INFO'); + + $total = $results['total']; + $success = $results['success']; + $skipped = $results['skipped']; + $failed = $results['failed']; + $duration = $results['duration']; + + $successRate = $total > 0 ? round(($success / $total) * 100, 1) : 0; + + $this->log(sprintf("Total: %d repositories", $total), 'INFO'); + $this->log(sprintf("Success: %d (✓)", $success), 'INFO'); + $this->log(sprintf("Skipped: %d (⊘)", $skipped), 'INFO'); + $this->log(sprintf("Failed: %d (✗)", $failed), 'INFO'); + $this->log(sprintf("Success Rate: %.1f%%", $successRate), 'INFO'); + $this->log(sprintf("Duration: %.2f seconds", $duration), 'INFO'); + + if ($failed > 0) { + $this->log("\n⚠️ Failed Repositories:", 'WARN'); + foreach ($results['repositories'] as $repo => $status) { + if ($status === 'failed') { + $this->log(" - {$repo}", 'WARN'); + } + } + } + + if ($this->verbose) { + $this->log("\n📋 Repository Details:", 'INFO'); + foreach ($results['repositories'] as $repo => $status) { + $icon = match($status) { + 'success' => '✓', + 'skipped' => '⊘', + 'failed' => '✗', + default => '?' + }; + $this->log(sprintf(" %s %s: %s", $icon, $repo, $status), 'INFO'); + } + } + + $this->log(str_repeat('=', 60), 'INFO'); + + $this->writeStepSummary($results); + } + + /** + * Write synchronization results to the GitHub Actions step summary. + * + * Appends a Markdown summary table listing every repository that was + * processed — together with its outcome (updated, skipped, or failed) — + * to the file referenced by the GITHUB_STEP_SUMMARY environment variable. + * When that variable is not set (e.g. local runs) the method is a no-op. + */ + private function writeStepSummary(array $results): void + { + $summaryFile = getenv('GITHUB_STEP_SUMMARY'); + if (empty($summaryFile)) { + return; + } + + // Validate that the path is an absolute filesystem path and not a + // special device file, to guard against environment variable injection. + $realDir = realpath(dirname($summaryFile)); + if ($realDir === false || !str_starts_with($summaryFile, '/') || strpos($summaryFile, '..') !== false) { + $this->log('⚠️ GITHUB_STEP_SUMMARY path is not safe, skipping step summary write.', 'WARN'); + return; + } + + $total = $results['total']; + $success = $results['success']; + $skipped = $results['skipped']; + $failed = $results['failed']; + $duration = $results['duration']; + $successRate = $total > 0 ? round(($success / $total) * 100, 1) : 0; + + $lines = []; + $lines[] = ''; + $lines[] = '### 📊 Synchronization Summary'; + $lines[] = ''; + $lines[] = '| Total | ✅ Updated | ⊘ Skipped | ❌ Failed | Success Rate | Duration |'; + $lines[] = '|------:|----------:|----------:|----------:|-------------:|---------:|'; + $lines[] = sprintf( + '| %d | %d | %d | %d | %.1f%% | %.2fs |', + $total, + $success, + $skipped, + $failed, + $successRate, + $duration + ); + $lines[] = ''; + + if (!empty($results['repositories'])) { + $lines[] = '### 📋 Repositories Processed'; + $lines[] = ''; + $lines[] = '| Repository | Status |'; + $lines[] = '|:-----------|:-------|'; + foreach ($results['repositories'] as $repo => $status) { + $label = match ($status) { + 'success' => '✅ Updated', + 'skipped' => '⊘ Skipped', + 'failed' => '❌ Failed', + default => $status, + }; + $lines[] = sprintf('| `%s` | %s |', $repo, $label); + } + $lines[] = ''; + } + + $written = file_put_contents($summaryFile, implode("\n", $lines) . "\n", FILE_APPEND); + if ($written === false) { + $this->log('⚠️ Failed to write to GITHUB_STEP_SUMMARY.', 'WARN'); + } + } + + /** + * Apply main branch protection to all repositories. + * + * Tries classic branch protection first; records the outcome per repo. + * Private repos on the free GitHub plan will receive a 403 — those are + * noted but do not count as failures. + * + * @param array $repositories + * @return array repo name => 'protected'|'skipped'|'no_main'|'plan_limit'|'error' + */ + private function applyBranchProtectionAll(string $org, array $repositories): array + { + $protection = []; + $token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN') ?: ''; + $payload = json_encode([ + 'required_status_checks' => null, + 'enforce_admins' => false, + 'required_pull_request_reviews' => [ + 'dismiss_stale_reviews' => false, + 'require_code_owner_reviews' => false, + 'required_approving_review_count' => 1, + ], + 'restrictions' => null, + ]); + + foreach ($repositories as $repo) { + $name = $repo['name']; + + if ($this->dryRun) { + $this->log(" (dry-run) would protect {$name}/main", 'INFO'); + $protection[$name] = 'skipped'; + continue; + } + + $ch = curl_init("https://api.github.com/repos/{$org}/{$name}/branches/main/protection"); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => 'PUT', + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + 'Authorization: token ' . $token, + 'Content-Type: application/json', + 'User-Agent: MokoStandards-BulkSync', + 'Accept: application/vnd.github.v3+json', + ], + ]); + $body = (string) curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($status >= 200 && $status < 300) { + $protection[$name] = 'protected'; + $this->log(" 🔒 {$name}: main branch protected", 'INFO'); + } elseif ($status === 403) { + $protection[$name] = 'plan_limit'; + $this->log(" ⚠️ {$name}: branch protection requires GitHub Pro/Teams (private repo)", 'WARN'); + } elseif ($status === 404) { + $protection[$name] = 'no_main'; + $this->log(" ⊘ {$name}: no main branch found", 'INFO'); + } else { + $msg = json_decode($body, true)['message'] ?? "HTTP {$status}"; + $protection[$name] = 'error'; + $this->log(" ✗ {$name}: {$msg}", 'ERROR'); + } + } + + $protectedCount = count(array_filter($protection, fn($v) => $v === 'protected')); + $planLimitCount = count(array_filter($protection, fn($v) => $v === 'plan_limit')); + $this->log(sprintf( + "🔒 Branch protection: %d protected, %d require GitHub Pro/Teams", + $protectedCount, + $planLimitCount + ), 'INFO'); + + return $protection; + } + + /** + * Run lightweight health checks on all repositories after sync. + * + * Checks rulesets (MAIN, VERSION, DEV) and branch protection via the GitHub API. + * Returns a map of repo name => ['score' => int, 'max' => int, 'level' => string]. + * + * @param array $repositories + * @return array + */ + private function runHealthChecksAll(string $org, array $repositories): array + { + $health = []; + $token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN') ?: ''; + + if (empty($token)) { + $this->log("⚠️ Cannot run health checks: GH_TOKEN not set", 'WARN'); + return $health; + } + + foreach ($repositories as $repo) { + $name = $repo['name']; + $fullName = "{$org}/{$name}"; + $score = 0; + $max = 0; + + // 1. Check rulesets (MAIN=5, VERSION=5, DEV=5, RC=5) + $rulesets = $this->fetchRepoRulesets($fullName, $token); + $hasMain = false; + $hasVersion = false; + $hasDev = false; + $hasRc = false; + + foreach ($rulesets as $rs) { + $rsName = strtolower($rs['name'] ?? ''); + $refs = $rs['conditions']['ref_name']['include'] ?? []; + + if (str_contains($rsName, 'main') || in_array('refs/heads/main', $refs, true)) { + $hasMain = true; + } + if (str_contains($rsName, 'version') || $this->refsContain($refs, 'version')) { + $hasVersion = true; + } + if (str_contains($rsName, 'dev') && !str_contains($rsName, 'develop') + || $this->refsContain($refs, 'dev')) { + $hasDev = true; + } + if (str_contains($rsName, 'rc') || $this->refsContain($refs, 'rc/')) { + $hasRc = true; + } + } + + $max += 20; + if ($hasMain) { $score += 5; } + if ($hasVersion) { $score += 5; } + if ($hasDev) { $score += 5; } + if ($hasRc) { $score += 5; } + + // 2. Check branch protection on main (10 pts) + $max += 10; + $protStatus = $this->checkBranchProtected($fullName, $token); + if ($protStatus === 200) { $score += 10; } + + // Calculate level + $pct = $max > 0 ? ($score / $max * 100) : 0; + $level = match (true) { + $pct >= 90 => 'excellent', + $pct >= 70 => 'good', + $pct >= 50 => 'fair', + default => 'poor', + }; + + $health[$name] = ['score' => $score, 'max' => $max, 'level' => $level]; + + if ($pct < 70) { + $this->log(" ⚠️ {$name}: health {$score}/{$max} ({$level})", 'WARN'); + } else { + $this->log(" ✓ {$name}: health {$score}/{$max} ({$level})", 'INFO'); + } + } + + $excellent = count(array_filter($health, fn($h) => $h['level'] === 'excellent')); + $good = count(array_filter($health, fn($h) => $h['level'] === 'good')); + $fair = count(array_filter($health, fn($h) => $h['level'] === 'fair')); + $poor = count(array_filter($health, fn($h) => $h['level'] === 'poor')); + $this->log(sprintf( + "🩺 Health: %d excellent, %d good, %d fair, %d poor", + $excellent, $good, $fair, $poor + ), 'INFO'); + + return $health; + } + + /** + * Fetch rulesets for a single repository (includes org-inherited). + * + * @return array> + */ + private function fetchRepoRulesets(string $fullRepo, string $token): array + { + $ch = curl_init("https://api.github.com/repos/{$fullRepo}/rulesets?per_page=100&includes_parents=true"); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: token ' . $token, + 'User-Agent: MokoStandards-BulkSync', + 'Accept: application/vnd.github.v3+json', + ], + ]); + $body = (string) curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + return $status === 200 ? (json_decode($body, true) ?? []) : []; + } + + /** + * Check if any ref patterns in the array contain a given keyword. + */ + private function refsContain(array $refs, string $keyword): bool + { + foreach ($refs as $ref) { + if (str_contains($ref, $keyword)) { + return true; + } + } + return false; + } + + /** + * Check if a repo's main branch has protection enabled. + * + * @return int HTTP status code (200 = protected, 404 = unprotected, 403 = plan limit) + */ + private function checkBranchProtected(string $fullRepo, string $token): int + { + $ch = curl_init("https://api.github.com/repos/{$fullRepo}/branches/main/protection"); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: token ' . $token, + 'User-Agent: MokoStandards-BulkSync', + 'Accept: application/vnd.github.v3+json', + ], + ]); + curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + return $status; + } + + /** + * Ensure all standard MokoStandards labels exist on a target repository. + * + * Fetches existing labels first (GET) and only POSTs the ones that are + * missing. This avoids the 422 "already exists" responses that would + * otherwise accumulate and trip the circuit breaker on subsequent runs. + */ + private function ensureRepoLabels(string $org, string $repo): void + { + /** @var list name, hex colour (no #), description */ + $labels = [ + // Project Type + ['joomla', '7F52FF', 'Joomla extension or component'], + ['dolibarr', 'FF6B6B', 'Dolibarr module or extension'], + ['generic', '808080', 'Generic project or library'], + + // Language + ['php', '4F5D95', 'PHP code changes'], + ['javascript', 'F7DF1E', 'JavaScript code changes'], + ['typescript', '3178C6', 'TypeScript code changes'], + ['python', '3776AB', 'Python code changes'], + ['css', '1572B6', 'CSS/styling changes'], + ['html', 'E34F26', 'HTML template changes'], + + // Component + ['documentation', '0075CA', 'Documentation changes'], + ['ci-cd', '000000', 'CI/CD pipeline changes'], + ['docker', '2496ED', 'Docker configuration changes'], + ['tests', '00FF00', 'Test suite changes'], + ['security', 'FF0000', 'Security-related changes'], + ['dependencies', '0366D6', 'Dependency updates'], + ['config', 'F9D0C4', 'Configuration file changes'], + ['build', 'FFA500', 'Build system changes'], + + // Workflow / Process + ['automation', '8B4513', 'Automated processes or scripts'], + ['mokostandards', 'B60205', 'MokoStandards compliance'], + ['needs-review', 'FBCA04', 'Awaiting code review'], + ['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'], + ['breaking-change', 'D73A4A', 'Breaking API or functionality change'], + + // Priority + ['priority: critical', 'B60205', 'Critical priority, must be addressed immediately'], + ['priority: high', 'D93F0B', 'High priority'], + ['priority: medium', 'FBCA04', 'Medium priority'], + ['priority: low', '0E8A16', 'Low priority'], + + // Type + ['type: bug', 'D73A4A', "Something isn't working"], + ['type: feature', 'A2EEEF', 'New feature or request'], + ['type: enhancement', '84B6EB', 'Enhancement to existing feature'], + ['type: refactor', 'F9D0C4', 'Code refactoring'], + ['type: chore', 'FEF2C0', 'Maintenance tasks'], + + // Status + ['status: pending', 'FBCA04', 'Pending action or decision'], + ['status: in-progress', '0E8A16', 'Currently being worked on'], + ['status: blocked', 'B60205', 'Blocked by another issue or dependency'], + ['status: on-hold', 'D4C5F9', 'Temporarily on hold'], + ['status: wontfix', 'FFFFFF', 'This will not be worked on'], + + // Size + ['size/xs', 'C5DEF5', 'Extra small change (1-10 lines)'], + ['size/s', '6FD1E2', 'Small change (11-30 lines)'], + ['size/m', 'F9DD72', 'Medium change (31-100 lines)'], + ['size/l', 'FFA07A', 'Large change (101-300 lines)'], + ['size/xl', 'FF6B6B', 'Extra large change (301-1000 lines)'], + ['size/xxl', 'B60205', 'Extremely large change (1000+ lines)'], + + // Health + ['health: excellent', '0E8A16', 'Health score 90-100'], + ['health: good', 'FBCA04', 'Health score 70-89'], + ['health: fair', 'FFA500', 'Health score 50-69'], + ['health: poor', 'FF6B6B', 'Health score below 50'], + + // Sync / Automation (used by bulk_sync, scan_drift, check_repo_health) + ['standards-update', 'B60205', 'MokoStandards sync update'], + ['standards-drift', 'FBCA04', 'Repository drifted from MokoStandards'], + ['sync-report', '0075CA', 'Bulk sync run report'], + ['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'], + ['push-failure', 'D73A4A', 'File push failure requiring attention'], + ['health-check', '0E8A16', 'Repository health check results'], + ['version-drift', 'FFA500', 'Version mismatch detected'], + ['deploy-failure', 'CC0000', 'Automated deploy failure tracking'], + ['template-validation-failure', 'D73A4A', 'Template workflow validation failure'], + ['version', '0E8A16', 'Version bump or release'], + ['type: version', '0E8A16', 'Version-related change'], + ]; + + // Quick check: if the repo already has the 'mokostandards' label, it was + // provisioned previously — skip the expensive full label provisioning. + try { + $probe = $this->api->get("/repos/{$org}/{$repo}/labels/mokostandards"); + if (!empty($probe['name'])) { + return; // already provisioned + } + } catch (\Exception $e) { + // Label doesn't exist — proceed with full provisioning + } + + // Fetch existing labels to determine which ones need creating. + $existing = []; + try { + $page = 1; + do { + $page_labels = $this->api->get("/repos/{$org}/{$repo}/labels?per_page=100&page={$page}"); + foreach ($page_labels as $label) { + $existing[strtolower($label['name'])] = true; + } + $page++; + } while (count($page_labels) === 100); + } catch (\Exception $e) { + // Cannot read labels (e.g. no access) — skip provisioning entirely + return; + } + + foreach ($labels as [$name, $color, $description]) { + if (isset($existing[strtolower($name)])) { + continue; // already exists — no POST needed + } + // Reset before each attempt — the circuit breaker checks state at the + // START of each API call, so resetting after a failure is too late. + $this->api->resetCircuitBreaker(); + try { + $this->api->post("/repos/{$org}/{$repo}/labels", [ + 'name' => $name, + 'color' => $color, + 'description' => $description, + ]); + } catch (\Exception $e) { + // Ignore — label already exists or transient failure + } + } + } + + /** + * Create a tracking issue in the target repository after a successful sync. + * + * Merge main into all open PR branches (except the sync branch itself). + * + * This ensures feature/development branches stay up to date with the + * latest synced standards after a bulk sync run. + */ + private function updateOpenBranches(string $org, string $repo): void + { + $syncBranchPrefix = 'chore/sync-mokostandards-'; + + try { + $defaultBranch = 'main'; + try { + $repoInfo = $this->api->get("/repos/{$org}/{$repo}"); + $defaultBranch = $repoInfo['default_branch'] ?? 'main'; + } catch (\Exception $e) { /* fallback to main */ } + + $prs = $this->api->get("/repos/{$org}/{$repo}/pulls", [ + 'state' => 'open', + 'per_page' => 30, + 'sort' => 'updated', + 'direction' => 'desc', + ]); + + foreach ($prs as $pr) { + $branch = $pr['head']['ref'] ?? ''; + $prNum = $pr['number'] ?? 0; + + // Skip sync branches — they were just reset from main + if (str_starts_with($branch, $syncBranchPrefix)) { + continue; + } + + try { + $this->api->post("/repos/{$org}/{$repo}/merges", [ + 'base' => $branch, + 'head' => $defaultBranch, + 'commit_message' => "chore: merge {$defaultBranch} into {$branch} (MokoStandards sync)", + ]); + $this->log(" 🔀 Merged {$defaultBranch} → {$branch} (PR #{$prNum})", 'INFO'); + } catch (\Exception $e) { + $msg = $e->getMessage(); + if (str_contains($msg, '409') || str_contains($msg, 'Merge conflict')) { + $this->log(" ⚠️ Merge conflict: {$defaultBranch} → {$branch} (PR #{$prNum})", 'WARN'); + } elseif (str_contains($msg, '204') || str_contains($msg, 'nothing to merge')) { + // Already up to date — silently skip + } else { + $this->log(" ⚠️ Could not merge into {$branch}: " . $msg, 'WARN'); + } + } + } + } catch (\Exception $e) { + $this->log(" ⚠️ Could not update branches in {$repo}: " . $e->getMessage(), 'WARN'); + } + } + + /** + * Records which sync run touched the repo, the PR number, and the + * MokoStandards version that was applied — giving each repo a clear audit + * trail of what was changed and why. + */ + private function createTargetRepoIssue(string $org, string $repo, int $prNumber): ?int + { + $now = gmdate('Y-m-d H:i:s') . ' UTC'; + $version = self::VERSION; + $minor = self::VERSION_MINOR; + $force = isset($this->options['force']) ? ' *(--force)*' : ''; + $prLink = "https://github.com/{$org}/{$repo}/pull/{$prNumber}"; + $source = "https://github.com/{$org}/MokoStandards"; + $branchName = 'chore/sync-mokostandards-v' . $minor; + $branchLink = "https://github.com/{$org}/{$repo}/tree/{$branchName}"; + + $title = "chore: MokoStandards v{$minor} sync tracking"; + + $body = <<api->get("/repos/{$org}/{$repo}/issues", [ + 'labels' => 'standards-update', + 'state' => 'all', + 'per_page' => 1, + 'sort' => 'created', + 'direction' => 'desc', + ]); + + if (!empty($existing) && isset($existing[0]['number'])) { + $num = $existing[0]['number']; + $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller-moko']]; + if (($existing[0]['state'] ?? 'open') === 'closed') { + $patch['state'] = 'open'; + } + $this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", $patch); + // Re-apply labels in case any were removed + try { + $this->api->post("/repos/{$org}/{$repo}/issues/{$num}/labels", ['labels' => $labels]); + } catch (\Exception $le) { /* non-fatal */ } + $this->log(" 📋 Tracking issue #{$num} updated in {$repo}", 'INFO'); + } else { + $issue = $this->api->post("/repos/{$org}/{$repo}/issues", [ + 'title' => $title, + 'body' => $body, + 'labels' => $labels, + 'assignees' => ['jmiller-moko'], + ]); + $num = $issue['number'] ?? '?'; + $this->log(" 📋 Tracking issue #{$num} created in {$repo}", 'INFO'); + } + + // Link the tracking issue to the sync PR so it appears in the PR's Development sidebar + if (is_int($num)) { + try { + $pr = $this->api->get("/repos/{$org}/{$repo}/pulls/{$prNumber}"); + $currentBody = $pr['body'] ?? ''; + $closeRef = "Linked to #{$num}"; + if (!str_contains($currentBody, $closeRef)) { + $this->api->patch("/repos/{$org}/{$repo}/pulls/{$prNumber}", [ + 'body' => $closeRef . "\n\n" . $currentBody, + ]); + } + } catch (\Exception $le) { /* non-fatal */ } + } + + return is_int($num) ? $num : null; + } catch (\Exception $e) { + $this->log(" ⚠️ Could not create/update tracking issue in {$repo}: " . $e->getMessage(), 'WARN'); + return null; + } + } + + /** + * Create a tracking issue in MokoStandards for this sync run. + */ + private function createSyncIssue(string $org, array $results): void + { + if ($this->dryRun) { + return; + } + + $now = gmdate('Y-m-d H:i:s') . ' UTC'; + $total = $results['total']; + $success = $results['success']; + $skipped = $results['skipped']; + $failed = $results['failed']; + $duration = round($results['duration'] ?? 0, 1); + $force = isset($this->options['force']) ? ' *(--force)*' : ''; + $prs = $results['prs'] ?? []; + $issues = $results['issues'] ?? []; + + // Stable title — no timestamp so repeated runs update a single issue + $title = "sync: MokoStandards v" . self::VERSION_MINOR . " bulk sync report"; + + $protection = $results['protection'] ?? []; + $hasProtect = !empty($protection); + $healthData = $results['health'] ?? []; + $hasHealth = !empty($healthData); + + // Build repo table + $rows = []; + foreach ($results['repositories'] as $repo => $status) { + $icon = match (true) { + $status === 'success' => '✅', + str_starts_with($status, 'skipped') => '⊘', + str_starts_with($status, 'failed') => '❌', + default => '⚠️', + }; + $prLink = isset($prs[$repo]) + ? "[#{$prs[$repo]}](https://github.com/{$org}/{$repo}/pull/{$prs[$repo]})" + : '—'; + $issueLink = isset($issues[$repo]) + ? "[#{$issues[$repo]}](https://github.com/{$org}/{$repo}/issues/{$issues[$repo]})" + : '—'; + $row = "| `{$repo}` | {$icon} {$status} | {$prLink} | {$issueLink} |"; + if ($hasHealth) { + $h = $healthData[$repo] ?? null; + if ($h) { + $healthIcon = match ($h['level']) { + 'excellent' => '🟢', + 'good' => '🟡', + 'fair' => '🟠', + default => '🔴', + }; + $row .= " {$healthIcon} {$h['score']}/{$h['max']} |"; + } else { + $row .= ' — |'; + } + } + $rows[] = $row; + } + $table = implode("\n", $rows); + + $header = $hasHealth + ? "| Repository | Status | PR | Issue | Health |" + : "| Repository | Status | PR | Issue |"; + $separator = $hasHealth + ? "|---|---|---|---|---|" + : "|---|---|---|---|"; + + $body = <<api->get("/repos/{$org}/MokoStandards/issues", [ + 'labels' => 'sync-report', + 'state' => 'all', + 'per_page' => 1, + 'sort' => 'created', + 'direction'=> 'desc', + ]); + + $labels = ['sync-report', 'mokostandards', 'type: chore', 'automation']; + + if (!empty($existing) && isset($existing[0]['number'])) { + $issueNumber = $existing[0]['number']; + $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller-moko']]; + if (($existing[0]['state'] ?? 'open') === 'closed') { + $patch['state'] = 'open'; + } + $this->api->patch("/repos/{$org}/MokoStandards/issues/{$issueNumber}", $patch); + try { + $this->api->post("/repos/{$org}/MokoStandards/issues/{$issueNumber}/labels", ['labels' => $labels]); + } catch (\Exception $le) { /* non-fatal */ } + $this->log("📋 Sync report issue updated: {$org}/MokoStandards#{$issueNumber}", 'INFO'); + } else { + $issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [ + 'title' => $title, + 'body' => $body, + 'labels' => $labels, + 'assignees' => ['jmiller-moko'], + ]); + $issueNumber = $issue['number'] ?? '?'; + $this->log("📋 Sync report issue created: {$org}/MokoStandards#{$issueNumber}", 'INFO'); + } + } catch (\Exception $e) { + $this->log("⚠️ Failed to create/update sync report issue: " . $e->getMessage(), 'WARN'); + } + } + + /** + * Create or update a failure issue in MokoStandards when repos fail to sync. + * Uses the 'sync-failure' label so it is distinct from the run-report issue. + * Reopens a closed issue rather than creating a duplicate. + */ + private function createFailureIssue(string $org, array $results): void + { + if ($this->dryRun) { + return; + } + + $now = gmdate('Y-m-d H:i:s') . ' UTC'; + $failed = $results['failed']; + $version = self::VERSION; + + $failedRepos = array_keys(array_filter( + $results['repositories'] ?? [], + fn($s) => $s === 'failed' + )); + + $repoList = implode("\n", array_map(fn($r) => "- `{$r}`", $failedRepos)); + + $title = "fix: bulk_sync failed for {$failed} repo(s) — action required"; + + $body = <<` to see the specific error. + 2. Fix the underlying issue (API token, rate limit, branch protection, etc.). + 3. Re-run: `php api/automation/bulk_sync.php --org={$org} --repos= --force --yes` + 4. Close this issue once all repos are synced successfully. + + --- + *Auto-created by `bulk_sync.php` — close once resolved.* + MD; + + $body = preg_replace('/^ /m', '', $body); + + try { + $existing = $this->api->get("/repos/{$org}/MokoStandards/issues", [ + 'labels' => 'sync-failure', + 'state' => 'all', + 'per_page' => 1, + 'sort' => 'created', + 'direction' => 'desc', + ]); + + if (!empty($existing) && isset($existing[0]['number'])) { + $num = $existing[0]['number']; + $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller-moko']]; + if (($existing[0]['state'] ?? 'open') === 'closed') { + $patch['state'] = 'open'; + } + $this->api->patch("/repos/{$org}/MokoStandards/issues/{$num}", $patch); + $this->log("🚨 Failure issue #{$num} updated: {$org}/MokoStandards#{$num}", 'WARN'); + } else { + $issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [ + 'title' => $title, + 'body' => $body, + 'labels' => ['sync-failure'], + 'assignees' => ['jmiller-moko'], + ]); + $num = $issue['number'] ?? '?'; + $this->log("🚨 Failure issue created: {$org}/MokoStandards#{$num}", 'WARN'); + } + } catch (\Exception $e) { + $this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN'); + } + } +} + +// Execute if run directly +if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) { + $app = new BulkSync( + 'bulk-sync', + 'Enterprise-grade bulk repository synchronization', + BulkSync::VERSION + ); + exit($app->execute()); +} diff --git a/automation/file-distributor-config-example.json b/automation/file-distributor-config-example.json new file mode 100644 index 0000000..3df259a --- /dev/null +++ b/automation/file-distributor-config-example.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Example configuration file for file-distributor.ps1 v02.00.00", + "SourceFile": "C:\\path\\to\\your\\source\\file.txt", + "RootDirectory": "C:\\path\\to\\root\\directory", + "Depth": 1, + "DryRun": true, + "Overwrite": false, + "ConfirmEach": false, + "IncludeHidden": true, + "LogDirectory": "C:\\path\\to\\logs" +} diff --git a/automation/index.md b/automation/index.md new file mode 100644 index 0000000..d105b1f --- /dev/null +++ b/automation/index.md @@ -0,0 +1,21 @@ +# Docs Index: /api/automation + +## Purpose + +This index provides navigation to documentation within this folder. + +## Documents + +- [README-file-distributor](./README-file-distributor.md) +- [README](./README.md) + +## Metadata + +- **Document Type:** index +- **Auto-generated:** This file is automatically generated by rebuild_indexes.py + +## Revision History + +| Date | Author | Change | Notes | +| ---------- | ------------------ | ----------------- | ------------------------------------------ | +| Auto | rebuild_indexes.py | Automated update | Generated by documentation index automation | diff --git a/automation/migrate_to_gitea.php b/automation/migrate_to_gitea.php new file mode 100644 index 0000000..7dd855c --- /dev/null +++ b/automation/migrate_to_gitea.php @@ -0,0 +1,296 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Automation + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/automation/migrate_to_gitea.php + * VERSION: 04.06.10 + * BRIEF: Migrate repositories from GitHub to self-hosted Gitea instance + * + * USAGE + * php api/automation/migrate_to_gitea.php --dry-run + * php api/automation/migrate_to_gitea.php --repos MokoCRM MokoDoliMods + * php api/automation/migrate_to_gitea.php --exclude MokoStandards --skip-archived + * php api/automation/migrate_to_gitea.php --resume + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\CheckpointManager; +use MokoEnterprise\CliFramework; +use MokoEnterprise\Config; +use MokoEnterprise\PlatformAdapterFactory; +use MokoEnterprise\GitHubAdapter; +use MokoEnterprise\GiteaAdapter; + +/** + * Gitea Migration Script + * + * Migrates repositories from GitHub to a self-hosted Gitea instance. + * Uses Gitea's built-in migration endpoint for git history, tags, releases, + * issues, and labels. Post-migration applies branch protection, topics, + * and workflow conversion. + */ +class MigrateToGitea extends CliFramework +{ + private ?GitHubAdapter $github = null; + private ?GiteaAdapter $gitea = null; + private ?CheckpointManager $checkpoints = null; + + protected function configure(): void + { + $this->setDescription('Migrate repositories from GitHub to Gitea'); + $this->addArgument('--dry-run', 'Show what would be migrated without making changes', false); + $this->addArgument('--repos', 'Specific repositories to migrate (space-separated)', ''); + $this->addArgument('--exclude', 'Repositories to exclude (space-separated)', ''); + $this->addArgument('--skip-archived', 'Skip archived repositories', false); + $this->addArgument('--resume', 'Resume from last checkpoint', false); + $this->addArgument('--github-token', 'GitHub token override', ''); + $this->addArgument('--gitea-token', 'Gitea token override', ''); + } + + protected function run(): int + { + $dryRun = (bool) $this->getArgument('--dry-run'); + $specificRepos = array_filter(explode(' ', (string) $this->getArgument('--repos'))); + $excludeRepos = array_filter(explode(' ', (string) $this->getArgument('--exclude'))); + $skipArchived = (bool) $this->getArgument('--skip-archived'); + $resume = (bool) $this->getArgument('--resume'); + + $config = Config::load(); + + // Override tokens if provided + $ghToken = (string) $this->getArgument('--github-token'); + $giteaToken = (string) $this->getArgument('--gitea-token'); + if ($ghToken !== '') { $config->set('github.token', $ghToken); } + if ($giteaToken !== '') { $config->set('gitea.token', $giteaToken); } + + // Create both adapters + try { + $adapters = PlatformAdapterFactory::createBoth($config); + $this->github = $adapters['github']; + $this->gitea = $adapters['gitea']; + } catch (\RuntimeException $e) { + $this->log('ERROR', $e->getMessage()); + return 1; + } + + $this->checkpoints = new CheckpointManager('.checkpoints/migration'); + $org = $config->getString('github.organization', 'mokoconsulting-tech'); + $giteaOrg = $config->getString('gitea.organization', 'mokoconsulting-tech'); + + echo "=== Gitea Migration Tool ===\n"; + echo "Source: GitHub ({$org})\n"; + echo "Destination: Gitea ({$giteaOrg}) at " . $config->getString('gitea.url') . "\n"; + echo "Mode: " . ($dryRun ? 'DRY RUN' : 'LIVE') . "\n\n"; + + // ── Phase 1: Discovery ────────────────────────────────────────── + $this->section('Phase 1: Discovery'); + + $ghRepos = $this->github->listOrgRepos($org, $skipArchived); + echo "Found " . count($ghRepos) . " repositories on GitHub\n"; + + // Filter repos + if (!empty($specificRepos)) { + $ghRepos = array_filter($ghRepos, fn($r) => in_array($r['name'], $specificRepos, true)); + } + if (!empty($excludeRepos)) { + $ghRepos = array_filter($ghRepos, fn($r) => !in_array($r['name'], $excludeRepos, true)); + } + + // Check which already exist on Gitea + $giteaRepos = []; + try { + $existing = $this->gitea->listOrgRepos($giteaOrg); + foreach ($existing as $r) { + $giteaRepos[$r['name']] = true; + } + } catch (\Exception $e) { + echo "Note: Could not list Gitea repos (org may not exist yet): {$e->getMessage()}\n"; + } + + $toMigrate = []; + $toSkip = []; + foreach ($ghRepos as $repo) { + $name = $repo['name']; + if (isset($giteaRepos[$name])) { + $toSkip[] = $name; + } else { + $toMigrate[] = $repo; + } + } + + echo "\nMigration plan:\n"; + echo " Migrate: " . count($toMigrate) . " repositories\n"; + echo " Skip: " . count($toSkip) . " (already on Gitea)\n"; + if (!empty($toSkip)) { + echo " Skipped: " . implode(', ', $toSkip) . "\n"; + } + echo "\n"; + + if (empty($toMigrate)) { + echo "Nothing to migrate.\n"; + return 0; + } + + if ($dryRun) { + echo "Repositories to migrate:\n"; + foreach ($toMigrate as $repo) { + $vis = $repo['private'] ? 'private' : 'public'; + echo " - {$repo['name']} ({$vis})\n"; + } + echo "\nDry run complete. Use without --dry-run to execute.\n"; + return 0; + } + + // ── Phase 2: Migrate ──────────────────────────────────────────── + $this->section('Phase 2: Migration'); + + $ghToken = $config->getString('github.token'); + $results = ['migrated' => [], 'failed' => [], 'skipped' => $toSkip]; + + // Resume support + $checkpoint = $resume ? $this->checkpoints->loadCheckpoint('gitea_migration') : null; + $startFrom = $checkpoint['last_completed'] ?? ''; + $skipUntil = !empty($startFrom); + + foreach ($toMigrate as $index => $repo) { + $name = $repo['name']; + + if ($skipUntil) { + if ($name === $startFrom) { + $skipUntil = false; + } + echo " Skipping {$name} (already migrated)\n"; + continue; + } + + echo "\n [{$index}/{" . count($toMigrate) . "}] Migrating {$name}...\n"; + + try { + // Shallow migration — copy current branch state only, no past + // commit history. This gives every repo a clean start on Gitea. + $this->gitea->migrateRepository([ + 'clone_addr' => "https://github.com/{$org}/{$name}.git", + 'repo_name' => $name, + 'repo_owner' => $giteaOrg, + 'service' => 'github', + 'auth_token' => $ghToken, + 'mirror' => false, + 'private' => $repo['private'], + 'issues' => false, + 'labels' => true, + 'milestones' => false, + 'releases' => false, + 'pull_requests' => false, + 'wiki' => false, + ]); + + echo " Migrated successfully\n"; + $results['migrated'][] = $name; + + // Save checkpoint after each successful migration + $this->checkpoints->saveCheckpoint('gitea_migration', [ + 'last_completed' => $name, + 'migrated' => $results['migrated'], + 'failed' => $results['failed'], + ]); + + } catch (\Exception $e) { + echo " FAILED: " . $e->getMessage() . "\n"; + $results['failed'][] = ['name' => $name, 'error' => $e->getMessage()]; + $this->gitea->getApiClient()->resetCircuitBreaker(); + } + } + + // ── Phase 3: Post-migration ───────────────────────────────────── + $this->section('Phase 3: Post-migration'); + + foreach ($results['migrated'] as $name) { + echo " Post-processing {$name}...\n"; + + try { + // Apply topics from GitHub + $ghTopics = $this->github->getRepoTopics($org, $name); + if (!empty($ghTopics)) { + $this->gitea->setRepoTopics($giteaOrg, $name, $ghTopics); + echo " Topics applied\n"; + } + + // Apply branch protection + $this->gitea->setBranchProtection($giteaOrg, $name, 'main', [ + 'required_reviews' => 1, + 'dismiss_stale' => true, + 'block_on_rejected' => true, + ]); + echo " Branch protection applied\n"; + + } catch (\Exception $e) { + echo " Warning: post-processing issue: " . $e->getMessage() . "\n"; + $this->gitea->getApiClient()->resetCircuitBreaker(); + } + } + + // ── Phase 4: Verification ─────────────────────────────────────── + $this->section('Phase 4: Verification'); + + $report = "## Migration Report\n\n"; + $report .= "**Date:** " . gmdate('Y-m-d H:i:s') . " UTC\n"; + $report .= "**Source:** GitHub ({$org})\n"; + $report .= "**Destination:** Gitea ({$giteaOrg})\n\n"; + + $report .= "### Results\n\n"; + $report .= "| Status | Count |\n|--------|-------|\n"; + $report .= "| Migrated | " . count($results['migrated']) . " |\n"; + $report .= "| Failed | " . count($results['failed']) . " |\n"; + $report .= "| Skipped (existing) | " . count($results['skipped']) . " |\n\n"; + + if (!empty($results['migrated'])) { + $report .= "### Migrated Repositories\n\n"; + foreach ($results['migrated'] as $name) { + $report .= "- {$name}\n"; + } + $report .= "\n"; + } + + if (!empty($results['failed'])) { + $report .= "### Failed Repositories\n\n"; + foreach ($results['failed'] as $fail) { + $report .= "- **{$fail['name']}**: {$fail['error']}\n"; + } + $report .= "\n"; + } + + echo $report; + + // Create summary issue on Gitea + try { + $this->gitea->createIssue($giteaOrg, 'MokoStandards', + 'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated', + $report, + ['labels' => ['automation', 'type: chore']] + ); + echo "Migration report issue created on Gitea.\n"; + } catch (\Exception $e) { + echo "Could not create report issue: " . $e->getMessage() . "\n"; + } + + echo "\nMigration complete: " . count($results['migrated']) . " migrated, " + . count($results['failed']) . " failed, " + . count($results['skipped']) . " skipped\n"; + + return count($results['failed']) > 0 ? 1 : 0; + } +} + +$script = new MigrateToGitea('migrate_to_gitea', 'Migrate repositories from GitHub to Gitea'); +exit($script->execute()); diff --git a/automation/push_files.php b/automation/push_files.php new file mode 100644 index 0000000..f8d534a --- /dev/null +++ b/automation/push_files.php @@ -0,0 +1,695 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Automation + * INGROUP: MokoStandards.Scripts + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/automation/push_files.php + * VERSION: 04.06.00 + * BRIEF: Push one or more specific files to one or more remote repositories + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\{ + ApiClient, + AuditLogger, + CLIApp, + Config, + DefinitionParser, + MetricsCollector, + ProjectTypeDetector +}; + +/** + * Targeted File Push Tool + * + * Pushes one or more specific files from MokoStandards templates to one or + * more remote repositories — without running a full sync. + * + * Files are specified by their destination path as they appear in the target + * repository (e.g., ".github/ISSUE_TEMPLATE/config.yml"). The tool looks up + * the matching source template from the appropriate platform definition. + * + * Files may also be given as "source:destination" pairs to bypass definition + * lookup and push any arbitrary local file. + * + * Usage: + * php push_files.php --files=.github/ISSUE_TEMPLATE/config.yml --repos=MokoCRM + * php push_files.php --files=".github/workflows/ci.yml,.github/workflows/codeql-analysis.yml" --repos=MokoCRM,WaasComponent + * php push_files.php --files=templates/foo.txt:docs/foo.txt --repos=MyRepo --direct + */ +class PushFiles extends CLIApp +{ + public const DEFAULT_ORG = 'mokoconsulting-tech'; + public const VERSION = '04.06.00'; + + private ApiClient $api; + private AuditLogger $logger; + private DefinitionParser $defParser; + private ProjectTypeDetector $typeDetector; + + /** + * Setup command-line arguments + */ + protected function setupArguments(): array + { + return [ + 'org:' => 'GitHub organization (default: ' . self::DEFAULT_ORG . ')', + 'repos:' => 'Target repositories — comma or space-separated (required)', + 'files:' => 'Files to push — destination paths or source:destination pairs, comma/space-separated (required)', + 'message:' => 'Custom commit message (optional)', + 'branch:' => 'Target branch for direct pushes (default: repo default branch). Ignored unless --direct is set', + 'direct' => 'Push directly to target branch instead of creating a PR', + 'yes' => 'Auto-confirm without prompting', + 'no-issue' => 'Skip creating a tracking issue in each target repository', + ]; + } + + /** + * Main execution + */ + protected function run(): int + { + $this->log('📦 MokoStandards File Push v' . self::VERSION, 'INFO'); + + if (!$this->initializeComponents()) { + return 1; + } + + $org = $this->getOption('org', self::DEFAULT_ORG); + $reposArg = $this->getOption('repos', ''); + $filesArg = $this->getOption('files', ''); + $direct = $this->hasOption('direct'); + $autoYes = $this->hasOption('yes'); + + // Validate required arguments + if (empty($reposArg)) { + $this->log('❌ --repos is required. Specify one or more repository names.', 'ERROR'); + $this->log(' Example: --repos=MokoCRM,WaasComponent', 'ERROR'); + return 1; + } + + if (empty($filesArg)) { + $this->log('❌ --files is required. Specify destination paths or source:destination pairs.', 'ERROR'); + $this->log(' Example: --files=.github/ISSUE_TEMPLATE/config.yml', 'ERROR'); + return 1; + } + + $repos = $this->parseList($reposArg); + $files = $this->parseList($filesArg); + + $this->log("Organisation: {$org}", 'INFO'); + $this->log('Repositories: ' . implode(', ', $repos), 'INFO'); + $this->log('Files: ' . implode(', ', $files), 'INFO'); + $this->log('Mode: ' . ($direct ? 'direct commit' : 'pull request'), 'INFO'); + + // Resolve file mappings for each repo + $this->log("\n🔍 Resolving file mappings...", 'INFO'); + $repoFileMaps = $this->buildRepoFileMaps($org, $repos, $files); + + if (empty($repoFileMaps)) { + $this->log('❌ No files could be resolved. Check file paths and platform definitions.', 'ERROR'); + return 1; + } + + // Confirm before proceeding + if (!$autoYes && !$this->confirm($repoFileMaps, $direct)) { + $this->log('❌ Cancelled.', 'INFO'); + return 0; + } + + // Execute pushes + $results = $this->executePushes($org, $repoFileMaps, $direct); + + $this->displayResults($results); + + if ($results['failed'] > 0 && !isset($this->options['no-issue']) && !$this->dryRun) { + $this->createFailureIssue($org, $results); + } + + return $results['failed'] > 0 ? 1 : 0; + } + + /** + * Initialize enterprise components + */ + private function initializeComponents(): bool + { + $config = Config::load(); + $token = $config->getString('github.token', ''); + + if (empty($token)) { + $this->log('❌ GitHub token not configured', 'ERROR'); + $this->log('Set GH_TOKEN or GITHUB_TOKEN, or run: gh auth login', 'ERROR'); + return false; + } + + try { + $this->api = new ApiClient('https://api.github.com', $token); + $this->logger = new AuditLogger('push_files'); + $this->defParser = new DefinitionParser(); + $this->typeDetector = new ProjectTypeDetector($this->logger); + + $this->log('✓ Components initialized', 'INFO'); + return true; + } catch (\Exception $e) { + $this->log('❌ Failed to initialize: ' . $e->getMessage(), 'ERROR'); + return false; + } + } + + /** + * Parse a comma- or space-separated list into a clean array + */ + private function parseList(string $input): array + { + return array_values(array_filter( + array_map('trim', preg_split('/[\s,]+/', $input)), + fn($v) => $v !== '' + )); + } + + /** + * Build per-repo file maps: repo → [ [source, destination], … ] + * + * Each entry in $files is either: + * - "destination/path" → looked up in the platform definition + * - "source/path:destination/path" → used as-is (raw mode) + * + * @param string[] $repos + * @param string[] $files + * @return array> + */ + private function buildRepoFileMaps(string $org, array $repos, array $files): array + { + $repoRoot = dirname(__DIR__, 2); + $maps = []; + + foreach ($repos as $repo) { + // Detect the repo's platform so we load the right definition + $platform = $this->detectRepoPlatform($org, $repo); + $this->log(" {$repo}: platform = {$platform}", 'INFO'); + + // Build a destination→source lookup from the definition + $defEntries = $this->defParser->parseForPlatform($platform, $repoRoot); + $destToSource = []; + foreach ($defEntries as $entry) { + $destToSource[$entry['destination']] = $entry['source']; + } + + $resolved = []; + foreach ($files as $fileSpec) { + if (str_contains($fileSpec, ':')) { + // Raw source:destination pair + [$src, $dest] = explode(':', $fileSpec, 2); + $srcAbs = rtrim($repoRoot, '/') . '/' . ltrim($src, '/'); + if (!file_exists($srcAbs)) { + $this->log(" ⚠️ Source not found for {$repo}: {$src}", 'WARN'); + continue; + } + $resolved[] = ['source' => $srcAbs, 'destination' => $dest]; + $this->log(" ✓ {$dest} (raw: {$src})", 'INFO'); + } else { + // Destination path — look up in definition + $dest = ltrim($fileSpec, '/'); + if (isset($destToSource[$dest])) { + $src = $destToSource[$dest]; + $srcAbs = str_starts_with($src, '/') + ? $src + : rtrim($repoRoot, '/') . '/' . ltrim($src, '/'); + if (!file_exists($srcAbs)) { + $this->log(" ⚠️ Template not found for {$repo}: {$src}", 'WARN'); + continue; + } + $resolved[] = ['source' => $srcAbs, 'destination' => $dest]; + $this->log(" ✓ {$dest}", 'INFO'); + } else { + $this->log(" ⚠️ {$dest} not found in {$platform} definition for {$repo}", 'WARN'); + } + } + } + + if (!empty($resolved)) { + $maps[$repo] = $resolved; + } + } + + return $maps; + } + + /** + * Detect platform for a repo by checking its sync def file, falling back + * to the live GitHub API detection used by bulk_sync. + */ + private function detectRepoPlatform(string $org, string $repo): string + { + // Check local sync def first — fastest path + $defDir = dirname(__DIR__) . '/definitions/sync'; + $defFile = "{$defDir}/{$repo}.def.tf"; + if (file_exists($defFile)) { + $content = file_get_contents($defFile) ?: ''; + if (preg_match('/detected_platform\s*=\s*"([^"]+)"/', $content, $m)) { + return $m[1]; + } + } + + // Fall back to live detection + try { + $repoData = $this->api->get("/repos/{$org}/{$repo}"); + return $this->typeDetector->detect($repoData, $org, $repo); + } catch (\Exception $e) { + $this->log(" ⚠️ Could not detect platform for {$repo}, using 'default'", 'WARN'); + return 'default'; + } + } + + /** + * Prompt for confirmation before pushing + * + * @param array> $repoFileMaps + */ + private function confirm(array $repoFileMaps, bool $direct): bool + { + if ($this->quiet) { + return true; + } + + $totalFiles = array_sum(array_map('count', $repoFileMaps)); + $totalRepos = count($repoFileMaps); + $mode = $direct ? 'direct commit' : 'PR'; + + echo "\n"; + foreach ($repoFileMaps as $repo => $entries) { + echo " {$repo}:\n"; + foreach ($entries as $entry) { + echo " → {$entry['destination']}\n"; + } + } + echo "\n"; + echo "⚠️ About to push {$totalFiles} file(s) to {$totalRepos} repo(s) via {$mode}.\n"; + echo "Continue? [y/N]: "; + + $handle = fopen('php://stdin', 'r'); + $line = fgets($handle); + if ($handle) { + fclose($handle); + } + + return is_string($line) && strtolower(trim($line)) === 'y'; + } + + /** + * Execute all file pushes + * + * @param array> $repoFileMaps + * @return array{total: int, success: int, failed: int, repos: array} + */ + private function executePushes(string $org, array $repoFileMaps, bool $direct): array + { + $results = [ + 'total' => count($repoFileMaps), + 'success' => 0, + 'failed' => 0, + 'repos' => [], + ]; + + $customMessage = $this->getOption('message', ''); + $targetBranch = $this->getOption('branch', ''); + + foreach ($repoFileMaps as $repo => $entries) { + $this->log("\n[{$repo}] Pushing " . count($entries) . ' file(s)...', 'INFO'); + + try { + // Resolve the default branch + $repoData = $this->api->get("/repos/{$org}/{$repo}"); + $defaultBranch = $repoData['default_branch'] ?? 'main'; + $branch = $direct + ? ($targetBranch ?: $defaultBranch) + : $this->createSyncBranch($org, $repo, $defaultBranch); + + $pushed = 0; + foreach ($entries as $entry) { + if ($this->pushSingleFile($org, $repo, $entry['source'], $entry['destination'], $branch, $customMessage)) { + $pushed++; + $this->log(" ✓ {$entry['destination']}", 'INFO'); + } else { + $this->log(" ✗ {$entry['destination']}", 'ERROR'); + } + } + + if ($pushed === 0) { + $results['failed']++; + $results['repos'][$repo] = 'failed'; + continue; + } + + $prNumber = null; + if (!$direct) { + $prTitle = "chore: push " . count($entries) . " file(s) from MokoStandards"; + $prBody = $this->buildPRBody($entries); + $pr = $this->api->post("/repos/{$org}/{$repo}/pulls", [ + 'title' => $prTitle, + 'head' => $branch, + 'base' => $defaultBranch, + 'body' => $prBody, + 'assignees' => ['jmiller-moko'], + ]); + $prNumber = $pr['number'] ?? null; + $this->log(" 📋 PR #{$prNumber} created", 'INFO'); + $results['repos'][$repo] = "pr#{$prNumber}"; + } else { + $results['repos'][$repo] = 'pushed'; + } + + if (!isset($this->options['no-issue']) && !$this->dryRun) { + $this->createTargetRepoIssue($org, $repo, $entries, $prNumber, $direct ? $branch : null); + } + + $results['success']++; + + } catch (\Exception $e) { + $this->log(" ✗ {$repo}: " . $e->getMessage(), 'ERROR'); + $results['failed']++; + $results['repos'][$repo] = 'failed'; + } + } + + return $results; + } + + /** + * Create a uniquely-named sync branch off the default branch + */ + private function createSyncBranch(string $org, string $repo, string $base): string + { + $branchName = 'moko/push-files-' . date('Ymd-His'); + + // Get SHA of the base branch tip + $ref = $this->api->get("/repos/{$org}/{$repo}/git/refs/heads/{$base}"); + $sha = $ref['object']['sha'] ?? null; + + if (empty($sha)) { + throw new \RuntimeException("Cannot resolve SHA for branch {$base} in {$repo}"); + } + + $this->api->post("/repos/{$org}/{$repo}/git/refs", [ + 'ref' => "refs/heads/{$branchName}", + 'sha' => $sha, + ]); + + $this->log(" 🌿 Branch created: {$branchName}", 'INFO'); + return $branchName; + } + + /** + * Push a single file to a repository branch via the Contents API + * + * @return bool True on success + */ + private function pushSingleFile( + string $org, + string $repo, + string $sourcePath, + string $destPath, + string $branch, + string $customMessage + ): bool { + $content = file_get_contents($sourcePath); + if ($content === false) { + $this->log(" ⚠️ Cannot read source: {$sourcePath}", 'WARN'); + return false; + } + + $message = !empty($customMessage) + ? $customMessage + : "chore: update {$destPath} from MokoStandards"; + + $payload = [ + 'message' => $message, + 'content' => base64_encode($content), + 'branch' => $branch, + ]; + + // Fetch existing file SHA (needed for updates) + try { + $existing = $this->api->get("/repos/{$org}/{$repo}/contents/{$destPath}", ['ref' => $branch]); + $payload['sha'] = $existing['sha']; + } catch (\Exception $e) { + // File does not exist — create it (no sha needed) + } + + try { + $this->api->put("/repos/{$org}/{$repo}/contents/{$destPath}", $payload); + return true; + } catch (\Exception $e) { + $this->log(" ✗ API error pushing {$destPath}: " . $e->getMessage(), 'ERROR'); + return false; + } + } + + /** + * Create a tracking issue in the target repository after a successful push. + * + * @param list $entries + */ + private function createTargetRepoIssue( + string $org, + string $repo, + array $entries, + ?int $prNumber, + ?string $directBranch + ): void { + $now = gmdate('Y-m-d H:i:s') . ' UTC'; + $version = self::VERSION; + $source = "https://github.com/{$org}/MokoStandards"; + + $title = "chore: MokoStandards file push tracking"; + + $deliveryLine = $prNumber !== null + ? "| **Pull request** | [#{$prNumber}](https://github.com/{$org}/{$repo}/pull/{$prNumber}) |" + : "| **Delivery** | Direct commit to `{$directBranch}` |"; + + $fileRows = implode("\n", array_map( + fn($e) => "- `{$e['destination']}`", + $entries + )); + + $body = <<api->get("/repos/{$org}/{$repo}/issues", [ + 'labels' => 'standards-update', + 'state' => 'all', + 'per_page' => 1, + 'sort' => 'created', + 'direction' => 'desc', + ]); + + if (!empty($existing) && isset($existing[0]['number'])) { + $num = $existing[0]['number']; + $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller-moko']]; + if (($existing[0]['state'] ?? 'open') === 'closed') { + $patch['state'] = 'open'; + } + $this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", $patch); + try { + $this->api->post("/repos/{$org}/{$repo}/issues/{$num}/labels", ['labels' => $labels]); + } catch (\Exception $le) { /* non-fatal */ } + $this->log(" 📋 Tracking issue #{$num} updated in {$repo}", 'INFO'); + } else { + $issue = $this->api->post("/repos/{$org}/{$repo}/issues", [ + 'title' => $title, + 'body' => $body, + 'labels' => $labels, + 'assignees' => ['jmiller-moko'], + ]); + $num = $issue['number'] ?? null; + $this->log(" 📋 Tracking issue #{$num} created in {$repo}", 'INFO'); + } + + // Cross-link: patch the sync PR body to reference the tracking issue + // so GitHub shows it in the PR's Development sidebar. + if ($prNumber !== null && is_int($num)) { + try { + $pr = $this->api->get("/repos/{$org}/{$repo}/pulls/{$prNumber}"); + $currentBody = $pr['body'] ?? ''; + $ref = "Linked to #{$num}"; + if (!str_contains($currentBody, $ref)) { + $this->api->patch("/repos/{$org}/{$repo}/pulls/{$prNumber}", [ + 'body' => $ref . "\n\n" . $currentBody, + ]); + } + } catch (\Exception $le) { /* non-fatal */ } + } + } catch (\Exception $e) { + $this->log(" ⚠️ Could not create/update tracking issue in {$repo}: " . $e->getMessage(), 'WARN'); + } + } + + /** + * Create or update a failure issue in MokoStandards when repos fail to receive files. + * Uses the 'push-failure' label. Reopens a closed issue rather than creating a duplicate. + */ + private function createFailureIssue(string $org, array $results): void + { + $now = gmdate('Y-m-d H:i:s') . ' UTC'; + $failed = $results['failed']; + $version = self::VERSION; + + $failedRepos = array_keys(array_filter( + $results['repos'] ?? [], + fn($s) => $s === 'failed' + )); + + $repoList = implode("\n", array_map(fn($r) => "- `{$r}`", $failedRepos)); + $fileArgs = $this->getOption('files', ''); + + $title = "fix: push_files failed for {$failed} repo(s) — action required"; + + $body = << --files= --yes` + 4. Close this issue once resolved. + + --- + *Auto-created by `push_files.php` — close once resolved.* + MD; + + $body = preg_replace('/^ /m', '', $body); + + try { + $existing = $this->api->get("/repos/{$org}/MokoStandards/issues", [ + 'labels' => 'push-failure', + 'state' => 'all', + 'per_page' => 1, + 'sort' => 'created', + 'direction' => 'desc', + ]); + + if (!empty($existing) && isset($existing[0]['number'])) { + $num = $existing[0]['number']; + $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller-moko']]; + if (($existing[0]['state'] ?? 'open') === 'closed') { + $patch['state'] = 'open'; + } + $this->api->patch("/repos/{$org}/MokoStandards/issues/{$num}", $patch); + $this->log("🚨 Failure issue #{$num} updated: {$org}/MokoStandards#{$num}", 'WARN'); + } else { + $issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [ + 'title' => $title, + 'body' => $body, + 'labels' => ['push-failure'], + 'assignees' => ['jmiller-moko'], + ]); + $num = $issue['number'] ?? '?'; + $this->log("🚨 Failure issue created: {$org}/MokoStandards#{$num}", 'WARN'); + } + } catch (\Exception $e) { + $this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN'); + } + } + + /** + * Build a markdown PR body listing every pushed file + * + * @param list $entries + */ + private function buildPRBody(array $entries): string + { + $now = gmdate('Y-m-d H:i:s') . ' UTC'; + $lines = ["## MokoStandards File Push\n", "**Pushed:** {$now}\n", '### Files\n']; + + foreach ($entries as $entry) { + $lines[] = "- `{$entry['destination']}`"; + } + + $lines[] = "\n---\n*Generated by [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards) `push_files.php`*"; + + return implode("\n", $lines); + } + + /** + * Display final results + * + * @param array{total: int, success: int, failed: int, repos: array} $results + */ + private function displayResults(array $results): void + { + $this->log("\n" . str_repeat('=', 60), 'INFO'); + $this->log('📊 Push Complete', 'INFO'); + $this->log(str_repeat('=', 60), 'INFO'); + $this->log(sprintf('Total: %d repos', $results['total']), 'INFO'); + $this->log(sprintf('Success: %d', $results['success']), 'INFO'); + $this->log(sprintf('Failed: %d', $results['failed']), 'INFO'); + + if ($this->verbose) { + $this->log("\n📋 Details:", 'INFO'); + foreach ($results['repos'] as $repo => $outcome) { + $icon = str_starts_with($outcome, 'pr#') || $outcome === 'pushed' ? '✓' : '✗'; + $this->log(" {$icon} {$repo}: {$outcome}", 'INFO'); + } + } + + $this->log(str_repeat('=', 60), 'INFO'); + } +} + +// Execute if run directly +if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) { + $app = new PushFiles( + 'push-files', + 'Push one or more specific files to one or more remote repositories', + PushFiles::VERSION + ); + exit($app->execute()); +} diff --git a/automation/repo_cleanup.php b/automation/repo_cleanup.php new file mode 100644 index 0000000..2845a83 --- /dev/null +++ b/automation/repo_cleanup.php @@ -0,0 +1,517 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Automation + * INGROUP: MokoStandards.Scripts + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/automation/repo_cleanup.php + * VERSION: 04.06.00 + * BRIEF: Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\{ApiClient, AuditLogger, CLIApp, MetricsCollector}; + +/** + * Enterprise Repository Cleanup + * + * Comprehensive maintenance tool for governed repositories: + * 1. Delete stale sync branches (keeps current versioned branch) + * 2. Close superseded PRs on deleted branches + * 3. Close/lock resolved tracking issues where linked PR is merged + * 4. Delete retired workflow files from repos + * 5. Clean cancelled/stale workflow runs + * 6. Delete workflow run logs older than N days + * 7. Verify and provision standard labels + * 8. Version drift detection + */ +class RepoCleanup extends CLIApp +{ + private const VERSION = '04.06.00'; + private const SYNC_PREFIX = 'chore/sync-mokostandards-'; + private const CURRENT_BRANCH = 'chore/sync-mokostandards-v04.02.00'; + + /** Workflow files that have been retired and should be deleted from governed repos. */ + private const RETIRED_WORKFLOWS = [ + 'build.yml', 'code-quality.yml', 'release-cycle.yml', 'release-pipeline.yml', + 'branch-cleanup.yml', 'auto-update-changelog.yml', 'enterprise-issue-manager.yml', + 'flush-actions-cache.yml', 'mokostandards-script-runner.yml', 'unified-ci.yml', + 'unified-platform-testing.yml', 'reusable-build.yml', 'reusable-ci-validation.yml', + 'reusable-deploy.yml', 'reusable-php-quality.yml', 'reusable-platform-testing.yml', + 'reusable-project-detector.yml', 'reusable-release.yml', 'reusable-script-executor.yml', + 'rebuild-docs-indexes.yml', 'setup-project-v2.yml', 'sync-docs-to-project.yml', + 'release.yml', 'sync-changelogs.yml', 'version_branch.yml', + 'publish-to-mokodolibarr.yml', 'ci.yml', + 'deploy-rs.yml', + ]; + + private ApiClient $api; + private AuditLogger $logger; + private MetricsCollector $metrics; + private bool $dryRun = false; + private float $startTime; + + protected function configure(): void + { + $this->setName('repo-cleanup'); + $this->setDescription('Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs'); + $this->setVersion(self::VERSION); + + $this->addOption('org', 'GitHub organization', 'mokoconsulting-tech'); + $this->addOption('repos', 'Specific repositories (space-separated)', ''); + $this->addOption('skip-archived', 'Skip archived repositories', false); + $this->addOption('close-issues', 'Close resolved tracking issues (merged PR = done)', false); + $this->addOption('lock-old-issues', 'Lock issues closed >30 days', false); + $this->addOption('clean-workflows', 'Delete cancelled/stale workflow runs', false); + $this->addOption('clean-logs', 'Delete workflow run logs older than --log-days', false); + $this->addOption('log-days', 'Days to keep logs (default: 30)', '30'); + $this->addOption('delete-retired', 'Delete retired workflow files from repos', false); + $this->addOption('check-labels', 'Verify mokostandards label exists', false); + $this->addOption('check-drift', 'Check for version drift against README.md', false); + $this->addOption('all', 'Run all cleanup operations', false); + $this->addOption('yes', 'Auto-confirm prompts', false); + $this->addOption('dry-run', 'Preview changes without making them', false); + $this->addOption('verbose', 'Show detailed output', false); + $this->addOption('quiet', 'Suppress non-error output', false); + $this->addOption('json', 'Output results as JSON', false); + } + + protected function execute(): int + { + $this->startTime = microtime(true); + $org = $this->getOption('org', 'mokoconsulting-tech'); + $this->dryRun = (bool) $this->getOption('dry-run', false); + $runAll = (bool) $this->getOption('all', false); + + $token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN') ?: ''; + if (empty($token)) { + $this->error('GH_TOKEN or GITHUB_TOKEN environment variable required'); + return 1; + } + + $this->api = new ApiClient( + 'https://api.github.com', + $token, + circuitBreakerThreshold: 50, + circuitBreakerTimeout: 10, + ); + $this->logger = new AuditLogger('repo_cleanup'); + $this->metrics = new MetricsCollector('repo_cleanup'); + + $this->log("🧹 MokoStandards Repository Cleanup v" . self::VERSION); + $this->log("Organization: {$org}"); + $this->log("Current sync branch: " . self::CURRENT_BRANCH); + if ($this->dryRun) { + $this->log("⚠️ DRY RUN — no changes will be made"); + } + $this->log(''); + + $repos = $this->fetchRepositories($org); + $this->log("Found " . count($repos) . " repositories"); + $this->log(''); + + $results = [ + 'repos_processed' => 0, + 'repos_cleaned' => 0, + 'branches_deleted' => 0, + 'prs_closed' => 0, + 'issues_closed' => 0, + 'issues_locked' => 0, + 'workflows_deleted' => 0, + 'runs_deleted' => 0, + 'logs_deleted' => 0, + 'labels_missing' => 0, + 'version_drift' => 0, + 'retired_files' => 0, + 'errors' => 0, + ]; + + foreach ($repos as $i => $repo) { + $name = $repo['name']; + $num = $i + 1; + $total = count($repos); + $this->log("[{$num}/{$total}] {$name}"); + $results['repos_processed']++; + + try { + $this->api->resetCircuitBreaker(); + $cleaned = false; + + // Always: delete old sync branches + close their PRs + $cleaned = $this->cleanBranches($org, $name, $results) || $cleaned; + + // Optional: close resolved issues + if ($runAll || $this->getOption('close-issues', false)) { + $cleaned = $this->closeResolvedIssues($org, $name, $results) || $cleaned; + } + + // Optional: lock old closed issues + if ($runAll || $this->getOption('lock-old-issues', false)) { + $cleaned = $this->lockOldIssues($org, $name, $results) || $cleaned; + } + + // Optional: delete retired workflow files + if ($runAll || $this->getOption('delete-retired', false)) { + $cleaned = $this->deleteRetiredWorkflows($org, $name, $results) || $cleaned; + } + + // Optional: clean workflow runs + if ($runAll || $this->getOption('clean-workflows', false)) { + $cleaned = $this->cleanWorkflowRuns($org, $name, $results) || $cleaned; + } + + // Optional: clean old logs + if ($runAll || $this->getOption('clean-logs', false)) { + $cleaned = $this->cleanOldLogs($org, $name, $results) || $cleaned; + } + + // Optional: check labels + if ($runAll || $this->getOption('check-labels', false)) { + $this->checkLabels($org, $name, $results); + } + + // Optional: check version drift + if ($runAll || $this->getOption('check-drift', false)) { + $this->checkVersionDrift($org, $name, $results); + } + + if ($cleaned) { + $results['repos_cleaned']++; + } + } catch (\Exception $e) { + $this->error(" ✗ {$name}: " . $e->getMessage()); + $results['errors']++; + } + } + + $duration = round(microtime(true) - $this->startTime, 1); + + $this->log(''); + $this->log('============================================================'); + $this->log("🧹 Cleanup Complete ({$duration}s)"); + $this->log('============================================================'); + $this->log("Repos processed: {$results['repos_processed']}"); + $this->log("Repos with changes: {$results['repos_cleaned']}"); + $this->log("Branches deleted: {$results['branches_deleted']}"); + $this->log("PRs closed: {$results['prs_closed']}"); + $this->log("Issues closed: {$results['issues_closed']}"); + $this->log("Issues locked: {$results['issues_locked']}"); + $this->log("Retired files: {$results['retired_files']}"); + $this->log("Workflow runs: {$results['runs_deleted']}"); + $this->log("Logs cleaned: {$results['logs_deleted']}"); + $this->log("Labels missing: {$results['labels_missing']}"); + $this->log("Version drift: {$results['version_drift']}"); + $this->log("Errors: {$results['errors']}"); + $this->log('============================================================'); + + if ($this->getOption('json', false)) { + $results['duration_seconds'] = $duration; + echo json_encode($results, JSON_PRETTY_PRINT) . "\n"; + } + + return $results['errors'] > 0 ? 1 : 0; + } + + // ─── Repository fetching ───────────────────────────────────────────── + + private function fetchRepositories(string $org): array + { + $specificRepos = trim((string) $this->getOption('repos', '')); + $skipArchived = (bool) $this->getOption('skip-archived', false); + + if (!empty($specificRepos)) { + $names = preg_split('/[\s,]+/', $specificRepos); + return array_map(fn($n) => ['name' => trim($n), 'archived' => false], $names); + } + + $page = 1; + $repos = []; + do { + $batch = $this->api->get("/orgs/{$org}/repos", [ + 'per_page' => 100, + 'page' => $page, + 'type' => 'all', + ]); + foreach ($batch as $r) { + if ($skipArchived && !empty($r['archived'])) continue; + if (in_array($r['name'], ['MokoStandards', '.github-private'], true)) continue; + $repos[] = $r; + } + $page++; + } while (count($batch) === 100); + + return $repos; + } + + // ─── Cleanup operations ────────────────────────────────────────────── + + private function cleanBranches(string $org, string $repo, array &$results): bool + { + $changed = false; + try { + $branches = $this->api->get("/repos/{$org}/{$repo}/branches", ['per_page' => 100]); + } catch (\Exception $e) { + return false; + } + + foreach ($branches as $branch) { + $name = $branch['name'] ?? ''; + if (!str_starts_with($name, self::SYNC_PREFIX) || $name === self::CURRENT_BRANCH) { + continue; + } + + // Close open PRs on this branch + try { + $prs = $this->api->get("/repos/{$org}/{$repo}/pulls", [ + 'state' => 'open', 'head' => "{$org}:{$name}", 'per_page' => 10, + ]); + foreach ($prs as $pr) { + if (($pr['number'] ?? 0) > 0 && !$this->dryRun) { + $this->api->patch("/repos/{$org}/{$repo}/pulls/{$pr['number']}", ['state' => 'closed']); + } + $this->log(" 🔒 Closed PR #{$pr['number']} ({$name})"); + $results['prs_closed']++; + $changed = true; + } + } catch (\Exception $e) { /* non-fatal */ } + + if (!$this->dryRun) { + try { + $this->api->delete("/repos/{$org}/{$repo}/git/refs/heads/{$name}"); + } catch (\Exception $e) { continue; } + } + $this->log(" 🗑️ Deleted branch: {$name}"); + $results['branches_deleted']++; + $changed = true; + } + + return $changed; + } + + private function closeResolvedIssues(string $org, string $repo, array &$results): bool + { + $changed = false; + foreach (['standards-update', 'standards-drift'] as $label) { + try { + $issues = $this->api->get("/repos/{$org}/{$repo}/issues", [ + 'labels' => $label, 'state' => 'open', 'per_page' => 10, + ]); + } catch (\Exception $e) { continue; } + + foreach ($issues as $issue) { + $num = $issue['number'] ?? 0; + $body = $issue['body'] ?? ''; + if (preg_match('/\[#(\d+)\]/', $body, $m)) { + $prNum = (int) $m[1]; + try { + $pr = $this->api->get("/repos/{$org}/{$repo}/pulls/{$prNum}"); + if (!empty($pr['merged_at'])) { + if (!$this->dryRun) { + $this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", [ + 'state' => 'closed', 'state_reason' => 'completed', + ]); + } + $this->log(" ✅ Closed issue #{$num} (PR #{$prNum} merged)"); + $results['issues_closed']++; + $changed = true; + } + } catch (\Exception $e) { /* non-fatal */ } + } + } + } + return $changed; + } + + private function lockOldIssues(string $org, string $repo, array &$results): bool + { + $changed = false; + $cutoff = date('Y-m-d\TH:i:s\Z', strtotime('-30 days')); + + try { + $issues = $this->api->get("/repos/{$org}/{$repo}/issues", [ + 'state' => 'closed', 'per_page' => 50, 'sort' => 'updated', 'direction' => 'asc', + ]); + } catch (\Exception $e) { return false; } + + foreach ($issues as $issue) { + $closedAt = $issue['closed_at'] ?? ''; + $locked = $issue['locked'] ?? false; + $num = $issue['number'] ?? 0; + + if ($locked || $closedAt > $cutoff || $num === 0) continue; + + if (!$this->dryRun) { + try { + $this->api->put("/repos/{$org}/{$repo}/issues/{$num}/lock", [ + 'lock_reason' => 'resolved', + ]); + } catch (\Exception $e) { continue; } + } + $results['issues_locked']++; + $changed = true; + } + + if ($results['issues_locked'] > 0) { + $this->log(" 🔒 Locked {$results['issues_locked']} old closed issue(s)"); + } + return $changed; + } + + private function deleteRetiredWorkflows(string $org, string $repo, array &$results): bool + { + $changed = false; + $defaultBranch = 'main'; + try { + $repoInfo = $this->api->get("/repos/{$org}/{$repo}"); + $defaultBranch = $repoInfo['default_branch'] ?? 'main'; + } catch (\Exception $e) { /* fallback to main */ } + + // Check both workflow directories for retired workflows + $wfDirs = ['.github/workflows', '.gitea/workflows']; + foreach (self::RETIRED_WORKFLOWS as $wf) { + foreach ($wfDirs as $wfDir) { + $path = "{$wfDir}/{$wf}"; + try { + $file = $this->api->get("/repos/{$org}/{$repo}/contents/{$path}"); + $sha = $file['sha'] ?? ''; + if (empty($sha)) continue; + + if (!$this->dryRun) { + $this->api->delete("/repos/{$org}/{$repo}/contents/{$path}", [ + 'message' => "chore: delete retired workflow {$wf}", + 'sha' => $sha, + 'branch' => $defaultBranch, + ]); + } + $this->log(" Deleted retired: {$wf} (from {$wfDir})"); + $results['retired_files']++; + $changed = true; + } catch (\Exception $e) { + // File doesn't exist in this dir — skip + $this->api->resetCircuitBreaker(); + } + } + } + return $changed; + } + + private function cleanWorkflowRuns(string $org, string $repo, array &$results): bool + { + $changed = false; + foreach (['cancelled', 'stale'] as $status) { + try { + $runs = $this->api->get("/repos/{$org}/{$repo}/actions/runs", [ + 'status' => $status, 'per_page' => 100, + ]); + foreach (($runs['workflow_runs'] ?? []) as $run) { + $id = $run['id'] ?? 0; + if ($id > 0 && !$this->dryRun) { + try { + $this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}"); + $results['runs_deleted']++; + $changed = true; + } catch (\Exception $e) { $this->api->resetCircuitBreaker(); } + } + } + } catch (\Exception $e) { /* non-fatal */ } + } + if ($results['runs_deleted'] > 0) { + $this->log(" 🔄 Cleaned {$results['runs_deleted']} workflow run(s)"); + } + return $changed; + } + + private function cleanOldLogs(string $org, string $repo, array &$results): bool + { + $changed = false; + $days = (int) $this->getOption('log-days', '30'); + $cutoff = date('Y-m-d\TH:i:s\Z', strtotime("-{$days} days")); + + try { + $runs = $this->api->get("/repos/{$org}/{$repo}/actions/runs", [ + 'created' => "<{$cutoff}", 'per_page' => 100, + ]); + foreach (($runs['workflow_runs'] ?? []) as $run) { + $id = $run['id'] ?? 0; + if ($id > 0 && !$this->dryRun) { + try { + $this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}/logs"); + $results['logs_deleted']++; + $changed = true; + } catch (\Exception $e) { $this->api->resetCircuitBreaker(); } + } + } + } catch (\Exception $e) { /* non-fatal */ } + + if ($results['logs_deleted'] > 0) { + $this->log(" 📋 Cleaned {$results['logs_deleted']} old log(s)"); + } + return $changed; + } + + private function checkLabels(string $org, string $repo, array &$results): void + { + try { + $this->api->get("/repos/{$org}/{$repo}/labels/mokostandards"); + } catch (\Exception $e) { + $this->log(" ⚠️ Missing 'mokostandards' label"); + $results['labels_missing']++; + $this->api->resetCircuitBreaker(); + } + } + + private function checkVersionDrift(string $org, string $repo, array &$results): void + { + try { + $file = $this->api->get("/repos/{$org}/{$repo}/contents/README.md"); + $content = base64_decode($file['content'] ?? ''); + if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { + $version = $m[1]; + + // Check .mokostandards for the tracked MokoStandards version + try { + $mokoFile = $this->api->get("/repos/{$org}/{$repo}/contents/.mokostandards"); + $mokoContent = base64_decode($mokoFile['content'] ?? ''); + if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) { + if ($vm[1] !== self::VERSION) { + $this->log(" ⚠️ Standards drift: {$vm[1]} (expected " . self::VERSION . ")"); + $results['version_drift']++; + } + } + } catch (\Exception $e) { + $this->api->resetCircuitBreaker(); + } + } + } catch (\Exception $e) { + $this->api->resetCircuitBreaker(); + } + } + + // ─── Helpers ───────────────────────────────────────────────────────── + + private function log(string $message): void + { + if (!$this->getOption('quiet', false)) { + echo $message . "\n"; + } + } + + private function error(string $message): void + { + fwrite(STDERR, $message . "\n"); + } +} + +$app = new RepoCleanup(); +exit($app->execute()); diff --git a/bin/moko b/bin/moko new file mode 100644 index 0000000..f63ff50 --- /dev/null +++ b/bin/moko @@ -0,0 +1,229 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.CLI + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /bin/moko + * VERSION: 04.00.15 + * BRIEF: Unified CLI dispatcher — run any MokoStandards script without needing GitHub Actions + * + * USAGE + * php bin/moko [options] (all platforms) + * ./bin/moko [options] (Unix, after: chmod +x bin/moko) + * + * COMMANDS + * sync Bulk-sync MokoStandards to organisation repos + * health Full repository health check (runs most validators) + * inventory Refresh docs/reference/REPOSITORY_INVENTORY.md + * + * check:syntax PHP syntax check (php -l) on all tracked .php files + * check:version Verify VERSION fields and badges match composer.json + * check:changelog Validate CHANGELOG.md format + * check:structure Verify required root files and directories + * check:headers Check SPDX-License-Identifier presence in source files + * check:secrets Scan for leaked credentials / API keys + * check:tabs Detect tab characters in YAML files + * check:paths Detect backslash path separators in PHP source + * check:xml Validate XML files are well-formed + * check:enterprise Full enterprise-readiness check (headers, strict types, PSR-12) + * check:dolibarr Validate Dolibarr module directory structure + * check:joomla Validate Joomla XML manifest + * check:language Validate Joomla/Dolibarr .ini language files + * detect Auto-detect repository platform type + * drift Scan org repos for drift from MokoStandards templates + * + * COMMON OPTIONS (passed through to each script) + * --path Repository root to check (default: .) + * --dry-run Preview changes without applying them + * --verbose Show passing checks as well as failures + * --quiet Show only failures + * --json Machine-readable JSON output + * --help Show help for the selected command + * + * AUTHENTICATION + * Token resolution order (first non-empty wins): + * 1. GH_TOKEN environment variable + * 2. GITHUB_TOKEN environment variable + * 3. `gh auth token` (GitHub CLI — run `gh auth login` once) + * 4. .env file in repo root (GH_TOKEN=... line) + * + * EXAMPLES + * php bin/moko health + * php bin/moko sync -- --repos MokoDoliTraining --dry-run + * php bin/moko check:version --path . + * php bin/moko drift -- --org mokoconsulting-tech --json + */ + +declare(strict_types=1); + +// ── Bootstrap ──────────────────────────────────────────────────────────────── + +$repoRoot = dirname(__DIR__); +$autoloader = $repoRoot . '/vendor/autoload.php'; + +if (!is_file($autoloader)) { + fwrite(STDERR, "Error: vendor/autoload.php not found.\nRun: composer install\n"); + exit(2); +} + +require_once $autoloader; + +// ── Command map ────────────────────────────────────────────────────────────── + +/** + * Map of moko command names → relative path to the PHP script. + * All paths are relative to the repo root. + */ +const COMMAND_MAP = [ + // Automation + 'sync' => 'api/automation/bulk_sync.php', + + // Maintenance + 'inventory' => 'api/maintenance/update_repo_inventory.php', + + // Validation — general + 'health' => 'api/validate/check_repo_health.php', + 'check:syntax' => 'api/validate/check_php_syntax.php', + 'check:version' => 'api/validate/check_version_consistency.php', + 'check:changelog' => 'api/validate/check_changelog.php', + 'check:structure' => 'api/validate/check_structure.php', + 'check:headers' => 'api/validate/check_license_headers.php', + 'check:secrets' => 'api/validate/check_no_secrets.php', + 'check:tabs' => 'api/validate/check_tabs.php', + 'check:paths' => 'api/validate/check_paths.php', + 'check:xml' => 'api/validate/check_xml_wellformed.php', + 'check:enterprise' => 'api/validate/check_enterprise_readiness.php', + + // Validation — platform-specific + 'check:dolibarr' => 'api/validate/check_dolibarr_module.php', + 'check:joomla' => 'api/validate/check_joomla_manifest.php', + 'check:language' => 'api/validate/check_language_structure.php', + + // Detection + 'detect' => 'api/validate/auto_detect_platform.php', + + // Org-wide + 'drift' => 'api/validate/scan_drift.php', + + // Release + 'release' => 'api/cli/release.php', + + // CLI utilities (used by workflows — centralized logic) + 'version:read' => 'api/cli/version_read.php', + 'version:bump' => 'api/cli/version_bump.php', + 'version:propagate' => 'api/maintenance/update_version_from_readme.php', + 'version:set-platform' => 'api/cli/version_set_platform.php', + 'platform:detect' => 'api/cli/platform_detect.php', + 'release:notes' => 'api/cli/release_notes.php', + 'validate:module' => 'bin/validate-module', +]; + +// ── Argument parsing ───────────────────────────────────────────────────────── + +$args = array_slice($argv, 1); +$command = array_shift($args) ?? ''; + +// Strip leading -- separator that Composer passes when using `composer run-script cmd -- extra-args` +if (isset($args[0]) && $args[0] === '--') { + array_shift($args); +} + +// ── Help / list ─────────────────────────────────────────────────────────────── + +if ($command === '' || $command === '--help' || $command === '-h' || $command === 'help') { + printHelp(); + exit(0); +} + +if ($command === 'list' || $command === 'commands') { + printCommandList(); + exit(0); +} + +// ── Dispatch ────────────────────────────────────────────────────────────────── + +if (!array_key_exists($command, COMMAND_MAP)) { + fwrite(STDERR, "Error: Unknown command '{$command}'\n\n"); + printCommandList(); + exit(2); +} + +$scriptPath = $repoRoot . '/' . COMMAND_MAP[$command]; + +if (!is_file($scriptPath)) { + fwrite(STDERR, "Error: Script not found: " . COMMAND_MAP[$command] . "\n"); + fwrite(STDERR, "Ensure the repository is complete and run: composer install\n"); + exit(2); +} + +// Rebuild $argv as if the target script were invoked directly, then include it. +// This is equivalent to: php + + diff --git a/fix/.gitkeep b/fix/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/fix/fix_line_endings.php b/fix/fix_line_endings.php new file mode 100644 index 0000000..60ff35a --- /dev/null +++ b/fix/fix_line_endings.php @@ -0,0 +1,64 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Fix + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/fix/fix_line_endings.php + * VERSION: 04.06.00 + * BRIEF: CLI script to fix line endings (CRLF → LF) in tracked files + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../lib/CliBase.php'; +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\FileFixUtility; + +/** + * CLI wrapper that delegates line-ending fixes to FileFixUtility. + */ +class FixLineEndings extends CliBase +{ + /** + * Print usage information. + */ + protected function showHelp(): void + { + echo "Usage: {$this->scriptName} [--path DIR] [--dry-run] [--help]\n\n"; + echo "Fixes CRLF line endings to LF in all tracked source files.\n\n"; + echo "OPTIONS:\n"; + echo " --path DIR Repository root (default: current directory)\n"; + echo " --dry-run Show what would be changed without modifying files\n"; + echo " --help Show this help message\n"; + } + + /** + * Run the line-ending fix via FileFixUtility. + * + * @return int Exit code: 0 on success. + */ + protected function execute(): int + { + $path = (string) ($this->getOption('path') ?? '.'); + $files = FileFixUtility::fixLineEndings($path, $this->dryRun); + + foreach ($files as $f) { + $this->success("Fixed: {$f}"); + } + + $label = $this->dryRun ? 'Would fix' : 'Fixed'; + $this->log("{$label} " . count($files) . ' file(s)'); + return 0; + } +} + +$script = new FixLineEndings($argv); +exit($script->run()); diff --git a/fix/fix_permissions.php b/fix/fix_permissions.php new file mode 100644 index 0000000..3067496 --- /dev/null +++ b/fix/fix_permissions.php @@ -0,0 +1,64 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Fix + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/fix/fix_permissions.php + * VERSION: 04.06.00 + * BRIEF: CLI script to fix file permissions (dirs 755, files 644, scripts 755) + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../lib/CliBase.php'; +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\FileFixUtility; + +/** + * CLI wrapper that delegates permission fixes to FileFixUtility. + */ +class FixPermissions extends CliBase +{ + /** + * Print usage information. + */ + protected function showHelp(): void + { + echo "Usage: {$this->scriptName} [--path DIR] [--dry-run] [--help]\n\n"; + echo "Fixes file permissions: 644 for files, 755 for dirs and *.php/*.sh scripts.\n\n"; + echo "OPTIONS:\n"; + echo " --path DIR Repository root (default: current directory)\n"; + echo " --dry-run Show what would be changed without modifying files\n"; + echo " --help Show this help message\n"; + } + + /** + * Run the permissions fix via FileFixUtility. + * + * @return int Exit code: 0 on success. + */ + protected function execute(): int + { + $path = (string) ($this->getOption('path') ?? '.'); + + if ($this->dryRun) { + $this->warning('[DRY-RUN] Would fix permissions (dirs 755, files 644, scripts 755)'); + return 0; + } + + FileFixUtility::fixPermissions($path, $this->dryRun); + $this->success('[OK] Permissions fixed'); + return 0; + } +} + +$script = new FixPermissions($argv); +exit($script->run()); diff --git a/fix/fix_tabs.php b/fix/fix_tabs.php new file mode 100644 index 0000000..a301be6 --- /dev/null +++ b/fix/fix_tabs.php @@ -0,0 +1,73 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Fix + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/fix/fix_tabs.php + * VERSION: 04.06.00 + * BRIEF: CLI script to convert tabs to spaces in tracked source files + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../lib/CliBase.php'; +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\FileFixUtility; + +/** + * CLI wrapper that delegates tab-to-space conversion to FileFixUtility. + */ +class FixTabs extends CliBase +{ + /** + * Print usage information. + */ + protected function showHelp(): void + { + echo "Usage: {$this->scriptName} [--path DIR] [--type TYPE] [--dry-run] [--help]\n\n"; + echo "Convert tabs to spaces in tracked source files.\n\n"; + echo "OPTIONS:\n"; + echo " --path DIR Repository root (default: current directory)\n"; + echo " --type TYPE File type: yaml, python, shell, all (default: all)\n"; + echo " --dry-run Show changes without modifying files\n"; + echo " --help Show this help message\n\n"; + echo "NOTE: Makefile variants are always skipped.\n"; + } + + /** + * Run the tab-fix via FileFixUtility. + * + * @return int Exit code: 0 on success, 2 on invalid arguments. + */ + protected function execute(): int + { + $path = (string) ($this->getOption('path') ?? '.'); + $fileType = (string) ($this->getOption('type') ?? 'all'); + + try { + $files = FileFixUtility::fixTabs($path, $fileType, $this->dryRun); + } catch (\InvalidArgumentException $e) { + $this->log($e->getMessage(), 'ERROR'); + return 2; + } + + foreach ($files as $f) { + $this->success("Fixed: {$f}"); + } + + $label = $this->dryRun ? 'Would fix' : 'Fixed'; + $this->log("{$label} " . count($files) . ' file(s)'); + return 0; + } +} + +$script = new FixTabs($argv); +exit($script->run()); diff --git a/fix/fix_trailing_spaces.php b/fix/fix_trailing_spaces.php new file mode 100644 index 0000000..45eaa3f --- /dev/null +++ b/fix/fix_trailing_spaces.php @@ -0,0 +1,72 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Fix + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/fix/fix_trailing_spaces.php + * VERSION: 04.06.00 + * BRIEF: CLI script to remove trailing whitespace from tracked source files + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../lib/CliBase.php'; +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\FileFixUtility; + +/** + * CLI wrapper that delegates trailing-space removal to FileFixUtility. + */ +class FixTrailingSpaces extends CliBase +{ + /** + * Print usage information. + */ + protected function showHelp(): void + { + echo "Usage: {$this->scriptName} [--path DIR] [--type TYPE] [--dry-run] [--help]\n\n"; + echo "Remove trailing whitespace from tracked source files.\n\n"; + echo "OPTIONS:\n"; + echo " --path DIR Repository root (default: current directory)\n"; + echo " --type TYPE File type: yaml, python, shell, markdown, all (default: all)\n"; + echo " --dry-run Show changes without modifying files\n"; + echo " --help Show this help message\n"; + } + + /** + * Run the trailing-space fix via FileFixUtility. + * + * @return int Exit code: 0 on success, 2 on invalid arguments. + */ + protected function execute(): int + { + $path = (string) ($this->getOption('path') ?? '.'); + $fileType = (string) ($this->getOption('type') ?? 'all'); + + try { + $files = FileFixUtility::fixTrailingSpaces($path, $fileType, $this->dryRun); + } catch (\InvalidArgumentException $e) { + $this->log($e->getMessage(), 'ERROR'); + return 2; + } + + foreach ($files as $f) { + $this->success("Fixed: {$f}"); + } + + $label = $this->dryRun ? 'Would fix' : 'Fixed'; + $this->log("{$label} " . count($files) . ' file(s)'); + return 0; + } +} + +$script = new FixTrailingSpaces($argv); +exit($script->run()); diff --git a/fix/index.md b/fix/index.md new file mode 100644 index 0000000..88cc345 --- /dev/null +++ b/fix/index.md @@ -0,0 +1,20 @@ +# Docs Index: /api/fix + +## Purpose + +This index provides navigation to documentation within this folder. + +## Documents + +- [README](./README.md) + +## Metadata + +- **Document Type:** index +- **Auto-generated:** This file is automatically generated by rebuild_indexes.py + +## Revision History + +| Date | Author | Change | Notes | +| ---------- | ------------------ | ----------------- | ------------------------------------------ | +| Auto | rebuild_indexes.py | Automated update | Generated by documentation index automation | diff --git a/index.md b/index.md new file mode 100644 index 0000000..2c9eacc --- /dev/null +++ b/index.md @@ -0,0 +1,89 @@ +# Scripts Index + +Quick navigation for MokoStandards scripts organized by function. + +## Core Categories + +### [Automation](automation/) +Repository automation and bulk operations +- Python, PowerShell, and Shell scripts for bulk updates +- File distribution utilities +- Project creation automation + +### [Validation](validate/) +Code quality, security, and standards compliance +- Repository health checks (Python and PowerShell GUI) +- Structure validation +- Security scanning +- Platform detection + +### [Maintenance](maintenance/) +Repository upkeep and housekeeping +- Changelog management +- Release automation +- File header validation +- Label setup + +### [Analysis](analysis/) +Analysis and reporting tools +- PR conflict analysis +- Dependency analysis +- Configuration generation + +### [Build](build/) +Build and compilation scripts +- Makefile resolution +- Build automation + +### [Release](release/) +Release management and packaging +- Platform detection +- Package creation +- Dolibarr release automation + +### [Lib](lib/) +Shared library code +- Python utilities (common.py) +- Shell utilities (common.sh) +- PowerShell modules (Common.psm1, GuiUtils.psm1, ConfigManager.psm1) +- Extension utilities +- GitHub client + +### [Docs](docs/) +Documentation generation and maintenance +- Index rebuilding +- Documentation coverage +- Script catalogs +- Guides and architecture documentation + +### [Tests](tests/) +Test scripts +- Bulk update tests +- Dry-run tests + +### [Run](run/) +Operational setup scripts +- GitHub Projects setup + +### [Fix](fix/) +Fix and repair scripts +- Tab/space fixing + +### [Wrappers](wrappers/) +Cross-platform wrappers +- Bash wrappers (53 scripts) +- PowerShell wrappers (53 scripts) + +## Multi-Language Support + +Scripts are organized by **function** rather than **language**. You'll find: +- **Python** scripts (.py) for core functionality +- **PowerShell** scripts (.ps1) and modules (.psm1) for Windows users +- **Shell** scripts (.sh) for Unix/Linux systems + +All three languages may coexist in the same directory for the same functionality. + +## See Also + +- [README.md](README.md) - Comprehensive scripts documentation +- [docs/](docs/) - Additional guides and documentation diff --git a/lib/.gitkeep b/lib/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lib/CliBase.php b/lib/CliBase.php new file mode 100644 index 0000000..0ee6509 --- /dev/null +++ b/lib/CliBase.php @@ -0,0 +1,591 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Lib + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/CliBase.php + * VERSION: 04.06.00 + * BRIEF: Standalone base CLI class for api/ scripts that do not use CliFramework + */ + +declare(strict_types=1); + +/** + * Base CLI Application Class + * + * Provides common functionality for command-line scripts that do not + * require the full CliFramework enterprise stack. + */ +abstract class CliBase +{ + protected array $args = []; + protected array $options = []; + protected bool $verbose = false; + protected bool $dryRun = false; + protected string $scriptName; + + /** + * @param array $argv Command-line argument vector. + */ + public function __construct(array $argv) + { + $this->scriptName = basename($argv[0] ?? 'script'); + $this->parseArguments(array_slice($argv, 1)); + + $this->verbose = $this->hasOption('verbose') || $this->hasOption('v'); + $this->dryRun = $this->hasOption('dry-run'); + } + + /** + * Parse command-line arguments into options and positional args. + * + * @param array $args Argument list (argv without argv[0]). + */ + private function parseArguments(array $args): void + { + foreach ($args as $arg) { + if (str_starts_with($arg, '--')) { + $parts = explode('=', substr($arg, 2), 2); + $this->options[$parts[0]] = $parts[1] ?? true; + } elseif (str_starts_with($arg, '-')) { + $this->options[substr($arg, 1)] = true; + } else { + $this->args[] = $arg; + } + } + } + + /** + * Get positional argument by index. + * + * @param int $index Zero-based position. + * @param mixed $default Fallback when argument is absent. + * @return mixed + */ + protected function getArg(int $index, mixed $default = null): mixed + { + return $this->args[$index] ?? $default; + } + + /** + * Get option value. + * + * @param string $name Option name (without leading dashes). + * @param mixed $default Fallback when option is absent. + * @return mixed + */ + protected function getOption(string $name, mixed $default = null): mixed + { + return $this->options[$name] ?? $default; + } + + /** + * Check if option exists. + * + * @param string $name Option name (without leading dashes). + */ + protected function hasOption(string $name): bool + { + return isset($this->options[$name]); + } + + // ------------------------------------------------------------------------- + // Console graphics constants + // ------------------------------------------------------------------------- + + private const ICONS = [ + 'SUCCESS' => "\u{2713}", // ✓ + 'ERROR' => "\u{2717}", // ✗ + 'WARNING' => "\u{26A0}", // ⚠ + 'INFO' => "\u{2192}", // → + ]; + + private const ANSI = [ + 'ERROR' => "\033[31m", + 'SUCCESS' => "\033[32m", + 'WARNING' => "\033[33m", + 'INFO' => "\033[36m", + 'BOLD' => "\033[1m", + 'DIM' => "\033[2m", + 'GRAY' => "\033[90m", + 'CYAN' => "\033[36m", + 'MAGENTA' => "\033[35m", + 'RESET' => "\033[0m", + ]; + + /** Cached terminal-colour detection result. */ + private ?bool $cliColorEnabled = null; + + // ------------------------------------------------------------------------- + // Colour helper + // ------------------------------------------------------------------------- + + /** + * Return whether ANSI colour output should be used. + */ + protected function isColorEnabled(): bool + { + if ($this->cliColorEnabled !== null) { + return $this->cliColorEnabled; + } + if (isset($this->options['no-color']) || getenv('NO_COLOR') !== false) { + return $this->cliColorEnabled = false; + } + return $this->cliColorEnabled = stream_isatty(STDOUT); + } + + /** + * Wrap text in an ANSI colour; returns plain text when colour is off. + */ + protected function colorize(string $code, string $text): string + { + if (!$this->isColorEnabled()) { + return $text; + } + return $code . $text . self::ANSI['RESET']; + } + + /** + * Return the terminal width (defaults to 80). + */ + protected function termWidth(): int + { + $cols = (int) getenv('COLUMNS'); + return ($cols > 40) ? $cols : 80; + } + + // ------------------------------------------------------------------------- + // Logging + // ------------------------------------------------------------------------- + + /** + * Print a levelled message to stdout. + * + * @param string $message Text to display. + * @param string $level One of INFO, SUCCESS, WARNING, ERROR. + */ + protected function log(string $message, string $level = 'INFO'): void + { + $level = strtoupper($level); + $color = self::ANSI[$level] ?? ''; + $icon = self::ICONS[$level] ?? self::ICONS['INFO']; + $reset = self::ANSI['RESET']; + + if ($this->isColorEnabled()) { + $badge = "{$color}" . self::ANSI['BOLD'] . "[{$level}]{$reset}"; + $icon = "{$color}{$icon}{$reset}"; + } else { + $badge = "[{$level}]"; + } + + $line = "{$icon} {$badge} {$message}\n"; + + if ($level === 'ERROR') { + fwrite(STDERR, $line); + } else { + echo $line; + } + } + + /** + * Print verbose message (only when --verbose or -v is set). + * + * @param string $message Text to display. + */ + protected function verbose(string $message): void + { + if ($this->verbose) { + $this->log($message, 'INFO'); + } + } + + /** + * Print error message and exit. + * + * @param string $message Error text. + * @param int $exitCode Process exit code. + * @return never + */ + protected function error(string $message, int $exitCode = 1): never + { + $this->log($message, 'ERROR'); + exit($exitCode); + } + + /** + * Print success message. + * + * @param string $message Text to display. + */ + protected function success(string $message): void + { + $this->log($message, 'SUCCESS'); + } + + /** + * Print warning message. + * + * @param string $message Text to display. + */ + protected function warning(string $message): void + { + $this->log($message, 'WARNING'); + } + + /** + * Ask user for confirmation (reads from stdin). + * + * @param string $question Prompt text. + * @return bool True when user enters 'y'. + */ + protected function confirm(string $question): bool + { + echo "{$question} [y/N]: "; + $handle = fopen('php://stdin', 'r'); + $line = fgets($handle); + fclose($handle); + return strtolower(trim((string) $line)) === 'y'; + } + + /** + * Print usage/help information. + */ + abstract protected function showHelp(): void; + + /** + * Main execution method. + * + * @return int Exit code (0 = success). + */ + abstract protected function execute(): int; + + /** + * Run the application, dispatching --help and catching exceptions. + * + * @return int Exit code. + */ + public function run(): int + { + if ($this->hasOption('help') || $this->hasOption('h')) { + $this->showHelp(); + return 0; + } + + if ($this->dryRun) { + $this->warning('Dry-run mode enabled - no changes will be made'); + } + + try { + return $this->execute(); + } catch (\Exception $e) { + $this->log('Error: ' . $e->getMessage(), 'ERROR'); + return 1; + } + } + + /** + * Execute a shell command and return its output. + * + * In dry-run mode the command is logged but not executed. + * + * @param string $command Shell command string. + * @param array|null &$output Lines of output (populated by reference). + * @param int|null &$exitCode Process exit code (populated by reference). + * @return string Last line of output. + */ + protected function exec(string $command, ?array &$output = null, ?int &$exitCode = null): string + { + $this->verbose("Executing: {$command}"); + + if ($this->dryRun) { + $this->log("[DRY-RUN] Would execute: {$command}"); + return ''; + } + + $result = exec($command, $output, $exitCode); + + if ($exitCode !== 0) { + $this->warning("Command failed with exit code {$exitCode}"); + } + + return (string) $result; + } + + /** + * Run command and return success status. + * + * @param string $command Shell command string. + * @return bool True when exit code is 0. + */ + protected function runCommand(string $command): bool + { + $exitCode = 0; + $this->exec($command, $output, $exitCode); + return $exitCode === 0; + } + + /** + * Read file contents. + * + * @param string $path File path to read. + * @return string File contents. + * @throws \RuntimeException When file does not exist. + */ + protected function readFile(string $path): string + { + if (!file_exists($path)) { + throw new \RuntimeException("File not found: {$path}"); + } + return (string) file_get_contents($path); + } + + /** + * Write file contents, creating parent directories as needed. + * + * In dry-run mode the write is logged but not performed. + * + * @param string $path Destination file path. + * @param string $content Content to write. + */ + protected function writeFile(string $path, string $content): void + { + if ($this->dryRun) { + $this->log("[DRY-RUN] Would write to: {$path}"); + return; + } + + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + file_put_contents($path, $content); + $this->verbose("Written: {$path}"); + } + + /** + * Copy a file, creating the destination directory if needed. + * + * In dry-run mode the copy is logged but not performed. + * + * @param string $source Source file path. + * @param string $dest Destination file path. + */ + protected function copyFile(string $source, string $dest): void + { + if ($this->dryRun) { + $this->log("[DRY-RUN] Would copy: {$source} -> {$dest}"); + return; + } + + $dir = dirname($dest); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + copy($source, $dest); + $this->verbose("Copied: {$source} -> {$dest}"); + } + + /** + * Delete a file or directory. + * + * In dry-run mode the deletion is logged but not performed. + * + * @param string $path Path to delete. + */ + protected function delete(string $path): void + { + if ($this->dryRun) { + $this->log("[DRY-RUN] Would delete: {$path}"); + return; + } + + if (is_dir($path)) { + $this->deleteDirectory($path); + } elseif (file_exists($path)) { + unlink($path); + } + + $this->verbose("Deleted: {$path}"); + } + + // ------------------------------------------------------------------------- + // Console graphics — visual primitives + // ------------------------------------------------------------------------- + + /** + * Print a script header banner with name and description. + * + * @param string $name Script name. + * @param string $desc One-line description. + * @param string $ver Version string. + */ + protected function printBanner(string $name, string $desc = '', string $ver = '04.00.15'): void + { + $w = min($this->termWidth(), 70); + $inner = $w - 2; + $h = "\u{2500}"; + $v = "\u{2502}"; + $tl = "\u{250C}"; + $tr = "\u{2510}"; + $bl = "\u{2514}"; + $br = "\u{2518}"; + + $title = " {$name} v{$ver}"; + $titlePad = str_pad($title, $inner); + $descPad = ($desc !== '') ? str_pad(" {$desc}", $inner) : null; + + echo "\n"; + echo $this->colorize(self::ANSI['CYAN'], $tl . str_repeat($h, $inner) . $tr) . "\n"; + echo $this->colorize(self::ANSI['CYAN'], $v) + . $this->colorize(self::ANSI['BOLD'], $titlePad) + . $this->colorize(self::ANSI['CYAN'], $v) . "\n"; + if ($descPad !== null) { + echo $this->colorize(self::ANSI['CYAN'], $v) + . $this->colorize(self::ANSI['DIM'], $descPad) + . $this->colorize(self::ANSI['CYAN'], $v) . "\n"; + } + echo $this->colorize(self::ANSI['CYAN'], $bl . str_repeat($h, $inner) . $br) . "\n\n"; + } + + /** + * Print a section header rule. + * + * Output example: ── Section Title ────────────────────────── + */ + protected function section(string $title): void + { + $h = "\u{2500}"; + $w = $this->termWidth(); + $text = " {$title} "; + $fill = max(0, $w - strlen($text) - 4); + echo "\n"; + echo $this->colorize(self::ANSI['CYAN'], + str_repeat($h, 2) . $text . str_repeat($h, $fill)) . "\n\n"; + } + + /** + * Print a plain horizontal divider. + */ + protected function printDivider(): void + { + echo $this->colorize(self::ANSI['DIM'], + str_repeat("\u{2500}", $this->termWidth())) . "\n"; + } + + /** + * Print a single pass/fail status line. + * + * @param bool $passed Whether the check passed. + * @param string $label Check description. + * @param string $detail Optional detail in dim text. + */ + protected function statusLine(bool $passed, string $label, string $detail = ''): void + { + [$icon, $color] = $passed + ? ["\u{2713}", self::ANSI['SUCCESS']] + : ["\u{2717}", self::ANSI['ERROR']]; + + $suffix = ($detail !== '') + ? ' ' . $this->colorize(self::ANSI['DIM'], "— {$detail}") + : ''; + + echo ' ' . $this->colorize($color . self::ANSI['BOLD'], $icon) + . ' ' . $label . $suffix . "\n"; + } + + /** + * Render an in-place progress bar. + * + * @param int $current Items done. + * @param int $total Total items. + * @param string $label Optional trailing label. + * @param bool $newline Finish bar with newline. + */ + protected function progress(int $current, int $total, string $label = '', bool $newline = false): void + { + $barWidth = min(30, $this->termWidth() - 22); + $filled = ($total > 0) ? (int) round($barWidth * $current / $total) : 0; + $pct = ($total > 0) ? (int) round(100 * $current / $total) : 0; + + $bar = $this->colorize(self::ANSI['SUCCESS'], str_repeat("\u{2588}", $filled)) + . $this->colorize(self::ANSI['DIM'], str_repeat("\u{2591}", $barWidth - $filled)); + + $line = sprintf( + ' [%s] %s %s%s', + $bar, + $this->colorize(self::ANSI['BOLD'], sprintf('%3d%%', $pct)), + $this->colorize(self::ANSI['DIM'], "({$current}/{$total})"), + ($label !== '') ? " {$label}" : '' + ); + + echo $newline ? "\r{$line}\n" : "\r{$line}"; + } + + /** + * Print a bordered summary box. + * + * @param array $rows Label => value pairs. + * @param bool|null $passed Border colour: green/red/cyan. + */ + protected function printSummaryBox(array $rows, ?bool $passed = null): void + { + $color = match ($passed) { + true => self::ANSI['SUCCESS'], + false => self::ANSI['ERROR'], + default => self::ANSI['CYAN'], + }; + + $h = "\u{2500}"; + $v = "\u{2502}"; + $tl = "\u{250C}"; + $tr = "\u{2510}"; + $bl = "\u{2514}"; + $br = "\u{2518}"; + + $maxKey = max(array_map('strlen', array_keys($rows))); + $inner = $maxKey + 20; + + echo "\n"; + echo $this->colorize($color, $tl . str_repeat($h, $inner) . $tr) . "\n"; + foreach ($rows as $label => $value) { + $valStr = (string) $value; + $padding = $inner - strlen($label) - strlen($valStr) - 4; + $row = ' ' . $this->colorize(self::ANSI['BOLD'], $label) + . str_repeat(' ', max(1, $padding)) . $valStr . ' '; + echo $this->colorize($color, $v) . $row . $this->colorize($color, $v) . "\n"; + } + echo $this->colorize($color, $bl . str_repeat($h, $inner) . $br) . "\n\n"; + } + + // ------------------------------------------------------------------------- + // Recursively delete a directory (private — used by delete()) + // ------------------------------------------------------------------------- + + /** + * Recursively delete a directory and all its contents. + * + * @param string $dir Directory path. + */ + private function deleteDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff((array) scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = "{$dir}/{$file}"; + is_dir($path) ? $this->deleteDirectory($path) : unlink($path); + } + + rmdir($dir); + } +} diff --git a/lib/Common.php b/lib/Common.php new file mode 100644 index 0000000..e12c8b6 --- /dev/null +++ b/lib/Common.php @@ -0,0 +1,298 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Lib + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Common.php + * VERSION: 04.06.00 + * BRIEF: Common utility functions for api/ scripts + * NOTE: Version format used throughout is zero-padded semver: XX.YY.ZZ (e.g. 04.00.04). + * All version regex patterns enforce exactly two digits per component by design. + */ + +declare(strict_types=1); + +/** + * Common utility class for Moko Consulting scripts. + * + * Provides static helpers for logging, git introspection, and guards. + */ +class Common +{ + /** + * Fallback version used when README.md cannot be parsed. + * NOTE: Kept in sync with _FALLBACK_VERSION in the original common.sh. + * Update this constant when the minimum supported baseline version changes. + */ + const FALLBACK_VERSION = '04.00.00'; + + const REPO_URL = 'https://github.com/mokoconsulting-tech/MokoStandards'; + const COPYRIGHT = 'Copyright (C) 2026 Moko Consulting '; + const LICENSE = 'GPL-3.0-or-later'; + + // Exit codes + const EXIT_SUCCESS = 0; + const EXIT_ERROR = 1; + const EXIT_INVALID_ARGS = 2; + const EXIT_NOT_FOUND = 3; + const EXIT_PERMISSION = 4; + + // ── Logging ─────────────────────────────────────────────────────────────── + + /** + * Print an informational message. + * + * @param string $message Text to display. + */ + public static function info(string $message): void + { + echo 'ℹ️ ' . $message . "\n"; + } + + /** + * Print a success message. + * + * @param string $message Text to display. + */ + public static function success(string $message): void + { + echo '✅ ' . $message . "\n"; + } + + /** + * Print a warning message. + * + * @param string $message Text to display. + */ + public static function warn(string $message): void + { + echo '⚠️ ' . $message . "\n"; + } + + /** + * Print an error message to STDERR. + * + * @param string $message Error text. + */ + public static function error(string $message): void + { + fwrite(STDERR, '❌ ' . $message . "\n"); + } + + /** + * Print a fatal error to STDERR and exit. + * + * @param string $message Error text. + * @param int $exitCode One of the EXIT_* constants. + * @return never + */ + public static function fatal(string $message, int $exitCode = self::EXIT_ERROR): never + { + fwrite(STDERR, '❌ ' . $message . "\n"); + exit($exitCode); + } + + /** + * Print a debug message to STDERR when the DEBUG env var is set. + * + * @param string $message Debug text. + */ + public static function debug(string $message): void + { + if (!empty($_SERVER['DEBUG'] ?? getenv('DEBUG'))) { + fwrite(STDERR, '🔍 ' . $message . "\n"); + } + } + + /** + * Print a plain message to stdout. + * + * @param string $message Text to display. + */ + public static function plain(string $message): void + { + echo $message . "\n"; + } + + // ── Guards ──────────────────────────────────────────────────────────────── + + /** + * Abort if a command is not available on PATH. + * + * @param string $cmd Command name (e.g. 'git'). + * @param string $description Human-readable description for the error message. + */ + public static function requireCommand(string $cmd, string $description = ''): void + { + $which = trim((string) shell_exec('command -v ' . escapeshellarg($cmd) . ' 2>/dev/null')); + if ($which === '') { + $msg = $description !== '' ? $description : "Command required: {$cmd}"; + self::fatal($msg, self::EXIT_NOT_FOUND); + } + } + + /** + * Abort if a file does not exist. + * + * @param string $path Absolute or relative file path. + * @param string $description Human-readable label used in the error message. + */ + public static function requireFile(string $path, string $description = 'File'): void + { + if (!is_file($path)) { + self::fatal("{$description} not found: {$path}", self::EXIT_NOT_FOUND); + } + } + + /** + * Abort if a directory does not exist. + * + * @param string $path Absolute or relative directory path. + * @param string $description Human-readable label used in the error message. + */ + public static function requireDir(string $path, string $description = 'Directory'): void + { + if (!is_dir($path)) { + self::fatal("{$description} not found: {$path}", self::EXIT_NOT_FOUND); + } + } + + // ── Repository utilities ────────────────────────────────────────────────── + + /** + * Return the absolute path to the repository root by walking up from cwd. + * + * @throws \RuntimeException When no .git directory is found. + * @return string Absolute path (no trailing slash). + */ + public static function getRepoRoot(): string + { + $dir = (string) getcwd(); + while ($dir !== '/') { + if (is_dir($dir . '/.git')) { + return $dir; + } + $dir = dirname($dir); + } + self::fatal('Not in a git repository', self::EXIT_ERROR); + } + + /** + * Return the current git branch name (or "unknown"). + * + * @return string Branch name. + */ + public static function getGitBranch(): string + { + $branch = trim((string) shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null')); + return $branch !== '' ? $branch : 'unknown'; + } + + /** + * Return the current full git commit hash (or "unknown"). + * + * @return string Full commit SHA. + */ + public static function getGitCommit(): string + { + $hash = trim((string) shell_exec('git rev-parse HEAD 2>/dev/null')); + return $hash !== '' ? $hash : 'unknown'; + } + + /** + * Return the short git commit hash (or "unknown"). + * + * @return string Short commit SHA. + */ + public static function getGitCommitShort(): string + { + $hash = trim((string) shell_exec('git rev-parse --short HEAD 2>/dev/null')); + return $hash !== '' ? $hash : 'unknown'; + } + + /** + * Return true when the git working directory is clean. + * + * @return bool True if no uncommitted changes. + */ + public static function isGitClean(): bool + { + return trim((string) shell_exec('git status --porcelain 2>/dev/null')) === ''; + } + + /** + * Return true when the current directory is inside a git repository. + * + * @return bool True if inside a git repo. + */ + public static function isGitRepo(): bool + { + exec('git rev-parse --git-dir 2>/dev/null', $out, $code); + return $code === 0; + } + + // ── Path utilities ──────────────────────────────────────────────────────── + + /** + * Return the path relative to the repository root, prefixed with '/'. + * + * @param string $absolutePath Absolute filesystem path. + * @return string Repo-relative path starting with '/'. + */ + public static function getRelativePath(string $absolutePath): string + { + $root = self::getRepoRoot(); + $rel = str_starts_with($absolutePath, $root) + ? substr($absolutePath, strlen($root)) + : $absolutePath; + return '/' . ltrim($rel, '/'); + } + + /** + * Create a directory (and parents) if it does not already exist. + * + * @param string $path Directory path to ensure. + * @param string $description Human-readable label for log output. + */ + public static function ensureDir(string $path, string $description = 'Directory'): void + { + if (!is_dir($path)) { + mkdir($path, 0755, true); + self::info("Created {$description}: {$path}"); + } + } + + // ── Version helpers ─────────────────────────────────────────────────────── + + /** + * Read the VERSION from the FILE INFORMATION block in README.md. + * + * Searches upward from cwd for the repo root, then reads README.md. + * Falls back to FALLBACK_VERSION when the file is absent or unparseable. + * + * @return string Zero-padded semver string, e.g. "04.00.04". + */ + public static function getVersionFromReadme(): string + { + try { + $root = self::getRepoRoot(); + $readme = $root . '/README.md'; + if (!is_file($readme)) { + return self::FALLBACK_VERSION; + } + $content = file_get_contents($readme); + if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', (string) $content, $m)) { + return $m[1]; + } + } catch (\Throwable $e) { + // Fall through to fallback + } + return self::FALLBACK_VERSION; + } +} diff --git a/lib/Enterprise/AbstractProjectPlugin.php b/lib/Enterprise/AbstractProjectPlugin.php new file mode 100644 index 0000000..7a829d3 --- /dev/null +++ b/lib/Enterprise/AbstractProjectPlugin.php @@ -0,0 +1,259 @@ +logger = $logger ?? new AuditLogger('project_plugin'); + $this->metricsCollector = $metricsCollector ?? new MetricsCollector(); + $this->config = $config; + } + + /** + * {@inheritdoc} + */ + abstract public function getProjectType(): string; + + /** + * {@inheritdoc} + */ + abstract public function getPluginName(): string; + + /** + * {@inheritdoc} + */ + public function getPluginVersion(): string + { + return '1.0.0'; + } + + /** + * {@inheritdoc} + */ + abstract public function validateProject(array $config, string $projectPath): array; + + /** + * {@inheritdoc} + */ + abstract public function collectMetrics(string $projectPath, array $config): array; + + /** + * {@inheritdoc} + */ + abstract public function healthCheck(string $projectPath, array $config): array; + + /** + * {@inheritdoc} + */ + abstract public function getRequiredFiles(): array; + + /** + * {@inheritdoc} + */ + abstract public function getRecommendedFiles(): array; + + /** + * {@inheritdoc} + */ + abstract public function getConfigSchema(): array; + + /** + * {@inheritdoc} + */ + abstract public function getBestPractices(): array; + + /** + * {@inheritdoc} + */ + public function checkReadiness(string $projectPath, array $config): array + { + $validation = $this->validateProject($config, $projectPath); + $health = $this->healthCheck($projectPath, $config); + + $blockers = array_merge( + $validation['errors'] ?? [], + array_filter($health['issues'] ?? [], function ($issue) { + return ($issue['severity'] ?? '') === 'critical'; + }) + ); + + $warnings = array_merge( + $validation['warnings'] ?? [], + array_filter($health['issues'] ?? [], function ($issue) { + return ($issue['severity'] ?? '') === 'warning'; + }) + ); + + return [ + 'ready' => empty($blockers), + 'blockers' => $blockers, + 'warnings' => $warnings, + 'score' => $health['score'] ?? 0, + ]; + } + + /** + * {@inheritdoc} + */ + public function getCommands(): array + { + // Default: no custom commands + return []; + } + + /** + * {@inheritdoc} + */ + public function initializeProject(string $projectPath, array $options = []): array + { + // Default: no initialization + return [ + 'success' => true, + 'message' => 'No initialization required for ' . $this->getProjectType(), + 'files_created' => [], + ]; + } + + /** + * Check if a file exists in the project + * + * @param string $projectPath Project directory path + * @param string $filePath Relative file path + * @return bool True if file exists + */ + protected function fileExists(string $projectPath, string $filePath): bool + { + return file_exists(rtrim($projectPath, '/') . '/' . ltrim($filePath, '/')); + } + + /** + * Read a file from the project + * + * @param string $projectPath Project directory path + * @param string $filePath Relative file path + * @return string|null File contents or null if not found + */ + protected function readFile(string $projectPath, string $filePath): ?string + { + $fullPath = rtrim($projectPath, '/') . '/' . ltrim($filePath, '/'); + return file_exists($fullPath) ? file_get_contents($fullPath) : null; + } + + /** + * Check if files match a pattern in the project + * + * @param string $projectPath Project directory path + * @param string $pattern Glob pattern + * @return array Matching file paths + */ + protected function findFiles(string $projectPath, string $pattern): array + { + $fullPattern = rtrim($projectPath, '/') . '/' . ltrim($pattern, '/'); + $matches = glob($fullPattern); + return is_array($matches) ? $matches : []; + } + + /** + * Count files matching a pattern + * + * @param string $projectPath Project directory path + * @param string $pattern Glob pattern + * @return int Number of matching files + */ + protected function countFiles(string $projectPath, string $pattern): int + { + return count($this->findFiles($projectPath, $pattern)); + } + + /** + * Parse JSON file + * + * @param string $projectPath Project directory path + * @param string $filePath Relative file path + * @return array|null Parsed JSON data or null on error + */ + protected function parseJsonFile(string $projectPath, string $filePath): ?array + { + $content = $this->readFile($projectPath, $filePath); + if ($content === null) { + return null; + } + + $data = json_decode($content, true); + return json_last_error() === JSON_ERROR_NONE ? $data : null; + } + + /** + * Log plugin activity + * + * @param string $message Log message + * @param string $level Log level (info, warning, error) + * @param array $context Additional context + * @return void + */ + protected function log(string $message, string $level = 'info', array $context = []): void + { + $context['plugin'] = $this->getPluginName(); + $context['project_type'] = $this->getProjectType(); + + switch ($level) { + case 'error': + $this->logger->logError($message, $context); + break; + case 'warning': + $this->logger->logWarning($message, $context); + break; + default: + $this->logger->logInfo($message, $context); + break; + } + } + + /** + * Record metrics + * + * @param string $category Metric category + * @param string $name Metric name + * @param mixed $value Metric value + * @param array $tags Optional tags + * @return void + */ + protected function recordMetric(string $category, string $name, $value, array $tags = []): void + { + $tags['plugin'] = $this->getPluginName(); + $tags['project_type'] = $this->getProjectType(); + + $this->metricsCollector->record($category, $name, $value, $tags); + } +} diff --git a/lib/Enterprise/ApiClient.php b/lib/Enterprise/ApiClient.php new file mode 100644 index 0000000..ab695df --- /dev/null +++ b/lib/Enterprise/ApiClient.php @@ -0,0 +1,525 @@ + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @package MokoStandards\Enterprise + * @version 04.00.04 + * @author MokoStandards Team + * @license GPL-3.0-or-later + */ + +namespace MokoEnterprise; + +use DateTime; +use DateTimeZone; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Exception\RequestException; +use Psr\Cache\CacheItemPoolInterface; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; + +/** + * Circuit breaker states. + */ +enum CircuitState: string +{ + case CLOSED = 'closed'; // Normal operation + case OPEN = 'open'; // Failures exceeded threshold, blocking requests + case HALF_OPEN = 'half_open'; // Testing if service recovered +} + +/** + * Exception raised when rate limit is exceeded. + */ +class RateLimitExceeded extends RuntimeException +{ +} + +/** + * Exception raised when circuit breaker is open. + */ +class CircuitBreakerOpen extends RuntimeException +{ +} + +/** + * Enterprise API client with rate limiting, retry logic, and circuit breaker. + * + * Features: + * - Rate limiting with configurable limits + * - Exponential backoff retry + * - Response caching with TTL + * - Circuit breaker pattern + * - Request tracking and metrics + * + * Example: + * ```php + * $client = new ApiClient( + * baseUrl: 'https://api.github.com', + * authToken: $token, + * maxRequestsPerHour: 5000 + * ); + * $response = $client->get('/repos/owner/repo'); + * ``` + */ +class ApiClient +{ + private Client $httpClient; + private string $baseUrl; + private ?string $authToken; + private int $maxRequestsPerHour; + private int $maxRetries; + private float $retryBackoffFactor; + private int $cacheTtlSeconds; + private int $circuitBreakerThreshold; + private int $circuitBreakerTimeout; + private bool $enableCaching; + private string $userAgent; + private string $authScheme; + + /** @var array Request timestamps for rate limiting */ + private array $requestTimestamps = []; + + /** @var CacheItemPoolInterface Response cache */ + private CacheItemPoolInterface $cache; + + /** Circuit breaker state */ + private CircuitState $circuitState = CircuitState::CLOSED; + + /** Circuit breaker failure count */ + private int $circuitFailureCount = 0; + + /** Circuit breaker last failure time */ + private ?DateTime $circuitLastFailure = null; + + /** @var array Request metrics */ + private array $metrics = [ + 'total_requests' => 0, + 'successful_requests' => 0, + 'failed_requests' => 0, + 'cache_hits' => 0, + 'cache_misses' => 0, + 'rate_limit_waits' => 0, + 'circuit_breaker_trips' => 0, + ]; + + public const VERSION = '04.06.00'; + + /** + * Initialize API client. + * + * @param string $baseUrl Base URL for API (e.g., 'https://api.github.com') + * @param string|null $authToken Authentication token (optional) + * @param int $maxRequestsPerHour Maximum requests per hour + * @param int $maxRetries Maximum retry attempts for failed requests + * @param float $retryBackoffFactor Exponential backoff factor + * @param int $cacheTtlSeconds Cache time-to-live in seconds + * @param int $circuitBreakerThreshold Failures before opening circuit + * @param int $circuitBreakerTimeout Seconds before attempting recovery + * @param bool $enableCaching Enable response caching + * @param string $userAgent User agent string + * @param LoggerInterface|null $logger Optional logger + * @param string $authScheme Authorization scheme ('Bearer' for GitHub, 'token' for Gitea) + */ + public function __construct( + string $baseUrl, + ?string $authToken = null, + int $maxRequestsPerHour = 5000, + int $maxRetries = 3, + float $retryBackoffFactor = 2.0, + int $cacheTtlSeconds = 300, + int $circuitBreakerThreshold = 5, + int $circuitBreakerTimeout = 60, + bool $enableCaching = true, + string $userAgent = 'MokoStandards-APIClient/1.0', + ?LoggerInterface $logger = null, + string $authScheme = 'Bearer' + ) { + $this->baseUrl = rtrim($baseUrl, '/'); + $this->authToken = $authToken; + $this->maxRequestsPerHour = $maxRequestsPerHour; + $this->maxRetries = $maxRetries; + $this->retryBackoffFactor = $retryBackoffFactor; + $this->cacheTtlSeconds = $cacheTtlSeconds; + $this->circuitBreakerThreshold = $circuitBreakerThreshold; + $this->circuitBreakerTimeout = $circuitBreakerTimeout; + $this->enableCaching = $enableCaching; + $this->userAgent = $userAgent; + $this->authScheme = $authScheme; + + // Initialize HTTP client + $this->httpClient = new Client([ + 'base_uri' => $this->baseUrl, + 'timeout' => 30, + 'headers' => [ + 'User-Agent' => $this->userAgent, + 'Accept' => 'application/json', + ], + ]); + + // Initialize cache + $cacheDir = sys_get_temp_dir() . '/mokostandards/api_cache'; + $this->cache = new FilesystemAdapter('api_client', $this->cacheTtlSeconds, $cacheDir); + } + + /** + * Perform GET request. + * + * @param string $endpoint API endpoint + * @param array $params Query parameters + * @return array Response data + * @throws RateLimitExceeded + * @throws CircuitBreakerOpen + */ + public function get(string $endpoint, array $params = []): array + { + return $this->request('GET', $endpoint, ['query' => $params]); + } + + /** + * Perform POST request. + * + * @param string $endpoint API endpoint + * @param array $data Request body data + * @return array Response data + * @throws RateLimitExceeded + * @throws CircuitBreakerOpen + */ + public function post(string $endpoint, array $data = []): array + { + return $this->request('POST', $endpoint, ['json' => $data]); + } + + /** + * Perform PUT request. + * + * @param string $endpoint API endpoint + * @param array $data Request body data + * @return array Response data + * @throws RateLimitExceeded + * @throws CircuitBreakerOpen + */ + public function put(string $endpoint, array $data = []): array + { + return $this->request('PUT', $endpoint, ['json' => $data]); + } + + /** + * Perform PATCH request. + * + * @param string $endpoint API endpoint + * @param array $data Request body + * @return array Response data + * @throws RateLimitExceeded + * @throws CircuitBreakerOpen + */ + public function patch(string $endpoint, array $data = []): array + { + return $this->request('PATCH', $endpoint, ['json' => $data]); + } + + /** + * Perform DELETE request. + * + * @param string $endpoint API endpoint + * @return array Response data + * @throws RateLimitExceeded + * @throws CircuitBreakerOpen + */ + public function delete(string $endpoint): array + { + return $this->request('DELETE', $endpoint); + } + + /** + * Perform HTTP request with rate limiting, caching, and resilience. + * + * @param string $method HTTP method + * @param string $endpoint API endpoint + * @param array $options Request options + * @return array Response data + * @throws RateLimitExceeded + * @throws CircuitBreakerOpen + */ + private function request(string $method, string $endpoint, array $options = []): array + { + $this->metrics['total_requests']++; + + // Check circuit breaker + $this->checkCircuitBreaker(); + + // Generate cache key + $cacheKey = $this->getCacheKey($method, $endpoint, $options); + + // Check cache for GET requests + if ($method === 'GET' && $this->enableCaching) { + $cachedItem = $this->cache->getItem($cacheKey); + if ($cachedItem->isHit()) { + $this->metrics['cache_hits']++; + return $cachedItem->get(); + } + $this->metrics['cache_misses']++; + } + + // Check rate limit + $this->checkRateLimit(); + + // Add authentication + if ($this->authToken) { + $options['headers']['Authorization'] = $this->authScheme . ' ' . $this->authToken; + } + + // Perform request with retry logic + $response = $this->requestWithRetry($method, $endpoint, $options); + + // Cache successful GET responses + if ($method === 'GET' && $this->enableCaching) { + $cachedItem = $this->cache->getItem($cacheKey); + $cachedItem->set($response); + $cachedItem->expiresAfter($this->cacheTtlSeconds); + $this->cache->save($cachedItem); + } + + return $response; + } + + /** + * Perform request with exponential backoff retry. + * + * @param string $method HTTP method + * @param string $endpoint API endpoint + * @param array $options Request options + * @return array Response data + * @throws RuntimeException + */ + private function requestWithRetry(string $method, string $endpoint, array $options): array + { + $attempt = 0; + $lastException = null; + + while ($attempt < $this->maxRetries) { + try { + $response = $this->httpClient->request($method, $endpoint, $options); + $body = (string) $response->getBody(); + $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + + $this->metrics['successful_requests']++; + $this->recordSuccess(); + + return $data; + } catch (GuzzleException $e) { + $lastException = $e; + $attempt++; + + // Do not retry 4xx client errors — they indicate a definitive + // "not found / forbidden / conflict" response, not a transient fault. + // Retrying wastes time and inflates the circuit-breaker failure count. + $statusCode = ($e instanceof RequestException && $e->hasResponse()) + ? $e->getResponse()->getStatusCode() + : 0; + if ($statusCode >= 400 && $statusCode < 500) { + $this->recordFailure(); + break; + } + + if ($attempt < $this->maxRetries) { + $waitTime = $this->retryBackoffFactor ** $attempt; + usleep((int) ($waitTime * 1000000)); + } + + $this->recordFailure(); + } + } + + $this->metrics['failed_requests']++; + throw new RuntimeException( + "Request failed after {$this->maxRetries} attempts: " . ($lastException?->getMessage() ?? 'Unknown error') + ); + } + + /** + * Check and enforce rate limit. + * + * @throws RateLimitExceeded + */ + private function checkRateLimit(): void + { + $now = time(); + $oneHourAgo = $now - 3600; + + // Remove old timestamps + $this->requestTimestamps = array_filter( + $this->requestTimestamps, + fn($ts) => $ts > $oneHourAgo + ); + + // Check if limit exceeded + if (count($this->requestTimestamps) >= $this->maxRequestsPerHour) { + $oldestTimestamp = min($this->requestTimestamps); + $waitTime = 3600 - ($now - $oldestTimestamp); + + $this->metrics['rate_limit_waits']++; + + throw new RateLimitExceeded( + "Rate limit of {$this->maxRequestsPerHour} requests/hour exceeded. Wait {$waitTime} seconds." + ); + } + + // Record this request + $this->requestTimestamps[] = $now; + } + + /** + * Check circuit breaker state. + * + * @throws CircuitBreakerOpen + */ + private function checkCircuitBreaker(): void + { + if ($this->circuitState === CircuitState::CLOSED) { + return; + } + + if ($this->circuitState === CircuitState::OPEN) { + $now = new DateTime('now', new DateTimeZone('UTC')); + $timeSinceFailure = $now->getTimestamp() - $this->circuitLastFailure?->getTimestamp(); + + if ($timeSinceFailure >= $this->circuitBreakerTimeout) { + // Try half-open state + $this->circuitState = CircuitState::HALF_OPEN; + $this->circuitFailureCount = 0; + } else { + throw new CircuitBreakerOpen( + "Circuit breaker is open. Service unavailable. Retry in " . + ($this->circuitBreakerTimeout - $timeSinceFailure) . " seconds." + ); + } + } + } + + /** + * Record successful request for circuit breaker. + */ + private function recordSuccess(): void + { + if ($this->circuitState === CircuitState::HALF_OPEN) { + // Service recovered, close circuit + $this->circuitState = CircuitState::CLOSED; + } + // Reset failure count on any success so only truly consecutive failures + // trip the breaker. Without this, expected 404s (e.g. checking if a branch + // or file exists before creating it) accumulate failure_count even when + // subsequent calls succeed, causing premature circuit-open events. + $this->circuitFailureCount = 0; + } + + /** + * Record failed request for circuit breaker. + */ + private function recordFailure(): void + { + $this->circuitFailureCount++; + $this->circuitLastFailure = new DateTime('now', new DateTimeZone('UTC')); + + if ($this->circuitFailureCount >= $this->circuitBreakerThreshold) { + $this->circuitState = CircuitState::OPEN; + $this->metrics['circuit_breaker_trips']++; + } + } + + /** + * Generate cache key for request. + * + * @param string $method HTTP method + * @param string $endpoint API endpoint + * @param array $options Request options + * @return string Cache key + */ + private function getCacheKey(string $method, string $endpoint, array $options): string + { + $key = $method . '_' . $endpoint; + if (isset($options['query'])) { + $key .= '_' . http_build_query($options['query']); + } + return md5($key); + } + + /** + * Get current metrics. + * + * @return array Metrics data + */ + public function getMetrics(): array + { + return array_merge($this->metrics, [ + 'circuit_state' => $this->circuitState->value, + 'circuit_failure_count' => $this->circuitFailureCount, + 'rate_limit_remaining' => max(0, $this->maxRequestsPerHour - count($this->requestTimestamps)), + ]); + } + + /** + * Get current circuit breaker state. + * + * @return string Circuit state ('CLOSED', 'OPEN', or 'HALF_OPEN') + */ + public function getCircuitState(): string + { + return strtoupper($this->circuitState->value); + } + + /** + * Simulate a failure for testing circuit breaker functionality. + * This method is intended for testing only and checks for test environment. + * + * @throws RuntimeException If not in test environment, or always to simulate failure + */ + public function simulateFailure(): void + { + // Only allow in test/development environments + $allowedEnvs = ['test', 'testing', 'development', 'dev', 'ci']; + $currentEnv = getenv('APP_ENV') ?: $_ENV['APP_ENV'] ?? getenv('ENVIRONMENT') ?: $_ENV['ENVIRONMENT'] ?? $_SERVER['APP_ENV'] ?? 'production'; + + if (!in_array(strtolower($currentEnv), $allowedEnvs, true)) { + throw new RuntimeException('simulateFailure() can only be called in test environments'); + } + + $this->recordFailure(); + throw new RuntimeException('Simulated failure for circuit breaker testing'); + } + + /** + * Reset circuit breaker to closed state. + */ + public function resetCircuitBreaker(): void + { + $this->circuitState = CircuitState::CLOSED; + $this->circuitFailureCount = 0; + $this->circuitLastFailure = null; + } + + /** + * Clear response cache. + */ + public function clearCache(): void + { + $this->cache->clear(); + } +} diff --git a/lib/Enterprise/AuditLogger.php b/lib/Enterprise/AuditLogger.php new file mode 100644 index 0000000..5677582 --- /dev/null +++ b/lib/Enterprise/AuditLogger.php @@ -0,0 +1,459 @@ + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * @package MokoStandards\Enterprise + * @version 04.00.04 + * @author MokoStandards Team + * @license GPL-3.0-or-later + */ + +namespace MokoEnterprise; + +use DateTime; +use DateTimeZone; +use RuntimeException; + +/** + * Enterprise audit logger with transaction tracking and structured logging. + * + * Features: + * - Transaction ID tracking + * - Security event logging + * - Structured JSON output + * - Automatic log rotation + * - Context manager support + * + * Example: + * ```php + * $logger = new AuditLogger('version_bump'); + * $transaction = $logger->startTransaction('bump_version'); + * $transaction->logEvent('version_change', ['old' => '1.0.0', 'new' => '1.1.0']); + * $transaction->logSecurityEvent('file_modified', ['file' => 'README.md']); + * $transaction->end(); + * ``` + */ +class AuditLogger +{ + /** @var string Service name */ + private string $service; + + /** @var string User performing actions */ + private string $user; + + /** @var string Directory for audit logs */ + private string $logDir; + + /** @var bool Enable console output */ + private bool $enableConsole; + + /** @var bool Enable file logging */ + private bool $enableFile; + + /** @var int Maximum log file size in MB */ + private int $maxLogSizeMb; + + /** @var int Days to retain audit logs */ + private int $retentionDays; + + /** @var string Session ID */ + private string $sessionId; + + /** @var array Transaction stack */ + private array $transactionStack = []; + + /** @var string Version constant */ + public const VERSION = '04.06.00'; + + /** + * Initialize audit logger. + * + * @param string $service Service name (e.g., 'version_bump', 'branch_cleanup') + * @param string|null $logDir Directory for audit logs (default: var/logs/audit/) + * @param string|null $user Username for audit trail (default: from environment) + * @param bool $enableConsole Output to console (default: true) + * @param bool $enableFile Write to file (default: true) + * @param int $maxLogSizeMb Maximum log file size before rotation + * @param int $retentionDays Days to retain audit logs + */ + public function __construct( + string $service, + ?string $logDir = null, + ?string $user = null, + bool $enableConsole = true, + bool $enableFile = true, + int $maxLogSizeMb = 10, + int $retentionDays = 90 + ) { + $this->service = $service; + $this->enableConsole = $enableConsole; + $this->enableFile = $enableFile; + $this->maxLogSizeMb = $maxLogSizeMb; + $this->retentionDays = $retentionDays; + + // Determine user + $this->user = $user ?? $_SERVER['USER'] ?? $_SERVER['USERNAME'] ?? posix_getpwuid(posix_geteuid())['name'] ?? 'unknown'; + + // Set up log directory + if ($logDir === null) { + // Default to var/logs/audit/ in repository root + $repoRoot = dirname(__DIR__, 3); + $this->logDir = $repoRoot . '/var/logs/audit'; + } else { + $this->logDir = $logDir; + } + + // Create log directory if it doesn't exist + if ($this->enableFile && !is_dir($this->logDir)) { + if (!mkdir($this->logDir, 0755, true) && !is_dir($this->logDir)) { + throw new RuntimeException("Failed to create log directory: {$this->logDir}"); + } + } + + // Session ID for this logger instance + $this->sessionId = $this->generateSessionId(); + + // Log session start + $this->logSystemEvent('session_start', [ + 'service' => $this->service, + 'user' => $this->user, + 'session_id' => $this->sessionId, + ]); + } + + /** + * Generate unique session ID. + * + * @return string Session ID + */ + private function generateSessionId(): string + { + $timestamp = (new DateTime('now', new DateTimeZone('UTC')))->format('Ymd_His'); + $uniqueId = substr(bin2hex(random_bytes(4)), 0, 8); + return "{$timestamp}_{$uniqueId}"; + } + + /** + * Generate unique transaction ID. + * + * @return string Transaction ID (UUID v4) + */ + private function generateTransactionId(): string + { + return sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0xffff) + ); + } + + /** + * Get current log file path with rotation support. + * + * @return string Log file path + */ + private function getLogFilePath(): string + { + $dateStr = (new DateTime('now', new DateTimeZone('UTC')))->format('Ymd'); + return "{$this->logDir}/audit_{$this->service}_{$dateStr}.jsonl"; + } + + /** + * Check if log file should be rotated based on size. + * + * @param string $logFile Log file path + * @return bool True if should rotate + */ + private function shouldRotateLog(string $logFile): bool + { + if (!file_exists($logFile)) { + return false; + } + + $sizeMb = filesize($logFile) / (1024 * 1024); + return $sizeMb >= $this->maxLogSizeMb; + } + + /** + * Rotate log file if it exceeds size limit. + * + * @param string $logFile Log file path + */ + private function rotateLogIfNeeded(string $logFile): void + { + if ($this->shouldRotateLog($logFile)) { + $timestamp = (new DateTime('now', new DateTimeZone('UTC')))->format('His'); + $rotatedFile = preg_replace('/\.jsonl$/', ".{$timestamp}.jsonl", $logFile); + rename($logFile, $rotatedFile); + } + } + + /** + * Write log entry to file and/or console. + * + * @param array $entry Log entry data + */ + private function writeLogEntry(array $entry): void + { + // Add timestamp and session info + $entry['timestamp'] = (new DateTime('now', new DateTimeZone('UTC')))->format('c'); + $entry['session_id'] = $this->sessionId; + $entry['service'] = $this->service; + $entry['user'] = $this->user; + + // Console output + if ($this->enableConsole) { + $jsonOutput = json_encode($entry, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + echo "[AUDIT] {$jsonOutput}\n"; + } + + // File output + if ($this->enableFile) { + $logFile = $this->getLogFilePath(); + $this->rotateLogIfNeeded($logFile); + + $jsonLine = json_encode($entry, JSON_UNESCAPED_SLASHES) . "\n"; + file_put_contents($logFile, $jsonLine, FILE_APPEND | LOCK_EX); + } + } + + /** + * Log a system event. + * + * @param string $eventType Type of system event + * @param array $data Event data + */ + private function logSystemEvent(string $eventType, array $data = []): void + { + $entry = [ + 'event_type' => 'system', + 'event_subtype' => $eventType, + 'data' => $data, + ]; + $this->writeLogEntry($entry); + } + + /** + * Start a new transaction. + * + * @param string $operation Operation name + * @param array $context Additional context data + * @return AuditTransaction Transaction object + */ + public function startTransaction(string $operation, array $context = []): AuditTransaction + { + $transactionId = $this->generateTransactionId(); + $transaction = new AuditTransaction($this, $transactionId, $operation, $context); + $this->transactionStack[] = $transactionId; + return $transaction; + } + + /** + * End a transaction. + * + * @param string $transactionId Transaction ID to end + */ + public function endTransaction(string $transactionId): void + { + $key = array_search($transactionId, $this->transactionStack, true); + if ($key !== false) { + unset($this->transactionStack[$key]); + } + } + + /** + * Log an event within a transaction. + * + * @param string $transactionId Transaction ID + * @param string $eventType Event type + * @param array $data Event data + */ + public function logEvent(string $transactionId, string $eventType, array $data = []): void + { + $entry = [ + 'event_type' => 'audit', + 'transaction_id' => $transactionId, + 'event_subtype' => $eventType, + 'data' => $data, + ]; + $this->writeLogEntry($entry); + } + + /** + * Log a security event. + * + * @param string $transactionId Transaction ID + * @param string $eventType Security event type + * @param array $data Event data + */ + public function logSecurityEvent(string $transactionId, string $eventType, array $data = []): void + { + $entry = [ + 'event_type' => 'security', + 'transaction_id' => $transactionId, + 'event_subtype' => $eventType, + 'severity' => $data['severity'] ?? 'medium', + 'data' => $data, + ]; + $this->writeLogEntry($entry); + } + + /** + * Log a message with specified level. + * + * @param string $level Log level (info, warning, error) + * @param string $message Message to log + * @param array $data Additional data + */ + private function logMessage(string $level, string $message, array $data = []): void + { + $entry = [ + 'event_type' => 'log', + 'level' => $level, + 'message' => $message, + 'data' => $data, + ]; + $this->writeLogEntry($entry); + } + + /** + * Log an informational message. + * + * @param string $message Message to log + * @param array $data Additional data + */ + public function logInfo(string $message, array $data = []): void + { + $this->logMessage('info', $message, $data); + } + + /** + * Log a warning message. + * + * @param string $message Message to log + * @param array $data Additional data + */ + public function logWarning(string $message, array $data = []): void + { + $this->logMessage('warning', $message, $data); + } + + /** + * Log an error message. + * + * @param string $message Message to log + * @param array $data Additional data + */ + public function logError(string $message, array $data = []): void + { + $this->logMessage('error', $message, $data); + } +} + +/** + * Audit transaction context manager. + */ +class AuditTransaction +{ + private AuditLogger $logger; + private string $transactionId; + private string $operation; + private array $context; + private float $startTime; + + public function __construct( + AuditLogger $logger, + string $transactionId, + string $operation, + array $context = [] + ) { + $this->logger = $logger; + $this->transactionId = $transactionId; + $this->operation = $operation; + $this->context = $context; + $this->startTime = microtime(true); + + // Log transaction start + $this->logger->logEvent($this->transactionId, 'transaction_start', [ + 'operation' => $this->operation, + 'context' => $this->context, + ]); + } + + /** + * Get the transaction ID. + * + * @return string Transaction ID + */ + public function getTransactionId(): string + { + return $this->transactionId; + } + + /** + * Log an event within this transaction. + * + * @param string $eventType Event type + * @param array $data Event data + */ + public function logEvent(string $eventType, array $data = []): void + { + $this->logger->logEvent($this->transactionId, $eventType, $data); + } + + /** + * Log a security event within this transaction. + * + * @param string $eventType Security event type + * @param array $data Event data + */ + public function logSecurityEvent(string $eventType, array $data = []): void + { + $this->logger->logSecurityEvent($this->transactionId, $eventType, $data); + } + + /** + * End the transaction. + * + * @param string|null $status Transaction status (success|failure) + * @param array $result Transaction result data + */ + public function end(?string $status = 'success', array $result = []): void + { + $duration = microtime(true) - $this->startTime; + + $this->logger->logEvent($this->transactionId, 'transaction_end', [ + 'operation' => $this->operation, + 'status' => $status, + 'duration_seconds' => round($duration, 3), + 'result' => $result, + ]); + + $this->logger->endTransaction($this->transactionId); + } +} diff --git a/lib/Enterprise/CheckpointManager.php b/lib/Enterprise/CheckpointManager.php new file mode 100644 index 0000000..5656ea6 --- /dev/null +++ b/lib/Enterprise/CheckpointManager.php @@ -0,0 +1,152 @@ + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @package MokoStandards\Enterprise + * @version 04.00.04 + * @author MokoStandards Team + * @license GPL-3.0-or-later + */ + +namespace MokoEnterprise; + +use DateTime; +use DateTimeZone; +use Throwable; + +/** + * Manages checkpoints for recovery operations. + * + * Features: + * - Save/load checkpoint state + * - Automatic timestamp tracking + * - Checkpoint listing and cleanup + * - JSON-based state persistence + * + * Example: + * ```php + * $manager = new CheckpointManager('.checkpoints'); + * $manager->saveCheckpoint('operation', ['step' => 1, 'data' => 'value']); + * $state = $manager->loadCheckpoint('operation'); + * ``` + */ +class CheckpointManager +{ + private string $checkpointDir; + + public const VERSION = '04.06.00'; + + /** + * Initialize checkpoint manager. + * + * @param string $checkpointDir Directory to store checkpoints + */ + public function __construct(string $checkpointDir = '.checkpoints') + { + $this->checkpointDir = $checkpointDir; + + // Create checkpoint directory if it doesn't exist + if (!is_dir($this->checkpointDir)) { + if (!mkdir($this->checkpointDir, 0755, true) && !is_dir($this->checkpointDir)) { + throw new RecoveryError("Failed to create checkpoint directory: {$this->checkpointDir}"); + } + } + } + + /** + * Save a checkpoint. + * + * @param string $name Checkpoint name + * @param array $state State to save + * @return string Path to checkpoint file + * @throws RecoveryError + */ + public function saveCheckpoint(string $name, array $state): string + { + $timestamp = (new DateTime('now', new DateTimeZone('UTC')))->format('Ymd_His'); + $checkpointFile = "{$this->checkpointDir}/{$name}_{$timestamp}.json"; + + try { + $json = json_encode($state, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); + file_put_contents($checkpointFile, $json, LOCK_EX); + error_log("Checkpoint saved: {$checkpointFile}"); + return $checkpointFile; + } catch (Throwable $e) { + error_log("Failed to save checkpoint: {$e->getMessage()}"); + throw new RecoveryError("Checkpoint save failed: {$e->getMessage()}"); + } + } + + /** + * Load the most recent checkpoint for a name. + * + * @param string $name Checkpoint name + * @return array|null Checkpoint state or null if not found + */ + public function loadCheckpoint(string $name): ?array + { + $checkpoints = glob("{$this->checkpointDir}/{$name}_*.json"); + if ($checkpoints === false || empty($checkpoints)) { + return null; + } + + // Sort by filename (which includes timestamp) to get latest + sort($checkpoints); + $latest = end($checkpoints); + + try { + $json = file_get_contents($latest); + if ($json === false) { + error_log("Failed to read checkpoint: {$latest}"); + return null; + } + + $state = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + error_log("Checkpoint loaded: {$latest}"); + return $state; + } catch (Throwable $e) { + error_log("Failed to load checkpoint: {$e->getMessage()}"); + return null; + } + } + + /** + * List available checkpoints. + * + * @param string|null $name Filter by checkpoint name (optional) + * @return array List of checkpoint file paths + */ + public function listCheckpoints(?string $name = null): array + { + $pattern = $name ? "{$this->checkpointDir}/{$name}_*.json" : "{$this->checkpointDir}/*.json"; + $checkpoints = glob($pattern); + return $checkpoints !== false ? $checkpoints : []; + } + + /** + * Clean up old checkpoints. + * + * @param string|null $name Filter by checkpoint name (optional) + * @param int $keepLatest Number of latest checkpoints to keep + */ + public function cleanupCheckpoints(?string $name = null, int $keepLatest = 5): void + { + $checkpoints = $this->listCheckpoints($name); + sort($checkpoints); + + if (count($checkpoints) > $keepLatest) { + $toRemove = array_slice($checkpoints, 0, -$keepLatest); + foreach ($toRemove as $checkpoint) { + unlink($checkpoint); + error_log("Removed old checkpoint: {$checkpoint}"); + } + } + } +} diff --git a/lib/Enterprise/CliFramework.php b/lib/Enterprise/CliFramework.php new file mode 100644 index 0000000..46f8a56 --- /dev/null +++ b/lib/Enterprise/CliFramework.php @@ -0,0 +1,1422 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise.CLI + * INGROUP: MokoStandards.Enterprise + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/CliFramework.php + * VERSION: 04.06.00 + * BRIEF: CLI base classes — CliFramework (current) and CLIApp (legacy) + * NOTE: All new scripts must extend CliFramework, not CLIApp. + * CLIApp remains for backward-compatibility with existing scripts. + */ + +declare(strict_types=1); + +namespace MokoEnterprise; + +use DateTime; +use DateTimeZone; +use Exception; + +/** + * Base class for CLI applications with common functionality + */ +abstract class CLIApp +{ + private const VERSION = '04.06.00'; + + protected string $name; + protected string $description; + protected string $version; + protected array $options = []; + protected array $arguments = []; + protected bool $verbose = false; + protected bool $quiet = false; + protected bool $dryRun = false; + protected bool $jsonOutput = false; + + // Enterprise features + protected ?MetricsCollector $metrics = null; + protected ?object $auditLogger = null; + + public function __construct(string $name, string $description = '', string $version = self::VERSION) + { + $this->name = $name; + $this->description = $description ?: "{$name} - MokoStandards CLI Tool"; + $this->version = $version; + } + + /** + * Setup script-specific arguments + * + * Return an associative array where keys are option specs and values are descriptions. + * Option spec format: 'name:' for required value, 'name::' for optional value, 'name' for flag + * + * @return array Option specifications and descriptions + */ + abstract protected function setupArguments(): array; + + /** + * Main execution logic + * + * @return int Exit code (0 for success, non-zero for error) + */ + abstract protected function run(): int; + + /** + * Get common CLI options + * + * @return array Common options + */ + protected function getCommonOptions(): array + { + return [ + 'version' => 'Display version information', + 'verbose' => 'Enable verbose output', + 'v' => 'Alias for --verbose', + 'quiet' => 'Suppress non-error output', + 'q' => 'Alias for --quiet', + 'dry-run' => 'Perform dry run without making changes', + 'json' => 'Output results in JSON format', + 'metrics' => 'Collect and display metrics', + 'help' => 'Display this help message', + 'h' => 'Alias for --help', + ]; + } + + /** + * Parse command line arguments + */ + protected function parseArguments(): void + { + $shortOpts = 'vqh'; + $longOpts = [ + 'version', + 'verbose', + 'quiet', + 'dry-run', + 'json', + 'metrics', + 'help', + ]; + + // Add custom options + $customOpts = $this->setupArguments(); + foreach (array_keys($customOpts) as $opt) { + if (str_ends_with($opt, '::')) { + $longOpts[] = rtrim($opt, ':') . '::'; + } elseif (str_ends_with($opt, ':')) { + $longOpts[] = rtrim($opt, ':') . ':'; + } else { + $longOpts[] = $opt; + } + } + + // Use $GLOBALS['argv'] instead of getopt() so that bin/moko's in-process + // dispatch (via require) is respected. PHP's getopt() reads from the C-level + // argv set at process start and ignores runtime changes to $argv/$_SERVER['argv']. + $this->options = $this->parseArgv($GLOBALS['argv'] ?? [], $longOpts, $shortOpts); + $this->arguments = array_slice($GLOBALS['argv'] ?? [], 1); + + // Handle common flags + if (isset($this->options['version'])) { + echo "{$this->name} v{$this->version}\n"; + exit(0); + } + + if (isset($this->options['help']) || isset($this->options['h'])) { + $this->printHelp(); + exit(0); + } + + $this->verbose = isset($this->options['verbose']) || isset($this->options['v']); + $this->quiet = isset($this->options['quiet']) || isset($this->options['q']); + $this->dryRun = isset($this->options['dry-run']); + $this->jsonOutput = isset($this->options['json']); + } + + /** + * Parse an argv array using getopt-compatible option specs. + * + * Replaces PHP's getopt() so callers can inject a custom argv (e.g. bin/moko + * in-process dispatch sets $GLOBALS['argv'] before require-ing a script). + * + * @param list $argv Full argv including $argv[0] (script path) + * @param list $longOpts Long option specs, e.g. ['verbose', 'repos:', 'path::'] + * @param string $shortOpts Short option chars, e.g. 'vqh' + * @return array> Parsed options (same shape as getopt()) + */ + private function parseArgv(array $argv, array $longOpts, string $shortOpts): array + { + $result = []; + $tokens = array_slice($argv, 1); + + // Build lookup: option-name → 0=flag, 1=required-value, 2=optional-value + $specs = []; + foreach ($longOpts as $spec) { + if (str_ends_with($spec, '::')) { + $specs[rtrim($spec, ':')] = 2; + } elseif (str_ends_with($spec, ':')) { + $specs[rtrim($spec, ':')] = 1; + } else { + $specs[$spec] = 0; + } + } + for ($i = 0, $len = strlen($shortOpts); $i < $len; $i++) { + $c = $shortOpts[$i]; + if ($c === ':') { + continue; + } + $mode = 0; + if (isset($shortOpts[$i + 1]) && $shortOpts[$i + 1] === ':') { + $mode = isset($shortOpts[$i + 2]) && $shortOpts[$i + 2] === ':' ? 2 : 1; + } + $specs[$c] = $mode; + } + + for ($i = 0, $n = count($tokens); $i < $n; $i++) { + $tok = $tokens[$i]; + + if ($tok === '--') { + break; // end of options + } + + if (str_starts_with($tok, '--')) { + $name = substr($tok, 2); + $val = null; + if (str_contains($name, '=')) { + [$name, $val] = explode('=', $name, 2); + } + $mode = $specs[$name] ?? -1; + if ($mode === -1) { + continue; // unknown option + } + if ($mode === 1 && $val === null) { + $val = $tokens[++$i] ?? false; + } elseif ($mode === 0) { + $val = false; + } elseif ($mode === 2 && $val === null) { + $val = false; + } + // Support repeated options as arrays (matches getopt() behaviour) + if (array_key_exists($name, $result)) { + $result[$name] = array_merge((array) $result[$name], [$val]); + } else { + $result[$name] = $val; + } + } elseif (str_starts_with($tok, '-') && strlen($tok) > 1) { + $chars = substr($tok, 1); + for ($j = 0, $jn = strlen($chars); $j < $jn; $j++) { + $c = $chars[$j]; + $mode = $specs[$c] ?? -1; + if ($mode === -1) { + continue; + } + $val = false; + if ($mode === 1) { + $rest = substr($chars, $j + 1); + if ($rest !== '') { + $val = $rest; + $j = $jn; // consumed rest of cluster + } else { + $val = $tokens[++$i] ?? false; + } + } + $result[$c] = $val; + } + } + } + + return $result; + } + + /** + * Print help message + */ + protected function printHelp(): void + { + echo "{$this->description}\n\n"; + echo "Usage: {$this->name} [options]\n\n"; + echo "Options:\n"; + + $allOpts = array_merge($this->getCommonOptions(), $this->setupArguments()); + + foreach ($allOpts as $opt => $desc) { + $optName = rtrim($opt, ':'); + $hasValue = str_ends_with($opt, ':'); + $optDisplay = strlen($optName) === 1 ? "-{$optName}" : "--{$optName}"; + + if ($hasValue) { + $optDisplay .= ' '; + } + + echo sprintf(" %-30s %s\n", $optDisplay, $desc); + } + + echo "\n"; + } + + /** + * Setup logging + */ + protected function setupLogging(): void + { + if ($this->quiet) { + error_reporting(E_ERROR); + } elseif ($this->verbose) { + error_reporting(E_ALL); + } + } + + /** + * Setup enterprise features + */ + protected function setupEnterpriseFeatures(): void + { + if (isset($this->options['metrics'])) { + try { + $this->metrics = new MetricsCollector($this->name); + $this->log("Metrics collection enabled", 'INFO'); + } catch (Exception $e) { + $this->log("Metrics collection unavailable: " . $e->getMessage(), 'WARNING'); + } + } + } + + /** + * Execute the CLI application + * + * @return int Exit code + */ + public function execute(): int + { + try { + $this->parseArguments(); + $this->setupLogging(); + $this->setupEnterpriseFeatures(); + + $this->log("Starting {$this->name} v{$this->version}", 'INFO'); + + if ($this->dryRun) { + $this->log("DRY RUN MODE - No changes will be made", 'INFO'); + } + + $startTime = microtime(true); + + if ($this->metrics !== null) { + $timer = $this->metrics->startTimer('main_execution'); + $exitCode = $this->run(); + $timer->stop($exitCode === 0); + } else { + $exitCode = $this->run(); + } + + $duration = microtime(true) - $startTime; + $this->log(sprintf("Completed {$this->name} with exit code %d (%.2fs)", $exitCode, $duration), 'INFO'); + + if ($this->metrics !== null && !$this->quiet) { + $this->metrics->printSummary(); + } + + return $exitCode; + } catch (Exception $e) { + $this->log("Unhandled exception: " . $e->getMessage(), 'ERROR'); + if ($this->verbose) { + $this->log($e->getTraceAsString(), 'ERROR'); + } + return 1; + } + } + + /** + * Get option value + * + * @param string $name Option name + * @param mixed $default Default value if not set + * @return mixed Option value + */ + protected function getOption(string $name, $default = null) + { + return $this->options[$name] ?? $default; + } + + /** + * Check if option is set + * + * @param string $name Option name + * @return bool True if option is set + */ + protected function hasOption(string $name): bool + { + return isset($this->options[$name]); + } + + /** + * Log a message + * + * @param string $message Message to log + * @param string $level Log level (INFO, WARNING, ERROR) + */ + protected function log(string $message, string $level = 'INFO'): void + { + if ($this->quiet && $level !== 'ERROR') { + return; + } + + if (!$this->verbose && $level === 'DEBUG') { + return; + } + + $timestamp = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); + $formatted = "[{$timestamp}] {$level}: {$message}\n"; + + if ($level === 'ERROR') { + fwrite(STDERR, $formatted); + } else { + echo $formatted; + } + } + + /** + * Print result in appropriate format + * + * @param mixed $result Result to print + */ + protected function printResult($result): void + { + if ($this->jsonOutput) { + echo json_encode($result, JSON_PRETTY_PRINT) . "\n"; + } else { + if (is_array($result)) { + print_r($result); + } else { + echo $result . "\n"; + } + } + } + + /** + * Ask for user confirmation + * + * @param string $message Confirmation message + * @param bool $default Default response + * @return bool True if user confirms + */ + protected function confirm(string $message, bool $default = false): bool + { + if ($this->dryRun) { + $this->log("[DRY RUN] Would ask: {$message}", 'INFO'); + return false; + } + + $suffix = $default ? ' [Y/n]' : ' [y/N]'; + echo $message . $suffix . ': '; + + $handle = fopen('php://stdin', 'r'); + $response = trim(fgets($handle)); + fclose($handle); + + if (empty($response)) { + return $default; + } + + return in_array(strtolower($response), ['y', 'yes'], true); + } + + /** + * Print colored output (if terminal supports it) + * + * @param string $text Text to print + * @param string $color Color name (red, green, yellow, blue, cyan, gray, bold, dim) + */ + protected function printColored(string $text, string $color): void + { + $colors = [ + 'red' => "\033[31m", + 'green' => "\033[32m", + 'yellow' => "\033[33m", + 'blue' => "\033[34m", + 'cyan' => "\033[36m", + 'gray' => "\033[90m", + 'bold' => "\033[1m", + 'dim' => "\033[2m", + 'reset' => "\033[0m", + ]; + + if (isset($colors[$color]) && $this->isColorEnabled()) { + echo $colors[$color] . $text . $colors['reset']; + } else { + echo $text; + } + } + + // ========================================================================= + // Console graphics — visual primitives added to CLIApp + // ========================================================================= + + /** + * Return whether ANSI colour output is enabled. + * + * Disabled when --no-color is passed, NO_COLOR env var is set, or + * stdout is not an interactive terminal. + */ + protected function isColorEnabled(): bool + { + static $cache = null; + if ($cache !== null) { + return $cache; + } + if (in_array('--no-color', $_SERVER['argv'] ?? [], true) || getenv('NO_COLOR') !== false) { + return $cache = false; + } + return $cache = stream_isatty(STDOUT); + } + + /** + * Return the terminal width (defaults to 80). + */ + protected function termWidth(): int + { + $cols = (int) getenv('COLUMNS'); + return ($cols > 40) ? $cols : 80; + } + + /** + * Wrap text in an ANSI code; returns plain text when colour is off. + */ + protected function colorize(string $code, string $text): string + { + if (!$this->isColorEnabled()) { + return $text; + } + return $code . $text . "\033[0m"; + } + + /** + * Print a script header banner. + * + * @param string $name Script name (defaults to $this->name). + * @param string $desc One-line description. + * @param string $ver Version string (defaults to $this->version). + */ + protected function printBanner(string $name = '', string $desc = '', string $ver = ''): void + { + $name = $name ?: $this->name; + $ver = $ver ?: $this->version; + $w = min($this->termWidth(), 70); + $inner = $w - 2; + + $titlePad = str_pad(" {$name} v{$ver}", $inner); + $descPad = ($desc !== '') ? str_pad(" {$desc}", $inner) : null; + + echo "\n"; + echo $this->colorize("\033[36m", + "\u{250C}" . str_repeat("\u{2500}", $inner) . "\u{2510}") . "\n"; + echo $this->colorize("\033[36m", "\u{2502}") + . $this->colorize("\033[1m", $titlePad) + . $this->colorize("\033[36m", "\u{2502}") . "\n"; + if ($descPad !== null) { + echo $this->colorize("\033[36m", "\u{2502}") + . $this->colorize("\033[2m", $descPad) + . $this->colorize("\033[36m", "\u{2502}") . "\n"; + } + echo $this->colorize("\033[36m", + "\u{2514}" . str_repeat("\u{2500}", $inner) . "\u{2518}") . "\n\n"; + } + + /** + * Print a section header rule. + * + * Output example: ── Section Title ────────────────────────── + */ + protected function section(string $title): void + { + $w = $this->termWidth(); + $text = " {$title} "; + $fill = max(0, $w - strlen($text) - 4); + echo "\n"; + echo $this->colorize("\033[36m", + str_repeat("\u{2500}", 2) . $text . str_repeat("\u{2500}", $fill)) . "\n\n"; + } + + /** + * Print a plain horizontal divider. + */ + protected function printDivider(): void + { + echo $this->colorize("\033[2m", str_repeat("\u{2500}", $this->termWidth())) . "\n"; + } + + /** + * Print a single pass/fail status line. + * + * @param bool $passed Whether the check passed. + * @param string $label Check description. + * @param string $detail Optional detail in dim text. + */ + protected function statusLine(bool $passed, string $label, string $detail = ''): void + { + [$icon, $color] = $passed + ? ["\u{2713}", "\033[32m"] + : ["\u{2717}", "\033[31m"]; + + $suffix = ($detail !== '') + ? ' ' . $this->colorize("\033[2m", "— {$detail}") + : ''; + + echo ' ' . $this->colorize($color . "\033[1m", $icon) . ' ' . $label . $suffix . "\n"; + } + + /** + * Render an in-place progress bar. + * + * @param int $current Items done. + * @param int $total Total items. + * @param string $label Optional trailing label. + * @param bool $newline Finish bar with newline. + */ + protected function progress(int $current, int $total, string $label = '', bool $newline = false): void + { + $barWidth = min(30, $this->termWidth() - 22); + $filled = ($total > 0) ? (int) round($barWidth * $current / $total) : 0; + $pct = ($total > 0) ? (int) round(100 * $current / $total) : 0; + + $bar = $this->colorize("\033[32m", str_repeat("\u{2588}", $filled)) + . $this->colorize("\033[2m", str_repeat("\u{2591}", $barWidth - $filled)); + + $line = sprintf( + ' [%s] %s %s%s', + $bar, + $this->colorize("\033[1m", sprintf('%3d%%', $pct)), + $this->colorize("\033[2m", "({$current}/{$total})"), + ($label !== '') ? " {$label}" : '' + ); + + echo $newline ? "\r{$line}\n" : "\r{$line}"; + } + + /** + * Print a bordered summary box. + * + * @param array $rows Label => value pairs. + * @param bool|null $passed Border colour: green/red/cyan. + */ + protected function printSummaryBox(array $rows, ?bool $passed = null): void + { + $color = match ($passed) { + true => "\033[32m", + false => "\033[31m", + default => "\033[36m", + }; + + $maxKey = max(array_map('strlen', array_keys($rows))); + $inner = $maxKey + 20; + + echo "\n"; + echo $this->colorize($color, "\u{250C}" . str_repeat("\u{2500}", $inner) . "\u{2510}") . "\n"; + foreach ($rows as $label => $value) { + $valStr = (string) $value; + $padding = $inner - strlen($label) - strlen($valStr) - 4; + $row = ' ' . $this->colorize("\033[1m", $label) + . str_repeat(' ', max(1, $padding)) . $valStr . ' '; + echo $this->colorize($color, "\u{2502}") . $row . $this->colorize($color, "\u{2502}") . "\n"; + } + echo $this->colorize($color, "\u{2514}" . str_repeat("\u{2500}", $inner) . "\u{2518}") . "\n\n"; + } + + public function getVersion(): string + { + return $this->version; + } +} + +/** + * CLI for validation operations + */ +class ValidationCLI extends CLIApp +{ + protected function setupArguments(): array + { + return [ + 'check:' => 'Type of validation (all, paths, markdown, licenses, workflows, security)', + 'dir:' => 'Directory to validate (default: current directory)', + ]; + } + + protected function run(): int + { + $check = $this->getOption('check', 'all'); + $dir = $this->getOption('dir', '.'); + + $this->log("Running validation: {$check}", 'INFO'); + + try { + $validator = new UnifiedValidator(); + + if (in_array($check, ['all', 'paths'], true)) { + $validator->addPlugin(new PathValidatorPlugin()); + } + if (in_array($check, ['all', 'markdown'], true)) { + $validator->addPlugin(new MarkdownValidatorPlugin()); + } + + $context = [ + 'paths' => [$dir], + 'scan_dir' => $dir, + ]; + + $results = $validator->validateAll($context); + + if (!$this->jsonOutput) { + $validator->printSummary(); + } else { + $resultData = array_map(function ($r) { + return [ + 'plugin' => $r->pluginName, + 'passed' => $r->passed, + 'message' => $r->message, + 'details' => $r->details, + ]; + }, $results); + $this->printResult($resultData); + } + + return $validator->allPassed() ? 0 : 1; + } catch (Exception $e) { + $this->log("Validation error: " . $e->getMessage(), 'ERROR'); + return 1; + } + } +} + +// ============================================================================= +// CliFramework — current base class for all MokoStandards CLI scripts +// ============================================================================= + +/** + * Base class for MokoStandards CLI scripts. + * + * Provides argument parsing, a structured lifecycle, and a full console + * graphics system (banners, coloured log levels, progress bars, status + * lines, section headers, summary boxes) that gracefully degrades when the + * terminal does not support ANSI escape codes. + * + * Lifecycle: configure() -> parseArguments() -> printBanner() -> initialize() -> run() + * + * All new scripts must extend CliFramework and implement configure() + run(). + */ +abstract class CliFramework +{ + // ------------------------------------------------------------------------- + // ANSI colour constants + // ------------------------------------------------------------------------- + + protected const C_RESET = "\033[0m"; + protected const C_BOLD = "\033[1m"; + protected const C_DIM = "\033[2m"; + protected const C_RED = "\033[31m"; + protected const C_GREEN = "\033[32m"; + protected const C_YELLOW = "\033[33m"; + protected const C_BLUE = "\033[34m"; + protected const C_MAGENTA = "\033[35m"; + protected const C_CYAN = "\033[36m"; + protected const C_GRAY = "\033[90m"; + + // ------------------------------------------------------------------------- + // Unicode graphic characters + // ------------------------------------------------------------------------- + + protected const ICON_OK = "\u{2713}"; // ✓ + protected const ICON_FAIL = "\u{2717}"; // ✗ + protected const ICON_WARN = "\u{26A0}"; // ⚠ + protected const ICON_INFO = "\u{2192}"; // → + protected const ICON_DRY = "\u{25CC}"; // ◌ + + protected const BOX_H = "\u{2500}"; // ─ + protected const BOX_V = "\u{2502}"; // │ + protected const BOX_TL = "\u{250C}"; // ┌ + protected const BOX_TR = "\u{2510}"; // ┐ + protected const BOX_BL = "\u{2514}"; // └ + protected const BOX_BR = "\u{2518}"; // ┘ + + protected const BAR_FILL = "\u{2588}"; // █ + protected const BAR_EMPTY = "\u{2591}"; // ░ + + // ------------------------------------------------------------------------- + // Script properties (set by configure()) + // ------------------------------------------------------------------------- + + /** @var string One-line description shown in the banner. */ + private string $description = ''; + + /** @var string Script name. */ + private string $scriptName = ''; + + /** @var string Script version shown in the banner. */ + private string $scriptVersion = '04.00.15'; + + // ------------------------------------------------------------------------- + // Argument definitions registered via addArgument() + // ------------------------------------------------------------------------- + + /** @var array */ + private array $argDefs = []; + + /** @var array Parsed argument values. */ + private array $parsedArgs = []; + + // ------------------------------------------------------------------------- + // Runtime flags (set from CLI arguments) + // ------------------------------------------------------------------------- + + protected bool $quiet = false; + protected bool $verbose = false; + protected bool $dryRun = false; + + // ------------------------------------------------------------------------- + // Internal state + // ------------------------------------------------------------------------- + + /** @var bool|null Cached terminal-colour detection result. */ + private ?bool $colorEnabled = null; + + /** @var bool Whether a progress bar is currently active (needs clearing). */ + private bool $progressActive = false; + + /** @var float Script start time for elapsed-time reporting. */ + private float $startTime; + + // ========================================================================= + // Constructor + // ========================================================================= + + /** + * @param string $name Script name (e.g. 'check_changelog'). + * @param string $version Script version string. + */ + public function __construct(string $name = '', string $version = '04.00.15') + { + $this->scriptName = $name ?: basename($_SERVER['argv'][0] ?? 'script', '.php'); + $this->scriptVersion = $version; + $this->startTime = microtime(true); + } + + // ========================================================================= + // Abstract methods — implement in each script + // ========================================================================= + + /** + * Register arguments and set the description. + * Called automatically by execute() before argument parsing. + */ + abstract protected function configure(): void; + + /** + * Main script logic. + * + * @return int Exit code: 0 = success, 1 = failure, 2 = misuse. + */ + abstract protected function run(): int; + + // ========================================================================= + // Optional override + // ========================================================================= + + /** + * Post-parse initialisation hook. + * Override to set up services after arguments are available. + */ + protected function initialize(): void + { + } + + // ========================================================================= + // Lifecycle + // ========================================================================= + + /** + * Run the script: configure -> parse -> banner -> initialize -> run. + * + * @return int Exit code. + */ + public function execute(): int + { + $this->configure(); + $this->parseArguments(); + + if ($this->hasRawArg('--help') || $this->hasRawArg('-h')) { + $this->printHelp(); + return 0; + } + + $this->quiet = $this->hasRawArg('--quiet') || $this->hasRawArg('-q'); + $this->verbose = $this->hasRawArg('--verbose') || $this->hasRawArg('-v'); + $this->dryRun = $this->hasRawArg('--dry-run'); + + if (!$this->quiet) { + $this->printBanner(); + } + + if ($this->dryRun && !$this->quiet) { + $this->printDryRunNotice(); + } + + $this->initialize(); + + try { + $code = $this->run(); + } catch (\Exception $e) { + $this->clearProgress(); + $this->log('ERROR', $e->getMessage()); + return 1; + } + + return $code; + } + + // ========================================================================= + // Argument registration + // ========================================================================= + + /** + * Set the one-line description shown in the banner and help. + */ + protected function setDescription(string $desc): void + { + $this->description = $desc; + } + + /** + * Register an argument. + * + * @param string $name Argument name with dashes, e.g. '--path'. + * @param string $desc Short description for the help screen. + * @param mixed $default Default value; pass false for boolean flags. + */ + protected function addArgument(string $name, string $desc, mixed $default = null): void + { + $this->argDefs[$name] = ['desc' => $desc, 'default' => $default]; + } + + /** + * Get a parsed argument value. + * + * @param string $name Argument name, e.g. '--path'. + * @param mixed $fallback Override the registered default for this call. + * @return mixed + */ + protected function getArgument(string $name, mixed $fallback = null): mixed + { + if (array_key_exists($name, $this->parsedArgs)) { + return $this->parsedArgs[$name]; + } + if ($fallback !== null) { + return $fallback; + } + return $this->argDefs[$name]['default'] ?? null; + } + + // ========================================================================= + // Argument parsing (internal) + // ========================================================================= + + private function parseArguments(): void + { + $argv = array_slice($_SERVER['argv'] ?? [], 1); + $len = count($argv); + + for ($i = 0; $i < $len; $i++) { + $token = $argv[$i]; + if (!str_starts_with($token, '-')) { + continue; + } + if (str_contains($token, '=')) { + [$key, $val] = explode('=', $token, 2); + $this->parsedArgs[$key] = $val; + } elseif ( + isset($argv[$i + 1]) + && !str_starts_with($argv[$i + 1], '-') + && isset($this->argDefs[$token]) + && $this->argDefs[$token]['default'] !== false + ) { + $this->parsedArgs[$token] = $argv[$i + 1]; + $i++; + } else { + $this->parsedArgs[$token] = true; + } + } + } + + /** Check if a raw flag was passed on the command line. */ + private function hasRawArg(string $flag): bool + { + return in_array($flag, $_SERVER['argv'] ?? [], true) + || array_key_exists($flag, $this->parsedArgs); + } + + // ========================================================================= + // Help screen + // ========================================================================= + + protected function printHelp(): void + { + $w = $this->termWidth(); + echo $this->c(self::C_BOLD . self::C_CYAN, $this->scriptName); + if ($this->description !== '') { + echo ' — ' . $this->description; + } + echo "\n"; + echo $this->c(self::C_DIM, str_repeat(self::BOX_H, $w)) . "\n\n"; + echo $this->c(self::C_BOLD, 'Usage:') . " php {$this->scriptName}.php [options]\n\n"; + echo $this->c(self::C_BOLD, 'Options:') . "\n"; + + $builtIn = [ + '--help' => ['desc' => 'Show this help message', 'default' => null], + '--dry-run' => ['desc' => 'Preview changes without writing', 'default' => null], + '--verbose' => ['desc' => 'Show detailed output', 'default' => null], + '--quiet' => ['desc' => 'Suppress all non-error output', 'default' => null], + '--no-color' => ['desc' => 'Disable ANSI colour output', 'default' => null], + ]; + + foreach (array_merge($this->argDefs, $builtIn) as $name => $def) { + $default = $def['default']; + $hint = ($default !== null && $default !== false) + ? $this->c(self::C_DIM, " (default: {$default})") + : ''; + printf(" %s%-22s%s%s%s\n", + self::C_CYAN, $name, self::C_RESET, $def['desc'], $hint); + } + echo "\n"; + } + + // ========================================================================= + // Console graphics — banner + // ========================================================================= + + /** Print the script header banner with name, description, and version. */ + protected function printBanner(): void + { + $w = min($this->termWidth(), 70); + $inner = $w - 2; + $name = $this->scriptName; + $ver = 'v' . $this->scriptVersion; + $desc = $this->description; + + $titleRaw = " {$name} {$ver}"; + $titleStyled = " {$name} " . $this->c(self::C_DIM, $ver); + $titleLine = $this->padRight($titleStyled, $inner, strlen($titleRaw)); + $descLine = ($desc !== '') ? $this->padRight(" {$desc}", $inner) : null; + + echo "\n"; + echo $this->c(self::C_CYAN, + self::BOX_TL . str_repeat(self::BOX_H, $inner) . self::BOX_TR) . "\n"; + echo $this->c(self::C_CYAN, self::BOX_V) + . $this->c(self::C_BOLD, $titleLine) + . $this->c(self::C_CYAN, self::BOX_V) . "\n"; + if ($descLine !== null) { + echo $this->c(self::C_CYAN, self::BOX_V) + . $this->c(self::C_DIM, $descLine) + . $this->c(self::C_CYAN, self::BOX_V) . "\n"; + } + echo $this->c(self::C_CYAN, + self::BOX_BL . str_repeat(self::BOX_H, $inner) . self::BOX_BR) . "\n\n"; + } + + /** Print the dry-run notice box. */ + protected function printDryRunNotice(): void + { + $w = min($this->termWidth(), 70); + $msg = ' ' . self::ICON_DRY . ' DRY-RUN MODE — no changes will be written '; + $row = $this->padRight($msg, $w - 2); + echo $this->c(self::C_YELLOW . self::C_BOLD, + self::BOX_TL . str_repeat(self::BOX_H, $w - 2) . self::BOX_TR) . "\n"; + echo $this->c(self::C_YELLOW . self::C_BOLD, + self::BOX_V . $row . self::BOX_V) . "\n"; + echo $this->c(self::C_YELLOW . self::C_BOLD, + self::BOX_BL . str_repeat(self::BOX_H, $w - 2) . self::BOX_BR) . "\n\n"; + } + + // ========================================================================= + // Console graphics — sections and dividers + // ========================================================================= + + /** + * Print a section header line. + * + * Output example: ── Scanning Files ───────────────────────────── + */ + protected function section(string $title): void + { + if ($this->quiet) { + return; + } + $this->clearProgress(); + $w = $this->termWidth(); + $text = " {$title} "; + $fill = max(0, $w - strlen($text) - 4); + echo "\n"; + echo $this->c(self::C_CYAN, + str_repeat(self::BOX_H, 2) . $text . str_repeat(self::BOX_H, $fill)) . "\n\n"; + } + + /** Print a plain horizontal divider. */ + protected function printDivider(): void + { + if ($this->quiet) { + return; + } + $this->clearProgress(); + echo $this->c(self::C_DIM, str_repeat(self::BOX_H, $this->termWidth())) . "\n"; + } + + // ========================================================================= + // Console graphics — logging + // ========================================================================= + + /** + * Log a message with a level badge and timestamp. + * + * Two calling conventions: + * log('INFO', 'message') — explicit level + * log('message') — defaults to INFO + * + * @param string $levelOrMessage Level (INFO|SUCCESS|WARNING|ERROR|DEBUG) or message. + * @param string $message Message text when first arg is a level name. + */ + protected function log(string $levelOrMessage, string $message = ''): void + { + if ($message === '') { + $level = 'INFO'; + $text = $levelOrMessage; + } else { + $level = strtoupper($levelOrMessage); + $text = $message; + } + + if ($this->quiet && !in_array($level, ['ERROR', 'WARNING'], true)) { + return; + } + if (!$this->verbose && $level === 'DEBUG') { + return; + } + + $this->clearProgress(); + [$icon, $color] = $this->levelStyle($level); + + $badge = $this->c($color . self::C_BOLD, sprintf('[%-7s]', $level)); + $icon = $this->c($color, $icon); + $ts = $this->c(self::C_GRAY, + (new \DateTime('now', new \DateTimeZone('UTC')))->format('H:i:s')); + + $line = "{$ts} {$icon} {$badge} {$text}\n"; + + if ($level === 'ERROR') { + fwrite(STDERR, $line); + } else { + echo $line; + } + } + + /** Log a success message. */ + protected function success(string $message): void + { + $this->log('SUCCESS', $message); + } + + /** Log a warning message. */ + protected function warning(string $message): void + { + $this->log('WARNING', $message); + } + + /** Alias for warning(). */ + protected function warn(string $message): void + { + $this->warning($message); + } + + /** + * Log an error message and exit. + * + * @param string $message Error description. + * @param int $exitCode Process exit code. + * @return never + */ + protected function error(string $message, int $exitCode = 1): never + { + $this->clearProgress(); + $this->log('ERROR', $message); + exit($exitCode); + } + + // ========================================================================= + // Console graphics — status lines (individual check results) + // ========================================================================= + + /** + * Print a single check-result status line. + * + * Output examples: + * ✓ CHANGELOG.md exists + * ✗ README.md missing — expected at repo root + * + * @param bool $passed Whether the check passed. + * @param string $label Check description. + * @param string $detail Optional detail shown in dim text. + */ + protected function status(bool $passed, string $label, string $detail = ''): void + { + if ($this->quiet) { + return; + } + $this->clearProgress(); + + [$icon, $color] = $passed + ? [self::ICON_OK, self::C_GREEN] + : [self::ICON_FAIL, self::C_RED]; + + $suffix = ($detail !== '') + ? ' ' . $this->c(self::C_DIM, "— {$detail}") + : ''; + + echo ' ' . $this->c($color . self::C_BOLD, $icon) . ' ' . $label . $suffix . "\n"; + } + + // ========================================================================= + // Console graphics — progress bar + // ========================================================================= + + /** + * Render an in-place progress bar (overwrites the current terminal line). + * + * Call with $newline = true on the final item to finalise the bar. + * + * @param int $current Items processed so far. + * @param int $total Total items. + * @param string $label Optional label shown after the bar. + * @param bool $newline Finalise the bar with a newline. + */ + protected function progress(int $current, int $total, string $label = '', bool $newline = false): void + { + if ($this->quiet) { + return; + } + + $barWidth = min(30, $this->termWidth() - 22); + $filled = ($total > 0) ? (int) round($barWidth * $current / $total) : 0; + $pct = ($total > 0) ? (int) round(100 * $current / $total) : 0; + + $bar = $this->c(self::C_GREEN, str_repeat(self::BAR_FILL, $filled)) + . $this->c(self::C_DIM, str_repeat(self::BAR_EMPTY, $barWidth - $filled)); + + $counter = $this->c(self::C_DIM, "({$current}/{$total})"); + $percent = $this->c(self::C_BOLD, sprintf('%3d%%', $pct)); + $suffix = ($label !== '') ? " {$label}" : ''; + + $line = " [{$bar}] {$percent} {$counter}{$suffix}"; + + if ($newline) { + echo "\r{$line}\n"; + $this->progressActive = false; + } else { + echo "\r{$line}"; + $this->progressActive = true; + } + } + + /** Clear the active progress bar line. */ + protected function clearProgress(): void + { + if ($this->progressActive) { + echo "\r" . str_repeat(' ', $this->termWidth()) . "\r"; + $this->progressActive = false; + } + } + + // ========================================================================= + // Console graphics — summary box + // ========================================================================= + + /** + * Print a bordered summary box from a label => value map. + * + * @param array $rows Label => value pairs. + * @param bool|null $passed Colours the border green/red/neutral. + */ + protected function printSummaryBox(array $rows, ?bool $passed = null): void + { + if ($this->quiet) { + return; + } + $this->clearProgress(); + + $color = match ($passed) { + true => self::C_GREEN, + false => self::C_RED, + default => self::C_CYAN, + }; + + $maxKey = max(array_map('strlen', array_keys($rows))); + $inner = $maxKey + 20; + + echo "\n"; + echo $this->c($color, self::BOX_TL . str_repeat(self::BOX_H, $inner) . self::BOX_TR) . "\n"; + + foreach ($rows as $label => $value) { + $valStr = (string) $value; + $valVis = strlen((string) preg_replace('/\033\[[0-9;]*m/', '', $valStr)); + $padding = $inner - strlen($label) - $valVis - 4; + $row = ' ' . $this->c(self::C_BOLD, $label) + . str_repeat(' ', max(1, $padding)) . $valStr . ' '; + echo $this->c($color, self::BOX_V) . $row . $this->c($color, self::BOX_V) . "\n"; + } + + echo $this->c($color, self::BOX_BL . str_repeat(self::BOX_H, $inner) . self::BOX_BR) . "\n\n"; + } + + /** + * Print a standardised pass/fail summary. + * + * @param int $passed Checks that passed. + * @param int $failed Checks that failed. + * @param float $elapsed Elapsed seconds (omit row when 0). + */ + protected function printSummary(int $passed, int $failed, float $elapsed = 0.0): void + { + $total = $passed + $failed; + $score = ($total > 0) ? (int) round(100 * $passed / $total) : 0; + $ok = $failed === 0; + + $rows = [ + 'Checks' => $total, + 'Passed' => $passed . ' ' . $this->c(self::C_GREEN, self::ICON_OK), + 'Failed' => $failed . ($failed > 0 ? ' ' . $this->c(self::C_RED, self::ICON_FAIL) : ''), + 'Score' => "{$score}%", + ]; + if ($elapsed > 0.0) { + $rows['Elapsed'] = sprintf('%.2fs', $elapsed); + } + + $this->printSummaryBox($rows, $ok); + } + + // ========================================================================= + // Console graphics — step indicator + // ========================================================================= + + /** + * Print a numbered step header. + * + * Output example: Step 2/5 → Running security checks + */ + protected function step(int $current, int $total, string $title): void + { + if ($this->quiet) { + return; + } + $this->clearProgress(); + $badge = $this->c(self::C_BOLD . self::C_MAGENTA, "Step {$current}/{$total}"); + $arrow = $this->c(self::C_DIM, self::ICON_INFO); + echo "\n{$badge} {$arrow} {$title}\n"; + } + + // ========================================================================= + // Colour helpers + // ========================================================================= + + /** + * Wrap $text in ANSI codes; returns plain $text when colour is disabled. + * + * When called with only $codes (no $text), returns the raw ANSI string + * for use in string concatenation. + * + * @param string $codes Concatenated ANSI escape sequences. + * @param string $text Text to wrap (optional). + */ + protected function c(string $codes, string $text = ''): string + { + if (!$this->isColorEnabled()) { + return $text; + } + if ($text === '') { + return $codes; + } + return $codes . $text . self::C_RESET; + } + + /** + * Return whether ANSI colour output is enabled. + * + * Disabled when --no-color is passed, NO_COLOR env var is set + * (https://no-color.org), or stdout is not an interactive terminal. + */ + protected function isColorEnabled(): bool + { + if ($this->colorEnabled !== null) { + return $this->colorEnabled; + } + if ($this->hasRawArg('--no-color') || getenv('NO_COLOR') !== false) { + return $this->colorEnabled = false; + } + return $this->colorEnabled = stream_isatty(STDOUT); + } + + /** + * Return the terminal width (defaults to 80 when not detectable). + */ + protected function termWidth(): int + { + $cols = (int) getenv('COLUMNS'); + return ($cols > 40) ? $cols : 80; + } + + /** + * Return elapsed seconds since the script started. + */ + protected function elapsed(): float + { + return microtime(true) - $this->startTime; + } + + // ========================================================================= + // Internal helpers + // ========================================================================= + + /** + * Return [icon, ANSI colour code] for a given log level. + * + * @return array{0: string, 1: string} + */ + private function levelStyle(string $level): array + { + return match ($level) { + 'SUCCESS' => [self::ICON_OK, self::C_GREEN], + 'ERROR' => [self::ICON_FAIL, self::C_RED], + 'WARNING' => [self::ICON_WARN, self::C_YELLOW], + 'DEBUG' => ['.', self::C_DIM], + default => [self::ICON_INFO, self::C_CYAN], + }; + } + + /** + * Right-pad a string to the given visible width, ignoring ANSI codes. + * + * @param string $text Text to pad (may contain ANSI codes). + * @param int $width Target visible width. + * @param int $visibleLength Override auto-detected visible length. + */ + private function padRight(string $text, int $width, int $visibleLength = -1): string + { + if ($visibleLength < 0) { + $stripped = (string) preg_replace('/\033\[[0-9;]*m/', '', $text); + $visibleLength = strlen($stripped); + } + return $text . str_repeat(' ', max(0, $width - $visibleLength)); + } +} diff --git a/lib/Enterprise/Config.php b/lib/Enterprise/Config.php new file mode 100644 index 0000000..fca45f9 --- /dev/null +++ b/lib/Enterprise/Config.php @@ -0,0 +1,445 @@ + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @package MokoStandards\Enterprise + * @version 04.00.04 + * @author MokoStandards Team + * @license GPL-3.0-or-later + */ + +namespace MokoEnterprise; + +use RuntimeException; + +/** + * Exception raised when configuration validation fails. + */ +class ConfigValidationError extends RuntimeException +{ +} + +/** + * Enterprise configuration manager with environment support. + * + * Features: + * - Environment-based configuration + * - Dot notation for nested access (e.g., 'github.rate_limit') + * - Runtime overrides + * - Type-safe getters + * - Default value fallbacks + * - Environment detection (dev/staging/production) + * + * Example: + * ```php + * $config = Config::load(); + * $org = $config->get('github.organization'); + * $rateLimit = $config->getInt('github.rate_limit', 5000); + * $isProduction = $config->isProduction(); + * ``` + */ +class Config +{ + /** @var array Default configuration values */ + private const DEFAULT_CONFIG = [ + 'version' => '04.00.04', + 'environment' => 'development', + 'platform' => 'github', + 'github' => [ + 'organization' => 'mokoconsulting-tech', + 'rate_limit' => 5000, + 'max_retries' => 3, + 'timeout' => 30, + ], + 'gitea' => [ + 'url' => 'https://git.mokoconsulting.tech', + 'organization' => 'mokoconsulting-tech', + 'rate_limit' => 5000, + 'max_retries' => 3, + 'timeout' => 30, + ], + 'logging' => [ + 'level' => 'INFO', + 'format' => 'json', + 'directory' => 'logs', + 'retention_days' => 90, + ], + 'audit' => [ + 'enabled' => true, + 'directory' => 'var/logs/audit', + 'max_file_size_mb' => 20, + 'retention_days' => 90, + ], + 'cache' => [ + 'enabled' => true, + 'ttl_seconds' => 300, + ], + 'circuit_breaker' => [ + 'enabled' => true, + 'threshold' => 5, + 'timeout_seconds' => 60, + ], + ]; + + /** @var array Configuration data */ + private array $configData; + + /** @var string Current environment */ + private string $environment; + + /** @var array Runtime override data */ + private array $overrideData = []; + + public const VERSION = '04.06.00'; + + /** + * Constructor. + * + * @param array $configData Configuration data + * @param string $environment Environment name + */ + public function __construct(array $configData, string $environment = 'development') + { + $this->configData = $configData; + $this->environment = $environment; + } + + /** + * Load configuration from environment. + * + * @param string|null $env Environment override (null = auto-detect) + * @return self Configuration instance + */ + public static function load(?string $env = null): self + { + // Detect environment from env var or default to development + $env = $env ?? $_ENV['MOKO_ENV'] ?? getenv('MOKO_ENV') ?: 'development'; + + // Start with default config + $configData = self::DEFAULT_CONFIG; + $configData['environment'] = $env; + + // Load from .env file if exists using vlucas/phpdotenv + $repoRoot = dirname(__DIR__, 2); + if (file_exists($repoRoot . '/.env')) { + // Note: In production, you'd use Dotenv::createImmutable() here + // For now, we'll manually parse simple .env files + self::loadEnvFile($repoRoot . '/.env'); + } + + // Override with environment variables + self::applyEnvironmentOverrides($configData); + + return new self($configData, $env); + } + + /** + * Load environment variables from .env file. + * + * @param string $envFile Path to .env file + */ + private static function loadEnvFile(string $envFile): void + { + if (!is_readable($envFile)) { + return; + } + + $lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines === false) { + return; + } + + foreach ($lines as $line) { + // Skip comments + if (str_starts_with(trim($line), '#')) { + continue; + } + + // Parse KEY=VALUE format + if (strpos($line, '=') !== false) { + [$key, $value] = explode('=', $line, 2); + $key = trim($key); + $value = trim($value); + + // Remove quotes if present + if (preg_match('/^(["\'])(.*)\\1$/', $value, $matches)) { + $value = $matches[2]; + } + + // Set environment variable + putenv("$key=$value"); + $_ENV[$key] = $value; + } + } + } + + /** + * Apply environment variable overrides to config. + * + * @param array &$configData Configuration data to modify + */ + private static function applyEnvironmentOverrides(array &$configData): void + { + // Platform selection: GIT_PLATFORM env var overrides default + if ($platform = getenv('GIT_PLATFORM')) { + $configData['platform'] = strtolower($platform); + } + + // GitHub token resolution (in priority order): + // 1. GH_TOKEN env var (GitHub Actions org/repo secret) + // 2. GITHUB_TOKEN env var (GitHub Actions built-in) + // 3. `gh auth token` from the gh CLI (local developer machines) + $token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN') ?: self::resolveGhCliToken(); + if (!empty($token)) { + $configData['github']['token'] = $token; + } + if ($org = getenv('GITHUB_ORG')) { + $configData['github']['organization'] = $org; + } + + // Gitea token resolution: GITEA_TOKEN env var + $giteaToken = getenv('GITEA_TOKEN') ?: ''; + if (!empty($giteaToken)) { + $configData['gitea']['token'] = $giteaToken; + } + if ($giteaUrl = getenv('GITEA_URL')) { + $configData['gitea']['url'] = rtrim($giteaUrl, '/'); + } + if ($giteaOrg = getenv('GITEA_ORG')) { + $configData['gitea']['organization'] = $giteaOrg; + } + + // Logging configuration + if ($logLevel = getenv('LOG_LEVEL')) { + $configData['logging']['level'] = $logLevel; + } + } + + /** + * Attempt to retrieve a GitHub token from the gh CLI. + * + * Runs `gh auth token` non-interactively (stdin from null device) and + * validates the output matches a known GitHub token prefix before returning + * it. Returns an empty string when gh is not installed, not authenticated, + * or the output is not a recognisable token. + */ + private static function resolveGhCliToken(): string + { + $nullDevice = PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null'; + $proc = proc_open( + ['gh', 'auth', 'token'], + [0 => ['file', $nullDevice, 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + if (!is_resource($proc)) { + return ''; + } + $output = trim(stream_get_contents($pipes[1])); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($proc); + + // Accept only strings that look like a real GitHub token + return preg_match('/^(ghp_|github_pat_|gho_|ghu_|ghs_)\S+$/', $output) ? $output : ''; + } + + /** + * Get configuration value with dot notation. + * + * @param string $key Configuration key (e.g., 'github.rate_limit') + * @param mixed $default Default value if key not found + * @return mixed Configuration value + */ + public function get(string $key, mixed $default = null): mixed + { + // Check runtime overrides first + if (array_key_exists($key, $this->overrideData)) { + return $this->overrideData[$key]; + } + + // Navigate nested configuration using dot notation + $value = $this->configData; + foreach (explode('.', $key) as $part) { + if (is_array($value) && array_key_exists($part, $value)) { + $value = $value[$part]; + } else { + return $default; + } + } + + return $value; + } + + /** + * Set configuration value (runtime override). + * + * @param string $key Configuration key + * @param mixed $value Value to set + */ + public function set(string $key, mixed $value): void + { + $this->overrideData[$key] = $value; + } + + /** + * Get integer value. + * + * @param string $key Configuration key + * @param int $default Default value + * @return int Integer value + */ + public function getInt(string $key, int $default = 0): int + { + $value = $this->get($key, $default); + return is_numeric($value) ? (int) $value : $default; + } + + /** + * Get string value. + * + * @param string $key Configuration key + * @param string $default Default value + * @return string String value + */ + public function getString(string $key, string $default = ''): string + { + $value = $this->get($key, $default); + return is_scalar($value) ? (string) $value : $default; + } + + /** + * Get boolean value. + * + * @param string $key Configuration key + * @param bool $default Default value + * @return bool Boolean value + */ + public function getBool(string $key, bool $default = false): bool + { + $value = $this->get($key, $default); + + // Handle string representations of booleans + if (is_string($value)) { + $value = strtolower($value); + if (in_array($value, ['true', '1', 'yes', 'on'], true)) { + return true; + } + if (in_array($value, ['false', '0', 'no', 'off'], true)) { + return false; + } + } + + return (bool) $value; + } + + /** + * Get entire configuration section. + * + * @param string $section Section name + * @return array Section data + */ + public function getSection(string $section): array + { + $value = $this->get($section, []); + return is_array($value) ? $value : []; + } + + /** + * Get current environment. + * + * @return string Environment name + */ + public function getEnvironment(): string + { + return $this->environment; + } + + /** + * Check if production environment. + * + * @return bool True if production + */ + public function isProduction(): bool + { + return in_array($this->environment, ['production', 'prod'], true); + } + + /** + * Check if development environment. + * + * @return bool True if development + */ + public function isDevelopment(): bool + { + return in_array($this->environment, ['development', 'dev'], true); + } + + /** + * Check if staging environment. + * + * @return bool True if staging + */ + public function isStaging(): bool + { + return in_array($this->environment, ['staging', 'stage'], true); + } + + /** + * Get all configuration data. + * + * @return array All configuration + */ + public function all(): array + { + return array_merge($this->configData, $this->overrideData); + } + + /** + * Validate required configuration keys exist. + * + * @param array $requiredKeys Required configuration keys + * @throws ConfigValidationError If validation fails + */ + public function validate(array $requiredKeys): void + { + $missing = []; + + foreach ($requiredKeys as $key) { + if ($this->get($key) === null) { + $missing[] = $key; + } + } + + if (!empty($missing)) { + throw new ConfigValidationError( + 'Missing required configuration keys: ' . implode(', ', $missing) + ); + } + } + + /** + * String representation. + * + * @return string + */ + public function __toString(): string + { + return "Config(environment='{$this->environment}')"; + } +} diff --git a/lib/Enterprise/DefinitionParser.php b/lib/Enterprise/DefinitionParser.php new file mode 100644 index 0000000..5f445df --- /dev/null +++ b/lib/Enterprise/DefinitionParser.php @@ -0,0 +1,499 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/DefinitionParser.php + * VERSION: 04.06.00 + * BRIEF: Parses Terraform HCL repository definition files into a flat sync-file list + */ + +declare(strict_types=1); + +namespace MokoEnterprise; + +/** + * Definition Parser + * + * Parses the Terraform HCL repository definition files stored in + * api/definitions/default/ and returns a flat list of file sync entries. + * + * File blocks that carry either a `template` field (external file path) or a + * `stub_content` heredoc (inline content) are returned — these are the files + * that the bulk-sync process should push to remote repositories. + * + * When both `stub_content` and `template` are present in the same block, + * `stub_content` takes priority (the definition file is authoritative). + * + * Each returned entry is an associative array with one of two shapes: + * + * External-file entry (legacy, uses `template` path): + * 'source' => string — path relative to the MokoStandards repo root + * 'destination' => string — path in the target repository + * 'always_overwrite' => bool — true: overwrite existing file; false: create-only + * + * Inline-content entry (uses `stub_content` heredoc): + * 'inline_content' => string — rendered template content (ready to push) + * 'destination' => string — path in the target repository + * 'always_overwrite' => bool — true: overwrite existing file; false: create-only + */ +class DefinitionParser +{ + /** Map platform slug → definition file basename */ + private const PLATFORM_DEFINITION_MAP = [ + 'crm-module' => 'crm-module.tf', + 'waas-component' => 'waas-component.tf', + 'generic-repository' => 'generic-repository.tf', + 'default-repository' => 'default-repository.tf', + 'standards' => 'standards-repository.tf', + ]; + + /** Default definition used when platform has no specific file */ + private const FALLBACK_DEFINITION = 'default-repository.tf'; + + /** Directory containing the base definition files */ + private const DEFINITIONS_DIR = 'api/definitions/default'; + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + /** + * Parse a definition file by platform slug. + * + * @param string $platform e.g. 'crm-module', 'waas-component' + * @param string $repoRoot Absolute path to the MokoStandards repository root + * @return array + */ + public function parseForPlatform(string $platform, string $repoRoot): array + { + $basename = self::PLATFORM_DEFINITION_MAP[$platform] ?? self::FALLBACK_DEFINITION; + $path = rtrim($repoRoot, '/') . '/' . self::DEFINITIONS_DIR . '/' . $basename; + + if (!file_exists($path)) { + $fallback = rtrim($repoRoot, '/') . '/' . self::DEFINITIONS_DIR . '/' . self::FALLBACK_DEFINITION; + if (!file_exists($fallback)) { + return []; + } + $path = $fallback; + } + + return $this->parseFile($path); + } + + /** + * Parse a definition file at an explicit filesystem path. + * + * @param string $filePath Absolute path to the .tf definition file + * @return array + */ + public function parseFile(string $filePath): array + { + if (!file_exists($filePath)) { + return []; + } + + $content = file_get_contents($filePath); + if ($content === false) { + return []; + } + + return $this->parse($content); + } + + /** + * Parse raw HCL content. + * + * @param string $content Raw .tf file content + * @return array + */ + public function parse(string $content): array + { + $entries = []; + + // root_files = [ { ... }, ... ] + $rootFilesContent = $this->extractNamedArray($content, 'root_files'); + if ($rootFilesContent !== null) { + $entries = array_merge($entries, $this->parseFileBlocks($rootFilesContent, '')); + } + + // directories = [ { ... }, ... ] + $dirsContent = $this->extractNamedArray($content, 'directories'); + if ($dirsContent !== null) { + $entries = array_merge($entries, $this->parseDirectories($dirsContent)); + } + + return $entries; + } + + // ----------------------------------------------------------------------- + // Internal parsing helpers + // ----------------------------------------------------------------------- + + /** + * Locate `name = [` inside $content and return the content between the + * outermost `[` and its matching `]`, or null if not found. + */ + private function extractNamedArray(string $content, string $name): ?string + { + $pattern = '/\b' . preg_quote($name, '/') . '\s*=\s*\[/'; + + // Build a mask of heredoc regions so the regex doesn't match inside them. + // Replace heredoc content with spaces (preserving offsets) before matching. + $masked = $content; + $len = strlen($content); + $i = 0; + while ($i < $len - 1) { + if ($content[$i] === '<' && $content[$i + 1] === '<') { + $heredocEnd = $this->skipHeredoc($content, $i, $len); + // Blank out the heredoc region in the masked copy + for ($k = $i; $k < $heredocEnd && $k < $len; $k++) { + $masked[$k] = ($content[$k] === "\n") ? "\n" : ' '; + } + $i = $heredocEnd; + continue; + } + $i++; + } + + if (!preg_match($pattern, $masked, $match, PREG_OFFSET_CAPTURE)) { + return null; + } + // Position of the `[` at the end of the matched string — use original content + $openPos = $match[0][1] + strlen($match[0][0]) - 1; + return $this->extractBetweenPair($content, $openPos, '[', ']'); + } + + /** + * Starting at $pos (which must hold $open), walk forward counting depth + * until the matching $close is found. Returns the content between them + * (exclusive), or null on malformed input. + */ + private function extractBetweenPair(string $content, int $pos, string $open, string $close): ?string + { + if (!isset($content[$pos]) || $content[$pos] !== $open) { + return null; + } + + $depth = 0; + $start = $pos; + $len = strlen($content); + + for ($i = $pos; $i < $len; $i++) { + // Skip heredoc regions — they contain unbalanced brackets in markdown/code + if ($content[$i] === '<' && isset($content[$i + 1]) && $content[$i + 1] === '<') { + $i = $this->skipHeredoc($content, $i, $len) - 1; // -1 because for loop increments + continue; + } + if ($content[$i] === $open) { + $depth++; + } elseif ($content[$i] === $close) { + $depth--; + if ($depth === 0) { + return substr($content, $start + 1, $i - $start - 1); + } + } + } + + return null; // unterminated + } + + /** + * Split $content into top-level `{ … }` blocks (depth 1 only). + * + * Heredoc sections (`<<-WORD … WORD` and `<skipHeredoc($content, $i, $len); + continue; + } + + if ($content[$i] === '{') { + if ($depth === 0) { + $start = $i; + } + $depth++; + } elseif ($content[$i] === '}') { + $depth--; + if ($depth === 0 && $start !== null) { + $blocks[] = substr($content, $start + 1, $i - $start - 1); + $start = null; + } + } + $i++; + } + + return $blocks; + } + + /** + * Advance past a HCL heredoc starting at position $i. + * + * Supports both `< + */ + private function parseFileBlocks(string $arrayContent, string $dirPath): array + { + $entries = []; + foreach ($this->splitBlocks($arrayContent) as $block) { + $entry = $this->parseFileBlock($block, $dirPath); + if ($entry !== null) { + $entries[] = $entry; + } + } + return $entries; + } + + /** + * Parse a single file block `{ name = "…", template = "…", … }` or + * `{ name = "…", stub_content = <<-EOT … EOT, … }`. + * + * When a `stub_content` heredoc is present it takes priority over a + * `template` file-path reference. Returns null when the block has + * neither (structural-only entry that should not be synced). + * + * @return array{source?: string, inline_content?: string, destination: string, always_overwrite: bool}|null + */ + private function parseFileBlock(string $block, string $dirPath): ?array + { + // --- try stub_content heredoc first (preferred) --- + $inlineContent = $this->extractHeredoc($block, 'stub_content'); + + // --- fall back to stub_content as a quoted string (e.g. "line1\nline2") --- + if ($inlineContent === null) { + if (preg_match('/\bstub_content\s*=\s*"((?:[^"\\\\]|\\\\.)*)"/', $block, $m)) { + $inlineContent = stripcslashes($m[1]); + } + } + + // --- fall back to external template path --- + $source = null; + if ($inlineContent === null) { + if (!preg_match('/\btemplate\s*=\s*"([^"]+)"/', $block, $m)) { + return null; // neither inline content nor template → structural entry + } + $source = $m[1]; + } + + // name is required + if (!preg_match('/\bname\s*=\s*"([^"]+)"/', $block, $m)) { + return null; + } + $filename = $m[1]; + + // destination_filename overrides name + if (preg_match('/\bdestination_filename\s*=\s*"([^"]+)"/', $block, $m)) { + $filename = $m[1]; + } + + // destination_path overrides dirPath + if (preg_match('/\bdestination_path\s*=\s*"([^"]+)"/', $block, $m)) { + $dp = trim($m[1], '/'); + $destination = ($dp === '' || $dp === '.') ? $filename : "{$dp}/{$filename}"; + } else { + $destination = $dirPath === '' ? $filename : "{$dirPath}/{$filename}"; + } + + // always_overwrite — default true for all template-driven files + $alwaysOverwrite = true; + if (preg_match('/\balways_overwrite\s*=\s*(true|false)\b/', $block, $m)) { + $alwaysOverwrite = ($m[1] === 'true'); + } + + // protected — when true, file is never overwritten even with --force + $protected = false; + if (preg_match('/\bprotected\s*=\s*(true|false)\b/', $block, $m)) { + $protected = ($m[1] === 'true'); + } + + if ($inlineContent !== null) { + return [ + 'inline_content' => $inlineContent, + 'destination' => $destination, + 'always_overwrite' => $alwaysOverwrite, + 'protected' => $protected, + ]; + } + + return [ + 'source' => $source, + 'destination' => $destination, + 'always_overwrite' => $alwaysOverwrite, + 'protected' => $protected, + ]; + } + + /** + * Extract a heredoc value for the given field name from a block string. + * + * Handles both `< (strlen($l) >= $minIndent) ? substr($l, $minIndent) : $l, + $lines + ); + $rawContent = implode("\n", $lines); + } + + return $rawContent; + } + + /** + * Walk the `directories = [ … ]` array, descending into every + * `subdirectories` block recursively. + * + * @return array + */ + private function parseDirectories(string $dirsArrayContent): array + { + $entries = []; + foreach ($this->splitBlocks($dirsArrayContent) as $block) { + $entries = array_merge($entries, $this->parseDirectoryBlock($block)); + } + return $entries; + } + + /** + * Process one directory block: extract its path, parse its files, and + * recurse into any subdirectories. + * + * @return array + */ + private function parseDirectoryBlock(string $block): array + { + $entries = []; + + // Determine the path prefix for files inside this directory + $dirPath = ''; + if (preg_match('/\bpath\s*=\s*"([^"]+)"/', $block, $m)) { + $dirPath = $m[1]; + } + + // files = [ … ] inside this directory + $filesContent = $this->extractNamedArray($block, 'files'); + if ($filesContent !== null) { + $entries = array_merge($entries, $this->parseFileBlocks($filesContent, $dirPath)); + } + + // subdirectories = [ … ] — recurse + $subdirsContent = $this->extractNamedArray($block, 'subdirectories'); + if ($subdirsContent !== null) { + foreach ($this->splitBlocks($subdirsContent) as $subBlock) { + $entries = array_merge($entries, $this->parseDirectoryBlock($subBlock)); + } + } + + return $entries; + } +} diff --git a/lib/Enterprise/EnterpriseReadinessValidator.php b/lib/Enterprise/EnterpriseReadinessValidator.php new file mode 100644 index 0000000..4fbe7af --- /dev/null +++ b/lib/Enterprise/EnterpriseReadinessValidator.php @@ -0,0 +1,259 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/EnterpriseReadinessValidator.php + * VERSION: 04.06.00 + * BRIEF: Enterprise readiness validation library + */ + +declare(strict_types=1); + +namespace MokoEnterprise; + +/** + * Enterprise Readiness Validator + * + * Enterprise library for validating repository compliance with + * enterprise standards including libraries, monitoring, security, and documentation. + */ +class EnterpriseReadinessValidator +{ + private AuditLogger $logger; + private SecurityValidator $securityValidator; + + private array $results = []; + + /** + * Constructor + */ + public function __construct( + ?AuditLogger $logger = null, + ?SecurityValidator $securityValidator = null + ) { + $this->logger = $logger ?? new AuditLogger('enterprise_readiness'); + $this->securityValidator = $securityValidator ?? new SecurityValidator(); + } + + /** + * Validate enterprise readiness + * + * @param string $path Repository path to validate + * @return array Validation results + */ + public function validate(string $path): array + { + $this->logger->logInfo("Starting enterprise readiness validation for: {$path}"); + + $this->results = []; + + // Run all validation checks + $this->checkEnterpriseLibraries($path); + $this->checkMonitoring($path); + $this->checkAuditLogging($path); + $this->checkSecurityCompliance($path); + $this->checkDocumentation($path); + + $passed = count(array_filter($this->results, fn($r) => $r['passed'])); + $total = count($this->results); + $percentage = $total > 0 ? ($passed / $total * 100) : 0; + + $this->logger->logInfo("Enterprise readiness validation complete: {$passed}/{$total} checks passed ({$percentage}%)"); + + return [ + 'results' => $this->results, + 'passed' => $passed, + 'failed' => $total - $passed, + 'total' => $total, + 'percentage' => $percentage, + 'compliant' => $passed === $total, + ]; + } + + /** + * Check for required enterprise libraries + */ + private function checkEnterpriseLibraries(string $path): void + { + $required = [ + 'ApiClient', + 'AuditLogger', + 'Config', + 'ErrorRecovery', + 'MetricsCollector' + ]; + + foreach ($required as $library) { + $phpFile = "{$path}/api/lib/Enterprise/{$library}.php"; + $this->addResult( + "Enterprise library: {$library}", + file_exists($phpFile), + file_exists($phpFile) ? "Found at {$phpFile}" : "Missing required enterprise library" + ); + } + } + + /** + * Check monitoring configuration + */ + private function checkMonitoring(string $path): void + { + // Check for metrics collection + $metricsDir = "{$path}/var/logs/metrics"; + $hasMetricsDir = is_dir($metricsDir); + $hasComposer = file_exists($path . '/composer.json'); + + $this->addResult( + 'Metrics directory configured', + $hasMetricsDir || !$hasComposer, + $hasMetricsDir ? "Metrics directory exists at {$metricsDir}" : 'Metrics logging not configured' + ); + + // Check for monitoring documentation + $monitoringDocs = "{$path}/docs/monitoring"; + $hasMonitoringDocs = is_dir($monitoringDocs) || file_exists("{$path}/docs/monitoring.md"); + + $this->addResult( + 'Monitoring documentation exists', + $hasMonitoringDocs, + $hasMonitoringDocs ? "Monitoring documentation found" : 'Monitoring documentation not found' + ); + } + + /** + * Check audit logging configuration + */ + private function checkAuditLogging(string $path): void + { + $auditDir = "{$path}/var/logs/audit"; + $hasAuditDir = is_dir($auditDir); + $hasComposer = file_exists($path . '/composer.json'); + + $this->addResult( + 'Audit logging directory configured', + $hasAuditDir || !$hasComposer, + $hasAuditDir ? "Audit directory exists at {$auditDir}" : 'Audit logging not configured' + ); + } + + /** + * Check security compliance + */ + private function checkSecurityCompliance(string $path): void + { + // Check for security policy + $hasSecurity = file_exists("{$path}/SECURITY.md") || file_exists("{$path}/.github/SECURITY.md"); + $this->addResult( + 'Security policy exists', + $hasSecurity, + $hasSecurity ? "SECURITY.md found" : 'SECURITY.md not found' + ); + + // Check for CodeQL configuration + $codeqlConfig = "{$path}/.github/codeql"; + $hasCodeQL = is_dir($codeqlConfig) || file_exists("{$path}/.github/codeql/codeql-config.yml"); + + $this->addResult( + 'CodeQL configured', + $hasCodeQL, + $hasCodeQL ? "CodeQL configuration found" : 'CodeQL not configured' + ); + + // Run security scan on PHP files + if (is_dir("{$path}/src")) { + $issues = $this->securityValidator->scanDirectory("{$path}/src", ['.php']); + $issueCount = count($issues); + + $this->addResult( + 'No security vulnerabilities in source code', + $issueCount === 0, + $issueCount === 0 ? "No security issues found" : "{$issueCount} security issues found" + ); + } + } + + /** + * Check documentation requirements + */ + private function checkDocumentation(string $path): void + { + // Check for architecture documentation + $hasArchitecture = file_exists("{$path}/docs/architecture.md") || + file_exists("{$path}/docs/guide/architecture.md"); + + $this->addResult( + 'Architecture documentation exists', + $hasArchitecture, + $hasArchitecture ? "Architecture documentation found" : 'Architecture documentation not found' + ); + + // Check for API documentation + $hasAPI = file_exists("{$path}/docs/api.md") || is_dir("{$path}/docs/api"); + + $this->addResult( + 'API documentation exists', + $hasAPI, + $hasAPI ? "API documentation found" : 'API documentation not found' + ); + } + + /** + * Add a validation result + */ + private function addResult(string $check, bool $passed, string $message): void + { + $this->results[] = [ + 'check' => $check, + 'passed' => $passed, + 'message' => $message, + ]; + } + + /** + * Get all results + * + * @return array All validation results + */ + public function getResults(): array + { + return $this->results; + } + + /** + * Get failed checks + * + * @return array Array of failed checks + */ + public function getFailedChecks(): array + { + return array_filter($this->results, fn($r) => !$r['passed']); + } + + /** + * Get passed checks + * + * @return array Array of passed checks + */ + public function getPassedChecks(): array + { + return array_filter($this->results, fn($r) => $r['passed']); + } + + /** + * Check if fully compliant + * + * @return bool True if all checks passed + */ + public function isCompliant(): bool + { + return empty($this->getFailedChecks()); + } +} diff --git a/lib/Enterprise/ErrorRecovery.php b/lib/Enterprise/ErrorRecovery.php new file mode 100644 index 0000000..c5485e3 --- /dev/null +++ b/lib/Enterprise/ErrorRecovery.php @@ -0,0 +1,58 @@ + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @package MokoStandards\Enterprise + * @version 04.00.04 + * @author MokoStandards Team + * @license GPL-3.0-or-later + * @deprecated Individual class files should be used instead + */ + +namespace MokoEnterprise; + +use Throwable; + +// For backward compatibility, ensure classes are loaded +require_once __DIR__ . '/RecoveryError.php'; +require_once __DIR__ . '/CheckpointManager.php'; +require_once __DIR__ . '/RetryHelper.php'; +require_once __DIR__ . '/RecoveryManager.php'; + +/** + * Execute a callable with automatic rollback on failure. + * + * @param callable $operation Operation to execute + * @param callable $rollback Rollback function to call on failure + * @return mixed Result of operation + * @throws Throwable Re-throws the original exception after rollback + */ +function withRollback(callable $operation, callable $rollback): mixed +{ + try { + return $operation(); + } catch (Throwable $e) { + error_log("Operation failed, executing rollback: {$e->getMessage()}"); + try { + $rollback(); + error_log("Rollback completed successfully"); + } catch (Throwable $rollbackError) { + error_log("Rollback failed: {$rollbackError->getMessage()}"); + } + throw $e; + } +} diff --git a/lib/Enterprise/FileFixUtility.php b/lib/Enterprise/FileFixUtility.php new file mode 100644 index 0000000..e47f6ac --- /dev/null +++ b/lib/Enterprise/FileFixUtility.php @@ -0,0 +1,283 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise + * INGROUP: MokoStandards.Lib + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/FileFixUtility.php + * VERSION: 04.06.00 + * BRIEF: Utility class for fixing file formatting issues (line endings, permissions, tabs, trailing spaces) + */ + +declare(strict_types=1); + +namespace MokoEnterprise; + +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use SplFileInfo; + +/** + * Static utility class for common file-formatting fix operations. + * + * Methods mirror the behaviour of the original shell fix scripts and support + * dry-run mode. Each method returns a list of files that were changed (or + * would be changed in dry-run mode). + */ +class FileFixUtility +{ + /** @var list Extensions processed by fixLineEndings(). */ + private const LINE_ENDING_EXTENSIONS = ['php', 'js', 'css', 'xml', 'sh', 'md']; + + /** @var list Extensions processed when $fileType = 'all' in fixTabs(). */ + private const TABS_ALL_EXTENSIONS = ['yml', 'yaml', 'py', 'sh', 'bash']; + + /** @var array> Extension sets per file-type name in fixTabs(). */ + private const TABS_TYPE_EXTENSIONS = [ + 'yaml' => ['yml', 'yaml'], + 'python' => ['py'], + 'shell' => ['sh', 'bash'], + 'all' => self::TABS_ALL_EXTENSIONS, + ]; + + /** @var list Extensions processed when $fileType = 'all' in fixTrailingSpaces(). */ + private const TRAILING_ALL_EXTENSIONS = ['yml', 'yaml', 'py', 'sh', 'bash', 'md', 'markdown']; + + /** @var array> Extension sets per file-type name in fixTrailingSpaces(). */ + private const TRAILING_TYPE_EXTENSIONS = [ + 'yaml' => ['yml', 'yaml'], + 'python' => ['py'], + 'shell' => ['sh', 'bash'], + 'markdown' => ['md', 'markdown'], + 'all' => self::TRAILING_ALL_EXTENSIONS, + ]; + + // ── Public API ──────────────────────────────────────────────────────────── + + /** + * Fix CRLF line endings to LF in tracked source files. + * + * Operates on all git-tracked files with extensions: php, js, css, xml, sh, md. + * In dry-run mode, returns the list of files that would be changed without + * modifying them. + * + * @param string $repoRoot Absolute path to the repository root. + * @param bool $dryRun When true, report changes without writing. + * @return list Files that were (or would be) changed. + */ + public static function fixLineEndings(string $repoRoot, bool $dryRun = false): array + { + $patterns = array_map( + static fn(string $ext): string => '*.' . $ext, + self::LINE_ENDING_EXTENSIONS + ); + $files = self::gitLsFiles($repoRoot, $patterns); + $changed = []; + + foreach ($files as $file) { + $path = $repoRoot . '/' . $file; + if (!is_file($path)) { + continue; + } + + $content = (string) file_get_contents($path); + if (strpos($content, "\r\n") === false) { + continue; + } + + $changed[] = $file; + + if (!$dryRun) { + file_put_contents($path, str_replace("\r\n", "\n", $content)); + } + } + + return $changed; + } + + /** + * Fix file permissions: directories 755, regular files 644, .php/.sh scripts 755. + * + * Skips the .git/ directory tree. In dry-run mode, no changes are applied. + * + * @param string $repoRoot Absolute path to the repository root. + * @param bool $dryRun When true, report what would change without writing. + */ + public static function fixPermissions(string $repoRoot, bool $dryRun = false): void + { + if ($dryRun) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($repoRoot, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $item) { + /** @var SplFileInfo $item */ + $path = $item->getPathname(); + + if (str_contains($path, '/.git/') || str_ends_with($path, '/.git')) { + continue; + } + + if ($item->isDir()) { + chmod($path, 0755); + } elseif ($item->isFile()) { + $ext = strtolower($item->getExtension()); + $perm = in_array($ext, ['php', 'sh'], true) ? 0755 : 0644; + chmod($path, $perm); + } + } + } + + /** + * Convert tab characters to spaces in tracked source files. + * + * YAML files use 2-space indentation; all other supported types use 4 spaces. + * Makefile variants are always skipped. In dry-run mode, returns the list of + * files that would be changed without modifying them. + * + * @param string $repoRoot Absolute path to the repository root. + * @param string $fileType One of yaml, python, shell, all (default: all). + * @param bool $dryRun When true, report changes without writing. + * @return list Files that were (or would be) changed. + * @throws \InvalidArgumentException When $fileType is unrecognised. + */ + public static function fixTabs(string $repoRoot, string $fileType = 'all', bool $dryRun = false): array + { + if (!array_key_exists($fileType, self::TABS_TYPE_EXTENSIONS)) { + throw new \InvalidArgumentException( + "Unknown file type: {$fileType}. Valid types: " . + implode(', ', array_keys(self::TABS_TYPE_EXTENSIONS)) + ); + } + + $extensions = self::TABS_TYPE_EXTENSIONS[$fileType]; + $patterns = array_map(static fn(string $ext): string => '*.' . $ext, $extensions); + $files = self::gitLsFiles($repoRoot, $patterns); + $changed = []; + + foreach ($files as $file) { + $path = $repoRoot . '/' . $file; + if (!is_file($path)) { + continue; + } + + if (self::isMakefile($file)) { + continue; + } + + $content = (string) file_get_contents($path); + if (strpos($content, "\t") === false) { + continue; + } + + $changed[] = $file; + + if (!$dryRun) { + $spaces = self::spacesForFile($file); + $pad = str_repeat(' ', $spaces); + file_put_contents($path, str_replace("\t", $pad, $content)); + } + } + + return $changed; + } + + /** + * Remove trailing whitespace from tracked source files. + * + * In dry-run mode, returns the list of files that would be changed without + * modifying them. + * + * @param string $repoRoot Absolute path to the repository root. + * @param string $fileType One of yaml, python, shell, markdown, all (default: all). + * @param bool $dryRun When true, report changes without writing. + * @return list Files that were (or would be) changed. + * @throws \InvalidArgumentException When $fileType is unrecognised. + */ + public static function fixTrailingSpaces(string $repoRoot, string $fileType = 'all', bool $dryRun = false): array + { + if (!array_key_exists($fileType, self::TRAILING_TYPE_EXTENSIONS)) { + throw new \InvalidArgumentException( + "Unknown file type: {$fileType}. Valid types: " . + implode(', ', array_keys(self::TRAILING_TYPE_EXTENSIONS)) + ); + } + + $extensions = self::TRAILING_TYPE_EXTENSIONS[$fileType]; + $patterns = array_map(static fn(string $ext): string => '*.' . $ext, $extensions); + $files = self::gitLsFiles($repoRoot, $patterns); + $changed = []; + + foreach ($files as $file) { + $path = $repoRoot . '/' . $file; + if (!is_file($path)) { + continue; + } + + $content = (string) file_get_contents($path); + if (!preg_match('/[[:space:]]+$/m', $content)) { + continue; + } + + $changed[] = $file; + + if (!$dryRun) { + $fixed = preg_replace('/[[:space:]]+$/m', '', $content); + file_put_contents($path, (string) $fixed); + } + } + + return $changed; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + /** + * Run git ls-files in the given root with the provided glob patterns. + * + * @param string $repoRoot Repository root path. + * @param list $patterns Shell glob patterns. + * @return list Relative file paths. + */ + private static function gitLsFiles(string $repoRoot, array $patterns): array + { + $quoted = implode(' ', array_map('escapeshellarg', $patterns)); + $cmd = 'git -C ' . escapeshellarg($repoRoot) . " ls-files {$quoted} 2>/dev/null"; + $output = shell_exec($cmd) ?? ''; + return array_values(array_filter(explode("\n", $output))); + } + + /** + * Return true when the filename matches a Makefile variant. + * + * @param string $path File path (only basename is examined). + */ + private static function isMakefile(string $path): bool + { + $base = strtolower(basename($path)); + return $base === 'makefile' + || $base === 'gnumakefile' + || str_starts_with($base, 'makefile.'); + } + + /** + * Return the number of spaces to substitute for a tab in a given file. + * + * @param string $path File path (extension determines width). + * @return int 2 for YAML, 4 for everything else. + */ + private static function spacesForFile(string $path): int + { + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + return in_array($ext, ['yml', 'yaml'], true) ? 2 : 4; + } +} diff --git a/lib/Enterprise/GitHubAdapter.php b/lib/Enterprise/GitHubAdapter.php new file mode 100644 index 0000000..73f7eef --- /dev/null +++ b/lib/Enterprise/GitHubAdapter.php @@ -0,0 +1,398 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise.Platform + * INGROUP: MokoStandards.Enterprise + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/GitHubAdapter.php + * VERSION: 04.06.10 + * BRIEF: GitHub implementation of GitPlatformAdapter + */ + +declare(strict_types=1); + +namespace MokoEnterprise; + +use RuntimeException; + +/** + * GitHub implementation of GitPlatformAdapter. + * + * Wraps ApiClient with GitHub-specific API semantics: + * - Base URL: https://api.github.com + * - Auth: Bearer {token} + * - Pagination: per_page + page params + * - File ops: PUT for both create and update (SHA distinguishes) + * - Topics: PUT with {"names": [...]} + * - Workflow dir: .github/workflows + * + * @package MokoStandards\Enterprise + * @version 04.06.10 + */ +class GitHubAdapter implements GitPlatformAdapter +{ + private ApiClient $apiClient; + + public function __construct(ApiClient $apiClient) + { + $this->apiClient = $apiClient; + } + + // ────────────────────────────────────────────── + // Identity + // ────────────────────────────────────────────── + + public function getPlatformName(): string + { + return 'github'; + } + + public function getBaseUrl(): string + { + return 'https://api.github.com'; + } + + public function getWorkflowDir(): string + { + return '.github/workflows'; + } + + // ────────────────────────────────────────────── + // Repository CRUD + // ────────────────────────────────────────────── + + public function listOrgRepos(string $org, bool $skipArchived = false): array + { + $all = $this->paginateAll("/orgs/{$org}/repos", ['type' => 'all']); + + $repos = []; + foreach ($all as $repo) { + if ($skipArchived && ($repo['archived'] ?? false)) { + continue; + } + $repos[] = [ + 'name' => $repo['name'], + 'full_name' => $repo['full_name'], + 'archived' => $repo['archived'] ?? false, + 'private' => $repo['private'] ?? false, + ]; + } + + return $repos; + } + + public function getRepo(string $org, string $repo): array + { + return $this->apiClient->get("/repos/{$org}/{$repo}"); + } + + public function createOrgRepo(string $org, string $name, array $options = []): array + { + $data = array_merge([ + 'name' => $name, + 'auto_init' => true, + ], $options); + + return $this->apiClient->post("/orgs/{$org}/repos", $data); + } + + public function archiveRepo(string $org, string $repo): array + { + return $this->apiClient->patch("/repos/{$org}/{$repo}", [ + 'archived' => true, + ]); + } + + public function setRepoTopics(string $org, string $repo, array $topics): void + { + $this->apiClient->put("/repos/{$org}/{$repo}/topics", [ + 'names' => $topics, + ]); + } + + public function getRepoTopics(string $org, string $repo): array + { + $response = $this->apiClient->get("/repos/{$org}/{$repo}/topics"); + return $response['names'] ?? []; + } + + // ────────────────────────────────────────────── + // File Contents + // ────────────────────────────────────────────── + + public function getFileContents(string $org, string $repo, string $path, ?string $ref = null): array + { + $params = []; + if ($ref !== null) { + $params['ref'] = $ref; + } + return $this->apiClient->get("/repos/{$org}/{$repo}/contents/{$path}", $params); + } + + public function createOrUpdateFile( + string $org, + string $repo, + string $path, + string $content, + string $message, + ?string $sha = null, + ?string $branch = null + ): array { + $data = [ + 'message' => $message, + 'content' => base64_encode($content), + ]; + + if ($sha !== null) { + $data['sha'] = $sha; + } + if ($branch !== null) { + $data['branch'] = $branch; + } + + // GitHub uses PUT for both create and update + return $this->apiClient->put("/repos/{$org}/{$repo}/contents/{$path}", $data); + } + + public function deleteFile( + string $org, + string $repo, + string $path, + string $sha, + string $message, + ?string $branch = null + ): array { + // GitHub's delete endpoint requires a body with sha+message, + // but ApiClient::delete() doesn't accept a body. Use the raw approach. + $data = [ + 'message' => $message, + 'sha' => $sha, + ]; + if ($branch !== null) { + $data['branch'] = $branch; + } + + // Work around ApiClient::delete() not accepting a body by using + // a direct HTTP call. For now, fall back to the underlying client. + return $this->apiClient->delete("/repos/{$org}/{$repo}/contents/{$path}"); + } + + // ────────────────────────────────────────────── + // Pull Requests + // ────────────────────────────────────────────── + + public function listPullRequests(string $org, string $repo, array $filters = []): array + { + return $this->apiClient->get("/repos/{$org}/{$repo}/pulls", $filters); + } + + public function createPullRequest( + string $org, + string $repo, + string $title, + string $head, + string $base, + string $body = '', + array $options = [] + ): array { + $data = array_merge([ + 'title' => $title, + 'head' => $head, + 'base' => $base, + 'body' => $body, + ], $options); + + return $this->apiClient->post("/repos/{$org}/{$repo}/pulls", $data); + } + + public function updatePullRequest(string $org, string $repo, int $number, array $data): array + { + return $this->apiClient->patch("/repos/{$org}/{$repo}/pulls/{$number}", $data); + } + + // ────────────────────────────────────────────── + // Issues + // ────────────────────────────────────────────── + + public function listIssues(string $org, string $repo, array $filters = []): array + { + return $this->apiClient->get("/repos/{$org}/{$repo}/issues", $filters); + } + + public function createIssue( + string $org, + string $repo, + string $title, + string $body = '', + array $options = [] + ): array { + $data = array_merge([ + 'title' => $title, + 'body' => $body, + ], $options); + + return $this->apiClient->post("/repos/{$org}/{$repo}/issues", $data); + } + + public function addIssueComment(string $org, string $repo, int $number, string $body): array + { + return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/comments", [ + 'body' => $body, + ]); + } + + public function closeIssue(string $org, string $repo, int $number): array + { + return $this->apiClient->patch("/repos/{$org}/{$repo}/issues/{$number}", [ + 'state' => 'closed', + ]); + } + + // ────────────────────────────────────────────── + // Labels + // ────────────────────────────────────────────── + + public function listLabels(string $org, string $repo): array + { + return $this->paginateAll("/repos/{$org}/{$repo}/labels"); + } + + public function createLabel(string $org, string $repo, string $name, string $color, string $description = ''): array + { + return $this->apiClient->post("/repos/{$org}/{$repo}/labels", [ + 'name' => $name, + 'color' => $color, + 'description' => $description, + ]); + } + + public function addIssueLabels(string $org, string $repo, int $number, array $labels): array + { + // GitHub accepts label names directly + return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/labels", [ + 'labels' => $labels, + ]); + } + + // ────────────────────────────────────────────── + // Branch Protection + // ────────────────────────────────────────────── + + public function setBranchProtection(string $org, string $repo, string $branch, array $rules): array + { + // GitHub uses rulesets API (newer) or branch protection API (legacy) + // Map our generic rules to GitHub's branch protection format + $protection = [ + 'required_status_checks' => null, + 'enforce_admins' => $rules['enforce_admins'] ?? true, + 'required_pull_request_reviews' => null, + 'restrictions' => null, + ]; + + if (isset($rules['required_reviews']) && $rules['required_reviews'] > 0) { + $protection['required_pull_request_reviews'] = [ + 'required_approving_review_count' => $rules['required_reviews'], + 'dismiss_stale_reviews' => $rules['dismiss_stale'] ?? false, + 'require_code_owner_reviews' => $rules['require_code_owner'] ?? false, + ]; + } + + return $this->apiClient->put( + "/repos/{$org}/{$repo}/branches/{$branch}/protection", + $protection + ); + } + + public function listBranchProtections(string $org, string $repo): array + { + // GitHub doesn't have a "list all protections" endpoint; list branches and check each + // For rulesets: GET /repos/{owner}/{repo}/rulesets + try { + return $this->apiClient->get("/repos/{$org}/{$repo}/rulesets"); + } catch (\Exception $e) { + return []; + } + } + + // ────────────────────────────────────────────── + // Git Refs + // ────────────────────────────────────────────── + + public function resolveRef(string $org, string $repo, string $ref): string + { + // Try as a tag first, then as a branch + try { + $tag = $this->apiClient->get("/repos/{$org}/{$repo}/git/ref/tags/{$ref}"); + $object = $tag['object'] ?? []; + + // Annotated tags have type 'tag' — dereference to the commit + if (($object['type'] ?? '') === 'tag') { + $tagObj = $this->apiClient->get($object['url'] ?? "/repos/{$org}/{$repo}/git/tags/{$object['sha']}"); + return $tagObj['object']['sha'] ?? $object['sha']; + } + + return $object['sha'] ?? ''; + } catch (\Exception $e) { + // Not a tag — try as a branch + $this->apiClient->resetCircuitBreaker(); + } + + $branch = $this->apiClient->get("/repos/{$org}/{$repo}/git/ref/heads/{$ref}"); + return $branch['object']['sha'] ?? ''; + } + + public function getTree(string $org, string $repo, string $ref = 'HEAD', bool $recursive = true): array + { + $params = $recursive ? ['recursive' => '1'] : []; + $response = $this->apiClient->get("/repos/{$org}/{$repo}/git/trees/{$ref}", $params); + return $response['tree'] ?? []; + } + + // ────────────────────────────────────────────── + // Pagination + // ────────────────────────────────────────────── + + public function paginateAll(string $endpoint, array $params = [], int $perPage = 100): array + { + $all = []; + $page = 1; + $params['per_page'] = $perPage; + + while (true) { + $params['page'] = $page; + $response = $this->apiClient->get($endpoint, $params); + + if (empty($response)) { + break; + } + + $all = array_merge($all, $response); + $page++; + } + + return $all; + } + + // ────────────────────────────────────────────── + // Migration + // ────────────────────────────────────────────── + + public function migrateRepository(array $options): array + { + throw new RuntimeException('Repository migration is not supported on GitHub — use Gitea\'s built-in migration'); + } + + // ────────────────────────────────────────────── + // Low-level + // ────────────────────────────────────────────── + + public function getApiClient(): ApiClient + { + return $this->apiClient; + } +} diff --git a/lib/Enterprise/GitPlatformAdapter.php b/lib/Enterprise/GitPlatformAdapter.php new file mode 100644 index 0000000..526ffdc --- /dev/null +++ b/lib/Enterprise/GitPlatformAdapter.php @@ -0,0 +1,405 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise.Platform + * INGROUP: MokoStandards.Enterprise + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/GitPlatformAdapter.php + * VERSION: 04.06.10 + * BRIEF: Interface defining all git platform operations for GitHub/Gitea abstraction + */ + +declare(strict_types=1); + +namespace MokoEnterprise; + +/** + * Git Platform Adapter Interface + * + * Defines all platform operations required by MokoStandards automation. + * Implementations exist for GitHub (GitHubAdapter) and Gitea (GiteaAdapter), + * allowing scripts to work against either platform transparently. + * + * @package MokoStandards\Enterprise + * @version 04.06.10 + */ +interface GitPlatformAdapter +{ + // ────────────────────────────────────────────── + // Identity + // ────────────────────────────────────────────── + + /** + * Get the platform name identifier. + * + * @return string 'github' or 'gitea' + */ + public function getPlatformName(): string; + + /** + * Get the API base URL. + * + * @return string e.g. 'https://api.github.com' or 'https://git.mokoconsulting.tech/api/v1' + */ + public function getBaseUrl(): string; + + /** + * Get the workflow directory name for this platform. + * + * @return string '.github/workflows' or '.gitea/workflows' + */ + public function getWorkflowDir(): string; + + // ────────────────────────────────────────────── + // Repository CRUD + // ────────────────────────────────────────────── + + /** + * List all repositories for an organization. + * + * @param string $org Organization name + * @param bool $skipArchived Whether to exclude archived repos + * @return array Repository list + */ + public function listOrgRepos(string $org, bool $skipArchived = false): array; + + /** + * Get a single repository's information. + * + * @param string $org Organization name + * @param string $repo Repository name + * @return array Repository data from API + */ + public function getRepo(string $org, string $repo): array; + + /** + * Create a new repository in an organization. + * + * @param string $org Organization name + * @param string $name Repository name + * @param array $options Repository options (description, private, auto_init, etc.) + * @return array Created repository data + */ + public function createOrgRepo(string $org, string $name, array $options = []): array; + + /** + * Archive a repository (set to read-only). + * + * @param string $org Organization name + * @param string $repo Repository name + * @return array Updated repository data + */ + public function archiveRepo(string $org, string $repo): array; + + /** + * Set repository topics/tags. + * + * @param string $org Organization name + * @param string $repo Repository name + * @param array $topics List of topic strings + * @return void + */ + public function setRepoTopics(string $org, string $repo, array $topics): void; + + /** + * Get repository topics/tags. + * + * @param string $org Organization name + * @param string $repo Repository name + * @return array List of topic strings + */ + public function getRepoTopics(string $org, string $repo): array; + + // ────────────────────────────────────────────── + // File Contents + // ────────────────────────────────────────────── + + /** + * Get file contents from a repository. + * + * @param string $org Organization name + * @param string $repo Repository name + * @param string $path File path within the repository + * @param string|null $ref Branch/tag/SHA reference (null = default branch) + * @return array{content: string, sha: string, size: int, encoding: string} File data (content is base64-encoded) + */ + public function getFileContents(string $org, string $repo, string $path, ?string $ref = null): array; + + /** + * Create or update a file in a repository. + * + * @param string $org Organization name + * @param string $repo Repository name + * @param string $path File path + * @param string $content Raw file content (will be base64-encoded internally) + * @param string $message Commit message + * @param string|null $sha SHA of existing file (null = create new, string = update existing) + * @param string|null $branch Target branch (null = default branch) + * @return array API response + */ + public function createOrUpdateFile( + string $org, + string $repo, + string $path, + string $content, + string $message, + ?string $sha = null, + ?string $branch = null + ): array; + + /** + * Delete a file from a repository. + * + * @param string $org Organization name + * @param string $repo Repository name + * @param string $path File path + * @param string $sha SHA of the file to delete + * @param string $message Commit message + * @param string|null $branch Target branch (null = default branch) + * @return array API response + */ + public function deleteFile( + string $org, + string $repo, + string $path, + string $sha, + string $message, + ?string $branch = null + ): array; + + // ────────────────────────────────────────────── + // Pull Requests + // ────────────────────────────────────────────── + + /** + * List pull requests for a repository. + * + * @param string $org Organization name + * @param string $repo Repository name + * @param array $filters Filters (state, head, base, sort, direction) + * @return array> Pull request list + */ + public function listPullRequests(string $org, string $repo, array $filters = []): array; + + /** + * Create a pull request. + * + * @param string $org Organization name + * @param string $repo Repository name + * @param string $title PR title + * @param string $head Source branch + * @param string $base Target branch + * @param string $body PR description + * @param array $options Additional options (labels, assignees, etc.) + * @return array Created PR data + */ + public function createPullRequest( + string $org, + string $repo, + string $title, + string $head, + string $base, + string $body = '', + array $options = [] + ): array; + + /** + * Update a pull request. + * + * @param string $org Organization name + * @param string $repo Repository name + * @param int $number PR number + * @param array $data Fields to update (title, body, state, etc.) + * @return array Updated PR data + */ + public function updatePullRequest(string $org, string $repo, int $number, array $data): array; + + // ────────────────────────────────────────────── + // Issues + // ────────────────────────────────────────────── + + /** + * List issues for a repository. + * + * @param string $org Organization name + * @param string $repo Repository name + * @param array $filters Filters (state, labels, assignee, etc.) + * @return array> Issue list + */ + public function listIssues(string $org, string $repo, array $filters = []): array; + + /** + * Create an issue. + * + * @param string $org Organization name + * @param string $repo Repository name + * @param string $title Issue title + * @param string $body Issue body + * @param array $options Additional options (labels, assignees, milestone) + * @return array Created issue data + */ + public function createIssue( + string $org, + string $repo, + string $title, + string $body = '', + array $options = [] + ): array; + + /** + * Add a comment to an issue or PR. + * + * @param string $org Organization name + * @param string $repo Repository name + * @param int $number Issue/PR number + * @param string $body Comment body + * @return array Created comment data + */ + public function addIssueComment(string $org, string $repo, int $number, string $body): array; + + /** + * Close an issue. + * + * @param string $org Organization name + * @param string $repo Repository name + * @param int $number Issue number + * @return array Updated issue data + */ + public function closeIssue(string $org, string $repo, int $number): array; + + // ────────────────────────────────────────────── + // Labels + // ────────────────────────────────────────────── + + /** + * List labels for a repository. + * + * @param string $org Organization name + * @param string $repo Repository name + * @return array Label list + */ + public function listLabels(string $org, string $repo): array; + + /** + * Create a label. + * + * @param string $org Organization name + * @param string $repo Repository name + * @param string $name Label name + * @param string $color Hex color (without #) + * @param string $description Label description + * @return array Created label data + */ + public function createLabel(string $org, string $repo, string $name, string $color, string $description = ''): array; + + /** + * Add labels to an issue or PR. + * + * @param string $org Organization name + * @param string $repo Repository name + * @param int $number Issue/PR number + * @param array $labels Label names (GitHub) or label IDs (Gitea) + * @return array API response + */ + public function addIssueLabels(string $org, string $repo, int $number, array $labels): array; + + // ────────────────────────────────────────────── + // Branch Protection + // ────────────────────────────────────────────── + + /** + * Set branch protection rules. + * + * On GitHub this maps to rulesets; on Gitea to branch_protections. + * + * @param string $org Organization name + * @param string $repo Repository name + * @param string $branch Branch name or pattern + * @param array $rules Protection rules (required_reviews, dismiss_stale, etc.) + * @return array Created/updated protection data + */ + public function setBranchProtection(string $org, string $repo, string $branch, array $rules): array; + + /** + * List branch protection rules. + * + * @param string $org Organization name + * @param string $repo Repository name + * @return array> Protection rules + */ + public function listBranchProtections(string $org, string $repo): array; + + // ────────────────────────────────────────────── + // Git Refs + // ────────────────────────────────────────────── + + /** + * Resolve a tag or branch name to a commit SHA. + * + * @param string $org Organization name + * @param string $repo Repository name + * @param string $ref Tag or branch name (e.g. 'v1.0.0', 'main') + * @return string Full commit SHA + */ + public function resolveRef(string $org, string $repo, string $ref): string; + + /** + * Get the repository tree (recursive file listing). + * + * @param string $org Organization name + * @param string $repo Repository name + * @param string $ref Tree SHA or branch (e.g. 'HEAD', 'main') + * @param bool $recursive Whether to recurse into subdirectories + * @return array Tree entries + */ + public function getTree(string $org, string $repo, string $ref = 'HEAD', bool $recursive = true): array; + + // ────────────────────────────────────────────── + // Pagination + // ────────────────────────────────────────────── + + /** + * Paginate through all pages of a list endpoint. + * + * @param string $endpoint API endpoint path + * @param array $params Query parameters + * @param int $perPage Items per page (platform default if 0) + * @return array> All items across all pages + */ + public function paginateAll(string $endpoint, array $params = [], int $perPage = 100): array; + + // ────────────────────────────────────────────── + // Migration (Gitea-specific, no-op on GitHub) + // ────────────────────────────────────────────── + + /** + * Migrate a repository from an external service. + * + * On Gitea, this calls POST /api/v1/repos/migrate. + * On GitHub, this is a no-op (throws UnsupportedOperationException). + * + * @param array $options Migration options (clone_addr, service, auth_token, etc.) + * @return array Migrated repository data + * @throws \RuntimeException If the platform does not support migration + */ + public function migrateRepository(array $options): array; + + // ────────────────────────────────────────────── + // Low-level API access + // ────────────────────────────────────────────── + + /** + * Get the underlying ApiClient instance. + * + * Escape hatch for operations not covered by this interface. + * Prefer adding new interface methods over using this directly. + * + * @return ApiClient The wrapped API client + */ + public function getApiClient(): ApiClient; +} diff --git a/lib/Enterprise/GiteaAdapter.php b/lib/Enterprise/GiteaAdapter.php new file mode 100644 index 0000000..cc40710 --- /dev/null +++ b/lib/Enterprise/GiteaAdapter.php @@ -0,0 +1,443 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise.Platform + * INGROUP: MokoStandards.Enterprise + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/GiteaAdapter.php + * VERSION: 04.06.10 + * BRIEF: Gitea implementation of GitPlatformAdapter + */ + +declare(strict_types=1); + +namespace MokoEnterprise; + +use Exception; +use RuntimeException; + +/** + * Gitea implementation of GitPlatformAdapter. + * + * Wraps ApiClient with Gitea-specific API semantics: + * - Base URL: https://git.mokoconsulting.tech/api/v1 + * - Auth: token {token} + * - Pagination: limit + page params + * - File ops: POST for create, PUT for update (check existence first) + * - Topics: PUT with {"topics": [...]} + * - Branch protection: flat API (not rulesets) + * - Workflow dir: .gitea/workflows + * + * @package MokoStandards\Enterprise + * @version 04.06.10 + */ +class GiteaAdapter implements GitPlatformAdapter +{ + private ApiClient $apiClient; + private string $baseUrl; + + public function __construct(ApiClient $apiClient, string $baseUrl = 'https://git.mokoconsulting.tech/api/v1') + { + $this->apiClient = $apiClient; + $this->baseUrl = rtrim($baseUrl, '/'); + } + + // ────────────────────────────────────────────── + // Identity + // ────────────────────────────────────────────── + + public function getPlatformName(): string + { + return 'gitea'; + } + + public function getBaseUrl(): string + { + return $this->baseUrl; + } + + public function getWorkflowDir(): string + { + return '.gitea/workflows'; + } + + // ────────────────────────────────────────────── + // Repository CRUD + // ────────────────────────────────────────────── + + public function listOrgRepos(string $org, bool $skipArchived = false): array + { + $all = $this->paginateAll("/orgs/{$org}/repos"); + + $repos = []; + foreach ($all as $repo) { + if ($skipArchived && ($repo['archived'] ?? false)) { + continue; + } + $repos[] = [ + 'name' => $repo['name'], + 'full_name' => $repo['full_name'], + 'archived' => $repo['archived'] ?? false, + 'private' => $repo['private'] ?? false, + ]; + } + + return $repos; + } + + public function getRepo(string $org, string $repo): array + { + return $this->apiClient->get("/repos/{$org}/{$repo}"); + } + + public function createOrgRepo(string $org, string $name, array $options = []): array + { + $data = array_merge([ + 'name' => $name, + 'auto_init' => true, + ], $options); + + return $this->apiClient->post("/orgs/{$org}/repos", $data); + } + + public function archiveRepo(string $org, string $repo): array + { + // Gitea uses PATCH with archived flag, same as GitHub + return $this->apiClient->patch("/repos/{$org}/{$repo}", [ + 'archived' => true, + ]); + } + + public function setRepoTopics(string $org, string $repo, array $topics): void + { + // Gitea uses {"topics": [...]} not {"names": [...]} + $this->apiClient->put("/repos/{$org}/{$repo}/topics", [ + 'topics' => $topics, + ]); + } + + public function getRepoTopics(string $org, string $repo): array + { + $response = $this->apiClient->get("/repos/{$org}/{$repo}/topics"); + return $response['topics'] ?? []; + } + + // ────────────────────────────────────────────── + // File Contents + // ────────────────────────────────────────────── + + public function getFileContents(string $org, string $repo, string $path, ?string $ref = null): array + { + $params = []; + if ($ref !== null) { + $params['ref'] = $ref; + } + return $this->apiClient->get("/repos/{$org}/{$repo}/contents/{$path}", $params); + } + + public function createOrUpdateFile( + string $org, + string $repo, + string $path, + string $content, + string $message, + ?string $sha = null, + ?string $branch = null + ): array { + $data = [ + 'message' => $message, + 'content' => base64_encode($content), + ]; + + if ($branch !== null) { + $data['branch'] = $branch; + } + + if ($sha !== null) { + // Update existing file — Gitea uses PUT with SHA + $data['sha'] = $sha; + return $this->apiClient->put("/repos/{$org}/{$repo}/contents/{$path}", $data); + } + + // Create new file — Gitea uses POST + return $this->apiClient->post("/repos/{$org}/{$repo}/contents/{$path}", $data); + } + + public function deleteFile( + string $org, + string $repo, + string $path, + string $sha, + string $message, + ?string $branch = null + ): array { + // Gitea's delete uses the same endpoint but with DELETE method + // ApiClient::delete() doesn't support a body, so we use the raw approach + // For now, this matches GitHubAdapter's limitation + return $this->apiClient->delete("/repos/{$org}/{$repo}/contents/{$path}"); + } + + // ────────────────────────────────────────────── + // Pull Requests + // ────────────────────────────────────────────── + + public function listPullRequests(string $org, string $repo, array $filters = []): array + { + return $this->apiClient->get("/repos/{$org}/{$repo}/pulls", $filters); + } + + public function createPullRequest( + string $org, + string $repo, + string $title, + string $head, + string $base, + string $body = '', + array $options = [] + ): array { + $data = array_merge([ + 'title' => $title, + 'head' => $head, + 'base' => $base, + 'body' => $body, + ], $options); + + return $this->apiClient->post("/repos/{$org}/{$repo}/pulls", $data); + } + + public function updatePullRequest(string $org, string $repo, int $number, array $data): array + { + return $this->apiClient->patch("/repos/{$org}/{$repo}/pulls/{$number}", $data); + } + + // ────────────────────────────────────────────── + // Issues + // ────────────────────────────────────────────── + + public function listIssues(string $org, string $repo, array $filters = []): array + { + return $this->apiClient->get("/repos/{$org}/{$repo}/issues", $filters); + } + + public function createIssue( + string $org, + string $repo, + string $title, + string $body = '', + array $options = [] + ): array { + $data = array_merge([ + 'title' => $title, + 'body' => $body, + ], $options); + + return $this->apiClient->post("/repos/{$org}/{$repo}/issues", $data); + } + + public function addIssueComment(string $org, string $repo, int $number, string $body): array + { + return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/comments", [ + 'body' => $body, + ]); + } + + public function closeIssue(string $org, string $repo, int $number): array + { + return $this->apiClient->patch("/repos/{$org}/{$repo}/issues/{$number}", [ + 'state' => 'closed', + ]); + } + + // ────────────────────────────────────────────── + // Labels + // ────────────────────────────────────────────── + + public function listLabels(string $org, string $repo): array + { + return $this->paginateAll("/repos/{$org}/{$repo}/labels"); + } + + public function createLabel(string $org, string $repo, string $name, string $color, string $description = ''): array + { + // Gitea expects color with # prefix + $color = ltrim($color, '#'); + return $this->apiClient->post("/repos/{$org}/{$repo}/labels", [ + 'name' => $name, + 'color' => '#' . $color, + 'description' => $description, + ]); + } + + public function addIssueLabels(string $org, string $repo, int $number, array $labels): array + { + // Gitea requires label IDs, not names. Resolve names to IDs first. + $allLabels = $this->listLabels($org, $repo); + $labelMap = []; + foreach ($allLabels as $label) { + $labelMap[$label['name']] = $label['id']; + } + + $labelIds = []; + foreach ($labels as $label) { + if (is_int($label)) { + $labelIds[] = $label; + } elseif (isset($labelMap[$label])) { + $labelIds[] = $labelMap[$label]; + } + } + + if (empty($labelIds)) { + return []; + } + + return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/labels", [ + 'labels' => $labelIds, + ]); + } + + // ────────────────────────────────────────────── + // Branch Protection + // ────────────────────────────────────────────── + + public function setBranchProtection(string $org, string $repo, string $branch, array $rules): array + { + // Gitea uses a flat branch protection API + $protection = [ + 'branch_name' => $branch, + 'enable_push' => true, + 'enable_push_whitelist' => false, + 'enable_merge_whitelist' => false, + 'enable_status_check' => $rules['required_status_checks'] ?? false, + 'enable_approvals_whitelist' => false, + 'required_approvals' => $rules['required_reviews'] ?? 0, + 'dismiss_stale_approvals' => $rules['dismiss_stale'] ?? false, + 'block_on_rejected_reviews' => $rules['block_on_rejected'] ?? true, + 'block_on_outdated_branch' => $rules['block_on_outdated'] ?? false, + 'block_on_official_review_requests' => false, + ]; + + // Check if protection already exists for this branch + try { + $existing = $this->apiClient->get("/repos/{$org}/{$repo}/branch_protections/{$branch}"); + if (!empty($existing)) { + return $this->apiClient->patch("/repos/{$org}/{$repo}/branch_protections/{$branch}", $protection); + } + } catch (Exception $e) { + $this->apiClient->resetCircuitBreaker(); + } + + return $this->apiClient->post("/repos/{$org}/{$repo}/branch_protections", $protection); + } + + public function listBranchProtections(string $org, string $repo): array + { + try { + return $this->apiClient->get("/repos/{$org}/{$repo}/branch_protections"); + } catch (Exception $e) { + return []; + } + } + + // ────────────────────────────────────────────── + // Git Refs + // ────────────────────────────────────────────── + + public function resolveRef(string $org, string $repo, string $ref): string + { + // Try as a tag first + try { + $tag = $this->apiClient->get("/repos/{$org}/{$repo}/git/tags/{$ref}"); + // Gitea tag objects have a 'commit' field with the SHA + if (isset($tag['commit']['sha'])) { + return $tag['commit']['sha']; + } + return $tag['id'] ?? $tag['sha'] ?? ''; + } catch (Exception $e) { + $this->apiClient->resetCircuitBreaker(); + } + + // Try as a branch + try { + $branch = $this->apiClient->get("/repos/{$org}/{$repo}/branches/{$ref}"); + return $branch['commit']['id'] ?? ''; + } catch (Exception $e) { + $this->apiClient->resetCircuitBreaker(); + } + + // Last resort: try git/refs endpoint + $refData = $this->apiClient->get("/repos/{$org}/{$repo}/git/refs/tags/{$ref}"); + return $refData['object']['sha'] ?? ''; + } + + public function getTree(string $org, string $repo, string $ref = 'HEAD', bool $recursive = true): array + { + $params = $recursive ? ['recursive' => 'true'] : []; + $response = $this->apiClient->get("/repos/{$org}/{$repo}/git/trees/{$ref}", $params); + return $response['tree'] ?? []; + } + + // ────────────────────────────────────────────── + // Pagination + // ────────────────────────────────────────────── + + public function paginateAll(string $endpoint, array $params = [], int $perPage = 50): array + { + $all = []; + $page = 1; + // Gitea uses 'limit' instead of 'per_page' + $params['limit'] = $perPage; + + while (true) { + $params['page'] = $page; + $response = $this->apiClient->get($endpoint, $params); + + if (empty($response)) { + break; + } + + $all = array_merge($all, $response); + + // If we got fewer results than the limit, we've reached the end + if (count($response) < $perPage) { + break; + } + + $page++; + } + + return $all; + } + + // ────────────────────────────────────────────── + // Migration + // ────────────────────────────────────────────── + + public function migrateRepository(array $options): array + { + // Gitea's built-in migration endpoint + $data = array_merge([ + 'service' => 'github', + 'issues' => true, + 'labels' => true, + 'milestones' => true, + 'releases' => true, + 'wiki' => false, + ], $options); + + return $this->apiClient->post('/repos/migrate', $data); + } + + // ────────────────────────────────────────────── + // Low-level + // ────────────────────────────────────────────── + + public function getApiClient(): ApiClient + { + return $this->apiClient; + } +} diff --git a/lib/Enterprise/InputValidator.php b/lib/Enterprise/InputValidator.php new file mode 100644 index 0000000..c57f34c --- /dev/null +++ b/lib/Enterprise/InputValidator.php @@ -0,0 +1,497 @@ + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @package MokoStandards\Enterprise + * @version 04.00.04 + * @author MokoStandards Team + * @license GPL-3.0-or-later + */ + +namespace MokoEnterprise; + +use InvalidArgumentException; +use RuntimeException; + +/** + * Exception raised when validation fails. + */ +class ValidationError extends RuntimeException +{ +} + +/** + * Input validation and sanitization utilities. + * + * Features: + * - Path validation (prevent path traversal) + * - Version format validation (semver, Moko format) + * - Email validation + * - URL validation with scheme checking + * - Shell injection prevention + * - SQL injection prevention + * - Integer validation with range checking + * - String validation with length/pattern checking + * - Choice validation (enum-like) + * + * Example: + * ```php + * use MokoEnterprise\InputValidator; + * + * $path = InputValidator::validatePath('/tmp/file.txt'); + * $email = InputValidator::validateEmail('user@example.com'); + * $version = InputValidator::validateVersion('04.00.04', 'moko'); + * $safe = InputValidator::sanitizeShellInput('user; rm -rf /'); + * ``` + */ +class InputValidator +{ + public const VERSION = '04.06.00'; + + /** + * Validate and sanitize file paths to prevent path traversal. + * + * @param string $path Path to validate + * @param bool $allowRelative Allow relative paths + * @param bool $mustExist Path must exist + * @param array|null $allowedExtensions List of allowed file extensions + * @return string Validated path + * @throws ValidationError If path is invalid or dangerous + */ + public static function validatePath( + string $path, + bool $allowRelative = false, + bool $mustExist = false, + ?array $allowedExtensions = null + ): string { + if (empty($path)) { + throw new ValidationError("Path must be a non-empty string"); + } + + // Check for path traversal attempts + if (strpos($path, '..') !== false) { + throw new ValidationError("Path traversal detected (..)"); + } + + // Resolve to absolute path if not allowing relative + if (!$allowRelative) { + $realPath = realpath($path); + if ($realPath === false && $mustExist) { + throw new ValidationError("Path does not exist: {$path}"); + } + if ($realPath !== false) { + $path = $realPath; + } + } + + // Check if path must exist + if ($mustExist && !file_exists($path)) { + throw new ValidationError("Path does not exist: {$path}"); + } + + // Check file extension if specified + if ($allowedExtensions !== null) { + $extension = pathinfo($path, PATHINFO_EXTENSION); + if ($extension !== '') { + $allowedLower = array_map('strtolower', $allowedExtensions); + if (!in_array(strtolower($extension), $allowedLower, true)) { + throw new ValidationError( + "Invalid file extension: .{$extension}. " . + "Allowed: " . implode(', ', $allowedExtensions) + ); + } + } + } + + return $path; + } + + /** + * Validate version strings. + * + * @param string $version Version string to validate + * @param string $formatType Version format ('semver', 'simple', 'moko') + * @return string Validated version string + * @throws ValidationError If version format is invalid + */ + public static function validateVersion(string $version, string $formatType = 'semver'): string + { + if (empty($version)) { + throw new ValidationError("Version must be a non-empty string"); + } + + switch ($formatType) { + case 'semver': + // Semantic versioning: MAJOR.MINOR.PATCH + $pattern = '/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/'; + if (!preg_match($pattern, $version)) { + throw new ValidationError( + "Invalid semver format: {$version}. Expected: MAJOR.MINOR.PATCH" + ); + } + break; + + case 'moko': + // MokoStandards format: XX.YY.ZZ + $pattern = '/^\d{2}\.\d{2}\.\d{2}$/'; + if (!preg_match($pattern, $version)) { + throw new ValidationError( + "Invalid MokoStandards version format: {$version}. Expected: XX.YY.ZZ" + ); + } + break; + + case 'simple': + // Simple format: X.Y or X.Y.Z + $pattern = '/^\d+\.\d+(\.\d+)?$/'; + if (!preg_match($pattern, $version)) { + throw new ValidationError("Invalid version format: {$version}"); + } + break; + + default: + throw new ValidationError("Unknown version format type: {$formatType}"); + } + + return $version; + } + + /** + * Validate email addresses. + * + * @param string $email Email address to validate + * @return string Validated email address (lowercase) + * @throws ValidationError If email is invalid + */ + public static function validateEmail(string $email): string + { + if (empty($email)) { + throw new ValidationError("Email must be a non-empty string"); + } + + // Simple but effective email regex + $pattern = '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'; + if (!preg_match($pattern, $email)) { + throw new ValidationError("Invalid email format: {$email}"); + } + + return strtolower($email); + } + + /** + * Validate URLs and check schemes. + * + * @param string $url URL to validate + * @param array|null $allowedSchemes List of allowed URL schemes (e.g., ['http', 'https']) + * @return string Validated URL + * @throws ValidationError If URL is invalid + */ + public static function validateUrl(string $url, ?array $allowedSchemes = null): string + { + if (empty($url)) { + throw new ValidationError("URL must be a non-empty string"); + } + + $parsed = parse_url($url); + if ($parsed === false || !isset($parsed['scheme']) || !isset($parsed['host'])) { + throw new ValidationError("Invalid URL format: {$url}"); + } + + if ($allowedSchemes !== null && !in_array($parsed['scheme'], $allowedSchemes, true)) { + throw new ValidationError( + "URL scheme '{$parsed['scheme']}' not allowed. " . + "Allowed: " . implode(', ', $allowedSchemes) + ); + } + + return $url; + } + + /** + * Sanitize input to prevent shell injection. + * + * @param string $input Input string to sanitize + * @return string Sanitized string + */ + public static function sanitizeShellInput(string $input): string + { + // Remove dangerous shell characters + $dangerousChars = [';', '&', '|', '`', '$', '(', ')', '<', '>', "\n", "\r"]; + $sanitized = str_replace($dangerousChars, '', $input); + + return trim($sanitized); + } + + /** + * Sanitize input to prevent SQL injection. + * + * @param string $input Input string to sanitize + * @return string Sanitized string + */ + public static function sanitizeSqlInput(string $input): string + { + // Remove SQL injection patterns + $dangerousPatterns = ["'", '"', '--', '/*', '*/', 'xp_', 'sp_']; + $sanitized = str_replace($dangerousPatterns, '', $input); + + return trim($sanitized); + } + + /** + * Validate and convert to integer with range checking. + * + * @param mixed $value Value to validate + * @param int|null $minValue Minimum allowed value + * @param int|null $maxValue Maximum allowed value + * @return int Validated integer + * @throws ValidationError If value is invalid or out of range + */ + public static function validateInteger( + mixed $value, + ?int $minValue = null, + ?int $maxValue = null + ): int { + if (!is_numeric($value)) { + throw new ValidationError("Cannot convert to integer: {$value}"); + } + + $intValue = (int) $value; + + if ($minValue !== null && $intValue < $minValue) { + throw new ValidationError("Value {$intValue} is below minimum {$minValue}"); + } + + if ($maxValue !== null && $intValue > $maxValue) { + throw new ValidationError("Value {$intValue} is above maximum {$maxValue}"); + } + + return $intValue; + } + + /** + * Validate string with length and pattern checking. + * + * @param string $value String to validate + * @param int|null $minLength Minimum string length + * @param int|null $maxLength Maximum string length + * @param string|null $pattern Regex pattern to match + * @return string Validated string + * @throws ValidationError If string is invalid + */ + public static function validateString( + string $value, + ?int $minLength = null, + ?int $maxLength = null, + ?string $pattern = null + ): string { + $length = strlen($value); + + if ($minLength !== null && $length < $minLength) { + throw new ValidationError("String length {$length} is below minimum {$minLength}"); + } + + if ($maxLength !== null && $length > $maxLength) { + throw new ValidationError("String length {$length} exceeds maximum {$maxLength}"); + } + + if ($pattern !== null && !preg_match($pattern, $value)) { + throw new ValidationError("String does not match pattern: {$pattern}"); + } + + return $value; + } + + /** + * Validate that value is in a list of allowed choices. + * + * @param mixed $value Value to validate + * @param array $choices List of allowed values + * @return mixed Validated value + * @throws ValidationError If value not in choices + */ + public static function validateChoice(mixed $value, array $choices): mixed + { + if (!in_array($value, $choices, true)) { + $choicesStr = implode(', ', array_map('strval', $choices)); + throw new ValidationError("Invalid choice: {$value}. Allowed: {$choicesStr}"); + } + + return $value; + } +} + +/** + * Chainable validator for complex validation scenarios. + * + * Features: + * - Fluent interface for chaining validations + * - Accumulates errors instead of throwing immediately + * - Single validation call at the end + * + * Example: + * ```php + * $validator = new Validator('user@example.com', 'email'); + * $email = $validator + * ->isString(minLength: 5, maxLength: 100) + * ->isEmail() + * ->validate(); + * ``` + */ +class Validator +{ + private mixed $value; + private string $name; + /** @var array */ + private array $errors = []; + + /** + * Initialize validator. + * + * @param mixed $value Value to validate + * @param string $name Name of the value (for error messages) + */ + public function __construct(mixed $value, string $name = 'value') + { + $this->value = $value; + $this->name = $name; + } + + /** + * Check if value is a string. + * + * @param int|null $minLength Minimum length + * @param int|null $maxLength Maximum length + * @return self + */ + public function isString(?int $minLength = null, ?int $maxLength = null): self + { + try { + if (!is_string($this->value)) { + throw new ValidationError("Value must be a string"); + } + InputValidator::validateString($this->value, $minLength, $maxLength); + } catch (ValidationError $e) { + $this->errors[] = $e->getMessage(); + } + return $this; + } + + /** + * Check if value is an integer. + * + * @param int|null $minValue Minimum value + * @param int|null $maxValue Maximum value + * @return self + */ + public function isInteger(?int $minValue = null, ?int $maxValue = null): self + { + try { + InputValidator::validateInteger($this->value, $minValue, $maxValue); + } catch (ValidationError $e) { + $this->errors[] = $e->getMessage(); + } + return $this; + } + + /** + * Check if value is a valid email. + * + * @return self + */ + public function isEmail(): self + { + try { + if (!is_string($this->value)) { + throw new ValidationError("Email must be a string"); + } + InputValidator::validateEmail($this->value); + } catch (ValidationError $e) { + $this->errors[] = $e->getMessage(); + } + return $this; + } + + /** + * Check if value is a valid URL. + * + * @param array|null $allowedSchemes Allowed URL schemes + * @return self + */ + public function isUrl(?array $allowedSchemes = null): self + { + try { + if (!is_string($this->value)) { + throw new ValidationError("URL must be a string"); + } + InputValidator::validateUrl($this->value, $allowedSchemes); + } catch (ValidationError $e) { + $this->errors[] = $e->getMessage(); + } + return $this; + } + + /** + * Check if value matches regex pattern. + * + * @param string $pattern Regex pattern + * @return self + */ + public function matches(string $pattern): self + { + if (!preg_match($pattern, (string) $this->value)) { + $this->errors[] = "{$this->name} does not match pattern: {$pattern}"; + } + return $this; + } + + /** + * Perform validation and raise exception if errors found. + * + * @return mixed The validated value + * @throws ValidationError If validation failed + */ + public function validate(): mixed + { + if (!empty($this->errors)) { + $errorMsg = "Validation failed for {$this->name}:\n"; + $errorMsg .= implode("\n", array_map(fn($e) => " - {$e}", $this->errors)); + throw new ValidationError($errorMsg); + } + return $this->value; + } + + /** + * Get all validation errors. + * + * @return array + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * Check if validation has errors. + * + * @return bool + */ + public function hasErrors(): bool + { + return !empty($this->errors); + } +} diff --git a/lib/Enterprise/MetricsCollector.php b/lib/Enterprise/MetricsCollector.php new file mode 100644 index 0000000..eac3fa6 --- /dev/null +++ b/lib/Enterprise/MetricsCollector.php @@ -0,0 +1,330 @@ +increment('requests_total'); + * $metrics->setGauge('cpu_usage', 45.5); + * + * // Timing operations + * $timer = $metrics->startTimer('operation'); + * // ... do work ... + * $timer->stop(); + * + * // Export for monitoring + * echo $metrics->exportPrometheus(); + * ``` + * + * Copyright (C) 2026 Moko Consulting + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @package MokoStandards\Enterprise + * @version 04.00.04 + * @author MokoStandards Team + * @license GPL-3.0-or-later + */ + +namespace MokoEnterprise; + +use DateTime; +use DateTimeZone; + +/** + * Timer class for timing operations + */ +class MetricsTimer +{ + private MetricsCollector $collector; + private string $metricName; + private array $labels; + private float $startTime; + + public function __construct(MetricsCollector $collector, string $metricName, array $labels = []) + { + $this->collector = $collector; + $this->metricName = $metricName; + $this->labels = $labels; + $this->startTime = microtime(true); + } + + public function stop(bool $success = true): float + { + $duration = microtime(true) - $this->startTime; + $this->collector->observe($this->metricName . '_duration_seconds', $duration, $this->labels); + + if ($success) { + $this->collector->increment($this->metricName . '_success_total', 1, $this->labels); + } else { + $this->collector->increment($this->metricName . '_failure_total', 1, $this->labels); + } + + return $duration; + } +} + +/** + * Metrics collector for monitoring and observability + */ +class MetricsCollector +{ + private const VERSION = '04.06.00'; + + private string $serviceName; + private array $counters = []; + private array $gauges = []; + private array $histograms = []; + private float $startTime; + + public function __construct(string $serviceName = 'mokostandards') + { + $this->serviceName = $serviceName; + $this->startTime = microtime(true); + } + + /** + * Increment a counter metric + * + * @param string $metricName Name of the metric + * @param int $value Value to increment by + * @param array $labels Optional labels for the metric + */ + public function increment(string $metricName, int $value = 1, array $labels = []): void + { + $key = $this->makeKey($metricName, $labels); + if (!isset($this->counters[$key])) { + $this->counters[$key] = 0; + } + $this->counters[$key] += $value; + } + + /** + * Set a gauge metric + * + * @param string $metricName Name of the metric + * @param float $value Value to set + * @param array $labels Optional labels for the metric + */ + public function setGauge(string $metricName, float $value, array $labels = []): void + { + $key = $this->makeKey($metricName, $labels); + $this->gauges[$key] = $value; + } + + /** + * Observe a value for histogram + * + * @param string $metricName Name of the metric + * @param float $value Value to observe + * @param array $labels Optional labels for the metric + */ + public function observe(string $metricName, float $value, array $labels = []): void + { + $key = $this->makeKey($metricName, $labels); + if (!isset($this->histograms[$key])) { + $this->histograms[$key] = []; + } + $this->histograms[$key][] = $value; + } + + /** + * Start a timer for timing operations + * + * @param string $metricName Name of the metric + * @param array $labels Optional labels for the metric + * @return MetricsTimer Timer instance + */ + public function startTimer(string $metricName, array $labels = []): MetricsTimer + { + return new MetricsTimer($this, $metricName, $labels); + } + + /** + * Create a metric key with labels + * + * @param string $metricName Name of the metric + * @param array $labels Optional labels + * @return string Metric key string + */ + private function makeKey(string $metricName, array $labels = []): string + { + if (empty($labels)) { + return $metricName; + } + + ksort($labels); + $labelPairs = []; + foreach ($labels as $key => $value) { + $labelPairs[] = sprintf('%s="%s"', $key, $value); + } + + return sprintf('%s{%s}', $metricName, implode(',', $labelPairs)); + } + + /** + * Get current counter value + * + * @param string $metricName Name of the metric + * @return int Counter value + */ + public function getCounter(string $metricName): int + { + return $this->counters[$metricName] ?? 0; + } + + /** + * Get current gauge value + * + * @param string $metricName Name of the metric + * @return float|null Gauge value or null if not set + */ + public function getGauge(string $metricName): ?float + { + return $this->gauges[$metricName] ?? null; + } + + /** + * Get statistics for a histogram + * + * @param string $metricName Name of the metric + * @return array Dictionary with min, max, avg, count, sum + */ + public function getHistogramStats(string $metricName): array + { + $values = $this->histograms[$metricName] ?? []; + + if (empty($values)) { + return ['count' => 0, 'min' => 0.0, 'max' => 0.0, 'avg' => 0.0, 'sum' => 0.0]; + } + + $sum = array_sum($values); + return [ + 'count' => count($values), + 'min' => min($values), + 'max' => max($values), + 'avg' => $sum / count($values), + 'sum' => $sum + ]; + } + + /** + * Export metrics in Prometheus format + * + * @return string Metrics in Prometheus text format + */ + public function exportPrometheus(): string + { + $lines = []; + $now = new DateTime('now', new DateTimeZone('UTC')); + + $lines[] = sprintf('# Metrics for %s', $this->serviceName); + $lines[] = sprintf('# Generated at %s', $now->format('c')); + $lines[] = ''; + + // Export counters + foreach ($this->counters as $key => $value) { + $lines[] = sprintf('# TYPE %s counter', $this->stripLabels($key)); + $lines[] = sprintf('%s %d', $key, $value); + } + + // Export gauges + foreach ($this->gauges as $key => $value) { + $lines[] = sprintf('# TYPE %s gauge', $this->stripLabels($key)); + $lines[] = sprintf('%s %s', $key, $value); + } + + // Export histograms + foreach ($this->histograms as $key => $values) { + if (!empty($values)) { + $stats = $this->getHistogramStats($key); + $lines[] = sprintf('# TYPE %s histogram', $this->stripLabels($key)); + $lines[] = sprintf('%s_count %d', $key, $stats['count']); + $lines[] = sprintf('%s_sum %s', $key, $stats['sum']); + $lines[] = sprintf('%s_min %s', $key, $stats['min']); + $lines[] = sprintf('%s_max %s', $key, $stats['max']); + $lines[] = sprintf('%s_avg %s', $key, $stats['avg']); + } + } + + // Add uptime + $uptime = microtime(true) - $this->startTime; + $lines[] = '# TYPE process_uptime_seconds gauge'; + $lines[] = sprintf('process_uptime_seconds %.2f', $uptime); + + return implode("\n", $lines); + } + + /** + * Strip labels from metric key + * + * @param string $key Metric key + * @return string Metric name without labels + */ + private function stripLabels(string $key): string + { + $pos = strpos($key, '{'); + return $pos !== false ? substr($key, 0, $pos) : $key; + } + + /** + * Print a summary of all metrics + */ + public function printSummary(): void + { + echo "\n" . str_repeat('=', 60) . "\n"; + echo "Metrics Summary for {$this->serviceName}\n"; + echo str_repeat('=', 60) . "\n"; + + if (!empty($this->counters)) { + echo "\nCounters:\n"; + ksort($this->counters); + foreach ($this->counters as $key => $value) { + echo " {$key}: {$value}\n"; + } + } + + if (!empty($this->gauges)) { + echo "\nGauges:\n"; + ksort($this->gauges); + foreach ($this->gauges as $key => $value) { + echo " {$key}: {$value}\n"; + } + } + + if (!empty($this->histograms)) { + echo "\nHistograms:\n"; + $keys = array_keys($this->histograms); + sort($keys); + foreach ($keys as $key) { + $stats = $this->getHistogramStats($key); + echo " {$key}:\n"; + echo sprintf(" Count: %d\n", $stats['count']); + echo sprintf(" Min: %.4f\n", $stats['min']); + echo sprintf(" Max: %.4f\n", $stats['max']); + echo sprintf(" Avg: %.4f\n", $stats['avg']); + } + } + + $uptime = microtime(true) - $this->startTime; + echo sprintf("\nUptime: %.2f seconds\n", $uptime); + echo str_repeat('=', 60) . "\n\n"; + } + + public function getVersion(): string + { + return self::VERSION; + } +} diff --git a/lib/Enterprise/PackageBuilder.php b/lib/Enterprise/PackageBuilder.php new file mode 100644 index 0000000..2b2300e --- /dev/null +++ b/lib/Enterprise/PackageBuilder.php @@ -0,0 +1,292 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise + * INGROUP: MokoStandards.Lib + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/PackageBuilder.php + * VERSION: 04.06.00 + * BRIEF: Builds release packages for generic, Dolibarr module, and Joomla component projects + */ + +declare(strict_types=1); + +namespace MokoEnterprise; + +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use SplFileInfo; +use ZipArchive; + +/** + * Static factory that creates distributable ZIP release packages. + * + * Supports three project types: generic (src/admin/site layout), Dolibarr module + * (src/ layout), and Joomla component (site/admin/media/language layout). + * All methods return the path to the created archive (or would-create path in dry-run). + */ +class PackageBuilder +{ + // ── Public API ──────────────────────────────────────────────────────────── + + /** + * Build a generic release package. + * + * Copies src/, admin/, site/, top-level *.xml files, LICENSE* files, and + * CHANGELOG.md into a build staging directory, then archives them as + * dist/-.zip. + * + * @param string $repoRoot Absolute path to the repository root. + * @param string $packageName Base name for the archive. + * @param string $version Version string (e.g. "1.2.0"). + * @param bool $dryRun When true, preview without writing. + * @return string Path to the created archive (or would-create path in dry-run). + * @throws \RuntimeException When the zip archive cannot be opened. + */ + public static function buildGeneric( + string $repoRoot, + string $packageName, + string $version, + bool $dryRun = false + ): string { + $buildDir = $repoRoot . '/build'; + $packageDir = $buildDir . '/' . $packageName; + $distDir = $repoRoot . '/dist'; + $archivePath = $distDir . '/' . $packageName . '-' . $version . '.zip'; + + if ($dryRun) { + return $archivePath; + } + + self::cleanDir($buildDir); + self::cleanDir($distDir); + mkdir($packageDir, 0755, true); + mkdir($distDir, 0755, true); + + foreach (['src', 'admin', 'site'] as $dir) { + if (is_dir($repoRoot . '/' . $dir)) { + self::copyDirectory($repoRoot . '/' . $dir, $packageDir . '/' . $dir); + } + } + + foreach (glob($repoRoot . '/*.xml') ?: [] as $xml) { + copy($xml, $packageDir . '/' . basename($xml)); + } + + foreach (glob($repoRoot . '/LICENSE*') ?: [] as $lic) { + copy($lic, $packageDir . '/' . basename($lic)); + } + + if (is_file($repoRoot . '/CHANGELOG.md')) { + copy($repoRoot . '/CHANGELOG.md', $packageDir . '/CHANGELOG.md'); + } + + self::zip($packageDir, $archivePath, $packageName); + + return $archivePath; + } + + /** + * Build a Dolibarr module release package. + * + * Copies everything under src/ into a build staging directory and archives + * it as dist/_.zip. + * + * @param string $repoRoot Absolute path to the repository root. + * @param string $moduleName Module name (used in archive filename). + * @param string $version Version string. + * @param bool $dryRun When true, preview without writing. + * @return string Path to the created archive (or would-create path in dry-run). + * @throws \RuntimeException When src/ is absent or archive creation fails. + */ + public static function buildDolibarr( + string $repoRoot, + string $moduleName, + string $version, + bool $dryRun = false + ): string { + $srcDir = $repoRoot . '/src'; + $buildDir = $repoRoot . '/build'; + $distDir = $repoRoot . '/dist'; + $archivePath = $distDir . '/' . $moduleName . '_' . $version . '.zip'; + + if (!is_dir($srcDir)) { + throw new \RuntimeException("src/ directory not found at {$srcDir}"); + } + + if ($dryRun) { + return $archivePath; + } + + self::cleanDir($buildDir); + self::cleanDir($distDir); + mkdir($buildDir, 0755, true); + mkdir($distDir, 0755, true); + + self::copyDirectory($srcDir, $buildDir); + self::zip($buildDir, $archivePath, ''); + + return $archivePath; + } + + /** + * Build a Joomla component release package. + * + * Copies site/, admin/, optional media/ and language/ directories, and the + * component XML manifest into a build staging directory, then archives as + * dist/_.zip. + * + * @param string $repoRoot Absolute path to the repository root. + * @param string $componentName Component name, e.g. "com_example". + * @param string $version Version string. + * @param bool $dryRun When true, preview without writing. + * @return string Path to the created archive (or would-create path in dry-run). + * @throws \RuntimeException When required directories are absent or archiving fails. + */ + public static function buildJoomla( + string $repoRoot, + string $componentName, + string $version, + bool $dryRun = false + ): string { + $buildDir = $repoRoot . '/build'; + $distDir = $repoRoot . '/dist'; + $archivePath = $distDir . '/' . $componentName . '_' . $version . '.zip'; + + if ($dryRun) { + return $archivePath; + } + + self::cleanDir($buildDir); + self::cleanDir($distDir); + mkdir($buildDir, 0755, true); + mkdir($distDir, 0755, true); + + foreach (['site', 'admin'] as $required) { + $src = $repoRoot . '/' . $required; + if (!is_dir($src)) { + throw new \RuntimeException("Required directory '{$required}/' not found at {$src}"); + } + self::copyDirectory($src, $buildDir . '/' . $required); + } + + foreach (['media', 'language'] as $optional) { + $src = $repoRoot . '/' . $optional; + if (is_dir($src)) { + self::copyDirectory($src, $buildDir . '/' . $optional); + } + } + + $manifest = $repoRoot . '/' . $componentName . '.xml'; + if (is_file($manifest)) { + copy($manifest, $buildDir . '/' . $componentName . '.xml'); + } + + self::zip($buildDir, $archivePath, ''); + + return $archivePath; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + /** + * Remove a directory if it exists, then recreate it. + * + * @param string $dir Directory path to clean. + */ + private static function cleanDir(string $dir): void + { + if (is_dir($dir)) { + self::deleteDirectory($dir); + } + } + + /** + * Recursively copy a source directory to a destination. + * + * @param string $src Source directory path. + * @param string $dst Destination directory path. + */ + private static function copyDirectory(string $src, string $dst): void + { + if (!is_dir($dst)) { + mkdir($dst, 0755, true); + } + + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($src, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iter as $item) { + /** @var SplFileInfo $item */ + $target = $dst . '/' . $iter->getSubPathname(); + if ($item->isDir()) { + if (!is_dir($target)) { + mkdir($target, 0755, true); + } + } else { + copy($item->getPathname(), $target); + } + } + } + + /** + * Create a ZIP archive from a source directory tree. + * + * @param string $sourceDir Directory to archive. + * @param string $archivePath Destination archive path. + * @param string $prefix Path prefix inside the archive (empty string for no prefix). + * @throws \RuntimeException When the archive cannot be opened for writing. + */ + private static function zip(string $sourceDir, string $archivePath, string $prefix): void + { + $zip = new ZipArchive(); + if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new \RuntimeException("Cannot create archive: {$archivePath}"); + } + + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iter as $item) { + /** @var SplFileInfo $item */ + $rel = $iter->getSubPathname(); + $name = $prefix !== '' ? $prefix . '/' . $rel : $rel; + if ($item->isFile()) { + $zip->addFile($item->getPathname(), $name); + } elseif ($item->isDir()) { + $zip->addEmptyDir($name); + } + } + + $zip->close(); + } + + /** + * Recursively delete a directory and all its contents. + * + * @param string $dir Directory path. + */ + private static function deleteDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $items = array_diff((array) scandir($dir), ['.', '..']); + foreach ($items as $item) { + $path = $dir . '/' . $item; + is_dir($path) ? self::deleteDirectory($path) : unlink($path); + } + + rmdir($dir); + } +} diff --git a/lib/Enterprise/PlatformAdapterFactory.php b/lib/Enterprise/PlatformAdapterFactory.php new file mode 100644 index 0000000..38c1dc6 --- /dev/null +++ b/lib/Enterprise/PlatformAdapterFactory.php @@ -0,0 +1,131 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise.Platform + * INGROUP: MokoStandards.Enterprise + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/PlatformAdapterFactory.php + * VERSION: 04.06.10 + * BRIEF: Factory for creating platform-specific GitPlatformAdapter instances + */ + +declare(strict_types=1); + +namespace MokoEnterprise; + +use RuntimeException; + +/** + * Factory for creating GitPlatformAdapter instances. + * + * Reads GIT_PLATFORM env var (default: 'github') and constructs + * the appropriate adapter with correct base URL, auth scheme, and token. + * + * Usage: + * ```php + * $config = Config::load(); + * $adapter = PlatformAdapterFactory::create($config); + * $repos = $adapter->listOrgRepos('mokoconsulting-tech'); + * ``` + * + * @package MokoStandards\Enterprise + * @version 04.06.10 + */ +class PlatformAdapterFactory +{ + /** + * Create a GitPlatformAdapter based on configuration. + * + * @param Config $config Configuration instance + * @param string|null $platformOverride Force a specific platform ('github' or 'gitea') + * @return GitPlatformAdapter The constructed adapter + * @throws RuntimeException If the platform is not supported or token is missing + */ + public static function create(Config $config, ?string $platformOverride = null): GitPlatformAdapter + { + $platform = $platformOverride ?? $config->getString('platform', 'github'); + + return match ($platform) { + 'github' => self::createGitHubAdapter($config), + 'gitea' => self::createGiteaAdapter($config), + default => throw new RuntimeException("Unsupported git platform: {$platform}. Use 'github' or 'gitea'."), + }; + } + + /** + * Create a GitHubAdapter with configured ApiClient. + * + * @param Config $config Configuration instance + * @return GitHubAdapter Configured GitHub adapter + * @throws RuntimeException If GitHub token is not available + */ + private static function createGitHubAdapter(Config $config): GitHubAdapter + { + $token = $config->getString('github.token', ''); + if (empty($token)) { + throw new RuntimeException( + 'GitHub token not found. Set GH_TOKEN, GITHUB_TOKEN, or authenticate with `gh auth login`.' + ); + } + + $apiClient = new ApiClient( + baseUrl: 'https://api.github.com', + authToken: $token, + maxRequestsPerHour: $config->getInt('github.rate_limit', 5000), + maxRetries: $config->getInt('github.max_retries', 3), + authScheme: 'Bearer' + ); + + return new GitHubAdapter($apiClient); + } + + /** + * Create a GiteaAdapter with configured ApiClient. + * + * @param Config $config Configuration instance + * @return GiteaAdapter Configured Gitea adapter + * @throws RuntimeException If Gitea token is not available + */ + private static function createGiteaAdapter(Config $config): GiteaAdapter + { + $token = $config->getString('gitea.token', ''); + if (empty($token)) { + throw new RuntimeException( + 'Gitea token not found. Set GITEA_TOKEN environment variable.' + ); + } + + $giteaUrl = $config->getString('gitea.url', 'https://git.mokoconsulting.tech'); + $apiBaseUrl = rtrim($giteaUrl, '/') . '/api/v1'; + + $apiClient = new ApiClient( + baseUrl: $apiBaseUrl, + authToken: $token, + maxRequestsPerHour: $config->getInt('gitea.rate_limit', 5000), + maxRetries: $config->getInt('gitea.max_retries', 3), + authScheme: 'token' + ); + + return new GiteaAdapter($apiClient, $apiBaseUrl); + } + + /** + * Create adapters for both platforms (useful during migration). + * + * @param Config $config Configuration instance + * @return array{github: GitHubAdapter, gitea: GiteaAdapter} Both adapters + * @throws RuntimeException If either token is missing + */ + public static function createBoth(Config $config): array + { + return [ + 'github' => self::createGitHubAdapter($config), + 'gitea' => self::createGiteaAdapter($config), + ]; + } +} diff --git a/lib/Enterprise/PluginFactory.php b/lib/Enterprise/PluginFactory.php new file mode 100644 index 0000000..79046a2 --- /dev/null +++ b/lib/Enterprise/PluginFactory.php @@ -0,0 +1,319 @@ +logger = $logger ?? new AuditLogger('plugin_factory'); + $this->metricsCollector = $metricsCollector ?? new MetricsCollector(); + $this->defaultConfig = $defaultConfig; + + // Set shared instances in registry + PluginRegistry::setLogger($this->logger); + PluginRegistry::setMetricsCollector($this->metricsCollector); + } + + /** + * Create plugin for a project type + * + * @param string $projectType Project type identifier + * @param array $config Optional plugin-specific configuration + * @return ProjectPluginInterface|null Plugin instance or null if not found + */ + public function create(string $projectType, array $config = []): ?ProjectPluginInterface + { + $config = array_merge($this->defaultConfig, $config); + return PluginRegistry::getPlugin($projectType, $config); + } + + /** + * Create plugin for a project by auto-detecting type + * + * @param string $projectPath Path to project directory + * @param array $config Optional plugin-specific configuration + * @return ProjectPluginInterface|null Plugin instance or null if type can't be detected + */ + public function createForProject(string $projectPath, array $config = []): ?ProjectPluginInterface + { + $detector = new ProjectTypeDetector($this->logger); + $detection = $detector->detect($projectPath); + + if (empty($detection['type'])) { + $this->logger->logWarning('Could not detect project type', [ + 'project_path' => $projectPath, + 'detection_result' => $detection, + ]); + return null; + } + + $projectType = $detection['type']; + $this->logger->logInfo("Detected project type: {$projectType}", [ + 'project_path' => $projectPath, + 'confidence' => $detection['confidence'] ?? 0, + ]); + + return $this->create($projectType, $config); + } + + /** + * Create all available plugins + * + * @param array $config Optional configuration for all plugins + * @return array Map of project types to plugin instances + */ + public function createAll(array $config = []): array + { + $config = array_merge($this->defaultConfig, $config); + return PluginRegistry::getAllPlugins($config); + } + + /** + * Create multiple plugins + * + * @param array $projectTypes List of project type identifiers + * @param array $config Optional configuration for plugins + * @return array Map of project types to plugin instances + */ + public function createMultiple(array $projectTypes, array $config = []): array + { + $config = array_merge($this->defaultConfig, $config); + $plugins = []; + + foreach ($projectTypes as $projectType) { + $plugin = $this->create($projectType, $config); + if ($plugin !== null) { + $plugins[$projectType] = $plugin; + } + } + + return $plugins; + } + + /** + * Validate and create plugin + * + * @param string $projectType Project type identifier + * @param string $projectPath Path to project directory + * @param array $projectConfig Project configuration + * @return array Result with 'plugin' (ProjectPluginInterface|null) and 'validation' (array) + */ + public function createAndValidate( + string $projectType, + string $projectPath, + array $projectConfig = [] + ): array { + $plugin = $this->create($projectType); + + if ($plugin === null) { + return [ + 'plugin' => null, + 'validation' => [ + 'valid' => false, + 'errors' => ["Plugin not found for project type: {$projectType}"], + 'warnings' => [], + ], + ]; + } + + $validation = $plugin->validateProject($projectConfig, $projectPath); + + return [ + 'plugin' => $plugin, + 'validation' => $validation, + ]; + } + + /** + * Get factory statistics + * + * @return array Factory and registry statistics + */ + public function getStatistics(): array + { + return [ + 'factory' => [ + 'has_logger' => $this->logger !== null, + 'has_metrics_collector' => $this->metricsCollector !== null, + 'default_config_keys' => array_keys($this->defaultConfig), + ], + 'registry' => PluginRegistry::getStatistics(), + ]; + } + + /** + * Get all available plugin information + * + * @return array Map of project types to plugin information + */ + public function getAvailablePlugins(): array + { + return PluginRegistry::getAllPluginsInfo(); + } + + /** + * Check if a plugin is available for a project type + * + * @param string $projectType Project type identifier + * @return bool True if plugin is available + */ + public function hasPlugin(string $projectType): bool + { + return PluginRegistry::hasPlugin($projectType); + } + + /** + * Get the logger instance + * + * @return AuditLogger + */ + public function getLogger(): AuditLogger + { + return $this->logger; + } + + /** + * Get the metrics collector instance + * + * @return MetricsCollector + */ + public function getMetricsCollector(): MetricsCollector + { + return $this->metricsCollector; + } + + /** + * Set default configuration for all plugins + * + * @param array $config Default configuration + * @return void + */ + public function setDefaultConfig(array $config): void + { + $this->defaultConfig = $config; + } + + /** + * Get default configuration + * + * @return array Default configuration + */ + public function getDefaultConfig(): array + { + return $this->defaultConfig; + } + + /** + * Run health check using appropriate plugin + * + * @param string $projectType Project type identifier + * @param string $projectPath Path to project directory + * @param array $projectConfig Project configuration + * @return array Health check result + */ + public function runHealthCheck( + string $projectType, + string $projectPath, + array $projectConfig = [] + ): array { + $plugin = $this->create($projectType); + + if ($plugin === null) { + return [ + 'healthy' => false, + 'score' => 0, + 'issues' => [ + [ + 'severity' => 'critical', + 'message' => "Plugin not found for project type: {$projectType}", + 'category' => 'plugin', + ], + ], + ]; + } + + return $plugin->healthCheck($projectPath, $projectConfig); + } + + /** + * Collect metrics using appropriate plugin + * + * @param string $projectType Project type identifier + * @param string $projectPath Path to project directory + * @param array $projectConfig Project configuration + * @return array Metrics data + */ + public function collectMetrics( + string $projectType, + string $projectPath, + array $projectConfig = [] + ): array { + $plugin = $this->create($projectType); + + if ($plugin === null) { + return [ + 'error' => "Plugin not found for project type: {$projectType}", + 'metrics' => [], + ]; + } + + return $plugin->collectMetrics($projectPath, $projectConfig); + } + + /** + * Check project readiness using appropriate plugin + * + * @param string $projectType Project type identifier + * @param string $projectPath Path to project directory + * @param array $projectConfig Project configuration + * @return array Readiness result + */ + public function checkReadiness( + string $projectType, + string $projectPath, + array $projectConfig = [] + ): array { + $plugin = $this->create($projectType); + + if ($plugin === null) { + return [ + 'ready' => false, + 'blockers' => ["Plugin not found for project type: {$projectType}"], + 'warnings' => [], + 'score' => 0, + ]; + } + + return $plugin->checkReadiness($projectPath, $projectConfig); + } +} diff --git a/lib/Enterprise/PluginRegistry.php b/lib/Enterprise/PluginRegistry.php new file mode 100644 index 0000000..6e10a63 --- /dev/null +++ b/lib/Enterprise/PluginRegistry.php @@ -0,0 +1,266 @@ + Map of project types to plugin class names */ + private static $pluginClasses = [ + 'joomla' => JoomlaPlugin::class, + 'dolibarr' => DolibarrPlugin::class, + 'generic' => GenericPlugin::class, + 'documentation' => DocumentationPlugin::class, + 'nodejs' => NodeJsPlugin::class, + 'python' => PythonPlugin::class, + 'terraform' => TerraformPlugin::class, + 'wordpress' => WordPressPlugin::class, + 'mobile' => MobilePlugin::class, + 'api' => ApiPlugin::class, + ]; + + /** @var array Instantiated plugins */ + private static $plugins = []; + + /** @var AuditLogger|null Shared audit logger */ + private static $logger = null; + + /** @var MetricsCollector|null Shared metrics collector */ + private static $metricsCollector = null; + + /** + * Set shared logger for all plugins + * + * @param AuditLogger $logger Audit logger instance + * @return void + */ + public static function setLogger(AuditLogger $logger): void + { + self::$logger = $logger; + } + + /** + * Set shared metrics collector for all plugins + * + * @param MetricsCollector $metricsCollector Metrics collector instance + * @return void + */ + public static function setMetricsCollector(MetricsCollector $metricsCollector): void + { + self::$metricsCollector = $metricsCollector; + } + + /** + * Register a custom plugin for a project type + * + * @param string $projectType Project type identifier + * @param string $pluginClass Fully qualified plugin class name + * @return void + * @throws \InvalidArgumentException If plugin class doesn't implement ProjectPluginInterface + */ + public static function registerPlugin(string $projectType, string $pluginClass): void + { + if (!class_exists($pluginClass)) { + throw new \InvalidArgumentException("Plugin class does not exist: {$pluginClass}"); + } + + if (!is_subclass_of($pluginClass, ProjectPluginInterface::class)) { + throw new \InvalidArgumentException( + "Plugin class must implement ProjectPluginInterface: {$pluginClass}" + ); + } + + self::$pluginClasses[$projectType] = $pluginClass; + + // Clear cached instance if exists + if (isset(self::$plugins[$projectType])) { + unset(self::$plugins[$projectType]); + } + } + + /** + * Get plugin instance for a project type + * + * @param string $projectType Project type identifier + * @param array $config Optional plugin configuration + * @return ProjectPluginInterface|null Plugin instance or null if not found + */ + public static function getPlugin(string $projectType, array $config = []): ?ProjectPluginInterface + { + // Check if plugin is already instantiated + if (isset(self::$plugins[$projectType])) { + return self::$plugins[$projectType]; + } + + // Check if plugin class is registered + if (!isset(self::$pluginClasses[$projectType])) { + return null; + } + + // Instantiate plugin + $pluginClass = self::$pluginClasses[$projectType]; + $plugin = new $pluginClass(self::$logger, self::$metricsCollector, $config); + + // Cache plugin instance + self::$plugins[$projectType] = $plugin; + + return $plugin; + } + + /** + * Get all registered project types + * + * @return array List of project type identifiers + */ + public static function getRegisteredTypes(): array + { + return array_keys(self::$pluginClasses); + } + + /** + * Get all registered plugins + * + * @param array $config Optional plugin configuration + * @return array Map of project types to plugin instances + */ + public static function getAllPlugins(array $config = []): array + { + $plugins = []; + foreach (self::$pluginClasses as $projectType => $pluginClass) { + $plugins[$projectType] = self::getPlugin($projectType, $config); + } + return $plugins; + } + + /** + * Check if a plugin is registered for a project type + * + * @param string $projectType Project type identifier + * @return bool True if plugin is registered + */ + public static function hasPlugin(string $projectType): bool + { + return isset(self::$pluginClasses[$projectType]); + } + + /** + * Unregister a plugin + * + * @param string $projectType Project type identifier + * @return void + */ + public static function unregisterPlugin(string $projectType): void + { + unset(self::$pluginClasses[$projectType]); + unset(self::$plugins[$projectType]); + } + + /** + * Clear all plugin instances (forces re-instantiation) + * + * @return void + */ + public static function clearCache(): void + { + self::$plugins = []; + } + + /** + * Get plugin information + * + * @param string $projectType Project type identifier + * @return array|null Plugin info or null if not found + */ + public static function getPluginInfo(string $projectType): ?array + { + $plugin = self::getPlugin($projectType); + if ($plugin === null) { + return null; + } + + return [ + 'project_type' => $plugin->getProjectType(), + 'plugin_name' => $plugin->getPluginName(), + 'plugin_version' => $plugin->getPluginVersion(), + 'required_files' => $plugin->getRequiredFiles(), + 'recommended_files' => $plugin->getRecommendedFiles(), + 'best_practices_count' => count($plugin->getBestPractices()), + 'commands_count' => count($plugin->getCommands()), + ]; + } + + /** + * Get all plugins information + * + * @return array Map of project types to plugin information + */ + public static function getAllPluginsInfo(): array + { + $info = []; + foreach (self::getRegisteredTypes() as $projectType) { + $info[$projectType] = self::getPluginInfo($projectType); + } + return $info; + } + + /** + * Find plugin by feature/capability + * + * @param string $feature Feature name (e.g., 'package_manager', 'type_checking') + * @return array List of project types supporting the feature + */ + public static function findPluginsByFeature(string $feature): array + { + $matches = []; + foreach (self::getRegisteredTypes() as $projectType) { + $plugin = self::getPlugin($projectType); + if ($plugin !== null) { + $bestPractices = $plugin->getBestPractices(); + foreach ($bestPractices as $practice) { + if (stripos($practice['title'] ?? '', $feature) !== false || + stripos($practice['description'] ?? '', $feature) !== false) { + $matches[] = $projectType; + break; + } + } + } + } + return $matches; + } + + /** + * Get plugin registry statistics + * + * @return array Registry statistics + */ + public static function getStatistics(): array + { + return [ + 'total_plugins' => count(self::$pluginClasses), + 'instantiated_plugins' => count(self::$plugins), + 'registered_types' => self::getRegisteredTypes(), + 'has_logger' => self::$logger !== null, + 'has_metrics_collector' => self::$metricsCollector !== null, + ]; + } +} diff --git a/lib/Enterprise/Plugins/ApiPlugin.php b/lib/Enterprise/Plugins/ApiPlugin.php new file mode 100644 index 0000000..c09d29f --- /dev/null +++ b/lib/Enterprise/Plugins/ApiPlugin.php @@ -0,0 +1,802 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise.Plugins + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/Plugins/ApiPlugin.php + * VERSION: 04.06.00 + * BRIEF: Enterprise plugin for API/Microservices projects + */ + +declare(strict_types=1); + +namespace MokoEnterprise\Plugins; + +use MokoEnterprise\AbstractProjectPlugin; + +/** + * API/Microservices Project Plugin + * + * Provides validation, metrics, and management capabilities for + * API and microservices projects (REST, GraphQL, gRPC). + */ +class ApiPlugin extends AbstractProjectPlugin +{ + /** + * {@inheritdoc} + */ + public function getProjectType(): string + { + return 'api'; + } + + /** + * {@inheritdoc} + */ + public function getPluginName(): string + { + return 'API/Microservices Enterprise Plugin'; + } + + /** + * {@inheritdoc} + */ + public function validateProject(array $config, string $projectPath): array + { + $errors = []; + $warnings = []; + + $apiType = $this->detectAPIType($projectPath); + + // Check for API documentation + if (!$this->hasAPIDocumentation($projectPath, $apiType)) { + $warnings[] = 'No API documentation found (OpenAPI, GraphQL schema, etc.)'; + } + + // Check for proper error handling + if (!$this->hasErrorHandling($projectPath)) { + $warnings[] = 'Consider implementing standardized error handling'; + } + + // Check for authentication + if (!$this->hasAuthentication($projectPath)) { + $warnings[] = 'No authentication mechanism detected'; + } + + // Check for rate limiting + if (!$this->hasRateLimiting($projectPath)) { + $warnings[] = 'Consider implementing rate limiting'; + } + + // Check for logging + if (!$this->hasLogging($projectPath)) { + $warnings[] = 'No logging configuration found'; + } + + // Check for input validation + if (!$this->hasInputValidation($projectPath)) { + $warnings[] = 'Ensure proper input validation is implemented'; + } + + // Check for CORS configuration + if (!$this->hasCORSConfig($projectPath)) { + $warnings[] = 'No CORS configuration found'; + } + + // Check for tests + if (!$this->hasTests($projectPath)) { + $warnings[] = 'No API tests found'; + } + + $this->log( + 'API project validation completed', + 'info', + ['errors' => count($errors), 'warnings' => count($warnings), 'type' => $apiType] + ); + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + 'warnings' => $warnings, + ]; + } + + /** + * {@inheritdoc} + */ + public function collectMetrics(string $projectPath, array $config): array + { + $apiType = $this->detectAPIType($projectPath); + $language = $this->detectLanguage($projectPath); + + $metrics = [ + 'api_type' => $apiType, + 'language' => $language, + 'has_documentation' => $this->hasAPIDocumentation($projectPath, $apiType), + 'has_authentication' => $this->hasAuthentication($projectPath), + 'has_authorization' => $this->hasAuthorization($projectPath), + 'has_rate_limiting' => $this->hasRateLimiting($projectPath), + 'has_logging' => $this->hasLogging($projectPath), + 'has_monitoring' => $this->hasMonitoring($projectPath), + 'has_caching' => $this->hasCaching($projectPath), + 'has_tests' => $this->hasTests($projectPath), + 'has_docker' => $this->fileExists($projectPath, 'Dockerfile'), + 'has_ci' => $this->hasCICD($projectPath), + 'has_kubernetes' => $this->hasKubernetes($projectPath), + ]; + + // Count endpoints + $metrics['endpoints_count'] = $this->countEndpoints($projectPath, $apiType, $language); + + // Count routes/controllers + $metrics['routes_count'] = $this->countRoutes($projectPath, $language); + + // Count middleware + $metrics['middleware_count'] = $this->countMiddleware($projectPath, $language); + + // Count lines of code + $metrics['total_lines'] = $this->countTotalLines($projectPath, $language); + + // Detect framework + $metrics['framework'] = $this->detectFramework($projectPath, $language); + + // Record metrics + $this->recordMetric('api', 'endpoints', $metrics['endpoints_count']); + $this->recordMetric('api', 'total_lines', $metrics['total_lines']); + + $this->log('Collected API metrics', 'info', $metrics); + + return $metrics; + } + + /** + * {@inheritdoc} + */ + public function healthCheck(string $projectPath, array $config): array + { + $issues = []; + $score = 100; + + $apiType = $this->detectAPIType($projectPath); + + // Check for API documentation + if (!$this->hasAPIDocumentation($projectPath, $apiType)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing API documentation', + ]; + $score -= 15; + } + + // Check for authentication + if (!$this->hasAuthentication($projectPath)) { + $issues[] = [ + 'severity' => 'critical', + 'message' => 'No authentication mechanism detected', + ]; + $score -= 20; + } + + // Check for authorization + if (!$this->hasAuthorization($projectPath)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'No authorization/access control detected', + ]; + $score -= 10; + } + + // Check for input validation + if (!$this->hasInputValidation($projectPath)) { + $issues[] = [ + 'severity' => 'critical', + 'message' => 'Input validation may be missing', + ]; + $score -= 20; + } + + // Check for rate limiting + if (!$this->hasRateLimiting($projectPath)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'No rate limiting configured', + ]; + $score -= 10; + } + + // Check for logging + if (!$this->hasLogging($projectPath)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'No logging configuration found', + ]; + $score -= 10; + } + + // Check for tests + if (!$this->hasTests($projectPath)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'No API tests found', + ]; + $score -= 15; + } + + // Check for README + if (!$this->fileExists($projectPath, 'README.md')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing README.md', + ]; + $score -= 5; + } + + // Check for environment configuration + if (!$this->fileExists($projectPath, '.env.example')) { + $issues[] = [ + 'severity' => 'info', + 'message' => 'Missing .env.example for configuration', + ]; + $score -= 5; + } + + $score = max(0, $score); + + $this->log('API health check completed', 'info', [ + 'score' => $score, + 'issues_count' => count($issues), + 'api_type' => $apiType, + ]); + + return [ + 'healthy' => $score >= 70, + 'score' => $score, + 'issues' => $issues, + ]; + } + + /** + * {@inheritdoc} + */ + public function getRequiredFiles(): array + { + return [ + 'API documentation (openapi.yaml, swagger.json, schema.graphql)', + 'Authentication configuration', + 'Error handling middleware', + ]; + } + + /** + * {@inheritdoc} + */ + public function getRecommendedFiles(): array + { + return [ + 'README.md', + '.env.example', + 'openapi.yaml or swagger.json (REST)', + 'schema.graphql (GraphQL)', + 'Dockerfile', + 'docker-compose.yml', + 'kubernetes/*.yaml', + 'tests/ or test/', + '.github/workflows/* or .gitea/workflows/* or .gitlab-ci.yml', + 'middleware/ or middlewares/', + ]; + } + + /** + * {@inheritdoc} + */ + public function getConfigSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'api_type' => [ + 'type' => 'string', + 'enum' => ['rest', 'graphql', 'grpc', 'soap', 'websocket'], + 'description' => 'API type', + ], + 'authentication' => [ + 'type' => 'string', + 'enum' => ['jwt', 'oauth2', 'api-key', 'basic', 'none'], + 'description' => 'Authentication method', + ], + 'framework' => [ + 'type' => 'string', + 'description' => 'Framework used (Express, FastAPI, Spring Boot, etc.)', + ], + 'enable_rate_limiting' => [ + 'type' => 'boolean', + 'description' => 'Enable rate limiting', + ], + 'enable_caching' => [ + 'type' => 'boolean', + 'description' => 'Enable response caching', + ], + 'port' => [ + 'type' => 'integer', + 'description' => 'API server port', + ], + ], + 'required' => ['api_type'], + ]; + } + + /** + * {@inheritdoc} + */ + public function getBestPractices(): array + { + return [ + 'Document API with OpenAPI/Swagger or GraphQL schema', + 'Implement proper authentication (JWT, OAuth2)', + 'Use authorization for access control', + 'Validate all input data', + 'Implement rate limiting to prevent abuse', + 'Use standardized error responses', + 'Implement comprehensive logging', + 'Add monitoring and metrics collection', + 'Use HTTPS/TLS for all endpoints', + 'Implement CORS properly', + 'Version your API endpoints', + 'Use pagination for list endpoints', + 'Implement caching where appropriate', + 'Write comprehensive API tests', + 'Use Docker for consistent deployments', + ]; + } + + /** + * Detect API type + */ + private function detectAPIType(string $projectPath): string + { + // GraphQL + if ($this->fileExists($projectPath, 'schema.graphql') || + $this->fileExists($projectPath, '*.graphql')) { + return 'graphql'; + } + + // gRPC + if ($this->fileExists($projectPath, '*.proto')) { + return 'grpc'; + } + + // REST (OpenAPI/Swagger) + if ($this->fileExists($projectPath, 'openapi.yaml') || + $this->fileExists($projectPath, 'openapi.json') || + $this->fileExists($projectPath, 'swagger.yaml') || + $this->fileExists($projectPath, 'swagger.json')) { + return 'rest'; + } + + // Check code for REST patterns + $files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}'); + foreach (array_slice($files, 0, 10) as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content) { + if (preg_match('/@(Get|Post|Put|Delete|Patch)\(/', $content) || + preg_match('/(get|post|put|delete|patch)\s*\([\'"]/', $content)) { + return 'rest'; + } + } + } + } + + return 'rest'; + } + + /** + * Detect language + */ + private function detectLanguage(string $projectPath): string + { + $counts = [ + 'js' => $this->countFiles($projectPath, '**/*.js'), + 'ts' => $this->countFiles($projectPath, '**/*.ts'), + 'py' => $this->countFiles($projectPath, '**/*.py'), + 'java' => $this->countFiles($projectPath, '**/*.java'), + 'go' => $this->countFiles($projectPath, '**/*.go'), + 'php' => $this->countFiles($projectPath, '**/*.php'), + ]; + + arsort($counts); + $topLang = array_key_first($counts); + + $langMap = [ + 'js' => 'JavaScript', + 'ts' => 'TypeScript', + 'py' => 'Python', + 'java' => 'Java', + 'go' => 'Go', + 'php' => 'PHP', + ]; + + return $langMap[$topLang] ?? 'Unknown'; + } + + /** + * Check for API documentation + */ + private function hasAPIDocumentation(string $projectPath, string $apiType): bool + { + if ($apiType === 'graphql') { + return $this->fileExists($projectPath, 'schema.graphql') || + $this->countFiles($projectPath, '**/*.graphql') > 0; + } + + if ($apiType === 'grpc') { + return $this->countFiles($projectPath, '**/*.proto') > 0; + } + + // REST + return $this->fileExists($projectPath, 'openapi.yaml') || + $this->fileExists($projectPath, 'openapi.json') || + $this->fileExists($projectPath, 'swagger.yaml') || + $this->fileExists($projectPath, 'swagger.json'); + } + + /** + * Check for error handling + */ + private function hasErrorHandling(string $projectPath): bool + { + $files = $this->findFiles($projectPath, '**/*.{js,ts,py}'); + + foreach (array_slice($files, 0, 10) as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && ( + strpos($content, 'errorHandler') !== false || + strpos($content, 'error_handler') !== false || + preg_match('/class\s+\w*Error/', $content) + )) { + return true; + } + } + } + + return false; + } + + /** + * Check for authentication + */ + private function hasAuthentication(string $projectPath): bool + { + $files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}'); + + foreach (array_slice($files, 0, 15) as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && ( + stripos($content, 'jwt') !== false || + stripos($content, 'oauth') !== false || + stripos($content, 'passport') !== false || + stripos($content, 'authenticate') !== false || + stripos($content, 'api_key') !== false || + stripos($content, 'bearer') !== false + )) { + return true; + } + } + } + + return false; + } + + /** + * Check for authorization + */ + private function hasAuthorization(string $projectPath): bool + { + $files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}'); + + foreach (array_slice($files, 0, 10) as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && ( + stripos($content, 'authorize') !== false || + stripos($content, 'permission') !== false || + stripos($content, 'role') !== false || + stripos($content, 'acl') !== false + )) { + return true; + } + } + } + + return false; + } + + /** + * Check for rate limiting + */ + private function hasRateLimiting(string $projectPath): bool + { + $files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}'); + + foreach (array_slice($files, 0, 10) as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && ( + stripos($content, 'rate_limit') !== false || + stripos($content, 'rateLimit') !== false || + stripos($content, 'throttle') !== false + )) { + return true; + } + } + } + + return false; + } + + /** + * Check for logging + */ + private function hasLogging(string $projectPath): bool + { + $files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}'); + + foreach (array_slice($files, 0, 10) as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && ( + stripos($content, 'logger') !== false || + stripos($content, 'winston') !== false || + stripos($content, 'logging') !== false || + stripos($content, 'log.') !== false + )) { + return true; + } + } + } + + return false; + } + + /** + * Check for monitoring + */ + private function hasMonitoring(string $projectPath): bool + { + $files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}'); + + foreach (array_slice($files, 0, 10) as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && ( + stripos($content, 'prometheus') !== false || + stripos($content, 'metrics') !== false || + stripos($content, 'monitoring') !== false || + stripos($content, 'newrelic') !== false + )) { + return true; + } + } + } + + return false; + } + + /** + * Check for caching + */ + private function hasCaching(string $projectPath): bool + { + $files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}'); + + foreach (array_slice($files, 0, 10) as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && ( + stripos($content, 'redis') !== false || + stripos($content, 'cache') !== false || + stripos($content, 'memcached') !== false + )) { + return true; + } + } + } + + return false; + } + + /** + * Check for input validation + */ + private function hasInputValidation(string $projectPath): bool + { + $files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}'); + + foreach (array_slice($files, 0, 10) as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && ( + stripos($content, 'validate') !== false || + stripos($content, 'validator') !== false || + stripos($content, 'joi') !== false || + stripos($content, 'yup') !== false + )) { + return true; + } + } + } + + return false; + } + + /** + * Check for CORS configuration + */ + private function hasCORSConfig(string $projectPath): bool + { + $files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}'); + + foreach (array_slice($files, 0, 10) as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && stripos($content, 'cors') !== false) { + return true; + } + } + } + + return false; + } + + /** + * Check for tests + */ + private function hasTests(string $projectPath): bool + { + return $this->fileExists($projectPath, 'tests') || + $this->fileExists($projectPath, 'test') || + $this->fileExists($projectPath, '__tests__') || + $this->countFiles($projectPath, '**/*.test.*') > 0 || + $this->countFiles($projectPath, '**/*.spec.*') > 0; + } + + /** + * Check for CI/CD + */ + private function hasCICD(string $projectPath): bool + { + return $this->fileExists($projectPath, '.github/workflows') || + $this->fileExists($projectPath, '.gitea/workflows') || + $this->fileExists($projectPath, '.gitlab-ci.yml') || + $this->fileExists($projectPath, 'Jenkinsfile') || + $this->fileExists($projectPath, '.circleci'); + } + + /** + * Check for Kubernetes + */ + private function hasKubernetes(string $projectPath): bool + { + return $this->fileExists($projectPath, 'k8s') || + $this->fileExists($projectPath, 'kubernetes') || + $this->countFiles($projectPath, '**/*.yaml') > 0; + } + + /** + * Count endpoints + */ + private function countEndpoints(string $projectPath, string $apiType, string $language): int + { + $count = 0; + $pattern = $language === 'Python' ? '**/*.py' : '**/*.{js,ts}'; + $files = $this->findFiles($projectPath, $pattern); + + foreach ($files as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content) { + $count += preg_match_all('/@(app\.)?(get|post|put|delete|patch)\s*\(/', $content); + $count += preg_match_all('/\.(get|post|put|delete|patch)\s*\([\'"]/', $content); + } + } + } + + return $count; + } + + /** + * Count routes + */ + private function countRoutes(string $projectPath, string $language): int + { + $routeFiles = array_merge( + $this->findFiles($projectPath, '**/routes/**/*.{js,ts,py}'), + $this->findFiles($projectPath, '**/route*.{js,ts,py}') + ); + + return count($routeFiles); + } + + /** + * Count middleware + */ + private function countMiddleware(string $projectPath, string $language): int + { + $middlewareFiles = array_merge( + $this->findFiles($projectPath, '**/middleware/**/*.{js,ts,py}'), + $this->findFiles($projectPath, '**/middlewares/**/*.{js,ts,py}') + ); + + return count($middlewareFiles); + } + + /** + * Count total lines + */ + private function countTotalLines(string $projectPath, string $language): int + { + $extMap = [ + 'JavaScript' => ['js'], + 'TypeScript' => ['ts'], + 'Python' => ['py'], + 'Java' => ['java'], + 'Go' => ['go'], + 'PHP' => ['php'], + ]; + + $extensions = $extMap[$language] ?? ['js', 'ts', 'py']; + $totalLines = 0; + + foreach ($extensions as $ext) { + $files = $this->findFiles($projectPath, "**/*.{$ext}"); + foreach ($files as $file) { + if (is_file($file)) { + $totalLines += count(file($file)); + } + } + } + + return $totalLines; + } + + /** + * Detect framework + */ + private function detectFramework(string $projectPath, string $language): string + { + if ($language === 'JavaScript' || $language === 'TypeScript') { + $packageData = $this->parseJsonFile($projectPath, 'package.json'); + if ($packageData) { + $deps = array_merge( + $packageData['dependencies'] ?? [], + $packageData['devDependencies'] ?? [] + ); + + if (isset($deps['express'])) return 'Express'; + if (isset($deps['fastify'])) return 'Fastify'; + if (isset($deps['@nestjs/core'])) return 'NestJS'; + if (isset($deps['koa'])) return 'Koa'; + } + } + + if ($language === 'Python') { + $requirements = $this->readFile($projectPath, 'requirements.txt'); + if ($requirements) { + if (stripos($requirements, 'fastapi') !== false) return 'FastAPI'; + if (stripos($requirements, 'flask') !== false) return 'Flask'; + if (stripos($requirements, 'django') !== false) return 'Django'; + } + } + + return 'Unknown'; + } +} diff --git a/lib/Enterprise/Plugins/DocumentationPlugin.php b/lib/Enterprise/Plugins/DocumentationPlugin.php new file mode 100644 index 0000000..f3db51c --- /dev/null +++ b/lib/Enterprise/Plugins/DocumentationPlugin.php @@ -0,0 +1,625 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise.Plugins + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/Plugins/DocumentationPlugin.php + * VERSION: 04.06.00 + * BRIEF: Enterprise plugin for documentation projects + */ + +declare(strict_types=1); + +namespace MokoEnterprise\Plugins; + +use MokoEnterprise\AbstractProjectPlugin; + +/** + * Documentation Project Plugin + * + * Provides validation, metrics, and management capabilities for + * documentation-focused projects (Sphinx, MkDocs, Docusaurus, etc.). + */ +class DocumentationPlugin extends AbstractProjectPlugin +{ + /** + * {@inheritdoc} + */ + public function getProjectType(): string + { + return 'documentation'; + } + + /** + * {@inheritdoc} + */ + public function getPluginName(): string + { + return 'Documentation Project Plugin'; + } + + /** + * {@inheritdoc} + */ + public function validateProject(array $config, string $projectPath): array + { + $errors = []; + $warnings = []; + + $docType = $this->detectDocumentationType($projectPath); + + // Validate based on documentation type + switch ($docType) { + case 'sphinx': + if (!$this->fileExists($projectPath, 'conf.py')) { + $errors[] = 'Sphinx project missing conf.py'; + } + if (!$this->fileExists($projectPath, 'index.rst')) { + $errors[] = 'Sphinx project missing index.rst'; + } + break; + + case 'mkdocs': + if (!$this->fileExists($projectPath, 'mkdocs.yml')) { + $errors[] = 'MkDocs project missing mkdocs.yml'; + } + if (!$this->fileExists($projectPath, 'docs/index.md')) { + $warnings[] = 'MkDocs project missing docs/index.md'; + } + break; + + case 'docusaurus': + if (!$this->fileExists($projectPath, 'docusaurus.config.js')) { + $errors[] = 'Docusaurus project missing docusaurus.config.js'; + } + if (!$this->fileExists($projectPath, 'package.json')) { + $errors[] = 'Docusaurus project missing package.json'; + } + break; + + case 'jekyll': + if (!$this->fileExists($projectPath, '_config.yml')) { + $errors[] = 'Jekyll project missing _config.yml'; + } + break; + + default: + if (!$this->fileExists($projectPath, 'README.md')) { + $warnings[] = 'No README.md found'; + } + } + + // Check for table of contents + if (!$this->hasTableOfContents($projectPath, $docType)) { + $warnings[] = 'No clear table of contents structure found'; + } + + // Check for images directory + if (!$this->fileExists($projectPath, 'images') && + !$this->fileExists($projectPath, 'assets') && + !$this->fileExists($projectPath, 'static')) { + $warnings[] = 'No images/assets directory found'; + } + + // Check for broken links (basic check) + $brokenLinks = $this->checkForBrokenLinks($projectPath); + if ($brokenLinks > 0) { + $warnings[] = "Found {$brokenLinks} potential broken internal links"; + } + + $this->log( + 'Documentation project validation completed', + 'info', + ['errors' => count($errors), 'warnings' => count($warnings), 'type' => $docType] + ); + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + 'warnings' => $warnings, + ]; + } + + /** + * {@inheritdoc} + */ + public function collectMetrics(string $projectPath, array $config): array + { + $docType = $this->detectDocumentationType($projectPath); + + $metrics = [ + 'documentation_type' => $docType, + 'markdown_files' => $this->countFiles($projectPath, '**/*.md'), + 'rst_files' => $this->countFiles($projectPath, '**/*.rst'), + 'html_files' => $this->countFiles($projectPath, '**/*.html'), + 'image_files' => $this->countImageFiles($projectPath), + 'total_pages' => $this->countTotalPages($projectPath, $docType), + 'total_words' => $this->countTotalWords($projectPath, $docType), + 'has_search' => $this->hasSearch($projectPath, $docType), + 'has_versioning' => $this->hasVersioning($projectPath, $docType), + 'has_i18n' => $this->hasInternationalization($projectPath, $docType), + ]; + + // Check for code examples + $metrics['code_examples'] = $this->countCodeExamples($projectPath); + + // Check structure depth + $metrics['max_depth'] = $this->getDocumentationDepth($projectPath); + + // Record metrics + $this->recordMetric('documentation', 'markdown_files', $metrics['markdown_files']); + $this->recordMetric('documentation', 'total_pages', $metrics['total_pages']); + $this->recordMetric('documentation', 'total_words', $metrics['total_words']); + + $this->log('Collected documentation metrics', 'info', $metrics); + + return $metrics; + } + + /** + * {@inheritdoc} + */ + public function healthCheck(string $projectPath, array $config): array + { + $issues = []; + $score = 100; + + $docType = $this->detectDocumentationType($projectPath); + + // Check for index/home page + if (!$this->hasIndexPage($projectPath, $docType)) { + $issues[] = [ + 'severity' => 'critical', + 'message' => 'Missing index/home page', + ]; + $score -= 20; + } + + // Check for configuration + if (!$this->hasConfiguration($projectPath, $docType)) { + $issues[] = [ + 'severity' => 'critical', + 'message' => 'Missing documentation configuration file', + ]; + $score -= 20; + } + + // Check for broken links + $brokenLinks = $this->checkForBrokenLinks($projectPath); + if ($brokenLinks > 0) { + $issues[] = [ + 'severity' => 'warning', + 'message' => "Found {$brokenLinks} potential broken internal links", + ]; + $score -= min(20, $brokenLinks * 2); + } + + // Check for table of contents + if (!$this->hasTableOfContents($projectPath, $docType)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'No clear navigation/table of contents', + ]; + $score -= 10; + } + + // Check page count + $pageCount = $this->countTotalPages($projectPath, $docType); + if ($pageCount < 3) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Very few documentation pages found', + ]; + $score -= 10; + } + + // Check for search functionality + if (!$this->hasSearch($projectPath, $docType)) { + $issues[] = [ + 'severity' => 'info', + 'message' => 'No search functionality configured', + ]; + $score -= 5; + } + + // Check for build output in repository + if ($this->hasBuildOutput($projectPath, $docType)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Build output detected in repository (should be in .gitignore)', + ]; + $score -= 5; + } + + $score = max(0, $score); + + $this->log('Documentation health check completed', 'info', [ + 'score' => $score, + 'issues_count' => count($issues), + 'doc_type' => $docType, + ]); + + return [ + 'healthy' => $score >= 70, + 'score' => $score, + 'issues' => $issues, + ]; + } + + /** + * {@inheritdoc} + */ + public function getRequiredFiles(): array + { + return [ + 'README.md or index.md or index.rst', + 'Configuration file (conf.py, mkdocs.yml, docusaurus.config.js, _config.yml)', + ]; + } + + /** + * {@inheritdoc} + */ + public function getRecommendedFiles(): array + { + return [ + 'Table of contents or navigation configuration', + 'images/ or assets/ directory', + 'CONTRIBUTING.md', + '.gitignore', + 'requirements.txt or package.json', + 'build/ or site/ in .gitignore', + ]; + } + + /** + * {@inheritdoc} + */ + public function getConfigSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'documentation_type' => [ + 'type' => 'string', + 'enum' => ['sphinx', 'mkdocs', 'docusaurus', 'jekyll', 'hugo', 'gitbook', 'custom'], + 'description' => 'Documentation framework', + ], + 'build_command' => [ + 'type' => 'string', + 'description' => 'Command to build documentation', + ], + 'output_directory' => [ + 'type' => 'string', + 'description' => 'Build output directory', + ], + 'enable_search' => [ + 'type' => 'boolean', + 'description' => 'Enable search functionality', + ], + 'enable_versioning' => [ + 'type' => 'boolean', + 'description' => 'Enable version management', + ], + ], + 'required' => ['documentation_type'], + ]; + } + + /** + * {@inheritdoc} + */ + public function getBestPractices(): array + { + return [ + 'Use clear hierarchical structure with logical organization', + 'Include comprehensive table of contents or navigation', + 'Write in clear, concise language appropriate for audience', + 'Add code examples with proper syntax highlighting', + 'Include screenshots and diagrams where helpful', + 'Maintain consistent formatting and style', + 'Use cross-references and internal links effectively', + 'Enable search functionality for easy navigation', + 'Version documentation alongside code releases', + 'Keep documentation up to date with code changes', + 'Include getting started and installation guides', + 'Add troubleshooting and FAQ sections', + 'Use admonitions (notes, warnings) appropriately', + 'Implement responsive design for mobile viewing', + 'Exclude build output from version control', + ]; + } + + /** + * Detect documentation type + */ + private function detectDocumentationType(string $projectPath): string + { + if ($this->fileExists($projectPath, 'conf.py')) { + return 'sphinx'; + } + if ($this->fileExists($projectPath, 'mkdocs.yml')) { + return 'mkdocs'; + } + if ($this->fileExists($projectPath, 'docusaurus.config.js')) { + return 'docusaurus'; + } + if ($this->fileExists($projectPath, '_config.yml')) { + return 'jekyll'; + } + if ($this->fileExists($projectPath, 'config.toml') || $this->fileExists($projectPath, 'config.yaml')) { + return 'hugo'; + } + if ($this->fileExists($projectPath, 'book.json')) { + return 'gitbook'; + } + + return 'custom'; + } + + /** + * Check for index page + */ + private function hasIndexPage(string $projectPath, string $docType): bool + { + $indexFiles = ['index.md', 'index.rst', 'index.html', 'README.md', 'docs/index.md']; + + foreach ($indexFiles as $file) { + if ($this->fileExists($projectPath, $file)) { + return true; + } + } + + return false; + } + + /** + * Check for configuration + */ + private function hasConfiguration(string $projectPath, string $docType): bool + { + $configFiles = [ + 'conf.py', + 'mkdocs.yml', + 'docusaurus.config.js', + '_config.yml', + 'config.toml', + 'book.json', + ]; + + foreach ($configFiles as $file) { + if ($this->fileExists($projectPath, $file)) { + return true; + } + } + + return false; + } + + /** + * Check for table of contents + */ + private function hasTableOfContents(string $projectPath, string $docType): bool + { + // Check for TOC files + $tocFiles = ['SUMMARY.md', 'toc.yml', 'toc.rst', 'sidebar.js', 'sidebars.js']; + + foreach ($tocFiles as $file) { + if ($this->fileExists($projectPath, $file)) { + return true; + } + } + + // Check configuration files + if ($docType === 'mkdocs' && $this->fileExists($projectPath, 'mkdocs.yml')) { + $content = $this->readFile($projectPath, 'mkdocs.yml'); + if ($content && strpos($content, 'nav:') !== false) { + return true; + } + } + + return false; + } + + /** + * Count image files + */ + private function countImageFiles(string $projectPath): int + { + $count = 0; + $extensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp']; + + foreach ($extensions as $ext) { + $count += $this->countFiles($projectPath, "**/*.{$ext}"); + } + + return $count; + } + + /** + * Count total pages + */ + private function countTotalPages(string $projectPath, string $docType): int + { + if (in_array($docType, ['sphinx', 'rst'])) { + return $this->countFiles($projectPath, '**/*.rst'); + } + + return $this->countFiles($projectPath, '**/*.md'); + } + + /** + * Count total words + */ + private function countTotalWords(string $projectPath, string $docType): int + { + $pattern = in_array($docType, ['sphinx', 'rst']) ? '**/*.rst' : '**/*.md'; + $files = $this->findFiles($projectPath, $pattern); + + $totalWords = 0; + foreach ($files as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content) { + $totalWords += str_word_count(strip_tags($content)); + } + } + } + + return $totalWords; + } + + /** + * Check for search + */ + private function hasSearch(string $projectPath, string $docType): bool + { + switch ($docType) { + case 'mkdocs': + $config = $this->readFile($projectPath, 'mkdocs.yml'); + return $config && strpos($config, 'search') !== false; + + case 'docusaurus': + $config = $this->readFile($projectPath, 'docusaurus.config.js'); + return $config && strpos($config, 'algolia') !== false; + + case 'sphinx': + return true; // Sphinx has built-in search + + default: + return false; + } + } + + /** + * Check for versioning + */ + private function hasVersioning(string $projectPath, string $docType): bool + { + return $this->fileExists($projectPath, 'versions') || + $this->fileExists($projectPath, 'versioned_docs'); + } + + /** + * Check for internationalization + */ + private function hasInternationalization(string $projectPath, string $docType): bool + { + return $this->fileExists($projectPath, 'i18n') || + $this->fileExists($projectPath, 'locales') || + $this->fileExists($projectPath, 'locale'); + } + + /** + * Count code examples + */ + private function countCodeExamples(string $projectPath): int + { + $files = $this->findFiles($projectPath, '**/*.md'); + $count = 0; + + foreach ($files as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content) { + $count += preg_match_all('/```/', $content) / 2; + } + } + } + + return (int)$count; + } + + /** + * Get documentation depth + */ + private function getDocumentationDepth(string $projectPath): int + { + $maxDepth = 0; + $docsDirs = ['docs', 'source', 'content', '.']; + + foreach ($docsDirs as $dir) { + $fullPath = $projectPath . '/' . $dir; + if (!is_dir($fullPath)) { + continue; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($fullPath, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $file) { + $depth = $iterator->getDepth(); + if ($depth > $maxDepth) { + $maxDepth = $depth; + } + } + } + + return $maxDepth; + } + + /** + * Check for broken links + */ + private function checkForBrokenLinks(string $projectPath): int + { + $files = array_merge( + $this->findFiles($projectPath, '**/*.md'), + $this->findFiles($projectPath, '**/*.rst') + ); + + $brokenCount = 0; + $linkedFiles = []; + + foreach ($files as $file) { + if (!is_file($file)) { + continue; + } + + $content = @file_get_contents($file); + if (!$content) { + continue; + } + + // Extract markdown links + preg_match_all('/\[([^\]]+)\]\(([^)]+)\)/', $content, $matches); + foreach ($matches[2] as $link) { + if (strpos($link, 'http') === 0 || strpos($link, '#') === 0) { + continue; // Skip external and anchor links + } + + $linkedPath = dirname($file) . '/' . $link; + if (!file_exists($linkedPath)) { + $brokenCount++; + } + } + } + + return $brokenCount; + } + + /** + * Check for build output + */ + private function hasBuildOutput(string $projectPath, string $docType): bool + { + $buildDirs = ['_build', 'build', 'site', '.docusaurus', '_site']; + + foreach ($buildDirs as $dir) { + if ($this->fileExists($projectPath, $dir)) { + return true; + } + } + + return false; + } +} diff --git a/lib/Enterprise/Plugins/DolibarrPlugin.php b/lib/Enterprise/Plugins/DolibarrPlugin.php new file mode 100644 index 0000000..332a5a4 --- /dev/null +++ b/lib/Enterprise/Plugins/DolibarrPlugin.php @@ -0,0 +1,448 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise.Plugins + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/Plugins/DolibarrPlugin.php + * VERSION: 04.06.00 + * BRIEF: Enterprise plugin for Dolibarr modules + */ + +declare(strict_types=1); + +namespace MokoEnterprise\Plugins; + +use MokoEnterprise\AbstractProjectPlugin; + +/** + * Dolibarr Module Plugin + * + * Provides validation, metrics, and management capabilities for Dolibarr + * modules and custom developments. + */ +class DolibarrPlugin extends AbstractProjectPlugin +{ + /** + * {@inheritdoc} + */ + public function getProjectType(): string + { + return 'dolibarr'; + } + + /** + * {@inheritdoc} + */ + public function getPluginName(): string + { + return 'Dolibarr Enterprise Plugin'; + } + + /** + * {@inheritdoc} + */ + public function validateProject(array $config, string $projectPath): array + { + $errors = []; + $warnings = []; + + // Check for module descriptor + $descriptorFile = $this->findModuleDescriptor($projectPath); + if (!$descriptorFile) { + $errors[] = 'No Dolibarr module descriptor (mod*.class.php) found'; + } else { + $descriptorData = $this->parseDescriptor($descriptorFile); + if (!$descriptorData) { + $errors[] = 'Invalid module descriptor'; + } else { + if (empty($descriptorData['name'])) { + $errors[] = 'Module descriptor missing name'; + } + if (empty($descriptorData['version'])) { + $warnings[] = 'Module descriptor missing version'; + } + } + } + + // Check core directories + $coreDirs = ['core/modules', 'class', 'lib']; + $missingCore = []; + foreach ($coreDirs as $dir) { + if (!$this->fileExists($projectPath, $dir)) { + $missingCore[] = $dir; + } + } + if (count($missingCore) > 1) { + $warnings[] = 'Missing standard directories: ' . implode(', ', $missingCore); + } + + // Check SQL directory + if (!$this->fileExists($projectPath, 'sql')) { + $warnings[] = 'No SQL directory found for database tables'; + } + + // Check language files + if (!$this->countFiles($projectPath, 'langs/*/*.lang')) { + $warnings[] = 'No language files found'; + } + + // Check for documentation + if (!$this->fileExists($projectPath, 'README.md') && + !$this->fileExists($projectPath, 'doc')) { + $warnings[] = 'No documentation found'; + } + + $this->log( + 'Dolibarr module validation completed', + 'info', + ['errors' => count($errors), 'warnings' => count($warnings)] + ); + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + 'warnings' => $warnings, + ]; + } + + /** + * {@inheritdoc} + */ + public function collectMetrics(string $projectPath, array $config): array + { + $metrics = [ + 'module_name' => $this->getModuleName($projectPath), + 'php_files' => $this->countFiles($projectPath, '**/*.php'), + 'class_files' => $this->countFiles($projectPath, 'class/*.class.php'), + 'language_files' => $this->countFiles($projectPath, 'langs/*/*.lang'), + 'sql_files' => $this->countFiles($projectPath, 'sql/*.sql'), + 'has_triggers' => $this->fileExists($projectPath, 'core/triggers'), + 'has_boxes' => $this->fileExists($projectPath, 'core/boxes'), + 'has_hooks' => $this->checkForHooks($projectPath), + 'has_rights' => $this->checkForRights($projectPath), + 'has_api' => $this->fileExists($projectPath, 'class/api_*.class.php'), + 'has_tests' => $this->fileExists($projectPath, 'test'), + ]; + + // Count lines of code + $phpFiles = $this->findFiles($projectPath, '**/*.php'); + $totalLines = 0; + foreach ($phpFiles as $file) { + if (is_file($file)) { + $totalLines += count(file($file)); + } + } + $metrics['total_lines'] = $totalLines; + + // Count database tables + $tables = $this->countDatabaseTables($projectPath); + $metrics['database_tables'] = $tables; + + // Record metrics + $this->recordMetric('dolibarr', 'php_files', $metrics['php_files']); + $this->recordMetric('dolibarr', 'total_lines', $totalLines); + $this->recordMetric('dolibarr', 'database_tables', $tables); + + $this->log('Collected Dolibarr metrics', 'info', $metrics); + + return $metrics; + } + + /** + * {@inheritdoc} + */ + public function healthCheck(string $projectPath, array $config): array + { + $issues = []; + $score = 100; + + // Check module descriptor + $descriptorFile = $this->findModuleDescriptor($projectPath); + if (!$descriptorFile) { + $issues[] = [ + 'severity' => 'critical', + 'message' => 'Missing module descriptor file', + 'file' => 'core/modules/mod*.class.php', + ]; + $score -= 30; + } + + // Check SQL structure + if (!$this->fileExists($projectPath, 'sql/llx_*.sql')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'No SQL table definitions found', + ]; + $score -= 10; + } + + // Check for SQL key file + if (!$this->fileExists($projectPath, 'sql/llx_*.key.sql')) { + $issues[] = [ + 'severity' => 'info', + 'message' => 'No SQL key definitions found', + ]; + $score -= 5; + } + + // Check for proper class structure + $hasClasses = $this->countFiles($projectPath, 'class/*.class.php') > 0; + if (!$hasClasses) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'No class files found in class/ directory', + ]; + $score -= 10; + } + + // Check language files + $langCount = $this->countFiles($projectPath, 'langs/*/*.lang'); + if ($langCount === 0) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'No language files found', + ]; + $score -= 10; + } + + // Check for documentation + if (!$this->fileExists($projectPath, 'README.md')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing README.md documentation', + ]; + $score -= 5; + } + + // Check for license + if (!$this->fileExists($projectPath, 'COPYING')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing COPYING license file', + ]; + $score -= 5; + } + + // Check for permissions setup + if (!$this->checkForRights($projectPath)) { + $issues[] = [ + 'severity' => 'info', + 'message' => 'No permissions/rights defined in module descriptor', + ]; + $score -= 5; + } + + $score = max(0, $score); + + $this->log('Dolibarr health check completed', 'info', [ + 'score' => $score, + 'issues_count' => count($issues), + ]); + + return [ + 'healthy' => $score >= 70, + 'score' => $score, + 'issues' => $issues, + ]; + } + + /** + * {@inheritdoc} + */ + public function getRequiredFiles(): array + { + return [ + 'core/modules/mod*.class.php (module descriptor)', + 'class/*.class.php', + 'langs/*/*.lang', + ]; + } + + /** + * {@inheritdoc} + */ + public function getRecommendedFiles(): array + { + return [ + 'README.md', + 'COPYING', + 'sql/llx_*.sql', + 'sql/llx_*.key.sql', + 'core/triggers/interface_*.class.php', + 'core/boxes/box_*.php', + 'lib/*.lib.php', + 'admin/setup.php', + 'admin/about.php', + 'test/*.php', + ]; + } + + /** + * {@inheritdoc} + */ + public function getConfigSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'module_name' => [ + 'type' => 'string', + 'description' => 'Module name', + ], + 'module_number' => [ + 'type' => 'integer', + 'description' => 'Unique module number (100000-999999)', + 'minimum' => 100000, + 'maximum' => 999999, + ], + 'dolibarr_min_version' => [ + 'type' => 'string', + 'description' => 'Minimum Dolibarr version required', + ], + 'has_database' => [ + 'type' => 'boolean', + 'description' => 'Module requires database tables', + ], + 'has_api' => [ + 'type' => 'boolean', + 'description' => 'Module provides REST API endpoints', + ], + ], + 'required' => ['module_name', 'module_number'], + ]; + } + + /** + * {@inheritdoc} + */ + public function getBestPractices(): array + { + return [ + 'Use unique module number between 100000-999999', + 'Follow Dolibarr naming conventions (llx_ prefix for tables)', + 'Implement proper database table structure with key files', + 'Use language files for all user-facing strings', + 'Implement module descriptor with proper metadata', + 'Define permissions/rights in module descriptor', + 'Use Dolibarr coding standards', + 'Implement triggers for extensibility', + 'Provide admin setup page', + 'Include comprehensive SQL upgrade scripts', + 'Use CommonObject class for business objects', + 'Implement proper error handling with setError()', + 'Add boxes for dashboard widgets if applicable', + 'Use Dolibarr Form classes for form generation', + 'Include unit tests in test/ directory', + ]; + } + + /** + * Find module descriptor + */ + private function findModuleDescriptor(string $projectPath): ?string + { + $files = $this->findFiles($projectPath, 'core/modules/mod*.class.php'); + return !empty($files) ? $files[0] : null; + } + + /** + * Parse module descriptor + */ + private function parseDescriptor(string $descriptorFile): ?array + { + $content = @file_get_contents($descriptorFile); + if (!$content) { + return null; + } + + $data = [ + 'name' => null, + 'version' => null, + 'number' => null, + ]; + + // Extract version + if (preg_match('/\$this->version\s*=\s*[\'"]([^\'"]+)[\'"]/', $content, $matches)) { + $data['version'] = $matches[1]; + } + + // Extract number + if (preg_match('/\$this->numero\s*=\s*(\d+)/', $content, $matches)) { + $data['number'] = (int)$matches[1]; + } + + // Extract name from class + if (preg_match('/class\s+mod(\w+)\s+extends/', $content, $matches)) { + $data['name'] = $matches[1]; + } + + return $data; + } + + /** + * Get module name + */ + private function getModuleName(string $projectPath): string + { + $descriptorFile = $this->findModuleDescriptor($projectPath); + if (!$descriptorFile) { + return 'unknown'; + } + + $data = $this->parseDescriptor($descriptorFile); + return $data['name'] ?? 'unknown'; + } + + /** + * Check for hooks + */ + private function checkForHooks(string $projectPath): bool + { + $descriptorFile = $this->findModuleDescriptor($projectPath); + if (!$descriptorFile) { + return false; + } + + $content = @file_get_contents($descriptorFile); + return $content && strpos($content, '$this->module_parts') !== false; + } + + /** + * Check for rights/permissions + */ + private function checkForRights(string $projectPath): bool + { + $descriptorFile = $this->findModuleDescriptor($projectPath); + if (!$descriptorFile) { + return false; + } + + $content = @file_get_contents($descriptorFile); + return $content && strpos($content, '$this->rights') !== false; + } + + /** + * Count database tables + */ + private function countDatabaseTables(string $projectPath): int + { + $sqlFiles = $this->findFiles($projectPath, 'sql/llx_*.sql'); + $tableCount = 0; + + foreach ($sqlFiles as $file) { + $content = @file_get_contents($file); + if ($content) { + $tableCount += preg_match_all('/CREATE\s+TABLE/i', $content); + } + } + + return $tableCount; + } +} diff --git a/lib/Enterprise/Plugins/GenericPlugin.php b/lib/Enterprise/Plugins/GenericPlugin.php new file mode 100644 index 0000000..8f72f55 --- /dev/null +++ b/lib/Enterprise/Plugins/GenericPlugin.php @@ -0,0 +1,521 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise.Plugins + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/Plugins/GenericPlugin.php + * VERSION: 04.06.00 + * BRIEF: Enterprise plugin for generic projects + */ + +declare(strict_types=1); + +namespace MokoEnterprise\Plugins; + +use MokoEnterprise\AbstractProjectPlugin; + +/** + * Generic Project Plugin + * + * Provides validation, metrics, and management capabilities for + * generic projects that don't fit specific technology categories. + */ +class GenericPlugin extends AbstractProjectPlugin +{ + /** + * {@inheritdoc} + */ + public function getProjectType(): string + { + return 'generic'; + } + + /** + * {@inheritdoc} + */ + public function getPluginName(): string + { + return 'Generic Project Plugin'; + } + + /** + * {@inheritdoc} + */ + public function validateProject(array $config, string $projectPath): array + { + $errors = []; + $warnings = []; + + // Check for README + if (!$this->fileExists($projectPath, 'README.md') && + !$this->fileExists($projectPath, 'README') && + !$this->fileExists($projectPath, 'README.txt')) { + $warnings[] = 'No README file found'; + } + + // Check for LICENSE + if (!$this->fileExists($projectPath, 'LICENSE') && + !$this->fileExists($projectPath, 'LICENSE.md') && + !$this->fileExists($projectPath, 'COPYING')) { + $warnings[] = 'No LICENSE file found'; + } + + // Check for version control ignore file + if (!$this->fileExists($projectPath, '.gitignore') && + !$this->fileExists($projectPath, '.hgignore')) { + $warnings[] = 'No version control ignore file found'; + } + + // Check for CI/CD configuration + $hasCICD = $this->fileExists($projectPath, '.github/workflows') || + $this->fileExists($projectPath, '.gitea/workflows') || + $this->fileExists($projectPath, '.gitlab-ci.yml') || + $this->fileExists($projectPath, '.travis.yml') || + $this->fileExists($projectPath, 'Jenkinsfile') || + $this->fileExists($projectPath, '.circleci'); + + if (!$hasCICD) { + $warnings[] = 'No CI/CD configuration found'; + } + + // Check for security policy + if (!$this->fileExists($projectPath, 'SECURITY.md')) { + $warnings[] = 'No SECURITY.md file found'; + } + + // Check for contributing guidelines + if (!$this->fileExists($projectPath, 'CONTRIBUTING.md')) { + $warnings[] = 'No CONTRIBUTING.md file found'; + } + + // Check for code of conduct + if (!$this->fileExists($projectPath, 'CODE_OF_CONDUCT.md')) { + $warnings[] = 'No CODE_OF_CONDUCT.md file found'; + } + + $this->log( + 'Generic project validation completed', + 'info', + ['errors' => count($errors), 'warnings' => count($warnings)] + ); + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + 'warnings' => $warnings, + ]; + } + + /** + * {@inheritdoc} + */ + public function collectMetrics(string $projectPath, array $config): array + { + $metrics = [ + 'total_files' => $this->countAllFiles($projectPath), + 'has_readme' => $this->hasReadme($projectPath), + 'has_license' => $this->hasLicense($projectPath), + 'has_cicd' => $this->hasCICD($projectPath), + 'has_tests' => $this->hasTests($projectPath), + 'has_documentation' => $this->hasDocumentation($projectPath), + 'language_detected' => $this->detectPrimaryLanguage($projectPath), + ]; + + // Count by file extension + $extensions = $this->countByExtension($projectPath); + $metrics['file_types'] = $extensions; + $metrics['dominant_type'] = $this->getDominantFileType($extensions); + + // Directory structure depth + $metrics['max_depth'] = $this->getDirectoryDepth($projectPath); + + // Count lines + $metrics['total_lines'] = $this->countTotalLines($projectPath); + + // Record metrics + $this->recordMetric('generic', 'total_files', $metrics['total_files']); + $this->recordMetric('generic', 'total_lines', $metrics['total_lines']); + + $this->log('Collected generic project metrics', 'info', $metrics); + + return $metrics; + } + + /** + * {@inheritdoc} + */ + public function healthCheck(string $projectPath, array $config): array + { + $issues = []; + $score = 100; + + // Check README + if (!$this->hasReadme($projectPath)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing README file', + ]; + $score -= 15; + } + + // Check LICENSE + if (!$this->hasLicense($projectPath)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing LICENSE file', + ]; + $score -= 15; + } + + // Check version control + if (!$this->fileExists($projectPath, '.git') && + !$this->fileExists($projectPath, '.hg')) { + $issues[] = [ + 'severity' => 'info', + 'message' => 'Not under version control', + ]; + $score -= 10; + } + + // Check .gitignore + if ($this->fileExists($projectPath, '.git') && + !$this->fileExists($projectPath, '.gitignore')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing .gitignore file', + ]; + $score -= 10; + } + + // Check for documentation + if (!$this->hasDocumentation($projectPath)) { + $issues[] = [ + 'severity' => 'info', + 'message' => 'No documentation directory found', + ]; + $score -= 5; + } + + // Check for tests + if (!$this->hasTests($projectPath)) { + $issues[] = [ + 'severity' => 'info', + 'message' => 'No test directory found', + ]; + $score -= 10; + } + + // Check CI/CD + if (!$this->hasCICD($projectPath)) { + $issues[] = [ + 'severity' => 'info', + 'message' => 'No CI/CD configuration found', + ]; + $score -= 10; + } + + // Check for security policy + if (!$this->fileExists($projectPath, 'SECURITY.md')) { + $issues[] = [ + 'severity' => 'info', + 'message' => 'No SECURITY.md file found', + ]; + $score -= 5; + } + + // Check for changelog + if (!$this->fileExists($projectPath, 'CHANGELOG.md') && + !$this->fileExists($projectPath, 'CHANGELOG')) { + $issues[] = [ + 'severity' => 'info', + 'message' => 'No CHANGELOG file found', + ]; + $score -= 5; + } + + $score = max(0, $score); + + $this->log('Generic project health check completed', 'info', [ + 'score' => $score, + 'issues_count' => count($issues), + ]); + + return [ + 'healthy' => $score >= 60, + 'score' => $score, + 'issues' => $issues, + ]; + } + + /** + * {@inheritdoc} + */ + public function getRequiredFiles(): array + { + return [ + 'README.md or README', + 'LICENSE or COPYING', + ]; + } + + /** + * {@inheritdoc} + */ + public function getRecommendedFiles(): array + { + return [ + '.gitignore', + 'CHANGELOG.md', + 'CONTRIBUTING.md', + 'CODE_OF_CONDUCT.md', + 'SECURITY.md', + '.github/workflows/* or .gitea/workflows/* or .gitlab-ci.yml', + 'docs/ or documentation/', + 'tests/ or test/', + ]; + } + + /** + * {@inheritdoc} + */ + public function getConfigSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'project_name' => [ + 'type' => 'string', + 'description' => 'Project name', + ], + 'primary_language' => [ + 'type' => 'string', + 'description' => 'Primary programming language', + ], + 'requires_build' => [ + 'type' => 'boolean', + 'description' => 'Project requires build step', + ], + 'has_dependencies' => [ + 'type' => 'boolean', + 'description' => 'Project has external dependencies', + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function getBestPractices(): array + { + return [ + 'Include comprehensive README with project description', + 'Add clear LICENSE file with appropriate license', + 'Maintain CHANGELOG for version history', + 'Provide CONTRIBUTING guidelines for contributors', + 'Include CODE_OF_CONDUCT for community standards', + 'Add SECURITY policy for vulnerability reporting', + 'Use .gitignore to exclude generated files', + 'Implement CI/CD for automated testing and deployment', + 'Organize code in logical directory structure', + 'Include documentation in docs/ directory', + 'Add unit and integration tests', + 'Use semantic versioning for releases', + 'Keep dependencies up to date', + 'Document installation and usage instructions', + 'Add examples and tutorials where applicable', + ]; + } + + /** + * Check if project has README + */ + private function hasReadme(string $projectPath): bool + { + return $this->fileExists($projectPath, 'README.md') || + $this->fileExists($projectPath, 'README') || + $this->fileExists($projectPath, 'README.txt'); + } + + /** + * Check if project has LICENSE + */ + private function hasLicense(string $projectPath): bool + { + return $this->fileExists($projectPath, 'LICENSE') || + $this->fileExists($projectPath, 'LICENSE.md') || + $this->fileExists($projectPath, 'COPYING') || + $this->fileExists($projectPath, 'LICENSE.txt'); + } + + /** + * Check if project has CI/CD + */ + private function hasCICD(string $projectPath): bool + { + return $this->fileExists($projectPath, '.github/workflows') || + $this->fileExists($projectPath, '.gitea/workflows') || + $this->fileExists($projectPath, '.gitlab-ci.yml') || + $this->fileExists($projectPath, '.travis.yml') || + $this->fileExists($projectPath, 'Jenkinsfile') || + $this->fileExists($projectPath, '.circleci/config.yml'); + } + + /** + * Check if project has tests + */ + private function hasTests(string $projectPath): bool + { + return $this->fileExists($projectPath, 'tests') || + $this->fileExists($projectPath, 'test') || + $this->fileExists($projectPath, '__tests__') || + $this->fileExists($projectPath, 'spec'); + } + + /** + * Check if project has documentation + */ + private function hasDocumentation(string $projectPath): bool + { + return $this->fileExists($projectPath, 'docs') || + $this->fileExists($projectPath, 'doc') || + $this->fileExists($projectPath, 'documentation'); + } + + /** + * Count all files + */ + private function countAllFiles(string $projectPath): int + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($projectPath, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + $count = 0; + foreach ($iterator as $file) { + if ($file->isFile()) { + $count++; + } + } + + return $count; + } + + /** + * Count files by extension + */ + private function countByExtension(string $projectPath): array + { + $extensions = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($projectPath, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + $ext = strtolower($file->getExtension()); + $extensions[$ext] = ($extensions[$ext] ?? 0) + 1; + } + } + + arsort($extensions); + return array_slice($extensions, 0, 10); + } + + /** + * Get dominant file type + */ + private function getDominantFileType(array $extensions): string + { + if (empty($extensions)) { + return 'unknown'; + } + + reset($extensions); + return key($extensions); + } + + /** + * Get directory depth + */ + private function getDirectoryDepth(string $projectPath): int + { + $maxDepth = 0; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($projectPath, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $file) { + $depth = $iterator->getDepth(); + if ($depth > $maxDepth) { + $maxDepth = $depth; + } + } + + return $maxDepth; + } + + /** + * Count total lines + */ + private function countTotalLines(string $projectPath): int + { + $totalLines = 0; + $textExtensions = ['php', 'js', 'py', 'java', 'c', 'cpp', 'h', 'cs', 'go', 'rb', 'ts', 'tsx', 'jsx']; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($projectPath, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + $ext = strtolower($file->getExtension()); + if (in_array($ext, $textExtensions)) { + $totalLines += count(file($file->getPathname())); + } + } + } + + return $totalLines; + } + + /** + * Detect primary language + */ + private function detectPrimaryLanguage(string $projectPath): string + { + $extensions = $this->countByExtension($projectPath); + $languageMap = [ + 'php' => 'PHP', + 'js' => 'JavaScript', + 'ts' => 'TypeScript', + 'py' => 'Python', + 'java' => 'Java', + 'c' => 'C', + 'cpp' => 'C++', + 'cs' => 'C#', + 'go' => 'Go', + 'rb' => 'Ruby', + 'rs' => 'Rust', + ]; + + foreach ($extensions as $ext => $count) { + if (isset($languageMap[$ext])) { + return $languageMap[$ext]; + } + } + + return 'Unknown'; + } +} diff --git a/lib/Enterprise/Plugins/JoomlaPlugin.php b/lib/Enterprise/Plugins/JoomlaPlugin.php new file mode 100644 index 0000000..65985d1 --- /dev/null +++ b/lib/Enterprise/Plugins/JoomlaPlugin.php @@ -0,0 +1,457 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise.Plugins + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/Plugins/JoomlaPlugin.php + * VERSION: 04.06.00 + * BRIEF: Enterprise plugin for Joomla projects + */ + +declare(strict_types=1); + +namespace MokoEnterprise\Plugins; + +use MokoEnterprise\AbstractProjectPlugin; + +/** + * Joomla Project Plugin + * + * Provides validation, metrics, and management capabilities for Joomla + * extensions (components, modules, plugins, templates). + */ +class JoomlaPlugin extends AbstractProjectPlugin +{ + /** + * {@inheritdoc} + */ + public function getProjectType(): string + { + return 'joomla'; + } + + /** + * {@inheritdoc} + */ + public function getPluginName(): string + { + return 'Joomla Enterprise Plugin'; + } + + /** + * {@inheritdoc} + */ + public function validateProject(array $config, string $projectPath): array + { + $errors = []; + $warnings = []; + + // Check for manifest file + $manifestFile = $this->findManifestFile($projectPath); + if (!$manifestFile) { + $errors[] = 'No Joomla manifest XML file found'; + } else { + $manifestData = $this->parseManifest($manifestFile); + if (!$manifestData) { + $errors[] = 'Invalid or malformed manifest XML file'; + } else { + // Validate manifest contents + if (empty($manifestData['name'])) { + $errors[] = 'Manifest missing required element'; + } + if (empty($manifestData['version'])) { + $warnings[] = 'Manifest missing version information'; + } + if (empty($manifestData['author'])) { + $warnings[] = 'Manifest missing author information'; + } + if (empty($manifestData['license'])) { + $warnings[] = 'Manifest missing license information'; + } + } + } + + // Check for language files + if (!$this->fileExists($projectPath, 'language') && + !$this->countFiles($projectPath, '**/language/*.ini')) { + $warnings[] = 'No language files found'; + } + + // Check for SQL installation files + if (!$this->fileExists($projectPath, 'sql/install.mysql.utf8.sql') && + !$this->fileExists($projectPath, 'admin/sql/install.mysql.utf8.sql')) { + $warnings[] = 'No SQL installation file found'; + } + + // Check code quality + if (!$this->fileExists($projectPath, 'phpcs.xml') && + !$this->fileExists($projectPath, 'phpcs.xml.dist')) { + $warnings[] = 'No PHPCS configuration found'; + } + + // Check for namespace usage (Joomla 4+) + $hasNamespaces = $this->checkForNamespaces($projectPath); + if (!$hasNamespaces) { + $warnings[] = 'Consider using namespaces for Joomla 4+ compatibility'; + } + + $this->log( + 'Joomla project validation completed', + 'info', + ['errors' => count($errors), 'warnings' => count($warnings)] + ); + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + 'warnings' => $warnings, + ]; + } + + /** + * {@inheritdoc} + */ + public function collectMetrics(string $projectPath, array $config): array + { + $metrics = [ + 'extension_type' => $this->detectExtensionType($projectPath), + 'php_files' => $this->countFiles($projectPath, '**/*.php'), + 'language_files' => $this->countFiles($projectPath, '**/language/*.ini'), + 'sql_files' => $this->countFiles($projectPath, 'sql/*.sql'), + 'media_files' => $this->countFiles($projectPath, 'media/**/*'), + 'has_namespaces' => $this->checkForNamespaces($projectPath), + 'joomla_version' => $this->detectJoomlaVersion($projectPath), + 'uses_mvc' => $this->checkMVCStructure($projectPath), + 'has_tests' => $this->fileExists($projectPath, 'tests') || + $this->fileExists($projectPath, 'test'), + ]; + + // Count lines of code + $phpFiles = $this->findFiles($projectPath, '**/*.php'); + $totalLines = 0; + foreach ($phpFiles as $file) { + if (is_file($file)) { + $totalLines += count(file($file)); + } + } + $metrics['total_lines'] = $totalLines; + + // Record metrics + $this->recordMetric('joomla', 'php_files', $metrics['php_files']); + $this->recordMetric('joomla', 'total_lines', $totalLines); + + $this->log('Collected Joomla metrics', 'info', $metrics); + + return $metrics; + } + + /** + * {@inheritdoc} + */ + public function healthCheck(string $projectPath, array $config): array + { + $issues = []; + $score = 100; + + // Check manifest + $manifestFile = $this->findManifestFile($projectPath); + if (!$manifestFile) { + $issues[] = [ + 'severity' => 'critical', + 'message' => 'Missing Joomla manifest file', + 'file' => 'manifest.xml', + ]; + $score -= 30; + } + + // Check for proper directory structure + $extensionType = $this->detectExtensionType($projectPath); + if ($extensionType === 'component') { + if (!$this->fileExists($projectPath, 'site') && + !$this->fileExists($projectPath, 'admin')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Component missing standard site/admin structure', + ]; + $score -= 10; + } + } + + // Check for security issues + if (!$this->checkForIndexFiles($projectPath)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Some directories missing index.html protection', + ]; + $score -= 5; + } + + // Check for update server + if (!$this->hasUpdateServer($manifestFile)) { + $issues[] = [ + 'severity' => 'info', + 'message' => 'No update server configured in manifest', + ]; + $score -= 5; + } + + // Check for documentation + if (!$this->fileExists($projectPath, 'README.md')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing README.md documentation', + ]; + $score -= 10; + } + + // Check for license file + if (!$this->fileExists($projectPath, 'LICENSE')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing LICENSE file', + ]; + $score -= 5; + } + + $score = max(0, $score); + + $this->log('Joomla health check completed', 'info', [ + 'score' => $score, + 'issues_count' => count($issues), + ]); + + return [ + 'healthy' => $score >= 70, + 'score' => $score, + 'issues' => $issues, + ]; + } + + /** + * {@inheritdoc} + */ + public function getRequiredFiles(): array + { + return [ + '*.xml (manifest)', + 'language/*.ini', + ]; + } + + /** + * {@inheritdoc} + */ + public function getRecommendedFiles(): array + { + return [ + 'README.md', + 'LICENSE', + 'CHANGELOG.md', + 'phpcs.xml or phpcs.xml.dist', + 'sql/install.mysql.utf8.sql', + 'sql/uninstall.mysql.utf8.sql', + 'language/en-GB/*.ini', + 'media/css/*.css', + 'media/js/*.js', + 'index.html (in directories)', + ]; + } + + /** + * {@inheritdoc} + */ + public function getConfigSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'joomla_version' => [ + 'type' => 'string', + 'enum' => ['3.x', '4.x', '5.x'], + 'description' => 'Target Joomla version', + ], + 'extension_type' => [ + 'type' => 'string', + 'enum' => ['component', 'module', 'plugin', 'template', 'library'], + 'description' => 'Type of Joomla extension', + ], + 'use_namespaces' => [ + 'type' => 'boolean', + 'description' => 'Use PHP namespaces (required for Joomla 4+)', + ], + 'update_server' => [ + 'type' => 'string', + 'description' => 'URL to update server XML', + ], + ], + 'required' => ['joomla_version', 'extension_type'], + ]; + } + + /** + * {@inheritdoc} + */ + public function getBestPractices(): array + { + return [ + 'Use namespaces for Joomla 4+ compatibility', + 'Include proper language files for all strings', + 'Add index.html files to all directories for security', + 'Use Joomla coding standards (PHPCS)', + 'Implement proper MVC structure for components', + 'Include SQL install and uninstall scripts', + 'Use JInput for all user input', + 'Escape all output with JText or htmlspecialchars', + 'Follow Joomla naming conventions', + 'Include update server in manifest for easy updates', + 'Use Joomla\'s database abstraction layer', + 'Implement proper ACL (Access Control List)', + 'Add comprehensive inline documentation', + 'Create unit tests using PHPUnit', + 'Version your extension properly', + ]; + } + + /** + * Find manifest XML file + */ + private function findManifestFile(string $projectPath): ?string + { + $files = $this->findFiles($projectPath, '*.xml'); + foreach ($files as $file) { + $content = $this->readFile($projectPath, basename($file)); + if ($content && ( + strpos($content, ' (string)$xml->name, + 'version' => (string)$xml->version, + 'author' => (string)$xml->author, + 'license' => (string)$xml->license, + 'description' => (string)$xml->description, + ]; + } + + /** + * Detect extension type + */ + private function detectExtensionType(string $projectPath): string + { + $manifestFile = $this->findManifestFile($projectPath); + if (!$manifestFile) { + return 'unknown'; + } + + $xml = @simplexml_load_file($manifestFile); + if (!$xml) { + return 'unknown'; + } + + return (string)($xml['type'] ?? 'unknown'); + } + + /** + * Check for namespaces + */ + private function checkForNamespaces(string $projectPath): bool + { + $phpFiles = $this->findFiles($projectPath, '*.php'); + foreach ($phpFiles as $file) { + $content = @file_get_contents($file); + if ($content && preg_match('/^namespace\s+/m', $content)) { + return true; + } + } + return false; + } + + /** + * Detect Joomla version + */ + private function detectJoomlaVersion(string $projectPath): string + { + $manifestFile = $this->findManifestFile($projectPath); + if (!$manifestFile) { + return 'unknown'; + } + + $content = @file_get_contents($manifestFile); + if (!$content) { + return 'unknown'; + } + + if (strpos($content, 'namespace=') !== false) { + return '4.x'; + } + + return '3.x'; + } + + /** + * Check MVC structure + */ + private function checkMVCStructure(string $projectPath): bool + { + return ($this->fileExists($projectPath, 'models') || + $this->fileExists($projectPath, 'views') || + $this->fileExists($projectPath, 'controllers')); + } + + /** + * Check for index.html files + */ + private function checkForIndexFiles(string $projectPath): bool + { + $dirs = glob($projectPath . '/*', GLOB_ONLYDIR); + $missingCount = 0; + + foreach ($dirs as $dir) { + if (!file_exists($dir . '/index.html')) { + $missingCount++; + } + } + + return $missingCount < count($dirs) / 2; + } + + /** + * Check for update server + */ + private function hasUpdateServer(?string $manifestFile): bool + { + if (!$manifestFile || !file_exists($manifestFile)) { + return false; + } + + $content = @file_get_contents($manifestFile); + return $content && strpos($content, '') !== false; + } +} diff --git a/lib/Enterprise/Plugins/MobilePlugin.php b/lib/Enterprise/Plugins/MobilePlugin.php new file mode 100644 index 0000000..31cfeef --- /dev/null +++ b/lib/Enterprise/Plugins/MobilePlugin.php @@ -0,0 +1,660 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise.Plugins + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/Plugins/MobilePlugin.php + * VERSION: 04.06.00 + * BRIEF: Enterprise plugin for mobile app projects + */ + +declare(strict_types=1); + +namespace MokoEnterprise\Plugins; + +use MokoEnterprise\AbstractProjectPlugin; + +/** + * Mobile App Project Plugin + * + * Provides validation, metrics, and management capabilities for + * mobile applications (React Native, Flutter, native iOS/Android). + */ +class MobilePlugin extends AbstractProjectPlugin +{ + /** + * {@inheritdoc} + */ + public function getProjectType(): string + { + return 'mobile'; + } + + /** + * {@inheritdoc} + */ + public function getPluginName(): string + { + return 'Mobile App Enterprise Plugin'; + } + + /** + * {@inheritdoc} + */ + public function validateProject(array $config, string $projectPath): array + { + $errors = []; + $warnings = []; + + $platform = $this->detectPlatform($projectPath); + + switch ($platform) { + case 'react-native': + if (!$this->fileExists($projectPath, 'package.json')) { + $errors[] = 'React Native project missing package.json'; + } + if (!$this->fileExists($projectPath, 'app.json') && + !$this->fileExists($projectPath, 'app.config.js')) { + $warnings[] = 'Missing app.json or app.config.js'; + } + if (!$this->fileExists($projectPath, 'ios') && + !$this->fileExists($projectPath, 'android')) { + $warnings[] = 'No native platform directories found'; + } + break; + + case 'flutter': + if (!$this->fileExists($projectPath, 'pubspec.yaml')) { + $errors[] = 'Flutter project missing pubspec.yaml'; + } + if (!$this->fileExists($projectPath, 'lib')) { + $errors[] = 'Flutter project missing lib directory'; + } + break; + + case 'ios': + if (!$this->fileExists($projectPath, '*.xcodeproj') && + !$this->fileExists($projectPath, '*.xcworkspace')) { + $errors[] = 'iOS project missing Xcode project file'; + } + if (!$this->fileExists($projectPath, 'Podfile')) { + $warnings[] = 'No Podfile found (CocoaPods not used)'; + } + break; + + case 'android': + if (!$this->fileExists($projectPath, 'build.gradle')) { + $errors[] = 'Android project missing build.gradle'; + } + if (!$this->fileExists($projectPath, 'app/src/main')) { + $errors[] = 'Android project missing standard structure'; + } + break; + } + + // Check for app icons + if (!$this->hasAppIcons($projectPath, $platform)) { + $warnings[] = 'App icons not found'; + } + + // Check for splash screen + if (!$this->hasSplashScreen($projectPath, $platform)) { + $warnings[] = 'Splash screen not found'; + } + + // Check for tests + if (!$this->hasTests($projectPath, $platform)) { + $warnings[] = 'No tests found'; + } + + $this->log( + 'Mobile project validation completed', + 'info', + ['errors' => count($errors), 'warnings' => count($warnings), 'platform' => $platform] + ); + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + 'warnings' => $warnings, + ]; + } + + /** + * {@inheritdoc} + */ + public function collectMetrics(string $projectPath, array $config): array + { + $platform = $this->detectPlatform($projectPath); + + $metrics = [ + 'platform' => $platform, + 'supports_ios' => $this->supportsIOS($projectPath, $platform), + 'supports_android' => $this->supportsAndroid($projectPath, $platform), + 'has_app_icons' => $this->hasAppIcons($projectPath, $platform), + 'has_splash_screen' => $this->hasSplashScreen($projectPath, $platform), + 'has_tests' => $this->hasTests($projectPath, $platform), + 'has_ci' => $this->hasCICD($projectPath), + ]; + + // Platform-specific metrics + switch ($platform) { + case 'react-native': + $packageData = $this->parseJsonFile($projectPath, 'package.json'); + $metrics['js_files'] = $this->countFiles($projectPath, '**/*.js'); + $metrics['jsx_files'] = $this->countFiles($projectPath, '**/*.jsx'); + $metrics['ts_files'] = $this->countFiles($projectPath, '**/*.ts'); + $metrics['tsx_files'] = $this->countFiles($projectPath, '**/*.tsx'); + $metrics['dependencies'] = $packageData ? count($packageData['dependencies'] ?? []) : 0; + $metrics['uses_typescript'] = $this->fileExists($projectPath, 'tsconfig.json'); + $metrics['uses_expo'] = $this->usesExpo($projectPath); + break; + + case 'flutter': + $metrics['dart_files'] = $this->countFiles($projectPath, '**/*.dart'); + $metrics['dependencies'] = $this->countFlutterDependencies($projectPath); + break; + + case 'ios': + $metrics['swift_files'] = $this->countFiles($projectPath, '**/*.swift'); + $metrics['objc_files'] = $this->countFiles($projectPath, '**/*.m'); + break; + + case 'android': + $metrics['kotlin_files'] = $this->countFiles($projectPath, '**/*.kt'); + $metrics['java_files'] = $this->countFiles($projectPath, '**/*.java'); + break; + } + + // Count total lines + $metrics['total_lines'] = $this->countTotalLines($projectPath, $platform); + + // Record metrics + $this->recordMetric('mobile', 'platform', $platform); + $this->recordMetric('mobile', 'total_lines', $metrics['total_lines']); + + $this->log('Collected mobile metrics', 'info', $metrics); + + return $metrics; + } + + /** + * {@inheritdoc} + */ + public function healthCheck(string $projectPath, array $config): array + { + $issues = []; + $score = 100; + + $platform = $this->detectPlatform($projectPath); + + // Platform-specific checks + switch ($platform) { + case 'react-native': + if (!$this->fileExists($projectPath, 'package.json')) { + $issues[] = [ + 'severity' => 'critical', + 'message' => 'Missing package.json', + ]; + $score -= 30; + } + if (!$this->fileExists($projectPath, '.watchmanconfig')) { + $issues[] = [ + 'severity' => 'info', + 'message' => 'Missing .watchmanconfig', + ]; + $score -= 5; + } + break; + + case 'flutter': + if (!$this->fileExists($projectPath, 'pubspec.yaml')) { + $issues[] = [ + 'severity' => 'critical', + 'message' => 'Missing pubspec.yaml', + ]; + $score -= 30; + } + break; + + case 'ios': + if (!$this->hasIOSProject($projectPath)) { + $issues[] = [ + 'severity' => 'critical', + 'message' => 'No Xcode project found', + ]; + $score -= 30; + } + break; + + case 'android': + if (!$this->fileExists($projectPath, 'build.gradle')) { + $issues[] = [ + 'severity' => 'critical', + 'message' => 'Missing build.gradle', + ]; + $score -= 30; + } + break; + } + + // Check for app icons + if (!$this->hasAppIcons($projectPath, $platform)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'App icons missing', + ]; + $score -= 10; + } + + // Check for tests + if (!$this->hasTests($projectPath, $platform)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'No tests found', + ]; + $score -= 15; + } + + // Check for CI/CD + if (!$this->hasCICD($projectPath)) { + $issues[] = [ + 'severity' => 'info', + 'message' => 'No CI/CD configuration', + ]; + $score -= 10; + } + + // Check for README + if (!$this->fileExists($projectPath, 'README.md')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing README.md', + ]; + $score -= 5; + } + + // Check for .gitignore + if (!$this->fileExists($projectPath, '.gitignore')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing .gitignore', + ]; + $score -= 5; + } + + // Check for security best practices + if ($this->hasInsecureStorage($projectPath, $platform)) { + $issues[] = [ + 'severity' => 'critical', + 'message' => 'Potential insecure data storage detected', + ]; + $score -= 20; + } + + $score = max(0, $score); + + $this->log('Mobile health check completed', 'info', [ + 'score' => $score, + 'issues_count' => count($issues), + 'platform' => $platform, + ]); + + return [ + 'healthy' => $score >= 70, + 'score' => $score, + 'issues' => $issues, + ]; + } + + /** + * {@inheritdoc} + */ + public function getRequiredFiles(): array + { + return [ + 'React Native: package.json, app.json', + 'Flutter: pubspec.yaml, lib/', + 'iOS: *.xcodeproj or *.xcworkspace', + 'Android: build.gradle, app/src/main/', + ]; + } + + /** + * {@inheritdoc} + */ + public function getRecommendedFiles(): array + { + return [ + 'README.md', + '.gitignore', + 'App icons for all required sizes', + 'Splash screen assets', + 'tests/ or __tests__/', + '.github/workflows/* or .gitea/workflows/* or fastlane/', + 'React Native: metro.config.js', + 'Flutter: analysis_options.yaml', + 'iOS: Podfile', + 'Android: proguard-rules.pro', + ]; + } + + /** + * {@inheritdoc} + */ + public function getConfigSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'platform' => [ + 'type' => 'string', + 'enum' => ['react-native', 'flutter', 'ios', 'android', 'xamarin'], + 'description' => 'Mobile platform/framework', + ], + 'supports_ios' => [ + 'type' => 'boolean', + 'description' => 'Project supports iOS', + ], + 'supports_android' => [ + 'type' => 'boolean', + 'description' => 'Project supports Android', + ], + 'min_ios_version' => [ + 'type' => 'string', + 'description' => 'Minimum iOS version', + ], + 'min_android_api' => [ + 'type' => 'integer', + 'description' => 'Minimum Android API level', + ], + ], + 'required' => ['platform'], + ]; + } + + /** + * {@inheritdoc} + */ + public function getBestPractices(): array + { + return [ + 'Support both iOS and Android for wider reach', + 'Implement proper error handling and crash reporting', + 'Use secure storage for sensitive data', + 'Implement proper app permissions handling', + 'Optimize app size and performance', + 'Provide app icons for all required sizes', + 'Include splash screen with proper sizing', + 'Implement comprehensive unit and integration tests', + 'Use CI/CD for automated builds and deployments', + 'Follow platform-specific design guidelines', + 'Implement proper deep linking', + 'Add analytics and monitoring', + 'Handle offline scenarios gracefully', + 'Optimize images and assets', + 'Keep dependencies up to date', + ]; + } + + /** + * Detect mobile platform + */ + private function detectPlatform(string $projectPath): string + { + // React Native + if ($this->fileExists($projectPath, 'package.json')) { + $packageData = $this->parseJsonFile($projectPath, 'package.json'); + if ($packageData && isset($packageData['dependencies']['react-native'])) { + return 'react-native'; + } + } + + // Flutter + if ($this->fileExists($projectPath, 'pubspec.yaml')) { + return 'flutter'; + } + + // iOS + if ($this->hasIOSProject($projectPath)) { + return 'ios'; + } + + // Android + if ($this->fileExists($projectPath, 'build.gradle') && + $this->fileExists($projectPath, 'app/src/main')) { + return 'android'; + } + + return 'unknown'; + } + + /** + * Check if supports iOS + */ + private function supportsIOS(string $projectPath, string $platform): bool + { + if ($platform === 'ios') { + return true; + } + + return $this->fileExists($projectPath, 'ios'); + } + + /** + * Check if supports Android + */ + private function supportsAndroid(string $projectPath, string $platform): bool + { + if ($platform === 'android') { + return true; + } + + return $this->fileExists($projectPath, 'android'); + } + + /** + * Check for app icons + */ + private function hasAppIcons(string $projectPath, string $platform): bool + { + switch ($platform) { + case 'react-native': + return $this->fileExists($projectPath, 'android/app/src/main/res/mipmap-*') || + $this->fileExists($projectPath, 'ios/*/Images.xcassets/AppIcon.appiconset'); + + case 'flutter': + return $this->fileExists($projectPath, 'android/app/src/main/res/mipmap-*') || + $this->fileExists($projectPath, 'ios/Runner/Assets.xcassets/AppIcon.appiconset'); + + case 'ios': + return $this->countFiles($projectPath, '**/AppIcon.appiconset') > 0; + + case 'android': + return $this->fileExists($projectPath, 'app/src/main/res/mipmap-*'); + + default: + return false; + } + } + + /** + * Check for splash screen + */ + private function hasSplashScreen(string $projectPath, string $platform): bool + { + switch ($platform) { + case 'react-native': + return $this->fileExists($projectPath, 'android/app/src/main/res/drawable/launch_screen*') || + $this->fileExists($projectPath, 'ios/*/LaunchScreen*'); + + case 'flutter': + return $this->fileExists($projectPath, 'android/app/src/main/res/drawable/launch_background*') || + $this->fileExists($projectPath, 'ios/Runner/Assets.xcassets/LaunchImage*'); + + case 'ios': + return $this->countFiles($projectPath, '**/LaunchScreen*') > 0; + + case 'android': + return $this->fileExists($projectPath, 'app/src/main/res/drawable/launch_*'); + + default: + return false; + } + } + + /** + * Check for tests + */ + private function hasTests(string $projectPath, string $platform): bool + { + switch ($platform) { + case 'react-native': + return $this->fileExists($projectPath, '__tests__') || + $this->fileExists($projectPath, 'e2e') || + $this->countFiles($projectPath, '**/*.test.js') > 0; + + case 'flutter': + return $this->fileExists($projectPath, 'test') || + $this->countFiles($projectPath, '**/*_test.dart') > 0; + + case 'ios': + return $this->fileExists($projectPath, '*Tests') || + $this->countFiles($projectPath, '**/*Tests.swift') > 0; + + case 'android': + return $this->fileExists($projectPath, 'app/src/test') || + $this->fileExists($projectPath, 'app/src/androidTest'); + + default: + return false; + } + } + + /** + * Check for CI/CD + */ + private function hasCICD(string $projectPath): bool + { + return $this->fileExists($projectPath, '.github/workflows') || + $this->fileExists($projectPath, '.gitea/workflows') || + $this->fileExists($projectPath, '.gitlab-ci.yml') || + $this->fileExists($projectPath, 'fastlane') || + $this->fileExists($projectPath, '.circleci'); + } + + /** + * Check if uses Expo + */ + private function usesExpo(string $projectPath): bool + { + $packageData = $this->parseJsonFile($projectPath, 'package.json'); + return $packageData && isset($packageData['dependencies']['expo']); + } + + /** + * Count Flutter dependencies + */ + private function countFlutterDependencies(string $projectPath): int + { + $pubspec = $this->readFile($projectPath, 'pubspec.yaml'); + if (!$pubspec) { + return 0; + } + + $lines = explode("\n", $pubspec); + $inDeps = false; + $count = 0; + + foreach ($lines as $line) { + if (strpos($line, 'dependencies:') !== false) { + $inDeps = true; + continue; + } + if ($inDeps && preg_match('/^\s{2}\w+:/', $line)) { + $count++; + } elseif ($inDeps && preg_match('/^\w+:/', $line)) { + break; + } + } + + return $count; + } + + /** + * Count total lines + */ + private function countTotalLines(string $projectPath, string $platform): int + { + $extensions = []; + + switch ($platform) { + case 'react-native': + $extensions = ['js', 'jsx', 'ts', 'tsx']; + break; + case 'flutter': + $extensions = ['dart']; + break; + case 'ios': + $extensions = ['swift', 'm', 'h']; + break; + case 'android': + $extensions = ['kt', 'java']; + break; + } + + $totalLines = 0; + foreach ($extensions as $ext) { + $files = $this->findFiles($projectPath, "**/*.{$ext}"); + foreach ($files as $file) { + if (is_file($file) && + strpos($file, 'node_modules') === false && + strpos($file, 'build') === false) { + $totalLines += count(file($file)); + } + } + } + + return $totalLines; + } + + /** + * Check for iOS project + */ + private function hasIOSProject(string $projectPath): bool + { + return $this->countFiles($projectPath, '*.xcodeproj') > 0 || + $this->countFiles($projectPath, '*.xcworkspace') > 0; + } + + /** + * Check for insecure storage + */ + private function hasInsecureStorage(string $projectPath, string $platform): bool + { + // Simple heuristic check - would be more comprehensive in production + switch ($platform) { + case 'react-native': + $files = $this->findFiles($projectPath, '**/*.js'); + foreach (array_slice($files, 0, 10) as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && strpos($content, 'AsyncStorage.setItem') !== false) { + // Should check if sensitive data without encryption + return true; + } + } + } + break; + } + + return false; + } +} diff --git a/lib/Enterprise/Plugins/NodeJsPlugin.php b/lib/Enterprise/Plugins/NodeJsPlugin.php new file mode 100644 index 0000000..5d89760 --- /dev/null +++ b/lib/Enterprise/Plugins/NodeJsPlugin.php @@ -0,0 +1,578 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise.Plugins + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/Plugins/NodeJsPlugin.php + * VERSION: 04.06.00 + * BRIEF: Enterprise plugin for Node.js/TypeScript projects + */ + +declare(strict_types=1); + +namespace MokoEnterprise\Plugins; + +use MokoEnterprise\AbstractProjectPlugin; + +/** + * Node.js/TypeScript Project Plugin + * + * Provides validation, metrics, and management capabilities for + * Node.js and TypeScript projects. + */ +class NodeJsPlugin extends AbstractProjectPlugin +{ + /** + * {@inheritdoc} + */ + public function getProjectType(): string + { + return 'nodejs'; + } + + /** + * {@inheritdoc} + */ + public function getPluginName(): string + { + return 'Node.js/TypeScript Enterprise Plugin'; + } + + /** + * {@inheritdoc} + */ + public function validateProject(array $config, string $projectPath): array + { + $errors = []; + $warnings = []; + + // Check for package.json + if (!$this->fileExists($projectPath, 'package.json')) { + $errors[] = 'Missing package.json file'; + } else { + $packageData = $this->parseJsonFile($projectPath, 'package.json'); + if (!$packageData) { + $errors[] = 'Invalid package.json format'; + } else { + // Validate package.json contents + if (empty($packageData['name'])) { + $errors[] = 'package.json missing name field'; + } + if (empty($packageData['version'])) { + $warnings[] = 'package.json missing version field'; + } + if (empty($packageData['description'])) { + $warnings[] = 'package.json missing description field'; + } + if (empty($packageData['license'])) { + $warnings[] = 'package.json missing license field'; + } + if (empty($packageData['scripts'])) { + $warnings[] = 'No npm scripts defined in package.json'; + } + } + } + + // Check for TypeScript + $isTypeScript = $this->isTypeScriptProject($projectPath); + if ($isTypeScript && !$this->fileExists($projectPath, 'tsconfig.json')) { + $warnings[] = 'TypeScript project missing tsconfig.json'; + } + + // Check for node_modules in git + if ($this->fileExists($projectPath, 'node_modules') && + !$this->isInGitignore($projectPath, 'node_modules')) { + $warnings[] = 'node_modules should be in .gitignore'; + } + + // Check for lock file + if (!$this->fileExists($projectPath, 'package-lock.json') && + !$this->fileExists($projectPath, 'yarn.lock') && + !$this->fileExists($projectPath, 'pnpm-lock.yaml')) { + $warnings[] = 'No lock file found (package-lock.json, yarn.lock, or pnpm-lock.yaml)'; + } + + // Check for linting + if (!$this->fileExists($projectPath, '.eslintrc.js') && + !$this->fileExists($projectPath, '.eslintrc.json') && + !$this->fileExists($projectPath, '.eslintrc.yml')) { + $warnings[] = 'No ESLint configuration found'; + } + + // Check for formatting + if (!$this->fileExists($projectPath, '.prettierrc') && + !$this->fileExists($projectPath, 'prettier.config.js')) { + $warnings[] = 'No Prettier configuration found'; + } + + $this->log( + 'Node.js project validation completed', + 'info', + ['errors' => count($errors), 'warnings' => count($warnings), 'typescript' => $isTypeScript] + ); + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + 'warnings' => $warnings, + ]; + } + + /** + * {@inheritdoc} + */ + public function collectMetrics(string $projectPath, array $config): array + { + $isTypeScript = $this->isTypeScriptProject($projectPath); + $packageData = $this->parseJsonFile($projectPath, 'package.json'); + + $metrics = [ + 'is_typescript' => $isTypeScript, + 'node_version' => $this->getNodeVersion($packageData), + 'js_files' => $this->countFiles($projectPath, '**/*.js'), + 'ts_files' => $this->countFiles($projectPath, '**/*.ts'), + 'jsx_files' => $this->countFiles($projectPath, '**/*.jsx'), + 'tsx_files' => $this->countFiles($projectPath, '**/*.tsx'), + 'json_files' => $this->countFiles($projectPath, '**/*.json'), + 'dependencies' => $this->countDependencies($packageData, 'dependencies'), + 'dev_dependencies' => $this->countDependencies($packageData, 'devDependencies'), + 'scripts' => $this->countScripts($packageData), + 'has_tests' => $this->hasTests($projectPath, $packageData), + 'framework' => $this->detectFramework($projectPath, $packageData), + 'has_docker' => $this->fileExists($projectPath, 'Dockerfile'), + 'has_ci' => $this->hasCICD($projectPath), + ]; + + // Count lines of code + $extensions = $isTypeScript ? ['ts', 'tsx'] : ['js', 'jsx']; + $totalLines = 0; + foreach ($extensions as $ext) { + $files = $this->findFiles($projectPath, "**/*.{$ext}"); + foreach ($files as $file) { + if (is_file($file) && strpos($file, 'node_modules') === false) { + $totalLines += count(file($file)); + } + } + } + $metrics['total_lines'] = $totalLines; + + // Record metrics + $this->recordMetric('nodejs', 'total_files', array_sum([ + $metrics['js_files'], + $metrics['ts_files'], + $metrics['jsx_files'], + $metrics['tsx_files'] + ])); + $this->recordMetric('nodejs', 'dependencies', $metrics['dependencies']); + $this->recordMetric('nodejs', 'total_lines', $totalLines); + + $this->log('Collected Node.js metrics', 'info', $metrics); + + return $metrics; + } + + /** + * {@inheritdoc} + */ + public function healthCheck(string $projectPath, array $config): array + { + $issues = []; + $score = 100; + + // Check package.json + if (!$this->fileExists($projectPath, 'package.json')) { + $issues[] = [ + 'severity' => 'critical', + 'message' => 'Missing package.json', + 'file' => 'package.json', + ]; + $score -= 30; + } else { + $packageData = $this->parseJsonFile($projectPath, 'package.json'); + + // Check for outdated dependencies (basic check) + if ($this->hasOldDependencies($packageData)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Some dependencies may be outdated', + ]; + $score -= 10; + } + } + + // Check for lock file + if (!$this->fileExists($projectPath, 'package-lock.json') && + !$this->fileExists($projectPath, 'yarn.lock') && + !$this->fileExists($projectPath, 'pnpm-lock.yaml')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'No lock file found', + ]; + $score -= 10; + } + + // Check for TypeScript configuration + $isTypeScript = $this->isTypeScriptProject($projectPath); + if ($isTypeScript && !$this->fileExists($projectPath, 'tsconfig.json')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'TypeScript project missing tsconfig.json', + ]; + $score -= 10; + } + + // Check for linting + if (!$this->hasLinting($projectPath)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'No ESLint configuration found', + ]; + $score -= 10; + } + + // Check for tests + $packageData = $this->parseJsonFile($projectPath, 'package.json'); + if (!$this->hasTests($projectPath, $packageData)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'No test setup found', + ]; + $score -= 10; + } + + // Check for README + if (!$this->fileExists($projectPath, 'README.md')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing README.md', + ]; + $score -= 5; + } + + // Check for .gitignore + if (!$this->fileExists($projectPath, '.gitignore')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing .gitignore', + ]; + $score -= 5; + } + + // Check for node_modules in git + if ($this->fileExists($projectPath, 'node_modules') && + !$this->isInGitignore($projectPath, 'node_modules')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'node_modules not in .gitignore', + ]; + $score -= 10; + } + + $score = max(0, $score); + + $this->log('Node.js health check completed', 'info', [ + 'score' => $score, + 'issues_count' => count($issues), + ]); + + return [ + 'healthy' => $score >= 70, + 'score' => $score, + 'issues' => $issues, + ]; + } + + /** + * {@inheritdoc} + */ + public function getRequiredFiles(): array + { + return [ + 'package.json', + 'package-lock.json or yarn.lock or pnpm-lock.yaml', + ]; + } + + /** + * {@inheritdoc} + */ + public function getRecommendedFiles(): array + { + return [ + 'tsconfig.json (for TypeScript)', + '.eslintrc.js or .eslintrc.json', + '.prettierrc', + '.gitignore', + 'README.md', + 'LICENSE', + '.nvmrc or .node-version', + '.editorconfig', + 'jest.config.js or vitest.config.js', + '.github/workflows/* or .gitea/workflows/* or .gitlab-ci.yml', + ]; + } + + /** + * {@inheritdoc} + */ + public function getConfigSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'node_version' => [ + 'type' => 'string', + 'description' => 'Target Node.js version', + ], + 'package_manager' => [ + 'type' => 'string', + 'enum' => ['npm', 'yarn', 'pnpm'], + 'description' => 'Package manager to use', + ], + 'use_typescript' => [ + 'type' => 'boolean', + 'description' => 'Project uses TypeScript', + ], + 'framework' => [ + 'type' => 'string', + 'enum' => ['express', 'fastify', 'nest', 'react', 'vue', 'angular', 'next', 'nuxt', 'none'], + 'description' => 'Framework used', + ], + 'build_command' => [ + 'type' => 'string', + 'description' => 'Command to build the project', + ], + 'test_command' => [ + 'type' => 'string', + 'description' => 'Command to run tests', + ], + ], + 'required' => ['node_version', 'package_manager'], + ]; + } + + /** + * {@inheritdoc} + */ + public function getBestPractices(): array + { + return [ + 'Use semantic versioning for package versions', + 'Lock dependencies with package-lock.json, yarn.lock, or pnpm-lock.yaml', + 'Use TypeScript for type safety in large projects', + 'Configure ESLint for code quality', + 'Use Prettier for consistent formatting', + 'Exclude node_modules from version control', + 'Define npm scripts for common tasks', + 'Use .nvmrc to specify Node.js version', + 'Implement comprehensive unit and integration tests', + 'Use environment variables for configuration', + 'Follow security best practices (audit dependencies regularly)', + 'Document API endpoints and usage in README', + 'Use proper error handling and logging', + 'Implement CI/CD for automated testing and deployment', + 'Keep dependencies up to date', + ]; + } + + /** + * Check if TypeScript project + */ + private function isTypeScriptProject(string $projectPath): bool + { + if ($this->fileExists($projectPath, 'tsconfig.json')) { + return true; + } + + $packageData = $this->parseJsonFile($projectPath, 'package.json'); + if ($packageData) { + $deps = array_merge( + $packageData['dependencies'] ?? [], + $packageData['devDependencies'] ?? [] + ); + return isset($deps['typescript']); + } + + return false; + } + + /** + * Get Node version + */ + private function getNodeVersion(?array $packageData): string + { + if (!$packageData) { + return 'unknown'; + } + + if (isset($packageData['engines']['node'])) { + return $packageData['engines']['node']; + } + + return 'any'; + } + + /** + * Count dependencies + */ + private function countDependencies(?array $packageData, string $type): int + { + if (!$packageData || !isset($packageData[$type])) { + return 0; + } + + return count($packageData[$type]); + } + + /** + * Count scripts + */ + private function countScripts(?array $packageData): int + { + if (!$packageData || !isset($packageData['scripts'])) { + return 0; + } + + return count($packageData['scripts']); + } + + /** + * Check for tests + */ + private function hasTests(string $projectPath, ?array $packageData): bool + { + // Check for test directories + if ($this->fileExists($projectPath, 'test') || + $this->fileExists($projectPath, 'tests') || + $this->fileExists($projectPath, '__tests__') || + $this->fileExists($projectPath, 'spec')) { + return true; + } + + // Check for test script + if ($packageData && isset($packageData['scripts']['test'])) { + return true; + } + + // Check for test files + if ($this->countFiles($projectPath, '**/*.test.js') > 0 || + $this->countFiles($projectPath, '**/*.test.ts') > 0 || + $this->countFiles($projectPath, '**/*.spec.js') > 0 || + $this->countFiles($projectPath, '**/*.spec.ts') > 0) { + return true; + } + + return false; + } + + /** + * Detect framework + */ + private function detectFramework(string $projectPath, ?array $packageData): string + { + if (!$packageData) { + return 'none'; + } + + $deps = array_merge( + $packageData['dependencies'] ?? [], + $packageData['devDependencies'] ?? [] + ); + + $frameworks = [ + 'react' => 'React', + 'vue' => 'Vue', + '@angular/core' => 'Angular', + 'express' => 'Express', + 'fastify' => 'Fastify', + '@nestjs/core' => 'NestJS', + 'next' => 'Next.js', + 'nuxt' => 'Nuxt.js', + 'svelte' => 'Svelte', + ]; + + foreach ($frameworks as $dep => $name) { + if (isset($deps[$dep])) { + return $name; + } + } + + return 'none'; + } + + /** + * Check for CI/CD + */ + private function hasCICD(string $projectPath): bool + { + return $this->fileExists($projectPath, '.github/workflows') || + $this->fileExists($projectPath, '.gitea/workflows') || + $this->fileExists($projectPath, '.gitlab-ci.yml') || + $this->fileExists($projectPath, '.travis.yml') || + $this->fileExists($projectPath, '.circleci/config.yml'); + } + + /** + * Check for linting + */ + private function hasLinting(string $projectPath): bool + { + return $this->fileExists($projectPath, '.eslintrc.js') || + $this->fileExists($projectPath, '.eslintrc.json') || + $this->fileExists($projectPath, '.eslintrc.yml') || + $this->fileExists($projectPath, '.eslintrc'); + } + + /** + * Check if path is in .gitignore + */ + private function isInGitignore(string $projectPath, string $path): bool + { + $gitignore = $this->readFile($projectPath, '.gitignore'); + if (!$gitignore) { + return false; + } + + $lines = explode("\n", $gitignore); + foreach ($lines as $line) { + $line = trim($line); + if ($line === $path || $line === "/{$path}") { + return true; + } + } + + return false; + } + + /** + * Check for old dependencies + */ + private function hasOldDependencies(?array $packageData): bool + { + if (!$packageData) { + return false; + } + + // Simple heuristic: check for caret/tilde ranges on major version 0 + $deps = array_merge( + $packageData['dependencies'] ?? [], + $packageData['devDependencies'] ?? [] + ); + + $oldCount = 0; + foreach ($deps as $name => $version) { + if (preg_match('/^[\^~]?0\./', $version)) { + $oldCount++; + } + } + + return $oldCount > count($deps) * 0.3; + } +} diff --git a/lib/Enterprise/Plugins/PythonPlugin.php b/lib/Enterprise/Plugins/PythonPlugin.php new file mode 100644 index 0000000..d532fd8 --- /dev/null +++ b/lib/Enterprise/Plugins/PythonPlugin.php @@ -0,0 +1,625 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise.Plugins + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/Plugins/PythonPlugin.php + * VERSION: 04.06.00 + * BRIEF: Enterprise plugin for Python projects + */ + +declare(strict_types=1); + +namespace MokoEnterprise\Plugins; + +use MokoEnterprise\AbstractProjectPlugin; + +/** + * Python Project Plugin + * + * Provides validation, metrics, and management capabilities for + * Python projects. + */ +class PythonPlugin extends AbstractProjectPlugin +{ + /** + * {@inheritdoc} + */ + public function getProjectType(): string + { + return 'python'; + } + + /** + * {@inheritdoc} + */ + public function getPluginName(): string + { + return 'Python Enterprise Plugin'; + } + + /** + * {@inheritdoc} + */ + public function validateProject(array $config, string $projectPath): array + { + $errors = []; + $warnings = []; + + // Check for project configuration + $hasSetupPy = $this->fileExists($projectPath, 'setup.py'); + $hasPyproject = $this->fileExists($projectPath, 'pyproject.toml'); + + if (!$hasSetupPy && !$hasPyproject) { + $warnings[] = 'No setup.py or pyproject.toml found'; + } + + // Validate pyproject.toml if exists + if ($hasPyproject) { + $pyprojectData = $this->parsePyprojectToml($projectPath); + if (!$pyprojectData) { + $errors[] = 'Invalid pyproject.toml format'; + } else { + if (empty($pyprojectData['project']['name']) && empty($pyprojectData['tool']['poetry']['name'])) { + $errors[] = 'pyproject.toml missing project name'; + } + } + } + + // Check for requirements + if (!$this->fileExists($projectPath, 'requirements.txt') && + !$this->fileExists($projectPath, 'Pipfile') && + !$hasPyproject) { + $warnings[] = 'No requirements file found (requirements.txt, Pipfile, or pyproject.toml)'; + } + + // Check for __init__.py in package + $pythonFiles = $this->countFiles($projectPath, '**/*.py'); + if ($pythonFiles > 0) { + $hasInit = $this->countFiles($projectPath, '**/__init__.py') > 0; + if (!$hasInit) { + $warnings[] = 'No __init__.py found - may not be a proper Python package'; + } + } + + // Check for virtual environment in git + $venvDirs = ['venv', '.venv', 'env', '.env']; + foreach ($venvDirs as $dir) { + if ($this->fileExists($projectPath, $dir) && + !$this->isInGitignore($projectPath, $dir)) { + $warnings[] = "Virtual environment directory '{$dir}' should be in .gitignore"; + break; + } + } + + // Check for linting/formatting + if (!$this->fileExists($projectPath, '.flake8') && + !$this->fileExists($projectPath, '.pylintrc') && + !$this->fileExists($projectPath, 'pyproject.toml')) { + $warnings[] = 'No linting configuration found (.flake8, .pylintrc, or pyproject.toml)'; + } + + $this->log( + 'Python project validation completed', + 'info', + ['errors' => count($errors), 'warnings' => count($warnings)] + ); + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + 'warnings' => $warnings, + ]; + } + + /** + * {@inheritdoc} + */ + public function collectMetrics(string $projectPath, array $config): array + { + $metrics = [ + 'python_files' => $this->countFiles($projectPath, '**/*.py'), + 'has_setup_py' => $this->fileExists($projectPath, 'setup.py'), + 'has_pyproject_toml' => $this->fileExists($projectPath, 'pyproject.toml'), + 'has_requirements' => $this->fileExists($projectPath, 'requirements.txt'), + 'has_pipfile' => $this->fileExists($projectPath, 'Pipfile'), + 'has_poetry' => $this->hasPoetry($projectPath), + 'python_version' => $this->detectPythonVersion($projectPath), + 'dependencies_count' => $this->countDependencies($projectPath), + 'has_tests' => $this->hasTests($projectPath), + 'test_framework' => $this->detectTestFramework($projectPath), + 'framework' => $this->detectFramework($projectPath), + 'has_docker' => $this->fileExists($projectPath, 'Dockerfile'), + 'has_ci' => $this->hasCICD($projectPath), + ]; + + // Count lines of code + $pythonFiles = $this->findFiles($projectPath, '**/*.py'); + $totalLines = 0; + $docstringLines = 0; + + foreach ($pythonFiles as $file) { + if (is_file($file) && strpos($file, 'venv') === false && + strpos($file, '.venv') === false) { + $content = @file_get_contents($file); + if ($content) { + $lines = explode("\n", $content); + $totalLines += count($lines); + $docstringLines += preg_match_all('/""".*?"""/s', $content); + } + } + } + + $metrics['total_lines'] = $totalLines; + $metrics['docstring_count'] = $docstringLines; + + // Count classes and functions + $metrics['classes'] = $this->countClasses($projectPath); + $metrics['functions'] = $this->countFunctions($projectPath); + + // Record metrics + $this->recordMetric('python', 'python_files', $metrics['python_files']); + $this->recordMetric('python', 'total_lines', $totalLines); + $this->recordMetric('python', 'dependencies', $metrics['dependencies_count']); + + $this->log('Collected Python metrics', 'info', $metrics); + + return $metrics; + } + + /** + * {@inheritdoc} + */ + public function healthCheck(string $projectPath, array $config): array + { + $issues = []; + $score = 100; + + // Check for project configuration + if (!$this->fileExists($projectPath, 'setup.py') && + !$this->fileExists($projectPath, 'pyproject.toml')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'No setup.py or pyproject.toml found', + ]; + $score -= 10; + } + + // Check for requirements + if (!$this->fileExists($projectPath, 'requirements.txt') && + !$this->fileExists($projectPath, 'Pipfile') && + !$this->fileExists($projectPath, 'pyproject.toml')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'No requirements file found', + ]; + $score -= 15; + } + + // Check for virtual environment in git + $venvDirs = ['venv', '.venv', 'env']; + foreach ($venvDirs as $dir) { + if ($this->fileExists($projectPath, $dir) && + !$this->isInGitignore($projectPath, $dir)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => "Virtual environment '{$dir}' not in .gitignore", + ]; + $score -= 10; + break; + } + } + + // Check for __pycache__ in git + if ($this->fileExists($projectPath, '__pycache__') && + !$this->isInGitignore($projectPath, '__pycache__')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => '__pycache__ directories not in .gitignore', + ]; + $score -= 5; + } + + // Check for tests + if (!$this->hasTests($projectPath)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'No test directory or files found', + ]; + $score -= 15; + } + + // Check for linting configuration + if (!$this->hasLintingConfig($projectPath)) { + $issues[] = [ + 'severity' => 'info', + 'message' => 'No linting configuration found', + ]; + $score -= 5; + } + + // Check for type hints (basic check) + if (!$this->hasTypeHints($projectPath)) { + $issues[] = [ + 'severity' => 'info', + 'message' => 'Consider using type hints for better code quality', + ]; + $score -= 5; + } + + // Check for README + if (!$this->fileExists($projectPath, 'README.md') && + !$this->fileExists($projectPath, 'README.rst')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing README file', + ]; + $score -= 5; + } + + // Check for license + if (!$this->fileExists($projectPath, 'LICENSE') && + !$this->fileExists($projectPath, 'LICENSE.txt')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing LICENSE file', + ]; + $score -= 5; + } + + // Check for .gitignore + if (!$this->fileExists($projectPath, '.gitignore')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing .gitignore file', + ]; + $score -= 5; + } + + $score = max(0, $score); + + $this->log('Python health check completed', 'info', [ + 'score' => $score, + 'issues_count' => count($issues), + ]); + + return [ + 'healthy' => $score >= 70, + 'score' => $score, + 'issues' => $issues, + ]; + } + + /** + * {@inheritdoc} + */ + public function getRequiredFiles(): array + { + return [ + 'setup.py or pyproject.toml', + 'requirements.txt or Pipfile or pyproject.toml', + '__init__.py (in packages)', + ]; + } + + /** + * {@inheritdoc} + */ + public function getRecommendedFiles(): array + { + return [ + 'README.md or README.rst', + 'LICENSE', + '.gitignore', + 'requirements.txt or requirements/*.txt', + '.flake8 or .pylintrc', + 'tox.ini or noxfile.py', + 'pytest.ini or pyproject.toml', + '.python-version or .tool-versions', + 'Dockerfile', + '.github/workflows/* or .gitea/workflows/* or .gitlab-ci.yml', + ]; + } + + /** + * {@inheritdoc} + */ + public function getConfigSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'python_version' => [ + 'type' => 'string', + 'description' => 'Target Python version (e.g., 3.9, 3.10, 3.11)', + ], + 'package_manager' => [ + 'type' => 'string', + 'enum' => ['pip', 'pipenv', 'poetry', 'conda'], + 'description' => 'Package manager to use', + ], + 'framework' => [ + 'type' => 'string', + 'enum' => ['django', 'flask', 'fastapi', 'pyramid', 'none'], + 'description' => 'Web framework used', + ], + 'test_framework' => [ + 'type' => 'string', + 'enum' => ['pytest', 'unittest', 'nose', 'none'], + 'description' => 'Testing framework', + ], + 'use_type_hints' => [ + 'type' => 'boolean', + 'description' => 'Use Python type hints', + ], + ], + 'required' => ['python_version'], + ]; + } + + /** + * {@inheritdoc} + */ + public function getBestPractices(): array + { + return [ + 'Use virtual environments (venv, virtualenv, or conda)', + 'Pin dependencies with exact versions in requirements.txt', + 'Use setup.py or pyproject.toml for package metadata', + 'Follow PEP 8 style guide for code formatting', + 'Use type hints for better code clarity and tooling', + 'Write docstrings for all public functions and classes', + 'Organize code into packages with __init__.py', + 'Use pytest for testing with good coverage', + 'Configure linting with flake8, pylint, or ruff', + 'Format code with black or autopep8', + 'Exclude venv/, __pycache__/, and *.pyc from git', + 'Use .python-version to specify Python version', + 'Implement CI/CD for automated testing', + 'Document dependencies clearly', + 'Keep dependencies up to date with security patches', + ]; + } + + /** + * Parse pyproject.toml + */ + private function parsePyprojectToml(string $projectPath): ?array + { + $content = $this->readFile($projectPath, 'pyproject.toml'); + if (!$content) { + return null; + } + + // Basic TOML parsing (simplified) + $data = []; + $section = ''; + + foreach (explode("\n", $content) as $line) { + $line = trim($line); + if (preg_match('/^\[(.*)\]$/', $line, $matches)) { + $section = $matches[1]; + $data[$section] = []; + } elseif (preg_match('/^(\w+)\s*=\s*(.+)$/', $line, $matches) && $section) { + $key = $matches[1]; + $value = trim($matches[2], ' "\''); + $data[$section][$key] = $value; + } + } + + return $data; + } + + /** + * Check for Poetry + */ + private function hasPoetry(string $projectPath): bool + { + $pyprojectData = $this->parsePyprojectToml($projectPath); + return $pyprojectData && isset($pyprojectData['tool.poetry']); + } + + /** + * Detect Python version + */ + private function detectPythonVersion(string $projectPath): string + { + // Check .python-version + $pythonVersion = $this->readFile($projectPath, '.python-version'); + if ($pythonVersion) { + return trim($pythonVersion); + } + + // Check pyproject.toml + $pyprojectData = $this->parsePyprojectToml($projectPath); + if ($pyprojectData && isset($pyprojectData['project']['requires-python'])) { + return $pyprojectData['project']['requires-python']; + } + + // Check setup.py + $setupPy = $this->readFile($projectPath, 'setup.py'); + if ($setupPy && preg_match('/python_requires=["\']([^"\']+)["\']/', $setupPy, $matches)) { + return $matches[1]; + } + + return 'unknown'; + } + + /** + * Count dependencies + */ + private function countDependencies(string $projectPath): int + { + // Check requirements.txt + $requirements = $this->readFile($projectPath, 'requirements.txt'); + if ($requirements) { + $lines = array_filter(explode("\n", $requirements), function($line) { + $line = trim($line); + return !empty($line) && !str_starts_with($line, '#'); + }); + return count($lines); + } + + // Check pyproject.toml + $pyprojectData = $this->parsePyprojectToml($projectPath); + if ($pyprojectData && isset($pyprojectData['project']['dependencies'])) { + return count($pyprojectData['project']['dependencies']); + } + + return 0; + } + + /** + * Check for tests + */ + private function hasTests(string $projectPath): bool + { + return $this->fileExists($projectPath, 'tests') || + $this->fileExists($projectPath, 'test') || + $this->countFiles($projectPath, '**/test_*.py') > 0 || + $this->countFiles($projectPath, '**/*_test.py') > 0; + } + + /** + * Detect test framework + */ + private function detectTestFramework(string $projectPath): string + { + if ($this->fileExists($projectPath, 'pytest.ini') || + $this->fileExists($projectPath, 'pyproject.toml')) { + return 'pytest'; + } + + if ($this->countFiles($projectPath, '**/test_*.py') > 0) { + return 'pytest/unittest'; + } + + return 'none'; + } + + /** + * Detect framework + */ + private function detectFramework(string $projectPath): string + { + $requirements = $this->readFile($projectPath, 'requirements.txt'); + if ($requirements) { + if (stripos($requirements, 'django') !== false) { + return 'Django'; + } + if (stripos($requirements, 'flask') !== false) { + return 'Flask'; + } + if (stripos($requirements, 'fastapi') !== false) { + return 'FastAPI'; + } + if (stripos($requirements, 'pyramid') !== false) { + return 'Pyramid'; + } + } + + return 'none'; + } + + /** + * Check for CI/CD + */ + private function hasCICD(string $projectPath): bool + { + return $this->fileExists($projectPath, '.github/workflows') || + $this->fileExists($projectPath, '.gitea/workflows') || + $this->fileExists($projectPath, '.gitlab-ci.yml') || + $this->fileExists($projectPath, '.travis.yml') || + $this->fileExists($projectPath, 'tox.ini'); + } + + /** + * Check if in .gitignore + */ + private function isInGitignore(string $projectPath, string $path): bool + { + $gitignore = $this->readFile($projectPath, '.gitignore'); + if (!$gitignore) { + return false; + } + + return strpos($gitignore, $path) !== false; + } + + /** + * Check for linting configuration + */ + private function hasLintingConfig(string $projectPath): bool + { + return $this->fileExists($projectPath, '.flake8') || + $this->fileExists($projectPath, '.pylintrc') || + $this->fileExists($projectPath, 'pyproject.toml') || + $this->fileExists($projectPath, 'setup.cfg'); + } + + /** + * Check for type hints + */ + private function hasTypeHints(string $projectPath): bool + { + $pythonFiles = $this->findFiles($projectPath, '*.py'); + + foreach (array_slice($pythonFiles, 0, 5) as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && preg_match('/def\s+\w+\([^)]*:[^)]+\)\s*->/', $content)) { + return true; + } + } + } + + return false; + } + + /** + * Count classes + */ + private function countClasses(string $projectPath): int + { + $pythonFiles = $this->findFiles($projectPath, '**/*.py'); + $count = 0; + + foreach ($pythonFiles as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content) { + $count += preg_match_all('/^class\s+\w+/m', $content); + } + } + } + + return $count; + } + + /** + * Count functions + */ + private function countFunctions(string $projectPath): int + { + $pythonFiles = $this->findFiles($projectPath, '**/*.py'); + $count = 0; + + foreach ($pythonFiles as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content) { + $count += preg_match_all('/^def\s+\w+/m', $content); + } + } + } + + return $count; + } +} diff --git a/lib/Enterprise/Plugins/TerraformPlugin.php b/lib/Enterprise/Plugins/TerraformPlugin.php new file mode 100644 index 0000000..e59cfb7 --- /dev/null +++ b/lib/Enterprise/Plugins/TerraformPlugin.php @@ -0,0 +1,584 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise.Plugins + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/Plugins/TerraformPlugin.php + * VERSION: 04.06.00 + * BRIEF: Enterprise plugin for Terraform projects + */ + +declare(strict_types=1); + +namespace MokoEnterprise\Plugins; + +use MokoEnterprise\AbstractProjectPlugin; + +/** + * Terraform Project Plugin + * + * Provides validation, metrics, and management capabilities for + * Terraform infrastructure-as-code projects. + */ +class TerraformPlugin extends AbstractProjectPlugin +{ + /** + * {@inheritdoc} + */ + public function getProjectType(): string + { + return 'terraform'; + } + + /** + * {@inheritdoc} + */ + public function getPluginName(): string + { + return 'Terraform Enterprise Plugin'; + } + + /** + * {@inheritdoc} + */ + public function validateProject(array $config, string $projectPath): array + { + $errors = []; + $warnings = []; + + // Check for .tf files + $tfFiles = $this->countFiles($projectPath, '*.tf'); + if ($tfFiles === 0) { + $errors[] = 'No Terraform (.tf) files found'; + } + + // Check for main.tf + if (!$this->fileExists($projectPath, 'main.tf')) { + $warnings[] = 'No main.tf file found'; + } + + // Check for variables.tf + if (!$this->fileExists($projectPath, 'variables.tf')) { + $warnings[] = 'No variables.tf file found'; + } + + // Check for outputs.tf + if (!$this->fileExists($projectPath, 'outputs.tf')) { + $warnings[] = 'No outputs.tf file found'; + } + + // Check for terraform.tfvars in git + if ($this->fileExists($projectPath, 'terraform.tfvars') && + !$this->isInGitignore($projectPath, 'terraform.tfvars')) { + $warnings[] = 'terraform.tfvars may contain secrets and should be in .gitignore'; + } + + // Check for .terraform directory in git + if ($this->fileExists($projectPath, '.terraform') && + !$this->isInGitignore($projectPath, '.terraform')) { + $warnings[] = '.terraform directory should be in .gitignore'; + } + + // Check for backend configuration + if (!$this->hasBackendConfig($projectPath)) { + $warnings[] = 'No backend configuration found - state will be stored locally'; + } + + // Check for version constraints + if (!$this->hasVersionConstraints($projectPath)) { + $warnings[] = 'No Terraform version constraints defined'; + } + + // Check for proper formatting + $hasFormatIssues = $this->checkFormatting($projectPath); + if ($hasFormatIssues) { + $warnings[] = 'Some Terraform files may not be properly formatted (run terraform fmt)'; + } + + $this->log( + 'Terraform project validation completed', + 'info', + ['errors' => count($errors), 'warnings' => count($warnings)] + ); + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + 'warnings' => $warnings, + ]; + } + + /** + * {@inheritdoc} + */ + public function collectMetrics(string $projectPath, array $config): array + { + $metrics = [ + 'tf_files' => $this->countFiles($projectPath, '*.tf'), + 'tfvars_files' => $this->countFiles($projectPath, '*.tfvars'), + 'modules' => $this->countModules($projectPath), + 'resources' => $this->countResources($projectPath), + 'data_sources' => $this->countDataSources($projectPath), + 'variables' => $this->countVariables($projectPath), + 'outputs' => $this->countOutputs($projectPath), + 'providers' => $this->detectProviders($projectPath), + 'has_backend' => $this->hasBackendConfig($projectPath), + 'has_lock_file' => $this->fileExists($projectPath, '.terraform.lock.hcl'), + 'has_tests' => $this->hasTests($projectPath), + 'terraform_version' => $this->detectTerraformVersion($projectPath), + ]; + + // Count lines of code + $tfFiles = $this->findFiles($projectPath, '*.tf'); + $totalLines = 0; + foreach ($tfFiles as $file) { + if (is_file($file)) { + $totalLines += count(file($file)); + } + } + $metrics['total_lines'] = $totalLines; + + // Record metrics + $this->recordMetric('terraform', 'tf_files', $metrics['tf_files']); + $this->recordMetric('terraform', 'resources', $metrics['resources']); + $this->recordMetric('terraform', 'total_lines', $totalLines); + + $this->log('Collected Terraform metrics', 'info', $metrics); + + return $metrics; + } + + /** + * {@inheritdoc} + */ + public function healthCheck(string $projectPath, array $config): array + { + $issues = []; + $score = 100; + + // Check for .tf files + if ($this->countFiles($projectPath, '*.tf') === 0) { + $issues[] = [ + 'severity' => 'critical', + 'message' => 'No Terraform files found', + ]; + $score -= 30; + } + + // Check for standard file structure + if (!$this->fileExists($projectPath, 'main.tf')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing main.tf', + ]; + $score -= 10; + } + + if (!$this->fileExists($projectPath, 'variables.tf')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing variables.tf', + ]; + $score -= 5; + } + + if (!$this->fileExists($projectPath, 'outputs.tf')) { + $issues[] = [ + 'severity' => 'info', + 'message' => 'Missing outputs.tf', + ]; + $score -= 5; + } + + // Check for backend configuration + if (!$this->hasBackendConfig($projectPath)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'No remote backend configured (state stored locally)', + ]; + $score -= 10; + } + + // Check for lock file + if (!$this->fileExists($projectPath, '.terraform.lock.hcl')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing .terraform.lock.hcl (run terraform init)', + ]; + $score -= 10; + } + + // Check for version constraints + if (!$this->hasVersionConstraints($projectPath)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'No Terraform version constraints defined', + ]; + $score -= 5; + } + + // Check for secrets in tfvars + if ($this->fileExists($projectPath, 'terraform.tfvars') && + !$this->isInGitignore($projectPath, 'terraform.tfvars')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'terraform.tfvars not in .gitignore', + ]; + $score -= 10; + } + + // Check .terraform directory + if ($this->fileExists($projectPath, '.terraform') && + !$this->isInGitignore($projectPath, '.terraform')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => '.terraform directory not in .gitignore', + ]; + $score -= 5; + } + + // Check formatting + if ($this->checkFormatting($projectPath)) { + $issues[] = [ + 'severity' => 'info', + 'message' => 'Files not formatted (run terraform fmt)', + ]; + $score -= 5; + } + + // Check for README + if (!$this->fileExists($projectPath, 'README.md')) { + $issues[] = [ + 'severity' => 'info', + 'message' => 'Missing README.md', + ]; + $score -= 5; + } + + $score = max(0, $score); + + $this->log('Terraform health check completed', 'info', [ + 'score' => $score, + 'issues_count' => count($issues), + ]); + + return [ + 'healthy' => $score >= 70, + 'score' => $score, + 'issues' => $issues, + ]; + } + + /** + * {@inheritdoc} + */ + public function getRequiredFiles(): array + { + return [ + '*.tf files', + '.terraform.lock.hcl', + ]; + } + + /** + * {@inheritdoc} + */ + public function getRecommendedFiles(): array + { + return [ + 'main.tf', + 'variables.tf', + 'outputs.tf', + 'versions.tf', + 'terraform.tfvars.example', + '.gitignore', + 'README.md', + '.terraform-version or .tool-versions', + 'modules/ (for reusable modules)', + 'examples/', + ]; + } + + /** + * {@inheritdoc} + */ + public function getConfigSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'terraform_version' => [ + 'type' => 'string', + 'description' => 'Required Terraform version', + ], + 'providers' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'description' => 'List of Terraform providers used', + ], + 'backend_type' => [ + 'type' => 'string', + 'enum' => ['local', 's3', 'azurerm', 'gcs', 'remote', 'consul', 'etcd'], + 'description' => 'Backend type for state storage', + ], + 'enable_validation' => [ + 'type' => 'boolean', + 'description' => 'Enable terraform validate checks', + ], + 'enable_security_scan' => [ + 'type' => 'boolean', + 'description' => 'Enable security scanning with tools like tfsec', + ], + ], + 'required' => ['terraform_version'], + ]; + } + + /** + * {@inheritdoc} + */ + public function getBestPractices(): array + { + return [ + 'Use remote backend for state storage (S3, Azure, GCS)', + 'Define Terraform version constraints in versions.tf', + 'Organize code into reusable modules', + 'Use variables.tf for all input variables', + 'Document outputs in outputs.tf', + 'Never commit terraform.tfvars with sensitive data', + 'Use .terraform-version or .tool-versions for version management', + 'Run terraform fmt before committing', + 'Use terraform validate to check syntax', + 'Implement security scanning with tfsec or checkov', + 'Use consistent naming conventions', + 'Add descriptions to all variables and outputs', + 'Pin provider versions in versions.tf', + 'Use workspaces for environment separation', + 'Document infrastructure in README with examples', + ]; + } + + /** + * Check for backend configuration + */ + private function hasBackendConfig(string $projectPath): bool + { + $tfFiles = $this->findFiles($projectPath, '*.tf'); + + foreach ($tfFiles as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && preg_match('/terraform\s*\{[^}]*backend\s+["\w]+\s*\{/', $content)) { + return true; + } + } + } + + return false; + } + + /** + * Check for version constraints + */ + private function hasVersionConstraints(string $projectPath): bool + { + $tfFiles = $this->findFiles($projectPath, '*.tf'); + + foreach ($tfFiles as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && preg_match('/required_version\s*=/', $content)) { + return true; + } + } + } + + return false; + } + + /** + * Check formatting + */ + private function checkFormatting(string $projectPath): bool + { + // This is a simplified check - in production, would run terraform fmt -check + return false; + } + + /** + * Count modules + */ + private function countModules(string $projectPath): int + { + $count = 0; + $tfFiles = $this->findFiles($projectPath, '*.tf'); + + foreach ($tfFiles as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content) { + $count += preg_match_all('/module\s+"[^"]+"\s*\{/', $content); + } + } + } + + return $count; + } + + /** + * Count resources + */ + private function countResources(string $projectPath): int + { + $count = 0; + $tfFiles = $this->findFiles($projectPath, '*.tf'); + + foreach ($tfFiles as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content) { + $count += preg_match_all('/resource\s+"[^"]+"\s+"[^"]+"\s*\{/', $content); + } + } + } + + return $count; + } + + /** + * Count data sources + */ + private function countDataSources(string $projectPath): int + { + $count = 0; + $tfFiles = $this->findFiles($projectPath, '*.tf'); + + foreach ($tfFiles as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content) { + $count += preg_match_all('/data\s+"[^"]+"\s+"[^"]+"\s*\{/', $content); + } + } + } + + return $count; + } + + /** + * Count variables + */ + private function countVariables(string $projectPath): int + { + $count = 0; + $tfFiles = $this->findFiles($projectPath, '*.tf'); + + foreach ($tfFiles as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content) { + $count += preg_match_all('/variable\s+"[^"]+"\s*\{/', $content); + } + } + } + + return $count; + } + + /** + * Count outputs + */ + private function countOutputs(string $projectPath): int + { + $count = 0; + $tfFiles = $this->findFiles($projectPath, '*.tf'); + + foreach ($tfFiles as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content) { + $count += preg_match_all('/output\s+"[^"]+"\s*\{/', $content); + } + } + } + + return $count; + } + + /** + * Detect providers + */ + private function detectProviders(string $projectPath): array + { + $providers = []; + $tfFiles = $this->findFiles($projectPath, '*.tf'); + + foreach ($tfFiles as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content) { + if (preg_match_all('/provider\s+"([^"]+)"\s*\{/', $content, $matches)) { + $providers = array_merge($providers, $matches[1]); + } + } + } + } + + return array_unique($providers); + } + + /** + * Detect Terraform version + */ + private function detectTerraformVersion(string $projectPath): string + { + // Check .terraform-version + $versionFile = $this->readFile($projectPath, '.terraform-version'); + if ($versionFile) { + return trim($versionFile); + } + + // Check versions.tf + $tfFiles = $this->findFiles($projectPath, '*.tf'); + foreach ($tfFiles as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && preg_match('/required_version\s*=\s*"([^"]+)"/', $content, $matches)) { + return $matches[1]; + } + } + } + + return 'unknown'; + } + + /** + * Check for tests + */ + private function hasTests(string $projectPath): bool + { + return $this->fileExists($projectPath, 'test') || + $this->fileExists($projectPath, 'tests') || + $this->fileExists($projectPath, 'examples'); + } + + /** + * Check if in .gitignore + */ + private function isInGitignore(string $projectPath, string $path): bool + { + $gitignore = $this->readFile($projectPath, '.gitignore'); + if (!$gitignore) { + return false; + } + + return strpos($gitignore, $path) !== false; + } +} diff --git a/lib/Enterprise/Plugins/WordPressPlugin.php b/lib/Enterprise/Plugins/WordPressPlugin.php new file mode 100644 index 0000000..8882109 --- /dev/null +++ b/lib/Enterprise/Plugins/WordPressPlugin.php @@ -0,0 +1,677 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise.Plugins + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/Plugins/WordPressPlugin.php + * VERSION: 04.06.00 + * BRIEF: Enterprise plugin for WordPress projects + */ + +declare(strict_types=1); + +namespace MokoEnterprise\Plugins; + +use MokoEnterprise\AbstractProjectPlugin; + +/** + * WordPress Project Plugin + * + * Provides validation, metrics, and management capabilities for + * WordPress plugins and themes. + */ +class WordPressPlugin extends AbstractProjectPlugin +{ + /** + * {@inheritdoc} + */ + public function getProjectType(): string + { + return 'wordpress'; + } + + /** + * {@inheritdoc} + */ + public function getPluginName(): string + { + return 'WordPress Enterprise Plugin'; + } + + /** + * {@inheritdoc} + */ + public function validateProject(array $config, string $projectPath): array + { + $errors = []; + $warnings = []; + + $projectTypeWP = $this->detectWordPressType($projectPath); + + // Check for main file + $mainFile = $this->findMainFile($projectPath, $projectTypeWP); + if (!$mainFile) { + $errors[] = 'No WordPress plugin or theme header found'; + } else { + $headerData = $this->parseHeader($mainFile, $projectTypeWP); + if (!$headerData) { + $errors[] = 'Invalid WordPress header format'; + } else { + if (empty($headerData['name'])) { + $errors[] = 'Missing plugin/theme name in header'; + } + if (empty($headerData['version'])) { + $warnings[] = 'Missing version in header'; + } + if (empty($headerData['author'])) { + $warnings[] = 'Missing author in header'; + } + if ($projectTypeWP === 'plugin' && empty($headerData['license'])) { + $warnings[] = 'Missing license in header'; + } + } + } + + // Check for WordPress coding standards + if (!$this->fileExists($projectPath, 'phpcs.xml') && + !$this->fileExists($projectPath, 'phpcs.xml.dist')) { + $warnings[] = 'No PHPCS configuration found (WordPress Coding Standards recommended)'; + } + + // Check for text domain + if (!$this->hasTextDomain($projectPath)) { + $warnings[] = 'No text domain found for translations'; + } + + // Check for unescaped output (basic check) + if ($this->hasUnescapedOutput($projectPath)) { + $warnings[] = 'Potential unescaped output found (security risk)'; + } + + // Check for direct file access protection + if (!$this->hasFileAccessProtection($projectPath)) { + $warnings[] = 'Some files missing direct access protection'; + } + + $this->log( + 'WordPress project validation completed', + 'info', + ['errors' => count($errors), 'warnings' => count($warnings), 'type' => $projectTypeWP] + ); + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + 'warnings' => $warnings, + ]; + } + + /** + * {@inheritdoc} + */ + public function collectMetrics(string $projectPath, array $config): array + { + $projectTypeWP = $this->detectWordPressType($projectPath); + + $metrics = [ + 'wordpress_type' => $projectTypeWP, + 'php_files' => $this->countFiles($projectPath, '**/*.php'), + 'js_files' => $this->countFiles($projectPath, '**/*.js'), + 'css_files' => $this->countFiles($projectPath, '**/*.css'), + 'template_files' => $this->countTemplateFiles($projectPath, $projectTypeWP), + 'has_hooks' => $this->hasHooks($projectPath), + 'hooks_count' => $this->countHooks($projectPath), + 'has_ajax' => $this->hasAjax($projectPath), + 'has_rest_api' => $this->hasRestAPI($projectPath), + 'has_gutenberg_blocks' => $this->hasGutenbergBlocks($projectPath), + 'has_widgets' => $this->hasWidgets($projectPath), + 'has_shortcodes' => $this->hasShortcodes($projectPath), + 'has_tests' => $this->fileExists($projectPath, 'tests') || + $this->fileExists($projectPath, 'test'), + ]; + + // Count lines of code + $phpFiles = $this->findFiles($projectPath, '**/*.php'); + $totalLines = 0; + foreach ($phpFiles as $file) { + if (is_file($file)) { + $totalLines += count(file($file)); + } + } + $metrics['total_lines'] = $totalLines; + + // Record metrics + $this->recordMetric('wordpress', 'php_files', $metrics['php_files']); + $this->recordMetric('wordpress', 'total_lines', $totalLines); + $this->recordMetric('wordpress', 'hooks_count', $metrics['hooks_count']); + + $this->log('Collected WordPress metrics', 'info', $metrics); + + return $metrics; + } + + /** + * {@inheritdoc} + */ + public function healthCheck(string $projectPath, array $config): array + { + $issues = []; + $score = 100; + + $projectTypeWP = $this->detectWordPressType($projectPath); + + // Check for main file + $mainFile = $this->findMainFile($projectPath, $projectTypeWP); + if (!$mainFile) { + $issues[] = [ + 'severity' => 'critical', + 'message' => 'No WordPress plugin or theme header found', + ]; + $score -= 30; + } + + // Check for security issues + if ($this->hasUnescapedOutput($projectPath)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Potential unescaped output detected', + ]; + $score -= 15; + } + + if (!$this->hasFileAccessProtection($projectPath)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Some files missing direct access protection', + ]; + $score -= 10; + } + + // Check for SQL injection risks + if ($this->hasSQLInjectionRisk($projectPath)) { + $issues[] = [ + 'severity' => 'critical', + 'message' => 'Potential SQL injection vulnerability detected', + ]; + $score -= 20; + } + + // Check for nonce verification + if (!$this->hasNonceVerification($projectPath)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing nonce verification in forms/AJAX', + ]; + $score -= 10; + } + + // Check for text domain + if (!$this->hasTextDomain($projectPath)) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'No text domain for translations', + ]; + $score -= 5; + } + + // Check for README + if (!$this->fileExists($projectPath, 'README.md') && + !$this->fileExists($projectPath, 'readme.txt')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing README file', + ]; + $score -= 5; + } + + // Check for license + if (!$this->fileExists($projectPath, 'LICENSE') && + !$this->fileExists($projectPath, 'license.txt')) { + $issues[] = [ + 'severity' => 'warning', + 'message' => 'Missing LICENSE file', + ]; + $score -= 5; + } + + $score = max(0, $score); + + $this->log('WordPress health check completed', 'info', [ + 'score' => $score, + 'issues_count' => count($issues), + 'type' => $projectTypeWP, + ]); + + return [ + 'healthy' => $score >= 70, + 'score' => $score, + 'issues' => $issues, + ]; + } + + /** + * {@inheritdoc} + */ + public function getRequiredFiles(): array + { + return [ + 'Plugin: main plugin file with header', + 'Theme: style.css with theme header', + 'Theme: index.php', + ]; + } + + /** + * {@inheritdoc} + */ + public function getRecommendedFiles(): array + { + return [ + 'README.md or readme.txt', + 'LICENSE or license.txt', + 'CHANGELOG.md', + 'phpcs.xml or phpcs.xml.dist', + 'languages/*.pot (translation template)', + 'assets/ (for WordPress.org)', + 'uninstall.php (for cleanup)', + 'Plugin: plugin-name.php', + 'Theme: functions.php, screenshot.png', + ]; + } + + /** + * {@inheritdoc} + */ + public function getConfigSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'wordpress_type' => [ + 'type' => 'string', + 'enum' => ['plugin', 'theme', 'mu-plugin'], + 'description' => 'Type of WordPress project', + ], + 'min_wp_version' => [ + 'type' => 'string', + 'description' => 'Minimum WordPress version required', + ], + 'min_php_version' => [ + 'type' => 'string', + 'description' => 'Minimum PHP version required', + ], + 'text_domain' => [ + 'type' => 'string', + 'description' => 'Text domain for translations', + ], + 'uses_gutenberg' => [ + 'type' => 'boolean', + 'description' => 'Uses Gutenberg blocks', + ], + ], + 'required' => ['wordpress_type'], + ]; + } + + /** + * {@inheritdoc} + */ + public function getBestPractices(): array + { + return [ + 'Follow WordPress Coding Standards', + 'Use proper escaping for all output (esc_html, esc_attr, etc.)', + 'Sanitize all user input', + 'Use $wpdb->prepare() for database queries', + 'Implement nonce verification for forms and AJAX', + 'Add direct file access protection to all PHP files', + 'Use wp_enqueue_script/style for assets', + 'Implement proper text domain for translations', + 'Use WordPress APIs instead of direct database access', + 'Add uninstall.php for cleanup', + 'Follow semantic versioning', + 'Include comprehensive inline documentation', + 'Use hooks (actions/filters) for extensibility', + 'Implement proper error handling and logging', + 'Test with WP_DEBUG enabled', + ]; + } + + /** + * Detect WordPress project type + */ + private function detectWordPressType(string $projectPath): string + { + // Check for theme + if ($this->fileExists($projectPath, 'style.css')) { + $styleContent = $this->readFile($projectPath, 'style.css'); + if ($styleContent && strpos($styleContent, 'Theme Name:') !== false) { + return 'theme'; + } + } + + // Check for plugin + $phpFiles = $this->findFiles($projectPath, '*.php'); + foreach ($phpFiles as $file) { + $content = @file_get_contents($file); + if ($content && strpos($content, 'Plugin Name:') !== false) { + return 'plugin'; + } + } + + return 'unknown'; + } + + /** + * Find main file + */ + private function findMainFile(string $projectPath, string $type): ?string + { + if ($type === 'theme') { + $styleFile = $projectPath . '/style.css'; + return file_exists($styleFile) ? $styleFile : null; + } + + // Look for plugin header + $phpFiles = $this->findFiles($projectPath, '*.php'); + foreach ($phpFiles as $file) { + $content = @file_get_contents($file); + if ($content && strpos($content, 'Plugin Name:') !== false) { + return $file; + } + } + + return null; + } + + /** + * Parse WordPress header + */ + private function parseHeader(string $file, string $type): ?array + { + $content = @file_get_contents($file); + if (!$content) { + return null; + } + + $data = [ + 'name' => null, + 'version' => null, + 'author' => null, + 'license' => null, + ]; + + $nameField = $type === 'theme' ? 'Theme Name' : 'Plugin Name'; + + if (preg_match('/' . $nameField . ':\s*(.+)/i', $content, $matches)) { + $data['name'] = trim($matches[1]); + } + if (preg_match('/Version:\s*(.+)/i', $content, $matches)) { + $data['version'] = trim($matches[1]); + } + if (preg_match('/Author:\s*(.+)/i', $content, $matches)) { + $data['author'] = trim($matches[1]); + } + if (preg_match('/License:\s*(.+)/i', $content, $matches)) { + $data['license'] = trim($matches[1]); + } + + return $data; + } + + /** + * Check for text domain + */ + private function hasTextDomain(string $projectPath): bool + { + $phpFiles = $this->findFiles($projectPath, '*.php'); + + foreach (array_slice($phpFiles, 0, 5) as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && preg_match('/__(|_e|_x|_ex|_n)\s*\(/', $content)) { + return true; + } + } + } + + return false; + } + + /** + * Check for unescaped output + */ + private function hasUnescapedOutput(string $projectPath): bool + { + $phpFiles = $this->findFiles($projectPath, '*.php'); + + foreach (array_slice($phpFiles, 0, 10) as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content) { + // Look for echo without escape functions + if (preg_match('/echo\s+\$[^;]+;(?!.*esc_)/m', $content)) { + return true; + } + } + } + } + + return false; + } + + /** + * Check for file access protection + */ + private function hasFileAccessProtection(string $projectPath): bool + { + $phpFiles = $this->findFiles($projectPath, '*.php'); + $protectedCount = 0; + + foreach (array_slice($phpFiles, 0, 10) as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && ( + strpos($content, 'defined( \'ABSPATH\' )') !== false || + strpos($content, 'defined(\'ABSPATH\')') !== false || + strpos($content, 'if ( ! defined( \'ABSPATH\' ) )') !== false + )) { + $protectedCount++; + } + } + } + + return $protectedCount > count(array_slice($phpFiles, 0, 10)) / 2; + } + + /** + * Check for SQL injection risk + */ + private function hasSQLInjectionRisk(string $projectPath): bool + { + $phpFiles = $this->findFiles($projectPath, '*.php'); + + foreach (array_slice($phpFiles, 0, 10) as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content) { + // Look for direct $wpdb->query without prepare + if (preg_match('/\$wpdb->query\s*\(\s*["\'].*\$/', $content)) { + return true; + } + } + } + } + + return false; + } + + /** + * Check for nonce verification + */ + private function hasNonceVerification(string $projectPath): bool + { + $phpFiles = $this->findFiles($projectPath, '*.php'); + + foreach (array_slice($phpFiles, 0, 10) as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && ( + strpos($content, 'wp_verify_nonce') !== false || + strpos($content, 'check_ajax_referer') !== false + )) { + return true; + } + } + } + + return false; + } + + /** + * Count template files + */ + private function countTemplateFiles(string $projectPath, string $type): int + { + if ($type === 'theme') { + return $this->countFiles($projectPath, '*.php'); + } + + return $this->countFiles($projectPath, 'templates/*.php'); + } + + /** + * Check for hooks + */ + private function hasHooks(string $projectPath): bool + { + $phpFiles = $this->findFiles($projectPath, '*.php'); + + foreach (array_slice($phpFiles, 0, 5) as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && ( + strpos($content, 'add_action') !== false || + strpos($content, 'add_filter') !== false + )) { + return true; + } + } + } + + return false; + } + + /** + * Count hooks + */ + private function countHooks(string $projectPath): int + { + $count = 0; + $phpFiles = $this->findFiles($projectPath, '*.php'); + + foreach ($phpFiles as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content) { + $count += preg_match_all('/(add_action|add_filter)\s*\(/', $content); + } + } + } + + return $count; + } + + /** + * Check for AJAX + */ + private function hasAjax(string $projectPath): bool + { + $phpFiles = $this->findFiles($projectPath, '*.php'); + + foreach ($phpFiles as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && strpos($content, 'wp_ajax_') !== false) { + return true; + } + } + } + + return false; + } + + /** + * Check for REST API + */ + private function hasRestAPI(string $projectPath): bool + { + $phpFiles = $this->findFiles($projectPath, '*.php'); + + foreach ($phpFiles as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && strpos($content, 'register_rest_route') !== false) { + return true; + } + } + } + + return false; + } + + /** + * Check for Gutenberg blocks + */ + private function hasGutenbergBlocks(string $projectPath): bool + { + return $this->fileExists($projectPath, 'blocks') || + $this->fileExists($projectPath, 'src/blocks') || + $this->countFiles($projectPath, '**/block.json') > 0; + } + + /** + * Check for widgets + */ + private function hasWidgets(string $projectPath): bool + { + $phpFiles = $this->findFiles($projectPath, '*.php'); + + foreach ($phpFiles as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && strpos($content, 'WP_Widget') !== false) { + return true; + } + } + } + + return false; + } + + /** + * Check for shortcodes + */ + private function hasShortcodes(string $projectPath): bool + { + $phpFiles = $this->findFiles($projectPath, '*.php'); + + foreach ($phpFiles as $file) { + if (is_file($file)) { + $content = @file_get_contents($file); + if ($content && strpos($content, 'add_shortcode') !== false) { + return true; + } + } + } + + return false; + } +} diff --git a/lib/Enterprise/ProjectConfigValidator.php b/lib/Enterprise/ProjectConfigValidator.php new file mode 100644 index 0000000..d6a2416 --- /dev/null +++ b/lib/Enterprise/ProjectConfigValidator.php @@ -0,0 +1,355 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise.ProjectTypes + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/ProjectConfigValidator.php + * VERSION: 04.06.00 + * BRIEF: Enterprise library for validating project configurations + */ + +declare(strict_types=1); + +namespace MokoEnterprise; + +/** + * Project Config Validator + * + * Enterprise library for validating project configurations against + * project type templates and standards. + */ +class ProjectConfigValidator +{ + private AuditLogger $logger; + private MetricsCollector $metrics; + private ProjectTypeDetector $detector; + + private array $validationResults = []; + private int $errorsCount = 0; + private int $warningsCount = 0; + + private const VALIDATION_RULES = [ + 'nodejs' => [ + 'required_files' => ['package.json'], + 'recommended_files' => ['README.md', '.gitignore', 'tsconfig.json'], + 'required_fields' => ['name', 'version', 'description'], + ], + 'python' => [ + 'required_files' => ['setup.py|pyproject.toml'], + 'recommended_files' => ['README.md', 'requirements.txt', '.gitignore'], + 'required_fields' => ['name', 'version'], + ], + 'terraform' => [ + 'required_files' => ['*.tf'], + 'recommended_files' => ['README.md', 'variables.tf', 'outputs.tf'], + 'required_fields' => [], + ], + 'wordpress' => [ + 'required_files' => ['*.php'], + 'recommended_files' => ['README.md', 'readme.txt'], + 'required_fields' => ['Plugin Name|Theme Name', 'Version'], + ], + 'mobile' => [ + 'required_files' => ['package.json|pubspec.yaml'], + 'recommended_files' => ['README.md', '.gitignore'], + 'required_fields' => ['name', 'version'], + ], + 'api' => [ + 'required_files' => [], + 'recommended_files' => ['README.md', 'openapi.yaml|swagger.yaml', 'Dockerfile'], + 'required_fields' => [], + ], + ]; + + /** + * Constructor + */ + public function __construct( + ?AuditLogger $logger = null, + ?MetricsCollector $metrics = null, + ?ProjectTypeDetector $detector = null + ) { + $this->logger = $logger ?? new AuditLogger('project_config_validator'); + $this->metrics = $metrics ?? new MetricsCollector(); + $this->detector = $detector ?? new ProjectTypeDetector($this->logger, $this->metrics); + } + + /** + * Validate project configuration + * + * @param string $repoPath Path to repository + * @param string|null $projectType Optional project type (auto-detect if null) + * @return array Validation results + */ + public function validate(string $repoPath, ?string $projectType = null): array + { + $this->logger->logInfo("Validating project configuration: {$repoPath}"); + + $this->resetResults(); + + // Detect project type if not provided + if ($projectType === null) { + $detection = $this->detector->detect($repoPath); + $projectType = $detection['type']; + $this->logger->logInfo("Auto-detected project type: {$projectType}"); + } + + // Get validation rules for project type + $rules = self::VALIDATION_RULES[$projectType] ?? []; + + if (empty($rules)) { + $this->addWarning('No validation rules for project type: ' . $projectType); + return $this->getResults(); + } + + // Run validations + $this->validateRequiredFiles($repoPath, $rules['required_files'] ?? []); + $this->validateRecommendedFiles($repoPath, $rules['recommended_files'] ?? []); + $this->validateProjectFields($repoPath, $projectType, $rules['required_fields'] ?? []); + + // Record metrics + $this->metrics->setGauge('validation_errors', $this->errorsCount); + $this->metrics->setGauge('validation_warnings', $this->warningsCount); + + $this->logger->logInfo("Validation complete: {$this->errorsCount} errors, {$this->warningsCount} warnings"); + + return $this->getResults(); + } + + /** + * Check if validation passed (no errors) + */ + public function passed(): bool + { + return $this->errorsCount === 0; + } + + /** + * Get validation results + */ + public function getResults(): array + { + return [ + 'passed' => $this->passed(), + 'errors' => $this->errorsCount, + 'warnings' => $this->warningsCount, + 'results' => $this->validationResults, + ]; + } + + private function resetResults(): void + { + $this->validationResults = []; + $this->errorsCount = 0; + $this->warningsCount = 0; + } + + private function validateRequiredFiles(string $path, array $files): void + { + foreach ($files as $filePattern) { + $found = false; + + // Handle OR patterns (file1|file2) + if (strpos($filePattern, '|') !== false) { + $patterns = explode('|', $filePattern); + foreach ($patterns as $pattern) { + if ($this->filePatternExists($path, trim($pattern))) { + $found = true; + break; + } + } + } else { + $found = $this->filePatternExists($path, $filePattern); + } + + if (!$found) { + $this->addError("Required file missing: {$filePattern}"); + } else { + $this->addSuccess("Required file found: {$filePattern}"); + } + } + } + + private function validateRecommendedFiles(string $path, array $files): void + { + foreach ($files as $filePattern) { + $found = false; + + // Handle OR patterns + if (strpos($filePattern, '|') !== false) { + $patterns = explode('|', $filePattern); + foreach ($patterns as $pattern) { + if ($this->filePatternExists($path, trim($pattern))) { + $found = true; + break; + } + } + } else { + $found = $this->filePatternExists($path, $filePattern); + } + + if (!$found) { + $this->addWarning("Recommended file missing: {$filePattern}"); + } else { + $this->addSuccess("Recommended file found: {$filePattern}"); + } + } + } + + private function validateProjectFields(string $path, string $projectType, array $fields): void + { + if (empty($fields)) { + return; + } + + // Validate based on project type + switch ($projectType) { + case 'nodejs': + $this->validateNodeJSFields($path, $fields); + break; + case 'python': + $this->validatePythonFields($path, $fields); + break; + case 'wordpress': + $this->validateWordPressFields($path, $fields); + break; + default: + $this->logger->logInfo("No field validation for project type: {$projectType}"); + } + } + + private function validateNodeJSFields(string $path, array $fields): void + { + $packageFile = "{$path}/package.json"; + if (!file_exists($packageFile)) { + $this->addError("Cannot validate fields: package.json not found"); + return; + } + + $package = json_decode(file_get_contents($packageFile), true); + if (!$package) { + $this->addError("Cannot parse package.json"); + return; + } + + foreach ($fields as $field) { + if (!isset($package[$field])) { + $this->addError("Required field missing in package.json: {$field}"); + } else { + $this->addSuccess("Required field found in package.json: {$field}"); + } + } + } + + private function validatePythonFields(string $path, array $fields): void + { + $setupFile = "{$path}/setup.py"; + $pyprojectFile = "{$path}/pyproject.toml"; + + if (!file_exists($setupFile) && !file_exists($pyprojectFile)) { + $this->addError("Cannot validate fields: setup.py or pyproject.toml not found"); + return; + } + + // Basic validation - check if fields appear in file content + $content = ''; + if (file_exists($setupFile)) { + $content = file_get_contents($setupFile); + } elseif (file_exists($pyprojectFile)) { + $content = file_get_contents($pyprojectFile); + } + + foreach ($fields as $field) { + if (stripos($content, $field) === false) { + $this->addWarning("Field may be missing: {$field}"); + } else { + $this->addSuccess("Field appears to be present: {$field}"); + } + } + } + + private function validateWordPressFields(string $path, array $fields): void + { + $phpFiles = glob("{$path}/*.php"); + if (empty($phpFiles)) { + $this->addError("No PHP files found for WordPress validation"); + return; + } + + $content = ''; + foreach ($phpFiles as $file) { + $content .= file_get_contents($file); + } + + foreach ($fields as $field) { + // Handle OR patterns + if (strpos($field, '|') !== false) { + $patterns = explode('|', $field); + $found = false; + foreach ($patterns as $pattern) { + if (stripos($content, trim($pattern)) !== false) { + $found = true; + break; + } + } + if (!$found) { + $this->addError("Required header field missing: {$field}"); + } else { + $this->addSuccess("Required header field found"); + } + } else { + if (stripos($content, $field) === false) { + $this->addError("Required header field missing: {$field}"); + } else { + $this->addSuccess("Required header field found: {$field}"); + } + } + } + } + + private function filePatternExists(string $path, string $pattern): bool + { + // Handle wildcard patterns + if (strpos($pattern, '*') !== false) { + $files = glob("{$path}/{$pattern}"); + return !empty($files); + } + + return file_exists("{$path}/{$pattern}"); + } + + private function addError(string $message): void + { + $this->validationResults[] = [ + 'level' => 'error', + 'message' => $message, + ]; + $this->errorsCount++; + $this->logger->logError($message); + } + + private function addWarning(string $message): void + { + $this->validationResults[] = [ + 'level' => 'warning', + 'message' => $message, + ]; + $this->warningsCount++; + $this->logger->logWarning($message); + } + + private function addSuccess(string $message): void + { + $this->validationResults[] = [ + 'level' => 'success', + 'message' => $message, + ]; + } +} diff --git a/lib/Enterprise/ProjectMetricsCollector.php b/lib/Enterprise/ProjectMetricsCollector.php new file mode 100644 index 0000000..bfa90ba --- /dev/null +++ b/lib/Enterprise/ProjectMetricsCollector.php @@ -0,0 +1,303 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise.ProjectTypes + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/ProjectMetricsCollector.php + * VERSION: 04.06.00 + * BRIEF: Enterprise library for collecting project-specific metrics + */ + +declare(strict_types=1); + +namespace MokoEnterprise; + +/** + * Project Metrics Collector + * + * Enterprise library for collecting metrics specific to different + * project types (Node.js, Python, Terraform, etc.). + */ +class ProjectMetricsCollector +{ + private AuditLogger $logger; + private MetricsCollector $metrics; + private ProjectTypeDetector $detector; + + private array $collectedMetrics = []; + + /** + * Constructor + */ + public function __construct( + ?AuditLogger $logger = null, + ?MetricsCollector $metrics = null, + ?ProjectTypeDetector $detector = null + ) { + $this->logger = $logger ?? new AuditLogger('project_metrics_collector'); + $this->metrics = $metrics ?? new MetricsCollector(); + $this->detector = $detector ?? new ProjectTypeDetector($this->logger, $this->metrics); + } + + /** + * Collect metrics for a project + * + * @param string $repoPath Path to repository + * @param string|null $projectType Optional project type (auto-detect if null) + * @return array Collected metrics + */ + public function collect(string $repoPath, ?string $projectType = null): array + { + $this->logger->logInfo("Collecting project metrics: {$repoPath}"); + + $this->collectedMetrics = []; + + // Detect project type if not provided + if ($projectType === null) { + $detection = $this->detector->detect($repoPath); + $projectType = $detection['type']; + } + + // Collect common metrics + $this->collectCommonMetrics($repoPath); + + // Collect type-specific metrics + switch ($projectType) { + case 'nodejs': + $this->collectNodeJSMetrics($repoPath); + break; + case 'python': + $this->collectPythonMetrics($repoPath); + break; + case 'terraform': + $this->collectTerraformMetrics($repoPath); + break; + case 'wordpress': + $this->collectWordPressMetrics($repoPath); + break; + case 'mobile': + $this->collectMobileMetrics($repoPath); + break; + case 'api': + $this->collectAPIMetrics($repoPath); + break; + } + + // Record to metrics system + foreach ($this->collectedMetrics as $key => $value) { + if (is_numeric($value)) { + $this->metrics->setGauge("project_{$key}", (float)$value); + } + } + + $this->logger->logInfo("Collected " . count($this->collectedMetrics) . " metrics"); + + return $this->collectedMetrics; + } + + /** + * Get collected metrics + */ + public function getMetrics(): array + { + return $this->collectedMetrics; + } + + private function collectCommonMetrics(string $path): void + { + // File counts + $this->collectedMetrics['total_files'] = $this->countFiles($path, '*'); + $this->collectedMetrics['total_directories'] = $this->countDirectories($path); + + // Documentation + $this->collectedMetrics['has_readme'] = file_exists("{$path}/README.md") ? 1 : 0; + $this->collectedMetrics['has_license'] = file_exists("{$path}/LICENSE") ? 1 : 0; + $this->collectedMetrics['has_contributing'] = file_exists("{$path}/CONTRIBUTING.md") ? 1 : 0; + + // Git + $this->collectedMetrics['has_gitignore'] = file_exists("{$path}/.gitignore") ? 1 : 0; + + // CI/CD — check both .github/workflows and .gitea/workflows + $hasGithubWf = is_dir("{$path}/.github/workflows"); + $hasGiteaWf = is_dir("{$path}/.gitea/workflows"); + $this->collectedMetrics['has_ci_workflows'] = ($hasGithubWf || $hasGiteaWf) ? 1 : 0; + $this->collectedMetrics['workflow_count'] = + $this->countFiles("{$path}/.github/workflows", '*.yml') + + $this->countFiles("{$path}/.github/workflows", '*.yaml') + + $this->countFiles("{$path}/.gitea/workflows", '*.yml') + + $this->countFiles("{$path}/.gitea/workflows", '*.yaml'); + } + + private function collectNodeJSMetrics(string $path): void + { + // Package.json analysis + if (file_exists("{$path}/package.json")) { + $package = json_decode(file_get_contents("{$path}/package.json"), true); + if ($package) { + $this->collectedMetrics['npm_dependencies'] = count($package['dependencies'] ?? []); + $this->collectedMetrics['npm_dev_dependencies'] = count($package['devDependencies'] ?? []); + $this->collectedMetrics['npm_scripts'] = count($package['scripts'] ?? []); + $this->collectedMetrics['has_npm_private'] = isset($package['private']) && $package['private'] ? 1 : 0; + } + } + + // TypeScript + $this->collectedMetrics['has_typescript'] = file_exists("{$path}/tsconfig.json") ? 1 : 0; + $this->collectedMetrics['typescript_files'] = $this->countFiles($path, '*.ts'); + $this->collectedMetrics['tsx_files'] = $this->countFiles($path, '*.tsx'); + + // JavaScript + $this->collectedMetrics['javascript_files'] = $this->countFiles($path, '*.js'); + $this->collectedMetrics['jsx_files'] = $this->countFiles($path, '*.jsx'); + + // Lock files + $this->collectedMetrics['has_package_lock'] = file_exists("{$path}/package-lock.json") ? 1 : 0; + $this->collectedMetrics['has_yarn_lock'] = file_exists("{$path}/yarn.lock") ? 1 : 0; + $this->collectedMetrics['has_pnpm_lock'] = file_exists("{$path}/pnpm-lock.yaml") ? 1 : 0; + } + + private function collectPythonMetrics(string $path): void + { + // Python files + $this->collectedMetrics['python_files'] = $this->countFiles($path, '*.py'); + + // Package configuration + $this->collectedMetrics['has_setup_py'] = file_exists("{$path}/setup.py") ? 1 : 0; + $this->collectedMetrics['has_pyproject_toml'] = file_exists("{$path}/pyproject.toml") ? 1 : 0; + $this->collectedMetrics['has_requirements_txt'] = file_exists("{$path}/requirements.txt") ? 1 : 0; + + // Requirements count + if (file_exists("{$path}/requirements.txt")) { + $lines = file("{$path}/requirements.txt", FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $deps = array_filter($lines, fn($line) => !str_starts_with(trim($line), '#')); + $this->collectedMetrics['python_dependencies'] = count($deps); + } + + // Virtual environment + $venvDirs = ['venv', '.venv', 'env', '.env']; + $hasVenv = false; + foreach ($venvDirs as $dir) { + if (is_dir("{$path}/{$dir}")) { + $hasVenv = true; + break; + } + } + $this->collectedMetrics['has_virtual_env'] = $hasVenv ? 1 : 0; + + // Testing + $this->collectedMetrics['has_pytest'] = is_dir("{$path}/tests") || is_dir("{$path}/test") ? 1 : 0; + } + + private function collectTerraformMetrics(string $path): void + { + // Terraform files + $this->collectedMetrics['terraform_files'] = $this->countFiles($path, '*.tf'); + $this->collectedMetrics['terraform_var_files'] = $this->countFiles($path, '*.tfvars'); + + // Standard files + $this->collectedMetrics['has_main_tf'] = file_exists("{$path}/main.tf") ? 1 : 0; + $this->collectedMetrics['has_variables_tf'] = file_exists("{$path}/variables.tf") ? 1 : 0; + $this->collectedMetrics['has_outputs_tf'] = file_exists("{$path}/outputs.tf") ? 1 : 0; + + // Terraform lock + $this->collectedMetrics['has_terraform_lock'] = file_exists("{$path}/.terraform.lock.hcl") ? 1 : 0; + + // Terraform directory + $this->collectedMetrics['has_terraform_dir'] = is_dir("{$path}/.terraform") ? 1 : 0; + } + + private function collectWordPressMetrics(string $path): void + { + // PHP files + $this->collectedMetrics['php_files'] = $this->countFiles($path, '*.php'); + + // WordPress readme + $this->collectedMetrics['has_wp_readme'] = file_exists("{$path}/readme.txt") ? 1 : 0; + + // Common WordPress directories + $wpDirs = ['includes', 'assets', 'templates', 'languages']; + $dirCount = 0; + foreach ($wpDirs as $dir) { + if (is_dir("{$path}/{$dir}")) { + $dirCount++; + } + } + $this->collectedMetrics['wordpress_directories'] = $dirCount; + + // Assets + $this->collectedMetrics['css_files'] = $this->countFiles($path, '*.css'); + $this->collectedMetrics['js_files'] = $this->countFiles($path, '*.js'); + } + + private function collectMobileMetrics(string $path): void + { + // Platform detection + $this->collectedMetrics['has_ios'] = is_dir("{$path}/ios") ? 1 : 0; + $this->collectedMetrics['has_android'] = is_dir("{$path}/android") ? 1 : 0; + + // Framework detection + $this->collectedMetrics['is_react_native'] = false; + $this->collectedMetrics['is_flutter'] = false; + + if (file_exists("{$path}/package.json")) { + $package = json_decode(file_get_contents("{$path}/package.json"), true); + if ($package && isset($package['dependencies']['react-native'])) { + $this->collectedMetrics['is_react_native'] = 1; + } + } + + if (file_exists("{$path}/pubspec.yaml")) { + $this->collectedMetrics['is_flutter'] = 1; + $this->collectedMetrics['dart_files'] = $this->countFiles($path, '*.dart'); + } + + // Build configurations + $this->collectedMetrics['has_gradle'] = file_exists("{$path}/build.gradle") ? 1 : 0; + $this->collectedMetrics['has_xcode_project'] = $this->countFiles($path, '*.xcodeproj') > 0 ? 1 : 0; + } + + private function collectAPIMetrics(string $path): void + { + // API documentation + $apiDocs = ['openapi.yaml', 'openapi.json', 'swagger.yaml', 'swagger.json']; + $hasApiDoc = false; + foreach ($apiDocs as $doc) { + if (file_exists("{$path}/{$doc}")) { + $hasApiDoc = true; + break; + } + } + $this->collectedMetrics['has_api_documentation'] = $hasApiDoc ? 1 : 0; + + // GraphQL + $this->collectedMetrics['graphql_files'] = $this->countFiles($path, '*.graphql'); + $this->collectedMetrics['has_graphql_schema'] = file_exists("{$path}/schema.graphql") ? 1 : 0; + + // Protocol Buffers + $this->collectedMetrics['proto_files'] = $this->countFiles($path, '*.proto'); + + // Docker + $this->collectedMetrics['has_dockerfile'] = file_exists("{$path}/Dockerfile") ? 1 : 0; + $this->collectedMetrics['has_docker_compose'] = + file_exists("{$path}/docker-compose.yml") || file_exists("{$path}/docker-compose.yaml") ? 1 : 0; + } + + private function countFiles(string $path, string $pattern): int + { + $files = glob("{$path}/{$pattern}"); + return count($files ?: []); + } + + private function countDirectories(string $path): int + { + $dirs = glob("{$path}/*", GLOB_ONLYDIR); + return count($dirs ?: []); + } +} diff --git a/lib/Enterprise/ProjectPluginInterface.php b/lib/Enterprise/ProjectPluginInterface.php new file mode 100644 index 0000000..4766907 --- /dev/null +++ b/lib/Enterprise/ProjectPluginInterface.php @@ -0,0 +1,118 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise.ProjectTypes + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/ProjectTypeDetector.php + * VERSION: 04.06.00 + * BRIEF: Enterprise library for detecting project types + */ + +declare(strict_types=1); + +namespace MokoEnterprise; + +/** + * Project Type Detector + * + * Enterprise library for automatically detecting project types based on + * repository structure, configuration files, and code patterns. + */ +class ProjectTypeDetector +{ + private const DETECTION_THRESHOLD = 0.5; + + private AuditLogger $logger; + private MetricsCollector $metrics; + + private array $detectionResults = []; + private string $detectedType = 'generic'; + private float $confidence = 0.0; + + /** + * Constructor + */ + public function __construct( + ?AuditLogger $logger = null, + ?MetricsCollector $metrics = null + ) { + $this->logger = $logger ?? new AuditLogger('project_type_detector'); + $this->metrics = $metrics ?? new MetricsCollector(); + } + + /** + * Detect project type from repository path + * + * @param string $repoPath Path to repository + * @return array Detection results with type and confidence + */ + public function detect(string $repoPath): array + { + $this->logger->logInfo("Detecting project type for: {$repoPath}"); + + $this->resetResults(); + + // Run all detection methods + $this->detectJoomla($repoPath); + $this->detectDolibarr($repoPath); + $this->detectNodeJS($repoPath); + $this->detectPython($repoPath); + $this->detectTerraform($repoPath); + $this->detectWordPress($repoPath); + $this->detectMobile($repoPath); + $this->detectAPI($repoPath); + + // Determine best match + $this->determineBestMatch(); + + // Record metrics + $this->metrics->increment("project_type_detected_{$this->detectedType}"); + $this->metrics->setGauge('detection_confidence', $this->confidence); + + $this->logger->logInfo("Detected type: {$this->detectedType} (confidence: {$this->confidence})"); + + return [ + 'type' => $this->detectedType, + 'confidence' => $this->confidence, + 'all_scores' => $this->detectionResults, + ]; + } + + /** + * Get detected project type + */ + public function getType(): string + { + return $this->detectedType; + } + + /** + * Get detection confidence + */ + public function getConfidence(): float + { + return $this->confidence; + } + + /** + * Get all detection scores + */ + public function getAllScores(): array + { + return $this->detectionResults; + } + + private function resetResults(): void + { + $this->detectionResults = [ + 'joomla' => 0.0, + 'dolibarr' => 0.0, + 'nodejs' => 0.0, + 'python' => 0.0, + 'terraform' => 0.0, + 'wordpress' => 0.0, + 'mobile' => 0.0, + 'api' => 0.0, + 'generic' => 0.0, + ]; + $this->detectedType = 'generic'; + $this->confidence = 0.0; + } + + private function determineBestMatch(): void + { + $maxScore = 0.0; + $bestType = 'generic'; + + foreach ($this->detectionResults as $type => $score) { + if ($score > $maxScore && $score >= self::DETECTION_THRESHOLD) { + $maxScore = $score; + $bestType = $type; + } + } + + $this->detectedType = $bestType; + $this->confidence = $maxScore; + } + + private function detectJoomla(string $path): void + { + $score = 0.0; + + // Check for Joomla manifest files + if ($this->fileExists($path, '*.xml', ['extension', 'install'])) { + $score += 0.5; + } + + // Check for Joomla directories + $joomlaDirs = ['site', 'admin', 'administrator', 'language', 'media']; + foreach ($joomlaDirs as $dir) { + if (is_dir("{$path}/{$dir}")) { + $score += 0.1; + } + } + + $this->detectionResults['joomla'] = min(1.0, $score); + } + + private function detectDolibarr(string $path): void + { + $score = 0.0; + + // Check for Dolibarr module descriptor + if ($this->fileContains($path, 'mod*.class.php', 'DolibarrModules')) { + $score += 0.6; + } + + // Check for Dolibarr directories + $dolibarrDirs = ['core/modules', 'sql', 'class', 'lib']; + foreach ($dolibarrDirs as $dir) { + if (is_dir("{$path}/{$dir}")) { + $score += 0.1; + } + } + + $this->detectionResults['dolibarr'] = min(1.0, $score); + } + + private function detectNodeJS(string $path): void + { + $score = 0.0; + + if (file_exists("{$path}/package.json")) { + $score += 0.6; + + $content = @file_get_contents("{$path}/package.json"); + if ($content) { + if (strpos($content, '"typescript"') !== false) { + $score += 0.1; + } + if (strpos($content, '"react"') !== false || strpos($content, '"vue"') !== false) { + $score += 0.1; + } + } + } + + if (file_exists("{$path}/tsconfig.json")) { + $score += 0.2; + } + + $this->detectionResults['nodejs'] = min(1.0, $score); + } + + private function detectPython(string $path): void + { + $score = 0.0; + + if (file_exists("{$path}/setup.py") || file_exists("{$path}/pyproject.toml")) { + $score += 0.6; + } + + if (file_exists("{$path}/requirements.txt")) { + $score += 0.2; + } + + if (file_exists("{$path}/Pipfile") || file_exists("{$path}/poetry.lock")) { + $score += 0.2; + } + + $this->detectionResults['python'] = min(1.0, $score); + } + + private function detectTerraform(string $path): void + { + $score = 0.0; + + if ($this->fileExists($path, '*.tf')) { + $score += 0.6; + } + + if (file_exists("{$path}/.terraform.lock.hcl")) { + $score += 0.2; + } + + $commonFiles = ['main.tf', 'variables.tf', 'outputs.tf']; + $found = 0; + foreach ($commonFiles as $file) { + if (file_exists("{$path}/{$file}")) { + $found++; + } + } + if ($found >= 2) { + $score += 0.2; + } + + $this->detectionResults['terraform'] = min(1.0, $score); + } + + private function detectWordPress(string $path): void + { + $score = 0.0; + + if ($this->fileContains($path, '*.php', 'Plugin Name:') || + $this->fileContains($path, '*.php', 'Theme Name:')) { + $score += 0.6; + } + + $wpFunctions = ['add_action', 'add_filter', 'wp_enqueue_script']; + foreach ($wpFunctions as $func) { + if ($this->fileContains($path, '*.php', $func)) { + $score += 0.15; + break; + } + } + + $this->detectionResults['wordpress'] = min(1.0, $score); + } + + private function detectMobile(string $path): void + { + $score = 0.0; + + // React Native + if (file_exists("{$path}/package.json")) { + $content = @file_get_contents("{$path}/package.json"); + if ($content && strpos($content, '"react-native"') !== false) { + $score += 0.6; + } + } + + // Flutter + if (file_exists("{$path}/pubspec.yaml")) { + $score += 0.6; + } + + // Native iOS/Android + if ($this->fileExists($path, '*.xcodeproj') || file_exists("{$path}/build.gradle")) { + $score += 0.4; + } + + $this->detectionResults['mobile'] = min(1.0, $score); + } + + private function detectAPI(string $path): void + { + $score = 0.0; + + // API documentation + $apiDocs = ['openapi.yaml', 'openapi.json', 'swagger.yaml', 'swagger.json']; + foreach ($apiDocs as $doc) { + if (file_exists("{$path}/{$doc}")) { + $score += 0.4; + break; + } + } + + // GraphQL + if ($this->fileExists($path, '*.graphql') || file_exists("{$path}/schema.graphql")) { + $score += 0.3; + } + + // Docker (common in APIs) + if (file_exists("{$path}/Dockerfile")) { + $score += 0.2; + } + + // API frameworks + if ($this->fileContains($path, '*.py', '@app.route') || + $this->fileContains($path, '*.js', 'express()') || + $this->fileContains($path, '*.ts', '@Controller')) { + $score += 0.3; + } + + $this->detectionResults['api'] = min(1.0, $score); + } + + private function fileExists(string $path, string $pattern, array $contains = []): bool + { + $files = glob("{$path}/{$pattern}"); + if (empty($files)) { + return false; + } + + if (empty($contains)) { + return true; + } + + foreach ($files as $file) { + $content = @file_get_contents($file); + if (!$content) continue; + + foreach ($contains as $search) { + if (strpos($content, $search) !== false) { + return true; + } + } + } + + return false; + } + + private function fileContains(string $path, string $pattern, string $search): bool + { + $files = glob("{$path}/{$pattern}"); + if (empty($files)) { + return false; + } + + foreach ($files as $file) { + $content = @file_get_contents($file); + if ($content && strpos($content, $search) !== false) { + return true; + } + } + + return false; + } +} diff --git a/lib/Enterprise/RecoveryError.php b/lib/Enterprise/RecoveryError.php new file mode 100644 index 0000000..04b31a7 --- /dev/null +++ b/lib/Enterprise/RecoveryError.php @@ -0,0 +1,27 @@ + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @package MokoStandards\Enterprise + * @version 04.00.04 + * @author MokoStandards Team + * @license GPL-3.0-or-later + */ + +namespace MokoEnterprise; + +use RuntimeException; + +/** + * Exception raised when recovery operations fail. + */ +class RecoveryError extends RuntimeException +{ +} diff --git a/lib/Enterprise/RecoveryManager.php b/lib/Enterprise/RecoveryManager.php new file mode 100644 index 0000000..cc05553 --- /dev/null +++ b/lib/Enterprise/RecoveryManager.php @@ -0,0 +1,124 @@ + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @package MokoStandards\Enterprise + * @version 04.00.04 + * @author MokoStandards Team + * @license GPL-3.0-or-later + */ + +namespace MokoEnterprise; + +use DateTime; +use DateTimeZone; + +/** + * High-level manager for recovery operations. + * + * Features: + * - Check recovery availability + * - Recover operations from checkpoints + * - Track recovery history + * - Cleanup old checkpoints + * + * Example: + * ```php + * $manager = new RecoveryManager(); + * + * if ($manager->canRecover('my_operation')) { + * $state = $manager->recoverOperation('my_operation'); + * // Resume from saved state + * } + * ``` + */ +class RecoveryManager +{ + private CheckpointManager $checkpointManager; + /** @var array> */ + private array $recoveryLog = []; + + /** + * Initialize recovery manager. + * + * @param string $checkpointDir Directory for checkpoints + */ + public function __construct(string $checkpointDir = '.checkpoints') + { + $this->checkpointManager = new CheckpointManager($checkpointDir); + } + + /** + * Check if an operation can be recovered. + * + * @param string $operationName Name of the operation + * @return bool True if recovery checkpoint exists + */ + public function canRecover(string $operationName): bool + { + $checkpoints = $this->checkpointManager->listCheckpoints($operationName); + return count($checkpoints) > 0; + } + + /** + * Recover an operation from checkpoint. + * + * @param string $operationName Name of the operation to recover + * @return array|null Recovered state or null + */ + public function recoverOperation(string $operationName): ?array + { + error_log("Attempting to recover operation: {$operationName}"); + $state = $this->checkpointManager->loadCheckpoint($operationName); + + if ($state !== null) { + $this->recoveryLog[] = [ + 'operation' => $operationName, + 'recovered_at' => (new DateTime('now', new DateTimeZone('UTC')))->format('c'), + 'state' => $state, + ]; + error_log("Successfully recovered operation: {$operationName}"); + } else { + error_log("No checkpoint found for operation: {$operationName}"); + } + + return $state; + } + + /** + * Get recovery log. + * + * @return array> List of recovery operations + */ + public function getRecoveryLog(): array + { + return $this->recoveryLog; + } + + /** + * Clean up old checkpoints. + * + * @param int $keepLatest Number of latest checkpoints to keep per operation + */ + public function cleanupOldCheckpoints(int $keepLatest = 5): void + { + $this->checkpointManager->cleanupCheckpoints(null, $keepLatest); + } + + /** + * Get checkpoint manager instance. + * + * @return CheckpointManager + */ + public function getCheckpointManager(): CheckpointManager + { + return $this->checkpointManager; + } +} diff --git a/lib/Enterprise/RepositoryHealthChecker.php b/lib/Enterprise/RepositoryHealthChecker.php new file mode 100644 index 0000000..e72274d --- /dev/null +++ b/lib/Enterprise/RepositoryHealthChecker.php @@ -0,0 +1,317 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/RepositoryHealthChecker.php + * VERSION: 04.06.00 + * BRIEF: Repository health checking enterprise library + */ + +declare(strict_types=1); + +namespace MokoEnterprise; + +/** + * Repository Health Checker + * + * Enterprise library for performing comprehensive repository health checks + * with scoring system and category-based validation. + */ +class RepositoryHealthChecker +{ + private AuditLogger $logger; + private MetricsCollector $metrics; + private UnifiedValidation $validator; + + private array $results = [ + 'categories' => [], + 'checks' => [], + 'score' => 0, + 'max_score' => 100, + 'percentage' => 0.0, + 'level' => 'unknown', + ]; + + /** + * Constructor + */ + public function __construct( + ?AuditLogger $logger = null, + ?MetricsCollector $metrics = null, + ?UnifiedValidation $validator = null + ) { + $this->logger = $logger ?? new AuditLogger('repo_health_checker'); + $this->metrics = $metrics ?? new MetricsCollector(); + $this->validator = $validator ?? new UnifiedValidation(); + } + + /** + * Check repository health + * + * @param string $path Repository path to check + * @return array Health check results + */ + public function check(string $path): array + { + $this->logger->logInfo("Starting health check for: {$path}"); + + $this->resetResults(); + + // Run all check categories + $this->runStructureChecks($path); + $this->runDocumentationChecks($path); + $this->runWorkflowChecks($path); + $this->runSecurityChecks($path); + + // Calculate final scores + $this->calculateScore(); + + // Record metrics + $this->metrics->setGauge('repo_health_score', $this->results['percentage']); + $this->metrics->setGauge('repo_health_checks_passed', + count(array_filter($this->results['checks'], fn($c) => $c['passed']))); + + $this->logger->logInfo("Health check complete: {$this->results['percentage']}% ({$this->results['level']})"); + + return $this->results; + } + + /** + * Reset results for new check + */ + private function resetResults(): void + { + $this->results = [ + 'categories' => [], + 'checks' => [], + 'score' => 0, + 'max_score' => 100, + 'percentage' => 0.0, + 'level' => 'unknown', + ]; + } + + /** + * Run repository structure checks + */ + private function runStructureChecks(string $path): void + { + $category = 'structure'; + $this->results['categories'][$category] = [ + 'name' => 'Repository Structure', + 'max_points' => 30, + 'earned_points' => 0, + 'checks_passed' => 0, + 'checks_failed' => 0, + ]; + + // Check README exists + $this->addCheck($category, 'README.md exists', + file_exists("{$path}/README.md"), 10); + + // Check LICENSE exists + $this->addCheck($category, 'LICENSE file exists', + file_exists("{$path}/LICENSE"), 10); + + // Check .gitignore exists + $this->addCheck($category, '.gitignore exists', + file_exists("{$path}/.gitignore"), 5); + + // Check CHANGELOG exists + $this->addCheck($category, 'CHANGELOG.md exists', + file_exists("{$path}/CHANGELOG.md"), 5); + } + + /** + * Run documentation checks + */ + private function runDocumentationChecks(string $path): void + { + $category = 'documentation'; + $this->results['categories'][$category] = [ + 'name' => 'Documentation', + 'max_points' => 25, + 'earned_points' => 0, + 'checks_passed' => 0, + 'checks_failed' => 0, + ]; + + // Check docs directory exists + $this->addCheck($category, 'docs/ directory exists', + is_dir("{$path}/docs"), 10); + + // Check README has content + if (file_exists("{$path}/README.md")) { + $content = file_get_contents("{$path}/README.md"); + $this->addCheck($category, 'README has substantial content', + strlen($content) > 500, 10); + } + + // Check for code of conduct + $this->addCheck($category, 'CODE_OF_CONDUCT.md exists', + file_exists("{$path}/CODE_OF_CONDUCT.md"), 5); + } + + /** + * Run workflow checks + */ + private function runWorkflowChecks(string $path): void + { + $category = 'workflows'; + $this->results['categories'][$category] = [ + 'name' => 'CI/CD Workflows', + 'max_points' => 20, + 'earned_points' => 0, + 'checks_passed' => 0, + 'checks_failed' => 0, + ]; + + // Check both .github/workflows and .gitea/workflows + $githubDir = "{$path}/.github/workflows"; + $giteaDir = "{$path}/.gitea/workflows"; + $hasWorkflowDir = is_dir($githubDir) || is_dir($giteaDir); + $workflowDir = is_dir($giteaDir) ? $giteaDir : $githubDir; + + // Check workflows directory exists + $this->addCheck($category, 'Workflows directory exists', + $hasWorkflowDir, 10); + + // Check for CI workflow + if ($hasWorkflowDir) { + $hasCI = !empty(glob("{$workflowDir}/ci*.yml")) || !empty(glob("{$workflowDir}/ci*.yaml")); + $this->addCheck($category, 'CI workflow exists', + $hasCI, 10); + } + } + + /** + * Run security checks + */ + private function runSecurityChecks(string $path): void + { + $category = 'security'; + $this->results['categories'][$category] = [ + 'name' => 'Security', + 'max_points' => 25, + 'earned_points' => 0, + 'checks_passed' => 0, + 'checks_failed' => 0, + ]; + + // Check for SECURITY.md + $this->addCheck($category, 'SECURITY.md exists', + file_exists("{$path}/SECURITY.md") || + file_exists("{$path}/.github/SECURITY.md"), 10); + + // Check for security scanning workflow (CodeQL on GitHub, Trivy on Gitea) + $githubWf = "{$path}/.github/workflows"; + $giteaWf = "{$path}/.gitea/workflows"; + $hasSecurityScan = false; + if (is_dir($githubWf)) { + $hasSecurityScan = !empty(glob("{$githubWf}/*codeql*.yml")) || !empty(glob("{$githubWf}/*codeql*.yaml")); + } + if (!$hasSecurityScan && is_dir($giteaWf)) { + $hasSecurityScan = !empty(glob("{$giteaWf}/*trivy*.yml")) || !empty(glob("{$giteaWf}/*trivy*.yaml")); + } + $this->addCheck($category, 'Security scanning workflow exists', + $hasSecurityScan, 10); + + // Check for dependency management (Dependabot on GitHub, Renovate on Gitea) + $this->addCheck($category, 'Dependency management configured', + file_exists("{$path}/.github/dependabot.yml") || + file_exists("{$path}/.github/dependabot.yaml") || + file_exists("{$path}/renovate.json") || + file_exists("{$path}/.renovaterc.json"), 5); + } + + /** + * Add a check result + */ + private function addCheck(string $category, string $name, bool $passed, int $points): void + { + $this->results['checks'][] = [ + 'category' => $category, + 'name' => $name, + 'passed' => $passed, + 'points' => $points, + ]; + + if ($passed) { + $this->results['categories'][$category]['earned_points'] += $points; + $this->results['categories'][$category]['checks_passed']++; + } else { + $this->results['categories'][$category]['checks_failed']++; + } + } + + /** + * Calculate overall score and health level + */ + private function calculateScore(): void + { + $totalEarned = 0; + $maxScore = 0; + + foreach ($this->results['categories'] as $category) { + $totalEarned += $category['earned_points']; + $maxScore += $category['max_points']; + } + + $this->results['score'] = $totalEarned; + $this->results['max_score'] = $maxScore; + $this->results['percentage'] = $maxScore > 0 ? ($totalEarned / $maxScore * 100) : 0; + + // Determine health level + $pct = $this->results['percentage']; + if ($pct >= 90) { + $this->results['level'] = 'excellent'; + } elseif ($pct >= 80) { + $this->results['level'] = 'good'; + } elseif ($pct >= 70) { + $this->results['level'] = 'fair'; + } elseif ($pct >= 60) { + $this->results['level'] = 'poor'; + } else { + $this->results['level'] = 'critical'; + } + } + + /** + * Get failed checks + * + * @return array Array of failed checks + */ + public function getFailedChecks(): array + { + return array_filter($this->results['checks'], fn($c) => !$c['passed']); + } + + /** + * Get passed checks + * + * @return array Array of passed checks + */ + public function getPassedChecks(): array + { + return array_filter($this->results['checks'], fn($c) => $c['passed']); + } + + /** + * Check if repository meets threshold + * + * @param float $threshold Minimum percentage required + * @return bool True if meets threshold + */ + public function meetsThreshold(float $threshold): bool + { + return $this->results['percentage'] >= $threshold; + } +} diff --git a/lib/Enterprise/RepositorySynchronizer.php b/lib/Enterprise/RepositorySynchronizer.php new file mode 100644 index 0000000..387b0c4 --- /dev/null +++ b/lib/Enterprise/RepositorySynchronizer.php @@ -0,0 +1,1193 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/RepositorySynchronizer.php + * VERSION: 04.06.00 + * BRIEF: Repository synchronization enterprise library + */ + +declare(strict_types=1); + +namespace MokoEnterprise; + +use Exception; +use RuntimeException; + +/** + * Repository Synchronizer + * + * Enterprise library for synchronizing files across multiple repositories + * based on configuration and override files. + */ +class RepositorySynchronizer +{ + private const SYNC_DEFINITION_DIR = 'api/definitions/sync'; + private const SYNC_OVERRIDE_FILE = '.github/override.tf'; + private const STANDARDS_VERSION = '04.06.00'; + private const STANDARDS_MAJOR = '04'; // Major only — version branch is version/XX + private const STANDARDS_MINOR = '04.06'; // Major.Minor for sync branch naming + private const VERSION_BRANCH = 'version/' . self::STANDARDS_MAJOR; + private const SYNC_BRANCH = 'chore/sync-mokostandards-v' . self::STANDARDS_MINOR; + + private ApiClient $apiClient; + private GitPlatformAdapter $adapter; + private AuditLogger $logger; + private MetricsCollector $metrics; + private CheckpointManager $checkpoints; + private DefinitionParser $definitionParser; + + /** + * Constructor + * + * @param ApiClient $apiClient Raw API client (kept for backward compatibility) + * @param AuditLogger $logger Audit logger + * @param MetricsCollector $metrics Metrics collector + * @param CheckpointManager|null $checkpoints Checkpoint manager + * @param DefinitionParser|null $definitionParser Definition parser + * @param GitPlatformAdapter|null $adapter Platform adapter (auto-created from ApiClient if null) + */ + public function __construct( + ApiClient $apiClient, + AuditLogger $logger, + MetricsCollector $metrics, + ?CheckpointManager $checkpoints = null, + ?DefinitionParser $definitionParser = null, + ?GitPlatformAdapter $adapter = null + ) { + $this->apiClient = $apiClient; + $this->adapter = $adapter ?? new GitHubAdapter($apiClient); + $this->logger = $logger; + $this->metrics = $metrics; + $this->checkpoints = $checkpoints ?? new CheckpointManager('.checkpoints'); + $this->definitionParser = $definitionParser ?? new DefinitionParser(); + } + + /** + * Get list of repositories for an organization + * + * @param string $org Organization name + * @param bool $skipArchived Whether to skip archived repositories + * @return array Array of repository information + */ + public function getRepositories(string $org, bool $skipArchived = false): array + { + $repos = $this->adapter->listOrgRepos($org, $skipArchived); + $this->metrics->setGauge('repositories_found', count($repos)); + return $repos; + } + + /** + * Check if repository has override file + * + * @param string $org Organization name + * @param string $repo Repository name + * @return bool True if override file exists + */ + public function hasOverrideFile(string $org, string $repo): bool + { + try { + $override = $this->adapter->getFileContents($org, $repo, self::SYNC_OVERRIDE_FILE); + return !empty($override); + } catch (Exception $e) { + return false; + } + } + + /** + * Process single repository + * + * @param string $org Organization name + * @param string $repo Repository name + * @param bool $dryRun Whether to perform a dry run + * @param bool $force Force update even if no changes + * @return int|false PR number on success, false if skipped/failed + * @throws SynchronizationNotImplementedException When synchronization logic is not implemented + */ + public function processRepository(string $org, string $repo, bool $dryRun = false, bool $force = false): int|false + { + $txn = $this->logger->startTransaction("process_repo_{$repo}"); + + try { + // Check for override file + if ($this->hasOverrideFile($org, $repo)) { + $this->logger->logInfo("Repository {$repo} has override file, parsing configuration"); + // Override file exists - in full implementation would parse it + // For now, skip repos with overrides + $this->metrics->increment('repos_with_overrides'); + $txn->end('success'); + return false; + } + + if ($dryRun) { + $this->logger->logInfo("DRY-RUN: Would update repository {$repo}"); + $txn->end('success'); + return 0; + } + + // Execute synchronization + $result = $this->synchronizeRepository($org, $repo, $force); + + if ($result !== false) { + $this->metrics->increment('repos_synced'); + $txn->end('success'); + } else { + $txn->end('failure'); + } + + return $result; + + } catch (Exception $e) { + $txn->end('failure'); + $this->logger->logError("Failed to process repository {$repo}: " . $e->getMessage()); + throw $e; + } + } + + /** + * Synchronize files to a repository + * + * @param string $org Organization name + * @param string $repo Repository name + * @param bool $force Force override protected files + * @return int|false PR number on success, false if skipped/failed + */ + private function synchronizeRepository(string $org, string $repo, bool $force): int|false + { + $this->logger->logInfo("Starting synchronization for {$org}/{$repo}"); + + // Resolve repo root (two levels up from this file: Enterprise/ → lib/ → api/ → root) + $repoRoot = dirname(dirname(dirname(__DIR__))); + + // Detect platform from repo metadata + $repoInfo = $this->adapter->getRepo($org, $repo); + $platform = $this->detectPlatform($repoInfo); + $this->logger->logInfo("Detected platform for {$repo}: {$platform}"); + + // Load file list from the Terraform definition for this platform + $filesToSync = $this->definitionParser->parseForPlatform($platform, $repoRoot); + + // Append shared workflows — the parser can't extract them from nested + // subdirectories blocks due to heredoc interference in .tf files. + $sharedFiles = $this->getSharedWorkflows($platform, $repoRoot); + + // Deduplicate by destination — shared workflows take precedence over parser entries + $seen = []; + foreach ($filesToSync as $f) { + $seen[$f['destination']] = true; + } + foreach ($sharedFiles as $f) { + if (!isset($seen[$f['destination']])) { + $filesToSync[] = $f; + } + } + + $this->logger->logInfo("Loaded " . count($filesToSync) . " sync entries from definition for {$platform}"); + + if (empty($filesToSync)) { + $this->logger->logWarning("No syncable entries found in definition for platform '{$platform}', skipping {$repo}"); + return false; + } + + // Check if there's already a PR open for this repo. + // With --force, proceed anyway — createSyncPR() will reset the branch + // and update the existing PR body rather than creating a duplicate. + $existingPR = $this->checkForExistingPR($org, $repo); + if ($existingPR && !$force) { + $this->logger->logInfo("PR #{$existingPR} already exists for {$repo}, skipping (use --force to re-sync)"); + return false; + } + if ($existingPR && $force) { + $this->logger->logInfo("PR #{$existingPR} already exists for {$repo} — force flag set, re-syncing"); + } + + // Create PR with file updates driven by the definition + $result = $this->createSyncPR($org, $repo, $platform, $filesToSync, $repoRoot, $force); + $prNumber = $result['number'] ?? null; + $summary = $result['summary'] ?? []; + + if ($prNumber) { + $this->logger->logInfo("Successfully created PR #{$prNumber} for {$repo}"); + + // Generate / update api/definitions/sync/{repo}.def.tf AFTER the sync so it + // reflects exactly what was pushed in this run. + $this->generateRepositoryDefinition($org, $repo, $platform, $repoInfo, $summary); + + return (int) $prNumber; + } + + return false; + } + + /** + * Check if there's already an open PR for sync + */ + private function checkForExistingPR(string $org, string $repo): ?int + { + try { + $prs = $this->adapter->listPullRequests($org, $repo, [ + 'state' => 'open', + 'head' => "{$org}:" . self::SYNC_BRANCH, + ]); + + if (!empty($prs) && is_array($prs)) { + return $prs[0]['number'] ?? null; + } + } catch (Exception $e) { + $this->logger->logWarning("Failed to check for existing PR: " . $e->getMessage()); + } + + return null; + } + + /** + * Generate / update the repository tracking definition after a successful sync. + * + * Writes api/definitions/sync/{repo}.def.tf with: + * - the base platform definition as a foundation + * - a sync_record block recording what was actually pushed (files created/updated/skipped) + * - full timestamps and platform metadata + * + * @param string $org + * @param string $repo + * @param string $platform Detected platform slug (e.g. 'crm-module') + * @param array $repoInfo Raw GitHub API repository object + * @param array $summary Sync result from createSyncPR: {copied[], skipped[], total} + * @return bool + */ + private function generateRepositoryDefinition( + string $org, + string $repo, + string $platform, + array $repoInfo, + array $summary + ): bool { + try { + $this->logger->logInfo("Writing sync tracking definition for {$org}/{$repo}"); + + $timestamp = date('c'); + $description = addslashes($repoInfo['description'] ?? ''); + $defaultBranch = $repoInfo['default_branch'] ?? 'main'; + + // Resolve repo root relative to this file's location + $repoRoot = dirname(dirname(dirname(__DIR__))); + $baseDefPath = "{$repoRoot}/api/definitions/default/{$platform}.tf"; + if (!file_exists($baseDefPath)) { + $baseDefPath = "{$repoRoot}/api/definitions/default/default-repository.tf"; + } + $baseDefinition = file_get_contents($baseDefPath) ?: ''; + + // Extract definition version from the source .tf metadata block + $definitionVersion = 'unknown'; + if (preg_match('/\bversion\s*=\s*"([^"]+)"/', $baseDefinition, $vm)) { + $definitionVersion = $vm[1]; + } + + // Cache the nullable sub-arrays once to avoid repeated null-coalescing + $copiedItems = $summary['copied'] ?? []; + $skippedItems = $summary['skipped'] ?? []; + $totalCount = (int) ($summary['total'] ?? 0); + + // Build the synced_files list + $syncedEntries = ''; + foreach ($copiedItems as $item) { + $action = addslashes($item['action'] ?? 'synced'); + $file = addslashes($item['file'] ?? ''); + $syncedEntries .= " { path = \"{$file}\" action = \"{$action}\" },\n"; + } + + $skippedEntries = ''; + foreach ($skippedItems as $item) { + $file = addslashes($item['file'] ?? ''); + $reason = addslashes($item['reason'] ?? ''); + $skippedEntries .= " { path = \"{$file}\" reason = \"{$reason}\" },\n"; + } + + $createdCount = count(array_filter($copiedItems, fn($i) => ($i['action'] ?? '') === 'created')); + $updatedCount = count(array_filter($copiedItems, fn($i) => ($i['action'] ?? '') === 'updated')); + $skippedCount = count($skippedItems); + + // Assemble the definition file using PHP 7.3+ flexible heredoc: + // the closing marker is indented, so PHP strips that many leading spaces automatically. + $definition = <<logger->logInfo("Wrote sync tracking definition: {$defFilePath}"); + $this->metrics->increment('definitions_generated'); + + return true; + + } catch (Exception $e) { + $this->logger->logError("Failed to write tracking definition for {$repo}: " . $e->getMessage()); + return false; + } + } + + /** + * Detect platform from repository info + */ + /** Repos that are the full Dolibarr platform, not individual modules. */ + private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods']; + + private function detectPlatform(array $repoInfo): string + { + $name = $repoInfo['name'] ?? ''; + $nameLower = strtolower($name); + $description = strtolower($repoInfo['description'] ?? ''); + $topics = $repoInfo['topics'] ?? []; + + // Explicit platform repos — full Dolibarr installation, not a module + if (in_array($name, self::CRM_PLATFORM_REPOS, true)) { + return 'crm-platform'; + } + if (in_array('dolibarr-platform', $topics)) { + return 'crm-platform'; + } + + // Check topics first + if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) { + return 'waas-component'; + } + if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) { + return 'crm-module'; + } + + // Check name patterns + if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) { + return 'waas-component'; + } + if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) { + return 'crm-module'; + } + + // Check description patterns + if (str_contains($description, 'joomla') || str_contains($description, 'component')) { + return 'waas-component'; + } + if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) { + return 'crm-module'; + } + + // Default + return 'default-repository'; + } + + /** + * Create a PR with sync updates driven by the flat entry list from DefinitionParser. + * + * @param string $org + * @param string $repo + * @param string $platform Detected platform slug (e.g. 'crm-module') + * @param array $filesToSync + * @param string $repoRoot Absolute path to the MokoStandards repository root + * @param bool $force When true, overwrite files even when always_overwrite = false + * @return array{number: ?int, summary: array} + */ + private function createSyncPR(string $org, string $repo, string $platform, array $filesToSync, string $repoRoot, bool $force): array + { + $nullResult = ['number' => null, 'summary' => []]; + + try { + $repoInfo = $this->adapter->getRepo($org, $repo); + $defaultBranch = $repoInfo['default_branch'] ?? 'main'; + // Push directly to default branch — no sync branch, no PR + $branchName = $defaultBranch; + + $this->logger->logInfo("Syncing files directly to {$org}/{$repo}:{$defaultBranch}"); + + $summary = ['copied' => [], 'skipped' => [], 'total' => 0]; + + // Pre-fetch Dolibarr module ID (one API call per repo, not per file) + $moduleId = ($platform === 'crm-module') ? $this->fetchModuleId($org, $repo) : null; + + foreach ($filesToSync as $entry) { + $summary['total']++; + $targetPath = $entry['destination']; + // README and CHANGELOG files are never overwritten regardless of flags or definition config. + // Case-insensitive: readme.md / README.md / ChangeLog.md / CHANGELOG.md etc. + $basename = strtolower(basename($targetPath)); + $isReadme = $basename === 'readme.md'; + $isChangelog = in_array($basename, ['changelog.md', 'changelog'], true); + $isProtected = $isReadme || $isChangelog; + $canOverwrite = !$isProtected && ($force || $entry['always_overwrite']) && !($entry['protected'] ?? false); + if ($isReadme) { + $this->logger->logInfo("Skipping README (protected by policy): {$targetPath}"); + $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'README — never overwritten']; + continue; + } + if ($isChangelog) { + $this->logger->logInfo("Skipping CHANGELOG (protected by policy): {$targetPath}"); + $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'CHANGELOG — never overwritten']; + continue; + } + + // Resolve content: prefer inline_content (stub_content heredoc), + // fall back to reading from the external template file (source path). + if (isset($entry['inline_content'])) { + $content = $entry['inline_content']; + } else { + $sourcePath = rtrim($repoRoot, '/') . '/' . ltrim($entry['source'] ?? '', '/'); + + if (!file_exists($sourcePath)) { + $this->logger->logWarning("Source not found: {$sourcePath}"); + $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'Source file not found']; + continue; + } + + $content = file_get_contents($sourcePath); + if ($content === false) { + $this->logger->logWarning("Cannot read: {$sourcePath}"); + $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'Failed to read source']; + continue; + } + } + + $content = $this->processTemplateContent($content, $repo, $org, $platform, $repoInfo, $moduleId ?? null); + + try { + $existingFile = $this->adapter->getFileContents($org, $repo, $targetPath, $branchName); + + if (!$canOverwrite) { + $existingDecoded = base64_decode($existingFile['content'] ?? ''); + $hasStaleTokens = (bool) preg_match('/\{\{[A-Z_a-z]+\}\}|\{[A-Z_]{4,}\}/', $existingDecoded); + if (!$hasStaleTokens) { + $this->logger->logInfo("Skipping existing file (always_overwrite=false): {$targetPath}"); + $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'Preserved (always_overwrite=false)']; + continue; + } + $this->logger->logInfo("Overwriting file with stale placeholders: {$targetPath}"); + } + + // .gitignore and .gitattributes: merge template lines into existing + // content instead of replacing — preserves custom entries added by the repo. + $isGitConfig = in_array(basename($targetPath), ['.gitignore', '.gitattributes', '.ftpignore'], true); + if ($isGitConfig) { + $existingDecoded = base64_decode($existingFile['content'] ?? ''); + $content = $this->mergeGitConfigFile($existingDecoded, $content); + } + + $this->adapter->createOrUpdateFile( + $org, $repo, $targetPath, $content, + "chore: update {$targetPath} from MokoStandards", + $existingFile['sha'] ?? null, + $branchName + ); + $this->logger->logInfo("Updated: {$targetPath}"); + $summary['copied'][] = ['file' => $targetPath, 'action' => 'updated']; + + } catch (Exception $e) { + // File does not exist yet — create it. + // Reset circuit breaker so 404s from the GET don't block the create. + $this->adapter->getApiClient()->resetCircuitBreaker(); + try { + $this->adapter->createOrUpdateFile( + $org, $repo, $targetPath, $content, + "chore: add {$targetPath} from MokoStandards", + null, + $branchName + ); + $this->logger->logInfo("Created: {$targetPath}"); + $summary['copied'][] = ['file' => $targetPath, 'action' => 'created']; + } catch (Exception $e2) { + // 422 "sha wasn't supplied" = file already exists on sync branch + // (created earlier in this run). Fetch sha and retry as update. + if (str_contains($e2->getMessage(), "sha") || str_contains($e2->getMessage(), '422')) { + try { + $this->adapter->getApiClient()->resetCircuitBreaker(); + $existing = $this->adapter->getFileContents($org, $repo, $targetPath, $branchName); + $this->adapter->createOrUpdateFile( + $org, $repo, $targetPath, $content, + "chore: update {$targetPath} from MokoStandards", + $existing['sha'] ?? null, + $branchName + ); + $this->logger->logInfo("Updated (retry): {$targetPath}"); + $summary['copied'][] = ['file' => $targetPath, 'action' => 'updated']; + } catch (Exception $e3) { + $this->logger->logError("Failed to update {$targetPath}: " . $e3->getMessage()); + $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'API error: ' . $e3->getMessage()]; + $this->adapter->getApiClient()->resetCircuitBreaker(); + } + } else { + $this->logger->logError("Failed to create {$targetPath}: " . $e2->getMessage()); + $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'API error: ' . $e2->getMessage()]; + } + } + } + } + + // Ensure composer.json requires mokoconsulting-tech/enterprise + $this->ensureComposerEnterprise($org, $repo, $branchName, $summary); + + // Migrate .mokostandards from root to .github/ + $this->migrateMokoStandards($org, $repo, $branchName, $summary); + + if (count($summary['copied']) === 0) { + $this->logger->logWarning("No files were created/updated for {$repo}"); + return $nullResult; + } + + // Create tracking issue (no PR — files pushed directly to default branch) + $issueBody = $this->generatePRBody($summary); + $issueTitle = 'chore: MokoStandards v' . self::STANDARDS_MINOR . ' sync — ' . count($summary['copied']) . ' files updated'; + $issueNumber = null; + + try { + $issueData = $this->adapter->createIssue($org, $repo, $issueTitle, $issueBody, [ + 'labels' => ['mokostandards', 'type: chore', 'automation'], + 'assignees' => ['jmiller-moko'], + ]); + $issueNumber = $issueData['number'] ?? null; + $this->logger->logInfo("Created tracking issue #{$issueNumber} — " . count($summary['copied']) . " files synced directly to {$defaultBranch}"); + } catch (\Exception $e) { + $this->logger->logWarning("Could not create tracking issue: " . $e->getMessage()); + } + + return ['number' => $issueNumber, 'summary' => $summary]; + + } catch (CircuitBreakerOpen | RateLimitExceeded $e) { + $this->logger->logError("Sync failed: " . $e->getMessage()); + throw $e; + } catch (Exception $e) { + $this->logger->logError("Sync failed: " . $e->getMessage()); + return $nullResult; + } + } + + /** + * Replace all {{TOKEN}} placeholders in a template file with repo-specific values. + * + * Tokens sourced from GitHub API data (always available): + * {{REPO_NAME}} — repository name + * {{REPO_URL}} — full GitHub URL + * {{REPO_DESCRIPTION}} — GitHub repo description + * {{PRIMARY_LANGUAGE}} — dominant language from GitHub + * {{PLATFORM_TYPE}} — human-readable platform label + * + * Dolibarr-specific tokens (crm-module platform only): + * {{MODULE_NAME}} — lowercase module name (e.g. mokocrm) + * {{MODULE_CLASS}} — PascalCase class name (e.g. MokoCRM) + * {{MODULE_ID}} — $this->numero from descriptor (null → left unreplaced) + * + * @param string $content Raw template content + * @param string $repo Repository name + * @param string $org Organisation name + * @param string $platform Detected platform slug + * @param array $repoInfo Raw GitHub API repository object + * Merge a git config file (gitignore / gitattributes / ftp_ignore) by + * ensuring all template lines are present without removing custom entries. + * + * Strategy: take the existing remote content, then append any template + * lines that are missing. Comments and blank lines from the template are + * included to preserve section structure. Duplicate non-blank lines are + * never added. + * + * @param string $existing Current file content from the remote repo + * @param string $template Template file content from MokoStandards + * @return string Merged content + */ + /** + * Return shared workflow entries that should be synced to all platforms. + * + * The .tf definition parser cannot extract workflows from nested + * subdirectories blocks because heredoc content in CLAUDE.md disrupts + * bracket matching. This method provides the workflow list directly. + * + * @return array + */ + /** + * Ensure the remote composer.json requires mokoconsulting-tech/enterprise. + * If the package is missing, add it and commit the change to the sync branch. + */ + /** + * Migrate .mokostandards from repo root to .github/.mokostandards. + * Deletes the root file after copying to .github/. + */ + private function migrateMokoStandards(string $org, string $repo, string $branchName, array &$summary): void + { + // Check if .mokostandards exists in root + try { + $rootFile = $this->adapter->getFileContents($org, $repo, '.mokostandards', $branchName); + } catch (Exception $e) { + $this->adapter->getApiClient()->resetCircuitBreaker(); + return; // Doesn't exist in root — nothing to migrate + } + + // Check if already exists in .github/ + $existsInGithub = false; + try { + $this->adapter->getFileContents($org, $repo, '.github/.mokostandards', $branchName); + $existsInGithub = true; + } catch (Exception $e) { + $this->adapter->getApiClient()->resetCircuitBreaker(); + } + + $content = base64_decode($rootFile['content'] ?? ''); + $rootSha = $rootFile['sha'] ?? ''; + + if (!$existsInGithub) { + // Copy to .github/.mokostandards + try { + $this->adapter->createOrUpdateFile( + $org, $repo, '.github/.mokostandards', $content, + 'chore: migrate .mokostandards to .github/', + null, $branchName + ); + $this->logger->logInfo("Migrated .mokostandards → .github/.mokostandards"); + $summary['copied'][] = ['file' => '.github/.mokostandards', 'action' => 'migrated from root']; + } catch (Exception $e) { + $this->adapter->getApiClient()->resetCircuitBreaker(); + return; + } + } + + // Delete from root + if (!empty($rootSha)) { + try { + $this->adapter->deleteFile( + $org, $repo, '.mokostandards', $rootSha, + 'chore: remove .mokostandards from root (moved to .github/)', + $branchName + ); + $this->logger->logInfo("Deleted root .mokostandards"); + } catch (Exception $e) { + $this->adapter->getApiClient()->resetCircuitBreaker(); + } + } + } + + private function ensureComposerEnterprise(string $org, string $repo, string $branchName, array &$summary): void + { + try { + $file = $this->adapter->getFileContents($org, $repo, 'composer.json', $branchName); + } catch (Exception $e) { + return; // No composer.json — skip + } + + $content = base64_decode($file['content'] ?? ''); + $json = json_decode($content, true); + if (!is_array($json)) { + return; + } + + $expectedConstraint = 'dev-' . self::VERSION_BRANCH; + + // Check if enterprise package is already required with correct constraint + $currentConstraint = $json['require']['mokoconsulting-tech/enterprise'] + ?? $json['require-dev']['mokoconsulting-tech/enterprise'] + ?? null; + + if ($currentConstraint === $expectedConstraint) { + return; // Already correct + } + + // Add or update the enterprise package to point to version branch + $json['require'] = $json['require'] ?? []; + $json['require']['mokoconsulting-tech/enterprise'] = $expectedConstraint; + + // Sort require keys for consistency + ksort($json['require']); + + $newContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; + + try { + $this->adapter->createOrUpdateFile( + $org, $repo, 'composer.json', $newContent, + 'chore: add mokoconsulting-tech/enterprise dependency', + $file['sha'] ?? null, + $branchName + ); + $this->logger->logInfo("Added mokoconsulting-tech/enterprise to composer.json"); + $summary['copied'][] = ['file' => 'composer.json', 'action' => 'enterprise dependency added']; + } catch (Exception $e) { + $this->logger->logWarning("Could not update composer.json: " . $e->getMessage()); + } + } + + private function getSharedWorkflows(string $platform, string $repoRoot): array + { + $root = rtrim($repoRoot, '/'); + $wfDir = $this->adapter->getWorkflowDir(); + + $shared = [ + ['templates/workflows/shared/enterprise-firewall-setup.yml.template', "{$wfDir}/enterprise-firewall-setup.yml"], + ['templates/workflows/shared/sync-version-on-merge.yml.template', "{$wfDir}/sync-version-on-merge.yml"], + ['templates/workflows/shared/repository-cleanup.yml.template', "{$wfDir}/repository-cleanup.yml"], + ['templates/workflows/shared/auto-dev-issue.yml.template', "{$wfDir}/auto-dev-issue.yml"], + ['templates/workflows/shared/branch-freeze.yml.template', "{$wfDir}/branch-freeze.yml"], + ['templates/workflows/shared/auto-assign.yml.template', "{$wfDir}/auto-assign.yml"], + ['templates/workflows/shared/changelog-validation.yml.template', "{$wfDir}/changelog-validation.yml"], + ['.github/workflows/standards-compliance.yml', "{$wfDir}/standards-compliance.yml"], + ]; + + // CodeQL is GitHub-only; on Gitea, Trivy replaces it + if ($this->adapter->getPlatformName() === 'github') { + $shared[] = ['.github/workflows/codeql-analysis.yml', "{$wfDir}/codeql-analysis.yml"]; + } + + // Platform-specific workflows + if ($platform === 'crm-module') { + $shared[] = ['templates/workflows/shared/deploy-dev.yml.template', "{$wfDir}/deploy-dev.yml"]; + $shared[] = ['templates/workflows/shared/deploy-demo.yml.template', "{$wfDir}/deploy-demo.yml"]; + $shared[] = ['templates/workflows/dolibarr/auto-release.yml.template', "{$wfDir}/auto-release.yml"]; + $shared[] = ['templates/workflows/dolibarr/ci-dolibarr.yml.template', "{$wfDir}/ci-dolibarr.yml"]; + $shared[] = ['templates/workflows/dolibarr/publish-to-mokodolimods.yml.template', "{$wfDir}/publish-to-mokodolimods.yml"]; + $shared[] = ['templates/workflows/dolibarr/repo_health.yml.template', "{$wfDir}/repo_health.yml"]; + } elseif ($platform === 'crm-platform') { + $shared[] = ['templates/workflows/shared/deploy-dev.yml.template', "{$wfDir}/deploy-dev.yml"]; + $shared[] = ['templates/workflows/shared/deploy-demo.yml.template', "{$wfDir}/deploy-demo.yml"]; + $shared[] = ['templates/workflows/dolibarr/auto-release.yml.template', "{$wfDir}/auto-release.yml"]; + $shared[] = ['templates/workflows/dolibarr/ci-dolibarr.yml.template', "{$wfDir}/ci-dolibarr.yml"]; + } elseif ($platform === 'waas-component') { + $shared[] = ['templates/workflows/joomla/auto-release.yml.template', "{$wfDir}/auto-release.yml"]; + $shared[] = ['templates/workflows/joomla/update-server.yml.template', "{$wfDir}/update-server.yml"]; + $shared[] = ['templates/workflows/joomla/ci-joomla.yml.template', "{$wfDir}/ci-joomla.yml"]; + $shared[] = ['templates/workflows/joomla/repo_health.yml.template', "{$wfDir}/repo_health.yml"]; + $shared[] = ['templates/workflows/joomla/deploy-manual.yml.template', "{$wfDir}/deploy-manual.yml"]; + } else { + $shared[] = ['templates/workflows/shared/deploy-dev.yml.template', "{$wfDir}/deploy-dev.yml"]; + $shared[] = ['templates/workflows/shared/deploy-demo.yml.template', "{$wfDir}/deploy-demo.yml"]; + $shared[] = ['templates/workflows/shared/auto-release.yml.template', "{$wfDir}/auto-release.yml"]; + } + + // CODEOWNERS — GitHub only; Gitea doesn't enforce it + if ($this->adapter->getPlatformName() === 'github') { + $shared[] = ['templates/github/CODEOWNERS', '.github/CODEOWNERS']; + } + + // Platform-specific gitignore (merged, not replaced) + $gitignoreMap = [ + 'crm-module' => 'templates/configs/gitignore.dolibarr', + 'crm-platform' => 'templates/configs/gitignore.dolibarr', + 'waas-component' => 'templates/configs/.gitignore.joomla', + ]; + $gitignoreTemplate = $gitignoreMap[$platform] ?? 'templates/configs/gitignore'; + $shared[] = [$gitignoreTemplate, '.gitignore']; + + // Create TODO.md stub if it doesn't exist (gitignored after first commit) + $entries[] = [ + 'inline_content' => "# TODO\n\n> **Note:** This file is not tracked in version control (.gitignore). It is for local task tracking only.\n\n## Critical\n -\n\n## Normal\n -\n\n## Low\n -\n", + 'destination' => 'TODO.md', + 'always_overwrite' => false, + ]; + + // Always create a custom/ subdirectory under the workflow dir with a README + // so repos have a safe place for custom workflows that sync won't touch. + $entries = [ + [ + 'inline_content' => "# Custom Workflows\n\nPlace repo-specific workflows here.\n\n" + . "- **Never overwritten** by MokoStandards bulk sync\n" + . "- **Never deleted** by the repository-cleanup workflow\n" + . "- Safe for custom CI, notifications, or repo-specific automation\n\n" + . "Synced workflows live in the parent `{$wfDir}/` directory.\n", + 'destination' => "{$wfDir}/custom/README.md", + 'always_overwrite' => false, + ], + ]; + + foreach ($shared as [$source, $dest]) { + $fullSource = "{$root}/{$source}"; + if (file_exists($fullSource)) { + $entries[] = [ + 'source' => $source, // relative — RepositorySynchronizer prepends repoRoot + 'destination' => $dest, + 'always_overwrite' => true, + ]; + } + } + + // Create update.txt stub for Dolibarr repos (plain text version file) + if ($platform === 'crm-module') { + $entries[] = [ + 'inline_content' => '0.0.0', + 'destination' => 'update.txt', + 'always_overwrite' => false, + ]; + } + + return $entries; + } + + private function mergeGitConfigFile(string $existing, string $template): string + { + $existingLines = array_map('rtrim', explode("\n", $existing)); + $templateLines = array_map('rtrim', explode("\n", $template)); + + // Build a set of normalised non-blank, non-comment lines from the remote + $existingSet = []; + foreach ($existingLines as $line) { + $trimmed = trim($line); + if ($trimmed !== '' && !str_starts_with($trimmed, '#')) { + $existingSet[$trimmed] = true; + } + } + + // Walk the template and collect lines that are missing from the remote + $missing = []; + $prevWasMissing = false; + foreach ($templateLines as $line) { + $trimmed = trim($line); + + // Blank or comment lines: include them if they precede a missing entry + // (to preserve section headers). Buffer them and flush when a missing + // non-blank line is found. + if ($trimmed === '' || str_starts_with($trimmed, '#')) { + if ($prevWasMissing) { + $missing[] = $line; + } + continue; + } + + if (!isset($existingSet[$trimmed])) { + // If the previous line was not missing, add a separator + any + // section header comments that precede this line in the template. + if (!$prevWasMissing && !empty($missing)) { + $missing[] = ''; + } + $missing[] = $line; + $prevWasMissing = true; + } else { + $prevWasMissing = false; + } + } + + if (empty($missing)) { + return $existing; // nothing to add + } + + // Append missing lines with a clear separator + $merged = rtrim($existing) . "\n\n" + . "# ── MokoStandards sync (auto-appended) ────────────────────────────────\n" + . implode("\n", $missing) . "\n"; + + return $merged; + } + + /** + * @param string|null $moduleId Pre-fetched Dolibarr module numero, or null + * @return string Processed content + */ + private function processTemplateContent( + string $content, + string $repo, + string $org = '', + string $platform = '', + array $repoInfo = [], + ?string $moduleId = null + ): string { + // Strip .template suffix from workflow file references + $content = str_replace('.yml.template', '.yml', $content); + + // Map platform slug to human-readable label + $platformType = match ($platform) { + 'crm-module' => 'Dolibarr module', + 'waas-component' => 'Joomla extension', + 'default-repository' => 'PHP library', + default => ucfirst(str_replace('-', ' ', $platform)), + }; + + // Derive Dolibarr module identifiers from the repository name + $moduleName = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $repo)); + $moduleClass = $repo; // Repo name is the PascalCase class (e.g. MokoCRM) + + // Build replacement map — uppercase tokens take precedence; legacy lowercase kept for compat + $map = [ + // Uppercase tokens (used in CLAUDE.md / copilot-instructions templates) + '{{REPO_NAME}}' => $repoInfo['name'] ?? $repo, + '{{REPO_URL}}' => "https://github.com/{$org}/{$repo}", + '{{REPO_DESCRIPTION}}' => $repoInfo['description'] ?? '', + '{{PRIMARY_LANGUAGE}}' => $repoInfo['language'] ?? '', + '{{PLATFORM_TYPE}}' => $platformType, + '{{MODULE_NAME}}' => $moduleName, + '{{MODULE_CLASS}}' => $moduleClass, + '{{WORKFLOW_DIR}}' => $this->adapter->getWorkflowDir(), + // Legacy lowercase tokens + '{{repo_name}}' => $repoInfo['name'] ?? $repo, + '{{repo_name_lower}}' => strtolower($repo), + '{{org}}' => $org, + '{{platform}}' => $platform, + '{{standards_version}}' => self::STANDARDS_VERSION, + '{{standards_minor}}' => self::STANDARDS_MINOR, + '{{standards_branch}}' => self::VERSION_BRANCH, + // Single-brace tokens — used by GitHub repository templates and older MokoStandards stubs + '{REPO_NAME}' => $repoInfo['name'] ?? $repo, + '{REPO_URL}' => "https://github.com/{$org}/{$repo}", + '{REPO_DESCRIPTION}' => $repoInfo['description'] ?? '', + '{PRIMARY_LANGUAGE}' => $repoInfo['language'] ?? '', + '{PLATFORM_TYPE}' => $platformType, + '{MODULE_NAME}' => $moduleName, + '{MODULE_CLASS}' => $moduleClass, + '{MODULE_ID}' => '', // overridden below when moduleId is available + '{repo_name}' => $repoInfo['name'] ?? $repo, + '{repo_name_lower}' => strtolower($repo), + '{org}' => $org, + ]; + + // Only replace {{MODULE_ID}} / {MODULE_ID} if we actually have the value; otherwise leave + // the placeholder intact so the CLAUDE.md self-repair block can fill it in later. + if ($moduleId !== null) { + $map['{{MODULE_ID}}'] = $moduleId; + $map['{MODULE_ID}'] = $moduleId; + } else { + // Remove the empty single-brace placeholder so it doesn't corrupt values + unset($map['{MODULE_ID}']); + } + + return strtr($content, $map); + } + + /** + * Fetch the Dolibarr module numero ($this->numero) from the module descriptor. + * + * Searches the repository tree for src/core/modules/mod*.class.php and extracts + * the unique module number. Returns null if not found or on any API error. + * + * @param string $org GitHub organisation + * @param string $repo Repository name + * @return string|null Module ID string, or null if unavailable + */ + private function fetchModuleId(string $org, string $repo): ?string + { + try { + $treeEntries = $this->adapter->getTree($org, $repo, 'HEAD', true); + $paths = array_column($treeEntries, 'path'); + + $descriptors = array_values(array_filter( + $paths, + static fn(string $p): bool => (bool) preg_match('#src/core/modules/mod\w+\.class\.php$#', $p) + )); + + if (empty($descriptors)) { + return null; + } + + $fileData = $this->adapter->getFileContents($org, $repo, $descriptors[0]); + $content = base64_decode(str_replace(["\n", "\r"], '', $fileData['content'] ?? '')); + + if (preg_match('/\$this->numero\s*=\s*(\d+)/', $content, $m)) { + return $m[1]; + } + } catch (\Exception $e) { + $this->logger->logInfo("Could not fetch module ID for {$repo}: " . $e->getMessage()); + } + + return null; + } + + /** + * Generate PR body text + */ + private function generatePRBody(array $summary): string + { + $body = "## MokoStandards Synchronization\n\n"; + $body .= "This PR synchronizes workflows, configurations, and scripts from the MokoStandards repository.\n\n"; + + // Summary statistics + $body .= "### Summary\n"; + $body .= "- 🆕 **Created**: " . count(array_filter($summary['copied'], fn($i) => $i['action'] === 'created')) . " files\n"; + $body .= "- 🔄 **Updated**: " . count(array_filter($summary['copied'], fn($i) => $i['action'] === 'updated')) . " files\n"; + $body .= "- ⚠️ **Skipped**: " . count($summary['skipped']) . " files\n"; + $body .= "- 📊 **Total**: " . $summary['total'] . " files processed\n\n"; + + // List copied files + if (!empty($summary['copied'])) { + $body .= "### Files Copied\n\n"; + foreach ($summary['copied'] as $item) { + $action = $item['action'] === 'created' ? '🆕' : '🔄'; + $body .= "- {$action} `{$item['file']}`\n"; + } + $body .= "\n"; + } + + // List skipped files + if (!empty($summary['skipped'])) { + $body .= "### Files Skipped\n\n"; + foreach ($summary['skipped'] as $item) { + $body .= "- ⚠️ `{$item['file']}` - {$item['reason']}\n"; + } + $body .= "\n"; + } + + $body .= "### Review Notes\n"; + $body .= "- Please review all changes carefully\n"; + $body .= "- Ensure no custom configurations are overwritten\n"; + $body .= "- Test workflows and scripts after merging\n"; + $body .= "- Verify issue templates render correctly\n\n"; + + $body .= "---\n"; + $body .= "*This PR was automatically generated by the MokoStandards bulk sync process.*\n"; + + return $body; + } + + /** + * Synchronize multiple repositories + * + * @param string $org Organization name + * @param array $options Sync options (repo, skipArchived, dryRun, force) + * @return array Sync results with statistics + */ + public function synchronize(string $org, array $options = []): array + { + $specificRepo = $options['repo'] ?? null; + $skipArchived = $options['skipArchived'] ?? false; + $dryRun = $options['dryRun'] ?? false; + $force = $options['force'] ?? false; + + $txn = $this->logger->startTransaction('bulk_synchronize'); + + try { + // Get list of repositories + $repos = $this->getRepositories($org, $skipArchived); + + if ($specificRepo) { + $repos = array_filter($repos, fn($repo) => $repo['name'] === $specificRepo); + } + + $total = count($repos); + $results = [ + 'total' => $total, + 'success' => 0, + 'skipped' => 0, + 'failed' => 0, + 'repositories' => [], + ]; + + foreach ($repos as $index => $repo) { + $repoName = $repo['name']; + $progress = $index + 1; + + try { + $updated = $this->processRepository($org, $repoName, $dryRun, $force); + + if ($updated) { + $results['success']++; + $this->metrics->increment('repos_updated_total', ['status' => 'success']); + $results['repositories'][$repoName] = 'updated'; + } else { + $results['skipped']++; + $this->metrics->increment('repos_updated_total', ['status' => 'skipped']); + $results['repositories'][$repoName] = 'skipped'; + } + } catch (Exception $e) { + $results['failed']++; + $this->metrics->increment('repos_updated_total', ['status' => 'failed']); + $results['repositories'][$repoName] = 'failed: ' . $e->getMessage(); + } + + // Save checkpoint + $this->checkpoints->saveCheckpoint('bulk_sync', [ + 'processed' => $progress, + 'total' => $total, + 'results' => $results, + ]); + } + + $txn->end('success'); + + return $results; + + } catch (Exception $e) { + $txn->end('failure'); + throw $e; + } + } + + /** + * Apply labels to a PR or issue, creating any that don't yet exist on the repo. + * + * @param string $org GitHub organisation + * @param string $repo Repository name + * @param int $number PR or issue number + * @param list $labels Label names to apply + */ + public function applyLabels(string $org, string $repo, int $number, array $labels): void + { + // Ensure labels exist on the repo before applying + $existingLabels = $this->adapter->listLabels($org, $repo); + $existingNames = array_column($existingLabels, 'name'); + + foreach ($labels as $label) { + if (!in_array($label, $existingNames, true)) { + try { + $this->adapter->createLabel($org, $repo, $label, + match ($label) { + 'mokostandards' => 'B60205', + 'type: chore' => 'FEF2C0', + 'automation' => '8B4513', + default => 'EDEDED', + }, + match ($label) { + 'mokostandards' => 'MokoStandards compliance', + 'type: chore' => 'Maintenance tasks', + 'automation' => 'Automated processes or scripts', + default => '', + } + ); + } catch (\Exception $createEx) { /* already exists race — ignore */ } + } + } + + try { + $this->adapter->addIssueLabels($org, $repo, $number, $labels); + } catch (\Exception $e) { + $this->logger->logInfo("Could not apply labels to #{$number}: " . $e->getMessage()); + } + } +} diff --git a/lib/Enterprise/RetryHelper.php b/lib/Enterprise/RetryHelper.php new file mode 100644 index 0000000..b360a48 --- /dev/null +++ b/lib/Enterprise/RetryHelper.php @@ -0,0 +1,140 @@ + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @package MokoStandards\Enterprise + * @version 04.00.04 + * @author MokoStandards Team + * @license GPL-3.0-or-later + */ + +namespace MokoEnterprise; + +use Exception; +use Throwable; + +/** + * Retry execution helper with exponential backoff. + * + * Features: + * - Configurable retry attempts + * - Exponential backoff strategy + * - Exception filtering + * - Retry and failure callbacks + * + * Example: + * ```php + * $retry = new RetryHelper(maxRetries: 3, backoffBase: 2.0); + * $result = $retry->execute(function() { + * // Your code that might fail + * return $api->call(); + * }); + * ``` + */ +class RetryHelper +{ + private int $maxRetries; + private float $backoffBase; + /** @var array> */ + private array $retryableExceptions; + /** @var callable|null */ + private $onRetry; + /** @var callable|null */ + private $onFailure; + + /** + * Initialize retry helper. + * + * @param int $maxRetries Maximum number of retry attempts + * @param float $backoffBase Base for exponential backoff (seconds) + * @param array> $retryableExceptions Exceptions to catch and retry + * @param callable|null $onRetry Callback function called on each retry + * @param callable|null $onFailure Callback function called on final failure + */ + public function __construct( + int $maxRetries = 3, + float $backoffBase = 2.0, + array $retryableExceptions = [Exception::class], + ?callable $onRetry = null, + ?callable $onFailure = null + ) { + $this->maxRetries = $maxRetries; + $this->backoffBase = $backoffBase; + $this->retryableExceptions = $retryableExceptions; + $this->onRetry = $onRetry; + $this->onFailure = $onFailure; + } + + /** + * Execute callable with retry logic. + * + * @param callable $callable Function to execute + * @return mixed Result of callable + * @throws Throwable If all retries exhausted + */ + public function execute(callable $callable): mixed + { + $lastException = null; + + for ($attempt = 0; $attempt < $this->maxRetries; $attempt++) { + try { + $result = $callable(); + + if ($attempt > 0) { + error_log("Function succeeded on attempt " . ($attempt + 1)); + } + + return $result; + } catch (Throwable $e) { + // Check if this exception is retryable + $shouldRetry = false; + foreach ($this->retryableExceptions as $exceptionClass) { + if ($e instanceof $exceptionClass) { + $shouldRetry = true; + break; + } + } + + if (!$shouldRetry) { + throw $e; + } + + $lastException = $e; + + if ($attempt < $this->maxRetries - 1) { + // Calculate backoff time + $backoffTime = $this->backoffBase ** $attempt; + error_log( + "Function failed on attempt " . ($attempt + 1) . "/{$this->maxRetries}: {$e->getMessage()}. " . + "Retrying in {$backoffTime}s..." + ); + + // Call retry callback if provided + if ($this->onRetry !== null) { + ($this->onRetry)($attempt, $e, $backoffTime); + } + + // Sleep for backoff time (convert to microseconds) + usleep((int) ($backoffTime * 1000000)); + } else { + error_log("Function failed after {$this->maxRetries} attempts: {$e->getMessage()}"); + + // Call failure callback if provided + if ($this->onFailure !== null) { + ($this->onFailure)($this->maxRetries, $e); + } + } + } + } + + // All retries exhausted + throw $lastException ?? new RecoveryError('All retries exhausted'); + } +} diff --git a/lib/Enterprise/SecurityValidator.php b/lib/Enterprise/SecurityValidator.php new file mode 100644 index 0000000..8f3a36c --- /dev/null +++ b/lib/Enterprise/SecurityValidator.php @@ -0,0 +1,402 @@ +scanFile('config.php'); + * + * if ($validator->hasCriticalFindings()) { + * $validator->printReport(); + * exit(1); + * } + * + * // Scan entire directory + * $validator->scanDirectory('src/', ['.php', '.js']); + * ``` + * + * Copyright (C) 2026 Moko Consulting + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @package MokoStandards\Enterprise + * @version 04.00.04 + * @author MokoStandards Team + * @license GPL-3.0-or-later + */ + +namespace MokoEnterprise; + +use Exception; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; + +/** + * Exception raised when security violations are detected + */ +class SecurityViolation extends Exception +{ +} + +/** + * Security validator for detecting vulnerabilities + */ +class SecurityValidator +{ + private const VERSION = '04.06.00'; + + /** + * Common patterns for credentials and secrets + */ + private const CREDENTIAL_PATTERNS = [ + ['/password\s*=\s*["\']([^"\']+)["\']/i', 'hardcoded password'], + ['/api[_-]?key\s*=\s*["\']([^"\']+)["\']/i', 'hardcoded API key'], + ['/secret[_-]?key\s*=\s*["\']([^"\']+)["\']/i', 'hardcoded secret key'], + ['/token\s*=\s*["\']([^"\']+)["\']/i', 'hardcoded token'], + ['/aws[_-]?access[_-]?key[_-]?id\s*=\s*["\']([^"\']+)["\']/i', 'AWS access key'], + ['/private[_-]?key\s*=\s*["\']([^"\']+)["\']/i', 'private key'], + ['/["\'][A-Za-z0-9\/+]{40,}["\']/i', 'potential secret (base64)'], + ]; + + /** + * Dangerous function calls + */ + private const DANGEROUS_FUNCTIONS = [ + 'eval', + 'exec', + 'system', + 'passthru', + 'shell_exec', + 'assert', + 'create_function', + 'unserialize', + 'extract', + '$$', + ]; + + /** + * File permissions that are too permissive + */ + private const DANGEROUS_PERMISSIONS = [ + 0777, // rwxrwxrwx + 0666, // rw-rw-rw- + ]; + + private array $findings = []; + + /** + * Scan a file for security issues + * + * @param string $filePath Path to file to scan + * @param bool $checkCredentials Check for hardcoded credentials + * @param bool $checkDangerousFunctions Check for dangerous function usage + * @return array> List of security findings + */ + public function scanFile( + string $filePath, + bool $checkCredentials = true, + bool $checkDangerousFunctions = true + ): array { + $findings = []; + + if (!file_exists($filePath)) { + return $findings; + } + + try { + $content = file_get_contents($filePath); + + if ($checkCredentials) { + $credFindings = $this->checkCredentialsInText($content, $filePath); + $findings = array_merge($findings, $credFindings); + } + + if ($checkDangerousFunctions) { + $funcFindings = $this->checkDangerousFunctions($content, $filePath); + $findings = array_merge($findings, $funcFindings); + } + } catch (Exception $e) { + $findings[] = [ + 'severity' => 'warning', + 'type' => 'scan_error', + 'file' => $filePath, + 'message' => 'Failed to scan file: ' . $e->getMessage() + ]; + } + + $this->findings = array_merge($this->findings, $findings); + return $findings; + } + + /** + * Check for hardcoded credentials in text + * + * @param string $text Text to scan + * @param string $source Source file/location + * @return array> List of findings + */ + private function checkCredentialsInText(string $text, string $source): array + { + $findings = []; + + foreach (self::CREDENTIAL_PATTERNS as [$pattern, $description]) { + if (preg_match_all($pattern, $text, $matches, PREG_OFFSET_CAPTURE)) { + foreach ($matches[0] as $match) { + $matchedValue = isset($matches[1]) && !empty($matches[1]) ? $matches[1][0][0] : $match[0]; + + if ($this->isPlaceholder($matchedValue)) { + continue; + } + + $line = substr_count(substr($text, 0, $match[1]), "\n") + 1; + $snippet = substr($match[0], 0, 50); + + $findings[] = [ + 'severity' => 'high', + 'type' => 'credential', + 'file' => $source, + 'description' => $description, + 'line' => $line, + 'snippet' => $snippet + ]; + } + } + } + + return $findings; + } + + /** + * Check for dangerous function usage + * + * @param string $text Text to scan + * @param string $source Source file/location + * @return array> List of findings + */ + private function checkDangerousFunctions(string $text, string $source): array + { + $findings = []; + + foreach (self::DANGEROUS_FUNCTIONS as $funcName) { + $pattern = '/\b' . preg_quote($funcName, '/') . '\s*\(/'; + if (preg_match_all($pattern, $text, $matches, PREG_OFFSET_CAPTURE)) { + foreach ($matches[0] as $match) { + $line = substr_count(substr($text, 0, $match[1]), "\n") + 1; + + $findings[] = [ + 'severity' => 'medium', + 'type' => 'dangerous_function', + 'file' => $source, + 'function' => $funcName, + 'line' => $line, + 'message' => "Potentially dangerous function: {$funcName}" + ]; + } + } + } + + return $findings; + } + + /** + * Check if a value looks like a placeholder + * + * @param string $value Value to check + * @return bool True if looks like placeholder + */ + private function isPlaceholder(string $value): bool + { + $placeholders = [ + 'your_', 'example', 'placeholder', 'xxx', 'test', + 'dummy', 'sample', 'replace', 'changeme', 'todo' + ]; + + $valueLower = strtolower($value); + foreach ($placeholders as $placeholder) { + if (strpos($valueLower, $placeholder) !== false) { + return true; + } + } + + return false; + } + + /** + * Check file permissions for security issues + * + * @param string $filePath Path to file + * @return array|null Finding if permissions are too permissive, null otherwise + */ + public function checkFilePermissions(string $filePath): ?array + { + if (!file_exists($filePath)) { + return null; + } + + $perms = fileperms($filePath) & 0777; + + if (in_array($perms, self::DANGEROUS_PERMISSIONS, true)) { + $finding = [ + 'severity' => 'medium', + 'type' => 'file_permissions', + 'file' => $filePath, + 'permissions' => decoct($perms), + 'message' => sprintf('File has overly permissive permissions: %o', $perms) + ]; + $this->findings[] = $finding; + return $finding; + } + + return null; + } + + /** + * Validate that sensitive data comes from environment variables + * + * @param string $varName Environment variable name + * @return bool True if variable exists + */ + public function validateEnvironmentVar(string $varName): bool + { + return getenv($varName) !== false; + } + + /** + * Get all security findings + * + * @param string|null $severity Filter by severity (high, medium, low, warning) + * @return array> List of findings + */ + public function getFindings(?string $severity = null): array + { + if ($severity !== null) { + return array_filter($this->findings, function ($finding) use ($severity) { + return ($finding['severity'] ?? '') === $severity; + }); + } + return $this->findings; + } + + /** + * Check if there are any critical/high severity findings + * + * @return bool True if critical findings exist + */ + public function hasCriticalFindings(): bool + { + foreach ($this->findings as $finding) { + if (in_array($finding['severity'] ?? '', ['critical', 'high'], true)) { + return true; + } + } + return false; + } + + /** + * Print a security report + */ + public function printReport(): void + { + echo "\n" . str_repeat('=', 60) . "\n"; + echo "Security Validation Report\n"; + echo str_repeat('=', 60) . "\n"; + + if (empty($this->findings)) { + echo "\n✓ No security issues found!\n"; + echo str_repeat('=', 60) . "\n\n"; + return; + } + + // Group by severity + $bySeverity = []; + foreach ($this->findings as $finding) { + $sev = $finding['severity'] ?? 'unknown'; + if (!isset($bySeverity[$sev])) { + $bySeverity[$sev] = []; + } + $bySeverity[$sev][] = $finding; + } + + // Print findings by severity + foreach (['critical', 'high', 'medium', 'low', 'warning'] as $sev) { + if (isset($bySeverity[$sev])) { + echo sprintf("\n%s Severity (%d findings):\n", strtoupper($sev), count($bySeverity[$sev])); + foreach ($bySeverity[$sev] as $finding) { + $message = $finding['message'] ?? $finding['description'] ?? 'No description'; + echo " - {$finding['type']}: {$message}\n"; + if (isset($finding['file'])) { + echo " File: {$finding['file']}\n"; + } + if (isset($finding['line'])) { + echo " Line: {$finding['line']}\n"; + } + } + } + } + + $total = count($this->findings); + $critical = count($bySeverity['critical'] ?? []) + count($bySeverity['high'] ?? []); + + echo "\nTotal findings: {$total}\n"; + echo "Critical/High: {$critical}\n"; + echo str_repeat('=', 60) . "\n\n"; + } + + /** + * Clear all findings + */ + public function clearFindings(): void + { + $this->findings = []; + } + + /** + * Scan a directory for security issues + * + * @param string $directory Directory to scan + * @param array|null $extensions File extensions to scan + */ + public function scanDirectory(string $directory, ?array $extensions = null): void + { + if ($extensions === null) { + $extensions = ['.php', '.sh', '.yaml', '.yml', '.json', '.conf', '.cfg']; + } + + if (!is_dir($directory)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory) + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + $filePath = $file->getPathname(); + foreach ($extensions as $ext) { + if (substr($filePath, -strlen($ext)) === $ext) { + $this->scanFile($filePath); + break; + } + } + } + } + } + + public function getVersion(): string + { + return self::VERSION; + } +} diff --git a/lib/Enterprise/SynchronizationException.php b/lib/Enterprise/SynchronizationException.php new file mode 100644 index 0000000..5f55a08 --- /dev/null +++ b/lib/Enterprise/SynchronizationException.php @@ -0,0 +1,42 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Enterprise + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/Enterprise/SynchronizationException.php + * VERSION: 04.06.00 + * BRIEF: Custom exception for repository synchronization errors + */ + +declare(strict_types=1); + +namespace MokoEnterprise; + +use RuntimeException; + +/** + * Exception thrown when repository synchronization fails or is not implemented + */ +class SynchronizationNotImplementedException extends RuntimeException +{ + /** + * Create exception for unimplemented synchronization logic + * + * @return self + */ + public static function create(): self + { + return new self( + "Repository synchronization logic is not implemented. " . + "The processRepository() method contains only placeholder code. " . + "Actual file synchronization, PR creation, and merge handling must be implemented." + ); + } +} diff --git a/lib/Enterprise/TransactionManager.php b/lib/Enterprise/TransactionManager.php new file mode 100644 index 0000000..b992a2b --- /dev/null +++ b/lib/Enterprise/TransactionManager.php @@ -0,0 +1,331 @@ +execute('create_user', function() { + * // Create user logic + * }, function() { + * // Rollback: delete user + * }); + * + * $txn->execute('send_email', function() { + * // Send welcome email + * }); + * + * $txn->commit(); + * } catch (TransactionError $e) { + * // Automatic rollback on failure + * echo "Transaction failed: " . $e->getMessage(); + * } + * ``` + * + * Copyright (C) 2026 Moko Consulting + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @package MokoStandards\Enterprise + * @version 04.00.04 + * @author MokoStandards Team + * @license GPL-3.0-or-later + */ + +namespace MokoEnterprise; + +use DateTime; +use DateTimeZone; +use Exception; + +/** + * Exception raised when transaction operations fail + */ +class TransactionError extends Exception +{ +} + +/** + * Represents a single step in a transaction + */ +class TransactionStep +{ + public string $name; + public $executeFunc; + public $rollbackFunc; + public bool $executed = false; + public $result = null; + public ?string $error = null; + + public function __construct(string $name, callable $executeFunc, ?callable $rollbackFunc = null) + { + $this->name = $name; + $this->executeFunc = $executeFunc; + $this->rollbackFunc = $rollbackFunc; + } +} + +/** + * Transaction manager for atomic multi-step operations + */ +class Transaction +{ + private const VERSION = '04.06.00'; + + private string $name; + /** @var array */ + private array $steps = []; + private bool $committed = false; + private bool $rolledBack = false; + private ?DateTime $startTime = null; + private ?DateTime $endTime = null; + + public function __construct(?string $name = null) + { + $this->name = $name ?? 'txn_' . date('Ymd_His'); + $this->startTime = new DateTime('now', new DateTimeZone('UTC')); + error_log("Starting transaction: {$this->name}"); + } + + /** + * Execute a transaction step + * + * @param string $name Step name + * @param callable $func Function to execute + * @param callable|null $rollbackFunc Function to rollback this step + * @param mixed ...$args Arguments for func + * @return mixed Result of func + * @throws TransactionError If step execution fails + */ + public function execute(string $name, callable $func, ?callable $rollbackFunc = null, ...$args) + { + $step = new TransactionStep($name, $func, $rollbackFunc); + + try { + error_log("Executing step: {$name}"); + $result = $func(...$args); + $step->executed = true; + $step->result = $result; + $this->steps[] = $step; + error_log("Step completed: {$name}"); + return $result; + } catch (Exception $e) { + $step->error = $e->getMessage(); + error_log("Step failed: {$name} - {$e->getMessage()}"); + throw new TransactionError("Transaction step '{$name}' failed: {$e->getMessage()}", 0, $e); + } + } + + /** + * Commit the transaction + * + * @throws TransactionError If already committed or rolled back + */ + public function commit(): void + { + if ($this->committed) { + error_log("Transaction already committed"); + return; + } + + if ($this->rolledBack) { + throw new TransactionError("Cannot commit a rolled-back transaction"); + } + + $this->committed = true; + $this->endTime = new DateTime('now', new DateTimeZone('UTC')); + + $duration = $this->endTime->getTimestamp() - $this->startTime->getTimestamp(); + error_log("Transaction committed: {$this->name} (" . count($this->steps) . " steps, {$duration}s)"); + } + + /** + * Rollback all executed steps in reverse order + */ + public function rollback(): void + { + if ($this->rolledBack) { + error_log("Transaction already rolled back"); + return; + } + + error_log("Rolling back transaction: {$this->name}"); + + // Rollback in reverse order + foreach (array_reverse($this->steps) as $step) { + if ($step->executed && $step->rollbackFunc !== null) { + try { + error_log("Rolling back step: {$step->name}"); + ($step->rollbackFunc)(); + } catch (Exception $e) { + error_log("Rollback failed for step {$step->name}: {$e->getMessage()}"); + } + } + } + + $this->rolledBack = true; + error_log("Transaction rolled back: {$this->name}"); + } + + /** + * Get transaction status + * + * @return array Dictionary with transaction status + */ + public function getStatus(): array + { + return [ + 'name' => $this->name, + 'steps_count' => count($this->steps), + 'committed' => $this->committed, + 'rolled_back' => $this->rolledBack, + 'start_time' => $this->startTime?->format('c'), + 'end_time' => $this->endTime?->format('c'), + 'steps' => array_map(function ($step) { + return [ + 'name' => $step->name, + 'executed' => $step->executed, + 'error' => $step->error + ]; + }, $this->steps) + ]; + } + + /** + * Get transaction name + */ + public function getName(): string + { + return $this->name; + } + + /** + * Check if transaction is committed + */ + public function isCommitted(): bool + { + return $this->committed; + } + + /** + * Check if transaction is rolled back + */ + public function isRolledBack(): bool + { + return $this->rolledBack; + } + + /** + * Destructor - auto rollback if not committed + */ + public function __destruct() + { + if (!$this->committed && !$this->rolledBack && count($this->steps) > 0) { + error_log("Transaction {$this->name} was not committed, auto-rolling back"); + $this->rollback(); + } + } +} + +/** + * High-level transaction management + */ +class TransactionManager +{ + private const VERSION = '04.06.00'; + + /** @var array */ + private array $transactions = []; + private ?Transaction $activeTransaction = null; + + /** + * Begin a new transaction + * + * @param string|null $name Transaction name + * @return Transaction New transaction instance + * @throws TransactionError If another transaction is already active + */ + public function begin(?string $name = null): Transaction + { + if ($this->activeTransaction !== null) { + throw new TransactionError("Another transaction is already active"); + } + + $txn = new Transaction($name); + $this->activeTransaction = $txn; + $this->transactions[] = $txn; + return $txn; + } + + /** + * End active transaction (commit or rollback should be done before this) + */ + public function end(): void + { + $this->activeTransaction = null; + } + + /** + * Get transaction history + * + * @return array> List of transaction status dictionaries + */ + public function getHistory(): array + { + return array_map(function ($txn) { + return $txn->getStatus(); + }, $this->transactions); + } + + /** + * Get transaction statistics + * + * @return array Dictionary with statistics + */ + public function getStats(): array + { + $committed = 0; + $rolledBack = 0; + + foreach ($this->transactions as $txn) { + if ($txn->isCommitted()) { + $committed++; + } + if ($txn->isRolledBack()) { + $rolledBack++; + } + } + + return [ + 'total' => count($this->transactions), + 'committed' => $committed, + 'rolled_back' => $rolledBack, + 'active' => $this->activeTransaction !== null ? 1 : 0 + ]; + } + + /** + * Get active transaction + */ + public function getActiveTransaction(): ?Transaction + { + return $this->activeTransaction; + } + + public function getVersion(): string + { + return self::VERSION; + } +} diff --git a/lib/Enterprise/UnifiedValidation.php b/lib/Enterprise/UnifiedValidation.php new file mode 100644 index 0000000..ad0f6fd --- /dev/null +++ b/lib/Enterprise/UnifiedValidation.php @@ -0,0 +1,532 @@ +addPlugin(new PathValidatorPlugin()); + * $validator->addPlugin(new MarkdownValidatorPlugin()); + * + * $context = [ + * 'paths' => ['/tmp', '/usr'], + * 'markdown_files' => ['README.md'] + * ]; + * + * $results = $validator->validateAll($context); + * $validator->printSummary(); + * ``` + * + * Copyright (C) 2026 Moko Consulting + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @package MokoStandards\Enterprise + * @version 04.00.04 + * @author MokoStandards Team + * @license GPL-3.0-or-later + */ + +namespace MokoEnterprise; + +use Exception; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; + +/** + * Result of a validation check + */ +class ValidationResult +{ + public string $pluginName; + public bool $passed; + public string $message; + public array $details; + + public function __construct(string $pluginName, bool $passed, string $message = '', array $details = []) + { + $this->pluginName = $pluginName; + $this->passed = $passed; + $this->message = $message; + $this->details = $details; + } + + public function __toString(): string + { + $status = $this->passed ? '✓ PASS' : '✗ FAIL'; + return "{$status} [{$this->pluginName}] {$this->message}"; + } +} + +/** + * Abstract base class for validation plugins + */ +abstract class ValidationPlugin +{ + protected string $name; + protected bool $enabled = true; + + public function __construct(string $name) + { + $this->name = $name; + } + + /** + * Perform validation + * + * @param array $context Validation context with data to validate + * @return ValidationResult Result indicating pass/fail with details + */ + abstract public function validate(array $context): ValidationResult; + + public function enable(): void + { + $this->enabled = true; + } + + public function disable(): void + { + $this->enabled = false; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + public function getName(): string + { + return $this->name; + } +} + +/** + * Validates file and directory paths + */ +class PathValidatorPlugin extends ValidationPlugin +{ + public function __construct() + { + parent::__construct('path_validator'); + } + + public function validate(array $context): ValidationResult + { + $paths = $context['paths'] ?? []; + + if (empty($paths)) { + return new ValidationResult($this->name, true, 'No paths to validate'); + } + + $invalidPaths = []; + foreach ($paths as $path) { + if (!file_exists($path)) { + $invalidPaths[] = $path; + } + } + + if (!empty($invalidPaths)) { + return new ValidationResult( + $this->name, + false, + sprintf('Found %d invalid paths', count($invalidPaths)), + ['invalid_paths' => $invalidPaths] + ); + } + + return new ValidationResult($this->name, true, sprintf('All %d paths valid', count($paths))); + } +} + +/** + * Validates Markdown files + */ +class MarkdownValidatorPlugin extends ValidationPlugin +{ + public function __construct() + { + parent::__construct('markdown_validator'); + } + + public function validate(array $context): ValidationResult + { + $files = $context['markdown_files'] ?? []; + + if (empty($files)) { + return new ValidationResult($this->name, true, 'No Markdown files to validate'); + } + + $issues = []; + foreach ($files as $filePath) { + if (!file_exists($filePath)) { + continue; + } + + $content = file_get_contents($filePath); + + // Check for broken links + if (strpos($content, '](404') !== false || strpos($content, '](broken') !== false) { + $issues[] = "{$filePath}: Potential broken links"; + } + } + + if (!empty($issues)) { + return new ValidationResult( + $this->name, + false, + sprintf('Found %d issues', count($issues)), + ['issues' => $issues] + ); + } + + return new ValidationResult($this->name, true, sprintf('Validated %d Markdown files', count($files))); + } +} + +/** + * Validates license headers + */ +class LicenseValidatorPlugin extends ValidationPlugin +{ + public function __construct() + { + parent::__construct('license_validator'); + } + + public function validate(array $context): ValidationResult + { + $files = $context['source_files'] ?? []; + + if (empty($files)) { + return new ValidationResult($this->name, true, 'No source files to validate'); + } + + $missingLicense = []; + $expectedCopyright = $context['copyright_year'] ?? '2026'; + + foreach ($files as $filePath) { + if (!file_exists($filePath)) { + continue; + } + + try { + $content = file_get_contents($filePath); + if (strpos($content, 'Copyright') === false || strpos($content, $expectedCopyright) === false) { + $missingLicense[] = $filePath; + } + } catch (Exception $e) { + // Skip files that can't be read + } + } + + if (!empty($missingLicense)) { + return new ValidationResult( + $this->name, + false, + sprintf('%d files missing proper license headers', count($missingLicense)), + ['files' => $missingLicense] + ); + } + + return new ValidationResult($this->name, true, sprintf('All %d files have license headers', count($files))); + } +} + +/** + * Validates GitHub Actions workflows + */ +class WorkflowValidatorPlugin extends ValidationPlugin +{ + public function __construct() + { + parent::__construct('workflow_validator'); + } + + public function validate(array $context): ValidationResult + { + $workflowDir = $context['workflow_dir'] + ?? (is_dir('.gitea/workflows') ? '.gitea/workflows' : '.github/workflows'); + + if (!is_dir($workflowDir)) { + return new ValidationResult($this->name, true, 'No workflows directory'); + } + + // Collect workflows from primary dir; also check the other platform dir + $workflows = array_merge( + glob($workflowDir . '/*.yml') ?: [], + glob($workflowDir . '/*.yaml') ?: [] + ); + $altDir = ($workflowDir === '.gitea/workflows') ? '.github/workflows' : '.gitea/workflows'; + if (is_dir($altDir)) { + $workflows = array_merge($workflows, + glob($altDir . '/*.yml') ?: [], + glob($altDir . '/*.yaml') ?: [] + ); + } + + if (empty($workflows)) { + return new ValidationResult($this->name, true, 'No workflow files found'); + } + + $issues = []; + foreach ($workflows as $workflow) { + $content = file_get_contents($workflow); + + // Basic checks + if (strpos($content, 'on:') === false && strpos($content, 'on :') === false) { + $issues[] = basename($workflow) . ": Missing 'on:' trigger"; + } + } + + if (!empty($issues)) { + return new ValidationResult( + $this->name, + false, + sprintf('Found %d workflow issues', count($issues)), + ['issues' => $issues] + ); + } + + return new ValidationResult($this->name, true, sprintf('Validated %d workflows', count($workflows))); + } +} + +/** + * Validates security concerns + */ +class SecurityValidatorPlugin extends ValidationPlugin +{ + public function __construct() + { + parent::__construct('security_validator'); + } + + public function validate(array $context): ValidationResult + { + $scanDir = $context['scan_dir'] ?? 'scripts'; + + if (!is_dir($scanDir)) { + return new ValidationResult($this->name, true, 'No directory to scan'); + } + + try { + $validator = new SecurityValidator(); + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($scanDir) + ); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $validator->scanFile($file->getPathname()); + } + } + + $findings = $validator->getFindings(); + $critical = array_filter($findings, function ($f) { + return in_array($f['severity'] ?? '', ['critical', 'high'], true); + }); + + if (!empty($critical)) { + return new ValidationResult( + $this->name, + false, + sprintf('Found %d critical security issues', count($critical)), + ['critical_count' => count($critical), 'total_count' => count($findings)] + ); + } + + return new ValidationResult( + $this->name, + true, + sprintf('Security scan complete: %d total findings, 0 critical', count($findings)) + ); + } catch (Exception $e) { + return new ValidationResult($this->name, true, 'Security validator error (skipped): ' . $e->getMessage()); + } + } +} + +/** + * Unified validation framework + */ +class UnifiedValidator +{ + private const VERSION = '04.06.00'; + + /** @var array */ + private array $plugins = []; + + /** @var array */ + private array $results = []; + + /** + * Add a validation plugin + * + * @param ValidationPlugin $plugin Plugin instance + */ + public function addPlugin(ValidationPlugin $plugin): void + { + $this->plugins[$plugin->getName()] = $plugin; + error_log("Added plugin: {$plugin->getName()}"); + } + + /** + * Remove a validation plugin + * + * @param string $pluginName Name of plugin to remove + */ + public function removePlugin(string $pluginName): void + { + if (isset($this->plugins[$pluginName])) { + unset($this->plugins[$pluginName]); + error_log("Removed plugin: {$pluginName}"); + } + } + + /** + * Get a plugin by name + * + * @param string $pluginName Name of plugin + * @return ValidationPlugin|null Plugin instance or null + */ + public function getPlugin(string $pluginName): ?ValidationPlugin + { + return $this->plugins[$pluginName] ?? null; + } + + /** + * Run all enabled validation plugins + * + * @param array $context Validation context data + * @return array List of validation results + */ + public function validateAll(array $context = []): array + { + $this->results = []; + + error_log("Running " . count($this->plugins) . " validation plugins..."); + + foreach ($this->plugins as $pluginName => $plugin) { + if (!$plugin->isEnabled()) { + error_log("Skipping disabled plugin: {$pluginName}"); + continue; + } + + try { + error_log("Running plugin: {$pluginName}"); + $result = $plugin->validate($context); + $this->results[] = $result; + } catch (Exception $e) { + error_log("Plugin {$pluginName} failed: {$e->getMessage()}"); + $this->results[] = new ValidationResult( + $pluginName, + false, + "Plugin error: {$e->getMessage()}" + ); + } + } + + return $this->results; + } + + /** + * Get validation results + * + * @param bool $passedOnly Return only passed results + * @param bool $failedOnly Return only failed results + * @return array List of validation results + */ + public function getResults(bool $passedOnly = false, bool $failedOnly = false): array + { + if ($passedOnly) { + return array_filter($this->results, fn($r) => $r->passed); + } + if ($failedOnly) { + return array_filter($this->results, fn($r) => !$r->passed); + } + return $this->results; + } + + /** + * Check if all validations passed + * + * @return bool True if all validations passed + */ + public function allPassed(): bool + { + foreach ($this->results as $result) { + if (!$result->passed) { + return false; + } + } + return true; + } + + /** + * Print validation summary + */ + public function printSummary(): void + { + echo "\n" . str_repeat('=', 60) . "\n"; + echo "Unified Validation Summary\n"; + echo str_repeat('=', 60) . "\n"; + + $passed = array_filter($this->results, fn($r) => $r->passed); + $failed = array_filter($this->results, fn($r) => !$r->passed); + + echo "\nTotal: " . count($this->results) . " validations\n"; + echo "Passed: " . count($passed) . "\n"; + echo "Failed: " . count($failed) . "\n"; + + if (!empty($passed)) { + echo "\n✓ Passed (" . count($passed) . "):\n"; + foreach ($passed as $result) { + echo " {$result}\n"; + } + } + + if (!empty($failed)) { + echo "\n✗ Failed (" . count($failed) . "):\n"; + foreach ($failed as $result) { + echo " {$result}\n"; + if (!empty($result->details)) { + foreach ($result->details as $key => $value) { + if (is_array($value) && count($value) <= 3) { + echo " {$key}: " . implode(', ', $value) . "\n"; + } elseif (is_array($value)) { + echo " {$key}: " . count($value) . " items\n"; + } else { + echo " {$key}: {$value}\n"; + } + } + } + } + } + + $status = $this->allPassed() ? '✓ ALL VALIDATIONS PASSED' : '✗ SOME VALIDATIONS FAILED'; + echo "\n{$status}\n"; + echo str_repeat('=', 60) . "\n\n"; + } + + public function getVersion(): string + { + return self::VERSION; + } +} diff --git a/lib/index.md b/lib/index.md new file mode 100644 index 0000000..0ac8b6e --- /dev/null +++ b/lib/index.md @@ -0,0 +1,20 @@ +# Docs Index: /api/lib + +## Purpose + +This index provides navigation to documentation within this folder. + +## Documents + +- [README](./README.md) + +## Metadata + +- **Document Type:** index +- **Auto-generated:** This file is automatically generated by rebuild_indexes.py + +## Revision History + +| Date | Author | Change | Notes | +| ---------- | ------------------ | ----------------- | ------------------------------------------ | +| Auto | rebuild_indexes.py | Automated update | Generated by documentation index automation | diff --git a/lib/plugins/Joomla/UpdateXmlGenerator.php b/lib/plugins/Joomla/UpdateXmlGenerator.php new file mode 100644 index 0000000..4a7e343 --- /dev/null +++ b/lib/plugins/Joomla/UpdateXmlGenerator.php @@ -0,0 +1,392 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Joomla + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/lib/plugins/Joomla/UpdateXmlGenerator.php + * VERSION: 04.06.00 + * BRIEF: Generates and updates Joomla extension updates.xml files + */ + +declare(strict_types=1); + +namespace MokoStandards\Plugins\Joomla; + +use DOMDocument; +use DOMElement; +use Exception; + +/** + * Joomla Update XML Generator + * + * Generates and updates updates.xml files for Joomla extensions + * following the Joomla update server specification + */ +class UpdateXmlGenerator +{ + private string $extensionName; + private string $extensionType; + private string $element; + private string $clientId; + + /** + * Constructor + * + * @param string $extensionName Human-readable extension name + * @param string $extensionType Extension type (component, module, plugin, etc.) + * @param string $element Extension element (e.g., com_example, mod_custom) + * @param string $clientId Client ID (0 for site, 1 for admin) + */ + public function __construct( + string $extensionName, + string $extensionType = 'component', + string $element = '', + string $clientId = '0' + ) { + $this->extensionName = $extensionName; + $this->extensionType = $extensionType; + $this->element = $element ?: $this->deriveElement($extensionName, $extensionType); + $this->clientId = $clientId; + } + + /** + * Generate updates.xml from release information + * + * @param array $release Release information + * @return string XML content + */ + public function generate(array $release): string + { + $dom = new DOMDocument('1.0', 'UTF-8'); + $dom->formatOutput = true; + $dom->preserveWhiteSpace = false; + + // Create root element + $updates = $dom->createElement('updates'); + $dom->appendChild($updates); + + // Add update entry + $this->addUpdateEntry($dom, $updates, $release); + + return $dom->saveXML(); + } + + /** + * Update existing updates.xml file with new release + * + * @param string $xmlPath Path to existing updates.xml + * @param array $release New release information + * @return string Updated XML content + * @throws Exception If XML cannot be parsed + */ + public function update(string $xmlPath, array $release): string + { + if (!file_exists($xmlPath)) { + return $this->generate($release); + } + + $dom = new DOMDocument('1.0', 'UTF-8'); + $dom->formatOutput = true; + $dom->preserveWhiteSpace = false; + + if (!@$dom->load($xmlPath)) { + throw new Exception("Failed to parse existing updates.xml at {$xmlPath}"); + } + + $updates = $dom->getElementsByTagName('updates')->item(0); + if (!$updates) { + throw new Exception("Invalid updates.xml: missing root element"); + } + + // Check if this version already exists + $version = $release['version']; + $existingUpdates = $updates->getElementsByTagName('update'); + + foreach ($existingUpdates as $existingUpdate) { + $versionNode = $existingUpdate->getElementsByTagName('version')->item(0); + if ($versionNode && $versionNode->textContent === $version) { + // Remove existing entry for this version + $updates->removeChild($existingUpdate); + break; + } + } + + // Add new update entry at the beginning + $this->addUpdateEntry($dom, $updates, $release, true); + + return $dom->saveXML(); + } + + /** + * Add an update entry to the XML document + * + * @param DOMDocument $dom DOM document + * @param DOMElement $updates Updates element + * @param array $release Release information + * @param bool $prepend Whether to prepend (insert at beginning) + */ + private function addUpdateEntry( + DOMDocument $dom, + DOMElement $updates, + array $release, + bool $prepend = false + ): void { + $update = $dom->createElement('update'); + + // Required fields + $this->addElement($dom, $update, 'name', $this->extensionName); + $this->addElement($dom, $update, 'description', $release['description'] ?? ''); + $this->addElement($dom, $update, 'element', $this->element); + $this->addElement($dom, $update, 'type', $this->extensionType); + $this->addElement($dom, $update, 'version', $release['version']); + + // Joomla target platform + $infourl = $this->addElement($dom, $update, 'infourl', $release['infourl'] ?? ''); + if (!empty($release['infourl'])) { + $infourl->setAttribute('title', 'Release Information'); + } + + // Downloads section + $downloads = $dom->createElement('downloads'); + $update->appendChild($downloads); + + $downloadUrl = $this->addElement($dom, $downloads, 'downloadurl', $release['download_url']); + $downloadUrl->setAttribute('type', 'full'); + $downloadUrl->setAttribute('format', 'zip'); + + // Target platform + if (!empty($release['target_platform'])) { + $targetPlatform = $dom->createElement('targetplatform'); + $targetPlatform->setAttribute('name', 'joomla'); + $targetPlatform->setAttribute('version', $release['target_platform']); + $update->appendChild($targetPlatform); + } + + // Optional: PHP minimum version + if (!empty($release['php_minimum'])) { + $this->addElement($dom, $update, 'php_minimum', $release['php_minimum']); + } + + // Optional: Tags + if (!empty($release['tags'])) { + $tags = $dom->createElement('tags'); + $update->appendChild($tags); + foreach ($release['tags'] as $tag) { + $this->addElement($dom, $tags, 'tag', $tag); + } + } + + // Optional: Maintainer information + if (!empty($release['maintainer'])) { + $this->addElement($dom, $update, 'maintainer', $release['maintainer']); + } + + if (!empty($release['maintainer_url'])) { + $this->addElement($dom, $update, 'maintainerurl', $release['maintainer_url']); + } + + // Optional: Client (site or administrator) + if ($this->clientId !== '0') { + $this->addElement($dom, $update, 'client', $this->clientId); + } + + // Optional: Checksums + if (!empty($release['sha256'])) { + $this->addElement($dom, $update, 'sha256', $release['sha256']); + } + + if (!empty($release['sha384'])) { + $this->addElement($dom, $update, 'sha384', $release['sha384']); + } + + if (!empty($release['sha512'])) { + $this->addElement($dom, $update, 'sha512', $release['sha512']); + } + + // Add to updates element + if ($prepend && $updates->firstChild) { + $updates->insertBefore($update, $updates->firstChild); + } else { + $updates->appendChild($update); + } + } + + /** + * Add a text element to parent + * + * @param DOMDocument $dom DOM document + * @param DOMElement $parent Parent element + * @param string $name Element name + * @param string $value Element value + * @return DOMElement Created element + */ + private function addElement( + DOMDocument $dom, + DOMElement $parent, + string $name, + string $value + ): DOMElement { + $element = $dom->createElement($name); + $element->textContent = $value; + $parent->appendChild($element); + return $element; + } + + /** + * Derive element name from extension name and type + * + * @param string $name Extension name + * @param string $type Extension type + * @return string Element name + */ + private function deriveElement(string $name, string $type): string + { + $prefix = match($type) { + 'component' => 'com_', + 'module' => 'mod_', + 'plugin' => 'plg_', + 'library' => 'lib_', + 'template' => 'tpl_', + 'package' => 'pkg_', + default => '', + }; + + // Convert name to lowercase and replace spaces with underscores + $element = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $name)); + + // Add prefix if not already present + if (!str_starts_with($element, $prefix)) { + $element = $prefix . $element; + } + + return $element; + } + + /** + * Validate updates.xml structure + * + * @param string $xmlContent XML content to validate + * @return array Validation result ['valid' => bool, 'errors' => array] + */ + public static function validate(string $xmlContent): array + { + $errors = []; + + $dom = new DOMDocument(); + libxml_use_internal_errors(true); + + if (!$dom->loadXML($xmlContent)) { + foreach (libxml_get_errors() as $error) { + $errors[] = "XML Error: {$error->message}"; + } + libxml_clear_errors(); + return ['valid' => false, 'errors' => $errors]; + } + + // Validate structure + $updates = $dom->getElementsByTagName('updates')->item(0); + if (!$updates) { + $errors[] = "Missing root element"; + return ['valid' => false, 'errors' => $errors]; + } + + $updateElements = $updates->getElementsByTagName('update'); + if ($updateElements->length === 0) { + $errors[] = "No elements found"; + return ['valid' => false, 'errors' => $errors]; + } + + // Validate each update entry + foreach ($updateElements as $update) { + $required = ['name', 'element', 'type', 'version']; + foreach ($required as $field) { + if ($update->getElementsByTagName($field)->length === 0) { + $errors[] = "Missing required field: <{$field}>"; + } + } + + // Check for download URL + $downloads = $update->getElementsByTagName('downloads'); + if ($downloads->length > 0) { + $downloadUrl = $downloads->item(0)->getElementsByTagName('downloadurl'); + if ($downloadUrl->length === 0) { + $errors[] = "Missing in "; + } + } + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors + ]; + } + + /** + * Extract release information from manifest XML + * + * @param string $manifestPath Path to extension manifest XML + * @return array Release information + * @throws Exception If manifest cannot be parsed + */ + public static function extractFromManifest(string $manifestPath): array + { + if (!file_exists($manifestPath)) { + throw new Exception("Manifest file not found: {$manifestPath}"); + } + + $dom = new DOMDocument(); + if (!@$dom->load($manifestPath)) { + throw new Exception("Failed to parse manifest XML: {$manifestPath}"); + } + + $root = $dom->documentElement; + + return [ + 'name' => self::getElementText($dom, 'name') ?: 'Unknown Extension', + 'version' => self::getElementText($dom, 'version') ?: '1.0.0', + 'description' => self::getElementText($dom, 'description') ?: '', + 'author' => self::getElementText($dom, 'author') ?: '', + 'author_url' => self::getElementText($dom, 'authorUrl') ?: '', + 'type' => $root->getAttribute('type') ?: 'component', + 'target_platform' => self::getElementText($dom, 'version', 'targetplatform') ?: '4.0', + ]; + } + + /** + * Get text content of an element + * + * @param DOMDocument $dom DOM document + * @param string $tagName Tag name + * @param string $parentTag Optional parent tag name + * @return string|null Element text content + */ + private static function getElementText( + DOMDocument $dom, + string $tagName, + string $parentTag = '' + ): ?string { + if ($parentTag) { + $parents = $dom->getElementsByTagName($parentTag); + if ($parents->length > 0) { + $elements = $parents->item(0)->getElementsByTagName($tagName); + if ($elements->length > 0) { + return trim($elements->item(0)->textContent); + } + } + } else { + $elements = $dom->getElementsByTagName($tagName); + if ($elements->length > 0) { + return trim($elements->item(0)->textContent); + } + } + + return null; + } +} diff --git a/maintenance/index.md b/maintenance/index.md new file mode 100644 index 0000000..68f1026 --- /dev/null +++ b/maintenance/index.md @@ -0,0 +1,20 @@ +# Docs Index: /api/maintenance + +## Purpose + +This index provides navigation to documentation within this folder. + +## Documents + +- [README](./README.md) + +## Metadata + +- **Document Type:** index +- **Auto-generated:** This file is automatically generated by rebuild_indexes.py + +## Revision History + +| Date | Author | Change | Notes | +| ---------- | ------------------ | ----------------- | ------------------------------------------ | +| Auto | rebuild_indexes.py | Automated update | Generated by documentation index automation | diff --git a/maintenance/pin_action_shas.php b/maintenance/pin_action_shas.php new file mode 100644 index 0000000..57af372 --- /dev/null +++ b/maintenance/pin_action_shas.php @@ -0,0 +1,318 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Maintenance + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/maintenance/pin_action_shas.php + * VERSION: 04.06.00 + * BRIEF: Pin GitHub Actions to immutable commit SHAs in workflow files + * NOTE: Resolves tag/branch refs to commit SHAs via the GitHub API to satisfy + * the CodeQL "Unpinned tag for a non-immutable Action" security rule. + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\Config; +use MokoEnterprise\GitPlatformAdapter; +use MokoEnterprise\PlatformAdapterFactory; + +/** + * GitHub Actions SHA Pinner + * + * Scans all workflow YAML files under the platform workflow directory and + * replaces any tag-based or branch-based action reference with the corresponding + * pinned commit SHA. Already-pinned references (40-char hex SHA) are left untouched. + * + * Usage: + * php api/maintenance/pin_action_shas.php [--dry-run] [--verbose] [--help] + * + * Environment: + * GH_TOKEN Personal access token for GitHub API calls. + * GITEA_TOKEN Personal access token for Gitea API calls. + * GIT_PLATFORM 'github' (default) or 'gitea' + */ +class ActionShaPinner +{ + private const TIMEOUT_SECS = 15; + + private bool $dryRun = false; + private bool $verbose = false; + private ?GitPlatformAdapter $adapter = null; + private string $workflowsDir = '.github/workflows'; + + /** @var array resolved-ref → SHA cache */ + private array $shaCache = []; + + /** @var list */ + private array $changes = []; + + // ------------------------------------------------------------------------- + // Bootstrap + // ------------------------------------------------------------------------- + + public function __construct(array $args) + { + $this->parseArguments($args); + + $config = Config::load(); + try { + $this->adapter = PlatformAdapterFactory::create($config); + $this->workflowsDir = $this->adapter->getWorkflowDir(); + } catch (\RuntimeException $e) { + fwrite(STDERR, "Warning: " . $e->getMessage() . " — falling back to unauthenticated mode\n"); + } + } + + private function parseArguments(array $args): void + { + foreach ($args as $arg) { + if ($arg === '--dry-run') { + $this->dryRun = true; + } elseif ($arg === '--verbose' || $arg === '-v') { + $this->verbose = true; + } elseif ($arg === '--help' || $arg === '-h') { + $this->showHelp(); + exit(0); + } + } + } + + private function showHelp(): void + { + echo <<<'HELP' +Usage: php api/maintenance/pin_action_shas.php [OPTIONS] + +Pins GitHub Actions to immutable commit SHAs in all .github/workflows/*.yml +files. Already-pinned references (40-character commit SHA) are skipped. + +Options: + --dry-run Show changes that would be made without modifying any file + --verbose Show detailed per-action resolution output + --help Show this help message and exit + +Environment: + GH_TOKEN GitHub API token (org secret, recommended to avoid rate limiting) + Falls back to GITHUB_TOKEN if GH_TOKEN is not set. + +Examples: + # Preview all changes + GH_TOKEN=ghp_xxx php api/maintenance/pin_action_shas.php --dry-run --verbose + + # Apply changes + GH_TOKEN=ghp_xxx php api/maintenance/pin_action_shas.php + +HELP; + } + + // ------------------------------------------------------------------------- + // Main entry point + // ------------------------------------------------------------------------- + + public function run(): int + { + $this->log("🔒 GitHub Actions SHA Pinner", true); + $this->log(str_repeat('=', 50), true); + + if ($this->dryRun) { + $this->log("Mode: DRY RUN (no files will be modified)\n", true); + } + + // Also check .github/workflows if on Gitea (workflows may exist in both dirs) + $dirs = [$this->workflowsDir]; + if ($this->workflowsDir !== '.github/workflows' && is_dir('.github/workflows')) { + $dirs[] = '.github/workflows'; + } + $files = []; + foreach ($dirs as $dir) { + $files = array_merge($files, glob($dir . '/*.yml') ?: []); + } + + if (empty($files)) { + $this->log('No workflow files found in ' . self::WORKFLOWS_DIR, true); + return 0; + } + + $this->log('Found ' . count($files) . " workflow file(s)\n", true); + + foreach ($files as $file) { + $this->processFile($file); + } + + $this->printSummary(count($files)); + + return 0; + } + + // ------------------------------------------------------------------------- + // File processing + // ------------------------------------------------------------------------- + + private function processFile(string $file): void + { + $content = file_get_contents($file); + + if ($content === false) { + fwrite(STDERR, "❌ Cannot read: {$file}\n"); + return; + } + + $lines = explode("\n", $content); + $modified = false; + + foreach ($lines as $idx => &$line) { + $updated = $this->processLine($line, $file, $idx + 1); + + if ($updated !== null) { + $this->changes[] = [ + 'file' => $file, + 'line' => $idx + 1, + 'old' => trim($line), + 'new' => trim($updated), + ]; + $line = $updated; + $modified = true; + } + } + unset($line); + + if ($modified) { + if (!$this->dryRun) { + if (file_put_contents($file, implode("\n", $lines)) === false) { + fwrite(STDERR, "❌ Cannot write: {$file}\n"); + return; + } + } + $this->log(($this->dryRun ? '(dry-run) ' : '') . "✏️ Updated: {$file}", true); + } else { + $this->log("✓ No changes: {$file}"); + } + } + + /** + * Inspect one line and return the pinned replacement, or null if the line + * does not need to be changed. + */ + private function processLine(string $line, string $file, int $lineNum): ?string + { + // Match: uses: @ [# optional comment] + // The ref must NOT already be a 40-character hex SHA. + if (!preg_match( + '/^(\s+uses:\s+)([\w.\-]+\/[\w.\-\/]+)@([^\s#]+)((?:\s+#.*)?)$/', + $line, + $m + )) { + return null; + } + + [, $prefix, $action, $ref, $trailingComment] = $m; + + // Already pinned – nothing to do + if (preg_match('/^[0-9a-f]{40}$/', $ref)) { + $this->log(" ✓ Already pinned: {$action}@{$ref}"); + return null; + } + + // Derive owner/repo from the action path + // e.g. "github/codeql-action/init" → owner=github, repo=codeql-action + $segments = explode('/', $action); + + if (count($segments) < 2) { + return null; + } + + [$owner, $repo] = $segments; + + $sha = $this->resolveTagToSha($owner, $repo, $ref); + + if ($sha === null) { + fwrite(STDERR, "⚠️ Cannot resolve {$action}@{$ref} ({$file}:{$lineNum}) – skipping\n"); + return null; + } + + // Preserve original trailing whitespace / newline handling; strip any + // existing comment so we replace it with the canonical one. + return "{$prefix}{$action}@{$sha} # {$ref}"; + } + + // ------------------------------------------------------------------------- + // GitHub API helpers + // ------------------------------------------------------------------------- + + private function resolveTagToSha(string $owner, string $repo, string $ref): ?string + { + $cacheKey = "{$owner}/{$repo}@{$ref}"; + + if (array_key_exists($cacheKey, $this->shaCache)) { + return $this->shaCache[$cacheKey]; + } + + $this->log(" Resolving {$owner}/{$repo}@{$ref} …"); + + $sha = null; + + if ($this->adapter !== null) { + // Use the platform adapter for ref resolution + try { + $sha = $this->adapter->resolveRef($owner, $repo, $ref); + if (empty($sha)) { + $sha = null; + } + } catch (\Exception $e) { + $this->log(" Warning: adapter resolve failed: " . $e->getMessage()); + $this->adapter->getApiClient()->resetCircuitBreaker(); + } + } + + if ($sha !== null) { + $this->log(" -> {$sha}"); + } + + $this->shaCache[$cacheKey] = $sha; + + return $sha; + } + + // ------------------------------------------------------------------------- + // Output helpers + // ------------------------------------------------------------------------- + + private function printSummary(int $fileCount): void + { + $this->log("\nSummary:", true); + $this->log(" Files scanned: {$fileCount}", true); + $this->log(" Actions pinned: " . count($this->changes), true); + + if (!empty($this->changes)) { + $this->log("\nChanges made:", true); + + foreach ($this->changes as $change) { + $this->log(" {$change['file']}:{$change['line']}", true); + $this->log(" - {$change['old']}", true); + $this->log(" + {$change['new']}", true); + } + } else { + $this->log("\n✅ All actions are already pinned to commit SHAs", true); + } + } + + private function log(string $message, bool $force = false): void + { + if ($this->verbose || $force) { + echo $message . "\n"; + } + } +} + +// Entry point +$pinner = new ActionShaPinner(array_slice($argv, 1)); +exit($pinner->run()); diff --git a/maintenance/repo_inventory.php b/maintenance/repo_inventory.php new file mode 100644 index 0000000..cec1053 --- /dev/null +++ b/maintenance/repo_inventory.php @@ -0,0 +1,219 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Maintenance + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/maintenance/repo_inventory.php + * VERSION: 04.06.00 + * BRIEF: Generate a live inventory dashboard of all governed repos as a GitHub issue + * + * USAGE + * php api/maintenance/repo_inventory.php # Generate and post dashboard + * php api/maintenance/repo_inventory.php --dry-run # Preview only + * php api/maintenance/repo_inventory.php --json # JSON output to stdout + */ + +declare(strict_types=1); + +$dryRun = in_array('--dry-run', $argv); +$jsonOut = in_array('--json', $argv); + +$org = 'mokoconsulting-tech'; +foreach ($argv as $i => $arg) { + if ($arg === '--org' && isset($argv[$i + 1])) { $org = $argv[$i + 1]; } +} + +$token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN'); +if (empty($token)) { + fwrite(STDERR, "GH_TOKEN or GITHUB_TOKEN not set\n"); + exit(1); +} + +$ALWAYS_EXCLUDE = ['MokoStandards', '.github-private']; + +/** + * @return array{int, array} + */ +function ghApi(string $method, string $path, ?array $body, string $token): array +{ + $ch = curl_init("https://api.github.com/{$path}"); + $opts = [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: token ' . $token, + 'Content-Type: application/json', + 'User-Agent: MokoStandards-Inventory', + 'Accept: application/vnd.github.v3+json', + ], + ]; + if ($method !== 'GET') { $opts[CURLOPT_CUSTOMREQUEST] = $method; } + if ($body !== null) { $opts[CURLOPT_POSTFIELDS] = json_encode($body); } + curl_setopt_array($ch, $opts); + $resp = (string) curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + return [$status, json_decode($resp, true) ?? []]; +} + +/** + * GitHub GraphQL helper. + * + * @return array + */ +function graphql(string $query, array $variables, string $token): array +{ + $ch = curl_init('https://api.github.com/graphql'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode(['query' => $query, 'variables' => $variables]), + CURLOPT_HTTPHEADER => [ + 'Authorization: bearer ' . $token, + 'Content-Type: application/json', + 'User-Agent: MokoStandards-Inventory', + ], + ]); + $body = (string) curl_exec($ch); + curl_close($ch); + return json_decode($body, true)['data'] ?? []; +} + +// ── Fetch all repos ───────────────────────────────────────────────────── +if (!$jsonOut) { echo "Fetching repositories from {$org}...\n"; } + +$allRepos = []; +$page = 1; +do { + [$_, $batch] = ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all&sort=full_name", null, $token); + $allRepos = array_merge($allRepos, $batch); + $page++; +} while (count($batch) === 100); + +if (!$jsonOut) { echo "Found " . count($allRepos) . " total repositories\n\n"; } + +// ── Build inventory ───────────────────────────────────────────────────── +$inventory = []; + +foreach ($allRepos as $repo) { + $name = $repo['name']; + if (in_array($name, $ALWAYS_EXCLUDE, true)) { continue; } + + $entry = [ + 'name' => $name, + 'visibility' => $repo['private'] ? 'private' : 'public', + 'archived' => $repo['archived'] ?? false, + 'platform' => '-', + 'version' => '-', + 'last_push' => $repo['pushed_at'] ?? '-', + 'open_issues' => $repo['open_issues_count'] ?? 0, + 'has_project' => false, + 'rulesets' => 0, + ]; + + if ($entry['archived']) { + $inventory[] = $entry; + continue; + } + + // Detect platform from .mokostandards + foreach (['.github/.mokostandards', '.mokostandards'] as $path) { + [$status, $data] = ghApi('GET', "repos/{$org}/{$name}/contents/{$path}", null, $token); + if ($status === 200 && !empty($data['content'])) { + $content = base64_decode($data['content']); + if (preg_match('/^platform:\s*(.+)/m', $content, $m)) { + $entry['platform'] = trim($m[1], " \t\n\r\"'"); + } + if (preg_match('/^version:\s*(.+)/m', $content, $m)) { + $entry['version'] = trim($m[1], " \t\n\r\"'"); + } + break; + } + } + + // Check rulesets count + [$status, $rulesets] = ghApi('GET', "repos/{$org}/{$name}/rulesets?per_page=100&includes_parents=true", null, $token); + if ($status === 200 && is_array($rulesets)) { + $entry['rulesets'] = count($rulesets); + } + + // Check for GitHub Project + $gql = graphql( + 'query($owner:String!,$name:String!){repository(owner:$owner,name:$name){projectsV2(first:1){totalCount}}}', + ['owner' => $org, 'name' => $name], + $token + ); + $entry['has_project'] = ($gql['repository']['projectsV2']['totalCount'] ?? 0) > 0; + + $inventory[] = $entry; + + if (!$jsonOut) { + echo " {$name}: {$entry['platform']} | v{$entry['version']} | rulesets:{$entry['rulesets']} | project:" . ($entry['has_project'] ? 'yes' : 'no') . "\n"; + } +} + +// ── JSON output ───────────────────────────────────────────────────────── +if ($jsonOut) { + echo json_encode($inventory, JSON_PRETTY_PRINT) . "\n"; + exit(0); +} + +// ── Build dashboard ───────────────────────────────────────────────────── +$now = gmdate('Y-m-d H:i:s') . ' UTC'; +$active = array_filter($inventory, fn($r) => !$r['archived']); +$archived = array_filter($inventory, fn($r) => $r['archived']); +$withRules = count(array_filter($active, fn($r) => $r['rulesets'] >= 3)); +$withProj = count(array_filter($active, fn($r) => $r['has_project'])); +$activeN = count($active); +$archivedN = count($archived); + +$rows = []; +foreach ($inventory as $r) { + $vis = $r['visibility'] === 'private' ? 'prv' : 'pub'; + $arch = $r['archived'] ? ' archived' : ''; + $proj = $r['has_project'] ? 'yes' : '-'; + $rs = $r['archived'] ? '-' : ($r['rulesets'] >= 3 ? '3/3' : "{$r['rulesets']}/3"); + $rows[] = "| `{$r['name']}` | {$vis}{$arch} | {$r['platform']} | {$r['version']} | {$rs} | {$proj} | {$r['open_issues']} |"; +} +$table = implode("\n", $rows); + +$body = "## Repository Inventory Dashboard\n\n"; +$body .= "**Organisation:** `{$org}`\n"; +$body .= "**Generated:** {$now}\n"; +$body .= "**Active:** {$activeN} | **Archived:** {$archivedN} | **Rulesets 3/3:** {$withRules} | **Projects:** {$withProj}\n\n"; +$body .= "| Repository | Visibility | Platform | Version | Rulesets | Project | Issues |\n"; +$body .= "|---|---|---|---|---|---|---|\n"; +$body .= $table . "\n\n"; +$body .= "---\n*Auto-generated by `repo_inventory.php`*\n"; + +echo "\n" . str_repeat('-', 50) . "\n"; +echo "Active: {$activeN} | Archived: {$archivedN} | Rulesets 3/3: {$withRules} | Projects: {$withProj}\n"; + +// ── Post as issue ─────────────────────────────────────────────────────── +if (!$dryRun) { + $title = "dashboard: repository inventory ({$org})"; + [$_, $existing] = ghApi('GET', "repos/{$org}/MokoStandards/issues?labels=inventory&state=all&per_page=1&sort=created&direction=desc", null, $token); + + if (!empty($existing[0]['number'])) { + $num = $existing[0]['number']; + ghApi('PATCH', "repos/{$org}/MokoStandards/issues/{$num}", [ + 'title' => $title, 'body' => $body, 'state' => 'open', 'assignees' => ['jmiller-moko'], + ], $token); + echo "Updated inventory issue #{$num}\n"; + } else { + [$_, $issue] = ghApi('POST', "repos/{$org}/MokoStandards/issues", [ + 'title' => $title, 'body' => $body, + 'labels' => ['inventory', 'type: chore', 'automation'], + 'assignees' => ['jmiller-moko'], + ], $token); + echo "Created inventory issue #{$issue['number']}\n"; + } +} else { + echo "(dry-run) would post inventory dashboard issue\n"; +} diff --git a/maintenance/rotate_secrets.php b/maintenance/rotate_secrets.php new file mode 100644 index 0000000..4602387 --- /dev/null +++ b/maintenance/rotate_secrets.php @@ -0,0 +1,214 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Maintenance + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/maintenance/rotate_secrets.php + * VERSION: 04.06.00 + * BRIEF: Audit FTP secrets and variables across all governed repos — report missing or stale + * + * USAGE + * php api/maintenance/rotate_secrets.php --all # Audit all repos + * php api/maintenance/rotate_secrets.php --repo MokoCRM # Single repo + * php api/maintenance/rotate_secrets.php --all --json # JSON output + * php api/maintenance/rotate_secrets.php --all --create-issue # Post results as issue + */ + +declare(strict_types=1); + +$allMode = in_array('--all', $argv); +$jsonOut = in_array('--json', $argv); +$createIssue = in_array('--create-issue', $argv); + +$org = 'mokoconsulting-tech'; +$repoName = null; + +foreach ($argv as $i => $arg) { + if ($arg === '--repo' && isset($argv[$i + 1])) { $repoName = $argv[$i + 1]; } + if ($arg === '--org' && isset($argv[$i + 1])) { $org = $argv[$i + 1]; } +} + +if (!$repoName && !$allMode) { + fwrite(STDERR, "Usage: php rotate_secrets.php --all | --repo [--json] [--create-issue]\n"); + exit(2); +} + +$token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN'); +if (empty($token)) { + fwrite(STDERR, "GH_TOKEN or GITHUB_TOKEN not set\n"); + exit(1); +} + +$ALWAYS_EXCLUDE = ['MokoStandards', '.github-private']; + +$ENVS = [ + 'DEV' => ['vars' => ['DEV_FTP_HOST', 'DEV_FTP_PATH', 'DEV_FTP_USERNAME', 'DEV_FTP_SUFFIX'], 'secrets' => ['DEV_FTP_KEY', 'DEV_FTP_PASSWORD']], + 'DEMO' => ['vars' => ['DEMO_FTP_HOST', 'DEMO_FTP_PATH', 'DEMO_FTP_USERNAME', 'DEMO_FTP_SUFFIX'], 'secrets' => ['DEMO_FTP_KEY', 'DEMO_FTP_PASSWORD']], + 'RS' => ['vars' => ['RS_FTP_HOST', 'RS_FTP_PATH', 'RS_FTP_USERNAME', 'RS_FTP_SUFFIX'], 'secrets' => ['RS_FTP_KEY', 'RS_FTP_PASSWORD']], +]; + +/** + * GitHub REST API helper. + * + * @return array{int, array} + */ +function ghApi(string $method, string $path, ?array $body, string $token): array +{ + $ch = curl_init("https://api.github.com/{$path}"); + $opts = [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: token ' . $token, + 'Content-Type: application/json', + 'User-Agent: MokoStandards-SecretAudit', + 'Accept: application/vnd.github.v3+json', + ], + ]; + if ($method !== 'GET') { $opts[CURLOPT_CUSTOMREQUEST] = $method; } + if ($body !== null) { $opts[CURLOPT_POSTFIELDS] = json_encode($body); } + curl_setopt_array($ch, $opts); + $resp = (string) curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + return [$status, json_decode($resp, true) ?? []]; +} + +/** + * Fetch all names from a paginated list endpoint. + * + * @return string[] + */ +function listNames(string $path, string $key, string $token): array +{ + $names = []; + $page = 1; + do { + [$status, $data] = ghApi('GET', "{$path}?per_page=100&page={$page}", null, $token); + if ($status !== 200) { break; } + $items = ($key === '') ? $data : ($data[$key] ?? []); + foreach ($items as $item) { + if (isset($item['name'])) { $names[] = $item['name']; } + } + $page++; + } while (count($items) === 100); + return $names; +} + +// ── Build repo list ───────────────────────────────────────────────────── +$repos = []; +if ($allMode) { + if (!$jsonOut) { echo "Fetching repositories from {$org}...\n"; } + $page = 1; + do { + [$_, $batch] = ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all", null, $token); + foreach ($batch as $r) { + if (!($r['archived'] ?? false) && !in_array($r['name'], $ALWAYS_EXCLUDE, true)) { + $repos[] = $r['name']; + } + } + $page++; + } while (count($batch) === 100); + sort($repos); + if (!$jsonOut) { echo "Found " . count($repos) . " repositories\n\n"; } +} else { + $repos = [$repoName]; +} + +// ── Audit each repo ───────────────────────────────────────────────────── +$results = []; +$issueCount = 0; + +foreach ($repos as $repo) { + $fullRepo = "{$org}/{$repo}"; + $repoVars = listNames("repos/{$fullRepo}/actions/variables", 'variables', $token); + $repoSecrets = listNames("repos/{$fullRepo}/actions/secrets", 'secrets', $token); + + $result = ['repo' => $repo, 'envs' => [], 'missing' => []]; + + foreach ($ENVS as $env => $config) { + $missingVars = array_diff($config['vars'], $repoVars); + $hasAuth = !empty(array_intersect($config['secrets'], $repoSecrets)); + $hostVar = "{$env}_FTP_HOST"; + $configured = in_array($hostVar, $repoVars, true); + + $result['envs'][$env] = [ + 'configured' => $configured, + 'missing_vars' => array_values($missingVars), + 'has_auth' => $hasAuth, + ]; + + if ($configured) { + foreach ($missingVars as $v) { + if ($v !== "{$env}_FTP_SUFFIX") { + $result['missing'][] = "{$env}: missing {$v}"; + $issueCount++; + } + } + if (!$hasAuth) { + $result['missing'][] = "{$env}: no auth key/password"; + $issueCount++; + } + } + } + + if (!$jsonOut) { + $parts = []; + foreach ($ENVS as $env => $_) { + $e = $result['envs'][$env]; + if ($e['configured'] && $e['has_auth'] && empty($e['missing_vars'])) { + $parts[] = "{$env}:OK"; + } elseif ($e['configured']) { + $parts[] = "{$env}:INCOMPLETE"; + } else { + $parts[] = "{$env}:--"; + } + } + echo "{$repo}: " . implode(' | ', $parts) . (empty($result['missing']) ? '' : ' [' . implode('; ', $result['missing']) . ']') . "\n"; + } + + $results[] = $result; +} + +if ($jsonOut) { + echo json_encode($results, JSON_PRETTY_PRINT) . "\n"; +} else { + echo "\n" . str_repeat('-', 50) . "\n"; + $total = count($results); + $devReady = count(array_filter($results, fn($r) => ($r['envs']['DEV']['configured'] ?? false) && ($r['envs']['DEV']['has_auth'] ?? false))); + $demoReady = count(array_filter($results, fn($r) => ($r['envs']['DEMO']['configured'] ?? false) && ($r['envs']['DEMO']['has_auth'] ?? false))); + $rsReady = count(array_filter($results, fn($r) => ($r['envs']['RS']['configured'] ?? false) && ($r['envs']['RS']['has_auth'] ?? false))); + echo "Total: {$total} | DEV: {$devReady} | DEMO: {$demoReady} | RS: {$rsReady} | Issues: {$issueCount}\n"; +} + +// ── Create issue if requested ─────────────────────────────────────────── +if ($createIssue && $issueCount > 0) { + $now = gmdate('Y-m-d H:i:s') . ' UTC'; + $rows = []; + foreach ($results as $r) { + foreach ($r['missing'] as $m) { $rows[] = "| `{$r['repo']}` | {$m} |"; } + } + $table = implode("\n", $rows); + $body = "## FTP Secret/Variable Audit\n\n**Date:** {$now}\n**Issues:** {$issueCount}\n\n| Repository | Issue |\n|---|---|\n{$table}\n\n---\n*Auto-created by `rotate_secrets.php`*\n"; + + [$_, $existing] = ghApi('GET', "repos/{$org}/MokoStandards/issues?labels=secret-audit&state=all&per_page=1&sort=created&direction=desc", null, $token); + if (!empty($existing[0]['number'])) { + $num = $existing[0]['number']; + ghApi('PATCH', "repos/{$org}/MokoStandards/issues/{$num}", ['title' => "audit: FTP secrets — {$issueCount} issues", 'body' => $body, 'state' => 'open', 'assignees' => ['jmiller-moko']], $token); + if (!$jsonOut) { echo "Updated audit issue #{$num}\n"; } + } else { + [$_, $issue] = ghApi('POST', "repos/{$org}/MokoStandards/issues", [ + 'title' => "audit: FTP secrets — {$issueCount} issues", 'body' => $body, + 'labels' => ['secret-audit', 'type: chore', 'automation'], 'assignees' => ['jmiller-moko'], + ], $token); + if (!$jsonOut) { echo "Created audit issue #{$issue['number']}\n"; } + } +} + +exit($issueCount > 0 ? 1 : 0); diff --git a/maintenance/setup_labels.php b/maintenance/setup_labels.php new file mode 100644 index 0000000..04de6bf --- /dev/null +++ b/maintenance/setup_labels.php @@ -0,0 +1,252 @@ +#!/usr/bin/env php + + * + * REQUIRED FILE: This file must be present in all MokoStandards-compliant repositories + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Maintenance + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/maintenance/setup_labels.php + * VERSION: 04.06.00 + * BRIEF: REQUIRED label deployment script for all MokoStandards-governed repositories + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\CliFramework; +use MokoEnterprise\Config; +use MokoEnterprise\GitPlatformAdapter; +use MokoEnterprise\PlatformAdapterFactory; + +/** + * Deploys the standard set of repository labels required by MokoStandards. + * + * Uses the platform adapter (GitHub or Gitea) to create or update each label. + * Supports --dry-run mode to preview without making changes. + */ +class SetupLabels extends CliFramework +{ + private ?GitPlatformAdapter $adapter = null; + /** + * Label definitions — [name, hexColor (no #), description]. + * + * @var list + */ + private const LABELS = [ + // Project Type + ['joomla', '7F52FF', 'Joomla extension or component'], + ['dolibarr', 'FF6B6B', 'Dolibarr module or extension'], + ['generic', '808080', 'Generic project or library'], + + // Language + ['php', '4F5D95', 'PHP code changes'], + ['javascript', 'F7DF1E', 'JavaScript code changes'], + ['typescript', '3178C6', 'TypeScript code changes'], + ['python', '3776AB', 'Python code changes'], + ['css', '1572B6', 'CSS/styling changes'], + ['html', 'E34F26', 'HTML template changes'], + + // Component + ['documentation', '0075CA', 'Documentation changes'], + ['ci-cd', '000000', 'CI/CD pipeline changes'], + ['docker', '2496ED', 'Docker configuration changes'], + ['tests', '00FF00', 'Test suite changes'], + ['security', 'FF0000', 'Security-related changes'], + ['dependencies', '0366D6', 'Dependency updates'], + ['config', 'F9D0C4', 'Configuration file changes'], + ['build', 'FFA500', 'Build system changes'], + + // Workflow / Process + ['automation', '8B4513', 'Automated processes or scripts'], + ['mokostandards', 'B60205', 'MokoStandards compliance'], + ['needs-review', 'FBCA04', 'Awaiting code review'], + ['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'], + ['breaking-change', 'D73A4A', 'Breaking API or functionality change'], + + // Priority + ['priority: critical', 'B60205', 'Critical priority, must be addressed immediately'], + ['priority: high', 'D93F0B', 'High priority'], + ['priority: medium', 'FBCA04', 'Medium priority'], + ['priority: low', '0E8A16', 'Low priority'], + + // Type + ['type: bug', 'D73A4A', "Something isn't working"], + ['type: feature', 'A2EEEF', 'New feature or request'], + ['type: enhancement', '84B6EB', 'Enhancement to existing feature'], + ['type: refactor', 'F9D0C4', 'Code refactoring'], + ['type: chore', 'FEF2C0', 'Maintenance tasks'], + + // Status + ['status: pending', 'FBCA04', 'Pending action or decision'], + ['status: in-progress', '0E8A16', 'Currently being worked on'], + ['status: blocked', 'B60205', 'Blocked by another issue or dependency'], + ['status: on-hold', 'D4C5F9', 'Temporarily on hold'], + ['status: wontfix', 'FFFFFF', 'This will not be worked on'], + + // Size + ['size/xs', 'C5DEF5', 'Extra small change (1-10 lines)'], + ['size/s', '6FD1E2', 'Small change (11-30 lines)'], + ['size/m', 'F9DD72', 'Medium change (31-100 lines)'], + ['size/l', 'FFA07A', 'Large change (101-300 lines)'], + ['size/xl', 'FF6B6B', 'Extra large change (301-1000 lines)'], + ['size/xxl', 'B60205', 'Extremely large change (1000+ lines)'], + + // Health + ['health: excellent', '0E8A16', 'Health score 90-100'], + ['health: good', 'FBCA04', 'Health score 70-89'], + ['health: fair', 'FFA500', 'Health score 50-69'], + ['health: poor', 'FF6B6B', 'Health score below 50'], + + // Sync / Automation + ['standards-update', 'B60205', 'MokoStandards sync update'], + ['standards-drift', 'FBCA04', 'Repository drifted from MokoStandards'], + ['sync-report', '0075CA', 'Bulk sync run report'], + ['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'], + ['push-failure', 'D73A4A', 'File push failure requiring attention'], + ['health-check', '0E8A16', 'Repository health check results'], + ['version-drift', 'FFA500', 'Version mismatch detected'], + ['deploy-failure', 'CC0000', 'Automated deploy failure tracking'], + ['template-validation-failure', 'D73A4A', 'Template workflow validation failure'], + ['version', '0E8A16', 'Version bump or release'], + ['type: version', '0E8A16', 'Version-related change'], + + // Testing + ['type: test', '00FF00', 'Test suite additions or changes'], + ['needs-testing', 'FBCA04', 'Requires manual or automated testing'], + ['test-failure', 'D73A4A', 'Automated test failure'], + ['regression', 'B60205', 'Regression from a previous working state'], + + // Version & Release + ['type: release', '0E8A16', 'Release preparation or tracking'], + ['release-candidate', 'BFD4F2', 'Release candidate build'], + ['minor-release', '0E8A16', 'Minor version release (XX.YY.00)'], + ['patch-release', 'C5DEF5', 'Patch version release (XX.YY.ZZ)'], + ['major-release', 'B60205', 'Major version release (breaking changes)'], + ['version-branch', '1D76DB', 'Version branch related'], + ]; + + /** + * Configure available arguments. + */ + protected function configure(): void + { + $this->setDescription('REQUIRED: Deploy standard labels to repository'); + $this->addArgument('--dry-run', 'Show what would be created without actually creating labels', false); + $this->addArgument('--org', 'Organization name', 'mokoconsulting-tech'); + $this->addArgument('--repo', 'Repository name (defaults to current repo)', ''); + } + + /** + * Run the label deployment. + * + * @return int Exit code: 0 on success, 1 on error. + */ + protected function run(): int + { + $dryRun = (bool) $this->getArgument('--dry-run'); + + $config = Config::load(); + try { + $this->adapter = PlatformAdapterFactory::create($config); + } catch (\RuntimeException $e) { + $this->log('ERROR', $e->getMessage()); + return 1; + } + + $orgArg = (string) $this->getArgument('--org'); + $repoArg = (string) $this->getArgument('--repo'); + $org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech'); + $repo = $repoArg ?: basename(getcwd() ?: '.'); + + $this->log('INFO', "Setting up labels for repository: {$org}/{$repo} ({$this->adapter->getPlatformName()})"); + + echo "\n"; + + $this->deployGroup('Creating REQUIRED project type labels...', 0, 2, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED language labels...', 3, 8, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED component labels...', 9, 16, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED workflow labels...', 17, 21, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED priority labels...', 22, 25, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED type labels...', 26, 30, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED status labels...', 31, 35, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED size labels...', 36, 41, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED health labels...', 42, 45, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED sync/automation labels...', 46, 56, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED testing labels...', 57, 60, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED version/release labels...', 61, 66, $org, $repo, $dryRun); + + echo "\n============================================================\n"; + if ($dryRun) { + $this->log('INFO', '[DRY-RUN] Label deployment simulation completed'); + } else { + $this->log('INFO', 'Label deployment completed successfully!'); + echo "\n - TOTAL: " . count(self::LABELS) . " labels\n"; + } + echo "============================================================\n\n"; + + return 0; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + /** + * Deploy a named group of labels by index range in self::LABELS. + * + * @param string $heading Informational banner printed before the group. + * @param int $fromIndex First label index (inclusive). + * @param int $toIndex Last label index (inclusive). + * @param string $org Organization name. + * @param string $repo Repository name. + * @param bool $dryRun When true, preview only. + */ + private function deployGroup(string $heading, int $fromIndex, int $toIndex, string $org, string $repo, bool $dryRun): void + { + $this->log('INFO', $heading); + for ($i = $fromIndex; $i <= $toIndex; $i++) { + [$name, $color, $desc] = self::LABELS[$i]; + $this->createLabelViaApi($name, $color, $desc, $org, $repo, $dryRun); + } + echo "\n"; + } + + /** + * Create or update a single label via the platform adapter. + * + * @param string $name Label name. + * @param string $color Hex colour without the leading '#'. + * @param string $desc Short description text. + * @param string $org Organization name. + * @param string $repo Repository name. + * @param bool $dryRun When true, preview only. + */ + private function createLabelViaApi(string $name, string $color, string $desc, string $org, string $repo, bool $dryRun): void + { + if ($dryRun) { + echo "[DRY-RUN] Would create label: {$name} (color: #{$color}, description: {$desc})\n"; + return; + } + + try { + $this->adapter->createLabel($org, $repo, $name, $color, $desc); + $this->log('INFO', "Created/updated label: {$name}"); + } catch (\Exception $e) { + // Label may already exist — that's fine + if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) { + $this->log('INFO', "Label already exists: {$name}"); + } else { + $this->log('WARNING', "Failed to create label: {$name} — " . $e->getMessage()); + } + } + } +} + +$script = new SetupLabels('setup_labels', 'REQUIRED: Deploy standard labels to repository'); +exit($script->execute()); diff --git a/maintenance/sync_dolibarr_readmes.php b/maintenance/sync_dolibarr_readmes.php new file mode 100644 index 0000000..c6f1dd3 --- /dev/null +++ b/maintenance/sync_dolibarr_readmes.php @@ -0,0 +1,325 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Maintenance + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/maintenance/sync_dolibarr_readmes.php + * VERSION: 04.06.00 + * BRIEF: Keeps root README.md and src/README.md in sync for Dolibarr module repositories + * NOTE: Version format is zero-padded semver: XX.YY.ZZ (e.g. 04.00.04). All version regex + * patterns enforce exactly two digits per component by design. + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\CliFramework; + +/** + * Synchronises root README.md ↔ src/README.md for a Dolibarr module repository. + * + * Steps performed: + * 1. Extract VERSION from the FILE INFORMATION block in root README.md. + * 2. Update the version badge and VERSION field in root README.md. + * 3. Regenerate src/README.md with the end-user FILE INFORMATION header, + * module name/description, and the Installation/Configuration/Usage/Support + * sections extracted from root README.md. + */ +class SyncDolibarrReadmes extends CliFramework +{ + /** + * Configure available arguments. + */ + protected function configure(): void + { + $this->setDescription('Keeps root README.md and src/README.md in sync for Dolibarr module repos'); + $this->addArgument('--path', 'Dolibarr module repo root', '.'); + $this->addArgument('--dry-run', 'Preview changes without writing', false); + } + + /** + * Run the sync. + * + * @return int Exit code: 0 on success, 1 on error. + */ + protected function run(): int + { + $repoRoot = rtrim((string) $this->getArgument('--path'), '/'); + $dryRun = (bool) $this->getArgument('--dry-run'); + $rootReadme = $repoRoot . '/README.md'; + $srcReadme = $repoRoot . '/src/README.md'; + + if (!is_file($rootReadme)) { + $this->log('ERROR', "Root README.md not found at {$rootReadme}"); + return 1; + } + + if (!is_dir($repoRoot . '/src')) { + $this->log('ERROR', 'src/ directory not found — is this a Dolibarr module repository?'); + return 1; + } + + $rootContent = (string) file_get_contents($rootReadme); + + if (!preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $rootContent, $m)) { + $this->log('ERROR', 'Could not find VERSION in root README.md FILE INFORMATION block'); + return 1; + } + $version = $m[1]; + + $moduleName = $this->extractModuleName($rootContent, $repoRoot); + $repoUrl = $this->extractField($rootContent, 'REPO', 'https://github.com/mokoconsulting-tech'); + $defgroup = $this->extractField($rootContent, 'DEFGROUP', 'MokoStandards.Module'); + $ingroup = $this->extractField($rootContent, 'INGROUP', 'MokoStandards'); + $brief = $this->extractField($rootContent, 'BRIEF', "{$moduleName} end-user documentation"); + + $installSection = $this->extractSection($rootContent, 'Installation'); + $configSection = $this->extractSection($rootContent, 'Configuration'); + $usageSection = $this->extractSection($rootContent, 'Usage'); + $supportSection = $this->extractSection($rootContent, 'Support'); + + echo "═══════════════════════════════════════════════════════════\n"; + echo " Dolibarr README Sync\n"; + echo "═══════════════════════════════════════════════════════════\n\n"; + echo "Module: {$moduleName}\n"; + echo "Version: {$version}\n"; + echo "Root: {$rootReadme}\n"; + echo "Src: {$srcReadme}\n"; + if ($dryRun) { + echo " DRY RUN — no files will be written\n"; + } + echo "\n"; + + echo "Step 1: Update root README.md badges and VERSION field...\n"; + $this->updateRootReadme($rootReadme, $rootContent, $version, $dryRun); + + echo "Step 2: Sync src/README.md...\n"; + $today = gmdate('Y-m-d'); + $newSrcContent = $this->buildSrcReadme( + $version, $moduleName, $repoUrl, $defgroup, $ingroup, $brief, $today, + $installSection, $configSection, $usageSection, $supportSection + ); + $this->syncSrcReadme($srcReadme, $newSrcContent, $dryRun); + + echo "\n═══════════════════════════════════════════════════════════\n"; + if ($dryRun) { + echo " Dry Run Complete\n"; + echo "═══════════════════════════════════════════════════════════\n"; + echo "Run without --dry-run to apply changes.\n"; + } else { + echo " Dolibarr README Sync Complete\n"; + echo "═══════════════════════════════════════════════════════════\n"; + echo "Module version: {$version}\n\n"; + echo "Next steps:\n"; + echo " git diff && git add README.md src/README.md\n"; + echo " git commit -m \"docs(readme): sync src/README.md from root for version {$version}\"\n"; + } + echo "\n"; + + return 0; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + /** + * Extract a named field from the FILE INFORMATION block. + * + * @param string $content Full file content. + * @param string $field Field name (e.g. 'REPO'). + * @param string $fallback Value to use when the field is absent. + * @return string Field value or fallback. + */ + private function extractField(string $content, string $field, string $fallback): string + { + if (preg_match('/^\s*' . preg_quote($field, '/') . ':\s*(.+)$/m', $content, $m)) { + return trim($m[1]); + } + return $fallback; + } + + /** + * Extract the module name from the first H1 heading after the closing '-->' of the header. + * + * @param string $content Full root README.md content. + * @param string $repoRoot Repository root path (used as fallback). + * @return string Module name. + */ + private function extractModuleName(string $content, string $repoRoot): string + { + if (preg_match('/-->\s*\n+# (.+)/u', $content, $m)) { + return trim($m[1]); + } + return basename($repoRoot); + } + + /** + * Extract a Markdown H2 section (from '## Heading' to the next '## '). + * + * @param string $content Full file content. + * @param string $heading Section heading (without '## ' prefix). + * @return string The extracted section text, or '' if not found. + */ + private function extractSection(string $content, string $heading): string + { + $quoted = preg_quote($heading, '/'); + if (!preg_match('/^## ' . $quoted . '$/m', $content)) { + return ''; + } + if (preg_match('/^## ' . $quoted . '$(.*?)(?=^## |\Z)/ms', $content, $m)) { + return '## ' . $heading . $m[1]; + } + return ''; + } + + /** + * Update the version badge and VERSION field in root README.md. + * + * @param string $path Path to root README.md. + * @param string $content Current file content. + * @param string $version New version string. + * @param bool $dryRun When true, preview only. + */ + private function updateRootReadme(string $path, string $content, string $version, bool $dryRun): void + { + $updated = preg_replace( + '/(https:\/\/img\.shields\.io\/badge\/MokoStandards-)\d{2}\.\d{2}\.\d{2}/i', + '${1}' . $version, + $content + ); + $updated = preg_replace( + '/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m', + '${1}' . $version, + (string) $updated + ); + + if ($updated === $content) { + echo " ✓ root README.md already current\n"; + return; + } + + if ($dryRun) { + echo " ~ root README.md (would update version fields)\n"; + return; + } + + file_put_contents($path, (string) $updated); + echo " ✓ root README.md updated\n"; + } + + /** + * Build the full content for src/README.md. + * + * @param string $version Version string. + * @param string $moduleName Module display name. + * @param string $repoUrl Repository URL. + * @param string $defgroup DEFGROUP value. + * @param string $ingroup INGROUP value. + * @param string $brief BRIEF value. + * @param string $today ISO date string (YYYY-MM-DD). + * @param string $installSection Extracted Installation section (may be ''). + * @param string $configSection Extracted Configuration section (may be ''). + * @param string $usageSection Extracted Usage section (may be ''). + * @param string $supportSection Extracted Support section (may be ''). + * @return string Complete file content. + */ + private function buildSrcReadme( + string $version, + string $moduleName, + string $repoUrl, + string $defgroup, + string $ingroup, + string $brief, + string $today, + string $installSection, + string $configSection, + string $usageSection, + string $supportSection + ): string { + $content = << + +This file is part of a Moko Consulting project. + +SPDX-License-Identifier: GPL-3.0-or-later + +# FILE INFORMATION +DEFGROUP: {$defgroup} +INGROUP: {$ingroup} +REPO: {$repoUrl} +PATH: /src/README.md +VERSION: {$version} +BRIEF: {$brief} — end-user documentation deployed with the module +NOTE: This file is auto-generated by sync_dolibarr_readmes.php from root README.md. + Edit the source sections in root README.md; do not edit this file directly. + Last synced: {$today} +--> + +[![MokoStandards](https://img.shields.io/badge/MokoStandards-{$version}-blue)]({$repoUrl}) + +# {$moduleName} + +> **End-user documentation.** For developer and contributor documentation, see the root `README.md`. + +SRCREADME; + + foreach ([$installSection, $configSection, $usageSection, $supportSection] as $section) { + if ($section !== '') { + $content .= "\n" . $section; + } + } + + $content .= "\n---\n\n*Documentation generated from root `README.md` — do not edit this file directly.*\n"; + return $content; + } + + /** + * Compare and write (or preview) src/README.md. + * + * @param string $path Path to src/README.md. + * @param string $content Desired file content. + * @param bool $dryRun When true, preview only. + */ + private function syncSrcReadme(string $path, string $content, bool $dryRun): void + { + if (is_file($path)) { + $existing = (string) file_get_contents($path); + if ($existing === $content) { + echo " ✓ src/README.md already current\n"; + return; + } + if ($dryRun) { + echo " ~ src/README.md (would regenerate)\n"; + return; + } + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0755, true); + } + file_put_contents($path, $content); + echo " ✓ src/README.md regenerated\n"; + return; + } + + if ($dryRun) { + echo " ~ src/README.md (would create — file does not exist)\n"; + return; + } + + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0755, true); + } + file_put_contents($path, $content); + echo " ✓ src/README.md created\n"; + } +} + +$script = new SyncDolibarrReadmes('sync_dolibarr_readmes', 'Keeps root README.md and src/README.md in sync for Dolibarr module repos'); +exit($script->execute()); diff --git a/maintenance/update_repo_inventory.php b/maintenance/update_repo_inventory.php new file mode 100644 index 0000000..37f9667 --- /dev/null +++ b/maintenance/update_repo_inventory.php @@ -0,0 +1,311 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Maintenance + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/maintenance/update_repo_inventory.php + * VERSION: 04.06.00 + * BRIEF: Queries GitHub org repos and rewrites the auto-generated section of REPOSITORY_INVENTORY.md + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\CliFramework; +use MokoEnterprise\Config; +use MokoEnterprise\GitPlatformAdapter; +use MokoEnterprise\PlatformAdapterFactory; + +/** + * Queries the git platform API for all repositories in the organisation and + * rewrites the auto-generated inventory table inside + * docs/reference/REPOSITORY_INVENTORY.md between the markers: + * + * + * + * + * Everything outside those markers is left untouched. + */ +class UpdateRepoInventory extends CliFramework +{ + private ?GitPlatformAdapter $adapter = null; + + /** Marker that begins the auto-generated block. */ + private const MARKER_START = ''; + + /** Marker that ends the auto-generated block. */ + private const MARKER_END = ''; + + /** Path to the Dolibarr module registry relative to repo root. */ + private const REGISTRY_PATH = 'docs/development/crm/module-registry.md'; + + /** Path to the inventory file relative to repo root. */ + private const INVENTORY_PATH = 'docs/reference/REPOSITORY_INVENTORY.md'; + + protected function configure(): void + { + $this->setDescription('Updates docs/reference/REPOSITORY_INVENTORY.md with current org repo list'); + $this->addArgument('--org', 'Organisation to query', 'mokoconsulting-tech'); + $this->addArgument('--path', 'Repository root path', '.'); + } + + protected function run(): int + { + $root = rtrim((string) $this->getArgument('--path'), '/\\'); + + $config = Config::load(); + try { + $this->adapter = PlatformAdapterFactory::create($config); + } catch (\RuntimeException $e) { + $this->status(false, 'auth', $e->getMessage()); + return 2; + } + + $orgArg = (string) $this->getArgument('--org'); + $org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech'); + + // ── 1. Fetch repositories ───────────────────────────────────────────── + $this->section("Fetching repositories for {$org} ({$this->adapter->getPlatformName()})"); + + $repos = $this->fetchAllRepos($org); + if ($repos === null) { + return 1; + } + + $this->status(true, 'API', sprintf('Fetched %d repositories', count($repos))); + + // ── 2. Load Dolibarr module registry ────────────────────────────────── + $this->section('Loading Dolibarr module registry'); + + $moduleMap = $this->parseModuleRegistry($root . '/' . self::REGISTRY_PATH); + $this->status(true, 'registry', sprintf('Loaded %d module ID entries', count($moduleMap))); + + // ── 3. Build the Markdown tables ────────────────────────────────────── + $this->section('Building inventory tables'); + + $table = $this->buildTables($repos, $moduleMap, $org); + + // ── 4. Rewrite the inventory file ──────────────────────────────────── + $this->section('Updating ' . self::INVENTORY_PATH); + + $inventoryPath = $root . '/' . self::INVENTORY_PATH; + if (!is_file($inventoryPath)) { + $this->status(false, self::INVENTORY_PATH, 'file not found'); + return 2; + } + + $original = (string) file_get_contents($inventoryPath); + $updated = $this->replaceSection($original, $table); + + if ($original === $updated) { + $this->status(true, self::INVENTORY_PATH, 'no changes needed'); + } elseif (!$this->isDryRun()) { + file_put_contents($inventoryPath, $updated); + $this->status(true, self::INVENTORY_PATH, 'updated'); + } else { + $this->status(true, self::INVENTORY_PATH, '[dry-run] would update'); + } + + $this->printSummary(1, 0, $this->elapsed()); + + return 0; + } + + // ── Platform API ────────────────────────────────────────────────────────── + + /** + * Fetch all repositories for the org via the platform adapter. + * + * @return list>|null Null on API error. + */ + private function fetchAllRepos(string $org): ?array + { + try { + // Use the adapter's paginated listing — returns full repo objects + $repos = $this->adapter->paginateAll("/orgs/{$org}/repos", ['type' => 'all']); + $this->progress(count($repos), count($repos), '', true); + return $repos; + } catch (\Exception $e) { + $this->status(false, 'API', $e->getMessage()); + return null; + } + } + + // ── Module registry ─────────────────────────────────────────────────────── + + /** + * Parse the Dolibarr module registry Markdown table. + * + * @return array Map of lower-case repo name → module number. + */ + private function parseModuleRegistry(string $path): array + { + if (!is_file($path)) { + $this->warning("Module registry not found: {$path}"); + return []; + } + + $map = []; + $content = (string) file_get_contents($path); + + // Match table rows: | ModuleName | 185051 | Status | … | + preg_match_all('/^\|\s*(\w+)\s*\|\s*(\d{6})\s*\|/m', $content, $matches, PREG_SET_ORDER); + + foreach ($matches as $match) { + $id = (int) $match[2]; + if ($id >= 100000) { + $map[strtolower($match[1])] = $id; + } + } + + return $map; + } + + // ── Table builder ───────────────────────────────────────────────────────── + + /** + * Build the full Markdown replacement for the inventory tables. + * + * @param list> $repos + * @param array $moduleMap + */ + private function buildTables(array $repos, array $moduleMap, string $org): string + { + // Sort: active first, then archived; within each group alphabetically. + usort($repos, static function (array $a, array $b): int { + $aArch = (bool) ($a['archived'] ?? false); + $bArch = (bool) ($b['archived'] ?? false); + if ($aArch !== $bArch) { + return $aArch ? 1 : -1; + } + return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? '')); + }); + + /** @var array>> $groups */ + $groups = ['core' => [], 'product' => [], 'extension' => [], 'template' => [], 'internal' => [], 'archived' => []]; + + foreach ($repos as $repo) { + $name = (string) ($repo['name'] ?? ''); + $topics = array_map('strtolower', (array) ($repo['topics'] ?? [])); + $archived = (bool) ($repo['archived'] ?? false); + + if ($archived) { + $groups['archived'][] = $repo; + continue; + } + + $lower = strtolower($name); + + if (in_array('mokostandards-core', $topics, true) || $name === 'MokoStandards' || $name === '.github-private') { + $groups['core'][] = $repo; + } elseif ( + in_array('dolibarr-module', $topics, true) + || str_starts_with($lower, 'mokodoli') + || (str_starts_with($lower, 'mokocrm') && $lower !== 'mokocrmtheme') + ) { + $groups['extension'][] = $repo; + } elseif (in_array('product', $topics, true) || in_array('platform', $topics, true)) { + $groups['product'][] = $repo; + } elseif (in_array('template', $topics, true) || str_contains($lower, 'template')) { + $groups['template'][] = $repo; + } else { + $groups['internal'][] = $repo; + } + } + + $updated = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s T'); + $lines = [ + "> ⚙️ **Auto-generated** by `update_repo_inventory.php` — last updated {$updated}.", + '> Do not edit this section manually; it is overwritten on every bulk sync.', + '', + ]; + + $groupLabels = [ + 'core' => 'Core Repositories', + 'product' => 'Product Repositories', + 'extension' => 'Extension Repositories (Dolibarr / CRM)', + 'template' => 'Template Repositories', + 'internal' => 'Internal and Testing', + 'archived' => 'Archived Repositories', + ]; + + foreach ($groupLabels as $key => $label) { + if (empty($groups[$key])) { + continue; + } + + $lines[] = "### {$label}"; + $lines[] = ''; + $isExt = ($key === 'extension'); + + if ($isExt) { + $lines[] = '| Repository | Status | Description | Module ID | Language | Visibility |'; + $lines[] = '|------------|--------|-------------|-----------|----------|------------|'; + } else { + $lines[] = '| Repository | Status | Description | Language | Visibility |'; + $lines[] = '|------------|--------|-------------|----------|------------|'; + } + + foreach ($groups[$key] as $repo) { + $name = (string) ($repo['name'] ?? ''); + $desc = str_replace('|', '\\|', (string) ($repo['description'] ?? '')); + $url = (string) ($repo['html_url'] ?? "https://github.com/{$org}/{$name}"); + $lang = (string) ($repo['language'] ?? '—'); + $private = (bool) ($repo['private'] ?? false); + $archived = (bool) ($repo['archived'] ?? false); + $status = $archived ? '🗄 Archived' : '✅ Active'; + $vis = $private ? 'Private' : 'Public'; + $modId = $moduleMap[strtolower($name)] ?? null; + $modCell = $modId !== null ? (string) $modId : '—'; + + if ($isExt) { + $lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$modCell} | {$lang} | {$vis} |"; + } else { + $lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$lang} | {$vis} |"; + } + } + + $lines[] = ''; + } + + return implode("\n", $lines); + } + + // ── File rewriter ───────────────────────────────────────────────────────── + + /** + * Replace content between the start/end markers in the inventory file. + * If markers are absent, appends a new section at the end. + */ + private function replaceSection(string $original, string $newContent): string + { + $startPos = strpos($original, self::MARKER_START); + $endPos = strpos($original, self::MARKER_END); + + if ($startPos === false || $endPos === false) { + $this->warning('Inventory markers not found; appending section to end of file.'); + return $original + . "\n\n## Active Repositories\n\n" + . self::MARKER_START . "\n" + . $newContent . "\n" + . self::MARKER_END . "\n"; + } + + $before = substr($original, 0, $startPos + strlen(self::MARKER_START)); + $after = substr($original, $endPos); + + return $before . "\n" . $newContent . "\n" . $after; + } +} + +$script = new UpdateRepoInventory('update_repo_inventory', 'Updates the repository inventory documentation after sync'); +exit($script->execute()); diff --git a/maintenance/update_sha_hashes.php b/maintenance/update_sha_hashes.php new file mode 100755 index 0000000..0a0559a --- /dev/null +++ b/maintenance/update_sha_hashes.php @@ -0,0 +1,196 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Maintenance + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/maintenance/update_sha_hashes.php + * VERSION: 04.06.00 + * BRIEF: Update SHA-256 hashes in script registry + */ + +declare(strict_types=1); + +/** + * Script Registry Hash Updater + * + * Updates SHA-256 hashes for all scripts in the registry + */ +class ScriptRegistryUpdater +{ + private const REGISTRY_PATH = 'api/.script-registry.json'; + + private bool $dryRun = false; + private bool $verbose = false; + private array $changes = []; + + public function __construct(array $args) + { + $this->parseArguments($args); + } + + private function parseArguments(array $args): void + { + foreach ($args as $arg) { + if ($arg === '--dry-run') { + $this->dryRun = true; + } elseif ($arg === '--verbose' || $arg === '-v') { + $this->verbose = true; + } elseif ($arg === '--help' || $arg === '-h') { + $this->showHelp(); + exit(0); + } + } + } + + private function showHelp(): void + { + echo "Usage: php update_sha_hashes.php [OPTIONS]\n\n"; + echo "Options:\n"; + echo " --dry-run Check for changes without updating the registry\n"; + echo " --verbose Show detailed output\n"; + echo " --help Show this help message\n"; + echo "\n"; + } + + public function run(): int + { + try { + $this->log("🔐 SHA-256 Hash Update Tool", true); + $this->log(str_repeat("=", 50), true); + + if ($this->dryRun) { + $this->log("Mode: DRY RUN (no changes will be made)", true); + } + + // Load registry + $registry = $this->loadRegistry(); + + // Update hashes + $updatedRegistry = $this->updateHashes($registry); + + // Save if not dry run and there are changes + if (!$this->dryRun && !empty($this->changes)) { + $this->saveRegistry($updatedRegistry); + $this->log("\n✅ Registry updated successfully", true); + } elseif (empty($this->changes)) { + $this->log("\nℹ️ No changes needed - all hashes are current", true); + } else { + $this->log("\n✅ Dry run complete - changes detected but not applied", true); + } + + return 0; + + } catch (Exception $e) { + fwrite(STDERR, "❌ Error: " . $e->getMessage() . "\n"); + return 1; + } + } + + private function loadRegistry(): array + { + if (!file_exists(self::REGISTRY_PATH)) { + throw new Exception("Registry file not found: " . self::REGISTRY_PATH); + } + + $content = file_get_contents(self::REGISTRY_PATH); + $registry = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new Exception("Failed to parse registry JSON: " . json_last_error_msg()); + } + + $this->log("Registry loaded: " . count($registry['scripts']) . " scripts tracked"); + + return $registry; + } + + private function updateHashes(array $registry): array + { + $this->log("\nChecking scripts for changes...\n"); + + foreach ($registry['scripts'] as $index => &$script) { + $path = $script['path']; + + if (!file_exists($path)) { + $this->log("⚠️ Skipping missing file: {$path}"); + continue; + } + + // Calculate current hash + $currentHash = hash_file('sha256', $path); + $currentSize = filesize($path); + + // Check if changed + if ($currentHash !== $script['sha256']) { + $this->changes[] = [ + 'path' => $path, + 'old_hash' => $script['sha256'], + 'new_hash' => $currentHash, + ]; + + $this->log("🔄 Hash updated: {$path}", true); + + if ($this->verbose) { + $this->log(" Old: {$script['sha256']}"); + $this->log(" New: {$currentHash}"); + } + + // Update in registry + $script['sha256'] = $currentHash; + $script['size_bytes'] = $currentSize; + } else { + $this->log("✓ No change: {$path}"); + } + } + + // Update metadata timestamp if there are changes + if (!empty($this->changes)) { + $microtime = microtime(true); + $dt = DateTime::createFromFormat('U.u', sprintf('%.6f', $microtime), new DateTimeZone('UTC')); + if ($dt === false) { + throw new Exception("Failed to create DateTime from microtime"); + } + $registry['metadata']['generated_at'] = $dt->format('Y-m-d\TH:i:s.u\Z'); + } + + $this->log("\nSummary:"); + $this->log(" Total scripts: " . count($registry['scripts']), true); + $this->log(" Changed: " . count($this->changes), true); + + return $registry; + } + + private function saveRegistry(array $registry): void + { + $json = json_encode($registry, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + if ($json === false) { + throw new Exception("Failed to encode registry JSON: " . json_last_error_msg()); + } + + if (file_put_contents(self::REGISTRY_PATH, $json) === false) { + throw new Exception("Failed to write registry file"); + } + + $this->log("Registry saved: " . self::REGISTRY_PATH); + } + + private function log(string $message, bool $force = false): void + { + if ($this->verbose || $force) { + echo $message . "\n"; + } + } +} + +// Run the updater +$updater = new ScriptRegistryUpdater(array_slice($argv, 1)); +exit($updater->run()); diff --git a/maintenance/update_version_from_readme.php b/maintenance/update_version_from_readme.php new file mode 100644 index 0000000..558196d --- /dev/null +++ b/maintenance/update_version_from_readme.php @@ -0,0 +1,483 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Maintenance + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/maintenance/update_version_from_readme.php + * VERSION: 04.06.00 + * BRIEF: Reads VERSION from README.md FILE INFORMATION block and propagates it to all badges and FILE INFORMATION headers + * NOTE: README.md is the single source of truth for the repository version. + * Version format is zero-padded semver: XX.YY.ZZ (e.g. 04.00.04). All regex patterns + * in this script enforce exactly two digits per component by design. + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\{ApiClient, AuditLogger, CliFramework}; + +/** + * Propagates the version from README.md FILE INFORMATION block to every + * badge and FILE INFORMATION VERSION field in the repository. + * + * Sources updated: + * - Markdown badge: [![MokoStandards](https://img.shields.io/badge/MokoStandards-OLD-blue)] + * - Markdown header: VERSION: OLD (inside comment blocks) + * - PHP header: * VERSION: OLD (inside block comments) + * - YAML/Shell header:# VERSION: OLD + * - composer.json: "version": "OLD" + */ +class UpdateVersionFromReadme extends CliFramework +{ + private AuditLogger $logger; + private ?ApiClient $apiClient = null; + + /** Files updated during this run */ + private array $updatedFiles = []; + + /** Errors encountered during this run */ + private array $errors = []; + + protected function configure(): void + { + $this->setDescription('Propagate README.md version to all badges and FILE INFORMATION headers'); + $this->addArgument('--path', 'Repository root path', '.'); + $this->addArgument('--dry-run', 'Preview changes without writing', false); + $this->addArgument('--create-issue', 'Create GitHub issue if version mismatches remain', false); + $this->addArgument('--repo', 'GitHub repo for issue creation (owner/repo)', ''); + } + + protected function initialize(): void + { + parent::initialize(); + $this->logger = new AuditLogger('update_version_from_readme'); + } + + protected function run(): int + { + $repoRoot = rtrim((string) $this->getArgument('--path'), '/'); + $dryRun = (bool) $this->getArgument('--dry-run'); + $createIssue = (bool) $this->getArgument('--create-issue'); + $repo = (string) $this->getArgument('--repo'); + + $readmePath = $repoRoot . '/README.md'; + if (!file_exists($readmePath)) { + $this->error("README.md not found at {$readmePath}"); + return 1; + } + + // ── 1. Extract version from README.md ──────────────────────────── + $version = $this->extractVersionFromReadme($readmePath); + if ($version === null) { + $this->error("Could not find VERSION field in README.md FILE INFORMATION block"); + return 1; + } + + $this->log("✅ README.md version: {$version}"); + if ($dryRun) { + $this->log("🔍 DRY RUN — no files will be written"); + } + + // ── 2. Scan and update every tracked file ──────────────────────── + $this->processFiles($repoRoot, $version, $dryRun); + + // ── 3. Update composer.json ────────────────────────────────────── + $this->updateComposerJson($repoRoot, $version, $dryRun); + + // ── 4. Summary ─────────────────────────────────────────────────── + $count = count($this->updatedFiles); + if ($dryRun) { + $this->log("🔍 DRY RUN complete — {$count} file(s) would be updated"); + } else { + $this->log("✅ Updated {$count} file(s) to version {$version}"); + } + + foreach ($this->updatedFiles as $f) { + $this->log(" ✓ {$f}"); + } + + // ── 5. Create issue if mismatches remain (non-dry-run only) ────── + if (!$dryRun && $createIssue && !empty($repo)) { + $remaining = $this->countRemainingMismatches($repoRoot, $version); + if ($remaining > 0) { + $this->log("⚠ {$remaining} version reference(s) could not be auto-updated"); + $this->createGitHubIssue($repo, $version, $remaining); + } + } + + return empty($this->errors) ? 0 : 1; + } + + // ──────────────────────────────────────────────────────────────────── + // Version extraction + // ──────────────────────────────────────────────────────────────────── + + /** + * Extract the VERSION value from the FILE INFORMATION block in README.md. + * + * Handles both indented (` VERSION: X`) and unindented (`VERSION: X`) forms. + * + * @param string $path Full path to README.md + * @return string|null Version string (e.g. "04.00.04"), or null if not found + */ + private function extractVersionFromReadme(string $path): ?string + { + $content = file_get_contents($path); + if ($content === false) { + return null; + } + // Match "VERSION: XX.YY.ZZ" allowing leading whitespace/tab + if (preg_match('/^\s*VERSION:\s*([0-9]{2}\.[0-9]{2}\.[0-9]{2})\s*$/m', $content, $m)) { + return $m[1]; + } + return null; + } + + // ──────────────────────────────────────────────────────────────────── + // File processing + // ──────────────────────────────────────────────────────────────────── + + /** + * Walk the repository tree and update every eligible file. + * + * @param string $repoRoot Absolute path to repository root + * @param string $version Target version string + * @param bool $dryRun If true, compute but do not write changes + */ + private function processFiles(string $repoRoot, string $version, bool $dryRun): void + { + $extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'ps1', 'py', 'tf']; + $excludeDirs = ['vendor', '.git', 'node_modules', 'logs']; + + $iterator = new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator( + $repoRoot, + RecursiveDirectoryIterator::SKIP_DOTS + ), + function (\SplFileInfo $fi) use ($excludeDirs): bool { + if ($fi->isDir()) { + return !in_array($fi->getFilename(), $excludeDirs, true); + } + return true; + } + ) + ); + + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + if (!$file->isFile()) { + continue; + } + + $ext = strtolower($file->getExtension()); + // Strip .template suffix for extension matching + if ($ext === 'template') { + $inner = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION)); + if (in_array($inner, $extensions, true)) { + $ext = $inner; + } else { + continue; + } + } elseif (!in_array($ext, $extensions, true)) { + continue; + } + + $this->processFile($file->getPathname(), $repoRoot, $version, $dryRun, $ext); + } + } + + /** + * Apply version replacements to a single file. + * + * @param string $path Absolute file path + * @param string $repoRoot Repository root (for display) + * @param string $version Target version + * @param bool $dryRun If true, do not write + * @param string $ext Canonical extension (without .template) + */ + private function processFile( + string $path, + string $repoRoot, + string $version, + bool $dryRun, + string $ext + ): void { + $original = file_get_contents($path); + if ($original === false) { + $this->errors[] = "Cannot read: {$path}"; + return; + } + + $updated = $original; + + // ── Badge replacement (all file types) ─────────────────────────── + // shields.io badge: [![MokoStandards](...badge/MokoStandards-XX.YY.ZZ-color)] + $updated = preg_replace( + '/(\[!\[MokoStandards\]\(https:\/\/img\.shields\.io\/badge\/MokoStandards-)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(-[a-z]+\)\])/', + '${1}' . $version . '${2}', + $updated + ); + // Plain text version badge: [VERSION: XX.YY.ZZ] + $updated = preg_replace( + '/\[VERSION:\s*[0-9]{2}\.[0-9]{2}\.[0-9]{2}\]/', + '[VERSION: ' . $version . ']', + $updated + ); + + // ── FILE INFORMATION VERSION replacement ────────────────────────── + // Markdown inside : VERSION: OLD or VERSION: OLD + if ($ext === 'md') { + $updated = preg_replace( + '/^(\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', + '${1}' . $version . '${2}', + $updated + ); + } + + // PHP inside /** */ or /* */: * VERSION: OLD + if ($ext === 'php') { + $updated = preg_replace( + '/^(\s*\*\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', + '${1}' . $version . '${2}', + $updated + ); + + // PHP class VERSION constants: + // private const VERSION = '04.06.00'; + // public const VERSION = '04.06.00'; + // private const VERSION = '04.06.00'; + $updated = preg_replace( + '/((?:private|public|protected)\s+const\s+VERSION\s*=\s*[\'"])[0-9]{2}\.[0-9]{2}\.[0-9]{2}([\'"])/', + '${1}' . $version . '${2}', + $updated + ); + + // composer.json "version" field (handled separately for JSON files) + } + + // YAML / Shell / PowerShell / Python: # VERSION: OLD + if (in_array($ext, ['yml', 'yaml', 'sh', 'ps1', 'py'], true)) { + $updated = preg_replace( + '/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', + '${1}' . $version . '${2}', + $updated + ); + } + + // Terraform (.tf / .tf.template) — three locations: + // 1. # VERSION: OLD (hash-comment header, template-style files) + // 2. * Version: OLD (block-comment header, definition files) + // 3. version = "OLD" (HCL metadata field) + if ($ext === 'tf') { + $updated = preg_replace( + '/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', + '${1}' . $version . '${2}', + $updated + ); + $updated = preg_replace( + '/^(\s*\*\s*Version:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', + '${1}' . $version . '${2}', + $updated + ); + $updated = preg_replace( + '/^(\s*version\s*=\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}("\s*)$/m', + '${1}' . $version . '${2}', + $updated + ); + } + + if ($updated === $original) { + return; // Nothing to change + } + + $rel = ltrim(str_replace($repoRoot, '', $path), '/'); + + if (!$dryRun) { + if (file_put_contents($path, $updated) === false) { + $this->errors[] = "Cannot write: {$path}"; + return; + } + } + + $this->updatedFiles[] = $rel; + } + + /** + * Update the "version" key in composer.json if it exists. + * + * @param string $repoRoot Repository root + * @param string $version Target version + * @param bool $dryRun If true, do not write + */ + private function updateComposerJson(string $repoRoot, string $version, bool $dryRun): void + { + $path = $repoRoot . '/composer.json'; + if (!file_exists($path)) { + return; + } + + $content = file_get_contents($path); + if ($content === false) { + return; + } + + $updated = preg_replace( + '/("version"\s*:\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}(")/m', + '${1}' . $version . '${2}', + $content + ); + + if ($updated === $content) { + return; + } + + if (!$dryRun) { + file_put_contents($path, $updated); + } + + $this->updatedFiles[] = 'composer.json'; + } + + // ──────────────────────────────────────────────────────────────────── + // Drift detection + // ──────────────────────────────────────────────────────────────────── + + /** + * Count FILE INFORMATION VERSION lines that still differ from $version. + * + * @param string $repoRoot Repository root + * @param string $version Expected version + * @return int Number of remaining mismatches + */ + private function countRemainingMismatches(string $repoRoot, string $version): int + { + $escaped = preg_quote($version, '/'); + $count = 0; + $versionRe = '/VERSION:\s*(?!' . $escaped . ')[0-9]{2}\.[0-9]{2}\.[0-9]{2}/'; + + $extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'tf']; + $excludeDirs = ['vendor', '.git', 'node_modules', 'logs']; + + $iterator = new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator($repoRoot, RecursiveDirectoryIterator::SKIP_DOTS), + function (\SplFileInfo $fi) use ($excludeDirs): bool { + return !($fi->isDir() && in_array($fi->getFilename(), $excludeDirs, true)); + } + ) + ); + + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + if (!$file->isFile()) { + continue; + } + $ext = strtolower($file->getExtension()); + if ($ext === 'template') { + $ext = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION)); + } + if (!in_array($ext, $extensions, true)) { + continue; + } + $content = file_get_contents($file->getPathname()); + if ($content !== false && preg_match($versionRe, $content)) { + $count++; + } + } + + return $count; + } + + // ──────────────────────────────────────────────────────────────────── + // GitHub issue creation + // ──────────────────────────────────────────────────────────────────── + + /** + * Create or update a GitHub issue listing files that could not be auto-updated. + * + * @param string $repo owner/repo + * @param string $version Expected version + * @param int $remaining Number of remaining mismatches + */ + private function createGitHubIssue(string $repo, string $version, int $remaining): void + { + $token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN'); + if (empty($token)) { + $this->error('GH_TOKEN or GITHUB_TOKEN required to create issue'); + return; + } + + $this->apiClient ??= new ApiClient($token, ['base_url' => 'https://api.github.com']); + + $title = "⚠️ Version drift: {$remaining} file(s) not updated to {$version}"; + $labels = ['version-drift', 'maintenance', 'type: chore', 'automation']; + $body = implode("\n", [ + "## ⚠️ Version Sync: {$remaining} file(s) could not be auto-updated", + "", + "**Target version:** `{$version}` (from README.md)", + "", + "After the automatic version propagation run, **{$remaining}** file(s) still contain", + "a VERSION field that does not match the README.md version.", + "", + "### How to fix", + "", + "1. Run the sync script locally:", + " ```bash", + " php api/maintenance/update_version_from_readme.php --path . --dry-run", + " php api/maintenance/update_version_from_readme.php --path .", + " ```", + "2. Inspect any files still flagged — they may use a non-standard VERSION format.", + "3. Update them manually to match `VERSION: {$version}`.", + "4. Commit and push — this issue will be closed automatically on the next successful sync.", + "", + "---", + "*Automatically created by [update_version_from_readme.php](api/maintenance/update_version_from_readme.php)*", + ]); + + try { + // Check for an existing version-drift issue to avoid duplicates + $existing = $this->apiClient->get("/repos/{$repo}/issues", [ + 'labels' => 'version-drift', + 'state' => 'all', + 'per_page' => 1, + 'sort' => 'created', + 'direction' => 'desc', + ]); + + if (!empty($existing[0]['number'])) { + $num = (int) $existing[0]['number']; + $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller-moko']]; + if (($existing[0]['state'] ?? 'open') === 'closed') { + $patch['state'] = 'open'; + } + $this->apiClient->patch("/repos/{$repo}/issues/{$num}", $patch); + try { + $this->apiClient->post("/repos/{$repo}/issues/{$num}/labels", ['labels' => $labels]); + } catch (\Exception $le) { /* non-fatal */ } + $this->log("✅ Updated issue #{$num} in {$repo}"); + } else { + $issue = $this->apiClient->post("/repos/{$repo}/issues", [ + 'title' => $title, + 'body' => $body, + 'labels' => $labels, + 'assignees' => ['jmiller-moko'], + ]); + $this->log('✅ Created issue #' . ($issue['number'] ?? '?') . " in {$repo}"); + } + } catch (\Exception $e) { + $this->error('Failed to create/update issue: ' . $e->getMessage()); + } + } +} + +$script = new UpdateVersionFromReadme(); +exit($script->execute()); diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..56c0662 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,54 @@ + + + + PHP_CodeSniffer configuration for MokoStandards projects + + + api/src + api/tests + + + */vendor/* + */node_modules/* + */.git/* + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..bbaf11b --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,35 @@ +# Copyright (C) 2026 Moko Consulting +# +# This file is part of a Moko Consulting project. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# PHPStan configuration for MokoStandards projects +parameters: + level: 5 + paths: + - api/src + - api/tests + excludePaths: + - vendor + - node_modules + + # Report unknown classes and functions + reportUnmatchedIgnoredErrors: false + + # Check for dead code + checkMissingIterableValueType: false + checkGenericClassInNonGenericObjectType: false + + # Additional checks + checkAlwaysTrueCheckTypeFunctionCall: true + checkAlwaysTrueInstanceof: true + checkAlwaysTrueStrictComparison: true + checkExplicitMixedMissingReturn: true + checkFunctionNameCase: true + checkInternalClassCaseSensitivity: true + + # Ignore common patterns + ignoreErrors: + # Add project-specific ignores here + # - '#Call to an undefined method#' diff --git a/plugin_health_check.php b/plugin_health_check.php new file mode 100755 index 0000000..7dba7ab --- /dev/null +++ b/plugin_health_check.php @@ -0,0 +1,287 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Plugin + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/plugin_health_check.php + * VERSION: 04.06.00 + * BRIEF: Run health checks on a project using the auto-detected or specified plugin + */ + +declare(strict_types=1); + +// Autoload dependencies +require_once __DIR__ . '/../vendor/autoload.php'; + +use MokoEnterprise\PluginFactory; +use MokoEnterprise\AuditLogger; +use MokoEnterprise\MetricsCollector; + +/** + * Display usage information + */ +function showUsage(): void +{ + echo << null, + 'project_type' => null, + 'config_file' => null, + 'json_output' => true, + 'verbose' => false, + 'help' => false, + ]; + + for ($i = 1; $i < count($argv); $i++) { + switch ($argv[$i]) { + case '--project-path': + $options['project_path'] = $argv[++$i] ?? null; + break; + case '--project-type': + $options['project_type'] = $argv[++$i] ?? null; + break; + case '--config': + $options['config_file'] = $argv[++$i] ?? null; + break; + case '--json': + $options['json_output'] = true; + break; + case '--verbose': + $options['verbose'] = true; + break; + case '--help': + case '-h': + $options['help'] = true; + break; + default: + fwrite(STDERR, "Unknown option: {$argv[$i]}\n"); + exit(2); + } + } + + return $options; +} + +/** + * Load project configuration from file + */ +function loadConfig(?string $configFile): array +{ + if ($configFile === null || !file_exists($configFile)) { + return []; + } + + $content = file_get_contents($configFile); + $config = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + fwrite(STDERR, "Error parsing configuration file: " . json_last_error_msg() . "\n"); + exit(2); + } + + return $config; +} + +/** + * Output health check results + */ +function outputResults(array $result, bool $jsonOutput, bool $verbose): int +{ + if ($jsonOutput) { + echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; + } else { + echo "\n=== Project Health Check Results ===\n\n"; + echo "Project Type: " . ($result['project_type'] ?? 'Unknown') . "\n"; + echo "Project Path: " . ($result['project_path'] ?? 'Unknown') . "\n"; + echo "Status: " . ($result['healthy'] ? 'HEALTHY' : 'UNHEALTHY') . "\n"; + echo "Health Score: " . ($result['score'] ?? 0) . "/100\n\n"; + + if (!empty($result['issues'])) { + $critical = array_filter($result['issues'], fn($i) => ($i['severity'] ?? '') === 'critical'); + $warnings = array_filter($result['issues'], fn($i) => ($i['severity'] ?? '') === 'warning'); + $info = array_filter($result['issues'], fn($i) => ($i['severity'] ?? '') === 'info'); + + if (!empty($critical)) { + echo "CRITICAL ISSUES:\n"; + foreach ($critical as $issue) { + $msg = is_array($issue) ? ($issue['message'] ?? json_encode($issue)) : $issue; + $cat = is_array($issue) ? ($issue['category'] ?? '') : ''; + echo " ✗ {$msg}" . ($cat ? " [{$cat}]" : "") . "\n"; + } + echo "\n"; + } + + if (!empty($warnings)) { + echo "WARNINGS:\n"; + foreach ($warnings as $issue) { + $msg = is_array($issue) ? ($issue['message'] ?? json_encode($issue)) : $issue; + $cat = is_array($issue) ? ($issue['category'] ?? '') : ''; + echo " ⚠ {$msg}" . ($cat ? " [{$cat}]" : "") . "\n"; + } + echo "\n"; + } + + if ($verbose && !empty($info)) { + echo "INFORMATION:\n"; + foreach ($info as $issue) { + $msg = is_array($issue) ? ($issue['message'] ?? json_encode($issue)) : $issue; + echo " ℹ {$msg}\n"; + } + echo "\n"; + } + } else { + echo "✓ No issues found! Project is healthy.\n\n"; + } + + if ($verbose && !empty($result['details'])) { + echo "DETAILS:\n"; + print_r($result['details']); + } + } + + return $result['healthy'] ? 0 : 1; +} + +/** + * Main execution + */ +function main(array $argv): int +{ + $options = parseArguments($argv); + + if ($options['help']) { + showUsage(); + return 0; + } + + // Validate required arguments + if ($options['project_path'] === null) { + fwrite(STDERR, "Error: --project-path is required\n\n"); + showUsage(); + return 2; + } + + $projectPath = realpath($options['project_path']); + if ($projectPath === false || !is_dir($projectPath)) { + fwrite(STDERR, "Error: Project path does not exist or is not a directory: {$options['project_path']}\n"); + return 2; + } + + // Load configuration + $projectConfig = loadConfig($options['config_file']); + + try { + // Create factory and plugin + $logger = new AuditLogger('plugin_health_check'); + $metricsCollector = new MetricsCollector(); + $factory = new PluginFactory($logger, $metricsCollector); + + // Get the appropriate plugin + if ($options['project_type'] !== null) { + $plugin = $factory->create($options['project_type']); + $projectType = $options['project_type']; + } else { + $plugin = $factory->createForProject($projectPath); + $projectType = $plugin ? $plugin->getProjectType() : null; + } + + if ($plugin === null) { + $error = $options['project_type'] !== null + ? "Plugin not found for project type: {$options['project_type']}" + : "Could not auto-detect project type for: {$projectPath}"; + + $result = [ + 'healthy' => false, + 'project_path' => $projectPath, + 'project_type' => $projectType, + 'score' => 0, + 'issues' => [ + [ + 'severity' => 'critical', + 'category' => 'plugin', + 'message' => $error, + ], + ], + 'timestamp' => date('c'), + ]; + + outputResults($result, $options['json_output'], $options['verbose']); + return 2; + } + + // Run health check + $health = $plugin->healthCheck($projectPath, $projectConfig); + + // Prepare result + $result = [ + 'healthy' => $health['healthy'] ?? false, + 'project_type' => $projectType, + 'project_path' => $projectPath, + 'plugin_name' => $plugin->getPluginName(), + 'plugin_version' => $plugin->getPluginVersion(), + 'score' => $health['score'] ?? 0, + 'issues' => $health['issues'] ?? [], + 'timestamp' => date('c'), + ]; + + if ($options['verbose']) { + $result['details'] = $health; + } + + return outputResults($result, $options['json_output'], $options['verbose']); + + } catch (\Exception $e) { + fwrite(STDERR, "Error: " . $e->getMessage() . "\n"); + if ($options['verbose']) { + fwrite(STDERR, $e->getTraceAsString() . "\n"); + } + return 2; + } +} + +// Execute +exit(main($argv)); diff --git a/plugin_list.php b/plugin_list.php new file mode 100755 index 0000000..3a56b22 --- /dev/null +++ b/plugin_list.php @@ -0,0 +1,292 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Plugin + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/plugin_list.php + * VERSION: 04.06.00 + * BRIEF: List all available project-type plugins and their capabilities + */ + +declare(strict_types=1); + +// Autoload dependencies +require_once __DIR__ . '/../vendor/autoload.php'; + +use MokoEnterprise\PluginFactory; +use MokoEnterprise\AuditLogger; +use MokoEnterprise\MetricsCollector; + +/** + * Display usage information + */ +function showUsage(): void +{ + echo << 'table', + 'type' => null, + 'details' => false, + 'help' => false, + ]; + + for ($i = 1; $i < count($argv); $i++) { + switch ($argv[$i]) { + case '--format': + $options['format'] = $argv[++$i] ?? 'table'; + break; + case '--type': + $options['type'] = $argv[++$i] ?? null; + break; + case '--details': + $options['details'] = true; + break; + case '--help': + case '-h': + $options['help'] = true; + break; + default: + fwrite(STDERR, "Unknown option: {$argv[$i]}\n"); + exit(1); + } + } + + return $options; +} + +/** + * Output as JSON + */ +function outputJson(array $data): void +{ + echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; +} + +/** + * Output as table + */ +function outputTable(array $plugins, bool $details): void +{ + echo "\n=== Available Plugins ===\n\n"; + echo sprintf("%-20s %-30s %-12s %s\n", "Type", "Name", "Version", "Description"); + echo str_repeat("-", 120) . "\n"; + + foreach ($plugins as $type => $info) { + $name = $info['name'] ?? 'Unknown'; + $version = $info['version'] ?? 'Unknown'; + $description = isset($info['description']) + ? (strlen($info['description']) > 45 + ? substr($info['description'], 0, 42) . "..." + : $info['description']) + : '-'; + + echo sprintf("%-20s %-30s %-12s %s\n", $type, $name, $version, $description); + + if ($details && !empty($info['required_files'])) { + echo " Required Files: " . implode(', ', array_slice($info['required_files'], 0, 3)); + if (count($info['required_files']) > 3) { + echo " (+" . (count($info['required_files']) - 3) . " more)"; + } + echo "\n"; + } + + if ($details && !empty($info['features'])) { + echo " Features: " . implode(', ', $info['features']) . "\n"; + } + } + + echo "\nTotal plugins: " . count($plugins) . "\n\n"; +} + +/** + * Output as simple list + */ +function outputSimple(array $plugins): void +{ + foreach ($plugins as $type => $info) { + echo $type . "\n"; + } +} + +/** + * Output plugin details + */ +function outputDetails(string $type, array $info): void +{ + echo "\n=== Plugin Details: {$type} ===\n\n"; + + foreach ($info as $key => $value) { + $displayKey = ucfirst(str_replace('_', ' ', $key)); + + if (is_array($value)) { + echo "{$displayKey}:\n"; + if (empty($value)) { + echo " (none)\n"; + } else { + foreach ($value as $item) { + echo " - " . (is_array($item) ? json_encode($item) : $item) . "\n"; + } + } + } else { + echo "{$displayKey}: {$value}\n"; + } + } + echo "\n"; +} + +/** + * Get plugin information + */ +function getPluginInfo(object $plugin, bool $details): array +{ + $info = [ + 'type' => $plugin->getProjectType(), + 'name' => $plugin->getPluginName(), + 'version' => $plugin->getPluginVersion(), + ]; + + if ($details) { + $info['required_files'] = $plugin->getRequiredFiles(); + $info['recommended_files'] = $plugin->getRecommendedFiles(); + $info['commands'] = $plugin->getCommands(); + $info['best_practices_count'] = count($plugin->getBestPractices()); + + // Add a description based on plugin name + $descriptions = [ + 'joomla' => 'Joomla CMS projects and extensions', + 'wordpress' => 'WordPress themes and plugins', + 'nodejs' => 'Node.js applications and packages', + 'python' => 'Python applications and packages', + 'terraform' => 'Infrastructure as Code with Terraform', + 'mobile' => 'Mobile applications (iOS/Android)', + 'api' => 'REST API and GraphQL services', + 'dolibarr' => 'Dolibarr ERP/CRM modules', + 'documentation' => 'Documentation projects', + 'generic' => 'Generic project types', + ]; + + $info['description'] = $descriptions[$info['type']] ?? 'Project plugin'; + $info['features'] = [ + 'validation' => true, + 'health_check' => true, + 'metrics' => true, + 'readiness' => true, + ]; + } + + return $info; +} + +/** + * Main execution + */ +function main(array $argv): int +{ + $options = parseArguments($argv); + + if ($options['help']) { + showUsage(); + return 0; + } + + try { + // Create factory + $logger = new AuditLogger('plugin_list'); + $metricsCollector = new MetricsCollector(); + $factory = new PluginFactory($logger, $metricsCollector); + + // Get plugins + if ($options['type'] !== null) { + // Get specific plugin + $plugin = $factory->create($options['type']); + + if ($plugin === null) { + fwrite(STDERR, "Error: Plugin not found for type: {$options['type']}\n"); + return 1; + } + + $info = getPluginInfo($plugin, true); + + if ($options['format'] === 'json') { + outputJson([$options['type'] => $info]); + } else { + outputDetails($options['type'], $info); + } + } else { + // Get all plugins + $allPlugins = $factory->createAll(); + $pluginsInfo = []; + + foreach ($allPlugins as $type => $plugin) { + $pluginsInfo[$type] = getPluginInfo($plugin, $options['details']); + } + + // Sort by type + ksort($pluginsInfo); + + // Output based on format + switch ($options['format']) { + case 'json': + outputJson($pluginsInfo); + break; + case 'simple': + outputSimple($pluginsInfo); + break; + case 'table': + default: + outputTable($pluginsInfo, $options['details']); + break; + } + } + + return 0; + + } catch (\Exception $e) { + fwrite(STDERR, "Error: " . $e->getMessage() . "\n"); + return 1; + } +} + +// Execute +exit(main($argv)); diff --git a/plugin_metrics.php b/plugin_metrics.php new file mode 100755 index 0000000..b4d72bb --- /dev/null +++ b/plugin_metrics.php @@ -0,0 +1,317 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Plugin + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/plugin_metrics.php + * VERSION: 04.06.00 + * BRIEF: Collect project metrics using the auto-detected or specified plugin + */ + +declare(strict_types=1); + +// Autoload dependencies +require_once __DIR__ . '/../vendor/autoload.php'; + +use MokoEnterprise\PluginFactory; +use MokoEnterprise\AuditLogger; +use MokoEnterprise\MetricsCollector; + +/** + * Display usage information + */ +function showUsage(): void +{ + echo << null, + 'project_type' => null, + 'config_file' => null, + 'format' => 'json', + 'verbose' => false, + 'help' => false, + ]; + + for ($i = 1; $i < count($argv); $i++) { + switch ($argv[$i]) { + case '--project-path': + $options['project_path'] = $argv[++$i] ?? null; + break; + case '--project-type': + $options['project_type'] = $argv[++$i] ?? null; + break; + case '--config': + $options['config_file'] = $argv[++$i] ?? null; + break; + case '--format': + $options['format'] = $argv[++$i] ?? 'json'; + break; + case '--json': + $options['format'] = 'json'; + break; + case '--verbose': + $options['verbose'] = true; + break; + case '--help': + case '-h': + $options['help'] = true; + break; + default: + fwrite(STDERR, "Unknown option: {$argv[$i]}\n"); + exit(2); + } + } + + return $options; +} + +/** + * Load project configuration from file + */ +function loadConfig(?string $configFile): array +{ + if ($configFile === null || !file_exists($configFile)) { + return []; + } + + $content = file_get_contents($configFile); + $config = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + fwrite(STDERR, "Error parsing configuration file: " . json_last_error_msg() . "\n"); + exit(2); + } + + return $config; +} + +/** + * Output metrics as JSON + */ +function outputJson(array $result): void +{ + echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; +} + +/** + * Output metrics as table + */ +function outputTable(array $result): void +{ + echo "\n=== Project Metrics ===\n\n"; + echo "Project Type: " . ($result['project_type'] ?? 'Unknown') . "\n"; + echo "Project Path: " . ($result['project_path'] ?? 'Unknown') . "\n"; + echo "Collected At: " . ($result['timestamp'] ?? 'Unknown') . "\n\n"; + + if (!empty($result['metrics'])) { + $maxKeyLen = max(array_map('strlen', array_keys($result['metrics']))); + + foreach ($result['metrics'] as $key => $value) { + $paddedKey = str_pad($key, $maxKeyLen); + $displayValue = is_array($value) ? json_encode($value) : $value; + echo " {$paddedKey} : {$displayValue}\n"; + } + echo "\n"; + } else { + echo "No metrics collected.\n\n"; + } + + if (!empty($result['summary'])) { + echo "SUMMARY:\n"; + foreach ($result['summary'] as $key => $value) { + echo " {$key}: {$value}\n"; + } + echo "\n"; + } +} + +/** + * Output metrics as CSV + */ +function outputCsv(array $result): void +{ + // Header + echo "metric,value,category,timestamp\n"; + + // Metrics + $timestamp = $result['timestamp'] ?? date('c'); + if (!empty($result['metrics'])) { + foreach ($result['metrics'] as $key => $value) { + $category = 'general'; + if (strpos($key, '.') !== false) { + list($category, $key) = explode('.', $key, 2); + } + + $displayValue = is_array($value) ? json_encode($value) : $value; + // Escape quotes in CSV + $displayValue = str_replace('"', '""', (string)$displayValue); + echo "\"{$key}\",\"{$displayValue}\",\"{$category}\",\"{$timestamp}\"\n"; + } + } +} + +/** + * Output metrics results + */ +function outputResults(array $result, string $format): int +{ + switch ($format) { + case 'table': + outputTable($result); + break; + case 'csv': + outputCsv($result); + break; + case 'json': + default: + outputJson($result); + break; + } + + // Return 1 if there were errors + return !empty($result['error']) ? 1 : 0; +} + +/** + * Main execution + */ +function main(array $argv): int +{ + $options = parseArguments($argv); + + if ($options['help']) { + showUsage(); + return 0; + } + + // Validate required arguments + if ($options['project_path'] === null) { + fwrite(STDERR, "Error: --project-path is required\n\n"); + showUsage(); + return 2; + } + + $projectPath = realpath($options['project_path']); + if ($projectPath === false || !is_dir($projectPath)) { + fwrite(STDERR, "Error: Project path does not exist or is not a directory: {$options['project_path']}\n"); + return 2; + } + + // Load configuration + $projectConfig = loadConfig($options['config_file']); + + try { + // Create factory and plugin + $logger = new AuditLogger('plugin_metrics'); + $metricsCollector = new MetricsCollector(); + $factory = new PluginFactory($logger, $metricsCollector); + + // Get the appropriate plugin + if ($options['project_type'] !== null) { + $plugin = $factory->create($options['project_type']); + $projectType = $options['project_type']; + } else { + $plugin = $factory->createForProject($projectPath); + $projectType = $plugin ? $plugin->getProjectType() : null; + } + + if ($plugin === null) { + $error = $options['project_type'] !== null + ? "Plugin not found for project type: {$options['project_type']}" + : "Could not auto-detect project type for: {$projectPath}"; + + $result = [ + 'project_path' => $projectPath, + 'project_type' => $projectType, + 'error' => $error, + 'metrics' => [], + 'timestamp' => date('c'), + ]; + + outputResults($result, $options['format']); + return 2; + } + + // Collect metrics + $metrics = $plugin->collectMetrics($projectPath, $projectConfig); + + // Prepare result + $result = [ + 'project_type' => $projectType, + 'project_path' => $projectPath, + 'plugin_name' => $plugin->getPluginName(), + 'plugin_version' => $plugin->getPluginVersion(), + 'metrics' => $metrics['metrics'] ?? $metrics, + 'timestamp' => date('c'), + ]; + + if (!empty($metrics['summary'])) { + $result['summary'] = $metrics['summary']; + } + + if (!empty($metrics['error'])) { + $result['error'] = $metrics['error']; + } + + if ($options['verbose']) { + $result['details'] = $metrics; + } + + return outputResults($result, $options['format']); + + } catch (\Exception $e) { + fwrite(STDERR, "Error: " . $e->getMessage() . "\n"); + if ($options['verbose']) { + fwrite(STDERR, $e->getTraceAsString() . "\n"); + } + return 2; + } +} + +// Execute +exit(main($argv)); diff --git a/plugin_readiness.php b/plugin_readiness.php new file mode 100755 index 0000000..787b5b0 --- /dev/null +++ b/plugin_readiness.php @@ -0,0 +1,272 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Plugin + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/plugin_readiness.php + * VERSION: 04.06.00 + * BRIEF: Check release readiness of a project using the auto-detected or specified plugin + */ + +declare(strict_types=1); + +// Autoload dependencies +require_once __DIR__ . '/../vendor/autoload.php'; + +use MokoEnterprise\PluginFactory; +use MokoEnterprise\AuditLogger; +use MokoEnterprise\MetricsCollector; + +/** + * Display usage information + */ +function showUsage(): void +{ + echo << null, + 'project_type' => null, + 'config_file' => null, + 'json_output' => true, + 'verbose' => false, + 'help' => false, + ]; + + for ($i = 1; $i < count($argv); $i++) { + switch ($argv[$i]) { + case '--project-path': + $options['project_path'] = $argv[++$i] ?? null; + break; + case '--project-type': + $options['project_type'] = $argv[++$i] ?? null; + break; + case '--config': + $options['config_file'] = $argv[++$i] ?? null; + break; + case '--json': + $options['json_output'] = true; + break; + case '--verbose': + $options['verbose'] = true; + break; + case '--help': + case '-h': + $options['help'] = true; + break; + default: + fwrite(STDERR, "Unknown option: {$argv[$i]}\n"); + exit(2); + } + } + + return $options; +} + +/** + * Load project configuration from file + */ +function loadConfig(?string $configFile): array +{ + if ($configFile === null || !file_exists($configFile)) { + return []; + } + + $content = file_get_contents($configFile); + $config = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + fwrite(STDERR, "Error parsing configuration file: " . json_last_error_msg() . "\n"); + exit(2); + } + + return $config; +} + +/** + * Output readiness results + */ +function outputResults(array $result, bool $jsonOutput, bool $verbose): int +{ + if ($jsonOutput) { + echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; + } else { + echo "\n=== Project Readiness Check ===\n\n"; + echo "Project Type: " . ($result['project_type'] ?? 'Unknown') . "\n"; + echo "Project Path: " . ($result['project_path'] ?? 'Unknown') . "\n"; + echo "Status: " . ($result['ready'] ? 'READY' : 'NOT READY') . "\n"; + echo "Readiness Score: " . ($result['score'] ?? 0) . "/100\n\n"; + + if (!empty($result['blockers'])) { + echo "BLOCKERS (must fix before release):\n"; + foreach ($result['blockers'] as $blocker) { + $msg = is_array($blocker) ? ($blocker['message'] ?? json_encode($blocker)) : $blocker; + echo " ✗ {$msg}\n"; + } + echo "\n"; + } + + if (!empty($result['warnings'])) { + echo "WARNINGS (should fix before release):\n"; + foreach ($result['warnings'] as $warning) { + $msg = is_array($warning) ? ($warning['message'] ?? json_encode($warning)) : $warning; + echo " ⚠ {$msg}\n"; + } + echo "\n"; + } + + if (empty($result['blockers']) && empty($result['warnings'])) { + echo "✓ Project is ready for release!\n\n"; + } elseif (empty($result['blockers']) && !empty($result['warnings'])) { + echo "⚠ Project can be released but has warnings.\n\n"; + } else { + echo "✗ Project is NOT ready for release. Fix blockers first.\n\n"; + } + + if ($verbose && !empty($result['details'])) { + echo "DETAILS:\n"; + print_r($result['details']); + } + } + + return $result['ready'] ? 0 : 1; +} + +/** + * Main execution + */ +function main(array $argv): int +{ + $options = parseArguments($argv); + + if ($options['help']) { + showUsage(); + return 0; + } + + // Validate required arguments + if ($options['project_path'] === null) { + fwrite(STDERR, "Error: --project-path is required\n\n"); + showUsage(); + return 2; + } + + $projectPath = realpath($options['project_path']); + if ($projectPath === false || !is_dir($projectPath)) { + fwrite(STDERR, "Error: Project path does not exist or is not a directory: {$options['project_path']}\n"); + return 2; + } + + // Load configuration + $projectConfig = loadConfig($options['config_file']); + + try { + // Create factory and plugin + $logger = new AuditLogger('plugin_readiness'); + $metricsCollector = new MetricsCollector(); + $factory = new PluginFactory($logger, $metricsCollector); + + // Get the appropriate plugin + if ($options['project_type'] !== null) { + $plugin = $factory->create($options['project_type']); + $projectType = $options['project_type']; + } else { + $plugin = $factory->createForProject($projectPath); + $projectType = $plugin ? $plugin->getProjectType() : null; + } + + if ($plugin === null) { + $error = $options['project_type'] !== null + ? "Plugin not found for project type: {$options['project_type']}" + : "Could not auto-detect project type for: {$projectPath}"; + + $result = [ + 'ready' => false, + 'project_path' => $projectPath, + 'project_type' => $projectType, + 'blockers' => [$error], + 'warnings' => [], + 'score' => 0, + 'timestamp' => date('c'), + ]; + + outputResults($result, $options['json_output'], $options['verbose']); + return 2; + } + + // Check readiness + $readiness = $plugin->checkReadiness($projectPath, $projectConfig); + + // Prepare result + $result = [ + 'ready' => $readiness['ready'] ?? false, + 'project_type' => $projectType, + 'project_path' => $projectPath, + 'plugin_name' => $plugin->getPluginName(), + 'plugin_version' => $plugin->getPluginVersion(), + 'blockers' => $readiness['blockers'] ?? [], + 'warnings' => $readiness['warnings'] ?? [], + 'score' => $readiness['score'] ?? 0, + 'timestamp' => date('c'), + ]; + + if ($options['verbose']) { + $result['details'] = $readiness; + } + + return outputResults($result, $options['json_output'], $options['verbose']); + + } catch (\Exception $e) { + fwrite(STDERR, "Error: " . $e->getMessage() . "\n"); + if ($options['verbose']) { + fwrite(STDERR, $e->getTraceAsString() . "\n"); + } + return 2; + } +} + +// Execute +exit(main($argv)); diff --git a/plugin_validate.php b/plugin_validate.php new file mode 100755 index 0000000..792123a --- /dev/null +++ b/plugin_validate.php @@ -0,0 +1,266 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Plugin + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/plugin_validate.php + * VERSION: 04.06.00 + * BRIEF: Validate a project's structure and standards using the auto-detected or specified plugin + */ + +declare(strict_types=1); + +// Autoload dependencies +require_once __DIR__ . '/../vendor/autoload.php'; + +use MokoEnterprise\PluginFactory; +use MokoEnterprise\AuditLogger; +use MokoEnterprise\MetricsCollector; + +/** + * Display usage information + */ +function showUsage(): void +{ + echo << null, + 'project_type' => null, + 'config_file' => null, + 'json_output' => true, + 'verbose' => false, + 'help' => false, + ]; + + for ($i = 1; $i < count($argv); $i++) { + switch ($argv[$i]) { + case '--project-path': + $options['project_path'] = $argv[++$i] ?? null; + break; + case '--project-type': + $options['project_type'] = $argv[++$i] ?? null; + break; + case '--config': + $options['config_file'] = $argv[++$i] ?? null; + break; + case '--json': + $options['json_output'] = true; + break; + case '--verbose': + $options['verbose'] = true; + break; + case '--help': + case '-h': + $options['help'] = true; + break; + default: + fwrite(STDERR, "Unknown option: {$argv[$i]}\n"); + exit(2); + } + } + + return $options; +} + +/** + * Load project configuration from file + */ +function loadConfig(?string $configFile): array +{ + if ($configFile === null || !file_exists($configFile)) { + return []; + } + + $content = file_get_contents($configFile); + $config = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + fwrite(STDERR, "Error parsing configuration file: " . json_last_error_msg() . "\n"); + exit(2); + } + + return $config; +} + +/** + * Output validation results + */ +function outputResults(array $result, bool $jsonOutput, bool $verbose): int +{ + if ($jsonOutput) { + echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; + } else { + echo "\n=== Project Validation Results ===\n\n"; + echo "Project Type: " . ($result['project_type'] ?? 'Unknown') . "\n"; + echo "Project Path: " . ($result['project_path'] ?? 'Unknown') . "\n"; + echo "Status: " . ($result['valid'] ? 'VALID' : 'INVALID') . "\n"; + echo "Validation Score: " . ($result['score'] ?? 0) . "/100\n\n"; + + if (!empty($result['errors'])) { + echo "ERRORS:\n"; + foreach ($result['errors'] as $error) { + echo " ✗ " . (is_array($error) ? ($error['message'] ?? $error) : $error) . "\n"; + } + echo "\n"; + } + + if (!empty($result['warnings'])) { + echo "WARNINGS:\n"; + foreach ($result['warnings'] as $warning) { + echo " ⚠ " . (is_array($warning) ? ($warning['message'] ?? $warning) : $warning) . "\n"; + } + echo "\n"; + } + + if (empty($result['errors']) && empty($result['warnings'])) { + echo "✓ No issues found!\n\n"; + } + + if ($verbose && !empty($result['details'])) { + echo "DETAILS:\n"; + print_r($result['details']); + } + } + + return $result['valid'] ? 0 : 1; +} + +/** + * Main execution + */ +function main(array $argv): int +{ + $options = parseArguments($argv); + + if ($options['help']) { + showUsage(); + return 0; + } + + // Validate required arguments + if ($options['project_path'] === null) { + fwrite(STDERR, "Error: --project-path is required\n\n"); + showUsage(); + return 2; + } + + $projectPath = realpath($options['project_path']); + if ($projectPath === false || !is_dir($projectPath)) { + fwrite(STDERR, "Error: Project path does not exist or is not a directory: {$options['project_path']}\n"); + return 2; + } + + // Load configuration + $projectConfig = loadConfig($options['config_file']); + + try { + // Create factory and plugin + $logger = new AuditLogger('plugin_validate'); + $metricsCollector = new MetricsCollector(); + $factory = new PluginFactory($logger, $metricsCollector); + + // Get the appropriate plugin + if ($options['project_type'] !== null) { + $plugin = $factory->create($options['project_type']); + $projectType = $options['project_type']; + } else { + $plugin = $factory->createForProject($projectPath); + $projectType = $plugin ? $plugin->getProjectType() : null; + } + + if ($plugin === null) { + $error = $options['project_type'] !== null + ? "Plugin not found for project type: {$options['project_type']}" + : "Could not auto-detect project type for: {$projectPath}"; + + $result = [ + 'valid' => false, + 'project_path' => $projectPath, + 'project_type' => $projectType, + 'errors' => [$error], + 'warnings' => [], + 'score' => 0, + 'timestamp' => date('c'), + ]; + + outputResults($result, $options['json_output'], $options['verbose']); + return 2; + } + + // Run validation + $validation = $plugin->validateProject($projectConfig, $projectPath); + + // Prepare result + $result = [ + 'valid' => $validation['valid'] ?? false, + 'project_type' => $projectType, + 'project_path' => $projectPath, + 'plugin_name' => $plugin->getPluginName(), + 'plugin_version' => $plugin->getPluginVersion(), + 'errors' => $validation['errors'] ?? [], + 'warnings' => $validation['warnings'] ?? [], + 'score' => $validation['score'] ?? ($validation['valid'] ? 100 : 0), + 'timestamp' => date('c'), + ]; + + if ($options['verbose']) { + $result['details'] = $validation; + } + + return outputResults($result, $options['json_output'], $options['verbose']); + + } catch (\Exception $e) { + fwrite(STDERR, "Error: " . $e->getMessage() . "\n"); + if ($options['verbose']) { + fwrite(STDERR, $e->getTraceAsString() . "\n"); + } + return 2; + } +} + +// Execute +exit(main($argv)); diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..b9f6bfd --- /dev/null +++ b/psalm.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/run/index.md b/run/index.md new file mode 100644 index 0000000..9489d56 --- /dev/null +++ b/run/index.md @@ -0,0 +1,20 @@ +# Docs Index: /api/run + +## Purpose + +This index provides navigation to documentation within this folder. + +## Documents + +- [README](./README.md) + +## Metadata + +- **Document Type:** index +- **Auto-generated:** This file is automatically generated by rebuild_indexes.py + +## Revision History + +| Date | Author | Change | Notes | +| ---------- | ------------------ | ----------------- | ------------------------------------------ | +| Auto | rebuild_indexes.py | Automated update | Generated by documentation index automation | diff --git a/src/functions.php b/src/functions.php new file mode 100644 index 0000000..ab645eb --- /dev/null +++ b/src/functions.php @@ -0,0 +1,39 @@ + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * This file provides global helper functions for MokoStandards. + * + * @package MokoStandards + * @version 04.00.04 + */ + +declare(strict_types=1); + +if (!function_exists('mokostandards_version')) { + /** + * Get the MokoStandards version + * + * @return string Version number + */ + function mokostandards_version(): string + { + return '04.00.04'; + } +} + +if (!function_exists('mokostandards_root_dir')) { + /** + * Get the MokoStandards root directory + * + * @return string Root directory path + */ + function mokostandards_root_dir(): string + { + return dirname(__DIR__); + } +} diff --git a/tests/Enterprise/GitPlatformAdapterTest.php b/tests/Enterprise/GitPlatformAdapterTest.php new file mode 100644 index 0000000..3df3ad9 --- /dev/null +++ b/tests/Enterprise/GitPlatformAdapterTest.php @@ -0,0 +1,199 @@ + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Tests.Enterprise + * INGROUP: MokoStandards.Tests + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/tests/Enterprise/GitPlatformAdapterTest.php + * VERSION: 04.06.10 + * BRIEF: Tests verifying both adapters implement GitPlatformAdapter correctly + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../../vendor/autoload.php'; + +use MokoEnterprise\ApiClient; +use MokoEnterprise\Config; +use MokoEnterprise\GitPlatformAdapter; +use MokoEnterprise\GitHubAdapter; +use MokoEnterprise\GiteaAdapter; +use MokoEnterprise\PlatformAdapterFactory; + +echo "Testing GitPlatformAdapter Interface Compliance\n"; +echo str_repeat('=', 60) . "\n\n"; + +$passed = 0; +$failed = 0; + +function assert_true(bool $condition, string $label): void +{ + global $passed, $failed; + if ($condition) { + echo " ✓ {$label}\n"; + $passed++; + } else { + echo " ✗ FAILED: {$label}\n"; + $failed++; + } +} + +// ── Test 1: GitHubAdapter implements GitPlatformAdapter ───────────────── +echo "1. Testing GitHubAdapter interface compliance...\n"; + +$ghClient = new ApiClient( + baseUrl: 'https://api.github.com', + authToken: 'test-token', + enableCaching: false, + authScheme: 'Bearer' +); +$ghAdapter = new GitHubAdapter($ghClient); + +assert_true($ghAdapter instanceof GitPlatformAdapter, 'GitHubAdapter implements GitPlatformAdapter'); +assert_true($ghAdapter->getPlatformName() === 'github', 'getPlatformName() returns "github"'); +assert_true($ghAdapter->getBaseUrl() === 'https://api.github.com', 'getBaseUrl() returns GitHub API URL'); +assert_true($ghAdapter->getWorkflowDir() === '.github/workflows', 'getWorkflowDir() returns .github/workflows'); +assert_true($ghAdapter->getApiClient() === $ghClient, 'getApiClient() returns injected client'); +echo "\n"; + +// ── Test 2: GiteaAdapter implements GitPlatformAdapter ────────────────── +echo "2. Testing GiteaAdapter interface compliance...\n"; + +$giteaClient = new ApiClient( + baseUrl: 'https://git.mokoconsulting.tech/api/v1', + authToken: 'test-token', + enableCaching: false, + authScheme: 'token' +); +$giteaAdapter = new GiteaAdapter($giteaClient); + +assert_true($giteaAdapter instanceof GitPlatformAdapter, 'GiteaAdapter implements GitPlatformAdapter'); +assert_true($giteaAdapter->getPlatformName() === 'gitea', 'getPlatformName() returns "gitea"'); +assert_true($giteaAdapter->getBaseUrl() === 'https://git.mokoconsulting.tech/api/v1', 'getBaseUrl() returns Gitea API URL'); +assert_true($giteaAdapter->getWorkflowDir() === '.gitea/workflows', 'getWorkflowDir() returns .gitea/workflows'); +assert_true($giteaAdapter->getApiClient() === $giteaClient, 'getApiClient() returns injected client'); +echo "\n"; + +// ── Test 3: All interface methods exist on both adapters ──────────────── +echo "3. Testing all interface methods are implemented...\n"; + +$requiredMethods = [ + 'getPlatformName', 'getBaseUrl', 'getWorkflowDir', + 'listOrgRepos', 'getRepo', 'createOrgRepo', 'archiveRepo', + 'setRepoTopics', 'getRepoTopics', + 'getFileContents', 'createOrUpdateFile', 'deleteFile', + 'listPullRequests', 'createPullRequest', 'updatePullRequest', + 'listIssues', 'createIssue', 'addIssueComment', 'closeIssue', + 'listLabels', 'createLabel', 'addIssueLabels', + 'setBranchProtection', 'listBranchProtections', + 'resolveRef', 'getTree', + 'paginateAll', 'migrateRepository', 'getApiClient', +]; + +foreach ($requiredMethods as $method) { + $ghHas = method_exists($ghAdapter, $method); + $giteaHas = method_exists($giteaAdapter, $method); + + if ($ghHas && $giteaHas) { + $passed++; + } else { + echo " ✗ FAILED: {$method} — GitHub=" . ($ghHas ? 'yes' : 'NO') . " Gitea=" . ($giteaHas ? 'yes' : 'NO') . "\n"; + $failed++; + } +} +echo " ✓ All " . count($requiredMethods) . " interface methods implemented on both adapters\n\n"; + +// ── Test 4: PlatformAdapterFactory ────────────────────────────────────── +echo "4. Testing PlatformAdapterFactory...\n"; + +// Test GitHub creation +putenv('GH_TOKEN=test-github-token'); +putenv('GIT_PLATFORM=github'); +$config = Config::load(); +$config->set('github.token', 'test-github-token'); +try { + $adapter = PlatformAdapterFactory::create($config, 'github'); + assert_true($adapter instanceof GitHubAdapter, 'Factory creates GitHubAdapter for platform=github'); + assert_true($adapter->getPlatformName() === 'github', 'Created adapter identifies as github'); +} catch (\Exception $e) { + assert_true(false, 'Factory creates GitHubAdapter: ' . $e->getMessage()); +} + +// Test Gitea creation +$config->set('gitea.token', 'test-gitea-token'); +try { + $adapter = PlatformAdapterFactory::create($config, 'gitea'); + assert_true($adapter instanceof GiteaAdapter, 'Factory creates GiteaAdapter for platform=gitea'); + assert_true($adapter->getPlatformName() === 'gitea', 'Created adapter identifies as gitea'); +} catch (\Exception $e) { + assert_true(false, 'Factory creates GiteaAdapter: ' . $e->getMessage()); +} + +// Test invalid platform +try { + PlatformAdapterFactory::create($config, 'bitbucket'); + assert_true(false, 'Factory should throw for unsupported platform'); +} catch (\RuntimeException $e) { + assert_true(str_contains($e->getMessage(), 'Unsupported'), 'Factory throws RuntimeException for unsupported platform'); +} +echo "\n"; + +// ── Test 5: Config platform defaults ──────────────────────────────────── +echo "5. Testing Config platform configuration...\n"; + +$config = new Config([ + 'platform' => 'github', + 'github' => ['organization' => 'mokoconsulting-tech', 'rate_limit' => 5000], + 'gitea' => ['url' => 'https://git.mokoconsulting.tech', 'organization' => 'mokoconsulting-tech', 'rate_limit' => 5000], +]); + +assert_true($config->getString('platform') === 'github', 'Default platform is github'); +assert_true($config->getString('gitea.url') === 'https://git.mokoconsulting.tech', 'Gitea URL configured'); +assert_true($config->getString('gitea.organization') === 'mokoconsulting-tech', 'Gitea org configured'); +assert_true($config->getInt('gitea.rate_limit') === 5000, 'Gitea rate limit configured'); +echo "\n"; + +// ── Test 6: ApiClient auth scheme ─────────────────────────────────────── +echo "6. Testing ApiClient auth scheme parameter...\n"; + +$bearerClient = new ApiClient( + baseUrl: 'https://api.github.com', + authToken: 'test', + enableCaching: false, + authScheme: 'Bearer' +); +assert_true($bearerClient instanceof ApiClient, 'ApiClient accepts Bearer auth scheme'); + +$tokenClient = new ApiClient( + baseUrl: 'https://git.mokoconsulting.tech/api/v1', + authToken: 'test', + enableCaching: false, + authScheme: 'token' +); +assert_true($tokenClient instanceof ApiClient, 'ApiClient accepts token auth scheme'); +echo "\n"; + +// ── Test 7: GitHubAdapter migration throws ────────────────────────────── +echo "7. Testing platform-specific behavior...\n"; + +try { + $ghAdapter->migrateRepository([]); + assert_true(false, 'GitHubAdapter.migrateRepository() should throw'); +} catch (\RuntimeException $e) { + assert_true(true, 'GitHubAdapter.migrateRepository() throws RuntimeException'); +} + +// GiteaAdapter.migrateRepository() should NOT throw (it calls the API) +// We can't test it without a real server, but verify the method exists +assert_true(method_exists($giteaAdapter, 'migrateRepository'), 'GiteaAdapter.migrateRepository() exists'); +echo "\n"; + +// ── Summary ───────────────────────────────────────────────────────────── +echo str_repeat('=', 60) . "\n"; +echo "Results: {$passed} passed, {$failed} failed\n"; +echo str_repeat('=', 60) . "\n"; + +exit($failed > 0 ? 1 : 0); diff --git a/tests/index.md b/tests/index.md new file mode 100644 index 0000000..b50e6b4 --- /dev/null +++ b/tests/index.md @@ -0,0 +1,17 @@ +# Docs Index: /api/tests + +## Purpose + +This directory contains PHPUnit test suites and sample fixtures for the +`mokoconsulting/mokostandards` package. + +## Contents + +| File / Directory | Description | +|---|---| +| `test_circuit_breaker_handling.php` | Tests `CircuitBreakerOpen` and `RateLimitExceeded` exception handling in `ApiClient` | +| `test_enterprise_libraries.php` | Smoke-tests all Enterprise library classes (MetricsCollector, SecurityValidator, TransactionManager, UnifiedValidator, CliFramework) | + +## Related Documentation + +- [API Overview](../index.md) diff --git a/tests/test_circuit_breaker_handling.php b/tests/test_circuit_breaker_handling.php new file mode 100644 index 0000000..e266609 --- /dev/null +++ b/tests/test_circuit_breaker_handling.php @@ -0,0 +1,81 @@ +#!/usr/bin/env php +getMessage()}\n"; +} + +// Test 2: Verify RateLimitExceeded exception can be caught +echo "\n2. Testing RateLimitExceeded exception...\n"; +try { + throw new RateLimitExceeded("Rate limit exceeded. Wait 60 seconds."); +} catch (RateLimitExceeded $e) { + echo " ✓ RateLimitExceeded exception caught: {$e->getMessage()}\n"; +} + +// Test 3: Test circuit breaker with ApiClient +echo "\n3. Testing ApiClient circuit breaker...\n"; +$client = new ApiClient( + baseUrl: 'https://api.github.com', + authToken: 'fake_token_for_testing', + circuitBreakerThreshold: 2, // Low threshold for testing + circuitBreakerTimeout: 5 +); + +// Simulate failures to trip the circuit breaker +echo " Simulating failures to trip circuit breaker...\n"; +for ($i = 1; $i <= 3; $i++) { + try { + // This will fail due to invalid token + $client->get('/user'); + } catch (\Exception $e) { + echo " - Attempt {$i}: " . get_class($e) . "\n"; + } +} + +// Check circuit state +$state = $client->getCircuitState(); +echo " Circuit breaker state: {$state}\n"; + +if ($state === 'OPEN') { + echo " ✓ Circuit breaker correctly opened after failures\n"; +} else { + echo " ⚠️ Circuit breaker state: {$state} (expected OPEN)\n"; +} + +// Test 4: Verify multi-catch syntax works (PHP 7.1+) +echo "\n4. Testing multi-catch syntax...\n"; +try { + $random = rand(0, 1); + if ($random === 0) { + throw new CircuitBreakerOpen("Test circuit breaker"); + } else { + throw new RateLimitExceeded("Test rate limit"); + } +} catch (CircuitBreakerOpen | RateLimitExceeded $e) { + echo " ✓ Multi-catch works: " . get_class($e) . "\n"; +} + +echo "\n" . str_repeat('=', 60) . "\n"; +echo "✓ All Circuit Breaker Exception Handling Tests Passed!\n"; +echo str_repeat('=', 60) . "\n"; diff --git a/tests/test_enterprise_libraries.php b/tests/test_enterprise_libraries.php new file mode 100644 index 0000000..a157206 --- /dev/null +++ b/tests/test_enterprise_libraries.php @@ -0,0 +1,101 @@ +increment('requests_total'); +$metrics->increment('requests_total', 3); +$metrics->setGauge('cpu_usage', 45.5); +$timer = $metrics->startTimer('operation'); +usleep(100000); // 0.1 seconds +$timer->stop(); +$counter = $metrics->getCounter('requests_total'); +$gauge = $metrics->getGauge('cpu_usage'); +echo " ✓ Counter: {$counter}, Gauge: {$gauge}\n"; +echo " ✓ MetricsCollector v{$metrics->getVersion()} working!\n\n"; + +// Test 2: SecurityValidator +echo "2. Testing SecurityValidator...\n"; +$validator = new SecurityValidator(); +$testCode = 'password = "mysecret123"'; +file_put_contents('/tmp/test_security.php', "scanFile('/tmp/test_security.php'); +echo " ✓ Found " . count($findings) . " security findings\n"; +echo " ✓ SecurityValidator v{$validator->getVersion()} working!\n"; +unlink('/tmp/test_security.php'); +echo "\n"; + +// Test 3: TransactionManager +echo "3. Testing TransactionManager...\n"; +$state = ['value' => 0]; +$txn = new Transaction('test_transaction'); +try { + $txn->execute('step1', function() use (&$state) { + $state['value'] += 10; + return $state['value']; + }); + $txn->execute('step2', function() use (&$state) { + $state['value'] *= 2; + return $state['value']; + }); + $txn->commit(); + echo " ✓ Transaction committed. Final value: {$state['value']}\n"; +} catch (Exception $e) { + echo " ✗ Transaction failed: {$e->getMessage()}\n"; +} + +$manager = new TransactionManager(); +$stats = $manager->getStats(); +echo " ✓ TransactionManager working!\n\n"; + +// Test 4: UnifiedValidation +echo "4. Testing UnifiedValidation...\n"; +$unifiedValidator = new UnifiedValidator(); +$unifiedValidator->addPlugin(new PathValidatorPlugin()); +$context = ['paths' => ['/tmp', '/usr']]; +$results = $unifiedValidator->validateAll($context); +echo " ✓ Ran " . count($results) . " validation(s)\n"; +echo " ✓ All passed: " . ($unifiedValidator->allPassed() ? 'Yes' : 'No') . "\n"; +echo " ✓ UnifiedValidator v{$unifiedValidator->getVersion()} working!\n\n"; + +// Test 5: CliFramework +echo "5. Testing CliFramework...\n"; + +class TestCLI extends CLIApp { + protected function setupArguments(): array { + return ['name:' => 'Name to greet']; + } + + protected function run(): int { + $name = $this->getOption('name', 'World'); + echo " ✓ Hello, {$name}!\n"; + return 0; + } +} + +// Simulate CLI arguments +$_SERVER['argv'] = ['test', '--name=MokoStandards', '--quiet']; +$app = new TestCLI('test_cli', 'Test CLI App'); +$exitCode = $app->execute(); +echo " ✓ Exit code: {$exitCode}\n"; +echo " ✓ CliFramework v{$app->getVersion()} working!\n\n"; + +echo str_repeat('=', 60) . "\n"; +echo "✓ All 5 Enterprise Libraries Tested Successfully!\n"; +echo "Library Migration: 100% Complete (10/10)\n"; +echo str_repeat('=', 60) . "\n"; diff --git a/validate/.gitkeep b/validate/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/validate/SECURITY_SCANNING.md b/validate/SECURITY_SCANNING.md new file mode 100644 index 0000000..38e5154 --- /dev/null +++ b/validate/SECURITY_SCANNING.md @@ -0,0 +1,331 @@ +# Security Scanning Guide + +**Version**: 01.00.00 | **Status**: Active | **Last Updated**: 2026-01-28 + +## Overview + +This guide provides instructions for implementing and using the comprehensive security scanning infrastructure required for all MokoStandards repositories. + +## Quick Start + +### 1. Install CodeQL Workflow + +Copy the CodeQL workflow template to your repository: + +```bash +# From your repository root +mkdir -p .github/workflows +cp templates/workflows/codeql-analysis.yml.template .github/workflows/codeql-analysis.yml +``` + +### 2. Configure Languages + +Edit `.github/workflows/codeql-analysis.yml` and update the language matrix to match your repository: + +```yaml +matrix: + language: ['python'] # Adjust based on your codebase +``` + +**Supported languages**: `cpp`, `csharp`, `go`, `java`, `javascript`, `python`, `ruby` + +Note: The template currently includes Python. Adjust based on your repository contents. + +### 3. Validate Configuration + +Run the validation script to ensure your CodeQL configuration matches your codebase: + +```bash +python3 scripts/validate/validate_codeql_config.py --repo-path . +``` + +### 4. Run Complete Security Scan + +Execute the comprehensive security scan: + +```bash +python3 scripts/validate/security_scan.py +``` + +## Security Scan Components + +The security scanning infrastructure includes four main components: + +### 1. CodeQL Analysis + +**Purpose**: Static Application Security Testing (SAST) for code vulnerabilities + +**What it scans**: +- SQL injection vulnerabilities +- Cross-site scripting (XSS) +- Path traversal issues +- Command injection +- Authentication bypasses +- And 200+ other security patterns + +**How to run**: +- Automatically runs on push to main/dev/rc branches +- Runs on all pull requests +- Weekly scheduled scan on Mondays at 6:00 AM UTC +- Manual trigger via Actions tab + +### 2. Secret Scanning + +**Purpose**: Detect accidentally committed credentials and API keys + +**What it scans**: +- Private keys (RSA, DSA, EC, SSH) +- AWS access keys +- GitHub tokens +- Slack tokens +- Stripe API keys +- And other sensitive patterns + +**How to run**: +```bash +python3 scripts/validate/no_secrets.py . +``` + +### 3. Dependency Checking + +**Purpose**: Identify vulnerable third-party dependencies + +**What it checks**: +- Python packages (requirements.txt, pyproject.toml) +- JavaScript packages (package.json) +- PHP packages (composer.json) +- Ruby gems (Gemfile) +- Go modules (go.mod) + +**How to run** (requires pip-audit): +```bash +pip install pip-audit +pip-audit --desc +``` + +### 4. Configuration Validation + +**Purpose**: Ensure CodeQL configuration matches repository contents + +**What it validates**: +- CodeQL workflow exists +- Configured languages match source files +- No misconfigured languages that cause CI failures + +**How to run**: +```bash +python3 scripts/validate/validate_codeql_config.py +``` + +## Comprehensive Security Scan + +The `security_scan.py` script orchestrates all security checks: + +### Basic Usage + +```bash +# Scan current directory +python3 scripts/validate/security_scan.py + +# Scan specific repository +python3 scripts/validate/security_scan.py --repo-path /path/to/repo + +# Verbose output +python3 scripts/validate/security_scan.py --verbose + +# Generate JSON report +python3 scripts/validate/security_scan.py --json-output security-report.json +``` + +### Exit Codes + +- `0`: All scans passed (no critical issues) +- `1`: Security issues found + +### Report Format + +The script generates a comprehensive report showing: + +``` +====================================================================== +🛡️ SECURITY SCAN REPORT +====================================================================== + +Status: PASS/FAIL +Total Issues: X + Critical: X + High: X + +---------------------------------------------------------------------- +SCAN RESULTS +---------------------------------------------------------------------- + +✓ CODEQL: configured +✓ CONFIG: passed +✓ SECRETS: passed +✓ DEPENDENCIES: passed + +---------------------------------------------------------------------- +RECOMMENDATIONS +---------------------------------------------------------------------- +1. [Actionable recommendations if issues found] + +====================================================================== +``` + +## CI/CD Integration + +### GitHub Actions + +The security scanning workflows are automatically triggered: + +**CodeQL Analysis** (`.github/workflows/codeql-analysis.yml`): +- On push to main, dev/**, rc/**, version/** branches +- On pull requests to main, dev/**, rc/** branches +- Weekly schedule: Mondays at 6:00 AM UTC +- Manual workflow dispatch + +**Results**: Available in the Security tab → Code scanning alerts + +### Pre-commit Hook + +Add to `.git/hooks/pre-commit`: + +```bash +#!/bin/bash +# Run secret scanning before commit +python3 scripts/validate/no_secrets.py . || exit 1 +``` + +### Pre-push Hook + +Add to `.git/hooks/pre-push`: + +```bash +#!/bin/bash +# Run comprehensive security scan before push +python3 scripts/validate/security_scan.py || exit 1 +``` + +## Vulnerability Response + +When security issues are found: + +### Critical/High Severity + +1. **Immediate Action**: Stop and assess the issue +2. **Triage**: Within 4 hours +3. **Fix**: Within 7 days (critical) or 14 days (high) +4. **Notify**: Security owner and team + +### Medium/Low Severity + +1. **Triage**: Within 48 hours +2. **Fix**: Within 30 days (medium) or 60 days (low) +3. **Plan**: Include in next sprint/release + +### Dismissing Alerts + +Only dismiss alerts with: +- Clear justification +- Risk assessment +- Compensating controls documented +- Security owner approval + +## Troubleshooting + +### CodeQL: "No supported languages found" + +**Problem**: CodeQL workflow fails because configured languages don't exist in repository + +**Solution**: Run validation script and adjust language matrix: +```bash +python3 scripts/validate/validate_codeql_config.py +# Update .github/workflows/codeql-analysis.yml with detected languages +``` + +### Secret Scanner: False Positives + +**Problem**: Secret scanner flags example code or test data + +**Solution**: Add exclusion to `scripts/validate/no_secrets.py` or update patterns + +### Dependency Scanner: Tool Not Found + +**Problem**: `pip-audit` or other scanners not installed + +**Solution**: Install required tools: +```bash +pip install pip-audit +npm install -g npm-audit +``` + +## Best Practices + +### 1. Run Locally Before Pushing + +Always run security scans locally before pushing: +```bash +python3 scripts/validate/security_scan.py --verbose +``` + +### 2. Keep Dependencies Updated + +Regularly update dependencies: +```bash +# Python +pip list --outdated +pip-audit + +# JavaScript +npm audit +npm update +``` + +### 3. Review Security Alerts Weekly + +Check the Security tab weekly for new findings: +- GitHub → Security → Code scanning +- GitHub → Security → Dependabot + +### 4. Use Branch Protection + +Require security checks to pass before merge: +- Settings → Branches → Branch protection rules +- Enable "Require status checks to pass" +- Select: CodeQL, Dependency Review + +### 5. Rotate Secrets Immediately + +If secrets are detected: +1. Revoke exposed credentials immediately +2. Rotate all related secrets +3. Update applications using the credentials +4. Audit access logs for unauthorized use + +## Additional Resources + +- [Security Scanning Policy](../../docs/policy/security-scanning.md) +- [CodeQL Documentation](https://codeql.github.com/docs/) +- [GitHub Security Features](https://docs.github.com/en/code-security) +- [OWASP Top 10](https://owasp.org/www-project-top-ten/) + +## Support + +For questions or issues with security scanning: +- Open an issue in this repository +- Contact: security@mokoconsulting.tech +- Slack: #security channel + +--- + +**Metadata** + +| Field | Value | +|-------|-------| +| Document | Security Scanning Guide | +| Path | /api/validate/SECURITY_SCANNING.md | +| Version | 01.00.00 | +| Status | Active | +| Last Updated | 2026-01-28 | +| Owner | Moko Consulting | diff --git a/validate/auto_detect_platform.php b/validate/auto_detect_platform.php new file mode 100755 index 0000000..4c220a9 --- /dev/null +++ b/validate/auto_detect_platform.php @@ -0,0 +1,772 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/validate/auto_detect_platform.php + * VERSION: 04.06.00 + * BRIEF: Automatic platform detection and validation - PHP implementation + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\{ + CLIApp, + ProjectTypeDetector, + PluginFactory, + PluginRegistry, + AuditLogger, + MetricsCollector +}; + +/** + * Automatic Platform Detection and Validation + * + * Detects whether a repository is a Joomla/WaaS component, Dolibarr/CRM module, + * or generic repository, then validates against appropriate schema + */ +class AutoDetectPlatform extends CLIApp +{ + private const DETECTION_THRESHOLD = 0.5; // 50% confidence required + + private ProjectTypeDetector $typeDetector; + private PluginFactory $pluginFactory; + + private array $detectionResults = [ + 'joomla' => ['score' => 0, 'indicators' => []], + 'dolibarr' => ['score' => 0, 'indicators' => []], + 'nodejs' => ['score' => 0, 'indicators' => []], + 'python' => ['score' => 0, 'indicators' => []], + 'terraform' => ['score' => 0, 'indicators' => []], + 'wordpress' => ['score' => 0, 'indicators' => []], + 'mobile' => ['score' => 0, 'indicators' => []], + 'api' => ['score' => 0, 'indicators' => []], + 'documentation' => ['score' => 0, 'indicators' => []], + 'generic' => ['score' => 0, 'indicators' => []], + ]; + + private string $detectedPlatform = 'generic'; + private string $schemaFile = ''; + private ?object $detectedPlugin = null; + + protected function setupArguments(): array + { + return [ + 'repo-path:' => 'Path to repository to analyze (default: current directory)', + 'schema-dir:' => 'Path to schema definitions directory (default: api/definitions/default)', + 'output-dir:' => 'Directory for output reports (default: var/logs/validation)', + ]; + } + + protected function run(): int + { + $repoPath = $this->getOption('repo-path', '.'); + $schemaDir = $this->getOption('schema-dir', 'api/definitions/default'); + $outputDir = $this->getOption('output-dir', 'var/logs/validation'); + + // Make paths absolute + $repoPath = $this->getAbsolutePath($repoPath); + $schemaDir = $this->getAbsolutePath($schemaDir); + $outputDir = $this->getAbsolutePath($outputDir); + + if (!is_dir($repoPath)) { + $this->log("Repository path not found: {$repoPath}", 'ERROR'); + return 3; + } + + if (!is_dir($schemaDir)) { + $this->log("Schema directory not found: {$schemaDir}", 'ERROR'); + return 3; + } + + $this->log("Analyzing repository: {$repoPath}", 'INFO'); + + // Initialize plugin system + $logger = new AuditLogger('auto_detect_platform'); + $metrics = new MetricsCollector(); + $this->pluginFactory = new PluginFactory($logger, $metrics); + $this->typeDetector = new ProjectTypeDetector($logger); + + // Use the new plugin system for detection + $this->log("Using ProjectTypeDetector for platform detection", 'INFO'); + $detectionResult = $this->typeDetector->detectProjectType($repoPath); + + if (!empty($detectionResult['type'])) { + $this->detectedPlatform = $detectionResult['type']; + $this->log("Detected platform via plugin system: {$this->detectedPlatform}", 'INFO'); + + // Try to get the plugin for this type + $this->detectedPlugin = $this->pluginFactory->createForProject($repoPath); + + if ($this->detectedPlugin) { + $this->log("Loaded plugin: {$this->detectedPlugin->getPluginName()}", 'INFO'); + + // Update detection results with plugin info + $this->detectionResults[$this->detectedPlatform] = [ + 'score' => $detectionResult['confidence'] ?? 1.0, + 'indicators' => $detectionResult['indicators'] ?? [], + ]; + } + } else { + // Fallback to legacy detection if plugin system doesn't detect anything + $this->log("Plugin system did not detect type, using legacy detection", 'WARNING'); + + // Run platform detection using legacy methods + $this->detectJoomla($repoPath); + $this->detectDolibarr($repoPath); + $this->detectNodeJS($repoPath); + $this->detectPython($repoPath); + $this->detectTerraform($repoPath); + $this->detectWordPress($repoPath); + $this->detectMobile($repoPath); + $this->detectAPI($repoPath); + + // Determine platform + $this->determinePlatform(); + } + + // Map to schema file + $this->schemaFile = $this->mapPlatformToSchema($schemaDir); + + if (!file_exists($this->schemaFile)) { + $this->log("Schema file not found: {$this->schemaFile}", 'ERROR'); + return 3; + } + + // Output results + if ($this->jsonOutput) { + $this->outputJson(); + } else { + $this->displayResults(); + } + + // Generate reports + $this->generateReports($outputDir, $repoPath); + + $this->log("Platform detection completed: {$this->detectedPlatform}", 'INFO'); + $this->log("Schema file: {$this->schemaFile}", 'INFO'); + + if ($this->detectedPlugin) { + $this->log("Plugin available for validation and health checks", 'INFO'); + } + + return 0; + } + + private function detectJoomla(string $repoPath): void + { + $score = 0; + $indicators = []; + + // Look for Joomla manifest files + $manifests = $this->findFiles($repoPath, '*.xml', 3); + foreach ($manifests as $manifest) { + $content = @file_get_contents($manifest); + if ($content && ( + strpos($content, 'findFiles($repoPath, 'index.html', 2)); + if ($indexCount > 2) { + $score += 0.2; + $indicators[] = "Found {$indexCount} index.html files (Joomla pattern)"; + } + + $this->detectionResults['joomla'] = [ + 'score' => min(1.0, $score), + 'indicators' => $indicators, + ]; + } + + private function detectDolibarr(string $repoPath): void + { + $score = 0; + $indicators = []; + + // Look for Dolibarr module descriptor + $descriptors = $this->findFiles($repoPath, 'mod*.class.php', 3); + foreach ($descriptors as $descriptor) { + $content = @file_get_contents($descriptor); + if ($content && strpos($content, 'DolibarrModules') !== false) { + $score += 0.4; + $indicators[] = "Found Dolibarr module descriptor: " . basename($descriptor); + } + } + + // Check for Dolibarr-specific code patterns + $phpFiles = $this->findFiles($repoPath, '*.php', 3); + $dolibarrPatterns = ['dol_include_once', '$this->numero', 'DoliDB', 'Translate']; + + foreach ($phpFiles as $file) { + $content = @file_get_contents($file); + if (!$content) continue; + + foreach ($dolibarrPatterns as $pattern) { + if (strpos($content, $pattern) !== false) { + $score += 0.05; + $indicators[] = "Found Dolibarr pattern '{$pattern}' in " . basename($file); + break; // Only count once per file + } + } + + if ($score >= 0.8) break; // Stop early if confident + } + + // Check for Dolibarr directory structure + $dolibarrDirs = ['core/modules', 'sql', 'class', 'lib', 'langs']; + foreach ($dolibarrDirs as $dir) { + if (is_dir("{$repoPath}/{$dir}")) { + $score += 0.1; + $indicators[] = "Found Dolibarr directory: {$dir}/"; + } + } + + // Check for SQL files in sql/ directory + if (is_dir("{$repoPath}/sql")) { + $sqlFiles = $this->findFiles("{$repoPath}/sql", '*.sql', 1); + if (count($sqlFiles) > 0) { + $score += 0.1; + $indicators[] = "Found " . count($sqlFiles) . " SQL files in sql/"; + } + } + + $this->detectionResults['dolibarr'] = [ + 'score' => min(1.0, $score), + 'indicators' => $indicators, + ]; + } + + private function detectNodeJS(string $repoPath): void + { + $score = 0; + $indicators = []; + + // Check for package.json + if (file_exists("{$repoPath}/package.json")) { + $score += 0.5; + $indicators[] = "Found package.json"; + + $content = @file_get_contents("{$repoPath}/package.json"); + if ($content) { + if (strpos($content, '"typescript"') !== false || strpos($content, '"@types/') !== false) { + $score += 0.1; + $indicators[] = "TypeScript dependencies detected"; + } + if (strpos($content, '"react"') !== false || strpos($content, '"vue"') !== false || + strpos($content, '"angular"') !== false || strpos($content, '"express"') !== false) { + $score += 0.1; + $indicators[] = "Node.js framework detected"; + } + } + } + + // Check for node_modules and lock files + if (is_dir("{$repoPath}/node_modules")) { + $score += 0.1; + $indicators[] = "Found node_modules directory"; + } + + if (file_exists("{$repoPath}/package-lock.json") || file_exists("{$repoPath}/yarn.lock") || + file_exists("{$repoPath}/pnpm-lock.yaml") || file_exists("{$repoPath}/bun.lockb")) { + $score += 0.1; + $indicators[] = "Found package lock file"; + } + + // Check for TypeScript config + if (file_exists("{$repoPath}/tsconfig.json")) { + $score += 0.2; + $indicators[] = "Found tsconfig.json"; + } + + $this->detectionResults['nodejs'] = [ + 'score' => min(1.0, $score), + 'indicators' => $indicators, + ]; + } + + private function detectPython(string $repoPath): void + { + $score = 0; + $indicators = []; + + // Check for Python package files + if (file_exists("{$repoPath}/setup.py") || file_exists("{$repoPath}/pyproject.toml")) { + $score += 0.5; + $indicators[] = "Found Python package configuration"; + } + + if (file_exists("{$repoPath}/requirements.txt")) { + $score += 0.2; + $indicators[] = "Found requirements.txt"; + } + + if (file_exists("{$repoPath}/Pipfile") || file_exists("{$repoPath}/poetry.lock")) { + $score += 0.2; + $indicators[] = "Found Python dependency manager config"; + } + + // Check for Python files + $pyFiles = $this->findFiles($repoPath, '*.py', 2); + if (count($pyFiles) > 0) { + $score += 0.2; + $indicators[] = "Found " . count($pyFiles) . " Python files"; + } + + // Check for virtual environment directories + $venvDirs = ['venv', '.venv', 'env', '.env']; + foreach ($venvDirs as $dir) { + if (is_dir("{$repoPath}/{$dir}")) { + $score += 0.05; + $indicators[] = "Found virtual environment: {$dir}/"; + break; + } + } + + $this->detectionResults['python'] = [ + 'score' => min(1.0, $score), + 'indicators' => $indicators, + ]; + } + + private function detectTerraform(string $repoPath): void + { + $score = 0; + $indicators = []; + + // Check for Terraform files + $tfFiles = $this->findFiles($repoPath, '*.tf', 3); + if (count($tfFiles) > 0) { + $score += 0.5; + $indicators[] = "Found " . count($tfFiles) . " Terraform files"; + } + + // Check for terraform.tfvars or *.tfvars + $tfvarsFiles = $this->findFiles($repoPath, '*.tfvars', 2); + if (count($tfvarsFiles) > 0) { + $score += 0.2; + $indicators[] = "Found Terraform variables files"; + } + + // Check for .terraform directory + if (is_dir("{$repoPath}/.terraform")) { + $score += 0.1; + $indicators[] = "Found .terraform directory"; + } + + // Check for terraform.lock.hcl + if (file_exists("{$repoPath}/.terraform.lock.hcl")) { + $score += 0.1; + $indicators[] = "Found Terraform lock file"; + } + + // Check for main.tf, variables.tf, outputs.tf (common pattern) + $commonFiles = ['main.tf', 'variables.tf', 'outputs.tf']; + $foundCommon = 0; + foreach ($commonFiles as $file) { + if (file_exists("{$repoPath}/{$file}")) { + $foundCommon++; + } + } + if ($foundCommon >= 2) { + $score += 0.2; + $indicators[] = "Found standard Terraform structure"; + } + + $this->detectionResults['terraform'] = [ + 'score' => min(1.0, $score), + 'indicators' => $indicators, + ]; + } + + private function detectWordPress(string $repoPath): void + { + $score = 0; + $indicators = []; + + // Check for plugin header + $phpFiles = $this->findFiles($repoPath, '*.php', 2); + foreach ($phpFiles as $file) { + $content = @file_get_contents($file); + if ($content && (strpos($content, 'Plugin Name:') !== false || + strpos($content, 'Theme Name:') !== false)) { + $score += 0.5; + $indicators[] = "Found WordPress plugin/theme header in " . basename($file); + break; + } + } + + // Check for WordPress functions + $wpFunctions = ['add_action', 'add_filter', 'wp_enqueue_script', 'register_activation_hook']; + foreach ($phpFiles as $file) { + $content = @file_get_contents($file); + if (!$content) continue; + + foreach ($wpFunctions as $func) { + if (strpos($content, $func) !== false) { + $score += 0.1; + $indicators[] = "Found WordPress function '{$func}'"; + break 2; + } + } + } + + // Check for WordPress directory structure + $wpDirs = ['includes', 'templates', 'assets']; + foreach ($wpDirs as $dir) { + if (is_dir("{$repoPath}/{$dir}")) { + $score += 0.05; + $indicators[] = "Found WordPress directory: {$dir}/"; + } + } + + $this->detectionResults['wordpress'] = [ + 'score' => min(1.0, $score), + 'indicators' => $indicators, + ]; + } + + private function detectMobile(string $repoPath): void + { + $score = 0; + $indicators = []; + + // Check for React Native + if (file_exists("{$repoPath}/package.json")) { + $content = @file_get_contents("{$repoPath}/package.json"); + if ($content && strpos($content, '"react-native"') !== false) { + $score += 0.5; + $indicators[] = "Found React Native in package.json"; + } + } + + // Check for Flutter + if (file_exists("{$repoPath}/pubspec.yaml")) { + $content = @file_get_contents("{$repoPath}/pubspec.yaml"); + if ($content && strpos($content, 'flutter:') !== false) { + $score += 0.5; + $indicators[] = "Found Flutter in pubspec.yaml"; + } + } + + // Check for iOS project + $xcodeFiles = $this->findFiles($repoPath, '*.xcodeproj', 2); + if (count($xcodeFiles) > 0) { + $score += 0.3; + $indicators[] = "Found Xcode project"; + } + + // Check for Android project + if (file_exists("{$repoPath}/build.gradle") || file_exists("{$repoPath}/app/build.gradle")) { + $content = @file_get_contents("{$repoPath}/build.gradle") ?: @file_get_contents("{$repoPath}/app/build.gradle"); + if ($content && strpos($content, 'com.android.application') !== false) { + $score += 0.3; + $indicators[] = "Found Android application gradle"; + } + } + + // Check for mobile directories + $mobileDirs = ['ios', 'android', 'lib']; + $foundCount = 0; + foreach ($mobileDirs as $dir) { + if (is_dir("{$repoPath}/{$dir}")) { + $foundCount++; + } + } + if ($foundCount >= 2) { + $score += 0.2; + $indicators[] = "Found mobile platform directories"; + } + + $this->detectionResults['mobile'] = [ + 'score' => min(1.0, $score), + 'indicators' => $indicators, + ]; + } + + private function detectAPI(string $repoPath): void + { + $score = 0; + $indicators = []; + + // Check for API documentation files + $apiDocs = ['openapi.yaml', 'openapi.json', 'swagger.yaml', 'swagger.json', 'api.yaml']; + foreach ($apiDocs as $doc) { + if (file_exists("{$repoPath}/{$doc}")) { + $score += 0.3; + $indicators[] = "Found API documentation: {$doc}"; + break; + } + } + + // Check for GraphQL schema + $graphqlFiles = $this->findFiles($repoPath, '*.graphql', 2); + if (count($graphqlFiles) > 0 || file_exists("{$repoPath}/schema.graphql")) { + $score += 0.3; + $indicators[] = "Found GraphQL schema"; + } + + // Check for gRPC proto files + $protoFiles = $this->findFiles($repoPath, '*.proto', 2); + if (count($protoFiles) > 0) { + $score += 0.3; + $indicators[] = "Found Protocol Buffer definitions"; + } + + // Check for Dockerfile (common in microservices) + if (file_exists("{$repoPath}/Dockerfile")) { + $score += 0.1; + $indicators[] = "Found Dockerfile"; + } + + // Check for docker-compose.yml + if (file_exists("{$repoPath}/docker-compose.yml") || file_exists("{$repoPath}/docker-compose.yaml")) { + $score += 0.1; + $indicators[] = "Found docker-compose configuration"; + } + + // Check for API patterns in code + $apiFiles = array_merge( + $this->findFiles($repoPath, '*.js', 2), + $this->findFiles($repoPath, '*.ts', 2), + $this->findFiles($repoPath, '*.py', 2) + ); + + $apiPatterns = [ + '@app.route' => 'Flask route', + '@api_view' => 'Django REST framework', + 'express()' => 'Express.js', + 'fastapi' => 'FastAPI', + '@Controller' => 'NestJS controller', + ]; + + foreach ($apiFiles as $file) { + $content = @file_get_contents($file); + if (!$content) continue; + + foreach ($apiPatterns as $pattern => $name) { + if (stripos($content, $pattern) !== false) { + $score += 0.2; + $indicators[] = "Found {$name} pattern"; + break 2; + } + } + } + + $this->detectionResults['api'] = [ + 'score' => min(1.0, $score), + 'indicators' => $indicators, + ]; + } + + private function determinePlatform(): void + { + // Find platform with highest score above threshold + $maxScore = 0; + $selectedPlatform = 'generic'; + + foreach ($this->detectionResults as $platform => $data) { + if ($data['score'] >= self::DETECTION_THRESHOLD && $data['score'] > $maxScore) { + $maxScore = $data['score']; + $selectedPlatform = $platform; + } + } + + $this->detectedPlatform = $selectedPlatform; + } + + private function mapPlatformToSchema(string $schemaDir): string + { + $mapping = [ + 'joomla' => 'waas-component.tf', + 'dolibarr' => 'crm-module.tf', + 'nodejs' => 'nodejs-repository.tf', + 'python' => 'python-repository.tf', + 'terraform' => 'terraform-repository.tf', + 'wordpress' => 'wordpress-repository.tf', + 'mobile' => 'mobile-app-repository.tf', + 'api' => 'api-repository.tf', + 'documentation' => 'documentation-repository.tf', + 'standards' => 'standards-repository.tf', + 'generic' => 'default-repository.tf', + ]; + + return $schemaDir . '/' . $mapping[$this->detectedPlatform]; + } + + private function displayResults(): void + { + echo "\n=== Platform Detection Results ===\n\n"; + + echo "Platform: {$this->detectedPlatform}\n"; + echo "Schema: {$this->schemaFile}\n\n"; + + echo "Detection Scores:\n"; + foreach ($this->detectionResults as $platform => $data) { + $percentage = round($data['score'] * 100, 1); + $status = ($data['score'] >= self::DETECTION_THRESHOLD) ? '✅' : '❌'; + echo sprintf(" %s %s: %.1f%%\n", $status, ucfirst($platform), $percentage); + } + + echo "\nDetection Indicators:\n"; + $indicators = $this->detectionResults[$this->detectedPlatform]['indicators']; + if (empty($indicators)) { + echo " No specific indicators found (generic repository)\n"; + } else { + foreach ($indicators as $indicator) { + echo " • {$indicator}\n"; + } + } + + echo "\n"; + } + + private function outputJson(): void + { + $output = [ + 'platform' => $this->detectedPlatform, + 'schema' => $this->schemaFile, + 'detection_results' => $this->detectionResults, + 'threshold' => self::DETECTION_THRESHOLD, + 'timestamp' => date('c'), + 'plugin_available' => $this->detectedPlugin !== null, + ]; + + if ($this->detectedPlugin) { + $output['plugin_info'] = [ + 'name' => $this->detectedPlugin->getPluginName(), + 'version' => $this->detectedPlugin->getPluginVersion(), + 'type' => $this->detectedPlugin->getProjectType(), + ]; + } + + echo json_encode($output, JSON_PRETTY_PRINT) . PHP_EOL; + } + + private function generateReports(string $outputDir, string $repoPath): void + { + // Ensure output directory exists + if (!is_dir($outputDir)) { + @mkdir($outputDir, 0755, true); + } + + $timestamp = date('Ymd_His'); + + // Generate detection report + $detectionReport = $outputDir . "/detection_report_{$timestamp}.md"; + $this->writeDetectionReport($detectionReport, $repoPath); + + // Generate summary report + $summaryReport = $outputDir . "/SUMMARY_{$timestamp}.md"; + $this->writeSummaryReport($summaryReport, $repoPath); + + $this->log("Reports generated in: {$outputDir}", 'INFO'); + } + + private function writeDetectionReport(string $file, string $repoPath): void + { + $content = "# Platform Detection Report\n\n"; + $content .= "**Generated**: " . date('Y-m-d H:i:s') . "\n"; + $content .= "**Repository**: {$repoPath}\n\n"; + + $content .= "## Detected Platform\n\n"; + $content .= "**Type**: " . strtoupper($this->detectedPlatform) . "\n"; + $content .= "**Confidence**: " . round($this->detectionResults[$this->detectedPlatform]['score'] * 100, 1) . "%\n"; + $content .= "**Schema**: {$this->schemaFile}\n\n"; + + $content .= "## Detection Indicators\n\n"; + foreach ($this->detectionResults[$this->detectedPlatform]['indicators'] as $indicator) { + $content .= "- {$indicator}\n"; + } + + $content .= "\n## All Platform Scores\n\n"; + foreach ($this->detectionResults as $platform => $data) { + $percentage = round($data['score'] * 100, 1); + $content .= "- **" . ucfirst($platform) . "**: {$percentage}%\n"; + } + + @file_put_contents($file, $content); + } + + private function writeSummaryReport(string $file, string $repoPath): void + { + $content = "# Platform Detection Summary\n\n"; + $content .= "| Property | Value |\n"; + $content .= "|----------|-------|\n"; + $content .= "| Repository | {$repoPath} |\n"; + $content .= "| Platform | " . strtoupper($this->detectedPlatform) . " |\n"; + $content .= "| Confidence | " . round($this->detectionResults[$this->detectedPlatform]['score'] * 100, 1) . "% |\n"; + $content .= "| Schema | " . basename($this->schemaFile) . " |\n"; + $content .= "| Timestamp | " . date('Y-m-d H:i:s') . " |\n\n"; + + $content .= "## Next Steps\n\n"; + $content .= "1. Review detection indicators\n"; + $content .= "2. Validate repository against schema: {$this->schemaFile}\n"; + $content .= "3. Address any validation errors or warnings\n"; + + @file_put_contents($file, $content); + } + + private function findFiles(string $dir, string $pattern, int $maxDepth = 1): array + { + $files = []; + $pattern = str_replace('*', '.*', $pattern); + $pattern = str_replace('.', '\.', $pattern); + + try { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + $iterator->setMaxDepth($maxDepth); + + foreach ($iterator as $file) { + if ($file->isFile() && preg_match("/{$pattern}$/", $file->getFilename())) { + $files[] = $file->getPathname(); + } + } + } catch (Exception $e) { + // Directory not accessible + } + + return $files; + } + + private function getAbsolutePath(string $path): string + { + if (strlen($path) > 0 && $path[0] === '/') { + return $path; + } + + return getcwd() . '/' . $path; + } +} + +// Run the application +$app = new AutoDetectPlatform('auto_detect_platform', 'Automatically detect platform type and validate repository'); +exit($app->execute()); diff --git a/validate/check_changelog.php b/validate/check_changelog.php new file mode 100644 index 0000000..1f144b4 --- /dev/null +++ b/validate/check_changelog.php @@ -0,0 +1,133 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/validate/check_changelog.php + * VERSION: 04.06.00 + * BRIEF: Validates CHANGELOG.md structure and format + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\CliFramework; + +/** + * Validates that CHANGELOG.md exists (in root, src/, or docs/) and follows Keep a Changelog format. + * + * By default passes as long as any ## [...] heading is present. + * Use --strict to also require an ## [Unreleased] section. + */ +class CheckChangelog extends CliFramework +{ + /** Directories searched for CHANGELOG.md, relative to --path (case-insensitive match). */ + private const SEARCH_DIRS = ['', 'src', 'docs']; + + /** + * Configure available arguments. + */ + protected function configure(): void + { + $this->setDescription('Validates CHANGELOG.md structure and format'); + $this->addArgument('--path', 'Repository path to check', '.'); + $this->addArgument('--strict', 'Also require an [Unreleased] section', false); + } + + /** + * Validate CHANGELOG.md. + * + * @return int Exit code: 0 on pass, 1 on failure. + */ + protected function run(): int + { + $path = rtrim($this->getArgument('--path'), '/\\'); + $strict = (bool) $this->getArgument('--strict'); + + $this->section('Checking CHANGELOG.md'); + + $found = $this->findChangelog($path); + + if ($found === null) { + $this->status(false, 'CHANGELOG.md found (checked root, src/, docs/)'); + $this->printSummary(0, 1, $this->elapsed()); + return 1; + } + + $rel = ltrim(str_replace(str_replace('\\', '/', $path), '', str_replace('\\', '/', $found)), '/'); + $this->status(true, "CHANGELOG.md found: {$rel}"); + + // Error if CHANGELOG exists at root AND in a subdirectory simultaneously + if ($rel !== 'CHANGELOG.md' && is_file($path . '/CHANGELOG.md')) { + $this->status(false, 'CHANGELOG.md duplicate: exists at root AND ' . dirname($rel)); + $this->printSummary(0, 1, $this->elapsed()); + return 1; + } + + $content = (string) file_get_contents($found); + $passed = 1; + $failed = 0; + + // Require Keep a Changelog format (any versioned heading) + if (preg_match('/^## \[/m', $content)) { + $this->status(true, 'Keep a Changelog format (## [...])'); + $passed++; + } else { + $this->status(false, 'Keep a Changelog format (## [...]) — no versioned headings found'); + $failed++; + } + + // --strict: also require an [Unreleased] section + if ($strict) { + if (preg_match('/^## \[Unreleased\]/mi', $content)) { + $this->status(true, '[Unreleased] section present'); + $passed++; + } else { + $this->status(false, '[Unreleased] section missing (required by --strict)'); + $failed++; + } + } + + $this->printSummary($passed, $failed, $this->elapsed()); + + return $failed > 0 ? 1 : 0; + } + + /** + * Find CHANGELOG.md case-insensitively in root, src/, or docs/. + * + * @param string $repoPath Absolute path to the repository root. + * @return string|null Absolute path to the found file, or null if not found. + */ + private function findChangelog(string $repoPath): ?string + { + foreach (self::SEARCH_DIRS as $sub) { + $dir = $sub === '' ? $repoPath : $repoPath . '/' . $sub; + if (!is_dir($dir)) { + continue; + } + $entries = @scandir($dir); + if ($entries === false) { + continue; + } + foreach ($entries as $entry) { + if (strcasecmp($entry, 'CHANGELOG.md') === 0 && is_file($dir . '/' . $entry)) { + return $dir . '/' . $entry; + } + } + } + + return null; + } +} + +$script = new CheckChangelog('check_changelog', 'Validates CHANGELOG.md structure and format'); +exit($script->execute()); diff --git a/validate/check_composer_deps.php b/validate/check_composer_deps.php new file mode 100644 index 0000000..10f7858 --- /dev/null +++ b/validate/check_composer_deps.php @@ -0,0 +1,186 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/validate/check_composer_deps.php + * VERSION: 04.06.00 + * BRIEF: Validate composer.json enterprise dependency across all governed repos + * + * USAGE + * php api/validate/check_composer_deps.php --repo MokoCRM # Single repo + * php api/validate/check_composer_deps.php --all # All repos + * php api/validate/check_composer_deps.php --all --json # JSON output + */ + +declare(strict_types=1); + +$allMode = in_array('--all', $argv); +$jsonOut = in_array('--json', $argv); + +$org = 'mokoconsulting-tech'; +$repoName = null; + +foreach ($argv as $i => $arg) { + if ($arg === '--repo' && isset($argv[$i + 1])) { $repoName = $argv[$i + 1]; } + if ($arg === '--org' && isset($argv[$i + 1])) { $org = $argv[$i + 1]; } +} + +if (!$repoName && !$allMode) { + fwrite(STDERR, "Usage: php check_composer_deps.php --repo | --all [--json]\n"); + exit(2); +} + +$token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN'); +if (empty($token)) { + fwrite(STDERR, "GH_TOKEN or GITHUB_TOKEN not set\n"); + exit(1); +} + +$EXPECTED_VERSION = '04.02.30'; +$EXPECTED_DEP = "dev-version/{$EXPECTED_VERSION}"; +$ENTERPRISE_PKG = 'mokoconsulting-tech/enterprise'; +$ALWAYS_EXCLUDE = ['MokoStandards', '.github-private']; + +/** + * GitHub REST API GET helper. + * + * @return array{int, array} + */ +function apiGet(string $path, string $token): array +{ + $ch = curl_init("https://api.github.com/{$path}"); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: token ' . $token, + 'User-Agent: MokoStandards-ComposerCheck', + 'Accept: application/vnd.github.v3+json', + ], + ]); + $body = (string) curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + return [$status, json_decode($body, true) ?? []]; +} + +/** + * Fetch and parse composer.json from a repo. + * + * @return array|null + */ +function fetchComposer(string $org, string $repo, string $token): ?array +{ + [$status, $data] = apiGet("repos/{$org}/{$repo}/contents/composer.json", $token); + if ($status !== 200 || empty($data['content'])) { return null; } + return json_decode(base64_decode($data['content']), true); +} + +// ── Build repo list ───────────────────────────────────────────────────── +$repos = []; +if ($allMode) { + echo "Fetching repositories from {$org}...\n"; + $page = 1; + do { + [$_, $batch] = apiGet("orgs/{$org}/repos?per_page=100&page={$page}&type=all", $token); + foreach ($batch as $r) { + if (!($r['archived'] ?? false) && !in_array($r['name'], $ALWAYS_EXCLUDE, true)) { + $repos[] = $r['name']; + } + } + $page++; + } while (count($batch) === 100); + sort($repos); + echo "Found " . count($repos) . " repositories\n\n"; +} else { + $repos = [$repoName]; +} + +// ── Check each repo ───────────────────────────────────────────────────── +$results = []; +$issueCount = 0; + +foreach ($repos as $repo) { + $result = [ + 'repo' => $repo, + 'has_composer' => false, + 'has_enterprise' => false, + 'version' => null, + 'version_ok' => false, + 'has_lock' => false, + 'issues' => [], + ]; + + $composer = fetchComposer($org, $repo, $token); + if ($composer === null) { + $result['issues'][] = 'No composer.json found'; + if (!$jsonOut) { echo "{$repo}: no composer.json\n"; } + $results[] = $result; + continue; + } + + $result['has_composer'] = true; + + // Check for enterprise dependency + $allDeps = array_merge($composer['require'] ?? [], $composer['require-dev'] ?? []); + + if (isset($allDeps[$ENTERPRISE_PKG])) { + $result['has_enterprise'] = true; + $result['version'] = $allDeps[$ENTERPRISE_PKG]; + + if ($allDeps[$ENTERPRISE_PKG] === $EXPECTED_DEP) { + $result['version_ok'] = true; + } else { + $result['issues'][] = "Version mismatch: {$allDeps[$ENTERPRISE_PKG]} (expected {$EXPECTED_DEP})"; + if ($allDeps[$ENTERPRISE_PKG] === 'dev-main') { + $result['issues'][] = 'STALE: pointing to dev-main instead of version branch'; + } + } + } else { + $result['issues'][] = 'Enterprise dependency not in require/require-dev'; + } + + // Check for composer.lock + [$lockStatus] = apiGet("repos/{$org}/{$repo}/contents/composer.lock", $token); + $result['has_lock'] = ($lockStatus === 200); + if (!$result['has_lock']) { + $result['issues'][] = 'No composer.lock committed'; + } + + if (!$jsonOut) { + if (empty($result['issues'])) { + echo "{$repo}: OK ({$result['version']})\n"; + } else { + foreach ($result['issues'] as $issue) { + echo "{$repo}: {$issue}\n"; + $issueCount++; + } + } + } + + $results[] = $result; +} + +// ── Output ────────────────────────────────────────────────────────────── +if ($jsonOut) { + echo json_encode($results, JSON_PRETTY_PRINT) . "\n"; +} else { + echo "\n" . str_repeat('-', 50) . "\n"; + $total = count($results); + $withDep = count(array_filter($results, fn($r) => $r['has_enterprise'])); + $ok = count(array_filter($results, fn($r) => $r['version_ok'])); + $stale = count(array_filter($results, fn($r) => $r['version'] === 'dev-main')); + + echo "Total: {$total} | With enterprise dep: {$withDep} | Correct version: {$ok}"; + if ($stale > 0) { echo " | Stale dev-main: {$stale}"; } + echo " | Issues: {$issueCount}\n"; +} + +exit($issueCount > 0 ? 1 : 0); diff --git a/validate/check_dolibarr_module.php b/validate/check_dolibarr_module.php new file mode 100644 index 0000000..bae3ac0 --- /dev/null +++ b/validate/check_dolibarr_module.php @@ -0,0 +1,96 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/validate/check_dolibarr_module.php + * VERSION: 04.06.00 + * BRIEF: Validates Dolibarr module directory structure + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\CliFramework; + +/** + * Validates the required directory structure of a Dolibarr module repository. + */ +class CheckDolibarrModule extends CliFramework +{ + /** + * Configure available arguments. + */ + protected function configure(): void + { + $this->setDescription('Validates Dolibarr module directory structure'); + $this->addArgument('--path', 'Repository path to check', '.'); + } + + /** + * Run the Dolibarr module validation. + * + * @return int Exit code: 0 on pass, 1 on failure. + */ + protected function run(): int + { + $path = $this->getArgument('--path'); + $passed = 0; + $failed = 0; + + $this->section('Checking directory structure'); + + if (!is_dir($path . '/src')) { + $this->status(false, 'src/ directory exists'); + $failed++; + } else { + $this->status(true, 'src/ directory exists'); + $passed++; + } + + if (!is_dir($path . '/src/core/modules')) { + $this->status(false, 'src/core/modules/ directory exists'); + $failed++; + } else { + $this->status(true, 'src/core/modules/ directory exists'); + $passed++; + } + + if (!is_dir($path . '/src/langs')) { + $this->warning('Missing suggested directory: src/langs/'); + } else { + $this->status(true, 'src/langs/ directory exists'); + $passed++; + } + + $this->section('Checking module descriptor'); + + $descriptors = glob($path . '/src/core/modules/mod*.class.php') ?: []; + if (empty($descriptors)) { + $this->status(false, 'Module descriptor found (mod*.class.php)'); + $failed++; + } else { + $this->status(true, 'Module descriptor found', basename($descriptors[0])); + $passed++; + } + + $this->printSummary($passed, $failed, $this->elapsed()); + + if ($failed > 0) { + return 1; + } + + return 0; + } +} + +$script = new CheckDolibarrModule('check_dolibarr_module', 'Validates Dolibarr module directory structure'); +exit($script->execute()); diff --git a/validate/check_enterprise_readiness.php b/validate/check_enterprise_readiness.php new file mode 100755 index 0000000..7843626 --- /dev/null +++ b/validate/check_enterprise_readiness.php @@ -0,0 +1,250 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/validate/check_enterprise_readiness.php + * VERSION: 04.06.00 + * BRIEF: Enterprise readiness checker - PHP implementation + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\{ + AuditLogger, + CliFramework, + SecurityValidator, + PluginFactory, + ProjectTypeDetector +}; + +/** + * Enterprise Readiness Checker + * + * Validates repository against enterprise standards + */ +class EnterpriseReadinessChecker extends CliFramework +{ + private AuditLogger $logger; + private SecurityValidator $securityValidator; + private PluginFactory $pluginFactory; + private ?object $projectPlugin = null; + + private array $results = []; + + protected function configure(): void + { + $this->setDescription('Check enterprise readiness compliance'); + $this->addArgument('--path', 'Repository path to check', '.'); + $this->addArgument('--strict', 'Fail on any non-compliance', false); + } + + protected function initialize(): void + { + parent::initialize(); + + $this->logger = new AuditLogger('enterprise_readiness'); + $this->securityValidator = new SecurityValidator(); + $metrics = new \MokoEnterprise\MetricsCollector(); + $this->pluginFactory = new PluginFactory($this->logger, $metrics); + + $this->log('Enterprise readiness checker initialized with plugin system'); + } + + protected function run(): int + { + $path = $this->getArgument('--path'); + $strict = $this->getArgument('--strict'); + + $this->section('Initialising'); + $this->log("Checking enterprise readiness: {$path}"); + + // Try to load the project plugin + $this->projectPlugin = $this->pluginFactory->createForProject($path); + + if ($this->projectPlugin) { + $pluginName = $this->projectPlugin->getPluginName(); + $projectType = $this->projectPlugin->getProjectType(); + $this->log("Using plugin: {$pluginName} for type: {$projectType}"); + + // Use plugin's readiness check if available + $pluginReadiness = $this->projectPlugin->checkReadiness($path, []); + + if (!empty($pluginReadiness)) { + $this->log("Plugin readiness check: " . ($pluginReadiness['ready'] ? 'READY' : 'NOT READY')); + $this->results['plugin_readiness'] = [ + 'passed' => $pluginReadiness['ready'], + 'score' => $pluginReadiness['score'] ?? 0, + 'blockers' => $pluginReadiness['blockers'] ?? [], + 'warnings' => $pluginReadiness['warnings'] ?? [], + ]; + } + } else { + $this->log("No plugin found, using generic readiness checks"); + } + + // Run standard enterprise checks (backwards compatible) + $this->section('Enterprise libraries'); + $this->checkEnterpriseLibraries($path); + $this->section('Monitoring & audit'); + $this->checkMonitoring($path); + $this->checkAuditLogging($path); + $this->section('Security compliance'); + $this->checkSecurityCompliance($path); + $this->section('Documentation'); + $this->checkDocumentation($path); + + // Display results using visual API + $this->section('Results'); + $passed = 0; + $failed = 0; + foreach ($this->results as $result) { + $this->status($result['passed'], $result['check'], $result['passed'] ? '' : $result['message']); + if ($result['passed']) { + $passed++; + } else { + $failed++; + } + } + + $this->printSummary($passed, $failed, $this->elapsed()); + + $failures = $failed; + + if ($strict && $failures > 0) { + $this->error("Enterprise readiness check failed (strict mode): {$failures} issues found"); + return 1; + } + + if ($failures > 0) { + $this->warning("{$failures} enterprise readiness issues found"); + } else { + $this->success('All enterprise readiness checks passed'); + } + + return 0; + } + + private function checkEnterpriseLibraries(string $path): void + { + $required = ['ApiClient', 'AuditLogger', 'Config', 'ErrorRecovery', 'MetricsCollector']; + + // Enterprise libs may live in vendor/ (Composer install) or api/lib/Enterprise/ (MokoStandards itself). + // A single vendor/ directory confirms the whole package is present — no need to check per-file. + $vendorPkg = "{$path}/vendor/mokoconsulting-tech/enterprise"; + $inVendor = is_dir($vendorPkg); + + foreach ($required as $library) { + $localFile = "{$path}/api/lib/Enterprise/{$library}.php"; + $found = $inVendor || file_exists($localFile); + $this->addResult( + "Enterprise library: {$library}", + $found, + "Missing enterprise library (not in vendor/mokoconsulting-tech/enterprise or api/lib/Enterprise/)" + ); + } + } + + private function checkMonitoring(string $path): void + { + // Check for metrics collection + $metricsDir = "{$path}/var/logs/metrics"; + $this->addResult( + 'Metrics directory configured', + is_dir($metricsDir) || !file_exists($path . '/composer.json'), + 'Metrics logging not configured' + ); + + // Check for monitoring documentation + $monitoringDocs = "{$path}/docs/monitoring"; + $this->addResult( + 'Monitoring documentation exists', + is_dir($monitoringDocs) || file_exists("{$path}/docs/monitoring.md"), + 'Monitoring documentation not found' + ); + } + + private function checkAuditLogging(string $path): void + { + $auditDir = "{$path}/var/logs/audit"; + $this->addResult( + 'Audit logging directory configured', + is_dir($auditDir) || !file_exists($path . '/composer.json'), + 'Audit logging not configured' + ); + } + + private function checkSecurityCompliance(string $path): void + { + // Check for security policy + $this->addResult( + 'Security policy exists', + file_exists("{$path}/SECURITY.md") || file_exists("{$path}/.github/SECURITY.md"), + 'SECURITY.md not found' + ); + + // Check for CodeQL configuration + $codeqlConfig = "{$path}/.github/codeql"; + $this->addResult( + 'CodeQL configured', + is_dir($codeqlConfig) || file_exists("{$path}/.github/codeql/codeql-config.yml"), + 'CodeQL not configured' + ); + + // Run security scan on PHP files + if (is_dir("{$path}/src")) { + $issues = $this->securityValidator->scanDirectory("{$path}/src", ['.php']); + $this->addResult( + 'No security vulnerabilities in source code', + empty($issues), + count($issues) . ' security issues found' + ); + } + } + + private function checkDocumentation(string $path): void + { + // Check for architecture documentation + $this->addResult( + 'Architecture documentation exists', + file_exists("{$path}/docs/architecture.md") || + file_exists("{$path}/docs/guide/architecture.md"), + 'Architecture documentation not found' + ); + + // Check for API documentation + $this->addResult( + 'API documentation exists', + file_exists("{$path}/docs/api.md") || is_dir("{$path}/docs/api"), + 'API documentation not found' + ); + } + + private function addResult(string $check, bool $passed, string $message): void + { + $this->results[] = [ + 'check' => $check, + 'passed' => $passed, + 'message' => $message, + ]; + } + + private function displayResults(): void + { + // Results are now displayed directly in run() using visual API methods. + } +} + +// Run the application +$app = new EnterpriseReadinessChecker(); +exit($app->execute($argv)); diff --git a/validate/check_joomla_manifest.php b/validate/check_joomla_manifest.php new file mode 100644 index 0000000..2874270 --- /dev/null +++ b/validate/check_joomla_manifest.php @@ -0,0 +1,91 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/validate/check_joomla_manifest.php + * VERSION: 04.06.00 + * BRIEF: Validates Joomla XML manifest structure and required elements + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\CliFramework; + +/** + * Validates that tracked XML files containing a Joomla element + * have the required child and the recommended child. + */ +class CheckJoomlaManifest extends CliFramework +{ + /** + * Configure available arguments. + */ + protected function configure(): void + { + $this->setDescription('Validates Joomla XML manifest structure'); + $this->addArgument('--path', 'Repository path to check', '.'); + } + + /** + * Validate all tracked XML manifests. + * + * @return int Exit code: 0 on pass, 1 on failure. + */ + protected function run(): int + { + $path = $this->getArgument('--path'); + $output = shell_exec('git -C ' . escapeshellarg($path) . " ls-files '*.xml' 2>/dev/null") ?? ''; + $files = array_filter(explode("\n", $output)); + $errors = 0; + $i = 0; + $total = count($files); + + $this->section('Scanning XML manifests'); + + foreach ($files as $file) { + $fullPath = $path . '/' . $file; + if (!is_file($fullPath)) { + continue; + } + if ($total >= 3) { + $this->progress(++$i, $total, basename((string) $file)); + } + $content = (string) file_get_contents($fullPath); + if (!str_contains($content, '')) { + $this->status(false, 'Missing ', (string) $file); + $errors++; + } + if (!str_contains($content, '')) { + $this->warning("Missing in: {$file}"); + } + } + if ($total >= 3) { + $this->progress($total, $total, 'done', true); + } + + if ($errors === 0) { + $this->status(true, 'Manifest validation passed'); + $this->printSummary(1, 0, $this->elapsed()); + return 0; + } + + $this->printSummary(0, $errors, $this->elapsed()); + return 1; + } +} + +$script = new CheckJoomlaManifest('check_joomla_manifest', 'Validates Joomla XML manifest structure'); +exit($script->execute()); diff --git a/validate/check_language_structure.php b/validate/check_language_structure.php new file mode 100644 index 0000000..77c0ec3 --- /dev/null +++ b/validate/check_language_structure.php @@ -0,0 +1,85 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/validate/check_language_structure.php + * VERSION: 04.06.00 + * BRIEF: Validates language INI file structure (KEY=value format) + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\CliFramework; + +/** + * Validates that all tracked INI language files follow the KEY=value format. + */ +class CheckLanguageStructure extends CliFramework +{ + /** + * Configure available arguments. + */ + protected function configure(): void + { + $this->setDescription('Validates language INI file structure'); + $this->addArgument('--path', 'Repository path to check', '.'); + } + + /** + * Validate language INI files. + * + * @return int Exit code: 0 on pass, 1 on failure. + */ + protected function run(): int + { + $path = $this->getArgument('--path'); + $output = shell_exec('git -C ' . escapeshellarg($path) . " ls-files '*.ini' 2>/dev/null") ?? ''; + $files = array_filter(explode("\n", $output)); + $errors = 0; + $i = 0; + $total = count($files); + + $this->section('Scanning INI language files'); + + foreach ($files as $file) { + $fullPath = $path . '/' . $file; + if (!is_file($fullPath)) { + continue; + } + if ($total >= 3) { + $this->progress(++$i, $total, basename((string) $file)); + } + $content = (string) file_get_contents($fullPath); + if (!preg_match('/^[A-Z_][A-Z0-9_]*=/m', $content)) { + $this->warning("Language file may have format issues: {$file}"); + $errors++; + } + } + if ($total >= 3) { + $this->progress($total, $total, 'done', true); + } + + if ($errors === 0) { + $this->status(true, 'Language file validation passed'); + $this->printSummary(1, 0, $this->elapsed()); + return 0; + } + + $this->status(false, 'Language file validation', "{$errors} file(s) with format issues"); + $this->printSummary(0, $errors, $this->elapsed()); + return 1; + } +} + +$script = new CheckLanguageStructure('check_language_structure', 'Validates language INI file structure'); +exit($script->execute()); diff --git a/validate/check_license_headers.php b/validate/check_license_headers.php new file mode 100644 index 0000000..2107120 --- /dev/null +++ b/validate/check_license_headers.php @@ -0,0 +1,96 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/validate/check_license_headers.php + * VERSION: 04.06.00 + * BRIEF: Validates SPDX license headers in source files (advisory) + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\CliFramework; + +/** + * Checks that tracked PHP, JS, CSS and Shell files contain an SPDX license identifier. + * + * This is an advisory check — always exits 0 regardless of findings. + */ +class CheckLicenseHeaders extends CliFramework +{ + /** + * Configure available arguments. + */ + protected function configure(): void + { + $this->setDescription('Validates SPDX license headers in source files (advisory)'); + $this->addArgument('--path', 'Repository path to check', '.'); + } + + /** + * Run the license-header check (advisory — always exits 0). + * + * @return int Exit code: always 0. + */ + protected function run(): int + { + $path = $this->getArgument('--path'); + $patterns = ['*.php', '*.js', '*.css', '*.sh']; + $quoted = implode(' ', array_map('escapeshellarg', $patterns)); + $output = shell_exec('git -C ' . escapeshellarg($path) . " ls-files {$quoted} 2>/dev/null") ?? ''; + $files = array_filter(explode("\n", $output)); + $missing = 0; + $i = 0; + $total = count($files); + + $this->section('Scanning license headers'); + + foreach ($files as $file) { + $fullPath = $path . '/' . $file; + if (!is_file($fullPath)) { + continue; + } + if ($total >= 3) { + $this->progress(++$i, $total, basename((string) $file)); + } + $handle = fopen($fullPath, 'r'); + if ($handle === false) { + continue; + } + $header = ''; + for ($j = 0; $j < 20 && !feof($handle); $j++) { + $header .= (string) fgets($handle); + } + fclose($handle); + if (!str_contains($header, 'SPDX-License-Identifier:')) { + $this->warning("Missing SPDX license identifier: {$file}"); + $missing++; + } + } + if ($total >= 3) { + $this->progress($total, $total, 'done', true); + } + + if ($missing === 0) { + $this->status(true, 'All source files have license headers'); + } else { + $this->status(false, 'Some files missing license headers (advisory)', "{$missing} file(s)"); + } + + $this->printSummary(max(0, $total - $missing), $missing, $this->elapsed()); + return 0; + } +} + +$script = new CheckLicenseHeaders('check_license_headers', 'Validates SPDX license headers in source files'); +exit($script->execute()); diff --git a/validate/check_no_secrets.php b/validate/check_no_secrets.php new file mode 100644 index 0000000..51ae451 --- /dev/null +++ b/validate/check_no_secrets.php @@ -0,0 +1,113 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/validate/check_no_secrets.php + * VERSION: 04.06.00 + * BRIEF: Checks for potential secrets in committed files (advisory) + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\CliFramework; + +/** + * Scans all tracked non-binary files for common secret patterns (advisory — always exits 0). + */ +class CheckNoSecrets extends CliFramework +{ + /** Regex matching suspicious key=value or key: value assignments. */ + private const SECRET_PATTERN = '/(password|api[_\-]?key|secret|token|private[_\-]?key)\s*[:=]\s*["\'][^"\']{8,}/i'; + + /** + * Substrings that mark a line as a known-safe false positive. + * Dolibarr CSRF token functions generate nonces at runtime — not credentials. + */ + private const SAFE_SUBSTRINGS = ['newToken()', 'checkToken()', 'currentToken()']; + + /** Binary file extensions to skip. */ + private const BINARY_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'zip', 'tar', 'gz']; + + /** + * Configure available arguments. + */ + protected function configure(): void + { + $this->setDescription('Checks for potential secrets in committed files (advisory)'); + $this->addArgument('--path', 'Repository path to check', '.'); + } + + /** + * Run the secrets scan (advisory — always exits 0). + * + * @return int Exit code: always 0. + */ + protected function run(): int + { + $path = $this->getArgument('--path'); + $output = shell_exec('git -C ' . escapeshellarg($path) . ' ls-files 2>/dev/null') ?? ''; + $all = array_values(array_filter(explode("\n", $output))); + $files = array_filter($all, function (string $f): bool { + return !in_array(strtolower(pathinfo($f, PATHINFO_EXTENSION)), self::BINARY_EXTENSIONS, true); + }); + $files = array_values($files); + $total = count($files); + $found = 0; + + $this->section('Scanning for secret patterns'); + + foreach ($files as $i => $file) { + $this->progress($i + 1, $total, $file); + $fullPath = $path . '/' . $file; + if (!is_file($fullPath)) { + continue; + } + $lines = explode("\n", (string) file_get_contents($fullPath)); + $flagged = false; + foreach ($lines as $line) { + if (!preg_match(self::SECRET_PATTERN, $line)) { + continue; + } + // Skip known-safe patterns (e.g. Dolibarr CSRF token functions) + $safe = false; + foreach (self::SAFE_SUBSTRINGS as $sub) { + if (str_contains($line, $sub)) { + $safe = true; + break; + } + } + if (!$safe) { + $flagged = true; + break; + } + } + if ($flagged) { + $this->progress($i + 1, $total, '', true); + $this->status(false, $file, 'potential secret pattern detected'); + $found++; + } + } + $this->progress($total, $total, '', true); + + $this->printSummary($total - $found, $found, $this->elapsed()); + + if ($found > 0) { + $this->log('WARNING', 'Advisory — review flagged files manually'); + } + + return 0; + } +} + +$script = new CheckNoSecrets('check_no_secrets', 'Checks for potential secrets in committed files'); +exit($script->execute()); diff --git a/validate/check_paths.php b/validate/check_paths.php new file mode 100644 index 0000000..6bff4b7 --- /dev/null +++ b/validate/check_paths.php @@ -0,0 +1,85 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/validate/check_paths.php + * VERSION: 04.06.00 + * BRIEF: Validates that path separators use forward slashes (advisory) + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\CliFramework; + +/** + * Warns when backslash characters that look like Windows path separators appear + * in XML, JSON, YAML and Markdown tracked files (advisory — always exits 0). + */ +class CheckPaths extends CliFramework +{ + /** + * Configure available arguments. + */ + protected function configure(): void + { + $this->setDescription('Validates that path separators use forward slashes (advisory)'); + $this->addArgument('--path', 'Repository path to check', '.'); + } + + /** + * Scan for backslash path separators (advisory — always exits 0). + * + * @return int Exit code: always 0. + */ + protected function run(): int + { + $path = $this->getArgument('--path'); + $patterns = ['*.xml', '*.json', '*.yml', '*.yaml', '*.md']; + $quoted = implode(' ', array_map('escapeshellarg', $patterns)); + $output = shell_exec('git -C ' . escapeshellarg($path) . " ls-files {$quoted} 2>/dev/null") ?? ''; + $files = array_values(array_filter(explode("\n", $output))); + $total = count($files); + $found = 0; + + $this->section('Scanning for backslash path separators'); + + foreach ($files as $i => $file) { + $this->progress($i + 1, $total, $file); + $fullPath = $path . '/' . $file; + if (!is_file($fullPath)) { + continue; + } + $content = (string) file_get_contents($fullPath); + if (preg_match('/\\\\\\\\/', $content)) { + $stripped = preg_replace('/\\\\(n|t|r|"|\\\\|namespace)/', '', $content); + if (preg_match('/\\\\\\\\/', (string) $stripped)) { + $this->progress($i + 1, $total, '', true); + $this->status(false, $file, 'backslash path separator detected'); + $found++; + } + } + } + $this->progress($total, $total, '', true); + + $this->printSummary($total - $found, $found, $this->elapsed()); + + if ($found > 0) { + $this->log('WARNING', 'Advisory — use forward slashes in path strings'); + } + + return 0; + } +} + +$script = new CheckPaths('check_paths', 'Validates that path separators use forward slashes'); +exit($script->execute()); diff --git a/validate/check_php_syntax.php b/validate/check_php_syntax.php new file mode 100644 index 0000000..6fd9ca4 --- /dev/null +++ b/validate/check_php_syntax.php @@ -0,0 +1,81 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/validate/check_php_syntax.php + * VERSION: 04.06.00 + * BRIEF: Validates PHP syntax for all tracked PHP files using php -l + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\CliFramework; + +/** + * Runs `php -l` against all tracked *.php files and reports any syntax errors. + */ +class CheckPhpSyntax extends CliFramework +{ + /** + * Configure available arguments. + */ + protected function configure(): void + { + $this->setDescription('Validates PHP syntax for all tracked PHP files'); + $this->addArgument('--path', 'Repository path to check', '.'); + } + + /** + * Check PHP syntax for all tracked PHP files. + * + * @return int Exit code: 0 if all files pass, 1 if any syntax errors found. + */ + protected function run(): int + { + $path = $this->getArgument('--path'); + $output = shell_exec('git -C ' . escapeshellarg($path) . " ls-files '*.php' 2>/dev/null") ?? ''; + $files = array_values(array_filter(explode("\n", $output))); + $total = count($files); + $passed = 0; + $errors = 0; + + $this->section('Checking PHP syntax'); + + foreach ($files as $i => $file) { + $this->progress($i + 1, $total, $file); + $fullPath = $path . '/' . $file; + if (!is_file($fullPath)) { + continue; + } + $out = []; + $code = 0; + exec('php -l ' . escapeshellarg($fullPath) . ' 2>&1', $out, $code); + if ($code !== 0) { + $this->progress($i + 1, $total, '', true); + $detail = implode(' ', array_slice($out, 0, 1)); + $this->status(false, $file, $detail); + $errors++; + } else { + $passed++; + } + } + $this->progress($total, $total, '', true); + + $this->printSummary($passed, $errors, $this->elapsed()); + + return $errors === 0 ? 0 : 1; + } +} + +$script = new CheckPhpSyntax('check_php_syntax', 'Validates PHP syntax for all tracked PHP files'); +exit($script->execute()); diff --git a/validate/check_repo_health.php b/validate/check_repo_health.php new file mode 100755 index 0000000..d39cc77 --- /dev/null +++ b/validate/check_repo_health.php @@ -0,0 +1,835 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/validate/check_repo_health.php + * VERSION: 04.06.00 + * BRIEF: Repository health checker - PHP implementation; includes deployment, secrets, and variables checks + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\{ + AuditLogger, + CliFramework, + MetricsCollector, + PluginFactory, + ProjectTypeDetector +}; + +/** + * Repository Health Checker + * + * Performs comprehensive repository health checks + */ +class RepoHealthChecker extends CliFramework +{ + private const DEFAULT_THRESHOLD = 70.0; + + /** Repos that are not Dolibarr modules — CUSTOM_FOLDER check is skipped for these. */ + private const CUSTOM_FOLDER_EXEMPT = ['MokoStandards', '.github-private']; + + private AuditLogger $logger; + private MetricsCollector $metrics; + private PluginFactory $pluginFactory; + private ?object $projectPlugin = null; + + private array $results = [ + 'categories' => [], + 'checks' => [], + 'score' => 0, + 'max_score' => 100, + 'percentage' => 0.0, + 'level' => 'unknown', + ]; + + protected function configure(): void + { + $this->setDescription('Check repository health and compliance'); + $this->addArgument('--path', 'Repository path to check', '.'); + $this->addArgument('--threshold', 'Minimum health threshold (%)', '70'); + $this->addArgument('--json', 'Output results as JSON', false); + $this->addArgument('--create-issue', 'Create GitHub issue with results', false); + $this->addArgument('--repo', 'Repository name (owner/repo)', ''); + } + + protected function initialize(): void + { + parent::initialize(); + + $this->logger = new AuditLogger('repo_health_checker'); + $this->metrics = new MetricsCollector(); + $this->pluginFactory = new PluginFactory($this->logger, $this->metrics); + + $this->log('Repository health checker initialized with plugin system'); + } + + protected function run(): int + { + $path = $this->getArgument('--path'); + $threshold = (float)$this->getArgument('--threshold'); + $jsonOutput = $this->getArgument('--json'); + $createIssue = $this->getArgument('--create-issue'); + $repo = $this->getArgument('--repo'); + + $this->log("Checking repository health: {$path}"); + + // Try to load the project plugin + $this->projectPlugin = $this->pluginFactory->createForProject($path); + + if ($this->projectPlugin) { + $pluginName = $this->projectPlugin->getPluginName(); + $projectType = $this->projectPlugin->getProjectType(); + $this->log("Using plugin: {$pluginName} for type: {$projectType}"); + + // Use plugin's health check if available + $pluginHealth = $this->projectPlugin->healthCheck($path, []); + + // Merge plugin health check results + if (!empty($pluginHealth)) { + $this->results['plugin_health'] = $pluginHealth; + $this->log("Plugin health check completed: {$pluginHealth['score']}/100"); + } + } else { + $this->log("No plugin found, using generic health checks"); + } + + // Run standard checks (backwards compatible) + $this->section('Structure'); + $this->runStructureChecks($path); + $this->section('Documentation'); + $this->runDocumentationChecks($path); + $this->section('Workflows'); + $this->runWorkflowChecks($path, $repo); + $this->section('Security'); + $this->runSecurityChecks($path, $repo); + $this->section('Rulesets'); + $this->runRulesetChecks($repo); + $this->section('Deployment'); + $this->runDeploymentChecks($path, $repo); + + // Calculate scores + $this->calculateScore(); + + // Output results + if ($jsonOutput) { + echo json_encode($this->results, JSON_PRETTY_PRINT) . PHP_EOL; + } else { + $this->displayResults(); + } + + // Create GitHub issue if requested + if ($createIssue && !empty($repo)) { + $this->createHealthIssue($repo); + } elseif ($createIssue && empty($repo)) { + $this->warn("--create-issue requires --repo parameter (format: owner/repo)"); + } + + // Record metrics + $this->metrics->setGauge('repo_health_score', $this->results['percentage']); + $this->metrics->setGauge('repo_health_checks_passed', + count(array_filter($this->results['checks'], fn($c) => $c['passed']))); + + // Check threshold + if ($this->results['percentage'] < $threshold) { + $this->error(sprintf( + "Health check failed: %.1f%% < %.1f%% threshold", + $this->results['percentage'], + $threshold + )); + return 1; + } + + $this->log(sprintf( + "Health check passed: %.1f%% >= %.1f%% threshold", + $this->results['percentage'], + $threshold + )); + + return 0; + } + + private function runStructureChecks(string $path): void + { + $category = 'structure'; + $this->results['categories'][$category] = [ + 'name' => 'Repository Structure', + 'max_points' => 30, + 'earned_points' => 0, + 'checks_passed' => 0, + 'checks_failed' => 0, + ]; + + // Check README exists + $this->addCheck($category, 'README.md exists', + file_exists("{$path}/README.md"), 10); + + // Check LICENSE exists + $this->addCheck($category, 'LICENSE file exists', + file_exists("{$path}/LICENSE"), 10); + + // Check .gitignore exists + $this->addCheck($category, '.gitignore exists', + file_exists("{$path}/.gitignore"), 5); + + // Check CHANGELOG exists + $this->addCheck($category, 'CHANGELOG.md exists', + file_exists("{$path}/CHANGELOG.md"), 5); + } + + private function runDocumentationChecks(string $path): void + { + $category = 'documentation'; + $this->results['categories'][$category] = [ + 'name' => 'Documentation', + 'max_points' => 25, + 'earned_points' => 0, + 'checks_passed' => 0, + 'checks_failed' => 0, + ]; + + // Check docs directory exists + $this->addCheck($category, 'docs/ directory exists', + is_dir("{$path}/docs"), 10); + + // Check README has content + if (file_exists("{$path}/README.md")) { + $content = file_get_contents("{$path}/README.md"); + $this->addCheck($category, 'README has substantial content', + strlen($content) > 500, 10); + } + + // Check for code of conduct + $this->addCheck($category, 'CODE_OF_CONDUCT.md exists', + file_exists("{$path}/CODE_OF_CONDUCT.md"), 5); + } + + private function runWorkflowChecks(string $path, string $repo = ''): void + { + $category = 'workflows'; + $this->results['categories'][$category] = [ + 'name' => 'GitHub Workflows', + 'max_points' => 20, + 'earned_points' => 0, + 'checks_passed' => 0, + 'checks_failed' => 0, + ]; + + $githubWfDir = "{$path}/.github/workflows"; + $giteaWfDir = "{$path}/.gitea/workflows"; + $workflowDir = is_dir($giteaWfDir) ? $giteaWfDir : $githubWfDir; + + if (!empty($repo)) { + // --repo provided: use API (authoritative, avoids checking the wrong local dir) + $token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN') ?: getenv('GITEA_TOKEN'); + // Try .github/workflows first, fall back to .gitea/workflows + $remoteFiles = empty($token) + ? [] + : $this->githubListNames("repos/{$repo}/contents/.github/workflows", '', $token); + if (empty($remoteFiles) && !empty($token)) { + $remoteFiles = $this->githubListNames("repos/{$repo}/contents/.gitea/workflows", '', $token); + } + + $hasWorkflowDir = !empty($remoteFiles); + $this->addCheck($category, 'Workflows directory exists', $hasWorkflowDir, 10); + + if ($hasWorkflowDir) { + $hasCI = false; + foreach ($remoteFiles as $name) { + if (preg_match('/^ci.*\.(yml|yaml)$/i', $name)) { + $hasCI = true; + break; + } + } + $this->addCheck($category, 'CI workflow exists', $hasCI, 10); + } + } elseif (is_dir($workflowDir)) { + // No --repo: check local filesystem + $this->addCheck($category, 'Workflows directory exists', true, 10); + $hasCI = glob("{$workflowDir}/ci*.yml") || glob("{$workflowDir}/ci*.yaml"); + $this->addCheck($category, 'CI workflow exists', !empty($hasCI), 10); + } else { + $this->addCheck($category, 'Workflows directory exists', false, 10); + } + } + + private function runSecurityChecks(string $path, string $repo = ''): void + { + $category = 'security'; + $this->results['categories'][$category] = [ + 'name' => 'Security', + 'max_points' => 25, + 'earned_points' => 0, + 'checks_passed' => 0, + 'checks_failed' => 0, + ]; + + // Check for SECURITY.md + $this->addCheck($category, 'SECURITY.md exists', + file_exists("{$path}/SECURITY.md") || + file_exists("{$path}/.github/SECURITY.md"), 10); + + // Check for security scanning workflow (CodeQL on GitHub, Trivy on Gitea) + $hasSecurityScan = false; + $ghWf = "{$path}/.github/workflows"; + $gtWf = "{$path}/.gitea/workflows"; + if (is_dir($ghWf)) { + $hasSecurityScan = !empty(glob("{$ghWf}/*codeql*.yml")) || !empty(glob("{$ghWf}/*codeql*.yaml")); + } + if (!$hasSecurityScan && is_dir($gtWf)) { + $hasSecurityScan = !empty(glob("{$gtWf}/*trivy*.yml")) || !empty(glob("{$gtWf}/*trivy*.yaml")); + } + $this->addCheck($category, 'Security scanning workflow exists', + $hasSecurityScan, 10); + + // Check for dependency management (Dependabot or Renovate) + $this->addCheck($category, 'Dependency management configured', + file_exists("{$path}/.github/dependabot.yml") || + file_exists("{$path}/.github/dependabot.yaml") || + file_exists("{$path}/renovate.json") || + file_exists("{$path}/.renovaterc.json"), 5); + + // Branch protection — requires --repo and GitHub token + if (empty($repo)) { + return; + } + + $this->results['categories'][$category]['max_points'] += 10; + $token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN'); + + if (empty($token)) { + $this->warn("Cannot check branch protection: GH_TOKEN not set"); + $this->addCheck($category, 'main branch is protected', false, 10); + return; + } + + $isProtected = $this->githubVarExists("repos/{$repo}/branches/main/protection", $token); + $this->addCheck($category, 'main branch is protected', $isProtected, 10); + } + + /** + * Check that the expected GitHub rulesets are applied to the repository. + * + * Expected rulesets (applied at org or repo level): + * - MAIN: protects main/master (deletion, non-fast-forward, requires PRs) + * - VERSION: immutable version/* branches + * - DEV: prevents deletion of dev/* branches + * + * @param string $repo Owner/repo format (e.g. "mokoconsulting-tech/MokoCRM") + */ + private function runRulesetChecks(string $repo): void + { + $category = 'rulesets'; + $this->results['categories'][$category] = [ + 'name' => 'Branch Rulesets', + 'max_points' => 20, + 'earned_points' => 0, + 'checks_passed' => 0, + 'checks_failed' => 0, + ]; + + if (empty($repo)) { + $this->log("Skipping ruleset checks (no --repo provided)"); + $this->addCheck($category, 'Main branch ruleset', false, 5); + $this->addCheck($category, 'Version branch ruleset', false, 5); + $this->addCheck($category, 'Dev branch ruleset', false, 5); + $this->addCheck($category, 'RC branch ruleset', false, 5); + return; + } + + $token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN'); + if (empty($token)) { + $this->warn("Cannot check rulesets: GH_TOKEN not set"); + $this->addCheck($category, 'Main branch ruleset', false, 5); + $this->addCheck($category, 'Version branch ruleset', false, 5); + $this->addCheck($category, 'Dev branch ruleset', false, 5); + $this->addCheck($category, 'RC branch ruleset', false, 5); + return; + } + + // Fetch all rulesets visible to this repository (includes org-level) + $rulesets = $this->fetchRulesets($repo, $token); + + $hasMain = false; + $hasVersion = false; + $hasDev = false; + $hasRc = false; + + foreach ($rulesets as $rs) { + $name = strtolower($rs['name'] ?? ''); + $refs = $this->extractRulesetRefs($rs); + + // MAIN: any ruleset targeting main or master + if (str_contains($name, 'main') || str_contains($name, 'protect main') + || $this->refsInclude($refs, ['refs/heads/main', 'refs/heads/master'])) { + $hasMain = true; + } + + // VERSION: any ruleset targeting version/* branches + if (str_contains($name, 'version') + || $this->refsInclude($refs, ['refs/heads/version/*', 'refs/heads/version/**'])) { + $hasVersion = true; + } + + // DEV: any ruleset targeting dev/* branches + if ((str_contains($name, 'dev') && !str_contains($name, 'develop')) + || $this->refsInclude($refs, ['refs/heads/dev/*', 'refs/heads/dev/**'])) { + $hasDev = true; + } + + // RC: any ruleset targeting rc/* branches + if (str_contains($name, 'rc') + || $this->refsInclude($refs, ['refs/heads/rc/*', 'refs/heads/rc/**'])) { + $hasRc = true; + } + } + + $this->addCheck($category, 'Main branch ruleset', $hasMain, 5); + $this->addCheck($category, 'Version branch ruleset', $hasVersion, 5); + $this->addCheck($category, 'Dev branch ruleset', $hasDev, 5); + $this->addCheck($category, 'RC branch ruleset', $hasRc, 5); + } + + /** + * Fetch rulesets for a repository (includes org-inherited rulesets). + * + * @return array> + */ + private function fetchRulesets(string $repo, string $token): array + { + $url = "https://api.github.com/repos/{$repo}/rulesets?per_page=100&includes_parents=true"; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: token ' . $token, + 'User-Agent: MokoStandards-HealthCheck', + 'Accept: application/vnd.github.v3+json', + ], + ]); + $body = (string) curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($status !== 200) { + $this->warn("Could not fetch rulesets for {$repo} (HTTP {$status})"); + return []; + } + + return json_decode($body, true) ?? []; + } + + /** + * Extract ref include patterns from a ruleset's conditions. + * + * @return string[] + */ + private function extractRulesetRefs(array $ruleset): array + { + return $ruleset['conditions']['ref_name']['include'] ?? []; + } + + /** + * Check if any of the expected ref patterns are present in the ruleset refs. + * + * @param string[] $rulesetRefs + * @param string[] $expected + */ + private function refsInclude(array $rulesetRefs, array $expected): bool + { + foreach ($expected as $pattern) { + foreach ($rulesetRefs as $ref) { + if ($ref === $pattern || fnmatch($pattern, $ref) || fnmatch($ref, $pattern)) { + return true; + } + } + } + return false; + } + + private function runDeploymentChecks(string $path, string $repo): void + { + $category = 'deployment'; + $this->results['categories'][$category] = [ + 'name' => 'Dev Deployment', + 'max_points' => 5, + 'earned_points' => 0, + 'checks_passed' => 0, + 'checks_failed' => 0, + ]; + + // 1. Workflow file exists — filesystem check, always runs + // Check both workflow directories for deploy-dev.yml + $workflowFile = file_exists("{$path}/.gitea/workflows/deploy-dev.yml") + ? "{$path}/.gitea/workflows/deploy-dev.yml" + : "{$path}/.github/workflows/deploy-dev.yml"; + $this->addCheck( + $category, + 'deploy-dev.yml workflow exists', + file_exists($workflowFile), + 5 + ); + + // 2. Secrets & variables — require --repo for GitHub API + if (empty($repo)) { + $this->log("Skipping deployment secrets/variables checks (no --repo provided)"); + return; + } + + // Expand max_points now that we can run API checks. + // CUSTOM_FOLDER (2 pts) is not applicable to MokoStandards or .github-private. + [, $repoName] = array_pad(explode('/', $repo, 2), 2, ''); + $checkCustomFolder = !in_array($repoName, self::CUSTOM_FOLDER_EXEMPT, true); + $this->results['categories'][$category]['max_points'] += $checkCustomFolder ? 12 : 10; + + $token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN'); + if (empty($token)) { + $this->warn("Cannot check deployment secrets/variables: GH_TOKEN not set"); + $this->addCheck($category, 'DEV_FTP_HOST variable configured', false, 3); + $this->addCheck($category, 'DEV_FTP_PATH variable configured', false, 3); + $this->addCheck($category, 'DEV_FTP_USERNAME variable configured', false, 2); + $this->addCheck($category, 'SFTP credentials configured (DEV_FTP_KEY or DEV_FTP_PASSWORD)', false, 2); + if ($checkCustomFolder) { + $this->addCheck($category, 'CUSTOM_FOLDER variable configured', false, 2); + } + return; + } + + [$org] = explode('/', $repo, 2); + + // Use the repo variables list — returns all vars visible to the repo (org-granted + // included) with just repo scope, avoiding the admin:org requirement of direct lookups. + $repoVars = $this->githubListNames("repos/{$repo}/actions/variables", 'variables', $token); + + $this->addCheck($category, 'DEV_FTP_HOST variable configured', + in_array('DEV_FTP_HOST', $repoVars, true), 3); + + $this->addCheck($category, 'DEV_FTP_PATH variable configured', + in_array('DEV_FTP_PATH', $repoVars, true), 3); + + $this->addCheck($category, 'DEV_FTP_USERNAME variable configured', + in_array('DEV_FTP_USERNAME', $repoVars, true), 2); + + // SFTP credentials — at least DEV_FTP_KEY or DEV_FTP_PASSWORD must exist. + // Use the list endpoint (repo scope sufficient) which returns all secrets visible + // to the repo, including org-level secrets that have been granted to it. + $repoSecrets = $this->githubListNames("repos/{$repo}/actions/secrets", 'secrets', $token); + $hasKey = in_array('DEV_FTP_KEY', $repoSecrets, true); + $hasPassword = in_array('DEV_FTP_PASSWORD', $repoSecrets, true); + $this->addCheck( + $category, + 'SFTP credentials configured (DEV_FTP_KEY or DEV_FTP_PASSWORD)', + $hasKey || $hasPassword, + 2 + ); + + // CUSTOM_FOLDER — repo-level variable; required for publish-to-mokodolimods workflow. + // Not applicable to MokoStandards or .github-private (no Dolibarr module to publish). + if ($checkCustomFolder) { + $this->addCheck( + $category, + 'CUSTOM_FOLDER variable configured', + $this->githubVarExists("repos/{$repo}/actions/variables/CUSTOM_FOLDER", $token), + 2 + ); + } + } + + /** + * Returns true when the GitHub API responds 200 for the given resource path. + * Used to check for the existence of org/repo variables and secrets by name. + * + * @param string $resourcePath e.g. "orgs/myorg/actions/variables/MY_VAR" + * @param string $token GitHub personal access token + */ + private function githubVarExists(string $resourcePath, string $token): bool + { + $url = "https://api.github.com/{$resourcePath}"; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: token ' . $token, + 'User-Agent: MokoStandards-HealthCheck', + 'Accept: application/vnd.github.v3+json', + ], + ]); + curl_exec($ch); + $error = curl_error($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if (!empty($error)) { + $this->warn("curl error checking {$resourcePath}: {$error}"); + } + return $status === 200; + } + + + /** + * Fetch all names from a paginated GitHub list endpoint. + * Works with repo scope — avoids admin:org requirement of direct-lookup endpoints. + * + * @param string $resourcePath e.g. "repos/org/repo/actions/secrets" + * @param string $itemsKey JSON key that holds the array, e.g. "secrets" or "variables" + * @param string $token + * @return string[] + */ + private function githubListNames(string $resourcePath, string $itemsKey, string $token): array + { + $names = []; + $page = 1; + $perPage = 100; + + do { + $url = "https://api.github.com/{$resourcePath}?per_page={$perPage}&page={$page}"; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: token ' . $token, + 'User-Agent: MokoStandards-HealthCheck', + 'Accept: application/vnd.github.v3+json', + ], + ]); + $body = (string) curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($status !== 200) { + break; + } + + $data = json_decode($body, true) ?? []; + // Contents API returns a top-level array; secrets/variables APIs use a keyed object. + $items = ($itemsKey === '') ? $data : ($data[$itemsKey] ?? []); + foreach ($items as $item) { + if (isset($item['name'])) { + $names[] = $item['name']; + } + } + + $page++; + } while (count($items) === $perPage); + + return $names; + } + + private function addCheck(string $category, string $name, bool $passed, int $points): void + { + $this->results['checks'][] = [ + 'category' => $category, + 'name' => $name, + 'passed' => $passed, + 'points' => $points, + ]; + + if ($passed) { + $this->results['categories'][$category]['earned_points'] += $points; + $this->results['categories'][$category]['checks_passed']++; + } else { + $this->results['categories'][$category]['checks_failed']++; + } + } + + private function calculateScore(): void + { + $totalEarned = 0; + $maxScore = 0; + + foreach ($this->results['categories'] as $category) { + $totalEarned += $category['earned_points']; + $maxScore += $category['max_points']; + } + + $this->results['score'] = $totalEarned; + $this->results['max_score'] = $maxScore; + $this->results['percentage'] = $maxScore > 0 ? ($totalEarned / $maxScore * 100) : 0; + + // Determine level + $pct = $this->results['percentage']; + if ($pct >= 90) { + $this->results['level'] = 'excellent'; + } elseif ($pct >= 80) { + $this->results['level'] = 'good'; + } elseif ($pct >= 70) { + $this->results['level'] = 'fair'; + } elseif ($pct >= 60) { + $this->results['level'] = 'poor'; + } else { + $this->results['level'] = 'critical'; + } + } + + private function displayResults(): void + { + $this->section('Results'); + + foreach ($this->results['checks'] as $check) { + $this->status($check['passed'], $check['name'], $check['passed'] ? '' : "{$check['points']} pts lost"); + } + + $passedChecks = count(array_filter($this->results['checks'], fn($c) => $c['passed'])); + $failedChecks = count(array_filter($this->results['checks'], fn($c) => !$c['passed'])); + + $this->printSummary( + $passedChecks, + $failedChecks, + $this->elapsed() + ); + + $this->log(sprintf( + "Overall Score: %d/%d (%.1f%%) — Level: %s", + $this->results['score'], + $this->results['max_score'], + $this->results['percentage'], + strtoupper($this->results['level']) + )); + } + + private function createHealthIssue(string $repo): void + { + $this->log("Creating or updating health check issue for {$repo}"); + + $body = $this->generateIssueBody(); + $pct = round($this->results['percentage'], 1); + $labels = ['health-check', 'type: chore', 'automation']; + + if ($pct >= 90) { + $title = "✅ Repository Health Check: Excellent ({$pct}%)"; + } elseif ($pct >= 70) { + $title = "⚠️ Repository Health Check: Good ({$pct}%)"; + } elseif ($pct >= 50) { + $title = "🟡 Repository Health Check: Fair ({$pct}%)"; + } else { + $title = "❌ Repository Health Check: Critical ({$pct}%)"; + } + + try { + // Search for an existing health-check issue (any state) to avoid duplicates + $existing = $this->apiClient->get("/repos/{$repo}/issues", [ + 'labels' => 'health-check', + 'state' => 'all', + 'per_page' => 1, + 'sort' => 'updated', + 'direction' => 'desc', + ]); + + if (!empty($existing[0]['number'])) { + $num = (int) $existing[0]['number']; + $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller-moko']]; + if (($existing[0]['state'] ?? 'open') === 'closed') { + $patch['state'] = 'open'; + } + $this->apiClient->patch("/repos/{$repo}/issues/{$num}", $patch); + try { + $this->apiClient->post("/repos/{$repo}/issues/{$num}/labels", ['labels' => $labels]); + } catch (\Exception $le) { /* non-fatal */ } + $this->log("✅ Updated health issue #{$num} in {$repo}"); + } else { + $issue = $this->apiClient->post("/repos/{$repo}/issues", [ + 'title' => $title, + 'body' => $body, + 'labels' => $labels, + 'assignees' => ['jmiller-moko'], + ]); + $issueNumber = $issue['number'] ?? 'unknown'; + $this->log("✅ Created health issue #{$issueNumber} in {$repo}"); + } + } catch (\Exception $e) { + $this->error("Failed to create/update health issue: " . $e->getMessage()); + } + } + + private function generateIssueBody(): string + { + $body = "## Repository Health Check Results\n\n"; + $body .= "**Generated**: " . date('Y-m-d H:i:s T') . "\n"; + $body .= "**Overall Score**: {$this->results['score']}/{$this->results['max_score']} points ({$this->results['percentage']}%)\n"; + $body .= "**Health Level**: " . strtoupper($this->results['level']) . "\n\n"; + + // Category breakdown + $body .= "### Category Breakdown\n\n"; + $body .= "| Category | Score | Percentage | Passed | Failed |\n"; + $body .= "|----------|-------|------------|--------|--------|\n"; + + foreach ($this->results['categories'] as $category) { + $pct = $category['max_points'] > 0 + ? ($category['earned_points'] / $category['max_points'] * 100) + : 0; + + $body .= sprintf( + "| %s | %d/%d | %.1f%% | %d | %d |\n", + $category['name'], + $category['earned_points'], + $category['max_points'], + $pct, + $category['checks_passed'], + $category['checks_failed'] + ); + } + + // Failed checks details + $failedChecks = array_filter($this->results['checks'], fn($c) => !$c['passed']); + if (!empty($failedChecks)) { + $body .= "\n### ❌ Failed Checks\n\n"; + + $byCategory = []; + foreach ($failedChecks as $check) { + $cat = $this->results['categories'][$check['category']]['name']; + if (!isset($byCategory[$cat])) { + $byCategory[$cat] = []; + } + $byCategory[$cat][] = $check; + } + + foreach ($byCategory as $catName => $checks) { + $body .= "**{$catName}**\n"; + foreach ($checks as $check) { + $body .= "- ❌ {$check['name']} ({$check['points']} points)\n"; + } + $body .= "\n"; + } + } else { + $body .= "\n### ✅ All Checks Passed!\n\n"; + $body .= "This repository has passed all health checks. Excellent work! 🎉\n\n"; + } + + // Recommendations + if (!empty($failedChecks)) { + $body .= "### 📋 Recommendations\n\n"; + $body .= "To improve repository health:\n\n"; + foreach ($failedChecks as $check) { + $body .= "1. **{$check['name']}**: Address this check to gain {$check['points']} points\n"; + } + $body .= "\n"; + } + + // Health thresholds + $body .= "### 📊 Health Thresholds\n\n"; + $body .= "- ✅ **Excellent**: ≥90%\n"; + $body .= "- ⚠️ **Good**: 70-89%\n"; + $body .= "- 🟡 **Fair**: 50-69%\n"; + $body .= "- ❌ **Critical**: <50%\n\n"; + + $body .= "---\n"; + $body .= "*This issue was automatically created by the MokoStandards repository health checker.*\n"; + $body .= "*To customize health checks, edit `.github/override.tf` in your repository.*\n"; + + return $body; + } +} + +// Run the application +$app = new RepoHealthChecker(); +exit($app->execute($argv)); diff --git a/validate/check_structure.php b/validate/check_structure.php new file mode 100644 index 0000000..4308363 --- /dev/null +++ b/validate/check_structure.php @@ -0,0 +1,139 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/validate/check_structure.php + * VERSION: 04.06.00 + * BRIEF: Validates required repository directory and file structure + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\CliFramework; + +/** + * Validates that the required directories and files exist in the repository root. + */ +class CheckStructure extends CliFramework +{ + /** @var list Required directory paths (relative to repo root). */ + /** @var list Required directory paths — at least one workflow dir must exist. */ + private const REQUIRED_DIRS = ['docs', 'scripts']; + + /** @var list At least one of these workflow directories must exist. */ + private const WORKFLOW_DIRS = ['.github/workflows', '.gitea/workflows']; + + /** @var list Required file paths (relative to repo root). */ + private const REQUIRED_FILES = ['README.md', 'LICENSE', 'CONTRIBUTING.md', 'SECURITY.md']; + + /** Directories searched for CHANGELOG.md (case-insensitive), relative to repo root. */ + private const CHANGELOG_DIRS = ['', 'src', 'docs']; + + /** + * Configure available arguments. + */ + protected function configure(): void + { + $this->setDescription('Validates required repository directory and file structure'); + $this->addArgument('--path', 'Repository path to check', '.'); + } + + /** + * Run the structure validation. + * + * @return int Exit code: 0 if everything is present, 1 if anything is missing. + */ + protected function run(): int + { + $path = $this->getArgument('--path'); + $missingDirs = []; + $missingFiles = []; + $passed = 0; + $failed = 0; + + $this->section('Checking required directories'); + + foreach (self::REQUIRED_DIRS as $dir) { + if (!is_dir($path . '/' . $dir)) { + $missingDirs[] = $dir; + $this->status(false, "Directory: {$dir}"); + $failed++; + } else { + $this->status(true, "Directory: {$dir}"); + $passed++; + } + } + + // At least one workflow directory must exist + $hasWorkflowDir = false; + foreach (self::WORKFLOW_DIRS as $wfDir) { + if (is_dir($path . '/' . $wfDir)) { + $hasWorkflowDir = true; + $this->status(true, "Directory: {$wfDir}"); + $passed++; + break; + } + } + if (!$hasWorkflowDir) { + $missingDirs[] = implode(' or ', self::WORKFLOW_DIRS); + $this->status(false, 'Directory: ' . implode(' or ', self::WORKFLOW_DIRS)); + $failed++; + } + + $this->section('Checking required files'); + + foreach (self::REQUIRED_FILES as $file) { + if (!is_file($path . '/' . $file)) { + $missingFiles[] = $file; + $this->status(false, "File: {$file}"); + $failed++; + } else { + $this->status(true, "File: {$file}"); + $passed++; + } + } + + // CHANGELOG.md — accepted at root, src/, or docs/ (case-insensitive) + $changelogFound = null; + foreach (self::CHANGELOG_DIRS as $sub) { + $dir = $sub === '' ? $path : $path . '/' . $sub; + $entries = is_dir($dir) ? (@scandir($dir) ?: []) : []; + foreach ($entries as $entry) { + if (strcasecmp($entry, 'CHANGELOG.md') === 0 && is_file($dir . '/' . $entry)) { + $changelogFound = ($sub === '' ? '' : $sub . '/') . $entry; + break 2; + } + } + } + + if ($changelogFound !== null) { + $this->status(true, "File: CHANGELOG.md (found: {$changelogFound})"); + $passed++; + } else { + $missingFiles[] = 'CHANGELOG.md'; + $this->status(false, 'File: CHANGELOG.md (checked root, src/, docs/)'); + $failed++; + } + + $this->printSummary($passed, $failed, $this->elapsed()); + + if (empty($missingDirs) && empty($missingFiles)) { + return 0; + } + + return 1; + } +} + +$script = new CheckStructure('check_structure', 'Validates required repository directory and file structure'); +exit($script->execute()); diff --git a/validate/check_tabs.php b/validate/check_tabs.php new file mode 100644 index 0000000..8b2cc72 --- /dev/null +++ b/validate/check_tabs.php @@ -0,0 +1,80 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/validate/check_tabs.php + * VERSION: 04.06.00 + * BRIEF: Validates that no literal tab characters exist in source files + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\CliFramework; + +/** + * Checks that none of the tracked PHP, JS, CSS, XML, YAML and Markdown files + * contain literal tab characters. + */ +class CheckTabs extends CliFramework +{ + /** + * Configure available arguments. + */ + protected function configure(): void + { + $this->setDescription('Validates that no literal tab characters exist in source files'); + $this->addArgument('--path', 'Repository path to check', '.'); + } + + /** + * Scan for tab characters in tracked source files. + * + * @return int Exit code: 0 if no tabs found, 1 if tabs are present. + */ + protected function run(): int + { + $path = $this->getArgument('--path'); + $patterns = ['*.php', '*.js', '*.css', '*.xml', '*.yml', '*.yaml', '*.md']; + $quoted = implode(' ', array_map('escapeshellarg', $patterns)); + $output = shell_exec('git -C ' . escapeshellarg($path) . " ls-files {$quoted} 2>/dev/null") ?? ''; + $files = array_values(array_filter(explode("\n", $output))); + $total = count($files); + $passed = 0; + $tabFiles = 0; + + $this->section('Scanning for tab characters'); + + foreach ($files as $i => $file) { + $this->progress($i + 1, $total, $file); + $fullPath = $path . '/' . $file; + if (!is_file($fullPath)) { + continue; + } + if (str_contains((string) file_get_contents($fullPath), "\t")) { + $this->progress($i + 1, $total, '', true); + $this->status(false, $file, 'contains tab characters — use spaces'); + $tabFiles++; + } else { + $passed++; + } + } + $this->progress($total, $total, '', true); + + $this->printSummary($passed, $tabFiles, $this->elapsed()); + + return $tabFiles === 0 ? 0 : 1; + } +} + +$script = new CheckTabs('check_tabs', 'Validates that no literal tab characters exist in source files'); +exit($script->execute()); diff --git a/validate/check_version_consistency.php b/validate/check_version_consistency.php new file mode 100755 index 0000000..81f4d8a --- /dev/null +++ b/validate/check_version_consistency.php @@ -0,0 +1,242 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/validate/check_version_consistency.php + * VERSION: 04.06.00 + * BRIEF: Validates that version numbers are consistent across all critical repository files + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\CliFramework; + +/** + * Checks that the version recorded in composer.json matches VERSION headers + * and badges in README.md, CHANGELOG.md, CONTRIBUTING.md, workflow files, + * and PHP Enterprise library files. + */ +class CheckVersionConsistency extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Validates version consistency across all critical repository files'); + $this->addArgument('--path', 'Repository root path to check', '.'); + } + + protected function run(): int + { + $path = rtrim((string) $this->getArgument('--path'), '/\\'); + $composerFile = $path . '/composer.json'; + + // ── Resolve expected version ────────────────────────────────────────── + $this->section('Resolving expected version'); + + $expected = null; + + if (is_file($composerFile)) { + $data = json_decode((string) file_get_contents($composerFile), true); + if (isset($data['version'])) { + $expected = (string) $data['version']; + $this->status(true, "Expected version (composer.json): {$expected}"); + } else { + $this->status(false, 'composer.json', 'missing "version" key'); + } + } else { + $this->status(false, 'composer.json', 'file not found — falling back to README.md'); + } + + // Fallback: extract version from README.md VERSION header + if ($expected === null) { + $readmeFile = $path . '/README.md'; + if (is_file($readmeFile)) { + $readme = (string) file_get_contents($readmeFile); + if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', $readme, $m)) { + $expected = $m[1]; + $this->status(true, "Expected version (README.md): {$expected}"); + } else { + $this->status(false, 'README.md', 'no VERSION header found'); + return 2; + } + } else { + $this->status(false, 'README.md', 'file not found'); + return 2; + } + } + + // ── Check critical root files ───────────────────────────────────────── + $this->section('Checking critical files'); + + $criticalChecks = [ + 'README.md' => ['/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', '/MokoStandards-(\d{2}\.\d{2}\.\d{2})/'], + 'CHANGELOG.md' => ['/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/'], + 'CONTRIBUTING.md' => ['/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/'], + ]; + + $issues = []; + + foreach ($criticalChecks as $filename => $patterns) { + $file = $path . '/' . $filename; + if (!is_file($file)) { + $this->status(false, $filename, 'file not found'); + $issues[] = $filename; + continue; + } + $content = (string) file_get_contents($file); + $filePassed = true; + foreach ($patterns as $pattern) { + preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE); + foreach ($matches[1] as $match) { + if ($match[0] !== $expected) { + $line = substr_count(substr($content, 0, (int) $match[1]), "\n") + 1; + $this->status(false, $filename, "line {$line}: found {$match[0]}, expected {$expected}"); + $issues[] = $filename; + $filePassed = false; + } + } + } + if ($filePassed) { + $this->status(true, $filename); + } + } + + // ── Check workflow files ────────────────────────────────────────────── + $this->section('Checking workflow files'); + + // Check both .github/workflows and .gitea/workflows + $workflowFiles = []; + foreach (['.github/workflows', '.gitea/workflows'] as $wfDir) { + $dir = $path . '/' . $wfDir; + if (is_dir($dir)) { + $workflowFiles = array_merge($workflowFiles, glob($dir . '/*.yml') ?: []); + } + } + $total = count($workflowFiles); + + foreach ($workflowFiles as $i => $file) { + $this->progress($i + 1, $total, basename($file)); + $content = (string) file_get_contents($file); + $filePassed = true; + preg_match_all('/#\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', $content, $matches, PREG_OFFSET_CAPTURE); + foreach ($matches[1] as $match) { + if ($match[0] !== $expected) { + $this->progress($i + 1, $total, '', true); + $rel = str_replace($path . '/', '', $file); + $this->status(false, $rel, "found {$match[0]}, expected {$expected}"); + $issues[] = $rel; + $filePassed = false; + } + } + } + $this->progress($total, $total, '', true); + + // ── Check PHP Enterprise library files ──────────────────────────────── + $this->section('Checking PHP source files'); + + $phpFiles = $this->findPhpFiles($path . '/api/lib/Enterprise'); + $phpTotal = count($phpFiles); + + foreach ($phpFiles as $i => $file) { + $this->progress($i + 1, $phpTotal, basename($file)); + $content = (string) file_get_contents($file); + $filePassed = true; + preg_match_all('/\* VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', $content, $matches, PREG_OFFSET_CAPTURE); + foreach ($matches[1] as $match) { + if ($match[0] !== $expected) { + $this->progress($i + 1, $phpTotal, '', true); + $rel = str_replace($path . '/', '', $file); + $this->status(false, $rel, "found {$match[0]}, expected {$expected}"); + $issues[] = $rel; + $filePassed = false; + } + } + } + $this->progress($phpTotal, $phpTotal, '', true); + + // ── Check Terraform definition files ───────────────────────────────── + // Each .tf file has TWO version locations that must both match: + // - Block-comment header: * Version: XX.XX.XX + // - HCL metadata field: version = "XX.XX.XX" + $this->section('Checking Terraform definition files'); + + $defFiles = glob($path . '/api/definitions/default/*.tf') ?: []; + $defTotal = count($defFiles); + + foreach ($defFiles as $i => $file) { + $this->progress($i + 1, $defTotal, basename($file)); + $content = (string) file_get_contents($file); + $filePassed = true; + $rel = str_replace($path . '/', '', $file); + + // Block-comment header version + preg_match_all('/\*\s*Version:\s*(\d{2}\.\d{2}\.\d{2})/', $content, $headerMatches, PREG_OFFSET_CAPTURE); + foreach ($headerMatches[1] as $match) { + if ($match[0] !== $expected) { + $this->progress($i + 1, $defTotal, '', true); + $this->status(false, $rel, "header Version: found {$match[0]}, expected {$expected}"); + $issues[] = $rel; + $filePassed = false; + } + } + + // HCL metadata version field + preg_match_all('/^\s*version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $content, $hclMatches, PREG_OFFSET_CAPTURE); + foreach ($hclMatches[1] as $match) { + if ($match[0] !== $expected) { + $this->progress($i + 1, $defTotal, '', true); + $this->status(false, $rel, "HCL version = found {$match[0]}, expected {$expected}"); + $issues[] = $rel; + $filePassed = false; + } + } + + if ($filePassed) { + $this->status(true, $rel); + } + } + $this->progress($defTotal, $defTotal, '', true); + + // ── Summary ─────────────────────────────────────────────────────────── + $totalChecked = count($criticalChecks) + $total + $phpTotal + $defTotal; + $totalFailed = count(array_unique($issues)); + $this->printSummary($totalChecked - $totalFailed, $totalFailed, $this->elapsed()); + + return $totalFailed === 0 ? 0 : 1; + } + + /** + * Recursively find all PHP files under a directory. + * + * @return list + */ + private function findPhpFiles(string $dir): array + { + if (!is_dir($dir)) { + return []; + } + $files = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS) + ); + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $files[] = $file->getPathname(); + } + } + return $files; + } +} + +$script = new CheckVersionConsistency('check_version_consistency', 'Validates version consistency across repository files'); +exit($script->execute()); diff --git a/validate/check_xml_wellformed.php b/validate/check_xml_wellformed.php new file mode 100644 index 0000000..acd1907 --- /dev/null +++ b/validate/check_xml_wellformed.php @@ -0,0 +1,86 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/validate/check_xml_wellformed.php + * VERSION: 04.06.00 + * BRIEF: Validates that all tracked XML files are well-formed + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\CliFramework; + +/** + * Runs `xmllint --noout` against all tracked *.xml files and reports errors. + */ +class CheckXmlWellformed extends CliFramework +{ + /** + * Configure available arguments. + */ + protected function configure(): void + { + $this->setDescription('Validates that all tracked XML files are well-formed'); + $this->addArgument('--path', 'Repository path to check', '.'); + } + + /** + * Validate XML well-formedness for all tracked XML files. + * + * @return int Exit code: 0 if all files pass, 1 if any are malformed. + */ + protected function run(): int + { + $path = $this->getArgument('--path'); + $output = shell_exec('git -C ' . escapeshellarg($path) . " ls-files '*.xml' 2>/dev/null") ?? ''; + $files = array_values(array_filter(explode("\n", $output))); + $total = count($files); + $passed = 0; + $errors = 0; + + $this->section('Validating XML well-formedness'); + + if ($total === 0) { + $this->log('INFO', 'No tracked XML files found'); + $this->printSummary(0, 0, $this->elapsed()); + return 0; + } + + foreach ($files as $i => $file) { + $this->progress($i + 1, $total, $file); + $fullPath = $path . '/' . $file; + if (!is_file($fullPath)) { + continue; + } + $out = []; + $code = 0; + exec('xmllint --noout ' . escapeshellarg($fullPath) . ' 2>&1', $out, $code); + if ($code !== 0) { + $this->progress($i + 1, $total, '', true); + $this->status(false, $file, implode('; ', array_slice($out, 0, 2))); + } else { + $passed++; + } + $errors += ($code !== 0) ? 1 : 0; + } + $this->progress($total, $total, '', true); + + $this->printSummary($passed, $errors, $this->elapsed()); + + return $errors === 0 ? 0 : 1; + } +} + +$script = new CheckXmlWellformed('check_xml_wellformed', 'Validates that all tracked XML files are well-formed'); +exit($script->execute()); diff --git a/validate/index.md b/validate/index.md new file mode 100644 index 0000000..0cc29a3 --- /dev/null +++ b/validate/index.md @@ -0,0 +1,21 @@ +# Docs Index: /api/validate + +## Purpose + +This index provides navigation to documentation within this folder. + +## Documents + +- [README](./README.md) +- [SECURITY_SCANNING](./SECURITY_SCANNING.md) + +## Metadata + +- **Document Type:** index +- **Auto-generated:** This file is automatically generated by rebuild_indexes.py + +## Revision History + +| Date | Author | Change | Notes | +| ---------- | ------------------ | ----------------- | ------------------------------------------ | +| Auto | rebuild_indexes.py | Automated update | Generated by documentation index automation | diff --git a/validate/scan_drift.php b/validate/scan_drift.php new file mode 100755 index 0000000..be16c74 --- /dev/null +++ b/validate/scan_drift.php @@ -0,0 +1,596 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/validate/scan_drift.php + * VERSION: 04.06.00 + * BRIEF: Standards drift detection - scans repositories for divergence from templates + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use MokoEnterprise\{ + ApiClient, + AuditLogger, + CliFramework, + MetricsCollector +}; + +/** + * Standards Drift Scanner + * + * Scans repositories for drift from MokoStandards templates + */ +class DriftScanner extends CliFramework +{ + private const VERSION = '04.06.00'; + private const DEFAULT_ORG = 'mokoconsulting-tech'; + + private ApiClient $apiClient; + private AuditLogger $logger; + private MetricsCollector $metrics; + + private array $driftResults = []; + private array $templates = []; + + protected function configure(): void + { + $this->setDescription('Scan repositories for standards drift'); + $this->addArgument('--org', 'GitHub organization', self::DEFAULT_ORG); + $this->addArgument('--repos', 'Specific repositories (comma-separated)', ''); + $this->addArgument('--type', 'Filter by repository type', ''); + $this->addArgument('--create-issues', 'Create GitHub issues for drift', false); + $this->addArgument('--threshold', 'Drift score threshold (0-100)', '20'); + $this->addArgument('--json', 'Output as JSON', false); + } + + protected function initialize(): void + { + parent::initialize(); + + $this->logger = new AuditLogger('drift_scanner'); + $this->metrics = new MetricsCollector(); + + // Initialize API client + $token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN'); + if (empty($token)) { + $this->error("GH_TOKEN or GITHUB_TOKEN environment variable required"); + exit(1); + } + + $this->apiClient = new ApiClient($token, ['base_url' => 'https://api.github.com']); + + $this->log("Standards Drift Scanner v" . self::VERSION); + } + + protected function run(): int + { + $org = $this->getArgument('--org'); + $repos = $this->getArgument('--repos'); + $type = $this->getArgument('--type'); + $createIssues = $this->getArgument('--create-issues'); + $threshold = (float)$this->getArgument('--threshold'); + $jsonOutput = $this->getArgument('--json'); + + $this->log("Scanning organization: {$org}"); + + // Load templates + $this->loadTemplates(); + + // Get repositories to scan + $repositories = $this->getRepositories($org, $repos, $type); + + if (empty($repositories)) { + $this->warn("No repositories found to scan"); + return 0; + } + + $this->log("Found " . count($repositories) . " repositories to scan"); + + // Scan each repository + $this->section('Scanning repositories'); + $total = count($repositories); + $i = 0; + foreach ($repositories as $repo) { + $this->progress(++$i, $total, (string) $repo); + $this->scanRepository($org, $repo); + } + if ($total >= 3) { + $this->progress($total, $total, 'done', true); + } + + // Generate report + if ($jsonOutput) { + echo json_encode($this->driftResults, JSON_PRETTY_PRINT) . PHP_EOL; + } else { + $this->displayReport($threshold); + } + + // Create issues if requested + if ($createIssues) { + $this->createDriftIssues($org, $threshold); + } + + // Record metrics + $this->recordMetrics(); + + // Return exit code based on drift threshold + $highDriftCount = count(array_filter( + $this->driftResults, + fn($r) => $r['drift_score'] >= $threshold + )); + + return $highDriftCount > 0 ? 1 : 0; + } + + private function loadTemplates(): void + { + $this->log("Loading templates..."); + + $templatesDir = __DIR__ . '/../../templates'; + + // Workflows + $workflowsDir = "{$templatesDir}/workflows"; + if (is_dir($workflowsDir)) { + $this->templates['workflows'] = $this->scanTemplateDirectory($workflowsDir); + } + + // GitHub configs + $githubDir = "{$templatesDir}/github"; + if (is_dir($githubDir)) { + $this->templates['github'] = $this->scanTemplateDirectory($githubDir); + } + + // Issue templates + $issueTemplatesDir = "{$templatesDir}/ISSUE_TEMPLATE"; + if (is_dir($issueTemplatesDir)) { + $this->templates['issue_templates'] = $this->scanTemplateDirectory($issueTemplatesDir); + } + + $totalTemplates = array_sum(array_map('count', $this->templates)); + $this->log("Loaded {$totalTemplates} templates"); + } + + private function scanTemplateDirectory(string $dir): array + { + $templates = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + $relativePath = substr($file->getPathname(), strlen($dir) + 1); + $templates[$relativePath] = [ + 'path' => $file->getPathname(), + 'size' => $file->getSize(), + 'mtime' => $file->getMTime(), + ]; + } + } + + return $templates; + } + + private function getRepositories(string $org, string $repoFilter, string $typeFilter): array + { + if (!empty($repoFilter)) { + return array_map('trim', explode(',', $repoFilter)); + } + + // Fetch all repositories from GitHub + try { + $response = $this->apiClient->get("/orgs/{$org}/repos", [ + 'type' => 'all', + 'per_page' => 100, + ]); + + $repos = array_map(fn($r) => $r['name'], $response); + + // Filter by type if specified + if (!empty($typeFilter)) { + $repos = array_filter($repos, function($repo) use ($org, $typeFilter) { + $repoType = $this->detectRepositoryType($org, $repo); + return $repoType === $typeFilter; + }); + } + + return $repos; + } catch (Exception $e) { + $this->error("Failed to fetch repositories: " . $e->getMessage()); + return []; + } + } + + private function detectRepositoryType(string $org, string $repo): string + { + // Try to read override.tf to determine type + try { + $override = $this->apiClient->get("/repos/{$org}/{$repo}/contents/.github/override.tf"); + if (!empty($override['content'])) { + $content = base64_decode($override['content']); + if (preg_match('/repository_type\s*=\s*"([^"]+)"/', $content, $matches)) { + return $matches[1]; + } + } + } catch (Exception $e) { + // Override file doesn't exist, try to detect from files + } + + // Detect from file presence + try { + // Check for package.json (nodejs) + $this->apiClient->get("/repos/{$org}/{$repo}/contents/package.json"); + return 'nodejs'; + } catch (Exception $e) {} + + try { + // Check for terraform files + $files = $this->apiClient->get("/repos/{$org}/{$repo}/contents"); + foreach ($files as $file) { + if (str_ends_with($file['name'], '.tf')) { + return 'terraform'; + } + } + } catch (Exception $e) {} + + return 'generic'; + } + + private function scanRepository(string $org, string $repo): void + { + $this->log("Scanning {$repo}..."); + + $drift = [ + 'repository' => $repo, + 'type' => $this->detectRepositoryType($org, $repo), + 'drift_score' => 0, + 'missing_files' => [], + 'outdated_files' => [], + 'modified_files' => [], + 'total_files_checked' => 0, + ]; + + // Get override configuration + $overrideConfig = $this->getOverrideConfig($org, $repo); + $protectedFiles = $overrideConfig['protected_files'] ?? []; + $syncExclusions = $overrideConfig['sync_exclusions'] ?? []; + + // Check workflows — scan both .github/workflows and .gitea/workflows + $drift = $this->checkFileCategory($org, $repo, 'workflows', '.github/workflows', $drift, $protectedFiles, $syncExclusions); + $drift = $this->checkFileCategory($org, $repo, 'workflows_gitea', '.gitea/workflows', $drift, $protectedFiles, $syncExclusions); + + // Check GitHub configs + $drift = $this->checkFileCategory($org, $repo, 'github', '.github', $drift, $protectedFiles, $syncExclusions); + + // Check issue templates + $drift = $this->checkFileCategory($org, $repo, 'issue_templates', '.github/ISSUE_TEMPLATE', $drift, $protectedFiles, $syncExclusions); + + // Calculate drift score (0-100) + $drift['drift_score'] = $this->calculateDriftScore($drift); + + // Determine drift level + $drift['drift_level'] = $this->getDriftLevel($drift['drift_score']); + + $this->driftResults[$repo] = $drift; + + $this->log(" Drift score: {$drift['drift_score']} ({$drift['drift_level']})"); + } + + private function getOverrideConfig(string $org, string $repo): array + { + try { + $override = $this->apiClient->get("/repos/{$org}/{$repo}/contents/.github/override.tf"); + if (!empty($override['content'])) { + $content = base64_decode($override['content']); + + // Parse Terraform HCL (simplified parsing) + $config = [ + 'protected_files' => [], + 'sync_exclusions' => [], + ]; + + // Extract protected_files array + if (preg_match('/protected_files\s*=\s*\[(.*?)\]/s', $content, $matches)) { + $items = explode(',', $matches[1]); + foreach ($items as $item) { + if (preg_match('/"([^"]+)"/', trim($item), $m)) { + $config['protected_files'][] = $m[1]; + } + } + } + + // Extract sync_exclusions array + if (preg_match('/sync_exclusions\s*=\s*\[(.*?)\]/s', $content, $matches)) { + $items = explode(',', $matches[1]); + foreach ($items as $item) { + if (preg_match('/"([^"]+)"/', trim($item), $m)) { + $config['sync_exclusions'][] = $m[1]; + } + } + } + + return $config; + } + } catch (Exception $e) { + // No override file + } + + return []; + } + + private function checkFileCategory(string $org, string $repo, string $category, string $remotePath, array $drift, array $protectedFiles, array $syncExclusions): array + { + if (!isset($this->templates[$category])) { + return $drift; + } + + foreach ($this->templates[$category] as $templateFile => $templateInfo) { + $remoteFile = $remotePath . '/' . str_replace('.template', '', $templateFile); + + // Skip if excluded or protected + if (in_array($remoteFile, $syncExclusions) || in_array($remoteFile, $protectedFiles)) { + continue; + } + + $drift['total_files_checked']++; + + try { + $remoteContent = $this->apiClient->get("/repos/{$org}/{$repo}/contents/{$remoteFile}"); + + if (empty($remoteContent['content'])) { + $drift['missing_files'][] = $remoteFile; + continue; + } + + $remoteFileContent = base64_decode($remoteContent['content']); + $templateContent = file_get_contents($templateInfo['path']); + + // Remove .template extension content if present + $templateContent = str_replace('.template', '', $templateContent); + + // Check for version mismatch + $remoteVersion = $this->extractVersion($remoteFileContent); + $templateVersion = $this->extractVersion($templateContent); + + if ($remoteVersion !== $templateVersion && !empty($templateVersion)) { + $drift['outdated_files'][] = [ + 'file' => $remoteFile, + 'current_version' => $remoteVersion ?: 'unknown', + 'expected_version' => $templateVersion, + ]; + } elseif ($this->hasSignificantDifferences($remoteFileContent, $templateContent)) { + $drift['modified_files'][] = $remoteFile; + } + + } catch (Exception $e) { + // File doesn't exist in remote + $drift['missing_files'][] = $remoteFile; + } + } + + return $drift; + } + + private function extractVersion(string $content): ?string + { + if (preg_match('/VERSION:\s*([0-9.]+)/', $content, $matches)) { + return $matches[1]; + } + return null; + } + + private function hasSignificantDifferences(string $remote, string $template): bool + { + // Normalize whitespace + $remote = preg_replace('/\s+/', ' ', $remote); + $template = preg_replace('/\s+/', ' ', $template); + + // Calculate similarity + $similarity = 0; + similar_text($remote, $template, $similarity); + + // Consider files with < 90% similarity as significantly different + return $similarity < 90; + } + + private function calculateDriftScore(array $drift): float + { + if ($drift['total_files_checked'] === 0) { + return 0; + } + + // Weight different types of drift + $missingWeight = 10; // Missing files are most critical + $outdatedWeight = 5; // Outdated versions are high priority + $modifiedWeight = 2; // Modified files are lower priority + + $driftPoints = + (count($drift['missing_files']) * $missingWeight) + + (count($drift['outdated_files']) * $outdatedWeight) + + (count($drift['modified_files']) * $modifiedWeight); + + // Normalize to 0-100 scale + $maxPoints = $drift['total_files_checked'] * $missingWeight; + $score = min(100, ($driftPoints / max(1, $maxPoints)) * 100); + + return round($score, 1); + } + + private function getDriftLevel(float $score): string + { + if ($score >= 50) return 'critical'; + if ($score >= 30) return 'high'; + if ($score >= 10) return 'medium'; + return 'low'; + } + + private function displayReport(float $threshold): void + { + $this->section('Drift report'); + + $totalRepos = count($this->driftResults); + $driftedRepos = array_filter($this->driftResults, fn($r) => $r['drift_score'] > 0); + + $this->log("Total repositories scanned: {$totalRepos}"); + $this->log("Repositories with drift: " . count($driftedRepos)); + + foreach ($this->driftResults as $repo => $drift) { + $detail = sprintf( + 'score: %s | missing: %d | outdated: %d | modified: %d', + $drift['drift_score'], + count($drift['missing_files']), + count($drift['outdated_files']), + count($drift['modified_files']) + ); + $this->status($drift['drift_score'] < $threshold, (string) $repo, $detail); + } + + $highDriftCount = count(array_filter( + $this->driftResults, + fn($r) => $r['drift_score'] >= $threshold + )); + + $this->printSummary( + $totalRepos - $highDriftCount, + $highDriftCount, + $this->elapsed() + ); + } + + private function createDriftIssues(string $org, float $threshold): void + { + $this->log("Creating drift issues..."); + + foreach ($this->driftResults as $repo => $drift) { + if ($drift['drift_score'] < $threshold) { + continue; + } + + $this->createDriftIssue($org, $repo, $drift); + } + } + + private function createDriftIssue(string $org, string $repo, array $drift): void + { + $icon = match($drift['drift_level']) { + 'critical' => '🚨', + 'high' => '⚠️', + 'medium' => '🟡', + 'low' => 'ℹ️', + }; + + $title = "{$icon} Standards Drift Detected: {$drift['drift_level']} ({$drift['drift_score']}%)"; + + $body = "## Standards Drift Report\n\n"; + $body .= "**Repository Type:** `{$drift['type']}`\n"; + $body .= "**Drift Score:** {$drift['drift_score']}/100\n"; + $body .= "**Drift Level:** {$drift['drift_level']}\n"; + $body .= "**Detected:** " . date('Y-m-d H:i:s T') . "\n\n"; + + if (!empty($drift['missing_files'])) { + $body .= "### ❌ Missing Files (" . count($drift['missing_files']) . ")\n\n"; + foreach ($drift['missing_files'] as $file) { + $body .= "- `{$file}`\n"; + } + $body .= "\n"; + } + + if (!empty($drift['outdated_files'])) { + $body .= "### 📅 Outdated Files (" . count($drift['outdated_files']) . ")\n\n"; + foreach ($drift['outdated_files'] as $file) { + $body .= "- `{$file['file']}`: {$file['current_version']} → {$file['expected_version']}\n"; + } + $body .= "\n"; + } + + if (!empty($drift['modified_files'])) { + $body .= "### ✏️ Modified Files (" . count($drift['modified_files']) . ")\n\n"; + foreach ($drift['modified_files'] as $file) { + $body .= "- `{$file}`\n"; + } + $body .= "\n"; + } + + $body .= "### 🔧 Remediation\n\n"; + $body .= "To fix this drift:\n\n"; + $body .= "1. **Option 1:** Run bulk sync to update all files automatically\n"; + $body .= " ```bash\n"; + $body .= " # From MokoStandards repository\n"; + $body .= " php api/automation/bulk_sync.php --repos=\"{$repo}\"\n"; + $body .= " ```\n\n"; + $body .= "2. **Option 2:** If changes are intentional, update `.github/override.tf` to exclude files\n\n"; + $body .= "3. **Option 3:** Manually update files to match templates\n\n"; + $body .= "---\n"; + $body .= "*This issue was automatically created by the standards drift scanner.*\n"; + + $labels = ['standards-drift', "drift-{$drift['drift_level']}", 'type: chore', 'automation']; + + try { + // Check for an existing drift issue to avoid duplicates + $existing = $this->apiClient->get("/repos/{$org}/{$repo}/issues", [ + 'labels' => 'standards-drift', + 'state' => 'all', + 'per_page' => 1, + 'sort' => 'created', + 'direction' => 'desc', + ]); + + if (!empty($existing) && isset($existing[0]['number'])) { + $num = $existing[0]['number']; + $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller-moko']]; + if (($existing[0]['state'] ?? 'open') === 'closed') { + $patch['state'] = 'open'; + } + $this->apiClient->patch("/repos/{$org}/{$repo}/issues/{$num}", $patch); + try { + $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$num}/labels", ['labels' => $labels]); + } catch (Exception $le) { /* non-fatal */ } + $this->log(" Updated drift issue #{$num} in {$repo}"); + } else { + $issue = $this->apiClient->post("/repos/{$org}/{$repo}/issues", [ + 'title' => $title, + 'body' => $body, + 'labels' => $labels, + 'assignees' => ['jmiller-moko'], + ]); + $num = $issue['number'] ?? '?'; + $this->log(" Created drift issue #{$num} in {$repo}"); + } + } catch (Exception $e) { + $this->error(" Failed to create/update issue in {$repo}: " . $e->getMessage()); + } + } + + private function recordMetrics(): void + { + $this->metrics->setGauge('drift_scan_total_repos', count($this->driftResults)); + $this->metrics->setGauge('drift_scan_drifted_repos', count(array_filter( + $this->driftResults, + fn($r) => $r['drift_score'] > 0 + ))); + + foreach (['critical', 'high', 'medium', 'low'] as $level) { + $count = count(array_filter( + $this->driftResults, + fn($r) => $r['drift_level'] === $level + )); + $this->metrics->setGauge("drift_scan_{$level}_repos", $count); + } + } +} + +// Run the application +$app = new DriftScanner(); +exit($app->execute($argv)); diff --git a/wrappers/auto_detect_platform.php b/wrappers/auto_detect_platform.php new file mode 100644 index 0000000..eeb5321 --- /dev/null +++ b/wrappers/auto_detect_platform.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/auto_detect_platform.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/validate/auto_detect_platform.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'auto_detect_platform'; +const SCRIPT_PATH = 'api/validate/auto_detect_platform.php'; +const SCRIPT_CATEGORY = 'validate'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/bulk_sync.php b/wrappers/bulk_sync.php new file mode 100644 index 0000000..9f8b21e --- /dev/null +++ b/wrappers/bulk_sync.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/bulk_sync.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/automation/bulk_sync.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'bulk_sync'; +const SCRIPT_PATH = 'api/automation/bulk_sync.php'; +const SCRIPT_CATEGORY = 'automation'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/check_changelog.php b/wrappers/check_changelog.php new file mode 100644 index 0000000..4d51d7b --- /dev/null +++ b/wrappers/check_changelog.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/check_changelog.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/validate/check_changelog.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'check_changelog'; +const SCRIPT_PATH = 'api/validate/check_changelog.php'; +const SCRIPT_CATEGORY = 'validate'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/check_dolibarr_module.php b/wrappers/check_dolibarr_module.php new file mode 100644 index 0000000..a8c838e --- /dev/null +++ b/wrappers/check_dolibarr_module.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/check_dolibarr_module.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/validate/check_dolibarr_module.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'check_dolibarr_module'; +const SCRIPT_PATH = 'api/validate/check_dolibarr_module.php'; +const SCRIPT_CATEGORY = 'validate'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/check_enterprise_readiness.php b/wrappers/check_enterprise_readiness.php new file mode 100644 index 0000000..f64f1bd --- /dev/null +++ b/wrappers/check_enterprise_readiness.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/check_enterprise_readiness.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/validate/check_enterprise_readiness.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'check_enterprise_readiness'; +const SCRIPT_PATH = 'api/validate/check_enterprise_readiness.php'; +const SCRIPT_CATEGORY = 'validate'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/check_joomla_manifest.php b/wrappers/check_joomla_manifest.php new file mode 100644 index 0000000..46b48e4 --- /dev/null +++ b/wrappers/check_joomla_manifest.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/check_joomla_manifest.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/validate/check_joomla_manifest.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'check_joomla_manifest'; +const SCRIPT_PATH = 'api/validate/check_joomla_manifest.php'; +const SCRIPT_CATEGORY = 'validate'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/check_language_structure.php b/wrappers/check_language_structure.php new file mode 100644 index 0000000..4573d22 --- /dev/null +++ b/wrappers/check_language_structure.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/check_language_structure.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/validate/check_language_structure.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'check_language_structure'; +const SCRIPT_PATH = 'api/validate/check_language_structure.php'; +const SCRIPT_CATEGORY = 'validate'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/check_license_headers.php b/wrappers/check_license_headers.php new file mode 100644 index 0000000..c57ce17 --- /dev/null +++ b/wrappers/check_license_headers.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/check_license_headers.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/validate/check_license_headers.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'check_license_headers'; +const SCRIPT_PATH = 'api/validate/check_license_headers.php'; +const SCRIPT_CATEGORY = 'validate'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/check_no_secrets.php b/wrappers/check_no_secrets.php new file mode 100644 index 0000000..77b352e --- /dev/null +++ b/wrappers/check_no_secrets.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/check_no_secrets.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/validate/check_no_secrets.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'check_no_secrets'; +const SCRIPT_PATH = 'api/validate/check_no_secrets.php'; +const SCRIPT_CATEGORY = 'validate'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/check_paths.php b/wrappers/check_paths.php new file mode 100644 index 0000000..bfd1be9 --- /dev/null +++ b/wrappers/check_paths.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/check_paths.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/validate/check_paths.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'check_paths'; +const SCRIPT_PATH = 'api/validate/check_paths.php'; +const SCRIPT_CATEGORY = 'validate'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/check_php_syntax.php b/wrappers/check_php_syntax.php new file mode 100644 index 0000000..d03d8c8 --- /dev/null +++ b/wrappers/check_php_syntax.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/check_php_syntax.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/validate/check_php_syntax.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'check_php_syntax'; +const SCRIPT_PATH = 'api/validate/check_php_syntax.php'; +const SCRIPT_CATEGORY = 'validate'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/check_repo_health.php b/wrappers/check_repo_health.php new file mode 100644 index 0000000..7717523 --- /dev/null +++ b/wrappers/check_repo_health.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/check_repo_health.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/validate/check_repo_health.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'check_repo_health'; +const SCRIPT_PATH = 'api/validate/check_repo_health.php'; +const SCRIPT_CATEGORY = 'validate'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/check_structure.php b/wrappers/check_structure.php new file mode 100644 index 0000000..feb1975 --- /dev/null +++ b/wrappers/check_structure.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/check_structure.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/validate/check_structure.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'check_structure'; +const SCRIPT_PATH = 'api/validate/check_structure.php'; +const SCRIPT_CATEGORY = 'validate'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/check_tabs.php b/wrappers/check_tabs.php new file mode 100644 index 0000000..37d2bd4 --- /dev/null +++ b/wrappers/check_tabs.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/check_tabs.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/validate/check_tabs.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'check_tabs'; +const SCRIPT_PATH = 'api/validate/check_tabs.php'; +const SCRIPT_CATEGORY = 'validate'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/check_version_consistency.php b/wrappers/check_version_consistency.php new file mode 100644 index 0000000..4e0c080 --- /dev/null +++ b/wrappers/check_version_consistency.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/check_version_consistency.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/validate/check_version_consistency.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'check_version_consistency'; +const SCRIPT_PATH = 'api/validate/check_version_consistency.php'; +const SCRIPT_CATEGORY = 'validate'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/check_xml_wellformed.php b/wrappers/check_xml_wellformed.php new file mode 100644 index 0000000..959b469 --- /dev/null +++ b/wrappers/check_xml_wellformed.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/check_xml_wellformed.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/validate/check_xml_wellformed.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'check_xml_wellformed'; +const SCRIPT_PATH = 'api/validate/check_xml_wellformed.php'; +const SCRIPT_CATEGORY = 'validate'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/deploy_sftp.php b/wrappers/deploy_sftp.php new file mode 100644 index 0000000..faa4121 --- /dev/null +++ b/wrappers/deploy_sftp.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/deploy_sftp.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/deploy/deploy-sftp.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'deploy_sftp'; +const SCRIPT_PATH = 'api/deploy/deploy-sftp.php'; +const SCRIPT_CATEGORY = 'deploy'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/fix_line_endings.php b/wrappers/fix_line_endings.php new file mode 100644 index 0000000..e3d4c39 --- /dev/null +++ b/wrappers/fix_line_endings.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/fix_line_endings.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/fix/fix_line_endings.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'fix_line_endings'; +const SCRIPT_PATH = 'api/fix/fix_line_endings.php'; +const SCRIPT_CATEGORY = 'fix'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/fix_permissions.php b/wrappers/fix_permissions.php new file mode 100644 index 0000000..f6188c9 --- /dev/null +++ b/wrappers/fix_permissions.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/fix_permissions.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/fix/fix_permissions.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'fix_permissions'; +const SCRIPT_PATH = 'api/fix/fix_permissions.php'; +const SCRIPT_CATEGORY = 'fix'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/fix_tabs.php b/wrappers/fix_tabs.php new file mode 100644 index 0000000..7ffb1f1 --- /dev/null +++ b/wrappers/fix_tabs.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/fix_tabs.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/fix/fix_tabs.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'fix_tabs'; +const SCRIPT_PATH = 'api/fix/fix_tabs.php'; +const SCRIPT_CATEGORY = 'fix'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/fix_trailing_spaces.php b/wrappers/fix_trailing_spaces.php new file mode 100644 index 0000000..8eeb2e2 --- /dev/null +++ b/wrappers/fix_trailing_spaces.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/fix_trailing_spaces.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/fix/fix_trailing_spaces.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'fix_trailing_spaces'; +const SCRIPT_PATH = 'api/fix/fix_trailing_spaces.php'; +const SCRIPT_CATEGORY = 'fix'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/gen_wrappers.php b/wrappers/gen_wrappers.php new file mode 100644 index 0000000..36d840a --- /dev/null +++ b/wrappers/gen_wrappers.php @@ -0,0 +1,205 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/gen_wrappers.php + * VERSION: 04.06.00 + * BRIEF: Generate PHP CLI wrapper scripts for every PHP script in api/ + */ + +declare(strict_types=1); + +/** + * Canonical map: wrapper_name => [script_path, category] + * + * @var array + */ +const SCRIPTS = [ + // validate + 'auto_detect_platform' => ['api/validate/auto_detect_platform.php', 'validate'], + 'check_changelog' => ['api/validate/check_changelog.php', 'validate'], + 'check_dolibarr_module' => ['api/validate/check_dolibarr_module.php', 'validate'], + 'check_enterprise_readiness' => ['api/validate/check_enterprise_readiness.php', 'validate'], + 'check_joomla_manifest' => ['api/validate/check_joomla_manifest.php', 'validate'], + 'check_language_structure' => ['api/validate/check_language_structure.php', 'validate'], + 'check_license_headers' => ['api/validate/check_license_headers.php', 'validate'], + 'check_no_secrets' => ['api/validate/check_no_secrets.php', 'validate'], + 'check_paths' => ['api/validate/check_paths.php', 'validate'], + 'check_php_syntax' => ['api/validate/check_php_syntax.php', 'validate'], + 'check_repo_health' => ['api/validate/check_repo_health.php', 'validate'], + 'check_structure' => ['api/validate/check_structure.php', 'validate'], + 'check_tabs' => ['api/validate/check_tabs.php', 'validate'], + 'check_version_consistency' => ['api/validate/check_version_consistency.php', 'validate'], + 'check_xml_wellformed' => ['api/validate/check_xml_wellformed.php', 'validate'], + 'scan_drift' => ['api/validate/scan_drift.php', 'validate'], + // automation + 'bulk_sync' => ['api/automation/bulk_sync.php', 'automation'], + // deploy + 'deploy_sftp' => ['api/deploy/deploy-sftp.php', 'deploy'], + // fix + 'fix_line_endings' => ['api/fix/fix_line_endings.php', 'fix'], + 'fix_permissions' => ['api/fix/fix_permissions.php', 'fix'], + 'fix_tabs' => ['api/fix/fix_tabs.php', 'fix'], + 'fix_trailing_spaces' => ['api/fix/fix_trailing_spaces.php', 'fix'], + // maintenance + 'pin_action_shas' => ['api/maintenance/pin_action_shas.php', 'maintenance'], + 'setup_labels' => ['api/maintenance/setup_labels.php', 'maintenance'], + 'sync_dolibarr_readmes' => ['api/maintenance/sync_dolibarr_readmes.php', 'maintenance'], + 'update_sha_hashes' => ['api/maintenance/update_sha_hashes.php', 'maintenance'], + 'update_version_from_readme' => ['api/maintenance/update_version_from_readme.php', 'maintenance'], + // plugin + 'plugin_health_check' => ['api/plugin_health_check.php', 'plugin'], + 'plugin_list' => ['api/plugin_list.php', 'plugin'], + 'plugin_metrics' => ['api/plugin_metrics.php', 'plugin'], + 'plugin_readiness' => ['api/plugin_readiness.php', 'plugin'], + 'plugin_validate' => ['api/plugin_validate.php', 'plugin'], +]; + +/** + * Render a single PHP wrapper file. + * + * @param string $name Wrapper / script name (snake_case) + * @param string $scriptPath Script path relative to repo root + * @param string $category Log sub-directory category + * @return string Complete PHP file content + */ +function renderWrapper(string $name, string $scriptPath, string $category): string +{ + return << + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/{$name}.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for {$scriptPath} + */ + + declare(strict_types=1); + + const SCRIPT_NAME = '{$name}'; + const SCRIPT_PATH = '{$scriptPath}'; + const SCRIPT_CATEGORY = '{$category}'; + + /** + * Walk up from \$startDir until a .git directory is found, or return getcwd(). + */ + function findRepoRoot(string \$startDir): string + { + \$dir = \$startDir; + for (\$i = 0; \$i < 12; \$i++) { + if (is_dir(\$dir . '/.git')) { + return \$dir; + } + \$parent = dirname(\$dir); + if (\$parent === \$dir) { + break; + } + \$dir = \$parent; + } + return (string) getcwd(); + } + + \$repoRoot = findRepoRoot(__DIR__); + \$fullPath = \$repoRoot . '/' . SCRIPT_PATH; + + if (!file_exists(\$fullPath)) { + fwrite(STDERR, '[ERROR] Script not found: ' . \$fullPath . PHP_EOL); + exit(1); + } + + \$logDir = \$repoRoot . '/logs/' . SCRIPT_CATEGORY; + @mkdir(\$logDir, 0755, true); + \$logFile = \$logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + + \$args = array_map('escapeshellarg', array_slice(\$argv, 1)); + \$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(\$fullPath); + if (\$args !== []) { + \$cmd .= ' ' . implode(' ', \$args); + } + + fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); + fwrite(STDOUT, '[INFO] Log: ' . \$logFile . PHP_EOL); + + \$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + \$process = proc_open(\$cmd, \$descriptors, \$pipes); + + if (!\is_resource(\$process)) { + fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); + exit(1); + } + + \$log = fopen(\$logFile, 'w'); + stream_set_blocking(\$pipes[1], false); + stream_set_blocking(\$pipes[2], false); + + while (true) { + \$read = [\$pipes[1], \$pipes[2]]; + \$write = []; + \$except = []; + if (stream_select(\$read, \$write, \$except, 0, 200000) === false) { + break; + } + foreach (\$read as \$stream) { + \$chunk = fread(\$stream, 8192); + if (\$chunk === false || \$chunk === '') { + continue; + } + \$out = (\$stream === \$pipes[1]) ? STDOUT : STDERR; + fwrite(\$out, \$chunk); + fwrite(\$log, \$chunk); + } + if (feof(\$pipes[1]) && feof(\$pipes[2])) { + break; + } + } + + fclose(\$pipes[1]); + fclose(\$pipes[2]); + fclose(\$log); + + \$exitCode = proc_close(\$process); + + if (\$exitCode === 0) { + fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); + } else { + fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . \$exitCode . ') — see ' . \$logFile . PHP_EOL); + } + + exit(\$exitCode); + PHP; +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +$outDir = __DIR__; +$count = 0; + +foreach (SCRIPTS as $name => [$scriptPath, $category]) { + $content = renderWrapper($name, $scriptPath, $category); + // Heredoc indentation: strip leading tab from each line + $content = preg_replace('/^\t/m', '', $content) ?? $content; + $filePath = $outDir . DIRECTORY_SEPARATOR . $name . '.php'; + + file_put_contents($filePath, $content); + echo " {$name}.php\n"; + $count++; +} + +echo "\nGenerated {$count} PHP wrappers in {$outDir}\n"; diff --git a/wrappers/index.md b/wrappers/index.md new file mode 100644 index 0000000..df57fdb --- /dev/null +++ b/wrappers/index.md @@ -0,0 +1,121 @@ + + +# PHP Wrappers + +Each file in this directory is a thin PHP wrapper for one CLI script in `api/`. Wrappers +add two things the scripts themselves don't provide: + +1. **Automatic logging** — output is tee'd to `logs/{category}/{name}_{timestamp}.log` +2. **Repo-root detection** — the script runs correctly regardless of your working directory + +Scripts in `api/` can always be called directly with `php api/validate/check_repo_health.php`. +The wrappers exist for convenience and auditability. + +--- + +## Usage + +```bash +# Via wrapper (logs automatically) +php api/wrappers/check_repo_health.php --path /repos/mymodule + +# Direct (no log file) +php api/validate/check_repo_health.php --path /repos/mymodule + +# All wrappers forward --help to the underlying script +php api/wrappers/deploy_sftp.php --help +``` + +--- + +## Wrapper Index + +### Validate + +| Wrapper | Script | Description | +|---------|--------|-------------| +| `auto_detect_platform.php` | `api/validate/auto_detect_platform.php` | Detect project type and platform | +| `check_changelog.php` | `api/validate/check_changelog.php` | Validate CHANGELOG.md format | +| `check_dolibarr_module.php` | `api/validate/check_dolibarr_module.php` | Validate Dolibarr module structure | +| `check_enterprise_readiness.php` | `api/validate/check_enterprise_readiness.php` | Enterprise readiness checks | +| `check_joomla_manifest.php` | `api/validate/check_joomla_manifest.php` | Validate Joomla manifest XML | +| `check_language_structure.php` | `api/validate/check_language_structure.php` | Validate language file structure | +| `check_license_headers.php` | `api/validate/check_license_headers.php` | Check copyright headers in all files | +| `check_no_secrets.php` | `api/validate/check_no_secrets.php` | Scan for accidentally committed secrets | +| `check_paths.php` | `api/validate/check_paths.php` | Validate required paths exist | +| `check_php_syntax.php` | `api/validate/check_php_syntax.php` | PHP syntax check across the repo | +| `check_repo_health.php` | `api/validate/check_repo_health.php` | Comprehensive repository health check | +| `check_structure.php` | `api/validate/check_structure.php` | Validate repository directory structure | +| `check_tabs.php` | `api/validate/check_tabs.php` | Check indentation consistency | +| `check_version_consistency.php` | `api/validate/check_version_consistency.php` | Check version numbers are consistent | +| `check_xml_wellformed.php` | `api/validate/check_xml_wellformed.php` | Validate XML files are well-formed | +| `scan_drift.php` | `api/validate/scan_drift.php` | Detect drift from MokoStandards | + +### Automation + +| Wrapper | Script | Description | +|---------|--------|-------------| +| `bulk_sync.php` | `api/automation/bulk_sync.php` | Bulk-sync standards to governed repos | + +### Deploy + +| Wrapper | Script | Description | +|---------|--------|-------------| +| `deploy_sftp.php` | `api/deploy/deploy-sftp.php` | Deploy src/ to remote server via SFTP | + +### Fix + +| Wrapper | Script | Description | +|---------|--------|-------------| +| `fix_line_endings.php` | `api/fix/fix_line_endings.php` | Normalise line endings across files | +| `fix_permissions.php` | `api/fix/fix_permissions.php` | Fix file permission issues | +| `fix_tabs.php` | `api/fix/fix_tabs.php` | Convert spaces to tabs | +| `fix_trailing_spaces.php` | `api/fix/fix_trailing_spaces.php` | Strip trailing whitespace | + +### Maintenance + +| Wrapper | Script | Description | +|---------|--------|-------------| +| `pin_action_shas.php` | `api/maintenance/pin_action_shas.php` | Pin GitHub Action references to SHAs | +| `setup_labels.php` | `api/maintenance/setup_labels.php` | Configure GitHub issue labels | +| `sync_dolibarr_readmes.php` | `api/maintenance/sync_dolibarr_readmes.php` | Sync Dolibarr README files | +| `update_sha_hashes.php` | `api/maintenance/update_sha_hashes.php` | Update pinned SHA hashes | +| `update_version_from_readme.php` | `api/maintenance/update_version_from_readme.php` | Propagate version from README | + +### Plugin + +| Wrapper | Script | Description | +|---------|--------|-------------| +| `plugin_health_check.php` | `api/plugin_health_check.php` | Health check across all plugins | +| `plugin_list.php` | `api/plugin_list.php` | List detected plugins | +| `plugin_metrics.php` | `api/plugin_metrics.php` | Collect plugin metrics | +| `plugin_readiness.php` | `api/plugin_readiness.php` | Plugin readiness assessment | +| `plugin_validate.php` | `api/plugin_validate.php` | Validate plugin structure | + +--- + +## Adding a New Wrapper + +1. Add an entry to the `SCRIPTS` constant in `gen_wrappers.php` +2. Run `php api/wrappers/gen_wrappers.php` to regenerate all wrappers +3. Update the table above + +--- + +**Location:** `api/wrappers/` +**Generator:** `api/wrappers/gen_wrappers.php` +**Last Updated:** 2026-03-14 diff --git a/wrappers/pin_action_shas.php b/wrappers/pin_action_shas.php new file mode 100644 index 0000000..b974e4d --- /dev/null +++ b/wrappers/pin_action_shas.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/pin_action_shas.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/maintenance/pin_action_shas.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'pin_action_shas'; +const SCRIPT_PATH = 'api/maintenance/pin_action_shas.php'; +const SCRIPT_CATEGORY = 'maintenance'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/plugin_health_check.php b/wrappers/plugin_health_check.php new file mode 100644 index 0000000..37a4dff --- /dev/null +++ b/wrappers/plugin_health_check.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/plugin_health_check.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/plugin_health_check.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'plugin_health_check'; +const SCRIPT_PATH = 'api/plugin_health_check.php'; +const SCRIPT_CATEGORY = 'plugin'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/plugin_list.php b/wrappers/plugin_list.php new file mode 100644 index 0000000..a35f089 --- /dev/null +++ b/wrappers/plugin_list.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/plugin_list.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/plugin_list.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'plugin_list'; +const SCRIPT_PATH = 'api/plugin_list.php'; +const SCRIPT_CATEGORY = 'plugin'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/plugin_metrics.php b/wrappers/plugin_metrics.php new file mode 100644 index 0000000..b390a8b --- /dev/null +++ b/wrappers/plugin_metrics.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/plugin_metrics.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/plugin_metrics.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'plugin_metrics'; +const SCRIPT_PATH = 'api/plugin_metrics.php'; +const SCRIPT_CATEGORY = 'plugin'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/plugin_readiness.php b/wrappers/plugin_readiness.php new file mode 100644 index 0000000..d533ae8 --- /dev/null +++ b/wrappers/plugin_readiness.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/plugin_readiness.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/plugin_readiness.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'plugin_readiness'; +const SCRIPT_PATH = 'api/plugin_readiness.php'; +const SCRIPT_CATEGORY = 'plugin'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/plugin_validate.php b/wrappers/plugin_validate.php new file mode 100644 index 0000000..3a8861c --- /dev/null +++ b/wrappers/plugin_validate.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/plugin_validate.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/plugin_validate.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'plugin_validate'; +const SCRIPT_PATH = 'api/plugin_validate.php'; +const SCRIPT_CATEGORY = 'plugin'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/scan_drift.php b/wrappers/scan_drift.php new file mode 100644 index 0000000..759fcc2 --- /dev/null +++ b/wrappers/scan_drift.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/scan_drift.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/validate/scan_drift.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'scan_drift'; +const SCRIPT_PATH = 'api/validate/scan_drift.php'; +const SCRIPT_CATEGORY = 'validate'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/setup_labels.php b/wrappers/setup_labels.php new file mode 100644 index 0000000..8be11c6 --- /dev/null +++ b/wrappers/setup_labels.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/setup_labels.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/maintenance/setup_labels.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'setup_labels'; +const SCRIPT_PATH = 'api/maintenance/setup_labels.php'; +const SCRIPT_CATEGORY = 'maintenance'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/sync_dolibarr_readmes.php b/wrappers/sync_dolibarr_readmes.php new file mode 100644 index 0000000..3851759 --- /dev/null +++ b/wrappers/sync_dolibarr_readmes.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/sync_dolibarr_readmes.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/maintenance/sync_dolibarr_readmes.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'sync_dolibarr_readmes'; +const SCRIPT_PATH = 'api/maintenance/sync_dolibarr_readmes.php'; +const SCRIPT_CATEGORY = 'maintenance'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/update_sha_hashes.php b/wrappers/update_sha_hashes.php new file mode 100644 index 0000000..cdd8735 --- /dev/null +++ b/wrappers/update_sha_hashes.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/update_sha_hashes.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/maintenance/update_sha_hashes.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'update_sha_hashes'; +const SCRIPT_PATH = 'api/maintenance/update_sha_hashes.php'; +const SCRIPT_CATEGORY = 'maintenance'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file diff --git a/wrappers/update_version_from_readme.php b/wrappers/update_version_from_readme.php new file mode 100644 index 0000000..d1db518 --- /dev/null +++ b/wrappers/update_version_from_readme.php @@ -0,0 +1,109 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Wrappers + * INGROUP: MokoStandards + * REPO: https://github.com/mokoconsulting-tech/MokoStandards + * PATH: /api/wrappers/update_version_from_readme.php + * VERSION: 04.06.00 + * BRIEF: PHP wrapper for api/maintenance/update_version_from_readme.php + */ + +declare(strict_types=1); + +const SCRIPT_NAME = 'update_version_from_readme'; +const SCRIPT_PATH = 'api/maintenance/update_version_from_readme.php'; +const SCRIPT_CATEGORY = 'maintenance'; + +/** + * Walk up from $startDir until a .git directory is found, or return getcwd(). + */ +function findRepoRoot(string $startDir): string +{ +$dir = $startDir; +for ($i = 0; $i < 12; $i++) { + if (is_dir($dir . '/.git')) { + return $dir; + } + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; +} +return (string) getcwd(); +} + +$repoRoot = findRepoRoot(__DIR__); +$fullPath = $repoRoot . '/' . SCRIPT_PATH; + +if (!file_exists($fullPath)) { +fwrite(STDERR, '[ERROR] Script not found: ' . $fullPath . PHP_EOL); +exit(1); +} + +$logDir = $repoRoot . '/logs/' . SCRIPT_CATEGORY; +@mkdir($logDir, 0755, true); +$logFile = $logDir . '/' . SCRIPT_NAME . '_' . date('Ymd_His') . '.log'; + +$args = array_map('escapeshellarg', array_slice($argv, 1)); +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fullPath); +if ($args !== []) { +$cmd .= ' ' . implode(' ', $args); +} + +fwrite(STDOUT, '[INFO] Running ' . SCRIPT_NAME . '...' . PHP_EOL); +fwrite(STDOUT, '[INFO] Log: ' . $logFile . PHP_EOL); + +$descriptors = [0 => STDIN, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open($cmd, $descriptors, $pipes); + +if (!\is_resource($process)) { +fwrite(STDERR, '[ERROR] Failed to start ' . SCRIPT_NAME . PHP_EOL); +exit(1); +} + +$log = fopen($logFile, 'w'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { +$read = [$pipes[1], $pipes[2]]; +$write = []; +$except = []; +if (stream_select($read, $write, $except, 0, 200000) === false) { + break; +} +foreach ($read as $stream) { + $chunk = fread($stream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + $out = ($stream === $pipes[1]) ? STDOUT : STDERR; + fwrite($out, $chunk); + fwrite($log, $chunk); +} +if (feof($pipes[1]) && feof($pipes[2])) { + break; +} +} + +fclose($pipes[1]); +fclose($pipes[2]); +fclose($log); + +$exitCode = proc_close($process); + +if ($exitCode === 0) { +fwrite(STDOUT, '[OK] ' . SCRIPT_NAME . ' completed successfully' . PHP_EOL); +} else { +fwrite(STDERR, '[ERROR] ' . SCRIPT_NAME . ' failed (exit ' . $exitCode . ') — see ' . $logFile . PHP_EOL); +} + +exit($exitCode); \ No newline at end of file