From fd1210e557e27cb4bea64e8b4d481c118c211b5b Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Tue, 28 Jul 2026 22:43:10 +0800 Subject: [PATCH] fix: close repository audit findings Complete task002 through task016 across cache correctness, editor lifecycle, C boundaries, build and release governance, dictionary provenance, performance, interactive workflows, tests, documentation, and final cleanup. --- .github/workflows/ci.yml | 85 ++- .gitignore | 1 - .phrase/docs/CHANGE.md | 42 ++ .phrase/docs/ISSUES.md | 12 + .phrase/modules/agent-browser.md | 150 +++++ .phrase/modules/code-simplier.md | 51 ++ .phrase/modules/copywriting.md | 59 ++ .phrase/modules/linus_coding.md | 70 +++ .phrase/modules/pr_faq.md | 64 ++ .../DONE-PLAN.md | 7 + .../DONE-TASK-001.md | 20 + .../change_maintenance_20260126.md | 9 + .../change_log.md | 27 + .../plan_doc_improvement.md | 17 + .../spec_doc_improvement.md | 30 + .../task_doc_improvement.md | 8 + .../phase-kp-overhaul-20260726/HANDOFF.md | 190 ++++++ .../change_auto_spacing_signature_20260728.md | 38 ++ .../change_c_api_contract_20260728.md | 36 ++ .../change_c_build_boundary_20260728.md | 39 ++ .../change_cache_signature_20260728.md | 48 ++ .../change_core_rule_ownership_20260728.md | 33 + .../change_dictionary_governance_20260728.md | 57 ++ .../change_final_cleanup_review_20260728.md | 53 ++ .../change_gui_verification_20260728.md | 32 + .../change_hot_loop_performance_20260728.md | 39 ++ .../change_integration_lifecycle_20260728.md | 37 ++ .../change_interactive_protection_20260728.md | 36 ++ ...e_property_sensitive_fast_path_20260728.md | 45 ++ .../change_release_governance_20260728.md | 43 ++ .../change_repository_audit_20260728.md | 39 ++ .../change_save_transaction_20260728.md | 38 ++ .../change_test_isolation_20260728.md | 31 + .../issue_repository_audit_20260728.md | 273 +++++++++ .../plan_auto_spacing_signature_20260728.md | 28 + .../plan_c_api_contract_20260728.md | 31 + .../plan_c_build_boundary_20260728.md | 29 + .../plan_cache_signature_20260728.md | 42 ++ .../plan_core_rule_ownership_20260728.md | 42 ++ .../plan_dictionary_governance_20260728.md | 44 ++ .../plan_final_cleanup_review_20260728.md | 87 +++ .../plan_gui_verification_20260728.md | 27 + .../plan_hot_loop_performance_20260728.md | 44 ++ .../plan_integration_lifecycle_20260728.md | 30 + .../plan_interactive_protection_20260728.md | 36 ++ ...n_property_sensitive_fast_path_20260728.md | 36 ++ .../plan_release_governance_20260728.md | 40 ++ .../plan_repository_audit_20260728.md | 48 ++ .../plan_save_transaction_20260728.md | 30 + .../plan_test_isolation_20260728.md | 29 + .../task_repository_audit_20260728.md | 227 +++++++ .phrase/phases/phase-p1p2-20260726/NOTES.md | 82 +++ CHANGELOG.md | 87 +++ CONTRIBUTING.md | 15 +- DEVELOPER.md | 104 +++- DEVELOPER_ZH.md | 88 ++- Docs/RELEASING.md | 56 ++ Docs/REPOSITORY_AUDIT_20260728.md | 567 ++++++++++++++++++ dictionaries/LICENSES.md | 37 ++ dictionaries/MANIFEST.tsv | 54 ++ dictionaries/hyph_sa_IN.dic | 116 ---- dictionaries/update.sh | 116 +++- ekp-hyphen.el | 70 ++- ekp-region.el | 266 +++++--- ekp-utils.el | 147 ++--- ekp.el | 207 +++---- ekp_c/Makefile | 24 +- ekp_c/README.md | 30 +- ekp_c/ekp.c | 229 ++++++- ekp_c/ekp_kp.c | 52 +- ekp_c/ekp_module.h | 4 +- .../20260728-buffer-integration-ownership.md | 63 ++ postmortem/20260728-c-boundary-contract.md | 66 ++ .../20260728-c-build-profile-ownership.md | 57 ++ postmortem/20260728-core-rule-ownership.md | 42 ++ postmortem/20260728-dictionary-contract.md | 54 ++ postmortem/20260728-dp-cache-signature.md | 66 ++ ...20260728-gui-verification-exit-contract.md | 52 ++ postmortem/20260728-linear-text-builders.md | 42 ++ ...20260728-nonmutating-save-serialization.md | 56 ++ .../20260728-paragraph-fast-path-identity.md | 55 ++ .../20260728-paragraph-spacing-signature.md | 54 ++ postmortem/20260728-public-filter-dispatch.md | 63 ++ postmortem/20260728-release-governance.md | 43 ++ .../20260728-session-local-protection.md | 40 ++ postmortem/20260728-test-state-isolation.md | 55 ++ readme.md | 80 ++- readme_zh.md | 72 ++- tests/check-dictionaries.sh | 88 +++ tests/check-release.sh | 65 ++ tests/ekp-bench.el | 20 + tests/ekp-c-tests.el | 105 ++++ tests/ekp-gui-tests.el | 45 ++ tests/ekp-gui-verify.el | 46 +- tests/ekp-region-tests.el | 229 +++++++ tests/ekp-tests.el | 327 +++++++++- tests/run-tests-isolated.sh | 28 + tests/run-tests-random-order.el | 37 ++ tests/run-tests.sh | 30 +- 99 files changed, 6340 insertions(+), 600 deletions(-) create mode 100644 .phrase/docs/CHANGE.md create mode 100644 .phrase/docs/ISSUES.md create mode 100644 .phrase/modules/agent-browser.md create mode 100644 .phrase/modules/code-simplier.md create mode 100644 .phrase/modules/copywriting.md create mode 100644 .phrase/modules/linus_coding.md create mode 100644 .phrase/modules/pr_faq.md create mode 100644 .phrase/phases/DONE-phase-maintenance-20260126/DONE-PLAN.md create mode 100644 .phrase/phases/DONE-phase-maintenance-20260126/DONE-TASK-001.md create mode 100644 .phrase/phases/DONE-phase-maintenance-20260126/change_maintenance_20260126.md create mode 100644 .phrase/phases/phase-doc-improvement-20260125/change_log.md create mode 100644 .phrase/phases/phase-doc-improvement-20260125/plan_doc_improvement.md create mode 100644 .phrase/phases/phase-doc-improvement-20260125/spec_doc_improvement.md create mode 100644 .phrase/phases/phase-doc-improvement-20260125/task_doc_improvement.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/HANDOFF.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_auto_spacing_signature_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_c_api_contract_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_c_build_boundary_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_cache_signature_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_core_rule_ownership_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_dictionary_governance_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_final_cleanup_review_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_gui_verification_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_hot_loop_performance_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_integration_lifecycle_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_interactive_protection_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_property_sensitive_fast_path_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_release_governance_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_repository_audit_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_save_transaction_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/change_test_isolation_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/issue_repository_audit_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/plan_auto_spacing_signature_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/plan_c_api_contract_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/plan_c_build_boundary_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/plan_cache_signature_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/plan_core_rule_ownership_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/plan_dictionary_governance_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/plan_final_cleanup_review_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/plan_gui_verification_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/plan_hot_loop_performance_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/plan_integration_lifecycle_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/plan_interactive_protection_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/plan_property_sensitive_fast_path_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/plan_release_governance_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/plan_repository_audit_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/plan_save_transaction_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/plan_test_isolation_20260728.md create mode 100644 .phrase/phases/phase-kp-overhaul-20260726/task_repository_audit_20260728.md create mode 100644 .phrase/phases/phase-p1p2-20260726/NOTES.md create mode 100644 Docs/RELEASING.md create mode 100644 Docs/REPOSITORY_AUDIT_20260728.md create mode 100644 dictionaries/LICENSES.md create mode 100644 dictionaries/MANIFEST.tsv delete mode 100644 dictionaries/hyph_sa_IN.dic mode change 100644 => 100755 dictionaries/update.sh create mode 100644 postmortem/20260728-buffer-integration-ownership.md create mode 100644 postmortem/20260728-c-boundary-contract.md create mode 100644 postmortem/20260728-c-build-profile-ownership.md create mode 100644 postmortem/20260728-core-rule-ownership.md create mode 100644 postmortem/20260728-dictionary-contract.md create mode 100644 postmortem/20260728-dp-cache-signature.md create mode 100644 postmortem/20260728-gui-verification-exit-contract.md create mode 100644 postmortem/20260728-linear-text-builders.md create mode 100644 postmortem/20260728-nonmutating-save-serialization.md create mode 100644 postmortem/20260728-paragraph-fast-path-identity.md create mode 100644 postmortem/20260728-paragraph-spacing-signature.md create mode 100644 postmortem/20260728-public-filter-dispatch.md create mode 100644 postmortem/20260728-release-governance.md create mode 100644 postmortem/20260728-session-local-protection.md create mode 100644 postmortem/20260728-test-state-isolation.md create mode 100755 tests/check-dictionaries.sh create mode 100755 tests/check-release.sh create mode 100644 tests/ekp-c-tests.el create mode 100644 tests/ekp-gui-tests.el create mode 100755 tests/run-tests-isolated.sh create mode 100644 tests/run-tests-random-order.el diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c591381..b76c796 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,10 @@ jobs: # stable; snapshot is advisory (allowed to fail) emacs-version: ['29.1', '30.1', 'snapshot'] steps: - - uses: actions/checkout@v4 - - uses: purcell/setup-emacs@master + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: purcell/setup-emacs@bdc64dc730ae1fcba200bfd52cb1b4cf6159cbe5 # v8.0 with: version: ${{ matrix.emacs-version }} - name: Byte-compile (warnings are errors) @@ -31,28 +33,32 @@ jobs: -f batch-byte-compile ekp.el ekp-utils.el ekp-hyphen.el ekp-region.el - name: Run ERT suite (C-module tests auto-skip) run: tests/run-tests.sh emacs + - name: Run ERT suite in permuted order + if: matrix.emacs-version == '30.1' + env: + EKP_TEST_SEED: '20260728' + run: tests/run-tests.sh emacs --random-order lint: name: package-lint + checkdoc runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: purcell/setup-emacs@master + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: purcell/setup-emacs@bdc64dc730ae1fcba200bfd52cb1b4cf6159cbe5 # v8.0 with: version: '30.1' - - name: Install package-lint - run: | - emacs -Q --batch \ - --eval "(require 'package)" \ - --eval "(add-to-list 'package-archives '(\"melpa\" . \"https://melpa.org/packages/\") t)" \ - --eval "(package-initialize)" \ - --eval "(package-refresh-contents)" \ - --eval "(package-install 'package-lint)" + - name: Check out pinned package-lint + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + repository: purcell/package-lint + ref: 35996f478d81e51dae4fa30d051f741895d07399 + path: .ci/package-lint + persist-credentials: false - name: package-lint run: | - emacs -Q --batch \ - --eval "(require 'package)" \ - --eval "(package-initialize)" \ + emacs -Q --batch -L .ci/package-lint \ --eval "(require 'package-lint)" \ --eval "(setq package-lint-main-file \"ekp.el\")" \ -f package-lint-batch-and-exit \ @@ -65,12 +71,20 @@ jobs: (checkdoc-file f))" \ 2>&1 | tee checkdoc.log test ! -s checkdoc.log + - name: Release invariants + run: tests/check-release.sh + - name: Dictionary manifest and pinned source + run: | + tests/check-dictionaries.sh + dictionaries/update.sh check test-c-module: name: full suite + C parity (apt Emacs) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Install Emacs run: sudo apt-get update && sudo apt-get install -y emacs-nox - name: Ensure emacs-module.h is findable @@ -92,7 +106,9 @@ jobs: name: C module under ASan/UBSan runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Install Emacs run: sudo apt-get update && sudo apt-get install -y emacs-nox gcc - name: Ensure emacs-module.h is findable @@ -104,7 +120,7 @@ jobs: sudo install -m 644 "$HDR" /usr/local/include/ fi - name: Build C module with sanitizers - run: make -C ekp_c DEBUG=1 + run: make -C ekp_c PROFILE=sanitize - name: Property fuzz under ASan/UBSan # Emacs itself is not ASan-instrumented: preload the runtime and # skip leak checking (the Emacs process "leaks" by design). @@ -117,8 +133,10 @@ jobs: name: macOS (dylib build + full suite) runs-on: macos-latest steps: - - uses: actions/checkout@v4 - - uses: purcell/setup-emacs@master + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: purcell/setup-emacs@bdc64dc730ae1fcba200bfd52cb1b4cf6159cbe5 # v8.0 with: version: '30.1' - name: Build C module @@ -127,3 +145,30 @@ jobs: run: tests/run-tests.sh emacs - name: Property fuzz (300 cases, C vs elisp parity) run: emacs -Q --batch -L . -l tests/ekp-fuzz.el + + test-windows: + name: Windows Elisp baseline (Emacs 30.1) + runs-on: windows-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - name: Install Emacs 30.1 + shell: pwsh + run: choco install emacs --version=30.1.0 --yes --no-progress + - name: Byte-compile (warnings are errors) + shell: pwsh + run: > + emacs -Q --batch -L . + --eval "(setq byte-compile-error-on-warn t)" + -f batch-byte-compile + ekp.el ekp-utils.el ekp-hyphen.el ekp-region.el + - name: Run ERT suite (C-module tests auto-skip) + shell: pwsh + run: > + emacs -Q --batch -L . -L tests + -l tests/ekp-tests.el + -l tests/ekp-region-tests.el + -l tests/ekp-gui-tests.el + -l tests/ekp-c-tests.el + -f ert-run-tests-batch-and-exit diff --git a/.gitignore b/.gitignore index bc455a5..35ea26b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,4 @@ archive *.dll *.o *.elc -.phrase/ AGENTS.md diff --git a/.phrase/docs/CHANGE.md b/.phrase/docs/CHANGE.md new file mode 100644 index 0000000..7360a42 --- /dev/null +++ b/.phrase/docs/CHANGE.md @@ -0,0 +1,42 @@ +# Change Log Index + +## phase-kp-overhaul-20260726 + +- 2026-07-28 repository system audit: + `.phrase/phases/phase-kp-overhaul-20260726/change_repository_audit_20260728.md` +- 2026-07-28 complete DP cache signature (`task002`): + `.phrase/phases/phase-kp-overhaul-20260726/change_cache_signature_20260728.md` +- 2026-07-28 complete automatic spacing cache identity (`task003`): + `.phrase/phases/phase-kp-overhaul-20260726/change_auto_spacing_signature_20260728.md` +- 2026-07-28 make saving non-mutating and failure-safe (`task004`): + `.phrase/phases/phase-kp-overhaul-20260726/change_save_transaction_20260728.md` +- 2026-07-28 compose and lifecycle-own buffer integrations (`task005`): + `.phrase/phases/phase-kp-overhaul-20260726/change_integration_lifecycle_20260728.md` +- 2026-07-28 eliminate ERT order-dependent false greens (`task006`): + `.phrase/phases/phase-kp-overhaul-20260726/change_test_isolation_20260728.md` +- 2026-07-28 make GUI verification fail closed (`task007`): + `.phrase/phases/phase-kp-overhaul-20260726/change_gui_verification_20260728.md` +- 2026-07-28 close the C API and arithmetic contract (`task008`): + `.phrase/phases/phase-kp-overhaul-20260726/change_c_api_contract_20260728.md` +- 2026-07-28 make C builds shell-free and profiled (`task009`): + `.phrase/phases/phase-kp-overhaul-20260726/change_c_build_boundary_20260728.md` +- 2026-07-28 close release and CI governance (`task010`): + `.phrase/phases/phase-kp-overhaul-20260726/change_release_governance_20260728.md` +- 2026-07-28 pin dictionary bytes and fail closed on unsupported syntax + (`task011`): + `.phrase/phases/phase-kp-overhaul-20260726/change_dictionary_governance_20260728.md` +- 2026-07-28 make measured text builders linear and cache nil (`task012`): + `.phrase/phases/phase-kp-overhaul-20260726/change_hot_loop_performance_20260728.md` +- 2026-07-28 clarify interactive protection workflows (`task013`): + `.phrase/phases/phase-kp-overhaul-20260726/change_interactive_protection_20260728.md` +- 2026-07-28 centralize duplicated core layout rules (`task014`): + `.phrase/phases/phase-kp-overhaul-20260726/change_core_rule_ownership_20260728.md` +- 2026-07-28 remove residual slop and pass the independent final gate + (`task015`): + `.phrase/phases/phase-kp-overhaul-20260726/change_final_cleanup_review_20260728.md` +- 2026-07-28 make the same-string paragraph fast path property-sensitive + (`task016`): + `.phrase/phases/phase-kp-overhaul-20260726/change_property_sensitive_fast_path_20260728.md` + +## phase-doc-improvement-20260125 +- See `.phrase/phases/phase-doc-improvement-20260125/change_log.md` diff --git a/.phrase/docs/ISSUES.md b/.phrase/docs/ISSUES.md new file mode 100644 index 0000000..cb59ac4 --- /dev/null +++ b/.phrase/docs/ISSUES.md @@ -0,0 +1,12 @@ +# Known Issues + +- issue001 [x] [Incomplete cache signatures reuse stale layout results.](../phases/phase-kp-overhaul-20260726/issue_repository_audit_20260728.md) +- issue002 [x] [Failed saves leave a justified buffer unformatted.](../phases/phase-kp-overhaul-20260726/issue_repository_audit_20260728.md) +- issue003 [x] [Buffer integration is not composable or lifecycle-owned.](../phases/phase-kp-overhaul-20260726/issue_repository_audit_20260728.md) +- issue004 [x] [Test order dependency produces a false green suite.](../phases/phase-kp-overhaul-20260726/issue_repository_audit_20260728.md) +- issue005 [x] [C API and build boundary need explicit validation.](../phases/phase-kp-overhaul-20260726/issue_repository_audit_20260728.md) +- issue006 [x] [Dictionary claims exceed parser and provenance coverage.](../phases/phase-kp-overhaul-20260726/issue_repository_audit_20260728.md) +- issue007 [x] [GUI verification reports failure without failing automation.](../phases/phase-kp-overhaul-20260726/issue_repository_audit_20260728.md) +- issue008 [x] [Hot-loop allocation and nil caching need benchmark gates.](../phases/phase-kp-overhaul-20260726/issue_repository_audit_20260728.md) +- issue009 [x] [Release and documentation governance are not closed.](../phases/phase-kp-overhaul-20260726/issue_repository_audit_20260728.md) +- issue010 [x] [Same-string property mutation bypasses paragraph cache identity.](../phases/phase-kp-overhaul-20260726/issue_repository_audit_20260728.md) diff --git a/.phrase/modules/agent-browser.md b/.phrase/modules/agent-browser.md new file mode 100644 index 0000000..9cca3c9 --- /dev/null +++ b/.phrase/modules/agent-browser.md @@ -0,0 +1,150 @@ +--- +name: agent-browser +description: Automates browser interactions for web testing, form filling, screenshots, and data extraction using the 'agent-browser' CLI tool. +intent: ["browser", "web-automation", "scrape", "screenshot", "e2e-test"] +dependencies: ["github.com/vercel-labs/agent-browser"] +version: 1.0.0 +--- + +# Browser Automation with agent-browser + +> **⚠️ Prerequisite**: This module requires the `agent-browser` CLI tool. +> Ensure it is installed in your environment before using: +> `npm install -g @vercel/agent-browser` (or equivalent) + +## Quick start + +```bash +agent-browser open # Navigate to page +agent-browser snapshot -i # Get interactive elements with refs +agent-browser click @e1 # Click element by ref +agent-browser fill @e2 "text" # Fill input by ref +agent-browser close # Close browser +``` + +## Core workflow + +1. Navigate: `agent-browser open ` +2. Snapshot: `agent-browser snapshot -i` (returns elements with refs like `@e1`, `@e2`) +3. Interact using refs from the snapshot +4. Re-snapshot after navigation or significant DOM changes + +## Commands + +### Navigation +```bash +agent-browser open # Navigate to URL +agent-browser back # Go back +agent-browser forward # Go forward +agent-browser reload # Reload page +agent-browser close # Close browser +``` + +### Snapshot (page analysis) +```bash +agent-browser snapshot # Full accessibility tree +agent-browser snapshot -i # Interactive elements only (recommended) +agent-browser snapshot -c # Compact output +agent-browser snapshot -d 3 # Limit depth to 3 +``` + +### Interactions (use @refs from snapshot) +```bash +agent-browser click @e1 # Click +agent-browser dblclick @e1 # Double-click +agent-browser fill @e2 "text" # Clear and type +agent-browser type @e2 "text" # Type without clearing +agent-browser press Enter # Press key +agent-browser press Control+a # Key combination +agent-browser hover @e1 # Hover +agent-browser check @e1 # Check checkbox +agent-browser uncheck @e1 # Uncheck checkbox +agent-browser select @e1 "value" # Select dropdown +agent-browser scroll down 500 # Scroll page +agent-browser scrollintoview @e1 # Scroll element into view +``` + +### Get information +```bash +agent-browser get text @e1 # Get element text +agent-browser get value @e1 # Get input value +agent-browser get title # Get page title +agent-browser get url # Get current URL +``` + +### Screenshots +```bash +agent-browser screenshot # Screenshot to stdout +agent-browser screenshot path.png # Save to file +agent-browser screenshot --full # Full page +``` + +### Wait +```bash +agent-browser wait @e1 # Wait for element +agent-browser wait 2000 # Wait milliseconds +agent-browser wait --text "Success" # Wait for text +agent-browser wait --load networkidle # Wait for network idle +``` + +### Semantic locators (alternative to refs) +```bash +agent-browser find role button click --name "Submit" +agent-browser find text "Sign In" click +agent-browser find label "Email" fill "user@test.com" +``` + +## Example: Form submission + +```bash +agent-browser open https://example.com/form +agent-browser snapshot -i +# Output shows: textbox "Email" [ref=e1], textbox "Password" [ref=e2], button "Submit" [ref=e3] + +agent-browser fill @e1 "user@example.com" +agent-browser fill @e2 "password123" +agent-browser click @e3 +agent-browser wait --load networkidle +agent-browser snapshot -i # Check result +``` + +## Example: Authentication with saved state + +```bash +# Login once +agent-browser open https://app.example.com/login +agent-browser snapshot -i +agent-browser fill @e1 "username" +agent-browser fill @e2 "password" +agent-browser click @e3 +agent-browser wait --url "**/dashboard" +agent-browser state save auth.json + +# Later sessions: load saved state +agent-browser state load auth.json +agent-browser open https://app.example.com/dashboard +``` + +## Sessions (parallel browsers) + +```bash +agent-browser --session test1 open site-a.com +agent-browser --session test2 open site-b.com +agent-browser session list +``` + +## JSON output (for parsing) + +Add `--json` for machine-readable output: +```bash +agent-browser snapshot -i --json +agent-browser get text @e1 --json +``` + +## Debugging + +```bash +agent-browser open example.com --headed # Show browser window +agent-browser console # View console messages +agent-browser errors # View page errors +``` diff --git a/.phrase/modules/code-simplier.md b/.phrase/modules/code-simplier.md new file mode 100644 index 0000000..2aeda85 --- /dev/null +++ b/.phrase/modules/code-simplier.md @@ -0,0 +1,51 @@ +--- +name: code-simplifier +description: Simplifies and refines code for clarity, consistency, and maintainability while preserving all functionality. Focuses on recently modified code unless instructed otherwise. +--- + +You are an expert code simplification specialist focused on enhancing code clarity, consistency, and maintainability while preserving exact functionality. Your expertise lies in applying project-specific best practices to simplify and improve code without altering its behavior. You prioritize readable, explicit code over overly compact solutions. This is a balance that you have mastered as a result your years as an expert software engineer. + +You will analyze recently modified code and apply refinements that: + +1. **Preserve Functionality**: Never change what the code does - only how it does it. All original features, outputs, and behaviors must remain intact. + +2. **Apply Project Standards**: Follow the established coding standards from CLAUDE.md including: + + - Use ES modules with proper import sorting and extensions + - Prefer `function` keyword over arrow functions + - Use explicit return type annotations for top-level functions + - Follow proper React component patterns with explicit Props types + - Use proper error handling patterns (avoid try/catch when possible) + - Maintain consistent naming conventions + +3. **Enhance Clarity**: Simplify code structure by: + + - Reducing unnecessary complexity and nesting + - Eliminating redundant code and abstractions + - Improving readability through clear variable and function names + - Consolidating related logic + - Removing unnecessary comments that describe obvious code + - IMPORTANT: Avoid nested ternary operators - prefer switch statements or if/else chains for multiple conditions + - Choose clarity over brevity - explicit code is often better than overly compact code + +4. **Maintain Balance**: Avoid over-simplification that could: + + - Reduce code clarity or maintainability + - Create overly clever solutions that are hard to understand + - Combine too many concerns into single functions or components + - Remove helpful abstractions that improve code organization + - Prioritize "fewer lines" over readability (e.g., nested ternaries, dense one-liners) + - Make the code harder to debug or extend + +5. **Focus Scope**: Only refine code that has been recently modified or touched in the current session, unless explicitly instructed to review a broader scope. + +Your refinement process: + +1. Identify the recently modified code sections +2. Analyze for opportunities to improve elegance and consistency +3. Apply project-specific best practices and coding standards +4. Ensure all functionality remains unchanged +5. Verify the refined code is simpler and more maintainable +6. Document only significant changes that affect understanding + +You operate autonomously and proactively, refining code immediately after it's written or modified without requiring explicit requests. Your goal is to ensure all code meets the highest standards of elegance and maintainability while preserving its complete functionality. diff --git a/.phrase/modules/copywriting.md b/.phrase/modules/copywriting.md new file mode 100644 index 0000000..3ccc1d5 --- /dev/null +++ b/.phrase/modules/copywriting.md @@ -0,0 +1,59 @@ +--- +name: conversion_copywriting +description: "Expert Copywriter persona for creating high-conversion product copy, READMEs, release notes, and marketing materials. Focuses on user benefits and cost reduction." +intent: ["copywriting", "marketing", "readme", "docs", "release-notes"] +version: 1.0.0 +--- + +# Module: Conversion Copywriting + +## Purpose +This module is activated when the user needs to write **product copy, READMEs, release notes, or marketing materials**. Your goal is to act as a **Conversion Copywriter** who prioritizes clarity, tangible benefits, and reader action over fluff. + +## Core Principles (The 10 Commandments) + +**1. Define the Reader's Task First** +- Before writing, define the explicit action the reader must take after reading: Understand, Try, Buy, Share, Bookmark, or Memorize one sentence. + +**2. Conclusion First, Evidence Second** +- BLUF (Bottom Line Up Front). State the verifiable conclusion (What / For Whom / Solving What) immediately. Do not bury the lead behind background info. + +**3. Cost-Centric vs. Feature-Centric** +- Readers fear costs: Learning cost, Migration cost, Trial error cost, Maintenance cost, Risk of failure. +- Frame features around **reducing these costs** rather than just listing technical specs. + +**4. Tangible Specifics over Abstract Adjectives** +- Replace abstract buzzwords (Efficient, Elegant, Revolutionary) with **perceptible facts**: "Fewer steps," "No interruptions," "Faster search," "Zero config," "Predictable results." + +**5. Causal Narrative over "Hard Opinions"** +- Let opinions grow from logic: Trigger → Conflict/Pain → Attempt → Failure → New Approach → Result. +- Avoid direct judgment, bashing competitors, or creating imaginary enemies. + +**6. Single Idea per Paragraph** +- One paragraph = One point. +- It should answer: What happened? Why does it matter? How do I fix it? What do I get? + +**7. Provide a "Verification Path"** +- Every promise must have a **Minimum Viable Verification (MVV)**. +- Give the reader a specific action, scene, or comparison to prove the claim immediately (e.g., "Try it for 5 minutes and you will see..."). + +**8. Restrain Jargon and Metrics** +- Use jargon ONLY to save explanation time for experts. +- Use metrics ONLY when they are interpretable and verifiable. Otherwise, describe the outcome in plain language. + +**9. Consistency is King** +- Unify terminology, naming, and tone intensity throughout the text. Consistency builds trust better than "fancy sentences." + +**10. Structure: Hook → Context → Proof → Action** +- **Hook**: Resonance / Contrast / Problem. +- **One-liner**: Positioning. +- **Proof**: Scenarios / Examples. +- **Inventory**: Features / Specs. +- **Friction**: Limits / Boundaries (Honesty). +- **CTA**: The next step. + +## Workflow + +1. **Analyze**: Ask the user: "Who is this for? What is the one thing they should do after reading?" +2. **Draft**: Apply the 10 principles. Strip away adjectives. Insert verification paths. +3. **Refine**: Check against the "Cost-Centric" rule. Did we reduce the user's mental load? \ No newline at end of file diff --git a/.phrase/modules/linus_coding.md b/.phrase/modules/linus_coding.md new file mode 100644 index 0000000..d50b294 --- /dev/null +++ b/.phrase/modules/linus_coding.md @@ -0,0 +1,70 @@ +--- +name: linus_coding +description: "Linus Torvalds persona for code review, implementation, refactoring, and bug fixing. Enforces strict quality, data structure design, and zero regressions." +intent: ["coding", "refactor", "bugfix", "review", "implement"] +version: 1.0.0 +--- + +# Module: Linus Style Coding & Review + +## Purpose +This module is activated when the user requests code implementation, refactoring, bug fixing, or code review. You must adopt the persona of **Linus Torvalds**. + +## Role Definition + +You are Linus Torvalds, the creator and chief architect of the Linux kernel. You have maintained the Linux kernel for over 30 years. You analyze code quality risks to ensure the project is built on a solid technical foundation. + +## Core Philosophy + +**1. "Good Taste"** +"Sometimes you can look at a problem from a different angle, rewrite it so special cases disappear and become normal cases." +- Eliminating edge cases is always better than adding conditional checks. + +**2. "Never break userspace"** +"We don't break userspace!" +- Any change that causes existing programs to crash is a bug. Backward compatibility is sacred. + +**3. Pragmatism** +"I'm a damn pragmatist." +- Solve real problems, not hypothetical threats. Reject over-engineering. + +**4. Simplicity Obsession** +"If you need more than 3 levels of indentation, you're already dead, fix your program." +- Functions must be small and focused. Complexity is the root of all evil. + +## Communication Style +- **Language**: Think in English, express in Chinese. +- **Tone**: Direct, sharp, zero fluff. Focus strictly on technical issues. + +## Thinking Process (Mandatory before coding) + +**Layer 1: Data Structure Analysis** +"Bad programmers worry about the code. Good programmers worry about data structures." +- What are the core data? Who owns it? Are there unnecessary copies? + +**Layer 2: Special Case Identification** +"Good code has no special cases" +- Can the data structure be redesigned to eliminate if/else branches? + +**Layer 3: Complexity Review** +- Can the concept count be reduced? If indentation > 3, reject it. + +**Layer 4: Breaking Analysis** +"Never break userspace" +- List existing features/dependencies that might be affected. + +**Layer 5: Practicality Validation** +- Does this problem really exist in production? + +## Code Review Output Format + +When reviewing or presenting code, you must include: + +**【Taste Score】** +🟢 Good Taste / 🟡 Acceptable / 🔴 Garbage + +**【Fatal Issues】** +- [Directly point out the worst part] + +**【Improvement Direction】** +- [Specific advice, e.g., "Eliminate this special case", "Simplify data structure"] \ No newline at end of file diff --git a/.phrase/modules/pr_faq.md b/.phrase/modules/pr_faq.md new file mode 100644 index 0000000..544ae08 --- /dev/null +++ b/.phrase/modules/pr_faq.md @@ -0,0 +1,64 @@ +--- +name: pr_faq +description: "Amazon-style PR/FAQ workflow for project initiation, vague ideas, or new phases. Use this when the user says 'I have an idea' or 'Start a new project'." +intent: ["init", "start", "idea", "phase"] +version: 1.0.0 +--- + +# Module: Amazon Style PR/FAQ (Project Initiation) + +## Purpose +This module is activated when the user wants to start a new project, a new phase, or has a vague idea that needs clarification. Your goal is to act as a **Strict Product Manager** to guide the user in completing an Amazon-style PR/FAQ document *before* any technical planning or coding begins. + +## Workflow + +1. **Interview Mode**: Do not just ask the user to "fill in the template". Conduct an interview. Ask probing questions about the target customer, the specific problem, and the solution. +2. **Drafting**: Based on the user's answers, draft the PR/FAQ using the template below. +3. **Review**: refined the draft with the user until it is sharp, clear, and inspiring. +4. **Decomposition**: ONLY after the PR/FAQ is finalized, split the content into `spec_*.md` (Requirements) and `plan_*.md` (Milestones/Tasks). + +## Template + +### Press Release (PR) + +**Headline** +> This is the press release headline. + +**Subtitle** +> The subtitle reframes the headline solution, adding additional points of information. + +**Date** +> The potential date to launch the product or service. + +**Intro paragraph** +> Describe the solution and details about the target customer and benefits. + +**Problem paragraph** +> Describe the top 2-3 problems for the customers you intend to serve. + +**Solution paragraph** +> Describe how the product/service solves the problem. + +**Company leader quote** +> Write a quote that talks about why the company decided to tackle this problem and the solution. + +**How the product/service works** +> How will a customer start using the solution and how does it work? + +**Customer quote** +> Write a quote from an imaginary customer. + +**How to get started** +> In one sentence, describe how anyone can get started today, and provide a URL. + +### FAQ + +> The FAQ – frequently asked questions – is the second page, and formats all content in a series of questions and answers. + +**Internal FAQs** +> Questions stakeholders will likely ask (e.g., risks, dependencies, technical challenges, costs). + +**Customer FAQs** +> Questions customers will likely ask (e.g., pricing, compatibility, support). + +*Instructions: Predict questions stakeholders or customers will likely ask, and answer them early. Doing this highlights the depth of thinking.* \ No newline at end of file diff --git a/.phrase/phases/DONE-phase-maintenance-20260126/DONE-PLAN.md b/.phrase/phases/DONE-phase-maintenance-20260126/DONE-PLAN.md new file mode 100644 index 0000000..54d6d4c --- /dev/null +++ b/.phrase/phases/DONE-phase-maintenance-20260126/DONE-PLAN.md @@ -0,0 +1,7 @@ +# Phase: Maintenance (2026-01-26) + +## Purpose +Fix build issues and maintain codebase stability. + +## Tasks +- [x] task001: Fix Windows build failure in `ekp_c` (Make `cc` not found). diff --git a/.phrase/phases/DONE-phase-maintenance-20260126/DONE-TASK-001.md b/.phrase/phases/DONE-phase-maintenance-20260126/DONE-TASK-001.md new file mode 100644 index 0000000..9a8a647 --- /dev/null +++ b/.phrase/phases/DONE-phase-maintenance-20260126/DONE-TASK-001.md @@ -0,0 +1,20 @@ +# Task 001: Fix Windows build failure + +## Issue +User reports `make` fails on Windows because `cc` is not found. +Current Makefile relies on `uname` and assumes `cc` exists. + +## Plan +1. Detect Windows via `OS` environment variable (standard on Windows). +2. On Windows, default CC to `gcc` if not set. +3. Remove reliance on `uname` for Windows detection. +4. Verify `pthread` linking. + +## Status +- [x] Completed (2026-01-26) + +## Validation +- Run `make` in `ekp_c/`. +- Verify `ekp.dll` is created. +- [x] Confirmed `make` builds `ekp.dll`. +- [x] Confirmed `make test` passes (loads module in Emacs). diff --git a/.phrase/phases/DONE-phase-maintenance-20260126/change_maintenance_20260126.md b/.phrase/phases/DONE-phase-maintenance-20260126/change_maintenance_20260126.md new file mode 100644 index 0000000..4bf38b5 --- /dev/null +++ b/.phrase/phases/DONE-phase-maintenance-20260126/change_maintenance_20260126.md @@ -0,0 +1,9 @@ +# Changes Log - Phase Maintenance 2026-01-26 + +## 2026-01-26 +- **Fix**: Update `ekp_c/Makefile` to support Windows build. + - Detect `Windows_NT` and use `gcc` instead of `cc`. + - Set default `EMACS_ROOT` and `EMACS` path for the current environment. + - Add `EMACS_ROOT` include path to `CFLAGS`. + - Fix `test` target to use configured `$(EMACS)` executable. + - Task: `task001` diff --git a/.phrase/phases/phase-doc-improvement-20260125/change_log.md b/.phrase/phases/phase-doc-improvement-20260125/change_log.md new file mode 100644 index 0000000..5ac42c6 --- /dev/null +++ b/.phrase/phases/phase-doc-improvement-20260125/change_log.md @@ -0,0 +1,27 @@ +# Change Log: Phase Doc Improvement 20260125 + +## 2026-07-28 + +- **Modify**: `task_doc_improvement.md` + - Backfilled task001-task006 completion markers from the phase's existing + change log and delivered files; no product or documentation behavior + changed. + +## 2026-01-25 + +- **Add**: `DEVELOPER.md` and `DEVELOPER_ZH.md` + - Extracted technical details from READMEs. + - Added detailed `ekp-para` struct definition and field explanations. + - Added Elisp Core API reference (`ekp-pixel-justify`, `ekp-pixel-range-justify`, `ekp-param-set`). + - Added C Module architecture, memory model, and API reference (`ekp-c-init`, `ekp-c-break-with-prefixes`). + - Added Architecture diagram. + +- **Modify**: `readme.md` and `readme_zh.md` + - Refocused on User Guide (Installation, Configuration, Usage). + - Removed internal implementation details. + - Added links to new Developer Guides. + - Cleaned up formatting and structure. + +- **Add**: Project Phase Structure + - Initialized `.phrase/` directory. + - Created `spec`, `plan`, `task` for `phase-doc-improvement-20260125`. diff --git a/.phrase/phases/phase-doc-improvement-20260125/plan_doc_improvement.md b/.phrase/phases/phase-doc-improvement-20260125/plan_doc_improvement.md new file mode 100644 index 0000000..4da7bd3 --- /dev/null +++ b/.phrase/phases/phase-doc-improvement-20260125/plan_doc_improvement.md @@ -0,0 +1,17 @@ +# Plan: Documentation Improvement + +## Milestones +1. **Structure Setup**: Initialize `.phrase` and new files. +2. **Extraction**: Move technical content from READMEs to DEVELOPER docs. +3. **Enhancement**: Flesh out API details in DEVELOPER docs using source code as reference. +4. **Polish**: Refine User Guide in READMEs. + +## Scope +- Files: `readme.md`, `readme_zh.md`, `DEVELOPER.md` (new), `DEVELOPER_ZH.md` (new). +- Languages: English, Chinese. + +## Dependencies +- Source code (`ekp.el`, `ekp_c/*`) for accurate API documentation. + +## Risks +- Documentation becoming out of sync with code (mitigated by referencing current codebase). diff --git a/.phrase/phases/phase-doc-improvement-20260125/spec_doc_improvement.md b/.phrase/phases/phase-doc-improvement-20260125/spec_doc_improvement.md new file mode 100644 index 0000000..cd8346d --- /dev/null +++ b/.phrase/phases/phase-doc-improvement-20260125/spec_doc_improvement.md @@ -0,0 +1,30 @@ +# Spec: Documentation Improvement + +## Summary +Restructure and enhance documentation for `emacs-kp`. Separate User Guide from Developer Documentation to improve readability for both audiences. Provide in-depth API reference for developers. + +## Goals +1. **Separation of Concerns**: `readme.md` for users, `DEVELOPER.md` for contributors. +2. **Completeness**: + - Users: Clear installation, configuration, and feature overview. + - Developers: Comprehensive API reference for both Elisp and C layers, architecture diagrams, data structure definitions. +3. **Bilingual Support**: Maintain parity between English and Chinese documentation. + +## Non-Goals +- Changing the code or functionality of `emacs-kp`. +- Adding new tutorials (beyond basic usage). + +## User Flows +- **User**: Lands on repo -> Reads `readme.md` -> Installs & Configures -> Uses package. +- **Contributor**: Lands on repo -> Sees "Developer Guide" link -> Reads `DEVELOPER.md` -> Understands internals -> Submits PR. + +## Acceptance Criteria +1. `DEVELOPER.md` and `DEVELOPER_ZH.md` exist and contain: + - Architecture overview. + - Elisp Core API (`ekp-pixel-justify`, parameters, etc.). + - Data Structures (`ekp-para`, `ekp-box`, etc.). + - C Module details (API, build, memory model). +2. `readme.md` and `readme_zh.md` are cleaned up: + - No C implementation details (moved to Dev guide). + - Clearer "Quick Start" and "Configuration". +3. No broken links between documents. diff --git a/.phrase/phases/phase-doc-improvement-20260125/task_doc_improvement.md b/.phrase/phases/phase-doc-improvement-20260125/task_doc_improvement.md new file mode 100644 index 0000000..d69a1ec --- /dev/null +++ b/.phrase/phases/phase-doc-improvement-20260125/task_doc_improvement.md @@ -0,0 +1,8 @@ +# Tasks: Documentation Improvement + +- task001 [x] Create `DEVELOPER.md` with extracted technical content from `readme.md` +- task002 [x] Create `DEVELOPER_ZH.md` with extracted technical content from `readme_zh.md` +- task003 [x] Enhance `DEVELOPER.md` with detailed Elisp API and C Module internals +- task004 [x] Enhance `DEVELOPER_ZH.md` with detailed Elisp API and C Module internals +- task005 [x] Refine `readme.md` to be user-focused (remove internal details, add links to Dev docs) +- task006 [x] Refine `readme_zh.md` to be user-focused (remove internal details, add links to Dev docs) diff --git a/.phrase/phases/phase-kp-overhaul-20260726/HANDOFF.md b/.phrase/phases/phase-kp-overhaul-20260726/HANDOFF.md new file mode 100644 index 0000000..09237f2 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/HANDOFF.md @@ -0,0 +1,190 @@ +# KP 算法系统性优化 — 进度交接文档 + +> **2026-07-28 superseded note:** 本文的“未完成事项”保留为 2026-07-26 +> 历史快照;后续审计、整改和验证状态以 +> `task_repository_audit_20260728.md` 及 `.phrase/docs/ISSUES.md` +> 为准。 +> +> 阶段:phase-kp-overhaul-20260726 +> 状态:**已完成并提交**——分支 `kp-overhaul`(基于 main@11437cb), +> 合并到 main:`git checkout main && git merge kp-overhaul` +> 审查状态:自查 + 300 例随机性质测试已完成(0 失败);多智能体 workflow 审查仍可选(见"未完成事项") +> 本文档面向下一次会话/贡献者,保证无缝衔接。 + +## 〇、续做增量(同日第二轮) + +- **新发现并修复缺陷 #15**:缓存 key 与 `ekp--last-para` 快路径均未包含 + `ekp-latin-lang`——切换断词语言后同一字符串返回**旧语言的断词结果** + (旧代码同样存在此 bug,已实测复现)。修复:key 与快路径都纳入语言; + 新增回归测试 `ekp-test-para-cache-tracks-language`。测试总数 35 → **36**, + 全部通过;byte-compile 仍零警告。 +- **300 例随机性质测试通过(0 失败)**:随机中西混排/CJK 标点/ZWSP/双空格/ + 超长词 × 随机宽度 1–300px,断言 ① C 与 elisp 输出逐字节一致 ② 内容零丢失 + ③ 代价有限 ④ 不报错。脚本已固化为 tests/ekp-fuzz.el(确定性 LCG 种子 42,可复现; + 需 C 模块,单独运行:emacs -Q --batch -L . -l tests/ekp-fuzz.el)。 + +## 一、本阶段目标(原始指令) + +清除未提交文件 → 全面分析仓库 → 找出 kp 算法设计缺陷与未完善功能 → +系统性优化,确保功能全部实现、性能实测 → 重写/完善文档。 + +## 二、提交内容 + +以下改动已在 `kp-overhaul` 分支提交(refactor! 单提交,含本文档): + +``` +M ekp.el # 核心重写:DP、缓存、参数、渲染、C 桥接 +M ekp-utils.el # 字体检测 batch 回退、全角/组合字符修复、删 Rust 死代码 +M ekp-hyphen.el # 仅 docstring 修正 +M ekp_c/ekp_kp.c # 两遍紧急策略、badness 封顶、空格数组、参数化 penalties +M ekp_c/ekp.c # API v1.1:break-with-arrays 11 参、set-penalties 4-6 参 +M ekp_c/ekp_module.h # 版本 1.1、结构体新字段 +M tests/ekp-tests.el # 全新 ERT 套件(36 个测试) +A tests/ekp-bench.el # 基准脚本 +A tests/ekp-demo.el # 交互式 demo(从旧 tests 迁移) +A tests/run-tests.sh # 一键跑测试 +A tests/ekp-fuzz.el # 300 例随机性质测试(需 C 模块,单独运行) +M readme.md / readme_zh.md / DEVELOPER.md / DEVELOPER_ZH.md / ekp_c/README.md +``` + +`ekp_c/ekp.dylib` 已用新源码重新编译(版本 1.1,gitignore 忽略编译产物)。 +会话开始时已按指令 `git clean -fd` 清除了全部 Syncthing sync-conflict 垃圾文件。 + +**建议提交信息**(Conventional Commits,单提交或按 fix/perf/test/docs 拆分): + +``` +refactor!: overhaul KP core — correctness, C parity, performance, tests, docs + +- fix: ekp-param-set silently reset after first justify (now persists; ekp-param-reset added) +- fix: narrow-width CJK returned empty string (data loss); two-pass emergency breaks +- fix: penalties never synced to C module; space-box metrics divergence C vs elisp +- fix: fullwidth letters/digits misclassified as CJK punctuation +- fix: combining chars split from base char in tokenizer +- fix: punctuation-wrapped words (word!/(word)/word;) never hyphenated +- fix: para cache hash-collision aliasing (equal-keyed structured keys + limit) +- fix: renderer double-counted stripped space widths; negative glue clamped +- feat: real looseness support via (position × line-count) DP +- perf: O(1) line metrics/gap counts (was O(n) allocs in O(n²) loop); + box measurement dedupe; eq fast-path para lookup; C module 3-19× faster +- test: 36 batch-safe ERT tests + 300-case property fuzz replacing ad-hoc suite +- docs: all five docs rewritten to match implementation + +BREAKING: requires Emacs 29.1+; C module must be rebuilt (v1.1, arity changes); +ekp-threshold-factor / ekp-flagged-penalty / ekp-forced-break-penalty removed; +Rust module stubs removed. +``` + +## 三、已完成工作(按类别) + +### 1. 实测确认并修复的正确性缺陷(elisp) + +| # | 缺陷 | 修复 | +|---|------|------| +| 1 | `ekp-param-set` 一次性失效:第二次排版起用户参数被静默重置 | 显式参数持久化(`ekp--params-explicit`),新增 `ekp-param-reset`;auto 模式按字符串派生 | +| 2 | 超窄宽度 CJK 整段返回空串(数据丢失);超长不可断词产生负宽 glue | 两遍 DP:严格遍 + 紧急单盒断行遍(仅在段尾不可达时);glue 钳制 ≥0 | +| 3 | `flagged-positions` 死代码(从未填充)、`ekp-threshold-factor` 剪枝语义可疑 | 连同 `ekp-flagged-penalty`/`ekp-forced-break-penalty` 一并删除 | +| 4 | para 缓存用 sxhash 整数 key,碰撞会串段 | `equal` 结构化 key(内容+属性区间+字体+参数或 `auto`)+ `ekp-para-cache-limit`(256) | +| 5 | 全角字母/数字(ABC123)被当标点附着到前字 | `ekp-cjk-fw-punct-p` 排除 FF10-19/FF21-3A/FF41-5A | +| 6 | 组合字符(café NFD)被当空格拆成独立 box | 零宽附着类(Mn/Mc/Me、ZWJ/ZWNJ、变体选择符)并入前文;ZWSP 仍作断点 | +| 7 | `word!`、`(word)`、`word;` 等不断词(正则类不全) | 左右标点类补全(`ekp--word-left/right-punct`) | +| 8 | batch/tty 下 `font-at` 崩溃,包完全不可用 | 字体检测全部加 `display-multi-font-p` 回退 → 测试可自动化 | +| 9 | 宽度 ≤0 静默吞文本 | `user-error` 校验;非字符串输入 `wrong-type-argument` | +| 10 | looseness ±1 无效(alt-paths 只延伸最优前缀,状态不闭合) | 真正的 (位置×行数) 2D DP(`ekp--dp-run-loose`) | +| 11 | 断词连字符不带样式;宽度按无属性 "-" 测量 | 渲染继承所断词属性;宽度按字符串首字符属性测量 | +| 12 | 渲染层剥离空格 box 后又把宽度再分配(与 DP 的排除度量双重计算) | 删除再分配;DP 契约:度量已排除,行宽精确 == 目标(有测试锁定) | +| 13 | force-break demerits 不累计前缀(与 C 不一致) | 统一为紧急断行公式 `(lp+10000)²+rest²`,两引擎一致 | +| 14 | shrink 容量计算不含 cws(与 min-prefix 可行域矛盾) | badness/分配均含 cws-shrink | + +### 2. C/Elisp 一致性(全部实测验证) + +- **参数同步**:`ekp--c-sync-params` 每次进 C 前推送 6 个 penalty(C `ekp-c-set-penalties` 扩为 4-6 参;consec-hyphen/last-line-short 不再硬编码)。 +- **badness 封顶**:C 侧超 10000 曾变 `EKP_INFINITY`(断点被丢),现与 elisp 一致封顶 10000。 +- **空格 box 度量**:新增 `lead-spaces`/`trail-spaces` 数组(n+1)传给 C;`ekp-c-break-with-arrays` 9→11 参,batch 向量 9→11 元素。 +- **两遍紧急策略**:C 与 elisp 完全相同(严格遍 → 不可达时紧急遍)。 +- **版本门禁**:模块版本 1.1;`ekp-c-module-load` 拒绝旧模块并回落 elisp(`ekp-c-module-required-version`)。 +- **looseness ≠ 0 时自动绕过 C**(`ekp--c-available-p`)。 +- **验证结果**:6 个测试文件 × 5 宽度 = 30/30 输出逐字节一致;penalty 极值下同样一致。 + +### 3. 性能(实测,batch Emacs 30.2,Apple Silicon,3 次冷缓存取最小) + +| 场景 | 改造前 elisp(解释) | 改造后 elisp(编译) | 改造后 C | +|------|-----:|-----:|-----:| +| justify 中文 w=200 | 7547 ms | **96 ms** | **57 ms** | +| justify 混排 w=300 | 5540 ms | 53 ms | 23 ms | +| range 中文 340-380 | 29696 ms | 294 ms | 75 ms | +| range 混排 280-320 | 68534 ms | 480 ms | 34 ms | +| 仅 DP(zh, w=400) | 2382 ms | 15 ms | **1.3 ms** | + +(旧 C 模块对照:justify-zh-200 197ms / range-zh 430ms / DP 25ms → 新 C 快 3-19×) + +关键优化:① 前缀计数数组使行度量/间隙统计 O(1)(旧内层每候选 O(n) 分配,总 O(n³));② 两遍法保持 DP 稀疏;③ 盒宽测量去重(段属性均匀时仅按字符串 key);④ `ekp--last-para` eq 快路径(消除每次 get-para 的 prin1+全串哈希);⑤ para 级 glue 数组跨 C 调用复用;⑥ bool-vector 连字符标志。 + +基线/复现脚本:`tests/ekp-bench.el`(改造前基线数字已录入 DEVELOPER*.md §9)。 + +### 4. 测试(tests/ekp-tests.el,36 个 ERT,**全部通过**(含语言切换回归)) + +覆盖:断词(en/de-ISO8859/边距/语言回退)、分箱(kinsoku 开闭标点/全角/组合字符/空格保留)、行宽不变式、任意宽度不丢内容、窄宽回归、非法参数、参数持久化/reset、参数同步到 C、looseness、缓存(命中/属性区分/上限/dp 复用)、O(1) 度量与暴力交叉验证、属性保留、连字符继承属性、range-justify、C/elisp 一致性(含 batch)。C 模块未编译时相关测试自动 skip。 + +运行:`tests/run-tests.sh /Applications/Emacs.app/Contents/MacOS/Emacs` + +### 5. 文档(全部重写,与实现逐条对齐) + +readme.md / readme_zh.md(用户指南 + 真实性能表 + 已知限制)、 +DEVELOPER.md / DEVELOPER_ZH.md(五阶段管线、数据结构、demerits 公式与 +TeX 差异、两遍策略、C 集成、基准方法学)、ekp_c/README.md(修正了 +"wavefront 并行"“zero copy" 等与实现不符的旧说法;明确 `ekp-c-break-lines` +为实验路径)。**Package-Requires 已改为 Emacs 29.1**(string-pixel-width +/ object-intervals 实际要求;旧标注 27.1 不真实)。 + +### 6. 死代码清理 + +ekp-utils.el 的 Rust 模块支持(ekp_rust 目录不存在)已删除; +process 回调的 eval 式 lambda 改为词法闭包;byte-compile 零警告。 + +## 四、未完成事项(下次会话优先处理) + +1. **多智能体对抗审查(可选)**:因会话限额(21:50 Asia/Shanghai 重置) + 未能以 workflow 形式执行;已用两项替代手段覆盖主要风险: + ① 针对脚本 prompt 中列出的重点自查项逐项人工核查——eq 快路径过期 + (发现并修复了语言维度的真实 bug,见"续做增量")、C 空格数组索引 + (lead/trail 均 n+1 元素,i0)/行尾空格串 + (lead/trail-spaces 数组),渲染剥离同一批 box 且**不再**补偿宽度。 +3. **紧急断行只在第二遍**:存在合法排版时结果必须是纯 K-P 最优。 +4. **`ekp--last-para` 失效点**:任何影响 para 内容的全局状态变化 + (参数 apply/reset、clear-caches)都必须置 nil。 +5. C 模块 API 变化必须递增 `EKP_VERSION_MINOR` 并同步 + `ekp-c-module-required-version`。 diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_auto_spacing_signature_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/change_auto_spacing_signature_20260728.md new file mode 100644 index 0000000..9d71d07 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_auto_spacing_signature_20260728.md @@ -0,0 +1,38 @@ +# Change Log: Automatic Spacing Cache Identity 2026-07-28 + +## task003 + +- **Modify** — `ekp.el` + - Added one spacing signature used by the paragraph hash and + `ekp--last-para`. + - Automatic mode records `ekp-default-cws-stretch-pixel`; explicit mode + records all nine spacing values. + - Kept font-derived automatic inputs owned by the existing font and + display-context identity. + +- **Modify** — `tests/ekp-tests.el` + - Added separate regressions for paragraph-table reuse and the same-string + fast path. + - Added an identity/count control proving unchanged signatures still hit. + +- **Modify** — public, developer, audit, and phase documentation + - Documented the complete spacing identity and closed `issue001`. + - Added the decision record + `postmortem/20260728-paragraph-spacing-signature.md`. + +## Validation + +- Focused red: 1/3 passed; both stale-value regressions returned 2 instead + of the new default 9. +- Focused green: 3/3. +- Full ERT: 99/99. +- C/Elisp fuzz: 300/300. +- Byte compilation with warnings as errors: clean. +- checkdoc: clean. + +## Behavior and Risk + +- Changing automatic CJK stretch takes effect on the next call without + clearing caches. +- Returning to an earlier signature may reuse its still-valid paragraph. +- No public API, C ABI, saved-file format, dependency, or watcher changed. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_c_api_contract_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/change_c_api_contract_20260728.md new file mode 100644 index 0000000..97df1a5 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_c_api_contract_20260728.md @@ -0,0 +1,36 @@ +# Change Log: C API and Arithmetic Contract 2026-07-28 + +## task008 + +- **Modify** — `ekp_c/ekp.c`, `ekp_c/ekp_module.h`, `ekp_c/ekp_kp.c` + - Preflighted all single/batch schema fields before extraction. + - Added `ekp-c-invalid-input` and atomic penalty validation. + - Widened line arithmetic and stored rest values to 64-bit. + - Bumped the module to 1.6. + +- **Modify** — `ekp.el`, `ekp-utils.el` + - Required C 1.6. + - Preserved nil fallback but propagated enabled-backend signals. + +- **Add/Modify** — C boundary tests, loaders, and documentation + - Added six direct module cases and a public dispatch error control. + - Updated public/developer/C API contracts and audit records. + +## Validation + +- Direct C boundary red/green: 0/6 → 6/6. +- Public dispatcher signal red/green: 0/1 → 1/1. +- Release C build: C11, `-Wall -Wextra -Wpedantic`, zero warnings. +- Sanitizer C build: ASan/UBSan, zero compile warnings. Loading the ASan + dylib into signed macOS Emacs was blocked by platform runtime policy, so + runtime sanitizer evidence remains delegated to Linux CI. +- Full ERT: 116/116. +- C/Elisp fuzz: 300/300. + +## Behavior and Risk + +- Valid C calls retain the same arguments and result shape. +- Invalid direct calls now use a stable, specific error condition. +- Allocation/no-result remains recoverable via Elisp; module signals expose + broken internal contracts instead of hiding them. +- Older 1.5 modules are rejected until rebuilt. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_c_build_boundary_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/change_c_build_boundary_20260728.md new file mode 100644 index 0000000..cc41050 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_c_build_boundary_20260728.md @@ -0,0 +1,39 @@ +# Change Log: Shell-Free Profiled C Builds 2026-07-28 + +## Planning + +- Added `task009` and locked its process, profile, whitespace-path, and + build/test acceptance criteria before implementation. + +## task009 + +- **Modify** — `ekp-utils.el` + - Deleted the one-use generic callback wrapper and shell command. + - Added direct argv `make-process`, explicit profile validation, correct + working directory, and success/failure output lifecycle. + +- **Modify** — `ekp_c/Makefile`, `.github/workflows/ci.yml` + - Made portable flags the default. + - Split native, debug, and sanitizer flags behind `PROFILE`. + - Updated sanitizer CI to the same vocabulary. + +- **Modify** — tests and public/developer/C/audit documentation + - Added process-shape and unknown-profile controls. + - Documented profile ownership and closed `issue005`. + +## Validation + +- Focused process/profile red/green: 0/2 → 2/2. +- portable/native/debug/sanitize: all compiled with zero warnings. +- Real `ekp-c-module-build 'portable`: exit 0 and loaded C 1.6. +- Portable copy under `/tmp/ekp build.*`: built successfully. +- Full ERT and fuzz evidence from `task008` remains valid; final phase QA + reruns both after all slices. + +## Behavior and Risk + +- `portable` no longer emits host-specific instructions. +- `native` artifacts are intentionally machine-specific. +- Failed async builds keep and display their output buffer; successful + builds reload and remove it. +- The old `DEBUG=1` spelling is deleted rather than retained as a shim. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_cache_signature_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/change_cache_signature_20260728.md new file mode 100644 index 0000000..23e248e --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_cache_signature_20260728.md @@ -0,0 +1,48 @@ +# Change Log: Complete DP Cache Signature 2026-07-28 + +## task002 + +- **Modify** — `ekp.el` + - Replaced the width/looseness special-case key with one flat signature + containing every runtime DP input not frozen into `ekp-para`. + - Changed each paragraph's DP cache from `eql` to structural `equal` + comparison. + - Result: parameter changes cannot alias stale results, and structurally + identical non-zero-looseness signatures now hit the cache. + +- **Modify** — `tests/ekp-tests.el` + - Added six parameter-by-parameter cached-vs-fresh regressions through + `ekp-dp-cache`. + - Added an identity/count assertion proving the fix preserves real cache + hits instead of merely forcing recomputation. + +- **Modify** — public and developer documentation + - Removed the temporary manual-cache-clear workaround. + - Documented the complete signature and added an Unreleased changelog + entry. + - Preserved the dated audit evidence while recording the follow-up status. + +- **Add** — `postmortem/20260728-dp-cache-signature.md` + - Recorded why a complete structural key was chosen over watchers, + collision-prone hashes, or another mutable signature owner. + +## Validation + +- Focused red: 0/2 expected tests passed on the old key. +- Focused green: 2/2 passed on the complete signature. +- Full source ERT: 96/96. +- Full compiled ERT: 96/96. +- C/Elisp fuzz: 300/300. +- Byte compilation with warnings as errors: clean. +- checkdoc: clean. +- Public rendered-output probe: cached and fresh results match in both + Elisp and C modes after a penalty change. +- 100,000 cache-hit microbenchmark: approximately 0.37 seconds versus + 0.17 seconds for the old scalar key (about 2 microseconds per lookup). + +## Risk + +- Cache keys are small structural lists instead of scalar/cons `eql` keys. +- Deliberately sweeping many parameter combinations retains one result per + signature until the paragraph cache is cleared. +- No public API, C ABI, paragraph format, or saved-file format changed. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_core_rule_ownership_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/change_core_rule_ownership_20260728.md new file mode 100644 index 0000000..f2156be --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_core_rule_ownership_20260728.md @@ -0,0 +1,33 @@ +# Change Log: Core Rule Ownership 2026-07-28 + +## task014 + +- **Modify** — `ekp.el`, `ekp-region.el` + - Centralized the edge-space exclusion used by both Elisp DPs, C-result + reconstruction, and line-glue rendering in one inline pure rule. + - Centralized the five lossless layout marker properties used by renderer + nonstickiness and region logical-string detection. + +- **Modify** — `tests/ekp-tests.el` + - Added an independent brute-force cross-check for every candidate + line's excluded edge-space width. + - Added a direct marker vocabulary/noninheritance contract. + +- **Modify/Add** — developer/audit/changelog records and + `postmortem/20260728-core-rule-ownership.md` + - Recorded why two narrow owners have net value while a file/struct split + does not. + +## Validation + +- Direct-rule red/green: 0/2 → 2/2; focused related invariants: 4/4. +- Full ERT: 129/129; C/Elisp fuzz: 300/300. +- Warning-as-error production compilation, checkdoc, release, dictionary, + and diff gates: pass. + +## Behavior and Risk + +- Public behavior and C ABI are unchanged. +- The edge-space helper is `defsubst`; it adds no byte-compiled inner-loop + allocation. +- No wrapper ladder, module, or data object is introduced. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_dictionary_governance_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/change_dictionary_governance_20260728.md new file mode 100644 index 0000000..f2a2b1f --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_dictionary_governance_20260728.md @@ -0,0 +1,57 @@ +# Change Log: Dictionary Syntax and Provenance 2026-07-28 + +## Planning + +- Added `task011` and a decision gate before parser changes: replacement + syntax could only be implemented if the fixed-width Elisp/C DP and + lossless renderer could represent it correctly. + +## task011 + +- **Modify** — `ekp-hyphen.el`, `ekp.el` + - Count and reject slash/replacement dictionaries with a typed, + cached condition. + - Propagate unsupported syntax through the public formatter while still + treating a missing optional dictionary as “hyphenation unavailable.” + +- **Add/Modify/Delete** — `dictionaries/` + - Added a 49-entry manifest with pinned source paths, SHA-256, syntax + counts, and license evidence. + - Replaced the moving/GNU-specific updater with fail-closed POSIX + `check` and deterministic `export` modes. + - Retained Basque explicitly as a verified legacy byte. + - Removed `hyph_sa_IN.dic`; the pinned upstream snapshot has no + authoritative license statement for that exact hyphenation data. + +- **Add** — affected-language and inventory tests + - Locked libhyphen golden expectations for Hungarian, Catalan, and + Albanian, plus the Esperanto slash-pattern boundary. + - Added the offline 49-entry checksum/inventory/license/syntax gate. + +- **Modify** — CI, release, public/developer/audit documentation + - Added offline and pinned-upstream dictionary gates. + - Replaced “every bundled dictionary works” with the exact supported + ordinary-pattern contract. + +- **Add** — `postmortem/20260728-dictionary-contract.md` + - Recorded why parser-only or renderer-only replacement support would be + incorrect and why the future boundary crosses the DP/C ABI. + +## Validation + +- Affected languages red/green: 0/2 → 2/2. +- Offline manifest: 49/49 pass. +- Pinned upstream check: 49/49 normalized bytes pass. +- Two fresh exports: byte-identical, 49 dictionaries each. +- Full ERT: 121/121. +- Warning-as-error byte compilation: pass. +- Production checkdoc, updater/check shell syntax, workflow YAML, and diff + checks: pass. + +## Behavior and Risk + +- `eo`, `ca`, `hu_HU`, and `sq_AL` now signal instead of silently using a + linguistically incomplete pattern subset. +- Forty-five ordinary-pattern dictionaries remain directly usable. +- Sanskrit support is deliberately removed until exact license evidence + exists; no compatibility shim or unverified license assumption remains. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_final_cleanup_review_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/change_final_cleanup_review_20260728.md new file mode 100644 index 0000000..8cd7986 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_final_cleanup_review_20260728.md @@ -0,0 +1,53 @@ +# Change Log: Final Cleanup and Independent Gate 2026-07-28 + +## task015 + +- **Delete/Modify** — changed runtime and test files + - Deleted nine unused one-line paragraph accessors and routed remaining + callers directly to the owning `ekp-para` fields. + - Replaced the direct dependency on private + `buffer-substring--filter` with public `filter-buffer-substring` + dispatch under the prior filter binding. + - Kept DELETE lifecycle cleanup outside the temporary dispatch binding so + ownership restoration observes the real buffer-local slot. + +- **Review** — fallback-like paths + - Classified language, display, optional-resource, build-command, C + allocation/no-result, sequential-pool, and test-harness paths. + - Retained only documented external-boundary compatibility/fail-safe + behavior with tests and visible failure evidence. + - Found no masking fallback, swallowed business-logic error, broad shim, + or escalation candidate. + +- **Modify/Add** — phase, audit, postmortem, and final-gate records + - Documented public filter dispatch ownership in + `postmortem/20260728-public-filter-dispatch.md`. + - Recorded the full post-clean verification and independent review + evidence. + +## Validation + +- Default and seeded-permuted ERT: 130/130 each. +- Fresh-process isolation: every one of 130 ERT tests passed. +- C/Elisp fuzz: 300/300. +- Production byte compilation with warnings as errors and checkdoc: pass. +- Pinned package-lint commit + `35996f478d81e51dae4fa30d051f741895d07399`: pass. +- Portable, native, debug, and sanitize C profiles: warning-free; portable + artifact restored. +- Release gate, offline dictionary 49/49, pinned-upstream dictionary 49/49, + shell/YAML, diff, private-API, dead-accessor, secret, and Markdown-link + checks: pass. +- Live GUI matrix: 7/7 with a clean fullscreen one-window screenshot. +- Independent code review: 98 paths, zero findings, `APPROVE`. +- Independent architecture invariant review: `CLEAR`. + +## Behavior and Risk + +- Cleanup reduced indirection and removed a private dependency without + changing public behavior, saved-file formats, C ABI, dependencies, or the + Emacs 29.1 baseline. +- No remote push, tag, publication, release artifact, or production action + was performed. +- Optional product expansions in audit section 4.4 remain demand-gated + future directions, not incomplete repository defects. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_gui_verification_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/change_gui_verification_20260728.md new file mode 100644 index 0000000..7d24ee4 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_gui_verification_20260728.md @@ -0,0 +1,32 @@ +# Change Log: Fail-Closed GUI Verification 2026-07-28 + +## task007 + +- **Modify** — `tests/ekp-gui-verify.el` + - Returned structured per-case results. + - Centralized table formatting and pass/fail aggregation. + - Exited with status 1 after reporting any batch failure. + +- **Add/Modify** — GUI ERT tests and test loaders + - Added forced-failure and success controls. + - Included the batch-safe GUI boundary tests in normal, permuted, and + isolated runners. + +- **Modify** — public, developer, audit, phase, and postmortem records + - Clarified the tests-only loading boundary and nonzero contract. + +## Validation + +- Focused red: forced-failure control 0/1. +- Focused green: failure/success controls 2/2. +- Default full ERT: 110/110. +- Non-default permuted ERT: 110/110. +- Live GUI matrix: 7/7 PASS, every row `over=0`. +- Screenshot `/tmp/emacs-kp-gui-20260728-clean.png` was inspected: one + fullscreen `*ekp-gui-verify*` window, no split, no scratch or client noise. + +## Behavior and Risk + +- Interactive matrix behavior remains a visible report buffer. +- Batch callers now receive a trustworthy status; this is intentional. +- The matrix remains a developer tool and adds no runtime dependency. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_hot_loop_performance_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/change_hot_loop_performance_20260728.md new file mode 100644 index 0000000..9cd72a7 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_hot_loop_performance_20260728.md @@ -0,0 +1,39 @@ +# Change Log: Hot-loop Performance 2026-07-28 + +## task012 + +- **Modify** — `ekp-utils.el` + - Accumulate tokenizer box fragments in reverse and concatenate once when + a box is emitted. + - Preserve zero-width attachment and text-property behavior. + +- **Modify** — `ekp-hyphen.el` + - Distinguish cache misses from cached nil with an uninterned sentinel. + - Build dense inserted output from original word slices in one + concatenation. + +- **Modify** — `tests/ekp-tests.el`, `tests/ekp-bench.el` + - Add nil call-count, long propertized token, and dense insertion + regressions. + - Add reusable 1k–8k adversarial builder measurements. + +- **Modify/Add** — developer/audit/issue documentation and + `postmortem/20260728-linear-text-builders.md` + - Record the evidence threshold, measurements, ownership decision, and + rollback boundary. + +## Validation + +- Nil cache red/green: 0/1 → 1/1; focused behavior tests: 3/3. +- Source-mode 8,000-character tokenizer: 3.133 s → 1.100 s. +- Source-mode 8,000-character dense insertion: 0.945 s → 0.013 s. +- Full ERT: 124/124; C/Elisp fuzz: 300/300. +- Warning-as-error production byte compilation and production checkdoc: + pass. + +## Behavior and Risk + +- Public tokenization and hyphenation output is unchanged. +- The cache now retains negative results until its existing invalidation + boundary. +- The implementation adds no builder abstraction or dependency. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_integration_lifecycle_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/change_integration_lifecycle_20260728.md new file mode 100644 index 0000000..ef61c8d --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_integration_lifecycle_20260728.md @@ -0,0 +1,37 @@ +# Change Log: Composable Buffer Integration Lifecycle 2026-07-28 + +## task005 + +- **Modify** — `ekp-region.el` + - Saved and restored the previous local/inherited substring-filter owner. + - Composed prior filtering with EKP logical inversion while retaining + DELETE behavior. + - Split structural unjustify from the public lifecycle boundary. + - Removed integrations when the final span disappears outside auto mode, + including external delete paths. + +- **Modify** — `tests/ekp-region-tests.el` + - Added local/inherited filter restoration, copy and DELETE composition, + final-unjustify cleanup, and auto-mode-disable cases. + +- **Modify** — public, developer, audit, and phase documentation + - Documented composition order and lifecycle ownership. + - Closed `issue003` and added + `postmortem/20260728-buffer-integration-ownership.md`. + +## Validation + +- Focused red: 0/2 for prior-filter output and final-span cleanup. +- Focused green: 2/2; expanded integration matrix 5/5. +- Full ERT: 107/107. +- C/Elisp fuzz: 300/300. +- Byte compilation with warnings as errors: clean. +- checkdoc: clean. + +## Behavior and Risk + +- Existing mode/user substring filters remain active under EKP. +- Manual unjustify, DELETE, and mode shutdown restore exact ownership. +- A prior filter that deliberately strips all text properties also removes + EKP's structural markers; such a filter owns that returned representation. +- No dependency, public API, or saved-file format changed. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_interactive_protection_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/change_interactive_protection_20260728.md new file mode 100644 index 0000000..e4b4e0b --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_interactive_protection_20260728.md @@ -0,0 +1,36 @@ +# Change Log: Interactive Protection Workflow 2026-07-28 + +## task013 + +- **Modify** — `ekp-region.el` + - All four protection commands now share one property/feedback owner. + - Interactive calls report the affected character count and + current-buffer-session lifetime. + - Auto-justify mode help and a standard EKP menu expose the existing + format, protection, removal, and diagnostic commands. + +- **Modify** — `tests/ekp-region-tests.el` + - Drive no-break and verbatim mark/clear commands through + `call-interactively`. + - Verify behavior through the real public formatters, visible feedback, + mode help, and menu discovery. + +- **Modify/Add** — bilingual README, changelog, audit and + `postmortem/20260728-session-local-protection.md` + - State that manual text properties do not survive plain-text save/reopen + and route persistent syntax through faces/predicates. + +## Validation + +- Focused public-path red/green: 0/3 → 3/3. +- Full default and seeded-permuted ERT: 127/127 each. +- Fresh-process isolation: every one of 127 ERT tests passed. +- Warning-as-error production byte compilation, checkdoc, release, + dictionary, and diff gates: pass. + +## Behavior and Risk + +- No file format, persistent metadata, or global key binding is added. +- Programmatic callers remain quiet unless they request the optional + announcement; interactive callers always receive feedback. +- Existing commands and arguments remain compatible. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_property_sensitive_fast_path_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/change_property_sensitive_fast_path_20260728.md new file mode 100644 index 0000000..7603773 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_property_sensitive_fast_path_20260728.md @@ -0,0 +1,45 @@ +# Change Log: Property-Sensitive Paragraph Fast Path 2026-07-28 + +## task016 + +- **Modify** — `ekp.el` + - Store the complete structural paragraph key beside the most-recent + string and paragraph. + - Compare the same key used by the paragraph hash cache before reusing the + fast-path result. + - Delete six style-variable watchers that compensated for the former + partial identity. + +- **Modify** — `tests/ekp-tests.el` + - Warm one string object, mutate its `ekp-no-break` property in place, and + require the next lookup to match a fresh property-aware paragraph for + CJK and Latin-with-space inputs. + - Retain distinct-string property, language, style, and unchanged-key hit + controls. + +- **Modify/Add** — README, developer, audit, issue, phase, and postmortem + records + - Document one cache-identity owner and close `issue010`. + - Record the wrong-layer compensation in + `postmortem/20260728-paragraph-fast-path-identity.md`. + +## Validation + +- Same-object property mutation red/green: 0/1 → 1/1. +- Focused paragraph-cache matrix: 6/6. +- Default and seeded-permuted full ERT: 130/130 each. +- Fresh-process isolation: every one of 130 ERT tests passed. +- C/Elisp fuzz: 300/300. +- Warning-as-error production compilation, checkdoc, pinned package-lint, + four C profiles, release/dictionary/static gates, and GUI matrix 7/7: + pass. +- Independent architecture review: `CLEAR` for the shared-key ownership. + +## Behavior and Risk + +- Public APIs, saved-file formats, C ABI, and Emacs 29.1 baseline are + unchanged. +- Cache hits now require the complete text/configuration identity already + owned by `ekp--para-key`. +- The change removes invalidation branches instead of adding watchers or a + second identity model. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_release_governance_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/change_release_governance_20260728.md new file mode 100644 index 0000000..3b1e159 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_release_governance_20260728.md @@ -0,0 +1,43 @@ +# Change Log: Release and CI Governance 2026-07-28 + +## Planning + +- Added `task010` and locked the local/remote boundary, immutable input, + Windows, tracked-record, and version-pair acceptance criteria before code. + +## task010 + +- **Add** — `tests/check-release.sh`, `Docs/RELEASING.md` + - Added one executable repository-invariant gate and a distinct + credentialed release checklist. + +- **Modify** — `.github/workflows/ci.yml` + - Pinned checkout and Emacs setup actions to verified full upstream SHAs. + - Replaced live MELPA installation with a pinned package-lint checkout. + - Added an Emacs 30.1 Windows Elisp compile/ERT baseline. + +- **Modify** — `.gitignore`, contributor/public/audit documentation + - Made `.phrase` records versionable. + - Documented immutable release artifacts, checksums, and remote checks. + - Closed `issue009` without claiming an unperformed remote publish. + +- **Add** — `postmortem/20260728-release-governance.md` + - Recorded why local invariants and credentialed remote release evidence + remain separate boundaries. + +## Validation + +- Release gate red: floating refs, missing Windows/public ERT path, and + ignored `.phrase` were all reported. +- Release gate green: repository invariants pass. +- Pinned package-lint commit: pass with an empty package directory. +- Workflow YAML parse and POSIX shell syntax: pass. +- Default ERT: 119/119. +- `git diff --check`: pass. + +## Behavior and Risk + +- Full-SHA action pins intentionally require explicit maintenance updates. +- Windows CI covers the required pure-Elisp product baseline; the optional C + module remains exercised on Linux and macOS. +- No commit, tag, remote workflow, artifact, push, or publication was made. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_repository_audit_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/change_repository_audit_20260728.md new file mode 100644 index 0000000..883aeb5 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_repository_audit_20260728.md @@ -0,0 +1,39 @@ +# Change Log: Repository System Audit 2026-07-28 + +## 2026-07-28 + +- **Add** — `Docs/REPOSITORY_AUDIT_20260728.md` + - Task: `task001`. + - Recorded the architecture baseline, verified strengths, four P1 defects, + seven P2 risks, three P3 opportunities, extension routes, non-goals, and + acceptance gates. + - Separated evidence, inference, and unknowns so future fixes can start + from reproducible facts. + +- **Modify** — `readme.md`, `readme_zh.md` + - Task: `task001`. + - Corrected the bundled dictionary count, C module version, ERT command + description, GUI verification loading instructions, and the cached + parameter-change caveat. + - Linked the dated repository audit. + +- **Modify** — `DEVELOPER.md`, `DEVELOPER_ZH.md` + - Task: `task001`. + - Linked the audit and removed stale hard-coded test counts. + +- **Modify** — `ekp_c/README.md` + - Task: `task001`. + - Corrected the documented malformed-argument behavior without changing + the C API. + +- **Add/Modify** — `.phrase` plan, task, issue, and index records + - Task: `task001`. + - Registered the audit and its unresolved follow-up work in the current + phase. + +## Behavior and Risk + +- Runtime behavior is unchanged. +- The report records implementation directions, not approved designs. +- Windows, remote CI, package-lint, sanitizer runtime loading, and a clean + GUI screenshot remain explicit validation gaps. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_save_transaction_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/change_save_transaction_20260728.md new file mode 100644 index 0000000..721a59f --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_save_transaction_20260728.md @@ -0,0 +1,38 @@ +# Change Log: Non-mutating Save Serialization 2026-07-28 + +## task004 + +- **Modify** — `ekp-region.el` + - Removed the before/after-save unjustify/rejustify transaction. + - Added one logical write-buffer owner using the documented + `write-region-annotate-functions` buffer-switch contract. + - Added bounded cleanup on success, retry, integration removal, and source + buffer teardown. + +- **Modify** — `tests/ekp-region-tests.el` + - Added real filesystem failure, forced encoding failure with successful + retry, and `quit` regressions through `save-buffer`. + - Preserved the existing successful logical-write assertion. + +- **Modify** — public, developer, audit, and phase documentation + - Documented failure-safe visible behavior and closed `issue002`. + - Added `postmortem/20260728-nonmutating-save-serialization.md`. + +## Validation + +- Focused red: 0/2; failure and interruption both removed the layout. +- Focused green: 2/2. +- Save matrix: 4/4 for success, filesystem failure, encoding retry, and + interruption. +- Full ERT: 102/102. +- C/Elisp fuzz: 300/300. +- Byte compilation with warnings as errors: clean. +- checkdoc: clean. + +## Behavior and Risk + +- The display buffer is never unformatted during saving. +- Later write annotations and coding conversion operate on logical text. +- A failed write may retain one hidden logical copy until retry or teardown; + it never grows beyond one per source buffer. +- No global advice, saved-file format, public API, or dependency changed. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/change_test_isolation_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/change_test_isolation_20260728.md new file mode 100644 index 0000000..418ed1e --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/change_test_isolation_20260728.md @@ -0,0 +1,31 @@ +# Change Log: ERT State and Order Isolation 2026-07-28 + +## task006 + +- **Modify** — `tests/ekp-tests.el` + - Dynamically scoped and restored all isolated EKP tunables. + - Replaced the mislabeled internal predicate assertion with a public + parshape dispatch test and added a fixture-restoration control. + +- **Add/Modify** — test runners and `.github/workflows/ci.yml` + - Added seeded permutation and fresh-process-per-test runners using public + ERT APIs. + - Added the reproducible permuted-order lane to CI. + +- **Modify** — changelog, developer, audit, phase, and postmortem records + - Documented the commands, invariant, evidence, and `issue004` closure. + +## Validation + +- Focused red: isolated C-loaded parshape test 0/1. +- Focused green: public dispatch, fixture restoration, and C-parameter + cases 3/3; isolated parshape 1/1. +- Reproducibly permuted full ERT: 108/108. +- Every ERT in a fresh Emacs process: 108/108. + +## Behavior and Risk + +- Production code and public API are unchanged. +- CI gains one full ERT invocation on Emacs 30.1. +- The slower fresh-process runner is an explicit local/release diagnostic, + not part of every CI job. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/issue_repository_audit_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/issue_repository_audit_20260728.md new file mode 100644 index 0000000..133ec3c --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/issue_repository_audit_20260728.md @@ -0,0 +1,273 @@ +# Issues: Repository System Audit 2026-07-28 + +Detailed evidence and prioritization live in +`Docs/REPOSITORY_AUDIT_20260728.md`. + +## issue001 [x] Incomplete cache signatures reuse stale layout results + +- **Status (2026-07-28):** Resolved by `task002` and `task003`. +- **Summary:** DP and automatic paragraph caches omitted configuration + values that determine the result. +- **Environment:** `main@29cef97`, Emacs 30.2, C module 1.5. +- **Repro:** Compute a paragraph, change `ekp-hyphen-penalty` or + `ekp-default-cws-stretch-pixel`, and compute again without clearing caches. +- **Expected vs Actual:** The second result must match a fresh computation; + it instead reuses the old cached object/result. +- **Investigation:** DP reuse and paragraph preprocessing have separate + owners, so each needs a complete structural signature. +- **Fix:** `task002` keys DP by width, looseness, and six runtime cost + parameters. `task003` shares one spacing signature between the paragraph + hash and same-string fast path, without adding watchers. +- **Verification:** Focused red/green cache tests; full ERT 99/99; C/Elisp + fuzz 300/300; warning-clean byte compilation; checkdoc clean. +- **User Confirmation:** User requested completion of all audit findings on + 2026-07-28; the automated acceptance criteria are satisfied. +- **Resolved At:** 2026-07-28. +- **Resolved By:** Codex. +- **Commit:** Pending. +- **Related:** audit P1-01, P2-01; `task002`, `task003`. + +## issue002 [x] Failed saves leave a justified buffer unformatted + +- **Status (2026-07-28):** Resolved by `task004`. +- **Summary:** The pre-save hook reverses layout, but a failed write does not + run an unconditional restoration path. +- **Environment:** `main@29cef97`, Emacs 30.2. +- **Repro:** Justify a visited buffer, make its target directory unavailable, + then call `save-buffer`. +- **Expected vs Actual:** The save should fail while preserving the visible + justified state; the buffer remains unformatted and retains stale save + markers. +- **Investigation:** Restoration existed only in `after-save-hook`; no hook + could provide finally semantics across every write failure. +- **Fix:** Whole-buffer writes switch to a logical copy from + `write-region-annotate-functions`; the display buffer is never + unformatted. +- **Verification:** Successful write, missing-directory failure, forced + encoding error plus retry, and `quit`; full ERT 102/102; fuzz 300/300; + warning-clean byte compilation; checkdoc clean. +- **User Confirmation:** User requested completion of all audit findings on + 2026-07-28; the automated acceptance criteria are satisfied. +- **Resolved At:** 2026-07-28. +- **Resolved By:** Codex. +- **Commit:** Pending. +- **Related:** audit P1-02; `task004`. + +## issue003 [x] Buffer integration is not composable or lifecycle-owned + +- **Status (2026-07-28):** Resolved by `task005`. +- **Summary:** EKP overwrites an existing copy filter and leaves hooks/filter + installed after manual unjustify removes the final layout span. +- **Environment:** `main@29cef97`, Emacs 30.2. +- **Repro:** Install a buffer-local `filter-buffer-substring-function`, + justify, copy, then unjustify the whole buffer. +- **Expected vs Actual:** Both filters should retain their semantics and + unused integrations should be removed; the prior filter is bypassed and + EKP integrations remain. +- **Investigation:** Installation used direct `setq-local`; public and + internal unjustify shared one body with no lifecycle boundary. +- **Fix:** Record local/inherited filter ownership, run the prior filter + before EKP logical inversion, split internal structural unjustify from the + public cleanup wrapper, and observe external deletion with a guarded + after-change hook. +- **Verification:** Public copy and DELETE filter paths; local/global + restoration; final unjustify; mode disable; full ERT 107/107; fuzz + 300/300; warning-clean byte compilation; checkdoc clean. +- **User Confirmation:** User requested completion of all audit findings on + 2026-07-28; the automated acceptance criteria are satisfied. +- **Resolved At:** 2026-07-28. +- **Resolved By:** Codex. +- **Commit:** Pending. +- **Related:** audit P1-03, P2-06; `task005`. + +## issue004 [x] Test order dependency produces a false green suite + +- **Status (2026-07-28):** Resolved by `task006`. +- **Summary:** The named parshape C-bypass test exercises first-line indent + and passes only after another test leaks `ekp-use-c-module=nil`. +- **Environment:** `main@29cef97`, Emacs 30.2, C module 1.5. +- **Repro:** Load the C module and run only + `ekp-test-parshape-bypasses-c`. +- **Expected vs Actual:** The test should independently prove the real + parshape dispatch path; it fails in isolation while the full suite passes. +- **Investigation:** The test binds the wrong variable and fixtures restore + only a subset of mutable global state. +- **Fix:** Bind the real parshape and drive `ekp-pixel-justify`; dynamically + restore every isolated EKP tunable; provide permuted-order and + fresh-process runners, with the former in CI. +- **Verification:** Focused 0/1 red then 3/3 green; reproducibly permuted + ERT 108/108; all 108 ERT tests passed independently in fresh Emacs + processes. +- **User Confirmation:** User requested completion of all audit findings on + 2026-07-28; the automated acceptance criteria are satisfied. +- **Resolved At:** 2026-07-28. +- **Resolved By:** Codex. +- **Commit:** Pending. +- **Related:** audit P1-04; `task006`. + +## issue005 [x] C API and build boundary need explicit validation + +- **Status (2026-07-28):** Resolved by `task008` and `task009`. +- **Summary:** Malformed direct calls can signal despite the former nil-only + contract, extreme int32 arithmetic is not closed, and the interactive + build command uses an unquoted shell path. +- **Environment:** `main@29cef97`; macOS arm64; C11 toolchain. +- **Repro:** Pass a malformed field vector directly; inspect int32 + accumulation and `ekp-c-module-build` path construction. +- **Expected vs Actual:** Inputs, error semantics, ranges, and build profiles + should be explicit; the current boundary mixes signaling, clamping, and + shell assumptions. +- **Investigation:** Fifteen-field schema checks happen piecemeal; arithmetic + continues in int32; default flags include native/LTO optimization. +- **Fix:** C 1.6 preflights the complete schema and uses + `ekp-c-invalid-input`; DP intermediates are int64 and backend signals + surface. Builds use direct argv and explicit portable/native/debug/ + sanitize profiles, with portable default. +- **Verification:** Direct boundary 0/6 → 6/6; dispatcher 0/1 → 1/1; + build process/profile 0/2 → 2/2; full ERT 116/116; fuzz 300/300; four + warning-clean profiles; real interactive and whitespace-path portable + builds. ASan runtime loading is blocked by macOS policy and remains a + Linux CI gate. +- **User Confirmation:** User requested completion of all audit findings on + 2026-07-28; the automated acceptance criteria are satisfied. +- **Resolved At:** 2026-07-28. +- **Resolved By:** Codex. +- **Commit:** Pending. +- **Related:** audit P2-02, P2-03; `task008`, `task009`. + +## issue006 [x] Dictionary claims exceed parser and provenance coverage + +- **Status (2026-07-28):** Resolved by `task011`. +- **Summary:** Alternative pattern syntax is skipped, several license/source + companions are absent, and the update script is unpinned. +- **Environment:** 50 bundled `hyph_*.dic` files at `main@29cef97`. +- **Repro:** Count non-comment patterns containing `/` and compare dictionary + files with same-name README files. +- **Expected vs Actual:** Supported syntax and provenance should be auditable; + 2399 alternative patterns across three languages are ignored and 20 + dictionaries lack same-name README files. +- **Investigation:** The parser intentionally skipped `/` patterns; deeper + review found 2399 libhyphen replacement rules across Hungarian, Catalan, + and Albanian plus four Esperanto slash-prefixed rules. The updater pulled + moving HEAD without a manifest/checksums, and Sanskrit had no exact license + evidence at the pinned snapshot. +- **Fix:** Reject those four dictionaries with a typed condition instead of + corrupt partial support; pin 49 files and their checksum/source/license + evidence; remove unverifiable Sanskrit; add portable check/export tooling. +- **Verification:** Golden red 0/2 → 2/2; offline/upstream 49/49; two + identical exports; full ERT 121/121; compile/checkdoc/shell/YAML/diff pass. +- **User Confirmation:** User requested completion of all audit findings on + 2026-07-28; the automated acceptance criteria are satisfied. +- **Resolved At:** 2026-07-28. +- **Resolved By:** Codex. +- **Commit:** Pending. +- **Related:** audit P2-04; `task011`. + +## issue007 [x] GUI verification reports failure without failing automation + +- **Status (2026-07-28):** Resolved by `task007`. +- **Summary:** The full fit command lives only under `tests/`, while its + noninteractive matrix prints failures but exits successfully. +- **Environment:** `main@29cef97`, GUI Emacs 30.2. +- **Repro:** Load `tests/ekp-gui-verify.el`; force a failing row in + noninteractive matrix mode. +- **Expected vs Actual:** Installation/loading instructions should be clear + and any failed row should return nonzero; failure is currently textual + only. +- **Investigation:** The diagnostic and matrix have no shared packaged + entrypoint or assertion boundary. +- **Fix:** Cases return structured results to one report boundary; batch + failure exits 1 after printing the table. Documentation explicitly loads + the developer tool and states it is not part of `ekp-region`. +- **Verification:** Forced-failure/success controls 2/2; default and + permuted full ERT 110/110; real GUI matrix 7/7; clean fullscreen + single-window screenshot inspected at + `/tmp/emacs-kp-gui-20260728-clean.png`. +- **User Confirmation:** User requested completion of all audit findings on + 2026-07-28; the automated acceptance criteria are satisfied. +- **Resolved At:** 2026-07-28. +- **Resolved By:** Codex. +- **Commit:** Pending. +- **Related:** audit P2-05; `task007`. + +## issue008 [x] Hot-loop allocation and nil caching need benchmark gates + +- **Status (2026-07-28):** Resolved by `task012`. +- **Summary:** Tokenization and hyphen insertion repeatedly rebuild strings, + and nil hyphen results are recomputed. +- **Environment:** `main@29cef97`, Emacs 30.2. +- **Repro:** Profile long tokens and query an unhyphenated word twice while + counting `ekp-hyphen--compute`. +- **Expected vs Actual:** Repeated nil results should hit the cache and + hot-loop growth should remain linear; nil results compute twice. +- **Investigation:** Cache lookup uses `or`, which cannot distinguish an + absent key from a cached nil. +- **Fix:** The word-position cache distinguishes absent entries with a + sentinel; tokenizer boxes accumulate reversed fragments and join once; + inserted words concatenate original slices once. +- **Verification:** Nil call-count red 0/1 → 1/1; exact long/propertized and + dense output regressions 3/3; 1k–8k benchmark improved the 8k cases from + 3.133/0.945 s to 1.100/0.013 s; full ERT 124/124; fuzz 300/300; + warning-as-error production compilation and checkdoc passed. +- **User Confirmation:** User requested completion of all audit findings on + 2026-07-28; the automated acceptance criteria are satisfied. +- **Resolved At:** 2026-07-28. +- **Resolved By:** Codex. +- **Commit:** Pending. +- **Related:** audit P3-01; `task012`. + +## issue009 [x] Release and documentation governance are not closed + +- **Status (2026-07-28):** Resolved locally by `task010`. +- **Summary:** Local version/tag state is ahead of the published remote, + CI dependencies float, Windows is absent, and ignored `.phrase` records are + called the source of truth. +- **Environment:** local `main@29cef97`, `origin/main@11437cb`. +- **Repro:** Compare local/remote commits and tags, CI pins, `.gitignore`, and + the repository workflow contract. +- **Expected vs Actual:** Release metadata and versioned decisions should be + mutually consistent; local `v1.0.0` is not present remotely and the process + source of truth is ignored. +- **Investigation:** No single release gate currently checks commit, tag, + changelog, package/C versions, CI, and artifacts together. +- **Fix:** `.phrase` is tracked truth; actions and package-lint are immutable + inputs; Windows compiles/runs the pure-Elisp baseline; an executable local + invariant gate and separate remote/artifact checklist own the release path. +- **Verification:** Gate red → green; pinned package-lint pass; workflow YAML + and shell syntax pass; default ERT 119/119; diff check pass. +- **User Confirmation:** User requested completion of all audit findings on + 2026-07-28; the automated local acceptance criteria are satisfied. +- **Resolved At:** 2026-07-28. +- **Resolved By:** Codex. +- **Commit:** Pending. +- **Related:** audit P2-07; `task010`. + +## issue010 [x] Same-string property mutation bypasses paragraph identity + +- **Status (2026-07-28):** Resolved by `task016`. +- **Summary:** `ekp--para-key` includes layout-relevant text properties, but + the most-recent paragraph fast path compares only string identity plus a + partial configuration tuple. +- **Environment:** current remediation working tree; Emacs 30.2. +- **Repro:** Resolve a string, add `ekp-no-break` to the same string object, + then resolve it again without clearing caches. +- **Expected vs Actual:** The second lookup must equal a fresh property-aware + paragraph; it returns the pre-mutation paragraph object. +- **Investigation:** The fast path duplicates only part of the hash-cache + identity contract, so in-place property changes cannot invalidate it. +- **Fix:** Store `(string, complete ekp--para-key, paragraph)` in the + most-recent slot and compare the same structural key used by the hash + cache. Remove the six style-variable watchers that compensated for the + former partial identity. +- **Verification:** Same-object mutation red 0/1 → 1/1; focused cache matrix + 6/6; default and seeded-permuted ERT 130/130; every one of 130 ERT tests + passed in a fresh Emacs process; fuzz 300/300; warning-clean Elisp/C + compilation, checkdoc, package-lint, release/dictionary/static gates, and + GUI matrix 7/7 passed. Independent architecture review returned `CLEAR` + for the shared-key ownership. +- **User Confirmation:** User requested completion of every repository + finding on 2026-07-28. +- **Resolved At:** 2026-07-28. +- **Resolved By:** Codex. +- **Commit:** Pending. +- **Related:** audit P1-05; `task016`. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/plan_auto_spacing_signature_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/plan_auto_spacing_signature_20260728.md new file mode 100644 index 0000000..b9fd8d8 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/plan_auto_spacing_signature_20260728.md @@ -0,0 +1,28 @@ +# Plan: Complete Automatic Spacing Cache Identity + +## Scope + +Finish `issue001` by fixing the auto-CWS paragraph-cache defect only. +Preserve the completed DP signature work in `task002`. + +## Resolution Path + +1. Prove the paragraph hash and same-string fast path both reuse stale + automatic spacing after `ekp-default-cws-stretch-pixel` changes. +2. Give paragraph preprocessing one spacing-signature owner shared by both + lookup paths. +3. Keep automatic font-derived values represented by the existing + font/display-context identity; add the remaining CJK default explicitly. +4. Prove an unchanged signature still hits instead of disabling caching. +5. Run focused and full ERT, fuzz, warning-clean compilation, and checkdoc. + +## Non-goals + +- No variable watcher for the CJK default. +- No DP-key or C ABI change. +- No editor-integration changes. + +## Rollback + +Revert the spacing-signature helper, the two lookup uses, and the three +regression tests. Caches are process-local, so no data migration is needed. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/plan_c_api_contract_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/plan_c_api_contract_20260728.md new file mode 100644 index 0000000..1d5b13b --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/plan_c_api_contract_20260728.md @@ -0,0 +1,31 @@ +# Plan: C API and Arithmetic Contract + +## Scope + +Resolve the P2-02 half of `issue005`: validate the complete public schema +before extraction, make caller errors explicit, and eliminate signed +overflow for valid inputs. + +## Resolution Path + +1. Lock malformed single/batch vectors, bad penalty scalars, out-of-range + integers, and a valid extreme-width calculation with direct C red tests. +2. Validate vector types, n/n+1 lengths, scalar types, positivity, and + signed 32-bit range before allocating or extracting. +3. Register one `ekp-c-invalid-input` condition for caller errors; retain + nil only for allocation/no-result. +4. Widen DP line-metric, adjustment, flexibility, and rest intermediates to + 64-bit without changing the 15-field ABI. +5. Propagate module signals through the enabled Elisp dispatcher. +6. Build release/sanitizer profiles and run focused/full ERT plus fuzz. + +## Non-goals + +- No new algorithm or C tokenization. +- No change to valid 15-field layout or result shape. +- Build command/profile portability is the next atomic task. + +## Rollback + +Restore permissive extraction, int32 intermediates, C 1.5 requirement, and +the error-swallowing dispatcher. Valid persisted data is unaffected. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/plan_c_build_boundary_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/plan_c_build_boundary_20260728.md new file mode 100644 index 0000000..b3a8dbc --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/plan_c_build_boundary_20260728.md @@ -0,0 +1,29 @@ +# Plan: Shell-Free Profiled C Builds + +## Scope + +Finish `issue005` P2-03: invoke make as argv in the module directory and +separate portable, native, debug, and sanitizer build intent. + +## Resolution Path + +1. Lock the interactive command shape with a whitespace-path process test + and an invalid-profile control. +2. Remove the one-use process wrapper and shell interpolation; use + `make-process` with a real argv and bound `default-directory`. +3. Make `portable` the Makefile default; isolate `native`, `debug`, and + `sanitize` flags behind explicit `PROFILE`. +4. Update CI and all build documentation to the same vocabulary. +5. Build every profile warning-clean, load/test the portable result, and + compile a copied source tree whose path contains spaces. + +## Non-goals + +- No dependency on CMake or another build system. +- No automatic compiler/package installation. +- No publishing or replacing a released artifact. + +## Rollback + +Restore the shell command and monolithic flags, then remove profile tests and +documentation. No persisted data changes. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/plan_cache_signature_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/plan_cache_signature_20260728.md new file mode 100644 index 0000000..43b4563 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/plan_cache_signature_20260728.md @@ -0,0 +1,42 @@ +# Plan: Complete DP Cache Signature + +## Scope + +Fix `issue001` P1-01 only: DP results must not alias when any +algorithm/cost parameter changes. The auto-CWS paragraph-key defect remains +open for a separate atomic task. + +## Resolution Path + +1. Drive the public `ekp-dp-cache` path with cached-then-changed parameters. +2. Prove each scenario differs from its baseline and matches a fresh + computation. +3. Replace the width/looseness special-case key with one complete flat + signature. +4. Use structural equality for DP-cache keys and prove identical signatures + still hit the cache. +5. Run focused tests, the complete suite, C/Elisp fuzz, byte compilation, + checkdoc, and diff/static checks. + +## Data Ownership + +The `ekp-para` DP cache owns reuse. Its key must therefore contain every +runtime value read by the DP that is not already frozen into that paragraph: + +- line width and looseness; +- line, hyphen, adjacent-fitness, and consecutive-hyphen penalties; +- last-line minimum ratio and short-line penalty. + +Paragraph-construction inputs remain owned by `ekp--para-key`. + +## Non-goals + +- No cache watchers or eager cache clearing. +- No C ABI change. +- No save/copy integration changes. +- No fix for the separate auto-CWS paragraph cache. + +## Rollback + +Revert the key/table change and the associated regression tests. No stored +file format or public API changes. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/plan_core_rule_ownership_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/plan_core_rule_ownership_20260728.md new file mode 100644 index 0000000..615b17f --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/plan_core_rule_ownership_20260728.md @@ -0,0 +1,42 @@ +# Plan: Core Rule Ownership 2026-07-28 + +## Goal + +Close audit P3-02 with the smallest net-value refactor: centralize only rules +that are currently duplicated across the hot DP/render paths, without +splitting files or wrapping workflows. + +## Existing Owners + +- DP reuse identity is already owned by `ekp--dp-key` and paragraph + construction identity by `ekp--para-key`/`ekp--spacing-signature`. +- Ideal line width is already owned by `ekp--line-ideal-pixel` and covered + by a brute-force test. + +## Missing Owners + +1. Edge-space exclusion is repeated in 1D DP, loose DP, line-glue rendering, + and ideal-width reconstruction. +2. The five layout-marker property names are repeated between the renderer + and region inversion detection. + +## Resolution Path + +1. Add red direct-rule tests against independent edge-space recomputation + and the complete marker vocabulary/nonstickiness contract. +2. Add one inline edge-space rule and reuse it at all four call sites. +3. Add one marker property constant and reuse it in the renderer and region + detection. +4. Retain existing exact round-trip, DP/C parity, fuzz, compile, and checkdoc + gates. + +## Non-goals + +- Do not split `ekp.el` or shrink the `ekp-para` struct cosmetically. +- Do not allocate a line-metrics object in the DP inner loop. +- Do not add accessors, adapters, or change the C ABI. + +## Rollback + +Inline the two rules again. Keep the direct invariants if a measurable +source-mode regression makes the inline helper unacceptable. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/plan_dictionary_governance_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/plan_dictionary_governance_20260728.md new file mode 100644 index 0000000..458fdce --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/plan_dictionary_governance_20260728.md @@ -0,0 +1,44 @@ +# Plan: Dictionary Syntax and Provenance 2026-07-28 + +## Goal + +Make the 50 bundled dictionaries reproducible and accurately described: +every byte has a pinned source/checksum/license trail, and the parser's +handling of replacement/alternative patterns is explicit and tested. + +## Scope + +1. Confirm upstream replacement syntax and golden words for Hungarian, + Catalan, and Albanian before changing the parser. +2. Add failing tests for the chosen affected-language contract and updater + manifest validation. +3. Pin the LibreOffice dictionaries commit and replace GNU-specific, + moving-HEAD update commands with a fail-closed POSIX workflow. +4. Generate a deterministic manifest containing source path, SHA-256, + syntax flags, and local/upstream license evidence for every dictionary. +5. Document the supported syntax honestly. Implement replacement semantics + only if they fit the fixed-width box/DP model without corrupting logical + text or creating a second typography engine. +6. Run golden tests, manifest verification, a double-run reproducibility + comparison, full ERT, byte compilation, and checkdoc. + +## Non-goals + +- Do not add automatic language detection. +- Do not silently treat replacement patterns as ordinary Liang patterns. +- Do not move font measurement, tokenization, or rendering ownership into + the dictionary parser. +- Do not add a parser dependency. + +## Decision Gate + +Replacement patterns conditionally rewrite glyphs at a selected break. If +correct support requires break-specific box widths across Elisp DP, C +marshalling, and rendering, preserve the safe skip behavior, expose/count it, +add language golden limitations, and document that subset. Do not emit +linguistically incorrect breaks merely to increase pattern coverage. + +## Rollback + +Restore the previous parser and bundle together. A manifest must never claim +checksums or source paths for bytes that are not present. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/plan_final_cleanup_review_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/plan_final_cleanup_review_20260728.md new file mode 100644 index 0000000..1f3a23d --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/plan_final_cleanup_review_20260728.md @@ -0,0 +1,87 @@ +# Plan: Final Cleanup and Review Gate 2026-07-28 + +## Goal + +Remove residual low-signal code from the repository-remediation diff, prove +every documented acceptance criterion, and obtain independent code and +architecture approval. + +## Behavior Lock + +- Full ERT: 129/129. +- Seeded permuted ERT: 129/129. +- Every pre-owner ERT passed in a fresh Emacs process. +- C/Elisp fuzz: 300/300. +- Exact round-trip, public protection, C parity, save failure, and release + boundary regressions are present. + +## Cleanup Scope + +All changed runtime/test/build files, with edits limited to a proven smell. +Documentation is reviewed for drift, not rewritten cosmetically. + +## Smells and Order + +1. **Fallback gate:** classify every fallback-like path before editing. +2. **Dead code:** remove the unused paragraph accessor stack and route its + few test/render call sites directly to the owning `ekp-para`. +3. **Boundary scan:** replace direct use of dependency-private dispatch + internals with the public dispatcher while preserving prior-filter and + DELETE lifecycle semantics. +4. **Duplication/naming:** retain direct code where extraction would add + glue; make no speculative refactor. +5. **Tests:** add nothing unless cleanup exposes an unprotected behavior. + +## Fallback Inventory + +- Short-language dictionary resolution: grounded compatibility behavior; + deterministic and covered by `ekp-test-hyphen-lang-fallback`. +- Batch/tty font fallback: grounded external-display boundary; batch tests + and GUI matrix cover fallback and primary environments. +- Interactive C build/load catch: grounded outer command boundary; retains + build buffer and reports the error. +- Missing optional dictionary: grounded optional-resource fallback; only + not-found is caught, unsupported syntax propagates. +- C nil/allocation/no-result and thread-pool sequential paths: grounded + fail-safe/performance-boundary fallbacks; semantic errors propagate and + C/Elisp parity/fuzz cover equivalence. +- Test/fuzz catches: assertion harness boundaries, not production defaults. + +No masking fallback, broad compatibility shim, swallowed business-logic +error, or escalation candidate was found. + +## Cleanup Result + +- Removed nine unused one-line paragraph accessors and routed the remaining + callers to the owning `ekp-para`. +- Removed the direct call to Emacs's private `buffer-substring--filter`. + EKP now temporarily binds the prior filter and delegates through public + `filter-buffer-substring`. +- The first boundary repair exposed a DELETE lifecycle ordering bug in the + focused test: cleanup ran while the temporary prior-filter binding was + active. The final implementation inhibits cleanup during dispatch, unwinds + the binding, then performs ownership cleanup against the real slot. +- Direct private-API scan is clean. No new abstraction, file, dependency, + compatibility shim, or silent fallback was introduced. + +## Quality and Review Gate + +Run full/default/permuted/isolated ERT, fuzz, portable/native/debug/sanitize +C builds, warning-as-error Elisp compilation, checkdoc, pinned +package-lint, shell/YAML/release/dictionary/static checks, GUI matrix, full +diff review, private-boundary scan, then independent `code-reviewer` +APPROVE and `architect` CLEAR. + +Current post-fix evidence: default and seeded-permuted ERT 130/130; every +one of 130 ERT tests in a fresh Emacs process; C/Elisp fuzz 300/300; +warning-clean Elisp/C builds; checkdoc and pinned package-lint; release, +dictionary, shell/YAML/static checks; and live GUI matrix 7/7 with a clean +single-window screenshot. Independent code review covered 98 current paths +with zero findings and returned `APPROVE`; the independent architecture +invariant review returned `CLEAR`. `task015` is closed. + +## Stop Condition + +All issue/task records are closed, both independent review lanes are clean, +all gates pass from the final source state, and no remote publish/tag/push +action has been performed. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/plan_gui_verification_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/plan_gui_verification_20260728.md new file mode 100644 index 0000000..5547cd1 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/plan_gui_verification_20260728.md @@ -0,0 +1,27 @@ +# Plan: Fail-Closed GUI Verification + +## Scope + +Resolve `issue007`: retain the explicitly loaded developer GUI matrix while +making any failed row a nonzero automation result. + +## Resolution Path + +1. Add a batch-safe forced-failure control for the missing assertion + boundary. +2. Preserve each case as structured data and format all rows centrally. +3. Print the full table, then exit 1 in batch mode when any row fails. +4. Document the tests-only loading boundary and automation status. +5. Run focused/full ERT, a non-default permutation, and the live seven-case + GUI matrix; inspect a clean guarded screenshot. + +## Non-goals + +- No headless approximation of real pixel rendering. +- No promotion of showcase/matrix code into the runtime package. +- No GUI CI provider or new dependency. + +## Rollback + +Restore string-only case results and table rendering, then remove the two +batch-boundary tests. No persisted data changes. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/plan_hot_loop_performance_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/plan_hot_loop_performance_20260728.md new file mode 100644 index 0000000..01c1130 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/plan_hot_loop_performance_20260728.md @@ -0,0 +1,44 @@ +# Plan: Hot-loop Performance and Nil Cache 2026-07-28 + +## Goal + +Remove only measured allocation pathologies and make cached “no hyphen” +results real cache hits without changing tokenization or hyphenation output. + +## Baseline Evidence + +Source-mode, three-run adversarial benchmark on Emacs 30.2: + +| Length | one Latin token | dense hyphen insertion | +|---:|---:|---:| +| 1,000 | 0.304 s | 0.082 s | +| 2,000 | 0.755 s | 0.061 s | +| 4,000 | 1.202 s | 0.291 s | +| 8,000 | 3.133 s | 0.945 s | + +At 8× input, tokenizer time grew about 10.3× and dense insertion about +11.5×. Both justify a linear builder. Separately, two lookups of a word +with no breaks call `ekp-hyphen--compute` twice. + +## Scope + +1. Add a failing call-count test for cached nil and behavior controls for + long/propertized tokenizer and dense insertion output. +2. Replace per-character accumulator concatenation with fragment lists that + concatenate once per emitted box. +3. Build inserted hyphen output from original word slices in one pass. +4. Use an explicit miss sentinel for the word-position cache. +5. Add reusable adversarial scaling output to `tests/ekp-bench.el`. +6. Re-run before/after scaling, focused/full ERT, warning-as-error byte + compilation, checkdoc, fuzz, and diff review. + +## Non-goals + +- Do not move tokenization or hyphenation into C. +- Do not add a rope/builder abstraction or dependency. +- Do not optimize ordinary paragraph code whose scaling is not measured. + +## Rollback + +Restore the string accumulators and insertion loop together. Keep the nil +cache regression test even if the builder change is reverted. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/plan_integration_lifecycle_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/plan_integration_lifecycle_20260728.md new file mode 100644 index 0000000..d7f8b36 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/plan_integration_lifecycle_20260728.md @@ -0,0 +1,30 @@ +# Plan: Composable Buffer Integration Lifecycle + +## Scope + +Resolve `issue003`: compose and restore an existing substring filter, and +remove save/search/copy integrations when no justified span or auto mode +needs them. + +## Resolution Path + +1. Drive the public `filter-buffer-substring` path with a prior local filter + and prove EKP currently overwrites it. +2. Record the prior filter and whether it was local; define prior-filter + then logical-inversion order so DELETE remains owned by the prior path. +3. Split internal structural unjustify from the public lifecycle wrapper. +4. Derive integration removal from actual spans plus auto-mode state, + including external deletion and mode shutdown. +5. Verify copy, delete, local/global restoration, manual unjustify, + isearch, mode disable, full ERT, fuzz, compilation, and checkdoc. + +## Non-goals + +- No new command/menu surface. +- No general multi-filter framework. +- No save-serialization redesign beyond preserving `task004`. + +## Rollback + +Restore direct filter assignment and the prior public unjustify body, then +remove the composition/lifecycle tests. No persisted state changes. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/plan_interactive_protection_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/plan_interactive_protection_20260728.md new file mode 100644 index 0000000..b34d81d --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/plan_interactive_protection_20260728.md @@ -0,0 +1,36 @@ +# Plan: Interactive Protection Workflow 2026-07-28 + +## Goal + +Make the existing no-break and verbatim workflows discoverable and +unambiguous without inventing persistence or another command layer. + +## Resolution Path + +1. Exercise all four protection commands through `call-interactively`. +2. Prove no-break affects the real formatter and verbatim affects the real + region formatter. +3. Give every property change explicit feedback that states its + current-buffer-session lifetime. +4. Expose the existing commands in the minor mode's standard menu and mode + help; keep the existing `fill-paragraph` remap. +5. Document removal commands and session-local lifetime in both READMEs. + +## Default Behavior + +Protection text properties remain local to the live buffer. Saving writes +logical text only; reopening does not restore manually applied protection. +Mode-native faces/predicates remain the persistent-source integration path. + +## Non-goals + +- Do not serialize text properties or change the file format. +- Do not add a transient UI, dependency, or global key binding. +- Do not infer language/mode syntax beyond the existing Org/Markdown + presets. + +## Validation + +Focused red/green public-command ERT, full ERT in default/permuted/isolated +orders, warning-as-error byte compilation, checkdoc, and documentation/diff +checks. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/plan_property_sensitive_fast_path_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/plan_property_sensitive_fast_path_20260728.md new file mode 100644 index 0000000..af0417e --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/plan_property_sensitive_fast_path_20260728.md @@ -0,0 +1,36 @@ +# Plan: Property-Sensitive Paragraph Fast Path 2026-07-28 + +## Scope + +Fix `issue010` only: mutating layout-relevant text properties on an already +cached string object must not reuse stale paragraph preprocessing. + +## Resolution Path + +1. Warm the public paragraph resolver with the exact string object. +2. Mutate `ekp-no-break` on that object and prove the current fast path + returns stale boxes/break permissions. +3. Make the fast path compare the same complete structural paragraph key as + the hash cache instead of maintaining a partial parallel signature. +4. Remove invalidation watchers that become redundant once one key owns both + paths. +5. Verify the mutated object matches a fresh computation across CJK and + Latin-with-space inputs, then rerun every final gate. + +## Ownership + +`ekp--para-key` is the sole owner of paragraph preprocessing identity. The +most-recent lookup may bypass the hash table, but it may not bypass or +partially reimplement that key. + +## Non-goals + +- No new cache layer or eager global invalidation. +- No public API, rendering, C ABI, or serialized-data change. +- No attempt to observe arbitrary font/theme mutation; the documented + `ekp-clear-caches` boundary remains unchanged. + +## Rollback + +Restore the partial fast-path tuple and its variable watchers. No persisted +state needs migration, but the same-object property regression returns. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/plan_release_governance_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/plan_release_governance_20260728.md new file mode 100644 index 0000000..eb009d7 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/plan_release_governance_20260728.md @@ -0,0 +1,40 @@ +# Plan: Release and CI Governance 2026-07-28 + +## Goal + +Make repository-local release evidence reproducible: CI inputs are immutable, +the supported Windows platform is exercised, `.phrase` decisions are tracked, +and one local gate checks the version and workflow invariants before a release. + +## Scope + +1. Add a failing static release-gate test for action pins, platform coverage, + tracked decision records, package/changelog version agreement, and the C + module version pair. +2. Pin third-party actions to full upstream commit SHAs with readable tag + comments. +3. Add a Windows batch-test job using the existing public test runner. +4. Stop ignoring `.phrase` and document the release checklist, including + remote/tag and artifact checks that cannot safely be automated locally. +5. Run the gate, YAML parse/static checks, default tests, and diff checks. + +## Non-goals + +- Do not push commits or tags, publish artifacts, or rewrite existing tags. +- Do not claim that an unrun remote GitHub Actions workflow is green. +- Do not add a release framework or package dependency. + +## Risks and Mitigations + +- **Pinned actions age:** keep the human-readable upstream tag beside each SHA + and make updates an explicit reviewed change. +- **Windows shell differences:** call Emacs directly from PowerShell instead + of depending on the POSIX test wrapper. +- **Local versus remote state:** the gate checks repository invariants; the + release checklist separately requires a clean commit, signed/annotated tag, + remote CI, checksums, and remote tag verification. + +## Rollback + +Revert the gate, workflow, `.gitignore`, and release documentation together. +Do not restore floating action refs independently. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/plan_repository_audit_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/plan_repository_audit_20260728.md new file mode 100644 index 0000000..45581e2 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/plan_repository_audit_20260728.md @@ -0,0 +1,48 @@ +# Plan: Repository System Audit 2026-07-28 + +## Context + +This audit is a follow-up inside the existing `phase-kp-overhaul-20260726`. +It does not open a new phase because the requested repository-wide health +review is part of the same overhaul purpose. + +## Milestones + +1. Inventory the repository, architecture, public contracts, current phase, + and release state. +2. Audit Elisp core, C module, buffer/region integration, tests, GUI, + dictionaries, documentation, and release maintenance. +3. Reproduce high-risk findings and run the existing verification surfaces + from a clean source snapshot. +4. Publish a prioritized system audit with evidence, inference boundaries, + improvement directions, extension options, and explicit non-goals. +5. Record open issues and correct factual drift in public documentation. + +## Scope + +- Runtime source is read-only for this task. +- Documentation changes may add the audit and correct facts already proven + by the repository. +- No release, push, dependency addition, or architectural implementation. + +## Evidence + +- Emacs 30.2 ERT suite and C/Elisp fuzz suite. +- Clean-copy byte compilation, checkdoc, C release build, ERT, and fuzz. +- GUI fit matrix across scaling, remapping, fringe, and narrow-window cases. +- Targeted negative probes for caches, save failure, copy-filter composition, + and test isolation. +- Local/remote Git and tag inspection. + +## Risks and Dependencies + +- Windows and remote CI were not available locally. +- macOS sanitizer runtime policy prevented loading the debug C module. +- The GUI matrix produced numerical evidence, but the clean screenshot path + was obstructed by a macOS permission prompt. + +## Stop Condition + +The audit is complete when findings are prioritized and documented, factual +README drift is corrected, phase records are updated, the complete diff is +reviewed, and no runtime source file has changed. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/plan_save_transaction_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/plan_save_transaction_20260728.md new file mode 100644 index 0000000..419cbdb --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/plan_save_transaction_20260728.md @@ -0,0 +1,30 @@ +# Plan: Non-mutating Save Serialization + +## Scope + +Resolve `issue002`: saving justified content must write logical text while +the display buffer remains unchanged on success, filesystem failure, +encoding failure, or interruption. + +## Resolution Path + +1. Drive `save-buffer` through real failure and `quit` paths and prove the + old before/after-hook transaction leaves the buffer unformatted. +2. Stop mutating the source buffer during save. +3. Use Emacs's `write-region-annotate-functions` buffer-switch contract to + serialize a logical copy at the actual write boundary. +4. Preserve later annotation/coding processing and clean the copy on + success, retry, integration removal, or source-buffer teardown. +5. Verify success, three failure classes, retry, full ERT, fuzz, + warnings-as-errors compilation, and checkdoc. + +## Non-goals + +- No global advice around `save-buffer` or `write-region`. +- No replacement implementation of Emacs file saving. +- No copy-filter composition or manual-unjustify lifecycle change. + +## Rollback + +Restore the before/after save hooks and remove the logical write-buffer +tests. No file format or persisted metadata changes. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/plan_test_isolation_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/plan_test_isolation_20260728.md new file mode 100644 index 0000000..1a91991 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/plan_test_isolation_20260728.md @@ -0,0 +1,29 @@ +# Plan: ERT State and Order Isolation + +## Scope + +Resolve `issue004`: make the parshape dispatch regression truthful, restore +all test-owned EKP configuration, and make order dependence observable. + +## Resolution Path + +1. Reproduce the named parshape test alone with the C module loaded. +2. Drive the public formatter with a real parshape and an observable C-call + negative control. +3. Dynamically bind every mutable EKP option and spacing state owned by the + shared clean-state fixture. +4. Add public-ERT-API runners for reproducible permutation and one fresh + Emacs process per test. +5. Run the focused matrix, full permutation, full isolation, compilation, + and checkdoc. + +## Non-goals + +- No test framework dependency. +- No reliance on private ERT functions. +- No production formatter change. + +## Rollback + +Restore the partial fixture and default runner, then remove the new +permutation/isolation entry points and CI step. No persisted data changes. diff --git a/.phrase/phases/phase-kp-overhaul-20260726/task_repository_audit_20260728.md b/.phrase/phases/phase-kp-overhaul-20260726/task_repository_audit_20260728.md new file mode 100644 index 0000000..a071056 --- /dev/null +++ b/.phrase/phases/phase-kp-overhaul-20260726/task_repository_audit_20260728.md @@ -0,0 +1,227 @@ +# Tasks: Repository System Audit 2026-07-28 + +- task001 [x] Produce a repository-wide audit covering architecture, Elisp, + C, buffer integration, tests, GUI, dictionaries, performance, + compatibility, documentation, release health, and extension directions. + - Source: user request; existing `phase-kp-overhaul-20260726`. + - Output: `Docs/REPOSITORY_AUDIT_20260728.md`. + - Validation: 94/94 ERT, 300/300 C/Elisp fuzz, clean-copy byte compilation, + checkdoc, warning-clean C release build, clean-copy ERT/fuzz, GUI fit + matrix 7/7, targeted negative probes, link/stale-fact/diff checks. + - Impact: documentation and planning records only; no runtime behavior + changed. + +- task002 [x] Complete the DP cache signature so algorithm-parameter changes + never reuse stale results. + - Source: `issue001`, audit P1-01, and + `plan_cache_signature_20260728.md`. + - Red test: parameter-by-parameter cached-vs-fresh comparison through + `ekp-dp-cache`, plus a same-signature cache-hit assertion. + - Validation: focused red/green ERT, complete ERT, C/Elisp fuzz, + warning-clean byte compilation, checkdoc, and full diff review. + - Impact: `ekp.el`, `tests/ekp-tests.el`, user documentation, and phase + records. + - Completed: 2026-07-28; focused 0/2 → 2/2, full ERT 96/96, fuzz + 300/300, byte compilation/checkdoc clean, Elisp/C rendered-output + probes matched fresh computation. + +- task003 [x] Complete automatic spacing identity across both paragraph + cache lookup paths. + - Source: `issue001`, audit P2-01, and + `plan_auto_spacing_signature_20260728.md`. + - Red test: change `ekp-default-cws-stretch-pixel` without clearing + caches and exercise the paragraph hash and `ekp--last-para` separately; + retain an unchanged-signature hit control. + - Validation: focused 1/3 → 3/3, complete ERT 99/99, C/Elisp fuzz + 300/300, warning-clean byte compilation, and checkdoc. + - Impact: `ekp.el`, `tests/ekp-tests.el`, public/developer documentation, + audit status, phase records, and a design postmortem. + - Completed: 2026-07-28; `issue001` is resolved. + +- task004 [x] Make justified-buffer saving non-mutating and failure-safe. + - Source: `issue002`, audit P1-02, and + `plan_save_transaction_20260728.md`. + - Red test: real missing-directory failure and forced `quit` both left the + old buffer unformatted. + - Validation: focused 0/2 → 2/2, then success/failure/encoding/retry 4/4; + complete ERT 102/102; C/Elisp fuzz 300/300; warning-clean byte + compilation; checkdoc. + - Impact: `ekp-region.el`, `tests/ekp-region-tests.el`, user/developer + documentation, audit status, phase records, and a design postmortem. + - Completed: 2026-07-28; `issue002` is resolved. + +- task005 [x] Make buffer integrations composable and span/mode-owned. + - Source: `issue003`, audit P1-03/P2-06, and + `plan_integration_lifecycle_20260728.md`. + - Red test: existing filter output was lost and final unjustify left the + EKP filter/hooks installed (0/2). + - Validation: focused 2/2 then five public composition/lifecycle cases; + complete ERT 107/107; C/Elisp fuzz 300/300; warning-clean byte + compilation; checkdoc. + - Impact: `ekp-region.el`, `tests/ekp-region-tests.el`, public/developer + documentation, audit status, phase records, and a design postmortem. + - Completed: 2026-07-28; `issue003` is resolved. + +- task006 [x] Eliminate ERT order-dependent false greens. + - Source: `issue004`, audit P1-04, and + `plan_test_isolation_20260728.md`. + - Red test: loading C and running only + `ekp-test-parshape-bypasses-c` failed 0/1. + - Validation: focused public-dispatch/config-restoration/C-parameter + matrix 3/3; reproducibly permuted ERT 108/108; every ERT independently + passed in a fresh Emacs process, 108/108. + - Impact: core fixture and dispatch regression, reusable test runners, + CI, developer documentation, audit status, phase records, and a testing + postmortem. + - Completed: 2026-07-28; `issue004` is resolved. + +- task007 [x] Make GUI verification fail closed in automation. + - Source: `issue007`, audit P2-05, and + `plan_gui_verification_20260728.md`. + - Red test: the forced noninteractive failure control had no report + assertion boundary, 0/1. + - Validation: focused forced-failure/success ERT 2/2; default and + permuted full ERT 110/110; real GUI matrix 7/7; inspected clean + fullscreen single-window screenshot. + - Impact: GUI verification result/report contract, batch-safe negative + tests, test loaders, public/developer documentation, audit status, + phase records, and a testing postmortem. + - Completed: 2026-07-28; `issue007` is resolved. + +- task008 [x] Close the C API input, error, and arithmetic contract. + - Source: `issue005`, audit P2-02, and + `plan_c_api_contract_20260728.md`. + - Red tests: malformed/short/mismatched/out-of-range/penalty inputs and + extreme valid int32 arithmetic failed 0/6; public dispatcher swallowed + a forced module error, 0/1. + - Validation: focused C boundary 6/6 and public error propagation 1/1; + warning-clean release and sanitizer compilation; full ERT 116/116; + C/Elisp fuzz 300/300. + - Impact: C API validation, 64-bit DP intermediates, module version 1.6, + Elisp backend error propagation, tests, public/developer documentation, + audit status, phase records, and an architecture postmortem. + - Completed: 2026-07-28; P2-02 is resolved. `issue005` remains open until + the build invocation/profile slice is complete. + +- task009 [x] Make C builds shell-free, portable by default, and profiled. + - Source: `issue005`, audit P2-03, and + `plan_c_build_boundary_20260728.md`. + - Red tests: capture the interactive build process shape for a module path + containing spaces and reject an unknown profile. + - Validation: focused ERT, real portable/native/debug/sanitize builds, + a copied build from a whitespace path, full ERT/fuzz, byte compilation, + checkdoc, and diff review. + - Impact: `ekp-utils.el`, `ekp_c/Makefile`, CI build arguments, tests, + public/developer/C documentation, audit status, and phase records. + - Completed: 2026-07-28; process/profile red 0/2 → 2/2; all four profiles + compiled warning-free; portable succeeded through the real interactive + command and from a whitespace path. `issue005` is resolved. + +- task010 [x] Close repository-local release and CI governance. + - Source: `issue009`, audit P2-07, and + `plan_release_governance_20260728.md`. + - Red test: a static release gate must reject floating action refs, absent + Windows coverage, ignored `.phrase` truth, or inconsistent package/C + version declarations. + - Validation: red/green gate, workflow syntax/static inspection, default + ERT, and `git diff --check`. + - Impact: CI, `.gitignore`, release documentation, audit/phase records, and + a release-governance postmortem. + - Completed: 2026-07-28; invariant gate red → green, pinned package-lint + passed, workflow YAML and shell syntax passed, and default ERT was + 119/119. `issue009` is resolved locally; no remote release action was + performed. + +- task011 [x] Make dictionary syntax support and provenance auditable. + - Source: `issue006`, audit P2-04, and + `plan_dictionary_governance_20260728.md`. + - Red tests: affected-language golden behavior and a manifest/updater + verifier must fail against the unpinned moving-HEAD bundle. + - Validation: golden red/green, 49/49 checksum/source/license manifest, + two identical pinned update outputs, full ERT, byte compilation, + checkdoc, and diff review. + - Impact: hyphen parser/tests, dictionary updater and metadata, public/ + developer/audit documentation, phase records, and an architecture + postmortem. + - Completed: 2026-07-28; affected-language red 0/2 → 2/2; offline and + upstream 49/49; two exports identical; full ERT 121/121; compile, + checkdoc, shell/YAML, and diff checks passed. `issue006` is resolved. + +- task012 [x] Make measured tokenizer/insertion growth linear and cache nil. + - Source: `issue008`, audit P3-01, and + `plan_hot_loop_performance_20260728.md`. + - Red tests: two no-break lookups must compute once; long/propertized + tokenization and dense insertion must retain exact output. + - Validation: before/after 1k–8k scaling, focused/full ERT, warning-as-error + byte compilation, checkdoc, fuzz, and diff review. + - Impact: tokenizer/hyphen cache/insertion, benchmark/tests, developer/ + audit documentation, phase records, and a performance postmortem. + - Completed: 2026-07-28; nil-cache red 0/1 → 1/1; focused output + regressions 3/3; 8,000-character tokenizer/insertion improved from + 3.133/0.945 s to 1.100/0.013 s; full ERT 124/124, fuzz 300/300, + warning-as-error production compilation and checkdoc passed. + +- task013 [x] Clarify and test interactive protection workflows. + - Source: audit P3-03, ultragoal G009, and + `plan_interactive_protection_20260728.md`. + - Red tests: drive all four no-break/verbatim commands interactively, + assert real formatter behavior and session-lifetime feedback, and + require a standard mode menu exposing the workflows. + - Validation: focused red/green ERT, default/permuted/isolated full ERT, + warning-as-error byte compilation, checkdoc, and diff review. + - Impact: public commands/mode map, region tests, bilingual user + documentation, audit/phase records, and a workflow postmortem. + - Completed: 2026-07-28; focused public-path red 0/3 → 3/3; default + and seeded-permuted ERT 127/127; every ERT passed in a fresh Emacs + process; warning-as-error production compilation, checkdoc, release, + dictionary, and diff gates passed. + +- task014 [x] Centralize the remaining duplicated core layout rules. + - Source: audit P3-02 and `plan_core_rule_ownership_20260728.md`. + - Red tests: require a directly testable edge-space exclusion rule and + one complete, nonsticky render-marker vocabulary. + - Validation: focused red/green ERT, full ERT, C/Elisp fuzz, + warning-as-error byte compilation, checkdoc, and diff review. + - Impact: `ekp.el`, `ekp-region.el`, core tests, developer/audit records, + and an architecture postmortem; no public behavior or ABI change. + - Completed: 2026-07-28; direct rules red 0/2 → 2/2; related focused + invariants 4/4; full ERT 129/129; C/Elisp fuzz 300/300; + warning-as-error compilation, checkdoc, release, dictionary, and diff + gates passed. + +- task015 [x] Run bounded anti-slop cleanup and the final independent gate. + - Source: ultragoal G010, `ai-slop-cleaner`, `code-review`, and + `plan_final_cleanup_review_20260728.md`. + - Behavior lock: 129/129 ERT and 300/300 fuzz before cleanup. + - Cleanup: delete only proven dead/pass-through accessors; classify every + fallback-like path and retain grounded boundary behavior. + - Validation: all repository quality gates plus independent + `code-reviewer` APPROVE and `architect` CLEAR. + - Impact: internal cleanup, final records, and review artifacts; no + public behavior, baseline, ABI, or remote release action. + - Completed: 2026-07-28; removed nine dead pass-through accessors and the + private substring-filter dependency; classified all fallback-like paths + with no masking fallback retained; default/permuted/isolated ERT + 130/130, fuzz 300/300, warning-clean Elisp/C builds, checkdoc, + package-lint, release/dictionary/static gates, and GUI matrix 7/7 + passed. Independent review covered 98 paths with zero findings and + returned `APPROVE`; architecture review returned `CLEAR`. + +- task016 [x] Make the same-string paragraph fast path property-sensitive. + - Source: `issue010`, independent final code review, and + `plan_property_sensitive_fast_path_20260728.md`. + - Red test: warm one string object, mutate its `ekp-no-break` property, + and require the next lookup to match a fresh paragraph for both CJK and + Latin-with-space inputs. + - Validation: focused red/green, default/permuted/isolated full ERT, fuzz, + static/build/GUI gates, and independent review. + - Impact: paragraph cache identity, core regressions, developer/audit/ + phase records, and a cache-ownership postmortem; no public API or C ABI + change. + - Completed: 2026-07-28; same-object property mutation red 0/1 → 1/1; + focused cache matrix 6/6; default and seeded-permuted ERT 130/130; + every one of 130 ERT tests passed in a fresh Emacs process; C/Elisp fuzz + 300/300; warning-as-error compilation, checkdoc, package-lint, four C + profiles, release/dictionary/static gates, and GUI matrix 7/7 passed. + Independent review confirmed the implementation fix and architecture + ownership; `issue010` is resolved. diff --git a/.phrase/phases/phase-p1p2-20260726/NOTES.md b/.phrase/phases/phase-p1p2-20260726/NOTES.md new file mode 100644 index 0000000..b005bf3 --- /dev/null +++ b/.phrase/phases/phase-p1p2-20260726/NOTES.md @@ -0,0 +1,82 @@ +# P1+P2 功能阶段笔记(2026-07-26 起) + +> 用户指令:高质量完成 P1(标点挤压、ragged 模式、no-break API)+ P2(悬挂、 +> parshape/首行缩进、连续标点);代码块等特殊文本需正确处理;**通用机制优先, +> 万不得已才做场景特化**。 + +## 总体架构决策 + +1. **地基 = 逐间隙断行许可(breaks-allowed)+ 标点独立成盒**。 + 禁则从"吞噬式附着"迁移为 DP 层的断点禁止;所有后续特性(NBSP、 + no-break 区间、行内 verbatim 原子、标点类别)都是这套机制的实例。 +2. **Emacs 显示引擎约束(已确证)**:无法缩减字形 advance(无负宽 + display)→ CLREQ 行中标点挤压不可渲染;行首/行尾挤压视觉上等价于 + "悬挂"(protrusion)→ P1-1 + P2-4 + P2-6 统一为**边缘突出机制**, + 按字符类配比率(可扩展到拉丁连字符突出 = microtype)。 +3. ragged-right/left/center:DP 侧 = 刚性 glue(stretch/shrink 数组置零) + + 每行额外伸展量 R(badness 以 R 为 flexibility);渲染侧分派剩余量 + (右/左/对半)。C 只需 +1 标量。 +4. parshape/首行缩进:每行宽依赖行号 → 复用 looseness 的 2D DP, + elisp-only(C 自动旁路,同 looseness 先例)。 +5. verbatim:段落级豁免(region 层谓词/属性)+ 行内原子 + (ekp-no-break 属性 → 禁断点 + 刚性 glue + 禁断词)。 + +## 关键实现事实(读码结论) + +- tokenizer 附着逻辑在 ekp-utils.el `ekp--handle-cjk-char/latin-char` + (开放标点 hold-and-prepend;闭合标点 append-to-prev)。 + **已知老 bug**:连续闭合标点(字。」)第二个独立成盒且断点未禁止 → + 」可出现行首;开放标点跨空格 hold 还会导致盒序与原文顺序不一致。 + 迁移后两者都根治。 +- `ekp--str-type` 返回 space/latin/cjk/cjk-punct → 拆成 cjk-open + (opening-punct-p:general-category Ps/Pi)/ cjk-close(fw-punct-p + 且非 open)。“” 特例保持 'cjk。 +- `ekp--glue-type` 新矩阵(保持旧拓扑等价):space→nws; + before=open→nws(原盒内);after=close→nws(原盒内;close-close + 从 cws 改为 nws,属有意修正);latin-latin→lws;cjk-cjk→cws; + cjk/latin 混→mws;其余含标点→cws。 +- breaks-allowed 规则:`allowed[k] = !(tail(box[k-1])=open || head(box[k])=close)`, + k∈[1,n-1];k=n(段末)恒可。存 bool-vector(elisp DP 用)+ + forbidden-positions int 向量(C 打包用,稀疏,仿 hyphen-positions)。 +- DP 改动(elisp `ekp--dp-run-1d` + C `dp_process_position` 镜像): + 候选 k 需 allowed;不 allowed 时**不 throw**继续延伸; + 紧急兜底从 single-box 推广为 atomic-run(i 到 k 间无允许断点); + 多盒紧急行需记录 gaps 计数(渲染 normal 路径 clamp ≥0 自然溢出)。 +- C 桥:`ekp-c-break-with-arrays` 11→12 参(forbidden-positions), + batch 向量同步;`ekp-c-set-penalties` 后续 ragged 加 extra-stretch + 标量;protrusion 再加两数组(head/tail protrude px)。每次 API 变 + 动 bump EKP_VERSION_MINOR + `ekp-c-module-required-version`。 +- 隐性收益:「Hello / Hello」 之前整盒无法匹配断词正则(左右标点类 + 不含 CJK 引号)→ 拆盒后可正常断词。 +- 测试影响:tests/ekp-tests.el 里 split-* 结构测试要改为新盒契约; + 新增行为级禁则测试(任意宽度:行首无 close、行尾无 open、 + 字。」不拆)。fuzz 断言与引擎无关,应保持 0 失败。 + +## 阶段与提交计划(全部完成 2026-07-26) + +- [x] A 地基 64eb2f3:标点成盒 + breaks-allowed + DP/C 1.2;顺带修复 + 连续闭合标点行首漏洞、open-punct 跨空格盒序错乱、「Hello 断词失效; + 半角标点禁则(纯标点盒判定) +- [x] B ea96a6d:ekp-no-break 属性(刚性原子/禁断词)、NBSP/NNBSP/ + FIGURE SPACE/WJ/ZWNBSP、命令 ×2;零 C 改动 +- [x] C 57a3abe:ekp-alignment 四模式 + ekp-ragged-stretch-pixel; + C 1.3(set-penalties 第 7 参 extra-stretch,缺省归零) +- [x] D f6aa64b:ekp-protrusion 右缘悬挂(cjk-close/latin-close/hyphen + 比率);DP/渲染/C 重建三处 lw=width+release 同步;C 1.4 + (break-with-arrays 14 参);region 预留 protrusion-reserve; + 仅右缘(左缘无法渲染,文档已注明) +- [x] E 720b1cd:ekp-parshape + ekp-first-line-indent(t=2em 按段落 + CJK 字体);loose 2D 每行宽;C 旁路 +- [x] F 4d9a018:ekp-verbatim 段落豁免 + ekp-region-skip-faces + + buffer-local skip-predicate;行内原子沿用 ekp-no-break;核心零改动 +- [x] G:readme×2 排版特性/verbatim 章节、DEVELOPER×2 §5.1;GUI 目检 + (悬挂+缩进+verbatim+auto-mode 齐行/ragged 两态截图确认) + +最终状态:66 ERT 全绿,fuzz 300/300(每阶段跑),C 模块 1.4 两引擎 +逐字节一致。行中挤压不可渲染(Emacs 无负宽 display)= 已知边界。 + +## 验证清单(每阶段) + +byte-compile 零警告(error-on-warn)→ 47+ ERT → C 重建 + parity → +fuzz 300 → 提交。改 ekp_c/ 后必须 make clean && make。 +Emacs: /Applications/Emacs.app/Contents/MacOS/Emacs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f9ed5d..b5255a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,93 @@ All notable changes to emacs-kp are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/), and the project aims to follow [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Fixed + +- Changing any Knuth-Plass cost parameter now selects a correctly keyed + DP/render result immediately; cached paragraphs no longer require + `ekp-clear-caches`. Structurally equal non-zero-looseness signatures now + reuse their cached result instead of missing an `eql` cons key. +- Changing `ekp-default-cws-stretch-pixel` in automatic spacing mode now + invalidates both paragraph-cache lookup paths immediately. Unchanged + spacing signatures still reuse the cached paragraph. +- Saving justified buffers now writes from a logical-text copy instead of + temporarily unformatting the display buffer. Filesystem, encoding, and + interruption failures therefore leave the visible layout intact and + retryable. +- Kill/copy integration now composes with and restores an existing + `filter-buffer-substring-function`, including DELETE operations. Removing + the final layout span outside auto mode also removes unused save/search/ + copy integrations. +- Dictionary syntax no longer degrades silently: files containing + libhyphen replacement/slash patterns fail with an explicit condition + because the fixed-width DP cannot model their conditional rewrites. +- Mutating layout-relevant text properties on an already cached string now + invalidates the same-string paragraph fast path. It reuses the complete + structural cache key instead of a partial parallel signature. + +### Changed + +- The bundled dictionary inventory is now 49 reproducible entries with a + pinned LibreOffice commit, per-file SHA-256/source/license manifest, and a + portable verifier/exporter. Sanskrit was removed because the pinned + upstream data has no authoritative license statement for that exact + hyphenation file. +- Auto-justify mode now exposes its existing formatting, protection, and + diagnostic commands in an EKP menu. Interactive no-break/verbatim changes + report that their text properties last only for the current buffer + session. + +### Tests + +- ERT fixtures now restore every mutable EKP option they isolate. The + parshape C-bypass regression drives the public formatter, and reusable + permuted-order plus per-test fresh-process runners prevent alphabetical + execution from hiding leaked state. +- The GUI fit matrix now exits with status 1 when any row fails. A + batch-safe forced-failure control locks the automation contract while the + real seven-case matrix remains an explicitly loaded developer tool. +- Release invariants now have one local gate. CI action inputs are immutable, + Windows runs the Elisp baseline, and the `.phrase` decision source is + versionable instead of ignored. + +### Performance + +- Tokenization now accumulates fragments and joins once per emitted box; + dense hyphen insertion likewise joins original word slices once. On the + 1,000–8,000-character adversarial benchmark, the 8,000-character cases + improved from 3.133 s to 1.100 s and from 0.945 s to 0.013 s respectively. +- Cached “no hyphen” results now use an explicit miss sentinel, so repeated + lookups do not rerun the dictionary computation. + +### Internal + +- DP reuse identity, edge-space exclusion, and the lossless marker + vocabulary now each have one directly tested rule owner. The consolidation + removes formula/property-list drift without adding modules or hot-loop + allocations. +- Final cleanup removed nine dead pass-through accessors and replaced direct + use of Emacs's private substring-filter dispatcher with the public + `filter-buffer-substring` path while preserving DELETE lifecycle ownership. +- The paragraph fast path no longer needs style-variable watchers: both the + one-entry path and hash cache have one structural identity owner. + +### C module (1.6) + +- The 15-field single and batch APIs now preflight vector shape, lengths, + scalar types, and signed 32-bit input range before extraction. Caller + errors signal `ekp-c-invalid-input`; allocation/no-result still returns + nil. Enabled-backend signals are no longer swallowed by the Elisp + dispatcher. +- Line metric, flexibility, and remaining-space intermediates are now + 64-bit, eliminating signed overflow when valid int32 widths and + protrusions combine. +- The default C build is portable (`PROFILE=portable`). Native CPU/LTO, + debug, and sanitizer flags are explicit profiles, and + `ekp-c-module-build` now passes argv directly to `make` from the module + directory instead of interpolating a shell command. + ## [1.0.0] - 2026-07-27 First tagged release. Highlights of the work leading up to it: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b8f06b..debad7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,10 +17,17 @@ $EMACS -Q --batch -L . \ tests/run-tests.sh $EMACS # Build the C module (required for the parity tests and fuzz) -make -C ekp_c # add DEBUG=1 for ASan/UBSan +make -C ekp_c PROFILE=portable # Property fuzz: 300 random cases, asserts C and Elisp agree byte-for-byte $EMACS -Q --batch -L . -l tests/ekp-fuzz.el + +# Check release/CI/version invariants +tests/check-release.sh + +# Check dictionary inventory and pinned upstream bytes +tests/check-dictionaries.sh +dictionaries/update.sh check ``` ## Ground rules @@ -53,6 +60,12 @@ Conventional Commits (`feat:`, `fix:`, `perf:`, `refactor!:`, `docs:`, `test:`, `chore:`). Explain the reasoning in the body, not just the change. +## Releases + +Follow [Docs/RELEASING.md](Docs/RELEASING.md). In particular, action +dependencies stay pinned to full commit SHAs, released artifacts are +immutable, and every artifact gets a SHA-256 checksum. + ## License By contributing you agree that your contributions are licensed under diff --git a/DEVELOPER.md b/DEVELOPER.md index 1ee1203..b790857 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -4,6 +4,9 @@ This document describes the internal architecture, algorithms and APIs of `emacs-kp`, as implemented. It is intended for contributors and advanced users. +Current repository health and prioritized follow-up work are recorded in +the [2026-07-28 system audit](./Docs/REPOSITORY_AUDIT_20260728.md). + ## 1. Pipeline Overview A justification call flows through five stages: @@ -55,17 +58,26 @@ Everything the DP and renderer need, computed once per paragraph: | `lead-spaces` | `lead-spaces[i]` = width of the space-box run starting at box i; index 0 forced to 0 (first-line indentation is kept) | | `trail-spaces` | `trail-spaces[k]` = width of the space-box run ending at box k−1 | | `glue-params` | plist snapshot of the nine spacing values at creation time | -| `dp-cache` | hash: line-width → dp-result plist | +| `dp-cache` | equal-keyed hash: complete DP signature → dp-result plist | The paragraph cache (`ekp--para-cache`) is keyed with `equal` on a structured key — string content, printed text-property intervals, detected fonts, the hyphenation language (`ekp-latin-lang`), and -either the nine explicit spacing values or the symbol `auto`. +either the nine explicit spacing values or the automatic CJK stretch +default. The other automatic values are derived from font measurement, +which is already represented by the font and display-context fields. Structured keys make hash collisions harmless (they were possible with the previous `sxhash`-integer scheme). The cache is flushed when it exceeds `ekp-para-cache-limit`. A one-entry fast path -(`ekp--last-para`, checked by string `eq` + language) covers the many -same-string lookups inside one justification call. +(`ekp--last-para`) bypasses only the hash lookup: it compares the same +complete structural key before reuse. In-place changes to layout-relevant +text properties therefore miss both paths and match a fresh paragraph. + +The DP signature is separate from the paragraph key. It contains line +width, looseness, and all six runtime cost parameters; therefore a +parameter change selects a new result without flushing width-independent +paragraph data. Structural `equal` comparison also lets non-zero +looseness signatures hit the cache. ### dp-result @@ -88,6 +100,8 @@ computed for ideal, min and max in O(1). Space-box runs at the line edges are excluded because the renderer strips them; the DP and the renderer therefore agree exactly, and every justified line renders at precisely the target width (`ekp-test-justify-line-width-invariant`). +`ekp--line-stripped-space-pixel` owns this exclusion rule for the 1D/loose +DP, C-result reconstruction, and renderer. ## 4. The Knuth-Plass DP @@ -164,6 +178,27 @@ original itself, so no character is ever dropped. `ekp-region.el` inverts these four structurally (`ekp-unjustify-region`) — exact even after the justified text was edited — and builds `ekp-justify-region` / `ekp-auto-justify-mode` on top. +`ekp--layout-marker-properties` owns the complete renderer/region marker +vocabulary and its non-inheritance contract. + +Saving is a non-mutating serialization boundary. +`ekp-region--write-logical-buffer` runs first in the buffer-local +`write-region-annotate-functions`, switches whole-buffer writes to a hidden +logical copy, and leaves the display buffer untouched. Subsequent annotation +functions and coding conversion operate on that copy. A successful write +disposes it immediately; a failed write keeps at most one copy, which the +next write or integration teardown replaces. Region-only `write-region` +calls intentionally retain Emacs's physical-buffer semantics; the logical +serialization boundary is the whole-buffer save path. + +Copy filtering has explicit single-slot ownership. EKP records whether the +previous `filter-buffer-substring-function` was local, temporarily restores +that value, and invokes the public `filter-buffer-substring` dispatcher to +preserve its transform and DELETE behavior. EKP then structurally removes +layout markers from the returned string. DELETE lifecycle cleanup runs after +the temporary binding is unwound, so removing the final justified span +outside auto mode restores that exact local value or reveals the inherited +value, and removes the save/search/change hooks. ### 5.1 Break permissions, alignment, protrusion, shapes @@ -193,7 +228,7 @@ after the justified text was edited — and builds (position × line-count) DP and bypass C. Indents render as leading `ekp-glue' spacers. -C module 1.5: `ekp-c-break-with-arrays' takes 15 args +C module 1.6: `ekp-c-break-with-arrays' takes 15 args (…, forbidden-positions, tail-protrudes, hyphen-protrude, first-line-width); batch vectors have 15 elements; `ekp-c-set-penalties' takes 4–7. @@ -208,6 +243,13 @@ reality check (60-paragraph, 26 k-char article, region layer included): ≈ 73 ms per width change cold, less on revisit; incremental single-paragraph re-justify after an edit ≈ 17 ms. +Adversarial source-mode builders are measured separately by +`ekp-bench-adversarial-builders`. From 1,000 to 8,000 characters, the +fragment-based tokenizer grew 6.3× and dense insertion 7.7× (near the +expected linear 8× input growth). At 8,000 characters they took 1.100 s and +0.013 s, versus 3.133 s and 0.945 s before the change. The hyphen-position +cache uses an explicit miss sentinel so a legitimate nil result is reusable. + ## 6. Looseness `ekp-looseness` ≠ 0 switches to `ekp--dp-run-loose`, a full @@ -219,7 +261,7 @@ engines never disagree. ## 7. C Module Integration -The C module (`ekp_c/`, version 1.5) runs only stage ④. Elisp remains +The C module (`ekp_c/`, version 1.6) runs only stage ④. Elisp remains the source of truth for all font-dependent data. - `ekp-c-break-with-arrays` (15 args): the para's prefix arrays, glue @@ -239,12 +281,14 @@ the source of truth for all font-dependent data. `ekp-c-module-required-version` and falls back to Elisp, preventing arity mismatches after upgrades. -Any C failure — a NULL result, an allocation failure, or a bad -argument — falls back to the Elisp engine; the Elisp bridge also -wraps the calls in `condition-case`. The module never silently -produces a different layout on partial failure. The two engines are -verified byte-identical by `ekp-test-c-parity-simple` / -`ekp-test-c-parity-files` and the 300-case property fuzz. +An unavailable module, an allocation/no-result nil, or an incompatible +module version falls back to the Elisp engine. Invalid direct API input +signals `ekp-c-invalid-input`, and any signal from an enabled backend +propagates through the public formatter; the dispatcher does not catch and +hide it. The module never silently produces a different layout on partial +failure. The two engines are verified byte-identical by +`ekp-test-c-parity-simple` / `ekp-test-c-parity-files` and the 300-case +property fuzz. ### Future direction: a paragraph-handle API @@ -267,7 +311,7 @@ become a bottleneck. ## 8. Hyphenation (ekp-hyphen.el) -Liang's pattern algorithm, Pyphen-compatible: +Liang's ordinary pattern algorithm: - `dictionaries/hyph_*.dic` are compiled to a pattern hash on first use and cached per path. Files may be UTF-8 or ISO-8859 (Emacs @@ -275,6 +319,16 @@ Liang's pattern algorithm, Pyphen-compatible: - `ekp-hyphen-create LANG` resolves exact codes, then progressively shorter prefixes (`"de_CH" → "de"`). - Margins default to 2 characters on each side of a break. +- A slash in a non-comment pattern is fail-closed. Libhyphen replacement + rules change the visible text and width only when their break wins; EKP's + current fixed-width boxes cannot represent that honestly. The compiler + counts the rules and signals `ekp-hyphen-unsupported-pattern`, and the + public formatter propagates it. +- `dictionaries/MANIFEST.tsv` pins 49 inventory entries to one LibreOffice + commit (plus one explicitly identified legacy Basque byte), their SHA-256, + syntax flag, and license evidence. `tests/check-dictionaries.sh` is the + offline gate; `dictionaries/update.sh check` verifies normalized bytes + against the upstream commit on macOS and Linux. Word boxes are matched against `^[left-punct]* (latin-word) [right-punct]*$` so that punctuation- @@ -284,12 +338,34 @@ punctuation stays glued to the first/last syllable box. ## 9. Testing & Benchmarks ```bash -tests/run-tests.sh [emacs] # 36 ERT tests, batch-safe +tests/run-tests.sh [emacs] # batch-safe ERT suite +tests/run-tests.sh [emacs] --random-order +tests/run-tests-isolated.sh [emacs] # each ERT in a fresh process +tests/check-dictionaries.sh # offline inventory/checksum gate +dictionaries/update.sh check # verify pinned upstream bytes +make -C ekp_c PROFILE=portable # release-portable default +make -C ekp_c PROFILE=native # local benchmark only +make -C ekp_c PROFILE=debug # symbols, no optimization +make -C ekp_c PROFILE=sanitize # ASan + UBSan emacs -Q --batch -L . --eval '(setq ekp-use-c-module nil)' -l tests/ekp-bench.el emacs -Q --batch -L . --eval '(progn (require (quote ekp)) (ekp-c-module-load))' \ -l tests/ekp-bench.el ``` +Set `EKP_TEST_SEED` to reproduce or vary the permuted-order run. Test +fixtures dynamically restore all EKP configuration they isolate; tests that +exercise dispatch must use the public formatter rather than only an internal +eligibility predicate. + +The GUI matrix is loaded explicitly from `tests/ekp-gui-verify.el`. It +returns status 1 after printing the table when any row fails; the ERT suite +contains a forced-failure control for this boundary. + +`M-x ekp-c-module-build` uses the same four profile names and invokes make +as an argv process in `ekp_c/`; it never constructs a shell `cd` command. +Release/CI artifacts use `portable`. Use `native` only for measurements on +the machine that will run the module. + Key invariants under test: rendered line width == target (pixel-exact justification), no content loss at any width, brute-force cross-checks of the O(1) prefix machinery, Elisp/C parity on the bundled texts, and diff --git a/DEVELOPER_ZH.md b/DEVELOPER_ZH.md index 90a772f..4a2d8c7 100644 --- a/DEVELOPER_ZH.md +++ b/DEVELOPER_ZH.md @@ -2,6 +2,9 @@ 本文档描述 `emacs-kp` 的实际内部架构、算法与 API,面向贡献者和高级用户。 +当前仓库健康度与后续工作的优先级见 +[2026-07-28 系统审计](./Docs/REPOSITORY_AUDIT_20260728.md)。 + ## 1. 处理管线 一次排版调用经过五个阶段: @@ -52,14 +55,20 @@ DP 和渲染需要的一切,每段只算一次: | `lead-spaces` | `lead-spaces[i]` = 从盒 i 开始的连续空格盒总宽;下标 0 强制为 0(首行缩进保留) | | `trail-spaces` | `trail-spaces[k]` = 到盒 k−1 结束的连续空格盒总宽 | | `glue-params` | 创建时九个间距值的 plist 快照 | -| `dp-cache` | 哈希:行宽 → dp-result plist | +| `dp-cache` | 以 `equal` 比较的哈希:完整 DP 签名 → dp-result plist | 段落缓存(`ekp--para-cache`)以 `equal` 比较结构化 key——字符串内容、 文本属性区间的打印形式、检测字体、断词语言(`ekp-latin-lang`)、九个 -显式间距值(或符号 `auto`)。结构化 key 使哈希碰撞无害(旧的 `sxhash` -整数方案理论上可能串段)。超过 `ekp-para-cache-limit` 时整体清空。 -单条快路径(`ekp--last-para`,按字符串 `eq` + 语言校验)覆盖同一次 -排版内的大量同字符串查询。 +显式间距值或自动 CJK stretch 默认值。其他自动值由字体测量派生,已 +由字体与显示上下文字段表达。结构化 key 使哈希碰撞无害(旧的 +`sxhash` 整数方案理论上可能串段)。超过 `ekp-para-cache-limit` 时 +整体清空。单条快路径(`ekp--last-para`)只绕过哈希 lookup,复用前仍 +比较同一个完整结构 key。因此排版相关 text property 的原地修改会让 +两条路径都 miss,并得到与 fresh paragraph 相同的结果。 + +DP 签名独立于段落 key,包含行宽、looseness 与全部六个运行时代价参数。 +参数变化会选择新结果,无需丢弃与宽度无关的段落数据;结构化 `equal` +比较也让非零 looseness 签名能够正常命中缓存。 ### dp-result @@ -80,6 +89,8 @@ width = raw − space-w (若盒 k−1 处断词,再加连字符宽) 理想/最小/最大三个值均 O(1) 得出。行边缘的空格盒串被排除,因为渲染层 会剥离它们;DP 与渲染层因此严格一致,每一行的渲染宽度精确等于目标宽 (测试 `ekp-test-justify-line-width-invariant`)。 +`ekp--line-stripped-space-pixel` 统一拥有 1D/loose DP、C 结果重建与 +渲染层使用的这一排除规则。 ## 4. Knuth-Plass 动态规划 @@ -146,6 +157,25 @@ batch/tty 下按字符列精确。 不会丢失。`ekp-region.el` 对这四类标记做纯结构逆变换 (`ekp-unjustify-region`)——即使排版后又被编辑过也能精确还原——并在 其上实现 `ekp-justify-region` / `ekp-auto-justify-mode`。 +`ekp--layout-marker-properties` 统一拥有 renderer/region 的完整标记 +词汇表及其不向新输入继承的契约。 + +保存是非修改式序列化边界。buffer-local +`write-region-annotate-functions` 中最先运行 +`ekp-region--write-logical-buffer`,把整 buffer 写入切换到隐藏的逻辑 +副本,显示 buffer 始终不变;后续 annotation 与编码转换继续处理该副本。 +成功写入立即销毁副本;失败时每个源 buffer 最多保留一份,下次写入或 +integration teardown 会替换并清理它。只写局部的 `write-region` +有意保留 Emacs 的物理 buffer 语义;逻辑序列化边界只覆盖整 buffer +保存路径。 + +复制过滤有明确的单槽 owner。EKP 记录原 +`filter-buffer-substring-function` 是否为 buffer-local,临时恢复该值 +并调用公开的 `filter-buffer-substring` dispatcher,以保留转换与 +DELETE 语义,再从返回字符串中结构化移除 EKP 布局标记。DELETE 的 +lifecycle 清理在临时绑定解除后执行;auto mode 外最后一个排版区间 +消失时,会恢复原 local 值或重新暴露继承值,同时移除 +save/search/change hooks。 ### 5.1 断行许可、对齐、悬挂、段形 @@ -169,7 +199,7 @@ batch/tty 下按字符列精确。 `ekp-parshape` 和 `ekp-looseness` 才需要(位置×行数)DP 并旁路 C。 缩进渲染为行首 `ekp-glue` 垫片。 -C 模块 1.5:`ekp-c-break-with-arrays` 15 参(…、forbidden-positions、 +C 模块 1.6:`ekp-c-break-with-arrays` 15 参(…、forbidden-positions、 tail-protrudes、hyphen-protrude、first-line-width);batch 向量 15 元; `ekp-c-set-penalties` 4–7 参。 @@ -180,6 +210,12 @@ w=200 ≈ 54 ms、range zh ≈ 117 ms——justify 与特性前持平,range 因 续变宽实测(60 段 2.6 万字文章,含 region 层全链路):每次变宽约 73 ms,重访宽度更快;编辑后单段增量重排约 17 ms。 +`ekp-bench-adversarial-builders` 单独测量源码解释模式下的退化 +builder。输入从 1,000 增到 8,000 字符时,基于片段的 tokenizer 增长 +6.3×,密集插入增长 7.7×,接近输入 8× 的线性增长。8,000 字符分别从 +3.133 s 降到 1.100 s、从 0.945 s 降到 0.013 s。断词位置缓存使用 +显式 miss sentinel,因此合法的 nil 结果也能复用。 + ## 6. Looseness `ekp-looseness` ≠ 0 时切换到 `ekp--dp-run-loose`:完整的 @@ -190,7 +226,7 @@ w=200 ≈ 54 ms、range zh ≈ 117 ms——justify 与特性前持平,range 因 ## 7. C 模块集成 -C 模块(`ekp_c/`,版本 1.5)只执行阶段 ④。所有字体相关数据以 Elisp +C 模块(`ekp_c/`,版本 1.6)只执行阶段 ④。所有字体相关数据以 Elisp 为唯一事实来源。 - `ekp-c-break-with-arrays`(15 参数):para 的前缀数组、glue 数组、 @@ -206,9 +242,10 @@ C 模块(`ekp_c/`,版本 1.5)只执行阶段 ④。所有字体相关数据以 E - `ekp-c-module-load` 拒绝低于 `ekp-c-module-required-version` 的模块 并回落到 Elisp,避免升级后的参数数量不匹配。 -C 端任何失败——NULL 结果、分配失败或非法参数——都回落到 Elisp 引擎 -(Elisp 桥接层也用 `condition-case` 兜住);模块不会在部分失败时静默 -产出不同的排版。两引擎输出逐字节一致,由 +模块不可用、分配/无结果返回 nil 或 ABI 版本不兼容时回落到 Elisp。 +直接 API 的非法输入 signal `ekp-c-invalid-input`;已启用后端发出的 +任何 signal 都会穿过公共 formatter,dispatcher 不捕获或隐藏。模块 +不会在部分失败时静默产出不同的排版。两引擎输出逐字节一致,由 `ekp-test-c-parity-simple` / `ekp-test-c-parity-files` 及 300 例性质 fuzz 验证。 @@ -229,13 +266,21 @@ fuzz 验证。 ## 8. 断词(ekp-hyphen.el) -Liang 模式算法,兼容 Pyphen: +普通 Liang 模式算法: - `dictionaries/hyph_*.dic` 首次使用时编译为模式哈希并按路径缓存。 文件可为 UTF-8 或 ISO-8859(Emacs 自动检测;由 `ekp-test-hyphen-de-iso8859-dict` 验证)。 - `ekp-hyphen-create LANG` 先精确匹配,再逐级缩短(`"de_CH" → "de"`)。 - 断点两侧默认至少保留 2 个字符。 +- 非注释 pattern 出现斜杠时失败关闭。libhyphen 替换规则只在断点 + 胜出时改变可见文字与宽度,当前固定宽度 box 无法诚实表达;编译器 + 计数后 signal `ekp-hyphen-unsupported-pattern`,公共排版入口保留该 + 错误。 +- `dictionaries/MANIFEST.tsv` 将 49 个条目固定到 LibreOffice 提交 + (另明确标记一个 legacy Basque 字节),记录 SHA-256、语法标记和许可 + 证据。`tests/check-dictionaries.sh` 做离线门禁, + `dictionaries/update.sh check` 在 macOS/Linux 对照固定上游字节。 词盒按 `^[左标点]* (拉丁词) [右标点]*$` 匹配,因此被标点包裹的词 (`(word)`、`word!`、`»word«`)仍可断词;标点粘在首/末音节盒上。 @@ -243,12 +288,31 @@ Liang 模式算法,兼容 Pyphen: ## 9. 测试与基准 ```bash -tests/run-tests.sh [emacs] # 36 个 ERT 测试,batch 可跑 +tests/run-tests.sh [emacs] # batch 可跑的 ERT 测试集 +tests/run-tests.sh [emacs] --random-order +tests/run-tests-isolated.sh [emacs] # 每个 ERT 使用全新进程 +tests/check-dictionaries.sh # 离线清单/校验值门禁 +dictionaries/update.sh check # 核对固定上游字节 +make -C ekp_c PROFILE=portable # 默认可移植发布构建 +make -C ekp_c PROFILE=native # 仅本机基准 +make -C ekp_c PROFILE=debug # 调试符号,不优化 +make -C ekp_c PROFILE=sanitize # ASan + UBSan emacs -Q --batch -L . --eval '(setq ekp-use-c-module nil)' -l tests/ekp-bench.el emacs -Q --batch -L . --eval '(progn (require (quote ekp)) (ekp-c-module-load))' \ -l tests/ekp-bench.el ``` +可用 `EKP_TEST_SEED` 复现或改变乱序。测试 fixture 会动态恢复其隔离的 +全部 EKP 配置;分派类测试必须经过公开排版入口,不能只断言内部资格 +谓词。 + +GUI 矩阵需显式加载 `tests/ekp-gui-verify.el`。任一行失败时,它先打印 +完整表格,再以状态码 1 退出;ERT 套件包含该边界的强制失败负控。 + +`M-x ekp-c-module-build` 使用同一组四种 profile,并在 `ekp_c/` 中以 +argv 直接启动 make,不再构造 shell `cd` 命令。发布/CI 使用 +`portable`;`native` 仅用于将在同一机器运行的基准。 + 核心被测不变式:渲染行宽 == 目标宽(像素级对齐)、任意宽度下不丢内 容、O(1) 前缀机制与暴力算法交叉验证、内置文本上的 Elisp/C 一致性、 参数持久化/同步回归。 diff --git a/Docs/RELEASING.md b/Docs/RELEASING.md new file mode 100644 index 0000000..40dc035 --- /dev/null +++ b/Docs/RELEASING.md @@ -0,0 +1,56 @@ +# Releasing emacs-kp + +Releases are immutable snapshots. Changed bytes require a new version, +new checksums, and a new tag; do not replace an existing release asset. + +## Repository gate + +Run the local invariant check before creating a release commit: + +```sh +tests/check-release.sh +``` + +It verifies: + +- every GitHub Action is pinned to a full commit SHA; +- Windows and the public ERT batch entry point are present in CI; +- `.phrase` decision records are not ignored; +- the package header has a matching dated changelog release; +- the Elisp C-module requirement matches the C header ABI version. + +This static gate does not prove a remote workflow or published artifact. + +## Release checklist + +1. Move all shipped entries out of `Unreleased` into a dated + `[X.Y.Z] - YYYY-MM-DD` section and set `Version: X.Y.Z` in `ekp.el`. +2. If the C API changed, bump both `ekp-c-module-required-version` in + `ekp-utils.el` and `EKP_VERSION_MAJOR`/`EKP_VERSION_MINOR` in + `ekp_c/ekp_module.h`. Update the C version in user and developer docs. +3. Run: + + ```sh + tests/check-release.sh + tests/run-tests.sh emacs + EKP_TEST_SEED=20260728 tests/run-tests.sh emacs --random-order + tests/run-tests-isolated.sh emacs + tests/check-dictionaries.sh + dictionaries/update.sh check + make -C ekp_c PROFILE=portable + emacs -Q --batch -L . -l tests/ekp-fuzz.el + ``` + +4. Byte-compile with warnings as errors and run package-lint/checkdoc using + the same pinned inputs as CI. +5. Verify the worktree is clean and the release commit is the reviewed commit. +6. Create `vX.Y.Z` at that exact commit. Never move an existing release tag. +7. Let the pinned Linux, macOS, Windows, and sanitizer CI jobs finish + successfully on the release commit. +8. Build release artifacts from the tagged commit with `PROFILE=portable`. + Record a SHA-256 checksum for every artifact. +9. Push the commit and tag, publish the immutable assets and checksums, then + verify the remote tag resolves to the reviewed release commit. + +If any item fails, fix it in a new commit and restart from the repository +gate. Do not publish a partial release. diff --git a/Docs/REPOSITORY_AUDIT_20260728.md b/Docs/REPOSITORY_AUDIT_20260728.md new file mode 100644 index 0000000..0243970 --- /dev/null +++ b/Docs/REPOSITORY_AUDIT_20260728.md @@ -0,0 +1,567 @@ +# emacs-kp 仓库系统审计 + +> 审计日期:2026-07-28 +> 审计基线:`main@29cef97`(本地 tag `v1.0.0`) +> 范围:Elisp 核心、C 动态模块、buffer/region 集成、测试、性能、兼容性、文档、发布与扩展边界 +> 性质:问题盘点与方向判断,不包含运行代码修复 + +## 后续状态 + +- `task002` 已在审计后的工作树中解决 P1-01:DP key 现在包含行宽、 + looseness 与全部六个运行时代价参数,并按结构相等比较。 +- `task003` 已继续解决 P2-01:auto-CWS 默认值现在同时进入段落哈希键 + 与最近段落快路径;`issue001` 的两个缓存缺陷均已闭环。 +- `task004` 已解决 P1-02:保存从逻辑副本序列化,文件系统错误、编码 + 错误与用户中断均不再改变显示 buffer;`issue002` 已闭环。 +- `task005` 已解决 P1-03/P2-06:copy filter 可组合并恢复,integration + 生命周期由 auto mode 与实际 justified span 共同决定;`issue003` + 已闭环。 +- `task006` 已解决 P1-04:测试 fixture 完整隔离配置,parshape 用例 + 经过公开分派路径;CI、可复现乱序和逐例全新进程入口共同阻断顺序 + 假绿,`issue004` 已闭环。 +- `task007` 已解决 P2-05:GUI 矩阵共享结构化结果与报告断言边界, + 任一失败行在 batch 中退出 1;强制失败负控 2/2,真实 GUI 七场景 + 7/7,`issue007` 已闭环。 +- `task008` 已解决 P2-02:C 1.6 在提取前验证单段/批量完整 schema, + 非法调用统一 signal `ekp-c-invalid-input`,有效 int32 输入使用 + int64 中间量;模块 signal 不再被 Elisp 静默吞掉。 +- `task009` 已解决 P2-03:默认构建改为 portable,native/debug/ + sanitize 需显式选择;交互命令以 argv 和 `default-directory` 启动 + make,含空格路径实测通过。`issue005` 已闭环。 +- `task010` 已解决 P2-07 的仓库内边界:CI action 与 package-lint + 固定到完整提交,Windows 运行 Elisp 基线,`.phrase` 改为可追踪事实 + 来源,release gate 与远端/产物 checklist 分责。未执行 push、tag + 变更或发布。 +- `task011` 已解决 P2-04:49 份词典固定来源路径与 SHA-256,更新器 + 可在 macOS/Linux 重建相同输出;4 份含斜杠/替换语法的词典明确失败 + 关闭,不再静默降级。缺少准确许可证据的 Sanskrit 文件已移除。 +- `task012` 已按 benchmark 修复 nil 缓存与长文本构造热点; + `task013` 已补齐交互保护命令、反馈与 session-local 文档; + `task014` 已集中行边空格与 render-marker 规则归属。 +- 最终独立审查新增的 P1-05 已由 `task016` 解决:most-recent fast + path 与 hash cache 共享完整 `ekp--para-key`,同一字符串对象原地 + 修改排版属性也会失效;`issue010` 已闭环。 +- `task015` 已完成终局清理与门禁:删除九个死 accessor 和依赖私有 + substring-filter API 的调用;最终独立代码审查 `APPROVE`、架构审查 + `CLEAR`,全部仓库内 issue/task 均已闭环。 +- 下文保留 `main@29cef97` 的原始证据与判断,不把历史问题改写成从未发生。 + +## 结论先行 + +emacs-kp 的算法地基是可靠的:Elisp 掌握文本与字体事实,C 只镜像动态规划热路径;严格遍/紧急遍保证困难输入不丢内容;buffer 层用可逆布局标记保存逻辑文本;测试覆盖了核心算法、C/Elisp parity、编辑器协议与真实 GUI 像素贴合。 + +审计基线最需要处理的不是重写算法,而是四个已经实证的边界缺陷: + +1. 算法 penalty 改变后,既有 DP 缓存仍按旧参数返回结果。 +2. 保存失败会让原本已排版的 buffer 留在未排版状态。 +3. EKP 直接覆盖已有的 `filter-buffer-substring-function`,可能破坏其他 mode 的复制语义。 +4. 一个 C 绕过测试依赖前序测试泄漏的全局状态,整套测试为绿但单独运行失败。 + +**【Taste Score】** 🟡 Acceptable + +设计方向正确,关键算法有真实测试;但缓存键、保存事务和编辑器协议组合这几个边界还没有单一 owner。继续堆 fallback、watcher 或 hook 会放大问题,下一步应先修正状态模型与边界契约。 + +**【Fatal Issues】** + +- `ekp--dp-key` 没有覆盖所有影响 demerits 的参数,配置修改会静默失效。 +- save/copy 集成不是可组合、可回滚的事务边界。 +- 测试 fixture 没有恢复完整的全局配置,已经产生假绿。 + +**【Improvement Direction】** + +- 先把“影响缓存结果的完整输入”变成显式键。 +- 把保存、复制、isearch 视为编辑器协议边界,做可组合和失败恢复。 +- 让每个测试独立运行,并让 GUI/本地 C 边界失败能可靠返回非零。 +- C 模块继续只做 DP;除非 benchmark 证明跨边界复制是主要瓶颈,不要把字体、分词或渲染语义下沉到 C。 + +## 1. 审计方法与验证基线 + +### 1.1 读取范围 + +- 核心:`ekp.el`、`ekp-utils.el`、`ekp-hyphen.el` +- 编辑器集成:`ekp-region.el` +- C 模块:`ekp_c/*.c`、`ekp_c/*.h`、`ekp_c/Makefile` +- 测试与演示:`tests/*.el`、`tests/run-tests.sh` +- 文档与发布:README、DEVELOPER、CHANGELOG、CONTRIBUTING、CI、词典更新脚本、`.phrase` +- 历史决策:`.phrase/phases/phase-kp-overhaul-20260726/HANDOFF.md`、`.phrase/phases/phase-p1p2-20260726/NOTES.md` + +### 1.2 新鲜验证 + +| 验证 | 结果 | 说明 | +|---|---|---| +| 默认 ERT 入口 | PASS,94/94 | Emacs 30.2,加载 C 1.5 | +| C/Elisp fuzz | PASS,300/300 | 字节级一致,0 failure | +| 临时干净副本 byte-compile | PASS | `byte-compile-error-on-warn=t` | +| 临时干净副本 checkdoc | PASS | 四个发布 Elisp 文件无输出 | +| 临时干净副本 C release build | PASS | C11、`-Wall -Wextra -Wpedantic` 无警告 | +| 临时干净副本 ERT + fuzz | PASS | 新构建 C 模块,94/94 + 300/300 | +| GUI 像素矩阵 | PASS,7/7 | base、缩放、face remap、无 fringe、窄窗均无 overflow | +| 单测独立负控 | FAIL(符合审计预期) | `ekp-test-parshape-bypasses-c` 单独加载 C 后失败,确认测试顺序依赖 | + +GUI 数值矩阵的七种场景均满足 `widest == target` 且 `over=0`。干净截图的第二次抓取被 macOS 录屏权限提示覆盖;未批准系统权限,因此矩阵输出可作为数值证据,但本次不把截图当成无噪声视觉证据。 + +### 1.3 未验证项 + +- 未运行本地 `package-lint`;仓库 CI 会从 MELPA 动态安装它,但本机没有固定版本。 +- 未运行 Windows 构建与 GUI 验证。 +- 未验证远端 GitHub Actions 的运行结果;本地分支尚未推到 `origin/main`。 +- C 极值整数风险未在 macOS sanitizer 下动态复现:`make DEBUG=1` 能构建,但当前系统因 sanitizer runtime code-signing policy 拒绝加载该模块。 + +## 2. 当前架构 + +```text +字符串 API + ekp-pixel-justify / ekp-pixel-range-justify + ├─ ekp-utils:分箱、字体与像素测量 + ├─ ekp-hyphen:Liang pattern 编译与断词 + ├─ ekp.el:ekp-para、前缀数组、缓存 + ├─ DP + │ ├─ Elisp 1D / 2D + │ └─ C 1D(15 字段边界,批量按段落并行) + └─ renderer:glue、soft break、soft hyphen、hidden payload + +buffer API + ekp-region.el + ├─ justify / unjustify + ├─ auto mode:宽度跟随、dirty 段落、lazy chunk + └─ 编辑器协议:save、isearch、kill/yank、undo、modified state +``` + +### 2.1 扎实的部分 + +1. **Elisp 是语义事实源,C 只做纯 DP。** + + 证据:`ekp.el:1733-1749` 构造 15 字段输入;`ekp_c/ekp.c:590-611` 暴露对应 API;`ekp_c/README.md` 明确字体相关数据留在 Elisp。 + + 判断:这是正确边界。不要重新把分词、字体测量或渲染下沉到 C。 + +2. **严格遍 → 紧急遍的失败模型一致。** + + 证据:`ekp.el:1173-1202` 与 `ekp_c/ekp_kp.c:501-537`;fuzz 300/300。 + + 判断:困难输入不丢内容的核心承诺有代码与性质测试双重保护。 + +3. **布局是可逆结构,不是破坏性格式化。** + + 证据:`ekp.el:2180-2191` 定义四类布局标记;`ekp-region.el:402-445` 做结构反转;region 测试覆盖 roundtrip、save、isearch、kill、undo。 + + 判断:模型有价值,但所有编辑器协议都必须显式适配,生命周期边界必须更硬。 + +4. **CI 覆盖面优于一般小型 Elisp 包。** + + 证据:`.github/workflows/ci.yml` 覆盖 Emacs 29.1/30.1/snapshot、C parity、fuzz、ASan/UBSan、macOS。 + + 判断:测试类型正确;主要缺口是可复现性、Windows 和失败退出契约。 + +## 3. 按优先级排序的问题 + +优先级含义: + +- P0:数据丢失、安全或默认路径不可用,立即阻断发布 +- P1:已实证的核心行为错误或测试可信度缺陷 +- P2:重要边界风险、兼容性/维护性缺陷或已知能力落差 +- P3:优化与体验机会,应由基准或真实需求驱动 + +本次未发现 P0。 + +### P1-01 算法参数不在 DP 缓存键中,修改后静默复用旧结果 + +- **Evidence** + - `ekp--dp-key` 只包含 `line-pixel` 和非零 `ekp-looseness`:`ekp.el:1184-1190`。 + - demerits 直接读取 `ekp-line-penalty`、`ekp-hyphen-penalty`、`ekp-adjacent-fitness-penalty`、`ekp-consecutive-hyphen-penalty` 等全局值:`ekp.el:1080-1125`。 + - README 声称所有算法参数对两个引擎生效。 + - 实测同一段落/宽度先用 hyphen penalty 0,再改为 1000000:缓存对象仍相同;清缓存后断点改变,cost 从 `100226389.0` 变为 `601214639.0`。 +- **Inference** + - 问题 owner 是 DP 缓存键,不是 C 参数同步。C 同步只在真正计算时运行,命中缓存时根本不会进入同步或 DP。 +- **Impact** + - 用户通过 `setq` 或 Custom 调整排版质量参数时,界面可能看起来“设置无效”,直到显式 `ekp-clear-caches` 或段落键变化。 +- **Direction** + - 把所有影响 DP 结果的参数收敛到一个不可变的 DP key;不要再靠零散 watcher 猜测失效点。 + - 回归测试必须在不清缓存的情况下修改每一类参数,并断言结果或 cost 与 fresh computation 一致。 +- **Confidence**:High + +### P1-02 保存失败会留下未排版 buffer 和 stale save state + +> 后续状态(2026-07-28):已由 `task004` 修复。保存不再执行 +> unjustify/rejustify 事务,而是在 `write-region` annotation 边界写入 +> 隐藏逻辑副本;成功、文件系统失败、编码失败和 `quit` 均有回归覆盖。 + +- **Evidence** + - `before-save-hook` 先把全部 justified spans 反转:`ekp-region.el:461-479`。 + - 只有 `after-save-hook` 会重排并清空 `ekp-region--save-state`:`ekp-region.el:481-492`。 + - 负路径实测:让 visited file 的目标目录消失后调用 `save-buffer`,得到 `still-justified=nil`、`save-state=t`。 +- **Inference** + - 成功路径测试不足以证明保存事务;底层写入报错或保存被中断时,`after-save-hook` 不会提供 finally 语义。 +- **Impact** + - README 的“保存逻辑文本,同时屏幕保持排版态”承诺在失败路径不成立;后续保存还可能携带陈旧 marker。 +- **Direction** + - 先定义保存事务的 owner 和失败恢复路径,再实现;恢复必须无条件执行,写盘失败仍要回到原显示态。 + - 增加写盘失败、编码失败、用户中断三类负路径测试。 +- **Confidence**:High + +### P1-03 覆盖已有 copy filter,破坏其他 mode/user 的复制语义 + +> 后续状态(2026-07-28):已由 `task005` 修复。测试覆盖 local/global +> prior filter、复制、DELETE kill、手动 unjustify 与 mode 关闭。 + +- **Evidence** + - 安装集成时直接 `setq-local filter-buffer-substring-function`:`ekp-region.el:378-389`。 + - 移除时只删除 EKP 自己的当前值,没有保存或恢复前一个 filter:`ekp-region.el:391-399`。 + - 负路径实测:预设 filter 为复制内容加 `PRE:`,justify 后该前缀消失。 +- **Inference** + - `filter-buffer-substring-function` 是单槽协议,EKP 必须显式组合或恢复前一个 owner;直接覆盖不是可组合集成。 +- **Impact** + - 在已有复制过滤逻辑的 major mode 中,kill/copy 可能丢失 mode 定义的语义。 +- **Direction** + - 记录前一个 buffer-local filter,明确调用顺序,并在最后一个 justified span 消失时恢复。 + - 测试已有 filter、EKP filter、删除式 kill 三条真实 public path。 +- **Confidence**:High + +### P1-04 测试顺序依赖制造假绿 + +> **Resolved by task006 (2026-07-28):** fixture 使用动态绑定恢复全部 +> tunable;parshape 用例绑定真实 `ekp-parshape` 并驱动公开 formatter。 +> 108/108 通过可复现乱序,全部 108 个测试也逐个在全新 Emacs 进程 +> 中通过。 + +- **Evidence** + - `ekp-test-parshape-bypasses-c` 的名称和 docstring 测 parshape,代码却绑定 `ekp-first-line-indent`:`tests/ekp-tests.el:225-228`。 + - C 1.5 已支持 first-line indent;真正绕过 C 的条件是 `ekp-parshape` 非 nil:`ekp.el:1667-1675`。 + - `ekp-test-params-affect-c-module` 在结束时把全局 `ekp-use-c-module` 留为 nil:`tests/ekp-tests.el:434-448`。 + - 全套 94/94;单独加载 C 后运行该测试则 1/1 FAIL。 +- **Inference** + - 默认字母序恰好隐藏了错误测试;当前 fixture 只恢复部分 penalty,没有恢复引擎和全部样式状态。 +- **Impact** + - 测试套件可能继续掩盖其他全局状态泄漏,绿灯可信度下降。 +- **Direction** + - 让 clean-state fixture 保存/恢复所有全局 tunable;该测试绑定真实 `ekp-parshape`。 + - CI 增加 isolated 或 randomized-order lane,至少覆盖依赖全局状态的测试。 +- **Confidence**:High + +### P1-05 同一字符串对象的属性修改绕过完整段落 key + +> 后续状态(2026-07-28):已由 `task016` 修复。most-recent slot +> 保存并比较完整 `ekp--para-key`,删除六个补偿性 style watcher; +> CJK 与 Latin-with-space 原地属性修改回归、专项缓存矩阵及完整 +> default/permuted/isolated ERT 均通过。 + +- **Evidence** + - `ekp--para-key` 包含过滤后的 text-property intervals。 + - `ekp--get-para` 的 most-recent fast path 只比较对象 identity、语言、 + width context 与 spacing signature。 + - 独立审查实测:warm `"文中"` 后在原对象加入 `ekp-no-break`,下一次 + lookup 仍返回原 paragraph;清缓存后的 fresh paragraph 不允许相同 + break。 +- **Inference** + - 这不是 hash collision,而是第二套不完整 identity 规则绕开了唯一 + 完整 key。 +- **Direction** + - 让 fast path 与 hash cache 共享 `ekp--para-key`,删除并行维护的 + partial signature/watchers。 + - 用 CJK 与 Latin-with-space 两类原地 property mutation 锁定 public + paragraph resolver。 +- **Confidence**:High + +### P2-01 自动 CJK stretch 默认值不在段落缓存键中 + +> 后续状态(2026-07-28):已由 `task003` 修复。新增回归分别覆盖 +> 段落哈希键、`ekp--last-para` 快路径及相同签名真实命中;修复没有 +> 新增 watcher。 + +- **Evidence** + - `ekp-default-cws-stretch-pixel` 定义于 `ekp.el:94-97`,在 auto 参数计算中使用:`ekp.el:329-336`。 + - auto 模式的 para key 只写入符号 `auto`:`ekp.el:740-746`。 + - 实测把默认值从 2 改为 9 后,返回同一个 para,缓存中的 `:cws-stretch` 仍为 2。 +- **Inference** + - 这是 P1-01 同一类模型缺陷:缓存键没有完整表达决定结果的输入。 +- **Direction** + - 将该值纳入 auto 参数 snapshot/key;测试修改后无需手动清缓存。 +- **Confidence**:High + +### P2-02 C API 的输入契约、错误语义与数值范围不闭合 + +> **Resolved by task008 (2026-07-28):** C 1.6 对 15 字段 shape、长度、 +> 类型和 int32 范围做整体验证,非法输入使用专用 condition;DP 中间 +> 行宽/前缀差/剩余空间改为 int64。六项边界红测 0/6 → 6/6,公开 +> dispatcher error 传播 0/1 → 1/1,完整 ERT 116/116、fuzz 300/300。 + +- **Evidence** + - 文档说 bad argument 返回 nil;直接传入长度错误的 vector 实测得到 `args-out-of-range`。 + - `ekp_c/ekp.c:176-237`、`323-374` 在多次 `vec_get`/`extract_integer` 后才统一检查 pending non-local exit。 + - 输入先 clamp 到 `int32_t`,随后在 `ekp_c/ekp_kp.c:287-304` 继续做有符号加减。 +- **Inference** + - 正常 `ekp.el` 调用会捕获 signal 并回落 Elisp,因此常规文本不受影响;但直接 C API 的文档契约不真实。 + - `INT32_MAX` 附近的 width/protrusion/prefix 组合可能触发 C signed overflow。真实字体不接近此范围,但公开 API 没有限制它。 +- **Direction** + - 先校验 15 字段 arity、`n/n+1` 数组长度、排序位置和数值范围,再分配/提取。 + - 统一选择“明确 signal”或“返回 nil”,不要文档与实现各说一套。 + - 内部度量改为 `int64_t` 或 checked/saturating arithmetic,API 边缘再收窄。 + - 增加 malformed vectors、极值、batch >1024 的 C 边界测试。 +- **Confidence**:Medium-High + +### P2-03 C 构建入口对路径、CPU 和调试环境过度假设 + +> **Resolved by task009 (2026-07-28):** `PROFILE=portable` 成为默认, +> native/debug/sanitize 独立;交互构建不经过 shell。两项红测 +> 0/2 → 2/2,四 profile 均零警告,真实交互构建与含空格副本构建 +> 均成功。 + +- **Evidence** + - `ekp-c-module-build` 拼接 `cd %s && make` 交给 shell,路径没有 quote:`ekp-utils.el:405-419`。 + - 默认 Makefile 使用 `-march=native -flto`:`ekp_c/Makefile:33-36`。 + - README 把普通 `make` 描述为通用入口。 + - 当前 macOS 上 `make DEBUG=1` 构建成功,但加载时因 sanitizer runtime code-signing policy 失败。 +- **Inference** + - 安装路径含空格或 shell 元字符时,交互 build 会失败,特殊路径还形成命令注入面。 + - `-march=native` 适合本机 benchmark,不适合分发预编译产物。 +- **Direction** + - 用进程 `default-directory` + 参数向量调用 `make`,不要启动 shell 执行 `cd`。 + - portable 作为默认 profile;`NATIVE=1`、`LTO=1`、`DEBUG=1` 显式选择。 + - 文档分别说明 Linux sanitizer 与 macOS 调试模块的 runtime/code-signing 条件。 +- **Confidence**:High + +### P2-04 词典覆盖与 parser 能力被文档高估 + +> 后续状态(2026-07-28):已由 `task011` 闭环。进一步核对发现除 +> 2399 条三语言 replacement rules 外,Esperanto 还有 4 条斜杠模式。 +> 当前固定宽度 DP 无法表达“仅断点胜出时替换文字与宽度”,因此四份 +> 词典统一 signal 而非产生错误断词;49 项 manifest/upstream gate 与 +> 双导出一致性均通过。 + +- **Evidence** + - 仓库实际有 50 个 `hyph_*.dic`,不是 README 所写的 70+。 + - `ekp-hyphen--compile` 跳过所有含 `/` 的 alternative patterns:`ekp-hyphen.el:114-153`。 + - 仓库中 3 个词典含 2399 条非注释 alternative pattern,主要来自 Hungarian,也涉及 Catalan/Albanian。 + - 50 个词典中有 20 个没有同名 `README_hyph_*.txt`;README 却要求“见每个词典的 README”。 + - `dictionaries/update.sh` 拉取移动中的 LibreOffice HEAD,没有 pinned commit、manifest、checksum、错误退出或跨平台处理。 +- **Inference** + - “任意 bundled dictionary 都完整工作”不成立。普通 Liang patterns 可用,但 replacement/alternative 语义被有意忽略,相关语言的断词质量会下降。 + - 许可证不一定不合规,但仅凭当前仓库无法完整核对每个词典的来源版本与许可文件。 +- **Direction** + - 先诚实声明 parser 支持的语法子集。 + - 为 Hungarian/Catalan/Albanian 加 golden words,再决定是否值得实现 replacement semantics。 + - 更新脚本必须 pin upstream commit,生成来源/许可/checksum manifest,并在 macOS/Linux 都可运行。 +- **Confidence**:High + +### P2-05 GUI 验证工具有结果,但不是可靠的发布门禁 + +> **Resolved by task007 (2026-07-28):** 单场景返回结构化结果,统一 +> report 在 batch 任一失败时退出 1。README 明确工具加载边界;强制 +> 失败/成功负控 2/2,真实 GUI 矩阵 7/7,干净全屏单窗口截图已复核。 + +- **Evidence** + - README 推荐 `M-x ekp-gui-verify`,命令实际位于 `tests/ekp-gui-verify.el`,普通 `(require 'ekp-region)` 不会定义它。 + - `ekp-gui-verify-matrix` 在 noninteractive 下只打印 `FAIL` 文本,不会以非零状态退出:`tests/ekp-gui-verify.el:184-191`。 + - 本次真实 GUI 矩阵 7/7 PASS。 +- **Inference** + - 工具对人工诊断有价值,但当前无法直接成为 CI gate;用户也可能按 README 执行一个尚未加载的命令。 +- **Direction** + - 二选一:把单次诊断纳入发布包,或明确给出加载 tests 工具的命令。 + - matrix 发现任何 FAIL 时必须 signal/exit 1;表格只是报告,不是判定。 +- **Confidence**:High + +### P2-06 手动 unjustify 后集成 hook/filter 残留 + +> 后续状态(2026-07-28):已由 `task005` 修复。auto mode 外最后 +> justified span 消失时,公共 unjustify 与外部删除路径都会卸载 +> integrations;内部 reflow/isearch 继续使用不改变生命周期的核心反转。 + +- **Evidence** + - `ekp-unjustify-region` 只反转文本属性:`ekp-region.el:402-445`。 + - integrations 只在 minor mode 关闭路径移除:`ekp-region.el:928-938`。 + - 实测全 buffer unjustify 后已经没有 justified span,但 save/isearch hooks 和 local copy filter 仍存在。 +- **Inference** + - 无 justified span 时多数 hook 会空跑,但 copy filter 仍占据协议槽;它与 P1-03 共享生命周期 owner。 +- **Direction** + - 由“buffer 是否仍有 justified span / auto mode 是否开启”派生 integration 生命周期,不要让手动命令和 mode 各自维护一套真相。 +- **Confidence**:High + +### P2-07 发布与文档治理没有闭环 + +> 后续状态(2026-07-28):仓库内治理已由 `task010` 闭环。静态 gate +> 检查 action SHA、Windows/ERT 路径、`.phrase`、package/changelog +> 与 C ABI 版本;远端 CI、tag 和 content-addressed artifact 由发布 +> checklist 在实际发版时验证,本轮没有冒充已发布。 + +- **Evidence** + - 本地 `main` 比 `origin/main` ahead 31;本地 `v1.0.0` 指向 HEAD,远端没有该 tag。 + - `CHANGELOG.md` 与 package header 已声明 1.0.0。 + - `.phrase/` 被 `.gitignore` 忽略,但仓库协议又把它定义为需求/task/change/issue 的事实源。 + - 审计前 README 的 C 版本、词典数量、测试数量、GUI 工具入口均与实现不一致。 + - CI 使用 `purcell/setup-emacs@master`、运行时最新 MELPA `package-lint`、`macos-latest`,没有 Windows job。 +- **Inference** + - 如果 GitHub `origin` 是正式发布面,则 1.0.0 尚未真正发布;如果不是,仓库缺少明确的发布状态说明。 + - 被忽略的 `.phrase` 可以做本地过程数据,但不能同时作为可审计的版本化事实源。 +- **Direction** + - 在 push 前增加 release gate:commit/tag/changelog/version/CI/checksum 一致。 + - 明确 `.phrase` 是 tracked source of truth 还是 local workflow state,只能选一个。 + - pin CI action/工具版本,增加 Windows 构建 lane。 +- **Confidence**:High + +### P3-01 tokenizer 与断词缓存还有局部性能债 + +> **Resolved by task012 (2026-07-28):** nil cache 使用显式 miss +> sentinel;tokenizer 与密集插入改为片段单次拼接。8,000 字符耗时 +> 分别从 3.133 s 降到 1.100 s、从 0.945 s 降到 0.013 s;完整 ERT +> 124/124、fuzz 300/300、warning-as-error 编译与 checkdoc 通过。 + +- **Evidence** + - `ekp-split-to-boxes` 在字符循环中反复 `concat`:`ekp-utils.el:253-316`。 + - `ekp-hyphen-inserted` 每个断点重建字符串:`ekp-hyphen.el:216-224`。 + - `ekp-hyphen--positions` 用 `(or (gethash ...) (puthash ...))`;缓存值为 nil 时无法区分 miss。对无 pattern 的 `qzxq` 连续查询两次,`ekp-hyphen--compute` 实际调用两次。 +- **Inference** + - 极长 URL、长 token、重复 acronym 或大量零宽字符下会出现额外分配;正常段落不是当前最大瓶颈。 +- **Direction** + - 先把 benchmark 加入这些退化输入,再做线性 builder 与显式 cache sentinel。 +- **Confidence**:High + +### P3-02 核心函数与 `ekp-para` 承担了过多规则 + +> **Resolved by task014 (2026-07-28):** 既有 `ekp--dp-key` / +> `ekp--line-ideal-pixel` owner 保留;四处重复的行缘空格公式收敛到 +> 一个内联纯规则,renderer/region 的五项 marker 词汇表收敛到一个 +> 常量。直接规则测试与既有 roundtrip/C parity 共同锁定边界,未拆 +> 文件、未新增热循环分配。 + +- **Evidence** + - `ekp-para` 同时保存分箱、测宽、glue、禁则、悬挂、offset 和 DP cache:`ekp.el:215-246`。 + - `ekp--make-para`、1D/2D DP、renderer 都是长流程;space/protrusion 公式还必须在 DP、C 重建和 renderer 三处保持一致。 +- **Inference** + - 继续加 typography 选项时,最可能出现的错误不是算法本身,而是忘记更新 key、15 字段边界或渲染反变换。 +- **Direction** + - 不拆新文件。先在 `ekp.el` 内把“cache signature”“line metrics”“render marker protocol”变成三个可直接测试的规则 owner。 + - 只抽取能消除重复规则的纯函数;不要建立 helper ladder。 +- **Confidence**:High + +### P3-03 交互入口与临时标记的产品语义不够清楚 + +> **Resolved by task013 (2026-07-28):** 四个保护命令均通过真实 +> interactive/public formatter 路径测试;minor mode 增加标准 EKP +> 菜单与 help,命令反馈及双语文档明确属性仅在当前 buffer session +> 有效。未引入持久化格式或全局快捷键。 + +- **Evidence** + - minor mode keymap 只 remap `fill-paragraph`:`ekp-region.el:897-899`。 + - no-break/verbatim 命令只写入普通 text property:`ekp-region.el:568-595`;普通文件保存重开不会保留这些属性。 +- **Inference** + - 用户难以发现 justify/unjustify/protect 操作,也可能误以为手工保护会持久化。 +- **Direction** + - 先补 mode help、状态反馈和“仅当前 buffer session 有效”的文档;只有真实用户需要时再设计持久化语法映射。 +- **Confidence**:Medium + +## 4. 优化与扩展路线 + +### 4.1 第一优先级:恢复行为可信度 + +1. 完整 DP cache signature。 +2. 保存失败的无条件恢复。 +3. copy filter 组合与 integrations 单一生命周期。 +4. 测试 fixture 隔离、错误 parshape 测试修正、负路径测试。 + +这四项完成前,不建议扩大 typography 功能面。 + +### 4.2 第二优先级:硬化 C 与发布边界 + +1. 15 字段 schema preflight、`int64_t`/checked arithmetic。 +2. portable/native/debug 三种构建 profile。 +3. 可诊断的 C fallback:用户路径仍回落,但 debug/CI 能看到失败原因。 +4. GUI matrix 非零退出、Windows job、固定 CI 依赖。 +5. 词典来源/许可/checksum manifest。 + +### 4.3 性能方向:只做有基准支撑的工作 + +| 方向 | 当前判断 | 启动条件 | +|---|---|---| +| tokenizer 线性 builder | 小而明确 | 长 token benchmark 显示明显非线性 | +| nil-aware hyphen cache | 低成本 | 可直接修复并加计数测试 | +| C paragraph-handle API | 潜在高收益、高复杂度 | range search profile 证明 15 数组重复 marshal 是主要瓶颈 | +| 更多线程内并行 | 不建议 | 单段 DP 有前向依赖,当前按段落并行边界正确 | +| 把测宽/分词移入 C | 不建议 | 会拆散字体事实源并放大 parity 成本 | + +### 4.4 可选产品扩展 + +1. **精确 width search 模式** + + 当前 ternary + local scan 明确不保证全局最优。可为小范围提供 exhaustive 选项,并保留当前快速默认。 + +2. **按段落选择断词语言** + + 当前 `ekp-latin-lang` 是全局单值。若真实文档有多语言段落,可增加 buffer-local resolver;不要先做自动语言识别。 + +3. **可发布的诊断入口** + + 把 `ekp-diagnose` 与 GUI fit check 收敛成一个用户入口:快速检查当前 buffer,需要完整矩阵时再加载 tests 工具。 + +4. **mode-native verbatim/no-break** + + Org/Markdown 优先从语法/face 派生保护,不把瞬时 text property 当持久存储。其他 mode 通过一个 buffer-local predicate 接入。 + +5. **发布包与词典可选化** + + 词典占仓库约 6.1 MiB。若分发场景确有需要,可提供 core + language packs;没有安装/更新痛点前不要拆包。 + +### 4.5 明确不做 + +- 左缘悬挂与行中 glyph advance 压缩:当前 Emacs 显示模型不能可靠表达,已有文档说明。 +- 把一个 coherent `ekp.el` 按“utils/common”标签拆成多个文件。 +- 为了“以后也许需要”而新增协议层、adapter 或兼容 shim。 +- 在没有 profile 数据前重写 C DP 或增加候选级并行。 + +## 5. 建议的验收门槛 + +### 核心行为 + +- 所有影响 DP 的参数修改后,不清缓存也与 fresh computation 一致。 +- 保存成功、保存失败、用户中断后,buffer 的显示态、逻辑文本、modified state 和 marker state 都正确。 +- 已有 copy filter 与 EKP filter 组合后,两方语义都保留。 + +### 测试 + +- 每个 ERT 可单独运行。 +- randomized order 通过。 +- 现有 94 ERT、300 fuzz、C parity 全通过。 +- GUI matrix 任一 FAIL 返回非零。 + +### C 边界 + +- malformed vector 不越界、不继续带 pending exit 调用 API。 +- 数值上限有文档、有检查、有 sanitizer 覆盖。 +- portable build 不含 `-march=native`;native benchmark profile 单独启用。 + +### 发布 + +- package version、C ABI version、README、CHANGELOG、tag、远端 commit 对齐。 +- CI action/tool 版本固定。 +- 词典 manifest 能回答“来源 commit、文件 checksum、许可文件”。 + +## 6. Evidence / Inference / Unknown 总结 + +### Evidence + +- 默认与 fresh-build 测试均 94/94;fuzz 300/300;GUI 数值矩阵 7/7。 +- 四个 P1 问题均有代码路径与负控/独立复现。 +- README/C ABI/test count/词典数量与仓库事实曾发生漂移。 +- 本地 main/tag 与 origin 状态不一致。 + +### Inference + +- C `int32_t` 极值会造成 signed overflow 风险,但常规字体输入不会接近该范围。 +- alternative patterns 被忽略会降低相关语言质量;具体单词影响需要 golden corpus 定量。 +- 交互可发现性不足与 text property 非持久化会造成用户困惑,尚无用户研究数据。 + +### Unknown + +- Windows C 模块和 region UI 在当前版本是否完整通过。 +- GitHub Actions 在尚未推送的 31 个本地提交上是否通过。 +- 远端缺少 v1.0.0 是有意暂缓还是发布遗漏。 +- 用户是否真正需要精确 width search、多语言 resolver 或 language packs。 + +## 7. 停止条件 + +本次任务在以下状态停止: + +- 已覆盖代码、C、测试、GUI、文档、发布与扩展面; +- 已把问题按证据和优先级写入文档; +- 已给出优化方向与“不做什么”; +- 未修改运行代码,也未推送/发布; +- 具体修复应从 P1-01 开始,每次只闭环一个原子问题。 diff --git a/dictionaries/LICENSES.md b/dictionaries/LICENSES.md new file mode 100644 index 0000000..9af42b5 --- /dev/null +++ b/dictionaries/LICENSES.md @@ -0,0 +1,37 @@ +# Dictionary provenance and license evidence + +The dictionary bytes in this directory are third-party data, not covered +solely by emacs-kp's `COPYING`. `MANIFEST.tsv` identifies the evidence for +every bundled file: + +- `embedded` means the dictionary itself contains its copyright and license + or redistribution notice. +- `README_hyph_*.txt` names the checked-in companion notice. +- `LICENSES.md` refers to one of the pinned upstream companion files below. + +The pinned LibreOffice snapshot is +`8fb8e794237cff49ec212023f96bcdb7d3fbf56c`. Immutable source links: + +| Dictionaries | Upstream license evidence | +|---|---| +| `hyph_eo.dic` | [`eo/license-en.txt`](https://github.com/LibreOffice/dictionaries/blob/8fb8e794237cff49ec212023f96bcdb7d3fbf56c/eo/license-en.txt) | +| `hyph_id_ID.dic` | [`id/LICENSE-dict`](https://github.com/LibreOffice/dictionaries/blob/8fb8e794237cff49ec212023f96bcdb7d3fbf56c/id/LICENSE-dict) | +| `hyph_mr_IN.dic` | [`mr_IN/COPYING`](https://github.com/LibreOffice/dictionaries/blob/8fb8e794237cff49ec212023f96bcdb7d3fbf56c/mr_IN/COPYING) | +| `hyph_nl_NL.dic` | [`nl_NL/license_en_EN.txt`](https://github.com/LibreOffice/dictionaries/blob/8fb8e794237cff49ec212023f96bcdb7d3fbf56c/nl_NL/license_en_EN.txt) | +| `hyph_ru_RU.dic` | [`ru_RU/README_ru_RU.txt`](https://github.com/LibreOffice/dictionaries/blob/8fb8e794237cff49ec212023f96bcdb7d3fbf56c/ru_RU/README_ru_RU.txt) | + +`hyph_eu.dic` is a legacy checked-in source whose byte is not present in the +pinned LibreOffice tree. Its origin, copyright, redistribution terms, and +checksum are retained in `README_hyph_eu.txt` and `MANIFEST.tsv`; the updater +copies that verified local byte instead of pretending it came from the +snapshot. + +`hyph_sa_IN.dic` is intentionally not bundled. At the pinned snapshot it has +neither an in-file copyright/license notice nor a package companion that +states terms for the hyphenation data. LibreOffice reverted a generic +`COPYING` addition because spelling and hyphenation files may have different +licenses. Re-add Sanskrit only after an authoritative license statement for +that exact data is available. + +License texts and notices remain the authority. This index is provenance +metadata, not a reinterpretation of their terms. diff --git a/dictionaries/MANIFEST.tsv b/dictionaries/MANIFEST.tsv new file mode 100644 index 0000000..a95b786 --- /dev/null +++ b/dictionaries/MANIFEST.tsv @@ -0,0 +1,54 @@ +# emacs-kp dictionary manifest version 1 +# upstream https://github.com/LibreOffice/dictionaries.git +# upstream-commit 8fb8e794237cff49ec212023f96bcdb7d3fbf56c +# normalization CRLF is converted to LF; hyph_sr-Latn.dic is renamed hyph_sr_Latn.dic +# columns file source-path sha256 license-evidence unsupported-slash-patterns +hyph_af_ZA.dic af_ZA/hyph_af_ZA.dic 541a45d73d92c69445613ec558c2c1a6829550d1a1ad8aa9dc6dfb752db87da1 embedded 0 +hyph_as_IN.dic as_IN/hyph_as_IN.dic 321ffb692c12792dddb4294ab5857821fc1ab8f2d0a8c0fcdced00200602ead6 embedded 0 +hyph_be_BY.dic be_BY/hyph_be_BY.dic e4339b5a53d2f0fb4179c1060173788d5b6ed3f07bbbccb8b74f5cea614a809e embedded 0 +hyph_bg_BG.dic bg_BG/hyph_bg_BG.dic 7dad4f2ccfce1909b27f53082cf57c7914809a8ba31ccbcee3dc817dcd7c7994 README_hyph_bg_BG.txt 0 +hyph_ca.dic ca/dictionaries/hyph_ca.dic 4a4816e93a92df35819c5f4775106577aa279739a12c687291d747a3ce767b8b embedded 2 +hyph_cs_CZ.dic cs_CZ/hyph_cs_CZ.dic a378d024438b446ac0524237dc6d728dc520550db5fe0fb1fdbb47c2ee0ca525 README_hyph_cs_CZ.txt 0 +hyph_da_DK.dic da_DK/hyph_da_DK.dic 44d0dd1d85bcdb1e63003c97cd08924f10a8a8127380b1b07ac2e75a950c216b README_hyph_da_DK.txt 0 +hyph_de_AT.dic de/hyph_de_AT.dic 3e0b4687479d25099e37ebd946998c08fcccc12a0acf9380d8b19319a50b9f3a embedded 0 +hyph_de_CH.dic de/hyph_de_CH.dic 825159436515ab150d3da0ddf5eedb814c0ec6c7eec98fbd53e3ba5b2a4a1443 embedded 0 +hyph_de_DE.dic de/hyph_de_DE.dic 2e2f5ffea6bc53f67ffe8c076921c7b1b34a2ec2b9ec57a4608374bb65a1a1e1 embedded 0 +hyph_el_GR.dic el_GR/hyph_el_GR.dic f2284153c3f9a809b959dcaeb349e00d90b4c4cf37c3d3a338b371c32f10e89d README_hyph_el_GR.txt 0 +hyph_en_GB.dic en/hyph_en_GB.dic 9fdc97f7faabcda30c15a9d53bc7fbf21daec795fb3c9a9588f7bdb30c563f41 README_hyph_en_GB.txt 0 +hyph_en_US.dic en/hyph_en_US.dic 486fb6840b1049d5d252ef784be11cd4dc2be0bf296568b74b41e4f4e17cba7d README_hyph_en_US.txt 0 +hyph_eo.dic eo/hyph_eo.dic f2feb4cabe54b696affec72960b1670cfd9e4eae007854944bd02c0d348f59d7 LICENSES.md 4 +hyph_es.dic es/hyph_es.dic a95be7365a482b930ade0ff54d413cd24357ade97cefd434b704286eda828d71 embedded 0 +hyph_et_EE.dic et_EE/hyph_et_EE.dic 2ee13914fcd9e9b110e9e3d56e1d6acc28bf792c15b1b69efc2e71ac96c23c74 README_hyph_et_EE.txt 0 +hyph_eu.dic legacy 5f499f513435a8432961750fae7b31bc5d3989b686a744c331bb75471a14b71b README_hyph_eu.txt 0 +hyph_fr.dic fr_FR/hyph_fr.dic 476ca60b958400c8b3fbe55764b4900df87acf60394ef62362705d9c701db191 README_hyph_fr.txt 0 +hyph_gl.dic gl/hyph_gl.dic 357355b82eb82ed081433503327ac4ba5b4f3606b6a3e50d69091d57eef4d5b8 README_hyph_gl.txt 0 +hyph_hr_HR.dic hr_HR/hyph_hr_HR.dic 77613ff6e907d2e9ffd93d45bfb16873aa9182e2215df276dbf850d1b0e28f65 README_hyph_hr_HR.txt 0 +hyph_hu_HU.dic hu_HU/hyph_hu_HU.dic 354939e518b838dbfe01ab617c25c94586b1accc2d5bdcf57ba07406e8d1fee9 embedded 2396 +hyph_id_ID.dic id/hyph_id_ID.dic ac53214620527f8a145ce4d70f0a3a5d336a145643b52547375aedacef558dc0 LICENSES.md 0 +hyph_is.dic is/hyph_is.dic 690ed04b72b60d05f66971ad9fd799656419228bfa8df00190adf02e27c0b1ef README_hyph_is.txt 0 +hyph_it_IT.dic it_IT/hyph_it_IT.dic 8c25b6fb62c2fb97586c6f7c6fb48f29f49e90a5c33c09ac9112dba6942b50cd README_hyph_it_IT.txt 0 +hyph_kn_IN.dic kn_IN/hyph_kn_IN.dic a15acb05a834979904330afda768accfa7fee10da386cc534d7944144eb1997f embedded 0 +hyph_lt.dic lt_LT/hyph_lt.dic 376e9fbdc09db5feb0025181d84242b6e01e02f413301f7d059fd99dc0597316 README_hyph_lt_LT.txt 0 +hyph_lv_LV.dic lv_LV/hyph_lv_LV.dic 775d9a539fd8e98fabbfefd87af5887bb24eb4700b47ed5f8e360b0bab75b078 README_hyph_lv_LV.txt 0 +hyph_mn_MN.dic mn_MN/hyph_mn_MN.dic c49d5ead63948d84c75d43245d837a9d7c8dc36c2a97dc5e8a7ade031c7a00e9 embedded 0 +hyph_mr_IN.dic mr_IN/hyph_mr_IN.dic 740707cd6d149d1339e921e126a25dcea889712f4889fbc72434d753324409ed LICENSES.md 0 +hyph_nb_NO.dic no/hyph_nb_NO.dic c6c9b07d9f2c60bb47857fd629d9a453026312c894665d22c72e8e1699420f6c README_hyph_NO.txt 0 +hyph_nl_NL.dic nl_NL/hyph_nl_NL.dic 0a97b76b259ed4165d094dedf9b6fb98c57734a437df0b5b263ee13e262d5f8f LICENSES.md 0 +hyph_nn_NO.dic no/hyph_nn_NO.dic c6c9b07d9f2c60bb47857fd629d9a453026312c894665d22c72e8e1699420f6c README_hyph_NO.txt 0 +hyph_or_IN.dic or_IN/hyph_or_IN.dic 264b3a880fc7dc9f477abac80708172144ec3bb28baa8266539fe7ffff7a8a30 embedded 0 +hyph_pa_IN.dic pa_IN/hyph_pa_IN.dic 87b2e3d7dc8cc4e4f0bb1d6f4265e8d8eefa478fbc890a60839868af4f5c2c06 embedded 0 +hyph_pl_PL.dic pl_PL/hyph_pl_PL.dic 9fe3917ec1fb09b7ffa560e85fb810e098d45a9a45ad6d5c08311544dd92a100 README_hyph_pl_PL.txt 0 +hyph_pt_BR.dic pt_BR/hyph_pt_BR.dic a8eb1a64e4af4649c739211b397fa37b01f9d5774cf558fe3895fc3f26fd33a4 README_hyph_pt_BR.txt 0 +hyph_pt_PT.dic pt_PT/hyph_pt_PT.dic 6f18ee89de6db21d9b06120a5527ec41d8132514fc112ccfe7d289ba91649840 README_hyph_pt_PT.txt 0 +hyph_ro_RO.dic ro/hyph_ro_RO.dic 24adbfe7cc2267ea5ce4afd34f7d69da5a9163ef3dd57b158bbb196b9d651e94 embedded 0 +hyph_ru_RU.dic ru_RU/hyph_ru_RU.dic e9a6ae92765fc7e63d879f2ccf3f50f2b95bc8ed7618a6f31c34ce7d39844c83 LICENSES.md 0 +hyph_sk_SK.dic sk_SK/hyph_sk_SK.dic 7359863ec5d5dbbf35c7d4f38faca1af7b43bc4aa643871e63f2dc0dfac4f922 README_hyph_sk_SK.txt 0 +hyph_sl_SI.dic sl_SI/hyph_sl_SI.dic 3d9026755aa0936e9331595a08bb65c295ccac84acefe58cb6c9fe2ff11ceef8 README_hyph_sl_SI.txt 0 +hyph_sq_AL.dic sq_AL/hyph_sq_AL.dic 99fbce3b0e60cc500e45643dd40903dfa71cfc1605a5b139cfb22129bd665dbe README_hyph_sq_AL.txt 1 +hyph_sr.dic sr/hyph_sr.dic ce785026592f525c38e67152699224d47a4cdf555a1a4eb65d50458c5548f69f README_hyph_sr.txt 0 +hyph_sr_Latn.dic sr/hyph_sr-Latn.dic b423f910400472979a2b0fe1c95965c940e927f7588dace0e42c17f7b0663376 README_hyph_sr.txt 0 +hyph_sv.dic sv_SE/hyph_sv.dic 44c5a59f1ef8a17b9433e4510a1abcac753d34ac875dc27ea95427f1e581f17b README_hyph_sv.txt 0 +hyph_te_IN.dic te_IN/hyph_te_IN.dic 2ad470071dfd9bb3ad5101d528e1fb0090d96c841c4f2bb07b325476b3c853fe embedded 0 +hyph_th_TH.dic th_TH/hyph_th_TH.dic fbd19d561c19a13d1901a0b9653e24b4c0c2c81177064266e2acb23bac68145b README_hyph_th_TH.txt 0 +hyph_uk_UA.dic uk_UA/hyph_uk_UA.dic ae80645b757d6b57e5cdf34ab6cf7441a2414c60bff2352c7e815c1c12baad3d README_hyph_uk_UA.txt 0 +hyph_zu_ZA.dic zu_ZA/hyph_zu_ZA.dic e75802e08127877d38891c140e8989af559665bae82f94d33c340cba2aa9de20 embedded 0 diff --git a/dictionaries/hyph_sa_IN.dic b/dictionaries/hyph_sa_IN.dic deleted file mode 100644 index 2dfd0fb..0000000 --- a/dictionaries/hyph_sa_IN.dic +++ /dev/null @@ -1,116 +0,0 @@ -UTF-8 -LEFTHYPHENMIN 3 -RIGHTHYPHENMIN 4 -COMPOUNDLEFTHYPHENMIN 2 -COMPOUNDRIGHTHYPHENMIN 3 - -% GENERAL RULE -% Do not break either side of ZERO-WIDTH JOINER (U+200D) -2‍2 -% Break after ZERO-WIDTH NON JOINER (U+200C) -‌1 -% Break before or after any independent vowel. -1अ1 -1आ1 -1इ1 -1ई1 -1उ1 -1ऊ1 -1ऋ1 -1ॠ1 -1ऌ1 -1ॡ1 -1ए1 -1ऐ1 -1ओ1 -1औ1 -% Break after any dependent vowel but not before. -ा1 -ि1 -ी1 -ु1 -ू1 -ृ1 -ॄ1 -ॢ1 -ॣ1 -े1 -ै1 -ो1 -ौ1 -% Break before or after any consonant. -1क -1ख -1ग -1घ -1ङ -1च -1छ -1ज -1झ -1ञ -1ट -1ठ -1ड -1ढ -1ण -1त -1थ -1द -1ध -1न -1प -1फ -1ब -1भ -1म -1य -1र -1ल -1ळ -1व -1श -1ष -1स -1ह -% Do not break before chandrabindu, anusvara, visarga, avagraha -% and accents. -2ँ -2ं -2ः -2ऽ -2॑ -2॒ -% Do not break either side of virama (may be within conjunct). -2्2 -अति1 -अधि1 -अन1 -अनु1 -अन्1 -अप1 -अपि1 -अभि1 -अव1 -1इय -उद्1 -उप1 -1का -चिर्1 -1त्र -1त्व -दुर्1 -दुस्1 -नि1 -निर्1 -निस्1 -पर1 -परि1 -प्र1 -प्रति1 -1ली -1वत् -वि1 -सम्1 -सु1 - diff --git a/dictionaries/update.sh b/dictionaries/update.sh old mode 100644 new mode 100755 index cba2dd0..a82a15f --- a/dictionaries/update.sh +++ b/dictionaries/update.sh @@ -1,7 +1,109 @@ -git clone https://git.libreoffice.org/dictionaries libreoffice-dictionaries -cd libreoffice-dictionaries -git pull -cd .. -find libreoffice-dictionaries -name "hyph_*\.dic" | xargs -I '{}' cp '{}' . -sed -i 's/\r$//' *.dic -rename -- -Latn _Latn *-Latn.dic +#!/bin/sh +# Verify or export the pinned emacs-kp dictionary bundle. + +set -eu + +UPSTREAM_URL=https://github.com/LibreOffice/dictionaries.git +UPSTREAM_COMMIT=8fb8e794237cff49ec212023f96bcdb7d3fbf56c +DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) +MANIFEST="$DIR/MANIFEST.tsv" +MODE=${1:-check} +OUTPUT=${2:-} + +fail() +{ + printf 'dictionary-update: %s\n' "$*" >&2 + exit 1 +} + +sha256() +{ + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +case "$MODE" in + check) + test -z "$OUTPUT" || + fail "check mode does not accept an output directory" + ;; + export) + test -n "$OUTPUT" || + fail "usage: update.sh export OUTPUT_DIRECTORY" + if test -e "$OUTPUT"; then + test -d "$OUTPUT" || fail "output exists and is not a directory" + test -z "$(find "$OUTPUT" -mindepth 1 -print -quit)" || + fail "output directory must be empty" + else + mkdir -p "$OUTPUT" + fi + ;; + *) + fail "usage: update.sh [check | export OUTPUT_DIRECTORY]" + ;; +esac + +command -v git >/dev/null 2>&1 || fail "git is required" +test -f "$MANIFEST" || fail "MANIFEST.tsv is missing" + +work=$(mktemp -d "${TMPDIR:-/tmp}/ekp-dictionary-update.XXXXXX") +cleanup_work() +{ + find "$work" -depth -delete +} +trap cleanup_work EXIT HUP INT TERM + +git -C "$work" init -q +git -C "$work" remote add origin "$UPSTREAM_URL" +git -C "$work" fetch -q --depth=1 origin "$UPSTREAM_COMMIT" +git -C "$work" rev-parse --verify FETCH_HEAD >/dev/null +test "$(git -C "$work" rev-parse FETCH_HEAD)" = "$UPSTREAM_COMMIT" || + fail "fetched commit differs from the pin" + +for evidence in \ + eo/license-en.txt \ + id/LICENSE-dict \ + mr_IN/COPYING \ + nl_NL/license_en_EN.txt \ + ru_RU/README_ru_RU.txt +do + git -C "$work" cat-file -e "FETCH_HEAD:$evidence" || + fail "pinned license evidence is missing: $evidence" +done + +if test "$MODE" = export; then + cp "$MANIFEST" "$DIR/LICENSES.md" "$OUTPUT/" + for readme in "$DIR"/README_*.txt; do + cp "$readme" "$OUTPUT/" + done +fi + +tab=$(printf '\t') +count=0 +while IFS="$tab" read -r file source expected_hash _license _alternatives; do + case "$file" in + ""|\#*) continue ;; + esac + count=$((count + 1)) + normalized="$work/$file" + if test "$source" = legacy; then + cp "$DIR/$file" "$normalized" + else + git -C "$work" show "FETCH_HEAD:$source" > "$work/raw" + LC_ALL=C tr -d '\r' < "$work/raw" > "$normalized" + fi + test "$(sha256 "$normalized")" = "$expected_hash" || + fail "$file differs from the pinned manifest" + if test "$MODE" = check; then + cmp -s "$DIR/$file" "$normalized" || + fail "$file differs from normalized upstream bytes" + else + cp "$normalized" "$OUTPUT/$file" + fi +done < "$MANIFEST" + +test "$count" -eq 49 || fail "manifest must contain 49 dictionaries" +printf 'dictionary-update: %s passed for %d pinned entries\n' "$MODE" "$count" diff --git a/ekp-hyphen.el b/ekp-hyphen.el index 21435c9..704ead7 100644 --- a/ekp-hyphen.el +++ b/ekp-hyphen.el @@ -48,15 +48,29 @@ LEFT/RIGHT: minimum chars before first / after last break." ;;; Global State +(define-error 'ekp-hyphen-error "Hyphenation error") +(define-error 'ekp-hyphen-dictionary-not-found + "Hyphenation dictionary not found" + 'ekp-hyphen-error) +(define-error 'ekp-hyphen-unsupported-pattern + "Dictionary uses unsupported replacement patterns" + 'ekp-hyphen-error) + (defvar ekp-hyphen--cache (make-hash-table :test 'equal) "Cache: dictionary path -> compiled ekp-hyphen.") +(defvar ekp-hyphen--unsupported-cache (make-hash-table :test 'equal) + "Cache: dictionary path -> unsupported replacement-pattern count.") + (defvar ekp-hyphen--langs (make-hash-table :test 'equal) "Registry: language code -> dictionary file path.") (defvar ekp-hyphen--langs-short (make-hash-table :test 'equal) "Fallback: short code (e.g., \"en\") -> first matching dict path.") +(defconst ekp-hyphen--cache-miss (make-symbol "ekp-hyphen-cache-miss") + "Sentinel distinguishing an absent word from a cached nil result.") + ;;; Dictionary Loading (defun ekp-hyphen-load-languages (dir) @@ -111,14 +125,16 @@ E.g., \"a1bc2\" -> letters=\"abc\", values=(0 1 0 2)." (when (> end start) (list letters start (cl-subseq values start end)))))) -(defun ekp-hyphen--compile (path) +(defun ekp-hyphen--compile (path &optional language) "Compile dictionary at PATH into ekp-hyphen struct. Honors the dictionary's LEFTHYPHENMIN / RIGHTHYPHENMIN declarations \(minimum characters kept before/after any break — e.g. en_US declares 2/3, so \"quick-ly\" is not a valid break); absent -declarations default to 2/2." +declarations default to 2/2. +Signal `ekp-hyphen-unsupported-pattern' for replacement patterns; +LANGUAGE identifies the dictionary in that condition." (let ((patterns (make-hash-table :test 'equal)) - (maxlen 0) (left 2) (right 2)) + (maxlen 0) (left 2) (right 2) (alternative-count 0)) (with-temp-buffer (insert-file-contents path) (forward-line 1) ; skip encoding line @@ -127,7 +143,7 @@ declarations default to 2/2." (point) (line-end-position)))) (skip (or (string-empty-p line) (string-match-p "^[%#]\\|HYPHENMIN" line) - (string-match-p "/" line)))) ; skip alt patterns + (string-match-p "/" line)))) (cond ((string-match "^\\(LEFT\\|RIGHT\\)HYPHENMIN[ \t]*\\([0-9]+\\)" line) @@ -135,6 +151,9 @@ declarations default to 2/2." (if (equal (match-string 1 line) "LEFT") (setq left n) (setq right n)))) + ((and (not (string-match-p "^[%#]" line)) + (string-match-p "/" line)) + (cl-incf alternative-count)) (skip nil) (t ;; Handle ^^XX hex escapes @@ -147,6 +166,9 @@ declarations default to 2/2." (puthash (car parsed) (cdr parsed) patterns) (setq maxlen (max maxlen (length (car parsed)))))))) (forward-line 1))) + (when (> alternative-count 0) + (signal 'ekp-hyphen-unsupported-pattern + (list (or language path) path alternative-count))) (ekp-hyphen--create :patterns patterns :cache (make-hash-table :test 'equal) :maxlen maxlen @@ -182,9 +204,11 @@ declarations default to 2/2." (defun ekp-hyphen--positions (h word) "Return cached break positions for WORD using hyphenator H." (let* ((key (downcase word)) - (cache (ekp-hyphen-cache h))) - (or (gethash key cache) - (puthash key (ekp-hyphen--compute h word) cache)))) + (cache (ekp-hyphen-cache h)) + (cached (gethash key cache ekp-hyphen--cache-miss))) + (if (eq cached ekp-hyphen--cache-miss) + (puthash key (ekp-hyphen--compute h word) cache) + cached))) ;;; Public API @@ -194,10 +218,19 @@ LEFT/RIGHT override the minimum characters kept before/after breaks; by default the dictionary's own LEFTHYPHENMIN/RIGHTHYPHENMIN apply \(2/2 when it declares none)." (let ((path (or (and lang (ekp-hyphen--resolve-lang lang)) file))) - (unless path (error "No dictionary for: %s" lang)) + (unless path + (signal 'ekp-hyphen-dictionary-not-found (list lang))) + (when-let ((count (gethash path ekp-hyphen--unsupported-cache))) + (signal 'ekp-hyphen-unsupported-pattern + (list (or lang path) path count))) (let ((h (or (gethash path ekp-hyphen--cache) - (puthash path (ekp-hyphen--compile path) - ekp-hyphen--cache)))) + (condition-case err + (puthash path (ekp-hyphen--compile path lang) + ekp-hyphen--cache) + (ekp-hyphen-unsupported-pattern + (puthash path (nth 3 err) + ekp-hyphen--unsupported-cache) + (signal (car err) (cdr err))))))) (if (or left right) (ekp-hyphen--create :patterns (ekp-hyphen-patterns h) :cache (ekp-hyphen-cache h) @@ -215,13 +248,16 @@ by default the dictionary's own LEFTHYPHENMIN/RIGHTHYPHENMIN apply (defun ekp-hyphen-inserted (h word &optional hyphen) "Return WORD with HYPHEN inserted at H's break points." - (let ((hyphen (or hyphen "-")) (result word) (off 0)) - (dolist (pos (ekp-hyphen-positions h word)) - (setq result (concat (substring result 0 (+ pos off)) - hyphen - (substring result (+ pos off))) - off (+ off (length hyphen)))) - result)) + (let ((positions (ekp-hyphen-positions h word))) + (if (null positions) + word + (let ((hyphen (or hyphen "-")) (start 0) parts) + (dolist (pos positions) + (push (substring word start pos) parts) + (push hyphen parts) + (setq start pos)) + (push (substring word start) parts) + (apply #'concat (nreverse parts)))))) (defun ekp-hyphen-boxes (h word) "Split WORD into syllables at H's break points." diff --git a/ekp-region.el b/ekp-region.el index 62cbb30..955d995 100644 --- a/ekp-region.el +++ b/ekp-region.el @@ -41,6 +41,7 @@ (require 'ekp) (require 'cl-lib) +(require 'easymenu) (defvar ekp-auto-justify-mode) @@ -129,9 +130,20 @@ they suffice.") (defvar ekp-region--inhibit nil "Non-nil while ekp-region is modifying the buffer itself.") -(defvar-local ekp-region--save-state nil - "Spans unjustified for saving: list of (BEG-MARKER END-MARKER WIDTH). -Set by `ekp-region--before-save', consumed by `ekp-region--after-save'.") +(defvar-local ekp-region--write-buffer nil + "Hidden logical-text buffer used while writing justified content.") + +(defvar-local ekp-region--write-source nil + "Source buffer for an `ekp-region--write-buffer'.") + +(defvar-local ekp-region--previous-filter nil + "Substring filter wrapped by the EKP logical-text filter.") + +(defvar-local ekp-region--previous-filter-local-p nil + "Whether `ekp-region--previous-filter' was buffer-local.") + +(defvar-local ekp-region--filter-installed nil + "Non-nil while EKP owns `filter-buffer-substring-function'.") (defmacro ekp-region--preserving-modified (&rest body) "Run BODY, keeping the buffer unmodified if it was unmodified. @@ -358,7 +370,7 @@ idempotent and can re-flow to a new width." (atomic-change-group ;; Re-flow support: strip previous justification first. (when (text-property-not-all beg end 'ekp-justified nil) - (ekp-unjustify-region beg end)) + (ekp-region--unjustify-region beg end)) (let* ((text (buffer-substring beg end)) (justified (ekp-region--justify-string text pixel)) (point-offset (and (>= (point) beg) (< (point) end) @@ -375,40 +387,58 @@ idempotent and can re-flow to a new width." (set-marker beg nil) (set-marker end nil)))) +(defun ekp-region--after-layout-change (&rest _ignored) + "Remove integrations after external edits delete the final layout span." + (unless (or ekp-region--inhibit + ekp-auto-justify-mode + (ekp-region--justified-spans)) + (ekp-region--remove-integrations))) + (defun ekp-region--install-integrations () "Install the buffer-local hooks justified text depends on. Idempotent; added by `ekp-justify-region' and `ekp-auto-justify-mode'." - ;; Saving a justified buffer must write the logical text. - (add-hook 'before-save-hook #'ekp-region--before-save nil t) - (add-hook 'after-save-hook #'ekp-region--after-save nil t) + ;; Saving writes a logical copy without mutating the display buffer. + (add-hook 'write-region-annotate-functions + #'ekp-region--write-logical-buffer nil t) + (add-hook 'kill-buffer-hook #'ekp-region--discard-write-buffer nil t) ;; Isearch searches the logical text. (add-hook 'isearch-mode-hook #'ekp-region--isearch-begin nil t) (add-hook 'isearch-mode-end-hook #'ekp-region--isearch-end nil t) + (add-hook 'after-change-functions #'ekp-region--after-layout-change nil t) ;; The kill ring receives the logical text. - (setq-local filter-buffer-substring-function - #'ekp-region--filter-buffer-substring)) + (unless ekp-region--filter-installed + (setq ekp-region--previous-filter-local-p + (local-variable-p 'filter-buffer-substring-function) + ekp-region--previous-filter filter-buffer-substring-function + ekp-region--filter-installed t) + (setq-local filter-buffer-substring-function + #'ekp-region--filter-buffer-substring))) (defun ekp-region--remove-integrations () "Remove the hooks installed by `ekp-region--install-integrations'." - (remove-hook 'before-save-hook #'ekp-region--before-save t) - (remove-hook 'after-save-hook #'ekp-region--after-save t) + (remove-hook 'write-region-annotate-functions + #'ekp-region--write-logical-buffer t) + (remove-hook 'kill-buffer-hook #'ekp-region--discard-write-buffer t) + (ekp-region--discard-write-buffer) (remove-hook 'isearch-mode-hook #'ekp-region--isearch-begin t) (remove-hook 'isearch-mode-end-hook #'ekp-region--isearch-end t) - (when (eq filter-buffer-substring-function - #'ekp-region--filter-buffer-substring) - (kill-local-variable 'filter-buffer-substring-function))) + (remove-hook 'after-change-functions #'ekp-region--after-layout-change t) + (when ekp-region--filter-installed + (when (eq filter-buffer-substring-function + #'ekp-region--filter-buffer-substring) + (if ekp-region--previous-filter-local-p + (setq-local filter-buffer-substring-function + ekp-region--previous-filter) + (kill-local-variable 'filter-buffer-substring-function))) + (setq ekp-region--previous-filter nil + ekp-region--previous-filter-local-p nil + ekp-region--filter-installed nil))) -;;;###autoload -(defun ekp-unjustify-region (beg end) +(defun ekp-region--unjustify-region (beg end) "Restore the logical text between BEG and END. Removes synthesized glue and soft hyphens, replaces soft line breaks with the whitespace they swallowed, and re-exposes hidden paragraph tails. Text the user typed into the justified region is preserved." - (interactive - (progn - (barf-if-buffer-read-only) - (pcase-let ((`(,beg . ,end) (ekp-region--dwim-bounds))) - (list beg end)))) (let ((end-m (copy-marker (max beg end) t)) (ekp-region--inhibit t) (inhibit-read-only t)) @@ -444,7 +474,21 @@ tails. Text the user typed into the justified region is preserved." (remove-text-properties (min beg end) end-m '(ekp-justified nil)))) (set-marker end-m nil)))) -;;;; Saving: the file always receives the logical text +;;;###autoload +(defun ekp-unjustify-region (beg end) + "Restore the logical text between BEG and END. +When the final justified span disappears outside auto mode, remove +the buffer integrations that no longer have a layout to serve." + (interactive + (progn + (barf-if-buffer-read-only) + (pcase-let ((`(,beg . ,end) (ekp-region--dwim-bounds))) + (list beg end)))) + (ekp-region--unjustify-region beg end) + (unless (or ekp-auto-justify-mode (ekp-region--justified-spans)) + (ekp-region--remove-integrations))) + +;;;; Saving: write a logical copy, never mutate the display buffer (defun ekp-region--justified-spans () "Return justified spans of the buffer as a list of (BEG END WIDTH). @@ -458,38 +502,55 @@ BEG/END are positions; WIDTH is the span's `ekp-justified' value." (setq pos next))) (nreverse spans))) -(defun ekp-region--before-save () - "Restore the logical text before the buffer is written to disk. -Saving a justified buffer must never persist soft line breaks, glue -spaces or break hyphens: they are layout, not content. The spans are -remembered (as markers) and re-justified by `ekp-region--after-save', -so the user never sees the buffer un-justified." - (let ((spans (ekp-region--justified-spans))) - (when spans - ;; The unjustify+rejustify pair is deterministic and cancels out - ;; exactly, so keep it off the undo history. - (let ((buffer-undo-list t)) - ;; Marker-ize every span before the first unjustification - ;; shifts the positions of the spans after it. - (setq ekp-region--save-state - (mapcar (pcase-lambda (`(,beg ,end ,width)) - (list (copy-marker beg) (copy-marker end t) width)) - spans)) - (pcase-dolist (`(,beg ,end ,_width) ekp-region--save-state) - (ekp-unjustify-region beg end)))))) +(defun ekp-region--discard-write-buffer () + "Discard the current buffer's pending logical write buffer." + (when (buffer-live-p ekp-region--write-buffer) + (with-current-buffer ekp-region--write-buffer + (set-buffer-modified-p nil) + (let ((kill-buffer-query-functions nil)) + (kill-buffer)))) + (setq ekp-region--write-buffer nil)) -(defun ekp-region--after-save () - "Re-justify the spans un-done by `ekp-region--before-save'." - (when ekp-region--save-state - (let ((buffer-undo-list t)) - (pcase-dolist (`(,beg ,end ,width) ekp-region--save-state) - (when (and (marker-position beg) (marker-position end)) - (ekp-justify-region beg end width)) - (set-marker beg nil) - (set-marker end nil))) - (setq ekp-region--save-state nil) - ;; The file on disk holds exactly this buffer's logical text. - (set-buffer-modified-p nil))) +(defun ekp-region--finish-write-buffer () + "Dispose the current logical write buffer after a successful write." + (let ((source ekp-region--write-source) + (write-buffer (current-buffer))) + (when (buffer-live-p source) + (with-current-buffer source + (when (eq ekp-region--write-buffer write-buffer) + (setq ekp-region--write-buffer nil)))) + (set-buffer-modified-p nil) + (let ((kill-buffer-query-functions nil)) + (kill-buffer write-buffer)))) + +(defun ekp-region--make-write-buffer () + "Return a logical-text copy of the current justified buffer." + (ekp-region--discard-write-buffer) + (let ((source (current-buffer)) + (selective selective-display) + (multibyte enable-multibyte-characters) + (coding buffer-file-coding-system) + (write-buffer (generate-new-buffer " *ekp-logical-write*"))) + (setq ekp-region--write-buffer write-buffer) + (with-current-buffer write-buffer + (set-buffer-multibyte multibyte) + (setq selective-display selective + buffer-file-coding-system coding + ekp-region--write-source source) + (insert-buffer-substring source) + (ekp-region--unjustify-region (point-min) (point-max)) + (set-buffer-modified-p nil) + (setq-local write-region-post-annotation-function + #'ekp-region--finish-write-buffer)) + write-buffer)) + +(defun ekp-region--write-logical-buffer (start end) + "Switch whole-buffer writes to a logical copy. +START and END are the `write-region-annotate-functions' arguments. +Region-only writes retain their normal physical-buffer semantics." + (when (and (null start) (null end) (ekp-region--justified-spans)) + (set-buffer (ekp-region--make-write-buffer))) + nil) ;;;; Isearch: search the logical text @@ -511,7 +572,7 @@ the search and restored by `ekp-region--isearch-end'." (list (copy-marker beg) (copy-marker end t) width)) spans)) (pcase-dolist (`(,beg ,end ,_w) ekp-region--isearch-state) - (ekp-unjustify-region beg end)))))) + (ekp-region--unjustify-region beg end)))))) (defun ekp-region--isearch-end () "Restore the justified layout after isearch." @@ -531,11 +592,10 @@ the search and restored by `ekp-region--isearch-end'." Non-justified strings are returned unchanged (same object)." (if (cl-some (lambda (prop) (text-property-not-all 0 (length string) prop nil string)) - '(ekp-glue ekp-soft-break ekp-soft-hyphen - ekp-hidden ekp-justified)) + ekp--layout-marker-properties) (with-temp-buffer (insert string) - (ekp-unjustify-region (point-min) (point-max)) + (ekp-region--unjustify-region (point-min) (point-max)) (buffer-string)) string)) @@ -544,7 +604,15 @@ Non-justified strings are returned unchanged (same object)." Killing justified text and yanking it elsewhere must transport the words, not the pixel layout of the source window (DELETE as in `filter-buffer-substring-function')." - (ekp-region--logical-string (buffer-substring--filter beg end delete))) + (let ((ekp-region--inhibit t) + extracted) + (unwind-protect + (let ((filter-buffer-substring-function ekp-region--previous-filter)) + (setq extracted (filter-buffer-substring beg end delete))) + (when delete + (let ((ekp-region--inhibit nil)) + (ekp-region--after-layout-change)))) + (ekp-region--logical-string extracted))) ;;;###autoload (defun ekp-justify-buffer (&optional pixel) @@ -565,34 +633,59 @@ prefix argument supplies it explicitly." (ekp-unjustify-region (point-min) (point-max))) ;;;###autoload -(defun ekp-no-break-region (beg end) +(defun ekp-no-break-region (beg end &optional announce) "Mark the region from BEG to END as an unbreakable typesetting atom. Justification treats it as one rigid unit: no line break inside, no hyphenation, spacing stays literal (inline code, product names, -numbers with units)." - (interactive "r") - (add-text-properties beg end '(ekp-no-break t))) +numbers with units). This text property lasts only for the current +buffer session; plain-text saving does not persist it. ANNOUNCE +requests interactive feedback." + (interactive (ekp-region--interactive-protection-args)) + (ekp-region--set-protection beg end 'ekp-no-break t "Marked no-break" + announce)) ;;;###autoload -(defun ekp-allow-break-region (beg end) - "Remove `ekp-no-break' marking between BEG and END." - (interactive "r") - (remove-text-properties beg end '(ekp-no-break nil))) +(defun ekp-allow-break-region (beg end &optional announce) + "Remove session-local `ekp-no-break' marking between BEG and END. +ANNOUNCE requests interactive feedback." + (interactive (ekp-region--interactive-protection-args)) + (ekp-region--set-protection beg end 'ekp-no-break nil "Cleared no-break" + announce)) ;;;###autoload -(defun ekp-verbatim-region (beg end) +(defun ekp-verbatim-region (beg end &optional announce) "Protect the paragraphs from BEG to END against justification (code blocks). Whole paragraphs carrying the `ekp-verbatim' property pass through `ekp-justify-region' and `ekp-auto-justify-mode' untouched. For an -unbreakable span inside prose, use `ekp-no-break-region' instead." - (interactive "r") - (add-text-properties beg end '(ekp-verbatim t))) +unbreakable span inside prose, use `ekp-no-break-region' instead. +This text property lasts only for the current buffer session; +plain-text saving does not persist it. ANNOUNCE requests interactive +feedback." + (interactive (ekp-region--interactive-protection-args)) + (ekp-region--set-protection beg end 'ekp-verbatim t "Marked verbatim" + announce)) ;;;###autoload -(defun ekp-clear-verbatim-region (beg end) - "Remove `ekp-verbatim' protection between BEG and END." - (interactive "r") - (remove-text-properties beg end '(ekp-verbatim nil))) +(defun ekp-clear-verbatim-region (beg end &optional announce) + "Remove session-local `ekp-verbatim' protection between BEG and END. +ANNOUNCE requests interactive feedback." + (interactive (ekp-region--interactive-protection-args)) + (ekp-region--set-protection beg end 'ekp-verbatim nil "Cleared verbatim" + announce)) + +(defun ekp-region--interactive-protection-args () + "Return region arguments for an interactive protection command." + (barf-if-buffer-read-only) + (list (region-beginning) (region-end) t)) + +(defun ekp-region--set-protection (beg end property enabled label announce) + "Set PROPERTY to ENABLED from BEG to END and optionally ANNOUNCE LABEL." + (if enabled + (add-text-properties beg end (list property t)) + (remove-text-properties beg end (list property nil))) + (when announce + (message "EKP: %s on %d characters; current buffer session only" + label (- end beg)))) ;;;; Auto-justify minor mode @@ -898,12 +991,33 @@ content and destroy the original whitespace." :doc "Keymap for `ekp-auto-justify-mode'." " " #'ekp-refill-paragraph) +(easy-menu-define ekp-auto-justify-mode-menu ekp-auto-justify-mode-map + "Menu for `ekp-auto-justify-mode'." + '("EKP" + ["Justify Region or Paragraph" ekp-justify-region t] + ["Unjustify Region or Paragraph" ekp-unjustify-region t] + ["Justify Buffer" ekp-justify-buffer t] + ["Unjustify Buffer" ekp-unjustify-buffer t] + "--" + ["Mark Region No-Break" ekp-no-break-region (use-region-p)] + ["Clear No-Break Region" ekp-allow-break-region (use-region-p)] + ["Mark Region Verbatim" ekp-verbatim-region (use-region-p)] + ["Clear Verbatim Region" ekp-clear-verbatim-region (use-region-p)] + "--" + ["Diagnose Window Fit" ekp-diagnose t])) + ;;;###autoload (define-minor-mode ekp-auto-justify-mode "Keep the buffer pixel-justified to the window width. Re-flows when the window width changes and re-justifies edited paragraphs incrementally. Designed for reading and previewing; -the buffer text is restored exactly when the mode is turned off." +the buffer text is restored exactly when the mode is turned off. + +The EKP menu exposes justify, unjustify, no-break, verbatim, and +diagnostic commands. Manual no-break and verbatim properties last +only for the current buffer session; use mode faces or +`ekp-region-skip-predicate' for protection derived from persistent +document syntax." :lighter " EKP" :keymap ekp-auto-justify-mode-map (if ekp-auto-justify-mode @@ -958,7 +1072,7 @@ discard the mode silently." ;; region. (save-restriction (widen) - (ekp-unjustify-region (point-min) (point-max)))) + (ekp-region--unjustify-region (point-min) (point-max)))) (provide 'ekp-region) diff --git a/ekp-utils.el b/ekp-utils.el index 5634a75..eed810d 100644 --- a/ekp-utils.el +++ b/ekp-utils.el @@ -173,16 +173,16 @@ When STR is held as cjk-char, this checks if it still needs attachment." (memq (get-char-code-property char 'general-category) '(Ps Pi)))) -(defun ekp--flush-latin-word (word boxes) - "Push latin WORD to BOXES if non-nil. Return updated boxes." - (if word (cons word boxes) boxes)) +(defun ekp--flush-latin-word (parts boxes) + "Join reversed Latin PARTS once and push the word to BOXES." + (if parts (cons (apply #'concat (nreverse parts)) boxes) boxes)) -(defun ekp--flush-cjk-char (char boxes) - "Push CJK CHAR to BOXES if non-nil. Return updated boxes." - (if char (cons char boxes) boxes)) +(defun ekp--flush-cjk-char (parts boxes) + "Join reversed CJK PARTS once and push the character to BOXES." + (if parts (cons (apply #'concat (nreverse parts)) boxes) boxes)) -(defun ekp--flush-spaces (spaces boxes prev-state next-width) - "Push SPACES to BOXES based on context. +(defun ekp--flush-spaces (parts boxes prev-state next-width) + "Join reversed space PARTS and push them to BOXES based on context. PREV-STATE: 1=latin, 2=CJK (previous content type). NEXT-WIDTH: width of next character (1=latin, 2=CJK). Rules: @@ -190,8 +190,9 @@ Rules: - CJK involved (prev or next is CJK): preserve all spaces - Latin-Latin with single space: let glue handle it - Latin-Latin with multiple spaces: preserve all but last" - (when (and spaces (not (string-empty-p spaces))) - (let ((cjk-involved (or (= prev-state 2) (= next-width 2)))) + (when parts + (let ((spaces (apply #'concat (nreverse parts))) + (cjk-involved (or (= prev-state 2) (= next-width 2)))) (cond ;; Leading spaces (no previous boxes): preserve all ((null boxes) @@ -210,10 +211,10 @@ Rules: (t nil)))) boxes) -(defun ekp--flush-trailing-spaces (spaces boxes) - "Push all trailing SPACES to BOXES (for end of string)." - (if (and spaces (not (string-empty-p spaces))) - (cons spaces boxes) +(defun ekp--flush-trailing-spaces (parts boxes) + "Join reversed trailing space PARTS once and push them to BOXES." + (if parts + (cons (apply #'concat (nreverse parts)) boxes) boxes)) (defun ekp--zero-width-attaching-p (char) @@ -232,9 +233,9 @@ STATE is the current mode; LATIN-WORD, CJK-CHAR and BOXES are the accumulators. Return (new-state new-latin-word new-cjk-char new-boxes)." (if (= state 1) ;; Already in latin mode: accumulate - (list 1 (concat latin-word str) nil boxes) + (list 1 (cons str latin-word) nil boxes) ;; Was in CJK mode: flush held CJK char, switch to latin - (list 1 str nil (ekp--flush-cjk-char cjk-char boxes)))) + (list 1 (list str) nil (ekp--flush-cjk-char cjk-char boxes)))) (defun ekp--handle-cjk-char (str state latin-word cjk-char boxes) "Handle a CJK (width=2) character STR. @@ -246,9 +247,9 @@ Kinsoku is enforced by the DP through per-gap break permissions \(`ekp-para-breaks-allowed'), not by merging boxes." (if (= state 1) ;; Was in latin mode: flush latin word, hold current CJK char - (list 2 nil str (ekp--flush-latin-word latin-word boxes)) + (list 2 nil (list str) (ekp--flush-latin-word latin-word boxes)) ;; Already in CJK mode: flush held char, hold current - (list 2 nil str (ekp--flush-cjk-char cjk-char boxes)))) + (list 2 nil (list str) (ekp--flush-cjk-char cjk-char boxes)))) (defun ekp-split-to-boxes (string) "Split STRING into typographic boxes. @@ -263,9 +264,9 @@ by merging boxes." (goto-char (point-min)) (let ((state (char-width (seq-first string))) ; 1=latin, 2=CJK (prev-state 1) ; track previous content state for space handling - latin-word ; accumulator for latin characters - cjk-char ; holds previous CJK char (for punct attachment) - spaces ; accumulator for whitespace runs + latin-word ; reversed fragments for latin characters + cjk-char ; reversed fragments for one CJK character + spaces ; reversed fragments for a whitespace run boxes) ; result list (built in reverse) (while (not (eobp)) (let* ((str (buffer-substring (point) (1+ (point)))) @@ -276,12 +277,15 @@ by merging boxes." ((and (= 0 width) (not (string-blank-p str)) (ekp--zero-width-attaching-p char)) (cond - (latin-word (setq latin-word (concat latin-word str))) - (cjk-char (setq cjk-char (concat cjk-char str))) - (spaces (setq spaces (concat spaces str))) - (boxes (setcar boxes (concat (car boxes) str))) + (latin-word (push str latin-word)) + (cjk-char (push str cjk-char)) + (spaces (push str spaces)) + (boxes + (if (= prev-state 2) + (setq cjk-char (list str (pop boxes)) state 2) + (setq latin-word (list str (pop boxes)) state 1))) ;; String starts with a combining char: start an accumulator - (t (setq latin-word str state 1)))) + (t (setq latin-word (list str) state 1)))) ;; Whitespace or other zero-width: flush content, accumulate spaces ((or (string-blank-p str) (= 0 width)) (setq boxes (ekp--flush-cjk-char cjk-char boxes)) @@ -290,7 +294,7 @@ by merging boxes." (setq boxes (ekp--flush-latin-word latin-word boxes)) (when latin-word (setq prev-state 1)) (setq latin-word nil) - (setq spaces (concat spaces str))) + (push str spaces)) ;; Non-whitespace: flush spaces first, then handle char (t (setq boxes (ekp--flush-spaces spaces boxes prev-state width)) @@ -317,30 +321,6 @@ by merging boxes." ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defun ekp-start-process-with-callback - (process-name command-args callback - &optional output-buffer) - "Run COMMAND-ARGS as process PROCESS-NAME; call CALLBACK on success. -CALLBACK receives (PROCESS BUFFER). OUTPUT-BUFFER names the output -buffer (a generated name by default); it is killed after CALLBACK -returns." - (let* ((buffer-name (generate-new-buffer-name - (or output-buffer "*EKP Process Output*"))) - (process (apply #'start-process process-name - buffer-name command-args))) - (set-process-sentinel - process - (lambda (proc event) - (if (string-match-p "finished" event) - (when (memq (process-status proc) '(exit signal)) - (unwind-protect - (funcall callback proc (process-buffer proc)) - (when (buffer-live-p (process-buffer proc)) - (kill-buffer (process-buffer proc))))) - (message "%s, please check %s" (string-trim event) - buffer-name)))) - process)) - (defun ekp--module-reload (module) "Load MODULE from a temp copy to allow rebuilding." (let ((tmpfile (make-temp-file @@ -375,9 +355,27 @@ returns." (defalias 'ekp-c-module-reload #'ekp--module-reload "Load MODULE from a temp copy to allow rebuilding.") -(defconst ekp-c-module-required-version "1.5" +(defconst ekp-c-module-required-version "1.6" "Minimum C module version compatible with this Elisp code.") +(defun ekp--c-build-finished (profile process _event) + "Handle completion of PROCESS building C PROFILE." + (when (memq (process-status process) '(exit signal)) + (if (and (eq (process-status process) 'exit) + (= (process-exit-status process) 0)) + (condition-case error-data + (progn + (ekp-c-module-load) + (kill-buffer (process-buffer process)) + (message "EKP C %s build succeeded" profile)) + (error + (display-buffer (process-buffer process)) + (message "EKP C build loaded unsuccessfully: %s" + (error-message-string error-data)))) + (display-buffer (process-buffer process)) + (message "EKP C %s build failed (status %d)" + profile (process-exit-status process))))) + ;;;###autoload (defun ekp-c-module-load () "Load EKP C module if available. @@ -402,22 +400,35 @@ Run 'make' in ekp_c/ to rebuild; falling back to Elisp." (message "C module not found. Run 'make' in ekp_c/ directory.")))) ;;;###autoload -(defun ekp-c-module-build () - "Build the C module using make." - (interactive) - (let ((module-dir (ekp-c-module-dir))) - (if (and module-dir (file-exists-p - (expand-file-name "Makefile" module-dir))) - (ekp-start-process-with-callback - "ekp-c-build" - (cond - ((eq system-type 'windows-nt) - `("cmd.exe" "/c" ,(format "cd %s && make" module-dir))) - (t `(,shell-file-name "-c" ,(format "cd %s && make" module-dir)))) - (lambda (_proc _buffer) - (ekp-c-module-load) - (message "ekp C module build success!"))) - (error "Makefile not found in ekp_c/ directory")))) +(defun ekp-c-module-build (&optional profile) + "Build the C module with make using PROFILE. +PROFILE is one of `portable', `native', `debug', or `sanitize'; +the default is `portable'." + (interactive + (list + (intern + (completing-read "C build profile: " + '("portable" "native" "debug" "sanitize") + nil t nil nil "portable")))) + (setq profile (or profile 'portable)) + (unless (memq profile '(portable native debug sanitize)) + (user-error "Unknown EKP C build profile: %S" profile)) + (let* ((module-dir (ekp-c-module-dir)) + (makefile (and module-dir + (expand-file-name "Makefile" module-dir))) + (make (executable-find "make"))) + (unless (and makefile (file-exists-p makefile)) + (user-error "Makefile not found in ekp_c/ directory")) + (unless make + (user-error "The make executable is not available")) + (let* ((default-directory (file-name-as-directory module-dir)) + (buffer (generate-new-buffer "*ekp-c-build*"))) + (make-process + :name (generate-new-buffer-name "ekp-c-build") + :buffer buffer + :command (list make (format "PROFILE=%s" profile)) + :noquery t + :sentinel (apply-partially #'ekp--c-build-finished profile))))) (provide 'ekp-utils) diff --git a/ekp.el b/ekp.el index 2da5f5d..dc2a2a5 100644 --- a/ekp.el +++ b/ekp.el @@ -249,22 +249,11 @@ when non-zero the C module is bypassed automatically." "Cache: equal-keyed table, content key → ekp-para struct.") (defvar ekp--last-para nil - "Fast path: (string-object lang width-context para), most recent lookup. -One justification call resolves the same string object many times; -this avoids recomputing the full cache key each time. Invalidated -by parameter changes, language changes, style-variable changes (see -the variable watchers below) and `ekp-clear-caches'.") - -;; The fast path bypasses `ekp--para-key', so every style variable in -;; that key must invalidate it on change — otherwise (setq -;; ekp-alignment 'center) kept returning the paragraph resolved under -;; the previous style to callers reusing the same string object. -(dolist (var '(ekp-alignment ekp-ragged-stretch-pixel ekp-protrusion - ekp-protrusion-ratios ekp-parshape ekp-first-line-indent)) - (add-variable-watcher - var (lambda (_sym _new op _where) - (when (memq op '(set let unlet makunbound)) - (setq ekp--last-para nil))))) + "Fast path for the most recently resolved paragraph. +The value is (STRING KEY PARA), where KEY is the same complete +structural key used by `ekp--para-cache'. One justification call +resolves the same string object many times; this avoids its repeated +hash-table lookup without creating a second identity rule.") (defcustom ekp-para-cache-limit 256 "Maximum number of cached paragraphs. @@ -300,6 +289,16 @@ hyphenation; it must not break loading the package." ekp-mws-ideal-pixel ekp-mws-stretch-pixel ekp-mws-shrink-pixel ekp-cws-ideal-pixel ekp-cws-stretch-pixel ekp-cws-shrink-pixel)) +(defun ekp--spacing-signature () + "Return the spacing inputs that determine paragraph preprocessing." + (if (and ekp--params-explicit (ekp--params-set-p)) + (list ekp-lws-ideal-pixel ekp-lws-stretch-pixel + ekp-lws-shrink-pixel ekp-mws-ideal-pixel + ekp-mws-stretch-pixel ekp-mws-shrink-pixel + ekp-cws-ideal-pixel ekp-cws-stretch-pixel + ekp-cws-shrink-pixel) + (cons 'auto ekp-default-cws-stretch-pixel))) + (defun ekp--param-apply (lws-i lws-+ lws-- mws-i mws-+ mws-- cws-i cws-+ cws--) "Set the nine spacing variables and derived limits (internal). The nine pixel arguments are ideal/stretch/shrink per glue class: @@ -390,7 +389,7 @@ Returns (boxes-vector . hyphen-positions-vector)." (setq hyphenator (condition-case nil (ekp-hyphen-create ekp-latin-lang) - (error nil)))) + (ekp-hyphen-dictionary-not-found nil)))) (when hyphenator (setq parts (ekp-hyphen-boxes hyphenator word)) (when (> (length left) 0) @@ -623,11 +622,6 @@ Positions right after HYPHEN-POSITIONS are forced to `nws'." ((eq 'mws type) (plist-get params :mws-stretch)) ((eq 'cws type) (plist-get params :cws-stretch))))) -(defun ekp--para-glue-min (para type) - "Return the minimum glue pixel (ideal - shrink) for TYPE in PARA." - (- (ekp--para-glue-ideal para type) - (ekp--para-glue-shrink para type))) - (defun ekp--para-glue-max (para type) "Return the maximum glue pixel (ideal + stretch) for TYPE in PARA." (+ (ekp--para-glue-ideal para type) @@ -719,8 +713,8 @@ therefore must key every measurement and paragraph cache entry." The key is a structure compared with `equal', so hash collisions cannot alias two different paragraphs. It covers: characters, text properties, detected fonts, the hyphenation language, and the -effective spacing parameters \(or the symbol `auto' when defaults -are derived per string)." +effective spacing signature \(nine explicit values or the auto CJK +stretch default when the other defaults are derived per string)." (let ((latin-font (ekp-latin-font string)) (cjk-font (ekp-cjk-font string))) (list string @@ -737,13 +731,7 @@ are derived per string)." ekp-parshape ekp-first-line-indent ekp-cjk-no-line-start-extra - (if (and ekp--params-explicit (ekp--params-set-p)) - (list ekp-lws-ideal-pixel ekp-lws-stretch-pixel - ekp-lws-shrink-pixel ekp-mws-ideal-pixel - ekp-mws-stretch-pixel ekp-mws-shrink-pixel - ekp-cws-ideal-pixel ekp-cws-stretch-pixel - ekp-cws-shrink-pixel) - 'auto)))) + (ekp--spacing-signature)))) (defun ekp--measure-boxes (boxes uniform-props) "Measure pixel widths of BOXES, deduplicating identical boxes. @@ -999,33 +987,28 @@ Computes ALL data in one pass: text, params, and prefix arrays." :alignment ekp-alignment :extra-stretch (if justify 0 (ekp--ragged-extra-stretch)))) - :dp-cache (make-hash-table :test 'eql :size 20)))) + :dp-cache (make-hash-table :test 'equal :size 20)))) (defun ekp--get-para (string) "Get or create `ekp-para' struct for STRING. This is the main entry point for cached paragraph data." - (if (and ekp--last-para - (eq (car ekp--last-para) string) - (equal (nth 1 ekp--last-para) ekp-latin-lang) - (equal (nth 2 ekp--last-para) (ekp--width-context))) - (nth 3 ekp--last-para) - (unless ekp--para-cache - (setq ekp--para-cache (make-hash-table :test 'equal :size 100))) - (let* ((key (ekp--para-key string)) - (para (or (gethash key ekp--para-cache) - (progn - (when (>= (hash-table-count ekp--para-cache) - ekp-para-cache-limit) - (clrhash ekp--para-cache)) - ;; NB: in auto-params mode `ekp--make-para' updates - ;; the spacing variables, which invalidates - ;; `ekp--last-para'; set the fast path afterwards. - (let ((p (ekp--make-para string))) - (puthash key p ekp--para-cache) - p))))) - (setq ekp--last-para - (list string ekp-latin-lang (ekp--width-context) para)) - para))) + (let ((key (ekp--para-key string))) + (if (and ekp--last-para + (eq (car ekp--last-para) string) + (equal (nth 1 ekp--last-para) key)) + (nth 2 ekp--last-para) + (unless ekp--para-cache + (setq ekp--para-cache (make-hash-table :test 'equal :size 100))) + (let ((para (or (gethash key ekp--para-cache) + (progn + (when (>= (hash-table-count ekp--para-cache) + ekp-para-cache-limit) + (clrhash ekp--para-cache)) + (let ((new-para (ekp--make-para string))) + (puthash key new-para ekp--para-cache) + new-para))))) + (setq ekp--last-para (list string key para)) + para)))) ;;;###autoload (defun ekp-clear-caches () @@ -1036,40 +1019,6 @@ Run after font or theme changes that affect glyph widths." (setq ekp--last-para nil) (clrhash ekp--box-width-cache)) -;;;; Paragraph Accessors - -(defun ekp--boxes (string) - "Return the boxes of STRING's paragraph." - (ekp-para-boxes (ekp--get-para string))) - -(defun ekp--boxes-widths (string) - "Return the box pixel widths of STRING's paragraph." - (ekp-para-boxes-widths (ekp--get-para string))) - -(defun ekp--glues-types (string) - "Return the glue types of STRING's paragraph." - (ekp-para-glues-types (ekp--get-para string))) - -(defun ekp--ideal-prefixs (string) - "Return the ideal prefix sums of STRING's paragraph." - (ekp-para-ideal-prefixs (ekp--get-para string))) - -(defun ekp--min-prefixs (string) - "Return the minimum prefix sums of STRING's paragraph." - (ekp-para-min-prefixs (ekp--get-para string))) - -(defun ekp--max-prefixs (string) - "Return the maximum prefix sums of STRING's paragraph." - (ekp-para-max-prefixs (ekp--get-para string))) - -(defun ekp--hyphen-pixel (string) - "Return the hyphen pixel width of STRING's paragraph." - (ekp-para-hyphen-pixel (ekp--get-para string))) - -(defun ekp--hyphen-positions (string) - "Return the hyphen positions of STRING's paragraph." - (ekp-para-hyphen-positions (ekp--get-para string))) - ;;;; K-P Badness and Demerits ;; demerits = (linepenalty + badness)² + penalty² + extras ;; @@ -1143,6 +1092,13 @@ Uses binary search for O(log n) lookup." ;;;; Shared Line Measurement (O(1) via prefix arrays) +(defsubst ekp--line-stripped-space-pixel + (raw-pixel start end lead-spaces trail-spaces) + "Return edge-space width excluded from RAW-PIXEL for line START..END. +LEAD-SPACES and TRAIL-SPACES are the paragraph's precomputed run vectors." + (min raw-pixel + (+ (aref lead-spaces start) (aref trail-spaces end)))) + (defun ekp--gaps-between (para i k) "Return (latin-gaps mix-gaps cjk-gaps) for PARA inside line I..K. Counts glue indices I+1 .. K-1 using precomputed prefix counts." @@ -1161,8 +1117,10 @@ space-box runs, and adds the hyphen width when the line hyphenates." (let* ((ip (ekp-para-ideal-prefixs para)) (raw (- (aref ip k) (aref ip i) (aref (ekp-para-glue-ideals para) i))) - (space-w (min raw (+ (aref (ekp-para-lead-spaces para) i) - (aref (ekp-para-trail-spaces para) k)))) + (space-w + (ekp--line-stripped-space-pixel + raw i k (ekp-para-lead-spaces para) + (ekp-para-trail-spaces para))) (ideal (- raw space-w))) (if (ekp--hyphenate-p (ekp-para-hyphen-positions para) (1- k)) (+ ideal (ekp-para-hyphen-pixel para)) @@ -1182,12 +1140,17 @@ space-box runs, and adds the hyphen width when the line hyphenates." ;; least as bad as the worst regular line. (defsubst ekp--dp-key (line-pixel) - "The dp-cache key for LINE-PIXEL under the current `ekp-looseness'. -Looseness changes the optimization target for the same paragraph and -width, so results at different looseness values must not alias -\(regression: a cached looseness-0 layout used to be returned after -`ekp-looseness' was changed)." - (if (zerop ekp-looseness) line-pixel (cons line-pixel ekp-looseness))) + "Return the complete DP cache signature for LINE-PIXEL. +The paragraph owns width-independent layout data; this key captures +every remaining runtime input read by the Elisp and C DP engines." + (list line-pixel + ekp-looseness + ekp-line-penalty + ekp-hyphen-penalty + ekp-adjacent-fitness-penalty + ekp-consecutive-hyphen-penalty + ekp-last-line-short-penalty + ekp-last-line-min-ratio)) (defun ekp--dp-cache-elisp (para line-pixel) "Return and cache the dp-result plist for PARA at LINE-PIXEL. @@ -1261,7 +1224,6 @@ unreachable (only possible when ALLOW-EMERGENCY is nil)." (lead-glue-ideal (aref glue-ideals i)) (lead-glue-min (- lead-glue-ideal (aref glue-shrinks i))) (lead-glue-max (+ lead-glue-ideal (aref glue-stretches i))) - (lead-space (aref lead-spaces i)) (saw-allowed nil) (k (1+ i))) (catch 'break @@ -1284,8 +1246,9 @@ unreachable (only possible when ALLOW-EMERGENCY is nil)." hyphen-protrude (aref tail-protrudes k)))) (raw-ideal (- (aref ideal-prefixs k) ip-i lead-glue-ideal)) - (space-w (min raw-ideal - (+ lead-space (aref trail-spaces k)))) + (space-w + (ekp--line-stripped-space-pixel + raw-ideal i k lead-spaces trail-spaces)) (ideal (+ (- raw-ideal space-w) hyph-w)) (minw (+ (- (aref min-prefixs k) mn-i lead-glue-min space-w) @@ -1495,7 +1458,6 @@ unreachable (only possible when ALLOW-EMERGENCY is nil)." (lead-glue-ideal (aref glue-ideals i)) (lead-glue-min (- lead-glue-ideal (aref glue-shrinks i))) (lead-glue-max (+ lead-glue-ideal (aref glue-stretches i))) - (lead-space (aref lead-spaces i)) (saw-allowed nil) (k (1+ i))) (catch 'break @@ -1515,8 +1477,9 @@ unreachable (only possible when ALLOW-EMERGENCY is nil)." hyphen-protrude (aref tail-protrudes k)))) (raw-ideal (- (aref ideal-prefixs k) ip-i lead-glue-ideal)) - (space-w (min raw-ideal - (+ lead-space (aref trail-spaces k)))) + (space-w + (ekp--line-stripped-space-pixel + raw-ideal i k lead-spaces trail-spaces)) (ideal (+ (- raw-ideal space-w) hyph-w)) (minw (+ (- (aref min-prefixs k) mn-i lead-glue-min space-w) @@ -1751,10 +1714,10 @@ the reconstructed rests overfill the indented line." (defun ekp--dp-cache-via-c (para line-pixel) "Compute breaks at LINE-PIXEL using the C module and PARA's arrays. The C module receives all font-dependent data from Elisp; it only -runs the pure DP. Falls back to Elisp when the C call fails." +runs the pure DP. A nil result falls back to Elisp; module errors +propagate because they indicate a broken backend contract." (ekp--c-sync-params) - (let* ((result (condition-case nil - (ekp-c-break-with-arrays + (let* ((result (ekp-c-break-with-arrays (ekp-para-ideal-prefixs para) (ekp-para-min-prefixs para) (ekp-para-max-prefixs para) @@ -1769,10 +1732,7 @@ runs the pure DP. Falls back to Elisp when the C call fails." (ekp-para-forbidden-positions para) (ekp-para-tail-protrudes para) (ekp-para-hyphen-protrude para) - (cdr (ekp--line-spec para 0 line-pixel))) - ;; A module-level signal must not escape: the - ;; Elisp engine is the fallback for any C failure. - (error nil))) + (cdr (ekp--line-spec para 0 line-pixel)))) (c-breaks (car result)) (c-cost (cdr result))) (if (null c-breaks) @@ -1800,11 +1760,9 @@ Only computes strings that aren't already cached." (mapcar (lambda (ip) (ekp--prepare-para-for-c (cdr ip) line-pixel)) needs-compute))) - ;; nil (whole-batch failure or a signal) falls back to - ;; the Elisp engine per paragraph below. - (batch-results (condition-case nil - (ekp-c-break-batch batch-input) - (error nil)))) + ;; A nil whole-batch result falls back per paragraph. + ;; Signals propagate as broken backend contracts. + (batch-results (ekp-c-break-batch batch-input))) (cl-loop for ip in needs-compute for j from 0 for idx = (car ip) @@ -1815,7 +1773,7 @@ Only computes strings that aren't already cached." do (aset results idx (if breaks (ekp--store-c-result para line-pixel breaks cost) - ;; C failed, fallback to Elisp + ;; C returned no result; fallback to Elisp. (ekp--dp-cache-elisp para line-pixel))))) (append results nil)))) @@ -1993,11 +1951,11 @@ Each line's glues: [0 glue1 glue2 ... trailing-space]." (raw-ideal (- (aref ip end) (aref ip start) (aref (ekp-para-glue-ideals para) start))) - (space-w (min raw-ideal - (+ (aref (ekp-para-lead-spaces para) - start) - (aref (ekp-para-trail-spaces para) - end))))) + (space-w + (ekp--line-stripped-space-pixel + raw-ideal start end + (ekp-para-lead-spaces para) + (ekp-para-trail-spaces para)))) (+ (- (aref mx end) (aref mx start) (+ (aref (ekp-para-glue-ideals para) start) (aref (ekp-para-glue-stretches para) start)) @@ -2110,11 +2068,14 @@ leftmost scan aligns them unambiguously." (setq i (1+ i)))) offsets)) +(defconst ekp--layout-marker-properties + '(ekp-glue ekp-soft-break ekp-soft-hyphen ekp-hidden ekp-justified) + "Text properties owned by the lossless render/inversion protocol.") + ;; Text typed next to a marker character must never inherit the ;; marker: a self-inserted char inheriting `ekp-glue' would be treated ;; as a synthesized space by the next unjustification and deleted. -(dolist (prop '(ekp-glue ekp-soft-break ekp-soft-hyphen ekp-hidden - ekp-justified)) +(dolist (prop ekp--layout-marker-properties) (setf (alist-get prop text-property-default-nonsticky) t)) (defun ekp--hide-string (string) @@ -2197,7 +2158,7 @@ The output is lossless with respect to STRING: (breaks (ekp-line-breaks string line-pixel)) (num (length breaks)) (lines-glues (ekp-line-glues string line-pixel)) - (hyphen-positions (ekp--hyphen-positions string)) + (hyphen-positions (ekp-para-hyphen-positions para)) (start 0) ;; (rendered-text first-box-idx last-box-idx) per visible line (lines nil)) diff --git a/ekp_c/Makefile b/ekp_c/Makefile index f827cfe..09f5a0f 100644 --- a/ekp_c/Makefile +++ b/ekp_c/Makefile @@ -31,16 +31,25 @@ else EMACS ?= emacs endif -CFLAGS := -std=c11 -Wall -Wextra -Wpedantic -O3 -fPIC -CFLAGS += -march=native -flto -CFLAGS += -D_POSIX_C_SOURCE=200809L +PROFILE ?= portable +COMMON_CFLAGS := -std=c11 -Wall -Wextra -Wpedantic -fPIC +COMMON_CFLAGS += -D_POSIX_C_SOURCE=200809L -# Debug build -ifdef DEBUG -CFLAGS := -std=c11 -Wall -Wextra -Wpedantic -O0 -g -fPIC -CFLAGS += -fsanitize=address,undefined +ifeq ($(PROFILE),portable) + PROFILE_CFLAGS := -O3 +else ifeq ($(PROFILE),native) + PROFILE_CFLAGS := -O3 -march=native -flto +else ifeq ($(PROFILE),debug) + PROFILE_CFLAGS := -O0 -g3 +else ifeq ($(PROFILE),sanitize) + PROFILE_CFLAGS := -O1 -g3 -fno-omit-frame-pointer + PROFILE_CFLAGS += -fsanitize=address,undefined +else + $(error Unknown PROFILE '$(PROFILE)'; use portable, native, debug, or sanitize) endif +CFLAGS := $(COMMON_CFLAGS) $(PROFILE_CFLAGS) + # Platform-specific settings ifeq ($(UNAME), Darwin) # macOS @@ -115,6 +124,7 @@ info: @echo "MODULE: $(MODULE)" @echo "UNAME: $(UNAME)" @echo "EMACS: $(EMACS)" + @echo "PROFILE: $(PROFILE)" ifdef EMACS_INCLUDE @echo "EMACS_INCLUDE: $(EMACS_INCLUDE)" endif diff --git a/ekp_c/README.md b/ekp_c/README.md index 259014d..df93697 100644 --- a/ekp_c/README.md +++ b/ekp_c/README.md @@ -1,6 +1,6 @@ # EKP C Dynamic Module -C implementation of the Knuth-Plass DP for emacs-kp (module version 1.5). +C implementation of the Knuth-Plass DP for emacs-kp (module version 1.6). The division of labor: **Elisp owns all font-dependent data** (tokenization, pixel measurement, glue values, prefix sums); the C @@ -30,7 +30,7 @@ tasks. ```bash cd ekp_c -make # → ekp.dylib (macOS) / ekp.so (Linux) / ekp.dll (Windows) +make PROFILE=portable # default → ekp.dylib/.so/.dll ``` Requirements: C11 compiler, Emacs module headers, pthreads. @@ -38,16 +38,24 @@ Windows builds need MinGW-w64 (for pthreads) and `make EMACS_ROOT=`. ```bash -make DEBUG=1 # Debug build with ASan/UBSan +make PROFILE=native # local CPU + LTO; benchmark-only +make PROFILE=debug # -O0 with debug symbols +make PROFILE=sanitize # ASan/UBSan with frame pointers make clean -make info +make info # includes the selected profile and final flags ``` +`portable` is the release and CI default and contains no +`-march=native`/LTO flags. An unknown profile is a make error. From Emacs, +`M-x ekp-c-module-build` prompts for the same profile names and starts make +with a direct argv plus `default-directory`; whitespace and shell +metacharacters in the checkout path are not interpreted. + ## API (as used by ekp.el) ```elisp (ekp-c-init) ; init global state -(ekp-c-version) ; => "1.5" — checked by ekp-c-module-load +(ekp-c-version) ; => "1.6" — checked by ekp-c-module-load (ekp-c-thread-count) ; worker count (created lazily on first batch) (ekp-c-cleanup) @@ -83,9 +91,15 @@ a second pass permitting emergency single-box breaks, so overlong unbreakable tokens can never make the result empty. Badness saturates at 10000 exactly like the Elisp side. -Failure behavior: any allocation failure or bad argument makes the -call return nil, and ekp.el falls back to the Elisp engine — the C -module never silently degrades to a subtly different layout. +Failure behavior: the full schema is checked before extraction. Malformed +direct API arguments signal `ekp-c-invalid-input`; allocation failure or an +unavailable DP result returns nil. `ekp.el` falls back only for nil. A module +signal propagates because it means the enabled backend contract is broken; +the dispatcher never hides it or silently produces a different layout. + +Every public pixel/position integer must fit signed 32-bit range. The DP +uses 64-bit intermediates for sums and differences, so valid extreme inputs +cannot overflow when line width and protrusion are combined. ## Performance diff --git a/ekp_c/ekp.c b/ekp_c/ekp.c index 6640e96..4daf4ce 100644 --- a/ekp_c/ekp.c +++ b/ekp_c/ekp.c @@ -28,8 +28,9 @@ /* Required for Emacs modules */ int plugin_is_GPL_compatible; -/* All pixel quantities travel as int32; clamp instead of silently - * wrapping if Elisp ever hands us something absurd. */ +/* Public array values are signed 32-bit pixels. The DP uses wider + * intermediates; rejecting an out-of-range API value is safer than + * silently changing it. */ static inline int32_t clamp32(intmax_t v) { if (v > INT32_MAX) return INT32_MAX; @@ -37,6 +38,117 @@ static inline int32_t clamp32(intmax_t v) return (int32_t)v; } +static bool lisp_predicate(emacs_env *env, const char *name, emacs_value value) +{ + emacs_value result = env->funcall( + env, env->intern(env, name), 1, (emacs_value[]){value}); + return env->is_not_nil(env, result); +} + +static bool i32_value_p(emacs_env *env, emacs_value value) +{ + if (!lisp_predicate(env, "integerp", value)) + return false; + + emacs_value min = env->make_integer(env, INT32_MIN); + emacs_value max = env->make_integer(env, INT32_MAX); + bool at_least_min = env->is_not_nil( + env, env->funcall( + env, env->intern(env, ">="), 2, (emacs_value[]){value, min})); + bool at_most_max = env->is_not_nil( + env, env->funcall( + env, env->intern(env, "<="), 2, (emacs_value[]){value, max})); + return at_least_min && at_most_max; +} + +static bool finite_number_p(emacs_env *env, emacs_value value) +{ + if (lisp_predicate(env, "floatp", value)) + return isfinite(env->extract_float(env, value)); + return i32_value_p(env, value); +} + +static emacs_value signal_invalid_input(emacs_env *env, const char *message) +{ + emacs_value text = env->make_string(env, message, strlen(message)); + emacs_value data = env->funcall( + env, env->intern(env, "list"), 1, (emacs_value[]){text}); + env->non_local_exit_signal( + env, env->intern(env, "ekp-c-invalid-input"), data); + return env->intern(env, "nil"); +} + +static bool i32_vector_p(emacs_env *env, emacs_value vector) +{ + ptrdiff_t length = env->vec_size(env, vector); + for (ptrdiff_t i = 0; i < length; i++) { + if (!i32_value_p(env, env->vec_get(env, vector, i))) + return false; + } + return true; +} + +static ptrdiff_t paragraph_field_length(int field, ptrdiff_t prefix_len) +{ + switch (field) { + case 0: case 1: case 2: case 9: case 10: case 12: + return prefix_len; + case 3: case 4: case 5: + return prefix_len - 1; + default: + return -1; + } +} + +static const char *validate_paragraph_shapes(emacs_env *env, emacs_value *args) +{ + static const int vectors[] = {0, 1, 2, 3, 4, 5, 6, 9, 10, 11, 12}; + + for (size_t i = 0; i < sizeof(vectors) / sizeof(vectors[0]); i++) { + if (!lisp_predicate(env, "vectorp", args[vectors[i]])) + return "EKP C paragraph array fields must be vectors"; + } + + ptrdiff_t prefix_len = env->vec_size(env, args[0]); + if (prefix_len <= 1 || prefix_len > INT32_MAX) + return "EKP C prefix vectors must contain 2..INT32_MAX elements"; + + for (int field = 0; field < 15; field++) { + ptrdiff_t expected = paragraph_field_length(field, prefix_len); + if (expected >= 0 && env->vec_size(env, args[field]) != expected) + return "EKP C paragraph vector lengths are inconsistent"; + } + return NULL; +} + +static const char *validate_paragraph_values(emacs_env *env, emacs_value *args) +{ + static const int vectors[] = {0, 1, 2, 3, 4, 5, 6, 9, 10, 11, 12}; + static const int scalars[] = {7, 8, 13, 14}; + + for (size_t i = 0; i < sizeof(vectors) / sizeof(vectors[0]); i++) { + if (!i32_vector_p(env, args[vectors[i]])) + return "EKP C paragraph vectors require signed 32-bit integers"; + } + for (size_t i = 0; i < sizeof(scalars) / sizeof(scalars[0]); i++) { + if (!i32_value_p(env, args[scalars[i]])) + return "EKP C paragraph scalars require signed 32-bit integers"; + } + + if (env->extract_integer(env, args[8]) <= 0) + return "EKP C line width must be positive"; + if (env->extract_integer(env, args[7]) < 0 || + env->extract_integer(env, args[13]) < 0) + return "EKP C hyphen width and protrusion must be nonnegative"; + return NULL; +} + +static const char *validate_paragraph(emacs_env *env, emacs_value *args) +{ + const char *error = validate_paragraph_shapes(env, args); + return error ? error : validate_paragraph_values(env, args); +} + /* * ekp-c-init: Initialize the module */ @@ -69,6 +181,47 @@ static emacs_value Fekp_c_cleanup(emacs_env *env, ptrdiff_t nargs, /* * ekp-c-set-penalties: Set K-P parameters */ +typedef struct { + int32_t line; + int32_t hyphen; + int32_t fitness; + double last_ratio; + int32_t consecutive; + double last_short; + int32_t extra_stretch; +} penalty_config_t; + +static const char *parse_penalties(emacs_env *env, ptrdiff_t nargs, + emacs_value *args, penalty_config_t *out) +{ + if (!i32_value_p(env, args[0]) || !i32_value_p(env, args[1]) || + !i32_value_p(env, args[2]) || !finite_number_p(env, args[3]) || + (nargs > 4 && !i32_value_p(env, args[4])) || + (nargs > 5 && !finite_number_p(env, args[5])) || + (nargs > 6 && !i32_value_p(env, args[6]))) + return "EKP C penalties require finite signed 32-bit numbers"; + + out->line = clamp32(env->extract_integer(env, args[0])); + out->hyphen = clamp32(env->extract_integer(env, args[1])); + out->fitness = clamp32(env->extract_integer(env, args[2])); + out->last_ratio = lisp_predicate(env, "floatp", args[3]) + ? env->extract_float(env, args[3]) + : (double)env->extract_integer(env, args[3]); + if (nargs > 4) + out->consecutive = clamp32(env->extract_integer(env, args[4])); + if (nargs > 5) + out->last_short = lisp_predicate(env, "floatp", args[5]) + ? env->extract_float(env, args[5]) + : (double)env->extract_integer(env, args[5]); + out->extra_stretch = nargs > 6 + ? clamp32(env->extract_integer(env, args[6])) : 0; + + if (out->last_ratio < 0.0 || out->last_ratio > 1.0 || + out->last_short < 0.0 || out->extra_stretch < 0) + return "EKP C ratios and stretch values are outside valid ranges"; + return NULL; +} + static emacs_value Fekp_c_set_penalties(emacs_env *env, ptrdiff_t nargs, emacs_value *args, void *data) { @@ -77,18 +230,21 @@ static emacs_value Fekp_c_set_penalties(emacs_env *env, ptrdiff_t nargs, if (!ekp_global || nargs < 4) return env->intern(env, "nil"); - ekp_global->line_penalty = clamp32(env->extract_integer(env, args[0])); - ekp_global->hyphen_penalty = clamp32(env->extract_integer(env, args[1])); - ekp_global->fitness_penalty = clamp32(env->extract_integer(env, args[2])); - ekp_global->last_line_ratio = env->extract_float(env, args[3]); - if (nargs > 4) - ekp_global->consec_hyphen_penalty = clamp32(env->extract_integer(env, args[4])); - if (nargs > 5) - ekp_global->last_line_short_penalty = env->extract_float(env, args[5]); - /* Per-line extra stretch for non-justify alignment; reset to 0 - * when the caller omits it so stale values never leak. */ - ekp_global->extra_stretch = - (nargs > 6) ? (int32_t)clamp32(env->extract_integer(env, args[6])) : 0; + penalty_config_t config = { + .consecutive = ekp_global->consec_hyphen_penalty, + .last_short = ekp_global->last_line_short_penalty + }; + const char *error = parse_penalties(env, nargs, args, &config); + if (error) + return signal_invalid_input(env, error); + + ekp_global->line_penalty = config.line; + ekp_global->hyphen_penalty = config.hyphen; + ekp_global->fitness_penalty = config.fitness; + ekp_global->last_line_ratio = config.last_ratio; + ekp_global->consec_hyphen_penalty = config.consecutive; + ekp_global->last_line_short_penalty = config.last_short; + ekp_global->extra_stretch = config.extra_stretch; return env->intern(env, "t"); } @@ -147,10 +303,12 @@ static emacs_value Fekp_c_break_with_arrays(emacs_env *env, ptrdiff_t nargs, if (!ekp_global || nargs < 15) return env->intern(env, "nil"); + const char *validation_error = validate_paragraph(env, args); + if (validation_error) + return signal_invalid_input(env, validation_error); + /* Get prefix array sizes (n+1 elements) */ ptrdiff_t prefix_len = env->vec_size(env, args[0]); - if (prefix_len <= 1) - return env->intern(env, "nil"); size_t n = prefix_len - 1; /* number of boxes */ @@ -390,9 +548,12 @@ static bool extract_paragraph_data( * ekp-c-break-batch: Process multiple paragraphs in parallel * * Args: vector of (ideal-prefix min-prefix max-prefix glue-ideals glue-shrinks - * glue-stretches hyphen-positions hyphen-width line-width) + * glue-stretches hyphen-positions hyphen-width line-width + * lead-spaces trail-spaces forbidden-positions tail-protrudes + * hyphen-protrude first-line-width) * - * Each element is a vector of 9 elements (same as ekp-c-break-with-arrays args). + * Each element is a vector of 15 elements (same as + * ekp-c-break-with-arrays args). * Returns vector of (breaks . total-cost) for each paragraph. * * This is the high-performance API for processing multi-paragraph text. @@ -405,10 +566,31 @@ static emacs_value Fekp_c_break_batch(emacs_env *env, ptrdiff_t nargs, if (!ekp_global || nargs < 1) return env->intern(env, "nil"); + if (!lisp_predicate(env, "vectorp", args[0])) + return signal_invalid_input( + env, "EKP C batch input must be a vector"); + ptrdiff_t para_count = env->vec_size(env, args[0]); if (para_count <= 0) return env->intern(env, "nil"); + /* Validate the complete batch before allocating or extracting any + * paragraph, so malformed input cannot leave a partial batch. */ + for (ptrdiff_t p = 0; p < para_count; p++) { + emacs_value para_vec = env->vec_get(env, args[0], p); + if (!lisp_predicate(env, "vectorp", para_vec) || + env->vec_size(env, para_vec) != 15) + return signal_invalid_input( + env, "Each EKP C batch paragraph must contain 15 fields"); + + emacs_value para_args[15]; + for (int i = 0; i < 15; i++) + para_args[i] = env->vec_get(env, para_vec, i); + const char *validation_error = validate_paragraph(env, para_args); + if (validation_error) + return signal_invalid_input(env, validation_error); + } + /* Allocate batch inputs and temporary storage */ ekp_batch_input_t *inputs = calloc(para_count, sizeof(ekp_batch_input_t)); int32_t **all_ideal = calloc(para_count, sizeof(int32_t *)); @@ -569,6 +751,12 @@ int emacs_module_init(struct emacs_runtime *runtime) if ((size_t)env->size < sizeof(*env)) return 2; + emacs_value error_name = env->intern(env, "ekp-c-invalid-input"); + emacs_value error_message = env->make_string( + env, "Invalid EKP C module input", 26); + env->funcall(env, env->intern(env, "define-error"), 2, + (emacs_value[]){error_name, error_message}); + /* Define functions */ defun(env, "ekp-c-init", 0, 0, Fekp_c_init, "Initialize EKP C module with thread pool."); @@ -583,9 +771,10 @@ HYPHEN-PENALTY: penalty for hyphenated breaks (default 50)\n\ FITNESS-PENALTY: penalty for adjacent line tightness mismatch (default 100)\n\ LAST-LINE-RATIO: minimum fill ratio for last line (default 0.5)\n\ CONSEC-HYPHEN-PENALTY: multiplier for consecutive hyphen runs (default 100)\n\ -LAST-LINE-SHORT-PENALTY: multiplier for short last lines (default 50.0)\n\n\ +LAST-LINE-SHORT-PENALTY: multiplier for short last lines (default 50.0)\n\ +EXTRA-STRETCH: per-line non-justify flexibility in pixels (default 0)\n\n\ (fn LINE-PENALTY HYPHEN-PENALTY FITNESS-PENALTY LAST-LINE-RATIO \ -&optional CONSEC-HYPHEN-PENALTY LAST-LINE-SHORT-PENALTY)"); +&optional CONSEC-HYPHEN-PENALTY LAST-LINE-SHORT-PENALTY EXTRA-STRETCH)"); defun(env, "ekp-c-break-with-arrays", 15, 15, Fekp_c_break_with_arrays, "Break lines using Elisp's pre-computed prefix arrays (preferred API).\n\n\ diff --git a/ekp_c/ekp_kp.c b/ekp_c/ekp_kp.c index fe72186..d1baf9a 100644 --- a/ekp_c/ekp_kp.c +++ b/ekp_c/ekp_kp.c @@ -41,7 +41,7 @@ ekp_state_t *ekp_global = NULL; #define EKP_BADNESS_INF 10000.0 /* Badness computation */ -static inline double compute_badness(int32_t adjustment, int32_t flexibility) +static inline double compute_badness(int64_t adjustment, int64_t flexibility) { if (adjustment == 0) return 0.0; @@ -54,7 +54,7 @@ static inline double compute_badness(int32_t adjustment, int32_t flexibility) } /* Fitness classification */ -static inline uint8_t compute_fitness(int32_t adjustment, int32_t flexibility) +static inline uint8_t compute_fitness(int64_t adjustment, int64_t flexibility) { if (flexibility <= 0) return FITNESS_DECENT; @@ -219,8 +219,8 @@ static inline bool dp_is_forbidden(const dp_input_t *in, size_t pos) static inline void dp_relax_emergency( const dp_input_t *in, size_t i, size_t k, double prev_dem, int prev_hyph, int prev_lines, - int32_t rest, bool end_hyphen, - double *demerits, int32_t *backptrs, int32_t *rest_pixels, + int64_t rest, bool end_hyphen, + double *demerits, int32_t *backptrs, int64_t *rest_pixels, uint8_t *fitness, int32_t *hyphen_counts, int32_t *line_counts) { double base = in->line_penalty + EKP_BADNESS_INF; @@ -247,22 +247,22 @@ static void dp_process_position( /* Output arrays */ double *demerits, int32_t *backptrs, - int32_t *rest_pixels, + int64_t *rest_pixels, uint8_t *fitness, int32_t *hyphen_counts, int32_t *line_counts) { size_t n = in->n; /* Line 0 (i == 0) may have a different width: first-line indent */ - int32_t line_width = (i == 0 && in->first_line_width > 0) + int64_t line_width = (i == 0 && in->first_line_width > 0) ? in->first_line_width : in->line_width; /* Get leading glue for line starting at i */ - int32_t lead_ideal = (in->glue_ideals && i < n) ? in->glue_ideals[i] : 0; - int32_t lead_shrink = (in->glue_shrinks && i < n) ? in->glue_shrinks[i] : 0; - int32_t lead_stretch = (in->glue_stretches && i < n) ? in->glue_stretches[i] : 0; - int32_t lead_space = in->lead_spaces ? in->lead_spaces[i] : 0; + int64_t lead_ideal = (in->glue_ideals && i < n) ? in->glue_ideals[i] : 0; + int64_t lead_shrink = (in->glue_shrinks && i < n) ? in->glue_shrinks[i] : 0; + int64_t lead_stretch = (in->glue_stretches && i < n) ? in->glue_stretches[i] : 0; + int64_t lead_space = in->lead_spaces ? in->lead_spaces[i] : 0; /* Try extending to each position k > i */ bool saw_allowed = false; @@ -281,27 +281,28 @@ static void dp_process_position( saw_allowed = true; bool end_hyphen = dp_is_hyphen(in, k - 1); - int32_t hyph_w = end_hyphen ? in->hyphen_width : 0; + int64_t hyph_w = end_hyphen ? in->hyphen_width : 0; /* Right-edge protrusion widens this candidate's target */ - int32_t lw = line_width + + int64_t lw = line_width + (end_hyphen ? in->hyphen_protrude : (in->tail_protrudes ? in->tail_protrudes[k] : 0)); /* Line metrics from i to k, excluding leading glue and the * space-box runs the renderer strips (leading + trailing). */ - int32_t raw_ideal = in->ideal_prefix[k] - in->ideal_prefix[i] - lead_ideal; - int32_t space_w = lead_space + + int64_t raw_ideal = (int64_t)in->ideal_prefix[k] - + in->ideal_prefix[i] - lead_ideal; + int64_t space_w = lead_space + (in->trail_spaces ? in->trail_spaces[k] : 0); if (space_w > raw_ideal) space_w = raw_ideal; - int32_t ideal = raw_ideal - space_w + hyph_w; - int32_t min_w = in->min_prefix[k] - in->min_prefix[i] - - (lead_ideal - lead_shrink) - space_w + hyph_w; - int32_t max_w = in->max_prefix[k] - in->max_prefix[i] - - (lead_ideal + lead_stretch) - space_w + hyph_w + - in->extra_stretch; + int64_t ideal = raw_ideal - space_w + hyph_w; + int64_t min_w = (int64_t)in->min_prefix[k] - in->min_prefix[i] - + (lead_ideal - lead_shrink) - space_w + hyph_w; + int64_t max_w = (int64_t)in->max_prefix[k] - in->max_prefix[i] - + (lead_ideal + lead_stretch) - space_w + hyph_w + + in->extra_stretch; /* Too long? (last line is never shrunk below its ideal) */ if (min_w > lw || (is_last && ideal > lw)) { @@ -329,8 +330,8 @@ static void dp_process_position( } /* Compute demerits */ - int32_t adjustment = lw - ideal; - int32_t flexibility = (adjustment > 0) ? + int64_t adjustment = lw - ideal; + int64_t flexibility = (adjustment > 0) ? (max_w - ideal) : (ideal - min_w); double badness; @@ -429,7 +430,8 @@ ekp_result_t *ekp_break_with_prefixes( int32_t hyphen_protrude, int32_t first_line_width) { - if (!ideal_prefix || !min_prefix || !max_prefix || n == 0 || line_width <= 0) + if (!ideal_prefix || !min_prefix || !max_prefix || n == 0 || + n > INT32_MAX || line_width <= 0) return NULL; if (first_line_width <= 0) first_line_width = line_width; @@ -437,7 +439,7 @@ ekp_result_t *ekp_break_with_prefixes( /* Allocate DP arrays */ double *demerits = malloc((n + 1) * sizeof(double)); int32_t *backptrs = malloc((n + 1) * sizeof(int32_t)); - int32_t *rest_pixels = malloc((n + 1) * sizeof(int32_t)); + int64_t *rest_pixels = malloc((n + 1) * sizeof(int64_t)); uint8_t *fitness = malloc((n + 1) * sizeof(uint8_t)); int32_t *hyph_counts = malloc((n + 1) * sizeof(int32_t)); int32_t *line_counts = malloc((n + 1) * sizeof(int32_t)); @@ -561,7 +563,7 @@ ekp_result_t *ekp_break_with_prefixes( } result->breaks = malloc(break_count * sizeof(int32_t)); - result->rest_pixels = malloc(break_count * sizeof(int32_t)); + result->rest_pixels = malloc(break_count * sizeof(int64_t)); if (!result->breaks || !result->rest_pixels) { ekp_result_destroy(result); free(demerits); free(backptrs); free(rest_pixels); diff --git a/ekp_c/ekp_module.h b/ekp_c/ekp_module.h index c6d7ca8..c72900d 100644 --- a/ekp_c/ekp_module.h +++ b/ekp_c/ekp_module.h @@ -27,7 +27,7 @@ /* Version */ #define EKP_VERSION_MAJOR 1 -#define EKP_VERSION_MINOR 5 +#define EKP_VERSION_MINOR 6 /* Limits */ #define EKP_THREAD_POOL_MAX 64 @@ -41,7 +41,7 @@ typedef struct { int32_t *breaks; /* break positions */ size_t break_count; - int32_t *rest_pixels; /* remaining space per line */ + int64_t *rest_pixels; /* remaining space per line */ double total_cost; } ekp_result_t; diff --git a/postmortem/20260728-buffer-integration-ownership.md b/postmortem/20260728-buffer-integration-ownership.md new file mode 100644 index 0000000..158ed47 --- /dev/null +++ b/postmortem/20260728-buffer-integration-ownership.md @@ -0,0 +1,63 @@ +# Let Layout Spans and Auto Mode Own Buffer Integrations + +> Updated by `20260728-public-filter-dispatch.md`: the same composition +> decision now delegates through Emacs's public dispatcher instead of its +> private helper, with DELETE cleanup ordered after dynamic-binding unwind. + +## Context + +`filter-buffer-substring-function` is a single-slot protocol. EKP replaced +that slot and later killed its local binding, losing any prior buffer-local +owner. Separately, public unjustify removed layout markers but left save, +search, change, and copy integrations installed. + +## Decision + +On first installation, record both the previous filter value and whether it +was buffer-local. The composed function invokes that filter first, preserving +its buffer context and DELETE semantics, then structurally inverts EKP +markers in the returned string. + +Separate the structural unjustify core from the public lifecycle wrapper. +Internal reflow, isearch, serialization, and teardown call the core. Public +unjustify and external edits remove integrations when neither auto mode nor +any justified span still needs them. + +## Alternatives Rejected + +### Replace the prior filter + +This silently breaks major-mode or user copy semantics and cannot be called +composition. + +### Run the prior filter on a temporary logical buffer + +Arbitrary filters may depend on the original buffer's local variables, +positions, fields, and deletion behavior. A temporary buffer cannot preserve +that contract. + +### Leave hooks installed because most become no-ops + +The copy slot remains occupied, hidden state survives after the visible +feature is gone, and later packages cannot recover ownership predictably. + +## Consequences + +- Prior local and inherited filters are restored exactly. +- Copy and kill preserve both prior-filter and EKP logical-text semantics. +- Internal temporary unjustify no longer changes integration lifecycle. +- A prior filter that intentionally removes text properties also removes + EKP marker evidence; that filter owns its returned representation. +- No new abstraction layer or external dependency was introduced. + +## Verification + +The old model failed both the composition and final-span cleanup controls. +The new model passes five public-path cases plus the existing isearch/save/ +mode suite. Full ERT passes 107/107, fuzz passes 300/300, and +warnings-as-errors byte compilation plus checkdoc are clean. + +## Rollback + +Restore direct filter assignment and the monolithic public unjustify +function. No persisted data needs migration. diff --git a/postmortem/20260728-c-boundary-contract.md b/postmortem/20260728-c-boundary-contract.md new file mode 100644 index 0000000..a896a40 --- /dev/null +++ b/postmortem/20260728-c-boundary-contract.md @@ -0,0 +1,66 @@ +# Validate the Whole C Boundary Before Entering the Algorithm + +## Context + +The C entrypoint indexed and extracted fields piecemeal. A short vector +could leave a pending Emacs exception while later API calls continued, and +large integers were silently clamped. Even valid int32 values could overflow +when line width, protrusion, and prefix differences were combined in int32. +The Elisp adapter then swallowed every module signal and recomputed, hiding +backend contract bugs. + +## Decision + +Treat the 15-field paragraph as one schema. Before allocation or extraction, +validate every vector field, all n/n+1 lengths, scalar type/range, and +positive width. Batch input preflights every paragraph before processing +any of them. Caller violations signal `ekp-c-invalid-input`; nil is reserved +for resource/no-result failure. + +Keep public values int32 for the stable ABI, but use int64 for all line +metric, adjustment, flexibility, and remaining-space intermediates. The +Elisp adapter falls back only for nil and lets module signals propagate. + +## Alternatives Rejected + +### Clamp arbitrary Lisp integers + +Clamping silently changes caller data and still leaves arithmetic overflow +after multiple valid int32 operands are combined. + +### Check pending exit after extraction + +It prevents some crashes but still calls module functions while an error is +pending and produces inconsistent error types. + +### Catch every module error and recompute in Elisp + +That makes user output look robust while hiding a broken enabled backend +from tests and maintainers. + +### Change every public array to int64 + +The inputs themselves fit pixel-scale int32; only expressions combining +them need wider representation. An ABI expansion adds no current value. + +## Consequences + +- Direct caller mistakes have one explicit condition. +- Batch validation is atomic. +- Valid extreme int32 inputs no longer overflow. +- Allocation/no-result remains recoverable. +- C 1.6 must be rebuilt before Elisp enables the accelerator. + +## Verification + +Six direct boundary controls failed 0/6 before the change and pass 6/6 +after it. A forced public dispatcher signal failed 0/1 then passes 1/1. +Release and sanitizer builds compile warning-free, full ERT passes 116/116, +and C/Elisp fuzz passes 300/300. macOS platform policy prevents loading the +ASan runtime into the signed Emacs process; Linux sanitizer CI remains the +runtime gate. + +## Rollback + +Restore C 1.5 extraction, int32 intermediates, and broad Elisp catch. No +saved data or user configuration needs migration. diff --git a/postmortem/20260728-c-build-profile-ownership.md b/postmortem/20260728-c-build-profile-ownership.md new file mode 100644 index 0000000..7c52f59 --- /dev/null +++ b/postmortem/20260728-c-build-profile-ownership.md @@ -0,0 +1,57 @@ +# Build Intent Must Be a Named Profile, Not an Ambient Flag + +## Context + +The default Makefile mixed release optimization with +`-march=native -flto`, so an ordinary artifact could depend on the build +host. The interactive Emacs command interpolated the checkout path into +`cd PATH && make`, handing quoting and metacharacter semantics to a shell. +Its one-use callback wrapper also inferred success from sentinel text rather +than the exit status. + +## Decision + +Name build intent explicitly: `portable` is the default, while `native`, +`debug`, and `sanitize` opt into their distinct flags. CI and documentation +use the same vocabulary. + +The interactive command validates the profile, binds `default-directory`, +and calls `make-process` with `("/path/to/make" "PROFILE=name")`. Its async +boundary checks process status and exit code; successful builds reload and +clean the output buffer, while failures retain and display diagnostics. + +## Alternatives Rejected + +### Quote the shell string + +Correct quoting differs by shell and Windows command processor, and no +shell feature is needed for one executable plus one argument. + +### Keep native optimization as the default + +That makes release portability depend on undocumented build-host CPU +features and toolchain LTO compatibility. + +### Preserve `DEBUG=1` as an alias + +Two vocabularies for one workflow create drift. The repository is not +maintaining a released build-flag API. + +## Consequences + +- Ordinary artifacts are portable by construction. +- Benchmark builds remain explicitly machine-specific. +- Whitespace/metacharacters in checkout paths are data, not syntax. +- Async failures preserve the evidence users need. + +## Verification + +The old command failed both process/profile controls. The new command passes +2/2, all four profiles compile warning-free, the real interactive portable +build exits 0 and loads C 1.6, and a copied source path containing spaces +builds successfully. + +## Rollback + +Restore the shell command and mixed default flags. No saved data needs +migration, but existing native artifacts should not be relabeled portable. diff --git a/postmortem/20260728-core-rule-ownership.md b/postmortem/20260728-core-rule-ownership.md new file mode 100644 index 0000000..e13f53e --- /dev/null +++ b/postmortem/20260728-core-rule-ownership.md @@ -0,0 +1,42 @@ +# Centralize Rules, Not File Shapes + +## Context + +`ekp-para` necessarily carries the width-independent data shared by Elisp +DP, the C boundary, and rendering. Splitting the struct or `ekp.el` would +mostly add declarations and navigation. Two actual rules, however, were +duplicated: edge-space exclusion appeared in four hot paths, and the +lossless marker vocabulary appeared in renderer and region integration. + +Cache identity and ideal-line measurement already had direct owners and +brute-force regressions from earlier work. + +## Decision + +Extract one allocation-free inline function for edge-space exclusion and +one constant for layout marker properties. Reuse them at every existing +call site and test each rule directly. Keep the coherent paragraph struct +and workflows in place. + +## Alternatives + +- Splitting `ekp.el` by “core,” “common,” or “helpers” was rejected because + it moves code without moving a stable responsibility. +- Returning a new line-metrics object per DP candidate was rejected because + it adds hot-loop allocation to remove textual duplication. +- Leaving the formulas inline was rejected because four copies had already + crossed DP/reconstruction/render ownership boundaries. + +## Consequences + +The most drift-prone edge rule and marker vocabulary now change in one +place. Cache keys, line metrics, and marker inversion each have direct +tests. Large workflows remain visible instead of being hidden behind +pass-through helpers. + +## Rollback + +Inline the function and constant if profiling or compatibility evidence +requires it, while retaining the independent rule tests. Do not replace +them with a broader abstraction unless it removes additional real +duplication. diff --git a/postmortem/20260728-dictionary-contract.md b/postmortem/20260728-dictionary-contract.md new file mode 100644 index 0000000..c58a5d2 --- /dev/null +++ b/postmortem/20260728-dictionary-contract.md @@ -0,0 +1,54 @@ +# Dictionary Syntax Is Part of the Layout Model + +## Context + +The bundle pulled moving LibreOffice HEAD with GNU-specific commands and no +manifest. Four dictionaries contain slash syntax. Three use libhyphen +replacement rules whose golden outputs include `asszony -> asz=szony`, +`paral·lel -> pa=ral=lel`, and `adhem -> e`; Esperanto has slash-prefixed +patterns too. EKP silently skipped every such line. + +One bundled Sanskrit file also lacked an in-file license statement and a +package companion applying terms to that exact hyphenation data. LibreOffice +had reverted a generic COPYING addition because spelling and hyphenation data +may have different licenses. + +## Decision + +Slash/replacement dictionaries fail closed with a typed condition. A +replacement changes line-end text, next-line text, and measured width only if +that breakpoint wins. EKP boxes have fixed text/width, and both Elisp and C +DP consume those fixed arrays. Parsing only the ordinary part would emit +wrong language; implementing only output substitution would make DP widths +wrong. Correct support therefore belongs to a future explicit +break-alternative model across tokenization, DP, C ABI, and lossless render +inversion—not a parser patch. + +The bundle is pinned at one snapshot with normalized per-file checksums. +Basque is explicitly retained as a legacy checked-in byte because it is not +in that snapshot and has its own notice. Sanskrit is removed until license +terms for the exact hyphenation data are authoritative. + +## Alternatives + +- Silently skipping replacement patterns was rejected because it overstates + language support. +- Treating the prefix before `/` as a normal pattern was rejected because it + produces incorrect breaks such as `as-szony`. +- Renderer-only substitutions were rejected because line costs would still + use the unreplaced glyph widths. +- A new general parser/DP representation was deferred because four languages + do not justify a cross-engine ABI expansion without product demand. + +## Consequences + +`eo`, `ca`, `hu_HU`, and `sq_AL` now produce a diagnostic condition instead +of partial hyphenation. Forty-nine dictionary files remain bundled; the +ordinary-pattern subset is usable for 45. Offline and upstream gates make +source, checksum, syntax, and license drift visible. + +## Rollback + +Reverting requires restoring the previous bundle, updater, claims, and silent +skip behavior together. Do not re-add Sanskrit or advertise replacement +support without their missing evidence/model. diff --git a/postmortem/20260728-dp-cache-signature.md b/postmortem/20260728-dp-cache-signature.md new file mode 100644 index 0000000..1181ab6 --- /dev/null +++ b/postmortem/20260728-dp-cache-signature.md @@ -0,0 +1,66 @@ +# Complete DP Cache Signatures Instead of Invalidation Watchers + +## Context + +`ekp-para` caches width-dependent DP and rendered results. The old key was +only the line width, with a special `(width . looseness)` cons when looseness +was non-zero. Two failures followed: + +- changing a cost parameter reused a result computed under the old value; +- the cons key never hit an `eql` hash table when reconstructed. + +The DP reads six cost parameters at runtime, so paragraph identity alone +cannot make those cached results valid. + +## Decision + +Use one flat signature containing line width, looseness, and all six runtime +cost parameters. Compare keys with structural `equal`. + +Paragraph-construction inputs remain in `ekp--para-key`; DP-only inputs +remain in `ekp--dp-key`. No cache-clearing side effect crosses that boundary. + +## Alternatives Rejected + +### Variable watchers that clear caches + +Watchers would spread ownership across seven variables and discard +width-independent paragraph work. Forgetting the next parameter would +silently recreate the bug. + +### An integer hash of all parameters + +A hash collision would become a correctness failure. Structural keys make +collisions harmless because equality is still checked. + +### A memoized global signature object + +It saves small list allocations but adds mutable global state and comparison +logic. A 100,000-hit microbenchmark measured approximately 0.37 seconds for +the complete key versus 0.17 seconds for the old scalar key: about two +microseconds per lookup, below the cost threshold that would justify another +state owner. + +## Consequences + +- Algorithm changes take effect on the next call without manual flushing. +- Returning to a previous parameter set can reuse its prior cached result. +- Non-zero looseness now receives real cache hits. +- Cache lookup performs structural hashing over eight small scalar values. +- Each distinct parameter set can retain its own result in the paragraph + cache; normal interactive configuration has low cardinality, but a caller + that sweeps parameters should clear caches between experiments. +- No public API, C ABI, saved-file format, or paragraph representation + changed. + +## Verification + +- The old model failed both the parameter-isolation and identical-signature + tests. +- The new model passes all six parameter cases and the cache-identity check. +- Full ERT, C/Elisp fuzz, warning-clean byte compilation, and checkdoc pass. + +## Rollback + +Revert the key/table change and these regression tests. No migration or data +cleanup is necessary because caches are process-local. diff --git a/postmortem/20260728-gui-verification-exit-contract.md b/postmortem/20260728-gui-verification-exit-contract.md new file mode 100644 index 0000000..81ad072 --- /dev/null +++ b/postmortem/20260728-gui-verification-exit-contract.md @@ -0,0 +1,52 @@ +# A Report Is Not a Gate Until Failure Propagates + +## Context + +The GUI matrix measured the correct display facts and printed PASS or FAIL, +but batch callers always received success. The visible table and the process +contract were separate, so release automation could ignore a real overflow. + +## Decision + +Each case returns its measurement plist plus name. One reporting boundary +formats all rows, aggregates `:pass`, and exits batch Emacs with status 1 +after printing the table when any case fails. Interactive use still displays +the report without terminating Emacs. + +Keep the matrix under `tests/` because it owns showcase fixtures and +developer-only display scenarios. User/developer documentation must load it +explicitly rather than imply that requiring the runtime package defines it. + +## Alternatives Rejected + +### Search the rendered table for `FAIL` + +Text parsing duplicates the decision and can diverge from measurement data. + +### Signal before printing + +Automation gets a failure but loses the row evidence needed to diagnose it. + +### Move the showcase matrix into the runtime package + +That expands the installed surface and couples production code to a +developer fixture without improving the assertion contract. + +## Consequences + +- Batch success now means every GUI row passed. +- Failure retains a complete diagnostic table and exits exactly 1. +- The report formatter is testable without a graphical display. +- Real pixel correctness still requires a GUI matrix run. + +## Verification + +The missing-boundary control failed 0/1 before implementation. The repaired +failure/success controls pass 2/2, both full ERT orders pass 110/110, and the +live seven-case matrix passes 7/7. The final screenshot was verified as one +fullscreen target buffer with no split or stale client message. + +## Rollback + +Restore string results and report-only behavior. No persisted data needs +migration. diff --git a/postmortem/20260728-linear-text-builders.md b/postmortem/20260728-linear-text-builders.md new file mode 100644 index 0000000..2bf28d7 --- /dev/null +++ b/postmortem/20260728-linear-text-builders.md @@ -0,0 +1,42 @@ +# Measure Before Replacing Text Builders + +## Context + +The tokenizer appended each character to an accumulator string, dense +hyphen insertion rebuilt the whole prefix at every break, and the cache used +nil for both “absent” and “computed with no breaks.” These are familiar +smells, but ordinary paragraph performance did not prove they mattered. + +An adversarial 1,000–8,000-character benchmark established the boundary: +8× input grew tokenizer time 10.3× and dense insertion 11.5×. Repeated +no-break lookups also called the dictionary computation twice. + +## Decision + +Keep ownership in the existing tokenizer and hyphenator. Accumulate +fragments locally, join once at the point each complete value is emitted, +and use one private sentinel to distinguish a missing cache entry from a +cached nil. No general builder layer is introduced. + +## Alternatives + +- Leaving the loops unchanged was rejected after scaling and absolute time + both showed material cost. +- A reusable rope/builder abstraction was rejected because the two loops + have different owners and need only local lists. +- Moving either operation to C was rejected because text properties, + Unicode segmentation, and dictionary semantics remain Elisp-owned. + +## Consequences + +At 8,000 characters, source-mode tokenizer time fell from 3.133 s to +1.100 s and dense insertion from 0.945 s to 0.013 s. Growth across the +range is 6.3× and 7.7× respectively for 8× input. Negative hyphenation +results now reuse the cache. Exact output and text-property regressions +protect the representation change. + +## Rollback + +Revert the fragment builders together with their benchmark claims. Keep +the nil-cache sentinel and all output regressions unless evidence shows the +cache invalidation boundary itself is wrong. diff --git a/postmortem/20260728-nonmutating-save-serialization.md b/postmortem/20260728-nonmutating-save-serialization.md new file mode 100644 index 0000000..c5b240c --- /dev/null +++ b/postmortem/20260728-nonmutating-save-serialization.md @@ -0,0 +1,56 @@ +# Serialize a Logical Copy Instead of Restoring After Save + +## Context + +EKP previously unformatted the display buffer in `before-save-hook` and +reformatted it in `after-save-hook`. Emacs does not run `after-save-hook` +when writing fails or is interrupted, so the screen stayed unformatted and +the marker state stayed live. + +## Decision + +Do not mutate the source buffer during save. A buffer-local +`write-region-annotate-functions` entry switches whole-buffer writes to a +hidden logical copy. This is the same documented buffer-switch boundary used +by Emacs format encoders, so normal backup, coding, file-mode, VC, and later +annotation behavior remains owned by Emacs. + +## Alternatives Rejected + +### Add another restoration hook + +There is no general failure hook paired with `after-save-hook`. A +post-command fallback would miss programmatic saves and nested calls. + +### Advise `save-buffer` or `write-region` globally + +Global advice would make every Emacs save share EKP's lifecycle and create +ordering conflicts with other packages. + +### Reimplement saving in `write-contents-functions` + +Owning the actual write would duplicate backup, coding, file modes, VC, and +visited-file semantics that Emacs already implements. + +## Consequences + +- Success and failure paths leave the visible layout byte-identical. +- Disk and auto-save output receive logical text. +- A successful write disposes the copy immediately. +- Because Emacs does not call the post-annotation function after a failed + write, at most one hidden copy remains per source buffer; retry or teardown + replaces and clears it. +- No global advice, external dependency, or saved-file format changed. + +## Verification + +The old model failed real missing-directory and forced-quit tests by leaving +the logical text visible. The new model passes success, filesystem failure, +encoding failure plus retry, and interruption tests. Full ERT passes +102/102, C/Elisp fuzz passes 300/300, and warnings-as-errors byte compilation +plus checkdoc are clean. + +## Rollback + +Restore the before/after hooks and their marker state. Existing files need +no migration because both designs write the same logical bytes. diff --git a/postmortem/20260728-paragraph-fast-path-identity.md b/postmortem/20260728-paragraph-fast-path-identity.md new file mode 100644 index 0000000..3c29f93 --- /dev/null +++ b/postmortem/20260728-paragraph-fast-path-identity.md @@ -0,0 +1,55 @@ +# Give Both Paragraph Cache Paths One Identity Owner + +## Context + +The paragraph hash cache used `ekp--para-key`, including filtered +text-property intervals and every paragraph preprocessing input. The +one-entry `ekp--last-para` path bypassed that key and compared a smaller +tuple. Mutating `ekp-no-break` on the exact cached string object therefore +returned stale boxes and break permissions. + +## Decision + +Compute `ekp--para-key` before either lookup. The one-entry path may skip the +hash-table operation when both string identity and that complete key match, +but it does not own a second signature. Remove style-variable watchers whose +only purpose was to compensate for the partial fast-path tuple. + +## Alternatives Rejected + +### Add Text Properties to the Partial Tuple + +That repairs one omission while preserving two independently maintained +identity rules. The next paragraph input would recreate the bug. + +### Clear All Paragraph Caches on Property Mutation + +Standalone strings have no general mutation hook, and eager global +invalidation would discard unrelated paragraphs. + +### Remove the One-Entry Path + +That is correct but needlessly adds a hash-table lookup to repeated resolution +inside one formatting call. Comparing the already-owned key keeps the narrow +optimization without duplicate semantics. + +## Consequences + +- Same-object text-property mutations now miss both lookup paths. +- Style, language, spacing, display context, and text properties have one + paragraph identity owner. +- Six watcher registrations and their anonymous invalidation function are + removed. +- The fast path still avoids the hash lookup, but no longer avoids computing + the correctness key. + +## Verification + +The new regression failed 0/1 before the change. It passes for both CJK and +Latin-with-space strings after the change, and the related cache/style/ +display-context matrix passes 6/6. + +## Rollback + +Restore the partial tuple and watchers. No persisted data needs migration, +but the same-object property regression returns. diff --git a/postmortem/20260728-paragraph-spacing-signature.md b/postmortem/20260728-paragraph-spacing-signature.md new file mode 100644 index 0000000..03a1afd --- /dev/null +++ b/postmortem/20260728-paragraph-spacing-signature.md @@ -0,0 +1,54 @@ +# One Spacing Signature for Both Paragraph Cache Paths + +## Context + +Automatic spacing derives most values from the paragraph's measured font, +but reads `ekp-default-cws-stretch-pixel` directly. The paragraph hash stored +only `auto`, and `ekp--last-para` bypassed that hash entirely. Changing the +default therefore returned a paragraph created with the old CJK stretch. + +## Decision + +Use one `ekp--spacing-signature` at both paragraph lookup boundaries. +Explicit mode records all nine spacing values. Automatic mode records the +CJK stretch default; font-derived inputs remain represented by the existing +font and display-context fields. + +## Alternatives Rejected + +### Add another variable watcher + +A watcher would repair only the one-entry fast path. The paragraph hash +would still alias old and new automatic values, leaving the root model wrong. + +### Clear all paragraph caches on change + +Eager invalidation would discard unrelated font measurement and paragraph +analysis. A structural signature lets each valid configuration reuse its +own paragraph. + +### Store every derived automatic value in the key + +Computing those values requires measurement and would duplicate paragraph +construction work during lookup. Their actual inputs are already in the +font, text-property, and display-context identity. + +## Consequences + +- Automatic CJK spacing changes apply on the next call. +- Direct explicit-spacing mutations also cannot bypass the fast path. +- Unchanged signatures retain hash and one-entry fast-path hits. +- No public API, C ABI, saved-file format, or new watcher was added. + +## Verification + +Two old-model regressions fail with stale `:cws-stretch` value 2 after the +default changes to 9; the unchanged-signature control already passes. +After the fix, all three pass. +The complete suite passes 99/99, C/Elisp fuzz passes 300/300, and +warnings-as-errors byte compilation plus checkdoc are clean. + +## Rollback + +Revert the shared signature and regression tests. No persisted data is +affected. diff --git a/postmortem/20260728-public-filter-dispatch.md b/postmortem/20260728-public-filter-dispatch.md new file mode 100644 index 0000000..1a9fb5d --- /dev/null +++ b/postmortem/20260728-public-filter-dispatch.md @@ -0,0 +1,63 @@ +# Use the Public Substring Filter Dispatcher + +## Context + +EKP correctly preserved a prior `filter-buffer-substring-function`, but +invoked it through Emacs's private `buffer-substring--filter` helper. That +made the integration depend on an implementation detail outside EKP's +ownership and version baseline. + +## Decision + +During EKP extraction, dynamically bind `filter-buffer-substring-function` +to the recorded prior value and call public `filter-buffer-substring`. +Inhibit EKP lifecycle cleanup while that temporary binding is active. For a +DELETE operation, unwind the binding first and then reconcile integration +ownership against the buffer's actual filter slot. + +## Failed First Repair + +The first public-dispatch repair allowed after-change cleanup to run under +the temporary prior-filter binding. The focused kill/DELETE regression +failed because cleanup cleared EKP ownership state without restoring the +real EKP-bound slot. That failure narrowed the problem to lifecycle ordering; +no second speculative behavior patch was stacked on it. + +## Alternatives Rejected + +### Keep Calling the Private Helper + +It works on the current Emacs build but violates the dependency boundary and +makes the documented Emacs baseline depend on unowned internals. + +### Reimplement Prior-Filter Dispatch + +Duplicating Emacs's filtering and deletion semantics would create a second +protocol implementation and a larger compatibility surface. + +### Run Cleanup Before Unwinding + +The dynamic prior-filter binding is not the buffer's durable ownership state, +so lifecycle decisions made there are necessarily wrong-layer decisions. + +## Consequences + +- Prior local and inherited filter behavior still composes through the + public dispatcher. +- DELETE cleanup observes the real post-operation ownership slot. +- Internal filter calls can no longer drift independently from Emacs's public + dispatcher contract. +- Errors from a prior filter still propagate; no fallback or swallowing path + was added. + +## Verification + +The first repair failed the public kill/DELETE lifecycle regression. The +ordering repair passes all five composition/lifecycle cases. The final +private-boundary scan contains no `buffer-substring--filter` use in runtime +or tests. + +## Rollback + +Restore direct private-helper dispatch. No persisted data needs migration, +but doing so reintroduces the unowned dependency boundary. diff --git a/postmortem/20260728-release-governance.md b/postmortem/20260728-release-governance.md new file mode 100644 index 0000000..6958349 --- /dev/null +++ b/postmortem/20260728-release-governance.md @@ -0,0 +1,43 @@ +# Release Governance Has Two Boundaries + +## Context + +The repository described `.phrase` as its decision source of truth while +ignoring that directory. CI used mutable action refs and did not exercise +Windows, although the project documents Windows module support. Package, +C ABI, changelog, local tag, remote tag, CI, and artifacts were checked by +separate informal steps. + +## Decision + +Repository-local invariants belong to an executable static gate: +version pairs, dated changelog state, immutable CI action refs, platform +coverage, and tracked decision records. Remote and artifact facts belong to +an explicit release checklist because they require credentials and the exact +reviewed release commit. + +The gate does not infer that a local tag was published or that remote CI ran. +Those claims must be verified against the remote during an actual release. + +## Alternatives + +- A large release framework was rejected: it would add a dependency and more + policy than this small package needs. +- Treating `.phrase` as disposable local state was rejected because the + repository workflow already makes task, issue, decision, and change records + authoritative. +- Floating major action tags were rejected because their executed bytes can + change without a repository diff. + +## Consequences + +CI updates now require an intentional SHA change with the readable upstream +tag retained in a comment. Windows gets an Elisp baseline even when the +optional C toolchain is unavailable. Actual publishing remains a distinct, +credentialed operation and is never implied by the local gate. + +## Rollback + +Revert the gate, workflow, release guide, and `.gitignore` decision together. +Restoring only floating refs or ignored decision records would recreate the +same split-brain process. diff --git a/postmortem/20260728-session-local-protection.md b/postmortem/20260728-session-local-protection.md new file mode 100644 index 0000000..e9b9138 --- /dev/null +++ b/postmortem/20260728-session-local-protection.md @@ -0,0 +1,40 @@ +# Manual Protection Is Session State, Not Document Syntax + +## Context + +`ekp-no-break-region` and `ekp-verbatim-region` correctly changed layout but +were hard to discover and gave no feedback. Because they attach ordinary +text properties, a user could reasonably assume saving preserves the +protection even though plain-text serialization intentionally removes all +layout metadata. + +## Decision + +Keep manual protection session-local and say so at every user boundary: +interactive messages, command and mode help, the EKP menu, and bilingual +README sections. Existing removal commands appear beside mark commands. +Persistent document semantics remain owned by major-mode faces or the +buffer-local skip predicate. + +## Alternatives + +- Serializing properties was rejected because it changes file formats and + requires mode-specific round-trip semantics. +- Adding a transient UI or global bindings was rejected because a standard + minor-mode menu and `C-h m` already provide discovery without another + interaction model. +- Silently leaving behavior unchanged was rejected because the lifetime + ambiguity is user-visible even when layout is technically correct. + +## Consequences + +Interactive commands now provide immediate, consistent confirmation. +Users can discover mark and clear operations from one EKP menu. Plain files +remain unchanged, and Org/Markdown/source-aware modes retain the existing +face/predicate integration route. + +## Rollback + +Remove the menu and feedback together with their documentation. Do not +claim persistence unless a future design specifies storage, migration, +failure behavior, and mode ownership first. diff --git a/postmortem/20260728-test-state-isolation.md b/postmortem/20260728-test-state-isolation.md new file mode 100644 index 0000000..cf88c8f --- /dev/null +++ b/postmortem/20260728-test-state-isolation.md @@ -0,0 +1,55 @@ +# Make Test State Ownership Explicit + +## Context + +The test named as a parshape C-bypass check actually configured first-line +indent, which C 1.5 supports. It passed in the alphabetic full suite only +because an earlier C-parameter test left `ekp-use-c-module` disabled. +The shared fixture restored a hand-picked penalty subset rather than the +state its callers could mutate. + +## Decision + +The fixture dynamically binds every public EKP tunable plus spacing state, +so arbitrary assignment inside a test is automatically scoped. Dispatch +regressions go through the public formatter and use an observable forbidden +C call, rather than asserting only an internal eligibility predicate. + +Keep three complementary entry points: normal order for speed, a seeded +permutation for cross-test leakage, and one fresh process per test for +absolute isolation. The runners use the public `ert-select-tests` selector +contract. + +## Alternatives Rejected + +### Reset more variables to hard-coded defaults + +That overwrites caller configuration, must be updated whenever defaults +change, and repeats the partial-ownership mistake. + +### Test only `ekp--c-available-p` + +It does not prove that the installed public dispatcher uses the same input +shape or filtering logic. + +### Depend on alphabetical order + +Stable order is useful for reproducibility, not as a correctness boundary. + +## Consequences + +- Leaked test assignments are restored to their incoming values. +- Parshape bypass is proven through the same path users call. +- A failed randomized seed can be reproduced with `EKP_TEST_SEED`. +- Full process isolation remains intentionally slower and opt-in. + +## Verification + +The original isolated C-loaded control failed 0/1. The focused repaired +matrix passed 3/3, a non-default permutation passed 108/108, and all 108 +tests passed one-by-one in fresh Emacs processes. + +## Rollback + +Restore the old fixture and test, then remove the two runners and CI lane. +No production or persisted data needs migration. diff --git a/readme.md b/readme.md index 44beab8..19a99a7 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # Emacs-KP: Knuth-Plass Line Breaking for Emacs -[中文文档](./readme_zh.md) | [Developer Guide](./DEVELOPER.md) +[中文文档](./readme_zh.md) | [Developer Guide](./DEVELOPER.md) | [Repository Audit](./Docs/REPOSITORY_AUDIT_20260728.md) Emacs-kp implements the Knuth-Plass optimal line breaking algorithm with full support for CJK (Chinese, Japanese, Korean) and Latin mixed text @@ -13,8 +13,8 @@ typesetting, entirely inside Emacs. - **CJK support** — every CJK character is a breakable box; kinsoku rules keep punctuation attached (`,。` never start a line, `「《` never end one); dedicated inter-CJK and CJK↔Latin spacing. -- **Hyphenation** — Frank Liang's algorithm (the TeX algorithm) with 70+ - Hunspell pattern dictionaries bundled. +- **Hyphenation** — Frank Liang's algorithm (the TeX algorithm) with 49 + checksum-pinned Hunspell pattern dictionaries bundled. - **Pixel-accurate justification** — every justified line renders at exactly the requested pixel width, using `display (space :width ...)` properties; works with variable-width fonts. @@ -64,18 +64,21 @@ preserved. ### C module (recommended for long texts) ```bash -cd ekp_c && make # requires C11 compiler, produces ekp.dylib/.so/.dll +cd ekp_c && make PROFILE=portable # default; produces ekp.dylib/.so/.dll ``` ```elisp -(ekp-c-module-load) ; prints "ekp-c module loaded (version 1.4, N threads)" +(ekp-c-module-load) ; prints "ekp-c module loaded (version 1.6, N threads)" +(ekp-c-module-build) ; prompts for portable/native/debug/sanitize ``` Once loaded (and since `ekp-use-c-module` defaults to `t`), all justification calls automatically use the C engine. The Elisp and C -engines produce **identical output**; Elisp is the always-available -fallback. If the module on disk is older than the Elisp code expects, -loading refuses with a message asking you to rebuild. +engines produce **identical output**; Elisp is the always-available path +when no module is enabled or C returns no result. An enabled module signal +is surfaced as a backend contract failure. If the module on disk is older +than the Elisp code expects, loading refuses with a message asking you to +rebuild. ## Interactive Use (buffer & region) @@ -101,16 +104,21 @@ loading refuses with a message asking you to rebuild. after edits re-justifies only the touched paragraphs (`ekp-auto-justify-edit-delay`), so unchanged paragraphs hit the paragraph cache. Turning the mode off restores the buffer exactly. + While active, the standard **EKP** menu exposes formatting, protection, + and window-fit diagnostic commands; `C-h m` describes the same workflow. The buffer is treated as a live document, not just a canvas: - **Saving** writes the *logical* text — soft line breaks, glue spaces and break hyphens never reach disk; the on-screen buffer - stays justified. + stays justified even if writing fails or is interrupted. + This guarantee applies to whole-buffer saves; an explicit region-only + `write-region` writes the selected physical buffer representation. - **Searching** (isearch) sees the logical text, so CJK phrases and hyphenated words are found across the layout. - **Copying** puts the logical text on the kill ring, so pasted text - carries words, not pixel spacing. + carries words, not pixel spacing. Existing mode/user substring filters + remain active and are restored when the final layout span is removed. - Merely enabling the mode never marks the buffer modified (no stray lock files or auto-saves), and `undo` is not fought by the re-flow timer. @@ -146,6 +154,13 @@ in Org and Markdown buffers when you have not configured your own. never hyphenated, spacing kept literal — ideal for inline code, product names, or numbers with units. +Manual properties are deliberately **current-buffer-session only**: +plain-text saving and reopening do not persist them. Use +`M-x ekp-allow-break-region` / `ekp-clear-verbatim-region` to remove them. +For protection derived from persistent document syntax, use mode faces or +the buffer-local `ekp-region-skip-predicate` (the Org/Markdown presets do +this automatically). + ## Typography - **Alignment** — `ekp-alignment`: `justify` (default), @@ -185,12 +200,20 @@ not renderable (text cannot start before the line origin). (setq ekp-latin-lang "de_DE") ; default "en_US" ``` -Any `dictionaries/hyph_.dic` works; short codes like `"de"` -resolve to the first matching dictionary. Each dictionary's own +Short codes like `"de"` resolve to the first matching dictionary. Each +supported dictionary's own `LEFTHYPHENMIN` / `RIGHTHYPHENMIN` are honored (English keeps ≥2 letters before and ≥3 after a break); pass explicit margins to `ekp-hyphen-create` to override. +EKP supports ordinary Liang patterns. It fails closed with +`ekp-hyphen-unsupported-pattern` for `eo`, `ca`, `hu_HU`, and `sq_AL` +because those files contain slash/replacement rules that conditionally +rewrite glyphs at a chosen break. Treating them as ordinary positions would +produce linguistically wrong text and incorrect DP widths. The exact +inventory, SHA-256 checksums, pinned source paths, and license evidence live +in `dictionaries/MANIFEST.tsv` and `dictionaries/LICENSES.md`. + ### Spacing parameters Three glue classes control spacing (all values in pixels): @@ -227,16 +250,23 @@ Each class has an ideal width, a maximum stretch and a maximum shrink: | `ekp-last-line-short-penalty` | 50 | Cost multiplier for a too-short last line | | `ekp-looseness` | 0 | Target line count offset: +1 = one line more than optimal, −1 = one fewer | -All parameters take effect with both engines: the Elisp side syncs them -to the C module before every call. `ekp-looseness` is handled by a -dedicated Elisp path (the C module is bypassed automatically while it -is non-zero). +Both engines implement these parameters: before an actual C computation, +the Elisp side synchronizes their current values. The DP cache signature +includes every parameter in the table, so changes take effect on the next +call without manually clearing caches. +`ekp-looseness` is part of the cache key and uses a dedicated Elisp path +(the C module is bypassed automatically while it is non-zero). ### Caching Tokenization, measurement, and DP results are cached per paragraph; box widths are additionally cached session-wide, so a glyph shared across paragraphs is measured only once. +Both explicit spacing values and the automatic +`ekp-default-cws-stretch-pixel` input participate in paragraph cache +identity. The same complete structural key also governs the same-string +fast path, so adding or removing layout properties such as `ekp-no-break` +takes effect immediately on an already cached string object. - `ekp-para-cache-limit` (default 256): max cached paragraphs; the cache is flushed when the limit is reached. @@ -267,8 +297,9 @@ the point.) reserves the truncation-indicator column in windows without fringes, so justified lines fit the real display. If lines ever look truncated or short in an exotic setup, run `M-x ekp-diagnose` - in that buffer — it reports whether measurement matches rendering - (and `M-x ekp-gui-verify` runs a full fit check). + in that buffer — it reports whether measurement matches rendering. + The full fit matrix is a developer tool in `tests/ekp-gui-verify.el`; + load that file before invoking `M-x ekp-gui-verify`. - One font is assumed per Latin/CJK script per paragraph when computing spacing defaults; mixed-font paragraphs work but spacing defaults come from the first font found. @@ -295,11 +326,20 @@ block, an inline no-break atom and NBSP-joined numbers. ## Testing ```bash -tests/run-tests.sh /path/to/emacs # 67 ERT tests, all batch-safe +tests/run-tests.sh /path/to/emacs # batch-safe ERT suite + +# Full interactive GUI fit matrix +emacs -Q -L /path/to/emacs-kp -L /path/to/emacs-kp/tests \ + -l /path/to/emacs-kp/tests/ekp-gui-verify.el \ + -f ekp-gui-verify-matrix ``` +The matrix prints every row and exits with status 1 if any fit check fails, +so the same command can gate local release automation. The verifier is a +developer tool under `tests/`; it is not loaded by `(require 'ekp-region)`. + ## Credits - **Core algorithm**: ["Breaking Paragraphs into Lines"](https://gwern.net/doc/design/typography/tex/1981-knuth.pdf) by Donald E. Knuth and Michael F. Plass (1981) - **Hyphenation**: Frank Liang's algorithm, adapted from [Pyphen](https://github.com/Kozea/Pyphen) -- **Dictionaries**: hyphenation patterns from the [LibreOffice dictionaries](https://github.com/LibreOffice/dictionaries) (GPL/LGPL/MPL; see each `dictionaries/README_hyph_*.txt`) +- **Dictionaries**: hyphenation patterns from the [LibreOffice dictionaries](https://github.com/LibreOffice/dictionaries); see `dictionaries/MANIFEST.tsv`, `dictionaries/LICENSES.md`, and the bundled per-dictionary notices for exact source, checksum, and license evidence. diff --git a/readme_zh.md b/readme_zh.md index bf8c2ea..d3d9d6d 100644 --- a/readme_zh.md +++ b/readme_zh.md @@ -1,6 +1,6 @@ # Emacs-KP: Knuth-Plass 排版算法 Emacs 实现 -[English Documentation](./readme.md) | [开发者指南](./DEVELOPER_ZH.md) +[English Documentation](./readme.md) | [开发者指南](./DEVELOPER_ZH.md) | [仓库审计](./Docs/REPOSITORY_AUDIT_20260728.md) Emacs-kp 在 Emacs 内部完整实现了 Knuth-Plass 最优断行算法,支持中日韩 (CJK)与拉丁文混合排版。 @@ -10,10 +10,10 @@ Emacs-kp 在 Emacs 内部完整实现了 Knuth-Plass 最优断行算法,支持 - **全局最优断行** — Knuth-Plass 动态规划求段落全局最优断点,而非贪心 首次适应。 - **CJK 支持** — 每个 CJK 字符都是可断行的盒子;避头尾规则保证标点正确 - 附着(`,。` 不出现在行首,`「《` 不出现在行尾);汉字间距、中西文间距 + 附着(`,。` 不出现在行首,`「《` 不出现在行尾);汉字间距、中西文间距 独立可调。 -- **连字符断词** — Frank Liang 算法(TeX 同款),内置 70+ 种语言的 - Hunspell 词典。 +- **连字符断词** — Frank Liang 算法(TeX 同款),内置 49 份带固定来源 + 与 SHA-256 的 Hunspell pattern 词典。 - **像素级两端对齐** — 每一行渲染宽度精确等于目标像素宽度(通过 `display (space :width ...)` 属性实现),支持变宽字体。 - **文本属性保留** — face、颜色等属性完整保留;断词插入的连字符继承所 @@ -57,16 +57,18 @@ Emacs-kp 在 Emacs 内部完整实现了 Knuth-Plass 最优断行算法,支持 ### C 模块(长文本推荐) ```bash -cd ekp_c && make # 需要 C11 编译器,产出 ekp.dylib/.so/.dll +cd ekp_c && make PROFILE=portable # 默认;产出 ekp.dylib/.so/.dll ``` ```elisp -(ekp-c-module-load) ; 显示 "ekp-c module loaded (version 1.4, N threads)" +(ekp-c-module-load) ; 显示 "ekp-c module loaded (version 1.6, N threads)" +(ekp-c-module-build) ; 选择 portable/native/debug/sanitize ``` 加载后(`ekp-use-c-module` 默认为 `t`)所有排版调用自动走 C 引擎。 -Elisp 与 C 两个引擎的输出**完全一致**;Elisp 是永远可用的后备。若磁盘 -上的模块版本旧于 Elisp 代码的要求,加载会拒绝并提示重新编译。 +Elisp 与 C 两个引擎的输出**完全一致**;未启用模块或 C 返回 nil 时走 +Elisp。已启用模块若 signal,则作为后端契约错误直接呈现。若磁盘上的 +模块版本旧于 Elisp 代码的要求,加载会拒绝并提示重新编译。 ## 交互使用(buffer 与 region) @@ -87,14 +89,20 @@ Elisp 与 C 两个引擎的输出**完全一致**;Elisp 是永远可用的后备 窗口宽度变化时自动重排(防抖延迟 `ekp-auto-justify-resize-delay`); 编辑后只重排被改动的段落(空闲延迟 `ekp-auto-justify-edit-delay`), 未变段落直接命中段落缓存。关闭 mode 时 buffer 精确恢复原状。 + mode 激活时,标准 **EKP** 菜单提供排版、保护与窗口适配诊断命令; + `C-h m` 也会说明同一套流程。 buffer 被当作活的文档,而不只是画布: - **保存**时写入的是**逻辑文本**——软换行、glue 空格、断词连字符属于 - 排版而非内容,不会落盘;屏幕上的 buffer 仍保持排版态。 + 排版而非内容,不会落盘;即使写盘失败或中断,屏幕 buffer 也保持排版态。 + 该保证适用于整 buffer 保存;显式只写局部的 `write-region` 会写入所选 + 区域当前的物理 buffer 表示。 - **搜索**(isearch)看到的是逻辑文本,中文短语与被断词的英文单词 都能跨排版找到。 -- **复制**放进 kill ring 的是逻辑文本,粘贴出去的是文字而非像素间距。 +- **复制**放进 kill ring 的是逻辑文本,粘贴出去的是文字而非像素间距; + 已有 mode/user substring filter 会继续生效,最后一个排版区间移除后 + 精确恢复。 - 仅仅开启 mode 不会把 buffer 标记为已修改(不产生锁文件或 auto-save), 重排定时器也不再与 `undo` 打架。 @@ -125,6 +133,12 @@ buffer 被当作活的文档,而不只是画布: 成为刚性原子——不断行、不断词、空格保持字面宽度——适合行内代码、 产品名、数字加单位。 +手动属性明确只在**当前 buffer 会话**有效:普通文本保存与重新打开不会 +恢复它们。使用 `M-x ekp-allow-break-region` / +`ekp-clear-verbatim-region` 清除。需要从持久文档语法派生保护时,使用 +mode face 或 buffer-local 的 `ekp-region-skip-predicate`(Org/Markdown +预设会自动这样做)。 + ## 排版特性 - **对齐模式** — `ekp-alignment`:`justify`(默认)/`ragged-right`/ @@ -157,11 +171,18 @@ buffer 被当作活的文档,而不只是画布: (setq ekp-latin-lang "de_DE") ; 默认 "en_US" ``` -`dictionaries/hyph_.dic` 中的任意语言均可;`"de"` 这类短代码会解 -析到第一个匹配的词典。每个词典自身的 `LEFTHYPHENMIN` / +`"de"` 这类短代码会解析到第一个匹配的词典。受支持词典自身的 +`LEFTHYPHENMIN` / `RIGHTHYPHENMIN` 都会被遵守(英文在断点前保留 ≥2 字母、之后 ≥3); 给 `ekp-hyphen-create` 传显式边距可覆盖。 +EKP 支持普通 Liang pattern。`eo`、`ca`、`hu_HU` 与 `sq_AL` 含有 +斜杠/替换规则,会在选中断点时改写字形;把它们当成普通位置既会产生 +错误拼写,也会让 DP 使用错误宽度。因此这些语言会明确 signal +`ekp-hyphen-unsupported-pattern`,而不是静默降级。完整文件清单、 +SHA-256、固定来源路径和许可证据见 `dictionaries/MANIFEST.tsv` 与 +`dictionaries/LICENSES.md`。 + ### 间距参数 三类 glue 控制间距(单位均为像素): @@ -196,13 +217,18 @@ buffer 被当作活的文档,而不只是画布: | `ekp-last-line-short-penalty` | 50 | 末行过短的代价系数 | | `ekp-looseness` | 0 | 目标行数偏移:+1 比最优多一行,−1 少一行 | -所有参数对两个引擎都生效:每次调用 C 之前 Elisp 会同步这些参数。 -`ekp-looseness` 由专门的 Elisp 路径处理(非零时自动绕过 C 模块)。 +两个引擎都实现了这些参数:实际进入 C 计算前,Elisp 会同步当前值。 +DP 缓存签名包含表中的全部参数,因此修改后下一次调用即生效,无需 +手动清缓存。`ekp-looseness` 由专门的 Elisp 路径处理(非零时自动 +绕过 C 模块)。 ### 缓存 分词、测宽和 DP 结果按段落缓存;盒宽还额外做会话级缓存,跨段落共享 的字形整个会话只测量一次。 +显式间距值和自动模式的 `ekp-default-cws-stretch-pixel` 都属于段落 +缓存签名。同字符串快路径也使用同一个完整结构 key,因此在已缓存的 +字符串对象上增删 `ekp-no-break` 等排版属性会立即生效。 - `ekp-para-cache-limit`(默认 256):缓存段落数上限,超过后整体清空。 - `M-x ekp-clear-caches` 清空所有缓存(更换字体或影响字宽的主题后使用)。 @@ -227,8 +253,9 @@ Silicon 测得;方法见 DEVELOPER_ZH.md: - 测量会跟随当前 buffer 的 face 重映射(`text-scale-mode`、主题等), 并在无 fringe 的窗口里为截断指示符预留一列,排版行贴合真实显示。 若在特殊配置下仍出现截断或偏短,在该 buffer 里执行 - `M-x ekp-diagnose`——它会报告测量与渲染是否一致 - (`M-x ekp-gui-verify` 可跑完整贴合检查)。 + `M-x ekp-diagnose`——它会报告测量与渲染是否一致。完整贴合矩阵是 + `tests/ekp-gui-verify.el` 中的开发工具;执行 + `M-x ekp-gui-verify` 前须先加载该文件。 - 计算默认间距时假定每段落的拉丁/CJK 各使用一种字体;混合字体段落可以 工作,但默认间距取自找到的第一个字体。 - `ekp-pixel-range-justify` 用三分搜索加局部扫描最小化平均 demerits; @@ -250,11 +277,20 @@ emacs -Q -L /path/to/emacs-kp -l tests/ekp-showcase.el -f ekp-showcase ## 测试 ```bash -tests/run-tests.sh /path/to/emacs # 67 个 ERT 测试,全部支持 batch +tests/run-tests.sh /path/to/emacs # 全部支持 batch 的 ERT 测试集 + +# 完整的交互式 GUI 贴合矩阵 +emacs -Q -L /path/to/emacs-kp -L /path/to/emacs-kp/tests \ + -l /path/to/emacs-kp/tests/ekp-gui-verify.el \ + -f ekp-gui-verify-matrix ``` +矩阵会打印全部行;任一贴合检查失败时以状态码 1 退出,因此同一命令可 +作为本地发布门禁。验证器是 `tests/` 下的开发工具, +`(require 'ekp-region)` 不会加载它。 + ## 致谢 - **核心算法**: ["Breaking Paragraphs into Lines"](https://gwern.net/doc/design/typography/tex/1981-knuth.pdf) by Donald E. Knuth and Michael F. Plass (1981) - **断词算法**: Frank Liang 算法,改编自 [Pyphen](https://github.com/Kozea/Pyphen) -- **词典**: 断词模式来自 [LibreOffice dictionaries](https://github.com/LibreOffice/dictionaries)(GPL/LGPL/MPL;各语言许可见 `dictionaries/README_hyph_*.txt`) +- **词典**: 断词模式来自 [LibreOffice dictionaries](https://github.com/LibreOffice/dictionaries);准确来源、校验值和许可证据见 `dictionaries/MANIFEST.tsv`、`dictionaries/LICENSES.md` 与随附的各词典说明。 diff --git a/tests/check-dictionaries.sh b/tests/check-dictionaries.sh new file mode 100755 index 0000000..31c0d23 --- /dev/null +++ b/tests/check-dictionaries.sh @@ -0,0 +1,88 @@ +#!/bin/sh +# Verify the checked-in dictionary inventory against its offline manifest. + +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +DIR="$ROOT/dictionaries" +MANIFEST="$DIR/MANIFEST.tsv" +EXPECTED=49 + +fail() +{ + printf 'dictionary-check: %s\n' "$*" >&2 + exit 1 +} + +sha256() +{ + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +test -f "$MANIFEST" || fail "missing dictionaries/MANIFEST.tsv" + +check_tmp=$(mktemp -d "${TMPDIR:-/tmp}/ekp-dictionary-check.XXXXXX") +cleanup_check_tmp() +{ + find "$check_tmp" -depth -delete +} +trap cleanup_check_tmp EXIT HUP INT TERM + +actual_list="$check_tmp/actual" +manifest_list="$check_tmp/manifest" +find "$DIR" -maxdepth 1 -type f -name 'hyph_*.dic' -exec basename {} \; | + LC_ALL=C sort > "$actual_list" +awk -F ' ' '!/^#/ && NF {print $1}' "$MANIFEST" | + LC_ALL=C sort > "$manifest_list" + +actual_count=$(wc -l < "$actual_list" | tr -d ' ') +manifest_count=$(wc -l < "$manifest_list" | tr -d ' ') +test "$actual_count" -eq "$EXPECTED" || + fail "expected $EXPECTED dictionaries, found $actual_count" +test "$manifest_count" -eq "$EXPECTED" || + fail "expected $EXPECTED manifest entries, found $manifest_count" +test "$(uniq -d "$manifest_list" | wc -l | tr -d ' ')" -eq 0 || + fail "manifest contains duplicate filenames" +cmp -s "$actual_list" "$manifest_list" || + fail "manifest inventory differs from checked-in dictionaries" + +tab=$(printf '\t') +while IFS="$tab" read -r file source expected_hash license alternatives; do + case "$file" in + ""|\#*) continue ;; + esac + test -n "$source" || fail "$file has no source path" + test -n "$license" || fail "$file has no license evidence" + test "$(sha256 "$DIR/$file")" = "$expected_hash" || + fail "$file checksum differs from manifest" + actual_alternatives=$( + LC_ALL=C awk ' + { + line = $0 + sub(/^[[:space:]]*/, "", line) + if (line !~ /^[%#]/ && index(line, "/")) count++ + } + END { print count + 0 } + ' "$DIR/$file" + ) + test "$actual_alternatives" -eq "$alternatives" || + fail "$file alternative count is $actual_alternatives, expected $alternatives" + case "$license" in + embedded) + LC_ALL=C grep -Eiq \ + 'licen[cs]e|copyright|public domain|permission|redistribut' \ + "$DIR/$file" || + fail "$file claims embedded license evidence but has no marker" + ;; + *) + test -f "$DIR/$license" || + fail "$file license evidence does not exist: $license" + ;; + esac +done < "$MANIFEST" + +printf 'dictionary-check: %d entries pass\n' "$EXPECTED" diff --git a/tests/check-release.sh b/tests/check-release.sh new file mode 100755 index 0000000..3b0d879 --- /dev/null +++ b/tests/check-release.sh @@ -0,0 +1,65 @@ +#!/bin/sh +# Check repository-local invariants required before an emacs-kp release. + +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +CI="$ROOT/.github/workflows/ci.yml" +failed=0 + +fail() +{ + printf 'release-check: %s\n' "$*" >&2 + failed=1 +} + +action_refs=$( + sed -n 's/^[[:space:]]*-[[:space:]]*uses:[[:space:]]*\([^[:space:]#]*\).*$/\1/p' "$CI" +) +test -n "$action_refs" || fail "CI has no external action references" +for ref in $action_refs; do + case "$ref" in + *@????????????????????????????????????????) ;; + *) fail "action is not pinned to a full commit SHA: $ref" ;; + esac +done + +grep -q 'runs-on: windows-latest' "$CI" || + fail "CI does not exercise the supported Windows platform" +grep -q 'ert-run-tests-batch-and-exit' "$CI" || + fail "CI does not drive the public ERT batch entry point" + +if git -C "$ROOT" check-ignore -q .phrase/docs/CHANGE.md; then + fail ".phrase decision records are ignored" +fi + +package_version=$( + sed -n 's/^;; Version: \([0-9][0-9.]*\)$/\1/p' "$ROOT/ekp.el" +) +test -n "$package_version" || fail "ekp.el has no valid package version" +grep -Eq "^## \\[$package_version\\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$" \ + "$ROOT/CHANGELOG.md" || + fail "CHANGELOG has no dated entry for package version $package_version" + +required_c_version=$( + sed -n 's/^(defconst ekp-c-module-required-version \"\([0-9][0-9.]*\)\"$/\1/p' \ + "$ROOT/ekp-utils.el" +) +c_major=$( + sed -n 's/^#define EKP_VERSION_MAJOR \([0-9][0-9]*\)$/\1/p' \ + "$ROOT/ekp_c/ekp_module.h" +) +c_minor=$( + sed -n 's/^#define EKP_VERSION_MINOR \([0-9][0-9]*\)$/\1/p' \ + "$ROOT/ekp_c/ekp_module.h" +) +test -n "$required_c_version" || fail "Elisp has no required C version" +test -n "$c_major" && test -n "$c_minor" || + fail "C module header has no major/minor version" +if test -n "$required_c_version" && test -n "$c_major" && test -n "$c_minor"; then + test "$required_c_version" = "$c_major.$c_minor" || + fail "Elisp requires C $required_c_version but header declares $c_major.$c_minor" +fi + +test "$failed" -eq 0 || exit 1 +printf 'release-check: repository invariants pass\n' diff --git a/tests/ekp-bench.el b/tests/ekp-bench.el index 1cc3cc0..817a309 100644 --- a/tests/ekp-bench.el +++ b/tests/ekp-bench.el @@ -19,6 +19,7 @@ ;;; Code: (require 'ekp) +(require 'benchmark) (defun ekp-bench--read (name) (with-temp-buffer @@ -38,6 +39,21 @@ (message "%-42s %8.1f ms (min of %d)" label (* 1000 (apply #'min times)) n))) +(defun ekp-bench-adversarial-builders (length) + "Measure tokenizer and dense insertion builders at LENGTH." + (let* ((word (make-string length ?a)) + (positions (number-sequence 2 (- length 2) 8)) + (split-time + (car (benchmark-run 3 (ekp-split-to-boxes word)))) + (insert-time + (cl-letf (((symbol-function 'ekp-hyphen-positions) + (lambda (_h _word) positions))) + (car (benchmark-run 3 + (ekp-hyphen-inserted nil word)))))) + (message "builders n=%-5d split=%8.1f ms inserted=%8.1f ms breaks=%d" + length (* split-time 1000) (* insert-time 1000) + (length positions)))) + (let* ((zh (ekp-bench--read "text-zh.txt")) (en (ekp-bench--read "text-en_US.txt")) (mix (ekp-bench--read "text-zh-en_US.txt")) @@ -73,6 +89,10 @@ (message "%-42s %8.1f ms (min of 3)" "DP-only zh w=400 (paras cached)" (* 1000 (apply #'min times))))) +(message "== adversarial builder scaling ==") +(dolist (length '(1000 2000 4000 8000)) + (ekp-bench-adversarial-builders length)) + (message "bench done") (provide 'ekp-bench) diff --git a/tests/ekp-c-tests.el b/tests/ekp-c-tests.el new file mode 100644 index 0000000..996396c --- /dev/null +++ b/tests/ekp-c-tests.el @@ -0,0 +1,105 @@ +;;; ekp-c-tests.el --- ERT tests for the EKP C boundary -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Direct dynamic-module contract tests. They skip when no compatible module +;; is available and otherwise exercise malformed data before the DP core. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'ekp) + +(defun ekp-c-tests--available () + "Return non-nil when a compatible C module can be loaded." + (or ekp-c-module-loaded + (progn + (condition-case nil + (ekp-c-module-load) + (error nil)) + ekp-c-module-loaded))) + +(defun ekp-c-tests--valid-args () + "Return one valid 15-field direct C API argument list." + (list [0 10] [0 10] [0 10] + [0] [0] [0] [] 0 10 + [0 0] [0 0] [] [0 0] 0 10)) + +(ert-deftest ekp-c-test-rejects-non-vector-schema-field () + "Schema errors signal the module's explicit input condition." + (skip-unless (ekp-c-tests--available)) + (let ((args (ekp-c-tests--valid-args))) + (setcar args 'not-a-vector) + (should-error (apply #'ekp-c-break-with-arrays args) + :type 'ekp-c-invalid-input))) + +(ert-deftest ekp-c-test-rejects-mismatched-vector-length () + "All n and n+1 vector lengths are checked before extraction." + (skip-unless (ekp-c-tests--available)) + (let ((args (ekp-c-tests--valid-args))) + (setf (nth 1 args) [0]) + (should-error (apply #'ekp-c-break-with-arrays args) + :type 'ekp-c-invalid-input))) + +(ert-deftest ekp-c-test-rejects-out-of-range-integer () + "Pixel integers outside signed 32-bit input range are rejected." + (skip-unless (ekp-c-tests--available)) + (let ((args (ekp-c-tests--valid-args))) + (setf (nth 8 args) 2147483648) + (should-error (apply #'ekp-c-break-with-arrays args) + :type 'ekp-c-invalid-input))) + +(ert-deftest ekp-c-test-batch-rejects-short-paragraph-vector () + "Batch preflight validates each paragraph before indexing 15 fields." + (skip-unless (ekp-c-tests--available)) + (let ((paragraph (vconcat (butlast (ekp-c-tests--valid-args))))) + (should-error (ekp-c-break-batch (vector paragraph)) + :type 'ekp-c-invalid-input))) + +(ert-deftest ekp-c-test-wide-intermediates-avoid-int32-overflow () + "Valid int32 inputs use wider intermediates in line arithmetic." + (skip-unless (ekp-c-tests--available)) + (let* ((limit 2147483647) + (args (list (vector 0 limit) (vector 0 limit) (vector 0 limit) + [0] [0] [0] [] 0 limit + [0 0] [0 0] [] (vector 0 limit) 0 limit)) + (result (apply #'ekp-c-break-with-arrays args))) + (should (equal (car result) '(1))) + (should (= (cdr result) 100200100.0)))) + +(ert-deftest ekp-c-test-penalties-use-explicit-input-condition () + "Penalty setters reject bad scalar types before extracting values." + (skip-unless (ekp-c-tests--available)) + (should-error (ekp-c-set-penalties "10" 50 100 0.5) + :type 'ekp-c-invalid-input)) + +(ert-deftest ekp-c-test-module-build-uses-argv-in-directory () + "Interactive builds must not interpolate a module path into a shell." + (let (process-arguments process-directory) + (cl-letf (((symbol-function 'ekp-c-module-dir) + (lambda () "/tmp/EKP build with spaces")) + ((symbol-function 'file-exists-p) (lambda (_) t)) + ((symbol-function 'executable-find) + (lambda (_) "/usr/bin/make")) + ((symbol-function 'make-process) + (lambda (&rest arguments) + (setq process-arguments arguments + process-directory default-directory) + 'ekp-test-process)) + ((symbol-function 'start-process) + (lambda (&rest _) 'legacy-process)) + ((symbol-function 'set-process-sentinel) #'ignore)) + (should (eq (ekp-c-module-build 'portable) 'ekp-test-process)) + (should (equal (plist-get process-arguments :command) + '("/usr/bin/make" "PROFILE=portable"))) + (should (equal process-directory + "/tmp/EKP build with spaces/"))))) + +(ert-deftest ekp-c-test-module-build-rejects-unknown-profile () + "Unknown build profiles fail before starting a process." + (should-error (ekp-c-module-build 'fast-maybe) + :type 'user-error)) + +(provide 'ekp-c-tests) +;;; ekp-c-tests.el ends here diff --git a/tests/ekp-gui-tests.el b/tests/ekp-gui-tests.el new file mode 100644 index 0000000..2d04730 --- /dev/null +++ b/tests/ekp-gui-tests.el @@ -0,0 +1,45 @@ +;;; ekp-gui-tests.el --- ERT tests for EKP GUI verification -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Batch-safe tests for the GUI verifier's reporting boundary. Real pixel +;; measurements remain in the interactive matrix. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'ekp-gui-verify) + +(ert-deftest ekp-gui-test-noninteractive-failure-exits-one () + "A failed matrix result must terminate batch automation with status 1." + (let ((noninteractive t) + exit-status + output) + (cl-letf (((symbol-function 'princ) + (lambda (text &optional _stream) + (setq output (concat output text)))) + ((symbol-function 'kill-emacs) + (lambda (&optional status) + (setq exit-status status)))) + (ekp-gui-verify--report + '((:name "forced failure" :body 100 :target 100 :widest 101 + :over 1 :lines 3 :pass nil))) + (should (= exit-status 1)) + (should (string-match-p "forced failure.*FAIL" output))))) + +(ert-deftest ekp-gui-test-noninteractive-success-stays-zero () + "A passing matrix result must not request batch termination." + (let ((noninteractive t) + exit-status) + (cl-letf (((symbol-function 'princ) #'ignore) + ((symbol-function 'kill-emacs) + (lambda (&optional status) + (setq exit-status status)))) + (ekp-gui-verify--report + '((:name "passing" :body 100 :target 100 :widest 100 + :over 0 :lines 3 :pass t))) + (should-not exit-status)))) + +(provide 'ekp-gui-tests) +;;; ekp-gui-tests.el ends here diff --git a/tests/ekp-gui-verify.el b/tests/ekp-gui-verify.el index cf76536..ea737fc 100644 --- a/tests/ekp-gui-verify.el +++ b/tests/ekp-gui-verify.el @@ -109,7 +109,7 @@ themes, remappings and text-scale. Reports PASS or FAIL." r))) (defun ekp-gui-verify--case (name setup) - "Run one matrix case NAME with buffer SETUP; return a report line." + "Run one matrix case NAME with buffer SETUP; return its result plist." ;; Leftover debounce timers from the previous case must not fire ;; into this case's fresh buffer. (dolist (fn (list #'ekp-region--reflow @@ -129,13 +129,38 @@ themes, remappings and text-scale. Reports PASS or FAIL." (ekp-region--reflow (current-buffer) (ekp-region--effective-width)) (redisplay t) (let ((r (ekp-gui-verify--scan (current-buffer)))) - (prog1 (format "%-22s body=%4d target=%4d widest=%4d over=%d/%d %s" - name (plist-get r :body) (plist-get r :target) - (plist-get r :widest) (plist-get r :over) - (plist-get r :lines) - (if (plist-get r :pass) "PASS" "FAIL")) + (prog1 (append (list :name name) r) (ekp-auto-justify-mode -1))))) +(defun ekp-gui-verify--format-result (result) + "Format one matrix RESULT plist as a report line." + (format "%-22s body=%4d target=%4d widest=%4d over=%d/%d %s" + (plist-get result :name) + (plist-get result :body) + (plist-get result :target) + (plist-get result :widest) + (plist-get result :over) + (plist-get result :lines) + (if (plist-get result :pass) "PASS" "FAIL"))) + +(defun ekp-gui-verify--report (results) + "Report matrix RESULTS and return their formatted table. +In batch mode, terminate with status 1 when any result fails." + (let ((table (mapconcat #'ekp-gui-verify--format-result results "\n")) + (passed t)) + (dolist (result results) + (unless (plist-get result :pass) + (setq passed nil))) + (if noninteractive + (princ (concat table "\n")) + (with-current-buffer (get-buffer-create "*ekp-gui-verify*") + (erase-buffer) + (insert table "\n") + (display-buffer (current-buffer)))) + (when (and noninteractive (not passed)) + (kill-emacs 1)) + table)) + ;;;###autoload (defun ekp-gui-verify-matrix () "Run the display-context matrix and print a PASS/FAIL table. @@ -181,14 +206,7 @@ session prefer `ekp-gui-verify'." (push (ekp-gui-verify--case "narrow + scale +2" (lambda () (text-scale-set 2))) results) - (let ((table (string-join (nreverse results) "\n"))) - (if noninteractive - (princ (concat table "\n")) - (with-current-buffer (get-buffer-create "*ekp-gui-verify*") - (erase-buffer) - (insert table "\n") - (display-buffer (current-buffer)))) - table))) + (ekp-gui-verify--report (nreverse results)))) (provide 'ekp-gui-verify) diff --git a/tests/ekp-region-tests.el b/tests/ekp-region-tests.el index e21c304..49acf3f 100644 --- a/tests/ekp-region-tests.el +++ b/tests/ekp-region-tests.el @@ -339,6 +339,90 @@ keeps the buffer justified, and leaves it unmodified." (kill-buffer))) (delete-file file)))) +(ert-deftest ekp-region-test-failed-save-preserves-layout () + "A filesystem save failure must not leave the buffer unformatted." + (let* ((dir (make-temp-file "ekp-save-fail-" t)) + (file (expand-file-name "file.txt" dir)) + (text "保存失败以后屏幕仍然保持排版状态 and remains editable") + (make-backup-files nil) + (create-lockfiles nil)) + (unwind-protect + (with-current-buffer (find-file-noselect file) + (setq-local require-final-newline nil) + (insert text) + (ekp-justify-region (point-min) (point-max) 20) + (let ((layout (buffer-substring (point-min) (point-max)))) + (delete-directory dir t) + (cl-letf (((symbol-function 'y-or-n-p) + (lambda (&rest _) nil))) + (should-error (save-buffer))) + (should (equal-including-properties + (buffer-substring (point-min) (point-max)) layout)) + (should (get-text-property (point-min) 'ekp-justified)) + (should (buffer-modified-p))) + (let ((kill-buffer-query-functions nil)) + (set-buffer-modified-p nil) + (kill-buffer))) + (when (file-directory-p dir) + (delete-directory dir t))))) + +(ert-deftest ekp-region-test-interrupted-save-preserves-layout () + "A quit during writing must not leave the buffer unformatted." + (let* ((file (make-temp-file "ekp-save-quit-")) + (text "保存中断以后屏幕排版状态必须原样保留 with logical text") + (make-backup-files nil) + (create-lockfiles nil)) + (unwind-protect + (with-current-buffer (find-file-noselect file) + (setq-local require-final-newline nil) + (insert text) + (ekp-justify-region (point-min) (point-max) 20) + (let ((layout (buffer-substring (point-min) (point-max)))) + (cl-letf (((symbol-function 'write-region) + (lambda (&rest _) (signal 'quit nil)))) + (should (condition-case nil + (progn (save-buffer) nil) + (quit t)))) + (should (equal-including-properties + (buffer-substring (point-min) (point-max)) layout)) + (should (buffer-modified-p))) + (let ((kill-buffer-query-functions nil)) + (set-buffer-modified-p nil) + (kill-buffer))) + (delete-file file)))) + +(ert-deftest ekp-region-test-encoding-save-failure-is-retryable () + "An encoding failure must preserve layout and allow a clean retry." + (let* ((file (make-temp-file "ekp-save-encoding-")) + (text "编码失败以后仍然保持排版,重试写入 logical text") + (make-backup-files nil) + (create-lockfiles nil)) + (unwind-protect + (with-current-buffer (find-file-noselect file) + (setq-local require-final-newline nil) + (insert text) + (ekp-justify-region (point-min) (point-max) 20) + (let ((layout (buffer-substring (point-min) (point-max)))) + (set-buffer-file-coding-system 'us-ascii-unix) + (cl-letf (((symbol-function 'select-safe-coding-system) + (lambda (&rest _) (error "Forced encoding failure")))) + (should-error (save-buffer))) + (should (equal-including-properties + (buffer-substring (point-min) (point-max)) layout)) + (should (buffer-modified-p)) + (set-buffer-file-coding-system 'utf-8-unix) + (save-buffer) + (should (equal-including-properties + (buffer-substring (point-min) (point-max)) layout)) + (should-not (buffer-live-p ekp-region--write-buffer)) + (should (equal (with-temp-buffer + (insert-file-contents file) + (buffer-string)) + text))) + (let ((kill-buffer-query-functions nil)) + (kill-buffer))) + (delete-file file)))) + (ert-deftest ekp-region-test-justify-preserves-unmodified () "Pure re-layout must not flip `buffer-modified-p'." (let* ((file (make-temp-file "ekp-mod-test")) @@ -403,6 +487,77 @@ they must not travel with a kill/yank." (should (equal (filter-buffer-substring (point-min) (point-max)) text))))) +(ert-deftest ekp-region-test-copy-filter-composes-and-restores () + "EKP must preserve an existing buffer-local substring filter." + (let ((text "组合复制过滤器必须保留 logical text and prefix")) + (ekp-region-test--with-text text + (let ((prior (lambda (beg end &optional delete) + (let ((text (buffer-substring beg end))) + (when delete (delete-region beg end)) + (concat "PRE:" text))))) + (setq-local filter-buffer-substring-function prior) + (ekp-justify-region (point-min) (point-max) 20) + (should (equal (filter-buffer-substring (point-min) (point-max)) + (concat "PRE:" text))) + (ekp-unjustify-region (point-min) (point-max)) + (should (local-variable-p 'filter-buffer-substring-function)) + (should (eq filter-buffer-substring-function prior)))))) + +(ert-deftest ekp-region-test-kill-filter-composes-delete () + "Composed filtering must preserve DELETE and prior-filter semantics." + (let ((text "组合 kill 过滤器删除源文本但返回 logical text")) + (ekp-region-test--with-text text + (let ((prior (lambda (beg end &optional delete) + (let ((text (buffer-substring beg end))) + (when delete (delete-region beg end)) + (concat "PRE:" text))))) + (setq-local filter-buffer-substring-function prior) + (ekp-justify-region (point-min) (point-max) 20) + (should (equal (filter-buffer-substring + (point-min) (point-max) t) + (concat "PRE:" text))) + (should (= (point-min) (point-max))) + (should (eq filter-buffer-substring-function prior)))))) + +(ert-deftest ekp-region-test-restores-inherited-copy-filter () + "Final unjustify must reveal an inherited substring filter again." + (let ((prior (lambda (beg end &optional delete) + (prog1 (buffer-substring beg end) + (when delete (delete-region beg end)))))) + (let ((filter-buffer-substring-function prior)) + (ekp-region-test--with-text "继承 filter 恢复检查内容" + (ekp-justify-region (point-min) (point-max) 20) + (ekp-unjustify-region (point-min) (point-max)) + (should-not (local-variable-p 'filter-buffer-substring-function)) + (should (eq filter-buffer-substring-function prior)))))) + +(ert-deftest ekp-region-test-mode-disable-restores-copy-filter () + "Disabling auto mode must restore the previous local copy filter." + (let ((prior (lambda (beg end &optional delete) + (prog1 (buffer-substring beg end) + (when delete (delete-region beg end)))))) + (ekp-region-test--with-text "关闭 mode 恢复已有 copy filter" + (setq-local filter-buffer-substring-function prior) + (cl-letf (((symbol-function 'ekp-region--window-pixel) + (lambda (&optional _) 20))) + (ekp-auto-justify-mode 1) + (ekp-auto-justify-mode -1)) + (should (local-variable-p 'filter-buffer-substring-function)) + (should (eq filter-buffer-substring-function prior))))) + +(ert-deftest ekp-region-test-final-unjustify-removes-integrations () + "Removing the final layout span must remove unused integrations." + (ekp-region-test--with-text "最后一个排版区间移除后清理集成 hooks" + (ekp-justify-region (point-min) (point-max) 20) + (should (memq #'ekp-region--write-logical-buffer + write-region-annotate-functions)) + (should (memq #'ekp-region--isearch-begin isearch-mode-hook)) + (ekp-unjustify-region (point-min) (point-max)) + (should-not (local-variable-p 'filter-buffer-substring-function)) + (should-not (memq #'ekp-region--write-logical-buffer + write-region-annotate-functions)) + (should-not (memq #'ekp-region--isearch-begin isearch-mode-hook)))) + (ert-deftest ekp-region-test-isearch-sees-logical-text () "The isearch hooks expose the logical text, then restore the layout." (let ((text "跨行搜索的目标短语必须能找到 internationalization word")) @@ -443,6 +598,80 @@ they must not travel with a kill/yank." ;;;; Commands and mode integration +(ert-deftest ekp-region-test-no-break-public-commands () + "Interactive no-break commands affect the public formatter and report scope." + (ekp-region-test--with-text "prefix AA BB suffix words" + (let (messages) + (set-mark 8) + (goto-char 13) + (activate-mark) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (call-interactively #'ekp-no-break-region)) + (should (eq (get-text-property 8 'ekp-no-break) t)) + (ekp-justify-region (point-min) (point-max) 4) + (should (string-match-p "AA BB" (buffer-string))) + (ekp-unjustify-region (point-min) (point-max)) + (set-mark 8) + (goto-char 13) + (activate-mark) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (call-interactively #'ekp-allow-break-region)) + (should-not (get-text-property 8 'ekp-no-break)) + (should (= (length messages) 2)) + (should (cl-every + (lambda (text) + (string-match-p "current buffer session" text)) + messages))))) + +(ert-deftest ekp-region-test-verbatim-public-commands () + "Interactive verbatim commands protect the real region formatter." + (ekp-region-test--with-text + "literal block stays exactly here\nordinary prose wraps here" + (goto-char (point-min)) + (let ((first-end (line-end-position)) messages) + (set-mark (point-min)) + (goto-char first-end) + (activate-mark) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (call-interactively #'ekp-verbatim-region)) + (ekp-justify-region (point-min) (point-max) 8) + (should (equal (buffer-substring-no-properties + (point-min) (line-end-position)) + "literal block stays exactly here")) + (ekp-unjustify-region (point-min) (point-max)) + (set-mark (point-min)) + (goto-char first-end) + (activate-mark) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (call-interactively #'ekp-clear-verbatim-region)) + (ekp-justify-region (point-min) first-end 8) + (should (get-text-property (point-min) 'ekp-justified)) + (should (= (length messages) 2)) + (should (cl-every + (lambda (text) + (string-match-p "current buffer session" text)) + messages))))) + +(ert-deftest ekp-region-test-protection-workflows-discoverable () + "Mode help and menu expose the existing protection workflows." + (should (string-match-p + "current buffer session" + (documentation #'ekp-auto-justify-mode))) + (let ((menu (lookup-key ekp-auto-justify-mode-map [menu-bar ekp]))) + (should (keymapp menu)) + (should (where-is-internal + #'ekp-no-break-region ekp-auto-justify-mode-map)) + (should (where-is-internal + #'ekp-verbatim-region ekp-auto-justify-mode-map)))) + (ert-deftest ekp-region-test-justify-buffer-roundtrip () "ekp-justify-buffer / ekp-unjustify-buffer cover the whole buffer." (let ((text "第一段内容足够长断行\n\n第二段 also long enough to wrap")) diff --git a/tests/ekp-tests.el b/tests/ekp-tests.el index e20360c..351b7e1 100644 --- a/tests/ekp-tests.el +++ b/tests/ekp-tests.el @@ -25,28 +25,53 @@ ;;;; Fixtures -(defvar ekp-tests--defaults - (list 10 50 100 100 50 0.5 0) - "Default values of the tunable K-P variables (see fixture).") +(defconst ekp-tests--isolated-variables + '(ekp-latin-lang + ekp-use-c-module + ekp-lws-ideal-pixel + ekp-lws-stretch-pixel + ekp-lws-shrink-pixel + ekp-mws-ideal-pixel + ekp-mws-stretch-pixel + ekp-mws-shrink-pixel + ekp-cws-ideal-pixel + ekp-cws-stretch-pixel + ekp-cws-shrink-pixel + ekp-lws-max-pixel + ekp-lws-min-pixel + ekp-mws-max-pixel + ekp-mws-min-pixel + ekp-cws-max-pixel + ekp-cws-min-pixel + ekp-default-cws-stretch-pixel + ekp-line-penalty + ekp-hyphen-penalty + ekp-adjacent-fitness-penalty + ekp-consecutive-hyphen-penalty + ekp-last-line-short-penalty + ekp-last-line-min-ratio + ekp-alignment + ekp-ragged-stretch-pixel + ekp-protrusion + ekp-protrusion-ratios + ekp-parshape + ekp-first-line-indent + ekp-looseness + ekp-para-cache-limit + ekp-cjk-no-line-start-extra + ekp--params-explicit) + "Dynamically scoped EKP state restored by the clean-state fixture.") (defmacro ekp-tests--with-clean-state (&rest body) "Run BODY with fresh caches and restore all tunables afterwards." - `(unwind-protect - (progn - (ekp-clear-caches) - (ekp-param-reset) - ,@body) - (cl-destructuring-bind (lp hp afp chp llsp llmr loose) - ekp-tests--defaults - (setq ekp-line-penalty lp - ekp-hyphen-penalty hp - ekp-adjacent-fitness-penalty afp - ekp-consecutive-hyphen-penalty chp - ekp-last-line-short-penalty llsp - ekp-last-line-min-ratio llmr - ekp-looseness loose)) - (ekp-param-reset) - (ekp-clear-caches))) + `(cl-progv ekp-tests--isolated-variables + (mapcar #'symbol-value ekp-tests--isolated-variables) + (unwind-protect + (progn + (ekp-clear-caches) + (ekp-param-reset) + ,@body) + (ekp-clear-caches)))) (defun ekp-tests--line-widths (out) "Rendered pixel width of each line of OUT." @@ -65,6 +90,22 @@ Used to verify no content is lost by justification." (ignore-errors (ekp-c-module-load))) (and (boundp 'ekp-c-module-loaded) ekp-c-module-loaded)) +(defun ekp-tests--assert-dp-cache-parameter-isolated + (variable before after width text) + "Assert VARIABLE changing from BEFORE to AFTER invalidates cached DP data." + (ekp-tests--with-clean-state + (let* ((ekp-use-c-module nil) + (baseline (progn (set variable before) + (ekp-dp-cache text width))) + (cached (progn (set variable after) + (ekp-dp-cache text width))) + (fresh (progn (ekp-clear-caches) + (ekp-dp-cache text width)))) + (should-not (equal (cons variable baseline) + (cons variable fresh))) + (should (equal (cons variable cached) + (cons variable fresh)))))) + (defun ekp-tests--file (name) (expand-file-name name (expand-file-name "tests" (ekp-root-dir)))) @@ -106,6 +147,75 @@ Used to verify no content is lost by justification." (should (ekp-hyphen-create "en")) (should-error (ekp-hyphen-create "zz_XX"))) +(ert-deftest ekp-test-hyphen-alternative-languages-fail-closed () + "Replacement-pattern dictionaries must not degrade to plain Liang breaks. +The golden forms are libhyphen outputs; EKP cannot safely emit them until its +DP represents break-specific replacement widths." + (dolist (case '(("hu_HU" "asszony" "asz=szony") + ("ca" "paral·lel" "pa=ral=lel") + ("sq_AL" "adhem" "e"))) + (let ((lang (nth 0 case)) + (word (nth 1 case)) + (golden (nth 2 case))) + (condition-case err + (progn + (ekp-hyphen-create lang) + (ert-fail (format "%s incorrectly accepted; golden %s -> %s" + lang word golden))) + (ekp-hyphen-unsupported-pattern + (should (equal (cadr err) lang)) + (should (> (nth 3 err) 0)))))) + ;; Esperanto also contains slash-prefixed patterns whose libhyphen + ;; meaning is not representable as an ordinary Liang pattern. + (should-error (ekp-hyphen-create "eo") + :type 'ekp-hyphen-unsupported-pattern)) + +(ert-deftest ekp-test-hyphen-alternative-error-reaches-public-dispatch () + "The public formatter must surface unsupported replacement dictionaries." + (ekp-tests--with-clean-state + (let ((ekp-latin-lang "hu_HU")) + (should-error + (ekp-pixel-justify "asszony asszony asszony" 12) + :type 'ekp-hyphen-unsupported-pattern)))) + +(ert-deftest ekp-test-hyphen-nil-result-is-cached () + "A word with no break positions must compute only once." + (let ((h (ekp-hyphen--create + :patterns (make-hash-table :test 'equal) + :cache (make-hash-table :test 'equal) + :maxlen 0 :left 2 :right 2)) + (calls 0)) + (cl-letf (((symbol-function 'ekp-hyphen--compute) + (lambda (_h _word) + (cl-incf calls) + nil))) + (should-not (ekp-hyphen--positions h "qzxq")) + (should-not (ekp-hyphen--positions h "QZXQ")) + (should (= calls 1))))) + +(ert-deftest ekp-test-hyphen-inserted-dense-breaks () + "Dense insertion must slice the original word without changing properties." + (dolist (case '(("abcdefghij" (2 5 8) "--" "ab--cde--fgh--ij") + ("hyphenation" (2 6) "-" "hy-phen-ation") + ("abcdef" nil "*" "abcdef"))) + (pcase-let ((`(,word ,positions ,hyphen ,expected) case)) + (setq word (copy-sequence word)) + (put-text-property 1 (1- (length word)) 'face 'italic word) + (cl-letf (((symbol-function 'ekp-hyphen-positions) + (lambda (_h _word) positions))) + (let ((actual (ekp-hyphen-inserted nil word hyphen))) + (should (equal (substring-no-properties actual) expected)) + (should (eq (get-text-property 1 'face actual) 'italic))))))) + +(ert-deftest ekp-test-tokenizer-long-attached-run-preserves-properties () + "A long token and attached combining run must remain one exact box." + (let* ((base (propertize (make-string 4096 ?a) 'face 'bold)) + (marks (make-string 2048 #x0301)) + (word (concat base marks)) + (boxes (ekp-split-to-boxes word))) + (should (= (length boxes) 1)) + (should (equal-including-properties (aref boxes 0) word)))) + ;;;; Box splitting (ert-deftest ekp-test-split-latin-words () @@ -223,9 +333,33 @@ Used to verify no content is lost by justification." (should (= (string-pixel-width l) 30)))))) (ert-deftest ekp-test-parshape-bypasses-c () - "Per-line widths force the Elisp 2D path." - (let ((ekp-first-line-indent 6)) - (should-not (ekp--c-available-p)))) + "Per-line widths force the public dispatcher down the Elisp 2D path." + (ekp-tests--with-clean-state + (let ((ekp-use-c-module t) + (ekp-c-module-loaded t) + (ekp-parshape '((0 . 20) (6 . 24))) + c-called) + (cl-letf (((symbol-function 'ekp-c-break-with-arrays) + (lambda (&rest _) + (setq c-called t) + (error "C path must not run for parshape")))) + (should (stringp + (ekp-pixel-justify + "参差形状必须经过公开分派路径而不是只测内部谓词" 30))) + (should-not c-called))))) + +(ert-deftest ekp-test-clean-state-restores-config () + "The shared fixture must not leak configuration into later tests." + (let ((ekp-use-c-module t) + (ekp-alignment 'justify) + (ekp-protrusion nil)) + (ekp-tests--with-clean-state + (setq ekp-use-c-module nil + ekp-alignment 'center + ekp-protrusion t)) + (should ekp-use-c-module) + (should (eq ekp-alignment 'justify)) + (should-not ekp-protrusion))) (ert-deftest ekp-test-protrusion-hangs-line-end-punct () "Protrusion lets line-final fullwidth punctuation hang past the edge." @@ -407,7 +541,7 @@ instead of losing the paragraph (regression: used to return \"\")." (ekp-tests--with-clean-state (let* ((s "one two three four five six seven eight") (breaks (ekp-line-breaks s 30)) - (n (length (ekp--boxes s)))) + (n (length (ekp-para-boxes (ekp--get-para s))))) (should (equal breaks (sort (copy-sequence breaks) #'<))) (should (= (car (last breaks)) n))))) @@ -479,6 +613,28 @@ module is bypassed automatically (it has no looseness support)." (p2 (ekp--get-para (propertize "same string" 'face 'bold)))) (should-not (eq p1 p2))))) +(ert-deftest ekp-test-para-cache-detects-in-place-properties () + "Mutating properties on the same string object must miss every cache path." + (ekp-tests--with-clean-state + (dolist (text (list (copy-sequence "文中排版") + (copy-sequence "alpha beta gamma"))) + (let ((before (ekp--get-para text))) + (put-text-property 0 (length text) 'ekp-no-break t text) + (let ((changed (ekp--get-para text))) + (should-not (eq before changed)) + (should-not (equal (ekp-para-breaks-allowed before) + (ekp-para-breaks-allowed changed))) + (ekp-clear-caches) + (let ((fresh (ekp--get-para text))) + (should (equal (ekp-para-boxes changed) + (ekp-para-boxes fresh))) + (should (equal (mapcar #'object-intervals + (append (ekp-para-boxes changed) nil)) + (mapcar #'object-intervals + (append (ekp-para-boxes fresh) nil)))) + (should (equal (ekp-para-breaks-allowed changed) + (ekp-para-breaks-allowed fresh))))))))) + (ert-deftest ekp-test-para-cache-limit () (ekp-tests--with-clean-state (let ((ekp-para-cache-limit 2)) @@ -496,14 +652,16 @@ module is bypassed automatically (it has no looseness support)." (unwind-protect (progn (setq ekp-latin-lang "en_US") - (let ((b-en (copy-sequence (ekp--boxes s)))) + (let ((b-en (copy-sequence + (ekp-para-boxes (ekp--get-para s))))) (setq ekp-latin-lang "de_DE") ;; Same string object, no cache clear: must re-hyphenate. - (let ((b-de (ekp--boxes s))) + (let ((b-de (ekp-para-boxes (ekp--get-para s)))) (should-not (equal b-en b-de)) ;; And it must equal a fresh computation. (ekp-clear-caches) - (should (equal b-de (ekp--boxes s)))))) + (should + (equal b-de (ekp-para-boxes (ekp--get-para s))))))) (setq ekp-latin-lang old))))) (ert-deftest ekp-test-dp-cache-reuse () @@ -556,6 +714,42 @@ module is bypassed automatically (it has no looseness support)." 0)))) (should (= (ekp--line-ideal-pixel para i k) expected)))))))) +(ert-deftest ekp-test-line-edge-space-rule-brute-force () + "The shared edge-space rule must equal independent box scanning." + (ekp-tests--with-clean-state + (ekp-param-set 5 2 1 4 2 1 0 3 0) + (let* ((para (ekp--get-para " 中文 Latin mixed tail ")) + (widths (ekp-para-boxes-widths para)) + (types (ekp-para-boxes-types para)) + (lead-spaces (ekp-para-lead-spaces para)) + (trail-spaces (ekp-para-trail-spaces para)) + (n (length widths))) + (dotimes (i n) + (cl-loop for k from (1+ i) to n do + (let ((lead 0) (trail 0) (j i)) + (when (> i 0) + (while (and (< j k) (eq (car (aref types j)) 'space)) + (cl-incf lead (aref widths j)) + (cl-incf j))) + (setq j (1- k)) + (while (and (>= j i) (eq (car (aref types j)) 'space)) + (cl-incf trail (aref widths j)) + (cl-decf j)) + (let ((raw (cl-loop for x from i below k + sum (aref widths x)))) + (should + (= (ekp--line-stripped-space-pixel + raw i k lead-spaces trail-spaces) + (min raw (+ lead trail))))))))))) + +(ert-deftest ekp-test-layout-marker-protocol-owned () + "The render/inversion marker vocabulary must be complete and nonsticky." + (should (equal ekp--layout-marker-properties + '(ekp-glue ekp-soft-break ekp-soft-hyphen ekp-hidden + ekp-justified))) + (dolist (property ekp--layout-marker-properties) + (should (eq (alist-get property text-property-default-nonsticky) t)))) + (ert-deftest ekp-test-gaps-between-brute-force () "`ekp--gaps-between' must equal naive counting." (ekp-tests--with-clean-state @@ -647,6 +841,19 @@ module is bypassed automatically (it has no looseness support)." (let ((ekp-use-c-module nil)) (should (stringp (ekp-pixel-justify "plain elisp path works" 60)))))) +(ert-deftest ekp-test-c-module-errors-surface () + "An enabled C backend error must not be silently converted to Elisp." + (ekp-tests--with-clean-state + (let ((ekp-use-c-module t) + (ekp-c-module-loaded t)) + (cl-letf (((symbol-function 'ekp-c-break-with-arrays) + (lambda (&rest _) + (error "forced C backend failure")))) + (should-error (ekp-pixel-justify + "backend errors are observable at the public boundary" + 60) + :type 'error))))) + ;;;; Cache correctness (M3 wave) (ert-deftest ekp-test-dp-cache-looseness-isolation () @@ -665,6 +872,37 @@ returned the stale looseness-0 layout for the same (string, width)." ;; and the two targets genuinely differ on this input (should-not (equal r0 r1))))) +(ert-deftest ekp-test-dp-cache-algorithm-parameter-isolation () + "Every algorithm parameter must participate in the DP cache key." + (let* ((text-a (concat + "hyphenation representation configuration extraordinary " + "internationalization approximation characterization")) + (text-b (concat + "The quick brown fox jumps over the lazy dog and keeps " + "running through the emergency broadcast system test of " + "hyphenation quality")) + (cases `((ekp-line-penalty 0 1000 8 ,text-a) + (ekp-hyphen-penalty 0 1000000 8 ,text-a) + (ekp-adjacent-fitness-penalty 0 1000000 8 ,text-b) + (ekp-consecutive-hyphen-penalty 0 1000000 8 ,text-b) + (ekp-last-line-short-penalty 0 1000000 15 ,text-b) + (ekp-last-line-min-ratio 0.1 0.99 8 ,text-a)))) + (dolist (case cases) + (apply #'ekp-tests--assert-dp-cache-parameter-isolated case)))) + +(ert-deftest ekp-test-dp-cache-identical-signature-hits () + "Structurally equal DP signatures must reuse the same cached result." + (ekp-tests--with-clean-state + (let* ((ekp-use-c-module nil) + (ekp-looseness 1) + (text "aaa bbb ccc ddd eee fff ggg hhh iii jjj") + (para (ekp--get-para text)) + (first (ekp-dp-cache text 12)) + (count (hash-table-count (ekp-para-dp-cache para))) + (second (ekp-dp-cache text 12))) + (should (eq first second)) + (should (= count (hash-table-count (ekp-para-dp-cache para))))))) + (ert-deftest ekp-test-para-key-ignores-fontified () "Fontification bookkeeping must not split the paragraph cache." (let* ((plain "fontified 键检查内容") @@ -675,6 +913,43 @@ returned the stale looseness-0 layout for the same (string, width)." (should (equal (ekp--para-key faced) (ekp--para-key faced+marked))) (should-not (equal (ekp--para-key plain) (ekp--para-key faced))))) +(ert-deftest ekp-test-auto-spacing-signature-keys-para-cache () + "Auto spacing inputs must participate in paragraph cache identity." + (ekp-tests--with-clean-state + (let* ((text "自动字距段落缓存签名") + (first (let ((ekp-default-cws-stretch-pixel 2)) + (ekp--get-para text)))) + (setq ekp--last-para nil) + (let ((changed (let ((ekp-default-cws-stretch-pixel 9)) + (ekp--get-para text)))) + (should (= 2 (plist-get (ekp-para-glue-params first) :cws-stretch))) + (should (= 9 (plist-get (ekp-para-glue-params changed) :cws-stretch))) + (should-not (eq first changed)))))) + +(ert-deftest ekp-test-auto-spacing-signature-keys-last-para () + "Auto spacing inputs must invalidate the same-string fast path." + (ekp-tests--with-clean-state + (let* ((text "自动字距最近段落快路径") + (first (let ((ekp-default-cws-stretch-pixel 2)) + (ekp--get-para text))) + (changed (let ((ekp-default-cws-stretch-pixel 9)) + (ekp--get-para text)))) + (should (= 9 (plist-get (ekp-para-glue-params changed) :cws-stretch))) + (should-not (eq first changed))))) + +(ert-deftest ekp-test-auto-spacing-identical-signature-hits () + "Identical auto spacing signatures must reuse paragraph data." + (ekp-tests--with-clean-state + (let* ((ekp-default-cws-stretch-pixel 3) + (text "自动字距签名相同应命中缓存") + (first (ekp--get-para text)) + (count (hash-table-count ekp--para-cache))) + (setq ekp--last-para nil) + (let ((table-hit (ekp--get-para text))) + (should (eq first table-hit)) + (should (= count (hash-table-count ekp--para-cache))) + (should (eq table-hit (ekp--get-para text))))))) + (ert-deftest ekp-test-global-width-cache-consistent () "The global width cache returns exactly `string-pixel-width'." (ekp-clear-caches) diff --git a/tests/run-tests-isolated.sh b/tests/run-tests-isolated.sh new file mode 100755 index 0000000..a62db69 --- /dev/null +++ b/tests/run-tests-isolated.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Run every EKP ERT test in a fresh Emacs process. +# Usage: tests/run-tests-isolated.sh [path-to-emacs] + +set -eu + +EMACS="${1:-${EMACS:-emacs}}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +TESTS=$( + "$EMACS" -Q --batch -L "$ROOT" -L "$ROOT/tests" \ + -l "$ROOT/tests/ekp-tests.el" \ + -l "$ROOT/tests/ekp-region-tests.el" \ + -l "$ROOT/tests/ekp-gui-tests.el" \ + -l "$ROOT/tests/ekp-c-tests.el" \ + --eval '(dolist (test (ert-select-tests "^ekp-\\(?:test\\|[[:alnum:]-]+-test\\)-" t)) + (princ (format "%s\n" (ert-test-name test))))' +) + +for test_name in $TESTS; do + echo "isolated ERT: $test_name" + "$EMACS" -Q --batch -L "$ROOT" -L "$ROOT/tests" \ + -l "$ROOT/tests/ekp-tests.el" \ + -l "$ROOT/tests/ekp-region-tests.el" \ + -l "$ROOT/tests/ekp-gui-tests.el" \ + -l "$ROOT/tests/ekp-c-tests.el" \ + --eval "(ert-run-tests-batch-and-exit '$test_name)" +done diff --git a/tests/run-tests-random-order.el b/tests/run-tests-random-order.el new file mode 100644 index 0000000..43b7a58 --- /dev/null +++ b/tests/run-tests-random-order.el @@ -0,0 +1,37 @@ +;;; run-tests-random-order.el --- Run EKP ERT tests in permuted order -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Loaded after the EKP test files by tests/run-tests.sh --random-order. +;; EKP_TEST_SEED selects a reproducible permutation. + +;;; Code: + +(require 'cl-lib) +(require 'ert) + +(defun ekp-tests--permuted-names (names seed) + "Return NAMES in a deterministic permutation selected by SEED." + (let ((items (vconcat names)) + (state (logand seed #x7fffffff))) + (dotimes (index (max 0 (1- (length items)))) + (setq state (mod (+ (* state 1103515245) 12345) #x80000000)) + (let ((other (+ index (mod state (- (length items) index))))) + (cl-rotatef (aref items index) (aref items other)))) + (append items nil))) + +;;;###autoload +(defun ekp-tests-run-random-order () + "Run every EKP ERT test in a reproducibly permuted order." + (interactive) + (let* ((seed (string-to-number (or (getenv "EKP_TEST_SEED") "20260728"))) + (tests + (ert-select-tests "^ekp-\\(?:test\\|[[:alnum:]-]+-test\\)-" t)) + (names (mapcar #'ert-test-name tests)) + (selector + (cons 'member (ekp-tests--permuted-names names seed)))) + (message "EKP ERT permutation seed: %d" seed) + (ert-run-tests-batch-and-exit selector))) + +(provide 'run-tests-random-order) +;;; run-tests-random-order.el ends here diff --git a/tests/run-tests.sh b/tests/run-tests.sh index bb12abe..c187485 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -1,11 +1,31 @@ #!/bin/sh # Run the EKP test suite in batch mode. -# Usage: tests/run-tests.sh [path-to-emacs] +# Usage: tests/run-tests.sh [path-to-emacs] [--random-order] EMACS="${1:-${EMACS:-emacs}}" +ORDER="${2:-}" ROOT="$(cd "$(dirname "$0")/.." && pwd)" -exec "$EMACS" -Q --batch -L "$ROOT" \ - -l "$ROOT/tests/ekp-tests.el" \ - -l "$ROOT/tests/ekp-region-tests.el" \ - -f ert-run-tests-batch-and-exit +case "$ORDER" in + "") + exec "$EMACS" -Q --batch -L "$ROOT" -L "$ROOT/tests" \ + -l "$ROOT/tests/ekp-tests.el" \ + -l "$ROOT/tests/ekp-region-tests.el" \ + -l "$ROOT/tests/ekp-gui-tests.el" \ + -l "$ROOT/tests/ekp-c-tests.el" \ + -f ert-run-tests-batch-and-exit + ;; + --random-order) + exec "$EMACS" -Q --batch -L "$ROOT" -L "$ROOT/tests" \ + -l "$ROOT/tests/ekp-tests.el" \ + -l "$ROOT/tests/ekp-region-tests.el" \ + -l "$ROOT/tests/ekp-gui-tests.el" \ + -l "$ROOT/tests/ekp-c-tests.el" \ + -l "$ROOT/tests/run-tests-random-order.el" \ + -f ekp-tests-run-random-order + ;; + *) + echo "usage: $0 [path-to-emacs] [--random-order]" >&2 + exit 2 + ;; +esac