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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
name: CI

# The public API surface — schemas + manifest + envelope + the `paramify` CLI —
# is the contract (CLAUDE.md). These jobs gate it:
# test — every fetcher.yaml / category file validates against its JSON Schema
# smoke — the CLI discovery surface works across all fetchers
# lint — the framework's own code passes ruff + mypy
# secrets — no credentials in the tree or its full git history
# Fetchers can't be integration-tested here (no live tenants); that signal comes
# from recorded fixtures + the scheduled dogfood run (SEC-38).

on:
push:
branches: [main]
pull_request:

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
lint:
name: lint (ruff + mypy)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- run: python -m pip install -e ".[dev]"
# Framework's own code only. Ported v0.x fetchers under fetchers/ are held
# to the schema contract (the test job), not lint rules. `ruff format` is
# intentionally not gated yet.
- name: ruff (lint + import order)
run: ruff check framework/
# Core framework. The TUI is suppressed via a pyproject override until it
# gets its own typing pass (see [tool.mypy] in pyproject.toml).
- name: mypy (core framework)
run: mypy framework/

test:
name: test (py ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: pyproject.toml
# tui extra: the CLI/TUI parity test reads the TUI source, and importing
# the CLI must stay clean with textual installed.
- run: python -m pip install -e ".[dev,tui]"
# Includes tests/test_contracts.py — the schema contract gate over every
# fetcher.yaml and _categories/*.yaml.
- name: pytest
run: pytest -q

smoke:
name: smoke (CLI discovery)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- run: python -m pip install -e .
- name: paramify list / catalog / ksi + describe every fetcher
run: |
set -euo pipefail
paramify list --json > /dev/null
paramify catalog --json > /dev/null
paramify ksi --json > /dev/null
for f in $(paramify list --json | python -c "import sys, json; [print(x['name']) for x in json.load(sys.stdin)]"); do
paramify describe "$f" --json > /dev/null
done
echo "smoke ok: described all discovered fetchers"

secrets:
name: secrets (gitleaks full history)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history — scanning it is the whole point
# The OSS binary, not the marketplace action (which requires an org
# license). `gitleaks git` scans the full commit history and exits 1 on
# any finding.
- name: Install gitleaks
run: |
set -euo pipefail
VERSION=8.30.1
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" -o gitleaks.tar.gz
tar -xzf gitleaks.tar.gz gitleaks
sudo install gitleaks /usr/local/bin/gitleaks
gitleaks version
- name: gitleaks (full history)
run: gitleaks git --verbose --redact
8 changes: 5 additions & 3 deletions framework/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,8 +515,8 @@ def on_line(line: str, _use=entry.use) -> None:
fetcher,
entry,
run_dir,
platforms.get(fetcher.category),
parsed.platforms.get(fetcher.category),
platforms.get(fetcher.category or ""),
parsed.platforms.get(fetcher.category or ""),
on_line=on_line,
)
except (RuntimeError, ValueError) as e:
Expand Down Expand Up @@ -815,11 +815,13 @@ def list_manifests(root) -> List[dict]:
return []
# Discover the fetcher tree once and reuse it across every manifest's
# validate() — otherwise the welcome screen re-scans all fetchers per file.
fetchers: Optional[dict] = None
platforms: Optional[dict] = None
try:
fetchers = discover_fetchers(root)
platforms = discover_platforms(root)
except Exception:
fetchers = platforms = None
pass
summaries = [_manifest_summary(p, root, fetchers, platforms) for p in paths]
return [s for s in summaries if s is not None]

Expand Down
12 changes: 6 additions & 6 deletions framework/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,8 +442,8 @@ def validate_cmd(
typer.echo(json.dumps({"ok": not errors, "errors": errors}, indent=2))
raise typer.Exit(0 if not errors else 1)
if errors:
for e in errors:
_err(f" ERROR {e}")
for err in errors:
_err(f" ERROR {err}")
raise typer.Exit(1)
n = len(m.get("run", {}).get("fetchers", []))
typer.echo(f"OK manifest valid; {n} fetcher entries")
Expand Down Expand Up @@ -525,8 +525,8 @@ def upload_cmd(
if json_out:
typer.echo(json.dumps(preflight, indent=2, default=str))
else:
for e in preflight["errors"]:
_err(f" ERROR {e}")
for err in preflight["errors"]:
_err(f" ERROR {err}")
raise typer.Exit(1)

try:
Expand Down Expand Up @@ -582,8 +582,8 @@ def _save_and_report(manifest: dict, path: Path, root: Path, json_out: bool, *,
typer.echo(f"{verb} {path}")
if errors:
_err(" (manifest saved but not yet runnable):")
for e in errors:
_err(f" {e}")
for err in errors:
_err(f" {err}")


@manifest_app.command("init")
Expand Down
4 changes: 2 additions & 2 deletions framework/config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import json
from pathlib import Path
from typing import Dict
from typing import Dict, Optional

import yaml
from jsonschema import Draft202012Validator
Expand All @@ -25,7 +25,7 @@ def _load_schema(repo_root: Path, name: str = "fetcher_schema.json") -> dict:
return json.loads((repo_root / "framework" / "schemas" / name).read_text())


def _parse_config_schema(raw: dict) -> dict:
def _parse_config_schema(raw: Optional[dict]) -> dict:
"""Parse a config_schema mapping (shared by fetcher + category files)."""
out = {}
for field_name, spec in (raw or {}).items():
Expand Down
3 changes: 1 addition & 2 deletions framework/runner/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@
PlatformSpec,
TargetInstance,
)
from framework.secret_resolver import resolve, SecretResolutionError

from framework.secret_resolver import SecretResolutionError, resolve

_INHERITED_ENV_VARS = ("PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "USER", "TZ")

Expand Down
2 changes: 1 addition & 1 deletion framework/tui/modals.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from __future__ import annotations

from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Tuple

from rich.text import Text
from textual import events, on
Expand Down
2 changes: 0 additions & 2 deletions framework/tui/screens/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@

from __future__ import annotations

from pathlib import Path

from textual.app import ComposeResult
from textual.binding import Binding
from textual.screen import Screen
Expand Down
29 changes: 27 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ dependencies = [
[project.optional-dependencies]
tui = ["textual>=1.0,<2.0"]
checkov = ["checkov"]
dev = ["pytest"]
dev = ["pytest", "ruff", "mypy"]
# Convenience: every front-end + dev tooling in one install.
all = ["textual>=1.0,<2.0", "checkov", "pytest"]
all = ["textual>=1.0,<2.0", "checkov", "pytest", "ruff", "mypy"]

# The single entry point. `paramify` steers every front-end: the headless
# commands, plus `paramify tui`. (Renaming later is a one-line change here; add
Expand Down Expand Up @@ -57,3 +57,28 @@ include = ["framework*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--import-mode=importlib"

# Lint + import order for the framework's OWN code. Ported v0.x fetchers under
# fetchers/ are held to the schema contract (tests/test_contracts.py), not lint
# rules (CLAUDE.md); CI runs `ruff check framework/` so only that tree is gated.
[tool.ruff]
target-version = "py310"
extend-exclude = ["fetchers", ".venv"]

[tool.ruff.lint]
# Default lint set (pyflakes F + the E4/E7/E9 pycodestyle slice) plus import
# sorting (I). `ruff format` is intentionally NOT gated yet (would churn ~25
# files) — adopt as a separate pass.
select = ["E4", "E7", "E9", "F", "I"]

# Static typing gate for the core framework. The TUI subclasses textual's App and
# reads attributes mypy can't see on the base class — it needs its own typing
# pass, so it's suppressed here rather than gating CI. Third-party libs ship no
# stubs, hence ignore_missing_imports.
[tool.mypy]
python_version = "3.10"
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = "framework.tui.*"
ignore_errors = true
108 changes: 108 additions & 0 deletions tests/test_contracts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Contract gate: every fetcher.yaml and category file must satisfy its schema.

This is the merge gate the CI ``test`` job protects (SEC-36). Discovery
(``framework.config_loader``) validates every ``fetcher.yaml`` against
``fetcher_schema.json`` and every ``fetchers/_categories/*.yaml`` against
``category_schema.json``, and raises on any schema violation or duplicate
fetcher name. The public API surface *is* the contract (CLAUDE.md), so a change
that breaks a fetcher's declared shape must turn this suite red before it can
merge.

The per-file parametrized tests validate directly against the JSON Schema so a
failure names the offending file and field; the discovery tests exercise the
same path in aggregate (must not raise) and assert the duplicate-name and
on-disk/discovered-count invariants.

Run: ``pytest tests/test_contracts.py`` (needs an editable install:
``pip install -e .``).
"""

from __future__ import annotations

import json
from pathlib import Path

import pytest
import yaml
from jsonschema import Draft202012Validator

from framework.config_loader import discover_fetchers, discover_platforms

REPO_ROOT = Path(__file__).resolve().parent.parent
FETCHERS_ROOT = REPO_ROOT / "fetchers"
SCHEMAS_ROOT = REPO_ROOT / "framework" / "schemas"


def _fetcher_yamls() -> list[Path]:
"""Every real fetcher.yaml, mirroring discover_fetchers' walk.

category/<name>/fetcher.yaml, skipping any ``_``-prefixed dir (_shared,
_categories, _template) at either level.
"""
out: list[Path] = []
for category_dir in sorted(FETCHERS_ROOT.iterdir()):
if not category_dir.is_dir() or category_dir.name.startswith("_"):
continue
for fetcher_dir in sorted(category_dir.iterdir()):
if not fetcher_dir.is_dir() or fetcher_dir.name.startswith("_"):
continue
yaml_path = fetcher_dir / "fetcher.yaml"
if yaml_path.exists():
out.append(yaml_path)
return out


FETCHER_YAMLS = _fetcher_yamls()
CATEGORY_YAMLS = sorted((FETCHERS_ROOT / "_categories").glob("*.yaml"))


def _load_schema(name: str) -> dict:
return json.loads((SCHEMAS_ROOT / name).read_text())


def _rel_ids(paths: list[Path]) -> list[str]:
return [str(p.relative_to(REPO_ROOT)) for p in paths]


def _format_errors(errors) -> str:
return "\n".join(
f" {'/'.join(map(str, e.path)) or '<root>'}: {e.message}" for e in errors
)


def test_fetcher_yamls_discovered() -> None:
"""Sanity: the walk actually finds fetchers (guards an empty parametrize)."""
assert FETCHER_YAMLS, "no fetcher.yaml files discovered under fetchers/"


@pytest.mark.parametrize("yaml_path", FETCHER_YAMLS, ids=_rel_ids(FETCHER_YAMLS))
def test_fetcher_yaml_matches_schema(yaml_path: Path) -> None:
validator = Draft202012Validator(_load_schema("fetcher_schema.json"))
data = yaml.safe_load(yaml_path.read_text())
errors = sorted(validator.iter_errors(data), key=lambda e: list(e.path))
assert not errors, f"{yaml_path.name} violates fetcher_schema.json:\n{_format_errors(errors)}"


@pytest.mark.parametrize("yaml_path", CATEGORY_YAMLS, ids=_rel_ids(CATEGORY_YAMLS))
def test_category_yaml_matches_schema(yaml_path: Path) -> None:
validator = Draft202012Validator(_load_schema("category_schema.json"))
data = yaml.safe_load(yaml_path.read_text()) or {}
errors = sorted(validator.iter_errors(data), key=lambda e: list(e.path))
assert not errors, f"{yaml_path.name} violates category_schema.json:\n{_format_errors(errors)}"


def test_discovery_does_not_raise() -> None:
"""The aggregate gate: discovery validates + dedupes, raising on violations."""
fetchers = discover_fetchers(REPO_ROOT)
assert fetchers, "discover_fetchers returned nothing"
discover_platforms(REPO_ROOT) # must not raise on any category file


def test_fetcher_names_unique_and_complete() -> None:
"""Every fetcher.yaml on disk maps to exactly one discovered, uniquely named
fetcher (discover_fetchers raises on a duplicate name before returning)."""
fetchers = discover_fetchers(REPO_ROOT)
assert len(fetchers) == len(FETCHER_YAMLS), (
f"{len(FETCHER_YAMLS)} fetcher.yaml files on disk but "
f"{len(fetchers)} discovered"
)
Loading