diff --git a/dokimi/changelog.d/20261023_150000_antonwolfy_nested-cursor-rules.md b/dokimi/changelog.d/20261023_150000_antonwolfy_nested-cursor-rules.md new file mode 100644 index 00000000..d316eaf4 --- /dev/null +++ b/dokimi/changelog.d/20261023_150000_antonwolfy_nested-cursor-rules.md @@ -0,0 +1,8 @@ +### Fixed + +- **`repo inspect` lists a `.cursor/rules/*.mdc` below the root** — a rule + file such as `web/.cursor/rules/style.mdc` was not listed in + `instructions`. It is now a record with `directory` `web`, and with scope + and activation `unknown`: Cursor's rules docs cover only the root + `.cursor/rules` (and nested `AGENTS.md`), so whether Cursor reads it is not + known from the docs. Its note says so. diff --git a/dokimi/docs/repo-evaluation-contract.md b/dokimi/docs/repo-evaluation-contract.md index 00aec556..a5304e97 100644 --- a/dokimi/docs/repo-evaluation-contract.md +++ b/dokimi/docs/repo-evaluation-contract.md @@ -877,11 +877,12 @@ not measured effects. The report keeps observations apart from findings: - `instructions`: one record per instruction file and per harness rule it - matches (a root `AGENTS.md`, a nested `CLAUDE.md`, `.cursor/rules/*.mdc`, - `.github/instructions/*.instructions.md`, …), with its `scope` - (`root`, `path-specific`, `mode-specific`, `local-private`, `legacy`, - `capability`, `unknown`), `activation`, the `directory` it applies to and - its size (for a link, the size of the file it names). This is what each + matches (a root `AGENTS.md`, a nested `CLAUDE.md`, `.cursor/rules/*.mdc` at + the root or in a subdirectory, `.github/instructions/*.instructions.md`, …), + with its `scope` (`root`, `path-specific`, `mode-specific`, `local-private`, + `legacy`, `capability`, `unknown`), `activation`, the `directory` it applies to + (for a record whose scope is `unknown`, the one it would apply to) and its size + (for a link, the size of the file it names). This is what each harness documents; which files a given harness version loads is measured by the reach probe (§7). - `checks`: ways to build, test, lint and type-check, per project root (the diff --git a/dokimi/dokimi/repos/environment/instructions.py b/dokimi/dokimi/repos/environment/instructions.py index d645a7a4..ed1c56d3 100644 --- a/dokimi/dokimi/repos/environment/instructions.py +++ b/dokimi/dokimi/repos/environment/instructions.py @@ -74,6 +74,16 @@ def _parent(p: str) -> str: return posixpath.dirname(p) or "." +def _nested_cursor_rule(p: str) -> bool: + """A ``/.cursor/rules/*.mdc`` below the root; one under the root ``.cursor/rules/`` is the root rule's.""" + return not p.startswith(".cursor/rules/") and re.match(r"^.+/\.cursor/rules/.+\.mdc$", p) is not None + + +def _cursor_dir(p: str) -> str: + """The directory a nested ``/.cursor/rules/*.mdc`` would apply to: ````, the closest one.""" + return p.rsplit("/.cursor/rules/", 1)[0] + + def _roo_mode(p: str) -> str | None: if p.startswith(".roo/rules-"): return p.split("/")[1].removeprefix("rules-") @@ -104,6 +114,11 @@ def _roo_mode(p: str) -> str | None: directory=_parent), Rule(_under(".cursor/rules/", r"\.mdc$"), ("cursor",), "unknown", "unknown", notes=("activation depends on the rule's frontmatter",)), + # Cursor's rules docs (cursor.com/docs/context/rules, read 2026-10-23) place rules in the root + # .cursor/rules only, and nested AGENTS.md: one below the root is listed, its scope not known. + Rule(_nested_cursor_rule, ("cursor",), "unknown", "unknown", directory=_cursor_dir, + notes=("below the root: Cursor documents only the root .cursor/rules, so whether it reads this " + "one is not known",)), Rule(_named(".cursorrules"), ("cursor", "cline"), "legacy", "always", legacy=True), Rule(_named("GEMINI.md"), ("gemini", "github-copilot"), "root", "always"), Rule(_nested("GEMINI.md"), ("gemini",), "path-specific", "path-scoped", directory=_parent), diff --git a/dokimi/dokimi/runner/manifest.py b/dokimi/dokimi/runner/manifest.py index 3fb1207c..4a6cf9e6 100644 --- a/dokimi/dokimi/runner/manifest.py +++ b/dokimi/dokimi/runner/manifest.py @@ -19,7 +19,8 @@ from dokimi import __version__, process -# Direct dependencies whose versions materially affect results. +# Dependencies whose versions materially affect results. ``ragas`` comes with the ``ragas`` +# extra and ``anthropic`` with that extra's ``langchain-anthropic``; one not installed is None. TRACKED_DEPS = ( "litellm", "openai", "anthropic", "ragas", "datasets", "jsonschema", "numpy", "PyYAML", "httpx", diff --git a/dokimi/scripts/ci_local.py b/dokimi/scripts/ci_local.py index 5e12ac02..c0ea3b83 100755 --- a/dokimi/scripts/ci_local.py +++ b/dokimi/scripts/ci_local.py @@ -20,9 +20,9 @@ windows-smoke, conda-forge-deps) run in a throwaway venv on a copy of the tracked tree instead, so the dev venv stays as CI's. - Every step runs even when an earlier one fails, so one failure does not hide - the next (CI stops a job's checks at its first failing step, except - `static`, whose steps after the install all run under - `if: ${{ !cancelled() }}`). + the next (CI stops a job's checks at its first failing step, except for + steps under `if: ${{ !cancelled() }}`: every step of `static` after the + install, and the deptry step of `typecheck`). - A matrix runs one leg: the dev venv's Python, standing in for the coverage leg too (`matrix.coverage` is true), so the changed-lines gate runs here. A notice says so when that is not the Python CI's single-version jobs use. diff --git a/dokimi/scripts/growth_baseline.json b/dokimi/scripts/growth_baseline.json index 0c237b9f..8d585f33 100644 --- a/dokimi/scripts/growth_baseline.json +++ b/dokimi/scripts/growth_baseline.json @@ -11,7 +11,7 @@ "tests/test_judge_reliability.py": {"lines": 1695, "rationale": "grew on main in #73 (judge-validity --context stored), 1505 -> 1695, before the ratchet landed; split it in its own PR"}, "tests/test_judge_validity.py": {"lines": 2235, "rationale": "grew on main in #73 (judge-validity --context stored), 1423 -> 2235, before the ratchet landed; split it in its own PR"}, "tests/test_pipeline_question_set.py": {"lines": 1223}, - "tests/test_repo_environment.py": {"lines": 1373, "rationale": "grew on main in #147 before the ratchet landed; +2 for the instructions assert of two crash tests (#103 review); split it in its own PR"}, + "tests/test_repo_environment.py": {"lines": 1337, "rationale": "grew on main in #147 before the ratchet landed; +2 for the instructions assert of two crash tests (#103 review); split it in its own PR"}, "tests/test_subjects.py": {"lines": 1064}, "tests/test_task_formats.py": {"lines": 1003} }, diff --git a/dokimi/tests/test_repo_environment.py b/dokimi/tests/test_repo_environment.py index 649080cd..3838f1ec 100644 --- a/dokimi/tests/test_repo_environment.py +++ b/dokimi/tests/test_repo_environment.py @@ -877,42 +877,6 @@ def test_autotools_names_the_makefile_that_declares_tests(tmp_path): "tests/Makefile.am"] -INSTRUCTION_TABLE = [ - (".claude/CLAUDE.md", ('claude-code',), "root", "always", False), - (".claude/rules/a.md", ('claude-code',), "unknown", "unknown", False), - (".claude/skills/x/SKILL.md", ('claude-code',), "capability", "on-demand", False), - (".clinerules", ('cline',), "legacy", "always", True), - (".clinerules/a.md", ('cline',), "root", "unknown", False), - (".cursor/rules/a.mdc", ('cursor',), "unknown", "unknown", False), - (".cursorrules", ('cursor', 'cline'), "legacy", "always", True), - (".github/agents/a.agent.md", ('github-copilot',), "capability", "manual", False), - (".github/copilot-instructions.md", ('github-copilot',), "root", "always", False), - (".github/instructions/a.instructions.md", ('github-copilot',), "path-specific", "path-scoped", False), - (".roo/rules-code/a.md", ('roo-code',), "mode-specific", "mode-scoped", False), - (".roo/rules/a.md", ('roo-code',), "root", "always", False), - (".roomodes", ('roo-code',), "mode-specific", "mode-scoped", False), - (".roorules", ('roo-code',), "legacy", "always", True), - (".roorules-code", ('roo-code',), "mode-specific", "mode-scoped", True), - (".windsurf/rules/a.md", ('windsurf',), "unknown", "unknown", False), - (".windsurfrules", ('windsurf', 'cline'), "legacy", "always", True), - ("AGENTS.md", ('codex', 'github-copilot', 'cursor', 'windsurf', 'cline', 'generic'), "root", "always", False), - ("AGENTS.override.md", ('codex',), "root", "always", False), - ("CLAUDE.local.md", ('claude-code',), "local-private", "always", False), - ("CLAUDE.md", ('claude-code', 'github-copilot'), "root", "always", False), - ("GEMINI.md", ('gemini', 'github-copilot'), "root", "always", False), - ("sub/AGENTS.md", ('codex', 'github-copilot', 'cursor', 'windsurf', 'cline', 'generic'), "path-specific", "path-scoped", False), - ("sub/AGENTS.override.md", ('codex',), "path-specific", "path-scoped", False), - ("sub/CLAUDE.md", ('claude-code',), "path-specific", "path-scoped", False), - ("sub/GEMINI.md", ('gemini',), "path-specific", "path-scoped", False), -] - - -def test_instruction_rule_table(): - got = [(i.path, tuple(i.harnesses), i.scope, i.activation, i.legacy) - for i in detect_instructions([row[0] for row in INSTRUCTION_TABLE])] - assert got == INSTRUCTION_TABLE - - @pytest.mark.parametrize("command, flagged", [ # wrappers with options that take a value, and a wrapper named by path ("sudo -u nobody npm ci", True), ("nice -n 10 npm ci", True), ("timeout -s KILL 30 npm ci", True), diff --git a/dokimi/tests/test_repo_instructions.py b/dokimi/tests/test_repo_instructions.py new file mode 100644 index 00000000..b4a0eb4e --- /dev/null +++ b/dokimi/tests/test_repo_instructions.py @@ -0,0 +1,58 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""The instruction-file rules of ``repo inspect``: which harness reads each file, and where it applies.""" + +from __future__ import annotations + +from dokimi.repos.environment.instructions import detect_instructions + +INSTRUCTION_TABLE = [ + (".claude/CLAUDE.md", ('claude-code',), "root", "always", False), + (".claude/rules/a.md", ('claude-code',), "unknown", "unknown", False), + (".claude/skills/x/SKILL.md", ('claude-code',), "capability", "on-demand", False), + (".clinerules", ('cline',), "legacy", "always", True), + (".clinerules/a.md", ('cline',), "root", "unknown", False), + (".cursor/rules/a.mdc", ('cursor',), "unknown", "unknown", False), + (".cursorrules", ('cursor', 'cline'), "legacy", "always", True), + (".github/agents/a.agent.md", ('github-copilot',), "capability", "manual", False), + (".github/copilot-instructions.md", ('github-copilot',), "root", "always", False), + (".github/instructions/a.instructions.md", ('github-copilot',), "path-specific", "path-scoped", False), + (".roo/rules-code/a.md", ('roo-code',), "mode-specific", "mode-scoped", False), + (".roo/rules/a.md", ('roo-code',), "root", "always", False), + (".roomodes", ('roo-code',), "mode-specific", "mode-scoped", False), + (".roorules", ('roo-code',), "legacy", "always", True), + (".roorules-code", ('roo-code',), "mode-specific", "mode-scoped", True), + (".windsurf/rules/a.md", ('windsurf',), "unknown", "unknown", False), + (".windsurfrules", ('windsurf', 'cline'), "legacy", "always", True), + ("AGENTS.md", ('codex', 'github-copilot', 'cursor', 'windsurf', 'cline', 'generic'), "root", "always", False), + ("AGENTS.override.md", ('codex',), "root", "always", False), + ("CLAUDE.local.md", ('claude-code',), "local-private", "always", False), + ("CLAUDE.md", ('claude-code', 'github-copilot'), "root", "always", False), + ("GEMINI.md", ('gemini', 'github-copilot'), "root", "always", False), + ("sub/.cursor/rules/a.mdc", ('cursor',), "unknown", "unknown", False), + ("sub/AGENTS.md", ('codex', 'github-copilot', 'cursor', 'windsurf', 'cline', 'generic'), "path-specific", "path-scoped", False), + ("sub/AGENTS.override.md", ('codex',), "path-specific", "path-scoped", False), + ("sub/CLAUDE.md", ('claude-code',), "path-specific", "path-scoped", False), + ("sub/GEMINI.md", ('gemini',), "path-specific", "path-scoped", False), +] + + +def test_instruction_rule_table(): + got = [(i.path, tuple(i.harnesses), i.scope, i.activation, i.legacy) + for i in detect_instructions([row[0] for row in INSTRUCTION_TABLE])] + assert got == INSTRUCTION_TABLE + + +def test_a_nested_cursor_rule_is_listed_with_its_directory_and_no_known_scope(): + (rule,) = detect_instructions(["pkg/web/.cursor/rules/style.mdc", "pkg/web/.cursor/notes.md"]) + assert (rule.path, rule.scope, rule.directory) == ("pkg/web/.cursor/rules/style.mdc", "unknown", "pkg/web") + assert any("Cursor documents only the root .cursor/rules" in note for note in rule.notes) + + +def test_a_cursor_rules_path_gets_one_record_and_the_closest_directory(): + paths = [".cursor/rules/sub/.cursor/rules/b.mdc", "a/.cursor/rules/b/.cursor/rules/c.mdc"] + got = sorted((i.path, i.directory) for i in detect_instructions(paths)) + # under the root .cursor/rules: the root rule's only; nested twice: the inner .cursor's directory + assert got == [(".cursor/rules/sub/.cursor/rules/b.mdc", None), + ("a/.cursor/rules/b/.cursor/rules/c.mdc", "a/.cursor/rules/b")]