Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
117 changes: 117 additions & 0 deletions goal/cli/version_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import ast
import json
import os
import re
Expand Down Expand Up @@ -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<prefix>(?i:[rub]*))(?P<quote>\"\"\"|'''|\"|').*(?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()
Expand Down Expand Up @@ -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"<Version>([^<]+)</Version>",
Expand Down Expand Up @@ -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>"),
Expand Down
11 changes: 1 addition & 10 deletions goal/cli/version_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions project/TICKETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
<!-- AUTO:TICKET_INDEX:END -->
48 changes: 48 additions & 0 deletions project/ticket-062/README.md
Original file line number Diff line number Diff line change
@@ -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)
9 changes: 9 additions & 0 deletions project/ticket-062/ai-codex-logs.txt
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 46 additions & 0 deletions project/ticket-062/ai-codex.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions project/ticket-062/changelog.md
Original file line number Diff line number Diff line change
@@ -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.
Loading