@jaguilar87/gaia-ops 4.4.0 → 4.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +12 -3
- package/ARCHITECTURE.md +9 -8
- package/CHANGELOG.md +34 -0
- package/README.md +43 -11
- package/agents/terraform-architect.md +1 -1
- package/bin/README.md +2 -2
- package/bin/gaia-doctor.js +18 -5
- package/bin/gaia-history.js +0 -1
- package/bin/gaia-metrics.js +2 -2
- package/bin/gaia-scan.py +23 -1
- package/bin/gaia-update.js +346 -54
- package/bin/pre-publish-validate.js +33 -10
- package/commands/gaia.md +37 -0
- package/config/README.md +3 -9
- package/config/context-contracts.json +47 -15
- package/config/surface-routing.json +9 -1
- package/dist/gaia-ops/.claude-plugin/plugin.json +22 -0
- package/dist/gaia-ops/agents/cloud-troubleshooter.md +73 -0
- package/dist/gaia-ops/agents/devops-developer.md +57 -0
- package/dist/gaia-ops/agents/gaia-system.md +58 -0
- package/dist/gaia-ops/agents/gitops-operator.md +60 -0
- package/dist/gaia-ops/agents/speckit-planner.md +71 -0
- package/dist/gaia-ops/agents/terraform-architect.md +60 -0
- package/dist/gaia-ops/commands/gaia.md +37 -0
- package/dist/gaia-ops/config/README.md +58 -0
- package/dist/gaia-ops/config/cloud/aws.json +140 -0
- package/dist/gaia-ops/config/cloud/gcp.json +145 -0
- package/dist/gaia-ops/config/context-contracts.json +131 -0
- package/dist/gaia-ops/config/git_standards.json +72 -0
- package/dist/gaia-ops/config/surface-routing.json +197 -0
- package/dist/gaia-ops/config/universal-rules.json +10 -0
- package/dist/gaia-ops/hooks/adapters/__init__.py +52 -0
- package/dist/gaia-ops/hooks/adapters/base.py +219 -0
- package/dist/gaia-ops/hooks/adapters/channel.py +17 -0
- package/dist/gaia-ops/hooks/adapters/claude_code.py +1477 -0
- package/dist/gaia-ops/hooks/adapters/types.py +194 -0
- package/dist/gaia-ops/hooks/adapters/utils.py +25 -0
- package/dist/gaia-ops/hooks/hooks.json +126 -0
- package/dist/gaia-ops/hooks/modules/__init__.py +15 -0
- package/dist/gaia-ops/hooks/modules/agents/__init__.py +29 -0
- package/dist/gaia-ops/hooks/modules/agents/contract_validator.py +647 -0
- package/dist/gaia-ops/hooks/modules/agents/response_contract.py +496 -0
- package/dist/gaia-ops/hooks/modules/agents/skill_injection_verifier.py +124 -0
- package/dist/gaia-ops/hooks/modules/agents/task_info_builder.py +74 -0
- package/dist/gaia-ops/hooks/modules/agents/transcript_analyzer.py +458 -0
- package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +152 -0
- package/dist/gaia-ops/hooks/modules/audit/__init__.py +28 -0
- package/dist/gaia-ops/hooks/modules/audit/event_detector.py +168 -0
- package/dist/gaia-ops/hooks/modules/audit/logger.py +131 -0
- package/dist/gaia-ops/hooks/modules/audit/metrics.py +134 -0
- package/dist/gaia-ops/hooks/modules/audit/workflow_auditor.py +576 -0
- package/dist/gaia-ops/hooks/modules/audit/workflow_recorder.py +296 -0
- package/dist/gaia-ops/hooks/modules/context/__init__.py +11 -0
- package/dist/gaia-ops/hooks/modules/context/anchor_tracker.py +317 -0
- package/dist/gaia-ops/hooks/modules/context/compact_context_builder.py +215 -0
- package/dist/gaia-ops/hooks/modules/context/context_cache.py +129 -0
- package/dist/gaia-ops/hooks/modules/context/context_freshness.py +145 -0
- package/dist/gaia-ops/hooks/modules/context/context_injector.py +427 -0
- package/dist/gaia-ops/hooks/modules/context/context_writer.py +518 -0
- package/dist/gaia-ops/hooks/modules/context/contracts_loader.py +161 -0
- package/dist/gaia-ops/hooks/modules/core/__init__.py +40 -0
- package/dist/gaia-ops/hooks/modules/core/hook_entry.py +78 -0
- package/dist/gaia-ops/hooks/modules/core/paths.py +160 -0
- package/dist/gaia-ops/hooks/modules/core/plugin_mode.py +149 -0
- package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +558 -0
- package/dist/gaia-ops/hooks/modules/core/state.py +179 -0
- package/dist/gaia-ops/hooks/modules/core/stdin.py +24 -0
- package/dist/gaia-ops/hooks/modules/events/__init__.py +1 -0
- package/dist/gaia-ops/hooks/modules/events/event_writer.py +210 -0
- package/dist/gaia-ops/hooks/modules/identity/__init__.py +0 -0
- package/dist/gaia-ops/hooks/modules/identity/identity_provider.py +21 -0
- package/dist/gaia-ops/hooks/modules/identity/ops_identity.py +34 -0
- package/dist/gaia-ops/hooks/modules/identity/security_identity.py +10 -0
- package/dist/gaia-ops/hooks/modules/memory/__init__.py +8 -0
- package/dist/gaia-ops/hooks/modules/memory/episode_writer.py +227 -0
- package/dist/gaia-ops/hooks/modules/orchestrator/__init__.py +1 -0
- package/dist/gaia-ops/hooks/modules/orchestrator/delegate_mode.py +128 -0
- package/dist/gaia-ops/hooks/modules/scanning/__init__.py +8 -0
- package/dist/gaia-ops/hooks/modules/scanning/scan_trigger.py +84 -0
- package/dist/gaia-ops/hooks/modules/security/__init__.py +89 -0
- package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +87 -0
- package/dist/gaia-ops/hooks/modules/security/approval_constants.py +23 -0
- package/dist/gaia-ops/hooks/modules/security/approval_grants.py +912 -0
- package/dist/gaia-ops/hooks/modules/security/approval_messages.py +71 -0
- package/dist/gaia-ops/hooks/modules/security/approval_scopes.py +153 -0
- package/dist/gaia-ops/hooks/modules/security/blocked_commands.py +584 -0
- package/dist/gaia-ops/hooks/modules/security/blocked_message_formatter.py +86 -0
- package/dist/gaia-ops/hooks/modules/security/command_semantics.py +130 -0
- package/dist/gaia-ops/hooks/modules/security/gitops_validator.py +179 -0
- package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +850 -0
- package/dist/gaia-ops/hooks/modules/security/prompt_validator.py +40 -0
- package/dist/gaia-ops/hooks/modules/security/tiers.py +196 -0
- package/dist/gaia-ops/hooks/modules/session/__init__.py +10 -0
- package/dist/gaia-ops/hooks/modules/session/session_context_writer.py +100 -0
- package/dist/gaia-ops/hooks/modules/session/session_event_injector.py +158 -0
- package/dist/gaia-ops/hooks/modules/session/session_manager.py +31 -0
- package/dist/gaia-ops/hooks/modules/tools/__init__.py +25 -0
- package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +708 -0
- package/dist/gaia-ops/hooks/modules/tools/cloud_pipe_validator.py +181 -0
- package/dist/gaia-ops/hooks/modules/tools/hook_response.py +55 -0
- package/dist/gaia-ops/hooks/modules/tools/shell_parser.py +227 -0
- package/dist/gaia-ops/hooks/modules/tools/task_validator.py +283 -0
- package/dist/gaia-ops/hooks/modules/validation/__init__.py +23 -0
- package/dist/gaia-ops/hooks/modules/validation/commit_validator.py +380 -0
- package/dist/gaia-ops/hooks/post_compact.py +43 -0
- package/dist/gaia-ops/hooks/post_tool_use.py +54 -0
- package/dist/gaia-ops/hooks/pre_tool_use.py +383 -0
- package/dist/gaia-ops/hooks/session_start.py +69 -0
- package/dist/gaia-ops/hooks/stop_hook.py +69 -0
- package/dist/gaia-ops/hooks/subagent_start.py +71 -0
- package/dist/gaia-ops/hooks/subagent_stop.py +288 -0
- package/dist/gaia-ops/hooks/task_completed.py +70 -0
- package/dist/gaia-ops/hooks/user_prompt_submit.py +177 -0
- package/dist/gaia-ops/settings.json +72 -0
- package/dist/gaia-ops/skills/README.md +109 -0
- package/dist/gaia-ops/skills/agent-protocol/SKILL.md +105 -0
- package/dist/gaia-ops/skills/agent-protocol/examples.md +170 -0
- package/dist/gaia-ops/skills/agent-response/SKILL.md +53 -0
- package/dist/gaia-ops/skills/approval/SKILL.md +85 -0
- package/dist/gaia-ops/skills/approval/examples.md +140 -0
- package/dist/gaia-ops/skills/approval/reference.md +57 -0
- package/dist/gaia-ops/skills/command-execution/SKILL.md +64 -0
- package/dist/gaia-ops/skills/command-execution/reference.md +83 -0
- package/dist/gaia-ops/skills/context-updater/SKILL.md +76 -0
- package/dist/gaia-ops/skills/context-updater/examples.md +71 -0
- package/dist/gaia-ops/skills/developer-patterns/SKILL.md +93 -0
- package/dist/gaia-ops/skills/developer-patterns/reference.md +112 -0
- package/dist/gaia-ops/skills/execution/SKILL.md +66 -0
- package/dist/gaia-ops/skills/fast-queries/SKILL.md +47 -0
- package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +92 -0
- package/dist/gaia-ops/skills/gaia-patterns/reference.md +22 -0
- package/dist/gaia-ops/skills/git-conventions/SKILL.md +48 -0
- package/dist/gaia-ops/skills/gitops-patterns/SKILL.md +73 -0
- package/dist/gaia-ops/skills/gitops-patterns/reference.md +183 -0
- package/dist/gaia-ops/skills/investigation/SKILL.md +77 -0
- package/dist/gaia-ops/skills/orchestrator-approval/SKILL.md +64 -0
- package/dist/gaia-ops/skills/reference.md +134 -0
- package/dist/gaia-ops/skills/security-tiers/SKILL.md +61 -0
- package/dist/gaia-ops/skills/security-tiers/destructive-commands-reference.md +623 -0
- package/dist/gaia-ops/skills/security-tiers/reference.md +39 -0
- package/dist/gaia-ops/skills/skill-creation/SKILL.md +119 -0
- package/dist/gaia-ops/skills/specification/SKILL.md +186 -0
- package/dist/gaia-ops/skills/speckit-workflow/SKILL.md +165 -0
- package/dist/gaia-ops/skills/speckit-workflow/reference.md +117 -0
- package/dist/gaia-ops/skills/terraform-patterns/SKILL.md +63 -0
- package/dist/gaia-ops/skills/terraform-patterns/reference.md +93 -0
- package/dist/gaia-ops/speckit/README.md +516 -0
- package/dist/gaia-ops/speckit/scripts/.gitkeep +0 -0
- package/dist/gaia-ops/speckit/templates/adr-template.md +118 -0
- package/dist/gaia-ops/speckit/templates/agent-file-template.md +23 -0
- package/dist/gaia-ops/speckit/templates/plan-template.md +227 -0
- package/dist/gaia-ops/speckit/templates/spec-template.md +140 -0
- package/dist/gaia-ops/speckit/templates/tasks-template.md +257 -0
- package/dist/gaia-ops/tools/context/README.md +132 -0
- package/dist/gaia-ops/tools/context/__init__.py +42 -0
- package/dist/gaia-ops/tools/context/_paths.py +20 -0
- package/dist/gaia-ops/tools/context/context_provider.py +476 -0
- package/dist/gaia-ops/tools/context/context_section_reader.py +330 -0
- package/dist/gaia-ops/tools/context/deep_merge.py +159 -0
- package/dist/gaia-ops/tools/context/pending_updates.py +760 -0
- package/dist/gaia-ops/tools/context/surface_router.py +278 -0
- package/dist/gaia-ops/tools/fast-queries/README.md +65 -0
- package/dist/gaia-ops/tools/fast-queries/__init__.py +30 -0
- package/dist/gaia-ops/tools/fast-queries/appservices/quicktriage_devops_developer.sh +75 -0
- package/dist/gaia-ops/tools/fast-queries/cloud/aws/quicktriage_aws_troubleshooter.sh +32 -0
- package/dist/gaia-ops/tools/fast-queries/cloud/gcp/quicktriage_gcp_troubleshooter.sh +88 -0
- package/dist/gaia-ops/tools/fast-queries/gitops/quicktriage_gitops_operator.sh +48 -0
- package/dist/gaia-ops/tools/fast-queries/run_triage.sh +59 -0
- package/dist/gaia-ops/tools/fast-queries/terraform/quicktriage_terraform_architect.sh +80 -0
- package/dist/gaia-ops/tools/gaia_simulator/__init__.py +33 -0
- package/dist/gaia-ops/tools/gaia_simulator/cli.py +354 -0
- package/dist/gaia-ops/tools/gaia_simulator/extractor.py +457 -0
- package/dist/gaia-ops/tools/gaia_simulator/reporter.py +258 -0
- package/dist/gaia-ops/tools/gaia_simulator/routing_simulator.py +334 -0
- package/dist/gaia-ops/tools/gaia_simulator/runner.py +539 -0
- package/dist/gaia-ops/tools/gaia_simulator/skills_mapper.py +262 -0
- package/dist/gaia-ops/tools/memory/README.md +0 -0
- package/dist/gaia-ops/tools/memory/__init__.py +20 -0
- package/dist/gaia-ops/tools/memory/episodic.py +1196 -0
- package/dist/gaia-ops/tools/persist_transcript_analysis.py +85 -0
- package/dist/gaia-ops/tools/review/__init__.py +1 -0
- package/dist/gaia-ops/tools/review/review_engine.py +157 -0
- package/dist/gaia-ops/tools/scan/__init__.py +35 -0
- package/dist/gaia-ops/tools/scan/config.py +247 -0
- package/dist/gaia-ops/tools/scan/merge.py +212 -0
- package/dist/gaia-ops/tools/scan/orchestrator.py +549 -0
- package/dist/gaia-ops/tools/scan/registry.py +127 -0
- package/dist/gaia-ops/tools/scan/scanners/__init__.py +18 -0
- package/dist/gaia-ops/tools/scan/scanners/base.py +137 -0
- package/dist/gaia-ops/tools/scan/scanners/environment.py +324 -0
- package/dist/gaia-ops/tools/scan/scanners/git.py +570 -0
- package/dist/gaia-ops/tools/scan/scanners/infrastructure.py +875 -0
- package/dist/gaia-ops/tools/scan/scanners/orchestration.py +600 -0
- package/dist/gaia-ops/tools/scan/scanners/stack.py +1085 -0
- package/dist/gaia-ops/tools/scan/scanners/tools.py +260 -0
- package/dist/gaia-ops/tools/scan/setup.py +753 -0
- package/dist/gaia-ops/tools/scan/tests/__init__.py +1 -0
- package/dist/gaia-ops/tools/scan/tests/conftest.py +796 -0
- package/dist/gaia-ops/tools/scan/tests/test_environment.py +323 -0
- package/dist/gaia-ops/tools/scan/tests/test_git.py +419 -0
- package/dist/gaia-ops/tools/scan/tests/test_infrastructure.py +382 -0
- package/dist/gaia-ops/tools/scan/tests/test_integration.py +920 -0
- package/dist/gaia-ops/tools/scan/tests/test_merge.py +269 -0
- package/dist/gaia-ops/tools/scan/tests/test_orchestration.py +304 -0
- package/dist/gaia-ops/tools/scan/tests/test_stack.py +604 -0
- package/dist/gaia-ops/tools/scan/tests/test_tools.py +349 -0
- package/dist/gaia-ops/tools/scan/ui.py +624 -0
- package/dist/gaia-ops/tools/scan/verify.py +266 -0
- package/dist/gaia-ops/tools/scan/walk.py +118 -0
- package/dist/gaia-ops/tools/scan/workspace.py +85 -0
- package/dist/gaia-ops/tools/validation/README.md +244 -0
- package/dist/gaia-ops/tools/validation/__init__.py +17 -0
- package/dist/gaia-ops/tools/validation/approval_gate.py +321 -0
- package/dist/gaia-ops/tools/validation/validate_skills.py +189 -0
- package/dist/gaia-security/.claude-plugin/plugin.json +22 -0
- package/dist/gaia-security/config/universal-rules.json +10 -0
- package/dist/gaia-security/hooks/adapters/__init__.py +52 -0
- package/dist/gaia-security/hooks/adapters/base.py +219 -0
- package/dist/gaia-security/hooks/adapters/channel.py +17 -0
- package/dist/gaia-security/hooks/adapters/claude_code.py +1477 -0
- package/dist/gaia-security/hooks/adapters/types.py +194 -0
- package/dist/gaia-security/hooks/adapters/utils.py +25 -0
- package/dist/gaia-security/hooks/hooks.json +57 -0
- package/dist/gaia-security/hooks/modules/__init__.py +15 -0
- package/dist/gaia-security/hooks/modules/agents/__init__.py +29 -0
- package/dist/gaia-security/hooks/modules/agents/contract_validator.py +647 -0
- package/dist/gaia-security/hooks/modules/agents/response_contract.py +496 -0
- package/dist/gaia-security/hooks/modules/agents/skill_injection_verifier.py +124 -0
- package/dist/gaia-security/hooks/modules/agents/task_info_builder.py +74 -0
- package/dist/gaia-security/hooks/modules/agents/transcript_analyzer.py +458 -0
- package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +152 -0
- package/dist/gaia-security/hooks/modules/audit/__init__.py +28 -0
- package/dist/gaia-security/hooks/modules/audit/event_detector.py +168 -0
- package/dist/gaia-security/hooks/modules/audit/logger.py +131 -0
- package/dist/gaia-security/hooks/modules/audit/metrics.py +134 -0
- package/dist/gaia-security/hooks/modules/audit/workflow_auditor.py +576 -0
- package/dist/gaia-security/hooks/modules/audit/workflow_recorder.py +296 -0
- package/dist/gaia-security/hooks/modules/context/__init__.py +11 -0
- package/dist/gaia-security/hooks/modules/context/anchor_tracker.py +317 -0
- package/dist/gaia-security/hooks/modules/context/compact_context_builder.py +215 -0
- package/dist/gaia-security/hooks/modules/context/context_cache.py +129 -0
- package/dist/gaia-security/hooks/modules/context/context_freshness.py +145 -0
- package/dist/gaia-security/hooks/modules/context/context_injector.py +427 -0
- package/dist/gaia-security/hooks/modules/context/context_writer.py +518 -0
- package/dist/gaia-security/hooks/modules/context/contracts_loader.py +161 -0
- package/dist/gaia-security/hooks/modules/core/__init__.py +40 -0
- package/dist/gaia-security/hooks/modules/core/hook_entry.py +78 -0
- package/dist/gaia-security/hooks/modules/core/paths.py +160 -0
- package/dist/gaia-security/hooks/modules/core/plugin_mode.py +149 -0
- package/dist/gaia-security/hooks/modules/core/plugin_setup.py +558 -0
- package/dist/gaia-security/hooks/modules/core/state.py +179 -0
- package/dist/gaia-security/hooks/modules/core/stdin.py +24 -0
- package/dist/gaia-security/hooks/modules/events/__init__.py +1 -0
- package/dist/gaia-security/hooks/modules/events/event_writer.py +210 -0
- package/dist/gaia-security/hooks/modules/identity/__init__.py +0 -0
- package/dist/gaia-security/hooks/modules/identity/identity_provider.py +21 -0
- package/dist/gaia-security/hooks/modules/identity/ops_identity.py +34 -0
- package/dist/gaia-security/hooks/modules/identity/security_identity.py +10 -0
- package/dist/gaia-security/hooks/modules/memory/__init__.py +8 -0
- package/dist/gaia-security/hooks/modules/memory/episode_writer.py +227 -0
- package/dist/gaia-security/hooks/modules/orchestrator/__init__.py +1 -0
- package/dist/gaia-security/hooks/modules/orchestrator/delegate_mode.py +128 -0
- package/dist/gaia-security/hooks/modules/scanning/__init__.py +8 -0
- package/dist/gaia-security/hooks/modules/scanning/scan_trigger.py +84 -0
- package/dist/gaia-security/hooks/modules/security/__init__.py +89 -0
- package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +87 -0
- package/dist/gaia-security/hooks/modules/security/approval_constants.py +23 -0
- package/dist/gaia-security/hooks/modules/security/approval_grants.py +912 -0
- package/dist/gaia-security/hooks/modules/security/approval_messages.py +71 -0
- package/dist/gaia-security/hooks/modules/security/approval_scopes.py +153 -0
- package/dist/gaia-security/hooks/modules/security/blocked_commands.py +584 -0
- package/dist/gaia-security/hooks/modules/security/blocked_message_formatter.py +86 -0
- package/dist/gaia-security/hooks/modules/security/command_semantics.py +130 -0
- package/dist/gaia-security/hooks/modules/security/gitops_validator.py +179 -0
- package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +850 -0
- package/dist/gaia-security/hooks/modules/security/prompt_validator.py +40 -0
- package/dist/gaia-security/hooks/modules/security/tiers.py +196 -0
- package/dist/gaia-security/hooks/modules/session/__init__.py +10 -0
- package/dist/gaia-security/hooks/modules/session/session_context_writer.py +100 -0
- package/dist/gaia-security/hooks/modules/session/session_event_injector.py +158 -0
- package/dist/gaia-security/hooks/modules/session/session_manager.py +31 -0
- package/dist/gaia-security/hooks/modules/tools/__init__.py +25 -0
- package/dist/gaia-security/hooks/modules/tools/bash_validator.py +708 -0
- package/dist/gaia-security/hooks/modules/tools/cloud_pipe_validator.py +181 -0
- package/dist/gaia-security/hooks/modules/tools/hook_response.py +55 -0
- package/dist/gaia-security/hooks/modules/tools/shell_parser.py +227 -0
- package/dist/gaia-security/hooks/modules/tools/task_validator.py +283 -0
- package/dist/gaia-security/hooks/modules/validation/__init__.py +23 -0
- package/dist/gaia-security/hooks/modules/validation/commit_validator.py +380 -0
- package/dist/gaia-security/hooks/post_tool_use.py +54 -0
- package/dist/gaia-security/hooks/pre_tool_use.py +383 -0
- package/dist/gaia-security/hooks/session_start.py +69 -0
- package/dist/gaia-security/hooks/stop_hook.py +69 -0
- package/dist/gaia-security/hooks/user_prompt_submit.py +177 -0
- package/dist/gaia-security/settings.json +58 -0
- package/git-hooks/commit-msg +41 -0
- package/hooks/README.md +8 -6
- package/hooks/adapters/channel.py +0 -25
- package/hooks/adapters/claude_code.py +364 -125
- package/hooks/elicitation_result.py +132 -0
- package/hooks/hooks.json +10 -1
- package/hooks/modules/README.md +3 -2
- package/hooks/modules/agents/contract_validator.py +3 -51
- package/hooks/modules/agents/response_contract.py +4 -8
- package/hooks/modules/agents/transcript_reader.py +4 -5
- package/hooks/modules/audit/__init__.py +4 -6
- package/hooks/modules/audit/event_detector.py +0 -2
- package/hooks/modules/audit/metrics.py +108 -187
- package/hooks/modules/audit/workflow_auditor.py +0 -4
- package/hooks/modules/audit/workflow_recorder.py +0 -5
- package/hooks/modules/context/compact_context_builder.py +1 -0
- package/hooks/modules/context/context_cache.py +129 -0
- package/hooks/modules/context/context_injector.py +18 -40
- package/hooks/modules/context/context_writer.py +1 -25
- package/hooks/modules/context/contracts_loader.py +7 -10
- package/hooks/modules/core/hook_entry.py +1 -0
- package/hooks/modules/core/paths.py +12 -13
- package/hooks/modules/core/plugin_mode.py +74 -4
- package/hooks/modules/core/plugin_setup.py +395 -23
- package/hooks/modules/events/__init__.py +1 -0
- package/hooks/modules/events/event_writer.py +210 -0
- package/hooks/modules/identity/ops_identity.py +18 -27
- package/hooks/modules/memory/episode_writer.py +1 -6
- package/hooks/modules/orchestrator/__init__.py +1 -0
- package/hooks/modules/orchestrator/delegate_mode.py +128 -0
- package/hooks/modules/security/__init__.py +2 -4
- package/hooks/modules/security/approval_constants.py +5 -1
- package/hooks/modules/security/approval_grants.py +189 -6
- package/hooks/modules/security/approval_messages.py +9 -21
- package/hooks/modules/security/blocked_commands.py +98 -34
- package/hooks/modules/security/command_semantics.py +0 -4
- package/hooks/modules/security/gitops_validator.py +1 -11
- package/hooks/modules/security/mutative_verbs.py +179 -38
- package/hooks/modules/security/tiers.py +1 -19
- package/hooks/modules/session/session_event_injector.py +1 -25
- package/hooks/modules/tools/bash_validator.py +310 -94
- package/hooks/modules/tools/shell_parser.py +0 -1
- package/hooks/modules/tools/task_validator.py +9 -29
- package/hooks/post_tool_use.py +0 -72
- package/hooks/pre_tool_use.py +42 -102
- package/hooks/session_start.py +4 -2
- package/hooks/subagent_start.py +6 -2
- package/hooks/subagent_stop.py +1 -13
- package/hooks/user_prompt_submit.py +119 -37
- package/index.js +1 -1
- package/package.json +5 -3
- package/skills/README.md +3 -5
- package/skills/agent-protocol/SKILL.md +17 -16
- package/skills/agent-protocol/examples.md +6 -6
- package/skills/agent-response/SKILL.md +11 -14
- package/skills/approval/SKILL.md +28 -13
- package/skills/approval/reference.md +2 -2
- package/skills/execution/SKILL.md +1 -1
- package/skills/gaia-patterns/SKILL.md +2 -3
- package/skills/orchestrator-approval/SKILL.md +22 -50
- package/skills/security-tiers/SKILL.md +1 -1
- package/templates/README.md +9 -9
- package/templates/managed-settings.template.json +43 -0
- package/tools/gaia_simulator/runner.py +34 -1
- package/tools/scan/orchestrator.py +13 -0
- package/tools/scan/scanners/base.py +8 -0
- package/tools/scan/scanners/git.py +78 -0
- package/tools/scan/scanners/infrastructure.py +65 -0
- package/tools/scan/scanners/stack.py +110 -0
- package/tools/scan/setup.py +120 -13
- package/tools/scan/workspace.py +85 -0
- package/config/context-contracts.aws.json +0 -42
- package/config/context-contracts.gcp.json +0 -39
- package/skills/project-dispatch/SKILL.md +0 -34
- package/templates/settings.template.json +0 -226
|
@@ -0,0 +1,1085 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Stack Scanner
|
|
3
|
+
|
|
4
|
+
Detects project languages, frameworks, build tools, monorepo structure,
|
|
5
|
+
and project identity from manifest files and dependency declarations.
|
|
6
|
+
|
|
7
|
+
Owned sections: project_identity, stack
|
|
8
|
+
Contract: specs/002-gaia-scan/data-model.md sections 2.3, 2.4
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
from tools.scan.scanners.base import BaseScanner, ScanResult
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Language detection: mapping of manifest files to language names
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
LANGUAGE_MANIFESTS: List[Tuple[str, str]] = [
|
|
26
|
+
# (filename_or_glob, language_name)
|
|
27
|
+
("package.json", "javascript"),
|
|
28
|
+
("pyproject.toml", "python"),
|
|
29
|
+
("setup.py", "python"),
|
|
30
|
+
("requirements.txt", "python"),
|
|
31
|
+
("go.mod", "go"),
|
|
32
|
+
("Cargo.toml", "rust"),
|
|
33
|
+
("pom.xml", "java"),
|
|
34
|
+
("build.gradle", "java"),
|
|
35
|
+
("build.gradle.kts", "java"),
|
|
36
|
+
("composer.json", "php"),
|
|
37
|
+
("Gemfile", "ruby"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# C#/.NET uses glob patterns
|
|
41
|
+
CSHARP_EXTENSIONS = (".csproj", ".sln")
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Framework detection: mapping of dependency names to framework info
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# (dep_name, framework_name, language)
|
|
47
|
+
JS_FRAMEWORKS: List[Tuple[str, str, str]] = [
|
|
48
|
+
("@nestjs/core", "nestjs", "javascript"),
|
|
49
|
+
("express", "express", "javascript"),
|
|
50
|
+
("react", "react", "javascript"),
|
|
51
|
+
("next", "next.js", "javascript"),
|
|
52
|
+
("@angular/core", "angular", "javascript"),
|
|
53
|
+
("vue", "vue", "javascript"),
|
|
54
|
+
("nuxt", "nuxt", "javascript"),
|
|
55
|
+
("svelte", "svelte", "javascript"),
|
|
56
|
+
("hono", "hono", "javascript"),
|
|
57
|
+
("fastify", "fastify", "javascript"),
|
|
58
|
+
("koa", "koa", "javascript"),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
PYTHON_FRAMEWORKS: List[Tuple[str, str, str]] = [
|
|
62
|
+
("fastapi", "fastapi", "python"),
|
|
63
|
+
("flask", "flask", "python"),
|
|
64
|
+
("django", "django", "python"),
|
|
65
|
+
("starlette", "starlette", "python"),
|
|
66
|
+
("tornado", "tornado", "python"),
|
|
67
|
+
("sanic", "sanic", "python"),
|
|
68
|
+
("aiohttp", "aiohttp", "python"),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Build tool / lock file detection
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
LOCK_FILE_TO_TOOL: List[Tuple[str, str]] = [
|
|
75
|
+
("package-lock.json", "npm"),
|
|
76
|
+
("pnpm-lock.yaml", "pnpm"),
|
|
77
|
+
("yarn.lock", "yarn"),
|
|
78
|
+
("poetry.lock", "poetry"),
|
|
79
|
+
("Pipfile.lock", "pipenv"),
|
|
80
|
+
("Cargo.lock", "cargo"),
|
|
81
|
+
("go.sum", "go"),
|
|
82
|
+
("Gemfile.lock", "bundler"),
|
|
83
|
+
("composer.lock", "composer"),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
MANIFEST_TO_BUILD_TOOL: List[Tuple[str, str]] = [
|
|
87
|
+
("Makefile", "make"),
|
|
88
|
+
("pom.xml", "maven"),
|
|
89
|
+
("build.gradle", "gradle"),
|
|
90
|
+
("build.gradle.kts", "gradle"),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Monorepo detection
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
MONOREPO_TOOLS: Dict[str, str] = {
|
|
97
|
+
"turbo.json": "turborepo",
|
|
98
|
+
"nx.json": "nx",
|
|
99
|
+
"lerna.json": "lerna",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Maximum depth for monorepo subdirectory scanning
|
|
103
|
+
MONOREPO_SCAN_DEPTH = 3
|
|
104
|
+
|
|
105
|
+
# Directories to always skip during scanning
|
|
106
|
+
SKIP_DIRS = frozenset({
|
|
107
|
+
"node_modules", ".git", "__pycache__", ".tox", ".venv",
|
|
108
|
+
"venv", "dist", "build", ".next", ".nuxt", "target",
|
|
109
|
+
".pytest_cache", ".mypy_cache", ".ruff_cache", "vendor",
|
|
110
|
+
".terraform", ".terragrunt-cache",
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class StackScanner(BaseScanner):
|
|
115
|
+
"""Detects project stack: languages, frameworks, build tools, and identity.
|
|
116
|
+
|
|
117
|
+
Scans the project root and subdirectories (for monorepo support) to
|
|
118
|
+
detect languages from manifest files, frameworks from dependency
|
|
119
|
+
declarations, and build tools from lock files.
|
|
120
|
+
|
|
121
|
+
Owned sections: project_identity, stack
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def SCANNER_NAME(self) -> str:
|
|
126
|
+
return "stack"
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def SCANNER_VERSION(self) -> str:
|
|
130
|
+
return "1.1.0"
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def OWNED_SECTIONS(self) -> List[str]:
|
|
134
|
+
return ["project_identity", "stack"]
|
|
135
|
+
|
|
136
|
+
def scan(self, root: Path) -> ScanResult:
|
|
137
|
+
"""Scan the project at root and return project_identity and stack sections.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
root: Absolute path to the project root directory.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
ScanResult with project_identity and stack sections.
|
|
144
|
+
"""
|
|
145
|
+
start_ms = time.monotonic() * 1000
|
|
146
|
+
warnings: List[str] = []
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
languages = self._detect_languages(root, warnings)
|
|
150
|
+
frameworks = self._detect_frameworks(root, languages, warnings)
|
|
151
|
+
build_tools = self._detect_build_tools(root, warnings)
|
|
152
|
+
project_identity = self._detect_project_identity(root, languages, warnings)
|
|
153
|
+
|
|
154
|
+
# Multi-repo workspace override: if orchestrator detected multi-repo,
|
|
155
|
+
# set project type and add workspace_repos listing
|
|
156
|
+
if self.workspace_info and self.workspace_info.is_multi_repo:
|
|
157
|
+
project_identity["type"] = "multi-repo-workspace"
|
|
158
|
+
project_identity["workspace_repos"] = self._build_workspace_repos(
|
|
159
|
+
root, self.workspace_info.repo_dirs, warnings
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
sections: Dict[str, Any] = {
|
|
163
|
+
"project_identity": project_identity,
|
|
164
|
+
"stack": {
|
|
165
|
+
"languages": languages,
|
|
166
|
+
"frameworks": frameworks,
|
|
167
|
+
"build_tools": build_tools,
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
logger.warning("Stack scanner failed: %s", exc)
|
|
172
|
+
sections = {
|
|
173
|
+
"project_identity": {
|
|
174
|
+
"name": root.name,
|
|
175
|
+
"type": "unknown",
|
|
176
|
+
"description": None,
|
|
177
|
+
"manifest_file": None,
|
|
178
|
+
"monorepo": {
|
|
179
|
+
"detected": False,
|
|
180
|
+
"tool": None,
|
|
181
|
+
"workspace_roots": [],
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
"stack": {
|
|
185
|
+
"languages": [],
|
|
186
|
+
"frameworks": [],
|
|
187
|
+
"build_tools": [],
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
warnings.append(f"Stack scanner error: {exc}")
|
|
191
|
+
|
|
192
|
+
elapsed_ms = (time.monotonic() * 1000) - start_ms
|
|
193
|
+
return self.make_result(sections, warnings=warnings, duration_ms=elapsed_ms)
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
# Language detection
|
|
197
|
+
# ------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def _detect_languages(
|
|
200
|
+
self, root: Path, warnings: List[str]
|
|
201
|
+
) -> List[Dict[str, Any]]:
|
|
202
|
+
"""Detect programming languages from manifest files.
|
|
203
|
+
|
|
204
|
+
Scans root directory and subdirectories for language-specific
|
|
205
|
+
manifest files. Handles monorepo by scanning subdirs up to
|
|
206
|
+
MONOREPO_SCAN_DEPTH.
|
|
207
|
+
"""
|
|
208
|
+
seen_languages: Dict[str, Dict[str, Any]] = {}
|
|
209
|
+
first_found = True
|
|
210
|
+
|
|
211
|
+
# Scan root and subdirectories
|
|
212
|
+
for manifest_file, language in LANGUAGE_MANIFESTS:
|
|
213
|
+
for path in self._find_files(root, manifest_file):
|
|
214
|
+
rel_path = str(path.relative_to(root))
|
|
215
|
+
if language not in seen_languages:
|
|
216
|
+
seen_languages[language] = {
|
|
217
|
+
"name": language,
|
|
218
|
+
"manifest": rel_path,
|
|
219
|
+
"primary": first_found,
|
|
220
|
+
}
|
|
221
|
+
first_found = False
|
|
222
|
+
|
|
223
|
+
# C#/.NET detection via glob patterns
|
|
224
|
+
for ext in CSHARP_EXTENSIONS:
|
|
225
|
+
for path in self._find_files_by_extension(root, ext):
|
|
226
|
+
if "csharp" not in seen_languages:
|
|
227
|
+
rel_path = str(path.relative_to(root))
|
|
228
|
+
seen_languages["csharp"] = {
|
|
229
|
+
"name": "csharp",
|
|
230
|
+
"manifest": rel_path,
|
|
231
|
+
"primary": first_found,
|
|
232
|
+
}
|
|
233
|
+
first_found = False
|
|
234
|
+
break # Only need one match per extension type
|
|
235
|
+
|
|
236
|
+
# Check for TypeScript: tsconfig*.json at root or subdirectories,
|
|
237
|
+
# or .ts/.tsx file extensions in the project tree
|
|
238
|
+
if "javascript" in seen_languages and self._has_typescript_indicators(root):
|
|
239
|
+
js_entry = seen_languages.pop("javascript")
|
|
240
|
+
seen_languages["typescript"] = {
|
|
241
|
+
"name": "typescript",
|
|
242
|
+
"manifest": js_entry["manifest"],
|
|
243
|
+
"primary": js_entry["primary"],
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return list(seen_languages.values())
|
|
247
|
+
|
|
248
|
+
def _has_typescript_indicators(self, root: Path) -> bool:
|
|
249
|
+
"""Check for TypeScript indicators: tsconfig files or .ts/.tsx extensions.
|
|
250
|
+
|
|
251
|
+
Searches root and subdirectories (for monorepo support).
|
|
252
|
+
"""
|
|
253
|
+
# Check for tsconfig*.json at root
|
|
254
|
+
for f in root.iterdir() if root.is_dir() else []:
|
|
255
|
+
if f.is_file() and f.name.startswith("tsconfig") and f.name.endswith(".json"):
|
|
256
|
+
return True
|
|
257
|
+
|
|
258
|
+
# Check subdirectories for tsconfig*.json (monorepo workspace roots)
|
|
259
|
+
for path in self._find_files(root, "tsconfig.json"):
|
|
260
|
+
return True
|
|
261
|
+
|
|
262
|
+
# Also check for tsconfig.*.json patterns in subdirectories
|
|
263
|
+
try:
|
|
264
|
+
for entry in self._iter_subdirs(root, depth=0):
|
|
265
|
+
for f in entry.iterdir():
|
|
266
|
+
if f.is_file() and f.name.startswith("tsconfig") and f.name.endswith(".json"):
|
|
267
|
+
return True
|
|
268
|
+
except (PermissionError, OSError):
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
# Check for .ts or .tsx file extensions
|
|
272
|
+
for ext in (".ts", ".tsx"):
|
|
273
|
+
if self._find_files_by_extension(root, ext):
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
def _iter_subdirs(self, root: Path, depth: int) -> List[Path]:
|
|
279
|
+
"""Iterate subdirectories respecting MONOREPO_SCAN_DEPTH and SKIP_DIRS."""
|
|
280
|
+
if depth >= MONOREPO_SCAN_DEPTH:
|
|
281
|
+
return []
|
|
282
|
+
results: List[Path] = []
|
|
283
|
+
try:
|
|
284
|
+
for entry in sorted(root.iterdir()):
|
|
285
|
+
if entry.is_dir() and entry.name not in SKIP_DIRS and not entry.name.startswith("."):
|
|
286
|
+
results.append(entry)
|
|
287
|
+
results.extend(self._iter_subdirs(entry, depth + 1))
|
|
288
|
+
except PermissionError:
|
|
289
|
+
pass
|
|
290
|
+
return results
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------------------
|
|
293
|
+
# Framework detection
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def _detect_frameworks(
|
|
297
|
+
self,
|
|
298
|
+
root: Path,
|
|
299
|
+
languages: List[Dict[str, Any]],
|
|
300
|
+
warnings: List[str],
|
|
301
|
+
) -> List[Dict[str, Any]]:
|
|
302
|
+
"""Detect frameworks from dependency declarations."""
|
|
303
|
+
frameworks: List[Dict[str, Any]] = []
|
|
304
|
+
lang_names = {lang["name"] for lang in languages}
|
|
305
|
+
|
|
306
|
+
# JavaScript/TypeScript frameworks from package.json
|
|
307
|
+
if "javascript" in lang_names or "typescript" in lang_names:
|
|
308
|
+
js_lang = "typescript" if "typescript" in lang_names else "javascript"
|
|
309
|
+
for path in self._find_files(root, "package.json"):
|
|
310
|
+
found = self._detect_js_frameworks(path, js_lang, warnings)
|
|
311
|
+
for fw in found:
|
|
312
|
+
if not any(f["name"] == fw["name"] for f in frameworks):
|
|
313
|
+
frameworks.append(fw)
|
|
314
|
+
|
|
315
|
+
# NestJS wraps Express/Fastify -- promote NestJS to primary position
|
|
316
|
+
# and mark the underlying framework as secondary
|
|
317
|
+
self._promote_meta_framework(frameworks, "nestjs", ["express", "fastify"])
|
|
318
|
+
|
|
319
|
+
# Python frameworks from pyproject.toml and requirements.txt
|
|
320
|
+
if "python" in lang_names:
|
|
321
|
+
for path in self._find_files(root, "pyproject.toml"):
|
|
322
|
+
found = self._detect_python_frameworks_pyproject(path, warnings)
|
|
323
|
+
for fw in found:
|
|
324
|
+
if not any(f["name"] == fw["name"] for f in frameworks):
|
|
325
|
+
frameworks.append(fw)
|
|
326
|
+
|
|
327
|
+
for path in self._find_files(root, "requirements.txt"):
|
|
328
|
+
found = self._detect_python_frameworks_requirements(path, warnings)
|
|
329
|
+
for fw in found:
|
|
330
|
+
if not any(f["name"] == fw["name"] for f in frameworks):
|
|
331
|
+
frameworks.append(fw)
|
|
332
|
+
|
|
333
|
+
for path in self._find_files(root, "setup.py"):
|
|
334
|
+
found = self._detect_python_frameworks_setup_py(path, warnings)
|
|
335
|
+
for fw in found:
|
|
336
|
+
if not any(f["name"] == fw["name"] for f in frameworks):
|
|
337
|
+
frameworks.append(fw)
|
|
338
|
+
|
|
339
|
+
return frameworks
|
|
340
|
+
|
|
341
|
+
def _detect_js_frameworks(
|
|
342
|
+
self, package_json_path: Path, language: str, warnings: List[str]
|
|
343
|
+
) -> List[Dict[str, Any]]:
|
|
344
|
+
"""Detect JavaScript/TypeScript frameworks from package.json."""
|
|
345
|
+
frameworks: List[Dict[str, Any]] = []
|
|
346
|
+
try:
|
|
347
|
+
data = json.loads(package_json_path.read_text(encoding="utf-8"))
|
|
348
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
349
|
+
warnings.append(f"Cannot read {package_json_path}: {exc}")
|
|
350
|
+
return frameworks
|
|
351
|
+
|
|
352
|
+
# Merge dependencies and devDependencies
|
|
353
|
+
deps: Dict[str, str] = {}
|
|
354
|
+
deps.update(data.get("dependencies", {}))
|
|
355
|
+
deps.update(data.get("devDependencies", {}))
|
|
356
|
+
|
|
357
|
+
for dep_name, framework_name, _ in JS_FRAMEWORKS:
|
|
358
|
+
if dep_name in deps:
|
|
359
|
+
version = deps[dep_name]
|
|
360
|
+
# Strip version prefix (^, ~, >=, etc.)
|
|
361
|
+
version_clean = re.sub(r"^[\^~>=<]*", "", version) if version else None
|
|
362
|
+
frameworks.append({
|
|
363
|
+
"name": framework_name,
|
|
364
|
+
"language": language,
|
|
365
|
+
"version": version_clean or None,
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
return frameworks
|
|
369
|
+
|
|
370
|
+
def _promote_meta_framework(
|
|
371
|
+
self,
|
|
372
|
+
frameworks: List[Dict[str, Any]],
|
|
373
|
+
meta_name: str,
|
|
374
|
+
underlying_names: List[str],
|
|
375
|
+
) -> None:
|
|
376
|
+
"""Promote a meta-framework (e.g. NestJS) above its underlying frameworks.
|
|
377
|
+
|
|
378
|
+
When a meta-framework like NestJS is detected alongside its underlying
|
|
379
|
+
framework (Express), move it to the first position and mark the
|
|
380
|
+
underlying framework as secondary (role='underlying').
|
|
381
|
+
"""
|
|
382
|
+
meta_idx = None
|
|
383
|
+
for i, fw in enumerate(frameworks):
|
|
384
|
+
if fw["name"] == meta_name:
|
|
385
|
+
meta_idx = i
|
|
386
|
+
break
|
|
387
|
+
|
|
388
|
+
if meta_idx is None:
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
# Move meta-framework to front
|
|
392
|
+
if meta_idx > 0:
|
|
393
|
+
meta_fw = frameworks.pop(meta_idx)
|
|
394
|
+
frameworks.insert(0, meta_fw)
|
|
395
|
+
|
|
396
|
+
# Mark underlying frameworks
|
|
397
|
+
for fw in frameworks:
|
|
398
|
+
if fw["name"] in underlying_names:
|
|
399
|
+
fw["role"] = "underlying"
|
|
400
|
+
|
|
401
|
+
def _detect_python_frameworks_pyproject(
|
|
402
|
+
self, pyproject_path: Path, warnings: List[str]
|
|
403
|
+
) -> List[Dict[str, Any]]:
|
|
404
|
+
"""Detect Python frameworks from pyproject.toml dependencies."""
|
|
405
|
+
frameworks: List[Dict[str, Any]] = []
|
|
406
|
+
try:
|
|
407
|
+
content = pyproject_path.read_text(encoding="utf-8")
|
|
408
|
+
except OSError as exc:
|
|
409
|
+
warnings.append(f"Cannot read {pyproject_path}: {exc}")
|
|
410
|
+
return frameworks
|
|
411
|
+
|
|
412
|
+
# Parse dependencies from [project.dependencies] and [tool.poetry.dependencies]
|
|
413
|
+
# Using simple regex since we avoid external TOML parser dependency
|
|
414
|
+
deps_text = self._extract_toml_deps(content)
|
|
415
|
+
|
|
416
|
+
for dep_name, framework_name, lang in PYTHON_FRAMEWORKS:
|
|
417
|
+
# Match dep_name with optional version specifier
|
|
418
|
+
pattern = rf'(?:^|\n)\s*["\']?{re.escape(dep_name)}(?:\[.*?\])?["\']?\s*(?:[>=<~!]|$)'
|
|
419
|
+
if re.search(pattern, deps_text, re.IGNORECASE):
|
|
420
|
+
version = self._extract_python_version(deps_text, dep_name)
|
|
421
|
+
frameworks.append({
|
|
422
|
+
"name": framework_name,
|
|
423
|
+
"language": lang,
|
|
424
|
+
"version": version,
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
return frameworks
|
|
428
|
+
|
|
429
|
+
def _detect_python_frameworks_requirements(
|
|
430
|
+
self, requirements_path: Path, warnings: List[str]
|
|
431
|
+
) -> List[Dict[str, Any]]:
|
|
432
|
+
"""Detect Python frameworks from requirements.txt."""
|
|
433
|
+
frameworks: List[Dict[str, Any]] = []
|
|
434
|
+
try:
|
|
435
|
+
content = requirements_path.read_text(encoding="utf-8")
|
|
436
|
+
except OSError as exc:
|
|
437
|
+
warnings.append(f"Cannot read {requirements_path}: {exc}")
|
|
438
|
+
return frameworks
|
|
439
|
+
|
|
440
|
+
for dep_name, framework_name, lang in PYTHON_FRAMEWORKS:
|
|
441
|
+
pattern = rf"(?:^|\n)\s*{re.escape(dep_name)}(?:\[.*?\])?\s*(?:([>=<~!]+)\s*([\d.]+))?"
|
|
442
|
+
match = re.search(pattern, content, re.IGNORECASE)
|
|
443
|
+
if match:
|
|
444
|
+
version = match.group(2) if match.group(2) else None
|
|
445
|
+
frameworks.append({
|
|
446
|
+
"name": framework_name,
|
|
447
|
+
"language": lang,
|
|
448
|
+
"version": version,
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
return frameworks
|
|
452
|
+
|
|
453
|
+
def _detect_python_frameworks_setup_py(
|
|
454
|
+
self, setup_py_path: Path, warnings: List[str]
|
|
455
|
+
) -> List[Dict[str, Any]]:
|
|
456
|
+
"""Detect Python frameworks from setup.py install_requires."""
|
|
457
|
+
frameworks: List[Dict[str, Any]] = []
|
|
458
|
+
try:
|
|
459
|
+
content = setup_py_path.read_text(encoding="utf-8")
|
|
460
|
+
except OSError as exc:
|
|
461
|
+
warnings.append(f"Cannot read {setup_py_path}: {exc}")
|
|
462
|
+
return frameworks
|
|
463
|
+
|
|
464
|
+
for dep_name, framework_name, lang in PYTHON_FRAMEWORKS:
|
|
465
|
+
pattern = rf'["\']({re.escape(dep_name)}(?:\[.*?\])?(?:\s*[>=<~!]+\s*[\d.]+)?)["\']'
|
|
466
|
+
match = re.search(pattern, content, re.IGNORECASE)
|
|
467
|
+
if match:
|
|
468
|
+
version = self._extract_inline_version(match.group(1))
|
|
469
|
+
frameworks.append({
|
|
470
|
+
"name": framework_name,
|
|
471
|
+
"language": lang,
|
|
472
|
+
"version": version,
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
return frameworks
|
|
476
|
+
|
|
477
|
+
# ------------------------------------------------------------------
|
|
478
|
+
# Build tool detection
|
|
479
|
+
# ------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
def _detect_build_tools(
|
|
482
|
+
self, root: Path, warnings: List[str]
|
|
483
|
+
) -> List[Dict[str, Any]]:
|
|
484
|
+
"""Detect build tools from lock files and manifest files."""
|
|
485
|
+
tools: List[Dict[str, Any]] = []
|
|
486
|
+
seen_tools: set = set()
|
|
487
|
+
|
|
488
|
+
# Lock file detection
|
|
489
|
+
for lock_file, tool_name in LOCK_FILE_TO_TOOL:
|
|
490
|
+
for path in self._find_files(root, lock_file):
|
|
491
|
+
if tool_name not in seen_tools:
|
|
492
|
+
rel_path = str(path.relative_to(root))
|
|
493
|
+
tools.append({
|
|
494
|
+
"name": tool_name,
|
|
495
|
+
"detected_by": "lock_file",
|
|
496
|
+
"lock_file": rel_path,
|
|
497
|
+
})
|
|
498
|
+
seen_tools.add(tool_name)
|
|
499
|
+
|
|
500
|
+
# Manifest-based build tool detection
|
|
501
|
+
for manifest_file, tool_name in MANIFEST_TO_BUILD_TOOL:
|
|
502
|
+
for path in self._find_files(root, manifest_file):
|
|
503
|
+
if tool_name not in seen_tools:
|
|
504
|
+
tools.append({
|
|
505
|
+
"name": tool_name,
|
|
506
|
+
"detected_by": "manifest",
|
|
507
|
+
"lock_file": None,
|
|
508
|
+
})
|
|
509
|
+
seen_tools.add(tool_name)
|
|
510
|
+
|
|
511
|
+
# Detect pip from requirements.txt (no lock file equivalent)
|
|
512
|
+
for path in self._find_files(root, "requirements.txt"):
|
|
513
|
+
if "pip" not in seen_tools:
|
|
514
|
+
tools.append({
|
|
515
|
+
"name": "pip",
|
|
516
|
+
"detected_by": "manifest",
|
|
517
|
+
"lock_file": None,
|
|
518
|
+
})
|
|
519
|
+
seen_tools.add("pip")
|
|
520
|
+
|
|
521
|
+
# Detect poetry from pyproject.toml [tool.poetry] section
|
|
522
|
+
if "poetry" not in seen_tools:
|
|
523
|
+
for path in self._find_files(root, "pyproject.toml"):
|
|
524
|
+
try:
|
|
525
|
+
content = path.read_text(encoding="utf-8")
|
|
526
|
+
if "[tool.poetry]" in content:
|
|
527
|
+
tools.append({
|
|
528
|
+
"name": "poetry",
|
|
529
|
+
"detected_by": "manifest",
|
|
530
|
+
"lock_file": None,
|
|
531
|
+
})
|
|
532
|
+
seen_tools.add("poetry")
|
|
533
|
+
break
|
|
534
|
+
except OSError:
|
|
535
|
+
pass
|
|
536
|
+
|
|
537
|
+
# Detect monorepo build orchestrators (turbo.json, nx.json, lerna.json)
|
|
538
|
+
# at root and in subdirectories
|
|
539
|
+
for config_file, tool_name in MONOREPO_TOOLS.items():
|
|
540
|
+
if tool_name not in seen_tools:
|
|
541
|
+
for path in self._find_files(root, config_file):
|
|
542
|
+
rel_path = str(path.relative_to(root))
|
|
543
|
+
tools.append({
|
|
544
|
+
"name": tool_name,
|
|
545
|
+
"detected_by": "config_file",
|
|
546
|
+
"lock_file": None,
|
|
547
|
+
"config_file": rel_path,
|
|
548
|
+
})
|
|
549
|
+
seen_tools.add(tool_name)
|
|
550
|
+
break # One match per tool is enough
|
|
551
|
+
|
|
552
|
+
return tools
|
|
553
|
+
|
|
554
|
+
# ------------------------------------------------------------------
|
|
555
|
+
# Project identity detection
|
|
556
|
+
# ------------------------------------------------------------------
|
|
557
|
+
|
|
558
|
+
def _detect_project_identity(
|
|
559
|
+
self,
|
|
560
|
+
root: Path,
|
|
561
|
+
languages: List[Dict[str, Any]],
|
|
562
|
+
warnings: List[str],
|
|
563
|
+
) -> Dict[str, Any]:
|
|
564
|
+
"""Detect project identity from manifest files.
|
|
565
|
+
|
|
566
|
+
Reads name, description, and type from the primary manifest file
|
|
567
|
+
(package.json or pyproject.toml). Falls back to directory name.
|
|
568
|
+
"""
|
|
569
|
+
name: Optional[str] = None
|
|
570
|
+
description: Optional[str] = None
|
|
571
|
+
manifest_file: Optional[str] = None
|
|
572
|
+
project_type = "unknown"
|
|
573
|
+
|
|
574
|
+
# Try package.json first
|
|
575
|
+
pkg_json = root / "package.json"
|
|
576
|
+
if pkg_json.is_file():
|
|
577
|
+
try:
|
|
578
|
+
data = json.loads(pkg_json.read_text(encoding="utf-8"))
|
|
579
|
+
name = data.get("name")
|
|
580
|
+
description = data.get("description")
|
|
581
|
+
manifest_file = "package.json"
|
|
582
|
+
|
|
583
|
+
# Detect monorepo indicators in package.json
|
|
584
|
+
if data.get("workspaces"):
|
|
585
|
+
project_type = "monorepo"
|
|
586
|
+
elif data.get("private") is True and not data.get("main"):
|
|
587
|
+
project_type = "monorepo"
|
|
588
|
+
else:
|
|
589
|
+
project_type = "application"
|
|
590
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
591
|
+
warnings.append(f"Cannot read package.json: {exc}")
|
|
592
|
+
|
|
593
|
+
# Try pyproject.toml
|
|
594
|
+
pyproject = root / "pyproject.toml"
|
|
595
|
+
if pyproject.is_file():
|
|
596
|
+
try:
|
|
597
|
+
content = pyproject.read_text(encoding="utf-8")
|
|
598
|
+
if name is None:
|
|
599
|
+
name = self._extract_toml_value(content, "name")
|
|
600
|
+
if description is None:
|
|
601
|
+
description = self._extract_toml_value(content, "description")
|
|
602
|
+
if manifest_file is None:
|
|
603
|
+
manifest_file = "pyproject.toml"
|
|
604
|
+
if project_type == "unknown":
|
|
605
|
+
project_type = "library" if "[build-system]" in content else "application"
|
|
606
|
+
except OSError as exc:
|
|
607
|
+
warnings.append(f"Cannot read pyproject.toml: {exc}")
|
|
608
|
+
|
|
609
|
+
# Try go.mod
|
|
610
|
+
go_mod = root / "go.mod"
|
|
611
|
+
if go_mod.is_file() and name is None:
|
|
612
|
+
try:
|
|
613
|
+
content = go_mod.read_text(encoding="utf-8")
|
|
614
|
+
match = re.search(r"^module\s+(\S+)", content, re.MULTILINE)
|
|
615
|
+
if match:
|
|
616
|
+
name = match.group(1).split("/")[-1]
|
|
617
|
+
manifest_file = "go.mod"
|
|
618
|
+
if project_type == "unknown":
|
|
619
|
+
project_type = "application"
|
|
620
|
+
except OSError as exc:
|
|
621
|
+
warnings.append(f"Cannot read go.mod: {exc}")
|
|
622
|
+
|
|
623
|
+
# Try Cargo.toml
|
|
624
|
+
cargo_toml = root / "Cargo.toml"
|
|
625
|
+
if cargo_toml.is_file() and name is None:
|
|
626
|
+
try:
|
|
627
|
+
content = cargo_toml.read_text(encoding="utf-8")
|
|
628
|
+
name = self._extract_toml_value(content, "name")
|
|
629
|
+
if description is None:
|
|
630
|
+
description = self._extract_toml_value(content, "description")
|
|
631
|
+
manifest_file = "Cargo.toml"
|
|
632
|
+
if project_type == "unknown":
|
|
633
|
+
# Cargo workspace = monorepo
|
|
634
|
+
if "[workspace]" in content:
|
|
635
|
+
project_type = "monorepo"
|
|
636
|
+
else:
|
|
637
|
+
project_type = "application"
|
|
638
|
+
except OSError as exc:
|
|
639
|
+
warnings.append(f"Cannot read Cargo.toml: {exc}")
|
|
640
|
+
|
|
641
|
+
# Try composer.json
|
|
642
|
+
composer = root / "composer.json"
|
|
643
|
+
if composer.is_file() and name is None:
|
|
644
|
+
try:
|
|
645
|
+
data = json.loads(composer.read_text(encoding="utf-8"))
|
|
646
|
+
name = data.get("name")
|
|
647
|
+
description = data.get("description")
|
|
648
|
+
manifest_file = "composer.json"
|
|
649
|
+
if project_type == "unknown":
|
|
650
|
+
project_type = "application"
|
|
651
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
652
|
+
warnings.append(f"Cannot read composer.json: {exc}")
|
|
653
|
+
|
|
654
|
+
# Before falling back to directory name, check monorepo subdirectory
|
|
655
|
+
# package.json files for a project name
|
|
656
|
+
if name is None:
|
|
657
|
+
name = self._derive_name_from_subdirs(root, warnings)
|
|
658
|
+
|
|
659
|
+
# Fallback to directory name
|
|
660
|
+
if name is None:
|
|
661
|
+
name = root.name
|
|
662
|
+
|
|
663
|
+
# Monorepo detection
|
|
664
|
+
monorepo_info = self._detect_monorepo(root, warnings)
|
|
665
|
+
if monorepo_info["detected"]:
|
|
666
|
+
project_type = "monorepo"
|
|
667
|
+
|
|
668
|
+
# Multi-language implies potential monorepo
|
|
669
|
+
if len(languages) > 1 and not monorepo_info["detected"]:
|
|
670
|
+
# Check if languages come from different subdirectories
|
|
671
|
+
subdirs = set()
|
|
672
|
+
for lang in languages:
|
|
673
|
+
manifest_dir = str(Path(lang["manifest"]).parent)
|
|
674
|
+
if manifest_dir != ".":
|
|
675
|
+
subdirs.add(manifest_dir)
|
|
676
|
+
if len(subdirs) > 1:
|
|
677
|
+
project_type = "monorepo"
|
|
678
|
+
monorepo_info["detected"] = True
|
|
679
|
+
|
|
680
|
+
return {
|
|
681
|
+
"name": name,
|
|
682
|
+
"type": project_type,
|
|
683
|
+
"description": description,
|
|
684
|
+
"manifest_file": manifest_file,
|
|
685
|
+
"monorepo": monorepo_info,
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
def _derive_name_from_subdirs(
|
|
689
|
+
self, root: Path, warnings: List[str]
|
|
690
|
+
) -> Optional[str]:
|
|
691
|
+
"""Derive project name from package.json in immediate subdirectories.
|
|
692
|
+
|
|
693
|
+
Checks subdirectories that look like monorepo roots (contain
|
|
694
|
+
package.json with a name field) and returns the first name found.
|
|
695
|
+
"""
|
|
696
|
+
try:
|
|
697
|
+
for entry in sorted(root.iterdir()):
|
|
698
|
+
if not entry.is_dir():
|
|
699
|
+
continue
|
|
700
|
+
if entry.name in SKIP_DIRS or entry.name.startswith("."):
|
|
701
|
+
continue
|
|
702
|
+
sub_pkg = entry / "package.json"
|
|
703
|
+
if sub_pkg.is_file():
|
|
704
|
+
try:
|
|
705
|
+
data = json.loads(sub_pkg.read_text(encoding="utf-8"))
|
|
706
|
+
pkg_name = data.get("name")
|
|
707
|
+
if pkg_name and isinstance(pkg_name, str):
|
|
708
|
+
return pkg_name
|
|
709
|
+
except (json.JSONDecodeError, OSError):
|
|
710
|
+
continue
|
|
711
|
+
except PermissionError:
|
|
712
|
+
pass
|
|
713
|
+
return None
|
|
714
|
+
|
|
715
|
+
# ------------------------------------------------------------------
|
|
716
|
+
# Monorepo detection
|
|
717
|
+
# ------------------------------------------------------------------
|
|
718
|
+
|
|
719
|
+
def _detect_monorepo(
|
|
720
|
+
self, root: Path, warnings: List[str]
|
|
721
|
+
) -> Dict[str, Any]:
|
|
722
|
+
"""Detect monorepo tool and workspace roots."""
|
|
723
|
+
result: Dict[str, Any] = {
|
|
724
|
+
"detected": False,
|
|
725
|
+
"tool": None,
|
|
726
|
+
"workspace_roots": [],
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
# Check for monorepo tool config files at root level
|
|
730
|
+
for config_file, tool_name in MONOREPO_TOOLS.items():
|
|
731
|
+
if (root / config_file).is_file():
|
|
732
|
+
result["detected"] = True
|
|
733
|
+
result["tool"] = tool_name
|
|
734
|
+
break
|
|
735
|
+
|
|
736
|
+
# Check pnpm workspaces at root level
|
|
737
|
+
pnpm_workspace = root / "pnpm-workspace.yaml"
|
|
738
|
+
if pnpm_workspace.is_file():
|
|
739
|
+
result["detected"] = True
|
|
740
|
+
if result["tool"] is None:
|
|
741
|
+
result["tool"] = "pnpm-workspaces"
|
|
742
|
+
|
|
743
|
+
# Check npm/yarn workspaces in package.json at root level
|
|
744
|
+
pkg_json = root / "package.json"
|
|
745
|
+
if pkg_json.is_file():
|
|
746
|
+
try:
|
|
747
|
+
data = json.loads(pkg_json.read_text(encoding="utf-8"))
|
|
748
|
+
workspaces = data.get("workspaces")
|
|
749
|
+
if workspaces:
|
|
750
|
+
result["detected"] = True
|
|
751
|
+
if result["tool"] is None:
|
|
752
|
+
result["tool"] = "npm-workspaces"
|
|
753
|
+
# Extract workspace patterns
|
|
754
|
+
if isinstance(workspaces, list):
|
|
755
|
+
workspace_patterns = workspaces
|
|
756
|
+
elif isinstance(workspaces, dict):
|
|
757
|
+
workspace_patterns = workspaces.get("packages", [])
|
|
758
|
+
else:
|
|
759
|
+
workspace_patterns = []
|
|
760
|
+
# Resolve workspace roots from patterns
|
|
761
|
+
for pattern in workspace_patterns:
|
|
762
|
+
result["workspace_roots"].append(str(pattern))
|
|
763
|
+
except (json.JSONDecodeError, OSError):
|
|
764
|
+
pass
|
|
765
|
+
|
|
766
|
+
# If not yet detected, scan immediate subdirectories for workspace
|
|
767
|
+
# config files (handles projects where monorepo root is a subdirectory)
|
|
768
|
+
if not result["detected"]:
|
|
769
|
+
result = self._detect_monorepo_in_subdirs(root, result, warnings)
|
|
770
|
+
|
|
771
|
+
return result
|
|
772
|
+
|
|
773
|
+
def _detect_monorepo_in_subdirs(
|
|
774
|
+
self,
|
|
775
|
+
root: Path,
|
|
776
|
+
result: Dict[str, Any],
|
|
777
|
+
warnings: List[str],
|
|
778
|
+
) -> Dict[str, Any]:
|
|
779
|
+
"""Scan immediate subdirectories for monorepo workspace config files.
|
|
780
|
+
|
|
781
|
+
Detects monorepo when a subdirectory contains workspace config files
|
|
782
|
+
like pnpm-workspace.yaml, pnpm-lock.yaml, lerna.json, etc.
|
|
783
|
+
"""
|
|
784
|
+
# Workspace config files that indicate a monorepo root
|
|
785
|
+
workspace_markers = [
|
|
786
|
+
("pnpm-workspace.yaml", "pnpm-workspaces"),
|
|
787
|
+
("pnpm-lock.yaml", "pnpm-workspaces"),
|
|
788
|
+
("lerna.json", "lerna"),
|
|
789
|
+
("turbo.json", "turborepo"),
|
|
790
|
+
("nx.json", "nx"),
|
|
791
|
+
]
|
|
792
|
+
|
|
793
|
+
try:
|
|
794
|
+
for entry in sorted(root.iterdir()):
|
|
795
|
+
if not entry.is_dir():
|
|
796
|
+
continue
|
|
797
|
+
if entry.name in SKIP_DIRS or entry.name.startswith("."):
|
|
798
|
+
continue
|
|
799
|
+
|
|
800
|
+
for marker_file, tool_name in workspace_markers:
|
|
801
|
+
if (entry / marker_file).is_file():
|
|
802
|
+
result["detected"] = True
|
|
803
|
+
if result["tool"] is None:
|
|
804
|
+
result["tool"] = tool_name
|
|
805
|
+
subdir_rel = str(entry.relative_to(root))
|
|
806
|
+
if subdir_rel not in result["workspace_roots"]:
|
|
807
|
+
result["workspace_roots"].append(subdir_rel)
|
|
808
|
+
break
|
|
809
|
+
|
|
810
|
+
# Also check for package.json with workspaces in subdirs
|
|
811
|
+
sub_pkg = entry / "package.json"
|
|
812
|
+
if sub_pkg.is_file() and not result["detected"]:
|
|
813
|
+
try:
|
|
814
|
+
data = json.loads(sub_pkg.read_text(encoding="utf-8"))
|
|
815
|
+
if data.get("workspaces"):
|
|
816
|
+
result["detected"] = True
|
|
817
|
+
if result["tool"] is None:
|
|
818
|
+
result["tool"] = "npm-workspaces"
|
|
819
|
+
subdir_rel = str(entry.relative_to(root))
|
|
820
|
+
if subdir_rel not in result["workspace_roots"]:
|
|
821
|
+
result["workspace_roots"].append(subdir_rel)
|
|
822
|
+
except (json.JSONDecodeError, OSError):
|
|
823
|
+
pass
|
|
824
|
+
|
|
825
|
+
if result["detected"]:
|
|
826
|
+
break
|
|
827
|
+
except PermissionError:
|
|
828
|
+
pass
|
|
829
|
+
|
|
830
|
+
return result
|
|
831
|
+
|
|
832
|
+
# ------------------------------------------------------------------
|
|
833
|
+
# Multi-repo workspace helpers
|
|
834
|
+
# ------------------------------------------------------------------
|
|
835
|
+
|
|
836
|
+
def _build_workspace_repos(
|
|
837
|
+
self,
|
|
838
|
+
root: Path,
|
|
839
|
+
repo_dirs: List[Path],
|
|
840
|
+
warnings: List[str],
|
|
841
|
+
) -> List[Dict[str, Any]]:
|
|
842
|
+
"""Build workspace_repos list for multi-repo workspaces.
|
|
843
|
+
|
|
844
|
+
For each subdirectory with .git, extracts name, relative path,
|
|
845
|
+
and primary language from the most prominent manifest file.
|
|
846
|
+
|
|
847
|
+
Args:
|
|
848
|
+
root: Workspace root path.
|
|
849
|
+
repo_dirs: List of subdirectory Paths that contain .git.
|
|
850
|
+
warnings: Warning accumulator.
|
|
851
|
+
|
|
852
|
+
Returns:
|
|
853
|
+
List of repo descriptor dicts.
|
|
854
|
+
"""
|
|
855
|
+
repos: List[Dict[str, Any]] = []
|
|
856
|
+
|
|
857
|
+
for repo_dir in repo_dirs:
|
|
858
|
+
repo_entry: Dict[str, Any] = {
|
|
859
|
+
"name": repo_dir.name,
|
|
860
|
+
"path": str(repo_dir.relative_to(root)),
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
# Detect primary language from manifest files
|
|
864
|
+
primary_language = self._detect_primary_language(repo_dir)
|
|
865
|
+
if primary_language:
|
|
866
|
+
repo_entry["primary_language"] = primary_language
|
|
867
|
+
|
|
868
|
+
# Detect role from directory naming conventions
|
|
869
|
+
repo_entry["role"] = self._infer_repo_role(repo_dir, primary_language)
|
|
870
|
+
|
|
871
|
+
repos.append(repo_entry)
|
|
872
|
+
|
|
873
|
+
return repos
|
|
874
|
+
|
|
875
|
+
@staticmethod
|
|
876
|
+
def _detect_primary_language(repo_dir: Path) -> Optional[str]:
|
|
877
|
+
"""Detect the primary language of a repo from its manifest files."""
|
|
878
|
+
manifest_checks = [
|
|
879
|
+
("package.json", "javascript"),
|
|
880
|
+
("pyproject.toml", "python"),
|
|
881
|
+
("setup.py", "python"),
|
|
882
|
+
("requirements.txt", "python"),
|
|
883
|
+
("go.mod", "go"),
|
|
884
|
+
("Cargo.toml", "rust"),
|
|
885
|
+
("pom.xml", "java"),
|
|
886
|
+
("build.gradle", "java"),
|
|
887
|
+
("composer.json", "php"),
|
|
888
|
+
("Gemfile", "ruby"),
|
|
889
|
+
]
|
|
890
|
+
|
|
891
|
+
for filename, language in manifest_checks:
|
|
892
|
+
if (repo_dir / filename).is_file():
|
|
893
|
+
# Check for TypeScript indicator
|
|
894
|
+
if language == "javascript":
|
|
895
|
+
for f in repo_dir.iterdir():
|
|
896
|
+
if f.is_file() and f.name.startswith("tsconfig") and f.name.endswith(".json"):
|
|
897
|
+
return "typescript"
|
|
898
|
+
return language
|
|
899
|
+
|
|
900
|
+
return None
|
|
901
|
+
|
|
902
|
+
@staticmethod
|
|
903
|
+
def _infer_repo_role(repo_dir: Path, primary_language: Optional[str]) -> str:
|
|
904
|
+
"""Infer the role of a repo from its name and contents.
|
|
905
|
+
|
|
906
|
+
Returns one of: gitops, iac, platform, agent, library, application.
|
|
907
|
+
"""
|
|
908
|
+
name_lower = repo_dir.name.lower()
|
|
909
|
+
|
|
910
|
+
# GitOps indicators
|
|
911
|
+
if any(kw in name_lower for kw in ("gitops", "flux", "argocd", "deploy")):
|
|
912
|
+
return "gitops"
|
|
913
|
+
|
|
914
|
+
# IaC indicators
|
|
915
|
+
if any(kw in name_lower for kw in ("iac", "infra", "terraform")):
|
|
916
|
+
return "iac"
|
|
917
|
+
# Also check for .tf files at root
|
|
918
|
+
try:
|
|
919
|
+
if any(f.suffix == ".tf" for f in repo_dir.iterdir() if f.is_file()):
|
|
920
|
+
return "iac"
|
|
921
|
+
except OSError:
|
|
922
|
+
pass
|
|
923
|
+
|
|
924
|
+
# Platform indicators
|
|
925
|
+
if any(kw in name_lower for kw in ("platform", "core", "shared", "common")):
|
|
926
|
+
return "platform"
|
|
927
|
+
|
|
928
|
+
# Agent indicators
|
|
929
|
+
if any(kw in name_lower for kw in ("agent", "bot", "assistant")):
|
|
930
|
+
return "agent"
|
|
931
|
+
|
|
932
|
+
return "application"
|
|
933
|
+
|
|
934
|
+
# ------------------------------------------------------------------
|
|
935
|
+
# File search helpers
|
|
936
|
+
# ------------------------------------------------------------------
|
|
937
|
+
|
|
938
|
+
def _find_files(self, root: Path, filename: str) -> List[Path]:
|
|
939
|
+
"""Find files matching filename in root and subdirectories.
|
|
940
|
+
|
|
941
|
+
Respects MONOREPO_SCAN_DEPTH and SKIP_DIRS. Returns root-level
|
|
942
|
+
matches first, then subdirectory matches.
|
|
943
|
+
"""
|
|
944
|
+
results: List[Path] = []
|
|
945
|
+
|
|
946
|
+
# Check root first
|
|
947
|
+
root_file = root / filename
|
|
948
|
+
if root_file.is_file():
|
|
949
|
+
results.append(root_file)
|
|
950
|
+
|
|
951
|
+
# Scan subdirectories up to MONOREPO_SCAN_DEPTH
|
|
952
|
+
self._find_files_recursive(root, filename, results, 0)
|
|
953
|
+
|
|
954
|
+
return results
|
|
955
|
+
|
|
956
|
+
def _find_files_recursive(
|
|
957
|
+
self, directory: Path, filename: str, results: List[Path], depth: int
|
|
958
|
+
) -> None:
|
|
959
|
+
"""Recursively search for files, respecting depth and skip dirs."""
|
|
960
|
+
if depth >= MONOREPO_SCAN_DEPTH:
|
|
961
|
+
return
|
|
962
|
+
|
|
963
|
+
try:
|
|
964
|
+
for entry in sorted(directory.iterdir()):
|
|
965
|
+
if not entry.is_dir():
|
|
966
|
+
continue
|
|
967
|
+
if entry.name in SKIP_DIRS or entry.name.startswith("."):
|
|
968
|
+
continue
|
|
969
|
+
|
|
970
|
+
target = entry / filename
|
|
971
|
+
if target.is_file():
|
|
972
|
+
results.append(target)
|
|
973
|
+
|
|
974
|
+
self._find_files_recursive(entry, filename, results, depth + 1)
|
|
975
|
+
except PermissionError:
|
|
976
|
+
pass
|
|
977
|
+
|
|
978
|
+
def _find_files_by_extension(self, root: Path, ext: str) -> List[Path]:
|
|
979
|
+
"""Find files with a given extension in root and subdirectories."""
|
|
980
|
+
results: List[Path] = []
|
|
981
|
+
|
|
982
|
+
# Check root
|
|
983
|
+
try:
|
|
984
|
+
for entry in root.iterdir():
|
|
985
|
+
if entry.is_file() and entry.name.endswith(ext):
|
|
986
|
+
results.append(entry)
|
|
987
|
+
return results # One match is enough
|
|
988
|
+
except PermissionError:
|
|
989
|
+
pass
|
|
990
|
+
|
|
991
|
+
# Check subdirectories
|
|
992
|
+
self._find_ext_recursive(root, ext, results, 0)
|
|
993
|
+
return results
|
|
994
|
+
|
|
995
|
+
def _find_ext_recursive(
|
|
996
|
+
self, directory: Path, ext: str, results: List[Path], depth: int
|
|
997
|
+
) -> None:
|
|
998
|
+
"""Recursively search for files by extension."""
|
|
999
|
+
if depth >= MONOREPO_SCAN_DEPTH or results:
|
|
1000
|
+
return
|
|
1001
|
+
|
|
1002
|
+
try:
|
|
1003
|
+
for entry in sorted(directory.iterdir()):
|
|
1004
|
+
if entry.is_dir() and entry.name not in SKIP_DIRS and not entry.name.startswith("."):
|
|
1005
|
+
# Check for matching files in this directory
|
|
1006
|
+
try:
|
|
1007
|
+
for child in entry.iterdir():
|
|
1008
|
+
if child.is_file() and child.name.endswith(ext):
|
|
1009
|
+
results.append(child)
|
|
1010
|
+
return
|
|
1011
|
+
except PermissionError:
|
|
1012
|
+
pass
|
|
1013
|
+
self._find_ext_recursive(entry, ext, results, depth + 1)
|
|
1014
|
+
except PermissionError:
|
|
1015
|
+
pass
|
|
1016
|
+
|
|
1017
|
+
# ------------------------------------------------------------------
|
|
1018
|
+
# TOML parsing helpers (no external dependency)
|
|
1019
|
+
# ------------------------------------------------------------------
|
|
1020
|
+
|
|
1021
|
+
def _extract_toml_value(self, content: str, key: str) -> Optional[str]:
|
|
1022
|
+
"""Extract a simple string value from TOML content.
|
|
1023
|
+
|
|
1024
|
+
Handles: key = "value" patterns. Does NOT handle nested tables
|
|
1025
|
+
or multiline values -- for those, we use section-specific parsers.
|
|
1026
|
+
"""
|
|
1027
|
+
pattern = rf'^\s*{re.escape(key)}\s*=\s*["\']([^"\']*)["\']'
|
|
1028
|
+
match = re.search(pattern, content, re.MULTILINE)
|
|
1029
|
+
return match.group(1) if match else None
|
|
1030
|
+
|
|
1031
|
+
def _extract_toml_deps(self, content: str) -> str:
|
|
1032
|
+
"""Extract dependency-related sections from pyproject.toml content.
|
|
1033
|
+
|
|
1034
|
+
Returns a combined string of all dependency declarations for
|
|
1035
|
+
framework matching.
|
|
1036
|
+
"""
|
|
1037
|
+
sections: List[str] = []
|
|
1038
|
+
|
|
1039
|
+
# [project.dependencies]
|
|
1040
|
+
dep_match = re.search(
|
|
1041
|
+
r"\[project\]\s*\n(.*?)(?=\n\[|\Z)",
|
|
1042
|
+
content,
|
|
1043
|
+
re.DOTALL,
|
|
1044
|
+
)
|
|
1045
|
+
if dep_match:
|
|
1046
|
+
sections.append(dep_match.group(1))
|
|
1047
|
+
|
|
1048
|
+
# dependencies = [...] array
|
|
1049
|
+
dep_array = re.search(
|
|
1050
|
+
r"dependencies\s*=\s*\[(.*?)\]",
|
|
1051
|
+
content,
|
|
1052
|
+
re.DOTALL,
|
|
1053
|
+
)
|
|
1054
|
+
if dep_array:
|
|
1055
|
+
sections.append(dep_array.group(1))
|
|
1056
|
+
|
|
1057
|
+
# [tool.poetry.dependencies]
|
|
1058
|
+
poetry_deps = re.search(
|
|
1059
|
+
r"\[tool\.poetry\.dependencies\]\s*\n(.*?)(?=\n\[|\Z)",
|
|
1060
|
+
content,
|
|
1061
|
+
re.DOTALL,
|
|
1062
|
+
)
|
|
1063
|
+
if poetry_deps:
|
|
1064
|
+
sections.append(poetry_deps.group(1))
|
|
1065
|
+
|
|
1066
|
+
# optional-dependencies sections
|
|
1067
|
+
opt_deps = re.findall(
|
|
1068
|
+
r"\[(?:project\.)?optional-dependencies(?:\.\w+)?\]\s*\n(.*?)(?=\n\[|\Z)",
|
|
1069
|
+
content,
|
|
1070
|
+
re.DOTALL,
|
|
1071
|
+
)
|
|
1072
|
+
sections.extend(opt_deps)
|
|
1073
|
+
|
|
1074
|
+
return "\n".join(sections)
|
|
1075
|
+
|
|
1076
|
+
def _extract_python_version(self, deps_text: str, dep_name: str) -> Optional[str]:
|
|
1077
|
+
"""Extract version specifier for a Python dependency."""
|
|
1078
|
+
pattern = rf'["\']?{re.escape(dep_name)}(?:\[.*?\])?\s*[>=<~!]+\s*([\d.]+)'
|
|
1079
|
+
match = re.search(pattern, deps_text, re.IGNORECASE)
|
|
1080
|
+
return match.group(1) if match else None
|
|
1081
|
+
|
|
1082
|
+
def _extract_inline_version(self, dep_string: str) -> Optional[str]:
|
|
1083
|
+
"""Extract version from an inline dependency string like 'fastapi>=0.100.0'."""
|
|
1084
|
+
match = re.search(r"[>=<~!]+\s*([\d.]+)", dep_string)
|
|
1085
|
+
return match.group(1) if match else None
|