From 53dbf5ee2e582f53c3c12e7f5ba0c5f9cc95fb35 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 01:27:28 +0800 Subject: [PATCH 01/28] docs: approve CodeGraph MCP proxy boundaries --- ...8-18-codegraph-polaris-mcp-proxy-design.md | 58 ++++++++++++++++--- ...odegraph-polaris-mcp-proxy-design.zh-CN.md | 50 +++++++++++++--- 2 files changed, 91 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/specs/2026-08-18-codegraph-polaris-mcp-proxy-design.md b/docs/superpowers/specs/2026-08-18-codegraph-polaris-mcp-proxy-design.md index 30a3cff..b3d9918 100644 --- a/docs/superpowers/specs/2026-08-18-codegraph-polaris-mcp-proxy-design.md +++ b/docs/superpowers/specs/2026-08-18-codegraph-polaris-mcp-proxy-design.md @@ -3,10 +3,22 @@ ## Status - Date: 2026-08-18 -- Status: proposed for implementation +- Status: approved for implementation on 2026-08-19 - Scope: Polaris Code Intelligence protocol and stage behavior - Provider: `colbymchenry/codegraph` +## Version Boundary + +- Polaris protocol and package version: `0.1.20` to `0.1.21`. +- Workflow graph version: remains `0.1.3`; no state or transition changes. +- Host adapter manifest version: v2 to v3. +- Code Intelligence records written by new stages: v3. +- Code Intelligence v1 and v2 records: immutable historical read support only. + +The adjacent `0.1.20` to `0.1.21` migration inventories immutable v2 records +without rewriting them. It upgrades the host registration and protocol files, +but does not alter the workflow graph. + ## Problem Polaris currently lets Planning, Implementation, and Review choose either a @@ -204,10 +216,27 @@ an arbitrary project path from the tool call. It rejects a missing, moved, or symlinked project root. Removing or disabling the project-local Polaris MCP registration disables the proxy without affecting CodeGraph itself. -The adapter contract must represent the project-scoped MCP registration for -each supported host rather than embedding host-specific configuration writes -in the Code Intelligence adapter. Vendoring and project validation verify that -the registration launches only the repository's vendored Polaris runtime. +Host adapter v3 adds one required declarative `project_mcp` registration. It +identifies the fixed `polaris-codegraph` server ID, the host-native project +configuration target and format, and the project-relative vendored launcher. +For the supported hosts, the targets are `.codex/config.toml` for Codex and +`.mcp.json` for Claude Code. The host renderer owns these syntax differences; +the Code Intelligence adapter remains host-neutral. + +Initialization and vendoring merge only the named `polaris-codegraph` entry +and preserve unrelated user servers and settings. They refuse malformed host +configuration, path/symlink escape, or an existing same-name registration with +a different definition instead of silently overwriting it. Upgrade removes or +replaces only the previously managed Polaris entry. Project validation parses +the resulting host configuration and proves that this entry launches only +`tools/polaris/scripts/code_intelligence_mcp.py`, fixes the repository argument +to the project root, and exposes no user-selected repository parameter. + +The launcher and server independently resolve and compare the configured root +with the actual project root. A host starting the process from an unexpected +working directory therefore fails closed rather than querying another +repository. Host-native trust or first-use approval remains a user decision; +Polaris does not bypass it. ## Envelope @@ -260,8 +289,8 @@ mismatch, response-hash mismatch, or a `CURRENT` claim with any non-zero pending count. Migration inventories immutable v2 records without rewriting them. The -Polaris protocol version increments; the workflow graph version does not -change because no workflow state or transition changes. +Polaris protocol and package version become `0.1.21`; the workflow graph stays +at `0.1.3` because no workflow state or transition changes. ## Stage Behavior @@ -271,8 +300,13 @@ change because no workflow state or transition changes. `CURRENT` stage record. - Implementation may query after edits only through a fresh proxy invocation for Polaris evidence; it does not reuse the entry envelope. -- Documentation Sync uses the same proxy/status machinery for its final - bounded sync evidence when supported source changed. +- When supported source changed, Documentation Sync makes one bounded + `polaris_codegraph_explore` call with `stage: DOCUMENTATION_SYNC`, + `sync_if_needed: true`, and a query limited to the changed source paths and + documented symbols. Its post-query status is the final sync observation; + there is no second status/sync MCP tool. When no supported source changed, + it creates no Code Intelligence record. `STALE` or `UNKNOWN` results require + the same source/Git fallback before documentation conclusions are used. - Validation remains graph-free. - On `STALE` or `UNKNOWN`, stage conclusions concerning returned files or relationships require the envelope's source/Git fallback before use. @@ -319,6 +353,12 @@ Deterministic unit and integration tests must cover: 13. Validation remains graph-free; 14. the full Polaris suite passes without requiring CodeGraph; 15. an optional real-CLI smoke test uses only a disposable temporary repo. +16. host registration merges into existing Codex TOML and Claude JSON without + changing unrelated servers or settings, rejects conflicting same-name + entries and unsafe paths, and validates the exact vendored launcher/root; +17. Documentation Sync uses the single explore proxy for its final bounded + query, skips graph evidence when supported source did not change, and + never calls a separate sync/status MCP tool. Skill evaluation must include pressure cases where an Agent is asked to skip the proxy, trust a clean-looking graph despite pending changes, reuse an old diff --git a/docs/superpowers/specs/2026-08-18-codegraph-polaris-mcp-proxy-design.zh-CN.md b/docs/superpowers/specs/2026-08-18-codegraph-polaris-mcp-proxy-design.zh-CN.md index 543a9f8..015f747 100644 --- a/docs/superpowers/specs/2026-08-18-codegraph-polaris-mcp-proxy-design.zh-CN.md +++ b/docs/superpowers/specs/2026-08-18-codegraph-polaris-mcp-proxy-design.zh-CN.md @@ -3,10 +3,21 @@ ## 状态 - 日期:2026-08-18 -- 状态:提议实施 +- 状态:已于 2026-08-19 批准实施 - 范围:Polaris Code Intelligence 协议与阶段行为 - Provider:`colbymchenry/codegraph` +## 版本边界 + +- Polaris 协议与包版本:`0.1.20` 升到 `0.1.21`。 +- Workflow graph 版本:保持 `0.1.3`;不改变 state 或 transition。 +- 宿主 adapter manifest 版本:v2 升到 v3。 +- 新阶段写入的 Code Intelligence record:v3。 +- Code Intelligence v1 和 v2 record:仅保留不可变的历史读取支持。 + +相邻的 `0.1.20` 到 `0.1.21` migration 会盘点不可变 v2 record,但不改写它们。 +它会升级宿主注册和协议文件,但不修改 workflow graph。 + ## 问题 目前,Polaris 允许 Planning、Implementation 和 Review 在独立的 @@ -183,9 +194,22 @@ server 进程在启动时接收仓库根目录,工具调用本身不接受任 缺失、已移动或为 symlink 时,server 必须拒绝。移除或禁用项目本地的 Polaris MCP 注册,只会禁用代理,不影响 CodeGraph 本身。 -adapter 契约必须为每个受支持宿主表示项目级 MCP 注册,而不能把宿主专属配置写入 -Code Intelligence adapter。Vendoring 和项目校验会验证该注册只启动仓库中的 -vendored Polaris runtime。 +宿主 adapter v3 新增一个必需的声明式 `project_mcp` 注册。它声明固定的 +`polaris-codegraph` server ID、宿主原生项目配置的目标与格式,以及项目相对路径 +下的 vendored launcher。当前受支持宿主中,Codex 目标为 +`.codex/config.toml`,Claude Code 目标为 `.mcp.json`。宿主 renderer 负责这些 +语法差异;Code Intelligence adapter 保持宿主无关。 + +初始化和 vendoring 只合并名为 `polaris-codegraph` 的条目,并保留用户其他 server +与设置。若宿主配置畸形、路径或 symlink 逃逸,或者存在同名但定义不同的注册, +系统必须拒绝,而不是静默覆盖。升级只移除或替换先前由 Polaris 管理的条目。项目 +校验会解析最终宿主配置,证明该条目只能启动 +`tools/polaris/scripts/code_intelligence_mcp.py`,仓库参数被固定为项目根目录,且 +不存在用户可选的仓库参数。 + +launcher 与 server 都会独立解析并比对配置根目录和实际项目根目录。因此,即使 +宿主从意外工作目录启动进程,也会 fail closed,而不会查询另一个仓库。宿主原生 +的项目信任或首次使用批准仍由用户决定;Polaris 不绕过它。 ## Envelope @@ -233,8 +257,9 @@ v3 查询证据包含: validator 会拒绝观察结果缺失、状态自相矛盾、项目不匹配、响应 hash 不匹配,或在 任何 pending 计数非零时声明 `CURRENT`。 -迁移过程会盘点不可变 v2 record,但不改写它们。Polaris 协议版本递增;workflow -graph 版本不变,因为 workflow 状态和 transition 都没有变化。 +迁移过程会盘点不可变 v2 record,但不改写它们。Polaris 协议与包版本升到 +`0.1.21`;workflow graph 保持 `0.1.3`,因为 workflow 状态和 transition 都没有 +变化。 ## 阶段行为 @@ -243,8 +268,12 @@ graph 版本不变,因为 workflow 状态和 transition 都没有变化。 Polaris 而言始终是未验证的,不能支撑 `CURRENT` 阶段 record。 - Implementation 在编辑后若要为 Polaris 生成证据,只能发起一次新的代理调用; 不能复用阶段入口的 envelope。 -- Documentation Sync 在受支持源码发生变化时,使用相同的代理/status 机制生成 - 最终有界 sync 证据。 +- 当受支持源码发生变化时,Documentation Sync 发起一次有界的 + `polaris_codegraph_explore` 调用,使用 `stage: DOCUMENTATION_SYNC`、 + `sync_if_needed: true`,并把查询限制为已变更源码路径和文档涉及的 symbol。查询 + 后 status 就是最终 sync 观察结果;不增加第二个 status/sync MCP 工具。如果受 + 支持源码没有变化,则不创建 Code Intelligence record。`STALE` 或 `UNKNOWN` + 结果必须在使用文档结论前完成同样的源码/Git 回退。 - Validation 仍然不使用 graph。 - 当状态为 `STALE` 或 `UNKNOWN` 时,任何涉及返回文件或关系的阶段结论,都必须先 完成 envelope 要求的源码/Git 回退。 @@ -285,6 +314,11 @@ Vendored `AGENTS.md`、宿主 overlay 和 canonical Skill 必须共享此契约 13. Validation 仍然不使用 graph; 14. 完整 Polaris 测试套件不依赖 CodeGraph 即可通过; 15. 可选的真实 CLI smoke test 只使用一次性临时仓库。 +16. 宿主注册会把配置合并到现有 Codex TOML 和 Claude JSON 中,且不修改无关 + server 或设置;拒绝冲突的同名条目和不安全路径,并验证精确的 vendored + launcher/root; +17. Documentation Sync 使用唯一的 explore 代理完成最终有界查询;受支持源码未 + 变化时跳过 graph 证据;并且绝不调用单独的 sync/status MCP 工具。 Skill 评估必须包含以下压力场景:要求 Agent 跳过代理;在存在 pending changes 时 仍信任看似干净的 graph;复用旧 Implementation envelope;或者把 `UNKNOWN` 当成 From 6a25acef69041124f9ff167f82d843778229c387 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 01:42:54 +0800 Subject: [PATCH 02/28] docs: plan CodeGraph MCP proxy implementation --- .../2026-08-19-codegraph-polaris-mcp-proxy.md | 937 ++++++++++++++++++ 1 file changed, 937 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-19-codegraph-polaris-mcp-proxy.md diff --git a/docs/superpowers/plans/2026-08-19-codegraph-polaris-mcp-proxy.md b/docs/superpowers/plans/2026-08-19-codegraph-polaris-mcp-proxy.md new file mode 100644 index 0000000..51e3d90 --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-codegraph-polaris-mcp-proxy.md @@ -0,0 +1,937 @@ +# CodeGraph Polaris MCP Proxy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a project-scoped Polaris MCP proxy that performs one bounded CodeGraph freshness window, delivers an adjacent freshness envelope, and records auditable v3 evidence without making CodeGraph a workflow gate. + +**Architecture:** Extend the existing CodeGraph CLI adapter with reusable status/sync/explore primitives, then place a host-neutral proxy orchestration module above it. A thin standard-library stdio MCP entry point exposes only `polaris_codegraph_explore`; host adapter v3 renders project-local registrations for Codex and Claude Code. New v3 records copy and validate the proxy bundle while frozen v1/v2 schemas remain readable historical formats. + +**Tech Stack:** Python 3.11+ standard library (`argparse`, `hashlib`, `json`, `subprocess`, `tomllib`, `unittest`), JSON Schema through Polaris's existing validator, JSON-RPC 2.0/MCP stdio protocol revision `2025-11-25`, Git, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-08-18-codegraph-polaris-mcp-proxy-design.md` + +## Global Constraints + +- Polaris protocol and package version becomes exactly `0.1.21`. +- Workflow graph version remains exactly `0.1.3`; do not change states or transitions. +- Host adapter manifest version becomes exactly v3. +- New Code Intelligence writes use record v3; v1 and v2 remain immutable historical read formats. +- `colbymchenry/codegraph` remains the only official CodeGraph Provider. +- Runtime code remains Python-standard-library-only; add no package dependency. +- The proxy performs no sleep, polling, retry, CodeGraph installation, initialization, configuration, daemon management, or watcher management. +- One invocation performs at most one pre-status, one sync, one post-sync status, one explore, and one post-query status. +- Raw `codegraph_explore` MCP and unrestricted shell access remain available but cannot back `CURRENT` Polaris evidence. +- Validation remains graph-free and CI must pass without CodeGraph installed. +- `CURRENT` is non-authoritative context; `STALE` and `UNKNOWN` are navigation-only and require exact source/Git fallback evidence. +- Project mismatch, unsafe paths, malformed warnings, missing proof, and unavailable capabilities fail closed. + +--- + +## File Structure + +- `scripts/internal/codegraph_adapter.py`: low-level bounded CodeGraph CLI calls and fail-safe response classification. +- `scripts/internal/code_intelligence_proxy.py`: stage resolution, query-window orchestration, delivery-state merge, runtime bundle persistence, and envelope rendering. +- `scripts/code_intelligence_mcp.py`: newline-delimited JSON-RPC/MCP stdio dispatcher only. +- `scripts/internal/code_intelligence_protocol.py`: v1/v2/v3 record selection and semantic validation. +- `schemas/code-intelligence-record-v2.schema.json`: frozen copy of the current v2 schema. +- `schemas/code-intelligence-record.schema.json`: current v3 schema. +- `schemas/code-intelligence-record-annotations.schema.json`: Agent-supplied summaries, symbols, and completed fallback evidence used when projecting a bundle. +- `scripts/internal/project_mcp_registration.py`: non-destructive Codex TOML and Claude JSON registration rendering/validation. +- `scripts/internal/host_adapters.py`: adapter v3 manifest validation and safe project MCP target resolution. +- `scripts/vendor_project.py`, `scripts/init_project.py`, `scripts/validate_project.py`: transactionally install and validate the project-local registration. +- `hosts/codex/adapter.json`, `hosts/claude-code/adapter.json`: declarative host registration metadata. +- `skills/*`, `templates/AGENTS.md`, `README*.md`, `docs/USAGE.md`, `plan.md`: one shared human/Agent behavior contract. +- `workflow/migrations.json`, `scripts/internal/migration_protocol.py`, version/template files: adjacent `0.1.20` to `0.1.21` migration and frozen v2 inventory. +- `tests/test_codegraph.py`: adapter, proxy, MCP, record, migration, Skill, and optional real-CLI coverage. +- `tests/test_core.py`: host registration, vendoring, version, validation, and rollback coverage. + +--- + +### Task 1: Bounded CodeGraph CLI primitives and fail-safe response classification + +**Files:** +- Modify: `scripts/internal/codegraph_adapter.py` +- Test: `tests/test_codegraph.py` + +**Interfaces:** +- Consumes: existing `inspect_status(repo, descriptor, runner, timeout_seconds) -> dict` and CodeGraph descriptor `cli.*_args`. +- Produces: `run_explore(repo, descriptor, query, *, runner, timeout_seconds) -> dict`, `synchronize_observed_status(repo, descriptor, initial, *, runner, status_timeout_seconds, sync_timeout_seconds) -> dict`, and stricter `classify_response(repo, response, checked_at=None) -> dict`. + +- [ ] **Step 1: Write failing tests for one-shot explore and observed-status sync** + +Add tests that assert the exact call sequence and shared repository cwd: + +```python +def test_explore_and_observed_sync_are_bounded_to_one_repo(self) -> None: + calls = [] + + def runner(command, **kwargs): + calls.append((command, kwargs["cwd"])) + if command[1:3] == ["status", "--json"]: + return completed(healthy_status(self.repo)) + if command[1:] == ["sync", "--quiet"]: + return completed("synced\n") + return completed("graph response\n") + + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["modified"] = 1 + initial = codegraph_adapter._status_result( + self.repo, pending, "2026-08-19T00:00:00Z", "a" * 64 + ) + synchronized = codegraph_adapter.synchronize_observed_status( + self.repo, load_providers(ROOT)["codegraph"], initial, runner=runner + ) + explored = codegraph_adapter.run_explore( + self.repo, load_providers(ROOT)["codegraph"], "find symbol A", runner=runner + ) + self.assertEqual(synchronized["sync"]["status"], "SUCCESS") + self.assertEqual(explored["status"], "SUCCESS") + self.assertEqual(explored["response_sha256"], hashlib.sha256(b"graph response\n").hexdigest()) + self.assertTrue(all(cwd == self.repo for _command, cwd in calls)) + self.assertEqual(sum(command[1] == "sync" for command, _cwd in calls), 1) + self.assertEqual(sum(command[1] == "explore" for command, _cwd in calls), 1) +``` + +- [ ] **Step 2: Run the new focused test and confirm RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_explore_and_observed_sync_are_bounded_to_one_repo -v` + +Expected: `AttributeError` for `synchronize_observed_status` or `run_explore`. + +- [ ] **Step 3: Implement the reusable primitives and refactor the legacy wrapper** + +Use these exact public shapes: + +```python +def run_explore(repo, descriptor, query, *, runner=subprocess.run, timeout_seconds=60): + checked_at = _checked_at() + if not isinstance(query, str) or not query.strip(): + return {"status": "FAILED", "checked_at": checked_at, "response": None, + "response_sha256": None, "error": "CodeGraph query must not be blank"} + try: + completed = _run_cli( + repo, descriptor, "explore_args", timeout_seconds, runner, + extra_args=[query], + ) + raw, digest = _stdout_and_hash(completed) + except (KeyError, OSError, TypeError, UnicodeError, ValueError, + subprocess.TimeoutExpired) as error: + return {"status": "FAILED", "checked_at": checked_at, "response": None, + "response_sha256": None, "error": _error_summary(error)} + if completed.returncode != 0: + return {"status": "FAILED", "checked_at": checked_at, "response": None, + "response_sha256": digest, + "error": f"CodeGraph explore exited with {completed.returncode}"} + return {"status": "SUCCESS", "checked_at": checked_at, "response": raw, + "response_sha256": digest, "error": None} +``` + +Change `_run_cli(..., extra_args: list[str] | None = None)` to append only the supplied list. Move the current sync body into `synchronize_observed_status(...)`; keep `sync_if_needed(...)` as `inspect_status(...)` followed by that function so the existing CLI remains compatible. + +- [ ] **Step 4: Write failing tests for suspicious warning forms** + +Replace the old permissive expectations with: + +```python +def test_suspicious_or_wrapped_freshness_warnings_are_not_verified(self) -> None: + samples = ( + "warning: graph may be stale\n", + "quoted: ⚠️ CodeGraph auto-sync is DISABLED — the index is frozen.\n", + "\ufeff⚠️ CodeGraph auto-sync is DISABLED — the index is frozen.\n", + " pending-sync required\n", + ) + for response in samples: + with self.subTest(response=response): + result = codegraph_adapter.classify_response(self.repo, response) + self.assertEqual(result["classification"], "NOT_VERIFIED") + self.assertEqual(result["stale_points"][0]["reason"], "STATUS_UNREADABLE") + self.assertEqual( + result["response_sha256"], + hashlib.sha256(response.encode("utf-8")).hexdigest(), + ) +``` + +- [ ] **Step 5: Run the warning test and confirm RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_suspicious_or_wrapped_freshness_warnings_are_not_verified -v` + +Expected: at least one sample classifies as `NONE`. + +- [ ] **Step 6: Implement conservative suspicious-signal classification** + +After exact supported-banner parsing and before returning `NONE`, reject case-insensitive `warning`, `stale`, `pending-sync`, `pending sync`, `out-of-date`, or any `⚠` marker as `NOT_VERIFIED`. Preserve the exact response digest in every branch. + +- [ ] **Step 7: Run adapter tests** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_explore_and_observed_sync_are_bounded_to_one_repo tests.test_codegraph.CodeGraphTests.test_suspicious_or_wrapped_freshness_warnings_are_not_verified tests.test_codegraph.CodeGraphTests.test_pending_changes_sync_once_and_recheck_once tests.test_codegraph.CodeGraphTests.test_response_banner_marks_only_named_files_stale -v` + +Expected: all PASS; no test observes more than one sync or explore. + +- [ ] **Step 8: Commit Task 1** + +```bash +git add scripts/internal/codegraph_adapter.py tests/test_codegraph.py +git commit -m "feat: add bounded CodeGraph query primitives" +``` + +--- + +### Task 2: Proxy query-window engine and immutable runtime bundles + +**Files:** +- Create: `scripts/internal/code_intelligence_proxy.py` +- Modify: `scripts/internal/task_layout.py` +- Modify: `scripts/internal/code_intelligence_protocol.py` +- Test: `tests/test_codegraph.py` + +**Interfaces:** +- Consumes: Task 1's `inspect_status`, `synchronize_observed_status`, `run_explore`, and `classify_response`. +- Produces: `resolve_stage_context(repo, task_id, stage) -> dict`, `execute_proxy_query(repo, task_id, stage, query_id, purpose, query, sync_if_needed, *, runner=subprocess.run) -> dict`, and `render_freshness_envelope(bundle) -> str`. + +- [ ] **Step 1: Write failing stage-context and path-confinement tests** + +Assert these canonical runtime locations: + +```python +def test_proxy_stage_context_uses_record_name_and_sequential_query_ids(self) -> None: + context = code_intelligence_proxy.resolve_stage_context( + self.repo, "TASK-0001", "PLANNING" + ) + self.assertEqual(context["work_item_revision"], 1) + self.assertEqual(context["artifact_attempt"], None) + self.assertEqual(context["reviewer_slot"], None) + self.assertEqual(context["record_name"], "planning") + expected = ( + self.repo + / ".polaris/tasks/TASK-0001/runtime/code-intelligence/planning/CIQ-001.json" + ) + self.assertEqual( + code_intelligence_proxy.proxy_bundle_path( + self.repo, "TASK-0001", context, "CIQ-001" + ), + expected, + ) +``` + +Add table cases for Implementation/Documentation Sync using the current implementation handoff attempt, and Review using the current review handoff plus the next reviewer slot. Reject a stage inconsistent with task state, `CIQ-000`, a skipped ID, an existing bundle, a symlinked runtime component, and more than `CIQ-999`. + +- [ ] **Step 2: Run stage-context tests and confirm RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_proxy_stage_context_uses_record_name_and_sequential_query_ids -v` + +Expected: import failure for `internal.code_intelligence_proxy`. + +- [ ] **Step 3: Implement stage resolution and bundle layout** + +Add `code_intelligence_proxy_bundle` to `TASK_PATH_PATTERNS`: + +```python +"code_intelligence_proxy_bundle": ( + "runtime/code-intelligence/{record_name}/{query_id}.json" +), +"code_intelligence_proxy_response": ( + "runtime/code-intelligence/{record_name}/{query_id}.response.txt" +), +``` + +Extend `task_relative_path(..., query_id: str = "CIQ-001")` and pass +`query_id=query_id` into its single `pattern.format(...)` call so both helpers +remain governed by the task-layout registry. + +`resolve_stage_context` must validate the task, current revision, and stage-specific artifact, then return exactly: + +```python +{ + "task_id": task_id, + "work_item_revision": revision, + "stage": stage, + "artifact_attempt": attempt_or_none, + "reviewer_slot": slot_or_none, + "record_name": _record_name(record_identity), + "target": {"base_commit": base, "head_commit": head, "diff_hash": diff_hash}, +} +``` + +Planning derives `base_commit` from the frozen work item and uses null head/diff. Implementation and Documentation Sync use the current implementation handoff/subject. Review uses the current review handoff subject and chooses slot 1 before slot 2. Reuse existing artifact validators rather than trusting raw JSON fields. + +- [ ] **Step 4: Write failing query-window classification tests** + +Use a scripted runner and assert all four outcomes: + +```python +class ScriptedCodeGraphRunner: + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + + def __call__(self, command, **kwargs): + self.calls.append((command, kwargs)) + if not self.responses: + raise AssertionError(f"unexpected extra CodeGraph call: {command}") + return self.responses.pop(0) + +def test_proxy_window_requires_clean_pre_and_post_status_for_current(self) -> None: + runner = ScriptedCodeGraphRunner([ + completed(healthy_status(self.repo)), + completed("graph bytes\n"), + completed(healthy_status(self.repo)), + ]) + result = code_intelligence_proxy.execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate affected symbols", "symbol A", False, runner=runner, + ) + bundle = result["bundle"] + self.assertEqual(bundle["delivery"]["state"], "CURRENT") + self.assertEqual(bundle["delivery"]["usage"], "NON_AUTHORITATIVE_CONTEXT") + self.assertEqual(bundle["delivery"]["record_status"], "CURRENT_AT_CHECK") + self.assertEqual([call[0][1] for call in runner.calls], ["status", "explore", "status"]) +``` + +Add cases for pending pre-status without sync (`STALE/INDEX_STALE` but explore once), pending pre-status with a successful single sync, pending post-status downgrade, malformed pre-status (`UNKNOWN` and no explore), explore failure (`UNKNOWN` without graph), stale banner, suspicious banner, project mismatch, disabled policy/no marker/missing executable (`UNAVAILABLE` and no Provider call), and unsafe response path (discard graph). + +- [ ] **Step 5: Run query-window tests and confirm RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_proxy_window_requires_clean_pre_and_post_status_for_current -v` + +Expected: missing `execute_proxy_query`. + +- [ ] **Step 6: Implement the exact bundle and conservative merge** + +Persist this versioned shape with `write_json_atomic` and reject overwrite: + +```python +{ + "bundle_version": 1, + "proxy": {"server_id": "polaris-codegraph", "tool": "polaris_codegraph_explore"}, + "provider": {"id": "codegraph", "descriptor_version": 2}, + "repository": {"project_id": project_id, "root_sha256": sha256(str(repo.resolve()))}, + "task_context": stage_context, + "query": { + "id": query_id, "purpose": purpose, "text": query, + "status": "SUCCESS|FAILED|UNAVAILABLE", "response_sha256": digest_or_none, + "error": error_or_none, + }, + "pre_status": status_observation, + "sync": sync_observation_or_none, + "post_sync_status": status_observation_or_none, + "response_classification": classification_or_none, + "post_query_status": status_observation_or_none, + "delivery": { + "state": "CURRENT|STALE|UNKNOWN|UNAVAILABLE", + "record_status": "CURRENT_AT_CHECK|PARTIAL_STALE|INDEX_STALE|NOT_VERIFIED|UNAVAILABLE", + "reason": finite_reason, + "checked_at": timestamp, + "usage": "NON_AUTHORITATIVE_CONTEXT|NAVIGATION_ONLY|NO_GRAPH", + "required_fallback": "NONE|READ_SOURCE|INSPECT_GIT_DIFF|SEARCH_SOURCE", + "stale_points": stale_points, + "error": finite_error_or_none, + }, + "response_path": task_relative_response_path_or_none, +} +``` + +Only `CURRENT` may use `NON_AUTHORITATIVE_CONTEXT`; only `UNAVAILABLE` may use `NO_GRAPH`. Any known stale signal wins over clean observations. Any missing/unreadable proof becomes `UNKNOWN`. Save the exact UTF-8 graph response before the bundle and verify its digest. If project identity or response integrity is unsafe, remove `response_path` and do not return graph text. + +- [ ] **Step 7: Implement and test the bounded envelope** + +`render_freshness_envelope` must emit only finite scalar fields between exact start/end markers, with pending counts from the most conservative successful status and a task-relative bundle path. Add an assertion that the first returned character sequence is `[POLARIS_CODEGRAPH_FRESHNESS]` and that diagnostics are truncated to 240 characters. + +- [ ] **Step 8: Run proxy tests** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests -k proxy -v` + +Expected: all proxy tests PASS and all fake runner call counts match their exact maxima. + +- [ ] **Step 9: Commit Task 2** + +```bash +git add scripts/internal/code_intelligence_proxy.py scripts/internal/task_layout.py scripts/internal/code_intelligence_protocol.py tests/test_codegraph.py +git commit -m "feat: bind CodeGraph queries to freshness windows" +``` + +--- + +### Task 3: Standard-library stdio MCP server + +**Files:** +- Create: `scripts/code_intelligence_mcp.py` +- Test: `tests/test_codegraph.py` + +**Interfaces:** +- Consumes: Task 2's `execute_proxy_query` and `render_freshness_envelope`. +- Produces: executable MCP server supporting `initialize`, `notifications/initialized`, `ping`, `tools/list`, and `tools/call` for exactly one tool. + +- [ ] **Step 1: Write a subprocess MCP transcript test** + +Send one compact JSON object per input line: + +```python +messages = [ + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { + "protocolVersion": "2025-11-25", "capabilities": {}, + "clientInfo": {"name": "test", "version": "1"}, + }}, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, +] +completed = subprocess.run( + [sys.executable, SCRIPTS / "code_intelligence_mcp.py", "--repo", self.repo], + input="".join(json.dumps(item) + "\n" for item in messages), + text=True, capture_output=True, check=False, +) +responses = [json.loads(line) for line in completed.stdout.splitlines()] +self.assertEqual(responses[0]["result"]["protocolVersion"], "2025-11-25") +self.assertEqual(responses[0]["result"]["capabilities"], {"tools": {"listChanged": False}}) +self.assertEqual([item["name"] for item in responses[1]["result"]["tools"]], + ["polaris_codegraph_explore"]) +self.assertNotIn("codegraph_explore", {item["name"] for item in responses[1]["result"]["tools"]}) +``` + +Also assert the initialized notification produces no response and stdout contains no non-JSON lines. + +- [ ] **Step 2: Run the transcript test and confirm RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_mcp_server_initializes_and_lists_one_proxy_tool -v` + +Expected: script missing. + +- [ ] **Step 3: Implement the MCP lifecycle and one tool schema** + +Use newline-delimited UTF-8 JSON-RPC per the MCP `2025-11-25` stdio transport. The tool schema must set `additionalProperties: false`, require all six approved arguments, constrain task/stage/query IDs, and never expose a repository argument: + +```python +TOOL = { + "name": "polaris_codegraph_explore", + "description": "Run one bounded Polaris CodeGraph freshness window.", + "inputSchema": { + "type": "object", + "required": ["task_id", "stage", "query_id", "purpose", "query", "sync_if_needed"], + "additionalProperties": False, + "properties": { + "task_id": {"type": "string", "pattern": r"^TASK-[0-9]{4}$"}, + "stage": {"type": "string", "enum": ["PLANNING", "IMPLEMENTATION", "DOCUMENTATION_SYNC", "REVIEW"]}, + "query_id": {"type": "string", "pattern": r"^CIQ-[0-9]{3}$"}, + "purpose": {"type": "string", "minLength": 1, "maxLength": 240}, + "query": {"type": "string", "minLength": 1, "maxLength": 8000}, + "sync_if_needed": {"type": "boolean"}, + }, + }, +} +``` + +Return parse/invalid-request/method errors as JSON-RPC `-32700`, `-32600`, `-32601`, and `-32602`. Return user-correctable tool execution/input failures as `tools/call` results with `isError: true`. Never write logs to stdout. + +- [ ] **Step 4: Write failing envelope-order and error tests** + +Mock `execute_proxy_query` in-process and assert a successful tool call returns two text blocks—the envelope first and raw graph second. A stale/unknown graph remains `isError: false`; Provider failure has only the envelope; invalid stage/query is `isError: true`; unknown tool name is `-32602`; requests before initialization are rejected. + +- [ ] **Step 5: Implement tool-call result formatting** + +Return: + +```python +{ + "content": [ + {"type": "text", "text": render_freshness_envelope(bundle)}, + *([{"type": "text", "text": graph_text}] if graph_text is not None else []), + ], + "structuredContent": {"bundle": bundle}, + "isError": False, +} +``` + +The structured bundle must match the saved runtime bundle exactly. Ensure `json.dumps(..., ensure_ascii=False, separators=(",", ":"))` produces one physical stdout line. + +- [ ] **Step 6: Run MCP tests** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests -k mcp_server -v` + +Expected: all PASS; no output before the envelope and no repository property in the tool schema. + +- [ ] **Step 7: Commit Task 3** + +```bash +git add scripts/code_intelligence_mcp.py tests/test_codegraph.py +git commit -m "feat: expose the Polaris CodeGraph MCP proxy" +``` + +--- + +### Task 4: Code Intelligence v3 record projection and validation + +**Files:** +- Create: `schemas/code-intelligence-record-v2.schema.json` +- Create: `schemas/code-intelligence-record-annotations.schema.json` +- Modify: `schemas/code-intelligence-record.schema.json` +- Modify: `scripts/internal/code_intelligence_protocol.py` +- Modify: `scripts/record_code_intelligence.py` +- Modify: `templates/task-sources/code-intelligence-record.json` +- Test: `tests/test_codegraph.py` + +**Interfaces:** +- Consumes: Task 2 bundle v1 and existing v1/v2 validators/fallback rules. +- Produces: `validate_historical_v2_record_value(...)`, `_validate_v3_record_value(...)`, and `record_proxy_bundle(repo, task_id, bundle_path, annotations, root=None) -> dict`. + +- [ ] **Step 1: Freeze v2 and write failing v3 projection tests** + +Copy the current schema byte-for-byte to `code-intelligence-record-v2.schema.json`, then add a test that records a Task 2 bundle through: + +```python +result = record_proxy_bundle( + self.repo, + "TASK-0001", + bundle_path, + { + "summary": "Located the affected symbol.", + "symbols": [{"path": "src/a.py", "line": 1, "name": "A"}], + "source_fallbacks": [], + }, + ROOT, +) +recorded = json.loads(Path(result["path"]).read_text(encoding="utf-8")) +self.assertEqual(recorded["record_version"], 3) +self.assertEqual(recorded["proxy"]["server_id"], "polaris-codegraph") +self.assertEqual(recorded["query_window"]["pre_status"]["pending_changes"], + {"added": 0, "modified": 0, "removed": 0}) +self.assertEqual(recorded["delivery"]["state"], "CURRENT") +``` + +- [ ] **Step 2: Run the projection test and confirm RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_v3_record_projects_exact_proxy_bundle -v` + +Expected: `record_proxy_bundle` missing. + +- [ ] **Step 3: Define the v3 and annotations schemas** + +The current schema must require these top-level fields and reject extras: + +```json +[ + "record_version", "task_id", "work_item_revision", "stage", + "artifact_attempt", "reviewer_slot", "provider", "repository", "target", + "status", "proxy", "query", "query_window", "delivery", + "source_fallbacks", "recorded_at" +] +``` + +Use `record_version: {"const": 3}`. `proxy` requires `server_id: "polaris-codegraph"`, `tool: "polaris_codegraph_explore"`, and a 64-hex `evidence_bundle_sha256`. `repository` requires nonblank `project_id` and 64-hex `root_sha256`. `query_window` requires `pre_status`, nullable `sync`, nullable `post_sync_status`, nullable `response_classification`, and nullable `post_query_status`. Every successful status observation requires `checked_at`, `response_sha256`, exact nonnegative `pending_changes`, `stale_reasons`, and null error; failed/unavailable observations prohibit pending counts and hashes. Reuse the current stale-point and source-fallback definitions without weakening them. + +The annotations schema permits only `summary`, `symbols`, and `source_fallbacks`; the recorder derives identity, target, status, observations, hashes, and timestamps from the bundle/current task. + +- [ ] **Step 4: Implement bundle projection and v3 semantic invariants** + +`record_proxy_bundle` must confine the bundle below the current task's runtime directory, reject symlinks, validate bundle v1, hash its exact bytes, confirm its repository/task/stage context, and build the record. Map bundle delivery to record status exactly: + +```python +RECORD_STATUS = { + "CURRENT": "USED", + "STALE": "USED", + "UNKNOWN": "FAILED", + "UNAVAILABLE": "UNAVAILABLE", +} +``` + +The v3 validator must reject: a non-proxy provider, project/root mismatch, target mismatch, non-sequential query ID for that stage record, response hash mismatch, `CURRENT` without two successful zero-pending effective pre/post observations, stale without an explicit reason/fallback, unknown without a verification error, unavailable with any attempted operation, successful sync without post-sync status, more than one sync, and missing exact fallback evidence. + +- [ ] **Step 5: Preserve historical validators and make v3 the only new write** + +Dispatch exactly: + +```python +if version == 1: + return validate_legacy_record_value(...) +if version == 2: + return _validate_v2_record_value(..., schema_name="code-intelligence-record-v2.schema.json") +if version == 3: + return _validate_v3_record_value(...) +raise RuleFailure("unsupported Code Intelligence record version") +``` + +`record(...)` must reject versions 1 and 2 with `new Code Intelligence records must use record_version 3`. Add `validate_historical_v2_record_value` so migration can validate canonical prior-revision v2 records without requiring the task's current revision. + +- [ ] **Step 6: Update the recording CLI and template** + +Support only: + +```text +record_code_intelligence.py TASK-0001 --bundle --annotations +record_code_intelligence.py --select-provider ... +``` + +Reject the old unrestricted `--input` new-write path. Update the template to a valid v3 unavailable example carrying proxy/repository/query-window/delivery fields and no attempted graph operation. + +- [ ] **Step 7: Add mutation tests for every v3 invariant** + +Deep-copy a valid v3 record, mutate one field per subtest, and assert `RuleFailure` for nonzero pending `CURRENT`, missing post-query status, response hash mismatch, bundle hash shape, repository mismatch, contradictory delivery/usage, stale without fallback, unsafe fallback result path, sync without post-sync observation, and v2 new-write attempt. Verify frozen v1/v2 samples remain byte-identical and readable. + +- [ ] **Step 8: Run record tests** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests -k v3 -v` + +Expected: all v3 tests PASS; existing v1/v2 historical tests PASS after updating only expected new-write messages. + +- [ ] **Step 9: Commit Task 4** + +```bash +git add schemas/code-intelligence-record-v2.schema.json schemas/code-intelligence-record-annotations.schema.json schemas/code-intelligence-record.schema.json scripts/internal/code_intelligence_protocol.py scripts/record_code_intelligence.py templates/task-sources/code-intelligence-record.json tests/test_codegraph.py +git commit -m "feat: record auditable CodeGraph proxy evidence" +``` + +--- + +### Task 5: Host adapter v3 and non-destructive project MCP registration + +**Files:** +- Create: `scripts/internal/project_mcp_registration.py` +- Modify: `schemas/host-adapter.schema.json` +- Modify: `hosts/codex/adapter.json` +- Modify: `hosts/claude-code/adapter.json` +- Modify: `scripts/internal/host_adapters.py` +- Modify: `scripts/vendor_project.py` +- Modify: `scripts/init_project.py` +- Modify: `scripts/validate_project.py` +- Test: `tests/test_core.py` + +**Interfaces:** +- Consumes: Task 3's vendored entry path `tools/polaris/scripts/code_intelligence_mcp.py`. +- Produces: `project_mcp_target(repo, adapter) -> Path`, `merge_project_mcp(repo, adapter, source_text=None) -> str`, and `validate_project_mcp(repo, adapter) -> None`. + +- [ ] **Step 1: Write failing adapter-v3 manifest tests** + +Require each manifest to contain: + +```json +"project_mcp": { + "server_id": "polaris-codegraph", + "format": "codex-toml", + "target": ".codex/config.toml", + "command": "python3", + "args": ["tools/polaris/scripts/code_intelligence_mcp.py", "--repo", "."] +} +``` + +Claude differs only by `format: "claude-json"` and `target: ".mcp.json"`. Update synthetic adapters in tests to version 3 and provide a unique safe target. Reject unknown format, wrong server ID, absolute/parent paths, a launcher outside `tools/polaris`, missing `--repo .`, duplicate registration targets, and overlap with `skill_target`/`files`. + +- [ ] **Step 2: Run manifest tests and confirm RED** + +Run: `python3 -m unittest tests.test_core.PolarisCoreTests.test_host_adapter_contract_rejects_invalid_or_conflicting_manifests -v` + +Expected: schema rejects v3 or missing `project_mcp`. + +- [ ] **Step 3: Implement adapter-v3 schema and semantic validation** + +Set `adapter_version.const` to 3 and make `project_mcp` required. Validate `server_id`, `format`, target confinement, exact project-relative launcher, argument order, and target overlap in `load_host_adapters`. Keep host discovery declarative—no host ID branches in vendoring or validation. + +- [ ] **Step 4: Write failing non-destructive merge tests** + +For Codex, start with: + +```toml +model = "gpt-5" +[mcp_servers.other] +command = "other" +``` + +Assert the original bytes remain and one marked Polaris block is appended: + +```toml +# POLARIS_MCP_START polaris-codegraph +[mcp_servers.polaris-codegraph] +command = "python3" +args = ["tools/polaris/scripts/code_intelligence_mcp.py", "--repo", "."] +cwd = "." +enabled = true +required = false +enabled_tools = ["polaris_codegraph_explore"] +# POLARIS_MCP_END polaris-codegraph +``` + +For Claude, preserve unrelated top-level fields and `mcpServers.other`, then insert exactly: + +```json +"polaris-codegraph": { + "type": "stdio", + "command": "python3", + "args": ["tools/polaris/scripts/code_intelligence_mcp.py", "--repo", "."], + "env": {} +} +``` + +Assert idempotent rerendering, exact managed-block replacement, malformed TOML/JSON rejection, conflicting unmanaged same-name rejection, symlink rejection, and unrelated-content preservation. + +- [ ] **Step 5: Implement format-specific merge/validation in one focused module** + +Use `tomllib.loads` to validate full TOML before and after replacing the uniquely marked block; never rewrite unrelated TOML bytes. Use `json.loads` plus four-space `json.dumps(..., ensure_ascii=False, indent=4) + "\n"` for Claude. A same-name Claude entry is accepted only if it exactly equals the managed definition; otherwise raise `RuleFailure`. + +- [ ] **Step 6: Integrate registration into the vendor transaction** + +During `_stage_install`, read the target host config if present, render its merged staged form, and list it as a preserved path. Add each registration target to `_polaris_destinations`/affected paths so rollback backs it up. `init_project.initialize` merges registrations only when `protocol_root(repo) == repo / "tools/polaris"`; source-tree initialization without a vendored runtime must not create a dangling registration. + +- [ ] **Step 7: Validate exact vendored runtime ownership** + +In `validate_project`, parse each registration, require only the declared tool, ensure its launcher is a regular file under the vendored root, reject symlink hops, and require `--repo .`. Include the registration config in the install manifest's preserved paths, never managed files. + +- [ ] **Step 8: Run host/vendoring tests** + +Run: `python3 -m unittest tests.test_core.PolarisCoreTests -k host -v` + +Run: `python3 -m unittest tests.test_core.PolarisCoreTests.test_vendor_rolls_back_after_partial_apply_failure tests.test_core.PolarisCoreTests.test_force_vendor_preserves_unrelated_claude_configuration tests.test_core.PolarisCoreTests.test_vendored_target_is_self_contained -v` + +Expected: all PASS; injected apply failure restores both host configs byte-for-byte. + +- [ ] **Step 9: Commit Task 5** + +```bash +git add scripts/internal/project_mcp_registration.py schemas/host-adapter.schema.json hosts/codex/adapter.json hosts/claude-code/adapter.json scripts/internal/host_adapters.py scripts/vendor_project.py scripts/init_project.py scripts/validate_project.py tests/test_core.py +git commit -m "feat: register the project CodeGraph proxy" +``` + +--- + +### Task 6: Protocol 0.1.21 migration and frozen v2 inventory + +**Files:** +- Modify: `VERSION` +- Modify: `pyproject.toml` +- Modify: `templates/project.json` +- Modify: `templates/task/state.json` +- Modify: `templates/task-sources/state.json` +- Modify: `workflow/migrations.json` +- Modify: `scripts/internal/migration_protocol.py` +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `docs/USAGE.md` +- Modify: `plan.md` +- Test: `tests/test_codegraph.py` +- Test: `tests/test_core.py` + +**Interfaces:** +- Consumes: Task 4's `validate_historical_v2_record_value` and Task 5 adapter v3 registration. +- Produces: adjacent migration `0.1.20-to-0.1.21`, protocol/package version `0.1.21`, unchanged workflow `0.1.3`. + +- [ ] **Step 1: Write failing version-route and frozen-v2 migration tests** + +Create a valid v2 record in both current and prior revision directories, freeze their bytes, migrate, then assert: + +```python +self.assertEqual(result["from"], "0.1.20") +self.assertEqual(result["to"], "0.1.21") +self.assertEqual(read_json(self.repo / ".polaris/project.json")["workflow_version"], "0.1.3") +self.assertEqual(v2_path.read_bytes(), frozen_v2_bytes) +self.assertIn( + {"task_id": "TASK-0001", "path": "code-intelligence/r001/planning.json", + "sha256": hashlib.sha256(frozen_v2_bytes).hexdigest()}, + migration["retired_code_intelligence_records"], +) +``` + +Add rejection tests for noncanonical v2 path, mutated v2 bytes on resumed migration, cross-root/dangling symlink, skipped `0.1.20 -> 0.1.22`, and workflow version change. + +- [ ] **Step 2: Run migration tests and confirm RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests -k migration -v` + +Expected: no `0.1.20-to-0.1.21` route and/or v2 not inventoried. + +- [ ] **Step 3: Add the adjacent migration and version replacements** + +Append exactly: + +```json +{ + "migration_id": "0.1.20-to-0.1.21", + "from_polaris_version": "0.1.20", + "to_polaris_version": "0.1.21", + "from_workflow_version": "0.1.3", + "to_workflow_version": "0.1.3", + "project_strategy": "replace_version", + "task_strategy": "append_version_event" +} +``` + +Change only protocol/package/template version literals to `0.1.21`; leave every workflow version at `0.1.3`. + +- [ ] **Step 4: Inventory v2 without weakening historical validation** + +Pass the migration step into `_retired_code_intelligence_records`. For `0.1.20-to-0.1.21`, include canonical validated v2 records (and retain already supported v1 inventory) with task-relative path and exact SHA-256. Other historical migration records remain valid and byte-identical. On resume, compare the recomputed inventory with the frozen migration record before appending events. + +- [ ] **Step 5: Update authority/version documentation** + +Update the current-version lines and migration ledger in both READMEs, `docs/USAGE.md`, and `plan.md`. State that `0.1.21` adds the project-scoped proxy, host adapter v3, and v3 records; state explicitly that Workflow remains `0.1.3`, CodeGraph remains optional/non-gating, and v1/v2 are historical only. + +- [ ] **Step 6: Run version and migration tests** + +Run: `python3 -m unittest tests.test_core.PolarisCoreTests -k migration -v` + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests -k migration -v` + +Expected: all PASS; route is adjacent and frozen v2 bytes do not change. + +- [ ] **Step 7: Commit Task 6** + +```bash +git add VERSION pyproject.toml templates/project.json templates/task/state.json templates/task-sources/state.json workflow/migrations.json scripts/internal/migration_protocol.py README.md README.zh-CN.md docs/USAGE.md plan.md tests/test_codegraph.py tests/test_core.py +git commit -m "feat: migrate CodeGraph evidence to protocol 0.1.21" +``` + +--- + +### Task 7: Stage Skills, host renderings, and user-facing proxy contract + +**Files:** +- Modify: `skills/code-intelligence/SKILL.md` +- Modify: `skills/architecture-planning/SKILL.md` +- Modify: `skills/implementation/SKILL.md` +- Modify: `skills/documentation-sync/SKILL.md` +- Modify: `skills/adversarial-review/SKILL.md` +- Modify: `templates/AGENTS.md` +- Modify: relevant `hosts/*/skill-appendices/*.md` +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `docs/USAGE.md` +- Test: `tests/test_codegraph.py` + +**Interfaces:** +- Consumes: MCP tool name/arguments from Task 3, bundle-to-record CLI from Task 4, and registration behavior from Task 5. +- Produces: one consistent Agent/human contract across canonical, rendered, vendored, and localized surfaces. + +- [ ] **Step 1: Invoke the required Skill-writing workflow** + +Before changing any Skill, read and follow `superpowers:writing-skills`. Record the required baseline adversarial evaluation using these four prompts: skip the proxy, trust a clean graph with pending changes, reuse an old Implementation envelope, and treat `UNKNOWN` as current. + +- [ ] **Step 2: Write failing semantic contract tests** + +Render every Skill through every adapter and assert the relevant surfaces contain all exact anchors: + +```python +anchors = ( + "polaris_codegraph_explore", + "freshness envelope", + "NON_AUTHORITATIVE_CONTEXT", + "NAVIGATION_ONLY", + "source/Git fallback", + "raw `codegraph_explore`", + "cannot back `CURRENT` Polaris evidence", +) +``` + +Assert Documentation Sync also contains `sync_if_needed: true`, changed source paths/documented symbols, and “no separate status/sync MCP tool”. Assert Validation surfaces contain none of `polaris_codegraph_explore`, `codegraph status`, or `codegraph sync`. Mutation-check removal of the proxy tool name and fallback branch from one rendered surface. + +- [ ] **Step 3: Run semantic tests and confirm RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_all_agent_surfaces_require_proxy_provenance tests.test_codegraph.CodeGraphTests.test_documentation_sync_uses_one_proxy_query tests.test_codegraph.CodeGraphTests.test_validation_remains_graph_free -v` + +Expected: existing direct status/sync instructions violate the new anchors. + +- [ ] **Step 4: Rewrite canonical stage behavior** + +Use the exact lifecycle: + +1. If Code Intelligence is disabled or `.codegraph/` is absent, skip proxy and use source/Git. +2. Call only `polaris_codegraph_explore` for Polaris graph evidence. +3. Read the envelope before graph content. +4. Treat `CURRENT` as non-authoritative context. +5. For `STALE`/`UNKNOWN`, complete every named fallback before using affected conclusions. +6. Record via `record_code_intelligence.py --bundle ... --annotations ...` only if a proxy operation occurred. +7. Raw Provider MCP/shell output remains allowed but is always out-of-band and cannot support `CURRENT`. + +Implementation must require a fresh call after edits. Documentation Sync must perform one bounded changed-path/symbol query with `sync_if_needed: true` only when supported source changed. Review must independently call the proxy and never inherit the implementer's envelope. Validation must remain graph-free. + +- [ ] **Step 5: Update human docs without obsolete paths** + +Document project trust/first-use approval, `.codex/config.toml`, `.mcp.json`, exact envelope meanings, source/Git fallback, optional/non-gating status, and user ownership of CodeGraph install/init/config/watchers. Remove stage instructions that tell users to separately choose `status` or `sync-if-needed` before raw MCP explore. + +- [ ] **Step 6: Regenerate/render and run adversarial evaluation** + +Run: `python3 scripts/materialize_task_layout.py` + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_all_agent_surfaces_require_proxy_provenance tests.test_codegraph.CodeGraphTests.test_documentation_sync_uses_one_proxy_query tests.test_codegraph.CodeGraphTests.test_validation_remains_graph_free -v` + +Repeat the four baseline prompts through the Skill evaluation method required by `writing-skills`. The changed behavior must refuse every shortcut and state the required fallback. + +- [ ] **Step 7: Commit Task 7** + +```bash +git add skills hosts templates/AGENTS.md README.md README.zh-CN.md docs/USAGE.md tests/test_codegraph.py +git commit -m "docs: route Polaris stages through the CodeGraph proxy" +``` + +--- + +### Task 8: End-to-end acceptance, packaging, and PR readiness + +**Files:** +- Modify if tests expose gaps: only files already named in Tasks 1-7 +- Test: `tests/test_codegraph.py` +- Test: `tests/test_core.py` + +**Interfaces:** +- Consumes: all prior tasks. +- Produces: a clean, reviewable feature branch ready for draft PR and CI monitoring. + +- [ ] **Step 1: Add a fake-CLI end-to-end MCP test** + +Create an executable temporary `codegraph` fixture that records cwd/argv and returns scripted status/sync/explore outputs. Vendor Polaris into a disposable Git repo, initialize the task and `.codegraph/`, launch the registered MCP command, call the tool, record its bundle into v3, and run `validate_project.py`. Assert envelope order, exact call maxima, runtime ignore, record hash binding, and no graph calls during project validation. + +- [ ] **Step 2: Add optional real-CLI smoke coverage** + +Extend the existing disposable-repo guard. If `codegraph` is absent, skip. If present, create the temporary repo outside the Polaris workspace, never run init/configuration, and run only when the fixture already has an explicitly created safe marker/index. Assert all CLI commands use the disposable repo cwd and clean up through `TemporaryDirectory`. + +- [ ] **Step 3: Run focused suites** + +Run: `python3 -m unittest tests.test_codegraph -v` + +Run: `python3 -m unittest tests.test_core -v` + +Expected: all PASS; the optional real-CLI test may be the only skip. + +- [ ] **Step 4: Run repository-wide verification** + +Run: `python3 tests/run_tests.py` + +Run: `python3 -m compileall -q polaris_cli.py scripts tests` + +Run: `python3 scripts/materialize_task_layout.py && git diff --exit-code` + +Run: `git diff --check dev...HEAD` + +Run: `rg -n "record_version.?[:=].?2|0\.1\.20|adapter_version.?[:=].?2|status.*or.*sync-if-needed" README.md README.zh-CN.md docs plan.md skills hosts templates scripts schemas tests --glob '!schemas/code-intelligence-record-v2.schema.json' --glob '!schemas/code-intelligence-record-v1.schema.json'` + +Expected: full suite PASS; compile/materialize/diff checks exit 0; the final scan contains only deliberate historical/migration assertions. + +- [ ] **Step 5: Review the final diff against all 17 acceptance tests** + +For each numbered item in the design's Testing and Evaluation section, point to one passing test name. Confirm no implementation added a dependency, daemon, watcher, retry loop, Validation graph call, alternate Provider, global MCP registration, or raw-tool restriction. + +Use this coverage map during review: + +| Spec test | Plan coverage | +| --- | --- | +| 1-3 pre/post pending and clean currentness | Task 2 query-window table tests | +| 4 failed/malformed status | Task 2 no-explore `UNKNOWN` tests | +| 5-6 exact, stale, wrapped, and suspicious banners | Task 1 classifier tests and Task 2 delivery tests | +| 7 shared cwd | Task 1 call-sequence test and Task 8 fake CLI | +| 8 project mismatch | Task 2 discard test | +| 9 envelope ordering | Task 3 tool result test and Task 8 transcript | +| 10 v3 pending/post-query rejection | Task 4 mutation tests | +| 11 historical v1/v2 bytes/readability | Tasks 4 and 6 migration tests | +| 12 stage/vendored/host provenance | Task 7 rendered semantic contract | +| 13 graph-free Validation | Task 7 negative surface test and Task 8 validation call log | +| 14 suite without CodeGraph | Task 8 full suite | +| 15 disposable real CLI | Task 8 guarded smoke test | +| 16 non-destructive host registration | Task 5 merge/rollback tests | +| 17 single Documentation Sync proxy | Task 7 semantic test | + +- [ ] **Step 6: Commit any verification-only test corrections** + +```bash +git add tests README.md README.zh-CN.md docs plan.md +git commit -m "test: verify the CodeGraph MCP proxy end to end" +``` + +Skip this commit if Step 4 required no changes. + +- [ ] **Step 7: Hand off for branch finishing and CI** + +After a clean verification run, use `superpowers:requesting-code-review`, then `superpowers:finishing-a-development-branch`. Publish a draft PR targeting `dev` with the GitHub publishing workflow. Monitor every GitHub Actions check; for any failure, use `github:gh-fix-ci`, reproduce the failing check locally, add or tighten a regression test, push the fix, and continue until all required checks pass. From 376ae95e382642a0ed40b8e3f1a9c2b64fdf57d5 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 02:39:44 +0800 Subject: [PATCH 03/28] feat: add bounded CodeGraph query primitives --- scripts/internal/codegraph_adapter.py | 113 ++++++++++++++++++++++++-- tests/test_codegraph.py | 76 ++++++++++++++--- 2 files changed, 171 insertions(+), 18 deletions(-) diff --git a/scripts/internal/codegraph_adapter.py b/scripts/internal/codegraph_adapter.py index 5e586b0..6f5e7fb 100644 --- a/scripts/internal/codegraph_adapter.py +++ b/scripts/internal/codegraph_adapter.py @@ -43,6 +43,10 @@ r"^ - (?P.+) \(edited [^\n()]+, pending sync\)$" ) _DISABLED_BANNER = "⚠️ CodeGraph auto-sync is DISABLED — the index is frozen." +_SUSPICIOUS_FRESHNESS_SIGNAL = re.compile( + r"(?:⚠|\bwarning\b|\bstale\b|\bpending(?:[- ]sync)?\b|\bout[- ]of[- ]date\b)", + re.IGNORECASE, +) def _checked_at() -> str: @@ -204,6 +208,12 @@ def classify_response( return result if not normalized.startswith(_PARTIAL_BANNER_HEADER): + if _SUSPICIOUS_FRESHNESS_SIGNAL.search(normalized): + result = _response_not_verified( + checked_at, "unrecognized CodeGraph freshness warning" + ) + result["response_sha256"] = response_sha256 + return result result = _response_result("NONE", checked_at, stale_points=[]) result["response_sha256"] = response_sha256 return result @@ -328,9 +338,14 @@ def _run_cli( args_key: str, timeout_seconds: float, runner: Runner, + extra_args: list[str] | None = None, ) -> subprocess.CompletedProcess[str]: timeout = _validated_timeout(timeout_seconds) - command = [descriptor["cli"]["executable"], *descriptor["cli"][args_key]] + command = [ + descriptor["cli"]["executable"], + *descriptor["cli"][args_key], + *(extra_args or []), + ] return runner( command, cwd=repo, @@ -476,6 +491,66 @@ def inspect_status( return _not_verified(checked_at, error, response_sha256) +def run_explore( + repo: Path, + descriptor: dict[str, Any], + query: str, + *, + runner: Runner = subprocess.run, + timeout_seconds: float = 60, +) -> dict[str, Any]: + """Run exactly one bounded CodeGraph explore command in ``repo``.""" + checked_at = _checked_at() + if not isinstance(query, str) or not query.strip(): + return { + "status": "FAILED", + "checked_at": checked_at, + "response": None, + "response_sha256": None, + "error": "CodeGraph query must not be blank", + } + try: + completed = _run_cli( + repo, + descriptor, + "explore_args", + timeout_seconds, + runner, + extra_args=[query], + ) + raw, response_sha256 = _stdout_and_hash(completed) + except ( + KeyError, + OSError, + TypeError, + UnicodeError, + ValueError, + subprocess.TimeoutExpired, + ) as error: + return { + "status": "FAILED", + "checked_at": checked_at, + "response": None, + "response_sha256": None, + "error": _error_summary(error), + } + if completed.returncode != 0: + return { + "status": "FAILED", + "checked_at": checked_at, + "response": None, + "response_sha256": response_sha256, + "error": f"CodeGraph explore exited with {completed.returncode}", + } + return { + "status": "SUCCESS", + "checked_at": checked_at, + "response": raw, + "response_sha256": response_sha256, + "error": None, + } + + def _sync_result(status: str, response_sha256: str | None, error: str | None) -> dict[str, Any]: return { "status": status, @@ -501,24 +576,22 @@ def _sync_failed( } -def sync_if_needed( +def synchronize_observed_status( repo: Path, descriptor: dict[str, Any], + initial: dict[str, Any], *, runner: Runner = subprocess.run, status_timeout_seconds: float = 15, sync_timeout_seconds: float = 120, ) -> dict[str, Any]: - """Synchronize at most once, then inspect status at most once more.""" + """Synchronize one already-observed status at most once, then recheck once.""" try: status_timeout = _validated_timeout(status_timeout_seconds) sync_timeout = _validated_timeout(sync_timeout_seconds) except ValueError as error: freshness = _not_verified(_checked_at(), error) return {"freshness": freshness, "sync": _sync_result("SKIPPED", None, None)} - initial = inspect_status( - repo, descriptor, runner=runner, timeout_seconds=status_timeout - ) skipped = _sync_result("SKIPPED", None, None) unavailable = _sync_result("UNAVAILABLE", None, None) if initial["status"] == "UNAVAILABLE" or _marker_path(repo, descriptor) is None: @@ -558,3 +631,31 @@ def sync_if_needed( ) rechecked["basis"] = [*rechecked["basis"], "SYNC_ACKNOWLEDGED"] return {"freshness": rechecked, "sync": sync} + + +def sync_if_needed( + repo: Path, + descriptor: dict[str, Any], + *, + runner: Runner = subprocess.run, + status_timeout_seconds: float = 15, + sync_timeout_seconds: float = 120, +) -> dict[str, Any]: + """Inspect once, synchronize at most once, then inspect at most once more.""" + try: + status_timeout = _validated_timeout(status_timeout_seconds) + sync_timeout = _validated_timeout(sync_timeout_seconds) + except ValueError as error: + freshness = _not_verified(_checked_at(), error) + return {"freshness": freshness, "sync": _sync_result("SKIPPED", None, None)} + initial = inspect_status( + repo, descriptor, runner=runner, timeout_seconds=status_timeout + ) + return synchronize_observed_status( + repo, + descriptor, + initial, + runner=runner, + status_timeout_seconds=status_timeout, + sync_timeout_seconds=sync_timeout, + ) diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 2dc9938..e5b01da 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib +import hashlib import io import json import shutil @@ -1694,6 +1695,51 @@ def runner( self.assertEqual(result["freshness"]["status"], "CURRENT_AT_CHECK") self.assertIn("SYNC_ACKNOWLEDGED", result["freshness"]["basis"]) + def test_explore_and_observed_sync_are_bounded_to_one_repo(self) -> None: + adapter = self.adapter_module() + (self.repo / ".codegraph").mkdir() + calls: list[tuple[list[str], Path]] = [] + + def runner( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + cwd = kwargs["cwd"] + self.assertIsInstance(cwd, Path) + calls.append((command, cwd)) + if command[1:3] == ["status", "--json"]: + return completed(healthy_status(self.repo)) + if command[1:] == ["sync", "--quiet"]: + return completed("synced\n") + return completed("graph response\n") + + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["modified"] = 1 + initial = adapter._status_result( + self.repo, pending, "2026-08-19T00:00:00Z", "a" * 64 + ) + synchronized = adapter.synchronize_observed_status( + self.repo, + load_providers(ROOT)["codegraph"], + initial, + runner=runner, + ) + explored = adapter.run_explore( + self.repo, + load_providers(ROOT)["codegraph"], + "find symbol A", + runner=runner, + ) + + self.assertEqual(synchronized["sync"]["status"], "SUCCESS") + self.assertEqual(explored["status"], "SUCCESS") + self.assertEqual( + explored["response_sha256"], + hashlib.sha256(b"graph response\n").hexdigest(), + ) + self.assertTrue(all(cwd == self.repo for _command, cwd in calls)) + self.assertEqual(sum(command[1] == "sync" for command, _cwd in calls), 1) + self.assertEqual(sum(command[1] == "explore" for command, _cwd in calls), 1) + def test_index_wide_stale_reasons_do_not_sync(self) -> None: inspect_status, sync_if_needed = self.adapter_functions() (self.repo / ".codegraph").mkdir() @@ -2101,29 +2147,35 @@ def test_response_banner_rejects_unsafe_windows_style_paths(self) -> None: result["stale_points"][0]["reason"], "STATUS_UNREADABLE" ) - def test_arbitrary_warning_is_not_a_codegraph_banner(self) -> None: - result = self.classify_response("⚠️ maybe stale: src/widget.py\n") - - self.assertEqual(result["classification"], "NONE") - self.assertEqual(result["stale_points"], []) - - def test_prefixed_or_quoted_official_banner_is_not_recognized(self) -> None: + def test_suspicious_or_wrapped_freshness_warnings_are_not_verified(self) -> None: banner = """⚠️ Some files referenced below were edited since the last index sync — their codegraph entries may be stale: - src/deleted.py (edited 800ms ago, pending sync) For accurate content of those specific files, Read them directly. """ - for response in ( + samples = ( + "warning: graph may be stale\n", + "⚠️ maybe stale: src/widget.py\n", + "quoted: ⚠️ CodeGraph auto-sync is DISABLED — the index is frozen.\n", + "\ufeff⚠️ CodeGraph auto-sync is DISABLED — the index is frozen.\n", + " pending-sync required\n", f"context before banner\n{banner}", f"> {banner}", f"quoted response: {banner}", f" {banner}", f"\ufeff{banner}", - ): - with self.subTest(response=response[:20]): + ) + for response in samples: + with self.subTest(response=response[:30]): result = self.classify_response(response) - self.assertEqual(result["classification"], "NONE") - self.assertEqual(result["stale_points"], []) + self.assertEqual(result["classification"], "NOT_VERIFIED") + self.assertEqual( + result["stale_points"][0]["reason"], "STATUS_UNREADABLE" + ) + self.assertEqual( + result["response_sha256"], + hashlib.sha256(response.encode("utf-8")).hexdigest(), + ) def test_merge_freshness_uses_conservative_status_and_ordered_evidence(self) -> None: merger = getattr(self.adapter_module(), "merge_freshness", None) From cf6501d660111aa3d15693da2cd4e84c4c73c452 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 02:49:40 +0800 Subject: [PATCH 04/28] feat: bind CodeGraph queries to freshness windows --- scripts/internal/code_intelligence_proxy.py | 615 ++++++++++++++++++++ scripts/internal/task_layout.py | 8 + tests/test_codegraph.py | 457 +++++++++++++++ 3 files changed, 1080 insertions(+) create mode 100644 scripts/internal/code_intelligence_proxy.py diff --git a/scripts/internal/code_intelligence_proxy.py b/scripts/internal/code_intelligence_proxy.py new file mode 100644 index 0000000..b141026 --- /dev/null +++ b/scripts/internal/code_intelligence_proxy.py @@ -0,0 +1,615 @@ +"""Bound one CodeGraph explore call to an auditable Polaris freshness window.""" + +from __future__ import annotations + +import hashlib +import re +import shutil +import subprocess +from pathlib import Path +from typing import Any + +from .code_intelligence_protocol import ( + _project_marker_path, + _record_name, + load_config, + load_providers, +) +from .codegraph_adapter import ( + classify_response, + inspect_status, + run_explore, + synchronize_observed_status, +) +from .implementation_protocol import validate_handoff as validate_implementation_handoff +from .path_security import confined_target +from .polaris_core import ( + InputFailure, + RuleFailure, + file_sha256, + full_commit, + protocol_root, + read_json, + require_protocol_compatible, + subject_diff_hash, + task_dir, + utc_now, + validate_json_file, + write_json_atomic, + write_text_atomic, +) +from .review_handoff_protocol import validate_handoff as validate_review_handoff +from .task_layout import state_path, task_relative_path, work_item_path + + +QUERY_ID_PATTERN = re.compile(r"^CIQ-(?P[0-9]{3})$") +STAGE_STATUSES = { + "PLANNING": {"QUALIFIED"}, + "IMPLEMENTATION": {"IMPLEMENTING"}, + "DOCUMENTATION_SYNC": {"IMPLEMENTING"}, + "REVIEW": {"REVIEWING"}, +} +_INDEX_FALLBACK = { + "scope": "INDEX", + "path": None, + "reason": "STATUS_UNREADABLE", + "fallback": "SEARCH_SOURCE", + "observed_sha256": None, +} + + +def _target(base: str, head: str | None, diff_hash: str | None) -> dict[str, str | None]: + return {"base_commit": base, "head_commit": head, "diff_hash": diff_hash} + + +def _validated_subject(repo: Path, subject: Any, fallback_base: str) -> dict[str, str | None]: + if subject is None: + return _target(full_commit(repo, fallback_base), None, None) + if not isinstance(subject, dict): + raise RuleFailure("CodeGraph stage context has an invalid subject") + base = full_commit(repo, subject.get("base_commit", "")) + head = full_commit(repo, subject.get("head_commit", "")) + digest = subject_diff_hash(repo, base, head) + if subject.get("diff_hash") != digest: + raise RuleFailure("CodeGraph stage context subject diff hash is stale") + return _target(base, head, digest) + + +def resolve_stage_context(repo: Path, task_id: str, stage: str) -> dict[str, Any]: + """Resolve one stage identity from validated frozen task artifacts.""" + if stage not in STAGE_STATUSES: + raise InputFailure(f"invalid CodeGraph stage: {stage}") + root = protocol_root(repo) + directory = task_dir(repo, task_id) + state = validate_json_file(state_path(directory), root / "schemas/task-state.schema.json") + require_protocol_compatible(repo, state) + if state["task_id"] != task_id: + raise RuleFailure("CodeGraph stage context targets the wrong task") + if state["status"] not in STAGE_STATUSES[stage]: + raise RuleFailure( + f"CodeGraph stage {stage} is inconsistent with task status {state['status']}" + ) + revision = state["current_revision"] + work_item = validate_json_file( + work_item_path(directory, revision), root / "schemas/work-item.schema.json" + ) + if work_item["id"] != task_id or work_item["revision"] != revision: + raise RuleFailure("CodeGraph stage context has the wrong frozen Work Item") + + attempt: int | None = None + reviewer_slot: int | None = None + if stage == "PLANNING": + target = _target(full_commit(repo, work_item["base_commit"]), None, None) + elif stage in {"IMPLEMENTATION", "DOCUMENTATION_SYNC"}: + handoff, _reference = validate_implementation_handoff( + repo, root, directory, state + ) + attempt = handoff["artifact_attempt"] + target = _validated_subject(repo, state.get("subject"), handoff["subject_base_commit"]) + else: + handoff = validate_review_handoff(repo, root, directory, state) + attempt = handoff["artifact_attempt"] + target = _target( + full_commit(repo, handoff["subject_base_commit"]), + full_commit(repo, handoff["subject_head_commit"]), + handoff["subject_diff_hash"], + ) + if target["diff_hash"] != subject_diff_hash( + repo, str(target["base_commit"]), str(target["head_commit"]) + ): + raise RuleFailure("CodeGraph Review handoff diff hash is stale") + if state["artifacts"].get("review_2") is not None: + raise RuleFailure("CodeGraph Review already has both reviewer slots") + reviewer_slot = 2 if state["artifacts"].get("review") is not None else 1 + + identity = { + "stage": stage, + "artifact_attempt": attempt, + "reviewer_slot": reviewer_slot, + } + return { + "task_id": task_id, + "work_item_revision": revision, + "stage": stage, + "artifact_attempt": attempt, + "reviewer_slot": reviewer_slot, + "record_name": _record_name(identity), + "target": target, + } + + +def _validated_query_id(query_id: str) -> int: + match = QUERY_ID_PATTERN.fullmatch(query_id) if isinstance(query_id, str) else None + if match is None or match["number"] == "000": + raise InputFailure(f"invalid CodeGraph query id: {query_id}") + return int(match["number"]) + + +def _proxy_path( + repo: Path, + task_id: str, + context: dict[str, Any], + query_id: str, + artifact: str, +) -> Path: + _validated_query_id(query_id) + if context.get("task_id") != task_id: + raise RuleFailure("CodeGraph proxy context targets the wrong task") + record_name = context.get("record_name") + if not isinstance(record_name, str) or re.fullmatch( + r"(?:planning|implementation-[0-9]{3}|documentation-sync-[0-9]{3}|review-[0-9]{3}-slot-[12])", + record_name, + ) is None: + raise RuleFailure("CodeGraph proxy context has an invalid record name") + directory = task_dir(repo, task_id) + relative = task_relative_path( + artifact, record_name=record_name, query_id=query_id + ) + return confined_target(directory, directory / relative, "CodeGraph proxy evidence") + + +def proxy_bundle_path( + repo: Path, task_id: str, context: dict[str, Any], query_id: str +) -> Path: + """Return the next immutable bundle path for this stage record.""" + number = _validated_query_id(query_id) + destination = _proxy_path( + repo, task_id, context, query_id, "code_intelligence_proxy_bundle" + ) + parent = destination.parent + confined_target(task_dir(repo, task_id), parent, "CodeGraph proxy runtime directory") + existing_numbers: list[int] = [] + if parent.exists(): + if not parent.is_dir(): + raise RuleFailure("CodeGraph proxy runtime path is not a directory") + for path in parent.glob("CIQ-*.json"): + match = QUERY_ID_PATTERN.fullmatch(path.stem) + if match is None or path.is_symlink() or not path.is_file(): + raise RuleFailure(f"invalid CodeGraph proxy bundle path: {path}") + existing_numbers.append(int(match["number"])) + expected_numbers = list(range(1, len(existing_numbers) + 1)) + if sorted(existing_numbers) != expected_numbers: + raise RuleFailure("existing CodeGraph proxy query IDs are not sequential") + expected = len(existing_numbers) + 1 + if expected > 999: + raise InputFailure("CodeGraph proxy query limit exceeded for this stage") + if number != expected: + raise InputFailure(f"CodeGraph query id must be the next sequential ID CIQ-{expected:03d}") + if destination.exists() or destination.is_symlink(): + raise InputFailure(f"CodeGraph proxy bundle is immutable: {destination}") + return destination + + +def _unavailable_status(reason: str) -> dict[str, Any]: + return { + "status": "UNAVAILABLE", + "checked_at": utc_now(), + "basis": ["NONE"], + "stale_points": [], + "status_response_sha256": None, + "error": reason[:240], + "needs_sync": False, + "pending_changes": None, + } + + +def _pending_point() -> dict[str, Any]: + return {**_INDEX_FALLBACK, "reason": "PENDING_CHANGES"} + + +def _observation_points(observation: dict[str, Any] | None) -> list[dict[str, Any]]: + if observation is None: + return [] + points = list(observation.get("stale_points", [])) + pending = observation.get("pending_changes") + if isinstance(pending, dict) and any(pending.get(key, 0) for key in ("added", "modified", "removed")): + if _pending_point() not in points: + points.append(_pending_point()) + return points + + +def _is_unknown(observation: dict[str, Any] | None) -> bool: + return observation is not None and observation.get("status") == "NOT_VERIFIED" + + +def _successful_statuses(*observations: dict[str, Any] | None) -> list[dict[str, Any]]: + return [ + item + for item in observations + if item is not None and isinstance(item.get("pending_changes"), dict) + ] + + +def _pending_counts(*observations: dict[str, Any] | None) -> dict[str, int]: + values = _successful_statuses(*observations) + return { + key: max((item["pending_changes"][key] for item in values), default=0) + for key in ("added", "modified", "removed") + } + + +def _deduplicate(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for item in items: + if item not in result: + result.append(item) + return result + + +def _unsafe_response(classification: dict[str, Any]) -> bool: + error = str(classification.get("error") or "").lower() + return classification.get("classification") == "NOT_VERIFIED" and any( + token in error + for token in ( + "path", + "symlink", + "regular file", + "escapes", + "repository reference", + ) + ) + + +def _delivery( + effective_pre: dict[str, Any], + query_result: dict[str, Any], + classification: dict[str, Any] | None, + post_status: dict[str, Any] | None, + *, + forced_unknown: str | None = None, +) -> dict[str, Any]: + checked_at = ( + (post_status or {}).get("checked_at") + or (classification or {}).get("checked_at") + or query_result.get("checked_at") + or effective_pre.get("checked_at") + or utc_now() + ) + points = _deduplicate([ + *_observation_points(effective_pre), + *((classification or {}).get("stale_points", [])), + *_observation_points(post_status), + ]) + known_stale = any( + point.get("reason") != "STATUS_UNREADABLE" for point in points + ) or effective_pre.get("status") in {"PARTIAL_STALE", "INDEX_STALE"} or ( + post_status is not None + and post_status.get("status") in {"PARTIAL_STALE", "INDEX_STALE"} + ) or (classification or {}).get("classification") in {"PARTIAL_STALE", "INDEX_STALE"} + unknown = ( + forced_unknown is not None + or query_result.get("status") != "SUCCESS" + or _is_unknown(effective_pre) + or post_status is None + or _is_unknown(post_status) + or (classification or {}).get("classification") == "NOT_VERIFIED" + ) + errors = [ + forced_unknown, + effective_pre.get("error"), + query_result.get("error"), + (classification or {}).get("error"), + (post_status or {}).get("error"), + ] + error = next((str(item)[:240] for item in errors if item), None) + if known_stale: + index_points = [point for point in points if point.get("scope") == "INDEX"] + state = "STALE" + record_status = "INDEX_STALE" if index_points else "PARTIAL_STALE" + reason = next( + ( + str(point["reason"]) + for point in points + if point.get("reason") != "STATUS_UNREADABLE" + ), + "INDEX_STALE", + ) + actions = {point.get("fallback") for point in points} + required_fallback = ( + "SEARCH_SOURCE" + if index_points or len(actions) != 1 + else str(next(iter(actions))) + ) + usage = "NAVIGATION_ONLY" + elif unknown: + state = "UNKNOWN" + record_status = "NOT_VERIFIED" + if not any(point.get("reason") == "STATUS_UNREADABLE" for point in points): + points.append(dict(_INDEX_FALLBACK)) + if forced_unknown: + reason = "RESPONSE_INTEGRITY_UNVERIFIED" + elif _is_unknown(effective_pre): + reason = ( + "PROJECT_MISMATCH" + if "different project" in str(effective_pre.get("error", "")).lower() + else "STATUS_UNREADABLE" + ) + elif query_result.get("status") != "SUCCESS": + reason = "EXPLORE_FAILED" + elif (classification or {}).get("classification") == "NOT_VERIFIED": + reason = "RESPONSE_NOT_VERIFIED" + else: + reason = "POST_STATUS_UNREADABLE" + required_fallback = "SEARCH_SOURCE" + usage = "NAVIGATION_ONLY" + else: + state = "CURRENT" + record_status = "CURRENT_AT_CHECK" + reason = "VERIFIED_WINDOW" + required_fallback = "NONE" + usage = "NON_AUTHORITATIVE_CONTEXT" + return { + "state": state, + "record_status": record_status, + "reason": reason, + "checked_at": checked_at, + "usage": usage, + "required_fallback": required_fallback, + "stale_points": points, + "pending_changes": _pending_counts(effective_pre, post_status), + "error": error, + } + + +def _bundle_base( + repo: Path, + context: dict[str, Any], + query_id: str, + purpose: str, + query: str, + descriptor: dict[str, Any], +) -> dict[str, Any]: + project = read_json(repo / ".polaris/project.json") + return { + "bundle_version": 1, + "proxy": { + "server_id": "polaris-codegraph", + "tool": "polaris_codegraph_explore", + }, + "provider": { + "id": descriptor["provider_id"], + "descriptor_version": descriptor["provider_version"], + }, + "repository": { + "project_id": project["project_id"], + "root_sha256": hashlib.sha256(str(repo.resolve()).encode("utf-8")).hexdigest(), + }, + "task_context": context, + "query": { + "id": query_id, + "purpose": purpose, + "text": query, + "status": "UNAVAILABLE", + "response_sha256": None, + "error": None, + }, + "pre_status": None, + "sync": None, + "post_sync_status": None, + "response_classification": None, + "post_query_status": None, + "delivery": None, + "response_path": None, + } + + +def _write_bundle(path: Path, bundle: dict[str, Any]) -> None: + if path.exists() or path.is_symlink(): + raise InputFailure(f"CodeGraph proxy bundle is immutable: {path}") + path.parent.mkdir(parents=True, exist_ok=True) + confined_target(path.parents[3], path, "CodeGraph proxy evidence") + write_json_atomic(path, bundle) + + +def execute_proxy_query( + repo: Path, + task_id: str, + stage: str, + query_id: str, + purpose: str, + query: str, + sync_if_needed: bool, + *, + runner: Any = subprocess.run, +) -> dict[str, Any]: + """Execute one immutable CodeGraph query window and persist its evidence.""" + if not isinstance(purpose, str) or not purpose.strip() or len(purpose) > 240: + raise InputFailure("CodeGraph query purpose must contain 1 to 240 characters") + if not isinstance(query, str) or not query.strip() or len(query) > 8000: + raise InputFailure("CodeGraph query must contain 1 to 8000 characters") + if not isinstance(sync_if_needed, bool): + raise InputFailure("sync_if_needed must be a boolean") + repo = repo.absolute() + if repo.is_symlink() or not repo.is_dir(): + raise RuleFailure("CodeGraph proxy repository root must be a fixed real directory") + repo = repo.resolve() + context = resolve_stage_context(repo, task_id, stage) + bundle_path = proxy_bundle_path(repo, task_id, context, query_id) + response_path = _proxy_path( + repo, task_id, context, query_id, "code_intelligence_proxy_response" + ) + if response_path.exists() or response_path.is_symlink(): + raise InputFailure(f"CodeGraph proxy response is immutable: {response_path}") + root = protocol_root(repo) + descriptor = load_providers(root)["codegraph"] + bundle = _bundle_base(repo, context, query_id, purpose.strip(), query, descriptor) + + config = load_config(repo, root) + marker = _project_marker_path(repo, descriptor["project_marker"]) + unavailable_reason: str | None = None + if config["mode"] == "disabled": + unavailable_reason = "POLICY_DISABLED" + elif not marker.is_dir() or marker.is_symlink(): + unavailable_reason = "MARKER_UNAVAILABLE" + elif shutil.which(descriptor["cli"]["executable"]) is None: + unavailable_reason = "CLI_UNAVAILABLE" + if unavailable_reason is not None: + pre_status = _unavailable_status(unavailable_reason) + bundle["pre_status"] = pre_status + bundle["query"]["error"] = unavailable_reason + bundle["delivery"] = { + "state": "UNAVAILABLE", + "record_status": "UNAVAILABLE", + "reason": unavailable_reason, + "checked_at": pre_status["checked_at"], + "usage": "NO_GRAPH", + "required_fallback": "SEARCH_SOURCE", + "stale_points": [], + "pending_changes": {"added": 0, "modified": 0, "removed": 0}, + "error": unavailable_reason, + } + _write_bundle(bundle_path, bundle) + return { + "bundle": bundle, + "bundle_path": bundle_path, + "response": None, + "envelope": render_freshness_envelope(bundle), + } + + pre_status = inspect_status(repo, descriptor, runner=runner) + bundle["pre_status"] = pre_status + effective_pre = pre_status + if sync_if_needed and pre_status.get("needs_sync"): + synchronized = synchronize_observed_status( + repo, descriptor, pre_status, runner=runner + ) + bundle["sync"] = synchronized["sync"] + effective_pre = synchronized["freshness"] + bundle["post_sync_status"] = effective_pre + + if effective_pre["status"] in {"UNAVAILABLE", "NOT_VERIFIED"}: + bundle["query"]["status"] = ( + "UNAVAILABLE" if effective_pre["status"] == "UNAVAILABLE" else "FAILED" + ) + bundle["query"]["error"] = effective_pre.get("error") + if effective_pre["status"] == "UNAVAILABLE": + bundle["delivery"] = { + "state": "UNAVAILABLE", + "record_status": "UNAVAILABLE", + "reason": "PROVIDER_UNAVAILABLE", + "checked_at": effective_pre["checked_at"], + "usage": "NO_GRAPH", + "required_fallback": "SEARCH_SOURCE", + "stale_points": [], + "pending_changes": {"added": 0, "modified": 0, "removed": 0}, + "error": effective_pre.get("error"), + } + else: + bundle["delivery"] = _delivery( + effective_pre, + bundle["query"], + None, + None, + ) + _write_bundle(bundle_path, bundle) + return { + "bundle": bundle, + "bundle_path": bundle_path, + "response": None, + "envelope": render_freshness_envelope(bundle), + } + + query_result = run_explore(repo, descriptor, query, runner=runner) + bundle["query"].update({ + "status": query_result["status"], + "response_sha256": query_result["response_sha256"], + "error": query_result["error"], + }) + response: str | None = query_result.get("response") + classification: dict[str, Any] | None = None + post_status: dict[str, Any] | None = None + forced_unknown: str | None = None + if query_result["status"] == "SUCCESS" and response is not None: + actual_digest = hashlib.sha256(response.encode("utf-8")).hexdigest() + if actual_digest != query_result["response_sha256"]: + forced_unknown = "CodeGraph response digest mismatch" + response = None + else: + classification = classify_response(repo, response) + bundle["response_classification"] = classification + if classification.get("response_sha256") != actual_digest: + forced_unknown = "CodeGraph classification digest mismatch" + response = None + elif _unsafe_response(classification): + forced_unknown = "CodeGraph response contains an unsafe repository path" + response = None + else: + response_path.parent.mkdir(parents=True, exist_ok=True) + confined_target(task_dir(repo, task_id), response_path, "CodeGraph response") + write_text_atomic(response_path, response) + if file_sha256(response_path) != actual_digest: + forced_unknown = "persisted CodeGraph response digest mismatch" + response_path.unlink() + response = None + else: + bundle["response_path"] = response_path.relative_to( + task_dir(repo, task_id) + ).as_posix() + post_status = inspect_status(repo, descriptor, runner=runner) + bundle["post_query_status"] = post_status + + bundle["delivery"] = _delivery( + effective_pre, + bundle["query"], + classification, + post_status, + forced_unknown=forced_unknown, + ) + if response is None: + bundle["response_path"] = None + _write_bundle(bundle_path, bundle) + return { + "bundle": bundle, + "bundle_path": bundle_path, + "response": response, + "envelope": render_freshness_envelope(bundle), + } + + +def render_freshness_envelope(bundle: dict[str, Any]) -> str: + """Render the finite freshness block that must precede graph content.""" + delivery = bundle["delivery"] + pending = delivery.get("pending_changes") or {} + error = " ".join(str(delivery.get("error") or "").split())[:240] + bundle_path = task_relative_path( + "code_intelligence_proxy_bundle", + record_name=bundle["task_context"]["record_name"], + query_id=bundle["query"]["id"], + ).as_posix() + lines = [ + "[POLARIS_CODEGRAPH_FRESHNESS]", + f"state: {delivery['state']}", + f"record_status: {delivery['record_status']}", + f"reason: {delivery['reason']}", + f"checked_at: {delivery['checked_at']}", + f"pending_added: {pending.get('added', 0)}", + f"pending_modified: {pending.get('modified', 0)}", + f"pending_removed: {pending.get('removed', 0)}", + f"usage: {delivery['usage']}", + f"required_fallback: {delivery['required_fallback']}", + f"evidence_bundle: {bundle_path}", + ] + if error: + lines.append(f"error: {error}") + lines.append("[/POLARIS_CODEGRAPH_FRESHNESS]") + return "\n".join(lines) + "\n" diff --git a/scripts/internal/task_layout.py b/scripts/internal/task_layout.py index 10e55be..d125423 100644 --- a/scripts/internal/task_layout.py +++ b/scripts/internal/task_layout.py @@ -13,6 +13,12 @@ "working_set": "working-set.json", "progress": "runtime/progress.json", "code_intelligence_runtime": "runtime/code-intelligence", + "code_intelligence_proxy_bundle": ( + "runtime/code-intelligence/{record_name}/{query_id}.json" + ), + "code_intelligence_proxy_response": ( + "runtime/code-intelligence/{record_name}/{query_id}.response.txt" + ), "code_intelligence_revision": "code-intelligence/r{revision:03d}", "code_intelligence_record": "code-intelligence/r{revision:03d}/{record_name}.json", "work_item": "revisions/work-item-r{revision:03d}.json", @@ -48,6 +54,7 @@ def task_relative_path( reviewer: int = 1, exploration_id: str = "EXP-0001", record_name: str = "planning", + query_id: str = "CIQ-001", ) -> Path: pattern = TASK_PATH_PATTERNS[artifact] reviewer_suffix = "" if reviewer == 1 else f"-{reviewer}" @@ -58,6 +65,7 @@ def task_relative_path( reviewer_suffix=reviewer_suffix, exploration_id=exploration_id, record_name=record_name, + query_id=query_id, ) ) diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index e5b01da..4784c46 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -21,6 +21,7 @@ from init_task import initialize as init_task # noqa: E402 from migrate_project import migrate as migrate_project # noqa: E402 from new_revision import create as new_revision # noqa: E402 +from transition_task import transition # noqa: E402 from internal.code_intelligence_protocol import ( # noqa: E402 _project_marker_path, load_providers, @@ -282,6 +283,462 @@ def add_explore_response_evidence(value: dict[str, object]) -> None: def initialize_task(self) -> None: init_task(self.repo, "TASK-0001", "R1") + def qualify_task(self) -> None: + self.initialize_task() + path = self.repo / ".polaris/tasks/TASK-0001/revisions/work-item-r001.json" + value = json.loads(path.read_text(encoding="utf-8")) + value.update({ + "title": "CodeGraph proxy task", + "goal": "Exercise the bounded proxy", + "motivation": "Keep graph evidence freshness-aware", + }) + value["scope"]["in"] = ["scripts"] + value["acceptance"][0].update({ + "statement": "The proxy emits a freshness envelope", + "evidence": "proxy bundle", + }) + value["implementation_dispatch"]["authorized"] = True + value["review_dispatch"]["authorized"] = True + write_json_atomic(path, value) + transition( + self.repo, + "TASK-0001", + "QUALIFY", + [], + None, + None, + None, + None, + None, + None, + ) + + def proxy_module(self) -> object: + return importlib.import_module("internal.code_intelligence_proxy") + + def test_proxy_stage_context_uses_record_name_and_sequential_query_ids(self) -> None: + self.qualify_task() + proxy = self.proxy_module() + + context = proxy.resolve_stage_context(self.repo, "TASK-0001", "PLANNING") + + self.assertEqual(context["work_item_revision"], 1) + self.assertEqual(context["artifact_attempt"], None) + self.assertEqual(context["reviewer_slot"], None) + self.assertEqual(context["record_name"], "planning") + expected = ( + self.repo + / ".polaris/tasks/TASK-0001/runtime/code-intelligence/planning/CIQ-001.json" + ) + self.assertEqual( + proxy.proxy_bundle_path(self.repo, "TASK-0001", context, "CIQ-001"), + expected, + ) + with self.assertRaisesRegex(InputFailure, "next sequential"): + proxy.proxy_bundle_path(self.repo, "TASK-0001", context, "CIQ-002") + with self.assertRaisesRegex(InputFailure, "invalid CodeGraph query id"): + proxy.proxy_bundle_path(self.repo, "TASK-0001", context, "CIQ-000") + + def test_proxy_stage_context_binds_attempt_subject_and_review_slot(self) -> None: + self.qualify_task() + proxy = self.proxy_module() + task = self.repo / ".polaris/tasks/TASK-0001" + state_path = task / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + base = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=self.repo, + capture_output=True, + check=True, + text=True, + encoding="utf-8", + ).stdout.strip() + implementation_handoff = { + "artifact_attempt": 2, + "subject_base_commit": base, + } + state["status"] = "IMPLEMENTING" + write_json_atomic(state_path, state) + with mock.patch( + "internal.code_intelligence_proxy.validate_implementation_handoff", + return_value=(implementation_handoff, {"path": "handoff.json", "sha256": "0" * 64}), + ): + implementation = proxy.resolve_stage_context( + self.repo, "TASK-0001", "IMPLEMENTATION" + ) + documentation = proxy.resolve_stage_context( + self.repo, "TASK-0001", "DOCUMENTATION_SYNC" + ) + self.assertEqual(implementation["record_name"], "implementation-002") + self.assertEqual(documentation["record_name"], "documentation-sync-002") + self.assertEqual(implementation["target"], { + "base_commit": base, + "head_commit": None, + "diff_hash": None, + }) + + state["status"] = "REVIEWING" + write_json_atomic(state_path, state) + review_handoff = { + "artifact_attempt": 2, + "subject_base_commit": base, + "subject_head_commit": base, + "subject_diff_hash": subject_diff_hash(self.repo, base, base), + } + with mock.patch( + "internal.code_intelligence_proxy.validate_review_handoff", + return_value=review_handoff, + ): + review = proxy.resolve_stage_context(self.repo, "TASK-0001", "REVIEW") + self.assertEqual(review["record_name"], "review-002-slot-1") + state["artifacts"]["review"] = {"path": "review.json", "sha256": "0" * 64} + write_json_atomic(state_path, state) + with mock.patch( + "internal.code_intelligence_proxy.validate_review_handoff", + return_value=review_handoff, + ): + review_2 = proxy.resolve_stage_context(self.repo, "TASK-0001", "REVIEW") + self.assertEqual(review_2["record_name"], "review-002-slot-2") + + with self.assertRaisesRegex(RuleFailure, "inconsistent with task status"): + proxy.resolve_stage_context(self.repo, "TASK-0001", "PLANNING") + + def test_proxy_window_requires_clean_pre_and_post_status_for_current(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + proxy = self.proxy_module() + calls: list[tuple[list[str], dict[str, object]]] = [] + responses = [ + completed(healthy_status(self.repo)), + completed("graph bytes\n"), + completed(healthy_status(self.repo)), + ] + + def runner(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append((command, kwargs)) + if not responses: + raise AssertionError(f"unexpected extra CodeGraph call: {command}") + return responses.pop(0) + + with mock.patch("internal.code_intelligence_proxy.shutil.which", return_value="/bin/codegraph"): + result = proxy.execute_proxy_query( + self.repo, + "TASK-0001", + "PLANNING", + "CIQ-001", + "locate affected symbols", + "symbol A", + False, + runner=runner, + ) + + bundle = result["bundle"] + self.assertEqual(bundle["delivery"]["state"], "CURRENT") + self.assertEqual(bundle["delivery"]["usage"], "NON_AUTHORITATIVE_CONTEXT") + self.assertEqual(bundle["delivery"]["record_status"], "CURRENT_AT_CHECK") + self.assertEqual([call[0][1] for call in calls], ["status", "explore", "status"]) + self.assertTrue(all(Path(call[1]["cwd"]).resolve() == self.repo.resolve() for call in calls)) + self.assertEqual(result["response"], "graph bytes\n") + self.assertTrue(result["envelope"].startswith("[POLARIS_CODEGRAPH_FRESHNESS]\n")) + self.assertEqual( + hashlib.sha256( + (self.repo / ".polaris/tasks/TASK-0001/runtime/code-intelligence/planning/CIQ-001.response.txt").read_bytes() + ).hexdigest(), + bundle["query"]["response_sha256"], + ) + + def test_proxy_window_downgrades_pending_unknown_and_unavailable_states(self) -> None: + cases = [ + ("pending", "STALE", 3), + ("malformed", "UNKNOWN", 1), + ("missing_marker", "UNAVAILABLE", 0), + ] + for index, (case, expected_state, expected_calls) in enumerate(cases, start=1): + with self.subTest(case=case): + if index > 1: + self.tearDown() + self.setUp() + self.qualify_task() + proxy = self.proxy_module() + query_id = "CIQ-001" + calls: list[list[str]] = [] + status = json.loads(healthy_status(self.repo)) + if case == "pending": + status["pendingChanges"]["modified"] = 1 + responses = [ + completed(json.dumps(status)), + completed("graph bytes\n"), + completed(json.dumps(status)), + ] + else: + responses = [completed("not-json\n")] + if case != "missing_marker": + (self.repo / ".codegraph").mkdir() + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append(command) + return responses.pop(0) + + with mock.patch("internal.code_intelligence_proxy.shutil.which", return_value="/bin/codegraph"): + result = proxy.execute_proxy_query( + self.repo, + "TASK-0001", + "PLANNING", + query_id, + "inspect freshness", + "symbol A", + False, + runner=runner, + ) + self.assertEqual(result["bundle"]["delivery"]["state"], expected_state) + self.assertEqual(len(calls), expected_calls) + if expected_state == "UNAVAILABLE": + self.assertIsNone(result["response"]) + + def test_proxy_window_syncs_once_and_rechecks_after_the_query(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + proxy = self.proxy_module() + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["modified"] = 1 + responses = [ + completed(json.dumps(pending)), + completed("synced\n"), + completed(healthy_status(self.repo)), + completed("graph bytes\n"), + completed(healthy_status(self.repo)), + ] + calls: list[list[str]] = [] + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append(command) + return responses.pop(0) + + with mock.patch("internal.code_intelligence_proxy.shutil.which", return_value="/bin/codegraph"): + result = proxy.execute_proxy_query( + self.repo, + "TASK-0001", + "PLANNING", + "CIQ-001", + "refresh one query window", + "symbol A", + True, + runner=runner, + ) + + self.assertEqual( + [command[1] for command in calls], + ["status", "sync", "status", "explore", "status"], + ) + self.assertEqual(result["bundle"]["sync"]["status"], "SUCCESS") + self.assertEqual(result["bundle"]["delivery"]["state"], "CURRENT") + self.assertEqual( + result["bundle"]["delivery"]["pending_changes"], + {"added": 0, "modified": 0, "removed": 0}, + ) + + def test_proxy_window_never_promotes_failed_or_post_stale_queries(self) -> None: + cases = [ + ("explore_failed", "UNKNOWN", ["status", "explore"]), + ("post_pending", "STALE", ["status", "explore", "status"]), + ] + for index, (case, expected_state, expected_calls) in enumerate(cases, start=1): + with self.subTest(case=case): + if index > 1: + self.tearDown() + self.setUp() + self.qualify_task() + (self.repo / ".codegraph").mkdir() + proxy = self.proxy_module() + post = json.loads(healthy_status(self.repo)) + post["pendingChanges"]["added"] = 1 + responses = ( + [completed(healthy_status(self.repo)), completed("", 1, "failed")] + if case == "explore_failed" + else [ + completed(healthy_status(self.repo)), + completed("graph bytes\n"), + completed(json.dumps(post)), + ] + ) + calls: list[list[str]] = [] + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append(command) + return responses.pop(0) + + with mock.patch("internal.code_intelligence_proxy.shutil.which", return_value="/bin/codegraph"): + result = proxy.execute_proxy_query( + self.repo, + "TASK-0001", + "PLANNING", + "CIQ-001", + "verify failure handling", + "symbol A", + False, + runner=runner, + ) + self.assertEqual(result["bundle"]["delivery"]["state"], expected_state) + self.assertEqual([command[1] for command in calls], expected_calls) + if case == "explore_failed": + self.assertIsNone(result["response"]) + else: + self.assertEqual( + result["bundle"]["delivery"]["reason"], "PENDING_CHANGES" + ) + + def test_proxy_window_classifies_banners_and_discards_unsafe_paths(self) -> None: + partial = """⚠️ Some files referenced below were edited since the last index sync — +their codegraph entries may be stale: + - src/widget.py (edited 800ms ago, pending sync) +For accurate content of those specific files, Read them directly. +""" + cases = [ + ("partial", partial, "STALE", True), + ("suspicious", "warning: graph may be stale\n", "UNKNOWN", True), + ("unsafe", partial.replace("src/widget.py", "../escape.py"), "UNKNOWN", False), + ] + for index, (case, response, expected_state, retained) in enumerate(cases, start=1): + with self.subTest(case=case): + if index > 1: + self.tearDown() + self.setUp() + self.qualify_task() + (self.repo / ".codegraph").mkdir() + source = self.repo / "src/widget.py" + source.parent.mkdir() + source.write_text("value = 1\n", encoding="utf-8") + proxy = self.proxy_module() + responses = [ + completed(healthy_status(self.repo)), + completed(response), + completed(healthy_status(self.repo)), + ] + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + return responses.pop(0) + + with mock.patch("internal.code_intelligence_proxy.shutil.which", return_value="/bin/codegraph"): + result = proxy.execute_proxy_query( + self.repo, + "TASK-0001", + "PLANNING", + "CIQ-001", + "classify response", + "symbol A", + False, + runner=runner, + ) + self.assertEqual(result["bundle"]["delivery"]["state"], expected_state) + self.assertEqual(result["response"] is not None, retained) + self.assertEqual(result["bundle"]["response_path"] is not None, retained) + + def test_proxy_window_rejects_cross_project_and_runtime_symlink_evidence(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + proxy = self.proxy_module() + other = self.repo / "other" + other.mkdir() + wrong = json.loads(healthy_status(self.repo)) + wrong["projectPath"] = str(other) + calls: list[list[str]] = [] + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append(command) + return completed(json.dumps(wrong)) + + with mock.patch("internal.code_intelligence_proxy.shutil.which", return_value="/bin/codegraph"): + result = proxy.execute_proxy_query( + self.repo, + "TASK-0001", + "PLANNING", + "CIQ-001", + "reject cross-project status", + "symbol A", + False, + runner=runner, + ) + self.assertEqual([command[1] for command in calls], ["status"]) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual(result["bundle"]["delivery"]["reason"], "PROJECT_MISMATCH") + + self.tearDown() + self.setUp() + self.qualify_task() + runtime = self.repo / ".polaris/tasks/TASK-0001/runtime" + shutil.rmtree(runtime) + runtime.symlink_to(self.repo / "escaped-runtime", target_is_directory=True) + context = proxy.resolve_stage_context(self.repo, "TASK-0001", "PLANNING") + with self.assertRaisesRegex(RuleFailure, "crosses a symlink"): + proxy.proxy_bundle_path(self.repo, "TASK-0001", context, "CIQ-001") + + def test_proxy_window_disabled_policy_or_missing_cli_never_calls_provider(self) -> None: + for index, case in enumerate(("disabled", "missing_cli"), start=1): + with self.subTest(case=case): + if index > 1: + self.tearDown() + self.setUp() + self.qualify_task() + (self.repo / ".codegraph").mkdir() + if case == "disabled": + config_path = self.repo / ".polaris/code-intelligence.json" + config = json.loads( + (ROOT / "templates/code-intelligence.json").read_text( + encoding="utf-8" + ) + ) + config["mode"] = "disabled" + write_json_atomic(config_path, config) + proxy = self.proxy_module() + + def runner(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]: + raise AssertionError("disabled or unavailable CodeGraph must not run") + + executable = "/bin/codegraph" if case == "disabled" else None + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value=executable, + ): + result = proxy.execute_proxy_query( + self.repo, + "TASK-0001", + "PLANNING", + "CIQ-001", + "verify activation gate", + "symbol A", + True, + runner=runner, + ) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNAVAILABLE") + self.assertEqual(result["bundle"]["query"]["status"], "UNAVAILABLE") + self.assertIsNone(result["response"]) + + def test_proxy_envelope_is_finite_and_truncates_diagnostics(self) -> None: + self.qualify_task() + proxy = self.proxy_module() + context = proxy.resolve_stage_context(self.repo, "TASK-0001", "PLANNING") + bundle = { + "task_context": context, + "query": {"id": "CIQ-001"}, + "delivery": { + "state": "UNKNOWN", + "record_status": "NOT_VERIFIED", + "reason": "STATUS_UNREADABLE", + "checked_at": "2026-08-19T00:00:00Z", + "pending_changes": {"added": 0, "modified": 0, "removed": 0}, + "usage": "NAVIGATION_ONLY", + "required_fallback": "SEARCH_SOURCE", + "error": "x" * 300, + }, + } + + envelope = proxy.render_freshness_envelope(bundle) + + self.assertTrue(envelope.startswith("[POLARIS_CODEGRAPH_FRESHNESS]\n")) + self.assertTrue(envelope.endswith("[/POLARIS_CODEGRAPH_FRESHNESS]\n")) + error_line = next(line for line in envelope.splitlines() if line.startswith("error: ")) + self.assertEqual(len(error_line.removeprefix("error: ")), 240) + def set_protocol_version(self, version: str) -> None: project_path = self.repo / ".polaris/project.json" project = json.loads(project_path.read_text(encoding="utf-8")) From 8c8a64459849d559052bb4cb1196721794e168cd Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 02:52:44 +0800 Subject: [PATCH 05/28] feat: expose the Polaris CodeGraph MCP proxy --- scripts/code_intelligence_mcp.py | 268 +++++++++++++++++++++++++++++++ tests/test_codegraph.py | 164 +++++++++++++++++++ 2 files changed, 432 insertions(+) create mode 100644 scripts/code_intelligence_mcp.py diff --git a/scripts/code_intelligence_mcp.py b/scripts/code_intelligence_mcp.py new file mode 100644 index 0000000..ab01a11 --- /dev/null +++ b/scripts/code_intelligence_mcp.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Project-scoped stdio MCP server for bounded Polaris CodeGraph queries.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + +from internal.code_intelligence_proxy import execute_proxy_query +from internal.path_security import require_regular_file +from internal.polaris_core import ( + InputFailure, + RuleFailure, + protocol_root, +) + + +PROTOCOL_VERSION = "2025-11-25" +TOOL_NAME = "polaris_codegraph_explore" +TOOL = { + "name": TOOL_NAME, + "description": "Run one bounded Polaris CodeGraph freshness window.", + "inputSchema": { + "type": "object", + "required": [ + "task_id", + "stage", + "query_id", + "purpose", + "query", + "sync_if_needed", + ], + "additionalProperties": False, + "properties": { + "task_id": {"type": "string", "pattern": r"^TASK-[0-9]{4}$"}, + "stage": { + "type": "string", + "enum": [ + "PLANNING", + "IMPLEMENTATION", + "DOCUMENTATION_SYNC", + "REVIEW", + ], + }, + "query_id": {"type": "string", "pattern": r"^CIQ-[0-9]{3}$"}, + "purpose": {"type": "string", "minLength": 1, "maxLength": 240}, + "query": {"type": "string", "minLength": 1, "maxLength": 8000}, + "sync_if_needed": {"type": "boolean"}, + }, + }, +} + + +def _error(request_id: Any, code: int, message: str) -> dict[str, Any]: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": code, "message": " ".join(message.split())[:240]}, + } + + +def _result(request_id: Any, value: dict[str, Any]) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": request_id, "result": value} + + +def _tool_error(message: str) -> dict[str, Any]: + return { + "content": [ + {"type": "text", "text": " ".join(message.split())[:240]} + ], + "isError": True, + } + + +def _valid_request_id(value: Any) -> bool: + return value is None or ( + not isinstance(value, bool) and isinstance(value, (int, str)) + ) + + +def _validate_arguments(value: Any) -> list[str]: + if not isinstance(value, dict): + return ["tool arguments must be an object"] + expected = { + "task_id", + "stage", + "query_id", + "purpose", + "query", + "sync_if_needed", + } + errors: list[str] = [] + missing = expected - set(value) + extra = set(value) - expected + if missing: + errors.append("missing arguments: " + ", ".join(sorted(missing))) + if extra: + errors.append("unknown arguments: " + ", ".join(sorted(extra))) + task_id = value.get("task_id") + if not isinstance(task_id, str) or re.fullmatch(r"TASK-[0-9]{4}", task_id) is None: + errors.append("task_id must match TASK-0000") + if value.get("stage") not in { + "PLANNING", + "IMPLEMENTATION", + "DOCUMENTATION_SYNC", + "REVIEW", + }: + errors.append("stage is invalid") + query_id = value.get("query_id") + if not isinstance(query_id, str) or re.fullmatch(r"CIQ-[0-9]{3}", query_id) is None: + errors.append("query_id must match CIQ-000") + for key, maximum in (("purpose", 240), ("query", 8000)): + item = value.get(key) + if not isinstance(item, str) or not item.strip() or len(item) > maximum: + errors.append(f"{key} must contain 1 to {maximum} characters") + if not isinstance(value.get("sync_if_needed"), bool): + errors.append("sync_if_needed must be a boolean") + return errors + + +class McpServer: + """Small stateful MCP dispatcher with no dependencies beyond the stdlib.""" + + def __init__(self, repo: Path) -> None: + raw_repo = repo.absolute() + if raw_repo.is_symlink() or not raw_repo.is_dir(): + raise InputFailure("MCP repository root must be a fixed real directory") + self.repo = raw_repo.resolve() + require_regular_file( + self.repo / ".polaris/project.json", "Polaris project configuration" + ) + self.initialized = False + self.ready = False + + def _initialize(self, request_id: Any, params: dict[str, Any]) -> dict[str, Any]: + if self.initialized: + return _error(request_id, -32600, "MCP server is already initialized") + if ( + params.get("protocolVersion") != PROTOCOL_VERSION + or not isinstance(params.get("capabilities"), dict) + or not isinstance(params.get("clientInfo"), dict) + ): + return _error(request_id, -32602, "unsupported or incomplete initialize params") + self.initialized = True + version = (protocol_root(self.repo) / "VERSION").read_text( + encoding="utf-8" + ).strip() + return _result( + request_id, + { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "polaris-codegraph", "version": version}, + }, + ) + + def _call_tool(self, request_id: Any, params: dict[str, Any]) -> dict[str, Any]: + if params.get("name") != TOOL_NAME: + return _error(request_id, -32602, "unknown MCP tool name") + arguments = params.get("arguments") + errors = _validate_arguments(arguments) + if errors: + return _result(request_id, _tool_error("; ".join(errors))) + assert isinstance(arguments, dict) + try: + proxy = execute_proxy_query( + self.repo, + arguments["task_id"], + arguments["stage"], + arguments["query_id"], + arguments["purpose"], + arguments["query"], + arguments["sync_if_needed"], + ) + content = [{"type": "text", "text": proxy["envelope"]}] + if proxy["response"] is not None: + content.append({"type": "text", "text": proxy["response"]}) + return _result( + request_id, + { + "content": content, + "structuredContent": {"bundle": proxy["bundle"]}, + "isError": False, + }, + ) + except (InputFailure, RuleFailure, OSError, ValueError) as error: + return _result(request_id, _tool_error(str(error))) + except Exception as error: # keep the long-lived stdio server usable + return _result( + request_id, + _tool_error(f"CodeGraph proxy execution failed: {type(error).__name__}"), + ) + + def handle(self, message: Any) -> dict[str, Any] | None: + """Handle one decoded JSON-RPC message; notifications return ``None``.""" + if not isinstance(message, dict) or message.get("jsonrpc") != "2.0": + return _error(None, -32600, "invalid JSON-RPC request") + method = message.get("method") + if not isinstance(method, str): + return _error(message.get("id"), -32600, "request method must be a string") + has_id = "id" in message + request_id = message.get("id") + if has_id and not _valid_request_id(request_id): + return _error(None, -32600, "invalid JSON-RPC request id") + params = message.get("params", {}) + if not isinstance(params, dict): + return None if not has_id else _error(request_id, -32602, "params must be an object") + + if method == "initialize": + if not has_id: + return None + return self._initialize(request_id, params) + if method == "notifications/initialized": + if not has_id and self.initialized: + self.ready = True + return None + known_methods = {"ping", "tools/list", "tools/call"} + if method not in known_methods: + return None if not has_id else _error(request_id, -32601, "method not found") + if not self.ready: + return None if not has_id else _error(request_id, -32600, "MCP server is not initialized") + if not has_id: + return None + if method == "ping": + return _result(request_id, {}) + if method == "tools/list": + return _result(request_id, {"tools": [TOOL]}) + return self._call_tool(request_id, params) + + +def _write_message(value: dict[str, Any]) -> None: + sys.stdout.write( + json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n" + ) + sys.stdout.flush() + + +def serve(repo: Path) -> int: + server = McpServer(repo) + for line in sys.stdin: + try: + message = json.loads(line) + except (json.JSONDecodeError, UnicodeError): + _write_message(_error(None, -32700, "parse error")) + continue + response = server.handle(message) + if response is not None: + _write_message(response) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo", type=Path, required=True) + args = parser.parse_args() + try: + return serve(args.repo) + except (InputFailure, RuleFailure, OSError) as error: + print(" ".join(str(error).split())[:240], file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 4784c46..f74632b 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -739,6 +739,170 @@ def test_proxy_envelope_is_finite_and_truncates_diagnostics(self) -> None: error_line = next(line for line in envelope.splitlines() if line.startswith("error: ")) self.assertEqual(len(error_line.removeprefix("error: ")), 240) + def test_mcp_server_initializes_and_lists_one_proxy_tool(self) -> None: + messages = [ + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "1"}, + }, + }, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, + ] + completed_process = subprocess.run( + [ + sys.executable, + SCRIPTS / "code_intelligence_mcp.py", + "--repo", + self.repo, + ], + input="".join(json.dumps(item) + "\n" for item in messages), + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual(completed_process.returncode, 0, completed_process.stderr) + responses = [json.loads(line) for line in completed_process.stdout.splitlines()] + self.assertEqual(len(responses), 2) + self.assertEqual(responses[0]["result"]["protocolVersion"], "2025-11-25") + self.assertEqual( + responses[0]["result"]["capabilities"], + {"tools": {"listChanged": False}}, + ) + tools = responses[1]["result"]["tools"] + self.assertEqual([item["name"] for item in tools], ["polaris_codegraph_explore"]) + self.assertNotIn("repository", tools[0]["inputSchema"]["properties"]) + self.assertEqual(completed_process.stderr, "") + + def test_mcp_server_returns_envelope_before_graph_and_preserves_bundle(self) -> None: + module = importlib.import_module("code_intelligence_mcp") + server = module.McpServer(self.repo) + initialized = server.handle({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "1"}, + }, + }) + self.assertIn("result", initialized) + self.assertIsNone(server.handle({ + "jsonrpc": "2.0", "method": "notifications/initialized" + })) + bundle = { + "delivery": {"state": "STALE"}, + "query": {"id": "CIQ-001"}, + } + proxy_result = { + "bundle": bundle, + "bundle_path": self.repo / "bundle.json", + "response": "graph bytes\n", + "envelope": "[POLARIS_CODEGRAPH_FRESHNESS]\nstate: STALE\n[/POLARIS_CODEGRAPH_FRESHNESS]\n", + } + request = { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "polaris_codegraph_explore", + "arguments": { + "task_id": "TASK-0001", + "stage": "PLANNING", + "query_id": "CIQ-001", + "purpose": "locate symbols", + "query": "symbol A", + "sync_if_needed": False, + }, + }, + } + with mock.patch( + "code_intelligence_mcp.execute_proxy_query", return_value=proxy_result + ): + response = server.handle(request) + + result = response["result"] + self.assertFalse(result["isError"]) + self.assertEqual(result["content"][0]["text"], proxy_result["envelope"]) + self.assertEqual(result["content"][1]["text"], "graph bytes\n") + self.assertEqual(result["structuredContent"], {"bundle": bundle}) + + proxy_result["response"] = None + with mock.patch( + "code_intelligence_mcp.execute_proxy_query", return_value=proxy_result + ): + no_graph = server.handle({**request, "id": 3}) + self.assertFalse(no_graph["result"]["isError"]) + self.assertEqual(len(no_graph["result"]["content"]), 1) + + def test_mcp_server_rejects_lifecycle_tool_and_input_errors(self) -> None: + module = importlib.import_module("code_intelligence_mcp") + server = module.McpServer(self.repo) + before = server.handle({ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} + }) + self.assertEqual(before["error"]["code"], -32600) + server.handle({ + "jsonrpc": "2.0", + "id": 2, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "1"}, + }, + }) + server.handle({"jsonrpc": "2.0", "method": "notifications/initialized"}) + unknown = server.handle({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "codegraph_explore", "arguments": {}}, + }) + self.assertEqual(unknown["error"]["code"], -32602) + invalid = server.handle({ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "polaris_codegraph_explore", + "arguments": { + "task_id": "TASK-0001", + "stage": "INVALID", + "query_id": "CIQ-000", + "purpose": "locate symbols", + "query": "symbol A", + "sync_if_needed": False, + }, + }, + }) + self.assertTrue(invalid["result"]["isError"]) + self.assertNotIn("structuredContent", invalid["result"]) + + def test_mcp_server_emits_jsonrpc_parse_and_method_errors_one_per_line(self) -> None: + transcript = "{bad json\n" + "\n".join([ + json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}), + json.dumps({"jsonrpc": "2.0", "id": 2, "method": "unknown", "params": {}}), + ]) + "\n" + completed_process = subprocess.run( + [sys.executable, SCRIPTS / "code_intelligence_mcp.py", "--repo", self.repo], + input=transcript, + text=True, + capture_output=True, + check=False, + ) + + responses = [json.loads(line) for line in completed_process.stdout.splitlines()] + self.assertEqual([item["error"]["code"] for item in responses], [-32700, -32602, -32601]) + self.assertTrue(all("\n" not in line for line in completed_process.stdout.splitlines())) + def set_protocol_version(self, version: str) -> None: project_path = self.repo / ".polaris/project.json" project = json.loads(project_path.read_text(encoding="utf-8")) From ce3d175c4e1f1eeb931dd44ccdb3df254b0dba24 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 03:11:05 +0800 Subject: [PATCH 06/28] feat: record auditable CodeGraph proxy evidence --- ...ntelligence-record-annotations.schema.json | 56 ++ .../code-intelligence-record-v2.schema.json | 496 ++++++++++++++ schemas/code-intelligence-record.schema.json | 518 +++----------- .../internal/code_intelligence_protocol.py | 631 +++++++++++++++++- scripts/internal/code_intelligence_proxy.py | 4 +- scripts/record_code_intelligence.py | 25 +- .../code-intelligence-record.json | 76 ++- .../task/code-intelligence/r001/planning.json | 76 ++- .../fixtures/code-intelligence-record-v2.json | 29 + tests/test_codegraph.py | 294 +++++++- tests/test_core.py | 97 ++- 11 files changed, 1806 insertions(+), 496 deletions(-) create mode 100644 schemas/code-intelligence-record-annotations.schema.json create mode 100644 schemas/code-intelligence-record-v2.schema.json create mode 100644 tests/fixtures/code-intelligence-record-v2.json diff --git a/schemas/code-intelligence-record-annotations.schema.json b/schemas/code-intelligence-record-annotations.schema.json new file mode 100644 index 0000000..6a77c92 --- /dev/null +++ b/schemas/code-intelligence-record-annotations.schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Polaris Code Intelligence v3 record annotations", + "type": "object", + "required": ["summary", "symbols", "source_fallbacks"], + "additionalProperties": false, + "properties": { + "summary": {"type": "string"}, + "symbols": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "line", "name"], + "additionalProperties": false, + "properties": { + "path": {"type": "string", "minLength": 1}, + "line": {"type": ["integer", "null"], "minimum": 1}, + "name": {"type": "string", "minLength": 1} + } + } + }, + "source_fallbacks": { + "type": "array", + "items": { + "type": "object", + "required": [ + "action", "path", "observed_sha256", "base_commit", + "head_commit", "diff_hash", "purpose", "result_paths" + ], + "additionalProperties": false, + "properties": { + "action": {"type": "string", "enum": ["READ_SOURCE", "INSPECT_GIT_DIFF", "SEARCH_SOURCE"]}, + "path": {"type": ["string", "null"]}, + "observed_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "base_commit": {"type": ["string", "null"], "pattern": "^[0-9a-f]{40}$"}, + "head_commit": {"type": ["string", "null"], "pattern": "^[0-9a-f]{40}$"}, + "diff_hash": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "purpose": {"type": "string", "minLength": 1}, + "result_paths": { + "type": "array", + "maxItems": 100, + "items": { + "type": "object", + "required": ["path", "observed_sha256"], + "additionalProperties": false, + "properties": { + "path": {"type": "string", "minLength": 1}, + "observed_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + } + } + } + } + } + } + } +} diff --git a/schemas/code-intelligence-record-v2.schema.json b/schemas/code-intelligence-record-v2.schema.json new file mode 100644 index 0000000..14ca0f6 --- /dev/null +++ b/schemas/code-intelligence-record-v2.schema.json @@ -0,0 +1,496 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Polaris Code Intelligence record v2", + "type": "object", + "required": [ + "record_version", + "task_id", + "work_item_revision", + "stage", + "artifact_attempt", + "reviewer_slot", + "provider", + "target", + "status", + "queries", + "status_check", + "sync", + "freshness", + "source_fallbacks", + "recorded_at" + ], + "additionalProperties": false, + "properties": { + "record_version": { + "const": 2 + }, + "task_id": { + "type": "string", + "pattern": "^TASK-[0-9]{4}$" + }, + "work_item_revision": { + "type": "integer", + "minimum": 1 + }, + "stage": { + "type": "string", + "enum": [ + "PLANNING", + "IMPLEMENTATION", + "DOCUMENTATION_SYNC", + "REVIEW" + ] + }, + "artifact_attempt": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "reviewer_slot": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "provider": { + "type": [ + "object", + "null" + ], + "required": [ + "id", + "descriptor_version", + "transport", + "available_operations" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "descriptor_version": { + "const": 2 + }, + "transport": { + "const": "mcp" + }, + "available_operations": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "explore", + "status", + "sync" + ] + } + } + } + }, + "target": { + "type": "object", + "required": [ + "base_commit", + "head_commit", + "diff_hash" + ], + "additionalProperties": false, + "properties": { + "base_commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "head_commit": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{40}$" + }, + "diff_hash": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + } + } + }, + "status": { + "type": "string", + "enum": [ + "USED", + "UNAVAILABLE", + "FAILED", + "SKIPPED" + ] + }, + "queries": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "operation", + "purpose", + "status", + "summary", + "symbols", + "response_sha256", + "error" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^CIQ-[0-9]{3}$" + }, + "operation": { + "const": "explore" + }, + "purpose": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "enum": [ + "SUCCESS", + "EMPTY", + "FAILED", + "UNAVAILABLE" + ] + }, + "summary": { + "type": "string" + }, + "symbols": { + "type": "array", + "items": { + "type": "object", + "required": [ + "path", + "line", + "name" + ], + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "line": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "name": { + "type": "string", + "minLength": 1 + } + } + } + }, + "response_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "status_check": { + "type": [ + "object", + "null" + ], + "required": [ + "status", + "phase", + "response_sha256", + "error" + ], + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "enum": [ + "SUCCESS", + "FAILED", + "SKIPPED", + "UNAVAILABLE" + ] + }, + "phase": { + "type": "string", + "enum": [ + "STAGE_ENTRY", + "POST_SYNC" + ] + }, + "response_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, + "sync": { + "type": [ + "object", + "null" + ], + "required": [ + "status", + "response_sha256", + "error" + ], + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "enum": [ + "SUCCESS", + "FAILED", + "SKIPPED", + "UNAVAILABLE" + ] + }, + "response_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, + "freshness": { + "type": "object", + "required": [ + "status", + "checked_at", + "basis", + "response_sha256", + "stale_points" + ], + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "enum": [ + "CURRENT_AT_CHECK", + "PARTIAL_STALE", + "INDEX_STALE", + "NOT_VERIFIED", + "UNAVAILABLE" + ] + }, + "checked_at": { + "type": "string", + "minLength": 1 + }, + "response_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + }, + "basis": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "CONNECT_RECONCILIATION", + "STATUS_JSON", + "SYNC_ACKNOWLEDGED", + "RESPONSE_BANNER", + "NONE" + ] + } + }, + "stale_points": { + "type": "array", + "items": { + "type": "object", + "required": [ + "scope", + "path", + "reason", + "fallback", + "observed_sha256" + ], + "additionalProperties": false, + "properties": { + "scope": { + "type": "string", + "enum": [ + "FILE", + "INDEX" + ] + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "reason": { + "type": "string", + "enum": [ + "PENDING_SYNC", + "AUTO_SYNC_DISABLED", + "WORKTREE_MISMATCH", + "INDEX_PARTIAL", + "INDEX_INDEXING", + "INDEX_FAILED", + "PENDING_REFERENCES", + "REINDEX_RECOMMENDED", + "SYNC_FAILED", + "STATUS_UNREADABLE" + ] + }, + "fallback": { + "type": "string", + "enum": [ + "READ_SOURCE", + "INSPECT_GIT_DIFF", + "SEARCH_SOURCE" + ] + }, + "observed_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + } + } + } + } + } + }, + "source_fallbacks": { + "type": "array", + "items": { + "type": "object", + "required": [ + "action", + "path", + "observed_sha256", + "base_commit", + "head_commit", + "diff_hash", + "purpose", + "result_paths" + ], + "additionalProperties": false, + "properties": { + "action": { + "type": "string", + "enum": [ + "READ_SOURCE", + "INSPECT_GIT_DIFF", + "SEARCH_SOURCE" + ] + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "observed_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + }, + "base_commit": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{40}$" + }, + "head_commit": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{40}$" + }, + "diff_hash": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + }, + "purpose": { + "type": "string" + }, + "result_paths": { + "type": "array", + "maxItems": 100, + "items": { + "type": "object", + "required": [ + "path", + "observed_sha256" + ], + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "observed_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + } + } + } + }, + "recorded_at": { + "type": "string", + "minLength": 1 + } + } +} diff --git a/schemas/code-intelligence-record.schema.json b/schemas/code-intelligence-record.schema.json index 14ca0f6..8199c11 100644 --- a/schemas/code-intelligence-record.schema.json +++ b/schemas/code-intelligence-record.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Polaris Code Intelligence record v2", + "title": "Polaris Code Intelligence record v3", "type": "object", "required": [ "record_version", @@ -10,396 +10,146 @@ "artifact_attempt", "reviewer_slot", "provider", + "repository", "target", "status", - "queries", - "status_check", - "sync", - "freshness", + "proxy", + "query", + "query_window", + "delivery", "source_fallbacks", "recorded_at" ], "additionalProperties": false, "properties": { - "record_version": { - "const": 2 - }, - "task_id": { - "type": "string", - "pattern": "^TASK-[0-9]{4}$" - }, - "work_item_revision": { - "type": "integer", - "minimum": 1 - }, + "record_version": {"const": 3}, + "task_id": {"type": "string", "pattern": "^TASK-[0-9]{4}$"}, + "work_item_revision": {"type": "integer", "minimum": 1}, "stage": { "type": "string", - "enum": [ - "PLANNING", - "IMPLEMENTATION", - "DOCUMENTATION_SYNC", - "REVIEW" - ] - }, - "artifact_attempt": { - "type": [ - "integer", - "null" - ], - "minimum": 1 - }, - "reviewer_slot": { - "type": [ - "integer", - "null" - ], - "minimum": 1 + "enum": ["PLANNING", "IMPLEMENTATION", "DOCUMENTATION_SYNC", "REVIEW"] }, + "artifact_attempt": {"type": ["integer", "null"], "minimum": 1}, + "reviewer_slot": {"type": ["integer", "null"], "minimum": 1}, "provider": { - "type": [ - "object", - "null" - ], - "required": [ - "id", - "descriptor_version", - "transport", - "available_operations" - ], + "type": "object", + "required": ["id", "descriptor_version"], "additionalProperties": false, "properties": { - "id": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]*$" - }, - "descriptor_version": { - "const": 2 - }, - "transport": { - "const": "mcp" - }, - "available_operations": { - "type": "array", - "uniqueItems": true, - "items": { - "type": "string", - "enum": [ - "explore", - "status", - "sync" - ] - } - } + "id": {"const": "codegraph"}, + "descriptor_version": {"const": 2} } }, - "target": { + "repository": { "type": "object", - "required": [ - "base_commit", - "head_commit", - "diff_hash" - ], + "required": ["project_id", "root_sha256"], "additionalProperties": false, "properties": { - "base_commit": { - "type": "string", - "pattern": "^[0-9a-f]{40}$" - }, - "head_commit": { - "type": [ - "string", - "null" - ], - "pattern": "^[0-9a-f]{40}$" - }, - "diff_hash": { - "type": [ - "string", - "null" - ], - "pattern": "^[0-9a-f]{64}$" - } + "project_id": {"type": "string", "minLength": 1}, + "root_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"} } }, - "status": { - "type": "string", - "enum": [ - "USED", - "UNAVAILABLE", - "FAILED", - "SKIPPED" - ] + "target": { + "type": "object", + "required": ["base_commit", "head_commit", "diff_hash"], + "additionalProperties": false, + "properties": { + "base_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "head_commit": {"type": ["string", "null"], "pattern": "^[0-9a-f]{40}$"}, + "diff_hash": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"} + } }, - "queries": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "operation", - "purpose", - "status", - "summary", - "symbols", - "response_sha256", - "error" - ], - "additionalProperties": false, - "properties": { - "id": { - "type": "string", - "pattern": "^CIQ-[0-9]{3}$" - }, - "operation": { - "const": "explore" - }, - "purpose": { - "type": "string", - "minLength": 1 - }, - "status": { - "type": "string", - "enum": [ - "SUCCESS", - "EMPTY", - "FAILED", - "UNAVAILABLE" - ] - }, - "summary": { - "type": "string" - }, - "symbols": { - "type": "array", - "items": { - "type": "object", - "required": [ - "path", - "line", - "name" - ], - "additionalProperties": false, - "properties": { - "path": { - "type": "string", - "minLength": 1 - }, - "line": { - "type": [ - "integer", - "null" - ], - "minimum": 1 - }, - "name": { - "type": "string", - "minLength": 1 - } - } - } - }, - "response_sha256": { - "type": [ - "string", - "null" - ], - "pattern": "^[0-9a-f]{64}$" - }, - "error": { - "type": [ - "string", - "null" - ] - } - } + "status": {"type": "string", "enum": ["USED", "FAILED", "UNAVAILABLE"]}, + "proxy": { + "type": "object", + "required": ["server_id", "tool", "evidence_bundle_sha256"], + "additionalProperties": false, + "properties": { + "server_id": {"const": "polaris-codegraph"}, + "tool": {"const": "polaris_codegraph_explore"}, + "evidence_bundle_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"} } }, - "status_check": { - "type": [ - "object", - "null" - ], + "query": { + "type": "object", "required": [ - "status", - "phase", - "response_sha256", - "error" + "id", "purpose", "text", "status", "summary", "symbols", + "response_sha256", "error" ], "additionalProperties": false, "properties": { - "status": { - "type": "string", - "enum": [ - "SUCCESS", - "FAILED", - "SKIPPED", - "UNAVAILABLE" - ] - }, - "phase": { - "type": "string", - "enum": [ - "STAGE_ENTRY", - "POST_SYNC" - ] - }, - "response_sha256": { - "type": [ - "string", - "null" - ], - "pattern": "^[0-9a-f]{64}$" + "id": {"type": "string", "pattern": "^CIQ-[0-9]{3}$"}, + "purpose": {"type": "string", "minLength": 1}, + "text": {"type": "string", "minLength": 1}, + "status": {"type": "string", "enum": ["SUCCESS", "FAILED", "UNAVAILABLE"]}, + "summary": {"type": "string"}, + "symbols": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "line", "name"], + "additionalProperties": false, + "properties": { + "path": {"type": "string", "minLength": 1}, + "line": {"type": ["integer", "null"], "minimum": 1}, + "name": {"type": "string", "minLength": 1} + } + } }, - "error": { - "type": [ - "string", - "null" - ] - } + "response_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "error": {"type": ["string", "null"]} } }, - "sync": { - "type": [ - "object", - "null" - ], + "query_window": { + "type": "object", "required": [ - "status", - "response_sha256", - "error" + "pre_status", "sync", "post_sync_status", + "response_classification", "post_query_status" ], "additionalProperties": false, "properties": { - "status": { - "type": "string", - "enum": [ - "SUCCESS", - "FAILED", - "SKIPPED", - "UNAVAILABLE" - ] - }, - "response_sha256": { - "type": [ - "string", - "null" - ], - "pattern": "^[0-9a-f]{64}$" - }, - "error": { - "type": [ - "string", - "null" - ] - } + "pre_status": {"type": "object"}, + "sync": {"type": ["object", "null"]}, + "post_sync_status": {"type": ["object", "null"]}, + "response_classification": {"type": ["object", "null"]}, + "post_query_status": {"type": ["object", "null"]} } }, - "freshness": { + "delivery": { "type": "object", "required": [ - "status", - "checked_at", - "basis", - "response_sha256", - "stale_points" + "state", "record_status", "reason", "checked_at", "usage", + "required_fallback", "stale_points", "pending_changes", "error" ], "additionalProperties": false, "properties": { - "status": { + "state": {"type": "string", "enum": ["CURRENT", "STALE", "UNKNOWN", "UNAVAILABLE"]}, + "record_status": { "type": "string", - "enum": [ - "CURRENT_AT_CHECK", - "PARTIAL_STALE", - "INDEX_STALE", - "NOT_VERIFIED", - "UNAVAILABLE" - ] + "enum": ["CURRENT_AT_CHECK", "PARTIAL_STALE", "INDEX_STALE", "NOT_VERIFIED", "UNAVAILABLE"] }, - "checked_at": { + "reason": {"type": "string", "minLength": 1}, + "checked_at": {"type": "string", "minLength": 1}, + "usage": { "type": "string", - "minLength": 1 + "enum": ["NON_AUTHORITATIVE_CONTEXT", "NAVIGATION_ONLY", "NO_GRAPH"] }, - "response_sha256": { - "type": [ - "string", - "null" - ], - "pattern": "^[0-9a-f]{64}$" + "required_fallback": { + "type": "string", + "enum": ["NONE", "READ_SOURCE", "INSPECT_GIT_DIFF", "SEARCH_SOURCE"] }, - "basis": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": { - "type": "string", - "enum": [ - "CONNECT_RECONCILIATION", - "STATUS_JSON", - "SYNC_ACKNOWLEDGED", - "RESPONSE_BANNER", - "NONE" - ] + "stale_points": {"type": "array", "items": {"type": "object"}}, + "pending_changes": { + "type": "object", + "required": ["added", "modified", "removed"], + "additionalProperties": false, + "properties": { + "added": {"type": "integer", "minimum": 0}, + "modified": {"type": "integer", "minimum": 0}, + "removed": {"type": "integer", "minimum": 0} } }, - "stale_points": { - "type": "array", - "items": { - "type": "object", - "required": [ - "scope", - "path", - "reason", - "fallback", - "observed_sha256" - ], - "additionalProperties": false, - "properties": { - "scope": { - "type": "string", - "enum": [ - "FILE", - "INDEX" - ] - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "reason": { - "type": "string", - "enum": [ - "PENDING_SYNC", - "AUTO_SYNC_DISABLED", - "WORKTREE_MISMATCH", - "INDEX_PARTIAL", - "INDEX_INDEXING", - "INDEX_FAILED", - "PENDING_REFERENCES", - "REINDEX_RECOMMENDED", - "SYNC_FAILED", - "STATUS_UNREADABLE" - ] - }, - "fallback": { - "type": "string", - "enum": [ - "READ_SOURCE", - "INSPECT_GIT_DIFF", - "SEARCH_SOURCE" - ] - }, - "observed_sha256": { - "type": [ - "string", - "null" - ], - "pattern": "^[0-9a-f]{64}$" - } - } - } - } + "error": {"type": ["string", "null"]} } }, "source_fallbacks": { @@ -407,90 +157,34 @@ "items": { "type": "object", "required": [ - "action", - "path", - "observed_sha256", - "base_commit", - "head_commit", - "diff_hash", - "purpose", - "result_paths" + "action", "path", "observed_sha256", "base_commit", + "head_commit", "diff_hash", "purpose", "result_paths" ], "additionalProperties": false, "properties": { - "action": { - "type": "string", - "enum": [ - "READ_SOURCE", - "INSPECT_GIT_DIFF", - "SEARCH_SOURCE" - ] - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "observed_sha256": { - "type": [ - "string", - "null" - ], - "pattern": "^[0-9a-f]{64}$" - }, - "base_commit": { - "type": [ - "string", - "null" - ], - "pattern": "^[0-9a-f]{40}$" - }, - "head_commit": { - "type": [ - "string", - "null" - ], - "pattern": "^[0-9a-f]{40}$" - }, - "diff_hash": { - "type": [ - "string", - "null" - ], - "pattern": "^[0-9a-f]{64}$" - }, - "purpose": { - "type": "string" - }, + "action": {"type": "string", "enum": ["READ_SOURCE", "INSPECT_GIT_DIFF", "SEARCH_SOURCE"]}, + "path": {"type": ["string", "null"]}, + "observed_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "base_commit": {"type": ["string", "null"], "pattern": "^[0-9a-f]{40}$"}, + "head_commit": {"type": ["string", "null"], "pattern": "^[0-9a-f]{40}$"}, + "diff_hash": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "purpose": {"type": "string", "minLength": 1}, "result_paths": { "type": "array", "maxItems": 100, "items": { "type": "object", - "required": [ - "path", - "observed_sha256" - ], + "required": ["path", "observed_sha256"], "additionalProperties": false, "properties": { - "path": { - "type": "string", - "minLength": 1 - }, - "observed_sha256": { - "type": "string", - "pattern": "^[0-9a-f]{64}$" - } + "path": {"type": "string", "minLength": 1}, + "observed_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"} } } } } } }, - "recorded_at": { - "type": "string", - "minLength": 1 - } + "recorded_at": {"type": "string", "minLength": 1} } } diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index 5ae7ec6..b222c94 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -2,6 +2,8 @@ from __future__ import annotations +import hashlib +import re from pathlib import Path from typing import Any, Iterable @@ -15,12 +17,18 @@ read_json, subject_diff_hash, task_dir, + utc_now, validate_json_file, validate_schema, write_json_atomic, ) from .task_location_protocol import resolve_repo_reference -from .task_layout import code_intelligence_record_path, state_path +from .task_layout import ( + code_intelligence_record_path, + code_intelligence_runtime_dir, + state_path, + task_relative_path, +) CONFIG_PATH = Path(".polaris/code-intelligence.json") @@ -364,11 +372,18 @@ def validate_historical_legacy_record_value( def _validate_record_identity( - repo: Path, task_id: str, value: dict[str, Any] + repo: Path, + task_id: str, + value: dict[str, Any], + *, + require_current_revision: bool = True, ) -> tuple[Path, str, str | None]: directory = task_dir(repo, task_id) state = read_json(state_path(directory)) - if value["task_id"] != task_id or value["work_item_revision"] != state["current_revision"]: + if value["task_id"] != task_id or ( + require_current_revision + and value["work_item_revision"] != state["current_revision"] + ): raise RuleFailure("Code Intelligence record targets the wrong task revision") _record_name(value) target = value["target"] @@ -532,14 +547,24 @@ def _validate_v2_freshness( def _validate_v2_record_value( - repo: Path, task_id: str, value: dict[str, Any], root: Path + repo: Path, + task_id: str, + value: dict[str, Any], + root: Path, + *, + require_current_revision: bool = True, ) -> dict[str, Any]: errors = validate_schema( - value, read_json(root / "schemas" / "code-intelligence-record.schema.json") + value, read_json(root / "schemas" / "code-intelligence-record-v2.schema.json") ) if errors: raise RuleFailure("Code Intelligence record failed schema validation:\n- " + "\n- ".join(errors)) - _, base, head = _validate_record_identity(repo, task_id, value) + _, base, head = _validate_record_identity( + repo, + task_id, + value, + require_current_revision=require_current_revision, + ) provider = value["provider"] if value["status"] in {"USED", "FAILED"} and provider is None: raise RuleFailure("used or failed Code Intelligence record requires a provider") @@ -689,13 +714,433 @@ def _validate_v2_record_value( return value +def validate_historical_v2_record_value( + repo: Path, + task_id: str, + path: Path, + value: dict[str, Any], + root: Path | None = None, +) -> dict[str, Any]: + """Validate immutable v2 evidence at its canonical historical location.""" + root = protocol_root(repo) if root is None else root + value = _validate_v2_record_value( + repo, + task_id, + value, + root, + require_current_revision=False, + ) + expected = code_intelligence_record_path( + task_dir(repo, task_id), value["work_item_revision"], _record_name(value) + ) + if path != expected: + raise RuleFailure("Code Intelligence record reference uses a non-canonical path") + return value + + +def _require_exact_keys(value: Any, keys: set[str], label: str) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != keys: + raise RuleFailure(f"{label} has an invalid field set") + return value + + +def _zero_pending(value: Any) -> bool: + return isinstance(value, dict) and value == { + "added": 0, + "modified": 0, + "removed": 0, + } + + +def _is_sha256(value: Any) -> bool: + return isinstance(value, str) and re.fullmatch(r"[0-9a-f]{64}", value) is not None + + +def _validate_v3_stale_point(repo: Path, point: Any) -> dict[str, Any]: + point = _require_exact_keys( + point, + {"scope", "path", "reason", "fallback", "observed_sha256"}, + "Code Intelligence stale point", + ) + if point["scope"] == "FILE": + if ( + not isinstance(point["path"], str) + or point["reason"] != "PENDING_SYNC" + or point["fallback"] not in {"READ_SOURCE", "INSPECT_GIT_DIFF"} + ): + raise RuleFailure("v3 file stale point is invalid") + resolved = resolve_repo_reference(repo, point["path"]) + if point["fallback"] == "READ_SOURCE": + if ( + not resolved.is_file() + or point["observed_sha256"] != file_sha256(resolved) + ): + raise RuleFailure("v3 READ_SOURCE stale point hash is stale") + elif point["observed_sha256"] is not None or resolved.is_file(): + raise RuleFailure("v3 INSPECT_GIT_DIFF stale point must name a missing path") + elif point["scope"] == "INDEX": + if ( + point["path"] is not None + or point["fallback"] != "SEARCH_SOURCE" + or point["observed_sha256"] is not None + or point["reason"] not in { + "PENDING_CHANGES", + "AUTO_SYNC_DISABLED", + "WORKTREE_MISMATCH", + "INDEX_PARTIAL", + "INDEX_INDEXING", + "INDEX_FAILED", + "PENDING_REFERENCES", + "REINDEX_RECOMMENDED", + "SYNC_FAILED", + "STATUS_UNREADABLE", + } + ): + raise RuleFailure("v3 index stale point is invalid") + else: + raise RuleFailure("v3 stale point scope is invalid") + return point + + +def _validate_v3_status_observation( + repo: Path, observation: Any, label: str +) -> dict[str, Any]: + observation = _require_exact_keys( + observation, + { + "status", + "checked_at", + "basis", + "stale_points", + "status_response_sha256", + "error", + "needs_sync", + "pending_changes", + }, + label, + ) + if ( + observation["status"] + not in {"CURRENT_AT_CHECK", "INDEX_STALE", "NOT_VERIFIED", "UNAVAILABLE"} + or not isinstance(observation["checked_at"], str) + or not observation["checked_at"] + or not isinstance(observation["basis"], list) + or not isinstance(observation["needs_sync"], bool) + or not isinstance(observation["stale_points"], list) + ): + raise RuleFailure(f"{label} has invalid status fields") + for point in observation["stale_points"]: + _validate_v3_stale_point(repo, point) + status = observation["status"] + if status in {"CURRENT_AT_CHECK", "INDEX_STALE"}: + if ( + not _is_sha256(observation["status_response_sha256"]) + or not isinstance(observation["pending_changes"], dict) + or set(observation["pending_changes"]) + != {"added", "modified", "removed"} + or any( + isinstance(value, bool) or not isinstance(value, int) or value < 0 + for value in observation["pending_changes"].values() + ) + or observation["error"] is not None + or "STATUS_JSON" not in observation["basis"] + or len(observation["basis"]) != len(set(observation["basis"])) + or not set(observation["basis"]).issubset( + {"STATUS_JSON", "SYNC_ACKNOWLEDGED"} + ) + ): + raise RuleFailure(f"{label} successful status evidence is incomplete") + if status == "CURRENT_AT_CHECK" and observation["stale_points"]: + raise RuleFailure(f"{label} current status cannot contain stale points") + if status == "INDEX_STALE" and not observation["stale_points"]: + raise RuleFailure(f"{label} stale status requires stale points") + elif status == "NOT_VERIFIED": + if ( + observation["pending_changes"] is not None + or not observation["error"] + or observation["basis"] != ["STATUS_JSON"] + or ( + observation["status_response_sha256"] is not None + and not _is_sha256(observation["status_response_sha256"]) + ) + ): + raise RuleFailure(f"{label} NOT_VERIFIED evidence is incomplete") + elif ( + observation["status_response_sha256"] is not None + or observation["pending_changes"] is not None + or not observation["error"] + or observation["basis"] != ["NONE"] + or observation["stale_points"] + ): + raise RuleFailure(f"{label} UNAVAILABLE evidence is invalid") + return observation + + +def _validate_v3_sync(value: Any) -> dict[str, Any] | None: + if value is None: + return None + value = _require_exact_keys( + value, + {"status", "response_sha256", "error"}, + "Code Intelligence v3 sync", + ) + if value["status"] not in {"SUCCESS", "FAILED"}: + raise RuleFailure("v3 sync must describe exactly one attempted command") + if value["status"] == "SUCCESS" and ( + not _is_sha256(value["response_sha256"]) or value["error"] is not None + ): + raise RuleFailure("successful v3 sync requires a response hash") + if value["status"] == "FAILED" and ( + not value["error"] + or ( + value["response_sha256"] is not None + and not _is_sha256(value["response_sha256"]) + ) + ): + raise RuleFailure("failed v3 sync requires an error") + return value + + +def _validate_v3_response(repo: Path, value: Any) -> dict[str, Any] | None: + if value is None: + return None + value = _require_exact_keys( + value, + { + "classification", + "checked_at", + "basis", + "stale_points", + "response_sha256", + "error", + }, + "Code Intelligence v3 response classification", + ) + if ( + value["classification"] + not in {"NONE", "PARTIAL_STALE", "INDEX_STALE", "NOT_VERIFIED"} + or not _is_sha256(value["response_sha256"]) + or value["basis"] != ["RESPONSE_BANNER"] + ): + raise RuleFailure("v3 response classification is invalid") + for point in value["stale_points"]: + _validate_v3_stale_point(repo, point) + if value["classification"] == "NONE" and ( + value["stale_points"] or value["error"] is not None + ): + raise RuleFailure("neutral v3 response cannot contain stale evidence") + if value["classification"] in {"PARTIAL_STALE", "INDEX_STALE"} and ( + not value["stale_points"] or value["error"] is not None + ): + raise RuleFailure("stale v3 response requires explicit stale points") + if value["classification"] == "NOT_VERIFIED" and not value["error"]: + raise RuleFailure("unverified v3 response requires an error") + return value + + +def _validate_v3_fallback_matches(value: dict[str, Any]) -> None: + fallbacks = value["source_fallbacks"] + points = value["delivery"]["stale_points"] + for point in points: + if point["reason"] == "STATUS_UNREADABLE": + continue + if _matching_fallback( + fallbacks, + point["fallback"], + point["path"], + point["observed_sha256"], + ) is None: + raise RuleFailure("v3 stale point requires exact source fallback evidence") + required = value["delivery"]["required_fallback"] + if required == "NONE" and fallbacks: + raise RuleFailure("CURRENT v3 evidence cannot contain source fallbacks") + if required != "NONE" and not fallbacks: + raise RuleFailure("non-current v3 evidence requires source fallback evidence") + if required == "SEARCH_SOURCE" and not any( + fallback["action"] == "SEARCH_SOURCE" for fallback in fallbacks + ): + raise RuleFailure("v3 evidence requires a SEARCH_SOURCE fallback") + + +def _validate_v3_record_value( + repo: Path, task_id: str, value: dict[str, Any], root: Path +) -> dict[str, Any]: + errors = validate_schema( + value, read_json(root / "schemas/code-intelligence-record.schema.json") + ) + if errors: + raise RuleFailure( + "Code Intelligence record failed schema validation:\n- " + + "\n- ".join(errors) + ) + _, base, head = _validate_record_identity(repo, task_id, value) + project = read_json(repo / ".polaris/project.json") + expected_repository = { + "project_id": project["project_id"], + "root_sha256": hashlib.sha256( + str(repo.resolve()).encode("utf-8") + ).hexdigest(), + } + if value["repository"] != expected_repository: + raise RuleFailure("Code Intelligence v3 repository identity does not match") + if value["provider"] != {"id": "codegraph", "descriptor_version": 2}: + raise RuleFailure("Code Intelligence v3 requires the official CodeGraph provider") + query = value["query"] + if query["status"] == "SUCCESS": + if query["response_sha256"] is None or query["error"] is not None: + raise RuleFailure("successful v3 query requires a response hash and no error") + elif not query["error"] or query["response_sha256"] is not None: + raise RuleFailure("unsuccessful v3 query requires only a finite error") + for symbol in query["symbols"]: + if not resolve_repo_reference(repo, symbol["path"]).is_file(): + raise RuleFailure(f"v3 symbol path is not a current file: {symbol['path']}") + + window = value["query_window"] + pre = _validate_v3_status_observation(repo, window["pre_status"], "pre-query status") + sync = _validate_v3_sync(window["sync"]) + post_sync = ( + _validate_v3_status_observation(repo, window["post_sync_status"], "post-sync status") + if window["post_sync_status"] is not None + else None + ) + response = _validate_v3_response(repo, window["response_classification"]) + post = ( + _validate_v3_status_observation(repo, window["post_query_status"], "post-query status") + if window["post_query_status"] is not None + else None + ) + if sync is None and post_sync is not None: + raise RuleFailure("post-sync status requires one sync attempt") + if sync is not None and post_sync is None: + raise RuleFailure("attempted sync requires post-sync status evidence") + if sync is not None and sync["status"] == "SUCCESS" and ( + post_sync["status"] != "CURRENT_AT_CHECK" or post_sync["needs_sync"] + ): + raise RuleFailure("successful sync requires a current post-sync status") + if query["status"] == "SUCCESS" and ( + response is None + or response["response_sha256"] != query["response_sha256"] + or post is None + ): + raise RuleFailure("successful v3 query requires matching response and post-status evidence") + if query["status"] != "SUCCESS" and (response is not None or post is not None): + raise RuleFailure("unsuccessful v3 query cannot claim response or post-status evidence") + + delivery = value["delivery"] + for point in delivery["stale_points"]: + _validate_v3_stale_point(repo, point) + effective = post_sync if sync is not None else pre + observed_points: list[dict[str, Any]] = [] + for observation in (effective, post): + if observation is None: + continue + observed_points.extend(observation["stale_points"]) + pending = observation["pending_changes"] + if isinstance(pending, dict) and any(pending.values()): + pending_point = { + "scope": "INDEX", + "path": None, + "reason": "PENDING_CHANGES", + "fallback": "SEARCH_SOURCE", + "observed_sha256": None, + } + if pending_point not in observed_points: + observed_points.append(pending_point) + if response is not None: + observed_points.extend(response["stale_points"]) + unique_points: list[dict[str, Any]] = [] + for point in observed_points: + if point not in unique_points: + unique_points.append(point) + if delivery["stale_points"] != unique_points: + raise RuleFailure("v3 delivery stale points do not match the observed query window") + successful_pending = [ + observation["pending_changes"] + for observation in (effective, post) + if observation is not None and isinstance(observation["pending_changes"], dict) + ] + expected_pending = { + key: max((pending[key] for pending in successful_pending), default=0) + for key in ("added", "modified", "removed") + } + if delivery["pending_changes"] != expected_pending: + raise RuleFailure("v3 delivery pending counts do not match the query window") + state = delivery["state"] + expected_record_status = { + "CURRENT": "CURRENT_AT_CHECK", + "STALE": delivery["record_status"], + "UNKNOWN": "NOT_VERIFIED", + "UNAVAILABLE": "UNAVAILABLE", + }[state] + if delivery["record_status"] != expected_record_status: + raise RuleFailure("v3 delivery state contradicts its record freshness status") + if value["status"] != { + "CURRENT": "USED", + "STALE": "USED", + "UNKNOWN": "FAILED", + "UNAVAILABLE": "UNAVAILABLE", + }[state]: + raise RuleFailure("v3 record status contradicts proxy delivery") + if state == "CURRENT": + if ( + query["status"] != "SUCCESS" + or response["classification"] != "NONE" + or effective["status"] != "CURRENT_AT_CHECK" + or effective["needs_sync"] + or not _zero_pending(effective["pending_changes"]) + or post["status"] != "CURRENT_AT_CHECK" + or post["needs_sync"] + or not _zero_pending(post["pending_changes"]) + or delivery["usage"] != "NON_AUTHORITATIVE_CONTEXT" + or delivery["required_fallback"] != "NONE" + or delivery["stale_points"] + or not _zero_pending(delivery["pending_changes"]) + or delivery["error"] is not None + ): + raise RuleFailure("CURRENT v3 evidence lacks a complete zero-pending window") + elif state == "STALE": + if ( + query["status"] != "SUCCESS" + or delivery["usage"] != "NAVIGATION_ONLY" + or delivery["required_fallback"] == "NONE" + or not any( + point["reason"] != "STATUS_UNREADABLE" + for point in delivery["stale_points"] + ) + ): + raise RuleFailure("STALE v3 evidence lacks an explicit stale reason") + elif state == "UNKNOWN": + if ( + delivery["usage"] != "NAVIGATION_ONLY" + or delivery["required_fallback"] != "SEARCH_SOURCE" + or not delivery["error"] + ): + raise RuleFailure("UNKNOWN v3 evidence lacks verification failure evidence") + elif ( + query["status"] != "UNAVAILABLE" + or pre["status"] != "UNAVAILABLE" + or any(item is not None for item in (sync, post_sync, response, post)) + or delivery["usage"] != "NO_GRAPH" + or delivery["stale_points"] + ): + raise RuleFailure("UNAVAILABLE v3 evidence contains an attempted operation") + _validate_source_fallbacks(repo, value, base, head) + _validate_v3_fallback_matches(value) + return value + + def validate_record_value( repo: Path, task_id: str, value: dict[str, Any], root: Path | None = None ) -> dict[str, Any]: root = protocol_root(repo) if root is None else root - if value.get("record_version") == 1: + version = value.get("record_version") + if version == 1: return validate_legacy_record_value(repo, task_id, value, root) - return _validate_v2_record_value(repo, task_id, value, root) + if version == 2: + return _validate_v2_record_value(repo, task_id, value, root) + if version == 3: + return _validate_v3_record_value(repo, task_id, value, root) + raise RuleFailure("unsupported Code Intelligence record version") def record( @@ -705,8 +1150,8 @@ def record( root: Path | None = None, ) -> dict[str, Any]: root = protocol_root(repo) if root is None else root - if value.get("record_version") == 1: - raise InputFailure("new Code Intelligence records must use record_version 2") + if value.get("record_version") != 3: + raise InputFailure("new Code Intelligence records must use record_version 3") value = validate_record_value(repo, task_id, value, root) directory = task_dir(repo, task_id) destination = code_intelligence_record_path( @@ -725,6 +1170,172 @@ def record( } +def record_proxy_bundle( + repo: Path, + task_id: str, + bundle_path: Path, + annotations: dict[str, Any], + root: Path | None = None, +) -> dict[str, Any]: + """Project one immutable proxy bundle into the only writable v3 record shape.""" + repo = repo.resolve() + root = protocol_root(repo) if root is None else root + directory = task_dir(repo, task_id) + runtime = code_intelligence_runtime_dir(directory) + candidate = bundle_path if bundle_path.is_absolute() else repo / bundle_path + candidate = confined_target(runtime, candidate, "CodeGraph proxy bundle") + require_regular_file(candidate, "CodeGraph proxy bundle") + bundle_digest = file_sha256(candidate) + bundle = read_json(candidate) + _require_exact_keys( + bundle, + { + "bundle_version", + "proxy", + "provider", + "repository", + "task_context", + "query", + "pre_status", + "sync", + "post_sync_status", + "response_classification", + "post_query_status", + "delivery", + "response_path", + }, + "CodeGraph proxy bundle", + ) + if bundle["bundle_version"] != 1 or bundle["proxy"] != { + "server_id": "polaris-codegraph", + "tool": "polaris_codegraph_explore", + }: + raise RuleFailure("CodeGraph proxy bundle has an unsupported identity") + if bundle["provider"] != {"id": "codegraph", "descriptor_version": 2}: + raise RuleFailure("CodeGraph proxy bundle does not use the official provider") + context = bundle["task_context"] + _require_exact_keys( + context, + { + "task_id", + "work_item_revision", + "stage", + "artifact_attempt", + "reviewer_slot", + "record_name", + "target", + }, + "CodeGraph proxy task context", + ) + if context["task_id"] != task_id: + raise RuleFailure("CodeGraph proxy bundle targets the wrong task") + from .code_intelligence_proxy import resolve_stage_context + + if context != resolve_stage_context(repo, task_id, context["stage"]): + raise RuleFailure("CodeGraph proxy bundle stage context is no longer current") + query = _require_exact_keys( + bundle["query"], + {"id", "purpose", "text", "status", "response_sha256", "error"}, + "CodeGraph proxy query", + ) + query_match = re.fullmatch(r"CIQ-([0-9]{3})", str(query["id"])) + if query_match is None or query_match.group(1) == "000": + raise RuleFailure("CodeGraph proxy bundle has an invalid query ID") + expected_path = directory / task_relative_path( + "code_intelligence_proxy_bundle", + record_name=context["record_name"], + query_id=query["id"], + ) + if candidate != expected_path: + raise RuleFailure("CodeGraph proxy bundle uses a non-canonical runtime path") + query_number = int(query_match.group(1)) + for number in range(1, query_number + 1): + prior = directory / task_relative_path( + "code_intelligence_proxy_bundle", + record_name=context["record_name"], + query_id=f"CIQ-{number:03d}", + ) + require_regular_file(prior, "sequential CodeGraph proxy bundle") + project = read_json(repo / ".polaris/project.json") + expected_repository = { + "project_id": project["project_id"], + "root_sha256": hashlib.sha256( + str(repo.resolve()).encode("utf-8") + ).hexdigest(), + } + if bundle["repository"] != expected_repository: + raise RuleFailure("CodeGraph proxy bundle repository identity does not match") + response_path = bundle["response_path"] + if response_path is None: + if query["status"] == "SUCCESS" and bundle["delivery"]["state"] != "UNKNOWN": + raise RuleFailure("successful CodeGraph bundle lost its response evidence") + else: + if not isinstance(response_path, str): + raise RuleFailure("CodeGraph proxy response path is invalid") + response_file = confined_target( + directory, directory / response_path, "CodeGraph proxy response" + ) + require_regular_file(response_file, "CodeGraph proxy response") + if query["response_sha256"] != file_sha256(response_file): + raise RuleFailure("CodeGraph proxy response hash does not match") + annotation_errors = validate_schema( + annotations, + read_json(root / "schemas/code-intelligence-record-annotations.schema.json"), + ) + if annotation_errors: + raise RuleFailure( + "Code Intelligence annotations failed schema validation:\n- " + + "\n- ".join(annotation_errors) + ) + if response_path is None and annotations["symbols"]: + raise RuleFailure("discarded CodeGraph output cannot annotate graph symbols") + delivery = bundle.get("delivery") + if not isinstance(delivery, dict) or delivery.get("state") not in { + "CURRENT", + "STALE", + "UNKNOWN", + "UNAVAILABLE", + }: + raise RuleFailure("CodeGraph proxy bundle has an invalid delivery state") + record_value = { + "record_version": 3, + "task_id": task_id, + "work_item_revision": context["work_item_revision"], + "stage": context["stage"], + "artifact_attempt": context["artifact_attempt"], + "reviewer_slot": context["reviewer_slot"], + "provider": bundle["provider"], + "repository": bundle["repository"], + "target": context["target"], + "status": { + "CURRENT": "USED", + "STALE": "USED", + "UNKNOWN": "FAILED", + "UNAVAILABLE": "UNAVAILABLE", + }[delivery["state"]], + "proxy": { + **bundle["proxy"], + "evidence_bundle_sha256": bundle_digest, + }, + "query": { + **query, + "summary": annotations["summary"], + "symbols": annotations["symbols"], + }, + "query_window": { + "pre_status": bundle["pre_status"], + "sync": bundle["sync"], + "post_sync_status": bundle["post_sync_status"], + "response_classification": bundle["response_classification"], + "post_query_status": bundle["post_query_status"], + }, + "delivery": delivery, + "source_fallbacks": annotations["source_fallbacks"], + "recorded_at": utc_now(), + } + return record(repo, task_id, record_value, root) + + def record_reference(repo: Path, task_id: str, reference: Any) -> dict[str, Any]: if reference is None: return {} diff --git a/scripts/internal/code_intelligence_proxy.py b/scripts/internal/code_intelligence_proxy.py index b141026..aa6ab88 100644 --- a/scripts/internal/code_intelligence_proxy.py +++ b/scripts/internal/code_intelligence_proxy.py @@ -64,7 +64,9 @@ def _target(base: str, head: str | None, diff_hash: str | None) -> dict[str, str def _validated_subject(repo: Path, subject: Any, fallback_base: str) -> dict[str, str | None]: if subject is None: - return _target(full_commit(repo, fallback_base), None, None) + base = full_commit(repo, fallback_base) + head = full_commit(repo) + return _target(base, head, subject_diff_hash(repo, base, head)) if not isinstance(subject, dict): raise RuleFailure("CodeGraph stage context has an invalid subject") base = full_commit(repo, subject.get("base_commit", "")) diff --git a/scripts/record_code_intelligence.py b/scripts/record_code_intelligence.py index 28afb60..a259cc4 100644 --- a/scripts/record_code_intelligence.py +++ b/scripts/record_code_intelligence.py @@ -8,7 +8,7 @@ from pathlib import Path from internal.code_intelligence_protocol import ( - record, + record_proxy_bundle, select_provider, ) from internal.polaris_core import ( @@ -23,7 +23,8 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("task_id", nargs="?") parser.add_argument("--repo", type=Path, default=Path.cwd()) - parser.add_argument("--input", type=Path) + parser.add_argument("--bundle", type=Path) + parser.add_argument("--annotations", type=Path) parser.add_argument("--available-tool", action="append", default=[]) parser.add_argument("--available-executable", action="append", default=[]) parser.add_argument("--select-provider", action="store_true") @@ -40,10 +41,22 @@ def execute() -> dict[str, object]: available_executables=args.available_executable, ) return {"selected": selected, "available": selected is not None} - if args.task_id is None or args.input is None: - raise InputFailure("recording requires task_id and --input") - input_path = args.input if args.input.is_absolute() else repo / args.input - return record(repo, args.task_id, read_json(input_path)) + if args.task_id is None or args.bundle is None or args.annotations is None: + raise InputFailure( + "recording requires task_id, --bundle, and --annotations" + ) + bundle_path = args.bundle if args.bundle.is_absolute() else repo / args.bundle + annotations_path = ( + args.annotations + if args.annotations.is_absolute() + else repo / args.annotations + ) + return record_proxy_bundle( + repo, + args.task_id, + bundle_path, + read_json(annotations_path), + ) return run_main(execute, args.json) diff --git a/templates/task-sources/code-intelligence-record.json b/templates/task-sources/code-intelligence-record.json index 9e17567..419a9c3 100644 --- a/templates/task-sources/code-intelligence-record.json +++ b/templates/task-sources/code-intelligence-record.json @@ -1,27 +1,83 @@ { - "record_version": 2, + "record_version": 3, "task_id": "TASK-0001", "work_item_revision": 1, "stage": "PLANNING", "artifact_attempt": null, "reviewer_slot": null, - "provider": null, + "provider": { + "id": "codegraph", + "descriptor_version": 2 + }, + "repository": { + "project_id": "PROJECT_ID", + "root_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + }, "target": { "base_commit": "0000000000000000000000000000000000000000", "head_commit": null, "diff_hash": null }, "status": "UNAVAILABLE", - "queries": [], - "status_check": null, - "sync": null, - "freshness": { + "proxy": { + "server_id": "polaris-codegraph", + "tool": "polaris_codegraph_explore", + "evidence_bundle_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "query": { + "id": "CIQ-001", + "purpose": "Record unavailable CodeGraph context", + "text": "No CodeGraph query was executed", "status": "UNAVAILABLE", - "checked_at": "1970-01-01T00:00:00Z", - "basis": ["NONE"], + "summary": "", + "symbols": [], "response_sha256": null, - "stale_points": [] + "error": "CodeGraph provider unavailable" + }, + "query_window": { + "pre_status": { + "status": "UNAVAILABLE", + "checked_at": "1970-01-01T00:00:00Z", + "basis": [ + "NONE" + ], + "stale_points": [], + "status_response_sha256": null, + "error": "CodeGraph provider unavailable", + "needs_sync": false, + "pending_changes": null + }, + "sync": null, + "post_sync_status": null, + "response_classification": null, + "post_query_status": null + }, + "delivery": { + "state": "UNAVAILABLE", + "record_status": "UNAVAILABLE", + "reason": "PROVIDER_UNAVAILABLE", + "checked_at": "1970-01-01T00:00:00Z", + "usage": "NO_GRAPH", + "required_fallback": "SEARCH_SOURCE", + "stale_points": [], + "pending_changes": { + "added": 0, + "modified": 0, + "removed": 0 + }, + "error": "CodeGraph provider unavailable" }, - "source_fallbacks": [], + "source_fallbacks": [ + { + "action": "SEARCH_SOURCE", + "path": null, + "observed_sha256": null, + "base_commit": null, + "head_commit": null, + "diff_hash": null, + "purpose": "Use repository source because CodeGraph is unavailable", + "result_paths": [] + } + ], "recorded_at": "1970-01-01T00:00:00Z" } diff --git a/templates/task/code-intelligence/r001/planning.json b/templates/task/code-intelligence/r001/planning.json index 9e17567..419a9c3 100644 --- a/templates/task/code-intelligence/r001/planning.json +++ b/templates/task/code-intelligence/r001/planning.json @@ -1,27 +1,83 @@ { - "record_version": 2, + "record_version": 3, "task_id": "TASK-0001", "work_item_revision": 1, "stage": "PLANNING", "artifact_attempt": null, "reviewer_slot": null, - "provider": null, + "provider": { + "id": "codegraph", + "descriptor_version": 2 + }, + "repository": { + "project_id": "PROJECT_ID", + "root_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + }, "target": { "base_commit": "0000000000000000000000000000000000000000", "head_commit": null, "diff_hash": null }, "status": "UNAVAILABLE", - "queries": [], - "status_check": null, - "sync": null, - "freshness": { + "proxy": { + "server_id": "polaris-codegraph", + "tool": "polaris_codegraph_explore", + "evidence_bundle_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "query": { + "id": "CIQ-001", + "purpose": "Record unavailable CodeGraph context", + "text": "No CodeGraph query was executed", "status": "UNAVAILABLE", - "checked_at": "1970-01-01T00:00:00Z", - "basis": ["NONE"], + "summary": "", + "symbols": [], "response_sha256": null, - "stale_points": [] + "error": "CodeGraph provider unavailable" + }, + "query_window": { + "pre_status": { + "status": "UNAVAILABLE", + "checked_at": "1970-01-01T00:00:00Z", + "basis": [ + "NONE" + ], + "stale_points": [], + "status_response_sha256": null, + "error": "CodeGraph provider unavailable", + "needs_sync": false, + "pending_changes": null + }, + "sync": null, + "post_sync_status": null, + "response_classification": null, + "post_query_status": null + }, + "delivery": { + "state": "UNAVAILABLE", + "record_status": "UNAVAILABLE", + "reason": "PROVIDER_UNAVAILABLE", + "checked_at": "1970-01-01T00:00:00Z", + "usage": "NO_GRAPH", + "required_fallback": "SEARCH_SOURCE", + "stale_points": [], + "pending_changes": { + "added": 0, + "modified": 0, + "removed": 0 + }, + "error": "CodeGraph provider unavailable" }, - "source_fallbacks": [], + "source_fallbacks": [ + { + "action": "SEARCH_SOURCE", + "path": null, + "observed_sha256": null, + "base_commit": null, + "head_commit": null, + "diff_hash": null, + "purpose": "Use repository source because CodeGraph is unavailable", + "result_paths": [] + } + ], "recorded_at": "1970-01-01T00:00:00Z" } diff --git a/tests/fixtures/code-intelligence-record-v2.json b/tests/fixtures/code-intelligence-record-v2.json new file mode 100644 index 0000000..099cd5d --- /dev/null +++ b/tests/fixtures/code-intelligence-record-v2.json @@ -0,0 +1,29 @@ +{ + "record_version": 2, + "task_id": "TASK-0001", + "work_item_revision": 1, + "stage": "PLANNING", + "artifact_attempt": null, + "reviewer_slot": null, + "provider": null, + "target": { + "base_commit": "0000000000000000000000000000000000000000", + "head_commit": null, + "diff_hash": null + }, + "status": "UNAVAILABLE", + "queries": [], + "status_check": null, + "sync": null, + "freshness": { + "status": "UNAVAILABLE", + "checked_at": "1970-01-01T00:00:00Z", + "basis": [ + "NONE" + ], + "response_sha256": null, + "stale_points": [] + }, + "source_fallbacks": [], + "recorded_at": "1970-01-01T00:00:00Z" +} diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index f74632b..34a6e1c 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import importlib import hashlib import io @@ -245,7 +246,7 @@ def classify_response( def v2_record(self) -> dict[str, object]: value = json.loads( - (ROOT / "templates" / "task-sources" / "code-intelligence-record.json").read_text( + (ROOT / "tests/fixtures/code-intelligence-record-v2.json").read_text( encoding="utf-8" ) ) @@ -316,6 +317,48 @@ def qualify_task(self) -> None: def proxy_module(self) -> object: return importlib.import_module("internal.code_intelligence_proxy") + def record_current_v3_fixture(self) -> tuple[dict[str, object], dict[str, object]]: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + source = self.repo / "src/a.py" + source.parent.mkdir() + source.write_text("class A:\n pass\n", encoding="utf-8") + proxy = self.proxy_module() + responses = [ + completed(healthy_status(self.repo)), + completed("A is defined in src/a.py\n"), + completed(healthy_status(self.repo)), + ] + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + return responses.pop(0) + + with mock.patch("internal.code_intelligence_proxy.shutil.which", return_value="/bin/codegraph"): + query = proxy.execute_proxy_query( + self.repo, + "TASK-0001", + "PLANNING", + "CIQ-001", + "locate A", + "symbol A", + False, + runner=runner, + ) + protocol = importlib.import_module("internal.code_intelligence_protocol") + result = protocol.record_proxy_bundle( + self.repo, + "TASK-0001", + query["bundle_path"], + { + "summary": "Located the affected symbol.", + "symbols": [{"path": "src/a.py", "line": 1, "name": "A"}], + "source_fallbacks": [], + }, + ROOT, + ) + recorded = json.loads(Path(result["path"]).read_text(encoding="utf-8")) + return recorded, query + def test_proxy_stage_context_uses_record_name_and_sequential_query_ids(self) -> None: self.qualify_task() proxy = self.proxy_module() @@ -373,8 +416,8 @@ def test_proxy_stage_context_binds_attempt_subject_and_review_slot(self) -> None self.assertEqual(documentation["record_name"], "documentation-sync-002") self.assertEqual(implementation["target"], { "base_commit": base, - "head_commit": None, - "diff_hash": None, + "head_commit": base, + "diff_hash": subject_diff_hash(self.repo, base, base), }) state["status"] = "REVIEWING" @@ -903,6 +946,233 @@ def test_mcp_server_emits_jsonrpc_parse_and_method_errors_one_per_line(self) -> self.assertEqual([item["error"]["code"] for item in responses], [-32700, -32602, -32601]) self.assertTrue(all("\n" not in line for line in completed_process.stdout.splitlines())) + def test_v3_record_projects_exact_proxy_bundle(self) -> None: + recorded, query = self.record_current_v3_fixture() + self.assertEqual(recorded["record_version"], 3) + self.assertEqual(recorded["proxy"]["server_id"], "polaris-codegraph") + self.assertEqual( + recorded["query_window"]["pre_status"]["pending_changes"], + {"added": 0, "modified": 0, "removed": 0}, + ) + self.assertEqual(recorded["delivery"]["state"], "CURRENT") + self.assertEqual( + recorded["proxy"]["evidence_bundle_sha256"], + file_sha256(query["bundle_path"]), + ) + self.assertEqual(recorded["query"]["symbols"][0]["path"], "src/a.py") + + def test_v3_record_rejects_mutated_window_identity_and_fallbacks(self) -> None: + recorded, _query = self.record_current_v3_fixture() + protocol = importlib.import_module("internal.code_intelligence_protocol") + + def current_to_stale(value: dict[str, object]) -> None: + pending = { + "scope": "INDEX", + "path": None, + "reason": "PENDING_CHANGES", + "fallback": "SEARCH_SOURCE", + "observed_sha256": None, + } + value["query_window"]["post_query_status"]["pending_changes"]["added"] = 1 + value["query_window"]["post_query_status"]["needs_sync"] = True + value["delivery"].update({ + "state": "STALE", + "record_status": "INDEX_STALE", + "reason": "PENDING_CHANGES", + "usage": "NAVIGATION_ONLY", + "required_fallback": "SEARCH_SOURCE", + "stale_points": [pending], + "pending_changes": {"added": 1, "modified": 0, "removed": 0}, + }) + + mutations: list[tuple[str, object]] = [ + ( + "current pending", + lambda value: value["delivery"]["pending_changes"].update( + {"modified": 1} + ), + ), + ( + "missing post status", + lambda value: value["query_window"].update( + {"post_query_status": None} + ), + ), + ( + "response hash mismatch", + lambda value: value["query_window"]["response_classification"].update( + {"response_sha256": "1" * 64} + ), + ), + ( + "bundle hash shape", + lambda value: value["proxy"].update( + {"evidence_bundle_sha256": "short"} + ), + ), + ( + "repository mismatch", + lambda value: value["repository"].update( + {"project_id": "another-project"} + ), + ), + ( + "delivery usage", + lambda value: value["delivery"].update( + {"usage": "NAVIGATION_ONLY"} + ), + ), + ( + "sync without post-sync", + lambda value: value["query_window"].update({ + "sync": { + "status": "SUCCESS", + "response_sha256": "2" * 64, + "error": None, + }, + "post_sync_status": None, + }), + ), + ] + for name, mutation in mutations: + with self.subTest(name=name): + value = copy.deepcopy(recorded) + mutation(value) + with self.assertRaises(RuleFailure): + protocol.validate_record_value(self.repo, "TASK-0001", value, ROOT) + + stale_without_fallback = copy.deepcopy(recorded) + current_to_stale(stale_without_fallback) + with self.assertRaisesRegex(RuleFailure, "source fallback"): + protocol.validate_record_value( + self.repo, "TASK-0001", stale_without_fallback, ROOT + ) + + unsafe_fallback = copy.deepcopy(stale_without_fallback) + unsafe_fallback["source_fallbacks"] = [{ + "action": "SEARCH_SOURCE", + "path": None, + "observed_sha256": None, + "base_commit": None, + "head_commit": None, + "diff_hash": None, + "purpose": "inspect current repository source", + "result_paths": [{ + "path": "../outside.py", + "observed_sha256": "0" * 64, + }], + }] + with self.assertRaises(RuleFailure): + protocol.validate_record_value( + self.repo, "TASK-0001", unsafe_fallback, ROOT + ) + + with self.assertRaisesRegex(InputFailure, "record_version 3"): + record(self.repo, "TASK-0001", self.v2_record(), ROOT) + + def test_v3_record_preserves_stale_unknown_and_unavailable_restrictions(self) -> None: + cases = [ + ("stale", "STALE", "USED"), + ("unknown", "UNKNOWN", "FAILED"), + ("unavailable", "UNAVAILABLE", "UNAVAILABLE"), + ] + for index, (case, delivery_state, record_status) in enumerate(cases, start=1): + with self.subTest(case=case): + if index > 1: + self.tearDown() + self.setUp() + self.qualify_task() + source = self.repo / "src/a.py" + source.parent.mkdir() + source.write_text("class A:\n pass\n", encoding="utf-8") + if case != "unavailable": + (self.repo / ".codegraph").mkdir() + if case == "stale": + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["modified"] = 1 + responses = [ + completed(json.dumps(pending)), + completed("A is defined in src/a.py\n"), + completed(json.dumps(pending)), + ] + elif case == "unknown": + responses = [completed("not-json\n")] + else: + responses = [] + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + if not responses: + raise AssertionError(f"unexpected provider call: {command}") + return responses.pop(0) + + proxy = self.proxy_module() + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + query = proxy.execute_proxy_query( + self.repo, + "TASK-0001", + "PLANNING", + "CIQ-001", + "locate A conservatively", + "symbol A", + False, + runner=runner, + ) + fallback = { + "action": "SEARCH_SOURCE", + "path": None, + "observed_sha256": None, + "base_commit": None, + "head_commit": None, + "diff_hash": None, + "purpose": "verify the graph conclusion in current source", + "result_paths": [{ + "path": "src/a.py", + "observed_sha256": file_sha256(source), + }], + } + protocol = importlib.import_module("internal.code_intelligence_protocol") + result = protocol.record_proxy_bundle( + self.repo, + "TASK-0001", + query["bundle_path"], + { + "summary": "Used source fallback.", + "symbols": [], + "source_fallbacks": [fallback], + }, + ROOT, + ) + recorded = json.loads(Path(result["path"]).read_text(encoding="utf-8")) + self.assertEqual(recorded["delivery"]["state"], delivery_state) + self.assertEqual(recorded["status"], record_status) + self.assertEqual(recorded["delivery"]["usage"], ( + "NO_GRAPH" if case == "unavailable" else "NAVIGATION_ONLY" + )) + self.assertEqual( + recorded["source_fallbacks"][0]["action"], "SEARCH_SOURCE" + ) + + def test_v2_schema_is_frozen_for_historical_reads(self) -> None: + frozen = ROOT / "schemas/code-intelligence-record-v2.schema.json" + self.assertTrue(frozen.is_file()) + self.assertEqual(json.loads(frozen.read_text(encoding="utf-8"))["title"], + "Polaris Code Intelligence record v2") + + self.initialize_task() + value = self.v2_record() + path = self.repo / ".polaris/tasks/TASK-0001/code-intelligence/r001/planning.json" + write_json_atomic(path, value) + original = path.read_bytes() + protocol = importlib.import_module("internal.code_intelligence_protocol") + validated = protocol.validate_historical_v2_record_value( + self.repo, "TASK-0001", path, value, ROOT + ) + self.assertEqual(validated["record_version"], 2) + self.assertEqual(path.read_bytes(), original) + def set_protocol_version(self, version: str) -> None: project_path = self.repo / ".polaris/project.json" project = json.loads(project_path.read_text(encoding="utf-8")) @@ -982,7 +1252,7 @@ def test_legacy_v1_records_remain_readable_but_cannot_be_written(self) -> None: } self.assertEqual(legacy_validator(self.repo, "TASK-0001", value, ROOT)["record_version"], 1) with self.assertRaisesRegex( - InputFailure, "new Code Intelligence records must use record_version 2" + InputFailure, "new Code Intelligence records must use record_version 3" ): record(self.repo, "TASK-0001", value, ROOT) @@ -1052,11 +1322,10 @@ def test_migration_retires_v1_records_without_rewriting_them(self) -> None: current = self.v2_record() current.update({"stage": "IMPLEMENTATION", "artifact_attempt": 1}) - result = record(self.repo, "TASK-0001", current, ROOT) - self.assertEqual( - Path(result["path"]).parts[-3:], - ("code-intelligence", "r001", "implementation-001.json"), - ) + with self.assertRaisesRegex( + InputFailure, "new Code Intelligence records must use record_version 3" + ): + record(self.repo, "TASK-0001", current, ROOT) def test_migration_rejects_noncanonical_v2_record_paths(self) -> None: """Migration scans only the canonical Code Intelligence record layout.""" @@ -2242,7 +2511,7 @@ def test_project_marker_rejects_unsafe_paths_and_symlinks(self) -> None: with self.assertRaisesRegex(RuleFailure, "must not be a symlink"): _project_marker_path(self.repo, ".codegraph") - def test_record_cli_requires_task_id_and_input(self) -> None: + def test_record_cli_requires_task_id_bundle_and_annotations(self) -> None: completed = subprocess.run( [ sys.executable, @@ -2260,7 +2529,10 @@ def test_record_cli_requires_task_id_and_input(self) -> None: self.assertEqual(completed.returncode, 2) payload = json.loads(completed.stdout) self.assertEqual(payload["status"], "ERROR") - self.assertEqual(payload["message"], "recording requires task_id and --input") + self.assertEqual( + payload["message"], + "recording requires task_id, --bundle, and --annotations", + ) def test_old_product_tool_names_are_absent_from_descriptor(self) -> None: text = (ROOT / "providers/code-intelligence/codegraph.json").read_text( diff --git a/tests/test_core.py b/tests/test_core.py index e00dfd4..695c11d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -29,10 +29,12 @@ add_provider, load_config, record as record_code_intelligence, + record_proxy_bundle, select_provider, validate_record_value, validate_static_configuration, ) +from internal.code_intelligence_proxy import execute_proxy_query # noqa: E402 from internal.host_adapters import ( # noqa: E402 discover_skills, load_host_adapters, @@ -346,6 +348,53 @@ def enter_implementing(self) -> None: self.enter_planned() self.register_implementation_handoff() + def record_current_proxy_intelligence(self, stage: str) -> dict[str, object]: + """Create genuine current v3 evidence for an active implementation stage.""" + (self.repo / ".codegraph").mkdir(exist_ok=True) + status = json.dumps({ + "initialized": True, + "projectPath": str(self.repo.resolve()), + "pendingChanges": {"added": 0, "modified": 0, "removed": 0}, + "worktreeMismatch": None, + "index": { + "state": "complete", + "pendingRefs": 0, + "reindexRecommended": False, + }, + }) + responses = [ + subprocess.CompletedProcess([], 0, status, ""), + subprocess.CompletedProcess([], 0, "graph context\n", ""), + subprocess.CompletedProcess([], 0, status, ""), + ] + + def runner( + _command: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: + return responses.pop(0) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + query = execute_proxy_query( + self.repo, + "TASK-0001", + stage, + "CIQ-001", + "bind final subject", + "final subject symbols", + False, + runner=runner, + ) + return record_proxy_bundle( + self.repo, + "TASK-0001", + query["bundle_path"], + {"summary": "Current graph context.", "symbols": [], "source_fallbacks": []}, + ROOT, + ) + def register_implementation_handoff(self) -> dict[str, object]: """Register the next deterministic handoff for initial work or rework.""" handoff = build_implementation_handoff(self.repo, "TASK-0001") @@ -2554,7 +2603,7 @@ def test_code_intelligence_v1_record_is_read_only_historical_evidence(self) -> N """v1 精简记录升级后仍可读取,并绑定任务、提交和安全路径。""" base = run_git(self.repo, "rev-parse", "HEAD") value = read_json( - ROOT / "templates" / "task-sources" / "code-intelligence-record.json" + ROOT / "tests" / "fixtures" / "code-intelligence-record-v2.json" ) value["record_version"] = 1 value.pop("sync") @@ -2567,7 +2616,7 @@ def test_code_intelligence_v1_record_is_read_only_historical_evidence(self) -> N validate_record_value(self.repo, "TASK-0001", value, ROOT)["status"], "UNAVAILABLE", ) - with self.assertRaisesRegex(InputFailure, "record_version 2"): + with self.assertRaisesRegex(InputFailure, "record_version 3"): record_code_intelligence(self.repo, "TASK-0001", value, ROOT) invalid = copy.deepcopy(value) @@ -4351,22 +4400,8 @@ def test_implementation_and_final_documentation_subjects_are_bound(self) -> None final_head = run_git(self.repo, "rev-parse", "HEAD") final_diff_hash = subject_diff_hash(self.repo, base, final_head) - implementation_intelligence = read_json( - ROOT / "templates" / "task-sources" / "code-intelligence-record.json" - ) - implementation_intelligence.update( - { - "stage": "IMPLEMENTATION", - "artifact_attempt": 1, - "target": { - "base_commit": base, - "head_commit": final_head, - "diff_hash": final_diff_hash, - }, - } - ) - implementation_intelligence_result = record_code_intelligence( - self.repo, "TASK-0001", implementation_intelligence, ROOT + implementation_intelligence_result = self.record_current_proxy_intelligence( + "IMPLEMENTATION" ) implementation_intelligence_path = Path( implementation_intelligence_result["path"] @@ -4374,26 +4409,14 @@ def test_implementation_and_final_documentation_subjects_are_bound(self) -> None implementation_path = self.task / "implementations" / "r001" / "attempt-001.json" implementation = self.implementation_value(base, final_head, "impl-session") implementation["code_intelligence"] = { - "path": implementation_intelligence_path.relative_to(self.task).as_posix(), + "path": implementation_intelligence_path.relative_to( + self.task.resolve() + ).as_posix(), "sha256": file_sha256(implementation_intelligence_path), } write_json_atomic(implementation_path, implementation) - documentation_intelligence = read_json( - ROOT / "templates" / "task-sources" / "code-intelligence-record.json" - ) - documentation_intelligence.update( - { - "stage": "DOCUMENTATION_SYNC", - "artifact_attempt": 1, - "target": { - "base_commit": base, - "head_commit": final_head, - "diff_hash": final_diff_hash, - }, - } - ) - documentation_intelligence_result = record_code_intelligence( - self.repo, "TASK-0001", documentation_intelligence, ROOT + documentation_intelligence_result = self.record_current_proxy_intelligence( + "DOCUMENTATION_SYNC" ) documentation_intelligence_path = Path( documentation_intelligence_result["path"] @@ -4401,7 +4424,9 @@ def test_implementation_and_final_documentation_subjects_are_bound(self) -> None knowledge_path = self.task / "knowledge" / "r001" / "knowledge-delta-001.json" knowledge = self.knowledge_value(1, base, final_head) knowledge["code_intelligence"] = { - "path": documentation_intelligence_path.relative_to(self.task).as_posix(), + "path": documentation_intelligence_path.relative_to( + self.task.resolve() + ).as_posix(), "sha256": file_sha256(documentation_intelligence_path), } knowledge["entries"][0].update( From 9400561868e0e2774006861c0c1ad0c5b969e20c Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 03:18:28 +0800 Subject: [PATCH 07/28] feat: register the project CodeGraph proxy --- hosts/claude-code/adapter.json | 13 +- hosts/codex/adapter.json | 13 +- schemas/host-adapter.schema.json | 39 ++- scripts/init_project.py | 5 + scripts/internal/host_adapters.py | 17 ++ scripts/internal/project_mcp_registration.py | 195 ++++++++++++++ scripts/validate_project.py | 9 + scripts/vendor_project.py | 13 +- tests/test_core.py | 254 ++++++++++++++++++- 9 files changed, 551 insertions(+), 7 deletions(-) create mode 100644 scripts/internal/project_mcp_registration.py diff --git a/hosts/claude-code/adapter.json b/hosts/claude-code/adapter.json index 760de14..f328e98 100644 --- a/hosts/claude-code/adapter.json +++ b/hosts/claude-code/adapter.json @@ -1,5 +1,5 @@ { - "adapter_version": 2, + "adapter_version": 3, "host_id": "claude-code", "display_name": "Claude Code", "skill_target": ".claude/skills", @@ -17,6 +17,17 @@ ], "skill_overlay_root": null, "skill_appendix_root": "skill-appendices", + "project_mcp": { + "server_id": "polaris-codegraph", + "format": "claude-json", + "target": ".mcp.json", + "command": "python3", + "args": [ + "tools/polaris/scripts/code_intelligence_mcp.py", + "--repo", + "." + ] + }, "files": [ { "source": "agents/polaris-implementer.md", diff --git a/hosts/codex/adapter.json b/hosts/codex/adapter.json index 3ebf39c..d87c0ee 100644 --- a/hosts/codex/adapter.json +++ b/hosts/codex/adapter.json @@ -1,5 +1,5 @@ { - "adapter_version": 2, + "adapter_version": 3, "host_id": "codex", "display_name": "Codex", "skill_target": ".agents/skills", @@ -15,5 +15,16 @@ "entry_frontmatter": [], "skill_overlay_root": "skill-overlays", "skill_appendix_root": "skill-appendices", + "project_mcp": { + "server_id": "polaris-codegraph", + "format": "codex-toml", + "target": ".codex/config.toml", + "command": "python3", + "args": [ + "tools/polaris/scripts/code_intelligence_mcp.py", + "--repo", + "." + ] + }, "files": [] } diff --git a/schemas/host-adapter.schema.json b/schemas/host-adapter.schema.json index 75a206f..3c88c8a 100644 --- a/schemas/host-adapter.schema.json +++ b/schemas/host-adapter.schema.json @@ -13,12 +13,13 @@ "entry_frontmatter", "skill_overlay_root", "skill_appendix_root", + "project_mcp", "files" ], "additionalProperties": false, "properties": { "adapter_version": { - "const": 2 + "const": 3 }, "host_id": { "type": "string", @@ -86,6 +87,42 @@ "null" ] }, + "project_mcp": { + "type": "object", + "required": [ + "server_id", + "format", + "target", + "command", + "args" + ], + "additionalProperties": false, + "properties": { + "server_id": { + "const": "polaris-codegraph" + }, + "format": { + "enum": [ + "codex-toml", + "claude-json" + ] + }, + "target": { + "type": "string", + "minLength": 1 + }, + "command": { + "const": "python3" + }, + "args": { + "const": [ + "tools/polaris/scripts/code_intelligence_mcp.py", + "--repo", + "." + ] + } + } + }, "files": { "type": "array", "items": { diff --git a/scripts/init_project.py b/scripts/init_project.py index ccd5c93..b735523 100644 --- a/scripts/init_project.py +++ b/scripts/init_project.py @@ -9,6 +9,7 @@ from pathlib import Path from internal.host_adapters import adapter_file_target, load_host_adapters +from internal.project_mcp_registration import merge_project_mcp, project_mcp_target from internal.polaris_core import ( InputFailure, ensure_gitignore_rule, @@ -16,6 +17,7 @@ read_json, run_main, write_json_atomic, + write_text_atomic, ) from internal.task_layout import ( ARCHIVED_RUNTIME_IGNORE_PATTERN, @@ -55,6 +57,9 @@ def initialize(repo: Path, project_id: str | None = None) -> dict[str, str]: if not destination.exists(): destination.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(adapter["adapter_root"] / item["source"], destination) + if root == repo / "tools" / "polaris": + destination = project_mcp_target(repo, adapter) + write_text_atomic(destination, merge_project_mcp(repo, adapter)) ensure_gitignore_rule(repo, RUNTIME_IGNORE_PATTERN) ensure_gitignore_rule(repo, ARCHIVED_RUNTIME_IGNORE_PATTERN) return {"message": f"initialized Polaris project {project_id}"} diff --git a/scripts/internal/host_adapters.py b/scripts/internal/host_adapters.py index 7ce6a4f..238a88e 100644 --- a/scripts/internal/host_adapters.py +++ b/scripts/internal/host_adapters.py @@ -172,6 +172,23 @@ def load_host_adapters(root: Path) -> list[dict[str, Any]]: adapter["skill_target"], "skill_target" ) current_targets = [(skill_target, f"{host_id} skill_target")] + project_mcp = adapter["project_mcp"] + project_mcp_target = _relative_path( + project_mcp["target"], "project_mcp.target" + ) + if project_mcp["server_id"] != "polaris-codegraph": + raise RuleFailure(f"host adapter has an invalid MCP server ID: {path}") + if project_mcp["format"] not in {"codex-toml", "claude-json"}: + raise RuleFailure(f"host adapter has an invalid MCP format: {path}") + if project_mcp["command"] != "python3": + raise RuleFailure(f"host adapter has an invalid MCP command: {path}") + if project_mcp["args"] != [ + "tools/polaris/scripts/code_intelligence_mcp.py", + "--repo", + ".", + ]: + raise RuleFailure(f"host adapter has invalid MCP arguments: {path}") + current_targets.append((project_mcp_target, f"{host_id} project_mcp")) overlay = adapter["skill_overlay_root"] if overlay is not None: _validate_overlay(path.parent, overlay, root, available_skills) diff --git a/scripts/internal/project_mcp_registration.py b/scripts/internal/project_mcp_registration.py new file mode 100644 index 0000000..923f4c0 --- /dev/null +++ b/scripts/internal/project_mcp_registration.py @@ -0,0 +1,195 @@ +"""Render and validate project-local Polaris MCP registrations.""" + +from __future__ import annotations + +import json +import re +import tomllib +from pathlib import Path +from typing import Any + +from .path_security import confined_target, require_regular_file +from .polaris_core import InputFailure, RuleFailure + + +SERVER_ID = "polaris-codegraph" +TOOL_NAME = "polaris_codegraph_explore" +LAUNCHER = "tools/polaris/scripts/code_intelligence_mcp.py" +ARGS = [LAUNCHER, "--repo", "."] +CODEX_START = f"# POLARIS_MCP_START {SERVER_ID}" +CODEX_END = f"# POLARIS_MCP_END {SERVER_ID}" + + +def project_mcp_target(repo: Path, adapter: dict[str, Any]) -> Path: + value = adapter["project_mcp"]["target"] + relative = Path(value) + if relative.is_absolute() or not relative.parts or ".." in relative.parts: + raise RuleFailure(f"project MCP target must be a safe relative path: {value}") + return confined_target(repo, repo / relative, "project MCP target") + + +def _definition(adapter: dict[str, Any]) -> dict[str, Any]: + registration = adapter["project_mcp"] + if registration["server_id"] != SERVER_ID: + raise RuleFailure("project MCP registration has an invalid server ID") + if registration["command"] != "python3" or registration["args"] != ARGS: + raise RuleFailure("project MCP registration has an invalid launcher") + return registration + + +def _codex_definition(adapter: dict[str, Any]) -> dict[str, Any]: + registration = _definition(adapter) + return { + "command": registration["command"], + "args": registration["args"], + "cwd": ".", + "enabled": True, + "required": False, + "enabled_tools": [TOOL_NAME], + } + + +def _codex_block(adapter: dict[str, Any]) -> str: + definition = _codex_definition(adapter) + args = json.dumps(definition["args"], ensure_ascii=False) + tools = json.dumps(definition["enabled_tools"], ensure_ascii=False) + return ( + f"{CODEX_START}\n" + f"[mcp_servers.{SERVER_ID}]\n" + f'command = "{definition["command"]}"\n' + f"args = {args}\n" + f'cwd = "{definition["cwd"]}"\n' + "enabled = true\n" + "required = false\n" + f"enabled_tools = {tools}\n" + f"{CODEX_END}\n" + ) + + +def _parse_toml(source: str) -> dict[str, Any]: + try: + value = tomllib.loads(source) + except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc: + raise RuleFailure(f"project MCP TOML is invalid: {exc}") from exc + if not isinstance(value, dict): + raise RuleFailure("project MCP TOML root must be a table") + return value + + +def _merge_codex(adapter: dict[str, Any], source: str) -> str: + parsed = _parse_toml(source) + starts = source.count(CODEX_START) + ends = source.count(CODEX_END) + if starts != ends or starts > 1: + raise RuleFailure("project MCP TOML has malformed or duplicate managed markers") + block = _codex_block(adapter) + if starts == 1: + pattern = re.compile( + rf"(?m)^{re.escape(CODEX_START)}\n.*?^{re.escape(CODEX_END)}(?:\n|$)", + re.DOTALL, + ) + if not pattern.search(source): + raise RuleFailure("project MCP TOML has malformed managed markers") + rendered = pattern.sub(block, source, count=1) + else: + servers = parsed.get("mcp_servers", {}) + if not isinstance(servers, dict): + raise RuleFailure("project MCP TOML mcp_servers must be a table") + if SERVER_ID in servers: + raise RuleFailure( + f"project MCP TOML has a conflicting unmanaged {SERVER_ID} entry" + ) + separator = "" if not source else ("\n" if source.endswith("\n") else "\n\n") + rendered = source + separator + block + final = _parse_toml(rendered) + servers = final.get("mcp_servers") + if not isinstance(servers, dict) or servers.get(SERVER_ID) != _codex_definition( + adapter + ): + raise RuleFailure("project MCP TOML did not render the exact Polaris entry") + return rendered + + +def _claude_definition(adapter: dict[str, Any]) -> dict[str, Any]: + registration = _definition(adapter) + return { + "type": "stdio", + "command": registration["command"], + "args": registration["args"], + "env": {}, + } + + +def _parse_json(source: str) -> dict[str, Any]: + try: + value = json.loads(source) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise RuleFailure(f"project MCP JSON is invalid: {exc}") from exc + if not isinstance(value, dict): + raise RuleFailure("project MCP JSON root must be an object") + return value + + +def _merge_claude(adapter: dict[str, Any], source: str) -> str: + value = _parse_json(source or "{}") + servers = value.setdefault("mcpServers", {}) + if not isinstance(servers, dict): + raise RuleFailure("project MCP JSON mcpServers must be an object") + expected = _claude_definition(adapter) + existing = servers.get(SERVER_ID) + if existing is not None and existing != expected: + raise RuleFailure(f"project MCP JSON has a conflicting {SERVER_ID} entry") + servers[SERVER_ID] = expected + return json.dumps(value, ensure_ascii=False, indent=4) + "\n" + + +def merge_project_mcp( + repo: Path, + adapter: dict[str, Any], + source_text: str | None = None, +) -> str: + registration = _definition(adapter) + target = project_mcp_target(repo, adapter) + if source_text is None: + if target.exists(): + require_regular_file(target, "project MCP configuration") + try: + source_text = target.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + raise InputFailure( + f"project MCP configuration is not UTF-8: {target}" + ) from exc + else: + source_text = "" + if registration["format"] == "codex-toml": + return _merge_codex(adapter, source_text) + if registration["format"] == "claude-json": + return _merge_claude(adapter, source_text) + raise RuleFailure(f"unsupported project MCP format: {registration['format']}") + + +def validate_project_mcp(repo: Path, adapter: dict[str, Any]) -> None: + registration = _definition(adapter) + target = project_mcp_target(repo, adapter) + require_regular_file(target, "project MCP configuration") + source = target.read_text(encoding="utf-8") + if registration["format"] == "codex-toml": + if source.count(CODEX_START) != 1 or source.count(CODEX_END) != 1: + raise RuleFailure("project MCP TOML lacks the unique managed block") + parsed = _parse_toml(source) + servers = parsed.get("mcp_servers") + if not isinstance(servers, dict) or servers.get(SERVER_ID) != _codex_definition( + adapter + ): + raise RuleFailure("project MCP TOML Polaris entry is invalid") + elif registration["format"] == "claude-json": + parsed = _parse_json(source) + servers = parsed.get("mcpServers") + if not isinstance(servers, dict) or servers.get(SERVER_ID) != _claude_definition( + adapter + ): + raise RuleFailure("project MCP JSON Polaris entry is invalid") + else: + raise RuleFailure(f"unsupported project MCP format: {registration['format']}") + launcher = confined_target(repo, repo / LAUNCHER, "project MCP launcher") + require_regular_file(launcher, "project MCP launcher") diff --git a/scripts/validate_project.py b/scripts/validate_project.py index 4f7820f..3ddf08b 100644 --- a/scripts/validate_project.py +++ b/scripts/validate_project.py @@ -14,6 +14,7 @@ load_host_adapters, ) from internal.install_manifest import validate_install_manifest +from internal.project_mcp_registration import project_mcp_target, validate_project_mcp from internal.code_intelligence_protocol import validate_static_configuration from internal.migration_protocol import validate_completed_migrations from internal.polaris_core import RuleFailure, protocol_root, read_json, run_main, validate_json_file @@ -126,6 +127,14 @@ def validate(repo: Path) -> dict[str, object]: f"{adapter['display_name']} adapter file is not {ownership}: " f"{relative}" ) + registration = project_mcp_target(repo, adapter) + relative = registration.relative_to(repo).as_posix() + if relative not in preserved_paths: + raise RuleFailure( + f"{adapter['display_name']} project MCP configuration is not preserved: " + f"{relative}" + ) + validate_project_mcp(repo, adapter) listed = set(project["active_tasks"]) validate_task_locations(repo, listed) diff --git a/scripts/vendor_project.py b/scripts/vendor_project.py index 185d67a..8387078 100644 --- a/scripts/vendor_project.py +++ b/scripts/vendor_project.py @@ -28,6 +28,7 @@ validate_install_manifest, write_install_manifest, ) +from internal.project_mcp_registration import merge_project_mcp, project_mcp_target from internal.polaris_core import ( InputFailure, RuleFailure, @@ -69,6 +70,7 @@ def _polaris_destinations( for item in adapter["files"] if item["overwrite"] ) + destinations.append(project_mcp_target(target, adapter)) return destinations @@ -280,6 +282,10 @@ def _stage_install( else: preserved_paths.append(destination) + registration = project_mcp_target(stage, adapter) + write_text_atomic(registration, merge_project_mcp(target, adapter)) + preserved_paths.append(registration) + tools_target = confined_target( stage, stage / "tools" / "polaris", "staged vendored protocol target" ) @@ -407,8 +413,13 @@ def vendor( previous_manifest = ( read_install_manifest(target, source) if manifest_path.is_file() else None ) + registration_targets = { + project_mcp_target(target, adapter) for adapter in adapters + } if not force and any( - path.exists() for path in _polaris_destinations(target, adapters, skills) + path.exists() + for path in _polaris_destinations(target, adapters, skills) + if path not in registration_targets ): raise InputFailure("vendored Polaris files already exist; use --force to update") if force and previous_manifest is not None and not discard_managed_changes: diff --git a/tests/test_core.py b/tests/test_core.py index 695c11d..a7558ad 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2741,6 +2741,8 @@ def test_risk_flag_requires_r2(self) -> None: def test_vendored_target_is_self_contained(self) -> None: """目标仓库 vendoring 后同时包含 Codex、Claude Code 与机械协议。""" + self.assertFalse((self.repo / ".codex" / "config.toml").exists()) + self.assertFalse((self.repo / ".mcp.json").exists()) vendor(ROOT, self.repo, False) for adapter in load_host_adapters(ROOT): skill_root = self.repo / str(adapter["skill_target"]) @@ -2772,6 +2774,46 @@ def test_vendored_target_is_self_contained(self) -> None: result = validate_project(self.repo) self.assertEqual(result["active_tasks"], 1) + def test_vendor_preserves_and_validates_project_mcp_configuration(self) -> None: + """vendoring 注册项目代理,把宿主配置列为保留文件并校验启动边界。""" + from internal.project_mcp_registration import validate_project_mcp + + codex_path = self.repo / ".codex" / "config.toml" + codex_path.parent.mkdir() + codex_path.write_text( + 'model = "gpt-5"\n[mcp_servers.other]\ncommand = "other"\n', + encoding="utf-8", + ) + claude_path = self.repo / ".mcp.json" + write_json_atomic( + claude_path, + { + "permissions": {"allow": ["Read"]}, + "mcpServers": {"other": {"command": "other", "args": []}}, + }, + ) + + vendor(ROOT, self.repo, False) + + adapters = load_host_adapters(self.repo / "tools" / "polaris") + for adapter in adapters: + validate_project_mcp(self.repo, adapter) + self.assertIn('model = "gpt-5"', codex_path.read_text(encoding="utf-8")) + claude = read_json(claude_path) + self.assertEqual(claude["permissions"], {"allow": ["Read"]}) + self.assertIn("other", claude["mcpServers"]) + manifest = read_json( + self.repo / "tools" / "polaris" / "install-manifest.json" + ) + self.assertIn(".codex/config.toml", manifest["preserved_files"]) + self.assertIn(".mcp.json", manifest["preserved_files"]) + self.assertEqual(validate_project(self.repo)["active_tasks"], 1) + + claude["mcpServers"]["polaris-codegraph"]["args"][-1] = "../other" + write_json_atomic(claude_path, claude) + with self.assertRaisesRegex(RuleFailure, "Polaris entry is invalid"): + validate_project(self.repo) + def test_doctor_reports_a_healthy_vendored_project_without_writing(self) -> None: """Doctor 聚合健康检查并通过报告 Schema,且诊断前后项目文件完全不变。""" vendor(ROOT, self.repo, False) @@ -3016,8 +3058,12 @@ def test_vendor_rolls_back_after_partial_apply_failure(self) -> None: vendor(ROOT, self.repo, False) manifest_path = self.repo / "tools" / "polaris" / "install-manifest.json" skill_path = self.repo / ".agents" / "skills" / "engineering-task" / "SKILL.md" + codex_path = self.repo / ".codex" / "config.toml" + claude_mcp_path = self.repo / ".mcp.json" original_manifest = manifest_path.read_bytes() original_skill = skill_path.read_bytes() + original_codex = codex_path.read_bytes() + original_claude_mcp = claude_mcp_path.read_bytes() with tempfile.TemporaryDirectory(prefix="polaris-vendor-source-") as temp: source = Path(temp) / "source" shutil.copytree( @@ -3048,6 +3094,8 @@ def fail_after_copy(staged: Path, destination: Path) -> None: self.assertEqual(manifest_path.read_bytes(), original_manifest) self.assertEqual(skill_path.read_bytes(), original_skill) + self.assertEqual(codex_path.read_bytes(), original_codex) + self.assertEqual(claude_mcp_path.read_bytes(), original_claude_mcp) self.assertEqual(validate_project(self.repo)["active_tasks"], 1) self.assertEqual( list( @@ -3232,6 +3280,36 @@ def test_host_adapters_render_from_one_host_neutral_skill_source(self) -> None: adapters = {item["host_id"]: item for item in load_host_adapters(ROOT)} self.assertEqual(set(adapters), {"codex", "claude-code"}) + self.assertIn("project_mcp", adapters["codex"]) + self.assertIn("project_mcp", adapters["claude-code"]) + self.assertEqual( + adapters["codex"]["project_mcp"], + { + "server_id": "polaris-codegraph", + "format": "codex-toml", + "target": ".codex/config.toml", + "command": "python3", + "args": [ + "tools/polaris/scripts/code_intelligence_mcp.py", + "--repo", + ".", + ], + }, + ) + self.assertEqual( + adapters["claude-code"]["project_mcp"], + { + "server_id": "polaris-codegraph", + "format": "claude-json", + "target": ".mcp.json", + "command": "python3", + "args": [ + "tools/polaris/scripts/code_intelligence_mcp.py", + "--repo", + ".", + ], + }, + ) self.assertFalse((ROOT / "hosts" / "codex" / "skills").exists()) codex = render_skill(source, "engineering-task", adapters["codex"]) claude = render_skill(source, "engineering-task", adapters["claude-code"]) @@ -3266,7 +3344,7 @@ def test_host_adapter_contract_rejects_invalid_or_conflicting_manifests(self) -> def adapter(host_id: str) -> dict[str, object]: return { - "adapter_version": 2, + "adapter_version": 3, "host_id": host_id, "display_name": host_id, "skill_target": f".{host_id}/skills", @@ -3282,6 +3360,17 @@ def adapter(host_id: str) -> dict[str, object]: "entry_frontmatter": [], "skill_overlay_root": None, "skill_appendix_root": None, + "project_mcp": { + "server_id": "polaris-codegraph", + "format": "claude-json", + "target": f".{host_id}/mcp.json", + "command": "python3", + "args": [ + "tools/polaris/scripts/code_intelligence_mcp.py", + "--repo", + ".", + ], + }, "files": [ { "source": "bridge.md", @@ -3293,7 +3382,7 @@ def adapter(host_id: str) -> dict[str, object]: cases = { "unknown version": lambda first, _second: first.update( - {"adapter_version": 3} + {"adapter_version": 4} ), "blank prefix": lambda first, _second: first.update( {"invocation_prefix": ""} @@ -3304,6 +3393,27 @@ def adapter(host_id: str) -> dict[str, object]: "overlapping target": lambda first, second: second["files"][0].update( {"target": first["skill_target"]} ), + "unknown MCP format": lambda first, _second: first["project_mcp"].update( + {"format": "yaml"} + ), + "wrong MCP server": lambda first, _second: first["project_mcp"].update( + {"server_id": "other"} + ), + "unsafe MCP target": lambda first, _second: first["project_mcp"].update( + {"target": "../config.json"} + ), + "wrong MCP launcher": lambda first, _second: first["project_mcp"].update( + {"args": ["scripts/code_intelligence_mcp.py", "--repo", "."]} + ), + "missing fixed repo": lambda first, _second: first["project_mcp"].update( + {"args": ["tools/polaris/scripts/code_intelligence_mcp.py"]} + ), + "duplicate MCP target": lambda first, second: second["project_mcp"].update( + {"target": first["project_mcp"]["target"]} + ), + "MCP overlaps skill": lambda first, _second: first["project_mcp"].update( + {"target": first["skill_target"]} + ), } for name, mutate in cases.items(): with self.subTest(case=name), tempfile.TemporaryDirectory( @@ -3329,6 +3439,118 @@ def adapter(host_id: str) -> dict[str, object]: with self.assertRaises(RuleFailure): load_host_adapters(root) + def test_project_mcp_registration_preserves_unrelated_host_configuration( + self, + ) -> None: + """项目 MCP 合并只管理 Polaris 条目,并且重复执行保持稳定。""" + from internal.project_mcp_registration import merge_project_mcp + + adapters = {item["host_id"]: item for item in load_host_adapters(ROOT)} + codex_source = 'model = "gpt-5"\n[mcp_servers.other]\ncommand = "other"\n' + codex_expected = codex_source + """ +# POLARIS_MCP_START polaris-codegraph +[mcp_servers.polaris-codegraph] +command = "python3" +args = ["tools/polaris/scripts/code_intelligence_mcp.py", "--repo", "."] +cwd = "." +enabled = true +required = false +enabled_tools = ["polaris_codegraph_explore"] +# POLARIS_MCP_END polaris-codegraph +""" + codex = merge_project_mcp( + self.repo, adapters["codex"], source_text=codex_source + ) + self.assertEqual(codex, codex_expected) + self.assertEqual( + merge_project_mcp(self.repo, adapters["codex"], source_text=codex), + codex_expected, + ) + stale_managed_block = codex.replace("enabled = true", "enabled = false") + self.assertEqual( + merge_project_mcp( + self.repo, + adapters["codex"], + source_text=stale_managed_block, + ), + codex_expected, + ) + + claude_source = json.dumps( + { + "permissions": {"allow": ["Read"]}, + "mcpServers": {"other": {"command": "other", "args": []}}, + } + ) + claude = merge_project_mcp( + self.repo, adapters["claude-code"], source_text=claude_source + ) + claude_value = json.loads(claude) + self.assertEqual(claude_value["permissions"], {"allow": ["Read"]}) + self.assertEqual( + claude_value["mcpServers"]["other"], + {"command": "other", "args": []}, + ) + self.assertEqual( + claude_value["mcpServers"]["polaris-codegraph"], + { + "type": "stdio", + "command": "python3", + "args": [ + "tools/polaris/scripts/code_intelligence_mcp.py", + "--repo", + ".", + ], + "env": {}, + }, + ) + self.assertEqual( + merge_project_mcp(self.repo, adapters["claude-code"], source_text=claude), + claude, + ) + + def test_project_mcp_registration_rejects_unsafe_or_conflicting_configuration( + self, + ) -> None: + """项目 MCP 拒绝损坏配置、非受管同名项与 symlink 目标。""" + from internal.project_mcp_registration import merge_project_mcp + + adapters = {item["host_id"]: item for item in load_host_adapters(ROOT)} + cases = ( + ( + adapters["codex"], + '[mcp_servers.other\ncommand = "broken"\n', + "TOML", + ), + ( + adapters["codex"], + '[mcp_servers.polaris-codegraph]\ncommand = "other"\n', + "conflicting unmanaged", + ), + (adapters["claude-code"], "{broken", "JSON"), + ( + adapters["claude-code"], + json.dumps( + {"mcpServers": {"polaris-codegraph": {"command": "other"}}} + ), + "conflicting", + ), + ) + for adapter, source, message in cases: + with self.subTest(format=adapter["project_mcp"]["format"]): + with self.assertRaisesRegex(RuleFailure, message): + merge_project_mcp(self.repo, adapter, source_text=source) + + target = self.repo / ".mcp.json" + outside = self.repo / "outside-mcp.json" + outside.write_text("{}\n", encoding="utf-8") + try: + target.symlink_to(outside) + except (NotImplementedError, OSError) as exc: + self.skipTest(f"file symlink creation is unavailable: {exc}") + with self.assertRaisesRegex(RuleFailure, "symlink"): + merge_project_mcp(self.repo, adapters["claude-code"]) + def test_host_adapter_hardening_rejects_entry_overlay_and_capability_errors(self) -> None: """入口必须存在,overlay 不得覆写 Skill,worker 能力依赖必须自洽。""" cases = { @@ -3471,7 +3693,7 @@ def test_vendor_and_validator_discover_a_third_host_without_code_changes(self) - write_json_atomic( synthetic_root / "adapter.json", { - "adapter_version": 2, + "adapter_version": 3, "host_id": "synthetic", "display_name": "Synthetic Host", "skill_target": ".synthetic/skills", @@ -3487,6 +3709,17 @@ def test_vendor_and_validator_discover_a_third_host_without_code_changes(self) - "entry_frontmatter": [], "skill_overlay_root": None, "skill_appendix_root": None, + "project_mcp": { + "server_id": "polaris-codegraph", + "format": "claude-json", + "target": ".synthetic/mcp.json", + "command": "python3", + "args": [ + "tools/polaris/scripts/code_intelligence_mcp.py", + "--repo", + ".", + ], + }, "files": [], }, ) @@ -3510,6 +3743,16 @@ def test_force_vendor_preserves_unrelated_claude_configuration(self) -> None: (unrelated_skill / "SKILL.md").write_text("# Keep me\n", encoding="utf-8") unrelated_agent.write_text("# Keep me\n", encoding="utf-8") (self.repo / "CLAUDE.md").write_text("# Project-owned Claude rules\n", encoding="utf-8") + codex_config = self.repo / ".codex" / "config.toml" + codex_config.parent.mkdir() + codex_config.write_text('model = "gpt-5"\n', encoding="utf-8") + write_json_atomic( + self.repo / ".mcp.json", + { + "permissions": {"allow": ["Read"]}, + "mcpServers": {"other": {"command": "other", "args": []}}, + }, + ) vendor(ROOT, self.repo, False) vendor(ROOT, self.repo, True) self.assertEqual( @@ -3520,6 +3763,11 @@ def test_force_vendor_preserves_unrelated_claude_configuration(self) -> None: (self.repo / "CLAUDE.md").read_text(encoding="utf-8"), "# Project-owned Claude rules\n", ) + self.assertIn('model = "gpt-5"', codex_config.read_text(encoding="utf-8")) + claude_mcp = read_json(self.repo / ".mcp.json") + self.assertEqual(claude_mcp["permissions"], {"allow": ["Read"]}) + self.assertIn("other", claude_mcp["mcpServers"]) + self.assertIn("polaris-codegraph", claude_mcp["mcpServers"]) def test_validate_project_requires_complete_claude_adapter(self) -> None: """vendored 项目缺少 Claude Skill 或 worker 定义时机械拒绝。""" From 1fe32430fb03a0a9ea4922674d31663a71322211 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 03:30:33 +0800 Subject: [PATCH 08/28] feat: migrate CodeGraph evidence to protocol 0.1.21 --- README.md | 4 +- README.zh-CN.md | 4 +- VERSION | 2 +- docs/USAGE.md | 9 +- plan.md | 4 +- pyproject.toml | 2 +- scripts/internal/migration_protocol.py | 40 +++++++- templates/project.json | 2 +- templates/task-sources/state.json | 2 +- templates/task/state.json | 2 +- tests/test_codegraph.py | 125 +++++++++++++++++++++++-- tests/test_core.py | 51 ++++++++-- workflow/migrations.json | 9 ++ 13 files changed, 224 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 6a12b19..656d6ca 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ English | [简体中文](README.zh-CN.md) -> Current protocol version: `0.1.20` (in development); workflow version: `0.1.3` +> Current protocol version: `0.1.21` (in development); workflow version: `0.1.3` Polaris is a repo-native engineering workflow for coding agent hosts. It stores requirements, plans, implementation results, independent reviews, validation evidence, and task state in Git, then uses deterministic gates to prevent requirement drift, stale evidence, and agents declaring their own work complete. @@ -93,6 +93,8 @@ Run these commands from the target repository as appropriate. `codegraph init` c CodeGraph's watcher and connection reconciliation are the primary freshness mechanisms. Polaris records a limited conclusion at the time it checks: `CURRENT_AT_CHECK`, `PARTIAL_STALE`, `INDEX_STALE`, `NOT_VERIFIED`, or `UNAVAILABLE`; it never claims commit-exact graph freshness. A `PARTIAL_STALE` response names specific files: read each current file directly (`READ_SOURCE`), or inspect the registered Git diff if it was deleted (`INSPECT_GIT_DIFF`). For `INDEX_STALE` or `NOT_VERIFIED`, treat the graph only as a lead and search the repository plus Git (`SEARCH_SOURCE`). Validation remains graph-free and relies on source, Git, builds, tests, static checks, and Human Checks. +Protocol `0.1.21` adds the project-scoped Polaris CodeGraph proxy, host adapter v3 registration, and auditable Code Intelligence record v3 while leaving Workflow at `0.1.3`. Record v1 and v2 are immutable historical evidence only; new evidence is projected from a retained proxy bundle into v3. + ## v0.1 scope Polaris v0.1 includes: diff --git a/README.zh-CN.md b/README.zh-CN.md index 871a53d..1f04835 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,7 +2,7 @@ [English](README.md) | 简体中文 -> 当前协议版本:`0.1.20`(开发中);Workflow 版本:`0.1.3` +> 当前协议版本:`0.1.21`(开发中);Workflow 版本:`0.1.3` Polaris 是运行在 Coding Agent 宿主上的仓库原生工程工作流。它把需求、计划、实现、独立审查、验证和任务状态保存在 Git 仓库中,并通过确定性门禁防止需求漂移、证据过期和 Agent 自行宣布完成。 @@ -93,6 +93,8 @@ polaris code-intelligence add codegraph --repo . CodeGraph 的 watcher 和连接时 reconciliation 是正常情况下的实时更新机制。Polaris 只记录检查时的有限结论:`CURRENT_AT_CHECK`、`PARTIAL_STALE`、`INDEX_STALE`、`NOT_VERIFIED` 或 `UNAVAILABLE`,不会宣称与 Git commit 精确一致。`PARTIAL_STALE` 会精确列出待同步文件:当前普通文件必须直接读取并记录 `READ_SOURCE`;已删除文件必须检查注册 subject 的 Git diff 并记录 `INSPECT_GIT_DIFF`。`INDEX_STALE` 或 `NOT_VERIFIED` 时,图只能作为导航线索,Agent 必须通过仓库搜索和 Git 证据记录 `SEARCH_SOURCE`。Provider 不可用、status 不可读或 sync 失败都不阻断阶段;Validation 不调用 CodeGraph,仍以源码、Git、构建、测试、静态检查和 Human Check 为准。 +协议 `0.1.21` 新增项目级 Polaris CodeGraph 代理、Host Adapter v3 注册和可审计的 Code Intelligence record v3,Workflow 仍为 `0.1.3`。record v1/v2 仅作为不可变历史证据读取;新证据必须由保留的代理 bundle 投影为 v3。 + ## v0.1 边界 Polaris v0.1 包含: diff --git a/VERSION b/VERSION index baa9837..7906299 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.20 +0.1.21 diff --git a/docs/USAGE.md b/docs/USAGE.md index 9cc12c0..53b21f5 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -2,7 +2,7 @@ 本文面向希望在受支持 Coding Agent 宿主中使用 Polaris 管理软件工程任务的项目成员。当前内置 Codex 与 Claude Code 适配器;本文从首次接入讲到日常提出需求、独立 Implementation、进度查询、Review、验证、恢复与升级。 -> 当前协议版本:v0.1.20;Workflow 版本:v0.1.3。Polaris v0.1 是仓库原生的 Skills、宿主 worker 定义与 Python 脚本集合,并提供一个只分发到这些脚本的 `polaris` CLI;不提供后台服务或图形界面。 +> 当前协议版本:v0.1.21;Workflow 版本:v0.1.3。Polaris v0.1 是仓库原生的 Skills、宿主 worker 定义与 Python 脚本集合,并提供一个只分发到这些脚本的 `polaris` CLI;不提供后台服务或图形界面。 ## 1. 先理解 Polaris 保存什么 @@ -606,8 +606,9 @@ polaris migrate --repo . 1. `workflow/migrations.json` 是支持路径的唯一、append-only 注册表;历史步骤必须保留,以便校验已提交的迁移记录。一次命令只允许从当前项目版本迁移到 vendored 版本的一个显式相邻步骤,不推断、不跨级。 2. 注册步骤同时绑定源/目标 `polaris_version` 与 `workflow_version`。Migration protocol v2 支持仅更新版本,也支持显式替换冻结 workflow 并映射任务状态。 3. `0.1.19 → 0.1.20` 使用 `replace_version_and_workflow` 与 `append_mapped_workflow_event`:冻结 workflow 更新到 `0.1.3`,旧 `IMPLEMENTED` / `DOCS_SYNCED` 映射到 `IMPLEMENTING`,旧 `REVIEWED` 映射到 `VALIDATING`;旧 R0/R1 `VERIFIED` 也映射回 `VALIDATING`,以便通过 `PASS_AND_CLOSE` 重新提交关闭产物,R2 `VERIFIED` 保持不变。迁移事件记录源/目标状态及旧版本;旧 `events.jsonl` 行不可修改。 -4. `.polaris/migrations/MIG--to-.json` 先写为 `IN_PROGRESS`,全部投影更新后改为 `COMPLETED`。迁移锁会记录迁移/任务身份、主机名和 PID;若进程在中间终止,同一主机重新执行命令会接管已死亡的同迁移锁、验证并复用已经追加的事件,不会重复迁移。活跃进程、其他迁移或来源不明的锁不会被自动删除。 -5. 迁移完成后脚本自动运行项目校验;`validate_project.py` 会拒绝未完成记录、缺失/伪造的任务迁移事件或版本不一致。 +4. `0.1.20 → 0.1.21` 只替换协议版本,Workflow 保持 `0.1.3`。迁移会校验并清点 canonical v1/v2 Code Intelligence 历史记录的路径与 SHA-256,保持原字节不变;中断恢复前会重算清单,任何变化都会拒绝继续。v1/v2 此后仅可作为历史证据读取。 +5. `.polaris/migrations/MIG--to-.json` 先写为 `IN_PROGRESS`,全部投影更新后改为 `COMPLETED`。迁移锁会记录迁移/任务身份、主机名和 PID;若进程在中间终止,同一主机重新执行命令会接管已死亡的同迁移锁、验证并复用已经追加的事件,不会重复迁移。活跃进程、其他迁移或来源不明的锁不会被自动删除。 +6. 迁移完成后脚本自动运行项目校验;`validate_project.py` 会拒绝未完成记录、缺失/伪造的任务迁移事件或版本不一致。 没有注册路径时不要手改版本号。应先取得包含所需相邻步骤的 Polaris 版本,逐级完成并分别提交;任何失败都先保留 `.polaris/migrations/` 和事件现场,修复原因后重跑同一迁移命令。 @@ -631,6 +632,8 @@ v0.1.19 将正式 Provider 固定为 [colbymchenry/codegraph](https://github.com v0.1.20 / Workflow v0.1.3 删除没有独立治理边界的中间状态和事件;`START_IMPLEMENTATION` 与 `START_REVIEW` 各自原子注册所需产物,Review 接受后直接进入 `VALIDATING`,R0/R1 使用 `PASS_AND_CLOSE`。本机进度改为可选遥测;未执行 Provider 操作时不再生成 Code Intelligence record。 +v0.1.21 新增项目级 Polaris CodeGraph MCP 代理、Host Adapter v3 注册与 Code Intelligence record v3;Workflow 仍为 v0.1.3,CodeGraph 仍为可选且不参与门禁。v1/v2 record 仅作为不可变历史证据读取。 + ## 13. 失败探索与卡点 如果一个技术方向被证据否定,不要让结论只留在聊天中。记录任务内探索: diff --git a/plan.md b/plan.md index 1abe8f9..dc7076b 100644 --- a/plan.md +++ b/plan.md @@ -2,7 +2,7 @@ > 状态:Implementation underway > 目标版本:v0.1 -> 当前协议:`0.1.20`;Workflow:`0.1.3` +> 当前协议:`0.1.21`;Workflow:`0.1.3` > 产品形态:Repo-native Skill System > 宿主 Runtime:声明式可扩展;v0.1 内置 Codex、Claude Code > @@ -452,7 +452,7 @@ v0.1 不设置 `FAILED`:可修复失败通过治理回路处理,外部阻塞 `.polaris/workflow.json` 保存当前项目实际使用且版本锁定的节点、边、依赖和门禁 ID;`tools/polaris/workflow/default-workflow.json` 只用于初始化。`transition_task.py` 只接受图中边并先运行对应 validators,Skill 不直接编辑 `state` 字段。v0.1 遇到 `polaris_version` 或 `workflow_version` 不匹配时拒绝正常执行,不做隐式迁移。 -版本升级必须先 vendoring 目标协议,再显式运行 vendored `migrate_project.py`。`workflow/migrations.json` 是迁移路径唯一且 append-only 的注册表,一次只执行一个从当前版本到目标版本的相邻步骤;历史步骤必须保留以校验已提交记录。Migration protocol v2 保留 `replace_version` / `append_version_event`,并增加 `replace_version_and_workflow` / `append_mapped_workflow_event`。`0.1.19 → 0.1.20` 原子替换冻结 workflow 为 `0.1.3`,追加带源/目标状态及旧版本字段的迁移事件;旧 `IMPLEMENTED`、`DOCS_SYNCED` 映射到 `IMPLEMENTING`,旧 `REVIEWED` 映射到 `VALIDATING`,旧 R0/R1 `VERIFIED` 映射到 `VALIDATING` 以重新提交 `PASS_AND_CLOSE`,仅 R2 保持 `VERIFIED`。迁移以 `.polaris/migrations/MIG-*.json` 记录 `IN_PROGRESS/COMPLETED`、各任务 sequence 和状态映射;重跑必须可恢复且不得重复事件。未知路径、跨版本跳跃、未声明的 workflow 变化、任务集合并发变化和不完整记录都必须机械拒绝。 +版本升级必须先 vendoring 目标协议,再显式运行 vendored `migrate_project.py`。`workflow/migrations.json` 是迁移路径唯一且 append-only 的注册表,一次只执行一个从当前版本到目标版本的相邻步骤;历史步骤必须保留以校验已提交记录。Migration protocol v2 保留 `replace_version` / `append_version_event`,并增加 `replace_version_and_workflow` / `append_mapped_workflow_event`。`0.1.19 → 0.1.20` 原子替换冻结 workflow 为 `0.1.3`,追加带源/目标状态及旧版本字段的迁移事件;旧 `IMPLEMENTED`、`DOCS_SYNCED` 映射到 `IMPLEMENTING`,旧 `REVIEWED` 映射到 `VALIDATING`,旧 R0/R1 `VERIFIED` 映射到 `VALIDATING` 以重新提交 `PASS_AND_CLOSE`,仅 R2 保持 `VERIFIED`。`0.1.20 → 0.1.21` 保持 Workflow `0.1.3`,新增项目级 CodeGraph 代理、Host Adapter v3 与 record v3,并把 canonical v1/v2 record 作为仅可读取的不可变历史证据按路径和 SHA-256 清点;迁移恢复前必须重算并比对清单。迁移以 `.polaris/migrations/MIG-*.json` 记录 `IN_PROGRESS/COMPLETED`、各任务 sequence 和状态映射;重跑必须可恢复且不得重复事件。未知路径、跨版本跳跃、未声明的 workflow 变化、任务集合并发变化和不完整记录都必须机械拒绝。 迁移占用任务转换锁时必须写入结构化 owner:迁移 ID、任务 ID、主机名、PID 和创建时间。重跑只允许接管同一迁移在同一主机上、且原 PID 已确认不存在的锁;活跃 PID、其他迁移、其他主机、空锁或损坏锁一律拒绝。这样既能从进程崩溃或机器重启恢复,又不把真实并发误判为遗留锁。 diff --git a/pyproject.toml b/pyproject.toml index d5c11c6..e8fb9c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "corona-polaris" -version = "0.1.20" +version = "0.1.21" description = "Repo-native AI engineering workflow command dispatcher" requires-python = ">=3.10" dependencies = [] diff --git a/scripts/internal/migration_protocol.py b/scripts/internal/migration_protocol.py index deb7a7a..1477666 100644 --- a/scripts/internal/migration_protocol.py +++ b/scripts/internal/migration_protocol.py @@ -8,7 +8,7 @@ from .code_intelligence_protocol import ( _record_name, validate_historical_legacy_record_value, - validate_record_value, + validate_historical_v2_record_value, ) from .polaris_core import ( InputFailure, @@ -272,9 +272,13 @@ def _step_for_record( def _retired_code_intelligence_records( - repo: Path, task_id: str, directory: Path, protocol_root: Path + repo: Path, + task_id: str, + directory: Path, + protocol_root: Path, + step: dict[str, Any], ) -> list[dict[str, str]]: - """Inventory immutable v1 records at their canonical task-local locations.""" + """Inventory immutable historical records at canonical task-local locations.""" records_root = directory / "code-intelligence" if records_root.is_symlink(): raise RuleFailure( @@ -297,9 +301,15 @@ def _retired_code_intelligence_records( repo, task_id, path, value, protocol_root ) elif value.get("record_version") == 2: - value = validate_record_value(repo, task_id, value, protocol_root) + value = validate_historical_v2_record_value( + repo, task_id, path, value, protocol_root + ) else: raise RuleFailure(f"Code Intelligence record path is non-canonical: {path}") + should_inventory = value["record_version"] == 1 or ( + value["record_version"] == 2 + and step["migration_id"] == "0.1.20-to-0.1.21" + ) if value["record_version"] != 1: expected = code_intelligence_record_path( directory, value["work_item_revision"], _record_name(value) @@ -308,6 +318,7 @@ def _retired_code_intelligence_records( raise RuleFailure( f"Code Intelligence record path is non-canonical: {path}" ) + if not should_inventory: continue retired.append( { @@ -349,7 +360,9 @@ def _new_record( } ) retired_code_intelligence_records.extend( - _retired_code_intelligence_records(repo, task_id, directory, protocol_root) + _retired_code_intelligence_records( + repo, task_id, directory, protocol_root, step + ) ) return { "record_version": 2, @@ -437,6 +450,23 @@ def migrate_project(repo: Path, protocol_root: Path) -> dict[str, Any]: item["task_id"] for item in record["tasks"] }: raise RuleFailure("project task list changed during migration") + if incomplete is not None: + current_inventory: list[dict[str, str]] = [] + for item in record["tasks"]: + directory = task_dir(repo, item["task_id"]) + current_inventory.extend( + _retired_code_intelligence_records( + repo, + item["task_id"], + directory, + protocol_root, + step, + ) + ) + if record.get("retired_code_intelligence_records", []) != current_inventory: + raise RuleFailure( + "retired Code Intelligence record inventory changed during migration" + ) locks: list[tuple[Path, int]] = [] try: diff --git a/templates/project.json b/templates/project.json index 4bfc7aa..e914021 100644 --- a/templates/project.json +++ b/templates/project.json @@ -1,6 +1,6 @@ { "project_id": "PROJECT_ID", - "polaris_version": "0.1.20", + "polaris_version": "0.1.21", "workflow_version": "0.1.3", "active_tasks": [] } diff --git a/templates/task-sources/state.json b/templates/task-sources/state.json index a623be4..7f63d16 100644 --- a/templates/task-sources/state.json +++ b/templates/task-sources/state.json @@ -1,6 +1,6 @@ { "task_id": "TASK-0001", - "polaris_version": "0.1.20", + "polaris_version": "0.1.21", "workflow_version": "0.1.3", "current_revision": 1, "status": "DRAFT", diff --git a/templates/task/state.json b/templates/task/state.json index a623be4..7f63d16 100644 --- a/templates/task/state.json +++ b/templates/task/state.json @@ -1,6 +1,6 @@ { "task_id": "TASK-0001", - "polaris_version": "0.1.20", + "polaris_version": "0.1.21", "workflow_version": "0.1.3", "current_revision": 1, "status": "DRAFT", diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 34a6e1c..2b68599 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -10,7 +10,7 @@ import sys import tempfile import unittest -from contextlib import redirect_stdout +from contextlib import contextmanager, redirect_stdout from pathlib import Path from unittest import mock @@ -39,6 +39,7 @@ write_json_atomic, write_text_atomic, ) +from internal.recovery_protocol import refresh_project_index # noqa: E402 from vendor_project import vendor # noqa: E402 @@ -48,6 +49,20 @@ def completed( return subprocess.CompletedProcess([], returncode, stdout, stderr) +@contextmanager +def protocol_source_at(version: str): + """Materialize a historical protocol target for adjacent migration tests.""" + with tempfile.TemporaryDirectory(prefix="polaris-codegraph-protocol-") as temp: + source = Path(temp) / "source" + shutil.copytree( + ROOT, + source, + ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc"), + ) + (source / "VERSION").write_text(version + "\n", encoding="utf-8") + yield source + + def healthy_status(project: Path) -> str: return json.dumps( { @@ -161,7 +176,7 @@ def test_managed_surfaces_only_name_the_official_codegraph(self) -> None: self.assertIn(official, path.read_text(encoding="utf-8"), path.relative_to(ROOT).as_posix()) for path in [ROOT / "README.md", ROOT / "README.zh-CN.md"]: text = path.read_text(encoding="utf-8") - self.assertIn("0.1.20", text, path.relative_to(ROOT).as_posix()) + self.assertIn("0.1.21", text, path.relative_to(ROOT).as_posix()) self.assertIn("0.1.3", text, path.relative_to(ROOT).as_posix()) def test_authority_surfaces_publish_workflow_013(self) -> None: @@ -172,7 +187,7 @@ def test_authority_surfaces_publish_workflow_013(self) -> None: ROOT / "plan.md", ]: text = path.read_text(encoding="utf-8") - self.assertIn("0.1.20", text, path.relative_to(ROOT).as_posix()) + self.assertIn("0.1.21", text, path.relative_to(ROOT).as_posix()) self.assertIn("0.1.3", text, path.relative_to(ROOT).as_posix()) def test_readmes_keep_codegraph_operational_boundaries(self) -> None: @@ -1214,6 +1229,98 @@ def set_workflow_version(self, version: str) -> None: "".join(json.dumps(event, separators=(",", ":")) + "\n" for event in events), ) + def prepare_v2_migration_records(self) -> list[tuple[Path, bytes]]: + """Create immutable v2 records in the current and prior revision slots.""" + self.initialize_task() + new_revision(self.repo, "TASK-0001") + task = self.repo / ".polaris/tasks/TASK-0001" + state_path = task / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["current_revision"] = 2 + write_json_atomic(state_path, state) + event_path = task / "events.jsonl" + events = [ + json.loads(line) + for line in event_path.read_text(encoding="utf-8").splitlines() + ] + events[0]["current_revision"] = 2 + write_text_atomic( + event_path, + "".join( + json.dumps(event, separators=(",", ":")) + "\n" + for event in events + ), + ) + frozen: list[tuple[Path, bytes]] = [] + for revision in (1, 2): + value = self.v2_record() + value["work_item_revision"] = revision + path = ( + task + / "code-intelligence" + / f"r{revision:03d}" + / "planning.json" + ) + write_json_atomic(path, value) + frozen.append((path, path.read_bytes())) + self.set_protocol_version("0.1.20") + self.set_workflow_version("0.1.3") + refresh_project_index(self.repo) + return frozen + + def test_migration_inventories_frozen_v2_records_without_rewriting_them( + self, + ) -> None: + """0.1.21 inventories current/prior v2 evidence and preserves Workflow 0.1.3.""" + frozen = self.prepare_v2_migration_records() + vendor(ROOT, self.repo, False) + + result = migrate_project(self.repo) + + self.assertEqual(result["from"], "0.1.20") + self.assertEqual(result["to"], "0.1.21") + project = json.loads( + (self.repo / ".polaris/project.json").read_text(encoding="utf-8") + ) + self.assertEqual(project["workflow_version"], "0.1.3") + migration = json.loads( + ( + self.repo + / ".polaris/migrations/MIG-0.1.20-to-0.1.21.json" + ).read_text(encoding="utf-8") + ) + self.assertEqual( + migration["retired_code_intelligence_records"], + [ + { + "task_id": "TASK-0001", + "path": f"code-intelligence/r{revision:03d}/planning.json", + "sha256": hashlib.sha256(content).hexdigest(), + } + for revision, (_path, content) in enumerate(frozen, start=1) + ], + ) + for path, content in frozen: + self.assertEqual(path.read_bytes(), content) + + def test_migration_resume_rejects_mutated_frozen_v2_inventory(self) -> None: + """中断迁移重跑前会重算 v2 清单,拒绝已经变化的历史证据。""" + frozen = self.prepare_v2_migration_records() + vendor(ROOT, self.repo, False) + with mock.patch( + "internal.migration_protocol.append_jsonl", + side_effect=OSError("injected migration interruption"), + ): + with self.assertRaisesRegex(OSError, "injected migration interruption"): + migrate_project(self.repo) + + path, _content = frozen[0] + value = json.loads(path.read_text(encoding="utf-8")) + value["recorded_at"] = "2026-08-19T00:00:00Z" + write_json_atomic(path, value) + with self.assertRaisesRegex(RuleFailure, "inventory changed"): + migrate_project(self.repo) + def test_legacy_v1_records_remain_readable_but_cannot_be_written(self) -> None: self.initialize_task() protocol = importlib.import_module("internal.code_intelligence_protocol") @@ -1293,7 +1400,8 @@ def test_migration_retires_v1_records_without_rewriting_them(self) -> None: } write_json_atomic(legacy_path, legacy) legacy_bytes = legacy_path.read_bytes() - vendor(ROOT, self.repo, False) + with protocol_source_at("0.1.20") as source: + vendor(source, self.repo, False) result = migrate_project(self.repo) @@ -1337,7 +1445,8 @@ def test_migration_rejects_noncanonical_v2_record_paths(self) -> None: / ".polaris/tasks/TASK-0001/code-intelligence/r001/not-a-stage.json" ) write_json_atomic(noncanonical, self.v2_record()) - vendor(ROOT, self.repo, False) + with protocol_source_at("0.1.20") as source: + vendor(source, self.repo, False) with self.assertRaisesRegex(RuleFailure, "non-canonical"): migrate_project(self.repo) @@ -1394,7 +1503,8 @@ def test_migration_inventories_v1_records_from_prior_revisions(self) -> None: RuleFailure, "targets the wrong task revision" ): validate_record_value(self.repo, "TASK-0001", legacy, ROOT) - vendor(ROOT, self.repo, False) + with protocol_source_at("0.1.20") as source: + vendor(source, self.repo, False) try: migrate_project(self.repo) @@ -1425,7 +1535,8 @@ def test_migration_rejects_a_dangling_code_intelligence_symlink(self) -> None: records_root = self.repo / ".polaris/tasks/TASK-0001/code-intelligence" shutil.rmtree(records_root) records_root.symlink_to(self.repo / "missing-code-intelligence") - vendor(ROOT, self.repo, False) + with protocol_source_at("0.1.20") as source: + vendor(source, self.repo, False) with self.assertRaisesRegex(RuleFailure, "must not be a symlink"): migrate_project(self.repo) diff --git a/tests/test_core.py b/tests/test_core.py index a7558ad..39e0ce8 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -132,6 +132,20 @@ def repository_file_snapshot(repo: Path) -> dict[str, bytes]: } +@contextmanager +def protocol_source_at(version: str) -> Iterator[Path]: + """Materialize a historical protocol target for adjacent migration tests.""" + with tempfile.TemporaryDirectory(prefix="polaris-protocol-source-") as temp: + source = Path(temp) / "source" + shutil.copytree( + ROOT, + source, + ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc"), + ) + (source / "VERSION").write_text(version + "\n", encoding="utf-8") + yield source + + @contextmanager def simulated_symlinks(*paths: Path) -> Iterator[None]: """Report selected paths as symlinks without requiring filesystem support.""" @@ -2188,7 +2202,8 @@ def test_explicit_migration_appends_task_event_and_records_completion(self) -> N """相邻版本迁移追加审计事件,不改写任务历史,并留下完成记录。""" self.set_protocol_version("0.1.19") self.set_workflow_version("0.1.2") - vendor(ROOT, self.repo, False) + with protocol_source_at("0.1.20") as source: + vendor(source, self.repo, False) result = migrate_project(self.repo) @@ -2340,7 +2355,8 @@ def test_migration_replaces_frozen_workflow_and_maps_tasks(self) -> None: for event in events ), ) - vendor(ROOT, self.repo, False) + with protocol_source_at("0.1.20") as source: + vendor(source, self.repo, False) result = migrate_project(self.repo) @@ -2417,7 +2433,8 @@ def test_migrated_r1_verified_task_can_close(self) -> None: ) self.set_protocol_version("0.1.19") self.set_workflow_version("0.1.2") - vendor(ROOT, self.repo, False) + with protocol_source_at("0.1.20") as source: + vendor(source, self.repo, False) migrate_project(self.repo) @@ -2455,7 +2472,8 @@ def test_migration_resumes_after_event_append_without_duplication(self) -> None: """中断后重跑会采用已追加的迁移事件并完成投影,不重复写事件。""" self.set_protocol_version("0.1.19") self.set_workflow_version("0.1.2") - vendor(ROOT, self.repo, False) + with protocol_source_at("0.1.20") as source: + vendor(source, self.repo, False) state = read_json(self.task / "state.json") started_at = "2026-08-15T00:00:00Z" record = { @@ -2519,7 +2537,8 @@ def test_migration_reclaims_only_its_own_dead_process_lock(self) -> None: """迁移可接管同一迁移的崩溃锁,但不能抢占仍存活的进程。""" self.set_protocol_version("0.1.19") self.set_workflow_version("0.1.2") - vendor(ROOT, self.repo, False) + with protocol_source_at("0.1.20") as source: + vendor(source, self.repo, False) lock_path = self.task / ".transition.lock" write_json_atomic( lock_path, @@ -2559,8 +2578,9 @@ def test_migration_reclaims_only_its_own_dead_process_lock(self) -> None: def test_migration_rejects_an_undeclared_version_jump(self) -> None: """没有注册的跨版本路径机械拒绝,且不创建部分迁移记录。""" - self.set_protocol_version("0.1.10") - vendor(ROOT, self.repo, False) + self.set_protocol_version("0.1.20") + with protocol_source_at("0.1.22") as source: + vendor(source, self.repo, False) with self.assertRaisesRegex(RuleFailure, "no explicit adjacent migration"): migrate_project(self.repo) @@ -2568,9 +2588,24 @@ def test_migration_rejects_an_undeclared_version_jump(self) -> None: self.assertFalse((self.repo / ".polaris" / "migrations").exists()) self.assertEqual( read_json(self.repo / ".polaris" / "project.json")["polaris_version"], - "0.1.10", + "0.1.20", ) + def test_version_only_migration_rejects_a_workflow_version_change(self) -> None: + """0.1.20→0.1.21 路由不能暗中改变已冻结的 Workflow 0.1.3。""" + self.set_protocol_version("0.1.20") + with protocol_source_at("0.1.21") as source: + migrations_path = source / "workflow" / "migrations.json" + migrations = read_json(migrations_path) + migrations["steps"][-1]["to_workflow_version"] = "0.1.4" + write_json_atomic(migrations_path, migrations) + vendor(source, self.repo, False) + + with self.assertRaisesRegex( + RuleFailure, "workflow migration requires replacement" + ): + migrate_project(self.repo) + def test_code_intelligence_auto_detects_available_operations_and_can_be_disabled(self) -> None: """已初始化的可选代码情报按 MCP 工具能力发现;缺失或禁用时不产生硬依赖。""" (self.repo / ".codegraph").mkdir() diff --git a/workflow/migrations.json b/workflow/migrations.json index 3582a1b..ab69d72 100644 --- a/workflow/migrations.json +++ b/workflow/migrations.json @@ -99,6 +99,15 @@ "to_workflow_version": "0.1.3", "project_strategy": "replace_version_and_workflow", "task_strategy": "append_mapped_workflow_event" + }, + { + "migration_id": "0.1.20-to-0.1.21", + "from_polaris_version": "0.1.20", + "to_polaris_version": "0.1.21", + "from_workflow_version": "0.1.3", + "to_workflow_version": "0.1.3", + "project_strategy": "replace_version", + "task_strategy": "append_version_event" } ] } From 66a1061cea9e9cbcea234be30776e8936d409591 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 03:56:36 +0800 Subject: [PATCH 09/28] test: verify the CodeGraph proxy end to end --- README.md | 6 +- README.zh-CN.md | 6 +- docs/USAGE.md | 20 +- .../plans/2026-08-18-codegraph-freshness.md | 6 + .../2026-08-18-codegraph-freshness-design.md | 4 +- plan.md | 24 +- .../internal/code_intelligence_protocol.py | 14 + skills/adversarial-review/SKILL.md | 6 +- skills/architecture-planning/SKILL.md | 4 +- skills/code-intelligence/SKILL.md | 30 +- skills/documentation-sync/SKILL.md | 4 +- skills/implementation/SKILL.md | 6 +- templates/AGENTS.md | 9 +- tests/test_codegraph.py | 401 +++++++++++++++--- 14 files changed, 426 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 656d6ca..797faa4 100644 --- a/README.md +++ b/README.md @@ -89,9 +89,11 @@ codegraph init polaris code-intelligence add codegraph --repo . ``` -Run these commands from the target repository as appropriate. `codegraph init` creates the `.codegraph/` marker; without it Polaris uses source and Git directly and creates no stage record. Polaris writes a Code Intelligence record only when it actually performs a Provider status, sync, or explore operation. Polaris can only read CodeGraph status, explore indexed relationships, and perform one bounded `codegraph sync` at a declared stage boundary. It never installs, initializes, starts, configures, reconfigures, waits for, or manages CodeGraph or its watcher/daemon/MCP configuration. +Run these commands from the target repository as appropriate. `codegraph init` creates the `.codegraph/` marker; without it Polaris uses source and Git directly and creates no stage record. Vendoring registers the project-scoped `polaris-codegraph` proxy in `.codex/config.toml` and `.mcp.json` without replacing unrelated settings. The host may require project trust or first-use approval; that approval remains the user's decision. -CodeGraph's watcher and connection reconciliation are the primary freshness mechanisms. Polaris records a limited conclusion at the time it checks: `CURRENT_AT_CHECK`, `PARTIAL_STALE`, `INDEX_STALE`, `NOT_VERIFIED`, or `UNAVAILABLE`; it never claims commit-exact graph freshness. A `PARTIAL_STALE` response names specific files: read each current file directly (`READ_SOURCE`), or inspect the registered Git diff if it was deleted (`INSPECT_GIT_DIFF`). For `INDEX_STALE` or `NOT_VERIFIED`, treat the graph only as a lead and search the repository plus Git (`SEARCH_SOURCE`). Validation remains graph-free and relies on source, Git, builds, tests, static checks, and Human Checks. +Polaris stages call only `polaris_codegraph_explore`. The proxy checks status, may internally perform one bounded `codegraph sync` when requested and pending, runs one explore, rechecks status, and returns a freshness envelope before graph content. There is no separate stage status/sync MCP call. `CURRENT` means `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN` mean `NAVIGATION_ONLY` and require the named source/Git fallback; `UNAVAILABLE` means no graph. A current named file uses `READ_SOURCE`, a deleted file uses `INSPECT_GIT_DIFF`, and an index-wide or unsafe result uses `SEARCH_SOURCE`. Validation remains graph-free and relies on source, Git, builds, tests, static checks, and Human Checks. + +The repository owner, not Polaris, owns CodeGraph installation, initialization, configuration, raw MCP registration, watcher, and daemon. Polaris never starts, configures, reconfigures, waits for, or manages them. Raw `codegraph_explore` or `codegraph explore` remains available out-of-band but cannot back `CURRENT` Polaris evidence. New records are v3 projections of the retained proxy bundle and completed fallbacks; v1/v2 are historical only. CodeGraph remains optional and never becomes a workflow gate. Protocol `0.1.21` adds the project-scoped Polaris CodeGraph proxy, host adapter v3 registration, and auditable Code Intelligence record v3 while leaving Workflow at `0.1.3`. Record v1 and v2 are immutable historical evidence only; new evidence is projected from a retained proxy bundle into v3. diff --git a/README.zh-CN.md b/README.zh-CN.md index 1f04835..c7d1397 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -89,9 +89,11 @@ codegraph init polaris code-intelligence add codegraph --repo . ``` -`codegraph init` 创建 `.codegraph/` marker。只有目标仓库已经有这个 marker 且项目策略允许时,Polaris 才会使用 CodeGraph;没有 marker 时直接使用源码和 Git,不生成阶段 record。只有实际执行 Provider `status`、`sync` 或 `explore` 操作时才写 Code Intelligence record。Polaris 只会读取 `status`、查询 `explore`,以及只在声明的阶段边界至多执行一次有界 `codegraph sync`;它绝不安装、初始化、启动、配置、重新配置、等待或管理 CodeGraph、watcher、daemon 或 MCP 配置。 +`codegraph init` 创建 `.codegraph/` marker;没有 marker 时 Polaris 直接使用源码和 Git,不生成阶段 record。Vendoring 会在 `.codex/config.toml` 与 `.mcp.json` 中非破坏地注册项目级 `polaris-codegraph` 代理,并保留其他设置。宿主可能要求信任项目或首次使用确认;是否批准仍由用户决定。 -CodeGraph 的 watcher 和连接时 reconciliation 是正常情况下的实时更新机制。Polaris 只记录检查时的有限结论:`CURRENT_AT_CHECK`、`PARTIAL_STALE`、`INDEX_STALE`、`NOT_VERIFIED` 或 `UNAVAILABLE`,不会宣称与 Git commit 精确一致。`PARTIAL_STALE` 会精确列出待同步文件:当前普通文件必须直接读取并记录 `READ_SOURCE`;已删除文件必须检查注册 subject 的 Git diff 并记录 `INSPECT_GIT_DIFF`。`INDEX_STALE` 或 `NOT_VERIFIED` 时,图只能作为导航线索,Agent 必须通过仓库搜索和 Git 证据记录 `SEARCH_SOURCE`。Provider 不可用、status 不可读或 sync 失败都不阻断阶段;Validation 不调用 CodeGraph,仍以源码、Git、构建、测试、静态检查和 Human Check 为准。 +Polaris 阶段只调用 `polaris_codegraph_explore`。代理先检查 status,按请求且确有 pending 时至多执行一次有界 `codegraph sync`,再执行一次 explore、复查 status,并保证 freshness envelope 位于图内容之前;阶段没有独立的 status/sync MCP 调用。`CURRENT` 表示 `NON_AUTHORITATIVE_CONTEXT`;`STALE` 与 `UNKNOWN` 表示 `NAVIGATION_ONLY`,必须完成 envelope 指定的源码/Git 回退;`UNAVAILABLE` 表示没有图内容。当前具名文件使用 `READ_SOURCE`,已删除文件使用 `INSPECT_GIT_DIFF`,索引级或不安全结果使用 `SEARCH_SOURCE`。Validation 不调用 CodeGraph,仍以源码、Git、构建、测试、静态检查和 Human Check 为准。 + +CodeGraph 的安装、初始化、配置、raw MCP 注册、watcher 与 daemon 归仓库所有者,而不是 Polaris。Polaris 绝不启动、配置、重新配置、等待或管理这些能力。raw `codegraph_explore` 或 `codegraph explore` 仍可作为带外工具使用,但不能支持 Polaris 的 `CURRENT` 证据。新 record 必须由保留的代理 bundle 与已完成回退投影为 v3;v1/v2 仅供历史读取。CodeGraph 始终可选,永远不是 Workflow 门禁。 协议 `0.1.21` 新增项目级 Polaris CodeGraph 代理、Host Adapter v3 注册和可审计的 Code Intelligence record v3,Workflow 仍为 `0.1.3`。record v1/v2 仅作为不可变历史证据读取;新证据必须由保留的代理 bundle 投影为 v3。 diff --git a/docs/USAGE.md b/docs/USAGE.md index 53b21f5..ed1f6da 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -175,17 +175,17 @@ Claude Code 应加载 `.claude/skills/engineering-task/SKILL.md`。R1/R2 Impleme ### 3.6 添加新的宿主适配器(维护者) -1. 新建 `hosts//adapter.json`,使用 `adapter_version: 2`,并按 `schemas/host-adapter.schema.json` 声明 Skill 目标、调用前缀、真实入口、能力、入口 frontmatter、overlay、appendix 与专用文件。 +1. 新建 `hosts//adapter.json`,使用 `adapter_version: 3`,并按 `schemas/host-adapter.schema.json` 声明 Skill 目标、调用前缀、真实入口、能力、入口 frontmatter、overlay、appendix、专用文件与唯一 `project_mcp` 注册。 2. `capabilities` 必须显式声明 `structured_user_input / worker_create / worker_status / worker_resume / stable_worker_identity`。`worker_status` 和稳定身份依赖 worker 创建;续接同时依赖创建与稳定身份。声明可创建 worker 的宿主必须提供入口 Skill appendix,写清创建、身份、查询和续接机制。 3. 只把宿主能力差异放入该目录:metadata 放在 overlay,worker 创建/身份/等待/续接规则放在 `skill-appendices/engineering-task.md`,原生 agent 或仓库规则放在 `files` 清单中。 4. 不要在共享 `skills/`、Workflow、Authority schema 或三个生命周期脚本中新增宿主名分支。共享 Skill 引用另一 Skill 时使用 `{{skill:}}`。 5. 运行完整测试,并在真实宿主中 smoke test 入口发现、显式触发边界、隔离 worker、handoff 拒绝和同一 Implementer 续接 Documentation Sync。 -`entry_skill` 必须对应 canonical `skills//SKILL.md`。Overlay 只能在已知 Skill 下增加 canonical 源中不存在的普通文件,不能提供 `SKILL.md`、覆盖任何同路径内容或包含未知 Skill;appendix 也只能使用 `.md`。适配器源树、manifest、overlay、appendix、专用源文件和目标写入路径都禁止 symlink,所有目标必须留在仓库内。不同宿主不能声明重叠目标。若新宿主无法用 v2 的“文件复制 + Skill 渲染 + 能力声明 + 执行附录”表达,应先升级适配器契约,而不是在核心脚本里写例外。 +`entry_skill` 必须对应 canonical `skills//SKILL.md`。Overlay 只能在已知 Skill 下增加 canonical 源中不存在的普通文件,不能提供 `SKILL.md`、覆盖任何同路径内容或包含未知 Skill;appendix 也只能使用 `.md`。适配器源树、manifest、overlay、appendix、专用源文件、MCP 配置目标和启动器路径都禁止 symlink,所有目标必须留在仓库内。`project_mcp` 固定 server ID、`python3` 启动器、vendored 脚本及 `--repo .`;不同宿主不能声明重叠目标。若新宿主无法用 v3 契约表达,应先升级适配器契约,而不是在核心脚本里写例外。 ### 3.7 可选 Code Intelligence -Polaris v0.1 只支持 [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph) 作为正式 Code Intelligence Provider。用户拥有安装、初始化和宿主 MCP 配置;在目标仓库中自行按顺序运行: +Polaris v0.1 只支持 [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph) 作为正式 Code Intelligence Provider。用户拥有 CodeGraph 的安装、初始化、配置、raw MCP 注册、watcher 与 daemon;在目标仓库中自行按顺序运行: ```text codegraph install @@ -193,9 +193,11 @@ codegraph init polaris code-intelligence add codegraph --repo . ``` -前两个命令绝不会由 Polaris 执行;`codegraph init` 创建 `.codegraph/`,它是 Polaris 允许查询的前提。最后一个命令只创建或更新 `.polaris/code-intelligence.json`,将模式设为 `auto_optional`、将 CodeGraph 放到 Provider 优先级首位,并保留已有 `include` / `exclude` 规则。命令可幂等重跑,未知 Provider 或非法旧配置会在写入前拒绝。 +前两个命令绝不会由 Polaris 执行;`codegraph init` 创建 `.codegraph/`,它是 Polaris 允许查询的前提。最后一个命令只创建或更新 `.polaris/code-intelligence.json`,将模式设为 `auto_optional`、将 CodeGraph 放到 Provider 优先级首位,并保留已有 `include` / `exclude` 规则。命令可幂等重跑,未知 Provider 或非法旧配置会在写入前拒绝。Polaris 不会启动、配置、重新配置或等待 CodeGraph。 -Python CLI 无法直接查看 Codex 或 Claude Code 当前会话中的 MCP 工具,因此成功只表示 Provider 已加入 Polaris;返回的 `runtime_status` 为 `checked_by_next_workflow`。下一次 Workflow 只有在仓库已经存在 `.codegraph/` 时才会检查实际能力;缺少 marker 或策略禁用时直接继续源码搜索、读取、构建、测试和 Review,不生成阶段 record。只有实际执行 `status`、`sync` 或 `explore` 后才写 record;操作失败时如实记录 `UNAVAILABLE` 或 `NOT_VERIFIED`,但不阻断阶段。 +Vendoring 会非破坏地把项目级 `polaris-codegraph` 代理注册到 Codex 的 `.codex/config.toml` 和 Claude Code 的 `.mcp.json`,只管理同名条目并把配置列入安装清单的 `preserved_files`。已有无关设置与服务器会保留;损坏配置、同名冲突、越界路径或 symlink 会在覆盖前拒绝。宿主可能在首次启动时要求信任项目或批准 MCP;这是用户决定,Polaris 不绕过。 + +Python CLI 无法直接查看 Codex 或 Claude Code 当前会话中的 MCP 工具,因此配置命令成功只表示 Provider 已加入 Polaris;返回的 `runtime_status` 为 `checked_by_next_workflow`。下一次 Workflow 只有在仓库已经存在 `.codegraph/` 时才会调用项目代理;缺少 marker 或策略禁用时直接继续源码搜索、读取、构建、测试和 Review,不生成阶段 record。 不执行该命令时仍保留默认自动发现。`.polaris/code-intelligence.json` 也可用于禁用 Provider、调整优先级或限制索引范围。例如: @@ -217,11 +219,13 @@ Python CLI 无法直接查看 Codex 或 Claude Code 当前会话中的 MCP 工 } ``` -CodeGraph watcher 与连接时 reconciliation 是常规实时更新机制。Polaris 只在 Planning、Implementation、Review 的阶段入口和最终 Documentation Sync 的有界点读取 status;仅 status 指出 pending changes 时,才至多运行一次 `codegraph sync` 并至多复查一次 status。Polaris 只会 `status`、`explore` 和这一次有界 `sync`,不会等待 watcher、循环查询、启动 daemon 或改写 MCP 配置。 +CodeGraph watcher 与连接时 reconciliation 是常规实时更新机制。Polaris 阶段只调用 `polaris_codegraph_explore`:代理在同一有界窗口内检查 status,按调用参数且确有 pending 时至多运行一次 `codegraph sync`,执行一次 explore,再复查 status。阶段没有独立的 status/sync MCP 工具,也不会等待 watcher、轮询、重试、启动 daemon 或改写用户的 raw MCP 配置。Documentation Sync 仅在 supported source 变化时执行一次查询,使用 `sync_if_needed: true`,并把 query 限制到 changed source paths 与 documented symbols。 + +代理结果的第一个内容块总是 freshness envelope。`CURRENT / NON_AUTHORITATIVE_CONTEXT` 表示图可作为非权威上下文;`STALE / NAVIGATION_ONLY` 表示已知失效;`UNKNOWN / NAVIGATION_ONLY` 表示无法证明新鲜度;`UNAVAILABLE / NO_GRAPH` 表示没有图输出。任何状态都不宣称与 Git commit 严格一致,`UNKNOWN` 绝不能当作 current。raw `codegraph_explore` 或 `codegraph explore` 仍可由用户带外调用,但不能支持 Polaris 的 `CURRENT` 证据。 -精简 record 保存在任务的 `code-intelligence/rNNN/*.json`,包含 Provider、阶段、目标 commit/diff、查询目的、响应哈希、新鲜度、stale point 与实际源码回退证据;原始 MCP 响应只允许进入 ignored 的 `runtime/code-intelligence/`。新鲜度只表示检查时的有限结论:`CURRENT_AT_CHECK`、`PARTIAL_STALE`、`INDEX_STALE`、`NOT_VERIFIED` 或 `UNAVAILABLE`,不宣称与 Git commit 严格一致。 +`STALE` 或 `UNKNOWN` 必须先完成 envelope 指定的源码/Git fallback。当前具名普通文件直接读取并记录 `READ_SOURCE` 与当前 SHA-256;安全但已删除的路径检查注册 subject 的 Git diff,记录 `INSPECT_GIT_DIFF`、null observed SHA-256 与 base/head/diff hashes;不安全路径或索引级失效执行 `SEARCH_SOURCE`,记录有限、受限的当前文件路径与 SHA-256。图不能扩大冻结 scope、替代源码或决定 Review verdict,Validation 完全不调用 CodeGraph。 -`PARTIAL_STALE` 会精确列出 pending 文件。若列出的受限路径仍是当前普通文件,Agent 必须直接读取它并记录 `READ_SOURCE`;若已删除,必须检查注册 subject 的 Git diff 并记录 `INSPECT_GIT_DIFF`。`INDEX_STALE` 或 `NOT_VERIFIED` 表示整个图只能作为导航线索,Agent 必须以仓库搜索和 Git 证据回退并记录 `SEARCH_SOURCE`。没有 `.codegraph/`、Provider 故障或 sync 失败都不阻塞阶段;图不能扩大冻结 scope、替代源码或决定 Review verdict,Validation 完全不调用 CodeGraph。 +每次代理调用都会把精确响应和 bundle 留在 ignored 的 `runtime/code-intelligence/`。完成 fallback 后,Agent 写只含 summary、已确认 symbols 和 source_fallbacks 的 annotations JSON,再运行 `record_code_intelligence.py --repo . --bundle --annotations ` 投影不可变 v3 record。不得手写 record;没有代理调用就省略 record。v1/v2 record 仅作为不可变历史证据读取。 ## 4. Polaris 仓库自举 diff --git a/docs/superpowers/plans/2026-08-18-codegraph-freshness.md b/docs/superpowers/plans/2026-08-18-codegraph-freshness.md index d055214..27b2a98 100644 --- a/docs/superpowers/plans/2026-08-18-codegraph-freshness.md +++ b/docs/superpowers/plans/2026-08-18-codegraph-freshness.md @@ -1,5 +1,11 @@ # CodeGraph Freshness Integration Implementation Plan +> **Historical plan:** This v2 plan has been superseded by +> `docs/superpowers/plans/2026-08-19-codegraph-polaris-mcp-proxy.md`. +> Current stages must use only `polaris_codegraph_explore`; the direct +> status/sync/raw-explore instructions below are retained solely as migration +> history and cannot support new Polaris `CURRENT` evidence. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make `colbymchenry/codegraph` the only formal `codegraph` Provider, keep its graph current with watcher-aware one-shot sync, and record precise stale points that force bounded source fallback. diff --git a/docs/superpowers/specs/2026-08-18-codegraph-freshness-design.md b/docs/superpowers/specs/2026-08-18-codegraph-freshness-design.md index 5f63fc2..979cbaa 100644 --- a/docs/superpowers/specs/2026-08-18-codegraph-freshness-design.md +++ b/docs/superpowers/specs/2026-08-18-codegraph-freshness-design.md @@ -3,11 +3,13 @@ ## 状态 - 日期:2026-08-18 -- 状态:已在对话中确认,等待书面规格复核 +- 状态:历史设计,已由 `2026-08-18-codegraph-polaris-mcp-proxy-design.md` 取代;不得作为当前操作指南 - 适用版本:Polaris v0.1 的下一协议版本 - 产品 authority:`plan.md` - 唯一正式 CodeGraph Provider:[`colbymchenry/codegraph`](https://github.com/colbymchenry/codegraph) +> 当前阶段必须只调用 Polaris 项目代理 `polaris_codegraph_explore`。本文以下对阶段直接编排 `status`、`sync-if-needed` 或 raw `codegraph_explore` 的描述仅用于解释 v2 历史协议,不能支持新的 Polaris `CURRENT` 证据。 + ## 背景 Polaris 已有可选 Code Intelligence Provider 协议,但当前 `codegraph` descriptor 指向另一个同名且协议不兼容的产品。目标 Provider 实际应为 `colbymchenry/codegraph`。它默认向 MCP 暴露单一高价值入口 `codegraph_explore`,并提供 `codegraph explore`、`codegraph status --json` 和 `codegraph sync` CLI。 diff --git a/plan.md b/plan.md index dc7076b..d383d9c 100644 --- a/plan.md +++ b/plan.md @@ -288,9 +288,9 @@ target-repo/ ### 宿主适配契约 -每个宿主占用独立、平级的 `hosts//`,并提供由 `host-adapter.schema.json` 校验的 `adapter.json`。清单版本 `adapter_version=2` 声明 Skill 目标目录、调用前缀、入口 Skill、宿主能力、额外 frontmatter、可选 metadata overlay、执行附录和宿主专用文件。能力至少包括结构化用户输入、worker 创建、状态查询、续接和稳定身份;依赖关系必须机械自洽。共享 Skill 只使用 `{{skill:}}` 占位符和宿主无关 worker 语义;vendoring 时再渲染调用语法并追加宿主执行机制。 +每个宿主占用独立、平级的 `hosts//`,并提供由 `host-adapter.schema.json` 校验的 `adapter.json`。清单版本 `adapter_version=3` 除 Skill 目标、调用前缀、入口 Skill、宿主能力、frontmatter、overlay、appendix 与专用文件外,还声明唯一项目级 `polaris-codegraph` MCP 注册。能力依赖必须机械自洽;MCP 注册固定启动器、vendored server、`--repo .` 与宿主配置目标。共享 Skill 只使用 `{{skill:}}` 占位符和宿主无关 worker 语义;vendoring 时再渲染调用语法、执行机制与宿主 MCP 格式。 -`vendor_project.py`、`init_project.py` 和 `validate_project.py` 必须通过 `scripts/internal/host_adapters.py` 发现 canonical Skills 与所有清单,不得按宿主 ID 编写条件分支。入口必须指向实际 Skill;overlay 只能向已知 Skill 增加 canonical 源中不存在的普通文件,不能替换 `SKILL.md` 或其他源内容;adapter 源树与全部目标路径禁止 symlink。新增满足 v2 文件型契约的宿主只增加目录和资产;目标路径冲突、越界路径、缺失源文件、能力矛盾和未知清单版本都必须机械拒绝。需要超出 v2 表达能力的新机制时,先升级 adapter schema/version,再保持旧版本迁移边界,不把宿主差异写回共享 Workflow 或 Authority schema。 +`vendor_project.py`、`init_project.py` 和 `validate_project.py` 必须通过 `scripts/internal/host_adapters.py` 发现 canonical Skills 与所有清单,不得按宿主 ID 编写条件分支。入口必须指向实际 Skill;overlay 只能向已知 Skill 增加 canonical 源中不存在的普通文件,不能替换 `SKILL.md` 或其他源内容;adapter 源树、MCP 启动器与全部目标路径禁止 symlink。新增满足 v3 契约的宿主只增加目录和资产;目标路径冲突、越界路径、缺失源文件、能力矛盾、同名 MCP 冲突和未知清单版本都必须机械拒绝。需要超出 v3 表达能力的新机制时,先升级 adapter schema/version,再保持旧版本迁移边界,不把宿主差异写回共享 Workflow 或 Authority schema。 JSON 文件是机械门禁的权威输入。结构化 artifact 不生成同名 Markdown 副本;用户可直接查看四格缩进 JSON,主任务也可按需格式化展示。旧 revision 和旧 attempt 文件不可覆盖,`state.json` 仅保存当前有效 artifact 的指针。 @@ -486,13 +486,12 @@ AGENTS.md ### 可选 Code Intelligence 协议 -- v0.1 的唯一正式 Provider 是 [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph)。`providers/code-intelligence/codegraph.json` 声明其 MCP `codegraph_explore` 和 CLI `status`、`explore`、`sync` 能力;核心 record 使用 Provider-neutral 的新鲜度和回退字段。 -- `.codegraph/` 由用户创建和维护。Polaris 允许用户显式运行 `polaris code-intelligence add codegraph --repo .`,但绝不安装、初始化、启动或配置 Provider、watcher、daemon、锁或 MCP;缺少 marker 或策略禁用时直接回退源码,不生成新的阶段 record。 -- Provider 原生 watcher 与连接时 reconciliation 是保持索引接近工作树的主机制。Polaris 只在阶段入口、已知索引冻结或最终 Documentation Sync 的有界点读取 status;仅在 status 表示 pending 时至多执行一次 `codegraph sync`,随后至多复查一次,绝不等待或轮询。 -- 记录的结论限定为检查时:`CURRENT_AT_CHECK`、`PARTIAL_STALE`、`INDEX_STALE`、`NOT_VERIFIED` 或 `UNAVAILABLE`,不得宣称与某个 Git commit 严格一致。逐文件 stale point 必须记录路径和原因;文件仍存在时 Agent 直接读取源码并记录 `READ_SOURCE`,已删除时检查注册 subject 的 Git diff 并记录 `INSPECT_GIT_DIFF`;索引级失效使用 `SEARCH_SOURCE` 和 Git 证据。 -- Planning、Implementation 与 Reviewer 只在冻结范围内使用图关系;返回路径必须经源码确认才可进入 Working Set,Reviewer 必须独立查询。响应的局部 stale 不会丢弃其余图线索,但 stale 路径不能直接作为编辑或 Review 结论。 -- 只有阶段实际执行 Provider `status`、`sync` 或 `explore` 操作时才写耐久 record,并准确记录成功、失败和新鲜度;未执行操作时省略 artifact 引用。图不扩展 scope,也不是 Workflow gate;Validation 完全不调用 CodeGraph,仍只依赖源码、Git、构建、测试、静态检查和 Human Check。 -- Git 只保存绑定 Provider、阶段、subject、目的、有限摘要、响应哈希、新鲜度、stale point 与源码回退证据;原始响应只进入 ignored runtime。已提交 v1 record 是不可变历史证据,迁移后标为 `retired_provider_evidence`,不能支持新的新鲜度结论。 +- v0.1 的唯一正式 Provider 是 [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph)。项目级 `polaris-codegraph` MCP 只暴露 `polaris_codegraph_explore`;raw `codegraph_explore` 与 shell 仍可带外使用,但不能支持 Polaris `CURRENT` 证据。 +- `.codegraph/` 与 CodeGraph 安装、初始化、配置、raw MCP、watcher 和 daemon 由用户拥有。Polaris 只非破坏地管理自身项目代理注册;缺少 marker 或策略禁用时直接回退源码,不生成阶段 record。 +- 代理在一个有界窗口内完成 pre-status、可选一次 `codegraph sync`、一次 explore 和 post-status,并先返回 freshness envelope。阶段不分别选择 status/sync;不等待、轮询或重试。 +- envelope 状态为 `CURRENT / NON_AUTHORITATIVE_CONTEXT`、`STALE / NAVIGATION_ONLY`、`UNKNOWN / NAVIGATION_ONLY` 或 `UNAVAILABLE / NO_GRAPH`。`STALE`/`UNKNOWN` 必须先完成具名 `READ_SOURCE`、删除路径 `INSPECT_GIT_DIFF` 或索引级 `SEARCH_SOURCE` 回退;`UNKNOWN` 不得提升为 current。 +- Planning、Implementation 与 Reviewer 只在冻结范围内使用图关系;Implementation 修改关系后必须 fresh proxy call,Reviewer 必须独立调用且不得继承 Implementer envelope。Documentation Sync 仅在 supported source 改变时,以 `sync_if_needed: true` 对 changed paths/symbols执行一次查询。Validation 完全不调用 CodeGraph。 +- 代理 bundle 与原始响应只进入 ignored runtime。Agent 完成 fallback 后提供 annotations,由 `record_code_intelligence.py --bundle ... --annotations ...` 投影不可变 v3 record;没有代理调用就省略 record。v1/v2 仅作为不可变历史证据读取。 ## 9. 确定性脚本 @@ -510,8 +509,9 @@ AGENTS.md | `materialize_task_layout.py` | 从 `internal/task_layout.py` 生成模板样例树和真实任务目录,并校验生成物与平铺模板正文一致 | | `update_implementation_progress.py` | 通过明确事件原子更新 ignored 的线性步骤进度;拒绝 session 接管、跳步、回退、未知验收 ID 和非法 blocker | | `doctor_project.py` | 只读聚合环境、协议、Authority、清单、迁移、索引、任务与操作残留诊断,输出版本化报告、证据和人工动作 | -| `record_code_intelligence.py` | 写入不可变的精简 Code Intelligence Record;只接受已检查的 v2 新鲜度、stale point 和源码回退证据 | -| `code_intelligence_runtime.py` | 内部阶段工具:读取一次 status、按需至多 sync 一次并复查一次,或分类 explore 响应;不暴露为用户 CLI 命令 | +| `record_code_intelligence.py` | 从保留的代理 bundle 与受 Schema 校验的 annotations 投影不可变 v3 Code Intelligence Record;拒绝手写 v1/v2/v3 输入 | +| `code_intelligence_mcp.py` | 项目级 stdio MCP,只暴露一个有界 `polaris_codegraph_explore` 工具并保证 freshness envelope 先于图内容 | +| `code_intelligence_runtime.py` | 保留的底层适配入口;阶段 Skill 不直接编排它,而由项目代理统一执行状态、同步和响应分类窗口 | | `configure_code_intelligence.py` | 启用并优先一个已配置 Provider,保留现有索引范围,不安装或运行 Provider | | `validate_project.py` | 检查目录、ID、结构化索引、活动任务、dangling refs、graph schema | | `validate_task.py` | 检查 revision、artifact JSON、commit/diff hash、finding、AC evidence、docs delta 和 closure eligibility | @@ -630,7 +630,7 @@ Work Item 的 `risk_flags` 用于机械计算最低 rigor:任意 risk flag 为 - [x] 建源仓库目录、JSON artifact 模板、必要 Markdown 上下文模板和八个 Skill - [x] 建立版本化 `hosts/*/adapter.json` 契约,从宿主无关 Skills 生成 Codex/Claude Code 目录与 worker 文件,并将适配器、脚本、Schema、模板和 Workflow vendoring 到 `tools/polaris/` -- [x] 将 Adapter 升级到 v2,校验真实入口、overlay 新增边界、symlink confinement 与宿主能力依赖 +- [x] 将 Adapter 升级到 v3,校验真实入口、overlay 新增边界、symlink confinement、宿主能力依赖与非破坏项目 MCP 注册 - [x] 用安装清单登记 vendored 文件归属、跨平台文本哈希/严格字节哈希,并以预生成、备份、回滚和崩溃恢复事务执行强制升级 - [x] 建立显式相邻迁移注册表、可恢复迁移记录与 append-only 任务版本事件 - [ ] 用最小 fixture 验证当前 Codex 宿主能够发现仓库内 Skills diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index b222c94..fa26eb4 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -1125,6 +1125,20 @@ def _validate_v3_record_value( ): raise RuleFailure("UNAVAILABLE v3 evidence contains an attempted operation") _validate_source_fallbacks(repo, value, base, head) + if state in {"STALE", "UNKNOWN"}: + confirmed_paths: set[str] = set() + for fallback in value["source_fallbacks"]: + if fallback["action"] == "READ_SOURCE": + confirmed_paths.add(fallback["path"]) + elif fallback["action"] == "SEARCH_SOURCE": + confirmed_paths.update( + result["path"] for result in fallback["result_paths"] + ) + for symbol in query["symbols"]: + if symbol["path"] not in confirmed_paths: + raise RuleFailure( + "non-current v3 symbol requires current source fallback evidence" + ) _validate_v3_fallback_matches(value) return value diff --git a/skills/adversarial-review/SKILL.md b/skills/adversarial-review/SKILL.md index 3d8a611..421befd 100644 --- a/skills/adversarial-review/SKILL.md +++ b/skills/adversarial-review/SKILL.md @@ -10,16 +10,16 @@ For R1/R2, run only in the fresh Reviewer context defined by the active host ada 1. Run `recover_task.py --repo . --json` and require state `REVIEWING`. 2. Require an explicit Reviewer slot and registered `review_handoff` path from the dispatcher. Load only that handoff and its package paths. Do not use implementer explanations, prior chat, another Reviewer's artifact, or an expected verdict. 3. Verify handoff hashes, task revision, Review attempt, exact subject commits/diff hash, and the required isolation mode. -4. Assign a reviewer session ID distinct from the implementer for R1/R2. Attest truthfully to isolation and chat-history inheritance; do not fabricate independence. At the Review boundary invoke `{{skill:code-intelligence}}` for optional independent registered-subject relationships, first using `status` or `sync-if-needed`. Use CodeGraph only with an existing `.codegraph/` directory: prefer `codegraph_explore`, with `codegraph explore` as the non-MCP fallback. A bounded `codegraph sync` is non-blocking. Do not reuse Implementer query conclusions. Missing or failing Provider output immediately falls back to the original frozen-package and source review. +4. Assign a reviewer session ID distinct from the implementer for R1/R2. Attest truthfully to isolation and chat-history inheritance; do not fabricate independence. At the Review boundary invoke `{{skill:code-intelligence}}` for optional independent registered-subject relationships. With enabled policy and an existing `.codegraph/`, make a fresh `polaris_codegraph_explore` call and read its freshness envelope before graph content. Never inherit or reuse an Implementer envelope, bundle, or graph conclusion; ignore any pre-existing Review-stage bundle and use the bundle created by this Reviewer call. Missing, stale, unknown, or failing output follows the required source/Git fallback and never blocks the frozen-package/source review. 5. Check specification compliance first: correct problem, scope, exclusions, constraints, and every acceptance criterion. 6. Check engineering quality second: correctness, failure paths, lifetime, concurrency, security, performance, compatibility, maintainability, test gaps, and counterexamples. 7. Preserve every prior Finding ID in a follow-up Review. Read the registered author response, recheck the entire new patch, and record a concrete `reviewer_resolution` for each carried Finding. 8. Give new Findings monotonic IDs and mark critical/high, acceptance failures, and scope violations as blocking. -9. If this stage actually performed a Provider status, sync, or explore operation, finalize an immutable v2 Review Code Intelligence record. If no Provider operation ran, omit the Code Intelligence record and its optional artifact reference. Resolve the output with `task_layout.review_path` from the handoff revision, attempt, and Reviewer slot. Write a new immutable Review JSON bound to the handoff, slot, session attestation, and optional record. Never assemble the path independently or overwrite an existing artifact. Reject while any blocking Finding remains open; Code Intelligence cannot determine the verdict. +9. If this stage ran the proxy, create annotations and run `record_code_intelligence.py --repo . --bundle --annotations ` to project the immutable v3 Review record. If no proxy operation ran, omit the Code Intelligence record and optional artifact reference. Resolve the output with `task_layout.review_path` from the handoff revision, attempt, and Reviewer slot. Write a new immutable Review JSON bound to the handoff, slot, session attestation, and optional record. Never assemble the path independently or overwrite an existing artifact. Reject while any blocking Finding remains open; Code Intelligence cannot determine the verdict. 10. Return the verdict and exact Review path to the dispatching `{{skill:engineering-task}}` context. Do not run `ACCEPT_REVIEW` or `REJECT_REVIEW`; the dispatcher validates and registers all required Review artifacts before applying the graph transition. Never modify implementation code or start another Reviewer task during Review. Return a concise structured result to the dispatcher with verdict, Review attempt, Reviewer slot, reviewer session ID, subject commits/diff hash, every Finding ID and status, and the immutable Review path. Do not emit a Polaris checkpoint marker from the child task. The dispatching context emits `[POLARIS:REVIEW_ACCEPTED]` or `[POLARIS:REVIEW_REJECTED]` with the nine fixed fields only after the corresponding transition succeeds. If isolation or handoff validation prevents review, do not write a Review; report the exact required fresh-session or handoff action to the dispatcher. Only the Reviewer context may write `ACCEPT`. -CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response; when `RESPONSE_BANNER` is present, persist its successful explore response hash as `freshness.response_sha256`. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Each `SEARCH_SOURCE` fallback records `result_paths`: zero or at most 100 unique POSIX paths, each a current confined regular file with its current SHA-256; non-`SEARCH_SOURCE` fallbacks use empty `result_paths`. Graph evidence cannot determine the Review verdict. +Proxy evidence contract: `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN` are `NAVIGATION_ONLY`. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. Never run `codegraph init` or manage the Provider. Graph evidence cannot determine the Review verdict. diff --git a/skills/architecture-planning/SKILL.md b/skills/architecture-planning/SKILL.md index bbb2bee..e89fe2f 100644 --- a/skills/architecture-planning/SKILL.md +++ b/skills/architecture-planning/SKILL.md @@ -6,7 +6,7 @@ description: Internal Polaris stage for an explicitly started `{{skill:engineeri # Architecture Planning 1. Read the frozen Work Item and project rules. -2. Refresh `working-set.json` with `build_working_set.py`. At the Planning boundary use `{{skill:code-intelligence}}` only when optional frozen-task relationship discovery is useful and a Provider operation can run. Use CodeGraph only when `.codegraph/` already exists: prefer `codegraph_explore`, with `codegraph explore` as the non-MCP fallback. A bounded `codegraph sync` is non-blocking. When a status, sync, or explore operation runs, write a compact v2 Planning record; otherwise omit the Code Intelligence record. Confirm every returned path from repository source before adding it with the query ID as `discovered_from`; provider failure immediately falls back to the original repository search path and never blocks Planning. Include `.polaris/code-intelligence.json` and the finalized Planning record in the Working Set only when they exist. Record every entry as section, path, reason, and discovery source; add explicit entries only for concrete dependencies. Do not parse or create a duplicate Markdown Working Set. +2. Refresh `working-set.json` with `build_working_set.py`. At the Planning boundary use `{{skill:code-intelligence}}` only when frozen-task relationship discovery is useful. If policy is enabled and `.codegraph/` exists, call `polaris_codegraph_explore` with stage `PLANNING` and a bounded frozen-scope query. Read its freshness envelope before graph content. Confirm every safe current returned path from repository source before adding it with the query ID as `discovered_from`; confirm a safe missing/deleted path through the registered subject Git diff. Proxy failure immediately uses the original source/Git fallback and never blocks Planning. Include `.polaris/code-intelligence.json` and the projected Planning record in the Working Set only when they exist. Record every entry as section, path, reason, and discovery source; add explicit entries only for concrete dependencies. Do not parse or create a duplicate Markdown Working Set. 3. Investigate only paths justified by the task or a discovered dependency. Provider observations cannot expand frozen scope. 4. Write `PLAN.md` as a delta from `base_commit`, including alternatives, risks, affected invariants, and expected documentation changes. Keep rationale in Markdown; do not use it as decision authority. 5. Map every acceptance criterion to a planned validation command or Human check. Code Intelligence observations are not acceptance evidence. @@ -21,4 +21,4 @@ After the transition succeeds, reload state and emit `[POLARIS:PLAN_READY]` with Do not modify the frozen Work Item or start implementation from this stage. -CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response; when `RESPONSE_BANNER` is present, persist its successful explore response hash as `freshness.response_sha256`. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Each `SEARCH_SOURCE` fallback records `result_paths`: zero or at most 100 unique POSIX paths, each a current confined regular file with its current SHA-256; non-`SEARCH_SOURCE` fallbacks use empty `result_paths`. Graph evidence cannot expand scope or act as a gate. +Proxy evidence contract: `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN` are `NAVIGATION_ONLY`. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. After a proxy operation, write annotations and run `record_code_intelligence.py --repo . --bundle --annotations ` to project v3; without a proxy operation, omit the Code Intelligence record. Never run `codegraph init` or manage the Provider. diff --git a/skills/code-intelligence/SKILL.md b/skills/code-intelligence/SKILL.md index cbe7a39..e584d19 100644 --- a/skills/code-intelligence/SKILL.md +++ b/skills/code-intelligence/SKILL.md @@ -5,21 +5,23 @@ description: Internal optional Polaris stage support for bounded CodeGraph relat # Code Intelligence -Treat Code Intelligence as read-only, best-effort evidence. Source, Git, builds, tests, and frozen Polaris artifacts remain authority. +CodeGraph is optional navigation context. Source, Git, builds, tests, frozen artifacts, Review, Validation, and Human decisions remain authority. -1. Load `.polaris/code-intelligence.json` when present and project rules. If policy disables Code Intelligence or the repository has no `.codegraph/` directory, use the stage's source path and omit the Code Intelligence record because no Provider operation ran. Stop CodeGraph calls for this project for the session and tell the user they may choose to initialize it; never run `codegraph init`. -2. At the calling stage's declared boundary, run `code_intelligence_runtime.py status` or `sync-if-needed`. The latter may run one bounded `codegraph sync` only when status reports pending changes; it never loops, waits for a watcher, or treats a successful command as a gate. -3. For an allowed frozen-scope relationship query, use only `codegraph_explore` when MCP exposes it. If MCP is unavailable and the executable is available, use `codegraph explore` as the non-MCP fallback. Do not select retired narrow operations. Bound the query to the Work Item, Working Set, registered subject, or a confirmed dependency; graph output cannot expand frozen scope, authorize change, satisfy acceptance, or determine a Review verdict. -4. Save each raw explore response only below the task's ignored `runtime/code-intelligence/` directory, then run `code_intelligence_runtime.py classify-response` for it. When `RESPONSE_BANNER` is a freshness basis, persist that successful explore response hash as `freshness.response_sha256`; final records contain the response hash and finite summary, never the response itself. -5. On `PARTIAL_STALE`, process every named path by its current safe state. If it is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256. If a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence. For unsafe paths, record `NOT_VERIFIED` and use source search. The remaining graph response may still be navigation evidence, but never a conclusion about a stale path. -6. On `INDEX_STALE` or `NOT_VERIFIED`, use repository source search and Git evidence, record the `SEARCH_SOURCE` fallback, and stop repeated graph calls for that stage. Every `SEARCH_SOURCE` fallback records `result_paths`: zero or at most 100 unique POSIX paths, each a current confined regular file with its current SHA-256; non-`SEARCH_SOURCE` fallbacks use empty `result_paths`. On malformed, missing, or unavailable Provider output, continue the same source fallback without blocking the stage. -7. Never initialize, install, start, authenticate, or reconfigure CodeGraph. Do not manage its watcher, daemon, lock, or host MCP settings. -8. Finalize an immutable v2 Code Intelligence record only after an actual Provider status, sync, or explore operation, including its real freshness, stale points, and source fallbacks. If no operation ran, omit the Code Intelligence record. Code Intelligence is never a workflow gate. +1. Load `.polaris/code-intelligence.json` and project rules. If policy disables Code Intelligence or the repository root lacks `.codegraph/`, skip the proxy, use source/Git, and omit the Code Intelligence record because no proxy operation ran. Never run `codegraph init`. +2. For Polaris graph evidence call only `polaris_codegraph_explore`, using the active task ID, legal stage, next `CIQ-NNN`, bounded purpose/query, and the stage's declared `sync_if_needed` value. The project registration fixes the repository root. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after an envelope requires the stage fallback. +3. Read the `freshness envelope` before any graph content: + - `CURRENT` with `usage: NON_AUTHORITATIVE_CONTEXT` permits the graph only as non-authoritative context. + - `STALE` or `UNKNOWN` with `usage: NAVIGATION_ONLY` requires every named source/Git fallback. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only the resulting current source/Git evidence does. Index-wide uncertainty affects the entire graph response. `UNKNOWN` is never current. + - `UNAVAILABLE` with `usage: NO_GRAPH` means use source/Git and do not expect graph content. +4. Complete fallbacks exactly. For a safe named current regular file, read it and record `READ_SOURCE` with its current SHA-256. For a safe missing/deleted path, inspect the registered subject diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff hashes. For an unsafe path or index-wide stale/unknown result, perform an actual bounded repository search and record `SEARCH_SOURCE` with zero to 100 unique confined POSIX `result_paths`, each a current regular file and current SHA-256; an empty result is valid only when that search found no current file. In `STALE`/`UNKNOWN`, annotate a symbol only when its current path is covered by `READ_SOURCE` or a hashed `SEARCH_SOURCE` result. +5. A raw `codegraph_explore` MCP call or `codegraph explore` shell command remains user-accessible out-of-band, but its output is always unverified for Polaris and cannot back `CURRENT` Polaris evidence. Never project raw Provider output into a Polaris record. +6. If the proxy ran, use the envelope's `evidence_bundle` path, write an annotations JSON containing only `summary`, confirmed `symbols`, and completed `source_fallbacks`, then run `record_code_intelligence.py --repo . --bundle --annotations `. This projects an immutable v3 record; do not hand-author records. If the proxy did not run, omit the Code Intelligence record and optional artifact reference. +7. Never install, initialize, start, authenticate, configure, reconfigure, or manage CodeGraph, its watcher, daemon, lock, raw MCP registration, or project index. Proxy failure and every non-current state are non-gating. Stage policy: -- Planning: at the Planning boundary, request only frozen-task relationship discovery needed to justify Working Set entries; confirm every returned path in repository source and record its query ID as `discovered_from`. -- Implementation: before editing, request only handoff-scoped edit relationships. Query again mid-stage only when a later declared implementation step depends on relationships changed by the current subject. -- Documentation Sync: run `sync-if-needed` once only when the final subject changed supported source files and the Provider is available; otherwise omit the Code Intelligence record. -- Review: independently request only registered-subject impact relationships. Do not reuse Implementer query conclusions. -- Validation: do not invoke this Skill; use builds, tests, static checks, and Human Checks as the acceptance evidence. +- Planning: query only frozen-task relationships needed to justify Working Set entries. Confirm safe current returned paths in current source before recording the query ID as `discovered_from`; confirm a safe missing/deleted path through the registered subject Git diff instead. +- Implementation: make a bounded handoff-scoped call before editing when useful. Any conclusion needed after edits requires a fresh `polaris_codegraph_explore` call; never reuse the entry freshness envelope. If an earlier non-current envelope ended graph use for the stage, use source/Git only rather than making that post-edit call. +- Documentation Sync: only when supported source changed, make one query over changed source paths and documented symbols with `sync_if_needed: true`; there is no separate status/sync MCP tool. +- Review: independently query only registered-subject impact relationships. Never inherit or reuse the Implementer's envelope, bundle, or conclusions. +- Validation: do not invoke this Skill. Validation remains graph-free. diff --git a/skills/documentation-sync/SKILL.md b/skills/documentation-sync/SKILL.md index b906cc5..c9793b4 100644 --- a/skills/documentation-sync/SKILL.md +++ b/skills/documentation-sync/SKILL.md @@ -12,7 +12,7 @@ description: Internal Polaris worker stage for an explicitly started `{{skill:en 5. Record failed attempts with `record_exploration.py`. Keep task-only conclusions in the task; promote reusable, evidence-backed conclusions to `.polaris/explorations/` with the same script. 6. Leave no unresolved `STALE` entry. 7. Create the final subject checkpoint and recompute the subject diff hash. -8. When the final subject includes supported source changes and the Provider is available, invoke `{{skill:code-intelligence}}` once at the Documentation Sync boundary with `sync-if-needed`. Use CodeGraph only with an existing `.codegraph/` directory: prefer `codegraph_explore`, with `codegraph explore` as the non-MCP fallback. A bounded `codegraph sync` is non-blocking. If a Provider operation ran, reference its immutable v2 record from the Knowledge Delta; otherwise omit the Code Intelligence record and its optional artifact reference. Never claim commit-exact freshness. +8. When the final subject includes supported source changes, policy is enabled, and `.codegraph/` exists, invoke `{{skill:code-intelligence}}` once at the Documentation Sync boundary. Call `polaris_codegraph_explore` with stage `DOCUMENTATION_SYNC`, a query limited to changed source paths and documented symbols, and `sync_if_needed: true`; there is no separate status/sync MCP tool. Read the freshness envelope first and finish every required source/Git fallback. Then create annotations and run `record_code_intelligence.py --repo . --bundle --annotations ` to project the immutable v3 record and reference it from the Knowledge Delta. Otherwise omit the Code Intelligence record and optional artifact reference. 9. Refresh the Working Set if a promoted exploration, documentation change, or confirmed Code Intelligence dependency alters the next stage's justified inputs. 10. Run `check_docs.py` with the final subject base/head. When live telemetry exists, append its result with `ADD_CHECK`, then use `SET_PHASE` to enter `COMPLETED` with no blocker. Return the Knowledge Delta path, final subject base/head, diff hash, changed documentation, promoted explorations, Code Intelligence refresh status, and check result. @@ -20,4 +20,4 @@ Do not run workflow transitions or emit a Polaris checkpoint marker. The main `{ Do not edit Review, Validation, Result, event, or state artifacts directly. -CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response; when `RESPONSE_BANNER` is present, persist its successful explore response hash as `freshness.response_sha256`. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Each `SEARCH_SOURCE` fallback records `result_paths`: zero or at most 100 unique POSIX paths, each a current confined regular file with its current SHA-256; non-`SEARCH_SOURCE` fallbacks use empty `result_paths`. Graph evidence never gates documentation checks or state changes. +Proxy evidence contract: `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN` are `NAVIGATION_ONLY`. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. Never run `codegraph init` or manage the Provider. Graph evidence never gates documentation checks or state changes. diff --git a/skills/implementation/SKILL.md b/skills/implementation/SKILL.md index 308b586..95240af 100644 --- a/skills/implementation/SKILL.md +++ b/skills/implementation/SKILL.md @@ -6,7 +6,7 @@ description: Internal Polaris worker stage for an explicitly started `{{skill:en # Implementation 1. Require the task ID and registered Implementation handoff path returned by the main task. Load only that handoff and its package as task context; read `state.json` only to verify registration. Use paths carried by the handoff or resolved by `task_layout.py`; never reconstruct them from prose. Do not read the main conversation or infer unstated requirements. -2. Confirm state is `IMPLEMENTING`, the handoff hash matches `state.json`, and `artifact_attempt`, revision, base commit, output path, and progress paths are current. At the Implementation boundary invoke `{{skill:code-intelligence}}` before editing for optional handoff-scoped edit relationships, first using `status` or `sync-if-needed`. Use CodeGraph only with an existing `.codegraph/` directory: prefer `codegraph_explore`, with `codegraph explore` as the non-MCP fallback. A bounded `codegraph sync` is non-blocking. Missing or failing Provider output immediately falls back to direct source reading. Query again during Implementation only when a later declared step depends on relationships from newly changed code. +2. Confirm state is `IMPLEMENTING`, the handoff hash matches `state.json`, and `artifact_attempt`, revision, base commit, output path, and progress paths are current. At the Implementation boundary invoke `{{skill:code-intelligence}}` before editing when handoff-scoped relationships are useful. With enabled policy and an existing `.codegraph/`, call only `polaris_codegraph_explore` and read its freshness envelope before graph content. Missing, failing, stale, or unknown output immediately follows the required source/Git fallback. A conclusion about relationships changed by current edits requires a fresh proxy call after edits and never reuses the entry envelope, except that an earlier non-current envelope ends graph use for this stage and requires source/Git only. 3. Generate one stable Implementer session ID for this conversation. Before changing code, create a non-empty ordered `implementation_steps` list. Every step receives the next `STEP-NNN` ID and must reference one or more acceptance IDs from the frozen Work Item. If an ignored live snapshot was initialized, mirror the list through its `DEFINE_STEPS` event. 4. Execute steps linearly. When live telemetry exists, use `START_STEP`, then `COMPLETE_STEP`, `BLOCK_STEP`, or `RESUME_STEP`; use `SKIP_STEP` only with an explicit reason. Existing step identity, title, order, and acceptance bindings are immutable. Newly discovered work may only be added at the end, using `APPEND_STEP` when telemetry exists. Never edit `progress.json` directly or create it as a durable prerequisite. 5. Change only declared subject paths and protect unrelated user changes. Work in small build/test/fix loops. @@ -14,9 +14,9 @@ description: Internal Polaris worker stage for an explicitly started `{{skill:en 7. Record Plan deviations and reasons. After Review rejection, load the handoff's prior Review, answer every open Finding once in an immutable Review Response, and bind it to the new subject. 8. Run planned local checks and record reproducible evidence in the immutable Implementation artifact. When live telemetry exists, append reproducible evidence to the snapshot with `ADD_CHECK`; without a snapshot, do not initialize one merely to report checks. Never report a made-up percentage; derive completed, current, and remaining work from the ordered steps. 9. Complete or explicitly skip every step, then create a subject checkpoint commit containing scoped code, tests, build configuration, and relevant project docs only. -10. If this stage actually performed a Provider status, sync, or explore operation, finalize an immutable v2 Implementation Code Intelligence record and reference it. If no Provider operation ran, omit the Code Intelligence record and its optional artifact reference. Write the immutable Implementation JSON at the handoff's `output_path`, bind the handoff, subject, session, deviations, and checks, and copy the exact terminal `id`, `status`, and `result` projection into `step_results`. Code Intelligence evidence is never a gate. +10. If this stage ran the proxy, create its annotations and run `record_code_intelligence.py --repo . --bundle --annotations ` to project the immutable v3 Implementation record and reference it. If no proxy operation ran, omit the Code Intelligence record and its optional artifact reference. Write the immutable Implementation JSON at the handoff's `output_path`, bind the handoff, subject, session, deviations, and checks, and copy the exact terminal `id`, `status`, and `result` projection into `step_results`. Code Intelligence evidence is never a gate. 11. After every step is `COMPLETED` or `SKIPPED`, use `SET_PHASE` to enter `CHECKPOINTING` only when live telemetry exists. Return the artifact path, session ID, subject base/head, diff hash, step results, checks, deviations, Review Response path when present, and remaining Documentation Sync work. Do not run workflow transitions, Review, Validation, or task closure. Do not emit a Polaris checkpoint marker; the main `{{skill:engineering-task}}` validates the artifact and continues this same task for `{{skill:documentation-sync}}` while authority remains `IMPLEMENTING`. -CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response; when `RESPONSE_BANNER` is present, persist its successful explore response hash as `freshness.response_sha256`. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Each `SEARCH_SOURCE` fallback records `result_paths`: zero or at most 100 unique POSIX paths, each a current confined regular file with its current SHA-256; non-`SEARCH_SOURCE` fallbacks use empty `result_paths`. Graph evidence never gates implementation. +Proxy evidence contract: `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN` are `NAVIGATION_ONLY`. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. Never run `codegraph init` or manage the Provider. diff --git a/templates/AGENTS.md b/templates/AGENTS.md index a4d44b8..30726bc 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -15,8 +15,9 @@ ## Optional CodeGraph rules -- Use CodeGraph only when the repository root already contains `.codegraph/`. When it is absent, stop CodeGraph calls for this session and use repository source and Git; a user may choose to initialize CodeGraph, but agents must never run `codegraph init`. -- Prefer MCP `codegraph_explore`; when MCP is unavailable, use `codegraph explore` as the CLI fallback. A bounded `codegraph sync` may run only through the Polaris stage boundary procedure and never gates a task. -- Save and classify every graph response in task runtime; when `RESPONSE_BANNER` is present, persist its successful explore response hash as `freshness.response_sha256`. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, treat graph output only as a lead, use source search and Git evidence, and stop repeated graph calls for that stage. Each `SEARCH_SOURCE` fallback records `result_paths`: zero or at most 100 unique POSIX paths, each a current confined regular file with its current SHA-256; non-`SEARCH_SOURCE` fallbacks use empty `result_paths`. -- Never install, start, authenticate, reconfigure, or manage CodeGraph, its watcher, daemon, lock, or MCP settings. CodeGraph cannot expand frozen scope or replace source, Git, builds, tests, Review, Validation, or Human gates. +- Use CodeGraph only when project policy permits it and the repository root already contains `.codegraph/`. Otherwise skip the proxy, use source/Git, and omit the Code Intelligence record; agents never run `codegraph init`. +- For Polaris evidence call only `polaris_codegraph_explore` and read its freshness envelope before graph content. `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN` are `NAVIGATION_ONLY`. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. `UNAVAILABLE` means no graph. +- Complete fallbacks exactly: a safe current regular file uses `READ_SOURCE` with current SHA-256; a safe missing/deleted path uses `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff hashes; unsafe or index-wide stale/unknown results use `SEARCH_SOURCE` with finite confined POSIX result paths and current hashes. +- A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. If the proxy ran, write annotations and run `record_code_intelligence.py --repo . --bundle --annotations ` to project v3; do not hand-author a record. +- Never install, initialize, start, authenticate, configure, reconfigure, or manage CodeGraph, its watcher, daemon, lock, raw MCP registration, or index. CodeGraph cannot expand frozen scope or replace source, Git, builds, tests, Review, Validation, or Human gates. - Preserve any installer-managed marker block exactly as owned by that installer; Polaris does not add, edit, or remove installer marker fences. diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 2b68599..a5b19ce 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -5,6 +5,7 @@ import hashlib import io import json +import os import shutil import subprocess import sys @@ -961,6 +962,256 @@ def test_mcp_server_emits_jsonrpc_parse_and_method_errors_one_per_line(self) -> self.assertEqual([item["error"]["code"] for item in responses], [-32700, -32602, -32601]) self.assertTrue(all("\n" not in line for line in completed_process.stdout.splitlines())) + def test_vendored_mcp_proxy_runs_one_auditable_fake_cli_window(self) -> None: + """Registered MCP, fake CLI, v3 projection, and Validation compose end to end.""" + with tempfile.TemporaryDirectory(prefix="polaris-codegraph-e2e-") as temporary: + fixture_root = Path(temporary) + repo = fixture_root / "repository" + fake_bin = fixture_root / "bin" + repo.mkdir() + fake_bin.mkdir() + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run( + ["git", "config", "user.email", "polaris@test.local"], + cwd=repo, + check=True, + ) + subprocess.run( + ["git", "config", "user.name", "Polaris Test"], + cwd=repo, + check=True, + ) + vendor(ROOT, repo, False) + init_project(repo, "codegraph-e2e") + source = repo / "src/a.py" + source.parent.mkdir() + source.write_text("class A:\n pass\n", encoding="utf-8") + (repo / ".codegraph").mkdir() + subprocess.run(["git", "add", "."], cwd=repo, check=True) + subprocess.run( + ["git", "commit", "-q", "-m", "initialize fixture"], + cwd=repo, + check=True, + ) + init_task(repo, "TASK-0001", "R1") + work_item_path = ( + repo + / ".polaris/tasks/TASK-0001/revisions/work-item-r001.json" + ) + work_item = json.loads(work_item_path.read_text(encoding="utf-8")) + work_item.update({ + "title": "Exercise the registered proxy", + "goal": "Prove one bounded CodeGraph window", + "motivation": "Keep graph evidence auditable", + }) + work_item["scope"]["in"] = ["src/a.py"] + work_item["acceptance"][0].update({ + "statement": "The registered proxy emits a current envelope", + "evidence": "v3 Code Intelligence record", + }) + work_item["implementation_dispatch"]["authorized"] = True + work_item["review_dispatch"]["authorized"] = True + write_json_atomic(work_item_path, work_item) + transition( + repo, + "TASK-0001", + "QUALIFY", + [], + None, + None, + None, + None, + None, + None, + ) + + call_log = fixture_root / "codegraph-calls.jsonl" + executable = fake_bin / "codegraph" + write_text_atomic( + executable, + """#!/usr/bin/env python3 +import json +import os +import sys +from pathlib import Path + +log = Path(os.environ["POLARIS_FAKE_CODEGRAPH_LOG"]) +entry = {"cwd": str(Path.cwd().resolve()), "argv": sys.argv[1:]} +with log.open("a", encoding="utf-8") as stream: + stream.write(json.dumps(entry, separators=(",", ":")) + "\\n") +entries = [json.loads(line) for line in log.read_text(encoding="utf-8").splitlines()] +args = sys.argv[1:] +if args == ["status", "--json"]: + status_count = sum(item["argv"] == ["status", "--json"] for item in entries) + pending = 1 if status_count == 1 else 0 + print(json.dumps({ + "initialized": True, + "projectPath": str(Path.cwd().resolve()), + "pendingChanges": {"added": 0, "modified": pending, "removed": 0}, + "worktreeMismatch": None, + "index": {"state": "complete", "pendingRefs": 0, "reindexRecommended": False}, + })) +elif args == ["sync", "--quiet"]: + print("synchronized") +elif len(args) == 2 and args[0] == "explore": + print("A is defined in src/a.py") +else: + print("unexpected fake CodeGraph arguments", file=sys.stderr) + raise SystemExit(2) +""", + ) + executable.chmod(0o755) + environment = os.environ.copy() + environment["PATH"] = str(fake_bin) + os.pathsep + environment["PATH"] + environment["POLARIS_FAKE_CODEGRAPH_LOG"] = str(call_log) + + registration = json.loads((repo / ".mcp.json").read_text(encoding="utf-8"))[ + "mcpServers" + ]["polaris-codegraph"] + transcript = "\n".join([ + json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "Polaris test", "version": "1"}, + }, + }), + json.dumps({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + }), + json.dumps({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "polaris_codegraph_explore", + "arguments": { + "task_id": "TASK-0001", + "stage": "PLANNING", + "query_id": "CIQ-001", + "purpose": "locate A", + "query": "symbol A", + "sync_if_needed": True, + }, + }, + }), + ]) + "\n" + mcp = subprocess.run( + [registration["command"], *registration["args"]], + cwd=repo, + env=environment, + input=transcript, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(mcp.returncode, 0, mcp.stderr) + responses = [json.loads(line) for line in mcp.stdout.splitlines()] + self.assertEqual([response["id"] for response in responses], [1, 2]) + tool_result = responses[1]["result"] + self.assertFalse(tool_result["isError"]) + self.assertTrue( + tool_result["content"][0]["text"].startswith( + "[POLARIS_CODEGRAPH_FRESHNESS]\nstate: CURRENT\n" + ) + ) + self.assertEqual( + tool_result["content"][1]["text"], "A is defined in src/a.py\n" + ) + bundle = tool_result["structuredContent"]["bundle"] + envelope = tool_result["content"][0]["text"] + bundle_relative = next( + line.split(": ", 1)[1] + for line in envelope.splitlines() + if line.startswith("evidence_bundle: ") + ) + task_relative = Path(".polaris/tasks/TASK-0001") + bundle_repo_relative = task_relative / bundle_relative + bundle_path = repo / bundle_repo_relative + response_relative = task_relative / bundle["response_path"] + for ignored in (bundle_repo_relative, response_relative): + ignored_result = subprocess.run( + ["git", "check-ignore", "-q", ignored.as_posix()], + cwd=repo, + check=False, + ) + self.assertEqual(ignored_result.returncode, 0, ignored.as_posix()) + + calls = [ + json.loads(line) + for line in call_log.read_text(encoding="utf-8").splitlines() + ] + self.assertEqual( + [entry["argv"] for entry in calls], + [ + ["status", "--json"], + ["sync", "--quiet"], + ["status", "--json"], + ["explore", "symbol A"], + ["status", "--json"], + ], + ) + self.assertTrue(all(entry["cwd"] == str(repo.resolve()) for entry in calls)) + + annotations_path = fixture_root / "annotations.json" + write_json_atomic( + annotations_path, + { + "summary": "Located A through the bounded proxy.", + "symbols": [{"path": "src/a.py", "line": 1, "name": "A"}], + "source_fallbacks": [], + }, + ) + recorder = subprocess.run( + [ + sys.executable, + "tools/polaris/scripts/record_code_intelligence.py", + "TASK-0001", + "--repo", + ".", + "--bundle", + bundle_repo_relative.as_posix(), + "--annotations", + str(annotations_path), + "--json", + ], + cwd=repo, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(recorder.returncode, 0, recorder.stderr) + record_result = json.loads(recorder.stdout) + record_value = json.loads( + Path(record_result["path"]).read_text(encoding="utf-8") + ) + self.assertEqual( + record_value["proxy"]["evidence_bundle_sha256"], + file_sha256(bundle_path), + ) + + calls_before_validation = call_log.read_bytes() + validation = subprocess.run( + [ + sys.executable, + "tools/polaris/scripts/validate_project.py", + "--repo", + ".", + "--json", + ], + cwd=repo, + env=environment, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(validation.returncode, 0, validation.stderr) + self.assertEqual(call_log.read_bytes(), calls_before_validation) + def test_v3_record_projects_exact_proxy_bundle(self) -> None: recorded, query = self.record_current_v3_fixture() self.assertEqual(recorded["record_version"], 3) @@ -1063,6 +1314,22 @@ def current_to_stale(value: dict[str, object]) -> None: self.repo, "TASK-0001", stale_without_fallback, ROOT ) + unconfirmed_symbol = copy.deepcopy(stale_without_fallback) + unconfirmed_symbol["source_fallbacks"] = [{ + "action": "SEARCH_SOURCE", + "path": None, + "observed_sha256": None, + "base_commit": None, + "head_commit": None, + "diff_hash": None, + "purpose": "inspect current repository source", + "result_paths": [], + }] + with self.assertRaisesRegex(RuleFailure, "symbol.*source fallback"): + protocol.validate_record_value( + self.repo, "TASK-0001", unconfirmed_symbol, ROOT + ) + unsafe_fallback = copy.deepcopy(stale_without_fallback) unsafe_fallback["source_fallbacks"] = [{ "action": "SEARCH_SOURCE", @@ -2517,45 +2784,19 @@ def test_official_descriptor_uses_explore_status_and_sync(self) -> None: ) self.assertEqual(descriptor["cli"]["sync_args"], ["sync", "--quiet"]) - def test_all_agent_surfaces_share_codegraph_fallback_rules(self) -> None: - """Stage instructions keep CodeGraph stale-data fallbacks identical per host.""" - required_fragments = ( - ".codegraph/", - "codegraph_explore", - "codegraph explore", - "codegraph sync", - "PARTIAL_STALE", - "INDEX_STALE", - "directly read", - "never run `codegraph init`", - ) - partial_stale_branches = ( - "current confined regular file", - "READ_SOURCE", - "current SHA-256", - "missing/deleted", - "INSPECT_GIT_DIFF", - "null observed SHA-256", - "base/head/diff evidence", - "unsafe paths", - "NOT_VERIFIED", - "source search", - ) - audit_binding_fragments = ( - "freshness.response_sha256", - "successful explore response", - "result_paths", - "at most 100", - "POSIX", - "current confined regular file", - "empty `result_paths`", - ) - retired_operations = ( - "symbol" + "_search", - "call" + "_graph", - "review" + "_context", - "refresh" + "_files", - "refresh" + "_workspace", + def test_all_agent_surfaces_require_proxy_provenance(self) -> None: + """Every CodeGraph-capable stage requires the proxy envelope and fallbacks.""" + anchors = ( + "polaris_codegraph_explore", + "freshness envelope", + "NON_AUTHORITATIVE_CONTEXT", + "NAVIGATION_ONLY", + "never substantiates", + "source/Git fallback", + "no separate status/sync MCP tool", + "do not retry", + "raw `codegraph_explore`", + "cannot back `CURRENT` Polaris evidence", ) stage_skills = ( "code-intelligence", @@ -2565,6 +2806,15 @@ def test_all_agent_surfaces_share_codegraph_fallback_rules(self) -> None: "documentation-sync", ) available_skills = set(discover_skills(ROOT)) + + def assert_contract(text: str, label: str) -> None: + for anchor in anchors: + self.assertIn(anchor, text, f"{label}: {anchor}") + self.assertIn("record_code_intelligence.py", text, label) + self.assertIn("--bundle", text, label) + self.assertIn("--annotations", text, label) + self.assertIn("v3", text, label) + for adapter in load_host_adapters(ROOT): for skill_name in stage_skills: source = (ROOT / "skills" / skill_name / "SKILL.md").read_text( @@ -2573,30 +2823,59 @@ def test_all_agent_surfaces_share_codegraph_fallback_rules(self) -> None: rendered = render_skill( source, skill_name, adapter, available_skills ) - for fragment in required_fragments: - self.assertIn(fragment, rendered, f"{adapter['host_id']}:{skill_name}") - for fragment in partial_stale_branches: - self.assertIn(fragment, rendered, f"{adapter['host_id']}:{skill_name}") - for fragment in audit_binding_fragments: - self.assertIn(fragment, rendered, f"{adapter['host_id']}:{skill_name}") - self.assertIn("v2", rendered, f"{adapter['host_id']}:{skill_name}") - self.assertNotIn("directly read every listed stale file", rendered) - for retired in retired_operations: - self.assertNotIn(retired, rendered, f"{adapter['host_id']}:{skill_name}") - - validation = (ROOT / "skills" / "validation" / "SKILL.md").read_text( - encoding="utf-8" - ) - self.assertIn("Do not invoke Code Intelligence", validation) + assert_contract(rendered, f"{adapter['host_id']}:{skill_name}") agents = (ROOT / "templates" / "AGENTS.md").read_text(encoding="utf-8") - self.assertIn("stop CodeGraph calls for this session", agents) - self.assertIn("installer-managed marker block", agents) - for fragment in partial_stale_branches: - self.assertIn(fragment, agents) - for fragment in audit_binding_fragments: - self.assertIn(fragment, agents) - self.assertNotIn("directly read every listed stale file", agents) + assert_contract(agents, "templates/AGENTS.md") + + rendered = render_skill( + (ROOT / "skills/implementation/SKILL.md").read_text(encoding="utf-8"), + "implementation", + load_host_adapters(ROOT)[0], + available_skills, + ) + for mutation in ( + rendered.replace("polaris_codegraph_explore", "missing_proxy"), + rendered.replace("source/Git fallback", "missing fallback"), + ): + with self.assertRaises(AssertionError): + assert_contract(mutation, "mutated implementation") + + def test_documentation_sync_uses_one_proxy_query(self) -> None: + """Documentation Sync uses one bounded changed-path/symbol proxy query.""" + source = (ROOT / "skills/documentation-sync/SKILL.md").read_text( + encoding="utf-8" + ) + for adapter in load_host_adapters(ROOT): + rendered = render_skill( + source, + "documentation-sync", + adapter, + set(discover_skills(ROOT)), + ) + for anchor in ( + "polaris_codegraph_explore", + "sync_if_needed: true", + "changed source paths", + "documented symbols", + "no separate status/sync MCP tool", + ): + self.assertIn(anchor, rendered, f"{adapter['host_id']}: {anchor}") + + def test_validation_remains_graph_free(self) -> None: + """Validation never invokes the proxy or raw CodeGraph lifecycle commands.""" + source = (ROOT / "skills/validation/SKILL.md").read_text(encoding="utf-8") + self.assertIn("Do not invoke Code Intelligence", source) + for adapter in load_host_adapters(ROOT): + rendered = render_skill( + source, "validation", adapter, set(discover_skills(ROOT)) + ) + for forbidden in ( + "polaris_codegraph_explore", + "codegraph status", + "codegraph sync", + ): + self.assertNotIn(forbidden, rendered, adapter["host_id"]) def test_provider_requires_marker_and_accepts_mcp_or_cli(self) -> None: self.assertIsNone( From 91e4c2d1cfe711c97c8722f3dfd169e65e2c708d Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 04:09:47 +0800 Subject: [PATCH 10/28] fix: preserve failed CodeGraph proxy evidence --- scripts/code_intelligence_mcp.py | 7 + .../internal/code_intelligence_protocol.py | 24 +- scripts/internal/code_intelligence_proxy.py | 10 +- scripts/internal/codegraph_adapter.py | 21 +- tests/test_codegraph.py | 210 +++++++++++++++++- 5 files changed, 256 insertions(+), 16 deletions(-) diff --git a/scripts/code_intelligence_mcp.py b/scripts/code_intelligence_mcp.py index ab01a11..919afb7 100644 --- a/scripts/code_intelligence_mcp.py +++ b/scripts/code_intelligence_mcp.py @@ -21,6 +21,7 @@ PROTOCOL_VERSION = "2025-11-25" TOOL_NAME = "polaris_codegraph_explore" +SERVER_ROOT = Path(__file__).resolve().parent.parent TOOL = { "name": TOOL_NAME, "description": "Run one bounded Polaris CodeGraph freshness window.", @@ -130,6 +131,10 @@ def __init__(self, repo: Path) -> None: if raw_repo.is_symlink() or not raw_repo.is_dir(): raise InputFailure("MCP repository root must be a fixed real directory") self.repo = raw_repo.resolve() + if protocol_root(self.repo).resolve() != SERVER_ROOT: + raise RuleFailure( + "MCP repository does not match the executing vendored protocol root" + ) require_regular_file( self.repo / ".polaris/project.json", "Polaris project configuration" ) @@ -240,6 +245,8 @@ def _write_message(value: dict[str, Any]) -> None: def serve(repo: Path) -> int: + if repo.absolute().resolve() != Path.cwd().resolve(): + raise InputFailure("MCP repository must match the process working directory") server = McpServer(repo) for line in sys.stdin: try: diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index fa26eb4..93296a1 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -1011,8 +1011,8 @@ def _validate_v3_record_value( ) if sync is None and post_sync is not None: raise RuleFailure("post-sync status requires one sync attempt") - if sync is not None and post_sync is None: - raise RuleFailure("attempted sync requires post-sync status evidence") + if sync is not None and sync["status"] == "SUCCESS" and post_sync is None: + raise RuleFailure("successful sync requires post-sync status evidence") if sync is not None and sync["status"] == "SUCCESS" and ( post_sync["status"] != "CURRENT_AT_CHECK" or post_sync["needs_sync"] ): @@ -1029,12 +1029,24 @@ def _validate_v3_record_value( delivery = value["delivery"] for point in delivery["stale_points"]: _validate_v3_stale_point(repo, point) - effective = post_sync if sync is not None else pre + effective = post_sync if post_sync is not None else pre observed_points: list[dict[str, Any]] = [] for observation in (effective, post): if observation is None: continue observed_points.extend(observation["stale_points"]) + if ( + observation is effective + and sync is not None + and sync["status"] == "FAILED" + ): + observed_points.append({ + "scope": "INDEX", + "path": None, + "reason": "SYNC_FAILED", + "fallback": "SEARCH_SOURCE", + "observed_sha256": None, + }) pending = observation["pending_changes"] if isinstance(pending, dict) and any(pending.values()): pending_point = { @@ -1068,7 +1080,11 @@ def _validate_v3_record_value( state = delivery["state"] expected_record_status = { "CURRENT": "CURRENT_AT_CHECK", - "STALE": delivery["record_status"], + "STALE": ( + "INDEX_STALE" + if any(point["scope"] == "INDEX" for point in delivery["stale_points"]) + else "PARTIAL_STALE" + ), "UNKNOWN": "NOT_VERIFIED", "UNAVAILABLE": "UNAVAILABLE", }[state] diff --git a/scripts/internal/code_intelligence_proxy.py b/scripts/internal/code_intelligence_proxy.py index aa6ab88..24f2e45 100644 --- a/scripts/internal/code_intelligence_proxy.py +++ b/scripts/internal/code_intelligence_proxy.py @@ -336,8 +336,6 @@ def _delivery( elif unknown: state = "UNKNOWN" record_status = "NOT_VERIFIED" - if not any(point.get("reason") == "STATUS_UNREADABLE" for point in points): - points.append(dict(_INDEX_FALLBACK)) if forced_unknown: reason = "RESPONSE_INTEGRITY_UNVERIFIED" elif _is_unknown(effective_pre): @@ -497,7 +495,7 @@ def execute_proxy_query( ) bundle["sync"] = synchronized["sync"] effective_pre = synchronized["freshness"] - bundle["post_sync_status"] = effective_pre + bundle["post_sync_status"] = synchronized["post_sync_status"] if effective_pre["status"] in {"UNAVAILABLE", "NOT_VERIFIED"}: bundle["query"]["status"] = ( @@ -534,7 +532,11 @@ def execute_proxy_query( query_result = run_explore(repo, descriptor, query, runner=runner) bundle["query"].update({ "status": query_result["status"], - "response_sha256": query_result["response_sha256"], + "response_sha256": ( + query_result["response_sha256"] + if query_result["status"] == "SUCCESS" + else None + ), "error": query_result["error"], }) response: str | None = query_result.get("response") diff --git a/scripts/internal/codegraph_adapter.py b/scripts/internal/codegraph_adapter.py index 6f5e7fb..0d51a1d 100644 --- a/scripts/internal/codegraph_adapter.py +++ b/scripts/internal/codegraph_adapter.py @@ -438,7 +438,7 @@ def _status_result( stale_points=[_index_point(reason) for reason in stale_reasons], status_response_sha256=response_sha256, error=None, - needs_sync=False, + needs_sync=any(pending.values()), pending_changes=pending, ) @@ -562,6 +562,8 @@ def _sync_result(status: str, response_sha256: str | None, error: str | None) -> def _sync_failed( freshness: dict[str, Any], sync: dict[str, Any], + *, + post_sync_status: dict[str, Any] | None = None, ) -> dict[str, Any]: points = [*freshness["stale_points"], _index_point("SYNC_FAILED")] return { @@ -573,6 +575,7 @@ def _sync_failed( "error": sync["error"] or freshness["error"], }, "sync": sync, + "post_sync_status": post_sync_status, } @@ -591,13 +594,17 @@ def synchronize_observed_status( sync_timeout = _validated_timeout(sync_timeout_seconds) except ValueError as error: freshness = _not_verified(_checked_at(), error) - return {"freshness": freshness, "sync": _sync_result("SKIPPED", None, None)} + return { + "freshness": freshness, + "sync": _sync_result("SKIPPED", None, None), + "post_sync_status": None, + } skipped = _sync_result("SKIPPED", None, None) unavailable = _sync_result("UNAVAILABLE", None, None) if initial["status"] == "UNAVAILABLE" or _marker_path(repo, descriptor) is None: - return {"freshness": initial, "sync": unavailable} + return {"freshness": initial, "sync": unavailable, "post_sync_status": None} if not initial["needs_sync"]: - return {"freshness": initial, "sync": skipped} + return {"freshness": initial, "sync": skipped, "post_sync_status": None} try: completed = _run_cli(repo, descriptor, "sync_args", sync_timeout, runner) @@ -628,9 +635,10 @@ def synchronize_observed_status( response_sha256, "CodeGraph post-sync status is not current", ), + post_sync_status=rechecked, ) rechecked["basis"] = [*rechecked["basis"], "SYNC_ACKNOWLEDGED"] - return {"freshness": rechecked, "sync": sync} + return {"freshness": rechecked, "sync": sync, "post_sync_status": rechecked} def sync_if_needed( @@ -651,7 +659,7 @@ def sync_if_needed( initial = inspect_status( repo, descriptor, runner=runner, timeout_seconds=status_timeout ) - return synchronize_observed_status( + result = synchronize_observed_status( repo, descriptor, initial, @@ -659,3 +667,4 @@ def sync_if_needed( status_timeout_seconds=status_timeout, sync_timeout_seconds=sync_timeout, ) + return {"freshness": result["freshness"], "sync": result["sync"]} diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index a5b19ce..7062afc 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -818,8 +818,9 @@ def test_mcp_server_initializes_and_lists_one_proxy_tool(self) -> None: sys.executable, SCRIPTS / "code_intelligence_mcp.py", "--repo", - self.repo, + ".", ], + cwd=self.repo, input="".join(json.dumps(item) + "\n" for item in messages), text=True, capture_output=True, @@ -951,7 +952,8 @@ def test_mcp_server_emits_jsonrpc_parse_and_method_errors_one_per_line(self) -> json.dumps({"jsonrpc": "2.0", "id": 2, "method": "unknown", "params": {}}), ]) + "\n" completed_process = subprocess.run( - [sys.executable, SCRIPTS / "code_intelligence_mcp.py", "--repo", self.repo], + [sys.executable, SCRIPTS / "code_intelligence_mcp.py", "--repo", "."], + cwd=self.repo, input=transcript, text=True, capture_output=True, @@ -962,6 +964,40 @@ def test_mcp_server_emits_jsonrpc_parse_and_method_errors_one_per_line(self) -> self.assertEqual([item["error"]["code"] for item in responses], [-32700, -32602, -32601]) self.assertTrue(all("\n" not in line for line in completed_process.stdout.splitlines())) + def test_mcp_server_rejects_cwd_and_vendored_launcher_project_mismatch(self) -> None: + mismatched_cwd = subprocess.run( + [sys.executable, SCRIPTS / "code_intelligence_mcp.py", "--repo", self.repo], + cwd=ROOT, + input="", + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(mismatched_cwd.returncode, 2) + self.assertIn("working directory", mismatched_cwd.stderr) + + with tempfile.TemporaryDirectory(prefix="polaris-codegraph-launcher-") as temporary: + fixture = Path(temporary) + projects = [fixture / "project-a", fixture / "project-b"] + for index, project in enumerate(projects, start=1): + project.mkdir() + subprocess.run(["git", "init", "-q"], cwd=project, check=True) + vendor(ROOT, project, False) + init_project(project, f"launcher-{index}") + foreign_launcher = ( + projects[0] / "tools/polaris/scripts/code_intelligence_mcp.py" + ) + mismatched_launcher = subprocess.run( + [sys.executable, foreign_launcher, "--repo", "."], + cwd=projects[1], + input="", + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(mismatched_launcher.returncode, 2) + self.assertIn("vendored protocol root", mismatched_launcher.stderr) + def test_vendored_mcp_proxy_runs_one_auditable_fake_cli_window(self) -> None: """Registered MCP, fake CLI, v3 projection, and Validation compose end to end.""" with tempfile.TemporaryDirectory(prefix="polaris-codegraph-e2e-") as temporary: @@ -1330,6 +1366,26 @@ def current_to_stale(value: dict[str, object]) -> None: self.repo, "TASK-0001", unconfirmed_symbol, ROOT ) + contradictory_status = copy.deepcopy(stale_without_fallback) + contradictory_status["source_fallbacks"] = [{ + "action": "SEARCH_SOURCE", + "path": None, + "observed_sha256": None, + "base_commit": None, + "head_commit": None, + "diff_hash": None, + "purpose": "inspect current repository source", + "result_paths": [{ + "path": "src/a.py", + "observed_sha256": file_sha256(self.repo / "src/a.py"), + }], + }] + contradictory_status["delivery"]["record_status"] = "CURRENT_AT_CHECK" + with self.assertRaisesRegex(RuleFailure, "record freshness status"): + protocol.validate_record_value( + self.repo, "TASK-0001", contradictory_status, ROOT + ) + unsafe_fallback = copy.deepcopy(stale_without_fallback) unsafe_fallback["source_fallbacks"] = [{ "action": "SEARCH_SOURCE", @@ -1352,6 +1408,129 @@ def current_to_stale(value: dict[str, object]) -> None: with self.assertRaisesRegex(InputFailure, "record_version 3"): record(self.repo, "TASK-0001", self.v2_record(), ROOT) + def test_failed_explore_proxy_bundle_projects_to_unknown_v3(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + source = self.repo / "src/a.py" + source.parent.mkdir() + source.write_text("class A:\n pass\n", encoding="utf-8") + responses = [ + completed(healthy_status(self.repo)), + completed("failed explore output\n", returncode=1), + ] + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + return responses.pop(0) + + proxy = self.proxy_module() + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + query = proxy.execute_proxy_query( + self.repo, + "TASK-0001", + "PLANNING", + "CIQ-001", + "locate A", + "symbol A", + False, + runner=runner, + ) + protocol = importlib.import_module("internal.code_intelligence_protocol") + result = protocol.record_proxy_bundle( + self.repo, + "TASK-0001", + query["bundle_path"], + { + "summary": "Explore failed; verified current source instead.", + "symbols": [], + "source_fallbacks": [{ + "action": "SEARCH_SOURCE", + "path": None, + "observed_sha256": None, + "base_commit": None, + "head_commit": None, + "diff_hash": None, + "purpose": "locate A in current source", + "result_paths": [{ + "path": "src/a.py", + "observed_sha256": file_sha256(source), + }], + }], + }, + ROOT, + ) + recorded = json.loads(Path(result["path"]).read_text(encoding="utf-8")) + self.assertEqual(recorded["delivery"]["state"], "UNKNOWN") + self.assertEqual(recorded["query"]["response_sha256"], None) + self.assertEqual(recorded["delivery"]["stale_points"], []) + + def test_failed_sync_proxy_bundle_preserves_only_observed_post_status(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + source = self.repo / "src/a.py" + source.parent.mkdir() + source.write_text("class A:\n pass\n", encoding="utf-8") + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["modified"] = 1 + responses = [ + completed(json.dumps(pending)), + completed("sync failed\n", returncode=1), + completed("A is defined in src/a.py\n"), + completed(healthy_status(self.repo)), + ] + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + return responses.pop(0) + + proxy = self.proxy_module() + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + query = proxy.execute_proxy_query( + self.repo, + "TASK-0001", + "PLANNING", + "CIQ-001", + "locate A after one sync attempt", + "symbol A", + True, + runner=runner, + ) + self.assertIsNone(query["bundle"]["post_sync_status"]) + protocol = importlib.import_module("internal.code_intelligence_protocol") + result = protocol.record_proxy_bundle( + self.repo, + "TASK-0001", + query["bundle_path"], + { + "summary": "Sync failed; verified current source instead.", + "symbols": [{"path": "src/a.py", "line": 1, "name": "A"}], + "source_fallbacks": [{ + "action": "SEARCH_SOURCE", + "path": None, + "observed_sha256": None, + "base_commit": None, + "head_commit": None, + "diff_hash": None, + "purpose": "verify A in current source", + "result_paths": [{ + "path": "src/a.py", + "observed_sha256": file_sha256(source), + }], + }], + }, + ROOT, + ) + recorded = json.loads(Path(result["path"]).read_text(encoding="utf-8")) + self.assertEqual(recorded["delivery"]["state"], "STALE") + self.assertIn( + "SYNC_FAILED", + [point["reason"] for point in recorded["delivery"]["stale_points"]], + ) + def test_v3_record_preserves_stale_unknown_and_unavailable_restrictions(self) -> None: cases = [ ("stale", "STALE", "USED"), @@ -2978,6 +3157,33 @@ def runner( self.assertEqual(result["freshness"]["status"], "CURRENT_AT_CHECK") self.assertIn("SYNC_ACKNOWLEDGED", result["freshness"]["basis"]) + def test_pending_changes_still_sync_once_with_an_index_stale_reason(self) -> None: + _, sync_if_needed = self.adapter_functions() + (self.repo / ".codegraph").mkdir() + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["modified"] = 1 + pending["index"]["state"] = "partial" + responses = iter([ + completed(json.dumps(pending)), + completed("Synced 1 changed file\n"), + completed(healthy_status(self.repo)), + ]) + calls: list[list[str]] = [] + + def runner( + command: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: + calls.append(command) + return next(responses) + + result = sync_if_needed( + self.repo, load_providers(ROOT)["codegraph"], runner=runner + ) + + self.assertEqual([call[1] for call in calls], ["status", "sync", "status"]) + self.assertEqual(result["sync"]["status"], "SUCCESS") + self.assertEqual(result["freshness"]["status"], "CURRENT_AT_CHECK") + def test_explore_and_observed_sync_are_bounded_to_one_repo(self) -> None: adapter = self.adapter_module() (self.repo / ".codegraph").mkdir() From 76de90d28029860f77f4d5f35146433e263706f0 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 04:18:04 +0800 Subject: [PATCH 11/28] fix: support MCP registration on Python 3.10 --- .../2026-08-19-codegraph-polaris-mcp-proxy.md | 4 +- scripts/internal/project_mcp_registration.py | 169 +++++++++++++++++- tests/test_core.py | 40 +++++ 3 files changed, 210 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-08-19-codegraph-polaris-mcp-proxy.md b/docs/superpowers/plans/2026-08-19-codegraph-polaris-mcp-proxy.md index 51e3d90..746f9cb 100644 --- a/docs/superpowers/plans/2026-08-19-codegraph-polaris-mcp-proxy.md +++ b/docs/superpowers/plans/2026-08-19-codegraph-polaris-mcp-proxy.md @@ -6,7 +6,7 @@ **Architecture:** Extend the existing CodeGraph CLI adapter with reusable status/sync/explore primitives, then place a host-neutral proxy orchestration module above it. A thin standard-library stdio MCP entry point exposes only `polaris_codegraph_explore`; host adapter v3 renders project-local registrations for Codex and Claude Code. New v3 records copy and validate the proxy bundle while frozen v1/v2 schemas remain readable historical formats. -**Tech Stack:** Python 3.11+ standard library (`argparse`, `hashlib`, `json`, `subprocess`, `tomllib`, `unittest`), JSON Schema through Polaris's existing validator, JSON-RPC 2.0/MCP stdio protocol revision `2025-11-25`, Git, GitHub Actions. +**Tech Stack:** Python 3.10+ standard library (`argparse`, `hashlib`, `json`, `subprocess`, `unittest`; `tomllib` when available), JSON Schema through Polaris's existing validator, JSON-RPC 2.0/MCP stdio protocol revision `2025-11-25`, Git, GitHub Actions. **Spec:** `docs/superpowers/specs/2026-08-18-codegraph-polaris-mcp-proxy-design.md` @@ -662,7 +662,7 @@ Assert idempotent rerendering, exact managed-block replacement, malformed TOML/J - [ ] **Step 5: Implement format-specific merge/validation in one focused module** -Use `tomllib.loads` to validate full TOML before and after replacing the uniquely marked block; never rewrite unrelated TOML bytes. Use `json.loads` plus four-space `json.dumps(..., ensure_ascii=False, indent=4) + "\n"` for Claude. A same-name Claude entry is accepted only if it exactly equals the managed definition; otherwise raise `RuleFailure`. +Use `tomllib.loads` when available to validate full TOML before and after replacing the uniquely marked block. On Python 3.10, use the standard-library-only compatibility path to validate and extract the managed MCP tables while preserving unrelated TOML bytes; never rewrite unrelated TOML bytes. Use `json.loads` plus four-space `json.dumps(..., ensure_ascii=False, indent=4) + "\n"` for Claude. A same-name Claude entry is accepted only if it exactly equals the managed definition; otherwise raise `RuleFailure`. - [ ] **Step 6: Integrate registration into the vendor transaction** diff --git a/scripts/internal/project_mcp_registration.py b/scripts/internal/project_mcp_registration.py index 923f4c0..1b1c642 100644 --- a/scripts/internal/project_mcp_registration.py +++ b/scripts/internal/project_mcp_registration.py @@ -4,10 +4,14 @@ import json import re -import tomllib from pathlib import Path from typing import Any +try: + import tomllib +except ModuleNotFoundError: # Python 3.10 compatibility + tomllib = None # type: ignore[assignment] + from .path_security import confined_target, require_regular_file from .polaris_core import InputFailure, RuleFailure @@ -66,7 +70,170 @@ def _codex_block(adapter: dict[str, Any]) -> str: ) +def _strip_toml_comment(line: str) -> str: + quote: str | None = None + escaped = False + for index, character in enumerate(line): + if quote == '"': + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == quote: + quote = None + elif quote == "'": + if character == quote: + quote = None + elif character in {'"', "'"}: + quote = character + elif character == "#": + return line[:index] + return line + + +def _toml_key_path(source: str) -> tuple[str, ...]: + parts: list[str] = [] + position = 0 + while position < len(source): + while position < len(source) and source[position].isspace(): + position += 1 + if position == len(source): + break + if source[position] in {'"', "'"}: + quote = source[position] + start = position + position += 1 + escaped = False + while position < len(source): + character = source[position] + if quote == '"' and escaped: + escaped = False + elif quote == '"' and character == "\\": + escaped = True + elif character == quote: + position += 1 + break + position += 1 + else: + raise ValueError("unterminated quoted key") + token = source[start:position] + try: + value = json.loads(token) if quote == '"' else token[1:-1] + except json.JSONDecodeError as exc: + raise ValueError("invalid quoted key") from exc + parts.append(value) + else: + match = re.match(r"[A-Za-z0-9_-]+", source[position:]) + if match is None: + raise ValueError("invalid bare key") + parts.append(match.group(0)) + position += len(match.group(0)) + while position < len(source) and source[position].isspace(): + position += 1 + if position == len(source): + break + if source[position] != ".": + raise ValueError("invalid dotted key") + position += 1 + if not parts: + raise ValueError("empty key") + return tuple(parts) + + +def _find_toml_assignment(line: str) -> int | None: + quote: str | None = None + escaped = False + for index, character in enumerate(line): + if quote == '"': + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == quote: + quote = None + elif quote == "'": + if character == quote: + quote = None + elif character in {'"', "'"}: + quote = character + elif character == "=": + return index + return None + + +def _toml_compat_value(source: str) -> Any: + value = source.strip() + if value == "true": + return True + if value == "false": + return False + try: + return json.loads(value) + except json.JSONDecodeError: + return value + + +def _parse_toml_compat(source: str) -> dict[str, Any]: + """Extract MCP tables on Python 3.10 while preserving unrelated TOML bytes.""" + result: dict[str, Any] = {} + current_table: tuple[str, ...] = () + declared_tables: set[tuple[str, ...]] = set() + assigned_keys: set[tuple[str, ...]] = set() + + def ensure_table(path: tuple[str, ...]) -> dict[str, Any]: + node = result + for part in path: + existing = node.get(part) + if existing is None: + existing = {} + node[part] = existing + if not isinstance(existing, dict): + raise ValueError("table conflicts with a scalar value") + node = existing + return node + + for raw_line in source.splitlines(): + line = _strip_toml_comment(raw_line).strip() + if not line: + continue + if line.startswith("["): + array_table = line.startswith("[[") + closing = "]]" if array_table else "]" + opening_length = 2 if array_table else 1 + if not line.endswith(closing): + raise ValueError("malformed table header") + current_table = _toml_key_path(line[opening_length:-len(closing)]) + if current_table in declared_tables: + raise ValueError("duplicate table") + declared_tables.add(current_table) + if current_table[0] == "mcp_servers": + ensure_table(current_table) + continue + assignment = _find_toml_assignment(line) + if assignment is None: + # Unrelated multiline TOML values are preserved byte-for-byte. The + # managed block emitted below never uses multiline values. + continue + key_path = _toml_key_path(line[:assignment]) + full_path = (*current_table, *key_path) + if full_path in assigned_keys: + raise ValueError("duplicate key") + assigned_keys.add(full_path) + if not full_path or full_path[0] != "mcp_servers": + continue + parent = ensure_table(full_path[:-1]) + if full_path[-1] in parent: + raise ValueError("key conflicts with a table") + parent[full_path[-1]] = _toml_compat_value(line[assignment + 1 :]) + return result + + def _parse_toml(source: str) -> dict[str, Any]: + if tomllib is None: + try: + return _parse_toml_compat(source) + except (UnicodeDecodeError, ValueError) as exc: + raise RuleFailure(f"project MCP TOML is invalid: {exc}") from exc try: value = tomllib.loads(source) except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc: diff --git a/tests/test_core.py b/tests/test_core.py index 39e0ce8..4368322 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3544,6 +3544,46 @@ def test_project_mcp_registration_preserves_unrelated_host_configuration( claude, ) + def test_project_mcp_registration_imports_without_python_311_tomllib( + self, + ) -> None: + """Python 3.10 can register the managed Codex block without a dependency.""" + script = f""" +import builtins +import sys + +real_import = builtins.__import__ + +def import_without_tomllib(name, *args, **kwargs): + if name == "tomllib": + raise ModuleNotFoundError("simulated Python 3.10") + return real_import(name, *args, **kwargs) + +builtins.__import__ = import_without_tomllib +sys.path.insert(0, {str(SCRIPTS)!r}) +from internal.project_mcp_registration import _parse_toml + +value = _parse_toml( + 'model = "gpt-5"\\n' + '[mcp_servers.polaris-codegraph]\\n' + 'command = "python3"\\n' + 'args = ["tools/polaris/scripts/code_intelligence_mcp.py", "--repo", "."]\\n' + 'cwd = "."\\n' + 'enabled = true\\n' + 'required = false\\n' + 'enabled_tools = ["polaris_codegraph_explore"]\\n' +) +assert value["mcp_servers"]["polaris-codegraph"]["command"] == "python3" +""" + completed_process = subprocess.run( + [sys.executable, "-c", script], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(completed_process.returncode, 0, completed_process.stderr) + def test_project_mcp_registration_rejects_unsafe_or_conflicting_configuration( self, ) -> None: From 62a9970f2af03f2103321db9ae3c53a1c08e79cd Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 04:22:53 +0800 Subject: [PATCH 12/28] fix: validate TOML consistently on Python 3.10 --- .../2026-08-19-codegraph-polaris-mcp-proxy.md | 2 +- scripts/internal/_tomllib_compat/__init__.py | 11 + scripts/internal/_tomllib_compat/_parser.py | 692 ++++++++++++++++++ scripts/internal/_tomllib_compat/_re.py | 108 +++ scripts/internal/_tomllib_compat/_types.py | 11 + scripts/internal/project_mcp_registration.py | 165 +---- tests/test_core.py | 38 +- 7 files changed, 852 insertions(+), 175 deletions(-) create mode 100644 scripts/internal/_tomllib_compat/__init__.py create mode 100644 scripts/internal/_tomllib_compat/_parser.py create mode 100644 scripts/internal/_tomllib_compat/_re.py create mode 100644 scripts/internal/_tomllib_compat/_types.py diff --git a/docs/superpowers/plans/2026-08-19-codegraph-polaris-mcp-proxy.md b/docs/superpowers/plans/2026-08-19-codegraph-polaris-mcp-proxy.md index 746f9cb..1e67b79 100644 --- a/docs/superpowers/plans/2026-08-19-codegraph-polaris-mcp-proxy.md +++ b/docs/superpowers/plans/2026-08-19-codegraph-polaris-mcp-proxy.md @@ -662,7 +662,7 @@ Assert idempotent rerendering, exact managed-block replacement, malformed TOML/J - [ ] **Step 5: Implement format-specific merge/validation in one focused module** -Use `tomllib.loads` when available to validate full TOML before and after replacing the uniquely marked block. On Python 3.10, use the standard-library-only compatibility path to validate and extract the managed MCP tables while preserving unrelated TOML bytes; never rewrite unrelated TOML bytes. Use `json.loads` plus four-space `json.dumps(..., ensure_ascii=False, indent=4) + "\n"` for Claude. A same-name Claude entry is accepted only if it exactly equals the managed definition; otherwise raise `RuleFailure`. +Use `tomllib.loads` when available to validate full TOML before and after replacing the uniquely marked block. On Python 3.10, use the vendored standard-library TOML parser compatibility package for equivalent full-document validation; never rewrite unrelated TOML bytes. Use `json.loads` plus four-space `json.dumps(..., ensure_ascii=False, indent=4) + "\n"` for Claude. A same-name Claude entry is accepted only if it exactly equals the managed definition; otherwise raise `RuleFailure`. - [ ] **Step 6: Integrate registration into the vendor transaction** diff --git a/scripts/internal/_tomllib_compat/__init__.py b/scripts/internal/_tomllib_compat/__init__.py new file mode 100644 index 0000000..f13f93d --- /dev/null +++ b/scripts/internal/_tomllib_compat/__init__.py @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2021 Taneli Hukkinen +# Licensed to PSF under a Contributor Agreement. + +__all__ = ("loads", "load", "TOMLDecodeError") + +from ._parser import TOMLDecodeError, load, loads + +# Pretend this exception was created here. +TOMLDecodeError.__module__ = __name__ + diff --git a/scripts/internal/_tomllib_compat/_parser.py b/scripts/internal/_tomllib_compat/_parser.py new file mode 100644 index 0000000..2719bb5 --- /dev/null +++ b/scripts/internal/_tomllib_compat/_parser.py @@ -0,0 +1,692 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2021 Taneli Hukkinen +# Licensed to PSF under a Contributor Agreement. + +from __future__ import annotations + +from collections.abc import Iterable +import string +from types import MappingProxyType +from typing import Any, BinaryIO, NamedTuple + +from ._re import ( + RE_DATETIME, + RE_LOCALTIME, + RE_NUMBER, + match_to_datetime, + match_to_localtime, + match_to_number, +) +from ._types import Key, ParseFloat, Pos + +ASCII_CTRL = frozenset(chr(i) for i in range(32)) | frozenset(chr(127)) + +# Neither of these sets include quotation mark or backslash. They are +# currently handled as separate cases in the parser functions. +ILLEGAL_BASIC_STR_CHARS = ASCII_CTRL - frozenset("\t") +ILLEGAL_MULTILINE_BASIC_STR_CHARS = ASCII_CTRL - frozenset("\t\n") + +ILLEGAL_LITERAL_STR_CHARS = ILLEGAL_BASIC_STR_CHARS +ILLEGAL_MULTILINE_LITERAL_STR_CHARS = ILLEGAL_MULTILINE_BASIC_STR_CHARS + +ILLEGAL_COMMENT_CHARS = ILLEGAL_BASIC_STR_CHARS + +TOML_WS = frozenset(" \t") +TOML_WS_AND_NEWLINE = TOML_WS | frozenset("\n") +BARE_KEY_CHARS = frozenset(string.ascii_letters + string.digits + "-_") +KEY_INITIAL_CHARS = BARE_KEY_CHARS | frozenset("\"'") +HEXDIGIT_CHARS = frozenset(string.hexdigits) + +BASIC_STR_ESCAPE_REPLACEMENTS = MappingProxyType( + { + "\\b": "\u0008", # backspace + "\\t": "\u0009", # tab + "\\n": "\u000A", # linefeed + "\\f": "\u000C", # form feed + "\\r": "\u000D", # carriage return + '\\"': "\u0022", # quote + "\\\\": "\u005C", # backslash + } +) + + +class TOMLDecodeError(ValueError): + """An error raised if a document is not valid TOML.""" + + +def load(fp: BinaryIO, /, *, parse_float: ParseFloat = float) -> dict[str, Any]: + """Parse TOML from a binary file object.""" + b = fp.read() + try: + s = b.decode() + except AttributeError: + raise TypeError( + "File must be opened in binary mode, e.g. use `open('foo.toml', 'rb')`" + ) from None + return loads(s, parse_float=parse_float) + + +def loads(s: str, /, *, parse_float: ParseFloat = float) -> dict[str, Any]: # noqa: C901 + """Parse TOML from a string.""" + + # The spec allows converting "\r\n" to "\n", even in string + # literals. Let's do so to simplify parsing. + src = s.replace("\r\n", "\n") + pos = 0 + out = Output(NestedDict(), Flags()) + header: Key = () + parse_float = make_safe_parse_float(parse_float) + + # Parse one statement at a time + # (typically means one line in TOML source) + while True: + # 1. Skip line leading whitespace + pos = skip_chars(src, pos, TOML_WS) + + # 2. Parse rules. Expect one of the following: + # - end of file + # - end of line + # - comment + # - key/value pair + # - append dict to list (and move to its namespace) + # - create dict (and move to its namespace) + # Skip trailing whitespace when applicable. + try: + char = src[pos] + except IndexError: + break + if char == "\n": + pos += 1 + continue + if char in KEY_INITIAL_CHARS: + pos = key_value_rule(src, pos, out, header, parse_float) + pos = skip_chars(src, pos, TOML_WS) + elif char == "[": + try: + second_char: str | None = src[pos + 1] + except IndexError: + second_char = None + out.flags.finalize_pending() + if second_char == "[": + pos, header = create_list_rule(src, pos, out) + else: + pos, header = create_dict_rule(src, pos, out) + pos = skip_chars(src, pos, TOML_WS) + elif char != "#": + raise suffixed_err(src, pos, "Invalid statement") + + # 3. Skip comment + pos = skip_comment(src, pos) + + # 4. Expect end of line or end of file + try: + char = src[pos] + except IndexError: + break + if char != "\n": + raise suffixed_err( + src, pos, "Expected newline or end of document after a statement" + ) + pos += 1 + + return out.data.dict + + +class Flags: + """Flags that map to parsed keys/namespaces.""" + + # Marks an immutable namespace (inline array or inline table). + FROZEN = 0 + # Marks a nest that has been explicitly created and can no longer + # be opened using the "[table]" syntax. + EXPLICIT_NEST = 1 + + def __init__(self) -> None: + self._flags: dict[str, dict[Any, Any]] = {} + self._pending_flags: set[tuple[Key, int]] = set() + + def add_pending(self, key: Key, flag: int) -> None: + self._pending_flags.add((key, flag)) + + def finalize_pending(self) -> None: + for key, flag in self._pending_flags: + self.set(key, flag, recursive=False) + self._pending_flags.clear() + + def unset_all(self, key: Key) -> None: + cont = self._flags + for k in key[:-1]: + if k not in cont: + return + cont = cont[k]["nested"] + cont.pop(key[-1], None) + + def set(self, key: Key, flag: int, *, recursive: bool) -> None: # noqa: A003 + cont = self._flags + key_parent, key_stem = key[:-1], key[-1] + for k in key_parent: + if k not in cont: + cont[k] = {"flags": set(), "recursive_flags": set(), "nested": {}} + cont = cont[k]["nested"] + if key_stem not in cont: + cont[key_stem] = {"flags": set(), "recursive_flags": set(), "nested": {}} + cont[key_stem]["recursive_flags" if recursive else "flags"].add(flag) + + def is_(self, key: Key, flag: int) -> bool: + if not key: + return False # document root has no flags + cont = self._flags + for k in key[:-1]: + if k not in cont: + return False + inner_cont = cont[k] + if flag in inner_cont["recursive_flags"]: + return True + cont = inner_cont["nested"] + key_stem = key[-1] + if key_stem in cont: + cont = cont[key_stem] + return flag in cont["flags"] or flag in cont["recursive_flags"] + return False + + +class NestedDict: + def __init__(self) -> None: + # The parsed content of the TOML document + self.dict: dict[str, Any] = {} + + def get_or_create_nest( + self, + key: Key, + *, + access_lists: bool = True, + ) -> dict[str, Any]: + cont: Any = self.dict + for k in key: + if k not in cont: + cont[k] = {} + cont = cont[k] + if access_lists and isinstance(cont, list): + cont = cont[-1] + if not isinstance(cont, dict): + raise KeyError("There is no nest behind this key") + return cont # type: ignore[no-any-return] + + def append_nest_to_list(self, key: Key) -> None: + cont = self.get_or_create_nest(key[:-1]) + last_key = key[-1] + if last_key in cont: + list_ = cont[last_key] + if not isinstance(list_, list): + raise KeyError("An object other than list found behind this key") + list_.append({}) + else: + cont[last_key] = [{}] + + +class Output(NamedTuple): + data: NestedDict + flags: Flags + + +def skip_chars(src: str, pos: Pos, chars: Iterable[str]) -> Pos: + try: + while src[pos] in chars: + pos += 1 + except IndexError: + pass + return pos + + +def skip_until( + src: str, + pos: Pos, + expect: str, + *, + error_on: frozenset[str], + error_on_eof: bool, +) -> Pos: + try: + new_pos = src.index(expect, pos) + except ValueError: + new_pos = len(src) + if error_on_eof: + raise suffixed_err(src, new_pos, f"Expected {expect!r}") from None + + if not error_on.isdisjoint(src[pos:new_pos]): + while src[pos] not in error_on: + pos += 1 + raise suffixed_err(src, pos, f"Found invalid character {src[pos]!r}") + return new_pos + + +def skip_comment(src: str, pos: Pos) -> Pos: + try: + char: str | None = src[pos] + except IndexError: + char = None + if char == "#": + return skip_until( + src, pos + 1, "\n", error_on=ILLEGAL_COMMENT_CHARS, error_on_eof=False + ) + return pos + + +def skip_comments_and_array_ws(src: str, pos: Pos) -> Pos: + while True: + pos_before_skip = pos + pos = skip_chars(src, pos, TOML_WS_AND_NEWLINE) + pos = skip_comment(src, pos) + if pos == pos_before_skip: + return pos + + +def create_dict_rule(src: str, pos: Pos, out: Output) -> tuple[Pos, Key]: + pos += 1 # Skip "[" + pos = skip_chars(src, pos, TOML_WS) + pos, key = parse_key(src, pos) + + if out.flags.is_(key, Flags.EXPLICIT_NEST) or out.flags.is_(key, Flags.FROZEN): + raise suffixed_err(src, pos, f"Cannot declare {key} twice") + out.flags.set(key, Flags.EXPLICIT_NEST, recursive=False) + try: + out.data.get_or_create_nest(key) + except KeyError: + raise suffixed_err(src, pos, "Cannot overwrite a value") from None + + if not src.startswith("]", pos): + raise suffixed_err(src, pos, "Expected ']' at the end of a table declaration") + return pos + 1, key + + +def create_list_rule(src: str, pos: Pos, out: Output) -> tuple[Pos, Key]: + pos += 2 # Skip "[[" + pos = skip_chars(src, pos, TOML_WS) + pos, key = parse_key(src, pos) + + if out.flags.is_(key, Flags.FROZEN): + raise suffixed_err(src, pos, f"Cannot mutate immutable namespace {key}") + # Free the namespace now that it points to another empty list item... + out.flags.unset_all(key) + # ...but this key precisely is still prohibited from table declaration + out.flags.set(key, Flags.EXPLICIT_NEST, recursive=False) + try: + out.data.append_nest_to_list(key) + except KeyError: + raise suffixed_err(src, pos, "Cannot overwrite a value") from None + + if not src.startswith("]]", pos): + raise suffixed_err(src, pos, "Expected ']]' at the end of an array declaration") + return pos + 2, key + + +def key_value_rule( + src: str, pos: Pos, out: Output, header: Key, parse_float: ParseFloat +) -> Pos: + pos, key, value = parse_key_value_pair(src, pos, parse_float) + key_parent, key_stem = key[:-1], key[-1] + abs_key_parent = header + key_parent + + relative_path_cont_keys = (header + key[:i] for i in range(1, len(key))) + for cont_key in relative_path_cont_keys: + # Check that dotted key syntax does not redefine an existing table + if out.flags.is_(cont_key, Flags.EXPLICIT_NEST): + raise suffixed_err(src, pos, f"Cannot redefine namespace {cont_key}") + # Containers in the relative path can't be opened with the table syntax or + # dotted key/value syntax in following table sections. + out.flags.add_pending(cont_key, Flags.EXPLICIT_NEST) + + if out.flags.is_(abs_key_parent, Flags.FROZEN): + raise suffixed_err( + src, pos, f"Cannot mutate immutable namespace {abs_key_parent}" + ) + + try: + nest = out.data.get_or_create_nest(abs_key_parent) + except KeyError: + raise suffixed_err(src, pos, "Cannot overwrite a value") from None + if key_stem in nest: + raise suffixed_err(src, pos, "Cannot overwrite a value") + # Mark inline table and array namespaces recursively immutable + if isinstance(value, (dict, list)): + out.flags.set(header + key, Flags.FROZEN, recursive=True) + nest[key_stem] = value + return pos + + +def parse_key_value_pair( + src: str, pos: Pos, parse_float: ParseFloat +) -> tuple[Pos, Key, Any]: + pos, key = parse_key(src, pos) + try: + char: str | None = src[pos] + except IndexError: + char = None + if char != "=": + raise suffixed_err(src, pos, "Expected '=' after a key in a key/value pair") + pos += 1 + pos = skip_chars(src, pos, TOML_WS) + pos, value = parse_value(src, pos, parse_float) + return pos, key, value + + +def parse_key(src: str, pos: Pos) -> tuple[Pos, Key]: + pos, key_part = parse_key_part(src, pos) + key: Key = (key_part,) + pos = skip_chars(src, pos, TOML_WS) + while True: + try: + char: str | None = src[pos] + except IndexError: + char = None + if char != ".": + return pos, key + pos += 1 + pos = skip_chars(src, pos, TOML_WS) + pos, key_part = parse_key_part(src, pos) + key += (key_part,) + pos = skip_chars(src, pos, TOML_WS) + + +def parse_key_part(src: str, pos: Pos) -> tuple[Pos, str]: + try: + char: str | None = src[pos] + except IndexError: + char = None + if char in BARE_KEY_CHARS: + start_pos = pos + pos = skip_chars(src, pos, BARE_KEY_CHARS) + return pos, src[start_pos:pos] + if char == "'": + return parse_literal_str(src, pos) + if char == '"': + return parse_one_line_basic_str(src, pos) + raise suffixed_err(src, pos, "Invalid initial character for a key part") + + +def parse_one_line_basic_str(src: str, pos: Pos) -> tuple[Pos, str]: + pos += 1 + return parse_basic_str(src, pos, multiline=False) + + +def parse_array(src: str, pos: Pos, parse_float: ParseFloat) -> tuple[Pos, list[Any]]: + pos += 1 + array: list[Any] = [] + + pos = skip_comments_and_array_ws(src, pos) + if src.startswith("]", pos): + return pos + 1, array + while True: + pos, val = parse_value(src, pos, parse_float) + array.append(val) + pos = skip_comments_and_array_ws(src, pos) + + c = src[pos : pos + 1] + if c == "]": + return pos + 1, array + if c != ",": + raise suffixed_err(src, pos, "Unclosed array") + pos += 1 + + pos = skip_comments_and_array_ws(src, pos) + if src.startswith("]", pos): + return pos + 1, array + + +def parse_inline_table(src: str, pos: Pos, parse_float: ParseFloat) -> tuple[Pos, dict[str, Any]]: + pos += 1 + nested_dict = NestedDict() + flags = Flags() + + pos = skip_chars(src, pos, TOML_WS) + if src.startswith("}", pos): + return pos + 1, nested_dict.dict + while True: + pos, key, value = parse_key_value_pair(src, pos, parse_float) + key_parent, key_stem = key[:-1], key[-1] + if flags.is_(key, Flags.FROZEN): + raise suffixed_err(src, pos, f"Cannot mutate immutable namespace {key}") + try: + nest = nested_dict.get_or_create_nest(key_parent, access_lists=False) + except KeyError: + raise suffixed_err(src, pos, "Cannot overwrite a value") from None + if key_stem in nest: + raise suffixed_err(src, pos, f"Duplicate inline table key {key_stem!r}") + nest[key_stem] = value + pos = skip_chars(src, pos, TOML_WS) + c = src[pos : pos + 1] + if c == "}": + return pos + 1, nested_dict.dict + if c != ",": + raise suffixed_err(src, pos, "Unclosed inline table") + if isinstance(value, (dict, list)): + flags.set(key, Flags.FROZEN, recursive=True) + pos += 1 + pos = skip_chars(src, pos, TOML_WS) + + +def parse_basic_str_escape( + src: str, pos: Pos, *, multiline: bool = False +) -> tuple[Pos, str]: + escape_id = src[pos : pos + 2] + pos += 2 + if multiline and escape_id in {"\\ ", "\\\t", "\\\n"}: + # Skip whitespace until next non-whitespace character or end of + # the doc. Error if non-whitespace is found before newline. + if escape_id != "\\\n": + pos = skip_chars(src, pos, TOML_WS) + try: + char = src[pos] + except IndexError: + return pos, "" + if char != "\n": + raise suffixed_err(src, pos, "Unescaped '\\' in a string") + pos += 1 + pos = skip_chars(src, pos, TOML_WS_AND_NEWLINE) + return pos, "" + if escape_id == "\\u": + return parse_hex_char(src, pos, 4) + if escape_id == "\\U": + return parse_hex_char(src, pos, 8) + try: + return pos, BASIC_STR_ESCAPE_REPLACEMENTS[escape_id] + except KeyError: + raise suffixed_err(src, pos, "Unescaped '\\' in a string") from None + + +def parse_basic_str_escape_multiline(src: str, pos: Pos) -> tuple[Pos, str]: + return parse_basic_str_escape(src, pos, multiline=True) + + +def parse_hex_char(src: str, pos: Pos, hex_len: int) -> tuple[Pos, str]: + hex_str = src[pos : pos + hex_len] + if len(hex_str) != hex_len or not HEXDIGIT_CHARS.issuperset(hex_str): + raise suffixed_err(src, pos, "Invalid hex value") + pos += hex_len + hex_int = int(hex_str, 16) + if not is_unicode_scalar_value(hex_int): + raise suffixed_err(src, pos, "Escaped character is not a Unicode scalar value") + return pos, chr(hex_int) + + +def parse_literal_str(src: str, pos: Pos) -> tuple[Pos, str]: + pos += 1 # Skip starting apostrophe + start_pos = pos + pos = skip_until( + src, pos, "'", error_on=ILLEGAL_LITERAL_STR_CHARS, error_on_eof=True + ) + return pos + 1, src[start_pos:pos] # Skip ending apostrophe + + +def parse_multiline_str(src: str, pos: Pos, *, literal: bool) -> tuple[Pos, str]: + pos += 3 + if src.startswith("\n", pos): + pos += 1 + + if literal: + delim = "'" + end_pos = skip_until( + src, + pos, + "'''", + error_on=ILLEGAL_MULTILINE_LITERAL_STR_CHARS, + error_on_eof=True, + ) + result = src[pos:end_pos] + pos = end_pos + 3 + else: + delim = '"' + pos, result = parse_basic_str(src, pos, multiline=True) + + # Add at maximum two extra apostrophes/quotes if the end sequence + # is 4 or 5 chars long instead of just 3. + if not src.startswith(delim, pos): + return pos, result + pos += 1 + if not src.startswith(delim, pos): + return pos, result + delim + pos += 1 + return pos, result + (delim * 2) + + +def parse_basic_str(src: str, pos: Pos, *, multiline: bool) -> tuple[Pos, str]: + if multiline: + error_on = ILLEGAL_MULTILINE_BASIC_STR_CHARS + parse_escapes = parse_basic_str_escape_multiline + else: + error_on = ILLEGAL_BASIC_STR_CHARS + parse_escapes = parse_basic_str_escape + result = "" + start_pos = pos + while True: + try: + char = src[pos] + except IndexError: + raise suffixed_err(src, pos, "Unterminated string") from None + if char == '"': + if not multiline: + return pos + 1, result + src[start_pos:pos] + if src.startswith('"""', pos): + return pos + 3, result + src[start_pos:pos] + pos += 1 + continue + if char == "\\": + result += src[start_pos:pos] + pos, parsed_escape = parse_escapes(src, pos) + result += parsed_escape + start_pos = pos + continue + if char in error_on: + raise suffixed_err(src, pos, f"Illegal character {char!r}") + pos += 1 + + +def parse_value( # noqa: C901 + src: str, pos: Pos, parse_float: ParseFloat +) -> tuple[Pos, Any]: + try: + char: str | None = src[pos] + except IndexError: + char = None + + # IMPORTANT: order conditions based on speed of checking and likelihood + + # Basic strings + if char == '"': + if src.startswith('"""', pos): + return parse_multiline_str(src, pos, literal=False) + return parse_one_line_basic_str(src, pos) + + # Literal strings + if char == "'": + if src.startswith("'''", pos): + return parse_multiline_str(src, pos, literal=True) + return parse_literal_str(src, pos) + + # Booleans + if char == "t": + if src.startswith("true", pos): + return pos + 4, True + if char == "f": + if src.startswith("false", pos): + return pos + 5, False + + # Arrays + if char == "[": + return parse_array(src, pos, parse_float) + + # Inline tables + if char == "{": + return parse_inline_table(src, pos, parse_float) + + # Dates and times + datetime_match = RE_DATETIME.match(src, pos) + if datetime_match: + try: + datetime_obj = match_to_datetime(datetime_match) + except ValueError as e: + raise suffixed_err(src, pos, "Invalid date or datetime") from e + return datetime_match.end(), datetime_obj + localtime_match = RE_LOCALTIME.match(src, pos) + if localtime_match: + return localtime_match.end(), match_to_localtime(localtime_match) + + # Integers and "normal" floats. + # The regex will greedily match any type starting with a decimal + # char, so needs to be located after handling of dates and times. + number_match = RE_NUMBER.match(src, pos) + if number_match: + return number_match.end(), match_to_number(number_match, parse_float) + + # Special floats + first_three = src[pos : pos + 3] + if first_three in {"inf", "nan"}: + return pos + 3, parse_float(first_three) + first_four = src[pos : pos + 4] + if first_four in {"-inf", "+inf", "-nan", "+nan"}: + return pos + 4, parse_float(first_four) + + raise suffixed_err(src, pos, "Invalid value") + + +def suffixed_err(src: str, pos: Pos, msg: str) -> TOMLDecodeError: + """Return a `TOMLDecodeError` where error message is suffixed with + coordinates in source.""" + + def coord_repr(src: str, pos: Pos) -> str: + if pos >= len(src): + return "end of document" + line = src.count("\n", 0, pos) + 1 + if line == 1: + column = pos + 1 + else: + column = pos - src.rindex("\n", 0, pos) + return f"line {line}, column {column}" + + return TOMLDecodeError(f"{msg} (at {coord_repr(src, pos)})") + + +def is_unicode_scalar_value(codepoint: int) -> bool: + return (0 <= codepoint <= 55295) or (57344 <= codepoint <= 1114111) + + +def make_safe_parse_float(parse_float: ParseFloat) -> ParseFloat: + """A decorator to make `parse_float` safe. + + `parse_float` must not return dicts or lists, because these types + would be mixed with parsed TOML tables and arrays, thus confusing + the parser. The returned decorated callable raises `ValueError` + instead of returning illegal types. + """ + # The default `float` callable never returns illegal types. Optimize it. + if parse_float is float: + return float + + def safe_parse_float(float_str: str) -> Any: + float_value = parse_float(float_str) + if isinstance(float_value, (dict, list)): + raise ValueError("parse_float must not return dicts or lists") + return float_value + + return safe_parse_float + diff --git a/scripts/internal/_tomllib_compat/_re.py b/scripts/internal/_tomllib_compat/_re.py new file mode 100644 index 0000000..330de92 --- /dev/null +++ b/scripts/internal/_tomllib_compat/_re.py @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2021 Taneli Hukkinen +# Licensed to PSF under a Contributor Agreement. + +from __future__ import annotations + +from datetime import date, datetime, time, timedelta, timezone, tzinfo +from functools import lru_cache +import re +from typing import Any + +from ._types import ParseFloat + +# E.g. +# - 00:32:00.999999 +# - 00:32:00 +_TIME_RE_STR = r"([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])(?:\.([0-9]{1,6})[0-9]*)?" + +RE_NUMBER = re.compile( + r""" +0 +(?: + x[0-9A-Fa-f](?:_?[0-9A-Fa-f])* # hex + | + b[01](?:_?[01])* # bin + | + o[0-7](?:_?[0-7])* # oct +) +| +[+-]?(?:0|[1-9](?:_?[0-9])*) # dec, integer part +(?P + (?:\.[0-9](?:_?[0-9])*)? # optional fractional part + (?:[eE][+-]?[0-9](?:_?[0-9])*)? # optional exponent part +) +""", + flags=re.VERBOSE, +) +RE_LOCALTIME = re.compile(_TIME_RE_STR) +RE_DATETIME = re.compile( + rf""" +([0-9]{{4}})-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01]) # date, e.g. 1988-10-27 +(?: + [Tt ] + {_TIME_RE_STR} + (?:([Zz])|([+-])([01][0-9]|2[0-3]):([0-5][0-9]))? # optional time offset +)? +""", + flags=re.VERBOSE, +) + + +def match_to_datetime(match: re.Match[str]) -> datetime | date: + """Convert a `RE_DATETIME` match to `datetime.datetime` or `datetime.date`. + + Raises ValueError if the match does not correspond to a valid date + or datetime. + """ + ( + year_str, + month_str, + day_str, + hour_str, + minute_str, + sec_str, + micros_str, + zulu_time, + offset_sign_str, + offset_hour_str, + offset_minute_str, + ) = match.groups() + year, month, day = int(year_str), int(month_str), int(day_str) + if hour_str is None: + return date(year, month, day) + hour, minute, sec = int(hour_str), int(minute_str), int(sec_str) + micros = int(micros_str.ljust(6, "0")) if micros_str else 0 + if offset_sign_str: + tz: tzinfo | None = cached_tz( + offset_hour_str, offset_minute_str, offset_sign_str + ) + elif zulu_time: + tz = timezone.utc + else: # local date-time + tz = None + return datetime(year, month, day, hour, minute, sec, micros, tzinfo=tz) + + +@lru_cache(maxsize=None) +def cached_tz(hour_str: str, minute_str: str, sign_str: str) -> timezone: + sign = 1 if sign_str == "+" else -1 + return timezone( + timedelta( + hours=sign * int(hour_str), + minutes=sign * int(minute_str), + ) + ) + + +def match_to_localtime(match: re.Match[str]) -> time: + hour_str, minute_str, sec_str, micros_str = match.groups() + micros = int(micros_str.ljust(6, "0")) if micros_str else 0 + return time(int(hour_str), int(minute_str), int(sec_str), micros) + + +def match_to_number(match: re.Match[str], parse_float: ParseFloat) -> Any: + if match.group("floatpart"): + return parse_float(match.group()) + return int(match.group(), 0) + diff --git a/scripts/internal/_tomllib_compat/_types.py b/scripts/internal/_tomllib_compat/_types.py new file mode 100644 index 0000000..cb8380e --- /dev/null +++ b/scripts/internal/_tomllib_compat/_types.py @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2021 Taneli Hukkinen +# Licensed to PSF under a Contributor Agreement. + +from typing import Any, Callable, Tuple + +# Type annotations +ParseFloat = Callable[[str], Any] +Key = Tuple[str, ...] +Pos = int + diff --git a/scripts/internal/project_mcp_registration.py b/scripts/internal/project_mcp_registration.py index 1b1c642..26cf8e4 100644 --- a/scripts/internal/project_mcp_registration.py +++ b/scripts/internal/project_mcp_registration.py @@ -10,7 +10,7 @@ try: import tomllib except ModuleNotFoundError: # Python 3.10 compatibility - tomllib = None # type: ignore[assignment] + from . import _tomllib_compat as tomllib from .path_security import confined_target, require_regular_file from .polaris_core import InputFailure, RuleFailure @@ -70,170 +70,7 @@ def _codex_block(adapter: dict[str, Any]) -> str: ) -def _strip_toml_comment(line: str) -> str: - quote: str | None = None - escaped = False - for index, character in enumerate(line): - if quote == '"': - if escaped: - escaped = False - elif character == "\\": - escaped = True - elif character == quote: - quote = None - elif quote == "'": - if character == quote: - quote = None - elif character in {'"', "'"}: - quote = character - elif character == "#": - return line[:index] - return line - - -def _toml_key_path(source: str) -> tuple[str, ...]: - parts: list[str] = [] - position = 0 - while position < len(source): - while position < len(source) and source[position].isspace(): - position += 1 - if position == len(source): - break - if source[position] in {'"', "'"}: - quote = source[position] - start = position - position += 1 - escaped = False - while position < len(source): - character = source[position] - if quote == '"' and escaped: - escaped = False - elif quote == '"' and character == "\\": - escaped = True - elif character == quote: - position += 1 - break - position += 1 - else: - raise ValueError("unterminated quoted key") - token = source[start:position] - try: - value = json.loads(token) if quote == '"' else token[1:-1] - except json.JSONDecodeError as exc: - raise ValueError("invalid quoted key") from exc - parts.append(value) - else: - match = re.match(r"[A-Za-z0-9_-]+", source[position:]) - if match is None: - raise ValueError("invalid bare key") - parts.append(match.group(0)) - position += len(match.group(0)) - while position < len(source) and source[position].isspace(): - position += 1 - if position == len(source): - break - if source[position] != ".": - raise ValueError("invalid dotted key") - position += 1 - if not parts: - raise ValueError("empty key") - return tuple(parts) - - -def _find_toml_assignment(line: str) -> int | None: - quote: str | None = None - escaped = False - for index, character in enumerate(line): - if quote == '"': - if escaped: - escaped = False - elif character == "\\": - escaped = True - elif character == quote: - quote = None - elif quote == "'": - if character == quote: - quote = None - elif character in {'"', "'"}: - quote = character - elif character == "=": - return index - return None - - -def _toml_compat_value(source: str) -> Any: - value = source.strip() - if value == "true": - return True - if value == "false": - return False - try: - return json.loads(value) - except json.JSONDecodeError: - return value - - -def _parse_toml_compat(source: str) -> dict[str, Any]: - """Extract MCP tables on Python 3.10 while preserving unrelated TOML bytes.""" - result: dict[str, Any] = {} - current_table: tuple[str, ...] = () - declared_tables: set[tuple[str, ...]] = set() - assigned_keys: set[tuple[str, ...]] = set() - - def ensure_table(path: tuple[str, ...]) -> dict[str, Any]: - node = result - for part in path: - existing = node.get(part) - if existing is None: - existing = {} - node[part] = existing - if not isinstance(existing, dict): - raise ValueError("table conflicts with a scalar value") - node = existing - return node - - for raw_line in source.splitlines(): - line = _strip_toml_comment(raw_line).strip() - if not line: - continue - if line.startswith("["): - array_table = line.startswith("[[") - closing = "]]" if array_table else "]" - opening_length = 2 if array_table else 1 - if not line.endswith(closing): - raise ValueError("malformed table header") - current_table = _toml_key_path(line[opening_length:-len(closing)]) - if current_table in declared_tables: - raise ValueError("duplicate table") - declared_tables.add(current_table) - if current_table[0] == "mcp_servers": - ensure_table(current_table) - continue - assignment = _find_toml_assignment(line) - if assignment is None: - # Unrelated multiline TOML values are preserved byte-for-byte. The - # managed block emitted below never uses multiline values. - continue - key_path = _toml_key_path(line[:assignment]) - full_path = (*current_table, *key_path) - if full_path in assigned_keys: - raise ValueError("duplicate key") - assigned_keys.add(full_path) - if not full_path or full_path[0] != "mcp_servers": - continue - parent = ensure_table(full_path[:-1]) - if full_path[-1] in parent: - raise ValueError("key conflicts with a table") - parent[full_path[-1]] = _toml_compat_value(line[assignment + 1 :]) - return result - - def _parse_toml(source: str) -> dict[str, Any]: - if tomllib is None: - try: - return _parse_toml_compat(source) - except (UnicodeDecodeError, ValueError) as exc: - raise RuleFailure(f"project MCP TOML is invalid: {exc}") from exc try: value = tomllib.loads(source) except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc: diff --git a/tests/test_core.py b/tests/test_core.py index 4368322..5ce4c83 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3548,6 +3548,24 @@ def test_project_mcp_registration_imports_without_python_311_tomllib( self, ) -> None: """Python 3.10 can register the managed Codex block without a dependency.""" + valid_source = ''' +[[profiles]] +name = "first" + +[[profiles]] +name = "second" +description = """ +[mcp_servers.not-a-real-table] +""" + +[mcp_servers.polaris-codegraph] +command = "python3" +args = ["tools/polaris/scripts/code_intelligence_mcp.py", "--repo", "."] +cwd = "." +enabled = true +required = false +enabled_tools = ["polaris_codegraph_explore"] +''' script = f""" import builtins import sys @@ -3562,18 +3580,18 @@ def import_without_tomllib(name, *args, **kwargs): builtins.__import__ = import_without_tomllib sys.path.insert(0, {str(SCRIPTS)!r}) from internal.project_mcp_registration import _parse_toml +from internal.polaris_core import RuleFailure -value = _parse_toml( - 'model = "gpt-5"\\n' - '[mcp_servers.polaris-codegraph]\\n' - 'command = "python3"\\n' - 'args = ["tools/polaris/scripts/code_intelligence_mcp.py", "--repo", "."]\\n' - 'cwd = "."\\n' - 'enabled = true\\n' - 'required = false\\n' - 'enabled_tools = ["polaris_codegraph_explore"]\\n' -) +value = _parse_toml({valid_source!r}) +assert [profile["name"] for profile in value["profiles"]] == ["first", "second"] +assert value["profiles"][1]["description"].strip() == "[mcp_servers.not-a-real-table]" assert value["mcp_servers"]["polaris-codegraph"]["command"] == "python3" +try: + _parse_toml("this is not TOML\\n") +except RuleFailure: + pass +else: + raise AssertionError("malformed TOML was accepted") """ completed_process = subprocess.run( [sys.executable, "-c", script], From e35d4f4dc758ab9883c2086ae5f09664933164db Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 04:27:52 +0800 Subject: [PATCH 13/28] fix: protect unrelated TOML from managed markers --- scripts/internal/project_mcp_registration.py | 13 ++++++++++ tests/test_core.py | 27 ++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/scripts/internal/project_mcp_registration.py b/scripts/internal/project_mcp_registration.py index 26cf8e4..08b2d37 100644 --- a/scripts/internal/project_mcp_registration.py +++ b/scripts/internal/project_mcp_registration.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import json import re from pathlib import Path @@ -80,6 +81,16 @@ def _parse_toml(source: str) -> dict[str, Any]: return value +def _without_managed_server(value: dict[str, Any]) -> dict[str, Any]: + cleaned = copy.deepcopy(value) + servers = cleaned.get("mcp_servers") + if isinstance(servers, dict): + servers.pop(SERVER_ID, None) + if not servers: + cleaned.pop("mcp_servers", None) + return cleaned + + def _merge_codex(adapter: dict[str, Any], source: str) -> str: parsed = _parse_toml(source) starts = source.count(CODEX_START) @@ -106,6 +117,8 @@ def _merge_codex(adapter: dict[str, Any], source: str) -> str: separator = "" if not source else ("\n" if source.endswith("\n") else "\n\n") rendered = source + separator + block final = _parse_toml(rendered) + if _without_managed_server(parsed) != _without_managed_server(final): + raise RuleFailure("project MCP markers would rewrite unrelated TOML") servers = final.get("mcp_servers") if not isinstance(servers, dict) or servers.get(SERVER_ID) != _codex_definition( adapter diff --git a/tests/test_core.py b/tests/test_core.py index 5ce4c83..68f1d52 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3644,6 +3644,33 @@ def test_project_mcp_registration_rejects_unsafe_or_conflicting_configuration( with self.assertRaisesRegex(RuleFailure, "symlink"): merge_project_mcp(self.repo, adapters["claude-code"]) + def test_project_mcp_registration_rejects_markers_inside_toml_strings( + self, + ) -> None: + """Managed markers cannot claim or rewrite unrelated multiline strings.""" + from internal.project_mcp_registration import merge_project_mcp + + adapter = {item["host_id"]: item for item in load_host_adapters(ROOT)}[ + "codex" + ] + source = ''' +description = """ +# POLARIS_MCP_START polaris-codegraph +This text belongs to the user. +# POLARIS_MCP_END polaris-codegraph +""" + +[mcp_servers.polaris-codegraph] +command = "python3" +args = ["tools/polaris/scripts/code_intelligence_mcp.py", "--repo", "."] +cwd = "." +enabled = true +required = false +enabled_tools = ["polaris_codegraph_explore"] +''' + with self.assertRaisesRegex(RuleFailure, "unrelated TOML"): + merge_project_mcp(self.repo, adapter, source_text=source) + def test_host_adapter_hardening_rejects_entry_overlay_and_capability_errors(self) -> None: """入口必须存在,overlay 不得覆写 Skill,worker 能力依赖必须自洽。""" cases = { From e3db8e6f7213df71352cf6bdb8144f28729e0f27 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 04:30:32 +0800 Subject: [PATCH 14/28] fix: compare TOML NaN values semantically --- scripts/internal/project_mcp_registration.py | 22 +++++++++++++++++++- tests/test_core.py | 18 ++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/scripts/internal/project_mcp_registration.py b/scripts/internal/project_mcp_registration.py index 08b2d37..5ec4f32 100644 --- a/scripts/internal/project_mcp_registration.py +++ b/scripts/internal/project_mcp_registration.py @@ -4,6 +4,7 @@ import copy import json +import math import re from pathlib import Path from typing import Any @@ -91,6 +92,23 @@ def _without_managed_server(value: dict[str, Any]) -> dict[str, Any]: return cleaned +def _toml_values_equal(left: Any, right: Any) -> bool: + if type(left) is not type(right): + return False + if isinstance(left, float) and math.isnan(left) and math.isnan(right): + return True + if isinstance(left, dict): + return left.keys() == right.keys() and all( + _toml_values_equal(left[key], right[key]) for key in left + ) + if isinstance(left, list): + return len(left) == len(right) and all( + _toml_values_equal(left_item, right_item) + for left_item, right_item in zip(left, right) + ) + return left == right + + def _merge_codex(adapter: dict[str, Any], source: str) -> str: parsed = _parse_toml(source) starts = source.count(CODEX_START) @@ -117,7 +135,9 @@ def _merge_codex(adapter: dict[str, Any], source: str) -> str: separator = "" if not source else ("\n" if source.endswith("\n") else "\n\n") rendered = source + separator + block final = _parse_toml(rendered) - if _without_managed_server(parsed) != _without_managed_server(final): + if not _toml_values_equal( + _without_managed_server(parsed), _without_managed_server(final) + ): raise RuleFailure("project MCP markers would rewrite unrelated TOML") servers = final.get("mcp_servers") if not isinstance(servers, dict) or servers.get(SERVER_ID) != _codex_definition( diff --git a/tests/test_core.py b/tests/test_core.py index 68f1d52..0303c29 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3671,6 +3671,24 @@ def test_project_mcp_registration_rejects_markers_inside_toml_strings( with self.assertRaisesRegex(RuleFailure, "unrelated TOML"): merge_project_mcp(self.repo, adapter, source_text=source) + def test_project_mcp_registration_preserves_unrelated_toml_nan_values( + self, + ) -> None: + """TOML NaN values remain equivalent across insert and managed updates.""" + from internal.project_mcp_registration import merge_project_mcp + + adapter = {item["host_id"]: item for item in load_host_adapters(ROOT)}[ + "codex" + ] + source = 'metric = nan\n[mcp_servers.other]\ncommand = "other"\n' + inserted = merge_project_mcp(self.repo, adapter, source_text=source) + self.assertTrue(inserted.startswith(source)) + stale = inserted.replace("enabled = true", "enabled = false") + self.assertEqual( + merge_project_mcp(self.repo, adapter, source_text=stale), + inserted, + ) + def test_host_adapter_hardening_rejects_entry_overlay_and_capability_errors(self) -> None: """入口必须存在,overlay 不得覆写 Skill,worker 能力依赖必须自洽。""" cases = { From 5cf30577c98408f31722216470607195293744b5 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 04:38:20 +0800 Subject: [PATCH 15/28] test: run CodeGraph proxy fixture cross-platform --- tests/test_codegraph.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 7062afc..0b76da9 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -1062,7 +1062,7 @@ def test_vendored_mcp_proxy_runs_one_auditable_fake_cli_window(self) -> None: ) call_log = fixture_root / "codegraph-calls.jsonl" - executable = fake_bin / "codegraph" + executable = fake_bin / "codegraph.py" write_text_atomic( executable, """#!/usr/bin/env python3 @@ -1096,9 +1096,20 @@ def test_vendored_mcp_proxy_runs_one_auditable_fake_cli_window(self) -> None: raise SystemExit(2) """, ) - executable.chmod(0o755) + descriptor_path = ( + repo + / "tools/polaris/providers/code-intelligence/codegraph.json" + ) + descriptor_bytes = descriptor_path.read_bytes() + runtime_descriptor = json.loads(descriptor_bytes) + runtime_descriptor["cli"] = { + "executable": sys.executable, + "explore_args": [str(executable), "explore"], + "status_args": [str(executable), "status", "--json"], + "sync_args": [str(executable), "sync", "--quiet"], + } + write_json_atomic(descriptor_path, runtime_descriptor) environment = os.environ.copy() - environment["PATH"] = str(fake_bin) + os.pathsep + environment["PATH"] environment["POLARIS_FAKE_CODEGRAPH_LOG"] = str(call_log) registration = json.loads((repo / ".mcp.json").read_text(encoding="utf-8"))[ @@ -1145,6 +1156,7 @@ def test_vendored_mcp_proxy_runs_one_auditable_fake_cli_window(self) -> None: capture_output=True, check=False, ) + descriptor_path.write_bytes(descriptor_bytes) self.assertEqual(mcp.returncode, 0, mcp.stderr) responses = [json.loads(line) for line in mcp.stdout.splitlines()] self.assertEqual([response["id"] for response in responses], [1, 2]) From 5e7eba1006d196600474ac3576dcecfbc5909979 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 16:22:47 +0800 Subject: [PATCH 16/28] docs: design CodeGraph freshness hardening --- ...19-codegraph-freshness-hardening-design.md | 407 ++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-19-codegraph-freshness-hardening-design.md diff --git a/docs/superpowers/specs/2026-08-19-codegraph-freshness-hardening-design.md b/docs/superpowers/specs/2026-08-19-codegraph-freshness-hardening-design.md new file mode 100644 index 0000000..5b0b693 --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-codegraph-freshness-hardening-design.md @@ -0,0 +1,407 @@ +# Polaris CodeGraph Freshness Hardening Design + +## Status + +- Date: 2026-08-19 +- Status: approved for specification +- Scope: Polaris-only CodeGraph query and evidence behavior +- Provider repository changes: prohibited + +## Context + +Polaris already routes workflow-owned CodeGraph evidence through the +project-scoped `polaris_codegraph_explore` proxy. The proxy checks CodeGraph +status, can run one incremental sync, executes one explore query, checks status +again, and places a freshness envelope before graph output. + +The current implementation does not yet meet the desired contract: + +- the caller can set `sync_if_needed: false`, so freshness depends on Agent + behavior rather than the proxy; +- an unreadable or timed-out pre-query status prevents the query, even when the + repository identity is known and stale graph data would still be useful for + navigation; +- response classification is coupled to older CodeGraph warning text and does + not precisely recognize current `indexing in progress`, auto-sync-disabled, + or changed-on-disk notices; +- a broad suspicious-word scan can confuse warning-like words in verbatim source + with CodeGraph response framing; +- the proxy bundle does not explicitly identify the automatic refresh policy + that governed the query. + +CodeGraph is an external Provider. This change must not modify its repository, +commands, MCP tools, watcher, daemon, configuration, or index implementation. + +## Goals + +1. Return graph data that is as fresh as Polaris can obtain with one bounded + incremental reconciliation. +2. Return known-stale graph data when it remains useful, but make its staleness + impossible to miss. +3. Return graph data when freshness cannot be verified, clearly marking it as + unknown and requiring it to be treated as stale. +4. Prevent stale or unverifiable relationships from becoming planning, + implementation, documentation, or Review conclusions without current source + or Git verification. +5. Preserve the optional, non-gating role of Code Intelligence. +6. Preserve all committed Code Intelligence v1, v2, and v3 records unchanged. + +## Non-Goals + +- Changing the CodeGraph repository or asking CodeGraph to add an API. +- Automatically running `codegraph index` or otherwise performing a full + rebuild. Full rebuilds are always initiated by the user. +- Installing, initializing, starting, configuring, or supervising CodeGraph. +- Waiting for a watcher, polling, retrying, or adding a daemon or scheduler. +- Proving that CodeGraph parsing or inferred relationships are semantically + correct. +- Claiming that a result remains current after it has been delivered. +- Making CodeGraph availability or freshness a workflow gate. + +## Decisions + +### Bounded freshness + +The only positive freshness claim is `CURRENT_AT_CHECK`. It means Polaris found +no stale or unverifiable signal during the bounded pre-query, optional-sync, +query, and post-query window. It is not a permanent guarantee and is not a +claim of strict Git-commit equivalence. + +### Automatic incremental reconciliation + +The proxy, not the caller, owns the refresh decision. If the pre-query status +reports any pending added, modified, or removed files, the proxy runs exactly +one bounded `codegraph sync` and then checks status once more. It does this for +every Polaris stage that queries CodeGraph. + +The proxy never runs `codegraph index`. Index states that require or recommend a +full rebuild remain stale and include a user-action reason. + +### Useful stale and unknown output + +A failed, timed-out, or malformed freshness check does not by itself prevent an +explore query when Polaris has independently established the fixed repository +identity and safe paths. The graph result is delivered as `UNKNOWN`, with +`TREAT_AS_STALE` and `NAVIGATION_ONLY` restrictions. + +Known stale signals produce `STALE`. Both `STALE` and `UNKNOWN` results may guide +navigation, but no relationship or conclusion derived from them is usable until +the relevant current source or Git facts have been checked. + +Repository/worktree identity mismatch or unsafe path resolution prevents the +query. Polaris must not deliver another checkout's graph as navigation for the +current checkout. + +## Considered Approaches + +### 1. Harden the existing Polaris proxy — selected + +Keep status, optional incremental synchronization, query, post-query status, +classification, evidence, and delivery in one Polaris-owned operation. This is +the only approach that makes the freshness warning mechanically adjacent to the +graph output while leaving CodeGraph unchanged. + +### 2. Mark every result stale + +This is safe but discards useful `CURRENT_AT_CHECK` evidence and does not satisfy +the goal of obtaining the freshest practical data. + +### 3. Use separate status and raw CodeGraph calls + +This requires the Agent to preserve the association between two independent +tool calls. It can omit or overlook the warning, and it leaves a wider race +between the status observation and delivered graph content. + +## Architecture + +`polaris_codegraph_explore` remains the only CodeGraph path that can create +Polaris Code Intelligence evidence. Raw CodeGraph MCP and shell commands remain +available outside that evidence path and are always unverified for Polaris. + +The project-scoped MCP server fixes the repository root at launch. A tool call +provides task, stage, sequential query ID, purpose, and query, but no repository +path and no refresh-policy switch. The proxy performs all operations with that +fixed repository as the working directory. + +The components retain narrow responsibilities: + +- `codegraph_adapter.py` invokes and normalizes CodeGraph CLI status, sync, and + explore operations and classifies Provider response framing. +- `code_intelligence_proxy.py` validates Polaris stage context, owns the bounded + query window, merges observations, persists immutable runtime evidence, and + renders the freshness envelope. +- `code_intelligence_mcp.py` exposes the single project-scoped MCP tool and + guarantees that the envelope precedes graph content. +- `code_intelligence_protocol.py` validates and projects proxy evidence into the + existing Code Intelligence record. +- the Code Intelligence Skill performs required current-source or Git fallback + before using stale or unknown graph conclusions. + +## Query Flow + +1. Validate protocol compatibility, policy, fixed repository root, task and + stage context, query ID, purpose, and evidence-path confinement. +2. Verify that `.codegraph/` and the CodeGraph CLI are available. +3. Execute `codegraph status --json` in the fixed repository. +4. If the status proves a repository/worktree identity mismatch, stop without + querying and return no graph content. +5. If pending changes are known, execute exactly one incremental + `codegraph sync`, then execute one post-sync status check. +6. Do not sync merely because the index is partial, failed, built with an older + extraction version, or has another index-wide stale reason. Those conditions + cannot be repaired reliably by pretending an incremental sync is a rebuild. + If pending changes coexist with an index-wide stale reason, still perform the + one incremental sync for those changes while retaining any reason that + remains after the post-sync check. +7. If status is unreadable or unavailable for a verification reason, retain the + failure observation and continue. Missing Provider capability or an unsafe + identity/path remains a no-query condition. +8. Execute one bounded `codegraph explore`. Do not retry. +9. Hash and persist an exact UTF-8 response only under the task's ignored runtime + evidence directory. Reject overwrite or digest mismatch. +10. Classify only CodeGraph response framing and metadata notices. +11. Execute one post-query status check whenever an explore response was + obtained. +12. Merge all observations conservatively and persist the immutable proxy + bundle. +13. Return the freshness envelope as the first MCP content block. Return raw + graph output, when safe and available, only in a later block. +14. Require and record the source/Git fallback for every `STALE` or `UNKNOWN` + result before projecting or using its conclusions. + +There is no wait, poll, query retry, sync retry, full rebuild, or raw-MCP +substitution. + +## Delivery States + +### `CURRENT_AT_CHECK` + +All of the following are required: + +- the effective pre-query status is structurally valid and belongs to the fixed + repository; +- pending added, modified, and removed counts are zero after any allowed sync; +- there is no worktree mismatch, partial/indexing/failed index, pending + resolution work, or reindex recommendation; +- explore succeeds and response framing carries no stale or unverifiable + signal; +- post-query status is structurally valid, belongs to the same repository, and + has zero pending changes and no unhealthy index signal. + +Usage is `NON_AUTHORITATIVE_CONTEXT`. Source, Git, builds, tests, Review, +Validation, and Human decisions remain authoritative. + +### `STALE` + +At least one known stale signal exists, such as: + +- pending changes remain before or after the query; +- the one allowed sync fails; +- CodeGraph reports pending sync, indexing in progress, changed-on-disk source, + or disabled auto-sync; +- the index is partial, indexing, failed, has pending resolution work, or + recommends a rebuild; +- a query-time or post-query observation proves the index changed during the + window. + +Graph output is returned when safe. Usage is `NAVIGATION_ONLY`, and the envelope +contains the exact known reasons and required fallback. + +### `UNKNOWN` + +Freshness cannot be established, including status timeout, malformed status, +unrecognized Provider freshness framing, post-query verification failure, or +response-integrity uncertainty. + +Graph output is still returned when repository identity, path confinement, and +response integrity are safe enough to deliver it. The envelope includes +`freshness: TREAT_AS_STALE`, usage is `NAVIGATION_ONLY`, and current-source or +Git fallback is mandatory. + +If a known stale signal and a verification failure coexist, the delivery must +retain both. The top-level state is `STALE` because known staleness must remain +explicit; the verification failure is an additional reason and cannot promote +the result. + +### `UNAVAILABLE` + +No CodeGraph data is available because policy disables it, `.codegraph/` is +absent, or the CLI is missing, so no Provider query can be attempted. Polaris +continues with source and Git. If explore is attempted but fails, the result is +instead `UNKNOWN` with no graph content because Polaris observed a verification +failure rather than Provider absence. + +An identity mismatch is represented as an unverifiable no-graph result rather +than Provider absence, so diagnostics preserve the security-relevant reason. + +## Response Classification + +CodeGraph returns human-readable text rather than a versioned structured +freshness object. Polaris therefore maintains a conservative compatibility +adapter without modifying CodeGraph. + +The classifier recognizes current documented framing for: + +- referenced files pending sync; +- referenced files whose indexing is in progress; +- pending files elsewhere in the project; +- auto-sync disabled or watcher degradation; +- files changed on disk after their last index sync; +- worktree/index-root mismatch. + +Classification is framing-aware. It examines only leading notices, recognized +file-section metadata, and recognized trailing notices. It must not search +verbatim source bodies for generic words such as `stale`, `warning`, or +`pending`, because those words can be legitimate program text. + +Exact recognized notices create precise file- or index-scoped stale points. A +new or malformed warning-like notice in a framing position produces `UNKNOWN`. +Ordinary source text cannot create a freshness downgrade. Status JSON remains +the primary machine-readable freshness basis; response parsing is an additional +race and degradation signal. + +## Freshness Envelope + +Every successful proxy tool result starts with a finite block similar to: + +```text +[POLARIS_CODEGRAPH_FRESHNESS] +state: UNKNOWN +record_status: NOT_VERIFIED +freshness: TREAT_AS_STALE +reason: PRE_STATUS_TIMEOUT +checked_at: 2026-08-19T00:00:00Z +pending_added: 0 +pending_modified: 0 +pending_removed: 0 +usage: NAVIGATION_ONLY +required_fallback: SEARCH_SOURCE +evidence_bundle: runtime/code-intelligence/CIQ-001.json +[/POLARIS_CODEGRAPH_FRESHNESS] +``` + +The envelope is always the first content block. No stdout, stderr, diagnostic, +or graph bytes may precede it. Raw graph output, if retained, is a separate later +content block. + +## Source and Git Fallback + +`STALE` and `UNKNOWN` evidence cannot support a workflow conclusion until the +required fallback is complete: + +- for a safe named current regular file, read it and record `READ_SOURCE` with + its current SHA-256; +- for a safe missing or deleted file, inspect the registered subject's Git diff + and record `INSPECT_GIT_DIFF` with the bound base, head, and diff hashes; +- for an unsafe, index-wide, or unknown point, perform a bounded repository + search and record `SEARCH_SOURCE` with zero to 100 confined current regular + files and their SHA-256 values. + +An old graph relationship can choose where to look. Only the resulting current +source or Git fact can support a plan, edit, documentation conclusion, or Review +verdict. + +## Interface and Versioning + +- Polaris protocol/package version advances from `0.1.21` to `0.1.22`. +- Workflow remains `0.1.3`; no workflow node, edge, status, or transition gate + changes. +- `polaris_codegraph_explore` removes the public `sync_if_needed` argument. The + proxy always owns the incremental-sync decision. +- New runtime proxy evidence uses `bundle_version: 2` and records the automatic + refresh policy. +- Bundle v1 remains readable for an interrupted pre-upgrade task, but new calls + never write it. +- New durable Code Intelligence records remain `record_version: 3`; the current + record already represents pre/post status, sync, query, delivery state, + reasons, and source fallback. +- Existing v1, v2, and v3 durable records remain immutable and valid. +- The adjacent migration updates vendored protocol files, host MCP definitions, + Skills, and validators without rewriting Code Intelligence records or + changing workflow state. + +## Failure Handling + +| Condition | Query? | Delivery | Required action | +|---|---:|---|---| +| Clean pre/post status | Yes | `CURRENT_AT_CHECK` | None beyond normal authority checks | +| Pending, sync succeeds, post-sync clean | Yes | Eligible for `CURRENT_AT_CHECK` | None beyond normal authority checks | +| Pending, sync fails | Yes | `STALE` | Source/Git fallback | +| Pending remains after sync | Yes | `STALE` | Source/Git fallback | +| Index partial/failed/rebuild recommended | Yes | `STALE` | Source/Git fallback; user may rebuild | +| Pre-status timeout/malformed | Yes | `UNKNOWN` | Treat as stale; source/Git fallback | +| Post-status timeout/malformed | Yes | `UNKNOWN`, unless known stale also exists | Treat as stale; source/Git fallback | +| Unknown response-framing warning | Yes, already completed | `UNKNOWN` | Treat as stale; source/Git fallback | +| Repository/worktree identity mismatch | No | `UNKNOWN`, no graph | Source/Git fallback | +| Unsafe response path or digest mismatch | No deliverable graph | `UNKNOWN` | Source/Git fallback | +| Policy disabled, marker absent, or CLI absent | No | `UNAVAILABLE` | Use source/Git | +| Explore failure | Attempted | `UNKNOWN`, no graph | Treat as stale; use source/Git | + +No failure path invokes `codegraph index`. + +## Implementation Scope + +Expected Polaris files include: + +- `scripts/internal/codegraph_adapter.py` +- `scripts/internal/code_intelligence_proxy.py` +- `scripts/internal/code_intelligence_protocol.py` +- `scripts/code_intelligence_mcp.py` +- Code Intelligence schemas and runtime bundle validation as needed +- `skills/code-intelligence/SKILL.md` and stage Skills that call it +- host-rendered/vendored instructions and templates +- `plan.md`, README files, and usage documentation +- protocol version and adjacent migration metadata +- `tests/test_codegraph.py` and relevant core/vendoring tests + +The CodeGraph repository is outside implementation scope and must remain +unchanged. + +## Testing + +Deterministic tests must cover: + +1. clean pre/post observations produce `CURRENT_AT_CHECK`; +2. pending changes cause exactly one incremental sync for every querying stage; +3. a successful sync followed by clean status can produce `CURRENT_AT_CHECK`; +4. sync failure or remaining pending changes still runs explore and produces + `STALE`; +5. unreadable, malformed, failed, or timed-out pre-status still runs explore and + produces `UNKNOWN`; +6. repository/worktree mismatch prevents explore; +7. pending changes first observed after explore downgrade the result; +8. current CodeGraph pending, indexing, degraded/disabled, changed-on-disk, and + mismatch notices classify correctly; +9. warning-like words inside returned source do not affect classification; +10. an unknown warning in a framing position produces `UNKNOWN`; +11. the envelope is always the first content block and graph output is never + delivered before it; +12. stale and unknown records without the exact required source/Git fallback are + rejected; +13. the MCP schema has no caller-controlled sync bypass; +14. command-runner tests prove no proxy branch can invoke `codegraph index`; +15. bundle v2 is validated and bundle v1 remains readable for interrupted + upgrade recovery; +16. committed Code Intelligence v1, v2, and v3 records remain byte-identical and + valid; +17. Windows paths and CRLF, macOS, and Linux behavior are covered without + platform-specific assumptions; +18. the complete Polaris suite passes without CodeGraph installed; +19. an optional real-CLI smoke test uses only a disposable temporary repository. + +## Acceptance Criteria + +1. Every Polaris-delivered graph response is preceded by a machine-readable and + human-visible freshness envelope. +2. A pending change always triggers at most one automatic incremental sync and + can never trigger a full rebuild. +3. No caller can disable the automatic incremental-sync policy. +4. A freshness-check failure still permits safe graph delivery as `UNKNOWN`. +5. A known stale signal is always visible as `STALE`, even when other + verification failures coexist. +6. Only a fully clean bounded window can produce `CURRENT_AT_CHECK`. +7. `STALE` and `UNKNOWN` graph data is navigation-only until current source or + Git fallback is recorded. +8. Repository/worktree mismatch and unsafe paths never deliver graph content. +9. Existing durable Code Intelligence records remain unchanged and valid. +10. No file in the CodeGraph repository is modified. From ab277962517d498e164d1bef2819c46dffc95f00 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 16:56:04 +0800 Subject: [PATCH 17/28] docs: translate CodeGraph freshness design --- ...19-codegraph-freshness-hardening-design.md | 572 +++++++----------- 1 file changed, 224 insertions(+), 348 deletions(-) diff --git a/docs/superpowers/specs/2026-08-19-codegraph-freshness-hardening-design.md b/docs/superpowers/specs/2026-08-19-codegraph-freshness-hardening-design.md index 5b0b693..89da91f 100644 --- a/docs/superpowers/specs/2026-08-19-codegraph-freshness-hardening-design.md +++ b/docs/superpowers/specs/2026-08-19-codegraph-freshness-hardening-design.md @@ -1,268 +1,174 @@ -# Polaris CodeGraph Freshness Hardening Design - -## Status - -- Date: 2026-08-19 -- Status: approved for specification -- Scope: Polaris-only CodeGraph query and evidence behavior -- Provider repository changes: prohibited +# Polaris CodeGraph 新鲜度加固设计 -## Context +## 状态 -Polaris already routes workflow-owned CodeGraph evidence through the -project-scoped `polaris_codegraph_explore` proxy. The proxy checks CodeGraph -status, can run one incremental sync, executes one explore query, checks status -again, and places a freshness envelope before graph output. +- 日期:2026-08-19 +- 状态:设计已确认,等待书面规格审阅 +- 范围:仅修改 Polaris 的 CodeGraph 查询与证据行为 +- CodeGraph 仓库:禁止修改 -The current implementation does not yet meet the desired contract: +## 背景 -- the caller can set `sync_if_needed: false`, so freshness depends on Agent - behavior rather than the proxy; -- an unreadable or timed-out pre-query status prevents the query, even when the - repository identity is known and stale graph data would still be useful for - navigation; -- response classification is coupled to older CodeGraph warning text and does - not precisely recognize current `indexing in progress`, auto-sync-disabled, - or changed-on-disk notices; -- a broad suspicious-word scan can confuse warning-like words in verbatim source - with CodeGraph response framing; -- the proxy bundle does not explicitly identify the automatic refresh policy - that governed the query. +Polaris 已经通过项目级 `polaris_codegraph_explore` 代理承载 Workflow 内的 CodeGraph 证据。代理会检查 CodeGraph 状态、按条件执行一次增量同步、执行一次 explore 查询、再次检查状态,并把新鲜度 envelope 放在图输出之前。 -CodeGraph is an external Provider. This change must not modify its repository, -commands, MCP tools, watcher, daemon, configuration, or index implementation. +当前实现仍未完全满足目标契约: -## Goals - -1. Return graph data that is as fresh as Polaris can obtain with one bounded - incremental reconciliation. -2. Return known-stale graph data when it remains useful, but make its staleness - impossible to miss. -3. Return graph data when freshness cannot be verified, clearly marking it as - unknown and requiring it to be treated as stale. -4. Prevent stale or unverifiable relationships from becoming planning, - implementation, documentation, or Review conclusions without current source - or Git verification. -5. Preserve the optional, non-gating role of Code Intelligence. -6. Preserve all committed Code Intelligence v1, v2, and v3 records unchanged. - -## Non-Goals - -- Changing the CodeGraph repository or asking CodeGraph to add an API. -- Automatically running `codegraph index` or otherwise performing a full - rebuild. Full rebuilds are always initiated by the user. -- Installing, initializing, starting, configuring, or supervising CodeGraph. -- Waiting for a watcher, polling, retrying, or adding a daemon or scheduler. -- Proving that CodeGraph parsing or inferred relationships are semantically - correct. -- Claiming that a result remains current after it has been delivered. -- Making CodeGraph availability or freshness a workflow gate. - -## Decisions - -### Bounded freshness - -The only positive freshness claim is `CURRENT_AT_CHECK`. It means Polaris found -no stale or unverifiable signal during the bounded pre-query, optional-sync, -query, and post-query window. It is not a permanent guarantee and is not a -claim of strict Git-commit equivalence. - -### Automatic incremental reconciliation - -The proxy, not the caller, owns the refresh decision. If the pre-query status -reports any pending added, modified, or removed files, the proxy runs exactly -one bounded `codegraph sync` and then checks status once more. It does this for -every Polaris stage that queries CodeGraph. - -The proxy never runs `codegraph index`. Index states that require or recommend a -full rebuild remain stale and include a user-action reason. - -### Useful stale and unknown output - -A failed, timed-out, or malformed freshness check does not by itself prevent an -explore query when Polaris has independently established the fixed repository -identity and safe paths. The graph result is delivered as `UNKNOWN`, with -`TREAT_AS_STALE` and `NAVIGATION_ONLY` restrictions. - -Known stale signals produce `STALE`. Both `STALE` and `UNKNOWN` results may guide -navigation, but no relationship or conclusion derived from them is usable until -the relevant current source or Git facts have been checked. - -Repository/worktree identity mismatch or unsafe path resolution prevents the -query. Polaris must not deliver another checkout's graph as navigation for the -current checkout. - -## Considered Approaches - -### 1. Harden the existing Polaris proxy — selected - -Keep status, optional incremental synchronization, query, post-query status, -classification, evidence, and delivery in one Polaris-owned operation. This is -the only approach that makes the freshness warning mechanically adjacent to the -graph output while leaving CodeGraph unchanged. - -### 2. Mark every result stale - -This is safe but discards useful `CURRENT_AT_CHECK` evidence and does not satisfy -the goal of obtaining the freshest practical data. - -### 3. Use separate status and raw CodeGraph calls - -This requires the Agent to preserve the association between two independent -tool calls. It can omit or overlook the warning, and it leaves a wider race -between the status observation and delivered graph content. - -## Architecture - -`polaris_codegraph_explore` remains the only CodeGraph path that can create -Polaris Code Intelligence evidence. Raw CodeGraph MCP and shell commands remain -available outside that evidence path and are always unverified for Polaris. - -The project-scoped MCP server fixes the repository root at launch. A tool call -provides task, stage, sequential query ID, purpose, and query, but no repository -path and no refresh-policy switch. The proxy performs all operations with that -fixed repository as the working directory. - -The components retain narrow responsibilities: - -- `codegraph_adapter.py` invokes and normalizes CodeGraph CLI status, sync, and - explore operations and classifies Provider response framing. -- `code_intelligence_proxy.py` validates Polaris stage context, owns the bounded - query window, merges observations, persists immutable runtime evidence, and - renders the freshness envelope. -- `code_intelligence_mcp.py` exposes the single project-scoped MCP tool and - guarantees that the envelope precedes graph content. -- `code_intelligence_protocol.py` validates and projects proxy evidence into the - existing Code Intelligence record. -- the Code Intelligence Skill performs required current-source or Git fallback - before using stale or unknown graph conclusions. - -## Query Flow - -1. Validate protocol compatibility, policy, fixed repository root, task and - stage context, query ID, purpose, and evidence-path confinement. -2. Verify that `.codegraph/` and the CodeGraph CLI are available. -3. Execute `codegraph status --json` in the fixed repository. -4. If the status proves a repository/worktree identity mismatch, stop without - querying and return no graph content. -5. If pending changes are known, execute exactly one incremental - `codegraph sync`, then execute one post-sync status check. -6. Do not sync merely because the index is partial, failed, built with an older - extraction version, or has another index-wide stale reason. Those conditions - cannot be repaired reliably by pretending an incremental sync is a rebuild. - If pending changes coexist with an index-wide stale reason, still perform the - one incremental sync for those changes while retaining any reason that - remains after the post-sync check. -7. If status is unreadable or unavailable for a verification reason, retain the - failure observation and continue. Missing Provider capability or an unsafe - identity/path remains a no-query condition. -8. Execute one bounded `codegraph explore`. Do not retry. -9. Hash and persist an exact UTF-8 response only under the task's ignored runtime - evidence directory. Reject overwrite or digest mismatch. -10. Classify only CodeGraph response framing and metadata notices. -11. Execute one post-query status check whenever an explore response was - obtained. -12. Merge all observations conservatively and persist the immutable proxy - bundle. -13. Return the freshness envelope as the first MCP content block. Return raw - graph output, when safe and available, only in a later block. -14. Require and record the source/Git fallback for every `STALE` or `UNKNOWN` - result before projecting or using its conclusions. - -There is no wait, poll, query retry, sync retry, full rebuild, or raw-MCP -substitution. - -## Delivery States +- 调用方可以传入 `sync_if_needed: false`,导致是否争取最新数据取决于 Agent 行为,而不是代理协议; +- 查询前状态超时、损坏或不可读时,代理直接放弃查询;即使仓库身份明确、旧图仍可用于导航,也拿不到图数据; +- 响应分类器绑定了旧版 CodeGraph 的警告文案,无法精确识别当前的 `indexing in progress`、auto-sync disabled 和 changed-on-disk 等提示; +- 当前对可疑词的宽泛扫描可能把返回源码正文中的 `stale`、`warning` 等普通文本误判为 CodeGraph 新鲜度警告; +- 代理 bundle 没有明确记录本次查询所遵循的自动刷新策略。 + +CodeGraph 是外部 Provider。本次改动不得修改 CodeGraph 仓库、命令、MCP 工具、watcher、daemon、配置或索引实现。 + +## 目标 + +1. 在一次有界增量协调能力内,让 Polaris 尽可能取得最新的图数据。 +2. 旧图仍有导航价值时允许返回,但必须让“数据已过期”这一事实无法被忽略。 +3. 无法验证新鲜度时仍允许返回图数据,但必须明确标为未知,并要求按过期数据处理。 +4. 过期或无法验证的关系在经过当前源码或 Git 核验前,不得成为 Planning、Implementation、Documentation Sync 或 Review 的结论。 +5. Code Intelligence 继续保持可选、非门禁能力。 +6. 已提交的 Code Intelligence v1、v2、v3 record 保持原样。 + +## 非目标 + +- 修改 CodeGraph 仓库,或要求 CodeGraph 新增接口。 +- 自动执行 `codegraph index` 或其他全量重建操作。全量重建始终由用户主动触发。 +- 安装、初始化、启动、配置或监管 CodeGraph。 +- 等待 watcher、轮询、重试,或增加 daemon、scheduler。 +- 证明 CodeGraph 的解析或关系推断在语义上正确。 +- 声称结果交付之后仍会持续保持最新。 +- 把 CodeGraph 的可用性或新鲜度变成 Workflow 门禁。 + +## 已确认决策 + +### 有界新鲜度 + +唯一允许的正向新鲜度声明是 `CURRENT_AT_CHECK`。它表示 Polaris 在查询前、可选增量同步、查询和查询后检查组成的有界窗口内,没有发现过期或不可验证信号。它不是永久保证,也不表示结果与某个 Git commit 严格等价。 + +### 自动增量协调 + +刷新决策归代理所有,不再归调用方所有。查询前状态只要报告任意 pending added、modified 或 removed 文件,代理就在所有会查询 CodeGraph 的 Polaris 阶段中执行且仅执行一次有界 `codegraph sync`,随后再检查一次状态。 + +代理绝不执行 `codegraph index`。需要或建议全量重建的索引状态继续标记为过期,并在原因中明确提示这是用户动作。 + +### 返回有用的旧数据和未知数据 + +状态检查失败、超时或格式损坏,本身不再阻止 explore 查询;前提是 Polaris 已独立确认固定仓库身份和路径安全。图结果以 `UNKNOWN` 交付,并带有 `TREAT_AS_STALE` 与 `NAVIGATION_ONLY` 限制。 + +已知的过期信号产生 `STALE`。`STALE` 和 `UNKNOWN` 都可以指引导航,但从中得到的关系或结论必须先由当前源码或 Git 事实核验,才可用于 Workflow。 + +仓库或 worktree 身份不匹配、路径不安全时禁止查询。Polaris 不得把另一个 checkout 的图当作当前 checkout 的导航数据。 + +## 备选方案 + +### 方案一:加固现有 Polaris 代理——采用 + +把状态检查、可选增量同步、查询、查询后检查、响应分类、证据和交付继续收敛在一个 Polaris 自有操作内。它能在不修改 CodeGraph 的前提下,机械保证新鲜度警告与图输出相邻交付。 + +### 方案二:所有结果一律标为过期 + +这个方案安全但会丢弃本可证明的 `CURRENT_AT_CHECK`,也不符合“尽可能拿到新鲜数据”的目标。 + +### 方案三:先独立检查状态,再调用原始 CodeGraph 工具 + +这要求 Agent 自行维护两个工具调用之间的关联。Agent 可能漏掉警告,而且状态检查与图交付之间的竞态窗口更大。 + +## 架构 + +`polaris_codegraph_explore` 继续作为唯一能够生成 Polaris Code Intelligence 证据的 CodeGraph 路径。原始 CodeGraph MCP 和 shell 命令仍可在该证据路径之外使用,但它们对 Polaris 而言始终属于未验证数据。 + +项目级 MCP server 在启动时固定仓库根。工具调用只提供 task、stage、顺序 query ID、purpose 和 query,不提供仓库路径,也不提供刷新策略开关。代理的所有操作都以该固定仓库为工作目录。 + +各组件继续保持单一职责: + +- `codegraph_adapter.py`:调用并标准化 CodeGraph CLI 的 status、sync、explore,以及分类 Provider 响应框架; +- `code_intelligence_proxy.py`:校验 Polaris 阶段上下文,控制有界查询窗口,合并观察结果,持久化不可变 runtime 证据,并渲染新鲜度 envelope; +- `code_intelligence_mcp.py`:暴露唯一的项目级 MCP 工具,并保证 envelope 先于图内容; +- `code_intelligence_protocol.py`:校验代理证据,并投影为现有 Code Intelligence record; +- Code Intelligence Skill:在使用过期或未知图结论之前,完成所需的当前源码或 Git 回退核验。 + +## 查询流程 + +1. 校验协议兼容性、策略、固定仓库根、task/stage 上下文、query ID、purpose 和 evidence 路径边界。 +2. 验证 `.codegraph/` 与 CodeGraph CLI 是否可用。 +3. 在固定仓库内执行 `codegraph status --json`。 +4. 如果状态证明仓库或 worktree 身份不匹配,停止且不执行查询,不返回图内容。 +5. 如果已知存在 pending changes,执行且仅执行一次增量 `codegraph sync`,然后执行一次同步后状态检查。 +6. 不因为索引处于 partial、failed、由旧 extraction version 构建或存在其他索引级过期原因而单独触发 sync;这些情况不能通过假装增量同步等同于全量重建来可靠修复。如果 pending changes 与索引级过期原因同时存在,仍对这些变更执行一次增量同步,但同步后仍存在的索引级原因必须保留。 +7. 如果状态因为验证错误而不可读或不可确认,保留失败观察并继续查询。Provider 能力缺失、仓库身份不安全或路径不安全仍属于禁止查询条件。 +8. 执行一次有界 `codegraph explore`,不重试。 +9. 仅在 task 的 Git ignored runtime evidence 目录下保存精确 UTF-8 响应及其哈希;已存在目标或摘要不一致时拒绝覆盖。 +10. 只分类 CodeGraph 响应框架和元数据提示。 +11. 只要取得 explore 响应,就执行一次查询后状态检查。 +12. 保守合并全部观察,并保存不可变代理 bundle。 +13. 把 freshness envelope 作为 MCP 的第一个内容块返回;安全且可用的原始图输出只能出现在后续内容块。 +14. 每个 `STALE` 或 `UNKNOWN` 结果都必须完成并记录源码/Git 回退,之后才允许投影或使用其结论。 + +整个流程不等待、不轮询、不重试查询、不重试同步、不全量重建,也不改用原始 MCP 作为替代路径。 + +## 交付状态 ### `CURRENT_AT_CHECK` -All of the following are required: +必须同时满足以下条件: -- the effective pre-query status is structurally valid and belongs to the fixed - repository; -- pending added, modified, and removed counts are zero after any allowed sync; -- there is no worktree mismatch, partial/indexing/failed index, pending - resolution work, or reindex recommendation; -- explore succeeds and response framing carries no stale or unverifiable - signal; -- post-query status is structurally valid, belongs to the same repository, and - has zero pending changes and no unhealthy index signal. +- 有效的查询前状态或同步后状态结构正确,且属于固定仓库; +- 任何允许的同步完成后,pending added、modified、removed 数量均为零; +- 不存在 worktree mismatch、partial/indexing/failed index、pending resolution 或 reindex recommendation; +- explore 成功,响应框架不包含过期或不可验证信号; +- 查询后状态结构正确,属于同一仓库,pending 数量均为零且索引健康。 -Usage is `NON_AUTHORITATIVE_CONTEXT`. Source, Git, builds, tests, Review, -Validation, and Human decisions remain authoritative. +其用途是 `NON_AUTHORITATIVE_CONTEXT`。源码、Git、构建、测试、Review、Validation 和 Human decision 继续拥有权威性。 ### `STALE` -At least one known stale signal exists, such as: +至少存在一个明确的过期信号,例如: -- pending changes remain before or after the query; -- the one allowed sync fails; -- CodeGraph reports pending sync, indexing in progress, changed-on-disk source, - or disabled auto-sync; -- the index is partial, indexing, failed, has pending resolution work, or - recommends a rebuild; -- a query-time or post-query observation proves the index changed during the - window. +- 查询前或查询后仍有 pending changes; +- 唯一一次允许的增量同步失败; +- CodeGraph 报告 pending sync、indexing in progress、changed on disk 或 auto-sync disabled; +- 索引为 partial、indexing、failed,存在 pending resolution,或建议重建; +- 查询期间或查询后观察证明索引在窗口中发生变化。 -Graph output is returned when safe. Usage is `NAVIGATION_ONLY`, and the envelope -contains the exact known reasons and required fallback. +安全时仍返回图输出。用途是 `NAVIGATION_ONLY`,envelope 必须包含已知原因和所需 fallback。 ### `UNKNOWN` -Freshness cannot be established, including status timeout, malformed status, -unrecognized Provider freshness framing, post-query verification failure, or -response-integrity uncertainty. +无法建立新鲜度证明,包括 status 超时、status 格式损坏、无法识别的 Provider freshness framing、查询后验证失败或响应完整性不确定。 -Graph output is still returned when repository identity, path confinement, and -response integrity are safe enough to deliver it. The envelope includes -`freshness: TREAT_AS_STALE`, usage is `NAVIGATION_ONLY`, and current-source or -Git fallback is mandatory. +只要仓库身份、路径边界和响应完整性足以安全交付,仍返回图输出。envelope 必须包含 `freshness: TREAT_AS_STALE`,用途是 `NAVIGATION_ONLY`,并强制执行当前源码或 Git 回退。 -If a known stale signal and a verification failure coexist, the delivery must -retain both. The top-level state is `STALE` because known staleness must remain -explicit; the verification failure is an additional reason and cannot promote -the result. +如果已知过期信号与验证失败同时存在,两者都必须保留。顶层状态使用 `STALE`,因为明确的过期事实不能被 `UNKNOWN` 隐藏;验证失败作为附加原因存在,也不得把结果升级。 ### `UNAVAILABLE` -No CodeGraph data is available because policy disables it, `.codegraph/` is -absent, or the CLI is missing, so no Provider query can be attempted. Polaris -continues with source and Git. If explore is attempted but fails, the result is -instead `UNKNOWN` with no graph content because Polaris observed a verification -failure rather than Provider absence. +策略禁用 Code Intelligence、缺少 `.codegraph/` 或缺少 CLI,导致无法尝试 Provider 查询时,没有 CodeGraph 数据可返回,Polaris 直接使用源码和 Git。 + +如果 explore 已经尝试但失败,状态改为 `UNKNOWN` 且不返回图内容,因为 Polaris 观察到的是验证失败,而不是 Provider 缺失。 -An identity mismatch is represented as an unverifiable no-graph result rather -than Provider absence, so diagnostics preserve the security-relevant reason. +身份不匹配表示“无法验证且禁止交付图”,不是 Provider 缺失,因此必须保留对应的安全诊断原因,不能伪装成普通 `UNAVAILABLE`。 -## Response Classification +## 响应分类 -CodeGraph returns human-readable text rather than a versioned structured -freshness object. Polaris therefore maintains a conservative compatibility -adapter without modifying CodeGraph. +CodeGraph 返回人类可读文本,而不是带版本的结构化 freshness 对象。因此 Polaris 维护一个保守的兼容适配层,但不修改 CodeGraph。 -The classifier recognizes current documented framing for: +分类器识别当前 CodeGraph 的以下响应框架: -- referenced files pending sync; -- referenced files whose indexing is in progress; -- pending files elsewhere in the project; -- auto-sync disabled or watcher degradation; -- files changed on disk after their last index sync; -- worktree/index-root mismatch. +- 响应引用的文件正在等待同步; +- 响应引用的文件正在建立索引; +- 项目内其他文件正在等待同步; +- auto-sync disabled 或 watcher degraded; +- 文件在上次索引同步后已在磁盘上变化; +- worktree 与 index root 不匹配。 -Classification is framing-aware. It examines only leading notices, recognized -file-section metadata, and recognized trailing notices. It must not search -verbatim source bodies for generic words such as `stale`, `warning`, or -`pending`, because those words can be legitimate program text. +分类器必须理解响应结构。它只检查开头提示、已识别的文件区块元数据和已识别的结尾提示,不得在逐字返回的源码正文中搜索 `stale`、`warning`、`pending` 等通用词,因为它们可能只是合法的程序文本。 -Exact recognized notices create precise file- or index-scoped stale points. A -new or malformed warning-like notice in a framing position produces `UNKNOWN`. -Ordinary source text cannot create a freshness downgrade. Status JSON remains -the primary machine-readable freshness basis; response parsing is an additional -race and degradation signal. +精确识别的提示生成文件级或索引级 stale point。响应框架位置出现新的或格式损坏的 warning-like 提示时,结果降级为 `UNKNOWN`。普通源码文本不能触发新鲜度降级。status JSON 是主要的机器可读 freshness 基础;响应解析只承担额外的竞态与降级信号。 -## Freshness Envelope +## 新鲜度 envelope -Every successful proxy tool result starts with a finite block similar to: +每个成功的代理工具结果都必须以一个有限文本块开头,例如: ```text [POLARIS_CODEGRAPH_FRESHNESS] @@ -280,128 +186,98 @@ evidence_bundle: runtime/code-intelligence/CIQ-001.json [/POLARIS_CODEGRAPH_FRESHNESS] ``` -The envelope is always the first content block. No stdout, stderr, diagnostic, -or graph bytes may precede it. Raw graph output, if retained, is a separate later -content block. - -## Source and Git Fallback - -`STALE` and `UNKNOWN` evidence cannot support a workflow conclusion until the -required fallback is complete: - -- for a safe named current regular file, read it and record `READ_SOURCE` with - its current SHA-256; -- for a safe missing or deleted file, inspect the registered subject's Git diff - and record `INSPECT_GIT_DIFF` with the bound base, head, and diff hashes; -- for an unsafe, index-wide, or unknown point, perform a bounded repository - search and record `SEARCH_SOURCE` with zero to 100 confined current regular - files and their SHA-256 values. - -An old graph relationship can choose where to look. Only the resulting current -source or Git fact can support a plan, edit, documentation conclusion, or Review -verdict. - -## Interface and Versioning - -- Polaris protocol/package version advances from `0.1.21` to `0.1.22`. -- Workflow remains `0.1.3`; no workflow node, edge, status, or transition gate - changes. -- `polaris_codegraph_explore` removes the public `sync_if_needed` argument. The - proxy always owns the incremental-sync decision. -- New runtime proxy evidence uses `bundle_version: 2` and records the automatic - refresh policy. -- Bundle v1 remains readable for an interrupted pre-upgrade task, but new calls - never write it. -- New durable Code Intelligence records remain `record_version: 3`; the current - record already represents pre/post status, sync, query, delivery state, - reasons, and source fallback. -- Existing v1, v2, and v3 durable records remain immutable and valid. -- The adjacent migration updates vendored protocol files, host MCP definitions, - Skills, and validators without rewriting Code Intelligence records or - changing workflow state. - -## Failure Handling - -| Condition | Query? | Delivery | Required action | +envelope 永远是第一个内容块。任何 stdout、stderr、诊断或图数据都不得出现在它之前。保留的原始图输出必须位于独立的后续内容块。 + +## 源码与 Git 回退 + +`STALE` 和 `UNKNOWN` 证据在完成所需回退之前,不得支持 Workflow 结论: + +- 对安全、具名且当前存在的普通文件,读取文件并记录 `READ_SOURCE` 与当前 SHA-256; +- 对安全但缺失或已删除的文件,检查注册 subject 的 Git diff,并记录 `INSPECT_GIT_DIFF` 以及绑定的 base、head、diff hash; +- 对不安全、索引级或未知失效点,执行有界仓库搜索,并记录 `SEARCH_SOURCE`;结果为 0 到 100 个位于仓库边界内的当前普通文件及其 SHA-256。 + +旧图关系可以决定“去哪里查”。只有由此得到的当前源码或 Git 事实,才能支持计划、编辑、文档结论或 Review verdict。 + +## 接口与版本 + +- Polaris 协议/包版本从 `0.1.21` 升级到 `0.1.22`。 +- Workflow 保持 `0.1.3`;不改变 Workflow node、edge、status 或 transition gate。 +- `polaris_codegraph_explore` 移除公开的 `sync_if_needed` 参数,增量同步决策始终归代理。 +- 新的 runtime proxy evidence 使用 `bundle_version: 2`,并记录自动刷新策略。 +- bundle v1 继续可读,以恢复升级时尚未完成投影的任务;新调用不再写入 v1。 +- 新的耐久 Code Intelligence record 继续使用 `record_version: 3`;现有格式已经能够表达查询前后状态、sync、query、delivery state、reason 和 source fallback。 +- 已存在的 v1、v2、v3 耐久 record 保持不可变且继续有效。 +- 相邻迁移更新 vendored 协议文件、宿主 MCP 定义、Skills 和 validators,不重写 Code Intelligence record,也不改变 Workflow 状态。 + +## 失败处理 + +| 条件 | 是否查询 | 交付状态 | 必须动作 | |---|---:|---|---| -| Clean pre/post status | Yes | `CURRENT_AT_CHECK` | None beyond normal authority checks | -| Pending, sync succeeds, post-sync clean | Yes | Eligible for `CURRENT_AT_CHECK` | None beyond normal authority checks | -| Pending, sync fails | Yes | `STALE` | Source/Git fallback | -| Pending remains after sync | Yes | `STALE` | Source/Git fallback | -| Index partial/failed/rebuild recommended | Yes | `STALE` | Source/Git fallback; user may rebuild | -| Pre-status timeout/malformed | Yes | `UNKNOWN` | Treat as stale; source/Git fallback | -| Post-status timeout/malformed | Yes | `UNKNOWN`, unless known stale also exists | Treat as stale; source/Git fallback | -| Unknown response-framing warning | Yes, already completed | `UNKNOWN` | Treat as stale; source/Git fallback | -| Repository/worktree identity mismatch | No | `UNKNOWN`, no graph | Source/Git fallback | -| Unsafe response path or digest mismatch | No deliverable graph | `UNKNOWN` | Source/Git fallback | -| Policy disabled, marker absent, or CLI absent | No | `UNAVAILABLE` | Use source/Git | -| Explore failure | Attempted | `UNKNOWN`, no graph | Treat as stale; use source/Git | +| 查询前后状态干净 | 是 | `CURRENT_AT_CHECK` | 继续遵守普通 Authority 规则 | +| 有 pending,同步成功且同步后干净 | 是 | 可成为 `CURRENT_AT_CHECK` | 继续遵守普通 Authority 规则 | +| 有 pending,但同步失败 | 是 | `STALE` | 源码/Git 回退 | +| 同步后仍有 pending | 是 | `STALE` | 源码/Git 回退 | +| 索引 partial/failed/建议重建 | 是 | `STALE` | 源码/Git 回退;用户可主动重建 | +| 查询前 status 超时/损坏 | 是 | `UNKNOWN` | 按过期处理;源码/Git 回退 | +| 查询后 status 超时/损坏 | 是 | `UNKNOWN`;若另有已知过期则为 `STALE` | 按过期处理;源码/Git 回退 | +| 未知响应框架警告 | 查询已完成 | `UNKNOWN` | 按过期处理;源码/Git 回退 | +| 仓库/worktree 身份不匹配 | 否 | `UNKNOWN`,无图 | 源码/Git 回退 | +| 不安全响应路径或摘要不一致 | 不交付图 | `UNKNOWN` | 源码/Git 回退 | +| 策略禁用、缺 marker 或缺 CLI | 否 | `UNAVAILABLE` | 使用源码/Git | +| explore 失败 | 已尝试 | `UNKNOWN`,无图 | 按过期处理;使用源码/Git | -No failure path invokes `codegraph index`. +任何失败路径都不得调用 `codegraph index`。 -## Implementation Scope +## 实现范围 -Expected Polaris files include: +预计涉及的 Polaris 文件包括: - `scripts/internal/codegraph_adapter.py` - `scripts/internal/code_intelligence_proxy.py` - `scripts/internal/code_intelligence_protocol.py` - `scripts/code_intelligence_mcp.py` -- Code Intelligence schemas and runtime bundle validation as needed -- `skills/code-intelligence/SKILL.md` and stage Skills that call it -- host-rendered/vendored instructions and templates -- `plan.md`, README files, and usage documentation -- protocol version and adjacent migration metadata -- `tests/test_codegraph.py` and relevant core/vendoring tests - -The CodeGraph repository is outside implementation scope and must remain -unchanged. - -## Testing - -Deterministic tests must cover: - -1. clean pre/post observations produce `CURRENT_AT_CHECK`; -2. pending changes cause exactly one incremental sync for every querying stage; -3. a successful sync followed by clean status can produce `CURRENT_AT_CHECK`; -4. sync failure or remaining pending changes still runs explore and produces - `STALE`; -5. unreadable, malformed, failed, or timed-out pre-status still runs explore and - produces `UNKNOWN`; -6. repository/worktree mismatch prevents explore; -7. pending changes first observed after explore downgrade the result; -8. current CodeGraph pending, indexing, degraded/disabled, changed-on-disk, and - mismatch notices classify correctly; -9. warning-like words inside returned source do not affect classification; -10. an unknown warning in a framing position produces `UNKNOWN`; -11. the envelope is always the first content block and graph output is never - delivered before it; -12. stale and unknown records without the exact required source/Git fallback are - rejected; -13. the MCP schema has no caller-controlled sync bypass; -14. command-runner tests prove no proxy branch can invoke `codegraph index`; -15. bundle v2 is validated and bundle v1 remains readable for interrupted - upgrade recovery; -16. committed Code Intelligence v1, v2, and v3 records remain byte-identical and - valid; -17. Windows paths and CRLF, macOS, and Linux behavior are covered without - platform-specific assumptions; -18. the complete Polaris suite passes without CodeGraph installed; -19. an optional real-CLI smoke test uses only a disposable temporary repository. - -## Acceptance Criteria - -1. Every Polaris-delivered graph response is preceded by a machine-readable and - human-visible freshness envelope. -2. A pending change always triggers at most one automatic incremental sync and - can never trigger a full rebuild. -3. No caller can disable the automatic incremental-sync policy. -4. A freshness-check failure still permits safe graph delivery as `UNKNOWN`. -5. A known stale signal is always visible as `STALE`, even when other - verification failures coexist. -6. Only a fully clean bounded window can produce `CURRENT_AT_CHECK`. -7. `STALE` and `UNKNOWN` graph data is navigation-only until current source or - Git fallback is recorded. -8. Repository/worktree mismatch and unsafe paths never deliver graph content. -9. Existing durable Code Intelligence records remain unchanged and valid. -10. No file in the CodeGraph repository is modified. +- 必要的 Code Intelligence schema 与 runtime bundle validation +- `skills/code-intelligence/SKILL.md` 及调用它的阶段 Skills +- 宿主渲染/vendoring 指令与模板 +- `plan.md`、README、使用文档 +- 协议版本与相邻迁移元数据 +- `tests/test_codegraph.py` 及相关 core/vendoring 测试 + +CodeGraph 仓库不在实现范围内,必须保持无修改。 + +## 测试 + +确定性测试必须覆盖: + +1. 查询前后观察干净时产生 `CURRENT_AT_CHECK`; +2. 所有查询阶段发现 pending changes 时都恰好执行一次增量同步; +3. 同步成功且状态转为干净后可以产生 `CURRENT_AT_CHECK`; +4. 同步失败或同步后仍 pending 时,仍执行 explore 并产生 `STALE`; +5. 查询前 status 不可读、格式损坏、失败或超时时,仍执行 explore 并产生 `UNKNOWN`; +6. 仓库/worktree 身份不匹配时不执行 explore; +7. explore 后首次出现 pending changes 时降级结果; +8. 当前 CodeGraph 的 pending、indexing、degraded/disabled、changed-on-disk 和 mismatch 提示都能正确分类; +9. 返回源码中的 warning-like 单词不影响分类; +10. 响应框架位置出现未知警告时产生 `UNKNOWN`; +11. envelope 永远是第一个内容块,图输出永远不能出现在它之前; +12. 缺少精确 source/Git fallback 的 stale 或 unknown record 被拒绝; +13. MCP schema 不再提供调用方控制的同步绕过开关; +14. command runner 测试证明任何代理分支都不能调用 `codegraph index`; +15. bundle v2 得到校验,bundle v1 继续支持升级中断恢复; +16. 已提交的 Code Intelligence v1、v2、v3 record 保持字节不变且继续有效; +17. 覆盖 Windows 路径与 CRLF、macOS、Linux 行为,不引入平台特定假设; +18. 未安装 CodeGraph 时,完整 Polaris 测试套件仍通过; +19. 可选真实 CLI smoke test 只使用一次性临时仓库。 + +## 验收标准 + +1. Polaris 交付的每个图响应之前,都有机器可读且人类可见的 freshness envelope。 +2. pending change 始终触发至多一次自动增量同步,且永远不能触发全量重建。 +3. 调用方不能关闭自动增量同步策略。 +4. freshness 检查失败时,仍可把安全图数据作为 `UNKNOWN` 交付。 +5. 已知过期信号始终明确显示为 `STALE`,即使还同时存在其他验证失败。 +6. 只有完整干净的有界窗口才能产生 `CURRENT_AT_CHECK`。 +7. `STALE` 和 `UNKNOWN` 图数据在记录当前源码或 Git 回退前,只能用于导航。 +8. 仓库/worktree 身份不匹配或路径不安全时,永不交付图内容。 +9. 已存在的耐久 Code Intelligence record 保持不变且继续有效。 +10. CodeGraph 仓库中的任何文件都不被修改。 From c731e4a21dc3ef5f82451897f2127b61d6e865a0 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 17:11:25 +0800 Subject: [PATCH 18/28] docs: plan CodeGraph freshness hardening --- ...026-08-19-codegraph-freshness-hardening.md | 852 ++++++++++++++++++ 1 file changed, 852 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-19-codegraph-freshness-hardening.md diff --git a/docs/superpowers/plans/2026-08-19-codegraph-freshness-hardening.md b/docs/superpowers/plans/2026-08-19-codegraph-freshness-hardening.md new file mode 100644 index 0000000..d2544bc --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-codegraph-freshness-hardening.md @@ -0,0 +1,852 @@ +# Polaris CodeGraph 新鲜度加固实施计划 + +> **供 Agent Worker 使用:** 必须使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans`,按任务逐项实施本计划。所有步骤使用 checkbox(`- [ ]`)跟踪。 + +**目标:** 在不修改 CodeGraph 的前提下,让 Polaris 每次查询都自动争取一次增量同步后的最新图数据,并把已知过期或无法验证的数据明确标记为只能导航使用。 + +**架构:** 保留项目级 `polaris_codegraph_explore` 代理,把状态检查、至多一次自动增量同步、一次 explore、查询后复查和新鲜度 envelope 收敛在同一个有界窗口。`codegraph_adapter.py` 只负责外部 CLI 与响应框架兼容,`code_intelligence_proxy.py` 负责窗口编排,MCP 层不再暴露同步开关;新的 bundle v2 记录自动刷新策略,但耐久 record 继续使用 v3。 + +**技术栈:** Python 3.10+ 标准库(`hashlib`、`json`、`re`、`subprocess`、`unittest`)、JSON Schema、JSON-RPC 2.0/MCP stdio 协议 `2025-11-25`、Git。 + +**规格:** `docs/superpowers/specs/2026-08-19-codegraph-freshness-hardening-design.md` + +## 全局约束 + +- 只修改 Polaris 仓库;`/Users/zero/Documents/work/ai/codegraph` 必须始终保持无修改。 +- Polaris 协议/包版本从 `0.1.21` 精确升级到 `0.1.22`。 +- Workflow 版本保持 `0.1.3`,不得改变节点、边、状态或 transition gate。 +- Runtime 除 Python 标准库外不得增加依赖。 +- pending changes 只允许触发一次 `codegraph sync`;任何路径都不得执行 `codegraph index`。 +- `polaris_codegraph_explore` 不再接受 `sync_if_needed`,调用方不能跳过自动增量同步。 +- 查询前状态无法验证但仓库身份安全时仍执行一次 explore,并返回 `UNKNOWN`。 +- 仓库/worktree 身份不匹配或路径不安全时不执行 explore,也不交付图内容。 +- 只有完整干净的有界窗口才能产生 `CURRENT_AT_CHECK`。 +- `STALE` 和 `UNKNOWN` 都是 `NAVIGATION_ONLY`;`UNKNOWN` 必须明确写出 `TREAT_AS_STALE`。 +- 新 runtime evidence 使用 bundle v2;升级中断时仍可读取 bundle v1。 +- 新耐久 Code Intelligence record 继续使用 v3;已提交的 v1、v2、v3 record 不得重写。 +- Validation 继续完全不调用 CodeGraph;没有安装 CodeGraph 时完整测试套件必须通过。 + +--- + +## 文件职责 + +- `scripts/internal/codegraph_adapter.py`:低层 status/sync/explore 调用、状态标准化、CodeGraph 响应框架分类。 +- `scripts/internal/code_intelligence_proxy.py`:Polaris 阶段约束、自动增量同步、查询窗口、bundle v2、状态合并和 envelope。 +- `scripts/code_intelligence_mcp.py`:只处理 MCP 生命周期、输入 schema 与 envelope-first 返回顺序。 +- `scripts/internal/code_intelligence_protocol.py`:读取 bundle v1/v2、校验自动刷新策略、投影现有 record v3。 +- `skills/code-intelligence/SKILL.md`:统一阶段行为;明确 UNKNOWN 继续查询但必须按过期处理。 +- `skills/architecture-planning/SKILL.md`、`skills/implementation/SKILL.md`、`skills/documentation-sync/SKILL.md`、`skills/adversarial-review/SKILL.md`:各阶段只声明查询目的,不再声明同步策略。 +- `templates/AGENTS.md`:vendored 项目共享行为边界。 +- `README.md`、`README.zh-CN.md`、`docs/USAGE.md`、`plan.md`:用户与产品 Authority。 +- `VERSION`、`pyproject.toml`、`templates/project.json`、`templates/task-sources/state.json`、`templates/task/state.json`:协议版本单一事实的各生成/模板表面。 +- `workflow/migrations.json`、`scripts/internal/migration_protocol.py`:显式相邻 `0.1.21 → 0.1.22` 版本迁移,不改变 Workflow。 +- `tests/test_codegraph.py`:适配器、代理、MCP、bundle、record、Skill 和可选真实 CLI 覆盖。 +- `tests/test_core.py`:版本、vendoring、迁移和宿主配置覆盖。 + +--- + +### Task 1:让响应分类器适配当前 CodeGraph,同时忽略源码正文中的警告词 + +**Files:** +- Modify: `scripts/internal/codegraph_adapter.py:24-229` +- Test: `tests/test_codegraph.py:3530-3680` + +**Interfaces:** +- Consumes: `classify_response(repo: Path, response: str, *, checked_at: str | None = None) -> dict[str, Any]`。 +- Produces: 相同签名;返回的 `classification` 仍只允许 `NONE`、`PARTIAL_STALE`、`INDEX_STALE`、`NOT_VERIFIED`,不改变下游类型。 + +- [ ] **Step 1:为当前 CodeGraph 的 framing 写失败测试** + +在 `CodeGraphTests` 中加入: + +```python +def test_current_codegraph_freshness_framing_is_classified(self) -> None: + samples = { + "pending": ( + "⚠️ Some files referenced below were edited since the last index sync — " + "their codegraph entries may be stale:\n" + " - src/a.py (edited 12ms ago, pending sync)\n" + "For accurate content of those specific files, Read them directly. " + "The rest of this response is fresh.\n", + "PARTIAL_STALE", + ), + "indexing": ( + "⚠️ Some files referenced below were edited since the last index sync — " + "their codegraph entries may be stale:\n" + " - src/a.py (edited 12ms ago, indexing in progress)\n" + "For accurate content of those specific files, Read them directly. " + "The rest of this response is fresh.\n", + "PARTIAL_STALE", + ), + "disabled": ( + "⚠️ CodeGraph auto-sync is DISABLED — live file watching stopped, so the " + "index is frozen and any file edited since then is stale here.\n", + "INDEX_STALE", + ), + "drift": ( + "**`src/a.py`** — A(function) · ⚠ changed since last index sync — " + "source below is current; the symbol list may be outdated\n", + "PARTIAL_STALE", + ), + "worktree": ( + "⚠ CodeGraph results below come from a different git worktree " + "(/tmp/main), not where you're working (/tmp/wt) — they may reflect " + "another branch.\n", + "INDEX_STALE", + ), + } + for name, (response, expected) in samples.items(): + with self.subTest(name=name): + self.assertEqual( + self.classify_response(response)["classification"], expected + ) +``` + +- [ ] **Step 2:运行 framing 测试并确认 RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_current_codegraph_freshness_framing_is_classified -v` + +Expected: pending/indexing/disabled/drift/worktree 中至少一个不等于期望状态。 + +- [ ] **Step 3:为源码正文误判写失败测试** + +```python +def test_warning_words_inside_verbatim_source_do_not_change_freshness(self) -> None: + response = ( + "**`src/a.py`** — A(function)\n\n" + "```python\n" + "def A():\n" + " warning = 'stale pending sync out-of-date ⚠'\n" + " return warning\n" + "```\n" + ) + self.assertEqual( + self.classify_response(response)["classification"], "NONE" + ) +``` + +- [ ] **Step 4:运行源码正文测试并确认 RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_warning_words_inside_verbatim_source_do_not_change_freshness -v` + +Expected: 当前全文 `_SUSPICIOUS_FRESHNESS_SIGNAL` 扫描返回 `NOT_VERIFIED`。 + +- [ ] **Step 5:实现 framing-aware 分类** + +把旧版固定文案常量替换为当前 CodeGraph 兼容规则,并增加只返回代码围栏之外文本的帮助函数: + +```python +_PARTIAL_BANNER_HEADER = ( + "⚠️ Some files referenced below were edited since the last index sync — " + "their codegraph entries may be stale:\n" +) +_PARTIAL_BANNER_ROW = re.compile( + r"^ - (?P.+) \(edited [^\n()]+, " + r"(?:pending sync|indexing in progress)\)$" +) +_DISABLED_BANNER_PREFIX = "⚠️ CodeGraph auto-sync is DISABLED —" +_WORKTREE_BANNER_PREFIX = "⚠ CodeGraph results below come from a different git worktree" +_DRIFTED_FILE_HEADER = re.compile( + r"^\*\*`(?P[^`]+)`\*\* — .*⚠ changed (?:since last index sync|on disk after the last index sync)" +) + +def _framing_lines(response: str) -> list[str]: + lines: list[str] = [] + inside_fence = False + for line in response.splitlines(): + if line.startswith("```"): + inside_fence = not inside_fence + continue + if not inside_fence: + lines.append(line) + return lines +``` + +处理顺序固定为:worktree/disabled 顶部提示 → partial 顶部列表 → drifted file header → 已识别尾部提示 → framing 位置的未知 warning-like 文本降级 `NOT_VERIFIED` → `NONE`。文件路径继续通过 `_response_file_point` 做仓库边界和 SHA-256 校验;无法安全拆分的项目级尾部提示生成 `INDEX_STALE + SEARCH_SOURCE`,不猜测文件名。 + +- [ ] **Step 6:运行适配器分类测试** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_current_codegraph_freshness_framing_is_classified tests.test_codegraph.CodeGraphTests.test_warning_words_inside_verbatim_source_do_not_change_freshness tests.test_codegraph.CodeGraphTests.test_response_banner_marks_only_named_files_stale tests.test_codegraph.CodeGraphTests.test_response_banner_rejects_unsafe_and_symlink_paths tests.test_codegraph.CodeGraphTests.test_response_banner_rejects_unsafe_windows_style_paths -v` + +Expected: all PASS。 + +- [ ] **Step 7:提交 Task 1** + +```bash +git add scripts/internal/codegraph_adapter.py tests/test_codegraph.py +git commit -m "fix: classify current CodeGraph freshness framing" +``` + +--- + +### Task 2:把自动增量同步、UNKNOWN 查询和 bundle v2 固化到代理契约 + +**Files:** +- Modify: `scripts/internal/code_intelligence_proxy.py:200-622` +- Modify: `scripts/code_intelligence_mcp.py:24-190` +- Modify: `scripts/internal/code_intelligence_protocol.py:1200-1360` +- Test: `tests/test_codegraph.py:320-1565` + +**Interfaces:** +- Consumes: Task 1 的 `classify_response`,以及现有 `inspect_status`、`synchronize_observed_status`、`run_explore`。 +- Produces: `execute_proxy_query(repo, task_id, stage, query_id, purpose, query, *, runner=subprocess.run) -> dict[str, Any]`,不再接受同步布尔值。 +- Produces: bundle v2 顶层 `refresh_policy`,值固定为 `AUTO_INCREMENTAL_ON_PENDING`。 +- Produces: MCP `polaris_codegraph_explore` 输入只包含 task/stage/query ID/purpose/query。 + +- [ ] **Step 1:写自动同步且无法绕过的失败测试** + +```python +def test_proxy_automatically_syncs_pending_without_a_caller_switch(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["modified"] = 1 + responses = [ + completed(json.dumps(pending)), + completed("synced\n"), + completed(healthy_status(self.repo)), + completed("graph bytes\n"), + completed(healthy_status(self.repo)), + ] + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return responses.pop(0) + + proxy = self.proxy_module() + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = proxy.execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], [ + "status", "sync", "status", "explore", "status" + ]) + self.assertEqual(result["bundle"]["bundle_version"], 2) + self.assertEqual(result["bundle"]["refresh_policy"], { + "mode": "AUTO_INCREMENTAL_ON_PENDING", + "max_sync_attempts": 1, + "full_rebuild": "USER_ONLY", + }) +``` + +- [ ] **Step 2:写 pre-status 无法验证但仍查询的失败测试** + +```python +def test_proxy_queries_unknown_pre_status_and_treats_result_as_stale(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + responses = [ + completed("not-json\n"), + completed("graph bytes\n"), + completed(healthy_status(self.repo)), + ] + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return responses.pop(0) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], ["status", "explore", "status"]) + self.assertEqual(result["response"], "graph bytes\n") + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertIn("freshness: TREAT_AS_STALE", result["envelope"]) +``` + +- [ ] **Step 3:写身份不匹配时禁止查询的失败测试** + +```python +def test_proxy_does_not_query_a_different_project_index(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + wrong = json.loads(healthy_status(self.repo)) + wrong["projectPath"] = str(self.repo / "other-checkout") + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return completed(json.dumps(wrong)) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], ["status"]) + self.assertIsNone(result["response"]) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual(result["bundle"]["delivery"]["reason"], "PROJECT_MISMATCH") +``` + +- [ ] **Step 4:运行三项代理测试并确认 RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_proxy_automatically_syncs_pending_without_a_caller_switch tests.test_codegraph.CodeGraphTests.test_proxy_queries_unknown_pre_status_and_treats_result_as_stale tests.test_codegraph.CodeGraphTests.test_proxy_does_not_query_a_different_project_index -v` + +Expected: 旧签名、旧 early-return 或 bundle v1 至少导致一项失败。 + +- [ ] **Step 5:实现固定自动刷新策略和新代理签名** + +在 `code_intelligence_proxy.py` 增加唯一策略常量: + +```python +REFRESH_POLICY = { + "mode": "AUTO_INCREMENTAL_ON_PENDING", + "max_sync_attempts": 1, + "full_rebuild": "USER_ONLY", +} + +def execute_proxy_query( + repo: Path, + task_id: str, + stage: str, + query_id: str, + purpose: str, + query: str, + *, + runner: Any = subprocess.run, +) -> dict[str, Any]: + """Execute one automatically refreshed, immutable CodeGraph query window.""" +``` + +以上代码块只替换现有函数声明和 docstring;函数主体按本步骤后续规则原位修改,不新建第二个入口。删除对 `sync_if_needed` 的输入校验。`_bundle_base` 写 `bundle_version: 2` 和 `refresh_policy: dict(REFRESH_POLICY)`。只要 `pre_status.get("needs_sync")` 为真,就无条件调用一次 `synchronize_observed_status`。 + +把旧的 `effective_pre["status"] == "NOT_VERIFIED"` early return 拆成两个分支,并使用一个精确判断函数: + +```python +def _pre_status_blocks_query(observation: dict[str, Any]) -> bool: + if any( + point.get("reason") == "WORKTREE_MISMATCH" + for point in observation.get("stale_points", []) + ): + return True + error = str(observation.get("error") or "").lower() + return any(token in error for token in ( + "different project", + "unsafe project marker", + "repository root", + "symlink", + )) +``` + +`_pre_status_blocks_query(effective_pre)` 为真时不查询;普通 timeout/JSON/执行验证失败继续调用 `run_explore`。查询成功后仍执行 post-status,最终 `_delivery` 保留 pre-status 的 UNKNOWN 原因。 + +- [ ] **Step 6:更新 envelope 的显式处理语义** + +在 `render_freshness_envelope` 中加入: + +```python +freshness = ( + "VERIFIED_AT_CHECK" + if delivery["state"] == "CURRENT" + else "NO_GRAPH" + if delivery["state"] == "UNAVAILABLE" + else "TREAT_AS_STALE" +) +lines.insert(3, f"freshness: {freshness}") +``` + +`STALE` 与 `UNKNOWN` 都必须输出 `TREAT_AS_STALE`;不得只依赖 `usage: NAVIGATION_ONLY` 暗示。 + +- [ ] **Step 7:为 MCP 公开 schema 写失败测试** + +把 MCP schema 测试改为: + +```python +tool = server.handle({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) +schema = tool["result"]["tools"][0]["inputSchema"] +self.assertNotIn("sync_if_needed", schema["properties"]) +self.assertNotIn("sync_if_needed", schema["required"]) +self.assertEqual(set(schema["required"]), { + "task_id", "stage", "query_id", "purpose", "query", +}) +``` + +- [ ] **Step 8:运行 MCP schema 测试并确认 RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_mcp_server_initializes_and_lists_one_proxy_tool -v` + +Expected: 当前 schema 仍包含 `sync_if_needed`,测试失败。 + +- [ ] **Step 9:删除 MCP 公开同步开关** + +从 `TOOL`、`_validate_arguments` 和 `_call_tool` 删除 `sync_if_needed`。调用精确改为: + +```python +proxy = execute_proxy_query( + self.repo, + arguments["task_id"], + arguments["stage"], + arguments["query_id"], + arguments["purpose"], + arguments["query"], +) +``` + +- [ ] **Step 10:为 bundle v1/v2 兼容性写失败测试** + +给现有 `record_current_v3_fixture` 增加测试专用参数: + +```python +def record_current_v3_fixture( + self, + *, + legacy_bundle: bool = False, + invalid_refresh_policy: bool = False, +) -> tuple[dict[str, object], dict[str, object]]: +``` + +在该 helper 已生成 `query`、尚未调用 `record_proxy_bundle` 的位置加入: + +```python +bundle_path = query["bundle_path"] +bundle = json.loads(bundle_path.read_text(encoding="utf-8")) +if legacy_bundle: + bundle["bundle_version"] = 1 + bundle.pop("refresh_policy") +elif invalid_refresh_policy: + bundle["refresh_policy"]["max_sync_attempts"] = 2 +if legacy_bundle or invalid_refresh_policy: + write_json_atomic(bundle_path, bundle) +``` + +然后添加: + +```python +def test_bundle_v1_remains_projectable_but_v2_policy_is_fixed(self) -> None: + recorded, _query = self.record_current_v3_fixture(legacy_bundle=True) + self.assertEqual(recorded["record_version"], 3) + + self.tearDown() + self.setUp() + with self.assertRaisesRegex(RuleFailure, "refresh policy"): + self.record_current_v3_fixture(invalid_refresh_policy=True) +``` + +- [ ] **Step 11:运行 bundle 兼容测试并确认 RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_bundle_v1_remains_projectable_but_v2_policy_is_fixed -v` + +Expected: bundle v2 尚未被支持,或错误策略尚未被拒绝。 + +- [ ] **Step 12:让 bundle v1/v2 都可投影,但新查询只写 v2** + +在 `record_proxy_bundle` 中按版本选择精确键集合: + +```python +version = bundle.get("bundle_version") +base_keys = { + "bundle_version", "proxy", "provider", "repository", "task_context", + "query", "pre_status", "sync", "post_sync_status", + "response_classification", "post_query_status", "delivery", + "response_path", +} +if version == 1: + _require_exact_keys(bundle, base_keys, "CodeGraph proxy bundle") +elif version == 2: + _require_exact_keys( + bundle, {*base_keys, "refresh_policy"}, "CodeGraph proxy bundle" + ) + if bundle["refresh_policy"] != REFRESH_POLICY: + raise RuleFailure("CodeGraph proxy bundle has an invalid refresh policy") +else: + raise RuleFailure("CodeGraph proxy bundle has an unsupported identity") +``` + +在 `record_proxy_bundle` 函数内与现有 `resolve_stage_context` 延迟导入放在同一位置,从 `code_intelligence_proxy` 导入同一个 `REFRESH_POLICY`,不要复制第二份策略常量。record v3 输出结构保持不变;bundle digest 继续绑定输入 bundle 的精确字节。 + +- [ ] **Step 13:机械更新所有代理调用,并证明公开表面无旧参数** + +把测试中的旧调用: + +```python +query = proxy.execute_proxy_query( + self.repo, + "TASK-0001", + "PLANNING", + "CIQ-001", + "locate A", + "symbol A", + False, + runner=runner, +) +``` + +统一移除第七个位置布尔参数,改为: + +```python +query = proxy.execute_proxy_query( + self.repo, + "TASK-0001", + "PLANNING", + "CIQ-001", + "locate A", + "symbol A", + runner=runner, +) +``` + +Run: `rg -n "sync_if_needed" scripts/code_intelligence_mcp.py scripts/internal/code_intelligence_proxy.py` + +Expected: no matches。`scripts/internal/codegraph_adapter.py` 与 `scripts/code_intelligence_runtime.py` 的低层显式 sync helper 保留,不属于 MCP 查询绕过开关。 + +- [ ] **Step 14:运行代理、MCP 与 record 聚焦测试** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_proxy_automatically_syncs_pending_without_a_caller_switch tests.test_codegraph.CodeGraphTests.test_proxy_queries_unknown_pre_status_and_treats_result_as_stale tests.test_codegraph.CodeGraphTests.test_proxy_does_not_query_a_different_project_index tests.test_codegraph.CodeGraphTests.test_proxy_window_requires_clean_pre_and_post_status_for_current tests.test_codegraph.CodeGraphTests.test_proxy_window_never_promotes_failed_or_post_stale_queries tests.test_codegraph.CodeGraphTests.test_mcp_server_initializes_and_lists_one_proxy_tool tests.test_codegraph.CodeGraphTests.test_mcp_server_returns_envelope_before_graph_and_preserves_bundle tests.test_codegraph.CodeGraphTests.test_bundle_v1_remains_projectable_but_v2_policy_is_fixed tests.test_codegraph.CodeGraphTests.test_v3_record_projects_exact_proxy_bundle tests.test_codegraph.CodeGraphTests.test_failed_explore_proxy_bundle_projects_to_unknown_v3 tests.test_codegraph.CodeGraphTests.test_failed_sync_proxy_bundle_preserves_only_observed_post_status -v` + +Expected: all PASS;任一测试都不得观察到两次 sync、两次 explore 或 `codegraph index`。 + +- [ ] **Step 15:提交 Task 2** + +```bash +git add scripts/internal/code_intelligence_proxy.py scripts/code_intelligence_mcp.py scripts/internal/code_intelligence_protocol.py tests/test_codegraph.py +git commit -m "feat: enforce automatic CodeGraph freshness windows" +``` + +--- + +### Task 3:统一所有阶段 Skill 和用户文档的新鲜度行为 + +**Files:** +- Modify: `skills/code-intelligence/SKILL.md` +- Modify: `skills/architecture-planning/SKILL.md` +- Modify: `skills/implementation/SKILL.md` +- Modify: `skills/documentation-sync/SKILL.md` +- Modify: `skills/adversarial-review/SKILL.md` +- Modify: `templates/AGENTS.md` +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `docs/USAGE.md` +- Modify: `plan.md` +- Test: `tests/test_codegraph.py:130-240,2970-3070` + +**Interfaces:** +- Consumes: Task 2 的无同步参数 MCP schema 和四态 envelope。 +- Produces: 所有宿主渲染后相同的阶段行为;Planning、Implementation、Documentation Sync、Review 不再选择同步策略。 + +- [ ] **Step 1:先加行为锚点失败测试** + +```python +def test_all_agent_surfaces_require_automatic_freshness_policy(self) -> None: + paths = [ + ROOT / "skills/code-intelligence/SKILL.md", + ROOT / "skills/architecture-planning/SKILL.md", + ROOT / "skills/implementation/SKILL.md", + ROOT / "skills/documentation-sync/SKILL.md", + ROOT / "skills/adversarial-review/SKILL.md", + ROOT / "templates/AGENTS.md", + ] + required = ( + "automatically runs at most one incremental `codegraph sync`", + "never runs `codegraph index`", + "UNKNOWN", + "TREAT_AS_STALE", + "source/Git fallback", + ) + for path in paths: + text = path.read_text(encoding="utf-8") + self.assertNotIn("sync_if_needed", text, path.as_posix()) + for anchor in required: + self.assertIn(anchor, text, f"{path}: {anchor}") +``` + +更新 `test_documentation_sync_uses_one_proxy_query`:删除 `sync_if_needed: true` 锚点,改为断言 `changed source paths`、`documented symbols`、`automatic incremental sync` 和 `no separate status/sync MCP tool`。 + +- [ ] **Step 2:运行 Skill 行为测试并确认 RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_all_agent_surfaces_require_automatic_freshness_policy tests.test_codegraph.CodeGraphTests.test_documentation_sync_uses_one_proxy_query -v` + +Expected: 当前 Skill 仍包含 `sync_if_needed`,测试失败。 + +- [ ] **Step 3:更新 canonical Skills** + +`skills/code-intelligence/SKILL.md` 必须明确以下单一流程: + +```text +Call only polaris_codegraph_explore with task ID, stage, next CIQ-NNN, +purpose, and query. The proxy automatically runs at most one incremental +`codegraph sync` when pending changes exist and never runs `codegraph index`. +Read the freshness envelope before graph content. CURRENT_AT_CHECK is bounded +non-authoritative context. STALE and UNKNOWN/TREAT_AS_STALE are navigation-only +and require the exact source/Git fallback before any conclusion is used. +``` + +各阶段 Skill 只保留 task/stage/query 范围和查询次数约束: + +- Planning:冻结范围内关系发现; +- Implementation:修改前可查,修改后需要结论时必须重新调用; +- Documentation Sync:仅 supported source 发生变化时,对 changed paths/symbols 调用一次; +- Review:Reviewer 独立调用,不继承 Implementer envelope; +- Validation:继续禁止 Code Intelligence。 + +- [ ] **Step 4:更新共享模板和用户文档** + +在 `templates/AGENTS.md`、README、`docs/USAGE.md`、`plan.md` 中统一写明: + +```text +代理在查询前检查状态;存在 pending changes 时自动且至多执行一次增量 +`codegraph sync`;状态无法验证但仓库身份安全时仍查询并标记 UNKNOWN / +TREAT_AS_STALE;全量 `codegraph index` 始终由用户主动执行。 +``` + +删除所有“按调用参数决定是否同步”和 `sync_if_needed: true/false` 描述。保留 CodeGraph 的安装、初始化、watcher、daemon、raw MCP 与全量重建归用户所有的边界。 + +- [ ] **Step 5:运行渲染与行为测试** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_all_agent_surfaces_require_proxy_provenance tests.test_codegraph.CodeGraphTests.test_all_agent_surfaces_require_automatic_freshness_policy tests.test_codegraph.CodeGraphTests.test_documentation_sync_uses_one_proxy_query tests.test_codegraph.CodeGraphTests.test_validation_remains_graph_free tests.test_core.PolarisCoreTests.test_host_adapters_render_from_one_host_neutral_skill_source -v` + +Expected: all PASS;Validation 渲染中没有代理、status 或 sync 调用。 + +- [ ] **Step 6:提交 Task 3** + +```bash +git add skills templates/AGENTS.md README.md README.zh-CN.md docs/USAGE.md plan.md tests/test_codegraph.py +git commit -m "docs: define automatic CodeGraph freshness behavior" +``` + +--- + +### Task 4:升级协议到 0.1.22,并增加不重写证据的相邻迁移 + +**Files:** +- Modify: `VERSION` +- Modify: `pyproject.toml` +- Modify: `templates/project.json` +- Modify: `templates/task-sources/state.json` +- Modify: `templates/task/state.json` +- Modify: `workflow/migrations.json` +- Modify: `scripts/internal/migration_protocol.py:280-480` +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `docs/USAGE.md` +- Modify: `plan.md` +- Test: `tests/test_codegraph.py:160-205,1720-1990` +- Test: `tests/test_core.py:2570-2620` + +**Interfaces:** +- Consumes: 现有 migration protocol v2 的 `replace_version` / `append_version_event`。 +- Produces: 唯一相邻步骤 `0.1.21-to-0.1.22`,Workflow 前后均为 `0.1.3`。 + +- [ ] **Step 1:写 0.1.21 → 0.1.22 迁移失败测试** + +在 `tests/test_codegraph.py` 中使用现有 v3 fixture: + +```python +def test_0122_migration_preserves_v3_code_intelligence_records(self) -> None: + recorded, _query = self.record_current_v3_fixture() + actual_path = ( + self.repo + / ".polaris/tasks/TASK-0001/code-intelligence/r001/planning.json" + ) + self.assertEqual( + json.loads(actual_path.read_text(encoding="utf-8")), recorded + ) + before = actual_path.read_bytes() + self.set_protocol_version("0.1.21") + + with protocol_source_at("0.1.22") as source: + vendor(source, self.repo, False) + result = migrate_project(self.repo) + + self.assertEqual(result["from"], "0.1.21") + self.assertEqual(result["to"], "0.1.22") + self.assertEqual(actual_path.read_bytes(), before) + migration = json.loads(Path(result["record"]).read_text(encoding="utf-8")) + self.assertEqual(migration["retired_code_intelligence_records"], []) +``` + +- [ ] **Step 2:写 Workflow 不得变化的失败测试** + +```python +def test_0122_version_only_migration_rejects_workflow_change(self) -> None: + self.set_protocol_version("0.1.21") + with protocol_source_at("0.1.22") as source: + migrations_path = source / "workflow/migrations.json" + migrations = read_json(migrations_path) + migrations["steps"][-1]["to_workflow_version"] = "0.1.4" + write_json_atomic(migrations_path, migrations) + vendor(source, self.repo, False) + with self.assertRaisesRegex( + RuleFailure, "workflow migration requires replacement" + ): + migrate_project(self.repo) +``` + +- [ ] **Step 3:运行迁移测试并确认 RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_0122_migration_preserves_v3_code_intelligence_records tests.test_core.PolarisCoreTests.test_0122_version_only_migration_rejects_workflow_change -v` + +Expected: 缺少相邻迁移步骤,或 v3 record 被旧 retirement inventory 拒绝。 + +- [ ] **Step 4:追加迁移步骤并限定历史 inventory** + +在 `workflow/migrations.json` 末尾追加: + +```json +{ + "migration_id": "0.1.21-to-0.1.22", + "from_polaris_version": "0.1.21", + "to_polaris_version": "0.1.22", + "from_workflow_version": "0.1.3", + "to_workflow_version": "0.1.3", + "project_strategy": "replace_version", + "task_strategy": "append_version_event" +} +``` + +`_retired_code_intelligence_records` 只服务 `0.1.20-to-0.1.21` 的历史 retirement。`_new_record` 和中断恢复重算 inventory 时,仅对该 migration ID 调用它;`0.1.21-to-0.1.22` 的 `retired_code_intelligence_records` 固定为空列表。不得重新清点或重写 v3 record。 + +- [ ] **Step 5:升级所有当前版本 Authority** + +把以下当前版本值精确改为 `0.1.22`: + +```text +VERSION +pyproject.toml project.version +templates/project.json polaris_version +templates/task-sources/state.json polaris_version +templates/task/state.json polaris_version +README.md / README.zh-CN.md / docs/USAGE.md / plan.md 当前版本说明 +``` + +历史迁移说明中的 `0.1.20 → 0.1.21` 保留,并新增 `0.1.21 → 0.1.22` 行为说明。现有测试中对当前版本的断言更新为 `0.1.22`,历史 fixture 和旧迁移断言不得机械替换。 + +- [ ] **Step 6:运行版本与迁移测试** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_0122_migration_preserves_v3_code_intelligence_records tests.test_codegraph.CodeGraphTests.test_migration_inventories_frozen_v2_records_without_rewriting_them tests.test_codegraph.CodeGraphTests.test_migration_resume_rejects_mutated_frozen_v2_inventory tests.test_core.PolarisCoreTests.test_0122_version_only_migration_rejects_workflow_change tests.test_core.PolarisCoreTests.test_version_only_migration_rejects_a_workflow_version_change tests.test_core.PolarisCoreTests.test_migration_rejects_an_undeclared_version_jump -v` + +Expected: all PASS;旧 `0.1.20 → 0.1.21` inventory 行为保持不变,新迁移不清点 v3。 + +- [ ] **Step 7:重新生成 task layout 并验证无漂移** + +Run: `python3 scripts/materialize_task_layout.py` + +Run: `git diff --check` + +Expected: 只出现计划内的模板版本变化;生成树与 `task-sources` 保持一致。 + +- [ ] **Step 8:提交 Task 4** + +```bash +git add VERSION pyproject.toml templates workflow/migrations.json scripts/internal/migration_protocol.py README.md README.zh-CN.md docs/USAGE.md plan.md tests/test_codegraph.py tests/test_core.py +git commit -m "chore: advance Polaris protocol to 0.1.22" +``` + +--- + +### Task 5:端到端验收、跨平台检查和完成前验证 + +**Files:** +- Modify if a failing assertion exposes a gap: only files already named in Tasks 1-4 +- Test: `tests/test_codegraph.py` +- Test: `tests/test_core.py` + +**Interfaces:** +- Consumes: Tasks 1-4 的完整实现。 +- Produces: 可审查、可迁移、无需修改 CodeGraph 的 Polaris `0.1.22`。 + +- [ ] **Step 1:加固 fake-CLI 端到端断言** + +扩展现有 `test_vendored_mcp_proxy_runs_one_auditable_fake_cli_window`,让 fake CLI 记录 argv/cwd,并断言: + +```python +self.assertEqual( + [entry["argv"][0] for entry in calls], + ["status", "sync", "status", "explore", "status"], +) +self.assertTrue(all(Path(entry["cwd"]).resolve() == repo.resolve() for entry in calls)) +self.assertNotIn("index", [arg for entry in calls for arg in entry["argv"]]) +self.assertTrue(first_content.startswith("[POLARIS_CODEGRAPH_FRESHNESS]\n")) +self.assertIn("freshness: VERIFIED_AT_CHECK", first_content) +self.assertEqual(record_value["record_version"], 3) +``` + +fake status 的第一次响应必须包含一个 pending modified,sync 后与 query 后响应必须干净,从而真实覆盖自动增量同步,而不是手工传入同步开关。 + +- [ ] **Step 2:运行 CodeGraph 聚焦全套** + +Run: `python3 -m unittest tests.test_codegraph -v` + +Expected: all PASS;如果本机未安装 CodeGraph,仅真实 CLI smoke test 可以 SKIP。 + +- [ ] **Step 3:运行 Core 聚焦全套** + +Run: `python3 -m unittest tests.test_core -v` + +Expected: all PASS。 + +- [ ] **Step 4:运行仓库完整验证** + +Run: `python3 tests/run_tests.py` + +Run: `python3 -m compileall -q polaris_cli.py scripts tests` + +Run: `python3 scripts/materialize_task_layout.py` + +Run: `git diff --check` + +Expected: 完整测试 PASS;编译与格式检查退出 0;materialize 不产生未解释漂移。 + +- [ ] **Step 5:证明不存在查询绕过和全量重建路径** + +Run: `rg -n "sync_if_needed" skills templates/AGENTS.md scripts/code_intelligence_mcp.py scripts/internal/code_intelligence_proxy.py README.md README.zh-CN.md docs/USAGE.md plan.md` + +Expected: no matches。 + +Run: `rg -n "codegraph index" scripts skills templates README.md README.zh-CN.md docs/USAGE.md plan.md` + +Expected: 只出现“Polaris 不执行、由用户主动执行”的文档语句;`scripts/` 内不得出现可执行 command 组装。 + +Run: `git -C /Users/zero/Documents/work/ai/codegraph status --short` + +Expected: no output。 + +- [ ] **Step 6:对照规格建立最终覆盖表** + +| 规格要求 | 必须通过的测试 | +|---|---| +| 当前 CodeGraph framing | `test_current_codegraph_freshness_framing_is_classified` | +| 源码 warning 不误判 | `test_warning_words_inside_verbatim_source_do_not_change_freshness` | +| pending 自动同步一次 | `test_proxy_automatically_syncs_pending_without_a_caller_switch` | +| pre-status unknown 仍查询 | `test_proxy_queries_unknown_pre_status_and_treats_result_as_stale` | +| 身份不匹配不查询 | `test_proxy_does_not_query_a_different_project_index` | +| clean window 才 CURRENT | `test_proxy_window_requires_clean_pre_and_post_status_for_current` | +| envelope 永远在前 | `test_mcp_server_returns_envelope_before_graph_and_preserves_bundle` | +| bundle v2 / record v3 | `test_v3_record_projects_exact_proxy_bundle` | +| STALE/UNKNOWN 强制 fallback | `test_v3_record_rejects_mutated_window_identity_and_fallbacks` | +| 所有阶段无同步开关 | `test_all_agent_surfaces_require_automatic_freshness_policy` | +| Validation graph-free | `test_validation_remains_graph_free` | +| v3 record 迁移不重写 | `test_0122_migration_preserves_v3_code_intelligence_records` | +| Workflow 保持 0.1.3 | `test_0122_version_only_migration_rejects_workflow_change` | +| vendored fake CLI 全窗口 | `test_vendored_mcp_proxy_runs_one_auditable_fake_cli_window` | +| CodeGraph 仓库不修改 | Step 5 的独立 `git status` 检查 | + +- [ ] **Step 7:提交仅由验收暴露的修正** + +```bash +git add scripts skills templates workflow tests README.md README.zh-CN.md docs/USAGE.md plan.md VERSION pyproject.toml +git commit -m "test: verify CodeGraph freshness hardening end to end" +``` + +如果 Steps 2-6 没有产生文件变化,则跳过该提交。 + +- [ ] **Step 8:请求代码审查并进入分支收尾** + +完整验证通过后,依次使用 `superpowers:requesting-code-review` 和 `superpowers:finishing-a-development-branch`。审查必须特别核对:没有 CodeGraph 仓库改动、没有 `codegraph index` 执行路径、没有同步绕过参数、UNKNOWN 图仍可返回但必须按过期处理。 From 0ec75416dfe30f994c39b2a930a4c53b09084f16 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 17:21:39 +0800 Subject: [PATCH 19/28] fix: classify current CodeGraph freshness framing --- scripts/internal/codegraph_adapter.py | 171 +++++++++++++++++++------- tests/test_codegraph.py | 71 +++++++++++ 2 files changed, 198 insertions(+), 44 deletions(-) diff --git a/scripts/internal/codegraph_adapter.py b/scripts/internal/codegraph_adapter.py index 0d51a1d..11b34d3 100644 --- a/scripts/internal/codegraph_adapter.py +++ b/scripts/internal/codegraph_adapter.py @@ -33,16 +33,26 @@ } _PARTIAL_BANNER_HEADER = ( - "⚠️ Some files referenced below were edited since the last index sync —\n" + "⚠️ Some files referenced below were edited since the last index sync — " "their codegraph entries may be stale:\n" ) +_LEGACY_PARTIAL_BANNER_HEADER = _PARTIAL_BANNER_HEADER.replace(" — ", " —\n") _PARTIAL_BANNER_FOOTER = ( "For accurate content of those specific files, Read them directly." ) _PARTIAL_BANNER_ROW = re.compile( - r"^ - (?P.+) \(edited [^\n()]+, pending sync\)$" + r"^ - (?P.+) \(edited [^\n()]+, " + r"(?:pending sync|indexing in progress)\)$" ) -_DISABLED_BANNER = "⚠️ CodeGraph auto-sync is DISABLED — the index is frozen." +_DISABLED_BANNER_PREFIX = "⚠️ CodeGraph auto-sync is DISABLED —" +_WORKTREE_BANNER_PREFIX = ( + "⚠ CodeGraph results below come from a different git worktree" +) +_DRIFTED_FILE_HEADER = re.compile( + r"^\*\*`(?P[^`]+)`\*\* — .*⚠ changed " + r"(?:since last index sync|on disk after the last index sync)" +) +_DRIFTED_PROJECT_TAIL_PREFIX = "> ⚠ Changed on disk after the last index sync:" _SUSPICIOUS_FRESHNESS_SIGNAL = re.compile( r"(?:⚠|\bwarning\b|\bstale\b|\bpending(?:[- ]sync)?\b|\bout[- ]of[- ]date\b)", re.IGNORECASE, @@ -183,13 +193,31 @@ def _response_file_point(repo: Path, raw_path: str) -> dict[str, Any]: } +def _framing_lines(response: str) -> list[str]: + """Return response lines outside Markdown code fences.""" + lines: list[str] = [] + inside_fence = False + for line in response.splitlines(): + if line.startswith("```"): + inside_fence = not inside_fence + continue + if not inside_fence: + lines.append(line) + return lines + + +def _with_response_sha256(result: dict[str, Any], response_sha256: str) -> dict[str, Any]: + result["response_sha256"] = response_sha256 + return result + + def classify_response( repo: Path, response: str, *, checked_at: str | None = None, ) -> dict[str, Any]: - """Classify only documented banners beginning at response byte zero. + """Classify documented freshness framing outside source-code fences. Leading whitespace and a UTF-8 BOM are not accepted as an official banner. """ @@ -198,49 +226,104 @@ def classify_response( return _response_not_verified(checked_at, "CodeGraph response is not text") response_sha256 = hashlib.sha256(response.encode("utf-8")).hexdigest() normalized = response.replace("\r\n", "\n").replace("\r", "\n") - if normalized.startswith(_DISABLED_BANNER): - result = _response_result( - "INDEX_STALE", - checked_at, - stale_points=[_index_point("AUTO_SYNC_DISABLED")], + if normalized.startswith(_WORKTREE_BANNER_PREFIX): + return _with_response_sha256( + _response_result( + "INDEX_STALE", + checked_at, + stale_points=[_index_point("WORKTREE_MISMATCH")], + ), + response_sha256, + ) + if normalized.startswith(_DISABLED_BANNER_PREFIX): + return _with_response_sha256( + _response_result( + "INDEX_STALE", + checked_at, + stale_points=[_index_point("AUTO_SYNC_DISABLED")], + ), + response_sha256, ) - result["response_sha256"] = response_sha256 - return result - if not normalized.startswith(_PARTIAL_BANNER_HEADER): - if _SUSPICIOUS_FRESHNESS_SIGNAL.search(normalized): - result = _response_not_verified( - checked_at, "unrecognized CodeGraph freshness warning" + header = next( + ( + candidate + for candidate in (_PARTIAL_BANNER_HEADER, _LEGACY_PARTIAL_BANNER_HEADER) + if normalized.startswith(candidate) + ), + None, + ) + if header is not None: + listed = normalized[len(header) :] + footer_index = listed.find(_PARTIAL_BANNER_FOOTER) + if footer_index < 0: + return _with_response_sha256( + _response_not_verified(checked_at, "malformed CodeGraph stale banner"), + response_sha256, ) - result["response_sha256"] = response_sha256 - return result - result = _response_result("NONE", checked_at, stale_points=[]) - result["response_sha256"] = response_sha256 - return result - - listed = normalized[len(_PARTIAL_BANNER_HEADER) :] - footer_index = listed.find(_PARTIAL_BANNER_FOOTER) - if footer_index < 0: - result = _response_not_verified(checked_at, "malformed CodeGraph stale banner") - result["response_sha256"] = response_sha256 - return result - rows = listed[:footer_index].splitlines() - if not rows or any(_PARTIAL_BANNER_ROW.fullmatch(row) is None for row in rows): - result = _response_not_verified(checked_at, "malformed CodeGraph stale banner") - result["response_sha256"] = response_sha256 - return result - try: - stale_points = [ - _response_file_point(repo, _PARTIAL_BANNER_ROW.fullmatch(row)["path"]) - for row in rows - ] - except (InputFailure, RuleFailure, OSError, ValueError) as error: - result = _response_not_verified(checked_at, error) - result["response_sha256"] = response_sha256 - return result - result = _response_result("PARTIAL_STALE", checked_at, stale_points=stale_points) - result["response_sha256"] = response_sha256 - return result + rows = listed[:footer_index].splitlines() + matches = [_PARTIAL_BANNER_ROW.fullmatch(row) for row in rows] + if not rows or any(match is None for match in matches): + return _with_response_sha256( + _response_not_verified(checked_at, "malformed CodeGraph stale banner"), + response_sha256, + ) + try: + stale_points = [ + _response_file_point(repo, match["path"]) + for match in matches + if match is not None + ] + except (InputFailure, RuleFailure, OSError, ValueError) as error: + return _with_response_sha256( + _response_not_verified(checked_at, error), response_sha256 + ) + return _with_response_sha256( + _response_result("PARTIAL_STALE", checked_at, stale_points=stale_points), + response_sha256, + ) + + framing = _framing_lines(normalized) + drifted_headers = [ + match + for line in framing + if (match := _DRIFTED_FILE_HEADER.match(line)) is not None + ] + if drifted_headers: + try: + stale_points = [ + _response_file_point(repo, match["path"]) + for match in drifted_headers + ] + except (InputFailure, RuleFailure, OSError, ValueError) as error: + return _with_response_sha256( + _response_not_verified(checked_at, error), response_sha256 + ) + return _with_response_sha256( + _response_result("PARTIAL_STALE", checked_at, stale_points=stale_points), + response_sha256, + ) + + if any(line.startswith(_DRIFTED_PROJECT_TAIL_PREFIX) for line in framing): + return _with_response_sha256( + _response_result( + "INDEX_STALE", + checked_at, + stale_points=[_index_point("PENDING_CHANGES")], + ), + response_sha256, + ) + + if _SUSPICIOUS_FRESHNESS_SIGNAL.search("\n".join(framing)): + return _with_response_sha256( + _response_not_verified( + checked_at, "unrecognized CodeGraph freshness warning" + ), + response_sha256, + ) + return _with_response_sha256( + _response_result("NONE", checked_at, stale_points=[]), response_sha256 + ) def _unique_items(items: list[Any]) -> list[Any]: diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 0b76da9..3a3bec0 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -3526,6 +3526,77 @@ def runner(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[ self.assertEqual(result["sync"]["status"], "SKIPPED") self.assertEqual(calls, []) + def test_current_codegraph_freshness_framing_is_classified(self) -> None: + samples = { + "pending": ( + "⚠️ Some files referenced below were edited since the last index sync — " + "their codegraph entries may be stale:\n" + " - src/a.py (edited 12ms ago, pending sync)\n" + "For accurate content of those specific files, Read them directly. " + "The rest of this response is fresh.\n", + "PARTIAL_STALE", + ), + "indexing": ( + "⚠️ Some files referenced below were edited since the last index sync — " + "their codegraph entries may be stale:\n" + " - src/a.py (edited 12ms ago, indexing in progress)\n" + "For accurate content of those specific files, Read them directly. " + "The rest of this response is fresh.\n", + "PARTIAL_STALE", + ), + "disabled": ( + "⚠️ CodeGraph auto-sync is DISABLED — live file watching stopped, so the " + "index is frozen and any file edited since then is stale here.\n", + "INDEX_STALE", + ), + "drift": ( + "**`src/a.py`** — A(function) · ⚠ changed since last index sync — " + "source below is current; the symbol list may be outdated\n", + "PARTIAL_STALE", + ), + "worktree": ( + "⚠ CodeGraph results below come from a different git worktree " + "(/tmp/main), not where you're working (/tmp/wt) — they may reflect " + "another branch.\n", + "INDEX_STALE", + ), + } + for name, (response, expected) in samples.items(): + with self.subTest(name=name): + self.assertEqual( + self.classify_response(response)["classification"], expected + ) + + def test_warning_words_inside_verbatim_source_do_not_change_freshness(self) -> None: + response = ( + "**`src/a.py`** — A(function)\n\n" + "```python\n" + "def A():\n" + " warning = 'stale pending sync out-of-date ⚠'\n" + " return warning\n" + "```\n" + ) + + self.assertEqual(self.classify_response(response)["classification"], "NONE") + + def test_project_drift_tail_requires_source_search(self) -> None: + response = ( + "> ⚠ Changed on disk after the last index sync: src/a.py, src/b.py. " + "Line numbers referencing these files elsewhere in this response may be " + "shifted until that project's next sync re-indexes them.\n" + ) + + result = self.classify_response(response) + + self.assertEqual(result["classification"], "INDEX_STALE") + self.assertEqual(result["stale_points"], [{ + "scope": "INDEX", + "path": None, + "reason": "PENDING_CHANGES", + "fallback": "SEARCH_SOURCE", + "observed_sha256": None, + }]) + def test_response_banner_marks_only_named_files_stale(self) -> None: source = self.repo / "src/widget.py" source.parent.mkdir() From b6c1c7ab189458a43fa3b7a2d022de2d812cc65b Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 17:35:00 +0800 Subject: [PATCH 20/28] feat: enforce automatic CodeGraph freshness windows --- scripts/code_intelligence_mcp.py | 6 - .../internal/code_intelligence_protocol.py | 42 ++-- scripts/internal/code_intelligence_proxy.py | 113 +++++++---- tests/test_codegraph.py | 187 ++++++++++++++++-- tests/test_core.py | 1 - 5 files changed, 267 insertions(+), 82 deletions(-) diff --git a/scripts/code_intelligence_mcp.py b/scripts/code_intelligence_mcp.py index 919afb7..468182d 100644 --- a/scripts/code_intelligence_mcp.py +++ b/scripts/code_intelligence_mcp.py @@ -33,7 +33,6 @@ "query_id", "purpose", "query", - "sync_if_needed", ], "additionalProperties": False, "properties": { @@ -50,7 +49,6 @@ "query_id": {"type": "string", "pattern": r"^CIQ-[0-9]{3}$"}, "purpose": {"type": "string", "minLength": 1, "maxLength": 240}, "query": {"type": "string", "minLength": 1, "maxLength": 8000}, - "sync_if_needed": {"type": "boolean"}, }, }, } @@ -92,7 +90,6 @@ def _validate_arguments(value: Any) -> list[str]: "query_id", "purpose", "query", - "sync_if_needed", } errors: list[str] = [] missing = expected - set(value) @@ -118,8 +115,6 @@ def _validate_arguments(value: Any) -> list[str]: item = value.get(key) if not isinstance(item, str) or not item.strip() or len(item) > maximum: errors.append(f"{key} must contain 1 to {maximum} characters") - if not isinstance(value.get("sync_if_needed"), bool): - errors.append("sync_if_needed must be a boolean") return errors @@ -179,7 +174,6 @@ def _call_tool(self, request_id: Any, params: dict[str, Any]) -> dict[str, Any]: arguments["query_id"], arguments["purpose"], arguments["query"], - arguments["sync_if_needed"], ) content = [{"type": "text", "text": proxy["envelope"]}] if proxy["response"] is not None: diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index 93296a1..4750bfb 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -1217,26 +1217,26 @@ def record_proxy_bundle( require_regular_file(candidate, "CodeGraph proxy bundle") bundle_digest = file_sha256(candidate) bundle = read_json(candidate) - _require_exact_keys( - bundle, - { - "bundle_version", - "proxy", - "provider", - "repository", - "task_context", - "query", - "pre_status", - "sync", - "post_sync_status", - "response_classification", - "post_query_status", - "delivery", - "response_path", - }, - "CodeGraph proxy bundle", - ) - if bundle["bundle_version"] != 1 or bundle["proxy"] != { + from .code_intelligence_proxy import REFRESH_POLICY, resolve_stage_context + + version = bundle.get("bundle_version") + base_keys = { + "bundle_version", "proxy", "provider", "repository", "task_context", + "query", "pre_status", "sync", "post_sync_status", + "response_classification", "post_query_status", "delivery", + "response_path", + } + if version == 1: + _require_exact_keys(bundle, base_keys, "CodeGraph proxy bundle") + elif version == 2: + _require_exact_keys( + bundle, {*base_keys, "refresh_policy"}, "CodeGraph proxy bundle" + ) + if bundle["refresh_policy"] != REFRESH_POLICY: + raise RuleFailure("CodeGraph proxy bundle has an invalid refresh policy") + else: + raise RuleFailure("CodeGraph proxy bundle has an unsupported identity") + if bundle["proxy"] != { "server_id": "polaris-codegraph", "tool": "polaris_codegraph_explore", }: @@ -1259,8 +1259,6 @@ def record_proxy_bundle( ) if context["task_id"] != task_id: raise RuleFailure("CodeGraph proxy bundle targets the wrong task") - from .code_intelligence_proxy import resolve_stage_context - if context != resolve_stage_context(repo, task_id, context["stage"]): raise RuleFailure("CodeGraph proxy bundle stage context is no longer current") query = _require_exact_keys( diff --git a/scripts/internal/code_intelligence_proxy.py b/scripts/internal/code_intelligence_proxy.py index 24f2e45..e2c4bfa 100644 --- a/scripts/internal/code_intelligence_proxy.py +++ b/scripts/internal/code_intelligence_proxy.py @@ -49,6 +49,11 @@ "DOCUMENTATION_SYNC": {"IMPLEMENTING"}, "REVIEW": {"REVIEWING"}, } +REFRESH_POLICY = { + "mode": "AUTO_INCREMENTAL_ON_PENDING", + "max_sync_attempts": 1, + "full_rebuild": "USER_ONLY", +} _INDEX_FALLBACK = { "scope": "INDEX", "path": None, @@ -272,6 +277,21 @@ def _unsafe_response(classification: dict[str, Any]) -> bool: ) +def _pre_status_blocks_query(observation: dict[str, Any]) -> bool: + if any( + point.get("reason") == "WORKTREE_MISMATCH" + for point in observation.get("stale_points", []) + ): + return True + error = str(observation.get("error") or "").lower() + return any(token in error for token in ( + "different project", + "unsafe project marker", + "repository root", + "symlink", + )) + + def _delivery( effective_pre: dict[str, Any], query_result: dict[str, Any], @@ -292,14 +312,16 @@ def _delivery( *((classification or {}).get("stale_points", [])), *_observation_points(post_status), ]) - known_stale = any( + pre_status_blocks_query = _pre_status_blocks_query(effective_pre) + known_stale = not pre_status_blocks_query and (any( point.get("reason") != "STATUS_UNREADABLE" for point in points ) or effective_pre.get("status") in {"PARTIAL_STALE", "INDEX_STALE"} or ( post_status is not None and post_status.get("status") in {"PARTIAL_STALE", "INDEX_STALE"} - ) or (classification or {}).get("classification") in {"PARTIAL_STALE", "INDEX_STALE"} + ) or (classification or {}).get("classification") in {"PARTIAL_STALE", "INDEX_STALE"}) unknown = ( - forced_unknown is not None + pre_status_blocks_query + or forced_unknown is not None or query_result.get("status") != "SUCCESS" or _is_unknown(effective_pre) or post_status is None @@ -336,7 +358,16 @@ def _delivery( elif unknown: state = "UNKNOWN" record_status = "NOT_VERIFIED" - if forced_unknown: + if pre_status_blocks_query: + reason = ( + "WORKTREE_MISMATCH" + if any( + point.get("reason") == "WORKTREE_MISMATCH" + for point in effective_pre.get("stale_points", []) + ) + else "PROJECT_MISMATCH" + ) + elif forced_unknown: reason = "RESPONSE_INTEGRITY_UNVERIFIED" elif _is_unknown(effective_pre): reason = ( @@ -381,7 +412,8 @@ def _bundle_base( ) -> dict[str, Any]: project = read_json(repo / ".polaris/project.json") return { - "bundle_version": 1, + "bundle_version": 2, + "refresh_policy": dict(REFRESH_POLICY), "proxy": { "server_id": "polaris-codegraph", "tool": "polaris_codegraph_explore", @@ -428,17 +460,14 @@ def execute_proxy_query( query_id: str, purpose: str, query: str, - sync_if_needed: bool, *, runner: Any = subprocess.run, ) -> dict[str, Any]: - """Execute one immutable CodeGraph query window and persist its evidence.""" + """Execute one automatically refreshed, immutable CodeGraph query window.""" if not isinstance(purpose, str) or not purpose.strip() or len(purpose) > 240: raise InputFailure("CodeGraph query purpose must contain 1 to 240 characters") if not isinstance(query, str) or not query.strip() or len(query) > 8000: raise InputFailure("CodeGraph query must contain 1 to 8000 characters") - if not isinstance(sync_if_needed, bool): - raise InputFailure("sync_if_needed must be a boolean") repo = repo.absolute() if repo.is_symlink() or not repo.is_dir(): raise RuleFailure("CodeGraph proxy repository root must be a fixed real directory") @@ -489,7 +518,7 @@ def execute_proxy_query( pre_status = inspect_status(repo, descriptor, runner=runner) bundle["pre_status"] = pre_status effective_pre = pre_status - if sync_if_needed and pre_status.get("needs_sync"): + if pre_status.get("needs_sync"): synchronized = synchronize_observed_status( repo, descriptor, pre_status, runner=runner ) @@ -497,30 +526,40 @@ def execute_proxy_query( effective_pre = synchronized["freshness"] bundle["post_sync_status"] = synchronized["post_sync_status"] - if effective_pre["status"] in {"UNAVAILABLE", "NOT_VERIFIED"}: - bundle["query"]["status"] = ( - "UNAVAILABLE" if effective_pre["status"] == "UNAVAILABLE" else "FAILED" - ) + if effective_pre["status"] == "UNAVAILABLE": + bundle["query"]["status"] = "UNAVAILABLE" bundle["query"]["error"] = effective_pre.get("error") - if effective_pre["status"] == "UNAVAILABLE": - bundle["delivery"] = { - "state": "UNAVAILABLE", - "record_status": "UNAVAILABLE", - "reason": "PROVIDER_UNAVAILABLE", - "checked_at": effective_pre["checked_at"], - "usage": "NO_GRAPH", - "required_fallback": "SEARCH_SOURCE", - "stale_points": [], - "pending_changes": {"added": 0, "modified": 0, "removed": 0}, - "error": effective_pre.get("error"), - } - else: - bundle["delivery"] = _delivery( - effective_pre, - bundle["query"], - None, - None, - ) + bundle["delivery"] = { + "state": "UNAVAILABLE", + "record_status": "UNAVAILABLE", + "reason": "PROVIDER_UNAVAILABLE", + "checked_at": effective_pre["checked_at"], + "usage": "NO_GRAPH", + "required_fallback": "SEARCH_SOURCE", + "stale_points": [], + "pending_changes": {"added": 0, "modified": 0, "removed": 0}, + "error": effective_pre.get("error"), + } + _write_bundle(bundle_path, bundle) + return { + "bundle": bundle, + "bundle_path": bundle_path, + "response": None, + "envelope": render_freshness_envelope(bundle), + } + + if _pre_status_blocks_query(effective_pre): + blocked_error = effective_pre.get("error") or ( + "CodeGraph status reports a worktree mismatch" + ) + bundle["query"]["status"] = "FAILED" + bundle["query"]["error"] = blocked_error + bundle["delivery"] = _delivery( + effective_pre, + bundle["query"], + None, + None, + ) _write_bundle(bundle_path, bundle) return { "bundle": bundle, @@ -595,6 +634,13 @@ def render_freshness_envelope(bundle: dict[str, Any]) -> str: delivery = bundle["delivery"] pending = delivery.get("pending_changes") or {} error = " ".join(str(delivery.get("error") or "").split())[:240] + freshness = ( + "VERIFIED_AT_CHECK" + if delivery["state"] == "CURRENT" + else "NO_GRAPH" + if delivery["state"] == "UNAVAILABLE" + else "TREAT_AS_STALE" + ) bundle_path = task_relative_path( "code_intelligence_proxy_bundle", record_name=bundle["task_context"]["record_name"], @@ -613,6 +659,7 @@ def render_freshness_envelope(bundle: dict[str, Any]) -> str: f"required_fallback: {delivery['required_fallback']}", f"evidence_bundle: {bundle_path}", ] + lines.insert(3, f"freshness: {freshness}") if error: lines.append(f"error: {error}") lines.append("[/POLARIS_CODEGRAPH_FRESHNESS]") diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 3a3bec0..e3253e8 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -333,7 +333,12 @@ def qualify_task(self) -> None: def proxy_module(self) -> object: return importlib.import_module("internal.code_intelligence_proxy") - def record_current_v3_fixture(self) -> tuple[dict[str, object], dict[str, object]]: + def record_current_v3_fixture( + self, + *, + legacy_bundle: bool = False, + invalid_refresh_policy: bool = False, + ) -> tuple[dict[str, object], dict[str, object]]: self.qualify_task() (self.repo / ".codegraph").mkdir() source = self.repo / "src/a.py" @@ -357,9 +362,17 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess "CIQ-001", "locate A", "symbol A", - False, runner=runner, ) + bundle_path = query["bundle_path"] + bundle = json.loads(bundle_path.read_text(encoding="utf-8")) + if legacy_bundle: + bundle["bundle_version"] = 1 + bundle.pop("refresh_policy") + elif invalid_refresh_policy: + bundle["refresh_policy"]["max_sync_attempts"] = 2 + if legacy_bundle or invalid_refresh_policy: + write_json_atomic(bundle_path, bundle) protocol = importlib.import_module("internal.code_intelligence_protocol") result = protocol.record_proxy_bundle( self.repo, @@ -487,7 +500,6 @@ def runner(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[ "CIQ-001", "locate affected symbols", "symbol A", - False, runner=runner, ) @@ -506,10 +518,130 @@ def runner(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[ bundle["query"]["response_sha256"], ) + def test_proxy_automatically_syncs_pending_without_a_caller_switch(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["modified"] = 1 + responses = [ + completed(json.dumps(pending)), + completed("synced\n"), + completed(healthy_status(self.repo)), + completed("graph bytes\n"), + completed(healthy_status(self.repo)), + ] + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return responses.pop(0) + + proxy = self.proxy_module() + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = proxy.execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], [ + "status", "sync", "status", "explore", "status" + ]) + self.assertEqual(result["bundle"]["bundle_version"], 2) + self.assertEqual(result["bundle"]["refresh_policy"], { + "mode": "AUTO_INCREMENTAL_ON_PENDING", + "max_sync_attempts": 1, + "full_rebuild": "USER_ONLY", + }) + + def test_proxy_queries_unknown_pre_status_and_treats_result_as_stale(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + responses = [ + completed("not-json\n"), + completed("graph bytes\n"), + completed(healthy_status(self.repo)), + ] + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return responses.pop(0) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], ["status", "explore", "status"]) + self.assertEqual(result["response"], "graph bytes\n") + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertIn("freshness: TREAT_AS_STALE", result["envelope"]) + + def test_proxy_does_not_query_a_different_project_index(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + wrong = json.loads(healthy_status(self.repo)) + wrong["projectPath"] = str(self.repo / "other-checkout") + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return completed(json.dumps(wrong)) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], ["status"]) + self.assertIsNone(result["response"]) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual(result["bundle"]["delivery"]["reason"], "PROJECT_MISMATCH") + + def test_proxy_worktree_mismatch_is_unknown_with_finite_error_evidence(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + wrong = json.loads(healthy_status(self.repo)) + wrong["worktreeMismatch"] = {"reason": "different checkout"} + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return completed(json.dumps(wrong)) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], ["status"]) + self.assertIsNone(result["response"]) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual( + result["bundle"]["delivery"]["reason"], "WORKTREE_MISMATCH" + ) + self.assertIsNotNone(result["bundle"]["query"]["error"]) + self.assertIsNotNone(result["bundle"]["delivery"]["error"]) + def test_proxy_window_downgrades_pending_unknown_and_unavailable_states(self) -> None: cases = [ - ("pending", "STALE", 3), - ("malformed", "UNKNOWN", 1), + ("pending", "STALE", 5), + ("malformed", "UNKNOWN", 3), ("missing_marker", "UNAVAILABLE", 0), ] for index, (case, expected_state, expected_calls) in enumerate(cases, start=1): @@ -525,12 +657,18 @@ def test_proxy_window_downgrades_pending_unknown_and_unavailable_states(self) -> if case == "pending": status["pendingChanges"]["modified"] = 1 responses = [ + completed(json.dumps(status)), + completed("synced\n"), completed(json.dumps(status)), completed("graph bytes\n"), completed(json.dumps(status)), ] else: - responses = [completed("not-json\n")] + responses = [ + completed("not-json\n"), + completed("graph bytes\n"), + completed(healthy_status(self.repo)), + ] if case != "missing_marker": (self.repo / ".codegraph").mkdir() @@ -546,7 +684,6 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess query_id, "inspect freshness", "symbol A", - False, runner=runner, ) self.assertEqual(result["bundle"]["delivery"]["state"], expected_state) @@ -581,7 +718,6 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess "CIQ-001", "refresh one query window", "symbol A", - True, runner=runner, ) @@ -634,7 +770,6 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess "CIQ-001", "verify failure handling", "symbol A", - False, runner=runner, ) self.assertEqual(result["bundle"]["delivery"]["state"], expected_state) @@ -685,7 +820,6 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess "CIQ-001", "classify response", "symbol A", - False, runner=runner, ) self.assertEqual(result["bundle"]["delivery"]["state"], expected_state) @@ -714,7 +848,6 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess "CIQ-001", "reject cross-project status", "symbol A", - False, runner=runner, ) self.assertEqual([command[1] for command in calls], ["status"]) @@ -765,7 +898,6 @@ def runner(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str "CIQ-001", "verify activation gate", "symbol A", - True, runner=runner, ) self.assertEqual(result["bundle"]["delivery"]["state"], "UNAVAILABLE") @@ -837,7 +969,13 @@ def test_mcp_server_initializes_and_lists_one_proxy_tool(self) -> None: ) tools = responses[1]["result"]["tools"] self.assertEqual([item["name"] for item in tools], ["polaris_codegraph_explore"]) - self.assertNotIn("repository", tools[0]["inputSchema"]["properties"]) + schema = tools[0]["inputSchema"] + self.assertNotIn("repository", schema["properties"]) + self.assertNotIn("sync_if_needed", schema["properties"]) + self.assertNotIn("sync_if_needed", schema["required"]) + self.assertEqual(set(schema["required"]), { + "task_id", "stage", "query_id", "purpose", "query", + }) self.assertEqual(completed_process.stderr, "") def test_mcp_server_returns_envelope_before_graph_and_preserves_bundle(self) -> None: @@ -879,7 +1017,6 @@ def test_mcp_server_returns_envelope_before_graph_and_preserves_bundle(self) -> "query_id": "CIQ-001", "purpose": "locate symbols", "query": "symbol A", - "sync_if_needed": False, }, }, } @@ -939,7 +1076,6 @@ def test_mcp_server_rejects_lifecycle_tool_and_input_errors(self) -> None: "query_id": "CIQ-000", "purpose": "locate symbols", "query": "symbol A", - "sync_if_needed": False, }, }, }) @@ -1142,7 +1278,6 @@ def test_vendored_mcp_proxy_runs_one_auditable_fake_cli_window(self) -> None: "query_id": "CIQ-001", "purpose": "locate A", "query": "symbol A", - "sync_if_needed": True, }, }, }), @@ -1275,6 +1410,15 @@ def test_v3_record_projects_exact_proxy_bundle(self) -> None: ) self.assertEqual(recorded["query"]["symbols"][0]["path"], "src/a.py") + def test_bundle_v1_remains_projectable_but_v2_policy_is_fixed(self) -> None: + recorded, _query = self.record_current_v3_fixture(legacy_bundle=True) + self.assertEqual(recorded["record_version"], 3) + + self.tearDown() + self.setUp() + with self.assertRaisesRegex(RuleFailure, "refresh policy"): + self.record_current_v3_fixture(invalid_refresh_policy=True) + def test_v3_record_rejects_mutated_window_identity_and_fallbacks(self) -> None: recorded, _query = self.record_current_v3_fixture() protocol = importlib.import_module("internal.code_intelligence_protocol") @@ -1446,7 +1590,6 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess "CIQ-001", "locate A", "symbol A", - False, runner=runner, ) protocol = importlib.import_module("internal.code_intelligence_protocol") @@ -1508,7 +1651,6 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess "CIQ-001", "locate A after one sync attempt", "symbol A", - True, runner=runner, ) self.assertIsNone(query["bundle"]["post_sync_status"]) @@ -1564,12 +1706,18 @@ def test_v3_record_preserves_stale_unknown_and_unavailable_restrictions(self) -> pending = json.loads(healthy_status(self.repo)) pending["pendingChanges"]["modified"] = 1 responses = [ + completed(json.dumps(pending)), + completed("synced\n"), completed(json.dumps(pending)), completed("A is defined in src/a.py\n"), completed(json.dumps(pending)), ] elif case == "unknown": - responses = [completed("not-json\n")] + responses = [ + completed("not-json\n"), + completed("A is defined in src/a.py\n"), + completed(healthy_status(self.repo)), + ] else: responses = [] @@ -1590,7 +1738,6 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess "CIQ-001", "locate A conservatively", "symbol A", - False, runner=runner, ) fallback = { diff --git a/tests/test_core.py b/tests/test_core.py index 0303c29..ea23960 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -398,7 +398,6 @@ def runner( "CIQ-001", "bind final subject", "final subject symbols", - False, runner=runner, ) return record_proxy_bundle( From b3eda95700a668fb3c56211ed032b2811fc5a4e3 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 17:46:43 +0800 Subject: [PATCH 21/28] fix: preserve CodeGraph identity failures --- scripts/internal/code_intelligence_proxy.py | 41 +++++- tests/test_codegraph.py | 150 ++++++++++++++++++++ 2 files changed, 185 insertions(+), 6 deletions(-) diff --git a/scripts/internal/code_intelligence_proxy.py b/scripts/internal/code_intelligence_proxy.py index e2c4bfa..b82255e 100644 --- a/scripts/internal/code_intelligence_proxy.py +++ b/scripts/internal/code_intelligence_proxy.py @@ -313,7 +313,13 @@ def _delivery( *_observation_points(post_status), ]) pre_status_blocks_query = _pre_status_blocks_query(effective_pre) - known_stale = not pre_status_blocks_query and (any( + post_status_blocks_query = ( + post_status is not None and _pre_status_blocks_query(post_status) + ) + pre_status_unknown = _is_unknown(effective_pre) + known_stale = not ( + pre_status_blocks_query or post_status_blocks_query or pre_status_unknown + ) and (any( point.get("reason") != "STATUS_UNREADABLE" for point in points ) or effective_pre.get("status") in {"PARTIAL_STALE", "INDEX_STALE"} or ( post_status is not None @@ -321,9 +327,10 @@ def _delivery( ) or (classification or {}).get("classification") in {"PARTIAL_STALE", "INDEX_STALE"}) unknown = ( pre_status_blocks_query + or post_status_blocks_query or forced_unknown is not None or query_result.get("status") != "SUCCESS" - or _is_unknown(effective_pre) + or pre_status_unknown or post_status is None or _is_unknown(post_status) or (classification or {}).get("classification") == "NOT_VERIFIED" @@ -358,18 +365,22 @@ def _delivery( elif unknown: state = "UNKNOWN" record_status = "NOT_VERIFIED" - if pre_status_blocks_query: + if pre_status_blocks_query or post_status_blocks_query: + identity_observation = ( + effective_pre if pre_status_blocks_query else post_status + ) + assert identity_observation is not None reason = ( "WORKTREE_MISMATCH" if any( point.get("reason") == "WORKTREE_MISMATCH" - for point in effective_pre.get("stale_points", []) + for point in identity_observation.get("stale_points", []) ) else "PROJECT_MISMATCH" ) elif forced_unknown: reason = "RESPONSE_INTEGRITY_UNVERIFIED" - elif _is_unknown(effective_pre): + elif pre_status_unknown: reason = ( "PROJECT_MISMATCH" if "different project" in str(effective_pre.get("error", "")).lower() @@ -523,8 +534,17 @@ def execute_proxy_query( repo, descriptor, pre_status, runner=runner ) bundle["sync"] = synchronized["sync"] - effective_pre = synchronized["freshness"] bundle["post_sync_status"] = synchronized["post_sync_status"] + effective_pre = synchronized["freshness"] + if ( + bundle["post_sync_status"] is not None + and _pre_status_blocks_query(bundle["post_sync_status"]) + and bundle["post_sync_status"].get("error") + ): + effective_pre = { + **effective_pre, + "error": bundle["post_sync_status"]["error"], + } if effective_pre["status"] == "UNAVAILABLE": bundle["query"]["status"] = "UNAVAILABLE" @@ -610,6 +630,15 @@ def execute_proxy_query( ).as_posix() post_status = inspect_status(repo, descriptor, runner=runner) bundle["post_query_status"] = post_status + if _pre_status_blocks_query(post_status): + forced_unknown = str( + post_status.get("error") + or "CodeGraph post-query status reports a worktree mismatch" + ) + if response_path.exists(): + response_path.unlink() + bundle["response_path"] = None + response = None bundle["delivery"] = _delivery( effective_pre, diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index e3253e8..0d72b7b 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -556,6 +556,96 @@ def runner(command, **_kwargs): "full_rebuild": "USER_ONLY", }) + def test_proxy_blocks_post_sync_project_mismatch_before_explore(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["modified"] = 1 + wrong = json.loads(healthy_status(self.repo)) + wrong["projectPath"] = str(self.repo / "other-checkout") + responses = [ + completed(json.dumps(pending)), + completed("synced\n"), + completed(json.dumps(wrong)), + completed("graph bytes must not be queried\n"), + completed(healthy_status(self.repo)), + ] + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return responses.pop(0) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual( + [item[1] for item in calls], ["status", "sync", "status"] + ) + self.assertIsNone(result["response"]) + self.assertIsNone(result["bundle"]["response_path"]) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual( + result["bundle"]["delivery"]["reason"], "PROJECT_MISMATCH" + ) + self.assertIn( + "different project", result["bundle"]["post_sync_status"]["error"] + ) + self.assertEqual( + [ + point["reason"] + for point in result["bundle"]["delivery"]["stale_points"] + ], + ["STATUS_UNREADABLE", "SYNC_FAILED"], + ) + + def test_proxy_discards_response_after_post_query_project_mismatch(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + wrong = json.loads(healthy_status(self.repo)) + wrong["projectPath"] = str(self.repo / "other-checkout") + responses = [ + completed(healthy_status(self.repo)), + completed("graph bytes must be discarded\n"), + completed(json.dumps(wrong)), + ] + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return responses.pop(0) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + response_path = ( + self.repo + / ".polaris/tasks/TASK-0001/runtime/code-intelligence/planning" + / "CIQ-001.response.txt" + ) + self.assertEqual( + [item[1] for item in calls], ["status", "explore", "status"] + ) + self.assertIsNone(result["response"]) + self.assertIsNone(result["bundle"]["response_path"]) + self.assertFalse(response_path.exists()) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual( + result["bundle"]["delivery"]["reason"], "PROJECT_MISMATCH" + ) + def test_proxy_queries_unknown_pre_status_and_treats_result_as_stale(self) -> None: self.qualify_task() (self.repo / ".codegraph").mkdir() @@ -584,6 +674,66 @@ def runner(command, **_kwargs): self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") self.assertIn("freshness: TREAT_AS_STALE", result["envelope"]) + def test_proxy_unknown_pre_status_overrides_later_stale_signals(self) -> None: + cases = [ + ( + "post_pending", + "graph bytes\n", + "PENDING_CHANGES", + ), + ( + "response_stale", + "⚠️ CodeGraph auto-sync is DISABLED — the index is frozen.\n", + "AUTO_SYNC_DISABLED", + ), + ] + for index, (case, response, stale_reason) in enumerate(cases, start=1): + with self.subTest(case=case): + if index > 1: + self.tearDown() + self.setUp() + self.qualify_task() + (self.repo / ".codegraph").mkdir() + post_status = json.loads(healthy_status(self.repo)) + if case == "post_pending": + post_status["pendingChanges"]["modified"] = 1 + responses = [ + completed("not-json\n"), + completed(response), + completed(json.dumps(post_status)), + ] + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return responses.pop(0) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual( + [item[1] for item in calls], ["status", "explore", "status"] + ) + self.assertEqual(result["response"], response) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual( + result["bundle"]["delivery"]["reason"], "STATUS_UNREADABLE" + ) + self.assertIn( + stale_reason, + [ + point["reason"] + for point in result["bundle"]["delivery"]["stale_points"] + ], + ) + self.assertIn("freshness: TREAT_AS_STALE", result["envelope"]) + def test_proxy_does_not_query_a_different_project_index(self) -> None: self.qualify_task() (self.repo / ".codegraph").mkdir() From 6a9ef4f42d771478cee7eba3b4a04a598e8515d9 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 17:54:24 +0800 Subject: [PATCH 22/28] docs: define automatic CodeGraph freshness behavior --- README.md | 4 ++-- README.zh-CN.md | 4 ++-- docs/USAGE.md | 8 ++++---- plan.md | 6 +++--- skills/adversarial-review/SKILL.md | 2 +- skills/architecture-planning/SKILL.md | 2 +- skills/code-intelligence/SKILL.md | 6 +++--- skills/documentation-sync/SKILL.md | 4 ++-- skills/implementation/SKILL.md | 2 +- templates/AGENTS.md | 2 +- tests/test_codegraph.py | 25 ++++++++++++++++++++++++- 11 files changed, 44 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 797faa4..fe1717f 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,9 @@ polaris code-intelligence add codegraph --repo . Run these commands from the target repository as appropriate. `codegraph init` creates the `.codegraph/` marker; without it Polaris uses source and Git directly and creates no stage record. Vendoring registers the project-scoped `polaris-codegraph` proxy in `.codex/config.toml` and `.mcp.json` without replacing unrelated settings. The host may require project trust or first-use approval; that approval remains the user's decision. -Polaris stages call only `polaris_codegraph_explore`. The proxy checks status, may internally perform one bounded `codegraph sync` when requested and pending, runs one explore, rechecks status, and returns a freshness envelope before graph content. There is no separate stage status/sync MCP call. `CURRENT` means `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN` mean `NAVIGATION_ONLY` and require the named source/Git fallback; `UNAVAILABLE` means no graph. A current named file uses `READ_SOURCE`, a deleted file uses `INSPECT_GIT_DIFF`, and an index-wide or unsafe result uses `SEARCH_SOURCE`. Validation remains graph-free and relies on source, Git, builds, tests, static checks, and Human Checks. +Polaris stages call only `polaris_codegraph_explore`. The proxy checks status and automatically runs at most one bounded incremental `codegraph sync` when pending changes exist, then runs one explore, rechecks status, and returns a freshness envelope before graph content. There is no separate stage status/sync MCP call. `CURRENT` means `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN`/`TREAT_AS_STALE` mean `NAVIGATION_ONLY` and require the exact source/Git fallback before a conclusion is used; `UNAVAILABLE` means no graph. A current named file uses `READ_SOURCE`, a deleted file uses `INSPECT_GIT_DIFF`, and an index-wide or unsafe result uses `SEARCH_SOURCE`. Validation remains graph-free and relies on source, Git, builds, tests, static checks, and Human Checks. -The repository owner, not Polaris, owns CodeGraph installation, initialization, configuration, raw MCP registration, watcher, and daemon. Polaris never starts, configures, reconfigures, waits for, or manages them. Raw `codegraph_explore` or `codegraph explore` remains available out-of-band but cannot back `CURRENT` Polaris evidence. New records are v3 projections of the retained proxy bundle and completed fallbacks; v1/v2 are historical only. CodeGraph remains optional and never becomes a workflow gate. +The repository owner, not Polaris, owns CodeGraph installation, initialization, configuration, raw MCP registration, watcher, daemon, and every full `codegraph index` rebuild. Polaris never starts, configures, reconfigures, waits for, or manages them. Raw `codegraph_explore` or `codegraph explore` remains available out-of-band but cannot back `CURRENT` Polaris evidence. New records are v3 projections of the retained proxy bundle and completed fallbacks; v1/v2 are historical only. CodeGraph remains optional and never becomes a workflow gate. Protocol `0.1.21` adds the project-scoped Polaris CodeGraph proxy, host adapter v3 registration, and auditable Code Intelligence record v3 while leaving Workflow at `0.1.3`. Record v1 and v2 are immutable historical evidence only; new evidence is projected from a retained proxy bundle into v3. diff --git a/README.zh-CN.md b/README.zh-CN.md index c7d1397..0ae2a8a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -91,9 +91,9 @@ polaris code-intelligence add codegraph --repo . `codegraph init` 创建 `.codegraph/` marker;没有 marker 时 Polaris 直接使用源码和 Git,不生成阶段 record。Vendoring 会在 `.codex/config.toml` 与 `.mcp.json` 中非破坏地注册项目级 `polaris-codegraph` 代理,并保留其他设置。宿主可能要求信任项目或首次使用确认;是否批准仍由用户决定。 -Polaris 阶段只调用 `polaris_codegraph_explore`。代理先检查 status,按请求且确有 pending 时至多执行一次有界 `codegraph sync`,再执行一次 explore、复查 status,并保证 freshness envelope 位于图内容之前;阶段没有独立的 status/sync MCP 调用。`CURRENT` 表示 `NON_AUTHORITATIVE_CONTEXT`;`STALE` 与 `UNKNOWN` 表示 `NAVIGATION_ONLY`,必须完成 envelope 指定的源码/Git 回退;`UNAVAILABLE` 表示没有图内容。当前具名文件使用 `READ_SOURCE`,已删除文件使用 `INSPECT_GIT_DIFF`,索引级或不安全结果使用 `SEARCH_SOURCE`。Validation 不调用 CodeGraph,仍以源码、Git、构建、测试、静态检查和 Human Check 为准。 +Polaris 阶段只调用 `polaris_codegraph_explore`。代理先检查 status,存在 pending changes 时自动且至多执行一次有界增量 `codegraph sync`,再执行一次 explore、复查 status,并保证 freshness envelope 位于图内容之前;阶段没有独立的 status/sync MCP 调用。`CURRENT` 表示 `NON_AUTHORITATIVE_CONTEXT`;`STALE` 与 `UNKNOWN`/`TREAT_AS_STALE` 表示 `NAVIGATION_ONLY`,在使用任何结论前必须完成 envelope 指定的精确源码/Git 回退;`UNAVAILABLE` 表示没有图内容。当前具名文件使用 `READ_SOURCE`,已删除文件使用 `INSPECT_GIT_DIFF`,索引级或不安全结果使用 `SEARCH_SOURCE`。Validation 不调用 CodeGraph,仍以源码、Git、构建、测试、静态检查和 Human Check 为准。 -CodeGraph 的安装、初始化、配置、raw MCP 注册、watcher 与 daemon 归仓库所有者,而不是 Polaris。Polaris 绝不启动、配置、重新配置、等待或管理这些能力。raw `codegraph_explore` 或 `codegraph explore` 仍可作为带外工具使用,但不能支持 Polaris 的 `CURRENT` 证据。新 record 必须由保留的代理 bundle 与已完成回退投影为 v3;v1/v2 仅供历史读取。CodeGraph 始终可选,永远不是 Workflow 门禁。 +CodeGraph 的安装、初始化、配置、raw MCP 注册、watcher、daemon 与每次全量 `codegraph index` 重建都归仓库所有者,而不是 Polaris;全量重建始终由用户主动执行。Polaris 绝不启动、配置、重新配置、等待或管理这些能力。raw `codegraph_explore` 或 `codegraph explore` 仍可作为带外工具使用,但不能支持 Polaris 的 `CURRENT` 证据。新 record 必须由保留的代理 bundle 与已完成回退投影为 v3;v1/v2 仅供历史读取。CodeGraph 始终可选,永远不是 Workflow 门禁。 协议 `0.1.21` 新增项目级 Polaris CodeGraph 代理、Host Adapter v3 注册和可审计的 Code Intelligence record v3,Workflow 仍为 `0.1.3`。record v1/v2 仅作为不可变历史证据读取;新证据必须由保留的代理 bundle 投影为 v3。 diff --git a/docs/USAGE.md b/docs/USAGE.md index ed1f6da..f711dee 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -219,13 +219,13 @@ Python CLI 无法直接查看 Codex 或 Claude Code 当前会话中的 MCP 工 } ``` -CodeGraph watcher 与连接时 reconciliation 是常规实时更新机制。Polaris 阶段只调用 `polaris_codegraph_explore`:代理在同一有界窗口内检查 status,按调用参数且确有 pending 时至多运行一次 `codegraph sync`,执行一次 explore,再复查 status。阶段没有独立的 status/sync MCP 工具,也不会等待 watcher、轮询、重试、启动 daemon 或改写用户的 raw MCP 配置。Documentation Sync 仅在 supported source 变化时执行一次查询,使用 `sync_if_needed: true`,并把 query 限制到 changed source paths 与 documented symbols。 +CodeGraph watcher 与连接时 reconciliation 是常规实时更新机制。Polaris 阶段只调用 `polaris_codegraph_explore`:代理在同一有界窗口内检查 status,存在 pending changes 时自动且至多运行一次增量 `codegraph sync`,执行一次 explore,再复查 status。阶段没有独立的 status/sync MCP 工具,也不会等待 watcher、轮询、重试、启动 daemon 或改写用户的 raw MCP 配置。Documentation Sync 仅在 supported source 变化时执行一次查询,把 query 限制到 changed source paths 与 documented symbols;automatic incremental sync 仅由代理负责。 -代理结果的第一个内容块总是 freshness envelope。`CURRENT / NON_AUTHORITATIVE_CONTEXT` 表示图可作为非权威上下文;`STALE / NAVIGATION_ONLY` 表示已知失效;`UNKNOWN / NAVIGATION_ONLY` 表示无法证明新鲜度;`UNAVAILABLE / NO_GRAPH` 表示没有图输出。任何状态都不宣称与 Git commit 严格一致,`UNKNOWN` 绝不能当作 current。raw `codegraph_explore` 或 `codegraph explore` 仍可由用户带外调用,但不能支持 Polaris 的 `CURRENT` 证据。 +代理结果的第一个内容块总是 freshness envelope。`CURRENT / NON_AUTHORITATIVE_CONTEXT` 表示图可作为非权威上下文;`STALE / NAVIGATION_ONLY` 表示已知失效;`UNKNOWN / TREAT_AS_STALE / NAVIGATION_ONLY` 表示无法证明新鲜度;`UNAVAILABLE / NO_GRAPH` 表示没有图输出。任何状态都不宣称与 Git commit 严格一致,`UNKNOWN` 必须按 `TREAT_AS_STALE` 处理,绝不能当作 current。raw `codegraph_explore` 或 `codegraph explore` 仍可由用户带外调用,但不能支持 Polaris 的 `CURRENT` 证据。 -`STALE` 或 `UNKNOWN` 必须先完成 envelope 指定的源码/Git fallback。当前具名普通文件直接读取并记录 `READ_SOURCE` 与当前 SHA-256;安全但已删除的路径检查注册 subject 的 Git diff,记录 `INSPECT_GIT_DIFF`、null observed SHA-256 与 base/head/diff hashes;不安全路径或索引级失效执行 `SEARCH_SOURCE`,记录有限、受限的当前文件路径与 SHA-256。图不能扩大冻结 scope、替代源码或决定 Review verdict,Validation 完全不调用 CodeGraph。 +`STALE` 或 `UNKNOWN`/`TREAT_AS_STALE` 必须先完成 envelope 指定的精确源码/Git fallback。当前具名普通文件直接读取并记录 `READ_SOURCE` 与当前 SHA-256;安全但已删除的路径检查注册 subject 的 Git diff,记录 `INSPECT_GIT_DIFF`、null observed SHA-256 与 base/head/diff hashes;不安全路径或索引级失效执行 `SEARCH_SOURCE`,记录有限、受限的当前文件路径与 SHA-256。图不能扩大冻结 scope、替代源码或决定 Review verdict,Validation 完全不调用 CodeGraph。 -每次代理调用都会把精确响应和 bundle 留在 ignored 的 `runtime/code-intelligence/`。完成 fallback 后,Agent 写只含 summary、已确认 symbols 和 source_fallbacks 的 annotations JSON,再运行 `record_code_intelligence.py --repo . --bundle --annotations ` 投影不可变 v3 record。不得手写 record;没有代理调用就省略 record。v1/v2 record 仅作为不可变历史证据读取。 +每次代理调用都会把精确响应和 bundle 留在 ignored 的 `runtime/code-intelligence/`。完成 fallback 后,Agent 写只含 summary、已确认 symbols 和 source_fallbacks 的 annotations JSON,再运行 `record_code_intelligence.py --repo . --bundle --annotations ` 投影不可变 v3 record。不得手写 record;没有代理调用就省略 record。全量 `codegraph index` 始终由用户主动执行,v1/v2 record 仅作为不可变历史证据读取。 ## 4. Polaris 仓库自举 diff --git a/plan.md b/plan.md index d383d9c..e603817 100644 --- a/plan.md +++ b/plan.md @@ -488,9 +488,9 @@ AGENTS.md - v0.1 的唯一正式 Provider 是 [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph)。项目级 `polaris-codegraph` MCP 只暴露 `polaris_codegraph_explore`;raw `codegraph_explore` 与 shell 仍可带外使用,但不能支持 Polaris `CURRENT` 证据。 - `.codegraph/` 与 CodeGraph 安装、初始化、配置、raw MCP、watcher 和 daemon 由用户拥有。Polaris 只非破坏地管理自身项目代理注册;缺少 marker 或策略禁用时直接回退源码,不生成阶段 record。 -- 代理在一个有界窗口内完成 pre-status、可选一次 `codegraph sync`、一次 explore 和 post-status,并先返回 freshness envelope。阶段不分别选择 status/sync;不等待、轮询或重试。 -- envelope 状态为 `CURRENT / NON_AUTHORITATIVE_CONTEXT`、`STALE / NAVIGATION_ONLY`、`UNKNOWN / NAVIGATION_ONLY` 或 `UNAVAILABLE / NO_GRAPH`。`STALE`/`UNKNOWN` 必须先完成具名 `READ_SOURCE`、删除路径 `INSPECT_GIT_DIFF` 或索引级 `SEARCH_SOURCE` 回退;`UNKNOWN` 不得提升为 current。 -- Planning、Implementation 与 Reviewer 只在冻结范围内使用图关系;Implementation 修改关系后必须 fresh proxy call,Reviewer 必须独立调用且不得继承 Implementer envelope。Documentation Sync 仅在 supported source 改变时,以 `sync_if_needed: true` 对 changed paths/symbols执行一次查询。Validation 完全不调用 CodeGraph。 +- 代理在一个有界窗口内完成 pre-status、存在 pending changes 时自动且至多一次增量 `codegraph sync`、一次 explore 和 post-status,并先返回 freshness envelope。阶段不分别选择 status/sync;不等待、轮询或重试;全量 `codegraph index` 始终由用户主动执行。 +- envelope 状态为 `CURRENT / NON_AUTHORITATIVE_CONTEXT`、`STALE / NAVIGATION_ONLY`、`UNKNOWN / TREAT_AS_STALE / NAVIGATION_ONLY` 或 `UNAVAILABLE / NO_GRAPH`。`STALE`/`UNKNOWN` 必须先完成具名 `READ_SOURCE`、删除路径 `INSPECT_GIT_DIFF` 或索引级 `SEARCH_SOURCE` 的精确源码/Git fallback;`UNKNOWN` 必须按 `TREAT_AS_STALE` 处理,不得提升为 current。 +- Planning、Implementation 与 Reviewer 只在冻结范围内使用图关系;Implementation 修改关系后必须 fresh proxy call,Reviewer 必须独立调用且不得继承 Implementer envelope。Documentation Sync 仅在 supported source 改变时,对 changed paths/symbols 执行一次查询,自动增量同步由代理负责。Validation 完全不调用 CodeGraph。 - 代理 bundle 与原始响应只进入 ignored runtime。Agent 完成 fallback 后提供 annotations,由 `record_code_intelligence.py --bundle ... --annotations ...` 投影不可变 v3 record;没有代理调用就省略 record。v1/v2 仅作为不可变历史证据读取。 ## 9. 确定性脚本 diff --git a/skills/adversarial-review/SKILL.md b/skills/adversarial-review/SKILL.md index 421befd..c54406b 100644 --- a/skills/adversarial-review/SKILL.md +++ b/skills/adversarial-review/SKILL.md @@ -22,4 +22,4 @@ Return a concise structured result to the dispatcher with verdict, Review attemp Only the Reviewer context may write `ACCEPT`. -Proxy evidence contract: `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN` are `NAVIGATION_ONLY`. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. Never run `codegraph init` or manage the Provider. Graph evidence cannot determine the Review verdict. +Proxy evidence contract: the proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN`/`TREAT_AS_STALE` are `NAVIGATION_ONLY` and require the exact source/Git fallback before any conclusion is used. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. Never run `codegraph init` or manage the Provider. Graph evidence cannot determine the Review verdict. diff --git a/skills/architecture-planning/SKILL.md b/skills/architecture-planning/SKILL.md index e89fe2f..f1dd333 100644 --- a/skills/architecture-planning/SKILL.md +++ b/skills/architecture-planning/SKILL.md @@ -21,4 +21,4 @@ After the transition succeeds, reload state and emit `[POLARIS:PLAN_READY]` with Do not modify the frozen Work Item or start implementation from this stage. -Proxy evidence contract: `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN` are `NAVIGATION_ONLY`. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. After a proxy operation, write annotations and run `record_code_intelligence.py --repo . --bundle --annotations ` to project v3; without a proxy operation, omit the Code Intelligence record. Never run `codegraph init` or manage the Provider. +Proxy evidence contract: the proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN`/`TREAT_AS_STALE` are `NAVIGATION_ONLY` and require the exact source/Git fallback before any conclusion is used. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. After a proxy operation, write annotations and run `record_code_intelligence.py --repo . --bundle --annotations ` to project v3; without a proxy operation, omit the Code Intelligence record. Never run `codegraph init` or manage the Provider. diff --git a/skills/code-intelligence/SKILL.md b/skills/code-intelligence/SKILL.md index e584d19..96414d3 100644 --- a/skills/code-intelligence/SKILL.md +++ b/skills/code-intelligence/SKILL.md @@ -8,10 +8,10 @@ description: Internal optional Polaris stage support for bounded CodeGraph relat CodeGraph is optional navigation context. Source, Git, builds, tests, frozen artifacts, Review, Validation, and Human decisions remain authority. 1. Load `.polaris/code-intelligence.json` and project rules. If policy disables Code Intelligence or the repository root lacks `.codegraph/`, skip the proxy, use source/Git, and omit the Code Intelligence record because no proxy operation ran. Never run `codegraph init`. -2. For Polaris graph evidence call only `polaris_codegraph_explore`, using the active task ID, legal stage, next `CIQ-NNN`, bounded purpose/query, and the stage's declared `sync_if_needed` value. The project registration fixes the repository root. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after an envelope requires the stage fallback. +2. Call only `polaris_codegraph_explore` with task ID, stage, next `CIQ-NNN`, purpose, and query. The proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. The project registration fixes the repository root. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after an envelope requires the stage fallback. 3. Read the `freshness envelope` before any graph content: - `CURRENT` with `usage: NON_AUTHORITATIVE_CONTEXT` permits the graph only as non-authoritative context. - - `STALE` or `UNKNOWN` with `usage: NAVIGATION_ONLY` requires every named source/Git fallback. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only the resulting current source/Git evidence does. Index-wide uncertainty affects the entire graph response. `UNKNOWN` is never current. + - `STALE` and `UNKNOWN`/`TREAT_AS_STALE` with `usage: NAVIGATION_ONLY` require the exact source/Git fallback before any conclusion is used. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only the resulting current source/Git evidence does. Index-wide uncertainty affects the entire graph response. `UNKNOWN` is never current. - `UNAVAILABLE` with `usage: NO_GRAPH` means use source/Git and do not expect graph content. 4. Complete fallbacks exactly. For a safe named current regular file, read it and record `READ_SOURCE` with its current SHA-256. For a safe missing/deleted path, inspect the registered subject diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff hashes. For an unsafe path or index-wide stale/unknown result, perform an actual bounded repository search and record `SEARCH_SOURCE` with zero to 100 unique confined POSIX `result_paths`, each a current regular file and current SHA-256; an empty result is valid only when that search found no current file. In `STALE`/`UNKNOWN`, annotate a symbol only when its current path is covered by `READ_SOURCE` or a hashed `SEARCH_SOURCE` result. 5. A raw `codegraph_explore` MCP call or `codegraph explore` shell command remains user-accessible out-of-band, but its output is always unverified for Polaris and cannot back `CURRENT` Polaris evidence. Never project raw Provider output into a Polaris record. @@ -22,6 +22,6 @@ Stage policy: - Planning: query only frozen-task relationships needed to justify Working Set entries. Confirm safe current returned paths in current source before recording the query ID as `discovered_from`; confirm a safe missing/deleted path through the registered subject Git diff instead. - Implementation: make a bounded handoff-scoped call before editing when useful. Any conclusion needed after edits requires a fresh `polaris_codegraph_explore` call; never reuse the entry freshness envelope. If an earlier non-current envelope ended graph use for the stage, use source/Git only rather than making that post-edit call. -- Documentation Sync: only when supported source changed, make one query over changed source paths and documented symbols with `sync_if_needed: true`; there is no separate status/sync MCP tool. +- Documentation Sync: only when supported source changed, make one query over changed source paths and documented symbols; the proxy's automatic incremental sync is the only freshness action, and there is no separate status/sync MCP tool. - Review: independently query only registered-subject impact relationships. Never inherit or reuse the Implementer's envelope, bundle, or conclusions. - Validation: do not invoke this Skill. Validation remains graph-free. diff --git a/skills/documentation-sync/SKILL.md b/skills/documentation-sync/SKILL.md index c9793b4..b5bb40b 100644 --- a/skills/documentation-sync/SKILL.md +++ b/skills/documentation-sync/SKILL.md @@ -12,7 +12,7 @@ description: Internal Polaris worker stage for an explicitly started `{{skill:en 5. Record failed attempts with `record_exploration.py`. Keep task-only conclusions in the task; promote reusable, evidence-backed conclusions to `.polaris/explorations/` with the same script. 6. Leave no unresolved `STALE` entry. 7. Create the final subject checkpoint and recompute the subject diff hash. -8. When the final subject includes supported source changes, policy is enabled, and `.codegraph/` exists, invoke `{{skill:code-intelligence}}` once at the Documentation Sync boundary. Call `polaris_codegraph_explore` with stage `DOCUMENTATION_SYNC`, a query limited to changed source paths and documented symbols, and `sync_if_needed: true`; there is no separate status/sync MCP tool. Read the freshness envelope first and finish every required source/Git fallback. Then create annotations and run `record_code_intelligence.py --repo . --bundle --annotations ` to project the immutable v3 record and reference it from the Knowledge Delta. Otherwise omit the Code Intelligence record and optional artifact reference. +8. When the final subject includes supported source changes, policy is enabled, and `.codegraph/` exists, invoke `{{skill:code-intelligence}}` once at the Documentation Sync boundary. Call `polaris_codegraph_explore` with stage `DOCUMENTATION_SYNC` and a query limited to changed source paths and documented symbols; automatic incremental sync is owned by the proxy, and there is no separate status/sync MCP tool. Read the freshness envelope first and finish every required source/Git fallback. Then create annotations and run `record_code_intelligence.py --repo . --bundle --annotations ` to project the immutable v3 record and reference it from the Knowledge Delta. Otherwise omit the Code Intelligence record and optional artifact reference. 9. Refresh the Working Set if a promoted exploration, documentation change, or confirmed Code Intelligence dependency alters the next stage's justified inputs. 10. Run `check_docs.py` with the final subject base/head. When live telemetry exists, append its result with `ADD_CHECK`, then use `SET_PHASE` to enter `COMPLETED` with no blocker. Return the Knowledge Delta path, final subject base/head, diff hash, changed documentation, promoted explorations, Code Intelligence refresh status, and check result. @@ -20,4 +20,4 @@ Do not run workflow transitions or emit a Polaris checkpoint marker. The main `{ Do not edit Review, Validation, Result, event, or state artifacts directly. -Proxy evidence contract: `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN` are `NAVIGATION_ONLY`. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. Never run `codegraph init` or manage the Provider. Graph evidence never gates documentation checks or state changes. +Proxy evidence contract: the proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN`/`TREAT_AS_STALE` are `NAVIGATION_ONLY` and require the exact source/Git fallback before any conclusion is used. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. Never run `codegraph init` or manage the Provider. Graph evidence never gates documentation checks or state changes. diff --git a/skills/implementation/SKILL.md b/skills/implementation/SKILL.md index 95240af..5888516 100644 --- a/skills/implementation/SKILL.md +++ b/skills/implementation/SKILL.md @@ -19,4 +19,4 @@ description: Internal Polaris worker stage for an explicitly started `{{skill:en Do not run workflow transitions, Review, Validation, or task closure. Do not emit a Polaris checkpoint marker; the main `{{skill:engineering-task}}` validates the artifact and continues this same task for `{{skill:documentation-sync}}` while authority remains `IMPLEMENTING`. -Proxy evidence contract: `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN` are `NAVIGATION_ONLY`. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. Never run `codegraph init` or manage the Provider. +Proxy evidence contract: the proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN`/`TREAT_AS_STALE` are `NAVIGATION_ONLY` and require the exact source/Git fallback before any conclusion is used. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. Never run `codegraph init` or manage the Provider. diff --git a/templates/AGENTS.md b/templates/AGENTS.md index 30726bc..9af1977 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -16,7 +16,7 @@ ## Optional CodeGraph rules - Use CodeGraph only when project policy permits it and the repository root already contains `.codegraph/`. Otherwise skip the proxy, use source/Git, and omit the Code Intelligence record; agents never run `codegraph init`. -- For Polaris evidence call only `polaris_codegraph_explore` and read its freshness envelope before graph content. `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN` are `NAVIGATION_ONLY`. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. `UNAVAILABLE` means no graph. +- For Polaris evidence call only `polaris_codegraph_explore` and read its freshness envelope before graph content. The proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN`/`TREAT_AS_STALE` are `NAVIGATION_ONLY` and require the exact source/Git fallback before any conclusion is used. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. `UNAVAILABLE` means no graph. - Complete fallbacks exactly: a safe current regular file uses `READ_SOURCE` with current SHA-256; a safe missing/deleted path uses `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff hashes; unsafe or index-wide stale/unknown results use `SEARCH_SOURCE` with finite confined POSIX result paths and current hashes. - A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. If the proxy ran, write annotations and run `record_code_intelligence.py --repo . --bundle --annotations ` to project v3; do not hand-author a record. - Never install, initialize, start, authenticate, configure, reconfigure, or manage CodeGraph, its watcher, daemon, lock, raw MCP registration, or index. CodeGraph cannot expand frozen scope or replace source, Git, builds, tests, Review, Validation, or Human gates. diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 0d72b7b..7ff2c67 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -3329,6 +3329,29 @@ def assert_contract(text: str, label: str) -> None: with self.assertRaises(AssertionError): assert_contract(mutation, "mutated implementation") + def test_all_agent_surfaces_require_automatic_freshness_policy(self) -> None: + """Every CodeGraph-capable Agent surface follows proxy-owned freshness.""" + paths = [ + ROOT / "skills/code-intelligence/SKILL.md", + ROOT / "skills/architecture-planning/SKILL.md", + ROOT / "skills/implementation/SKILL.md", + ROOT / "skills/documentation-sync/SKILL.md", + ROOT / "skills/adversarial-review/SKILL.md", + ROOT / "templates/AGENTS.md", + ] + required = ( + "automatically runs at most one incremental `codegraph sync`", + "never runs `codegraph index`", + "UNKNOWN", + "TREAT_AS_STALE", + "source/Git fallback", + ) + for path in paths: + text = path.read_text(encoding="utf-8") + self.assertNotIn("sync_if_needed", text, path.as_posix()) + for anchor in required: + self.assertIn(anchor, text, f"{path}: {anchor}") + def test_documentation_sync_uses_one_proxy_query(self) -> None: """Documentation Sync uses one bounded changed-path/symbol proxy query.""" source = (ROOT / "skills/documentation-sync/SKILL.md").read_text( @@ -3343,9 +3366,9 @@ def test_documentation_sync_uses_one_proxy_query(self) -> None: ) for anchor in ( "polaris_codegraph_explore", - "sync_if_needed: true", "changed source paths", "documented symbols", + "automatic incremental sync", "no separate status/sync MCP tool", ): self.assertIn(anchor, rendered, f"{adapter['host_id']}: {anchor}") From 76c87b08db171deb387fbbf457abfaa62e6b2fc1 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 17:59:37 +0800 Subject: [PATCH 23/28] docs: clarify CodeGraph unknown-status queries --- README.md | 2 ++ README.zh-CN.md | 2 ++ docs/USAGE.md | 2 ++ plan.md | 1 + skills/code-intelligence/SKILL.md | 2 +- templates/AGENTS.md | 2 +- tests/test_codegraph.py | 39 +++++++++++++++++++++++++++++++ 7 files changed, 48 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fe1717f..5999a29 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ Run these commands from the target repository as appropriate. `codegraph init` c Polaris stages call only `polaris_codegraph_explore`. The proxy checks status and automatically runs at most one bounded incremental `codegraph sync` when pending changes exist, then runs one explore, rechecks status, and returns a freshness envelope before graph content. There is no separate stage status/sync MCP call. `CURRENT` means `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN`/`TREAT_AS_STALE` mean `NAVIGATION_ONLY` and require the exact source/Git fallback before a conclusion is used; `UNAVAILABLE` means no graph. A current named file uses `READ_SOURCE`, a deleted file uses `INSPECT_GIT_DIFF`, and an index-wide or unsafe result uses `SEARCH_SOURCE`. Validation remains graph-free and relies on source, Git, builds, tests, static checks, and Human Checks. +When status cannot be verified but the project has a safe repository identity, the proxy still calls `polaris_codegraph_explore` and returns `UNKNOWN`/`TREAT_AS_STALE`. The graph remains navigation-only: use the exact source/Git fallback before any conclusion. + The repository owner, not Polaris, owns CodeGraph installation, initialization, configuration, raw MCP registration, watcher, daemon, and every full `codegraph index` rebuild. Polaris never starts, configures, reconfigures, waits for, or manages them. Raw `codegraph_explore` or `codegraph explore` remains available out-of-band but cannot back `CURRENT` Polaris evidence. New records are v3 projections of the retained proxy bundle and completed fallbacks; v1/v2 are historical only. CodeGraph remains optional and never becomes a workflow gate. Protocol `0.1.21` adds the project-scoped Polaris CodeGraph proxy, host adapter v3 registration, and auditable Code Intelligence record v3 while leaving Workflow at `0.1.3`. Record v1 and v2 are immutable historical evidence only; new evidence is projected from a retained proxy bundle into v3. diff --git a/README.zh-CN.md b/README.zh-CN.md index 0ae2a8a..53b9e0c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -93,6 +93,8 @@ polaris code-intelligence add codegraph --repo . Polaris 阶段只调用 `polaris_codegraph_explore`。代理先检查 status,存在 pending changes 时自动且至多执行一次有界增量 `codegraph sync`,再执行一次 explore、复查 status,并保证 freshness envelope 位于图内容之前;阶段没有独立的 status/sync MCP 调用。`CURRENT` 表示 `NON_AUTHORITATIVE_CONTEXT`;`STALE` 与 `UNKNOWN`/`TREAT_AS_STALE` 表示 `NAVIGATION_ONLY`,在使用任何结论前必须完成 envelope 指定的精确源码/Git 回退;`UNAVAILABLE` 表示没有图内容。当前具名文件使用 `READ_SOURCE`,已删除文件使用 `INSPECT_GIT_DIFF`,索引级或不安全结果使用 `SEARCH_SOURCE`。Validation 不调用 CodeGraph,仍以源码、Git、构建、测试、静态检查和 Human Check 为准。 +当 status 无法验证但仓库身份安全时,代理仍执行 `polaris_codegraph_explore`,并返回 `UNKNOWN`/`TREAT_AS_STALE`。图只用于导航;在使用任何结论前,必须完成精确源码/Git 回退。 + CodeGraph 的安装、初始化、配置、raw MCP 注册、watcher、daemon 与每次全量 `codegraph index` 重建都归仓库所有者,而不是 Polaris;全量重建始终由用户主动执行。Polaris 绝不启动、配置、重新配置、等待或管理这些能力。raw `codegraph_explore` 或 `codegraph explore` 仍可作为带外工具使用,但不能支持 Polaris 的 `CURRENT` 证据。新 record 必须由保留的代理 bundle 与已完成回退投影为 v3;v1/v2 仅供历史读取。CodeGraph 始终可选,永远不是 Workflow 门禁。 协议 `0.1.21` 新增项目级 Polaris CodeGraph 代理、Host Adapter v3 注册和可审计的 Code Intelligence record v3,Workflow 仍为 `0.1.3`。record v1/v2 仅作为不可变历史证据读取;新证据必须由保留的代理 bundle 投影为 v3。 diff --git a/docs/USAGE.md b/docs/USAGE.md index f711dee..8c8fd5e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -221,6 +221,8 @@ Python CLI 无法直接查看 Codex 或 Claude Code 当前会话中的 MCP 工 CodeGraph watcher 与连接时 reconciliation 是常规实时更新机制。Polaris 阶段只调用 `polaris_codegraph_explore`:代理在同一有界窗口内检查 status,存在 pending changes 时自动且至多运行一次增量 `codegraph sync`,执行一次 explore,再复查 status。阶段没有独立的 status/sync MCP 工具,也不会等待 watcher、轮询、重试、启动 daemon 或改写用户的 raw MCP 配置。Documentation Sync 仅在 supported source 变化时执行一次查询,把 query 限制到 changed source paths 与 documented symbols;automatic incremental sync 仅由代理负责。 +当 status 无法验证但仓库身份安全时,代理仍执行 `polaris_codegraph_explore`,并返回 `UNKNOWN`/`TREAT_AS_STALE`。图只用于导航;在使用任何结论前,必须完成精确源码/Git fallback。 + 代理结果的第一个内容块总是 freshness envelope。`CURRENT / NON_AUTHORITATIVE_CONTEXT` 表示图可作为非权威上下文;`STALE / NAVIGATION_ONLY` 表示已知失效;`UNKNOWN / TREAT_AS_STALE / NAVIGATION_ONLY` 表示无法证明新鲜度;`UNAVAILABLE / NO_GRAPH` 表示没有图输出。任何状态都不宣称与 Git commit 严格一致,`UNKNOWN` 必须按 `TREAT_AS_STALE` 处理,绝不能当作 current。raw `codegraph_explore` 或 `codegraph explore` 仍可由用户带外调用,但不能支持 Polaris 的 `CURRENT` 证据。 `STALE` 或 `UNKNOWN`/`TREAT_AS_STALE` 必须先完成 envelope 指定的精确源码/Git fallback。当前具名普通文件直接读取并记录 `READ_SOURCE` 与当前 SHA-256;安全但已删除的路径检查注册 subject 的 Git diff,记录 `INSPECT_GIT_DIFF`、null observed SHA-256 与 base/head/diff hashes;不安全路径或索引级失效执行 `SEARCH_SOURCE`,记录有限、受限的当前文件路径与 SHA-256。图不能扩大冻结 scope、替代源码或决定 Review verdict,Validation 完全不调用 CodeGraph。 diff --git a/plan.md b/plan.md index e603817..227f77a 100644 --- a/plan.md +++ b/plan.md @@ -490,6 +490,7 @@ AGENTS.md - `.codegraph/` 与 CodeGraph 安装、初始化、配置、raw MCP、watcher 和 daemon 由用户拥有。Polaris 只非破坏地管理自身项目代理注册;缺少 marker 或策略禁用时直接回退源码,不生成阶段 record。 - 代理在一个有界窗口内完成 pre-status、存在 pending changes 时自动且至多一次增量 `codegraph sync`、一次 explore 和 post-status,并先返回 freshness envelope。阶段不分别选择 status/sync;不等待、轮询或重试;全量 `codegraph index` 始终由用户主动执行。 - envelope 状态为 `CURRENT / NON_AUTHORITATIVE_CONTEXT`、`STALE / NAVIGATION_ONLY`、`UNKNOWN / TREAT_AS_STALE / NAVIGATION_ONLY` 或 `UNAVAILABLE / NO_GRAPH`。`STALE`/`UNKNOWN` 必须先完成具名 `READ_SOURCE`、删除路径 `INSPECT_GIT_DIFF` 或索引级 `SEARCH_SOURCE` 的精确源码/Git fallback;`UNKNOWN` 必须按 `TREAT_AS_STALE` 处理,不得提升为 current。 +- 当 status 无法验证但仓库身份安全时,代理仍执行 `polaris_codegraph_explore` 并返回 `UNKNOWN`/`TREAT_AS_STALE`;图仅用于导航,任何结论都必须先完成精确源码/Git fallback。 - Planning、Implementation 与 Reviewer 只在冻结范围内使用图关系;Implementation 修改关系后必须 fresh proxy call,Reviewer 必须独立调用且不得继承 Implementer envelope。Documentation Sync 仅在 supported source 改变时,对 changed paths/symbols 执行一次查询,自动增量同步由代理负责。Validation 完全不调用 CodeGraph。 - 代理 bundle 与原始响应只进入 ignored runtime。Agent 完成 fallback 后提供 annotations,由 `record_code_intelligence.py --bundle ... --annotations ...` 投影不可变 v3 record;没有代理调用就省略 record。v1/v2 仅作为不可变历史证据读取。 diff --git a/skills/code-intelligence/SKILL.md b/skills/code-intelligence/SKILL.md index 96414d3..3c84a32 100644 --- a/skills/code-intelligence/SKILL.md +++ b/skills/code-intelligence/SKILL.md @@ -8,7 +8,7 @@ description: Internal optional Polaris stage support for bounded CodeGraph relat CodeGraph is optional navigation context. Source, Git, builds, tests, frozen artifacts, Review, Validation, and Human decisions remain authority. 1. Load `.polaris/code-intelligence.json` and project rules. If policy disables Code Intelligence or the repository root lacks `.codegraph/`, skip the proxy, use source/Git, and omit the Code Intelligence record because no proxy operation ran. Never run `codegraph init`. -2. Call only `polaris_codegraph_explore` with task ID, stage, next `CIQ-NNN`, purpose, and query. The proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. The project registration fixes the repository root. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after an envelope requires the stage fallback. +2. Call only `polaris_codegraph_explore` with task ID, stage, next `CIQ-NNN`, purpose, and query. The proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. When status cannot be verified but the project has a safe repository identity, the proxy still calls `polaris_codegraph_explore` and returns `UNKNOWN`/`TREAT_AS_STALE`; graph content remains navigation-only and any conclusion requires the exact source/Git fallback. The project registration fixes the repository root. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after an envelope requires the stage fallback. 3. Read the `freshness envelope` before any graph content: - `CURRENT` with `usage: NON_AUTHORITATIVE_CONTEXT` permits the graph only as non-authoritative context. - `STALE` and `UNKNOWN`/`TREAT_AS_STALE` with `usage: NAVIGATION_ONLY` require the exact source/Git fallback before any conclusion is used. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only the resulting current source/Git evidence does. Index-wide uncertainty affects the entire graph response. `UNKNOWN` is never current. diff --git a/templates/AGENTS.md b/templates/AGENTS.md index 9af1977..6eaaddb 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -16,7 +16,7 @@ ## Optional CodeGraph rules - Use CodeGraph only when project policy permits it and the repository root already contains `.codegraph/`. Otherwise skip the proxy, use source/Git, and omit the Code Intelligence record; agents never run `codegraph init`. -- For Polaris evidence call only `polaris_codegraph_explore` and read its freshness envelope before graph content. The proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN`/`TREAT_AS_STALE` are `NAVIGATION_ONLY` and require the exact source/Git fallback before any conclusion is used. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. `UNAVAILABLE` means no graph. +- For Polaris evidence call only `polaris_codegraph_explore` and read its freshness envelope before graph content. The proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. When status cannot be verified but the project has a safe repository identity, the proxy still calls `polaris_codegraph_explore` and returns `UNKNOWN`/`TREAT_AS_STALE`; graph content remains navigation-only and any conclusion requires the exact source/Git fallback. `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN`/`TREAT_AS_STALE` are `NAVIGATION_ONLY` and require the exact source/Git fallback before any conclusion is used. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. `UNAVAILABLE` means no graph. - Complete fallbacks exactly: a safe current regular file uses `READ_SOURCE` with current SHA-256; a safe missing/deleted path uses `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff hashes; unsafe or index-wide stale/unknown results use `SEARCH_SOURCE` with finite confined POSIX result paths and current hashes. - A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. If the proxy ran, write annotations and run `record_code_intelligence.py --repo . --bundle --annotations ` to project v3; do not hand-author a record. - Never install, initialize, start, authenticate, configure, reconfigure, or manage CodeGraph, its watcher, daemon, lock, raw MCP registration, or index. CodeGraph cannot expand frozen scope or replace source, Git, builds, tests, Review, Validation, or Human gates. diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 7ff2c67..bcc0db3 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -3352,6 +3352,45 @@ def test_all_agent_surfaces_require_automatic_freshness_policy(self) -> None: for anchor in required: self.assertIn(anchor, text, f"{path}: {anchor}") + def test_safe_identity_unknown_status_still_queries_and_is_stale(self) -> None: + """Safe identity keeps the bounded query available after an unreadable status.""" + paths = { + ROOT / "skills/code-intelligence/SKILL.md": ( + "safe repository identity", + "still calls `polaris_codegraph_explore`", + "UNKNOWN`/`TREAT_AS_STALE", + ), + ROOT / "templates/AGENTS.md": ( + "safe repository identity", + "still calls `polaris_codegraph_explore`", + "UNKNOWN`/`TREAT_AS_STALE", + ), + ROOT / "README.md": ( + "safe repository identity", + "still calls `polaris_codegraph_explore`", + "UNKNOWN`/`TREAT_AS_STALE", + ), + ROOT / "README.zh-CN.md": ( + "仓库身份安全", + "仍执行 `polaris_codegraph_explore`", + "UNKNOWN`/`TREAT_AS_STALE", + ), + ROOT / "docs/USAGE.md": ( + "仓库身份安全", + "仍执行 `polaris_codegraph_explore`", + "UNKNOWN`/`TREAT_AS_STALE", + ), + ROOT / "plan.md": ( + "仓库身份安全", + "仍执行 `polaris_codegraph_explore`", + "UNKNOWN`/`TREAT_AS_STALE", + ), + } + for path, required in paths.items(): + text = path.read_text(encoding="utf-8") + for anchor in required: + self.assertIn(anchor, text, f"{path}: {anchor}") + def test_documentation_sync_uses_one_proxy_query(self) -> None: """Documentation Sync uses one bounded changed-path/symbol proxy query.""" source = (ROOT / "skills/documentation-sync/SKILL.md").read_text( From 854a8808dd5e07e7aca756f3d2449fbbd5ada6e2 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 18:04:10 +0800 Subject: [PATCH 24/28] test: bind CodeGraph unknown-status query contract --- tests/test_codegraph.py | 71 +++++++++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 24 deletions(-) diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index bcc0db3..1ae6565 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -3295,7 +3295,7 @@ def test_all_agent_surfaces_require_proxy_provenance(self) -> None: ) available_skills = set(discover_skills(ROOT)) - def assert_contract(text: str, label: str) -> None: + def assert_contract(text: str, label: Path) -> None: for anchor in anchors: self.assertIn(anchor, text, f"{label}: {anchor}") self.assertIn("record_code_intelligence.py", text, label) @@ -3354,42 +3354,65 @@ def test_all_agent_surfaces_require_automatic_freshness_policy(self) -> None: def test_safe_identity_unknown_status_still_queries_and_is_stale(self) -> None: """Safe identity keeps the bounded query available after an unreadable status.""" - paths = { + contracts = { ROOT / "skills/code-intelligence/SKILL.md": ( - "safe repository identity", - "still calls `polaris_codegraph_explore`", - "UNKNOWN`/`TREAT_AS_STALE", + "When status cannot be verified but the project has a safe repository " + "identity, the proxy still calls `polaris_codegraph_explore` and " + "returns `UNKNOWN`/`TREAT_AS_STALE`; graph content remains " + "navigation-only and any conclusion requires the exact source/Git " + "fallback." ), ROOT / "templates/AGENTS.md": ( - "safe repository identity", - "still calls `polaris_codegraph_explore`", - "UNKNOWN`/`TREAT_AS_STALE", + "When status cannot be verified but the project has a safe repository " + "identity, the proxy still calls `polaris_codegraph_explore` and " + "returns `UNKNOWN`/`TREAT_AS_STALE`; graph content remains " + "navigation-only and any conclusion requires the exact source/Git " + "fallback." ), ROOT / "README.md": ( - "safe repository identity", - "still calls `polaris_codegraph_explore`", - "UNKNOWN`/`TREAT_AS_STALE", + "When status cannot be verified but the project has a safe repository " + "identity, the proxy still calls `polaris_codegraph_explore` and " + "returns `UNKNOWN`/`TREAT_AS_STALE`. The graph remains " + "navigation-only: use the exact source/Git fallback before any " + "conclusion." ), ROOT / "README.zh-CN.md": ( - "仓库身份安全", - "仍执行 `polaris_codegraph_explore`", - "UNKNOWN`/`TREAT_AS_STALE", + "当 status 无法验证但仓库身份安全时,代理仍执行 " + "`polaris_codegraph_explore`,并返回 `UNKNOWN`/`TREAT_AS_STALE`。" + "图只用于导航;在使用任何结论前,必须完成精确源码/Git 回退。" ), ROOT / "docs/USAGE.md": ( - "仓库身份安全", - "仍执行 `polaris_codegraph_explore`", - "UNKNOWN`/`TREAT_AS_STALE", + "当 status 无法验证但仓库身份安全时,代理仍执行 " + "`polaris_codegraph_explore`,并返回 `UNKNOWN`/`TREAT_AS_STALE`。" + "图只用于导航;在使用任何结论前,必须完成精确源码/Git fallback。" ), ROOT / "plan.md": ( - "仓库身份安全", - "仍执行 `polaris_codegraph_explore`", - "UNKNOWN`/`TREAT_AS_STALE", + "当 status 无法验证但仓库身份安全时,代理仍执行 " + "`polaris_codegraph_explore` 并返回 `UNKNOWN`/`TREAT_AS_STALE`;" + "图仅用于导航,任何结论都必须先完成精确源码/Git fallback。" ), } - for path, required in paths.items(): - text = path.read_text(encoding="utf-8") - for anchor in required: - self.assertIn(anchor, text, f"{path}: {anchor}") + + def assert_contract(text: str, label: str) -> None: + self.assertIn(contracts[label], text, label) + + for path in contracts: + assert_contract(path.read_text(encoding="utf-8"), path) + + canonical = contracts[ROOT / "skills/code-intelligence/SKILL.md"] + detached = canonical.replace( + "the proxy still calls `polaris_codegraph_explore` and returns " + "`UNKNOWN`/`TREAT_AS_STALE`", + "the proxy returns `UNKNOWN`/`TREAT_AS_STALE`", + ) + " The proxy still calls `polaris_codegraph_explore` after current status." + for anchor in ( + "safe repository identity", + "still calls `polaris_codegraph_explore`", + "UNKNOWN`/`TREAT_AS_STALE", + ): + self.assertIn(anchor, detached) + with self.assertRaises(AssertionError): + assert_contract(detached, ROOT / "skills/code-intelligence/SKILL.md") def test_documentation_sync_uses_one_proxy_query(self) -> None: """Documentation Sync uses one bounded changed-path/symbol proxy query.""" From bcd2653dafeeca1797204c7eb30cb0f1b1a95421 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 18:13:59 +0800 Subject: [PATCH 25/28] chore: advance Polaris protocol to 0.1.22 --- README.md | 4 +- README.zh-CN.md | 4 +- VERSION | 2 +- docs/USAGE.md | 9 ++-- plan.md | 4 +- pyproject.toml | 2 +- scripts/internal/migration_protocol.py | 34 ++++++++------ templates/project.json | 2 +- templates/task-sources/state.json | 2 +- templates/task/state.json | 2 +- tests/test_codegraph.py | 63 ++++++++++++++++++++++++-- tests/test_core.py | 13 ++++++ workflow/migrations.json | 9 ++++ 13 files changed, 118 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 5999a29..56c5edb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ English | [简体中文](README.zh-CN.md) -> Current protocol version: `0.1.21` (in development); workflow version: `0.1.3` +> Current protocol version: `0.1.22` (in development); workflow version: `0.1.3` Polaris is a repo-native engineering workflow for coding agent hosts. It stores requirements, plans, implementation results, independent reviews, validation evidence, and task state in Git, then uses deterministic gates to prevent requirement drift, stale evidence, and agents declaring their own work complete. @@ -97,7 +97,7 @@ When status cannot be verified but the project has a safe repository identity, t The repository owner, not Polaris, owns CodeGraph installation, initialization, configuration, raw MCP registration, watcher, daemon, and every full `codegraph index` rebuild. Polaris never starts, configures, reconfigures, waits for, or manages them. Raw `codegraph_explore` or `codegraph explore` remains available out-of-band but cannot back `CURRENT` Polaris evidence. New records are v3 projections of the retained proxy bundle and completed fallbacks; v1/v2 are historical only. CodeGraph remains optional and never becomes a workflow gate. -Protocol `0.1.21` adds the project-scoped Polaris CodeGraph proxy, host adapter v3 registration, and auditable Code Intelligence record v3 while leaving Workflow at `0.1.3`. Record v1 and v2 are immutable historical evidence only; new evidence is projected from a retained proxy bundle into v3. +Protocol `0.1.22` keeps Workflow at `0.1.3` and adds an explicit version-only migration from `0.1.21` that neither inventories nor rewrites Code Intelligence record v3 evidence. Protocol `0.1.21` introduced the project-scoped Polaris CodeGraph proxy, host adapter v3 registration, and auditable record v3; record v1 and v2 remain immutable historical evidence only. ## v0.1 scope diff --git a/README.zh-CN.md b/README.zh-CN.md index 53b9e0c..0585080 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,7 +2,7 @@ [English](README.md) | 简体中文 -> 当前协议版本:`0.1.21`(开发中);Workflow 版本:`0.1.3` +> 当前协议版本:`0.1.22`(开发中);Workflow 版本:`0.1.3` Polaris 是运行在 Coding Agent 宿主上的仓库原生工程工作流。它把需求、计划、实现、独立审查、验证和任务状态保存在 Git 仓库中,并通过确定性门禁防止需求漂移、证据过期和 Agent 自行宣布完成。 @@ -97,7 +97,7 @@ Polaris 阶段只调用 `polaris_codegraph_explore`。代理先检查 status, CodeGraph 的安装、初始化、配置、raw MCP 注册、watcher、daemon 与每次全量 `codegraph index` 重建都归仓库所有者,而不是 Polaris;全量重建始终由用户主动执行。Polaris 绝不启动、配置、重新配置、等待或管理这些能力。raw `codegraph_explore` 或 `codegraph explore` 仍可作为带外工具使用,但不能支持 Polaris 的 `CURRENT` 证据。新 record 必须由保留的代理 bundle 与已完成回退投影为 v3;v1/v2 仅供历史读取。CodeGraph 始终可选,永远不是 Workflow 门禁。 -协议 `0.1.21` 新增项目级 Polaris CodeGraph 代理、Host Adapter v3 注册和可审计的 Code Intelligence record v3,Workflow 仍为 `0.1.3`。record v1/v2 仅作为不可变历史证据读取;新证据必须由保留的代理 bundle 投影为 v3。 +协议 `0.1.22` 保持 Workflow `0.1.3`,并新增从 `0.1.21` 出发的显式纯版本迁移;该迁移不清点也不重写 Code Intelligence record v3 证据。协议 `0.1.21` 引入项目级 Polaris CodeGraph 代理、Host Adapter v3 注册和可审计的 record v3;record v1/v2 仍仅作为不可变历史证据读取。 ## v0.1 边界 diff --git a/VERSION b/VERSION index 7906299..7e72641 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.21 +0.1.22 diff --git a/docs/USAGE.md b/docs/USAGE.md index 8c8fd5e..3659232 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -2,7 +2,7 @@ 本文面向希望在受支持 Coding Agent 宿主中使用 Polaris 管理软件工程任务的项目成员。当前内置 Codex 与 Claude Code 适配器;本文从首次接入讲到日常提出需求、独立 Implementation、进度查询、Review、验证、恢复与升级。 -> 当前协议版本:v0.1.21;Workflow 版本:v0.1.3。Polaris v0.1 是仓库原生的 Skills、宿主 worker 定义与 Python 脚本集合,并提供一个只分发到这些脚本的 `polaris` CLI;不提供后台服务或图形界面。 +> 当前协议版本:v0.1.22;Workflow 版本:v0.1.3。Polaris v0.1 是仓库原生的 Skills、宿主 worker 定义与 Python 脚本集合,并提供一个只分发到这些脚本的 `polaris` CLI;不提供后台服务或图形界面。 ## 1. 先理解 Polaris 保存什么 @@ -613,8 +613,9 @@ polaris migrate --repo . 2. 注册步骤同时绑定源/目标 `polaris_version` 与 `workflow_version`。Migration protocol v2 支持仅更新版本,也支持显式替换冻结 workflow 并映射任务状态。 3. `0.1.19 → 0.1.20` 使用 `replace_version_and_workflow` 与 `append_mapped_workflow_event`:冻结 workflow 更新到 `0.1.3`,旧 `IMPLEMENTED` / `DOCS_SYNCED` 映射到 `IMPLEMENTING`,旧 `REVIEWED` 映射到 `VALIDATING`;旧 R0/R1 `VERIFIED` 也映射回 `VALIDATING`,以便通过 `PASS_AND_CLOSE` 重新提交关闭产物,R2 `VERIFIED` 保持不变。迁移事件记录源/目标状态及旧版本;旧 `events.jsonl` 行不可修改。 4. `0.1.20 → 0.1.21` 只替换协议版本,Workflow 保持 `0.1.3`。迁移会校验并清点 canonical v1/v2 Code Intelligence 历史记录的路径与 SHA-256,保持原字节不变;中断恢复前会重算清单,任何变化都会拒绝继续。v1/v2 此后仅可作为历史证据读取。 -5. `.polaris/migrations/MIG--to-.json` 先写为 `IN_PROGRESS`,全部投影更新后改为 `COMPLETED`。迁移锁会记录迁移/任务身份、主机名和 PID;若进程在中间终止,同一主机重新执行命令会接管已死亡的同迁移锁、验证并复用已经追加的事件,不会重复迁移。活跃进程、其他迁移或来源不明的锁不会被自动删除。 -6. 迁移完成后脚本自动运行项目校验;`validate_project.py` 会拒绝未完成记录、缺失/伪造的任务迁移事件或版本不一致。 +5. `0.1.21 → 0.1.22` 只替换协议版本,Workflow 保持 `0.1.3`。迁移记录中的 `retired_code_intelligence_records` 固定为空列表,不重新清点或重写任何 record v3 历史证据。 +6. `.polaris/migrations/MIG--to-.json` 先写为 `IN_PROGRESS`,全部投影更新后改为 `COMPLETED`。迁移锁会记录迁移/任务身份、主机名和 PID;若进程在中间终止,同一主机重新执行命令会接管已死亡的同迁移锁、验证并复用已经追加的事件,不会重复迁移。活跃进程、其他迁移或来源不明的锁不会被自动删除。 +7. 迁移完成后脚本自动运行项目校验;`validate_project.py` 会拒绝未完成记录、缺失/伪造的任务迁移事件或版本不一致。 没有注册路径时不要手改版本号。应先取得包含所需相邻步骤的 Polaris 版本,逐级完成并分别提交;任何失败都先保留 `.polaris/migrations/` 和事件现场,修复原因后重跑同一迁移命令。 @@ -640,6 +641,8 @@ v0.1.20 / Workflow v0.1.3 删除没有独立治理边界的中间状态和事件 v0.1.21 新增项目级 Polaris CodeGraph MCP 代理、Host Adapter v3 注册与 Code Intelligence record v3;Workflow 仍为 v0.1.3,CodeGraph 仍为可选且不参与门禁。v1/v2 record 仅作为不可变历史证据读取。 +v0.1.22 新增 `0.1.21 → 0.1.22` 显式相邻迁移;Workflow 仍为 v0.1.3。该迁移不重新清点或重写 record v3,迁移记录中的 retirement inventory 固定为空。 + ## 13. 失败探索与卡点 如果一个技术方向被证据否定,不要让结论只留在聊天中。记录任务内探索: diff --git a/plan.md b/plan.md index 227f77a..b10eb1e 100644 --- a/plan.md +++ b/plan.md @@ -2,7 +2,7 @@ > 状态:Implementation underway > 目标版本:v0.1 -> 当前协议:`0.1.21`;Workflow:`0.1.3` +> 当前协议:`0.1.22`;Workflow:`0.1.3` > 产品形态:Repo-native Skill System > 宿主 Runtime:声明式可扩展;v0.1 内置 Codex、Claude Code > @@ -452,7 +452,7 @@ v0.1 不设置 `FAILED`:可修复失败通过治理回路处理,外部阻塞 `.polaris/workflow.json` 保存当前项目实际使用且版本锁定的节点、边、依赖和门禁 ID;`tools/polaris/workflow/default-workflow.json` 只用于初始化。`transition_task.py` 只接受图中边并先运行对应 validators,Skill 不直接编辑 `state` 字段。v0.1 遇到 `polaris_version` 或 `workflow_version` 不匹配时拒绝正常执行,不做隐式迁移。 -版本升级必须先 vendoring 目标协议,再显式运行 vendored `migrate_project.py`。`workflow/migrations.json` 是迁移路径唯一且 append-only 的注册表,一次只执行一个从当前版本到目标版本的相邻步骤;历史步骤必须保留以校验已提交记录。Migration protocol v2 保留 `replace_version` / `append_version_event`,并增加 `replace_version_and_workflow` / `append_mapped_workflow_event`。`0.1.19 → 0.1.20` 原子替换冻结 workflow 为 `0.1.3`,追加带源/目标状态及旧版本字段的迁移事件;旧 `IMPLEMENTED`、`DOCS_SYNCED` 映射到 `IMPLEMENTING`,旧 `REVIEWED` 映射到 `VALIDATING`,旧 R0/R1 `VERIFIED` 映射到 `VALIDATING` 以重新提交 `PASS_AND_CLOSE`,仅 R2 保持 `VERIFIED`。`0.1.20 → 0.1.21` 保持 Workflow `0.1.3`,新增项目级 CodeGraph 代理、Host Adapter v3 与 record v3,并把 canonical v1/v2 record 作为仅可读取的不可变历史证据按路径和 SHA-256 清点;迁移恢复前必须重算并比对清单。迁移以 `.polaris/migrations/MIG-*.json` 记录 `IN_PROGRESS/COMPLETED`、各任务 sequence 和状态映射;重跑必须可恢复且不得重复事件。未知路径、跨版本跳跃、未声明的 workflow 变化、任务集合并发变化和不完整记录都必须机械拒绝。 +版本升级必须先 vendoring 目标协议,再显式运行 vendored `migrate_project.py`。`workflow/migrations.json` 是迁移路径唯一且 append-only 的注册表,一次只执行一个从当前版本到目标版本的相邻步骤;历史步骤必须保留以校验已提交记录。Migration protocol v2 保留 `replace_version` / `append_version_event`,并增加 `replace_version_and_workflow` / `append_mapped_workflow_event`。`0.1.19 → 0.1.20` 原子替换冻结 workflow 为 `0.1.3`,追加带源/目标状态及旧版本字段的迁移事件;旧 `IMPLEMENTED`、`DOCS_SYNCED` 映射到 `IMPLEMENTING`,旧 `REVIEWED` 映射到 `VALIDATING`,旧 R0/R1 `VERIFIED` 映射到 `VALIDATING` 以重新提交 `PASS_AND_CLOSE`,仅 R2 保持 `VERIFIED`。`0.1.20 → 0.1.21` 保持 Workflow `0.1.3`,新增项目级 CodeGraph 代理、Host Adapter v3 与 record v3,并把 canonical v1/v2 record 作为仅可读取的不可变历史证据按路径和 SHA-256 清点;迁移恢复前必须重算并比对清单。`0.1.21 → 0.1.22` 同样保持 Workflow `0.1.3`,但 retirement inventory 固定为空,不重新清点或重写任何 record v3 证据。迁移以 `.polaris/migrations/MIG-*.json` 记录 `IN_PROGRESS/COMPLETED`、各任务 sequence 和状态映射;重跑必须可恢复且不得重复事件。未知路径、跨版本跳跃、未声明的 workflow 变化、任务集合并发变化和不完整记录都必须机械拒绝。 迁移占用任务转换锁时必须写入结构化 owner:迁移 ID、任务 ID、主机名、PID 和创建时间。重跑只允许接管同一迁移在同一主机上、且原 PID 已确认不存在的锁;活跃 PID、其他迁移、其他主机、空锁或损坏锁一律拒绝。这样既能从进程崩溃或机器重启恢复,又不把真实并发误判为遗留锁。 diff --git a/pyproject.toml b/pyproject.toml index e8fb9c0..b58db4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "corona-polaris" -version = "0.1.21" +version = "0.1.22" description = "Repo-native AI engineering workflow command dispatcher" requires-python = ">=3.10" dependencies = [] diff --git a/scripts/internal/migration_protocol.py b/scripts/internal/migration_protocol.py index 1477666..a8cea3e 100644 --- a/scripts/internal/migration_protocol.py +++ b/scripts/internal/migration_protocol.py @@ -38,6 +38,10 @@ MIGRATIONS_ROOT = Path(".polaris/migrations") +RETIREMENT_INVENTORY_MIGRATIONS = { + "0.1.19-to-0.1.20", + "0.1.20-to-0.1.21", +} LEGACY_STATUS_MAP = { "DRAFT": "DRAFT", @@ -359,11 +363,12 @@ def _new_record( else state["status"], } ) - retired_code_intelligence_records.extend( - _retired_code_intelligence_records( - repo, task_id, directory, protocol_root, step + if step["migration_id"] in RETIREMENT_INVENTORY_MIGRATIONS: + retired_code_intelligence_records.extend( + _retired_code_intelligence_records( + repo, task_id, directory, protocol_root, step + ) ) - ) return { "record_version": 2, "migration_id": step["migration_id"], @@ -452,17 +457,18 @@ def migrate_project(repo: Path, protocol_root: Path) -> dict[str, Any]: raise RuleFailure("project task list changed during migration") if incomplete is not None: current_inventory: list[dict[str, str]] = [] - for item in record["tasks"]: - directory = task_dir(repo, item["task_id"]) - current_inventory.extend( - _retired_code_intelligence_records( - repo, - item["task_id"], - directory, - protocol_root, - step, + if step["migration_id"] in RETIREMENT_INVENTORY_MIGRATIONS: + for item in record["tasks"]: + directory = task_dir(repo, item["task_id"]) + current_inventory.extend( + _retired_code_intelligence_records( + repo, + item["task_id"], + directory, + protocol_root, + step, + ) ) - ) if record.get("retired_code_intelligence_records", []) != current_inventory: raise RuleFailure( "retired Code Intelligence record inventory changed during migration" diff --git a/templates/project.json b/templates/project.json index e914021..124869f 100644 --- a/templates/project.json +++ b/templates/project.json @@ -1,6 +1,6 @@ { "project_id": "PROJECT_ID", - "polaris_version": "0.1.21", + "polaris_version": "0.1.22", "workflow_version": "0.1.3", "active_tasks": [] } diff --git a/templates/task-sources/state.json b/templates/task-sources/state.json index 7f63d16..256dcb7 100644 --- a/templates/task-sources/state.json +++ b/templates/task-sources/state.json @@ -1,6 +1,6 @@ { "task_id": "TASK-0001", - "polaris_version": "0.1.21", + "polaris_version": "0.1.22", "workflow_version": "0.1.3", "current_revision": 1, "status": "DRAFT", diff --git a/templates/task/state.json b/templates/task/state.json index 7f63d16..256dcb7 100644 --- a/templates/task/state.json +++ b/templates/task/state.json @@ -1,6 +1,6 @@ { "task_id": "TASK-0001", - "polaris_version": "0.1.21", + "polaris_version": "0.1.22", "workflow_version": "0.1.3", "current_revision": 1, "status": "DRAFT", diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 1ae6565..18c752b 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -177,7 +177,7 @@ def test_managed_surfaces_only_name_the_official_codegraph(self) -> None: self.assertIn(official, path.read_text(encoding="utf-8"), path.relative_to(ROOT).as_posix()) for path in [ROOT / "README.md", ROOT / "README.zh-CN.md"]: text = path.read_text(encoding="utf-8") - self.assertIn("0.1.21", text, path.relative_to(ROOT).as_posix()) + self.assertIn("0.1.22", text, path.relative_to(ROOT).as_posix()) self.assertIn("0.1.3", text, path.relative_to(ROOT).as_posix()) def test_authority_surfaces_publish_workflow_013(self) -> None: @@ -188,7 +188,7 @@ def test_authority_surfaces_publish_workflow_013(self) -> None: ROOT / "plan.md", ]: text = path.read_text(encoding="utf-8") - self.assertIn("0.1.21", text, path.relative_to(ROOT).as_posix()) + self.assertIn("0.1.22", text, path.relative_to(ROOT).as_posix()) self.assertIn("0.1.3", text, path.relative_to(ROOT).as_posix()) def test_readmes_keep_codegraph_operational_boundaries(self) -> None: @@ -2028,7 +2028,8 @@ def test_migration_inventories_frozen_v2_records_without_rewriting_them( ) -> None: """0.1.21 inventories current/prior v2 evidence and preserves Workflow 0.1.3.""" frozen = self.prepare_v2_migration_records() - vendor(ROOT, self.repo, False) + with protocol_source_at("0.1.21") as source: + vendor(source, self.repo, False) result = migrate_project(self.repo) @@ -2061,7 +2062,8 @@ def test_migration_inventories_frozen_v2_records_without_rewriting_them( def test_migration_resume_rejects_mutated_frozen_v2_inventory(self) -> None: """中断迁移重跑前会重算 v2 清单,拒绝已经变化的历史证据。""" frozen = self.prepare_v2_migration_records() - vendor(ROOT, self.repo, False) + with protocol_source_at("0.1.21") as source: + vendor(source, self.repo, False) with mock.patch( "internal.migration_protocol.append_jsonl", side_effect=OSError("injected migration interruption"), @@ -2076,6 +2078,59 @@ def test_migration_resume_rejects_mutated_frozen_v2_inventory(self) -> None: with self.assertRaisesRegex(RuleFailure, "inventory changed"): migrate_project(self.repo) + def test_0122_migration_preserves_v3_code_intelligence_records(self) -> None: + recorded, _query = self.record_current_v3_fixture() + actual_path = ( + self.repo + / ".polaris/tasks/TASK-0001/code-intelligence/r001/planning.json" + ) + self.assertEqual( + json.loads(actual_path.read_text(encoding="utf-8")), recorded + ) + before = actual_path.read_bytes() + self.set_protocol_version("0.1.21") + + with protocol_source_at("0.1.22") as source: + vendor(source, self.repo, False) + result = migrate_project(self.repo) + + self.assertEqual(result["from"], "0.1.21") + self.assertEqual(result["to"], "0.1.22") + self.assertEqual(actual_path.read_bytes(), before) + migration = json.loads(Path(result["record"]).read_text(encoding="utf-8")) + self.assertEqual(migration["retired_code_intelligence_records"], []) + + def test_0122_migration_resume_rejects_nonempty_retirement_inventory( + self, + ) -> None: + self.record_current_v3_fixture() + self.set_protocol_version("0.1.21") + with protocol_source_at("0.1.22") as source: + vendor(source, self.repo, False) + with mock.patch( + "internal.migration_protocol.append_jsonl", + side_effect=OSError("injected migration interruption"), + ): + with self.assertRaisesRegex(OSError, "injected migration interruption"): + migrate_project(self.repo) + + migration_path = ( + self.repo + / ".polaris/migrations/MIG-0.1.21-to-0.1.22.json" + ) + migration = json.loads(migration_path.read_text(encoding="utf-8")) + migration["retired_code_intelligence_records"] = [ + { + "task_id": "TASK-0001", + "path": "code-intelligence/r001/planning.json", + "sha256": "0" * 64, + } + ] + write_json_atomic(migration_path, migration) + + with self.assertRaisesRegex(RuleFailure, "inventory changed"): + migrate_project(self.repo) + def test_legacy_v1_records_remain_readable_but_cannot_be_written(self) -> None: self.initialize_task() protocol = importlib.import_module("internal.code_intelligence_protocol") diff --git a/tests/test_core.py b/tests/test_core.py index ea23960..d8f010d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2605,6 +2605,19 @@ def test_version_only_migration_rejects_a_workflow_version_change(self) -> None: ): migrate_project(self.repo) + def test_0122_version_only_migration_rejects_workflow_change(self) -> None: + self.set_protocol_version("0.1.21") + with protocol_source_at("0.1.22") as source: + migrations_path = source / "workflow/migrations.json" + migrations = read_json(migrations_path) + migrations["steps"][-1]["to_workflow_version"] = "0.1.4" + write_json_atomic(migrations_path, migrations) + vendor(source, self.repo, False) + with self.assertRaisesRegex( + RuleFailure, "workflow migration requires replacement" + ): + migrate_project(self.repo) + def test_code_intelligence_auto_detects_available_operations_and_can_be_disabled(self) -> None: """已初始化的可选代码情报按 MCP 工具能力发现;缺失或禁用时不产生硬依赖。""" (self.repo / ".codegraph").mkdir() diff --git a/workflow/migrations.json b/workflow/migrations.json index ab69d72..d97f961 100644 --- a/workflow/migrations.json +++ b/workflow/migrations.json @@ -108,6 +108,15 @@ "to_workflow_version": "0.1.3", "project_strategy": "replace_version", "task_strategy": "append_version_event" + }, + { + "migration_id": "0.1.21-to-0.1.22", + "from_polaris_version": "0.1.21", + "to_polaris_version": "0.1.22", + "from_workflow_version": "0.1.3", + "to_workflow_version": "0.1.3", + "project_strategy": "replace_version", + "task_strategy": "append_version_event" } ] } From 03b88baa1b140806c05ab2b412355f5258379cc4 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 18:24:20 +0800 Subject: [PATCH 26/28] test: verify CodeGraph freshness hardening end to end --- tests/test_codegraph.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 18c752b..aa0e712 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -1447,8 +1447,11 @@ def test_vendored_mcp_proxy_runs_one_auditable_fake_cli_window(self) -> None: self.assertEqual([response["id"] for response in responses], [1, 2]) tool_result = responses[1]["result"] self.assertFalse(tool_result["isError"]) + first_content = tool_result["content"][0]["text"] + self.assertTrue(first_content.startswith("[POLARIS_CODEGRAPH_FRESHNESS]\n")) + self.assertIn("freshness: VERIFIED_AT_CHECK", first_content) self.assertTrue( - tool_result["content"][0]["text"].startswith( + first_content.startswith( "[POLARIS_CODEGRAPH_FRESHNESS]\nstate: CURRENT\n" ) ) @@ -1488,7 +1491,14 @@ def test_vendored_mcp_proxy_runs_one_auditable_fake_cli_window(self) -> None: ["status", "--json"], ], ) - self.assertTrue(all(entry["cwd"] == str(repo.resolve()) for entry in calls)) + self.assertEqual( + [entry["argv"][0] for entry in calls], + ["status", "sync", "status", "explore", "status"], + ) + self.assertTrue( + all(Path(entry["cwd"]).resolve() == repo.resolve() for entry in calls) + ) + self.assertNotIn("index", [arg for entry in calls for arg in entry["argv"]]) annotations_path = fixture_root / "annotations.json" write_json_atomic( @@ -1522,6 +1532,7 @@ def test_vendored_mcp_proxy_runs_one_auditable_fake_cli_window(self) -> None: record_value = json.loads( Path(record_result["path"]).read_text(encoding="utf-8") ) + self.assertEqual(record_value["record_version"], 3) self.assertEqual( record_value["proxy"]["evidence_bundle_sha256"], file_sha256(bundle_path), From befb54c955dee77465937572b59fab13bf73929b Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 19:06:17 +0800 Subject: [PATCH 27/28] fix: close CodeGraph freshness safety gaps --- .../internal/code_intelligence_protocol.py | 14 +- scripts/internal/code_intelligence_proxy.py | 187 +++++++-- scripts/internal/codegraph_adapter.py | 354 ++++++++++++----- tests/test_codegraph.py | 365 +++++++++++++++++- 4 files changed, 775 insertions(+), 145 deletions(-) diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index 4750bfb..0e17d2d 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -1090,12 +1090,13 @@ def _validate_v3_record_value( }[state] if delivery["record_status"] != expected_record_status: raise RuleFailure("v3 delivery state contradicts its record freshness status") - if value["status"] != { + expected_value_status = { "CURRENT": "USED", - "STALE": "USED", + "STALE": "USED" if query["status"] == "SUCCESS" else "FAILED", "UNKNOWN": "FAILED", "UNAVAILABLE": "UNAVAILABLE", - }[state]: + }[state] + if value["status"] != expected_value_status: raise RuleFailure("v3 record status contradicts proxy delivery") if state == "CURRENT": if ( @@ -1116,13 +1117,14 @@ def _validate_v3_record_value( raise RuleFailure("CURRENT v3 evidence lacks a complete zero-pending window") elif state == "STALE": if ( - query["status"] != "SUCCESS" + query["status"] not in {"SUCCESS", "FAILED"} or delivery["usage"] != "NAVIGATION_ONLY" or delivery["required_fallback"] == "NONE" or not any( point["reason"] != "STATUS_UNREADABLE" for point in delivery["stale_points"] ) + or (query["status"] == "FAILED" and not delivery["error"]) ): raise RuleFailure("STALE v3 evidence lacks an explicit stale reason") elif state == "UNKNOWN": @@ -1337,7 +1339,9 @@ def record_proxy_bundle( "target": context["target"], "status": { "CURRENT": "USED", - "STALE": "USED", + "STALE": ( + "USED" if query["status"] == "SUCCESS" else "FAILED" + ), "UNKNOWN": "FAILED", "UNAVAILABLE": "UNAVAILABLE", }[delivery["state"]], diff --git a/scripts/internal/code_intelligence_proxy.py b/scripts/internal/code_intelligence_proxy.py index b82255e..0317829 100644 --- a/scripts/internal/code_intelligence_proxy.py +++ b/scripts/internal/code_intelligence_proxy.py @@ -263,7 +263,16 @@ def _deduplicate(items: list[dict[str, Any]]) -> list[dict[str, Any]]: return result +def _response_identity_mismatch(classification: dict[str, Any] | None) -> bool: + return classification is not None and any( + point.get("reason") == "WORKTREE_MISMATCH" + for point in classification.get("stale_points", []) + ) + + def _unsafe_response(classification: dict[str, Any]) -> bool: + if _response_identity_mismatch(classification): + return True error = str(classification.get("error") or "").lower() return classification.get("classification") == "NOT_VERIFIED" and any( token in error @@ -292,6 +301,42 @@ def _pre_status_blocks_query(observation: dict[str, Any]) -> bool: )) +def _clean_status_observation(observation: dict[str, Any] | None) -> bool: + if observation is None: + return False + pending = observation.get("pending_changes") + return ( + observation.get("status") == "CURRENT_AT_CHECK" + and observation.get("needs_sync") is False + and isinstance(pending, dict) + and set(pending) == {"added", "modified", "removed"} + and all(type(value) is int and value == 0 for value in pending.values()) + and observation.get("basis") + in (["STATUS_JSON"], ["STATUS_JSON", "SYNC_ACKNOWLEDGED"]) + and observation.get("stale_points") == [] + and observation.get("error") is None + and isinstance(observation.get("status_response_sha256"), str) + and re.fullmatch( + r"[0-9a-f]{64}", observation["status_response_sha256"] + ) is not None + ) + + +def _neutral_response_classification( + classification: dict[str, Any] | None, +) -> bool: + return ( + classification is not None + and classification.get("classification") == "NONE" + and classification.get("basis") == ["RESPONSE_BANNER"] + and classification.get("stale_points") == [] + and classification.get("error") is None + and isinstance(classification.get("response_sha256"), str) + and re.fullmatch(r"[0-9a-f]{64}", classification["response_sha256"]) + is not None + ) + + def _delivery( effective_pre: dict[str, Any], query_result: dict[str, Any], @@ -316,24 +361,42 @@ def _delivery( post_status_blocks_query = ( post_status is not None and _pre_status_blocks_query(post_status) ) - pre_status_unknown = _is_unknown(effective_pre) - known_stale = not ( - pre_status_blocks_query or post_status_blocks_query or pre_status_unknown - ) and (any( - point.get("reason") != "STATUS_UNREADABLE" for point in points - ) or effective_pre.get("status") in {"PARTIAL_STALE", "INDEX_STALE"} or ( - post_status is not None - and post_status.get("status") in {"PARTIAL_STALE", "INDEX_STALE"} - ) or (classification or {}).get("classification") in {"PARTIAL_STALE", "INDEX_STALE"}) - unknown = ( + response_identity_mismatch = _response_identity_mismatch(classification) + safety_failure = ( pre_status_blocks_query or post_status_blocks_query + or response_identity_mismatch or forced_unknown is not None + ) + pre_status_unknown = _is_unknown(effective_pre) + known_stale = ( + any(point.get("reason") != "STATUS_UNREADABLE" for point in points) + or effective_pre.get("status") in {"PARTIAL_STALE", "INDEX_STALE"} + or ( + post_status is not None + and post_status.get("status") in {"PARTIAL_STALE", "INDEX_STALE"} + ) + or (classification or {}).get("classification") + in {"PARTIAL_STALE", "INDEX_STALE"} + ) + clean_window = ( + _clean_status_observation(effective_pre) + and query_result.get("status") == "SUCCESS" + and query_result.get("error") is None + and _neutral_response_classification(classification) + and query_result.get("response_sha256") + == (classification or {}).get("response_sha256") + and _clean_status_observation(post_status) + and not safety_failure + ) + unknown = ( + safety_failure or query_result.get("status") != "SUCCESS" or pre_status_unknown or post_status is None or _is_unknown(post_status) or (classification or {}).get("classification") == "NOT_VERIFIED" + or not clean_window ) errors = [ forced_unknown, @@ -343,7 +406,30 @@ def _delivery( (post_status or {}).get("error"), ] error = next((str(item)[:240] for item in errors if item), None) - if known_stale: + + if safety_failure: + state = "UNKNOWN" + record_status = "NOT_VERIFIED" + if response_identity_mismatch: + reason = "WORKTREE_MISMATCH" + elif pre_status_blocks_query or post_status_blocks_query: + identity_observation = ( + effective_pre if pre_status_blocks_query else post_status + ) + assert identity_observation is not None + reason = ( + "WORKTREE_MISMATCH" + if any( + point.get("reason") == "WORKTREE_MISMATCH" + for point in identity_observation.get("stale_points", []) + ) + else "PROJECT_MISMATCH" + ) + else: + reason = "RESPONSE_INTEGRITY_UNVERIFIED" + required_fallback = "SEARCH_SOURCE" + usage = "NAVIGATION_ONLY" + elif known_stale: index_points = [point for point in points if point.get("scope") == "INDEX"] state = "STALE" record_status = "INDEX_STALE" if index_points else "PARTIAL_STALE" @@ -365,22 +451,7 @@ def _delivery( elif unknown: state = "UNKNOWN" record_status = "NOT_VERIFIED" - if pre_status_blocks_query or post_status_blocks_query: - identity_observation = ( - effective_pre if pre_status_blocks_query else post_status - ) - assert identity_observation is not None - reason = ( - "WORKTREE_MISMATCH" - if any( - point.get("reason") == "WORKTREE_MISMATCH" - for point in identity_observation.get("stale_points", []) - ) - else "PROJECT_MISMATCH" - ) - elif forced_unknown: - reason = "RESPONSE_INTEGRITY_UNVERIFIED" - elif pre_status_unknown: + if pre_status_unknown: reason = ( "PROJECT_MISMATCH" if "different project" in str(effective_pre.get("error", "")).lower() @@ -394,12 +465,14 @@ def _delivery( reason = "POST_STATUS_UNREADABLE" required_fallback = "SEARCH_SOURCE" usage = "NAVIGATION_ONLY" - else: + elif clean_window: state = "CURRENT" record_status = "CURRENT_AT_CHECK" reason = "VERIFIED_WINDOW" required_fallback = "NONE" usage = "NON_AUTHORITATIVE_CONTEXT" + else: + raise AssertionError("CodeGraph delivery state was not classified") return { "state": state, "record_status": record_status, @@ -529,6 +602,26 @@ def execute_proxy_query( pre_status = inspect_status(repo, descriptor, runner=runner) bundle["pre_status"] = pre_status effective_pre = pre_status + if _pre_status_blocks_query(pre_status): + blocked_error = pre_status.get("error") or ( + "CodeGraph status reports a worktree mismatch" + ) + bundle["query"]["status"] = "FAILED" + bundle["query"]["error"] = blocked_error + bundle["delivery"] = _delivery( + effective_pre, + bundle["query"], + None, + None, + ) + _write_bundle(bundle_path, bundle) + return { + "bundle": bundle, + "bundle_path": bundle_path, + "response": None, + "envelope": render_freshness_envelope(bundle), + } + if pre_status.get("needs_sync"): synchronized = synchronize_observed_status( repo, descriptor, pre_status, runner=runner @@ -538,7 +631,10 @@ def execute_proxy_query( effective_pre = synchronized["freshness"] if ( bundle["post_sync_status"] is not None - and _pre_status_blocks_query(bundle["post_sync_status"]) + and ( + _pre_status_blocks_query(bundle["post_sync_status"]) + or bundle["post_sync_status"].get("status") == "UNAVAILABLE" + ) and bundle["post_sync_status"].get("error") ): effective_pre = { @@ -546,6 +642,30 @@ def execute_proxy_query( "error": bundle["post_sync_status"]["error"], } + post_sync_status = bundle["post_sync_status"] + if post_sync_status is not None and ( + _pre_status_blocks_query(post_sync_status) + or post_sync_status.get("status") == "UNAVAILABLE" + ): + blocked_error = post_sync_status.get("error") or ( + "CodeGraph post-sync status is unsafe or unavailable" + ) + bundle["query"]["status"] = "FAILED" + bundle["query"]["error"] = blocked_error + bundle["delivery"] = _delivery( + effective_pre, + bundle["query"], + None, + None, + ) + _write_bundle(bundle_path, bundle) + return { + "bundle": bundle, + "bundle_path": bundle_path, + "response": None, + "envelope": render_freshness_envelope(bundle), + } + if effective_pre["status"] == "UNAVAILABLE": bundle["query"]["status"] = "UNAVAILABLE" bundle["query"]["error"] = effective_pre.get("error") @@ -614,7 +734,14 @@ def execute_proxy_query( forced_unknown = "CodeGraph classification digest mismatch" response = None elif _unsafe_response(classification): - forced_unknown = "CodeGraph response contains an unsafe repository path" + forced_unknown = ( + "CodeGraph response reports a worktree mismatch" + if _response_identity_mismatch(classification) + else "CodeGraph response contains an unsafe repository path" + ) + if response_path.exists(): + response_path.unlink() + bundle["response_path"] = None response = None else: response_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/internal/codegraph_adapter.py b/scripts/internal/codegraph_adapter.py index 11b34d3..2be57af 100644 --- a/scripts/internal/codegraph_adapter.py +++ b/scripts/internal/codegraph_adapter.py @@ -53,10 +53,21 @@ r"(?:since last index sync|on disk after the last index sync)" ) _DRIFTED_PROJECT_TAIL_PREFIX = "> ⚠ Changed on disk after the last index sync:" -_SUSPICIOUS_FRESHNESS_SIGNAL = re.compile( - r"(?:⚠|\bwarning\b|\bstale\b|\bpending(?:[- ]sync)?\b|\bout[- ]of[- ]date\b)", +_PROJECT_PENDING_FOOTER = re.compile( + r"^\(Note: (?P[1-9][0-9]*) file\(s\) elsewhere in this project " + r"are pending index sync but were not referenced above:$" +) +_PROJECT_PENDING_ROW = re.compile(r"^ - .+ \(edited [0-9]+ms ago\)$") +_PROJECT_PENDING_MORE = re.compile(r"^ - …and (?P[1-9][0-9]*) more$") +_FILE_SECTION_HEADER = re.compile(r"^\*\*`[^`]+`\*\*(?: — .*)?$") +_EXPLICIT_WARNING_FRAMING = re.compile( + r"(?:⚠|^\s*warning\s*:|^\s+pending[- ]sync(?:\s*:|\s+required\b))", re.IGNORECASE, ) +_PARTIAL_HEADER_VARIANTS = tuple( + tuple(value.rstrip("\n").splitlines()) + for value in (_PARTIAL_BANNER_HEADER, _LEGACY_PARTIAL_BANNER_HEADER) +) def _checked_at() -> str: @@ -150,11 +161,20 @@ def _response_result( } -def _response_not_verified(checked_at: str, error: BaseException | str) -> dict[str, Any]: +def _response_not_verified( + checked_at: str, + error: BaseException | str, + *, + stale_points: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + points = list(stale_points or []) + unreadable = _index_point("STATUS_UNREADABLE") + if unreadable not in points: + points.append(unreadable) return _response_result( "NOT_VERIFIED", checked_at, - stale_points=[_index_point("STATUS_UNREADABLE")], + stale_points=points, error=_error_summary(error), ) @@ -193,8 +213,8 @@ def _response_file_point(repo: Path, raw_path: str) -> dict[str, Any]: } -def _framing_lines(response: str) -> list[str]: - """Return response lines outside Markdown code fences.""" +def _framing_lines(response: str) -> tuple[list[str], bool]: + """Return response lines outside fences and whether every fence closed.""" lines: list[str] = [] inside_fence = False for line in response.splitlines(): @@ -203,7 +223,7 @@ def _framing_lines(response: str) -> list[str]: continue if not inside_fence: lines.append(line) - return lines + return lines, not inside_fence def _with_response_sha256(result: dict[str, Any], response_sha256: str) -> dict[str, Any]: @@ -211,13 +231,37 @@ def _with_response_sha256(result: dict[str, Any], response_sha256: str) -> dict[ return result +def _partial_header_length(lines: list[str], index: int) -> int: + for header in _PARTIAL_HEADER_VARIANTS: + if tuple(lines[index : index + len(header)]) == header: + return len(header) + return 0 + + +def _append_unique_point( + points: list[dict[str, Any]], point: dict[str, Any] +) -> None: + if point not in points: + points.append(point) + + +def _last_paragraph_start(lines: list[str]) -> int: + end = len(lines) + while end > 0 and lines[end - 1] == "": + end -= 1 + start = end + while start > 0 and lines[start - 1] != "": + start -= 1 + return start + + def classify_response( repo: Path, response: str, *, checked_at: str | None = None, ) -> dict[str, Any]: - """Classify documented freshness framing outside source-code fences. + """Classify recognized freshness regions outside source-code fences. Leading whitespace and a UTF-8 BOM are not accepted as an official banner. """ @@ -226,104 +270,188 @@ def classify_response( return _response_not_verified(checked_at, "CodeGraph response is not text") response_sha256 = hashlib.sha256(response.encode("utf-8")).hexdigest() normalized = response.replace("\r\n", "\n").replace("\r", "\n") - if normalized.startswith(_WORKTREE_BANNER_PREFIX): - return _with_response_sha256( - _response_result( - "INDEX_STALE", - checked_at, - stale_points=[_index_point("WORKTREE_MISMATCH")], - ), - response_sha256, - ) - if normalized.startswith(_DISABLED_BANNER_PREFIX): - return _with_response_sha256( - _response_result( - "INDEX_STALE", - checked_at, - stale_points=[_index_point("AUTO_SYNC_DISABLED")], - ), - response_sha256, - ) - - header = next( + framing, fences_balanced = _framing_lines(normalized) + stale_points: list[dict[str, Any]] = [] + parse_error: str | None = None + + # Parse only consecutive recognized banners at the top. CodeGraph composes + # the pending/degraded wrapper around the worktree notice, so more than one + # banner can legitimately precede the first graph-result line. + index = 0 + while index < len(framing): + partial_header_length = _partial_header_length(framing, index) + if partial_header_length: + header_index = index + index += partial_header_length + matches: list[re.Match[str]] = [] + while index < len(framing): + match = _PARTIAL_BANNER_ROW.fullmatch(framing[index]) + if match is None: + break + matches.append(match) + index += 1 + footer_present = ( + index < len(framing) + and framing[index].startswith(_PARTIAL_BANNER_FOOTER) + ) + if not matches or not footer_present: + parse_error = parse_error or "malformed CodeGraph stale banner" + index = header_index + partial_header_length + while index < len(framing) and framing[index] != "": + index += 1 + else: + index += 1 + for match in matches: + try: + _append_unique_point( + stale_points, + _response_file_point(repo, match["path"]), + ) + except (InputFailure, RuleFailure, OSError, ValueError) as error: + parse_error = parse_error or _error_summary(error) + elif framing[index].startswith(_DISABLED_BANNER_PREFIX): + _append_unique_point( + stale_points, _index_point("AUTO_SYNC_DISABLED") + ) + index += 1 + if index < len(framing) and framing[index].startswith(" Reason: "): + index += 1 + elif framing[index].startswith(_WORKTREE_BANNER_PREFIX): + _append_unique_point( + stale_points, _index_point("WORKTREE_MISMATCH") + ) + index += 1 + else: + break + while index < len(framing) and framing[index] == "": + index += 1 + + # A known banner outside the top region is malformed framing, not ordinary + # prose. This retains the legacy wrapped-banner safety behavior. + for line_index in range(index, len(framing)): + line = framing[line_index] + if ( + _partial_header_length(framing, line_index) + or line.startswith(_DISABLED_BANNER_PREFIX) + or line.startswith(_WORKTREE_BANNER_PREFIX) + ): + parse_error = parse_error or "misplaced CodeGraph freshness banner" + + # Recognized per-file headers may occur between source fences throughout an + # explore response. Generic words in prose and source are intentionally not + # inspected. + for line in framing: + drifted = _DRIFTED_FILE_HEADER.match(line) + if drifted is not None: + try: + _append_unique_point( + stale_points, + _response_file_point(repo, drifted["path"]), + ) + except (InputFailure, RuleFailure, OSError, ValueError) as error: + parse_error = parse_error or _error_summary(error) + elif ( + _FILE_SECTION_HEADER.fullmatch(line) + and _EXPLICIT_WARNING_FRAMING.search(line) + ): + parse_error = parse_error or "unrecognized CodeGraph file warning" + + # The current project-level pending footer is a final parenthesized region. + # Validate its rows and count so similar prose cannot masquerade as framing. + pending_footer_lines: set[int] = set() + for footer_index, line in enumerate(framing): + footer_match = _PROJECT_PENDING_FOOTER.fullmatch(line) + if footer_match is None: + continue + tail_end = len(framing) + while tail_end > footer_index + 1 and framing[tail_end - 1] == "": + tail_end -= 1 + rows = framing[footer_index + 1 : tail_end] + valid = bool(rows) and rows[-1].endswith(")") + normalized_rows = list(rows) + if valid: + normalized_rows[-1] = normalized_rows[-1][:-1] + observed_count = 0 + saw_more = False + for row_index, row in enumerate(normalized_rows): + if _PROJECT_PENDING_ROW.fullmatch(row): + if saw_more: + valid = False + break + observed_count += 1 + continue + more = _PROJECT_PENDING_MORE.fullmatch(row) + if more is None or row_index != len(normalized_rows) - 1: + valid = False + break + saw_more = True + observed_count += int(more["count"]) + valid = valid and observed_count == int(footer_match["count"]) + if not valid: + parse_error = parse_error or "malformed CodeGraph pending footer" + continue + pending_footer_lines.update(range(footer_index, tail_end)) + _append_unique_point(stale_points, _index_point("PENDING_CHANGES")) + + footer_start = _last_paragraph_start(framing) + for line_index, line in enumerate(framing): + if line.startswith(_DRIFTED_PROJECT_TAIL_PREFIX): + if line_index < footer_start: + parse_error = parse_error or "misplaced CodeGraph freshness footer" + else: + _append_unique_point( + stale_points, _index_point("PENDING_CHANGES") + ) + + # Unknown warning-like syntax is conservative only in framing positions: + # the first top content line, recognized file headers, and a distinct final + # epilogue paragraph. Ordinary non-framing prose is not keyword-scanned. + first_content = next( ( - candidate - for candidate in (_PARTIAL_BANNER_HEADER, _LEGACY_PARTIAL_BANNER_HEADER) - if normalized.startswith(candidate) + line_index + for line_index in range(index, len(framing)) + if framing[line_index] != "" ), None, ) - if header is not None: - listed = normalized[len(header) :] - footer_index = listed.find(_PARTIAL_BANNER_FOOTER) - if footer_index < 0: - return _with_response_sha256( - _response_not_verified(checked_at, "malformed CodeGraph stale banner"), - response_sha256, - ) - rows = listed[:footer_index].splitlines() - matches = [_PARTIAL_BANNER_ROW.fullmatch(row) for row in rows] - if not rows or any(match is None for match in matches): - return _with_response_sha256( - _response_not_verified(checked_at, "malformed CodeGraph stale banner"), - response_sha256, - ) - try: - stale_points = [ - _response_file_point(repo, match["path"]) - for match in matches - if match is not None - ] - except (InputFailure, RuleFailure, OSError, ValueError) as error: - return _with_response_sha256( - _response_not_verified(checked_at, error), response_sha256 - ) - return _with_response_sha256( - _response_result("PARTIAL_STALE", checked_at, stale_points=stale_points), - response_sha256, + if first_content is not None: + line = framing[first_content] + if ( + _DRIFTED_FILE_HEADER.match(line) is None + and _PROJECT_PENDING_FOOTER.fullmatch(line) is None + and not line.startswith(_DRIFTED_PROJECT_TAIL_PREFIX) + and _EXPLICIT_WARNING_FRAMING.search(line) + ): + parse_error = parse_error or "unrecognized CodeGraph freshness warning" + if footer_start > 0: + for line_index in range(footer_start, len(framing)): + line = framing[line_index] + if ( + line_index not in pending_footer_lines + and _DRIFTED_FILE_HEADER.match(line) is None + and not line.startswith(_DRIFTED_PROJECT_TAIL_PREFIX) + and _EXPLICIT_WARNING_FRAMING.search(line) + ): + parse_error = parse_error or "unrecognized CodeGraph freshness warning" + + if not fences_balanced: + parse_error = parse_error or "unclosed CodeGraph Markdown fence" + + if parse_error is not None: + result = _response_not_verified( + checked_at, parse_error, stale_points=stale_points ) - - framing = _framing_lines(normalized) - drifted_headers = [ - match - for line in framing - if (match := _DRIFTED_FILE_HEADER.match(line)) is not None - ] - if drifted_headers: - try: - stale_points = [ - _response_file_point(repo, match["path"]) - for match in drifted_headers - ] - except (InputFailure, RuleFailure, OSError, ValueError) as error: - return _with_response_sha256( - _response_not_verified(checked_at, error), response_sha256 - ) - return _with_response_sha256( - _response_result("PARTIAL_STALE", checked_at, stale_points=stale_points), - response_sha256, + elif any(point.get("scope") == "INDEX" for point in stale_points): + result = _response_result( + "INDEX_STALE", checked_at, stale_points=stale_points ) - - if any(line.startswith(_DRIFTED_PROJECT_TAIL_PREFIX) for line in framing): - return _with_response_sha256( - _response_result( - "INDEX_STALE", - checked_at, - stale_points=[_index_point("PENDING_CHANGES")], - ), - response_sha256, + elif stale_points: + result = _response_result( + "PARTIAL_STALE", checked_at, stale_points=stale_points ) - - if _SUSPICIOUS_FRESHNESS_SIGNAL.search("\n".join(framing)): - return _with_response_sha256( - _response_not_verified( - checked_at, "unrecognized CodeGraph freshness warning" - ), - response_sha256, - ) - return _with_response_sha256( - _response_result("NONE", checked_at, stale_points=[]), response_sha256 - ) + else: + result = _response_result("NONE", checked_at, stale_points=[]) + return _with_response_sha256(result, response_sha256) def _unique_items(items: list[Any]) -> list[Any]: @@ -361,7 +489,29 @@ def merge_freshness( } response_status = status if classification == "NONE" else classification - merged_status = max((status, response_status), key=FRESHNESS_ORDER.__getitem__) + merged_points = _unique_items( + [ + *status_result.get("stale_points", []), + *response_result.get("stale_points", []), + ] + ) + explicit_stale = any( + point.get("reason") != "STATUS_UNREADABLE" for point in merged_points + ) + if explicit_stale: + merged_status = ( + "INDEX_STALE" + if ( + status == "INDEX_STALE" + or classification == "INDEX_STALE" + or any(point.get("scope") == "INDEX" for point in merged_points) + ) + else "PARTIAL_STALE" + ) + else: + merged_status = max( + (status, response_status), key=FRESHNESS_ORDER.__getitem__ + ) status_basis = status_result.get("basis", []) response_basis = response_result.get("basis", []) include_response_basis = merged_status != "UNAVAILABLE" and ( @@ -379,9 +529,7 @@ def merge_freshness( "checked_at": response_result.get("checked_at") or status_result.get("checked_at"), "basis": basis, - "stale_points": _unique_items( - [*status_result.get("stale_points", []), *response_result.get("stale_points", [])] - ), + "stale_points": merged_points, "response_sha256": ( response_result.get("response_sha256") if include_response_basis else None ), diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index aa0e712..a4b828d 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -556,6 +556,41 @@ def runner(command, **_kwargs): "full_rebuild": "USER_ONLY", }) + def test_proxy_gates_initial_worktree_mismatch_before_sync(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + mismatch = json.loads(healthy_status(self.repo)) + mismatch["pendingChanges"]["modified"] = 1 + mismatch["worktreeMismatch"] = { + "worktreeRoot": str(self.repo), + "indexRoot": str(self.repo / "other-worktree"), + } + calls: list[list[str]] = [] + + def runner( + command: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: + calls.append(command) + return completed(json.dumps(mismatch)) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], ["status"]) + self.assertIsNone(result["response"]) + self.assertIsNone(result["bundle"]["sync"]) + self.assertEqual(result["bundle"]["query"]["status"], "FAILED") + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual( + result["bundle"]["delivery"]["reason"], "WORKTREE_MISMATCH" + ) + def test_proxy_blocks_post_sync_project_mismatch_before_explore(self) -> None: self.qualify_task() (self.repo / ".codegraph").mkdir() @@ -605,6 +640,43 @@ def runner(command, **_kwargs): ["STATUS_UNREADABLE", "SYNC_FAILED"], ) + def test_proxy_blocks_post_sync_unavailable_before_explore(self) -> None: + self.qualify_task() + marker = self.repo / ".codegraph" + marker.mkdir() + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["modified"] = 1 + calls: list[list[str]] = [] + + def runner( + command: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: + calls.append(command) + if command[1] == "status": + return completed(json.dumps(pending)) + if command[1] == "sync": + marker.rmdir() + return completed("synced\n") + return completed("graph bytes must not be queried\n") + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], ["status", "sync"]) + self.assertEqual( + result["bundle"]["post_sync_status"]["status"], "UNAVAILABLE" + ) + self.assertEqual(result["bundle"]["query"]["status"], "FAILED") + self.assertEqual(result["bundle"]["delivery"]["state"], "STALE") + self.assertIsNone(result["response"]) + self.assertIsNone(result["bundle"]["response_path"]) + def test_proxy_discards_response_after_post_query_project_mismatch(self) -> None: self.qualify_task() (self.repo / ".codegraph").mkdir() @@ -646,6 +718,94 @@ def runner(command, **_kwargs): result["bundle"]["delivery"]["reason"], "PROJECT_MISMATCH" ) + def test_proxy_discards_response_classified_as_worktree_mismatch(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + response = ( + "⚠ CodeGraph results below come from a different git worktree " + "(/tmp/main), not where you're working (/tmp/wt) — they may reflect " + "another branch.\n\n" + "graph bytes must be discarded\n" + ) + responses = [ + completed(healthy_status(self.repo)), + completed(response), + completed(healthy_status(self.repo)), + ] + calls: list[list[str]] = [] + + def runner( + command: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: + calls.append(command) + return responses.pop(0) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + response_path = ( + self.repo + / ".polaris/tasks/TASK-0001/runtime/code-intelligence/planning" + / "CIQ-001.response.txt" + ) + self.assertEqual( + [item[1] for item in calls], ["status", "explore", "status"] + ) + self.assertEqual( + result["bundle"]["response_classification"]["classification"], + "INDEX_STALE", + ) + self.assertIsNone(result["response"]) + self.assertIsNone(result["bundle"]["response_path"]) + self.assertFalse(response_path.exists()) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual( + result["bundle"]["delivery"]["reason"], "WORKTREE_MISMATCH" + ) + + def test_proxy_post_query_unavailable_cannot_become_current(self) -> None: + self.qualify_task() + marker = self.repo / ".codegraph" + marker.mkdir() + calls: list[list[str]] = [] + + def runner( + command: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: + calls.append(command) + if command[1] == "status": + return completed(healthy_status(self.repo)) + if command[1] == "explore": + marker.rmdir() + return completed("graph bytes\n") + raise AssertionError(f"unexpected CodeGraph command: {command}") + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], ["status", "explore"]) + self.assertEqual( + result["bundle"]["post_query_status"]["status"], "UNAVAILABLE" + ) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual( + result["bundle"]["delivery"]["record_status"], "NOT_VERIFIED" + ) + self.assertEqual(result["response"], "graph bytes\n") + self.assertIsNotNone(result["bundle"]["response_path"]) + def test_proxy_queries_unknown_pre_status_and_treats_result_as_stale(self) -> None: self.qualify_task() (self.repo / ".codegraph").mkdir() @@ -674,7 +834,7 @@ def runner(command, **_kwargs): self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") self.assertIn("freshness: TREAT_AS_STALE", result["envelope"]) - def test_proxy_unknown_pre_status_overrides_later_stale_signals(self) -> None: + def test_proxy_known_stale_overrides_unknown_pre_status(self) -> None: cases = [ ( "post_pending", @@ -721,17 +881,18 @@ def runner(command, **_kwargs): [item[1] for item in calls], ["status", "explore", "status"] ) self.assertEqual(result["response"], response) - self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual(result["bundle"]["delivery"]["state"], "STALE") self.assertEqual( - result["bundle"]["delivery"]["reason"], "STATUS_UNREADABLE" + result["bundle"]["delivery"]["reason"], stale_reason ) - self.assertIn( - stale_reason, - [ + self.assertEqual( + { point["reason"] for point in result["bundle"]["delivery"]["stale_points"] - ], + }, + {"STATUS_UNREADABLE", stale_reason}, ) + self.assertIsNotNone(result["bundle"]["delivery"]["error"]) self.assertIn("freshness: TREAT_AS_STALE", result["envelope"]) def test_proxy_does_not_query_a_different_project_index(self) -> None: @@ -1782,6 +1943,73 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess self.assertEqual(recorded["query"]["response_sha256"], None) self.assertEqual(recorded["delivery"]["stale_points"], []) + def test_failed_explore_with_known_stale_projects_to_failed_v3(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + source = self.repo / "src/a.py" + source.parent.mkdir() + source.write_text("class A:\n pass\n", encoding="utf-8") + stale = json.loads(healthy_status(self.repo)) + stale["index"]["state"] = "partial" + responses = [ + completed(json.dumps(stale)), + completed("failed explore output\n", returncode=1), + ] + + def runner( + command: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: + return responses.pop(0) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + query = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A in a partial index", "symbol A", runner=runner, + ) + + self.assertEqual(query["bundle"]["delivery"]["state"], "STALE") + self.assertEqual(query["bundle"]["query"]["status"], "FAILED") + self.assertIsNone(query["bundle"]["response_path"]) + protocol = importlib.import_module("internal.code_intelligence_protocol") + try: + result = protocol.record_proxy_bundle( + self.repo, + "TASK-0001", + query["bundle_path"], + { + "summary": "Explore failed; verified current source instead.", + "symbols": [], + "source_fallbacks": [{ + "action": "SEARCH_SOURCE", + "path": None, + "observed_sha256": None, + "base_commit": None, + "head_commit": None, + "diff_hash": None, + "purpose": "locate A in current source", + "result_paths": [{ + "path": "src/a.py", + "observed_sha256": file_sha256(source), + }], + }], + }, + ROOT, + ) + except RuleFailure as error: + self.fail(f"known-stale failed query must remain projectable: {error}") + recorded = json.loads(Path(result["path"]).read_text(encoding="utf-8")) + self.assertEqual(recorded["record_version"], 3) + self.assertEqual(recorded["delivery"]["state"], "STALE") + self.assertEqual(recorded["status"], "FAILED") + self.assertEqual(recorded["query"]["status"], "FAILED") + self.assertIn( + "INDEX_PARTIAL", + [point["reason"] for point in recorded["delivery"]["stale_points"]], + ) + def test_failed_sync_proxy_bundle_preserves_only_observed_post_status(self) -> None: self.qualify_task() (self.repo / ".codegraph").mkdir() @@ -4002,6 +4230,12 @@ def test_current_codegraph_freshness_framing_is_classified(self) -> None: "source below is current; the symbol list may be outdated\n", "PARTIAL_STALE", ), + "drifted_omitted_source": ( + "**`src/a.py`** — ⚠ changed on disk after the last index sync — " + "source omitted (indexed line ranges no longer match, so a slice " + "could show the wrong code).\n", + "PARTIAL_STALE", + ), "worktree": ( "⚠ CodeGraph results below come from a different git worktree " "(/tmp/main), not where you're working (/tmp/wt) — they may reflect " @@ -4015,6 +4249,88 @@ def test_current_codegraph_freshness_framing_is_classified(self) -> None: self.classify_response(response)["classification"], expected ) + def test_combined_response_framing_preserves_worktree_mismatch(self) -> None: + source = self.repo / "src/a.py" + source.parent.mkdir() + source.write_text("value = 1\n", encoding="utf-8") + worktree = ( + "⚠ CodeGraph results below come from a different git worktree " + "(/tmp/main), not where you're working (/tmp/wt) — they may reflect " + "another branch, and symbols changed only here are missing.\n" + ) + partial = ( + "⚠️ Some files referenced below were edited since the last index sync — " + "their codegraph entries may be stale:\n" + " - src/a.py (edited 12ms ago, pending sync)\n" + "For accurate content of those specific files, Read them directly. " + "The rest of this response is fresh.\n\n" + ) + disabled = ( + "⚠️ CodeGraph auto-sync is DISABLED — live file watching stopped, so " + "the index is frozen and any file edited since then is stale here. " + "Read files directly to confirm current content before relying on it.\n\n" + ) + cases = { + "partial_and_worktree": ( + partial + worktree + "\ngraph result\n", + {"PENDING_SYNC", "WORKTREE_MISMATCH"}, + ), + "disabled_and_worktree": ( + disabled + worktree + "\ngraph result\n", + {"AUTO_SYNC_DISABLED", "WORKTREE_MISMATCH"}, + ), + } + + for name, (response, expected_reasons) in cases.items(): + with self.subTest(name=name): + result = self.classify_response(response) + self.assertEqual(result["classification"], "INDEX_STALE") + self.assertEqual( + {point["reason"] for point in result["stale_points"]}, + expected_reasons, + ) + + def test_current_project_pending_footer_is_index_stale(self) -> None: + response = ( + "Graph result with no referenced stale files.\n\n" + "(Note: 2 file(s) elsewhere in this project are pending index sync " + "but were not referenced above:\n" + " - src/a.py (edited 12ms ago)\n" + " - src/b.py (edited 20ms ago))\n" + ) + + result = self.classify_response(response) + + self.assertEqual(result["classification"], "INDEX_STALE") + self.assertEqual(result["stale_points"], [{ + "scope": "INDEX", + "path": None, + "reason": "PENDING_CHANGES", + "fallback": "SEARCH_SOURCE", + "observed_sha256": None, + }]) + + def test_unclosed_source_fence_is_not_verified(self) -> None: + response = ( + "**`src/a.py`** — A(function)\n\n" + "```python\n" + "def A():\n" + " return 'ordinary source'\n" + ) + + result = self.classify_response(response) + + self.assertEqual(result["classification"], "NOT_VERIFIED") + self.assertIn("fence", str(result["error"]).lower()) + + def test_warning_words_in_ordinary_prose_do_not_change_freshness(self) -> None: + response = ( + "The pending request emits a warning when its cached value becomes stale.\n" + "This sentence is returned program prose, not CodeGraph freshness framing.\n" + ) + + self.assertEqual(self.classify_response(response)["classification"], "NONE") + def test_warning_words_inside_verbatim_source_do_not_change_freshness(self) -> None: response = ( "**`src/a.py`** — A(function)\n\n" @@ -4225,6 +4541,41 @@ def test_merge_freshness_uses_conservative_status_and_ordered_evidence(self) -> self.assertEqual(result["stale_points"], response["stale_points"]) self.assertEqual(result["status_response_sha256"], "status-sha") + def test_merge_freshness_keeps_explicit_stale_above_verification_failure(self) -> None: + merger = getattr(self.adapter_module(), "merge_freshness", None) + self.assertTrue(callable(merger), "CodeGraph freshness merger must exist") + status = { + "status": "NOT_VERIFIED", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["STATUS_JSON"], + "stale_points": [{ + "scope": "INDEX", + "path": None, + "reason": "STATUS_UNREADABLE", + "fallback": "SEARCH_SOURCE", + "observed_sha256": None, + }], + "status_response_sha256": None, + "error": "status JSON was unreadable", + "needs_sync": False, + "pending_changes": None, + } + response = self.classify_response( + "⚠️ Some files referenced below were edited since the last index sync — " + "their codegraph entries may be stale:\n" + " - src/deleted.py (edited 800ms ago, pending sync)\n" + "For accurate content of those specific files, Read them directly.\n" + ) + + result = merger(status, response) + + self.assertEqual(result["status"], "INDEX_STALE") + self.assertEqual( + {point["reason"] for point in result["stale_points"]}, + {"STATUS_UNREADABLE", "PENDING_SYNC"}, + ) + self.assertEqual(result["error"], "status JSON was unreadable") + def test_none_response_does_not_upgrade_unverified_status(self) -> None: merger = getattr(self.adapter_module(), "merge_freshness", None) self.assertTrue(callable(merger), "CodeGraph freshness merger must exist") From b823f1d9664867865e0110e4e928cbc0214316e4 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 19:21:13 +0800 Subject: [PATCH 28/28] fix: preserve misplaced worktree mismatch evidence --- scripts/internal/codegraph_adapter.py | 7 +++- tests/test_codegraph.py | 60 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/scripts/internal/codegraph_adapter.py b/scripts/internal/codegraph_adapter.py index 2be57af..5096a12 100644 --- a/scripts/internal/codegraph_adapter.py +++ b/scripts/internal/codegraph_adapter.py @@ -330,10 +330,15 @@ def classify_response( # prose. This retains the legacy wrapped-banner safety behavior. for line_index in range(index, len(framing)): line = framing[line_index] + worktree_mismatch = line.startswith(_WORKTREE_BANNER_PREFIX) + if worktree_mismatch: + _append_unique_point( + stale_points, _index_point("WORKTREE_MISMATCH") + ) if ( _partial_header_length(framing, line_index) or line.startswith(_DISABLED_BANNER_PREFIX) - or line.startswith(_WORKTREE_BANNER_PREFIX) + or worktree_mismatch ): parse_error = parse_error or "misplaced CodeGraph freshness banner" diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index a4b828d..22594ae 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -769,6 +769,66 @@ def runner( result["bundle"]["delivery"]["reason"], "WORKTREE_MISMATCH" ) + def test_proxy_discards_misplaced_worktree_mismatch_banner(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + response = ( + "Ordinary graph content appears before the safety framing.\n\n" + "⚠ CodeGraph results below come from a different git worktree " + "(/tmp/main), not where you're working (/tmp/wt) — they may reflect " + "another branch.\n\n" + "graph bytes must be discarded\n" + ) + responses = [ + completed(healthy_status(self.repo)), + completed(response), + completed(healthy_status(self.repo)), + ] + calls: list[list[str]] = [] + + def runner( + command: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: + calls.append(command) + return responses.pop(0) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + response_path = ( + self.repo + / ".polaris/tasks/TASK-0001/runtime/code-intelligence/planning" + / "CIQ-001.response.txt" + ) + classification = result["bundle"]["response_classification"] + self.assertEqual( + [item[1] for item in calls], ["status", "explore", "status"] + ) + self.assertEqual(classification["classification"], "NOT_VERIFIED") + self.assertEqual( + {point["reason"] for point in classification["stale_points"]}, + {"WORKTREE_MISMATCH", "STATUS_UNREADABLE"}, + ) + self.assertIn("misplaced", classification["error"]) + self.assertIsNone(result["response"]) + self.assertIsNone(result["bundle"]["response_path"]) + self.assertFalse(response_path.exists()) + self.assertEqual( + result["bundle"]["post_query_status"]["status"], + "CURRENT_AT_CHECK", + ) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual(result["bundle"]["delivery"]["usage"], "NAVIGATION_ONLY") + self.assertEqual( + result["bundle"]["delivery"]["reason"], "WORKTREE_MISMATCH" + ) + def test_proxy_post_query_unavailable_cannot_become_current(self) -> None: self.qualify_task() marker = self.repo / ".codegraph"