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.
- maylang_cli/__init__.py +9 -0
- maylang_cli/_version.py +15 -0
- maylang_cli/bumper.py +66 -0
- maylang_cli/checker.py +213 -0
- maylang_cli/cli.py +169 -0
- maylang_cli/parser.py +239 -0
- maylang_cli/template.py +76 -0
- maylang_cli-0.1.0.dist-info/METADATA +344 -0
- maylang_cli-0.1.0.dist-info/RECORD +13 -0
- maylang_cli-0.1.0.dist-info/WHEEL +5 -0
- maylang_cli-0.1.0.dist-info/entry_points.txt +2 -0
- maylang_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- maylang_cli-0.1.0.dist-info/top_level.txt +1 -0
maylang_cli/__init__.py
ADDED
|
@@ -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
|
+
"""
|
maylang_cli/_version.py
ADDED
|
@@ -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
|
maylang_cli/template.py
ADDED
|
@@ -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
|
+
[](https://github.com/mayankkatulkar/maylang-cli/actions)
|
|
43
|
+
[](https://pypi.org/project/maylang-cli/)
|
|
44
|
+
[](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,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
|