Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
58 changes: 30 additions & 28 deletions .github/workflows/opencode-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,14 @@ jobs:
append "- Required docstring evidence: repository-owned docstring gates must pass when configured; otherwise docstring coverage is advisory."
append ""

implementation_changed_files="$(mktemp)"
changed_files_for_coverage >"$implementation_changed_files"
run_and_capture "Python implementation completeness scan" \
python3 "$GITHUB_WORKSPACE/scripts/ci/implementation_completeness_scan.py" \
--repo-root . \
--changed-files "$implementation_changed_files"
rm -f "$implementation_changed_files"

measured_any=0

if has_changed_tracked_files '*.py'; then
Expand Down Expand Up @@ -1315,7 +1323,7 @@ jobs:
)
)
runs-on: ubuntu-latest
timeout-minutes: 120
timeout-minutes: 420
permissions:
actions: read
checks: read
Expand Down Expand Up @@ -2930,7 +2938,7 @@ jobs:
- name: Run OpenCode PR Review model pool
id: opencode_review_model_pool
if: needs.coverage-evidence.result == 'success'
timeout-minutes: 12
timeout-minutes: 350
continue-on-error: true
env:
STRIX_GITHUB_MODELS_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN || github.token }}
Expand All @@ -2946,30 +2954,24 @@ jobs:
SHARE: "false"
NPM_CONFIG_IGNORE_SCRIPTS: "true"
NO_COLOR: "1"
# High-sensitivity review candidates only. Mini/nano/o*-mini models
# previously consumed the whole job on context-window timeouts before
# the pool reached stronger reviewers, so the default pool now starts
# with GPT-5/o3-class models, keeps provider-diverse full-size
# fallbacks, and bounds provider stalls so the org queue releases with
# a visible MODEL_OUTPUT_UNAVAILABLE reason.
OPENCODE_MODEL_CANDIDATES: "openai/gpt-5 github-models/openai/gpt-5 github-models/openai/gpt-5-chat github-models/openai/o3 github-models/deepseek/deepseek-r1-0528"
# High-sensitivity review candidates only. DeepSeek V3 has been the
# most reliable first-pass reviewer in the org queue, then the pool
# falls through to full-size GPT/o3 and reasoning-capable fallbacks.
OPENCODE_MODEL_CANDIDATES: "github-models/deepseek/deepseek-v3-0324 openai/gpt-5 github-models/openai/gpt-5 github-models/openai/gpt-5-chat github-models/openai/o3 github-models/deepseek/deepseek-r1-0528 github-models/deepseek/deepseek-r1"
# One attempt per model, then fall through to the next model. Retrying
# the SAME model 5x let a rate-limited/hung leader consume the whole
# step, so the pool never reached a healthy fallback model.
OPENCODE_MODEL_ATTEMPTS: "1"
# Three minutes per model is enough for healthy providers to emit the
# required control block and short enough to avoid queue pileups when a
# provider stalls silently.
OPENCODE_RUN_TIMEOUT_SECONDS: "180"
OPENCODE_EXPORT_TIMEOUT_SECONDS: "60"
OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "540"
# Keep cycling through the high-sensitivity catalog until a model
# emits a valid control block or the bounded retry/step timeout fires.
# A single invalid no-control response is provider/tool state, not a
# source finding.
OPENCODE_POOL_CYCLE_SLEEP_SECONDS: "15"
OPENCODE_BACKOFF_INITIAL_SECONDS: "5"
OPENCODE_BACKOFF_MAX_SECONDS: "5"
# 90 minutes per model gives deep tool-using reviews room to finish;
# stale providers still yield within the bounded retry budget.
OPENCODE_RUN_TIMEOUT_SECONDS: "5400"
OPENCODE_EXPORT_TIMEOUT_SECONDS: "120"
OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "18000"
# Stop after one catalog pass; the retry budget fails closed with
# visible diagnostics before the 350-minute step timeout.
OPENCODE_POOL_MAX_CYCLES: "1"
OPENCODE_BACKOFF_INITIAL_SECONDS: "30"
OPENCODE_BACKOFF_MAX_SECONDS: "30"
OPENCODE_FIRST_ATTEMPT_AGENT: ci-review
OPENCODE_AGENT: ci-review-fallback
OPENCODE_EVIDENCE_FILE: ${{ runner.temp }}/opencode-review-evidence.md
Expand Down Expand Up @@ -3364,11 +3366,11 @@ jobs:
CHECK_LOOKUP_RETRY_SLEEP_SECONDS: "5"
REVIEW_PUBLISH_RETRY_ATTEMPTS: "3"
REVIEW_PUBLISH_RETRY_SLEEP_SECONDS: "20"
OPENCODE_MODEL_CANDIDATES: "openai/gpt-5 github-models/openai/gpt-5 github-models/openai/o3 github-models/deepseek/deepseek-r1-0528"
OPENCODE_MODEL_CANDIDATES: "github-models/deepseek/deepseek-v3-0324 openai/gpt-5 github-models/openai/gpt-5 github-models/openai/o3 github-models/deepseek/deepseek-r1-0528"
OPENCODE_MODEL_ATTEMPTS: "1"
OPENCODE_RUN_TIMEOUT_SECONDS: "120"
OPENCODE_EXPORT_TIMEOUT_SECONDS: "60"
OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "180"
OPENCODE_RUN_TIMEOUT_SECONDS: "5400"
OPENCODE_EXPORT_TIMEOUT_SECONDS: "120"
OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "10800"
OPENCODE_BACKOFF_INITIAL_SECONDS: "30"
OPENCODE_BACKOFF_MAX_SECONDS: "30"
OPENCODE_FIRST_ATTEMPT_AGENT: ci-review
Expand Down Expand Up @@ -4297,7 +4299,7 @@ jobs:
"scripts/ci/test_strix_quick_gate.sh"
emit_known_missing_string_finding \
"MODEL: github-models/deepseek/deepseek-r1-0528" \
"OpenCode review must start with DeepSeek R1" \
"OpenCode failed-check diagnosis must keep DeepSeek R1" \
".github/workflows/opencode-review.yml" \
"scripts/ci/test_strix_quick_gate.sh"

Expand Down Expand Up @@ -4705,7 +4707,7 @@ jobs:
} >"$prompt_file"

cd "$OPENCODE_REVIEW_WORKDIR"
if ! timeout --kill-after=30s "${OPENCODE_RUN_TIMEOUT_SECONDS:-600}s" opencode run "$(cat "$prompt_file")" \
if ! timeout --kill-after=30s "${OPENCODE_RUN_TIMEOUT_SECONDS:-5400}s" opencode run "$(cat "$prompt_file")" \
--pure \
--agent ci-review-fallback \
--model "$MODEL" \
Expand Down
7 changes: 4 additions & 3 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ title = "ContextualWisdomLab central gitleaks configuration"
[extend]
useDefault = true

[allowlist]
[[allowlists]]
description = "False-positive token-like strings used by scheduler scrubber regression tests only."
regexTarget = "match"
paths = [
'''(^|/)tests/test_pr_review_merge_scheduler\.py$''',
'''^\.gitleaks\.toml$''',
'''(^|/)__pycache__/''',
'''\.pyc$''',
]
regexes = [
'''gh[pousr]_(?:1234567890abcdef(?:1234)?|abcdef1234567890abcdef1234567890abcdef|installation_token_value|user_token_value|server_token_value|runner_token_value|placeholder_token_with_underscores_123)''',
'''github_pat_11AAAAA_(?:abcdefg|abcdefg1234567890)''',
'''gh[pousr]_(?:1234567890abcdef(?:1234)?|abcdef1234567890abcdef1234567890abcdef|installation_token_value|user_token_value|server_token_value|runner_token_value|placeholder_token_with_underscores_123|[a-z]{16}|[a-z]{20}|[a-z]{30}|[a-z]{38})''',
'''github_pat_11AAAAA_(?:abcdefg|abcdefg1234567890|[A-Za-z]{7}_[a-z]{7}|[A-Za-z]{7}_[a-z]{17})''',
]
246 changes: 246 additions & 0 deletions scripts/ci/implementation_completeness_scan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""Detect executable Python placeholder implementations in changed runtime code."""

from __future__ import annotations

import argparse
import ast
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable


RUNTIME_TEST_PARTS = {
"test",
"tests",
"testing",
"fixture",
"fixtures",
}


@dataclass(frozen=True)
class Finding:
path: str
line: int
symbol: str
reason: str


class ClassContext:
def __init__(self, name: str, is_protocol_or_abc: bool) -> None:
self.name = name
self.is_protocol_or_abc = is_protocol_or_abc


def dotted_name(node: ast.AST) -> str:
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
parent = dotted_name(node.value)
return f"{parent}.{node.attr}" if parent else node.attr
if isinstance(node, ast.Subscript):
return dotted_name(node.value)
if isinstance(node, ast.Call):
return dotted_name(node.func)
return ""


def is_protocol_or_abc_base(node: ast.AST) -> bool:
name = dotted_name(node)
return name in {"Protocol", "typing.Protocol", "ABC", "abc.ABC"}


def is_abstract_or_overload(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
for decorator in node.decorator_list:
name = dotted_name(decorator)
if name.endswith(".abstractmethod") or name == "abstractmethod":
return True
if name.endswith(".overload") or name == "overload":
return True
return False


def strip_docstring(
body: list[ast.stmt],
) -> list[ast.stmt]:
if not body:
return body
first = body[0]
if (
isinstance(first, ast.Expr)
and isinstance(first.value, ast.Constant)
and isinstance(first.value.value, str)
):
return body[1:]
return body


def placeholder_reason(
node: ast.FunctionDef | ast.AsyncFunctionDef,
) -> str | None:
body = strip_docstring(node.body)
if len(body) != 1:
return None
only = body[0]
if isinstance(only, ast.Pass):
return "pass-only body"
if (
isinstance(only, ast.Expr)
and isinstance(only.value, ast.Constant)
and only.value.value is Ellipsis
):
return "ellipsis-only body"
if isinstance(only, ast.Raise) and only.exc is not None:
exc_name = dotted_name(only.exc)
if exc_name == "NotImplementedError":
return "raises NotImplementedError"
return None


class PlaceholderVisitor(ast.NodeVisitor):
def __init__(self, path: str) -> None:
self.path = path
self.class_stack: list[ClassContext] = []
self.findings: list[Finding] = []

def visit_ClassDef(self, node: ast.ClassDef) -> None:
is_protocol_or_abc = any(is_protocol_or_abc_base(base) for base in node.bases)
self.class_stack.append(ClassContext(node.name, is_protocol_or_abc))
self.generic_visit(node)
self.class_stack.pop()

def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
self._visit_function(node)

def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
self._visit_function(node)

def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
if any(context.is_protocol_or_abc for context in self.class_stack):
return
if is_abstract_or_overload(node):
return
reason = placeholder_reason(node)
if reason is not None:
symbol_parts = [context.name for context in self.class_stack] + [node.name]
self.findings.append(
Finding(
path=self.path,
line=node.lineno,
symbol=".".join(symbol_parts),
reason=reason,
)
)
self.generic_visit(node)


def is_runtime_python_path(path: Path) -> bool:
if path.suffix != ".py":
return False
return not any(part in RUNTIME_TEST_PARTS for part in path.parts)


def changed_paths_from_file(path: Path) -> list[Path]:
if not path.exists():
return []
changed_paths: list[Path] = []
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
clean_line = line.strip().lstrip("\ufeff")
if clean_line and not clean_line.startswith("/"):
changed_paths.append(Path(clean_line))
return changed_paths


def scan_python_file(repo_root: Path, relative_path: Path) -> list[Finding]:
source_path = repo_root / relative_path
source = source_path.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(relative_path))
visitor = PlaceholderVisitor(relative_path.as_posix())
visitor.visit(tree)
return visitor.findings


def scan_changed_paths(
repo_root: Path, changed_paths: Iterable[Path]
) -> tuple[list[Finding], list[str]]:
findings: list[Finding] = []
errors: list[str] = []
seen: set[str] = set()
for relative_path in changed_paths:
key = relative_path.as_posix()
if key in seen or not is_runtime_python_path(relative_path):
continue
seen.add(key)
source_path = repo_root / relative_path
if not source_path.is_file():
continue
try:
findings.extend(scan_python_file(repo_root, relative_path))
except SyntaxError as exc:
line = exc.lineno or 1
errors.append(f"{key}:{line} could not be parsed: {exc.msg}")
return findings, errors


def render_report(findings: list[Finding], errors: list[str], checked_count: int) -> str:
lines = [
"# Implementation Completeness Scan",
"",
f"- Checked runtime Python files: {checked_count}",
"- Declaration handling: typing.Protocol, abc.ABC, @abstractmethod, and @overload placeholders are treated as contracts, not executable missing implementations.",
]
if errors:
lines.extend(
[
"- Result: FAIL",
"- Reason: one or more changed Python runtime files could not be parsed before placeholder scanning.",
"",
"Parse errors:",
]
)
lines.extend(f"- {error}" for error in errors)
return "\n".join(lines) + "\n"
if findings:
lines.extend(
[
"- Result: FAIL",
"- Reason: changed runtime code contains executable placeholder implementations.",
"",
"Findings:",
]
)
lines.extend(
f"- {finding.path}:{finding.line} `{finding.symbol}` - {finding.reason}"
for finding in findings
)
return "\n".join(lines) + "\n"
lines.extend(
[
"- Result: PASS",
"- Reason: no executable placeholder implementations were found in changed runtime Python files.",
]
)
return "\n".join(lines) + "\n"


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--repo-root", default=".")
parser.add_argument("--changed-files", required=True)
args = parser.parse_args()

repo_root = Path(args.repo_root).resolve()
changed_paths = changed_paths_from_file(Path(args.changed_files))
runtime_paths = [
path
for path in dict.fromkeys(changed_paths)
if is_runtime_python_path(path) and (repo_root / path).is_file()
]
findings, errors = scan_changed_paths(repo_root, runtime_paths)
print(render_report(findings, errors, len(runtime_paths)), end="")
return 1 if findings or errors else 0


if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
Loading
Loading