Files
Agent-System-Dev/scripts/workspace/go.py
T
weichen b450e51c40 Add safe component editing and explicit legacy workspace transition
Why: Preserve a single Dev entry and independent repository ownership while allowing active feature branches to migrate without losing source or Git state.

Evidence: SELF 29 real Git/Go workspace tests and ordinary component evidence/lock tests passed; detailed RED/GREEN runs are recorded by make evidence in the task card.

Impact: Add fixed cloning, selected-version baselines, development overlays, npm source resolution, and independently preserved legacy snapshots. No other active worktree is modified.

Refs: Versioned dependencies U2/U5; R15; feature/dependency-workspace
2026-10-13 15:58:23 +00:00

154 lines
8.9 KiB
Python

import json
from pathlib import Path
import shutil
import tempfile
from .common import WorkspaceError, atomic_write, digest, run, safe_path
from .manifest import SHA
class Go:
def __init__(self, workspace):
self.workspace = workspace
def call(self, path, *args):
result = run(["go", *args], path, self.workspace.env)
try:
return json.loads(result.stdout)
except ValueError as error:
raise WorkspaceError("Go returned invalid module metadata") from error
def modules(self, component):
result = []
base = safe_path(self.workspace.root, component["path"])
for module in component.get("modules", []):
directory = safe_path(base, module.get("directory", "."), allow_dot=True)
if (directory / "go.mod").is_file():
document = self.call(directory, "mod", "edit", "-json")
if document["Module"]["Path"] not in (module["path"], module.get("legacy")):
raise WorkspaceError(f"module identity mismatch at {directory}")
result.append((module, directory, document))
return result
def resolve(self, component):
selected = []
consumers = sorted(self.workspace.manifest.select(), key=lambda c: ("backend" not in c["id"].lower(), c["id"]))
for consumer in consumers:
if consumer["role"] != "application":
continue
for consumer_module, directory, document in self.modules(consumer):
if document["Module"]["Path"] != consumer_module["path"]:
# An old short-name graph still belongs to the old gitlinks.
continue
# Package management can update go.sum even when source merely
# needs inspection. Resolve an identical isolated manifest so
# both editable and historical consumers stay untouched.
with tempfile.TemporaryDirectory(prefix="workspace-go-resolve-") as temporary:
isolated = Path(temporary)
shutil.copy2(directory / "go.mod", isolated / "go.mod")
if (directory / "go.sum").exists():
shutil.copy2(directory / "go.sum", isolated / "go.sum")
output = run(["go", "list", "-mod=mod", "-m", "-json", "all"], isolated, self.workspace.env).stdout
graph = {}
decoder = json.JSONDecoder()
while output.strip():
metadata, end = decoder.raw_decode(output.lstrip())
output = output.lstrip()[end:]
graph[metadata["Path"]] = metadata
for module in component.get("modules", []):
metadata = graph.get(module["path"])
if not metadata:
continue
if metadata.get("Replace"):
raise WorkspaceError(f"local replacement cannot establish version baseline: {module['path']}")
version = metadata.get("Version")
if not version:
raise WorkspaceError("Go module has no fixed selected version")
download = self.call(isolated, "mod", "download", "-json", module["path"] + "@" + version)
origin = download.get("Origin") or {}
commit = origin.get("Hash", "")
if origin.get("VCS") != "git" or not SHA.fullmatch(commit):
raise WorkspaceError(f"Go module has no verifiable Origin.Hash: {module['path']}")
if origin.get("URL") != component["repo"]:
# Canonical discovery uses HTTPS; transport maps that
# exact registered source to SSH, without a new identity.
canonical = "https://" + module["path"].split(".git", 1)[0] + ".git"
if origin.get("URL") != canonical:
raise WorkspaceError(f"Go Origin URL does not match approved repository: {module['path']}")
selected.append((commit, {"kind": "go-module", "module": module["path"],
"version": version, "consumer": consumer["id"], "origin": origin}))
if not selected:
return None
if len({entry[0] for entry in selected}) != 1:
raise WorkspaceError(f"consumer/submodule versions select different commits for {component['id']}; owner must choose compatible versions")
commit, source = selected[0]
source = dict(source, consumers=sorted({entry[1]["consumer"] for entry in selected}),
selections=[entry[1] for entry in selected])
return commit, source
def check_owned(self):
path = safe_path(self.workspace.root, "go.work")
recorded = self.workspace.state.data.get("go_work")
if path.exists() and (not recorded or digest(path) != recorded.get("sha256")):
raise WorkspaceError("go.work is not the registered generated overlay; preserve manual changes")
if recorded and not path.exists():
raise WorkspaceError("registered go.work is missing; owner must resolve overlay state")
def update(self):
self.check_owned()
paths = set()
linked_modules = set()
cached = {}
def modules(component):
if component["id"] not in cached:
cached[component["id"]] = self.modules(component)
return cached[component["id"]]
for name, entry in self.workspace.state.data["components"].items():
if entry.get("linked"):
for _, directory, document in modules(self.workspace.manifest.components[name]):
paths.add(directory)
linked_modules.add(document["Module"]["Path"])
for consumer in entry.get("source", {}).get("consumers", []):
for _, directory, document in modules(self.workspace.manifest.components[consumer]):
paths.add(directory)
linked_modules.add(document["Module"]["Path"])
if linked_modules:
# Include only consuming modules, including transitive local consumers.
changed = True
while changed:
changed = False
for component in self.workspace.manifest.select():
for _, directory, document in modules(component):
required = {item["Path"] for item in document.get("Require") or []}
if required & linked_modules and directory not in paths:
paths.add(directory)
linked_modules.add(document["Module"]["Path"])
changed = True
path = safe_path(self.workspace.root, "go.work")
if not paths:
if path.exists():
path.unlink()
self.workspace.state.data.pop("go_work", None)
return
version = run(["go", "env", "GOVERSION"], self.workspace.root, self.workspace.env).stdout.strip().removeprefix("go")
content = "go " + version + "\n\nuse (\n" + "".join("\t" + json.dumps("./" + str(item.relative_to(self.workspace.root))) + "\n" for item in sorted(paths)) + ")\n"
documents = [(directory, document) for rows in cached.values() for _, directory, document in rows if directory in paths]
directories = {document["Module"]["Path"]: directory for directory, document in documents}
aliases = {entry["Old"]["Path"] for _, document in documents for entry in document.get("Replace") or []
if not entry["New"].get("Version") and entry["Old"]["Path"] in directories}
versions = {(entry["Path"], entry["Version"]) for _, document in documents for entry in document.get("Require") or []
if entry["Path"] in aliases}
versions.update((entry["Old"]["Path"], entry["Old"]["Version"]) for _, document in documents
for entry in document.get("Replace") or []
if entry["Old"]["Path"] in aliases and entry["Old"].get("Version"))
# Historical modules can replace the same library with different nested
# paths. Exact-version local overrides reconcile those paths while Go
# forbids an all-version replacement of a workspace main module.
if versions:
content += "\nreplace (\n" + "".join("\t" + name + " " + selected + " => " + json.dumps("./" + str(directories[name].relative_to(self.workspace.root))) + "\n"
for name, selected in sorted(versions)) + ")\n"
atomic_write(path, content)
self.workspace.state.data["go_work"] = {"sha256": digest(path), "paths": [str(item.relative_to(self.workspace.root)) for item in sorted(paths)]}