diff --git a/.gitignore b/.gitignore index 34b6425..a43f97d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ research_packets/ codex_validation/ .bench/ .release/ + +# editor / cloud-sync duplicate artifacts (e.g. "suggestclaims 2.py"); never tracked or shipped +* 2.py +* 2.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5edf6af --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +All notable changes to dorian (`dorian-vwp`) are recorded here. Full per-release notes live in +[`docs/releases/`](docs/releases/). The warrant format, checker grammar, exit codes, and trust +semantics have been stable since 1.0.0. + +## [1.1.0] — 2026-06-18 + +Productization release — easier first run, clearer PR output, cleaner package. **No breaking +changes** (command surface and output formatting only; verification is unchanged). + +### Added +- **`dorian init`** — first-run scaffolding: a born-verifiable starter `claims.json`, the change + note it backs, and a `.github/workflows/dorian.yml` Action workflow. Writes files only (never runs + a checker or executes code), confined to the repo root, idempotent, with `--force`, `--dry-run`, + and the global `--json`. +- **Customer-readable PR comment** (`revalidate --format md`): an explicit `Status:` Blocked / + Passed / Errored verdict, an aggregate trust-change counts table, a `sealed in .warrant` + line per affected artifact, and a verdict-keyed `What to do:` remediation line. The comment stays + deterministic and keeps its content-carryover bound. + +### Packaging +- Added a `.gitignore` rule and a Hatch build `exclude` so stray editor/file-sync `… 2.py` + duplicate files can never be tracked or packaged into a wheel, even from a dirty working tree. + (These were untracked local artifacts — never in a CI build or on PyPI.) + +See [`docs/releases/v1.1.0.md`](docs/releases/v1.1.0.md). + +## [1.0.2] — 2026-06-17 + +Announcement-readiness hotfix: PyPI coherence, immutable Action ref, an SCA-scope fix, and two +edge-case bug fixes (`export` of a `*.warrant`-named artifact; `suggest-claims` PEP 263 reads). See +[`docs/releases/v1.0.2.md`](docs/releases/v1.0.2.md). + +## [1.0.1] — 2026-06-17 + +Added `suggest-claims` (C3 scaffolds) and `export --in-toto`; C4/C5 edge-case fixes. See +[`docs/releases/v1.0.1.md`](docs/releases/v1.0.1.md). + +## [1.0.0] — 2026-06-16 + +First PyPI release of the Validity Warrant Protocol reference implementation. diff --git a/README.md b/README.md index ae50224..c70b998 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,17 @@ pip install 'dorian-vwp[data] @ git+https://github.com/ajaysurya1221/dorian.git' pip install 'dorian-vwp[extract] @ git+https://github.com/ajaysurya1221/dorian.git' # + anthropic for LLM claim drafting (frozen/experimental) ``` -Then run `dorian verify --claims claims.json` on one change. For CI, add the composite +The fastest start is `dorian init`, which scaffolds a born-verifiable starter `claims.json`, the +change note it backs, and a GitHub Action workflow — so the very first `dorian verify` seals green: + +```bash +cd your-repo +dorian init # writes claims.json + change note + .github/workflows/dorian.yml +dorian verify dorian-change-note.md --claims claims.json # seals the warrant — exit 0 +``` + +Edit `claims.json` for the real facts your change depends on (add code claims with +`dorian suggest-claims `), then commit `dorian-change-note.md.warrant`. For CI, add the composite [GitHub Action](action/README.md) — it revalidates the claims a pull request touches and posts a sticky PR comment. **Read its [security notes](action/README.md#security-checker-execution-and-untrusted-pull-requests) first:** @@ -371,7 +381,7 @@ jobs: with: fetch-depth: 0 # revalidate diffs against the PR base sha persist-credentials: false # the Action only reads the diff + posts via GITHUB_TOKEN - - uses: ajaysurya1221/dorian/action@v1.0.2 + - uses: ajaysurya1221/dorian/action@v1.1.0 with: fail_on: revoked # install defaults to the published PyPI package (dorian-vwp); pin a @@ -409,6 +419,10 @@ The core loop is `verify` (auto-capture the read-set, run every checker, seal th `revalidate` (re-check only what changed). `capture` + `seal` are the lower-level path for C1 span claims. +- `dorian init [--force] [--dry-run]` — first-run scaffolding: writes a born-verifiable starter + `claims.json`, the change note it backs, and a `.github/workflows/dorian.yml` Action workflow. + Writes files only (never runs a checker or executes code), stays inside the repo, and skips + existing files unless `--force`. The global `--json` prints a machine-readable plan. - `dorian verify --claims claims.json` — the one-shot agent-claims entry point: auto-derive the read-set from each C3/C4/C5 checker, then seal (born-verifiable). C1 span claims use `dorian capture` + `dorian seal` instead. @@ -510,7 +524,7 @@ work perishable, so you find out when it expired. **reproducible on those frozen SHAs only** — not a real-world performance claim; the trigger and truth layers are reported separately. - **PyPI trusted publishing** — `dorian-vwp` is published to PyPI via a Trusted Publisher - (latest: **`v1.0.2`**); `pip install dorian-vwp` installs the released package. + (latest: **`v1.1.0`**); `pip install dorian-vwp` installs the released package. Non-goals stay non-goals: no servers, no dashboards, no hosted control plane, no model at check time. Local-first is the design center. diff --git a/action/README.md b/action/README.md index fb96c0c..79694b3 100644 --- a/action/README.md +++ b/action/README.md @@ -26,7 +26,7 @@ jobs: fetch-depth: 0 # REQUIRED: revalidate diffs against the PR base # sha, which a shallow clone does not contain persist-credentials: false # the Action reads the diff + posts via GITHUB_TOKEN - - uses: ajaysurya1221/dorian/action@v1.0.2 + - uses: ajaysurya1221/dorian/action@v1.1.0 with: fail_on: revoked # install defaults to the published PyPI package (dorian-vwp); @@ -78,7 +78,7 @@ self-attested-verdict problem for *non-executable* checkers — that is what ```yaml # untrusted / public-fork posture -- uses: ajaysurya1221/dorian/action@v1.0.2 +- uses: ajaysurya1221/dorian/action@v1.1.0 with: deny_exec: "true" # C4/C5 ERROR instead of executing ``` @@ -94,7 +94,7 @@ executed). Implemented and proven by the ```yaml # public / forked-PR posture: trusted checker specs + no code execution -- uses: ajaysurya1221/dorian/action@v1.0.2 +- uses: ajaysurya1221/dorian/action@v1.1.0 with: checker_trust: base # run only base-approved checker specs deny_exec: "true" # and refuse to execute even those (belt and braces) diff --git a/docs/BENCHMARK_CURRENT.md b/docs/BENCHMARK_CURRENT.md index 2c9b4c1..8315b75 100644 --- a/docs/BENCHMARK_CURRENT.md +++ b/docs/BENCHMARK_CURRENT.md @@ -10,9 +10,9 @@ and are kept as-is for provenance. | field | value | | --- | --- | -| dorian version | `1.0.2` | +| dorian version | `1.1.0` | | metric commit | `33e9eaf` (the benchmark figures were measured here, during the release audit) | -| release commit | `81cebbc` (1.0.1) → v1.0.2 announcement hotfix. The 1.0.1 changes (C4 leading-dash nodeid rejection, C5 reconcile per-query timeout, a byte-identical index-once `verify` refactor, the `suggest-claims` / `export --in-toto` commands) plus the v1.0.2 hotfix (export `.warrant` filename disambiguation, `suggest-claims` PEP 263 encoding read, a `symbol_index` non-git `GitError` guard, and CI/SCA/credential/doc hardening) touch no checker numeric behavior; both suites below were **re-run at 1.0.2 and reproduce the metric-commit figures exactly** — binding-lifecycle to the same content-derived `run_id` `168b50d9aa631d52` — so these changes do not move what the suites measure | +| release commit | `81cebbc` (1.0.1) → v1.0.2 announcement hotfix. The 1.0.1 changes (C4 leading-dash nodeid rejection, C5 reconcile per-query timeout, a byte-identical index-once `verify` refactor, the `suggest-claims` / `export --in-toto` commands) plus the v1.0.2 hotfix (export `.warrant` filename disambiguation, `suggest-claims` PEP 263 encoding read, a `symbol_index` non-git `GitError` guard, and CI/SCA/credential/doc hardening) touch no checker numeric behavior; both suites below were **re-run at 1.0.2 and reproduce the metric-commit figures exactly** — binding-lifecycle to the same content-derived `run_id` `168b50d9aa631d52` — so these changes do not move what the suites measure. v1.1.0 adds the `dorian init` scaffolder, the PR-comment renderer enhancements (a status line, trust-change counts, sealed-at, and remediation), and removal of accidentally-packaged duplicate source files — a new command plus output formatting and packaging hygiene only, touching no checker/binding/fold code, so the figures stand unchanged at 1.1.0 (the suites were last executed at 1.0.2, not re-run at 1.1.0) | | Python | 3.12.4 | | platform | darwin (CI matrix: 3.11 / 3.12 / 3.13) | | reproduce | `dorian bench large-mutation` · `dorian bench binding-lifecycle` · `dorian bench realworld-usecases` | diff --git a/docs/releases/v1.1.0.md b/docs/releases/v1.1.0.md new file mode 100644 index 0000000..d32158e --- /dev/null +++ b/docs/releases/v1.1.0.md @@ -0,0 +1,72 @@ +# dorian 1.1.0 + +A productization release that makes the first run easy. **No breaking changes**: the warrant +format, checker grammar, exit codes, and trust semantics are unchanged. 1.1.0 adds the missing +golden-path onboarding command, makes the PR-comment output customer-readable, and cleans up a +packaging-hygiene defect — it changes a command surface and output formatting, not verification. + +## What's new + +- **`dorian init` (new command)** — first-run scaffolding so a new user reaches a sealed warrant in + minutes instead of hand-writing JSON. It writes three files: + - a born-verifiable starter `claims.json` (a `config-value:` claim about the pyproject package + name when available — the same checker family that caught `encode/httpx` #3592 — otherwise a + `path:` existence claim about a file that is present, so the first `dorian verify` seals **green**, not red); + - `dorian-change-note.md`, the change note those claims back; + - `.github/workflows/dorian.yml`, the GitHub Action workflow, pinned to this package's version. + + It writes files **only** — it never runs a checker, never executes code, never writes outside the + repo root, and never overwrites an existing file without `--force` (re-running is idempotent). + Supports `--dry-run` (print the plan, write nothing) and the global `--json` (machine-readable + summary). The scaffolded starter checker is always a read-only C3 family, never an executable + C4/C5 — safe by default. + +- **Customer-readable PR comment** — `dorian revalidate --format md` (the body the GitHub Action + posts) now leads with an explicit **`Status:` Blocked / Passed / Errored** verdict, an aggregate + **trust-change counts** table (how many touched warrants this change moved to REVOKED / DEGRADED / + TRUSTED / UNKNOWN), a **`sealed in .warrant`** line under each affected artifact, and a + verdict-keyed **`What to do:`** remediation line. The existing per-claim verdict table, fold + transitions, recall section, and stats footer are unchanged; the comment stays deterministic (no + timestamps, no absolute paths) and keeps the 160-char content-carryover bound on every detail cell. + +## Packaging hygiene + +- **Guards against editor/file-sync duplicate artifacts.** A `.gitignore` rule and a Hatch build + `exclude` now keep stray `… 2.py` sync-duplicate files (the kind macOS / cloud sync leaves behind, + e.g. `suggestclaims 2.py`) from ever being tracked or packaged into a wheel — even from a dirty + working tree. To be precise: these files were **never tracked in git**, so they were never in a + CI-built wheel or on PyPI; the guards additionally make a local `uv build` from a dirty tree + provably clean (33 modules, no space-named files). + +## Tests & gates + +Full suite green at 1.1.0 (883 tracked tests: the 1.0.2 suite plus 8 new `dorian init` tests, and +new assertions pinning the enhanced PR-comment output), ruff clean, wheel/sdist build + +`twine check` pass. The new `dorian init` golden path is covered end to end (`init` → `verify` +exits 0 and writes a warrant — a tool whose pitch is "don't ship false claims" must not ship a +false scaffold). + +The reproducible benchmark suites are **not** re-run here: 1.1.0 adds a command, output formatting, +and a packaging cleanup, none of which touch the checker, binding, or fold code the suites measure, +so the recorded figures stand unchanged (last executed at 1.0.2; see +[`docs/BENCHMARK_CURRENT.md`](../BENCHMARK_CURRENT.md)). + +## Honest scope (unchanged) + +dorian has **one documented, reproduced real cross-PR catch** on frozen public SHAs (`encode/httpx` +`requires-python` floor; see [`docs/REAL_CATCH_LOG.md`](../REAL_CATCH_LOG.md)) — not broad +real-world validation. The benchmark suites are reproducibility evidence on frozen fixtures only. +`--deny-exec`/`--deny-shell` are fail-closed policies, **not** sandboxes; `checker_trust: base` is a +checker-source trust root, not a sandbox. `dorian init` and `suggest-claims` scaffold starter claims +for review — existence/value checks, not behavior (a gutted body keeps a `symbol:` claim green). A +warrant id is content-addressed and tamper-evident, but its body includes the seal timestamp, so a +fresh seal yields a different id — what reproduces is the outcome, not the id. + +## Install + +```bash +pip install dorian-vwp # 1.1.0 on PyPI + +dorian init # scaffold a starter setup +dorian verify dorian-change-note.md --claims claims.json # seal the warrant — exit 0 +``` diff --git a/pyproject.toml b/pyproject.toml index fa02aab..9738f51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dorian-vwp" -version = "1.0.2" +version = "1.1.0" description = "Hold AI agents to what they said they did: deterministic, token-free verification of claims about a change." readme = "README.md" requires-python = ">=3.11" @@ -30,6 +30,9 @@ dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.8", "pyyaml>=6"] [tool.hatch.build.targets.wheel] packages = ["src/dorian"] +# defense in depth: never package editor/cloud-sync duplicate artifacts ("foo 2.py"), +# even from a dirty working tree (they are also git-ignored, so never tracked) +exclude = ["**/* 2.py"] [tool.ruff] line-length = 100 diff --git a/src/dorian/__init__.py b/src/dorian/__init__.py index c57e6be..7e9003d 100644 --- a/src/dorian/__init__.py +++ b/src/dorian/__init__.py @@ -3,4 +3,4 @@ PyPI distribution: `dorian-vwp`; import package: `dorian`; CLI: `dorian`. """ -__version__ = "1.0.2" +__version__ = "1.1.0" diff --git a/src/dorian/cli.py b/src/dorian/cli.py index b84c771..6c3b022 100644 --- a/src/dorian/cli.py +++ b/src/dorian/cli.py @@ -1,5 +1,5 @@ -"""dorian CLI: capture | seal | verify | status | blast | bindings | revalidate | -report | suggest-data-checks | suggest-claims | sync | export | bench. +"""dorian CLI: init | capture | seal | verify | status | blast | bindings | +revalidate | report | suggest-data-checks | suggest-claims | sync | export | bench. Exit codes: 0 ok/TRUSTED · 2 usage/infra · 3 DEGRADED · 4 REVOKED/integrity · 5 ERRORED-only · 6 scope violation (ring 1). @@ -51,6 +51,21 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--json", action="store_true", help="machine-readable output") sub = p.add_subparsers(dest="command", required=True) + ini = sub.add_parser( + "init", + help="scaffold a starter claims.json, change note, and GitHub Action workflow", + description="Scaffold a born-verifiable starter setup so a first run reaches a" + " sealed warrant in minutes: a starter claims.json, the change note it backs, and" + " a GitHub Action workflow that re-checks the claims on every pull request. Writes" + " files only — never runs a checker or executes code, never writes outside the repo," + " and never overwrites an existing file without --force. Use the global --json for a" + " machine-readable summary.", + ) + ini.add_argument( + "--force", action="store_true", help="overwrite existing files (default: skip them)" + ) + ini.add_argument("--dry-run", action="store_true", help="print the plan; write nothing") + cap = sub.add_parser("capture", help="build a read-set from a run") cap.add_argument("--transcript", help="Claude Code session .jsonl") cap.add_argument("--manual", action="append", default=[], help="path[:Lx-y] (repeatable)") diff --git a/src/dorian/commands.py b/src/dorian/commands.py index b102a16..23ff276 100644 --- a/src/dorian/commands.py +++ b/src/dorian/commands.py @@ -28,6 +28,7 @@ claims_io, datachecks, gitio, + init, intoto, store, strength, @@ -881,6 +882,60 @@ def cmd_suggest_claims(args: argparse.Namespace) -> int: return EXIT_OK +def cmd_init(args: argparse.Namespace) -> int: + """Scaffold a born-verifiable starter setup (claims.json, change note, + GitHub Action workflow) so a first run reaches a sealed warrant in minutes. + Writes files only — never runs a checker, never executes code, never writes + outside the repo, never overwrites without --force. Re-running is idempotent: + existing files are left untouched. Path/permission problems are usage errors.""" + repo = _repo(args) + if _missing_repo(repo, "init"): + return EXIT_USAGE + try: + plan = init.build_plan(repo) + result = init.apply(plan, force=args.force, dry_run=args.dry_run) + except (ValueError, OSError) as exc: + print(f"dorian init: {exc}", file=sys.stderr) + return EXIT_USAGE + if args.json: + print( + json.dumps( + { + "repo": str(repo), + "dry_run": args.dry_run, + "created": list(result.created), + "overwritten": list(result.overwritten), + "skipped": list(result.skipped), + "warnings": list(result.warnings), + "next_steps": list(init.NEXT_STEPS), + }, + indent=2, + ) + ) + else: + _print_init_summary(plan, result, dry_run=args.dry_run) + return EXIT_OK + + +def _print_init_summary(plan, result, *, dry_run: bool) -> None: + blurbs = {f.path: f.blurb for f in plan.files} + header = "dorian init --dry-run (no files written)" if dry_run else "dorian initialized." + print(header) + written = list(result.created) + list(result.overwritten) + if written: + print("\nWould create:" if dry_run else "\nWrote:") + width = max(len(p) for p in written) + for p in written: + print(f" {p.ljust(width)} {blurbs.get(p, '')}") + if result.skipped: + print("\nSkipped (already present — use --force to overwrite):") + for p in result.skipped: + print(f" {p}") + print("\nNext:") + for i, step in enumerate(init.NEXT_STEPS, 1): + print(f" {i}. {step}") + + def cmd_sync(args: argparse.Namespace) -> int: repo = _repo(args) if _missing_repo(repo, "sync"): diff --git a/src/dorian/init.py b/src/dorian/init.py new file mode 100644 index 0000000..467f932 --- /dev/null +++ b/src/dorian/init.py @@ -0,0 +1,232 @@ +"""`dorian init`: first-run scaffolding for the golden path. + +Writes three files so a first-time user reaches a sealed warrant in minutes: + +- ``claims.json`` — a *born-verifiable* starter claim (so the very first + ``dorian verify`` seals green, not red); +- ``dorian-change-note.md`` — the change note those claims back; +- ``.github/workflows/dorian.yml`` — a GitHub Action that re-checks the sealed + claims on every pull request. + +This module writes files only. It never runs a checker, never executes code, +never writes outside the repo root, and never overwrites an existing file +without ``--force`` (re-running is safe and idempotent). The starter claim is a +read-only C3 family (``config-value:`` when a ``pyproject.toml`` package name is +available, else ``path:``) — never an executable C4/C5 checker — so init is +safe by default. +""" + +from __future__ import annotations + +import json +import os +import re +import tomllib +from dataclasses import dataclass +from pathlib import Path + +from dorian import __version__ + +CLAIMS_FILE = "claims.json" +NOTE_FILE = "dorian-change-note.md" +WORKFLOW_FILE = ".github/workflows/dorian.yml" + +# Files init will bind a `path:` existence claim to when no pyproject name is +# available, in preference order. The first one that exists wins. +_PATH_CANDIDATES = ( + "README.md", + "README.rst", + "README", + "LICENSE", + "LICENSE.md", + "pyproject.toml", + "setup.py", + "setup.cfg", +) + +NEXT_STEPS = ( + f"Review {CLAIMS_FILE} and {NOTE_FILE}", + f"Seal them: dorian verify {NOTE_FILE} --claims {CLAIMS_FILE}", + f"Commit {NOTE_FILE}.warrant alongside your change", + "Add code claims: dorian suggest-claims ", + "Open a PR — the workflow re-checks every sealed claim on every change", +) + + +@dataclass(frozen=True) +class InitFile: + """One scaffolded file: its repo-relative posix path, the content to write, + a one-line summary blurb, and whether it already exists at plan time.""" + + path: str + content: str + blurb: str + exists: bool + + +@dataclass(frozen=True) +class InitPlan: + repo_root: Path + files: tuple[InitFile, ...] + starter_desc: str + + +@dataclass(frozen=True) +class ApplyResult: + created: tuple[str, ...] + overwritten: tuple[str, ...] + skipped: tuple[str, ...] + warnings: tuple[str, ...] + + +def _slug(name: str) -> str: + s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return s or "file" + + +def _project_name(repo: Path) -> str | None: + """The ``project.name`` from ``pyproject.toml`` if cleanly parseable, else None.""" + pp = repo / "pyproject.toml" + if not pp.is_file(): + return None + try: + data = tomllib.loads(pp.read_text(encoding="utf-8")) + except (OSError, ValueError, UnicodeDecodeError): + return None + name = data.get("project", {}).get("name") if isinstance(data.get("project"), dict) else None + return name if isinstance(name, str) and name else None + + +def _starter(repo: Path) -> tuple[dict, str, str]: + """Choose a born-verifiable starter claim grounded in this repo's current + state. Returns (claim_dict, note_fact_line, one_line_desc). + + Preference: a ``config-value:`` claim about the pyproject package name (the + flagship checker — the family that caught httpx#3592), falling back to a + ``path:`` existence claim about a file that is present. Both are read-only. + """ + name = _project_name(repo) + if name is not None: + program = f"config-value:pyproject.toml:project.name:{json.dumps(name)}" + claim = { + "id": "package-name", + "text": f'The package name declared in pyproject.toml is "{name}".', + "kind": "fact", + "load_bearing": False, + "checkers": [{"type": "C3", "program": program}], + } + fact = f"The package name declared in `pyproject.toml` (`project.name`) is `{name}`." + return claim, fact, f"config-value: pyproject.toml project.name == {name!r}" + + rel = next((c for c in _PATH_CANDIDATES if (repo / c).is_file()), NOTE_FILE) + claim = { + "id": f"{_slug(Path(rel).stem)}-present", + "text": f"The file {rel} is present.", + "kind": "fact", + "load_bearing": False, + "checkers": [{"type": "C3", "program": f"path:{rel}"}], + } + return claim, f"The file `{rel}` is present.", f"path: {rel} exists" + + +def _claims_content(claim: dict) -> str: + # indent=2, fixed field order (built deterministically); no sort_keys so the + # human-friendly id/text/kind order is preserved in the scaffolded file. + return json.dumps({"claims": [claim]}, indent=2) + "\n" + + +def _note_content(fact: str) -> str: + return ( + "# Dorian change note\n" + "\n" + "This note records the load-bearing facts this change relies on. `dorian verify`\n" + "seals them into a `.warrant` sidecar and `dorian` re-checks them on every future\n" + "pull request, so a later change that quietly breaks one of these promises fails\n" + "CI instead of shipping silently.\n" + "\n" + f"- {fact}\n" + "\n" + f"The checkable claims behind this note live in `{CLAIMS_FILE}`. Seal them with:\n" + "\n" + f" dorian verify {NOTE_FILE} --claims {CLAIMS_FILE}\n" + "\n" + "Then replace the example above with the real facts your change depends on, and\n" + "add more checks with `dorian suggest-claims `.\n" + ) + + +def _workflow_content() -> str: + # Pin the action to THIS package's version so a `pip install dorian-vwp` + # and the workflow always agree. The snippet mirrors action/README.md. + return ( + "name: dorian\n" + "on: [pull_request]\n" + "\n" + "permissions:\n" + " contents: read\n" + " pull-requests: write # sticky comment (update-or-create)\n" + "\n" + "jobs:\n" + " revalidate:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/checkout@v6\n" + " with:\n" + " fetch-depth: 0 # required: revalidate diffs against the PR base sha\n" + " persist-credentials: false\n" + f" - uses: ajaysurya1221/dorian/action@v{__version__}\n" + " with:\n" + " fail_on: revoked\n" + ) + + +def build_plan(repo: Path) -> InitPlan: + """Compute (without writing) the files init would scaffold for ``repo``.""" + claim, fact, desc = _starter(repo) + specs = ( + (CLAIMS_FILE, _claims_content(claim), desc), + (NOTE_FILE, _note_content(fact), "the change note these claims back"), + (WORKFLOW_FILE, _workflow_content(), "re-checks claims on every pull request"), + ) + files = tuple( + InitFile(path=path, content=content, blurb=blurb, exists=(repo / path).exists()) + for path, content, blurb in specs + ) + return InitPlan(repo_root=repo.resolve(), files=files, starter_desc=desc) + + +def _ensure_within(repo: Path, target: Path) -> None: + """Defense in depth: refuse to write anywhere outside the repo root.""" + resolved = (target if target.is_absolute() else repo / target).resolve() + if not resolved.is_relative_to(repo): + raise ValueError(f"refusing to write outside repo: {target}") + + +def _atomic_write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(path.name + ".tmp") + tmp.write_text(content, encoding="utf-8") + os.replace(tmp, path) + + +def apply(plan: InitPlan, *, force: bool, dry_run: bool) -> ApplyResult: + """Realize the plan. Existing files are skipped unless ``force``. With + ``dry_run`` nothing is written but the same classification is returned.""" + created: list[str] = [] + overwritten: list[str] = [] + skipped: list[str] = [] + for f in plan.files: + target = plan.repo_root / f.path + _ensure_within(plan.repo_root, target) + if f.exists and not force: + skipped.append(f.path) + continue + if not dry_run: + _atomic_write(target, f.content) + (overwritten if f.exists else created).append(f.path) + return ApplyResult( + created=tuple(created), + overwritten=tuple(overwritten), + skipped=tuple(skipped), + warnings=(), + ) diff --git a/src/dorian/revalidate.py b/src/dorian/revalidate.py index 55fab14..a940b67 100644 --- a/src/dorian/revalidate.py +++ b/src/dorian/revalidate.py @@ -380,6 +380,22 @@ def render_json(result: RevalResult) -> str: EXIT_ERRORED: "checker errors only (infra, not failures)", } +# Verdict-keyed "what to do next" guidance for the PR comment. Lowercase "broken" +# deliberately (the uppercase BROKEN token is reserved for verdict cells). ERROR is +# never a failure, so its guidance says so explicitly. +_REMEDIATION = { + EXIT_REVOKED: "A load-bearing claim is broken, so a touched warrant is REVOKED. Fix the" + " change so the claim holds again, update the claim to match the new intent, or supersede" + " the warrant with reviewer approval.", + EXIT_DEGRADED: "A non-load-bearing claim is broken (DEGRADED) — not blocking by default." + " Address or update the broken claim.", + EXIT_ERRORED: "A checker could not run (ERROR is not a failure). Fix the checker or its" + " environment and re-run — ERROR never silently passes the gate.", +} + +# Aggregate trust-outcome ordering for the counts table (most severe first). +_TRUST_ORDER = ["REVOKED", "DEGRADED", "TRUSTED", "WARRANTED", "UNKNOWN", "SUPERSEDED"] + def _md_cell(text: str) -> str: """One markdown detail cell: bound content carryover, escape pipes, flatten @@ -403,6 +419,34 @@ def render_md(result: RevalResult) -> str: else: lines.append("### dorian: warranted claims re-checked; none broken") + # explicit allow/block verdict, scannable above the per-claim detail + if result.broken: + lines.append(f"**Status:** Blocked — {len(result.broken)} broken claim(s)") + elif result.errored: + lines.append( + f"**Status:** Errored — {len(result.errored)} checker(s) could not run (not a verdict)" + ) + else: + lines.append("**Status:** Passed — warranted claims re-checked; none broken") + + # aggregate trust outcomes this change produced (folds record only changes) + if result.folds: + tally: dict[str, int] = {} + for _old, new in result.folds.values(): + tally[new] = tally.get(new, 0) + 1 + states = [s for s in _TRUST_ORDER if s in tally] + sorted( + s for s in tally if s not in _TRUST_ORDER + ) + lines += [ + "", + "Trust changes from this change:", + "", + "| trust state | warrants |", + "| --- | ---: |", + ] + for s in states: + lines.append(f"| {s} | {tally[s]} |") + rows: dict[str, list[tuple[str, str, str]]] = {} for verdict, recs in ( ("BROKEN", result.broken), @@ -418,6 +462,8 @@ def render_md(result: RevalResult) -> str: for wid in sorted(rows.keys() | errors.keys()): label = result.artifacts.get(wid, wid[:23]) lines += ["", f"#### `{label}`"] + if wid in result.artifacts: # the sidecar these claims were sealed into + lines.append(f"_sealed in `{result.artifacts[wid]}.warrant`_") if wid in rows: lines += ["", "| claim | verdict | why |", "| --- | --- | --- |"] for cid, verdict, detail in rows[wid]: @@ -440,6 +486,10 @@ def render_md(result: RevalResult) -> str: for note in result.notes: lines.append(f"- {_md_cell(note)}") + tip = _REMEDIATION.get(result.exit_code) + if tip: + lines += ["", f"**What to do:** {tip}"] + checks = sum(map(len, (result.broken, result.relocated, result.errored, result.passed))) meaning = _EXIT_MEANINGS.get(result.exit_code, "unknown") lines += [ diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..722ea5d --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,178 @@ +"""`dorian init`: first-run scaffolding for the golden path. + +`init` writes three files — a born-verifiable starter `claims.json`, the change +note they back, and a GitHub Action workflow — so a first-time user reaches a +sealed warrant in minutes. It writes files only: it never runs a checker, never +executes code, never writes outside the repo root, and never overwrites an +existing file without `--force`. The load-bearing test here is that the starter +claim it scaffolds actually *seals green* end to end (`init` -> `verify` exit 0): +a tool whose pitch is "don't ship false claims" must not ship a false scaffold. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +from dorian import __version__, claims_io, init +from dorian.cli import main +from dorian.model import CheckerSpec +from dorian.policy import executable_kind + +_GIT_ENV = { + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@t", +} + + +def _git(repo: Path, *args: str) -> None: + subprocess.run( + ["git", *args], cwd=repo, env={**os.environ, **_GIT_ENV}, check=True, capture_output=True + ) + + +def _dorian(*args: str, repo: Path) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-m", "dorian", *args], + cwd=repo, + capture_output=True, + text=True, + timeout=120, + ) + + +def _seed_repo(repo: Path, *, name: str = "demo-app", readme: bool = True) -> None: + """A minimal but realistic Python repo: a pyproject with a project name.""" + repo.mkdir(parents=True, exist_ok=True) + (repo / "pyproject.toml").write_text( + f'[project]\nname = "{name}"\nversion = "0.1.0"\n', encoding="utf-8" + ) + if readme: + (repo / "README.md").write_text("# demo-app\n", encoding="utf-8") + + +def test_init_creates_the_three_scaffold_files(tmp_path: Path) -> None: + repo = tmp_path / "repo" + _seed_repo(repo) + + assert main(["--repo", str(repo), "init"]) == 0 + + claims = repo / init.CLAIMS_FILE + note = repo / init.NOTE_FILE + workflow = repo / init.WORKFLOW_FILE + assert claims.is_file() + assert note.is_file() + assert workflow.is_file() + + # claims.json is structurally valid (round-trips through the real loader) + loaded = claims_io.load_claims(claims) + assert len(loaded) == 1 + + # the workflow pins the action to THIS package version (coherent install) + wf = workflow.read_text(encoding="utf-8") + assert f"ajaysurya1221/dorian/action@v{__version__}" in wf + assert "on: [pull_request]" in wf + assert "actions/checkout" in wf + + +def test_init_starter_claim_is_non_executable(tmp_path: Path) -> None: + """init scaffolds a safe-by-default starter: the seeded checker is a + read-only C3 family (path:/config-value:), never an executable C4/C5.""" + repo = tmp_path / "repo" + _seed_repo(repo) + plan = init.build_plan(repo) + claim = json.loads(_claims_content(plan))["claims"][0] + for spec in claim["checkers"]: + assert executable_kind(CheckerSpec.from_dict(spec)) is None + + +def test_init_scaffold_seals_green_end_to_end(tmp_path: Path) -> None: + """The golden path: init -> verify exits 0 and writes a warrant. If the + scaffolded claim did not actually hold, this would fail.""" + repo = tmp_path / "repo" + _seed_repo(repo) + _git(repo, "init", "-q") + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", "seed repo") + + r = _dorian("init", repo=repo) + assert r.returncode == 0, r.stderr + + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", "dorian init") + + r = _dorian("verify", init.NOTE_FILE, "--claims", init.CLAIMS_FILE, repo=repo) + assert r.returncode == 0, f"{r.returncode}\n{r.stdout}\n{r.stderr}" + assert "verified 1/1 claim(s)" in r.stdout + assert (repo / f"{init.NOTE_FILE}.warrant").is_file() + + +def test_init_dry_run_writes_nothing(tmp_path: Path, capsys) -> None: + repo = tmp_path / "repo" + _seed_repo(repo) + + assert main(["--repo", str(repo), "init", "--dry-run"]) == 0 + assert not (repo / init.CLAIMS_FILE).exists() + assert not (repo / init.NOTE_FILE).exists() + assert not (repo / init.WORKFLOW_FILE).exists() + out = capsys.readouterr().out + assert init.CLAIMS_FILE in out + + +def test_init_is_idempotent_without_force(tmp_path: Path) -> None: + repo = tmp_path / "repo" + _seed_repo(repo) + assert main(["--repo", str(repo), "init"]) == 0 + + # user edits the scaffold; a second init must NOT clobber it + edited = '{"claims": [], "_mine": true}\n' + (repo / init.CLAIMS_FILE).write_text(edited, encoding="utf-8") + assert main(["--repo", str(repo), "init"]) == 0 + assert (repo / init.CLAIMS_FILE).read_text(encoding="utf-8") == edited + + +def test_init_force_overwrites(tmp_path: Path) -> None: + repo = tmp_path / "repo" + _seed_repo(repo) + assert main(["--repo", str(repo), "init"]) == 0 + (repo / init.CLAIMS_FILE).write_text('{"claims": [], "_mine": true}\n', encoding="utf-8") + + assert main(["--repo", str(repo), "init", "--force"]) == 0 + regenerated = claims_io.load_claims(repo / init.CLAIMS_FILE) + assert len(regenerated) == 1 # the starter claim is back + + +def test_init_json_output_is_machine_readable(tmp_path: Path, capsys) -> None: + repo = tmp_path / "repo" + _seed_repo(repo) + assert main(["--repo", str(repo), "--json", "init"]) == 0 + payload = json.loads(capsys.readouterr().out) + created = set(payload["created"]) + assert init.CLAIMS_FILE in created + assert init.NOTE_FILE in created + assert init.WORKFLOW_FILE in created + + +def test_init_falls_back_to_path_claim_without_pyproject(tmp_path: Path) -> None: + """No pyproject: init still scaffolds a born-verifiable claim about a file + that exists (here, the README).""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "README.md").write_text("# hi\n", encoding="utf-8") + + assert main(["--repo", str(repo), "init"]) == 0 + claim = claims_io.load_claims(repo / init.CLAIMS_FILE)[0] + programs = [c.program for c in claim.checkers] + assert any(p.startswith("path:") for p in programs) + + +def _claims_content(plan: init.InitPlan) -> str: + for f in plan.files: + if f.path == init.CLAIMS_FILE: + return f.content + raise AssertionError("no claims.json in plan") diff --git a/tests/test_render_md.py b/tests/test_render_md.py index 1a6be12..a8344ae 100644 --- a/tests/test_render_md.py +++ b/tests/test_render_md.py @@ -164,6 +164,16 @@ def test_mixed_run_markdown(fixture_repo: Path) -> None: assert "- `c0`: no checker registered for C9" in md assert md.count("BROKEN") == 2 + # explicit allow/block verdict, scannable at the top + assert "**Status:** Blocked — 2 broken claim(s)" in md + # aggregate trust-outcome counts (folds record only the warrants that changed) + assert "| REVOKED | 1 |" in md + assert "| UNKNOWN | 1 |" in md + # each artifact section names the sidecar its claims were sealed into + assert "_sealed in `docs/design.md.warrant`_" in md + # verdict-keyed remediation tells the reviewer what to do next + assert "**What to do:**" in md + # fold transitions labeled by artifact uri (PR readers know paths, not warrant ids) assert "- `docs/design.md`: WARRANTED -> REVOKED" in md assert "- `docs/g.md`: WARRANTED -> UNKNOWN" in md