maylang-cli 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.
@@ -0,0 +1,9 @@
1
+ """MayLang CLI – Explainable Change Standard toolkit.
2
+
3
+ Provides the ``may`` CLI plus helpers to create and validate
4
+ MayLang Change Packages (``*.may.md``).
5
+
6
+ The importable package is ``maylang_cli`` (underscore) to avoid
7
+ namespace conflicts with any other installed ``maylang`` package.
8
+ The PyPI distribution name remains ``maylang-cli``.
9
+ """
@@ -0,0 +1,15 @@
1
+ """Version helper – single source of truth via ``importlib.metadata``.
2
+
3
+ Usage::
4
+
5
+ from maylang_cli._version import __version__
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from importlib.metadata import PackageNotFoundError, version
11
+
12
+ try:
13
+ __version__: str = version("maylang-cli")
14
+ except PackageNotFoundError: # pragma: no cover – editable / dev installs
15
+ __version__ = "0.0.0-dev"
maylang_cli/bumper.py ADDED
@@ -0,0 +1,66 @@
1
+ """Version bump helper for ``may version --bump``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ _VERSION_RE = re.compile(r'^(version\s*=\s*")(\d+\.\d+\.\d+)(")', re.MULTILINE)
10
+
11
+
12
+ def _find_pyproject(start: Path | None = None) -> Path | None:
13
+ """Walk up from *start* (default: cwd) to find ``pyproject.toml``."""
14
+ current = (start or Path.cwd()).resolve()
15
+ for parent in [current, *current.parents]:
16
+ candidate = parent / "pyproject.toml"
17
+ if candidate.is_file():
18
+ return candidate
19
+ return None
20
+
21
+
22
+ def bump(part: str, *, pyproject_path: Path | None = None) -> int:
23
+ """Bump the version in ``pyproject.toml``.
24
+
25
+ Parameters
26
+ ----------
27
+ part : str
28
+ One of ``"patch"``, ``"minor"``, ``"major"``.
29
+ pyproject_path : Path | None
30
+ Explicit path; if *None* we search upward from cwd.
31
+
32
+ Returns
33
+ -------
34
+ int
35
+ Exit code (0 = success, 1 = error).
36
+ """
37
+ path = pyproject_path or _find_pyproject()
38
+ if path is None or not path.is_file():
39
+ print("ERROR: Could not find pyproject.toml.", file=sys.stderr)
40
+ return 1
41
+
42
+ text = path.read_text(encoding="utf-8")
43
+ match = _VERSION_RE.search(text)
44
+ if not match:
45
+ print("ERROR: No version = \"x.y.z\" found in pyproject.toml.", file=sys.stderr)
46
+ return 1
47
+
48
+ old_version = match.group(2)
49
+ parts = [int(x) for x in old_version.split(".")]
50
+
51
+ if part == "major":
52
+ parts = [parts[0] + 1, 0, 0]
53
+ elif part == "minor":
54
+ parts = [parts[0], parts[1] + 1, 0]
55
+ elif part == "patch":
56
+ parts = [parts[0], parts[1], parts[2] + 1]
57
+ else:
58
+ print(f"ERROR: Unknown bump part '{part}'. Use patch, minor, or major.", file=sys.stderr)
59
+ return 1
60
+
61
+ new_version = ".".join(str(p) for p in parts)
62
+ new_text = _VERSION_RE.sub(rf"\g<1>{new_version}\3", text, count=1)
63
+ path.write_text(new_text, encoding="utf-8")
64
+
65
+ print(f"Bumped version: {old_version} → {new_version} ({path})")
66
+ return 0
maylang_cli/checker.py ADDED
@@ -0,0 +1,213 @@
1
+ """High-level checker logic for ``may check``.
2
+
3
+ Orchestrates file discovery, git-diff integration, and per-file validation.
4
+ Provides structured, grouped error output.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import glob
10
+ import subprocess
11
+ import sys
12
+ from collections import defaultdict
13
+ from collections.abc import Sequence
14
+ from pathlib import Path
15
+
16
+ from maylang_cli.parser import ParseResult, ValidationError, parse_file
17
+
18
+ # ── Exit codes ───────────────────────────────────────────────────────────────
19
+
20
+ EXIT_OK = 0
21
+ EXIT_MISSING = 2
22
+ EXIT_INVALID = 3
23
+
24
+
25
+ # ── Git helpers ──────────────────────────────────────────────────────────────
26
+
27
+
28
+ def _git_changed_files(base: str) -> tuple[list[str] | None, str | None]:
29
+ """Return list of changed file paths relative to repo root.
30
+
31
+ Uses ``git diff --name-only <base>...HEAD``. Returns a tuple of
32
+ ``(file_list, warning_message)``. *file_list* is ``None`` when git
33
+ is not available or the command fails; *warning_message* explains why.
34
+ """
35
+ # First, try the three-dot merge-base diff (works on branches).
36
+ try:
37
+ result = subprocess.run(
38
+ ["git", "diff", "--name-only", f"{base}...HEAD"],
39
+ capture_output=True,
40
+ text=True,
41
+ check=True,
42
+ )
43
+ return (
44
+ [line.strip() for line in result.stdout.splitlines() if line.strip()],
45
+ None,
46
+ )
47
+ except FileNotFoundError:
48
+ return None, "git is not installed or not on PATH."
49
+ except subprocess.CalledProcessError as exc:
50
+ # Detached HEAD or missing ref – fall back to two-dot diff.
51
+ try:
52
+ result = subprocess.run(
53
+ ["git", "diff", "--name-only", base, "HEAD"],
54
+ capture_output=True,
55
+ text=True,
56
+ check=True,
57
+ )
58
+ return (
59
+ [line.strip() for line in result.stdout.splitlines() if line.strip()],
60
+ None,
61
+ )
62
+ except subprocess.CalledProcessError:
63
+ stderr = exc.stderr.strip() if exc.stderr else "unknown error"
64
+ return None, f"git diff failed: {stderr}"
65
+
66
+
67
+ # ── Discovery ────────────────────────────────────────────────────────────────
68
+
69
+
70
+ def discover_maylang_files(root: str = ".") -> list[str]:
71
+ """Glob for ``maylang/*.may.md`` under *root*."""
72
+ pattern = str(Path(root) / "maylang" / "*.may.md")
73
+ return sorted(glob.glob(pattern))
74
+
75
+
76
+ def _paths_match(changed_files: list[str], path_prefixes: list[str]) -> bool:
77
+ """Return True if any changed file starts with one of *path_prefixes*.
78
+
79
+ Files inside ``maylang/`` are excluded from matching.
80
+ """
81
+ for cf in changed_files:
82
+ if cf.startswith("maylang/"):
83
+ continue
84
+ for prefix in path_prefixes:
85
+ if cf.startswith(prefix):
86
+ return True
87
+ return False
88
+
89
+
90
+ # ── Pretty error output ─────────────────────────────────────────────────────
91
+
92
+
93
+ def _print_errors(all_errors: list[ValidationError]) -> None:
94
+ """Print validation errors grouped by file with structured formatting."""
95
+ grouped: dict[str, list[ValidationError]] = defaultdict(list)
96
+ for err in all_errors:
97
+ grouped[err.file].append(err)
98
+
99
+ total = len(all_errors)
100
+ file_count = len(grouped)
101
+ print(
102
+ f"\n✗ Validation failed: {total} error(s) in {file_count} file(s)\n",
103
+ file=sys.stderr,
104
+ )
105
+
106
+ for filepath, errors in grouped.items():
107
+ print(f" ── {filepath} ──", file=sys.stderr)
108
+
109
+ # Sub-group by category for readability
110
+ by_cat: dict[str, list[ValidationError]] = defaultdict(list)
111
+ for e in errors:
112
+ by_cat[e.category].append(e)
113
+
114
+ for cat, errs in by_cat.items():
115
+ label = cat.capitalize()
116
+ for e in errs:
117
+ print(f" ✗ [{label}] {e.message}", file=sys.stderr)
118
+
119
+ print(file=sys.stderr)
120
+
121
+
122
+ # ── Main check routine ──────────────────────────────────────────────────────
123
+
124
+
125
+ def run_check(
126
+ *,
127
+ require: str = "always",
128
+ base: str | None = None,
129
+ paths: Sequence[str] | None = None,
130
+ root: str = ".",
131
+ enforce_diff: bool = False,
132
+ ) -> int:
133
+ """Execute the full ``may check`` pipeline.
134
+
135
+ Parameters
136
+ ----------
137
+ require : str
138
+ ``"always"`` – at least one ``.may.md`` must exist.
139
+ ``"changed"`` – only require when relevant files changed.
140
+ base : str | None
141
+ Git ref to diff against (e.g. ``origin/main``).
142
+ paths : sequence of str | None
143
+ Path prefixes that trigger the requirement (used with
144
+ ``--require=changed``).
145
+ root : str
146
+ Working directory / repo root.
147
+ enforce_diff : bool
148
+ When *True*, require a ``diff`` fenced block in the Patch section.
149
+
150
+ Returns
151
+ -------
152
+ int
153
+ Exit code: 0 = ok, 2 = missing required, 3 = validation failure.
154
+ """
155
+ maylang_files = discover_maylang_files(root)
156
+
157
+ # ── Decide whether MayLang files are required ────────────────────────
158
+ need_maylang = False
159
+
160
+ if require == "always":
161
+ need_maylang = True
162
+ elif require == "changed":
163
+ if base is not None:
164
+ changed, warning = _git_changed_files(base)
165
+ if changed is not None:
166
+ prefixes = list(paths) if paths else []
167
+ if prefixes:
168
+ need_maylang = _paths_match(changed, prefixes)
169
+ else:
170
+ # No path filter → any non-maylang change triggers
171
+ non_ml = [f for f in changed if not f.startswith("maylang/")]
172
+ need_maylang = len(non_ml) > 0
173
+ else:
174
+ # git unavailable – warn and fall back to requiring
175
+ print(
176
+ f"WARNING: Could not detect changed files ({warning}). "
177
+ "Falling back to requiring MayLang change packages.",
178
+ file=sys.stderr,
179
+ )
180
+ need_maylang = True
181
+ else:
182
+ # No base ref – cannot determine changes; require if files exist
183
+ need_maylang = len(maylang_files) > 0
184
+
185
+ # ── Check existence ──────────────────────────────────────────────────
186
+ if need_maylang and not maylang_files:
187
+ print(
188
+ "\n✗ No MayLang change packages found in maylang/*.may.md.\n"
189
+ "\n"
190
+ " Create one with:\n"
191
+ " may new --id MC-0001 --slug my-change "
192
+ "--scope backend --risk low --owner 'your-team'\n",
193
+ file=sys.stderr,
194
+ )
195
+ return EXIT_MISSING
196
+
197
+ if not maylang_files:
198
+ # Nothing to validate and not required.
199
+ print("No MayLang files to validate – skipping.")
200
+ return EXIT_OK
201
+
202
+ # ── Validate each file ───────────────────────────────────────────────
203
+ all_errors: list[ValidationError] = []
204
+ for fpath in maylang_files:
205
+ result: ParseResult = parse_file(fpath, enforce_diff=enforce_diff)
206
+ all_errors.extend(result.errors)
207
+
208
+ if all_errors:
209
+ _print_errors(all_errors)
210
+ return EXIT_INVALID
211
+
212
+ print(f"✓ {len(maylang_files)} MayLang change package(s) validated successfully.")
213
+ return EXIT_OK
maylang_cli/cli.py ADDED
@@ -0,0 +1,169 @@
1
+ """Command-line interface for MayLang (``may`` command).
2
+
3
+ Usage examples::
4
+
5
+ may new --id MC-0001 --slug auth-sessions --scope fullstack --risk low --owner "team"
6
+ may check --require always
7
+ may check --require changed --base origin/main --paths auth/,payments/
8
+ may version --bump patch
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from maylang_cli._version import __version__
18
+ from maylang_cli.bumper import bump
19
+ from maylang_cli.checker import run_check
20
+ from maylang_cli.template import render
21
+
22
+ # ── Subcommand handlers ─────────────────────────────────────────────────────
23
+
24
+
25
+ def _handle_new(args: argparse.Namespace) -> int:
26
+ """Create a new MayLang Change Package file."""
27
+ filename = f"{args.id}-{args.slug}.may.md"
28
+ target_dir = Path("maylang")
29
+ target_dir.mkdir(exist_ok=True)
30
+ target = target_dir / filename
31
+
32
+ if target.exists():
33
+ print(f"ERROR: {target} already exists.", file=sys.stderr)
34
+ return 1
35
+
36
+ rollback = args.rollback or "revert_commit"
37
+
38
+ content = render(
39
+ id=args.id,
40
+ slug=args.slug,
41
+ scope=args.scope,
42
+ risk=args.risk,
43
+ owner=args.owner,
44
+ rollback=rollback,
45
+ )
46
+
47
+ target.write_text(content, encoding="utf-8")
48
+ print(f"Created {target}")
49
+ return 0
50
+
51
+
52
+ def _handle_check(args: argparse.Namespace) -> int:
53
+ """Validate MayLang Change Package(s)."""
54
+ paths = None
55
+ if args.paths:
56
+ paths = [p.strip() for p in args.paths.split(",") if p.strip()]
57
+
58
+ return run_check(
59
+ require=args.require,
60
+ base=args.base,
61
+ paths=paths,
62
+ enforce_diff=args.enforce_diff,
63
+ )
64
+
65
+
66
+ def _handle_version_bump(args: argparse.Namespace) -> int:
67
+ """Bump version in pyproject.toml."""
68
+ return bump(args.bump)
69
+
70
+
71
+ # ── Argument parser ─────────────────────────────────────────────────────────
72
+
73
+
74
+ def _build_parser() -> argparse.ArgumentParser:
75
+ parser = argparse.ArgumentParser(
76
+ prog="may",
77
+ description=f"MayLang – Explainable Change Standard CLI (v{__version__})",
78
+ )
79
+ parser.add_argument(
80
+ "--version",
81
+ action="version",
82
+ version=f"%(prog)s {__version__}",
83
+ )
84
+ subparsers = parser.add_subparsers(dest="command", required=True)
85
+
86
+ # ── may new ──────────────────────────────────────────────────────────
87
+ new_parser = subparsers.add_parser(
88
+ "new",
89
+ help="Create a new MayLang Change Package (.may.md)",
90
+ )
91
+ new_parser.add_argument("--id", required=True, help='Change ID, e.g. "MC-0001"')
92
+ new_parser.add_argument("--slug", required=True, help='Short slug, e.g. "auth-sessions"')
93
+ new_parser.add_argument(
94
+ "--scope",
95
+ required=True,
96
+ help='Scope of the change, e.g. "backend", "fullstack"',
97
+ )
98
+ new_parser.add_argument(
99
+ "--risk",
100
+ required=True,
101
+ choices=["low", "medium", "high", "critical"],
102
+ help="Risk level",
103
+ )
104
+ new_parser.add_argument("--owner", required=True, help="Team or person responsible")
105
+ new_parser.add_argument(
106
+ "--rollback",
107
+ default=None,
108
+ help='Rollback strategy (default: "revert_commit")',
109
+ )
110
+ new_parser.set_defaults(func=_handle_new)
111
+
112
+ # ── may check ────────────────────────────────────────────────────────
113
+ check_parser = subparsers.add_parser(
114
+ "check",
115
+ help="Validate MayLang Change Packages",
116
+ )
117
+ check_parser.add_argument(
118
+ "--require",
119
+ choices=["always", "changed"],
120
+ default="always",
121
+ help='When to require MayLang files (default: "always")',
122
+ )
123
+ check_parser.add_argument(
124
+ "--base",
125
+ default=None,
126
+ help="Git base ref for change detection, e.g. origin/main",
127
+ )
128
+ check_parser.add_argument(
129
+ "--paths",
130
+ default=None,
131
+ help="Comma-separated path prefixes that trigger the requirement",
132
+ )
133
+ check_parser.add_argument(
134
+ "--enforce-diff",
135
+ action="store_true",
136
+ default=False,
137
+ help="Require a ```diff fenced block in the Patch section",
138
+ )
139
+ check_parser.set_defaults(func=_handle_check)
140
+
141
+ # ── may version ──────────────────────────────────────────────────────
142
+ version_parser = subparsers.add_parser(
143
+ "version",
144
+ help="Manage project version",
145
+ )
146
+ version_parser.add_argument(
147
+ "--bump",
148
+ required=True,
149
+ choices=["patch", "minor", "major"],
150
+ help="Bump the version in pyproject.toml (patch, minor, or major)",
151
+ )
152
+ version_parser.set_defaults(func=_handle_version_bump)
153
+
154
+ return parser
155
+
156
+
157
+ # ── Entrypoint ───────────────────────────────────────────────────────────────
158
+
159
+
160
+ def main(argv: list[str] | None = None) -> None:
161
+ """CLI entrypoint (installed as ``may``)."""
162
+ parser = _build_parser()
163
+ args = parser.parse_args(argv)
164
+ exit_code = args.func(args)
165
+ sys.exit(exit_code)
166
+
167
+
168
+ if __name__ == "__main__":
169
+ main()
maylang_cli/parser.py ADDED
@@ -0,0 +1,239 @@
1
+ """Parse and validate MayLang Change Package (.may.md) files.
2
+
3
+ Responsibilities
4
+ ----------------
5
+ * Extract YAML frontmatter.
6
+ * Extract Markdown headings in document order.
7
+ * Validate required frontmatter keys.
8
+ * Validate required headings and their order.
9
+ * Validate that the Verification section contains at least one runnable
10
+ command line.
11
+ * Optionally enforce a ``diff`` fenced block in the Patch section.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ import yaml
21
+
22
+ # ── Constants ────────────────────────────────────────────────────────────────
23
+
24
+ REQUIRED_FRONTMATTER_KEYS = frozenset(
25
+ {"id", "type", "scope", "risk", "owner", "rollback", "ai_used"}
26
+ )
27
+
28
+ REQUIRED_HEADINGS = [
29
+ "Intent",
30
+ "Contract",
31
+ "Invariants",
32
+ "Patch",
33
+ "Verification",
34
+ "Debug Map",
35
+ ]
36
+
37
+ # Regex helpers
38
+ _FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---", re.DOTALL)
39
+ _HEADING_RE = re.compile(r"^#\s+(.+)$", re.MULTILINE)
40
+ # A "runnable command" is either a `- ` list item containing a backtick
41
+ # command, or any fenced code line. We keep it deliberately simple.
42
+ _RUNNABLE_CMD_RE = re.compile(
43
+ r"(^- `.+`$)|(^- .+$)|(^```)",
44
+ re.MULTILINE,
45
+ )
46
+ # Matches a ```diff fenced block or a "Link:" line in the Patch section.
47
+ _DIFF_BLOCK_RE = re.compile(r"(^```diff)|(^Link:)", re.MULTILINE)
48
+
49
+
50
+ # ── Data classes ─────────────────────────────────────────────────────────────
51
+
52
+
53
+ @dataclass
54
+ class ValidationError:
55
+ """A single validation failure."""
56
+
57
+ file: str
58
+ message: str
59
+ category: str = "general"
60
+
61
+ def __str__(self) -> str:
62
+ return f"{self.file}: {self.message}"
63
+
64
+
65
+ @dataclass
66
+ class ParseResult:
67
+ """Result of parsing a single .may.md file."""
68
+
69
+ path: str
70
+ frontmatter: dict = field(default_factory=dict)
71
+ headings: list[str] = field(default_factory=list)
72
+ body: str = ""
73
+ errors: list[ValidationError] = field(default_factory=list)
74
+
75
+ @property
76
+ def ok(self) -> bool:
77
+ return len(self.errors) == 0
78
+
79
+
80
+ # ── Parsing helpers ──────────────────────────────────────────────────────────
81
+
82
+
83
+ def _extract_frontmatter(text: str, path: str) -> tuple[dict | None, list[ValidationError]]:
84
+ """Return parsed YAML frontmatter dict and any errors."""
85
+ errors: list[ValidationError] = []
86
+ match = _FRONTMATTER_RE.search(text)
87
+ if not match:
88
+ errors.append(
89
+ ValidationError(path, "Missing YAML frontmatter (--- delimiters).", "frontmatter")
90
+ )
91
+ return None, errors
92
+ try:
93
+ data = yaml.safe_load(match.group(1))
94
+ except yaml.YAMLError as exc:
95
+ errors.append(
96
+ ValidationError(path, f"Invalid YAML in frontmatter: {exc}", "frontmatter")
97
+ )
98
+ return None, errors
99
+ if not isinstance(data, dict):
100
+ errors.append(
101
+ ValidationError(path, "Frontmatter must be a YAML mapping.", "frontmatter")
102
+ )
103
+ return None, errors
104
+ return data, errors
105
+
106
+
107
+ def _validate_frontmatter_keys(data: dict, path: str) -> list[ValidationError]:
108
+ """Ensure all required keys are present."""
109
+ missing = sorted(REQUIRED_FRONTMATTER_KEYS - set(data.keys()))
110
+ if missing:
111
+ return [
112
+ ValidationError(
113
+ path,
114
+ f"Missing required frontmatter key: {key}",
115
+ "frontmatter",
116
+ )
117
+ for key in missing
118
+ ]
119
+ return []
120
+
121
+
122
+ def _extract_headings(text: str) -> list[str]:
123
+ """Return top-level (``# …``) headings in document order."""
124
+ return _HEADING_RE.findall(text)
125
+
126
+
127
+ def _validate_headings(headings: list[str], path: str) -> list[ValidationError]:
128
+ """Validate required headings exist and appear in the correct order."""
129
+ errors: list[ValidationError] = []
130
+ remaining = list(REQUIRED_HEADINGS)
131
+ for heading in headings:
132
+ if remaining and heading.strip() == remaining[0]:
133
+ remaining.pop(0)
134
+ if remaining:
135
+ for h in remaining:
136
+ errors.append(
137
+ ValidationError(
138
+ path,
139
+ f"Missing or out-of-order heading: '# {h}'",
140
+ "heading",
141
+ )
142
+ )
143
+ return errors
144
+
145
+
146
+ def _section_text(body: str, heading: str) -> str:
147
+ """Return the text between ``# <heading>`` and the next ``# `` heading."""
148
+ pattern = re.compile(
149
+ rf"^#\s+{re.escape(heading)}\s*\n(.*?)(?=^#\s+|\Z)",
150
+ re.DOTALL | re.MULTILINE,
151
+ )
152
+ match = pattern.search(body)
153
+ return match.group(1) if match else ""
154
+
155
+
156
+ def _validate_verification(body: str, path: str) -> list[ValidationError]:
157
+ """Ensure the Verification section has at least one runnable command."""
158
+ section = _section_text(body, "Verification")
159
+ if not section.strip():
160
+ return [ValidationError(path, "Verification section is empty.", "verification")]
161
+ if not _RUNNABLE_CMD_RE.search(section):
162
+ return [
163
+ ValidationError(
164
+ path,
165
+ "Verification section must contain at least one runnable command "
166
+ "(a list item starting with '- ' or a fenced code block).",
167
+ "verification",
168
+ )
169
+ ]
170
+ return []
171
+
172
+
173
+ def _validate_patch_diff(body: str, path: str) -> list[ValidationError]:
174
+ """Ensure the Patch section contains a ```diff block or a Link: line."""
175
+ section = _section_text(body, "Patch")
176
+ if not section.strip():
177
+ return [ValidationError(path, "Patch section is empty.", "patch")]
178
+ if not _DIFF_BLOCK_RE.search(section):
179
+ return [
180
+ ValidationError(
181
+ path,
182
+ "Patch section must contain a ```diff fenced block or a 'Link:' reference "
183
+ "(enable with --enforce-diff).",
184
+ "patch",
185
+ )
186
+ ]
187
+ return []
188
+
189
+
190
+ # ── Public API ───────────────────────────────────────────────────────────────
191
+
192
+
193
+ def parse_file(
194
+ filepath: str | Path,
195
+ *,
196
+ enforce_diff: bool = False,
197
+ ) -> ParseResult:
198
+ """Parse and validate a single ``.may.md`` file.
199
+
200
+ Parameters
201
+ ----------
202
+ filepath : str | Path
203
+ Path to the ``.may.md`` file.
204
+ enforce_diff : bool
205
+ When *True*, require a ``diff`` fenced block in the Patch section.
206
+
207
+ Returns a ``ParseResult`` with any validation errors collected in
208
+ ``result.errors``.
209
+ """
210
+ filepath = Path(filepath)
211
+ result = ParseResult(path=str(filepath))
212
+
213
+ try:
214
+ text = filepath.read_text(encoding="utf-8")
215
+ except OSError as exc:
216
+ result.errors.append(ValidationError(str(filepath), f"Cannot read file: {exc}"))
217
+ return result
218
+
219
+ result.body = text
220
+
221
+ # 1. Frontmatter
222
+ fm, fm_errors = _extract_frontmatter(text, str(filepath))
223
+ result.errors.extend(fm_errors)
224
+ if fm is not None:
225
+ result.frontmatter = fm
226
+ result.errors.extend(_validate_frontmatter_keys(fm, str(filepath)))
227
+
228
+ # 2. Headings
229
+ result.headings = _extract_headings(text)
230
+ result.errors.extend(_validate_headings(result.headings, str(filepath)))
231
+
232
+ # 3. Verification section
233
+ result.errors.extend(_validate_verification(text, str(filepath)))
234
+
235
+ # 4. Patch diff (optional enforcement)
236
+ if enforce_diff:
237
+ result.errors.extend(_validate_patch_diff(text, str(filepath)))
238
+
239
+ return result
@@ -0,0 +1,76 @@
1
+ """Internal template for MayLang Change Package (.may.md) files.
2
+
3
+ Part of the ``maylang_cli`` package (PyPI: maylang-cli).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ TEMPLATE = """\
9
+ ---
10
+ id: "{id}"
11
+ type: change
12
+ scope: "{scope}"
13
+ risk: "{risk}"
14
+ owner: "{owner}"
15
+ rollback: "{rollback}"
16
+ ai_used: false
17
+ ---
18
+
19
+ # Intent
20
+
21
+ _Describe **why** this change exists in one or two sentences._
22
+
23
+ # Contract
24
+
25
+ _What does this change promise to the rest of the system?_
26
+
27
+ - Input: …
28
+ - Output: …
29
+ - Side-effects: …
30
+
31
+ # Invariants
32
+
33
+ _List properties that must remain true before **and** after this change._
34
+
35
+ 1. …
36
+
37
+ # Patch
38
+
39
+ ```diff
40
+ # Paste or link your diff here
41
+ ```
42
+
43
+ # Verification
44
+
45
+ _At least one runnable command that proves correctness._
46
+
47
+ - `pytest tests/`
48
+
49
+ # Debug Map
50
+
51
+ _If something breaks, where should an engineer look first?_
52
+
53
+ | Symptom | Likely cause | First file to check |
54
+ |---------|-------------|---------------------|
55
+ | … | … | … |
56
+ """
57
+
58
+
59
+ def render(
60
+ *,
61
+ id: str,
62
+ slug: str,
63
+ scope: str,
64
+ risk: str,
65
+ owner: str,
66
+ rollback: str,
67
+ ) -> str:
68
+ """Return a fully-rendered MayLang Change Package as a string."""
69
+ return TEMPLATE.format(
70
+ id=id,
71
+ slug=slug,
72
+ scope=scope,
73
+ risk=risk,
74
+ owner=owner,
75
+ rollback=rollback,
76
+ )
@@ -0,0 +1,344 @@
1
+ Metadata-Version: 2.4
2
+ Name: maylang-cli
3
+ Version: 0.1.0
4
+ Summary: MayLang – Explainable Change Standard CLI for reducing AI tech debt.
5
+ Author: MayLang Contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/mayankkatulkar/maylang-cli
8
+ Project-URL: Repository, https://github.com/mayankkatulkar/maylang-cli
9
+ Project-URL: Issues, https://github.com/mayankkatulkar/maylang-cli/issues
10
+ Keywords: maylang,change-management,explainability,ai-tech-debt,cli
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Quality Assurance
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: PyYAML>=6.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7.0; extra == "dev"
29
+ Requires-Dist: ruff>=0.4; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # MayLang CLI
33
+
34
+ **Explainable Change Standard** — a minimal, universal spec that reduces AI tech debt across ANY language or repo.
35
+
36
+ MayLang is a lightweight convention: every meaningful change ships with a **Change Package** (`.may.md` file) that documents *intent*, *contract*, *invariants*, *patch*, *verification*, and *debug map* in a single, machine-readable Markdown file with YAML frontmatter.
37
+
38
+ `maylang-cli` is the tiny Python CLI that creates and validates these packages.
39
+
40
+ > **Import note:** The Python package is `maylang_cli` (underscore) to avoid namespace conflicts. The PyPI name is `maylang-cli`. The CLI command is `may`.
41
+
42
+ [![CI](https://github.com/mayankkatulkar/maylang-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/mayankkatulkar/maylang-cli/actions)
43
+ [![PyPI](https://img.shields.io/pypi/v/maylang-cli)](https://pypi.org/project/maylang-cli/)
44
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
45
+
46
+ ---
47
+
48
+ ## What Is a MayLang Change Package?
49
+
50
+ A `.may.md` file lives at `maylang/MC-<id>-<slug>.may.md` and contains:
51
+
52
+ ```markdown
53
+ ---
54
+ id: "MC-0001"
55
+ type: change
56
+ scope: fullstack
57
+ risk: low
58
+ owner: "team-alpha"
59
+ rollback: revert_commit
60
+ ai_used: false
61
+ ---
62
+
63
+ # Intent
64
+
65
+ Add session caching to reduce auth latency by 40%.
66
+
67
+ # Contract
68
+
69
+ - Input: session token (JWT)
70
+ - Output: cached session object
71
+ - Side-effects: writes to Redis
72
+
73
+ # Invariants
74
+
75
+ 1. Tokens are never stored in plain text.
76
+ 2. Cache TTL ≤ session expiry.
77
+
78
+ # Patch
79
+
80
+ ```diff
81
+ --- a/auth/sessions.py
82
+ +++ b/auth/sessions.py
83
+ @@ -12,6 +12,9 @@
84
+ def get_session(token: str) -> Session:
85
+ - return db.query(Session).filter_by(token=token).first()
86
+ + cached = redis.get(f"sess:{token}")
87
+ + if cached:
88
+ + return Session.from_cache(cached)
89
+ + session = db.query(Session).filter_by(token=token).first()
90
+ + redis.setex(f"sess:{token}", session.ttl, session.to_cache())
91
+ + return session
92
+ ```
93
+
94
+ # Verification
95
+
96
+ - `pytest tests/test_sessions.py`
97
+ - `curl -H "Authorization: Bearer $TOKEN" localhost:8000/me`
98
+
99
+ # Debug Map
100
+
101
+ | Symptom | Likely cause | First file to check |
102
+ |---------|-------------|---------------------|
103
+ | 401 after deploy | Cache not warmed | auth/sessions.py |
104
+ | Stale session data | TTL mismatch | config/redis.yml |
105
+ ```
106
+
107
+ ### Required Frontmatter Keys
108
+
109
+ `id`, `type`, `scope`, `risk`, `owner`, `rollback`, `ai_used`
110
+
111
+ ### Required Headings (in order)
112
+
113
+ 1. `# Intent`
114
+ 2. `# Contract`
115
+ 3. `# Invariants`
116
+ 4. `# Patch`
117
+ 5. `# Verification` — must contain at least one runnable command
118
+ 6. `# Debug Map`
119
+
120
+ ---
121
+
122
+ ## Installation
123
+
124
+ ```bash
125
+ # Recommended (isolated install)
126
+ pipx install maylang-cli
127
+
128
+ # Or with pip
129
+ pip install maylang-cli
130
+ ```
131
+
132
+ For development:
133
+
134
+ ```bash
135
+ git clone https://github.com/mayankkatulkar/maylang-cli.git
136
+ cd maylang-cli
137
+ pip install -e ".[dev]"
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Usage
143
+
144
+ ### Create a New Change Package
145
+
146
+ ```bash
147
+ may new \
148
+ --id MC-0001 \
149
+ --slug auth-sessions \
150
+ --scope fullstack \
151
+ --risk low \
152
+ --owner "team-alpha" \
153
+ --rollback revert_commit
154
+ ```
155
+
156
+ This creates `maylang/MC-0001-auth-sessions.may.md` from the built-in template.
157
+
158
+ ### Validate Change Packages
159
+
160
+ ```bash
161
+ # Always require at least one .may.md
162
+ may check
163
+
164
+ # Same as above (explicit)
165
+ may check --require always
166
+
167
+ # Only require when files in specific paths changed
168
+ may check --require changed --base origin/main --paths auth/,payments/,db/migrations/
169
+ ```
170
+
171
+ #### Exit Codes
172
+
173
+ | Code | Meaning |
174
+ |------|---------|
175
+ | `0` | All checks passed |
176
+ | `2` | Missing required MayLang file |
177
+ | `3` | Validation error (bad frontmatter, wrong headings, etc.) |
178
+
179
+ #### Enforce Diff Block
180
+
181
+ By default, the Patch section is not strictly validated for a `diff` block. To require one:
182
+
183
+ ```bash
184
+ may check --enforce-diff
185
+ ```
186
+
187
+ ### Bump Version
188
+
189
+ ```bash
190
+ # Bump patch version (0.1.0 → 0.1.1)
191
+ may version --bump patch
192
+
193
+ # Bump minor version (0.1.0 → 0.2.0)
194
+ may version --bump minor
195
+
196
+ # Bump major version (0.1.0 → 1.0.0)
197
+ may version --bump major
198
+ ```
199
+
200
+ This updates the `version` field in your `pyproject.toml`.
201
+
202
+ ---
203
+
204
+ ## Project Structure
205
+
206
+ ```
207
+ maylang-cli/
208
+ ├── maylang_cli/
209
+ │ ├── __init__.py # Package marker
210
+ │ ├── _version.py # Version via importlib.metadata
211
+ │ ├── bumper.py # Version bump helper
212
+ │ ├── cli.py # Argparse CLI (entrypoint: may)
213
+ │ ├── checker.py # High-level check orchestration
214
+ │ ├── parser.py # .may.md parsing & validation
215
+ │ └── template.py # Built-in template for `may new`
216
+ ├── tests/
217
+ │ ├── test_check_required.py
218
+ │ ├── test_frontmatter.py
219
+ │ ├── test_headings_order.py
220
+ │ ├── test_verification.py
221
+ │ ├── test_enforce_diff.py
222
+ │ └── test_version_bump.py
223
+ ├── .github/workflows/ci.yml
224
+ ├── .gitignore
225
+ ├── LICENSE
226
+ ├── README.md
227
+ └── pyproject.toml
228
+ ```
229
+
230
+ ---
231
+
232
+ ## Company Adoption Guide
233
+
234
+ ### Why Adopt MayLang?
235
+
236
+ - **AI Accountability** — Every AI-assisted change has a human-readable spec.
237
+ - **Cross-team Clarity** — Backend, frontend, and infra teams share one format.
238
+ - **Audit Trail** — Change packages live in git; they're versioned and reviewable.
239
+ - **CI-enforceable** — Block PRs that lack proper documentation.
240
+
241
+ ### Step-by-Step
242
+
243
+ 1. **Install:** `pip install maylang-cli` in your CI environment.
244
+ 2. **Create a change package** for every meaningful PR:
245
+ ```bash
246
+ may new --id MC-0001 --slug add-rate-limiter --scope backend --risk medium --owner "platform-team"
247
+ ```
248
+ 3. **Fill in the template** — document intent, contract, invariants, patch, verification steps, and debug map.
249
+ 4. **Add CI enforcement** (see below).
250
+ 5. **Review `.may.md` files in PR reviews** just like code.
251
+
252
+ ### Suggested Team Conventions
253
+
254
+ | Decision | Recommendation |
255
+ |----------|---------------|
256
+ | When to require | Use `--require changed --paths` for gradual adoption |
257
+ | ID format | `MC-NNNN` (monotonically increasing) |
258
+ | Who writes it | The PR author, reviewed by the team |
259
+ | AI changes | Set `ai_used: true` in frontmatter |
260
+ | Rollback | Always specify a concrete rollback strategy |
261
+
262
+ ---
263
+
264
+ ## Company Adoption: CI Snippet
265
+
266
+ ```bash
267
+ # In your CI pipeline:
268
+ pipx install maylang-cli
269
+ may check --require changed --base origin/main --paths auth/,payments/,db/migrations/
270
+ ```
271
+
272
+ Or as a GitHub Actions step:
273
+
274
+ ```yaml
275
+ name: MayLang Check
276
+
277
+ on:
278
+ pull_request:
279
+ branches: [main]
280
+
281
+ jobs:
282
+ maylang:
283
+ runs-on: ubuntu-latest
284
+ steps:
285
+ - uses: actions/checkout@v4
286
+ with:
287
+ fetch-depth: 0
288
+
289
+ - uses: actions/setup-python@v5
290
+ with:
291
+ python-version: "3.12"
292
+
293
+ - run: pip install maylang-cli
294
+
295
+ - name: Validate MayLang Change Packages
296
+ run: may check --require changed --base origin/main --paths auth/,payments/,db/migrations/
297
+ ```
298
+
299
+ For repos that want to enforce MayLang on **every** PR:
300
+
301
+ ```yaml
302
+ - run: may check --require always
303
+ ```
304
+
305
+ To also enforce diff blocks in the Patch section:
306
+
307
+ ```yaml
308
+ - run: may check --require always --enforce-diff
309
+ ```
310
+
311
+ ---
312
+
313
+ ## Manual PyPI Release (Option A)
314
+
315
+ ```bash
316
+ # 1. Install build tools
317
+ python -m pip install -U build twine
318
+
319
+ # 2. Build wheel + sdist
320
+ python -m build
321
+
322
+ # 3. Upload to TestPyPI first
323
+ python -m twine upload --repository testpypi dist/*
324
+
325
+ # 4. Verify in a fresh venv
326
+ python -m venv /tmp/test-maylang && . /tmp/test-maylang/bin/activate
327
+ pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ maylang-cli
328
+ may --version
329
+ deactivate && rm -rf /tmp/test-maylang
330
+
331
+ # 5. Upload to production PyPI
332
+ python -m twine upload dist/*
333
+
334
+ # 6. Install from PyPI
335
+ pipx install maylang-cli
336
+ ```
337
+
338
+ See [docs/RELEASING.md](docs/RELEASING.md) for the full release checklist.
339
+
340
+ ---
341
+
342
+ ## License
343
+
344
+ [MIT](LICENSE)
@@ -0,0 +1,13 @@
1
+ maylang_cli/__init__.py,sha256=3dAgj38_TBZPezu79rqCPyJ3obbMwuGKkJ0p6PjEMjU,345
2
+ maylang_cli/_version.py,sha256=DWFENj7HATk3tfp1uh80xuNPIYRUqR2mhblARRiuQIE,393
3
+ maylang_cli/bumper.py,sha256=l9GjJ2tMOhl8H8UPv4nU6sPfI9DCs2v5fhesuHtUFh4,2030
4
+ maylang_cli/checker.py,sha256=em5kgPc6wq3mgi5Onoyj85WTLgflsWufgYAoBMBkSRE,8025
5
+ maylang_cli/cli.py,sha256=8uv_QwlbkJzgltOOY3HAeikFNk4C1bXrpQ1SUh0JjI0,5651
6
+ maylang_cli/parser.py,sha256=e3XnpPCpPfymkQaWhkLifak8hjQAdktMCDQ37yPEmRw,7783
7
+ maylang_cli/template.py,sha256=qjzj8v3ghlnpwagQHq1mcoBEP-UOchWPp7WG4QI_8YY,1317
8
+ maylang_cli-0.1.0.dist-info/licenses/LICENSE,sha256=7QBhFPRuRfeN7wcL2o1SFA_vDN3zn7znw3niZDy2L7Y,1077
9
+ maylang_cli-0.1.0.dist-info/METADATA,sha256=Ur_V1uTSaPIIdEe9gCVePar2M5PSEgN4RxdzWtI0Ioc,9015
10
+ maylang_cli-0.1.0.dist-info/WHEEL,sha256=YLJXdYXQ2FQ0Uqn2J-6iEIC-3iOey8lH3xCtvFLkd8Q,91
11
+ maylang_cli-0.1.0.dist-info/entry_points.txt,sha256=WJihHnYhmGhrOsZK39uJX5_ZQQfv5VC1UsbKZvb1HKg,45
12
+ maylang_cli-0.1.0.dist-info/top_level.txt,sha256=6ErCndgEwQbMjJyGkhw7airAFtx1ClLKb5dZramRy3g,12
13
+ maylang_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (81.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ may = maylang_cli.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MayLang Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ maylang_cli