diff --git a/TODO.md b/TODO.md index 970a529..a20c833 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,13 @@ ## Governed architecture roadmap +- [ ] Deliver [ticket-062](project/ticket-062/README.md): read and safely + synchronize literal versions passed to imported setuptools setup calls + without executing setup.py or touching unrelated keyword arguments. State: + `IN_PROGRESS / PUBLICATION`; 55 focused and 619 full tests (2 skips), Ruff, + governance and Docker validation pass; classification: + `BUG / P1 / regression`. + - [ ] Deliver [ticket-061](project/ticket-061/README.md): publish the merged retry-safe runtime, doctor, producer and tracked configuration fixes as Goal 2.1.300, then prove the public package on a disposable Glon checkout. State: diff --git a/goal/cli/version_state.py b/goal/cli/version_state.py index a91a507..824e696 100644 --- a/goal/cli/version_state.py +++ b/goal/cli/version_state.py @@ -2,6 +2,7 @@ from __future__ import annotations +import ast import json import os import re @@ -166,6 +167,96 @@ def _normalized_spec(spec: str) -> str: return f"{normalized}:{selector}" if selector else normalized +def _dotted_python_name(node: ast.expr) -> tuple[str, ...]: + """Return the dotted name represented by a simple Name/Attribute node.""" + if isinstance(node, ast.Name): + return (node.id,) + if isinstance(node, ast.Attribute): + parent = _dotted_python_name(node.value) + return (*parent, node.attr) if parent else () + return () + + +def _setup_version_literal(content: str) -> Optional[ast.Constant]: + """Locate a literal version passed to an imported package setup function.""" + try: + module = ast.parse(content) + except SyntaxError: + return None + + setup_names: set[str] = set() + module_names: set[tuple[str, ...]] = set() + setup_modules = {"setuptools", "distutils.core"} + for statement in module.body: + if isinstance(statement, ast.ImportFrom) and statement.module in setup_modules: + setup_names.update( + alias.asname or alias.name + for alias in statement.names + if alias.name == "setup" + ) + elif isinstance(statement, ast.Import): + for alias in statement.names: + if alias.name not in setup_modules: + continue + module_names.add( + (alias.asname,) if alias.asname else tuple(alias.name.split(".")) + ) + + setup_calls = {(name,) for name in setup_names} + setup_calls.update((*name, "setup") for name in module_names) + candidates: list[ast.Constant] = [] + for node in ast.walk(module): + if not isinstance(node, ast.Call): + continue + if _dotted_python_name(node.func) not in setup_calls: + continue + candidates.extend( + keyword.value + for keyword in node.keywords + if keyword.arg == "version" + and isinstance(keyword.value, ast.Constant) + and isinstance(keyword.value.value, str) + ) + return min( + candidates, + key=lambda node: (node.lineno, node.col_offset), + default=None, + ) + + +def _replace_setup_version_literal( + content: str, node: ast.Constant, target: str +) -> Optional[str]: + """Replace one AST-located string literal while preserving its quote style.""" + if node.end_lineno is None or node.end_col_offset is None: + return None + lines = content.splitlines(keepends=True) + + def offset(lineno: int, byte_column: int) -> int: + line = lines[lineno - 1] + char_column = len(line.encode("utf-8")[:byte_column].decode("utf-8")) + return sum(len(item) for item in lines[: lineno - 1]) + char_column + + try: + start = offset(node.lineno, node.col_offset) + end = offset(node.end_lineno, node.end_col_offset) + except (IndexError, UnicodeDecodeError): + return None + literal = content[start:end] + match = re.fullmatch( + r"(?P(?i:[rub]*))(?P\"\"\"|'''|\"|').*(?P=quote)", + literal, + re.DOTALL, + ) + replacement = ( + f"{match.group('prefix')}{match.group('quote')}" + f"{target}{match.group('quote')}" + if match + else repr(target) + ) + return f"{content[:start]}{replacement}{content[end:]}" + + def _extract_version(path: Path, selector: str, content: str) -> tuple[Optional[str], bool]: if path.name == "VERSION" and not selector: value = content.strip() @@ -201,6 +292,11 @@ def _extract_version(path: Path, selector: str, content: str) -> tuple[Optional[ except (TypeError, ValueError): pass + if path.name == "setup.py" and selector in {"", "version"}: + literal = _setup_version_literal(content) + if literal is not None: + return normalize_version(literal.value), False + patterns = ( r'(?m)^version\s*=\s*["\']([^"\']+)["\']', r"([^<]+)", @@ -870,6 +966,27 @@ def write_version_source(spec: str, target_version: str) -> bool: "Cannot synchronize configured version source", [f"{normalized_spec}: {exc}"], ) from exc + elif path.name == "setup.py": + literal = _setup_version_literal(content) + replacement = ( + _replace_setup_version_literal(content, literal, target) + if literal is not None + else None + ) + if replacement is not None: + new_content = replacement + else: + new_content, count = re.subn( + r'(?m)^(version\s*=\s*["\'])[^"\']+(["\'])', + rf"\g<1>{target}\g<2>", + content, + count=1, + ) + if count != 1: + raise VersionStateError( + "Cannot synchronize configured version source", + [f"{normalized_spec}: no supported writable selector"], + ) else: substitutions = ( (r'(?m)^(version\s*=\s*["\'])[^"\']+(["\'])', rf"\g<1>{target}\g<2>"), diff --git a/goal/cli/version_sync.py b/goal/cli/version_sync.py index f9dbdf1..6a2cc34 100644 --- a/goal/cli/version_sync.py +++ b/goal/cli/version_sync.py @@ -168,16 +168,7 @@ def _update_setup_py_version(new_version: str, user_config, updated: List[str]) return try: - content = path.read_text() - new_content = re.sub( - r"(version\s*=\s*['\"])[^'\"]+(['\"])", - rf"\g<1>{new_version}\g<2>", - content, - count=1, - flags=re.MULTILINE, - ) - if new_content != content: - path.write_text(new_content) + if write_version_source("setup.py:version", new_version): updated.append("setup.py") if user_config and update_project_metadata(path, user_config): diff --git a/project/TICKETS.md b/project/TICKETS.md index a1dfee9..c268397 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -64,4 +64,5 @@ This file indexes governance tickets without taking ownership of | **ticket-059** | [`README.md`](./ticket-059/README.md) | [`preprompt.md`](./ticket-059/preprompt.md) | - | [`ai-codex.md`](./ticket-059/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-059/ai-codex-logs.txt) | [`changelog.md`](./ticket-059/changelog.md) | | **ticket-060** | [`README.md`](./ticket-060/README.md) | [`preprompt.md`](./ticket-060/preprompt.md) | - | [`ai-codex.md`](./ticket-060/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-060/ai-codex-logs.txt) | [`changelog.md`](./ticket-060/changelog.md) | | **ticket-061** | [`README.md`](./ticket-061/README.md) | [`preprompt.md`](./ticket-061/preprompt.md) | - | [`ai-codex.md`](./ticket-061/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-061/ai-codex-logs.txt) | [`changelog.md`](./ticket-061/changelog.md) | +| **ticket-062** | [`README.md`](./ticket-062/README.md) | [`preprompt.md`](./ticket-062/preprompt.md) | - | [`ai-codex.md`](./ticket-062/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-062/ai-codex-logs.txt) | [`changelog.md`](./ticket-062/changelog.md) | diff --git a/project/ticket-062/README.md b/project/ticket-062/README.md new file mode 100644 index 0000000..0cf9caf --- /dev/null +++ b/project/ticket-062/README.md @@ -0,0 +1,48 @@ +# Ticket 062: Read setuptools setup() version declarations + +- **ID**: ticket-062 +- **Owner**: unresolved:human +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION +- **Created**: 2026-08-13 + +## Goal and scope + +Make configured and auto-detected `setup.py:version` sources accept the +standard literal `version="..."` keyword passed to `setuptools.setup(...)`. +Keep discovery, reading and synchronization aligned without executing +`setup.py` or rewriting unrelated `version=` keywords. + +## Acceptance criteria + +- [x] AC-01: The user's instruction to implement the repair records + `SESSION_EXECUTION_AUTHORIZATION` for this bounded defect. +- [x] AC-02: Version-state resolution reads literal versions from imported + `setuptools.setup(...)` calls, including supported aliases. +- [x] AC-03: Synchronization updates only the selected setup-call version and + leaves unrelated `version=` keywords unchanged. +- [x] AC-04: Focused and full Python tests, scoped Ruff, governance and the + repository Docker build pass. + +## Boundary + +- Parse Python source statically; never import or execute a target `setup.py`. +- Preserve existing module-level `version = "..."` support. +- Do not add runtime dependencies or change public CLI behavior. +- Do not publish, push or modify ticket-061 release evidence. + +## Validation evidence + +- 55 focused tests and 619 full tests pass with 2 existing skips. +- Scoped Ruff, governance (0 errors / 0 warnings) and whitespace validation + pass. +- Docker build passed with host networking to avoid allocating a bridge + subnet; the resulting CLI passed with `--network none`, then the exact + disposable image was removed. No Docker network was removed or modified. +- Immutable new-project v0.16.1 drift check reports `up-to-date`; governed + commit-only delivery opened PR #103 without version or publication effects. + +## 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-062/ai-codex-logs.txt b/project/ticket-062/ai-codex-logs.txt new file mode 100644 index 0000000..cda5873 --- /dev/null +++ b/project/ticket-062/ai-codex-logs.txt @@ -0,0 +1,9 @@ +2026-08-13T10:44:54Z ticket-062 allocated on clean main@e972ff21 after refreshing origin; distinct application workstream does not overlap active integration ticket-061. +2026-08-13T10:44:54Z SESSION_EXECUTION_AUTHORIZATION recorded from the user's instruction to implement the Goal repair. +2026-08-13T10:46:00Z intent preflight: governance GOV-PASS 0 errors/0 warnings after splitting combined acceptance references; implementation began only after the intent validated. +2026-08-13T10:48:00Z focused validation: 55 tests PASS; scoped Ruff PASS; regression proves an unrelated configure(version="9.9.9") remains unchanged while imported setup() advances. +2026-08-13T10:49:00Z full validation: 619 PASS/2 SKIP; governance GOV-PASS 0 errors/0 warnings; whitespace PASS. +2026-08-13T10:52:00Z Docker validation: --network none failed only because apt-get could not resolve Debian repositories; --network host avoided bridge subnet allocation and built successfully. Runtime --network none reported goal 2.1.300. +2026-08-13T10:52:00Z Docker cleanup: removed only disposable goal:ticket-062 image sha256:32ad62dd774a97c38368675c841bd20c61c24c900d31512c2641298c1fcaca58; no Docker network was removed or modified. +2026-08-13T10:55:00Z standardization: immutable local new-project revision 4e6ba5ec15873346446d67d8787f17f68f57f81e is tagged v0.16.1 and present on origin/main; goal governance adopt --check reported up-to-date. +2026-08-13T10:57:00Z governed delivery: commit-only pull-request mode kept 2.1.300 unchanged, ran 619 PASS/2 SKIP, skipped registry/tag/changelog/version effects, created d8113564d12c758fc621d0dbb50b9de4604f53d4 and opened PR #103. diff --git a/project/ticket-062/ai-codex.md b/project/ticket-062/ai-codex.md new file mode 100644 index 0000000..968ba00 --- /dev/null +++ b/project/ticket-062/ai-codex.md @@ -0,0 +1,46 @@ +--- +participant-id: agent:codex +participant: codex +role: agent +ticket: ticket-062 +--- +# Participant: codex (AI agent) + +## Understanding + +Goal advertises and discovers `setup.py:version`, and its legacy synchronizer +already accepts an inline setup keyword, but the strict state reader only +matches module-level assignments. A normal multiline `setuptools.setup()` +therefore fails before synchronization begins. + +## Execution plan + +1. Add a static AST locator for literal versions on imported setup calls. +2. Reuse that locator for targeted writes and the legacy sync path. +3. Add regressions covering multiline calls, aliases and unrelated keywords. +4. Run focused/full tests, Ruff, governance and Docker validation. + +## Actual changes + +- Initialized the bounded ticket and recorded SESSION_EXECUTION_AUTHORIZATION + from the request to execute this work. +- Accepted a one-component, four-implementation-file boundary on clean + `main@e972ff2`; active ticket-061 is a distinct integration/publication + workstream with no source or test overlap. +- Added static AST discovery for literal versions on imported setuptools and + distutils setup calls and reused its exact source span for safe writes. +- Replaced the legacy broad setup.py substitution with the strict version + source writer so unrelated keyword arguments remain unchanged. +- Added resolution/write and end-to-end sync regressions; 55 focused and 619 + full tests (2 skips), Ruff, governance and Docker validation pass. +- Moved the locally complete ticket to PUBLICATION; no commit, push, PR or + release action had yet been performed. +- Verified zero drift from immutable new-project v0.16.1, then used Goal's + governed commit-only delivery to open PR #103 at candidate `d811356`; no + package, version, tag or release effect occurred. + +## 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-062/changelog.md b/project/ticket-062/changelog.md new file mode 100644 index 0000000..f4a494c --- /dev/null +++ b/project/ticket-062/changelog.md @@ -0,0 +1,12 @@ +# Ticket Changelog (ticket-062) + +## [0.1.0] - 2026-08-13 + +- Initial governance scaffold created. +- No human participant identity or content was generated. +- Bounded the repair to static setup-call version parsing, targeted + synchronization and regression tests. +- Added safe literal reading and source-span rewriting for imported + setuptools/distutils setup calls. +- Routed legacy setup.py synchronization through the strict writer and added + regressions for aliases, multiline calls and unrelated version keywords. diff --git a/project/ticket-062/intent.json b/project/ticket-062/intent.json new file mode 100644 index 0000000..93c1905 --- /dev/null +++ b/project/ticket-062/intent.json @@ -0,0 +1,100 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-062", + "summary": "Read setuptools setup() version declarations", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "regression" + }, + "allowedPaths": [ + "goal/cli/version_state.py", + "goal/cli/version_sync.py", + "tests/test_version_state.py", + "tests/test_version_sync.py", + "project/ticket-062/**", + "TODO.md", + "project/TICKETS.md" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md", + ".github/**", + "pyproject.toml", + "uv.lock", + "VERSION", + "goal/__init__.py", + ".env", + "**/*.pem", + "**/*secret*" + ], + "stacks": ["python", "docker"], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "e972ff21dbb58f64603f0efe233b98dba24e4bfc", + "targetBranch": "main", + "outcome": "Goal reads and safely synchronizes standard setuptools setup() version literals", + "nonGoals": [ + "No setup.py execution or import", + "No support for dynamically computed setup() versions", + "No dependency, release metadata or publication change" + ], + "complexity": "S", + "estimatedMinutes": 20, + "budgets": { + "maxImplementationFiles": 4, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Use Python AST locations to identify and rewrite only literal version keywords on imported setuptools or distutils setup calls", + "components": [ + { + "name": "version-state", + "paths": [ + "goal/cli/version_state.py", + "goal/cli/version_sync.py", + "tests/test_version_state.py", + "tests/test_version_sync.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": {"impact": "none", "states": [], "evidence": []}, + "rollback": "Revert the bounded parser/writer changes; module-level declarations remain the prior workaround" + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-02", + "commands": [ + "pytest -q tests/test_version_state.py tests/test_version_sync.py", + "ruff check goal/cli/version_state.py goal/cli/version_sync.py tests/test_version_state.py tests/test_version_sync.py" + ], + "evidence": "project/ticket-062/ai-codex-logs.txt" + }, + { + "criterion": "AC-03", + "commands": [ + "pytest -q tests/test_version_state.py tests/test_version_sync.py" + ], + "evidence": "project/ticket-062/ai-codex-logs.txt" + }, + { + "criterion": "AC-04", + "commands": [ + "pytest -q", + "./project/governance-check.sh", + "docker build --network none ." + ], + "evidence": "project/ticket-062/ai-codex-logs.txt" + } + ] + } +} diff --git a/project/ticket-062/preprompt.md b/project/ticket-062/preprompt.md new file mode 100644 index 0000000..b7f9117 --- /dev/null +++ b/project/ticket-062/preprompt.md @@ -0,0 +1,12 @@ +# Ticket preprompt + +- **Task ID**: ticket-062 +- **Task title**: Read setuptools setup() version declarations +- **Created**: 2026-08-13T10:44:54Z + +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_version_state.py b/tests/test_version_state.py index 2f41205..212db6c 100644 --- a/tests/test_version_state.py +++ b/tests/test_version_state.py @@ -8,6 +8,7 @@ from goal.cli.version_state import ( VersionStateError, collect_version_sources, + read_version_source, resolve_version_decision, validate_version_sources, write_version_source, @@ -84,6 +85,33 @@ def test_complete_local_prebump_is_not_bumped_twice(tmp_path, monkeypatch): assert decision.stale_sources == () +def test_setup_py_keyword_version_is_resolved_and_written_safely( + tmp_path, monkeypatch +): + config = _init_release(tmp_path, "1.2.3") + (tmp_path / "setup.py").write_text( + "import setuptools as packaging\n" + 'configure(version="9.9.9")\n' + "packaging.setup(\n" + ' name="fixture",\n' + " version='1.2.3',\n" + ")\n" + ) + config["versioning"]["files"].append("setup.py:version") + monkeypatch.chdir(tmp_path) + + decision = resolve_version_decision(config=config, registry_versions={}) + + setup_source = read_version_source("setup.py:version") + assert setup_source.value == "1.2.3" + assert setup_source.error is None + assert decision.target_version == "1.2.4" + assert write_version_source("setup.py:version", "1.2.4") + content = (tmp_path / "setup.py").read_text() + assert 'configure(version="9.9.9")' in content + assert "version='1.2.4'" in content + + def test_partial_local_prebump_repairs_forward(tmp_path, monkeypatch): config = _init_release(tmp_path, "1.2.3") (tmp_path / "VERSION").write_text("1.2.4\n") diff --git a/tests/test_version_sync.py b/tests/test_version_sync.py index 431f0f6..fa98787 100644 --- a/tests/test_version_sync.py +++ b/tests/test_version_sync.py @@ -135,7 +135,12 @@ def test_sync_updates_setup_py_version(tmp_path, monkeypatch): (tmp_path / "VERSION").write_text("4.0.0\n") (tmp_path / "setup.py").write_text( 'from setuptools import setup\n' - 'setup(name="tellm", version="4.0.0", packages=["tellm"])\n' + 'configure(version="9.9.9")\n' + "setup(\n" + ' name="tellm",\n' + ' version="4.0.0",\n' + ' packages=["tellm"],\n' + ")\n" ) func = sync_all_versions @@ -145,7 +150,9 @@ def test_sync_updates_setup_py_version(tmp_path, monkeypatch): updated = func("4.0.1") assert "setup.py" in updated - assert 'version="4.0.1"' in (tmp_path / "setup.py").read_text() + content = (tmp_path / "setup.py").read_text() + assert 'configure(version="9.9.9")' in content + assert 'version="4.0.1"' in content def test_sync_updates_uv_lock_after_pyproject_version_change(tmp_path, monkeypatch):