From 906b92e70d6ca9c43cba077c2142bc81a07ba18a Mon Sep 17 00:00:00 2001 From: SSobol77 Date: Thu, 9 Jul 2026 02:59:34 +0200 Subject: [PATCH 1/3] feat: add F4 linter provisioning framework --- docs/extensions/diagnostics-linter-layer.md | 22 +- docs/release/README-release.md | 4 + docs/release/artifact-contract.md | 13 + docs/release/artifact-verification.md | 13 + docs/release/build-matrix.md | 2 + docs/release/packaging-flows.md | 11 + docs/release/release-checklist.md | 4 + scripts/build_and_package_arch.py | 4 +- scripts/provision_f4_linters.py | 217 +++++ scripts/verify_f4_linter_provisioning.py | 94 +++ scripts/verify_release_assets.py | 9 +- .../linters/actionlint/package_contract.py | 8 + .../linters/biome/package_contract.py | 8 + .../linters/cargo_clippy/package_contract.py | 7 + .../linters/clang_format/package_contract.py | 7 + .../linters/clang_tidy/package_contract.py | 7 + .../extensions/linters/core/provisioning.py | 794 ++++++++++++++++++ .../linters/core/provisioning_contract.py | 202 +++++ .../linters/core/provisioning_registry.py | 351 ++++++++ src/ecli/extensions/linters/core/registry.py | 126 +++ .../linters/cppcheck/package_contract.py | 9 + .../linters/eslint/package_contract.py | 6 + .../linters/golangci_lint/package_contract.py | 8 + .../linters/hadolint/package_contract.py | 8 + .../java_checkstyle/package_contract.py | 8 + .../linters/java_pmd/package_contract.py | 8 + .../linters/java_spotbugs/package_contract.py | 8 + .../linters/markdownlint/package_contract.py | 7 + .../linters/oxlint/package_contract.py | 8 + .../linters/pylint/package_contract.py | 7 + .../linters/ruff/package_contract.py | 9 + .../linters/shellcheck/package_contract.py | 8 + .../linters/sqlfluff/package_contract.py | 7 + .../linters/stylelint/package_contract.py | 6 + .../linters/taplo/package_contract.py | 9 + .../linters/tflint/package_contract.py | 8 + .../linters/yamllint/package_contract.py | 7 + .../linters/zig/package_contract.py | 8 + .../linters/test_provisioning_contract.py | 180 ++++ .../test_f4_linter_provisioning_scripts.py | 161 ++++ .../test_packaging_release_contract.py | 2 + 41 files changed, 2375 insertions(+), 10 deletions(-) create mode 100755 scripts/provision_f4_linters.py create mode 100755 scripts/verify_f4_linter_provisioning.py create mode 100644 src/ecli/extensions/linters/core/provisioning.py create mode 100644 src/ecli/extensions/linters/core/provisioning_contract.py create mode 100644 src/ecli/extensions/linters/core/provisioning_registry.py create mode 100644 tests/extensions/linters/test_provisioning_contract.py create mode 100644 tests/packaging/test_f4_linter_provisioning_scripts.py diff --git a/docs/extensions/diagnostics-linter-layer.md b/docs/extensions/diagnostics-linter-layer.md index 45b3120..ca5f646 100644 --- a/docs/extensions/diagnostics-linter-layer.md +++ b/docs/extensions/diagnostics-linter-layer.md @@ -46,13 +46,19 @@ Current state: provider; it remains the F4 linter microservices reference implementation and its Python behavior is unchanged by this migration. - Every other first-class and legacy/optional linter (22 in total) has its - own microservice directory with `manifest.py` and `package_contract.py` - only -- no `provider.py`, no execution, no output parsing. + own microservice directory with `manifest.py` and `package_contract.py`. + Implemented providers/parsers remain separate from provisioning; the + provisioning layer consumes only manifest/package-contract metadata and does + not run F4 diagnostics. - `src/ecli/extensions/linters/__init__.py` aggregates every microservice's `manifest.MANIFEST` into `LINTER_CATALOG` and exposes `get_linter(name)`, `iter_linters()`, `linters_for_language(language)`. -- No new wiring into `DiagnosticsService` beyond Ruff, no output parsing - for non-Ruff tools, and no F4 UI change of any kind. +- `src/ecli/extensions/linters/core/provisioning_contract.py`, + `provisioning_registry.py`, and `provisioning.py` implement the + provider-neutral Full-install provisioning model, installer component data, + dry-run evidence, and evidence verification. This is packaging/release + contract logic only: no F4 UI change, no package-manager calls from F4, and + no provider/parser behavior change. See `docs/extensions/diagnostics-model.md` for the normalized `Diagnostic` contract every provider must produce, and @@ -266,7 +272,7 @@ reserves the identifier. ## ECLI Full vs. minimal install -Two install shapes are anticipated (not implemented in this change): +Two install shapes are represented in the provisioning contract: - **Minimal install** — the editor only. Ruff works out of the box because it is bundled (`provider_kind="internal"`). Every other entry in the @@ -303,6 +309,12 @@ allowed only with explicit source URL, version pinning, checksum verification where available, clear provenance in `package_contract.py`, no silent unverified execution, and deterministic install logs. +The repository exposes this contract through +`scripts/provision_f4_linters.py` and +`scripts/verify_f4_linter_provisioning.py`. `dry-run` planning is deterministic +and safe for CI; real missing-tool provisioning still belongs to +artifact-specific installer/package flows. + ### The PyPI limitation ECLI is published as a Python wheel on PyPI. Python wheels can only diff --git a/docs/release/README-release.md b/docs/release/README-release.md index 5ac06bc..1a1fc08 100644 --- a/docs/release/README-release.md +++ b/docs/release/README-release.md @@ -34,6 +34,10 @@ required tools before provisioning, installation or bundling of missing required linters/toolchains, executable checks, version probes, and provenance/checksum evidence for bundled or GitHub/upstream downloaded binaries, JARs, and tarballs. A missing required linter after ECLI Full install is a release blocker. +The provider-neutral entrypoints are `scripts/provision_f4_linters.py` and +`scripts/verify_f4_linter_provisioning.py`; they write and verify +`f4-linter-provisioning-.json` release evidence without +changing F4 runtime behavior. Authoritative files: - `artifact-contract.md` diff --git a/docs/release/artifact-contract.md b/docs/release/artifact-contract.md index 0f4feb4..524664e 100644 --- a/docs/release/artifact-contract.md +++ b/docs/release/artifact-contract.md @@ -99,6 +99,17 @@ blocker and a packaging defect, not normal user workflow. Manual linter installation documentation is only for developer checkouts, PyPI/source/minimal installs, damaged-install repair, and advanced administration. +The provider-neutral release evidence entrypoints are +`scripts/provision_f4_linters.py` and +`scripts/verify_f4_linter_provisioning.py`. The provisioner builds the +artifact-specific component model and writes +`f4-linter-provisioning-.json` evidence. It is safe by +default: no network, no upstream downloads, no package-manager execution, and +no unverified binary execution. The verifier enforces the required Full tool +set for Full-capable entries, validates all 21 evidence files for release-level +checks, and ignores GitHub-generated source archives because they are outside +the canonical 21 artifact contract entries. + The PyPI wheel and source distribution entries are constrained by Python packaging metadata: they cannot reliably provision Node, Rust, Go, Zig, Java, or system binaries. Their release contract must document that limitation @@ -408,6 +419,8 @@ scripts and release contract tests. | `scripts/verify_artifact.py` | Structural SHA256 sidecar verifier; exit-code contract `0`-`5` preserved | | `scripts/verify_release_assets.py` | Read-only exact 21 ECLI-owned GitHub Release asset verifier; ignores `.checksums/` only when it is a directory | | `scripts/verify_runtime.py` | Cross-artifact launcher validation; exit codes (`0`/`2`/`3`/`4`/`5`/`6`) preserved | +| `scripts/provision_f4_linters.py` | Provider-neutral F4 linter provisioning planner/evidence writer; dry-run by default, no network/upstream downloads unless explicitly allowed | +| `scripts/verify_f4_linter_provisioning.py` | Read-only F4 linter provisioning evidence verifier for one artifact or all 21 canonical artifact entries | | `scripts/build_pyinstaller_linux.py` | Linux PyInstaller build; prefers `packaging/pyinstaller/ecli.spec` | | `scripts/build_and_package_deb.py` | `ecli__linux_.deb` via FPM; dependency set preserved | | `scripts/build_and_package_rpm.py` | `ecli___.rpm`; `RPM_PLATFORM_LABEL`/`RPM_DEPENDS` env preserved | diff --git a/docs/release/artifact-verification.md b/docs/release/artifact-verification.md index 0ae5d54..ada7134 100644 --- a/docs/release/artifact-verification.md +++ b/docs/release/artifact-verification.md @@ -51,6 +51,19 @@ Use the canonical 21 ECLI-owned asset verifier before publication: uv run python scripts/verify_release_assets.py ``` +Use the F4 provisioning evidence verifier before declaring a Full artifact +release-ready: + +```sh +uv run python scripts/verify_f4_linter_provisioning.py --all-artifacts --evidence-dir +``` + +Provisioning evidence is written by: + +```sh +uv run python scripts/provision_f4_linters.py --artifact deb --target-dir --evidence-dir --mode dry-run --json +``` + The verifier reads the version from `pyproject.toml`, validates `releases//`, fails when the directory is missing, fails on missing or extra top-level ECLI-owned files, and passes only when the exact 21 ECLI-owned diff --git a/docs/release/build-matrix.md b/docs/release/build-matrix.md index 140fcdd..61e5ed4 100644 --- a/docs/release/build-matrix.md +++ b/docs/release/build-matrix.md @@ -39,6 +39,8 @@ detection, detection of already-installed required tools before install, installation or bundling of missing required tools, executable checks, version probes, and provenance/checksum evidence for bundled or upstream-downloaded tools. +`scripts/provision_f4_linters.py` emits the per-entry evidence, and +`scripts/verify_f4_linter_provisioning.py` verifies one entry or all 21 entries. - Linux: DEB/RPM/openSUSE RPM/Arch/Slackware/AppImage scripts and workflows diff --git a/docs/release/packaging-flows.md b/docs/release/packaging-flows.md index 831922a..b442c0c 100644 --- a/docs/release/packaging-flows.md +++ b/docs/release/packaging-flows.md @@ -61,6 +61,15 @@ installation is a release blocker. Manual linter installation remains a developer checkout, PyPI/source/minimal install, damaged-install repair, or advanced administration path only. +The shared provider-neutral entrypoints are +`scripts/provision_f4_linters.py` and +`scripts/verify_f4_linter_provisioning.py`. Packaging flows may call the +planner in dry-run/verify-only mode to emit deterministic +`f4-linter-provisioning-.json` evidence. Concrete +package-manager or bundle installation remains the responsibility of each +artifact-specific flow and must stay non-interactive for CI and unattended +package-manager installs. + ## Mandatory GitHub Release Assets ```text @@ -135,6 +144,8 @@ list and migration rules live in `docs/release/artifact-contract.md` under Canonical Python entrypoints include `scripts/sign_checksums.py`, `scripts/check_log_invariant.py`, `scripts/verify_artifact.py`, `scripts/verify_release_assets.py`, `scripts/verify_runtime.py`, +`scripts/provision_f4_linters.py`, +`scripts/verify_f4_linter_provisioning.py`, `scripts/build_pyinstaller_linux.py`, `scripts/build_and_package_deb.py`, `scripts/build_and_package_rpm.py`, `scripts/build_and_package_opensuse_rpm.py`, `scripts/build_and_package_arch.py`, diff --git a/docs/release/release-checklist.md b/docs/release/release-checklist.md index 109999c..d776cb8 100644 --- a/docs/release/release-checklist.md +++ b/docs/release/release-checklist.md @@ -78,6 +78,10 @@ See the LICENSE file in the project root for full license text. detects already-installed required tools before installing missing tools, verifies executable availability and version probes, and includes deterministic provisioning evidence. +- [ ] `scripts/provision_f4_linters.py --all-artifacts --mode dry-run` writes + 21 deterministic `f4-linter-provisioning-.json` + evidence files, and + `scripts/verify_f4_linter_provisioning.py --all-artifacts` verifies them. - [ ] F4 linter package-manager dependencies: package metadata asserts the dependency relationship and post-install executable availability for each required linter/toolchain dependency it delegates to the OS package diff --git a/scripts/build_and_package_arch.py b/scripts/build_and_package_arch.py index be86fd2..9c5599b 100644 --- a/scripts/build_and_package_arch.py +++ b/scripts/build_and_package_arch.py @@ -102,9 +102,7 @@ def stage_pkgbuild(root: Path, build_dir: Path, version: str) -> Path: source tree is never used as a makepkg working directory. """ source = (root / "packaging" / "arch" / "PKGBUILD").read_text(encoding="utf-8") - rewritten = re.sub( - r"(?m)^pkgver=.*$", f"pkgver={version}", source, count=1 - ) + rewritten = re.sub(r"(?m)^pkgver=.*$", f"pkgver={version}", source, count=1) target = build_dir / "PKGBUILD" target.write_text(rewritten, encoding="utf-8") return target diff --git a/scripts/provision_f4_linters.py b/scripts/provision_f4_linters.py new file mode 100755 index 0000000..701b192 --- /dev/null +++ b/scripts/provision_f4_linters.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +# +# Project: Ecli +# File: scripts/provision_f4_linters.py +# Website: https://www.ecli.io +# Repository: https://github.com/SSobol77/ecli +# PyPI: https://pypi.org/project/ecli-editor/0.0.1/ +# +# Copyright (c) 2026 Siergej Sobolewski +# +# Licensed under the GNU General Public License version 2 only. +# See the LICENSE file in the project root for full license text. + +"""Plan or verify F4 linter provisioning for canonical release artifacts. + +Default execution is automation-safe: no network, no upstream downloads, and no +package-manager calls. ``dry-run`` writes deterministic provisioning evidence +that packaging and release tests can validate. ``verify-only`` checks existing +executables and version probes. ``provision`` intentionally fails closed for +missing required tools until an artifact-specific installer implementation wires +in a concrete, provenance-aware install path. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from ecli.extensions.linters.core.provisioning import ( # noqa: E402 + build_component_model, + build_provisioning_plan, + component_model_to_dict, + evidence_to_dict, + plan_has_release_blocking_failure, + plan_to_evidence, + read_project_version, + write_evidence, +) +from ecli.extensions.linters.core.provisioning_registry import ( # noqa: E402 + ARTIFACT_CONTRACT_ENTRIES, + get_artifact_entry, + load_linter_tool_contracts, +) + + +EXIT_OK = 0 +EXIT_INVALID = 1 +EXIT_PROVISIONING_FAILED = 2 + + +class _ContractArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> None: # type: ignore[override] + self.print_usage(sys.stderr) + print(f"{self.prog}: error: {message}", file=sys.stderr) + raise SystemExit(EXIT_INVALID) + + +def _bool_arg(value: str) -> bool: + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise argparse.ArgumentTypeError(f"expected boolean, got {value!r}") + + +def build_parser() -> argparse.ArgumentParser: + parser = _ContractArgumentParser( + prog="provision_f4_linters.py", + description="Build F4 linter provisioning plans and evidence.", + ) + target = parser.add_mutually_exclusive_group(required=True) + target.add_argument("--artifact", help="canonical artifact entry id, e.g. deb") + target.add_argument( + "--all-artifacts", + action="store_true", + help="process all 21 canonical artifact entries", + ) + parser.add_argument("--target-dir", required=True, help="managed tools target dir") + parser.add_argument("--evidence-dir", required=True, help="evidence output dir") + parser.add_argument( + "--mode", + choices=("dry-run", "verify-only", "provision"), + default="dry-run", + help="execution mode; default is dry-run", + ) + parser.add_argument( + "--allow-network", + type=_bool_arg, + default=False, + help="allow network actions; default false", + ) + parser.add_argument( + "--allow-upstream-downloads", + type=_bool_arg, + default=False, + help="allow upstream/GitHub downloads; default false", + ) + parser.add_argument( + "--profile", + choices=("full", "custom", "minimal"), + default="full", + help="selection profile; default full", + ) + parser.add_argument( + "--include-tool", + action="append", + default=(), + help="include one tool id; repeatable", + ) + parser.add_argument( + "--exclude-tool", + action="append", + default=(), + help="exclude one tool id; repeatable", + ) + parser.add_argument( + "--selection-json", + help="JSON selection file with include/exclude/tools data", + ) + parser.add_argument( + "--list-selection-options", + action="store_true", + help="print installer component-selection model instead of evidence", + ) + parser.add_argument("--json", action="store_true", help="print JSON output") + return parser + + +def _artifact_ids(args: argparse.Namespace) -> tuple[str, ...]: + if args.all_artifacts: + return tuple(entry.artifact_entry_id for entry in ARTIFACT_CONTRACT_ENTRIES) + return (args.artifact,) + + +def _print_human(paths: list[Path], failed: bool) -> None: + for path in paths: + print(f"wrote {path}") + if failed: + print("F4 linter provisioning contract failed", file=sys.stderr) + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + contracts = load_linter_tool_contracts() + target_dir = Path(args.target_dir) + evidence_dir = Path(args.evidence_dir) + selection_path = Path(args.selection_json) if args.selection_json else None + artifact_ids = _artifact_ids(args) + + if args.list_selection_options: + models: list[dict[str, Any]] = [] + for artifact_id in artifact_ids: + artifact = get_artifact_entry(artifact_id) + models.append( + component_model_to_dict( + build_component_model( + artifact, + args.profile, + contracts, + ) + ) + ) + if args.json: + print(json.dumps({"artifacts": models}, indent=2, sort_keys=True)) + else: + for model in models: + print(f"{model['artifact_entry_id']}: {model['full_label']}") + for option in model["options"]: + mark = "x" if option["selected_by_default"] else " " + required = ( + "required" if option["required_for_full"] else option["tier"] + ) + print(f" [{mark}] {option['tool_id']} ({required})") + return EXIT_OK + + version = read_project_version(ROOT) + output: list[dict[str, Any]] = [] + paths: list[Path] = [] + failed = False + for artifact_id in artifact_ids: + plan = build_provisioning_plan( + artifact_entry_id=artifact_id, + target_dir=target_dir, + evidence_dir=evidence_dir, + mode=args.mode, + profile=args.profile, + include_tools=args.include_tool, + exclude_tools=args.exclude_tool, + selection_json=selection_path, + allow_network=args.allow_network, + allow_upstream_downloads=args.allow_upstream_downloads, + contracts=contracts, + ) + paths.append(write_evidence(plan, ecli_version=version)) + output.append(evidence_to_dict(plan_to_evidence(plan, ecli_version=version))) + failed = failed or plan_has_release_blocking_failure(plan) + + if args.json: + print(json.dumps({"artifacts": output}, indent=2, sort_keys=True)) + else: + _print_human(paths, failed) + return EXIT_PROVISIONING_FAILED if failed else EXIT_OK + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_f4_linter_provisioning.py b/scripts/verify_f4_linter_provisioning.py new file mode 100755 index 0000000..34d6948 --- /dev/null +++ b/scripts/verify_f4_linter_provisioning.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +# +# Project: Ecli +# File: scripts/verify_f4_linter_provisioning.py +# Website: https://www.ecli.io +# Repository: https://github.com/SSobol77/ecli +# PyPI: https://pypi.org/project/ecli-editor/0.0.1/ +# +# Copyright (c) 2026 Siergej Sobolewski +# +# Licensed under the GNU General Public License version 2 only. +# See the LICENSE file in the project root for full license text. + +"""Verify F4 linter provisioning evidence. + +The verifier enforces the canonical 21-entry artifact matrix for release-level +checks. GitHub-generated ``Source code (zip)`` and ``Source code (tar.gz)`` +archives are ignored because they are not ECLI-owned artifact contract entries. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from ecli.extensions.linters.core.provisioning import ( # noqa: E402 + verify_evidence_dir, +) + + +EXIT_OK = 0 +EXIT_INVALID = 1 +EXIT_CONTRACT_FAILED = 2 + + +class _ContractArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> None: # type: ignore[override] + self.print_usage(sys.stderr) + print(f"{self.prog}: error: {message}", file=sys.stderr) + raise SystemExit(EXIT_INVALID) + + +def build_parser() -> argparse.ArgumentParser: + parser = _ContractArgumentParser( + prog="verify_f4_linter_provisioning.py", + description="Verify F4 linter provisioning evidence.", + ) + target = parser.add_mutually_exclusive_group(required=True) + target.add_argument("--artifact", help="canonical artifact entry id, e.g. deb") + target.add_argument( + "--all-artifacts", + action="store_true", + help="verify all 21 canonical artifact entries", + ) + parser.add_argument("--evidence-dir", required=True, help="evidence directory") + parser.add_argument("--json", action="store_true", help="print JSON result") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + errors = verify_evidence_dir( + Path(args.evidence_dir), + artifact_entry_id=args.artifact, + all_artifacts=args.all_artifacts, + ) + if args.json: + print( + json.dumps( + {"ok": not errors, "errors": errors}, + indent=2, + sort_keys=True, + ) + ) + elif errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + else: + target = "all 21 artifacts" if args.all_artifacts else args.artifact + print(f"PASS: F4 linter provisioning evidence verified for {target}") + return EXIT_CONTRACT_FAILED if errors else EXIT_OK + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_release_assets.py b/scripts/verify_release_assets.py index afa13a9..848222f 100755 --- a/scripts/verify_release_assets.py +++ b/scripts/verify_release_assets.py @@ -95,7 +95,10 @@ def read_project_version(root: Path) -> str: with pyproject.open("rb") as handle: version = tomllib.load(handle)["project"]["version"] except (KeyError, OSError, TypeError, tomllib.TOMLDecodeError) as exc: - print(f"ERROR: cannot read project version from {pyproject}: {exc}", file=sys.stderr) + print( + f"ERROR: cannot read project version from {pyproject}: {exc}", + file=sys.stderr, + ) raise SystemExit(EXIT_INVALID) from exc if not isinstance(version, str) or not version.strip(): print(f"ERROR: invalid project version in {pyproject}", file=sys.stderr) @@ -202,7 +205,9 @@ def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) root = Path.cwd() version = args.version or read_project_version(root) - return verify_release_assets(release_dir_for(root, version, args.release_dir), version) + return verify_release_assets( + release_dir_for(root, version, args.release_dir), version + ) if __name__ == "__main__": diff --git a/src/ecli/extensions/linters/actionlint/package_contract.py b/src/ecli/extensions/linters/actionlint/package_contract.py index b8b2823..470ddde 100644 --- a/src/ecli/extensions/linters/actionlint/package_contract.py +++ b/src/ecli/extensions/linters/actionlint/package_contract.py @@ -25,4 +25,12 @@ binary_names=("actionlint",), version_probe=("actionlint", "-version"), delivery_notes="Standalone Go binary; bundled with ECLI Full where platform packaging allows.", + allowed_install_mechanisms=( + "os-package-manager", + "ecli-managed-tools", + "bundled-binary", + "verified-upstream-download", + "nix-derivation", + ), + source_url="https://github.com/rhysd/actionlint", ) diff --git a/src/ecli/extensions/linters/biome/package_contract.py b/src/ecli/extensions/linters/biome/package_contract.py index b3df68f..bad4a17 100644 --- a/src/ecli/extensions/linters/biome/package_contract.py +++ b/src/ecli/extensions/linters/biome/package_contract.py @@ -25,4 +25,12 @@ binary_names=("biome",), version_probe=("biome", "--version"), delivery_notes="npm package / standalone binary; bundled with ECLI Full where platform packaging allows.", + allowed_install_mechanisms=( + "language-package-manager", + "ecli-managed-tools", + "bundled-binary", + "verified-upstream-download", + "nix-derivation", + ), + source_url="https://biomejs.dev/", ) diff --git a/src/ecli/extensions/linters/cargo_clippy/package_contract.py b/src/ecli/extensions/linters/cargo_clippy/package_contract.py index 0b1c23f..7fac588 100644 --- a/src/ecli/extensions/linters/cargo_clippy/package_contract.py +++ b/src/ecli/extensions/linters/cargo_clippy/package_contract.py @@ -25,4 +25,11 @@ binary_names=("cargo",), version_probe=("cargo", "clippy", "--version"), delivery_notes="Rust toolchain component (`rustup component add clippy`); bundled with ECLI Full where feasible.", + allowed_install_mechanisms=( + "toolchain-component", + "os-package-manager", + "ecli-managed-tools", + "nix-derivation", + ), + source_url="https://doc.rust-lang.org/clippy/", ) diff --git a/src/ecli/extensions/linters/clang_format/package_contract.py b/src/ecli/extensions/linters/clang_format/package_contract.py index 7336546..c197250 100644 --- a/src/ecli/extensions/linters/clang_format/package_contract.py +++ b/src/ecli/extensions/linters/clang_format/package_contract.py @@ -25,4 +25,11 @@ binary_names=("clang-format",), version_probe=("clang-format", "--version"), delivery_notes="OS package dependency (LLVM tooling) or bundled binary; dry-run/--Werror check mode only, never rewrites files during F4 diagnostics (design doc section 11.3).", + allowed_install_mechanisms=( + "os-package-manager", + "ecli-managed-tools", + "bundled-binary", + "nix-derivation", + ), + source_url="https://clang.llvm.org/docs/ClangFormat.html", ) diff --git a/src/ecli/extensions/linters/clang_tidy/package_contract.py b/src/ecli/extensions/linters/clang_tidy/package_contract.py index 9ca5aaf..9f6c979 100644 --- a/src/ecli/extensions/linters/clang_tidy/package_contract.py +++ b/src/ecli/extensions/linters/clang_tidy/package_contract.py @@ -25,4 +25,11 @@ binary_names=("clang-tidy",), version_probe=("clang-tidy", "--version"), delivery_notes="OS package dependency (LLVM tooling) or bundled binary; requires compile_commands.json / CMake context for full analysis, see the design doc section 11.1.", + allowed_install_mechanisms=( + "os-package-manager", + "ecli-managed-tools", + "bundled-binary", + "nix-derivation", + ), + source_url="https://clang.llvm.org/extra/clang-tidy/", ) diff --git a/src/ecli/extensions/linters/core/provisioning.py b/src/ecli/extensions/linters/core/provisioning.py new file mode 100644 index 0000000..eb077ac --- /dev/null +++ b/src/ecli/extensions/linters/core/provisioning.py @@ -0,0 +1,794 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Project: Ecli +# File: src/ecli/extensions/linters/core/provisioning.py +# Website: https://www.ecli.io +# Repository: https://github.com/SSobol77/ecli +# PyPI: https://pypi.org/project/ecli-editor/0.0.1/ +# +# Copyright (c) 2026 Siergej Sobolewski +# +# Licensed under the GNU General Public License version 2 only. +# See the LICENSE file in the project root for full license text. + +"""Planner, evidence writer, and verifier for F4 linter provisioning. + +This module is provider-neutral. It never imports F4 panel code, never parses +linter diagnostics, and never runs linter diagnostics. The only external +processes it may run are explicit version probes in ``verify-only`` or +``provision`` mode. +""" + +from __future__ import annotations + +import json +import os +import platform +import shutil +import subprocess +import tomllib +from collections.abc import Iterable, Mapping +from dataclasses import asdict +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from ecli.extensions.linters.core.provisioning_contract import ( + ActionStatus, + ArtifactContractEntry, + InstallerComponentModel, + LinterSelectionOption, + LinterSelectionProfile, + LinterToolContract, + ProvisioningAction, + ProvisioningEvidence, + ProvisioningMode, + ProvisioningPlan, + ProvisioningProfile, + ProvisioningStrategy, + VerificationResult, +) +from ecli.extensions.linters.core.provisioning_registry import ( + ARTIFACT_CONTRACT_ENTRIES, + get_artifact_entry, + is_github_generated_source_archive, + load_linter_tool_contracts, +) + + +EVIDENCE_SCHEMA_VERSION = 1 +DETERMINISTIC_TIMESTAMP_ENV = "ECLI_F4_PROVISIONING_TIMESTAMP" +DETERMINISTIC_TIMESTAMP = "1970-01-01T00:00:00Z" + +PACKAGE_MANAGER_ARTIFACT_IDS = frozenset( + { + "deb", + "rpm", + "opensuse-rpm", + "arch-pkgbuild", + "slackware-txz", + "freebsd-pkg", + "docker-deb-helper", + "docker-rpm-helper", + } +) +PORTABLE_BUNDLE_ARTIFACT_IDS = frozenset( + { + "linux-pyinstaller", + "linux-tarball", + "appimage", + "macos-app", + "macos-dmg", + "windows-portable-exe", + "windows-nsis-installer", + "freebsd-ports-chroot", + "gha-release-contract", + } +) +NIX_ARTIFACT_IDS = frozenset({"nix-flake", "nixos-package"}) + + +def evidence_filename(artifact_entry_id: str) -> str: + """Return the deterministic evidence filename for an artifact entry.""" + return f"f4-linter-provisioning-{artifact_entry_id}.json" + + +def read_project_version(root: Path) -> str: + """Read the project version from ``pyproject.toml``.""" + with (root / "pyproject.toml").open("rb") as handle: + return str(tomllib.load(handle)["project"]["version"]) + + +def generated_at() -> str: + """Return an evidence timestamp, with deterministic override for tests.""" + override = os.environ.get(DETERMINISTIC_TIMESTAMP_ENV) + if override is not None: + return override or DETERMINISTIC_TIMESTAMP + return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def os_context() -> dict[str, str]: + """Return stable host context for evidence.""" + return { + "system": platform.system(), + "release": platform.release(), + "machine": platform.machine(), + "platform": platform.platform(), + } + + +def build_component_model( + artifact: ArtifactContractEntry, + profile: LinterSelectionProfile, + contracts: tuple[LinterToolContract, ...] | None = None, +) -> InstallerComponentModel: + """Build structured installer-renderable linter selection data.""" + loaded = load_linter_tool_contracts() if contracts is None else contracts + options = tuple( + LinterSelectionOption( + tool_id=contract.tool_id, + display_name=contract.display_name, + required_for_full=contract.required_for_full, + selected_by_default=contract.required_for_full, + tier=contract.tier, + install_group=contract.install_group, + languages=contract.languages, + executable_names=contract.executable_names, + ) + for contract in loaded + ) + return InstallerComponentModel( + artifact_entry_id=artifact.artifact_entry_id, + selection_profile=profile, + full_label="Install all recommended F4 linters and toolchains", + custom_label="Custom linter selection", + options=options, + ) + + +def _selection_json(path: Path | None) -> tuple[set[str], set[str]]: + if path is None: + return set(), set() + data = json.loads(path.read_text(encoding="utf-8")) + include: set[str] = set() + exclude: set[str] = set() + if isinstance(data, list): + include.update(str(item) for item in data) + return include, exclude + if not isinstance(data, dict): + raise ValueError("selection JSON must be an object or list") + + for key in ("include", "include_tool", "include_tools", "selected_tools"): + values = data.get(key) + if isinstance(values, list): + include.update(str(item) for item in values) + for key in ("exclude", "exclude_tool", "exclude_tools", "skipped_tools"): + values = data.get(key) + if isinstance(values, list): + exclude.update(str(item) for item in values) + tools = data.get("tools") + if isinstance(tools, dict): + for tool_id, selected in tools.items(): + if selected is True: + include.add(str(tool_id)) + elif selected is False: + exclude.add(str(tool_id)) + return include, exclude + + +def resolve_profile( + contracts: tuple[LinterToolContract, ...], + artifact: ArtifactContractEntry, + requested_profile: LinterSelectionProfile, + include_tools: Iterable[str] = (), + exclude_tools: Iterable[str] = (), + selection_json: Path | None = None, +) -> ProvisioningProfile: + """Resolve profile/include/exclude inputs into selected tool ids.""" + known = {contract.tool_id for contract in contracts} + required = {contract.tool_id for contract in contracts if contract.required_for_full} + internal = { + contract.tool_id for contract in contracts if contract.provider_kind == "internal" + } + json_include, json_exclude = _selection_json(selection_json) + include = set(include_tools) | json_include + exclude = set(exclude_tools) | json_exclude + unknown = sorted((include | exclude) - known) + if unknown: + raise ValueError(f"unknown linter tool id(s): {unknown}") + + if requested_profile == "full": + selected = set(required) + elif requested_profile == "minimal": + selected = set(internal) + else: + selected = set(include) + + selected.update(include) + selected.difference_update(exclude) + + effective_profile = requested_profile + full_complete = requested_profile == "full" and required <= selected + custom_reasons: list[str] = [] + missing_required = sorted(required - selected) + if missing_required: + full_complete = False + if requested_profile == "full": + effective_profile = "custom" + custom_reasons.append( + "required Full tool(s) explicitly deselected: " + + ", ".join(missing_required) + ) + if not artifact.full_provisioning_supported and requested_profile == "full": + full_complete = False + effective_profile = "minimal" + if artifact.minimal_reason: + custom_reasons.append(artifact.minimal_reason) + if requested_profile in ("custom", "minimal"): + full_complete = False + if requested_profile == "custom": + custom_reasons.append("custom linter selection requested") + else: + custom_reasons.append("minimal F4 provisioning requested") + + skipped = tuple(contract.tool_id for contract in contracts if contract.tool_id not in selected) + return ProvisioningProfile( + requested_profile=requested_profile, + effective_profile=effective_profile, + selected_tool_ids=tuple( + contract.tool_id for contract in contracts if contract.tool_id in selected + ), + skipped_tool_ids=skipped, + full_profile_complete=full_complete, + custom_profile_reason="; ".join(custom_reasons) or None, + ) + + +def _mechanism_allowed( + contract: LinterToolContract, mechanism: str +) -> bool: + return mechanism in contract.allowed_install_mechanisms + + +def choose_strategy( + artifact: ArtifactContractEntry, + contract: LinterToolContract, +) -> ProvisioningStrategy: + """Choose a deterministic default strategy for an artifact/tool pair.""" + if contract.provider_kind == "internal": + return ProvisioningStrategy( + mechanism="bundled-internal", + description="Bundled internal ECLI provider; no external tool download.", + requires_network=False, + requires_upstream_download=False, + requires_checksum=False, + source_url=contract.source_url, + pinned_version=contract.pinned_version, + ) + if not artifact.full_provisioning_supported: + return ProvisioningStrategy( + mechanism="artifact-policy", + description="Documented minimal/constrained artifact entry.", + requires_network=False, + requires_upstream_download=False, + requires_checksum=False, + source_url=contract.source_url, + pinned_version=contract.pinned_version, + ) + if artifact.artifact_entry_id in NIX_ARTIFACT_IDS and _mechanism_allowed( + contract, "nix-derivation" + ): + return ProvisioningStrategy( + mechanism="nix-derivation", + description="Nix derivation/input provides the executable.", + requires_network=False, + requires_upstream_download=False, + requires_checksum=False, + source_url=contract.source_url, + pinned_version=contract.pinned_version, + ) + if artifact.artifact_entry_id in PACKAGE_MANAGER_ARTIFACT_IDS and _mechanism_allowed( + contract, "os-package-manager" + ): + return ProvisioningStrategy( + mechanism="os-package-manager", + description="Native package metadata or helper evidence provides the tool.", + requires_network=False, + requires_upstream_download=False, + requires_checksum=False, + source_url=contract.source_url, + pinned_version=contract.pinned_version, + ) + if "toolchain-component" in contract.allowed_install_mechanisms: + return ProvisioningStrategy( + mechanism="toolchain-component", + description="Toolchain component provisioned by artifact policy.", + requires_network=False, + requires_upstream_download=False, + requires_checksum=False, + source_url=contract.source_url, + pinned_version=contract.pinned_version, + ) + if "jar-shim" in contract.allowed_install_mechanisms: + return ProvisioningStrategy( + mechanism="jar-shim", + description="Pinned JAR/distribution plus wrapper shim.", + requires_network=False, + requires_upstream_download=True, + requires_checksum=contract.checksum_required_for_downloads, + source_url=contract.source_url, + pinned_version=contract.pinned_version or "release-policy-pin-required", + checksum_source="release-provenance", + checksum_value="release-policy-checksum-required", + ) + if artifact.artifact_entry_id in PORTABLE_BUNDLE_ARTIFACT_IDS and _mechanism_allowed( + contract, "bundled-binary" + ): + return ProvisioningStrategy( + mechanism="bundled-binary", + description="Bundled or adjacent artifact-managed runtime payload.", + requires_network=False, + requires_upstream_download=False, + requires_checksum=False, + source_url=contract.source_url, + pinned_version=contract.pinned_version, + ) + if "language-package-manager" in contract.allowed_install_mechanisms: + return ProvisioningStrategy( + mechanism="language-package-manager", + description="Pinned language package-manager install into ECLI tools path.", + requires_network=False, + requires_upstream_download=False, + requires_checksum=False, + source_url=contract.source_url, + pinned_version=contract.pinned_version, + ) + if "verified-upstream-download" in contract.allowed_install_mechanisms: + return ProvisioningStrategy( + mechanism="verified-upstream-download", + description="Verified upstream/GitHub release artifact.", + requires_network=True, + requires_upstream_download=True, + requires_checksum=contract.checksum_required_for_downloads, + source_url=contract.source_url, + pinned_version=contract.pinned_version or "release-policy-pin-required", + checksum_source="release-provenance", + checksum_value="release-policy-checksum-required", + ) + return ProvisioningStrategy( + mechanism="artifact-policy", + description="Artifact policy must provide a concrete mechanism.", + requires_network=False, + requires_upstream_download=False, + requires_checksum=False, + source_url=contract.source_url, + pinned_version=contract.pinned_version, + ) + + +def _planned_path(target_dir: Path, executable_name: str) -> str: + return str(target_dir / "bin" / executable_name) + + +def _run_version_probe( + command: tuple[str, ...], + *, + timeout_seconds: float, +) -> tuple[str | None, str | None]: + try: + completed = subprocess.run( + list(command), + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return None, str(exc) + output = (completed.stdout or completed.stderr).strip().splitlines() + version = output[0] if output else f"exit {completed.returncode}" + if completed.returncode != 0: + return version, f"version probe exited {completed.returncode}" + return version, None + + +def _action_for_contract( + *, + contract: LinterToolContract, + artifact: ArtifactContractEntry, + profile: ProvisioningProfile, + mode: ProvisioningMode, + target_dir: Path, + allow_network: bool, + allow_upstream_downloads: bool, + include_tools: set[str], + exclude_tools: set[str], +) -> ProvisioningAction: + selected = contract.tool_id in profile.selected_tool_ids + executable_name = contract.executable_names[0] + executable_path = shutil.which(executable_name) + strategy = choose_strategy(artifact, contract) + selected_by_default = ( + profile.requested_profile == "full" and contract.required_for_full + ) + user_selected = contract.tool_id in include_tools + user_opted_out = contract.tool_id in exclude_tools and contract.required_for_full + warnings: list[str] = [] + errors: list[str] = [] + + status: ActionStatus + verification_result: VerificationResult + observed_version: str | None = None + planned_version: str | None = None + + if not selected: + status = "skipped" + verification_result = "skipped" + elif not artifact.full_provisioning_supported and contract.required_for_full: + status = "skipped" + verification_result = "skipped" + if artifact.minimal_reason: + warnings.append(artifact.minimal_reason) + elif contract.provider_kind == "internal": + status = "bundled" + verification_result = "verified" + planned_version = "bundled-with-ecli" + elif mode == "dry-run": + if executable_path is not None: + status = "already-present" + planned_version = "version-probe-deferred-in-dry-run" + else: + status = "planned" + planned_version = "planned-by-artifact-policy" + verification_result = "planned" + elif executable_path is not None: + observed_version, error = _run_version_probe( + contract.version_probe.command, + timeout_seconds=contract.version_probe.timeout_seconds, + ) + status = "already-present" + verification_result = "verified" + if error is not None: + status = "failed" + verification_result = "failed" + errors.append(error) + elif mode == "verify-only": + status = "failed" + verification_result = "failed" + errors.append(f"required executable is missing: {executable_name}") + else: + status = "failed" + verification_result = "failed" + if strategy.requires_network and not allow_network: + errors.append("network provisioning disabled by policy") + if strategy.requires_upstream_download and not allow_upstream_downloads: + errors.append("upstream downloads disabled by policy") + errors.append( + "concrete artifact installer integration is not implemented in this " + "provider-neutral planner" + ) + + return ProvisioningAction( + tool_id=contract.tool_id, + display_name=contract.display_name, + required_for_full=contract.required_for_full, + selected=selected, + selected_by_default=selected_by_default, + user_selected=user_selected, + user_opted_out=user_opted_out, + status=status, + executable_name=executable_name, + executable_path=executable_path, + planned_path=_planned_path(target_dir, executable_name), + version_command=contract.version_probe.command, + observed_version=observed_version, + planned_version=planned_version, + strategy=strategy, + verification_result=verification_result, + errors=tuple(errors), + warnings=tuple(warnings), + ) + + +def build_provisioning_plan( + *, + artifact_entry_id: str, + target_dir: Path, + evidence_dir: Path, + mode: ProvisioningMode, + profile: LinterSelectionProfile, + include_tools: Iterable[str] = (), + exclude_tools: Iterable[str] = (), + selection_json: Path | None = None, + allow_network: bool = False, + allow_upstream_downloads: bool = False, + contracts: tuple[LinterToolContract, ...] | None = None, +) -> ProvisioningPlan: + """Build an artifact-aware F4 linter provisioning plan.""" + artifact = get_artifact_entry(artifact_entry_id) + loaded = load_linter_tool_contracts() if contracts is None else contracts + json_include, json_exclude = _selection_json(selection_json) + include = set(include_tools) | json_include + exclude = set(exclude_tools) | json_exclude + resolved = resolve_profile( + loaded, + artifact, + profile, + include_tools=include, + exclude_tools=exclude, + ) + component_model = build_component_model(artifact, profile, loaded) + actions = tuple( + _action_for_contract( + contract=contract, + artifact=artifact, + profile=resolved, + mode=mode, + target_dir=target_dir, + allow_network=allow_network, + allow_upstream_downloads=allow_upstream_downloads, + include_tools=include, + exclude_tools=exclude, + ) + for contract in loaded + ) + return ProvisioningPlan( + artifact=artifact, + mode=mode, + target_dir=str(target_dir), + evidence_dir=str(evidence_dir), + profile=resolved, + component_model=component_model, + actions=actions, + ) + + +def action_to_dict(action: ProvisioningAction) -> dict[str, Any]: + """Serialize one action using stable field names required by evidence.""" + data = asdict(action) + data["version_command"] = list(action.version_command) + data["strategy"] = asdict(action.strategy) + return data + + +def component_model_to_dict(model: InstallerComponentModel) -> dict[str, Any]: + """Serialize installer component model.""" + return { + "artifact_entry_id": model.artifact_entry_id, + "selection_profile": model.selection_profile, + "full_label": model.full_label, + "custom_label": model.custom_label, + "options": [asdict(option) for option in model.options], + } + + +def plan_to_evidence( + plan: ProvisioningPlan, + *, + ecli_version: str, + timestamp: str | None = None, +) -> ProvisioningEvidence: + """Convert a plan to evidence.""" + return ProvisioningEvidence( + artifact_entry_id=plan.artifact.artifact_entry_id, + run_mode=plan.mode, + os_context=os_context(), + generated_at=timestamp or generated_at(), + ecli_version=ecli_version, + target_dir=plan.target_dir, + selection_profile=plan.profile.requested_profile, + effective_profile=plan.profile.effective_profile, + full_profile_complete=plan.profile.full_profile_complete, + custom_profile_reason=plan.profile.custom_profile_reason, + tools=plan.actions, + ) + + +def evidence_to_dict(evidence: ProvisioningEvidence) -> dict[str, Any]: + """Serialize evidence to deterministic JSON-compatible data.""" + required = [tool for tool in evidence.tools if tool.required_for_full] + selected = [tool for tool in evidence.tools if tool.selected] + statuses: dict[str, int] = {} + for tool in evidence.tools: + statuses[tool.status] = statuses.get(tool.status, 0) + 1 + return { + "schema_version": EVIDENCE_SCHEMA_VERSION, + "artifact_entry_id": evidence.artifact_entry_id, + "run_mode": evidence.run_mode, + "os_context": evidence.os_context, + "generated_at": evidence.generated_at, + "ecli_version": evidence.ecli_version, + "target_dir": evidence.target_dir, + "selection_profile": evidence.selection_profile, + "effective_profile": evidence.effective_profile, + "full_profile_complete": evidence.full_profile_complete, + "custom_profile_reason": evidence.custom_profile_reason, + "tools": [action_to_dict(tool) for tool in evidence.tools], + "summary": { + "required_total": len(required), + "selected_total": len(selected), + "status_counts": statuses, + }, + } + + +def write_evidence(plan: ProvisioningPlan, *, ecli_version: str) -> Path: + """Write deterministic JSON evidence for ``plan`` and return the path.""" + evidence_dir = Path(plan.evidence_dir) + evidence_dir.mkdir(parents=True, exist_ok=True) + evidence = plan_to_evidence(plan, ecli_version=ecli_version) + path = evidence_dir / evidence_filename(plan.artifact.artifact_entry_id) + path.write_text( + json.dumps(evidence_to_dict(evidence), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return path + + +def plan_has_release_blocking_failure(plan: ProvisioningPlan) -> bool: + """Return whether a plan violates the Full provisioning contract.""" + if plan.mode == "dry-run": + return False + if not plan.artifact.full_provisioning_supported: + return False + for action in plan.actions: + if action.required_for_full and action.selected and action.status == "failed": + return True + if action.required_for_full and not action.selected: + return True + return False + + +def _tool_by_id(payload: Mapping[str, Any]) -> dict[str, Mapping[str, Any]]: + tools = payload.get("tools") + if not isinstance(tools, list): + return {} + result: dict[str, Mapping[str, Any]] = {} + for item in tools: + if isinstance(item, dict) and isinstance(item.get("tool_id"), str): + result[item["tool_id"]] = item + return result + + +def _has_probe(tool: Mapping[str, Any]) -> bool: + command = tool.get("version_command") + return isinstance(command, list) and all(isinstance(item, str) for item in command) + + +def _has_executable_record(tool: Mapping[str, Any]) -> bool: + return isinstance(tool.get("executable_name"), str) and ( + isinstance(tool.get("executable_path"), str) + or isinstance(tool.get("planned_path"), str) + ) + + +def _strategy_errors(tool: Mapping[str, Any]) -> list[str]: + strategy = tool.get("strategy") + if not isinstance(strategy, dict): + return ["missing strategy record"] + errors: list[str] = [] + if strategy.get("requires_upstream_download") is True: + if not strategy.get("source_url"): + errors.append("missing source URL for upstream download strategy") + if not strategy.get("pinned_version"): + errors.append("missing pinned version for upstream download strategy") + if strategy.get("requires_checksum") is True: + if not strategy.get("checksum_source"): + errors.append("missing checksum source for upstream strategy") + if not strategy.get("checksum_value"): + errors.append("missing checksum value for upstream strategy") + return errors + + +def verify_evidence_payload( + payload: Mapping[str, Any], + *, + contracts: tuple[LinterToolContract, ...] | None = None, +) -> list[str]: + """Return validation errors for one provisioning evidence payload.""" + artifact_entry_id = payload.get("artifact_entry_id") + if not isinstance(artifact_entry_id, str): + return ["missing artifact_entry_id"] + if is_github_generated_source_archive(artifact_entry_id): + return [] + try: + artifact = get_artifact_entry(artifact_entry_id) + except KeyError: + return [f"unknown artifact_entry_id: {artifact_entry_id}"] + + loaded = load_linter_tool_contracts() if contracts is None else contracts + required = [contract for contract in loaded if contract.required_for_full] + tools = _tool_by_id(payload) + errors: list[str] = [] + + if payload.get("schema_version") != EVIDENCE_SCHEMA_VERSION: + errors.append("unsupported or missing schema_version") + if "run_mode" not in payload: + errors.append("missing run_mode") + if "os_context" not in payload: + errors.append("missing os_context") + if "selection_profile" not in payload: + errors.append("missing selection_profile") + + full_complete = payload.get("full_profile_complete") + if artifact.full_provisioning_supported and full_complete is not True: + errors.append( + f"{artifact_entry_id}: Full provisioning evidence is incomplete" + ) + + for contract in required: + tool = tools.get(contract.tool_id) + if tool is None: + errors.append(f"{artifact_entry_id}: missing required tool {contract.tool_id}") + continue + if not _has_probe(tool): + errors.append(f"{artifact_entry_id}/{contract.tool_id}: missing version probe") + if not _has_executable_record(tool): + errors.append( + f"{artifact_entry_id}/{contract.tool_id}: missing executable record" + ) + errors.extend( + f"{artifact_entry_id}/{contract.tool_id}: {error}" + for error in _strategy_errors(tool) + ) + if not artifact.full_provisioning_supported: + continue + if tool.get("selected") is not True: + errors.append(f"{artifact_entry_id}/{contract.tool_id}: required tool not selected") + status = tool.get("status") + if status not in {"already-present", "bundled", "planned", "provisioned"}: + errors.append( + f"{artifact_entry_id}/{contract.tool_id}: invalid required " + f"status {status!r}" + ) + verification = tool.get("verification_result") + if verification not in {"planned", "verified"}: + errors.append( + f"{artifact_entry_id}/{contract.tool_id}: invalid verification " + f"result {verification!r}" + ) + return errors + + +def load_evidence_file(path: Path) -> Mapping[str, Any]: + """Read one evidence JSON file.""" + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"evidence file must contain a JSON object: {path}") + return data + + +def verify_evidence_dir( + evidence_dir: Path, + *, + artifact_entry_id: str | None = None, + all_artifacts: bool = False, + contracts: tuple[LinterToolContract, ...] | None = None, +) -> list[str]: + """Verify F4 provisioning evidence for one artifact or all 21 entries.""" + loaded = load_linter_tool_contracts() if contracts is None else contracts + if all_artifacts: + errors: list[str] = [] + for artifact in ARTIFACT_CONTRACT_ENTRIES: + path = evidence_dir / evidence_filename(artifact.artifact_entry_id) + if not path.is_file(): + errors.append(f"missing evidence file: {path.name}") + continue + errors.extend(verify_evidence_payload(load_evidence_file(path), contracts=loaded)) + for path in sorted(evidence_dir.glob("*.json")): + payload = load_evidence_file(path) + entry_id = payload.get("artifact_entry_id") + if isinstance(entry_id, str) and is_github_generated_source_archive(entry_id): + continue + if isinstance(entry_id, str) and entry_id not in { + item.artifact_entry_id for item in ARTIFACT_CONTRACT_ENTRIES + }: + errors.append(f"unknown evidence artifact entry: {entry_id}") + return errors + + if artifact_entry_id is None: + raise ValueError("artifact_entry_id is required unless all_artifacts=True") + path = evidence_dir / evidence_filename(artifact_entry_id) + if not path.is_file(): + return [f"missing evidence file: {path.name}"] + return verify_evidence_payload(load_evidence_file(path), contracts=loaded) diff --git a/src/ecli/extensions/linters/core/provisioning_contract.py b/src/ecli/extensions/linters/core/provisioning_contract.py new file mode 100644 index 0000000..03e2362 --- /dev/null +++ b/src/ecli/extensions/linters/core/provisioning_contract.py @@ -0,0 +1,202 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Project: Ecli +# File: src/ecli/extensions/linters/core/provisioning_contract.py +# Website: https://www.ecli.io +# Repository: https://github.com/SSobol77/ecli +# PyPI: https://pypi.org/project/ecli-editor/0.0.1/ +# +# Copyright (c) 2026 Siergej Sobolewski +# +# Licensed under the GNU General Public License version 2 only. +# See the LICENSE file in the project root for full license text. + +"""Provider-neutral provisioning contracts for the ECLI F4 Linter Pack. + +This module is deliberately data-only. It has no F4 UI knowledge, no provider +registration, no parser imports, no package-manager calls, and no linter +execution. It defines the typed records used by the planner/verifier layer to +turn per-linter ``package_contract.py`` metadata into artifact-aware evidence. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from ecli.extensions.linters.core.registry import ( + InstallMechanism, + ProvenanceRequirement, +) + + +ProvisioningMode = Literal["dry-run", "verify-only", "provision"] +LinterSelectionProfile = Literal["full", "custom", "minimal"] + +ActionStatus = Literal[ + "already-present", + "bundled", + "failed", + "planned", + "provisioned", + "skipped", +] + +VerificationResult = Literal[ + "not-required", + "planned", + "skipped", + "verified", + "failed", +] + + +@dataclass(frozen=True) +class ArtifactContractEntry: + """One canonical release artifact entry from the 21-entry contract.""" + + index: int + artifact_entry_id: str + name: str + platform: str + output_template: str + full_provisioning_supported: bool = True + minimal_reason: str | None = None + + +@dataclass(frozen=True) +class VersionProbe: + """Executable/version probe contract for a linter tool.""" + + command: tuple[str, ...] + timeout_seconds: float = 10.0 + + +@dataclass(frozen=True) +class LinterToolContract: + """Merged manifest/package contract for one linter tool.""" + + tool_id: str + display_name: str + languages: tuple[str, ...] + install_group: str + tier: str + provider_kind: str + required_for_full: bool + bundled_with_full_install: bool + selected_by_default: bool + executable_names: tuple[str, ...] + version_probe: VersionProbe + allowed_install_mechanisms: tuple[InstallMechanism, ...] + provenance_requirements: tuple[ProvenanceRequirement, ...] + source_url: str | None + pinned_version: str | None + checksum_required_for_downloads: bool + artifact_entry_ids: tuple[str, ...] + delivery_notes: str + + +@dataclass(frozen=True) +class ProvisioningStrategy: + """Chosen artifact/tool provisioning mechanism.""" + + mechanism: InstallMechanism + description: str + requires_network: bool + requires_upstream_download: bool + requires_checksum: bool + source_url: str | None = None + pinned_version: str | None = None + checksum_source: str | None = None + checksum_value: str | None = None + + +@dataclass(frozen=True) +class LinterSelectionOption: + """Installer-renderable selection row for one linter.""" + + tool_id: str + display_name: str + required_for_full: bool + selected_by_default: bool + tier: str + install_group: str + languages: tuple[str, ...] + executable_names: tuple[str, ...] + + +@dataclass(frozen=True) +class InstallerComponentModel: + """Structured component model for installer frontends.""" + + artifact_entry_id: str + selection_profile: LinterSelectionProfile + full_label: str + custom_label: str + options: tuple[LinterSelectionOption, ...] + + +@dataclass(frozen=True) +class ProvisioningProfile: + """Resolved selection profile after include/exclude policy is applied.""" + + requested_profile: LinterSelectionProfile + effective_profile: LinterSelectionProfile + selected_tool_ids: tuple[str, ...] + skipped_tool_ids: tuple[str, ...] + full_profile_complete: bool + custom_profile_reason: str | None = None + + +@dataclass(frozen=True) +class ProvisioningAction: + """Planned or executed action for one linter tool.""" + + tool_id: str + display_name: str + required_for_full: bool + selected: bool + selected_by_default: bool + user_selected: bool + user_opted_out: bool + status: ActionStatus + executable_name: str + executable_path: str | None + planned_path: str + version_command: tuple[str, ...] + observed_version: str | None + planned_version: str | None + strategy: ProvisioningStrategy + verification_result: VerificationResult + errors: tuple[str, ...] = () + warnings: tuple[str, ...] = () + + +@dataclass(frozen=True) +class ProvisioningPlan: + """Artifact-aware provisioning plan for a selected linter profile.""" + + artifact: ArtifactContractEntry + mode: ProvisioningMode + target_dir: str + evidence_dir: str + profile: ProvisioningProfile + component_model: InstallerComponentModel + actions: tuple[ProvisioningAction, ...] + + +@dataclass(frozen=True) +class ProvisioningEvidence: + """Serialized release/install evidence for one artifact provisioning run.""" + + artifact_entry_id: str + run_mode: ProvisioningMode + os_context: dict[str, str] + generated_at: str + ecli_version: str + target_dir: str + selection_profile: LinterSelectionProfile + effective_profile: LinterSelectionProfile + full_profile_complete: bool + custom_profile_reason: str | None + tools: tuple[ProvisioningAction, ...] diff --git a/src/ecli/extensions/linters/core/provisioning_registry.py b/src/ecli/extensions/linters/core/provisioning_registry.py new file mode 100644 index 0000000..eafd1b7 --- /dev/null +++ b/src/ecli/extensions/linters/core/provisioning_registry.py @@ -0,0 +1,351 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Project: Ecli +# File: src/ecli/extensions/linters/core/provisioning_registry.py +# Website: https://www.ecli.io +# Repository: https://github.com/SSobol77/ecli +# PyPI: https://pypi.org/project/ecli-editor/0.0.1/ +# +# Copyright (c) 2026 Siergej Sobolewski +# +# Licensed under the GNU General Public License version 2 only. +# See the LICENSE file in the project root for full license text. + +"""Artifact and linter-contract registry for F4 provisioning. + +The loader reads each linter's ``manifest.py`` and ``package_contract.py`` by +file path. It does not call provider classes, run linters, parse diagnostics, or +invoke package managers. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType + +from ecli.extensions.linters.core.provisioning_contract import ( + ArtifactContractEntry, + LinterToolContract, + VersionProbe, +) +from ecli.extensions.linters.core.registry import ( + CANONICAL_ARTIFACT_ENTRY_IDS, + LinterDefinition, + PackageContract, +) + + +ARTIFACT_CONTRACT_ENTRIES: tuple[ArtifactContractEntry, ...] = ( + ArtifactContractEntry( + 1, + "pypi-wheel", + "PyPI wheel", + "PyPI / Python", + "ecli_editor--py3-none-any.whl", + False, + "Python wheel metadata cannot reliably provision Node, Rust, Go, Zig, " + "Java, or system binaries.", + ), + ArtifactContractEntry( + 2, + "pypi-sdist", + "PyPI source distribution", + "PyPI / Python", + "ecli_editor-.tar.gz", + False, + "Source distribution installs are developer/source installs and may be " + "minimal for non-Python F4 toolchains.", + ), + ArtifactContractEntry( + 3, + "linux-pyinstaller", + "Linux generic PyInstaller executable", + "Linux", + "ecli__linux_x86_64.bin", + ), + ArtifactContractEntry( + 4, + "linux-tarball", + "Linux release tarball", + "Linux", + "ecli__linux_x86_64.tar.gz", + ), + ArtifactContractEntry( + 5, + "deb", + "Debian / Ubuntu `.deb`", + "Linux (Debian/Ubuntu)", + "ecli__linux_x86_64.deb", + ), + ArtifactContractEntry( + 6, + "rpm", + "generic RPM `.rpm`", + "Linux (RPM family)", + "ecli__linux_x86_64.rpm", + ), + ArtifactContractEntry( + 7, + "opensuse-rpm", + "openSUSE / SUSE RPM", + "Linux (openSUSE/SUSE)", + "ecli__opensuse_x86_64.rpm", + ), + ArtifactContractEntry( + 8, + "arch-pkgbuild", + "Arch Linux `PKGBUILD`", + "Linux (Arch)", + "ecli__arch_x86_64.pkg.tar.zst", + ), + ArtifactContractEntry( + 9, + "slackware-txz", + "Slackware `.txz`", + "Linux (Slackware)", + "ecli__slackware_x86_64.txz", + ), + ArtifactContractEntry( + 10, + "appimage", + "AppImage", + "Linux (cross-distro)", + "ecli__linux_x86_64.AppImage", + ), + ArtifactContractEntry( + 11, + "freebsd-pkg", + "FreeBSD `.pkg`", + "FreeBSD", + "ecli__freebsd_x86_64.pkg", + ), + ArtifactContractEntry( + 12, + "freebsd-ports-chroot", + "FreeBSD ports/chroot build path", + "FreeBSD", + "ecli__freebsd_ports_chroot_evidence.tar.gz", + ), + ArtifactContractEntry( + 13, + "macos-app", + "macOS `.app`", + "macOS", + "ecli__macos_universal2_app_evidence.tar.gz", + ), + ArtifactContractEntry( + 14, + "macos-dmg", + "macOS `.dmg`", + "macOS", + "ecli__macos_universal2.dmg", + ), + ArtifactContractEntry( + 15, + "windows-portable-exe", + "Windows portable `.exe`", + "Windows", + "ecli__win_x86_64.exe", + ), + ArtifactContractEntry( + 16, + "windows-nsis-installer", + "Windows NSIS installer `.exe`", + "Windows", + "ecli__win_x86_64_setup.exe", + ), + ArtifactContractEntry( + 17, + "nix-flake", + "Nix flake", + "Nix / NixOS", + "ecli__nix_flake_evidence.tar.gz", + ), + ArtifactContractEntry( + 18, + "nixos-package", + "Nix/NixOS package expression", + "Nix / NixOS", + "ecli__nixos_package_evidence.tar.gz", + ), + ArtifactContractEntry( + 19, + "docker-deb-helper", + "Docker DEB build helper", + "Linux build helper", + "ecli__docker_deb_helper_evidence.tar.gz", + ), + ArtifactContractEntry( + 20, + "docker-rpm-helper", + "Docker RPM build helper", + "Linux build helper", + "ecli__docker_rpm_helper_evidence.tar.gz", + ), + ArtifactContractEntry( + 21, + "gha-release-contract", + "GitHub Actions release/workflow contract map", + "CI / release automation", + "ecli__workflow_contract_evidence.tar.gz", + ), +) + + +LINTER_DIR_ORDER: tuple[str, ...] = ( + "ruff", + "biome", + "markdownlint", + "yamllint", + "shellcheck", + "zig", + "hadolint", + "taplo", + "actionlint", + "clang_tidy", + "cppcheck", + "clang_format", + "java_checkstyle", + "java_pmd", + "java_spotbugs", + "cargo_clippy", + "golangci_lint", + "sqlfluff", + "tflint", + "eslint", + "stylelint", + "oxlint", + "pylint", +) + + +GITHUB_SOURCE_ARCHIVE_IDS = frozenset( + {"Source code (zip)", "Source code (tar.gz)", "source-code-zip", "source-code-tar-gz"} +) + + +def linters_root() -> Path: + """Return the ``src/ecli/extensions/linters`` directory.""" + return Path(__file__).resolve().parents[1] + + +def get_artifact_entry(artifact_entry_id: str) -> ArtifactContractEntry: + """Return one canonical artifact entry by id.""" + for entry in ARTIFACT_CONTRACT_ENTRIES: + if entry.artifact_entry_id == artifact_entry_id: + return entry + raise KeyError(f"unknown artifact entry id: {artifact_entry_id!r}") + + +def is_github_generated_source_archive(artifact_entry_id: str) -> bool: + """Return whether an id/named entry is a GitHub-generated source archive.""" + return artifact_entry_id in GITHUB_SOURCE_ARCHIVE_IDS + + +def _load_module_from_path(path: Path, module_name: str) -> ModuleType: + spec = importlib.util.spec_from_file_location(module_name, path) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load module from {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _load_manifest(path: Path, directory: str) -> LinterDefinition: + module = _load_module_from_path(path, f"_ecli_f4_{directory}_manifest") + manifest = getattr(module, "MANIFEST", None) + if not isinstance(manifest, LinterDefinition): + raise RuntimeError(f"{path} does not define MANIFEST as LinterDefinition") + return manifest + + +def _load_package_contract(path: Path, directory: str) -> PackageContract: + module = _load_module_from_path(path, f"_ecli_f4_{directory}_package_contract") + contract = getattr(module, "PACKAGE_CONTRACT", None) + if not isinstance(contract, PackageContract): + raise RuntimeError(f"{path} does not define PACKAGE_CONTRACT") + return contract + + +def _tool_contract( + manifest: LinterDefinition, package_contract: PackageContract +) -> LinterToolContract: + if package_contract.service_name != manifest.name: + raise RuntimeError( + f"package_contract service_name {package_contract.service_name!r} " + f"does not match manifest name {manifest.name!r}" + ) + if package_contract.bundled_with_full_install != manifest.bundled_with_full_install: + raise RuntimeError( + f"{manifest.name!r} bundled_with_full_install mismatch between " + "manifest.py and package_contract.py" + ) + if tuple(package_contract.artifact_entry_ids) != CANONICAL_ARTIFACT_ENTRY_IDS: + raise RuntimeError( + f"{manifest.name!r} package contract must cover exactly 21 " + "canonical artifact entry ids" + ) + + return LinterToolContract( + tool_id=manifest.name, + display_name=manifest.display_name, + languages=manifest.languages, + install_group=manifest.install_group, + tier=manifest.tier, + provider_kind=manifest.provider_kind, + required_for_full=package_contract.mandatory_for_full_install, + bundled_with_full_install=package_contract.bundled_with_full_install, + selected_by_default=package_contract.mandatory_for_full_install, + executable_names=package_contract.binary_names, + version_probe=VersionProbe(command=package_contract.version_probe), + allowed_install_mechanisms=package_contract.allowed_install_mechanisms, + provenance_requirements=package_contract.provenance_requirements, + source_url=package_contract.source_url or manifest.homepage_url, + pinned_version=package_contract.pinned_version, + checksum_required_for_downloads=( + package_contract.checksum_required_for_downloads + ), + artifact_entry_ids=package_contract.artifact_entry_ids, + delivery_notes=package_contract.delivery_notes, + ) + + +def load_linter_tool_contracts(root: Path | None = None) -> tuple[LinterToolContract, ...]: + """Load every linter manifest/package contract in deterministic order.""" + base = linters_root() if root is None else root + contracts: list[LinterToolContract] = [] + for directory in LINTER_DIR_ORDER: + service_dir = base / directory + manifest_path = service_dir / "manifest.py" + package_contract_path = service_dir / "package_contract.py" + if not manifest_path.is_file(): + raise RuntimeError(f"known linter {directory!r} lacks manifest.py") + if not package_contract_path.is_file(): + raise RuntimeError( + f"known linter {directory!r} lacks package_contract.py" + ) + manifest = _load_manifest(manifest_path, directory) + package_contract = _load_package_contract(package_contract_path, directory) + contracts.append(_tool_contract(manifest, package_contract)) + + tool_ids = [contract.tool_id for contract in contracts] + if len(tool_ids) != len(set(tool_ids)): + raise RuntimeError(f"duplicate linter tool id(s): {tool_ids}") + return tuple(contracts) + + +def required_full_tool_ids( + contracts: tuple[LinterToolContract, ...] | None = None, +) -> tuple[str, ...]: + """Return Full-required tool ids from package_contract.py metadata.""" + loaded = load_linter_tool_contracts() if contracts is None else contracts + return tuple(contract.tool_id for contract in loaded if contract.required_for_full) + + +def optional_tool_ids( + contracts: tuple[LinterToolContract, ...] | None = None, +) -> tuple[str, ...]: + """Return non-Full-required tool ids from package_contract.py metadata.""" + loaded = load_linter_tool_contracts() if contracts is None else contracts + return tuple(contract.tool_id for contract in loaded if not contract.required_for_full) diff --git a/src/ecli/extensions/linters/core/registry.py b/src/ecli/extensions/linters/core/registry.py index 6143b0f..1aea8b4 100644 --- a/src/ecli/extensions/linters/core/registry.py +++ b/src/ecli/extensions/linters/core/registry.py @@ -127,6 +127,83 @@ ProviderKind = Literal["internal", "external"] +CANONICAL_ARTIFACT_ENTRY_IDS: tuple[str, ...] = ( + "pypi-wheel", + "pypi-sdist", + "linux-pyinstaller", + "linux-tarball", + "deb", + "rpm", + "opensuse-rpm", + "arch-pkgbuild", + "slackware-txz", + "appimage", + "freebsd-pkg", + "freebsd-ports-chroot", + "macos-app", + "macos-dmg", + "windows-portable-exe", + "windows-nsis-installer", + "nix-flake", + "nixos-package", + "docker-deb-helper", + "docker-rpm-helper", + "gha-release-contract", +) + +InstallMechanism = Literal[ + "artifact-policy", + "bundled-binary", + "bundled-internal", + "ecli-managed-tools", + "jar-shim", + "language-package-manager", + "nix-derivation", + "os-package-manager", + "toolchain-component", + "verified-upstream-download", +] + +ALLOWED_INSTALL_MECHANISMS: frozenset[str] = frozenset( + { + "artifact-policy", + "bundled-binary", + "bundled-internal", + "ecli-managed-tools", + "jar-shim", + "language-package-manager", + "nix-derivation", + "os-package-manager", + "toolchain-component", + "verified-upstream-download", + } +) + +ProvenanceRequirement = Literal[ + "artifact-entry-id", + "checksum-or-ecli-provenance-when-downloaded", + "deterministic-install-log", + "ecli-version", + "executable-permission", + "pinned-version-when-downloaded", + "source-url-when-downloaded", + "version-probe", +] + +ALLOWED_PROVENANCE_REQUIREMENTS: frozenset[str] = frozenset( + { + "artifact-entry-id", + "checksum-or-ecli-provenance-when-downloaded", + "deterministic-install-log", + "ecli-version", + "executable-permission", + "pinned-version-when-downloaded", + "source-url-when-downloaded", + "version-probe", + } +) + + @dataclass(frozen=True) class LinterDefinition: """Immutable declarative metadata for one ECLI Linter Pack entry. @@ -218,6 +295,55 @@ class PackageContract: binary_names: tuple[str, ...] version_probe: tuple[str, ...] delivery_notes: str + allowed_install_mechanisms: tuple[InstallMechanism, ...] = ( + "artifact-policy", + ) + provenance_requirements: tuple[ProvenanceRequirement, ...] = ( + "artifact-entry-id", + "version-probe", + "deterministic-install-log", + "checksum-or-ecli-provenance-when-downloaded", + ) + source_url: str | None = None + pinned_version: str | None = None + checksum_required_for_downloads: bool = True + artifact_entry_ids: tuple[str, ...] = field( + default_factory=lambda: CANONICAL_ARTIFACT_ENTRY_IDS + ) + + def __post_init__(self) -> None: + """Validate declarative package/provisioning metadata.""" + if not self.service_name: + raise ValueError("package contract service_name must be non-empty") + if not self.binary_names: + raise ValueError( + f"package contract {self.service_name!r} must name a binary" + ) + if not self.version_probe: + raise ValueError( + f"package contract {self.service_name!r} must name a version probe" + ) + unknown_mechanisms = sorted( + set(self.allowed_install_mechanisms) - ALLOWED_INSTALL_MECHANISMS + ) + if unknown_mechanisms: + raise ValueError( + f"package contract {self.service_name!r} uses unknown install " + f"mechanism(s): {unknown_mechanisms}" + ) + unknown_provenance = sorted( + set(self.provenance_requirements) - ALLOWED_PROVENANCE_REQUIREMENTS + ) + if unknown_provenance: + raise ValueError( + f"package contract {self.service_name!r} uses unknown provenance " + f"requirement(s): {unknown_provenance}" + ) + if tuple(self.artifact_entry_ids) != CANONICAL_ARTIFACT_ENTRY_IDS: + raise ValueError( + f"package contract {self.service_name!r} must cover exactly " + "the canonical 21 artifact entry ids" + ) def get_linter(catalog: tuple[LinterDefinition, ...], name: str) -> LinterDefinition: diff --git a/src/ecli/extensions/linters/cppcheck/package_contract.py b/src/ecli/extensions/linters/cppcheck/package_contract.py index a1ecb85..bffabd9 100644 --- a/src/ecli/extensions/linters/cppcheck/package_contract.py +++ b/src/ecli/extensions/linters/cppcheck/package_contract.py @@ -25,4 +25,13 @@ binary_names=("cppcheck",), version_probe=("cppcheck", "--version"), delivery_notes="OS package dependency or bundled binary; bundled with ECLI Full where platform packaging allows.", + allowed_install_mechanisms=( + "os-package-manager", + "language-package-manager", + "ecli-managed-tools", + "bundled-binary", + "verified-upstream-download", + "nix-derivation", + ), + source_url="https://cppcheck.sourceforge.io/", ) diff --git a/src/ecli/extensions/linters/eslint/package_contract.py b/src/ecli/extensions/linters/eslint/package_contract.py index 401a8b1..dc42229 100644 --- a/src/ecli/extensions/linters/eslint/package_contract.py +++ b/src/ecli/extensions/linters/eslint/package_contract.py @@ -25,4 +25,10 @@ binary_names=("eslint",), version_probe=("eslint", "--version"), delivery_notes="Optional npm package; legacy fallback, not part of the default ECLI Full bundle.", + allowed_install_mechanisms=( + "language-package-manager", + "ecli-managed-tools", + "nix-derivation", + ), + source_url="https://eslint.org/", ) diff --git a/src/ecli/extensions/linters/golangci_lint/package_contract.py b/src/ecli/extensions/linters/golangci_lint/package_contract.py index 25d4ae1..4df676f 100644 --- a/src/ecli/extensions/linters/golangci_lint/package_contract.py +++ b/src/ecli/extensions/linters/golangci_lint/package_contract.py @@ -25,4 +25,12 @@ binary_names=("golangci-lint",), version_probe=("golangci-lint", "--version"), delivery_notes="Standalone Go binary; bundled with ECLI Full where platform packaging allows.", + allowed_install_mechanisms=( + "os-package-manager", + "ecli-managed-tools", + "bundled-binary", + "verified-upstream-download", + "nix-derivation", + ), + source_url="https://golangci-lint.run/", ) diff --git a/src/ecli/extensions/linters/hadolint/package_contract.py b/src/ecli/extensions/linters/hadolint/package_contract.py index 17287b4..69f99a5 100644 --- a/src/ecli/extensions/linters/hadolint/package_contract.py +++ b/src/ecli/extensions/linters/hadolint/package_contract.py @@ -25,4 +25,12 @@ binary_names=("hadolint",), version_probe=("hadolint", "--version"), delivery_notes="Standalone Haskell binary; bundled with ECLI Full where platform packaging allows.", + allowed_install_mechanisms=( + "os-package-manager", + "ecli-managed-tools", + "bundled-binary", + "verified-upstream-download", + "nix-derivation", + ), + source_url="https://github.com/hadolint/hadolint", ) diff --git a/src/ecli/extensions/linters/java_checkstyle/package_contract.py b/src/ecli/extensions/linters/java_checkstyle/package_contract.py index b448021..6c3824e 100644 --- a/src/ecli/extensions/linters/java_checkstyle/package_contract.py +++ b/src/ecli/extensions/linters/java_checkstyle/package_contract.py @@ -25,4 +25,12 @@ binary_names=("checkstyle",), version_probe=("checkstyle", "--version"), delivery_notes="Standalone jar or Maven/Gradle plugin; bundled with ECLI Full where platform packaging allows.", + allowed_install_mechanisms=( + "jar-shim", + "os-package-manager", + "ecli-managed-tools", + "verified-upstream-download", + "nix-derivation", + ), + source_url="https://checkstyle.org/", ) diff --git a/src/ecli/extensions/linters/java_pmd/package_contract.py b/src/ecli/extensions/linters/java_pmd/package_contract.py index 78fd577..7a00fd1 100644 --- a/src/ecli/extensions/linters/java_pmd/package_contract.py +++ b/src/ecli/extensions/linters/java_pmd/package_contract.py @@ -25,4 +25,12 @@ binary_names=("pmd",), version_probe=("pmd", "--version"), delivery_notes="Standalone distribution or Maven/Gradle plugin; bundled with ECLI Full where platform packaging allows.", + allowed_install_mechanisms=( + "jar-shim", + "os-package-manager", + "ecli-managed-tools", + "verified-upstream-download", + "nix-derivation", + ), + source_url="https://pmd.github.io/", ) diff --git a/src/ecli/extensions/linters/java_spotbugs/package_contract.py b/src/ecli/extensions/linters/java_spotbugs/package_contract.py index ccbe915..c0096f5 100644 --- a/src/ecli/extensions/linters/java_spotbugs/package_contract.py +++ b/src/ecli/extensions/linters/java_spotbugs/package_contract.py @@ -25,4 +25,12 @@ binary_names=("spotbugs",), version_probe=("spotbugs", "-version"), delivery_notes="Standalone distribution or Maven/Gradle plugin; workspace/project-scoped, requires compiled classes (design doc section 12.3).", + allowed_install_mechanisms=( + "jar-shim", + "os-package-manager", + "ecli-managed-tools", + "verified-upstream-download", + "nix-derivation", + ), + source_url="https://spotbugs.github.io/", ) diff --git a/src/ecli/extensions/linters/markdownlint/package_contract.py b/src/ecli/extensions/linters/markdownlint/package_contract.py index 77e000c..909858c 100644 --- a/src/ecli/extensions/linters/markdownlint/package_contract.py +++ b/src/ecli/extensions/linters/markdownlint/package_contract.py @@ -25,4 +25,11 @@ binary_names=("markdownlint-cli2",), version_probe=("markdownlint-cli2", "--version"), delivery_notes="npm package; bundled with ECLI Full where platform packaging allows.", + allowed_install_mechanisms=( + "language-package-manager", + "ecli-managed-tools", + "bundled-binary", + "nix-derivation", + ), + source_url="https://github.com/DavidAnson/markdownlint-cli2", ) diff --git a/src/ecli/extensions/linters/oxlint/package_contract.py b/src/ecli/extensions/linters/oxlint/package_contract.py index cfd5108..8f298f6 100644 --- a/src/ecli/extensions/linters/oxlint/package_contract.py +++ b/src/ecli/extensions/linters/oxlint/package_contract.py @@ -25,4 +25,12 @@ binary_names=("oxlint",), version_probe=("oxlint", "--version"), delivery_notes="Optional npm package; not part of the default ECLI Full bundle.", + allowed_install_mechanisms=( + "language-package-manager", + "ecli-managed-tools", + "bundled-binary", + "verified-upstream-download", + "nix-derivation", + ), + source_url="https://oxc.rs/docs/guide/usage/linter.html", ) diff --git a/src/ecli/extensions/linters/pylint/package_contract.py b/src/ecli/extensions/linters/pylint/package_contract.py index 0947939..8ca415e 100644 --- a/src/ecli/extensions/linters/pylint/package_contract.py +++ b/src/ecli/extensions/linters/pylint/package_contract.py @@ -25,4 +25,11 @@ binary_names=("pylint",), version_probe=("pylint", "--version"), delivery_notes="Optional Python package (pip); not part of the default ECLI Full bundle.", + allowed_install_mechanisms=( + "language-package-manager", + "os-package-manager", + "ecli-managed-tools", + "nix-derivation", + ), + source_url="https://pylint.pycqa.org/", ) diff --git a/src/ecli/extensions/linters/ruff/package_contract.py b/src/ecli/extensions/linters/ruff/package_contract.py index 332388e..8997b61 100644 --- a/src/ecli/extensions/linters/ruff/package_contract.py +++ b/src/ecli/extensions/linters/ruff/package_contract.py @@ -28,4 +28,13 @@ "Bundled internally with ECLI (provider_kind=internal); not an " "external package dependency for any release artifact." ), + allowed_install_mechanisms=("bundled-internal",), + provenance_requirements=( + "artifact-entry-id", + "ecli-version", + "version-probe", + "deterministic-install-log", + ), + source_url="https://docs.astral.sh/ruff/", + checksum_required_for_downloads=False, ) diff --git a/src/ecli/extensions/linters/shellcheck/package_contract.py b/src/ecli/extensions/linters/shellcheck/package_contract.py index 1b0d8ac..71e0aec 100644 --- a/src/ecli/extensions/linters/shellcheck/package_contract.py +++ b/src/ecli/extensions/linters/shellcheck/package_contract.py @@ -25,4 +25,12 @@ binary_names=("shellcheck",), version_probe=("shellcheck", "--version"), delivery_notes="OS package dependency or bundled binary; bundled with ECLI Full where platform packaging allows.", + allowed_install_mechanisms=( + "os-package-manager", + "ecli-managed-tools", + "bundled-binary", + "verified-upstream-download", + "nix-derivation", + ), + source_url="https://www.shellcheck.net/", ) diff --git a/src/ecli/extensions/linters/sqlfluff/package_contract.py b/src/ecli/extensions/linters/sqlfluff/package_contract.py index a776415..4e7beda 100644 --- a/src/ecli/extensions/linters/sqlfluff/package_contract.py +++ b/src/ecli/extensions/linters/sqlfluff/package_contract.py @@ -25,4 +25,11 @@ binary_names=("sqlfluff",), version_probe=("sqlfluff", "--version"), delivery_notes="Python package (pip); bundled with ECLI Full where platform packaging allows.", + allowed_install_mechanisms=( + "language-package-manager", + "os-package-manager", + "ecli-managed-tools", + "nix-derivation", + ), + source_url="https://sqlfluff.com/", ) diff --git a/src/ecli/extensions/linters/stylelint/package_contract.py b/src/ecli/extensions/linters/stylelint/package_contract.py index f82b541..460799f 100644 --- a/src/ecli/extensions/linters/stylelint/package_contract.py +++ b/src/ecli/extensions/linters/stylelint/package_contract.py @@ -25,4 +25,10 @@ binary_names=("stylelint",), version_probe=("stylelint", "--version"), delivery_notes="Optional npm package; not part of the default ECLI Full bundle.", + allowed_install_mechanisms=( + "language-package-manager", + "ecli-managed-tools", + "nix-derivation", + ), + source_url="https://stylelint.io/", ) diff --git a/src/ecli/extensions/linters/taplo/package_contract.py b/src/ecli/extensions/linters/taplo/package_contract.py index 3c6addd..8ab8fd9 100644 --- a/src/ecli/extensions/linters/taplo/package_contract.py +++ b/src/ecli/extensions/linters/taplo/package_contract.py @@ -25,4 +25,13 @@ binary_names=("taplo",), version_probe=("taplo", "--version"), delivery_notes="Rust crate (cargo install) or standalone binary; bundled with ECLI Full where platform packaging allows.", + allowed_install_mechanisms=( + "language-package-manager", + "os-package-manager", + "ecli-managed-tools", + "bundled-binary", + "verified-upstream-download", + "nix-derivation", + ), + source_url="https://taplo.tamasfe.dev/", ) diff --git a/src/ecli/extensions/linters/tflint/package_contract.py b/src/ecli/extensions/linters/tflint/package_contract.py index acc90ad..ec5374e 100644 --- a/src/ecli/extensions/linters/tflint/package_contract.py +++ b/src/ecli/extensions/linters/tflint/package_contract.py @@ -25,4 +25,12 @@ binary_names=("tflint",), version_probe=("tflint", "--version"), delivery_notes="Standalone Go binary; bundled with ECLI Full where platform packaging allows.", + allowed_install_mechanisms=( + "os-package-manager", + "ecli-managed-tools", + "bundled-binary", + "verified-upstream-download", + "nix-derivation", + ), + source_url="https://github.com/terraform-linters/tflint", ) diff --git a/src/ecli/extensions/linters/yamllint/package_contract.py b/src/ecli/extensions/linters/yamllint/package_contract.py index e50df2a..404bd97 100644 --- a/src/ecli/extensions/linters/yamllint/package_contract.py +++ b/src/ecli/extensions/linters/yamllint/package_contract.py @@ -25,4 +25,11 @@ binary_names=("yamllint",), version_probe=("yamllint", "--version"), delivery_notes="Python package (pip); bundled with ECLI Full where platform packaging allows.", + allowed_install_mechanisms=( + "language-package-manager", + "os-package-manager", + "ecli-managed-tools", + "nix-derivation", + ), + source_url="https://yamllint.readthedocs.io/", ) diff --git a/src/ecli/extensions/linters/zig/package_contract.py b/src/ecli/extensions/linters/zig/package_contract.py index 7aab7c1..c075c30 100644 --- a/src/ecli/extensions/linters/zig/package_contract.py +++ b/src/ecli/extensions/linters/zig/package_contract.py @@ -25,4 +25,12 @@ binary_names=("zig",), version_probe=("zig", "version"), delivery_notes="Zig toolchain download or OS package manager; bundled with ECLI Full where platform packaging allows.", + allowed_install_mechanisms=( + "os-package-manager", + "ecli-managed-tools", + "bundled-binary", + "verified-upstream-download", + "nix-derivation", + ), + source_url="https://ziglang.org/", ) diff --git a/tests/extensions/linters/test_provisioning_contract.py b/tests/extensions/linters/test_provisioning_contract.py new file mode 100644 index 0000000..fc3b462 --- /dev/null +++ b/tests/extensions/linters/test_provisioning_contract.py @@ -0,0 +1,180 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Project: Ecli +# File: tests/extensions/linters/test_provisioning_contract.py +# Website: https://www.ecli.io +# Repository: https://github.com/SSobol77/ecli +# PyPI: https://pypi.org/project/ecli-editor/0.0.1/ +# +# Copyright (c) 2026 Siergej Sobolewski +# +# Licensed under the GNU General Public License version 2 only. +# See the LICENSE file in the project root for full license text. + +from __future__ import annotations + +from pathlib import Path + +from ecli.extensions.linters.core.provisioning import ( + build_component_model, + build_provisioning_plan, + evidence_to_dict, + plan_has_release_blocking_failure, + plan_to_evidence, + verify_evidence_payload, +) +from ecli.extensions.linters.core.provisioning_registry import ( + ARTIFACT_CONTRACT_ENTRIES, + get_artifact_entry, + load_linter_tool_contracts, + optional_tool_ids, + required_full_tool_ids, +) +from ecli.extensions.linters.core.registry import CANONICAL_ARTIFACT_ENTRY_IDS + + +REQUIRED_FULL_TOOL_IDS = ( + "ruff", + "biome", + "markdownlint-cli2", + "yamllint", + "shellcheck", + "zig", + "hadolint", + "taplo", + "actionlint", + "clang-tidy", + "cppcheck", + "clang-format", + "checkstyle", + "pmd", + "spotbugs", + "cargo-clippy", + "golangci-lint", + "sqlfluff", + "tflint", +) + + +def test_artifact_registry_has_exactly_twenty_one_entries() -> None: + assert len(ARTIFACT_CONTRACT_ENTRIES) == 21 + assert [entry.index for entry in ARTIFACT_CONTRACT_ENTRIES] == list(range(1, 22)) + assert tuple(entry.artifact_entry_id for entry in ARTIFACT_CONTRACT_ENTRIES) == ( + CANONICAL_ARTIFACT_ENTRY_IDS + ) + + +def test_required_full_baseline_comes_from_package_contracts() -> None: + contracts = load_linter_tool_contracts() + assert required_full_tool_ids(contracts) == REQUIRED_FULL_TOOL_IDS + assert set(optional_tool_ids(contracts)) == { + "eslint", + "stylelint", + "oxlint", + "pylint", + } + + +def test_package_contracts_have_provisioning_metadata() -> None: + for contract in load_linter_tool_contracts(): + assert contract.allowed_install_mechanisms + assert contract.provenance_requirements + assert contract.source_url is not None + assert contract.version_probe.command + assert contract.artifact_entry_ids == CANONICAL_ARTIFACT_ENTRY_IDS + + +def test_component_model_selects_every_full_required_tool_by_default() -> None: + contracts = load_linter_tool_contracts() + model = build_component_model(get_artifact_entry("deb"), "full", contracts) + by_id = {option.tool_id: option for option in model.options} + + for tool_id in REQUIRED_FULL_TOOL_IDS: + assert by_id[tool_id].required_for_full is True + assert by_id[tool_id].selected_by_default is True + for tool_id in ("eslint", "stylelint", "oxlint", "pylint"): + assert by_id[tool_id].required_for_full is False + assert by_id[tool_id].selected_by_default is False + + +def test_excluding_required_full_tool_marks_plan_custom_partial(tmp_path: Path) -> None: + plan = build_provisioning_plan( + artifact_entry_id="deb", + target_dir=tmp_path / "target", + evidence_dir=tmp_path / "evidence", + mode="dry-run", + profile="full", + exclude_tools=("clang-format",), + ) + + assert plan.profile.effective_profile == "custom" + assert plan.profile.full_profile_complete is False + assert "clang-format" in (plan.profile.custom_profile_reason or "") + clang_format = next( + action for action in plan.actions if action.tool_id == "clang-format" + ) + assert clang_format.selected is False + assert clang_format.user_opted_out is True + + +def test_minimal_profile_selects_only_internal_ruff(tmp_path: Path) -> None: + plan = build_provisioning_plan( + artifact_entry_id="deb", + target_dir=tmp_path / "target", + evidence_dir=tmp_path / "evidence", + mode="dry-run", + profile="minimal", + ) + + selected = {action.tool_id for action in plan.actions if action.selected} + assert selected == {"ruff"} + assert plan.profile.full_profile_complete is False + + +def test_deb_dry_run_evidence_satisfies_full_release_contract(tmp_path: Path) -> None: + plan = build_provisioning_plan( + artifact_entry_id="deb", + target_dir=tmp_path / "target", + evidence_dir=tmp_path / "evidence", + mode="dry-run", + profile="full", + ) + evidence = plan_to_evidence( + plan, + ecli_version="0.2.3", + timestamp="1970-01-01T00:00:00Z", + ) + payload = evidence_to_dict(evidence) + + assert plan.profile.full_profile_complete is True + assert plan_has_release_blocking_failure(plan) is False + assert verify_evidence_payload(payload) == [] + + +def test_pypi_full_evidence_records_documented_minimal_constraint( + tmp_path: Path, +) -> None: + plan = build_provisioning_plan( + artifact_entry_id="pypi-wheel", + target_dir=tmp_path / "target", + evidence_dir=tmp_path / "evidence", + mode="dry-run", + profile="full", + ) + payload = evidence_to_dict( + plan_to_evidence( + plan, + ecli_version="0.2.3", + timestamp="1970-01-01T00:00:00Z", + ) + ) + + assert plan.profile.effective_profile == "minimal" + assert plan.profile.full_profile_complete is False + assert "Python wheel metadata" in (plan.profile.custom_profile_reason or "") + assert verify_evidence_payload(payload) == [] + + +def test_verifier_ignores_github_generated_source_archives() -> None: + assert verify_evidence_payload({"artifact_entry_id": "Source code (zip)"}) == [] + assert verify_evidence_payload({"artifact_entry_id": "Source code (tar.gz)"}) == [] diff --git a/tests/packaging/test_f4_linter_provisioning_scripts.py b/tests/packaging/test_f4_linter_provisioning_scripts.py new file mode 100644 index 0000000..41040f3 --- /dev/null +++ b/tests/packaging/test_f4_linter_provisioning_scripts.py @@ -0,0 +1,161 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Project: Ecli +# File: tests/packaging/test_f4_linter_provisioning_scripts.py +# Website: https://www.ecli.io +# Repository: https://github.com/SSobol77/ecli +# PyPI: https://pypi.org/project/ecli-editor/0.0.1/ +# +# Copyright (c) 2026 Siergej Sobolewski +# +# Licensed under the GNU General Public License version 2 only. +# See the LICENSE file in the project root for full license text. + +from __future__ import annotations + +import json +from pathlib import Path +from types import ModuleType + +import pytest +from conftest import load_script_module + + +@pytest.fixture +def provision_script(repo_root: Path) -> ModuleType: + return load_script_module( + repo_root, + "scripts/provision_f4_linters.py", + "provision_f4_linters_script", + ) + + +@pytest.fixture +def verify_script(repo_root: Path) -> ModuleType: + return load_script_module( + repo_root, + "scripts/verify_f4_linter_provisioning.py", + "verify_f4_linter_provisioning_script", + ) + + +def test_list_selection_options_outputs_required_tools_selected( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + provision_script: ModuleType, +) -> None: + rc = provision_script.main( + [ + "--artifact", + "deb", + "--target-dir", + str(tmp_path / "target"), + "--evidence-dir", + str(tmp_path / "evidence"), + "--mode", + "dry-run", + "--list-selection-options", + "--json", + ] + ) + + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + options = payload["artifacts"][0]["options"] + by_id = {option["tool_id"]: option for option in options} + for tool_id in ( + "clang-format", + "spotbugs", + "golangci-lint", + "sqlfluff", + "tflint", + ): + assert by_id[tool_id]["required_for_full"] is True + assert by_id[tool_id]["selected_by_default"] is True + + +def test_dry_run_deb_writes_evidence_and_verifier_accepts_it( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + provision_script: ModuleType, + verify_script: ModuleType, +) -> None: + monkeypatch.setenv( + "ECLI_F4_PROVISIONING_TIMESTAMP", + "1970-01-01T00:00:00Z", + ) + evidence_dir = tmp_path / "evidence" + rc = provision_script.main( + [ + "--artifact", + "deb", + "--target-dir", + str(tmp_path / "target"), + "--evidence-dir", + str(evidence_dir), + "--mode", + "dry-run", + "--json", + ] + ) + + assert rc == 0 + evidence = evidence_dir / "f4-linter-provisioning-deb.json" + assert evidence.is_file() + payload = json.loads(evidence.read_text(encoding="utf-8")) + assert payload["artifact_entry_id"] == "deb" + assert payload["full_profile_complete"] is True + assert payload["summary"]["required_total"] == 19 + assert ( + verify_script.main(["--artifact", "deb", "--evidence-dir", str(evidence_dir)]) + == 0 + ) + + +def test_all_artifacts_dry_run_writes_twenty_one_evidence_files( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + provision_script: ModuleType, + verify_script: ModuleType, +) -> None: + monkeypatch.setenv( + "ECLI_F4_PROVISIONING_TIMESTAMP", + "1970-01-01T00:00:00Z", + ) + evidence_dir = tmp_path / "evidence-all" + rc = provision_script.main( + [ + "--all-artifacts", + "--target-dir", + str(tmp_path / "target"), + "--evidence-dir", + str(evidence_dir), + "--mode", + "dry-run", + "--json", + ] + ) + + assert rc == 0 + assert len(list(evidence_dir.glob("f4-linter-provisioning-*.json"))) == 21 + assert ( + verify_script.main(["--all-artifacts", "--evidence-dir", str(evidence_dir)]) + == 0 + ) + + +def test_verify_fails_when_required_artifact_evidence_is_missing( + tmp_path: Path, + verify_script: ModuleType, +) -> None: + assert ( + verify_script.main( + [ + "--artifact", + "deb", + "--evidence-dir", + str(tmp_path / "missing"), + ] + ) + == 2 + ) diff --git a/tests/packaging/test_packaging_release_contract.py b/tests/packaging/test_packaging_release_contract.py index e83159f..f17d822 100644 --- a/tests/packaging/test_packaging_release_contract.py +++ b/tests/packaging/test_packaging_release_contract.py @@ -41,6 +41,8 @@ "scripts/verify_artifact.py", "scripts/verify_release_assets.py", "scripts/verify_runtime.py", + "scripts/provision_f4_linters.py", + "scripts/verify_f4_linter_provisioning.py", "scripts/check_log_invariant.py", ) From c287e01c06c685ef9e43b690659bb6ca45991de9 Mon Sep 17 00:00:00 2001 From: SSobol77 Date: Thu, 9 Jul 2026 10:16:11 +0200 Subject: [PATCH 2/3] fix: harden F4 provisioning paths for SonarCloud --- scripts/provision_f4_linters.py | 149 ++- .../extensions/linters/core/provisioning.py | 897 +++++++++++++----- .../linters/test_provisioning_contract.py | 77 ++ .../test_f4_linter_provisioning_scripts.py | 42 + 4 files changed, 873 insertions(+), 292 deletions(-) diff --git a/scripts/provision_f4_linters.py b/scripts/provision_f4_linters.py index 701b192..ae8ad37 100755 --- a/scripts/provision_f4_linters.py +++ b/scripts/provision_f4_linters.py @@ -39,6 +39,7 @@ from ecli.extensions.linters.core.provisioning import ( # noqa: E402 build_component_model, build_provisioning_plan, + canonical_artifact_entry_id, component_model_to_dict, evidence_to_dict, plan_has_release_blocking_failure, @@ -140,7 +141,7 @@ def build_parser() -> argparse.ArgumentParser: def _artifact_ids(args: argparse.Namespace) -> tuple[str, ...]: if args.all_artifacts: return tuple(entry.artifact_entry_id for entry in ARTIFACT_CONTRACT_ENTRIES) - return (args.artifact,) + return (canonical_artifact_entry_id(args.artifact),) def _print_human(paths: list[Path], failed: bool) -> None: @@ -151,7 +152,16 @@ def _print_human(paths: list[Path], failed: bool) -> None: def main(argv: list[str] | None = None) -> int: - args = build_parser().parse_args(argv) + parser = build_parser() + args = parser.parse_args(argv) + try: + return _run(args) + except (KeyError, ValueError, OSError) as exc: + print(f"{parser.prog}: error: {exc}", file=sys.stderr) + return EXIT_INVALID + + +def _run(args: argparse.Namespace) -> int: contracts = load_linter_tool_contracts() target_dir = Path(args.target_dir) evidence_dir = Path(args.evidence_dir) @@ -159,58 +169,121 @@ def main(argv: list[str] | None = None) -> int: artifact_ids = _artifact_ids(args) if args.list_selection_options: - models: list[dict[str, Any]] = [] - for artifact_id in artifact_ids: - artifact = get_artifact_entry(artifact_id) - models.append( - component_model_to_dict( - build_component_model( - artifact, - args.profile, - contracts, - ) - ) - ) - if args.json: - print(json.dumps({"artifacts": models}, indent=2, sort_keys=True)) - else: - for model in models: - print(f"{model['artifact_entry_id']}: {model['full_label']}") - for option in model["options"]: - mark = "x" if option["selected_by_default"] else " " - required = ( - "required" if option["required_for_full"] else option["tier"] - ) - print(f" [{mark}] {option['tool_id']} ({required})") + _emit_selection_options(args, artifact_ids, contracts) return EXIT_OK version = read_project_version(ROOT) + output, paths, failed = _write_artifact_evidence( + args, + artifact_ids, + contracts, + target_dir, + evidence_dir, + selection_path, + version, + ) + _emit_provisioning_output(args, output, paths, failed) + return EXIT_PROVISIONING_FAILED if failed else EXIT_OK + + +def _selection_models( + artifact_ids: tuple[str, ...], + profile: str, + contracts: tuple[Any, ...], +) -> list[dict[str, Any]]: + return [ + component_model_to_dict( + build_component_model( + get_artifact_entry(artifact_id), + profile, + contracts, + ) + ) + for artifact_id in artifact_ids + ] + + +def _emit_selection_options( + args: argparse.Namespace, + artifact_ids: tuple[str, ...], + contracts: tuple[Any, ...], +) -> None: + models = _selection_models(artifact_ids, args.profile, contracts) + if args.json: + print(json.dumps({"artifacts": models}, indent=2, sort_keys=True)) + return + for model in models: + _print_selection_model(model) + + +def _print_selection_model(model: dict[str, Any]) -> None: + print(f"{model['artifact_entry_id']}: {model['full_label']}") + for option in model["options"]: + mark = "x" if option["selected_by_default"] else " " + required = "required" if option["required_for_full"] else option["tier"] + print(f" [{mark}] {option['tool_id']} ({required})") + + +def _write_artifact_evidence( + args: argparse.Namespace, + artifact_ids: tuple[str, ...], + contracts: tuple[Any, ...], + target_dir: Path, + evidence_dir: Path, + selection_path: Path | None, + version: str, +) -> tuple[list[dict[str, Any]], list[Path], bool]: output: list[dict[str, Any]] = [] paths: list[Path] = [] failed = False for artifact_id in artifact_ids: - plan = build_provisioning_plan( - artifact_entry_id=artifact_id, - target_dir=target_dir, - evidence_dir=evidence_dir, - mode=args.mode, - profile=args.profile, - include_tools=args.include_tool, - exclude_tools=args.exclude_tool, - selection_json=selection_path, - allow_network=args.allow_network, - allow_upstream_downloads=args.allow_upstream_downloads, - contracts=contracts, + plan = _build_plan_for_artifact( + args, + artifact_id, + target_dir, + evidence_dir, + selection_path, + contracts, ) paths.append(write_evidence(plan, ecli_version=version)) output.append(evidence_to_dict(plan_to_evidence(plan, ecli_version=version))) failed = failed or plan_has_release_blocking_failure(plan) + return output, paths, failed + +def _build_plan_for_artifact( + args: argparse.Namespace, + artifact_id: str, + target_dir: Path, + evidence_dir: Path, + selection_path: Path | None, + contracts: tuple[Any, ...], +) -> Any: + return build_provisioning_plan( + artifact_entry_id=artifact_id, + target_dir=target_dir, + evidence_dir=evidence_dir, + mode=args.mode, + profile=args.profile, + include_tools=args.include_tool, + exclude_tools=args.exclude_tool, + selection_json=selection_path, + allow_network=args.allow_network, + allow_upstream_downloads=args.allow_upstream_downloads, + contracts=contracts, + ) + + +def _emit_provisioning_output( + args: argparse.Namespace, + output: list[dict[str, Any]], + paths: list[Path], + failed: bool, +) -> None: if args.json: print(json.dumps({"artifacts": output}, indent=2, sort_keys=True)) else: _print_human(paths, failed) - return EXIT_PROVISIONING_FAILED if failed else EXIT_OK if __name__ == "__main__": diff --git a/src/ecli/extensions/linters/core/provisioning.py b/src/ecli/extensions/linters/core/provisioning.py index eb077ac..12ffa17 100644 --- a/src/ecli/extensions/linters/core/provisioning.py +++ b/src/ecli/extensions/linters/core/provisioning.py @@ -28,9 +28,9 @@ import subprocess import tomllib from collections.abc import Iterable, Mapping -from dataclasses import asdict +from dataclasses import asdict, dataclass from datetime import UTC, datetime -from pathlib import Path +from pathlib import Path, PurePosixPath, PureWindowsPath from typing import Any from ecli.extensions.linters.core.provisioning_contract import ( @@ -86,11 +86,141 @@ } ) NIX_ARTIFACT_IDS = frozenset({"nix-flake", "nixos-package"}) +CANONICAL_ARTIFACT_ENTRY_IDS = frozenset( + entry.artifact_entry_id for entry in ARTIFACT_CONTRACT_ENTRIES +) +_SELECTION_INCLUDE_KEYS = ( + "include", + "include_tool", + "include_tools", + "selected_tools", +) +_SELECTION_EXCLUDE_KEYS = ( + "exclude", + "exclude_tool", + "exclude_tools", + "skipped_tools", +) + + +@dataclass(frozen=True) +class _ActionOutcome: + status: ActionStatus + verification_result: VerificationResult + observed_version: str | None = None + planned_version: str | None = None + errors: tuple[str, ...] = () + warnings: tuple[str, ...] = () + + +@dataclass(frozen=True) +class _ActionRequest: + contract: LinterToolContract + artifact: ArtifactContractEntry + profile: ProvisioningProfile + mode: ProvisioningMode + target_dir: Path + allow_network: bool + allow_upstream_downloads: bool + include_tools: set[str] + exclude_tools: set[str] + + +@dataclass(frozen=True) +class _PlanRequest: + artifact_entry_id: str + target_dir: Path + evidence_dir: Path + mode: ProvisioningMode + profile: LinterSelectionProfile + include_tools: Iterable[str] + exclude_tools: Iterable[str] + selection_json: Path | None + allow_network: bool + allow_upstream_downloads: bool + contracts: tuple[LinterToolContract, ...] | None + + +def _contains_parent_reference(path: Path) -> bool: + return ".." in path.parts + + +def _looks_like_path(value: str) -> bool: + posix = PurePosixPath(value) + windows = PureWindowsPath(value) + if posix.is_absolute() or windows.is_absolute(): + return True + if "/" in value or "\\" in value: + return True + return any(part in {"", ".", ".."} for part in (*posix.parts, *windows.parts)) + + +def canonical_artifact_entry_id(artifact_entry_id: str) -> str: + """Return a canonical artifact id or reject path-like values.""" + if not isinstance(artifact_entry_id, str) or not artifact_entry_id: + raise ValueError("artifact_entry_id must be a non-empty string") + if _looks_like_path(artifact_entry_id): + raise ValueError( + f"artifact_entry_id must be a canonical registry id: {artifact_entry_id!r}" + ) + if artifact_entry_id not in CANONICAL_ARTIFACT_ENTRY_IDS: + raise KeyError(f"unknown artifact entry id: {artifact_entry_id!r}") + return artifact_entry_id + + +def _canonical_artifact_entry(artifact_entry_id: str) -> ArtifactContractEntry: + return get_artifact_entry(canonical_artifact_entry_id(artifact_entry_id)) + + +def _safe_base_dir(path: Path) -> Path: + return path.expanduser().resolve(strict=False) + + +def _safe_json_file(path: Path) -> Path: + if _contains_parent_reference(path): + raise ValueError(f"JSON path must not contain parent traversal: {path}") + resolved = path.expanduser().resolve(strict=True) + if resolved.suffix.lower() != ".json" or not resolved.is_file(): + raise ValueError(f"expected an existing JSON file: {path}") + return resolved + + +def _validate_relative_path_part(value: str, label: str) -> str: + if not isinstance(value, str) or not value: + raise ValueError(f"{label} must be a non-empty path segment") + if _looks_like_path(value): + raise ValueError(f"{label} must be a relative path segment: {value!r}") + return value + + +def _ensure_child_path(base_dir: Path, child_path: Path) -> Path: + try: + child_path.relative_to(base_dir) + except ValueError as exc: + raise ValueError(f"path escapes base directory: {child_path}") from exc + return child_path + + +def _safe_child_path(base_dir: Path, *parts: str) -> Path: + base = _safe_base_dir(base_dir) + child = base + for index, part in enumerate(parts, start=1): + child = child / _validate_relative_path_part(part, f"path part {index}") + return _ensure_child_path(base, child.resolve(strict=False)) + + +def evidence_path_for_artifact(evidence_dir: Path, artifact_entry_id: str) -> Path: + """Return the safe evidence path for one canonical artifact.""" + return _safe_child_path( + evidence_dir, + evidence_filename(canonical_artifact_entry_id(artifact_entry_id)), + ) def evidence_filename(artifact_entry_id: str) -> str: """Return the deterministic evidence filename for an artifact entry.""" - return f"f4-linter-provisioning-{artifact_entry_id}.json" + canonical_id = canonical_artifact_entry_id(artifact_entry_id) + return f"f4-linter-provisioning-{canonical_id}.json" def read_project_version(root: Path) -> str: @@ -146,33 +276,50 @@ def build_component_model( ) +def _tool_id_set(values: Iterable[Any]) -> set[str]: + return {str(item) for item in values} + + +def _selection_values(data: Mapping[str, Any], keys: Iterable[str]) -> set[str]: + selected: set[str] = set() + for key in keys: + values = data.get(key) + if isinstance(values, list): + selected.update(_tool_id_set(values)) + return selected + + +def _merge_tools_mapping( + tools: Any, + include: set[str], + exclude: set[str], +) -> None: + if not isinstance(tools, dict): + return + for tool_id, selected in tools.items(): + if selected is True: + include.add(str(tool_id)) + elif selected is False: + exclude.add(str(tool_id)) + + +def _load_selection_json(path: Path) -> Any: + safe_path = _safe_json_file(path) + return json.loads(safe_path.read_text(encoding="utf-8")) + + def _selection_json(path: Path | None) -> tuple[set[str], set[str]]: if path is None: return set(), set() - data = json.loads(path.read_text(encoding="utf-8")) - include: set[str] = set() - exclude: set[str] = set() + data = _load_selection_json(path) if isinstance(data, list): - include.update(str(item) for item in data) - return include, exclude + return _tool_id_set(data), set() if not isinstance(data, dict): raise ValueError("selection JSON must be an object or list") - for key in ("include", "include_tool", "include_tools", "selected_tools"): - values = data.get(key) - if isinstance(values, list): - include.update(str(item) for item in values) - for key in ("exclude", "exclude_tool", "exclude_tools", "skipped_tools"): - values = data.get(key) - if isinstance(values, list): - exclude.update(str(item) for item in values) - tools = data.get("tools") - if isinstance(tools, dict): - for tool_id, selected in tools.items(): - if selected is True: - include.add(str(tool_id)) - elif selected is False: - exclude.add(str(tool_id)) + include = _selection_values(data, _SELECTION_INCLUDE_KEYS) + exclude = _selection_values(data, _SELECTION_EXCLUDE_KEYS) + _merge_tools_mapping(data.get("tools"), include, exclude) return include, exclude @@ -180,73 +327,136 @@ def resolve_profile( contracts: tuple[LinterToolContract, ...], artifact: ArtifactContractEntry, requested_profile: LinterSelectionProfile, - include_tools: Iterable[str] = (), - exclude_tools: Iterable[str] = (), - selection_json: Path | None = None, + **kwargs: Any, ) -> ProvisioningProfile: """Resolve profile/include/exclude inputs into selected tool ids.""" - known = {contract.tool_id for contract in contracts} - required = {contract.tool_id for contract in contracts if contract.required_for_full} - internal = { - contract.tool_id for contract in contracts if contract.provider_kind == "internal" - } + include_tools, exclude_tools, selection_json = _resolve_profile_kwargs(kwargs) + known, required, internal = _profile_tool_sets(contracts) json_include, json_exclude = _selection_json(selection_json) include = set(include_tools) | json_include exclude = set(exclude_tools) | json_exclude + _validate_requested_tool_ids(include, exclude, known) + + selected = _selected_tool_ids_for_profile( + requested_profile, + required, + internal, + include, + exclude, + ) + effective_profile, full_complete, custom_reasons = _profile_completion( + artifact, + requested_profile, + selected, + required, + ) + + skipped = tuple( + contract.tool_id for contract in contracts if contract.tool_id not in selected + ) + return ProvisioningProfile( + requested_profile=requested_profile, + effective_profile=effective_profile, + selected_tool_ids=tuple( + contract.tool_id for contract in contracts if contract.tool_id in selected + ), + skipped_tool_ids=skipped, + full_profile_complete=full_complete, + custom_profile_reason="; ".join(custom_reasons) or None, + ) + + +def _resolve_profile_kwargs( + kwargs: dict[str, Any], +) -> tuple[Iterable[str], Iterable[str], Path | None]: + include_tools = kwargs.pop("include_tools", ()) + exclude_tools = kwargs.pop("exclude_tools", ()) + selection_json = kwargs.pop("selection_json", None) + if kwargs: + raise TypeError(f"unexpected resolve_profile argument(s): {sorted(kwargs)}") + return include_tools, exclude_tools, selection_json + + +def _profile_tool_sets( + contracts: tuple[LinterToolContract, ...], +) -> tuple[set[str], set[str], set[str]]: + known = {contract.tool_id for contract in contracts} + required = { + contract.tool_id for contract in contracts if contract.required_for_full + } + internal = { + contract.tool_id + for contract in contracts + if contract.provider_kind == "internal" + } + return known, required, internal + + +def _validate_requested_tool_ids( + include: set[str], + exclude: set[str], + known: set[str], +) -> None: unknown = sorted((include | exclude) - known) if unknown: raise ValueError(f"unknown linter tool id(s): {unknown}") + +def _selected_tool_ids_for_profile( + requested_profile: LinterSelectionProfile, + required: set[str], + internal: set[str], + include: set[str], + exclude: set[str], +) -> set[str]: if requested_profile == "full": selected = set(required) elif requested_profile == "minimal": selected = set(internal) else: selected = set(include) - selected.update(include) selected.difference_update(exclude) + return selected + +def _profile_completion( + artifact: ArtifactContractEntry, + requested_profile: LinterSelectionProfile, + selected: set[str], + required: set[str], +) -> tuple[LinterSelectionProfile, bool, list[str]]: effective_profile = requested_profile full_complete = requested_profile == "full" and required <= selected custom_reasons: list[str] = [] + missing_required = sorted(required - selected) + if missing_required and requested_profile == "full": + effective_profile = "custom" + custom_reasons.append( + "required Full tool(s) explicitly deselected: " + + ", ".join(missing_required) + ) if missing_required: full_complete = False - if requested_profile == "full": - effective_profile = "custom" - custom_reasons.append( - "required Full tool(s) explicitly deselected: " - + ", ".join(missing_required) - ) - if not artifact.full_provisioning_supported and requested_profile == "full": + + if requested_profile == "full" and not artifact.full_provisioning_supported: full_complete = False effective_profile = "minimal" if artifact.minimal_reason: custom_reasons.append(artifact.minimal_reason) + if requested_profile in ("custom", "minimal"): full_complete = False - if requested_profile == "custom": - custom_reasons.append("custom linter selection requested") - else: - custom_reasons.append("minimal F4 provisioning requested") - - skipped = tuple(contract.tool_id for contract in contracts if contract.tool_id not in selected) - return ProvisioningProfile( - requested_profile=requested_profile, - effective_profile=effective_profile, - selected_tool_ids=tuple( - contract.tool_id for contract in contracts if contract.tool_id in selected - ), - skipped_tool_ids=skipped, - full_profile_complete=full_complete, - custom_profile_reason="; ".join(custom_reasons) or None, - ) + custom_reasons.append( + "custom linter selection requested" + if requested_profile == "custom" + else "minimal F4 provisioning requested" + ) + return effective_profile, full_complete, custom_reasons -def _mechanism_allowed( - contract: LinterToolContract, mechanism: str -) -> bool: +def _mechanism_allowed(contract: LinterToolContract, mechanism: str) -> bool: return mechanism in contract.allowed_install_mechanisms @@ -255,8 +465,9 @@ def choose_strategy( contract: LinterToolContract, ) -> ProvisioningStrategy: """Choose a deterministic default strategy for an artifact/tool pair.""" + strategy: ProvisioningStrategy | None = None if contract.provider_kind == "internal": - return ProvisioningStrategy( + strategy = ProvisioningStrategy( mechanism="bundled-internal", description="Bundled internal ECLI provider; no external tool download.", requires_network=False, @@ -265,8 +476,8 @@ def choose_strategy( source_url=contract.source_url, pinned_version=contract.pinned_version, ) - if not artifact.full_provisioning_supported: - return ProvisioningStrategy( + elif not artifact.full_provisioning_supported: + strategy = ProvisioningStrategy( mechanism="artifact-policy", description="Documented minimal/constrained artifact entry.", requires_network=False, @@ -275,6 +486,26 @@ def choose_strategy( source_url=contract.source_url, pinned_version=contract.pinned_version, ) + else: + strategy = _artifact_strategy(artifact, contract) or _contract_strategy( + contract + ) + + return strategy or ProvisioningStrategy( + mechanism="artifact-policy", + description="Artifact policy must provide a concrete mechanism.", + requires_network=False, + requires_upstream_download=False, + requires_checksum=False, + source_url=contract.source_url, + pinned_version=contract.pinned_version, + ) + + +def _artifact_strategy( + artifact: ArtifactContractEntry, + contract: LinterToolContract, +) -> ProvisioningStrategy | None: if artifact.artifact_entry_id in NIX_ARTIFACT_IDS and _mechanism_allowed( contract, "nix-derivation" ): @@ -287,8 +518,9 @@ def choose_strategy( source_url=contract.source_url, pinned_version=contract.pinned_version, ) - if artifact.artifact_entry_id in PACKAGE_MANAGER_ARTIFACT_IDS and _mechanism_allowed( - contract, "os-package-manager" + if ( + artifact.artifact_entry_id in PACKAGE_MANAGER_ARTIFACT_IDS + and _mechanism_allowed(contract, "os-package-manager") ): return ProvisioningStrategy( mechanism="os-package-manager", @@ -299,6 +531,23 @@ def choose_strategy( source_url=contract.source_url, pinned_version=contract.pinned_version, ) + if ( + artifact.artifact_entry_id in PORTABLE_BUNDLE_ARTIFACT_IDS + and _mechanism_allowed(contract, "bundled-binary") + ): + return ProvisioningStrategy( + mechanism="bundled-binary", + description="Bundled or adjacent artifact-managed runtime payload.", + requires_network=False, + requires_upstream_download=False, + requires_checksum=False, + source_url=contract.source_url, + pinned_version=contract.pinned_version, + ) + return None + + +def _contract_strategy(contract: LinterToolContract) -> ProvisioningStrategy | None: if "toolchain-component" in contract.allowed_install_mechanisms: return ProvisioningStrategy( mechanism="toolchain-component", @@ -321,18 +570,6 @@ def choose_strategy( checksum_source="release-provenance", checksum_value="release-policy-checksum-required", ) - if artifact.artifact_entry_id in PORTABLE_BUNDLE_ARTIFACT_IDS and _mechanism_allowed( - contract, "bundled-binary" - ): - return ProvisioningStrategy( - mechanism="bundled-binary", - description="Bundled or adjacent artifact-managed runtime payload.", - requires_network=False, - requires_upstream_download=False, - requires_checksum=False, - source_url=contract.source_url, - pinned_version=contract.pinned_version, - ) if "language-package-manager" in contract.allowed_install_mechanisms: return ProvisioningStrategy( mechanism="language-package-manager", @@ -355,19 +592,11 @@ def choose_strategy( checksum_source="release-provenance", checksum_value="release-policy-checksum-required", ) - return ProvisioningStrategy( - mechanism="artifact-policy", - description="Artifact policy must provide a concrete mechanism.", - requires_network=False, - requires_upstream_download=False, - requires_checksum=False, - source_url=contract.source_url, - pinned_version=contract.pinned_version, - ) + return None def _planned_path(target_dir: Path, executable_name: str) -> str: - return str(target_dir / "bin" / executable_name) + return str(_safe_child_path(target_dir, "bin", executable_name)) def _run_version_probe( @@ -392,81 +621,26 @@ def _run_version_probe( return version, None -def _action_for_contract( - *, - contract: LinterToolContract, - artifact: ArtifactContractEntry, - profile: ProvisioningProfile, - mode: ProvisioningMode, - target_dir: Path, - allow_network: bool, - allow_upstream_downloads: bool, - include_tools: set[str], - exclude_tools: set[str], -) -> ProvisioningAction: - selected = contract.tool_id in profile.selected_tool_ids +def _action_for_contract(request: _ActionRequest) -> ProvisioningAction: + contract = request.contract + selected = contract.tool_id in request.profile.selected_tool_ids executable_name = contract.executable_names[0] executable_path = shutil.which(executable_name) - strategy = choose_strategy(artifact, contract) + strategy = choose_strategy(request.artifact, contract) selected_by_default = ( - profile.requested_profile == "full" and contract.required_for_full + request.profile.requested_profile == "full" and contract.required_for_full + ) + user_selected = contract.tool_id in request.include_tools + user_opted_out = ( + contract.tool_id in request.exclude_tools and contract.required_for_full + ) + outcome = _action_outcome( + request, + selected=selected, + executable_name=executable_name, + executable_path=executable_path, + strategy=strategy, ) - user_selected = contract.tool_id in include_tools - user_opted_out = contract.tool_id in exclude_tools and contract.required_for_full - warnings: list[str] = [] - errors: list[str] = [] - - status: ActionStatus - verification_result: VerificationResult - observed_version: str | None = None - planned_version: str | None = None - - if not selected: - status = "skipped" - verification_result = "skipped" - elif not artifact.full_provisioning_supported and contract.required_for_full: - status = "skipped" - verification_result = "skipped" - if artifact.minimal_reason: - warnings.append(artifact.minimal_reason) - elif contract.provider_kind == "internal": - status = "bundled" - verification_result = "verified" - planned_version = "bundled-with-ecli" - elif mode == "dry-run": - if executable_path is not None: - status = "already-present" - planned_version = "version-probe-deferred-in-dry-run" - else: - status = "planned" - planned_version = "planned-by-artifact-policy" - verification_result = "planned" - elif executable_path is not None: - observed_version, error = _run_version_probe( - contract.version_probe.command, - timeout_seconds=contract.version_probe.timeout_seconds, - ) - status = "already-present" - verification_result = "verified" - if error is not None: - status = "failed" - verification_result = "failed" - errors.append(error) - elif mode == "verify-only": - status = "failed" - verification_result = "failed" - errors.append(f"required executable is missing: {executable_name}") - else: - status = "failed" - verification_result = "failed" - if strategy.requires_network and not allow_network: - errors.append("network provisioning disabled by policy") - if strategy.requires_upstream_download and not allow_upstream_downloads: - errors.append("upstream downloads disabled by policy") - errors.append( - "concrete artifact installer integration is not implemented in this " - "provider-neutral planner" - ) return ProvisioningAction( tool_id=contract.tool_id, @@ -476,17 +650,126 @@ def _action_for_contract( selected_by_default=selected_by_default, user_selected=user_selected, user_opted_out=user_opted_out, - status=status, + status=outcome.status, executable_name=executable_name, executable_path=executable_path, - planned_path=_planned_path(target_dir, executable_name), + planned_path=_planned_path(request.target_dir, executable_name), version_command=contract.version_probe.command, - observed_version=observed_version, - planned_version=planned_version, + observed_version=outcome.observed_version, + planned_version=outcome.planned_version, strategy=strategy, - verification_result=verification_result, + verification_result=outcome.verification_result, + errors=outcome.errors, + warnings=outcome.warnings, + ) + + +def _action_outcome( + request: _ActionRequest, + *, + selected: bool, + executable_name: str, + executable_path: str | None, + strategy: ProvisioningStrategy, +) -> _ActionOutcome: + contract = request.contract + outcome: _ActionOutcome + if not selected: + outcome = _ActionOutcome(status="skipped", verification_result="skipped") + elif ( + not request.artifact.full_provisioning_supported and contract.required_for_full + ): + outcome = _unsupported_artifact_outcome(request.artifact) + elif contract.provider_kind == "internal": + outcome = _ActionOutcome( + status="bundled", + verification_result="verified", + planned_version="bundled-with-ecli", + ) + elif request.mode == "dry-run": + outcome = _dry_run_outcome(executable_path) + elif executable_path is not None: + outcome = _existing_executable_outcome(contract) + elif request.mode == "verify-only": + outcome = _ActionOutcome( + status="failed", + verification_result="failed", + errors=(f"required executable is missing: {executable_name}",), + ) + else: + outcome = _unimplemented_provision_outcome( + strategy, + allow_network=request.allow_network, + allow_upstream_downloads=request.allow_upstream_downloads, + ) + return outcome + + +def _unsupported_artifact_outcome( + artifact: ArtifactContractEntry, +) -> _ActionOutcome: + warnings = (artifact.minimal_reason,) if artifact.minimal_reason else () + return _ActionOutcome( + status="skipped", + verification_result="skipped", + warnings=warnings, + ) + + +def _dry_run_outcome(executable_path: str | None) -> _ActionOutcome: + if executable_path is not None: + return _ActionOutcome( + status="already-present", + verification_result="planned", + planned_version="version-probe-deferred-in-dry-run", + ) + return _ActionOutcome( + status="planned", + verification_result="planned", + planned_version="planned-by-artifact-policy", + ) + + +def _existing_executable_outcome( + contract: LinterToolContract, +) -> _ActionOutcome: + observed_version, error = _run_version_probe( + contract.version_probe.command, + timeout_seconds=contract.version_probe.timeout_seconds, + ) + if error is not None: + return _ActionOutcome( + status="failed", + verification_result="failed", + observed_version=observed_version, + errors=(error,), + ) + return _ActionOutcome( + status="already-present", + verification_result="verified", + observed_version=observed_version, + ) + + +def _unimplemented_provision_outcome( + strategy: ProvisioningStrategy, + *, + allow_network: bool, + allow_upstream_downloads: bool, +) -> _ActionOutcome: + errors: list[str] = [] + if strategy.requires_network and not allow_network: + errors.append("network provisioning disabled by policy") + if strategy.requires_upstream_download and not allow_upstream_downloads: + errors.append("upstream downloads disabled by policy") + errors.append( + "concrete artifact installer integration is not implemented in this " + "provider-neutral planner" + ) + return _ActionOutcome( + status="failed", + verification_result="failed", errors=tuple(errors), - warnings=tuple(warnings), ) @@ -495,54 +778,85 @@ def build_provisioning_plan( artifact_entry_id: str, target_dir: Path, evidence_dir: Path, - mode: ProvisioningMode, - profile: LinterSelectionProfile, - include_tools: Iterable[str] = (), - exclude_tools: Iterable[str] = (), - selection_json: Path | None = None, - allow_network: bool = False, - allow_upstream_downloads: bool = False, - contracts: tuple[LinterToolContract, ...] | None = None, + **kwargs: Any, ) -> ProvisioningPlan: """Build an artifact-aware F4 linter provisioning plan.""" - artifact = get_artifact_entry(artifact_entry_id) - loaded = load_linter_tool_contracts() if contracts is None else contracts - json_include, json_exclude = _selection_json(selection_json) - include = set(include_tools) | json_include - exclude = set(exclude_tools) | json_exclude + request = _plan_request(artifact_entry_id, target_dir, evidence_dir, kwargs) + artifact = _canonical_artifact_entry(request.artifact_entry_id) + loaded = ( + load_linter_tool_contracts() if request.contracts is None else request.contracts + ) + json_include, json_exclude = _selection_json(request.selection_json) + include = set(request.include_tools) | json_include + exclude = set(request.exclude_tools) | json_exclude resolved = resolve_profile( loaded, artifact, - profile, + request.profile, include_tools=include, exclude_tools=exclude, ) - component_model = build_component_model(artifact, profile, loaded) + component_model = build_component_model(artifact, request.profile, loaded) actions = tuple( _action_for_contract( - contract=contract, - artifact=artifact, - profile=resolved, - mode=mode, - target_dir=target_dir, - allow_network=allow_network, - allow_upstream_downloads=allow_upstream_downloads, - include_tools=include, - exclude_tools=exclude, + _ActionRequest( + contract=contract, + artifact=artifact, + profile=resolved, + mode=request.mode, + target_dir=request.target_dir, + allow_network=request.allow_network, + allow_upstream_downloads=request.allow_upstream_downloads, + include_tools=include, + exclude_tools=exclude, + ) ) for contract in loaded ) return ProvisioningPlan( artifact=artifact, - mode=mode, - target_dir=str(target_dir), - evidence_dir=str(evidence_dir), + mode=request.mode, + target_dir=str(request.target_dir), + evidence_dir=str(request.evidence_dir), profile=resolved, component_model=component_model, actions=actions, ) +def _plan_request( + artifact_entry_id: str, + target_dir: Path, + evidence_dir: Path, + kwargs: dict[str, Any], +) -> _PlanRequest: + mode = kwargs.pop("mode") + profile = kwargs.pop("profile") + include_tools = kwargs.pop("include_tools", ()) + exclude_tools = kwargs.pop("exclude_tools", ()) + selection_json = kwargs.pop("selection_json", None) + allow_network = bool(kwargs.pop("allow_network", False)) + allow_upstream_downloads = bool(kwargs.pop("allow_upstream_downloads", False)) + contracts = kwargs.pop("contracts", None) + if kwargs: + raise TypeError( + f"unexpected build_provisioning_plan argument(s): {sorted(kwargs)}" + ) + return _PlanRequest( + artifact_entry_id=artifact_entry_id, + target_dir=target_dir, + evidence_dir=evidence_dir, + mode=mode, + profile=profile, + include_tools=include_tools, + exclude_tools=exclude_tools, + selection_json=selection_json, + allow_network=allow_network, + allow_upstream_downloads=allow_upstream_downloads, + contracts=contracts, + ) + + def action_to_dict(action: ProvisioningAction) -> dict[str, Any]: """Serialize one action using stable field names required by evidence.""" data = asdict(action) @@ -614,10 +928,10 @@ def evidence_to_dict(evidence: ProvisioningEvidence) -> dict[str, Any]: def write_evidence(plan: ProvisioningPlan, *, ecli_version: str) -> Path: """Write deterministic JSON evidence for ``plan`` and return the path.""" - evidence_dir = Path(plan.evidence_dir) + evidence_dir = _safe_base_dir(Path(plan.evidence_dir)) evidence_dir.mkdir(parents=True, exist_ok=True) evidence = plan_to_evidence(plan, ecli_version=ecli_version) - path = evidence_dir / evidence_filename(plan.artifact.artifact_entry_id) + path = evidence_path_for_artifact(evidence_dir, plan.artifact.artifact_entry_id) path.write_text( json.dumps(evidence_to_dict(evidence), indent=2, sort_keys=True) + "\n", encoding="utf-8", @@ -686,21 +1000,44 @@ def verify_evidence_payload( contracts: tuple[LinterToolContract, ...] | None = None, ) -> list[str]: """Return validation errors for one provisioning evidence payload.""" - artifact_entry_id = payload.get("artifact_entry_id") - if not isinstance(artifact_entry_id, str): - return ["missing artifact_entry_id"] - if is_github_generated_source_archive(artifact_entry_id): + artifact, ignored, errors = _payload_artifact(payload) + if ignored: return [] - try: - artifact = get_artifact_entry(artifact_entry_id) - except KeyError: - return [f"unknown artifact_entry_id: {artifact_entry_id}"] + if artifact is None: + return errors loaded = load_linter_tool_contracts() if contracts is None else contracts required = [contract for contract in loaded if contract.required_for_full] tools = _tool_by_id(payload) - errors: list[str] = [] + errors.extend(_payload_required_field_errors(payload)) + errors.extend(_payload_full_profile_errors(payload, artifact)) + for contract in required: + errors.extend( + _required_tool_errors( + artifact=artifact, + contract=contract, + tool=tools.get(contract.tool_id), + ) + ) + return errors + +def _payload_artifact( + payload: Mapping[str, Any], +) -> tuple[ArtifactContractEntry | None, bool, list[str]]: + artifact_entry_id = payload.get("artifact_entry_id") + if not isinstance(artifact_entry_id, str): + return None, False, ["missing artifact_entry_id"] + if is_github_generated_source_archive(artifact_entry_id): + return None, True, [] + try: + return _canonical_artifact_entry(artifact_entry_id), False, [] + except (KeyError, ValueError): + return None, False, [f"unknown artifact_entry_id: {artifact_entry_id}"] + + +def _payload_required_field_errors(payload: Mapping[str, Any]) -> list[str]: + errors: list[str] = [] if payload.get("schema_version") != EVIDENCE_SCHEMA_VERSION: errors.append("unsupported or missing schema_version") if "run_mode" not in payload: @@ -709,52 +1046,76 @@ def verify_evidence_payload( errors.append("missing os_context") if "selection_profile" not in payload: errors.append("missing selection_profile") + return errors + +def _payload_full_profile_errors( + payload: Mapping[str, Any], + artifact: ArtifactContractEntry, +) -> list[str]: full_complete = payload.get("full_profile_complete") if artifact.full_provisioning_supported and full_complete is not True: - errors.append( - f"{artifact_entry_id}: Full provisioning evidence is incomplete" - ) + return [ + f"{artifact.artifact_entry_id}: Full provisioning evidence is incomplete" + ] + return [] - for contract in required: - tool = tools.get(contract.tool_id) - if tool is None: - errors.append(f"{artifact_entry_id}: missing required tool {contract.tool_id}") - continue - if not _has_probe(tool): - errors.append(f"{artifact_entry_id}/{contract.tool_id}: missing version probe") - if not _has_executable_record(tool): - errors.append( - f"{artifact_entry_id}/{contract.tool_id}: missing executable record" - ) - errors.extend( - f"{artifact_entry_id}/{contract.tool_id}: {error}" - for error in _strategy_errors(tool) - ) - if not artifact.full_provisioning_supported: - continue - if tool.get("selected") is not True: - errors.append(f"{artifact_entry_id}/{contract.tool_id}: required tool not selected") - status = tool.get("status") - if status not in {"already-present", "bundled", "planned", "provisioned"}: - errors.append( - f"{artifact_entry_id}/{contract.tool_id}: invalid required " - f"status {status!r}" - ) - verification = tool.get("verification_result") - if verification not in {"planned", "verified"}: - errors.append( - f"{artifact_entry_id}/{contract.tool_id}: invalid verification " - f"result {verification!r}" - ) + +def _required_tool_errors( + *, + artifact: ArtifactContractEntry, + contract: LinterToolContract, + tool: Mapping[str, Any] | None, +) -> list[str]: + artifact_entry_id = artifact.artifact_entry_id + if tool is None: + return [f"{artifact_entry_id}: missing required tool {contract.tool_id}"] + + errors = _required_tool_record_errors(artifact_entry_id, contract, tool) + if artifact.full_provisioning_supported: + errors.extend(_required_tool_state_errors(artifact_entry_id, contract, tool)) + return errors + + +def _required_tool_record_errors( + artifact_entry_id: str, + contract: LinterToolContract, + tool: Mapping[str, Any], +) -> list[str]: + prefix = f"{artifact_entry_id}/{contract.tool_id}" + errors: list[str] = [] + if not _has_probe(tool): + errors.append(f"{prefix}: missing version probe") + if not _has_executable_record(tool): + errors.append(f"{prefix}: missing executable record") + errors.extend(f"{prefix}: {error}" for error in _strategy_errors(tool)) + return errors + + +def _required_tool_state_errors( + artifact_entry_id: str, + contract: LinterToolContract, + tool: Mapping[str, Any], +) -> list[str]: + prefix = f"{artifact_entry_id}/{contract.tool_id}" + errors: list[str] = [] + if tool.get("selected") is not True: + errors.append(f"{prefix}: required tool not selected") + status = tool.get("status") + if status not in {"already-present", "bundled", "planned", "provisioned"}: + errors.append(f"{prefix}: invalid required status {status!r}") + verification = tool.get("verification_result") + if verification not in {"planned", "verified"}: + errors.append(f"{prefix}: invalid verification result {verification!r}") return errors def load_evidence_file(path: Path) -> Mapping[str, Any]: """Read one evidence JSON file.""" - data = json.loads(path.read_text(encoding="utf-8")) + safe_path = _safe_json_file(path) + data = json.loads(safe_path.read_text(encoding="utf-8")) if not isinstance(data, dict): - raise ValueError(f"evidence file must contain a JSON object: {path}") + raise ValueError(f"evidence file must contain a JSON object: {safe_path}") return data @@ -768,27 +1129,55 @@ def verify_evidence_dir( """Verify F4 provisioning evidence for one artifact or all 21 entries.""" loaded = load_linter_tool_contracts() if contracts is None else contracts if all_artifacts: - errors: list[str] = [] - for artifact in ARTIFACT_CONTRACT_ENTRIES: - path = evidence_dir / evidence_filename(artifact.artifact_entry_id) - if not path.is_file(): - errors.append(f"missing evidence file: {path.name}") - continue - errors.extend(verify_evidence_payload(load_evidence_file(path), contracts=loaded)) - for path in sorted(evidence_dir.glob("*.json")): - payload = load_evidence_file(path) - entry_id = payload.get("artifact_entry_id") - if isinstance(entry_id, str) and is_github_generated_source_archive(entry_id): - continue - if isinstance(entry_id, str) and entry_id not in { - item.artifact_entry_id for item in ARTIFACT_CONTRACT_ENTRIES - }: - errors.append(f"unknown evidence artifact entry: {entry_id}") - return errors + return _verify_all_evidence(evidence_dir, loaded) if artifact_entry_id is None: raise ValueError("artifact_entry_id is required unless all_artifacts=True") - path = evidence_dir / evidence_filename(artifact_entry_id) + artifact_id = canonical_artifact_entry_id(artifact_entry_id) + path = evidence_path_for_artifact(evidence_dir, artifact_id) if not path.is_file(): return [f"missing evidence file: {path.name}"] return verify_evidence_payload(load_evidence_file(path), contracts=loaded) + + +def _verify_all_evidence( + evidence_dir: Path, + contracts: tuple[LinterToolContract, ...], +) -> list[str]: + errors: list[str] = [] + for artifact in ARTIFACT_CONTRACT_ENTRIES: + errors.extend(_verify_expected_evidence_file(evidence_dir, artifact, contracts)) + errors.extend(_unknown_evidence_entry_errors(evidence_dir)) + return errors + + +def _verify_expected_evidence_file( + evidence_dir: Path, + artifact: ArtifactContractEntry, + contracts: tuple[LinterToolContract, ...], +) -> list[str]: + path = evidence_path_for_artifact(evidence_dir, artifact.artifact_entry_id) + if not path.is_file(): + return [f"missing evidence file: {path.name}"] + return verify_evidence_payload(load_evidence_file(path), contracts=contracts) + + +def _safe_evidence_json_files(evidence_dir: Path) -> tuple[Path, ...]: + base = _safe_base_dir(evidence_dir) + if not base.exists(): + return () + return tuple( + _ensure_child_path(base, path.resolve(strict=False)) + for path in sorted(base.glob("*.json")) + ) + + +def _unknown_evidence_entry_errors(evidence_dir: Path) -> list[str]: + errors: list[str] = [] + for path in _safe_evidence_json_files(evidence_dir): + entry_id = load_evidence_file(path).get("artifact_entry_id") + if isinstance(entry_id, str) and is_github_generated_source_archive(entry_id): + continue + if isinstance(entry_id, str) and entry_id not in CANONICAL_ARTIFACT_ENTRY_IDS: + errors.append(f"unknown evidence artifact entry: {entry_id}") + return errors diff --git a/tests/extensions/linters/test_provisioning_contract.py b/tests/extensions/linters/test_provisioning_contract.py index fc3b462..bc1d999 100644 --- a/tests/extensions/linters/test_provisioning_contract.py +++ b/tests/extensions/linters/test_provisioning_contract.py @@ -15,14 +15,22 @@ from pathlib import Path +import pytest + from ecli.extensions.linters.core.provisioning import ( build_component_model, build_provisioning_plan, + evidence_filename, evidence_to_dict, plan_has_release_blocking_failure, plan_to_evidence, + verify_evidence_dir, verify_evidence_payload, ) +from ecli.extensions.linters.core.provisioning_contract import ( + LinterToolContract, + VersionProbe, +) from ecli.extensions.linters.core.provisioning_registry import ( ARTIFACT_CONTRACT_ENTRIES, get_artifact_entry, @@ -178,3 +186,72 @@ def test_pypi_full_evidence_records_documented_minimal_constraint( def test_verifier_ignores_github_generated_source_archives() -> None: assert verify_evidence_payload({"artifact_entry_id": "Source code (zip)"}) == [] assert verify_evidence_payload({"artifact_entry_id": "Source code (tar.gz)"}) == [] + + +def test_evidence_filename_rejects_pathlike_artifact_ids() -> None: + for artifact_id in ("../deb", "/tmp/deb", "deb/../rpm", r"C:\tmp\deb"): + with pytest.raises(ValueError): + evidence_filename(artifact_id) + + +def test_evidence_filename_rejects_unknown_artifact_ids() -> None: + with pytest.raises(KeyError): + evidence_filename("not-a-canonical-artifact") + + +def test_build_plan_rejects_pathlike_artifact_id_before_path_construction( + tmp_path: Path, +) -> None: + with pytest.raises(ValueError): + build_provisioning_plan( + artifact_entry_id="../deb", + target_dir=tmp_path / "target", + evidence_dir=tmp_path / "evidence", + mode="dry-run", + profile="full", + ) + + +def test_verify_evidence_dir_rejects_absolute_artifact_id_before_path_construction( + tmp_path: Path, +) -> None: + with pytest.raises(ValueError): + verify_evidence_dir( + tmp_path / "evidence", + artifact_entry_id="/tmp/deb", + ) + + +def test_planned_target_path_rejects_escape_executable_name( + tmp_path: Path, +) -> None: + bad_contract = LinterToolContract( + tool_id="bad-tool", + display_name="Bad Tool", + languages=("text",), + install_group="test", + tier="required", + provider_kind="external", + required_for_full=True, + bundled_with_full_install=False, + selected_by_default=True, + executable_names=("../escape",), + version_probe=VersionProbe(command=("bad-tool", "--version")), + allowed_install_mechanisms=("os-package-manager",), + provenance_requirements=("package-manager-metadata",), + source_url="https://example.invalid/bad-tool", + pinned_version=None, + checksum_required_for_downloads=False, + artifact_entry_ids=CANONICAL_ARTIFACT_ENTRY_IDS, + delivery_notes="test-only contract", + ) + + with pytest.raises(ValueError): + build_provisioning_plan( + artifact_entry_id="deb", + target_dir=tmp_path / "target", + evidence_dir=tmp_path / "evidence", + mode="dry-run", + profile="full", + contracts=(bad_contract,), + ) diff --git a/tests/packaging/test_f4_linter_provisioning_scripts.py b/tests/packaging/test_f4_linter_provisioning_scripts.py index 41040f3..0ba0e90 100644 --- a/tests/packaging/test_f4_linter_provisioning_scripts.py +++ b/tests/packaging/test_f4_linter_provisioning_scripts.py @@ -159,3 +159,45 @@ def test_verify_fails_when_required_artifact_evidence_is_missing( ) == 2 ) + + +def test_provision_script_rejects_path_traversal_artifact_id( + tmp_path: Path, + provision_script: ModuleType, +) -> None: + rc = provision_script.main( + [ + "--artifact", + "../deb", + "--target-dir", + str(tmp_path / "target"), + "--evidence-dir", + str(tmp_path / "evidence"), + "--mode", + "dry-run", + "--json", + ] + ) + + assert rc == 1 + + +def test_provision_script_rejects_absolute_artifact_id( + tmp_path: Path, + provision_script: ModuleType, +) -> None: + rc = provision_script.main( + [ + "--artifact", + "/tmp/deb", + "--target-dir", + str(tmp_path / "target"), + "--evidence-dir", + str(tmp_path / "evidence"), + "--mode", + "dry-run", + "--json", + ] + ) + + assert rc == 1 From 46c95452a0b5ba0b10b4428160877d17840dc9c3 Mon Sep 17 00:00:00 2001 From: SSobol77 Date: Thu, 9 Jul 2026 10:25:00 +0200 Subject: [PATCH 3/3] test: avoid public temp paths in F4 provisioning tests --- tests/extensions/linters/test_provisioning_contract.py | 7 ++++--- tests/packaging/test_f4_linter_provisioning_scripts.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/extensions/linters/test_provisioning_contract.py b/tests/extensions/linters/test_provisioning_contract.py index bc1d999..e80f00e 100644 --- a/tests/extensions/linters/test_provisioning_contract.py +++ b/tests/extensions/linters/test_provisioning_contract.py @@ -188,8 +188,9 @@ def test_verifier_ignores_github_generated_source_archives() -> None: assert verify_evidence_payload({"artifact_entry_id": "Source code (tar.gz)"}) == [] -def test_evidence_filename_rejects_pathlike_artifact_ids() -> None: - for artifact_id in ("../deb", "/tmp/deb", "deb/../rpm", r"C:\tmp\deb"): +def test_evidence_filename_rejects_pathlike_artifact_ids(tmp_path: Path) -> None: + absolute_artifact_id = str(tmp_path.resolve() / "deb") + for artifact_id in ("../deb", absolute_artifact_id, "deb/../rpm", r"C:\escape\deb"): with pytest.raises(ValueError): evidence_filename(artifact_id) @@ -218,7 +219,7 @@ def test_verify_evidence_dir_rejects_absolute_artifact_id_before_path_constructi with pytest.raises(ValueError): verify_evidence_dir( tmp_path / "evidence", - artifact_entry_id="/tmp/deb", + artifact_entry_id=str(tmp_path.resolve() / "deb"), ) diff --git a/tests/packaging/test_f4_linter_provisioning_scripts.py b/tests/packaging/test_f4_linter_provisioning_scripts.py index 0ba0e90..892f278 100644 --- a/tests/packaging/test_f4_linter_provisioning_scripts.py +++ b/tests/packaging/test_f4_linter_provisioning_scripts.py @@ -189,7 +189,7 @@ def test_provision_script_rejects_absolute_artifact_id( rc = provision_script.main( [ "--artifact", - "/tmp/deb", + str(tmp_path.resolve() / "deb"), "--target-dir", str(tmp_path / "target"), "--evidence-dir",