diff --git a/experiments/core/selection_test.cpp b/experiments/core/selection_test.cpp index 36e676d2..51f09fd0 100644 --- a/experiments/core/selection_test.cpp +++ b/experiments/core/selection_test.cpp @@ -188,7 +188,7 @@ static void empty_stale_and_capacity_boundaries() { } static std::vector observed_discards; -static void observe_discard(const char* reason) { observed_discards.push_back(reason ? reason : ""); } +static void observe_discard(const char* reason, size_t) { observed_discards.push_back(reason ? reason : ""); } static void discard_reasons_are_reported() { Parser p(2, 6, 2); p.feed("ONETWO\r\nTHREE"); @@ -222,4 +222,4 @@ int main() { discard_reasons_are_reported(); std::cout << "selection contracts passed (8 independent groups)\n"; } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; } -} \ No newline at end of file +} diff --git a/experiments/core/text_selection.hpp b/experiments/core/text_selection.hpp index 3f136c54..21ae9b1d 100644 --- a/experiments/core/text_selection.hpp +++ b/experiments/core/text_selection.hpp @@ -14,7 +14,7 @@ class TextSelection { // Diagnostics only: never changes behaviour. A consumer may install a hook to // learn why a reconciled selection was discarded; the hook runs synchronously // on the caller's thread and must not reenter selection or the document. - using DiscardObserver = void (*)(const char* reason); + using DiscardObserver = void (*)(const char* reason, size_t row); static void set_discard_observer(DiscardObserver observer) { discard_observer_ = observer; } private: static inline DiscardObserver discard_observer_ = nullptr; @@ -40,8 +40,8 @@ class TextSelection { return c >= 0x80 || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' ? 0 : 2; } - bool invalidate(const char* reason) { - if (discard_observer_) discard_observer_(reason); + bool invalidate(const char* reason, size_t row = 0) { + if (discard_observer_) discard_observer_(reason, row); clear(); invalidated_ = true; return false; } public: @@ -77,28 +77,28 @@ class TextSelection { bool reconcile(const TextDocument& document) { if (!range_) return !invalidated_; if (range_->begin.epoch != document.epoch() || range_->end.epoch != document.epoch()) - return invalidate("epoch-mismatch"); + return invalidate("epoch-mismatch", 0); const auto begin = document.index_of(range_->begin.line_id); - if (!begin || saved_.size() > document.size() - *begin) return invalidate("anchor-missing-or-shrunk"); + if (!begin || saved_.size() > document.size() - *begin) return invalidate("anchor-missing-or-shrunk", 0); for (size_t i = 0; i < saved_.size(); ++i) { const auto meta = document.meta(*begin + i); auto& saved = saved_[i]; if (meta.id != saved.projection.id && (i + 1 != saved_.size() || range_->end.column != 0 || - !saved.projection.text.empty() || !saved.projection.spans.empty())) return invalidate("row-identity-replaced"); + !saved.projection.text.empty() || !saved.projection.spans.empty())) return invalidate("row-identity-replaced", i); if (meta.id != saved.projection.id || meta.version != saved.projection.version || meta.joins_next != saved.joins_next) { auto current = document.line(*begin + i); // The last row at column zero is an excluded half-open // endpoint. Its own following break is outside the copy. const bool compare_boundary = i + 1 != saved_.size() || range_->end.column != 0; - if (!same_text(saved, current, compare_boundary)) return invalidate("row-text-changed"); + if (!same_text(saved, current, compare_boundary)) return invalidate("row-text-changed", i); saved = std::move(current); } } const auto first = document.position(*begin, range_->begin.column); const auto last = document.position(*begin + saved_.size() - 1, range_->end.column); - if (!first || !last) return invalidate("endpoint-unavailable"); + if (!first || !last) return invalidate("endpoint-unavailable", 0); range_ = TextRange{*first, *last}; return true; } bool select_word(const TextDocument& document, size_t index, size_t column) { diff --git a/experiments/shell/README.md b/experiments/shell/README.md index 5caaf33d..047fe115 100644 --- a/experiments/shell/README.md +++ b/experiments/shell/README.md @@ -61,7 +61,7 @@ R1 的 [#12–#17 限定合同](../../docs/milestones/R1-local-text.md)和 [#18 ## 选区丢弃诊断 -几何/DPI/缩放变化后若选区被 `TextSelection::reconcile` 丢弃,可用环境变量 `TERMFORGE_SELECTION_DIAG=` 打开一次性诊断:每次丢弃写入一行 `{"schema":"termforge-selection-discard-v1","reason":"..."}`,`reason` 取 `epoch-mismatch`、`anchor-missing-or-shrunk`、`row-identity-replaced`、`row-text-changed`、`endpoint-unavailable` 之一。未设置该变量时不写任何文件、行为不变;`experiments/shell/run.py` 的 `execute()` 会自动把该变量指向本次场景产物目录下的 `selection-discard.jsonl`,因此失败时该文件随 `build/test-artifacts/` 一起保留。`selection_core` 里新增的分组只验证观察器能收到分支名。 +几何/DPI/缩放变化后若选区被 `TextSelection::reconcile` 丢弃,可用环境变量 `TERMFORGE_SELECTION_DIAG=` 打开一次性诊断:每次丢弃写入一行 `{"schema":"termforge-selection-discard-v1","reason":"...","row":N}`(`row` 是发生不一致的选区行序号,`0` 表示与具体行无关的分支),`reason` 取 `epoch-mismatch`、`anchor-missing-or-shrunk`、`row-identity-replaced`、`row-text-changed`、`endpoint-unavailable` 之一。未设置该变量时不写任何文件、行为不变;`experiments/shell/run.py` 的 `execute()` 会自动把该变量指向本次场景产物目录下的 `selection-discard.jsonl`,因此失败时该文件随 `build/test-artifacts/` 一起保留。`selection_core` 里新增的分组只验证观察器能收到分支名。 ## 所有权与调度 diff --git a/experiments/shell/shell.cpp b/experiments/shell/shell.cpp index f4b84c80..06396a2c 100644 --- a/experiments/shell/shell.cpp +++ b/experiments/shell/shell.cpp @@ -40,12 +40,13 @@ std::string selection_diag_path() { return value ? std::string(value) : std::string{}; #endif } -void selection_discard_diagnostic(const char* reason) { +void selection_discard_diagnostic(const char* reason, size_t row) { static const std::string path = selection_diag_path(); if (path.empty()) return; std::ofstream stream(path, std::ios::app); if (!stream) return; - stream << "{\"schema\":\"termforge-selection-discard-v1\",\"reason\":\"" << reason << "\"}\n"; + stream << "{\"schema\":\"termforge-selection-discard-v1\",\"reason\":\"" << reason + << "\",\"row\":" << row << "}\n"; } void install_selection_diagnostic_once() { static std::once_flag once;