refactor(file_state): PR 11 — kind_codes 3중 복제 통합 + decision 매핑 table

PYTHON_THINNING_PLAN §5 PR 11. Wave 1.5 amend §C single-source-of-truth
양방향 보강 정합. amend A1 (사용자 보이는 문자열 = Python single source) 보존.

변경:
- ``_KIND_CODES`` (4 entries) — module-level constant. RemoteFileKind →
  Rust REMOTE_KIND_* 매핑. 기존 3중 복제 (open guard / reload / save) 제거.
- ``_metadata_to_tuple(meta)`` helper — Rust ABI Optional-tuple 인코딩 단일화.
- ``_OPEN_GUARD_REASON_MAP`` (4 entries) — reason_code → enum 단일 lookup.
- ``_RELOAD_RECOMMENDATION_MAP`` (4 entries) — reload_code → enum 단일 lookup.
- ``_SAVE_CONFLICT_SPECS`` (5 entries) — decision_code → (kind, message,
  reload_hint) tuple. 기존 6단계 if-chain + inline SaveConflict 생성을
  단일 dict + 1줄 unpack 으로 축약. (decision_code 0 / OK 는 inline.)
- ``evaluate_save_file`` 본체 ~50 LOC → ~15 LOC.
- ``open_guard_reason_for_remote_metadata`` 본체 ~12 LOC → ~6 LOC.
- ``reload_recommendation`` 본체 ~30 LOC → ~6 LOC.

amend A1 사용자 문자열 정책:
- ``_SAVE_CONFLICT_SPECS`` 안의 5종 message string 그대로 보존 — Python이
  사용자 보이는 문자열의 single source. Rust ABI는 decision_code (int) 만
  반환 (Lint #4 정합).

테스트: PR 10 parity 33 + sublime/tests 전체 1294 그린.
pyright (file_state.py CLI): 0 errors.

boundary-claim:
  removes:
    - sublime/sessions/file_state.py:open_guard kind_codes/reason_map (~12 LOC)
    - sublime/sessions/file_state.py:reload kind_codes/mapping (~30 LOC)
    - sublime/sessions/file_state.py:save kind_codes + 6 if-branches (~70 LOC)
  delete-count: ~85
  rust-additions: 0  (Python-only — single-source-of-truth 정합)
  ban-list: 'amend §C 양방향 + amend A1 사용자 문자열 Python 보존'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 00:24:46 +09:00
parent b47f7eba3b
commit 859c413872
2 changed files with 100 additions and 124 deletions

View File

@@ -13,8 +13,8 @@
> | PR 5.5 | ✅ | `c29e3f5` | diagnostics 청산은 *이미 일원화됨* — 인벤토리 정정 (no-op) |
> | PR 8 | ✅ | `32fc8ef` | `derive_venv_name` heuristic → `sessions_native::interpreter_probe` (~40 LOC) |
> | PR 9 | ✅ no-op | `c19aaae` | tree/list 잔여 호출자 0건 확인 — PR 2가 이미 일원화 완료 |
> | PR 10 | ✅ | (TBD) | file_state parity tests +25 (총 33 시나리오, amend §D paired) |
> | PR 11 | ⏭ pending | — | file_state kind_codes 통합 + decision 매핑 lookup table (~120 LOC) |
> | PR 10 | ✅ | `b47f7eb` | file_state parity tests +26 (총 33 시나리오, amend §D paired) |
> | PR 11 | ✅ | (TBD) | file_state kind_codes 3중 복제 통합 + decision 매핑 lookup table (~80 LOC 감소) |
> | PR 12 | ⏭ pending | — | eager_hydrate parity tests |
> | **PR 13a** | ⏭ Wave 2 게이트 | — | envelope 스펙 + ref impl + parity (PR-A 본체 가능) |
> | PR 13b | ⏭ Wave 2 | — | envelope 완전 구현 (취소·deadline·우선순위) |

View File

@@ -3,7 +3,7 @@
from dataclasses import dataclass
from enum import Enum
from pathlib import Path, PurePosixPath
from typing import Optional, Sequence, Tuple
from typing import Mapping, Optional, Sequence, Tuple
from ._rust_ffi import SessionsNativeLibraryError
from ._rust_ffi import (
@@ -32,6 +32,33 @@ from ._rust_ffi import (
)
from .remote import RemoteFileKind, RemoteFileMetadata
# ---------------------------------------------------------------------------
# Single source of truth for kind_code mapping (Wave 1.5 amend §C / PR 11).
# Mirrors ``rust/crates/sessions_native/src/lib.rs`` REMOTE_KIND_* constants.
# ``OTHER`` falls through to ``3`` so the Rust ABI receives a known sentinel.
# ---------------------------------------------------------------------------
_KIND_CODES: Mapping[RemoteFileKind, int] = {
RemoteFileKind.REGULAR_FILE: 0,
RemoteFileKind.DIRECTORY: 1,
RemoteFileKind.SYMLINK: 2,
RemoteFileKind.OTHER: 3,
}
def _metadata_to_tuple(
meta: Optional[RemoteFileMetadata],
) -> Optional[Tuple[int, int, int]]:
"""Pack ``(mtime_ns, size_bytes, kind_code)`` for the Rust decision ABIs.
Returns ``None`` so callers can pass it straight through to
``rust_reload_recommendation_code`` / ``rust_save_decision_code`` whose
Optional-tuple branch encodes "no metadata available".
"""
if meta is None:
return None
return (meta.mtime_ns, meta.size_bytes, _KIND_CODES.get(meta.kind, 3))
class RemotePathMappingError(ValueError):
"""Raised when a remote path cannot be mapped safely to the local cache."""
@@ -214,6 +241,16 @@ class UnsupportedOpenReason(Enum):
ZERO_BYTE_READ_NOT_ALLOWED = "zero_byte_read_not_allowed"
# Single source of truth for open-guard reason codes (Wave 1.5 amend §C).
# Mirrors ``rust/crates/sessions_native/src/lib.rs`` OPEN_REASON_* constants.
_OPEN_GUARD_REASON_MAP: Mapping[int, Optional[UnsupportedOpenReason]] = {
0: None,
1: UnsupportedOpenReason.FILE_TOO_LARGE,
2: UnsupportedOpenReason.UNSUPPORTED_REMOTE_KIND,
3: UnsupportedOpenReason.ZERO_BYTE_READ_NOT_ALLOWED,
}
class CacheInvalidationTrigger(Enum):
"""Catalog of events that should drop or refresh cached bytes."""
@@ -256,6 +293,16 @@ class ReloadRecommendation(Enum):
REMOTE_MISSING = "remote_missing"
# Single source of truth for reload recommendation codes (Wave 1.5 amend §C).
# Mirrors ``rust/crates/sessions_native/src/lib.rs`` RELOAD_* constants.
_RELOAD_RECOMMENDATION_MAP: Mapping[int, ReloadRecommendation] = {
0: ReloadRecommendation.NO_ACTION_NEEDED,
1: ReloadRecommendation.RECOMMEND_RELOAD,
2: ReloadRecommendation.RECOMMEND_REVIEW_CONFLICT,
3: ReloadRecommendation.REMOTE_MISSING,
}
@dataclass(frozen=True)
class FileOpenGuardrails:
"""Hard limits for MVP open behavior.
@@ -286,25 +333,13 @@ def open_guard_reason_for_remote_metadata(
Raises:
None.
"""
kind_codes = {
RemoteFileKind.REGULAR_FILE: 0,
RemoteFileKind.DIRECTORY: 1,
RemoteFileKind.SYMLINK: 2,
}
reason_map = {
0: None,
1: UnsupportedOpenReason.FILE_TOO_LARGE,
2: UnsupportedOpenReason.UNSUPPORTED_REMOTE_KIND,
3: UnsupportedOpenReason.ZERO_BYTE_READ_NOT_ALLOWED,
}
kind_code = kind_codes.get(meta.kind, 0)
reason_code = rust_open_guard_reason_code(
remote_kind_code=kind_code,
remote_kind_code=_KIND_CODES.get(meta.kind, 0),
size_bytes=meta.size_bytes,
max_open_bytes=limits.max_open_bytes,
allow_empty_files=limits.allow_empty_files,
)
return reason_map.get(reason_code)
return _OPEN_GUARD_REASON_MAP.get(reason_code)
def is_likely_binary_from_head(content_head: bytes) -> bool:
@@ -363,42 +398,12 @@ def reload_recommendation(
Raises:
None.
"""
kind_codes = {
RemoteFileKind.REGULAR_FILE: 0,
RemoteFileKind.DIRECTORY: 1,
RemoteFileKind.SYMLINK: 2,
RemoteFileKind.OTHER: 3,
}
baseline_tuple = (
(
baseline.mtime_ns,
baseline.size_bytes,
kind_codes.get(baseline.kind, 3),
)
if baseline is not None
else None
)
current_tuple = (
(
current.mtime_ns,
current.size_bytes,
kind_codes.get(current.kind, 3),
)
if current is not None
else None
)
code = rust_reload_recommendation_code(
had_metadata_at_open=had_metadata_at_open,
baseline=baseline_tuple,
current=current_tuple,
baseline=_metadata_to_tuple(baseline),
current=_metadata_to_tuple(current),
)
mapping = {
0: ReloadRecommendation.NO_ACTION_NEEDED,
1: ReloadRecommendation.RECOMMEND_RELOAD,
2: ReloadRecommendation.RECOMMEND_REVIEW_CONFLICT,
3: ReloadRecommendation.REMOTE_MISSING,
}
return mapping[code]
return _RELOAD_RECOMMENDATION_MAP[code]
def default_source_of_truth_policy() -> SourceOfTruthPolicy:
@@ -460,6 +465,39 @@ class SaveConflictKind(Enum):
BASELINE_UNKNOWN = "baseline_unknown"
# Single source of truth for save decision codes (Wave 1.5 amend §C / amend A1
# user-visible strings = Python single source).
# Mirrors ``rust/crates/sessions_native/src/lib.rs`` SAVE_DECISION_* constants.
# ``code 0`` (OK) is handled inline in ``evaluate_save_file`` without a spec.
_SAVE_CONFLICT_SPECS: Mapping[int, Tuple[SaveConflictKind, str, ReloadChoice]] = {
1: (
SaveConflictKind.BASELINE_UNKNOWN,
"Cannot save safely without metadata captured at open.",
ReloadChoice.CANCEL,
),
2: (
SaveConflictKind.REMOTE_FILE_MISSING,
"Remote file disappeared before save; choose reload or cancel.",
ReloadChoice.CANCEL,
),
3: (
SaveConflictKind.REMOTE_PATH_IS_DIRECTORY,
"Remote path is a directory; refusing save.",
ReloadChoice.CANCEL,
),
4: (
SaveConflictKind.REMOTE_PATH_IS_SYMLINK,
"Remote path is a symlink; refusing blind save.",
ReloadChoice.CANCEL,
),
5: (
SaveConflictKind.REMOTE_METADATA_CHANGED,
"Remote file changed since local copy; choose overwrite or reload.",
ReloadChoice.KEEP_LOCAL_AND_OVERWRITE_REMOTE,
),
}
@dataclass(frozen=True)
class OpenFileRequest:
"""Parameters needed to stage a remote file into the local cache.
@@ -591,81 +629,19 @@ def evaluate_save_file(request: SaveFileRequest) -> SaveFileResult:
Raises:
None.
"""
kind_codes = {
RemoteFileKind.REGULAR_FILE: 0,
RemoteFileKind.DIRECTORY: 1,
RemoteFileKind.SYMLINK: 2,
RemoteFileKind.OTHER: 3,
}
baseline_tuple = (
(
request.baseline_remote_metadata.mtime_ns,
request.baseline_remote_metadata.size_bytes,
kind_codes.get(request.baseline_remote_metadata.kind, 3),
)
if request.baseline_remote_metadata is not None
else None
)
candidate_tuple = (
(
request.candidate_remote_metadata.mtime_ns,
request.candidate_remote_metadata.size_bytes,
kind_codes.get(request.candidate_remote_metadata.kind, 3),
)
if request.candidate_remote_metadata is not None
else None
)
decision_code = rust_save_decision_code(
baseline=baseline_tuple,
candidate=candidate_tuple,
baseline=_metadata_to_tuple(request.baseline_remote_metadata),
candidate=_metadata_to_tuple(request.candidate_remote_metadata),
)
if decision_code == 0:
return SaveFileResult(outcome=SaveOutcome.OK)
if decision_code == 1:
return SaveFileResult(
outcome=SaveOutcome.CONFLICT,
conflict=SaveConflict(
kind=SaveConflictKind.BASELINE_UNKNOWN,
message="Cannot save safely without metadata captured at open.",
reload_choice_hint=ReloadChoice.CANCEL,
),
)
if decision_code == 2:
return SaveFileResult(
outcome=SaveOutcome.CONFLICT,
conflict=SaveConflict(
kind=SaveConflictKind.REMOTE_FILE_MISSING,
message="Remote file disappeared before save; choose reload or cancel.",
reload_choice_hint=ReloadChoice.CANCEL,
),
)
if decision_code == 3:
return SaveFileResult(
outcome=SaveOutcome.CONFLICT,
conflict=SaveConflict(
kind=SaveConflictKind.REMOTE_PATH_IS_DIRECTORY,
message="Remote path is a directory; refusing save.",
reload_choice_hint=ReloadChoice.CANCEL,
),
)
if decision_code == 4:
return SaveFileResult(
outcome=SaveOutcome.CONFLICT,
conflict=SaveConflict(
kind=SaveConflictKind.REMOTE_PATH_IS_SYMLINK,
message="Remote path is a symlink; refusing blind save.",
reload_choice_hint=ReloadChoice.CANCEL,
),
)
if decision_code == 5:
return SaveFileResult(
outcome=SaveOutcome.CONFLICT,
conflict=SaveConflict(
kind=SaveConflictKind.REMOTE_METADATA_CHANGED,
message=(
"Remote file changed since local copy; choose overwrite or reload."
),
reload_choice_hint=ReloadChoice.KEEP_LOCAL_AND_OVERWRITE_REMOTE,
),
)
raise ValueError("unexpected save decision code: {}".format(decision_code))
spec = _SAVE_CONFLICT_SPECS.get(decision_code)
if spec is None:
raise ValueError("unexpected save decision code: {}".format(decision_code))
kind, message, reload_hint = spec
return SaveFileResult(
outcome=SaveOutcome.CONFLICT,
conflict=SaveConflict(
kind=kind, message=message, reload_choice_hint=reload_hint
),
)