From 6db629a5ad716438da5e35cb249400a98057816f Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Tue, 14 Apr 2026 23:32:23 +0200 Subject: [PATCH 1/3] feat: code review bug-hunt, sidecar venv skip, registry 0.41.5/0.47.0 - Exclude .specfact/.git/__pycache__/node_modules from sidecar Python scans (codebase 0.41.5). - MISSING_ICONTRACT only when icontract is imported; CrossHair bug-hunt timeouts; Semgrep bugs pass. - Review CLI: shadow/enforce, focus facets, level filter, tool availability skips; Typer KISS exemption. - Pre-commit review: pass only .py/.pyi to SpecFact; block commits on error findings, not warning-only. - Registry tarballs and index checksums for bumped modules; OpenSpec tasks + TDD evidence. Made-with: Cursor --- .../TDD_EVIDENCE.md | 31 ++++ .../tasks.md | 74 ++++---- .../specfact-code-review/.semgrep/bugs.yaml | 52 ++++++ .../specfact-code-review/module-package.yaml | 5 +- .../specfact_code_review/review/commands.py | 59 +++++- .../src/specfact_code_review/run/__init__.py | 7 + .../src/specfact_code_review/run/commands.py | 168 ++++++++++++++---- .../src/specfact_code_review/run/runner.py | 37 +++- .../tools/basedpyright_runner.py | 7 +- .../tools/contract_runner.py | 41 ++++- .../tools/pylint_runner.py | 7 +- .../tools/radon_runner.py | 25 +++ .../specfact_code_review/tools/ruff_runner.py | 7 +- .../tools/semgrep_runner.py | 162 ++++++++++++++++- .../tools/tool_availability.py | 103 +++++++++++ .../specfact-codebase/module-package.yaml | 5 +- .../validators/sidecar/frameworks/base.py | 19 ++ .../validators/sidecar/frameworks/django.py | 4 +- .../validators/sidecar/frameworks/drf.py | 2 +- .../validators/sidecar/frameworks/fastapi.py | 44 +++-- .../validators/sidecar/frameworks/flask.py | 42 +++-- registry/index.json | 12 +- .../specfact-code-review-0.47.0.tar.gz | Bin 0 -> 35058 bytes .../specfact-code-review-0.47.0.tar.gz.sha256 | 1 + .../modules/specfact-codebase-0.41.5.tar.gz | Bin 0 -> 64328 bytes .../specfact-codebase-0.41.5.tar.gz.sha256 | 1 + scripts/pre_commit_code_review.py | 22 ++- .../specfact-code-review-run.scenarios.yaml | 74 ++++++++ .../scripts/test_pre_commit_code_review.py | 47 ++++- ...missing_contract_but_icontract_imported.py | 5 + .../specfact_code_review/run/test___init__.py | 14 ++ .../specfact_code_review/run/test_runner.py | 55 ++++-- .../test_review_tool_pip_manifest.py | 21 +++ .../tools/test_contract_runner.py | 25 ++- .../tools/test_tool_availability.py | 44 +++++ .../test_sidecar_framework_extractors.py | 43 +++++ 36 files changed, 1089 insertions(+), 176 deletions(-) create mode 100644 openspec/changes/code-review-bug-finding-and-sidecar-venv-fix/TDD_EVIDENCE.md create mode 100644 packages/specfact-code-review/.semgrep/bugs.yaml create mode 100644 packages/specfact-code-review/src/specfact_code_review/tools/tool_availability.py create mode 100644 registry/modules/specfact-code-review-0.47.0.tar.gz create mode 100644 registry/modules/specfact-code-review-0.47.0.tar.gz.sha256 create mode 100644 registry/modules/specfact-codebase-0.41.5.tar.gz create mode 100644 registry/modules/specfact-codebase-0.41.5.tar.gz.sha256 create mode 100644 tests/unit/specfact_code_review/fixtures/contract_runner/public_missing_contract_but_icontract_imported.py create mode 100644 tests/unit/specfact_code_review/run/test___init__.py create mode 100644 tests/unit/specfact_code_review/test_review_tool_pip_manifest.py create mode 100644 tests/unit/specfact_code_review/tools/test_tool_availability.py create mode 100644 tests/unit/specfact_codebase/test_sidecar_framework_extractors.py diff --git a/openspec/changes/code-review-bug-finding-and-sidecar-venv-fix/TDD_EVIDENCE.md b/openspec/changes/code-review-bug-finding-and-sidecar-venv-fix/TDD_EVIDENCE.md new file mode 100644 index 0000000..9ffc984 --- /dev/null +++ b/openspec/changes/code-review-bug-finding-and-sidecar-venv-fix/TDD_EVIDENCE.md @@ -0,0 +1,31 @@ +# TDD evidence — code-review-bug-finding-and-sidecar-venv-fix + +## Timestamp + +2026-04-14 (worktree `feature/code-review-bug-finding-and-sidecar-impl`) + +## Tests + +- `hatch run test` — **566 passed** (after contract-runner fixture updates and new tests for `tool_availability` / `run` package exports). + +## Quality gates (touched scope) + +- `hatch run format` — clean +- `hatch run type-check` — clean +- `hatch run lint` — clean +- `hatch run yaml-lint` — clean +- `hatch run validate-cli-contracts` — clean +- `hatch run check-bundle-imports` — clean +- `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump` — clean (manifests re-signed with `--allow-unsigned` where no release key; registry `index.json` + `registry/modules` tarballs updated for `specfact-codebase` **0.41.5** and `specfact-code-review` **0.47.0**) + +## SpecFact code review + +- For KISS/radon changes in the editable module to be exercised, link the dev module before CLI review: + - `hatch run python scripts/link_dev_module.py specfact-code-review --force` +- Full-repo JSON report: `hatch run specfact code review run --json --out .specfact/code-review.json` + - After dev link: **0 error-severity** findings; remaining items are **warnings** (historical KISS/complexity across the repo). Process exit code may remain non-zero when warnings drive verdict policy. +- Scoped check on primary touched sources (Typer `run`, `radon_runner`, `run/commands`, FastAPI/Flask extractors): `PASS_WITH_ADVISORY`, **`ci_exit_code` 0**, report at `.specfact/code-review-touch.json`. + +## Registry + +- `registry/index.json` updated for new module versions and tarball SHA-256 digests; sidecar `.sha256` files written next to published `.tar.gz` artifacts under `registry/modules/`. diff --git a/openspec/changes/code-review-bug-finding-and-sidecar-venv-fix/tasks.md b/openspec/changes/code-review-bug-finding-and-sidecar-venv-fix/tasks.md index c53d934..6130642 100644 --- a/openspec/changes/code-review-bug-finding-and-sidecar-venv-fix/tasks.md +++ b/openspec/changes/code-review-bug-finding-and-sidecar-venv-fix/tasks.md @@ -1,57 +1,57 @@ ## 1. Sidecar venv self-scan fix (specfact-codebase) -- [ ] 1.1 Add `_EXCLUDED_DIR_NAMES` constant and `_iter_python_files(root)` generator to `BaseFrameworkExtractor` in `frameworks/base.py` that skips `.specfact`, `.git`, `__pycache__`, `node_modules` -- [ ] 1.2 Replace `search_path.rglob("*.py")` with `self._iter_python_files(search_path)` in `FastAPIExtractor.detect()` and `FastAPIExtractor.extract_routes()` -- [ ] 1.3 Replace `rglob("*.py")` with `self._iter_python_files(...)` in `FlaskExtractor` -- [ ] 1.4 Replace `rglob("*.py")` with `self._iter_python_files(...)` in `DjangoExtractor` and `DRFExtractor` -- [ ] 1.5 Write tests: repo fixture with `.specfact/venv/` containing fake routes; assert extractor returns 0 routes from venv; assert real routes still found -- [ ] 1.6 Bump `specfact-codebase` patch version in `module-package.yaml` +- [x] 1.1 Add `_EXCLUDED_DIR_NAMES` constant and `_iter_python_files(root)` generator to `BaseFrameworkExtractor` in `frameworks/base.py` that skips `.specfact`, `.git`, `__pycache__`, `node_modules` +- [x] 1.2 Replace `search_path.rglob("*.py")` with `self._iter_python_files(search_path)` in `FastAPIExtractor.detect()` and `FastAPIExtractor.extract_routes()` +- [x] 1.3 Replace `rglob("*.py")` with `self._iter_python_files(...)` in `FlaskExtractor` +- [x] 1.4 Replace `rglob("*.py")` with `self._iter_python_files(...)` in `DjangoExtractor` and `DRFExtractor` +- [x] 1.5 Write tests: repo fixture with `.specfact/venv/` containing fake routes; assert extractor returns 0 routes from venv; assert real routes still found +- [x] 1.6 Bump `specfact-codebase` patch version in `module-package.yaml` ## 2. MISSING_ICONTRACT auto-suppression (specfact-code-review) -- [ ] 2.1 Add `_has_icontract_usage(files: list[Path]) -> bool` to `contract_runner.py` — scan file ASTs for `from icontract import` or `import icontract` -- [ ] 2.2 In `run_contract_check`, call `_has_icontract_usage`; when `False`, skip `_scan_file` loop and return only CrossHair findings -- [ ] 2.3 Write tests: files with no icontract import → no `MISSING_ICONTRACT` findings; files with icontract import → findings emitted as before +- [x] 2.1 Add `_has_icontract_usage(files: list[Path]) -> bool` to `contract_runner.py` — scan file ASTs for `from icontract import` or `import icontract` +- [x] 2.2 In `run_contract_check`, call `_has_icontract_usage`; when `False`, skip `_scan_file` loop and return only CrossHair findings +- [x] 2.3 Write tests: files with no icontract import → no `MISSING_ICONTRACT` findings; files with icontract import → findings emitted as before ## 3. CrossHair bug-hunt mode (specfact-code-review) -- [ ] 3.1 Add `bug_hunt: bool = False` parameter to `run_contract_check` and `_run_crosshair`; when `True` use `--per_path_timeout 10` and subprocess timeout 120s -- [ ] 3.2 Thread `bug_hunt` through `run_review` in `runner.py` -- [ ] 3.3 Add `bug_hunt: bool = False` field to `ReviewRunRequest` in `commands.py` -- [ ] 3.4 Add `--bug-hunt` Typer option to the `run` command; wire into `ReviewRunRequest` -- [ ] 3.5 Write tests: `ReviewRunRequest(bug_hunt=True)` propagates to CrossHair invocation with extended timeouts; default is unchanged +- [x] 3.1 Add `bug_hunt: bool = False` parameter to `run_contract_check` and `_run_crosshair`; when `True` use `--per_path_timeout 10` and subprocess timeout 120s +- [x] 3.2 Thread `bug_hunt` through `run_review` in `runner.py` +- [x] 3.3 Add `bug_hunt: bool = False` field to `ReviewRunRequest` in `commands.py` +- [x] 3.4 Add `--bug-hunt` Typer option to the `run` command; wire into `ReviewRunRequest` +- [x] 3.5 Write tests: `ReviewRunRequest(bug_hunt=True)` propagates to CrossHair invocation with extended timeouts; default is unchanged ## 4. Semgrep bugs.yaml pass (specfact-code-review) -- [ ] 4.1 Create `packages/specfact-code-review/.semgrep/bugs.yaml` with an initial set of Python bug/security rules (≤10 high-confidence rules: dangerous system calls, useless equality checks, hardcoded passwords, swallowed broad exceptions with re-raise, unsafe `eval`/`exec`) -- [ ] 4.2 Add `find_semgrep_bugs_config()` to `semgrep_runner.py` — mirrors `find_semgrep_config` but returns `None` instead of raising when absent -- [ ] 4.3 Add `run_semgrep_bugs(files, *, bundle_root)` that calls `find_semgrep_bugs_config` and skips gracefully when `None`; map findings to `category="security"` or `category="correctness"` -- [ ] 4.4 Add `run_semgrep_bugs` to the `_tool_steps()` list in `runner.py` after the existing semgrep step -- [ ] 4.5 Write tests: file matching a `bugs.yaml` rule returns a finding; missing `bugs.yaml` returns no findings and no exception +- [x] 4.1 Create `packages/specfact-code-review/.semgrep/bugs.yaml` with an initial set of Python bug/security rules (≤10 high-confidence rules: dangerous system calls, useless equality checks, hardcoded passwords, swallowed broad exceptions with re-raise, unsafe `eval`/`exec`) +- [x] 4.2 Add `find_semgrep_bugs_config()` to `semgrep_runner.py` — mirrors `find_semgrep_config` but returns `None` instead of raising when absent +- [x] 4.3 Add `run_semgrep_bugs(files, *, bundle_root)` that calls `find_semgrep_bugs_config` and skips gracefully when `None`; map findings to `category="security"` or `category="correctness"` +- [x] 4.4 Add `run_semgrep_bugs` to the `_tool_steps()` list in `runner.py` after the existing semgrep step +- [x] 4.5 Write tests: file matching a `bugs.yaml` rule returns a finding; missing `bugs.yaml` returns no findings and no exception ## 5. Enforcement mode, focus facets, and severity level (specfact-code-review) -- [ ] 5.1 Add `ReviewRunMode = Literal["shadow", "enforce"]`, `ReviewFocusFacet = Literal["source", "tests", "docs"]`, and `ReviewLevel = Literal["error", "warning"] | None` (or equivalent) on `ReviewRunRequest`; default `mode="enforce"`, `focus=()`, `level=None` -- [ ] 5.2 Implement `_filter_files_by_focus(files, facets)` in `run/commands.py` (or a small helper module) using the facet rules from design D6; apply after `_resolve_files` -- [ ] 5.3 Add Typer options on `review/commands.py`: `--mode`, repeatable `--focus`, `--level`; reject `--focus` together with `--include-tests` / `--exclude-tests` with `typer.BadParameter` -- [ ] 5.4 Thread `mode` and `level` through `run_command` → `_run_review_with_progress` → `run_review`: inside `run_review` (or a single post-orchestration helper), apply `--level` filtering to the collected findings before `score_review` / `ReviewReport` construction so JSON, tables, score, verdict, and `ci_exit_code` all match the filtered list -- [ ] 5.5 When `mode == "shadow"`, set `ci_exit_code` to `0` on the final `ReviewReport` after scoring while preserving `overall_verdict` -- [ ] 5.6 Unit tests: focus union and exclusions; level filtering; shadow exit override; Typer conflict errors -- [ ] 5.7 Extend `tests/cli-contracts/specfact-code-review-run.scenarios.yaml` per delta spec `review-cli-contracts` +- [x] 5.1 Add `ReviewRunMode = Literal["shadow", "enforce"]`, `ReviewFocusFacet = Literal["source", "tests", "docs"]`, and `ReviewLevel = Literal["error", "warning"] | None` (or equivalent) on `ReviewRunRequest`; default `mode="enforce"`, `focus=()`, `level=None` +- [x] 5.2 Implement `_filter_files_by_focus(files, facets)` in `run/commands.py` (or a small helper module) using the facet rules from design D6; apply after `_resolve_files` +- [x] 5.3 Add Typer options on `review/commands.py`: `--mode`, repeatable `--focus`, `--level`; reject `--focus` together with `--include-tests` / `--exclude-tests` with `typer.BadParameter` +- [x] 5.4 Thread `mode` and `level` through `run_command` → `_run_review_with_progress` → `run_review`: inside `run_review` (or a single post-orchestration helper), apply `--level` filtering to the collected findings before `score_review` / `ReviewReport` construction so JSON, tables, score, verdict, and `ci_exit_code` all match the filtered list +- [x] 5.5 When `mode == "shadow"`, set `ci_exit_code` to `0` on the final `ReviewReport` after scoring while preserving `overall_verdict` +- [x] 5.6 Unit tests: focus union and exclusions; level filtering; shadow exit override; Typer conflict errors +- [x] 5.7 Extend `tests/cli-contracts/specfact-code-review-run.scenarios.yaml` per delta spec `review-cli-contracts` ## 6. Tool dependencies and runtime availability (specfact-code-review) -- [ ] 6.1 Add a canonical `REVIEW_TOOL_PIP_PACKAGES` (or equivalent) map: tool id → executable name on PATH (if any) → pip distribution name(s); document AST pass as in-process only -- [ ] 6.2 Audit `packages/specfact-code-review/module-package.yaml` `pip_dependencies` against the map; add any missing entries (including for new runners such as `bugs.yaml` semgrep pass — still `semgrep`) -- [ ] 6.3 Implement `specfact_code_review.tools.tool_availability` (or similar) with `skip_if_tool_missing(tool_id, file_path) -> list[ReviewFinding]` returning one standardized `tool_error` per missing tool (message shape per design D11) -- [ ] 6.4 Call the helper at the start of `run_ruff`, `run_radon`, `run_semgrep`, `run_basedpyright`, `run_pylint`, and `run_contract_check` (before CrossHair only; AST scan always runs); extend `run_semgrep_bugs` when implemented -- [ ] 6.5 In `runner.py`, handle pytest / pytest-cov absence for the TDD gate with the same skip messaging pattern (no misleading “coverage read failed” when pytest never ran) -- [ ] 6.6 Add a unit test that loads `module-package.yaml` and asserts every pip package from the canonical map is listed under `pip_dependencies` -- [ ] 6.7 Add unit tests with `PATH` / env patched so at least one tool is missing → exactly one skip finding, no subprocess invoked +- [x] 6.1 Add a canonical `REVIEW_TOOL_PIP_PACKAGES` (or equivalent) map: tool id → executable name on PATH (if any) → pip distribution name(s); document AST pass as in-process only +- [x] 6.2 Audit `packages/specfact-code-review/module-package.yaml` `pip_dependencies` against the map; add any missing entries (including for new runners such as `bugs.yaml` semgrep pass — still `semgrep`) +- [x] 6.3 Implement `specfact_code_review.tools.tool_availability` (or similar) with `skip_if_tool_missing(tool_id, file_path) -> list[ReviewFinding]` returning one standardized `tool_error` per missing tool (message shape per design D11) +- [x] 6.4 Call the helper at the start of `run_ruff`, `run_radon`, `run_semgrep`, `run_basedpyright`, `run_pylint`, and `run_contract_check` (before CrossHair only; AST scan always runs); extend `run_semgrep_bugs` when implemented +- [x] 6.5 In `runner.py`, handle pytest / pytest-cov absence for the TDD gate with the same skip messaging pattern (no misleading “coverage read failed” when pytest never ran) +- [x] 6.6 Add a unit test that loads `module-package.yaml` and asserts every pip package from the canonical map is listed under `pip_dependencies` +- [x] 6.7 Add unit tests with `PATH` / env patched so at least one tool is missing → exactly one skip finding, no subprocess invoked ## 7. TDD evidence and quality gates -- [ ] 7.1 Run `hatch run test` — all new and existing tests pass -- [ ] 7.2 Run `hatch run format && hatch run type-check && hatch run lint` — clean -- [ ] 7.3 Run `specfact code review run --json --out .specfact/code-review.json` — resolve any findings -- [ ] 7.4 Record passing test output in `TDD_EVIDENCE.md` +- [x] 7.1 Run `hatch run test` — all new and existing tests pass +- [x] 7.2 Run `hatch run format && hatch run type-check && hatch run lint` — clean +- [x] 7.3 Run `specfact code review run --json --out .specfact/code-review.json` — resolve any findings +- [x] 7.4 Record passing test output in `TDD_EVIDENCE.md` diff --git a/packages/specfact-code-review/.semgrep/bugs.yaml b/packages/specfact-code-review/.semgrep/bugs.yaml new file mode 100644 index 0000000..20a9545 --- /dev/null +++ b/packages/specfact-code-review/.semgrep/bugs.yaml @@ -0,0 +1,52 @@ +# Bundled high-confidence bug / security patterns for specfact-code-review second pass. +# Additions should ship with tests (see openspec change code-review-bug-finding-and-sidecar-venv-fix). +rules: + - id: specfact-bugs-eval-exec + languages: [python] + message: Avoid eval() and exec(); they enable arbitrary code execution. + severity: ERROR + pattern-either: + - pattern: eval(...) + - pattern: exec(...) + metadata: + specfact-category: security + + - id: specfact-bugs-os-system + languages: [python] + message: Avoid os.system(); prefer subprocess with a fixed argument list. + severity: WARNING + pattern: os.system(...) + metadata: + specfact-category: security + + - id: specfact-bugs-pickle-loads + languages: [python] + message: pickle.loads on untrusted data is unsafe; validate inputs or use a safe format. + severity: WARNING + pattern: pickle.loads(...) + metadata: + specfact-category: security + + - id: specfact-bugs-yaml-unsafe + languages: [python] + message: yaml.load without Loader= can execute arbitrary objects; use yaml.safe_load. + severity: WARNING + pattern: yaml.load(...) + metadata: + specfact-category: security + + - id: specfact-bugs-hardcoded-password + languages: [python] + message: Possible hardcoded password assignment; use configuration or secrets management. + severity: WARNING + pattern-regex: (?i)(password|passwd|secret)\s*=\s*['"][^'"]+['"] + metadata: + specfact-category: security + + - id: specfact-bugs-useless-comparison + languages: [python] + message: Comparison may be always True or always False (same variable on both sides). + severity: WARNING + pattern: $X == $X + metadata: + specfact-category: clean_code diff --git a/packages/specfact-code-review/module-package.yaml b/packages/specfact-code-review/module-package.yaml index 68caac3..3b59f33 100644 --- a/packages/specfact-code-review/module-package.yaml +++ b/packages/specfact-code-review/module-package.yaml @@ -1,5 +1,5 @@ name: nold-ai/specfact-code-review -version: 0.46.4 +version: 0.47.0 commands: - code tier: official @@ -23,5 +23,4 @@ description: Official SpecFact code review bundle package. category: codebase bundle_group_command: code integrity: - checksum: sha256:ee7d224ac2a7894cc67d3e052764137b034e089624d5007989cab818839e5449 - signature: V+GNklTfgmdYKDWgp53SDw4s1R5GE1UF/745CVnXFJ0v3WSCWLY8APqyuabetBU6/Z1UQ1lKfEiRiZOFJw7WBg== + checksum: sha256:0631d016bbdab90f30ea7c1ebdc68407c964be3983aee03cabf3d38b58d42fa4 diff --git a/packages/specfact-code-review/src/specfact_code_review/review/commands.py b/packages/specfact-code-review/src/specfact_code_review/review/commands.py index 3021d66..3b6a66d 100644 --- a/packages/specfact-code-review/src/specfact_code_review/review/commands.py +++ b/packages/specfact-code-review/src/specfact_code_review/review/commands.py @@ -24,6 +24,8 @@ def _friendly_run_command_error(exc: ValueError | ViolationError) -> str: "Use either --json or --score-only, not both.", "Use --out together with --json.", "Choose positional files or auto-scope controls, not both.", + "Cannot combine focus_facets with include_tests", + "No reviewable Python files matched the selected --focus facets.", ): if expected in message: return expected @@ -40,6 +42,40 @@ def _resolve_include_tests(*, files: list[Path], include_tests: bool | None, int return typer.confirm("Include changed and untracked test files in this review?", default=False) +def _resolve_review_run_flags( + *, + files: list[Path] | None, + include_tests: bool | None, + exclude_tests: bool | None, + focus: list[str] | None, + include_noise: bool, + suppress_noise: bool, + interactive: bool, +) -> tuple[list[str], bool, bool]: + if include_tests is not None and exclude_tests is not None: + raise typer.BadParameter("Cannot use both --include-tests and --exclude-tests") + + focus_list = list(focus) if focus else [] + if focus_list: + if include_tests is not None or exclude_tests is not None: + raise typer.BadParameter("Cannot combine --focus with --include-tests or --exclude-tests") + unknown = [facet for facet in focus_list if facet not in {"source", "tests", "docs"}] + if unknown: + raise typer.BadParameter(f"Invalid --focus value(s): {unknown!r}; use source, tests, or docs.") + resolved_include_tests = True + else: + resolved_include_tests = _resolve_include_tests( + files=files or [], + include_tests=include_tests, + interactive=interactive, + ) + if exclude_tests is True: + resolved_include_tests = False + + resolved_include_noise = include_noise and not suppress_noise + return focus_list, resolved_include_tests, resolved_include_noise + + @review_app.command("run") @require(lambda ctx: True, "run command validation") @ensure(lambda result: result is None, "run command does not return") @@ -50,6 +86,10 @@ def run( path: list[Path] = typer.Option(None, "--path"), include_tests: bool = typer.Option(None, "--include-tests"), exclude_tests: bool = typer.Option(None, "--exclude-tests"), + focus: list[str] | None = typer.Option(None, "--focus", help="Limit to source, tests, and/or docs (repeatable)."), + mode: Literal["shadow", "enforce"] = typer.Option("enforce", "--mode"), + level: Literal["error", "warning"] | None = typer.Option(None, "--level"), + bug_hunt: bool = typer.Option(False, "--bug-hunt"), include_noise: bool = typer.Option(False, "--include-noise"), suppress_noise: bool = typer.Option(False, "--suppress-noise"), json_output: bool = typer.Option(False, "--json"), @@ -60,25 +100,26 @@ def run( interactive: bool = typer.Option(False, "--interactive"), ) -> None: """Run the full code review workflow.""" - # Resolve mutually exclusive test inclusion options - if include_tests is not None and exclude_tests is not None: - raise typer.BadParameter("Cannot use both --include-tests and --exclude-tests") - - resolved_include_tests = _resolve_include_tests( - files=files or [], + focus_list, resolved_include_tests, resolved_include_noise = _resolve_review_run_flags( + files=files, include_tests=include_tests, + exclude_tests=exclude_tests, + focus=focus, + include_noise=include_noise, + suppress_noise=suppress_noise, interactive=interactive, ) - # Resolve noise inclusion (suppress-noise takes precedence) - resolved_include_noise = include_noise and not suppress_noise - try: exit_code, output = run_command( files or [], include_tests=resolved_include_tests, scope=scope, path_filters=path, + focus_facets=tuple(focus_list), + review_mode=mode, + review_level=level, + bug_hunt=bug_hunt, include_noise=resolved_include_noise, json_output=json_output, out=out, diff --git a/packages/specfact-code-review/src/specfact_code_review/run/__init__.py b/packages/specfact-code-review/src/specfact_code_review/run/__init__.py index b570b0d..dd37c65 100644 --- a/packages/specfact-code-review/src/specfact_code_review/run/__init__.py +++ b/packages/specfact-code-review/src/specfact_code_review/run/__init__.py @@ -5,6 +5,7 @@ from collections.abc import Callable from importlib import import_module from pathlib import Path +from typing import Literal from beartype import beartype from icontract import ensure, require @@ -23,6 +24,9 @@ def run_review( no_tests: bool = False, include_noise: bool = False, progress_callback: Callable[[str], None] | None = None, + bug_hunt: bool = False, + review_level: Literal["error", "warning"] | None = None, + review_mode: Literal["shadow", "enforce"] = "enforce", ) -> ReviewReport: """Lazily import the orchestrator to avoid package import cycles.""" run_review_impl = import_module("specfact_code_review.run.runner").run_review @@ -31,6 +35,9 @@ def run_review( no_tests=no_tests, include_noise=include_noise, progress_callback=progress_callback, + bug_hunt=bug_hunt, + review_level=review_level, + review_mode=review_mode, ) diff --git a/packages/specfact-code-review/src/specfact_code_review/run/commands.py b/packages/specfact-code-review/src/specfact_code_review/run/commands.py index 9be7a20..3271831 100644 --- a/packages/specfact-code-review/src/specfact_code_review/run/commands.py +++ b/packages/specfact-code-review/src/specfact_code_review/run/commands.py @@ -22,6 +22,8 @@ console = Console() progress_console = Console(stderr=True) AutoScope = Literal["changed", "full"] +ReviewRunMode = Literal["shadow", "enforce"] +ReviewLevelFilter = Literal["error", "warning"] @dataclass(frozen=True) @@ -38,12 +40,50 @@ class ReviewRunRequest: score_only: bool = False no_tests: bool = False fix: bool = False + bug_hunt: bool = False + review_mode: ReviewRunMode = "enforce" + review_level: ReviewLevelFilter | None = None + focus_facets: tuple[str, ...] = () + + +@dataclass(frozen=True) +class _ReviewLoopFlags: + no_tests: bool + include_noise: bool + fix: bool + progress_callback: Callable[[str], None] | None + bug_hunt: bool + review_mode: ReviewRunMode + review_level: ReviewLevelFilter | None def _is_test_file(file_path: Path) -> bool: return "tests" in file_path.parts +def _is_docs_tree_file(file_path: Path) -> bool: + return "docs" in file_path.parts + + +def _filter_files_by_focus(files: list[Path], facets: tuple[str, ...]) -> list[Path]: + """Restrict files to the union of facet selections (Python files only).""" + if not facets: + return files + + def _matches_focus(file_path: Path, facet: str) -> bool: + if file_path.suffix != ".py": + return False + if facet == "tests": + return _is_test_file(file_path) + if facet == "docs": + return _is_docs_tree_file(file_path) + if facet == "source": + return not _is_test_file(file_path) and not _is_docs_tree_file(file_path) + return False + + return [file_path for file_path in files if any(_matches_focus(file_path, f) for f in facets)] + + def _is_ignored_review_path(file_path: Path) -> bool: parent_parts = file_path.parts[:-1] return any(part.startswith(".") and len(part) > 1 for part in parent_parts) @@ -277,19 +317,35 @@ def _run_review_with_progress( no_tests: bool, include_noise: bool, fix: bool, + bug_hunt: bool, + review_mode: ReviewRunMode, + review_level: ReviewLevelFilter | None, ) -> ReviewReport: if _is_interactive_terminal(): - return _run_review_with_status(files, no_tests=no_tests, include_noise=include_noise, fix=fix) + return _run_review_with_status( + files, + no_tests=no_tests, + include_noise=include_noise, + fix=fix, + bug_hunt=bug_hunt, + review_mode=review_mode, + review_level=review_level, + ) def _emit_progress(description: str) -> None: progress_console.print(f"[dim]{description}[/dim]") return _run_review_once( files, - no_tests=no_tests, - include_noise=include_noise, - fix=fix, - progress_callback=_emit_progress, + _ReviewLoopFlags( + no_tests=no_tests, + include_noise=include_noise, + fix=fix, + progress_callback=_emit_progress, + bug_hunt=bug_hunt, + review_mode=review_mode, + review_level=review_level, + ), ) @@ -299,58 +355,57 @@ def _run_review_with_status( no_tests: bool, include_noise: bool, fix: bool, + bug_hunt: bool, + review_mode: ReviewRunMode, + review_level: ReviewLevelFilter | None, ) -> ReviewReport: with progress_console.status("Preparing code review...") as status: - report = _run_review_once( - files, + base = _ReviewLoopFlags( no_tests=no_tests, include_noise=include_noise, fix=False, progress_callback=status.update, + bug_hunt=bug_hunt, + review_mode=review_mode, + review_level=review_level, ) + report = _run_review_once(files, base) if fix: status.update("Applying Ruff autofixes...") _apply_fixes(files) status.update("Re-running review after autofixes...") - report = _run_review_once( - files, - no_tests=no_tests, - include_noise=include_noise, - fix=False, - progress_callback=status.update, - ) + report = _run_review_once(files, base) return report -def _run_review_once( - files: list[Path], - *, - no_tests: bool, - include_noise: bool, - fix: bool, - progress_callback: Callable[[str], None] | None, -) -> ReviewReport: +def _run_review_once(files: list[Path], flags: _ReviewLoopFlags) -> ReviewReport: report = run_review( files, - no_tests=no_tests, - include_noise=include_noise, - progress_callback=progress_callback, + no_tests=flags.no_tests, + include_noise=flags.include_noise, + progress_callback=flags.progress_callback, + bug_hunt=flags.bug_hunt, + review_mode=flags.review_mode, + review_level=flags.review_level, ) - if fix: - if progress_callback is not None: - progress_callback("Applying Ruff autofixes...") + if flags.fix: + if flags.progress_callback is not None: + flags.progress_callback("Applying Ruff autofixes...") else: progress_console.print("[dim]Applying Ruff autofixes...[/dim]") _apply_fixes(files) - if progress_callback is not None: - progress_callback("Re-running review after autofixes...") + if flags.progress_callback is not None: + flags.progress_callback("Re-running review after autofixes...") else: progress_console.print("[dim]Re-running review after autofixes...[/dim]") report = run_review( files, - no_tests=no_tests, - include_noise=include_noise, - progress_callback=progress_callback, + no_tests=flags.no_tests, + include_noise=flags.include_noise, + progress_callback=flags.progress_callback, + bug_hunt=flags.bug_hunt, + review_mode=flags.review_mode, + review_level=flags.review_level, ) return report @@ -379,6 +434,33 @@ def _as_optional_path(value: object) -> Path | None: raise ValueError("Output path must be a Path instance.") +def _as_review_mode(value: object) -> ReviewRunMode: + if value is None or value == "enforce": + return "enforce" + if value == "shadow": + return "shadow" + raise ValueError(f"Invalid review mode: {value!r}") + + +def _as_review_level(value: object) -> ReviewLevelFilter | None: + if value is None: + return None + if value in ("error", "warning"): + return cast(ReviewLevelFilter, value) + raise ValueError(f"Invalid review level: {value!r}") + + +def _as_focus_facets(value: object) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, (list, tuple)) and all(isinstance(item, str) for item in value): + for item in value: + if item not in ("source", "tests", "docs"): + raise ValueError(f"Invalid focus facet: {item!r}") + return tuple(value) + raise ValueError("focus_facets must be a list or tuple of strings") + + def _build_review_run_request( files: list[Path], kwargs: dict[str, object], @@ -390,6 +472,7 @@ def _build_review_run_request( raise ValueError("files must contain only Path instances") request_kwargs = dict(kwargs) + had_include_tests_key = "include_tests" in request_kwargs # Validate and extract known boolean flags with proper type checking def _get_bool_param(name: str, default: bool = False) -> bool: @@ -425,6 +508,10 @@ def _get_optional_param(name: str, validator: Callable[[object], object], defaul path_filters = cast(list[Path] | None, path_filters_value) out = cast(Path | None, out_value) + focus_facets = cast(tuple[str, ...], _as_focus_facets(request_kwargs.pop("focus_facets", None))) + if focus_facets and had_include_tests_key: + raise ValueError("Cannot combine focus_facets with include_tests; use --focus alone to scope files.") + request = ReviewRunRequest( files=files, include_tests=include_tests, @@ -436,6 +523,10 @@ def _get_optional_param(name: str, validator: Callable[[object], object], defaul score_only=_get_bool_param("score_only"), no_tests=_get_bool_param("no_tests"), fix=_get_bool_param("fix"), + bug_hunt=_get_bool_param("bug_hunt"), + review_mode=_as_review_mode(request_kwargs.pop("review_mode", "enforce")), + review_level=_as_review_level(request_kwargs.pop("review_level", None)), + focus_facets=focus_facets, ) # Reject any unexpected keyword arguments @@ -493,11 +584,22 @@ def run_command( scope=request.scope, path_filters=request.path_filters or [], ) + resolved_files = _filter_files_by_focus(resolved_files, request.focus_facets) + if not resolved_files: + raise ValueError( + "No reviewable Python files matched the selected --focus facets." + if request.focus_facets + else "No Python files to review were provided or detected." + ) + report = _run_review_with_progress( resolved_files, no_tests=request.no_tests, include_noise=request.include_noise, fix=request.fix, + bug_hunt=request.bug_hunt, + review_mode=request.review_mode, + review_level=request.review_level, ) return _render_review_result(report, request) diff --git a/packages/specfact-code-review/src/specfact_code_review/run/runner.py b/packages/specfact-code-review/src/specfact_code_review/run/runner.py index 53426cc..fab4d2f 100644 --- a/packages/specfact-code-review/src/specfact_code_review/run/runner.py +++ b/packages/specfact-code-review/src/specfact_code_review/run/runner.py @@ -10,7 +10,9 @@ import tempfile from collections.abc import Callable, Iterable from contextlib import suppress +from functools import partial from pathlib import Path +from typing import Literal from uuid import uuid4 from beartype import beartype @@ -25,7 +27,8 @@ from specfact_code_review.tools.pylint_runner import run_pylint from specfact_code_review.tools.radon_runner import run_radon from specfact_code_review.tools.ruff_runner import run_ruff -from specfact_code_review.tools.semgrep_runner import run_semgrep +from specfact_code_review.tools.semgrep_runner import run_semgrep, run_semgrep_bugs +from specfact_code_review.tools.tool_availability import skip_if_pytest_unavailable _SOURCE_ROOT = Path("packages/specfact-code-review/src") @@ -243,18 +246,30 @@ def _checklist_findings() -> list[ReviewFinding]: ] -def _tool_steps() -> list[tuple[str, Callable[[list[Path]], list[ReviewFinding]]]]: +def _tool_steps(*, bug_hunt: bool) -> list[tuple[str, Callable[[list[Path]], list[ReviewFinding]]]]: return [ ("Running Ruff checks...", run_ruff), ("Running Radon complexity checks...", run_radon), ("Running Semgrep rules...", run_semgrep), + ("Running Semgrep bug rules...", run_semgrep_bugs), ("Running AST clean-code checks...", run_ast_clean_code), ("Running basedpyright type checks...", run_basedpyright), ("Running pylint checks...", run_pylint), - ("Running contract checks...", run_contract_check), + ("Running contract checks...", partial(run_contract_check, bug_hunt=bug_hunt)), ] +def _filter_findings_by_review_level( + findings: list[ReviewFinding], + level: Literal["error", "warning"] | None, +) -> list[ReviewFinding]: + if level is None: + return findings + if level == "error": + return [finding for finding in findings if finding.severity == "error"] + return [finding for finding in findings if finding.severity in {"error", "warning"}] + + def _collect_tdd_inputs(files: list[Path]) -> tuple[list[Path], list[Path], list[ReviewFinding]]: source_files = [file_path for file_path in files if _expected_test_path(file_path) is not None] findings: list[ReviewFinding] = [] @@ -366,6 +381,10 @@ def _evaluate_tdd_gate(files: list[Path]) -> tuple[list[ReviewFinding], dict[str if findings: return findings, None + pytest_skip = skip_if_pytest_unavailable(source_files[0]) + if pytest_skip: + return pytest_skip, None + try: test_result, coverage_path = _run_pytest_with_coverage(test_files) except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc: @@ -442,10 +461,13 @@ def run_review( no_tests: bool = False, include_noise: bool = False, progress_callback: Callable[[str], None] | None = None, + bug_hunt: bool = False, + review_level: Literal["error", "warning"] | None = None, + review_mode: Literal["shadow", "enforce"] = "enforce", ) -> ReviewReport: """Run all configured review runners and build the governed report.""" findings: list[ReviewFinding] = [] - for description, runner in _tool_steps(): + for description, runner in _tool_steps(bug_hunt=bug_hunt): if progress_callback is not None: progress_callback(description) findings.extend(runner(files)) @@ -463,6 +485,8 @@ def run_review( if not include_noise: findings = _suppress_known_noise(findings) + findings = _filter_findings_by_review_level(findings, review_level) + score = score_review( findings=findings, zero_loc_violations=not any(finding.tool == "ruff" and finding.rule == "E501" for finding in findings), @@ -471,9 +495,12 @@ def run_review( coverage_90_plus=coverage_90_plus, no_new_suppressions=_has_no_suppressions(files), ) - return ReviewReport( + report = ReviewReport( run_id=f"review-{uuid4()}", score=score.score, findings=findings, summary=_summary_for_findings(findings), ) + if review_mode == "shadow": + return report.model_copy(update={"ci_exit_code": 0}) + return report diff --git a/packages/specfact-code-review/src/specfact_code_review/tools/basedpyright_runner.py b/packages/specfact-code-review/src/specfact_code_review/tools/basedpyright_runner.py index 17a253d..1c89412 100644 --- a/packages/specfact-code-review/src/specfact_code_review/tools/basedpyright_runner.py +++ b/packages/specfact-code-review/src/specfact_code_review/tools/basedpyright_runner.py @@ -12,6 +12,7 @@ from specfact_code_review._review_utils import normalize_path_variants, tool_error from specfact_code_review.run.findings import ReviewFinding +from specfact_code_review.tools.tool_availability import skip_if_tool_missing def _allowed_paths(files: list[Path]) -> set[str]: @@ -91,6 +92,10 @@ def run_basedpyright(files: list[Path]) -> list[ReviewFinding]: if not files: return [] + skipped = skip_if_tool_missing("basedpyright", files[0]) + if skipped: + return skipped + try: result = subprocess.run( ["basedpyright", "--outputjson", "--project", ".", *[str(file_path) for file_path in files]], @@ -100,7 +105,7 @@ def run_basedpyright(files: list[Path]) -> list[ReviewFinding]: timeout=30, ) diagnostics = _diagnostics_from_output(result.stdout) - except (FileNotFoundError, OSError, ValueError, json.JSONDecodeError, KeyError, subprocess.TimeoutExpired) as exc: + except (OSError, ValueError, json.JSONDecodeError, KeyError, subprocess.TimeoutExpired) as exc: return [ tool_error( tool="basedpyright", diff --git a/packages/specfact-code-review/src/specfact_code_review/tools/contract_runner.py b/packages/specfact-code-review/src/specfact_code_review/tools/contract_runner.py index fd04bf0..64b2a8d 100644 --- a/packages/specfact-code-review/src/specfact_code_review/tools/contract_runner.py +++ b/packages/specfact-code-review/src/specfact_code_review/tools/contract_runner.py @@ -12,10 +12,30 @@ from specfact_code_review._review_utils import normalize_path_variants, tool_error from specfact_code_review.run.findings import ReviewFinding +from specfact_code_review.tools.tool_availability import skip_if_tool_missing _CROSSHAIR_LINE_RE = re.compile(r"^(?P.+?):(?P\d+):\s*(?:error|warning|info):\s*(?P.+)$") _IGNORED_CROSSHAIR_PREFIXES = ("SideEffectDetected:",) + + +def _has_icontract_usage(files: list[Path]) -> bool: + """True when any reviewed file imports the icontract package.""" + for file_path in files: + try: + tree = ast.parse(file_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, SyntaxError): + continue + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module == "icontract": + return True + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name == "icontract": + return True + return False + + _SYNC_RUNTIME_ICONTRACT_ENTRYPOINTS = { "bridge_probe.py", "bridge_sync.py", @@ -104,17 +124,23 @@ def _scan_file(file_path: Path) -> list[ReviewFinding]: return findings -def _run_crosshair(files: list[Path]) -> list[ReviewFinding]: +def _run_crosshair(files: list[Path], *, bug_hunt: bool) -> list[ReviewFinding]: if not files: return [] + skipped = skip_if_tool_missing("crosshair", files[0]) + if skipped: + return skipped + + per_path_timeout = "10" if bug_hunt else "2" + proc_timeout = 120 if bug_hunt else 30 try: result = subprocess.run( - ["crosshair", "check", "--per_path_timeout", "2", *(str(file_path) for file_path in files)], + ["crosshair", "check", "--per_path_timeout", per_path_timeout, *(str(file_path) for file_path in files)], capture_output=True, text=True, check=False, - timeout=30, + timeout=proc_timeout, ) except subprocess.TimeoutExpired: return [] @@ -163,13 +189,14 @@ def _run_crosshair(files: list[Path]) -> list[ReviewFinding]: lambda result: all(isinstance(finding, ReviewFinding) for finding in result), "result must contain ReviewFinding instances", ) -def run_contract_check(files: list[Path]) -> list[ReviewFinding]: +def run_contract_check(files: list[Path], *, bug_hunt: bool = False) -> list[ReviewFinding]: """Run AST-based contract checks and a CrossHair fast pass for the provided files.""" if not files: return [] findings: list[ReviewFinding] = [] - for file_path in files: - findings.extend(_scan_file(file_path)) - findings.extend(_run_crosshair(files)) + if _has_icontract_usage(files): + for file_path in files: + findings.extend(_scan_file(file_path)) + findings.extend(_run_crosshair(files, bug_hunt=bug_hunt)) return findings diff --git a/packages/specfact-code-review/src/specfact_code_review/tools/pylint_runner.py b/packages/specfact-code-review/src/specfact_code_review/tools/pylint_runner.py index e194f90..e95e9ee 100644 --- a/packages/specfact-code-review/src/specfact_code_review/tools/pylint_runner.py +++ b/packages/specfact-code-review/src/specfact_code_review/tools/pylint_runner.py @@ -12,6 +12,7 @@ from specfact_code_review._review_utils import normalize_path_variants, tool_error from specfact_code_review.run.findings import ReviewFinding +from specfact_code_review.tools.tool_availability import skip_if_tool_missing PYLINT_CATEGORY_MAP: dict[str, Literal["architecture"]] = { @@ -105,6 +106,10 @@ def run_pylint(files: list[Path]) -> list[ReviewFinding]: if not files: return [] + skipped = skip_if_tool_missing("pylint", files[0]) + if skipped: + return skipped + try: result = subprocess.run( ["pylint", "--output-format", "json", *[str(file_path) for file_path in files]], @@ -114,7 +119,7 @@ def run_pylint(files: list[Path]) -> list[ReviewFinding]: timeout=30, ) payload = _payload_from_output(result.stdout) - except (FileNotFoundError, OSError, ValueError, json.JSONDecodeError, subprocess.TimeoutExpired) as exc: + except (OSError, ValueError, json.JSONDecodeError, subprocess.TimeoutExpired) as exc: return [tool_error(tool="pylint", file_path=files[0], message=f"Unable to parse pylint output: {exc}")] allowed_paths = _allowed_paths(files) diff --git a/packages/specfact-code-review/src/specfact_code_review/tools/radon_runner.py b/packages/specfact-code-review/src/specfact_code_review/tools/radon_runner.py index 7e31382..882d83f 100644 --- a/packages/specfact-code-review/src/specfact_code_review/tools/radon_runner.py +++ b/packages/specfact-code-review/src/specfact_code_review/tools/radon_runner.py @@ -14,6 +14,7 @@ from icontract import ensure, require from specfact_code_review.run.findings import ReviewFinding +from specfact_code_review.tools.tool_availability import skip_if_tool_missing _KISS_LOC_WARNING = 80 @@ -163,10 +164,30 @@ def _kiss_nesting_findings( return findings +def _typer_cli_entrypoint_exempt(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + """Typer command callbacks legitimately take many injected options; skip parameter-count KISS on them.""" + args0 = function_node.args.args + if not args0: + return False + first = args0[0] + if first.arg != "ctx": + return False + ann = first.annotation + if ann is None: + return False + try: + rendered = ast.unparse(ann) + except AttributeError: + return False + return rendered.endswith("Context") + + def _kiss_parameter_findings( function_node: ast.FunctionDef | ast.AsyncFunctionDef, file_path: Path ) -> list[ReviewFinding]: findings: list[ReviewFinding] = [] + if _typer_cli_entrypoint_exempt(function_node): + return findings parameter_count = len(function_node.args.posonlyargs) parameter_count += len(function_node.args.args) parameter_count += len(function_node.args.kwonlyargs) @@ -273,6 +294,10 @@ def run_radon(files: list[Path]) -> list[ReviewFinding]: if not files: return [] + skipped = skip_if_tool_missing("radon", files[0]) + if skipped: + return skipped + payload = _run_radon_command(files) findings: list[ReviewFinding] = [] if payload is None: diff --git a/packages/specfact-code-review/src/specfact_code_review/tools/ruff_runner.py b/packages/specfact-code-review/src/specfact_code_review/tools/ruff_runner.py index 93f1a9d..2350fb3 100644 --- a/packages/specfact-code-review/src/specfact_code_review/tools/ruff_runner.py +++ b/packages/specfact-code-review/src/specfact_code_review/tools/ruff_runner.py @@ -12,6 +12,7 @@ from specfact_code_review._review_utils import normalize_path_variants, tool_error from specfact_code_review.run.findings import ReviewFinding +from specfact_code_review.tools.tool_availability import skip_if_tool_missing def _allowed_paths(files: list[Path]) -> set[str]: @@ -99,6 +100,10 @@ def run_ruff(files: list[Path]) -> list[ReviewFinding]: if not files: return [] + skipped = skip_if_tool_missing("ruff", files[0]) + if skipped: + return skipped + try: result = subprocess.run( ["ruff", "check", "--output-format", "json", *[str(file_path) for file_path in files]], @@ -108,7 +113,7 @@ def run_ruff(files: list[Path]) -> list[ReviewFinding]: timeout=30, ) payload = _payload_from_output(result.stdout) - except (FileNotFoundError, OSError, ValueError, json.JSONDecodeError, subprocess.TimeoutExpired) as exc: + except (OSError, ValueError, json.JSONDecodeError, subprocess.TimeoutExpired) as exc: return [tool_error(tool="ruff", file_path=files[0], message=f"Unable to parse Ruff output: {exc}")] allowed_paths = _allowed_paths(files) diff --git a/packages/specfact-code-review/src/specfact_code_review/tools/semgrep_runner.py b/packages/specfact-code-review/src/specfact_code_review/tools/semgrep_runner.py index 7118cd7..0c5a9ab 100644 --- a/packages/specfact-code-review/src/specfact_code_review/tools/semgrep_runner.py +++ b/packages/specfact-code-review/src/specfact_code_review/tools/semgrep_runner.py @@ -13,6 +13,7 @@ from icontract import ensure, require from specfact_code_review.run.findings import ReviewFinding +from specfact_code_review.tools.tool_availability import skip_if_tool_missing SEMGREP_RULE_CATEGORY = { @@ -29,6 +30,16 @@ _SEMGREP_STDERR_SNIP_MAX = 4000 _MAX_CONFIG_PARENT_WALK = 32 SemgrepCategory = Literal["clean_code", "architecture", "naming"] +BugSemgrepCategory = Literal["security", "clean_code"] + +BUG_RULE_CATEGORY: dict[str, BugSemgrepCategory] = { + "specfact-bugs-eval-exec": "security", + "specfact-bugs-os-system": "security", + "specfact-bugs-pickle-loads": "security", + "specfact-bugs-yaml-unsafe": "security", + "specfact-bugs-hardcoded-password": "security", + "specfact-bugs-useless-comparison": "clean_code", +} def _normalize_path_variants(path_value: str | Path) -> set[str]: @@ -121,7 +132,40 @@ def find_semgrep_config( raise FileNotFoundError(f"Semgrep config not found (no .semgrep/clean_code.yaml under bundle for {here})") -def _run_semgrep_command(files: list[Path], *, bundle_root: Path | None) -> subprocess.CompletedProcess[str]: +@beartype +@require(lambda bundle_root, module_file: bundle_root is None or isinstance(bundle_root, Path)) +@require(lambda bundle_root, module_file: module_file is None or isinstance(module_file, Path)) +@ensure(lambda result: result is None or isinstance(result, Path)) +def find_semgrep_bugs_config( + *, + bundle_root: Path | None = None, + module_file: Path | None = None, +) -> Path | None: + """Locate ``.semgrep/bugs.yaml`` for this package or bundle root; return ``None`` if absent.""" + if bundle_root is not None: + br = bundle_root.resolve() + candidate = br / ".semgrep" / "bugs.yaml" + return candidate if candidate.is_file() else None + + here = (module_file if module_file is not None else Path(__file__)).resolve() + for depth, parent in enumerate([here.parent, *here.parents]): + if depth > _MAX_CONFIG_PARENT_WALK: + break + if parent == parent.parent: + break + candidate = parent / ".semgrep" / "bugs.yaml" + if candidate.is_file(): + return candidate + if _is_bundle_boundary(parent): + break + if parent.name == "site-packages": + break + return None + + +def _run_semgrep_command( + files: list[Path], *, bundle_root: Path | None, config_file: Path +) -> subprocess.CompletedProcess[str]: with tempfile.TemporaryDirectory(prefix="semgrep-home-") as temp_home: semgrep_home = Path(temp_home) semgrep_log_dir = semgrep_home / ".semgrep" @@ -138,7 +182,7 @@ def _run_semgrep_command(files: list[Path], *, bundle_root: Path | None) -> subp "--disable-version-check", "--quiet", "--config", - str(find_semgrep_config(bundle_root=bundle_root)), + str(config_file), "--json", *(str(file_path) for file_path in files), ], @@ -158,11 +202,11 @@ def _snip_stderr_tail(stderr: str) -> str: return "…" + err_raw[-_SEMGREP_STDERR_SNIP_MAX:] -def _load_semgrep_results(files: list[Path], *, bundle_root: Path | None) -> list[object]: +def _load_semgrep_results(files: list[Path], *, bundle_root: Path | None, config_file: Path) -> list[object]: last_error: Exception | None = None for _attempt in range(SEMGREP_RETRY_ATTEMPTS): try: - result = _run_semgrep_command(files, bundle_root=bundle_root) + result = _run_semgrep_command(files, bundle_root=bundle_root, config_file=config_file) raw_out = result.stdout.strip() if not raw_out: err_tail = _snip_stderr_tail(result.stderr or "") @@ -254,8 +298,13 @@ def run_semgrep(files: list[Path], *, bundle_root: Path | None = None) -> list[R if not files: return [] + skipped = skip_if_tool_missing("semgrep", files[0]) + if skipped: + return skipped + try: - raw_results = _load_semgrep_results(files, bundle_root=bundle_root) + config_path = find_semgrep_config(bundle_root=bundle_root) + raw_results = _load_semgrep_results(files, bundle_root=bundle_root, config_file=config_path) except (FileNotFoundError, OSError, ValueError, json.JSONDecodeError, subprocess.TimeoutExpired) as exc: return _tool_error(files[0], f"Unable to parse Semgrep output: {exc}") @@ -281,3 +330,106 @@ def _append_semgrep_finding( finding = _finding_from_result(item, allowed_paths=allowed_paths) if finding is not None: findings.append(finding) + + +def _normalize_bug_rule_id(rule: str) -> str: + for rule_id in BUG_RULE_CATEGORY: + if rule == rule_id or rule.endswith(f".{rule_id}"): + return rule_id + return rule.rsplit(".", 1)[-1] + + +def _finding_from_bug_result(item: dict[str, object], *, allowed_paths: set[str]) -> ReviewFinding | None: + filename = item["path"] + if not isinstance(filename, str): + raise ValueError("semgrep filename must be a string") + if _normalize_path_variants(filename).isdisjoint(allowed_paths): + return None + + raw_rule = item["check_id"] + if not isinstance(raw_rule, str): + raise ValueError("semgrep rule must be a string") + rule = _normalize_bug_rule_id(raw_rule) + category = BUG_RULE_CATEGORY.get(rule) + if category is None: + return None + + start = item["start"] + if not isinstance(start, dict): + raise ValueError("semgrep start location must be an object") + line = start["line"] + if not isinstance(line, int): + raise ValueError("semgrep line must be an integer") + + extra = item["extra"] + if not isinstance(extra, dict): + raise ValueError("semgrep extra payload must be an object") + message = extra["message"] + if not isinstance(message, str): + raise ValueError("semgrep message must be a string") + + severity_raw = extra.get("severity", "WARNING") + severity: Literal["error", "warning"] = ( + "error" if isinstance(severity_raw, str) and severity_raw.upper() == "ERROR" else "warning" + ) + + return ReviewFinding( + category=category, + severity=severity, + tool="semgrep", + rule=rule, + file=filename, + line=line, + message=message, + fixable=False, + ) + + +def _append_semgrep_bug_finding( + findings: list[ReviewFinding], + item: object, + *, + allowed_paths: set[str], +) -> None: + if not isinstance(item, dict): + raise ValueError("semgrep finding must be an object") + finding = _finding_from_bug_result(item, allowed_paths=allowed_paths) + if finding is not None: + findings.append(finding) + + +@beartype +@require(lambda files: isinstance(files, list), "files must be a list") +@require(lambda files: all(isinstance(file_path, Path) for file_path in files), "files must contain Path instances") +@ensure(lambda result: isinstance(result, list), "result must be a list") +@ensure( + lambda result: all(isinstance(finding, ReviewFinding) for finding in result), + "result must contain ReviewFinding instances", +) +def run_semgrep_bugs(files: list[Path], *, bundle_root: Path | None = None) -> list[ReviewFinding]: + """Second Semgrep pass using ``.semgrep/bugs.yaml`` when present; no-op if config is absent.""" + if not files: + return [] + + config_path = find_semgrep_bugs_config(bundle_root=bundle_root) + if config_path is None: + return [] + + skipped = skip_if_tool_missing("semgrep", files[0]) + if skipped: + return skipped + + try: + raw_results = _load_semgrep_results(files, bundle_root=bundle_root, config_file=config_path) + except (FileNotFoundError, OSError, ValueError, json.JSONDecodeError, subprocess.TimeoutExpired) as exc: + return _tool_error(files[0], f"Unable to parse Semgrep bugs pass output: {exc}") + + allowed_paths = _allowed_paths(files) + findings: list[ReviewFinding] = [] + try: + for item in raw_results: + _append_semgrep_bug_finding(findings, item, allowed_paths=allowed_paths) + except (KeyError, TypeError, ValueError) as exc: + return _tool_error(files[0], f"Unable to parse Semgrep bugs finding payload: {exc}") + + return findings diff --git a/packages/specfact-code-review/src/specfact_code_review/tools/tool_availability.py b/packages/specfact-code-review/src/specfact_code_review/tools/tool_availability.py new file mode 100644 index 0000000..efcc01a --- /dev/null +++ b/packages/specfact-code-review/src/specfact_code_review/tools/tool_availability.py @@ -0,0 +1,103 @@ +"""Resolve external review-tool executables and emit skip findings when missing.""" + +from __future__ import annotations + +import importlib.util +import shutil +from pathlib import Path +from typing import Literal + +from beartype import beartype +from icontract import ensure, require + +from specfact_code_review._review_utils import tool_error +from specfact_code_review.run.findings import ReviewFinding + + +ReviewToolId = Literal[ + "ruff", + "radon", + "semgrep", + "basedpyright", + "pylint", + "crosshair", + "pytest", +] + +# tool_id -> pip distribution name(s) declared on module-package.yaml +REVIEW_TOOL_PIP_PACKAGES: dict[ReviewToolId, str] = { + "ruff": "ruff", + "radon": "radon", + "semgrep": "semgrep", + "basedpyright": "basedpyright", + "pylint": "pylint", + "crosshair": "crosshair-tool", + "pytest": "pytest", +} + +_EXECUTABLE_ON_PATH: dict[ReviewToolId, str] = { + "ruff": "ruff", + "radon": "radon", + "semgrep": "semgrep", + "basedpyright": "basedpyright", + "pylint": "pylint", + "crosshair": "crosshair", +} + + +def _skip_message(tool_id: ReviewToolId) -> str: + pip_name = REVIEW_TOOL_PIP_PACKAGES[tool_id] + return ( + f"Review checks for {tool_id} were skipped: executable not found on PATH. " + f"Install the `{pip_name}` package (declared on the code-review module) so the tool is available." + ) + + +@beartype +@require(lambda tool_id: tool_id in REVIEW_TOOL_PIP_PACKAGES) +@ensure(lambda result: isinstance(result, list)) +def skip_if_tool_missing(tool_id: ReviewToolId, file_path: Path) -> list[ReviewFinding]: + """Return a single tool_error when the CLI is absent; otherwise return an empty list.""" + exe = _EXECUTABLE_ON_PATH.get(tool_id) + if exe is not None and shutil.which(exe) is None: + return [ + tool_error( + tool=tool_id, + file_path=file_path, + message=_skip_message(tool_id), + severity="warning", + ) + ] + return [] + + +@beartype +@require(lambda file_path: isinstance(file_path, Path)) +@ensure(lambda result: isinstance(result, list)) +def skip_if_pytest_unavailable(file_path: Path) -> list[ReviewFinding]: + """Skip TDD gate when pytest cannot be imported in the current interpreter.""" + if importlib.util.find_spec("pytest") is None: + return [ + tool_error( + tool="pytest", + file_path=file_path, + message=( + "Review checks for pytest were skipped: pytest is not importable in this environment. " + "Install `pytest` and `pytest-cov` (declared on the code-review module)." + ), + severity="warning", + ) + ] + if importlib.util.find_spec("pytest_cov") is None: + return [ + tool_error( + tool="pytest", + file_path=file_path, + message=( + "Review checks for pytest coverage were skipped: pytest-cov is not importable. " + "Install `pytest-cov` (declared on the code-review module)." + ), + severity="warning", + ) + ] + return [] diff --git a/packages/specfact-codebase/module-package.yaml b/packages/specfact-codebase/module-package.yaml index 9776e9d..fea5ecc 100644 --- a/packages/specfact-codebase/module-package.yaml +++ b/packages/specfact-codebase/module-package.yaml @@ -1,5 +1,5 @@ name: nold-ai/specfact-codebase -version: 0.41.4 +version: 0.41.5 commands: - code tier: official @@ -24,5 +24,4 @@ description: Official SpecFact codebase bundle package. category: codebase bundle_group_command: code integrity: - checksum: sha256:436e9e3d0d56a6eae861c4a6bef0a8f805f00b8b5bd8b0e037c19dede4972117 - signature: FQOsqabH5ATcyLfjTVrVJPIC4KiuwwFbCQZL+BZAu6dFoOz/DLjk91NZAyc7z6oq5dpVfwMHFsKEYqzW4wlDDA== + checksum: sha256:6c7d032c0db2569148386309f14a73ee481f6e74adc314ef930043728e4b18db diff --git a/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/base.py b/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/base.py index f97dadb..7b51923 100644 --- a/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/base.py +++ b/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/base.py @@ -8,6 +8,8 @@ from __future__ import annotations from abc import ABC, abstractmethod +from collections.abc import Iterator +from pathlib import Path from typing import Any from beartype import beartype @@ -31,6 +33,23 @@ class RouteInfo(BaseModel): class BaseFrameworkExtractor(ABC): """Abstract base class for framework-specific route and schema extractors.""" + _EXCLUDED_DIR_NAMES: frozenset[str] = frozenset({".specfact", ".git", "__pycache__", "node_modules"}) + + @beartype + @staticmethod + def _path_touches_excluded_dir(path: Path) -> bool: + """True when any path component is a directory we must not scan (venv, VCS, caches).""" + return any(part in BaseFrameworkExtractor._EXCLUDED_DIR_NAMES for part in path.parts) + + @beartype + def _iter_python_files(self, root: Path) -> Iterator[Path]: + """Yield ``*.py`` files under ``root``, skipping excluded directory subtrees by path.""" + if not root.exists() or not root.is_dir(): + return + for py_file in root.rglob("*.py"): + if not self._path_touches_excluded_dir(py_file): + yield py_file + @abstractmethod @beartype @require(lambda repo_path: repo_path.exists(), "Repository path must exist") diff --git a/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/django.py b/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/django.py index e7093c7..913f6f1 100644 --- a/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/django.py +++ b/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/django.py @@ -38,7 +38,7 @@ def detect(self, repo_path: Path) -> bool: if manage_py.exists(): return True - urls_files = list(repo_path.rglob("urls.py")) + urls_files = [path for path in self._iter_python_files(repo_path) if path.name == "urls.py"] return len(urls_files) > 0 @beartype @@ -98,7 +98,7 @@ def _find_urls_file(self, repo_path: Path) -> Path | None: if candidate.exists(): return candidate - urls_files = list(repo_path.rglob("urls.py")) + urls_files = [path for path in self._iter_python_files(repo_path) if path.name == "urls.py"] return urls_files[0] if urls_files else None @beartype diff --git a/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/drf.py b/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/drf.py index 66d89ad..9af097a 100644 --- a/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/drf.py +++ b/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/drf.py @@ -38,7 +38,7 @@ def detect(self, repo_path: Path) -> bool: True if DRF is detected """ # Check for rest_framework imports - for py_file in repo_path.rglob("*.py"): + for py_file in self._iter_python_files(repo_path): try: content = py_file.read_text(encoding="utf-8") if "rest_framework" in content or "from rest_framework" in content: diff --git a/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/fastapi.py b/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/fastapi.py index 34a353f..0666af9 100644 --- a/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/fastapi.py +++ b/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/fastapi.py @@ -48,7 +48,7 @@ def detect(self, repo_path: Path) -> bool: for search_path in [repo_path, repo_path / "src", repo_path / "app", repo_path / "backend" / "app"]: if not search_path.exists(): continue - for py_file in search_path.rglob("*.py"): + for py_file in self._iter_python_files(search_path): if py_file.name in ["main.py", "app.py"]: try: content = py_file.read_text(encoding="utf-8") @@ -79,7 +79,7 @@ def extract_routes(self, repo_path: Path) -> list[RouteInfo]: for search_path in [repo_path, repo_path / "src", repo_path / "app", repo_path / "backend" / "app"]: if not search_path.exists(): continue - for py_file in search_path.rglob("*.py"): + for py_file in self._iter_python_files(search_path): try: routes = self._extract_routes_from_file(py_file) results.extend(routes) @@ -143,6 +143,29 @@ def _extract_imports(self, tree: ast.AST) -> dict[str, str]: imports[alias_name] = alias.name return imports + @beartype + def _path_method_from_route_decorator(self, decorator: ast.expr, path: str, method: str) -> tuple[str, str]: + if not isinstance(decorator, ast.Call): + return path, method + func = decorator.func + if isinstance(func, ast.Attribute): + next_method = func.attr.upper() + next_path = path + if decorator.args: + lit = self._extract_string_literal(decorator.args[0]) + if lit: + next_path = lit + return next_path, next_method + if isinstance(func, ast.Name): + next_method = func.id.upper() + next_path = path + if decorator.args: + lit = self._extract_string_literal(decorator.args[0]) + if lit: + next_path = lit + return next_path, next_method + return path, method + @beartype def _extract_route_from_function( self, func_node: ast.FunctionDef, imports: dict[str, str], py_file: Path @@ -152,23 +175,8 @@ def _extract_route_from_function( method = "GET" operation_id = func_node.name - # Check decorators for route information for decorator in func_node.decorator_list: - if isinstance(decorator, ast.Call): - if isinstance(decorator.func, ast.Attribute): - # @app.get(), @app.post(), etc. - method = decorator.func.attr.upper() - if decorator.args: - path_arg = self._extract_string_literal(decorator.args[0]) - if path_arg: - path = path_arg - elif isinstance(decorator.func, ast.Name): - # @get(), @post(), etc. - method = decorator.func.id.upper() - if decorator.args: - path_arg = self._extract_string_literal(decorator.args[0]) - if path_arg: - path = path_arg + path, method = self._path_method_from_route_decorator(decorator, path, method) normalized_path, path_params = self._extract_path_parameters(path) diff --git a/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/flask.py b/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/flask.py index c046231..cddb24c 100644 --- a/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/flask.py +++ b/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/flask.py @@ -48,7 +48,7 @@ def detect(self, repo_path: Path) -> bool: for search_path in [repo_path, repo_path / "src", repo_path / "app", repo_path / "backend" / "app"]: if not search_path.exists(): continue - for py_file in search_path.rglob("*.py"): + for py_file in self._iter_python_files(search_path): if py_file.name in ["app.py", "main.py", "__init__.py"]: try: content = py_file.read_text(encoding="utf-8") @@ -79,7 +79,7 @@ def extract_routes(self, repo_path: Path) -> list[RouteInfo]: for search_path in [repo_path, repo_path / "src", repo_path / "app", repo_path / "backend" / "app"]: if not search_path.exists(): continue - for py_file in search_path.rglob("*.py"): + for py_file in self._iter_python_files(search_path): try: routes = self._extract_routes_from_file(py_file) results.extend(routes) @@ -107,6 +107,24 @@ def extract_schemas(self, repo_path: Path, routes: list[RouteInfo]) -> dict[str, # For now, return empty dict return {} + @beartype + def _register_flask_assign_symbols( + self, target: ast.expr, value: ast.expr, app_names: set[str], bp_names: set[str] + ) -> None: + if not isinstance(target, ast.Name) or not isinstance(value, ast.Call): + return + func = value.func + if isinstance(func, ast.Name) and func.id == "Flask": + app_names.add(target.id) + return + if isinstance(func, ast.Attribute) and func.attr == "Flask": + app_names.add(target.id) + return + if (isinstance(func, ast.Name) and func.id == "Blueprint") or ( + isinstance(func, ast.Attribute) and func.attr == "Blueprint" + ): + bp_names.add(target.id) + @beartype def _extract_routes_from_file(self, py_file: Path) -> list[RouteInfo]: """Extract routes from a Python file.""" @@ -125,22 +143,10 @@ def _extract_routes_from_file(self, py_file: Path) -> list[RouteInfo]: # First pass: Find Flask app and Blueprint instances for node in ast.walk(tree): - if isinstance(node, ast.Assign): - for target in node.targets: - if isinstance(target, ast.Name): - if isinstance(node.value, ast.Call): - if isinstance(node.value.func, ast.Name): - func_name = node.value.func.id - if func_name == "Flask": - app_names.add(target.id) - elif isinstance(node.value.func, ast.Attribute): - if node.value.func.attr == "Flask": - app_names.add(target.id) - elif isinstance(node.value, ast.Call) and ( - (isinstance(node.value.func, ast.Name) and node.value.func.id == "Blueprint") - or (isinstance(node.value.func, ast.Attribute) and node.value.func.attr == "Blueprint") - ): - bp_names.add(target.id) + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + self._register_flask_assign_symbols(target, node.value, app_names, bp_names) # Second pass: Extract routes from functions with decorators for node in ast.walk(tree): diff --git a/registry/index.json b/registry/index.json index 935d33a..64b4985 100644 --- a/registry/index.json +++ b/registry/index.json @@ -30,9 +30,9 @@ }, { "id": "nold-ai/specfact-codebase", - "latest_version": "0.41.4", - "download_url": "modules/specfact-codebase-0.41.4.tar.gz", - "checksum_sha256": "18534ed0fa07e711f57c9a473db01ab83b5b0ebefba0039b969997919907e049", + "latest_version": "0.41.5", + "download_url": "modules/specfact-codebase-0.41.5.tar.gz", + "checksum_sha256": "fe8f95c325f21eb80209aa067f6a4f2055f1f5feed4e818a1c9d3061320c2270", "tier": "official", "publisher": { "name": "nold-ai", @@ -78,9 +78,9 @@ }, { "id": "nold-ai/specfact-code-review", - "latest_version": "0.46.4", - "download_url": "modules/specfact-code-review-0.46.4.tar.gz", - "checksum_sha256": "caecd26d6e6308ed88047385e0a9579c5336665d46e8118c3ae9caf4cbd786c8", + "latest_version": "0.47.0", + "download_url": "modules/specfact-code-review-0.47.0.tar.gz", + "checksum_sha256": "42ea7d2d16c5b500787468d3aef529e7e7ac4d8e21ae2b3b7bd14c802256b0e8", "core_compatibility": ">=0.44.0,<1.0.0", "tier": "official", "publisher": { diff --git a/registry/modules/specfact-code-review-0.47.0.tar.gz b/registry/modules/specfact-code-review-0.47.0.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..f34ccc6d0417bec2009b79c07610942320382f5b GIT binary patch literal 35058 zcmV)bK&ihUiwFn>uij|_|8sC6(9e_rw0*+}V8gd}nLt+0HZA|IeOnJ^x+M{gnHEm|f2;x9I(U=;sf?H?zrboD74j z^zsU@U@}UF$z+fOu=c@|phyO@JS}g6X~#goeM0eO z;V3%Xmn4XD=YbP-BB=6BP?5}v<;}$!ePSf)Wd!SxjCuKa0%UHtD zs}PsTWtKy;a=A8YrX(xc#Z6Hr*Q-oPR&=-$PR2A(MhRfR>|&Z{11QK#8V9f-fJJeB zIlE3KWiU>Q(w&sQ?jFB7c=fG0Ejv8F!Q+Wbda2-zo7#3V&27ts} zCLel1_@}fP%C>%?FT-CLV)LisaUcGjJ!+hv{V)9YIevSnse+*Zvs3qXqT`}n2^PV-rU(;oT9tTK-$he}??G{d}tf|7~wQTkoH>{HLku$4G$-)lqUhs=}gP&nC3O;-J=G7i?ojv0Xe&Ct&ju$H)7xPJ=#lYdq?FwtISdys(xo z3iYb5@rj!684fE)j&+ittkI4KC{x)J{)%1&Ft9wmAU>Q55ow}BKpgv;i>*ZSDVQ2Xy`0VJL|LHK--1+Qe zfA8qki)z`=e%L*Hvk&v-E3Ia0kOQOM9>+IH4lLhzY){j1GR=y#1U8Tm@w?O0*T4>@ zD2!s^!otVVY%(AY{yO~-Oq09-ah22_SQY2-QgHZrQcN?zrHd4%EKQ1kKP=7FxI3R6 z@4r5Z_I3{sO@%^z)vL0cMm$HI&v3%#)>Z2O6k>+LHB7=75Vr*3<*iR~0AdVG;^Zwu>Rl?!sY0D1Aa!ofc1LfBaBV&~rB z0h56|0lHO`Mdt)Y3jJ}X%3BF%`9o)q#_m=0v?p2lQK+yO(qIg-CH86eJFv?Lz}$rQIyTeN7;$-aCo zhLyw70qju#I3_x;w0^AX{T0p*6Lm|F>*rE39+vLDTN-=$AWe7yujT)>{QsMg z|2Ln4{Qq=kYxB#s{Quu8{|o+Z8V}wA@&4!w;05ykX7`zr|DQjl{r|k%UElwHk`FR9 zJviaVAbO@xeAuw@4y7YFRfBHl>GMu^Lx}4_Ye1@Y5RCF(kd1(AO5^bcv0a5Y*{v4f zcXACZL@&5XfS>p?K8{lm_^vlD=pH&6CevhsH_)lnOL#kX5f{nEG@W|NuK@oY{mkPbU5m68+s{8{qY-@(S~8fBJc;Nc zrDQP?xS&U0_hGu8cDk)UZg#qz?xT%iQVjBx8I<5iOjU3KgL(;r;_GB_lg?8S$o;or zTyMysi`lx&v)MEfE5ffgSbD`?KraVZ$>41_NxIvE_+qp@-2UQX=ZoRftx^1ReJrf+ zf7blpZ~Xq}i}n4_e>49F@;zyFiYL=-oDOb?6D-sL%-2F(1C=m?bUIGv*a8wC4Xl!3S41%9D|{5Amv%d%hC!K; z^8GRcKoj_FS`G?+C!3XMPt}12M)7Q1Mj(d(>lhE;r3G+5uCd=DU7zNf;8{AkWX+Qt zk@0jnHJR7O!~to&VX$x0qUemXLFav(Ph>ANEPU-k5zn0ZNuFoX&p~ksW6d9|`TsTlzvlnfpWht+U*rSD?9(9-i5F3nPSP@p zI@6m6)c~uQQL(JqF37%x|Vy3QQIh-Pj<5ttcAJ@Pa>z zr2Pi4;#o<&L?O!Xi*b4(i}0U_?-saBc{4@p08!xd$JhJO-go);J8I*Z^xKe+nj0JG$ZnQzc&T%~4bTyP zpgXeHp}G@>hpi2LVeIx)zjOPm-?+WjZ=AmK3m5>(O0XW6Nr~oRQ53Sey(sf$(EeH& zf$^wdtoV|y^Fdw{)2r{auZv5V>rq2k=G;aR7LTIe=Xb$XTtGd;1nm~z!JCGvp2umC z1iP}tKC2gLPOf6K)g3}YHhM0TvbAT8Ktd60zq zaSPe6;1?QZv$^qS?wPEfC&g@BT2=WGR7*-Ij~v|uoruTjKa)s+&C_^N7NOa;(Fn47 zQ27iVoijrRD|O6_Qmlauu~3kWXb3np<1tMEx%9Ig?{!=bu29j7OD#+_uYgs^0DNA6ZmU!m&Ks=t)32hN9zoFD|+aO&% zieusefF!udC~MrUvQ@0@DmD$yN<`pUD0|FE#4`#V<)6mn;54*s8)#KbcVAGYv-+N& z12W3D8?+g%?)6M0u-<6lQL5hi4Lyp6AsC9@^yL@*3d{EiNi?>D7*qoYN8d->R?}eY zN}vY72q7) zRqTJZcb;vp<-boP{~ag5&kWnVwo362d>AZ$Vu*ZRQHpDIgrBXH2KL6L}|N8UE&wmjy;9>N?+nX5d#X0}CasRK+|9{2# zZ^XhNAMcadG)9-B_9)GZk~Bc$Y!Huw|2a8&6^!7Nz5teCW!3QCG31=1CLUztaWdeb zq2kpZ+IJ>7zsC!3Bv$g}o726PdbpCNd3SQtl8T*HaC$RMhA+}Vsd}dzz8*po%^kZL zVrYwjEc6Xb>G!a;p!t_+G9I>qANYzNMo-GiRTbDTcMlF7y_o!okx_CO+y*mJKP34O z1`Di}Qfwf? zPz~%^L%lsb+S@&hUhkfMC)CEBtL!=no57Qy(UGHR;4i`dAtnIsY;cGQ?&1lc%d801 z!ZejuTLf+p*ji9ISoIqUUdcK(wl?28Jdlxv#Ki^a*|Ky}^nwu-FImr5=1KfELRbD? zz&?TM;RPW2Tm2OJS4Qu6@<>xC)Qi*TU7C#BQJmvT#A|eoOC(&4e z&YV0Q*1TRO{ca1euD`j}bzpxhT^eK@WCYPs)+$I`BUt7#_JFkj9mnrQ`GCr^9g?|) zB~*ok?uM=dee@3Z`B-*Q)R*8SDT#0^iW$cDjKDxE&sUo4lO#{YeUlphQUoHvofzgyogTGAp<4%C zurtZthj7L;oqoV05!whkfCYgHT~D#eDiKS3`+RSf=ZNLGb@EaSHNDLrZvV`#XGopi zp_8(veif+Qj*Hp515uZdn-G&AsLkTg&MHW`aqP_~hL81d%@_=9uE`~w7B*~6f>#7i z6>FgrDfetj1GWYA`>ep_8qlga+th+tvon_`tyW9k-Kq|oh@4+w>h)g5!=@I$u~-FL zF7xD)7Z`2w7@{IxGj*V;b896^aEOfJ_(n06LjqadDXiLY&q4DPvxvJd^S5dU(+_8G@5m_VH_Cd(ELf2o$`7(P}=wbWh03Y`f=zs zN27$A^p{siUxm{z=N37qbI-W~#z!`93naS1&pBij#-+9jh=DJE!Hk=Qp~7)Kj1UJ2 zaxSeZ1nY_AkQd5tL=%W{x>87}riNkl8CTR~clFAWR5qaSU;gMEs(KV~)rGT~&^!)A3Ejvl&|Ouosr&+VC`cgo+l31=6rjOLd~DVkXU&2=S0^F;`)5LYamBg7H6tB5-g z6fDZb-JZ5-ls7KV1KK;KkogvuXRjamYEgC9GinBIP!nwPI zI1jkpi;r9}G=}11qDaKa=d2c%NaBfgI;gUG-(WFoHC`m+gyWz(n?Pk`F1y zBQ=*p&9FX*J6tSu2wA1rY=RPf72`3xgP5pko&iRbH=4b27qV_p0v6eid3YC1l}#?B zY4C9t^=CA<&VZgNc+ELF3b&&>4$c2+&1OQ7hPTce%#fzPp&- zlC&KC%F5VfwGO23M>kJ#NgOID;m{CgMM(7!R#vc8InVoNkL{VlhlU_SUrnVPv=}0)z+NPH8%Iwcw)fTQ;*6J`$)OlurI*gV=6!ml7q}`wGrm>%zgmTvG1=1? zNloYa?Jz|j75q{3k=Jc8Umay{>1VSZ1b2uQtT1K;IUiz)!W0AelzpJ-L1jgafiY8$ z@)A3Zi`irnCI(60d%~&Pz-nMig<7$K+J?v?6dOZ*o$4%tC3?+A0BF%&egc25#iw?i zJ!}h*9+B23%~mGCGYHD&k(j9*Kzk2gp*pMW(|Q7H7bwCU`*at$tCIGC1sevNJ zV=oNxvf-&*k(X~hTa@MF^Sb>hcNE&p38_Hupo(>2^C8%9b#EpFRw;4!G?!S*ncJ%2 zDoO(^A9bH*#V6+0IN>>Jol$Q;VLMiq3aYS3396MNHCoVnlV4*}7Lj1dJ3QPRe$)}U z`YbLuI$7BADBx5{t(%A9dQJ4>ntBxqCL42K)E&K=V91*K(Y!j6Kv=cB`ZYa9gGS0~ z#mE@zG?YTvMDDbSmaXp+0IdibjOWwzZE}OFQ@IkeXREP0E3dLV{Ud3E&HS1#_@GYZx12PWo- zIE6k7FAX;!Kcgx@ z3+S)VC>H0I%p2+&=mc>Il38=Uj@hnI?=@jmsrp|m8Fb^M1<<^PGSUoKtcWZVZj=*XEy*!;1>gE)Lvb@nW7<^%$ih0yvg z_d83vu8~wIPX||+DjtsKM7xX9&yR{&(XtrGdxa5H4wo1xy3Zr)&^(72U9qn`95~xN zLNA?}zJk}7=q?a!tyzK0uY{>krLw`=K03d%E3#$Pf|^vQ zQWaTRY1vk-YBIb~wV{G*{~6X##wQ7d_}k!MbCEDI5-b=;`%W=KitXjWYtIgodPZ&E zk4C$VSZ46oj3vjwlvx-hyj&0?Im#c0XB64gF|uP(0y=gCq^5{QrxzGDF0Uoqm9ilz zV>nhsZa?<52u&#y(>#73nF@Y2^m(=m+B$`b;uRID$Z3+u6b?v4hdYl@G%t^(j20!U zZ^(5t!O7q%3mes*Lp#Ew1g2-ArYA`9+_~B3_~i_3|3g}axYhfcLNIV*P*MYZ+c(p` z59iB&qvuwD%Gzsjb2JAN0#;p*)ds-pt8YuJp$OaizC{5^>QNpnreB2}Uo(gceB$l> zsPTrb_0VIBw8f^iKkSlQsCajeaX>XH=_PX2Jw{(+zt;scQ%tRT`UIXON zAn+a_E`^u7+SLg;^;{Llxui;t)4xF(5Gg4-EvhCue}@?iBi4%Q3gYVCwhOR9HVwmI zd!(n?G%({0+{!j-R1W%+tAWIR`Vq?M8>RWRQdC-}6i4;MQ%Mwqq40sU$S0xG@E$pp zYL>I6$y6FdUv{yE(2r*1J4_n{qZ(`AVy~cgXf`VYHHwcW`xO$Y-8epOgJb`H%^l8u zxUvaqk9}|nE&SMNnVG>JR5?Vd7G>m4!LfnIi9+svKiKM7A!%FeUJU5QcDatu3s^}k zbr*!URPL_XsBe?+^gn5n&b@ukIZXIci4>Ih1b6*x@^+HFpES%pY;;20lj9>S{ijBA z*=|YG<1&}VKX#nFd&I^2chLWBZSGX{f7@&Q->3L&ZayU$qQ9CV=>8dkH`W@*|6Kjw z{g!>@{LkB4J3H(6Z=d9|&i}m5|Gdusyw3l;&j0-1`B~rpukZiY-1wURUzPta-lpTR z=KQVdCx1COJnUQ#m%RVq-r9QR@c&&j30U+0pWxGOw>Lz}V9!$8tUkrx6kI*3u6HrO zE_q`Mo9ZHfGbF`moP0sg+`YUa-;n$@CZ;NS=pcu!Z~Q?3J|@6z1iQyz$vE{x8HDdp zA1f}Qd%;$B>sh+qukHU2pUj^~IEFA{ZaXd(_U^p0!o1-1m`(T{D zP0;K*elpE7*o4IsXa@u*0Q{FEnFf;U#Li&l&8=>5c(m7oA=1?CU?)(F zbPxo~=eDL|4K`}E@YM}%?$jCE;+CdY07x0sQ4PB&x(6$L zl?{0fFJ~0a^(szL8rO-v;{jrp*fFmjo#Li>kxd?9SaZ6uAl)`>x3sv?;e7@6-()jo zd<$ssKAx1QtxgB!lhI6vA7(*c0mjXLfqR7o?;ctqh^rKBx=+z!Rh1al=?~C}f5gEP z_=2PE1?lAkCsbbiq!`O83H}ZgJ{<|0b_D>igKt(p7q` zD4D#&43b6gE{&CeR0pFApCn~T1XcoIr*DHElz@#irKpI3QQ}sIFL0bAohj6m#lbKe z%&uWj6zwg>82yL|r$?`Y9}bQVcTW$FUY!JCJS(%d3_p2sqt1??s{Y|pK)(O@`*u5^ zir4WZzC=Pk%;=^s8N!^v!x_bjZMVN(vz}}DZ!Q0=<-hgk-&g(I7H-r{MH*kl#$pOqV>}{pN7r53XZ)V+U-F1VYbj2LHW2KKOq3 z_{ZQc`#(x|rry20+N7osr8V<51J;aO21 zT`(;(2Z%Ek(Sm$=@G1x&VT89wt>95L(i=X-K#q@OTm&ncC;4Gtc!qm+4#TCFEnhZ| z>bPZ1ky}~^CDQ3L%70diff*Sy z?UQcr5X<|MIw5e%da){EnW0x}(W9$2h*281i4BDP0G5Wr#SJjpjs4d_KLZH_0_qn1 z2xy^=KtZFI0tMIn|C;|_^Z)D5zmor_KM$P$ySx3Y;{Un%e9ixViVvlIAa$K|02K+q z$sB*ZKi2xc_4)tbcm995KL6L}fBpH7E@sOn|6bz!$M5#}5C5<2KR(s@&+#9~p=g?+ z8>|fdV47$rAIfBc$_FF%gGP~Y+Tm+X6$gQTMrjrmSLyZKT-N+Y#uQOEr_d*$Y$#Xnx*ARa%UNqfM0uMEU;Re4 zPx6gBMETt9kbLem3yu z*PQS~ulKlT{egF4~<^N)KahYecY4AR!OzI{V;6{g9J^)m0)F*mT z-b^W(j|jq)mY8737Z6HW;K{OVn%9!SU7S+v5TtmCAEawLM~_+}I8>DxL2rJk(+AdM zUQ{Ubd8~oJcVvqIOe)nee6e^oC#uw*xm1c{V`JT-%x42!D3!(&Ezd^d?0r!mV@h0& z)wTDn>CCqRSQ&UAPZMBJZX#oJ5v^Q^aTAR2<-sq`8ro3_u9KpOFDZj-Np*FsPT;DN z0a~f0lfVpUg$8vgSpm1(bk~{TH@b;!XOrE?xM)2f`kI2?3!gn$P$jPMG5Z{GCzKo@Ea$%JQ`e@)y=?DmGH1u zNm-pc3t3~?M|+~*^Wlz__E*)@K|2;I}K$1)d zs%HTTog3JYtVKyilcUH<3 z(ZG@e8>#SWa!6z}9~RACa4SIlKEGqzYwkdcT|HVjPHewpMQ))jH?#>F3|0{eT+H6$ z@->vv$+?S(g4J>2<^-{7_N`xTLA9y+#&=(iO``q|D&f#7`I!8pViLIFEkk=9%Gkdt zwJhd}vPgnSMK#K_Y8r0UG=^caG;3o-%bH=(9_jxfvwsiD550hs0yfy6gBh%MDuy3c z@@EX^zcL}017MgXydQZ$B1~5LTCnZOAA}al){mOl#shIqK?JWiKq#Ug@Ymp65Pe?GP(kmVo|&|DA&ev?phkhaeZaEm{eFo@SW)OAY^G9 z2C~)>`wT4tVCL7!us8){t(zk#(P$b5n-OyU3;U>K1X&k^oH@9vYfdr;Pty;Jsz4FF z77hnAZzWVwe?UHTP$PdbbjtDxZz>qy{fVr3miZY`=P1+1&Qb@MMJD>zua=Q{5xU=} z|Eu7sn|n7;%gSh&J2#{K%f3^ zIl21EE3|J$C(u6ruwR=ZXP>z3P~(J~!JC(vQeEMH-nT@U`t-m3UJ|Lk{Nf)nuZJJ! z>>38$=o{bdLYROkb02DI(AQt>B68FBuA6!;SCTX)WvpJd#H*91r3%fvp z(sW`%0t+WF&MO8O(J8rDbE7`qq{3p9ISp1S9u6Za8){uyi`>XFC>n6KLLnn# zDs=AD(Dz;>2pG)rBFo7;O^AAEHpAu1EJ`Jt%FRcHFB?cs8}n^;yuFhz>-#55RC{Qd z_LzN?FRHzqYu>Xy|JUdL`utyi9`yWQ(fnu8`MP@%l?b3n1uI!+d2nPJ6u=~V1dTkP{Upn z?4Vo}{4Qe$Bs~i&S57OZIY*UM&YtJS0cJUYnHBNb2k|7Eq?j#+gBEgvIV1+y?!k-w zPH-gd$KGPhK~#(e%LD`M=yZI|Ioav77U6)1GktH;72jelW{Y}6XD>%AZ@*$DK2X`A9ht$#<*t~cr=_6BORK}1v zH+ARw?J&(lk*Jz+pF168Z;k#L6)8dt+P1Y=wz;Dh#a4Uf?dI}Xs#L}3&QM~<~F*K;EBOHeS8Y4 z^=Ezmv)2Et^*`&+e~A7^2-8cufX~Uwh+t1eepMS;u4|7*#ay=cx61Rxk zL&k$c#`D4h<9V5ZL~wPzybegZF$qVlK{g&IA~v;nwTFqEq`Q~#z7t;zR2kqi(B`rg zoFxC4VH|iaj=e%6Cd=V(ev?dQ*YfE}neW3-;VMSY#_2^z#Wa=W$mnX0){5S!An@Xq z{Jo^R+WnMWz}_Oe_05}CFAn!#M4AZ$){Aoe@<7n=g++4u=Go!+TngsI0)D^yHv#VO z;MM*Kz2} zhWP*qKQypUllQ~Gr;ktnp@H2q3y21A&txD*4<0lpNCR&wH2rvWTBRVrPNEP4VOPu- z;?}%x+7K7cuarH+ZRQ?HL|hQ}%_ZW(dB~I^4ZiXkj4`~gsag#Mi`GkrSTUaY1 zrSgBA@ft~^rBvOBA#;jw_6~R7030|zIlwp5{k+o2csvlLP1=b1@B@gs|dXm5`RAZoq+fJuPqQahE{x4+Tkh>(#^V>`V+CSg=P>SVjcyE2bzN2vZ@|MK-*_s1GVDBVM=}jYmj63bkx(sRHaw zq0CpsrDWT<{u(u`G>^B&uRAlONIZTY-xRykDLP4(fJKc~9mt099sAtLdDKTSkK))lI(j)aLl z#|4mOyCY^q)$5Fnh2blF5?QcFr+_^+xC-;e|C$K$Q}Hh!Y zo_zrGJXp=3s14m^drjzNhhkBMCT2F8a_?=-AE$>85t%KlO+8K~YEPq_Ntg#4 z8^vZvA13^D8y~hp* z)VMWOh02@#Rx*mQ*LY}9U^P%M6X-)d8^ zpXifWrO1HgBX`6<;JIS8D6n8Fz>Yty_b(i zg$R_G7uxGgv=gzrJ1`R{V<2yRE}Zna8-8{j8VH%?t}wq%pD~jV1q2>y7T!9F;%T++vKJ%*@hWx ze~Qhha)UL`+FRY;xd{Yzl;|#T=J-vJA)}SE_9j#l3!?`(r-rZy1yaG0ow8v64Txqm*MUuZ4H$$g%J0AK2itcFH?b&Iapu;<$jYG6>@DObyUpP1 zplhS2I{@G?OToQ}T4w)DSQZwGC8|IZ1G1J4pY?*UjepNJF`6KL>7F;6%Sn;`>%w#r zDDr6wl=veS>R`WNr7y%(O>5%vaJkmZt3_a<^S2ai;opw~o_iXxrUy@eIlFxoR#|ed z@<=rWQESwE3cybcy*5lf;BLfg3;xACQcKPt{_4^Hrfg_3o=xizzBfqCp*94>GMh$M zWUaRnP}JObaal@zh%X9arIF8r%?&NR8D)WHAQ}l4637O9J2T6i2fy$8FjG7v zjT6l-!2xrfihE!eyPb{2jTs&WY_O|?tb_nwSnE!JYr!mrg;1WDzj+jA1`-G&eJ{C5 z_|8NU11ZEK&r|ii)hKG2t76Qp+10yd!vn;^HU_i2gd^0*Cb7o}n#MV>()RPvZq`Bw zz?488Zu$`C*oOW?-95j#GR9jhvW@E#A6#2vb&1^h)5ytz%e0 zrQT#p$93OdrDQ3f=@l~Ku7Mw7iLR;VOF!#OGl@MP z+a)>E0)(9G`C#VEwL;Wo=9>;1rNbNn-jR$1C+aP2{=48REh)A(CO8hZy6kh)@Jdfc zH!NM+*<*VK9_v=meKABTVzIQha71GH*jYaLSeH~!44M456QyMR`EC3Etn)wp%lv=r z{7>Cy-L1~^?d@kfTVJfV>)QXP;{P*QIr`he_%Bbp&$a*0GxP=A+TPq==YRTC{y!k$ zkn@Zg`_1rgSTWGc0@oeK`$oPNDGu4p^?pgCer2~DQ$Ul|Idj%ae%ca0j}v^i01`zN z&TTj+_^c&j4|CbMDr%7jzz_~Oj5eFqVSIni_A{gV*C3|n9_6!u0}B*7u!IjfPJL4d ztl}Z1?7(_XJ5nG0n#(Y(VkM(Ma~7K_C4!ic(4PUpx~{E+W9Pu=a{}5M1*Um+NpZf> zn))Ihysav1P{(}k-T`}~>>F^+;MYsW_1A!nxY`R74CtH!d@pTIr?!%f|CwU^6cK;v z3QchbR|)!epm7misI|EUb_WttzlUp3m7Mr!QWR1KKM4Lw4Y!c8CPx?hy+(57CKO z+OP(Pw8EqZ`Zt|D4bD#!X&+Iu5%MK9&Zc#kkG1^2mj8ca^8e=c^XHxCThBJXe7d>b z-fQ{aBmb`&{dJN2zq1Llzs~>sbc^KwXV2F1|0k0F_eA7Z#}OY{tGZZflb=@({ZY&= zglD^Ya#Pes0})piXx_GDM34iNij;x@J?Y86REPCYkM5&hXa$3~C?7`2z;?jt;0iOq z7TGw_AyDY2QGhbc$=9beyica0U=GwwK&^BVvfq8#<*>P-C8x^Eq8#ebm%FnvJE4#f z{W(bqH$+EglkXAn`g5gTQTPxgu9uX^%B)Zou~FJm*bu@j{4<$|-my=zYW$d^TxfUX z$#hnt$8|E~fRf3FWI!JB!KKWFtv8AGK(0t1b226BY{4RRqnz({FV$y((l+?mS)5uZ^kXztd0sT9m3D#KjPW4<3C>6+x<_FcY2iOn0(Qr-O=> zu(=R#BGFEkO<$t(Q_nL4b!%bI$bEECp4nS4Yb(r@yt7XW8VtK-C^bP(=nkyZ+tPlD za~?`ff(k{et2=OMaINP#6qO7`P8JiWrZYxjy0kD9ZFHq) z%t&R<^Hj`6fNN-%*#JJS;k6jk`%YG;f%W@xNxijdFr?`RL`zr;rw%J>fQdBGUT`?c zbzM`rhL>y8njXstM61pGN;K?lJh=($))7`vsI;gG;Y9P?*d`pRWGI!R6s>jsUSS__ zcA6Ez3yhs|)@yHCb}ZOCd}CQ^bs9WHXj6nQ(Vl42if~}IqD;*$Q=Vzc6d4M!!LsUk zOfj*uJh}!d7hf9og6^&pfg}0`8`)8blG%2Qz-xu;Af6IaD)K+tp?%SH$$13pF?icI zH1=!`ew|=~$nCCF)GEgZvYO{vBi(~*qp28HtP6^6~Y#uGfFddE1)6~OvcqbjX*tZf4+I`t8ss?-3xJ*fxhz;OhG%V+w z#^@Q!xpIvJrq*2a8*eyV5RlO!hN-&7af=biE{N?z4C~L(Sb!}B*i2xw@w1HSEKKhw zD-U2?w5cq2KEs$i;=4>Vjr!>sAGlB1mx0Ny$SJI_qS*lE@QD`2wu4f0vX4DCU7p(m0vhx_h#%Ac{3`$O3 zYJMa)|F~5v7T0+X<4jt0ToOW4CN9`mrpvF%aCs=JAcC0V7*)q*wm{iN$~-ilHT5}e zx(6Eos;$~?Ai6YSN_6p3MD&@=HzV-fO<=R?tqFt z4)G}s*PgRQIMKAjGX%oz$mXhP^`dkne7DhwqbP6ia2aHi(HM61GBz?bs-@OkrBWr0 zh@e~?!XV^%Fr4t6t!+9jRdBGeca>%Eh)yBO?<2)!5f6!>lWsK|7cCCl1eCm^Q&`Bb z0vd)*C>GEGnNptCbqPssIr4jMhztb2LR;huznc~2Ke6-&loEzijPHRKqLS!cirPrZ z15|PdGgf4alMr6$V^Ku0^~@UWkYQR-kY7b(tsrYvxxtT3OQDg64D%Y}*+*L7%1o)g z;%l*+xtmtk`qnRdPxmc^-0%UH($ho0cjma#Z@)CQ;sItL?mU*$JnXDS6*DpS2%NVk zR1X$%#y-4vCdoJd4>C^@us!&`GZ`>83inozDD%W?8&SM1I;0%qZcz9+<%#) zZy-+EC*o|S!=)%HCx-A>t?oTd&*qDr4^pY7NVvuJ7hHI_G?GjW&H$)+#8b#Oj<~qIRCV`Mr$pIx>cQwQ%NF z;2?a|auE|@67MP}F^`lO=wmI`{Bw(w`LR#UgMP;UzMXN`QuDoj!bvq}Qs5m%4mnsI4RV<&6qD>O8Y z3$Z#BEeXo>yc7zCdv5JnY4 zrL)e8{O!^y{3SM)dTqVE~6fc)utc z?G8&|GeEBV#>FJhM9;-18+Kz4ciFKSv3!-r>()XSszq2LLAXSWBI2C-jf-pybm%6Qp==3$Cms%A;l{J;Nl1qa0^;&>iyo#E zEXsKxZu*Tpy}T-GOP>OlSo`vr;&arzJV^laQi?w9FYrAx#n2Z-)zLV6--iQbJZyM3 zdv%v={OQ;jBmz-!;VJ&{1?-xdK#lVvr<@ zn?fEB5j3McaW=fjBD8@Nj@L9)&c{g z)(RFzwf<`9ye07Hh@6=z%D9{rF5X*yWD9)Bmi8wT^VNrc_QN^8V+)HyQROs&ka2qOn|AS_Dn&=0p>yGfD~&q2~Jc3qc>*s1jodK z2VZHE9@pIYd+l*JumgG+*ijcc)(mA+akE2`aCM3dvG_qIhlah<;Eq^rt224f*sL+x zLo#-iXFwKT>K_t{`TU@7_z6`yZF>+YAmm!WUT4)R;>9A=7KD>IX+LV4^q_W_Rqpy8 zi>S78SaptT`uB3B^=0?87`PA-{3AC#uRJ4nymFQtu(o%o;w-7kTMW4A-PxEVKoCC) z#tc7wTeFjfhE&N_tqLkAF#)i z=0TcX6}6<$GI8<8%b9!k=!>M&3N8WKEjsuLCax1j=yMQ7cXoERdq&ooXBFoOxr3e# z)wnx?ZJ>FJc%t!o2=9gX7xxsXm2Hd?y-hG5T*G?U;9DaT*oM0nxM>oR>zgmsP;ZdL z6J}GDa6iS=?C2~_7lMFAT1ADaXRuO4Dau2UMGWymTx;1bpdOz#L7<(!QsiQUrT|%d zcMaXJ90sVC8^XFuNXT_&p>^#o_T|n%D=||hX7M(yU{>WwUAzp4Tu+#l(y8C=NjG=| zXX4*6+$=X;P310C+f}a*KQR+;@RNsJBJ1>)Eibcddg6FYk`{_N{5Emz;7pt~f!RA| z&WN0C#QwR>Dh!Fz*bSMzPqR#}LrbKlqbe6xQPWd(Swyw_I2T_%t@yEbuCZNYAvG?D zL`?&CJ)|V2==IttORb3yy&F^n7Q>dHniaFH`B2P~0$9}wD4Uh@p=6lOzRIX8q};<8 zGqN!4M?Y8BzB<(VBdOj*m2Ct7;=&5epry{zD{ zbFC^1=-XIk0lBZV|CiMcl0@%0H`n}}VwRwt#|8QkSpW(DENEW^GpOHgGba|0kxIeh z@G1x^MEgvJ(Yx%8qYLai8j+=p;Y{1WjTLO)Y%Pj=5;v^Z4dd?DxN5D|GG3Z>(3S}P zS)CT~BC0U-I}?P`3{}d@H)d&6&sPef6<5=v@T?S+Z_LuBrpJ{?eb{QeHzSoan>Cu! ziu^YRC>2m!z!(YFLMtes5$Nuc6-cGdJSfR{- z^kHY{eB&Om;>{C_)=nSDn>!P+ReF^tChKm6ezl7!O-yA4nkx4QpPrf*mEo1d3v>^& zl>sb(aJs?eqtOaokO~|t%sN9$uGRx~I}AjkF8GqTp?@s3AKX8o@|Z_z8jZ&Oyzo+F z4o1A9@&Oa9hm1d~SPvsNl{1vOG*gw8sw|OpowP_JBtM+*$i)^|#Itk9!i=ZOy%^#D z7j0>EOt5*l_DlEpf*6z1nahF6LUTDWgCNv$WU`FKgVPk4*Q3?W`@b!ec zP}%WCSM)BvIG2;iq-wnT;NeW=+xeIAvee@1c&B9LLSFkp*78vbz5HROHFc1|qXijV z$BJIZf1VfrdAl0_d2=2A`IE$des1S~+J3&(>280yz4dIpW7qMYees{AgTX_^f8N~O z+I*_=KW$+-jQ{*}9sl_g#eY62^VtC11C?;0f(WyWNz4=J5sDR)K_QP$xuryjH>Z0o z_3&59<|M*9-wfkPnW{k1-^4|N1|s8D@G?!tLv&PvKasi|YVip51K@Ce*gZUW5$)}s z?tgo9e6W9_B&o&#??Y&7s9cyJsgWCx_>KLHig=XhN3y?#DwEJXh5~_JN%9BgMoy8T zo#pBodfP<8WP_hf;%fuI+q6)yZ{o{IsxpA)suRH7rb7~rd2}cHKkOeLoQly^4MJLY zH$2K3&5hT)Cs?L|e;f2G`s=~zchT;P9}Z5Aj(?Qz?5B;5m%9gt_zwSKwrn%TvTzIw z)$%lz>}#PVrR$cF$wAVO1A|^E-ys!mS^K2PFwi=hKxOHC%U836-evSGWo0?*mejwj z)unZpx3a{}GFwYcp2T?Nh!`JsIvu=pRo)!^#&VC&|=)c4=8*KnfuQU zeGLiCSuP|WOtNjkzoLhtgMTLSa1Cu{Lv6L76h4z|BHUj|$vwbOP{L$Px;3dNU53(w zibyx`1^3_$zQ84wBk5z4fLfeulA%?m^vl*e7Rh+jQrbu9tqjG!($plajL18@6`gPg<3(Xj)qhiL&Y%d7GBUwhiHs;rOrBZ zo-bZ}WS)r+DjnOf#+OKj5>)BH131v%C1bi$wNp}xDJ(R>F9ui1b&NjTg>-SNAa`T4 z(``7&y)T<$0D%CYY04x@hl{q|3Hvb{n%OE@L52r7&Fg7TGeu4ZQ3-}LrO#NS3w9>i z`w&>Crn}uw_cUCZBiy5uctBj!+pg^f>83NhJkMzzvtPUD~T_^tpXhamnFdgYFB;|O6Je3;y;EZ@5MVE>` zMKnK!K1Qp4e592Z$1U^ek&s6KLos@(lt0X4xssbwoQ}Minr4KpkrfpL!}!oit2Vhg zss4UAZkkx#q5Zg*fT~x^S(oSiVAHoiSkUZ*(Z8?z!Sn7?&ErwCu=(!l&F?JVynWI= zKxdYj>G`v4LS4*A&bh!Q3r~&cSFy&$NMhY8yka61Kf><3Ad4Xh(l`~WjN*N_#9AA> z&i}d2|M@S?|JmKy-0AFezx?9qm)q<8xW51K-2ZT1z~yg$7Ty0meY)9wuI_(!x_JMy z^&B?j`u^wB-2WWq7;V4IS-F0djHhUjghzFug+Ws_3>Z+Xoc|N83+4ArezCBA9uKpLr#d}fTzfVe z`6|QXg>{SM`VwRXPj&I8W&VgRW|zf6n1rG6yEug%osLo8EHUS8I>o5j(;JE}Kbwd` zq;ZZ;j@}&a?MKH)N2e(AQDnbqJa`*lCdHGwqps21h+gmR{bl!CRnr3WBxv}0+SrKp zj(*rb#%ibE9q*rfcXapy>VDDfbfG72UcZKCCkIEbqVIQ)|FVC~_p5(^UHOj~wM%~> z_brg+DDpEu5oqT=Lo@z-96nuIgMT&9h^WH-yG7_?JY@RVZ*^l1o8a=^yAgH z(ZSx)tJCA%y;JGh5;ja^C*>FIVwUJS+Ky4|qX+@_K*oB}@;jD?B{j|KVW&ud+;o zO5+6X|Lqhj7{&l~^-d2?5BKH0dtIS#j$Zt@sLbo*qt{0#yN7gbAMG9P@4n(9vOnJ) zygKEnAUYTfgyWICLzZ{P>dsi)6{|a9yw%3^-yb*-X>_`K{O$hf{tL#m(}VB#kKUY; z_huPpMJJiOOY>~fxlGE?-)k=bY-e+;+t3-N_{uYqx2WtRBOdZitjZ`=30xxUW*O|u z7>*>;^`bedxqVx~<4D=`TIXPB785bbjiw!##+x}HjZ${9iwP=)m7*kcG)u)Dc2%9wE7YB$X;GNDYH;jY?<(yP1EtjjcJG|FQ5Gd!8s%oyZ6EF|X{$xwz`Tki{`B3kH}?V5AB z;Rb9G{ge)HoKt0$Jz_6ufANMhJi5%93&YBY-+bhfUHp^TBrO|FI6f^_j1|1$_yC2^yy+Y|aZN zBCEEj#b*7yfJ~CT7nqK8q=qN2E`bad!a)*5*bwfnj2t2#bT9-=VO}5kd(EKmHfnKL z{hA@;a_iNS3c(k{0nOp5^@hM3Rc-)YLidehe2`6V zLNn(jtvNK8r|yL;{WHJPwHc;FlZAE%Y zXXy>MitEY`(fq}%Np8AD*ld~x4ltfGb1dh)0;2yS(O;#1)-nSKeIV2hw7P2C4Z1ru zvt`nP{z742tjvI7c6)|HqVi;PJY%hiiBJRLc8Oax)=Yk}iItv6{w4xu$+6geVf;|0 zsNhLgq1rutc;Mk%5PEe%#tN6v-rzMen{RLdPURh{389-!@x#iZNRDu4Tl|3oGze}>Yx?^`A#8!3ncIGD`Gc@hNzmnztm5% zt5HzTb&L-KHr^_tdwf$Ro{=v^R{Jf=K@;jIYl0V%S?nuSFs1!=l~XjzS=_uaYb->gb}6fcZTTUudx41PGG+i`0f(FuCG#_H;Y2hQo%d&tcKjj{edziV;w82R(u1t1&qDXA)D*{JB`rT_#$bGeW^)wzx_ zcn5R@rvKg9W)C)A!zax(+HG#GO$Ijn9fsJjiol{94`{JW4O`PK+{V00?HT%=_Pa*S zk$BCDrat0h@76-IJEJrw*D|okAT9hr5f_1F&6brN+n+_tz;8~cr5||GYU#8qD!rC2 zZ=~7MXqU?G<|detu~3D1 zY{CoJ# zo(!gvEvV0P-p~;gU)xFi_@vRR!777@qV}EmD<|CPz-dFe8)T1}O>NYu9KoU{W{?kN6i_$TS7L1GV7N63p zxSDwA)*=9Q;sLZ!%K^s{bjb?yQRAlzIAc3W0Tew6uRtbm-u(n;n(Va5{LqIo2j9Lr zI^N&gJ=y0@VPWc2om#$rM&wWR-u8*JQBAmG_5J>9riWIaov`eeh~WK(T5#trBD{c1 zw_}}Y-d1JXA^~TcEd=7R+c55@enT&c^BVn``kVGMvCI2mCGIi6QK$k^kjz1vd;rmC zfad+GlQ4fVFa)?^9WIC9pg8~qDnwgGz6fare>pfgX$3#-e*5a66}&k9v4w#{PL2)_ zUf=~I-Mvp_NS$d`#AC`3b_^SFc;mz{sboYWzZds(ZwdxO>^QpD1m0^(DXs0CJ9_IG zArf^IR?YaI`_n<5pZEM|g$d|5e*@*LpoOKB5G{4dYYfIHjaSEkz8I?3!~))`Cw$pU zh-Fj|cffP2z@*evqpM?Y-QAN@6EWO=+6Dq%2P!StlAK?s!4;Iih~shrh)`9J;yjfR$#%!-XaW81Z=PO^*pz;JIy;X2v+SCg6xc4 zTDzC|645jNQp3xb+=jq`r=Pm0PR1|bE>pe`FR?{hQ3Q02R` z$9wKAWw9VTvk1rLzeFRP4eoc8vKU-d1o9{H~OxP9)k*ywr92S0JS zFAolZ$7WLzPf0rDQNA`8p<2v$*qaCbs3D9?V6t+Mf2JAlIa)cs(z~^0_|DrG?^$c- zJF}Q>6X#mSqUwCL20PAfT}@Gi@`j7j>pLoTvaq?zc()gkWNb z3G_ObapCR??$V#Y;JE!MZ`{$pV9iF70e@1)t$B&;F8v}|V&H+?u{a1aT zb2m!qRV&;1iUj~7nW%lFM*ipI=#}({LJz5AgzjfoF&>-PjD0-|;DtN(b?|@w@Bfdp zg9N89UIdr%v?C{{x=V%P+&VCADget9UliGRR^rtHT@4w^?cj-7*wA$wJ9kXx4rDc> ztx)RqwRBUy=Z@hs$YC4hBVXJ!;brJlU9=?7c1%zW$9Jr_xK z@&ThYue?TW<0?XEJ1X2%HEE5ZO0Baov0}OUIBKH`-R7U$fnJwnwNy`FCvu;ARlxn= z_HH%Ct7_P=$m*t5nG}l~a{+q!G2h|VcTj}ZcEN${$($s)FeIP*AF!?5;QWdoOB;v{=UOmg2`C~r~CitR~qJdDd&sagc;^C9?pxx_6G&M9LVG>X6w4JeOaVus;` z()eoh8;)|7kBQUVaNInrhrx6_D-f^SBFvNGg@`>;ZVZb3Uo5gPsrKs{EbB!-;aA$| zmX#f__I^*#H5;4>lPc^OdS&4GU+bJA!l!&^yZ>Q|cuzJF0HHdy8_QDp1!TfY-Eki03EFQaBw3bH z&V;XY>y44RIZHf9+5?xm>y379PC6`=G1VO&ASqjcvFk1v`M;Z{WB1_j&GG({M3&HX zbwb-XwI(}23kQ@mWRxbF5*_qSMDD*QO+2sNA13aR~c+? zRt^ysM4V?e881bsJvXl<$VD}9?wQBZZbzo8tYoTOKBS54y)ewFc3(iUbCWQ*$`~w{ zRY8g>_9a|fCm+%6%Hde`jd7u8mdqWpfX}GCDslOUHpi5t5I+M>^=MIWjl?*N3plb+ zk!aBq!At0)JF4LjOW|ApG7Ieq18L?Ig`BBF>&Q027>n&Bag<8vb znoF<3!LSNobuIQxwv1iYPkoM^%?04a10)0gjoaAt68COrvp_>{BR(SMW)v1i+cFi0 zoiDbvQAx_-DR^NA|AXoQ=hW&Eb;ojtUmSyz75yBc3tKo)dFl}^Dyd)WaCCg zITWMS4?B&r5x@-NJIyAD5V)SB^wNy)$9}>rvrrNr;!lixLzH97r6fz3nHllhn^lzndr*YjEkW#>8@8V9%WgzMTi@8rLZVU__h8*s zMgcaHE-c)I2E5ZTNF0*tdGhZTK#lWq_)iFT0&Cp@e)! zC+{G5n4@IQlbHmZA3}2t?||VPIt3WYenm{r!9S! zYZk44mI02z@#1M(0LSnyiBy=A3S`ymYQTaeT5)l-?>?|{IZa@tOwOfW&(1i8)*_XZ z71vmcIutw<#0_5S_AP~f37e*5LCDaj|2Zs5YZ;6X*4zF0X53a^>H0;TBQ&$(1gT5T z$U!v89gst(_HP?z=)y)X=-zo!Fs?&|t>gc#^FOWgKdnE%{rG{yb$fG z1Qd%?Pt*h3J7}Z01o)1(3W-cSA@XM{G!J2r|4b(03ZSygu)>rjq)>}|G-V$fkgq2* z+&JY`jB@P=L}OF}K&r`TYbn}#Sr7cepkJ#mu5}IWido;z^ zG6e?m7jxvDIZ}D0G6cSmN_K+B zUG&_Jv^7kIGm4aIii=WO?76og=1k6aqEI=1v`;1Hm2w5k&7EbTErK*3kkqDDs%@Eg zv8m?;w)u5BNhFyG9g5kSujw=`{#K$-Et|qixo+rUSQ25Az=OarC0kv-MX0LX zmhgOf{FqbE_k?+;rBx@|lPd>JTcQb6MjjfbQHh~N0Dg{Xm%N_1a zvuW7y6awty*d5C${QMxjWqpwAw0#w@H3fYzE4sZ3Ft>ycicjrV0puyqGK(&zoU^SEiXe;t1afusRV4T-I!=S?q5*9x3;C?0ZbC`nsYwWy_n zG}&gKlC@<}G|jU1HRq`dI34Y~9oGnH44`lRkQlPo|E=?X{3quBApPId?)KKsmum&! zTL0(h|0p=S=|}Yt`oGQXr(16R_oqA0*ZRLtr2jib30zu%$V{s;*GD{w$2Uc4s{Yog z8h&G`8j!0}F*evh$cnpxnH3^rgCZ3rui>d9qu5v1*?eQ?5YW%nb_m0CD73v@xq>b5pm(obMY|F7-*+{v|2Z9wu`d zGE~R=ua8a+PLGa%q>#9j|A-PQ$;3)Uin-1kcN@`nr>C!tLW6OdaC)SxvIOQ!{lr1Z zfY$}ndBUYmFobWc3)xvbxZ~U-(j8SOH4}FB$fiF!4-&}dHaenz= zV+B~1p(6a%M~aZ(U%#HklG&WiC}q-l?39 z<3VzjjWJkJKhRU>WJl01IA?kA3t?7B7$qXwH1>{Oot!q7n{(A&o^qob_ny+1m=P4< zzeq+8Hme5A%EX%OEkIL=pqB}p&MDsVNkr8siWXOSX%9`-uMW`DhzQ3LG+w@WwO3gN zU2gTAU))RvzsBskfU9)CZTXYTbEVafnrtJ*$w#eL*h|JcjPI$yLyrXEcQA_YgA4*)vVo@K z%K28HNJ=9+jOf!XQNDC|p9yB>1+52MN(v_j7db>PaTVEnoQ%rr4CFNIVrXmw*+Z4w zhWj{}^0+f5yo8DL!U;rknbnuzJVpUy|uZh5W z67EB^6=3l zn-{s|H8(B%6M~;_tFwRiv!o(o^lpeAbJEjmIB*|;etLG$pn6rJC*uwTUrc~VCGRth_MVnb~XlSRS zM%9*!(WX_K6IOEJu2wr+0k7TF_Rf|F342;s75mv(>=-uoRid#npdK%=zZdNBY9PzG zd!16HKiqErm~+u11DfXG$Q)5{z#2BQ!h;oNHs6YBn$FE?F|C$vev;Epq^kdwPCJ6$ zxlTX8VK+>$n{bNUwDWA7e8SVNEo=|u`pZhq|$p6du_|2YxP>>J>F#5u!oM6W5kW_vD;XLnxlyz_fp; ziJZ6;uVF+|G{~G0CDA}{Upzz_pjeGS1{UXVh|P`qvrxGwf)!H{%dXjMfP7iTic!IUp(F3dA_r@|61FBdF{VER@rl10T$SQb$2$O zEBmjVO|t(&GO)J)`ZV@mR_a5a*_Y`LaBtf$3>7WE>qzSL=3NgRALPCkUrNJHd_?pv z&eNE3T0M{{*dmjCYDUD-Cpk=lk-_$V-wFLHe}RrpHTZ?$8+31MDZN%6e4T5W^@ z^~OpAPQB1sIcc#YT5O3!+7X=vwIFW_;#Qulb-WVQnS(ShI$xb0_w5tFy4#nU&pNXd zS0N{(Y5ca8+EVpvZijm+eytsaWEV)7wRM1;XAYy2Cxa{>TFG%a{FFbXm57bIdo`UP)SZD>Q~^4=bm&sLZRL_lSRR-*-G0px8X*+&4dNX7ylO71}TUP{P+0G zRw&mj=DCvsXAn;Jv_ zeP0dHqMhD?#1nXCMeh2j9LrI^KU_H2eB^|K-8o_R&2( zY@DRPqK`%(yu3(CidETbw3=qpK(%7g3;{1p8i+BfKtvIVk|#Ghc`9eNr`ahuWVu3y zX*_s~DYKNN(5Ep0nq%qnW|HD!TSAO!{j-FDvfpj2=jO5Z`tBUiG`$4mYYJWGFzK-E zEZuMD0Ibz?_GSjd<=bjj0a38}aT=2aw=!TXdR|jRjEan9g@@#VKGyZsO2-Jh<>bd# zd(rWmSEmQx@9PXx(LVhB@%7QcD~__jh{a&Tol z#-08)Eh7xEG0raCTIw~|7NtA|23M$7$G%LnA|4xX?tT;3enZso#ATMV!SjWduYF8B);rKTfhR-vy0!TSTdVB~iN*b>0=cdW4 zT#m_2SUWRKcAXSQDrca?Q?_v@Qay@tYFleS>{%imA2Ae3G@V_H(*YSU7e>$b@TKhE z{e6G+9SSw399ktt>&KFmk~#h0Djg4vK3Pg()7w;4Zv~;#!8$K@b-AP~dmYp^gmQ-` zOH5NdO+~jJ!q?lJ``$|RZ3X?@`KvnURgQ_3r>=I=oXncW_@sv3Ud}qFKEKDS#K}3W zsQi?EBS1oyTg4D3Lk$TOFgLTsP6=TP(BfA~b_hjr>wcR1!R{xy=bV11Tiq?o z*f=neswRV_e)s9_^ZK1;hkEJ_hNMsQ2A6p8W`or%aX&I{!z=D(uDD=Ju1ODwpsApN z3!+#X_1eGwg&V>vi!Hv>gn{8R^@{OnQIftAM;czM+`(HoQyu^c&ogsBlt(!fUCRVh zt|EVhB!i+Tsxe;G_&uOX8_atWvKvX5`$ERq;R|)PC;>0A1m_?p6lRaEa<@*Q(>@ZH zp#|S&?l3smS-97!IajA**KkeGsSc)hQ^#-}!a4nqsS$({KxN%p^&QJ{z%B;oB&tkj z!a5lv?Z7>mcdgEXb}9?$rQy#2eSf>pz)XKb%PW^|%{@x;73tFedMOlbGDy?wFiuPs znrz?tt=|)u^z3#@*`P-oDPjL-OgT5;VZ52ul^W(3z2Tvk)@tHb^tO_Cb#28=yT0;h z1E|`g8jtu(X3tzZ;cfmEOhLVrG1Om_F;pF^yjhg*rA`y2Ycm&n@CWG0=fUX1VBGKU zotJORb2FzYb1^qtTJ$YyVFYr-=HLQp zZA0d7s5L=29O`{zFI6tRKxO!%j?{vM!Vc4{e|v|(C$^4TzwdrJI%v7hRMVkOJ56z( zv;ikSO5b;{LBrv$!sY%*xLiGmekt}v+`U-JbBl!vPRTCB_3Rw8i&A=+ZTH?KNFKnl zrwq-Ds(Cp7zQ!G{w4c(EtK>!=7a17T2xJrVnszX&DbxA-F(Z>&n8<9{cf=pQ{|vyW zmH$WO|DoeQx0-L>yk1>he^dE?RQ?}U{}1i{n-Z5mO~ik0Zf5w;HF&@FdUL&s|Gy~z z4>p~DUgYPG<_~i8Pmn@SgT39;)<=xt^lA4-SzQwZDrM&adl&5!}9Ur&j(uNQdkgo9tT;?@~cvGz1E_8q(K0j1YWfl zP}{c{S2$4Bif5J4YMC5OcgSW%7k5*iWv@mNsH}a&sj<J} zlOSxd;1U=zzQqc8uMDIXOFpY%`{n?q<@M7-STuxInP|}~{;CmoCSg?}SLxnmrB1-A zvPR4SlgcYDMx$oBGp+NpqjdFd+xk2W&a}iT_b0+nt!bI^OuNNTYRr^rE)6xE5oXG< z91Db)PK0stnaqPrEeK9trX9V>Kh`B$W#Onzq;TBMMGGckHRbO7{qUL0>_-Z>AWj7b zC_{(|wVtK#4nL|{^naDI)vauGPtx*q8q-W)$`;-uVXS=B3IMCvhxgwv4!|ll)dfOV zWt9@N+Q7TRLVCsVt1knsnr-RUGhsd`)kk1c4W}7xmUGtF0#_OBRLMKd%oRGCJ9wTU zWYpk3<^vYZ@%1kdCR*wLEB(Ks|6g6-*l4b8yxn-SRq6jL{l8uRuijr@Lj6BM|G%~Y z>s{#ox7JqIQUCvXWwX-%FXH(tKf_Q%oRZ9pB6b5Ny3rlLD)I&B>*dCYGj*Va-%zQczH!H0vxFTv--_Y4Y3NI(wk_#kmlgzyl* zzToX~?9bEWj=zgh29;^EpP$ebO6qZ3+DiTlSV(bYopij{tp_BF?9f*w6JygvY0@Zy z&me6wkO0(-e~=q0vkWY|6Vb0Q4e)A)Z*`IM!76|ax?f4!<9_<|yRLD}T=Xy)V$oo& z^>lZdwbi07-}g)85UJH}XzCPM`|~j61hd(46mi11TBEv_DyQ zPJGDR6m4IbltTw|?hA8FbQU|&wV%|ktNOb_Z$pQtVGi3ykg<#Ipna3p+5b3!z_zWZ zUox{Lz84C~Za5l6{YYpybBT17Bo&S%!)qzH=a5nu)*QRamWG-a?urJewN+>%DNFV} zU_N7PD1vr(fM*X7s>rtNDH(+FQhXGHIiU>uF!7RA)36+Smr#O01 zgcrHxO}QAZl>RD&l;)wN_SyZ`kJ_gW&4CofIrrD+{q7WbBJ zlif?pgUexh$H*heTZd`jfZn8+Wf`2F4**sPEL>&H;`$;Xaw^?vfE>Wjo!VB15;uZi1ot zMaIu# zeQzbpa+tU#^sek?qSRMr;gqBgXctNh`rT%DrZFs`d2uUy4|QALIjP6kbRqr0~MQANLt+ z3?*s&t&Egp*L-D#^B1C6CH4r0I0UY$rb`4C*6P{q-tjbK?H6y(S$`ST?a(#Z7V;Vn z(m}s_hkyIloIg3UJ|@r2tzH`vrkUz1l$SW%zASaE78G@J`>2xg-{fIt=*u5>NR}x7 z2Rklv`{Z*zHp4buN&HFa9xpWL#Gb zr%s!k$^Kn?et|tE`Hd;Az!q<@=`NlrQhH9fb;jZ!TrPyjYz}M=pZ^RGBdKcGjlRJ) zkR7wqQA#JOr*}}!AQMa{tT-#9%C@J)_b89JD4M)UcbAD%{^_E3I4m8e(tT1lBeE3F*a6EV&lX>=@(slD5~Yj+1A5yx$H z0C8>I6VHSUxY~@T3FyYWc`8`P^Nyk8n5G%S;m|60B)Wrnz1f4;NX&;y*U}AaLD&z$ zKtD1dU;Q03jd+vKh{nB$7+TDSP1@ODirPd58_Eao!E&^KNGG;{NU~UGw!OEud`c5s zIb{eZ_-7yg`C<*`#KRdmRO`<#yb&GSZ=@rNZglO>=z*A7Gd`AAGO{ZA2t2{ozp zj0WMgj9~tpV=~bkRS}u0h)k90evuKGaM?XpV^2IlvBG}wT6)@Q7Z{(3SEyd9Q-x_- zNSG#Ns>=syI?j%nA3Q`;jwzW>fTm+rGI@dFnX33tmHvN`@t;=SzFujrtggS=e7jy5 z04n{zRsWwxy{jY|mNo$t>;Km`w>G8ze{+4~HR}Jjwl*sL|AO@YCu}+wV*;>-b>eS8 z=p5o9{}|)IGO(o>72r=7@ad%W>Em(hC^-J@pe5tuXLmJRMWaS<(1|ba8fn;z8og+A zJ?P*y3x*?MZQQ>ahY1Kp4aD=o{}8i~ruM{ie^sL!-XZt`*hAN~4(zh&1_1-1+|lhI zxpAc(CLne-;(h}*7+0DLpde`4RRpvcx5cH^sD`43!XKGNtCrq!#g`H4&aKnqe+Ij!r>##%r}%AcDG=XIPT#kVkAsuX z`$xg2-QVHkYuLb+@NbOa@nQcX;x<~JPlGSJ2ft#Vt+ge-y7n@)sI*_`HjDg)3B0F& zEd4ybDk#KZ1OXZ1=iff+RHM4vn}sSDfXGAPVtkc0qOW1Mfj(I{q*-;gq=U4P-T`U# zCMO-n?Hi;URN75WeHZq+jd4E>FQdtcuYt&M5IPOKfDQ<Y%yyHSQFvI`1<>Yf{CCt3_B-YSE%m}w5@<47f z081S5Wi3N6<{=AQ{YPIlG&7VSgQlgy#kk*rHeVo^D@^X{9bj16BbY!X7>f>APsWjV zdmZ(?^K+T_{2Z0_Axv#QjZtBOr*=9F+mV+H1|zTD8IbNXjqp@LT}p&oQg{J;p%;&Q zsUzi$%$ni7@@mbicvSP7(KjH_v`#iiJptz#puQWtDHu2RnxBPKDpa}i0l;R$nxKLx z+qU{eSQN?pslcApyhm&?mM>rV;V558k(FOs>rNSLT6g@wRpiKf215P-q1D z+LhKbg5U;z*wgTTERohLTOy(&{2;0504=W(kWiCX{wg~fY5stsnCcxyhR-VwPS}Uz zV+b5vJmPI50&6l2=NCqnN~0NJ!uGrDAtqe^_^P1tm_k;hF+={d;txmHQF4ohJ&ZCX zsd#V+#VTU}x$7{tA9LG#Gke+K!m|krtsK zFi)&tY&r0KULA74x`D>XvMtNx{M`4_fj90aQ8&c65*Ruw_3Bacs>v+KAr9Jq%b~&y z&~LAgo45yD4h#;TQ(=n8tsB#Xcab16ElZW-57;pReZ&(GQzopM^m_NA^Z`pf(t zAjXOT!ib*>=wlH0er}j>c~Ib;Wlo8BrbPX55BPZC9L})DCi}AN{ayK${-I-1sSxi! zJ%`wli?f$R;f)d>{Ae)#Ij1?dHk@WO@i!#-T30wviBdq9DZso<1RM9jf5?=ra6{P=G3$UB86IR z&DFj~kZrv`@LW3$?-Sov2wsQ8!$|qhe~|Li(q4{sv|3J36#W)(jA~wX*3%RlQgn_g zXXiVyUe3?4P(1rCfip{y5*NFx%SP9hTua%p{@H$+InY(R?Nqz1W#GTlKKuAh(DZ9X z_m7OVWk^KnII2nUS9qsx5zR2C*%iHePXk3fFFKOM0biaG!VHK~bio<^vgg~50) zc)$8Ei9ke?B5&h*(2E*1GJ|8b0Do^Q&Umat_*STVWC0Zj>i06=NY))(1;A#K3PQ=K z5`ZgDvv&jk@<~gX|Aj{~STMMup9)^F{}rkjq#&ApjgvvY*&Yn<>e~0S8XkW&(uRPD zt~Mu^?)Uc}#lmoAp&qb#soApc?)}o5lp9#^D4$tsW?QEiVeTaOuzz5dAx8BTY5_+h zGGg4J%&8Uz13cnlKwT;K^ZVda>-2bk?*uihgUd@Z=t`kOj^V7}(3Q}qMuXqN(Ex5e zMQt{B5B}Kk<>a1Q38xQI>~q&CT3{j4x%Q^XNGjE{Yd8y&N;GoBl9piNvn=}7X?JRer!S5(Qz349En(@=*)4hM2WbE4 z4e7v7!v0lMx0`nSZ8tt#`r26!CGO*?%8nYdtMqC|U2M4+CAYW;pbpK0O`GLhHQ$U2lJ|eU6x$Rh5s;&BQ13j}Os~#fqZG1p&#TB@J^1f9 z)$ZJjhWsUY$W<&n)yP%Lpd~f97VEePxPP+PR#yCeMujlY@KvtF?7fL(FdWN`aRu3K zOq)KZgj6_~Q`W5T%vs(xT$Yepd zx|1VxEBa5&GL?KLt90Qslx!t{0NH%8ii*GhO37u>Y^i8msYv5ETwcP2amO zXJCHVHl??8RL`|*Sr+Y=!lkiM;K($CBxeAr87MN$J_iJunC227vfyb@WJEj}jO&cK za0hXxn2<}swj>c3Ah&qkHuD&-=ZO$r%#j{ZlCtac%h_-GI>k1{%ASa!<0vyq%|f)z zh%poOyJ2SBz&=Vs*>(EmteCz|(REg~5w|bb^9<=m>E{S_6YcnpXg5>ddRoAnBBWI4 zp-F+ia>dOA)tWI{7^jmdg%~b=ltL&8{r((4N^I5aCRbV{C~Ei-;YA>4+7A1fy+Z52 zAxThSSFvf1u0uIqX|bi3nHy=Vz~J_}iZ9bt`%Pxy9rR)l$vQ$X!oxs%95IhhRogut z!b0wNyC(LHxcLEPWSo%W+v1LsOYBpA$`D$X!uY9!FIYSF25*3bIw^L6;l@27Hy1!Yit0aLJO0 z+gU+b(+;QuI;9`ldFZaAvy+FIZQxoo3)L#;5E!_jZ4PlP(p;}y?2-#Rtq46O=5i8p z+KHMr+}JM0S7lt;coL*k(>K`W$0jmZ9ohItmnR$loOfkwCh4#nkLtB1?4GOsS!4AN ztNch^s$|HQFGFgy>5?N2axBtpRH9@hN*0NdLL%hW6KV#FJtDqPOL9E={^^6CyS^^h>r7-i_K8s&q6G= zaSFIs{5ae~?udI@j-k>*yeusQxu!jlD&ho$GT4*^kB=ZJZ%if@hesNDE)StcQGfIf z=&mssvTG59IB&Y2O%}2XKWET`vB|#_P4s zD*nf!;(s$+3<}Cof;J&G*$td#eisX#Jj^~7^ks^zbrd{xr6Xk0cVH$OYcS(G8J(8g}?m%20 zX~8|N!#K%4kD?Sm1E>5~)}6S6a>Nj3x)Z1D2IiRGp;k|QuM@SqAzt2$S}@b_wshS3 zul?4S;PmkDAUN7T3XXR7e%<}pI^mjJwWFjcQNmA9KeuiD-F9?*yLb$?3&&*JJv!UU z4_Xho@!D2K?qO*uX#L*W`|Wi1=Yv*o_!+u<`pb*$t{U)yq$RG{sdM7k_S8P<#s(mo zfa}*?WN^ld`9p(|XKZ_omCpRWQwD3}SsuJwgbN9YOxdCo#2ip14u8{A?&qsqUUxApAR{DVvV6%z%fQst2>w$!7AHY zXU({?e6kk<4_o13q1{N?_}DRk1G9IqpXmhNG4Ldzutm&^K6fR?iZXZiZ)7+yR}iUg zdGn<>j;U2d7%5rl&}iOX$L;GnB=Ft(fayY0^2#U0reta^XK+gED)I~Nc(gb)9`m_Xgf~>{q`}f`zBH)rs%=#jrL!1KrfGr$x-Rzaxe-cs7|cW|X6ZCI=8nimM&AzkOTIc-=V^1K z(Sp|;^VLI*omaEhC3eblR@yl&F#ZEXg|FwOw#uei`Wy(p#FPTx_4UP1Ya4^`;i+Vs zUz`MUj?2#H_nyLR&#ENw>ZzXUsh;Yop6aQd>ZzXUsh;Yop6aQd>ZzXUsh;Yop6aQd e>ZzXUsh;Yop6aQd>ZzW`KmQNI2VQ#sa03AJ-Jq%f literal 0 HcmV?d00001 diff --git a/registry/modules/specfact-code-review-0.47.0.tar.gz.sha256 b/registry/modules/specfact-code-review-0.47.0.tar.gz.sha256 new file mode 100644 index 0000000..e80f685 --- /dev/null +++ b/registry/modules/specfact-code-review-0.47.0.tar.gz.sha256 @@ -0,0 +1 @@ +42ea7d2d16c5b500787468d3aef529e7e7ac4d8e21ae2b3b7bd14c802256b0e8 diff --git a/registry/modules/specfact-codebase-0.41.5.tar.gz b/registry/modules/specfact-codebase-0.41.5.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..c705667cef2cfe0c9aad274fc849262d98fc94eb GIT binary patch literal 64328 zcmV)wK$O29iwFn>uij|_|8sCi*r?dHBfuj?Z^@_ICGvAwK-#GyKf+BFLcoU;KUk?0q4oMLdo6cb`6e zvbVGQ=;>bX(ca_Te|++&@r&)x&;BQ$MWb;rD!QXIj4pya>QB>fK8d=sVDvt?jCzY; zI{5_W|H%_Ns^h;%%T zM{zJ|%;p!9IKP5t(8K``e@D|Go(#lQG?}Eo!pA|}1CWi2c@j>dVHnM#B#e?#9LXLk zBb#OEAEHswn8hN5{?Za~I^maO5?)KoH`;9Ql zM_D{8Xer;wr4q+5sTVLRp_W_lItX?68l#|yF4GKP>K&0wb(y8}*-&l=ziz|{w9aq{ zFu>7OGL#gm6S|2W=#6g+(xMUTGN z9Y2YlJ_^Fo!`(;G_#Yqc>^yq-bnlDk(Z%i;;l-98{0x73TlsG*{~7sj2hMwM=h5!N zy~kf{&->q{{Fg;}I?qN?-Un7YofUZ>_(v9nePE~EC>f{lZW<*;ZyJ6Q`EPgc(eAF2 z{~qsQyS<0-$5#IP96$HOvzJHWyBwqqacDMIyaOE|Z#3?~dk}EM+bjktYc`1*jRy}7 zUjFXj_s3!m@`{#V5Q>rwd*XEja-$e!5r{7WB(fM$EEi#%f#^0_^d3B5(D2c9E?&NT zC7uPdfW)VmyQP=iH_2on-hh}DpyZT`*0ZDjv*+R!;3^1UuT%Q;UG&E}01n%Y#>rL0 zBMA}#U%_;yqPU7+ps4}!;K5B?T&42@zkcvw&}ekU4}bn&#Yvh@MD!sV&9R+mW%Kd4 z1M=Yn#Hx%IzN6cq9h;AH4f&N3bQ&10%Px{9^-7YPK zfN@P0c?`gjbeI{yk_AN(WeE%}izYmRt9X_xP{(Sq)bwe!VV9GGw@3Yhe36Xc7okLU ztCfQA0`@g1oX>xM@akp%pO4?X7C8vM(*TiiHi?5|q^EF9WKAH@_qaZQsv^wdaUsH} z0K5b^h~3ZL9i1FKJ9sHx9=$p`IXF3b^ZHm}$^Z4g1NJ;Sc>Vg#iMWgsT;GU9?*|VI zJ_gsI;{+G5{c%EAoFW!aQ9v(&2vb<0AWw0-6a>I2QceV55a?b7j1mWHp)4N{_@%2b z-#H=&N8;%Dp*kbKjRSE!0bGG2Ow^oDN%=%%42e$!tqhQSK?66_xdvDgdQKoEgU~1F z8TKjHg^v(}KgzNny58G3vF20BCRdI3l=X>m%{QfBA2;FA9-!dO|c=k|lm}Fq^?3;xRua z!i}GO0}3cFx#SgHae^d{AQ+k-CV!^G#sl>pK#-bV|VyhS(=@1~OQ^#I;6 z(sBG+>&JwVH&;;taNh&s7bzUFG4L9-a~IK7a1Hz1m-`~1pXkGE0*ed#0P9B>|4*<* zNV5xZ-UXQksAqSU!t$R3^?-B1Js`@#)`vKf?{aGpQ_C^#)_iM#PT>^o;L6Tu^~Xv7q7UtFfJ9kB10SNK`uA;NeE znj-;t9_KK_1>r1$`Hq%qLdSvdqyPq+Q`3-}_B9W|eQi8Ka4b3i^@_&}(Np4S9|1m3 zMUoa$tUx;@Ho+cpq_pRz@wr~O#$yBX>%}a91N&ReH4rAwK1y;t>{ZU;AY|hSf}->N zjUjRGEDA^PN+eLuO#qVQ@v(5u*EQH$;5%SpP!%ONkY8C)nYzk zuR+U5U_t=N_&59qn;OcZ4|!-)9~6S<44bV}ItPMiMZL>jN1R)nR?% z-R}u8X%?>!e|z{&HBpF(ZGjtI#&816fu}*p-Gw=+j)q?%Ftnw9)tfEOaX0mR@g;LB zde^{1iECfR89i;3@UZvSFvTJxZs_isq5efQo$(Py@k0ba7eJzmu7U6JVMaS6EaUMa zDS{7*mBTH;ZVy-bOHUP&p(SGDIR1cC zF3;yo?DWoXlXp>9s}ij#Y{f%?NW}79-DvVTh#6664=4au4;TT<5+s+5&>BfiZ`d-6 zrXY|pilkYPElTedKUCDSuk{q9Cn)5cSX4$RWSFC70UW|o%k-8ylHwAgAo}9mJj5w2 z&fADq6Fk!kM2!hSi9+n+zy5z!xpg^J;SS($1N${Dq=C%n#=t8=rUpJUZ*0)=$y&e< zfTbe?&?HHb5<%=yB7PkVWHP_I0+H=o=nS|ZBFOL4IWXGfegRq*as#FJ9B83r3OpWp z6{uh%k^>auly^nB{Nj}q$rK6@H#KO$jif3UCny8QdBK-C^5t*cqk~LZzWH66y_cL7 zm8^7zLNDo99X@6B7>xz;gYp)u<5AJkfYbq*Ll~l!loYx)t=NGU$8qk@q>zQ{bgtMK zi+ZSZi!b@Urc2cO9CqRep2jdSpoH=+OWyia0pze)BM*qfT6Amzq$A^C zJ}F4XN8?6fUpAHCPt0QR1}F* ziLJ2EpfsUYd)Dgp`tZLn9p(K@J?(2#-ZX3zHjU?SDkgA1k#xUKiwK3?S7}}tEh7QZ zF`v%}S?rmPL2f{9)Ky|rm=`ITh~U!&85&Uww1MVu1W<%Mw+_mA zPY_d))&fwsfl7=~qK)Xr6=p`{LP~v6avf)BLJEEF@Aa}{d;hn+|Eu2r?LFLk{B(Q& zx4r-4`#;&Hx3k-2p<(^Izt#7Ddrx+smhb=ecAq}k-v51$pKiD7IwwhKPYZfz-XI>! zY@3Y>AY71kC9-r5T3(+O02Cms`yMvDur`Ct7^Mls!-dShU%?`gWe7CAZP|bSu!;YG`RKs6J7i%drF*$dMqx z<)kSG*=1DpX(>?b(lV5Ieemk=T(nZ!yI|5$3IJ*DmbldJ;724YzCAek_PkTFdpl3H zrb-X#3gOMXfWG_hq8x$CF|b+NYW20Uy}zOHsCM~P5T-YzVcA`DVN<$DTHeP+zuQex z&vTfYoY4Rl4|OgyU!f?CTJ<3>XGQ|5)*lN?+2#w7gYcS^+=O6rR@?_N;@vN@ro|F3!U$2s&9qwR{cX(@45t@PBL z7VJ;IdHLq+gO}pXTe@F7Q_2`BSwxDpMp4$t#3}D~es*rGEeab45*1{1KBQJ(sqnJMc>K7SHXCkPUyBGcW+U74BxG(-EeDEA874i z&(UQu<1a}g2*l+iy#Td^XUP_LG!CJKBFNNPrYoiSg;W@{rC6_T8FqDjd%fN{oO~J( zpa>`&HtI`c6Bi40r08PAQA?!FB^TGhQ8X*)+F*pX-YgC}WY^%sE#zIOyg$TI zlQ|+Di2}AL7jrU}IC$O40mt|r6SezuTYCm|(2V)Kt@lB$tM4oUO$g(hEEj=0U$qRz z*2V}WkBPzqgBo}{EZd;v!C99F4^p}TV!ct`q%!$GXY3g&?CH4%sUcnFob$~*-O1Bn zvFj)aiBgqOTRR)HduU6t&OWHiAsQ3a0Pu#2jsc=lPGrQfh$zExf=d;QQPH-ob~&ie zZ?BLaJmOo02M_38K{tU3x1=G8XkABNyP+Wu_tv4V=k0;A-3Fn1XF9O&vZReD?<;!H zH!y~ZolZl%3gMXwbcL|au{phrbr)HB10*k+gz{oCmqz@`Wh-tOUaXa_NL%C{5dI+; z;lgmZK;EXS*br&qcp<9y=(el29Eao<|AcN34;}!WfE5HPmL8X-2I8w53! zq<7Sj$ooa_aa*5u>FI(;{Dr#u!*k8nt@4;97o?NWG|aA-5!kOto%zr|afgea`^o0_C-xbCRFk@W%ZnFG1c7NS+&NF!+VOD;#s z&CK#`vz>gbTk|FEY-~Dnq3~_|S*us?9?6z7zS}{;?!g24W`N#rU18tgkMRZ{qaBv} z%}GZD+$6~*Jxp{w2`;tu+c9#hx(Ud zzS0pzFmi3c1o7hvTTv{Hc*#g|so*k*lf2{>1zjE0F-t|%m?MRuVN!iBe@aq$Fa;>L ztoqrfmZmkoyo|_J@AGD(j-)hKacpapIfe2y@o$U_Sy@o8u2zmR8w~*u{Ra$Q(GH8S_ zodkx#02l=rDX82xLQcGnn$4McSs03bBSd`4Lsf8|lUG~B_j7(}Lve1p+8hN4NpfI) zuS)C9YW|8%ZoLY!_n^=xZR5XlCs}5NkOJeW!x9v=6)mPks)`mMU6VJsHGX^(kvCvO zDvUzSkx(;|3)M5yP;9v_F=HGV-v9?Vl~T)VnCY3Q3>s(hJm4F%-@{M$)vNCF z=O^EOJ9zbKaD2?B6M}#(oMmfBJJ~&bnf^i&329)Ee~Od1z|TeN*UbE`p+e0bLGEpL>MF{8|A1r_yK*s`< z)reoP1CJ(C14XCO1Yqmm_pIL^@`s>N9VC2bog2s-0hMZ*0wdR^vMR6z{#Dsoy(+8M z;_l19!4{f89~c6SWKWI$@L!IOPmW%H)BW|~?{U=sxPxxi$RGyl;xjDqPgZ6}?3s+l z0~VvcR+Rpkr1a3sTlSHL)%rHRyizXH3$6IDr7bfKSmZ`UBA#+#9*9>cWvZLt3)l?t z?(q18xXd?MFuVHAOH}ER2fmB*_XF`ZE!c*pVm64uNoOar8%)pwDo|?4BUlb82}$3P zca~v=cm$n{W2uJl#U|ZWH%;N%|I2&V-(HM$s4E!U5Ya)uo+vMkxd;y zMVO>g|9U>Xh*18+D|682*F3D}N;Uf$cnISYw1b{A<+Dg^;QYY@3FBNVa{vqZFNz!q zg}ku&)zb$w*X~xJ3S>BMP=6;N3Mca^S)^#uuNE^9qVql@6A6XNl`oov=zRms8u~*v zF+og{HlFS|En56Ro{ug(st_PpZxFZx97S;xfvm2KuGTl^AQI2(s@_7hsxh1&vg(2`g@Wp?1NhfSQD9;FDw+isBty}L(f2`RJ{TJ@rK zC8pQ7*?Zkbz@>r_+E=(6WbkqV+IDG#!e>$_WZU+J1V}I6Gt?ZS>Y^vKKDh9zC_old*kX_JoPEIG$omW-4{@$n{=Ak(o`d2MlXoWk z*A}Ps#gqC-x+WTcWkLv`-s)HpCprTZsTggwAh=8>py;tlvdv!RW84%0TpQps<2Y|M(R`+Qfeg# zx@9|NBf1gd3ue2L)`#Dfs8Kp8^)vxebf>mjsehcdkS%jw?e4R9i)2(TBpx1>nq+=*kx z?#LTNiX*xN^atX~j1(cyXQ)%N_^#qWXgV{Pj23A0&;bN~&zK0`F}urah7PTV(3(ab zn4O78g~$+{`lyq@j)xtg9C`EHH{c}H!;HAG6!@j5F>Dfre#W^{g`-d#8sPlH2Y@$4 z@SdH453bWVwDd5AQ#EWxO56$gp0d%!G7_r;T8332q)520qinGj<(AMCO-1XGrl;Mq z4xry8Hbt+D$fZ$|y8$$xI(|dGvlpykv4=4!dlZ~xQw`AqPT3Y{8$jztI+laL7rVW4 zHXce-fO3|V^Jk%(rv?HRhzc!RBrx+-uDVLO=MRCEOC8xV+&pA!mvZxE6`SKFd{!^i zUM<~9tId4TORcj475-#?PTba^?>3W3|cX-@=b@2M&n?rOvGhN<84-&MAV#qVDi=$g=bU{0#PCh0% z6`lQp&(p$;(6AYKLkOxJ37nEaqgZYoVUE=b+rYOepzh`>HUp5f(d?WXAX{}RFc+xj z)brPTJ6OeM6(H=M46-a=L>ylHCqDBU=ldq(gZ~6kk1t#Y4eMrh$w93+YIN`_QiA#J z-ODOfBF`5^ns7IMYefjs!kd%1#=`i%1bbtpN5XvCRhjx?@&V-9MStWmlwg*C&QO6Awvp#!b|>amRC=uHaK& z-;-_p5H%Gt;HemY=~;%(QGhh^&p?LvkpV!Zt|1LZ2Gr(eYdtQ_`eX~w&%KI=Tc_+o z<#8FS=|+xs5?uzPMK_qmrt5p6#u07#h-*B)aNN&p9KKe-vwcJJY}7|Jw7>eJ(p?cq zvy7o^wP7Rk$Afp@9N%t(3%2n;+xVYt{Ll91Zz%ppSxhzy|5=s)4{&a$9RIWTaCaO3 z^B0N#c{fiu*w@JvfqpKis*e`x+m z3i2Vwl+c`0g#H{Ecly~RRTm(f1|culB~&ScXcd4NO1%q>W^utlwJ+x^o<(%gsuKs= zV!N~a0Qfiac=R4Yo>vdBmfAR%jJLsclwDwED->8m^lIGQ+c~d9C!xjBj!&XA2zIEE z=%W06#rdsZ`C~NF_r!A*)-#%Ccw;SHq42J>)svP?H|j`2MvIOD%nfWM!vb9LEfO2J zC~0%7eCAxmBf^bslE6Tr)rdkf2l9QEtKa1}{&a()rP|H3{jy5>;`Iqx@OA;cw)7|LOhe z)%I-t>uvvf@A*;@lNIyuIzIKzijzNQR~iPFpN~Tu`F~>fcn?;r=_X&9-A3;6oba=B z3o7CG6My~_hNC5=gr0OG$T>Q#JWoKkq98+$BAs}X<9s=9TeT}=j&4L zciu)Xg`8K0I9bJm`$sqcu&}`Et&*OqDa+8i%w(l&xcQmf9MjC+_|8fup;~femmc-H z1O6kN189nW>uv#6yFEz?Y`Hhe*AIXGH@e(0U0{m7JdeVEXf&AU9kaZE{#h^4I!vh( z+s0ImAl}j^(1MbAYx}#e#R+Om&!ooW2crEK4viM&ws@OiqFPYZFh67N1#&!1-$(9y zw@E`r7^s+r!H-fH*ne-gyZ7W|cc=T1{P)a-4X*tQCQA0&|GPL&r;)SxV@eD8DcAnr zsb_L7l`hup^|qnc>siM9l@}`&RkN{7%k;m5qgV4niOBxfQdWMrv$MT^*xLWM_W!N@ zfBUnx{U5J;)zhJVIvk=Cz;M``E$(#wm(+jU*?C;H|L^YZ?QHG;pW~<5Y*seAT!j$Y zY-z!k|8M#Kmj7>m{!aP7bnLv79I%T2?>^lt-~T^;y5;|$i~q~5m6^QgQm#fKAW5J> zi6$yddJ;@e+dZX&U@)6C8jayFm`sL4u`f=W@T7URr7m0kzvcgbrTqWlmj7@0znA}G z7~7^MfR+6Jahd<`KH1sU|NY$jUj=HC??1WzZ~6U}|8M#Kmj7>mw)|i6f9;L;$@Kq+ zk9PMSmG%E854ZI{J{SLQ9Fzort}stZo>$V*{5r}oYYaA)l{9;p{~^wm5kXhB)Y1Wk zjuTi_f))XjcN9kBW>ZWnRq3L`t~RP%CnlqfM>2u9T@Ny)WOMU`QU&xHMs67nx!fX% zB1%3$(v-_h;PfA__9{^_Q>I-~2Wnxm8GI0_=|R z%j8z*0ocQUhIFFgJmWvD2Alz3zCIxz-I6>uiF>BZ2rvJTJfPsU{_+()KYF8lX*@tU z<3dj@wH{Fz{e~ZPg#6`1)qd~-nNvMx{#p$u9p+c@)YCqjC&+zNdq9f$B`d` z`EZs|Y1TL&^4~aI4?taCQh_uDC##Jyxr81obugr~NFDiyOsrJ*We>3YGQ${a31X54 z;gC&bL-ZmWss^s%;wR zy2LA3_ffe;L%ILdeo<-w>qG(P;m_S9^oozl=(}7&u!Y$NtDQk zZ3>g<5X|P+R&c!PUBOGzq)P!&2n7ysv(tbvF?BK%%qdEO|5c-bsZr*>ECQTN=n+gO zjK(VC!%#0D&#e>{gpYXXC>Tg#Ge^6$bRIN(*=qKhaxyd__8)7SCye}?2NhW$I7h(# zNj8tnL-ctbrtgErK1H{f7XXEyoAz>mYU^M*#lP}0*G-L9(Vi85Tb-9v6PEW;CYUi9 z1KQSHol=`fX72ln$lh=2fwx8XD;f^x*h5~4{~BsiCLl~!q}t!0#+WBPH-yJ#;woPT zW)I?I8R{@~3}@uQAR6l|AtwI>vv-*%4m0^MER%xA;{0+v4<6{CGzut~QFw=t88XWS zv>2s2CKrRw5!zPZWELC^27mOr2BoFS?JNkD^bXD{$77d;wb}!NXI1WpWQpig6HZ{I zTYN~lG$VXZ`IYuRHtDz!P7-lKvLta$(U{wIsZU~et>$SMPtPj74210Cw2$wb{IQRZ zG<2>E2P>_@#BNE>qx5rzz%{qSG@+i73^P6(tAk}L#S9@zCl?7oZ&B8%FV$(?-ErR4s2UR>%T%P!U9_IsjpsAV0`R0n_ zVg%m-9}n?SBk}F`i8?IIAYOu=Bxdf~(;|QZPo+Y%VkHC4tnK6DqB%mX%GEL(sKe!> z;Z=}dMY$C4)J%p-$ljL^oWUx&$jcvGg&C5hY z5+d2CSIR0`Wu-XaIGINc=M9br6HrpxNgP8BJa3lDY9}PjTU42U@0%_vU#MxQ4?W~` z^(;L5`pbQ>TWO=LEc-$=q(Kek^Ohb(yY5r7ASi{a4^Q>{&H%Q2*znC&+3gg5`Df`v zMDK(wh#DhTf;70h3Z_Qll`svu6HMzMA5&slLKjuoBL0&+!PO*qG~#X=^7#<*E4(P)rk!B(zoi1ivSBANWvlIO=j znz90qbPJE`KooO0FsGmnbZl$h8L5H%?jlVmm}jIQ^TL_C#(Fv(3u_Uw(@HLkK{}0C zj+NN#xcyY`nFF%4umC!rPg|#D_?;^7P8IT5NgQN5hT6~NyJ!@^6xARmIXE2pv$ga< zDw5ej1lm_S<1iS?xWF(InY{+%>JpA!W~`XR)0kqpAMVH;HNf>bKaGYo4L%H2fc8E# zEHQ&3wcgq@IUQJEo;BR%S93*cll_r11C>@n#hz;rXs;e}((!%Ktd}{Ow!~_8{FOZ` z7cXt#aMZ_5(aK>ZIbeMzK3X5%)-7+l>5dgfNjCNtf!(*dXuA!2K^OwIOy*Ni(XsGD z6OYaUzG{khFY5p>H1w_z43Cr$P+%trq}X;mEAUZ& zE74-m+xZV=)yzT+S4p4+t`a?6ePS$;fD)F&uLag(xC2dad-Y7?`$QQWs0g_+IUH|F zIz-b0zJ&>SfMtKQ#(S$${1O}`7Pfm{0558UgMhM|DVZhgHS5;>o(dAL*$G?$RPWAI zEIDg?XG`=uAznB`2j$omac$*LZKaWazY;s{CaD83oRQMv6EXWGAg;clmZb{79F3(A zH2zm1n4_^ALd_bH7OO>SwNTFFZbj>(y##~a?ti##FSU}D;2Qzk=|Dfm9-KwmybB|5 zJ6-6<*oCu-l`fF7EIXWaOSZGzURbvfn|x+XZpSrDP{nS=#FRW&(e6n@-M1}!ebJOJ zyIKmwbPtwX!`Hi<0^A3%$CcZ9)Awi!8vWkk~6@N-ht>k?kEHP5VJTjw__uUwS3N`=iIC=p4=FpmbI{bze9qQGezid)bd&mfuf|IoStu>cr9LPfxylFJAHHK z-J!BHn9-3cXL1AK*uJD)BEYgMW&*I-&as)cV~f>;(2t9cIun<#Us?J?ao7)Kb)qc@d! zc9YHl&&VAGMhZuQaypx~m^9JWspR8*EOcv&JIjxiK1%jlcl_$mzO#@Wzs9rgtVXGk z!dvZqTM|Gdq8`#em@V8|t9LvT<>;X;LDEAkk2#rHzHI86XD=gA1ER5j{KT};bnW^v zSu|90NL-X4PI4xw2--f#qNPcSrnMbvAPH`}hT=-+Bc8f-35BG}=Fa5~JDfjRP@VFd zIFFnO;112?fFyqcwBd})>b*6%$~!g2A&pVLmd-iHGL0bmQeUN^bgK%fY9(Q_b=?pr zqscsEO9Ul+Ll-s7VauN~QmDLyLj};e_g47=EKw1j0Vga!!fcM^XVD+aPw@WMeP%S3 z>Qi;?YrhF&w4j>5|9}nz*59Lawy^)^RF&2fo&C;T3`|Js;1sOmb(F+Za#*(MNRJI0 zhuU`6)7iWLafeMa)lvA~kcHKq5_!^=ANtlJ$BLzs_)qQvMdio03U`=8uwQL=5?!Mt zDC@xIoF*SpQm=?A9WAe?Kyc&Bj8Qs4uN|r+r;wPwW>M`biuI~Cl#w~XFaWG*3upf( zn7l8`DkRNmXW4|Z5o6nD2>*FBZkO%PaxQYHal%Q9X*kt;=^~n969n74NJF1!==6^t zbnv>a=)I5&1Y_V4QC>#tX`9`7@sl1oPRY8i<#Xki%kH5+TQ_$Pwdg9*-^DdA1TV=z z3g&Hn0XoV1^7VfeM0K$Iq4Eu;>X%yHsKKa~`IXlMYd^qZEqI&Swi}dZ7PP8nPY_{v z2iUQ_ZF8@0B1Xp|oylR~UUMBvjAy}3Z#%ah3Lz1!2Pb^^xDh_!qzK;-Cwk<8nIjkS zX!FC8HF!W$^88`~i=daVarb?9w$4b2nrTXkhoiRm(oc1C5hOVs>jQ~#M5|6DHe#@^ zVLf??$)7n6#XiV{n(;RWwWp-Yls7mg%Z7AN6j^)$q~97>P@qEnQ;n>$qFU0|%c`FY zu_m^6eeL)J-MqZY`h{9*jU&xA1vz+!Sr56iSO&aaG1EcSt+jp)U6E>?^}8p&cF3y| zI&&1KfKXk47!4FevKSn!pU`Q?=@))^5te!&TkQ74w?SSxdQvg9Pu@K&K>xZdu^MS5 zE0G4(73chhDKG%$Tn_x8~?wJ|KG;{Z-0J9@&EYCU6TN>&j0tM9RCj}xQ+k+ zO!5B|VIN#xX3-^;gZ^=80P&Y?PM|IS-}3)0|KI-ny!iiJlK-v4{~wj}|LpEO-sb=N zto(n&*uGAa0iR|J|Lf{{Ol7|1($r zSCW98^{1-%ua{TPN}0Donz%b;97ac4<>-rINDYKAi^oOX_w;hv*D|}=Xd2dmkS&(L zprWO9pVP}_Un?h$IsiJ)0gGbQ#BmLiXzH2TTji=nSC3hGlZ?rQQbJZe4djE4TIUWg zm`SeC;qmvR*GK|0$u4vB<)RKbc~+Z7MX`1?u9jJ{4&{y6Z}O97yVTIyUe!`Qsx;L* zsT$IQN;7-4R7>q9Q*NyHMzy2|=w?pERa05^ReNHn8d_Oeq@#2qWSwMl&Wxlja`aBl zA|bOO0z`BLn#6eh$(M@KGX?7oBNVzDonRGD*$sTorGa}tw=8{@I>BCYs8h&Xchxbe zH{~g+=j8#Z=cR7p@!yHsvX%e0^8Z%;-^%~1ErMJAzvcg1{=fbC8SwwRT>!4W|10zV-M#Jo-)H9k8(siz`TdsvZ~6b0 z|8IZ(p7{UWE&!MF|GkGhPb&4lAMWgJ`Tu9)|BdI40MHfBqXNTor*R%k;>&~zY+Xdf zO@u+4bXf?^5Jlo~JmMR_waR~m1Q)}}S>#YKW&Q-ZV#Xv2dBfMi+INq&g}mqm=JbFO z$ma*i;xAU;>nADU^)sx#RbK#0BC3l`H5j@reN|ujiVvp0?$uvYrK(R`i|fbLz2Y?+ z67JQOyjoHp>L9zEBV{dBi2A9kK-tx+euf3P?uj?^LalyEzYfQk1gYRnsGDb#GSoJm zS@L(4d2$CrLA_63-WM&T_nb)>kncXG6}jI*RKAaC7VleYVRt2m4I`Bsv1t`C__B6{ z7)?x=-$L&D&&O|G_kVxz>ZKgIjXil08bI|W!Zx9|PN6V^6V$R)`U8+g7WvkcactI`^1+|h$r5XcG=i(yV8Z*g=(N&OKV%0Bh z@+OxCh4rzcPVgIE^8(NNp0F4S*lEb%E-c1(HJqX31GuomCzU!m=;=;N!LSL@x^qZ zsytZ*)OZvEPY{oUzo7I!Bb@4B~3;XT{Xstni zXcb)}!Vh1lk9#>t6}~ULHA(Pen_4()PYGjKB6I12DgeL|{*b{8sb!1*uydVz!BT3u z5;bDnBOd0w;ICnh_==Kp=dzpLkaD>VhghKp#iaeccA3JV{8*tK@UVB2#h~^TKy+H% zgC{e1H8E%@k@q`Ze}q+lz()zZ0orZp(`v@|D#Gv}R<0c7PWP^yn?T)JIg`%3vT$|T z%R9POQm)dO|9n<9|4WuRb-Cl6E1Zs--zD#p^d`~z)<;*WTD#h5oRAenk>HgjPu)V1 zY80uf{FMi@n%ONW)WJ1FKzo=|1}b;m(S{cvYt!kG_ z&1guP(UOL!^$e9}G(^p4Nn>53!e~Y9hNq~BE&!tzXTMxi#64Ke*QujtL}=A^^i-~> zg3fLrApFAyWh=(2fQpb((g_uJIii6n?x_T*kM^kVQs%&HHK4E`1w7gL?Y(!ubFjkm zrGpOcr!J;=zI9Q={kViap0BHT<3jM@RmWG?pn^~?7$3&@Mq}+80I>i3D=5*hg0`+v zouR77E}8L$l^IGZtiLG3O5tjNS2I;Jl#=SW6Db&C?3Xa+H}+fcC^4hAcF==FNUlxn69 zd~0{-R!q|@s#U`DrYLi$XIpH-zvjl+(A=$YxX9f%#|FdOv*~WsS$u1E#~JyZ_s4~@ z$qs27v}cPP2D|1SIq=*~a{2Y$cgcZsw{3Dv?X`R34*1TSQv$Qe?r8HrS|an(_P8R< zn)~BIbT_CZ6WywU*DE*4)aot}JMU5%&L-PLmoK`6>^C3^ zmbR)YE3Ua+Rmkp^d4$m0^>+(z#M5k1+PEsryKP=aeAM81C;0-Zb}K!Tx$}1VU~RIc z`g-iQS}ZT^sYh^Mb5lLA-CgzR1go}HH??-s184oM)Zg6JZkTVJciu5Cu(h^q>od9a z|K9q4Z~ecwKYvgD-zN9X=0V`A;=cgxO8(z~ep~8Dq9gBH^&xEDkRt^daK#rJ?n-!%f(8&~1RvR%KW|Qme^lh)aNG z(Ih~hh3G>OWl1m*F$Qg61?nQzAuy!~xm@K`#<|0X!#pZ5jy~W2*u<`e9GpSXG3dQ@ z+U#CoNJ_VhZ_RJrAiTyvS&CojUIooF>Dv`2*XjF+f{N35p~4NU@XB&BXBm*$)}h28 z)m;Rm_pQ1pT?(gQ_l#CsE(AP5N4Yp_sc>B@P?y?rPU^^njW*kV6OqZVPh$*e9Kn!w zdOPCFed`hawcFc)DL*sMR5#GN3oo5mZ1k8@Xe@E5b;t1Ha?I{JVa6R1%!{io+Xn9t zbKG-dU4QDRV+tpw)%pv>ACvMlB<<;r0lfsFOAsoPJ)U2f3D8kN$~*%!iGh$%HuM}& zzeO|#w?;0E$kMcE_iT7^2zcikKl)K3;@o-o3P9~vy$Z7TAv)8Ww#t)}u;}H>SL+N_ z`H9|XnEY|=5GlCBEU3IQckwvC3Sbt)G?^@b?xg9Yw)YbDK==G@7r(7;GQFe9&Y~;Q zi#*C;CnafyHG?f50o}2Xo8UJ+`-pJ~?`u#eBVy7>wJM5c=J2l6sz3W^gbs z(kUPyGKGa43a727V`;M1BlaT8>;5_}td#fG)S?7Edc)Gv;}S$0FFJiy8SD#uD#r84 z1h)AyN~A}&NXG((FYs(3Maw%PzD&}La>pd2jN`T^(yc8o4G>yP<{^d$0ae*}pz`Z3 zeGRJ~eX#znOyG!HBX5!o$@7OD{@6W5I(S^gGcg5jO%at;I(-&lMA>K|F0){ERUy zi=k&V^V;b>c4lS1w7Qwb$v$vz>p}2gf7gD%vw9UL@pL}51%jNaKbRCra6;5P*O|!s zXt7?B3#uW3Ox{g&0pHgpyLiFFz%ES23k~Kz<+sQ|_QC88!Q}p`(N-P$rHmYyQZpn7 z2X&%wPeuKgh4EG?pnSg^gNsCbpEAT4u5Y;YzV*A^Kyj#j^J}LRn7YsZn8y}p*tdQ! zgSDyie)Unub~qbF`}AM)&>=DV&R_NbD@Sb#dr73+H@}+K2GjP0Lq4i3++~U=h~j{SpRCejrn2QV@2-hiXRG%wua@c{da5s-P(V*KcB|_OVzGE zWBre9{_oFb|D~<{v94dWb?vryzFYpk<^Nm$zy0|c@c++O|KsVCE&u=$&A|H~9Vnn@m9RfPXu5 zhsDLu=}+zU6@8dZQdOM-2TXS!pQ1j<#=dByNj!RQN5HL8njC9Ii!p32drW06&AvEZyG7wf{{VY%C*(e$+kDWNm8$Tv5ca0z}4^f)s zERzpukY>zuxS#Q}C8bpG%&&9Yw`El9GAfkIZa$-jvxU5=9*(j!&#!_w8`2%>Wy`!m zvcoP*9lmS590*KOdkPqU`F#uVuk4F(MpxzNZQ+(+_>MZmd-qWm;cusijy>JwnaE`( zxi}HJ-(HbUC$b{LL3U}SPVo&5J)qoA-TPq}^YfTG3E)6c!s=((|F_V;Y%!N!G_YiI zzv5>t*+<7_u zuDjUU9k!Z=m)5k@8c1K#YGzGOo?+xUsnzXVzFaTVxwMM{+h`|E%aV$a7h=?5xt4 z9**6if<35!!hG8w&X$em)B?HA;0R*J9-;#s(Uvkr(Ye9#@!9ABHJ*AgrEV;z00sxd zfNCaI+QZ+G*CV=dH`YVOn+=e0%o(#=8kH!;ISjw;*~09yiw{a8ltq}3Y&0y--BC0v z1p5&kV!Gx*X<9F&Z~8#YFX4}xDG|Bcx}HUK&U1SUcARfJ&=l7a$5pQ8(-1Fu3R zN~8i{yT@w9_-bL6;KsPFvICq|lKwFmh!>A`c0E~MS9j@4NQ$*1xcOPWm!#mSpj zFF_{1$dJ?r*FiiXp|O=jQK+!JqsB$`j%cuLPx5nX3yG>Pr>GR_kQAl0d7@Si6 zumeI`aEZQjz2|RE4qm=ohn3zF2TWg}4=*>AC{Y-`7SR|tjHvV zG(v0fmRd^>am3LeNt4Ai{u8n;Z4hAZU5uzGjVqr|Tf6k#kd!In2$z%eqSbuRn}H@= zjec7eN9T{BYd7v0iOpcg>GJ7^d7gHrPcd7x(cr2H0$%y@cLax*+rUf9)TmjbD|HfE9?1N zZY8NmDe?!VHyj6fv3c|leA&e>s{vO~dA@ZM z-QC-9=E>hw=7c7GH57XiDx??#n``27aFE~9-{NASCfO1B6^3V$-d_qFcLE@RnU8{O z^WaDRt{Uz&PZUhs6pc)9p2!;H9~~!poL$w#eektHRUWGav11X>#NF;%$bP zS;d0yFGUMvTMXOepih14*7pzCgn>I_KAwX3=>?eMn+<_^kXj?M3(0#%t?ONa_`5WN z{Yn#)`a+{)EDJoRb9FO{Ypz28pS?T+#N+NXN>EkeLAAgzyR&IwLnyk59R)D_>|?A8>NzEusY?3y8d#*U)rxy zd08^Zf#il*!ddc~fg;i7*-#h3avxabu9j75rV&?5G%MA~As#YI1DEfHlNpS$jJLJu z)UvIX7uVls5uj;Wn_`t0{5lAAeel-WsZ@0NWJ237ovM2E(7)#9O1z*W9A8HUUcG#0 zV;2o*Nz-+QvqYq;AELX&ww(ihUu%uuST+u8l`T7m=}nz^WymW@C~Ndqp%#>tLJAl+ zcz00|F%&Y^_zke*0jiNh`c?Z{%%`)Vsj})VIZf4p-$|dIvV79(_3R?T-Wq1ry1`sA z*sba8{l=1ZsT4H+a+^EfVO9XGCpPMCJMe=SN-5NtCMtkH^IHnyKq_jr5IhRoQh)}Z zje@rl^s<9CSYEuj{`|oD*7s0a^P0jghe?k1DdXQ_Nf_07?-8{g>ejN-dxP!1+Vsc9 zKoI7ARzd5NRpq*;T;#ICe3}GlD05GQ(4^A&1|_@MQy!S{s(Rf+OleWr9u24iU=k7* zb|+t+@9ihI%`%L#^I^FYIjep7ueHB;EL5-MQonL6K^P8k2(6}v74eY)UWBI(i<$1z zokjWDmRwg!H$|I=K?#)DqF{8za0K~0CKX=*U6@{;NIc~f`@+4qY6{XDL$rqn$nwHx zzcq7_rfXRg)Rf_L%1CqVQ{&A<=EQw=>ueQq0jn%8XygahjTDzn#q}J?9jBJf4!be! zH<6PsZGVCuD7rb|;L_H|pe4?8(;nxw^n>TpbXM-dsmZ0bO4s9}O{mmBr^mgVhS1{+ zODs7*3vv{is#lfVa)=(hbfpal5+!Fp{Q2Kb`~0hTdvJVw_`F%V0wej$Urkjk1BR?nl-*`{BR-zo&gwyZYql)#01(PS)>ET6Wgx4oTDx|J(n` zv`QTR`snT3>b!h6=w3ko`TvoC-X6Ywe)RgA(tU9#QD?}Rk;cuBJbv|j(Ayi|=1qec z>IGk*G+*}*N)QctX6a3fKSE1jM*=_#SjQe~#~Fl$weHa;xZb0UuvQWb*?8dA4)#}s zxInqWHpxy*1w`((Og#>NgvfPFFUiQq6QDZWuMe zUGtBg9iSl%X&u&G7WL7Fvuv6gl0%iiZmo}R{qj6j=#J2_hVBE(##i%bP;s7JM+R`@ z0P03)8=TA50M19X;xPbaK8EZGH)UC2>(!r=^ z+m^~$%Vvk{!)baSxrpZ&0WI~ss_IZ&OA=esYM@>i#RVQXe4cJQWTrwYE8%j0~2w1h^ zTt>x^za3glOA4xL?aV1XSnShfdSBiy%s?+CP_ z8Vk#M4xq{v9amhs=J43@;Z2Z*6d?(ul&^J>eWP9r+%G{3HhOh!GBxcl6ot;5n$~22 zGLVG`=s_@@c+h(@Go2g~Mw+09>hwK&7V;m`n~P0=!}L9;gEUtx%J8g~7;g+b;)J7v`*!iF`^oxOY3_?Cul&zOxkN6NP8*V*%znBT3i zJ^3{TNk+yFFK=JjbL5T?VUMq1G8sPA&fas-LgaW$mx7yjVBPsj&YzY|V|J4`AvA0( z5rH)=cJ3I~bPK6oJ(P%@v=o-Vb6VLi_m|AVPtdG}RRZAU5--xZs?BYE+`e@NqP;$A zc_WBT1)4X#UkS29;*wnjS%L;Glj*vuWlQXkP7(VqD)uc4?G)&O<^!1fHwKqx8s zrhtq>C83xH5Tof7f`TSL<{SVC_H;c+}$@1)A;qG7^F+!Wr^7Prvi~ekGpBF;qwAk zLPTDTOm)Fl@rN2A*>T*`2%Q;XDEZG5b zPcYyHRK;1oo=Ym7rKKXcaljvtdGGiBkj9{_ zm_FLKX1jEtY4zl4DqD&)vI67GLx~Q`fy2)1)RIOqi|}i-^Z4Is@b$iZ)m2zBdU@8| zRj(|pk}>}K|1dH}38=$)9EU!27TkNwD$8uFlHnu=BtvB#6C_gNkwB%bD4$;dNyAI8 zvikY;Jf4IGsPcA4N?%7v?%>9CfxYnd%P!n;=vA49gzK}IYtHcFri~{w-LJ3`RPP_3 zmh@E@OtsUN!my6rw4{=IlZCL#j5@SlNpwbPSzrM1gBhj8;~NKQjm5B0^{aHid$I#< zZ^C`;TJODy;Vyd|8c?Ge-u)~n2CObaF%)W1cllC1hUoc(as8E>WUt0k8{Ka?->}|R zi{QleXhHHKjI!*utZH#?0c(4tW5MBvnd=k0EIW%C&k>J;r7+yM5RK&iPU{jHtT}b3 zE4rZ@lrkEu(ub-y#?qC0Hc-%2v|&Y>p-dZ;SVpDe8F-8+Z53XI-#0kuo)aCz=I*G*dck%=dEJSsZpVx%eEOASq<-Et$_gBRZ0rSzu3hhbbu zLv&6b@9a3f)$tt?>~$&=wPc;U__r8$7U{mi!Z$erD5n)2m*kE`Mku)t+qY|Zf^aYO z{vc}zTAU;(6dx;@jkrQAupcJzw@bCB+D^6infcI(b@q6pd6LDHcFUhOrt0EUN)_Xk znLdv@lfs8aox_mFrwIw^{cuZO@)rJL~;UBy@s7AuqS|cyvU8%hgyFI#vIVHu`Rjg0@^pGqc z9CuvxD|Z9QHcvqnqMG%I-^uy@-?)}cM*X?tDz1d7x7Epdp{f~zaA8+e_e75=@&v9Gjy#|HxFF=N#lA?wMz>9di zK+D23&OvD4yw&0&DsDhP=S(l;0|_XT*A)Xn!)si`G7Y2b2|yA;X;^@8a|}xYkbTNp z(@=oX@t17`9brm^q}l8$NTfSd2gjF_vmlOyOM_@R+W60GS||jAw5i~|DPN%LK&XI5JNR!IR|1A=VTf}YYBP}vK~bq1l?SpxvI$U|gvsNV)T#`6ZrrM%Zw_%kGo*$5UoMe7M`zbZsp7}s@_ zfp#*y3}y-_=&+NB?Q95tjSgDlh*EnF$yAc#OZ>I1v78Txl>7-%h6c$un#&RD)_AB{ z9SGAX$MivDQPjWz-7Fk?E?I!aGWCGZw{xl;3SkbiK|0FsDho@u@@O*dIAH;TmQA~{ zSQ%g4aE7Lp3j7Fw#Xzk7q%dCdEOVFQ*1;$RHVp8}{|{C~1U~d9STHp+jK6}&(zNi7 z4CmK#BPguD+-AB9X&+WL7|`>sBNbe3Jb~!1k~a{-8I+*u3t;IBB!^t`2$E{04`6A3 z;+%BfWE8cW011qBg)Zz|c`6|fCX7rjRToUzYJ2F&t0t!>e$=r^9(MK$JCON%tyi)q z@@N_b$wpXKA*$sqT}EL)6TX>VDn(ZC$C`q451dSlLo|;V$jUs*y%VgvNR{KCq^LS7 zHidr;mC;ZZ%2@?yo$^POoD0^Z+(Zj6VFY64EN7#%H@V-ykJ*;EK~MU9&;Wr@_(G|q z4~WaVXeQqD^w^Llio>eursqPF>*5c;tgIg<1Ze4A{1?^a!dNEgqHhM*@K*t0oYG$} zscB(38R1;RC4!W{JO{~xw&2#|qA07TM)ujdb-fDmp%lDYdKRlkuSC67!*xVL&vH(JFFt(-|!7f{pji1s}c?Mt((jUkof6;_zEExrj*3y*18m0GM7G0*T^ z-A4`08krlB4?>eI-f}#W#A=rC-DAWVFS&#atTy{4K6ucl!U-MnM6~~e?)9H%)u?`R z8f5SXBRADzkiE2hK2Z~OgzyU99E$QSCgY|zqz=n57%Cxc3goewq)B%z?t>1PHJK{_ zaFeIRBkfJnn<#7fj~lE*b2ywWM!xf34%)59Eh^WJACMr7^|KQ=~D@l<& z9mWo#PKH_<=UB)bUG@bXiPtDt(k-57!Ep7ZaNAYyq#eTEjg$j4r(;GaT!vyUi4E94jUf#)}r zvQJhzBD&i2OVeCZr#g`OBU^U$mR@uCRj6E>KRU+_s8#PWrX(P}>nOR#U(~+|Ak0wK zx}^sC^mTaJx%#DfTi!i#)J+x`D_?L4Fnbg-gK*(48$QX~){Za1DBg7!uLfw1w2@1d zCS_C_ING(>;vyaAJ$6?Kb8#o4pM`wfdxb@w$34W=O_p>BIfX=idA) zHh>l4=|J(cj?a$rr|H)9>SJyOaU;VFf{|`Tp zo|NwY_x7GV-roOzru+YAa*w-{bOc( zW-fN0+jEXuW%FPfO=KNiZilm~yzJ#56a^W3tf-dH)A_|D`pq0v|L~aJAJcp7C552A z!*ilNq`sx)6Z~4Qp$ zZe_XLDom5^QmT@V(>?PR2^$r8f$s{@{5{+6U#IwA#7_K|MuWe+N$9`haT5dT@3b}-s{u3b!NGLl1EE~Y;v7Rw<>WOfF-DIW88 zOEKAjZ{r~@dM-tejd9pFHvg7c{kQ*8#0Xa&bA-z!$JhsXe_2mT>V<>n0Eu8YRAK2R zaF5UBH%j7->PU_(5IR;<+xW0o+H8#IY32unsBx2+(s%L0- zjp_Rd**=&md~}mdrLTEuu0Va5ApY9-VObP}(i}+xQz(>6SzZ*}BoD?>u~<&1nm5h$ zsFlUH5zTNmfLrZSLaM_Lvq?OPVPhxr{EB1tX-Kq6c#$U)(93dzYW|qJH1}1XL%k~3 z`_Mec&LG@57V z`#xFRT`03o-cKTw&65eH)&@y_aX*s+eAwe1P)>bvC#h8)aefjmWiOkXF=8IcDI9*R z%^GABs*fmv1dDFEDGde&ps8~yB;|}0!_(hm3)&%LGDHoSbUQy5%M&zcvWYNV0w(Om^3B+WuOFD*rMtb zQyM`Fvqg0%w5;tP3W2ffY$(#8P)1clAEiLMMlRLzO`1vpluOo_Q-ZH^eJ0F_Q21ZP zd#=|feyP4`S4eWC&xT0HriMGlRnQqk8V~yz!w9UiKp}Zo(X{N6gJm6fAHKrmRz5FP zTDgz!2df$Eb(^y%b;LDpC&E>}K)0SX%BRR7GPO!P^KqQzg;^k~TY!U4q8n5*CcvxS=)Hch)YJo>pC1HpFj5_2iIuUX39*MOK!52%-Z4Us_UZCWnf5iCc(miC!ZORZ}`CysB5E;0%_w=1svkhGy+1)RmWsZFiqQ} z#RP;p>zMJoF9_A#M>SX%MKkV86gLv@^mta?Nbrzo!Oa|;7J7JU2~9iEDuVYB>6arQ zePn%Kj<&CGn4zyL*G?o84pV8cQ94_+O69oS9FKB{wXVz1nj=&QED_c6ww^+pGr70r z7SxYdTr`8F@0A_H+IR;#0a~X@iVKrI^F(A|n?|ESR~MlA#<3icjG>!9f8AW@+(_ zCDlDXwPEd(*+#Wpl$LZ2pK;SP1EjUy&=9I-={JCGvh=I$p{s16tJa$r+P1~jEcYjoEO1fD z%w42Pu&5C!)VS$L%XB~`Y2s5XqaS(8Ld8Z%YfDYiSi1ymKg|uyC#5jl18xza{_<-V zwKR0z@47o{-@N>EWJc|na|yHc|J?e2{&fDIPq+S`TmMhT|5JvvMRy4SSrz}W`}9%8 z|8s9=>;L(g{6E#!DvwVY{O|0)3>57d$IjaN(2=TEsPiQTvl$2)!yy);8B+Zmc+x!E z(wA-g$Cm$Z`TzFkZ;Ah7Ioge)KiA;@kIVdj_vyo@TmJvK`M=650GevHn5A)2Z2A3` z|8M#Kmj7>mw)|i6e?{y+ng0K9@6qn#ivGXN|M1!Pf8)1h0pK!A=QFyTR4J0hvzJG8 zfuVMFG%X3}A`LmT83mcuYJ>1l`Ao%Z`T|8&8>%~Zr|?iGEYwL#rqlZMc+NxeGKJR~ z^ecLP^yZmLnNfLyJl2t(L)mP2lVgT+P^ccc4PSJ&GYYG9uAM*44=Juee&+$*$FhSAQMjy zue)?Ua2I`gs1jMSNM?i$EZcdKjshHtin3^t&-uG}bR_`a-vdeH7wmna=pPqOVP_fi#&rAlUMi{?nnCju!Iin~ zb~JgX9S&8t^ItrE=Sl!es(4EMF^7C;G-1%(v(+7B9~SRTxf%5!l?43mj36ATB6 z3th}~vZ!AgomZ2B0~m(Pbe!ARSHf(CbrOn9uM39@$d3TdnGrF^fE&@mq@}9GD8LEh zWwUG*N4Z%s?*8Tqy*IE8v2`0Jw5}{AW*>&PGwPsz;pEI+ciW~M7K|{?QZ6{fqgF*L z?82F`$~7wMR>_aiUjYMLO4Y)fYNO^k#a0WL$dqD(y0#=kB{NkfV~$;M!1oMPs}{H# zl8&j>Yp9=iajw+Kt~sY<`$?wdL2LRA`8PaibkVqCw3<4N#LeL&OGL{%8#U7O+o26?ss zT3kkj;}qm9ekWjD5%>2eESSK-dvba`m1O=Z5=tZ@UnZ577laS#(NgW?BvoL6L1wBy zs$+)rnnBV=yhmwZkH8z^;=r&5;-l5YZF%cx`wls}lPl(6D)1;7#WF0=oGn>ZS1CId z;p#POy!X|K^^UA3*6O#X59aYADS{7Hd?=8$1Y`6RBIMMd9uaa2z@`V*?i@e8j1p8; zU|r-WfO0eb-^1OY=;v0k)k2D|7uhJV} z(s_V710Hn@ok5D<;u097WWw<@(jf4hAf8Bmcu%}ZfN2mE4mv?Ch={oSzVab`hplp3 zEXjdTQR#B&jZVNwrNXK|*!pzmObJ#LYeNtyTF=M{@3>DXCz!62G?tlD9JqLB=NE8d zWmXDH7V15~0LEs=`T%~P1ef_fe0B8A>o@NXpB)?@wwFK!Ih>OK!C1ZzfBEA9ey=mA z_fcHw!_xIxqVNoN8K8AUnDtjcQQR@6vAzmm0|Hpe2rya-U>!yf;H;oSOk+F=lPI}V zQT>!qUk?*;XF@USdL|T3WlNVY5enD*3UVo`GsH$I9IFK7@lH7>inToEkJG{Kqo-#m z*!Oz9N;U}7PK)k9elCL1`vIRk{ zU^CBFW+kg{&ht&*7mqRDpQeRfFxXsJ{p>B>A6(;iKB6)hfL8YA|gKGiC zsCD)fu@kMRcL@#dPom3UwCD!2_gY-r*+Ym#j1dXfaw@kTq=pm;FarGekz1SWp`zP zA1X$|V|(!6cNqT;1OZjm!Gj0%v@4ZHI8Pa@I}VsuQ?#D{AxJLM4jkINICy*15zpVf z=!h8%yvxM2EdgvaY5C1JlGo?u1<-Xr%)mTq9jT!(m^3P~MMuo>_jslTbR5A~Xo^-d z>Z&55rQRfxJ^$x}*WbJu9v_~Z9KHVLSen_|YS|7y1gIvc6+1sahec-!s&nn@+i-L~o6;&f?qlx?lp9e=mZ2jjEVznYvre(=eW%dB=yaXhf%dY@^awF_vHmrEPBRQG9TzT&Wp{{uHgXJ??tk zJiSOK;hCl+_T7d{OH^%@j~4jb(>{H!#ubm38m(JAZfiH)(KJslvnWc=e)#jho%Z=x zHA8DKriT!C+z3O1u@;AlfAmdKh!>i8C|e86goo49$B?U?#607*5$=w3Pccfajgv~ z10S<9YfQRq(_5VsYa+oK-JrAIuw}2R0?Z{%))bR_Fwjm zb#Fc{(P0!Dcy-Wz$b^8OnmNqU*?b};3}qrQ_LL4MVKKd;Wj8l6r5b}HzU=nkmEj4` z)mR=p7>I*;kQ(W+}n+=qj3G&Nd1X zu8M=|a)cm1M9AtERlL=!g23fp0QSu+O0c9kRKuBZ{^Z{qn8!| z8$sA$ytY%3_k!#>G(8f`$23~~&4D};2oq2&_Z=I&)G=xSEs*L8D-K3vAPAW#KSA&Y z;muvP?x1o8g3AC+j8t$dNc0cm#w*? zA7EB+&Ak2G*x6*wxxD`+^QEu)TnrG-0gt_ZE};Pb0?{+il}R_1$>Aqya*4xF5S2>V z2|usm0)yB{Z>b7$^@>cjZM|aSYi#pf_`vzQroG{11D8j-?eD5a0mx6PMVT*q%xRoC zqIK5(+uz(&g4}nfyi7-XCvnfiXxG)x)FIlh@g9;2T1SmwU;e9Y!_WEjDL|glHos4J zxYpTy$E^ouG%J)en1#A`yo`$0eWRf%?d?8Dlk?<#lHMfu+qbn4-!ZGz%&!cXdErpM z2e3O!z&+DERdc;EM9l<;y2%Ql-?}1A6{O^WL*3whhjYlcZ!N{!{>0q759Q-#z0$2V z%rF@Le)%)tNcg9oF3l<3v=clH) zsDzjM{H$)SqB1Cw2m?%LCwue4F0zGxPM)KKj|kiN;!z?T=4B4|o1)oX6OOZ>9cS76 zAfgYWXjWh$=!g`@`T^5*9=mQ8m?4Ug6NFwa;EL{1S5yjId6k%+xo!g{JPF28dr1d) zX(sU2jJ9|ZP=(((DQZ74XAks9zOnu2EP-#_!o(4dyzT7xis1Z4w(9WnsVEMQ#MMfVDv&L{+!n@VTQoo0| ztbAJamA`){DB)>{zgKza>vx&KoHf=U zV(b}JT;=PLv&P!Qr9xPat+g5mMN?`>iu*N6>6H~|uc9mK(Ud!*ii^@dTTPxU@ zy65@(7zkHbFW9&a0x0GzqRAw^Is4(i{|`&Km2k>Jm)p^hEz>A7B%~YL|b@V%c9#!Bz{g`Nd#K#b^#(oT$7fZQ(~LyF6B{>~77FoF8&w9ZnOty>5%fSN7*`Iz)RzQcOSyLy;A(|qlZs+xADK9EB^N# zZfH0k#TQ)pU2ZL26yWvHb&$pBJg?jw(zR1?31ZA8U3)2JT@xm(@@xjw2i{HN9Y~B3 zHkTdtR8!>$%#|j_GfqWfG7JANs#vMHp*B9=oRel8h{ovB!NHsQxT(Zyd~==2lp<{4Kx2bf5aK`Gu;m zqk@}Zj@$>PZKWErCZbqBLeJ%B66%JL6Gtrn=C@HYpQ<;9@cUn~5HF}gJry%68$OfQ zRShec_L+{JRYi8mh4`4H-xf}yt?%dgA-Y%wj?XWCU<&QNJ$(KA==C=!mt%kh<^kr5 z{qJ7Cme1uimP{mjaD06DoSNeXgfam2;OHekm0mdX^yKK(;hXO;s#nKI(evY9AH99c z&6QrZGzl-psyf+s@qtdedqS1fOuk)CW&G(9B2&8A*)T}*m{>vH;_>*Rab+mN!RzBA z5#^&`#+TFb@|$9A)$Jd_lWoXJ9}gL<9eVtK*!{Zo9|xzu>i*B)f8QOR{p85dvDs_#*H+J<}-gqPv_22cS6yUyk+=A$v9pT&zsnOY-i@oD>EEQY}!4h zNM1H6S)4Q-n8gNwN zCKY4#BROl8(=BhwTY*}moV%@L4LL7f-=4e=6q|^tWeSOHjiG?TRQe0r{lG6@@k3A-m_m>8ZDHQ{o{n)KYpEdlb8Bpj8K7R)$#ssNFGYl}Y<) zs1fUS=5NPS!TY}u`!1yD#vxSUqvbH5y8^-j?HqnM%So`cJ#ovI* zW^Ae3YM8+XMbAf8nt&TeG=#XkSjz1(-x#5ta)x&(}a=hnq z{O1Zh=sNh&m3YzRc6T7Z^R?}7OUwWqg6ID*tBOUYzUG$XtiHDGt=#sIQZGr18n*C= zGu84lkirVld1Zzi1*50(;gI>HYeMXDor$W+>5R`E$Fcjivo`S-M(WD*N?d%~C68Qr;s=62|oG`uD@ zqViZyJeST7m*tpWy^6_6R&xn)a^T^;#3A_a0HWpfiHbXRi-OfKW??E10qTOP4zn4? z6^*iWcikYs(S9miK&48F`7z=+TAfZV^F_U!QuGW`HcAxle{Z@Zt1fAVkrA&MgM63@TI(Td+y{_UGXNrcj}VhzY$`U(yI zOQFYd4b9h?7)`=Tb;Awn8z`?S#1xYINpxr~*azigR!okjpfK7`my6N%S7B4M=dYY&deyB8W~m8L1!$7P z$eV+a^ma)A{I+H$9?`;F=9bp*hSu6H>bj3+&4AXo}bdw!cjG^j{9`>2PX+JV#9e7)dlrKqrje^H39 zGWi;%RBX&RH~c0{%}}DJIBR9k3Si5QQmekBGEKhRPjXLgJr7HvJ_#c(DDyy*I-z^EcI(Zw?NAdDH8C`KBj+ zWSuYb|0iwRGj(E#41)n75p(;jI2;GkIPZ8h_gsgAAE(pz(rs0m+2b5*=d28D#f5J- zLTQ@fBvd=HAGY+V&TACFs~8rb@Zgtj@5{WK0eH9FG1CT#(6OZ7#xDObUA--Y)*1DTV0<2>^fo;LG(0Y0G!qK8IhQlbfa(4Eb zgc>H$hJ>jI5_gjvbsnjzsSzs6iN))#AjKvmc;Q4MYB^_@5_^h;Ck2v>1}X_2{?cI9 zle9oaqp+AEw zD13s7R7y^2K=EzL0fu&}gJ-i1T# zAa22?Pjt=ajbXd{?M2<~)gXHMtp~5tfT@T|s9xW530;r(%AuH>ernb)M)J;rRt1DW zC#A)@my-C!1Oraai^2OVU0}w=NikmU24n5LH7+I%FC|ScB&)6(7cLHQs%ZZ~SuR3$ zhUzep+(BQ@WJzAr#9}KwUkw+2;4$qr*IB<))6}{_sVM#M=B+|XNNwv9qQMB9P~3e? zgD_E9aHQRgIJOVRMaLC^yom~g?%?TBGb*UV@}J2l26gu-0{?9rxPbhm;C1#`9dMCFZFAf< zVjys&6O$4XlK^8(#9jQ$YW8e?vs%69u$D*zU@8vq+qqhv)hfC=rsqwZok71F76(RI zbVIIDA?Z3REHw`pxL)$jK1MCqpRAbW6LeudDO^rDJA?Yy&n5}(<#?o-lyj-V@6u;B zR3y1as7N`v-poUz(^#p;qD%8-94BAK zhq6AS1mj2B%NU(oaqBDl+y#2&Qa*&&5J2Bcgb>%}|DN6jET*ZsC1DWR*d-MC(q(uB zCxt$gS_f4j{iTZPhm^dnJr$6nXzs

X4uWqbDlm&@~l@P~AUW2zHsNY!&qGK*;+hKt0~R*m~cGM9u#32_3b zF3}O;F2vl59&VgfV-`f|swn3rC}6ny80Z*-4*PgR8uZ5fk9UPQGv<_HiZBv7Te2aM zVHaoU*!%gj{b}%_JYF74D-mES^TDayzPIS5nsvyx(Q=|=CMTH~$m9s4TcPpXI!{E5t9K%DGDUSK_CtvRewtv*>QB9tz`DTx_oV30#)C;Mlwu;fJu1NezE9bU8dpatW)Z77DnImP zUbp}XVci5uszsoTXTrgo!mvpt91>aadJP)fLTauEjL!8RRHUiuQrq)VjPOYm&QEJ}gNe{FZZrGnNadt6AqF2*lD5Ti>JLk^Bb2_p{khAD=Y>l3lDDvlH`Y- zrn(||DY2AA_BW<2atc1Kx+v^*K-lpVL*D1GD}r1?y*O2R1znJf%Bv}ZC?@rxkSTSzld!mUXady_WUWY2q9vs*Us6X{6yF_+e2bH>F+blKH;-A)v3__vtoZ1{&e} zR>fXK=>^i~vAW#^GsWrBeR#!j&r5R)a(u6;(Lt&!;4Ri}<3Tf*pa?XRT~|ZXpCZBm zJ(VmR!P^E)H4RMq?E&;ISrKq9sH7rh2uQq6F0ROWTW z?}?EfSQP`Bee_}K+hq)*s=(l>Ppd`1=s^9N2EJc*C0VnkQ>#_Y0IYh}{Y?RPB{$MR zsZs>3e#DhgOat|hQi#|l6Ed5KqjOp(iw7BA6i1k=Smh<5WF(iem2fhchyz&2?<)5* z#(p1Q0+4y}PDpwXj#J*u^}HI0vPyj;nG%Vt-_eg|Vk)evbtKgTNd}{ZkfGplnaPYQ z@@8h~3z&P39fHqR^q6W~86~c)d(12^qUvE4$`7%kRjQy+%~>(W0r&M|yQd+OkElWQ zi~jqmz)LB}@;~xU>!-r0m114Q!J9{cYXLVsBby#A=W~b#r?U*$2f9M+YY+gCKySad z_pmMd=f&d(=T!Z{qi|GtrtVZQrRF>CK$Anib#Dm#ve3lHBi|MxbU0^0) zabsly?knlaYwFm2)-Asr zKBOYg>PCL!NnoHW`C0Y$kbVX_lwpg5Q&NmVQ96rWtUk%f$Y0(u2LT8vyVx+&NijL; zVi@3XXk<}h6g^N)b>YNCUQcrrdPR_;5-X|)>*(D1_}wDuKJZBWj(86!F<`!&$k&R9 z9H1@h+KyeS^+{8UiO*}QuQ-l%MB8%IKQ5~Zosqf9%IMc&{er=z{qx;3DRj>cA%50a z7S^5cgpwbYV#eDL!4*$Ox_Cp8ZRO$G@WLg1NQv2<(^H{P?H}}tLgChisL=VMm~T_B zYJ(WzLNd`?$z*waD1^VM)ck^O!H5ie%Qz{D-SxmoPg1!cZT;XbJ(?~jcJgstqvtb9 z?Po>#1_2#VMLEDh4v^1VEWus9x{SZPNtGDtu>o6-J#$bY#@Ztf-Z!>(`-+7Ez>G|1;bQ{SF zeDeY4Au99^6>CFEPjPC}(Xn-IMsl%b79Iovh*;jHcXgc^FQE2Uw?Y?(&LLln2eS%K zUnY(u+pf66QLCDKW2tNcFKm{4VP1*?CK+{@lP`mz`bJ2LK{R;%&VJiUvU4{0iz-iB z&5$@4A1%jd5iC2W=eSZvtzvrsQh9*(P|H_b?%h^sY*{)>CU+i}LZ*4?KF3&gA@p~f#b9q!w*YoN|x zhABRY`|R7tl^$8~AzDNj-M6sa4l9uNotrP?xceZw#KNdCENdyj+h2g$AjPV8VF=zW z(AXhTgfF4$+NBQblB-GGMo`tn@0JsZ)9_fJX7T?A?0#qxwd$z-EKh3&915f-aRSNv zuERj9zJ|~jEO}X2HYQfJVbL!}BXLEqt2nZ}R~&B*Jf9ro!L#a4D1bUeJu<$Dqx761 z=^{C2n7v3Xf0>mt42Qo=PsIuS7D7CT(-1U8)4Av>mBDBDaDvUj-}`-Wb}CwJ!#lPC zv=#`oFqWkN624DJA-mtEJ9AsG>0%(3p;D%JZ4xK&&F@-77|GA! z+2N!tP66?W%+#Z#nC{-4fb}NrFxHOkyMylSot>V>wDgceg;H8O<8+mX71@((usUN= z(uV6Cs&iDTWvO7#J8uFbLDip(!e*`?=MDld-b~(1&TZseq${?8AsuHQtRkLau}#$z z+9SVTqR|^X(cIt~XvM`Jh?bm0eg#UX*1VgL8Ioaf((i!DaZ&~J_`}S9l)JQ~bCc5k zLw!xhi>iE)Ik%*~iz@ZztbHwj_KTaGs6={$S2kj)}d9tuPtdE^H=rRv0rt_&iif~wCJvw zk5gcx#s9k$U#jJ?asiOE0gL3j9D=S;RFYj7Ls8GkAgF2!Iv5ywd4%i=A6->~Y4dEh zRau%O!ITDXi#ZB&n4eBu4rY)$YhDhRwoJ0Gs+0UQcn=1cVhHp)JXQKBq+*CDF_%H( zz^d}}T`}RNU8{^)+58FMfO}Zr>v0e*6ywE`c)i4A@qpFod8B5(KEPBN-gPla8}G2v<%;DJF~M~Y-7=Vfwjdau_TocAZF!c zQp|}vq6TtsGA)NPz9uH;Lpi15O6NYrp%N_6N1XGx6+#Q4F=1#-hG&z(F-7V!x($u( z%Jk$ff!CqwkB9e@QA2gqP73WOkxIHdAwj(p)AiiJ%{aQj8#!8;+2GyVh%-o&2`-#$ z&7b}O=7>pqzDyYaO?l&*gBJCD&{a?}eFBE1A7GAxS0M4WLC*vj24lSB1!iv&csP15 zs6lWTN-DQ<2ptvY#Zh&rVA*7#B^Ar;VTJ-y$Wqtr%_PD@gvoA7 z%eqUw|Ib5ynXaBxJ`JZP=ce=wb|&7r|*kKjj^U_NWpZ($ZDF| zDo*9pM(HKhyAsPT*=FxnZG4w%hu@%jJS8F_?8kC~ECsERKqylOj}em~WQjg$+DRWC zo?Z6s5Pe$)LTPK>WW6=mARVC*$`tDaN|b=edKLcI-X;~pHgUxL(Q-T%-@jla-1Br_ z3hOnykoT-SmS{n4rFjDeLX@4a-2*gpdvLJeAE%i@Y}=}v`=_k-zRFU2u~V~397J2v z5!vFy-%O?Cvmvs}H5Xs`1cy=v5AnSAm=1cuk?>Sik{21GZKIzGC1eWmC&R$PhMlT# zeR5aKEJX26&k@4s{g0<_^>dp?XB{PwA$t*3Eq2-wAJQn`qk))9Q_xtM!|tCH^D3w^ zat98Hw}qV4IestpTt+`CdSRe7=}+J9`@Rq1pA$ztB;r#va%U4gIKkGky@bHJneVs^|Lo}0K0rUSp`d-5M;rUNyWLdK{ zWl3yn7JxBfL*9b@(%wqj_Q>f_wi2S+f*{16u`@gz7#)v_88j(W?k3i_=HZmDT4EeJHgv$nB=` zttrzSFHcuxD>bB3ThYU2eRXEMR$nn}cNIl>ylUPsLux#hlY8{8My@@nwnhwaLHnqbHMnrB!Ai+h&*B#H z(UZX4%!5+PKJ417FHz1aWHOe&9v7~!9<>cUaIaaPzSbMSs>{?X(DYuCqL@DUH%d)7 zgI|l1IO^3Z;(%_Pgc|l-Pdhp;LSKj}6`IqL=0EtpCC!=T;~~Bi@@KNkSgEgJseTD- zb-b-&;x}hL#%Du%Rg)K%dKOPI?Z%;LGxr=_R48J-s@h69={sHxAQPj|VvyYmT{fhB zQQ%_r+8dC->Jb=!655&T{W+jOdU9y&1O&9;g^_}S`xefPVkTVD7AbC% zq_5uM4xyblA)^&D;OHjjyx~RKs=a!J973&_39((sA($|LZd^Y8N{VSDoo$O=5|;CZ zMQGHCcH8^HY&Kt27PGqxETLDmW=Vr}?G+VF_B=$afrV~|Ebg3)Q}s;pa;tTzSbbNd zU&^nT?AwG47T<=DY06^7+dao*XuSQMQ%lXDu0vlZ$IxaI1CN^q}$NM$4J zPn^XLZFq560+iAqo@3yI<0ExqRIWOeLk118P=mjoKN`PIO=slerNgtxD6a{G529r2 zCf<&GoQ?ZM#mvrhd@*Ho_Da-wBI>+S1uZ|&1?V5V$4>M9<&X?Sj^snPA`l@Xal#@A z(B3FIfG!nXSJ$jnbwWHV2Xc+BkXIcX!tVAOhbI{5O0@5kRi8iHX}sm#mFG|ZAE2>v zdkBMHM>ny4ryf7Ru%I3t;u=d2C@T&W+13SvQbkuiQh(LjN1D};fNUX`Ki0mM3K)p1 zP(U0-uqM2d>m`E5{rVjQGaH0C&+&9vpv9{p8_9egn@wleFom+o>G5GL9Ar^1`*^T_SL;uD8-PyT)$NUZ7cX#%7 z_dZKLeD)FkTY^w0y8rBt`QP4G$uS7D`@8q=-`f*QaeuF~bLXqudv|ZQKHL2FyZ=v} zq}%Ewy#fw7PxtXzP>Z{$~Gg&i~E%zd8Rm z=l>kArPv4eDk@H+h z1!V22T8TsY`K*{cc=414Bf&@85rp*t8DLwciiAhqDaP=|H>aa_#qpq8;}FL>ppO}s zhpGaE`K5xz*$lmU>HCApS*s=gMm-SLQGp3mEgh%;OG`BYRZM{Z+Uk)-j=(m!#D`Z&<)=pJ_!&ITR>4wUr#D?X8Qu)_s!^ksYiQw89|1TC z(4dN8+w0hQJDwh9=@+P*P4hhYvbGY|X<5c2yo+umnUo%0pK@vG)R6d62&|&QZ!K*e z#KBU)t#Ch{Zd+I6N@Mjgv%vV4VJ^{U7Bofenz%#H;DeJQ(p}pgo3Ctjv1H}4!wP_? zJ_W1Um!X~+M0k?PQmmA=lkpW2t40bk5R*gA8WY8-vG6(-Bg_g2_a}J#e!5(Yw!cbqTyl>s0<-N@ zgHh23^kkzP_SyNxjW22pBt3Chk>xGEr`FiU4G+gIS3AP~%qq0qPVJRD0)vy3TqABkn-_79GG)&X9o(?VNo5;%b85OnZ8;c zfk>HNgiW{8JK<*E4Tj3hGTQWRxarf0d_k#-`3ZO~WBlU)5a-i{7iicZrH}EM13sl1 z<*=eMgfs0PuW>67>_$%UyXo-Ec#RV?fFR)7F#tB(i|4Oi-+J-0_`e6QAO0X|tO}1= zlIC(Y!w}t&Dm#Vt;7B4s;6DH*4c)S+|9#C*;pU?@G+upzaW}AX*kpzwiA|VzOe^k8 zDK?DQ?NN%;6$NJ<%ZtUWe^*EZ^(|;O;D90BDgIIKHhIxnuf(CH)qDSdvkX9t#+jWE z(Xe7;I4e(YeF|(e`<3rMy&@#zC9qZ{=jC0jPqKKOnp*T>R7`_( zDc00p=!{EN2EqO0o=h@q!k}O_!cZzeaoIVXRz)azaDxQLrMSg{ z6zKJO@z2*kJbwmJ8j-zd4xuaSm3xe#MAdIM&mUVwopg|;TgDB59Rz}t>xkG!Pr(;l z!<$JAK>hh~IT1TaFg2>1{O#5AXSR0NCkPEmZL(Rxrfhq`MW`eQLf3OpOjM(r$SBoE zCa>gDATldDZ#!*)*U5>AW35A&ds;T8x09(K)bM$TdqsU1BMp@em>`fcG~#1lFe79e zg0zd%K`ioI95lBE!y%)PmJGuljWJ3=HWY2~$n6KI0fU!oQ%^nJeFq#_BWDR3RSPNL z&}RY&;3Q5{7>#Ri;B3!)s3U_Svik~w|k_3^(jbe7>1IYlM0z02H)aUp!UBUz_6fh@%+JXkD} zsjQP4a&h;l-+Fc2uvnll3E$tvD?9O;u%5g!)t$1vChMQ^nGld>D18vm z>VQV5O`hQamL`E74JL5%7srPMzRb(K3-a8cbD&&+D^jba-RTuGZkRnG;Ic_Xurkah z?xO&~$>y>Kjm)yyFmV)W@=D~`BIXa~9Wvc+wO6s9?b{r>xoISAAl!jV3l_^1?sYt*5N`m2AmBepTi4=*= z^FOVcW*~v4J*(~A*fi2KHg*Vz}_Azm>lB<0y%3 zP40E5P9s24jWSFatpE{(a)P1C4O$76HP8eO`%NHP^;SYPYgq%*U2tW72ZY4}VG~_t zB-?fqnT-p?S^Tk5LHYvVtx2b-iJ{18;H9hwDz&H{Fv_7t{cb1Uohf5W8MT%|cvMOH zviP^m2c8+Axoq|iP`7PwQ)O^hZkvV=^%Auils1kt{dw|!GCh^~gtn9C)|is?$#Q}! zy=Q~tqMLk8;n==OF&bUCEcu$|E^0}bXyISXiw^^cELV9@)sT|x$g0^*KJ4FZO9I&6 zzMH2(J+xd~#*y%gs{~fdZql8fym_4|7%S%_u&PWT^=qV zVJLK9Xka8W8~rVvgp?R|<5lx`E8&+{3Qy1IpweHmgI~J6FLMiJ)faEv&Hx>qFTc&- zc+*e*3%`H*`N!8!e|-Avaf|6B8={2X)hSG<*&P-dV#}n5PovVK&;xiZ4|#|0Bw{UP zHX^rVKZ3#zB;gTzEOWR)u5H3$#C-~;?SO!nT{MKYk5L}{6U7v}0?0R*t%;uSQhU8w z3LB?4V0x~H0$FwQQ0aofE&M^jP&?IZTrRS-?b^sG_Lg?ic-%eR|{>Whlve}lat-5Z7lljNXkcX%PjtfiMj;O2 zK}vQB38vv_%eK<39Je=>6qBL(UX#YgU%LY_nlwU+Pf_Z%cP7#V*f{TM< zLux++yc+C5r`^yjLe3C_Q}UtEeO%NXQ0G2xxo|THUAPZf%Afe~Or8n&70(6a%@*@j z3`9s_h7Utspg?TXZMgwEx$x1c#HIb{T?SQi&r?Q9SC+#xlDI2?)PP~as^@r(4!@`H z*m_dHL#}*&93~6Uv=}M+46^XZgyibhY8|1W{0oE0S$1+@2&1P^f;c+CE$UKRfW(DN zaseXEEs%SO%hL_!Rx})JdP_T;nc2aC zfKP2~SL!1}w5Vaq&6t7vUjqk_5VL5nZsswK^NR+ph-Uza#Q9M(hm+jigpLGXvj^>K zByK~?;a+EWltL&yi-O?HaIJ-%D~CXOKprgJM}5-^FDBXXTi=R*<%a;45P>5g(YbiVWd0K5o==)<`Ny;-R{o&VW60w+;Yj$ zSP;45l-o!ug4`rCzGdk%YnZU+!4yknpNm^Azh=8wx&6^YgMYpL93#T96dFrW6*YxdR5Tg z)%$AsM^f7+Z&WJVjI=s6$#cj6jS{b=ucQhcjRTGI!C^rRN515|EhgZ|S*V9%GG!wN z0Q%yEQP39qb1NK}_6`tKcRY(5%D>PyiAtsXy&Oa0zjkVi?yzmV8sRJdxxfkC;f>iu z00UcCh%r)egk`5C*vb|%-pb}*t<=yOsD(&84u_%+z*BFZKfiw1)*rQbs2QEO#F(Hf)?~m=RJQ8~zL5z? zc*u<~^-lWdfByOS&%@V${^8F*{rT1Zrbe5GM62J$++2|2tU{DR=wh5a&p!^=B22xL zyHwUWelPwvqc#mzSPk+Y^U_%f6F`TrgpQx%M#}+=Q?#7m*f462kebC@_xEy(pK|b*eEEl` zYv2-if`Cw|$SkDe2Pubq{ND5LcOGJ}NxUCqXWvn%k#B!jz; z_sXwT3=v*Lz+?ekTgfk0 zMG^^ohzbc&z?#e<*{MX;3r zb=yJrcCY>#ctZ4{Z!LmlNJ%@*Z}uYhdGZ{ivdDcL02)AFk`2bQcZ13DxR{qmLUI?f ztw7k(bRO0ZhOcp<3uLh3%1dRmmBI0!_6$nW1kHxGn?%zD>RBLFvAFWetla3R9s|3s zfj^4?qrRN!KcT0%&Hu-a027M#(ma0zB}`SYo~YymuVJWlL1O?1GOh(vI_R%$3XKiV zbLx{;-9kwyB(I6QVG=*H5Hv5HC>)mz_q`%0FX{FA5|63TcuDt%&XffblcVDFP1k>!3AtUdZmN&$Ack{a@r}XE^>!LzWeie`flDDhz z&6ktrR)&p&81VlvyayX5hW6N&6qfel&z36#A2bMp5DJKJx4q)&(v9I7j8G6ak@-CN zI}n0Y|HC3-(w#PCBAyp&>qG%aR)Zl$SEWXdWku#gfJaiZ?qWYfWEN?_Yd8b4f|!BR zqheev=*3)4AgukE4XUbDKN7Js4(HSN#RQn+@$>{jdDv7gPtkX9j@cRn`qr5rFXlzT zcZttzWb}$A?hiMZH;KZ^uc zPTsbu1I!^A;>*^oI)rw9aN^s*tenYdNx(X0evF>tG$d=vxZ=&ube}b23?3-5Le_!`HpvD0^^{RoB1f{ldi*6R;sYszSJpalM!vw6K!Q`(%e8Zknr?fZSP zKxErv)9BaJYw0jbqGF{6V$fodk&P|=W=lv@OS)B=KF?%~I28COXjN;iXh!=Dbrv5M^7)3iz0`l38hxm-Z~91XC8-M`HNn5^h5gJ8 zD8)RpY1quvIy2#DiSsCSA7tS@_4xqINXig&yTVA66jKtgi4p-zE+RNZXC`ir@J5d- zds{o6fNRwV(M^Y zV^G2%Iva7?@NtajMkicxi1sXG`B%b=gE%`Uj2^~ zjZXnd#ihN1fP<+x5a0i&SiVhFV8bSmAG~_~;KkD@?17-rS&6T;C*{#}jt{r9v*BQ} zD38|Z=+WOEJp2B6te@e(2a~tcwcx#cvI3YGDE%b_x`hU3%x)fxPX}ier14`mbv(Bs z)0Wt4ryu5wA=H8dLuU1);tpEWky+$ntkqOy3V@kA-RYoXlLh8R;8VQx3xpLASMm$h zS#M=*LlE?W$!^2?(5}?#u~ujEr3=5dkEtn%%DLlC#5P3 zzd&%rvK7OU4h+!Qk~QTbark2C^UmT~y)5SD}M+8|lB3Hl>5p;l4)a zCCQ?pLMNnyF%k~lk9>K?HwC(q+_92C*9RMCB=zF!bx<>P5hLdpSjyb(%jv<3~4fih>3fO zIPTYGIiFeqQMsEcn%(eL>Ix`78Y`jC9c!7Mm+^{?&R0%)bQ`=`au|7-DPITwuDM2C z2e~g#Vx<1_-x--hFz@AwJaEI5EiQu*PD%;_{CjSZ>Sb_!&W?1s_DBC+)F_F~S7{BkLW0Y25{I+2;BSrh>4J0ZO_qV7-HZafQUS3huyVC@j3U z-G39J{Km>^WB<9a|J>MrZvOjS*?)44@B*DHBLSf6?LT+#?(W@n?LQ$U;Ku&*BiVmG zVegbOK~^%JzAcX$27sPVPeh$cI{=A_i!+r!3(~W{E05l>*U%E~YLsARz#hC?*&|dp zw}seHEgrmh+D;z5e9}&4LUP%b@)C}S2W!Lh!M9|(W))X(ErZgVwG;gamY!EL=V$_~ zMAzjwutRVH(=OO+8Hrfabgf0KLTNT4pW!t%WuY5})zG&7kTw9e*@U`q=(*X}9w_yy z06)^=74)0M`WB8z( z!hrA#hV}z=E=u#pa=VkzF5&>_1zL8toR2H`6D}{LP`=ZFjm8_mi%*|zSlM+ZZcYRp zEiK2@RQZ6@t>JDbc>y?kA+LbqzBF=pOEC-?%rr2BiG*}k_6zoQu`|;tyXD?9hUo_%WoBTr#+l(UPUiX%nic{BQgcY6IfR3*923w+LE;%8c+3ct|&&3*o&F z*SV~K8=AO`?x)MeX#1-)9F_;ET|Q09iKi3^hG1hraKcN*3u3mZ{$^G0qODM1#h)i7 z?5IZt{I3kK_yV$$RbZSNi?AqtT9Is%#k;{mnpGY`6o|nvh^TV1EX;GIAXkdoML}@5K=_9?aiTZd(wis@R@@C-^M7G4g}%-JM>Sh0DV*Tz7Za z;nF+CGe~2qxbt!G@yqER`)rZVQSTMzqPl=W$SG z91%eEa3XulBi*2#KAL@qC3t_0=Z;6BaB^FtvD;Ap%HovvXpjdOsf@@D9(1OYx9BpW zLNpS7sAhk5=IXaK2(8%GRfG9rwbP7{_goQ<&#(V(AVmK8h*`J>AL>1mqE>wcv|(Zq zH;D~`hmgW$Tek$0t=0;BhZ#J!Y`Htkl6w<**Hjx#8B=1Uiz9_4Jv_{rTy%RVt6bCZ(nM~+1WeSi?Ay3r@QUSVlI+W5uKFCX_Q8S@QZU$4s>!N*{| z>!4m3BBPg&U%fW-hozFMq_XIm>rCfbFF?w&UlNG5EanEZ>Sosp9GxD~y27M)B3}M9 z1o6)vnJ4+l8+xox)ab!FCoX>yxUyXwqZ9v)(BM}Tnfqk@Q)RpM5!@EG6xhpMBz5a` z?~x(%Pe^-1R&?xy}n*v85sfPX_2#6SC=3oT|*877(@`8egCTe)AL6^|M)l=i2d;vaoch{Kj($< z=Txz+=Y=dXx6DgJ0mEFLV5rwtXQ#9PwJ>!VYxZy8LcOci5IO zI+D7c$nslPExHCvT92NDs)lnQLcWU0NinC`azK}i!r00~B&!pxE3~@n=+ebBbL>)a zVGv+n1p<`PB?H*Oq65ejlf#H!lCv)xv9%ZsTyRa&PD)|0c+DKLoplt*wBdnJ6MfAw;@StF&A`DI2ND7VuVRJ;bCKNSdjrF9qI8$tp>3+;DQx;2yxDkd682Ou*fAj5Tqgu&sFDtz$W!?qsiaj|$e9a5d6>3B>J zvsH)Hf4YFM0@HciHWUjMXEV&t3dwYp zoHlQ=v;_fF7`{Zn-6uInCKjOZ zc3Zawv+~xmDg?JUFNYUfDq1_?eigtUUcY`Jt7?efKYk4{*{JUtQZJuw@YRn5v>3CGAB($=4 zUD!|TTnf_cNlhGZ4XU`ov5GQb=Yi?DUGzt|@6Pt2veOmn&=FW6$X;dN0AhrPE!vP? zg0p1b$};G2EN)DVZl8Uyuj>_2hEvR?D_&%4!vFakN@w(AAf%I z_)-7S)0h2c4}N<5stX(JKVpv;6bl3lH`RyiJng8X7M(}mmiTYKKRY`bh;I6rT4Ex$ zqmhWWYnM%?D~6P#D))SNwK@TW zS}E&|$~%Y_a#s~(E&VOY!Xm4R@u-b2MGOUs=^wzCo`sBm0u?6z`WL)_|Lb4Uf&>$q zh>rl~U;m;wqj(-DJTnYc%R`8rSwSjm95Tm-az_<#Ou9WWNBvPf4u&-JF~c`(SZ@VN z7JB1Rr;bbrV&EzD3~@43sjm?C&Ji0R)9ZX#Z47Yj$5n)U9hN1E2I)0X(VZEiV0Pi{ z=NPzSniCMff=)C+`x5`%be*@;;QJ}JH}OYfy_mYwm=8+9V4qDFPa$(EJU!DbptzIZi<+1!!5Y7bd31T;NVhCLG#Apl^qIvqbfy7(B%2)#Fd5 zhZv`WqW&6Lm9X$zr7#@4O$hkO5W-8k2^rdA)XNX`$m&&8kt;h8XXUt*ewOQJd>T}XmN8ZQRW!37=k=M?P=qt1 zw+gp3s;u}T8uirXGG2EXk1N9hw{hbJ|7+gD%_wSb-%ah78~Bj9w)wii@_IU;km;zX zBWF@lH2JDHW8+E*Rt1Gn!efZWFTJFCuHq1q%bNZmN$pN+qcdo%Gth*-zS6*E=$1>} zucRUfj#(22+E)YRjmpWeuRn)OtGN@N@{$PB{Xwtg5+wDZ@J?U^o}9kgrh~k-(Lk)w zK*;!!*RO&Ii6)>4V`gfg(ip1Ac=3nfa|d5h{VetZw{n^F zc2uj+%J@Dp;DU~4tX0H&4BGcg@Dm@U=LC%ue-~Z(6FRyG=XsYHI#5vX&CYb`?|!Nu zeSmU!Pn;inDM{qoJrMvZ^0L9j#*64Iak9Ha&|#bl>RN zrw8H@iY}_jn<&w$tNuDdhUx2Ir)RrpQwn(FyA@dT$XO8KavF5Q!&$MipI`o{6{N(^ zqQp_-t*d))>;MXnnWCY%lELF^Rb&!a8~X*^?nAg*bKK>g3+}74$zt$9`f{!B%^5h0 zRWucs$H?y)3_9neq%r3T393LgaboQHrxE3%v7cfIL?7Pn6WX*2YVaYJ9iqHx8*%(_ zFdnb;W_5TUB`s6$yJC%CVtonQ=|f`3%Y@)Ec!V0-uZR{Em)um%Tj5~B)yi$7){OQG zEJGJD@!-{K=jb;ng`B;i5?U?<2a&GWut5X_`}LiA>N+6l4CE`SRuYDBIvBqv;_w!e zEu>k%r`X98(SKkWr50r$d+5kt;5wa#j?!c>E(aJu4y(gntHOsMR_qg_qaU3?g+CRP zj^&u>X9KMc42&L~r8K;Y&ba~LoT8Y0s0Isp_fwlzpphh36&_*L$*vhjj9!spz=z_) z%%N92+&MRbx_}6mQm;yc576~Dg8l`>Q(9>J)6d8X*d0(+>f)KeA*HdPuN@G<=#W*@ zOXK0X{DD)>Jc144a~^NZR?W2R6yrrz*I!l(uVysJl4~1d(v%Q@4j2V8e657yG*w8| z7+nbf^d$_4SQ=EC>_!`|jb{LPu-o;7D0M6b_Ktrj(dOYtQ&;1<9b?pCN!q9h0^;-w z=viECA)SC=wM>YjDOsCNAY3sUI!rMco;1(I66-L<6|$5H1_GY*lO%(cdq=r(`sz=C zhcqnW7%@`(Lz-8)T+baOX>r`8HWNEP9$VO z%;v9$5hXpv5+vEdp`^wcQ^qLCnYZhO5X&nx8(@r{BaOHe7ML50l0sLC{lsa?2j<5@ z(f5!jMyxn27{Wy%hlA+Vg|*TbDTE+yQ)Y;;X#;vmuOW6)*V4U*Iao&l*4;V=n2}Le zu$$Ub+=i!C&O61M?7ZNiUMDd&VNzP`)2y8dgWl!q{i$?0Fp|mp;_P%fANo3-_opDW zpqviC1GgTT4!m9j6JW4jPz{bHwc*o$->cv)=dpB8+KC!iK;$C&oi;0jVo4sqXuu!tUz0S^;s>5#bH73jk;`j#S zx3BRp-AKk=Gm`o|{W|-$n|=EN+kNxq^vk@Pe+$2{A^iR(wF7wZ0HhsIRlvGsHe^C_ z&Xx_p_m2lN(12Mak(QH1+Qom0x5eCfK1fI7=>W?o%i}}8SYQtdiG%`$@HJjOUe=T@ zm*p^CiZ`rKsq@4|G0)10!d=&qK?OO;RN7XgvJ~@4qr(o)G0;k0POMhQ6mnNko(yKC z>O`$WN64_5>Ao|bo)%caYBqtD-l&X>--n2iVGRN@PPL)$?;R>5GdCj#IsT zcRL>hPkUQ@PS{(7Pa8D-FfiMY7wplx7&;m zck|y5@u&^)i4cyyX~Z=Qai2v`X~3@-qAxra z1n+)7IAVX4%h^zl67s&ozen!`pD5B^ zth1jl-Pf=f7u44r<2gqAc=Y(k$FCn>wi~qd!7QZ1;sfG;JdSqt{bPGNniMH}eV|vQ zD@ind=cn_Mrf4sJuXgEL!BMS}Ho4VF#<8XNsU|Rd?^W>Gd z3yr~sXGpC~Osr%(87;?SxGa%R5IO3dPM6?ziyCZff!4yz)aUc$3BD7Zwz&XK$!RgE z0O+9|A9iLoDwBpi!vPUnU z=E?<}GVkljIs1wIq%y^N#e6ofMnOK{U6JRF(EG9$clV(bC<64ID zwTuFcGBwJ~o{0QZR4DN_jFe#dK+#gd$JtvZ>!^j6ic8aG-*b}7H-2u{?dL{$g??2h zw?Jwmcfbu(90jvxuCf{L-gF+oo5+6jv2y5KIvdhWW<+Y16{%rL(@SMX`Un}4>Q?wB z@|pS>lQtfgAJOAdFu@EhW&A_<6LP%#90$tB$z4?1Ni~H+Zw_I9O83C;IA~_x3L9_A zPs5v1EPhqYNy3}|p1mk7)vcG%pn>w%K~;_Zwm6-&*}bjwfA{ae z{@)XSZuEa2P5&pOi{D@XDAmIz0@NB2pd`|o>+fpTaT5dT26STz-@mecj18k!M`nd- zFglkMf=Xa$ha?sI6JsIR9YIxP8pSm*HZWKFqFY@}{bImeM*l)lW$AR1JJ8d=Nc?4= zrpiSr*Kll@$96oG9MoD_%G29dp)d}3G!?Ej6UV!&NE55>emWc+y)P!iR2BCk(c^WW zHH)n0mkLB2Jq9;04g?5YKu=U zuCGg=nFdT&btbqX*a(gY&Fp zA2nfMHfr0CueN=ml8qfh`mjGp9K0)Qa2>2&w+45W5wphtq|e;hWde!ugJzIO@9cGA zh^;FnHH;vpnA)coKWvRyU{?v+qY}YADn_A5d48-fi##NCNEn$D?JIg>tcdruiTSmO z`Sr(*`GxLVeL@CC5P^XhUJX=fXmAq_lP1O6MA zi&g&eHV7+T5qE+wBe!clLQyFgI7C%x^SQ6YUm#LN0CnZ?4~w`E$u<&!oHNcc8ubGG zu;a8QqL?K^A;8r^#a8528<;pkXzHpiz#8wB7lJ^bGQE}hVWn)(J0~oZiT;DU%{t9= zU%+xrMg6%CqDkPZ~NyV;GOS^Al|JtHhZ6Y&$s*yx&rJUzD zeeID%RDbqhOi&T-jSjLA``yKu3U1^)idJx*9sF|9?R}YN-*&t3xA-&v_RU43fFhq% zB$1C73#3Uf1r@ZT|E|PaGOxUsQ?3>33kB%&;)pa04T8OF;)5g(Lv0$1VdMY1@&Em! z`~U9V{_3mF-kp1Y`O8;dZ43Z6_J2+7|HgyrebfVZh5g^X+q>HT_x|49J7E8J=l1Q5 z{ohBk|AW=LN$Wp^zp?#W$Mz3Uwua?jYy~%Ve>Y+GhuHqLt^U*?E@ks4Eu-2AHK*APnLqwfxgO#PSbmWx+L!&Kb8EEAjBf;OSk>I-Y|Et&xeq5T}SQY*utqPII zejhf4O3d6?6mBdEHx`Azszo7VE5CCaLeFrp-oCHSIB*RkKo#P)k0*bBfT`X3)!Fgk zbX>V_;FODv+o4eA0QNy3qfhoXq7ANjPu!=xZ{{uBeqHo!RQ#-PIBlzyol|ot$`XZR z+qQOW+qP}nwr$(ClO62Xwr$%t=O^5%sd<{Gsp0C?U&m(2(mdjvX4a%Ln@>s8$|LM+ zEQrT9cFi~4z=RG+m(@;O3^%RI4?Dx6F|2HPv<_$YBC}^r?ajfa_50pv_@RMmQ}^5( z7)xSjE$;-$T%7C&5|9v(*#pLySBj(XVCG`#)3FzWGC6Zbt2L1P^#HZIODR7Q_o)JF z2o)$BD91XSjgEAXLAI*o=5+DaVNjuhO%$xzTS5D$3;CV_Lpm&|C4^b0Y@BWklgQf% z>*NATd<;QkNt0D&Yc!U6ZfBjv);iT|wQ+nLnj#yxm>+ONnfa?v0Qcy)SvOlvN3|l# zVNZa=QnTFoSF|k%PcU!fBBZYYW?DYeQ)@C{LC6|tv^q}J1lV=gy{^_;j=p)^tIc{4 zZCYQTMSDrDnW%n%YZ>P6zn~ebte$(U?f5_!pdO#<`ivmWw(`1}OzT^9MI?wiNIs{+ zT93^jPsGN_@+OlGW^{u1VsTQbVyaqsQBFR&nm}8X)TSaFU@4p8LZE7~ET}AoGpRzd zEoN5{To_t#Oy&<{T(6d!faW~Qx@srgY*?dUJKS?AHb@c`&hNJ1aQ8!}`=G~&|1&I- zAtw(}Eb%(d-e3OlR+Xmrg|h9$ZczEwJ|{&Q9WzMIZ!)Znt{!5K1!HnYK=<~ZWO`DD zJ#wCiPw)>UCGNMsGxl)sph9h^u(j3N>Ko=Xx6&~a|C_qxVsvp~Z(BYg<&CX%wbZ)4 zEE;D3VZlv_`tW5i3vBZVGi3>l5&s+-u?LYHGyQ2`hic?hLp5B$_3P3^B!rz~byrBh zGi3RYu+x68@DA%9tc2pjG8YLQR^)wKv_4-;N}eWnA<1YPDYaYO=CxNnwQto?y}NL1 zJWU<213*1Fx(zfIlfKIkXL8fUmf7rTcPfYcwrC+baNCAJg!>Vap?9IT6L22V;@_68 zgz*EVBp0L>=wIY4(q*1dup}qvc`f;fxg&T5xZ|dP{WpIpSR$Ls7{k&_+IFgozvd0|8#rHLEO?_=OBYP`-pmkWgK)n@WdF z&lhX&7O?G$7X9#L?8zzN?B@CR(DWZU$+{jt)I@~R4tiG>hr4|qoR)|JYD+jFFTg1P z8fh#mQ))M)=3|3AlPODcOj|_iBZE~rw@~xu(c^_vQ#|_C1AT*@chWE$o&*eBvE96ssbY($J8pzBTk0qF zPhQN=Ov;y^tF6%I4VPcXUpF_qrZU!VK9_I48?Xln1(mP1p<)7DgF(RZ$%r>u=lL&zt%;AK$DXnIoYK?~ds;Ut>!9I#0G-)p?izha8DTJR#ZEdCFu_ zw>9k^uruZe{@crMW}P>WEI&;Vxx*E}(_)`>yVcJjSSY+o00UlY42({nWm&>-$XU6& zKvuO8h<8hFY6&9>(CNi(Dd17O=5r5@(D7vMCCpVqZ#}GS$yi=q^EUe)#nJRjV;e$@ zNO{!}HV&tdcX0zeXpke&;DMlHbi@~AzlmL^_GBt_)TI_6^vDSlGda$}_-kpb1Q=H= z1^n_+WeBXlLhQY^GmO)!^)zJ3{IVcJ=pX9vg;Pibm_W}XQu5+vM-&* z`YcEWlnp0b0nn6lNniPKWTBrPyC7ogO=JRc7vNRpU72ZRGB7nPXvSO#1#pN7%PaaP zT(bu?iy=9+s^4C_2`O^W1FAcs9p^O1wy%CFyKVCdP}IKA=j4^gU12uP5}$5&@a%oz z<&|mn(ODO2Igr)u_o1gX15tPMUdy~UAMOzKpg}skuGw$_g06k1WZ~vRbB(uY&n1ej zGlI3z9RpI+yo9Hg=?eVIdbiNhA62sWoQ#II%&5T0i6OP&@If=s828tTn_z4$ol6e` zGjW_A)$|S6XYJ|9h7-6a zHEtIxM`LE5kc=tL5xdVuEJT0n%=6_Zhw_CEsYjF8B+D>I3-U}8Kpn)FGDSv}ZXi28 z&Xfr?Mt~{NXK#q);i)RB<-FK?daKJq^^ov~VJUiNT&~#s)t!#-erk*v9G(`O(9`pb z-*T{WB3~B}Y{$wSsDNAgZ)bn^!S=NCq+t^_bn7!>tKU{ zLVq=nS_;*+M6gSgtYSG@kZ(%L)%FRE4WmJk?gFei!US{UUmiK#@lbjC!^He=qL96O zG{}~J71?I`)F-YbE+y9${=>!8mC{Ral4F z>(Rkej}E@j>_BpNp#`0DuGFO(qyB-3ZY3qdoRxz9@xS^A)-`h3wM5$Y>*bPnPq!Kb zG8}SXNAC9Qw2pa?i|+f*;K_pm7ozHvc5|Mk;<-o^2C~eA8vj0eu-|;ie=))VZfFZM zFKCOixmql(PPHlDelYMqRo(;xSaBgZFh%p*)C8948~@3T0QIY5(kp!3Z6QMJq!UVr`RF zaQ}DjzstnTC#qxED?3v@tP>l{4XZ32M0V%!?xb`NVAXcau&dZw5bR?0>6PPk7T4FR ziAfeD0mKD37XBt7Ndh{(0|h22yTK?1WHt|`M&onAW9Y0cds0eop9?+=%WgP*VRn&botKa4p#NhlF zl+*;L#+)T52U7Wmn}A(e@d+ro@bR?hvyeqX<2$X|(~lm9a>Q9DaqqM`EI&0;#jbY8 zu>~}^A0|T68A532K<2rxglM}7GcE?gea$Ge-j%S?sIMV9tgKw<2|oEU`VW*;Pjf;S z9hh$U%}N%vL$*0~OJO}|DbwzKK^W(wH8 zj-T8FB#jgQVDtjW0YsIdO(f5^{-_bh=@hp#T7EMxRvH1XilH(S4bwR;KOwz_lLC4n zTZ980^H!DEbWIral)2fl(3$y!^o2^_8fT|Mzwyv~z{pmPVu5uu*sg zr3VRfdahQ%CwqlfV>kJY3i)m+3p=Qu)@G@rZtU6XUN=U!8rO4wdnTFKX&iN#bJUP< zGTljGf1&@7ZOC!R-R!O&o-_lqgjMsZB%exF;p*~_wyh{%v%Pat#Vw@Soev%pZRJM$ zo(>rp#3M|201HVc-->%@INK$*@wL&(ns*e-=J)~fV7+D7R!#EofoUwn>GpRdwD>~3ethKV<2IiP)L$(mqO|Jiw_ zh5$@vq>J6tiKjXHMQwj>mVyp*08UP?amGir2Dp0#_2@krzdpTY6{UWF%TT2gqY-#X z{7{No?`O@G%4|>RDJ#xUB@;9AT`BCQM4|d(@HI^m@=p3N4<&F|Yn7Wt5Plt|D*B8# z3(3tmksTEM+5mw;+ZP8pBNhEE;K(iPj^Zj~3#D$NkR$Ms^j1)wXc0CsPjddJ#; z0bWMT&gN5oBdD|Wu)4&lu;6`@Um*uYzp+vCNyiD#dw19MSxlvnw`i)ebHlDWB9_2AU%Y=N)$8dd#ToG z!-!+Ew15Cu()ih;wzg{NJSTnCU%UIe5;Jd|&LhkP41hSzRM~7_C9u+VQw|(vWr8X@k4fN!Ov>}Y*PmdOu`kfw z<~J=Zz@v6^x56#VjeBi-)Zt3On3)D$Np0>5EGfFS$5z8DQ^){ajdqiKcKReb{i}^J zbUg%A^&2WnG96xyK>(cblgut%V~7&ylX}1qzPnx2AJ-$T5Yow^{2U=}(oU`mhC_6i zN1{;H_0l;m>PO}tt<-}84wC$wxq6Ys)HA&dqBtkt zSiJ$)RW;+EZq-B1iHlVob$5E7+nLn}6}n~UI+CsiN(f5WQ*1FU#nsP%Kb9#q#V~?| zF3o6(clypMYl6EKXh((>%|3#P8gRq9mIoK?{5h56K^;ejdIMWAC#(z{(#3Bq-*2!} z;h{OJ0d|)i^?Ji+YcGzi+Id45V-Fs<@p?|-JHh~(R8AlcCBci5oyfJiOxL=aZWZbk zJ~S32_TB7r@*p;YQiqhW7cqNVk{Uq-(6?!;d>|B2KEpF zonax77X-0WOibFaQhD3Z;2+Af)(-y^??yCnaZP~ME2IqhkVsPO$yIQ|_Fbv2B)pkh zS->h_a%=Wsxcq18o4fE&W)&7!7UJ$n0aQuL2rHBO9{c_&{JOe&^5#&Qkie77y-MEd z=)M5o4y6b^7XR2FGL24)Fc?b=4WfsVdnJ_fv!bTG{}AUzs|Rh4rSUG8ty64qvSlCd zZ#cnS8B*0K6fFC%Novus!NJL{@>Sku6H#KuO);v!TVbzlAuRY$07>i0b4_hqC>OOn z2icbfq%V%2FLEMqowSO@w!4~FYf~&cOozHBoXA!$J~=O{;VDnU55CS}-a!bQFd89- zBY77UtuA9J8W{~N8K6fD@2d9jg$2e{!A(@cgK1m<){_x#$#taA1!@oIW`nMwy_?w{Ee|#Xu%cLPHk@2_F2`GidEpK$IOquE$ZZBEpbXK0jc&SRV0Kgs z{7c3(jS|>7?@Zp3ZOv!X1?Z*2a#_Gwa5d(}Czca)b!8x2QX{*7?FP&6$;nAxU;s<7 zr)?wlSPLr>!D@xh@Bq9cmvld1*Y5d3J}_4sJkYVHkZ%VDDi_nR*|P-3Fskz>qdQ+$ zf^I=v=%5`yA8y}dorbnkUJhGW;=|79cAJ$M9tyrFbL9JW*VbI}iSL{~7ZG<*^ew9Y zPJZS;_CFM|2ak6K?(evWv;J9CeU5_L=VaFe1g=qPq$!aEv>~)m8q_<%vOzxwpSmHE zQxm3!8v&~37^}0$_VARD*r)vB^4xh(veRLr?c7p++8`bmx8;fsc5IlG8!sZ#klmcJ zHM)E_?0|Ydxr3d1^X6ptd=cCBA(VAj!nu>ozGEBaqHVHUUgv|wT(tA@vCIDJn_QyV zKVT%~j6C6VR`T{*Hf@r2WLXy1U0H3`j_n&(S|{K}Z|hOKI61x`)e{0OpqAOY%k;n% zAhd$3;JUjmU!reE#{+ZJcUq9i^+0dssqUjYGl>5z5Cknzoe)WI-8TOQ3tVHY?a+%T zf)~dEY*cGV57|7>XW3vH^yY0pj8$Ni#lW|V@sDUFO4?h?z^9MU^Vp^c<*I##B|A#j zxjcD_gI*vj_wnEbe&3Xv)}6q(q6(^-Hzob4&2nCGds_;*2D4T2dP}2W&=y;B454|? z88T4I`A&;hqsxo#UVO(gc_9BPLwjkdN$;i0VCSRN>g2HO9O`IaAC8Cf&;7b7Or}74 zDGa4NvZ(tmfeAC&28#Lb%vCiXNqtvf8!~7&5!_%LWrq)Y(z(ONjMC>BRUG{-BY5Py zLWVfe2_tS0eY)fXTzBQ0w1`7*?hueK%Z!n7aL2Pg0_XZ0l|3!1qs9f{h+Rk{M>FN5 zK2FU(ba=JDMf0X_xEVo(5C2_($V)U^-je+Y*{qAct&Q>%UNW8F!~H71VSP)u3KM9a z0hggvO^3{;)j(DSrbhiuD%Z0nH$BBpyGfcjphR`FxUK1tHh5r+qW$c3deG0LPNv3sV z9|cY;Kd>&t*yx;#zU0ioN10d_C&(O|V&chq_>sN#?=UQ2)86#Di-mv>j71}vhz;P; z57Ef1TXq5WzDh=X-t#KhJtrk=uOU9-4NsiW=7Bs&qG=T2GV@PnK$<6Nu?|HG>QreD zwh%vRa}vy8GOJ1P_?A78JZxgEgGvhf{>hp|?EODT&owIjH&~nkv|2*X{SGYU&+=dz zW?a$}PyHwG(xx`@g)vtLZmsOQYG&_&xQQ2iqTlEcK!7tt>WN|Be$jVu3oi20c3gH< zq?EAcpAv)AO2U8h{RDeELytay0Zihi6Gnf|>ddYJ!Ov{1mzcMSqfisATgKdGQ_BNj z<2ap&TK6xoKC0^vAq!pp;_A2OMUBiuJZgD#0$CKSroJ{>aiBGPh(!ng<+`+-zdN=9 zUv47ONe99;bf1X)Yq4*}f0z2yd3FT5d??sz64hAFeG|Z&tU{fOm&ZJ z73##ZF)kXf?h5#BDD`AYkHQ}Vvz$B1+E62s3pBYgg=NWw2@?i1vU614}z`G0V6X;==1RN-fPkM zqW!$=J`LcnBYkeiwg<&89L4+JAljqqNL@e-EZTyu=L&dvMM4&LV?yQjEliNjzh<%c zvg$ybo#O*784A&ja%!pis`2BX2gENJT5*e7S<%xuzh}fCuCRMGFj1m~K!=hJ*Kn&) z^;ZQY?fF6{Mx!RWuKQQ85`hChCVWX3Crvw&ofeM5RfqoFY%Q{GKi(q+AIJfsFIdS2 z`~fGDoULIWj-9Ba1Z5nmak6JbRhvQ_rG*@3LE0%1@NNOH+98&1_HZ)`vyN1im5XkI zCB8Tsp8^xD4ZTn*X*Ov5HwE4YKXGnOk8f&7z_AvDD>nqAuRYp!IxaoY5yNc??GDXJ zx!b4Jzw5WHbL37xAxJWxVe}A$d*^|}kkPl^_GQW(z#BZTt?>;)7MoXKJ%3AE0 z6dVU;K|;K#&idYplHHg2SQuj9GuBTG5cxm%iS%g$wR;2T}1jH01$$w%Ht z{Hr+T>1Xu;(l)AMNlk+2AQ?%wT9kr@zw=6-z`*M6*#0CjCK);<$@iX;0LkOBbwxwz zXW)dyn#){(uo|;-#X|sN=Z%XPKkGS`=I#o}%7YLVuqA(G&RMB}fwh@>dj*4K<0H&S zqvb^8DAoKjS9beH43V|5#hFc;?+jT#7rt!r%eKKvuT3$u-iivkadq_d-5Z*Ux!1qX zjz5p>16r!G%{JMWB2_!3E})=JU(&6^)FxMscGO}ksS-}@UxXkDQGh1YJGzt0!Hxoi zCzrZEcxMEdBN4S+U6<}_hRp4)RVuN>3cXy8#79yiXciS#6O&FnKAq^$4S;s(W+oF= zPPPYVA;BZvf&*^2r_i#|w_;HC`tuKbtE>~vUf37Q3Xvzat{JO;;9W@v<|6$^*i~G; zxP3k|7&KPSML}NqO&E=s2u;U=MKG;c&V}aI^QoeqN_g|U1Om;g?n4pLcG6)Rma^j6 z{au_sl(0v^3D*AaXCJKdDXQ<35ES(g0k|N)x5Ba1l`)&FMH)edC}*c9mIXQ2Ftseq z&wgmQ2pOGC<@tGygpTRnoAy1YVH6V9`XdIN$=f2W7=pX~GmK5LL7Ws=tWKOzh+4~C z2dh_}ULJ+h3jb-+uKqX{q*4I(rVcX+V^|P}M3D32%q0qA*RSs5ZvWWBxR#P$kWmu- zXcP{F$6uCN(+M!%z187dj8WQuNUJTNulrZc)VZOt13+&`nbsFC8<^sfV_%!CQ^DBB z_;%}pWDDcS)EB6P{)>>6iJ34(B2cK=Eum`AJ%H$)hJa$a`!~oy&mlGh&vFkEVM<1T zmL$T%6WY^g|Em4vf5O5!9}i^cv(<3WgiJCV{}3xDjiaa>*XQHw9;8VM^k}p`grc2R zT!|q9JA5#ik+V*n-X?my4i`TvP&X&@TX4DEaJ>3=UWAeB3?C1QVj0IZMyr;pHc9T~ z#qKZmVvlu#xKJxHt-UFLpeJBOwexa^#>W{oUISl8x?)E~hUsit=o}KZL$kI+i+~R?vgs z`A~uk7~?l8{&Ug){RJ9@qJIK@R}DgPV&TxV?u7$*C1@>uo~FL9ujrMa>2ucEj-^Ak z#fq%8pHEI_UjBH!*vH*$_jV2Zp#EH4ZEtivtX*$ydA;2H_CcOrZr7V@Z`5mV=^uXV zJhe4#m7i?y_!wfn?AUN*QTG%DsHA{`rDn$K_=Kd<<84yo-t!hMxU;9-FrDu{n2+Vv5e710X14SvG~b9BKmaSkMldzd?Pi9qRn{?Uf6G70s$< zA;~WMHU*M;G{^bpmP1JXV`L2=aW$f2IHb-YRvKqq4IREf%q>aeCckkO&6=V+esNnl z6tcb-t|a2gmYUD1Bk5xkW{RS=JNni+C_HSw_l+R49X@ad_t%^L!>i=rf8_AGf5EPx zg?TZ;X&9)^K8n)h>d(DNF1}uOmwxnV=y8kf=zM+O+JE-*>ipclVI)lrmp4#!0>}M) zpZR8Te}E@alU|a}&PbLPylIx732ALg6%rokiKDaSb<9q|M++GrGU>>_h{XxsZRMwB z_DT3H$^NRpNcf{Q-p+Lo?uLGxO&kRu;nqyY!j~B}a1Vv;n~w=H0XgqRjuhS$J>~Z* zPDvk%h4VqD34wL7`*P8lB95MRq5{d($WNl*f+zLAR#Q@eq00c6nLXTJbq!_qc_>Am z1b!nj+qU@D^Xy-QS-8b@izpvLDr-%FF~YWYr>t^#E$MUR#4-({?>VYPxy8YtyL5Yq#|!RDiq*Tt_#u_Fxq99 zWKfa*ewMtHU7j)E0F2$pCs%25Vr9n@`%r92^y7U!6Wd0tp7EY?iMQ!i$p@zeDB+ID z7n#9z$2Ufwv&_STtftkS!_EkV85YK^BYsBQmCZ^g7d{G24%b}%I zM_8y*4BnnOBJRjE3hE0Dbr2ndladWJnbmi-9l)&I{yURk7*R%S)DpLd<+Y6LrT4`M z^qB#*!7#unHVm2qkbFi5HFN3}rMIa?2dQtc9)F{5g6>9@%mig2D zR~A_uk@(b9p@ROEv6xnp^xzMpNzK;XNZAutzd&WfRt;`u#2B_P)bqO?3OxSjOMK&%21`j^7C^0h4NlN6Lg|Sg z?+W9pC+!~R8`?i)q}0%tQ9WM@DTR0?zAEr;Yt%p@3Razt-MhjK8M*c8ujkma9W+ru zIol0?UzU}3)*DV$x5&D^-W9dERoX1tU5^vv3zW|}*gn9FRhlR!((uCAh7F;ke-{nZ zoC>V8jLe4@R=t?P=9a*{_>|ys0w(Ov+{6IJVS}@E%q7&lRNO6teNjM``19>Z+0~&P zyl!jK*kyZ7;O^X!_z?K#i++!--1#LvPbBZ_o}^g+Qa=(79H!C`P|9Q4!(AtCUn2=$ z@8_HVU!OA0<5sh#Hs0MVr;6qKTVZP55>Vi><*=|zS{NyyxP8!(;d&j~#K3V|2zcQb z>nSlBj0FG@p&WsWcqE-q5FfgIXO~-V6Y=+s?K#|%|2~GWJ6KF^^le&BP7anhObEo8 zv-~i@Rt+HV6hg>!Katz_ZwZMUo*Bi!Dmca{j0dU)|1igcAnmY=qnDF8j|UgeK7XBr zc@BzJd8=x~Fa!-^7+Fr8ELexpcromD_*@+8wHAcN>3!@m(!SfFc^ug)!tF%YBq8cA$)I73D8zr#4i$H z6^&aYU^z1lG`8(;>uzr?+2s<@$;W?avH9YC@!oKc0Z#PzC^`5~XNp~5nFJxppn_Xb z5^f`O3yc~}NlIBx_LIs-5(S#3AgK(raTA~?)^*~XG03lSyNt-Q)wRk`xU__wlvs9a z)D-p)xpMu70<041)Fl`5qvkxM9ObJ}(?7j`%{CmcIi7X&1ZLq2Sg~UN>TU!8;NqCs zbnxyi)Q2EO(YUXwwn1IQ{AH-?s;qV96GD0xvA~ACk#*T`+KJ&j+weV z)pT~&@|cb@p}=?r%C$rc+_{YpMk=iN|Jhf*9Y$uOdWV+2N{;AWeGHhgf%htzLc>c@;S3STTL^IRlhfmPynd?Y4t zdSOki-@}Lk8CM5^zVia8UVy*^7wr zKBm5z|A9mnR2Sarh8UXyMlARjxWzAka;`qg2IW$nsQ6w;6i@VIGM`n3pgEChaeEBL z$~+>svc3H2Gl8bJ`gLC<$%Q|QL6Wik2`W#FyDc7lz95HGwxRTOw<=_Fv2-{#*tH^* zM>+M?E&^f-ADCGdFb5H9>|)}U3U$pM^Fod_IB!o<7Lib1W?!}r%$A)Ru7)LPi5+n9 z4Xri}jibrZ-)Fd3r#dY$RvJGXScNU!98=C1L~0XqnuQkl^vsBBwSEk8&w!B9QTS71 z(`AjWu8Iy0JYtvSFGR_Fhx(ijU~eyimmP}0CsTikaG=c<#9}i+D&f6i8kyejq#`_p z49OzY&6^^lvreyF_c8%`i61x@MqR=p&Db;>wO)-58)QWya3%g!_Saon{GV)SSR7FFjL(nVGTIFYS)Eew;R19Z79TT)SDxC{ab^cJV`!% zD!o1zF$nX6=WIo9b&u(D!V3SA5By`M|J$kE_ux72f<_n|l|+}@rWwel^~c-!r(bC0 z`(f^YTo3*^PjCGDp~WuCBBsXX+(I{{#aMN}G>0de@ocSAwf8)W(>6YW&Xo;2S!m)! zPK|A3)nYtt!$g6~IlyAfz#{dNMaoh<}uYl6u1j=IXbOHaZlf*MkyGo=p_ z*otv>+3iE8rP^s%W*fB%s4*G=!~&hm%&t&R%A$>(vvC~tJa}mID}C9^cG*kzAgjtZ z%WgUA3u*gL-(x;=v+B*L2YC(udm(R6j2=|5*PS|N5gVoF;kBPbIBP zj#I@=M)WX`rCzfAD1}-Yn-e!XU0SIKW(1>Db?#jra^vT~y%Ve;4%&x`t?;E|YW0=| zG8(GvqSe?JdBSs7KzDPU6d4XUN~);Gws9B3H`crr6*>4hXMUM$557wCbvkH$G zG%`P<5FugtYP2l+R;@s)5SWNPJ(!g$^Pb{98D~ySg1qX+lVm4bugCZM)aMKzua_(& zuIq+RM8@Z4Al*->NblF#Qq>7xcgBE>C5IKI^%0;CoTs=5KU3kBQVSn5v)~PSROL2x z6t?RwIsrPjNa+HDZlb`VO|0_03N0BDvHYd0RtWmxxw`5NVFf6})|o}@%sysVwKYG_|>2&|%f3z??D+t(+T%XY1mxILd!;FE_vkE3~t?H$es zno)W8I|2KkC!GVj)v1qfsikIU?IM@nsMe=|M6o?`1Iw~@0nFrp05h&h>FWB+@B5W4 z&|sR-AP6X{%96qT1B$LvPQ}GjcW`$RqhW}=SlepY|DB^IJ#A2OJ*-Yf`!gnZL&&zD zX8`YfBp+XCnCs+9IqnC=xS2M%$8DfLnl1nw#esaGHQO0^dFz+skL{6X5AbPpe_ z8n7U`J=AJn{R75S)RN~D|Eg3YU`D5pYtuqlJQxa5XAA5zNk|ugU2-~#hzkaX)vFkF zYnm;fd?5Xl`0z%qImjQFLO4*nIKWlL=j?Q;@I%TC-ZRNRX#!107{Z*wXAas8&rBsi z=yFIHwddR0G-jpACrz^LE@8PbDyaHBGtiq|!NZM*p!93=cHNL8qx&A$H zFhVg^5^(~NX*;?cX!#V-qKIip5;&)~s^06vOBv|T4+e4_%KSx8Or5-nkR&i#m)ZQC?>>GS!*~{ydx#&}t7J$oN3_O_o;CQ)R_SE-WbVlz<2)-t zy2ay01A!b4)NjE(F0N}YG3T%~=iUREhtdknX+gT59o4v@HX?|txW;Il!?M(?1OmR| zi2;@TzLpGgd?kPgqU9?{E+im7Xk?atK~2gPVHN4MlOZ|N9B&#R$_TAlu;ODkW};Yy zrdZ{1EsYXtK(#pX*RWt&!moE{O|hL28#9eI^M@)!(iLr0VmdO-S+ksob>gwzG=)qn zZX1zyLFP!r2stS}Vh<`c$9v$k&_A7Rk@hLP7&{v(HqiYu!Dfda&GbWdJ2rWa`y4oh z6eKgrB@Gp9(@?s?aJa*3ccpy=wF4u~)!-IfKG6n?N8id6x0Hh;c0A`6Lak&1*To2 zE{KMdFAy{5=UaE%_lC`-Xe;f|&QPGQ*Bw=C}xVjt0Y~2uDT&UQ>cH*oWteYl(#s3O`F~k!Q~8<;QkYM?C72LEbNF#mkeM)D ztzd?mYtT$?!k?`RLXI_(mcYxLqFy2H(#| zuKwg!uangbsZeKTNI5xDDa&x>V;Gr+t5bywy>1-eg;%2w{o#gvOif5%*_m>DO^WJW|JP`8oZT1Vu|uFI9kjjH;W$d-h5H{8dJN1Bhdib z-uRHQCD9>mb^Ip9^;a}E0C!hGu-PBDb?a=4FtXup&gftXm`(iOv5_gPE9T*F`q$%qWKzj;^apL$xmpkXz0q6||?>$MxOo4;L&&JZg1sl(TzJzi;qbt%?a{ z=3iMlpB6n$^DQMniNEDpMP#P*elKW5gX5=OET9l#wpKm9QEi-GEdB6<@VG6PRKKhe z)2v4)N2P$^qcKCB<*FS8dTl77z>*gEQ5E2|1TF?JBhogh+AOC65O>+S(J&fuC$~T{ z<1^1+WdE+kJ%S?1XLqHl+Qr!n)heUw?mJuuRBs9VUW2}`rL_Gce`=ZfR-Ub&Zb>KK zs8gzk1Q}%~oc&}AL5M5Ji4x5)U_aTezOx@SkQ~1{`+Oujc=2R+lD%o%9@D-wd;Bhr z$&!`gzK)6(7O9qCH6$6x7L56vB1jV0;cx*#cI=}^+ZCju>ndaD#8wf~i1A_6fR3V} zRIi4>0u0M5N6emg6HQ%Zoc775Ak3UgLsvKNR`tZz^Re@fZ(wKWmdl^iF)6JzdpZv% zopVgW0#ba++4PBcg8Nm+hPu>rv=qjC!@EvIKimu)$~P*3WK;mCe(N|Qdn6;bvZ(!T?48ti3Ek z7PX-HnR@)T9GdbNxXV-BfsED_86*xX)8*6`O(VK^PpZt~d*{M3r6aEcWsAOY9Hn40 z<~_q>P@0)j-E*})82AjB>|TaRx5m2$`QL;-M(kAz%Q_ePj{l|j3F-Iw7ZC@@x` z)%SB9;r9)~k6ZW;*WeEi!FL@t6g?JjJID9U*T_q+$K~LU&*y1>((fSjsGlm6Z_r*Fe19ldk@G(EEC8&qaq7;iUPd6P~K4q6PN$i%sk@;Ny{Ts<)dR-cT{wG!Fh%K}YPjop> z>H(+D76Dgbv_cz%WEa2U-n!+Fr9gZ^{c{u3bf@@u zfW%A0e74q=q$J2m;lIP$3d=Nd45h&d0U$=S;yLE(CUdC~Gs>)dDN-;=3I-r6TyT|^ z7eF~+na=tNhcm;XW0L9IQg}+~l59=Ogow&n&&l z`Pf5#i3{nv*m+e;S4jw<*kP}YynC1h3P5hz=QW1P7eet(ad3cvo-lC z!Qg&%5_4~K+bL)PUtjeb-9FJL@2(@!v!82K|5+3^`FyKG*pIIx@xs}qKn_Siqw^G!a33IO zocMXLL*hU~GxA z?IK@?HQ?(ooMQ+?X@rwQryFFu#7>Z>ctabi25Tb0cx^Yf__WUa75zT2!R zv4#sf_`C@Mq*tm&pdq%;IG@p|$&PAO(qc~SpMVH;wMOTTPR?h5#^6mWsW*wLX{@fi zaR8$LeYyd9Fe(C6Ra4Dqu z3={?NBw4^onBf~MaQBER6j`fm{v*glWrL5ZE(aXjG6dKc%`3p0@0_+j7KUC(yD#V} zux^JhW0>p5G{`ZJDOVn;Q@uu8p6NU5X~Tg(0*l$71gd<2#Ob~|%O_pxG{Y9DA_kKy zT`119{EXbFRsxU8hk17E~GS>5d7!1D0F`b%I zfK8JL2XgMEE`{m0n-U4jc5LwE7*w-#jDON^x!Y~JGQk?;m>jxVRhYey_pXmP9%QHh zRedxd8c97X0Nyyc0W%t+Nm{5tf$41xzy)iX+(A~xLJZEdC;~$W3-=4XiV&e#R$?3D zBKC|e2*%AS*slRGL)97g@j)(;nvay@(;qh(z)^xL|M^ok1#9UcGtY3TpxWKCny6D2 zmhxeH&6Nm-RtL0NZo+Mz*l4Zm*WigJumT}gt9nr}r!_0;(sBf8Yz#OR{ry&l)jE=; zNPlg>wua4?Hu04{I|;8;=M=2yJ9HIUgsFfFtSn#F&UAuiZcgIygZ>ch(e|TgOR#5@ z^=gpu)S1bg0nL(ESM9k=c$e^-OUQOBO)-?s=i)naGzaAzL{2U^ED~jIPT%+;{T0A1D8T;!VQvc* literal 0 HcmV?d00001 diff --git a/registry/modules/specfact-codebase-0.41.5.tar.gz.sha256 b/registry/modules/specfact-codebase-0.41.5.tar.gz.sha256 new file mode 100644 index 0000000..227d9a9 --- /dev/null +++ b/registry/modules/specfact-codebase-0.41.5.tar.gz.sha256 @@ -0,0 +1 @@ +fe8f95c325f21eb80209aa067f6a4f2055f1f5feed4e818a1c9d3061320c2270 diff --git a/scripts/pre_commit_code_review.py b/scripts/pre_commit_code_review.py index 6256967..ac592d4 100755 --- a/scripts/pre_commit_code_review.py +++ b/scripts/pre_commit_code_review.py @@ -1,7 +1,8 @@ """Run specfact code review as a staged-file pre-commit gate (modules repo). Writes a machine-readable JSON report to ``.specfact/code-review.json`` (gitignored) -so IDEs and Copilot can read findings; exit code still reflects the governed CI verdict. +so IDEs and Copilot can read findings. The hook exits non-zero only when the report +contains error-severity findings (warning-only verdicts do not block commits). If ``specfact_cli`` is not installed, attempts ``hatch run dev-deps`` / ``ensure_core_dependency`` (sibling ``specfact-cli`` checkout) before failing. @@ -83,13 +84,15 @@ def filter_review_gate_paths(paths: Sequence[str]) -> list[str]: def _specfact_review_paths(paths: Sequence[str]) -> list[str]: - """Paths to pass to SpecFact ``code review run`` (it treats inputs as Python; skip OpenSpec Markdown).""" + """Paths to pass to SpecFact ``code review run`` (Python sources only; skip Markdown and binary artifacts).""" result: list[str] = [] for raw in paths: normalized = raw.replace("\\", "/").strip() if normalized.startswith("openspec/changes/") and normalized.lower().endswith(".md"): continue - result.append(raw) + lower = normalized.lower() + if lower.endswith((".py", ".pyi")): + result.append(raw) return result @@ -287,8 +290,8 @@ def main(argv: Sequence[str] | None = None) -> int: specfact_files = _specfact_review_paths(files) if len(specfact_files) == 0: sys.stdout.write( - "Staged review paths are only OpenSpec Markdown under openspec/changes/; " - "skipping SpecFact code review (those files are not Python review targets).\n" + "Staged review paths include no Python files (.py/.pyi) for SpecFact " + "(e.g. only Markdown, YAML, or registry bundles); skipping SpecFact code review.\n" ) return 0 @@ -309,6 +312,15 @@ def main(argv: Sequence[str] | None = None) -> int: # is in REVIEW_JSON_OUT; we print a short summary on stderr below. if not _print_review_findings_summary(repo_root): return 1 + try: + data = json.loads(report_path.read_text(encoding="utf-8")) + findings_raw = data.get("findings") + if isinstance(findings_raw, list): + counts = count_findings_by_severity(findings_raw) + if counts["error"] == 0: + return 0 + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + pass return result.returncode diff --git a/tests/cli-contracts/specfact-code-review-run.scenarios.yaml b/tests/cli-contracts/specfact-code-review-run.scenarios.yaml index 7be31de..0b8c65e 100644 --- a/tests/cli-contracts/specfact-code-review-run.scenarios.yaml +++ b/tests/cli-contracts/specfact-code-review-run.scenarios.yaml @@ -55,3 +55,77 @@ scenarios: exit_code: 2 stderr_contains: - choose positional files or auto-scope controls + - name: mode-shadow-dirty-exit-zero + type: pattern + argv: + - --json + - --mode + - shadow + - tests/fixtures/review/dirty_module.py + expect: + exit_code: 0 + stdout_contains: + - review-report.json + - name: mode-enforce-dirty-exit-nonzero + type: pattern + argv: + - --json + - --mode + - enforce + - tests/fixtures/review/dirty_module.py + expect: + exit_code: 1 + stdout_contains: + - review-report.json + - name: focus-source-and-docs-union + type: pattern + argv: + - --scope + - full + - --path + - packages/specfact-code-review + - --json + - --focus + - source + - --focus + - docs + expect: + exit_code: 0 + stdout_contains: + - '"run_id":' + - name: focus-tests-narrows-to-test-tree + type: pattern + argv: + - --scope + - full + - --path + - packages/specfact-code-review + - --json + - --focus + - tests + expect: + exit_code: 0 + stdout_contains: + - '"run_id":' + - name: level-error-json-clean-module + type: pattern + argv: + - --json + - --level + - error + - tests/fixtures/review/clean_module.py + expect: + exit_code: 0 + stdout_contains: + - review-report.json + - name: focus-cannot-combine-with-include-tests + type: anti-pattern + argv: + - tests/fixtures/review/clean_module.py + - --focus + - source + - --include-tests + expect: + exit_code: 2 + stderr_contains: + - Cannot combine --focus with --include-tests or --exclude-tests diff --git a/tests/unit/scripts/test_pre_commit_code_review.py b/tests/unit/scripts/test_pre_commit_code_review.py index dc43362..a8e649c 100644 --- a/tests/unit/scripts/test_pre_commit_code_review.py +++ b/tests/unit/scripts/test_pre_commit_code_review.py @@ -34,11 +34,19 @@ def _load_script_module() -> Any: } -def test_specfact_review_paths_skips_openspec_markdown() -> None: +def test_specfact_review_paths_keeps_only_python_sources() -> None: module = _load_script_module() assert module._specfact_review_paths( - ["tests/test_app.py", "openspec/changes/foo/tasks.md", "openspec/changes/foo/proposal.md"] - ) == ["tests/test_app.py"] + [ + "tests/test_app.py", + "openspec/changes/foo/tasks.md", + "openspec/changes/foo/proposal.md", + "registry/modules/specfact-code-review-0.47.0.tar.gz", + "registry/index.json", + "packages/specfact-code-review/module-package.yaml", + "src/pkg/stub.pyi", + ] + ) == ["tests/test_app.py", "src/pkg/stub.pyi"] def test_filter_review_gate_paths_keeps_contract_relevant_trees() -> None: @@ -95,6 +103,39 @@ def test_main_skips_specfact_when_only_openspec_markdown(capsys: pytest.CaptureF assert exit_code == 0 out = capsys.readouterr().out assert "skipping SpecFact code review" in out + assert ".py/.pyi" in out + + +def test_main_warnings_only_does_not_block_despite_cli_fail_exit( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Pre-commit gate blocks on error findings only; warning-only FAIL verdict is advisory.""" + module = _load_script_module() + repo_root = tmp_path + payload: dict[str, object] = { + "overall_verdict": "FAIL", + "findings": [{"severity": "warning", "rule": "w1"}], + } + _write_sample_review_report(repo_root, payload) + + def _fake_root() -> Path: + return repo_root + + def _fake_ensure() -> tuple[bool, str | None]: + return True, None + + def _fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + _write_sample_review_report(repo_root, payload) + return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="") + + monkeypatch.setattr(module, "_repo_root", _fake_root) + monkeypatch.setattr(module, "ensure_runtime_available", _fake_ensure) + monkeypatch.setattr(module.subprocess, "run", _fake_run) + + exit_code = module.main(["tests/unit/test_app.py"]) + err = capsys.readouterr().err + assert exit_code == 0 + assert "warnings=1" in err def test_main_propagates_review_gate_exit_code( diff --git a/tests/unit/specfact_code_review/fixtures/contract_runner/public_missing_contract_but_icontract_imported.py b/tests/unit/specfact_code_review/fixtures/contract_runner/public_missing_contract_but_icontract_imported.py new file mode 100644 index 0000000..a73114a --- /dev/null +++ b/tests/unit/specfact_code_review/fixtures/contract_runner/public_missing_contract_but_icontract_imported.py @@ -0,0 +1,5 @@ +import icontract as _icontract_presence_only # noqa: F401 # pyright: ignore[reportUnusedImport] + + +def public_without_contracts(value: int) -> int: + return value + 1 diff --git a/tests/unit/specfact_code_review/run/test___init__.py b/tests/unit/specfact_code_review/run/test___init__.py new file mode 100644 index 0000000..69f96ad --- /dev/null +++ b/tests/unit/specfact_code_review/run/test___init__.py @@ -0,0 +1,14 @@ +"""Smoke tests for lazy `specfact_code_review.run` exports.""" + +from __future__ import annotations + +import specfact_code_review.run as run_pkg + + +def test_run_package_exports_run_review() -> None: + assert callable(run_pkg.run_review) + + +def test_all_exports_are_defined() -> None: + for name in run_pkg.__all__: + assert hasattr(run_pkg, name) diff --git a/tests/unit/specfact_code_review/run/test_runner.py b/tests/unit/specfact_code_review/run/test_runner.py index 2bb18fb..c776939 100644 --- a/tests/unit/specfact_code_review/run/test_runner.py +++ b/tests/unit/specfact_code_review/run/test_runner.py @@ -63,10 +63,14 @@ def _record(name: str) -> list[ReviewFinding]: monkeypatch.setattr("specfact_code_review.run.runner.run_ruff", lambda files: _record("ruff")) monkeypatch.setattr("specfact_code_review.run.runner.run_radon", lambda files: _record("radon")) monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep", lambda files: _record("semgrep")) + monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep_bugs", lambda files: _record("semgrep_bugs")) monkeypatch.setattr("specfact_code_review.run.runner.run_ast_clean_code", lambda files: _record("ast")) monkeypatch.setattr("specfact_code_review.run.runner.run_basedpyright", lambda files: _record("basedpyright")) monkeypatch.setattr("specfact_code_review.run.runner.run_pylint", lambda files: _record("pylint")) - monkeypatch.setattr("specfact_code_review.run.runner.run_contract_check", lambda files: _record("contracts")) + monkeypatch.setattr( + "specfact_code_review.run.runner.run_contract_check", + lambda files, **_: _record("contracts"), + ) monkeypatch.setattr( "specfact_code_review.run.runner._evaluate_tdd_gate", lambda files: ( @@ -78,7 +82,17 @@ def _record(name: str) -> list[ReviewFinding]: report = run_review([Path("packages/specfact-code-review/src/specfact_code_review/run/scorer.py")]) assert isinstance(report, ReviewReport) - assert calls == ["ruff", "radon", "semgrep", "ast", "basedpyright", "pylint", "contracts", "testing"] + assert calls == [ + "ruff", + "radon", + "semgrep", + "semgrep_bugs", + "ast", + "basedpyright", + "pylint", + "contracts", + "testing", + ] def test_run_review_merges_findings_from_all_runners(monkeypatch: MonkeyPatch) -> None: @@ -90,6 +104,10 @@ def test_run_review_merges_findings_from_all_runners(monkeypatch: MonkeyPatch) - "specfact_code_review.run.runner.run_semgrep", lambda files: [_finding(tool="semgrep", rule="cross-layer-call", category="architecture")], ) + monkeypatch.setattr( + "specfact_code_review.run.runner.run_semgrep_bugs", + lambda files: [_finding(tool="semgrep", rule="specfact-bugs-eval-exec", category="security")], + ) monkeypatch.setattr( "specfact_code_review.run.runner.run_ast_clean_code", lambda files: [_finding(tool="ast", rule="dry.duplicate-function-shape", category="dry")], @@ -104,7 +122,7 @@ def test_run_review_merges_findings_from_all_runners(monkeypatch: MonkeyPatch) - ) monkeypatch.setattr( "specfact_code_review.run.runner.run_contract_check", - lambda files: [_finding(tool="contract_runner", rule="MISSING_ICONTRACT", category="contracts")], + lambda files, **_: [_finding(tool="contract_runner", rule="MISSING_ICONTRACT", category="contracts")], ) monkeypatch.setattr( "specfact_code_review.run.runner._evaluate_tdd_gate", @@ -120,6 +138,7 @@ def test_run_review_merges_findings_from_all_runners(monkeypatch: MonkeyPatch) - "ruff", "radon", "semgrep", + "semgrep", "ast", "basedpyright", "pylint", @@ -141,10 +160,11 @@ def test_run_review_skips_tdd_gate_when_no_tests_is_true(monkeypatch: MonkeyPatc monkeypatch.setattr("specfact_code_review.run.runner.run_ruff", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_radon", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep_bugs", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_ast_clean_code", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_basedpyright", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_pylint", lambda files: []) - monkeypatch.setattr("specfact_code_review.run.runner.run_contract_check", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_contract_check", lambda files, **_: []) monkeypatch.setattr( "specfact_code_review.run.runner._evaluate_tdd_gate", lambda files: (_ for _ in ()).throw(AssertionError("_evaluate_tdd_gate should not be called")), @@ -162,10 +182,11 @@ def test_run_review_returns_review_report(monkeypatch: MonkeyPatch) -> None: monkeypatch.setattr("specfact_code_review.run.runner.run_ruff", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_radon", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep_bugs", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_ast_clean_code", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_basedpyright", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_pylint", lambda files: []) - monkeypatch.setattr("specfact_code_review.run.runner.run_contract_check", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_contract_check", lambda files, **_: []) monkeypatch.setattr( "specfact_code_review.run.runner._evaluate_tdd_gate", lambda files: ([], {"packages/specfact-code-review/src/specfact_code_review/run/scorer.py": 95.0}), @@ -213,10 +234,14 @@ def test_run_review_suppresses_known_test_noise_by_default(monkeypatch: MonkeyPa monkeypatch.setattr("specfact_code_review.run.runner.run_ruff", lambda files: noisy_findings[2:]) monkeypatch.setattr("specfact_code_review.run.runner.run_radon", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep_bugs", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_ast_clean_code", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_basedpyright", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_pylint", lambda files: noisy_findings[1:2]) - monkeypatch.setattr("specfact_code_review.run.runner.run_contract_check", lambda files: noisy_findings[:1]) + monkeypatch.setattr( + "specfact_code_review.run.runner.run_contract_check", + lambda files, **_: noisy_findings[:1], + ) monkeypatch.setattr("specfact_code_review.run.runner._evaluate_tdd_gate", lambda files: ([], None)) report = run_review([Path("tests/unit/specfact_code_review/run/test_commands.py")], no_tests=True) @@ -250,10 +275,14 @@ def test_run_review_can_include_known_test_noise(monkeypatch: MonkeyPatch) -> No monkeypatch.setattr("specfact_code_review.run.runner.run_ruff", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_radon", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep_bugs", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_ast_clean_code", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_basedpyright", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_pylint", lambda files: noisy_findings[1:]) - monkeypatch.setattr("specfact_code_review.run.runner.run_contract_check", lambda files: noisy_findings[:1]) + monkeypatch.setattr( + "specfact_code_review.run.runner.run_contract_check", + lambda files, **_: noisy_findings[:1], + ) monkeypatch.setattr("specfact_code_review.run.runner._evaluate_tdd_gate", lambda files: ([], None)) report = run_review( @@ -269,10 +298,11 @@ def test_run_review_emits_advisory_checklist_finding_in_pr_mode(monkeypatch: Mon monkeypatch.setattr("specfact_code_review.run.runner.run_ruff", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_radon", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep_bugs", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_ast_clean_code", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_basedpyright", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_pylint", lambda files: []) - monkeypatch.setattr("specfact_code_review.run.runner.run_contract_check", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_contract_check", lambda files, **_: []) monkeypatch.setattr("specfact_code_review.run.runner._evaluate_tdd_gate", lambda files: ([], None)) monkeypatch.setenv("SPECFACT_CODE_REVIEW_PR_MODE", "true") monkeypatch.setenv("SPECFACT_CODE_REVIEW_PR_TITLE", "Expand code review coverage") @@ -291,10 +321,11 @@ def test_run_review_requires_explicit_pr_mode_token_for_clean_code_reasoning(mon monkeypatch.setattr("specfact_code_review.run.runner.run_ruff", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_radon", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep_bugs", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_ast_clean_code", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_basedpyright", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_pylint", lambda files: []) - monkeypatch.setattr("specfact_code_review.run.runner.run_contract_check", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_contract_check", lambda files, **_: []) monkeypatch.setattr("specfact_code_review.run.runner._evaluate_tdd_gate", lambda files: ([], None)) monkeypatch.setenv("SPECFACT_CODE_REVIEW_PR_MODE", "true") monkeypatch.setenv("SPECFACT_CODE_REVIEW_PR_TITLE", "Expand code review coverage") @@ -320,10 +351,11 @@ def test_run_review_suppresses_global_duplicate_code_noise_by_default(monkeypatc monkeypatch.setattr("specfact_code_review.run.runner.run_ruff", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_radon", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep_bugs", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_ast_clean_code", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_basedpyright", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_pylint", lambda files: [duplicate_code_finding]) - monkeypatch.setattr("specfact_code_review.run.runner.run_contract_check", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_contract_check", lambda files, **_: []) monkeypatch.setattr("specfact_code_review.run.runner._evaluate_tdd_gate", lambda files: ([], None)) report = run_review([Path("scripts/link_dev_module.py")], no_tests=True) @@ -374,10 +406,11 @@ def test_run_review_can_include_global_duplicate_code_noise(monkeypatch: MonkeyP monkeypatch.setattr("specfact_code_review.run.runner.run_ruff", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_radon", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep_bugs", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_ast_clean_code", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_basedpyright", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_pylint", lambda files: [duplicate_code_finding]) - monkeypatch.setattr("specfact_code_review.run.runner.run_contract_check", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_contract_check", lambda files, **_: []) monkeypatch.setattr("specfact_code_review.run.runner._evaluate_tdd_gate", lambda files: ([], None)) report = run_review([Path("scripts/link_dev_module.py")], no_tests=True, include_noise=True) diff --git a/tests/unit/specfact_code_review/test_review_tool_pip_manifest.py b/tests/unit/specfact_code_review/test_review_tool_pip_manifest.py new file mode 100644 index 0000000..abca519 --- /dev/null +++ b/tests/unit/specfact_code_review/test_review_tool_pip_manifest.py @@ -0,0 +1,21 @@ +"""Guard: code-review pip_dependencies cover all external review tools.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +from specfact_code_review.tools.tool_availability import REVIEW_TOOL_PIP_PACKAGES + + +REPO_ROOT = Path(__file__).resolve().parents[3] +MODULE_PACKAGE = REPO_ROOT / "packages" / "specfact-code-review" / "module-package.yaml" + + +def test_module_package_lists_all_review_tool_pip_packages() -> None: + data = yaml.safe_load(MODULE_PACKAGE.read_text(encoding="utf-8")) + pip_deps: list[str] = data["pip_dependencies"] + declared = set(pip_deps) + for _tool_id, pip_name in REVIEW_TOOL_PIP_PACKAGES.items(): + assert pip_name in declared, f"Add {pip_name!r} to specfact-code-review module-package.yaml pip_dependencies" diff --git a/tests/unit/specfact_code_review/tools/test_contract_runner.py b/tests/unit/specfact_code_review/tools/test_contract_runner.py index cccf1fd..8c25bc9 100644 --- a/tests/unit/specfact_code_review/tools/test_contract_runner.py +++ b/tests/unit/specfact_code_review/tools/test_contract_runner.py @@ -1,9 +1,11 @@ from __future__ import annotations +import shutil import subprocess from pathlib import Path from unittest.mock import Mock +import pytest from pytest import MonkeyPatch from specfact_code_review.tools.contract_runner import _skip_icontract_ast_scan, run_contract_check @@ -13,18 +15,27 @@ FIXTURES_DIR = Path(__file__).resolve().parent.parent / "fixtures" / "contract_runner" -def test_run_contract_check_reports_public_function_without_contracts(monkeypatch: MonkeyPatch) -> None: +@pytest.fixture(autouse=True) +def _stub_crosshair_on_path(monkeypatch: MonkeyPatch) -> None: # pyright: ignore[reportUnusedFunction] + """So skip_if_tool_missing does not short-circuit before mocked subprocess.run.""" + real_which = shutil.which + + def _which(name: str) -> str | None: + if name == "crosshair": + return "/fake/crosshair" + return real_which(name) + + monkeypatch.setattr("specfact_code_review.tools.tool_availability.shutil.which", _which) + + +def test_run_contract_check_skips_missing_icontract_when_package_unused(monkeypatch: MonkeyPatch) -> None: file_path = FIXTURES_DIR / "public_without_contracts.py" run_mock = Mock(return_value=completed_process("crosshair", stdout="")) monkeypatch.setattr(subprocess, "run", run_mock) findings = run_contract_check([file_path]) - assert len(findings) == 1 - assert findings[0].category == "contracts" - assert findings[0].rule == "MISSING_ICONTRACT" - assert findings[0].file == str(file_path) - assert findings[0].line == 1 + assert not findings assert_tool_run(run_mock, ["crosshair", "check", "--per_path_timeout", "2", str(file_path)]) @@ -88,7 +99,7 @@ def test_run_contract_check_ignores_crosshair_timeout(monkeypatch: MonkeyPatch) def test_run_contract_check_reports_unavailable_crosshair_but_keeps_ast_findings(monkeypatch: MonkeyPatch) -> None: - file_path = FIXTURES_DIR / "public_without_contracts.py" + file_path = FIXTURES_DIR / "public_missing_contract_but_icontract_imported.py" monkeypatch.setattr(subprocess, "run", Mock(side_effect=FileNotFoundError("crosshair not found"))) findings = run_contract_check([file_path]) diff --git a/tests/unit/specfact_code_review/tools/test_tool_availability.py b/tests/unit/specfact_code_review/tools/test_tool_availability.py new file mode 100644 index 0000000..d047410 --- /dev/null +++ b/tests/unit/specfact_code_review/tools/test_tool_availability.py @@ -0,0 +1,44 @@ +"""Unit tests for review tool PATH / import skip helpers.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from specfact_code_review.tools.tool_availability import ( + REVIEW_TOOL_PIP_PACKAGES, + skip_if_pytest_unavailable, + skip_if_tool_missing, +) + + +def test_skip_if_tool_missing_empty_when_executable_present() -> None: + with patch("specfact_code_review.tools.tool_availability.shutil.which", return_value="/usr/bin/ruff"): + assert skip_if_tool_missing("ruff", Path("x.py")) == [] + + +def test_skip_if_tool_missing_finds_single_finding_when_absent() -> None: + with patch("specfact_code_review.tools.tool_availability.shutil.which", return_value=None): + findings = skip_if_tool_missing("ruff", Path("src/m.py")) + assert len(findings) == 1 + assert findings[0].tool == "ruff" + assert "ruff" in findings[0].message.lower() + + +def test_skip_if_pytest_unavailable_when_pytest_missing() -> None: + with patch("importlib.util.find_spec", return_value=None): + findings = skip_if_pytest_unavailable(Path("tests/test_x.py")) + assert len(findings) == 1 + assert findings[0].tool == "pytest" + + +def test_review_tool_pip_packages_covers_each_tool_id() -> None: + assert set(REVIEW_TOOL_PIP_PACKAGES) == { + "ruff", + "radon", + "semgrep", + "basedpyright", + "pylint", + "crosshair", + "pytest", + } diff --git a/tests/unit/specfact_codebase/test_sidecar_framework_extractors.py b/tests/unit/specfact_codebase/test_sidecar_framework_extractors.py new file mode 100644 index 0000000..bb8015c --- /dev/null +++ b/tests/unit/specfact_codebase/test_sidecar_framework_extractors.py @@ -0,0 +1,43 @@ +"""Tests for sidecar framework extractors (path exclusions).""" + +from __future__ import annotations + +from pathlib import Path + +from specfact_codebase.validators.sidecar.frameworks.fastapi import FastAPIExtractor + + +def _fake_fastapi_main() -> str: + return """ +from fastapi import FastAPI +app = FastAPI() + +@app.get("/real") +def real(): + return {"ok": True} +""" + + +def test_fastapi_extractor_ignores_specfact_venv_routes(tmp_path: Path) -> None: + """Routes under .specfact/venv must not be counted (sidecar installs deps there).""" + (tmp_path / "main.py").write_text(_fake_fastapi_main(), encoding="utf-8") + + venv_app = tmp_path / ".specfact" / "venv" / "lib" / "site-packages" / "fastapi_app" + venv_app.mkdir(parents=True) + (venv_app / "noise.py").write_text( + """ +from fastapi import FastAPI +app = FastAPI() + +@app.get("/ghost-from-venv") +def ghost(): + return {} +""", + encoding="utf-8", + ) + + extractor = FastAPIExtractor() + routes = extractor.extract_routes(tmp_path) + paths = {route.path for route in routes} + assert "/real" in paths + assert "/ghost-from-venv" not in paths From 5dcf4b84d1380bdeabc7e72ea084cb7739a2502f Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Wed, 15 Apr 2026 00:14:13 +0200 Subject: [PATCH 2/3] Add sign fixes --- .../workflows/sign-modules-on-approval.yml | 53 +++++++++--- CHANGELOG.md | 7 ++ README.md | 4 +- docs/modules/code-review.md | 53 +++++++++--- docs/reference/module-security.md | 7 +- .../TDD_EVIDENCE.md | 2 +- .../specfact-code-review/.semgrep/bugs.yaml | 8 +- .../specfact-code-review/module-package.yaml | 2 +- .../src/specfact_code_review/run/commands.py | 12 +-- .../tools/tool_availability.py | 5 ++ .../specfact-codebase/module-package.yaml | 4 +- .../validators/sidecar/frameworks/base.py | 6 +- .../validators/sidecar/frameworks/fastapi.py | 79 ++++++++++++++---- registry/index.json | 2 +- .../specfact-code-review-0.47.0.tar.gz | Bin 35058 -> 35334 bytes .../specfact-code-review-0.47.0.tar.gz.sha256 | 2 +- scripts/git-branch-module-signature-flag.sh | 27 ++++++ .../pre-commit-verify-modules-signature.sh | 47 +++++++---- scripts/pre_commit_code_review.py | 28 +++---- scripts/validate_agent_rule_applies_when.py | 0 scripts/verify-modules-signature.py | 64 +++++++++++--- .../test_sidecar_framework_extractors.py | 41 +++++++++ ...git_branch_module_signature_flag_script.py | 15 ++++ ..._commit_verify_modules_signature_script.py | 25 +++--- .../test_verify_modules_signature_script.py | 70 ++++++++++++++++ .../test_sign_modules_on_approval.py | 40 ++++++--- 26 files changed, 476 insertions(+), 127 deletions(-) create mode 100755 scripts/git-branch-module-signature-flag.sh mode change 100644 => 100755 scripts/validate_agent_rule_applies_when.py create mode 100644 tests/unit/test_git_branch_module_signature_flag_script.py diff --git a/.github/workflows/sign-modules-on-approval.yml b/.github/workflows/sign-modules-on-approval.yml index c6d71bb..02ef3a2 100644 --- a/.github/workflows/sign-modules-on-approval.yml +++ b/.github/workflows/sign-modules-on-approval.yml @@ -14,10 +14,6 @@ permissions: jobs: sign-modules: - if: >- - github.event.review.state == 'approved' && - (github.event.pull_request.base.ref == 'dev' || github.event.pull_request.base.ref == 'main') && - github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest env: SPECFACT_MODULE_PRIVATE_SIGN_KEY: ${{ secrets.SPECFACT_MODULE_PRIVATE_SIGN_KEY }} @@ -25,7 +21,33 @@ jobs: PR_BASE_REF: ${{ github.event.pull_request.base.ref }} PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} steps: + - name: Eligibility gate (required status check) + id: gate + run: | + set -euo pipefail + if [ "${{ github.event.review.state }}" != "approved" ]; then + echo "sign=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping module signing: review state is not approved." + exit 0 + fi + base_ref="${{ github.event.pull_request.base.ref }}" + if [ "$base_ref" != "dev" ] && [ "$base_ref" != "main" ]; then + echo "sign=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping module signing: base branch is not dev or main." + exit 0 + fi + head_repo="${{ github.event.pull_request.head.repo.full_name }}" + this_repo="${{ github.repository }}" + if [ "$head_repo" != "$this_repo" ]; then + echo "sign=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping module signing: fork PR (head repo differs from target repo)." + exit 0 + fi + echo "sign=true" >> "$GITHUB_OUTPUT" + echo "Eligible for module signing (approved, same-repo PR to dev or main)." + - name: Guard signing secrets + if: steps.gate.outputs.sign == 'true' run: | set -euo pipefail if [ -z "${SPECFACT_MODULE_PRIVATE_SIGN_KEY:-}" ]; then @@ -38,21 +60,25 @@ jobs: fi - uses: actions/checkout@v4 + if: steps.gate.outputs.sign == 'true' with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - name: Set up Python 3.12 + if: steps.gate.outputs.sign == 'true' uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install signing dependencies + if: steps.gate.outputs.sign == 'true' run: | python -m pip install --upgrade pip python -m pip install pyyaml beartype icontract cryptography cffi - name: Discover module manifests + if: steps.gate.outputs.sign == 'true' id: discover run: | set -euo pipefail @@ -61,6 +87,7 @@ jobs: echo "Discovered ${#MANIFESTS[@]} module-package.yaml file(s) under packages/" - name: Sign changed module manifests + if: steps.gate.outputs.sign == 'true' id: sign run: | set -euo pipefail @@ -73,6 +100,7 @@ jobs: --payload-from-filesystem - name: Commit and push signed manifests + if: steps.gate.outputs.sign == 'true' id: commit run: | set -euo pipefail @@ -94,15 +122,20 @@ jobs: - name: Write job summary if: always() env: - COMMIT_CHANGED: ${{ steps.commit.outputs.changed }} - MANIFESTS_COUNT: ${{ steps.discover.outputs.manifests_count }} + GATE_SIGN: ${{ steps.gate.outputs.sign }} + COMMIT_CHANGED: ${{ steps.commit.outputs.changed || '' }} + MANIFESTS_COUNT: ${{ steps.discover.outputs.manifests_count || '' }} run: | { echo "### Module signing (CI approval)" - echo "Manifests discovered under \`packages/\`: ${MANIFESTS_COUNT:-unknown}" - if [ "${COMMIT_CHANGED}" = "true" ]; then - echo "Committed signed manifest updates to ${PR_HEAD_REF}." + if [ "${GATE_SIGN}" != "true" ]; then + echo "Signing skipped (eligibility gate: not approved, wrong base branch, or fork PR)." else - echo "No changes detected (manifests already signed or no module changes on this PR vs merge-base)." + echo "Manifests discovered under \`packages/\`: ${MANIFESTS_COUNT:-unknown}" + if [ "${COMMIT_CHANGED}" = "true" ]; then + echo "Committed signed manifest updates to ${PR_HEAD_REF}." + else + echo "No changes detected (manifests already signed or no module changes on this PR vs merge-base)." + fi fi } >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index f461b5a..c527f84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ and this project follows SemVer for bundle versions. - Refresh the canonical `specfact-code-review` house-rules skill to a compact clean-code charter and bump the bundle metadata for the signed 0.45.1 release. +- Document CI module verification: **`pr-orchestrator`** PR checks run + `verify-modules-signature` with **`--payload-from-filesystem --enforce-version-bump`** + and omit **`--require-signature` by default**; **`--require-signature`** is enforced + when the target is **`main`** (including pushes to **`main`**). **`sign-modules.py`** + in approval workflows continues to use **`--payload-from-filesystem`**. Sign bundled + manifests before merging release PRs or address post-merge verification failures by + re-signing and bumping versions as required. ## [0.44.0] - 2026-03-17 diff --git a/README.md b/README.md index ed4e049..b32e768 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ hatch run test hatch run specfact code review run --json --out .specfact/code-review.json ``` -**Module signatures:** `pr-orchestrator` enforces `--require-signature` only for events targeting **`main`**; for **`dev`** (and feature branches) CI checks checksums and version bumps without requiring a cryptographic signature yet. Add `--require-signature` to the `verify-modules-signature` command when you want the same bar as **`main`** (for example before merging to `main`). Pre-commit runs `scripts/pre-commit-verify-modules-signature.sh`, which mirrors that policy (signatures required on branch `main`, or when `GITHUB_BASE_REF=main` in Actions). +**Module signatures:** For pull request verification, `pr-orchestrator` runs `verify-modules-signature` with **`--payload-from-filesystem --enforce-version-bump`** and does **not** pass **`--require-signature` by default** (checksum + version bump only). **Strict `--require-signature`** applies when the integration target is **`main`** (pushes to `main` and PRs whose base is `main`). Add `--require-signature` locally when you want the same bar as **`main`** before promotion. Approval-time **`sign-modules-on-approval`** signs with `scripts/sign-modules.py` using **`--payload-from-filesystem`** among other flags; if verification fails after merge, re-sign affected **`module-package.yaml`** files and bump versions as needed. Pre-commit runs `scripts/pre-commit-verify-modules-signature.sh`, mirroring CI (**`--require-signature`** on branch **`main`** or when **`GITHUB_BASE_REF=main`** in Actions). **CI signing:** Approved PRs to `dev` or `main` from **this repository** (not forks) run `.github/workflows/sign-modules-on-approval.yml`, which can commit signed manifests using repository secrets. See [Module signing](./docs/authoring/module-signing.md). @@ -59,7 +59,7 @@ pre-commit install pre-commit run --all-files ``` -**Code review gate (matches specfact-cli core):** runs in **Block 2** after the module verify hook and Block 1 quality hooks (`pre-commit-quality-checks.sh block2`, which calls `scripts/pre_commit_code_review.py`). Staged paths under `packages/`, `registry/`, `scripts/`, `tools/`, `tests/`, and `openspec/changes/` are eligible; `openspec/changes/**/TDD_EVIDENCE.md` is excluded from the gate. OpenSpec Markdown other than evidence files is not passed to SpecFact (the review CLI treats paths as Python). The helper runs `specfact code review run --json --out .specfact/code-review.json` on the remaining paths and prints only a short findings summary and copy-paste prompts on stderr. Block 1 is split into separate pre-commit hooks so output appears between stages instead of buffering until the end. Requires a local **specfact-cli** install (`hatch run dev-deps` resolves sibling `../specfact-cli` or `SPECFACT_CLI_REPO`). +**Code review gate (matches specfact-cli core):** runs in **Block 2** after the module verify hook and Block 1 quality hooks (`pre-commit-quality-checks.sh block2`, which calls `scripts/pre_commit_code_review.py`). Staged paths under `packages/`, `registry/`, `scripts/`, `tools/`, `tests/`, and `openspec/changes/` are eligible; `openspec/changes/**/TDD_EVIDENCE.md` is excluded from the gate. Only staged **`.py` / `.pyi`** files are forwarded to SpecFact (YAML, registry tarballs, and similar are skipped). The hook blocks the commit when the JSON report contains **error**-severity findings; warning-only outcomes do not block. The helper runs `specfact code review run --json --out .specfact/code-review.json` on those Python paths and prints a short findings summary on stderr. Full CLI options (`--mode`, `--focus`, `--level`, `--bug-hunt`, etc.) are documented under [Code review module](./docs/modules/code-review.md). Block 1 is split into separate pre-commit hooks so output appears between stages instead of buffering until the end. Requires a local **specfact-cli** install (`hatch run dev-deps` resolves sibling `../specfact-cli` or `SPECFACT_CLI_REPO`). Scope notes: - Pre-commit runs `hatch run lint` in the **Block 1 — lint** hook when any staged path matches `*.py` / `*.pyi`, matching the CI quality job (Ruff alone does not run pylint). diff --git a/docs/modules/code-review.md b/docs/modules/code-review.md index d5c188f..cba6db8 100644 --- a/docs/modules/code-review.md +++ b/docs/modules/code-review.md @@ -37,6 +37,22 @@ Options: findings such as test-scope contract noise - `--interactive`: ask whether changed test files should be included before auto-detected review runs +- `--bug-hunt`: use longer CrossHair budgets (`--per_path_timeout 10`, subprocess + timeout 120s) for deeper counterexample search; other tools keep normal speed +- `--mode shadow|enforce`: **enforce** (default) keeps today’s non-zero process + exit when the governed report says the run failed; **shadow** still runs the + full toolchain and preserves `overall_verdict` in JSON, but forces + `ci_exit_code` and the process exit code to `0` so CI or hooks can log signal + without blocking +- `--focus`: repeatable facet filter applied after scope resolution; values are + `source` (non-test, non-docs Python), `tests` (paths with a `tests/` segment), + and `docs` (Python under a `docs/` directory segment). Multiple `--focus` + values **union** their file sets, then intersect with the resolved scope. When + any `--focus` is set, **`--include-tests` and `--exclude-tests` are rejected** + (use focus alone to express test intent) +- `--level error|warning`: drop findings below the chosen severity **before** + scoring and report construction so JSON, tables, score, verdict, and + `ci_exit_code` all match the filtered list. Omit to keep all severities When `FILES` is omitted, the command falls back to: @@ -80,8 +96,10 @@ findings such as: ### Exit codes -- `0`: `PASS` or `PASS_WITH_ADVISORY` -- `1`: `FAIL` +- `0`: `PASS` or `PASS_WITH_ADVISORY`, or any outcome under **`--mode shadow`** + (shadow forces success at the process level even when `overall_verdict` is + `FAIL`) +- `1`: `FAIL` under default **enforce** semantics - `2`: invalid CLI usage, such as a missing file path or incompatible options ### Output modes @@ -249,6 +267,14 @@ Additional behavior: - semgrep rule IDs emitted with path prefixes are normalized back to the governed rule IDs above - malformed output, a missing `results` list, or a missing Semgrep executable yields a single `tool_error` finding +### Semgrep bug-rules pass + +After the clean-code Semgrep pass, the orchestrator runs +`specfact_code_review.tools.semgrep_runner.run_semgrep_bugs(files)`, which uses +`packages/specfact-code-review/.semgrep/bugs.yaml` when present. Findings are +mapped to `security` or `correctness`. If the config file is missing, the pass +is skipped with no error. + ### Contract runner `specfact_code_review.tools.contract_runner.run_contract_check(files)` combines two @@ -261,20 +287,26 @@ AST scan behavior: - only public module-level and class-level functions are checked - functions prefixed with `_` are treated as private and skipped +- the AST scan for `MISSING_ICONTRACT` runs **only when the file imports + `icontract`** (`from icontract …` or `import icontract`). Files that never + reference icontract skip the decorator scan and rely on CrossHair only - missing icontract decorators become `contracts` findings with rule - `MISSING_ICONTRACT` + `MISSING_ICONTRACT` when the scan runs - unreadable or invalid Python files degrade to a single `tool_error` finding instead of raising CrossHair behavior: ```bash -crosshair check --per_path_timeout 2 +crosshair check --per_path_timeout 2 # default +crosshair check --per_path_timeout 10 # with CLI --bug-hunt ``` - CrossHair counterexamples map to `contracts` warnings with tool `crosshair` - timeouts are skipped so the AST scan can still complete - missing CrossHair binaries degrade to a single `tool_error` finding +- with **`--bug-hunt`**, the per-path timeout is **10** seconds and the + subprocess budget is **120** seconds instead of **2** / **30** Operational note: @@ -374,12 +406,13 @@ bundle runners in this order: 1. Ruff 2. Radon -3. Semgrep -4. AST clean-code checks -5. basedpyright -6. pylint -7. contract runner -8. TDD gate, unless `no_tests=True` +3. Semgrep (clean-code ruleset) +4. Semgrep bug rules (`.semgrep/bugs.yaml`, skipped if absent) +5. AST clean-code checks +6. basedpyright +7. pylint +8. contract runner (AST + CrossHair; optional bug-hunt timeouts) +9. TDD gate, unless `no_tests=True` When `SPECFACT_CODE_REVIEW_PR_MODE=1` is present, the runner also evaluates a bundle-local advisory PR checklist from `SPECFACT_CODE_REVIEW_PR_TITLE`, diff --git a/docs/reference/module-security.md b/docs/reference/module-security.md index 5edfb5e..12d187b 100644 --- a/docs/reference/module-security.md +++ b/docs/reference/module-security.md @@ -45,9 +45,10 @@ Module packages carry **publisher** and **integrity** metadata so installation, - **CI secrets**: - `SPECFACT_MODULE_PRIVATE_SIGN_KEY` - `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` -- **Verification command**: - - Default strict local / **main** check: `scripts/verify-modules-signature.py --require-signature --payload-from-filesystem --enforce-version-bump` - - **Dev / feature parity with CI** (checksum + version bump, signature optional): omit `--require-signature` (see `pr-orchestrator` and `scripts/pre-commit-verify-modules-signature.sh`). +- **Verification command** (`scripts/verify-modules-signature.py`): + - **Strict** (signatures required): `--require-signature --enforce-version-bump --payload-from-filesystem` (and optional `--version-check-base ` in CI), same idea as the **specfact-cli** docs for `verify-modules-signature.py`. + - **`--metadata-only`**: validates manifest shape (`integrity.checksum` format; optional `integrity.signature` presence when `--require-signature`) **without** hashing the bundle or verifying crypto — for **local pre-commit** on non-`main` branches only. **CI** (`.github/workflows/pr-orchestrator.yml`) always runs the **full** verifier without `--metadata-only`. +- **Pre-commit** (this repo): `scripts/pre-commit-verify-modules-signature.sh` follows the same **`require` / `omit`** policy shape as **specfact-cli** `scripts/pre-commit-verify-modules.sh`, driven by `scripts/git-branch-module-signature-flag.sh`. Here, `omit` maps to `--metadata-only` so developers are not forced to re-sign locally; **specfact-cli** `omit` still runs **full checksum** verification against paths under `modules/` / `src/specfact_cli/modules/`. - `--version-check-base ` is used for PR comparisons in CI. - **CI signing**: Approved same-repo PRs to `dev` or `main` may receive automated signing commits via `sign-modules-on-approval.yml` (repository secrets; merge-base scoped `--changed-only`). diff --git a/openspec/changes/code-review-bug-finding-and-sidecar-venv-fix/TDD_EVIDENCE.md b/openspec/changes/code-review-bug-finding-and-sidecar-venv-fix/TDD_EVIDENCE.md index 9ffc984..5902d26 100644 --- a/openspec/changes/code-review-bug-finding-and-sidecar-venv-fix/TDD_EVIDENCE.md +++ b/openspec/changes/code-review-bug-finding-and-sidecar-venv-fix/TDD_EVIDENCE.md @@ -23,7 +23,7 @@ - For KISS/radon changes in the editable module to be exercised, link the dev module before CLI review: - `hatch run python scripts/link_dev_module.py specfact-code-review --force` - Full-repo JSON report: `hatch run specfact code review run --json --out .specfact/code-review.json` - - After dev link: **0 error-severity** findings; remaining items are **warnings** (historical KISS/complexity across the repo). Process exit code may remain non-zero when warnings drive verdict policy. + - After dev link: **0 error-severity** findings; remaining items are **warnings** (historical KISS/complexity across the repo). The pre-commit / quality gate exit policy is **error-severity only**: **warnings do not block**—only **error**-severity findings affect the CI exit code. - Scoped check on primary touched sources (Typer `run`, `radon_runner`, `run/commands`, FastAPI/Flask extractors): `PASS_WITH_ADVISORY`, **`ci_exit_code` 0**, report at `.specfact/code-review-touch.json`. ## Registry diff --git a/packages/specfact-code-review/.semgrep/bugs.yaml b/packages/specfact-code-review/.semgrep/bugs.yaml index 20a9545..536c387 100644 --- a/packages/specfact-code-review/.semgrep/bugs.yaml +++ b/packages/specfact-code-review/.semgrep/bugs.yaml @@ -29,9 +29,13 @@ rules: - id: specfact-bugs-yaml-unsafe languages: [python] - message: yaml.load without Loader= can execute arbitrary objects; use yaml.safe_load. + message: > + yaml.load(...) without an explicit Loader= uses unsafe defaults; use yaml.safe_load() or pass + Loader= explicitly (e.g. SafeLoader/FullLoader). severity: WARNING - pattern: yaml.load(...) + patterns: + - pattern: yaml.load(...) + - pattern-not-regex: yaml\.load\([^)]*Loader\s*= metadata: specfact-category: security diff --git a/packages/specfact-code-review/module-package.yaml b/packages/specfact-code-review/module-package.yaml index 3b59f33..95efd65 100644 --- a/packages/specfact-code-review/module-package.yaml +++ b/packages/specfact-code-review/module-package.yaml @@ -23,4 +23,4 @@ description: Official SpecFact code review bundle package. category: codebase bundle_group_command: code integrity: - checksum: sha256:0631d016bbdab90f30ea7c1ebdc68407c964be3983aee03cabf3d38b58d42fa4 + checksum: sha256:aab4cba70012af43ef8451412b7d45fa70e5bd460eec02fc0523392b45d06b48 diff --git a/packages/specfact-code-review/src/specfact_code_review/run/commands.py b/packages/specfact-code-review/src/specfact_code_review/run/commands.py index 3271831..8b89499 100644 --- a/packages/specfact-code-review/src/specfact_code_review/run/commands.py +++ b/packages/specfact-code-review/src/specfact_code_review/run/commands.py @@ -71,7 +71,7 @@ def _filter_files_by_focus(files: list[Path], facets: tuple[str, ...]) -> list[P return files def _matches_focus(file_path: Path, facet: str) -> bool: - if file_path.suffix != ".py": + if file_path.suffix not in (".py", ".pyi"): return False if facet == "tests": return _is_test_file(file_path) @@ -115,7 +115,7 @@ def _changed_files_from_git_diff(*, include_tests: bool) -> list[Path]: python_files = [ file_path for file_path in [*tracked_files, *untracked_files] - if file_path.suffix == ".py" and file_path.is_file() and not _is_ignored_review_path(file_path) + if file_path.suffix in (".py", ".pyi") and file_path.is_file() and not _is_ignored_review_path(file_path) ] deduped_python_files = list(dict.fromkeys(python_files)) if include_tests: @@ -135,7 +135,7 @@ def _all_python_files_from_git() -> list[Path]: python_files = [ file_path for file_path in [*tracked_files, *untracked_files] - if file_path.suffix == ".py" and file_path.is_file() and not _is_ignored_review_path(file_path) + if file_path.suffix in (".py", ".pyi") and file_path.is_file() and not _is_ignored_review_path(file_path) ] return list(dict.fromkeys(python_files)) @@ -472,7 +472,6 @@ def _build_review_run_request( raise ValueError("files must contain only Path instances") request_kwargs = dict(kwargs) - had_include_tests_key = "include_tests" in request_kwargs # Validate and extract known boolean flags with proper type checking def _get_bool_param(name: str, default: bool = False) -> bool: @@ -509,7 +508,7 @@ def _get_optional_param(name: str, validator: Callable[[object], object], defaul out = cast(Path | None, out_value) focus_facets = cast(tuple[str, ...], _as_focus_facets(request_kwargs.pop("focus_facets", None))) - if focus_facets and had_include_tests_key: + if focus_facets and include_tests: raise ValueError("Cannot combine focus_facets with include_tests; use --focus alone to scope files.") request = ReviewRunRequest( @@ -578,9 +577,10 @@ def run_command( ) _validate_review_request(request) + include_for_resolve = request.include_tests or ("tests" in request.focus_facets) resolved_files = _resolve_files( request.files, - include_tests=request.include_tests, + include_tests=include_for_resolve, scope=request.scope, path_filters=request.path_filters or [], ) diff --git a/packages/specfact-code-review/src/specfact_code_review/tools/tool_availability.py b/packages/specfact-code-review/src/specfact_code_review/tools/tool_availability.py index efcc01a..a42b2fc 100644 --- a/packages/specfact-code-review/src/specfact_code_review/tools/tool_availability.py +++ b/packages/specfact-code-review/src/specfact_code_review/tools/tool_availability.py @@ -35,6 +35,11 @@ "pytest": "pytest", } +# Pytest is listed in REVIEW_TOOL_PIP_PACKAGES for documentation parity with module-package.yaml, but it is +# intentionally omitted here: skip_if_tool_missing() only consults _EXECUTABLE_ON_PATH and would treat a +# stray `pytest` script on PATH as “tool present” even when the review interpreter cannot import pytest. +# TDD coverage instead uses skip_if_pytest_unavailable(), which probes importlib.util.find_spec("pytest") +# and importlib.util.find_spec("pytest_cov") for the active Python environment. _EXECUTABLE_ON_PATH: dict[ReviewToolId, str] = { "ruff": "ruff", "radon": "radon", diff --git a/packages/specfact-codebase/module-package.yaml b/packages/specfact-codebase/module-package.yaml index fea5ecc..5552a70 100644 --- a/packages/specfact-codebase/module-package.yaml +++ b/packages/specfact-codebase/module-package.yaml @@ -1,5 +1,5 @@ name: nold-ai/specfact-codebase -version: 0.41.5 +version: 0.41.6 commands: - code tier: official @@ -24,4 +24,4 @@ description: Official SpecFact codebase bundle package. category: codebase bundle_group_command: code integrity: - checksum: sha256:6c7d032c0db2569148386309f14a73ee481f6e74adc314ef930043728e4b18db + checksum: sha256:a1508cf26a2519eefae9106ad991db5406cdbbb46ba0eefd56068aa32e754f83 diff --git a/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/base.py b/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/base.py index 7b51923..96d7c6e 100644 --- a/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/base.py +++ b/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/base.py @@ -33,12 +33,14 @@ class RouteInfo(BaseModel): class BaseFrameworkExtractor(ABC): """Abstract base class for framework-specific route and schema extractors.""" - _EXCLUDED_DIR_NAMES: frozenset[str] = frozenset({".specfact", ".git", "__pycache__", "node_modules"}) + _EXCLUDED_DIR_NAMES: frozenset[str] = frozenset( + {".specfact", ".git", "__pycache__", "node_modules", "venv", ".venv"} + ) @beartype @staticmethod def _path_touches_excluded_dir(path: Path) -> bool: - """True when any path component is a directory we must not scan (venv, VCS, caches).""" + """True when any path component is an excluded dir (.specfact, venvs, VCS, caches, node_modules).""" return any(part in BaseFrameworkExtractor._EXCLUDED_DIR_NAMES for part in path.parts) @beartype diff --git a/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/fastapi.py b/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/fastapi.py index 0666af9..efd1845 100644 --- a/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/fastapi.py +++ b/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/fastapi.py @@ -17,6 +17,9 @@ from specfact_codebase.validators.sidecar.frameworks.base import BaseFrameworkExtractor, RouteInfo +_FASTAPI_HTTP_VERBS: frozenset[str] = frozenset({"get", "post", "put", "delete", "patch", "head", "options"}) + + class FastAPIExtractor(BaseFrameworkExtractor): """FastAPI framework extractor.""" @@ -143,27 +146,69 @@ def _extract_imports(self, tree: ast.AST) -> dict[str, str]: imports[alias_name] = alias.name return imports + @beartype + def _route_path_from_decorator_call(self, call: ast.Call) -> str | None: + if call.args: + lit = self._extract_string_literal(call.args[0]) + if lit: + return lit + for keyword in call.keywords: + if keyword.arg in ("path", "route") and keyword.value is not None: + lit = self._extract_string_literal(keyword.value) + if lit: + return lit + return None + + @beartype + def _http_methods_from_api_route_keywords(self, call: ast.Call) -> list[str]: + for keyword in call.keywords: + if keyword.arg != "methods" or keyword.value is None: + continue + node = keyword.value + if not isinstance(node, (ast.List, ast.Tuple, ast.Set)): + return [] + methods: list[str] = [] + for element in node.elts: + raw = self._extract_string_literal(element) + if raw is None: + continue + lowered = raw.lower() + if lowered in _FASTAPI_HTTP_VERBS: + methods.append(lowered.upper()) + return methods + return [] + + @beartype + def _decorator_route_name(self, decorator: ast.expr) -> str | None: + if isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Attribute): + return decorator.func.attr.lower() + if isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Name): + return decorator.func.id.lower() + return None + @beartype def _path_method_from_route_decorator(self, decorator: ast.expr, path: str, method: str) -> tuple[str, str]: if not isinstance(decorator, ast.Call): return path, method - func = decorator.func - if isinstance(func, ast.Attribute): - next_method = func.attr.upper() - next_path = path - if decorator.args: - lit = self._extract_string_literal(decorator.args[0]) - if lit: - next_path = lit - return next_path, next_method - if isinstance(func, ast.Name): - next_method = func.id.upper() - next_path = path - if decorator.args: - lit = self._extract_string_literal(decorator.args[0]) - if lit: - next_path = lit - return next_path, next_method + name = self._decorator_route_name(decorator) + if name is None: + return path, method + + if name == "api_route": + extracted_path = self._route_path_from_decorator_call(decorator) + if extracted_path is not None: + path = extracted_path + methods = self._http_methods_from_api_route_keywords(decorator) + if methods: + method = methods[0] + return path, method + + if name in _FASTAPI_HTTP_VERBS: + extracted_path = self._route_path_from_decorator_call(decorator) + if extracted_path is not None: + path = extracted_path + return path, name.upper() + return path, method @beartype diff --git a/registry/index.json b/registry/index.json index 64b4985..e7dae2f 100644 --- a/registry/index.json +++ b/registry/index.json @@ -80,7 +80,7 @@ "id": "nold-ai/specfact-code-review", "latest_version": "0.47.0", "download_url": "modules/specfact-code-review-0.47.0.tar.gz", - "checksum_sha256": "42ea7d2d16c5b500787468d3aef529e7e7ac4d8e21ae2b3b7bd14c802256b0e8", + "checksum_sha256": "7bda277c0c8fb137750ee6b88090e0df929e6e699bf5c1c048d18679890bb347", "core_compatibility": ">=0.44.0,<1.0.0", "tier": "official", "publisher": { diff --git a/registry/modules/specfact-code-review-0.47.0.tar.gz b/registry/modules/specfact-code-review-0.47.0.tar.gz index f34ccc6d0417bec2009b79c07610942320382f5b..7f59324c06575aaa907fe8f6a3dc1ef8420012ad 100644 GIT binary patch delta 35049 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 V29w~31Pqz|`G4Wn>;(XD0{|re1#bWV delta 34816 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

$IJ(ya-fK!Jt?isUdg~b>5_J?- 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%0DXVE&%jK7 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 diff --git a/registry/modules/specfact-code-review-0.47.0.tar.gz.sha256 b/registry/modules/specfact-code-review-0.47.0.tar.gz.sha256 index e80f685..369986c 100644 --- a/registry/modules/specfact-code-review-0.47.0.tar.gz.sha256 +++ b/registry/modules/specfact-code-review-0.47.0.tar.gz.sha256 @@ -1 +1 @@ -42ea7d2d16c5b500787468d3aef529e7e7ac4d8e21ae2b3b7bd14c802256b0e8 +7bda277c0c8fb137750ee6b88090e0df929e6e699bf5c1c048d18679890bb347 diff --git a/scripts/git-branch-module-signature-flag.sh b/scripts/git-branch-module-signature-flag.sh new file mode 100755 index 0000000..99bcdf1 --- /dev/null +++ b/scripts/git-branch-module-signature-flag.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Emit module signature policy for the current context (consumed by pre-commit-verify-modules-signature.sh). +# +# Contract matches specfact-cli `scripts/git-branch-module-signature-flag.sh`: print a single token +# "require" on `main`, "omit" elsewhere. This repo additionally treats GITHUB_BASE_REF=main (PRs +# targeting main) as "require" so pre-commit matches integration-target semantics. +set -euo pipefail + +if [[ -n "${GITHUB_BASE_REF:-}" ]]; then + if [[ "${GITHUB_BASE_REF}" == "main" ]]; then + printf '%s\n' "require" + exit 0 + fi + printf '%s\n' "omit" + exit 0 +fi + +branch="" +branch=$(git branch --show-current 2>/dev/null || true) +if [[ -z "${branch}" || "${branch}" == "HEAD" ]]; then + branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true) +fi +if [[ "${branch}" == "main" ]]; then + printf '%s\n' "require" +else + printf '%s\n' "omit" +fi diff --git a/scripts/pre-commit-verify-modules-signature.sh b/scripts/pre-commit-verify-modules-signature.sh index 2fcd3b9..8e5481e 100755 --- a/scripts/pre-commit-verify-modules-signature.sh +++ b/scripts/pre-commit-verify-modules-signature.sh @@ -1,24 +1,39 @@ #!/usr/bin/env bash -# Mirror pr-orchestrator verify-module-signatures policy: require cryptographic signatures only when -# the integration target is `main`. Locally that is branch `main`; in GitHub Actions pull_request* -# contexts use GITHUB_BASE_REF (PR base / target), not GITHUB_REF_NAME (head). +# Pre-commit entry: branch-aware module verify (same policy shape as specfact-cli +# `scripts/pre-commit-verify-modules.sh`, adapted for this repository). +# +# Uses `scripts/git-branch-module-signature-flag.sh` (require | omit). When policy is `require` +# (checkout or PR target is `main`), run full payload + signature verification. When `omit`, +# run `verify-modules-signature.py --metadata-only` so local commits are not blocked by checksum +# drift before CI / approval-time signing refreshes manifests (specfact-cli `omit` still runs full +# checksum verification against bundled modules under modules/). set -euo pipefail + _repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$_repo_root" -_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" -_require_signature=false -if [[ -n "${GITHUB_BASE_REF:-}" ]]; then - if [[ "${GITHUB_BASE_REF}" == "main" ]]; then - _require_signature=true - fi -elif [[ "$_branch" == "main" ]]; then - _require_signature=true +_flag_script="${_repo_root}/scripts/git-branch-module-signature-flag.sh" +if [[ ! -f "${_flag_script}" ]]; then + echo "❌ Missing ${_flag_script}" >&2 + exit 1 fi +sig_policy=$(bash "${_flag_script}") +sig_policy="${sig_policy//$'\r'/}" +sig_policy="${sig_policy//$'\n'/}" _base=(hatch run ./scripts/verify-modules-signature.py --payload-from-filesystem --enforce-version-bump) -if [[ "$_require_signature" == true ]]; then - exec "${_base[@]}" --require-signature -else - exec "${_base[@]}" -fi + +case "${sig_policy}" in + require) + echo "🔐 Verifying module manifests (strict: --require-signature, --enforce-version-bump, --payload-from-filesystem)" >&2 + exec "${_base[@]}" --require-signature + ;; + omit) + echo "🔐 Verifying module manifests (metadata-only for local commits; full verify runs in CI — see docs/reference/module-security.md)" >&2 + exec "${_base[@]}" --metadata-only + ;; + *) + echo "❌ Invalid module signature policy from ${_flag_script}: '${sig_policy}' (expected require or omit)" >&2 + exit 1 + ;; +esac diff --git a/scripts/pre_commit_code_review.py b/scripts/pre_commit_code_review.py index ac592d4..6f0e09d 100755 --- a/scripts/pre_commit_code_review.py +++ b/scripts/pre_commit_code_review.py @@ -207,25 +207,25 @@ def count_findings_by_severity(findings: list[object]) -> dict[str, int]: return buckets -def _print_review_findings_summary(repo_root: Path) -> bool: - """Parse ``REVIEW_JSON_OUT`` and print a one-line findings count (errors / warnings / etc.).""" +def _print_review_findings_summary(repo_root: Path) -> dict[str, int] | None: + """Parse ``REVIEW_JSON_OUT``, print a one-line findings count, and return severity buckets.""" report_path = _report_path(repo_root) if not report_path.is_file(): sys.stderr.write(f"Code review: no report file at {REVIEW_JSON_OUT} (could not print findings summary).\n") - return False + return None try: data = json.loads(report_path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError) as exc: sys.stderr.write(f"Code review: could not read {REVIEW_JSON_OUT}: {exc}\n") - return False + return None except json.JSONDecodeError as exc: sys.stderr.write(f"Code review: invalid JSON in {REVIEW_JSON_OUT}: {exc}\n") - return False + return None findings_raw = data.get("findings") if not isinstance(findings_raw, list): sys.stderr.write(f"Code review: report has no findings list in {REVIEW_JSON_OUT}.\n") - return False + return None counts = count_findings_by_severity(findings_raw) total = len(findings_raw) @@ -249,7 +249,7 @@ def _print_review_findings_summary(repo_root: Path) -> bool: f" Read `{REVIEW_JSON_OUT}` and fix every finding (errors first), using file and line from each entry.\n" ) sys.stderr.write(f" @workspace Open `{REVIEW_JSON_OUT}` and remediate each item in `findings`.\n") - return True + return counts @ensure(lambda result: isinstance(result, tuple) and len(result) == 2) @@ -310,17 +310,11 @@ def main(argv: Sequence[str] | None = None) -> int: return _missing_report_exit_code(report_path, result) # Do not echo nested `specfact code review run` stdout/stderr (verbose tool banners); full report # is in REVIEW_JSON_OUT; we print a short summary on stderr below. - if not _print_review_findings_summary(repo_root): + counts = _print_review_findings_summary(repo_root) + if counts is None: return 1 - try: - data = json.loads(report_path.read_text(encoding="utf-8")) - findings_raw = data.get("findings") - if isinstance(findings_raw, list): - counts = count_findings_by_severity(findings_raw) - if counts["error"] == 0: - return 0 - except (OSError, UnicodeDecodeError, json.JSONDecodeError): - pass + if counts["error"] == 0: + return 0 return result.returncode diff --git a/scripts/validate_agent_rule_applies_when.py b/scripts/validate_agent_rule_applies_when.py old mode 100644 new mode 100755 diff --git a/scripts/verify-modules-signature.py b/scripts/verify-modules-signature.py index 8a75a72..77d2946 100755 --- a/scripts/verify-modules-signature.py +++ b/scripts/verify-modules-signature.py @@ -390,6 +390,27 @@ def verify_manifest( return verification_mode +@beartype +@require(lambda manifest_path: manifest_path.exists(), "manifest_path must exist") +def verify_manifest_metadata_only(manifest_path: Path, *, require_signature: bool) -> None: + """Validate manifest shape only; no payload digest or cryptographic verification.""" + raw = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("manifest YAML must be object") + integrity = raw.get("integrity") + if not isinstance(integrity, dict): + raise ValueError("missing integrity metadata") + checksum = str(integrity.get("checksum", "")).strip() + if not checksum: + raise ValueError("missing integrity.checksum") + _parse_checksum(checksum) + signature = str(integrity.get("signature", "")).strip() + if require_signature and not signature: + raise ValueError("missing integrity.signature") + if signature and len(signature) < 32: + raise ValueError("integrity.signature is present but implausibly short") + + @beartype @ensure(lambda result: result in {0, 1}, "main must return a process exit code") def main() -> int: @@ -422,9 +443,19 @@ def main() -> int: "--payload-from-filesystem." ), ) + parser.add_argument( + "--metadata-only", + action="store_true", + help=( + "Only validate module-package.yaml structure (integrity.checksum format; " + "integrity.signature required when --require-signature). Skips payload digest and " + "cryptographic checks so developers are not forced to re-sign locally; CI must run " + "the full verifier without this flag." + ), + ) args = parser.parse_args() - public_key_pem = _resolve_public_key(args) + public_key_pem = "" if args.metadata_only else _resolve_public_key(args) manifests = _iter_manifests() if not manifests: _emit_line("No module-package.yaml manifests found.") @@ -433,18 +464,25 @@ def main() -> int: failures: list[str] = [] for manifest in manifests: try: - verification_mode = verify_manifest( - manifest, - require_signature=args.require_signature, - public_key_pem=public_key_pem, - payload_from_filesystem=args.payload_from_filesystem, - ) - suffix = ( - " (filesystem payload)" - if verification_mode == "filesystem" and not args.payload_from_filesystem - else "" - ) - _emit_line(f"OK {manifest}{suffix}") + if args.metadata_only: + verify_manifest_metadata_only( + manifest, + require_signature=args.require_signature, + ) + _emit_line(f"OK {manifest} (metadata-only)") + else: + verification_mode = verify_manifest( + manifest, + require_signature=args.require_signature, + public_key_pem=public_key_pem, + payload_from_filesystem=args.payload_from_filesystem, + ) + suffix = ( + " (filesystem payload)" + if verification_mode == "filesystem" and not args.payload_from_filesystem + else "" + ) + _emit_line(f"OK {manifest}{suffix}") except ValueError as exc: failures.append(f"FAIL {manifest}: {exc}") diff --git a/tests/unit/specfact_codebase/test_sidecar_framework_extractors.py b/tests/unit/specfact_codebase/test_sidecar_framework_extractors.py index bb8015c..70f407b 100644 --- a/tests/unit/specfact_codebase/test_sidecar_framework_extractors.py +++ b/tests/unit/specfact_codebase/test_sidecar_framework_extractors.py @@ -18,6 +18,47 @@ def real(): """ +def test_fastapi_extractor_resolves_api_route_methods(tmp_path: Path) -> None: + """api_route(methods=[...]) should yield a canonical HTTP verb, not the decorator name.""" + (tmp_path / "routes.py").write_text( + """ +from fastapi import APIRouter +router = APIRouter() + +@router.api_route("/multi", methods=["GET", "POST"]) +def multi(): + return {"ok": True} +""", + encoding="utf-8", + ) + extractor = FastAPIExtractor() + routes = extractor.extract_routes(tmp_path) + match = next((r for r in routes if r.path == "/multi"), None) + assert match is not None + assert match.method == "GET" + + +def test_fastapi_extractor_ignores_non_http_decorators(tmp_path: Path) -> None: + """Middleware-style decorators must not overwrite method with bogus verb names.""" + (tmp_path / "app.py").write_text( + """ +from fastapi import FastAPI +app = FastAPI() + +@app.middleware("http") +@app.get("/ok") +def ok(): + return {"ok": True} +""", + encoding="utf-8", + ) + extractor = FastAPIExtractor() + routes = extractor.extract_routes(tmp_path) + match = next((r for r in routes if r.path == "/ok"), None) + assert match is not None + assert match.method == "GET" + + def test_fastapi_extractor_ignores_specfact_venv_routes(tmp_path: Path) -> None: """Routes under .specfact/venv must not be counted (sidecar installs deps there).""" (tmp_path / "main.py").write_text(_fake_fastapi_main(), encoding="utf-8") diff --git a/tests/unit/test_git_branch_module_signature_flag_script.py b/tests/unit/test_git_branch_module_signature_flag_script.py new file mode 100644 index 0000000..3b288e2 --- /dev/null +++ b/tests/unit/test_git_branch_module_signature_flag_script.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT_PATH = REPO_ROOT / "scripts" / "git-branch-module-signature-flag.sh" + + +def test_git_branch_module_signature_flag_script_documents_cli_parity() -> None: + text = SCRIPT_PATH.read_text(encoding="utf-8") + assert "specfact-cli" in text + assert "GITHUB_BASE_REF" in text + assert '"require"' in text + assert "omit" in text diff --git a/tests/unit/test_pre_commit_verify_modules_signature_script.py b/tests/unit/test_pre_commit_verify_modules_signature_script.py index a2a31d7..8263d8b 100644 --- a/tests/unit/test_pre_commit_verify_modules_signature_script.py +++ b/tests/unit/test_pre_commit_verify_modules_signature_script.py @@ -6,22 +6,23 @@ REPO_ROOT = Path(__file__).resolve().parents[2] -def test_pre_commit_verify_modules_signature_script_matches_ci_branch_policy() -> None: - text = (REPO_ROOT / "scripts" / "pre-commit-verify-modules-signature.sh").read_text(encoding="utf-8") - assert "git rev-parse --abbrev-ref HEAD" in text - assert "GITHUB_BASE_REF" in text - assert "_branch" in text - assert "_require_signature" in text - assert '== "main"' in text +def test_pre_commit_verify_modules_signature_script_matches_cli_shape() -> None: + text = (REPO_ROOT / "scripts/pre-commit-verify-modules-signature.sh").read_text(encoding="utf-8") + assert "git-branch-module-signature-flag.sh" in text + assert 'case "${sig_policy}" in' in text + assert "require)" in text + assert "omit)" in text assert "--payload-from-filesystem" in text assert "--enforce-version-bump" in text assert "verify-modules-signature.py" in text + assert "--metadata-only" in text - marker = 'if [[ "$_require_signature" == true ]]; then' + marker = 'case "${sig_policy}" in' assert marker in text _head, tail = text.split(marker, 1) assert "--require-signature" not in _head - true_part, from_else = tail.split("else", 1) - false_part = from_else.split("fi", 1)[0] - assert "--require-signature" in true_part - assert "--require-signature" not in false_part + require_block = tail.split("omit)", 1)[0] + assert "--require-signature" in require_block + omit_block = tail.split("omit)", 1)[1].split("*)", 1)[0] + assert "--require-signature" not in omit_block + assert "--metadata-only" in omit_block diff --git a/tests/unit/test_verify_modules_signature_script.py b/tests/unit/test_verify_modules_signature_script.py index 683b042..6c122c8 100644 --- a/tests/unit/test_verify_modules_signature_script.py +++ b/tests/unit/test_verify_modules_signature_script.py @@ -68,3 +68,73 @@ def test_verify_manifest_falls_back_to_filesystem_payload_when_checksum_matches( ) assert verification_mode == "filesystem" + + +def test_verify_manifest_metadata_only_accepts_valid_manifest(tmp_path: Path) -> None: + verify_script = _load_verify_script() + module_dir = tmp_path / "packages" / "specfact-example" + module_dir.mkdir(parents=True) + manifest_path = module_dir / "module-package.yaml" + manifest_path.write_text( + yaml.safe_dump( + { + "name": "nold-ai/specfact-example", + "version": "0.1.0", + "integrity": { + "checksum": "sha256:" + "a" * 64, + "signature": "x" * 64, + }, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + verify_script.verify_manifest_metadata_only(manifest_path, require_signature=False) + + +def test_verify_manifest_metadata_only_rejects_bad_checksum_format(tmp_path: Path) -> None: + verify_script = _load_verify_script() + module_dir = tmp_path / "packages" / "specfact-example" + module_dir.mkdir(parents=True) + manifest_path = module_dir / "module-package.yaml" + manifest_path.write_text( + yaml.safe_dump( + { + "name": "nold-ai/specfact-example", + "version": "0.1.0", + "integrity": {"checksum": "not-a-valid-checksum"}, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + try: + verify_script.verify_manifest_metadata_only(manifest_path, require_signature=False) + except ValueError as exc: + assert "checksum" in str(exc).lower() + else: + raise AssertionError("expected ValueError") + + +def test_verify_manifest_metadata_only_enforces_signature_when_requested(tmp_path: Path) -> None: + verify_script = _load_verify_script() + module_dir = tmp_path / "packages" / "specfact-example" + module_dir.mkdir(parents=True) + manifest_path = module_dir / "module-package.yaml" + manifest_path.write_text( + yaml.safe_dump( + { + "name": "nold-ai/specfact-example", + "version": "0.1.0", + "integrity": {"checksum": "sha256:" + "b" * 64}, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + try: + verify_script.verify_manifest_metadata_only(manifest_path, require_signature=True) + except ValueError as exc: + assert "signature" in str(exc).lower() + else: + raise AssertionError("expected ValueError") diff --git a/tests/unit/workflows/test_sign_modules_on_approval.py b/tests/unit/workflows/test_sign_modules_on_approval.py index c4d0cf4..bbd342e 100644 --- a/tests/unit/workflows/test_sign_modules_on_approval.py +++ b/tests/unit/workflows/test_sign_modules_on_approval.py @@ -52,14 +52,28 @@ def _assert_pull_request_review_submitted(doc: dict[Any, Any]) -> None: assert pr_review["types"] == ["submitted"] -def _assert_sign_job_branch_filters(doc: dict[Any, Any]) -> None: +def _assert_sign_job_has_no_top_level_if(doc: dict[Any, Any]) -> None: job = _sign_modules_job(doc) - job_if = job["if"] - assert isinstance(job_if, str) - assert "github.event.review.state == 'approved'" in job_if - assert "github.event.pull_request.base.ref == 'dev'" in job_if - assert "github.event.pull_request.base.ref == 'main'" in job_if - assert "github.event.pull_request.head.repo.full_name == github.repository" in job_if + assert "if" not in job, "Job-level `if` prevents a stable required check; gating belongs in steps" + + +def _assert_eligibility_gate_step(doc: dict[Any, Any]) -> None: + job = _sign_modules_job(doc) + steps = job["steps"] + assert isinstance(steps, list) + gate = steps[0] + assert isinstance(gate, dict) + assert gate.get("name") == "Eligibility gate (required status check)" + assert gate.get("id") == "gate" + run = gate["run"] + assert isinstance(run, str) + assert "github.event.review.state" in run + assert "approved" in run + assert 'echo "sign=false"' in run + assert 'echo "sign=true"' in run + assert "github.event.pull_request.base.ref" in run + assert "github.event.pull_request.head.repo.full_name" in run + assert "github.repository" in run def _assert_concurrency_and_permissions(doc: dict[Any, Any]) -> None: @@ -76,7 +90,8 @@ def _assert_concurrency_and_permissions(doc: dict[Any, Any]) -> None: def test_sign_modules_on_approval_trigger_and_job_filter() -> None: doc = _parsed_workflow() _assert_pull_request_review_submitted(doc) - _assert_sign_job_branch_filters(doc) + _assert_sign_job_has_no_top_level_if(doc) + _assert_eligibility_gate_step(doc) _assert_concurrency_and_permissions(doc) @@ -116,7 +131,7 @@ def test_sign_modules_on_approval_secrets_guard() -> None: def test_sign_modules_on_approval_sign_step_merge_base() -> None: workflow = _workflow_text() - assert "MERGE_BASE=" in workflow + assert "merge-base" in workflow assert "git merge-base HEAD" in workflow assert 'git fetch origin "${PR_BASE_REF}"' in workflow assert "--no-tags" in workflow @@ -126,6 +141,8 @@ def test_sign_modules_on_approval_sign_step_merge_base() -> None: assert '"$MERGE_BASE"' in workflow assert "--bump-version patch" in workflow assert "--payload-from-filesystem" in workflow + assert "steps.gate.outputs.sign == 'true'" in workflow + assert '--base-ref "origin/' not in workflow def _assert_discover_step_writes_outputs(steps: list[Any]) -> None: @@ -151,8 +168,9 @@ def _assert_job_summary_step(steps: list[Any]) -> None: assert summary.get("if") == "always()" env = summary["env"] assert isinstance(env, dict) - assert env["COMMIT_CHANGED"] == "${{ steps.commit.outputs.changed }}" - assert env["MANIFESTS_COUNT"] == "${{ steps.discover.outputs.manifests_count }}" + assert env["COMMIT_CHANGED"] == "${{ steps.commit.outputs.changed || '' }}" + assert env["MANIFESTS_COUNT"] == "${{ steps.discover.outputs.manifests_count || '' }}" + assert env["GATE_SIGN"] == "${{ steps.gate.outputs.sign }}" summary_run = summary["run"] assert isinstance(summary_run, str) assert "GITHUB_STEP_SUMMARY" in summary_run From a2590cbdc8da8ddc39c99fb196ba41264742aca7 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Wed, 15 Apr 2026 00:54:51 +0200 Subject: [PATCH 3/3] fix tests and logic --- .github/workflows/pr-orchestrator.yml | 31 ++- .pre-commit-config.yaml | 5 + docs/reference/module-security.md | 8 +- .../specfact-code-review/module-package.yaml | 2 +- .../src/specfact_code_review/_review_utils.py | 9 + .../tools/ast_clean_code_runner.py | 4 +- .../tools/basedpyright_runner.py | 3 +- .../tools/contract_runner.py | 11 +- .../tools/pylint_runner.py | 3 +- .../tools/radon_runner.py | 2 + .../specfact_code_review/tools/ruff_runner.py | 3 +- .../validators/sidecar/frameworks/fastapi.py | 249 ++++++++++-------- scripts/pre_commit_code_review.py | 41 +-- scripts/verify-modules-signature.py | 58 ++-- .../scripts/test_pre_commit_code_review.py | 21 +- .../test__review_utils.py | 11 +- .../tools/test_basedpyright_runner.py | 13 +- .../tools/test_radon_runner.py | 11 + .../test_verify_modules_signature_script.py | 13 +- .../workflows/test_pr_orchestrator_signing.py | 38 ++- 20 files changed, 328 insertions(+), 208 deletions(-) diff --git a/.github/workflows/pr-orchestrator.yml b/.github/workflows/pr-orchestrator.yml index a8638d9..2b38717 100644 --- a/.github/workflows/pr-orchestrator.yml +++ b/.github/workflows/pr-orchestrator.yml @@ -80,29 +80,28 @@ jobs: - name: Verify bundled module signatures and version bumps run: | set -euo pipefail - TARGET_BRANCH="" - if [ "${{ github.event_name }}" = "pull_request" ]; then - TARGET_BRANCH="${{ github.event.pull_request.base.ref }}" - else - TARGET_BRANCH="${GITHUB_REF#refs/heads/}" - fi - - BASE_REF="" - if [ "${{ github.event_name }}" = "pull_request" ]; then - BASE_REF="origin/${{ github.event.pull_request.base.ref }}" - fi - if [ -z "${SPECFACT_MODULE_PUBLIC_SIGN_KEY:-}" ] && [ -z "${SPECFACT_MODULE_SIGNING_PUBLIC_KEY_PEM:-}" ]; then echo "warning: no public signing key secret set; verifier must resolve key from repo/default path" fi VERIFY_CMD=(python scripts/verify-modules-signature.py --payload-from-filesystem --enforce-version-bump) - if [ "$TARGET_BRANCH" = "main" ]; then - VERIFY_CMD+=(--require-signature) - fi - if [ -n "$BASE_REF" ]; then + + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE_REF="origin/${{ github.event.pull_request.base.ref }}" + TARGET_BRANCH="${{ github.event.pull_request.base.ref }}" VERIFY_CMD+=(--version-check-base "$BASE_REF") + if [ "$TARGET_BRANCH" = "dev" ]; then + VERIFY_CMD+=(--metadata-only) + elif [ "$TARGET_BRANCH" = "main" ] && \ + [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then + VERIFY_CMD+=(--require-signature) + fi + else + if [ "${{ github.ref_name }}" = "main" ]; then + VERIFY_CMD+=(--require-signature) + fi fi + "${VERIFY_CMD[@]}" quality: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9281007..3fee305 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,10 +19,12 @@ repos: pass_filenames: false always_run: true verbose: true + # pass_filenames: false — same chunking issue as lint; script runs repo-wide yaml-lint once. - id: modules-block1-yaml name: "Block 1 — stage 2/4 — yaml-lint (when YAML staged)" entry: ./scripts/pre-commit-quality-checks.sh block1-yaml language: system + pass_filenames: false files: \.(yaml|yml)$ verbose: true - id: modules-block1-bundle @@ -32,10 +34,13 @@ repos: pass_filenames: false always_run: true verbose: true + # pass_filenames: false — otherwise pre-commit re-invokes this hook per filename chunk (ARG_MAX), + # and each run still executes full-repo `hatch run lint` (wasteful duplicate output). - id: modules-block1-lint name: "Block 1 — stage 4/4 — lint (when Python staged)" entry: ./scripts/pre-commit-quality-checks.sh block1-lint language: system + pass_filenames: false files: \.(py|pyi)$ verbose: true - id: modules-block2 diff --git a/docs/reference/module-security.md b/docs/reference/module-security.md index 12d187b..1060aab 100644 --- a/docs/reference/module-security.md +++ b/docs/reference/module-security.md @@ -46,10 +46,10 @@ Module packages carry **publisher** and **integrity** metadata so installation, - `SPECFACT_MODULE_PRIVATE_SIGN_KEY` - `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` - **Verification command** (`scripts/verify-modules-signature.py`): - - **Strict** (signatures required): `--require-signature --enforce-version-bump --payload-from-filesystem` (and optional `--version-check-base ` in CI), same idea as the **specfact-cli** docs for `verify-modules-signature.py`. - - **`--metadata-only`**: validates manifest shape (`integrity.checksum` format; optional `integrity.signature` presence when `--require-signature`) **without** hashing the bundle or verifying crypto — for **local pre-commit** on non-`main` branches only. **CI** (`.github/workflows/pr-orchestrator.yml`) always runs the **full** verifier without `--metadata-only`. -- **Pre-commit** (this repo): `scripts/pre-commit-verify-modules-signature.sh` follows the same **`require` / `omit`** policy shape as **specfact-cli** `scripts/pre-commit-verify-modules.sh`, driven by `scripts/git-branch-module-signature-flag.sh`. Here, `omit` maps to `--metadata-only` so developers are not forced to re-sign locally; **specfact-cli** `omit` still runs **full checksum** verification against paths under `modules/` / `src/specfact_cli/modules/`. - - `--version-check-base ` is used for PR comparisons in CI. + - **Baseline (PR/CI and local hook)**: `--payload-from-filesystem --enforce-version-bump` — full payload checksum verification plus version-bump enforcement. This is the default integration path **without** `--require-signature` when the target branch is **`dev`** (pull requests to `dev`, or pushes to `dev`). + - **Strict mode**: add `--require-signature` so every manifest must include a verifiable `integrity.signature`. In `.github/workflows/pr-orchestrator.yml` this is appended for **pull requests whose base is `main`** and for **pushes to `main`**, in addition to the baseline flags. Locally, `scripts/pre-commit-verify-modules-signature.sh` adds `--require-signature` only when the checkout (or `GITHUB_BASE_REF` in Actions) is **`main`**; otherwise it runs the same baseline flags only. + - **Pull request CI** also passes `--version-check-base ` (typically `origin/`) so version rules compare against the PR base. + - **CI uses the full verifier** (payload digest + rules above). It does **not** pass `--metadata-only`. The script still supports `--metadata-only` for optional tooling that only needs manifest shape and checksum format checks. - **CI signing**: Approved same-repo PRs to `dev` or `main` may receive automated signing commits via `sign-modules-on-approval.yml` (repository secrets; merge-base scoped `--changed-only`). ## Public key and key rotation diff --git a/packages/specfact-code-review/module-package.yaml b/packages/specfact-code-review/module-package.yaml index 95efd65..05fefe6 100644 --- a/packages/specfact-code-review/module-package.yaml +++ b/packages/specfact-code-review/module-package.yaml @@ -1,5 +1,5 @@ name: nold-ai/specfact-code-review -version: 0.47.0 +version: 0.47.1 commands: - code tier: official diff --git a/packages/specfact-code-review/src/specfact_code_review/_review_utils.py b/packages/specfact-code-review/src/specfact_code_review/_review_utils.py index 33323ad..2da0395 100644 --- a/packages/specfact-code-review/src/specfact_code_review/_review_utils.py +++ b/packages/specfact-code-review/src/specfact_code_review/_review_utils.py @@ -31,6 +31,15 @@ def normalize_path_variants(path_value: str | Path) -> set[str]: return variants +@beartype +@require(lambda files: isinstance(files, list)) +@require(lambda files: all(isinstance(p, Path) for p in files)) +@ensure(lambda result: isinstance(result, list)) +def python_source_paths_for_tools(files: list[Path]) -> list[Path]: + """Paths Python linters and typecheckers should analyze (excludes YAML manifests, etc.).""" + return [path for path in files if path.suffix == ".py"] + + @beartype @require(lambda tool: isinstance(tool, str) and bool(tool.strip())) @require(lambda file_path: isinstance(file_path, Path)) diff --git a/packages/specfact-code-review/src/specfact_code_review/tools/ast_clean_code_runner.py b/packages/specfact-code-review/src/specfact_code_review/tools/ast_clean_code_runner.py index 7b122be..ab5842f 100644 --- a/packages/specfact-code-review/src/specfact_code_review/tools/ast_clean_code_runner.py +++ b/packages/specfact-code-review/src/specfact_code_review/tools/ast_clean_code_runner.py @@ -10,7 +10,7 @@ from beartype import beartype from icontract import ensure, require -from specfact_code_review._review_utils import tool_error +from specfact_code_review._review_utils import python_source_paths_for_tools, tool_error from specfact_code_review.run.findings import ReviewFinding @@ -187,7 +187,7 @@ def _solid_findings(file_path: Path, tree: ast.Module) -> list[ReviewFinding]: def run_ast_clean_code(files: list[Path]) -> list[ReviewFinding]: """Run Python-native AST checks for SOLID, YAGNI, and DRY findings.""" findings: list[ReviewFinding] = [] - for file_path in files: + for file_path in python_source_paths_for_tools(files): try: tree = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path)) except (OSError, SyntaxError) as exc: diff --git a/packages/specfact-code-review/src/specfact_code_review/tools/basedpyright_runner.py b/packages/specfact-code-review/src/specfact_code_review/tools/basedpyright_runner.py index 1c89412..3746b53 100644 --- a/packages/specfact-code-review/src/specfact_code_review/tools/basedpyright_runner.py +++ b/packages/specfact-code-review/src/specfact_code_review/tools/basedpyright_runner.py @@ -10,7 +10,7 @@ from beartype import beartype from icontract import require -from specfact_code_review._review_utils import normalize_path_variants, tool_error +from specfact_code_review._review_utils import normalize_path_variants, python_source_paths_for_tools, tool_error from specfact_code_review.run.findings import ReviewFinding from specfact_code_review.tools.tool_availability import skip_if_tool_missing @@ -89,6 +89,7 @@ def _findings_from_diagnostics(diagnostics: list[object], *, allowed_paths: set[ @require(lambda files: all(isinstance(file_path, Path) for file_path in files), "files must contain Path instances") def run_basedpyright(files: list[Path]) -> list[ReviewFinding]: """Run basedpyright and map diagnostics into ReviewFinding records.""" + files = python_source_paths_for_tools(files) if not files: return [] diff --git a/packages/specfact-code-review/src/specfact_code_review/tools/contract_runner.py b/packages/specfact-code-review/src/specfact_code_review/tools/contract_runner.py index 64b2a8d..d8062f7 100644 --- a/packages/specfact-code-review/src/specfact_code_review/tools/contract_runner.py +++ b/packages/specfact-code-review/src/specfact_code_review/tools/contract_runner.py @@ -10,7 +10,7 @@ from beartype import beartype from icontract import ensure, require -from specfact_code_review._review_utils import normalize_path_variants, tool_error +from specfact_code_review._review_utils import normalize_path_variants, python_source_paths_for_tools, tool_error from specfact_code_review.run.findings import ReviewFinding from specfact_code_review.tools.tool_availability import skip_if_tool_missing @@ -191,12 +191,13 @@ def _run_crosshair(files: list[Path], *, bug_hunt: bool) -> list[ReviewFinding]: ) def run_contract_check(files: list[Path], *, bug_hunt: bool = False) -> list[ReviewFinding]: """Run AST-based contract checks and a CrossHair fast pass for the provided files.""" - if not files: + py_files = python_source_paths_for_tools(files) + if not py_files: return [] findings: list[ReviewFinding] = [] - if _has_icontract_usage(files): - for file_path in files: + if _has_icontract_usage(py_files): + for file_path in py_files: findings.extend(_scan_file(file_path)) - findings.extend(_run_crosshair(files, bug_hunt=bug_hunt)) + findings.extend(_run_crosshair(py_files, bug_hunt=bug_hunt)) return findings diff --git a/packages/specfact-code-review/src/specfact_code_review/tools/pylint_runner.py b/packages/specfact-code-review/src/specfact_code_review/tools/pylint_runner.py index e95e9ee..af333ff 100644 --- a/packages/specfact-code-review/src/specfact_code_review/tools/pylint_runner.py +++ b/packages/specfact-code-review/src/specfact_code_review/tools/pylint_runner.py @@ -10,7 +10,7 @@ from beartype import beartype from icontract import ensure, require -from specfact_code_review._review_utils import normalize_path_variants, tool_error +from specfact_code_review._review_utils import normalize_path_variants, python_source_paths_for_tools, tool_error from specfact_code_review.run.findings import ReviewFinding from specfact_code_review.tools.tool_availability import skip_if_tool_missing @@ -103,6 +103,7 @@ def _result_is_review_findings(result: list[ReviewFinding]) -> bool: @ensure(_result_is_review_findings, "result must contain ReviewFinding instances") def run_pylint(files: list[Path]) -> list[ReviewFinding]: """Run pylint and map message IDs into ReviewFinding records.""" + files = python_source_paths_for_tools(files) if not files: return [] diff --git a/packages/specfact-code-review/src/specfact_code_review/tools/radon_runner.py b/packages/specfact-code-review/src/specfact_code_review/tools/radon_runner.py index 882d83f..4490300 100644 --- a/packages/specfact-code-review/src/specfact_code_review/tools/radon_runner.py +++ b/packages/specfact-code-review/src/specfact_code_review/tools/radon_runner.py @@ -13,6 +13,7 @@ from beartype import beartype from icontract import ensure, require +from specfact_code_review._review_utils import python_source_paths_for_tools from specfact_code_review.run.findings import ReviewFinding from specfact_code_review.tools.tool_availability import skip_if_tool_missing @@ -291,6 +292,7 @@ def _ensure_review_findings(result: list[ReviewFinding]) -> bool: ) def run_radon(files: list[Path]) -> list[ReviewFinding]: """Run Radon for the provided files and map complexity findings into ReviewFinding records.""" + files = python_source_paths_for_tools(files) if not files: return [] diff --git a/packages/specfact-code-review/src/specfact_code_review/tools/ruff_runner.py b/packages/specfact-code-review/src/specfact_code_review/tools/ruff_runner.py index 2350fb3..c4da5ca 100644 --- a/packages/specfact-code-review/src/specfact_code_review/tools/ruff_runner.py +++ b/packages/specfact-code-review/src/specfact_code_review/tools/ruff_runner.py @@ -10,7 +10,7 @@ from beartype import beartype from icontract import ensure, require -from specfact_code_review._review_utils import normalize_path_variants, tool_error +from specfact_code_review._review_utils import normalize_path_variants, python_source_paths_for_tools, tool_error from specfact_code_review.run.findings import ReviewFinding from specfact_code_review.tools.tool_availability import skip_if_tool_missing @@ -97,6 +97,7 @@ def _result_is_review_findings(result: list[ReviewFinding]) -> bool: @ensure(_result_is_review_findings, "result must contain ReviewFinding instances") def run_ruff(files: list[Path]) -> list[ReviewFinding]: """Run Ruff for the provided files and map findings into ReviewFinding records.""" + files = python_source_paths_for_tools(files) if not files: return [] diff --git a/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/fastapi.py b/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/fastapi.py index efd1845..46cc37f 100644 --- a/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/fastapi.py +++ b/packages/specfact-codebase/src/specfact_codebase/validators/sidecar/frameworks/fastapi.py @@ -17,7 +17,62 @@ from specfact_codebase.validators.sidecar.frameworks.base import BaseFrameworkExtractor, RouteInfo -_FASTAPI_HTTP_VERBS: frozenset[str] = frozenset({"get", "post", "put", "delete", "patch", "head", "options"}) +_ROUTE_HTTP_METHODS = frozenset( + {"get", "post", "put", "delete", "patch", "options", "head", "trace"}, +) + +_EXCLUDED_DIR_PARTS = frozenset( + { + ".specfact", + ".git", + "__pycache__", + "node_modules", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "venv", + ".venv", + }, +) + + +def _should_skip_path_for_fastapi_scan(path: Path, root: Path) -> bool: + """True when ``path`` lies under a directory we must not scan (venvs, caches, etc.).""" + try: + parts = path.resolve().relative_to(root.resolve()).parts + except ValueError: + return True + return any(part in _EXCLUDED_DIR_PARTS for part in parts) + + +def _iter_scan_python_files(search_path: Path): + """Yield ``*.py`` files under ``search_path``, skipping excluded directory trees.""" + root = search_path.resolve() + for path in search_path.rglob("*.py"): + if _should_skip_path_for_fastapi_scan(path, root): + continue + yield path + + +def _content_suggests_fastapi(content: str) -> bool: + return "from fastapi import" in content or "FastAPI(" in content + + +def _read_text_if_exists(path: Path) -> str | None: + try: + return path.read_text(encoding="utf-8") + except (UnicodeDecodeError, PermissionError): + return None + + +def _scan_known_app_files(search_path: Path) -> bool: + for py_file in _iter_scan_python_files(search_path): + if py_file.name not in {"main.py", "app.py"}: + continue + content = _read_text_if_exists(py_file) + if content is not None and _content_suggests_fastapi(content): + return True + return False class FastAPIExtractor(BaseFrameworkExtractor): @@ -29,36 +84,27 @@ class FastAPIExtractor(BaseFrameworkExtractor): @ensure(lambda result: isinstance(result, bool), "Must return bool") def detect(self, repo_path: Path) -> bool: """ - Detect if FastAPI is used in the repository. + Detect if this framework is used in the repository. Args: repo_path: Path to repository root Returns: - True if FastAPI is detected + True if this framework is detected """ for candidate_file in ["main.py", "app.py"]: file_path = repo_path / candidate_file - if file_path.exists(): - try: - content = file_path.read_text(encoding="utf-8") - if "from fastapi import" in content or "FastAPI(" in content: - return True - except (UnicodeDecodeError, PermissionError): - continue + if not file_path.exists(): + continue + if _should_skip_path_for_fastapi_scan(file_path, repo_path.resolve()): + continue + content = _read_text_if_exists(file_path) + if content is not None and _content_suggests_fastapi(content): + return True - # Check in common locations for search_path in [repo_path, repo_path / "src", repo_path / "app", repo_path / "backend" / "app"]: - if not search_path.exists(): - continue - for py_file in self._iter_python_files(search_path): - if py_file.name in ["main.py", "app.py"]: - try: - content = py_file.read_text(encoding="utf-8") - if "from fastapi import" in content or "FastAPI(" in content: - return True - except (UnicodeDecodeError, PermissionError): - continue + if search_path.exists() and _scan_known_app_files(search_path): + return True return False @@ -68,21 +114,20 @@ def detect(self, repo_path: Path) -> bool: @ensure(lambda result: isinstance(result, list), "Must return list") def extract_routes(self, repo_path: Path) -> list[RouteInfo]: """ - Extract routes from FastAPI route files. + Extract route information from framework-specific patterns. Args: repo_path: Path to repository root Returns: - List of RouteInfo objects + List of RouteInfo objects with extracted routes """ results: list[RouteInfo] = [] - # Find FastAPI app files for search_path in [repo_path, repo_path / "src", repo_path / "app", repo_path / "backend" / "app"]: if not search_path.exists(): continue - for py_file in self._iter_python_files(search_path): + for py_file in _iter_scan_python_files(search_path): try: routes = self._extract_routes_from_file(py_file) results.extend(routes) @@ -97,17 +142,17 @@ def extract_routes(self, repo_path: Path) -> list[RouteInfo]: @ensure(lambda result: isinstance(result, dict), "Must return dict") def extract_schemas(self, repo_path: Path, routes: list[RouteInfo]) -> dict[str, dict[str, Any]]: """ - Extract schemas from Pydantic models for routes. + Extract request/response schemas from framework-specific patterns. Args: - repo_path: Path to repository root - routes: List of extracted routes + repo_path: Path to repository root (reserved for future schema mining) + routes: List of extracted routes (reserved for future schema mining) Returns: Dictionary mapping route identifiers to schema dictionaries """ - # Simplified schema extraction - full implementation would parse Pydantic models - # For now, return empty dict - can be enhanced later + _ = (repo_path, routes) + # Placeholder until Pydantic schema mining is implemented. return {} @beartype @@ -119,116 +164,93 @@ def _extract_routes_from_file(self, py_file: Path) -> list[RouteInfo]: except (SyntaxError, UnicodeDecodeError, PermissionError): return [] - imports = self._extract_imports(tree) results: list[RouteInfo] = [] for node in ast.walk(tree): if isinstance(node, ast.FunctionDef): - route_info = self._extract_route_from_function(node, imports, py_file) + route_info = self._extract_route_from_function(node) if route_info: results.append(route_info) return results @beartype - def _extract_imports(self, tree: ast.AST) -> dict[str, str]: - """Extract import statements from AST.""" - imports: dict[str, str] = {} - for node in ast.walk(tree): - if isinstance(node, ast.ImportFrom): - module = node.module or "" - for alias in node.names: - alias_name = alias.asname or alias.name - imports[alias_name] = f"{module}.{alias.name}" - elif isinstance(node, ast.Import): - for alias in node.names: - alias_name = alias.asname or alias.name - imports[alias_name] = alias.name - return imports - - @beartype - def _route_path_from_decorator_call(self, call: ast.Call) -> str | None: - if call.args: - lit = self._extract_string_literal(call.args[0]) - if lit: - return lit - for keyword in call.keywords: - if keyword.arg in ("path", "route") and keyword.value is not None: - lit = self._extract_string_literal(keyword.value) - if lit: - return lit + def _path_method_from_route_call(self, decorator: ast.Call) -> tuple[str, str] | None: + """If ``decorator`` is ``@app.get`` / ``@router.post`` / …, return ``(METHOD, path)``.""" + if isinstance(decorator.func, ast.Attribute): + attr = decorator.func.attr.lower() + if attr not in _ROUTE_HTTP_METHODS: + return None + path = "/" + if decorator.args: + path_arg = self._extract_string_literal(decorator.args[0]) + if path_arg: + path = path_arg + return attr.upper(), path + if isinstance(decorator.func, ast.Name): + name = decorator.func.id.lower() + if name not in _ROUTE_HTTP_METHODS: + return None + path = "/" + if decorator.args: + path_arg = self._extract_string_literal(decorator.args[0]) + if path_arg: + path = path_arg + return name.upper(), path return None @beartype - def _http_methods_from_api_route_keywords(self, call: ast.Call) -> list[str]: - for keyword in call.keywords: - if keyword.arg != "methods" or keyword.value is None: + def _path_method_from_api_route_call(self, decorator: ast.Call) -> tuple[str, str] | None: + """If ``decorator`` is ``@router.api_route(path, methods=[...])``, return first method + path.""" + if not isinstance(decorator.func, ast.Attribute): + return None + if decorator.func.attr != "api_route": + return None + path = "/" + if decorator.args: + path_arg = self._extract_string_literal(decorator.args[0]) + if path_arg: + path = path_arg + methods: list[str] = [] + for kw in decorator.keywords: + if kw.arg != "methods": continue - node = keyword.value - if not isinstance(node, (ast.List, ast.Tuple, ast.Set)): - return [] - methods: list[str] = [] - for element in node.elts: - raw = self._extract_string_literal(element) - if raw is None: - continue - lowered = raw.lower() - if lowered in _FASTAPI_HTTP_VERBS: - methods.append(lowered.upper()) - return methods - return [] - - @beartype - def _decorator_route_name(self, decorator: ast.expr) -> str | None: - if isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Attribute): - return decorator.func.attr.lower() - if isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Name): - return decorator.func.id.lower() - return None + if isinstance(kw.value, (ast.List, ast.Tuple)): + for elt in kw.value.elts: + lit = self._extract_string_literal(elt) + if lit: + methods.append(lit.strip().upper()) + if not methods: + return "GET", path + return methods[0], path @beartype - def _path_method_from_route_decorator(self, decorator: ast.expr, path: str, method: str) -> tuple[str, str]: - if not isinstance(decorator, ast.Call): - return path, method - name = self._decorator_route_name(decorator) - if name is None: - return path, method - - if name == "api_route": - extracted_path = self._route_path_from_decorator_call(decorator) - if extracted_path is not None: - path = extracted_path - methods = self._http_methods_from_api_route_keywords(decorator) - if methods: - method = methods[0] - return path, method - - if name in _FASTAPI_HTTP_VERBS: - extracted_path = self._route_path_from_decorator_call(decorator) - if extracted_path is not None: - path = extracted_path - return path, name.upper() - - return path, method - - @beartype - def _extract_route_from_function( - self, func_node: ast.FunctionDef, imports: dict[str, str], py_file: Path - ) -> RouteInfo | None: + def _extract_route_from_function(self, func_node: ast.FunctionDef) -> RouteInfo | None: """Extract route information from a function with FastAPI decorators.""" + matched = False path = "/" method = "GET" - operation_id = func_node.name for decorator in func_node.decorator_list: - path, method = self._path_method_from_route_decorator(decorator, path, method) + if not isinstance(decorator, ast.Call): + continue + got = self._path_method_from_route_call(decorator) + if got is None: + got = self._path_method_from_api_route_call(decorator) + if got is None: + continue + matched = True + method, path = got + + if not matched: + return None normalized_path, path_params = self._extract_path_parameters(path) return RouteInfo( path=normalized_path, method=method, - operation_id=operation_id, + operation_id=func_node.name, function=func_node.name, path_params=path_params, ) @@ -246,7 +268,6 @@ def _extract_path_parameters(self, path: str) -> tuple[str, list[dict[str, Any]] path_params: list[dict[str, Any]] = [] normalized_path = path - # FastAPI path parameter pattern: {param_name} or {param_name:type} pattern = r"\{([^}:]+)(?::([^}]+))?\}" matches = list(re.finditer(pattern, path)) diff --git a/scripts/pre_commit_code_review.py b/scripts/pre_commit_code_review.py index 6f0e09d..aac752f 100755 --- a/scripts/pre_commit_code_review.py +++ b/scripts/pre_commit_code_review.py @@ -1,8 +1,7 @@ """Run specfact code review as a staged-file pre-commit gate (modules repo). Writes a machine-readable JSON report to ``.specfact/code-review.json`` (gitignored) -so IDEs and Copilot can read findings. The hook exits non-zero only when the report -contains error-severity findings (warning-only verdicts do not block commits). +so IDEs and Copilot can read findings; exit code still reflects the governed CI verdict. If ``specfact_cli`` is not installed, attempts ``hatch run dev-deps`` / ``ensure_core_dependency`` (sibling ``specfact-cli`` checkout) before failing. @@ -54,6 +53,8 @@ def _is_review_gate_path(path: str) -> bool: normalized = path.replace("\\", "/").strip() if not normalized: return False + if normalized.endswith("module-package.yaml"): + return False if normalized.startswith("openspec/changes/") and Path(normalized).name.casefold() == "tdd_evidence.md": return False prefixes = ( @@ -84,15 +85,15 @@ def filter_review_gate_paths(paths: Sequence[str]) -> list[str]: def _specfact_review_paths(paths: Sequence[str]) -> list[str]: - """Paths to pass to SpecFact ``code review run`` (Python sources only; skip Markdown and binary artifacts).""" + """Paths to pass to SpecFact ``code review run`` (Python sources only; skip Markdown and non-.py/.pyi).""" result: list[str] = [] for raw in paths: normalized = raw.replace("\\", "/").strip() if normalized.startswith("openspec/changes/") and normalized.lower().endswith(".md"): continue - lower = normalized.lower() - if lower.endswith((".py", ".pyi")): - result.append(raw) + if not normalized.endswith((".py", ".pyi")): + continue + result.append(raw) return result @@ -207,25 +208,29 @@ def count_findings_by_severity(findings: list[object]) -> dict[str, int]: return buckets -def _print_review_findings_summary(repo_root: Path) -> dict[str, int] | None: - """Parse ``REVIEW_JSON_OUT``, print a one-line findings count, and return severity buckets.""" +def _print_review_findings_summary(repo_root: Path) -> tuple[bool, int | None]: + """Parse ``REVIEW_JSON_OUT``, print a one-line findings count, return ``(ok, error_count)``.""" report_path = _report_path(repo_root) if not report_path.is_file(): sys.stderr.write(f"Code review: no report file at {REVIEW_JSON_OUT} (could not print findings summary).\n") - return None + return False, None try: data = json.loads(report_path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError) as exc: sys.stderr.write(f"Code review: could not read {REVIEW_JSON_OUT}: {exc}\n") - return None + return False, None except json.JSONDecodeError as exc: sys.stderr.write(f"Code review: invalid JSON in {REVIEW_JSON_OUT}: {exc}\n") - return None + return False, None + + if not isinstance(data, dict): + sys.stderr.write(f"Code review: expected top-level JSON object in {REVIEW_JSON_OUT}.\n") + return False, None findings_raw = data.get("findings") if not isinstance(findings_raw, list): sys.stderr.write(f"Code review: report has no findings list in {REVIEW_JSON_OUT}.\n") - return None + return False, None counts = count_findings_by_severity(findings_raw) total = len(findings_raw) @@ -249,7 +254,7 @@ def _print_review_findings_summary(repo_root: Path) -> dict[str, int] | None: f" Read `{REVIEW_JSON_OUT}` and fix every finding (errors first), using file and line from each entry.\n" ) sys.stderr.write(f" @workspace Open `{REVIEW_JSON_OUT}` and remediate each item in `findings`.\n") - return counts + return True, counts["error"] @ensure(lambda result: isinstance(result, tuple) and len(result) == 2) @@ -290,8 +295,8 @@ def main(argv: Sequence[str] | None = None) -> int: specfact_files = _specfact_review_paths(files) if len(specfact_files) == 0: sys.stdout.write( - "Staged review paths include no Python files (.py/.pyi) for SpecFact " - "(e.g. only Markdown, YAML, or registry bundles); skipping SpecFact code review.\n" + "Staged review paths are only OpenSpec Markdown under openspec/changes/; " + "skipping SpecFact code review (no staged .py/.pyi targets; Markdown is not passed to SpecFact).\n" ) return 0 @@ -310,10 +315,10 @@ def main(argv: Sequence[str] | None = None) -> int: return _missing_report_exit_code(report_path, result) # Do not echo nested `specfact code review run` stdout/stderr (verbose tool banners); full report # is in REVIEW_JSON_OUT; we print a short summary on stderr below. - counts = _print_review_findings_summary(repo_root) - if counts is None: + summary_ok, error_count = _print_review_findings_summary(repo_root) + if not summary_ok or error_count is None: return 1 - if counts["error"] == 0: + if error_count == 0: return 0 return result.returncode diff --git a/scripts/verify-modules-signature.py b/scripts/verify-modules-signature.py index 77d2946..565bc20 100755 --- a/scripts/verify-modules-signature.py +++ b/scripts/verify-modules-signature.py @@ -392,14 +392,19 @@ def verify_manifest( @beartype @require(lambda manifest_path: manifest_path.exists(), "manifest_path must exist") -def verify_manifest_metadata_only(manifest_path: Path, *, require_signature: bool) -> None: - """Validate manifest shape only; no payload digest or cryptographic verification.""" +@ensure(lambda result: result is None, "integrity-shape-only verification raises or returns None") +def verify_manifest_integrity_shape_only( + manifest_path: Path, + *, + require_signature: bool, +) -> None: raw = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) if not isinstance(raw, dict): raise ValueError("manifest YAML must be object") integrity = raw.get("integrity") if not isinstance(integrity, dict): raise ValueError("missing integrity metadata") + checksum = str(integrity.get("checksum", "")).strip() if not checksum: raise ValueError("missing integrity.checksum") @@ -411,9 +416,7 @@ def verify_manifest_metadata_only(manifest_path: Path, *, require_signature: boo raise ValueError("integrity.signature is present but implausibly short") -@beartype -@ensure(lambda result: result in {0, 1}, "main must return a process exit code") -def main() -> int: +def _parse_verify_cli_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--require-signature", action="store_true", help="Require integrity.signature for every manifest" @@ -449,23 +452,19 @@ def main() -> int: help=( "Only validate module-package.yaml structure (integrity.checksum format; " "integrity.signature required when --require-signature). Skips payload digest and " - "cryptographic checks so developers are not forced to re-sign locally; CI must run " - "the full verifier without this flag." + "cryptographic checks so PRs to dev can pass before approval-time signing updates " + "manifests; push to main and fork PRs to main still use the full verifier in CI." ), ) - args = parser.parse_args() + return parser.parse_args() - public_key_pem = "" if args.metadata_only else _resolve_public_key(args) - manifests = _iter_manifests() - if not manifests: - _emit_line("No module-package.yaml manifests found.") - return 0 +def _verify_manifests_for_cli(args: argparse.Namespace, public_key_pem: str, manifests: list[Path]) -> list[str]: failures: list[str] = [] for manifest in manifests: try: if args.metadata_only: - verify_manifest_metadata_only( + verify_manifest_integrity_shape_only( manifest, require_signature=args.require_signature, ) @@ -485,14 +484,31 @@ def main() -> int: _emit_line(f"OK {manifest}{suffix}") except ValueError as exc: failures.append(f"FAIL {manifest}: {exc}") + return failures - version_failures: list[str] = [] - if args.enforce_version_bump: - base_ref = _resolve_version_check_base(args.version_check_base) - try: - version_failures = _verify_version_bumps(base_ref) - except ValueError as exc: - version_failures.append(f"FAIL version-check: {exc}") + +def _version_bump_failures_for_cli(args: argparse.Namespace) -> list[str]: + if not args.enforce_version_bump: + return [] + base_ref = _resolve_version_check_base(args.version_check_base) + try: + return _verify_version_bumps(base_ref) + except ValueError as exc: + return [f"FAIL version-check: {exc}"] + + +@beartype +@ensure(lambda result: result in {0, 1}, "main must return a CLI exit code") +def main() -> int: + args = _parse_verify_cli_args() + public_key_pem = "" if args.metadata_only else _resolve_public_key(args) + manifests = _iter_manifests() + if not manifests: + _emit_line("No module-package.yaml manifests found.") + return 0 + + failures = _verify_manifests_for_cli(args, public_key_pem, manifests) + version_failures = _version_bump_failures_for_cli(args) if failures or version_failures: if failures: diff --git a/tests/unit/scripts/test_pre_commit_code_review.py b/tests/unit/scripts/test_pre_commit_code_review.py index a8e649c..a25ca5c 100644 --- a/tests/unit/scripts/test_pre_commit_code_review.py +++ b/tests/unit/scripts/test_pre_commit_code_review.py @@ -49,6 +49,13 @@ def test_specfact_review_paths_keeps_only_python_sources() -> None: ) == ["tests/test_app.py", "src/pkg/stub.pyi"] +def test_filter_review_gate_paths_excludes_module_package_manifest() -> None: + """module-package.yaml is not Python; it must not trigger the code-review gate.""" + module = _load_script_module() + + assert module.filter_review_gate_paths(["packages/specfact-code-review/module-package.yaml"]) == [] + + def test_filter_review_gate_paths_keeps_contract_relevant_trees() -> None: """Review gate should include staged paths under tooling and contract trees.""" module = _load_script_module() @@ -124,7 +131,7 @@ def _fake_root() -> Path: def _fake_ensure() -> tuple[bool, str | None]: return True, None - def _fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + def _fake_run(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: _write_sample_review_report(repo_root, payload) return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="") @@ -152,11 +159,11 @@ def _fake_root() -> Path: def _fake_ensure() -> tuple[bool, str | None]: return True, None - def _fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + def _fake_run(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: assert "--json" in cmd assert module.REVIEW_JSON_OUT in cmd - assert kwargs.get("cwd") == str(repo_root) - assert kwargs.get("timeout") == 300 + assert _kwargs.get("cwd") == str(repo_root) + assert _kwargs.get("timeout") == 300 _write_sample_review_report(repo_root, SAMPLE_FAIL_REVIEW_REPORT) return subprocess.CompletedProcess(cmd, 1, stdout=".specfact/code-review.json\n", stderr="") @@ -239,9 +246,9 @@ def test_main_timeout_fails_hook(monkeypatch: pytest.MonkeyPatch, capsys: pytest def _fake_ensure() -> tuple[bool, str | None]: return True, None - def _fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: - assert kwargs.get("cwd") == str(repo_root) - assert kwargs.get("timeout") == 300 + def _fake_run(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + assert _kwargs.get("cwd") == str(repo_root) + assert _kwargs.get("timeout") == 300 raise subprocess.TimeoutExpired(cmd, 300) monkeypatch.setattr(module, "ensure_runtime_available", _fake_ensure) diff --git a/tests/unit/specfact_code_review/test__review_utils.py b/tests/unit/specfact_code_review/test__review_utils.py index d886eb4..989c618 100644 --- a/tests/unit/specfact_code_review/test__review_utils.py +++ b/tests/unit/specfact_code_review/test__review_utils.py @@ -2,7 +2,7 @@ from pathlib import Path -from specfact_code_review._review_utils import normalize_path_variants, tool_error +from specfact_code_review._review_utils import normalize_path_variants, python_source_paths_for_tools, tool_error def test_normalize_path_variants_includes_relative_and_resolved_paths(tmp_path: Path) -> None: @@ -16,6 +16,15 @@ def test_normalize_path_variants_includes_relative_and_resolved_paths(tmp_path: assert file_path.resolve().as_posix() in variants +def test_python_source_paths_for_tools_keeps_only_py_suffix(tmp_path: Path) -> None: + py_file = tmp_path / "a.py" + yaml_file = tmp_path / "module-package.yaml" + py_file.write_text("x = 1\n", encoding="utf-8") + yaml_file.write_text("name: t\n", encoding="utf-8") + + assert python_source_paths_for_tools([py_file, yaml_file]) == [py_file] + + def test_tool_error_returns_review_finding_defaults(tmp_path: Path) -> None: file_path = tmp_path / "example.py" file_path.write_text("VALUE = 1\n", encoding="utf-8") diff --git a/tests/unit/specfact_code_review/tools/test_basedpyright_runner.py b/tests/unit/specfact_code_review/tools/test_basedpyright_runner.py index 52ac4e6..db2e500 100644 --- a/tests/unit/specfact_code_review/tools/test_basedpyright_runner.py +++ b/tests/unit/specfact_code_review/tools/test_basedpyright_runner.py @@ -12,7 +12,18 @@ def test_run_basedpyright_returns_empty_for_no_files() -> None: - assert run_basedpyright([]) == [] + assert not run_basedpyright([]) + + +def test_run_basedpyright_skips_yaml_manifests(tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + manifest = tmp_path / "module-package.yaml" + manifest.write_text("name: example\nversion: 1\n", encoding="utf-8") + run_mock = Mock() + monkeypatch.setattr(subprocess, "run", run_mock) + + assert not run_basedpyright([manifest]) + + run_mock.assert_not_called() def test_run_basedpyright_maps_error_diagnostic_to_type_safety(tmp_path: Path, monkeypatch: MonkeyPatch) -> None: diff --git a/tests/unit/specfact_code_review/tools/test_radon_runner.py b/tests/unit/specfact_code_review/tools/test_radon_runner.py index 6a70133..ce0c248 100644 --- a/tests/unit/specfact_code_review/tools/test_radon_runner.py +++ b/tests/unit/specfact_code_review/tools/test_radon_runner.py @@ -11,6 +11,17 @@ from tests.unit.specfact_code_review.tools.helpers import assert_tool_run, completed_process, create_noisy_file +def test_run_radon_returns_empty_when_only_non_python_paths(tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + manifest = tmp_path / "module-package.yaml" + manifest.write_text("name: example\n", encoding="utf-8") + run_mock = Mock() + monkeypatch.setattr(subprocess, "run", run_mock) + + assert not run_radon([manifest]) + + run_mock.assert_not_called() + + def test_run_radon_maps_complexity_thresholds_and_filters_files(tmp_path: Path, monkeypatch: MonkeyPatch) -> None: file_path = tmp_path / "target.py" other_path = tmp_path / "other.py" diff --git a/tests/unit/test_verify_modules_signature_script.py b/tests/unit/test_verify_modules_signature_script.py index 6c122c8..eb25349 100644 --- a/tests/unit/test_verify_modules_signature_script.py +++ b/tests/unit/test_verify_modules_signature_script.py @@ -70,7 +70,7 @@ def test_verify_manifest_falls_back_to_filesystem_payload_when_checksum_matches( assert verification_mode == "filesystem" -def test_verify_manifest_metadata_only_accepts_valid_manifest(tmp_path: Path) -> None: +def test_verify_manifest_integrity_shape_only_accepts_checksum_only_manifest(tmp_path: Path) -> None: verify_script = _load_verify_script() module_dir = tmp_path / "packages" / "specfact-example" module_dir.mkdir(parents=True) @@ -82,17 +82,16 @@ def test_verify_manifest_metadata_only_accepts_valid_manifest(tmp_path: Path) -> "version": "0.1.0", "integrity": { "checksum": "sha256:" + "a" * 64, - "signature": "x" * 64, }, }, sort_keys=False, ), encoding="utf-8", ) - verify_script.verify_manifest_metadata_only(manifest_path, require_signature=False) + verify_script.verify_manifest_integrity_shape_only(manifest_path, require_signature=False) -def test_verify_manifest_metadata_only_rejects_bad_checksum_format(tmp_path: Path) -> None: +def test_verify_manifest_integrity_shape_only_rejects_bad_checksum_format(tmp_path: Path) -> None: verify_script = _load_verify_script() module_dir = tmp_path / "packages" / "specfact-example" module_dir.mkdir(parents=True) @@ -109,14 +108,14 @@ def test_verify_manifest_metadata_only_rejects_bad_checksum_format(tmp_path: Pat encoding="utf-8", ) try: - verify_script.verify_manifest_metadata_only(manifest_path, require_signature=False) + verify_script.verify_manifest_integrity_shape_only(manifest_path, require_signature=False) except ValueError as exc: assert "checksum" in str(exc).lower() else: raise AssertionError("expected ValueError") -def test_verify_manifest_metadata_only_enforces_signature_when_requested(tmp_path: Path) -> None: +def test_verify_manifest_integrity_shape_only_enforces_signature_when_requested(tmp_path: Path) -> None: verify_script = _load_verify_script() module_dir = tmp_path / "packages" / "specfact-example" module_dir.mkdir(parents=True) @@ -133,7 +132,7 @@ def test_verify_manifest_metadata_only_enforces_signature_when_requested(tmp_pat encoding="utf-8", ) try: - verify_script.verify_manifest_metadata_only(manifest_path, require_signature=True) + verify_script.verify_manifest_integrity_shape_only(manifest_path, require_signature=True) except ValueError as exc: assert "signature" in str(exc).lower() else: diff --git a/tests/unit/workflows/test_pr_orchestrator_signing.py b/tests/unit/workflows/test_pr_orchestrator_signing.py index fd81093..d3a0037 100644 --- a/tests/unit/workflows/test_pr_orchestrator_signing.py +++ b/tests/unit/workflows/test_pr_orchestrator_signing.py @@ -6,21 +6,43 @@ REPO_ROOT = Path(__file__).resolve().parents[3] -def test_pr_orchestrator_verify_splits_signature_requirement_by_target_branch() -> None: - workflow = (REPO_ROOT / ".github" / "workflows" / "pr-orchestrator.yml").read_text(encoding="utf-8") +def _workflow_text() -> str: + return (REPO_ROOT / ".github" / "workflows" / "pr-orchestrator.yml").read_text(encoding="utf-8") + +def test_pr_orchestrator_verify_has_core_verifier_flags() -> None: + workflow = _workflow_text() assert "verify-module-signatures" in workflow assert "scripts/verify-modules-signature.py" in workflow assert "--payload-from-filesystem" in workflow assert "--enforce-version-bump" in workflow assert "github.event.pull_request.base.ref" in workflow assert "TARGET_BRANCH" in workflow - assert "GITHUB_REF#refs/heads/" in workflow or "${GITHUB_REF#refs/heads/}" in workflow - branch_guard = 'if [ "$TARGET_BRANCH" = "main" ]; then' + assert "github.ref_name" in workflow + assert "VERIFY_CMD" in workflow + + +def test_pr_orchestrator_verify_pr_to_dev_passes_integrity_shape_flag() -> None: + workflow = _workflow_text() + assert "--metadata-only" in workflow + assert '[ "$TARGET_BRANCH" = "dev" ]' in workflow + assert "github.event.pull_request.head.repo.full_name" in workflow + dev_guard = 'if [ "$TARGET_BRANCH" = "dev" ]; then' + metadata_append = "VERIFY_CMD+=(--metadata-only)" + assert dev_guard in workflow + assert metadata_append in workflow + assert workflow.index(dev_guard) < workflow.index(metadata_append) + + +def test_pr_orchestrator_verify_require_signature_on_main_paths() -> None: + workflow = _workflow_text() + main_ref_guard = '[ "${{ github.ref_name }}" = "main" ]; then' require_append = "VERIFY_CMD+=(--require-signature)" - assert branch_guard in workflow + assert main_ref_guard in workflow assert require_append in workflow - assert workflow.index(branch_guard) < workflow.index(require_append) - assert '[ "$TARGET_BRANCH" = "main" ]' in workflow + assert workflow.count(require_append) == 2 + push_require_block = ( + 'if [ "${{ github.ref_name }}" = "main" ]; then\n VERIFY_CMD+=(--require-signature)' + ) + assert push_require_block in workflow assert "--require-signature" in workflow - assert "VERIFY_CMD" in workflow