diff --git a/README.md b/README.md index 59daedd..40cf0a3 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,30 @@ does zero functional work. ```python from intentumdiff import SemanticDiffer +old = """def greet(name): + return "hi " + name +""" + +new = """def greet(name): + if not name: + return None + return "hi " + name +""" + diff = SemanticDiffer().diff_strings(old, new, "example.py") for change in diff.changes: print(change.change_type, change.description) ``` +Running that prints one change — the added guard clause: + +```text +ChangeType.ADDITION Insert -> if_statement('if_statement') +``` + +Reformat the same file — change the quotes, rewrap the lines, add blank lines — and +IntentumDiff reports **no changes at all**. That is the point: it compares meaning, not text. + ## Building from source The wheel bundles two provisioned build inputs — the engine and the parser components: diff --git a/scripts/smoke_published_wheel.py b/scripts/smoke_published_wheel.py new file mode 100644 index 0000000..f5163a4 --- /dev/null +++ b/scripts/smoke_published_wheel.py @@ -0,0 +1,171 @@ +"""Install the PUBLISHED wheel into a clean venv and use it as a user would. + +# Why this exists + +IntentumDiff 0.0.1 shipped broken and had to be pulled. Every invocation printed ~69 +"Failed to catalog parser plugin" errors, because the distribution name +(`intentumdiff-python`) was missing from the package's own first-party trust list, so all +69 built-in parsers were refused as untrusted third-party code. + +**No test in the repo could have caught it.** In a source checkout the distribution resolves +differently, so the trust check passed. The bug only exists in an installed wheel — and +nothing in CI ever installed one and ran it. + +That is the gap this closes: CI proves the code builds; this proves the ARTEFACT works. + +# What it checks + +Everything a new user does in their first five minutes, in order: + +1. `pip install` from the index into an empty venv +2. `import intentumdiff` +3. the console script runs +4. `python -m intentumdiff` works +5. a real diff produces a real result +6. **stderr is CLEAN** — the check that would have caught 0.0.1 +7. every URL in the error catalogue actually resolves + +Usage: + python scripts/smoke_published_wheel.py # from PyPI + python scripts/smoke_published_wheel.py --wheel dist/x.whl # a local build, pre-publish +""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +import tempfile +import venv +from pathlib import Path + +# A diff with an obvious right answer: a guard clause is added, so exactly one change. +OLD_SRC = "def greet(name):\n return 'hi ' + name\n" +NEW_SRC = "def greet(name):\n if not name:\n return None\n return 'hi ' + name\n" + +USE_SCRIPT = """ +from intentumdiff import SemanticDiffer +old = {old!r} +new = {new!r} +diff = SemanticDiffer().diff_strings(old, new, "example.py") +print("CHANGES", len(diff.changes)) +""" + + +class Smoke: + def __init__(self, root: Path) -> None: + self.root = root + self.py = ( + root / ".venv" / ("Scripts" if sys.platform == "win32" else "bin") + / ("python.exe" if sys.platform == "win32" else "python") + ) + self.failures: list[str] = [] + + def check(self, name: str, ok: bool, detail: str = "") -> None: + print(f" {'PASS' if ok else 'FAIL'} {name}") + if not ok: + if detail: + print(f" {detail.strip()[:400]}") + self.failures.append(name) + + def run(self, *args: str, **kw) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(self.py), *args], capture_output=True, text=True, + encoding="utf-8", errors="replace", cwd=self.root, **kw + ) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--wheel", help="local wheel or sdist; defaults to installing from PyPI") + ap.add_argument("--package", default="intentumdiff-python") + args = ap.parse_args() + + root = Path(tempfile.mkdtemp(prefix="intentumdiff-smoke-")) + print(f" clean environment: {root}\n") + try: + venv.create(root / ".venv", with_pip=True) + s = Smoke(root) + + # 1. Install exactly as a user would. + target = args.wheel or args.package + r = s.run("-m", "pip", "install", "--quiet", target) + s.check(f"pip install {target}", r.returncode == 0, r.stderr) + if r.returncode != 0: + return report(s) + + # 2. Import. + r = s.run("-c", "import intentumdiff; print(intentumdiff.__version__)") + s.check("import intentumdiff", r.returncode == 0, r.stderr) + version = r.stdout.strip() + + # 3. Console script — the documented entry point. + exe = s.py.parent / ("intentumdiff.exe" if sys.platform == "win32" else "intentumdiff") + r2 = subprocess.run([str(exe), "--version"], capture_output=True, text=True, + encoding="utf-8", errors="replace") + s.check("console script `intentumdiff --version`", r2.returncode == 0, r2.stderr) + + # 4. `python -m` — READMEs reach for this constantly, so it must work. + r = s.run("-m", "intentumdiff", "--version") + s.check("python -m intentumdiff", r.returncode == 0, r.stderr) + + # 5. A real diff with a known-correct answer. + script = root / "use.py" + script.write_text(USE_SCRIPT.format(old=OLD_SRC, new=NEW_SRC), encoding="utf-8") + r = s.run(str(script)) + s.check("SemanticDiffer produces a diff", "CHANGES" in r.stdout, r.stderr) + + # 6. THE ONE THAT MATTERS. 0.0.1 emitted ~69 plugin-catalogue errors on every + # invocation while still returning a result, so exit code alone said "fine". + noise = [ + ln for ln in r.stderr.splitlines() + if ln.strip() and "Failed to catalog" in ln + ] + s.check( + "no plugin-catalogue errors on stderr", + not noise, + f"{len(noise)} error line(s), first: {noise[0] if noise else ''}", + ) + + # 7. Every URL the package tells users to visit must resolve. 0.0.1 pointed at + # docs.intentumdiff.dev, which does not exist. + import re + import urllib.error + import urllib.request + + urls = sorted(set(re.findall(r"https?://[^\s'\"<>)\]]+", r.stderr))) + dead = [] + for u in urls: + try: + req = urllib.request.Request(u, method="HEAD", + headers={"User-Agent": "intentumdiff-smoke"}) + with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status >= 400: + dead.append(f"{u} -> {resp.status}") + except urllib.error.HTTPError as e: + dead.append(f"{u} -> {e.code}") + except Exception as e: # DNS failure counts: the domain does not exist + dead.append(f"{u} -> {type(e).__name__}") + s.check( + f"all {len(urls)} URL(s) in output resolve", + not dead, + "; ".join(dead[:3]), + ) + + print(f"\n version under test: {version or '(unknown)'}") + return report(s) + finally: + shutil.rmtree(root, ignore_errors=True) + + +def report(s: Smoke) -> int: + if s.failures: + print(f"\n SMOKE FAILED: {len(s.failures)} check(s) — {', '.join(s.failures)}") + return 1 + print("\n SMOKE PASSED") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/intentumdiff/__main__.py b/src/intentumdiff/__main__.py new file mode 100644 index 0000000..ef83b92 --- /dev/null +++ b/src/intentumdiff/__main__.py @@ -0,0 +1,12 @@ +"""Enable `python -m intentumdiff`. + +The console script alone is not enough: `python -m` is what people reach for when the +script is not on PATH (a venv they have not activated, CI, a container), and READMEs use +it constantly. 0.0.1 shipped without this module, so the invocation failed with an +unhelpful "cannot be directly executed". +""" + +from intentumdiff.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/intentumdiff/plugins/registry.py b/src/intentumdiff/plugins/registry.py index cfd4bbe..1a8c1b3 100644 --- a/src/intentumdiff/plugins/registry.py +++ b/src/intentumdiff/plugins/registry.py @@ -70,6 +70,18 @@ # trusted plugin metadata. _TRUSTED_PLUGIN_PACKAGE_NAMES: frozenset[str] = frozenset( { + # The DISTRIBUTION name, which is not the import name. The wheel is published as + # `intentumdiff-python` (the polyglot-binding convention) while the import package + # is `intentumdiff`, and entry points report the DISTRIBUTION. + # + # Omitting it made the package fail its own first-party check: all 69 built-in + # parsers were treated as untrusted third-party plugins and refused with a security + # warning naming the package itself as the culprit. Every invocation printed ~69 + # error lines before producing output. Shipped in 0.0.1 and only found by installing + # the published wheel — no test in the repo could see it, because in a source + # checkout the distribution resolves differently. + "intentumdiff-python", + "intentumdiff_python", "intentumdiff", "intentumdiff-dbt", "intentumdiff_dbt", @@ -275,7 +287,7 @@ def _wasm_path_from_ep(ep: importlib.metadata.EntryPoint) -> str: "execute arbitrary code before Wasm sandboxing is in effect. " f"The plugin author must add '{_WASM_PATH_METADATA_FIELD}: ' " "to their package metadata. " - "See: https://docs.intentumdiff.dev/plugins/metadata" + "See: https://github.com/buchochelliq-labs/intentumdiff-python/blob/main/docs/PLUGIN_GUIDE.md" ) # Locate the file via importlib.metadata without importing anything.