devready-cli 0.0.4__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
devready/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.0.4"
1
+ __version__ = "0.2.0"
devready/baseline.py ADDED
@@ -0,0 +1,43 @@
1
+ """Compare a run with a saved report (`devready check --format json > baseline.json`).
2
+
3
+ DevReady only reads the baseline; saving or committing it is up to you. That covers both a
4
+ shared team baseline (committed file) and diffing against an earlier local run.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from pathlib import Path
12
+
13
+ from devready.checks.project import _read
14
+ from devready.models import Result, Status
15
+
16
+ _ID = re.compile(r"^[a-z_]+(\.[a-z_]+)+$")
17
+ _RANK = {Status.PASS: 0, Status.SKIP: 0, Status.WARN: 1, Status.ERROR: 2, Status.FAIL: 3}
18
+
19
+
20
+ def load_baseline(path: Path) -> dict[str, Status] | None:
21
+ """check_id -> status, or None if the file is missing, too big, or not a v1 report."""
22
+ text = _read(path)
23
+ try:
24
+ data = json.loads(text) if text else None
25
+ if data["schema_version"] != 1:
26
+ return None
27
+ return {
28
+ r["check_id"]: Status(r["status"])
29
+ for r in data["results"]
30
+ if isinstance(r["check_id"], str) and _ID.match(r["check_id"])
31
+ }
32
+ except (ValueError, KeyError, TypeError):
33
+ return None
34
+
35
+
36
+ def diff(results: list[Result], base: dict[str, Status]) -> tuple[list[Result], bool]:
37
+ """(results that are new or changed status, whether any FAIL got worse)."""
38
+ changed = [r for r in results if base.get(r.check_id) is not r.status]
39
+ worse = any(
40
+ r.status is Status.FAIL and _RANK[r.status] > _RANK.get(base.get(r.check_id), 0)
41
+ for r in changed
42
+ )
43
+ return changed, worse
@@ -0,0 +1,102 @@
1
+ """Optional `.devready.json`: extra requirements, required env var names, checks to skip.
2
+
3
+ {"skip": ["project.ports"], "require": {"node": ">=20", "python": ">=3.11"}, "env": ["API_KEY"]}
4
+
5
+ Read-only, same file rules as project.py. Env vars are tested for presence only; values are
6
+ never read. Invalid files give one ERROR result and never echo file content.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ from collections.abc import Mapping
15
+ from pathlib import Path
16
+
17
+ from devready.checks.project import _compare, _read
18
+ from devready.models import Result, Status
19
+ from devready.versions import SAFE_SPEC, parse_version
20
+
21
+ CONFIG_FILE = ".devready.json"
22
+ _ID = re.compile(r"^[a-z_]+(\.[a-z_]+)*$")
23
+ _ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
24
+ _RUNTIMES = {"node": "Node", "python": "Python"}
25
+
26
+
27
+ def _valid(cfg: object) -> bool:
28
+ if not isinstance(cfg, dict) or not set(cfg) <= {"skip", "require", "env"}:
29
+ return False
30
+ skip, req, env = cfg.get("skip", []), cfg.get("require", {}), cfg.get("env", [])
31
+ return (
32
+ isinstance(skip, list)
33
+ and all(isinstance(i, str) and _ID.match(i) for i in skip)
34
+ and isinstance(req, dict)
35
+ and all(
36
+ k in _RUNTIMES and isinstance(v, str) and SAFE_SPEC.match(v) for k, v in req.items()
37
+ )
38
+ and isinstance(env, list)
39
+ and all(isinstance(n, str) and _ENV_NAME.match(n) for n in env)
40
+ )
41
+
42
+
43
+ def load_config(root: Path) -> tuple[dict | None, Result | None]:
44
+ """(config, None) if absent/valid ({} when absent), (None, error result) if invalid."""
45
+ text = _read(root / CONFIG_FILE)
46
+ if text is None:
47
+ return {}, None
48
+ try:
49
+ cfg = json.loads(text)
50
+ except ValueError:
51
+ cfg = None
52
+ if _valid(cfg):
53
+ return cfg, None
54
+ return None, Result(
55
+ "project.config.file",
56
+ "Config",
57
+ Status.ERROR,
58
+ f"{CONFIG_FILE} is invalid; it was ignored",
59
+ 'Allowed keys: "skip" (check ids), "require" (node/python specs), "env" (variable names).',
60
+ )
61
+
62
+
63
+ def check_config(
64
+ cfg: dict, node: str | None, python: str | None, environ: Mapping[str, str] = os.environ
65
+ ) -> list[Result]:
66
+ out: list[Result] = []
67
+ versions = {
68
+ "node": parse_version(node) if node else None,
69
+ "python": parse_version(python or ""),
70
+ }
71
+ for key, spec in cfg.get("require", {}).items():
72
+ out.append(
73
+ _compare(
74
+ f"project.{key}.config",
75
+ f"{_RUNTIMES[key]} required",
76
+ CONFIG_FILE,
77
+ spec,
78
+ versions[key],
79
+ Status.FAIL,
80
+ )
81
+ )
82
+ wanted = cfg.get("env", [])
83
+ if wanted:
84
+ missing = [n for n in wanted if n not in environ]
85
+ cid, title = "project.env.required", "Required env"
86
+ if missing:
87
+ out.append(
88
+ Result(
89
+ cid,
90
+ title,
91
+ Status.FAIL,
92
+ f"{len(missing)} of {len(wanted)} not set: {', '.join(missing[:10])}",
93
+ "Set these variables in your shell or CI (values are never read).",
94
+ )
95
+ )
96
+ else:
97
+ out.append(Result(cid, title, Status.PASS, f"all {len(wanted)} variables are set"))
98
+ return out
99
+
100
+
101
+ # Empty on purpose: pre-filling "env" from .env.example would make optional variables fail.
102
+ TEMPLATE = json.dumps({"skip": [], "require": {}, "env": []}, indent=2)
@@ -0,0 +1,116 @@
1
+ """Project health: .env ignored by git, node_modules vs lockfile, ports the project declares.
2
+
3
+ Read-only. Only numbers and fixed phrases reach the report, never file content.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ import shutil
11
+ import socket
12
+ from collections.abc import Callable
13
+ from pathlib import Path
14
+
15
+ from devready.checks.extras import DOCKER_FILES, _run
16
+ from devready.checks.project import _read
17
+ from devready.models import Result, Status
18
+
19
+ LOCKFILES = ("package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "bun.lock")
20
+ _COMPOSE_PORT = re.compile(r"^\s*-\s*[\"']?(?:[\d.]+:)?(\d{2,5}):\d+", re.M)
21
+ _ENV_PORT = re.compile(r"^\s*(?:export\s+)?[A-Z_]*PORT\s*=\s*[\"']?(\d{2,5})\b", re.M)
22
+ MAX_PORTS = 20
23
+
24
+
25
+ def _listening(port: int) -> bool:
26
+ with socket.socket() as s:
27
+ s.settimeout(0.3)
28
+ return s.connect_ex(("127.0.0.1", port)) == 0
29
+
30
+
31
+ def check_env_ignored(root: Path, run: Callable = _run) -> list[Result]:
32
+ """A .env that git would commit is a leak risk. Silent outside a git repo."""
33
+ git = shutil.which("git")
34
+ if not (root / ".env").is_file() or not git:
35
+ return []
36
+ p = run([git, "-C", str(root), "check-ignore", "-q", ".env"])
37
+ if p is None or p.returncode not in (0, 1):
38
+ return []
39
+ if p.returncode == 0:
40
+ return [Result("project.env.ignored", "Env ignored", Status.PASS, ".env is git-ignored")]
41
+ return [
42
+ Result(
43
+ "project.env.ignored",
44
+ "Env ignored",
45
+ Status.WARN,
46
+ ".env is not ignored by git (or is already tracked)",
47
+ "Add .env to .gitignore; if it is tracked, untrack it and rotate its secrets.",
48
+ )
49
+ ]
50
+
51
+
52
+ def check_node_modules(root: Path) -> list[Result]:
53
+ text = _read(root / "package.json")
54
+ try:
55
+ data = json.loads(text) if text else None
56
+ except ValueError:
57
+ return [] # project.py already reports the parse error
58
+ if not isinstance(data, dict) or not any(
59
+ isinstance(data.get(k), dict) and data[k] for k in ("dependencies", "devDependencies")
60
+ ):
61
+ return []
62
+ cid, title = "project.node.modules", "node_modules"
63
+ nm = root / "node_modules"
64
+ if not nm.is_dir():
65
+ return [
66
+ Result(
67
+ cid,
68
+ title,
69
+ Status.WARN,
70
+ "node_modules not found",
71
+ "Install dependencies with your package manager (npm, pnpm, yarn or bun).",
72
+ )
73
+ ]
74
+ # ponytail: directory mtime, not a content comparison; upgrade if it proves noisy.
75
+ locks = [root / f for f in LOCKFILES if (root / f).is_file()]
76
+ if any(lock.stat().st_mtime > nm.stat().st_mtime for lock in locks):
77
+ return [
78
+ Result(
79
+ cid,
80
+ title,
81
+ Status.WARN,
82
+ "a lockfile is newer than node_modules",
83
+ "Re-run your package manager's install to sync dependencies.",
84
+ )
85
+ ]
86
+ return [Result(cid, title, Status.PASS, "node_modules is present and not older than lockfiles")]
87
+
88
+
89
+ def check_ports(root: Path, in_use: Callable[[int], bool] = _listening) -> list[Result]:
90
+ ports: set[int] = set()
91
+ for name in DOCKER_FILES[1:]: # compose files only
92
+ if (text := _read(root / name)) is not None:
93
+ ports.update(int(p) for p in _COMPOSE_PORT.findall(text))
94
+ if (text := _read(root / ".env.example")) is not None:
95
+ ports.update(int(p) for p in _ENV_PORT.findall(text))
96
+ ports = {p for p in ports if 0 < p < 65536}
97
+ if not ports:
98
+ return []
99
+ cid, title = "project.ports.free", "Ports"
100
+ shown = sorted(ports)[:MAX_PORTS]
101
+ busy = [p for p in shown if in_use(p)]
102
+ if busy:
103
+ return [
104
+ Result(
105
+ cid,
106
+ title,
107
+ Status.WARN,
108
+ f"already in use on localhost: {', '.join(map(str, busy))}",
109
+ "Stop the other process or change the port; it may be this project running.",
110
+ )
111
+ ]
112
+ return [Result(cid, title, Status.PASS, f"{len(shown)} declared port(s) are free")]
113
+
114
+
115
+ def check_health(root: Path) -> list[Result]:
116
+ return check_env_ignored(root) + check_node_modules(root) + check_ports(root)
@@ -0,0 +1,103 @@
1
+ """Go, Ruby, Java, Rust, .NET, pnpm, Yarn, Bun: checked only when the project pins them.
2
+
3
+ Same read-only rules as project.py. Only digits/validated specs ever reach the report.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ from collections.abc import Callable
11
+ from pathlib import Path
12
+
13
+ from devready.checks.project import _compare, _read
14
+ from devready.checks.tools import check_tool
15
+ from devready.models import Result, Status
16
+ from devready.versions import parse_version
17
+
18
+
19
+ def _first_version(text: str) -> str | None:
20
+ m = re.search(r"\d+(?:\.\d+){0,2}", text.strip().split("\n", 1)[0])
21
+ return m.group() if m else None
22
+
23
+
24
+ def _go_mod(text: str) -> str | None:
25
+ m = re.search(r"^go\s+(\d+(?:\.\d+){1,2})", text, re.M)
26
+ return f">={m.group(1)}" if m else None
27
+
28
+
29
+ def _rust(text: str) -> str | None:
30
+ m = re.search(r"^\s*channel\s*=\s*[\"']([^\"']*)[\"']", text, re.M)
31
+ return m.group(1) if m else text.strip().split("\n", 1)[0] or None
32
+
33
+
34
+ def _global_json(text: str) -> str | None:
35
+ try:
36
+ spec = json.loads(text).get("sdk", {}).get("version")
37
+ except (ValueError, AttributeError):
38
+ return None
39
+ return spec if isinstance(spec, str) else None
40
+
41
+
42
+ def _pkg_manager(name: str) -> Callable[[str], str | None]:
43
+ def extract(text: str) -> str | None:
44
+ try:
45
+ value = json.loads(text).get("packageManager")
46
+ except (ValueError, AttributeError):
47
+ return None
48
+ m = (
49
+ re.fullmatch(rf"{name}@(\d+(?:\.\d+){{0,2}})(?:\+.*)?", value)
50
+ if isinstance(value, str)
51
+ else None
52
+ )
53
+ return m.group(1) if m else None
54
+
55
+ return extract
56
+
57
+
58
+ # key -> (title, exe, version args, docs, .tool-versions name, [(id, file, extractor, miss)])
59
+ RUNTIMES: dict[str, tuple] = {
60
+ "go": ("Go", "go", ("version",), "https://go.dev/doc/install", "golang",
61
+ [("project.go.mod", "go.mod", _go_mod, Status.FAIL)]),
62
+ "ruby": ("Ruby", "ruby", ("--version",), "https://www.ruby-lang.org/en/downloads/", "ruby",
63
+ [("project.ruby.pin", ".ruby-version", _first_version, Status.WARN)]),
64
+ "java": ("Java", "java", ("-version",), "https://adoptium.net/", "java",
65
+ [("project.java.pin", ".java-version", _first_version, Status.WARN)]),
66
+ "rust": ("Rust", "rustc", ("--version",), "https://rustup.rs/", "rust",
67
+ [("project.rust.toolchain", "rust-toolchain.toml", _rust, Status.WARN),
68
+ ("project.rust.toolchain", "rust-toolchain", _rust, Status.WARN)]),
69
+ "dotnet": ("dotnet", "dotnet", ("--version",), "https://dotnet.microsoft.com/download",
70
+ "dotnet", [("project.dotnet.global", "global.json", _global_json, Status.WARN)]),
71
+ # packageManager in package.json pins one manager, e.g. "pnpm@9.1.0"
72
+ "pnpm": ("pnpm", "pnpm", ("--version",), "https://pnpm.io/installation", "pnpm",
73
+ [("project.pnpm.pin", "package.json", _pkg_manager("pnpm"), Status.WARN)]),
74
+ "yarn": ("Yarn", "yarn", ("--version",), "https://yarnpkg.com/getting-started/install", "yarn",
75
+ [("project.yarn.pin", "package.json", _pkg_manager("yarn"), Status.WARN)]),
76
+ "bun": ("Bun", "bun", ("--version",), "https://bun.sh/docs/installation", "bun",
77
+ [("project.bun.pin", "package.json", _pkg_manager("bun"), Status.WARN)]),
78
+ } # fmt: skip
79
+
80
+
81
+ def check_runtimes(
82
+ root: Path, tool: Callable = check_tool, runtimes: dict[str, tuple] = RUNTIMES
83
+ ) -> list[Result]:
84
+ tv = _read(root / ".tool-versions") or ""
85
+ tv_pins = {p[0]: p[1] for line in tv.splitlines() if len(p := line.split("#")[0].split()) >= 2}
86
+ out: list[Result] = []
87
+ for key, (title, exe, args, docs, tv_name, pins) in runtimes.items():
88
+ found: list[tuple[str, str, str, Status]] = []
89
+ for cid, fname, extract, miss in pins:
90
+ text = _read(root / fname)
91
+ if text is not None and (spec := extract(text)):
92
+ found.append((cid, fname, spec, miss))
93
+ break
94
+ if tv_name in tv_pins:
95
+ found.append((f"project.{key}.tool_versions", ".tool-versions", tv_pins[tv_name],
96
+ Status.WARN)) # fmt: skip
97
+ if not found:
98
+ continue
99
+ res, ver = tool(f"{key}.version", title, exe, docs, args)
100
+ out.append(res)
101
+ v = parse_version(ver) if ver else None
102
+ out += [_compare(c, f"{title} pin", src, spec, v, miss) for c, src, spec, miss in found]
103
+ return out
devready/checks/tools.py CHANGED
@@ -19,7 +19,9 @@ TOOLS = [
19
19
  ]
20
20
 
21
21
 
22
- def check_tool(check_id: str, title: str, exe: str, docs: str) -> tuple[Result, str | None]:
22
+ def check_tool(
23
+ check_id: str, title: str, exe: str, docs: str, args: tuple[str, ...] = ("--version",)
24
+ ) -> tuple[Result, str | None]:
23
25
  """Returns (result, parsed version or None). Raw command output is never reported."""
24
26
  path = shutil.which(exe)
25
27
  if not path:
@@ -33,11 +35,12 @@ def check_tool(check_id: str, title: str, exe: str, docs: str) -> tuple[Result,
33
35
  ), None
34
36
  try:
35
37
  proc = subprocess.run(
36
- [path, "--version"], capture_output=True, text=True, timeout=5, errors="replace"
38
+ [path, *args], capture_output=True, text=True, timeout=5, errors="replace"
37
39
  )
38
40
  except (subprocess.TimeoutExpired, OSError):
39
41
  return Result(check_id, title, Status.ERROR, "Could not run version command"), None
40
- m = re.search(r"\d+(?:\.\d+)+", proc.stdout[:200]) if proc.returncode == 0 else None
42
+ out = (proc.stdout or proc.stderr)[:200] # java -version prints to stderr
43
+ m = re.search(r"\d+(?:\.\d+)+", out) if proc.returncode == 0 else None
41
44
  if not m:
42
45
  return Result(check_id, title, Status.ERROR, "Version output not recognized"), None
43
46
  return Result(check_id, title, Status.PASS, f"{m.group()} found"), m.group()
devready/cli.py CHANGED
@@ -6,8 +6,10 @@ import sys
6
6
  from pathlib import Path
7
7
 
8
8
  from devready import __version__
9
+ from devready.baseline import diff, load_baseline
10
+ from devready.formats import fix_plan, junit, sarif
9
11
  from devready.models import Result, Status
10
- from devready.runner import report, run_checks
12
+ from devready.runner import matches, report, run_checks
11
13
 
12
14
  EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_INTERNAL = 0, 1, 2, 3
13
15
 
@@ -19,16 +21,29 @@ def _text(results: list[Result]) -> str:
19
21
  return "\n".join(lines)
20
22
 
21
23
 
24
+ def _selected(check_id: str, only: list[str], skip: list[str]) -> bool:
25
+ return (not only or matches(check_id, only)) and not matches(check_id, skip)
26
+
27
+
22
28
  def main(argv: list[str] | None = None) -> int:
23
29
  parser = argparse.ArgumentParser(prog="devready", description=__doc__)
24
30
  parser.add_argument("--version", action="version", version=f"devready {__version__}")
25
31
  sub = parser.add_subparsers(dest="command", required=True)
26
32
  check = sub.add_parser("check", help="run read-only environment checks")
27
- check.add_argument("--format", choices=["text", "json"], default="text")
33
+ check.add_argument("--format", choices=["text", "json", "junit", "sarif"], default="text")
34
+ check.add_argument(
35
+ "--baseline", metavar="FILE", help="saved --format json report; show only what changed"
36
+ )
37
+ fix = sub.add_parser("fix", help="print suggested fixes (never runs them)")
38
+ fix.add_argument("--plan", action="store_true", help="required: list steps without running")
28
39
  explain = sub.add_parser("explain", help="explain what a check id means")
29
40
  explain.add_argument("check_id")
41
+ sub.add_parser("init", help="print a starter .devready.json (does not write it)")
30
42
  sub.add_parser("mcp", help="run as an MCP server over stdio")
31
- check.add_argument("--project", default=".", help="directory whose version pins to compare")
43
+ for p in (check, fix):
44
+ p.add_argument("--project", default=".", help="directory whose version pins to compare")
45
+ p.add_argument("--only", action="append", default=[], metavar="ID", help="check id/prefix")
46
+ p.add_argument("--skip", action="append", default=[], metavar="ID", help="check id/prefix")
32
47
 
33
48
  try:
34
49
  args = parser.parse_args(argv)
@@ -40,6 +55,12 @@ def main(argv: list[str] | None = None) -> int:
40
55
 
41
56
  return serve()
42
57
 
58
+ if args.command == "init":
59
+ from devready.checks.config import TEMPLATE
60
+
61
+ print(TEMPLATE)
62
+ return EXIT_OK
63
+
43
64
  if args.command == "explain":
44
65
  from devready.explain import EXPLANATIONS
45
66
 
@@ -50,6 +71,13 @@ def main(argv: list[str] | None = None) -> int:
50
71
  print(text)
51
72
  return EXIT_OK
52
73
 
74
+ if args.command == "fix" and not args.plan:
75
+ print(
76
+ "devready: fix only supports --plan (DevReady never changes your system)",
77
+ file=sys.stderr,
78
+ )
79
+ return EXIT_USAGE
80
+
53
81
  root = Path(args.project)
54
82
  if not root.is_dir():
55
83
  print(f"devready: not a directory: {args.project}", file=sys.stderr)
@@ -60,10 +88,29 @@ def main(argv: list[str] | None = None) -> int:
60
88
  print(f"devready: internal error ({type(e).__name__})", file=sys.stderr)
61
89
  return EXIT_INTERNAL
62
90
 
91
+ results = [r for r in results if _selected(r.check_id, args.only, args.skip)]
92
+
93
+ if args.command == "fix":
94
+ print(fix_plan(results))
95
+ return EXIT_OK
96
+
97
+ regressed = None
98
+ if args.baseline:
99
+ base = load_baseline(Path(args.baseline))
100
+ if base is None:
101
+ print("devready: baseline is not a readable devready JSON report", file=sys.stderr)
102
+ return EXIT_USAGE
103
+ total = len(results)
104
+ results, regressed = diff(results, base)
105
+ print(f"Compared with baseline: {len(results)} of {total} changed.", file=sys.stderr)
63
106
  if args.format == "json":
64
107
  print(json.dumps(report(results), indent=2))
108
+ elif args.format in ("junit", "sarif"):
109
+ print(junit(results) if args.format == "junit" else sarif(results))
65
110
  else:
66
111
  print(_text(results))
112
+ if regressed is not None: # with a baseline only a new or worse FAIL breaks the build
113
+ return EXIT_FAILED if regressed else EXIT_OK
67
114
  return EXIT_FAILED if any(r.status is Status.FAIL for r in results) else EXIT_OK
68
115
 
69
116
 
devready/explain.py CHANGED
@@ -16,4 +16,35 @@ EXPLANATIONS = {
16
16
  "docker.daemon": "The Docker CLI can exist while the daemon is stopped. Start Docker Desktop or the service.",
17
17
  "git.identity": "Commits need user.name and user.email. DevReady checks they exist, never what they are.",
18
18
  "disk.space": "Warns below 1 GB free where the project lives; installs and builds can fail when the disk is full.",
19
+ "go.version": "Go toolchain; checked only when the project has a go.mod or a golang line in .tool-versions.",
20
+ "ruby.version": "Ruby interpreter; checked only when the project pins it (.ruby-version or .tool-versions).",
21
+ "java.version": "Java runtime; checked only when the project pins it (.java-version or .tool-versions).",
22
+ "rust.version": "Rust compiler (rustc); checked only when the project has a rust-toolchain file or pin.",
23
+ "dotnet.version": "The .NET SDK; checked only when the project has a global.json or a dotnet pin.",
24
+ "project.go.mod": "The go directive in go.mod is the minimum Go version. A lower toolchain fails the check.",
25
+ "project.go.tool_versions": ".tool-versions (asdf/mise) pins Go for this project.",
26
+ "project.ruby.pin": ".ruby-version pins Ruby for this project (rbenv, rvm, mise).",
27
+ "project.ruby.tool_versions": ".tool-versions (asdf/mise) pins Ruby for this project.",
28
+ "project.java.pin": ".java-version pins Java for this project (jenv, mise).",
29
+ "project.java.tool_versions": ".tool-versions (asdf/mise) pins Java for this project.",
30
+ "project.rust.toolchain": "rust-toolchain(.toml) pins the Rust channel. Named channels such as stable are reported as not evaluated.",
31
+ "project.rust.tool_versions": ".tool-versions (asdf/mise) pins Rust for this project.",
32
+ "project.dotnet.global": "global.json sdk.version pins the .NET SDK for this project.",
33
+ "project.dotnet.tool_versions": ".tool-versions (asdf/mise) pins .NET for this project.",
34
+ "pnpm.version": "pnpm package manager; checked only when package.json packageManager names pnpm.",
35
+ "yarn.version": "Yarn package manager; checked only when package.json packageManager names yarn.",
36
+ "bun.version": "Bun runtime and package manager; checked only when package.json packageManager names bun.",
37
+ "project.pnpm.pin": "package.json packageManager pins the pnpm version (corepack enable can install it).",
38
+ "project.yarn.pin": "package.json packageManager pins the Yarn version (corepack enable can install it).",
39
+ "project.bun.pin": "package.json packageManager pins the Bun version.",
40
+ "project.pnpm.tool_versions": ".tool-versions (asdf/mise) pins pnpm for this project.",
41
+ "project.yarn.tool_versions": ".tool-versions (asdf/mise) pins Yarn for this project.",
42
+ "project.bun.tool_versions": ".tool-versions (asdf/mise) pins Bun for this project.",
43
+ "project.env.ignored": "Warns when .env exists but git would commit it (not ignored, or already tracked). Values are never shown.",
44
+ "project.node.modules": "Checked when package.json lists dependencies: node_modules must exist and not be older than a lockfile.",
45
+ "project.ports.free": "Ports declared in compose files (host side) or *PORT= lines in .env.example, tested by connecting to localhost. A busy port may be this project already running.",
46
+ "project.config.file": ".devready.json is optional. With unknown keys or invalid values it is ignored entirely. Run `devready init` for a starter.",
47
+ "project.node.config": "require.node in .devready.json is a hard requirement (fails when unmet).",
48
+ "project.python.config": "require.python in .devready.json is a hard requirement (fails when unmet).",
49
+ "project.env.required": "Variable NAMES listed under env in .devready.json must be set in the environment. Values are never read.",
19
50
  }
devready/formats.py ADDED
@@ -0,0 +1,82 @@
1
+ """Extra output formats for CI: JUnit XML, SARIF 2.1.0, and the read-only fix plan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import xml.etree.ElementTree as ET
7
+
8
+ from devready import __version__
9
+ from devready.models import Result, Status
10
+
11
+ _LEVEL = {Status.FAIL: "error", Status.ERROR: "error", Status.WARN: "warning"}
12
+
13
+
14
+ def junit(results: list[Result]) -> str:
15
+ count = lambda *s: str(sum(r.status in s for r in results)) # noqa: E731
16
+ suite = ET.Element(
17
+ "testsuite",
18
+ name="devready",
19
+ tests=str(len(results)),
20
+ failures=count(Status.FAIL),
21
+ errors=count(Status.ERROR),
22
+ skipped=count(Status.SKIP),
23
+ )
24
+ for r in results:
25
+ case = ET.SubElement(suite, "testcase", classname="devready", name=r.check_id)
26
+ if r.status is Status.FAIL:
27
+ ET.SubElement(case, "failure", message=r.summary)
28
+ elif r.status is Status.ERROR:
29
+ ET.SubElement(case, "error", message=r.summary)
30
+ elif r.status is Status.SKIP:
31
+ ET.SubElement(case, "skipped", message=r.summary)
32
+ elif r.status is Status.WARN: # warnings do not fail the suite; keep them visible
33
+ ET.SubElement(case, "system-out").text = f"WARN: {r.summary}"
34
+ return ET.tostring(suite, encoding="unicode", xml_declaration=False)
35
+
36
+
37
+ def sarif(results: list[Result]) -> str:
38
+ # ponytail: findings carry no file location, so GitHub code scanning may not display them;
39
+ # add locations once checks know which file they read.
40
+ bad = [r for r in results if r.status in _LEVEL]
41
+ doc = {
42
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
43
+ "version": "2.1.0",
44
+ "runs": [
45
+ {
46
+ "tool": {
47
+ "driver": {
48
+ "name": "devready",
49
+ "version": __version__,
50
+ "rules": [
51
+ {"id": i, "shortDescription": {"text": i}}
52
+ for i in sorted({r.check_id for r in bad})
53
+ ],
54
+ }
55
+ },
56
+ "results": [
57
+ {
58
+ "ruleId": r.check_id,
59
+ "level": _LEVEL[r.status],
60
+ "message": {"text": f"{r.summary}. {r.suggestion}".strip()},
61
+ }
62
+ for r in bad
63
+ ],
64
+ }
65
+ ],
66
+ }
67
+ return json.dumps(doc, indent=2)
68
+
69
+
70
+ def fix_plan(results: list[Result]) -> str:
71
+ """Suggested next steps only. DevReady never runs any of them."""
72
+ todo = [r for r in results if r.status in (Status.FAIL, Status.WARN) and r.suggestion]
73
+ lines = ["DevReady - Fix Plan (suggestions only)", ""]
74
+ if not todo:
75
+ lines.append("Nothing to fix.")
76
+ for i, r in enumerate(todo, 1):
77
+ lines.append(f"{i}. [{r.status.value.upper()}] {r.title}: {r.summary}")
78
+ lines.append(f" -> {r.suggestion}")
79
+ if r.docs_url:
80
+ lines.append(f" {r.docs_url}")
81
+ lines += ["", "DevReady did not run any of these. Nothing was changed."]
82
+ return "\n".join(lines)
devready/runner.py CHANGED
@@ -3,14 +3,22 @@ from __future__ import annotations
3
3
  import platform
4
4
  from pathlib import Path
5
5
 
6
+ from devready.checks.config import check_config, load_config
6
7
  from devready.checks.extras import check_disk, check_docker, check_git_identity
8
+ from devready.checks.health import check_health
7
9
  from devready.checks.project import check_project
8
10
  from devready.checks.python_check import check_python
11
+ from devready.checks.runtimes import check_runtimes
9
12
  from devready.checks.tools import TOOLS, check_tool
10
13
  from devready.models import Result
11
14
 
12
15
 
16
+ def matches(check_id: str, ids: list[str]) -> bool:
17
+ return any(check_id == i or check_id.startswith(i + ".") for i in ids)
18
+
19
+
13
20
  def run_checks(root: Path) -> list[Result]:
21
+ cfg, cfg_error = load_config(root)
14
22
  results = [check_python()]
15
23
  versions = {}
16
24
  for tool in TOOLS:
@@ -18,7 +26,13 @@ def run_checks(root: Path) -> list[Result]:
18
26
  results.append(result)
19
27
  results += check_git_identity()
20
28
  results += check_project(root, versions["node"], platform.python_version())
21
- return results + check_docker(root) + check_disk(root)
29
+ results += check_runtimes(root) + check_health(root)
30
+ results += check_docker(root) + check_disk(root)
31
+ results += (
32
+ [cfg_error] if cfg_error else check_config(cfg, versions["node"], platform.python_version())
33
+ )
34
+ skip = (cfg or {}).get("skip", [])
35
+ return [r for r in results if not matches(r.check_id, skip)]
22
36
 
23
37
 
24
38
  def report(results: list[Result]) -> dict:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: devready-cli
3
- Version: 0.0.4
3
+ Version: 0.2.0
4
4
  Summary: A local-first, read-only CLI that checks a project's environment requirements against what is installed.
5
5
  Project-URL: Homepage, https://devready-ten.vercel.app/
6
6
  Project-URL: Repository, https://github.com/Evalogical-Inc/devReady
@@ -111,6 +111,12 @@ devready check # human-readable report for the current direct
111
111
  devready check --project ../app # inspect another project's pins
112
112
  devready check --format json # machine-readable (see docs/schema.json)
113
113
  devready explain docker.daemon # what a check means and how to fix it
114
+ devready check --format junit # or sarif, for CI
115
+ devready check --only project --skip project.ports # filter by id or prefix
116
+ devready fix --plan # suggested steps only; never runs them
117
+ devready check --format json > baseline.json # save a snapshot (commit it for a team baseline)
118
+ devready check --baseline baseline.json # show only what changed; fail only on new/worse failures
119
+ devready init > .devready.json # optional config: skip ids, require node/python, required env names
114
120
  devready mcp # run as an MCP server over stdio
115
121
  devready --version
116
122
  ```
@@ -148,6 +154,16 @@ devready --version
148
154
  }
149
155
  ```
150
156
 
157
+ ## Use it in CI (GitHub Actions)
158
+
159
+ ```yaml
160
+ - uses: Evalogical-Inc/devReady@main # pin a release tag once published
161
+ with:
162
+ skip: project.ports # optional; also: only, format, project, version
163
+ ```
164
+
165
+ The job fails when a declared requirement is unmet (`fail`). It installs `devready-cli` from PyPI, so flags added after your installed release (`--only`, `--skip`, junit/sarif) need a release that includes them; set `version:` to pin one.
166
+
151
167
  ## Use it with AI coding agents
152
168
 
153
169
  DevReady ships the same engine three ways, so your agent can verify the environment *before* it installs, builds or tests.
@@ -181,15 +197,16 @@ See [SECURITY.md](SECURITY.md) to report a vulnerability.
181
197
 
182
198
  ## Limitations (alpha)
183
199
 
184
- - Checks today: Python, Git (+ identity set), Node.js, npm, project pins (`.nvmrc`, `.node-version`, `.python-version`, `.tool-versions`, `package.json` engines, `pyproject.toml` requires-python), `.env.example` vs `.env` key names, Docker CLI + daemon (only when the project has a Dockerfile/compose file), and free disk space.
200
+ - Checks today: Python, Git (+ identity set), Node.js, npm, project pins (`.nvmrc`, `.node-version`, `.python-version`, `.tool-versions`, `package.json` engines, `pyproject.toml` requires-python), `.env.example` vs `.env` key names, Docker CLI + daemon (only when the project has a Dockerfile/compose file), free disk space, and (only when the project declares them) Go, Ruby, Java, Rust, .NET, pnpm, Yarn and Bun pins, `.env` not git-ignored, `node_modules` vs lockfile, and declared ports already in use.
185
201
  - The version-range matcher covers common npm/PEP 440 syntax; pre-releases and exotic ranges report `skip` rather than guess.
186
202
  - The Python version compared is the interpreter running DevReady, not necessarily your project's virtualenv.
187
203
 
188
204
  ## Roadmap
189
205
 
190
206
  - [x] Docker CLI + daemon reachability, `.tool-versions`, `.env` key-name diff, disk space, git identity, `devready explain`
191
- - [ ] Port-in-use checks (ports read from compose files)
192
- - [ ] More ecosystems (Go, Rust, Java, Ruby pins)
207
+ - [x] Port-in-use checks, more ecosystems (Go, Rust, Java, Ruby, .NET, pnpm/yarn/bun), JUnit/SARIF output, `fix --plan`, GitHub Action
208
+ - [x] `.devready.json` config, `--baseline` snapshots
209
+ - [ ] Platform-specific checks (see [ROADMAP.md](ROADMAP.md))
193
210
  - [ ] Standalone binaries
194
211
 
195
212
  Have a setup problem DevReady should catch? [Open an issue](https://github.com/Evalogical-Inc/devReady/issues/new/choose). Early feedback shapes the roadmap.
@@ -202,6 +219,10 @@ PRs welcome, especially new read-only checks and platform fixes. Start with [CON
202
219
  ruff check . && pytest
203
220
  ```
204
221
 
222
+ ## About Evalogical
223
+
224
+ DevReady is built and maintained by [Evalogical](https://evalogical.com/), a software company offering AI-enabled product engineering, custom software and IT services. Need help with a product or an engineering team? [Talk to us](https://evalogical.com/contactus).
225
+
205
226
  ## License
206
227
 
207
228
  [MIT](LICENSE) © Evalogical Inc
@@ -0,0 +1,23 @@
1
+ devready/__init__.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
2
+ devready/__main__.py,sha256=c-4qpm2SKZs1nENGzSnPnChNTo8iMwjY0XaPyAApn4I,60
3
+ devready/baseline.py,sha256=pfVzZ695bLEiSWKUBK0PNDp1S2UWTWj9lfUCDAsVS4g,1524
4
+ devready/cli.py,sha256=RPijit2MT_ouZgD2TOUu1x-6YJevRIkh_a1wTWbMeuY,4619
5
+ devready/explain.py,sha256=nYzzwtZGpn-FQQR8_KxC61_FLf7jCuQEv4iX-Fvqqr0,5140
6
+ devready/formats.py,sha256=akN62ouKLeZQB6POcXNzINYKHwpUd4im0n2-9rtB8eE,3129
7
+ devready/mcp_server.py,sha256=zVm89kZL_-pZwTSWkt6rxKonytNklUSa0RCQkX0lzFk,3177
8
+ devready/models.py,sha256=KyGp3ie2LVQmuSfzLV8tbEH_hJyU5-Vi11d0JcQPAuE,646
9
+ devready/runner.py,sha256=MVQh-dlsXnihWSbvbF4XKuFxgIyoywtL-9I-y0XrPpc,1456
10
+ devready/versions.py,sha256=dXKIl1-qvcfadRNaCURMoftwx7YtadDZ-YfUp-85XZc,2112
11
+ devready/checks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ devready/checks/config.py,sha256=SGyIot_QXmZtPLIybtxnyJEFL2aV1NemSqX_fXbFI6A,3471
13
+ devready/checks/extras.py,sha256=pDJbFaAwjkLc92tcFagOmj-5c_vMx32_MtYa_DBfCz8,3428
14
+ devready/checks/health.py,sha256=woaaUL2TJ_biRuiStXqJtnvPua_y69bW2quNPlwd7tQ,4235
15
+ devready/checks/project.py,sha256=UnnjuZmG2aXXBAY45wTEP-bj4CWODMv3DPfEi1V_QDE,5983
16
+ devready/checks/python_check.py,sha256=uQCd-N0LEwt9R5gwZm5mlYiPMST1JXVDNQOdZ5xpako,328
17
+ devready/checks/runtimes.py,sha256=OiC5LEhmfkiMseP2PbQS6Dc6TC2JhYNICU6LmdFFvqk,4446
18
+ devready/checks/tools.py,sha256=AiL9TdIREmY7tEkkpt62DELKuJ2lhu3XcA2yGak9QkA,1616
19
+ devready_cli-0.2.0.dist-info/METADATA,sha256=b76wbK0qZnoG8aNy66wxg8QmFDPENvL3GhzAwa2-RMQ,10533
20
+ devready_cli-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
21
+ devready_cli-0.2.0.dist-info/entry_points.txt,sha256=7oAecSWtsIt7qLOP8PipOeDqhXCkEJat0oxhD4T1RpY,47
22
+ devready_cli-0.2.0.dist-info/licenses/LICENSE,sha256=S_AQsEw289FIsOgT-OPWL5JZeV8nn5kicwEq0r9qu7s,1071
23
+ devready_cli-0.2.0.dist-info/RECORD,,
@@ -1,18 +0,0 @@
1
- devready/__init__.py,sha256=1mptEzQihbdyqqzMgdns_j5ZGK9gz7hR2bsgA_TnjO4,22
2
- devready/__main__.py,sha256=c-4qpm2SKZs1nENGzSnPnChNTo8iMwjY0XaPyAApn4I,60
3
- devready/cli.py,sha256=yStxOgFcYqVOVmcbMZGwU83zaZZemecJKGsg0KbVUEE,2521
4
- devready/explain.py,sha256=PaAt0K0-tdQB5pS9Y2vxZEKChwra_SdY7-qAT0juKto,1765
5
- devready/mcp_server.py,sha256=zVm89kZL_-pZwTSWkt6rxKonytNklUSa0RCQkX0lzFk,3177
6
- devready/models.py,sha256=KyGp3ie2LVQmuSfzLV8tbEH_hJyU5-Vi11d0JcQPAuE,646
7
- devready/runner.py,sha256=5WML6vsoVer0FUpgCderZuqip6ldhWtWGZ-ufVxQV7U,848
8
- devready/versions.py,sha256=dXKIl1-qvcfadRNaCURMoftwx7YtadDZ-YfUp-85XZc,2112
9
- devready/checks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- devready/checks/extras.py,sha256=pDJbFaAwjkLc92tcFagOmj-5c_vMx32_MtYa_DBfCz8,3428
11
- devready/checks/project.py,sha256=UnnjuZmG2aXXBAY45wTEP-bj4CWODMv3DPfEi1V_QDE,5983
12
- devready/checks/python_check.py,sha256=uQCd-N0LEwt9R5gwZm5mlYiPMST1JXVDNQOdZ5xpako,328
13
- devready/checks/tools.py,sha256=bjO61jFefp0b6iBHg-a79uaz8nZGCldakfHyB1G8Gf4,1511
14
- devready_cli-0.0.4.dist-info/METADATA,sha256=UPBgdGDMBm_Uvwzb2vd-ZAhs3EL-rzn7qCCwHgYvHj0,8953
15
- devready_cli-0.0.4.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
16
- devready_cli-0.0.4.dist-info/entry_points.txt,sha256=7oAecSWtsIt7qLOP8PipOeDqhXCkEJat0oxhD4T1RpY,47
17
- devready_cli-0.0.4.dist-info/licenses/LICENSE,sha256=S_AQsEw289FIsOgT-OPWL5JZeV8nn5kicwEq0r9qu7s,1071
18
- devready_cli-0.0.4.dist-info/RECORD,,