diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e0b1ab08..13591d2b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,3 +77,23 @@ jobs: run: | uv run --frozen graphify --help uv run --frozen graphify install + + nix: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Nix + uses: cachix/install-nix-action@v27 + with: + extra_nix_config: | + experimental-features = nix-command flakes + + - name: Check flake + run: nix flake check --all-systems + + - name: Build default package + run: nix build .#default + + - name: Verify built binary runs + run: nix run .#default -- --help diff --git a/.gitignore b/.gitignore index fe096379e..c80da3055 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ docs/superpowers/ .vscode/ .kilo openspec/ +result # Local benchmark scripts — never commit scripts/run_k2_*.py scripts/llm.py diff --git a/README.md b/README.md index f46a4f34a..ac70f015c 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ graphify export callflow-html | Python | 3.10+ | `python --version` | [python.org](https://www.python.org/downloads/) | | uv *(recommended)* | any | `uv --version` | `curl -LsSf https://astral.sh/uv/install.sh \| sh` | | pipx *(alternative)* | any | `pipx --version` | `pip install pipx` | +| Nix *(alternative)* | 2.4+ (flakes enabled) | `nix --version` | [nixos.org](https://nixos.org/download/) | **macOS quick install (Homebrew):** ```bash @@ -90,6 +91,36 @@ pipx install graphifyy pip install graphifyy # may need PATH setup — see note below ``` +**Nix (flake):** + +Run directly without installing: +```bash +nix run github:safishamsi/graphify +``` + +To expose `graphify` as a package inside another flake, add it as an input and reference its default package: +```nix +{ + inputs = { + graphify.url = "github:safishamsi/graphify"; + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + }; + + outputs = { nixpkgs, graphify, ... }: { + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + in { + devShells.${system}.default = pkgs.mkShell { + buildInputs = [ graphify.packages.${system}.default ]; + }; + }; + }; +} +``` + +--- + **Step 2 — register the skill with your AI assistant:** ```bash diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..b5a47d707 --- /dev/null +++ b/flake.lock @@ -0,0 +1,120 @@ +{ + "nodes": { + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1778716662, + "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1779560665, + "narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "pyproject-build-systems": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ], + "uv2nix": [ + "uv2nix" + ] + }, + "locked": { + "lastModified": 1779676664, + "narHash": "sha256-MbXylBTkWqVm8/VYjoULtMoVRgWBN1gSHbeRKsOsPlU=", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "rev": "7bff980f37fc24e09dbc986643719900c139bf12", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "type": "github" + } + }, + "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1778901413, + "narHash": "sha256-GSKXTAnFqRAMlZkJrIPcQMYf+lpMr66K3i60mB9STvc=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "a228447c3e179d477c1b6246ef3efa8cfe3c469a", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs", + "pyproject-build-systems": "pyproject-build-systems", + "pyproject-nix": "pyproject-nix", + "uv2nix": "uv2nix" + } + }, + "uv2nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ] + }, + "locked": { + "lastModified": 1779411315, + "narHash": "sha256-IMFlxeyClau51KplhhSRGhdGTvD/knShHdybP1UOTuk=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "fdf2a76275d7a9c27deb5d2f2ab33526ac9052ff", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..73b73e0d5 --- /dev/null +++ b/flake.nix @@ -0,0 +1,216 @@ +{ + description = "flake for graphify using uv2nix"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + + flake-parts = { + url = "github:hercules-ci/flake-parts"; + inputs.nixpkgs-lib.follows = "nixpkgs"; + }; + + pyproject-nix = { + url = "github:pyproject-nix/pyproject.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + uv2nix = { + url = "github:pyproject-nix/uv2nix"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + pyproject-build-systems = { + url = "github:pyproject-nix/build-system-pkgs"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.uv2nix.follows = "uv2nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = inputs @ { + flake-parts, + pyproject-nix, + uv2nix, + pyproject-build-systems, + ... + }: + flake-parts.lib.mkFlake {inherit inputs;} { + systems = ["x86_64-linux" "aarch64-linux" "aarch64-darwin"]; + + perSystem = { + pkgs, + lib, + ... + }: let + pyproject = lib.importTOML ./pyproject.toml; + projectMeta = pyproject.project; + + workspace = uv2nix.lib.workspace.loadWorkspace {workspaceRoot = ./.;}; + + overlay = workspace.mkPyprojectOverlay { + sourcePreference = "wheel"; + }; + + editableOverlay = workspace.mkEditablePyprojectOverlay { + root = "$REPO_ROOT"; + }; + + python = pkgs.python312; + + baseSet = + (pkgs.callPackage pyproject-nix.build.packages { + inherit python; + }) + .overrideScope + ( + lib.composeManyExtensions [ + pyproject-build-systems.overlays.wheel + overlay + ] + ); + + pythonSet = baseSet.overrideScope (final: prev: { + # numba manylinux wheel dlopens libtbb.so at runtime; expose it so + # autoPatchelfHook (from pyproject-build-systems' wheel overlay) can + # resolve it on the rpath. + numba = prev.numba.overrideAttrs (old: { + buildInputs = (old.buildInputs or []) ++ [pkgs.tbb]; + }); + + # nuitka's sdist doesn't declare setuptools as a build dep. + nuitka = prev.nuitka.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem {setuptools = [];}; + }); + + # jieba's sdist doesn't declare setuptools as a build dep. + jieba = prev.jieba.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem {setuptools = [];}; + }); + + # tree-sitter-dm's sdist doesn't declare setuptools as a build dep. + tree-sitter-dm = prev.tree-sitter-dm.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem {setuptools = [];}; + }); + + # Expose tests via passthru.tests so they can be wired into flake + # checks (mirrors the uv2nix testing pattern). + graphifyy = prev.graphifyy.overrideAttrs (old: { + passthru = + (old.passthru or {}) + // { + tests = let + # Virtualenv containing graphify plus the dev dependency + # group (which carries pytest and friends). + testVenv = final.mkVirtualEnv "graphify-test-env" (workspace.deps.default + // { + graphifyy = ["dev"]; + }); + in + (old.passthru.tests or {}) + // { + pytest = pkgs.stdenv.mkDerivation { + name = "${final.graphifyy.name}-pytest"; + inherit (final.graphifyy) src; + nativeBuildInputs = [testVenv pkgs.git]; + dontConfigure = true; + + buildPhase = '' + runHook preBuild + # The Nix build sandbox sets HOME=/homeless-shelter + # which is unwritable; several tests (e.g. the Gemini + # install ones) call helpers that resolve paths via + # Path.home() when not project-scoped. Point HOME at a + # writable temp dir so those tests pass under + # `nix flake check`. + export HOME=''${PWD}/home + pytest + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + touch $out + runHook postInstall + ''; + }; + }; + }; + }); + }); + + editablePythonSet = pythonSet.overrideScope editableOverlay; + virtualenv = editablePythonSet.mkVirtualEnv "graphify-dev-env" workspace.deps.all; + + graphifyEnv = pythonSet.mkVirtualEnv "graphify-env" workspace.deps.default; + + # Wrap the virtualenv so the default package exposes the `graphify` + # entry point directly while still carrying metadata from pyproject.toml. + graphifyPackage = pkgs.stdenv.mkDerivation { + pname = projectMeta.name; + version = projectMeta.version; + + dontUnpack = true; + dontBuild = true; + dontConfigure = true; + + nativeBuildInputs = [pkgs.makeWrapper]; + + installPhase = '' + mkdir -p $out/bin + makeWrapper ${graphifyEnv}/bin/graphify $out/bin/graphify + ''; + + passthru = { + inherit graphifyEnv; + }; + + meta = { + description = projectMeta.description; + homepage = projectMeta.urls.Homepage; + license = lib.licenses.mit; + mainProgram = "graphify"; + platforms = lib.platforms.unix; + }; + }; + in { + devShells.default = pkgs.mkShell { + packages = [ + virtualenv + pkgs.uv + pkgs.python3Packages.pytest + ]; + env = { + UV_NO_SYNC = "1"; + UV_PYTHON = editablePythonSet.python.interpreter; + UV_PYTHON_DOWNLOADS = "never"; + UV_PROJECT_ENVIRONMENT = virtualenv.outPath; + VIRTUAL_ENV = virtualenv.outPath; + }; + + shellHook = '' + unset PYTHONPATH + export REPO_ROOT=$(git rev-parse --show-toplevel) + ''; + }; + + packages.default = graphifyPackage; + + checks = { + inherit (pythonSet.graphifyy.passthru.tests) pytest; + }; + + apps.default = { + type = "app"; + program = "${graphifyPackage}/bin/graphify"; + meta = graphifyPackage.meta; + }; + }; + }; +} diff --git a/graphify/extract.py b/graphify/extract.py index a56a7809c..d6c5a4029 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -11102,7 +11102,7 @@ def _ignored(p: Path) -> bool: for ext in sorted(_EXTENSIONS): results.extend( p for p in target.rglob(f"*{ext}") - if not any(_is_noise_dir(part) for part in p.parts) + if not any(_is_noise_dir(part) for part in p.relative_to(target).parts) and not _ignored(p) ) return sorted(results) diff --git a/tests/test_extract.py b/tests/test_extract.py index 0d5db2c5a..5d57916b4 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -220,7 +220,7 @@ def test_collect_files_from_dir(): def test_collect_files_skips_hidden(): files = collect_files(FIXTURES) for f in files: - assert not any(part.startswith(".") for part in f.parts) + assert not any(part.startswith(".") for part in f.relative_to(FIXTURES).parts) def test_collect_files_follows_symlinked_directory(tmp_path): diff --git a/tests/test_security.py b/tests/test_security.py index c547ab842..8b68af923 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -26,14 +26,18 @@ _sanitize_metadata_value, ) +from .test_utils import skip_in_sandbox + # --------------------------------------------------------------------------- # validate_url # --------------------------------------------------------------------------- +@skip_in_sandbox() def test_validate_url_accepts_http(): assert validate_url("http://example.com/page") == "http://example.com/page" +@skip_in_sandbox() def test_validate_url_accepts_https(): assert validate_url("https://arxiv.org/abs/1706.03762") == "https://arxiv.org/abs/1706.03762" @@ -77,6 +81,7 @@ def test_safe_fetch_rejects_ftp_url(): with pytest.raises(ValueError, match="ftp"): safe_fetch("ftp://example.com/file.zip") +@skip_in_sandbox() def test_safe_fetch_returns_bytes(tmp_path): mock_resp = _make_mock_response(b"hello world") with patch("graphify.security._build_opener") as mock_opener_fn: @@ -86,6 +91,7 @@ def test_safe_fetch_returns_bytes(tmp_path): result = safe_fetch("https://example.com/") assert result == b"hello world" +@skip_in_sandbox() def test_safe_fetch_raises_on_non_2xx(): mock_resp = _make_mock_response(b"Not Found", status=404) with patch("graphify.security._build_opener") as mock_opener_fn: @@ -95,6 +101,7 @@ def test_safe_fetch_raises_on_non_2xx(): with pytest.raises(urllib.error.HTTPError): safe_fetch("https://example.com/missing") +@skip_in_sandbox() def test_safe_fetch_raises_on_size_exceeded(): # Build a response larger than max_bytes big_chunk = b"x" * 65_537 @@ -118,6 +125,7 @@ def test_safe_fetch_raises_on_size_exceeded(): # safe_fetch_text # --------------------------------------------------------------------------- +@skip_in_sandbox() def test_safe_fetch_text_decodes_utf8(): content = "héllo wörld".encode("utf-8") mock_resp = _make_mock_response(content) @@ -128,6 +136,7 @@ def test_safe_fetch_text_decodes_utf8(): result = safe_fetch_text("https://example.com/") assert result == "héllo wörld" +@skip_in_sandbox() def test_safe_fetch_text_replaces_bad_bytes(): bad = b"hello \xff world" mock_resp = _make_mock_response(bad) diff --git a/tests/test_skillgen.py b/tests/test_skillgen.py index 474948017..394b2da39 100644 --- a/tests/test_skillgen.py +++ b/tests/test_skillgen.py @@ -13,6 +13,8 @@ import pytest +from .test_utils import skip_in_sandbox + # tests/ -> repo root is one parent up; put it on the path so tools.skillgen # imports regardless of pytest's import mode. REPO_ROOT = Path(__file__).resolve().parent.parent @@ -22,6 +24,7 @@ from tools.skillgen import gen # noqa: E402 +@skip_in_sandbox() def test_audit_coverage_passes(): """Every v8 heading lands in the lean core or exactly one reference.""" platforms = gen.load_platforms() @@ -220,6 +223,7 @@ def test_check_passes_for_codex_and_windows(): assert problems == [], f"[{key}]\n" + "\n".join(problems) +@skip_in_sandbox() def test_audit_coverage_passes_for_codex_and_windows(): """Every v8 heading single-homes for the cli-inline split hosts too.""" platforms = gen.load_platforms() @@ -355,6 +359,7 @@ def test_schema_singleton_catches_legacy_enums(): ) +@skip_in_sandbox() def test_all_progressive_hosts_check_and_audit_clean(): """check + audit-coverage pass for every rendered progressive host.""" platforms = gen.load_platforms() @@ -447,6 +452,7 @@ def test_monoliths_render_inline_single_file_no_references(): assert "references/" not in arts[0].content or "see `references/" not in arts[0].content.lower() +@skip_in_sandbox() def test_monolith_roundtrip_passes_for_aider_and_devin(): """Each monolith is diff-clean vs v8 except the file_type enum unification.""" platforms = gen.load_platforms() @@ -455,6 +461,7 @@ def test_monolith_roundtrip_passes_for_aider_and_devin(): assert problems == [], f"[{key}]\n" + "\n".join(problems) +@skip_in_sandbox() def test_monoliths_change_only_the_enum_and_the_description(): """The rendered monolith differs from v8 on exactly the enum + description lines. @@ -528,6 +535,7 @@ def test_always_on_included_in_full_render_not_per_platform(): assert "graphify/always_on/claude-md.md" not in claude_only +@skip_in_sandbox() def test_always_on_roundtrip_is_byte_faithful(): """Each always_on/*.md reproduces its former __main__.py constant byte for byte. @@ -578,6 +586,7 @@ def test_always_on_files_are_guarded_by_check(tmp_path): # --- the per-host coverage audit (the systemic guard) -------------------------- +@skip_in_sandbox() def test_audit_coverage_passes_for_every_split_host(): """Every split host's render single-homes its own v8 body's headings.""" platforms = gen.load_platforms() @@ -598,6 +607,7 @@ def test_audit_reads_each_host_against_its_own_v8_body(): assert gen._v8_baseline_ref("vscode") == "47042beb05d1f6dd2186c0c499ae2840ce604ead:graphify/skill-vscode.md" +@skip_in_sandbox() def test_audit_catches_an_induced_per_host_drop(): """Re-inducing the trae regression (claude-flavored hooks) fails the audit. @@ -615,6 +625,7 @@ def test_audit_catches_an_induced_per_host_drop(): assert any("native AGENTS.md integration (Trae)" in p for p in problems), problems +@skip_in_sandbox() def test_audit_catches_a_dropped_non_allowlisted_heading(): """A core fragment that drops a real v8 heading fails the audit. @@ -775,6 +786,7 @@ def test_amp_has_no_pretooluse_caveat_anywhere(): assert "Trae" not in b2 +@skip_in_sandbox() def test_amp_audit_coverage_passes_against_its_own_v8(): """The per-host audit (the guard amp is the exact case for) passes for amp. diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 000000000..26f21ce6f --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,14 @@ +"""Shared test utilities and markers.""" + +import os + +import pytest + + +def skip_in_sandbox(): + """Skip tests that need access to external resources (git, network) in a sandbox.""" + sandbox_variables = ["NIX_ENFORCE_PURITY"] + return pytest.mark.skipif( + any(var in os.environ for var in sandbox_variables), + reason="Sandboxed environment with limited external access" + )