From 708d6a375311ce7f5121e38aa2899e618321d489 Mon Sep 17 00:00:00 2001 From: Samran Asif Date: Wed, 9 Sep 2026 15:29:25 +0500 Subject: [PATCH] docs: generate the competitive landscape from data fetched this run The competitive tables in this family of repositories stated their own method -- "repository metadata fetched via the GitHub API" -- and were then typed by hand, from data 17 days stale. api-verity-lab shows what that costs: its table recorded a live competitor as "repo gone (404)" because its fetcher asked for the wrong org name and wrote the resulting null into the artifact as a finding. So the numbers now come from a fetch run today and the table is generated from the committed result, with scripts/generate_landscape.py --check failing the build when the markdown and the data disagree. Only api-verity-lab had a checked-in fetcher; the others had committed capability data with no reproducible way to refresh it. All four have one now, each with its own competitor set, and all four inherit the fix that makes a failed lookup raise rather than silently become data. The README gains a "How this compares" section that says plainly where this project overlaps its neighbours and where it does not -- linking to the full table rather than duplicating it, so there is one place for the numbers to be right. Nothing here asserts that a competitor lacks a feature. Stars, licences and dates are facts about repositories on the fetch date; capability judgements stay in the prose, where they can be argued with. Verified: ruff, ruff format, mypy, 238 tests pass, landscape --check clean. --- .github/workflows/ci.yml | 5 ++ README.md | 17 +++++ data/competitor-meta.json | 108 ++++++++++++++++++++++++++++ docs/competitive-analysis.md | 21 +++++- pyproject.toml | 3 + scripts/fetch_competitor_meta.py | 117 ++++++++++++++++++++++++++++++ scripts/generate_landscape.py | 119 +++++++++++++++++++++++++++++++ 7 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 data/competitor-meta.json create mode 100644 scripts/fetch_competitor_meta.py create mode 100644 scripts/generate_landscape.py 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())