Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ AGENTS.md

todo.md
notes/
.cursor-persona/
docs/superpowers/
.worktrees/
docker/dogfood-staging/
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@

## Unreleased

## v0.1.9

### Fixed

- `shipgate.__version__` follows the installed package metadata (`pyproject.toml`),
so it cannot drift from the published version.
- `import-linter.check` uses layout detection for an importable package (src or
flat) instead of requiring `src/*/__init__.py`.

## v0.1.8

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion docs/check-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ uv run shipgate list checks
| `tags` | Metadata labels (e.g. `security`); filter with `shipgate list tools --tag` |
| `cache` | Optional result-cache policy (`results`, `ttl_seconds`). Keys include scoped file contents, tool version, config bytes, and check bindings (threshold / metric extras). `--no-cache` disables the cache; `--display-cli` prints `(cached)` on a hit. |
| `suggest_if` | Additive init hints when matching files exist (does not change default suites) |
| `require_if` | Skip the check (exit 0, skipped status) unless matching files exist; e.g. `files_present: ["pyproject.toml"]` or `src/*/__init__.py` for import-linter. Require-if skips print to stderr (the glob is in the reason). `no matching files in scope` stays silent unless `--display-cli`. |
| `require_if` | Skip the check (exit 0, skipped status) unless prerequisites match: `files_present` globs and/or `importable_package` (layout-detected package with `__init__.py`, src or flat). Require-if skips print to stderr. `no matching files in scope` stays silent unless `--display-cli`. |

Project overlays under `.shipgate/catalog/tools/` can replace or `extends:` a bundled tool without editing the package.

Expand Down
7 changes: 4 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ threshold bindings. For suites, commands, and CI examples see
Policy lives in `.shipgate/shipgate.yaml` or `[tool.shipgate]` in `pyproject.toml`
(see `.shipgate/pyproject.toml.example` after init). `shipgate init` also scaffolds
`.shipgate/catalog/`, `.shipgate/gates/`, `.shipgate/configs/`, and cache metadata.
That includes an `importlinter.ini` starter when an importable `src/<pkg>/`
package exists (src-layout only; flat-layout packages skip `import-linter.check`
with a stderr line such as `required files not present: src/*/__init__.py`)
That includes an `importlinter.ini` starter when layout detection finds an
importable package (src-layout `src/<pkg>/` or a flat `pkg/` at the repo root).
Trees with no importable package skip `import-linter.check` with a stderr line
such as `no importable package in project layout`
and a `[tool.deptry]` section in `pyproject.toml` when missing.
Managed env prepends `src/` onto `PYTHONPATH` when that src-layout package
exists, so `lint-imports` can import the package without a consumer `.pth`
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "shipgate"
version = "0.1.8"
version = "0.1.9"
description = "Portable quality-gate orchestrator for developer tools"
readme = "README.md"
requires-python = ">=3.11,<3.15"
Expand Down
7 changes: 6 additions & 1 deletion src/shipgate/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
"""ShipGate — portable, metadata-driven quality-gate orchestrator."""

from importlib.metadata import PackageNotFoundError, version

from shipgate.api import install, load_catalog, run

__version__ = "0.1.5"
try:
__version__ = version("shipgate")
except PackageNotFoundError:
__version__ = "0.0.0"

__all__ = [
"__version__",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Enforces layer and forbidden-import contracts (architecture / policy).
# `shipgate init` scaffolds configs/importlinter.ini with optional starter layers
# only when an importable src-layout package exists (`src/<pkg>/__init__.py`).
# when layout detection finds an importable package (src-layout or flat).
# Customize contracts for your architecture. The package under test must be
# importable. Managed env prepends src/ onto PYTHONPATH when src/<pkg>/__init__.py
# exists. Skipped when no src-layout package is present (require_if).
# exists. Skipped when no importable package is present (require_if.importable_package).
import-linter.check:
executable: lint-imports
display_name: import-linter
Expand Down Expand Up @@ -44,8 +44,4 @@ import-linter.check:
- .py
delivery: root
require_if:
files_present:
- src/*/__init__.py
suggest_if:
files_present:
- src/*/__init__.py
importable_package: true
2 changes: 1 addition & 1 deletion src/shipgate/catalog/bundled/configs/importlinter.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# ShipGate starter import contracts (copied by `shipgate init` / `configs sync`).
# Only scaffolded when an importable src-layout package is detected.
# Only scaffolded when an importable package is detected (src-layout or flat).
# Replace optional layer names with your real package layout, or switch to
# forbidden / independence contracts. root_package must be importable.
# Managed env prepends src/ onto PYTHONPATH when src/<pkg>/__init__.py exists.
Expand Down
1 change: 1 addition & 0 deletions src/shipgate/catalog/core/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ def _parse_require_if(raw: dict | None) -> RequireIfDefinition | None:
return (
RequireIfDefinition(
files_present=tuple(str(item) for item in raw.get("files_present", []) or []),
importable_package=raw.get("importable_package") is True,
)
if raw
else None
Expand Down
6 changes: 4 additions & 2 deletions src/shipgate/catalog/core/validate_tool_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,7 @@ def validate_suggest_if(tool: ToolDefinition) -> None:
def validate_require_if(tool: ToolDefinition) -> None:
if tool.require_if is None:
return
if not tool.require_if.files_present:
raise CatalogError(f"tool {tool.id!r} require_if.files_present must not be empty")
if not tool.require_if.files_present and not tool.require_if.importable_package:
raise CatalogError(
f"tool {tool.id!r} require_if must set files_present or importable_package"
)
3 changes: 2 additions & 1 deletion src/shipgate/domain/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,10 @@ class SuggestIfDefinition:

@dataclass(frozen=True)
class RequireIfDefinition:
"""Skip the tool at check time unless at least one pattern matches."""
"""Skip the tool at check time unless prerequisites match."""

files_present: tuple[str, ...] = ()
importable_package: bool = False


@dataclass(frozen=True)
Expand Down
16 changes: 11 additions & 5 deletions src/shipgate/planning/check_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
effective_incremental,
tool_paths_after_incremental,
)
from shipgate.project.layout.packages import detect_importable_packages
from shipgate.project.python import discover_project_python

if TYPE_CHECKING:
Expand Down Expand Up @@ -141,12 +142,17 @@ def prepare(self, selected: SelectedTool, command: RunCommand) -> PreparedRun:
return PreparedRun(request=resolved)

def _require_if_skip_reason(self, tool: ToolDefinition) -> str | None:
if tool.require_if is None or not tool.require_if.files_present:
require_if = tool.require_if
if require_if is None:
return None
if any_files_present(self.project_root, tool.require_if.files_present):
return None
patterns = ", ".join(tool.require_if.files_present)
return f"required files not present: {patterns}"
if require_if.importable_package and not detect_importable_packages(self.project_root):
return "no importable package in project layout"
if require_if.files_present and not any_files_present(
self.project_root, require_if.files_present
):
patterns = ", ".join(require_if.files_present)
return f"required files not present: {patterns}"
return None

@staticmethod
def _requires_python_skip_reason(tool: ToolDefinition) -> str | None:
Expand Down
24 changes: 2 additions & 22 deletions src/shipgate/project/config_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
update_project_cache_env,
)
from shipgate.project.layout import detect_layout
from shipgate.project.layout.packages import detect_importable_root_package
from shipgate.project.scope_defaults import (
render_scopes_toml,
render_scopes_yaml,
Expand Down Expand Up @@ -57,27 +58,6 @@ def bundled_template_path(tool: ToolDefinition) -> Path:
return bundled_root_path() / tool.configuration.bundled


def detect_importable_root_package(project_root: Path) -> str | None:
"""Importable src-layout package name for import-linter / deptry scaffolding."""
return RootPackageDetector(project_root).from_src_layout()


class RootPackageDetector:
def __init__(self, project_root: Path) -> None:
self.root = project_root.resolve()

def from_src_layout(self) -> str | None:
src = self.root / "src"
if not src.is_dir():
return None
packages = sorted(
path.name
for path in src.iterdir()
if path.is_dir() and not path.name.startswith(".") and (path / "__init__.py").is_file()
)
return packages[0] if packages else None


def render_root_package_template(text: str, root_package: str) -> str:
return text.replace(ROOT_PACKAGE_PLACEHOLDER, root_package)

Expand Down Expand Up @@ -214,7 +194,7 @@ def scaffold_bundled_configs(project_root: Path, catalog: Catalog) -> list[Path]
root = project_root.resolve()
created: list[Path] = []
seen: set[Path] = set()
# Only substitute import-linter placeholders when a real src package exists.
# Only substitute import-linter placeholders when a real package exists.
importable_package = detect_importable_root_package(root)
for tool in catalog.tools.values():
rel = project_config_relpath(tool)
Expand Down
63 changes: 63 additions & 0 deletions src/shipgate/project/layout/packages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Map layout python dirs to importable package names."""

from __future__ import annotations

from typing import TYPE_CHECKING

from shipgate.project.layout.engine import LayoutEngine

if TYPE_CHECKING:
from pathlib import Path


def detect_importable_packages(project_root: Path) -> tuple[str, ...]:
"""Importable package names from layout detection (src or flat)."""
return RootPackageDetector(project_root).packages()


def detect_importable_root_package(project_root: Path) -> str | None:
"""First importable package name for import-linter / deptry scaffolding."""
packages = detect_importable_packages(project_root)
return packages[0] if packages else None


def has_src_layout_package(project_root: Path) -> bool:
"""True when ``src/<pkg>/__init__.py`` exists (managed PYTHONPATH=src)."""
return RootPackageDetector(project_root).from_src_layout() is not None


class RootPackageDetector:
def __init__(self, project_root: Path) -> None:
self.root = project_root.resolve()

def packages(self) -> tuple[str, ...]:
names: list[str] = []
for rel in LayoutEngine(self.root).detect().python_dirs:
names.extend(self._names_for_python_dir(rel))
return tuple(sorted(set(names)))

def from_src_layout(self) -> str | None:
found = self._packages_under(self.root / "src")
return found[0] if found else None

def _names_for_python_dir(self, rel: str) -> tuple[str, ...]:
if rel == "src":
return self._packages_under(self.root / "src")
path = self.root / rel
return (path.name,) if (path / "__init__.py").is_file() else ()

@staticmethod
def _packages_under(parent: Path) -> tuple[str, ...]:
return (
tuple(
sorted(
path.name
for path in parent.iterdir()
if path.is_dir()
and not path.name.startswith(".")
and (path / "__init__.py").is_file()
)
)
if parent.is_dir()
else ()
)
4 changes: 2 additions & 2 deletions src/shipgate/runtime/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ def managed_environment(project_root: Path) -> ExecutionEnvironment:

def apply_src_layout_pythonpath(env: dict[str, str], project_root: Path) -> None:
"""Prepend src/ onto PYTHONPATH when an importable src-layout package exists."""
from shipgate.project.config_setup import detect_importable_root_package
from shipgate.project.layout.packages import has_src_layout_package

if detect_importable_root_package(project_root) is None:
if not has_src_layout_package(project_root):
return
src = str((project_root / "src").resolve())
parts = [part for part in env.get("PYTHONPATH", "").split(os.pathsep) if part]
Expand Down
11 changes: 11 additions & 0 deletions tests/unit/catalog/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ def test_parse_tool_require_if():
tool = catalog.get_tool("demo.tool")
assert tool.require_if is not None
assert tool.require_if.files_present == ("pyproject.toml",)
assert tool.require_if.importable_package is False


def test_parse_tool_require_if_importable_package():
raw = demo_tool_raw()
raw["require_if"] = {"importable_package": True}
catalog = CatalogParser.parse({"tools": {"demo.tool": raw}, "suites": {}})
tool = catalog.get_tool("demo.tool")
assert tool.require_if is not None
assert tool.require_if.files_present == ()
assert tool.require_if.importable_package is True


def test_parse_install_download_and_known_bad():
Expand Down
17 changes: 17 additions & 0 deletions tests/unit/catalog/test_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
Catalog,
CliOptionDefinition,
InstallDefinition,
RequireIfDefinition,
SuiteDefinition,
ToolDefinition,
)
Expand Down Expand Up @@ -115,6 +116,22 @@ def test_validator_rejects_invalid_requires_python():
CatalogValidator.validate(catalog)


def test_validator_rejects_empty_require_if():
catalog = Catalog(
tools={
"bad.tool": ToolDefinition(
id="bad.tool",
executable="bad",
modes=(RunMode.CHECK,),
require_if=RequireIfDefinition(),
)
},
suites={},
)
with pytest.raises(CatalogError, match="files_present or importable_package"):
CatalogValidator.validate(catalog)


def test_bundled_deadcode_and_semgrep_declare_requires_python():
catalog = CatalogLoader.load()
deadcode = catalog.get_tool("deadcode.check")
Expand Down
Loading
Loading