Skip to content
Merged
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
1 change: 1 addition & 0 deletions .jscpd.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
"format": ["python"],
"gitignore": true,
"ignore": ["**/__pycache__/**", "**/.venv/**", "**/*.egg-info/**"],
"ignorePattern": ["from [\\w.]+ import \\([^)]*\\)"],
"path": ["project_validator", "tests"]
}
38 changes: 19 additions & 19 deletions README.md

Large diffs are not rendered by default.

19 changes: 17 additions & 2 deletions project_validator/build_configurations.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Recognising the CMake build types, Cargo profiles and sanitizer settings a CI job builds with."""
"""Recognising the CMake build types, Cargo profiles, SwiftPM and Xcode configurations and sanitizer settings a CI
job builds with."""

import re
from collections.abc import Iterable
Expand All @@ -20,6 +21,11 @@
_CARGO_RELEASE = re.compile(r"--release\b|--profile[=\s]+release\b")
_CARGO_OTHER_PROFILE = re.compile(r"--profile\b")

# SwiftPM builds debug unless told otherwise; xcodebuild is judged only when it names its configuration.
_SWIFT_COMMANDS = re.compile(r"\bswift\s+(?:build|test|run)\b([^\\\n\"&;|]*)")
_SWIFT_CONFIGURATION = re.compile(r"(?:\s-c|--configuration)[=\s]+(debug|release)\b", re.IGNORECASE)
_XCODEBUILD_CONFIGURATION = re.compile(r"\bxcodebuild\b[^\\\n\"&;|]*\s-configuration\s+[\"']?(\w+)", re.IGNORECASE)

_DISABLED = r"(?!(?:off|false|no|none|0)\b)"
_SANITIZERS_ENABLED = re.compile(
r"[-/]fsanitize=\w"
Expand Down Expand Up @@ -50,10 +56,19 @@ def _cargo_build_types(texts: Iterable[str]) -> set[str]:
return {profile for profile in profiles if profile is not None}


def _swift_build_types(texts: Iterable[str]) -> set[str]:
configurations = (
_SWIFT_CONFIGURATION.search(match[1]) for text in texts for match in _SWIFT_COMMANDS.finditer(text)
)
swiftpm = {found[1].lower() if found else "debug" for found in configurations}
xcode = (match[1].lower() for text in texts for match in _XCODEBUILD_CONFIGURATION.finditer(text))
return swiftpm | {BUILD_TYPE_KINDS[value] for value in xcode if value in BUILD_TYPE_KINDS}


def detect_build_types(*texts: str) -> set[str]:
values = (value for text in texts for match in _BUILD_TYPE_SOURCES.finditer(text) for value in match.groups())
cmake = {BUILD_TYPE_KINDS[value.lower()] for value in values if value and value.lower() in BUILD_TYPE_KINDS}
return cmake | _cargo_build_types(texts)
return cmake | _cargo_build_types(texts) | _swift_build_types(texts)


def sanitizers_enabled(texts: Iterable[str]) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion project_validator/checks/code_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
)
def code_formatting(formatter: Formatter, context: Context) -> Result:
config = formatter.config_file(context)
tools = matching_names(context.ci_texts, formatter.runs)
tools = matching_names(formatter.texts(context), formatter.runs)
if tools and config is not None:
return Result.passed(f"{config} is defined and CI runs {', '.join(tools)}")
if tools and formatter.default_style:
Expand Down
18 changes: 2 additions & 16 deletions project_validator/checks/data_wellformed.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Every JSON, TOML, YAML and XML file parses: real validation, since a broken data file is a bug."""

import json
import re
import tomllib
from collections import Counter
from collections.abc import Callable, Mapping
Expand All @@ -11,6 +10,7 @@

from project_validator import yaml_support
from project_validator.core import Context, Result, check
from project_validator.text import strip_json_comments

# Hidden directories that hold configuration worth parsing (CI, editors, dev containers).
CONFIGURATION_DIRECTORIES = frozenset({".github", ".gitlab", ".circleci", ".buildkite", ".devcontainer", ".vscode"})
Expand All @@ -33,24 +33,10 @@ def _decoded(data: bytes) -> str:
raise _ParseError(f"not UTF-8 ({error.reason} at byte {error.start})") from error


# A string, a comment, or a comma that only whitespace separates from the closing bracket.
_JSONC_TOKENS = re.compile(r'"(?:\\.|[^"\\])*"|//[^\n]*|/\*.*?\*/|,(?=\s*[}\]])', re.DOTALL)


def _without_comments(text: str) -> str:
"""JSON with comments and trailing commas as plain JSON; comments become blanks, so positions are kept."""

def blank(match: re.Match[str]) -> str:
token = match[0]
return token if token.startswith('"') else re.sub(r"[^\n]", " ", token)

return _JSONC_TOKENS.sub(blank, _JSONC_TOKENS.sub(blank, text))


def _parse_json(data: bytes, *, comments: bool = False) -> None:
text = _decoded(data)
try:
json.loads(_without_comments(text) if comments else text)
json.loads(strip_json_comments(text) if comments else text)
except json.JSONDecodeError as error:
raise _ParseError(error.msg, error.lineno, error.colno) from error

Expand Down
2 changes: 1 addition & 1 deletion project_validator/checks/dependency_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@
rule=DependencyAudit,
)
def dependency_audit(audit: DependencyAudit, context: Context) -> Result:
if tools := matching_names(context.ci_texts, audit.tools):
if tools := matching_names(audit.texts(context), audit.tools):
return Result.passed(f"CI audits the dependencies with {', '.join(tools)}")
return Result.warned("no dependency vulnerability audit found in CI configuration", audit.hint)
2 changes: 1 addition & 1 deletion project_validator/checks/tests_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@
)
def tests_run(suite: TestSuite, context: Context) -> Result:
result = suite.found(context)
if result.status != Status.PASS or matching_names(context.ci_texts, suite.runs):
if result.status != Status.PASS or matching_names(suite.texts(context), suite.runs):
return result
return Result.warned(f"{result.message}, but no CI configuration runs {suite.runner}", *result.details)
4 changes: 2 additions & 2 deletions project_validator/checks/vcs_ignore.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from dataclasses import dataclass

from project_validator.core import Context, Result
from project_validator.ecosystems import check_each_ecosystem, ecosystem_check, rules_of
from project_validator.ecosystems import ecosystem_check, judge_each_ecosystem
from project_validator.rules import BuildOutputs


Expand Down Expand Up @@ -180,4 +180,4 @@ def vcs_ignore(context: Context) -> Result:
f"no version control ignore file found ({IGNORE_FILE_NAMES}); build outputs can be committed by accident",
"consider adding a .gitignore (or your version control system's equivalent) that ignores build outputs",
)
return check_each_ecosystem(context, rules_of(BuildOutputs), _outputs_ignored)
return judge_each_ecosystem(context, BuildOutputs, _outputs_ignored)
34 changes: 25 additions & 9 deletions project_validator/ecosystems/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@

from project_validator.core import Check, CheckFunction, Context, Result, Status
from project_validator.ecosystems.c_cpp import C_AND_CXX
from project_validator.ecosystems.dotnet import DOTNET
from project_validator.ecosystems.go import GO
from project_validator.ecosystems.javascript import JAVASCRIPT_AND_TYPESCRIPT
from project_validator.ecosystems.jvm import JVM
from project_validator.ecosystems.model import (
C_FAMILY,
BuildSystem,
Expand All @@ -21,10 +24,13 @@
languages_of,
toolchain_versions,
)
from project_validator.ecosystems.php import PHP
from project_validator.ecosystems.python import PYTHON
from project_validator.ecosystems.ruby import RUBY
from project_validator.ecosystems.rust import RUST
from project_validator.ecosystems.swift import SWIFT

BUILT_IN = (C_AND_CXX, PYTHON, RUST, GO)
BUILT_IN = (C_AND_CXX, PYTHON, RUST, GO, JAVASCRIPT_AND_TYPESCRIPT, JVM, PHP, RUBY, DOTNET, SWIFT)

_registered: list[Ecosystem] = list(BUILT_IN)

Expand Down Expand Up @@ -168,6 +174,16 @@ def judge_ecosystem[R](
return Result(status=Status.SKIP, message=f"not applicable to {ecosystem.label} built with {used}")


def judge_each_ecosystem[R](context: Context, kind: type[R], judge: Judge[R]) -> Result:
"""`judge` of the rule of type `kind` of each ecosystem the project uses, for the build systems it uses (see
`judge_ecosystem`): the worst result wins, and findings are labelled."""
structure = context.structure
used = ecosystems_for(structure.languages, providers(kind))
return combined(
{ecosystem: judge_ecosystem(ecosystem, kind, judge, context, structure.build_systems) for ecosystem in used}
)


def practice[R](
*, name: str, description: str, rule: type[R], aliases: tuple[str, ...] = ()
) -> Callable[[Judge[R]], Check]:
Expand All @@ -179,14 +195,7 @@ def practice[R](

def wrap(judge: Judge[R]) -> Check:
def run(context: Context) -> Result:
structure = context.structure
used = ecosystems_for(structure.languages, providers(rule))
return combined(
{
ecosystem: judge_ecosystem(ecosystem, rule, judge, context, structure.build_systems)
for ecosystem in used
}
)
return judge_each_ecosystem(context, rule, judge)

languages = languages_of(providers(rule))
return Check(name=name, description=description, run=run, languages=languages, rule=rule, aliases=aliases)
Expand All @@ -203,9 +212,15 @@ def is_build_tree(directory: Path) -> bool:
"BUILT_IN",
"C_AND_CXX",
"C_FAMILY",
"DOTNET",
"GO",
"JAVASCRIPT_AND_TYPESCRIPT",
"JVM",
"PHP",
"PYTHON",
"RUBY",
"RUST",
"SWIFT",
"BuildSystem",
"Ecosystem",
"RegistryError",
Expand All @@ -220,6 +235,7 @@ def is_build_tree(directory: Path) -> bool:
"ecosystems_for",
"is_build_tree",
"isolated_registry",
"judge_each_ecosystem",
"judge_ecosystem",
"languages_of",
"practice",
Expand Down
Loading
Loading