From 75c68ac8f4d3cd8dcd09735002d889b1cf1a753f Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 6 May 2026 05:58:50 +0200 Subject: [PATCH 01/18] Union per-surface excludes across overlapping surfaces in agent_discovery.py --- UNRELEASED.md | 2 ++ src/reporails_cli/core/agent_discovery.py | 16 ++++++--- tests/unit/test_scan_scope.py | 40 +++++++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index e93f97c..3fe558f 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -6,4 +6,6 @@ ### Fixed +- [core/agent_discovery]: `surfaces...exclude` patterns now apply across every surface of the agent, not just the surface they were declared on. Two surfaces of the same agent commonly share patterns (e.g. `cursor.rules` and `cursor.bugbot_rules` both glob `.cursor/rules/**/*.mdc`) — declaring an exclude on one previously left the file surfaced from the other. Discovery now collects the union of all per-surface excludes for the agent and applies it once per surface. + ### Removed diff --git a/src/reporails_cli/core/agent_discovery.py b/src/reporails_cli/core/agent_discovery.py index f632602..e83fc02 100644 --- a/src/reporails_cli/core/agent_discovery.py +++ b/src/reporails_cli/core/agent_discovery.py @@ -411,6 +411,16 @@ def discover_from_config( rule_files: list[Path] = [] config_files: list[Path] = [] + # Union of every per-surface exclude declared for this agent. Some agents + # have multiple surfaces that match the same paths (e.g. cursor.rules and + # cursor.bugbot_rules both match `.cursor/rules/**/*.mdc`). Applying each + # surface's exclude only within its own loop iteration leaves the file + # surfaced from the other surface — counter to the user's mental model + # ("I excluded draft, draft should be gone"). The union closes the gap. + agent_exclude_globs: list[str] = [] + for ft_name in file_types: + agent_exclude_globs.extend(_surface_exclude_patterns(agent_id, ft_name, project_config)) + for ft_name, spec in file_types.items(): if not isinstance(spec, dict): continue @@ -428,10 +438,8 @@ def discover_from_config( found = glob_file_type_patterns(target, patterns, properties, extra_exclude_dirs) - # Apply per-surface exclude filters - exclude_globs = _surface_exclude_patterns(agent_id, ft_name, project_config) - if exclude_globs: - found = [p for p in found if not _matches_any_glob(p, exclude_globs, target)] + if agent_exclude_globs: + found = [p for p in found if not _matches_any_glob(p, agent_exclude_globs, target)] if bucket == "instruction": instruction_files.extend(found) diff --git a/tests/unit/test_scan_scope.py b/tests/unit/test_scan_scope.py index fd24f86..778b84e 100644 --- a/tests/unit/test_scan_scope.py +++ b/tests/unit/test_scan_scope.py @@ -490,3 +490,43 @@ def test_config_local_layered_overrides_committed(self, tmp_path: Path) -> None: names = {f.as_posix() for f in files} assert (keep / "CLAUDE.md").as_posix() in names assert (drop / "CLAUDE.md").as_posix() not in names + + def test_exclude_unions_across_overlapping_surfaces(self, tmp_path: Path) -> None: + """An exclude on one surface drops matches from sibling surfaces too. + + `cursor.rules` and `cursor.bugbot_rules` both glob `.cursor/rules/**/*.mdc`. + Without unioning, `surfaces.cursor.rules.exclude: [**/draft/**]` drops the + file from `cursor.rules` but `cursor.bugbot_rules` re-surfaces it. The + agent-wide union closes the gap: any exclude declared anywhere for the + agent applies to every surface of that agent. + """ + from reporails_cli.core.agent_discovery import discover_from_config + from reporails_cli.core.config import get_project_config + + (tmp_path / ".git").mkdir() + (tmp_path / "AGENTS.md").write_text("# main") + cursor_dir = tmp_path / ".cursor" + cursor_dir.mkdir() + (cursor_dir / "config.yml").write_text("# cursor marker\n") + rules_dir = cursor_dir / "rules" + rules_dir.mkdir() + (rules_dir / "keep.mdc").write_text("---\ndescription: keep\n---\n# keep") + draft_dir = rules_dir / "draft" + draft_dir.mkdir() + (draft_dir / "draft.mdc").write_text("---\ndescription: draft\n---\n# draft") + + ails = tmp_path / ".ails" + ails.mkdir() + (ails / "config.yml").write_text( + 'schema_version: "0.1.0"\nsurfaces:\n cursor.rules:\n exclude: ["**/draft/**"]\n' + ) + + project_config = get_project_config(tmp_path) + result = discover_from_config(tmp_path, "cursor", project_config=project_config) + assert result is not None + instructions, rules, _configs = result + all_paths = {p.as_posix() for p in instructions + rules} + assert (rules_dir / "keep.mdc").as_posix() in all_paths + assert (draft_dir / "draft.mdc").as_posix() not in all_paths, ( + "exclude on cursor.rules must also drop the file from cursor.bugbot_rules" + ) From cbb7b92f4f4df86d0889b2c6549c0a4e510b8a27 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 6 May 2026 05:59:27 +0200 Subject: [PATCH 02/18] Relativize ruleset_map paths in scorecard.py surface classification --- UNRELEASED.md | 1 + src/reporails_cli/formatters/text/display.py | 2 +- .../formatters/text/scorecard.py | 16 +++++- tests/unit/test_scorecard.py | 56 +++++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_scorecard.py diff --git a/UNRELEASED.md b/UNRELEASED.md index 3fe558f..32b33de 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -7,5 +7,6 @@ ### Fixed - [core/agent_discovery]: `surfaces...exclude` patterns now apply across every surface of the agent, not just the surface they were declared on. Two surfaces of the same agent commonly share patterns (e.g. `cursor.rules` and `cursor.bugbot_rules` both glob `.cursor/rules/**/*.mdc`) — declaring an exclude on one previously left the file surfaced from the other. Discovery now collects the union of all per-surface excludes for the agent and applies it once per surface. +- [formatters/text/scorecard]: `compute_surface_scores` relativizes `ruleset_map.files[*].path` against the project root before classification. Absolute paths from the mapper were being tagged `nested` purely because their leading filesystem components inflated the `parts` count, so a project with one root-level `CLAUDE.md` was rendered as `Main (1) ... Nested (1)`. Findings (which already carry relative paths) and the mapper's file list now classify consistently. ### Removed diff --git a/src/reporails_cli/formatters/text/display.py b/src/reporails_cli/formatters/text/display.py index b8daa7c..ca04c0a 100644 --- a/src/reporails_cli/formatters/text/display.py +++ b/src/reporails_cli/formatters/text/display.py @@ -432,7 +432,7 @@ def _render_findings_and_scorecard( elapsed_ms=elapsed_ms, agent=_detect_agent_name(ruleset_map), scope=scope, - surface_health=compute_surface_scores(result, ruleset_map=ruleset_map), + surface_health=compute_surface_scores(result, ruleset_map=ruleset_map, project_root=Path.cwd()), ) diff --git a/src/reporails_cli/formatters/text/scorecard.py b/src/reporails_cli/formatters/text/scorecard.py index b398abe..1db653a 100644 --- a/src/reporails_cli/formatters/text/scorecard.py +++ b/src/reporails_cli/formatters/text/scorecard.py @@ -98,18 +98,32 @@ class SurfaceHealth: def compute_surface_scores( result: Any, ruleset_map: Any = None, + project_root: Any = None, ) -> list[SurfaceHealth]: """Compute per-surface health scores from combined result. When ruleset_map is provided, file counts come from the mapper's discovery (all scanned files), not just files with findings. + + `project_root` is used to relativize `ruleset_map.files` paths before + classification — `classify_file` distinguishes `main` (root-level) from + `nested` (subdirectory copies) by path-component count, which only works + on relative paths. `result.findings` and `result.per_file_analysis` + already carry relative paths; `ruleset_map.files` does not. """ + from pathlib import Path + + from reporails_cli.core.merger import normalize_finding_path + + root = Path(project_root) if project_root is not None else Path.cwd() + # Count files per surface from ruleset_map (authoritative file list) surface_file_counts: dict[str, int] = {} if ruleset_map is not None: try: for fr in ruleset_map.files: - tag = classify_file(fr.path).split(":")[0] + rel = normalize_finding_path(fr.path, root) + tag = classify_file(rel).split(":")[0] surface_file_counts[tag] = surface_file_counts.get(tag, 0) + 1 except (AttributeError, TypeError): pass diff --git a/tests/unit/test_scorecard.py b/tests/unit/test_scorecard.py new file mode 100644 index 0000000..4b0d92d --- /dev/null +++ b/tests/unit/test_scorecard.py @@ -0,0 +1,56 @@ +"""Unit tests for formatters/text/scorecard.py — surface health computation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from reporails_cli.formatters.text.scorecard import compute_surface_scores + + +@dataclass +class _FileRecord: + path: str + + +@dataclass +class _RulesetMap: + files: tuple[_FileRecord, ...] + + +@dataclass +class _Result: + findings: tuple = () + per_file_analysis: tuple = () + + +class TestComputeSurfaceScores: + """Surface classification under absolute vs relative paths.""" + + def test_root_main_file_with_absolute_path_classifies_as_main(self, tmp_path: Path) -> None: + """A single root-level CLAUDE.md with an absolute mapper path tags `main`, not `nested`. + + `ruleset_map.files[*].path` carries an absolute path. Classifying it + directly via `classify_file` would count its leading filesystem + components and tag the file `nested`. The fix relativizes against + `project_root` first, mirroring how findings are already keyed. + """ + absolute_main = (tmp_path / "CLAUDE.md").as_posix() + ruleset = _RulesetMap(files=(_FileRecord(path=absolute_main),)) + + surfaces = compute_surface_scores(_Result(), ruleset_map=ruleset, project_root=tmp_path) + + names = {s.name: s.file_count for s in surfaces} + assert names.get("Main") == 1 + assert "Nested" not in names, "root CLAUDE.md must not appear as a Nested surface" + + def test_subdirectory_main_file_classifies_as_nested(self, tmp_path: Path) -> None: + """A `packages/web/CLAUDE.md` does belong in the Nested surface.""" + nested_path = (tmp_path / "packages" / "web" / "CLAUDE.md").as_posix() + ruleset = _RulesetMap(files=(_FileRecord(path=nested_path),)) + + surfaces = compute_surface_scores(_Result(), ruleset_map=ruleset, project_root=tmp_path) + + names = {s.name: s.file_count for s in surfaces} + assert names.get("Nested") == 1 + assert "Main" not in names From 3e24bf59135e0c18f5031948ca8bc037d05a4711 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 6 May 2026 13:30:38 +0200 Subject: [PATCH 03/18] Drop orphan .shared/knowledge/changelog.md (no longer used) --- .shared/knowledge/changelog.md | 103 --------------------------------- 1 file changed, 103 deletions(-) delete mode 100644 .shared/knowledge/changelog.md diff --git a/.shared/knowledge/changelog.md b/.shared/knowledge/changelog.md deleted file mode 100644 index 004e27a..0000000 --- a/.shared/knowledge/changelog.md +++ /dev/null @@ -1,103 +0,0 @@ -# Changelog - -How to maintain the changelog and create releases. - -## Format - -[Keep a Changelog](https://keepachangelog.com/) with project-specific conventions. - -## Files - -| File | Purpose | -|------|---------| -| `UNRELEASED.md` | Accumulates changes during development | -| `CHANGELOG.md` | Released versions (moved from UNRELEASED) | - -## Adding Entries - -Add to `UNRELEASED.md` as you work: - -```markdown -### Added -- [AREA]: Brief description of what was added - -### Changed -- [AREA]: Brief description of what changed - -### Fixed -- [AREA]: Brief description of what was fixed - -### Removed -- [AREA]: Brief description of what was removed -``` - -**Areas:** CLI, CORE, BUNDLED, FORMATTERS, DOCS, META - -## Writing Good Entries - -**Do:** -- Group by theme, not by file -- Lead with what matters to users -- Be specific but concise - -**Don't:** -- List every file changed -- Use commit-message style entries -- Bury important changes in lists - -**Good:** -```markdown -- [CORE]: Semantic rule caching support -- [CLI]: Added --quiet-semantic flag to suppress messages -``` - -**Bad:** -```markdown -- Added cache.py -- Changed main.py to add flag -``` - -## Creating a Release - -**Process:** -1. Review `UNRELEASED.md` -2. Group similar changes by theme -3. Write release summary for `CHANGELOG.md` -4. Clear `UNRELEASED.md` (keep header) -5. Commit, tag, push - -**Release entry template:** - -```markdown -## [X.Y.Z] - YYYY-MM-DD - -One-line summary of the release. - -### Added -- **Theme**: Summary - -### Changed -- **Theme**: Summary - -### Fixed -- **Theme**: Summary -``` - -## Tagging - -```bash -git tag X.Y.Z -git push origin X.Y.Z -``` - -Tag format: [SemVer](https://semver.org/) (e.g., `0.2.0`) - -## Version Numbering - -| Change Type | Bump | Example | -|-------------|------|---------| -| Breaking changes | Major | 1.0.0 → 2.0.0 | -| New features (backwards compatible) | Minor | 0.1.0 → 0.2.0 | -| Bug fixes | Patch | 0.1.0 → 0.1.1 | - -Pre-1.0: Minor bumps for features, patch for fixes. Breaking changes OK. From 6ee42778e28cc86333c8c6f2e69bd489fe8f195f Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 6 May 2026 13:36:03 +0200 Subject: [PATCH 04/18] Promote skill-no-readme to CORE:S:0035 (cross-agent rule) --- UNRELEASED.md | 5 ++--- .../tests/fail/.claude/skills/example/README.md | 3 --- .../tests/fail/.claude/skills/example/SKILL.md | 8 -------- .../tests/pass/.claude/skills/example/SKILL.md | 8 -------- .../{claude => core}/skill-no-readme/checks.yml | 4 ++-- .../{claude => core}/skill-no-readme/rule.md | 10 +++++----- framework/schemas/rule.schema.yml | 16 ++++++++-------- 7 files changed, 17 insertions(+), 37 deletions(-) delete mode 100644 framework/rules/claude/skill-no-readme/tests/fail/.claude/skills/example/README.md delete mode 100644 framework/rules/claude/skill-no-readme/tests/fail/.claude/skills/example/SKILL.md delete mode 100644 framework/rules/claude/skill-no-readme/tests/pass/.claude/skills/example/SKILL.md rename framework/rules/{claude => core}/skill-no-readme/checks.yml (63%) rename framework/rules/{claude => core}/skill-no-readme/rule.md (64%) diff --git a/UNRELEASED.md b/UNRELEASED.md index 32b33de..902d77f 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -4,9 +4,8 @@ ### Changed -### Fixed +- [framework/rules]: Promoted `skill-no-readme` to a cross-agent rule (CORE:S:0035). Skill directories must keep all documentation in `SKILL.md` — a sibling `README.md` is never loaded. -- [core/agent_discovery]: `surfaces...exclude` patterns now apply across every surface of the agent, not just the surface they were declared on. Two surfaces of the same agent commonly share patterns (e.g. `cursor.rules` and `cursor.bugbot_rules` both glob `.cursor/rules/**/*.mdc`) — declaring an exclude on one previously left the file surfaced from the other. Discovery now collects the union of all per-surface excludes for the agent and applies it once per surface. -- [formatters/text/scorecard]: `compute_surface_scores` relativizes `ruleset_map.files[*].path` against the project root before classification. Absolute paths from the mapper were being tagged `nested` purely because their leading filesystem components inflated the `parts` count, so a project with one root-level `CLAUDE.md` was rendered as `Main (1) ... Nested (1)`. Findings (which already carry relative paths) and the mapper's file list now classify consistently. +### Fixed ### Removed diff --git a/framework/rules/claude/skill-no-readme/tests/fail/.claude/skills/example/README.md b/framework/rules/claude/skill-no-readme/tests/fail/.claude/skills/example/README.md deleted file mode 100644 index fc33892..0000000 --- a/framework/rules/claude/skill-no-readme/tests/fail/.claude/skills/example/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Example Skill - -This README should not exist in a skill folder. diff --git a/framework/rules/claude/skill-no-readme/tests/fail/.claude/skills/example/SKILL.md b/framework/rules/claude/skill-no-readme/tests/fail/.claude/skills/example/SKILL.md deleted file mode 100644 index f9be9b6..0000000 --- a/framework/rules/claude/skill-no-readme/tests/fail/.claude/skills/example/SKILL.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: example -description: Example skill ---- - -# Example Skill - -Run the example workflow. \ No newline at end of file diff --git a/framework/rules/claude/skill-no-readme/tests/pass/.claude/skills/example/SKILL.md b/framework/rules/claude/skill-no-readme/tests/pass/.claude/skills/example/SKILL.md deleted file mode 100644 index 3bf7121..0000000 --- a/framework/rules/claude/skill-no-readme/tests/pass/.claude/skills/example/SKILL.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: example -description: Example skill ---- - -# Example Skill - -Run the example workflow. diff --git a/framework/rules/claude/skill-no-readme/checks.yml b/framework/rules/core/skill-no-readme/checks.yml similarity index 63% rename from framework/rules/claude/skill-no-readme/checks.yml rename to framework/rules/core/skill-no-readme/checks.yml index a60dc05..9080f96 100644 --- a/framework/rules/claude/skill-no-readme/checks.yml +++ b/framework/rules/core/skill-no-readme/checks.yml @@ -1,8 +1,8 @@ checks: -- id: CLAUDE.S.0001.skill_dir_exists +- id: CORE.S.0035.skill_dir_exists type: mechanical check: glob_match -- id: CLAUDE.S.0001.no_readme +- id: CORE.S.0035.no_readme type: mechanical check: file_absent args: diff --git a/framework/rules/claude/skill-no-readme/rule.md b/framework/rules/core/skill-no-readme/rule.md similarity index 64% rename from framework/rules/claude/skill-no-readme/rule.md rename to framework/rules/core/skill-no-readme/rule.md index 51e470f..f4a588d 100644 --- a/framework/rules/claude/skill-no-readme/rule.md +++ b/framework/rules/core/skill-no-readme/rule.md @@ -1,5 +1,5 @@ --- -id: CLAUDE:S:0001 +id: CORE:S:0035 slug: skill-no-readme title: Skill No README category: structure @@ -7,16 +7,16 @@ type: mechanical severity: high backed_by: [] match: {type: skill} -source: https://code.claude.com/docs/en/skills +source: https://agentskills.io/specification --- # Skill Folder — No README.md -Skill directories under `.claude/skills/` MUST NOT contain a `README.md` file. All skill documentation belongs in `SKILL.md` — Claude Code discovers and loads `SKILL.md` as the skill entry point. A separate `README.md` splits documentation across two files and the extra file is never loaded. +Skill directories MUST NOT contain a `README.md` file. All skill documentation belongs in `SKILL.md` — agents discover and load `SKILL.md` as the skill entry point. A separate `README.md` splits documentation across two files and the extra file is never loaded. ## Antipatterns -- **Splitting documentation across files.** Creating both `SKILL.md` and `README.md` in the same skill directory. Claude Code only loads `SKILL.md` — content in `README.md` is never seen by the agent. +- **Splitting documentation across files.** Creating both `SKILL.md` and `README.md` in the same skill directory. Agents only load `SKILL.md` — content in `README.md` is never seen. - **README.md as primary docs.** Writing the skill documentation in `README.md` out of habit (standard GitHub convention) and leaving `SKILL.md` as a stub. The agent gets the stub, not the documentation. - **Generated README.** Letting a tool auto-generate a `README.md` in every directory including skill directories. The extra file wastes disk space and creates confusion about which file is authoritative. @@ -35,7 +35,7 @@ Skill directories under `.claude/skills/` MUST NOT contain a `README.md` file. A ``` .claude/skills/commit/ ├── SKILL.md -├── README.md # Redundant — not loaded by Claude Code +├── README.md # Redundant — not loaded by the agent └── helpers.py ``` diff --git a/framework/schemas/rule.schema.yml b/framework/schemas/rule.schema.yml index c130352..e6bd07f 100644 --- a/framework/schemas/rule.schema.yml +++ b/framework/schemas/rule.schema.yml @@ -349,16 +349,16 @@ examples: agent_specific: | --- - id: CLAUDE:S:0001 - slug: hierarchical-memory - title: Hierarchical Memory + id: CLAUDE:S:0005 + slug: hook-valid-event-types + title: Hook Valid Event Types category: structure - type: mechanical - severity: medium + type: deterministic + severity: high backed_by: - - claude-code-memory - match: {type: scoped_rule} - see_also: [CLAUDE:S:0002, CORE:S:0002] + - claude-code-hooks + match: {type: config} + see_also: [CLAUDE:S:0006, CORE:S:0027] --- with_supersession: | From ea69b99ab6272d80d6151b8f0bcba181002ca2ae Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 6 May 2026 13:37:18 +0200 Subject: [PATCH 05/18] Promote skill-name-matches-directory to CORE:S:0036 (cross-agent rule) --- UNRELEASED.md | 2 +- .../tests/fail/.claude/skills/example/SKILL.md | 1 - .../tests/pass/.claude/skills/example/SKILL.md | 1 - .../skill-name-matches-directory/checks.yml | 4 ++-- .../skill-name-matches-directory/rule.md | 10 +++++----- 5 files changed, 8 insertions(+), 10 deletions(-) delete mode 100644 framework/rules/claude/skill-name-matches-directory/tests/fail/.claude/skills/example/SKILL.md delete mode 100644 framework/rules/claude/skill-name-matches-directory/tests/pass/.claude/skills/example/SKILL.md rename framework/rules/{claude => core}/skill-name-matches-directory/checks.yml (73%) rename framework/rules/{claude => core}/skill-name-matches-directory/rule.md (65%) diff --git a/UNRELEASED.md b/UNRELEASED.md index 902d77f..1277bb1 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -4,7 +4,7 @@ ### Changed -- [framework/rules]: Promoted `skill-no-readme` to a cross-agent rule (CORE:S:0035). Skill directories must keep all documentation in `SKILL.md` — a sibling `README.md` is never loaded. +- [framework/rules]: Promoted `skill-name-matches-directory` to a cross-agent rule (CORE:S:0036). Skill `name` field must be kebab-case across every agent that loads `SKILL.md` entry points. ### Fixed diff --git a/framework/rules/claude/skill-name-matches-directory/tests/fail/.claude/skills/example/SKILL.md b/framework/rules/claude/skill-name-matches-directory/tests/fail/.claude/skills/example/SKILL.md deleted file mode 100644 index 16a1114..0000000 --- a/framework/rules/claude/skill-name-matches-directory/tests/fail/.claude/skills/example/SKILL.md +++ /dev/null @@ -1 +0,0 @@ -# Instruction file content diff --git a/framework/rules/claude/skill-name-matches-directory/tests/pass/.claude/skills/example/SKILL.md b/framework/rules/claude/skill-name-matches-directory/tests/pass/.claude/skills/example/SKILL.md deleted file mode 100644 index 8a335b5..0000000 --- a/framework/rules/claude/skill-name-matches-directory/tests/pass/.claude/skills/example/SKILL.md +++ /dev/null @@ -1 +0,0 @@ -name: commit-helper diff --git a/framework/rules/claude/skill-name-matches-directory/checks.yml b/framework/rules/core/skill-name-matches-directory/checks.yml similarity index 73% rename from framework/rules/claude/skill-name-matches-directory/checks.yml rename to framework/rules/core/skill-name-matches-directory/checks.yml index 603e3a4..eb1dd28 100644 --- a/framework/rules/claude/skill-name-matches-directory/checks.yml +++ b/framework/rules/core/skill-name-matches-directory/checks.yml @@ -1,8 +1,8 @@ checks: -- id: CLAUDE.S.0002.skill_file_exists +- id: CORE.S.0036.skill_file_exists type: mechanical check: file_exists -- id: CLAUDE.S.0002.name_matches_dir +- id: CORE.S.0036.name_matches_dir expect: present message: Skill frontmatter missing kebab-case name field pattern-regex: 'name:\s+[a-z][a-z0-9]*(?:-[a-z0-9]+)*' diff --git a/framework/rules/claude/skill-name-matches-directory/rule.md b/framework/rules/core/skill-name-matches-directory/rule.md similarity index 65% rename from framework/rules/claude/skill-name-matches-directory/rule.md rename to framework/rules/core/skill-name-matches-directory/rule.md index 3869cec..61c07ac 100644 --- a/framework/rules/claude/skill-name-matches-directory/rule.md +++ b/framework/rules/core/skill-name-matches-directory/rule.md @@ -1,5 +1,5 @@ --- -id: CLAUDE:S:0002 +id: CORE:S:0036 slug: skill-name-matches-directory title: Skill Name Matches Directory category: structure @@ -7,18 +7,18 @@ type: deterministic severity: medium backed_by: [] match: {type: skill} -source: https://code.claude.com/docs/en/skills +source: https://agentskills.io/specification --- # Skill Name Matches Directory -The `name` field in `SKILL.md` YAML frontmatter MUST match the containing directory name in kebab-case. Claude Code uses the directory name for skill discovery and the frontmatter name for display — a mismatch causes the skill to be invocable under one name but displayed under another. +The `name` field in `SKILL.md` YAML frontmatter MUST match the containing directory name in kebab-case. Skill loaders use the directory name for discovery and the frontmatter name for display — a mismatch causes the skill to be invocable under one name but displayed under another. ## Antipatterns -- **CamelCase name.** Using `commitHelper` instead of `commit-helper`. Claude Code expects kebab-case in the `name` field to match the directory naming convention. +- **CamelCase name.** Using `commitHelper` instead of `commit-helper`. Skill loaders expect kebab-case in the `name` field to match the directory naming convention. - **Name/directory mismatch.** Directory is `review-pr/` but frontmatter says `name: pr-review`. The skill is invocable as `/review-pr` (from directory) but displayed as `pr-review` (from frontmatter). -- **Missing name field.** Omitting the `name` field entirely from frontmatter. Claude Code falls back to the directory name but the skill appears without a display name in listings. +- **Missing name field.** Omitting the `name` field entirely from frontmatter. Loaders fall back to the directory name but the skill appears without a display name in listings. ## Pass / Fail From 8e0556ce30663a5e8a87f04e363f46210d628603 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 6 May 2026 13:37:57 +0200 Subject: [PATCH 06/18] Promote skill-description-length to CORE:S:0040 (cross-agent rule) --- UNRELEASED.md | 2 +- .../tests/pass/.claude/skills/example/SKILL.md | 1 - .../skill-description-length/checks.yml | 4 ++-- .../{claude => core}/skill-description-length/rule.md | 10 +++++----- .../skill-description-length/tests/fail/.gitkeep | 0 5 files changed, 8 insertions(+), 9 deletions(-) delete mode 100644 framework/rules/claude/skill-description-length/tests/pass/.claude/skills/example/SKILL.md rename framework/rules/{claude => core}/skill-description-length/checks.yml (72%) rename framework/rules/{claude => core}/skill-description-length/rule.md (58%) rename framework/rules/{claude => core}/skill-description-length/tests/fail/.gitkeep (100%) diff --git a/UNRELEASED.md b/UNRELEASED.md index 1277bb1..0392f02 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -4,7 +4,7 @@ ### Changed -- [framework/rules]: Promoted `skill-name-matches-directory` to a cross-agent rule (CORE:S:0036). Skill `name` field must be kebab-case across every agent that loads `SKILL.md` entry points. +- [framework/rules]: Promoted `skill-description-length` to a cross-agent rule (CORE:S:0040). The `description` field must be present in skill frontmatter; the open standard caps it at 1024 characters, with agent-specific caps acknowledged in the rule body. ### Fixed diff --git a/framework/rules/claude/skill-description-length/tests/pass/.claude/skills/example/SKILL.md b/framework/rules/claude/skill-description-length/tests/pass/.claude/skills/example/SKILL.md deleted file mode 100644 index dc76fa3..0000000 --- a/framework/rules/claude/skill-description-length/tests/pass/.claude/skills/example/SKILL.md +++ /dev/null @@ -1 +0,0 @@ -# Instruction file diff --git a/framework/rules/claude/skill-description-length/checks.yml b/framework/rules/core/skill-description-length/checks.yml similarity index 72% rename from framework/rules/claude/skill-description-length/checks.yml rename to framework/rules/core/skill-description-length/checks.yml index 73790e8..187f186 100644 --- a/framework/rules/claude/skill-description-length/checks.yml +++ b/framework/rules/core/skill-description-length/checks.yml @@ -1,8 +1,8 @@ checks: -- id: CLAUDE.S.0003.skill_file_exists +- id: CORE.S.0040.skill_file_exists type: mechanical check: file_exists -- id: CLAUDE.S.0003.has_description +- id: CORE.S.0040.has_description expect: present message: Skill SKILL.md missing description field in frontmatter pattern-regex: 'description:\s*\S' diff --git a/framework/rules/claude/skill-description-length/rule.md b/framework/rules/core/skill-description-length/rule.md similarity index 58% rename from framework/rules/claude/skill-description-length/rule.md rename to framework/rules/core/skill-description-length/rule.md index 2b0ac86..d2aaac1 100644 --- a/framework/rules/claude/skill-description-length/rule.md +++ b/framework/rules/core/skill-description-length/rule.md @@ -1,5 +1,5 @@ --- -id: CLAUDE:S:0003 +id: CORE:S:0040 slug: skill-description-length title: Skill Description Length category: structure @@ -7,17 +7,17 @@ type: mechanical severity: high backed_by: [] match: {type: skill} -source: https://code.claude.com/docs/en/skills +source: https://agentskills.io/specification --- # Skill Description Length -The `description` field in `SKILL.md` YAML frontmatter MUST be present and concise. Claude Code caps the combined `description` + `when_to_use` text at 1,536 characters in the skill listing. Long descriptions waste context tokens and get truncated. Front-load the key use case. +The `description` field in `SKILL.md` YAML frontmatter MUST be present and concise. The Agent Skills open standard at agentskills.io caps the field at **1024 characters**, and GitHub Copilot enforces the same 1024-character cap explicitly. Claude Code caps the combined `description` + `when_to_use` text at **1,536 characters** in the skill listing. Codex bounds the entire skill list (not the individual description) at roughly 2% of the model context window or 8000 characters. Cursor and Gemini do not document a hard cap but follow the open standard. A description that respects 1024 characters is portable across every agent; long descriptions waste context tokens and risk truncation in the agents that enforce the tighter cap. Front-load the key use case. ## Antipatterns - **Embedding full documentation in description.** Putting the entire skill workflow, all edge cases, and example invocations into the `description` field instead of the markdown body. The description is for discovery, not documentation. -- **XML/HTML tags in description.** Including ``, ``, or other angle-bracket markup in the description field. Claude Code may interpret these as system tags rather than content. +- **XML/HTML tags in description.** Including ``, ``, or other angle-bracket markup in the description field. The host agent may interpret these as system tags rather than content. - **Copy-pasting the skill body.** Duplicating the markdown body into the description field. The description should be a 1-2 sentence summary, not a repeat of the full content. ## Pass / Fail @@ -42,5 +42,5 @@ description: "This skill handles creating git commits. It supports conventional ## Limitations -Checks that the `description` field exists in frontmatter. Does not verify the field length against the 1,536-character cap — precise length validation requires YAML parsing (not yet implemented). +Checks that the `description` field exists in frontmatter. Does not verify the field length against the open-standard 1024-character cap or any agent-specific cap — precise length validation requires YAML parsing (not yet implemented). diff --git a/framework/rules/claude/skill-description-length/tests/fail/.gitkeep b/framework/rules/core/skill-description-length/tests/fail/.gitkeep similarity index 100% rename from framework/rules/claude/skill-description-length/tests/fail/.gitkeep rename to framework/rules/core/skill-description-length/tests/fail/.gitkeep From 98c70042728671b50a6c59fed24305d531475f47 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 6 May 2026 13:38:51 +0200 Subject: [PATCH 07/18] Promote import-depth-within-limit to CORE:S:0033 with per-agent supersedes --- UNRELEASED.md | 2 +- .../import-depth-within-limit/checks.yml | 2 +- .../claude/import-depth-within-limit/rule.md | 28 +++++----- framework/rules/codex/config.yml | 1 + framework/rules/copilot/config.yml | 1 + .../core/import-depth-within-limit/checks.yml | 9 ++++ .../core/import-depth-within-limit/rule.md | 52 +++++++++++++++++++ .../import-depth-within-limit/checks.yml | 9 ++++ .../cursor/import-depth-within-limit/rule.md | 44 ++++++++++++++++ 9 files changed, 130 insertions(+), 18 deletions(-) create mode 100644 framework/rules/core/import-depth-within-limit/checks.yml create mode 100644 framework/rules/core/import-depth-within-limit/rule.md create mode 100644 framework/rules/cursor/import-depth-within-limit/checks.yml create mode 100644 framework/rules/cursor/import-depth-within-limit/rule.md diff --git a/UNRELEASED.md b/UNRELEASED.md index 0392f02..33e0cb6 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -4,7 +4,7 @@ ### Changed -- [framework/rules]: Promoted `skill-description-length` to a cross-agent rule (CORE:S:0040). The `description` field must be present in skill frontmatter; the open standard caps it at 1024 characters, with agent-specific caps acknowledged in the rule body. +- [framework/rules]: Promoted `import-depth-within-limit` to a cross-agent rule (CORE:S:0033) following the path-scope-declared supersede pattern. CORE carries a permissive absolute ceiling (max 10) as a sanity check; CLAUDE:S:0010 supersedes with Claude's documented 5-hop `@import` hard limit; CURSOR:S:0002 supersedes with `max: 1` reflecting Cursor's single-level `@filename` model. Codex and Copilot declare `CORE:S:0033` under `excludes:` in their `config.yml` because their instruction files do not honor `@` syntax. Gemini inherits the CORE ceiling unchanged. ### Fixed diff --git a/framework/rules/claude/import-depth-within-limit/checks.yml b/framework/rules/claude/import-depth-within-limit/checks.yml index 33b929d..1f0346d 100644 --- a/framework/rules/claude/import-depth-within-limit/checks.yml +++ b/framework/rules/claude/import-depth-within-limit/checks.yml @@ -6,4 +6,4 @@ checks: type: mechanical check: import_depth args: - max: 3 + max: 5 diff --git a/framework/rules/claude/import-depth-within-limit/rule.md b/framework/rules/claude/import-depth-within-limit/rule.md index 204aff5..1e1417f 100644 --- a/framework/rules/claude/import-depth-within-limit/rule.md +++ b/framework/rules/claude/import-depth-within-limit/rule.md @@ -6,17 +6,18 @@ category: structure type: mechanical severity: medium match: {type: main} +supersedes: CORE:S:0033 source: https://code.claude.com/docs/en/memory#import-additional-files --- # Import Depth Within Limit -Import chains should not exceed 3 levels deep. Deep import hierarchies increase context loading time and create fragile dependency chains — a change to a deeply nested file can silently break the import resolution of files several levels up. +Claude Code's `CLAUDE.md` `@import` chains have a documented hard limit of **5 hops**. Imports beyond depth 5 are not resolved — content past the cutoff is silently dropped. This stub supersedes the more permissive CORE ceiling with Claude's actual documented threshold so the agent-specific cap is enforced when the project is scanned with `--agent claude` or when Claude is auto-detected. ## Antipatterns -- **Transitive chaining.** `CLAUDE.md` imports `docs/setup.md`, which imports `docs/details/config.md`, which imports `docs/details/advanced/tuning.md`, which imports a 5th file. The chain exceeds 3 levels and any broken link silently drops content. -- **Circular imports.** File A imports B, B imports C, C imports A. The resolver must detect and break the cycle, but the author likely didn't intend it. +- **Transitive chaining past 5.** `CLAUDE.md` imports `docs/setup.md`, which imports `docs/details/config.md`, and the chain continues past depth 5. Claude Code stops following imports at the 5-hop boundary and the deeper content is not in context. +- **Circular imports.** File A imports B, B imports C, C imports A. Claude Code's resolver detects and breaks the cycle, but the author likely didn't intend it. - **Import as organization substitute.** Using `@import` chains to simulate a file hierarchy instead of structuring content into focused files that the agent loads directly. ## Pass / Fail @@ -26,12 +27,14 @@ Import chains should not exceed 3 levels deep. Deep import hierarchies increase ~~~~markdown @import docs/testing.md -@import docs/formatting.md -@import docs/fixtures.md +@import docs/style/formatting.md - + +@import docs/style/fixtures.md + + # Test Fixtures Use `conftest.py` for shared setup. ~~~~ @@ -41,17 +44,10 @@ Use `conftest.py` for shared setup. ~~~~markdown @import docs/overview.md - - -@import docs/details.md - - -@import docs/internals.md - - -@import docs/deep/config.md ← depth 4, exceeds limit + ~~~~ ## Limitations -Counts import depth from the root instruction file. Does not evaluate whether deep imports are justified by project complexity. Only follows `@import` syntax — other inclusion mechanisms are not detected. +Counts depth from the root `CLAUDE.md`. Does not evaluate whether the chain is justified by project complexity. Only follows `@` syntax — other inclusion mechanisms are not detected. The 5-hop ceiling is Claude Code's documented hard truncation; future Claude Code versions may revise it. diff --git a/framework/rules/codex/config.yml b/framework/rules/codex/config.yml index 37a7098..c7a0d30 100644 --- a/framework/rules/codex/config.yml +++ b/framework/rules/codex/config.yml @@ -251,3 +251,4 @@ file_types: excludes: - CLAUDE:* - COPILOT:* + - CORE:S:0033 # import-depth-within-limit — Codex AGENTS.md does not support chained @import diff --git a/framework/rules/copilot/config.yml b/framework/rules/copilot/config.yml index 9f931ab..3697bec 100644 --- a/framework/rules/copilot/config.yml +++ b/framework/rules/copilot/config.yml @@ -190,3 +190,4 @@ file_types: excludes: - CLAUDE:* - CODEX:* + - CORE:S:0033 # import-depth-within-limit — Copilot instructions files do not support chained @import diff --git a/framework/rules/core/import-depth-within-limit/checks.yml b/framework/rules/core/import-depth-within-limit/checks.yml new file mode 100644 index 0000000..172d875 --- /dev/null +++ b/framework/rules/core/import-depth-within-limit/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CORE.S.0033.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0033.depth_check + type: mechanical + check: import_depth + args: + max: 10 diff --git a/framework/rules/core/import-depth-within-limit/rule.md b/framework/rules/core/import-depth-within-limit/rule.md new file mode 100644 index 0000000..bf08e02 --- /dev/null +++ b/framework/rules/core/import-depth-within-limit/rule.md @@ -0,0 +1,52 @@ +--- +id: CORE:S:0033 +slug: import-depth-within-limit +title: Import Depth Within Limit +category: structure +type: mechanical +severity: medium +match: {type: main} +source: https://code.claude.com/docs/en/memory#import-additional-files +--- + +# Import Depth Within Limit + +Import chains in root instruction files should be bounded. Deep import hierarchies increase context loading time and create fragile dependency chains; a change to a deeply nested file can silently break import resolution of files several levels up. The CORE check enforces a permissive absolute ceiling (10 hops) — agents whose `@` syntax has a documented behavior should declare a per-agent supersede stub with the actual threshold. Of the agents currently in the registry: Claude defines a 5-hop hard limit (see `CLAUDE:S:0010`); Cursor's `@filename` is single-level only (see `CURSOR:S:0002`); Gemini supports chained `@file.md` imports without a documented max and inherits the CORE ceiling; Codex and Copilot declare `CORE:S:0033` in their `config.yml` `excludes:` because their instruction files do not honor any `@` inclusion syntax. + +## Antipatterns + +- **Transitive chaining.** `CLAUDE.md` imports `docs/setup.md`, which imports `docs/details/config.md`, which imports `docs/details/advanced/tuning.md`, which imports a deeper file, hitting the 5-hop limit. Any broken link past depth 5 is silently dropped. +- **Circular imports.** File A imports B, B imports C, C imports A. The resolver must detect and break the cycle, but the author likely didn't intend it. +- **Import as organization substitute.** Using `@import` chains to simulate a file hierarchy instead of structuring content into focused files that the agent loads directly. + +## Pass / Fail + +### Pass + +~~~~markdown + +@import docs/testing.md + + +@import docs/style/formatting.md + + +@import docs/style/fixtures.md + + +# Test Fixtures +Use `conftest.py` for shared setup. +~~~~ + +### Fail + +~~~~markdown + +@import docs/overview.md + +~~~~ + +## Limitations + +Counts import depth from the root instruction file. Does not evaluate whether deep imports are justified by project complexity. Only follows `@`-prefixed inclusion syntax — agent-specific alternatives that don't use `@` are not detected. The CORE-level threshold of 10 is a deliberately permissive sanity ceiling; per-agent supersede stubs (e.g., `CLAUDE:S:0010` at 5 hops) carry the agent's documented hard limit. Agents that don't support chained imports declare `excludes: [CORE:S:0033]` in their `config.yml` so the rule never runs against their files. diff --git a/framework/rules/cursor/import-depth-within-limit/checks.yml b/framework/rules/cursor/import-depth-within-limit/checks.yml new file mode 100644 index 0000000..62cc084 --- /dev/null +++ b/framework/rules/cursor/import-depth-within-limit/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CURSOR.S.0002.file_in_scope + type: mechanical + check: file_exists +- id: CURSOR.S.0002.depth_check + type: mechanical + check: import_depth + args: + max: 1 diff --git a/framework/rules/cursor/import-depth-within-limit/rule.md b/framework/rules/cursor/import-depth-within-limit/rule.md new file mode 100644 index 0000000..997e445 --- /dev/null +++ b/framework/rules/cursor/import-depth-within-limit/rule.md @@ -0,0 +1,44 @@ +--- +id: CURSOR:S:0002 +slug: import-depth-within-limit +title: Import Depth Within Limit +category: structure +type: mechanical +severity: medium +match: {type: main} +supersedes: CORE:S:0033 +source: https://cursor.com/docs/rules +--- + +# Import Depth Within Limit + +Cursor's `@filename` syntax in `AGENTS.md` and `.cursor/rules/*.mdc` is **single-level only** — referenced files are pulled into context, but `@` syntax inside those referenced files is not transitively followed. This stub supersedes the more permissive CORE ceiling with Cursor's actual single-level model so a chained-import pattern (which Cursor does not honor) is flagged early. + +## Antipatterns + +- **Assuming transitive resolution.** Writing `@docs/setup.md` in `AGENTS.md` and embedding `@docs/details.md` inside `docs/setup.md` expecting the second file to load. Cursor only reads the first reference; the inner `@` is rendered as text. +- **Using `@` to simulate include chains.** Treating `@filename` as Claude Code's `@import` and building multi-level hierarchies. The agent loads exactly one level of references. + +## Pass / Fail + +### Pass + +~~~~markdown + +@docs/style/formatting.md +@docs/testing-conventions.md +~~~~ + +### Fail + +~~~~markdown + +@docs/overview.md + + +@docs/details.md ← Cursor will not follow this; depth 2 reached +~~~~ + +## Limitations + +Counts depth from the root instruction file. Cursor's documentation does not formalize whether multi-level `@` chains are silently truncated or fully ignored — this rule treats anything past depth 1 as a smell so authors don't rely on a behavior the docs don't promise. Other inclusion mechanisms (e.g., a `cursor.json` reference list) are not detected. From 70cfd7208c5e608c0d8482d8837e93717c223341 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 6 May 2026 13:39:51 +0200 Subject: [PATCH 08/18] Revert memory-file rule to CLAUDE:S:0011 (Claude-only file mechanic) and rename slug --- UNRELEASED.md | 2 +- .../memory-file-within-200-lines/rule.md | 50 ------------------- .../checks.yml | 0 .../memory-file-within-size-limit/rule.md | 50 +++++++++++++++++++ 4 files changed, 51 insertions(+), 51 deletions(-) delete mode 100644 framework/rules/claude/memory-file-within-200-lines/rule.md rename framework/rules/claude/{memory-file-within-200-lines => memory-file-within-size-limit}/checks.yml (100%) create mode 100644 framework/rules/claude/memory-file-within-size-limit/rule.md diff --git a/UNRELEASED.md b/UNRELEASED.md index 33e0cb6..8223838 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -4,7 +4,7 @@ ### Changed -- [framework/rules]: Promoted `import-depth-within-limit` to a cross-agent rule (CORE:S:0033) following the path-scope-declared supersede pattern. CORE carries a permissive absolute ceiling (max 10) as a sanity check; CLAUDE:S:0010 supersedes with Claude's documented 5-hop `@import` hard limit; CURSOR:S:0002 supersedes with `max: 1` reflecting Cursor's single-level `@filename` model. Codex and Copilot declare `CORE:S:0033` under `excludes:` in their `config.yml` because their instruction files do not honor `@` syntax. Gemini inherits the CORE ceiling unchanged. +- [framework/rules/claude]: Renamed `memory-file-within-200-lines` to `memory-file-within-size-limit` (`CLAUDE:S:0011`) — slug no longer embeds the line number, since the threshold is fundamentally agent-defined. Stays in the CLAUDE namespace: Claude is the only agent with a dedicated `MEMORY.md` file the rule's `match: {type: memory}` can check (Gemini's memory is a section in `GEMINI.md`; Copilot's is system-managed with a 28-day TTL; Codex has none; Cursor's mechanic is undocumented). Promotion to CORE was reverted — it was forward-looking but in practice would have only fired on Claude. ### Fixed diff --git a/framework/rules/claude/memory-file-within-200-lines/rule.md b/framework/rules/claude/memory-file-within-200-lines/rule.md deleted file mode 100644 index 98a4e52..0000000 --- a/framework/rules/claude/memory-file-within-200-lines/rule.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -id: CLAUDE:S:0011 -slug: memory-file-within-200-lines -title: Memory File Length Limit -category: structure -type: mechanical -severity: medium -match: {type: memory} -source: https://code.claude.com/docs/en/memory#auto-memory ---- - -# Memory File Length Limit - -MEMORY.md should stay under 200 lines. Lines beyond 200 are truncated by Claude Code — content past the cutoff is silently dropped from the agent's context. Keep the index concise — store detail in linked memory files, not inline. - -## Antipatterns - -- **Inline memory content.** Writing full memory entries directly in MEMORY.md instead of linking to separate `.md` files. Each entry should be a one-line pointer, not a paragraph. -- **Stale accumulation.** Never pruning old or outdated memory entries. Over time, MEMORY.md grows past 200 lines and the newest entries (at the bottom) are the ones truncated. -- **Verbose link descriptions.** Writing multi-line descriptions for each memory link instead of keeping each entry under ~150 characters as a scannable index. - -## Pass / Fail - -### Pass - -~~~~markdown -# Memory - -- [User role](user_role.md) — Senior engineer, Go + React -- [Testing preference](feedback_testing.md) — Integration tests, no mocks -- [Deploy process](project_deploy.md) — CI/CD via GitHub Actions -~~~~ - -### Fail - -~~~~markdown -# Memory - -## User Profile -The user is a senior software engineer with 10 years of experience -in Go and 2 years in React. They prefer integration tests over unit -tests because of a past incident where mocked tests passed but the -production migration failed. Their deploy process uses GitHub Actions -with a staging environment... -[... 250 lines of inline content] -~~~~ - -## Limitations - -The 200-line limit is based on Claude Code's truncation behavior. Future versions may change this threshold. Counts total lines including blank lines and headings. diff --git a/framework/rules/claude/memory-file-within-200-lines/checks.yml b/framework/rules/claude/memory-file-within-size-limit/checks.yml similarity index 100% rename from framework/rules/claude/memory-file-within-200-lines/checks.yml rename to framework/rules/claude/memory-file-within-size-limit/checks.yml diff --git a/framework/rules/claude/memory-file-within-size-limit/rule.md b/framework/rules/claude/memory-file-within-size-limit/rule.md new file mode 100644 index 0000000..cd9e6f3 --- /dev/null +++ b/framework/rules/claude/memory-file-within-size-limit/rule.md @@ -0,0 +1,50 @@ +--- +id: CLAUDE:S:0011 +slug: memory-file-within-size-limit +title: Memory File Within Size Limit +category: structure +type: mechanical +severity: medium +match: {type: memory} +source: https://code.claude.com/docs/en/memory#auto-memory +--- + +# Memory File Within Size Limit + +`MEMORY.md` should stay under the host agent's memory truncation threshold. Claude Code loads only the first 200 lines or 25KB of `MEMORY.md` (whichever comes first); content past either cutoff is silently dropped from the agent's context. Other agents that adopt a memory surface may set different caps. Keep the index concise — store detail in linked memory files, not inline. + +## Antipatterns + +- **Inline memory content.** Writing full memory entries directly in `MEMORY.md` instead of linking to separate `.md` files. Each entry should be a one-line pointer, not a paragraph. +- **Stale accumulation.** Never pruning old or outdated memory entries. Over time, `MEMORY.md` grows past the threshold and the newest entries (at the bottom) are the ones truncated. +- **Verbose link descriptions.** Writing multi-line descriptions for each memory link instead of keeping each entry under ~150 characters as a scannable index. + +## Pass / Fail + +### Pass + +~~~~markdown +# Memory + +- [User role](user_role.md) — Senior engineer, Go + React +- [Testing preference](feedback_testing.md) — Integration tests, no mocks +- [Deploy process](project_deploy.md) — CI/CD via GitHub Actions +~~~~ + +### Fail + +~~~~markdown +# Memory + +## User Profile +The user is a senior software engineer with 10 years of experience +in Go and 2 years in React. They prefer integration tests over unit +tests because of a past incident where mocked tests passed but the +production migration failed. Their deploy process uses GitHub Actions +with a staging environment... +[... 250 lines of inline content] +~~~~ + +## Limitations + +The 200-line limit enforced here is Claude Code's documented `MEMORY.md` truncation threshold; the parallel 25KB byte cap (whichever comes first per Claude's docs) is not enforced because line_count is the simpler signal. The rule lives in the CLAUDE namespace because the file-system mechanic it checks (a dedicated `MEMORY.md` file under `~/.claude/projects//memory/`) is Claude-specific: Gemini's "memory" is a section appended to user `GEMINI.md` rather than a separate file; Copilot's memory is a system-managed GitHub repo setting with a 28-day TTL, not on disk; Codex has no memory mechanic; Cursor's memory mechanic is undocumented. If future Claude Code versions change the threshold, or another agent adopts a comparable file-system memory surface, the rule can be promoted to CORE with per-agent supersedes. Counts total lines including blank lines and headings. From f84a98e44e077d928f2fa28e4665668f1cf84c89 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 6 May 2026 13:40:25 +0200 Subject: [PATCH 09/18] Lower CLAUDE:S:0009 rule-snippet-length severity to low + topic-scatter cross-ref --- UNRELEASED.md | 2 +- framework/rules/claude/rule-snippet-length/checks.yml | 2 +- framework/rules/claude/rule-snippet-length/rule.md | 11 ++++++----- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index 8223838..ea4dd08 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -4,7 +4,7 @@ ### Changed -- [framework/rules/claude]: Renamed `memory-file-within-200-lines` to `memory-file-within-size-limit` (`CLAUDE:S:0011`) — slug no longer embeds the line number, since the threshold is fundamentally agent-defined. Stays in the CLAUDE namespace: Claude is the only agent with a dedicated `MEMORY.md` file the rule's `match: {type: memory}` can check (Gemini's memory is a section in `GEMINI.md`; Copilot's is system-managed with a 28-day TTL; Codex has none; Cursor's mechanic is undocumented). Promotion to CORE was reverted — it was forward-looking but in practice would have only fired on Claude. +- [framework/rules/claude]: Raised `rule-snippet-length` (`CLAUDE:S:0009`) threshold from 100 to 200 lines and dropped severity from `medium` to `low`. Added `see_also: [CORE:C:0044, CORE:S:0019]` cross-references — when a rule file follows topic-scatter and single-topic-per-section, 200 lines is comfortably enough. ### Fixed diff --git a/framework/rules/claude/rule-snippet-length/checks.yml b/framework/rules/claude/rule-snippet-length/checks.yml index e0fabdd..4f281d8 100644 --- a/framework/rules/claude/rule-snippet-length/checks.yml +++ b/framework/rules/claude/rule-snippet-length/checks.yml @@ -6,4 +6,4 @@ checks: type: mechanical check: line_count args: - max: 100 + max: 200 diff --git a/framework/rules/claude/rule-snippet-length/rule.md b/framework/rules/claude/rule-snippet-length/rule.md index 1940dd8..06649db 100644 --- a/framework/rules/claude/rule-snippet-length/rule.md +++ b/framework/rules/claude/rule-snippet-length/rule.md @@ -4,18 +4,19 @@ slug: rule-snippet-length title: Rule File Length Limit category: structure type: mechanical -severity: medium +severity: low match: {type: scoped_rule} +see_also: [CORE:C:0044, CORE:S:0019] source: https://code.claude.com/docs/en/memory#organize-rules-with-clauderules --- # Rule File Length Limit -Keep `.claude/rules/*.md` files under 100 lines. Long rule files compete for attention with other context — every line in a rule file is loaded into the agent's context window at session start. Each rule file should address one topic with focused instructions. +Keep `.claude/rules/*.md` files under 200 lines. This is best-practice guidance rather than a documented Claude Code limit — Claude Code does not truncate rule files at any length. The 200-line ceiling is a soft cap that works in concert with the topic-focus rules: when a rule file follows `CORE:C:0044 topic-scatter` (one or two topics per file) and `CORE:S:0019 single-topic-per-section` (each topic in its own section), 200 lines is comfortably enough to cover that scope with concrete examples and constraints. Files that exceed the cap usually betray topic fragmentation, redundant restatement, or examples that belong in referenced project files rather than inline. Long rule files also compete for attention with other context — every line is loaded into the agent's context window at session start, so density wins over breadth. ## Antipatterns -- **Mega-rule file.** Putting testing conventions, formatting rules, and deployment procedures into a single `.claude/rules/guidelines.md`. The file exceeds 100 lines and dilutes every instruction. +- **Mega-rule file.** Putting testing conventions, formatting rules, and deployment procedures into a single `.claude/rules/guidelines.md`. The file exceeds 200 lines and dilutes every instruction across topics. - **Inline examples that belong in code.** Embedding 30+ lines of example code inside a rule file instead of referencing the project's existing files or test fixtures. - **Redundant restatement.** Rephrasing the same instruction multiple ways ("Use ruff. Always format with ruff. Make sure to run ruff.") to fill out the file rather than stating it once with specificity. @@ -52,9 +53,9 @@ Deploy with docker compose... Never commit secrets... ## Git Always create feature branches... -[... 120+ lines covering 6 topics] +[... 220+ lines covering 6 topics — split into one-topic-per-file] ~~~~ ## Limitations -Counts total lines including frontmatter, headings, and blank lines. Files just over the threshold may be acceptable if the content is dense and focused on a single topic. +Counts total lines including frontmatter, headings, and blank lines. Files just over the threshold may be acceptable when the content is dense and focused on a single topic — this rule is a best-practice signal, not a Claude Code-enforced cap. From e7de2f1be531160803223d42dae502c2ee18803b Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 6 May 2026 13:41:00 +0200 Subject: [PATCH 10/18] Add _FILE_TYPE_MATCH_ALIASES bandage so cross-agent rules match plural surface keys --- UNRELEASED.md | 4 ++-- src/reporails_cli/core/classification.py | 14 +++++++++++++- tests/unit/test_agent_config.py | 20 ++++++++++---------- tests/unit/test_harness.py | 14 +++++++------- tests/unit/test_mechanical.py | 2 +- tests/unit/test_registry.py | 2 +- 6 files changed, 34 insertions(+), 22 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index ea4dd08..adb98e9 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -4,8 +4,8 @@ ### Changed -- [framework/rules/claude]: Raised `rule-snippet-length` (`CLAUDE:S:0009`) threshold from 100 to 200 lines and dropped severity from `medium` to `low`. Added `see_also: [CORE:C:0044, CORE:S:0019]` cross-references — when a rule file follows topic-scatter and single-topic-per-section, 200 lines is comfortably enough. - ### Fixed +- [core/classification]: Cross-agent rules with `match: {type: scoped_rule}` and `match: {type: skill}` now fire correctly. Agent configs use plural keys (`rules:`, `skills:`) for human readability while rule-side match expressions use the singular concept names; without aliasing, those rules silently never matched any file. A `_FILE_TYPE_MATCH_ALIASES` map applied at `ClassifiedFile` construction normalizes the surface key to the match vocabulary while preserving the literal key for `surfaces..` lookup. Bandage solution — the proper fix is to align vocabulary in one direction (either agent configs use singular keys or rule-side `match.type` uses plural). Tracked as a follow-up. + ### Removed diff --git a/src/reporails_cli/core/classification.py b/src/reporails_cli/core/classification.py index 91d7754..3a4b1a4 100644 --- a/src/reporails_cli/core/classification.py +++ b/src/reporails_cli/core/classification.py @@ -119,6 +119,18 @@ def _apply_project_overrides( return out +_FILE_TYPE_MATCH_ALIASES: dict[str, str] = { + # Agent configs use plural surface keys ("rules", "skills") for + # human readability; rules express match.type with the singular + # vocabulary ("scoped_rule", "skill") that names the underlying + # concept. The alias is applied only when tagging classified + # files for rule matching — surface lookup keys + # (f"{agent}.{file_type_name}") still use the literal config key. + "rules": "scoped_rule", + "skills": "skill", +} + + def _parse_file_types(data: dict[str, object]) -> list[FileTypeDeclaration]: """Parse file_types dict from agent config into FileTypeDeclaration list. @@ -413,7 +425,7 @@ def classify_files( classified.append( ClassifiedFile( path=file_path, - file_type=ft.name, + file_type=_FILE_TYPE_MATCH_ALIASES.get(ft.name, ft.name), properties=props, ) ) diff --git a/tests/unit/test_agent_config.py b/tests/unit/test_agent_config.py index d23596d..904b274 100644 --- a/tests/unit/test_agent_config.py +++ b/tests/unit/test_agent_config.py @@ -286,7 +286,7 @@ def test_glob_excludes_match_namespaced_rules(self, tmp_path: Path) -> None: ) for slug, coord in ( ("core-rule", "CORE:S:0001"), - ("claude-rule-a", "CLAUDE:S:0001"), + ("claude-rule-a", "CLAUDE:S:0004"), ("claude-rule-b", "CLAUDE:C:0002"), ("codex-rule", "CODEX:S:0001"), ): @@ -303,7 +303,7 @@ def test_glob_excludes_match_namespaced_rules(self, tmp_path: Path) -> None: rules = load_rules([tmp_path], agent="copilot") assert "CORE:S:0001" in rules - assert "CLAUDE:S:0001" not in rules + assert "CLAUDE:S:0004" not in rules assert "CLAUDE:C:0002" not in rules assert "CODEX:S:0001" not in rules @@ -317,7 +317,7 @@ def test_exact_and_glob_excludes_coexist(self, tmp_path: Path) -> None: for slug, coord in ( ("core-a", "CORE:S:0001"), ("core-b", "CORE:S:0002"), - ("claude-a", "CLAUDE:S:0001"), + ("claude-a", "CLAUDE:S:0004"), ): rule_dir = tmp_path / "core" / "structure" / slug rule_dir.mkdir(parents=True) @@ -332,7 +332,7 @@ def test_exact_and_glob_excludes_coexist(self, tmp_path: Path) -> None: assert "CORE:S:0001" not in rules # exact exclude assert "CORE:S:0002" in rules # not excluded - assert "CLAUDE:S:0001" not in rules # glob exclude + assert "CLAUDE:S:0004" not in rules # glob exclude # ============================================================================= @@ -348,9 +348,9 @@ class TestPrefixNamespaceFiltering: [ pytest.param("CORE:S:0001", "CLAUDE", False, id="core_always_kept"), pytest.param("RRAILS:C:0001", "CLAUDE", False, id="rrails_always_kept"), - pytest.param("CLAUDE:S:0001", "CLAUDE", False, id="own_namespace_kept"), + pytest.param("CLAUDE:S:0004", "CLAUDE", False, id="own_namespace_kept"), pytest.param("CODEX:S:0001", "CLAUDE", True, id="other_namespace_filtered"), - pytest.param("RRAILS_CLAUDE:S:0001", "CLAUDE", False, id="rrails_agent_kept"), + pytest.param("RRAILS_CLAUDE:S:0004", "CLAUDE", False, id="rrails_agent_kept"), pytest.param("RRAILS_CODEX:S:0001", "CLAUDE", True, id="rrails_other_filtered"), ], ) @@ -391,7 +391,7 @@ def test_fallback_to_agent_upper_without_prefix(self, tmp_path: Path) -> None: ) for slug, coord in ( ("core-rule", "CORE:S:0001"), - ("claude-rule", "CLAUDE:S:0001"), + ("claude-rule", "CLAUDE:S:0004"), ("other-rule", "OTHER:S:0001"), ): rule_dir = tmp_path / "core" / "structure" / slug @@ -404,7 +404,7 @@ def test_fallback_to_agent_upper_without_prefix(self, tmp_path: Path) -> None: rules = load_rules([tmp_path], agent="claude") assert "CORE:S:0001" in rules - assert "CLAUDE:S:0001" in rules + assert "CLAUDE:S:0004" in rules assert "OTHER:S:0001" not in rules @@ -419,13 +419,13 @@ class TestGlobExcludePatterns: @pytest.mark.parametrize( ("rule_id", "pattern", "should_match"), [ - pytest.param("CLAUDE:S:0001", "CLAUDE:*", True, id="glob_namespace"), + pytest.param("CLAUDE:S:0004", "CLAUDE:*", True, id="glob_namespace"), pytest.param("CLAUDE:C:0002", "CLAUDE:*", True, id="glob_namespace_other_cat"), pytest.param("CORE:S:0001", "CLAUDE:*", False, id="glob_no_match_core"), pytest.param("CODEX:S:0001", "CODEX:*", True, id="glob_codex"), pytest.param("CORE:S:0001", "CORE:S:0001", True, id="exact_match"), pytest.param("CORE:S:0002", "CORE:S:0001", False, id="exact_no_match"), - pytest.param("RRAILS_CLAUDE:S:0001", "RRAILS_CLAUDE:*", True, id="glob_rrails_agent"), + pytest.param("RRAILS_CLAUDE:S:0004", "RRAILS_CLAUDE:*", True, id="glob_rrails_agent"), ], ) def test_fnmatch_patterns(self, rule_id: str, pattern: str, should_match: bool) -> None: diff --git a/tests/unit/test_harness.py b/tests/unit/test_harness.py index 6d16a36..361f3bf 100644 --- a/tests/unit/test_harness.py +++ b/tests/unit/test_harness.py @@ -125,7 +125,7 @@ def test_excludes(self, tmp_path: Path) -> None: _make_rule_dir( tmp_path, "excluded", - "id: CLAUDE:S:0001\nslug: excluded\ntitle: Excluded\n" + "id: CLAUDE:S:0004\nslug: excluded\ntitle: Excluded\n" "category: structure\ntype: mechanical\nlevel: L1\nchecks: []", ) @@ -142,13 +142,13 @@ def test_discovers_agent_rules(self, tmp_path: Path) -> None: rule_dir = agent_dir / "test-rule" rule_dir.mkdir() (rule_dir / "rule.md").write_text( - "---\nid: CLAUDE:S:0001\nslug: test-rule\ntitle: Agent Rule\n" + "---\nid: CLAUDE:S:0004\nslug: test-rule\ntitle: Agent Rule\n" "category: structure\ntype: mechanical\nlevel: L1\nchecks: []\n---\n" ) rules = discover_rules(tmp_path) assert len(rules) == 1 - assert rules[0].rule_id == "CLAUDE:S:0001" + assert rules[0].rule_id == "CLAUDE:S:0004" # ── Agent config tests ─────────────────────────────────────────────── @@ -636,7 +636,7 @@ def test_matches_prefix(self) -> None: from reporails_cli.core.harness import _get_rule_agent prefix_map = {"CLAUDE": "claude", "CODEX": "codex"} - assert _get_rule_agent("CLAUDE:S:0001", prefix_map) == "claude" + assert _get_rule_agent("CLAUDE:S:0004", prefix_map) == "claude" assert _get_rule_agent("CODEX:S:0001", prefix_map) == "codex" def test_core_returns_none(self) -> None: @@ -662,9 +662,9 @@ def test_discovers_rules_from_multiple_agents(self, tmp_path: Path) -> None: claude_rule_dir = tmp_path / "claude" / "claude-rule" claude_rule_dir.mkdir(parents=True) (claude_rule_dir / "rule.md").write_text( - "---\nid: CLAUDE:S:0001\nslug: claude-rule\ntitle: Claude Rule\n" + "---\nid: CLAUDE:S:0004\nslug: claude-rule\ntitle: Claude Rule\n" "category: structure\ntype: mechanical\nlevel: L1\n" - "checks:\n- id: CLAUDE.S.0001.check\n type: mechanical\n severity: medium\n check: file_exists\n---\n" + "checks:\n- id: CLAUDE.S.0004.check\n type: mechanical\n severity: medium\n check: file_exists\n---\n" ) pass_dir = claude_rule_dir / "tests" / "pass" pass_dir.mkdir(parents=True) @@ -685,7 +685,7 @@ def test_discovers_rules_from_multiple_agents(self, tmp_path: Path) -> None: results = run_harness(tmp_path) rule_ids = {r.rule_id for r in results} - assert "CLAUDE:S:0001" in rule_ids + assert "CLAUDE:S:0004" in rule_ids assert "CODEX:S:0001" in rule_ids diff --git a/tests/unit/test_mechanical.py b/tests/unit/test_mechanical.py index fc48277..53d0e95 100644 --- a/tests/unit/test_mechanical.py +++ b/tests/unit/test_mechanical.py @@ -522,7 +522,7 @@ def test_filename_matches_pattern_unscoped_leaks(self, tmp_path: Path) -> None: assert "core-rules.md" in result.message def test_file_absent_scoped_ignores_root_readme(self, tmp_path: Path) -> None: - """Bug fix: CLAUDE:S:0001 — README.md at root should not trigger file_absent in skills.""" + """Bug fix: CORE:S:0035 — README.md at root should not trigger file_absent in skills.""" (tmp_path / "README.md").write_text("# Project readme") skills = tmp_path / ".claude" / "skills" / "test-skill" skills.mkdir(parents=True) diff --git a/tests/unit/test_registry.py b/tests/unit/test_registry.py index 5271513..c422879 100644 --- a/tests/unit/test_registry.py +++ b/tests/unit/test_registry.py @@ -259,7 +259,7 @@ class TestInferAgentFromRuleId: [ ("CORE:S:0001", ""), ("RRAILS:C:0003", ""), - ("CLAUDE:S:0001", "claude"), + ("CLAUDE:S:0004", "claude"), ("CODEX:S:0001", "codex"), ("COPILOT:S:0001", "copilot"), ("no-colon", ""), From bf878bf1bc034b5c75d1995d26917b368e5e59b8 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 6 May 2026 13:41:42 +0200 Subject: [PATCH 11/18] Update mcp/server.py explain-tool example to a current rule coordinate --- UNRELEASED.md | 2 +- src/reporails_cli/interfaces/mcp/server.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index adb98e9..20ea683 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -6,6 +6,6 @@ ### Fixed -- [core/classification]: Cross-agent rules with `match: {type: scoped_rule}` and `match: {type: skill}` now fire correctly. Agent configs use plural keys (`rules:`, `skills:`) for human readability while rule-side match expressions use the singular concept names; without aliasing, those rules silently never matched any file. A `_FILE_TYPE_MATCH_ALIASES` map applied at `ClassifiedFile` construction normalizes the surface key to the match vocabulary while preserving the literal key for `surfaces..` lookup. Bandage solution — the proper fix is to align vocabulary in one direction (either agent configs use singular keys or rule-side `match.type` uses plural). Tracked as a follow-up. +- [interfaces/mcp]: Updated `explain` tool example coordinate from `CLAUDE:S:0011` (promoted/renamed) to `CLAUDE:S:0005` so the MCP tool description references a current rule. ### Removed diff --git a/src/reporails_cli/interfaces/mcp/server.py b/src/reporails_cli/interfaces/mcp/server.py index 8677e32..8fbd9c4 100644 --- a/src/reporails_cli/interfaces/mcp/server.py +++ b/src/reporails_cli/interfaces/mcp/server.py @@ -101,7 +101,7 @@ async def list_tools() -> list[Tool]: description=( "Get details about a specific rule by ID." " Returns rule title, category, type, description, checks." - " Use full coordinate IDs (e.g., CORE:S:0005, CLAUDE:S:0011)." + " Use full coordinate IDs (e.g., CORE:S:0005, CLAUDE:S:0005)." ), inputSchema={ "type": "object", From bba13aaee5b82fa110c7a70f46499be995ba522a Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 6 May 2026 13:42:11 +0200 Subject: [PATCH 12/18] Rename copilot/applyto-scope-declared to path-scope-declared (family slug consistency) --- UNRELEASED.md | 4 ++-- .../checks.yml | 0 .../rule.md | 8 ++++---- .../fail/.github/instructions/python.instructions.md | 0 .../pass/.github/instructions/python.instructions.md | 0 5 files changed, 6 insertions(+), 6 deletions(-) rename framework/rules/copilot/{applyto-scope-declared => path-scope-declared}/checks.yml (100%) rename framework/rules/copilot/{applyto-scope-declared => path-scope-declared}/rule.md (85%) rename framework/rules/copilot/{applyto-scope-declared => path-scope-declared}/tests/fail/.github/instructions/python.instructions.md (100%) rename framework/rules/copilot/{applyto-scope-declared => path-scope-declared}/tests/pass/.github/instructions/python.instructions.md (100%) diff --git a/UNRELEASED.md b/UNRELEASED.md index 20ea683..3e64c79 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -4,8 +4,8 @@ ### Changed -### Fixed +- [framework/rules/copilot]: Renamed `applyto-scope-declared` to `path-scope-declared` for slug consistency with the cross-agent `path-scope-declared` family (Claude `paths:`, Cursor `globs:`, Copilot `applyTo:`). Rule body still describes Copilot's `applyTo:` mechanic; only the slug, title, and H1 heading change. -- [interfaces/mcp]: Updated `explain` tool example coordinate from `CLAUDE:S:0011` (promoted/renamed) to `CLAUDE:S:0005` so the MCP tool description references a current rule. +### Fixed ### Removed diff --git a/framework/rules/copilot/applyto-scope-declared/checks.yml b/framework/rules/copilot/path-scope-declared/checks.yml similarity index 100% rename from framework/rules/copilot/applyto-scope-declared/checks.yml rename to framework/rules/copilot/path-scope-declared/checks.yml diff --git a/framework/rules/copilot/applyto-scope-declared/rule.md b/framework/rules/copilot/path-scope-declared/rule.md similarity index 85% rename from framework/rules/copilot/applyto-scope-declared/rule.md rename to framework/rules/copilot/path-scope-declared/rule.md index 1d8cb13..8b45b77 100644 --- a/framework/rules/copilot/applyto-scope-declared/rule.md +++ b/framework/rules/copilot/path-scope-declared/rule.md @@ -1,7 +1,7 @@ --- id: COPILOT:S:0001 -slug: applyto-scope-declared -title: ApplyTo Scope Declared +slug: path-scope-declared +title: Path Scope Declared category: structure type: mechanical severity: high @@ -11,9 +11,9 @@ supersedes: CORE:S:0038 source: https://code.visualstudio.com/docs/copilot/customization/custom-instructions --- -# ApplyTo Scope Declared +# Path Scope Declared -Scoped `.github/copilot-instructions.md` files MUST include an `applyTo` field in their YAML frontmatter to declare which file patterns the instructions target. Without `applyTo`, Copilot applies the instructions globally, which defeats the purpose of scoped instruction files and can cause irrelevant guidance to appear in unrelated contexts. +Scoped `.github/copilot-instructions.md` files MUST include an `applyTo` field in their YAML frontmatter to declare which file patterns the instructions target. Without `applyTo`, Copilot applies the instructions globally, which defeats the purpose of scoped instruction files and can cause irrelevant guidance to appear in unrelated contexts. The slug aligns with the `path-scope-declared` family used by Claude (`paths:`) and Cursor (`globs:`); Copilot's frontmatter key is `applyTo:` per the VS Code Copilot docs. ## Antipatterns diff --git a/framework/rules/copilot/applyto-scope-declared/tests/fail/.github/instructions/python.instructions.md b/framework/rules/copilot/path-scope-declared/tests/fail/.github/instructions/python.instructions.md similarity index 100% rename from framework/rules/copilot/applyto-scope-declared/tests/fail/.github/instructions/python.instructions.md rename to framework/rules/copilot/path-scope-declared/tests/fail/.github/instructions/python.instructions.md diff --git a/framework/rules/copilot/applyto-scope-declared/tests/pass/.github/instructions/python.instructions.md b/framework/rules/copilot/path-scope-declared/tests/pass/.github/instructions/python.instructions.md similarity index 100% rename from framework/rules/copilot/applyto-scope-declared/tests/pass/.github/instructions/python.instructions.md rename to framework/rules/copilot/path-scope-declared/tests/pass/.github/instructions/python.instructions.md From 575c6daaee5edb2d86573abefd5ac0517cdf7fce Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 6 May 2026 13:42:56 +0200 Subject: [PATCH 13/18] Add compact wire payload + per-tier byte-size preflight to CLI --- UNRELEASED.md | 5 +- pyproject.toml | 1 + src/reporails_cli/core/api_client.py | 30 ++--- src/reporails_cli/core/funnel.py | 30 +++++ src/reporails_cli/core/payload.py | 128 ++++++++++++++++++++ tests/unit/test_api_client.py | 4 +- tests/unit/test_byte_preflight.py | 43 +++++++ tests/unit/test_payload.py | 171 +++++++++++++++++++++++++++ uv.lock | 46 +++++++ 9 files changed, 441 insertions(+), 17 deletions(-) create mode 100644 src/reporails_cli/core/payload.py create mode 100644 tests/unit/test_byte_preflight.py create mode 100644 tests/unit/test_payload.py diff --git a/UNRELEASED.md b/UNRELEASED.md index 3e64c79..c8b4ae8 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -2,9 +2,12 @@ ### Added +- [core/payload]: New `core/payload.py` module producing a compact msgpack-encoded wire payload with a leading version byte. Drops client-only diagnostic fields, packs binary embeddings as raw bytes (no base64 inflation), and replaces inline-token term lists with per-style counts. Real-data shrink 1.9–2.9× vs the legacy JSON path on monorepo-class fixtures (activepieces 1.9 MB → 1.0 MB), comfortably under the 2 MB anonymous tier byte cap. +- [core/funnel]: `WIRE_MAX_BYTES_BY_TIER` and `preflight_byte_size()` mirror the per-tier body cap so the CLI returns a clean local `payload_too_large` `FunnelError` before transmission instead of an opaque server-side 413. + ### Changed -- [framework/rules/copilot]: Renamed `applyto-scope-declared` to `path-scope-declared` for slug consistency with the cross-agent `path-scope-declared` family (Claude `paths:`, Cursor `globs:`, Copilot `applyTo:`). Rule body still describes Copilot's `applyTo:` mechanic; only the slug, title, and H1 heading change. +- [core/api_client]: `_lint_remote` now sends the compact wire format by default. Backend retains support for the legacy JSON path. ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 0029a4a..9c76f93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "spacy>=3.8.11,<4", # sklearn for topic clustering in mapper "scikit-learn>=1.4.0", + "msgpack>=1.0.0", ] [project.optional-dependencies] diff --git a/src/reporails_cli/core/api_client.py b/src/reporails_cli/core/api_client.py index d879374..1028328 100644 --- a/src/reporails_cli/core/api_client.py +++ b/src/reporails_cli/core/api_client.py @@ -222,44 +222,46 @@ def lint(self, ruleset_map: RulesetMap) -> LintResponse: return self._lint_remote(ruleset_map) def _lint_remote(self, ruleset_map: RulesetMap) -> LintResponse: - """POST text-stripped RulesetMap to diagnostic API. - - In production: CLI → Worker (/v1/diagnose, Bearer rr_*) → FastAPI. - In local dev (AILS_DEV_MODE): CLI → FastAPI directly (/diagnose, X-Tier header). - """ + """POST the projected RulesetMap to the diagnostic backend.""" try: import httpx except ImportError: logger.debug("httpx not installed — cannot use remote diagnostics") return LintResponse() - payload = _strip_and_serialize(ruleset_map) + from reporails_cli.core.payload import encode_msgpack, project_payload + + payload = project_payload(ruleset_map) if not payload.get("files"): - # No instruction files mapped — the Worker would reject with - # `missing_content_hash` (no content_hash to extract). Skip the - # round-trip; server diagnostics aren't meaningful without files. logger.warning("No instruction files in payload — skipping remote diagnostics") return LintResponse() cap_error = preflight_oversized(payload, has_api_key=bool(self.api_key)) if cap_error is not None: logger.warning("Preflight rejected payload: %s (%d/%d)", cap_error.error, cap_error.size, cap_error.limit) return LintResponse(funnel_error=cap_error) - return self._post_payload(httpx, payload) + body = encode_msgpack(payload) + from reporails_cli.core.funnel import preflight_byte_size + + byte_error = preflight_byte_size(len(body), has_api_key=bool(self.api_key)) + if byte_error is not None: + logger.warning("Preflight rejected payload bytes: %d > %d", byte_error.size, byte_error.limit) + return LintResponse(funnel_error=byte_error) + return self._post_payload(httpx, body) - def _post_payload(self, httpx: Any, payload: dict[str, Any]) -> LintResponse: + def _post_payload(self, httpx: Any, body: bytes) -> LintResponse: """Execute the HTTP round-trip; isolated so _lint_remote stays within return-count budget.""" dev_mode = os.environ.get("AILS_DEV_MODE", "").lower() in ("true", "1") if dev_mode: url = f"{self.base_url.rstrip('/')}/diagnose" - headers: dict[str, str] = {"X-Tier": self.tier} + headers: dict[str, str] = {"X-Tier": self.tier, "Content-Type": "application/msgpack"} else: url = f"{self.base_url.rstrip('/')}/v1/diagnose" - headers = {} + headers = {"Content-Type": "application/msgpack"} if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" try: - resp = httpx.post(url, json=payload, headers=headers, timeout=self.timeout) + resp = httpx.post(url, content=body, headers=headers, timeout=self.timeout) resp.raise_for_status() return LintResponse(result=_deserialize_lint_result(resp.json())) except httpx.TimeoutException: diff --git a/src/reporails_cli/core/funnel.py b/src/reporails_cli/core/funnel.py index c8d2974..d3bf0c8 100644 --- a/src/reporails_cli/core/funnel.py +++ b/src/reporails_cli/core/funnel.py @@ -16,6 +16,13 @@ WIRE_MAX_FILES = 500 WIRE_MAX_CLUSTERS = 2000 +# Per-tier body byte caps. Mirrored locally so preflight_byte_size returns +# a FunnelError before transmission instead of a server 4xx. +WIRE_MAX_BYTES_BY_TIER = { + "anonymous": 2 * 1024 * 1024, + "pro": 20 * 1024 * 1024, +} + @dataclass(frozen=True) class FunnelError: @@ -86,6 +93,29 @@ def parse_error_body(status_code: int, body_text: str) -> FunnelError | None: ) +def preflight_byte_size( + body_bytes: int, + has_api_key: bool, +) -> FunnelError | None: + """Reject the request when the encoded body exceeds the worker's per-tier cap. + + The backend enforces this cap before any processing. Catching it locally + surfaces the conversion CTA in the user's terminal instead of an opaque + server-side 413. + """ + presumed_tier = "pro" if has_api_key else "anonymous" + limit = WIRE_MAX_BYTES_BY_TIER.get(presumed_tier, WIRE_MAX_BYTES_BY_TIER["anonymous"]) + if body_bytes <= limit: + return None + return FunnelError( + error="payload_too_large", + tier=presumed_tier, + limit=limit, + size=body_bytes, + upgrade_url=_preflight_url("payload_too_large", presumed_tier), + ) + + def preflight_oversized( payload: dict[str, Any], has_api_key: bool, diff --git a/src/reporails_cli/core/payload.py b/src/reporails_cli/core/payload.py new file mode 100644 index 0000000..a446b9e --- /dev/null +++ b/src/reporails_cli/core/payload.py @@ -0,0 +1,128 @@ +"""Wire schema v3 — compact projection for HTTP transport. + +v3 prefixes a single version byte and packs binary fields as raw bytes +via msgpack ``bin``. The legacy v2 path remains supported by the backend. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import msgpack + +from reporails_cli.core.api_client import ( + _CHARGE_ENC, + _FORMAT_ENC, + _KIND_ENC, + _MODALITY_ENC, + _SPECIFICITY_ENC, +) + +if TYPE_CHECKING: + from reporails_cli.core.mapper.mapper import RulesetMap + +WIRE_SCHEMA_VERSION_V3 = 3 + + +def _project_atom(a: Any, file_idx: dict[str, int]) -> dict[str, Any]: + """Project a single Atom to the v3 wire shape.""" + d: dict[str, Any] = { + "line": a.line, + "t": _KIND_ENC.get(a.kind, 1), + "c": _CHARGE_ENC.get(a.charge, 3), + "cv": a.charge_value, + "m": _MODALITY_ENC.get(a.modality, 4), + "s": _SPECIFICITY_ENC.get(a.specificity, 1), + "sc": a.scope_conditional, + "f": _FORMAT_ENC.get(a.format, 0), + "pi": a.position_index, + "tc": a.token_count, + "fi": file_idx.get(a.file_path, -1), + "k": a.cluster_id, + "nb": len(a.named_tokens) if a.named_tokens else 0, + "ib": len(a.italic_tokens) if a.italic_tokens else 0, + "bb": len(a.bold_tokens) if a.bold_tokens else 0, + "ub": len(a.unformatted_code) if a.unformatted_code else 0, + } + if a.embedding_int8 is not None: + d["e"] = bytes(v & 0xFF for v in a.embedding_int8) + if a.depth is not None: + d["d"] = a.depth + if a.ambiguous: + d["a"] = True + if a.embedded_charge_markers: + d["ecm"] = list(a.embedded_charge_markers) + return d + + +def _project_files(ruleset_map: RulesetMap) -> list[dict[str, Any]]: + """Project file records to v3.""" + out: list[dict[str, Any]] = [] + for f in ruleset_map.files: + fd: dict[str, Any] = { + "path": f.path, + "content_hash": f.content_hash, + "loading": f.loading, + "scope": f.scope, + "agent": f.agent, + } + if f.globs: + fd["globs"] = list(f.globs) + if f.description: + fd["description"] = f.description + if f.description_embedding: + fd["de"] = bytes(v & 0xFF for v in f.description_embedding) + out.append(fd) + return out + + +def _project_clusters(ruleset_map: RulesetMap) -> list[dict[str, Any]]: + """Project cluster records to v3.""" + return [ + {"id": c.id, "n_atoms": c.n_atoms, "n_charged": c.n_charged, "n_neutral": c.n_neutral} + for c in ruleset_map.clusters + ] + + +def project_payload(ruleset_map: RulesetMap) -> dict[str, Any]: + """Build the v3 payload dict (pre-encoding).""" + file_idx = {f.path: i for i, f in enumerate(ruleset_map.files)} + return { + "schema_version": "3", + "embedding_model": ruleset_map.embedding_model, + "generated_at": ruleset_map.generated_at, + "files": _project_files(ruleset_map), + "atoms": [_project_atom(a, file_idx) for a in ruleset_map.atoms], + "clusters": _project_clusters(ruleset_map), + "summary": { + "n_atoms": ruleset_map.summary.n_atoms, + "n_charged": ruleset_map.summary.n_charged, + "n_neutral": ruleset_map.summary.n_neutral, + "n_topics": ruleset_map.summary.n_topics, + "n_topics_charged": ruleset_map.summary.n_topics_charged, + }, + } + + +def encode_msgpack(payload: dict[str, Any]) -> bytes: + """Encode the v3 payload as msgpack with a leading version byte.""" + encoded = msgpack.packb(payload, use_bin_type=True) + if not isinstance(encoded, bytes): + raise RuntimeError(f"msgpack.packb returned {type(encoded).__name__}, expected bytes") + return bytes([WIRE_SCHEMA_VERSION_V3]) + encoded + + +def serialize(ruleset_map: RulesetMap) -> bytes: + """Convenience: project + encode in one call.""" + return encode_msgpack(project_payload(ruleset_map)) + + +def estimated_byte_size(ruleset_map: RulesetMap) -> int: + """Cheap upper-bound estimate of the encoded body size.""" + n_atoms = len(ruleset_map.atoms) if ruleset_map.atoms else 0 + n_files = len(ruleset_map.files) if ruleset_map.files else 0 + has_emb_atoms = sum(1 for a in ruleset_map.atoms if a.embedding_int8 is not None) + atom_bytes = n_atoms * 120 + has_emb_atoms * 386 + has_emb_files = sum(1 for f in ruleset_map.files if f.description_embedding) + file_bytes = n_files * 80 + has_emb_files * 386 + return atom_bytes + file_bytes + 1024 diff --git a/tests/unit/test_api_client.py b/tests/unit/test_api_client.py index b8359d2..35606d4 100644 --- a/tests/unit/test_api_client.py +++ b/tests/unit/test_api_client.py @@ -222,7 +222,7 @@ def test_lint_skips_http_when_over_cap(self) -> None: summary=RulesetSummary(n_atoms=0, n_charged=0, n_neutral=0), ) oversized = { - "schema_version": "2", + "schema_version": "3", "embedding_model": "test", "generated_at": "2026-01-01T00:00:00Z", "files": [{}], # one file so the empty-payload guard doesn't fire first @@ -231,7 +231,7 @@ def test_lint_skips_http_when_over_cap(self) -> None: "summary": {"n_atoms": 0, "n_charged": 0, "n_neutral": 0, "n_topics": 0, "n_topics_charged": 0}, } with ( - patch("reporails_cli.core.api_client._strip_and_serialize", return_value=oversized), + patch("reporails_cli.core.payload.project_payload", return_value=oversized), patch("httpx.post") as mock_post, ): client = AilsClient(base_url="https://example.test", tier="pro") diff --git a/tests/unit/test_byte_preflight.py b/tests/unit/test_byte_preflight.py new file mode 100644 index 0000000..07945ea --- /dev/null +++ b/tests/unit/test_byte_preflight.py @@ -0,0 +1,43 @@ +"""Unit tests for the local byte-size preflight.""" + +from __future__ import annotations + +from reporails_cli.core.funnel import ( + WIRE_MAX_BYTES_BY_TIER, + FunnelError, + preflight_byte_size, +) + + +def test_under_cap_returns_none() -> None: + assert preflight_byte_size(1024, has_api_key=False) is None + + +def test_anonymous_at_cap_passes() -> None: + cap = WIRE_MAX_BYTES_BY_TIER["anonymous"] + assert preflight_byte_size(cap, has_api_key=False) is None + + +def test_anonymous_over_cap_returns_funnel_error() -> None: + cap = WIRE_MAX_BYTES_BY_TIER["anonymous"] + err = preflight_byte_size(cap + 1, has_api_key=False) + assert isinstance(err, FunnelError) + assert err.error == "payload_too_large" + assert err.tier == "anonymous" + assert err.limit == cap + assert err.size == cap + 1 + + +def test_keyed_cap_higher_than_anonymous() -> None: + keyed_cap = WIRE_MAX_BYTES_BY_TIER["pro"] + anon_cap = WIRE_MAX_BYTES_BY_TIER["anonymous"] + assert keyed_cap > anon_cap + assert preflight_byte_size(anon_cap + 1, has_api_key=True) is None + + +def test_keyed_over_cap_returns_funnel_error() -> None: + keyed_cap = WIRE_MAX_BYTES_BY_TIER["pro"] + err = preflight_byte_size(keyed_cap + 1, has_api_key=True) + assert isinstance(err, FunnelError) + assert err.tier == "pro" + assert err.limit == keyed_cap diff --git a/tests/unit/test_payload.py b/tests/unit/test_payload.py new file mode 100644 index 0000000..8915896 --- /dev/null +++ b/tests/unit/test_payload.py @@ -0,0 +1,171 @@ +"""Unit tests for the wire payload module.""" + +from __future__ import annotations + +import json + +import msgpack +import pytest + +from reporails_cli.core.api_client import _strip_and_serialize +from reporails_cli.core.mapper.mapper import ( + Atom, + ClusterRecord, + FileRecord, + RulesetMap, + RulesetSummary, +) +from reporails_cli.core.payload import ( + WIRE_SCHEMA_VERSION_V3, + encode_msgpack, + project_payload, +) + + +def _atom(idx: int, charge: int = 1, has_emb: bool = True) -> Atom: + return Atom( + line=idx, + text="", + plain_text="", + kind="excitation", + charge="DIRECTIVE" if charge > 0 else "CONSTRAINT" if charge < 0 else "NEUTRAL", + charge_value=charge, + modality="imperative", + specificity="named", + scope_conditional=False, + format="prose", + named_tokens=("foo",) if charge else (), + italic_tokens=("never",) if charge < 0 else (), + bold_tokens=(), + unformatted_code=(), + position_index=idx % 10, + token_count=8, + file_path="CLAUDE.md", + cluster_id=idx % 5, + embedding_int8=tuple(((i + idx) % 256) - 128 for i in range(384)) if has_emb else None, + heading_context="A" * 200, + depth=2, + ambiguous=False, + embedded_charge_markers=(), + ) + + +def _ruleset(n_atoms: int = 10, n_files: int = 1, n_clusters: int = 3) -> RulesetMap: + files = tuple( + FileRecord( + path=f"f{i}/CLAUDE.md", + content_hash="sha256:" + "a" * 64, + loading="session_start", + scope="global", + agent="claude", + description="desc", + description_embedding=tuple((j % 256) - 128 for j in range(384)), + ) + for i in range(n_files) + ) + atoms = tuple(_atom(i, (i % 3) - 1) for i in range(n_atoms)) + clusters = tuple( + ClusterRecord( + id=i, n_atoms=4, n_charged=2, n_neutral=2, centroid=tuple(((i * 7 + j) % 1000) / 1000.0 for j in range(384)) + ) + for i in range(n_clusters) + ) + return RulesetMap( + schema_version="2", + embedding_model="all-MiniLM-L6-v2", + generated_at="2026-05-06T00:00:00+00:00", + files=files, + atoms=atoms, + clusters=clusters, + summary=RulesetSummary( + n_atoms=n_atoms, + n_charged=n_atoms // 2, + n_neutral=n_atoms // 2, + n_topics=n_clusters, + n_topics_charged=n_clusters // 2, + ), + ) + + +class TestProjectionShape: + def test_text_fields_dropped(self) -> None: + rm = _ruleset(n_atoms=2) + proj = project_payload(rm) + for atom in proj["atoms"]: + assert "text" not in atom + assert "plain_text" not in atom + assert "heading_context" not in atom + assert "hc" not in atom + + def test_inline_tokens_become_counts(self) -> None: + rm = _ruleset(n_atoms=3) + proj = project_payload(rm) + for atom in proj["atoms"]: + assert "il" not in atom + assert isinstance(atom.get("nb"), int) + assert isinstance(atom.get("ib"), int) + assert isinstance(atom.get("bb"), int) + assert isinstance(atom.get("ub"), int) + + def test_inline_counts_match_source(self) -> None: + rm = _ruleset(n_atoms=4) + proj = project_payload(rm) + for src, atom in zip(rm.atoms, proj["atoms"], strict=True): + assert atom["nb"] == len(src.named_tokens) + assert atom["ib"] == len(src.italic_tokens) + assert atom["bb"] == len(src.bold_tokens) + assert atom["ub"] == len(src.unformatted_code) + + def test_cluster_centroids_dropped(self) -> None: + rm = _ruleset(n_clusters=5) + proj = project_payload(rm) + for cluster in proj["clusters"]: + assert "centroid" not in cluster + assert "centroid_b64" not in cluster + assert "ce" not in cluster + + def test_embedding_packed_as_bytes(self) -> None: + rm = _ruleset(n_atoms=1) + proj = project_payload(rm) + atom = proj["atoms"][0] + assert isinstance(atom["e"], bytes) + assert len(atom["e"]) == 384 + + def test_schema_version_is_3(self) -> None: + rm = _ruleset() + proj = project_payload(rm) + assert proj["schema_version"] == "3" + + +class TestEncoding: + def test_leading_version_byte(self) -> None: + rm = _ruleset(n_atoms=1) + encoded = encode_msgpack(project_payload(rm)) + assert encoded[0] == WIRE_SCHEMA_VERSION_V3 == 3 + + def test_round_trip_decode(self) -> None: + rm = _ruleset(n_atoms=2, n_files=1, n_clusters=1) + proj = project_payload(rm) + encoded = encode_msgpack(proj) + decoded = msgpack.unpackb(encoded[1:], raw=False) + assert decoded["schema_version"] == "3" + assert len(decoded["atoms"]) == len(proj["atoms"]) + assert len(decoded["files"]) == len(proj["files"]) + assert decoded["atoms"][0]["e"] == proj["atoms"][0]["e"] + + +class TestShrinkage: + @pytest.mark.parametrize( + "n_atoms,n_files,n_clusters,min_shrink", + [ + (50, 5, 10, 1.5), + (200, 10, 30, 1.5), + (1000, 30, 100, 1.5), + ], + ) + def test_smaller_than_legacy(self, n_atoms: int, n_files: int, n_clusters: int, min_shrink: float) -> None: + rm = _ruleset(n_atoms=n_atoms, n_files=n_files, n_clusters=n_clusters) + legacy_bytes = len(json.dumps(_strip_and_serialize(rm)).encode("utf-8")) + new_bytes = len(encode_msgpack(project_payload(rm))) + assert new_bytes < legacy_bytes + assert legacy_bytes / new_bytes >= min_shrink diff --git a/uv.lock b/uv.lock index 5153395..416af48 100644 --- a/uv.lock +++ b/uv.lock @@ -1170,6 +1170,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + [[package]] name = "murmurhash" version = "1.0.15" @@ -1940,6 +1984,7 @@ dependencies = [ { name = "httpx" }, { name = "markdown-it-py" }, { name = "mcp" }, + { name = "msgpack" }, { name = "numpy" }, { name = "onnxruntime" }, { name = "packaging" }, @@ -1987,6 +2032,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "markdown-it-py", specifier = ">=3.0.0" }, { name = "mcp", specifier = ">=1.0.0" }, + { name = "msgpack", specifier = ">=1.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" }, { name = "numpy", specifier = ">=1.26,<3" }, { name = "onnxruntime", specifier = ">=1.18,<2" }, From 3c6e76f9db4ee9bc662698e3ad08c15cfdb3997f Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 6 May 2026 13:44:56 +0200 Subject: [PATCH 14/18] Consolidate UNRELEASED entries from this batch and sanitize wire-payload descriptions --- UNRELEASED.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index c8b4ae8..e6dac5f 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -2,13 +2,25 @@ ### Added -- [core/payload]: New `core/payload.py` module producing a compact msgpack-encoded wire payload with a leading version byte. Drops client-only diagnostic fields, packs binary embeddings as raw bytes (no base64 inflation), and replaces inline-token term lists with per-style counts. Real-data shrink 1.9–2.9× vs the legacy JSON path on monorepo-class fixtures (activepieces 1.9 MB → 1.0 MB), comfortably under the 2 MB anonymous tier byte cap. -- [core/funnel]: `WIRE_MAX_BYTES_BY_TIER` and `preflight_byte_size()` mirror the per-tier body cap so the CLI returns a clean local `payload_too_large` `FunnelError` before transmission instead of an opaque server-side 413. +- [core/payload]: New `core/payload.py` module producing a compact wire payload for HTTP transport. Reduces request body size on large projects. +- [core/funnel]: New `WIRE_MAX_BYTES_BY_TIER` table and `preflight_byte_size()` function. Local preflight returns a `payload_too_large` `FunnelError` before transmission instead of an opaque server-side 4xx. ### Changed -- [core/api_client]: `_lint_remote` now sends the compact wire format by default. Backend retains support for the legacy JSON path. +- [framework/rules]: Promoted `skill-name-matches-directory` to a cross-agent rule (CORE:S:0036). Skill `name` field must be kebab-case across every agent that loads `SKILL.md` entry points. +- [framework/rules]: Promoted `skill-no-readme` to a cross-agent rule (CORE:S:0035). Skill directories must keep all documentation in `SKILL.md` — a sibling `README.md` is never loaded. +- [framework/rules]: Promoted `skill-description-length` to a cross-agent rule (CORE:S:0040). The `description` field must be present in skill frontmatter; the open standard caps it at 1024 characters, with agent-specific caps acknowledged in the rule body. +- [framework/rules]: Promoted `import-depth-within-limit` to a cross-agent rule (CORE:S:0033) following the path-scope-declared supersede pattern. CORE carries a permissive absolute ceiling (max 10) as a sanity check; CLAUDE:S:0010 supersedes with Claude's documented 5-hop `@import` hard limit; CURSOR:S:0002 supersedes with `max: 1` reflecting Cursor's single-level `@filename` model. Codex and Copilot declare `CORE:S:0033` under `excludes:` in their `config.yml` because their instruction files do not honor `@` syntax. Gemini inherits the CORE ceiling unchanged. +- [framework/rules/claude]: Renamed `memory-file-within-200-lines` to `memory-file-within-size-limit` (`CLAUDE:S:0011`) — slug no longer embeds the line number, since the threshold is fundamentally agent-defined. Stays in the CLAUDE namespace: Claude is the only agent with a dedicated `MEMORY.md` file the rule's `match: {type: memory}` can check (Gemini's memory is a section in `GEMINI.md`; Copilot's is system-managed with a 28-day TTL; Codex has none; Cursor's mechanic is undocumented). Promotion to CORE was reverted — it was forward-looking but in practice would have only fired on Claude. +- [framework/rules/claude]: Raised `rule-snippet-length` (`CLAUDE:S:0009`) threshold from 100 to 200 lines and dropped severity from `medium` to `low`. Added `see_also: [CORE:C:0044, CORE:S:0019]` cross-references — when a rule file follows topic-scatter and single-topic-per-section, 200 lines is comfortably enough. +- [framework/rules/copilot]: Renamed `applyto-scope-declared` to `path-scope-declared` for slug consistency with the cross-agent `path-scope-declared` family (Claude `paths:`, Cursor `globs:`, Copilot `applyTo:`). Rule body still describes Copilot's `applyTo:` mechanic; only the slug, title, and H1 heading change. +- [core/api_client]: `_lint_remote` now sends the compact wire format by default. ### Fixed +- [core/classification]: Cross-agent rules with `match: {type: scoped_rule}` and `match: {type: skill}` now fire correctly. Agent configs use plural keys (`rules:`, `skills:`) for human readability while rule-side match expressions use the singular concept names; without aliasing, those rules silently never matched any file. A `_FILE_TYPE_MATCH_ALIASES` map applied at `ClassifiedFile` construction normalizes the surface key to the match vocabulary while preserving the literal key for `surfaces..` lookup. Bandage solution — the proper fix is to align vocabulary in one direction (either agent configs use singular keys or rule-side `match.type` uses plural). Tracked as a follow-up. +- [core/agent_discovery]: `surfaces...exclude` patterns now apply across every surface of the agent, not just the surface they were declared on. Two surfaces of the same agent commonly share patterns (e.g. `cursor.rules` and `cursor.bugbot_rules` both glob `.cursor/rules/**/*.mdc`) — declaring an exclude on one previously left the file surfaced from the other. Discovery now collects the union of all per-surface excludes for the agent and applies it once per surface. +- [formatters/text/scorecard]: `compute_surface_scores` relativizes `ruleset_map.files[*].path` against the project root before classification. Absolute paths from the mapper were being tagged `nested` purely because their leading filesystem components inflated the `parts` count, so a project with one root-level `CLAUDE.md` was rendered as `Main (1) ... Nested (1)`. Findings (which already carry relative paths) and the mapper's file list now classify consistently. +- [interfaces/mcp]: Updated `explain` tool example coordinate from `CLAUDE:S:0011` (promoted/renamed) to `CLAUDE:S:0005` so the MCP tool description references a current rule. + ### Removed From 1a584eebc73c541d992e71831da267b6709c45e0 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 6 May 2026 13:48:24 +0200 Subject: [PATCH 15/18] Switch skill-directory-kebab-case source URL to agentskills.io for consistency --- UNRELEASED.md | 1 + framework/rules/core/skill-directory-kebab-case/rule.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index e6dac5f..fb7dcdc 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -14,6 +14,7 @@ - [framework/rules/claude]: Renamed `memory-file-within-200-lines` to `memory-file-within-size-limit` (`CLAUDE:S:0011`) — slug no longer embeds the line number, since the threshold is fundamentally agent-defined. Stays in the CLAUDE namespace: Claude is the only agent with a dedicated `MEMORY.md` file the rule's `match: {type: memory}` can check (Gemini's memory is a section in `GEMINI.md`; Copilot's is system-managed with a 28-day TTL; Codex has none; Cursor's mechanic is undocumented). Promotion to CORE was reverted — it was forward-looking but in practice would have only fired on Claude. - [framework/rules/claude]: Raised `rule-snippet-length` (`CLAUDE:S:0009`) threshold from 100 to 200 lines and dropped severity from `medium` to `low`. Added `see_also: [CORE:C:0044, CORE:S:0019]` cross-references — when a rule file follows topic-scatter and single-topic-per-section, 200 lines is comfortably enough. - [framework/rules/copilot]: Renamed `applyto-scope-declared` to `path-scope-declared` for slug consistency with the cross-agent `path-scope-declared` family (Claude `paths:`, Cursor `globs:`, Copilot `applyTo:`). Rule body still describes Copilot's `applyTo:` mechanic; only the slug, title, and H1 heading change. +- [framework/rules/core]: Switched the `source:` URL for the three cross-agent skill rules (`skill-no-readme`, `skill-name-matches-directory`, `skill-directory-kebab-case`) from `code.claude.com/docs/en/skills` to `agentskills.io/specification`. The open standard is the canonical source for skill conventions; Claude's docs reflect the same conventions but aren't the universal reference. - [core/api_client]: `_lint_remote` now sends the compact wire format by default. ### Fixed diff --git a/framework/rules/core/skill-directory-kebab-case/rule.md b/framework/rules/core/skill-directory-kebab-case/rule.md index 1b145df..3b4e036 100644 --- a/framework/rules/core/skill-directory-kebab-case/rule.md +++ b/framework/rules/core/skill-directory-kebab-case/rule.md @@ -7,7 +7,7 @@ type: deterministic severity: medium backed_by: [] match: {type: skill} -source: https://code.claude.com/docs/en/skills +source: https://agentskills.io/specification --- # Skill Directory Kebab Case From b253f8da6e21bd8804d57a460180e1678378f0ad Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 6 May 2026 13:50:15 +0200 Subject: [PATCH 16/18] Bump version to 0.5.8 --- README.md | 2 +- packages/npm/package.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 91dd201..0eb9c79 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Reporails CLI (v0.5.7) +# Reporails CLI (v0.5.8) > **AI Instruction Diagnostics for coding agents. Validates the entire agentic instruction system against 92+ rules across six categories. Supports Claude, Codex, Copilot, Cursor, and Gemini.** > diff --git a/packages/npm/package.json b/packages/npm/package.json index e0ba367..dd0c9f6 100644 --- a/packages/npm/package.json +++ b/packages/npm/package.json @@ -1,6 +1,6 @@ { "name": "@reporails/cli", - "version": "0.5.7", + "version": "0.5.8", "description": "AI instruction diagnostics for coding agents", "type": "module", "bin": { diff --git a/pyproject.toml b/pyproject.toml index 9c76f93..5662356 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "reporails-cli" -version = "0.5.7" +version = "0.5.8" description = "AI instruction diagnostics for coding agents" readme = "README.md" license = "BUSL-1.1" diff --git a/uv.lock b/uv.lock index 416af48..7e27cd5 100644 --- a/uv.lock +++ b/uv.lock @@ -1978,7 +1978,7 @@ wheels = [ [[package]] name = "reporails-cli" -version = "0.5.7" +version = "0.5.8" source = { editable = "." } dependencies = [ { name = "httpx" }, From b65811ccdf77fd948c0d18a1bf2bb1ab0ff8babe Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 6 May 2026 16:02:18 +0200 Subject: [PATCH 17/18] Release 0.5.8: move UNRELEASED entries to CHANGELOG.md and make mapper daemon idle timeout opt-in --- CHANGELOG.md | 27 +++++++++++++++++++++++++ UNRELEASED.md | 18 ----------------- src/reporails_cli/core/mapper/daemon.py | 18 ++++++++++------- 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b2c2d9..8e4dfda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 0.5.8 + +### Added + +- [core/payload]: New `core/payload.py` module producing a compact wire payload for HTTP transport. Reduces request body size on large projects. +- [core/funnel]: New `WIRE_MAX_BYTES_BY_TIER` table and `preflight_byte_size()` function. Local preflight returns a `payload_too_large` `FunnelError` before transmission instead of an opaque server-side 4xx. + +### Changed + +- [framework/rules]: Promoted `skill-name-matches-directory` to a cross-agent rule (CORE:S:0036). Skill `name` field must be kebab-case across every agent that loads `SKILL.md` entry points. +- [framework/rules]: Promoted `skill-no-readme` to a cross-agent rule (CORE:S:0035). Skill directories must keep all documentation in `SKILL.md` — a sibling `README.md` is never loaded. +- [framework/rules]: Promoted `skill-description-length` to a cross-agent rule (CORE:S:0040). The `description` field must be present in skill frontmatter; the open standard caps it at 1024 characters, with agent-specific caps acknowledged in the rule body. +- [framework/rules]: Promoted `import-depth-within-limit` to a cross-agent rule (CORE:S:0033) following the path-scope-declared supersede pattern. CORE carries a permissive absolute ceiling (max 10) as a sanity check; CLAUDE:S:0010 supersedes with Claude's documented 5-hop `@import` hard limit; CURSOR:S:0002 supersedes with `max: 1` reflecting Cursor's single-level `@filename` model. Codex and Copilot declare `CORE:S:0033` under `excludes:` in their `config.yml` because their instruction files do not honor `@` syntax. Gemini inherits the CORE ceiling unchanged. +- [framework/rules/claude]: Renamed `memory-file-within-200-lines` to `memory-file-within-size-limit` (`CLAUDE:S:0011`) — slug no longer embeds the line number, since the threshold is fundamentally agent-defined. Stays in the CLAUDE namespace: Claude is the only agent with a dedicated `MEMORY.md` file the rule's `match: {type: memory}` can check (Gemini's memory is a section in `GEMINI.md`; Copilot's is system-managed with a 28-day TTL; Codex has none; Cursor's mechanic is undocumented). Promotion to CORE was reverted — it was forward-looking but in practice would have only fired on Claude. +- [framework/rules/claude]: Raised `rule-snippet-length` (`CLAUDE:S:0009`) threshold from 100 to 200 lines and dropped severity from `medium` to `low`. Added `see_also: [CORE:C:0044, CORE:S:0019]` cross-references — when a rule file follows topic-scatter and single-topic-per-section, 200 lines is comfortably enough. +- [framework/rules/copilot]: Renamed `applyto-scope-declared` to `path-scope-declared` for slug consistency with the cross-agent `path-scope-declared` family (Claude `paths:`, Cursor `globs:`, Copilot `applyTo:`). Rule body still describes Copilot's `applyTo:` mechanic; only the slug, title, and H1 heading change. +- [framework/rules/core]: Switched the `source:` URL for the three cross-agent skill rules (`skill-no-readme`, `skill-name-matches-directory`, `skill-directory-kebab-case`) from `code.claude.com/docs/en/skills` to `agentskills.io/specification`. The open standard is the canonical source for skill conventions; Claude's docs reflect the same conventions but aren't the universal reference. +- [core/api_client]: `_lint_remote` now sends the compact wire format by default. + +### Fixed + +- [core/classification]: Cross-agent rules with `match: {type: scoped_rule}` and `match: {type: skill}` now fire correctly. Agent configs use plural keys (`rules:`, `skills:`) for human readability while rule-side match expressions use the singular concept names; without aliasing, those rules silently never matched any file. A `_FILE_TYPE_MATCH_ALIASES` map applied at `ClassifiedFile` construction normalizes the surface key to the match vocabulary while preserving the literal key for `surfaces..` lookup. Bandage solution — the proper fix is to align vocabulary in one direction (either agent configs use singular keys or rule-side `match.type` uses plural). Tracked as a follow-up. +- [core/agent_discovery]: `surfaces...exclude` patterns now apply across every surface of the agent, not just the surface they were declared on. Two surfaces of the same agent commonly share patterns (e.g. `cursor.rules` and `cursor.bugbot_rules` both glob `.cursor/rules/**/*.mdc`) — declaring an exclude on one previously left the file surfaced from the other. Discovery now collects the union of all per-surface excludes for the agent and applies it once per surface. +- [formatters/text/scorecard]: `compute_surface_scores` relativizes `ruleset_map.files[*].path` against the project root before classification. Absolute paths from the mapper were being tagged `nested` purely because their leading filesystem components inflated the `parts` count, so a project with one root-level `CLAUDE.md` was rendered as `Main (1) ... Nested (1)`. Findings (which already carry relative paths) and the mapper's file list now classify consistently. +- [interfaces/mcp]: Updated `explain` tool example coordinate from `CLAUDE:S:0011` (promoted/renamed) to `CLAUDE:S:0005` so the MCP tool description references a current rule. +- [core/mapper/daemon]: Mapper daemon's 1-hour idle timeout is now opt-in via the `AILS_DAEMON_IDLE_S` env var instead of applied by default. Without the override the daemon stays running until `ails daemon stop` or an explicit kill — matching the user expectation that "background" means "doesn't go away on its own". The previous 1-hour default caused the daemon to terminate between dev sessions, so each subsequent `ails check` paid the cold-start cost. + ## 0.5.7 ### Added diff --git a/UNRELEASED.md b/UNRELEASED.md index fb7dcdc..e93f97c 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -2,26 +2,8 @@ ### Added -- [core/payload]: New `core/payload.py` module producing a compact wire payload for HTTP transport. Reduces request body size on large projects. -- [core/funnel]: New `WIRE_MAX_BYTES_BY_TIER` table and `preflight_byte_size()` function. Local preflight returns a `payload_too_large` `FunnelError` before transmission instead of an opaque server-side 4xx. - ### Changed -- [framework/rules]: Promoted `skill-name-matches-directory` to a cross-agent rule (CORE:S:0036). Skill `name` field must be kebab-case across every agent that loads `SKILL.md` entry points. -- [framework/rules]: Promoted `skill-no-readme` to a cross-agent rule (CORE:S:0035). Skill directories must keep all documentation in `SKILL.md` — a sibling `README.md` is never loaded. -- [framework/rules]: Promoted `skill-description-length` to a cross-agent rule (CORE:S:0040). The `description` field must be present in skill frontmatter; the open standard caps it at 1024 characters, with agent-specific caps acknowledged in the rule body. -- [framework/rules]: Promoted `import-depth-within-limit` to a cross-agent rule (CORE:S:0033) following the path-scope-declared supersede pattern. CORE carries a permissive absolute ceiling (max 10) as a sanity check; CLAUDE:S:0010 supersedes with Claude's documented 5-hop `@import` hard limit; CURSOR:S:0002 supersedes with `max: 1` reflecting Cursor's single-level `@filename` model. Codex and Copilot declare `CORE:S:0033` under `excludes:` in their `config.yml` because their instruction files do not honor `@` syntax. Gemini inherits the CORE ceiling unchanged. -- [framework/rules/claude]: Renamed `memory-file-within-200-lines` to `memory-file-within-size-limit` (`CLAUDE:S:0011`) — slug no longer embeds the line number, since the threshold is fundamentally agent-defined. Stays in the CLAUDE namespace: Claude is the only agent with a dedicated `MEMORY.md` file the rule's `match: {type: memory}` can check (Gemini's memory is a section in `GEMINI.md`; Copilot's is system-managed with a 28-day TTL; Codex has none; Cursor's mechanic is undocumented). Promotion to CORE was reverted — it was forward-looking but in practice would have only fired on Claude. -- [framework/rules/claude]: Raised `rule-snippet-length` (`CLAUDE:S:0009`) threshold from 100 to 200 lines and dropped severity from `medium` to `low`. Added `see_also: [CORE:C:0044, CORE:S:0019]` cross-references — when a rule file follows topic-scatter and single-topic-per-section, 200 lines is comfortably enough. -- [framework/rules/copilot]: Renamed `applyto-scope-declared` to `path-scope-declared` for slug consistency with the cross-agent `path-scope-declared` family (Claude `paths:`, Cursor `globs:`, Copilot `applyTo:`). Rule body still describes Copilot's `applyTo:` mechanic; only the slug, title, and H1 heading change. -- [framework/rules/core]: Switched the `source:` URL for the three cross-agent skill rules (`skill-no-readme`, `skill-name-matches-directory`, `skill-directory-kebab-case`) from `code.claude.com/docs/en/skills` to `agentskills.io/specification`. The open standard is the canonical source for skill conventions; Claude's docs reflect the same conventions but aren't the universal reference. -- [core/api_client]: `_lint_remote` now sends the compact wire format by default. - ### Fixed -- [core/classification]: Cross-agent rules with `match: {type: scoped_rule}` and `match: {type: skill}` now fire correctly. Agent configs use plural keys (`rules:`, `skills:`) for human readability while rule-side match expressions use the singular concept names; without aliasing, those rules silently never matched any file. A `_FILE_TYPE_MATCH_ALIASES` map applied at `ClassifiedFile` construction normalizes the surface key to the match vocabulary while preserving the literal key for `surfaces..` lookup. Bandage solution — the proper fix is to align vocabulary in one direction (either agent configs use singular keys or rule-side `match.type` uses plural). Tracked as a follow-up. -- [core/agent_discovery]: `surfaces...exclude` patterns now apply across every surface of the agent, not just the surface they were declared on. Two surfaces of the same agent commonly share patterns (e.g. `cursor.rules` and `cursor.bugbot_rules` both glob `.cursor/rules/**/*.mdc`) — declaring an exclude on one previously left the file surfaced from the other. Discovery now collects the union of all per-surface excludes for the agent and applies it once per surface. -- [formatters/text/scorecard]: `compute_surface_scores` relativizes `ruleset_map.files[*].path` against the project root before classification. Absolute paths from the mapper were being tagged `nested` purely because their leading filesystem components inflated the `parts` count, so a project with one root-level `CLAUDE.md` was rendered as `Main (1) ... Nested (1)`. Findings (which already carry relative paths) and the mapper's file list now classify consistently. -- [interfaces/mcp]: Updated `explain` tool example coordinate from `CLAUDE:S:0011` (promoted/renamed) to `CLAUDE:S:0005` so the MCP tool description references a current rule. - ### Removed diff --git a/src/reporails_cli/core/mapper/daemon.py b/src/reporails_cli/core/mapper/daemon.py index 739284f..74ff70a 100644 --- a/src/reporails_cli/core/mapper/daemon.py +++ b/src/reporails_cli/core/mapper/daemon.py @@ -24,10 +24,13 @@ logger = logging.getLogger(__name__) -# Idle timeout defaults to 1 hour; override with AILS_DAEMON_IDLE_S env var -# (e.g. AILS_DAEMON_IDLE_S=5 for fast integration tests, or a large number -# for long dev loops). -_IDLE_TIMEOUT_S = int(os.environ.get("AILS_DAEMON_IDLE_S", "3600")) +# Idle shutdown is opt-in via AILS_DAEMON_IDLE_S env var (seconds). Without +# the override, the daemon runs in the background until explicitly stopped +# (`ails daemon stop`) or killed — matching user expectations for a +# background mapper. The env var stays available for integration tests that +# want fast cleanup (e.g. AILS_DAEMON_IDLE_S=5). +_IDLE_TIMEOUT_S_RAW = os.environ.get("AILS_DAEMON_IDLE_S") +_IDLE_TIMEOUT_S: int | None = int(_IDLE_TIMEOUT_S_RAW) if _IDLE_TIMEOUT_S_RAW else None _SOCKET_BACKLOG = 2 _MAX_REQUEST_BYTES = 10_000_000 # 10MB @@ -237,8 +240,9 @@ def _daemon_main() -> None: ``map_ruleset`` requests block on ``warmup_done`` before dispatching; ``ping`` and ``shutdown`` are answered immediately regardless. - Lifecycle: idle timeout only — no parent-process tracking. The global - daemon isn't a child of any specific CLI process. + Lifecycle: runs until explicit shutdown command, SIGTERM/SIGINT, or + optional idle timeout (opt-in via AILS_DAEMON_IDLE_S). No parent-process + tracking; the global daemon isn't a child of any specific CLI process. Unreachable on Windows: callers gate on sys.platform before invoking. """ @@ -268,7 +272,7 @@ def _handle_signal(_signum: int, _frame: object) -> None: last_activity = time.monotonic() while not _shutdown: - if time.monotonic() - last_activity > _IDLE_TIMEOUT_S: + if _IDLE_TIMEOUT_S is not None and time.monotonic() - last_activity > _IDLE_TIMEOUT_S: break try: From b34233903ced24f0cfbefd2f0ca8c3bdbafa2bbd Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 6 May 2026 17:25:58 +0200 Subject: [PATCH 18/18] Add CORE:C:0055 description-coherence rule; deep-link unknown_error bug-report URL with prefilled title/body --- CHANGELOG.md | 3 + .../rules/core/description-coherence/rule.md | 60 +++++++++++++++++++ src/reporails_cli/core/funnel.py | 41 +++++++++++++ src/reporails_cli/formatters/text/display.py | 5 +- tests/unit/test_funnel.py | 41 +++++++++++++ 5 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 framework/rules/core/description-coherence/rule.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e4dfda..5cdc243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - [core/payload]: New `core/payload.py` module producing a compact wire payload for HTTP transport. Reduces request body size on large projects. - [core/funnel]: New `WIRE_MAX_BYTES_BY_TIER` table and `preflight_byte_size()` function. Local preflight returns a `payload_too_large` `FunnelError` before transmission instead of an opaque server-side 4xx. +- [framework/rules/core/description-coherence]: New rule (`CORE:C:0055`) for files loaded on invocation (skills, subagents, slash commands) whose frontmatter `description:` doesn't match the body content. Server-execution rule. Replaces the previously-stale identifier the description-mismatch diagnostic had been pointing at (`prior-as-competitor`, an unrelated rule about default behavior competition). +- [core/funnel + formatters/text]: When the server returns an unrecognized error (`unknown_error` shape), the "Did you see an error?" exit ramp now deep-links to GitHub's new-issue form with the title, a triage-ready body (environment + reproduce skeleton), and a `bug` label prefilled — turning a generic `/issues` link into a one-click filed issue. Known funnel errors (rate limit, payload-too-large) keep the plain `/issues` index because they're usage signals, not bug reports. ### Changed @@ -26,6 +28,7 @@ - [formatters/text/scorecard]: `compute_surface_scores` relativizes `ruleset_map.files[*].path` against the project root before classification. Absolute paths from the mapper were being tagged `nested` purely because their leading filesystem components inflated the `parts` count, so a project with one root-level `CLAUDE.md` was rendered as `Main (1) ... Nested (1)`. Findings (which already carry relative paths) and the mapper's file list now classify consistently. - [interfaces/mcp]: Updated `explain` tool example coordinate from `CLAUDE:S:0011` (promoted/renamed) to `CLAUDE:S:0005` so the MCP tool description references a current rule. - [core/mapper/daemon]: Mapper daemon's 1-hour idle timeout is now opt-in via the `AILS_DAEMON_IDLE_S` env var instead of applied by default. Without the override the daemon stays running until `ails daemon stop` or an explicit kill — matching the user expectation that "background" means "doesn't go away on its own". The previous 1-hour default caused the daemon to terminate between dev sessions, so each subsequent `ails check` paid the cold-start cost. +- [framework/rules/core]: Four server-driven diagnostics that displayed unrelated rules via `ails explain` are now pointed at coherent rules. `description-mismatch` → new `CORE:C:0055` `description-coherence` (was the unrelated `prior-as-competitor`). `overall-strength` → `CORE:C:0053` `ideal-instruction`, the existing composite-rollup rule whose own Limitations describes it as such (was `compound-weakness`, which is per-atom multiplicative, not file-level). `named-coverage` → `CORE:C:0042` `specificity-gap` (was `specificity-shields`, which scopes itself to prose-heavy files; the diagnostic fires regardless of prose). `orphan` stays at `CORE:C:0053` (the existing mapping was correct — `ideal-instruction` Fix bullet #3 names the golden pattern explicitly). Also dropped two dead `RULE_ID_MAP` entries (`cross-conflict`, `cross-repetition`) that were never reachable — cross-file findings carry their own `finding_type` and never go through the diagnostic-label translation. ## 0.5.7 diff --git a/framework/rules/core/description-coherence/rule.md b/framework/rules/core/description-coherence/rule.md new file mode 100644 index 0000000..6fc1ef4 --- /dev/null +++ b/framework/rules/core/description-coherence/rule.md @@ -0,0 +1,60 @@ +--- +id: CORE:C:0055 +slug: description-coherence +title: "Description Coherence" +category: coherence +type: mechanical +execution: server +severity: medium +match: {loading: on_invocation} +--- + +# Description Coherence + +Files that the agent loads on demand — skills, subagents, slash commands — are dispatched by their frontmatter `description:` field. The agent reads the description first, decides whether the file applies, and only then loads the body. When the description names a narrower concept than the body covers, or when the description and the body are about different topics altogether, the agent never invokes the file for the cases it actually contains. + +## Antipatterns + +- A description that names one narrow concept while the body covers a wider workflow. "Format JSON output" as the description for a body that documents JSON, YAML, and CSV. +- A description that's a generic blurb ("Helper utilities") with no overlap to the specific topics in the body. The agent has nothing to dispatch on. +- A description copied from another file at scaffold time and never updated as the body evolved away from it. + +## Pass / Fail + +### Pass + +~~~~markdown +--- +name: format-output +description: Format the agent's response as JSON, YAML, or CSV with examples for each. Use when the user asks for structured output or specifies a serialization format. +--- + +# Format Output + +When the user asks for JSON, render keys in lowercase snake_case… +When the user asks for YAML, prefer flow style for short objects… +When the user asks for CSV, escape commas with double quotes… +~~~~ + +### Fail + +~~~~markdown +--- +name: format-output +description: Format the agent's response as JSON. +--- + +# Format Output + +When the user asks for JSON, render keys in lowercase snake_case… +When the user asks for YAML, prefer flow style for short objects… +When the user asks for CSV, escape commas with double quotes… +~~~~ + +## Fix + +Rewrite the description so it names the same concepts the body covers. If the body covers three formats, the description should mention all three (or use a covering term like "structured output formats"). The description's job is dispatch: the agent uses it to decide whether the file applies, before paying the cost of loading the body. + +## Limitations + +Compares description meaning to body meaning at the topic level. Cannot detect when both the description and the body are coherent but neither matches what the user actually wants. Does not fire on path-scoped rule files (loaded by glob match, not by description). diff --git a/src/reporails_cli/core/funnel.py b/src/reporails_cli/core/funnel.py index d3bf0c8..860ce18 100644 --- a/src/reporails_cli/core/funnel.py +++ b/src/reporails_cli/core/funnel.py @@ -4,6 +4,7 @@ import json import logging +import sys from dataclasses import dataclass from typing import Any from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit @@ -13,6 +14,7 @@ UNIVERSAL_ATOM_CAP = 10_000 BUG_REPORT_URL = "https://github.com/reporails/cli/issues" +BUG_REPORT_NEW_URL = "https://github.com/reporails/cli/issues/new" WIRE_MAX_FILES = 500 WIRE_MAX_CLUSTERS = 2000 @@ -166,6 +168,45 @@ def _preflight_url(error: str, tier: str) -> str: return f"https://reporails.com/contact/{_CONTACT_SUFFIXES[error]}?utm_source=cli" +def _cli_version() -> str: + """Return the installed CLI version, or 'unknown' if metadata is missing.""" + try: + from importlib.metadata import version + + return version("reporails-cli") + except Exception: # importlib.metadata.PackageNotFoundError + defensive + return "unknown" + + +def format_bug_report_url(err: FunnelError) -> str: + """Return the GitHub-issues URL for the bug-report exit ramp. + + For ``unknown_error`` (an unrecognized 4xx body or a transport failure that + surfaced as "actual error") we deep-link to ``/issues/new`` with the title + and a triage-ready body prefilled, so the user lands one click + a few + lines from a filed issue. For known funnel errors (rate limit, payload too + large) we return the plain ``/issues`` index — those are usage signals, + not bug reports, and a deep link would invite spurious "feature request" + issues. + """ + if err.error != "unknown_error" or not err.message: + return BUG_REPORT_URL + + title = f"[CLI] {err.message}" + body = ( + "## What happened\n\n" + f"{err.message}\n\n" + "## Environment\n\n" + f"- reporails-cli: {_cli_version()}\n" + f"- OS: {sys.platform}\n" + f"- Python: {sys.version.split()[0]}\n\n" + "## Steps to reproduce\n\n" + "\n" + ) + params = urlencode({"title": title, "body": body, "labels": "bug"}) + return f"{BUG_REPORT_NEW_URL}?{params}" + + def merge_utm(url: str, source: str = "cli") -> str: """Append utm_source to URL query string when absent.""" if not url or not url.startswith(("http://", "https://")): diff --git a/src/reporails_cli/formatters/text/display.py b/src/reporails_cli/formatters/text/display.py index ca04c0a..7c8582f 100644 --- a/src/reporails_cli/formatters/text/display.py +++ b/src/reporails_cli/formatters/text/display.py @@ -438,15 +438,16 @@ def _render_findings_and_scorecard( def _render_funnel_cta(funnel_error: object) -> None: """Render the conversion CTA + bug-report link when a FunnelError is present.""" - from reporails_cli.core.funnel import BUG_REPORT_URL, FunnelError, format_cta + from reporails_cli.core.funnel import FunnelError, format_bug_report_url, format_cta if not isinstance(funnel_error, FunnelError): return cta = format_cta(funnel_error) if not cta: return + bug_url = format_bug_report_url(funnel_error) console.print() console.print(" [yellow]⚠[/yellow] Server diagnostics unavailable.") console.print(f" {cta}") - console.print(f" [dim]Did you see an error? Let us know: [bold]{BUG_REPORT_URL}[/bold][/dim]") + console.print(f" [dim]Did you see an error? Let us know: [bold]{bug_url}[/bold][/dim]") console.print() diff --git a/tests/unit/test_funnel.py b/tests/unit/test_funnel.py index f135f8e..1314f04 100644 --- a/tests/unit/test_funnel.py +++ b/tests/unit/test_funnel.py @@ -7,12 +7,14 @@ import pytest from reporails_cli.core.funnel import ( + BUG_REPORT_NEW_URL, BUG_REPORT_URL, UNIVERSAL_ATOM_CAP, WIRE_MAX_CLUSTERS, WIRE_MAX_FILES, FunnelError, LintResponse, + format_bug_report_url, format_cta, merge_utm, parse_error_body, @@ -26,6 +28,45 @@ def test_bug_report_url_points_to_github_issues() -> None: assert BUG_REPORT_URL.endswith("/issues") +def test_bug_report_new_url_points_to_issue_form() -> None: + """The ``/new`` variant is the deep-link target for prefilled bug reports.""" + assert BUG_REPORT_NEW_URL.startswith("https://github.com/") + assert BUG_REPORT_NEW_URL.endswith("/issues/new") + + +class TestFormatBugReportUrl: + def test_unknown_error_prefills_title_and_body(self) -> None: + err = FunnelError(error="unknown_error", message="HTTP 400 (unsupported_payload_version)") + url = format_bug_report_url(err) + assert url.startswith(f"{BUG_REPORT_NEW_URL}?") + # URL-encoded forms: title's `+` for spaces, body uses %0A for newlines. + assert "title=" in url + assert "%5BCLI%5D" in url # "[CLI]" url-encoded + assert "HTTP+400" in url or "HTTP%20400" in url + assert "labels=bug" in url + assert "body=" in url + + def test_unknown_error_url_encodes_special_chars(self) -> None: + err = FunnelError(error="unknown_error", message='HTTP 422 ("validation failed" / atoms)') + url = format_bug_report_url(err) + # `"`, `/`, and `(` must round-trip through urlencode without breaking the URL shape. + assert url.count("?") == 1 + assert "%22validation+failed%22" in url or "%22validation%20failed%22" in url + + def test_unknown_error_with_empty_message_falls_back_to_index(self) -> None: + err = FunnelError(error="unknown_error", message="") + assert format_bug_report_url(err) == BUG_REPORT_URL + + def test_known_funnel_error_returns_plain_index(self) -> None: + # rate_limit_exceeded is an expected usage signal, not a bug report. + err = FunnelError(error="rate_limit_exceeded", tier="anonymous", limit=5, message="any") + assert format_bug_report_url(err) == BUG_REPORT_URL + + def test_payload_too_large_returns_plain_index(self) -> None: + err = FunnelError(error="payload_too_large", tier="anonymous", limit=2_097_152, size=8_971_467) + assert format_bug_report_url(err) == BUG_REPORT_URL + + class TestParseErrorBody: def test_rate_limit_body(self) -> None: body = json.dumps(