diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f4d8a..50a0a31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- Keep explicit guardrail review scoped to the selected file instead of requiring whole-repository instruction discovery first. Unselected broken/external/nonregular sources and unrelated subtrees no longer block a valid review; relevant ignore rules, selected ancestry, supported filenames and canonical filename preference still apply. Directory aliases or ambiguous directory casing require manual integration. Full scans/doctor still reject unreadable sources; confirmation, append guards, scoring and published v0.24.0 artifacts are unchanged. + - Preserve per-repository smoke results and partial progress after acquisition or scanner operational failures instead of aborting and losing earlier rows. Error cases have null scores/findings; acquisition failures never match a clean expectation. Explicitly document the pinned Reaparr dangling instruction symlink as an expected typed operational rejection, separate from the historical clean root-file example. Expectation matching is not all-scans-completed or accuracy. - Check simple full/collapsed Markdown reference links and images against document-relative local targets, reporting a used target at its definition line. Respect document-local, first-definition precedence, skip unused definitions and avoid treating supported definitions/link captions as extra prose paths. Code, ambiguous containers/layouts and unsupported Markdown forms retain explicit coverage limits. Stable rule IDs/penalties, runtime dependencies, published v0.24.0 artifacts and frozen research records are unchanged. diff --git a/docs/guardrail-suggestions.md b/docs/guardrail-suggestions.md index 3d5a95f..58c15e9 100644 --- a/docs/guardrail-suggestions.md +++ b/docs/guardrail-suggestions.md @@ -23,6 +23,16 @@ does not create missing instruction files or silently choose another agent's configuration. Paths must be repository-relative and cannot escape the root. The target must be a regular in-repository UTF-8 file of at most 1 MiB. +Unreleased source builds select this exact file without walking the whole tree. +They inspect the relevant ignore file and selected ancestry/parent names, keeping +supported-file, ignored/pruned-directory and canonical filename-preference rules. +An unselected broken or external instruction link does not block this local +review and is not opened or repaired. This is not in v0.24.0. Directory aliases, +Windows reparse ancestors and ambiguous directory casing require manual +integration rather than guessing a discovery scope. Relevant selection I/O +failures remain errors. Normal scans and `doctor` still check the whole tree +and reject unsafe/unreadable sources; review success cannot bypass that gate. + The preview reads but does not modify files or save a plan. It reports evidence line numbers/kinds without quoting old instruction text, and otherwise proposes a block requiring explicit user approval before deleting existing files, diff --git a/docs/limitations.md b/docs/limitations.md index 773a977..56b169c 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -245,6 +245,15 @@ whole selected file. Narrow approval-bypass directives also require manual resolution even alongside recognized protection; they are unscored review signals, not complete conflict detection or evidence of actual agent behavior. +Unreleased source builds scope explicit guardrail selection without whole-tree +discovery. Only relevant ignore rules and the selected ancestry/parent names are +inspected for selection; unselected broken/external/nonregular instruction files +are not validated or repaired. Directory aliases/reparse ancestors and ambiguous +directory casing require manual integration. This does not change full scans or +doctor: an unsafe source elsewhere still stops the whole-tree check. Selected +review/append success is not a clean repository result. This refinement is not +in v0.24.0; confirmation and non-atomic append boundaries remain unchanged. + The scanner intentionally does not claim to understand arbitrary prose. These inputs can still receive A 100 when they avoid every known deterministic rule: diff --git a/docs/simplified/limitations.md b/docs/simplified/limitations.md index 8834622..a055bd8 100644 --- a/docs/simplified/limitations.md +++ b/docs/simplified/limitations.md @@ -90,6 +90,8 @@ v0.24.0+ 的 `--explain` 和[维护者清单(英文)](../best-practices.md) v0.24.0+ 的 `init --workflow-only` 将策略排除在读写范围外,即使使用 `--force` 也不会重置策略,但也不会验证策略。初始化选定目标若涉及链接、reparse point、共享文件或非普通文件,须手动接入。独占创建和已打开文件检查减少误写风险,不代表完全消除文件系统竞争;初始化不是原子事务,写入失败可能留下部分文件。生成的 `@v0` 不一定与本地 CLI 源码相同。 +尚未发布的源码不再为单文件保护审阅先遍历整仓,仅检查相关忽略规则、所选路径的祖先目录及父目录名称。无关的坏链接、外部或非普通指令文件不在本次验证/修复范围内;目录别名、reparse 祖先及大小写有歧义的目录须手动接入。整仓扫描和 doctor 仍会因不安全来源报错,单文件审阅/追加成功不代表整仓检查通过。此修正不在 v0.24.0 中,确认及非原子追加边界不变。 + ## 评测尚未证明什么 维护的离线对抗用例目前有 474/474 个确定性契约匹配;公开挑战中检出 6/8,仍保留两个语义漏报。这是封闭开发用例的回归结果,**不是现实仓库准确率**。修复已知的虚假成功报告和环境上传案例是开发进展,不是独立准确率估计。 diff --git a/docs/simplified/user-guide.md b/docs/simplified/user-guide.md index 10fafa1..73219e1 100644 --- a/docs/simplified/user-guide.md +++ b/docs/simplified/user-guide.md @@ -116,6 +116,8 @@ agent-config-score guardrails . --file AGENTS.md --template-language simplified 明确标注的示例章节不算有效策略;如果该章节延续到文件末尾,就不能自动追加。少数明确要求跳过删除确认的句式会产生只含行号与类型的 `review_signals`,即使其他位置已识别到保护,也不能自动追加。请先手动处理这些策略;这不是通用的矛盾检测器。 +尚未发布的源码可直接选择 `--file`,不先遍历整仓,因此无关指令文件的坏链接不会阻止这次单文件审阅。相关忽略规则和所选路径的祖先目录检查仍生效;目录别名或大小写有歧义的目录须手动接入。其他文件不会因此被验证或修复,普通扫描/doctor 的失败仍须单独处理;追加成功不代表整仓扫描完整或安全。此范围修正不在 v0.24.0 包中。 + ## 可选:面向维护者的具体建议(v0.24.0+) ```bash diff --git a/docs/traditional/limitations.md b/docs/traditional/limitations.md index 55bc44a..bc0bbd3 100644 --- a/docs/traditional/limitations.md +++ b/docs/traditional/limitations.md @@ -90,6 +90,8 @@ v0.24.0+ 的 `--explain` 和[維護者清單(英文)](../best-practices.md) v0.24.0+ 的 `init --workflow-only` 將策略排除在讀寫範圍外,即使使用 `--force` 也不會重設策略,但也不會驗證策略。初始化選定目標若涉及連結、reparse point、共用檔案或非一般檔案,須手動整合。獨佔建立與已開啟檔案檢查減少誤寫風險,不代表完全消除檔案系統競爭;初始化不是原子交易,寫入失敗可能留下部分檔案。產生的 `@v0` 不一定與本機 CLI 原始碼相同。 +尚未發布的原始碼不再為單檔保護審閱先遍歷整個儲存庫,僅檢查相關排除規則、所選路徑的祖先目錄及父目錄名稱。無關的失效連結、外部或非一般指令檔不在本次驗證/修復範圍內;目錄別名、reparse 祖先及大小寫有歧義的目錄須手動整合。整個儲存庫的掃描及 doctor 仍會因不安全來源報錯,單檔審閱/追加成功不代表整體檢查通過。此修正不在 v0.24.0 中,確認及非原子追加界線不變。 + ## 評測尚未證明什麼 持續維護的離線對抗案例目前有 474/474 個確定性契約符合預期;公開挑戰中偵測到 6/8,仍保留兩個語意漏報。這是封閉開發案例的回歸結果,**不是實際儲存庫的準確率**。修正已知的虛假成功回報與環境上傳案例是開發進展,不是獨立準確率估計。 diff --git a/docs/traditional/user-guide.md b/docs/traditional/user-guide.md index a57612c..fab5de4 100644 --- a/docs/traditional/user-guide.md +++ b/docs/traditional/user-guide.md @@ -116,6 +116,8 @@ agent-config-score guardrails . --file AGENTS.md --template-language simplified 明確標示的範例章節不算有效策略;如果該章節延續到檔案末尾,就不能自動追加。少數明確要求略過刪除確認的句式會產生只含行號與類型的 `review_signals`,即使其他位置已識別到保護,也不能自動追加。請先手動處理這些策略;這不是通用的矛盾偵測器。 +尚未發布的原始碼可直接選擇 `--file`,不先遍歷整個儲存庫,因此無關指令檔的失效連結不會阻止這次單檔審閱。相關排除規則及所選路徑的祖先目錄檢查仍生效;目錄別名或大小寫有歧義的目錄須手動整合。其他檔案不會因此被驗證或修復,一般掃描/doctor 的失敗仍須另行處理;追加成功不代表整個儲存庫已完整掃描或安全。此範圍修正不在 v0.24.0 套件中。 + ## 選用:面向維護者的具體建議(v0.24.0+) ```bash diff --git a/docs/translations.md b/docs/translations.md index 7f90cd3..310d27e 100644 --- a/docs/translations.md +++ b/docs/translations.md @@ -111,6 +111,13 @@ handling and unsupported Markdown/code/layout boundaries. This is an unreleased source update, not a change to v0.24.0 packages, tags, rolling Action refs or frozen research evidence. Executable guide examples are unchanged. +On 2026-10-24, built on `8c1747be614a79692d07882d2c7841fb0ad83543`, both core +guides and limitation summaries were synchronized for scoped guardrail file +selection: no whole-tree traversal, relevant ignore/ancestor checks, manual-only +directory aliases/case ambiguity and unchanged full-scan/doctor failure behavior. +This is unreleased source behavior, not a v0.24.0 artifact change. Executable +guide examples, scoring, confirmation and append boundaries remain unchanged. + When an English source page changes, review both variants together and record the new source revision here. Preserve executable examples, rule IDs and JSON keys verbatim. Keep the score limitations and failure/exit semantics aligned; diff --git a/docs/user-guide.md b/docs/user-guide.md index cba5bcf..a0d2c6a 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -174,6 +174,14 @@ policies manually; this is not a general contradiction detector. See [guardrail suggestions](guardrail-suggestions.md) for grammar, scope, privacy and non-atomic/partial-write limits. Existing scoring and CI defaults are unchanged. +Unreleased source builds resolve `--file` without whole-tree discovery, so an +unrelated broken instruction link does not block this selected-file review. +Relevant ignore rules and selected ancestry still apply; directory aliases or +ambiguous directory casing need manual integration. Other sources are not +certified or repaired. Normal scan/doctor failures still need separate attention; +an append does not turn an incomplete whole-tree scan into a clean result. +This scope refinement is not in the v0.24.0 package. + ## Optional: actionable maintainer results (v0.24.0+) ```bash diff --git a/scripts/verify_installed_package.py b/scripts/verify_installed_package.py index 1730948..b16a08c 100644 --- a/scripts/verify_installed_package.py +++ b/scripts/verify_installed_package.py @@ -240,6 +240,35 @@ def verify(python: Path, expected_version: str) -> list[str]: checks.append("unscored guardrail preview, explicit confirmation, append-only preservation and idempotence") checks.append("guardrail examples, ambiguous layout and opposing approval directives require manual resolution") + selected_repo = workspace / "selected guardrail scope" + selected_repo.mkdir() + selected_source = selected_repo / "AGENTS.md" + selected_original = b"Run tests.\n" + selected_source.write_bytes(selected_original) + dangling = selected_repo / "CLAUDE.md" + has_dangling = False + try: + os.symlink("missing.txt", dangling) + has_dangling = True + except OSError: + pass # Not all Windows hosts permit creating symbolic links. + selected_check = json.loads(run([str(python), "-I", "-c", + "import json; from pathlib import Path; from unittest.mock import patch; " + "from agent_config_score.guardrails import review_guardrails; " + "scope=patch('agent_config_score.scanner.os.walk', side_effect=AssertionError('whole tree walked')); " + "scope.start(); preview=review_guardrails(Path.cwd()); " + "applied=review_guardrails(Path.cwd(), apply=preview.confirmation_token); scope.stop(); " + "print(json.dumps(applied.to_dict()))" + ], selected_repo)) + require(selected_check["applied"] is True and selected_check["scope"] == "selected_file_only", + "Selected guardrail review walked or certified the whole tree") + require(selected_source.read_bytes().startswith(selected_original), "Selected append replaced existing bytes") + if has_dangling: + require(os.readlink(dangling) == "missing.txt", "Unselected broken source was changed") + require(run([*cli, ".", "--json"], selected_repo, expected=2) == "", + "Selected review weakened full-scan source rejection") + checks.append("installed selected-file guardrail review avoids whole-tree discovery; full scan still rejects broken sources where symlinks are available") + scoped_rules = repo / "packages" / "client" / "AGENTS.md" scoped_rules.parent.mkdir(parents=True) instructions.rename(scoped_rules) diff --git a/src/agent_config_score/guardrails.py b/src/agent_config_score/guardrails.py index cf2aae4..08a16ff 100644 --- a/src/agent_config_score/guardrails.py +++ b/src/agent_config_score/guardrails.py @@ -11,7 +11,7 @@ import stat from .provenance import ToolIdentity, capture_tool_identity -from .scanner import _deletion_guardrail_assessment, _instruction_context, _scanner_source_target, discover +from .scanner import _deletion_guardrail_assessment, _instruction_context, _scanner_source_target, _select_instruction_source class GuardrailError(ValueError): @@ -157,9 +157,9 @@ def review_guardrails(root: Path, *, file: str = "AGENTS.md", template_language: if not root.is_dir(): raise GuardrailError("Repository path must be a directory") rel = _relative_file(file) - path = root / rel - if path not in discover(root): - raise GuardrailError("Selected file was not discovered: choose an existing, non-ignored supported instruction file with --file") + path = _select_instruction_source(root, rel) + if path is None: + raise GuardrailError("Selected file is unavailable: choose an existing, non-ignored supported instruction file without directory aliases or ambiguous path casing") target = _scanner_source_target(root, path) try: original, info = _read_source(root, path, target) diff --git a/src/agent_config_score/scanner.py b/src/agent_config_score/scanner.py index bc99bbb..2878181 100644 --- a/src/agent_config_score/scanner.py +++ b/src/agent_config_score/scanner.py @@ -488,6 +488,81 @@ def _prefer_canonical_name(candidate: Path, current: Path) -> bool: return candidate.name < current.name +def _supported_instruction_path(rel: str) -> bool: + path = PurePosixPath(rel) + return (path.name.casefold() in TARGET_NAMES_CASEFOLD + or (rel.startswith((".cursor/rules/", ".claude/", ".github/instructions/")) + and path.suffix.lower() in {".md", ".mdc"})) + + +def _instruction_directory_excluded(rel: str, patterns: list[str]) -> bool: + return PurePosixPath(rel).name in SKIP_DIRS or any( + pattern.endswith("/**") and fnmatch(rel, pattern[:-3]) for pattern in patterns + ) + + +def _select_instruction_source(root: Path, rel: str) -> Path | None: + """Select an exact file without discovering/validating the whole tree. + + Apply the same support, exclusion and filename precedence rules. Only the + selected ancestry/parent names and relevant ignore file are inspected. + Directory aliases and ambiguous directory-case layouts require manual work. + The caller must still validate and safely open the selected file itself. + """ + relative = PurePosixPath(rel) + if (not rel or relative.is_absolute() or PureWindowsPath(rel).drive + or '..' in relative.parts or '\\' in rel or '\x00' in rel + or not _supported_instruction_path(relative.as_posix())): + return None + try: + root = root.resolve(strict=True) + patterns = _ignore_patterns(root) + if _ignored(relative.as_posix(), patterns): + return None + parent = root + for depth, name in enumerate(relative.parts[:-1], 1): + partial = PurePosixPath(*relative.parts[:depth]).as_posix() + if _instruction_directory_excluded(partial, patterns): + return None + directory = parent / name + try: + info = directory.lstat() + except FileNotFoundError: + return None + reparse = getattr(info, 'st_file_attributes', 0) & getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0) + if (not stat.S_ISDIR(info.st_mode) or reparse or directory.is_symlink() + or directory.resolve(strict=True) != directory): + return None + # A whole-tree case-folded winner can depend on another directory. + # Do not inspect that subtree or guess which scope would win. + if sum(entry.name.casefold() == name.casefold() for entry in parent.iterdir()) != 1: + return None + parent = directory + path = root / relative.as_posix() + try: + info = path.lstat() + except FileNotFoundError: + return None + reparse = getattr(info, 'st_file_attributes', 0) & getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0) + if stat.S_ISDIR(info.st_mode) and not reparse: + return None + for entry in parent.iterdir(): + if entry == path or entry.name.casefold() != path.name.casefold(): + continue + sibling = entry.lstat() + sibling_reparse = (getattr(sibling, 'st_file_attributes', 0) + & getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0)) + if stat.S_ISDIR(sibling.st_mode) and not sibling_reparse: + continue + if _prefer_canonical_name(entry, path): + return None + return path + except RuntimeError as exc: + raise OSError(errno.ELOOP, "Unable to select instruction source") from exc + except OSError as exc: + raise OSError(exc.errno, "Unable to select instruction source") from exc + + def discover(root: Path) -> list[Path]: # Keep the caller's path identity instead of resolving filesystem aliases. # macOS may rewrite /var to /private/var and Windows may expand 8.3 paths; @@ -502,9 +577,7 @@ def select(path: Path) -> None: rel = path.relative_to(root).as_posix() if _ignored(rel, patterns): return - if (path.name.casefold() in TARGET_NAMES_CASEFOLD - or (rel.startswith((".cursor/rules/", ".claude/", ".github/instructions/")) - and path.suffix.lower() in {".md", ".mdc"})): + if _supported_instruction_path(rel): key = rel.casefold() current = found.get(key) if current is None or _prefer_canonical_name(path, current): @@ -517,9 +590,7 @@ def select(path: Path) -> None: rel = path.relative_to(root).as_posix() # Only /** patterns prove every descendant is excluded. A literal # directory-name match alone must not change existing glob meaning. - if name in SKIP_DIRS or any( - pattern.endswith("/**") and fnmatch(rel, pattern[:-3]) for pattern in patterns - ): + if _instruction_directory_excluded(rel, patterns): continue if path.is_symlink() or getattr(path, "is_junction", lambda: False)(): select(path) # A link named AGENTS.md must not silently vanish. diff --git a/tests/test_guardrail_scope.py b/tests/test_guardrail_scope.py new file mode 100644 index 0000000..725de70 --- /dev/null +++ b/tests/test_guardrail_scope.py @@ -0,0 +1,214 @@ +"""Explicit guardrail selection must not certify or require a healthy whole tree.""" +import contextlib +import io +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from agent_config_score import guardrails, scanner +from agent_config_score.entrypoint import main + + +class GuardrailSelectionScopeTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name).resolve() + self.source = self.root / "AGENTS.md" + self.source.write_bytes(b"Run tests.\n") + + def invoke(self, args): + stdout, stderr = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + code = main(args) + return code, stdout.getvalue(), stderr.getvalue() + + def link(self, path, target, directory=False): + path.parent.mkdir(parents=True, exist_ok=True) + try: + os.symlink(target, path, target_is_directory=directory) + except OSError: + self.skipTest("host does not permit symbolic links") + + def test_preview_and_apply_do_not_walk_unrelated_directories(self): + blocked = self.root / "unrelated" + blocked.mkdir() + original = self.source.read_bytes() + with patch.object(scanner.os, "walk", side_effect=AssertionError("whole tree enumerated")): + preview = guardrails.review_guardrails(self.root) + self.assertEqual(preview.status, "review_needed") + self.assertEqual(self.source.read_bytes(), original) + applied = guardrails.review_guardrails(self.root, apply=preview.confirmation_token) + self.assertTrue(applied.applied) + self.assertTrue(self.source.read_bytes().startswith(original)) + + def test_unselected_broken_source_does_not_block_review_but_full_scan_still_fails(self): + broken = self.root / "nested" / "CLAUDE.md" + self.link(broken, "absent.md") + with self.assertRaises(OSError): + scanner.analyze(self.root) + code, stdout, stderr = self.invoke(["guardrails", str(self.root), "--json"]) + self.assertEqual((code, stderr), (0, "")) + preview = json.loads(stdout) + self.assertEqual(preview["scope"], "selected_file_only") + self.assertEqual(preview["file"], "AGENTS.md") + code, stdout, stderr = self.invoke(["guardrails", str(self.root), "--json", + "--apply", preview["confirmation_token"]]) + self.assertEqual((code, stderr), (0, "")) + self.assertTrue(json.loads(stdout)["applied"]) + self.assertEqual(os.readlink(broken), "absent.md") + code, stdout, stderr = self.invoke([str(self.root), "--json"]) + self.assertEqual((code, stdout), (2, "")) + + def test_unselected_external_source_is_not_opened_or_modified(self): + with tempfile.TemporaryDirectory() as directory: + outside = Path(directory) / "private.txt" + outside.write_bytes(b"outside sentinel\n") + self.link(self.root / "CLAUDE.md", outside) + with patch.object(guardrails, "_read_source", wraps=guardrails._read_source) as read: + review = guardrails.review_guardrails(self.root) + self.assertEqual(review.status, "review_needed") + self.assertEqual([call.args[1] for call in read.call_args_list], [self.source]) + self.assertEqual(outside.read_bytes(), b"outside sentinel\n") + + def test_exclusion_patterns_and_skipped_ancestors_still_reject_selected_targets(self): + cases = (("node_modules/client/AGENTS.md", ""), ("dist/AGENTS.md", ""), + ("package/AGENTS.md", "package/**\n"), ("package/CLAUDE.md", "*/CLAUDE.md\n")) + for file, patterns in cases: + with self.subTest(file=file): + selected = self.root / file + selected.parent.mkdir(parents=True, exist_ok=True) + selected.write_bytes(b"Run tests.\n") + (self.root / ".agentconfigscoreignore").write_text(patterns, encoding="utf-8") + with patch.object(guardrails, "_read_source", side_effect=AssertionError("target read")): + with self.assertRaises(guardrails.GuardrailError): + guardrails.review_guardrails(self.root, file=file) + + def test_new_exclusion_after_preview_prevents_append(self): + preview = guardrails.review_guardrails(self.root) + (self.root / ".agentconfigscoreignore").write_text("AGENTS.md\n", encoding="utf-8") + with self.assertRaises(guardrails.GuardrailError): + guardrails.review_guardrails(self.root, apply=preview.confirmation_token) + self.assertEqual(self.source.read_bytes(), b"Run tests.\n") + + def test_selected_directory_alias_is_not_traversed(self): + actual = self.root / "actual" + actual.mkdir() + (actual / "AGENTS.md").write_bytes(b"Run tests.\n") + self.link(self.root / "alias", actual, directory=True) + with patch.object(guardrails, "_read_source", side_effect=AssertionError("alias target read")): + with self.assertRaises((guardrails.GuardrailError, OSError)): + guardrails.review_guardrails(self.root, file="alias/AGENTS.md") + + def test_selected_broken_alias_still_fails_before_reading(self): + with patch.object(guardrails, "_read_source", side_effect=AssertionError("unsafe target read")): + self.link(self.root / "CLAUDE.md", "absent.md") + code, stdout, stderr = self.invoke(["guardrails", str(self.root), "--file", "CLAUDE.md", "--json"]) + self.assertEqual((code, stdout), (2, "")) + self.assertIn("error:", stderr) + + @unittest.skipUnless(hasattr(os, "mkfifo"), "FIFO fixture requires POSIX") + def test_unselected_nonregular_source_does_not_block_but_selected_fifo_is_rejected(self): + fifo = self.root / "CLAUDE.md" + os.mkfifo(fifo) + with self.assertRaises(OSError): + scanner.analyze(self.root) + self.assertIsNotNone(guardrails.review_guardrails(self.root).confirmation_token) + with patch.object(guardrails, "_read_source", side_effect=AssertionError("FIFO opened")): + code, stdout, stderr = self.invoke(["guardrails", str(self.root), "--file", "CLAUDE.md", "--json"]) + self.assertEqual((code, stdout), (2, "")) + + def test_ambiguous_directory_casing_requires_manual_selection(self): + selected = self.root / "package" / "AGENTS.md" + selected.parent.mkdir() + selected.write_bytes(b"Run tests.\n") + alternate = self.root / "Package" + if alternate.exists(): + self.skipTest("case-insensitive filesystem") + alternate.mkdir() + with patch.object(guardrails, "_read_source", side_effect=AssertionError("ambiguous target read")): + with self.assertRaises(guardrails.GuardrailError): + guardrails.review_guardrails(self.root, file="package/AGENTS.md") + + def test_selected_parent_errors_do_not_expose_private_exception_details(self): + selected = self.root / "package" / "AGENTS.md" + selected.parent.mkdir() + selected.write_bytes(b"Run tests.\n") + original_resolve = Path.resolve + + def replaced(path, *args, **kwargs): + if path == selected.parent: + raise RuntimeError("private parent-loop detail") + return original_resolve(path, *args, **kwargs) + + with patch.object(Path, "resolve", new=replaced): + code, stdout, stderr = self.invoke(["guardrails", str(self.root), "--file", "package/AGENTS.md", "--json"]) + self.assertEqual((code, stdout), (2, "")) + self.assertNotIn("private parent-loop detail", stderr) + self.assertNotIn("Traceback", stderr) + self.assertEqual(selected.read_bytes(), b"Run tests.\n") + + def test_common_selection_rules_match_normal_discovery(self): + files = ("CLAUDE.md", "nested/AGENTS.override.md", "nested/GEMINI.md", "literal/AGENTS.md", + ".cursor/rules/review.mdc", ".claude/review.md", ".github/instructions/review.md", + "node_modules/AGENTS.md", "excluded/AGENTS.md", ".cursor/rules/build/review.md", "docs/review.md") + for file in files: + target = self.root / file + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"Run tests.\n") + (self.root / ".agentconfigscoreignore").write_text("literal\nexcluded/**\n", encoding="utf-8") + discovered = set(scanner.discover(self.root)) + for file in ("AGENTS.md", *files): + with self.subTest(file=file): + chosen = scanner._select_instruction_source(self.root, file) + self.assertEqual(chosen is not None, self.root / file in discovered) + + def test_regular_directory_does_not_override_a_supported_case_variant_file(self): + selected = self.root / "nested" / "agents.md" + selected.parent.mkdir() + selected.write_bytes(b"Run tests.\n") + alternate = selected.parent / "AGENTS.md" + if alternate.exists(): + self.skipTest("case-insensitive filesystem") + alternate.mkdir() + self.assertEqual(guardrails.review_guardrails(self.root, file="nested/agents.md").file, "nested/agents.md") + + def test_unsafe_ignore_file_is_still_relevant_to_selected_scope(self): + with tempfile.TemporaryDirectory() as directory: + outside = Path(directory) / "private.txt" + outside.write_bytes(b"external ignore sentinel\n") + self.link(self.root / ".agentconfigscoreignore", outside) + with patch.object(Path, "read_text", side_effect=AssertionError("external ignore read")): + code, stdout, stderr = self.invoke(["guardrails", str(self.root), "--json"]) + self.assertEqual((code, stdout), (2, "")) + self.assertNotIn("external ignore sentinel", stderr) + + def test_supported_selection_keeps_canonical_filename_precedence(self): + alternate = self.root / "agents.md" + if alternate.exists(): + self.skipTest("case-insensitive filesystem") + alternate.write_bytes(b"Run different tests.\n") + with self.assertRaises(guardrails.GuardrailError): + guardrails.review_guardrails(self.root, file="agents.md") + self.assertEqual(guardrails.review_guardrails(self.root).file, "AGENTS.md") + + def test_modern_rule_directory_accepts_supported_suffix_but_not_arbitrary_files(self): + for file in (".cursor/rules/review.mdc", ".claude/review.md", ".github/instructions/review.md"): + with self.subTest(file=file): + selected = self.root / file + selected.parent.mkdir(parents=True, exist_ok=True) + selected.write_bytes(b"Run tests.\n") + with patch.object(scanner.os, "walk", side_effect=AssertionError("whole tree enumerated")): + self.assertEqual(guardrails.review_guardrails(self.root, file=file).file, file) + unsupported = self.root / "docs" / "review.md" + unsupported.parent.mkdir() + unsupported.write_bytes(b"Run tests.\n") + with self.assertRaises(guardrails.GuardrailError): + guardrails.review_guardrails(self.root, file="docs/review.md") + + +if __name__ == "__main__": + unittest.main()