devready-cli 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (56) hide show
  1. {devready_cli-0.1.0 → devready_cli-0.2.0}/.claude-plugin/plugin.json +1 -1
  2. {devready_cli-0.1.0 → devready_cli-0.2.0}/CHANGELOG.md +4 -0
  3. {devready_cli-0.1.0 → devready_cli-0.2.0}/PKG-INFO +6 -2
  4. {devready_cli-0.1.0 → devready_cli-0.2.0}/README.md +5 -1
  5. {devready_cli-0.1.0 → devready_cli-0.2.0}/ROADMAP.md +7 -7
  6. {devready_cli-0.1.0 → devready_cli-0.2.0}/action.yml +5 -0
  7. {devready_cli-0.1.0 → devready_cli-0.2.0}/pyproject.toml +1 -1
  8. devready_cli-0.2.0/src/devready/__init__.py +1 -0
  9. devready_cli-0.2.0/src/devready/baseline.py +43 -0
  10. devready_cli-0.2.0/src/devready/checks/config.py +102 -0
  11. {devready_cli-0.1.0 → devready_cli-0.2.0}/src/devready/cli.py +25 -6
  12. {devready_cli-0.1.0 → devready_cli-0.2.0}/src/devready/explain.py +4 -0
  13. {devready_cli-0.1.0 → devready_cli-0.2.0}/src/devready/runner.py +12 -1
  14. devready_cli-0.2.0/tests/unit/test_baseline.py +49 -0
  15. devready_cli-0.2.0/tests/unit/test_config.py +67 -0
  16. devready_cli-0.1.0/src/devready/__init__.py +0 -1
  17. {devready_cli-0.1.0 → devready_cli-0.2.0}/.claude-plugin/marketplace.json +0 -0
  18. {devready_cli-0.1.0 → devready_cli-0.2.0}/.gitattributes +0 -0
  19. {devready_cli-0.1.0 → devready_cli-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  20. {devready_cli-0.1.0 → devready_cli-0.2.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  21. {devready_cli-0.1.0 → devready_cli-0.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  22. {devready_cli-0.1.0 → devready_cli-0.2.0}/.github/workflows/ci.yml +0 -0
  23. {devready_cli-0.1.0 → devready_cli-0.2.0}/.github/workflows/publish.yml +0 -0
  24. {devready_cli-0.1.0 → devready_cli-0.2.0}/.gitignore +0 -0
  25. {devready_cli-0.1.0 → devready_cli-0.2.0}/.mcp.json +0 -0
  26. {devready_cli-0.1.0 → devready_cli-0.2.0}/AGENTS.md +0 -0
  27. {devready_cli-0.1.0 → devready_cli-0.2.0}/CODE_OF_CONDUCT.md +0 -0
  28. {devready_cli-0.1.0 → devready_cli-0.2.0}/CONTRIBUTING.md +0 -0
  29. {devready_cli-0.1.0 → devready_cli-0.2.0}/LICENSE +0 -0
  30. {devready_cli-0.1.0 → devready_cli-0.2.0}/SECURITY.md +0 -0
  31. {devready_cli-0.1.0 → devready_cli-0.2.0}/commands/doctor.md +0 -0
  32. {devready_cli-0.1.0 → devready_cli-0.2.0}/docs/agents.md +0 -0
  33. {devready_cli-0.1.0 → devready_cli-0.2.0}/docs/research.md +0 -0
  34. {devready_cli-0.1.0 → devready_cli-0.2.0}/docs/schema.json +0 -0
  35. {devready_cli-0.1.0 → devready_cli-0.2.0}/docs/technical-plan.md +0 -0
  36. {devready_cli-0.1.0 → devready_cli-0.2.0}/skills/devready/SKILL.md +0 -0
  37. {devready_cli-0.1.0 → devready_cli-0.2.0}/src/devready/__main__.py +0 -0
  38. {devready_cli-0.1.0 → devready_cli-0.2.0}/src/devready/checks/__init__.py +0 -0
  39. {devready_cli-0.1.0 → devready_cli-0.2.0}/src/devready/checks/extras.py +0 -0
  40. {devready_cli-0.1.0 → devready_cli-0.2.0}/src/devready/checks/health.py +0 -0
  41. {devready_cli-0.1.0 → devready_cli-0.2.0}/src/devready/checks/project.py +0 -0
  42. {devready_cli-0.1.0 → devready_cli-0.2.0}/src/devready/checks/python_check.py +0 -0
  43. {devready_cli-0.1.0 → devready_cli-0.2.0}/src/devready/checks/runtimes.py +0 -0
  44. {devready_cli-0.1.0 → devready_cli-0.2.0}/src/devready/checks/tools.py +0 -0
  45. {devready_cli-0.1.0 → devready_cli-0.2.0}/src/devready/formats.py +0 -0
  46. {devready_cli-0.1.0 → devready_cli-0.2.0}/src/devready/mcp_server.py +0 -0
  47. {devready_cli-0.1.0 → devready_cli-0.2.0}/src/devready/models.py +0 -0
  48. {devready_cli-0.1.0 → devready_cli-0.2.0}/src/devready/versions.py +0 -0
  49. {devready_cli-0.1.0 → devready_cli-0.2.0}/tests/unit/test_cli.py +0 -0
  50. {devready_cli-0.1.0 → devready_cli-0.2.0}/tests/unit/test_extras.py +0 -0
  51. {devready_cli-0.1.0 → devready_cli-0.2.0}/tests/unit/test_formats.py +0 -0
  52. {devready_cli-0.1.0 → devready_cli-0.2.0}/tests/unit/test_health.py +0 -0
  53. {devready_cli-0.1.0 → devready_cli-0.2.0}/tests/unit/test_mcp.py +0 -0
  54. {devready_cli-0.1.0 → devready_cli-0.2.0}/tests/unit/test_runtimes.py +0 -0
  55. {devready_cli-0.1.0 → devready_cli-0.2.0}/tests/unit/test_schema.py +0 -0
  56. {devready_cli-0.1.0 → devready_cli-0.2.0}/tests/unit/test_versions_project.py +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devready",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "homepage": "https://devready-ten.vercel.app/",
5
5
  "description": "Read-only environment preflight: compares a project's declared Node/Python versions with what is installed.",
6
6
  "author": { "name": "DevReady contributors" },
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-10-16
4
+ - `check --baseline FILE` (and `baseline:` Action input): show only results that changed since a saved JSON report.
5
+ - Optional `.devready.json` (`skip`, `require`, `env`) and `devready init` (prints a starter).
6
+
3
7
  ## 0.1.0 - 2026-10-16
4
8
  - GitHub Action (`action.yml`) wrapping `devready check`.
5
9
  - `--format junit|sarif`, `--only`/`--skip` filters, and `devready fix --plan` (suggestions only).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: devready-cli
3
- Version: 0.1.0
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
@@ -114,6 +114,9 @@ devready explain docker.daemon # what a check means and how to fix it
114
114
  devready check --format junit # or sarif, for CI
115
115
  devready check --only project --skip project.ports # filter by id or prefix
116
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
117
120
  devready mcp # run as an MCP server over stdio
118
121
  devready --version
119
122
  ```
@@ -202,7 +205,8 @@ See [SECURITY.md](SECURITY.md) to report a vulnerability.
202
205
 
203
206
  - [x] Docker CLI + daemon reachability, `.tool-versions`, `.env` key-name diff, disk space, git identity, `devready explain`
204
207
  - [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))
208
+ - [x] `.devready.json` config, `--baseline` snapshots
209
+ - [ ] Platform-specific checks (see [ROADMAP.md](ROADMAP.md))
206
210
  - [ ] Standalone binaries
207
211
 
208
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.
@@ -90,6 +90,9 @@ devready explain docker.daemon # what a check means and how to fix it
90
90
  devready check --format junit # or sarif, for CI
91
91
  devready check --only project --skip project.ports # filter by id or prefix
92
92
  devready fix --plan # suggested steps only; never runs them
93
+ devready check --format json > baseline.json # save a snapshot (commit it for a team baseline)
94
+ devready check --baseline baseline.json # show only what changed; fail only on new/worse failures
95
+ devready init > .devready.json # optional config: skip ids, require node/python, required env names
93
96
  devready mcp # run as an MCP server over stdio
94
97
  devready --version
95
98
  ```
@@ -178,7 +181,8 @@ See [SECURITY.md](SECURITY.md) to report a vulnerability.
178
181
 
179
182
  - [x] Docker CLI + daemon reachability, `.tool-versions`, `.env` key-name diff, disk space, git identity, `devready explain`
180
183
  - [x] Port-in-use checks, more ecosystems (Go, Rust, Java, Ruby, .NET, pnpm/yarn/bun), JUnit/SARIF output, `fix --plan`, GitHub Action
181
- - [ ] Config file, shared baseline (see [ROADMAP.md](ROADMAP.md))
184
+ - [x] `.devready.json` config, `--baseline` snapshots
185
+ - [ ] Platform-specific checks (see [ROADMAP.md](ROADMAP.md))
182
186
  - [ ] Standalone binaries
183
187
 
184
188
  Have a setup problem DevReady should catch? [Open an issue](https://github.com/Evalogical-Inc/devReady/issues/new/choose). Early feedback shapes the roadmap.
@@ -2,12 +2,12 @@
2
2
 
3
3
  Principles (unchanged): read-only, local-first, zero runtime dependencies, Python >= 3.10, never print env values or raw command output.
4
4
 
5
- ## Where we are (0.1.0)
5
+ ## Where we are (0.2.0)
6
6
 
7
7
  - Tools: Git, Node, npm, Python. Docker, Go, Ruby, Java, Rust, .NET, pnpm, Yarn, Bun only when the project declares them (details in the sections below).
8
8
  - Pins compared: `.nvmrc`, `.node-version`, `.python-version`, `package.json` engines.node, `pyproject.toml` requires-python, `.tool-versions` (node/python only).
9
9
  - Also: `.env.example` vs `.env` key names, git identity presence, free disk space.
10
- - Interfaces: `check` (text/JSON, exit 1 on `fail`), `explain`, `mcp`, Claude Code plugin, `AGENTS.md`.
10
+ - Interfaces: `check` (text/JSON/JUnit/SARIF, `--only`/`--skip`/`--baseline`, exit 1 on `fail`), `fix --plan`, `init`, `explain`, `mcp`, GitHub Action, Claude Code plugin, `AGENTS.md`.
11
11
  - CI and PyPI publish workflows exist.
12
12
 
13
13
  ## Decisions (from the docs review)
@@ -41,14 +41,14 @@ Principles (unchanged): read-only, local-first, zero runtime dependencies, Pytho
41
41
  - [x] `devready fix --plan`: prints each `fail`/`warn` with its suggestion. Never runs anything. (Suggestions are prose today, not exact commands.)
42
42
  - [x] `--format sarif` and `junit` (SARIF has no file locations yet, so GitHub code scanning may not show alerts).
43
43
  - [x] `--only` / `--skip` filters by check id or dotted prefix (repeatable).
44
- - [ ] Optional `.devready.toml` to declare requirements and silence checks (needs a 3.10-safe parser, or limit to JSON).
45
- - [ ] `devready init` generates that file from what it detects.
44
+ - [x] Optional `.devready.json` (JSON, so no parser dependency): `skip` ids, `require` node/python, required `env` names.
45
+ - [x] `devready init` prints a starter file (does not write it; DevReady stays read-only).
46
46
 
47
- ## 0.4 - Team and CI (in progress)
47
+ ## 0.4 - Team and CI
48
48
 
49
49
  - [x] GitHub Action (`action.yml`, composite). Untested on a real runner; needs a release with the new flags.
50
- - [ ] Shared baseline file (committed) to compare against.
51
- - [ ] `--watch`/diff mode: show only what changed since the last run.
50
+ - [x] `--baseline FILE`: compare with a saved `--format json` report (commit it for a team baseline). Shows new/changed results; exit 1 only for a new or worse `fail`. Results that disappeared are not reported.
51
+ - [x] Diff mode = `--baseline` against your own saved snapshot. DevReady never writes the snapshot (read-only), and there is no `--watch` loop.
52
52
 
53
53
  ## 0.5 - Platform specifics
54
54
 
@@ -16,6 +16,9 @@ inputs:
16
16
  skip:
17
17
  description: Space-separated check ids or prefixes to skip.
18
18
  default: ""
19
+ baseline:
20
+ description: Path to a committed `--format json` report; only changes are shown and only a new or worse failure fails the job.
21
+ default: ""
19
22
  version:
20
23
  description: devready-cli version to install (empty = latest).
21
24
  default: ""
@@ -36,10 +39,12 @@ runs:
36
39
  DR_ONLY: ${{ inputs.only }}
37
40
  DR_SKIP: ${{ inputs.skip }}
38
41
  DR_VERSION: ${{ inputs.version }}
42
+ DR_BASELINE: ${{ inputs.baseline }}
39
43
  run: |
40
44
  set -eu
41
45
  python -m pip install --quiet "devready-cli${DR_VERSION:+==$DR_VERSION}"
42
46
  args=(check --project "$DR_PROJECT" --format "$DR_FORMAT")
43
47
  for id in $DR_ONLY; do args+=(--only "$id"); done
44
48
  for id in $DR_SKIP; do args+=(--skip "$id"); done
49
+ if [ -n "$DR_BASELINE" ]; then args+=(--baseline "$DR_BASELINE"); fi
45
50
  python -m devready "${args[@]}"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "devready-cli"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "A local-first, read-only CLI that checks a project's environment requirements against what is installed."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -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)
@@ -6,9 +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
9
10
  from devready.formats import fix_plan, junit, sarif
10
11
  from devready.models import Result, Status
11
- from devready.runner import report, run_checks
12
+ from devready.runner import matches, report, run_checks
12
13
 
13
14
  EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_INTERNAL = 0, 1, 2, 3
14
15
 
@@ -20,12 +21,8 @@ def _text(results: list[Result]) -> str:
20
21
  return "\n".join(lines)
21
22
 
22
23
 
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
24
  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)
25
+ return (not only or matches(check_id, only)) and not matches(check_id, skip)
29
26
 
30
27
 
31
28
  def main(argv: list[str] | None = None) -> int:
@@ -34,10 +31,14 @@ def main(argv: list[str] | None = None) -> int:
34
31
  sub = parser.add_subparsers(dest="command", required=True)
35
32
  check = sub.add_parser("check", help="run read-only environment checks")
36
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
37
  fix = sub.add_parser("fix", help="print suggested fixes (never runs them)")
38
38
  fix.add_argument("--plan", action="store_true", help="required: list steps without running")
39
39
  explain = sub.add_parser("explain", help="explain what a check id means")
40
40
  explain.add_argument("check_id")
41
+ sub.add_parser("init", help="print a starter .devready.json (does not write it)")
41
42
  sub.add_parser("mcp", help="run as an MCP server over stdio")
42
43
  for p in (check, fix):
43
44
  p.add_argument("--project", default=".", help="directory whose version pins to compare")
@@ -54,6 +55,12 @@ def main(argv: list[str] | None = None) -> int:
54
55
 
55
56
  return serve()
56
57
 
58
+ if args.command == "init":
59
+ from devready.checks.config import TEMPLATE
60
+
61
+ print(TEMPLATE)
62
+ return EXIT_OK
63
+
57
64
  if args.command == "explain":
58
65
  from devready.explain import EXPLANATIONS
59
66
 
@@ -86,12 +93,24 @@ def main(argv: list[str] | None = None) -> int:
86
93
  if args.command == "fix":
87
94
  print(fix_plan(results))
88
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)
89
106
  if args.format == "json":
90
107
  print(json.dumps(report(results), indent=2))
91
108
  elif args.format in ("junit", "sarif"):
92
109
  print(junit(results) if args.format == "junit" else sarif(results))
93
110
  else:
94
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
95
114
  return EXIT_FAILED if any(r.status is Status.FAIL for r in results) else EXIT_OK
96
115
 
97
116
 
@@ -43,4 +43,8 @@ EXPLANATIONS = {
43
43
  "project.env.ignored": "Warns when .env exists but git would commit it (not ignored, or already tracked). Values are never shown.",
44
44
  "project.node.modules": "Checked when package.json lists dependencies: node_modules must exist and not be older than a lockfile.",
45
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.",
46
50
  }
@@ -3,6 +3,7 @@ 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
7
8
  from devready.checks.health import check_health
8
9
  from devready.checks.project import check_project
@@ -12,7 +13,12 @@ from devready.checks.tools import TOOLS, check_tool
12
13
  from devready.models import Result
13
14
 
14
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
+
15
20
  def run_checks(root: Path) -> list[Result]:
21
+ cfg, cfg_error = load_config(root)
16
22
  results = [check_python()]
17
23
  versions = {}
18
24
  for tool in TOOLS:
@@ -21,7 +27,12 @@ def run_checks(root: Path) -> list[Result]:
21
27
  results += check_git_identity()
22
28
  results += check_project(root, versions["node"], platform.python_version())
23
29
  results += check_runtimes(root) + check_health(root)
24
- return results + check_docker(root) + check_disk(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)]
25
36
 
26
37
 
27
38
  def report(results: list[Result]) -> dict:
@@ -0,0 +1,49 @@
1
+ import json
2
+
3
+ from devready.baseline import diff, load_baseline
4
+ from devready.cli import main
5
+ from devready.models import Result, Status
6
+ from devready.runner import report
7
+
8
+
9
+ def res(cid, status):
10
+ return Result(cid, cid, status, "s")
11
+
12
+
13
+ def save(path, results):
14
+ path.write_text(json.dumps(report(results)))
15
+ return path
16
+
17
+
18
+ def test_load_rejects_bad_files(tmp_path):
19
+ assert load_baseline(tmp_path / "missing.json") is None
20
+ for bad in ("{nope", "[]", '{"schema_version": 2, "results": []}', '{"schema_version": 1}'):
21
+ (tmp_path / "b.json").write_text(bad)
22
+ assert load_baseline(tmp_path / "b.json") is None
23
+ (tmp_path / "b.json").write_text(
24
+ json.dumps({"schema_version": 1, "results": [{"check_id": "Bad Id", "status": "pass"}]})
25
+ )
26
+ assert load_baseline(tmp_path / "b.json") == {} # invalid ids are dropped, not echoed
27
+
28
+
29
+ def test_diff_changed_new_and_worse():
30
+ base = {"a.x": Status.PASS, "b.x": Status.FAIL, "c.x": Status.WARN}
31
+ now = [
32
+ res("a.x", Status.PASS), # unchanged
33
+ res("b.x", Status.FAIL), # unchanged failure: not a regression
34
+ res("c.x", Status.FAIL), # worse
35
+ res("d.x", Status.WARN), # new, not a fail
36
+ ]
37
+ changed, worse = diff(now, base)
38
+ assert [r.check_id for r in changed] == ["c.x", "d.x"] and worse
39
+ assert diff(now[:2], base) == ([], False)
40
+ assert diff([res("e.x", Status.FAIL)], base)[1] # new FAIL counts
41
+
42
+
43
+ def test_cli_baseline_exit_codes(tmp_path, capsys):
44
+ base = save(tmp_path / "base.json", [res("python.version", Status.PASS)])
45
+ assert main(["check", "--only", "python", "--baseline", str(base), "--format", "json"]) == 0
46
+ assert json.loads(capsys.readouterr().out)["results"] == [] # nothing changed
47
+ broken = save(tmp_path / "old.json", [res("python.version", Status.FAIL)])
48
+ assert main(["check", "--only", "python", "--baseline", str(broken)]) == 0
49
+ assert main(["check", "--baseline", str(tmp_path / "missing.json")]) == 2
@@ -0,0 +1,67 @@
1
+ import json
2
+
3
+ from devready.checks.config import check_config, load_config
4
+ from devready.cli import main
5
+ from devready.explain import EXPLANATIONS
6
+ from devready.models import Status
7
+ from devready.runner import run_checks
8
+
9
+
10
+ def write(tmp_path, cfg):
11
+ (tmp_path / ".devready.json").write_text(cfg if isinstance(cfg, str) else json.dumps(cfg))
12
+
13
+
14
+ def test_absent_and_valid(tmp_path):
15
+ assert load_config(tmp_path) == ({}, None)
16
+ write(tmp_path, {"skip": ["project.ports"], "require": {"node": ">=20"}, "env": ["API_KEY"]})
17
+ cfg, err = load_config(tmp_path)
18
+ assert err is None and cfg["skip"] == ["project.ports"]
19
+
20
+
21
+ def test_invalid_is_one_error_and_never_echoed(tmp_path):
22
+ for bad in (
23
+ "{nope",
24
+ "[]",
25
+ {"unknown": 1},
26
+ {"skip": "project"},
27
+ {"skip": ["Bad Id; rm -rf"]},
28
+ {"require": {"ruby": ">=3"}},
29
+ {"require": {"node": "PWNED-MARKER; curl x"}},
30
+ {"env": ["bad name"]},
31
+ ):
32
+ write(tmp_path, bad)
33
+ cfg, err = load_config(tmp_path)
34
+ assert cfg is None and err.status is Status.ERROR
35
+ assert "PWNED" not in str(err) and "rm -rf" not in str(err)
36
+
37
+
38
+ def test_require_and_env(tmp_path):
39
+ cfg = {"require": {"node": ">=20", "python": ">=3.99"}, "env": ["A_SET", "B_MISSING"]}
40
+ r = {x.check_id: x for x in check_config(cfg, "18.0.0", "3.12.0", {"A_SET": "hunter2"})}
41
+ assert r["project.node.config"].status is Status.FAIL
42
+ assert r["project.python.config"].status is Status.FAIL
43
+ assert r["project.env.required"].status is Status.FAIL
44
+ assert "B_MISSING" in r["project.env.required"].summary
45
+ assert "hunter2" not in str(r)
46
+ ok = check_config({"env": ["A"]}, None, "3.12.0", {"A": "x"})
47
+ assert ok[0].status is Status.PASS
48
+
49
+
50
+ def test_run_checks_applies_skip_and_requirements(tmp_path):
51
+ write(tmp_path, {"skip": ["git", "disk"], "require": {"python": ">=99"}})
52
+ ids = {r.check_id: r for r in run_checks(tmp_path)}
53
+ assert "git.version" not in ids and "disk.space" not in ids and "git.identity" not in ids
54
+ assert ids["project.python.config"].status is Status.FAIL
55
+
56
+
57
+ def test_init_prints_valid_config_and_writes_nothing(tmp_path, capsys, monkeypatch):
58
+ monkeypatch.chdir(tmp_path)
59
+ assert main(["init"]) == 0
60
+ write(tmp_path, capsys.readouterr().out) # the test writes it, init does not
61
+ assert load_config(tmp_path)[1] is None
62
+
63
+
64
+ def test_ids_have_explanations():
65
+ for cid in ("project.config.file", "project.node.config", "project.python.config",
66
+ "project.env.required"): # fmt: skip
67
+ assert cid in EXPLANATIONS
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
File without changes
File without changes
File without changes
File without changes
File without changes