diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd1fcf0..708a09b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,11 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} run: python scripts/check_action_pins.py + - name: Landscape table matches the fetched competitor data + # The table states its own method ("fetched via the GitHub API") + # but was typed by hand. In a sibling repo that drifted into + # publishing that a live competitor's repo was gone. + run: python scripts/generate_landscape.py --check - name: README example matches the real renderer # The README used to show a hand-written scan with a rule id no code # emits, under the caption "examples from actual scans". This renders a diff --git a/README.md b/README.md index db54c3b..802c739 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,23 @@ These are independent projects: no shared library, no coupled releases, and each +## How this compares + +9 projects are tracked in [`docs/competitive-analysis.md`](docs/competitive-analysis.md), +fetched from the GitHub API on 2026-09-09 and committed to +[`data/competitor-meta.json`](data/competitor-meta.json). + +Almost all of them — Nix, devenv, Devbox, mise, asdf, direnv, Dev Containers — *prevent* +environment drift by declaring the environment up front. That is a different shape of +solution, and usually a better one when you can adopt it. DevRepro Doctor exists for the +case you cannot: a machine that is already broken, that you did not configure, and that has +to work today. It diagnoses and explains rather than replacing. + +## Citation + +If this tool contributed to published work, cite it via +[`CITATION.cff`](CITATION.cff) — GitHub renders a "Cite this repository" control from it. + ## License Apache License 2.0 — see [LICENSE](LICENSE). Creator attribution: diff --git a/data/competitor-meta.json b/data/competitor-meta.json new file mode 100644 index 0000000..5a33de9 --- /dev/null +++ b/data/competitor-meta.json @@ -0,0 +1,108 @@ +{ + "fetched_utc": "2026-09-09T10:16:42+00:00", + "tool": "gh api (authenticated)", + "repos": { + "NixOS/nix": { + "repo": "NixOS/nix", + "license_spdx": "LGPL-2.1", + "stars": 17660, + "pushed_at": "2026-09-09T02:54:11Z", + "archived": false, + "description": "Nix, the purely functional package manager", + "latest_release": null + }, + "cachix/devenv": { + "repo": "cachix/devenv", + "license_spdx": "Apache-2.0", + "stars": 7618, + "pushed_at": "2026-09-07T22:15:33Z", + "archived": false, + "description": "Fast, Declarative, Reproducible, and Composable Developer Environments using Nix", + "latest_release": { + "tag": "v2.3", + "published_at": "2026-09-07T13:08:42Z" + } + }, + "jetify-com/devbox": { + "repo": "jetify-com/devbox", + "license_spdx": "Apache-2.0", + "stars": 12344, + "pushed_at": "2026-09-04T00:07:25Z", + "archived": false, + "description": "Instant, easy, and predictable development environments", + "latest_release": { + "tag": "0.18.0", + "published_at": "2026-08-16T19:43:10Z" + } + }, + "jdx/mise": { + "repo": "jdx/mise", + "license_spdx": "MIT", + "stars": 33693, + "pushed_at": "2026-09-09T00:49:45Z", + "archived": false, + "description": "dev tools, env vars, task runner", + "latest_release": { + "tag": "v2026.9.3", + "published_at": "2026-09-08T13:55:29Z" + } + }, + "asdf-vm/asdf": { + "repo": "asdf-vm/asdf", + "license_spdx": "MIT", + "stars": 25571, + "pushed_at": "2026-09-03T13:48:57Z", + "archived": false, + "description": "Extendable version manager with support for Ruby, Node.js, Elixir, Erlang & more", + "latest_release": { + "tag": "v0.20.0", + "published_at": "2026-07-07T14:05:10Z" + } + }, + "direnv/direnv": { + "repo": "direnv/direnv", + "license_spdx": "MIT", + "stars": 15430, + "pushed_at": "2026-03-31T18:52:06Z", + "archived": false, + "description": "unclutter your .profile", + "latest_release": { + "tag": "v2.37.1", + "published_at": "2025-07-20T10:35:29Z" + } + }, + "devcontainers/cli": { + "repo": "devcontainers/cli", + "license_spdx": "MIT", + "stars": 2947, + "pushed_at": "2026-09-03T07:27:05Z", + "archived": false, + "description": "A reference implementation for the specification that can create and configure a dev container from a devcontainer.json.", + "latest_release": null + }, + "loft-sh/devpod": { + "repo": "loft-sh/devpod", + "license_spdx": "MPL-2.0", + "stars": 15198, + "pushed_at": "2025-11-14T06:33:38Z", + "archived": false, + "description": "Codespaces but open-source, client-only and unopinionated: Works with any IDE and lets you use any cloud, kubernetes or just localhost docker.", + "latest_release": { + "tag": "v0.6.15", + "published_at": "2025-03-10T14:33:39Z" + } + }, + "coder/coder": { + "repo": "coder/coder", + "license_spdx": "AGPL-3.0", + "stars": 14410, + "pushed_at": "2026-09-09T10:13:57Z", + "archived": false, + "description": "Secure environments for developers and their agents", + "latest_release": { + "tag": "v2.36.4", + "published_at": "2026-09-01T05:07:25Z" + } + } + } +} \ No newline at end of file diff --git a/docs/competitive-analysis.md b/docs/competitive-analysis.md index 974b3fc..4d11573 100644 --- a/docs/competitive-analysis.md +++ b/docs/competitive-analysis.md @@ -9,6 +9,25 @@ Absence claims are only made where documented or structurally implied by the project's stated scope; otherwise fields are `null`. Re-verify before quoting externally. No proprietary code, assets or branding is copied from any project. + + +## Landscape snapshot (fetched 2026-09-09) + +| Project | License | Stars | Last push | Latest release | Status | +|---|---|---|---|---|---| +| [jdx/mise](https://github.com/jdx/mise) | MIT | 33,693 | 2026-09-09 | v2026.9.3 (2026-09-08) | active | +| [asdf-vm/asdf](https://github.com/asdf-vm/asdf) | MIT | 25,571 | 2026-09-03 | v0.20.0 (2026-07-07) | active | +| [NixOS/nix](https://github.com/NixOS/nix) | LGPL-2.1 | 17,660 | 2026-09-09 | — | active | +| [direnv/direnv](https://github.com/direnv/direnv) | MIT | 15,430 | 2026-03-31 | v2.37.1 (2025-07-20) | active | +| [loft-sh/devpod](https://github.com/loft-sh/devpod) | MPL-2.0 | 15,198 | 2025-11-14 | v0.6.15 (2025-03-10) | active | +| [coder/coder](https://github.com/coder/coder) | AGPL-3.0 | 14,410 | 2026-09-09 | v2.36.4 (2026-09-01) | active | +| [jetify-com/devbox](https://github.com/jetify-com/devbox) | Apache-2.0 | 12,344 | 2026-09-04 | 0.18.0 (2026-08-16) | active | +| [cachix/devenv](https://github.com/cachix/devenv) | Apache-2.0 | 7,618 | 2026-09-07 | v2.3 (2026-09-07) | active | +| [devcontainers/cli](https://github.com/devcontainers/cli) | MIT | 2,947 | 2026-09-03 | — | active | + +Rows are generated from `data/competitor-meta.json` by `scripts/fetch_competitor_meta.py`, which reads the GitHub API. Star counts and dates are facts about the repositories on the fetch date, not judgements. Nothing here claims a project lacks a feature: where a capability was not verified it is absent from this table rather than asserted as missing. + + ## Positioning statement DevRepro Doctor is **not** an environment manager. It is the *project-aware @@ -82,4 +101,4 @@ Legend: ✅ first-class · partial · – absent/not applicable. - Package/build management (Nix store model, mise/asdf shims) — out of scope. - Cloud workspace provisioning (Coder/DevPod) — different problem. - Auto-executing generated environment files without review. -- Telemetry of any kind. \ No newline at end of file +- Telemetry of any kind. diff --git a/pyproject.toml b/pyproject.toml index a570876..29212d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,6 +102,9 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] +# Same shared-script situation: the gh endpoint is built from the hardcoded +# REPOS list above it, not from user input. +"scripts/fetch_competitor_meta.py" = ["S603", "S607", "RUF100"] # Shared verbatim across the four sibling repositories, whose ruff # rule sets differ: S310 must be suppressed where it is enabled, and # that suppression must not read as unused (RUF100) where it is not. diff --git a/scripts/fetch_competitor_meta.py b/scripts/fetch_competitor_meta.py new file mode 100644 index 0000000..9cc517d --- /dev/null +++ b/scripts/fetch_competitor_meta.py @@ -0,0 +1,117 @@ +"""Fetch live GitHub metadata for competitor repos via the authenticated gh CLI. + +Writes data/competitor-meta.json and prints a compact TSV summary. +Access date is recorded for evidence purposes. Temp-free: writes only the +final artifact. +""" + +from __future__ import annotations + +import json +import subprocess +from datetime import UTC, datetime +from pathlib import Path + +REPOS = [ + "NixOS/nix", + "cachix/devenv", + "jetify-com/devbox", + "jdx/mise", + "asdf-vm/asdf", + "direnv/direnv", + "devcontainers/cli", + "loft-sh/devpod", + "coder/coder", +] + + +class FetchError(RuntimeError): + """A competitor could not be fetched, and the run must not pretend it could.""" + + +def gh_json(endpoint: str, *, allow_404: bool = False) -> dict[str, object] | None: + """Fetch `endpoint`, raising rather than silently returning None. + + This used to swallow every failure into `None`, and the caller recorded + that as `{"error": "repo-not-found-or-error"}` in the artifact. The + competitive analysis then transcribed it as a fact about the world: + "Optic -- repo gone (404)". Optic's repository is not gone. It is + `opticdev/optic`, archived with 1,534 stars; this script was asking for + `useoptic/optic`, which does not exist. A typo in a constant became a + published claim that an archived competitor had disappeared, which is + both wrong and the opposite of useful -- an archived incumbent in this + exact domain is the most interesting fact in the file. + + `allow_404` is for endpoints where absence is a real answer (a repo with + no releases). Everywhere else a 404 stops the run so a human decides + whether the project moved or genuinely went away. + """ + try: + raw = subprocess.run( + ["gh", "api", endpoint], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + check=True, + ).stdout + return json.loads(raw) # type: ignore[no-any-return] + except subprocess.CalledProcessError as exc: + if allow_404 and "404" in (exc.stderr or ""): + return None + detail = (exc.stderr or "").strip()[:200] + raise FetchError( + f"{endpoint}: {detail}. " + "If the project moved, update REPOS. If it really is gone, say so " + "deliberately rather than letting a failed fetch write it." + ) from exc + except (subprocess.TimeoutExpired, json.JSONDecodeError) as exc: + raise FetchError(f"{endpoint}: {type(exc).__name__}") from exc + + +def main() -> None: + results: dict[str, dict[str, object]] = {} + for repo in REPOS: + meta = gh_json(f"repos/{repo}") + rel = gh_json(f"repos/{repo}/releases/latest", allow_404=True) + entry: dict[str, object] = {"repo": repo} + if meta is None: # pragma: no cover - gh_json raises instead now + raise FetchError(f"{repo}: no metadata returned") + else: + lic = meta.get("license") or {} + entry.update( + { + "license_spdx": lic.get("spdx_id"), + "stars": meta.get("stargazers_count"), + "pushed_at": meta.get("pushed_at"), + "archived": meta.get("archived"), + "description": meta.get("description"), + } + ) + if rel is None: + entry["latest_release"] = None + else: + entry["latest_release"] = { + "tag": rel.get("tag_name"), + "published_at": rel.get("published_at"), + } + results[repo] = entry + + payload = { + "fetched_utc": datetime.now(UTC).isoformat(timespec="seconds"), + "tool": "gh api (authenticated)", + "repos": results, + } + out = Path("data/competitor-meta.json") + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(payload, indent=2), encoding="utf-8") + for repo, entry in results.items(): + print( + f"{repo} | {entry.get('license_spdx')} | {entry.get('stars')}" + f" | {entry.get('pushed_at')} | {entry.get('latest_release')}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_landscape.py b/scripts/generate_landscape.py new file mode 100644 index 0000000..61f921b --- /dev/null +++ b/scripts/generate_landscape.py @@ -0,0 +1,119 @@ +"""Render the landscape table in docs/competitive-analysis.md from committed data. + +The table states its own method -- "fetched via the GitHub API" -- but was +typed by hand, so it could disagree with the data it cited. In api-verity-lab +it did, in the worst cell available: a competitor was listed as "repo gone +(404)" when the repository is archived and public with 1,534 stars. The 404 +came from the fetcher asking for the wrong org name, and the null it recorded +was transcribed into the published table as a finding. + +So the table is generated, not written. `--check` fails when the committed +markdown differs from what the data renders, which is what stops it drifting +again. + + python scripts/generate_landscape.py # rewrite the table + python scripts/generate_landscape.py --check # verify, exit 1 on drift +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DOC = ROOT / "docs" / "competitive-analysis.md" +DATA = ROOT / "data" / "competitor-meta.json" + +OPEN = "" +CLOSE = "" + +NOTE = ( + "Rows are generated from `data/competitor-meta.json` by " + "`scripts/fetch_competitor_meta.py`, which reads the GitHub API. " + "Star counts and dates are facts about the repositories on the fetch date, " + "not judgements. Nothing here claims a project lacks a feature: where a " + "capability was not verified it is absent from this table rather than " + "asserted as missing." +) + + +def render() -> str: + data = json.loads(DATA.read_text(encoding="utf-8")) + date = data["fetched_utc"][:10] + + rows = [ + "| Project | License | Stars | Last push | Latest release | Status |", + "|---|---|---|---|---|---|", + ] + for entry in sorted(data["repos"].values(), key=lambda r: r.get("stars") or 0, reverse=True): + release = entry.get("latest_release") or {} + tag = release.get("tag") + published = (release.get("published_at") or "")[:10] + release_cell = f"{tag} ({published})" if tag else "—" + stars = f"{entry['stars']:,}" if entry.get("stars") is not None else "—" + name = entry["repo"] + rows.append( + f"| [{name}](https://github.com/{name}) | {entry.get('license_spdx') or '—'} " + f"| {stars} | {(entry.get('pushed_at') or '')[:10]} | {release_cell} " + f"| {'**archived**' if entry.get('archived') else 'active'} |" + ) + + return ( + f"{OPEN}\n## Landscape snapshot (fetched {date})\n\n" + + "\n".join(rows) + + f"\n\n{NOTE}\n{CLOSE}" + ) + + +def splice(text: str) -> str: + if OPEN not in text or CLOSE not in text: + raise SystemExit(f"{DOC.name} is missing the {OPEN} / {CLOSE} markers.") + head, rest = text.split(OPEN, 1) + _, tail = rest.split(CLOSE, 1) + return head + render() + tail + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + + current = DOC.read_text(encoding="utf-8") + updated = splice(current) + + if args.check: + if current == updated: + data = json.loads(DATA.read_text(encoding="utf-8")) + print( + f"ok landscape table matches competitor-meta.json " + f"({len(data['repos'])} projects, fetched {data['fetched_utc'][:10]})" + ) + return 0 + print( + "The landscape table no longer matches competitor-meta.json.\n" + "Run: python scripts/generate_landscape.py", + file=sys.stderr, + ) + import difflib + + for line in list( + difflib.unified_diff( + current.splitlines(), + updated.splitlines(), + fromfile="committed", + tofile="rendered", + lineterm="", + ) + )[:40]: + print(line, file=sys.stderr) + return 1 + + DOC.write_text(updated, encoding="utf-8", newline="\n") + print("wrote docs/competitive-analysis.md (table rendered from committed data)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())