Why: Replace repeated submodule source pointers with consumer-owned language versions and fixed application sources, keeping independent repository edits and existing branches intact. Evidence: SELF task receipts contain startup/sync evidence, real Go and npm consumption, Native overlay/unlink, safe transition/ref preservation, credential boundary and historical build regressions. Exact committed full composition and RDM checks follow this checkpoint; formal acceptance and GATE CI remain pending. Impact: Add isolated build/test composition, source provenance and safe owner refresh, adapt governance and service build callers, remove replaced index links while retaining original Git metadata, and adopt reusable dependency standards. Refs: Documents approved dependency-workspace r1/R1-R15 and U1-U7 implementation plan; feature/dependency-workspace task card.
159 lines
9.3 KiB
Python
159 lines
9.3 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 {}
|
|
if not origin and download.get('Info'):
|
|
# Cached downloads may omit Origin in stdout; the
|
|
# Go-owned .info file retains the authenticated source.
|
|
cached = json.loads(Path(download['Info']).read_text())
|
|
origin = cached.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]
|
|
if origin.get("URL") not in {canonical, canonical + '.git', component['repo'].removesuffix('.git')}:
|
|
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)]}
|