diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b66f22..0cb1282 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to Milo are documented here. +## 0.3.1 — 2026-05-23 + +### Changed + +- Bumped to `kida-templates>=0.9.0,<0.10.0`, refreshing the lockfile and release surface for Kida 0.9. +- Expanded the AGENTS.md steward network with verification-status signals, cross-cutting root concerns, known regression patterns, steward questions, and self-audit tracking. ([#steward-network](https://github.com/lbliii/milo-cli/issues/steward-network)) +- Prepared the 0.3.1 release by aligning package metadata, changelog intent, and public site release notes. + +### Fixed + +- Strengthened `scripts/check_templates.py` to enforce Kida strict end-tag and fragile same-folder import checks in Milo's terminal-aware template environment, and cleaned the bundled component imports/error template to satisfy that gate. + + ## 0.3.0 — 2026-05-03 ### Added @@ -11,7 +24,7 @@ All notable changes to Milo are documented here. - Added display-cell width template filters for terminal layouts: `cell_width`, `cell_fit`, `cell_pad`, `cell_rpad`, and `cell_truncate`. ([#terminal-cell-width](https://github.com/lbliii/milo-cli/issues/terminal-cell-width)) - Added display-cell exact topology filters for terminal templates: `rule_line`, `divider_line`, `bottom_rule`, `frame_line`, `rail_line`, `cell_fill`, `cell_meter`, `open_rule`, `open_rule_divider`, and `open_rule_end`. ([#terminal-open-rules](https://github.com/lbliii/milo-cli/issues/terminal-open-rules)) - Add silent-exception lint gate and `# silent: ` annotations to prevent unlogged exception swallowing -- Adopt Kida capabilities: `inline_components=True` and `validate_calls=True` defaults in `get_env()`; `enable_capture` opt-in kwarg on `get_env()` for static-site / capture flows; new `milo components` subcommand listing bundled and user-defined template defs (with `--json` for tooling, `--path` to scan extra dirs); `milo.live` re-exports for `LiveRenderer`, `Spinner`, `stream_to_terminal`, `terminal_env`; `kida.get_optimal_workers` now sizes the gateway, registry, and saga executor pools by workload type (IO_BOUND for I/O fan-out, RENDER for saga effects); `{% flush %}` boundaries added to `pipeline_progress` and `pipeline_detail` defs to encode streaming contract; CI gains a template compile-check via `scripts/check_templates.py`; new `examples/liverender` shows `LiveRenderer` outside the App harness; new docs page `docs/build-apps/live`. +- Adopt Kida 0.7 capabilities: `inline_components=True` and `validate_calls=True` defaults in `get_env()`; `enable_capture` opt-in kwarg on `get_env()` for static-site / capture flows; new `milo components` subcommand listing bundled and user-defined template defs (with `--json` for tooling, `--path` to scan extra dirs); `milo.live` re-exports for `LiveRenderer`, `Spinner`, `stream_to_terminal`, `terminal_env`; `kida.get_optimal_workers` now sizes the gateway, registry, and saga executor pools by workload type (IO_BOUND for I/O fan-out, RENDER for saga effects); `{% flush %}` boundaries added to `pipeline_progress` and `pipeline_detail` defs to encode streaming contract; CI gains a template compile-check via `scripts/check_templates.py`; new `examples/liverender` shows `LiveRenderer` outside the App harness; new docs page `docs/build-apps/live`. - Agent-first improvements: structured MCP validation errors with argument/constraint context, `form_schema()` introspection helper, `llms.txt` required/optional/default markers, `docs/agent-quickstart.md`, `docs/testing.md`, and `examples/greet/` test template. - Agent-native affordances: `milo new ` scaffold (app.py, tests, conftest, README), `milo verify ` six-check self-diagnosis (imports, CLI located, commands registered, schemas generate, in-process MCP list, subprocess MCP transport), `function_to_schema(..., warn_missing_docs=True)` surfacing undocumented typed params, README examples index with drift lint, and a Python 3.14+ preflight on `milo` with an actionable install hint instead of ImportError. @@ -21,7 +34,7 @@ All notable changes to Milo are documented here. - Tightened steward guidance with contract checklists, evidence-backed finding format, collateral update rules, synthesis requirements, and parity-matrix expectations for cross-surface work. ([#steward-contract-checklists](https://github.com/lbliii/milo-cli/issues/steward-contract-checklists)) - Added scoped AGENTS.md steward guidance for core Milo domains so future agent work has explicit ownership, consultation, and safety routing. - Adopt Python 3.14+ patterns: PEP 695 type aliases in middleware, match/case in form key handlers, frozen+slotted dataclasses in tests -- Bumped to `kida-templates>=0.9.0,<0.10.0`. Kida makes `strict_undefined=True` the default — milo's bundled templates already conformed, so no behaviour changes for callers using stock templates. User templates that relied on silent-undefined fallbacks now raise `UndefinedError` at render; opt back into the loose mode by passing `get_env(strict_undefined=False)`. +- Bumped to `kida-templates>=0.7.0,<0.8.0`. Kida 0.7 makes `strict_undefined=True` the default — milo's bundled templates already conformed, so no behaviour changes for callers using stock templates. User templates that relied on silent-undefined fallbacks now raise `UndefinedError` at render; opt back into the loose mode by passing `get_env(strict_undefined=False)`. ### Fixed diff --git a/Makefile b/Makefile index 5cf6a8e..8736a7f 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ PYTHON_VERSION ?= 3.14t VENV_DIR ?= .venv -.PHONY: all help setup install test test-cov lint format ty bench docs-test ci clean build gh-release changelog changelog-draft +.PHONY: all help setup install test test-cov lint format ty bench docs-test ci clean build release-status gh-release changelog changelog-draft all: help @@ -24,6 +24,7 @@ help: @echo " make docs-test - verify templates and tagged docs snippets" @echo " make clean - remove build artifacts" @echo " make build - uv build" + @echo " make release-status - verify release version/tag/changelog alignment" @echo " make changelog - compile changelog.d/ fragments into CHANGELOG.md" @echo " make changelog-draft - preview changelog without writing" @echo " make gh-release - create GitHub release → triggers PyPI publish" @@ -68,6 +69,9 @@ clean: build: uv build +release-status: + uv run python scripts/release_status.py --remote + changelog: uv run towncrier build --yes @@ -77,6 +81,7 @@ changelog-draft: # Create GitHub release from site release notes; triggers python-publish workflow → PyPI # Strips YAML frontmatter (--- ... ---) from notes before passing to gh gh-release: + uv run python scripts/release_status.py --remote --pypi @VERSION=$$(grep -m1 '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/'); \ PROJECT=$$(grep -m1 '^name = ' pyproject.toml | sed 's/name = "\(.*\)"/\1/'); \ NOTES="site/content/releases/$$VERSION.md"; \ diff --git a/changelog.d/kida-0.9.changed.md b/changelog.d/kida-0.9.changed.md deleted file mode 100644 index 1bcdda0..0000000 --- a/changelog.d/kida-0.9.changed.md +++ /dev/null @@ -1,4 +0,0 @@ - - -Bumped the runtime dependency to `kida-templates>=0.9.0,<0.10.0` and -refreshed the lockfile. diff --git a/changelog.d/release-0.3.0.changed.md b/changelog.d/release-0.3.0.changed.md deleted file mode 100644 index 38e5f10..0000000 --- a/changelog.d/release-0.3.0.changed.md +++ /dev/null @@ -1 +0,0 @@ -Prepared the 0.3.0 release by bumping package metadata, compiling prior changelog fragments, and adding public site release notes. diff --git a/changelog.d/steward-network.changed.md b/changelog.d/steward-network.changed.md deleted file mode 100644 index e8cb69c..0000000 --- a/changelog.d/steward-network.changed.md +++ /dev/null @@ -1,5 +0,0 @@ - - -Expanded the AGENTS.md steward network with verification-status signals, -cross-cutting root concerns, known regression patterns, steward -questions, and self-audit tracking. diff --git a/docs/testing.md b/docs/testing.md index e060c8b..3556fe4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -101,10 +101,19 @@ uv run milo verify examples/greet/app.py # Verify built-in templates and tagged docs snippets make docs-test +# Verify release version, tag, changelog, and release-note alignment +make release-status + # With coverage (project enforces 80% floor) make test-cov ``` +For normal `src/` changes, CI requires a new non-empty `changelog.d/*.md` +fragment. For a release-cut PR, apply the `skip-changelog` label after +fragments have been compiled into `CHANGELOG.md` and the new +`site/content/releases/.md` file. `make release-status` is the +release-side guard that catches leftover or empty fragments before publishing. + ## Free-threading (Python 3.14t) Milo runs its test suite with `PYTHON_GIL=0` on 3.14t builds so threading bugs diff --git a/pyproject.toml b/pyproject.toml index ef7a042..c8fe7d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "milo-cli" -version = "0.3.0" +version = "0.3.1" description = "Template-driven CLI applications for free-threaded Python" readme = "README.md" requires-python = ">=3.14" diff --git a/scripts/check_templates.py b/scripts/check_templates.py index 2e7c82d..678cfc2 100644 --- a/scripts/check_templates.py +++ b/scripts/check_templates.py @@ -6,6 +6,8 @@ configuration that ships at runtime. This catches unknown filters, unknown globals, arity mismatches, and syntax errors that the upstream ``kida check`` CLI misses because it only knows HTML-autoescape filters. +It also mirrors Kida's strict end-tag and fragile-path checks inside that +Milo-aware environment. Exit code 0 = clean, 1 = one or more templates failed to compile. """ @@ -14,6 +16,7 @@ import sys from pathlib import Path +from typing import Any ROOT = Path(__file__).resolve().parent.parent BUILTIN = ROOT / "src" / "milo" / "templates" @@ -26,15 +29,77 @@ def _iter_templates(root: Path) -> list[Path]: def _check_root(root: Path, label: str) -> list[str]: from kida import FileSystemLoader + from kida.analysis.fragile_paths import check_fragile_paths + from kida.lexer import Lexer + from kida.parser import Parser from milo.templates import get_env + def explicit_close_suggestion(block_type: str) -> str: + if block_type == "block": + return "{% endblock %}" + return f"{{% end{block_type} %}}" + + def check_strict_closures(path: Path, rel: str, env: Any) -> list[str]: + source = path.read_text(encoding="utf-8") + lexer_config = getattr(env, "_lexer_config", None) + if lexer_config is None: + return [ + f"[{label}] {rel}\n" + "lint/internal: kida environment no longer exposes lexer configuration; " + "strict end-tag lint could not run" + ] + lexer = Lexer(source, lexer_config) + tokens = list(lexer.tokenize()) + parser = Parser( + tokens, + name=rel, + filename=str(path), + source=source, + autoescape=env.select_autoescape(rel), + ) + parser.parse() + unified_end_closures = getattr(parser, "_unified_end_closures", None) + if unified_end_closures is None: + return [ + f"[{label}] {rel}\n" + "lint/internal: kida parser no longer exposes unified end closures; " + "strict end-tag lint could not run" + ] + errors: list[str] = [] + for lineno, _col, closing in unified_end_closures: + want = explicit_close_suggestion(closing) + errors.append( + f"[{label}] {rel}:{lineno}\n" + f"strict: unified {{% end %}} closes '{closing}' — prefer {want}" + ) + return errors + env = get_env(loader=FileSystemLoader(str(root))) errors: list[str] = [] for path in _iter_templates(root): rel = path.relative_to(root).as_posix() try: - env.get_template(rel) + tmpl = env.get_template(rel) + errors.extend(check_strict_closures(path, rel, env)) + ast = getattr(tmpl, "_optimized_ast", None) + if ast is None: + errors.append( + f"[{label}] {rel}\n" + "lint/internal: kida template no longer exposes optimized AST; " + "fragile-path lint could not run" + ) + continue + errors.extend( + ( + f"[{label}] {rel}:{issue.lineno}\n" + "lint/fragile-path: " + f'{{% {issue.statement} "{issue.target}" %}} ' + "is in the same folder as the caller — " + f'prefer "{issue.suggestion}" so folder moves stay zero-edit' + ) + for issue in check_fragile_paths(ast, rel) + ) except Exception as exc: formatter = getattr(exc, "format_compact", None) detail = formatter() if callable(formatter) else f"{type(exc).__name__}: {exc}" diff --git a/scripts/release_status.py b/scripts/release_status.py new file mode 100644 index 0000000..d09b438 --- /dev/null +++ b/scripts/release_status.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python3 +"""Report whether the worktree is ready to prepare or publish a release.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import sys +import tomllib +import urllib.error +import urllib.request +from dataclasses import asdict, dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") +CHANGELOG_VERSION_RE = re.compile(r"^##\s+(\d+\.\d+\.\d+)\b", re.MULTILINE) +INIT_VERSION_RE = re.compile(r'^__version__\s*=\s*"([^"]+)"', re.MULTILINE) + + +@dataclass(frozen=True, slots=True) +class Check: + level: str + message: str + + +@dataclass(frozen=True, slots=True) +class ReleaseFacts: + package_name: str + pyproject_version: str + module_version: str + lock_version: str + head_sha: str + local_tags: tuple[str, ...] + remote_tags: tuple[str, ...] = () + pypi_version: str = "" + changelog_versions: tuple[str, ...] = () + release_note_versions: tuple[str, ...] = () + pending_fragments: tuple[str, ...] = () + empty_fragments: tuple[str, ...] = () + changed_since_current_tag: tuple[str, ...] = () + changed_released_notes: tuple[str, ...] = () + tag_diff_warnings: tuple[str, ...] = () + remote_checked: bool = False + pypi_checked: bool = False + + +@dataclass(frozen=True, slots=True) +class ReleaseReport: + current_version: str + latest_released_version: str + suggested_next_version: str + checks: tuple[Check, ...] + + @property + def has_errors(self) -> bool: + return any(check.level == "error" for check in self.checks) + + +def _parse_version(version: str) -> tuple[int, int, int] | None: + match = VERSION_RE.fullmatch(version) + if match is None: + return None + major, minor, patch = match.groups() + return int(major), int(minor), int(patch) + + +def _next_patch(version: str) -> str: + parsed = _parse_version(version) + if parsed is None: + return version + major, minor, patch = parsed + return f"{major}.{minor}.{patch + 1}" + + +def _max_version(versions: set[str]) -> str: + valid = [(version, _parse_version(version)) for version in versions] + parsed = [(version, parts) for version, parts in valid if parts is not None] + if not parsed: + return "" + return max(parsed, key=lambda item: item[1])[0] + + +def _tag_to_version(tag: str) -> str | None: + name = tag.rsplit("/", 1)[-1].removesuffix("^{}") + if name.startswith("v"): + name = name[1:] + return name if _parse_version(name) is not None else None + + +def _released_versions(facts: ReleaseFacts) -> set[str]: + versions: set[str] = set() + for tag in (*facts.local_tags, *facts.remote_tags): + version = _tag_to_version(tag) + if version is not None: + versions.add(version) + if facts.pypi_version: + versions.add(facts.pypi_version) + return versions + + +def analyze_release_state(facts: ReleaseFacts) -> ReleaseReport: + checks: list[Check] = [] + current = facts.pyproject_version + versions = _released_versions(facts) + latest = _max_version(versions) + suggested = _next_patch(latest or current) + + def add(level: str, message: str) -> None: + checks.append(Check(level, message)) + + if facts.module_version != current: + add( + "error", + f"pyproject version {current} disagrees with src/milo/__init__.py " + f"version {facts.module_version}.", + ) + if facts.lock_version and facts.lock_version != current: + add("error", f"pyproject version {current} disagrees with uv.lock {facts.lock_version}.") + + if not facts.remote_checked: + add("warn", "Remote tags were not checked; pass --remote for release prep.") + if not facts.pypi_checked: + add("warn", "PyPI was not checked; pass --pypi before publishing.") + elif not facts.pypi_version: + add("warn", "PyPI was checked, but no latest version could be read.") + + current_released = current in versions + if current_released: + add( + "error", + f"Version {current} is already released/tagged. Bump to {suggested} before " + "editing release notes or building artifacts.", + ) + else: + current_parts = _parse_version(current) + latest_parts = _parse_version(latest) + if latest and current_parts is not None and latest_parts is not None: + if current_parts < latest_parts: + add( + "error", + f"Current version {current} is older than released version {latest}. " + f"Bump to at least {suggested}.", + ) + else: + add("ok", f"Current version {current} is newer than latest released {latest}.") + elif latest: + add("warn", f"Could not compare current version {current} to latest released {latest}.") + + if facts.changed_since_current_tag: + changed = ", ".join(facts.changed_since_current_tag) + add( + "error", + f"Release surfaces for already-tagged {current} changed after the tag: {changed}. " + f"Move those edits to {suggested}.", + ) + if facts.changed_released_notes: + changed = ", ".join(facts.changed_released_notes) + add( + "warn", + "Already-tagged release note files changed after their tags: " + f"{changed}. Verify this is an intentional historical correction.", + ) + for warning in facts.tag_diff_warnings: + add("warn", warning) + + if facts.changelog_versions: + top = facts.changelog_versions[0] + if top != current: + add("error", f"Top CHANGELOG.md section is {top}, but package version is {current}.") + else: + add("error", "CHANGELOG.md has no version section.") + + if current not in facts.release_note_versions: + add("error", f"Missing site/content/releases/{current}.md.") + + if facts.pending_fragments: + fragments = ", ".join(facts.pending_fragments) + add( + "warn", + f"Pending changelog fragments remain: {fragments}. Compile or intentionally " + "consolidate them before publishing.", + ) + if facts.empty_fragments: + fragments = ", ".join(facts.empty_fragments) + add( + "error", + f"Empty changelog fragments found: {fragments}. Remove them or add release-note " + "content before preparing a release.", + ) + + if not checks: + add("ok", f"Release surfaces look aligned for {current}.") + + return ReleaseReport( + current_version=current, + latest_released_version=latest, + suggested_next_version=suggested, + checks=tuple(checks), + ) + + +def _run_git(root: Path, args: list[str], *, timeout: int = 20) -> str: + git = shutil.which("git") + if git is None: + raise RuntimeError("git executable not found on PATH") + result = subprocess.run( + [git, *args], + cwd=root, + text=True, + capture_output=True, + timeout=timeout, + check=False, + ) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() + raise RuntimeError(f"git {' '.join(args)} failed: {detail}") + return result.stdout.strip() + + +def _read_pyproject(root: Path) -> tuple[str, str]: + data = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) + project = data["project"] + return str(project["name"]), str(project["version"]) + + +def _read_module_version(root: Path) -> str: + text = (root / "src" / "milo" / "__init__.py").read_text(encoding="utf-8") + match = INIT_VERSION_RE.search(text) + if match is None: + raise RuntimeError("Could not find __version__ in src/milo/__init__.py") + return match.group(1) + + +def _read_lock_version(root: Path, package_name: str) -> str: + lock_path = root / "uv.lock" + if not lock_path.exists(): + return "" + data = tomllib.loads(lock_path.read_text(encoding="utf-8")) + for package in data.get("package", []): + if package.get("name") == package_name: + return str(package.get("version", "")) + return "" + + +def _read_changelog_versions(root: Path) -> tuple[str, ...]: + path = root / "CHANGELOG.md" + if not path.exists(): + return () + return tuple(CHANGELOG_VERSION_RE.findall(path.read_text(encoding="utf-8"))) + + +def _read_release_note_versions(root: Path) -> tuple[str, ...]: + release_dir = root / "site" / "content" / "releases" + if not release_dir.exists(): + return () + versions: list[str] = [] + for path in sorted(release_dir.glob("*.md")): + version = path.stem + if _parse_version(version) is not None: + versions.append(version) + return tuple(versions) + + +def _read_pending_fragments(root: Path) -> tuple[str, ...]: + fragment_dir = root / "changelog.d" + if not fragment_dir.exists(): + return () + return tuple( + path.name + for path in sorted(fragment_dir.iterdir()) + if path.is_file() and path.name != ".gitkeep" + ) + + +def _read_empty_fragments(root: Path) -> tuple[str, ...]: + fragment_dir = root / "changelog.d" + if not fragment_dir.exists(): + return () + empty: list[str] = [] + for path in sorted(fragment_dir.iterdir()): + if not path.is_file() or path.name == ".gitkeep": + continue + text = path.read_text(encoding="utf-8").strip() + if not text: + empty.append(path.name) + return tuple(empty) + + +def _read_local_tags(root: Path) -> tuple[str, ...]: + output = _run_git(root, ["tag", "--list", "v*"]) + return tuple(line for line in output.splitlines() if line) + + +def _read_remote_tags(root: Path) -> tuple[str, ...]: + output = _run_git(root, ["ls-remote", "--tags", "origin", "v*"]) + tags: list[str] = [] + for line in output.splitlines(): + parts = line.split() + if len(parts) == 2: + tags.append(parts[1]) + return tuple(tags) + + +def _tag_names(tags: tuple[str, ...]) -> set[str]: + return {tag.rsplit("/", 1)[-1].removesuffix("^{}") for tag in tags} + + +def _read_changed_since_current_tag( + root: Path, + version: str, + local_tags: tuple[str, ...], + remote_tags: tuple[str, ...], +) -> tuple[tuple[str, ...], tuple[str, ...]]: + tag = f"v{version}" + local_tag_names = _tag_names(local_tags) + remote_tag_names = _tag_names(remote_tags) + if tag not in local_tag_names: + if tag in remote_tag_names: + return (), ( + f"Release tag {tag} exists on origin but is not available locally; " + "run with --fetch-tags to check release-surface drift.", + ) + return (), () + try: + output = _run_git( + root, + [ + "diff", + "--name-only", + f"{tag}..HEAD", + "--", + "CHANGELOG.md", + f"site/content/releases/{version}.md", + ], + ) + except RuntimeError as exc: + return (), (f"Could not check release surfaces against local tag {tag}: {exc}",) + return tuple(line for line in output.splitlines() if line), () + + +def _read_changed_released_notes( + root: Path, + current_version: str, + local_tags: tuple[str, ...], + remote_tags: tuple[str, ...], +) -> tuple[tuple[str, ...], tuple[str, ...]]: + changed: list[str] = [] + warnings: list[str] = [] + local_tag_names = _tag_names(local_tags) + remote_tag_names = _tag_names(remote_tags) + versions = sorted( + version + for version in {_tag_to_version(tag) for tag in (*local_tags, *remote_tags)} + if version is not None + ) + for version in versions: + if version == current_version: + continue + path = f"site/content/releases/{version}.md" + if not (root / path).exists(): + continue + tag = f"v{version}" + if tag not in local_tag_names: + if tag in remote_tag_names: + warnings.append( + f"Release tag {tag} exists on origin but is not available locally; " + f"run with --fetch-tags to check {path} for historical edits." + ) + continue + try: + output = _run_git(root, ["diff", "--name-only", f"{tag}..HEAD", "--", path]) + except RuntimeError as exc: + warnings.append(f"Could not check {path} against local tag {tag}: {exc}") + continue + changed.extend(line for line in output.splitlines() if line) + return tuple(sorted(set(changed))), tuple(warnings) + + +def _read_pypi_version(package_name: str, *, timeout: int = 10) -> str: + url = f"https://pypi.org/pypi/{package_name}/json" + try: + with urllib.request.urlopen(url, timeout=timeout) as response: # noqa: S310 + data = json.loads(response.read().decode("utf-8")) + except OSError, urllib.error.URLError, json.JSONDecodeError: + return "" + return str(data.get("info", {}).get("version", "")) + + +def collect_release_facts( + root: Path, + *, + fetch_tags: bool = False, + remote: bool = False, + pypi: bool = False, +) -> ReleaseFacts: + package_name, pyproject_version = _read_pyproject(root) + if fetch_tags: + _run_git(root, ["fetch", "--tags", "origin"], timeout=60) + local_tags = _read_local_tags(root) + remote_tags = _read_remote_tags(root) if remote else () + pypi_version = _read_pypi_version(package_name) if pypi else "" + changed_since_current_tag, current_tag_warnings = _read_changed_since_current_tag( + root, pyproject_version, local_tags, remote_tags + ) + changed_released_notes, released_note_warnings = _read_changed_released_notes( + root, pyproject_version, local_tags, remote_tags + ) + return ReleaseFacts( + package_name=package_name, + pyproject_version=pyproject_version, + module_version=_read_module_version(root), + lock_version=_read_lock_version(root, package_name), + head_sha=_run_git(root, ["rev-parse", "--short", "HEAD"]), + local_tags=local_tags, + remote_tags=remote_tags, + pypi_version=pypi_version, + changelog_versions=_read_changelog_versions(root), + release_note_versions=_read_release_note_versions(root), + pending_fragments=_read_pending_fragments(root), + empty_fragments=_read_empty_fragments(root), + changed_since_current_tag=changed_since_current_tag, + changed_released_notes=changed_released_notes, + tag_diff_warnings=(*current_tag_warnings, *released_note_warnings), + remote_checked=remote, + pypi_checked=pypi, + ) + + +def _format_report(facts: ReleaseFacts, report: ReleaseReport) -> str: + lines = [ + f"Package: {facts.package_name}", + f"Current version: {report.current_version}", + f"HEAD: {facts.head_sha}", + f"Latest released/tagged: {report.latest_released_version or 'none found'}", + f"Suggested next patch: {report.suggested_next_version}", + "", + "Checks:", + ] + for check in report.checks: + marker = {"ok": "OK", "warn": "WARN", "error": "ERROR"}.get(check.level, check.level) + lines.append(f"- {marker}: {check.message}") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=ROOT) + parser.add_argument("--fetch-tags", action="store_true", help="Run git fetch --tags first.") + parser.add_argument("--remote", action="store_true", help="Check tags on origin.") + parser.add_argument("--pypi", action="store_true", help="Check the latest PyPI version.") + parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.") + args = parser.parse_args(argv) + + try: + facts = collect_release_facts( + args.root.resolve(), + fetch_tags=args.fetch_tags, + remote=args.remote, + pypi=args.pypi, + ) + except Exception as exc: + sys.stderr.write(f"release-status: {exc}\n") + return 2 + + report = analyze_release_state(facts) + if args.json: + sys.stdout.write(json.dumps({"facts": asdict(facts), "report": asdict(report)}, indent=2)) + sys.stdout.write("\n") + else: + sys.stdout.write(_format_report(facts, report) + "\n") + return 1 if report.has_errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/site/content/releases/0.3.0.md b/site/content/releases/0.3.0.md index 77b49d5..1323647 100644 --- a/site/content/releases/0.3.0.md +++ b/site/content/releases/0.3.0.md @@ -1,6 +1,6 @@ --- title: Milo 0.3.0 -description: Agent-native scaffolding and verification, Kida templates, terminal layout filters, and contract hardening. +description: Agent-native scaffolding and verification, Kida 0.7 templates, terminal layout filters, and contract hardening. type: changelog date: 2026-05-03 draft: false @@ -16,7 +16,7 @@ Feature release focused on agent-native CLI authoring, stricter template contrac - **Agent-native authoring** — `milo new ` scaffolds a tested CLI project, and `milo verify ` runs import, schema, dispatch, in-process MCP, and subprocess MCP transport checks. - **Agent repair loops** — MCP validation errors now carry structured argument and constraint context; `llms.txt` output marks required, optional, and defaulted inputs more clearly. -- **Kida adoption** — Milo now uses `kida-templates>=0.9.0,<0.10.0`, with inline component compilation and validated template calls enabled by default. +- **Kida 0.7 adoption** — Milo now uses `kida-templates>=0.7.0,<0.8.0`, with inline component compilation and validated template calls enabled by default. - **Strict undefined templates** — Stock templates already satisfy strict rendering. User templates that relied on silent undefined fallbacks now raise `UndefinedError`; pass `get_env(strict_undefined=False)` to keep loose rendering. - **Terminal layout filters** — New display-cell filters and open-rule helpers make table, frame, divider, meter, and truncation output align by rendered cell width. - **Steward system** — Scoped `AGENTS.md` files define ownership, contract checklists, consultation triggers, and review expectations across core, docs, tests, templates, examples, site, benchmarks, and scaffold domains. diff --git a/site/content/releases/0.3.1.md b/site/content/releases/0.3.1.md new file mode 100644 index 0000000..44d4d4d --- /dev/null +++ b/site/content/releases/0.3.1.md @@ -0,0 +1,29 @@ +--- +title: Milo 0.3.1 +description: Kida 0.9 adoption, template verification hardening, and steward network updates. +type: changelog +date: 2026-05-23 +draft: false +lang: en +tags: [release, changelog] +keywords: [release, 0.3.1] +category: changelog +--- + +Patch release focused on post-0.3.0 dependency alignment, template verification, and steward guidance. + +## Highlights + +- **Kida 0.9 adoption** — Milo now depends on `kida-templates>=0.9.0,<0.10.0`. +- **Template verification** — `scripts/check_templates.py` now enforces Kida strict end tags and refactor-safe same-folder imports while still compiling through Milo's terminal-aware filters and globals. +- **Steward network** — Root and scoped `AGENTS.md` guidance now includes verification status, cross-cutting concerns, known regression patterns, steward questions, and self-audit tracking. + +## Changed + +- Refreshed the lockfile for Kida 0.9. +- Expanded steward guidance across core, docs, examples, site, benchmarks, scaffold, templates, input, and tests. + +## Fixed + +- Cleaned bundled component imports to use refactor-safe `./_defs.kida` same-folder paths. +- Removed the generic `try` close from `error.kida` so the bundled templates satisfy the stricter local template gate without relying on Kida 0.9's inconsistent `try` close suggestion. diff --git a/src/milo/__init__.py b/src/milo/__init__.py index d0b97de..966089c 100644 --- a/src/milo/__init__.py +++ b/src/milo/__init__.py @@ -161,7 +161,7 @@ def _Py_mod_gil() -> int: # noqa: N802 return 0 -__version__ = "0.3.0" +__version__ = "0.3.1" __all__ = [ "BUILTIN_ACTIONS", "CLI", diff --git a/src/milo/templates/components/command_list.kida b/src/milo/templates/components/command_list.kida index 012d532..7c3379b 100644 --- a/src/milo/templates/components/command_list.kida +++ b/src/milo/templates/components/command_list.kida @@ -5,7 +5,7 @@ title -- optional section title (default: "Commands") grouped -- optional bool, if true group by first tag (default: false) -#} -{%- from "components/_defs.kida" import section, command_row, tag_list -%} +{%- from "./_defs.kida" import section, command_row, tag_list -%} {{ section(title | default("Commands")) }} {% for cmd in commands %} diff --git a/src/milo/templates/components/help_page.kida b/src/milo/templates/components/help_page.kida index b8f69f0..88ddc25 100644 --- a/src/milo/templates/components/help_page.kida +++ b/src/milo/templates/components/help_page.kida @@ -10,7 +10,7 @@ examples -- list of example dicts: {command, description} (optional) epilog -- footer text (optional) -#} -{%- from "components/_defs.kida" import header, section, command_row, example_block, def_list -%} +{%- from "./_defs.kida" import header, section, command_row, example_block, def_list -%} {% set version ??= "" %} {% set description ??= "" %} diff --git a/src/milo/templates/error.kida b/src/milo/templates/error.kida index d2e7eb2..1e12b70 100644 --- a/src/milo/templates/error.kida +++ b/src/milo/templates/error.kida @@ -1,9 +1,10 @@ -{% try %} +{%- set error ??= "Unknown error" -%} +{%- set code ??= "" -%} +{%- set template_name ??= "" -%} +{%- set hint ??= "" -%} +{%- set docs_url ??= "" -%} {{ "error" | red | bold }}{% if code %} {{ code | yellow }}{% endif %} {{ error | dim }} {% if template_name %} {{ "template:" | dim }} {{ template_name }}{% endif %} {% if hint %} {{ "hint:" | cyan }} {{ hint }}{% endif %} {% if docs_url %} {{ "docs:" | dim }} {{ docs_url }}{% endif %} -{% fallback %} -error: {{ error }} -{% end %} diff --git a/tests/test_release_status.py b/tests/test_release_status.py new file mode 100644 index 0000000..499b937 --- /dev/null +++ b/tests/test_release_status.py @@ -0,0 +1,193 @@ +"""Tests for scripts/release_status.py.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +def _load_release_status_script(): + script = Path(__file__).parent.parent / "scripts" / "release_status.py" + spec = importlib.util.spec_from_file_location("release_status", script) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _facts(**overrides): + release_status = _load_release_status_script() + base = { + "package_name": "milo-cli", + "pyproject_version": "0.3.1", + "module_version": "0.3.1", + "lock_version": "0.3.1", + "head_sha": "abc1234", + "local_tags": ("v0.3.0",), + "remote_tags": ("refs/tags/v0.3.0",), + "pypi_version": "", + "changelog_versions": ("0.3.1", "0.3.0"), + "release_note_versions": ("0.3.1", "0.3.0"), + "pending_fragments": (), + "empty_fragments": (), + "changed_since_current_tag": (), + "changed_released_notes": (), + "tag_diff_warnings": (), + "remote_checked": True, + "pypi_checked": False, + } + base.update(overrides) + return release_status.ReleaseFacts(**base) + + +class TestReleaseStatus: + def test_allows_next_patch_after_released_tag(self): + release_status = _load_release_status_script() + + report = release_status.analyze_release_state(_facts()) + + messages = [check.message for check in report.checks] + assert report.has_errors is False + assert report.latest_released_version == "0.3.0" + assert report.suggested_next_version == "0.3.1" + assert any("Current version 0.3.1 is newer" in msg for msg in messages) + + def test_rejects_metadata_left_on_already_released_version(self): + release_status = _load_release_status_script() + + report = release_status.analyze_release_state( + _facts( + pyproject_version="0.3.0", + module_version="0.3.0", + lock_version="0.3.0", + changelog_versions=("0.3.0",), + release_note_versions=("0.3.0",), + changed_since_current_tag=("CHANGELOG.md", "site/content/releases/0.3.0.md"), + ) + ) + + messages = [check.message for check in report.checks] + assert report.has_errors is True + assert any("Version 0.3.0 is already released/tagged" in msg for msg in messages) + assert any("Release surfaces for already-tagged 0.3.0 changed" in msg for msg in messages) + assert report.suggested_next_version == "0.3.1" + + def test_rejects_version_drift_between_metadata_files(self): + release_status = _load_release_status_script() + + report = release_status.analyze_release_state(_facts(module_version="0.3.0")) + + assert report.has_errors is True + assert any("disagrees with src/milo/__init__.py" in c.message for c in report.checks) + + def test_rejects_version_already_on_pypi(self): + release_status = _load_release_status_script() + + report = release_status.analyze_release_state( + _facts( + local_tags=(), + remote_tags=(), + pypi_version="0.3.1", + pypi_checked=True, + ) + ) + + assert report.has_errors is True + assert any("Version 0.3.1 is already released/tagged" in c.message for c in report.checks) + + def test_warns_about_pending_fragments(self): + release_status = _load_release_status_script() + + report = release_status.analyze_release_state( + _facts(pending_fragments=("template-check.fixed.md",)) + ) + + assert any( + check.level == "warn" and "Pending changelog fragments" in check.message + for check in report.checks + ) + + def test_rejects_empty_changelog_fragments(self): + release_status = _load_release_status_script() + + report = release_status.analyze_release_state( + _facts( + pending_fragments=("empty.fixed.md",), + empty_fragments=("empty.fixed.md",), + ) + ) + + assert report.has_errors is True + assert any("Empty changelog fragments found" in c.message for c in report.checks) + + def test_warns_when_prior_release_notes_changed(self): + release_status = _load_release_status_script() + + report = release_status.analyze_release_state( + _facts(changed_released_notes=("site/content/releases/0.3.0.md",)) + ) + + assert any( + check.level == "warn" and "Already-tagged release note files changed" in check.message + for check in report.checks + ) + + def test_warns_when_tag_diff_cannot_be_checked(self): + release_status = _load_release_status_script() + + report = release_status.analyze_release_state( + _facts(tag_diff_warnings=("Release tag v0.3.0 exists on origin but is not local.",)) + ) + + assert any( + check.level == "warn" and "exists on origin" in check.message for check in report.checks + ) + + def test_remote_only_current_tag_reports_missing_local_diff(self, tmp_path): + release_status = _load_release_status_script() + + changed, warnings = release_status._read_changed_since_current_tag( + tmp_path, + "0.3.1", + (), + ("refs/tags/v0.3.1",), + ) + + assert changed == () + assert warnings == ( + "Release tag v0.3.1 exists on origin but is not available locally; " + "run with --fetch-tags to check release-surface drift.", + ) + + def test_remote_only_historical_tag_reports_missing_local_diff(self, tmp_path): + release_status = _load_release_status_script() + note = tmp_path / "site" / "content" / "releases" / "0.3.0.md" + note.parent.mkdir(parents=True) + note.write_text("release notes\n", encoding="utf-8") + + changed, warnings = release_status._read_changed_released_notes( + tmp_path, + "0.3.1", + (), + ("refs/tags/v0.3.0",), + ) + + assert changed == () + assert warnings == ( + "Release tag v0.3.0 exists on origin but is not available locally; " + "run with --fetch-tags to check site/content/releases/0.3.0.md " + "for historical edits.", + ) + + def test_reads_empty_fragments_from_disk(self, tmp_path): + release_status = _load_release_status_script() + fragment_dir = tmp_path / "changelog.d" + fragment_dir.mkdir() + (fragment_dir / ".gitkeep").write_text("", encoding="utf-8") + (fragment_dir / "empty.fixed.md").write_text(" \n", encoding="utf-8") + (fragment_dir / "full.fixed.md").write_text("Fixed the release guard.\n", encoding="utf-8") + + assert release_status._read_empty_fragments(tmp_path) == ("empty.fixed.md",) diff --git a/tests/test_templates.py b/tests/test_templates.py index 9f8c5cb..db5ba4d 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -2,6 +2,19 @@ from __future__ import annotations +import importlib.util +from pathlib import Path + + +def _load_check_templates_script(): + script = Path(__file__).parent.parent / "scripts" / "check_templates.py" + spec = importlib.util.spec_from_file_location("check_templates", script) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + class TestGetEnv: def test_returns_environment(self): @@ -203,3 +216,42 @@ def test_templates_loader_finds_components(self): except Exception: # If kida can't parse it, that's OK -- the point is the file is found pass + + +class TestTemplateCheckScript: + def test_milo_aware_check_accepts_terminal_filters(self, tmp_path): + """The local checker uses Milo's terminal environment, not raw kida defaults.""" + check_templates = _load_check_templates_script() + (tmp_path / "ok.kida").write_text('{{ "ok" | bold }}', encoding="utf-8") + + errors = check_templates._check_root(tmp_path, "tmp") + + assert errors == [] + + def test_milo_aware_check_flags_unified_end_tags(self, tmp_path): + check_templates = _load_check_templates_script() + (tmp_path / "bad.kida").write_text("{% if value %}ok{% end %}", encoding="utf-8") + + errors = check_templates._check_root(tmp_path, "tmp") + + assert len(errors) == 1 + assert "bad.kida:1" in errors[0] + assert "strict: unified {% end %} closes 'if'" in errors[0] + assert "prefer {% endif %}" in errors[0] + + def test_milo_aware_check_flags_fragile_same_folder_imports(self, tmp_path): + check_templates = _load_check_templates_script() + components = tmp_path / "components" + components.mkdir() + (components / "_defs.kida").write_text("{% def item() %}x{% enddef %}", encoding="utf-8") + (components / "page.kida").write_text( + '{% from "components/_defs.kida" import item %}{{ item() }}', + encoding="utf-8", + ) + + errors = check_templates._check_root(tmp_path, "tmp") + + assert len(errors) == 1 + assert "components/page.kida:1" in errors[0] + assert "lint/fragile-path" in errors[0] + assert 'prefer "./_defs.kida"' in errors[0] diff --git a/uv.lock b/uv.lock index 0f5612d..937dc02 100644 --- a/uv.lock +++ b/uv.lock @@ -294,7 +294,7 @@ wheels = [ [[package]] name = "milo-cli" -version = "0.3.0" +version = "0.3.1" source = { editable = "." } dependencies = [ { name = "kida-templates" },