diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 260f478..56e0b91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,35 @@ jobs: - name: Run smoke test run: python scripts/smoke_test.py + + windows: + name: Windows Python 3.12 + runs-on: windows-latest + # FlowGrid ledgers and repository fixtures are UTF-8. Python otherwise + # falls back to a Windows locale encoding for unqualified test file I/O. + env: + PYTHONUTF8: "1" + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install package and test runner + run: | + python -m pip install --upgrade pip + python -m pip install -e . pytest + + - name: Run test suite + run: pytest -q + + - name: Check current-state freshness + run: python scripts/check_current_state.py + + - name: Run smoke test + run: python scripts/smoke_test.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c28a5f..7a95d14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,18 @@ pip install -e . pytest -q ``` +FlowGrid ledgers and repository fixtures use UTF-8. Windows contributors should +run Python in UTF-8 mode so unqualified test-only `pathlib` reads and writes use +the same encoding contract: + +```powershell +$env:PYTHONUTF8 = '1' +pytest -q +``` + +The FlowGrid runtime explicitly reads and writes project ledger files as UTF-8; +this environment setting is the repository test-process contract on Windows. + ## Running the smoke test ```bash diff --git a/ITERATION_LOG.md b/ITERATION_LOG.md index 151300e..caa7f7f 100644 --- a/ITERATION_LOG.md +++ b/ITERATION_LOG.md @@ -16,6 +16,28 @@ --- +## 2026-08-17:真实 Windows 兼容性报告回灌 + +### FLG-ITER-20260817-01:GBK 首次初始化、MSYS 路径和 Windows 测试链暴露兼容性缺口 [fixed — P0] + +**场景**:一位外部测试者在 Windows 11、PowerShell 5.1 代码页 936、PortableGit Git Bash 和 Python 3.13.14 上,按项目提供的测试任务运行合并提交 `4cd893b`。测试使用真实营销讨论作为 raw session,原始报告和私有素材不进入公开仓库。 + +**观察**:`flg version`、smoke、`C:\` 和 `C:/` session 路径通过;pytest 为 `7 failed, 221 passed`。中文和空格目录已经完成文件写入,但 `init` 打印字面量 checkmark 时触发 GBK `UnicodeEncodeError`。Git Bash `/c/...` 目标没有经过已有路径归一化,被创建到 `C:\c\...`。 + +**逐项复核**:7 个 pytest failure 中,路径显示、`PYTHONPATH` 分隔符、manifest 路径和 Rich 表格宽度属于测试可移植性问题;`current-state` 分类器把 Windows `Path` 字符串与 Git 的 POSIX 路径直接比较,属于真实脚本兼容性缺陷。不能把 7 项统一归为测试错误。 + +**本地候选方向**:首次初始化成功标记改为 GBK 可编码 ASCII;`init --dir` 接入 `normalize_user_path`;修复跨平台断言和 current-state 路径分类;增加 Windows Python 3.12 CI。`doctor` 缺少 repo-map 当前只显示信息,不单独导致 strict 失败,本轮不改变这一产品语义。 + +**本地候选验证**:Windows 定向回归 `10 passed`,全量 `231 passed`,强制 repository source-tree smoke、current-state freshness、workflow YAML 解析与 `git diff --check` 均通过。Windows CI 已加入候选,尚未通过远端 runner 执行。 + +**首次 Windows CI**:`fef01e0` 的 Windows Python 3.12 job 为 `38 failed, 193 passed`。日志逐项显示其中 37 项来自测试和 fixture 的无编码 `pathlib` I/O 在 Windows 默认落到 `cp1252`,另 1 项来自 Rich 表格对 `pending_review` 的平台相关截断。后续候选把 UTF-8 明确为 Windows 测试进程契约,在 job 设置 `PYTHONUTF8=1`,并将 status 回归改为验证 pending warning 语义。 + +**第二次 Windows CI**:UTF-8 修复将结果收敛到 `1 failed, 230 passed`。残余失败揭示 BIZ 批量导入在 Windows 同一时钟刻度内生成相同 capture ID,后一条覆盖前一条。候选保留原有 ID 形态,改用独立 UUID 后缀,并增加冻结时钟下的唯一性回归。 + +**现场复测**:外部测试者在同一 Windows 11 环境特征下,以独立虚拟环境复测固定提交 `ed527d0abd442d53027100937295e5cd32690c8c`。PowerShell 5.1 代码页 936 初始化中文空格目录退出码为 0、无 `UnicodeEncodeError`;Git Bash `/c/Users/...` 正确落到 `C:\Users\...`,进入目标目录后 `flg status` 能识别项目。全量测试记录为 `232 passed / 0 failed / 0 errors`,smoke 通过。 + +**收口**:两个原始运行时缺陷已在报告主机和固定提交上闭环,PR #47 的 Linux 与 Windows CI 也全部通过。结论保持有界:这是单一外部 Windows 主机的直接修复证据,不代表全部 Windows 环境,也不推导用户满意度或产品采用情况。 + ## 2026-08-16:中文 owner 范围收拢与外部 Windows 案例收口 ### FLG-ITER-20260816-01:中文 owner 范围收拢在短句与长口述中重复漏提取 [confirmed — P0] diff --git a/docs/product/current-state.md b/docs/product/current-state.md index 539de98..1ca20f2 100644 --- a/docs/product/current-state.md +++ b/docs/product/current-state.md @@ -1,6 +1,6 @@ # FlowGrid Current State -Last updated: 2026-08-12 +Last updated: 2026-08-17 Primary branch: `master` Current code version: `v0.3.0` Current stage: `v0.4 core validation` @@ -28,6 +28,39 @@ Its job is to let project state, boundaries, judgments, progress, and next actio ## Current Verification +- PR #46 merged feedback-driven continuity hardening into `master` at + `4cd893b`; Linux CI passed on Python 3.10, 3.11, and 3.12 with `228` tests. +- A 2026-08-17 external Windows 11 run against `4cd893b` passed installation, + version, smoke, and `C:\` / `C:/` session paths. It observed a GBK `init` + output crash, an MSYS `/c/` init target error, and seven pytest failures. +- The seven failures were reviewed individually: five are platform-sensitive + assertions, while two expose one real Windows path-normalization bug in the + current-state freshness classifier. See the + [bounded compatibility record](../../evals/results/windows-compatibility-observed-20260817.md). +- The current PR #47 candidate uses a GBK-safe init success marker, routes + `init --dir` through the shared MSYS path normalizer, makes the affected tests + platform-aware, and adds Windows Python 3.12 CI. GitHub Actions passes on + Python 3.10, 3.11, 3.12, and Windows Python 3.12. +- The first Windows CI run at `fef01e0` reported `38 failed, 193 passed`: + 37 failures came from Python defaulting unqualified test and fixture I/O to + `cp1252`, and one came from Rich table-cell truncation. The follow-up candidate + documents UTF-8 as the Windows test-process contract, enables `PYTHONUTF8=1` + for the Windows job, and asserts status warning semantics independently of + table rendering. +- The UTF-8 follow-up reduced Windows CI to `1 failed, 230 passed`. The remaining + failure exposed a real same-timestamp capture-ID collision during a multi-item + BIZ import on Windows. The next candidate preserves the `cap-...-xxxxxx` ID + shape while replacing the timestamp-derived suffix with an independent UUID + suffix and adds a frozen-clock uniqueness regression. +- A same-host field rerun against fixed commit `ed527d0` then passed the two + original runtime scenarios: PowerShell 5.1 at code page 936 initialized a + Chinese, spaced path without an encoding crash, and Git Bash mapped an + explicit `/c/Users/...` target to `C:\Users\...` rather than + `C:\c\Users\...`. The rerun also reported `232` tests with zero failures + or errors and a passing repository smoke test. This closes the reported + defects for that host and fixed commit; it does not establish universal + Windows compatibility. + - PR #44 established the pre-Continuation V2 `master` baseline at merge commit `11c337d` - The independent FlowGrid AML Retriever ranked #8 in the first public Agent diff --git a/evals/README.md b/evals/README.md index cc1f273..3ccf667 100644 --- a/evals/README.md +++ b/evals/README.md @@ -26,6 +26,13 @@ separately in [`case-registry.json`](case-registry.json). The registry records both the supported claim and limitation of every case; it must not label owner-operated evidence as independent external adoption. +The observed Windows 11 compatibility run against merge commit `4cd893b` and +the seven-failure disposition are recorded in +[`results/windows-compatibility-observed-20260817.md`](results/windows-compatibility-observed-20260817.md). +The source report used a real private transcript and remains outside the public +repository; the checked-in result keeps only the technical observations needed +to reproduce and verify the fixes. + ## Evaluation Modes Each scenario should support at least three modes: diff --git a/evals/results/windows-compatibility-observed-20260817.md b/evals/results/windows-compatibility-observed-20260817.md new file mode 100644 index 0000000..24d0b12 --- /dev/null +++ b/evals/results/windows-compatibility-observed-20260817.md @@ -0,0 +1,106 @@ +# Windows 11 compatibility observation and failure disposition + +Date: 2026-08-17 + +Tested base: `4cd893b5b8198b7e74426fd0c3bde9e3d2e99b92` + +## Evidence boundary + +This record summarizes an externally executed Windows report supplied to the +project owner. The original report and its real marketing transcript remain +outside this repository. They establish observed behavior on one Windows host; +they do not establish user satisfaction or broad Windows compatibility. A +second report from the same host reran the bounded compatibility scenarios +against candidate commit `ed527d0abd442d53027100937295e5cd32690c8c`. + +Observed environment: + +- Windows 11 Pro, 64-bit, build 26200 +- PowerShell 5.1 with console code page 936 (GBK) +- PortableGit Git Bash +- Python 3.13.14 +- FlowGrid v0.3.0 at merge commit `4cd893b` + +Observed baseline results: + +- `flg version`: passed +- repository smoke test: passed +- full pytest: `7 failed, 221 passed` +- `C:\` and `C:/` session paths: passed +- Chinese and spaced project directory: files were created, then `init` exited + with `UnicodeEncodeError` +- Git Bash `/c/` init target: created under `C:\c\...` instead of the intended + drive root + +## Runtime defects + +| Observation | Assessment | Candidate action | +| --- | --- | --- | +| `init` prints a literal checkmark that code page 936 cannot encode | Runtime defect. The command fails after writing project files. | Use an ASCII first-run success marker and add a GBK-encodable output regression. | +| `init --dir /c/...` resolves to `C:\c\...` | Runtime defect. The existing MSYS normalizer was not used by `init`. | Route `--dir` through `normalize_user_path` and add an init wiring regression. | + +## Seven pytest failures + +The seven failures were reviewed individually. They do not share one cause. + +| Reported failure | Disposition | Reason and action | +| --- | --- | --- | +| Windows path spelling expected `C:/...` but received `C:\...` | Test portability defect | Native `Path` display is platform-specific. Compare with `PureWindowsPath`. | +| Source-tree smoke `PYTHONPATH` expected `:` | Test portability defect | The implementation correctly uses `os.pathsep`; make the assertion do the same. | +| Forced-source smoke `PYTHONPATH` expected `:` | Test portability defect | Same path-separator issue as above. | +| Manifest metadata expected `/` separators | Test portability defect | Validate native `Path.parts` instead of a POSIX suffix string. | +| Status table expected the full `pending_review` cell | Rendering-sensitive test | Rich may truncate a cell at detected terminal width. Fix the test width while retaining the warning and lifecycle assertion. | +| Current-state classifier included its own state document in product drift | Script portability defect | `str(Path(...))` used backslashes on Windows while Git paths use `/`. Normalize Git-style paths in the classifier. | +| Current-state non-product case returned its own state document | Script portability defect | Same classifier defect as above. | + +## Doctor boundary + +The report attributed `doctor --strict` exit 1 to a missing +`.flg/repo-map.json`. Current FlowGrid semantics explicitly keep an unmapped +project compatible: no repo-map is displayed as informational and does not by +itself make strict mode fail. The report does not provide enough machine-readable +doctor output to identify another failing integrity item. This candidate does +not change doctor semantics. + +## Local candidate verification + +- focused Windows regressions: `10 passed` +- full repository suite: `231 passed` +- forced repository source-tree smoke: passed +- current-state freshness check: passed with the state document refresh +- workflow YAML parse and `git diff --check`: passed +- Windows Python 3.12 GitHub Actions job: added, not executed locally + +The first remote Windows job at `fef01e0` produced `38 failed, 193 passed`. +Log review assigned 37 failures to unqualified test and fixture I/O using the +runner's `cp1252` default, plus one failure to Rich table-cell truncation. The +follow-up defines `PYTHONUTF8=1` as the Windows repository test-process contract +and changes the status regression to assert the warning behavior rather than a +fully rendered table cell. + +That follow-up reduced the remote result to `1 failed, 230 passed`. The remaining +failure exposed a same-timestamp capture-ID collision in a two-item BIZ import +on Windows. The next candidate replaces the timestamp-derived six-character +suffix with an independent UUID suffix and adds a frozen-clock uniqueness test. + +## Windows-host candidate rerun + +On 2026-08-17, the external tester reran the bounded scenarios against fixed +candidate commit `ed527d0abd442d53027100937295e5cd32690c8c` in a fresh Python +3.13.14 virtual environment on the same Windows 11 host characteristics. + +- PowerShell 5.1 at code page 936 initialized a Chinese, spaced project path + with exit code 0, no `UnicodeEncodeError`, and an intact `.flg` directory. +- Git Bash initialized an explicit `/c/Users/...` target under the intended + `C:\Users\...` path, not `C:\c\Users\...`; `flg status` then recognized + the project. +- The repository suite reported `232` tests, `0` failures, and `0` errors after + PortableGit was made available to subprocesses. +- The repository smoke test ended with `Smoke test passed.` +- `doctor --strict` still reported `Needs attention` for the temporary project + because no runtime identity was configured. That observation is outside the + two compatibility acceptance criteria and does not change doctor semantics. + +This rerun is direct field evidence that the two reported runtime defects are +fixed for the tested host and commit. It remains one-host evidence, not a claim +of universal Windows compatibility. diff --git a/scripts/check_current_state.py b/scripts/check_current_state.py index 391a8bf..bcc7b1d 100644 --- a/scripts/check_current_state.py +++ b/scripts/check_current_state.py @@ -32,10 +32,12 @@ def _git(*args: str) -> str: def product_changes_after_state_update(paths: list[str]) -> list[str]: + state_doc_path = STATE_DOC.as_posix() return sorted( path for path in paths - if path != str(STATE_DOC) and path.startswith(PRODUCT_PREFIXES) + if path.replace("\\", "/") != state_doc_path + and path.replace("\\", "/").startswith(PRODUCT_PREFIXES) ) diff --git a/src/flg/commands/capture.py b/src/flg/commands/capture.py index 4aa6c09..01b2f4e 100644 --- a/src/flg/commands/capture.py +++ b/src/flg/commands/capture.py @@ -2,9 +2,9 @@ from __future__ import annotations -import hashlib import json import re +import uuid from datetime import datetime from pathlib import Path from typing import Optional @@ -40,7 +40,10 @@ def _ensure_captures_dir(root: Path) -> Path: def _generate_id() -> str: now = datetime.now() ts = now.strftime("%Y%m%d-%H%M%S") - rand = hashlib.md5(str(now.timestamp()).encode()).hexdigest()[:6] + # Windows wall-clock resolution can return the same timestamp for multiple + # captures created in one import loop. Keep the public ID shape while using + # an independent random suffix so one judgment cannot overwrite another. + rand = uuid.uuid4().hex[:6] return f"cap-{ts}-{rand}" diff --git a/src/flg/commands/init.py b/src/flg/commands/init.py index a7c5a44..2bfcf20 100644 --- a/src/flg/commands/init.py +++ b/src/flg/commands/init.py @@ -10,7 +10,7 @@ from rich.table import Table from rich.text import Text -from ..core.files import ensure_dir, is_flg_project, safe_write +from ..core.files import ensure_dir, is_flg_project, normalize_user_path, safe_write from ..core.i18n import normalize_language from ..core.state import create_initial_state, save_state from .. import templates_en @@ -67,7 +67,7 @@ def init_project( This avoids the common pitfall of initializing in the wrong cwd. """ if directory: - root = Path(directory).expanduser().resolve() + root = normalize_user_path(directory).resolve() root.mkdir(parents=True, exist_ok=True) else: root = Path.cwd() @@ -252,7 +252,11 @@ def init_project( # Display results console.print() - console.print(f"[bold green]✓ FlowGrid project initialized: {project_name}[/bold green]") + # Keep first-run success output encodable by legacy Windows consoles such + # as code page 936. A literal checkmark crashes Rich's legacy renderer + # after the project has already been written, leaving users with a false + # failure and a partially confusing first-run state. + console.print(f"[bold green][OK] FlowGrid project initialized: {project_name}[/bold green]") creation_path = Text("Created in: ", style="dim") creation_path.append(str(root), style="dim") console.print(creation_path, soft_wrap=True) diff --git a/tests/test_capture.py b/tests/test_capture.py index 8c8c837..392f8d3 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -2,15 +2,28 @@ import json import os +from datetime import datetime as RealDateTime from typer.testing import CliRunner from flg.cli import app +from flg.commands import capture as capture_command runner = CliRunner() +def test_capture_ids_remain_unique_when_wall_clock_is_unchanged(monkeypatch): + class FrozenDateTime: + @classmethod + def now(cls): + return RealDateTime(2026, 8, 17, 14, 44, 8) + + monkeypatch.setattr(capture_command, "datetime", FrozenDateTime) + + assert capture_command._generate_id() != capture_command._generate_id() + + def test_auto_confirm_keeps_inferred_capture_pending(tmp_path): old_cwd = os.getcwd() os.chdir(tmp_path) diff --git a/tests/test_context.py b/tests/test_context.py index 1440c0e..bdfa045 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -11,6 +11,7 @@ import os import os.path import json +from pathlib import Path from typer.testing import CliRunner @@ -549,7 +550,7 @@ def test_continuity_manifest_is_compact_and_expands_by_decision_id(tmp_path): assert "flg trace D-002" in manifest assert "long rationale" not in manifest assert ".flg/sessions" not in manifest - assert metadata["path"].endswith(".flg/context/manifest.md") + assert Path(metadata["path"]).parts[-3:] == (".flg", "context", "manifest.md") finally: os.chdir(old_cwd) diff --git a/tests/test_current_state_check.py b/tests/test_current_state_check.py index f4e9d70..4a55e66 100644 --- a/tests/test_current_state_check.py +++ b/tests/test_current_state_check.py @@ -21,3 +21,13 @@ def test_current_state_classifier_ignores_non_product_changes(): assert product_changes_after_state_update( ["README.md", "ITERATION_LOG.md", "docs/product/current-state.md"] ) == [] + + +def test_current_state_classifier_normalizes_windows_git_paths(): + assert product_changes_after_state_update( + [ + "README.md", + "docs\\product\\current-state.md", + "src\\flg\\commands\\init.py", + ] + ) == ["src\\flg\\commands\\init.py"] diff --git a/tests/test_init.py b/tests/test_init.py index 378db7b..b3b5c2e 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -10,6 +10,7 @@ from flg import __version__ from flg.cli import app +from flg.commands import init as init_command runner = CliRunner() @@ -50,6 +51,15 @@ def test_init_creates_project_structure(tmp_dir): assert ".flg/" in (tmp_dir / ".gitignore").read_text(encoding="utf-8") +def test_init_output_is_encodable_by_gbk_console(tmp_dir): + """The first-run success path must not crash a code-page-936 console.""" + result = runner.invoke(app, ["init", "中文项目"]) + + assert result.exit_code == 0 + assert "[OK] FlowGrid project initialized" in result.output + result.output.encode("gbk") + + def test_init_keeps_existing_gitignore_and_adds_flg_privacy_rule(tmp_path): old_cwd = os.getcwd() os.chdir(tmp_path) @@ -243,6 +253,31 @@ def test_init_with_dir_creates_missing_directory(tmp_path): os.chdir(old_cwd) +def test_init_routes_msys_drive_path_through_normalizer(tmp_path, monkeypatch): + """Git Bash /c/ input must reach the normalized native Windows target.""" + old_cwd = os.getcwd() + os.chdir(tmp_path) + try: + target = tmp_path / "msys-target" + seen = [] + + def fake_normalize(value): + seen.append(value) + return target + + monkeypatch.setattr(init_command, "normalize_user_path", fake_normalize) + result = runner.invoke( + app, + ["init", "MSYS Path Test", "--dir", "/c/Users/owner/project"], + ) + + assert result.exit_code == 0 + assert seen == ["/c/Users/owner/project"] + assert (target / ".flg" / "state.json").exists() + finally: + os.chdir(old_cwd) + + def test_init_output_shows_creation_path(tmp_path): """Success output must show where files were created. diff --git a/tests/test_integrity.py b/tests/test_integrity.py index c180922..4844a5d 100644 --- a/tests/test_integrity.py +++ b/tests/test_integrity.py @@ -3,7 +3,7 @@ import json import os import subprocess -from pathlib import Path +from pathlib import Path, PureWindowsPath from typer.testing import CliRunner @@ -450,9 +450,10 @@ def test_doctor_strict_ignores_runtime_identity_when_repo_map_is_absent(tmp_path def test_windows_path_spellings_normalize_for_native_windows_input(): - assert str(normalize_user_path(r"C:\Users\owner\project\session.md", windows=True)) == r"C:\Users\owner\project\session.md" - assert str(normalize_user_path("C:/Users/owner/project/session.md", windows=True)) == "C:/Users/owner/project/session.md" - assert str(normalize_user_path("/c/Users/owner/project/session.md", windows=True)) == "C:/Users/owner/project/session.md" + expected = PureWindowsPath("C:/Users/owner/project/session.md") + assert PureWindowsPath(str(normalize_user_path(r"C:\Users\owner\project\session.md", windows=True))) == expected + assert PureWindowsPath(str(normalize_user_path("C:/Users/owner/project/session.md", windows=True))) == expected + assert PureWindowsPath(str(normalize_user_path("/c/Users/owner/project/session.md", windows=True))) == expected def test_integrity_recognizes_all_supported_windows_path_spellings(tmp_path): diff --git a/tests/test_smoke_command_resolution.py b/tests/test_smoke_command_resolution.py index 4c32100..23278df 100644 --- a/tests/test_smoke_command_resolution.py +++ b/tests/test_smoke_command_resolution.py @@ -1,5 +1,6 @@ """Regression tests for selecting the checkout under test in smoke runs.""" +import os from pathlib import Path from scripts import smoke_test @@ -15,7 +16,9 @@ def test_source_tree_fallback_preserves_dependency_pythonpath(monkeypatch, tmp_p assert command == ["/usr/bin/python3", "-m", "flg.cli"] assert environment == { - "PYTHONPATH": f"{tmp_path / 'src'}:/tmp/dependency-site" + "PYTHONPATH": os.pathsep.join( + (str(tmp_path / "src"), "/tmp/dependency-site") + ) } assert mode == "repository source tree" @@ -30,6 +33,8 @@ def test_forced_source_tree_ignores_sibling_console_script(monkeypatch, tmp_path assert command == ["/venv/bin/python", "-m", "flg.cli"] assert environment == { - "PYTHONPATH": f"{tmp_path / 'src'}:/tmp/dependency-site" + "PYTHONPATH": os.pathsep.join( + (str(tmp_path / "src"), "/tmp/dependency-site") + ) } assert mode == "forced repository source tree" diff --git a/tests/test_status.py b/tests/test_status.py index 607e166..fb15da1 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -117,10 +117,11 @@ def test_status_warns_on_pending_review_patches(tmp_path): result = runner.invoke(app, ["status"]) assert result.exit_code == 0 - # Warning must trigger for pending_review patches + # Assert the lifecycle behavior, not Rich's platform-dependent table + # cell rendering, which may truncate the literal status value. assert "⚠" in result.output assert "1 pending patch" in result.output - assert "pending_review" in result.output + assert "needing review" in result.output finally: os.chdir(old_cwd)