@khoavu882/doflow 0.0.0-stage → 2.0.0-beta.4
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/LICENSE +21 -0
- package/README.md +132 -2
- package/bin/doflow.js +44 -0
- package/core/.antigravity-plugin/hooks.json +39 -0
- package/core/.antigravity-plugin/mcp_config.json +18 -0
- package/core/.antigravity-plugin/plugin.json +31 -0
- package/core/.claude-plugin/marketplace.json +20 -0
- package/core/.claude-plugin/plugin.json +21 -0
- package/core/.codex-plugin/marketplace.json +20 -0
- package/core/.codex-plugin/plugin.json +35 -0
- package/core/.plugin/plugin.json +19 -0
- package/core/harnesses/antigravity/hooks/pre-implementation-gate.sh +9 -0
- package/core/harnesses/antigravity/hooks/stop-check.sh +10 -0
- package/core/harnesses/claude/hooks/lib.sh +304 -0
- package/core/harnesses/claude/hooks/mcp-tool-guard.sh +20 -0
- package/core/harnesses/claude/hooks/permission-audit.sh +47 -0
- package/core/harnesses/claude/hooks/post-compact.sh +74 -0
- package/core/harnesses/claude/hooks/post-edit-lint.sh +36 -0
- package/core/harnesses/claude/hooks/post-tool-failure.sh +47 -0
- package/core/harnesses/claude/hooks/pre-bash-guard.sh +20 -0
- package/core/harnesses/claude/hooks/pre-compact.sh +43 -0
- package/core/harnesses/claude/hooks/pre-implement-gate.sh +21 -0
- package/core/harnesses/claude/hooks/session-end.sh +89 -0
- package/core/harnesses/claude/hooks/session-start.sh +5 -0
- package/core/harnesses/claude/hooks/skill-config-audit.sh +39 -0
- package/core/harnesses/claude/hooks/stop-check.sh +5 -0
- package/core/harnesses/claude/hooks/subagent-audit.sh +7 -0
- package/core/harnesses/claude/hooks/user-prompt-submit.sh +5 -0
- package/core/harnesses/claude/settings/keybindings.json +20 -0
- package/core/harnesses/claude/settings/settings.json +201 -0
- package/core/harnesses/claude/statusline/doflow-statusline.sh +49 -0
- package/core/harnesses/codex/agents/core-implementer.toml +7 -0
- package/core/harnesses/codex/agents/quality-guardian.toml +8 -0
- package/core/harnesses/codex/agents/research-writer.toml +8 -0
- package/core/harnesses/codex/agents/spec-analyst.toml +8 -0
- package/core/harnesses/codex/agents/system-architect.toml +9 -0
- package/core/harnesses/codex/config/config.toml +10 -0
- package/core/harnesses/codex/hooks/hooks.json +122 -0
- package/core/harnesses/codex/hooks/lib.sh +335 -0
- package/core/harnesses/codex/hooks/mcp-tool-guard.sh +18 -0
- package/core/harnesses/codex/hooks/post-edit-lint.impl.sh +52 -0
- package/core/harnesses/codex/hooks/post-edit-lint.sh +6 -0
- package/core/harnesses/codex/hooks/pre-bash-guard.sh +18 -0
- package/core/harnesses/codex/hooks/pre-compact.impl.sh +46 -0
- package/core/harnesses/codex/hooks/pre-compact.sh +9 -0
- package/core/harnesses/codex/hooks/pre-implement-gate.sh +19 -0
- package/core/harnesses/codex/hooks/session-end.impl.sh +92 -0
- package/core/harnesses/codex/hooks/session-end.sh +5 -0
- package/core/harnesses/codex/hooks/session-start.sh +5 -0
- package/core/harnesses/codex/hooks/stop-check.sh +8 -0
- package/core/harnesses/codex/hooks/subagent-audit.sh +8 -0
- package/core/harnesses/codex/hooks/user-prompt-submit.sh +6 -0
- package/core/harnesses/gemini/hooks/hooks.json +77 -0
- package/core/harnesses/gemini/hooks/lib.sh +302 -0
- package/core/harnesses/gemini/hooks/mcp-tool-guard.sh +21 -0
- package/core/harnesses/gemini/hooks/post-edit-lint.sh +39 -0
- package/core/harnesses/gemini/hooks/pre-bash-guard.sh +21 -0
- package/core/harnesses/gemini/hooks/pre-compact.sh +38 -0
- package/core/harnesses/gemini/hooks/pre-implement-gate.sh +22 -0
- package/core/harnesses/gemini/hooks/session-end.impl.sh +89 -0
- package/core/harnesses/gemini/hooks/session-end.sh +5 -0
- package/core/harnesses/gemini/hooks/session-start.sh +5 -0
- package/core/harnesses/gemini/settings/adapter-defaults.json +4 -0
- package/core/harnesses/kiro/agents/doflow.json +58 -0
- package/core/harnesses/kiro/hooks/doflow.json +53 -0
- package/core/harnesses/kiro/hooks/lib.sh +324 -0
- package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +9 -0
- package/core/harnesses/kiro/hooks/pre-bash-guard.sh +9 -0
- package/core/harnesses/kiro/hooks/pre-implement-gate.sh +9 -0
- package/core/harnesses/kiro/hooks/session-start.sh +9 -0
- package/core/harnesses/kiro/hooks/stop-check.sh +43 -0
- package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +39 -0
- package/core/harnesses/shared/hooks/adapters/antigravity.js +111 -0
- package/core/harnesses/shared/hooks/adapters/arg-fields.js +19 -0
- package/core/harnesses/shared/hooks/adapters/gemini.js +114 -0
- package/core/harnesses/shared/hooks/hooks.json +47 -0
- package/core/harnesses/shared/hooks/policies/blocked-patterns.conf +91 -0
- package/core/harnesses/shared/hooks/policies/capture-failure.sh +97 -0
- package/core/harnesses/shared/hooks/policies/deny-json.sh +29 -0
- package/core/harnesses/shared/hooks/policies/lib.sh +501 -0
- package/core/harnesses/shared/hooks/policies/mcp-policy.conf +21 -0
- package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +91 -0
- package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +328 -0
- package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +496 -0
- package/core/harnesses/shared/hooks/policies/prompt-nudge.jq +131 -0
- package/core/harnesses/shared/hooks/policies/session-context.sh +141 -0
- package/core/harnesses/shared/hooks/policies/stop-check.sh +264 -0
- package/core/harnesses/shared/hooks/policies/subagent-audit.sh +46 -0
- package/core/harnesses/shared/hooks/policies/user-prompt-submit.sh +235 -0
- package/core/harnesses/shared/hooks/stream-hook-runner.js +590 -0
- package/core/harnesses/shared/locator/doflow-run +87 -0
- package/core/registry/advice.json +101 -0
- package/core/registry/assets.json +792 -0
- package/core/registry/capabilities.json +120 -0
- package/core/registry/contracts.json +189 -0
- package/core/registry/external-tools.json +191 -0
- package/core/registry/harnesses.json +2062 -0
- package/core/registry/lifecycle.json +231 -0
- package/core/registry/mcp.json +18 -0
- package/core/registry/models.json +79 -0
- package/core/registry/readiness-templates.json +118 -0
- package/core/registry/routes.json +47 -0
- package/core/registry/storage.json +1061 -0
- package/core/registry/verification.json +258 -0
- package/core/registry/workflows.json +790 -0
- package/core/shared/agent-specs/core-implementer.md +73 -0
- package/core/shared/agent-specs/quality-guardian.md +28 -0
- package/core/shared/agent-specs/research-writer.md +31 -0
- package/core/shared/agent-specs/spec-analyst.md +33 -0
- package/core/shared/agent-specs/system-architect.md +29 -0
- package/core/shared/guidance/DOFLOW_CORE.md +8 -0
- package/core/shared/guidance/FLAGS.md +25 -0
- package/core/shared/guidance/PRINCIPLES.md +61 -0
- package/core/shared/guidance/VERSION +1 -0
- package/core/shared/guidance/mcp/MCP_Context7.md +18 -0
- package/core/shared/guidance/mcp/MCP_Sequential.md +17 -0
- package/core/shared/guidance/modes/MODE_Brainstorming.md +46 -0
- package/core/shared/guidance/modes/MODE_DeepResearch.md +69 -0
- package/core/shared/guidance/modes/MODE_Introspection.md +33 -0
- package/core/shared/guidance/modes/MODE_Orchestration.md +57 -0
- package/core/shared/guidance/modes/MODE_Task_Management.md +74 -0
- package/core/shared/guidance/pointers/claude-gemini.md +2 -0
- package/core/shared/guidance/pointers/codex.md +6 -0
- package/core/shared/guidance/references/ARTIFACT_FORMAT.md +373 -0
- package/core/shared/guidance/references/ARTIFACT_VOICE.md +104 -0
- package/core/shared/guidance/references/CONSTITUTION_BASE.md +47 -0
- package/core/shared/guidance/references/DIAGRAM_GUIDE.md +161 -0
- package/core/shared/guidance/references/DOFLOW_CHAIN.md +87 -0
- package/core/shared/guidance/references/EVIDENCE_LEDGER.md +93 -0
- package/core/shared/guidance/references/MODEL_SELECTION.md +74 -0
- package/core/shared/guidance/references/ORCHESTRATION.md +36 -0
- package/core/shared/guidance/references/RESEARCH_CONFIG.md +21 -0
- package/core/shared/guidance/references/WORKFLOW_HANDOFF.md +81 -0
- package/core/shared/guidance/rules/RULE_01_SAFETY.md +25 -0
- package/core/shared/guidance/rules/RULE_02_WORKFLOW.md +23 -0
- package/core/shared/guidance/rules/RULE_03_QUALITY.md +14 -0
- package/core/shared/guidance/rules/RULE_04_QUESTIONS.md +46 -0
- package/core/shared/scripts/doflow/assets/mermaid-theme.json +46 -0
- package/core/shared/scripts/doflow/bash/do-exec-paths.sh +167 -0
- package/core/shared/scripts/doflow/bash/do-git-state.sh +546 -0
- package/core/shared/scripts/doflow/bash/do-parallel-check.sh +208 -0
- package/core/shared/scripts/doflow/bash/do-paths.sh +347 -0
- package/core/shared/scripts/doflow/bash/do-prereqs.sh +66 -0
- package/core/shared/scripts/doflow/bash/do-review-package.sh +127 -0
- package/core/shared/scripts/doflow/bash/do-task-brief.sh +698 -0
- package/core/shared/scripts/doflow/bash/render-audit.sh +224 -0
- package/core/shared/scripts/doflow/bash/render-diagrams.sh +177 -0
- package/core/shared/scripts/doflow/bash/render-puml.sh +223 -0
- package/core/shared/scripts/doflow/bash/sync-context.sh +66 -0
- package/core/shared/scripts/doflow/bash/validate-artifacts.sh +526 -0
- package/core/shared/scripts/doflow/bin/doflow-run +592 -0
- package/core/shared/skills/do/SKILL.md +128 -0
- package/core/shared/skills/do/references/estimation.md +13 -0
- package/core/shared/skills/do/references/pm_routing.md +25 -0
- package/core/shared/skills/do/references/tool_matrix.md +52 -0
- package/core/shared/skills/do-brainstorm/SKILL.md +252 -0
- package/core/shared/skills/do-code-review/README.md +49 -0
- package/core/shared/skills/do-code-review/SKILL.md +333 -0
- package/core/shared/skills/do-code-review/assets/sample_c_clean.c +71 -0
- package/core/shared/skills/do-code-review/assets/sample_c_smells.c +66 -0
- package/core/shared/skills/do-code-review/assets/sample_cpp_shift.cpp +19 -0
- package/core/shared/skills/do-code-review/assets/sample_csharp_clean.cs +100 -0
- package/core/shared/skills/do-code-review/assets/sample_csharp_smells.cs +78 -0
- package/core/shared/skills/do-code-review/assets/sample_java_clean.java +55 -0
- package/core/shared/skills/do-code-review/assets/sample_java_smells.java +56 -0
- package/core/shared/skills/do-code-review/assets/sample_json_clean.json +7 -0
- package/core/shared/skills/do-code-review/assets/sample_markdown_clean.md +30 -0
- package/core/shared/skills/do-code-review/assets/sample_markdown_smells.md +29 -0
- package/core/shared/skills/do-code-review/assets/sample_shell_heredoc.sh +38 -0
- package/core/shared/skills/do-code-review/assets/sample_shell_smells.sh +15 -0
- package/core/shared/skills/do-code-review/assets/sample_typescript_strings.ts +36 -0
- package/core/shared/skills/do-code-review/assets/sample_yaml_smells.yaml +6 -0
- package/core/shared/skills/do-code-review/content-types/config.md +83 -0
- package/core/shared/skills/do-code-review/content-types/markdown.md +132 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_c_clean_quality.json +79 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_c_smells_quality.json +169 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_cpp_shift_quality.json +49 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_csharp_clean_quality.json +149 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_csharp_smells_quality.json +157 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_java_clean_quality.json +67 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_java_smells_quality.json +97 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_json_clean_quality.json +33 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_markdown_clean_quality.json +1 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_markdown_smells_quality.json +16 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_shell_heredoc_quality.json +54 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_shell_smells_quality.json +91 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_typescript_strings_quality.json +60 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_yaml_smells_quality.json +46 -0
- package/core/shared/skills/do-code-review/languages/c.md +103 -0
- package/core/shared/skills/do-code-review/languages/cpp.md +103 -0
- package/core/shared/skills/do-code-review/languages/csharp.md +97 -0
- package/core/shared/skills/do-code-review/languages/dart.md +108 -0
- package/core/shared/skills/do-code-review/languages/go.md +92 -0
- package/core/shared/skills/do-code-review/languages/java.md +103 -0
- package/core/shared/skills/do-code-review/languages/kotlin.md +88 -0
- package/core/shared/skills/do-code-review/languages/php.md +109 -0
- package/core/shared/skills/do-code-review/languages/python.md +94 -0
- package/core/shared/skills/do-code-review/languages/ruby.md +105 -0
- package/core/shared/skills/do-code-review/languages/rust.md +99 -0
- package/core/shared/skills/do-code-review/languages/shell.md +90 -0
- package/core/shared/skills/do-code-review/languages/swift.md +88 -0
- package/core/shared/skills/do-code-review/languages/typescript.md +97 -0
- package/core/shared/skills/do-code-review/review-policy.json +47 -0
- package/core/shared/skills/do-code-review/rules/universal.md +49 -0
- package/core/shared/skills/do-code-review/scripts/code_quality_checker.py +1696 -0
- package/core/shared/skills/do-code-review/scripts/doc_quality_checker.py +532 -0
- package/core/shared/skills/do-code-review/scripts/pr_analyzer.py +620 -0
- package/core/shared/skills/do-code-review/scripts/review_report_generator.py +505 -0
- package/core/shared/skills/do-constitution/SKILL.md +72 -0
- package/core/shared/skills/do-design/SKILL.md +212 -0
- package/core/shared/skills/do-diagnose/SKILL.md +156 -0
- package/core/shared/skills/do-diagnose/references/code_audit.md +15 -0
- package/core/shared/skills/do-diagnose/references/refactoring.md +12 -0
- package/core/shared/skills/do-diagnose/references/root_cause.md +17 -0
- package/core/shared/skills/do-document/SKILL.md +81 -0
- package/core/shared/skills/do-document/references/api-reference.md +42 -0
- package/core/shared/skills/do-document/references/deep_research.md +18 -0
- package/core/shared/skills/do-document/references/embedded-research.md +11 -0
- package/core/shared/skills/do-document/references/implementation-flow.md +105 -0
- package/core/shared/skills/do-document/references/index_generator.md +9 -0
- package/core/shared/skills/do-document/references/user-guide.md +22 -0
- package/core/shared/skills/do-execute-plan/SKILL.md +261 -0
- package/core/shared/skills/do-execute-plan/references/handoff_record.md +57 -0
- package/core/shared/skills/do-execute-plan/references/parallel_dispatch.md +92 -0
- package/core/shared/skills/do-execute-plan/references/readiness_gate.md +155 -0
- package/core/shared/skills/do-execute-plan/references/run_position.md +63 -0
- package/core/shared/skills/do-execute-plan/references/scaffold.md +441 -0
- package/core/shared/skills/do-execute-plan/references/task_attempts.md +41 -0
- package/core/shared/skills/do-flow/SKILL.md +186 -0
- package/core/shared/skills/do-flow/references/task_classes.md +134 -0
- package/core/shared/skills/do-git/SKILL.md +115 -0
- package/core/shared/skills/do-git/references/hotfix-ritual.md +44 -0
- package/core/shared/skills/do-git/references/lifecycle-policy.md +54 -0
- package/core/shared/skills/do-git/references/release-ritual.md +64 -0
- package/core/shared/skills/do-implement/SKILL.md +222 -0
- package/core/shared/skills/do-plan/SKILL.md +219 -0
- package/core/shared/skills/do-test/SKILL.md +113 -0
- package/core/shared/templates/doflow/constitution-template.md +28 -0
- package/core/shared/templates/doflow/data-model-template.md +169 -0
- package/core/shared/templates/doflow/design-template.md +193 -0
- package/core/shared/templates/doflow/external-contract-template.md +35 -0
- package/core/shared/templates/doflow/intent-template.md +45 -0
- package/core/shared/templates/doflow/plan-template.md +151 -0
- package/core/shared/templates/doflow/question-log-template.md +51 -0
- package/core/shared/templates/doflow/requirement-template.md +126 -0
- package/core/shared/templates/doflow/specs-template.md +55 -0
- package/core/shared/templates/doflow/state-template.md +59 -0
- package/core/shared/workflows/do-flow-chain.md +17 -0
- package/package.json +48 -4
- package/src/adapters/antigravity/index.js +631 -0
- package/src/adapters/claude/index.js +548 -0
- package/src/adapters/claude/mcp.js +94 -0
- package/src/adapters/codex/agents.js +208 -0
- package/src/adapters/codex/config.js +272 -0
- package/src/adapters/codex/hooks.js +203 -0
- package/src/adapters/codex/index.js +707 -0
- package/src/adapters/codex/mcp.js +271 -0
- package/src/adapters/codex/native-projection.js +34 -0
- package/src/adapters/copilot/index.js +413 -0
- package/src/adapters/copy-tree.js +620 -0
- package/src/adapters/gemini/hooks.js +210 -0
- package/src/adapters/gemini/index.js +390 -0
- package/src/adapters/hook-commands.js +60 -0
- package/src/adapters/index.js +106 -0
- package/src/adapters/kiro/index.js +310 -0
- package/src/adapters/mcp-entries.js +331 -0
- package/src/adapters/opencode/index.js +466 -0
- package/src/adapters/pi/index.js +279 -0
- package/src/adapters/pi/json-members.js +185 -0
- package/src/adapters/pi/mcp.js +373 -0
- package/src/adapters/resolve-source.js +19 -0
- package/src/cli/commands/install.js +218 -0
- package/src/cli/commands/list-backups.js +21 -0
- package/src/cli/commands/reconcile.js +120 -0
- package/src/cli/commands/remove.js +82 -0
- package/src/cli/commands/rollback.js +172 -0
- package/src/cli/commands/self-update.js +33 -0
- package/src/cli/commands/status.js +138 -0
- package/src/cli/commands/tools.js +92 -0
- package/src/cli/commands/update.js +162 -0
- package/src/cli/index.js +568 -0
- package/src/cli/runtime-commands.js +281 -0
- package/src/cli/shared.js +218 -0
- package/src/helper/bash-helper.js +34 -0
- package/src/helper/git.js +24 -0
- package/src/helper/guardrails.js +49 -0
- package/src/helper/harness-paths.js +140 -0
- package/src/helper/marker-merge.js +116 -0
- package/src/helper/prompt.js +61 -0
- package/src/helper/repo-root.js +29 -0
- package/src/helper/settings-merge.js +151 -0
- package/src/helper/settings-scope.js +33 -0
- package/src/helper/toml.js +244 -0
- package/src/install/backup-set.js +149 -0
- package/src/install/backup.js +724 -0
- package/src/install/context.js +58 -0
- package/src/install/manifest.js +145 -0
- package/src/install/mcp.js +254 -0
- package/src/install/paths.js +84 -0
- package/src/install/targets.js +90 -0
- package/src/install/tool-lifecycle.js +151 -0
- package/src/lifecycle/bash-availability.js +25 -0
- package/src/lifecycle/index.js +812 -0
- package/src/lifecycle/mcp-index.js +28 -0
- package/src/lifecycle/policies.js +73 -0
- package/src/lifecycle/view.js +175 -0
- package/src/registry/index.js +611 -0
- package/src/release/build-package.js +91 -0
- package/src/release/changelog-section.js +70 -0
- package/src/release/sync-plugin-versions.js +66 -0
- package/src/runtime/advice/cli.js +283 -0
- package/src/runtime/advice/client.js +324 -0
- package/src/runtime/advice/config.js +189 -0
- package/src/runtime/advice/doctor.js +169 -0
- package/src/runtime/advice/endpoint-guard.js +134 -0
- package/src/runtime/advice/kinds.js +154 -0
- package/src/runtime/advice/providers/ollama-systemone.js +209 -0
- package/src/runtime/advice/record.js +166 -0
- package/src/runtime/advice/registry.js +170 -0
- package/src/runtime/advice/strength.js +66 -0
- package/src/runtime/brief-tools.js +342 -0
- package/src/runtime/c4-project.js +371 -0
- package/src/runtime/capability-router.js +484 -0
- package/src/runtime/checkouts.js +170 -0
- package/src/runtime/claims.js +468 -0
- package/src/runtime/cli-result.js +55 -0
- package/src/runtime/cli.js +678 -0
- package/src/runtime/command-detect.js +516 -0
- package/src/runtime/context-pack.js +492 -0
- package/src/runtime/decision-register.js +493 -0
- package/src/runtime/evidence-ledger.js +338 -0
- package/src/runtime/failure/capture.js +272 -0
- package/src/runtime/failure/classifier.js +68 -0
- package/src/runtime/failure/cli.js +178 -0
- package/src/runtime/failure/home.js +64 -0
- package/src/runtime/failure/store.js +226 -0
- package/src/runtime/feature-resolve.js +79 -0
- package/src/runtime/freshness.js +177 -0
- package/src/runtime/health.js +863 -0
- package/src/runtime/history-compactor.js +237 -0
- package/src/runtime/implementation-gate.js +321 -0
- package/src/runtime/inventory/coverage.js +218 -0
- package/src/runtime/inventory/identity.js +65 -0
- package/src/runtime/inventory/index.js +705 -0
- package/src/runtime/inventory/precedence.js +134 -0
- package/src/runtime/inventory/read-scopes.js +131 -0
- package/src/runtime/inventory/siblings.js +192 -0
- package/src/runtime/knowledge/chunker.js +124 -0
- package/src/runtime/knowledge/hybrid.js +149 -0
- package/src/runtime/knowledge/index-store.js +145 -0
- package/src/runtime/knowledge/retrieval.js +194 -0
- package/src/runtime/leak-scan.js +185 -0
- package/src/runtime/lifecycle/cli.js +276 -0
- package/src/runtime/lifecycle/event-store.js +340 -0
- package/src/runtime/lifecycle/fold.js +353 -0
- package/src/runtime/lifecycle/followup.js +432 -0
- package/src/runtime/lifecycle/goal.js +249 -0
- package/src/runtime/lifecycle/intent-writer.js +86 -0
- package/src/runtime/lifecycle/overview.js +292 -0
- package/src/runtime/lifecycle/release.js +241 -0
- package/src/runtime/lifecycle/report-store.js +221 -0
- package/src/runtime/lifecycle/retention.js +212 -0
- package/src/runtime/lifecycle/root.js +71 -0
- package/src/runtime/lifecycle/status.js +353 -0
- package/src/runtime/lifecycle/store-upkeep.js +393 -0
- package/src/runtime/locator-resolve.js +158 -0
- package/src/runtime/mask.js +210 -0
- package/src/runtime/mcp-consultations.js +412 -0
- package/src/runtime/mcp-reach.js +157 -0
- package/src/runtime/model-router.js +153 -0
- package/src/runtime/outcome.js +467 -0
- package/src/runtime/reach.js +148 -0
- package/src/runtime/readiness-record.js +150 -0
- package/src/runtime/readiness.js +435 -0
- package/src/runtime/recovery.js +282 -0
- package/src/runtime/research-request.js +231 -0
- package/src/runtime/retrieval-plan.js +578 -0
- package/src/runtime/scaffold/artifacts.js +217 -0
- package/src/runtime/scaffold/fingerprint.js +56 -0
- package/src/runtime/scaffold/generate.js +911 -0
- package/src/runtime/scaffold/languages.js +245 -0
- package/src/runtime/storage/cli.js +64 -0
- package/src/runtime/storage/engine.js +249 -0
- package/src/runtime/storage/registry.js +130 -0
- package/src/runtime/storage/roots.js +63 -0
- package/src/runtime/storage/rules.js +282 -0
- package/src/runtime/storage/upkeep.js +243 -0
- package/src/runtime/storage/work-status.js +326 -0
- package/src/runtime/task-attempts.js +415 -0
- package/src/runtime/task-classifier.js +550 -0
- package/src/runtime/task-scope.js +130 -0
- package/src/runtime/task-state.js +176 -0
- package/src/runtime/trace/indicators.js +235 -0
- package/src/runtime/trace/ledger.js +626 -0
- package/src/runtime/trace/render.js +76 -0
- package/src/runtime/trace/views.js +459 -0
- package/src/runtime/verification/contract-runner.js +341 -0
- package/src/runtime/verification/engine.js +1461 -0
- package/src/runtime/verification/registry.js +130 -0
- package/src/runtime/verification/scope-bound.js +418 -0
- package/src/runtime/verification/test-selection.js +172 -0
- package/src/runtime/workflow-engine.js +588 -0
- package/src/runtime/workflow-orchestrator.js +953 -0
- package/src/runtime/worktree.js +340 -0
- package/src/state/index.js +193 -0
- package/src/state/lockfile.js +136 -0
- package/src/state/run-lock.js +408 -0
- package/types/node-commonjs.d.ts +68 -0
- package/types/runtime.d.ts +91 -0
|
@@ -0,0 +1,1696 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Code Quality Checker
|
|
4
|
+
|
|
5
|
+
Analyzes source code for quality issues, code smells, complexity metrics,
|
|
6
|
+
and SOLID principle violations.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python code_quality_checker.py /path/to/file.py
|
|
10
|
+
python code_quality_checker.py /path/to/directory --recursive
|
|
11
|
+
python code_quality_checker.py . --language typescript --json
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Language-specific file extensions.
|
|
24
|
+
# `c` is declared before `cpp` so plain `.h` resolves to C, matching the
|
|
25
|
+
# dispatch table in SKILL.md. C++ headers use `.hpp` / `.hh` / `.hxx`.
|
|
26
|
+
LANGUAGE_EXTENSIONS = {
|
|
27
|
+
"python": [".py"],
|
|
28
|
+
"typescript": [".ts", ".tsx"],
|
|
29
|
+
"javascript": [".js", ".jsx", ".mjs"],
|
|
30
|
+
"go": [".go"],
|
|
31
|
+
"swift": [".swift"],
|
|
32
|
+
"kotlin": [".kt", ".kts"],
|
|
33
|
+
"csharp": [".cs", ".csx", ".razor", ".cshtml"],
|
|
34
|
+
"java": [".java"],
|
|
35
|
+
"c": [".c", ".h"],
|
|
36
|
+
"cpp": [".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"],
|
|
37
|
+
"rust": [".rs"],
|
|
38
|
+
"ruby": [".rb", ".rake", ".gemspec", ".ru"],
|
|
39
|
+
"php": [".php", ".phtml"],
|
|
40
|
+
"dart": [".dart"],
|
|
41
|
+
"shell": [".sh", ".bash", ".zsh"],
|
|
42
|
+
"yaml": [".yaml", ".yml"],
|
|
43
|
+
"json": [".json"],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Languages analysed as declarative structure rather than as code. Functions, classes, cyclomatic
|
|
47
|
+
# complexity and SOLID have no meaning here, and reporting them anyway is how a fabricated score
|
|
48
|
+
# gets attached to a file nobody analysed — the defect this coverage work exists to remove.
|
|
49
|
+
DECLARATIVE_LANGUAGES = {"yaml", "json"}
|
|
50
|
+
|
|
51
|
+
# Code smell thresholds
|
|
52
|
+
THRESHOLDS = {
|
|
53
|
+
"long_function_lines": 50,
|
|
54
|
+
"too_many_parameters": 5,
|
|
55
|
+
"high_complexity": 10,
|
|
56
|
+
"god_class_methods": 20,
|
|
57
|
+
"max_imports": 15,
|
|
58
|
+
"max_declarative_depth": 8,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Feature 030 — the review policy resolves from three tiers instead of living in prose.
|
|
62
|
+
#
|
|
63
|
+
# THRESHOLDS above stays the literal source for the built-in tier (IC-001): it is copied, never
|
|
64
|
+
# duplicated, so the lowest tier cannot drift from the code that reads it. The shipped default
|
|
65
|
+
# (review-policy.json beside this scripts/ directory) and an optional per-repo file overlay it, and
|
|
66
|
+
# load_policy() writes the merged result back into THRESHOLDS before any analysis runs — which is why
|
|
67
|
+
# the eight existing THRESHOLDS[...] reads need no edit and cannot be missed.
|
|
68
|
+
#
|
|
69
|
+
# Why a file at all: SKILL.md used to transcribe these numbers and drifted, listing five of six and
|
|
70
|
+
# denying a nesting-depth check the analyzer performs for declarative files. A document that copies a
|
|
71
|
+
# value is a copy that goes stale; a document that names a file is not.
|
|
72
|
+
|
|
73
|
+
# The label each threshold is known by in documentation. Lives here rather than in the guard that
|
|
74
|
+
# reads it, so adding a threshold does not mean editing the guard too.
|
|
75
|
+
THRESHOLD_LABELS = {
|
|
76
|
+
"long_function_lines": "Long function",
|
|
77
|
+
"too_many_parameters": "Too many params",
|
|
78
|
+
"high_complexity": "High complexity",
|
|
79
|
+
"god_class_methods": "God class",
|
|
80
|
+
"max_imports": "Too many imports",
|
|
81
|
+
"max_declarative_depth": "Nesting depth",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
DEFAULT_SEVERITIES = ["low", "medium", "high"]
|
|
85
|
+
|
|
86
|
+
# Bands a reviewer reads a score against. Declared, never applied: the verdict stays a reading of the
|
|
87
|
+
# code, and automating it would make a score authoritative over that reading.
|
|
88
|
+
DEFAULT_VERDICT_BANDS = [
|
|
89
|
+
{"verdict": "Approve", "minScore": 90, "maxHighFindings": 0},
|
|
90
|
+
{"verdict": "Approve with suggestions", "minScore": 75, "maxHighFindings": 2},
|
|
91
|
+
{"verdict": "Request changes", "minScore": 50, "maxHighFindings": None},
|
|
92
|
+
{"verdict": "Block", "minScore": 0, "maxHighFindings": None},
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
# Empty because the analyzer excludes nothing today. Inventing a default exclusion here would change
|
|
96
|
+
# behaviour under cover of a refactor.
|
|
97
|
+
DEFAULT_EXCLUSIONS = []
|
|
98
|
+
|
|
99
|
+
POLICY_GROUPS = ("thresholds", "thresholdLabels", "severities", "verdictBands", "exclusions")
|
|
100
|
+
SHIPPED_POLICY_FILENAME = "review-policy.json"
|
|
101
|
+
REPO_POLICY_RELPATH = Path("agent-docs") / "review-policy.json"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _policy_error(message: str) -> None:
|
|
105
|
+
"""Stop rather than fall back. A policy that silently reverts to defaults applies one policy
|
|
106
|
+
while its author believes another is in force, which is worse than having no file."""
|
|
107
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
108
|
+
sys.exit(2)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _check_thresholds(value, source: str) -> None:
|
|
112
|
+
if not isinstance(value, dict):
|
|
113
|
+
_policy_error(f"{source}: 'thresholds' must be an object")
|
|
114
|
+
for key, number in value.items():
|
|
115
|
+
if key not in THRESHOLDS:
|
|
116
|
+
_policy_error(f"{source}: unknown threshold '{key}' — the analyzer implements "
|
|
117
|
+
f"{', '.join(sorted(THRESHOLDS))}")
|
|
118
|
+
# bool is an int in Python, and `true` is not a threshold.
|
|
119
|
+
if not isinstance(number, int) or isinstance(number, bool) or number <= 0:
|
|
120
|
+
_policy_error(f"{source}: threshold '{key}' must be a positive integer, got {number!r}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _check_threshold_labels(value, source: str) -> None:
|
|
124
|
+
if not isinstance(value, dict) or any(
|
|
125
|
+
not isinstance(k, str) or not isinstance(v, str) for k, v in value.items()):
|
|
126
|
+
_policy_error(f"{source}: 'thresholdLabels' must be an object of strings")
|
|
127
|
+
for key in value:
|
|
128
|
+
if key not in THRESHOLDS:
|
|
129
|
+
_policy_error(f"{source}: label declared for unknown threshold '{key}'")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _check_string_list(group: str):
|
|
133
|
+
def check(value, source: str) -> None:
|
|
134
|
+
if not isinstance(value, list) or any(not isinstance(v, str) for v in value):
|
|
135
|
+
_policy_error(f"{source}: '{group}' must be a list of strings")
|
|
136
|
+
return check
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _check_verdict_bands(value, source: str) -> None:
|
|
140
|
+
if not isinstance(value, list) or any(not isinstance(v, dict) for v in value):
|
|
141
|
+
_policy_error(f"{source}: 'verdictBands' must be a list of objects")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# One validator per group, looked up rather than branched through. The first version of this was a
|
|
145
|
+
# single nested ladder at complexity 27, which this skill's own analyzer flagged at high severity —
|
|
146
|
+
# a validator whose shape is hard to read is the wrong place to economise.
|
|
147
|
+
GROUP_VALIDATORS = {
|
|
148
|
+
"thresholds": _check_thresholds,
|
|
149
|
+
"thresholdLabels": _check_threshold_labels,
|
|
150
|
+
"severities": _check_string_list("severities"),
|
|
151
|
+
"exclusions": _check_string_list("exclusions"),
|
|
152
|
+
"verdictBands": _check_verdict_bands,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _validate_policy_fragment(fragment: Dict, source: str) -> None:
|
|
157
|
+
if not isinstance(fragment, dict):
|
|
158
|
+
_policy_error(f"{source}: top level must be an object")
|
|
159
|
+
for group, value in fragment.items():
|
|
160
|
+
if group == "description":
|
|
161
|
+
continue
|
|
162
|
+
validator = GROUP_VALIDATORS.get(group)
|
|
163
|
+
if validator is None:
|
|
164
|
+
_policy_error(f"{source}: unknown policy group '{group}' "
|
|
165
|
+
f"(known: {', '.join(POLICY_GROUPS)})")
|
|
166
|
+
validator(value, source)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _read_policy_file(path: Path) -> Dict:
|
|
170
|
+
try:
|
|
171
|
+
fragment = json.loads(path.read_text(encoding="utf-8"))
|
|
172
|
+
except json.JSONDecodeError as err:
|
|
173
|
+
_policy_error(f"{path}: does not parse as JSON — {err}")
|
|
174
|
+
_validate_policy_fragment(fragment, str(path))
|
|
175
|
+
return fragment
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _trace_path(path: Path) -> str:
|
|
179
|
+
"""Relative to the working directory where possible. The fixtures compare emitted JSON across
|
|
180
|
+
machines, and an absolute path would make every recorded output machine-specific — the same
|
|
181
|
+
reason finding locations are already emitted relative."""
|
|
182
|
+
try:
|
|
183
|
+
return str(path.relative_to(Path.cwd()))
|
|
184
|
+
except ValueError:
|
|
185
|
+
return str(path)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _walk_up_for_policy(start: Path) -> Optional[Path]:
|
|
189
|
+
current = start if start.is_dir() else start.parent
|
|
190
|
+
for directory in [current, *current.parents]:
|
|
191
|
+
candidate = directory / REPO_POLICY_RELPATH
|
|
192
|
+
if candidate.is_file():
|
|
193
|
+
return candidate
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def find_repo_policy(start: Path) -> Optional[Path]:
|
|
198
|
+
"""First agent-docs/review-policy.json found walking upward, mirroring the runtime seam's own
|
|
199
|
+
upward search for .doflow/. First hit wins, and the trace names which file won.
|
|
200
|
+
|
|
201
|
+
The analysed path is searched first, because when it sits in a repository that repository's own
|
|
202
|
+
policy is the one under review. Only when that search finds nothing does the working directory
|
|
203
|
+
get a turn — which is what makes `cd my-repo && ... /tmp/patch.py` apply my-repo's policy instead
|
|
204
|
+
of silently applying none. A target inside a policy-bearing repo is therefore never overridden by
|
|
205
|
+
wherever the reviewer happened to be standing."""
|
|
206
|
+
found = _walk_up_for_policy(start)
|
|
207
|
+
if found is not None:
|
|
208
|
+
return found
|
|
209
|
+
return _walk_up_for_policy(Path.cwd())
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def load_policy(start: Path):
|
|
213
|
+
"""Resolve the policy from three tiers, lowest precedence first. Returns (policy, tiers)."""
|
|
214
|
+
policy = {
|
|
215
|
+
"thresholds": dict(THRESHOLDS),
|
|
216
|
+
"severities": list(DEFAULT_SEVERITIES),
|
|
217
|
+
"verdictBands": [dict(b) for b in DEFAULT_VERDICT_BANDS],
|
|
218
|
+
"exclusions": list(DEFAULT_EXCLUSIONS),
|
|
219
|
+
"thresholdLabels": dict(THRESHOLD_LABELS),
|
|
220
|
+
}
|
|
221
|
+
tiers = ["built-in"]
|
|
222
|
+
|
|
223
|
+
shipped = Path(__file__).resolve().parent.parent / SHIPPED_POLICY_FILENAME
|
|
224
|
+
for source in (shipped, find_repo_policy(start)):
|
|
225
|
+
if source is None or not source.is_file():
|
|
226
|
+
continue
|
|
227
|
+
fragment = _read_policy_file(source)
|
|
228
|
+
# Per key within a group, never deeper: groups hold scalars and flat lists, so there is no
|
|
229
|
+
# nested case, and validation above refuses a shape that would need one.
|
|
230
|
+
for group in POLICY_GROUPS:
|
|
231
|
+
if group not in fragment:
|
|
232
|
+
continue
|
|
233
|
+
if group in ("thresholds", "thresholdLabels"):
|
|
234
|
+
policy[group].update(fragment[group])
|
|
235
|
+
else:
|
|
236
|
+
policy[group] = fragment[group]
|
|
237
|
+
tiers.append(_trace_path(source))
|
|
238
|
+
|
|
239
|
+
return policy, tiers
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def get_file_extension(filepath: Path) -> str:
|
|
243
|
+
"""Get file extension."""
|
|
244
|
+
return filepath.suffix.lower()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def detect_language(filepath: Path) -> Optional[str]:
|
|
248
|
+
"""Detect programming language from file extension."""
|
|
249
|
+
ext = get_file_extension(filepath)
|
|
250
|
+
for lang, extensions in LANGUAGE_EXTENSIONS.items():
|
|
251
|
+
if ext in extensions:
|
|
252
|
+
return lang
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def read_file_content(filepath: Path) -> str:
|
|
257
|
+
"""Read file content safely."""
|
|
258
|
+
try:
|
|
259
|
+
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
|
260
|
+
return f.read()
|
|
261
|
+
except Exception:
|
|
262
|
+
return ""
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# Only these languages have heredocs. Everything else spells `<<` as an operator — a stream insert
|
|
266
|
+
# in C++, a bit shift in Java, an array append in Ruby's non-heredoc use — and must never be scanned
|
|
267
|
+
# for one. See `_blank_heredocs` for what happens when it is.
|
|
268
|
+
_HEREDOC_LANGUAGES = frozenset({"shell", "ruby", "php"})
|
|
269
|
+
|
|
270
|
+
# A bare terminator must follow the delimiter immediately: `cat <<EOF`, never `cat << EOF`. That one
|
|
271
|
+
# character of strictness is what separates a heredoc from `cout << total`, which always has the
|
|
272
|
+
# space. The quoted forms may keep it, since `<< 'EOF'` is not a shift in any language.
|
|
273
|
+
_HEREDOC_OPENER = re.compile(
|
|
274
|
+
r"""<<<?[-~]?(?:\s*(['"])([A-Za-z_][A-Za-z0-9_]*)\1|([A-Za-z_][A-Za-z0-9_]*))"""
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _heredoc_end(lines: List[str], start: int, word: str) -> Optional[int]:
|
|
279
|
+
"""
|
|
280
|
+
Index of the line terminating a heredoc opened at `start`, or None if it never terminates.
|
|
281
|
+
|
|
282
|
+
Whitespace around the terminator is ignored, which is more lenient than any single language
|
|
283
|
+
allows. Leniency here can only end a body early, and ending early under-blanks — the safe
|
|
284
|
+
direction. Returning None is what makes an opener that is really an operator harmless.
|
|
285
|
+
"""
|
|
286
|
+
for j in range(start + 1, len(lines)):
|
|
287
|
+
if lines[j].strip() == word:
|
|
288
|
+
return j
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _blank_heredocs(content: str) -> str:
|
|
293
|
+
"""
|
|
294
|
+
Blank the body of every heredoc that actually terminates, preserving newlines.
|
|
295
|
+
|
|
296
|
+
A heredoc body is prose, SQL or JSON far more often than it is code, and the keyword scan
|
|
297
|
+
charges every `and`, `or`, `if` and `case` in it as a branch. A shell function whose entire
|
|
298
|
+
body is `cat <<'EOF' ... EOF` printing a usage message scored complexity 17 against a
|
|
299
|
+
threshold of 10 — a false `high_complexity` smell on a function with no branch at all.
|
|
300
|
+
|
|
301
|
+
Heredocs were previously parked as needing a tokenizer, alongside regex literals and PHP's
|
|
302
|
+
`<?php` boundary. That grouping was wrong for this one form: a heredoc *declares its own
|
|
303
|
+
terminator at the opener*, so finding the end needs no grammar, only the word the opener
|
|
304
|
+
already named. Recognising the opener is the bounded part, not recognising the end.
|
|
305
|
+
|
|
306
|
+
Bounded exactly like `_blank_spanning`, and in the same direction: an opener whose terminator
|
|
307
|
+
never appears in the remaining lines is treated as not an opener, leaving the text untouched.
|
|
308
|
+
Every bound below fails toward under-blanking, because over-blanking is what manufactures a
|
|
309
|
+
false pass.
|
|
310
|
+
|
|
311
|
+
Recognised: `<<WORD`, `<<-WORD`, `<<~WORD` (Ruby) and `<<<WORD` (PHP), quoted or bare. The
|
|
312
|
+
terminator is matched on its own line ignoring surrounding whitespace — more lenient than any
|
|
313
|
+
single language, which can only end a body early and so under-blank. Lines beginning with `#`
|
|
314
|
+
are skipped as openers, so a comment mentioning a heredoc cannot start one.
|
|
315
|
+
|
|
316
|
+
**Only called for `_HEREDOC_LANGUAGES`, and that gate is load-bearing rather than tidy.** An
|
|
317
|
+
earlier version ran for every language on the reasoning that a `<<` operator would find no
|
|
318
|
+
terminator and be left alone. That reasoning was wrong: it checked only that an opener with no
|
|
319
|
+
closer cannot blank to end-of-file, never that a *spurious* opener could meet a *spurious*
|
|
320
|
+
closer part-way down. `std::cout << total` followed anywhere below by a line reading `total`
|
|
321
|
+
blanked every branch between them, taking a C++ function from complexity 7 to 1 — a false pass,
|
|
322
|
+
the one failure this module treats as unacceptable. The gate removes the case; requiring a bare
|
|
323
|
+
terminator to touch the delimiter (`<<EOF`, never `<< EOF`) removes it a second time.
|
|
324
|
+
|
|
325
|
+
The opener's own line is code and is kept; only the body and the terminator line are blanked.
|
|
326
|
+
"""
|
|
327
|
+
lines = content.split("\n")
|
|
328
|
+
out = list(lines)
|
|
329
|
+
i = 0
|
|
330
|
+
while i < len(lines):
|
|
331
|
+
line = lines[i]
|
|
332
|
+
if line.lstrip().startswith("#"):
|
|
333
|
+
i += 1
|
|
334
|
+
continue
|
|
335
|
+
# A single command may open several bodies: `cmd <<A <<B`. They fill in the order opened.
|
|
336
|
+
words = [m.group(2) or m.group(3) for m in _HEREDOC_OPENER.finditer(line)]
|
|
337
|
+
cursor = i
|
|
338
|
+
for word in words:
|
|
339
|
+
end = _heredoc_end(lines, cursor, word)
|
|
340
|
+
if end is None:
|
|
341
|
+
# No terminator anywhere below: not a heredoc. Leave this line and stop here.
|
|
342
|
+
break
|
|
343
|
+
for j in range(cursor + 1, end + 1):
|
|
344
|
+
out[j] = " " * len(lines[j])
|
|
345
|
+
cursor = end
|
|
346
|
+
i = cursor + 1 if cursor > i else i + 1
|
|
347
|
+
return "\n".join(out)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def strip_comments(content: str, language: Optional[str] = None) -> str:
|
|
351
|
+
"""
|
|
352
|
+
Remove comment text so prose is not measured as code.
|
|
353
|
+
|
|
354
|
+
The keyword scan below matches \\band\\b and \\bor\\b — Python operators — against raw text,
|
|
355
|
+
case-insensitively. A documented function was therefore charged for every "and" and "or" in its
|
|
356
|
+
own prose, so the better a function was commented the worse it scored. Blank lines replace the
|
|
357
|
+
removed text rather than deleting it, so any line number derived from this string still lines up
|
|
358
|
+
with the original file.
|
|
359
|
+
|
|
360
|
+
Deliberately not handled: an "and"/"or" inside a string literal still counts. That is a separate
|
|
361
|
+
defect from the reported one, and stripping strings correctly needs a real tokenizer.
|
|
362
|
+
"""
|
|
363
|
+
# Heredocs first of all: their bodies are arbitrary text, so a stray backtick or quote inside
|
|
364
|
+
# one would otherwise pair with real code below and let `_blank_spanning` erase across it.
|
|
365
|
+
# Gated on the language, and defaulting to *not* blanking: a caller that passes nothing gets the
|
|
366
|
+
# under-blanking behaviour, so a future call site that forgets the argument cannot reintroduce
|
|
367
|
+
# the false pass that gating exists to prevent.
|
|
368
|
+
if language in _HEREDOC_LANGUAGES:
|
|
369
|
+
content = _blank_heredocs(content)
|
|
370
|
+
# Block comments next, preserving newline count so line numbers survive.
|
|
371
|
+
content = re.sub(r"/\*.*?\*/", lambda m: "\n" * m.group(0).count("\n"), content, flags=re.DOTALL)
|
|
372
|
+
# Multi-line forms first, since a line-scoped pass cannot see them. Each is blanked in place with
|
|
373
|
+
# newlines preserved, so the line-scoped pass below then sees ordinary blank content.
|
|
374
|
+
for opener in ('\"\"\"', "'''", '`'):
|
|
375
|
+
content = _blank_spanning(content, opener)
|
|
376
|
+
|
|
377
|
+
out = []
|
|
378
|
+
for line in content.split("\n"):
|
|
379
|
+
stripped = line.lstrip()
|
|
380
|
+
if stripped.startswith("//") or stripped.startswith("*") or stripped.startswith("#"):
|
|
381
|
+
out.append("")
|
|
382
|
+
else:
|
|
383
|
+
out.append(re.sub(r"\s//.*$", "", line))
|
|
384
|
+
return "\n".join(out)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _blank_spanning(content: str, delim: str) -> str:
|
|
388
|
+
"""
|
|
389
|
+
Blank the interior of every `delim`-fenced block that actually closes, preserving newlines.
|
|
390
|
+
|
|
391
|
+
Bounded the same way as the line-scoped pass: an opener with no matching closer in the rest of
|
|
392
|
+
the text is left exactly as it is. Over-blanking is the failure that matters — one unterminated
|
|
393
|
+
delimiter silently erasing the remainder of a file would turn a branch-heavy function into a
|
|
394
|
+
clean one, which is the false pass this whole analyser thread exists to remove.
|
|
395
|
+
"""
|
|
396
|
+
out, i, n = [], 0, len(content)
|
|
397
|
+
while i < n:
|
|
398
|
+
start = content.find(delim, i)
|
|
399
|
+
if start == -1:
|
|
400
|
+
out.append(content[i:])
|
|
401
|
+
break
|
|
402
|
+
end = content.find(delim, start + len(delim))
|
|
403
|
+
if end == -1:
|
|
404
|
+
# No closer anywhere: not a fenced block. Leave the rest untouched.
|
|
405
|
+
out.append(content[i:])
|
|
406
|
+
break
|
|
407
|
+
inner = content[start + len(delim):end]
|
|
408
|
+
out.append(content[i:start] + delim + re.sub(r"[^\n]", " ", inner) + delim)
|
|
409
|
+
i = end + len(delim)
|
|
410
|
+
return "".join(out)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def strip_string_literals(content: str) -> str:
|
|
414
|
+
"""
|
|
415
|
+
Blank the contents of string literals so text inside them is not measured as code.
|
|
416
|
+
|
|
417
|
+
`strip_comments` fixed prose in comments being counted as control flow. The same defect exists
|
|
418
|
+
for strings: `const msg = "error or warning";` contributes a branch for the word `or`.
|
|
419
|
+
|
|
420
|
+
**This cannot be made fully correct without a tokenizer**, and none is available — the repository
|
|
421
|
+
has zero runtime dependencies and Python's `tokenize` only understands Python. Regex literals in
|
|
422
|
+
JS and PHP's `<?php` boundary need parse context this does not have. Triple-quotes and heredocs
|
|
423
|
+
do not, and are handled — see `_blank_spanning` and `_blank_heredocs`.
|
|
424
|
+
|
|
425
|
+
So the rule is bounded rather than complete: scanning is **line-scoped**, and a quote that does
|
|
426
|
+
not close on its own line is retroactively treated as not a string, leaving the rest of that line
|
|
427
|
+
untouched. That direction is deliberate. The failure that matters is not under-counting a branch;
|
|
428
|
+
it is a phantom unterminated string blanking the remainder of a file and turning a forty-branch
|
|
429
|
+
function into a clean one. Under-blanking loses a little accuracy. Over-blanking manufactures a
|
|
430
|
+
false pass, which is the whole defect class this analyser work exists to remove.
|
|
431
|
+
|
|
432
|
+
Length and newlines are preserved, so any line number derived from the result still lines up
|
|
433
|
+
with the original file.
|
|
434
|
+
|
|
435
|
+
Three multi-line forms are handled by carrying state across lines, in `strip_comments`, which
|
|
436
|
+
runs first: Python/JS triple-quoted blocks, JS backtick templates (`_blank_spanning`) and
|
|
437
|
+
heredocs (`_blank_heredocs`). All three are bounded the same way — an opener with no closer
|
|
438
|
+
anywhere in the remaining text is treated as not an opener, so an odd quote can never blank the
|
|
439
|
+
tail of a file. A heredoc's terminator is caller-chosen but the opener declares it, so it needs
|
|
440
|
+
no parsing; what is still unhandled is JS regex literals and PHP's `<?php` boundary, where
|
|
441
|
+
finding the end genuinely does require the grammar.
|
|
442
|
+
"""
|
|
443
|
+
out = []
|
|
444
|
+
for line in content.split("\n"):
|
|
445
|
+
i, n, buf = 0, len(line), []
|
|
446
|
+
while i < n:
|
|
447
|
+
ch = line[i]
|
|
448
|
+
if ch in "\"'`":
|
|
449
|
+
# Look for the matching close on this line, honouring backslash escapes.
|
|
450
|
+
j, closed = i + 1, False
|
|
451
|
+
while j < n:
|
|
452
|
+
if line[j] == "\\":
|
|
453
|
+
j += 2
|
|
454
|
+
continue
|
|
455
|
+
if line[j] == ch:
|
|
456
|
+
closed = True
|
|
457
|
+
break
|
|
458
|
+
j += 1
|
|
459
|
+
if closed:
|
|
460
|
+
buf.append(ch + " " * (j - i - 1) + ch)
|
|
461
|
+
i = j + 1
|
|
462
|
+
continue
|
|
463
|
+
# Unclosed on this line: bounded failure. Treat it as not a string and stop
|
|
464
|
+
# scanning this line rather than blanking to end-of-file.
|
|
465
|
+
buf.append(line[i:])
|
|
466
|
+
i = n
|
|
467
|
+
continue
|
|
468
|
+
buf.append(ch)
|
|
469
|
+
i += 1
|
|
470
|
+
out.append("".join(buf))
|
|
471
|
+
return "\n".join(out)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def trim_trailing_comment_block(body: str) -> str:
|
|
475
|
+
"""
|
|
476
|
+
Drop a documentation comment sitting at the end of a sliced function body.
|
|
477
|
+
|
|
478
|
+
`find_functions` slices a body from one function match to the *next* one, so whatever documents
|
|
479
|
+
the next function landed inside this one — inflating its line count and its complexity. The
|
|
480
|
+
comment belongs to what follows, not to what precedes it.
|
|
481
|
+
"""
|
|
482
|
+
lines = body.split("\n")
|
|
483
|
+
end = len(lines)
|
|
484
|
+
while end > 0 and lines[end - 1].strip() == "":
|
|
485
|
+
end -= 1
|
|
486
|
+
# Walk back over a contiguous run of comment lines at the tail.
|
|
487
|
+
start = end
|
|
488
|
+
while start > 0:
|
|
489
|
+
candidate = lines[start - 1].strip()
|
|
490
|
+
if candidate.startswith(("//", "/*", "*", "#")) or candidate == "":
|
|
491
|
+
start -= 1
|
|
492
|
+
else:
|
|
493
|
+
break
|
|
494
|
+
return "\n".join(lines[:start]) if start < end else "\n".join(lines[:end])
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def calculate_cyclomatic_complexity(content: str, language: Optional[str] = None) -> int:
|
|
498
|
+
"""
|
|
499
|
+
Estimate cyclomatic complexity based on control flow keywords.
|
|
500
|
+
|
|
501
|
+
Comments and string literals are stripped first: counting keywords in either made prose a
|
|
502
|
+
penalty. String stripping is bounded, not complete — see `strip_string_literals`.
|
|
503
|
+
"""
|
|
504
|
+
content = strip_string_literals(strip_comments(content, language))
|
|
505
|
+
complexity = 1 # Base complexity
|
|
506
|
+
|
|
507
|
+
# Control flow patterns that increase complexity
|
|
508
|
+
patterns = [
|
|
509
|
+
r"\bif\b",
|
|
510
|
+
r"\belif\b",
|
|
511
|
+
r"\belse\b",
|
|
512
|
+
r"\bfor\b",
|
|
513
|
+
r"\bwhile\b",
|
|
514
|
+
r"\bcase\b",
|
|
515
|
+
r"\bcatch\b",
|
|
516
|
+
r"\bexcept\b",
|
|
517
|
+
r"\band\b",
|
|
518
|
+
r"\bor\b",
|
|
519
|
+
r"\|\|",
|
|
520
|
+
r"&&"
|
|
521
|
+
]
|
|
522
|
+
|
|
523
|
+
for pattern in patterns:
|
|
524
|
+
matches = re.findall(pattern, content, re.IGNORECASE)
|
|
525
|
+
complexity += len(matches)
|
|
526
|
+
|
|
527
|
+
return complexity
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def count_lines(content: str) -> Dict[str, int]:
|
|
531
|
+
"""Count different types of lines in code."""
|
|
532
|
+
lines = content.split("\n")
|
|
533
|
+
total = len(lines)
|
|
534
|
+
blank = sum(1 for line in lines if not line.strip())
|
|
535
|
+
comment = 0
|
|
536
|
+
|
|
537
|
+
for line in lines:
|
|
538
|
+
stripped = line.strip()
|
|
539
|
+
if stripped.startswith("#") or stripped.startswith("//"):
|
|
540
|
+
comment += 1
|
|
541
|
+
elif stripped.startswith("/*") or stripped.startswith("'''") or stripped.startswith('"""'):
|
|
542
|
+
comment += 1
|
|
543
|
+
|
|
544
|
+
code = total - blank - comment
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
"total": total,
|
|
548
|
+
"code": code,
|
|
549
|
+
"blank": blank,
|
|
550
|
+
"comment": comment
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def find_functions(content: str, language: str) -> List[Dict]:
|
|
555
|
+
"""Find function definitions and their metrics."""
|
|
556
|
+
functions = []
|
|
557
|
+
|
|
558
|
+
# Language-specific function patterns
|
|
559
|
+
patterns = {
|
|
560
|
+
"python": r"def\s+(\w+)\s*\(([^)]*)\)",
|
|
561
|
+
# Both shell spellings: `name() {` and `function name {`.
|
|
562
|
+
"shell": r"(?:function\s+(\w+)\s*(?:\(\s*\))?|(\w+)\s*\(\s*\))\s*\{",
|
|
563
|
+
"typescript": r"(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>)",
|
|
564
|
+
"javascript": r"(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>)",
|
|
565
|
+
"go": r"func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(([^)]*)\)",
|
|
566
|
+
"swift": r"func\s+(\w+)\s*\(([^)]*)\)",
|
|
567
|
+
"kotlin": r"fun\s+(\w+)\s*\(([^)]*)\)",
|
|
568
|
+
# C#: require at least one method modifier (public/private/etc. or static/async/...)
|
|
569
|
+
# to distinguish declarations from invocations.
|
|
570
|
+
"csharp": (
|
|
571
|
+
r"(?:(?:public|private|protected|internal|static|async|virtual|"
|
|
572
|
+
r"override|sealed|abstract|partial|new|readonly|extern)\s+)+"
|
|
573
|
+
r"(?:[\w<>?,\s\[\]\.]+?\s+)?(\w+)\s*\(([^)]*)\)"
|
|
574
|
+
),
|
|
575
|
+
# Java: require at least one method modifier to distinguish
|
|
576
|
+
# declarations from invocations (mirrors the C# approach).
|
|
577
|
+
"java": (
|
|
578
|
+
r"(?:(?:public|private|protected|static|final|abstract|"
|
|
579
|
+
r"synchronized|native|default|strictfp)\s+)+"
|
|
580
|
+
r"(?:[\w<>?,\s\[\]\.]+?\s+)?(\w+)\s*\(([^)]*)\)"
|
|
581
|
+
),
|
|
582
|
+
# C: require an opening brace after the parens so prototypes and
|
|
583
|
+
# call sites don't get matched. Return type / qualifiers come first.
|
|
584
|
+
# Skip C control-flow keywords that look like function calls.
|
|
585
|
+
"c": (
|
|
586
|
+
r"^(?:static\s+|inline\s+|extern\s+|const\s+|unsigned\s+|"
|
|
587
|
+
r"signed\s+|volatile\s+|register\s+)*"
|
|
588
|
+
r"(?:[\w\*]+\s+\**)+"
|
|
589
|
+
r"(?!(?:if|while|for|switch|return|sizeof)\b)"
|
|
590
|
+
r"(\w+)\s*\(([^)]*)\)\s*\{"
|
|
591
|
+
),
|
|
592
|
+
# C++: like C but also catches `ClassName::method(...)` definitions
|
|
593
|
+
# and template return types like `std::vector<int>`.
|
|
594
|
+
"cpp": (
|
|
595
|
+
r"^(?:static\s+|inline\s+|extern\s+|const\s+|virtual\s+|"
|
|
596
|
+
r"explicit\s+|constexpr\s+|noexcept\s+)*"
|
|
597
|
+
r"(?:[\w:\*<>&,\s]+\s+\**)+"
|
|
598
|
+
r"(?!(?:if|while|for|switch|return|sizeof)\b)"
|
|
599
|
+
r"(\w+)(?:::\w+)?\s*\(([^)]*)\)\s*(?:const\s*)?"
|
|
600
|
+
r"(?:noexcept\s*)?(?:override\s*)?(?:final\s*)?\{"
|
|
601
|
+
),
|
|
602
|
+
# Rust: `fn` keyword is always present and unambiguous.
|
|
603
|
+
"rust": (
|
|
604
|
+
r"(?:pub(?:\([^)]+\))?\s+)?(?:async\s+)?(?:unsafe\s+)?"
|
|
605
|
+
r"(?:extern\s+\"[^\"]+\"\s+)?fn\s+(\w+)\s*"
|
|
606
|
+
r"(?:<[^>]+>)?\s*\(([^)]*)\)"
|
|
607
|
+
),
|
|
608
|
+
# Ruby: `def` keyword; params may be parenthesised or bare.
|
|
609
|
+
"ruby": (
|
|
610
|
+
r"def\s+(?:self\.)?(\w+[?!=]?)(?:\s*\(([^)]*)\)|\s*$|\s+\w)"
|
|
611
|
+
),
|
|
612
|
+
# PHP: `function` keyword is always present.
|
|
613
|
+
"php": (
|
|
614
|
+
r"(?:(?:public|private|protected|static|abstract|final)\s+)*"
|
|
615
|
+
r"function\s+(\w+)\s*\(([^)]*)\)"
|
|
616
|
+
),
|
|
617
|
+
# Dart: typed return followed by name and parens. Constructors
|
|
618
|
+
# (where name matches enclosing class) are not specially handled.
|
|
619
|
+
"dart": (
|
|
620
|
+
r"^\s*(?:static\s+|external\s+)*"
|
|
621
|
+
r"(?:Future<[^>]*>|Stream<[^>]*>|void|[\w<>?,\s]+?)\s+"
|
|
622
|
+
r"(\w+)\s*\(([^)]*)\)\s*(?:async\*?\s*|sync\*?\s*)?\{"
|
|
623
|
+
),
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
pattern = patterns.get(language, patterns["python"])
|
|
627
|
+
matches = re.finditer(pattern, content, re.MULTILINE)
|
|
628
|
+
|
|
629
|
+
for match in matches:
|
|
630
|
+
name = next((g for g in match.groups() if g), "anonymous")
|
|
631
|
+
params_str = match.group(2) if len(match.groups()) > 1 and match.group(2) else ""
|
|
632
|
+
|
|
633
|
+
# Count parameters
|
|
634
|
+
params = [p.strip() for p in params_str.split(",") if p.strip()]
|
|
635
|
+
param_count = len(params)
|
|
636
|
+
|
|
637
|
+
# Estimate function length
|
|
638
|
+
start_pos = match.end()
|
|
639
|
+
remaining = content[start_pos:]
|
|
640
|
+
|
|
641
|
+
next_func = re.search(pattern, remaining)
|
|
642
|
+
if next_func:
|
|
643
|
+
func_body = remaining[:next_func.start()]
|
|
644
|
+
else:
|
|
645
|
+
func_body = remaining[:min(2000, len(remaining))]
|
|
646
|
+
|
|
647
|
+
# The slice runs to the next function's *declaration*, so it swallows that function's
|
|
648
|
+
# documentation comment. Give it back before measuring anything.
|
|
649
|
+
func_body = trim_trailing_comment_block(func_body)
|
|
650
|
+
|
|
651
|
+
line_count = len(func_body.split("\n"))
|
|
652
|
+
complexity = calculate_cyclomatic_complexity(func_body, language)
|
|
653
|
+
|
|
654
|
+
functions.append({
|
|
655
|
+
"name": name,
|
|
656
|
+
"parameters": param_count,
|
|
657
|
+
"lines": line_count,
|
|
658
|
+
"complexity": complexity
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
return functions
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def find_classes(content: str, language: str) -> List[Dict]:
|
|
665
|
+
"""Find class definitions and their metrics."""
|
|
666
|
+
classes = []
|
|
667
|
+
|
|
668
|
+
patterns = {
|
|
669
|
+
"python": r"class\s+(\w+)",
|
|
670
|
+
"typescript": r"class\s+(\w+)",
|
|
671
|
+
"javascript": r"class\s+(\w+)",
|
|
672
|
+
"go": r"type\s+(\w+)\s+struct",
|
|
673
|
+
"swift": r"class\s+(\w+)",
|
|
674
|
+
"kotlin": r"class\s+(\w+)",
|
|
675
|
+
"csharp": r"(?:class|struct|record|interface)\s+(\w+)",
|
|
676
|
+
"java": r"(?:class|interface|enum|record)\s+(\w+)",
|
|
677
|
+
# C has no classes; `struct` and `typedef struct` are the closest.
|
|
678
|
+
"c": r"(?:typedef\s+)?struct\s+(\w+)",
|
|
679
|
+
"cpp": r"(?:class|struct)\s+(\w+)",
|
|
680
|
+
# Rust uses `struct`, `enum`, `trait`, `union` for type definitions.
|
|
681
|
+
# `impl` blocks attach methods but are not type defs themselves.
|
|
682
|
+
"rust": r"(?:pub(?:\([^)]+\))?\s+)?(?:struct|enum|trait|union)\s+(\w+)",
|
|
683
|
+
"ruby": r"(?:class|module)\s+(\w+)",
|
|
684
|
+
"php": (
|
|
685
|
+
r"(?:abstract\s+|final\s+)?"
|
|
686
|
+
r"(?:class|interface|trait|enum)\s+(\w+)"
|
|
687
|
+
),
|
|
688
|
+
# Dart 3 class modifiers: final / interface / base / sealed / mixin.
|
|
689
|
+
"dart": (
|
|
690
|
+
r"(?:abstract\s+|sealed\s+|final\s+|base\s+|interface\s+)?"
|
|
691
|
+
r"(?:class|mixin|enum|extension)\s+(\w+)"
|
|
692
|
+
),
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
pattern = patterns.get(language, patterns["python"])
|
|
696
|
+
matches = re.finditer(pattern, content)
|
|
697
|
+
|
|
698
|
+
for match in matches:
|
|
699
|
+
name = match.group(1)
|
|
700
|
+
|
|
701
|
+
start_pos = match.end()
|
|
702
|
+
remaining = content[start_pos:]
|
|
703
|
+
|
|
704
|
+
next_class = re.search(pattern, remaining)
|
|
705
|
+
if next_class:
|
|
706
|
+
class_body = remaining[:next_class.start()]
|
|
707
|
+
else:
|
|
708
|
+
class_body = remaining
|
|
709
|
+
|
|
710
|
+
# Count methods
|
|
711
|
+
method_patterns = {
|
|
712
|
+
"python": r"def\s+\w+\s*\(",
|
|
713
|
+
"typescript": r"(?:public|private|protected)?\s*\w+\s*\([^)]*\)\s*[:{]",
|
|
714
|
+
"javascript": r"\w+\s*\([^)]*\)\s*\{",
|
|
715
|
+
"go": r"func\s+\(",
|
|
716
|
+
"swift": r"func\s+\w+",
|
|
717
|
+
"kotlin": r"fun\s+\w+",
|
|
718
|
+
"csharp": (
|
|
719
|
+
r"(?:(?:public|private|protected|internal|static|async|virtual|"
|
|
720
|
+
r"override|sealed|abstract|partial)\s+)+"
|
|
721
|
+
r"(?:[\w<>?,\s\[\]\.]+?\s+)?\w+\s*\("
|
|
722
|
+
),
|
|
723
|
+
"java": (
|
|
724
|
+
r"(?:(?:public|private|protected|static|final|abstract|"
|
|
725
|
+
r"synchronized|native|default|strictfp)\s+)+"
|
|
726
|
+
r"(?:[\w<>?,\s\[\]\.]+?\s+)?\w+\s*\("
|
|
727
|
+
),
|
|
728
|
+
# C has no classes; struct members are typically function pointers
|
|
729
|
+
# rather than methods. Use the function definition pattern.
|
|
730
|
+
"c": (
|
|
731
|
+
r"^(?:static\s+|inline\s+)*(?:[\w\*]+\s+\**)+"
|
|
732
|
+
r"(?!(?:if|while|for|switch|return|sizeof)\b)"
|
|
733
|
+
r"\w+\s*\([^)]*\)\s*\{"
|
|
734
|
+
),
|
|
735
|
+
"cpp": (
|
|
736
|
+
r"^(?:static\s+|inline\s+|virtual\s+|explicit\s+|"
|
|
737
|
+
r"constexpr\s+)*(?:[\w:\*<>&,\s]+\s+\**)+"
|
|
738
|
+
r"(?!(?:if|while|for|switch|return|sizeof)\b)"
|
|
739
|
+
r"\w+(?:::\w+)?\s*\([^)]*\)"
|
|
740
|
+
),
|
|
741
|
+
"rust": (
|
|
742
|
+
r"(?:pub(?:\([^)]+\))?\s+)?(?:async\s+)?(?:unsafe\s+)?"
|
|
743
|
+
r"fn\s+\w+"
|
|
744
|
+
),
|
|
745
|
+
"ruby": r"def\s+(?:self\.)?\w+[?!=]?",
|
|
746
|
+
"php": (
|
|
747
|
+
r"(?:(?:public|private|protected|static|abstract|final)\s+)*"
|
|
748
|
+
r"function\s+\w+\s*\("
|
|
749
|
+
),
|
|
750
|
+
"dart": (
|
|
751
|
+
r"^\s*(?:static\s+|external\s+)*"
|
|
752
|
+
r"(?:Future<[^>]*>|Stream<[^>]*>|void|[\w<>?,\s]+?)\s+"
|
|
753
|
+
r"\w+\s*\([^)]*\)\s*(?:async\*?\s*|sync\*?\s*)?\{"
|
|
754
|
+
),
|
|
755
|
+
}
|
|
756
|
+
method_pattern = method_patterns.get(language, method_patterns["python"])
|
|
757
|
+
methods = len(re.findall(method_pattern, class_body))
|
|
758
|
+
|
|
759
|
+
classes.append({
|
|
760
|
+
"name": name,
|
|
761
|
+
"methods": methods,
|
|
762
|
+
"lines": len(class_body.split("\n"))
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
return classes
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def check_code_smells(content: str, functions: List[Dict], classes: List[Dict]) -> List[Dict]:
|
|
769
|
+
"""Check for code smells in the content."""
|
|
770
|
+
smells = []
|
|
771
|
+
|
|
772
|
+
# Long functions
|
|
773
|
+
for func in functions:
|
|
774
|
+
if func["lines"] > THRESHOLDS["long_function_lines"]:
|
|
775
|
+
smells.append({
|
|
776
|
+
"type": "long_function",
|
|
777
|
+
"severity": "medium",
|
|
778
|
+
"message": f"Function '{func['name']}' has {func['lines']} lines (max: {THRESHOLDS['long_function_lines']})",
|
|
779
|
+
"location": func["name"]
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
# Too many parameters
|
|
783
|
+
for func in functions:
|
|
784
|
+
if func["parameters"] > THRESHOLDS["too_many_parameters"]:
|
|
785
|
+
smells.append({
|
|
786
|
+
"type": "too_many_parameters",
|
|
787
|
+
"severity": "low",
|
|
788
|
+
"message": f"Function '{func['name']}' has {func['parameters']} parameters (max: {THRESHOLDS['too_many_parameters']})",
|
|
789
|
+
"location": func["name"]
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
# High complexity
|
|
793
|
+
for func in functions:
|
|
794
|
+
if func["complexity"] > THRESHOLDS["high_complexity"]:
|
|
795
|
+
severity = "high" if func["complexity"] > 20 else "medium"
|
|
796
|
+
smells.append({
|
|
797
|
+
"type": "high_complexity",
|
|
798
|
+
"severity": severity,
|
|
799
|
+
"message": f"Function '{func['name']}' has complexity {func['complexity']} (max: {THRESHOLDS['high_complexity']})",
|
|
800
|
+
"location": func["name"]
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
# God classes
|
|
804
|
+
for cls in classes:
|
|
805
|
+
if cls["methods"] > THRESHOLDS["god_class_methods"]:
|
|
806
|
+
smells.append({
|
|
807
|
+
"type": "god_class",
|
|
808
|
+
"severity": "high",
|
|
809
|
+
"message": f"Class '{cls['name']}' has {cls['methods']} methods (max: {THRESHOLDS['god_class_methods']})",
|
|
810
|
+
"location": cls["name"]
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
# Magic numbers
|
|
814
|
+
magic_pattern = r"\b(?<![.\"\'])\d{3,}\b(?!\.\d)"
|
|
815
|
+
for i, line in enumerate(content.split("\n"), 1):
|
|
816
|
+
if line.strip().startswith(("#", "//", "import", "from")):
|
|
817
|
+
continue
|
|
818
|
+
matches = re.findall(magic_pattern, line)
|
|
819
|
+
for match in matches[:1]: # One per line
|
|
820
|
+
smells.append({
|
|
821
|
+
"type": "magic_number",
|
|
822
|
+
"severity": "low",
|
|
823
|
+
"message": f"Magic number {match} should be a named constant",
|
|
824
|
+
"location": f"line {i}"
|
|
825
|
+
})
|
|
826
|
+
|
|
827
|
+
# Commented code patterns
|
|
828
|
+
commented_code_pattern = r"^\s*[#//]+\s*(if|for|while|def|function|class|const|let|var)\s"
|
|
829
|
+
for i, line in enumerate(content.split("\n"), 1):
|
|
830
|
+
if re.match(commented_code_pattern, line, re.IGNORECASE):
|
|
831
|
+
smells.append({
|
|
832
|
+
"type": "commented_code",
|
|
833
|
+
"severity": "low",
|
|
834
|
+
"message": "Commented-out code should be removed",
|
|
835
|
+
"location": f"line {i}"
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
return smells
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _strip_csharp_comments(content: str) -> str:
|
|
842
|
+
"""Remove // line comments and /* */ block comments so regex detectors
|
|
843
|
+
don't match keywords inside prose."""
|
|
844
|
+
no_block = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL)
|
|
845
|
+
no_line = re.sub(r"//[^\n]*", "", no_block)
|
|
846
|
+
return no_line
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def check_csharp_specific_smells(content: str) -> List[Dict]:
|
|
850
|
+
"""C# / .NET-specific code smells documented in SKILL.md."""
|
|
851
|
+
smells: List[Dict] = []
|
|
852
|
+
content = _strip_csharp_comments(content)
|
|
853
|
+
|
|
854
|
+
# async void (event handler exception only — caller must justify)
|
|
855
|
+
for match in re.finditer(r"\basync\s+void\s+(\w+)\s*\(", content):
|
|
856
|
+
smells.append({
|
|
857
|
+
"type": "csharp_async_void",
|
|
858
|
+
"severity": "high",
|
|
859
|
+
"message": (
|
|
860
|
+
f"'async void {match.group(1)}' — only safe for event handlers; "
|
|
861
|
+
"prefer 'async Task'"
|
|
862
|
+
),
|
|
863
|
+
"location": match.group(1),
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
# Blocking on async: .Result, .Wait(), .GetAwaiter().GetResult()
|
|
867
|
+
for match in re.finditer(
|
|
868
|
+
r"\.(?:Result\b|Wait\(\)|GetAwaiter\(\)\.GetResult\(\))", content
|
|
869
|
+
):
|
|
870
|
+
smells.append({
|
|
871
|
+
"type": "csharp_blocking_async",
|
|
872
|
+
"severity": "high",
|
|
873
|
+
"message": (
|
|
874
|
+
"Blocking call on async operation ('.Result' / '.Wait()' / "
|
|
875
|
+
"'.GetAwaiter().GetResult()') — can deadlock in ASP.NET contexts"
|
|
876
|
+
),
|
|
877
|
+
"location": f"offset {match.start()}",
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
# Bare catch / catch (Exception) that swallows
|
|
881
|
+
swallow_pattern = re.compile(
|
|
882
|
+
r"catch\s*(?:\(\s*(?:System\.)?Exception(?:\s+\w+)?\s*\))?\s*\{\s*\}"
|
|
883
|
+
)
|
|
884
|
+
for match in swallow_pattern.finditer(content):
|
|
885
|
+
smells.append({
|
|
886
|
+
"type": "csharp_swallowed_exception",
|
|
887
|
+
"severity": "high",
|
|
888
|
+
"message": "Empty catch block swallows exceptions silently",
|
|
889
|
+
"location": f"offset {match.start()}",
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
# IDisposable instantiated but not in `using` — heuristic: `new SomethingClient(`
|
|
893
|
+
# / `new SomethingStream(` / `new SqlConnection(` outside a `using` line.
|
|
894
|
+
disposable_hint = re.compile(
|
|
895
|
+
r"^(?!\s*using\b)\s*(?:var|[\w<>]+)\s+\w+\s*=\s*new\s+"
|
|
896
|
+
r"(\w*(?:Stream|Connection|Reader|Writer|Client|Context|Command))\s*\(",
|
|
897
|
+
re.MULTILINE,
|
|
898
|
+
)
|
|
899
|
+
for match in disposable_hint.finditer(content):
|
|
900
|
+
smells.append({
|
|
901
|
+
"type": "csharp_undisposed_idisposable",
|
|
902
|
+
"severity": "medium",
|
|
903
|
+
"message": (
|
|
904
|
+
f"'{match.group(1)}' looks like IDisposable but is not wrapped in "
|
|
905
|
+
"'using' / 'using var'"
|
|
906
|
+
),
|
|
907
|
+
"location": f"offset {match.start()}",
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
# HttpClient instantiated with `new` inside a method body (socket exhaustion)
|
|
911
|
+
httpclient_inline = re.compile(r"new\s+HttpClient\s*\(\s*\)")
|
|
912
|
+
for match in httpclient_inline.finditer(content):
|
|
913
|
+
smells.append({
|
|
914
|
+
"type": "csharp_new_httpclient",
|
|
915
|
+
"severity": "medium",
|
|
916
|
+
"message": (
|
|
917
|
+
"'new HttpClient()' — prefer IHttpClientFactory or a long-lived "
|
|
918
|
+
"static instance to avoid socket exhaustion"
|
|
919
|
+
),
|
|
920
|
+
"location": f"offset {match.start()}",
|
|
921
|
+
})
|
|
922
|
+
|
|
923
|
+
# Missing await: `Task.Run(` / async method call assigned but never awaited.
|
|
924
|
+
# Heuristic: a statement ending in `Async()` or `Async(...)` followed by `;`
|
|
925
|
+
# with no `await` keyword on the same line.
|
|
926
|
+
for line_no, line in enumerate(content.split("\n"), 1):
|
|
927
|
+
stripped = line.strip()
|
|
928
|
+
if not stripped or stripped.startswith(("//", "/*", "*")):
|
|
929
|
+
continue
|
|
930
|
+
if re.search(r"\b\w+Async\s*\([^)]*\)\s*;\s*$", stripped) and "await " not in stripped:
|
|
931
|
+
# Skip `return ...Async();` (forwarding the Task is legitimate)
|
|
932
|
+
if stripped.startswith("return "):
|
|
933
|
+
continue
|
|
934
|
+
smells.append({
|
|
935
|
+
"type": "csharp_missing_await",
|
|
936
|
+
"severity": "medium",
|
|
937
|
+
"message": "Async method called without 'await' — Task is discarded",
|
|
938
|
+
"location": f"line {line_no}",
|
|
939
|
+
})
|
|
940
|
+
|
|
941
|
+
# Unnecessary `using` directives — heuristic: `using` directive whose
|
|
942
|
+
# namespace tail isn't referenced anywhere else in the file.
|
|
943
|
+
using_directives = re.findall(
|
|
944
|
+
r"^using\s+(?:static\s+)?([A-Z]\w*(?:\.\w+)*)\s*;", content, re.MULTILINE
|
|
945
|
+
)
|
|
946
|
+
body = re.sub(r"^using\s+[^;]+;\s*$", "", content, flags=re.MULTILINE)
|
|
947
|
+
for ns in using_directives:
|
|
948
|
+
tail = ns.split(".")[-1]
|
|
949
|
+
if not re.search(rf"\b{re.escape(tail)}\b", body):
|
|
950
|
+
smells.append({
|
|
951
|
+
"type": "csharp_unused_using",
|
|
952
|
+
"severity": "low",
|
|
953
|
+
"message": f"'using {ns};' appears unused",
|
|
954
|
+
"location": ns,
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
return smells
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def check_java_specific_smells(content: str) -> List[Dict]:
|
|
961
|
+
"""Java-specific code smells documented in languages/java.md."""
|
|
962
|
+
smells: List[Dict] = []
|
|
963
|
+
# Java comment syntax matches C#, so the same stripper applies.
|
|
964
|
+
content = _strip_csharp_comments(content)
|
|
965
|
+
|
|
966
|
+
# Empty catch block — swallows the exception silently.
|
|
967
|
+
for match in re.finditer(r"catch\s*\([^)]*\)\s*\{\s*\}", content):
|
|
968
|
+
smells.append({
|
|
969
|
+
"type": "java_empty_catch",
|
|
970
|
+
"severity": "high",
|
|
971
|
+
"message": "Empty catch block swallows exceptions silently",
|
|
972
|
+
"location": f"offset {match.start()}",
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
# printStackTrace() as error handling — use a logger instead.
|
|
976
|
+
for match in re.finditer(r"\.printStackTrace\s*\(\s*\)", content):
|
|
977
|
+
smells.append({
|
|
978
|
+
"type": "java_print_stack_trace",
|
|
979
|
+
"severity": "medium",
|
|
980
|
+
"message": (
|
|
981
|
+
"'printStackTrace()' is not real error handling — log via a "
|
|
982
|
+
"proper logger or rethrow with context"
|
|
983
|
+
),
|
|
984
|
+
"location": f"offset {match.start()}",
|
|
985
|
+
})
|
|
986
|
+
|
|
987
|
+
# InterruptedException caught without restoring the interrupt flag.
|
|
988
|
+
for match in re.finditer(
|
|
989
|
+
r"catch\s*\(\s*InterruptedException\s+(\w+)\s*\)\s*\{(.*?)\}",
|
|
990
|
+
content,
|
|
991
|
+
re.DOTALL,
|
|
992
|
+
):
|
|
993
|
+
if "interrupt()" not in match.group(2):
|
|
994
|
+
smells.append({
|
|
995
|
+
"type": "java_swallowed_interrupt",
|
|
996
|
+
"severity": "high",
|
|
997
|
+
"message": (
|
|
998
|
+
"InterruptedException caught without "
|
|
999
|
+
"'Thread.currentThread().interrupt()' — breaks cooperative "
|
|
1000
|
+
"cancellation"
|
|
1001
|
+
),
|
|
1002
|
+
"location": f"offset {match.start()}",
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
# Closeable resource instantiated outside try-with-resources (leak heuristic).
|
|
1006
|
+
resource_hint = re.compile(
|
|
1007
|
+
r"^(?!\s*try\b)\s*(?:final\s+)?[\w<>\[\]]+\s+\w+\s*=\s*new\s+"
|
|
1008
|
+
r"(\w*(?:InputStream|OutputStream|Reader|Writer|Stream|Connection))\s*\(",
|
|
1009
|
+
re.MULTILINE,
|
|
1010
|
+
)
|
|
1011
|
+
for match in resource_hint.finditer(content):
|
|
1012
|
+
smells.append({
|
|
1013
|
+
"type": "java_unclosed_resource",
|
|
1014
|
+
"severity": "medium",
|
|
1015
|
+
"message": (
|
|
1016
|
+
f"'{match.group(1)}' looks like an AutoCloseable but is not in a "
|
|
1017
|
+
"try-with-resources statement"
|
|
1018
|
+
),
|
|
1019
|
+
"location": f"offset {match.start()}",
|
|
1020
|
+
})
|
|
1021
|
+
|
|
1022
|
+
# Heavy object built per use instead of shared as a singleton.
|
|
1023
|
+
# A `static` field assignment is the recommended singleton form — skip it.
|
|
1024
|
+
heavy_object = re.compile(
|
|
1025
|
+
r"^(?!.*\bstatic\b).*\bnew\s+(ObjectMapper|Gson)\s*\(\s*\)",
|
|
1026
|
+
re.MULTILINE,
|
|
1027
|
+
)
|
|
1028
|
+
for match in heavy_object.finditer(content):
|
|
1029
|
+
smells.append({
|
|
1030
|
+
"type": "java_per_use_heavy_object",
|
|
1031
|
+
"severity": "medium",
|
|
1032
|
+
"message": (
|
|
1033
|
+
f"'new {match.group(1)}()' is expensive — share a singleton "
|
|
1034
|
+
"instance instead of constructing per call"
|
|
1035
|
+
),
|
|
1036
|
+
"location": f"offset {match.start()}",
|
|
1037
|
+
})
|
|
1038
|
+
|
|
1039
|
+
return smells
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def check_c_specific_smells(content: str) -> List[Dict]:
|
|
1043
|
+
"""C-specific code smells documented in languages/c.md.
|
|
1044
|
+
|
|
1045
|
+
Focuses on memory-safety and command/format-string patterns that the
|
|
1046
|
+
CERT C Coding Standard and the CWE catalogue rank as the
|
|
1047
|
+
highest-impact footguns. C uses the same line/block comment syntax as
|
|
1048
|
+
C# and Java, so the existing comment stripper applies.
|
|
1049
|
+
"""
|
|
1050
|
+
smells: List[Dict] = []
|
|
1051
|
+
content = _strip_csharp_comments(content)
|
|
1052
|
+
|
|
1053
|
+
# 1. Banned functions — no bounds check on any of them.
|
|
1054
|
+
banned = {
|
|
1055
|
+
"gets": "no bounds check, removed from C11 (CWE-242)",
|
|
1056
|
+
"strcpy": "no bounds check — prefer strncpy or strlcpy",
|
|
1057
|
+
"strcat": "no bounds check — prefer strncat or strlcat",
|
|
1058
|
+
"sprintf": "no bounds check — prefer snprintf",
|
|
1059
|
+
"vsprintf": "no bounds check — prefer vsnprintf",
|
|
1060
|
+
}
|
|
1061
|
+
for fn, reason in banned.items():
|
|
1062
|
+
for m in re.finditer(rf"\b{fn}\s*\(", content):
|
|
1063
|
+
smells.append({
|
|
1064
|
+
"type": f"c_banned_{fn}",
|
|
1065
|
+
"severity": "high",
|
|
1066
|
+
"message": f"'{fn}()' is unsafe: {reason}",
|
|
1067
|
+
"location": f"offset {m.start()}",
|
|
1068
|
+
})
|
|
1069
|
+
|
|
1070
|
+
# 2. Format-string vulnerability — printf/syslog called with a bare
|
|
1071
|
+
# identifier as the format argument (CWE-134). Skip when the first
|
|
1072
|
+
# arg is a string literal.
|
|
1073
|
+
for fn in ("printf", "syslog"):
|
|
1074
|
+
pattern = rf"\b{fn}\s*\(\s*(?!\")(\w+)\s*[,\)]"
|
|
1075
|
+
for m in re.finditer(pattern, content):
|
|
1076
|
+
smells.append({
|
|
1077
|
+
"type": "c_format_string",
|
|
1078
|
+
"severity": "high",
|
|
1079
|
+
"message": (
|
|
1080
|
+
f"'{fn}({m.group(1)})' uses a non-literal format string "
|
|
1081
|
+
"— CWE-134 format string vulnerability"
|
|
1082
|
+
),
|
|
1083
|
+
"location": f"offset {m.start()}",
|
|
1084
|
+
})
|
|
1085
|
+
|
|
1086
|
+
# 3. Unbounded scanf — `%s` without a width specifier invites overflow.
|
|
1087
|
+
scanf_call = re.compile(
|
|
1088
|
+
r"\b(?:scanf|fscanf|sscanf)\s*\(\s*[^)]*?\"([^\"]*)\""
|
|
1089
|
+
)
|
|
1090
|
+
for m in scanf_call.finditer(content):
|
|
1091
|
+
fmt = m.group(1)
|
|
1092
|
+
if "%s" in fmt and not re.search(r"%\d+s", fmt):
|
|
1093
|
+
smells.append({
|
|
1094
|
+
"type": "c_unbounded_scanf",
|
|
1095
|
+
"severity": "high",
|
|
1096
|
+
"message": (
|
|
1097
|
+
"scanf '%s' without a width specifier — unbounded read "
|
|
1098
|
+
"can overflow the destination buffer"
|
|
1099
|
+
),
|
|
1100
|
+
"location": f"offset {m.start()}",
|
|
1101
|
+
})
|
|
1102
|
+
|
|
1103
|
+
# 4. malloc / calloc / realloc result dereferenced without a NULL check
|
|
1104
|
+
# within 5 lines. CWE-690.
|
|
1105
|
+
lines = content.split("\n")
|
|
1106
|
+
malloc_assign = re.compile(
|
|
1107
|
+
r"^\s*(?:[\w\*]+\s+)?\*?(\w+)\s*=\s*\(?[\w\s\*]*\)?\s*"
|
|
1108
|
+
r"(?:m|c|re)alloc\s*\("
|
|
1109
|
+
)
|
|
1110
|
+
for i, line in enumerate(lines):
|
|
1111
|
+
m = malloc_assign.match(line)
|
|
1112
|
+
if not m:
|
|
1113
|
+
continue
|
|
1114
|
+
var = m.group(1)
|
|
1115
|
+
window = "\n".join(lines[i + 1 : i + 6])
|
|
1116
|
+
null_check = re.compile(
|
|
1117
|
+
rf"\bif\s*\([^)]*(?:{re.escape(var)}\s*==\s*NULL"
|
|
1118
|
+
rf"|NULL\s*==\s*{re.escape(var)}"
|
|
1119
|
+
rf"|!\s*{re.escape(var)}\b"
|
|
1120
|
+
rf"|{re.escape(var)}\s*!=\s*NULL)"
|
|
1121
|
+
)
|
|
1122
|
+
if not null_check.search(window):
|
|
1123
|
+
smells.append({
|
|
1124
|
+
"type": "c_malloc_unchecked",
|
|
1125
|
+
"severity": "medium",
|
|
1126
|
+
"message": (
|
|
1127
|
+
f"'{var}' from malloc/calloc/realloc is not NULL-checked "
|
|
1128
|
+
"within 5 lines — dereferencing NULL is UB (CWE-690)"
|
|
1129
|
+
),
|
|
1130
|
+
"location": f"line {i + 1}",
|
|
1131
|
+
})
|
|
1132
|
+
|
|
1133
|
+
# 5. free(p) without setting p to NULL on the next real line.
|
|
1134
|
+
# CWE-416 use-after-free guardrail.
|
|
1135
|
+
free_call = re.compile(r"^\s*free\s*\(\s*(\w+)\s*\)\s*;")
|
|
1136
|
+
for i, line in enumerate(lines):
|
|
1137
|
+
m = free_call.match(line)
|
|
1138
|
+
if not m:
|
|
1139
|
+
continue
|
|
1140
|
+
var = m.group(1)
|
|
1141
|
+
for j in range(i + 1, min(i + 3, len(lines))):
|
|
1142
|
+
nxt = lines[j].strip()
|
|
1143
|
+
if not nxt:
|
|
1144
|
+
continue
|
|
1145
|
+
if re.match(rf"^{re.escape(var)}\s*=\s*NULL\s*;", nxt):
|
|
1146
|
+
break
|
|
1147
|
+
smells.append({
|
|
1148
|
+
"type": "c_free_without_null",
|
|
1149
|
+
"severity": "low",
|
|
1150
|
+
"message": (
|
|
1151
|
+
f"'free({var})' not followed by '{var} = NULL;' — "
|
|
1152
|
+
"dangling pointer can be reused (CWE-416)"
|
|
1153
|
+
),
|
|
1154
|
+
"location": f"line {i + 1}",
|
|
1155
|
+
})
|
|
1156
|
+
break
|
|
1157
|
+
|
|
1158
|
+
# 6. system() with a non-string-literal argument — command injection.
|
|
1159
|
+
system_pattern = re.compile(r"\bsystem\s*\(\s*(?!\"|NULL\b)(\w+)\s*\)")
|
|
1160
|
+
for m in system_pattern.finditer(content):
|
|
1161
|
+
smells.append({
|
|
1162
|
+
"type": "c_system_non_literal",
|
|
1163
|
+
"severity": "high",
|
|
1164
|
+
"message": (
|
|
1165
|
+
f"'system({m.group(1)})' with a non-literal argument — "
|
|
1166
|
+
"command injection (CWE-78); use execve with validated args"
|
|
1167
|
+
),
|
|
1168
|
+
"location": f"offset {m.start()}",
|
|
1169
|
+
})
|
|
1170
|
+
|
|
1171
|
+
return smells
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def check_solid_violations(content: str) -> List[Dict]:
|
|
1175
|
+
"""Check for potential SOLID principle violations."""
|
|
1176
|
+
violations = []
|
|
1177
|
+
|
|
1178
|
+
# OCP: Type checking instead of polymorphism
|
|
1179
|
+
type_checks = len(re.findall(r"isinstance\(|type\(.*\)\s*==|typeof\s+\w+\s*===", content))
|
|
1180
|
+
if type_checks > 2:
|
|
1181
|
+
violations.append({
|
|
1182
|
+
"principle": "OCP",
|
|
1183
|
+
"name": "Open/Closed Principle",
|
|
1184
|
+
"severity": "medium",
|
|
1185
|
+
"message": f"Found {type_checks} type checks - consider using polymorphism"
|
|
1186
|
+
})
|
|
1187
|
+
|
|
1188
|
+
# LSP/ISP: NotImplementedError
|
|
1189
|
+
not_impl = len(re.findall(r"raise\s+NotImplementedError|not\s+implemented", content, re.IGNORECASE))
|
|
1190
|
+
if not_impl:
|
|
1191
|
+
violations.append({
|
|
1192
|
+
"principle": "LSP/ISP",
|
|
1193
|
+
"name": "Liskov/Interface Segregation",
|
|
1194
|
+
"severity": "low",
|
|
1195
|
+
"message": f"Found {not_impl} unimplemented methods - may indicate oversized interface"
|
|
1196
|
+
})
|
|
1197
|
+
|
|
1198
|
+
# DIP: Too many direct imports
|
|
1199
|
+
imports = len(re.findall(r"^(?:import|from)\s+", content, re.MULTILINE))
|
|
1200
|
+
if imports > THRESHOLDS["max_imports"]:
|
|
1201
|
+
violations.append({
|
|
1202
|
+
"principle": "DIP",
|
|
1203
|
+
"name": "Dependency Inversion Principle",
|
|
1204
|
+
"severity": "low",
|
|
1205
|
+
"message": f"File has {imports} imports - consider dependency injection"
|
|
1206
|
+
})
|
|
1207
|
+
|
|
1208
|
+
return violations
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def calculate_quality_score(
|
|
1212
|
+
line_metrics: Dict,
|
|
1213
|
+
functions: List[Dict],
|
|
1214
|
+
classes: List[Dict],
|
|
1215
|
+
smells: List[Dict],
|
|
1216
|
+
violations: List[Dict]
|
|
1217
|
+
) -> int:
|
|
1218
|
+
"""Calculate overall quality score (0-100)."""
|
|
1219
|
+
score = 100
|
|
1220
|
+
|
|
1221
|
+
# Deduct for code smells
|
|
1222
|
+
for smell in smells:
|
|
1223
|
+
if smell["severity"] == "high":
|
|
1224
|
+
score -= 10
|
|
1225
|
+
elif smell["severity"] == "medium":
|
|
1226
|
+
score -= 5
|
|
1227
|
+
elif smell["severity"] == "low":
|
|
1228
|
+
score -= 2
|
|
1229
|
+
|
|
1230
|
+
# Deduct for SOLID violations
|
|
1231
|
+
for violation in violations:
|
|
1232
|
+
if violation["severity"] == "high":
|
|
1233
|
+
score -= 8
|
|
1234
|
+
elif violation["severity"] == "medium":
|
|
1235
|
+
score -= 4
|
|
1236
|
+
elif violation["severity"] == "low":
|
|
1237
|
+
score -= 2
|
|
1238
|
+
|
|
1239
|
+
# Bonus for good comment ratio (10-30%)
|
|
1240
|
+
if line_metrics["total"] > 0:
|
|
1241
|
+
comment_ratio = line_metrics["comment"] / line_metrics["total"]
|
|
1242
|
+
if 0.1 <= comment_ratio <= 0.3:
|
|
1243
|
+
score += 5
|
|
1244
|
+
|
|
1245
|
+
# Bonus for reasonable function sizes
|
|
1246
|
+
if functions:
|
|
1247
|
+
avg_lines = sum(f["lines"] for f in functions) / len(functions)
|
|
1248
|
+
if avg_lines < 30:
|
|
1249
|
+
score += 5
|
|
1250
|
+
|
|
1251
|
+
return max(0, min(100, score))
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def get_grade(score: int) -> str:
|
|
1255
|
+
"""Convert score to letter grade."""
|
|
1256
|
+
if score >= 90:
|
|
1257
|
+
return "A"
|
|
1258
|
+
elif score >= 80:
|
|
1259
|
+
return "B"
|
|
1260
|
+
elif score >= 70:
|
|
1261
|
+
return "C"
|
|
1262
|
+
elif score >= 60:
|
|
1263
|
+
return "D"
|
|
1264
|
+
else:
|
|
1265
|
+
return "F"
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def display_path(path: Path) -> str:
|
|
1269
|
+
"""The form a path takes in emitted output: relative to the working directory.
|
|
1270
|
+
|
|
1271
|
+
Input is resolved to an absolute path so filesystem checks are unambiguous, but an
|
|
1272
|
+
absolute path in the *output* is machine-specific. That has two costs: committed
|
|
1273
|
+
fixtures under expected_outputs/ can only ever match the machine that produced them,
|
|
1274
|
+
and findings here cannot be matched against pr_analyzer.py's git-relative paths when
|
|
1275
|
+
review_report_generator.py merges both into one report.
|
|
1276
|
+
|
|
1277
|
+
os.path.relpath rather than Path.relative_to: the latter raises ValueError for a
|
|
1278
|
+
target outside the working directory, which is a supported input here.
|
|
1279
|
+
"""
|
|
1280
|
+
return os.path.relpath(str(path), os.getcwd())
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def check_shell_specific_smells(content: str) -> List[Dict]:
|
|
1284
|
+
"""Shell has real control flow, so it is analysed as code — but its failure modes are its own."""
|
|
1285
|
+
smells = []
|
|
1286
|
+
lines = content.split("\n")
|
|
1287
|
+
|
|
1288
|
+
if lines and lines[0].startswith("#!") and "set -" not in content:
|
|
1289
|
+
smells.append({
|
|
1290
|
+
"type": "no_error_handling",
|
|
1291
|
+
"severity": "high",
|
|
1292
|
+
"message": "Script has no 'set -e'/'set -u'/'set -o pipefail' — a failing command is "
|
|
1293
|
+
"silently ignored and the script continues with bad state",
|
|
1294
|
+
"location": "line 1",
|
|
1295
|
+
})
|
|
1296
|
+
|
|
1297
|
+
for i, line in enumerate(lines, 1):
|
|
1298
|
+
stripped = line.strip()
|
|
1299
|
+
if stripped.startswith("#"):
|
|
1300
|
+
continue
|
|
1301
|
+
# Unquoted expansion of a variable that may hold spaces or globs.
|
|
1302
|
+
if re.search(r"(?<![\"'\w])\$\{?[A-Za-z_][A-Za-z0-9_]*\}?(?![\"'\w])", stripped) and re.search(
|
|
1303
|
+
r"^\s*(?:rm|cp|mv|cat|source|\.|cd|mkdir|test|\[)\s", stripped
|
|
1304
|
+
):
|
|
1305
|
+
if '"' not in stripped:
|
|
1306
|
+
smells.append({
|
|
1307
|
+
"type": "unquoted_expansion",
|
|
1308
|
+
"severity": "high",
|
|
1309
|
+
"message": "Unquoted variable expansion in a command that acts on paths — a "
|
|
1310
|
+
"value containing spaces or globs will not do what it looks like",
|
|
1311
|
+
"location": f"line {i}",
|
|
1312
|
+
})
|
|
1313
|
+
if re.search(r"\bcd\s+[^&|;]+$", stripped) and "||" not in stripped and "set -e" not in content:
|
|
1314
|
+
smells.append({
|
|
1315
|
+
"type": "unchecked_cd",
|
|
1316
|
+
"severity": "medium",
|
|
1317
|
+
"message": "'cd' without '|| exit' or 'set -e' — the rest of the script runs in the "
|
|
1318
|
+
"wrong directory if it fails",
|
|
1319
|
+
"location": f"line {i}",
|
|
1320
|
+
})
|
|
1321
|
+
|
|
1322
|
+
return smells
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
def analyze_declarative(content: str, language: str) -> Dict:
|
|
1326
|
+
"""
|
|
1327
|
+
Structural analysis for YAML and JSON.
|
|
1328
|
+
|
|
1329
|
+
Reports parse validity, nesting depth, duplicate keys and plain-text secrets. Deliberately
|
|
1330
|
+
reports no cyclomatic complexity, no SOLID verdict and no function count: those readings do not
|
|
1331
|
+
exist for declarative data, and inventing them is exactly the false-confidence defect this
|
|
1332
|
+
coverage work removes.
|
|
1333
|
+
"""
|
|
1334
|
+
smells = []
|
|
1335
|
+
lines = content.split("\n")
|
|
1336
|
+
|
|
1337
|
+
parse_error = None
|
|
1338
|
+
if language == "json":
|
|
1339
|
+
try:
|
|
1340
|
+
json.loads(content)
|
|
1341
|
+
except ValueError as exc:
|
|
1342
|
+
parse_error = str(exc)
|
|
1343
|
+
else:
|
|
1344
|
+
seen_at_indent = {}
|
|
1345
|
+
for i, line in enumerate(lines, 1):
|
|
1346
|
+
match = re.match(r"^(\s*)([A-Za-z_][\w.-]*):", line)
|
|
1347
|
+
if not match:
|
|
1348
|
+
continue
|
|
1349
|
+
indent, key = len(match.group(1)), match.group(2)
|
|
1350
|
+
bucket = seen_at_indent.setdefault(indent, {})
|
|
1351
|
+
if key in bucket:
|
|
1352
|
+
smells.append({
|
|
1353
|
+
"type": "duplicate_key",
|
|
1354
|
+
"severity": "high",
|
|
1355
|
+
"message": f"Key '{key}' is defined twice at the same level — the later value "
|
|
1356
|
+
f"silently wins (first seen at line {bucket[key]})",
|
|
1357
|
+
"location": f"line {i}",
|
|
1358
|
+
})
|
|
1359
|
+
else:
|
|
1360
|
+
bucket[key] = i
|
|
1361
|
+
|
|
1362
|
+
if parse_error:
|
|
1363
|
+
smells.append({
|
|
1364
|
+
"type": "parse_error",
|
|
1365
|
+
"severity": "high",
|
|
1366
|
+
"message": f"File does not parse as {language}: {parse_error}",
|
|
1367
|
+
"location": "line 1",
|
|
1368
|
+
})
|
|
1369
|
+
|
|
1370
|
+
max_depth = 0
|
|
1371
|
+
for line in lines:
|
|
1372
|
+
if not line.strip() or line.strip().startswith("#"):
|
|
1373
|
+
continue
|
|
1374
|
+
indent = len(line) - len(line.lstrip())
|
|
1375
|
+
max_depth = max(max_depth, indent // 2)
|
|
1376
|
+
if max_depth > THRESHOLDS["max_declarative_depth"]:
|
|
1377
|
+
smells.append({
|
|
1378
|
+
"type": "deep_nesting",
|
|
1379
|
+
"severity": "medium",
|
|
1380
|
+
"message": f"Nesting reaches depth {max_depth} (threshold "
|
|
1381
|
+
f"{THRESHOLDS['max_declarative_depth']}) — deeply nested config is hard to "
|
|
1382
|
+
f"review and easy to mis-indent",
|
|
1383
|
+
"location": "file",
|
|
1384
|
+
})
|
|
1385
|
+
|
|
1386
|
+
for i, line in enumerate(lines, 1):
|
|
1387
|
+
if re.search(r"(?i)\b(password|secret|token|api[_-]?key|private[_-]?key)\b\s*[:=]\s*['\"]?[^\s'\"{}$]{8,}", line):
|
|
1388
|
+
smells.append({
|
|
1389
|
+
"type": "plaintext_secret",
|
|
1390
|
+
"severity": "high",
|
|
1391
|
+
"message": "A credential-shaped key carries a literal value — secrets belong in a "
|
|
1392
|
+
"secret store, not in a committed config file",
|
|
1393
|
+
"location": f"line {i}",
|
|
1394
|
+
})
|
|
1395
|
+
|
|
1396
|
+
return {"smells": smells, "max_depth": max_depth, "parses": parse_error is None}
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def analyze_file(filepath: Path) -> Dict:
|
|
1400
|
+
"""Analyze a single file for code quality."""
|
|
1401
|
+
language = detect_language(filepath)
|
|
1402
|
+
if not language:
|
|
1403
|
+
return {"error": f"Unsupported file type: {filepath.suffix}"}
|
|
1404
|
+
|
|
1405
|
+
content = read_file_content(filepath)
|
|
1406
|
+
if not content:
|
|
1407
|
+
return {"error": f"Could not read file: {filepath}"}
|
|
1408
|
+
|
|
1409
|
+
if language in DECLARATIVE_LANGUAGES:
|
|
1410
|
+
line_metrics = count_lines(content)
|
|
1411
|
+
declarative = analyze_declarative(content, language)
|
|
1412
|
+
smells = declarative["smells"]
|
|
1413
|
+
# Scored on the same 100-point scale so a mixed change compares, but from structural
|
|
1414
|
+
# findings only — there is no complexity or SOLID term to include here.
|
|
1415
|
+
score = max(0, 100 - sum(
|
|
1416
|
+
15 if sm["severity"] == "high" else 8 if sm["severity"] == "medium" else 3
|
|
1417
|
+
for sm in smells
|
|
1418
|
+
))
|
|
1419
|
+
return {
|
|
1420
|
+
"file": display_path(filepath),
|
|
1421
|
+
"language": language,
|
|
1422
|
+
"analysis_kind": "declarative",
|
|
1423
|
+
"metrics": {
|
|
1424
|
+
"lines": line_metrics,
|
|
1425
|
+
"max_depth": declarative["max_depth"],
|
|
1426
|
+
"parses": declarative["parses"],
|
|
1427
|
+
},
|
|
1428
|
+
"smells": smells,
|
|
1429
|
+
"solid_violations": [],
|
|
1430
|
+
"quality_score": score,
|
|
1431
|
+
"grade": get_grade(score),
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
line_metrics = count_lines(content)
|
|
1435
|
+
functions = find_functions(content, language)
|
|
1436
|
+
classes = find_classes(content, language)
|
|
1437
|
+
smells = check_code_smells(content, functions, classes)
|
|
1438
|
+
if language == "csharp":
|
|
1439
|
+
smells.extend(check_csharp_specific_smells(content))
|
|
1440
|
+
if language == "java":
|
|
1441
|
+
smells.extend(check_java_specific_smells(content))
|
|
1442
|
+
if language == "c":
|
|
1443
|
+
smells.extend(check_c_specific_smells(content))
|
|
1444
|
+
if language == "shell":
|
|
1445
|
+
smells.extend(check_shell_specific_smells(content))
|
|
1446
|
+
violations = check_solid_violations(content)
|
|
1447
|
+
score = calculate_quality_score(line_metrics, functions, classes, smells, violations)
|
|
1448
|
+
|
|
1449
|
+
return {
|
|
1450
|
+
"file": display_path(filepath),
|
|
1451
|
+
"language": language,
|
|
1452
|
+
"metrics": {
|
|
1453
|
+
"lines": line_metrics,
|
|
1454
|
+
"functions": len(functions),
|
|
1455
|
+
"classes": len(classes),
|
|
1456
|
+
"avg_complexity": round(sum(f["complexity"] for f in functions) / max(1, len(functions)), 1)
|
|
1457
|
+
},
|
|
1458
|
+
"quality_score": score,
|
|
1459
|
+
"grade": get_grade(score),
|
|
1460
|
+
"smells": smells,
|
|
1461
|
+
"solid_violations": violations,
|
|
1462
|
+
"function_details": functions[:10],
|
|
1463
|
+
"class_details": classes[:10]
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def analyze_directory(
|
|
1468
|
+
dir_path: Path,
|
|
1469
|
+
recursive: bool = True,
|
|
1470
|
+
language: Optional[str] = None
|
|
1471
|
+
) -> Dict:
|
|
1472
|
+
"""
|
|
1473
|
+
Analyze all files in a directory, reporting what was NOT analysed as prominently as what was.
|
|
1474
|
+
|
|
1475
|
+
This function used to enumerate only the extensions it recognised and then drop any per-file
|
|
1476
|
+
error before averaging (`if "error" not in result`). A change of nineteen OpenAPI files and two
|
|
1477
|
+
TypeScript files therefore reported "Files Analyzed: 2" with a score computed from the two, and
|
|
1478
|
+
the nineteen appeared nowhere. The skill's own Review Contract already forbids that — "a file
|
|
1479
|
+
the derivation includes but the review never opened is reported as not reviewed; it is never
|
|
1480
|
+
dropped from the list" — so this is the deterministic tool being made to obey a policy the
|
|
1481
|
+
bundle already publishes, not a new policy.
|
|
1482
|
+
|
|
1483
|
+
Every file the walk sees ends up in exactly one of `files` or `skipped`. `coverage` is
|
|
1484
|
+
"complete" only when nothing was skipped.
|
|
1485
|
+
"""
|
|
1486
|
+
results = []
|
|
1487
|
+
skipped = []
|
|
1488
|
+
|
|
1489
|
+
pattern = "**/*" if recursive else "*"
|
|
1490
|
+
selected_extensions = set(LANGUAGE_EXTENSIONS.get(language, [])) if language else None
|
|
1491
|
+
|
|
1492
|
+
for filepath in sorted(dir_path.glob(pattern)):
|
|
1493
|
+
if not filepath.is_file():
|
|
1494
|
+
continue
|
|
1495
|
+
parts = filepath.parts
|
|
1496
|
+
if "node_modules" in parts or ".git" in parts:
|
|
1497
|
+
continue
|
|
1498
|
+
|
|
1499
|
+
detected = detect_language(filepath)
|
|
1500
|
+
if selected_extensions is not None and get_file_extension(filepath) not in selected_extensions:
|
|
1501
|
+
# An explicit --language narrowed the scope. Still reported: the caller chose it, but a
|
|
1502
|
+
# reader of the report did not, and a narrowed run that looks like a full one is the
|
|
1503
|
+
# same false confidence in a different costume.
|
|
1504
|
+
skipped.append({"file": display_path(filepath), "reason": f"excluded by --language {language}"})
|
|
1505
|
+
continue
|
|
1506
|
+
if not detected:
|
|
1507
|
+
skipped.append({
|
|
1508
|
+
"file": display_path(filepath),
|
|
1509
|
+
"reason": f"no analyser for '{filepath.suffix or filepath.name}'",
|
|
1510
|
+
})
|
|
1511
|
+
continue
|
|
1512
|
+
|
|
1513
|
+
result = analyze_file(filepath)
|
|
1514
|
+
if "error" in result:
|
|
1515
|
+
skipped.append({"file": display_path(filepath), "reason": result["error"]})
|
|
1516
|
+
continue
|
|
1517
|
+
results.append(result)
|
|
1518
|
+
|
|
1519
|
+
skipped_by_reason: Dict[str, int] = {}
|
|
1520
|
+
for item in skipped:
|
|
1521
|
+
skipped_by_reason[item["reason"]] = skipped_by_reason.get(item["reason"], 0) + 1
|
|
1522
|
+
|
|
1523
|
+
base = {
|
|
1524
|
+
"directory": display_path(dir_path),
|
|
1525
|
+
"files_analyzed": len(results),
|
|
1526
|
+
"files_skipped": len(skipped),
|
|
1527
|
+
"coverage": "complete" if not skipped else "partial",
|
|
1528
|
+
"skipped": skipped,
|
|
1529
|
+
"skipped_by_reason": skipped_by_reason,
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
if not results:
|
|
1533
|
+
# Previously an error string, which discarded the skipped list with it — the caller was told
|
|
1534
|
+
# "No supported files found" and could not see WHICH files those were.
|
|
1535
|
+
return {
|
|
1536
|
+
**base,
|
|
1537
|
+
"average_score": None,
|
|
1538
|
+
"overall_grade": None,
|
|
1539
|
+
"total_code_smells": 0,
|
|
1540
|
+
"total_solid_violations": 0,
|
|
1541
|
+
"files": [],
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
total_score = sum(r["quality_score"] for r in results)
|
|
1545
|
+
avg_score = total_score / len(results)
|
|
1546
|
+
|
|
1547
|
+
return {
|
|
1548
|
+
**base,
|
|
1549
|
+
"average_score": round(avg_score, 1),
|
|
1550
|
+
"overall_grade": get_grade(int(avg_score)),
|
|
1551
|
+
"total_code_smells": sum(len(r["smells"]) for r in results),
|
|
1552
|
+
"total_solid_violations": sum(len(r["solid_violations"]) for r in results),
|
|
1553
|
+
"files": sorted(results, key=lambda x: x["quality_score"]),
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
def print_report(analysis: Dict) -> None:
|
|
1558
|
+
"""Print human-readable analysis report."""
|
|
1559
|
+
if "error" in analysis:
|
|
1560
|
+
print(f"Error: {analysis['error']}")
|
|
1561
|
+
return
|
|
1562
|
+
|
|
1563
|
+
print("=" * 60)
|
|
1564
|
+
print("CODE QUALITY REPORT")
|
|
1565
|
+
print("=" * 60)
|
|
1566
|
+
|
|
1567
|
+
if "file" in analysis:
|
|
1568
|
+
print(f"\nFile: {analysis['file']}")
|
|
1569
|
+
print(f"Language: {analysis['language']}")
|
|
1570
|
+
print(f"Quality Score: {analysis['quality_score']}/100 ({analysis['grade']})")
|
|
1571
|
+
|
|
1572
|
+
metrics = analysis["metrics"]
|
|
1573
|
+
print(f"\nLines: {metrics['lines']['total']} ({metrics['lines']['code']} code, {metrics['lines']['comment']} comments)")
|
|
1574
|
+
if analysis.get("analysis_kind") == "declarative":
|
|
1575
|
+
# No function, class or complexity line here on purpose: those readings do not exist for
|
|
1576
|
+
# declarative data, and printing a zero would read as "analysed and found none".
|
|
1577
|
+
print(f"Max nesting depth: {metrics['max_depth']}")
|
|
1578
|
+
print(f"Parses: {'yes' if metrics['parses'] else 'no'}")
|
|
1579
|
+
print("Analysed as declarative structure — complexity and SOLID do not apply.")
|
|
1580
|
+
else:
|
|
1581
|
+
print(f"Functions: {metrics['functions']}")
|
|
1582
|
+
print(f"Classes: {metrics['classes']}")
|
|
1583
|
+
print(f"Avg Complexity: {metrics['avg_complexity']}")
|
|
1584
|
+
|
|
1585
|
+
if analysis["smells"]:
|
|
1586
|
+
print("\n--- CODE SMELLS ---")
|
|
1587
|
+
for smell in analysis["smells"][:10]:
|
|
1588
|
+
print(f" [{smell['severity'].upper()}] {smell['message']} ({smell['location']})")
|
|
1589
|
+
|
|
1590
|
+
if analysis["solid_violations"]:
|
|
1591
|
+
print("\n--- SOLID VIOLATIONS ---")
|
|
1592
|
+
for v in analysis["solid_violations"]:
|
|
1593
|
+
print(f" [{v['principle']}] {v['message']}")
|
|
1594
|
+
else:
|
|
1595
|
+
print(f"\nDirectory: {analysis['directory']}")
|
|
1596
|
+
print(f"Files Analyzed: {analysis['files_analyzed']}")
|
|
1597
|
+
print(f"Files Skipped: {analysis['files_skipped']}")
|
|
1598
|
+
if analysis["average_score"] is None:
|
|
1599
|
+
print("Average Score: n/a — nothing in this directory was analysed")
|
|
1600
|
+
else:
|
|
1601
|
+
print(f"Average Score: {analysis['average_score']}/100 ({analysis['overall_grade']})"
|
|
1602
|
+
f" — over the {analysis['files_analyzed']} analysed file(s) only")
|
|
1603
|
+
print(f"Total Code Smells: {analysis['total_code_smells']}")
|
|
1604
|
+
print(f"Total SOLID Violations: {analysis['total_solid_violations']}")
|
|
1605
|
+
|
|
1606
|
+
# The coverage line comes before the findings, not after: a reader who stops at the score
|
|
1607
|
+
# must already have been told what the score does not cover.
|
|
1608
|
+
if analysis["coverage"] == "partial":
|
|
1609
|
+
print(f"\n*** COVERAGE: PARTIAL — {analysis['files_skipped']} file(s) were not analysed. ***")
|
|
1610
|
+
print("*** This verdict describes the analysed files only, not the whole change. ***")
|
|
1611
|
+
else:
|
|
1612
|
+
print("\nCoverage: complete — every file in scope was analysed.")
|
|
1613
|
+
|
|
1614
|
+
if analysis["files"]:
|
|
1615
|
+
print("\n--- FILES BY QUALITY ---")
|
|
1616
|
+
for f in analysis["files"][:10]:
|
|
1617
|
+
print(f" {f['quality_score']:3d}/100 [{f['grade']}] {f['file']}")
|
|
1618
|
+
|
|
1619
|
+
if analysis["skipped"]:
|
|
1620
|
+
print("\n--- SKIPPED (not analysed) ---")
|
|
1621
|
+
for reason, count in sorted(analysis["skipped_by_reason"].items(), key=lambda kv: -kv[1]):
|
|
1622
|
+
print(f" {count:3d} x {reason}")
|
|
1623
|
+
for item in analysis["skipped"][:20]:
|
|
1624
|
+
print(f" {item['file']} — {item['reason']}")
|
|
1625
|
+
if len(analysis["skipped"]) > 20:
|
|
1626
|
+
print(f" ... and {len(analysis['skipped']) - 20} more (use --json for the full list)")
|
|
1627
|
+
|
|
1628
|
+
print("\n" + "=" * 60)
|
|
1629
|
+
|
|
1630
|
+
|
|
1631
|
+
def main():
|
|
1632
|
+
parser = argparse.ArgumentParser(
|
|
1633
|
+
description="Analyze code quality, smells, and SOLID violations"
|
|
1634
|
+
)
|
|
1635
|
+
parser.add_argument(
|
|
1636
|
+
"path",
|
|
1637
|
+
help="File or directory to analyze"
|
|
1638
|
+
)
|
|
1639
|
+
parser.add_argument(
|
|
1640
|
+
"--recursive", "-r",
|
|
1641
|
+
action="store_true",
|
|
1642
|
+
default=True,
|
|
1643
|
+
help="Recursively analyze directories (default: true)"
|
|
1644
|
+
)
|
|
1645
|
+
parser.add_argument(
|
|
1646
|
+
"--language", "-l",
|
|
1647
|
+
choices=list(LANGUAGE_EXTENSIONS.keys()),
|
|
1648
|
+
help="Filter by programming language"
|
|
1649
|
+
)
|
|
1650
|
+
parser.add_argument(
|
|
1651
|
+
"--json",
|
|
1652
|
+
action="store_true",
|
|
1653
|
+
help="Output in JSON format"
|
|
1654
|
+
)
|
|
1655
|
+
parser.add_argument(
|
|
1656
|
+
"--output", "-o",
|
|
1657
|
+
help="Write output to file"
|
|
1658
|
+
)
|
|
1659
|
+
|
|
1660
|
+
args = parser.parse_args()
|
|
1661
|
+
|
|
1662
|
+
target = Path(args.path).resolve()
|
|
1663
|
+
|
|
1664
|
+
if not target.exists():
|
|
1665
|
+
print(f"Error: Path does not exist: {target}", file=sys.stderr)
|
|
1666
|
+
sys.exit(1)
|
|
1667
|
+
|
|
1668
|
+
# Resolve the policy before any analysis runs, and write the merged thresholds back into
|
|
1669
|
+
# THRESHOLDS so every check reads the resolved values where it already read the dict (IC-004).
|
|
1670
|
+
policy, policy_tiers = load_policy(target)
|
|
1671
|
+
THRESHOLDS.update(policy["thresholds"])
|
|
1672
|
+
|
|
1673
|
+
if target.is_file():
|
|
1674
|
+
analysis = analyze_file(target)
|
|
1675
|
+
else:
|
|
1676
|
+
analysis = analyze_directory(target, args.recursive, args.language)
|
|
1677
|
+
|
|
1678
|
+
# IC-005: every report states the policy it applied, so a surprising finding is traceable to the
|
|
1679
|
+
# tier that produced it rather than guessed at.
|
|
1680
|
+
analysis["policy"] = {"tiers": policy_tiers, "thresholds": dict(policy["thresholds"])}
|
|
1681
|
+
|
|
1682
|
+
if args.json:
|
|
1683
|
+
output = json.dumps(analysis, indent=2, default=str)
|
|
1684
|
+
if args.output:
|
|
1685
|
+
with open(args.output, "w") as f:
|
|
1686
|
+
f.write(output)
|
|
1687
|
+
print(f"Results written to {args.output}")
|
|
1688
|
+
else:
|
|
1689
|
+
print(output)
|
|
1690
|
+
else:
|
|
1691
|
+
print(f"Policy tiers applied: {' -> '.join(policy_tiers)}")
|
|
1692
|
+
print_report(analysis)
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
if __name__ == "__main__":
|
|
1696
|
+
main()
|