easy-coding-harness 1.0.1 → 1.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,305 @@
1
+ """Scoped evidence inputs. Caches live for one state operation, never across edits."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import shlex
8
+ import shutil
9
+ import stat
10
+ import subprocess
11
+ import sys
12
+ import xml.etree.ElementTree as ET
13
+ from contextlib import contextmanager
14
+ from contextvars import ContextVar
15
+ from pathlib import Path
16
+
17
+
18
+ _operation = ContextVar("evidence_operation", default=None)
19
+ BUILD_FILES = ("pom.xml", "package.json", "package-lock.json", "pnpm-lock.yaml",
20
+ "yarn.lock", "tsconfig.json", "build.gradle", "build.gradle.kts",
21
+ "settings.gradle", "gradle.properties", ".gitattributes", ".npmrc",
22
+ "vitest.config.ts", "jest.config.js", "biome.json", "pytest.ini", "pyproject.toml")
23
+
24
+
25
+ @contextmanager
26
+ def evidence_operation():
27
+ token = _operation.set({})
28
+ try:
29
+ yield
30
+ finally:
31
+ _operation.reset(token)
32
+
33
+
34
+ def memo(key, compute):
35
+ cache = _operation.get()
36
+ if cache is None:
37
+ return compute()
38
+ if key not in cache:
39
+ cache[key] = compute()
40
+ return cache[key]
41
+
42
+
43
+ def digest(value):
44
+ return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False,
45
+ separators=(",", ":")).encode()).hexdigest()
46
+
47
+
48
+ def git(root, *args):
49
+ result = subprocess.run(["git", "-C", str(root), *args], stdout=subprocess.PIPE,
50
+ stderr=subprocess.PIPE, check=False)
51
+ if result.returncode:
52
+ raise ValueError(result.stderr.decode("utf-8", "replace").strip())
53
+ return result.stdout
54
+
55
+
56
+ class RepositoryInputs:
57
+ def __init__(self, root):
58
+ self.root = root
59
+ self.content = {}
60
+ self.index = {}
61
+ self.unmerged = set()
62
+ try:
63
+ raw = git(root, "ls-files", "--stage", "-z")
64
+ except ValueError:
65
+ # Non-Git fixtures and newly initialized projects use the explicit input paths.
66
+ self.paths = set()
67
+ self.dirty = set()
68
+ self.is_git = False
69
+ return
70
+ self.is_git = True
71
+ for entry in raw.split(b"\0"):
72
+ if not entry:
73
+ continue
74
+ metadata, name = entry.split(b"\t", 1)
75
+ mode, oid, stage = metadata.split()
76
+ if stage != b"0":
77
+ self.unmerged.add(os.fsdecode(name))
78
+ self.index[os.fsdecode(name)] = (mode.decode(), oid.decode())
79
+ self.dirty = set(map(os.fsdecode, filter(None, git(
80
+ root, "diff-files", "--relative", "--name-only", "-z").split(b"\0"))))
81
+ untracked = set(map(os.fsdecode, filter(None, git(
82
+ root, "ls-files", "--others", "--exclude-standard", "-z").split(b"\0"))))
83
+ self.paths = set(self.index) | untracked
84
+ self.dirty.update(untracked)
85
+
86
+ def capture(self, scopes, production_only=False):
87
+ names = set()
88
+ for scope in scopes:
89
+ path = self.root / scope
90
+ if path.is_dir():
91
+ names.update(n for n in self.paths if n.startswith(scope.rstrip("/") + "/"))
92
+ if not self.is_git:
93
+ names.update(p.relative_to(self.root).as_posix() for p in path.rglob("*")
94
+ if p.is_file() and "__pycache__" not in p.parts)
95
+ else:
96
+ names.add(scope)
97
+ if production_only:
98
+ names = {n for n in names if not is_test(n)}
99
+ if names & self.unmerged:
100
+ raise ValueError("Resolve unmerged check inputs: " + ", ".join(sorted(names & self.unmerged)))
101
+ pending = []
102
+ for name in sorted(names):
103
+ if name in self.content:
104
+ continue
105
+ path = self.root / name
106
+ if name in self.index and name not in self.dirty:
107
+ mode, oid = self.index[name]
108
+ if mode == "160000":
109
+ raise ValueError("Declare the checked-out submodule as an input repository: " + name)
110
+ self.content[name] = [mode, oid]
111
+ elif path.is_symlink():
112
+ content = os.fsencode(os.readlink(path))
113
+ algorithm = "sha256" if any(len(oid) == 64 for _, oid in self.index.values()) else "sha1"
114
+ oid = hashlib.new(algorithm, b"blob " + str(len(content)).encode() + b"\0" + content).hexdigest()
115
+ self.content[name] = ["120000", oid]
116
+ elif path.is_file():
117
+ pending.append(name)
118
+ else:
119
+ self.content[name] = None
120
+ if pending:
121
+ # Batch Git's filtered blob hashing; no subprocess per changed file.
122
+ if self.is_git:
123
+ values = git(self.root, "hash-object", "--", *pending).decode().splitlines()
124
+ else:
125
+ values = [hashlib.sha256((self.root / n).read_bytes()).hexdigest() for n in pending]
126
+ for name, oid in zip(pending, values):
127
+ executable = (self.root / name).stat().st_mode & stat.S_IXUSR
128
+ self.content[name] = ["100755" if executable else "100644", oid]
129
+ return {name: self.content[name] for name in sorted(names)}
130
+
131
+
132
+ def is_test(path):
133
+ return any(p in {"test", "tests", "__tests__"} for p in Path(path).parts) or is_test_case(path)
134
+
135
+
136
+ def is_test_case(path):
137
+ name = Path(path).name
138
+ return bool(re.search(r"(?:Test|Tests|IT)\.java$|\.(?:test|spec)\.[cm]?[jt]sx?$|^test_.*\.py$", name))
139
+
140
+
141
+ def command_tokens(command):
142
+ tokens = shlex.split(command)
143
+ if tokens and tokens[0] == "env":
144
+ tokens = tokens[1:]
145
+ while tokens and re.match(r"^[A-Za-z_][A-Za-z_0-9]*=", tokens[0]):
146
+ tokens = tokens[1:]
147
+ return tokens
148
+
149
+
150
+ def toolchain_identity(command):
151
+ tokens = command_tokens(command)
152
+ executable = tokens[0] if tokens else ""
153
+ resolved = shutil.which(executable) if executable else None
154
+ metadata = Path(resolved).stat() if resolved else None
155
+ return {
156
+ "executable": resolved,
157
+ "binary": [metadata.st_size, metadata.st_mtime_ns] if metadata else None,
158
+ "python": sys.version,
159
+ "environment": {key: os.environ.get(key) for key in (
160
+ "JAVA_HOME", "JAVA_TOOL_OPTIONS", "MAVEN_OPTS", "NODE_OPTIONS", "PATH", "GRADLE_USER_HOME"
161
+ )},
162
+ }
163
+
164
+
165
+ def maven_modules(root):
166
+ modules = {}
167
+ def visit(directory):
168
+ pom = directory / "pom.xml"
169
+ if not pom.is_file():
170
+ return
171
+ try:
172
+ tree = ET.fromstring(pom.read_bytes())
173
+ except ET.ParseError:
174
+ # The POM bytes still bind the check; Maven reports malformed build input.
175
+ modules[directory] = (None, [])
176
+ return
177
+ ns = "{http://maven.apache.org/POM/4.0.0}" if tree.tag.startswith("{") else ""
178
+ artifact = tree.findtext(ns + "artifactId")
179
+ dependencies = [d.findtext(ns + "artifactId") for d in tree.findall(
180
+ f"{ns}dependencies/{ns}dependency")]
181
+ modules[directory] = (artifact, dependencies)
182
+ for child in tree.findall(f"{ns}modules/{ns}module"):
183
+ visit((directory / child.text).resolve())
184
+ visit(root)
185
+ return modules
186
+
187
+
188
+ def input_spec(root, task, plan, check):
189
+ units = plan.get("units", [])
190
+ by_id = {u["id"]: u for u in units}
191
+ selected = [u for u in units if
192
+ (not check.get("unit_id") or u["id"] == check["unit_id"]) and
193
+ (not check.get("source_task_id") or u.get("source_task_id") == check["source_task_id"])]
194
+ if not selected:
195
+ raise ValueError("Check must belong to an existing implementation Unit.")
196
+ owners = list(selected)
197
+ for unit in selected:
198
+ for dep in unit.get("depends_on", []):
199
+ if dep in by_id and by_id[dep] not in selected:
200
+ selected.append(by_id[dep])
201
+ command = check.get("command", "")
202
+ tokens = command_tokens(command)
203
+ executable = Path(tokens[0]).name if tokens else ""
204
+ builds_module = check.get("type") == "verify" and executable in {
205
+ "mvn", "mvnw", "gradle", "gradlew", "npm", "npx", "pnpm", "yarn", "tsc"
206
+ }
207
+ repositories = {}
208
+ for unit in selected:
209
+ repo_id = unit.get("repo_id") or "current"
210
+ base = Path(task.get("repo_paths", {}).get(repo_id, root))
211
+ base = (base if base.is_absolute() else root / base).resolve()
212
+ paths = repositories.setdefault(str(base), set())
213
+ for name in [*unit.get("files", []), *unit.get("input_files", [])]:
214
+ absolute = Path(name) if Path(name).is_absolute() else base / name
215
+ relative = absolute.relative_to(base).as_posix()
216
+ if ".." in Path(relative).parts:
217
+ raise ValueError("Input path escapes repository: " + name)
218
+ paths.add(relative)
219
+ directory = absolute if absolute.is_dir() else absolute.parent
220
+ while directory != base and not any((directory / marker).is_file() for marker in ("pom.xml", "package.json", "build.gradle", "build.gradle.kts")):
221
+ directory = directory.parent
222
+ modules = memo(("maven", str(base)), lambda: maven_modules(base))
223
+ involved = {directory}
224
+ # A reactor-wide command consumes every module, even when attributed to one Unit.
225
+ if executable in {"mvn", "mvnw"}:
226
+ selector = next((t.split("=", 1)[1] for t in tokens if t.startswith("--projects=")), None)
227
+ for flag in ("-pl", "--projects"):
228
+ if flag in tokens and tokens.index(flag) + 1 < len(tokens):
229
+ selector = tokens[tokens.index(flag) + 1]
230
+ if selector is None:
231
+ involved.update(modules)
232
+ else:
233
+ selected_modules = set(selector.split(","))
234
+ involved.update(m for m, (artifact, _) in modules.items()
235
+ if m.relative_to(base).as_posix() in selected_modules
236
+ or f":{artifact}" in selected_modules)
237
+ pending = list(involved)
238
+ for module in pending:
239
+ for dep in modules.get(module, (None, []))[1]:
240
+ for candidate, (artifact, _) in modules.items():
241
+ if artifact == dep and candidate not in involved:
242
+ involved.add(candidate)
243
+ pending.append(candidate)
244
+ for module in involved:
245
+ for folder in ("src/main", "src" if not (module / "pom.xml").exists() else "src/test"):
246
+ if (builds_module or "input_files" not in unit) and (module / folder).is_dir():
247
+ paths.add((module / folder).relative_to(base).as_posix())
248
+ for parent in [module, *module.parents]:
249
+ if not parent.is_relative_to(base):
250
+ break
251
+ for filename in BUILD_FILES:
252
+ if (parent / filename).is_file():
253
+ paths.add((parent / filename).relative_to(base).as_posix())
254
+ for config in (".mvn", "gradle", "test" if builds_module else "test/fixtures",
255
+ "tests" if builds_module else "tests/fixtures"):
256
+ if (base / config).is_dir():
257
+ paths.add(config)
258
+ if task.get("tdd_enabled") is True:
259
+ for directory in {str(root.resolve()), *repositories}:
260
+ manifest = Path(directory) / ".easy-coding/tdd/readiness.json"
261
+ if manifest.is_file():
262
+ readiness = json.loads(manifest.read_text())
263
+ paths = repositories.setdefault(directory, set())
264
+ for field in ("build_files", "tool_files"):
265
+ paths.update(record["path"] for record in readiness.get(field, []))
266
+ return {
267
+ "schema": 1,
268
+ "repositories": {r: sorted(paths) for r, paths in sorted(repositories.items())},
269
+ "production_only": check.get("review_scope") == "production",
270
+ "contract": [{"id": u["id"], "contracts": u.get("contracts", []),
271
+ "acceptance_criteria": u.get("acceptance_criteria", [])} for u in owners]
272
+ if check.get("type") == "review" else [],
273
+ "command": shlex.split(command),
274
+ "environment": check.get("environment", {}),
275
+ "toolchain": toolchain_identity(command) if check.get("type") == "verify" else {},
276
+ }
277
+
278
+
279
+ def capture(spec):
280
+ inputs = {}
281
+ for root, scopes in spec["repositories"].items():
282
+ repository = memo(("repository", root), lambda: RepositoryInputs(Path(root)))
283
+ inputs[root] = repository.capture(scopes, spec["production_only"])
284
+ return {"spec": spec, "files": inputs, "signature": digest([spec, inputs])}
285
+
286
+
287
+ def changed_inputs(before, after):
288
+ changes = []
289
+ for repo in sorted(set(before["files"]) | set(after["files"])):
290
+ old, new = before["files"].get(repo, {}), after["files"].get(repo, {})
291
+ changes.extend(f"{repo}:{p}" for p in sorted(set(old) | set(new)) if old.get(p) != new.get(p))
292
+ if before["spec"] != after["spec"]:
293
+ changes.append("check command, contract, configuration, or input scope changed")
294
+ return changes
295
+
296
+
297
+ def command_covers(executed, required):
298
+ """A grouped Maven test command proves each selector; other arguments stay exact."""
299
+ actual, expected = shlex.split(executed), shlex.split(required)
300
+ a = [t for t in actual if t.startswith("-Dtest=")]
301
+ b = [t for t in expected if t.startswith("-Dtest=")]
302
+ if len(a) != 1 or len(b) != 1:
303
+ return actual == expected
304
+ return ([t for t in actual if t not in a] == [t for t in expected if t not in b]
305
+ and set(b[0][7:].split(",")) <= set(a[0][7:].split(",")))