diff --git a/.claude/skills/taos-development-skill/SKILL.md b/.claude/skills/taos-development-skill/SKILL.md index cba0f7238..9ac405b28 100644 --- a/.claude/skills/taos-development-skill/SKILL.md +++ b/.claude/skills/taos-development-skill/SKILL.md @@ -428,16 +428,28 @@ normalized form (`1.0.0bN`). Only pyproject vs uv.lock is test-gated ## Documentation gate -A gate blocks PRs that add or remove certain feature code without a matching doc update -(configured in `docs/doc-gate.toml`): - -| Change | Requires editing | -|--------|-----------------| -| Desktop app under `desktop/src/apps/` added/removed | `README.md` | -| Route module under `tinyagentos/routes/` added/removed | `docs/agent-coordination.md` | -| Installer under `tinyagentos/installers/` or `scripts/install*` added/removed | `README.md` | -| Manifest under `app-catalog/` added/removed | `README.md` | -| `tinyagentos/auth_middleware.py` (agent-token route allowlist) changed | `docs/agent-coordination.md` | +A gate blocks PRs that change certain feature code without a matching doc update +(configured in `docs/doc-gate.toml`). Rules marked **any change** also fire on a plain +modification; the rest fire only when a matching file is added or deleted. Test files +(`test_*.py`, `*.test.*`, `*.spec.*`, `__tests__/`) never trigger any rule. + +| Change | Fires on | Requires editing | +|--------|----------|-----------------| +| Desktop app under `desktop/src/apps/` | add/remove | `README.md` | +| Route module under `tinyagentos/routes/` | any change | `docs/agent-coordination.md` | +| Installer under `tinyagentos/installers/` or `scripts/install*` | any change | `README.md` | +| Manifest under `app-catalog/` | any change | `README.md` | +| `tinyagentos/auth_middleware.py` (agent-token route allowlist) | any change | `docs/agent-coordination.md` | +| Anything under `tinyagentos/` or `desktop/src/` | any change | `CHANGELOG.md` or a `changelog.d/*.md` fragment | +| Agent registry, token auth, scope-requests store, `routes/agent_*.py`, `tinyagentos/mcp/` | any change | `docs/agent-manual/*.md` or `docs/agent-coordination.md` | +| `.github/workflows/*.yml`, `pyproject.toml`, `CONTRIBUTING.md` | any change | this skill or `docs/*.md` | +| `routes/desktop.py`, `routes/desktop_control.py`, `routes/taos_agent.py` | any change | `.claude/skills/taos-agent/*.md` or `docs/agent-manual/*.md` | +| `update_runner.py`, `auto_update.py`, `restart_orchestrator.py`, `scripts/collate_changelog.py` | any change | `docs/RELEASING.md`, a runbook, or another `docs/*.md` | +| `tinyagentos/worker/` | add/remove | `tinyagentos/worker/README.md` | + +The changelog rule is the one that catches most PRs: any non-test change under +`tinyagentos/` or `desktop/src/` needs a `changelog.d/-.md` fragment (preferred +over editing `CHANGELOG.md` directly, which conflicts between PRs). `.github/workflows/doc-gate.yml` is authoritative (a local `--no-verify` does not bypass it) and also runs `scripts/check_schema_migrations.py` (the SCHEMA-before-migrations guard, see Pitfalls). @@ -446,6 +458,10 @@ If your PR trips a rule and there is genuinely nothing to document, add a traile ``` Docs-Reviewed: no user-facing change, internal refactor only ``` +The trailer passes **every** rule for that PR, so it is an escape hatch, not a shortcut: +the gate prints `doc-gate: trailer override used in by : ` in its CI +log for each commit that carries one, and that line is reviewable. A reviewer may ask for +a real doc instead. Run `scripts/install-git-hooks.sh` to enable local hooks (`.githooks/pre-commit` and `.githooks/commit-msg`) so the gate runs before you push. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fbfdd830..a7160f1f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ Versions follow semver beta: `1.0.0-beta.N`, bumped on each dev->master promotio ## [Unreleased] +### Added + +- Doc-gate now triggers on plain modifications (not only add/delete) for + behaviour-bearing trees: routes, installers, app-catalog, and auth_middleware. + A modified route file now requires `docs/agent-coordination.md` to be touched + in the same PR. + +- A new CHANGELOG rule covers every code change under `tinyagentos/` or + `desktop/src/` that is not test-only: such changes require a `CHANGELOG.md` + edit or a new `changelog.d/` fragment. + +- Agent-facing coverage is broadened: changes to the agent identity and scope + surface (`agent_scope_requests_store.py`, `agent_auth_requests.py`, and + related token-auth files) now require `docs/agent-manual/` to be touched. + +### Changed + +- The Docs-Reviewed trailer override is now logged in CI output: when a commit + carries the trailer, the gate prints the commit hash, author, and trailer + text. The escape hatch still works exactly as before. + ## [1.0.0-beta.48] - 2026-08-11 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c994ef046..248c88868 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -253,24 +253,59 @@ Applied to your own changes: ## Documentation gate -A gate blocks PRs that add or remove certain feature code without a matching doc update. It only fires on structural changes (a file added or deleted), never on a plain edit, and only for a small set of conservative rules configured in `docs/doc-gate.toml`: +A gate blocks PRs that change feature code without a matching doc update, unless +a commit in the PR carries a non-empty `Docs-Reviewed: ` trailer. The gate +is configured in `docs/doc-gate.toml`; rules are data, no code changes needed to +add new ones. + +### What triggers the gate + +The gate fires on **additions, deletions, and modifications** (plain edits count) +for the following trees: | Change | Requires editing one of | |--------|--------------------------| -| A desktop app under `desktop/src/apps/` is added or removed | `README.md` | -| A route module under `tinyagentos/routes/` is added or removed | `docs/agent-coordination.md` | -| An installer under `tinyagentos/installers/` or `scripts/install*` is added or removed | `README.md` | -| A manifest under `app-catalog/` is added or removed | `README.md` | +| A route module under `tinyagentos/routes/` is added, removed, or modified | `docs/agent-coordination.md` | +| An installer under `tinyagentos/installers/` or `scripts/install*` is added, removed, or modified | `README.md` | +| A manifest under `app-catalog/` is added, removed, or modified | `README.md` | +| `tinyagentos/auth_middleware.py` is added, removed, or modified | `docs/agent-coordination.md` | -If your PR trips a rule and there is genuinely nothing to document (or you already covered it elsewhere), add a trailer line to a commit message instead of editing a doc: +In addition, **any non-test change under `tinyagentos/` or `desktop/src/`** +(docs and assets under those trees included, not only code) requires a +changelog entry: either a line in `CHANGELOG.md` +or a new file under `changelog.d/` (preferred, avoids merge conflicts on the +shared `[Unreleased]` anchor). -``` +Agent-facing changes (agent registry, token auth, the scope-requests store, +`tinyagentos/routes/agent_*.py`, and the MCP surface) also require a doc: +either a page under `docs/agent-manual/` or `docs/agent-coordination.md` +satisfies the rule. + +### Test-file exemption + +Test files are never structural changes and do not trip the gate. This covers +Python `test_*.py` modules, co-located `__tests__/` directories, and frontend +`*.test.*` / `*.spec.*` files. + +### Docs-Reviewed trailer + +If your PR trips a rule and there is genuinely nothing to document (or you +already covered it elsewhere), add a trailer line to a commit message instead of +editing a doc: + +```text Docs-Reviewed: no user-facing change, internal refactor only ``` -The trailer must have non-empty text after the colon; a bare `Docs-Reviewed:` does not count. +The trailer must have non-empty text after the colon; a bare `Docs-Reviewed:` +does not count. When CI detects the trailer it logs which commit used it and who +authored it, then passes all rules for that PR. -Run `scripts/install-git-hooks.sh` once to enable local hooks (`.githooks/pre-commit` and `.githooks/commit-msg`) so the gate runs before you push instead of after you open the PR. Local hooks are a convenience only: `.github/workflows/doc-gate.yml` is the authoritative check and runs on every PR regardless of local setup or `--no-verify`. +Run `scripts/install-git-hooks.sh` once to enable local hooks (`.githooks/pre-commit` +and `.githooks/commit-msg`) so the gate runs before you push instead of after you +open the PR. Local hooks are a convenience only: `.github/workflows/doc-gate.yml` +is the authoritative check and runs on every PR regardless of local setup or +`--no-verify`. To add a new rule, edit `docs/doc-gate.toml` -- rules are data, no code changes needed. diff --git a/docs/doc-gate.toml b/docs/doc-gate.toml index a37324d52..960c56f3f 100644 --- a/docs/doc-gate.toml +++ b/docs/doc-gate.toml @@ -3,11 +3,17 @@ # Read by scripts/check_doc_gate.py. Rules are data: add more [[rules]] # entries here to cover new feature areas without touching the script. # -# Precision over recall: `when_changed` only triggers a rule on a structural -# change (a file added or deleted, or a plain modification when the rule sets -# `on_modify = true`), and every glob below is scoped to the specific tree it -# names. This is deliberate -- a noisy gate gets disabled, so false positives -# are worse than an occasional missed doc update. +# Two trigger modes: +# when_changed -- fires on structural add/delete by default; set +# `on_modify = true` to also count plain modifications. +# require_doc -- any changed file matching these globs satisfies the rule. +# +# Test paths (test_*.py, *.test.*, *.spec.*, __tests__/) are never structural +# and are always excluded from triggering. +# +# The Docs-Reviewed: trailer is an explicit escape hatch: when present in a +# commit message, the gate prints which commit used it and who authored it, +# then passes all rules for that PR. [gate] trailer = "Docs-Reviewed:" @@ -41,27 +47,31 @@ hint = "a desktop app was added or removed" [[rules]] name = "routes" +on_modify = true when_changed = ["tinyagentos/routes/*.py"] require_doc = ["docs/agent-coordination.md"] -hint = "an API route module was added or removed" +hint = "an API route module was added, removed, or modified" [[rules]] name = "installers" +on_modify = true when_changed = ["tinyagentos/installers/*", "scripts/install*"] require_doc = ["README.md"] -hint = "an installer was added or removed" +hint = "an installer was added, removed, or modified" [[rules]] # The repo's catalog tree is app-catalog/, not catalog/ (there is no # catalog/ directory in this repo). Scoped to the real path so the rule can # actually fire instead of being permanently dead config. name = "catalog" +on_modify = true when_changed = ["app-catalog/**"] require_doc = ["README.md"] -hint = "a catalog manifest was added or removed" +hint = "a catalog manifest was added, removed, or modified" [[rules]] name = "agent-api" +on_modify = true when_changed = ["tinyagentos/auth_middleware.py"] require_doc = ["docs/agent-coordination.md"] hint = "the agent-token route allowlist changed; update the agent-facing API surface (or add a Docs-Reviewed trailer explaining why not)" @@ -81,10 +91,15 @@ hint = "the agent-token route allowlist changed; update the agent-facing API sur # PR that merely touched the README satisfy the rule. Verified by probe: with the # doc inside the directory the gate went green on a change carrying no changelog # at all. +# +# Broadened to cover every NON-TEST change under tinyagentos/ or desktop/src/, +# not only code: a doc or asset under those trees triggers it too (the test +# suite pins tinyagentos/README.md as a trigger). Any such change requires a +# CHANGELOG.md edit or a new changelog.d/ fragment in the same PR. [[rules]] name = "user-visible-changelog" on_modify = true -when_changed = ["tinyagentos/routes/*.py", "desktop/src/apps/*/**", "desktop/src/App.tsx", "desktop/src/components/**", "desktop/src/stores/**", "tinyagentos/installers/*"] +when_changed = ["tinyagentos/**", "desktop/src/**"] require_doc = ["CHANGELOG.md", "changelog.d/*.md"] hint = "user-visible behaviour changed; add a changelog.d/-.md fragment (preferred) or a CHANGELOG.md line (or add a Docs-Reviewed trailer explaining why not)" @@ -92,7 +107,7 @@ hint = "user-visible behaviour changed; add a changelog.d/-.md fragmen [[rules]] name = "agent-manual" on_modify = true -when_changed = ["tinyagentos/agent_registry*.py", "tinyagentos/agent_token_auth.py", "tinyagentos/routes/agent_*.py", "tinyagentos/mcp/**"] +when_changed = ["tinyagentos/agent_registry*.py", "tinyagentos/agent_token_auth.py", "tinyagentos/agent_scope_requests_store.py", "tinyagentos/routes/agent_*.py", "tinyagentos/mcp/**"] require_doc = ["docs/agent-manual/*.md", "docs/agent-coordination.md"] hint = "agent-facing behaviour changed; review the agent manual or coordination doc (or add a Docs-Reviewed trailer explaining why not)" diff --git a/scripts/check_doc_gate.py b/scripts/check_doc_gate.py index 8666a61c3..b5010952e 100644 --- a/scripts/check_doc_gate.py +++ b/scripts/check_doc_gate.py @@ -315,6 +315,37 @@ def _git_commit_messages(base_ref: str) -> list[str]: return [m for m in out.split("\x00") if m.strip()] +def _git_commits_with_messages(base_ref: str) -> list[tuple[str, str, str]]: + """Return (hash, author_name, message_body) for each commit in the range.""" + # %x1e terminates each commit record and %x1f separates the three fields + # inside it. A record terminator distinct from the field separator is what + # makes this parseable: with one separator for both, the flat split cannot + # tell a new commit's hash from the previous commit's body. + out = _run_git(["log", f"{base_ref}..HEAD", "--format=%H%x1f%an%x1f%B%x1e"]) + commits: list[tuple[str, str, str]] = [] + for record in out.split("\x1e"): + if not record.strip(): + continue + fields = record.lstrip("\n").split("\x1f") + if len(fields) < 3: + continue + commit_hash, author, body = fields[0], fields[1], fields[2] + commits.append((commit_hash.strip(), author, body)) + return commits + + +def _log_trailer_usage(commits: list[tuple[str, str, str]], trailer: str) -> None: + """Print a log line for each commit that carries a non-empty trailer.""" + for commit_hash, author, message in commits: + for line in message.splitlines(): + stripped = line.strip() + if stripped.startswith(trailer) and stripped[len(trailer):].strip(): + short_hash = commit_hash[:8] + why = stripped[len(trailer):].strip() + print(f"doc-gate: trailer override used in {short_hash} by {author}: {why}") + break + + def get_trailer(config: dict) -> str: """Single source of truth for the commit-message trailer prefix, shared by the diff-gate check and the hooks (via the print-trailer command).""" @@ -365,7 +396,9 @@ def main(argv: list[str] | None = None) -> int: commit_messages: list[str] = [] else: changed = _git_changed_base(args.base) - commit_messages = _git_commit_messages(args.base) + commits_meta = _git_commits_with_messages(args.base) + commit_messages = [msg for _hash, _author, msg in commits_meta] + _log_trailer_usage(commits_meta, get_trailer(config)) failures = evaluate_rules(changed, commit_messages, config) return _report(failures) diff --git a/tests/test_doc_gate.py b/tests/test_doc_gate.py index 2e7a10ad3..d3a613c33 100644 --- a/tests/test_doc_gate.py +++ b/tests/test_doc_gate.py @@ -421,3 +421,188 @@ def test_exit_codes_are_mutually_distinguishable(self): assert dg.EXIT_CONFIG_ERROR != 1 assert dg.EXIT_CONFIG_ERROR != 2 + +ROUTES_MODIFY_CONFIG = { + "gate": {"trailer": "Docs-Reviewed:"}, + "rules": [ + { + "name": "routes", + "on_modify": True, + "when_changed": ["tinyagentos/routes/*.py"], + "require_doc": ["docs/agent-coordination.md"], + "hint": "an API route module was added, removed, or modified", + }, + ], +} + + +class TestModificationTriggersGate: + def test_modification_to_route_fails_without_doc(self): + """A plain modification to a route with no doc edit FAILS the gate.""" + changed = [("M", "tinyagentos/routes/agents.py")] + failures = dg.evaluate_rules(changed, [], ROUTES_MODIFY_CONFIG) + assert len(failures) == 1 + assert failures[0].startswith("routes -- ") + + def test_modification_to_route_passes_with_doc_edit(self): + changed = [ + ("M", "tinyagentos/routes/agents.py"), + ("M", "docs/agent-coordination.md"), + ] + assert dg.evaluate_rules(changed, [], ROUTES_MODIFY_CONFIG) == [] + + def test_modification_to_route_passes_with_trailer(self): + changed = [("M", "tinyagentos/routes/agents.py")] + messages = ["feat: modify agents route\n\nDocs-Reviewed: internal refactor\n"] + assert dg.evaluate_rules(changed, messages, ROUTES_MODIFY_CONFIG) == [] + + def test_modified_test_file_does_not_trigger(self): + """Modifying a test file is never structural, even with on_modify. + + The path must MATCH the rule's glob, otherwise the test passes for the + wrong reason -- it would pass with the exemption deleted. + """ + changed = [("M", "tinyagentos/routes/test_agents.py")] + assert dg.evaluate_rules(changed, [], ROUTES_MODIFY_CONFIG) == [] + + +BROAD_CHANGELOG_CONFIG = { + "gate": {"trailer": "Docs-Reviewed:"}, + "rules": [ + { + "name": "user-visible-changelog", + "on_modify": True, + "when_changed": ["tinyagentos/**", "desktop/src/**"], + "require_doc": ["CHANGELOG.md", "changelog.d/*.md"], + "hint": "user-visible behaviour changed", + }, + ], +} + + +class TestBroadChangelogRequired: + def test_tinyagentos_modification_requires_changelog(self): + changed = [("M", "tinyagentos/app.py")] + failures = dg.evaluate_rules(changed, [], BROAD_CHANGELOG_CONFIG) + assert len(failures) == 1 + assert failures[0].startswith("user-visible-changelog -- ") + + def test_desktop_src_modification_requires_changelog(self): + changed = [("M", "desktop/src/components/Foo.tsx")] + failures = dg.evaluate_rules(changed, [], BROAD_CHANGELOG_CONFIG) + assert len(failures) == 1 + assert failures[0].startswith("user-visible-changelog -- ") + + def test_changelog_fragment_satisfies_broad_rule(self): + changed = [ + ("M", "tinyagentos/app.py"), + ("A", "changelog.d/1234-fix.md"), + ] + assert dg.evaluate_rules(changed, [], BROAD_CHANGELOG_CONFIG) == [] + + def test_changelog_md_edit_satisfies_broad_rule(self): + changed = [ + ("M", "tinyagentos/app.py"), + ("M", "CHANGELOG.md"), + ] + assert dg.evaluate_rules(changed, [], BROAD_CHANGELOG_CONFIG) == [] + + def test_trailer_satisfies_broad_rule(self): + changed = [("M", "tinyagentos/app.py")] + messages = ["feat: update app\n\nDocs-Reviewed: no user-facing change\n"] + assert dg.evaluate_rules(changed, messages, BROAD_CHANGELOG_CONFIG) == [] + + def test_test_file_under_tinyagentos_exempt(self): + """A test file under tinyagentos/ is not a structural change. + + `tests/` is outside the rule's globs, so it cannot prove the exemption; + these paths are inside them. + """ + for path in ("tinyagentos/test_helpers.py", "desktop/src/apps/Foo/Foo.test.tsx"): + changed = [("M", path)] + assert dg.evaluate_rules(changed, [], BROAD_CHANGELOG_CONFIG) == [], path + + def test_doc_file_under_tinyagentos_still_triggers(self): + """A non-test doc file under tinyagentos/ should still require changelog.""" + changed = [("M", "tinyagentos/README.md")] + failures = dg.evaluate_rules(changed, [], BROAD_CHANGELOG_CONFIG) + assert len(failures) == 1 + + +class TestTrailerLogged: + def test_trailer_usage_is_logged(self, capsys): + commits = [ + ("abc1234567890", "John Doe", "fix: something\n\nDocs-Reviewed: internal refactor\n"), + ] + dg._log_trailer_usage(commits, "Docs-Reviewed:") + captured = capsys.readouterr() + assert "trailer override" in captured.out + assert "John Doe" in captured.out + assert "abc12345" in captured.out + + def test_no_trailer_no_log(self, capsys): + commits = [ + ("abc1234567890", "John Doe", "fix: something\n"), + ] + dg._log_trailer_usage(commits, "Docs-Reviewed:") + captured = capsys.readouterr() + assert captured.out == "" + + def test_empty_trailer_text_not_logged(self, capsys): + commits = [ + ("abc1234567890", "John Doe", "fix: something\n\nDocs-Reviewed:\n"), + ] + dg._log_trailer_usage(commits, "Docs-Reviewed:") + captured = capsys.readouterr() + assert captured.out == "" + + def test_multiple_trailer_commits_all_logged(self, capsys): + commits = [ + ("aaa1111111111", "Alice", "feat: add feature\n\nDocs-Reviewed: new feature\n"), + ("bbb2222222222", "Bob", "fix: bug\n\nDocs-Reviewed: bug fix\n"), + ] + dg._log_trailer_usage(commits, "Docs-Reviewed:") + captured = capsys.readouterr() + assert "Alice" in captured.out + assert "Bob" in captured.out + assert "aaa11111" in captured.out + assert "bbb22222" in captured.out + + + +class TestCommitsWithMessagesParsing: + """The producer half of the trailer audit. + + The tests above hand-build the tuples, so they pass whether or not + anything can actually produce them. These drive the parser with the exact + bytes `git log --format=%H%x1f%an%x1f%B%x1f` emits. + """ + + LOG_FORMAT_OUTPUT = ( + "abc1234567890\x1fJohn Doe\x1ffix: something\n\nDocs-Reviewed: internal refactor\n\x1e" + "\ndef4567890123\x1fJane Roe\x1ffeat: another thing\n\x1e" + ) + + def _parse(self, monkeypatch, out): + monkeypatch.setattr(dg, "_run_git", lambda args: out) + return dg._git_commits_with_messages("origin/dev") + + def test_parses_one_record_per_commit(self, monkeypatch): + commits = self._parse(monkeypatch, self.LOG_FORMAT_OUTPUT) + assert len(commits) == 2 + assert [c[0] for c in commits] == ["abc1234567890", "def4567890123"] + assert [c[1] for c in commits] == ["John Doe", "Jane Roe"] + assert "Docs-Reviewed: internal refactor" in commits[0][2] + assert "Docs-Reviewed" not in commits[1][2] + + def test_parsed_commits_reach_the_log(self, monkeypatch, capsys): + """End to end over the seam: real log bytes must produce a log line.""" + commits = self._parse(monkeypatch, self.LOG_FORMAT_OUTPUT) + dg._log_trailer_usage(commits, "Docs-Reviewed:") + captured = capsys.readouterr() + assert "trailer override" in captured.out + assert "John Doe" in captured.out + assert "Jane Roe" not in captured.out + + def test_empty_range_is_empty(self, monkeypatch): + assert self._parse(monkeypatch, "") == []