diff --git a/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md b/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md new file mode 100644 index 000000000..9957bb936 --- /dev/null +++ b/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md @@ -0,0 +1,16 @@ +### Added + +- **`repo budget` counts `.claude/rules`** — a `.claude/rules/**/*.md` file is a `rule` entry. + A rule in the root `.claude/rules/` whose `paths` frontmatter names no pattern is `start`; one + whose `paths` does, and every rule of a subdirectory's `.claude/rules/`, is `lazy`, unless a root `CLAUDE.md` or `AGENTS.md` imports it: then it is `start` whatever its `paths`. The + frontmatter is not counted, since Claude Code removes it before loading the rule, and an `@` + path in a rule is not followed, unless a CLAUDE.md or AGENTS.md imports the rule: then its `@` imports are followed, transitively and within the hop limit. A root import loads the rule at start; a nested CLAUDE.md import loads it lazily, when that nested file is read. A link to a directory under `.claude/rules` is a + warning: the rules behind it are not counted. A link under `.claude/rules` that does not resolve in + the tree is named too, whatever its suffix, since the tree cannot tell a dangling file from a directory; a + `CLAUDE.md` under `.claude/rules` is a rule, not guidance. Measured on Claude Code 2.1.304: a root rule + raised the first prompt by its size, `paths: []` included; a scoped or nested one loaded only + once the agent read a file it covers; a rule's `@extra.md` was not expanded. `start` was too + low by unscoped root rules before; `repo_budget.v1` widens `kind` with `rule`. Rules are counted + for a harness profile with the new optional `guidance.claude_rules: true` (`claude-code` + only); the Codex and Copilot profiles leave it unset. An `@` import of a file keeps its frontmatter + (it is counted in full), and a profile that does not read rules strips none from an imported rule file. diff --git a/dokimi/docs/architecture.md b/dokimi/docs/architecture.md index 5aafd5a88..2860b9e64 100644 --- a/dokimi/docs/architecture.md +++ b/dokimi/docs/architecture.md @@ -298,12 +298,18 @@ next release. non-empty body line, as Claude Code does. It warns on skills with no description or body text, skills with near-duplicate descriptions (identical descriptions, or Jaccard index >= 0.5 after stop-word removal when both have 3+ words left), on AGENTS.md files turned off, on - unreadable files (non-UTF-8, dangling or out-of-tree link), and on imports past the harness's - hop limit, each of which also trips - `--fail-on-warning`. `start` is not an upper bound: it is too low by what it does not count - (`.claude/rules/*.md` files; only `@path.md` imports are followed, so `@notes.txt` adds nothing; + unreadable files (non-UTF-8, dangling or out-of-tree link), on imports past the harness's + hop limit, and on a link at or below `.claude/rules` to a directory or out of the tree (the rules behind + it are not counted), each of which also trips `--fail-on-warning`. For a profile with `guidance.claude_rules` (Claude Code), + `.claude/rules/**/*.md` files are `rule` entries: `start` in the root `.claude/rules/` when + `paths` names no pattern, `lazy` when it does or in a subdirectory's `.claude/rules/`, but `start` whatever its `paths` when a root CLAUDE.md or + AGENTS.md imports it, the frontmatter not counted. An `@` path in a rule is not followed, unless a CLAUDE.md or AGENTS.md + imports the rule: then its `@` imports are followed, transitively and within the hop limit. A root import loads the rule at start; a nested CLAUDE.md import loads it lazily, when that nested file is read. `start` is not an upper bound: it is too low by what it does not count + (only `@path.md` imports are followed, so `@notes.txt` adds nothing; skills are read only from real `.claude/skills/*/SKILL.md` directories at the root, so a skill - behind a linked directory is not seen, and a nested `/.claude/skills/` skill, which Claude + behind a linked directory is not seen, nor are the rules behind a link at or below `.claude/rules` (a warning + names it; a `.claude` link itself is neither counted nor named), and a + nested `/.claude/skills/` skill, which Claude Code loads on the first read in that subdirectory, is not counted; and imports past the profile's `guidance.import_max_depth`, four hops for Claude Code, which warn naming what was skipped). It is too high by whatever the harness cuts from the skill listing: the listing is charged in full, and the harness cuts it past diff --git a/dokimi/docs/repo-evaluation-contract.md b/dokimi/docs/repo-evaluation-contract.md index 6377283ab..d1a221d9c 100644 --- a/dokimi/docs/repo-evaluation-contract.md +++ b/dokimi/docs/repo-evaluation-contract.md @@ -46,9 +46,14 @@ SPDX-License-Identifier: Apache-2.0 > no LLM call): root CLAUDE.md files and their `@` imports plus the skill listing at > start (AGENTS.md files and their imports too, unless a root CLAUDE*, `.claude/CLAUDE.md` or > `CLAUDE.local.md` turns them off), nested guidance lazily, skill bodies on demand, and AGENTS.md -> files a root CLAUDE* turns off. The `start` total is not an upper bound: it is too low by what it does -> not count (`.claude/rules/*.md` files, non-`.md` imports, skills behind a linked directory, nested -> `.claude/skills/` skills, and +> files a root CLAUDE* turns off. For a profile with `guidance.claude_rules` (`claude-code`), +> `.claude/rules/**/*.md` files are `rule` entries: at start in the root `.claude/rules/` unless +> their frontmatter's `paths` names a pattern, lazily then and in a subdirectory's `.claude/rules/`; a rule a root CLAUDE.md or AGENTS.md +> imports is `start` whatever its `paths`; +> an `@` path in a rule is not followed, unless a CLAUDE.md or AGENTS.md imports the rule: then its `@` imports are followed, transitively and within the hop limit. +> A root import loads the rule at start; a nested CLAUDE.md import loads it lazily, when that nested file is read. The `start` total is not an upper bound: +> it is too low by what it does not count (non-`.md` imports, skills behind a linked directory, rules behind a +> link at or below `.claude/rules` to a directory, which warns, nested `.claude/skills/` skills, and > imports past the profile's `guidance.import_max_depth`, four hops for Claude Code, which warns > naming what was skipped), and too high by whatever the harness cuts from the skill listing. > Usage: diff --git a/dokimi/docs/what-it-measures.md b/dokimi/docs/what-it-measures.md index 265663aea..bba4faf11 100644 --- a/dokimi/docs/what-it-measures.md +++ b/dokimi/docs/what-it-measures.md @@ -53,7 +53,7 @@ manifest it owns: - `dokimi repo scan` (L0, no LLM): do the links, paths, identifiers, commands and build labels the guidance names exist in the tree? -- `dokimi repo budget` (no LLM): how many characters of guidance and skill +- `dokimi repo budget` (no LLM): how many characters of guidance, `.claude/rules` and skill listing every model call pays for at start-up, what loads lazily or on demand, and which AGENTS.md files a CLAUDE.md turns off. - `dokimi repo reach` (L1): which guidance files does a given harness actually diff --git a/dokimi/dokimi/commands/repo.py b/dokimi/dokimi/commands/repo.py index 9f61497ca..5a39cdfd4 100644 --- a/dokimi/dokimi/commands/repo.py +++ b/dokimi/dokimi/commands/repo.py @@ -480,7 +480,8 @@ def _print_receipt(tree, profile, payload: dict, out_dir: Path) -> None: from dokimi.repos.budget import budget from dokimi.repos.receipt import build_receipt - receipt = build_receipt(payload, budget(tree, profile.guidance_names, profile.import_max_depth), tree) + receipt = build_receipt(payload, budget(tree, profile.guidance_names, profile.import_max_depth, + profile.reads_claude_rules), tree) try: # a directory that is not writable, or one already named effective_instructions.json: a runtime failure written = save_artifact("effective_instructions", receipt, out_dir / RECEIPT_NAME) except OSError as exc: @@ -501,7 +502,7 @@ def cmd_repo_budget(args: argparse.Namespace) -> None: print(f"✗ {exc}", file=sys.stderr) sys.exit(EXIT_USAGE) _, tree = _source_tree(args) - payload = budget(tree, profile.guidance_names, profile.import_max_depth) + payload = budget(tree, profile.guidance_names, profile.import_max_depth, profile.reads_claude_rules) if args.out: try: save_artifact("repo_budget", payload, Path(args.out)) diff --git a/dokimi/dokimi/repos/budget.py b/dokimi/dokimi/repos/budget.py index 1351e01e7..702fce77e 100644 --- a/dokimi/dokimi/repos/budget.py +++ b/dokimi/dokimi/repos/budget.py @@ -7,12 +7,15 @@ starts at the repository root: - **start**: guidance files at the root, the files a root ``CLAUDE*`` or ``AGENTS.md`` file - ``@import``s (``AGENTS.md`` only when it is not turned off), and the skill + ``@import``s (``AGENTS.md`` only when it is not turned off), the root ``.claude/rules/**/*.md`` + files whose ``paths`` frontmatter names no pattern, when the harness reads rules (their own ``@`` paths are not + imports unless a CLAUDE.md imports the rule; then they are, transitively), and the skill listing (each project skill's ``name``, plus its ``description`` and ``when_to_use`` up to 1,536 characters together; a skill with ``disable-model-invocation: true`` is not listed). Every model call pays for these. -- **lazy**: guidance files in subdirectories, and what a nested CLAUDE.md or AGENTS.md imports, loaded - when the agent works in that subtree. +- **lazy**: guidance files in subdirectories, what a nested CLAUDE.md or AGENTS.md imports, and the + rules of a subdirectory's ``.claude/rules/``, loaded when the agent works in that subtree; rules + whose ``paths`` names a pattern, loaded when the agent reads a matching file. - **on demand**: skill bodies, loaded when the agent invokes the skill. - **off**: AGENTS.md files the harness will not read: a root CLAUDE.md, .claude/CLAUDE.md or CLAUDE.local.md is present and no CLAUDE.md imports them (measured on Claude Code 2.1.286; the reach probe re-measures). @@ -21,10 +24,11 @@ and differs between model families. The exact cost is the first-call prompt difference between a run with the file and one without. -Five checks ride along: skills with no description and no body text, skills whose descriptions are near-duplicates +Six checks ride along: skills with no description and no body text, skills whose descriptions are near-duplicates (the agent has to choose between them from the listing alone), AGENTS.md files turned off by a -CLAUDE.md, .claude/CLAUDE.md or CLAUDE.local.md at the root, unreadable files (non-UTF-8, dangling link), and imports past the -harness's hop limit. +CLAUDE.md, .claude/CLAUDE.md or CLAUDE.local.md at the root, unreadable files (non-UTF-8, dangling link), imports past the +harness's hop limit, and links under ``.claude/rules`` to a directory or out of the tree, whose rules +are not counted. """ from __future__ import annotations @@ -34,9 +38,18 @@ from itertools import combinations from typing import Any +import yaml + from dokimi.repos.source import RepoTree SKILL_GLOB = re.compile(r"^\.claude/skills/[^/]+/SKILL\.md$") +# Rules are discovered recursively, in the root .claude/rules/ and in a subdirectory's (docs: Memory). +RULE_GLOB = re.compile(r"^(?:.+/)?\.claude/rules/(?:.+/)?[^/]+\.md$") +_RULES_ENTRY = re.compile(r"^(?:.+/)?\.claude/rules(?:/.+)?$") +_FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n(.*?)^---[ \t]*(?:\r?\n|\Z)", re.S | re.M) +# The `paths:` key of frontmatter that does not parse: its line, read on its own (see _paths_block). +_PATHS_LINE = re.compile(r"^paths[ \t]*:(.*)$") +_INDENTED_KEY = re.compile(r"^[ \t]+(?:[A-Za-z0-9_][^:#]*|\"[^\"]*\"|'[^']*'):(?:[ \t]|$)") # ` unused: ...`: a key, not a paths continuation MCP_FILE = ".mcp.json" # Claude Code truncates description + when_to_use at 1,536 characters in the listing (docs: Skills). LISTING_TEXT_CAP = 1536 @@ -58,10 +71,33 @@ _STOP = frozenset("a an and the of to for in on with use when or is are be this that it its from by as at".split()) -def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: int | None = None) -> dict[str, Any]: +def _nested_unscoped(tree: RepoTree, files) -> set: + """(rule target, directory above its .claude/rules) for each unscoped rule in a nested .claude/rules.""" + return {(_target(tree, f), f.split(".claude/rules/", 1)[0]) for f in files + if RULE_GLOB.match(f) and not f.startswith(".claude/rules/") and not _rule(tree, f)[1]} + + +def _cut_is_quiet(tree: RepoTree, target: str, root: str, loaded, pair_lazy: bool, pair_start: bool, + nested_unscoped: set) -> bool: + """True when a cut import needs no warning: another chain loaded its target, or it is a cut lazy import of an + unscoped nested rule from guidance in that rule's subtree (the rule loads with that guidance anyway). + + ``root`` is the guidance file the chain of the cut import starts from: that guidance, not the importing file, + decides whether the rule loads with it. The second exemption does not cover a pair the start chain cut too: + that rule does not load at start.""" + if _target(tree, target) in loaded: + return True # another chain did load it, possibly through a link + return pair_lazy and not pair_start and any( + _target(tree, target) == t and root.startswith(sub) for t, sub in nested_unscoped) + + +def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: int | None = None, + rules: bool = False) -> dict[str, Any]: files = tree.files() names = set(guidance_names) - every = [f for f in files if f.rsplit("/", 1)[-1] in names] + # under .claude/rules a file is a rule whatever its name (a rules/CLAUDE.md is not guidance), when + # the harness reads rules; otherwise it is an ordinary file of the profile's guidance names + every = [f for f in files if f.rsplit("/", 1)[-1] in names and not (rules and RULE_GLOB.match(f))] guidance = _unaliased(tree, every) groups: dict[str, list[str]] = {} for f in every: @@ -73,17 +109,18 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in # working directory or above it, so any root one of those turns AGENTS.md off. Only those that # resolve (not dangling/out-of-tree) count; unreadable UTF-8 still counts. root_claude_for_off = [f for f in root_claude if _is_resolvable(tree, f)] + displaced = bool(root_claude_for_off) # A root CLAUDE.md's imports load at start; a nested one's when the agent reaches its subtree. - at_start, cut_start = _imports(tree, root_claude_for_off, import_max_depth) - lazy_all, cut_lazy = _imports(tree, [f for f in claude if f not in root_claude], import_max_depth) + at_start, cut_start = _imports(tree, root_claude_for_off, import_max_depth, rules) + lazy_all, cut_lazy = _imports(tree, [f for f in claude if f not in root_claude], import_max_depth, rules) # An AGENTS.md Claude reads on its own expands its @imports from hop 0: root ones at start, nested # ones when the agent reaches their subtree. One a root CLAUDE* displaces is read only through an # import, whose chain already follows it at its real hop count, unless it is a root CLAUDE* alias. agents = [f for f in every if f.rsplit("/", 1)[-1] == "AGENTS.md" and _is_resolvable(tree, f) - and (not root_claude_for_off or any(a in root_claude for a in groups.get(_target(tree, f), [f])))] - agents_at_start, cut_agents_start = _imports(tree, [f for f in agents if _is_root(f)], import_max_depth) - agents_lazy, cut_agents_lazy = _imports(tree, [f for f in agents if not _is_root(f)], import_max_depth) + and (not displaced or any(a in root_claude for a in groups.get(_target(tree, f), [f])))] + agents_at_start, cut_agents_start = _imports(tree, [f for f in agents if _is_root(f)], import_max_depth, rules) + agents_lazy, cut_agents_lazy = _imports(tree, [f for f in agents if not _is_root(f)], import_max_depth, rules) at_start |= agents_at_start lazy_all |= agents_lazy cut_start = cut_start + cut_agents_start @@ -94,66 +131,38 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in entries: list[dict[str, Any]] = [] unreadable_warnings: list[str] = [] + own_t = _own_targets(tree, guidance, groups, claude, displaced, at_start, lazily) for f in guidance: - t = _target(tree, f) - aliases = groups.get(t, [f]) - root_alias = any(a in root_claude for a in aliases) - nested_alias = any(a in claude and a not in root_claude for a in aliases) - if f.rsplit("/", 1)[-1] == "AGENTS.md" and root_claude_for_off: - # a root CLAUDE.md displaces AGENTS.md: it is read only through a CLAUDE.md that reaches it - if root_alias or t in start_t: - when = "start" - elif nested_alias or t in loaded_t: - when = "lazy" - else: - when = "off" - elif root_alias or t in start_t or _is_root(f): - when = "start" - else: - when = "lazy" + when = _guidance_when(tree, f, groups, claude, root_claude, displaced, start_t, loaded_t) chars, warning = _chars(tree, f) if warning: unreadable_warnings.append(warning) entries.append({"path": f, "kind": "guidance", "when": when, "chars": chars}) + # What loads without a rule: a rule promotes a guidance entry below, but that does not make it independently loaded + guidance_loaded = {_target(tree, e["path"]) for e in entries if e["when"] != "off"} counted = {_target(tree, f) for f in guidance} - for f in sorted((at_start | lazily) - set(guidance), key=lambda p: (p not in at_start, p)): - if _is_resolvable(tree, f) and _target(tree, f) not in counted: - counted.add(_target(tree, f)) - chars, warning = _chars(tree, f) - if warning: - unreadable_warnings.append(warning) - entries.append({"path": f, "kind": "import", "when": "start" if f in at_start else "lazy", - "chars": chars}) + entries += _rules(tree, files if rules else [], counted, start_t, unreadable_warnings, entries, own_t) + _import_entries(tree, at_start, lazily, guidance, counted, entries, unreadable_warnings, rules) skills = _skills(tree, [f for f in files if SKILL_GLOB.match(f)]) - for s in skills: - if s["listed"]: - entries.append({"path": s["path"], "kind": "skill listing", "when": "start", "chars": s["listing_chars"]}) - entries.append({"path": s["path"], "kind": "skill body", "when": "on demand", "chars": s["body_chars"]}) + entries += _skill_entries(skills) totals = {w: sum(e["chars"] for e in entries if e["when"] == w) for w in ("start", "lazy", "on demand", "off")} - warnings = unreadable_warnings[:] - # A guidance file is an entry of its own, so a cut import of it is already loaded (not an import). - loaded = {_target(tree, p) for p in at_start | lazy_all} | { - _target(tree, e["path"]) for e in entries if e["kind"] == "guidance" and e["when"] != "off"} - for f, target in sorted(set(cut_start + cut_lazy)): - if _target(tree, target) in loaded: - continue # another chain did load it, possibly through a link - if not _is_resolvable(tree, target): - continue # nothing to load, as with a missing import within the limit - warnings.append(f"{f}: import of {target} is past the harness's {import_max_depth}-hop import limit, " - "not followed") + # A guidance file or a rule is an entry of its own, so a cut import of it is already loaded (not an import). + # A scoped rule (lazy) loads only when a matching file is read, so it does not cover a cut import at start. + loaded = {_target(tree, p) for p in at_start | lazy_all} | guidance_loaded | { + _target(tree, e["path"]) for e in entries if e["when"] == "start" and e["kind"] in ("guidance", "rule")} + # An unscoped nested rule loads for any work in its subtree, so a cut import of it there skips nothing. + # A path-scoped rule, or one imported from outside its subtree, does not load with its importer: still named. + nested_unscoped = _nested_unscoped(tree, files) if rules else set() + warnings = unreadable_warnings + _cut_warnings(tree, cut_start, cut_lazy, loaded, nested_unscoped, + import_max_depth) off = [e["path"] for e in entries if e["when"] == "off"] if off: warnings.append(f"{', '.join(root_claude_for_off)} turns AGENTS.md loading off and no CLAUDE.md " f"imports these, so Claude Code reads none of {', '.join(off)} unless the agent opens " "them (import each with `@AGENTS.md` from a CLAUDE.md beside it; `repo reach` measures it)") - for s in skills: - if s["unreadable"]: - warnings.append(f"{s['path']}: not readable ({s['unreadable']}), body counted as 0") - elif s["listed"] and not s["description"]: - warnings.append(f"{s['path']}: no description and an empty body; the listing gives the agent nothing " - "to choose it by") + warnings += _skill_warnings(skills) duplicates = _near_duplicates([s for s in skills if s["listed"]]) for d in duplicates: warnings.append(f"skills {d['a']} and {d['b']} have near-duplicate descriptions " @@ -169,6 +178,113 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in } +def _guidance_when(tree: RepoTree, f: str, groups: dict[str, list[str]], claude: list[str], root_claude: list[str], + displaced: bool, start_t: set[str], loaded_t: set[str]) -> str: + """When a guidance file loads: ``start``, ``lazy`` (its subtree, or an import reached later) or ``off``.""" + t = _target(tree, f) + aliases = groups.get(t, [f]) + root_alias = any(a in root_claude for a in aliases) + if displaced and f.rsplit("/", 1)[-1] == "AGENTS.md": + # a root CLAUDE.md displaces AGENTS.md: it is read only through a CLAUDE.md that reaches it + if root_alias or t in start_t: + return "start" + nested_alias = any(a in claude and a not in root_claude for a in aliases) + return "lazy" if nested_alias or t in loaded_t else "off" + return "start" if root_alias or t in start_t or _is_root(f) else "lazy" + + +def _cut_warnings(tree: RepoTree, cut_start: list[tuple[str, str, str]], cut_lazy: list[tuple[str, str, str]], + loaded: set[str], nested_unscoped: set, import_max_depth: int | None) -> list[str]: + """One warning per cut import that no chain loads (``_cut_is_quiet``), naming every root whose chain cuts it.""" + cut_lazy_set = {(f, p) for f, p, _ in cut_lazy} + cut_start_set = {(f, p) for f, p, _ in cut_start} + roots_of: dict[tuple[str, str], set[str]] = {} # a cut pair names every guidance file whose chain cuts it + for f, target, root in cut_start + cut_lazy: + roots_of.setdefault((f, target), set()).add(root) + out = [] + for (f, target), roots in sorted(roots_of.items()): + if all(_cut_is_quiet(tree, target, root, loaded, (f, target) in cut_lazy_set, (f, target) in cut_start_set, + nested_unscoped) for root in roots): + continue + if not _is_resolvable(tree, target): + continue # nothing to load, as with a missing import within the limit + out.append(f"{f}: import of {target} is past the harness's {import_max_depth}-hop import limit, " + "not followed") + return out + + +def _skill_entries(skills: list[dict[str, Any]]) -> list[dict[str, Any]]: + """The listing (at start, when listed) and the body (on demand) of each skill.""" + out = [] + for s in skills: + if s["listed"]: + out.append({"path": s["path"], "kind": "skill listing", "when": "start", "chars": s["listing_chars"]}) + out.append({"path": s["path"], "kind": "skill body", "when": "on demand", "chars": s["body_chars"]}) + return out + + +def _skill_warnings(skills: list[dict[str, Any]]) -> list[str]: + out = [] + for s in skills: + if s["unreadable"]: + out.append(f"{s['path']}: not readable ({s['unreadable']}), body counted as 0") + elif s["listed"] and not s["description"]: + out.append(f"{s['path']}: no description and an empty body; the listing gives the agent nothing " + "to choose it by") + return out + + +def _import_entries(tree: RepoTree, at_start: set[str], lazily: set[str], guidance: list[str], counted: set[str], + entries: list[dict[str, Any]], unreadable_warnings: list[str], rules: bool = False) -> None: + """Add an entry for each import that is neither guidance nor a rule of its own (``counted`` grows with them).""" + rule_entries = {_target(tree, e["path"]): e for e in entries if e["kind"] == "rule"} + for f in sorted((at_start | lazily) - set(guidance), key=lambda p: (p not in at_start, p)): + if not _is_resolvable(tree, f): + continue + t = _target(tree, f) + when = "start" if f in at_start else "lazy" + if t in counted: + # A rule link counted this file with its frontmatter removed. An ordinary @import keeps the frontmatter, + # so its count replaces the rule's when it loads at the same time or earlier (a rule file is left alone). + rule = rule_entries.get(t) + if rule is None or RULE_GLOB.match(f) or RULE_GLOB.match(t) or (when == "lazy" and rule["when"] == "start"): + continue + chars, warning = _chars(tree, f) + if warning: + unreadable_warnings.append(warning) + rule.update(path=f, kind="import", when=when, chars=chars) + continue + counted.add(t) + chars, warning = _chars(tree, f) + if warning: + unreadable_warnings.append(warning) + elif rules and (RULE_GLOB.match(f) or RULE_GLOB.match(t)): + chars = len(_without_frontmatter(tree.read_text(f))) + entry = {"path": f, "kind": "import", "when": when, "chars": chars} + entries.append(entry) + if rules and not warning and (RULE_GLOB.match(f) or RULE_GLOB.match(t)): + # a rule-path import whose rules dir is a link is not a rule entry, but its count is body-only too: + # an ordinary import of the same file, later in the loop, replaces it (as it replaces a rule entry) + rule_entries[t] = entry + + +def _own_targets(tree: RepoTree, guidance: list[str], groups: dict[str, list[str]], claude: list[str], + displaced: bool, at_start: set[str], lazily: set[str]) -> set[str]: + """The guidance files read on their own, or through an ordinary import (not only a rule link).""" + own = set() + start_t = {_target(tree, p) for p in at_start} + for f in guidance: + t = _target(tree, f) + aliases = groups.get(t, [f]) + if any(a in claude for a in aliases) or not (displaced and f.rsplit("/", 1)[-1] == "AGENTS.md"): + own.add(t) + for p in at_start | lazily: + t = _target(tree, p) + if not (RULE_GLOB.match(p) or RULE_GLOB.match(t)) and (p in at_start or t not in start_t): + own.add(t) + return own + + def _is_root(path: str) -> bool: """Guidance Claude Code reads at start: at the root, or the project memory in `.claude/`.""" return "/" not in path or path in (".claude/CLAUDE.md", ".claude/AGENTS.md") @@ -217,43 +333,269 @@ def _chars(tree: RepoTree, path: str) -> tuple[int, str | None]: return 0, f"{path}: not readable ({_unreadable_reason(err)}), counted as 0" -def _imports(tree: RepoTree, roots: list[str], max_depth: int | None = None) -> tuple[set[str], list[tuple[str, str]]]: +def _rules(tree: RepoTree, files: list[str], counted: set[str], start_t: set[str], + warnings: list[str], guidance: list[dict[str, Any]], own_t: set[str]) -> list[dict[str, Any]]: + """The ``rule`` entries of ``files``, skipping files already ``counted`` (and adding theirs). + + A rule that is the same file as a guidance entry (in ``guidance``) is not counted twice: that entry + takes the earlier of the two loading conditions, so a root rule linked to an AGENTS.md that a root + CLAUDE.md turns off loads it at start. + + ``budget`` passes no files for a harness that does not read ``.claude/rules`` (``guidance.claude_rules``). + """ + rank = {"off": 0, "lazy": 1, "start": 2} + by_target = {_target(tree, e["path"]): e for e in guidance} + out = [] + dirs: set[str] = set() + for f in sorted(f for f in files if _RULES_ENTRY.match(f)): + try: + if tree.is_dir(tree.resolve(f, dirs=True)): # a tree lists a link to a directory as one entry + warnings.append(f"{f}: a link to a directory; the rules behind it are not counted") + dirs.add(f) + except (OSError, ValueError, RuntimeError): # out of the tree (a shared rules dir), dangling or a loop + # A link that does not resolve may be a directory whatever its name, and the tree cannot tell which: + # any such entry is named, except a Markdown rule, which _rule names as unreadable and counts as 0. + if not RULE_GLOB.match(f): + warnings.append(f"{f}: a link that does not resolve in the tree; any rules behind it are not counted") + for f in sorted(f for f in files if RULE_GLOB.match(f) and f not in dirs): + target = _target(tree, f) + chars, scoped, warning = _rule(tree, f) + # a CLAUDE.md that imports a rule at start loads it then, whatever its frontmatter says + when = "start" if (f.startswith(".claude/rules/") and not scoped) or target in start_t else "lazy" + if target in counted: + # a link to a file already counted (a guidance file, or another rule): the same bytes, loaded once. + # The entry takes the earlier loading condition, and that route's count: a rule has its frontmatter removed. + entry = by_target.get(target) + # guidance that loads only through this rule link takes the rule's count even at the same condition + if entry is not None and (rank[when] > rank[entry["when"]] + or (rank[when] == rank[entry["when"]] and target not in own_t)): + entry["when"] = when + entry["chars"] = chars + continue + counted.add(target) + if warning: + warnings.append(warning) + entry = {"path": f, "kind": "rule", "when": when, "chars": chars} + out.append(entry) + by_target[target] = entry + return out + + +def _rule(tree: RepoTree, path: str) -> tuple[int, bool, str | None]: + """Return (chars in context, has ``paths``, warning) for a ``.claude/rules`` file. + + Claude Code removes the frontmatter before loading a rule, so it is not counted. A rule with + a non-empty ``paths`` loads only when the agent reads a matching file. Measured on Claude Code + 2.1.304, the key is read line by line: it holds even when the rest of the YAML does not parse + (the docs say such a rule loads unconditionally), while ``paths: []`` loads at start. A rule + whose only pattern is invalid (``[unclosed``) never loads; it is counted lazy. + """ + try: + text = tree.read_text(path) + except (OSError, ValueError, RuntimeError) as err: # UnicodeDecodeError is a ValueError; RuntimeError a loop + return 0, False, f"{path}: not readable ({_unreadable_reason(err)}), counted as 0" + text = text.removeprefix("\ufeff") # a UTF-8 BOM from an editor is not part of the frontmatter (as in skills) + m = _FRONTMATTER.match(text) + if not m: + return len(text), False, None + return len(text) - m.end(), _has_paths(m.group(1)), None + + +def _without_frontmatter(text: str) -> str: + text = text.removeprefix("\ufeff") + m = _FRONTMATTER.match(text) + return text[m.end():] if m else text + + +def _has_paths(frontmatter: str) -> bool: + """True if ``paths`` names at least one pattern: a string, or a list item that is a string, not blank. + + A value that is not a string (a number, a boolean, a mapping) names no pattern, and a container is never + converted to text: a YAML alias chain expands on ``str()``. When the frontmatter does not parse, the + ``paths`` key is read on its own (``_paths_block``), as Claude Code reads it. + """ + try: + meta = yaml.safe_load(frontmatter) + except (yaml.YAMLError, RecursionError, ValueError): # RecursionError: nesting too deep; ValueError: an integer over the digit limit + meta = None # Claude Code still reads a `paths:` line of YAML that does not parse + if isinstance(meta, dict): + return _value_names(meta.get("paths")) + block = _paths_block(frontmatter) + if block is None: + return False + try: + own = yaml.safe_load("\n".join(block)) + except (yaml.YAMLError, RecursionError, ValueError): + own = None + if isinstance(own, dict): # the key parses on its own, whatever the rest of the frontmatter does + return _value_names(own.get("paths")) + # the key itself does not parse (`[unclosed`): its text names a pattern unless every part is empty + values = [_without_comment(block[0].split(":", 1)[1])] + for line in block[1:]: + s = line.strip() + if s and not s.startswith("#"): + values.append(_without_comment(s[1:] if s.startswith("-") else s)) + return any(_names_pattern(v) for v in values) + + +def _value_names(value: object) -> bool: + """True if a parsed ``paths`` value names a pattern: a string (split on commas) or a list with a non-blank string.""" + items = value if isinstance(value, list) else value.split(",") if isinstance(value, str) else [] + return any(isinstance(v, str) and v.strip() for v in items) + + +def _names_pattern(value: str) -> bool: + """True if one unparsed ``paths`` value (the inline value or a list item) names a pattern. + + Brackets and commas are split as YAML would; an empty item, ``null`` and ``~`` name nothing, and quotes + around an item are not part of it (``"null"`` is the pattern ``null``). + """ + for part in value.strip().strip("[]").split(","): + part = part.strip() + if part in ("", "null", "~"): + continue + if part.strip("\"'").strip(): + return True + return False + + +def _paths_block(frontmatter: str) -> list[str] | None: + """The lines of the ``paths`` key: its line, then the lines that continue it (indented, ``- item``, blank or comment). + + None when there is no ``paths`` key. A flow list that runs over several lines (``paths: [`` then ``"src/**"]``) + is one block, so its continuation lines are read with the key. + """ + lines = frontmatter.splitlines() + for i, line in enumerate(lines): + if _PATHS_LINE.match(line): + block = [line] + depth = _flow_depth(line) # a flow list open on the key runs on, unindented or not + for item in lines[i + 1:]: + if depth == 0 and _INDENTED_KEY.match(item): # an indented field of another key ends the block + break + if depth > 0 or not item.strip() or item[:1] in (" ", "\t", "-", "#"): + block.append(item) + depth += _flow_depth(item) + else: + break + return block + return None + + +def _flow_depth(line: str) -> int: + """Open minus closed brackets on a line, a YAML comment left out (``paths: [] # [x`` is closed).""" + text = _without_comment(line) + return text.count("[") - text.count("]") + + +def _without_comment(text: str) -> str: + """``text`` up to a YAML comment: a `#` that starts a word, outside a quoted scalar (``"src/#x"`` keeps it).""" + quote = None + prev = " " + for i, ch in enumerate(text): + if quote: + if ch == quote: + quote = None + elif ch in "\"'" and prev in " \t[,": + quote = ch + elif ch == "#" and prev in " \t": + return text[:i] + prev = ch + return text + + +def _imports(tree: RepoTree, roots: list[str], max_depth: int | None = None, + rules: bool = False) -> tuple[set[str], list[tuple[str, str, str]]]: """Files the CLAUDE.md ``roots`` ``@import``, transitively (relative to the importing file). - Breadth first, so a file is placed at its shortest hop count. With ``max_depth``, imports of - files already ``max_depth`` hops from a root are not followed; those left unreached come back - as ``(importing file, target)`` pairs. + Breadth first, so a file is placed at its shortest hop count. A rule an import reaches is followed like any + other file (its @ paths are imports only because a CLAUDE.md imports the rule: a rule that no CLAUDE.md imports + is never read here). With ``max_depth``, imports of files already ``max_depth`` hops from a root are not + followed; those left unreached come back as ``(importing file, target, root)``, one for each guidance + file whose import chain reaches the importing file (a file reached from two roots, one through a shared + file that is read once, names both). """ seen: set[str] = set() - # Also by the file a path resolves to: an alias (CLAUDE.md -> .) names the same file under - # ever longer paths, which a path-string check never recognises as a cycle. + # Also by the file a path resolves to, so an alias (CLAUDE.md -> .) is not walked again under a longer path. + # Expanded once per route kind (rule path or not): a rule link reached first must not hide a later import. reached = {_target(tree, r) for r in roots} + reached_by_rule: set[str] = set() level = list(roots) cut: list[tuple[str, str]] = [] + edges: list[tuple[str, str]] = [] # every import read from an expanded file, by the files it links depth = 0 while level: nxt: list[str] = [] for f in level: - try: - text = tree.read_text(f) - except (OSError, ValueError, RuntimeError): # RuntimeError: a link loop before Python 3.13 + text = _import_text(tree, f, roots, rules) + if text is None: continue here = f.rsplit("/", 1)[0] + "/" if "/" in f else "" for target in IMPORT.findall(_strip_code_spans(_CODE_BLOCK.sub("", text))): if target.startswith("/"): continue # an absolute path names the host filesystem, not a file of the tree path = _normalize(here + target) - if not path or path in seen or _target(tree, path) in reached: + if not path: + continue + edges.append((_target(tree, f), _target(tree, path))) + if path in seen: + continue + t = _target(tree, path) + done = reached_by_rule if rules and RULE_GLOB.match(path) else reached + if t in done: continue if max_depth is not None and depth >= max_depth: cut.append((f, path)) continue seen.add(path) - reached.add(_target(tree, path)) + done.add(t) nxt.append(path) level = nxt depth += 1 - return seen, [(f, p) for f, p in cut if p not in seen] + reach = _roots_reaching(tree, edges, roots, max_depth) + return seen, [(f, p, r) for f, p in cut if p not in seen for r in sorted(reach.get(_target(tree, f), ()))] + + +def _import_text(tree: RepoTree, f: str, roots: list[str], rules: bool) -> str | None: + """The text whose @paths ``_imports`` follows from ``f``; None when ``f`` cannot be read.""" + try: + text = tree.read_text(f) + except (OSError, ValueError, RuntimeError): # RuntimeError: a link loop before Python 3.13 + return None + if rules and f not in roots and (RULE_GLOB.match(f) or RULE_GLOB.match(_target(tree, f))): + text = _without_frontmatter(text) # a rule's frontmatter is removed before it loads (as _rule counts) + return text + + +def _roots_reaching(tree: RepoTree, edges: list[tuple[str, str]], roots: list[str], + max_depth: int | None) -> dict[str, set[str]]: + """For each file (by its target), the guidance roots whose import chains reach it within ``max_depth`` hops. + + The traversal of ``_imports`` reads a file once, so a second root that reaches it is not walked again. Each root + is therefore walked over the recorded import edges with the same hop limit: a file past the limit for a root is + not that root's (its import chain is cut there, not loaded). + """ + adj: dict[str, list[str]] = {} + for src, dst in edges: + adj.setdefault(src, []).append(dst) + reach: dict[str, set[str]] = {} + for r in roots: + start = _target(tree, r) + reach.setdefault(start, set()).add(r) + seen = {start} + level = [start] + depth = 0 + while level and (max_depth is None or depth < max_depth): + nxt: list[str] = [] + for node in level: + for dst in adj.get(node, ()): + if dst not in seen: + seen.add(dst) + reach.setdefault(dst, set()).add(r) + nxt.append(dst) + level = nxt + depth += 1 + return reach def _normalize(path: str) -> str: diff --git a/dokimi/dokimi/repos/harness_profile.py b/dokimi/dokimi/repos/harness_profile.py index 07f401058..502ae78ee 100644 --- a/dokimi/dokimi/repos/harness_profile.py +++ b/dokimi/dokimi/repos/harness_profile.py @@ -74,6 +74,11 @@ def import_max_depth(self) -> int | None: depth = self.guidance.get("import_max_depth") return int(depth) if depth is not None else None + @property + def reads_claude_rules(self) -> bool: + """Whether the harness reads ``.claude/rules/**/*.md`` (``guidance.claude_rules``).""" + return bool(self.guidance.get("claude_rules")) + def applied_args(self, tools: str, present: frozenset[str]) -> list[str]: """The ``args_if_present`` names :meth:`argv` adds: those in ``present``, in the ``all`` tool mode only.""" if tools != "all": # --tools does not limit MCP tools: only here is the repo's MCP config part of the arm diff --git a/dokimi/dokimi/repos/profiles/claude-code.yaml b/dokimi/dokimi/repos/profiles/claude-code.yaml index 54fdf12b2..96c5909a9 100644 --- a/dokimi/dokimi/repos/profiles/claude-code.yaml +++ b/dokimi/dokimi/repos/profiles/claude-code.yaml @@ -84,6 +84,8 @@ guidance: imports: "@path" # Memory docs (code.claude.com/docs/en/memory): imports recurse "with a maximum depth of four hops". import_max_depth: 4 + # .claude/rules/**/*.md: measured on CLI 2.1.304, a root rule with no `paths` loads at start. + claude_rules: true notes: >- Measured on CLI 2.1.286: it reads AGENTS.md only when no CLAUDE.md is in the directory chain it starts in, and one CLAUDE.md diff --git a/dokimi/dokimi/repos/receipt.py b/dokimi/dokimi/repos/receipt.py index 0b60ca44a..62678f3cf 100644 --- a/dokimi/dokimi/repos/receipt.py +++ b/dokimi/dokimi/repos/receipt.py @@ -158,10 +158,10 @@ def _known(reach: dict[str, Any]) -> set[str]: def _budget_when(reach: dict[str, Any], budget: dict[str, Any]) -> dict[str, str]: - """budget's ``when`` of each guidance file and import, keyed by the file the path resolves to.""" + """budget's ``when`` of each guidance file, import and rule, keyed by the file the path resolves to.""" target_of = {alias: item["path"] for item in reach["files"] for alias in item.get("aliases", [])} return {target_of.get(e["path"], e["path"]): e["when"] - for e in budget["entries"] if e["kind"] in ("guidance", "import", "skill listing")} + for e in budget["entries"] if e["kind"] in ("guidance", "import", "rule", "skill listing")} def _own_guidance(reach: dict[str, Any], budget: dict[str, Any], tree: RepoTree) -> frozenset[str]: diff --git a/dokimi/dokimi/schemas/harness_profile.v1.json b/dokimi/dokimi/schemas/harness_profile.v1.json index 956b85b2d..7a49514a7 100644 --- a/dokimi/dokimi/schemas/harness_profile.v1.json +++ b/dokimi/dokimi/schemas/harness_profile.v1.json @@ -52,6 +52,7 @@ "resolution": {"type": "string"}, "imports": {"type": ["string", "null"]}, "import_max_depth": {"type": "integer", "minimum": 1, "description": "How many hops of @imports the harness follows from a guidance file; absent: unlimited."}, + "claude_rules": {"type": "boolean", "description": "The harness reads .claude/rules/**/*.md (repo budget counts them); absent: false."}, "notes": {"type": "string"} } }, diff --git a/dokimi/dokimi/schemas/repo_budget.v1.json b/dokimi/dokimi/schemas/repo_budget.v1.json index b8d6a43d8..309a1c0fd 100644 --- a/dokimi/dokimi/schemas/repo_budget.v1.json +++ b/dokimi/dokimi/schemas/repo_budget.v1.json @@ -17,7 +17,7 @@ "additionalProperties": false, "properties": { "path": {"type": "string"}, - "kind": {"enum": ["guidance", "import", "skill listing", "skill body"], "description": "`import` is a file a CLAUDE.md or AGENTS.md `@import`s; a skill has a `skill listing` entry unless it is hidden from the model, and always a `skill body` entry."}, + "kind": {"enum": ["guidance", "import", "rule", "skill listing", "skill body"], "description": "`import` is a file a CLAUDE.md or AGENTS.md `@import`s; `rule` is a `.claude/rules/**/*.md` file, `start` at the root unless its frontmatter's `paths` names a pattern; a skill has a `skill listing` entry unless it is hidden from the model, and always a `skill body` entry."}, "when": {"enum": ["start", "lazy", "on demand", "off"]}, "chars": {"type": "integer", "minimum": 0, "description": "Characters in context; 0 for an unreadable file, which is also named in `warnings`."} } diff --git a/dokimi/scripts/growth_baseline.json b/dokimi/scripts/growth_baseline.json index b0b75b387..340190c54 100644 --- a/dokimi/scripts/growth_baseline.json +++ b/dokimi/scripts/growth_baseline.json @@ -70,7 +70,6 @@ "dokimi/report/model_compare.py::extract_scores": {"complexity": 16}, "dokimi/report/subject_report.py::render_subject_scorecard": {"complexity": 12}, "dokimi/report/task_runs_report.py::render_task_runs_report": {"complexity": 22}, - "dokimi/repos/budget.py::budget": {"complexity": 21, "rationale": "pre-existing when the gate was introduced"}, "dokimi/repos/environment/__init__.py::inspect_repo": {"complexity": 25, "rationale": "pre-existing when the gate was introduced"}, "dokimi/repos/environment/checks.py::_python": {"complexity": 11, "rationale": "pre-existing when the gate was introduced"}, "dokimi/repos/environment/risks.py::_runs_repository_code": {"complexity": 12, "rationale": "pre-existing when the gate was introduced"}, diff --git a/dokimi/tests/test_repo_budget_rules.py b/dokimi/tests/test_repo_budget_rules.py new file mode 100644 index 000000000..a746d09f5 --- /dev/null +++ b/dokimi/tests/test_repo_budget_rules.py @@ -0,0 +1,680 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""`repo budget` and `.claude/rules`: when each rule loads, and what of it is counted.""" + +from __future__ import annotations + +import pytest + +from dokimi.repos.budget import budget +from dokimi.repos.source import WorkTree + +NAMES = ("CLAUDE.md", "CLAUDE.local.md", "AGENTS.md") + + +def _tree(tmp_path, files): + for rel, text in files.items(): + p = tmp_path / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text, encoding="utf-8") + return WorkTree(tmp_path) + + +def _by_path(payload): + return {(e["path"], e["kind"]): e for e in payload["entries"]} + + +@pytest.mark.outcome("cjm-repo-budget") +def test_rules_start_at_the_root_unless_scoped_and_lazily_in_a_subdirectory(tmp_path): + # Measured on Claude Code 2.1.304: a root rule raised the first prompt by its size; a scoped one, + # a nested one and a rule's @path did not (scratch probe, haiku, first-call prompt tokens). + scoped = "---\npaths:\n - \"src/**\"\n---\nscoped body\n" + payload = budget(_tree(tmp_path, { + ".claude/rules/plain.md": "plain body\n", + ".claude/rules/deep/x.md": "deep body\n", + ".claude/rules/scoped.md": scoped, + ".claude/rules/bad.md": "---\npaths: [unclosed\n---\nbad yaml body\n", + ".claude/rules/imp.md": "See @extra.md\n", + "extra.md": "never loaded\n", + ".claude/rules/notes.txt": "not a rule\n", + "sub/.claude/rules/n.md": "nested body\n", + }), NAMES, rules=True) + got = _by_path(payload) + rules = {p: (e["when"], e["chars"]) for (p, k), e in got.items() if k == "rule"} + assert rules == { + ".claude/rules/plain.md": ("start", len("plain body\n")), + ".claude/rules/deep/x.md": ("start", len("deep body\n")), + ".claude/rules/imp.md": ("start", len("See @extra.md\n")), + # the frontmatter is removed before the rule loads; `paths` holds it back even unparsed + ".claude/rules/scoped.md": ("lazy", len("scoped body\n")), + ".claude/rules/bad.md": ("lazy", len("bad yaml body\n")), + "sub/.claude/rules/n.md": ("lazy", len("nested body\n")), + } + assert ("extra.md", "import") not in got + assert payload["totals"]["start"] == len("plain body\n") + len("deep body\n") + len("See @extra.md\n") + assert payload["warnings"] == [] + + +def test_a_rule_does_not_turn_agents_md_off(tmp_path): + got = _by_path(budget(_tree(tmp_path, {"AGENTS.md": "root\n", ".claude/rules/r.md": "rule\n"}), NAMES, rules=True)) + assert got[("AGENTS.md", "guidance")]["when"] == "start" + assert got[(".claude/rules/r.md", "rule")]["when"] == "start" + + +def test_a_rule_a_root_claude_md_imports_is_one_start_entry(tmp_path): + got = _by_path(budget(_tree(tmp_path, { + "CLAUDE.md": "@.claude/rules/api.md\n", + ".claude/rules/api.md": "---\npaths: src/**\n---\napi\n", + }), NAMES, rules=True)) + assert got[(".claude/rules/api.md", "rule")]["when"] == "start" + assert (".claude/rules/api.md", "import") not in got + + +def test_a_root_rule_linked_to_an_off_agents_md_loads_it_at_start(tmp_path): + # a root CLAUDE.md turns AGENTS.md off, but the rule links to the same bytes and loads them at start + _tree(tmp_path, {"CLAUDE.md": "claude\n", "AGENTS.md": "agents\n"}) + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/agents.md").symlink_to(tmp_path / "AGENTS.md") + except OSError: + pytest.skip("symlinks unavailable") + payload = budget(WorkTree(tmp_path), NAMES, rules=True) + assert _by_path(payload)[("AGENTS.md", "guidance")]["when"] == "start" + assert not [e for e in payload["entries"] if e["when"] == "off"] + assert payload["warnings"] == [] + assert payload["totals"]["start"] == len("claude\n") + len("agents\n") + + +def test_a_root_rule_linked_to_a_nested_guidance_file_loads_it_at_start(tmp_path): + _tree(tmp_path, {"CLAUDE.md": "root\n", "sub/CLAUDE.md": "nested\n"}) + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/nested.md").symlink_to(tmp_path / "sub/CLAUDE.md") + except OSError: + pytest.skip("symlinks unavailable") + payload = budget(WorkTree(tmp_path), NAMES, rules=True) + assert _by_path(payload)[("sub/CLAUDE.md", "guidance")]["when"] == "start" + assert payload["totals"]["start"] == len("root\n") + len("nested\n") + assert payload["totals"]["lazy"] == 0 + + +def test_an_unreadable_rule_is_counted_as_zero_and_named(tmp_path): + (tmp_path / ".claude/rules").mkdir(parents=True) + (tmp_path / ".claude/rules/bin.md").write_bytes(b"\xff\xfe\x00") + payload = budget(WorkTree(tmp_path), NAMES, rules=True) + assert _by_path(payload)[(".claude/rules/bin.md", "rule")]["chars"] == 0 + assert payload["warnings"] == [".claude/rules/bin.md: not readable (non-UTF-8 or binary), counted as 0"] + + +def test_rules_are_counted_only_for_a_harness_that_reads_them(tmp_path): + from dokimi.repos.harness_profile import load_profile + + tree = _tree(tmp_path, {"AGENTS.md": "root\n", ".claude/rules/r.md": "rule\n"}) + for name in ("codex", "copilot"): + profile = load_profile(name) + assert not profile.reads_claude_rules + got = budget(tree, profile.guidance_names, profile.import_max_depth, profile.reads_claude_rules) + assert not [e for e in got["entries"] if e["kind"] == "rule"], name + assert load_profile("claude-code").reads_claude_rules + + +def test_a_profile_that_does_not_read_rules_still_counts_a_rules_file_as_guidance_and_imports(tmp_path): + # Without rules, .claude/rules/AGENTS.md is an ordinary guidance file, and an @import through a + # .claude/rules file is followed (the rule exclusions apply only to a harness that reads rules). + from dokimi.repos.harness_profile import load_profile + + profile = load_profile("codex") + tree = _tree(tmp_path, { + "AGENTS.md": "root @.claude/rules/mid.md\n", + ".claude/rules/AGENTS.md": "nested guidance\n", + ".claude/rules/mid.md": "---\ndescription: see @../../extra.md\n---\nmid @deep.md\n", + ".claude/rules/deep.md": "deep body\n", + "extra.md": "extra body\n", + }) + got = _by_path(budget(tree, profile.guidance_names, profile.import_max_depth, False)) + assert ((".claude/rules/AGENTS.md", "guidance") in got) + assert ((".claude/rules/mid.md", "import") in got) + assert ((".claude/rules/deep.md", "import") in got) + # the frontmatter is not removed for a harness that does not read rules, so its @path is an import too + assert ("extra.md", "import") in got + + +@pytest.mark.parametrize("claude_md", ["@shared.md\n@.claude/rules/r.md\n", "@.claude/rules/r.md\n@shared.md\n"]) +def test_an_ordinary_import_keeps_the_frontmatter_that_a_rule_link_to_the_same_file_removes(tmp_path, claude_md): + # CLAUDE.md imports shared.md whole, frontmatter included; the rule link to it loads only the body. + # Whichever comes first, the file loads at start as an import, so it is counted in full. + text = "---\ndescription: example\n---\nbody\n" + tree = _tree(tmp_path, {"CLAUDE.md": claude_md, "shared.md": text}) + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/r.md").symlink_to(tmp_path / "shared.md") + except OSError: + pytest.skip("symlinks unavailable") + + payload = budget(tree, NAMES, rules=True) + + got = _by_path(payload) + assert got[("shared.md", "import")]["when"] == "start" + assert got[("shared.md", "import")]["chars"] == len(text) + assert payload["warnings"] == [] + + +@pytest.mark.parametrize(("front", "when"), [ + ('paths:\n - "src/**"\n', "lazy"), + ("paths: src/**, lib/**\n", "lazy"), + ('paths: "src/**"\nfoo: [unclosed\n', "lazy"), # measured: the key is read line by line + ('paths: [\n"src/**"]\nunused: [unclosed\n', "lazy"), # an unindented continuation of an open flow list + ("foo: [unclosed\n", "start"), # measured: no paths, so it loads at start + ("paths: []\n", "start"), # measured: an empty list scopes nothing + ('paths: ""\n', "start"), + ("paths:\n", "start"), + ("paths: null\n", "start"), + ("paths: # none yet\n", "start"), + ('paths: [\n "src/**"]\n', "lazy"), + ('paths:\n # the api\n - "src/**"\n', "lazy"), + pytest.param("paths: " + "[" * 3000 + "]" * 3000 + "\n", "start", id="too-deep-for-yaml"), + pytest.param("unused: " + "9" * 5000 + "\n", "start", id="oversized-integer-unrelated-to-paths"), + # the same for YAML that does not parse: the fallback reads the key line by line + pytest.param("paths: # none yet\nfoo: [unclosed\n", "start", id="unparsed-comment-only-value"), + pytest.param("paths: [] # none\nfoo: [unclosed\n", "start", id="unparsed-empty-list-and-comment"), + pytest.param('paths:\n # the api\n - "src/**"\nfoo: [unclosed\n', "lazy", + id="unparsed-comment-line-before-item"), + pytest.param('paths:\n\n - "src/**"\nfoo: [unclosed\n', "lazy", id="unparsed-blank-line-before-item"), + pytest.param('paths: "src/#x" # the api\nfoo: [unclosed\n', "lazy", id="unparsed-quoted-hash-kept"), + # null and empty items name no pattern, with or without unrelated malformed YAML + pytest.param("paths: null\nunused: [unclosed\n", "start", id="unparsed-null-unrelated-malformed"), + pytest.param('paths: ["", ""]\nunused: [unclosed\n', "start", id="unparsed-empty-items-unrelated-malformed"), + pytest.param('paths: ["", ""]\n', "start", id="parsed-empty-items"), + # a value that is not a string names no pattern (not measured either way) + pytest.param("paths: true\n", "start", id="paths-boolean"), + pytest.param("paths: 5\n", "start", id="paths-integer"), + pytest.param("paths: {a: 1}\n", "start", id="paths-mapping"), + # the key is read on its own when the rest of the frontmatter does not parse + pytest.param("paths: true\nunused: [unclosed\n", "start", id="unparsed-boolean-unrelated-malformed"), + pytest.param("paths: 5\nunused: [unclosed\n", "start", id="unparsed-integer-unrelated-malformed"), + pytest.param('paths: [\n "src/**"]\nunused: [unclosed\n', "lazy", id="unparsed-flow-list-over-lines"), + pytest.param('paths: [\n ""]\nunused: [unclosed\n', "start", id="unparsed-flow-list-empty-over-lines"), + # a bracket in a comment is not part of the flow list, so the next key is not pulled into the paths block + pytest.param("paths: [] # [optional\nunused: [unclosed\n", "start", id="unparsed-bracket-in-comment"), + pytest.param("paths: []\n# see [docs\nunused: [unclosed\n", "start", id="unparsed-comment-line-with-bracket"), + # an indented field of another key is not a continuation of the paths value, after a complete value or a list + pytest.param("paths: []\n unused: [unclosed\n", "start", id="unparsed-indented-field-after-empty-value"), + pytest.param("paths: null\n unused: [unclosed\n", "start", id="unparsed-indented-field-after-null"), + pytest.param('paths: []\n "unused": [unclosed\n', "start", id="unparsed-double-quoted-field-after-empty-value"), + pytest.param("paths: []\n 'unused': [unclosed\n", "start", id="unparsed-single-quoted-field-after-empty-value"), + pytest.param("paths: src/**\n unused: [unclosed\n", "lazy", id="unparsed-indented-field-after-pattern"), + pytest.param('paths:\n - "src/**"\n unused: [unclosed\n', "lazy", id="unparsed-indented-field-after-list"), +]) +def test_only_a_paths_value_that_names_a_pattern_scopes_a_rule(tmp_path, front, when): + payload = budget(_tree(tmp_path, {".claude/rules/r.md": f"---\n{front}---\nbody\n"}), NAMES, rules=True) + assert _by_path(payload)[(".claude/rules/r.md", "rule")] == { + "path": ".claude/rules/r.md", "kind": "rule", "when": when, "chars": len("body\n")} + + +def test_a_cut_import_of_a_counted_rule_does_not_warn(tmp_path): + files = {"CLAUDE.md": "@c1.md\n", "c1.md": "@c2.md\n", "c2.md": "@c3.md\n", "c3.md": "@c4.md\n", + "c4.md": "@.claude/rules/r.md\n", ".claude/rules/r.md": "rule\n"} + payload = budget(_tree(tmp_path, files), NAMES, import_max_depth=4, rules=True) + assert _by_path(payload)[(".claude/rules/r.md", "rule")]["when"] == "start" + assert payload["warnings"] == [] + + +def test_a_cut_import_of_a_scoped_rule_warns(tmp_path): + # a scoped rule loads only when a matching file is read, so the cut import is still named + files = {"CLAUDE.md": "@c1.md\n", "c1.md": "@c2.md\n", "c2.md": "@c3.md\n", "c3.md": "@c4.md\n", + "c4.md": "@.claude/rules/r.md\n", ".claude/rules/r.md": "---\npaths: src/**\n---\nrule\n"} + payload = budget(_tree(tmp_path, files), NAMES, import_max_depth=4, rules=True) + assert _by_path(payload)[(".claude/rules/r.md", "rule")]["when"] == "lazy" + assert len(payload["warnings"]) == 1 + assert "c4.md: import of .claude/rules/r.md" in payload["warnings"][0] + + +def test_a_linked_rules_directory_is_named_not_silently_skipped(tmp_path): + (tmp_path / "shared").mkdir() + (tmp_path / "shared/style.md").write_text("style\n", encoding="utf-8") + (tmp_path / ".claude/rules").mkdir(parents=True) + (tmp_path / ".claude/rules/own.md").write_text("own\n", encoding="utf-8") + try: + (tmp_path / ".claude/rules/shared").symlink_to(tmp_path / "shared", target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable") + payload = budget(WorkTree(tmp_path), NAMES, rules=True) + assert [e["path"] for e in payload["entries"] if e["kind"] == "rule"] == [".claude/rules/own.md"] + assert payload["warnings"] == [".claude/rules/shared: a link to a directory; the rules behind it are not counted"] + + +def test_a_rules_link_out_of_the_tree_is_named(tmp_path): + shared = tmp_path / "shared" # the docs' `ln -s ~/shared-claude-rules .claude/rules/shared` + shared.mkdir() + (shared / "style.md").write_text("style\n", encoding="utf-8") + repo = tmp_path / "repo" + (repo / ".claude/rules").mkdir(parents=True) + try: + (repo / ".claude/rules/shared").symlink_to(shared, target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable") + payload = budget(WorkTree(repo), NAMES, rules=True) + assert not [e for e in payload["entries"] if e["kind"] == "rule"] + assert payload["warnings"] == [ + ".claude/rules/shared: a link that does not resolve in the tree; any rules behind it are not counted"] + + +def test_a_rule_with_a_utf8_bom_reads_its_frontmatter(tmp_path): + payload = budget(_tree(tmp_path, {".claude/rules/r.md": "\ufeff---\npaths: src/**\n---\nbody\n"}), + NAMES, rules=True) + assert _by_path(payload)[(".claude/rules/r.md", "rule")] == { + "path": ".claude/rules/r.md", "kind": "rule", "when": "lazy", "chars": len("body\n")} + + +def test_a_paths_value_is_never_stringified(monkeypatch): + # a YAML alias chain expands on str(); only strings are patterns, so a container is never converted + from dokimi.repos import budget as module + + class Bomb: + def __str__(self): + raise AssertionError("a paths value was stringified") + + monkeypatch.setattr(module.yaml, "safe_load", lambda text: {"paths": [Bomb()]}) + assert module._has_paths("paths: x\n") is False + + +def test_a_root_rule_linked_to_an_off_agents_md_is_charged_its_body(tmp_path): + # the rule route strips the frontmatter, so the body is what loads at start + _tree(tmp_path, {"CLAUDE.md": "claude\n", "AGENTS.md": "---\npaths: []\n---\nagents\n"}) + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/agents.md").symlink_to(tmp_path / "AGENTS.md") + except OSError: + pytest.skip("symlinks unavailable") + payload = budget(WorkTree(tmp_path), NAMES, rules=True) + assert _by_path(payload)[("AGENTS.md", "guidance")] == { + "path": "AGENTS.md", "kind": "guidance", "when": "start", "chars": len("agents\n")} + assert payload["totals"]["start"] == len("claude\n") + len("agents\n") + + +def test_a_root_rule_linked_to_a_nested_rule_that_sorts_first_still_loads_at_start(tmp_path): + # "!" sorts before "." : the nested real file is listed first, its root alias must still promote it + _tree(tmp_path, {"!sub/.claude/rules/r.md": "rule body\n"}) + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/r.md").symlink_to(tmp_path / "!sub/.claude/rules/r.md") + except OSError: + pytest.skip("symlinks unavailable") + payload = budget(WorkTree(tmp_path), NAMES, rules=True) + assert _by_path(payload)[("!sub/.claude/rules/r.md", "rule")]["when"] == "start" + assert payload["totals"]["start"] == len("rule body\n") + assert payload["totals"]["lazy"] == 0 + + +def test_a_dangling_link_under_rules_that_is_not_markdown_is_named(tmp_path): + # a link that does not resolve may be a directory of rules (`shared.rules`), which the tree cannot tell apart + (tmp_path / ".claude/rules").mkdir(parents=True) + (tmp_path / ".claude/rules/own.md").write_text("own\n", encoding="utf-8") + try: + (tmp_path / ".claude/rules/shared.rules").symlink_to(tmp_path / "nowhere") + except OSError: + pytest.skip("symlinks unavailable") + payload = budget(WorkTree(tmp_path), NAMES, rules=True) + assert payload["warnings"] == [ + ".claude/rules/shared.rules: a link that does not resolve in the tree; any rules behind it are not counted"] + + +def test_a_rule_a_claude_md_imports_expands_its_at_imports_transitively(tmp_path): + # maintainer decision (intel/dokimi #326): a CLAUDE.md that imports a rule loads it at start, and the @ paths + # in it are followed like any import, hop by hop + files = {"CLAUDE.md": "@.claude/rules/r.md\n", ".claude/rules/r.md": "rule @../../extra.md\n", + "extra.md": "extra @extra2.md\n", "extra2.md": "extra2\n"} + payload = budget(_tree(tmp_path, files), NAMES, rules=True) + assert _by_path(payload)[(".claude/rules/r.md", "rule")]["when"] == "start" + assert _by_path(payload)[("extra.md", "import")]["when"] == "start" + assert _by_path(payload)[("extra2.md", "import")]["when"] == "start" + assert payload["warnings"] == [] + + +def test_a_rule_discovered_as_a_rule_does_not_expand_its_at_imports(tmp_path): + # no CLAUDE.md imports the rule: it loads as a rule, and its @ paths are not followed + files = {".claude/rules/r.md": "rule @../../extra.md\n", "extra.md": "extra\n"} + payload = budget(_tree(tmp_path, files), NAMES, rules=True) + assert _by_path(payload)[(".claude/rules/r.md", "rule")]["when"] == "start" + assert [e["path"] for e in payload["entries"] if e["path"] == "extra.md"] == [] + + +def test_an_at_path_in_a_rules_frontmatter_is_not_an_import(tmp_path): + # the frontmatter is not counted (Claude Code removes it before loading the rule), so an @ path in it is no import + files = {"CLAUDE.md": "@.claude/rules/r.md\n", + ".claude/rules/r.md": "---\ndescription: see @../../team.md\n---\nrule\n", "team.md": "team\n"} + payload = budget(_tree(tmp_path, files), NAMES, rules=True) + assert _by_path(payload)[(".claude/rules/r.md", "rule")]["when"] == "start" + assert [e["path"] for e in payload["entries"] if e["path"] == "team.md"] == [] + + +def test_an_import_chain_through_a_rule_counts_toward_the_hop_limit(tmp_path): + files = {"CLAUDE.md": "@.claude/rules/r.md\n", ".claude/rules/r.md": "rule @../../extra.md\n", + "extra.md": "extra @extra2.md\n", "extra2.md": "extra2\n"} + # CLAUDE.md -> rule (hop 1) -> extra.md (hop 2) -> extra2.md (hop 3): a 2-hop limit cuts extra2.md + payload = budget(_tree(tmp_path, files), NAMES, import_max_depth=2, rules=True) + assert _by_path(payload)[("extra.md", "import")]["when"] == "start" + assert [e["path"] for e in payload["entries"] if e["path"] == "extra2.md"] == [] + assert payload["warnings"] == [ + "extra.md: import of extra2.md is past the harness's 2-hop import limit, not followed"] + + +def test_a_file_named_claude_md_under_rules_is_a_rule_not_guidance(tmp_path): + files = {"CLAUDE.md": "root\n", ".claude/rules/CLAUDE.md": "@extra.md\n", ".claude/rules/extra.md": "extra\n"} + payload = budget(_tree(tmp_path, files), NAMES, rules=True) + assert _by_path(payload)[(".claude/rules/CLAUDE.md", "rule")]["when"] == "start" + assert not [e for e in payload["entries"] if e["kind"] == "guidance" and e["path"].startswith(".claude/rules/")] + + +def test_a_linked_directory_named_like_a_rule_is_named_not_counted_as_a_rule(tmp_path): + (tmp_path / "shared").mkdir() + (tmp_path / "shared/style.md").write_text("style\n", encoding="utf-8") + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/shared.md").symlink_to(tmp_path / "shared", target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable") + payload = budget(WorkTree(tmp_path), NAMES, rules=True) + assert not [e for e in payload["entries"] if e["kind"] == "rule"] + assert payload["warnings"] == [".claude/rules/shared.md: a link to a directory; the rules behind it are not counted"] + + +def test_a_dangling_link_to_a_markdown_rule_is_named_and_counted_as_zero(tmp_path): + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/api.md").symlink_to(tmp_path / "missing.md") + except OSError: + pytest.skip("symlinks unavailable") + payload = budget(WorkTree(tmp_path), NAMES, rules=True) + assert _by_path(payload)[(".claude/rules/api.md", "rule")]["chars"] == 0 + assert payload["warnings"] == [".claude/rules/api.md: not readable (not found, broken link, or outside tree), " + "counted as 0"] + + +def test_an_alias_of_a_rule_is_followed_as_an_import(tmp_path): + # CLAUDE.md imports an alias that links to a rule: the rule is imported, so its @ imports are followed + tree = _tree(tmp_path, {"CLAUDE.md": "@docs/memory/alias.md\n", "extra.md": "extra\n", + ".claude/rules/r.md": "rule @../../extra.md\n"}) + (tmp_path / "docs/memory").mkdir(parents=True) + try: + (tmp_path / "docs/memory/alias.md").symlink_to(tmp_path / ".claude/rules/r.md") + except OSError: + pytest.skip("symlinks unavailable") + + payload = budget(tree, NAMES, rules=True) + + assert _by_path(payload)[("extra.md", "import")]["when"] == "start" + assert _by_path(payload)[(".claude/rules/r.md", "rule")]["when"] == "start" + + +@pytest.mark.parametrize("claude_md", ["@.claude/rules/r.md\n@shared.md\n", "@shared.md\n@.claude/rules/r.md\n"]) +def test_the_order_of_imports_does_not_hide_what_a_rule_link_leads_to(tmp_path, claude_md): + # .claude/rules/r.md links to shared.md, which imports extra.md. Whichever of the two imports comes first, + # shared.md's own route (at the root) still expands extra.md + tree = _tree(tmp_path, {"CLAUDE.md": claude_md, "shared.md": "shared @extra.md\n", "extra.md": "extra\n"}) + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/r.md").symlink_to(tmp_path / "shared.md") + except OSError: + pytest.skip("symlinks unavailable") + + payload = budget(tree, NAMES, rules=True) + + assert _by_path(payload)[("extra.md", "import")]["when"] == "start" + assert payload["warnings"] == [] + + +def test_a_cut_import_of_an_unscoped_nested_rule_does_not_warn(tmp_path): + # a nested rule with no paths loads whenever the agent works in sub/, as the cut import in sub/CLAUDE.md would + files = {"sub/CLAUDE.md": "@.claude/rules/r.md\n", "sub/.claude/rules/r.md": "rule\n"} + payload = budget(_tree(tmp_path, files), NAMES, import_max_depth=0, rules=True) + assert _by_path(payload)[("sub/.claude/rules/r.md", "rule")]["when"] == "lazy" + assert payload["warnings"] == [] + + +def test_a_cut_import_of_an_unscoped_nested_rule_from_outside_its_subtree_still_warns(tmp_path): + # other/ does not load sub/'s rule: only the agent working in sub/ does, so the cut import from other/ is named + files = {"other/CLAUDE.md": "@../sub/.claude/rules/r.md\n", "sub/.claude/rules/r.md": "rule\n"} + payload = budget(_tree(tmp_path, files), NAMES, import_max_depth=0, rules=True) + assert payload["warnings"] == [ + "other/CLAUDE.md: import of sub/.claude/rules/r.md is past the harness's 0-hop import limit, not followed"] + + +def test_a_cut_import_of_an_unscoped_nested_rule_from_a_chain_rooted_outside_its_subtree_still_warns(tmp_path): + # other/CLAUDE.md reaches sub/notes.md, which cuts sub/'s rule: the chain is other/'s, so the rule is not loaded + files = {"other/CLAUDE.md": "@../sub/notes.md\n", "sub/notes.md": "@.claude/rules/x.md\n", + "sub/.claude/rules/x.md": "rule\n"} + payload = budget(_tree(tmp_path, files), NAMES, import_max_depth=1, rules=True) + assert payload["warnings"] == [ + "sub/notes.md: import of sub/.claude/rules/x.md is past the harness's 1-hop import limit, not followed"] + + +def test_a_cut_import_stays_named_for_every_root_that_reaches_it_through_a_shared_file(tmp_path): + # sub/CLAUDE.md reaches sub/notes.md first and zother/CLAUDE.md reaches the same file later: the rule + # loads for the work in sub/ only, so zother/'s route to the cut import must still be named + files = {"sub/CLAUDE.md": "@notes.md\n", "zother/CLAUDE.md": "@../sub/notes.md\n", + "sub/notes.md": "@.claude/rules/x.md\n", "sub/.claude/rules/x.md": "rule\n"} + payload = budget(_tree(tmp_path, files), NAMES, import_max_depth=1, rules=True) + assert payload["warnings"] == [ + "sub/notes.md: import of sub/.claude/rules/x.md is past the harness's 1-hop import limit, not followed"] + + +def test_a_cut_import_is_not_blamed_on_a_root_that_reaches_the_shared_file_past_the_hop_limit(tmp_path): + # sub/'s cut of the unscoped nested rule is quiet (it loads with sub/); zother/ reads mid.md past the limit, + # so its chain must not be blamed for the cut + files = {"sub/CLAUDE.md": "@mid.md\n", "sub/mid.md": "@.claude/rules/r.md\n", "sub/.claude/rules/r.md": "rule\n", + "zother/CLAUDE.md": "@bridge.md\n", "zother/bridge.md": "@../sub/mid.md\n"} + payload = budget(_tree(tmp_path, files), NAMES, import_max_depth=1, rules=True) + assert payload["warnings"] == [] + + +def test_an_alias_cycle_back_to_agents_md_is_not_expanded_again_when_rules_are_off(tmp_path): + # with rules off, .claude/rules/link.md is ordinary guidance: its target is AGENTS.md, already read, so the + # traversal must not expand it again relative to .claude/rules/ (which would import .claude/rules/extra.md) + files = {"AGENTS.md": "@.claude/rules/link.md\n@extra.md\n", "extra.md": "extra\n", + ".claude/rules/extra.md": "rule extra\n"} + tree = _tree(tmp_path, files) + try: + (tmp_path / ".claude/rules/link.md").symlink_to(tmp_path / "AGENTS.md") + except OSError: + pytest.skip("symlinks unavailable") + + payload = budget(tree, NAMES, rules=False) + + assert not [e for e in payload["entries"] if e["path"] == ".claude/rules/extra.md"] + + +def test_a_cut_import_of_a_scoped_nested_rule_still_warns(tmp_path): + # a scoped nested rule loads only when the agent reads a matching file, so the cut import is still named + files = {"sub/CLAUDE.md": "@.claude/rules/r.md\n", "sub/.claude/rules/r.md": "---\npaths: src/**\n---\nrule\n"} + payload = budget(_tree(tmp_path, files), NAMES, import_max_depth=0, rules=True) + assert _by_path(payload)[("sub/.claude/rules/r.md", "rule")]["when"] == "lazy" + assert payload["warnings"] == [ + "sub/CLAUDE.md: import of sub/.claude/rules/r.md is past the harness's 0-hop import limit, not followed"] + + +def test_a_root_claude_md_linked_to_a_rule_still_expands_its_imports(tmp_path): + # the root is memory, not a rule: Claude Code expands its @ imports whatever file it links to + tree = _tree(tmp_path, {"extra.md": "x" * 500 + "\n", ".claude/rules/shared.md": "shared @extra.md\n"}) + try: + (tmp_path / "CLAUDE.md").symlink_to(tmp_path / ".claude/rules/shared.md") + except OSError: + pytest.skip("symlinks unavailable") + + payload = budget(tree, NAMES, rules=True) + + assert _by_path(payload)[("extra.md", "import")]["when"] == "start" + assert _by_path(payload)[("extra.md", "import")]["chars"] == 501 + + +def test_a_scoped_rule_alias_of_agents_md_does_not_suppress_a_cut_import(tmp_path): + # AGENTS.md is off (a root CLAUDE.md displaces it) and is reached only through an import cut at hop 4. + # The rule link to it is scoped, so the file still does not load at start: the cut import is named. + files = {"CLAUDE.md": "@c1.md\n", "c1.md": "@c2.md\n", "c2.md": "@c3.md\n", "c3.md": "@c4.md\n", + "c4.md": "@AGENTS.md\n", "AGENTS.md": "---\npaths: src/**\n---\nagents\n"} + tree = _tree(tmp_path, files) + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/agents.md").symlink_to(tmp_path / "AGENTS.md") + except OSError: + pytest.skip("symlinks unavailable") + + payload = budget(tree, NAMES, import_max_depth=4, rules=True) + + assert _by_path(payload)[("AGENTS.md", "guidance")]["when"] == "lazy" + assert any("c4.md: import of AGENTS.md is past" in w for w in payload["warnings"]) + + +def test_an_off_agents_md_reached_only_through_a_rule_link_is_charged_its_body(tmp_path): + # CLAUDE.md imports the rule link and nothing else loads AGENTS.md, so the rule route's count applies + tree = _tree(tmp_path, {"CLAUDE.md": "@.claude/rules/agents.md\n", "AGENTS.md": "---\npaths: src/**\n---\nagents\n"}) + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/agents.md").symlink_to(tmp_path / "AGENTS.md") + except OSError: + pytest.skip("symlinks unavailable") + + payload = budget(tree, NAMES, rules=True) + + assert _by_path(payload)[("AGENTS.md", "guidance")] == { + "path": "AGENTS.md", "kind": "guidance", "when": "start", "chars": len("agents\n")} + + +@pytest.mark.parametrize("claude_md", ["@AGENTS.md\n@.claude/rules/agents.md\n", "@.claude/rules/agents.md\n@AGENTS.md\n"]) +def test_an_off_agents_md_with_an_ordinary_import_keeps_its_full_count(tmp_path, claude_md): + agents = "---\npaths: []\n---\nagents\n" + tree = _tree(tmp_path, {"CLAUDE.md": claude_md, "AGENTS.md": agents}) + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/agents.md").symlink_to(tmp_path / "AGENTS.md") + except OSError: + pytest.skip("symlinks unavailable") + + payload = budget(tree, NAMES, rules=True) + + assert _by_path(payload)[("AGENTS.md", "guidance")]["chars"] == len(agents) + + +@pytest.mark.parametrize("claude_md", ["@.claude/rules/r.md\n@shared/r.md\n", "@shared/r.md\n@.claude/rules/r.md\n"]) +def test_an_ordinary_import_of_a_rule_behind_a_linked_rules_directory_keeps_its_frontmatter(tmp_path, claude_md): + # .claude/rules is a link to a directory, so no rule entry is made for shared/r.md. The rule-path import loads + # the file without its frontmatter, the ordinary import loads it whole: the start total counts it whole. + text = "---\npaths: []\n---\nbody\n" + tree = _tree(tmp_path, {"CLAUDE.md": claude_md, "shared/r.md": text}) + (tmp_path / ".claude").mkdir() + try: + (tmp_path / ".claude/rules").symlink_to(tmp_path / "shared", target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable") + + payload = budget(tree, NAMES, rules=True) + + assert _by_path(payload)[("shared/r.md", "import")] == { + "path": "shared/r.md", "kind": "import", "when": "start", "chars": len(text)} + assert payload["totals"]["start"] == len(claude_md) + len(text) + assert payload["warnings"] == [".claude/rules: a link to a directory; the rules behind it are not counted"] + + +def test_an_explicit_import_of_a_rule_behind_a_linked_rules_directory_drops_its_frontmatter(tmp_path): + tree = _tree(tmp_path, {"CLAUDE.md": "@.claude/rules/r.md\n", "shared/r.md": "---\npaths: src/**\n---\nbody\n"}) + (tmp_path / ".claude").mkdir() + try: + (tmp_path / ".claude/rules").symlink_to(tmp_path / "shared", target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable") + + payload = budget(tree, NAMES, rules=True) + + assert _by_path(payload)[(".claude/rules/r.md", "import")]["chars"] == len("body\n") + + +def test_a_root_cut_of_a_nested_rule_is_named_when_a_nested_claude_md_cuts_the_same_import(tmp_path): + # the root chain cuts sub/mid.md's import of the rule at hop 1: the rule does not load at start. A nested + # sub/CLAUDE.md reaches the same cut lazily, and that must not silence the start-time warning. + files = {"CLAUDE.md": "@sub/mid.md\n", "sub/mid.md": "@.claude/rules/r.md\n", "sub/.claude/rules/r.md": "rule\n", + "sub/CLAUDE.md": "@mid.md\n"} + payload = budget(_tree(tmp_path, files), NAMES, import_max_depth=1, rules=True) + assert payload["warnings"] == [ + "sub/mid.md: import of sub/.claude/rules/r.md is past the harness's 1-hop import limit, not followed"] + + +@pytest.mark.parametrize(("importer", "link"), [ + ("sub/CLAUDE.md", "@../.claude/rules/r.md\n"), + ("sub/AGENTS.md", "@../.claude/rules/r.md\n"), + ("AGENTS.md", "@.claude/rules/r.md\n"), +]) +def test_a_rule_imported_by_guidance_does_not_import_what_its_frontmatter_names(tmp_path, importer, link): + # a rule's frontmatter is removed before it loads, so an @path in it is not an import, whichever guidance file imports the rule + files = {importer: link, ".claude/rules/r.md": "---\npaths: []\nnote: see @../../extra.md\n---\nbody\n", "extra.md": "x\n"} + payload = budget(_tree(tmp_path, files), NAMES, rules=True) + assert not [e for e in payload["entries"] if e["path"] == "extra.md"] + assert payload["warnings"] == [] + + +def test_a_root_claude_md_linked_to_a_rule_reads_its_frontmatter_as_memory(tmp_path): + # the root CLAUDE.md is memory, read whole: an @ path in its frontmatter is an import, not a rule's stripped text + tree = _tree(tmp_path, {"extra.md": "x\n", ".claude/rules/shared.md": "---\npaths: []\nnote: see @extra.md\n---\nshared\n"}) + try: + (tmp_path / "CLAUDE.md").symlink_to(tmp_path / ".claude/rules/shared.md") + except OSError: + pytest.skip("symlinks unavailable") + + payload = budget(tree, NAMES, rules=True) + + assert _by_path(payload)[("extra.md", "import")]["when"] == "start" + + +def test_a_nested_import_of_a_file_a_root_rule_links_to_keeps_the_rule_at_start(tmp_path): + # the root rule loads the file at start (body only); the nested CLAUDE.md's import of it must not relabel that entry lazy + shared = "---\npaths: []\n---\nshared\n" + tree = _tree(tmp_path, {"sub/CLAUDE.md": "@../shared.md\n", "shared.md": shared}) + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/r.md").symlink_to(tmp_path / "shared.md") + except OSError: + pytest.skip("symlinks unavailable") + + payload = budget(tree, NAMES, rules=True) + + assert _by_path(payload)[(".claude/rules/r.md", "rule")] == { + "path": ".claude/rules/r.md", "kind": "rule", "when": "start", "chars": len("shared\n")} + assert payload["totals"]["lazy"] == len("@../shared.md\n") # the nested CLAUDE.md itself, and nothing else + + +@pytest.mark.parametrize("rules", [True, False]) +def test_a_cut_start_import_of_a_nested_rule_warns_even_when_a_nested_claude_md_also_cuts_it(tmp_path, rules): + # sub/notes.md is imported at start and cuts the rule import; sub/CLAUDE.md imports notes.md and cuts it lazily + files = {"CLAUDE.md": "@sub/notes.md\n", "sub/notes.md": "@.claude/rules/r.md\n", "sub/CLAUDE.md": "@notes.md\n", + "sub/.claude/rules/r.md": "rule\n"} + payload = budget(_tree(tmp_path, files), NAMES, import_max_depth=1, rules=rules) + assert payload["warnings"] == [ + "sub/notes.md: import of sub/.claude/rules/r.md is past the harness's 1-hop import limit, not followed"] + + +def test_the_receipt_budget_reads_rules_as_repo_budget_does(tmp_path, monkeypatch, capsys): + # antonwolfy's tree: AGENTS.md is off, and loads only through the rule link; the receipt must not disagree + from types import SimpleNamespace + + from dokimi.commands import repo as repo_cmd + from dokimi.repos import receipt as receipt_module + + _tree(tmp_path, {"CLAUDE.md": "root\n", "AGENTS.md": "agents guidance\n"}) + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/agents.md").symlink_to(tmp_path / "AGENTS.md") + except OSError: + pytest.skip("symlinks unavailable") + seen = {} + + def fake_build_receipt(reach, budget_payload, tree): + seen["budget"] = budget_payload + return {} + + monkeypatch.setattr(receipt_module, "build_receipt", fake_build_receipt) + monkeypatch.setattr("dokimi.artifacts.save_artifact", lambda name, data, path: {"path": str(path)}) + profile = SimpleNamespace(guidance_names=NAMES, import_max_depth=None, reads_claude_rules=True) + + repo_cmd._print_receipt(WorkTree(tmp_path), profile, {}, tmp_path) + + assert _by_path(seen["budget"])[("AGENTS.md", "guidance")]["when"] == "start" diff --git a/dokimi/tests/test_repo_budget_schema.py b/dokimi/tests/test_repo_budget_schema.py index 2108ff392..3d1e978c0 100644 --- a/dokimi/tests/test_repo_budget_schema.py +++ b/dokimi/tests/test_repo_budget_schema.py @@ -18,12 +18,13 @@ def _populated_tree(root): - """Guidance, an import, two near-duplicate skills, a hidden skill, an MCP server and a warning.""" + """Guidance, an import, a rule, two near-duplicate skills, a hidden skill, an MCP server and a warning.""" files = { "CLAUDE.md": "See @docs/style.md for the style.\n", "AGENTS.md": "Agents read this.\n", # turned off by CLAUDE.md: a warning "docs/style.md": "Use four spaces.\n", "sub/CLAUDE.md": "Nested guidance.\n", + ".claude/rules/testing.md": "Run the tests.\n", ".claude/skills/review/SKILL.md": SKILL.format(name="review", description=DESCRIPTION), ".claude/skills/audit/SKILL.md": SKILL.format(name="audit", description=DESCRIPTION), ".claude/skills/hidden/SKILL.md": "---\nname: hidden\ndisable-model-invocation: true\n---\nBody.\n", @@ -47,7 +48,7 @@ def test_json_is_a_stamped_repo_budget_artifact(tmp_path, capsys): assert data["schema_version"] == "repo_budget.v1" validate_artifact("repo_budget", data) # every field is populated, so the schema was checked against more than empty lists - assert {e["kind"] for e in data["entries"]} == {"guidance", "import", "skill listing", "skill body"} + assert {e["kind"] for e in data["entries"]} == {"guidance", "import", "rule", "skill listing", "skill body"} assert {e["when"] for e in data["entries"]} == {"start", "lazy", "on demand", "off"} assert data["skills"] == 3 assert [(d["a"], d["b"]) for d in data["duplicate_skills"]] == [("audit", "review")] diff --git a/dokimi/tests/test_repo_receipt.py b/dokimi/tests/test_repo_receipt.py index 700935d71..7a0c3e98c 100644 --- a/dokimi/tests/test_repo_receipt.py +++ b/dokimi/tests/test_repo_receipt.py @@ -82,6 +82,20 @@ def test_each_reach_class_maps_to_its_observed_and_thread_states(tmp_path, cls): assert src["states"]["followed"] == "unknown" +def test_a_start_up_rule_imported_by_claude_md_is_a_receipt_source_under_claude_code(tmp_path): + # with rules read, budget counts the import as a rule (not an import): the receipt must still list it + from dokimi.repos.budget import budget + + tree = _tree(tmp_path, {"CLAUDE.md": "@.claude/rules/r.md\n", ".claude/rules/r.md": "body\n"}) + payload = budget(tree, ("CLAUDE.md", "AGENTS.md"), None, True) + assert [(e["path"], e["kind"], e["when"]) for e in payload["entries"] if e["path"].endswith("r.md")] == [ + (".claude/rules/r.md", "rule", "start")] + reach = _reach([_record("CLAUDE.md", "cwd=.", "loaded")], [ROOT_ROW]) + src = _sources(build_receipt(reach, payload, tree), "cwd=.") + assert src[".claude/rules/r.md"]["expected"] is True + assert src[".claude/rules/r.md"]["sha256"] is not None + + def test_a_source_the_probe_did_not_record_has_no_dokimi_method(tmp_path): tree = _tree(tmp_path, {"AGENTS.md": "a\n", "CLAUDE.md": "c\n", "docs/style.md": "d\n"}) reach = _reach([_record("AGENTS.md", "cwd=.", "loaded")], [ROOT_ROW])