diff --git a/.codacy/codacy.yaml b/.codacy/codacy.yaml index ec91a308..b6a29d57 100644 --- a/.codacy/codacy.yaml +++ b/.codacy/codacy.yaml @@ -1,16 +1,14 @@ runtimes: - - dart@3.7.2 - go@1.22.3 - java@17.0.10 - node@22.2.0 - python@3.11.11 tools: - - codacy-enigma-cli@0.0.1-main.8.49310c3 - - dartanalyzer@3.7.2 - - eslint@8.57.0 + - eslint@9.26.0 - lizard@1.17.19 - pmd@6.55.0 - - pylint@3.3.6 + - pylint@3.3.7 - revive@1.7.0 - semgrep@1.78.0 - trivy@0.59.1 + - pyrefly@0.22.0 diff --git a/cmd/analyze.go b/cmd/analyze.go index 8af70786..4b99a6f3 100644 --- a/cmd/analyze.go +++ b/cmd/analyze.go @@ -359,6 +359,9 @@ func runToolByName(toolName string, workDirectory string, pathsToCheck []string, return tools.RunEnigma(workDirectory, tool.InstallDir, tool.Binaries["codacy-enigma-cli"], pathsToCheck, outputFile, outputFormat) case "revive": return reviveTool.RunRevive(workDirectory, tool.Binaries["revive"], pathsToCheck, outputFile, outputFormat) + case "pyrefly": + binaryPath := tool.Binaries["pyrefly"] + return tools.RunPyrefly(workDirectory, binaryPath, pathsToCheck, outputFile, outputFormat) } return fmt.Errorf("unsupported tool: %s", toolName) } diff --git a/plugins/tool-utils_test.go b/plugins/tool-utils_test.go index f459c06f..e6ba2fac 100644 --- a/plugins/tool-utils_test.go +++ b/plugins/tool-utils_test.go @@ -171,6 +171,7 @@ func TestGetSupportedTools(t *testing.T) { "lizard", "codacy-enigma-cli", "revive", + "pyrefly", }, expectedError: false, }, diff --git a/plugins/tools/pyrefly/plugin.yaml b/plugins/tools/pyrefly/plugin.yaml new file mode 100644 index 00000000..6ce4eb70 --- /dev/null +++ b/plugins/tools/pyrefly/plugin.yaml @@ -0,0 +1,15 @@ +name: pyrefly +# Pyrefly: Fast, modern Python type checker (https://pyrefly.org/en/docs/installation/) +description: Pyrefly is a fast, modern static type checker for Python, designed for developer productivity and CI integration. +default_version: 0.22.0 +runtime: python +runtime_binaries: + package_manager: python3 + execution: python3 +binaries: + - name: pyrefly + path: "venv/bin/pyrefly" +output_options: + file_flag: "--output" +analysis_options: + default_path: "." \ No newline at end of file diff --git a/plugins/tools/pyrefly/test/.codacy/codacy.yaml b/plugins/tools/pyrefly/test/.codacy/codacy.yaml new file mode 100644 index 00000000..5e7392c1 --- /dev/null +++ b/plugins/tools/pyrefly/test/.codacy/codacy.yaml @@ -0,0 +1,16 @@ +runtimes: + - dart@3.7.2 + - go@1.22.3 + - java@17.0.10 + - node@22.2.0 + - python@3.11.11 +tools: + - dartanalyzer@3.7.2 + - eslint@8.57.0 + - lizard@1.17.19 + - pmd@7.11.0 + - pylint@3.3.6 + - pyrefly@0.22.0 + - revive@1.7.0 + - semgrep@1.78.0 + - trivy@0.59.1 diff --git a/plugins/tools/pyrefly/test/actual.sarif b/plugins/tools/pyrefly/test/actual.sarif new file mode 100644 index 00000000..7c99af8b --- /dev/null +++ b/plugins/tools/pyrefly/test/actual.sarif @@ -0,0 +1,78 @@ +{ + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "runs": [ + { + "results": [ + { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src/test_file.py" + }, + "region": { + "startColumn": 24, + "startLine": 12 + } + } + } + ], + "message": { + "text": "Argument `Literal['one']` is not assignable to parameter `a` with type `int` in function `add_numbers`" + }, + "ruleId": "bad-argument-type" + }, + { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "test_file.py" + }, + "region": { + "startColumn": 25, + "startLine": 14 + } + } + } + ], + "message": { + "text": "Function declared to return `int` but is missing an explicit `return`" + }, + "ruleId": "bad-return" + }, + { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src/test_file.py" + }, + "region": { + "startColumn": 12, + "startLine": 20 + } + } + } + ], + "message": { + "text": "Returned type `Literal[123]` is not assignable to declared return type `str`" + }, + "ruleId": "bad-return" + } + ], + "tool": { + "driver": { + "informationUri": "https://pyrefly.org", + "name": "Pyrefly", + "rules": null, + "version": "0.22.0" + } + } + } + ], + "version": "2.1.0" +} \ No newline at end of file diff --git a/plugins/tools/pyrefly/test/expected.sarif b/plugins/tools/pyrefly/test/expected.sarif new file mode 100644 index 00000000..41dc18dd --- /dev/null +++ b/plugins/tools/pyrefly/test/expected.sarif @@ -0,0 +1,78 @@ +{ + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "results": [ + { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "test_file.py" + }, + "region": { + "startColumn": 24, + "startLine": 12 + } + } + } + ], + "message": { + "text": "Argument `Literal['one']` is not assignable to parameter `a` with type `int` in function `add_numbers`" + }, + "ruleId": "bad-argument-type" + }, + { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "test_file.py" + }, + "region": { + "startColumn": 25, + "startLine": 14 + } + } + } + ], + "message": { + "text": "Function declared to return `int` but is missing an explicit `return`" + }, + "ruleId": "bad-return" + }, + { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "test_file.py" + }, + "region": { + "startColumn": 12, + "startLine": 20 + } + } + } + ], + "message": { + "text": "Returned type `Literal[123]` is not assignable to declared return type `str`" + }, + "ruleId": "bad-return" + } + ], + "tool": { + "driver": { + "informationUri": "https://pyrefly.org", + "name": "Pyrefly", + "rules": null, + "version": "0.22.0" + } + } + } + ] +} \ No newline at end of file diff --git a/plugins/tools/pyrefly/test/src/test_file.py b/plugins/tools/pyrefly/test/src/test_file.py new file mode 100644 index 00000000..bfd484ce --- /dev/null +++ b/plugins/tools/pyrefly/test/src/test_file.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Test file for pyrefly analysis +""" + +def add_numbers(a: int, b: int) -> int: + return a + b + +def call_with_wrong_type(): + # This should trigger a type error in pyrefly + return add_numbers("one", 2) + +def missing_return() -> int: + # This function is missing a return statement + pass + +def wrong_return_type() -> str: + # This function returns an int instead of a str + return 123 + +if __name__ == "__main__": + call_with_wrong_type() + missing_return() + wrong_return_type() \ No newline at end of file diff --git a/tools/language_config.go b/tools/language_config.go index d1aa10ef..cc3bb22f 100644 --- a/tools/language_config.go +++ b/tools/language_config.go @@ -19,10 +19,6 @@ import ( "gopkg.in/yaml.v3" ) -// -// This file is responsible for building the languages-config.yaml file. -// - // buildToolLanguageInfoFromAPI builds tool language information from API data // This is the core shared logic used by both GetToolLanguageMappingFromAPI and buildToolLanguageConfigFromAPI func buildToolLanguageInfoFromAPI() (map[string]domain.ToolLanguageInfo, error) { @@ -95,6 +91,14 @@ func buildToolLanguageInfoFromAPI() (map[string]domain.ToolLanguageInfo, error) result[toolName] = configTool } + // Fallback: Add Pyrefly mapping if not present in API + if _, ok := result["pyrefly"]; !ok { + result["pyrefly"] = domain.ToolLanguageInfo{ + Name: "pyrefly", + Languages: []string{"Python"}, + Extensions: []string{".py"}, + } + } return result, nil } diff --git a/tools/pyreflyRunner.go b/tools/pyreflyRunner.go new file mode 100644 index 00000000..a94304ac --- /dev/null +++ b/tools/pyreflyRunner.go @@ -0,0 +1,78 @@ +package tools + +import ( + "codacy/cli-v2/utils" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" +) + +// RunPyrefly executes Pyrefly type checking on the specified directory or files +func RunPyrefly(workDirectory string, binary string, files []string, outputFile string, outputFormat string) error { + args := []string{"check"} + + // Always use JSON output for SARIF conversion + var tempFile string + if outputFormat == "sarif" { + tmp, err := ioutil.TempFile("", "pyrefly-*.json") + if err != nil { + return fmt.Errorf("failed to create temporary file: %w", err) + } + tempFile = tmp.Name() + tmp.Close() + defer os.Remove(tempFile) + args = append(args, "--output", tempFile, "--output-format", "json") + } else if outputFile != "" { + args = append(args, "--output", outputFile) + } + if outputFormat == "json" && outputFile == "" { + args = append(args, "--output-format", "json") + } + + // Detect config file (pyrefly.toml or pyproject.toml) + configFiles := []string{"pyrefly.toml", "pyproject.toml"} + for _, configFile := range configFiles { + if _, err := os.Stat(filepath.Join(workDirectory, configFile)); err == nil { + // Pyrefly auto-detects config, so no need to add a flag + break + } + } + + // Add files to check, or "." for current directory + if len(files) > 0 { + args = append(args, files...) + } else { + args = append(args, ".") + } + + cmd := exec.Command(binary, args...) + cmd.Dir = workDirectory + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err := cmd.Run() + if err != nil { + if _, ok := err.(*exec.ExitError); !ok { + return fmt.Errorf("failed to run Pyrefly: %w", err) + } + } + + if outputFormat == "sarif" { + jsonOutput, err := os.ReadFile(tempFile) + if err != nil { + return fmt.Errorf("failed to read Pyrefly output: %w", err) + } + sarifOutput := utils.ConvertPyreflyToSarif(jsonOutput) + if outputFile != "" { + err = os.WriteFile(outputFile, sarifOutput, 0644) + if err != nil { + return fmt.Errorf("failed to write SARIF output: %w", err) + } + } else { + fmt.Println(string(sarifOutput)) + } + } + return nil +} diff --git a/utils/sarif.go b/utils/sarif.go index 2f3f2073..12daad61 100644 --- a/utils/sarif.go +++ b/utils/sarif.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" ) // PylintIssue represents a single issue in Pylint's JSON output @@ -21,8 +22,8 @@ type PylintIssue struct { // SarifReport represents the SARIF report structure type SarifReport struct { - Version string `json:"version"` Schema string `json:"$schema"` + Version string `json:"version"` Runs []Run `json:"runs"` } @@ -276,3 +277,113 @@ func FilterRulesFromSarif(sarifData []byte) ([]byte, error) { return filteredData, nil } + +// PyreflyIssue represents a single issue in Pyrefly's JSON output. +// Example fields: line, column, stop_line, stop_column, path, code, name, description, concise_description +// See: https://pyrefly.org/en/docs/usage/#output-formats +type PyreflyIssue struct { + Line int `json:"line"` + Column int `json:"column"` + StopLine int `json:"stop_line"` + StopColumn int `json:"stop_column"` + Path string `json:"path"` + Code int `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + ConciseDescription string `json:"concise_description"` +} + +// ConvertPyreflyToSarif converts Pyrefly JSON output to SARIF format +func ConvertPyreflyToSarif(pyreflyOutput []byte) []byte { + // Pyrefly outputs: { "errors": [ ... ] } + type pyreflyRoot struct { + Errors []PyreflyIssue `json:"errors"` + } + var root pyreflyRoot + var sarifReport SarifReport + cwd, _ := os.Getwd() + if err := json.Unmarshal(pyreflyOutput, &root); err != nil { + // If parsing fails, return empty SARIF report with Pyrefly metadata + sarifReport = SarifReport{ + Schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + Version: "2.1.0", + Runs: []Run{ + { + Tool: Tool{ + Driver: Driver{ + Name: "Pyrefly", + Version: "0.22.0", + InformationURI: "https://pyrefly.org", + }, + }, + Results: []Result{}, + }, + }, + } + } else { + sarifReport = SarifReport{ + Schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + Version: "2.1.0", + Runs: []Run{ + { + Tool: Tool{ + Driver: Driver{ + Name: "Pyrefly", + Version: "0.22.0", + InformationURI: "https://pyrefly.org", + }, + }, + Results: make([]Result, 0, len(root.Errors)), + }, + }, + } + for _, issue := range root.Errors { + relPath := issue.Path + if rel, err := filepath.Rel(cwd, issue.Path); err == nil { + relPath = rel + } + result := Result{ + RuleID: issue.Name, + Level: "error", // Pyrefly only reports errors + Message: MessageText{ + Text: issue.Description, + }, + Locations: []Location{ + { + PhysicalLocation: PhysicalLocation{ + ArtifactLocation: ArtifactLocation{ + URI: relPath, + }, + Region: Region{ + StartLine: issue.Line, + StartColumn: issue.Column, + }, + }, + }, + }, + } + sarifReport.Runs[0].Results = append(sarifReport.Runs[0].Results, result) + } + } + sarifData, err := json.MarshalIndent(sarifReport, "", " ") + if err != nil { + // If marshaling fails, return a minimal SARIF report with Pyrefly metadata + return []byte(`{ + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Pyrefly", + "version": "0.22.0", + "informationUri": "https://pyrefly.org" + } + }, + "results": [] + } + ] +}`) + } + return sarifData +}