diff --git a/.bestpractices.json b/.bestpractices.json index 5ea07345..b92f5938 100644 --- a/.bestpractices.json +++ b/.bestpractices.json @@ -99,7 +99,7 @@ "build_floss_tools_justification": "pip, uv, hatchling, pytest, ruff and pyright are all FLOSS, and the build runs on ubuntu-latest and windows-latest GitHub runners.", "test_status": "Met", - "test_justification": "An automated test suite of 6376 tests lives under tests_py/ (measured 2026-07-28 with 'pytest --collect-only -q'), covering core, handlers, infrastructure, integration, invariants and architecture: https://github.com/cdeust/Cortex/tree/main/tests_py", + "test_justification": "An automated test suite of 6426 tests lives under tests_py/ (measured 2026-07-29 with 'pytest --collect-only -q'), covering core, handlers, infrastructure, integration, invariants and architecture: https://github.com/cdeust/Cortex/tree/main/tests_py", "test_invocation_status": "Met", "test_invocation_justification": "The whole suite runs with a single 'pytest' command, documented in CONTRIBUTING.md under Testing along with per-layer subsets: https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md", @@ -114,7 +114,7 @@ "test_policy_justification": "CONTRIBUTING.md carries an explicit, mandatory testing policy: new functionality ships with tests in the automated suite, a bug fix carries a regression test that fails on the pre-fix code, and each failure path asserts its observable effect including the signal it emits \u2014 https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md#the-testing-policy-mandatory. The per-tool checklist ('Add a unit test', 'Add an integration test if the tool touches the database') and the five-element mechanism checklist restate it per change type.", "tests_are_added_status": "Met", - "tests_are_added_justification": "New functionality ships with its tests; the suite has grown to 6376 tests alongside the v4.x feature series, and CI runs it on every pull request.", + "tests_are_added_justification": "New functionality ships with its tests; the suite has grown to 6426 tests alongside the v4.x feature series, and CI runs it on every pull request.", "tests_documented_added_status": "Met", "tests_documented_added_justification": "The requirement is written into the documented instructions for change proposals: https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md#the-testing-policy-mandatory states that every change adding or altering observable behaviour must arrive with tests in the same PR, the per-tool and per-mechanism checklists repeat it as a concrete step, and .github/PULL_REQUEST_TEMPLATE.md requires a Test plan section in every pull request.", @@ -189,7 +189,7 @@ "static_analysis_often_justification": "CodeQL default setup analyses each push and pull request and additionally runs on a weekly schedule, so analysis happens per change rather than per release.", "dynamic_analysis_status": "Unmet", - "dynamic_analysis_justification": "No dynamic analysis tool in the badge's sense (fuzzer, sanitizer, or scanner) is applied. The project runs a 6376-test suite with coverage on every change, but a test suite is not a dynamic analysis tool and is not claimed as one here.", + "dynamic_analysis_justification": "No dynamic analysis tool in the badge's sense (fuzzer, sanitizer, or scanner) is applied. The project runs a 6426-test suite with coverage on every change, but a test suite is not a dynamic analysis tool and is not claimed as one here.", "dynamic_analysis_unsafe_status": "N/A", "dynamic_analysis_unsafe_justification": "Cortex is written in Python, a memory-safe language, so the memory-safety tooling this criterion asks about (ASan, Valgrind) does not apply.", @@ -297,7 +297,7 @@ "interfaces_current_justification": "The stack targets currently supported runtimes and APIs: Python 3.10 through 3.13, all four in the CI matrix, with pgvector 0.3+/PostgreSQL 17 and current major versions of FastMCP, Pydantic v2, numpy and sentence-transformers. Deprecated interfaces are removed rather than wrapped \u2014 the standing rule is one-shot migrations with no back-compat shims (CLAUDE.md), and CI runs on the newest released Python so a deprecation surfaces as a warning in the build rather than as a surprise at end of life.", "automated_integration_testing_status": "Met", - "automated_integration_testing_justification": "The full suite runs on every push and pull request to main via .github/workflows/ci.yml and reports success or failure per job: 6376 tests on Python 3.10-3.13 against PostgreSQL + pgvector, the same suite against the SQLite backend, the suite on Windows, and a Docker smoke job that boots the bare container and exercises the DB-less contract. tests_py/integration/ holds the database-backed integration tests specifically.", + "automated_integration_testing_justification": "The full suite runs on every push and pull request to main via .github/workflows/ci.yml and reports success or failure per job: 6426 tests on Python 3.10-3.13 against PostgreSQL + pgvector, the same suite against the SQLite backend, the suite on Windows, and a Docker smoke job that boots the bare container and exercises the DB-less contract. tests_py/integration/ holds the database-backed integration tests specifically.", "regression_tests_added50_status": "Met", "regression_tests_added50_justification": "Measured on 2026-07-27 over the merged pull requests titled as fixes in the preceding six months (2026-01-27 onward): 23 of 30 \u2014 76.7% \u2014 changed the test tree in the same PR, against the 50% this criterion asks for. (The measure counts a PR as carrying tests when it touches tests_py/ or tests_js/, which is a proxy for 'added a regression test'; the policy behind it is written down at https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md#the-testing-policy-mandatory \u2014 a bug fix carries a regression test that fails on the pre-fix code.)", diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile new file mode 100644 index 00000000..f1945f24 --- /dev/null +++ b/.clusterfuzzlite/Dockerfile @@ -0,0 +1,19 @@ +# ClusterFuzzLite build environment for the fuzz/ harnesses. +# +# ClusterFuzzLite is the per-repository form of OSS-Fuzz: it builds the +# harnesses in fuzz/ against libFuzzer via atheris and runs them on pull +# requests (a short batch) and on a schedule (a longer one). See +# .github/workflows/fuzz.yml for the two cadences. +# +# Base image pinned by digest, exactly as the other four Dockerfiles in this +# repository are: an unpinned base makes the build non-reproducible and is +# itself an OpenSSF Scorecard Pinned-Dependencies finding, so adding fuzzing +# with a floating base would trade one finding for another. +# source: gcr.io/v2/oss-fuzz-base/base-builder-python/manifests/latest, +# docker-content-digest header, fetched 2026-07-28. +FROM gcr.io/oss-fuzz-base/base-builder-python@sha256:093721649cdd0d15c1e97eb905ed94079c09ffc8b3341c5dc42957b92f1b388f + +# $SRC is the OSS-Fuzz convention for the checkout root. +COPY . $SRC/cortex +WORKDIR $SRC/cortex +COPY .clusterfuzzlite/build.sh $SRC/build.sh diff --git a/.clusterfuzzlite/build.sh b/.clusterfuzzlite/build.sh new file mode 100644 index 00000000..27dfc5b8 --- /dev/null +++ b/.clusterfuzzlite/build.sh @@ -0,0 +1,29 @@ +#!/bin/bash -eu +# Build every fuzz/ harness into a libFuzzer binary. +# +# -e so a failed dependency install fails the build instead of producing a +# fuzzer that cannot import the code it is meant to exercise — a silently +# useless fuzzer is worse than none, because the dashboard turns green. + +# Runtime dependencies of the modules under test, hash-pinned from uv.lock +# (scripts/generate_pip_constraints.py). The harnesses import mcp_server +# modules, so their imports must resolve. +pip3 install --require-hashes -r "$SRC/cortex/requirements/ci-sqlite-min.txt" +pip3 install --no-deps -e "$SRC/cortex" + +# compile_python_fuzzer is provided by the base image. It wraps each harness +# with atheris's instrumentation and emits $OUT/. +for harness in "$SRC"/cortex/fuzz/fuzz_*.py; do + compile_python_fuzzer "$harness" +done + +# Ship each harness's committed corpus as its seed corpus. These are the +# reproducers of bugs already found (see fuzz/corpus/*/repro-*) plus shape +# seeds; starting from them keeps the fuzzer from rediscovering the shallow +# surface on every run. +for corpus in "$SRC"/cortex/fuzz/corpus/*/; do + name="$(basename "$corpus")" + if [ -d "$corpus" ]; then + zip -j "$OUT/${name}_seed_corpus.zip" "$corpus"* >/dev/null + fi +done diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index a209170a..d990bc57 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -46,20 +46,24 @@ WORKDIR /workspace COPY pyproject.toml README.md ./ COPY mcp_server ./mcp_server -# CPU-only torch wheel: sentence-transformers pulls torch transitively as a -# mandatory base dependency (pyproject.toml); pip's default index resolves -# the full CUDA build (~2GB across torch/cudnn/cusparselt/cublas/etc.) even -# though this container never uses a GPU — identical rationale to -# ../Dockerfile:38-45 and benchmarks/reproduce.sh's own CPU-only builds. -# Versions pinned exactly to this repo's lockfile so the devcontainer -# never silently drifts from what CI/production actually run. -# source: uv.lock (this repo, checked 2026-07-14) — torch==2.11.0, -# sentence-transformers==5.4.1, flashrank==0.2.10. -RUN pip install --no-cache-dir --upgrade pip && \ - pip install --no-cache-dir torch==2.11.0 --index-url https://download.pytorch.org/whl/cpu && \ - pip install --no-cache-dir ".[postgresql,codebase]" \ - "sentence-transformers==5.4.1" \ - "flashrank==0.2.10" +# Dependencies, hash-pinned from uv.lock via +# scripts/generate_pip_constraints.py — the same lock CI and production +# resolve from, so the devcontainer cannot silently drift from them. +# +# This replaces three hand-copied version pins (torch==2.11.0, +# sentence-transformers==5.4.1, flashrank==0.2.10) that had already drifted: +# the lock had moved to torch 2.13.0 while this file still said 2.11.0. An +# exact version is also not a hash — it names a release, not the bytes the +# index serves for it. The CPU-only torch build is carried by the file (see +# [[tool.uv.index]] in pyproject.toml) rather than by an --index-url flag +# here; the rationale for CPU-only is unchanged and lives in ../Dockerfile. +COPY requirements/devcontainer.txt /tmp/requirements.txt +RUN pip install --no-cache-dir --require-hashes -r /tmp/requirements.txt + +# The project itself, editable so a contributor's edits take effect without +# a rebuild. --no-deps because the hashed file above is the complete +# dependency graph; without it pip would re-resolve, unpinned. +RUN pip install --no-cache-dir --no-deps -e . # ── Model prewarming AT BUILD TIME ───────────────────────────────────────── # HF_HOME / XDG_CACHE_HOME point at a durable image path baked into a layer diff --git a/.gitattributes b/.gitattributes index 3eba3c31..9017e0d7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -21,3 +21,10 @@ *.dylib binary *.dll binary *.exe binary + +# Fuzz corpus inputs are opaque byte sequences, not text. Git's end-of-line +# normalisation would rewrite CRLF seeds to LF on checkout and silently +# change what the harness is fed — fuzz/corpus/fuzz_yaml_frontmatter/seed-crlf +# exists precisely to exercise CRLF handling, so normalising it deletes the +# case. -text disables that; -diff keeps binary junk out of diffs. +fuzz/corpus/** -text -diff diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bde5aa8..93fbaf03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: DATABASE_URL: postgresql://cortex:cortex@localhost:5432/cortex steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -67,7 +67,7 @@ jobs: PGPASSWORD=cortex psql -h localhost -U cortex -d cortex -c "SELECT version();" - name: Cache pip - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }} @@ -75,7 +75,7 @@ jobs: ${{ runner.os }}-pip-${{ matrix.python-version }}- - name: Cache HuggingFace models - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/huggingface key: ${{ runner.os }}-hf-all-MiniLM-L6-v2 @@ -89,13 +89,18 @@ jobs: # whole suite at 300s, while every other leg happened to download fine. # Cache the model so the fetch happens once, not once per leg. - name: Cache FlashRank reranker model - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/flashrank key: ${{ runner.os }}-flashrank-ms-marco-MiniLM-L-12-v2 - name: Install dependencies - run: pip install -e ".[dev,postgresql,codebase]" + # Hash-pinned from uv.lock (scripts/generate_pip_constraints.py). + # --no-deps on the project install because the file above IS the + # complete dependency graph; re-resolving here would be unpinned. + run: | + pip install --require-hashes -r requirements/ci-postgresql.txt + pip install --no-deps -e . # Populate the HuggingFace cache before the (offline) test run. A transient # huggingface.co blip must not leave the cache empty — that surfaced as 58 @@ -180,7 +185,7 @@ jobs: - name: Upload coverage if: matrix.python-version == '3.12' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-report path: coverage.xml @@ -199,7 +204,7 @@ jobs: CORTEX_MEMORY_STORE_BACKEND: sqlite steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -207,7 +212,7 @@ jobs: python-version: "3.12" - name: Cache pip - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-3.12-sqlite-${{ hashFiles('pyproject.toml') }} @@ -215,7 +220,7 @@ jobs: ${{ runner.os }}-pip-3.12-sqlite- - name: Cache HuggingFace models - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/huggingface key: ${{ runner.os }}-hf-all-MiniLM-L6-v2 @@ -226,7 +231,12 @@ jobs: # codebase-analysis regression could reach the default backend unseen. # Measured 2026-07-28 locally: 8 skips from tree-sitter, 1 from leidenalg. - name: Install dependencies (no postgresql extra) - run: pip install -e ".[dev,sqlite,codebase]" + # Hash-pinned from uv.lock (scripts/generate_pip_constraints.py). + # --no-deps on the project install because the file above IS the + # complete dependency graph; re-resolving here would be unpinned. + run: | + pip install --require-hashes -r requirements/ci-sqlite.txt + pip install --no-deps -e . # Retry-with-backoff, fail-loudly: see the `test` job's pre-download step # for the root-cause rationale (CI run 28495801728, 2026-07-01). @@ -288,7 +298,7 @@ jobs: CORTEX_MEMORY_STORE_BACKEND: sqlite steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -296,13 +306,18 @@ jobs: python-version: "3.12" - name: Cache HuggingFace models - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/huggingface key: ${{ runner.os }}-hf-all-MiniLM-L6-v2 - name: Install dependencies (no postgresql extra) - run: pip install -e ".[dev,sqlite]" + # Hash-pinned from uv.lock (scripts/generate_pip_constraints.py). + # --no-deps on the project install because the file above IS the + # complete dependency graph; re-resolving here would be unpinned. + run: | + pip install --require-hashes -r requirements/ci-sqlite-min.txt + pip install --no-deps -e . # Import smoke: the modules that previously crashed at load on Windows # (fcntl import) or silently misbehaved. If any fails to import, the @@ -372,7 +387,7 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -383,7 +398,7 @@ jobs: # Pinned: ruff's formatter output changes across minor versions # (0.15.6 vs 0.15.20 divergence broke Lint on PR #83). The repo is # formatted with 0.15.20; bump this pin and reformat together. - run: pip install ruff==0.15.20 + run: pip install --require-hashes -r requirements/lint.txt - name: Check formatting run: ruff format --check . @@ -408,11 +423,32 @@ jobs: - name: Check committed badges run: python scripts/generate_repo_badges.py --check + # The requirements/ files are the ONLY thing standing between a pip + # install and an unpinned one, and they are generated — so a lock + # change that is not re-exported leaves them describing dependencies + # nobody resolved. Checked here, on every push and PR, because that + # drift is introduced at commit time. Static: reads uv.lock, installs + # nothing. + # uv arrives via the SHA-pinned action, not `pip install uv==0.11.3`: + # Scorecard scores a bare version specifier as "pipCommand not pinned + # by hash" (an `==` resolves to whatever bytes the index serves under + # that version today), so installing the pinning tool with pip would + # have minted the 22nd Pinned-Dependencies alert in the very commit + # that closes the other 21 — and the policy clears the check only at + # score 10. Same form as release.yml's SBOM job. + - name: Install uv (pinned) + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.3" + + - name: Check hash-pinned requirements match uv.lock + run: python scripts/generate_pip_constraints.py --check + typecheck: name: Type Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -430,11 +466,11 @@ jobs: - name: Create .venv with the full type-check environment run: | python -m venv .venv - .venv/bin/pip install -U pip - .venv/bin/pip install -e ".[dev,postgresql,sqlite,codebase,otel]" flashrank + .venv/bin/pip install --require-hashes -r requirements/ci-typecheck.txt + .venv/bin/pip install --no-deps -e . # Pin pyright — diagnostic output drifts between releases, so a # zero-diagnostic tree is only comparable against the pinned version. - .venv/bin/pip install pyright==1.1.410 + .venv/bin/pip install --require-hashes -r requirements/typecheck-tool.txt # pyright IS the gate: the backlog was burned to zero (issue #197, # 568 baselined diagnostics -> 0 measured 2026-07-28), so ANY @@ -449,7 +485,7 @@ jobs: name: Build Package runs-on: ubuntu-latest steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -457,17 +493,81 @@ jobs: python-version: "3.12" - name: Install build tools - run: pip install build + run: pip install --require-hashes -r requirements/packaging.txt - name: Build sdist and wheel run: python -m build - name: Upload build artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dist path: dist/ + # The two images below had NO CI build at all until this change, so a + # regression in either was invisible until someone built it by hand. That + # is not hypothetical: docker/Dockerfile copied a python3.12 site-packages + # path against a python:3.14 base and could not build, and nothing said so. + # + # These build only — no smoke run. docker/Dockerfile brings up PostgreSQL + # and pre-caches models, and .devcontainer/Dockerfile is opened by the Dev + # Containers CLI; asserting their runtime behaviour is a separate job with + # separate services. The claim here is narrow and worth making on its own: + # the image still builds, and its hash-pinned installs still resolve. + docker-runtime-build: + name: Docker Build (runtime image) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # This image carries PostgreSQL 17, Node 22, torch and a pre-cached + # embedding model. A stock runner has ~14GB free, which the build can + # exhaust; the preinstalled toolchains below are worth ~10GB and are + # not used by it. + - name: Free runner disk + run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc + sudo rm -rf /usr/local/share/boost "$AGENT_TOOLSDIRECTORY" + df -h / + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Build docker/Dockerfile + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./docker/Dockerfile + tags: cortex-runtime:ci + load: false + cache-from: type=gha + cache-to: type=gha,mode=max + + devcontainer-build: + name: Docker Build (devcontainer image) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Free runner disk + run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc + sudo rm -rf /usr/local/share/boost "$AGENT_TOOLSDIRECTORY" + df -h / + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Build .devcontainer/Dockerfile + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./.devcontainer/Dockerfile + tags: cortex-devcontainer:ci + load: false + cache-from: type=gha + cache-to: type=gha,mode=max + docker-smoke: name: Docker Smoke (bare-container DB-less contract) runs-on: ubuntu-latest @@ -480,7 +580,7 @@ jobs: # the production image; nothing else runs the image with psycopg absent # and no DATABASE_URL. continue-on-error is intentionally NOT set. steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 @@ -495,7 +595,7 @@ jobs: # (https://github.com/docker/build-push-action#cache-backend-api), # type=gha is the documented zero-config option for GHA runners. - name: Build image (buildx, GHA layer cache) - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: ./Dockerfile diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 00000000..dc5b29b6 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,87 @@ +name: Fuzz + +# Coverage-guided fuzzing of the pure parsers in fuzz/, via ClusterFuzzLite. +# +# Two cadences, because they answer different questions: +# +# * on pull_request — a short batch over the changed tree, so a crash +# introduced by the diff is caught before merge. Kept to 120s per +# harness: long enough to exercise the mutated surface, short enough +# not to become the slowest required check. +# * on schedule — a longer run from the accumulated corpus, which is +# where a fuzzer actually finds things. Nothing blocks on it. +# +# Why not a required check on the schedule run: a fuzzer that has run for +# hours will eventually find SOMETHING, and blocking merges on an +# asynchronous discovery would make the queue hostage to an unrelated +# input. The PR batch blocks; the deep run reports. +# +# Findings land as artifacts (crash reproducer + stacktrace). The fix +# workflow is: add the reproducer to fuzz/corpus//repro-, +# fix the code, and the committed corpus keeps it fixed forever — that +# corpus is replayed by the ordinary pytest suite +# (tests_py/fuzz/test_corpus_replay.py), on every platform, with no +# atheris needed. + +on: + pull_request: + branches: [main] + schedule: + # Weekly, Monday 04:17 UTC. Off the hour to avoid the scheduling spike + # that delays every cron firing at :00. + - cron: "17 4 * * 1" + workflow_dispatch: + +permissions: read-all + +jobs: + pr-batch: + name: Fuzz (PR batch) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sanitizer: [address, undefined] + steps: + - name: Build fuzzers (${{ matrix.sanitizer }}) + id: build + uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 + with: + sanitizer: ${{ matrix.sanitizer }} + language: python + # Build the base commit too, so the run only reports crashes this + # PR introduces rather than pre-existing ones. Without it every PR + # inherits the backlog and the check is ignored within a week. + bad-build-check: false + - name: Run fuzzers (${{ matrix.sanitizer }}) + uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + fuzz-seconds: 120 + mode: code-change + sanitizer: ${{ matrix.sanitizer }} + output-sarif: true + + batch: + name: Fuzz (scheduled batch) + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sanitizer: [address, undefined] + steps: + - name: Build fuzzers (${{ matrix.sanitizer }}) + uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 + with: + sanitizer: ${{ matrix.sanitizer }} + language: python + - name: Run fuzzers (${{ matrix.sanitizer }}) + uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + fuzz-seconds: 900 + mode: batch + sanitizer: ${{ matrix.sanitizer }} + output-sarif: true diff --git a/.github/workflows/marketplace-pins.yml b/.github/workflows/marketplace-pins.yml index 6b307931..da35a239 100644 --- a/.github/workflows/marketplace-pins.yml +++ b/.github/workflows/marketplace-pins.yml @@ -31,7 +31,7 @@ jobs: name: pins current vs latest releases runs-on: ubuntu-latest steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check marketplace pins env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/mcp-toplist-badge.yml b/.github/workflows/mcp-toplist-badge.yml index b28b1415..f04197f6 100644 --- a/.github/workflows/mcp-toplist-badge.yml +++ b/.github/workflows/mcp-toplist-badge.yml @@ -31,7 +31,7 @@ jobs: contents: write pull-requests: write steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 diff --git a/.github/workflows/publish-ccplugins.yml b/.github/workflows/publish-ccplugins.yml index 242175f3..a9b82f0d 100644 --- a/.github/workflows/publish-ccplugins.yml +++ b/.github/workflows/publish-ccplugins.yml @@ -41,7 +41,7 @@ jobs: - name: Checkout Cortex if: steps.guard.outputs.has_pat == 'true' - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: cortex fetch-depth: 0 @@ -60,7 +60,7 @@ jobs: - name: Checkout fork of ccplugins if: steps.guard.outputs.has_pat == 'true' - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ env.FORK }} token: ${{ secrets.CCPLUGINS_PAT }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b1ef37c..9845cd8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,7 +54,7 @@ jobs: DATABASE_URL: postgresql://cortex:cortex@localhost:5432/cortex steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -87,13 +87,16 @@ jobs: PGPASSWORD=cortex psql -h localhost -U cortex -d cortex -c "SELECT version();" - name: Cache HuggingFace models - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/huggingface key: ${{ runner.os }}-hf-all-MiniLM-L6-v2 - name: Install dependencies - run: pip install -e ".[dev,postgresql]" + # Hash-pinned from uv.lock (scripts/generate_pip_constraints.py). + run: | + pip install --require-hashes -r requirements/release.txt + pip install --no-deps -e . - name: Pre-download embedding model run: python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2', device='cpu')" @@ -109,7 +112,7 @@ jobs: permissions: contents: write # create the release + attach auto-generated notes steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -146,7 +149,7 @@ jobs: id-token: write # OIDC token Sigstore exchanges for a signing cert attestations: write # record the build-provenance statement on the repo steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -154,7 +157,7 @@ jobs: python-version: "3.12" - name: Install build tools - run: pip install build + run: pip install --require-hashes -r requirements/packaging.txt - name: Build sdist and wheel run: python -m build @@ -182,7 +185,7 @@ jobs: dist/*.tar.gz - name: Upload build artifacts (for the PyPI job) - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dist # Only the distributions go to PyPI; the .sha256 companions are @@ -218,7 +221,7 @@ jobs: id-token: write # OIDC for attestation attestations: write # record the provenance statement steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -277,7 +280,7 @@ jobs: id-token: write steps: - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: dist path: dist/ diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 73f5b566..90438edb 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -44,7 +44,7 @@ jobs: actions: read steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -56,6 +56,6 @@ jobs: publish_results: true - name: Upload SARIF to code scanning - uses: github/codeql-action/upload-sarif@4187e74d05793876e9989daffde9c3e66b4acd07 # v3 + uses: github/codeql-action/upload-sarif@c54b30b7df092240050e69945842bc67aee0f0f4 # v4.37.3 with: sarif_file: results.sarif diff --git a/.github/workflows/sync-ccplugins-fork.yml b/.github/workflows/sync-ccplugins-fork.yml index 4f9da00d..321ea32c 100644 --- a/.github/workflows/sync-ccplugins-fork.yml +++ b/.github/workflows/sync-ccplugins-fork.yml @@ -46,7 +46,7 @@ jobs: - name: Checkout fork if: steps.guard.outputs.has_pat == 'true' - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ env.FORK }} token: ${{ secrets.CCPLUGINS_PAT }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 34637085..bff144df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Security +- **Every dependency install is hash-pinned, and the two that could not be pinned were replaced** (#203; closes all 21 OpenSSF Scorecard Pinned-Dependencies alerts). An exact version is **not a pin**: `foo==1.2.3` still resolves to whatever the index serves under that version today, and only a hash pins the bytes — which is what Scorecard's check encodes and why `ruff==0.15.20` and `torch==2.11.0` counted as unpinned. `pip install --require-hashes` is all-or-nothing, so it needs a resolved lock; **`uv.lock` becomes the single source of truth** and `scripts/generate_pip_constraints.py` exports one hashed file per call site into `requirements/`, refusing an export that is empty or carries an unhashed requirement. `--check` is a blocking Lint step, so a lock change that is not re-exported fails there instead of at install time. All 21 sites rewired: `ci.yml` ×7, `release.yml` ×2, `scripts/setup.sh`, and the three Dockerfiles. The project itself installs `--no-deps` against the hashed set; the root image builds a **wheel** instead, because an editable install leaves a `.pth` pointing at a build directory the runtime stage never copies. CI tool pins (ruff, pyright, build+hatchling) moved into `[dependency-groups]` so they are locked rather than restated as bare version strings in two workflow files — `hatchling` is pinned too, since `python -m build` with isolation would otherwise fetch the build backend from PyPI mid-build, outside every hash check. **The CPU-only torch build is now described by the lock**: the containers passed `--index-url https://download.pytorch.org/whl/cpu` at the call site, so `uv.lock` recorded PyPI's artifact while the image installed a different one and no source of truth could produce a hash for what was actually installed. `[[tool.uv.index]]` + `[tool.uv.sources]` bind torch to that index on Linux; the lock now carries `torch 2.13.0+cpu` with 22 hashes and resolution **drops 18 nvidia/cuda packages plus triton**. torch is named in a `container` dependency-group purely so the source can bind to it — PEP 735 groups are not published, so nothing changes for anyone installing `hypermnesia-mcp` from PyPI. The two non-pip findings had to stop being what they were rather than be pinned: `docker/Dockerfile` piped `https://deb.nodesource.com/setup_22.x` **into bash** — an unreviewed remote script executed as root at build time, with no hash to check a pipe against — and now does what that script does (fetch the signing key, register the signed apt source, install the signed package, with curl feeding `gpg --dearmor`, which executes nothing); and `npm install -g @anthropic-ai/claude-code` was **unversioned**, so the image tracked whatever the registry served that minute, and is now `npm ci` against a committed lockfile that records a sha512 integrity hash for every transitive package. **Making the lock the install source exposed a latent defect in the lock itself**, which is the point of doing it: `uv.lock` had `onnxruntime 1.24.3` recorded for the `python_full_version < '3.11'` fork, and onnxruntime 1.24.x publishes no cp310 artifact and no sdist (1.24.3 ships 24 files whose lowest interpreter tag is cp311; 1.24.0 declares no `Requires-Python` at all, which is why uv accepted it there). The entry was already on `main` and stayed invisible because `main` installs from `pyproject.toml`, where pip quietly re-resolves onnxruntime down to 1.23.2 on 3.10 — a hash-pinned install cannot, so `Test (Python 3.10)` died with `No matching distribution found for onnxruntime==1.24.3`. Fixed at the lock with a `[tool.uv] constraint-dependencies` entry (`onnxruntime<1.24 ; python_full_version < '3.11'`) rather than at the workflow: constraints steer only our resolution and are never published in the wheel metadata, so consumers on 3.11+ still resolve the current onnxruntime. Every requirements file was then re-checked with `pip install --dry-run --require-hashes` on **linux/amd64** against each Python its consumers actually use, and onnxruntime was the only package in the set with this defect. +- **Coverage-guided fuzzing** (closes the Scorecard Fuzzing alert). Two harnesses in `fuzz/` over pure parsers that read untrusted text (§13.1 D2 — LLM-generated content is untrusted): the hand-rolled YAML frontmatter parser and the wiki source-path canonicaliser. Wired to **ClusterFuzzLite** (`.clusterfuzzlite/`, `.github/workflows/fuzz.yml`) — a 120s batch on PRs that blocks, and a longer scheduled run that does not, because a fuzzer left running will eventually find something and holding the merge queue hostage to an unrelated input makes the check ignored within a week. Writing the path harness **found a live bug**: `normalize_source_path` stripped `./` in a loop and then `/` exactly once, so removing the slashes could expose a `./` the loop had already walked past — `.//./x` came out as `./x`, still carrying the prefix the function exists to remove, and not idempotent. `extract_document_paths` dedupes on that result, so one document reachable by two spellings counted as two. Fixed by iterating to a fixed point; the four reproducers are committed as corpus inputs and **fail on the pre-fix code**. `fuzz/replay_corpus.py` runs every corpus input through its harness with no atheris, so the properties execute in the ordinary `pytest` suite on every platform — atheris publishes manylinux x86_64 wheels for cpython 3.12–3.14 and nothing else, and a property only one CI job can run is one that rots. + +### Fixed +- **`docker/Dockerfile` could not build at all**: it copied `/usr/local/lib/python3.12/site-packages` from the builder against a `python:3.14` base — a path absent from both stages since the base image moved off 3.12. Invisible because **no CI job built this image**; it now installs into a version-free venv (the rule the root Dockerfile already documents, from the incident where a literal `python3.13` path broke on every base bump). Both `docker/Dockerfile` and `.devcontainer/Dockerfile` gain build jobs in `ci.yml`, so the next such breakage is visible. +- **`scripts/setup.sh` reported success over any install failure**: the dependency install ended in `2>/dev/null`, which is where a resolution failure, a hash mismatch and a network error all appear, and the script printed "Python packages installed" regardless. Its exit status is now checked. Its hand-written package list — a duplicate of `pyproject.toml` — had already drifted from it, asking for `sentence-transformers>=2.2.0` against a real floor of `>=3.0.0`, and is replaced by the generated hashed file. +- **`.gitattributes` marks `fuzz/corpus/**` as binary**: end-of-line normalisation would have rewritten the CRLF corpus seed to LF on checkout, silently deleting the case that seed exists to cover. +- **`.bestpractices.json` was committed carrying four unresolved merge-conflict blocks**, which left it invalid JSON — and the file is transcribed into the OpenSSF Best Practices questionnaire, so an unparseable copy is a broken consumer rather than a stale number. It passed the doc-claim gate, CodeQL and 18 green checks, because `.bestpractices.json` is one of that gate's own `SCANNED_FILES` and every check it runs is a claim regex: a regex matches the first side of a conflict and never looks at the file's structure. Both sides of all four blocks were byte-identical, so the repair is lossless (verified by comparing the sides, not by choosing one). The gate now also runs `check_no_conflict_markers` and `check_scanned_json_parses`, both derived from `SCANNED_FILES` so a newly scanned file is enrolled with no further edit, and both failing closed on a file they cannot read. Only the **labelled** markers are matched (`<<<<<<< HEAD`, `>>>>>>> origin/main`) — a bare `=======` is a legal setext H1 underline in Markdown, and most scanned files are Markdown, so matching it would fail honest documents; a test pins that. + ### Added - **The README's repo-derived badges are now self-hosted SVGs, gated in CI** — `assets/badge-{license,python,tests,references,version}.svg`, `scripts/generate_repo_badges.py`, `scripts/badge_render.py`. Five hotlinked `img.shields.io` images became committed files, for the reason the MCP Toplist badge already was one: a remote badge URL is a third-party request fired on every README view, and it lets its host restate our claim with no commit in this repository. **Only repo-derived figures were converted**, and the line is deliberate — every one of these (licence, Python floor, collected test count, bibliography size, package version) is determined BY THIS REPOSITORY, so a committed copy can always be made true again from the working tree with no network access. That is why they are kept honest by a **blocking `--check` gate on every push and PR** rather than by a cron: drift is caught where it is introduced. **Two badges were deliberately NOT converted and must not be:** the CI status badge reports the LIVE result of the last run on main, so a committed copy would assert "passing" while main was broken — a static build-status badge is a false claim by construction, not merely a stale one — and it is GitHub-hosted, so it is not a third-party beacon in the first place; the OpenSSF Best Practices badge reflects an external body's live assessment that can be downgraded without any commit here, and `.bestpractices.json` separately justifies displaying THEIR badge image. The MCP Toplist badge sits between the two and stays committed because it carries an explicit "as of " stamp, which keeps a stale copy a true statement about a point in time. **The conversion had to rewire the gate it would otherwise have silenced:** `check_doc_claims.py` enforced the version and test-count claims by regex over the shields.io URLs (`badge/version-(\d+\.\d+\.\d+)`, `badge/tests-(\d+)_passing`), so self-hosting alone would have left both patterns matching nothing while the gate still reported success. They now read the figure out of the committed SVG's own `` and **fail closed** on a missing file or an unmatched title — the predecessor's `if badge and ...` passed silently the moment its subject disappeared. A new check also fails any reintroduced `img.shields.io` hotlink in the README, so reverting this is loud rather than quiet. Found by the new tests before shipping: `--` is illegal inside an XML comment, and the first cut described its own gate as `--check` and its source as `pytest --collect-only` in the provenance comment, leaving **all five badges unparseable**; the renderer now parses every badge it produces and refuses to return one that is not well-formed, so no future wording can reintroduce the class. The shared renderer was extracted from the MCP Toplist refresher first, as a separate behavior-preserving commit whose proof is that `assets/badge-mcp-toplist.svg` is byte-identical and its 51 tests pass unchanged. Suite grows 6348 → 6373. - **MCP Toplist rank badge, self-hosted and refreshed on a cron** — `assets/badge-mcp-toplist.svg`, `scripts/refresh_mcp_toplist_badge.py`, `.github/workflows/mcp-toplist-badge.yml`. The badge records that Cortex ranks in the top 1.2% of MCP servers tracked by MCP Toplist (#964 of 81,919, read 2026-07-28). It is a **committed file, not a hotlinked remote image**: a remote badge URL is a third-party beacon fired on every README view, and it lets its host restate the claim at any time with no commit in this repository. The arriving PR (#241, closed) proposed exactly that. The cost of the static choice is that the badge cannot self-update — the date it carries is part of the claim and goes stale by INACTION, and inaction never opens a PR — so a monthly workflow regenerates it and proposes the diff. Monthly is deliberate: the badge stamps a month, so it is the least frequent cadence that keeps the date honest and the most frequent one that does not churn a PR proposing an identical file. Wording is **attributive throughout** (`RANKED in this tier by MCP Toplist`, never `is a top-1.2% server`), because upstream's own methodology page states the score "is a popularity and activity signal, not a quality assessment" and keeps ~25% of its weighting (organic reach, package downloads) undisclosed — the figure is attributable, not reproducible. Every generated SVG carries its own audit trail in a comment: source path, raw rank and field size, the percentile arithmetic, and the verify URL, so the next maintainer can re-derive the claim from the file alone. **Two extraction paths, fail-closed:** the structured export at `/data/leaderboard.json` is tried first but returns HTTP 503 (measured 2026-07-28: 3/3 attempts, 8–14s each under a browser UA, i.e. a server-side generation timeout, not UA gating or rate limiting), so its schema has never been observed and the parser accepts only a narrow set of documented candidate shapes under strict validation rather than guessing at one; the fallback is the server page's prose sentence `ranks #N of M servers tracked`, the ONLY construct on that page carrying both numbers (the `<title>`, og/twitter meta tags and all four JSON-LD blocks carry the rank without the total, so none can yield a percentile). Both paths feed one validator that refuses a non-numeric, zero, negative, or beyond-the-field figure — guarding the division in `percentile()` and the semantics of the claim — and a figure that fails validation is **never written**: the script exits non-zero, the badge keeps its last good value, and the run goes red. A fallback is never silent; the path that failed is reported even when a later one succeeds. No `pull_request` trigger, so a required check never depends on a third party's uptime; the 51 unit tests cover the logic with no network at all. Caught by those tests before shipping: a top-of-field rank renders the tier as `Top <0.1%`, whose unescaped `<` **made the badge invalid XML** — text and attributes are now XML-escaped, verified across the rank-1, mid-field and last-place boundaries. Note that PRs opened with the default `GITHUB_TOKEN` do not trigger workflows, so `main`'s required checks will not start on them; the workflow prefers an optional `BADGE_REFRESH_TOKEN` secret when present. Suite grows 6297 → 6373. diff --git a/CLAUDE.md b/CLAUDE.md index cecd17d0..5169b0ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ coding write gates, causal graphs, and intent-aware retrieval. - Install (dev): `uv pip install -e ".[dev]"` — SQLite backend: `".[dev,sqlite]"` - Environment preflight: `python -m mcp_server.doctor` (backend-aware check list, fix message per check) -- Tests: `pytest` (full suite, 6376 tests) · `pytest tests_py/core/` (one layer) · `pytest --cov=mcp_server --cov-report=term-missing` +- Tests: `pytest` (full suite, 6426 tests) · `pytest tests_py/core/` (one layer) · `pytest --cov=mcp_server --cov-report=term-missing` - Lint BEFORE every commit: `ruff check && ruff format --check` — the CI enforces **both**; passing only `ruff check` is not enough. - Release gate benchmarks (isolated, ephemeral container — the only source of truth for pre-tag/floor decisions): `benchmarks/reproduce.sh`. Do NOT diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e878d484..9f7180a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,7 +46,7 @@ bash scripts/setup.sh # macOS / Linux # Verify everything is wired uvx --python 3.13 --from "hypermnesia-mcp[postgresql]" cortex-doctor -# Run tests (6376 tests under tests_py/) +# Run tests (6426 tests under tests_py/) pytest # Run a benchmark @@ -134,7 +134,7 @@ The full standard lives in ## Testing ```bash -pytest # full suite (6376 tests) +pytest # full suite (6426 tests) pytest tests_py/core # core (pure business logic) only pytest tests_py/integration # PostgreSQL-backed integration pytest tests_py/benchmarks -k locomo # subset diff --git a/Dockerfile b/Dockerfile index ddb8a124..62d4636b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,17 +55,38 @@ COPY pyproject.toml README.md ./ COPY mcp_server ./mcp_server COPY tests_py ./tests_py -# CPU-only torch wheel: sentence-transformers pulls torch transitively as -# a mandatory base dependency (pyproject.toml); pip's default index -# resolves the full CUDA build (~2GB across torch/cudnn/cusparselt/ -# cublas/etc.) even though this image never uses a GPU. Pinning the CPU -# wheel index first, same as docker/Dockerfile:33-34, keeps this layer's -# download bounded — source: measured 2026-07-12, root Dockerfile build -# pulled 2GB+ of nvidia-cu13-* wheels before this pin was added (H2, -# fix/bare-container-contract root-cause report). -RUN pip install --no-cache-dir --upgrade pip build && \ - pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu && \ - pip install --no-cache-dir .[postgresql] +# Dependencies, hash-pinned from uv.lock via +# scripts/generate_pip_constraints.py. Every requirement carries a hash and +# pip refuses anything whose bytes do not match, so this layer is +# reproducible and cannot be substituted at the index. +# +# The CPU-only torch build is part of that file rather than a separate +# `--index-url` flag. sentence-transformers pulls torch as a mandatory base +# dependency, and the default index serves the CUDA build — ~2GB of +# nvidia-cu13-* wheels in an image that never sees a GPU (measured +# 2026-07-12, H2 of the fix/bare-container-contract root-cause report). The +# index is declared once in pyproject.toml's [[tool.uv.index]]; the lock +# records the +cpu wheels and their hashes. +# +# `--upgrade pip build` is gone: it was itself an unpinned install, and +# `build` was never invoked in this file. The base image is digest-pinned, +# so its pip is a known quantity. +COPY requirements/runtime-postgresql.txt requirements/packaging.txt /tmp/ +RUN pip install --no-cache-dir --require-hashes -r /tmp/runtime-postgresql.txt + +# The project itself, as a built wheel installed with --no-deps. +# +# Not `pip install .[postgresql]` (that re-resolves every dependency, +# unpinned) and not `-e .` (an editable install leaves a .pth pointing at +# /build, which the runtime stage does not copy — the venv would arrive +# broken). A wheel is a self-contained artifact, and --no-deps guarantees +# the hashed set above is the whole dependency graph. +# +# --no-isolation so the build backend is the hashed hatchling from +# packaging.txt rather than one fetched from PyPI mid-build. +RUN pip install --no-cache-dir --require-hashes -r /tmp/packaging.txt && \ + python -m build --wheel --no-isolation --outdir /tmp/dist . && \ + pip install --no-cache-dir --no-deps /tmp/dist/*.whl # ── Runtime stage ──────────────────────────────────────────────────────── diff --git a/README.md b/README.md index 95f0169f..f4fd6538 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ <a href="https://github.com/cdeust/Cortex/actions/workflows/ci.yml"><img src="https://github.com/cdeust/Cortex/actions/workflows/ci.yml/badge.svg" alt="CI"></a> <a href="LICENSE"><img src="assets/badge-license.svg" alt="License: MIT"></a> <img src="assets/badge-python.svg" alt="Python 3.10+"> - <img src="assets/badge-tests.svg" alt="6376 tests passing"> + <img src="assets/badge-tests.svg" alt="6426 tests passing"> <img src="assets/badge-references.svg" alt="97 referenced papers"> <img src="assets/badge-version.svg" alt="Version 4.16.0"> <a href="https://www.bestpractices.dev/projects/13836"><img src="https://www.bestpractices.dev/projects/13836/badge" alt="OpenSSF Best Practices"></a> @@ -526,7 +526,7 @@ Cortex is **local-first**: your memories, conversations, and profiles stay on yo ## Development ```bash -pytest # 6376 tests +pytest # 6426 tests ruff check . # Lint ruff format --check . # Format python scripts/check_doc_claims.py # advertised counts must match the repo diff --git a/assets/badge-tests.svg b/assets/badge-tests.svg index 0eade110..64c35b14 100644 --- a/assets/badge-tests.svg +++ b/assets/badge-tests.svg @@ -1,5 +1,5 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="126" height="20" viewBox="0 0 126 20" role="img" aria-label="6376 tests passing"> - <title>6376 tests passing + + 6426 tests passing