diff --git a/.geodebug.toml.example b/.geodebug.toml.example index 185d768..546de49 100644 --- a/.geodebug.toml.example +++ b/.geodebug.toml.example @@ -1,3 +1,4 @@ +schema_version = "1" # GeoDebug project policy. profile = "default" fail_on = "error" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fa5170..1e87dfc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,38 @@ jobs: - name: Integration tests run: pytest tests/integration + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install build tooling + run: python -m pip install build twine + - name: Build distributions + run: python -m build + - name: Validate distributions + run: python -m twine check dist/* + - name: Install wheel in clean environment + run: | + python -m venv .wheel-smoke + .wheel-smoke/bin/python -m pip install --upgrade pip + .wheel-smoke/bin/python -m pip install dist/*.whl + .wheel-smoke/bin/geodebug --version + .wheel-smoke/bin/geodebug schema > /tmp/report.schema.json + .wheel-smoke/bin/geodebug schema --kind config > /tmp/config.schema.json + .wheel-smoke/bin/python - <<'PY' + import json + from pathlib import Path + + report = json.loads(Path("/tmp/report.schema.json").read_text()) + config = json.loads(Path("/tmp/config.schema.json").read_text()) + assert report["properties"]["schema_version"]["const"] == "1.0.0" + assert config["properties"]["schema_version"]["const"] == "1" + PY + smoke: strategy: matrix: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b090444 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install build tooling + run: python -m pip install build twine + - name: Verify tag matches package version + run: | + VERSION="$(python - <<'PY' + from pathlib import Path + import re + + text = Path("src/geodebug/version.py").read_text() + match = re.search(r'__version__ = "([^"]+)"', text) + if match is None: + raise SystemExit("package version not found") + print(match.group(1)) + PY + )" + test "${GITHUB_REF_NAME}" = "v${VERSION}" + - name: Build distributions + run: python -m build + - name: Validate distributions + run: python -m twine check dist/* + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "${GITHUB_REF_NAME}" dist/* --generate-notes --verify-tag diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..68a3ca0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to GeoDebug are documented here. + +## 0.1.0 - 2026-09-19 + +### Added + +- Deterministic fact, rule, diagnostic, and report kernel. +- GeoJSON, Pyogrio, Rasterio, GeoParquet, and GeoPandas adapters. +- Metadata-first `inspect`, `check`, and `compare` workflows. +- Operation-aware `preflight` checks. +- Project policy through `.geodebug.toml`, including profiles, rule overrides, + disable lists, and reasoned suppressions. +- Canonical report schema `1.0.0` and config schema `1`. +- Eleven built-in high-signal rules across CRS, vector, raster, relations, and + operation semantics. +- Golden false-positive contracts and cross-platform CI. diff --git a/README.md b/README.md index 60ce495..828c383 100644 --- a/README.md +++ b/README.md @@ -10,20 +10,21 @@ stable integration boundary for CLI, CI, and future agent tooling. ## Install -Core GeoJSON support: +PyPI publication is not enabled for 0.1.0. Install the tagged release directly +from GitHub: ```bash -pip install geodebug +pip install "geodebug @ git+https://github.com/GeoGeekLab/geodebug.git@v0.1.0" ``` -Optional format support: +Optional format support can be installed from the same tag: ```bash -pip install "geodebug[vector]" # GPKG, Shapefile, FlatGeobuf -pip install "geodebug[raster]" # GeoTIFF, COG, VRT -pip install "geodebug[parquet]" # GeoParquet -pip install "geodebug[geopandas]" # in-memory GeoDataFrame -pip install "geodebug[all]" +pip install "geodebug[vector] @ git+https://github.com/GeoGeekLab/geodebug.git@v0.1.0" +pip install "geodebug[raster] @ git+https://github.com/GeoGeekLab/geodebug.git@v0.1.0" +pip install "geodebug[parquet] @ git+https://github.com/GeoGeekLab/geodebug.git@v0.1.0" +pip install "geodebug[geopandas] @ git+https://github.com/GeoGeekLab/geodebug.git@v0.1.0" +pip install "geodebug[all] @ git+https://github.com/GeoGeekLab/geodebug.git@v0.1.0" ``` ## Usage @@ -68,6 +69,7 @@ GeoDebug searches the current directory and its parents for `.geodebug.toml`. An config path can be supplied with `--config`. ```toml +schema_version = "1" profile = "default" fail_on = "error" @@ -120,7 +122,7 @@ adapter -> facts -> rules -> diagnostics -> report Rules never read files directly and adapters never emit diagnostics. Unknown evidence remains `UNKNOWN`; it is never silently treated as a pass. -See [`docs/architecture.md`](docs/architecture.md) for the kernel contracts. +See [`docs/architecture.md`](docs/architecture.md) for the kernel contracts and [`docs/rules.md`](docs/rules.md) for the released diagnostic contracts. ## Development @@ -135,4 +137,6 @@ pytest ``` Golden rule cases live in `tests/golden/cases.toml`. Each case declares both expected -diagnostics and rules that must not be reported. +diagnostics and rules that must not be reported. The release gate also enforces +four-state rule contracts, metamorphic corrections, clean-corpus silence, and +installable wheel validation. diff --git a/docs/architecture.md b/docs/architecture.md index 1d0ca68..56f141c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,4 +1,4 @@ -# Kernel architecture +# Architecture GeoDebug separates observation from diagnosis. @@ -27,16 +27,24 @@ Report - Adapters observe sources and emit facts. They do not diagnose. - Rules consume normalized facts. They do not perform I/O. - `UNKNOWN` is distinct from `PASS`. +- Policy can change presentation severity or suppress a finding, but it never + changes a rule truth value. - Diagnostics carry evidence and a stable fingerprint. -- Rule IDs are stable public identifiers once released. -- The canonical JSON report is the integration boundary for CLI, CI, and future agent tooling. +- Released rule IDs are stable public identifiers. +- The canonical JSON report is the integration boundary for CLI, CI, and agent tooling. +- Report schema `1.0.0` and config schema `1` are independently versioned + from the Python package. -## Sentinel rules +## Rule scopes -The kernel starts with three rules chosen to exercise different scopes: +Dataset rules evaluate each subject independently. Relation rules evaluate a +multi-subject context. Operation rules require an explicit operation context. -- `GEO101`: dataset metadata semantics -- `GEO201`: vector geometry semantics -- `GEO501`: operation-aware measurement semantics +A new rule is not release-ready until its `PASS`, `FAIL`, `UNKNOWN`, and +`NOT_APPLICABLE` states are exercised by the contract suite. -New rules should not be added until their PASS / FAIL / UNKNOWN / NOT_APPLICABLE boundaries are explicit in tests. +## Cost model + +Metadata rules may run during ordinary checks. Full-scan rules require explicit +deep inspection. GeoDebug does not silently turn an expensive scan into a +default operation. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..646d20b --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,13 @@ +# Releasing + +GeoDebug releases are tag-driven. + +1. Ensure CI is green on `main`. +2. Confirm `CHANGELOG.md` contains the release entry. +3. Confirm `src/geodebug/version.py` contains the release version. +4. Create and push a matching `vX.Y.Z` tag. +5. The release workflow verifies the tag/version match, builds sdist and wheel, + runs `twine check`, and creates the GitHub Release with both artifacts. + +PyPI publication is intentionally not automated in 0.1.0. Configure trusted +publishing and add a separate publish job before enabling registry publication. diff --git a/docs/rules.md b/docs/rules.md new file mode 100644 index 0000000..c5e5e71 --- /dev/null +++ b/docs/rules.md @@ -0,0 +1,17 @@ +# Diagnostic rules + +GeoDebug 0.1.0 ships eleven built-in rules. Rule IDs are stable public identifiers. + +| Rule | Scope | Default | Description | +| --- | --- | --- | --- | +| [GEO101](rules/GEO101.md) | dataset | warning | Missing CRS | +| [GEO103](rules/GEO103.md) | dataset | error | Geographic coordinate out of range | +| [GEO105](rules/GEO105.md) | dataset | warning | Projected data outside CRS area of use | +| [GEO201](rules/GEO201.md) | dataset | error | Invalid geometry | +| [GEO301](rules/GEO301.md) | dataset | error | Invalid or singular raster transform | +| [GEO304](rules/GEO304.md) | dataset | warning | NoData value conflicts with valid mask | +| [GEO402](rules/GEO402.md) | relation | error | No spatial overlap | +| [GEO404](rules/GEO404.md) | relation | warning | Raster grid misalignment | +| [GEO501](rules/GEO501.md) | operation | error | Buffer on angular CRS | +| [GEO502](rules/GEO502.md) | operation | error | Planar area on geographic CRS | +| [GEO503](rules/GEO503.md) | operation | error | Planar distance on geographic CRS | diff --git a/docs/rules/GEO101.md b/docs/rules/GEO101.md new file mode 100644 index 0000000..b20e8df --- /dev/null +++ b/docs/rules/GEO101.md @@ -0,0 +1,16 @@ +# GEO101 — Missing CRS + +**Scope:** dataset +**Default severity:** warning +**Certainty:** deterministic + +Reports when a dataset explicitly has no declared coordinate reference system. +It does not guess a CRS from coordinate values. + +**PASS:** CRS presence is explicitly established. +**FAIL:** CRS metadata is explicitly absent. +**UNKNOWN:** CRS presence cannot be established. +**NOT_APPLICABLE:** no dataset subject is available. + +**Remediation:** establish the CRS from authoritative source metadata before +assigning it. Do not infer and write a CRS solely from plausible bounds. diff --git a/docs/rules/GEO103.md b/docs/rules/GEO103.md new file mode 100644 index 0000000..7978981 --- /dev/null +++ b/docs/rules/GEO103.md @@ -0,0 +1,17 @@ +# GEO103 — Geographic coordinate out of range + +**Scope:** dataset +**Default severity:** error +**Certainty:** deterministic + +Checks declared geographic data for implausible angular coordinate ranges. +Latitude must remain within ±90 degrees; longitude is allowed within the +commonly encountered -360 to 360 degree domain. + +**PASS:** geographic bounds are within supported angular ranges. +**FAIL:** observed bounds exceed those ranges. +**UNKNOWN:** finite bounds cannot be established. +**NOT_APPLICABLE:** the CRS is not geographic. + +This rule diagnoses a mismatch between declared CRS semantics and coordinates; +it does not identify the correct CRS. diff --git a/docs/rules/GEO105.md b/docs/rules/GEO105.md new file mode 100644 index 0000000..3a86c47 --- /dev/null +++ b/docs/rules/GEO105.md @@ -0,0 +1,17 @@ +# GEO105 — Outside CRS area of use + +**Scope:** dataset +**Default severity:** warning +**Certainty:** deterministic + +Checks projected datasets against the area of use published by the CRS +definition. Dataset bounds are transformed to geographic coordinates before +comparison. + +**PASS:** the observed extent overlaps the CRS area of use. +**FAIL:** the observed extent is outside the CRS area of use. +**UNKNOWN:** CRS, area-of-use, or transform evidence is unavailable. +**NOT_APPLICABLE:** the CRS is not projected. + +A failure can indicate either an inappropriate projected CRS or incorrect CRS +metadata. GeoDebug does not choose a replacement CRS automatically. diff --git a/docs/rules/GEO201.md b/docs/rules/GEO201.md new file mode 100644 index 0000000..ee2e0f5 --- /dev/null +++ b/docs/rules/GEO201.md @@ -0,0 +1,18 @@ +# GEO201 — Invalid geometry + +**Scope:** dataset +**Default severity:** error +**Certainty:** deterministic +**Cost:** full scan + +Reports vector geometries that fail the geometry engine's validity predicate. +File-based checks require deep inspection when validity is not available from +metadata. + +**PASS:** the scanned dataset contains no invalid geometries. +**FAIL:** one or more invalid geometries are present. +**UNKNOWN:** geometry validity has not been scanned. +**NOT_APPLICABLE:** the subject is not vector data. + +Automatic geometry repair is intentionally not performed because repair can +change geometry type and topology. diff --git a/docs/rules/GEO301.md b/docs/rules/GEO301.md new file mode 100644 index 0000000..fa820d6 --- /dev/null +++ b/docs/rules/GEO301.md @@ -0,0 +1,13 @@ +# GEO301 — Invalid or singular raster transform + +**Scope:** dataset +**Default severity:** error +**Certainty:** deterministic + +Validates the six affine transform coefficients and checks that the linear +transform is invertible. + +**PASS:** coefficients are finite and the determinant is non-zero. +**FAIL:** coefficients are malformed/non-finite or the transform is singular. +**UNKNOWN:** transform metadata is unavailable. +**NOT_APPLICABLE:** the subject is not raster data. diff --git a/docs/rules/GEO304.md b/docs/rules/GEO304.md new file mode 100644 index 0000000..0a3863a --- /dev/null +++ b/docs/rules/GEO304.md @@ -0,0 +1,17 @@ +# GEO304 — NoData conflicts with valid mask + +**Scope:** dataset +**Default severity:** warning +**Certainty:** deterministic +**Cost:** full scan + +Reports bands where pixels equal to the declared NoData value are simultaneously +marked valid by the dataset validity mask. + +**PASS:** a deep scan finds no conflicts. +**FAIL:** one or more bands contain mask/NoData conflicts. +**UNKNOWN:** a required deep scan has not been performed. +**NOT_APPLICABLE:** the raster has no NoData value or the subject is not raster data. + +The rule does not assume that merely observing the NoData numeric value is a +semantic collision. diff --git a/docs/rules/GEO402.md b/docs/rules/GEO402.md new file mode 100644 index 0000000..6efb533 --- /dev/null +++ b/docs/rules/GEO402.md @@ -0,0 +1,15 @@ +# GEO402 — No spatial overlap + +**Scope:** relation +**Default severity:** error +**Certainty:** deterministic + +Compares two dataset extents in a common CRS. A failure means their transformed +bounding boxes do not overlap. + +**PASS:** transformed extents overlap. +**FAIL:** transformed extents do not overlap. +**UNKNOWN:** comparable bounds cannot be established. +**NOT_APPLICABLE:** the context does not contain exactly two subjects. + +Bounding-box overlap is not treated as proof that geometries intersect. diff --git a/docs/rules/GEO404.md b/docs/rules/GEO404.md new file mode 100644 index 0000000..58f1419 --- /dev/null +++ b/docs/rules/GEO404.md @@ -0,0 +1,15 @@ +# GEO404 — Raster grid misalignment + +**Scope:** relation +**Default severity:** warning +**Certainty:** deterministic + +Checks whether two rasters with the same CRS and resolution share the same +pixel lattice. Integer-pixel origin offsets are aligned; fractional offsets are +not. + +**PASS:** grids share the pixel lattice. +**FAIL:** a fractional-pixel offset is present. +**UNKNOWN:** CRS/resolution is incompatible or rotation/skew prevents the +current deterministic test. +**NOT_APPLICABLE:** the relation is not between raster grids. diff --git a/docs/rules/GEO501.md b/docs/rules/GEO501.md new file mode 100644 index 0000000..952d8b2 --- /dev/null +++ b/docs/rules/GEO501.md @@ -0,0 +1,16 @@ +# GEO501 — Buffer on angular CRS + +**Scope:** operation +**Default severity:** error +**Certainty:** deterministic + +Reports a planar buffer operation on a geographic CRS when the supplied +distance would be interpreted in angular coordinate units. + +**PASS:** the input CRS is projected. +**FAIL:** the input CRS is geographic and a distance is supplied. +**UNKNOWN:** CRS semantics or the distance parameter is unavailable. +**NOT_APPLICABLE:** the operation is not `buffer`. + +GeoDebug recommends projecting to an appropriate linear CRS before a metric +buffer; it does not select that CRS automatically. diff --git a/docs/rules/GEO502.md b/docs/rules/GEO502.md new file mode 100644 index 0000000..c8d51ea --- /dev/null +++ b/docs/rules/GEO502.md @@ -0,0 +1,15 @@ +# GEO502 — Planar area on geographic CRS + +**Scope:** operation +**Default severity:** error +**Certainty:** deterministic + +Reports planar area measurement on a geographic CRS. + +**PASS:** the input CRS is projected. +**FAIL:** the input CRS is geographic. +**UNKNOWN:** CRS semantics cannot be established. +**NOT_APPLICABLE:** the operation is not `area`. + +The rule concerns planar coordinate-unit area. Geodesic area calculations are a +different operation contract. diff --git a/docs/rules/GEO503.md b/docs/rules/GEO503.md new file mode 100644 index 0000000..ae66748 --- /dev/null +++ b/docs/rules/GEO503.md @@ -0,0 +1,14 @@ +# GEO503 — Planar distance on geographic CRS + +**Scope:** operation +**Default severity:** error +**Certainty:** deterministic + +Reports planar `distance` or `length` measurement on a geographic CRS. + +**PASS:** the input CRS is projected. +**FAIL:** the input CRS is geographic. +**UNKNOWN:** CRS semantics cannot be established. +**NOT_APPLICABLE:** the operation is neither `distance` nor `length`. + +The rule does not prohibit explicitly geodesic measurement algorithms. diff --git a/pyproject.toml b/pyproject.toml index a848247..90f012c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,23 @@ build-backend = "hatchling.build" [project] name = "geodebug" -version = "0.1.0.dev0" +dynamic = ["version"] description = "Deterministic diagnostics for geospatial data and workflows." +keywords = ["gis", "geospatial", "crs", "raster", "vector", "diagnostics"] readme = "README.md" requires-python = ">=3.12" authors = [{ name = "GeoGeekLab" }] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Scientific/Engineering :: GIS", + "Typing :: Typed", +] dependencies = [ "pydantic>=2.9,<3", "pyproj>=3.6,<4", @@ -29,16 +41,27 @@ all = [ "rasterio>=1.4,<2", ] dev = [ + "build>=1.2,<2", "jsonschema>=4.23,<5", "mypy>=1.13,<2", "pytest>=8.3,<9", "pytest-cov>=6,<8", "ruff>=0.8,<1", + "twine>=6,<7", ] [project.scripts] geodebug = "geodebug.cli.app:main" +[project.urls] +Homepage = "https://github.com/GeoGeekLab/geodebug" +Repository = "https://github.com/GeoGeekLab/geodebug" +Issues = "https://github.com/GeoGeekLab/geodebug/issues" +Changelog = "https://github.com/GeoGeekLab/geodebug/blob/main/CHANGELOG.md" + +[tool.hatch.version] +path = "src/geodebug/version.py" + [tool.hatch.build.targets.wheel] packages = ["src/geodebug"] diff --git a/schemas/config.schema.json b/schemas/config.schema.json new file mode 100644 index 0000000..7cf45ec --- /dev/null +++ b/schemas/config.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://geogeeklab.github.io/geodebug/schemas/config-1.json", + "title": "GeoDebug Config", + "type": "object", + "additionalProperties": false, + "properties": { + "schema_version": {"const": "1", "default": "1"}, + "profile": { + "enum": ["default", "strict", "exploratory"], + "default": "default" + }, + "fail_on": { + "enum": ["error", "warning", "none"], + "default": "error" + }, + "rules": {"$ref": "#/$defs/rules"}, + "suppress": { + "type": "array", + "items": {"$ref": "#/$defs/suppression"}, + "default": [] + } + }, + "$defs": { + "rules": { + "type": "object", + "additionalProperties": false, + "properties": { + "disable": { + "type": "array", + "items": {"type": "string", "pattern": "^GEO[0-9]{3}$"}, + "default": [] + }, + "severity": { + "type": "object", + "propertyNames": {"pattern": "^GEO[0-9]{3}$"}, + "additionalProperties": {"enum": ["error", "warning", "note"]}, + "default": {} + } + } + }, + "suppression": { + "type": "object", + "additionalProperties": false, + "required": ["rule", "reason"], + "properties": { + "rule": {"type": "string", "pattern": "^GEO[0-9]{3}$"}, + "path": {"type": "string", "default": "*"}, + "reason": {"type": "string", "minLength": 1}, + "expires": {"type": ["string", "null"], "format": "date"} + } + } + } +} diff --git a/src/geodebug/cli/app.py b/src/geodebug/cli/app.py index 0375089..59ebdbe 100644 --- a/src/geodebug/cli/app.py +++ b/src/geodebug/cli/app.py @@ -34,6 +34,11 @@ class OutputFormat(StrEnum): JSON = "json" +class SchemaKind(StrEnum): + REPORT = "report" + CONFIG = "config" + + def _version_callback(value: bool) -> None: if value: typer.echo(__version__) @@ -224,10 +229,16 @@ def show_rule(rule_id: str) -> None: @app.command("schema") -def schema() -> None: - """Print the canonical report JSON Schema.""" +def schema( + kind: Annotated[ + SchemaKind, + typer.Option(help="Schema to print."), + ] = SchemaKind.REPORT, +) -> None: + """Print a canonical GeoDebug JSON Schema.""" + filename = "report.schema.json" if kind is SchemaKind.REPORT else "config.schema.json" typer.echo( - files("geodebug.schemas").joinpath("report.schema.json").read_text(encoding="utf-8"), + files("geodebug.schemas").joinpath(filename).read_text(encoding="utf-8"), nl=False, ) diff --git a/src/geodebug/config/model.py b/src/geodebug/config/model.py index a683040..94150ab 100644 --- a/src/geodebug/config/model.py +++ b/src/geodebug/config/model.py @@ -2,6 +2,7 @@ from datetime import date from enum import StrEnum +from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -62,6 +63,7 @@ def validate_rule(cls, value: str) -> str: class GeoDebugConfig(BaseModel): model_config = ConfigDict(extra="forbid") + schema_version: Literal["1"] = "1" profile: ProfileName = ProfileName.DEFAULT fail_on: FailOnName = FailOnName.ERROR rules: RulesConfig = Field(default_factory=RulesConfig) diff --git a/src/geodebug/py.typed b/src/geodebug/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/geodebug/schemas/config.schema.json b/src/geodebug/schemas/config.schema.json new file mode 100644 index 0000000..7cf45ec --- /dev/null +++ b/src/geodebug/schemas/config.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://geogeeklab.github.io/geodebug/schemas/config-1.json", + "title": "GeoDebug Config", + "type": "object", + "additionalProperties": false, + "properties": { + "schema_version": {"const": "1", "default": "1"}, + "profile": { + "enum": ["default", "strict", "exploratory"], + "default": "default" + }, + "fail_on": { + "enum": ["error", "warning", "none"], + "default": "error" + }, + "rules": {"$ref": "#/$defs/rules"}, + "suppress": { + "type": "array", + "items": {"$ref": "#/$defs/suppression"}, + "default": [] + } + }, + "$defs": { + "rules": { + "type": "object", + "additionalProperties": false, + "properties": { + "disable": { + "type": "array", + "items": {"type": "string", "pattern": "^GEO[0-9]{3}$"}, + "default": [] + }, + "severity": { + "type": "object", + "propertyNames": {"pattern": "^GEO[0-9]{3}$"}, + "additionalProperties": {"enum": ["error", "warning", "note"]}, + "default": {} + } + } + }, + "suppression": { + "type": "object", + "additionalProperties": false, + "required": ["rule", "reason"], + "properties": { + "rule": {"type": "string", "pattern": "^GEO[0-9]{3}$"}, + "path": {"type": "string", "default": "*"}, + "reason": {"type": "string", "minLength": 1}, + "expires": {"type": ["string", "null"], "format": "date"} + } + } + } +} diff --git a/src/geodebug/version.py b/src/geodebug/version.py index 4b4a921..3dc1f76 100644 --- a/src/geodebug/version.py +++ b/src/geodebug/version.py @@ -1 +1 @@ -__version__ = "0.1.0.dev0" +__version__ = "0.1.0" diff --git a/tests/clean/test_clean_corpus.py b/tests/clean/test_clean_corpus.py new file mode 100644 index 0000000..ed1a9a8 --- /dev/null +++ b/tests/clean/test_clean_corpus.py @@ -0,0 +1,63 @@ +from pyproj import CRS, Transformer + +from geodebug import evaluate +from geodebug.facts.crs import build_crs_facts +from geodebug.facts.keys import ( + RASTER_NODATA_COLLISION_BANDS, + RASTER_RESOLUTION, + RASTER_TRANSFORM, + SPATIAL_BOUNDS, + VECTOR_INVALID_GEOMETRY_COUNT, +) +from geodebug.models.context import EvaluationContext +from geodebug.models.enums import FactState, SubjectKind +from geodebug.models.facts import FactRecord, FactStore +from geodebug.models.subjects import DatasetSnapshot, SubjectRef + + +def test_clean_geographic_vector_is_quiet() -> None: + snapshot = DatasetSnapshot( + subject=SubjectRef(id="vector:clean", kind=SubjectKind.VECTOR, adapter="test"), + facts=FactStore( + ( + *build_crs_facts("OGC:CRS84", origin="test", method="clean"), + FactRecord.known(SPATIAL_BOUNDS, (103.0, 1.0, 104.0, 2.0)), + FactRecord.known(VECTOR_INVALID_GEOMETRY_COUNT, 0), + ) + ), + ) + + report = evaluate(EvaluationContext(subjects=(snapshot,))) + + assert report.diagnostics == [] + + +def test_clean_projected_raster_is_quiet() -> None: + crs = CRS.from_epsg(32648) + transformer = Transformer.from_crs("OGC:CRS84", crs, always_xy=True) + x, y = transformer.transform(103.8, 1.3) + snapshot = DatasetSnapshot( + subject=SubjectRef(id="raster:clean", kind=SubjectKind.RASTER, adapter="test"), + facts=FactStore( + ( + *build_crs_facts(crs.to_wkt(), origin="test", method="clean"), + FactRecord.known( + SPATIAL_BOUNDS, + (x - 1000.0, y - 1000.0, x + 1000.0, y + 1000.0), + ), + FactRecord.known(RASTER_RESOLUTION, (10.0, 10.0)), + FactRecord.known( + RASTER_TRANSFORM, + (10.0, 0.0, x - 1000.0, 0.0, -10.0, y + 1000.0), + ), + FactRecord( + key=RASTER_NODATA_COLLISION_BANDS, + state=FactState.NOT_APPLICABLE, + ), + ) + ), + ) + + report = evaluate(EvaluationContext(subjects=(snapshot,))) + + assert report.diagnostics == [] diff --git a/tests/rules/test_contract_coverage.py b/tests/rules/test_contract_coverage.py new file mode 100644 index 0000000..4e1ef4e --- /dev/null +++ b/tests/rules/test_contract_coverage.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +from pyproj import CRS, Transformer + +from geodebug.engine.defaults import build_default_registry +from geodebug.facts.crs import build_crs_facts +from geodebug.facts.keys import ( + CRS_AREA_OF_USE_BOUNDS, + CRS_AXIS_UNITS, + CRS_KIND, + CRS_PRESENT, + RASTER_NODATA_COLLISION_BANDS, + RASTER_TRANSFORM, + RELATION_BOUNDS_OVERLAP, + RELATION_GRID_ALIGNED, + SPATIAL_BOUNDS, + VECTOR_INVALID_GEOMETRY_COUNT, +) +from geodebug.models.context import EvaluationContext +from geodebug.models.enums import FactState, RuleState, SubjectKind +from geodebug.models.facts import FactRecord, FactStore +from geodebug.models.operations import OperationContext +from geodebug.models.subjects import DatasetSnapshot, SubjectRef +from geodebug.rules.base import Rule + +DOCS = Path(__file__).parents[2] / "docs" / "rules" + + +def _snapshot( + *, + kind: SubjectKind = SubjectKind.DATASET, + facts: tuple[FactRecord, ...] = (), + subject_id: str = "subject:test", +) -> DatasetSnapshot: + return DatasetSnapshot( + subject=SubjectRef(id=subject_id, kind=kind, adapter="test"), + facts=FactStore(facts), + ) + + +def _context( + *, + kind: SubjectKind = SubjectKind.DATASET, + facts: tuple[FactRecord, ...] = (), + operation: OperationContext | None = None, +) -> EvaluationContext: + return EvaluationContext( + subjects=(_snapshot(kind=kind, facts=facts),), + operation=operation, + ) + + +def _two_subject_context(*facts: FactRecord) -> EvaluationContext: + return EvaluationContext( + subjects=( + _snapshot(subject_id="left"), + _snapshot(subject_id="right"), + ), + facts=FactStore(facts), + ) + + +def _utm_context(lon: float, lat: float) -> EvaluationContext: + crs = CRS.from_epsg(32648) + transformer = Transformer.from_crs("OGC:CRS84", crs, always_xy=True) + x, y = transformer.transform(lon, lat) + return _context( + facts=( + *build_crs_facts(crs.to_wkt(), origin="test", method="contract"), + FactRecord.known( + SPATIAL_BOUNDS, + (x - 1000.0, y - 1000.0, x + 1000.0, y + 1000.0), + ), + ) + ) + + +def _geo_operation(name: str, *, with_units: bool = True) -> EvaluationContext: + facts = [FactRecord.known(CRS_KIND, "geographic")] + if with_units: + facts.append(FactRecord.known(CRS_AXIS_UNITS, ("degree", "degree"))) + return _context( + facts=tuple(facts), + operation=OperationContext( + name=name, + parameters={"distance": 500} if name == "buffer" else {}, + ), + ) + + +def _projected_operation(name: str) -> EvaluationContext: + return _context( + facts=( + FactRecord.known(CRS_KIND, "projected"), + FactRecord.known(CRS_AXIS_UNITS, ("metre", "metre")), + ), + operation=OperationContext( + name=name, + parameters={"distance": 500} if name == "buffer" else {}, + ), + ) + + +def _contracts() -> dict[str, dict[RuleState, Callable[[], EvaluationContext]]]: + return { + "GEO101": { + RuleState.PASS: lambda: _context(facts=(FactRecord.known(CRS_PRESENT, True),)), + RuleState.FAIL: lambda: _context(facts=(FactRecord.known(CRS_PRESENT, False),)), + RuleState.UNKNOWN: lambda: _context(facts=(FactRecord.unknown(CRS_PRESENT),)), + RuleState.NOT_APPLICABLE: lambda: EvaluationContext(subjects=()), + }, + "GEO103": { + RuleState.PASS: lambda: _context( + facts=( + FactRecord.known(CRS_KIND, "geographic"), + FactRecord.known(SPATIAL_BOUNDS, (103.0, 1.0, 104.0, 2.0)), + ) + ), + RuleState.FAIL: lambda: _context( + facts=( + FactRecord.known(CRS_KIND, "geographic"), + FactRecord.known(SPATIAL_BOUNDS, (0.0, 91.0, 1.0, 92.0)), + ) + ), + RuleState.UNKNOWN: lambda: _context( + facts=( + FactRecord.known(CRS_KIND, "geographic"), + FactRecord.unknown(SPATIAL_BOUNDS), + ) + ), + RuleState.NOT_APPLICABLE: lambda: _context( + facts=(FactRecord.known(CRS_KIND, "projected"),) + ), + }, + "GEO105": { + RuleState.PASS: lambda: _utm_context(103.8, 1.3), + RuleState.FAIL: lambda: _utm_context(120.0, 1.3), + RuleState.UNKNOWN: lambda: _context( + facts=( + FactRecord.known(CRS_KIND, "projected"), + FactRecord.unknown(CRS_AREA_OF_USE_BOUNDS), + FactRecord.unknown(SPATIAL_BOUNDS), + ) + ), + RuleState.NOT_APPLICABLE: lambda: _context( + facts=(FactRecord.known(CRS_KIND, "geographic"),) + ), + }, + "GEO201": { + RuleState.PASS: lambda: _context( + kind=SubjectKind.VECTOR, + facts=(FactRecord.known(VECTOR_INVALID_GEOMETRY_COUNT, 0),), + ), + RuleState.FAIL: lambda: _context( + kind=SubjectKind.VECTOR, + facts=(FactRecord.known(VECTOR_INVALID_GEOMETRY_COUNT, 1),), + ), + RuleState.UNKNOWN: lambda: _context( + kind=SubjectKind.VECTOR, + facts=(FactRecord.unknown(VECTOR_INVALID_GEOMETRY_COUNT),), + ), + RuleState.NOT_APPLICABLE: lambda: _context(kind=SubjectKind.RASTER), + }, + "GEO301": { + RuleState.PASS: lambda: _context( + kind=SubjectKind.RASTER, + facts=( + FactRecord.known( + RASTER_TRANSFORM, + (10.0, 0.0, 0.0, 0.0, -10.0, 100.0), + ), + ), + ), + RuleState.FAIL: lambda: _context( + kind=SubjectKind.RASTER, + facts=( + FactRecord.known( + RASTER_TRANSFORM, + (0.0, 0.0, 0.0, 0.0, 0.0, 100.0), + ), + ), + ), + RuleState.UNKNOWN: lambda: _context( + kind=SubjectKind.RASTER, + facts=(FactRecord.unknown(RASTER_TRANSFORM),), + ), + RuleState.NOT_APPLICABLE: lambda: _context(kind=SubjectKind.VECTOR), + }, + "GEO304": { + RuleState.PASS: lambda: _context( + kind=SubjectKind.RASTER, + facts=(FactRecord.known(RASTER_NODATA_COLLISION_BANDS, ()),), + ), + RuleState.FAIL: lambda: _context( + kind=SubjectKind.RASTER, + facts=(FactRecord.known(RASTER_NODATA_COLLISION_BANDS, (1,)),), + ), + RuleState.UNKNOWN: lambda: _context( + kind=SubjectKind.RASTER, + facts=(FactRecord.unknown(RASTER_NODATA_COLLISION_BANDS),), + ), + RuleState.NOT_APPLICABLE: lambda: _context(kind=SubjectKind.VECTOR), + }, + "GEO402": { + RuleState.PASS: lambda: _two_subject_context( + FactRecord.known(RELATION_BOUNDS_OVERLAP, True) + ), + RuleState.FAIL: lambda: _two_subject_context( + FactRecord.known(RELATION_BOUNDS_OVERLAP, False) + ), + RuleState.UNKNOWN: lambda: _two_subject_context( + FactRecord.unknown(RELATION_BOUNDS_OVERLAP) + ), + RuleState.NOT_APPLICABLE: lambda: _context(), + }, + "GEO404": { + RuleState.PASS: lambda: _two_subject_context( + FactRecord.known(RELATION_GRID_ALIGNED, True) + ), + RuleState.FAIL: lambda: _two_subject_context( + FactRecord.known(RELATION_GRID_ALIGNED, False) + ), + RuleState.UNKNOWN: lambda: _two_subject_context( + FactRecord.unknown(RELATION_GRID_ALIGNED) + ), + RuleState.NOT_APPLICABLE: lambda: _two_subject_context( + FactRecord( + key=RELATION_GRID_ALIGNED, + state=FactState.NOT_APPLICABLE, + ) + ), + }, + "GEO501": { + RuleState.PASS: lambda: _projected_operation("buffer"), + RuleState.FAIL: lambda: _geo_operation("buffer"), + RuleState.UNKNOWN: lambda: _geo_operation("buffer", with_units=False), + RuleState.NOT_APPLICABLE: lambda: _geo_operation("clip"), + }, + "GEO502": { + RuleState.PASS: lambda: _projected_operation("area"), + RuleState.FAIL: lambda: _geo_operation("area"), + RuleState.UNKNOWN: lambda: _geo_operation("area", with_units=False), + RuleState.NOT_APPLICABLE: lambda: _geo_operation("buffer"), + }, + "GEO503": { + RuleState.PASS: lambda: _projected_operation("distance"), + RuleState.FAIL: lambda: _geo_operation("distance"), + RuleState.UNKNOWN: lambda: _geo_operation("distance", with_units=False), + RuleState.NOT_APPLICABLE: lambda: _geo_operation("area"), + }, + } + + +def test_every_registered_rule_has_documentation_and_contract() -> None: + registry = build_default_registry() + rule_ids = {rule.spec.id for rule in registry.all()} + + assert set(_contracts()) == rule_ids + assert {path.stem for path in DOCS.glob("GEO*.md")} == rule_ids + + +def test_every_rule_exercises_all_four_states() -> None: + expected_states = { + RuleState.PASS, + RuleState.FAIL, + RuleState.UNKNOWN, + RuleState.NOT_APPLICABLE, + } + + for rule in build_default_registry().all(): + cases = _contracts()[rule.spec.id] + assert set(cases) == expected_states + for expected, factory in cases.items(): + result = _evaluate(rule, factory()) + assert result is expected, f"{rule.spec.id}: expected {expected}, got {result}" + + +def _evaluate(rule: Rule, context: EvaluationContext) -> RuleState: + return rule.evaluate(context).state diff --git a/tests/rules/test_metamorphic.py b/tests/rules/test_metamorphic.py new file mode 100644 index 0000000..656afb6 --- /dev/null +++ b/tests/rules/test_metamorphic.py @@ -0,0 +1,98 @@ +from pyproj import CRS + +from geodebug.facts.keys import ( + CRS_AXIS_UNITS, + CRS_KIND, + CRS_WKT, + RASTER_RESOLUTION, + RASTER_TRANSFORM, + RELATION_GRID_ALIGNED, + SPATIAL_BOUNDS, +) +from geodebug.facts.relations import build_relation_facts +from geodebug.models.context import EvaluationContext +from geodebug.models.enums import RuleState, SubjectKind +from geodebug.models.facts import FactRecord, FactStore +from geodebug.models.operations import OperationContext +from geodebug.models.subjects import DatasetSnapshot, SubjectRef +from geodebug.rules.crs.geo103 import GeographicCoordinateRangeRule +from geodebug.rules.operations.geo501 import AngularCRSBufferRule +from geodebug.rules.relations.geo404 import RasterGridMisalignmentRule + + +def _raster(subject_id: str, origin_x: float) -> DatasetSnapshot: + return DatasetSnapshot( + subject=SubjectRef(id=subject_id, kind=SubjectKind.RASTER, adapter="test"), + facts=FactStore( + ( + FactRecord.known(CRS_WKT, CRS.from_epsg(32648).to_wkt()), + FactRecord.known(SPATIAL_BOUNDS, (origin_x, 0.0, origin_x + 100.0, 100.0)), + FactRecord.known(RASTER_RESOLUTION, (10.0, 10.0)), + FactRecord.known( + RASTER_TRANSFORM, + (10.0, 0.0, origin_x, 0.0, -10.0, 100.0), + ), + ) + ), + ) + + +def test_geo103_correction_removes_finding(make_context) -> None: + invalid = make_context( + facts=( + FactRecord.known(CRS_KIND, "geographic"), + FactRecord.known(SPATIAL_BOUNDS, (0.0, 91.0, 1.0, 92.0)), + ) + ) + corrected = make_context( + facts=( + FactRecord.known(CRS_KIND, "geographic"), + FactRecord.known(SPATIAL_BOUNDS, (0.0, 41.0, 1.0, 42.0)), + ) + ) + + rule = GeographicCoordinateRangeRule() + assert rule.evaluate(invalid).state is RuleState.FAIL + assert rule.evaluate(corrected).state is RuleState.PASS + + +def test_geo404_integer_pixel_shift_restores_alignment() -> None: + left = _raster("left", 0.0) + half_pixel = _raster("right", 5.0) + integer_pixel = _raster("right", 20.0) + rule = RasterGridMisalignmentRule() + + bad = EvaluationContext( + subjects=(left, half_pixel), + facts=build_relation_facts(left, half_pixel), + ) + corrected = EvaluationContext( + subjects=(left, integer_pixel), + facts=build_relation_facts(left, integer_pixel), + ) + + assert bad.facts.require(RELATION_GRID_ALIGNED).value is False + assert rule.evaluate(bad).state is RuleState.FAIL + assert corrected.facts.require(RELATION_GRID_ALIGNED).value is True + assert rule.evaluate(corrected).state is RuleState.PASS + + +def test_geo501_projection_changes_buffer_contract(make_context) -> None: + geographic = make_context( + facts=( + FactRecord.known(CRS_KIND, "geographic"), + FactRecord.known(CRS_AXIS_UNITS, ("degree", "degree")), + ), + operation=OperationContext(name="buffer", parameters={"distance": 500}), + ) + projected = make_context( + facts=( + FactRecord.known(CRS_KIND, "projected"), + FactRecord.known(CRS_AXIS_UNITS, ("metre", "metre")), + ), + operation=OperationContext(name="buffer", parameters={"distance": 500}), + ) + + rule = AngularCRSBufferRule() + assert rule.evaluate(geographic).state is RuleState.FAIL + assert rule.evaluate(projected).state is RuleState.PASS diff --git a/tests/schemas/test_report_schema.py b/tests/schemas/test_report_schema.py index e70172d..f58975c 100644 --- a/tests/schemas/test_report_schema.py +++ b/tests/schemas/test_report_schema.py @@ -4,15 +4,16 @@ from jsonschema import Draft202012Validator +from geodebug.config.model import GeoDebugConfig from geodebug.engine.defaults import build_default_registry from geodebug.engine.evaluator import Evaluator from geodebug.models.context import EvaluationContext -SCHEMA_PATH = Path(__file__).parents[2] / "schemas" / "report.schema.json" +SCHEMA_DIR = Path(__file__).parents[2] / "schemas" -def test_reports_validate_against_checked_in_schema() -> None: - source_text = SCHEMA_PATH.read_text(encoding="utf-8") +def test_report_schema_is_valid_and_packaged_verbatim() -> None: + source_text = (SCHEMA_DIR / "report.schema.json").read_text(encoding="utf-8") packaged_text = files("geodebug.schemas").joinpath("report.schema.json").read_text( encoding="utf-8" ) @@ -20,8 +21,18 @@ def test_reports_validate_against_checked_in_schema() -> None: schema = json.loads(source_text) Draft202012Validator.check_schema(schema) - validator = Draft202012Validator(schema) - report = Evaluator(build_default_registry()).evaluate(EvaluationContext(subjects=())) + Draft202012Validator(schema).validate(report.model_dump(mode="json")) + + +def test_config_schema_is_valid_and_packaged_verbatim() -> None: + source_text = (SCHEMA_DIR / "config.schema.json").read_text(encoding="utf-8") + packaged_text = files("geodebug.schemas").joinpath("config.schema.json").read_text( + encoding="utf-8" + ) + assert packaged_text == source_text - validator.validate(report.model_dump(mode="json")) + schema = json.loads(source_text) + Draft202012Validator.check_schema(schema) + config = GeoDebugConfig() + Draft202012Validator(schema).validate(config.model_dump(mode="json")) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 4e8c8c4..4844d20 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -110,3 +110,12 @@ def test_preflight_cli_detects_area_in_geographic_crs(tmp_path) -> None: assert result.exit_code == 1 report = json.loads(result.stdout) assert [item["rule_id"] for item in report["diagnostics"]] == ["GEO502"] + + +def test_schema_command_emits_config_schema() -> None: + result = runner.invoke(app, ["schema", "--kind", "config"]) + + assert result.exit_code == 0 + schema = json.loads(result.stdout) + assert schema["title"] == "GeoDebug Config" + assert schema["properties"]["schema_version"]["const"] == "1"