devready-cli 0.0.4__py3-none-any.whl → 0.1.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 +1 -1
- devready/checks/health.py +116 -0
- devready/checks/runtimes.py +103 -0
- devready/checks/tools.py +6 -3
- devready/cli.py +30 -2
- devready/explain.py +27 -0
- devready/formats.py +82 -0
- devready/runner.py +3 -0
- {devready_cli-0.0.4.dist-info → devready_cli-0.1.0.dist-info}/METADATA +21 -4
- devready_cli-0.1.0.dist-info/RECORD +21 -0
- devready_cli-0.0.4.dist-info/RECORD +0 -18
- {devready_cli-0.0.4.dist-info → devready_cli-0.1.0.dist-info}/WHEEL +0 -0
- {devready_cli-0.0.4.dist-info → devready_cli-0.1.0.dist-info}/entry_points.txt +0 -0
- {devready_cli-0.0.4.dist-info → devready_cli-0.1.0.dist-info}/licenses/LICENSE +0 -0
devready/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.0
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -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(
|
|
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,
|
|
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
|
-
|
|
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,6 +6,7 @@ import sys
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
from devready import __version__
|
|
9
|
+
from devready.formats import fix_plan, junit, sarif
|
|
9
10
|
from devready.models import Result, Status
|
|
10
11
|
from devready.runner import report, run_checks
|
|
11
12
|
|
|
@@ -19,16 +20,29 @@ def _text(results: list[Result]) -> str:
|
|
|
19
20
|
return "\n".join(lines)
|
|
20
21
|
|
|
21
22
|
|
|
23
|
+
def _matches(check_id: str, ids: list[str]) -> bool:
|
|
24
|
+
return any(check_id == i or check_id.startswith(i + ".") for i in ids)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _selected(check_id: str, only: list[str], skip: list[str]) -> bool:
|
|
28
|
+
return (not only or _matches(check_id, only)) and not _matches(check_id, skip)
|
|
29
|
+
|
|
30
|
+
|
|
22
31
|
def main(argv: list[str] | None = None) -> int:
|
|
23
32
|
parser = argparse.ArgumentParser(prog="devready", description=__doc__)
|
|
24
33
|
parser.add_argument("--version", action="version", version=f"devready {__version__}")
|
|
25
34
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
26
35
|
check = sub.add_parser("check", help="run read-only environment checks")
|
|
27
|
-
check.add_argument("--format", choices=["text", "json"], default="text")
|
|
36
|
+
check.add_argument("--format", choices=["text", "json", "junit", "sarif"], default="text")
|
|
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")
|
|
30
41
|
sub.add_parser("mcp", help="run as an MCP server over stdio")
|
|
31
|
-
|
|
42
|
+
for p in (check, fix):
|
|
43
|
+
p.add_argument("--project", default=".", help="directory whose version pins to compare")
|
|
44
|
+
p.add_argument("--only", action="append", default=[], metavar="ID", help="check id/prefix")
|
|
45
|
+
p.add_argument("--skip", action="append", default=[], metavar="ID", help="check id/prefix")
|
|
32
46
|
|
|
33
47
|
try:
|
|
34
48
|
args = parser.parse_args(argv)
|
|
@@ -50,6 +64,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
50
64
|
print(text)
|
|
51
65
|
return EXIT_OK
|
|
52
66
|
|
|
67
|
+
if args.command == "fix" and not args.plan:
|
|
68
|
+
print(
|
|
69
|
+
"devready: fix only supports --plan (DevReady never changes your system)",
|
|
70
|
+
file=sys.stderr,
|
|
71
|
+
)
|
|
72
|
+
return EXIT_USAGE
|
|
73
|
+
|
|
53
74
|
root = Path(args.project)
|
|
54
75
|
if not root.is_dir():
|
|
55
76
|
print(f"devready: not a directory: {args.project}", file=sys.stderr)
|
|
@@ -60,8 +81,15 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
60
81
|
print(f"devready: internal error ({type(e).__name__})", file=sys.stderr)
|
|
61
82
|
return EXIT_INTERNAL
|
|
62
83
|
|
|
84
|
+
results = [r for r in results if _selected(r.check_id, args.only, args.skip)]
|
|
85
|
+
|
|
86
|
+
if args.command == "fix":
|
|
87
|
+
print(fix_plan(results))
|
|
88
|
+
return EXIT_OK
|
|
63
89
|
if args.format == "json":
|
|
64
90
|
print(json.dumps(report(results), indent=2))
|
|
91
|
+
elif args.format in ("junit", "sarif"):
|
|
92
|
+
print(junit(results) if args.format == "junit" else sarif(results))
|
|
65
93
|
else:
|
|
66
94
|
print(_text(results))
|
|
67
95
|
return EXIT_FAILED if any(r.status is Status.FAIL for r in results) else EXIT_OK
|
devready/explain.py
CHANGED
|
@@ -16,4 +16,31 @@ 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.",
|
|
19
46
|
}
|
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
|
@@ -4,8 +4,10 @@ import platform
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
6
|
from devready.checks.extras import check_disk, check_docker, check_git_identity
|
|
7
|
+
from devready.checks.health import check_health
|
|
7
8
|
from devready.checks.project import check_project
|
|
8
9
|
from devready.checks.python_check import check_python
|
|
10
|
+
from devready.checks.runtimes import check_runtimes
|
|
9
11
|
from devready.checks.tools import TOOLS, check_tool
|
|
10
12
|
from devready.models import Result
|
|
11
13
|
|
|
@@ -18,6 +20,7 @@ def run_checks(root: Path) -> list[Result]:
|
|
|
18
20
|
results.append(result)
|
|
19
21
|
results += check_git_identity()
|
|
20
22
|
results += check_project(root, versions["node"], platform.python_version())
|
|
23
|
+
results += check_runtimes(root) + check_health(root)
|
|
21
24
|
return results + check_docker(root) + check_disk(root)
|
|
22
25
|
|
|
23
26
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: devready-cli
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.1.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,9 @@ 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
|
|
114
117
|
devready mcp # run as an MCP server over stdio
|
|
115
118
|
devready --version
|
|
116
119
|
```
|
|
@@ -148,6 +151,16 @@ devready --version
|
|
|
148
151
|
}
|
|
149
152
|
```
|
|
150
153
|
|
|
154
|
+
## Use it in CI (GitHub Actions)
|
|
155
|
+
|
|
156
|
+
```yaml
|
|
157
|
+
- uses: Evalogical-Inc/devReady@main # pin a release tag once published
|
|
158
|
+
with:
|
|
159
|
+
skip: project.ports # optional; also: only, format, project, version
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
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.
|
|
163
|
+
|
|
151
164
|
## Use it with AI coding agents
|
|
152
165
|
|
|
153
166
|
DevReady ships the same engine three ways, so your agent can verify the environment *before* it installs, builds or tests.
|
|
@@ -181,15 +194,15 @@ See [SECURITY.md](SECURITY.md) to report a vulnerability.
|
|
|
181
194
|
|
|
182
195
|
## Limitations (alpha)
|
|
183
196
|
|
|
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),
|
|
197
|
+
- 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
198
|
- The version-range matcher covers common npm/PEP 440 syntax; pre-releases and exotic ranges report `skip` rather than guess.
|
|
186
199
|
- The Python version compared is the interpreter running DevReady, not necessarily your project's virtualenv.
|
|
187
200
|
|
|
188
201
|
## Roadmap
|
|
189
202
|
|
|
190
203
|
- [x] Docker CLI + daemon reachability, `.tool-versions`, `.env` key-name diff, disk space, git identity, `devready explain`
|
|
191
|
-
- [
|
|
192
|
-
- [ ]
|
|
204
|
+
- [x] Port-in-use checks, more ecosystems (Go, Rust, Java, Ruby, .NET, pnpm/yarn/bun), JUnit/SARIF output, `fix --plan`, GitHub Action
|
|
205
|
+
- [ ] Config file, shared baseline (see [ROADMAP.md](ROADMAP.md))
|
|
193
206
|
- [ ] Standalone binaries
|
|
194
207
|
|
|
195
208
|
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 +215,10 @@ PRs welcome, especially new read-only checks and platform fixes. Start with [CON
|
|
|
202
215
|
ruff check . && pytest
|
|
203
216
|
```
|
|
204
217
|
|
|
218
|
+
## About Evalogical
|
|
219
|
+
|
|
220
|
+
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).
|
|
221
|
+
|
|
205
222
|
## License
|
|
206
223
|
|
|
207
224
|
[MIT](LICENSE) © Evalogical Inc
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
devready/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
devready/__main__.py,sha256=c-4qpm2SKZs1nENGzSnPnChNTo8iMwjY0XaPyAApn4I,60
|
|
3
|
+
devready/cli.py,sha256=OucYELHjvcFY2qw96Kc6yi4lp1RX9GnSif3MlelJ3ks,3787
|
|
4
|
+
devready/explain.py,sha256=UbYKUdfdVsITUtuFIvG4vJ21SP8GI-HNPKh9xp6LCRE,4641
|
|
5
|
+
devready/formats.py,sha256=akN62ouKLeZQB6POcXNzINYKHwpUd4im0n2-9rtB8eE,3129
|
|
6
|
+
devready/mcp_server.py,sha256=zVm89kZL_-pZwTSWkt6rxKonytNklUSa0RCQkX0lzFk,3177
|
|
7
|
+
devready/models.py,sha256=KyGp3ie2LVQmuSfzLV8tbEH_hJyU5-Vi11d0JcQPAuE,646
|
|
8
|
+
devready/runner.py,sha256=3OLWash5jGZKQM30iA1ObAzF2FTmM7STsiDD3LMO9Qs,1005
|
|
9
|
+
devready/versions.py,sha256=dXKIl1-qvcfadRNaCURMoftwx7YtadDZ-YfUp-85XZc,2112
|
|
10
|
+
devready/checks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
devready/checks/extras.py,sha256=pDJbFaAwjkLc92tcFagOmj-5c_vMx32_MtYa_DBfCz8,3428
|
|
12
|
+
devready/checks/health.py,sha256=woaaUL2TJ_biRuiStXqJtnvPua_y69bW2quNPlwd7tQ,4235
|
|
13
|
+
devready/checks/project.py,sha256=UnnjuZmG2aXXBAY45wTEP-bj4CWODMv3DPfEi1V_QDE,5983
|
|
14
|
+
devready/checks/python_check.py,sha256=uQCd-N0LEwt9R5gwZm5mlYiPMST1JXVDNQOdZ5xpako,328
|
|
15
|
+
devready/checks/runtimes.py,sha256=OiC5LEhmfkiMseP2PbQS6Dc6TC2JhYNICU6LmdFFvqk,4446
|
|
16
|
+
devready/checks/tools.py,sha256=AiL9TdIREmY7tEkkpt62DELKuJ2lhu3XcA2yGak9QkA,1616
|
|
17
|
+
devready_cli-0.1.0.dist-info/METADATA,sha256=jlbjbIv1omn0U0G-ZlgrrHdDMP-O33GC4tMmAJC8pdE,10176
|
|
18
|
+
devready_cli-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
19
|
+
devready_cli-0.1.0.dist-info/entry_points.txt,sha256=7oAecSWtsIt7qLOP8PipOeDqhXCkEJat0oxhD4T1RpY,47
|
|
20
|
+
devready_cli-0.1.0.dist-info/licenses/LICENSE,sha256=S_AQsEw289FIsOgT-OPWL5JZeV8nn5kicwEq0r9qu7s,1071
|
|
21
|
+
devready_cli-0.1.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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|