diff --git a/CHANGELOG.md b/CHANGELOG.md index 50a0a31..7beaf98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- Reject duplicate decoded JSON member names throughout repository configuration instead of silently adopting the last policy/suppression value, including escaped aliases and equal-valued repeats. Reject non-standard numeric constants and Boolean format versions while preserving valid-policy defaults and numeric `1.0` compatibility. Doctor derives policy/schema metadata from one read. Invalid baseline and candidate configs remain errors despite CLI threshold overrides; rejection does not overwrite reports or certify policy adequacy. Published v0.24.0 artifacts, Action refs, rule scores and frozen research are unchanged. + - 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. diff --git a/README.md b/README.md index c6d46ea..2ab44a1 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,14 @@ The `$schema` annotation enables live validation and completion in editors that The schema is Draft 2020-12 and lives at `schema/agentconfigscore.schema.json`. AgentConfigScore's tests verify that its suppression rule enum stays exactly aligned with the stable Rule Catalog, so editor hints cannot silently drift away from scanner behavior. +Unreleased source builds additionally reject duplicate JSON member names at any +depth, even if the values match or a name uses JSON escapes. A repeated threshold +is a configuration error, not "last value wins." `NaN`/`Infinity` constants and a +Boolean format `version` are rejected too. Keep one reviewed value per field; +do not rely on editor schema validation to detect duplicate names. See +[configuration failure behavior](docs/doctor.md#configuration-decoding). These +changes are not in the published v0.24.0 artifacts. + Policy fields: | Key | Default | Meaning | diff --git a/docs/doctor.md b/docs/doctor.md index 7074a51..4b01c9d 100644 --- a/docs/doctor.md +++ b/docs/doctor.md @@ -22,6 +22,39 @@ When automatic baseline detection succeeds, doctor reports the selected ref, for Warnings are advisory and keep exit status `0`. Concrete configuration or standard-workflow errors return a non-zero exit status. +## Configuration decoding + +Unreleased source builds use one shared configuration decoder for scans, +`feedback`, `diff`, `compare` and `doctor`. Every JSON object in +`.agentconfigscore.json` must have unique decoded member names, including nested +policy and suppression objects. Repeating a name is an error even when both +values match; `fail_under` and `fail\u005funder` are the same decoded name. +Names reused in separate objects are not duplicates. Remove the duplicate after +reviewing which value is intended; no automatic rewrite or first/last-value +selection is performed. + +The decoder also rejects the non-standard number constants `NaN`, `Infinity` +and `-Infinity`. The format `version` cannot be a Boolean; numeric `1` (including +the previously supported `1.0`) and an omitted version retain their behavior. +Duplicate/constant diagnostics do not echo raw member names or values. Ordinary +JSON syntax failures still report a line and column. Editor JSON Schema checks +may see only an already-decoded object and are not a substitute for this check. + +Invalid configuration returns `2` from scans, `feedback`, `diff` and `compare`, +without a successful report or overwriting a requested report destination. +`doctor --json` instead reports `ok: false` and a `config` error, with exit `1`. +Both baseline and candidate configurations must validate even when trusted CLI +threshold overrides are supplied. `doctor` derives policy and schema metadata +from one read, not two potentially different reads. Missing config still uses +compatibility defaults and produces an advisory doctor warning. + +This is configuration validation, not an atomic repository snapshot, filesystem +sandbox, authenticity check or proof that the policy is adequate. The direct +`parse_policy(dict)` API cannot recover duplicate names that a caller has already +discarded while decoding. Published v0.24.0 packages and Action refs are unchanged. + +## Instruction and repository access + The `repository-paths` check reports an error if index traversal fails, for example because a source directory cannot be read. Fix the access problem before scanning; an incomplete index must not be treated as a clean result or evidence diff --git a/docs/limitations.md b/docs/limitations.md index 56b169c..ed2cc27 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -16,6 +16,17 @@ not an accuracy result or a reproducibility guarantee: repository inputs, policy runtime and filesystem behavior are outside that fingerprint. Missing source is explicitly unknown, and historic reports are not relabelled. +## Configuration decoding + +Unreleased source builds reject duplicate decoded JSON member names at every +configuration depth, non-standard `NaN`/`Infinity` constants and a Boolean format +`version`. They do not choose the first or last repeated value. Both comparison +trees must validate; CLI threshold overrides cannot authorize malformed config. +`doctor` uses one read for policy and schema metadata. These changes are not in +v0.24.0. See [failure and compatibility details](doctor.md#configuration-decoding). +The check does not validate policy adequacy, authenticate config authors, provide +an atomic snapshot or recover names already lost by a caller's JSON decoder. + ## Instruction-file filesystem boundary Selected instruction files and `.agentconfigscoreignore` must resolve to regular diff --git a/docs/simplified/limitations.md b/docs/simplified/limitations.md index a055bd8..5a793be 100644 --- a/docs/simplified/limitations.md +++ b/docs/simplified/limitations.md @@ -6,6 +6,10 @@ AgentConfigScore 的分数衡量已经检测到的、由规则定义的风险, v0.24.0+ 提供[工具源码身份(英文)](../report-identity.md),用于区分声明同一版本号的不同实现。相同指纹不证明准确率或可复现性:仓库输入、策略、运行环境和文件系统行为不在该指纹范围内。工具源码缺失时明确记为未知,历史报告不补标成当前版本。 +## 配置解析 + +尚未发布的源码拒绝各层配置中解码后重复的 JSON 字段名、非标准 `NaN`/`Infinity` 常量以及布尔类型的格式 `version`,不选择重复字段的第一个或最后一个值。比较的两侧都必须通过验证;命令行门槛覆盖不能批准格式错误的配置。`doctor` 从同一次读取的内容获取策略和 schema 元数据。这些变化不在 v0.24.0 中。[失败行为与兼容性细节(英文)](../doctor.md#configuration-decoding)。这不证明策略充分、不认证作者、不提供原子快照,也不能恢复调用方 JSON 解析时已经丢失的重复字段。 + ## 指令文件的文件系统边界 选中的指令文件和 `.agentconfigscoreignore` 必须解析为扫描根目录内的普通文件。仓库内的文件符号链接仍受支持;发现位置、Markdown 相对链接和指令作用域使用链接所在的位置,不是共享目标的目录。用户明确指定的根目录别名可用,并以其解析后的目录为边界。 diff --git a/docs/simplified/user-guide.md b/docs/simplified/user-guide.md index 73219e1..dca8eed 100644 --- a/docs/simplified/user-guide.md +++ b/docs/simplified/user-guide.md @@ -28,6 +28,8 @@ agent-config-score doctor 审核后提交生成的配置和工作流。只想本地使用时选 `init --no-workflow`;标准工作流缺失会成为 doctor 的提示性警告,而不是错误。 +尚未发布的源码拒绝配置中重复的字段名(包括嵌套对象和转义拼写)、`NaN`/`Infinity` 常量及布尔类型的格式 `version`,不会悄悄采用覆盖后的值。请审核并为每个字段保留一个值。扫描、`feedback`、`diff` 和 `compare` 返回 `2` 且不生成成功报告;doctor 给出 `config` 错误并返回 `1`。命令行门槛覆盖不能跳过配置验证;缺失配置时的默认行为不变。这些变化不在 v0.24.0 中。[配置解析细节(英文)](../doctor.md#configuration-decoding)。 + 若没有找到支持的指令文件,请编写符合仓库实际情况的指令,或采用[模板(英文)](../../examples/README.md)。不要仅为涨分添加词句。[完整发现范围(英文)](../../README.md#what-it-scans)。 ## 3. 检查当前目录 diff --git a/docs/traditional/limitations.md b/docs/traditional/limitations.md index bc0bbd3..936e095 100644 --- a/docs/traditional/limitations.md +++ b/docs/traditional/limitations.md @@ -6,6 +6,10 @@ AgentConfigScore 的分數衡量已偵測到、由規則定義的風險,不衡 v0.24.0+ 提供[工具原始碼身分(英文)](../report-identity.md),用來區分宣告同一版本號的不同實作。相同指紋不證明準確率或可重現性:儲存庫輸入、策略、執行環境與檔案系統行為不在該指紋範圍內。工具原始碼缺失時明確記為未知,歷史報告不補標為目前版本。 +## 設定解析 + +尚未發布的原始碼拒絕各層設定中解碼後重複的 JSON 欄位名稱、非標準 `NaN`/`Infinity` 常數以及布林型別的格式 `version`,不選取重複欄位的第一個或最後一個值。比較的兩側都必須通過驗證;命令列門檻覆寫不能核准格式錯誤的設定。`doctor` 從同一次讀取的內容取得策略及 schema 中繼資料。這些變更不在 v0.24.0 中。[失敗行為與相容性細節(英文)](../doctor.md#configuration-decoding)。這不證明策略充分、不認證作者、不提供原子快照,也不能復原呼叫端 JSON 解析時已遺失的重複欄位。 + ## 指令檔的檔案系統邊界 選取的指令檔與 `.agentconfigscoreignore` 必須解析為掃描根目錄內的一般檔案。儲存庫內的檔案符號連結仍受支援;發現位置、Markdown 相對連結與指令作用域使用連結所在位置,不是共享目標的目錄。使用者明確指定的根目錄別名可用,並以其解析後的目錄為邊界。 diff --git a/docs/traditional/user-guide.md b/docs/traditional/user-guide.md index fab5de4..7303e32 100644 --- a/docs/traditional/user-guide.md +++ b/docs/traditional/user-guide.md @@ -28,6 +28,8 @@ agent-config-score doctor 審閱後提交產生的設定與工作流程。只想在本機使用時選 `init --no-workflow`;缺少標準工作流程只會成為 doctor 的提示性警告,不是錯誤。 +尚未發布的原始碼拒絕設定中重複的欄位名稱(包括巢狀物件及跳脫拼寫)、`NaN`/`Infinity` 常數與布林型別的格式 `version`,不會悄悄採用覆蓋後的值。請審閱並為每個欄位保留一個值。掃描、`feedback`、`diff` 與 `compare` 返回 `2` 且不產生成功報告;doctor 提供 `config` 錯誤並返回 `1`。命令列門檻覆寫不能略過設定驗證;缺少設定時的預設行為不變。這些變更不在 v0.24.0 中。[設定解析細節(英文)](../doctor.md#configuration-decoding)。 + 若沒有找到支援的指令檔,請撰寫符合儲存庫實際需求的指令,或使用[範本(英文)](../../examples/README.md)。不要只為提高分數添加詞句。[完整探索範圍(英文)](../../README.md#what-it-scans)。 ## 3. 檢查目前目錄 diff --git a/docs/translations.md b/docs/translations.md index 310d27e..ebb545e 100644 --- a/docs/translations.md +++ b/docs/translations.md @@ -118,6 +118,15 @@ 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. +On 2026-10-25, built on `10e9b50a6f507ae9edf7a1589fdca704d05cfc07`, both core +guides and limitation summaries were synchronized for strict policy JSON: +duplicate decoded names at every depth, non-standard numeric constants, Boolean +format-version rejection, one-read doctor metadata and unchanged missing-config +defaults. Invalid baseline/candidate configs remain operational failures even +under CLI threshold overrides; validation is not policy adequacy or an atomic +snapshot. This is unreleased source behavior, not a v0.24.0 artifact or Action-ref +update. Executable examples, valid-policy scoring and frozen research are 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 a0d2c6a..9c9c3bc 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -41,6 +41,14 @@ Commit the generated files after reviewing them. If you only want local checks, use `init --no-workflow`. A missing standard workflow is then an advisory doctor warning, not an error. +Unreleased source builds reject duplicate config member names (including nested +objects and escaped spellings), `NaN`/`Infinity` constants and a Boolean format +`version`, rather than choosing an overwritten value. Review and keep one value +per field. Scans, `feedback`, `diff` and `compare` return `2` without a success +report; doctor returns `1` with a `config` error. Threshold overrides do not bypass +config validation. Missing-config defaults remain unchanged. This is not in +v0.24.0; see [configuration decoding](doctor.md#configuration-decoding). + If doctor finds no supported instruction files, add repository-specific instructions or adapt one of the [templates](../examples/README.md). Do not add phrases just to increase the score. See [supported discovery](../README.md#what-it-scans). diff --git a/scripts/verify_installed_package.py b/scripts/verify_installed_package.py index b16a08c..0a5f168 100644 --- a/scripts/verify_installed_package.py +++ b/scripts/verify_installed_package.py @@ -331,6 +331,37 @@ def verify(python: Path, expected_version: str) -> list[str]: checks.append("danger regression, compare, baseline policy/suppressions, removal, and worktree cleanup") instructions.write_text(clean_text, encoding="utf-8") + for invalid_json in ( + '{"policy":{"fail_under":90,"fail_under":0}}', + r'{"policy":{"fail_under":90,"fail\u005funder":0}}', + '{"version":true}', + '{"version":NaN}', + '{"version":Infinity}', + '{"version":-Infinity}', + ): + config.write_text(invalid_json, encoding="utf-8") + require(run([*cli, ".", "--json"], repo, expected=2) == "", + "Ambiguous/non-standard config emitted a scan") + ambiguous = '{"policy":{"fail_under":90,"fail_under":0}}' + config.write_text(ambiguous, encoding="utf-8") + for command in commands[1:]: + require(run([*command, ".", "--json"], repo, expected=2) == "", + "Alternate entrypoint accepted ambiguous config") + invalid_doctor = json.loads(run([*cli, "doctor", "--json"], repo, expected=1)) + require(invalid_doctor["ok"] is False + and any(c["name"] == "config" and c["status"] == "error" for c in invalid_doctor["checks"]), + "Doctor certified ambiguous config") + for args in (["diff", "--json"], ["compare", str(base), str(repo), "--json"]): + require(run([*cli, *args], repo, expected=2) == "", "Invalid config emitted a regression report") + protected_report = workspace / "preserved-feedback.md" + protected_report.write_bytes(b"preserved report") + require(run([*cli, "feedback", "--output", str(protected_report)], repo, expected=2) == "", + "Feedback accepted ambiguous config") + require(protected_report.read_bytes() == b"preserved report" and config.read_text(encoding="utf-8") == ambiguous, + "Config rejection overwrote an input or report") + config.write_bytes(original[0]) + checks.append("strict policy JSON across aliases, module, doctor, diff, compare and feedback without success reports or overwrites") + config.write_bytes(b'{"private":"synthetic-\xff"}') require(run([*cli, ".", "--json"], repo, expected=2) == "", "Invalid config emitted a scan") config.write_bytes(original[0]) diff --git a/src/agent_config_score/config.py b/src/agent_config_score/config.py index 268629c..6727801 100644 --- a/src/agent_config_score/config.py +++ b/src/agent_config_score/config.py @@ -137,7 +137,7 @@ def parse_policy(data: object, *, today: date | None = None) -> Policy: raise ConfigError("$schema must be a non-empty string when provided") version = data.get("version", 1) - if version != 1: + if isinstance(version, bool) or version != 1: raise ConfigError("version must be 1") raw_policy = data.get("policy", {}) @@ -179,15 +179,38 @@ def parse_policy(data: object, *, today: date | None = None) -> Policy: ) -def load_policy(root: Path) -> Policy: +def _unique_json_object(pairs: list[tuple[str, object]]) -> dict: + result = {} + for name, value in pairs: + if name in result: + # Member names as well as values can contain private material. + raise ConfigError("duplicate JSON member names are not allowed; keep one value per field") + result[name] = value + return result + + +def _reject_nonstandard_number(_value: str) -> None: + raise ConfigError("non-standard JSON number constants are not allowed") + + +def _load_policy_document(root: Path) -> tuple[Policy, dict | None]: + """Validate one decoded document; None identifies a missing config. + + Retain metadata for doctor without reopening the file or decoding it using + different rules. This is not an atomic snapshot of the repository. + """ path = root.resolve() / CONFIG_NAME if not path.exists(): - return Policy() + return Policy(), None if not path.is_file(): raise ConfigError(f"{CONFIG_NAME} exists but is not a file") try: - data = json.loads(path.read_text(encoding="utf-8")) + data = json.loads( + path.read_text(encoding="utf-8"), + object_pairs_hook=_unique_json_object, + parse_constant=_reject_nonstandard_number, + ) except UnicodeError as exc: raise ConfigError(f"{CONFIG_NAME} must be saved as UTF-8") from exc except OSError as exc: @@ -196,8 +219,14 @@ def load_policy(root: Path) -> Policy: raise ConfigError( f"invalid JSON in {CONFIG_NAME} at line {exc.lineno}, column {exc.colno}: {exc.msg}" ) from exc + except ConfigError as exc: + raise ConfigError(f"invalid JSON in {CONFIG_NAME}: {exc}") from exc try: - return parse_policy(data) + return parse_policy(data), data except ConfigError as exc: raise ConfigError(f"invalid {CONFIG_NAME}: {exc}") from exc + + +def load_policy(root: Path) -> Policy: + return _load_policy_document(root)[0] diff --git a/src/agent_config_score/doctor.py b/src/agent_config_score/doctor.py index f34d89f..a423b44 100644 --- a/src/agent_config_score/doctor.py +++ b/src/agent_config_score/doctor.py @@ -2,10 +2,9 @@ from dataclasses import asdict, dataclass from datetime import datetime, timezone -import json from pathlib import Path -from .config import CONFIG_NAME, SCHEMA_URL, ConfigError, load_policy +from .config import CONFIG_NAME, SCHEMA_URL, ConfigError, _load_policy_document from .gitdiff import GitError, detect_base_ref, repository_root from .scanner import _read_scanner_source, _repository_path_index, discover @@ -49,28 +48,21 @@ def to_dict(self) -> dict: def _check_config(root: Path) -> tuple[list[DoctorCheck], object | None]: checks: list[DoctorCheck] = [] - path = root / CONFIG_NAME - if not path.exists(): + try: + policy, raw = _load_policy_document(root) + except ConfigError as exc: + checks.append(DoctorCheck("config", "error", f"Invalid {CONFIG_NAME}: {exc}")) + return checks, None + except OSError: + checks.append(DoctorCheck("config", "error", f"Could not read {CONFIG_NAME}; check filesystem access.")) + return checks, None + if raw is None: checks.append(DoctorCheck( "config", "warning", f"{CONFIG_NAME} is missing; compatibility defaults will be used.", )) return checks, None - if not path.is_file(): - checks.append(DoctorCheck("config", "error", f"{CONFIG_NAME} exists but is not a file.")) - return checks, None - - try: - policy = load_policy(root) - raw = json.loads(path.read_text(encoding="utf-8")) - except UnicodeError: - checks.append(DoctorCheck("config", "error", f"{CONFIG_NAME} must be saved as UTF-8.")) - return checks, None - except (OSError, json.JSONDecodeError, ConfigError) as exc: - checks.append(DoctorCheck("config", "error", f"Invalid {CONFIG_NAME}: {exc}")) - return checks, None - checks.append(DoctorCheck( "config", "pass", diff --git a/tests/test_policy_json.py b/tests/test_policy_json.py new file mode 100644 index 0000000..0e514e3 --- /dev/null +++ b/tests/test_policy_json.py @@ -0,0 +1,217 @@ +import contextlib +import io +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +from agent_config_score.config import CONFIG_NAME, SCHEMA_URL, ConfigError, Policy, load_policy, parse_policy +from agent_config_score.doctor import _check_config +from agent_config_score.entrypoint import main + + +AMBIGUOUS = '{"policy":{"fail_under":90,"fail_under":0}}' + + +def _git(root, *args): + return subprocess.run( + ["git", "-C", str(root), *args], check=True, capture_output=True, + text=True, encoding="utf-8", timeout=30, + ).stdout.strip() + + +class PolicyJsonTests(unittest.TestCase): + def _load_text(self, text): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / CONFIG_NAME).write_text(text, encoding="utf-8") + return load_policy(root) + + def test_duplicate_root_members_are_rejected(self): + for text in ( + '{"policy":{"max_drop":0},"policy":{"max_drop":100}}', + '{"version":1,"version":1}', + '{"suppressions":[],"suppressions":[]}', + '{"$schema":"first","$schema":"second"}', + ): + with self.subTest(text=text), self.assertRaisesRegex(ConfigError, "duplicate JSON member"): + self._load_text(text) + + def test_duplicate_nested_policy_members_are_rejected(self): + for text in ( + AMBIGUOUS, + '{"policy":{"max_drop":0,"max_drop":100}}', + '{"policy":{"fail_on_new_errors":true,"fail_on_new_errors":false}}', + '{"policy":{"fail_under":90,"fail_under":90}}', + ): + with self.subTest(text=text), self.assertRaisesRegex(ConfigError, "duplicate JSON member"): + self._load_text(text) + + def test_duplicate_suppression_members_are_rejected(self): + for member in ( + '"rule":"dead-path","rule":"curl-pipe-shell"', + '"reason":"first","reason":"second"', + '"expires":"2098-01-01","expires":"2099-01-01"', + '"paths":["AGENTS.md"],"paths":["**"]', + ): + text = '{"suppressions":[{' + member + '}]}' + with self.subTest(member=member), self.assertRaisesRegex(ConfigError, "duplicate JSON member"): + self._load_text(text) + + def test_duplicate_detection_uses_decoded_json_names(self): + text = r'{"policy":{"fail_under":90,"fail\u005funder":0}}' + with self.assertRaisesRegex(ConfigError, "duplicate JSON member"): + self._load_text(text) + + def test_duplicate_error_does_not_echo_member_names_or_values(self): + text = '{"synthetic-private-key":"synthetic-private-value","synthetic-private-key":0}' + with self.assertRaises(ConfigError) as raised: + self._load_text(text) + self.assertIn("duplicate JSON member", str(raised.exception)) + self.assertNotIn("synthetic-private", str(raised.exception)) + + def test_nonstandard_json_number_constants_are_rejected(self): + for constant in ("NaN", "Infinity", "-Infinity"): + with self.subTest(constant=constant), self.assertRaisesRegex(ConfigError, "non-standard JSON number"): + self._load_text('{"version":' + constant + '}') + + def test_boolean_is_not_a_config_format_version(self): + for version in (True, False): + with self.subTest(version=version), self.assertRaisesRegex(ConfigError, "version must be 1"): + parse_policy({"version": version}) + + def test_valid_configuration_defaults_and_numeric_version_are_preserved(self): + self.assertEqual(self._load_text('{}'), Policy()) + self.assertEqual(self._load_text('{"version":1.0}'), Policy()) + self.assertEqual(self._load_text('{"policy":{"fail_under":90}}'), Policy(fail_under=90)) + + def test_same_member_names_in_distinct_objects_are_not_duplicates(self): + items = [ + {"rule": rule, "reason": "Reviewed synthetic fixture.", "expires": "2099-01-01"} + for rule in ("dead-path", "curl-pipe-shell") + ] + policy = self._load_text(json.dumps({"suppressions": items})) + self.assertEqual(len(policy.suppressions), 2) + + def test_json_like_string_content_is_not_decoded_again(self): + reason = 'Reviewed literal example: {"field":90,"field":0}; NaN is text here.' + policy = self._load_text(json.dumps({"suppressions": [{ + "rule": "dead-path", "reason": reason, "expires": "2099-01-01", + }]})) + self.assertEqual(policy.suppressions[0].reason, reason) + + def test_missing_configuration_still_uses_compatibility_defaults(self): + with tempfile.TemporaryDirectory() as directory: + self.assertEqual(load_policy(Path(directory)), Policy()) + + def test_scan_and_feedback_reject_before_analysis_or_report_writes(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + config = root / CONFIG_NAME + config.write_text(AMBIGUOUS, encoding="utf-8") + artifact = root / "existing-report" + artifact.write_bytes(b"preserve this report") + for args in ( + [str(root), "--json", "--fail-under", "0", "--html", str(artifact)], + ["feedback", str(root), "--output", str(artifact)], + ): + stdout, stderr = io.StringIO(), io.StringIO() + with self.subTest(args=args), patch("agent_config_score.cli.analyze") as analyze: + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + code = main(args) + self.assertEqual(code, 2) + self.assertEqual(stdout.getvalue(), "") + self.assertIn("duplicate JSON member", stderr.getvalue()) + self.assertNotIn("Traceback", stderr.getvalue()) + self.assertEqual(artifact.read_bytes(), b"preserve this report") + self.assertEqual(config.read_text(encoding="utf-8"), AMBIGUOUS) + analyze.assert_not_called() + + def test_compare_rejects_either_invalid_tree_even_with_cli_overrides(self): + for invalid in ("base", "head"): + with self.subTest(invalid=invalid), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + base, head = root / "base", root / "head" + base.mkdir() + head.mkdir() + (root / invalid / CONFIG_NAME).write_text(AMBIGUOUS, encoding="utf-8") + stdout, stderr = io.StringIO(), io.StringIO() + with patch("agent_config_score.cli.compare") as compare: + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + code = main(["compare", str(base), str(head), "--json", "--max-drop", "100"]) + self.assertEqual(code, 2) + self.assertEqual(stdout.getvalue(), "") + self.assertIn("duplicate JSON member", stderr.getvalue()) + compare.assert_not_called() + + def test_git_diff_rejects_either_invalid_tree_and_cleans_worktree(self): + for invalid in ("base", "head"): + with self.subTest(invalid=invalid), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + _git(root, "init", "-q", "--template=") + hooks = root / "empty-hooks" + hooks.mkdir() + _git(root, "config", "core.hooksPath", str(hooks)) + _git(root, "config", "user.name", "ACS Tests") + _git(root, "config", "user.email", "tests@example.invalid") + config = root / CONFIG_NAME + config.write_text(AMBIGUOUS if invalid == "base" else '{}', encoding="utf-8") + _git(root, "add", CONFIG_NAME) + _git(root, "-c", "commit.gpgsign=false", "commit", "-q", "-m", "baseline") + config.write_text(AMBIGUOUS if invalid == "head" else '{}', encoding="utf-8") + before = config.read_bytes() + stdout, stderr = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + code = main(["diff", "HEAD", "--path", str(root), "--json"]) + self.assertEqual(code, 2) + self.assertEqual(stdout.getvalue(), "") + self.assertIn("duplicate JSON member", stderr.getvalue()) + self.assertEqual(config.read_bytes(), before) + worktrees = _git(root, "worktree", "list", "--porcelain") + self.assertEqual(sum(line.startswith("worktree ") for line in worktrees.splitlines()), 1) + + def test_doctor_reports_structured_config_error_without_success(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / CONFIG_NAME).write_text(AMBIGUOUS, encoding="utf-8") + stdout, stderr = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + code = main(["doctor", str(root), "--json"]) + self.assertEqual(code, 1) + self.assertEqual(stderr.getvalue(), "") + report = json.loads(stdout.getvalue()) + self.assertFalse(report["ok"]) + config = next(check for check in report["checks"] if check["name"] == "config") + self.assertEqual(config["status"], "error") + self.assertIn("duplicate JSON member", config["message"]) + self.assertNotIn("schema", [check["name"] for check in report["checks"]]) + + def test_doctor_policy_and_schema_are_from_one_read(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / CONFIG_NAME).write_text(json.dumps({"$schema": SCHEMA_URL}), encoding="utf-8") + original_read = Path.read_text + reads = [] + + def read_once(path, *args, **kwargs): + reads.append(path) + self.assertEqual(len(reads), 1, "Doctor reread the config for schema metadata") + return original_read(path, *args, **kwargs) + + with patch.object(Path, "read_text", read_once): + checks, policy = _check_config(root) + self.assertEqual(policy, Policy()) + self.assertEqual(next(check.status for check in checks if check.name == "schema"), "pass") + + def test_doctor_loader_operational_error_is_structured(self): + with patch("agent_config_score.doctor._load_policy_document", side_effect=PermissionError("synthetic-private-detail")): + checks, policy = _check_config(Path(".")) + self.assertIsNone(policy) + self.assertEqual([(check.name, check.status) for check in checks], [("config", "error")]) + self.assertNotIn("synthetic-private-detail", checks[0].message) + + +if __name__ == "__main__": + unittest.main()