diff --git a/.github/workflows/central-review.yml b/.github/workflows/central-review.yml index 799cf9e06..2fe8f3810 100644 --- a/.github/workflows/central-review.yml +++ b/.github/workflows/central-review.yml @@ -429,7 +429,7 @@ jobs: - name: Run independent PydanticAI review and publish current-head verdict env: GH_TOKEN: ${{ steps.noema_write_app.outputs.token }} - PYTHONPATH: ${{ github.workspace }}/reviewer + PYTHONPATH: ${{ github.workspace }}/reviewer:${{ github.workspace }}/packages/noema-core/src NOEMA_REVIEW_TOKEN_SOURCE: noema-github-app NOEMA_LLM_API_URL: ${{ vars.NOEMA_LLM_API_URL }} NOEMA_LLM_MODEL: ${{ vars.NOEMA_LLM_MODEL }} diff --git a/.github/workflows/reviewer-ci.yml b/.github/workflows/reviewer-ci.yml index e04e0c1ea..4363aafb4 100644 --- a/.github/workflows/reviewer-ci.yml +++ b/.github/workflows/reviewer-ci.yml @@ -20,6 +20,7 @@ jobs: timeout-minutes: 30 env: NOEMA_CODEGRAPH_SANDBOX_SOURCE_IMAGE: gcr.io/distroless/java-base-debian13:nonroot + PYTHONPATH: ${{ github.workspace }}/reviewer:${{ github.workspace }}/packages/noema-core/src defaults: run: working-directory: reviewer @@ -50,12 +51,98 @@ jobs: - name: install (hash-pinned dependencies) run: pip install --require-hashes --no-deps -r requirements-ci-hashes.txt + - name: test noema-core (100% line+branch coverage gate) + working-directory: packages/noema-core + run: python -m pytest + + - name: docstring coverage noema-core (100% gate) + working-directory: packages/noema-core + run: python -m interrogate -c pyproject.toml src/noema_core + - name: test (100% line+branch coverage gate) run: python -m pytest - name: docstring coverage (100% gate) run: python -m interrogate -c pyproject.toml noema_reviewer + - name: smoke-test installed reviewer wheel and sdist-to-wheel path + run: | + set -euo pipefail + wheel_dir="$RUNNER_TEMP/noema-reviewer-wheel" + sdist_dir="$RUNNER_TEMP/noema-reviewer-sdist" + sdist_wheel_dir="$RUNNER_TEMP/noema-reviewer-sdist-wheel" + direct_venv="$RUNNER_TEMP/noema-reviewer-install-smoke" + sdist_venv="$RUNNER_TEMP/noema-reviewer-sdist-install-smoke" + mkdir -p "$wheel_dir" "$sdist_dir" "$sdist_wheel_dir" + + python -m pip wheel . --no-deps --no-build-isolation --wheel-dir "$wheel_dir" + direct_wheel="$(find "$wheel_dir" -maxdepth 1 -type f -name 'noema_reviewer-*.whl' -print -quit)" + test -n "$direct_wheel" + + SDIST_DIR="$sdist_dir" SDIST_NAME_FILE="$RUNNER_TEMP/noema-reviewer-sdist-name" python - <<'PY' + import os + from pathlib import Path + from build_backend import build_sdist + + sdist_name = build_sdist(os.environ["SDIST_DIR"]) + Path(os.environ["SDIST_NAME_FILE"]).write_text(sdist_name, encoding="utf-8") + PY + sdist="$sdist_dir/$(cat "$RUNNER_TEMP/noema-reviewer-sdist-name")" + test -f "$sdist" + python -m pip wheel "$sdist" --no-deps --no-build-isolation --wheel-dir "$sdist_wheel_dir" + sdist_wheel="$(find "$sdist_wheel_dir" -maxdepth 1 -type f -name 'noema_reviewer-*.whl' -print -quit)" + test -n "$sdist_wheel" + + for contract in direct sdist; do + if [ "$contract" = direct ]; then + wheel="$direct_wheel" + venv_dir="$direct_venv" + else + wheel="$sdist_wheel" + venv_dir="$sdist_venv" + fi + python -m venv --system-site-packages "$venv_dir" + ( + cd "$RUNNER_TEMP" + PYTHONPATH='' "$venv_dir/bin/python" -m pip install --no-deps "$wheel" + PYTHONPATH='' "$venv_dir/bin/python" - <<'PY' + import hashlib + import os + from pathlib import Path + + import noema_core + import noema_core.agent + import noema_reviewer + from noema_reviewer.cli import parse_args + + canonical_agent = Path(os.environ["GITHUB_WORKSPACE"]) / "packages" / "noema-core" / "src" / "noema_core" / "agent.py" + installed_agent = Path(noema_core.agent.__file__) + assert hashlib.sha256(installed_agent.read_bytes()).digest() == hashlib.sha256(canonical_agent.read_bytes()).digest() + assert noema_core.NOEMA_PERSONA + assert noema_reviewer.build_agent is not None + assert parse_args([]).repo == "" + PY + ) + done + + - name: smoke-test isolated editable reviewer with locked runtime dependencies + run: | + set -euo pipefail + editable_venv="$RUNNER_TEMP/noema-reviewer-editable-smoke" + python -m venv "$editable_venv" + "$editable_venv/bin/python" -m pip install --require-hashes --no-deps -r requirements-ci-hashes.txt + "$editable_venv/bin/python" -m pip install --no-deps -e . + ( + cd "$RUNNER_TEMP" + PYTHONPATH='' "$editable_venv/bin/python" - <<'PY' + import noema_core + import noema_reviewer + + assert noema_core.build_agent is not None + assert noema_reviewer.build_agent is not None + PY + ) + - name: install lock-pinned CodeGraph tooling for sandbox smoke test env: NPM_CONFIG_IGNORE_SCRIPTS: "true" @@ -98,7 +185,7 @@ jobs: source_root="$RUNNER_TEMP/noema-codegraph-smoke" mkdir -p "$source_root" printf 'export const commercialReadiness = true;\n' >"$source_root/example.ts" - PYTHONPATH=. python - <<'PY' + python - <<'PY' import os from noema_reviewer.sandbox import DockerCodeGraphRunner diff --git a/.gitignore b/.gitignore index 910e84693..8fa7fc874 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ exchange-30d.ndjson exchange-30d.ndjson.provenance.json noema-kpi-evidence.json noema-smoke-evidence.json +reviewer/_build_include/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 437fbcb39..916363ee5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- `noema-core` provider-neutral Shared Kernel을 추가하여 이미 해석된 PydanticAI `Model`과 역할별 prompt/schema만 받아 Agent를 구성한다. 문자열 model identifier와 provider discovery·credential·routing·retry·failover는 Shared Kernel 밖에 두고 `Agent(..., retries=0)`으로 repository-local model-attempt authority를 만들지 않는다. Reviewer wheel·sdist·editable 설치는 canonical `packages/noema-core` source를 포함하거나 참조하며 별도 100% coverage·docstring과 clean install smoke로 검증한다. 외부 소비는 immutable versioned publication·exact source identity·SBOM/provenance·licensing/NOTICE·compatibility/rollback evidence 전에는 허용하지 않는다. - Noema reviewer의 strict changed-file evidence를 historical 12-file prefix에서 canonical 80-file CodeGraph scope와 일치시켰다. 13–80 file PR은 선택된 모든 current-head file context를 유지하고 81개 이상은 기존처럼 실패-폐쇄하며, local CodeGraph fallback의 `HOME`·`TEMP`·`TMP`·`TMPDIR`은 ambient host path를 상속하지 않고 실행마다 새 private temporary directory로 격리한다. - Workflow / Task Execution은 untrusted DAG를 execution/plan identity에 결합한 detached immutable snapshot으로 승인하고, validated array bounds 안에서만 task/dependency/state evidence를 읽는다. runnable 선택은 cross-execution·foreign·duplicate·non-canonical evidence, admitted concurrency를 초과한 running state, 성공하지 않은 prerequisite 뒤에 존재하는 causally impossible executed state를 실패-폐쇄하며, 선택 결과는 reservation이나 side-effect authority가 아닌 후보임을 명시한다. Agent Runtime lifecycle·State & Checkpoint·Workflow admission은 null·throwing accessor·revoked proxy 같은 malformed runtime input의 임의 JavaScript 예외를 각 bounded-context domain error로 정규화한다. - State & Checkpoint admission은 accepted/replay 결과와 내부 checkpoint를 모두 caller-owned alias에서 분리한 frozen snapshot으로 반환한다. TypeScript `readonly`만으로는 막을 수 없는 JavaScript 런타임 alias mutation이 승인된 checkpoint authority나 `accepted`/`replay` 분류를 사후 변경하지 못하도록 실패-폐쇄한다. diff --git a/docs/adr/0014-shared-noema-core-package.md b/docs/adr/0014-shared-noema-core-package.md new file mode 100644 index 000000000..4b5e01ead --- /dev/null +++ b/docs/adr/0014-shared-noema-core-package.md @@ -0,0 +1,100 @@ +# ADR-0014: Minimal `noema-core` Shared Kernel for Agent construction + +- **Status:** Proposed +- **Decision owner:** Noema repository governance +- **Scope:** `ContextualWisdomLab/noema` reviewer self-consumption and future versioned consumers + +## Problem + +Noema has multiple bounded-context consumers that need the same PydanticAI `Agent(...)` construction semantics, but those consumers do not share domain authority. Repeating the framework construction call in each consumer creates drift; centralizing model discovery, provider SDKs, credentials, fallback, retry policy, verdict schemas, tools, tenant state, or security policy would instead violate the repository's DDD boundary and duplicate canonical owners. + +The previous branch-local ADR used number `0012`, which now belongs on protected `main` to the runtime bounded-context decision. ADR identity is immutable repository architecture authority, so this decision is renumbered to `0014` rather than retaining two different ADR-0012 documents. + +## Constraints + +- `contextual-orchestrator` owns provider/model discovery, routing, test-time compute, provider/model retry and failover, provider credentials and provider-specific transport policy. +- Noema owns Agent Runtime and its bounded contexts, not foreign product truth. +- Reviewer verdict schema, deterministic gates, GitHub evidence policy and reviewer publication remain reviewer-owned. +- Tenant/application tool authority and domain state stay in their owning product. +- Security isolation, quarantine and outbound-policy authority stay with their canonical owners. +- Mutable branch refs and copied source are not acceptable cross-repository dependencies. +- External adoption requires an immutable versioned publication with exact source identity and compatibility evidence. + +## Alternatives + +### A. Duplicate the construction in every consumer + +Rejected. It preserves local autonomy but guarantees repeated framework wiring and version drift without adding a useful bounded-context distinction. + +### B. Put provider discovery, retry or transport in `noema-core` + +Rejected. That would recreate `contextual-orchestrator` policy inside Noema and would let a Shared Kernel become an ambient provider/model-attempt authority boundary. + +### C. Build an always-on Noema service for every consumer + +Rejected for this phase. A service would add deployment, network, authorization and recovery semantics that are not required to remove the verified same-language construction duplication. Cross-language consumers can be handled through released service/API contracts when a real caller requires them. + +### D. Minimal package with caller-supplied model + +Chosen. `packages/noema-core` owns only a role-neutral Noema persona fragment and a factory that accepts an already-constructed PydanticAI `Model` and calls `Agent(...)` with caller-owned prompt, output and deps types. The factory fixes PydanticAI model-attempt retries to zero instead of exposing a reusable retry knob; orchestration-level retry/failover remains with `contextual-orchestrator`. + +## Decision + +Create `packages/noema-core` as a minimal Shared Kernel with: + +- `NOEMA_PERSONA = "You are Noema"` as a role-neutral identity prefix; +- `build_agent(model, *, system_prompt, output_type=str, deps_type=None)`; +- rejection of string model identifiers so PydanticAI's implicit provider/model inference cannot move discovery into the Shared Kernel; +- no caller-visible `retries` parameter and `Agent(..., retries=0)` at this boundary so the Shared Kernel cannot silently create additional model attempts outside the orchestrator contract. + +`noema-core` deliberately does **not** own: + +- provider SDK construction or endpoint selection; +- credentials, key discovery, model groups, retries or fallback; +- reviewer verdicts, gates or merge authority; +- tool/dependency authorization; +- tenant isolation, domain persistence or foreign truth; +- quarantine, egress or malware/security verdict authority. + +The current PR's only production consumer is `reviewer/noema_reviewer`. Reviewer packaging stages the canonical `packages/noema-core/src/noema_core` source into wheel/sdist builds so the installed reviewer contains the exact shared module without copying a second source tree. Editable installs and CI use the same canonical path. This is a transitional monorepo packaging arrangement, not permission for external repositories to consume the mutable branch. + +## Verification contract + +Before this decision can become `Accepted`, the exact candidate head must prove: + +1. `packages/noema-core` line and branch coverage are 100% and public docstring coverage is 100%. +2. The reviewer retains its existing coverage/docstring gates and behavior. +3. Installed reviewer wheel and sdist-to-wheel smoke tests import both `noema_reviewer` and `noema_core` outside the checkout and prove the installed shared `agent.py` bytes match the canonical source. +4. Evidence-only reviewer imports remain lazy and do not require model construction. +5. String model identifiers fail closed at the Shared Kernel boundary. +6. `build_agent` exposes no retry-policy argument and constructs the PydanticAI agent with model-attempt retries disabled; provider/model retry and failover remain contextual-orchestrator authority. +7. Central review execution receives the canonical package path without moving provider routing authority into Noema. +8. No cross-repository consumer adopts `noema-core` until immutable publication exists. + +## Publication boundary + +A merge of this PR establishes protected source, not an external dependency. External consumption requires the repository's selected immutable publication mechanism to provide all applicable evidence together: + +- semantic version and immutable source commit; +- artifact digest/integrity; +- package/install smoke tests; +- SBOM and provenance; +- licensing/NOTICE compatibility; +- compatibility/migration and rollback guidance. + +After such a release exists, consumers must pin the released version through their own ACL/adapter and regenerate their exact-head acceptance evidence. A mutable Git branch, local path, copied module, or open PR head is never the production dependency. + +## Consequences + +The shared surface stays intentionally small, so framework construction drift is removed without turning Noema into an LLM gateway or a domain super-service. The cost is a transitional reviewer build backend until `noema-core` has its own immutable package publication. That transitional backend must remain bounded, deterministic and covered by installed-artifact tests. + +Removing the retry argument is intentionally restrictive. A consumer that needs a different attempt policy must not add a local convenience knob to the Shared Kernel; it must use the released contextual-orchestrator contract or make a separately reviewed bounded-context decision that does not duplicate provider/model retry authority. + +A future need for cross-language access is a separate architecture decision. It should begin from a real consumer and released contract rather than expanding this package pre-emptively. + +## Follow-up + +- Merge the reviewer self-consumption only after current-head CI, security, reviewer, package and provenance gates pass. +- Publish `noema-core` through the repository-approved immutable mechanism when release evidence is ready. +- Replace transitional monorepo bundling with a normal released dependency after publication. +- Update any future consumer only after verifying its canonical owner boundary and exact released artifact identity. diff --git a/docs/adr/README.md b/docs/adr/README.md index 2ceec6502..58df3e222 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,6 +16,7 @@ ADR은 **왜 이 구조를 선택했는지**를 기록합니다. 구현 상태 | [0010](./0010-private-target-review-auth.md) | Proposed | private review target의 첫 live PR lookup부터 single-repository Noema App token을 사용하고 workflow `GITHUB_TOKEN` cross-repository fallback을 금지한다. | | [0011](./0011-independent-reviewer-governance.md) | Proposed | qualifying formal approval의 eligibility·exact-head·staleness를 검증하고 check/status/scanner/model evidence가 approval을 대체하지 못하게 한다. | | [0012](./0012-runtime-orchestration-bounded-contexts.md) | Proposed | Agent Runtime, Workflow / Task Execution, Tool / Capability, State / Checkpoint, isolation, policy, observability, recovery의 소유권을 분리하고 provider routing·foreign truth·cross-service SQL을 Noema 경계 밖에 둔다. | +| [0014](./0014-shared-noema-core-package.md) | Proposed | role-neutral PydanticAI `Agent(...)` construction만 `packages/noema-core` Shared Kernel로 추출하고 provider routing·credential policy·verdict·tool/deps·tenant truth는 canonical owner에 남긴다. | ## ADR lifecycle diff --git a/packages/noema-core/.gitignore b/packages/noema-core/.gitignore new file mode 100644 index 000000000..4ed85d4a5 --- /dev/null +++ b/packages/noema-core/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.coverage +.pytest_cache/ +*.egg-info/ diff --git a/packages/noema-core/README.md b/packages/noema-core/README.md new file mode 100644 index 000000000..3c58b2a87 --- /dev/null +++ b/packages/noema-core/README.md @@ -0,0 +1,55 @@ +# noema-core + +Provider-neutral PydanticAI `Agent` construction shared by Noema's per-context +consumers. See [`docs/adr/0014-shared-noema-core-package.md`](../../docs/adr/0014-shared-noema-core-package.md) +for the decision and its scope boundary. + +## What this package is + +One function and one role-neutral identity fragment shared without moving +provider or bounded-context authority into Noema: + +- `build_agent(model, *, system_prompt, output_type=str, deps_type=None, retries=3) -> Agent` + constructs an agent around a caller-supplied, already constructed PydanticAI + `Model`. String model names are rejected so provider/model discovery cannot + occur inside the Shared Kernel. +- `NOEMA_PERSONA` is exactly `"You are Noema"`. Consumers compose that stable + identity with their own precise role, organization context, evidence rules, + tool authority and output contract; the Shared Kernel does not assign a + generic role that could weaken a specialized reviewer or runtime agent. + +The injected model is deliberate. `noema-core` does not construct `AsyncOpenAI`, +`OpenAIChatModel`, `OpenAIProvider`, provider credentials, model discovery, +routing or failover. A consuming bounded context may own a transport adapter to +the published `contextual-orchestrator` interface, but that adapter does not +become Shared Kernel authority. + +## What this package explicitly is not + +It does not own a verdict/output schema, tool/deps machinery, credential +resolution or validation policy, provider SDK, routing policy, provider +fallback, or tenant isolation. Those stay with their canonical owners. + +## Status + +Self-consumption only: `reviewer/noema_reviewer` is the sole consumer today. +`noema-core` is not yet published to an immutable package index, so external +consumers must not pin a mutable branch or copy this source. During this +transition the `noema-reviewer` distribution includes `noema_core` from this +single canonical source path through the custom packaging backend. Wheel and +sdist builds stage a bounded snapshot; editable installs keep an ignored +canonical-source view so their package mapping remains valid after the PEP 660 +hook completes. Required `reviewer-ci` runs this package's 100% line/branch and +docstring gates and validates installed distributions outside the checkout. + +Publishing `noema-core` through the repository's selected immutable package +mechanism and moving consumers to a normal versioned dependency are tracked as +follow-ups in the ADR. + +## Develop + +```bash +pip install -e . +python -m pytest # 100% line+branch coverage gate +python -m interrogate -c pyproject.toml src/noema_core # 100% docstring gate +``` diff --git a/packages/noema-core/pyproject.toml b/packages/noema-core/pyproject.toml new file mode 100644 index 000000000..4da0392f4 --- /dev/null +++ b/packages/noema-core/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "noema-core" +version = "0.1.0" +description = "Provider-neutral PydanticAI Agent-construction wiring for Noema's per-context consumers." +requires-python = ">=3.11" +license = "Apache-2.0" +dependencies = [ + "pydantic-ai-slim>=2.9.0,<3", +] + +[dependency-groups] +dev = [ + "pytest>=8.0.0", + "pytest-cov>=5.0.0", + "interrogate>=1.7.0", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +addopts = "--cov=noema_core --cov-branch --cov-report=term-missing --cov-fail-under=100" + +[tool.coverage.run] +source = ["noema_core"] +omit = ["tests/*"] + +[tool.coverage.report] +show_missing = true + +[tool.interrogate] +fail-under = 100 +exclude = ["tests"] diff --git a/packages/noema-core/src/noema_core/__init__.py b/packages/noema-core/src/noema_core/__init__.py new file mode 100644 index 000000000..24c444caa --- /dev/null +++ b/packages/noema-core/src/noema_core/__init__.py @@ -0,0 +1,13 @@ +"""noema-core: shared PydanticAI Agent-construction wiring for Noema consumers. + +See :mod:`noema_core.agent` for the provider-neutral agent factory and shared +persona fragment. Provider transport and credential wiring stay outside this +Shared Kernel. See ``docs/adr/0014-shared-noema-core-package.md`` in +``ContextualWisdomLab/noema`` for the ownership boundary. +""" + +from __future__ import annotations + +from .agent import NOEMA_PERSONA, build_agent + +__all__ = ["NOEMA_PERSONA", "build_agent"] diff --git a/packages/noema-core/src/noema_core/agent.py b/packages/noema-core/src/noema_core/agent.py new file mode 100644 index 000000000..66bea74d0 --- /dev/null +++ b/packages/noema-core/src/noema_core/agent.py @@ -0,0 +1,62 @@ +"""Shared PydanticAI Agent-construction wiring for Noema's per-context consumers. + +The Shared Kernel centralizes only framework-neutral Noema agent construction +that is safe to reuse across bounded contexts. Provider discovery, endpoint +selection, credentials, provider SDKs, model routing and failover remain outside +this package and are supplied through an already constructed PydanticAI model. + +This package deliberately owns none of a consumer's domain logic: no verdict +schema, no tool/deps machinery, no credential resolution or validation policy, +no tenant isolation. Those stay local to each bounded context. See +``docs/adr/0014-shared-noema-core-package.md`` in +``ContextualWisdomLab/noema`` for the full rationale and scope boundary. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic_ai import Agent +from pydantic_ai.models import Model + + +NOEMA_PERSONA = "You are Noema" +"""The role-neutral identity prefix shared by Noema's bounded-context agents. + +Consumers append their own precise role, organization context, evidence rules, +tool authority and output contract. Keeping this fragment role-neutral avoids +silently broadening a specialized reviewer, runtime agent or application agent +when the shared identity is reused. +""" + + +def build_agent( + model: Model, + *, + system_prompt: str, + output_type: Any = str, + deps_type: Any = None, +) -> Agent[Any, Any]: + """Construct a PydanticAI ``Agent`` around a caller-owned model adapter. + + ``model`` must already be a constructed PydanticAI ``Model`` so provider + discovery, credentials, routing, failover, and retry policy cannot migrate + into Noema's Shared Kernel through PydanticAI convenience configuration. + ``output_type`` (a consumer's verdict/result schema), ``deps_type`` (a + consumer's tool/deps machinery), and ``system_prompt`` (identity plus domain + instructions) remain per-consumer. Model-attempt retry is disabled here; + contextual-orchestrator owns provider/model retry and failover semantics. + """ + if not isinstance(model, Model): + raise TypeError("model must be a constructed PydanticAI Model") + + kwargs: dict[str, Any] = {} + if deps_type is not None: + kwargs["deps_type"] = deps_type + return Agent( + model, + output_type=output_type, + system_prompt=system_prompt, + retries=0, + **kwargs, + ) diff --git a/packages/noema-core/tests/__init__.py b/packages/noema-core/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/noema-core/tests/test_agent.py b/packages/noema-core/tests/test_agent.py new file mode 100644 index 000000000..7d8d715de --- /dev/null +++ b/packages/noema-core/tests/test_agent.py @@ -0,0 +1,53 @@ +"""Tests for the shared provider-neutral Agent-construction wiring.""" + +from __future__ import annotations + +import inspect + +import pytest +from pydantic_ai import Agent +from pydantic_ai.models.test import TestModel + +from noema_core import NOEMA_PERSONA, build_agent + + +def test_build_agent_applies_output_type_and_system_prompt() -> None: + """build_agent constructs an Agent carrying the caller's schema and prompt.""" + agent = build_agent( + TestModel(), + system_prompt=NOEMA_PERSONA, + output_type=str, + ) + assert isinstance(agent, Agent) + result = agent.run_sync("hello") + assert isinstance(result.output, str) + + +def test_build_agent_does_not_expose_retry_policy() -> None: + """Provider/model retry authority cannot leak into the reusable Shared Kernel.""" + assert "retries" not in inspect.signature(build_agent).parameters + + +def test_build_agent_forwards_deps_type_only_when_given() -> None: + """A caller that needs deps machinery can pass deps_type; others get none.""" + agent = build_agent( + TestModel(), + system_prompt=NOEMA_PERSONA, + output_type=str, + deps_type=dict, + ) + assert agent.deps_type is dict + + +def test_build_agent_rejects_unresolved_model_names() -> None: + """Provider/model discovery stays outside noema-core's Shared Kernel.""" + with pytest.raises(TypeError, match="constructed PydanticAI Model"): + build_agent( + "openai:gpt-4o-mini", # type: ignore[arg-type] + system_prompt=NOEMA_PERSONA, + ) + + +def test_noema_persona_is_role_neutral_identity_prefix() -> None: + """Consumers append their bounded-context role without inheriting another role.""" + assert NOEMA_PERSONA == "You are Noema" diff --git a/packages/noema-core/tests/test_owner_boundary.py b/packages/noema-core/tests/test_owner_boundary.py new file mode 100644 index 000000000..ccf9f2b3f --- /dev/null +++ b/packages/noema-core/tests/test_owner_boundary.py @@ -0,0 +1,11 @@ +"""DDD fitness tests for the shared Noema runtime package boundary.""" + +from __future__ import annotations + +import noema_core + + +def test_shared_core_does_not_construct_provider_specific_models() -> None: + """Model/provider transport construction must remain outside Noema's Shared Kernel.""" + + assert not hasattr(noema_core, "build_openai_model") diff --git a/reviewer/MANIFEST.in b/reviewer/MANIFEST.in new file mode 100644 index 000000000..3834c316a --- /dev/null +++ b/reviewer/MANIFEST.in @@ -0,0 +1,2 @@ +include build_backend.py +recursive-include _build_include/noema_core *.py diff --git a/reviewer/README.md b/reviewer/README.md index 851a0a426..34d1933bd 100644 --- a/reviewer/README.md +++ b/reviewer/README.md @@ -14,6 +14,14 @@ Division of responsibility: - **`noema_reviewer`** (this package) — the **judgement** plane. It turns a bounded pull-request manifest into a validated `ReviewVerdict` and can publish it as an independent GitHub review. +- **[`../packages/noema-core`](../packages/noema-core)** — only the shared, + role-neutral PydanticAI `Agent(...)` construction around an already-resolved + caller-owned `Model`, plus a shared `NOEMA_PERSONA` fragment. See + [`docs/adr/0014-shared-noema-core-package.md`](../docs/adr/0014-shared-noema-core-package.md) + for scope. `noema_reviewer` is its only consumer today. Provider/model + discovery, endpoint selection, credentials and failover remain outside the + Shared Kernel; reviewer verdict schema, gating and evidence policy remain + here. ## Contract @@ -187,5 +195,18 @@ python -m pytest # 100% line+branch coverage gate python -m interrogate -c pyproject.toml noema_reviewer # 100% docstring gate ``` +The shared source remains canonical at `../packages/noema-core/src/noema_core`. +Until `noema-core` has an immutable index release, the reviewer wheel includes +that module directly from the canonical monorepo path through setuptools package +mapping. A normal wheel install therefore provides both `noema_reviewer` and +`noema_core`; callers do not need an ambient `PYTHONPATH`. Required +`reviewer-ci` builds and installs the wheel in a clean temporary environment and +imports both packages before the artifact is considered valid. + +Evidence-only package imports are intentionally lazy: importing +`noema_reviewer.github_io` or `noema_reviewer.sandbox` does not load the model +construction layer. Actual model execution still imports `noema_core` through +the package-level agent API. + Tests drive the agent with PydanticAI's offline `TestModel`/`FunctionModel` and a stub `gh` runner — no network, no secret, no real model. diff --git a/reviewer/build_backend.py b/reviewer/build_backend.py new file mode 100644 index 000000000..d68d1513c --- /dev/null +++ b/reviewer/build_backend.py @@ -0,0 +1,307 @@ +"""PEP 517/660 wrapper that stages canonical noema-core for reviewer builds. + +The reviewer cannot declare an immutable external ``noema-core`` dependency until +that package is published. Distribution hooks therefore build from a private +per-invocation copy of the reviewer project containing one canonical noema-core +snapshot. Editable hooks keep one ignored symlink to canonical monorepo source, +so distribution cleanup cannot invalidate an existing editable installation. +""" + +from __future__ import annotations + +from contextlib import contextmanager +import json +import os +from pathlib import Path +from shutil import copytree, ignore_patterns, rmtree +import subprocess +import sys +from tempfile import TemporaryDirectory +from threading import RLock +from typing import Any, Callable, Iterator, TypeVar, cast + +from setuptools import build_meta as _setuptools + +_PROJECT_ROOT = Path(__file__).resolve().parent +_CANONICAL_CORE = _PROJECT_ROOT.parent / "packages" / "noema-core" / "src" / "noema_core" +_STAGING_ROOT = _PROJECT_ROOT / "_build_include" +_STAGED_CORE = _STAGING_ROOT / "noema_core" +_EDITABLE_BUILD_LOCK = RLock() +_BUILD_RESULT = TypeVar("_BUILD_RESULT") +_STAGED_BACKEND_PROGRAM = """ +from __future__ import annotations + +import importlib +import json +from pathlib import Path +import sys + +hook_name, result_path, args_payload, kwargs_payload = sys.argv[1:] +backend = importlib.import_module("setuptools.build_meta") +result = getattr(backend, hook_name)( + *json.loads(args_payload), + **json.loads(kwargs_payload), +) +Path(result_path).write_text(json.dumps(result), encoding="utf-8") +""" + + +def _remove_generated_path(path: Path) -> None: + """Remove a generated file, symlink, or directory without following links.""" + + if path.is_symlink() or path.is_file(): + path.unlink(missing_ok=True) + elif path.exists(): + rmtree(path) + + +def _reset_staging_root() -> None: + """Recreate the editable package view without following stale path aliases.""" + + _remove_generated_path(_STAGING_ROOT) + _STAGING_ROOT.mkdir(parents=True) + + +def _prepare_editable_core() -> None: + """Expose canonical noema-core to editable installs through a live source link. + + Editable packaging must never fall back to a copied snapshot because such a + copy silently stops reflecting edits to the canonical Shared Kernel. A host + that cannot create the directory link fails explicitly instead. + """ + + if not _CANONICAL_CORE.is_dir(): + if _STAGED_CORE.is_dir(): + return + raise RuntimeError("canonical noema-core source is unavailable for reviewer editable install") + + if _STAGED_CORE.is_symlink(): + try: + points_to_canonical = ( + _STAGED_CORE.resolve(strict=True) == _CANONICAL_CORE.resolve(strict=True) + ) + except OSError: + # Broken or inaccessible prior links are non-authoritative and must be restaged. + points_to_canonical = False + if points_to_canonical: + return + + _reset_staging_root() + try: + _STAGED_CORE.symlink_to(_CANONICAL_CORE, target_is_directory=True) + except OSError as error: + _remove_generated_path(_STAGING_ROOT) + raise RuntimeError( + "reviewer editable install requires a live symlink to canonical noema-core source" + ) from error + + +def _distribution_source_core() -> Path: + """Return the canonical or embedded noema-core source used for a distribution.""" + + if _CANONICAL_CORE.is_dir(): + return _CANONICAL_CORE + if _STAGED_CORE.is_dir(): + return _STAGED_CORE + raise RuntimeError("canonical noema-core source is unavailable for reviewer packaging") + + +@contextmanager +def _distribution_project() -> Iterator[Path]: + """Yield a private reviewer project containing one exact shared-core snapshot. + + The caller gets a distinct filesystem tree for each invocation. This keeps + concurrent wheel, sdist, metadata, and requirement hooks from deleting or + overwriting one another's package staging. + """ + + source_core = _distribution_source_core() + with TemporaryDirectory(prefix="noema-reviewer-build-") as temporary_root: + project_root = Path(temporary_root) / "reviewer" + copytree( + _PROJECT_ROOT, + project_root, + ignore=ignore_patterns( + "_build_include", + "__pycache__", + ".pytest_cache", + "*.egg-info", + "build", + "dist", + ), + ) + staged_core = project_root / "_build_include" / "noema_core" + staged_core.parent.mkdir(parents=True, exist_ok=True) + copytree(source_core, staged_core, symlinks=False) + yield project_root + + +def _distribution_child_environment(project_root: Path) -> dict[str, str]: + """Preserve the frontend-provided isolated backend paths for the staged child. + + PEP 517 frontends can expose build requirements through interpreter search + paths rather than a dedicated virtualenv executable. Launching a nested + ``sys.executable`` without those paths can silently import an unrelated host + setuptools and produce ``UNKNOWN-0.0.0`` artifacts. The staged project stays + first, while the current backend process's search paths carry the frontend's + already-admitted build dependencies into the fresh interpreter. + """ + + child_environment = os.environ.copy() + search_paths = [str(project_root)] + for search_path in sys.path: + if search_path and search_path not in search_paths: + search_paths.append(search_path) + child_environment["PYTHONPATH"] = os.pathsep.join(search_paths) + return child_environment + + +def _run_distribution_hook( + hook_name: str, + *args: Any, + **kwargs: Any, +) -> _BUILD_RESULT: + """Invoke setuptools in a fresh process whose project root is the staged copy. + + ``setuptools.build_meta`` is project-context-sensitive. Reusing the module + imported for the checkout after merely changing process cwd can retain the + wrong distribution identity and emit ``UNKNOWN-0.0.0`` artifacts. A child + interpreter imports the public backend only after entering the private + staged project. Its environment explicitly preserves the parent PEP 517 + backend search paths so the child cannot fall back to an unrelated host + setuptools, while independent build invocations retain separate cwd and + module state. + """ + + with _distribution_project() as project_root: + result_path = project_root.parent / "backend-result.json" + subprocess.run( + [ + sys.executable, + "-c", + _STAGED_BACKEND_PROGRAM, + hook_name, + str(result_path), + json.dumps(args), + json.dumps(kwargs), + ], + cwd=project_root, + env=_distribution_child_environment(project_root), + check=True, + ) + if not result_path.is_file(): + raise RuntimeError(f"staged setuptools hook {hook_name!r} produced no result") + return cast(_BUILD_RESULT, json.loads(result_path.read_text(encoding="utf-8"))) + + +def _with_editable_core( + builder: Callable[..., _BUILD_RESULT], + *args: Any, + **kwargs: Any, +) -> _BUILD_RESULT: + """Run an editable hook while retaining its live canonical source view.""" + + with _EDITABLE_BUILD_LOCK: + _prepare_editable_core() + return builder(*args, **kwargs) + + +def _absolute_path(path: str | None) -> str | None: + """Preserve frontend output-directory identity across private-project builds.""" + + if path is None: + return None + return str(Path(path).resolve()) + + +def build_wheel( + wheel_directory: str, + config_settings: dict[str, Any] | None = None, + metadata_directory: str | None = None, +) -> str: + """Build a reviewer wheel containing the staged canonical noema-core snapshot.""" + + return _run_distribution_hook( + "build_wheel", + _absolute_path(wheel_directory), + config_settings, + _absolute_path(metadata_directory), + ) + + +def build_editable( + wheel_directory: str, + config_settings: dict[str, Any] | None = None, + metadata_directory: str | None = None, +) -> str: + """Build an editable reviewer wheel against the canonical shared-core source.""" + + return _with_editable_core( + _setuptools.build_editable, + _absolute_path(wheel_directory), + config_settings, + _absolute_path(metadata_directory), + ) + + +def build_sdist( + sdist_directory: str, + config_settings: dict[str, Any] | None = None, +) -> str: + """Build a self-contained source distribution from canonical monorepo source.""" + + return _run_distribution_hook( + "build_sdist", + _absolute_path(sdist_directory), + config_settings, + ) + + +def prepare_metadata_for_build_wheel( + metadata_directory: str, + config_settings: dict[str, Any] | None = None, +) -> str: + """Prepare wheel metadata in a backend imported from the staged project root.""" + + return _run_distribution_hook( + "prepare_metadata_for_build_wheel", + _absolute_path(metadata_directory), + config_settings, + ) + + +def prepare_metadata_for_build_editable( + metadata_directory: str, + config_settings: dict[str, Any] | None = None, +) -> str: + """Prepare editable metadata against the canonical shared-core source view.""" + + return _with_editable_core( + _setuptools.prepare_metadata_for_build_editable, + _absolute_path(metadata_directory), + config_settings, + ) + + +def get_requires_for_build_wheel( + config_settings: dict[str, Any] | None = None, +) -> list[str]: + """Return wheel-build requirements from a staged-project backend context.""" + + return _run_distribution_hook("get_requires_for_build_wheel", config_settings) + + +def get_requires_for_build_editable( + config_settings: dict[str, Any] | None = None, +) -> list[str]: + """Return editable requirements after validating canonical package availability.""" + + return _with_editable_core(_setuptools.get_requires_for_build_editable, config_settings) + + +def get_requires_for_build_sdist( + config_settings: dict[str, Any] | None = None, +) -> list[str]: + """Return sdist-build requirements from a staged-project backend context.""" + + return _run_distribution_hook("get_requires_for_build_sdist", config_settings) diff --git a/reviewer/noema_reviewer/__init__.py b/reviewer/noema_reviewer/__init__.py index 02e6bb78f..671c40dc1 100644 --- a/reviewer/noema_reviewer/__init__.py +++ b/reviewer/noema_reviewer/__init__.py @@ -6,11 +6,17 @@ publish it as an independent GitHub review, satisfying the organization's two-reviewer merge rule alongside OpenCode. The Noema Cloudflare Worker remains the token-exchange boundary; this package is the judgement plane. + +Agent-construction exports are loaded lazily so evidence-only modules can run +without importing the model runtime. That keeps collection and sandbox evidence +paths independent from the shared ``noema_core`` package while preserving the +existing package-level reviewer API for actual model execution. """ from __future__ import annotations -from .agent import PydanticAIReviewAgent, ReviewAgent, build_agent +from typing import Any + from .manifest import ReviewManifest from .models import Confidence, Finding, ReviewVerdict, Severity, Verdict from .patch_image_validation import ( @@ -30,6 +36,18 @@ inspect_patch_bytes, ) +_AGENT_EXPORTS = frozenset({"PydanticAIReviewAgent", "ReviewAgent", "build_agent"}) + + +def __getattr__(name: str) -> Any: + """Load model-runtime exports only when callers request those symbols.""" + + if name in _AGENT_EXPORTS: + from . import agent + + return getattr(agent, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + __all__ = [ "Confidence", diff --git a/reviewer/noema_reviewer/agent.py b/reviewer/noema_reviewer/agent.py index dc7d24b7a..db52a5c11 100644 --- a/reviewer/noema_reviewer/agent.py +++ b/reviewer/noema_reviewer/agent.py @@ -12,6 +12,8 @@ from typing import Protocol, runtime_checkable +from noema_core import NOEMA_PERSONA +from noema_core import build_agent as build_core_agent from pydantic_ai import Agent from pydantic_ai.models import Model @@ -22,7 +24,7 @@ SYSTEM_PROMPT = ( - "You are Noema, an independent second reviewer for ContextualWisdomLab, " + f"{NOEMA_PERSONA}, an independent second reviewer for ContextualWisdomLab, " "separate from the OpenCode reviewer. You review a bounded manifest of a " "pull request: its diff, changed-file context, workflow logs, SARIF " "summary, dependency findings, prior review comments, and current check " @@ -101,13 +103,12 @@ def build_prompt(manifest: ReviewManifest) -> str: class PydanticAIReviewAgent: """A ``ReviewAgent`` backed by a PydanticAI ``Agent`` with a typed verdict.""" - def __init__(self, model: Model | str) -> None: - """Build the agent around an injected model (a real model or a test model).""" - self._agent: Agent[None, ReviewVerdict] = Agent( + def __init__(self, model: Model) -> None: + """Build the agent around an already resolved real or test model.""" + self._agent: Agent[None, ReviewVerdict] = build_core_agent( model, output_type=ReviewVerdict, system_prompt=SYSTEM_PROMPT, - retries=3, ) def review(self, manifest: ReviewManifest, *, strict: bool = False) -> ReviewVerdict: @@ -123,7 +124,8 @@ def build_agent(config: ReviewerConfig | None = None) -> PydanticAIReviewAgent: Configuration (model name, orchestrator base URL, API key) is resolved through :func:`resolve_model`, which follows the org KV-first rule and fails loudly when the model provider or credential is unavailable — the - reviewer never degrades to a silent approval. + reviewer never degrades to a silent approval. Provider/model retries and + failover stay with contextual-orchestrator rather than this reviewer. """ model = resolve_model(config) return PydanticAIReviewAgent(model) diff --git a/reviewer/noema_reviewer/config.py b/reviewer/noema_reviewer/config.py index d3d6861f6..eda219a75 100644 --- a/reviewer/noema_reviewer/config.py +++ b/reviewer/noema_reviewer/config.py @@ -7,9 +7,10 @@ CI step uses to hand secrets to the KV, so the env fallback is explicit and documented rather than scattered ``os.getenv`` reads. -The reviewer talks to an OpenAI-compatible endpoint (the -``contextual-orchestrator`` gateway in production). Upstream model selection -stays in that gateway; leftover sequential ``NOEMA_FALLBACK_*`` settings fail +The reviewer talks to an OpenAI-compatible endpoint exposed by +``contextual-orchestrator`` in production. Upstream model selection, provider +routing and failover stay in that gateway; this module owns only the reviewer's +transport adapter. Leftover sequential ``NOEMA_FALLBACK_*`` settings fail closed instead of trying the next model inside Noema. """ @@ -151,11 +152,13 @@ def resolve_config(credential_getter: CredentialGetter | None = None) -> Reviewe def resolve_model(config: ReviewerConfig | None = None) -> Model: - """Build an OpenAI-compatible PydanticAI model from resolved configuration. + """Build the reviewer's transport adapter to contextual-orchestrator. - The reviewer routes every model call through an OpenAI-compatible endpoint - (the ``contextual-orchestrator`` gateway in production), so the OpenAI - provider is a required dependency rather than an optional extra. + The OpenAI-compatible client exists only as this bounded-context adapter to + the orchestrator endpoint. It does not select a provider, discover models, + or implement fallback; those authorities remain in contextual-orchestrator. + The shared ``noema_core`` package receives the resulting PydanticAI model by + injection and therefore has no provider SDK or credential surface. """ from openai import AsyncOpenAI from pydantic_ai.models.openai import OpenAIChatModel diff --git a/reviewer/pyproject.toml b/reviewer/pyproject.toml index df7650571..e5b6f168b 100644 --- a/reviewer/pyproject.toml +++ b/reviewer/pyproject.toml @@ -1,6 +1,7 @@ [build-system] requires = ["setuptools>=68"] -build-backend = "setuptools.build_meta" +build-backend = "build_backend" +backend-path = ["."] [project] name = "noema-reviewer" @@ -9,12 +10,24 @@ description = "Noema independent PydanticAI second reviewer for ContextualWisdom requires-python = ">=3.11" dependencies = [ "pydantic>=2.7", - "pydantic-ai-slim[openai]>=0.0.14", + "pydantic-ai-slim[openai]>=2.9.0,<3", ] [project.scripts] noema-reviewer = "noema_reviewer.cli:main" +# noema-core is not yet published as an immutable index dependency. The custom +# PEP 517 backend stages the exact canonical monorepo source into a build-only +# directory. That snapshot is embedded in an sdist, allowing its wheel to build +# without the original checkout while keeping repository source authority in +# packages/noema-core. +[tool.setuptools] +packages = ["noema_reviewer", "noema_core"] + +[tool.setuptools.package-dir] +noema_reviewer = "noema_reviewer" +noema_core = "_build_include/noema_core" + [dependency-groups] dev = [ "pytest>=8.0.0", @@ -23,7 +36,7 @@ dev = [ ] [tool.pytest.ini_options] -pythonpath = ["."] +pythonpath = [".", "../packages/noema-core/src"] addopts = "--cov=noema_reviewer --cov-branch --cov-report=term-missing --cov-fail-under=100" [tool.coverage.run] diff --git a/reviewer/requirements-ci.in b/reviewer/requirements-ci.in index a85cb013a..129ab6384 100644 --- a/reviewer/requirements-ci.in +++ b/reviewer/requirements-ci.in @@ -1,4 +1,4 @@ -pydantic-ai-slim[openai]>=0.0.14 +pydantic-ai-slim[openai]>=2.9.0,<3 pytest>=8.0.0 pytest-cov>=5.0.0 interrogate>=1.7.0 diff --git a/reviewer/tests/test_agent.py b/reviewer/tests/test_agent.py index db624d5d2..d131e3086 100644 --- a/reviewer/tests/test_agent.py +++ b/reviewer/tests/test_agent.py @@ -5,6 +5,7 @@ from pydantic_ai.models.test import TestModel from noema_reviewer.agent import ( + SYSTEM_PROMPT, PydanticAIReviewAgent, ReviewAgent, build_agent, @@ -45,6 +46,13 @@ def test_agent_satisfies_protocol() -> None: assert isinstance(_agent_returning(), ReviewAgent) +def test_reviewer_identity_preserves_the_protected_main_role() -> None: + """Shared identity reuse must not broaden the reviewer's prompt-sensitive role.""" + assert SYSTEM_PROMPT.startswith( + "You are Noema, an independent second reviewer for ContextualWisdomLab, " + ) + + def test_agent_returns_model_approval() -> None: """A model approval flows through unchanged when no gate fires.""" verdict = _agent_returning().review(_evidenced_manifest()) diff --git a/reviewer/tests/test_build_backend_editable.py b/reviewer/tests/test_build_backend_editable.py new file mode 100644 index 000000000..ab59fb559 --- /dev/null +++ b/reviewer/tests/test_build_backend_editable.py @@ -0,0 +1,111 @@ +"""Regression coverage for the reviewer packaging backend's editable-install contract.""" + +from __future__ import annotations + +import os +from pathlib import Path +import shlex +import subprocess +import sys + +import build_backend + + +def test_build_backend_exposes_pep660_editable_hooks() -> None: + """The custom backend must preserve setuptools' documented editable-install path.""" + + for hook_name in ( + "build_editable", + "prepare_metadata_for_build_editable", + "get_requires_for_build_editable", + ): + assert callable(getattr(build_backend, hook_name, None)), hook_name + + +def test_clean_editable_install_imports_reviewer_and_canonical_core(tmp_path: Path) -> None: + """An isolated editable install must resolve declared runtime dependencies and shared core.""" + + reviewer_root = Path(__file__).resolve().parents[1] + requirements = reviewer_root / "requirements-ci-hashes.txt" + venv_dir = tmp_path / "editable-venv" + subprocess.run( + [sys.executable, "-m", "venv", str(venv_dir)], + check=True, + ) + python = venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + env = os.environ.copy() + env["PYTHONPATH"] = "" + subprocess.run( + [ + str(python), + "-m", + "pip", + "install", + "--require-hashes", + "--no-deps", + "-r", + str(requirements), + ], + cwd=tmp_path, + env=env, + check=True, + capture_output=True, + text=True, + ) + subprocess.run( + [ + str(python), + "-m", + "pip", + "install", + "--no-deps", + "-e", + str(reviewer_root), + ], + cwd=tmp_path, + env=env, + check=True, + capture_output=True, + text=True, + ) + completed = subprocess.run( + [ + str(python), + "-c", + "import noema_core, noema_reviewer; assert noema_core.build_agent; assert noema_reviewer.build_agent", + ], + cwd=tmp_path, + env=env, + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr + + +def test_reviewer_ci_proves_an_isolated_editable_install_with_locked_dependencies() -> None: + """Required CI must validate editable packaging without inheriting host site-packages.""" + + reviewer_root = Path(__file__).resolve().parents[1] + workflow = (reviewer_root.parent / ".github" / "workflows" / "reviewer-ci.yml").read_text( + encoding="utf-8" + ) + + assert 'editable_venv="$RUNNER_TEMP/noema-reviewer-editable-smoke"' in workflow + assert 'python -m venv "$editable_venv"' in workflow + assert ( + '"$editable_venv/bin/python" -m pip install --require-hashes --no-deps ' + '-r requirements-ci-hashes.txt' + ) in workflow + + editable_install_commands = [ + line.strip() + for line in workflow.splitlines() + if "pip install" in line and "-e ." in line + ] + assert len(editable_install_commands) == 1 + editable_tokens = shlex.split(editable_install_commands[0]) + assert "-e" in editable_tokens + assert editable_tokens[editable_tokens.index("-e") + 1] == "." + assert "--system-site-packages" not in editable_tokens + assert "--no-build-isolation" not in editable_tokens diff --git a/reviewer/tests/test_build_backend_staging.py b/reviewer/tests/test_build_backend_staging.py new file mode 100644 index 000000000..5a1bd22e9 --- /dev/null +++ b/reviewer/tests/test_build_backend_staging.py @@ -0,0 +1,150 @@ +"""Regression coverage for isolated reviewer build staging and editable source lifetime.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +import json +import os +from pathlib import Path +import threading + +import pytest + +import build_backend + + +def test_distribution_staging_is_private_per_build_invocation() -> None: + """Concurrent distribution preparations must never share a mutable staging tree.""" + + barrier = threading.Barrier(2) + + def observe_distribution_project() -> tuple[Path, Path]: + with build_backend._distribution_project() as project_root: + staged_core = project_root / "_build_include" / "noema_core" + assert staged_core.is_dir() + barrier.wait(timeout=10) + return project_root, staged_core + + with ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit(observe_distribution_project) + second = pool.submit(observe_distribution_project) + first_project, first_core = first.result(timeout=20) + second_project, second_core = second.result(timeout=20) + + assert first_project != second_project + assert first_core != second_core + + +def test_concurrent_distribution_metadata_keeps_reviewer_project_identity(tmp_path: Path) -> None: + """Fresh backend contexts must emit reviewer metadata, never UNKNOWN artifacts.""" + + def prepare_metadata(index: int) -> tuple[str, bool]: + metadata_root = tmp_path / f"metadata-{index}" + metadata_root.mkdir() + distribution_name = build_backend.prepare_metadata_for_build_wheel(str(metadata_root)) + return distribution_name, (metadata_root / distribution_name).is_dir() + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(prepare_metadata, (1, 2))) + + for distribution_name, exists in results: + assert distribution_name.startswith("noema_reviewer-") + assert distribution_name.endswith(".dist-info") + assert exists + + +def test_distribution_hook_preserves_frontend_backend_environment( + tmp_path: Path, + monkeypatch, +) -> None: + """A staged child must retain the PEP 517 frontend's isolated backend search path.""" + + isolated_backend_path = str(tmp_path / "pep517-overlay-site-packages") + monkeypatch.setattr( + build_backend.sys, + "path", + [isolated_backend_path, *build_backend.sys.path], + ) + observed: dict[str, object] = {} + + def fake_run(command, *, cwd, check, env) -> None: + observed["cwd"] = cwd + observed["check"] = check + observed["env"] = env + Path(command[4]).write_text( + json.dumps("noema_reviewer-0.1.0.dist-info"), + encoding="utf-8", + ) + + monkeypatch.setattr(build_backend.subprocess, "run", fake_run) + metadata_root = tmp_path / "metadata" + metadata_root.mkdir() + + result = build_backend.prepare_metadata_for_build_wheel(str(metadata_root)) + + assert result == "noema_reviewer-0.1.0.dist-info" + assert observed["check"] is True + child_env = observed["env"] + assert isinstance(child_env, dict) + child_pythonpath = child_env["PYTHONPATH"].split(os.pathsep) + assert child_pythonpath[0] == str(observed["cwd"]) + assert isolated_backend_path in child_pythonpath + + +def test_distribution_build_does_not_destroy_editable_canonical_view(tmp_path: Path) -> None: + """A real distribution build must not remove the source view used by an editable install.""" + + if not build_backend._CANONICAL_CORE.is_dir(): + return + + build_backend._prepare_editable_core() + editable_view = build_backend._STAGED_CORE + assert editable_view.is_symlink() + assert editable_view.resolve() == build_backend._CANONICAL_CORE.resolve() + + wheel_root = tmp_path / "wheel" + wheel_root.mkdir() + try: + wheel_name = build_backend.build_wheel(str(wheel_root)) + assert wheel_name.startswith("noema_reviewer-") + assert (wheel_root / wheel_name).is_file() + assert editable_view.is_symlink() + assert editable_view.resolve() == build_backend._CANONICAL_CORE.resolve() + finally: + build_backend._remove_generated_path(build_backend._STAGING_ROOT) + + +def test_generated_path_cleanup_unlinks_files_and_symlinks(tmp_path: Path) -> None: + """Generated cleanup must unlink leaf capabilities instead of passing them to rmtree.""" + + regular_file = tmp_path / "regular-file" + regular_file.write_text("generated", encoding="utf-8") + build_backend._remove_generated_path(regular_file) + assert not regular_file.exists() + + target = tmp_path / "target" + target.mkdir() + alias = tmp_path / "alias" + alias.symlink_to(target, target_is_directory=True) + build_backend._remove_generated_path(alias) + assert not alias.exists() + assert target.is_dir() + + +def test_editable_source_view_fails_closed_when_live_link_cannot_be_created( + monkeypatch, +) -> None: + """Editable packaging must not replace a failed live link with a stale copied snapshot.""" + + if not build_backend._CANONICAL_CORE.is_dir(): + return + + build_backend._remove_generated_path(build_backend._STAGING_ROOT) + + def deny_symlink(*_args, **_kwargs) -> None: + raise OSError("symlink unavailable") + + monkeypatch.setattr(Path, "symlink_to", deny_symlink) + with pytest.raises(RuntimeError, match="requires a live symlink"): + build_backend._prepare_editable_core() + assert not build_backend._STAGING_ROOT.exists() diff --git a/reviewer/tests/test_shared_core_import_boundary.py b/reviewer/tests/test_shared_core_import_boundary.py new file mode 100644 index 000000000..d90defe53 --- /dev/null +++ b/reviewer/tests/test_shared_core_import_boundary.py @@ -0,0 +1,55 @@ +"""Regression tests for the shared-core import and distribution boundary.""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +import noema_reviewer + + +def test_evidence_modules_import_without_shared_core_on_pythonpath() -> None: + """Evidence-only reviewer imports must not require the model-construction package.""" + + reviewer_root = Path(__file__).resolve().parents[1] + env = os.environ.copy() + env["PYTHONPATH"] = "." + completed = subprocess.run( + [ + sys.executable, + "-c", + ( + "import sys; sys.modules['noema_core'] = None; " + "from noema_reviewer.github_io import fetch_manifest; " + "from noema_reviewer.sandbox import DockerCodeGraphRunner; " + "assert fetch_manifest is not None; " + "assert DockerCodeGraphRunner is not None" + ), + ], + cwd=reviewer_root, + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0, completed.stderr + + +def test_agent_exports_remain_available_from_package_root() -> None: + """Lazy loading must preserve the existing package-level agent API.""" + + assert noema_reviewer.build_agent is not None + assert noema_reviewer.ReviewAgent is not None + assert noema_reviewer.PydanticAIReviewAgent is not None + + +def test_unknown_package_export_fails_normally() -> None: + """Unknown package attributes must still raise the standard error.""" + + with pytest.raises(AttributeError, match="has no attribute"): + getattr(noema_reviewer, "missing_runtime_export") diff --git a/test/noema-core-packaging-contract.test.ts b/test/noema-core-packaging-contract.test.ts new file mode 100644 index 000000000..e464de8db --- /dev/null +++ b/test/noema-core-packaging-contract.test.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +const centralReview = readFileSync(".github/workflows/central-review.yml", "utf8"); +const reviewerCi = readFileSync(".github/workflows/reviewer-ci.yml", "utf8"); +const reviewerPyproject = readFileSync("reviewer/pyproject.toml", "utf8"); +const reviewerBuildBackend = readFileSync("reviewer/build_backend.py", "utf8"); +const reviewerManifest = readFileSync("reviewer/MANIFEST.in", "utf8"); +const corePyproject = readFileSync("packages/noema-core/pyproject.toml", "utf8"); + +describe("noema-core packaging and workflow contract", () => { + it("makes the shared core importable everywhere reviewer code runs", () => { + const sharedPath = + "PYTHONPATH: ${{ github.workspace }}/reviewer:${{ github.workspace }}/packages/noema-core/src"; + + expect(centralReview).toContain(sharedPath); + expect(reviewerCi).toContain(sharedPath); + expect(reviewerCi).not.toContain("PYTHONPATH=. python"); + }); + + it("stages the canonical core into reviewer build artifacts until an immutable index release exists", () => { + expect(reviewerPyproject).toContain('build-backend = "build_backend"'); + expect(reviewerPyproject).toContain('backend-path = ["."]'); + expect(reviewerPyproject).toContain('[tool.setuptools]'); + expect(reviewerPyproject).toContain('packages = ["noema_reviewer", "noema_core"]'); + expect(reviewerPyproject).toContain('[tool.setuptools.package-dir]'); + expect(reviewerPyproject).toContain('noema_core = "_build_include/noema_core"'); + expect(reviewerBuildBackend).toContain('"packages" / "noema-core" / "src" / "noema_core"'); + expect(reviewerBuildBackend).toContain('from setuptools import build_meta as _setuptools'); + expect(reviewerBuildBackend).toContain('def build_sdist('); + expect(reviewerManifest).toContain('include build_backend.py'); + expect(reviewerManifest).toContain('recursive-include _build_include/noema_core *.py'); + expect(reviewerCi).toContain("smoke-test installed reviewer wheel and sdist-to-wheel path"); + expect(reviewerCi).toContain("from build_backend import build_sdist"); + expect(reviewerCi).toContain('python -m pip wheel "$sdist"'); + expect(reviewerCi).toContain("hashlib.sha256(installed_agent.read_bytes()).digest()"); + }); + + it("does not retain the obsolete out-of-tree setuptools package mapping", () => { + expect(reviewerPyproject).not.toContain( + 'noema_core = "../packages/noema-core/src/noema_core"', + ); + }); + + it("smokes a CLI symbol that the installed reviewer actually exports", () => { + expect(reviewerCi).toContain("from noema_reviewer.cli import parse_args"); + expect(reviewerCi).toContain('assert parse_args([]).repo == ""'); + expect(reviewerCi).not.toContain("from noema_reviewer.cli import build_parser"); + }); + + it("keeps the provider SDK extra at the reviewer integration adapter", () => { + expect(reviewerPyproject).toContain('"pydantic-ai-slim[openai]>=2.9.0,<3"'); + expect(corePyproject).toContain('"pydantic-ai-slim>=2.9.0,<3"'); + expect(corePyproject).not.toContain("pydantic-ai-slim[openai]"); + }); + + it("runs shared-core coverage and docstring gates in required reviewer CI", () => { + expect(reviewerCi).toContain("test noema-core (100% line+branch coverage gate)"); + expect(reviewerCi).toContain("docstring coverage noema-core (100% gate)"); + }); +}); diff --git a/test/reviewer-ci-action-runtime-integrity.test.ts b/test/reviewer-ci-action-runtime-integrity.test.ts index a32e68ee2..8f2cd5201 100644 --- a/test/reviewer-ci-action-runtime-integrity.test.ts +++ b/test/reviewer-ci-action-runtime-integrity.test.ts @@ -19,6 +19,15 @@ describe("reviewer CI action runtime integrity", () => { ); }); + it("installs wheel smoke artifacts outside source import authority", () => { + expect(workflow).toMatch( + /cd "\$RUNNER_TEMP"\n\s+PYTHONPATH='' "\$venv_dir\/bin\/python" -m pip install --no-deps "\$wheel"/, + ); + expect(workflow).not.toMatch( + /"\$venv_dir\/bin\/python" -m pip install --no-deps "\$wheel"\n\s+\(\n\s+cd "\$RUNNER_TEMP"/, + ); + }); + it("fails the CodeGraph smoke gate when semantic retrieval is empty", () => { expect(workflow).toContain( '["codegraph", "explore", "commercialReadiness"]',