diff --git a/.github/workflows/l9-analysis.yml b/.github/workflows/l9-analysis.yml new file mode 100644 index 00000000..a74002ba --- /dev/null +++ b/.github/workflows/l9-analysis.yml @@ -0,0 +1,186 @@ +# L9 Governed Analysis Pipeline — Python Preset (LOCKED) +# +# DO NOT EDIT — this file is managed by l9-ci-core presets/python. +# To update, pull the latest preset from Quantum-L9/l9-ci-core. +# +# This workflow runs the full L9 analysis pipeline: +# 1. Resolve governance config from .github/governance/ +# 2. Run semgrep with Python rulesets +# 3. Provision the SDK (immutable, pinned) +# 4. Normalize → Validate → Project → Route → Manifest → Upload +# 5. Publish results as GitHub Checks +name: L9 Analysis +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +env: + L9_CORE_REF: "f88116503430aa18992b70d8d31063e34ff97ef1" + L9_PROFILE: "pr_fast" + L9_MATRIX_ID: "pr-semgrep" + +permissions: + contents: read + checks: write + +concurrency: + group: l9-analysis-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Governed Semgrep Analysis + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + enabled: ${{ steps.gov.outputs.enabled }} + mode: ${{ steps.gov.outputs.mode }} + governance-digest: ${{ steps.gov.outputs.governance-digest }} + artifact-name: ${{ steps.names.outputs.artifact-name }} + permissions: + contents: read + checks: write + steps: + - name: Checkout immutable event revision + env: + REPOSITORY: ${{ github.repository }} + REVISION: ${{ github.sha }} + TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git init . + git remote add origin \ + "https://x-access-token:${TOKEN}@github.com/${REPOSITORY}.git" + git -c protocol.version=2 fetch --depth=1 origin "${REVISION}" + git checkout --detach FETCH_HEAD + git remote set-url origin "https://github.com/${REPOSITORY}.git" + + - id: gov + name: Resolve governance + uses: Quantum-L9/l9-ci-core/.github/actions/resolve-governance@555d577eb805851b624cf7b0b8fc4df75a225d9f + with: + profile: ${{ env.L9_PROFILE }} + provider: semgrep + event-name: ${{ github.event_name }} + repository: ${{ github.repository }} + ref: ${{ github.ref }} + + - id: names + name: Compute artifact names + env: + MATRIX_ID: ${{ env.L9_MATRIX_ID }} + run: | + set -euo pipefail + echo "artifact-name=l9-semgrep-${MATRIX_ID}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + + - name: Run semgrep + if: steps.gov.outputs.enabled == 'true' + run: | + set -euo pipefail + pip install --upgrade pip semgrep + mkdir -p "artifacts/raw/semgrep/${L9_MATRIX_ID}" + # No `|| true` (Baseline Ratchet rejects fail-open). Also no + # `--error`: findings must reach normalize/publish so governance + # can decide blocking vs advisory; `--error` exits 1 before that. + semgrep scan \ + --config p/python \ + --json \ + --output "artifacts/raw/semgrep/${L9_MATRIX_ID}/report.json" \ + --quiet + env: + L9_MATRIX_ID: ${{ env.L9_MATRIX_ID }} + + - id: sdk + name: Provision immutable SDK + if: steps.gov.outputs.enabled == 'true' + uses: Quantum-L9/l9-ci-core/.github/actions/provision-sdk@0d28395428426853c44825c4645c23ee8ace23b1 + + - name: Normalize provider report + if: steps.gov.outputs.enabled == 'true' + uses: Quantum-L9/l9-ci-core/.github/actions/invoke-sdk@f88116503430aa18992b70d8d31063e34ff97ef1 + with: + executable: ${{ steps.sdk.outputs.executable }} + operation: semgrep-normalize + input: artifacts/raw/semgrep/${{ env.L9_MATRIX_ID }}/report.json + output: .l9/runtime/${{ env.L9_MATRIX_ID }}/finding-bundle.json + root: . + snapshot-id: ${{ github.sha }} + revision: ${{ github.sha }} + strict: ${{ steps.gov.outputs.strict }} + required: ${{ steps.gov.outputs.required-provider }} + policy: ${{ steps.gov.outputs.sdk-policy }} + identity-map: .github/governance/semgrep-identity-map.yaml + + - name: Validate canonical bundle + if: steps.gov.outputs.enabled == 'true' + uses: Quantum-L9/l9-ci-core/.github/actions/validate-bundle@84375ed2bc9e005048dfb6f74076fc420b4bc01c + with: + executable: ${{ steps.sdk.outputs.executable }} + bundle: .l9/runtime/${{ env.L9_MATRIX_ID }}/finding-bundle.json + + - name: Project agent-review payload + if: steps.gov.outputs.enabled == 'true' + uses: Quantum-L9/l9-ci-core/.github/actions/invoke-sdk@f88116503430aa18992b70d8d31063e34ff97ef1 + with: + executable: ${{ steps.sdk.outputs.executable }} + operation: bundle-project-agent-payload + input: .l9/runtime/${{ env.L9_MATRIX_ID }}/finding-bundle.json + output: .l9/runtime/${{ env.L9_MATRIX_ID }}/agent-review-payload.json + strict: ${{ steps.gov.outputs.strict }} + + - id: route + name: Route artifacts + if: steps.gov.outputs.enabled == 'true' + uses: Quantum-L9/l9-ci-core/.github/actions/route-artifacts@84375ed2bc9e005048dfb6f74076fc420b4bc01c + with: + provider: semgrep + matrix-id: ${{ env.L9_MATRIX_ID }} + raw-report: artifacts/raw/semgrep/${{ env.L9_MATRIX_ID }}/report.json + bundle: .l9/runtime/${{ env.L9_MATRIX_ID }}/finding-bundle.json + agent-payload: .l9/runtime/${{ env.L9_MATRIX_ID }}/agent-review-payload.json + destination-root: artifacts + + - name: Build artifact manifest + if: steps.gov.outputs.enabled == 'true' + uses: Quantum-L9/l9-ci-core/.github/actions/build-artifact-manifest@555d577eb805851b624cf7b0b8fc4df75a225d9f + with: + provider: semgrep + matrix-id: ${{ env.L9_MATRIX_ID }} + sdk-revision: ${{ steps.sdk.outputs.sdk-revision }} + bundle: ${{ steps.route.outputs.bundle }} + agent-payload: ${{ steps.route.outputs.agent-payload }} + raw-directory: ${{ steps.route.outputs.raw-directory }} + output: artifacts/metadata/${{ env.L9_MATRIX_ID }}/artifact-manifest.json + + - name: Upload analysis artifact set + if: steps.gov.outputs.enabled == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ steps.names.outputs.artifact-name }} + path: | + artifacts/raw/semgrep/${{ env.L9_MATRIX_ID }}/ + artifacts/l9/${{ env.L9_MATRIX_ID }}/ + artifacts/metadata/${{ env.L9_MATRIX_ID }}/ + if-no-files-found: error + retention-days: 14 + + publish: + name: Publish analysis (Core) + needs: analyze + if: needs.analyze.outputs.enabled == 'true' + uses: Quantum-L9/l9-ci-core/.github/workflows/publish-analysis.yml@0d28395428426853c44825c4645c23ee8ace23b1 + permissions: + actions: read + checks: write + contents: read + with: + artifact-name: ${{ needs.analyze.outputs.artifact-name }} + profile: pr_fast + mode: ${{ needs.analyze.outputs.mode }} + provider: semgrep + matrix-id: pr-semgrep + governance-digest: ${{ needs.analyze.outputs.governance-digest }} + repository-revision: ${{ github.sha }} + workflow-result: ${{ needs.analyze.result }} diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml index c2e2a227..e0f8236e 100644 --- a/.github/workflows/supply-chain.yml +++ b/.github/workflows/supply-chain.yml @@ -64,6 +64,8 @@ jobs: persist-credentials: false - name: Run OpenSSF Scorecard Analysis + # v2.4.4 action.yaml image is docker://ghcr.io/ossf/scorecard-action:v2.4.4 + # Do not pin v2.4.0 — that tag still docker-pulls gcr.io/openssf (billing denied). uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 with: results_file: scorecard.sarif diff --git a/docs/contracts/BANNED_PATTERNS.md b/docs/contracts/BANNED_PATTERNS.md index 42efa004..bcf886a3 100644 --- a/docs/contracts/BANNED_PATTERNS.md +++ b/docs/contracts/BANNED_PATTERNS.md @@ -126,3 +126,27 @@ def compile_traversal_gate(spec: GateSpec) -> str: **Scope note:** these rules apply to `engine/` only. Abstract base classes in `chassis/` (for example `AuditSink.write_batch`) raise `NotImplementedError` as their defining contract — that is the intended use, not a stub. + +### `typing.Protocol` method bodies (CEG#267) + +A Protocol method is a structural signature. It is never instantiated and never +executed. The only body that is valid Python *and* clean on every in-repo gate +is a docstring and nothing else: + +| Body | `STUB-001` | ruff `PIE790` | github-code-quality | +|---|---|---|---| +| `raise NotImplementedError` | CRITICAL (blocks merge) | clean | clean | +| `pass` | clean | PIE790 | clean | +| `...` | clean | clean | “Statement has no effect” | +| docstring only | clean | clean | clean | + +Do not “fix” a Protocol by raising. That is the opposite of a stub-free engine: +it trips the blocking scanner so a review bot can go quiet. Chassis ABCs may +still raise; `engine/` Protocols may not. + +```python +# ✅ CORRECT — Protocol signature, no executable statement +class GraphWriter(Protocol): + async def execute_write(self, *args: Any, **kwargs: Any) -> Any: + """Run one managed write transaction.""" +``` diff --git a/engine/hoprag/indexer.py b/engine/hoprag/indexer.py index 7279c1ce..23cc8b79 100644 --- a/engine/hoprag/indexer.py +++ b/engine/hoprag/indexer.py @@ -67,7 +67,6 @@ async def fetch_passages( Returns: List of dicts with 'id' and 'text' keys. """ - ... async def write_edges( self, @@ -83,7 +82,6 @@ async def write_edges( Returns: Number of edges written. """ - ... async def get_vertex_count(self, label: str) -> int: """Count vertices with given label. @@ -94,7 +92,6 @@ async def get_vertex_count(self, label: str) -> int: Returns: Number of vertices. """ - ... @dataclass diff --git a/engine/sync/idea_portfolio.py b/engine/sync/idea_portfolio.py index 32d8fdf4..dab4580e 100644 --- a/engine/sync/idea_portfolio.py +++ b/engine/sync/idea_portfolio.py @@ -222,7 +222,6 @@ async def execute_write( **kwargs: Any, ) -> dict[str, Any] | Any: """Run one managed write transaction, via a transaction function or `cypher`.""" - ... @dataclass(frozen=True) diff --git a/engine/traversal/multihop.py b/engine/traversal/multihop.py index 779ba673..c0731ce7 100644 --- a/engine/traversal/multihop.py +++ b/engine/traversal/multihop.py @@ -75,7 +75,6 @@ def evaluate_edges( Returns: Index of the selected edge in candidate_edges. """ - ... @dataclass(frozen=True) @@ -132,7 +131,6 @@ async def get_outgoing_edges(self, vertex_id: str) -> list[TraversalEdge]: Returns: List of TraversalEdge objects. """ - ... class MultiHopTraverser: diff --git a/engine/traversal/pseudo_query.py b/engine/traversal/pseudo_query.py index 0f71cbc2..8527a7eb 100644 --- a/engine/traversal/pseudo_query.py +++ b/engine/traversal/pseudo_query.py @@ -126,7 +126,6 @@ def generate(self, prompt: str) -> str: Returns: Generated text response. """ - ... class KeywordExtractor(Protocol): @@ -141,7 +140,6 @@ def extract(self, text: str) -> frozenset[str]: Returns: Set of extracted keyword strings. """ - ... class EmbeddingEncoder(Protocol): @@ -156,7 +154,6 @@ def encode(self, text: str) -> tuple[float, ...]: Returns: Embedding vector as tuple of floats. """ - ... # ── Main Generator ─────────────────────────────────────────────────── diff --git a/tests/unit/test_protocol_bodies.py b/tests/unit/test_protocol_bodies.py new file mode 100644 index 00000000..54fbf095 --- /dev/null +++ b/tests/unit/test_protocol_bodies.py @@ -0,0 +1,66 @@ +"""Protocol method bodies must stay lint-clean on every in-repo gate (CEG#267).""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +ENGINE = Path(__file__).resolve().parents[2] / "engine" + + +def _protocol_methods(path: Path) -> list[tuple[str, str, ast.AST]]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + found: list[tuple[str, str, ast.AST]] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + if not any( + (isinstance(base, ast.Name) and base.id == "Protocol") + or (isinstance(base, ast.Attribute) and base.attr == "Protocol") + for base in node.bases + ): + continue + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + found.append((path.as_posix(), f"{node.name}.{item.name}", item)) + return found + + +def _engine_protocol_methods() -> list[tuple[str, str, ast.AST]]: + rows: list[tuple[str, str, ast.AST]] = [] + for path in sorted(ENGINE.rglob("*.py")): + rows.extend(_protocol_methods(path)) + return rows + + +@pytest.mark.unit +def test_engine_has_protocol_methods() -> None: + assert _engine_protocol_methods(), "expected at least one engine Protocol method" + + +@pytest.mark.unit +def test_protocol_methods_have_no_executable_stub() -> None: + failures: list[str] = [] + for rel, qualname, fn in _engine_protocol_methods(): + executable = [] + for stmt in fn.body: + if ( + isinstance(stmt, ast.Expr) + and isinstance(stmt.value, ast.Constant) + and isinstance(stmt.value.value, str) + ): + continue + executable.append(stmt) + for stmt in executable: + if isinstance(stmt, ast.Pass): + failures.append(f"{rel}:{qualname} uses pass (ruff PIE790)") + elif isinstance(stmt, ast.Raise) and isinstance(stmt.exc, ast.Call): + func = stmt.exc.func + name = func.id if isinstance(func, ast.Name) else "" + if name == "NotImplementedError": + failures.append(f"{rel}:{qualname} raises NotImplementedError (STUB-001)") + elif isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant) and stmt.value.value is Ellipsis: + failures.append(f"{rel}:{qualname} uses ... (github-code-quality no-op)") + assert failures == [], "\n".join(failures)