Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion SELECTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,12 @@ Given a prepared lint state, tool selection proceeds as follows:
### Tool catalogue snapshot

* **External** (catalog-sourced): black, isort, prettier, ruff-format, shfmt,
shellcheck,
actionlint, bandit, cargo-clippy, cargo-fmt, checkmake, cpplint,
dockerfilelint, dotenv-linter, eslint, gofmt, golangci-lint, gts, hadolint,
luacheck, lualint, mdformat, perlcritic, perltidy, phplint, pylint,
pyupgrade, remark-lint, ruff, selene, speccy, sqlfluff, stylelint, tombi,
tsc, yamllint, kube-linter, mypy, pyright (38 tools in total).
tsc, yamllint, kube-linter, mypy, pyright (39 tools in total).
* **Internal** (phase‑8/10, repo-agnostic): docstrings, suppressions, types,
closures, signatures, cache, value-types, license-header, copyright,
python-hygiene, file-size.
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ name = "pyqa_lint"
version = "0.3.0"
description = "Polyglot lint orchestration toolkit"
readme = "README.md"
license = { file = "LICENSE" }
license = { text = "MIT License" }
classifiers = [
"License :: OSI Approved :: MIT License",
]
requires-python = ">=3.13"
dependencies = [
"autopep8>=2.3.2",
Expand Down
26 changes: 26 additions & 0 deletions ref_docs/tool-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,32 @@
"type": "bool"
}
},
"shellcheck": {
"args": {
"description": "Additional arguments appended to shellcheck.",
"type": "list[str]"
},
"exclude": {
"description": "Rule identifiers (SC####) to ignore.",
"type": "list[str]"
},
"external-sources": {
"description": "Allow following source statements outside the current directory.",
"type": "bool"
},
"severity": {
"description": "Minimum severity to report (error, warning, info, style).",
"type": "str"
},
"shell": {
"description": "Explicit shell dialect (bash, sh, dash, ksh, zsh, mksh).",
"type": "str"
},
"source-path": {
"description": "Additional lookup path for sourced files.",
"type": "path"
}
},
"speccy": {
"args": {
"description": "Additional arguments appended to speccy.",
Expand Down
2 changes: 1 addition & 1 deletion src/pyqa/cli/commands/lint/cli_models/overrides.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

def _override_formatting_dependency(
line_length: Annotated[int, typer.Option(120, "--line-length", help=LINE_LENGTH_HELP)],
sql_dialect: Annotated[str, typer.Option("postgresql", "--sql-dialect", help=SQL_DIALECT_HELP)],
sql_dialect: Annotated[str, typer.Option("postgres", "--sql-dialect", help=SQL_DIALECT_HELP)],
python_version: Annotated[str | None, typer.Option(None, "--python-version", help=PYTHON_VERSION_HELP)],
) -> OverrideFormattingParams:
"""Return formatting overrides shared across compatible tools.
Expand Down
2 changes: 1 addition & 1 deletion src/pyqa/config/models/sections/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class ExecutionConfig(BaseModel):
bail: bool = False
use_local_linters: bool = False
line_length: int = 120
sql_dialect: str = "postgresql"
sql_dialect: str = "postgres"
python_version: str | None = None


Expand Down
2 changes: 2 additions & 0 deletions src/pyqa/parsers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
parse_golangci_lint,
parse_perlcritic,
parse_phplint,
parse_shellcheck,
parse_shfmt,
parse_tombi,
)
Expand Down Expand Up @@ -55,6 +56,7 @@
"parse_remark",
"parse_ruff",
"parse_selene",
"parse_shellcheck",
"parse_shfmt",
"parse_speccy",
"parse_sqlfluff",
Expand Down
72 changes: 72 additions & 0 deletions src/pyqa/parsers/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@
"warning": Severity.WARNING,
"info": Severity.NOTICE,
}
SHELLCHECK_SEVERITY_MAP: Final[dict[str, Severity]] = {
"error": Severity.ERROR,
"warning": Severity.WARNING,
"info": Severity.NOTICE,
"style": Severity.NOTE,
}


def parse_shfmt(stdout: Sequence[str], context: ToolContext) -> Sequence[RawDiagnostic]:
Expand Down Expand Up @@ -283,6 +289,71 @@ def parse_cpplint(stdout: Sequence[str], context: ToolContext) -> Sequence[RawDi
return results


def parse_shellcheck(payload: JsonValue, context: ToolContext) -> Sequence[RawDiagnostic]:
"""Parse shellcheck JSON payload into canonical diagnostics.

Args:
payload: Parsed JSON payload emitted by shellcheck.
context: Tool execution context supplied by the orchestrator.

Returns:
Sequence[RawDiagnostic]: Diagnostics describing shellcheck findings.
"""

del context
diagnostics: list[RawDiagnostic] = []
for entry in _iter_shellcheck_entries(payload):
message = coerce_optional_str(entry.get("message"))
if not message:
continue
severity = map_severity(
entry.get("level"),
SHELLCHECK_SEVERITY_MAP,
Severity.WARNING,
)
location = DiagnosticLocation(
file=coerce_optional_str(entry.get("file")),
line=coerce_optional_int(entry.get("line")),
column=coerce_optional_int(entry.get("column")),
)
details = DiagnosticDetails(
severity=severity,
message=message.strip(),
tool="shellcheck",
code=_format_shellcheck_code(entry.get("code")),
)
diagnostics.append(create_spec(location=location, details=details).build())
return diagnostics


def _iter_shellcheck_entries(payload: JsonValue) -> tuple[Mapping[str, JsonValue], ...]:
"""Return shellcheck diagnostic entries regardless of payload shape."""

if isinstance(payload, Mapping):
comments = payload.get("comments")
if comments is not None:
return tuple(mapping_sequence(comments))
return tuple(mapping_sequence(payload))
return tuple(mapping_sequence(payload))
Comment on lines +336 to +337

Copilot AI Nov 15, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function has redundant logic. When payload is not a Mapping, or when it is a Mapping without 'comments', both branches call tuple(mapping_sequence(payload)). Simplify by removing the duplicate conversion: when comments is None, return mapping_sequence(payload) directly instead of wrapping it again with tuple(), since mapping_sequence already returns a tuple.

Suggested change
return tuple(mapping_sequence(payload))
return tuple(mapping_sequence(payload))
return mapping_sequence(payload)
return mapping_sequence(payload)

Copilot uses AI. Check for mistakes.


def _format_shellcheck_code(value: JsonValue | None) -> str | None:
"""Return shellcheck rule identifiers prefixed with ``SC``."""

if value is None:
return None
if isinstance(value, int):
return f"SC{value}"
text = str(value).strip()
if not text:
return None
try:
numeric = int(text)
except ValueError:
return text
return f"SC{numeric}"


_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
_TOMBI_HEADER_RE = re.compile(
r"^(?P<level>Error|Warning|Info|Hint|Note):\s*(?P<message>.+)$",
Expand Down Expand Up @@ -520,6 +591,7 @@ def parse_cargo_clippy(payload: JsonValue, context: ToolContext) -> Sequence[Raw
"parse_golangci_lint",
"parse_perlcritic",
"parse_phplint",
"parse_shellcheck",
"parse_shfmt",
"parse_tombi",
]
2 changes: 1 addition & 1 deletion tests/test_cli_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def test_config_show_outputs_json(tmp_path: Path, monkeypatch) -> None:
payload = json.loads(stdout)
assert payload["execution"]["jobs"] == 3
assert payload["execution"]["line_length"] == 120
assert payload["execution"]["sql_dialect"] == "postgresql"
assert payload["execution"]["sql_dialect"] == "postgres"
assert payload["tool_settings"]["black"]["line-length"] == 88


Expand Down
2 changes: 1 addition & 1 deletion tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def _build_options(
use_local_linters: bool = False,
strict_config: bool = False,
line_length: int = 120,
sql_dialect: str = "postgresql",
sql_dialect: str = "postgres",
python_version: str | None = None,
max_complexity: int | None = None,
max_arguments: int | None = None,
Expand Down
2 changes: 1 addition & 1 deletion tests/test_config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def test_load_config_defaults(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -
assert cfg.file_discovery.roots == [project_root.resolve()]
assert cfg.execution.cache_dir == (project_root / ".lint-cache").resolve()
assert cfg.execution.line_length == 120
assert cfg.execution.sql_dialect == "postgresql"
assert cfg.execution.sql_dialect == "postgres"
assert cfg.output.pr_summary_out is None


Expand Down
18 changes: 18 additions & 0 deletions tests/test_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
parse_remark,
parse_ruff,
parse_selene,
parse_shellcheck,
parse_shfmt,
parse_speccy,
parse_sqlfluff,
Expand Down Expand Up @@ -113,6 +114,23 @@ def test_parse_mypy_single_object() -> None:
assert diag.severity.value == "error"


def test_parse_shellcheck() -> None:
parser = JsonParser(parse_shellcheck)
stdout = """
[
{"file": "scripts/run.sh", "line": 5, "column": 2, "level": "info", "code": 2086, "message": "Double quote to prevent globbing."}
]
"""
diags = parser.parse(stdout, "", context=_ctx())
assert len(diags) == 1
diag = diags[0]
assert diag.file == "scripts/run.sh"
assert diag.line == 5
assert diag.column == 2
assert diag.code == "SC2086"
assert diag.severity.value == "notice"


def test_parse_actionlint() -> None:
parser = JsonParser(parse_actionlint)
stdout = """
Expand Down
53 changes: 53 additions & 0 deletions tests/test_shellcheck_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Blackcat Informatics® Inc.
"""Tests for shellcheck catalog definition."""

from __future__ import annotations

from pathlib import Path

from pyqa.catalog import ToolCatalogLoader
from pyqa.catalog.strategies import command_option_map
from pyqa.config import Config
from pyqa.tools.base import ToolAction, ToolContext

_PYQA_ROOT = Path(__file__).resolve().parents[1]
_CATALOG_ROOT = _PYQA_ROOT / "tooling" / "catalog"


def _shellcheck_config() -> dict[str, object]:
loader = ToolCatalogLoader(catalog_root=_CATALOG_ROOT)
snapshot = loader.load_snapshot()
for definition in snapshot.tools:
if definition.name != "shellcheck":
continue
for action in definition.actions:
if action.name == "lint":
return dict(action.command.reference.config)
raise AssertionError("shellcheck lint action missing from catalog")


def test_shellcheck_command(tmp_path: Path) -> None:
ctx = ToolContext(
cfg=Config(),
root=tmp_path,
files=[tmp_path / "script.sh"],
settings={
"severity": "error",
"exclude": ["SC1000", "SC2000"],
"external-sources": True,
"shell": "bash",
},
)
builder = command_option_map(_shellcheck_config())
action = ToolAction(name="lint", command=builder)

command = action.build_command(ctx)
assert command[:3] == ["shellcheck", "--color=never", "--format=json"]
assert "--severity=error" in command
assert "--exclude" in command
exclude_idx = command.index("--exclude")
assert "SC1000" in command[exclude_idx + 1]
assert "--external-sources" in command
assert "--shell=bash" in command
assert str(ctx.files[0]) in command
1 change: 1 addition & 0 deletions tests/test_tool_catalog_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def test_initialize_registry_real_catalog(schema_root: Path, tmp_path: Path) ->
"ruff",
"ruff-format",
"selene",
"shellcheck",
"shfmt",
"speccy",
"sqlfluff",
Expand Down
49 changes: 6 additions & 43 deletions tooling/catalog/cache.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,10 @@
{
"checksum": "d713394391d096328f843b51a4c38407ec7bd895c61c7f61880290b6c1d83bc2",
"checksum": "7acec60806965d37533a3cbf568ce5474103ffd54d817059abab2530f0b4c967",
"files": [
"_shared/_go_runtime.json",
"_shared/_npm_runtime.json",
"_shared/_python_defaults.json",
"docs/SHARED_KNOBS.md",
"docs/_scratch/Dockerfile",
"docs/_scratch/sample.js",
"docs/_scratch/sample.lua",
"docs/_scratch/sample.php",
"docs/_scratch/sample.py",
"docs/_scratch/sample.sql",
"docs/_scratch/sample.ts",
"docs/_scratch/sample.txt",
"docs/_scratch/sample.yaml",
"docs/actionlint_cmd_help.txt",
"docs/actionlint_help.txt",
"docs/bandit_cmd_help.txt",
Expand Down Expand Up @@ -78,6 +69,8 @@
"docs/ruff_help.txt",
"docs/selene_cmd_help.txt",
"docs/selene_help.txt",
"docs/shellcheck_cmd_help.txt",
"docs/shellcheck_help.txt",
"docs/shfmt_cmd_help.txt",
"docs/shfmt_help.txt",
"docs/speccy_cmd_help.txt",
Expand Down Expand Up @@ -127,47 +120,17 @@
"languages/python/ruff.json",
"languages/rust/cargo-clippy.json",
"languages/rust/cargo-fmt.json",
"languages/shell/shellcheck.json",
"languages/shell/shfmt.json",
"languages/sql/sqlfluff.json",
"languages/toml/tombi.json",
"languages/yaml/yamllint.json",
"strategies/command_download_binary.json",
"strategies/command_black.json",
"strategies/command_cargo_clippy.json",
"strategies/command_cargo_fmt.json",
"strategies/command_checkmake.json",
"strategies/command_cpplint.json",
"strategies/command_dockerfilelint.json",
"strategies/command_dotenv_linter.json",
"strategies/command_eslint.json",
"strategies/command_gofmt.json",
"strategies/command_golangci_lint.json",
"strategies/command_gts.json",
"strategies/command_option_map.json",
"strategies/command_project_scanner.json",
"strategies/command_isort.json",
"strategies/command_kube_linter.json",
"strategies/command_luacheck.json",
"strategies/command_lualint.json",
"strategies/command_mdformat.json",
"strategies/command_mypy.json",
"strategies/command_perlcritic.json",
"strategies/command_perltidy.json",
"strategies/command_phplint.json",
"strategies/command_prettier.json",
"strategies/command_pylint.json",
"strategies/command_pyright.json",
"strategies/command_remark_lint.json",
"strategies/command_ruff.json",
"strategies/command_ruff_format.json",
"strategies/command_selene.json",
"strategies/command_speccy.json",
"strategies/command_sqlfluff.json",
"strategies/command_stylelint.json",
"strategies/command_tsc.json",
"strategies/command_yamllint.json",
"strategies/installer_download_artifact.json",
"strategies/json_parser.json",
"strategies/pyupgrade_command.json",
"strategies/parser_json_diagnostics.json",
"strategies/text_parser.json"
]
}
Loading
Loading