diff --git a/TODO.md b/TODO.md index 0675aee..8a25e46 100644 --- a/TODO.md +++ b/TODO.md @@ -61,6 +61,9 @@ - [x] Deliver [ticket-020](project/ticket-020/README.md): record Goal 2.1.291 release notes and publication evidence without another version change. State: `DONE / PUBLICATION`; classification: `SERVICE / integration`. +- [ ] Deliver [ticket-021](project/ticket-021/README.md): detect only writable + Python `__version__` declarations and support conventional version modules. + State: `IN_PROGRESS / VALIDATION`; classification: `BUG / application`. - [ ] After governance bootstrap, execute the sequential phases defined in [the refactoring plan](docs/GOAL_KORU_SUBACTOR_REFACTORING_PLAN.md), with one narrowly scoped ticket active at a time. @@ -71,7 +74,7 @@ > `ticket-010 DONE`; `ticket-011 DONE`; `ticket-012 DONE`; > `ticket-013 DONE`; `ticket-014 CANCELLED`; `ticket-015 DONE`; > `ticket-016 DONE`; `ticket-017 DONE`; `ticket-018 DONE`; -> `ticket-019 DONE`; `ticket-020 DONE`. +> `ticket-019 DONE`; `ticket-020 DONE`; `ticket-021 IN_PROGRESS`. > **Recently shipped (manual note):** `goal all [PATHS...]` monorepo sweep — > runs `goal -a` in every git repo with uncommitted changes under the given diff --git a/goal/cli/version_state.py b/goal/cli/version_state.py index 4ecccbe..9cbf998 100644 --- a/goal/cli/version_state.py +++ b/goal/cli/version_state.py @@ -58,6 +58,12 @@ "setup.py", "pom.xml", } +_PYTHON_VERSION_FILENAMES = { + "__init__.py", + "version.py", + "_version.py", + "__about__.py", +} _DERIVED_LOCKS = { "pyproject.toml": ("uv.lock", "poetry.lock", "pdm.lock"), "package.json": ("package-lock.json", "pnpm-lock.yaml"), @@ -291,7 +297,7 @@ def _configured_specs(config) -> tuple[str, ...]: def _candidate_spec(path: Path, content: Optional[str] = None) -> Optional[str]: - if path.name == "__init__.py": + if path.name in _PYTHON_VERSION_FILENAMES: try: text = content if content is not None else path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): diff --git a/goal/config/manager.py b/goal/config/manager.py index eb59ece..06767a2 100644 --- a/goal/config/manager.py +++ b/goal/config/manager.py @@ -278,7 +278,7 @@ def _detect_version_files(self) -> List[str]: } manifests = ("pyproject.toml", "package.json", "Cargo.toml", "setup.py") shallowest: Dict[str, str] = {} - init_candidates: List[str] = [] + python_version_candidates: List[str] = [] for dirpath, dirnames, filenames in os.walk("."): dirnames[:] = [ @@ -292,8 +292,13 @@ def _detect_version_files(self) -> List[str]: prev = shallowest.get(filename) if prev is None or rel.count(os.sep) < prev.count(os.sep): shallowest[filename] = rel - elif filename == "__init__.py": - init_candidates.append(rel) + elif filename in { + "__init__.py", + "version.py", + "_version.py", + "__about__.py", + }: + python_version_candidates.append(rel) version_files: List[str] = [] version_path = Path("VERSION") @@ -308,14 +313,22 @@ def _detect_version_files(self) -> List[str]: if filename in shallowest: version_files.append(f"{shallowest[filename]}:version") - # First __init__.py (shallowest, then alphabetical) that declares - # __version__ — only project packages remain after pruning above. - for rel in sorted(init_candidates, key=lambda p: (p.count(os.sep), p)): + # First conventional Python carrier (shallowest, then alphabetical) + # with a writable literal assignment. Imports and re-exports are not + # declarations and must never replace an explicit configured source. + for rel in sorted( + python_version_candidates, key=lambda p: (p.count(os.sep), p) + ): try: - if "__version__" in Path(rel).read_text(): + content = Path(rel).read_text(encoding="utf-8") + if re.search( + r'^__version__\s*=\s*["\'][^"\']+["\']', + content, + re.MULTILINE, + ): version_files.append(f"{rel}:__version__") break - except OSError as exc: + except (OSError, UnicodeDecodeError) as exc: logger.debug("Unable to scan %s for __version__: %s", rel, exc) if not version_files: diff --git a/project/TICKETS.md b/project/TICKETS.md index 9f29199..dcc945b 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -26,4 +26,5 @@ This file indexes governance tickets without taking ownership of | **ticket-018** | [`README.md`](./ticket-018/README.md) | [`preprompt.md`](./ticket-018/preprompt.md) | - | [`ai-codex.md`](./ticket-018/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-018/ai-codex-logs.txt) | [`changelog.md`](./ticket-018/changelog.md) | | **ticket-019** | [`README.md`](./ticket-019/README.md) | [`preprompt.md`](./ticket-019/preprompt.md) | - | [`ai-codex.md`](./ticket-019/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-019/ai-codex-logs.txt) | [`changelog.md`](./ticket-019/changelog.md) | | **ticket-020** | [`README.md`](./ticket-020/README.md) | [`preprompt.md`](./ticket-020/preprompt.md) | - | [`ai-codex.md`](./ticket-020/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-020/ai-codex-logs.txt) | [`changelog.md`](./ticket-020/changelog.md) | +| **ticket-021** | [`README.md`](./ticket-021/README.md) | [`preprompt.md`](./ticket-021/preprompt.md) | - | [`ai-codex.md`](./ticket-021/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-021/ai-codex-logs.txt) | [`changelog.md`](./ticket-021/changelog.md) | diff --git a/project/ticket-021/README.md b/project/ticket-021/README.md new file mode 100644 index 0000000..b890310 --- /dev/null +++ b/project/ticket-021/README.md @@ -0,0 +1,50 @@ +# Ticket 021: Detect writable Python version declarations + +- **ID**: ticket-021 +- **Owner**: unresolved:human +- **Status**: IN_PROGRESS +- **Workflow state**: VALIDATION +- **Created**: 2026-08-10 + +## Goal and scope + +Make Python version discovery select only writable, top-level +`__version__ = "..."` declarations. Importing or re-exporting `__version__` +must not make a file a version carrier, while conventional `version.py` and +`_version.py` modules must be discoverable. This keeps `goal check-versions` +from rewriting a valid `goal.yaml` to an unreadable selector. + +## Acceptance criteria + +- [x] AC-01: The user requested autonomous continuation and correct Goal + decisions about which files carry versions. +- [x] AC-02: Import-only `__init__.py` files are ignored by configuration + detection. +- [x] AC-03: A conventional Python version module with a literal assignment is + discovered and remains readable/writable by version-state logic. +- [x] AC-04: Focused and full tests plus governance pass. + +## Validation evidence + +- Focused version-discovery suite: 26 passed. +- Full Goal suite: 512 passed, 2 skipped. +- The detector run against `wellmanifest/wellm` selects + `src/wellmanifest/version.py:__version__` and excludes the compatibility + re-export in `src/well/__init__.py`. + +## Session authorization + +The user explicitly authorized autonomous implementation, testing and +publication. No repeated confirmation is required inside this bounded ticket; +exact-head validation and protected merge remain mandatory. + +## Risk boundary + +Only version-source discovery and regression tests change. Version precedence, +bump arithmetic, registry comparison, publication credentials and delivery +policy remain unchanged. + +## Participants + +- Human participant: unresolved; no user-* file was created by this script. +- Agent participant: [ai-codex.md](ai-codex.md) diff --git a/project/ticket-021/ai-codex-logs.txt b/project/ticket-021/ai-codex-logs.txt new file mode 100644 index 0000000..1e81d5a --- /dev/null +++ b/project/ticket-021/ai-codex-logs.txt @@ -0,0 +1,4 @@ +2026-08-10: Reproduced in wellmanifest/wellm: detection selected an import-only +src/well/__init__.py and made goal check-versions unreadable. +2026-08-10: New detector selects src/wellmanifest/version.py:__version__. +2026-08-10: Focused tests 26 passed; full suite 512 passed, 2 skipped. diff --git a/project/ticket-021/ai-codex.md b/project/ticket-021/ai-codex.md new file mode 100644 index 0000000..af4c9e8 --- /dev/null +++ b/project/ticket-021/ai-codex.md @@ -0,0 +1,34 @@ +--- +participant-id: agent:codex +participant: codex +role: agent +ticket: ticket-021 +--- +# Participant: codex (AI agent) + +## Understanding + +Goal currently treats any `__version__` substring in the shallowest +`__init__.py` as a declaration. In wellm that selects a re-export, overwrites +the correct configured carrier and makes `check-versions` fail. + +## Execution plan + +1. Add import-only and conventional-version-module regression cases. +2. Centralize the anchored literal-assignment predicate in both discovery + paths. +3. Run focused/full tests and governance. +4. Deliver and publish through the protected Goal workflow. + +## Actual changes + +- Initialized the bounded ticket and reproduced the defect in wellm. +- Restricted Python carrier discovery to anchored literal assignments. +- Added conventional `version.py`, `_version.py` and `__about__.py` carriers. +- Added manager and version-state regression coverage; full suite passes. + +## Blockers + +- None inside the recorded intent; proceed without a second confirmation. +- New authority remains required for destructive action, secret access, new + external coordination, material objective expansion and trusted merge. diff --git a/project/ticket-021/changelog.md b/project/ticket-021/changelog.md new file mode 100644 index 0000000..2cb2f77 --- /dev/null +++ b/project/ticket-021/changelog.md @@ -0,0 +1,11 @@ +# Ticket Changelog (ticket-021) + +## [0.1.0] - 2026-08-10 + +- Initial governance scaffold created. +- No human participant identity or content was generated. +- Recorded the import-only false positive and conventional version-module + discovery scope. +- Import-only compatibility modules are no longer selected as version files. +- Conventional Python version modules now participate in discovery and + synchronization when they contain a literal assignment. diff --git a/project/ticket-021/intent.json b/project/ticket-021/intent.json new file mode 100644 index 0000000..6cf3543 --- /dev/null +++ b/project/ticket-021/intent.json @@ -0,0 +1,68 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-021", + "summary": "Detect writable Python version declarations", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "requested" + }, + "allowedPaths": [ + "goal/config/manager.py", + "goal/cli/version_state.py", + "tests/test_detect_version_files.py", + "tests/test_version_state.py", + "project/ticket-021/**", + "TODO.md", + "project/TICKETS.md" + ], + "forbiddenPaths": [ + ".governance/**", + ".github/**", + "project/ticket-*/user-*.md", + "VERSION", + "pyproject.toml", + "uv.lock", + "goal/__init__.py" + ], + "stacks": ["python"], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "97a27a5cbaff522d078bef2390358741f7b8eed4", + "targetBranch": "main", + "outcome": "Goal detects the real writable Python version carrier and never mistakes an import-only module for one", + "nonGoals": [ + "No bump arithmetic, registry, dependency or publication change", + "No version metadata or governance package change" + ], + "complexity": "S", + "estimatedMinutes": 25, + "budgets": { + "maxImplementationFiles": 4, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Use one anchored literal-assignment predicate for conventional Python version carrier modules", + "components": [ + {"name": "version-source-discovery", "paths": ["goal/config/manager.py", "goal/cli/version_state.py", "tests/test_detect_version_files.py", "tests/test_version_state.py"]} + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": {"impact": "none", "states": [], "evidence": []}, + "rollback": "Revert the discovery predicate and its regression tests" + }, + "runtimeDependencies": [], + "validation": [ + {"criterion": "AC-02", "commands": ["pytest tests/test_detect_version_files.py -q"], "evidence": "Import-only modules are excluded"}, + {"criterion": "AC-03", "commands": ["pytest tests/test_version_state.py -q"], "evidence": "Conventional version modules are readable and writable"}, + {"criterion": "AC-04", "commands": ["pytest -q", "./project/governance-check.sh"], "evidence": "Full suite and governance pass"} + ] + } +} diff --git a/project/ticket-021/preprompt.md b/project/ticket-021/preprompt.md new file mode 100644 index 0000000..b3c5bd9 --- /dev/null +++ b/project/ticket-021/preprompt.md @@ -0,0 +1,12 @@ +# Ticket preprompt + +- **Task ID**: ticket-021 +- **Task title**: Detect writable Python version declarations +- **Created**: 2026-08-10T11:46:28Z + +Keep executable implementation outside this governance/evidence directory. +Read a human-owned user-*.md file only when one exists. +The request to execute this work creates SESSION_EXECUTION_AUTHORIZATION; +proceed within the recorded intent without a redundant confirmation prompt. +Require new authority for destructive action, secrets, external coordination, +material objective expansion and trusted merge approval. diff --git a/tests/test_detect_version_files.py b/tests/test_detect_version_files.py index 156ccc7..4cb3bb4 100644 --- a/tests/test_detect_version_files.py +++ b/tests/test_detect_version_files.py @@ -106,6 +106,22 @@ def test_prefers_shallowest_package(tmp_path): assert init_entries == ["rootpkg/__init__.py:__version__"] +def test_import_only_initializer_defers_to_version_module(tmp_path): + compatibility = tmp_path / "src/well" + compatibility.mkdir(parents=True) + (compatibility / "__init__.py").write_text( + "from wellmanifest import __version__\n" + ) + package = tmp_path / "src/wellmanifest" + package.mkdir(parents=True) + (package / "version.py").write_text('__version__ = "0.2.0rc4"\n') + + files = _detect_in(tmp_path) + + assert "src/well/__init__.py:__version__" not in files + assert "src/wellmanifest/version.py:__version__" in files + + def test_multiline_version_contract_is_not_the_release_version(tmp_path): (tmp_path / "VERSION").write_text( "FORMAT=bioxfoundry.intent-version/v1\nARTIFACT=intent-corpus\n" diff --git a/tests/test_version_state.py b/tests/test_version_state.py index d0747d6..774f7be 100644 --- a/tests/test_version_state.py +++ b/tests/test_version_state.py @@ -10,6 +10,7 @@ collect_version_sources, resolve_version_decision, validate_version_sources, + write_version_source, ) @@ -245,6 +246,26 @@ def test_imported_version_name_is_not_detected_as_a_declaration(tmp_path, monkey assert {source.spec for source in sources} == {"VERSION"} +def test_conventional_version_module_is_detected_and_writable(tmp_path, monkeypatch): + package = tmp_path / "src/package" + package.mkdir(parents=True) + version_module = package / "version.py" + version_module.write_text('__version__ = "1.0.0"\n') + (package / "__init__.py").write_text( + "from package.version import __version__\n" + ) + (tmp_path / "VERSION").write_text("1.0.0\n") + monkeypatch.chdir(tmp_path) + + sources = collect_version_sources({"versioning": {"files": ["VERSION"]}}) + + by_spec = {source.spec: source for source in sources} + assert "src/package/__init__.py:__version__" not in by_spec + assert by_spec["src/package/version.py:__version__"].value == "1.0.0" + assert write_version_source("src/package/version.py:__version__", "1.0.1") + assert version_module.read_text() == '__version__ = "1.0.1"\n' + + def test_registry_failure_does_not_disable_local_git_decision(tmp_path, monkeypatch): config = _init_release(tmp_path, "1.2.3") monkeypatch.chdir(tmp_path)