diff --git a/pyproject.toml b/pyproject.toml index 3c36a9a..d274d60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ build-backend = "maturin" # distribution costs users an uninstall/reinstall for no benefit. # Distribution name only: the import is unchanged, `import intentumdiff`. name = "intentumdiff-python" -version = "0.0.1" +version = "0.0.2b1" description = "Semantic code review: detect intent, moves, refactorings, and style changes" readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/provision_build_inputs.py b/scripts/provision_build_inputs.py index d1ba3a0..3541ac0 100644 --- a/scripts/provision_build_inputs.py +++ b/scripts/provision_build_inputs.py @@ -27,7 +27,16 @@ REPO_ROOT = Path(__file__).resolve().parents[1] CORE_REPO = "https://github.com/buchochelliq-labs/intentumdiff-core" -CORE_REF = "main" # pin to a tag once intentumdiff-core cuts releases +# Which intentumdiff-core to build against. +# +# NOT "main". `main` only moves when a release is cut, so a consumer pinned to it cannot verify +# against an unreleased engine change — which is precisely what a release candidate exists to +# allow. That gap is not theoretical: the provenance tests here assert facts added to the core +# and failed in CI while passing locally, because CI was building an engine that predated them. +# +# Tracking a branch does make the build unreproducible, so this becomes a TAG the moment core +# cuts one. Override for a one-off build without editing the file. +CORE_REF = os.environ.get("INTENTUMDIFF_CORE_REF", "release/v0.0.2-rc") CORE_DEST = REPO_ROOT / "build" / "intentumdiff-core" WASM_DEST = REPO_ROOT / "src" / "intentumdiff" / "wasm" 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/cli/_shared.py b/src/intentumdiff/cli/_shared.py index 9589af1..1b90a0d 100644 --- a/src/intentumdiff/cli/_shared.py +++ b/src/intentumdiff/cli/_shared.py @@ -347,14 +347,16 @@ def _render_terminal(diff: SemanticDiff) -> None: ) ) return - scope_label = (diff.staging_status or "working tree").replace("_", " ") header = Table.grid(padding=(0, 2)) header.add_column(style="bold") header.add_column() header.add_row("Old", diff.old_filename or "") header.add_row("New", diff.new_filename or "") header.add_row("Language", diff.language or "unknown") - header.add_row("Scope", scope_label) + # Only a git-backed diff has a staging scope. Defaulting to "working tree" told + # someone comparing two local files that a working tree was involved when none was. + if diff.staging_status: + header.add_row("Scope", diff.staging_status.replace("_", " ")) header.add_row("Changes", str(len(diff.changes))) _console.print( Panel( diff --git a/src/intentumdiff/core/models.py b/src/intentumdiff/core/models.py index 6a3cb99..aea2aa9 100644 --- a/src/intentumdiff/core/models.py +++ b/src/intentumdiff/core/models.py @@ -131,6 +131,11 @@ class NodeFacts(BaseModel, frozen=True): without revealing code. The host carries this through unchanged. """ + #: Which derivation passes produced these facts, e.g. ``"cst,enrich(derived=14,added=5)"``. + #: Populated only when ``INTENTUMDIFF_TRACE_FACTS=1``; ``None`` in normal operation. + #: Diagnostic provenance, never source — safe to paste into a bug report. + facts_trace: str | None = None + param_count: int | None = None #: ``"none"`` | ``"value"`` | ``"literal"`` (constant) | ``"unknown"``. returns: str | None = None diff --git a/src/intentumdiff/differ.py b/src/intentumdiff/differ.py index 54e346d..ed1a1b9 100644 --- a/src/intentumdiff/differ.py +++ b/src/intentumdiff/differ.py @@ -491,13 +491,20 @@ def diff(self, source: Source) -> SemanticDiff: profiler = self._new_profiler() with profiler.phase("source_loading"): old_content, new_content, filename, language_hint = source.get_content() - return self._run_pipeline( + diff = self._run_pipeline( old_content, new_content, filename, language_hint, _profiler=profiler, ) + # The pipeline works from the single filename language detection needs. A source + # comparing two differently named files knows both, so restore them here rather + # than threading a second name through every construction site. + names = source.display_names() + if names is not None: + diff = diff.model_copy(update={"old_filename": names[0], "new_filename": names[1]}) + return diff def diff_strings( self, diff --git a/src/intentumdiff/live_server.py b/src/intentumdiff/live_server.py index e8a7c27..33cef67 100644 --- a/src/intentumdiff/live_server.py +++ b/src/intentumdiff/live_server.py @@ -1047,9 +1047,40 @@ def _handle_control_request( if op == "cancel": send_fn(self._cancel_request(request, seq)) return True - send_fn(self._error_response(seq, "unknown_op", f"unknown op: {op!r}", op=str(op))) + if op == "asset_diff": + send_fn(self._asset_diff_request(request, seq)) + return True + # `invalid_op`, not `unknown_op`: the native server has always answered an unrouted op + # with that code, and it is the spelling the extension lists as non-retryable — so the + # python server's own spelling made the extension auto-retry a request that can never + # succeed. Two servers on one protocol have to name the same failure the same way. + send_fn(self._error_response(seq, "invalid_op", f"unknown op: {op!r}", op=str(op))) return True + def _asset_diff_request(self, request: dict[str, Any], seq: int) -> dict[str, Any]: + """Perceptual image diff — the engine does the pixel work, this only marshals the request. + + The extension previously synthesised an asset "review" and never asked for artifacts, + so the panel always reported PERCEPTUAL DIFF PENDING while the engine sat there able + to answer. This is the missing call. + + Two request shapes, both engine-served: + + * ``path`` (+ optional ``ref``) — one tracked image against a git ref. This is what a + reviewing editor has: a repo-relative path and a base. The *before* bytes are in the + object store, and materialising them is git work, so the engine does it. + * ``before_path`` + ``after_path`` — two files that already exist on disk. + + Request parsing, the path-containment guard and the self-ignoring artifact cache live in + the core's ``live_handle_asset_diff``, so this server and the native live-server binary + answer from ONE implementation. A second copy here would be a second place for the + containment rule to drift — and the rule is what stops a protocol method that accepts + file paths from becoming an arbitrary-file reader. + """ + from intentumdiff import rust_core + + return rust_core.live_handle_asset_diff(self._repo_path, self._ref, request, seq) + def _process_review_request( self, request: dict[str, Any], diff --git a/src/intentumdiff/plugins/loader.py b/src/intentumdiff/plugins/loader.py index 85eb39e..966b8aa 100644 --- a/src/intentumdiff/plugins/loader.py +++ b/src/intentumdiff/plugins/loader.py @@ -40,6 +40,9 @@ ) logger = logging.getLogger(__name__) + +# Emitted once per process, not once per plugin load: there are 78 plugins. +_OVERRIDE_WARNED: bool = False _MAX_TELEMETRY_RECORDS = 128 # --------------------------------------------------------------------------- @@ -404,13 +407,24 @@ def _check_osv_cache_or_block( # Stamp is fresh but no cache file: the last fetch failed. override = os.environ.get("INTENTUMDIFF_ALLOW_VULNERABLE_WASMTIME", "").strip() if override in ("1", "true", "yes"): - logger.warning( - "INTENTUMDIFF_ALLOW_VULNERABLE_WASMTIME: loading plugins with " - "unverified OSV status — last advisory fetch failed." - ) + # Worth saying — a safety check has been deliberately disabled — but worth + # saying ONCE. Emitting it per plugin load repeated it up to 78 times a run. + global _OVERRIDE_WARNED + if not _OVERRIDE_WARNED: + _OVERRIDE_WARNED = True + logger.warning( + "INTENTUMDIFF_ALLOW_VULNERABLE_WASMTIME: loading plugins with " + "unverified OSV status — last advisory fetch failed." + ) return if trusted or _is_trusted_wasm_path(wasm_path): - logger.warning( + # DEBUG, not WARNING. This fires on every ordinary run whenever the machine + # is offline or the advisory cache has expired, and there is nothing for the + # user to do about it: first-party plugins ship inside the wheel and + # third-party ones stay blocked either way. Emitting it at warning level put + # alarming text — naming a "allow vulnerable" override — on stderr beside + # correct results, which is precisely how 0.0.1 came to look broken. + logger.debug( "Loading first-party trusted Wasm plugin with unverified OSV " "status because the last advisory fetch failed. Third-party " "plugins remain blocked without INTENTUMDIFF_ALLOW_VULNERABLE_WASMTIME=1." 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. diff --git a/src/intentumdiff/rust_core.py b/src/intentumdiff/rust_core.py index 0a6608b..d5c6ade 100644 --- a/src/intentumdiff/rust_core.py +++ b/src/intentumdiff/rust_core.py @@ -55,7 +55,38 @@ _SOURCE_ENGINE = "rust_core_sources_v3_stage11" +#: Distributions that ship the FIRST-PARTY Python parser. The plugin id is qualified by the +#: distribution name, and this package is published as ``intentumdiff-python`` while the import +#: package is ``intentumdiff`` — so the same certified parser is spelled two ways depending on +#: how it was catalogued. Deliberately an allowlist, not a prefix match: canonicalising an +#: arbitrary ``:python:python`` would let a third-party plugin claim the certified path. +_PYTHON_PLUGIN_DISTRIBUTIONS = frozenset( + {"intentumdiff", "intentumdiff-python", "intentumdiff_python"} +) + + def _canonical_python_plugin_id(parser_plugin_id: str | None) -> str | None: + """Normalise a first-party Python parser id to the certified spelling. + + This was a no-op returning its argument, which meant the certified batch path rejected + the parser as an "unsupported parser plugin" and silently fell through to routed + finalize. The diff was still correct and still Rust — so no gate fired and nothing looked + broken — but the certified path was never taken, and the facts that only it derives went + missing. + + Invisible until parser plugins could be catalogued at all: before that the id was absent + and the ``is not None`` guard let everything through. + """ + if not parser_plugin_id: + return parser_plugin_id + parts = parser_plugin_id.split(":") + if ( + len(parts) == 3 + and parts[0] in _PYTHON_PLUGIN_DISTRIBUTIONS + and parts[1] == "python" + and parts[2] == "python" + ): + return _CANONICAL_PYTHON_PLUGIN_ID return parser_plugin_id @@ -1355,6 +1386,23 @@ def live_handle_review( return json.loads(raw) +def live_handle_asset_diff( + repo_path: str, default_ref: str, request: dict[str, Any], seq: int +) -> dict[str, Any]: + """Native perceptual asset diff: the whole protocol response for op ``asset_diff``. + + Unlike the other ``live_*`` handlers this returns a finished response envelope — success or + error — because a malformed request is an answer to send, not a call that failed. Request + parsing, path containment and the artifact cache all live in the core, so the Python server + and the native binary answer identically. + """ + return json.loads( + _load_backend().live_handle_asset_diff_json( + repo_path, default_ref, json.dumps(request, separators=(",", ":")), int(seq) + ) + ) + + _registered_xml_dialects_fingerprint: str | None = None diff --git a/src/intentumdiff/sources/base.py b/src/intentumdiff/sources/base.py index 4c6b1d8..1e354f6 100644 --- a/src/intentumdiff/sources/base.py +++ b/src/intentumdiff/sources/base.py @@ -26,3 +26,18 @@ def get_content(self) -> tuple[str, str, str, str | None]: ``language_hint`` — e.g. "python", "sql". Pass None to auto-detect. """ ... + + def display_names(self) -> tuple[str, str] | None: + """ + Distinct ``(old, new)`` names, when the two sides are named differently. + + ``get_content`` returns ONE filename because that is what language detection + needs, and for most sources it is also the right thing to display: a git diff + compares one path at two revisions, so both sides share a name. + + ``FileSource`` is the exception — it compares two separately named files — and + collapsing both sides onto the new name made the CLI report that it had diffed + a file with itself. Sources that know two names override this; ``None`` means + "one name applies to both", which stays the default. + """ + return None diff --git a/src/intentumdiff/sources/file_source.py b/src/intentumdiff/sources/file_source.py index 4e96f6a..9dea41d 100644 --- a/src/intentumdiff/sources/file_source.py +++ b/src/intentumdiff/sources/file_source.py @@ -46,3 +46,12 @@ def get_content(self) -> tuple[str, str, str, str | None]: old_content = self._old_path.read_text(encoding="utf-8", errors="replace") new_content = self._new_path.read_text(encoding="utf-8", errors="replace") return old_content, new_content, self._filename, self._language_hint + + def display_names(self) -> tuple[str, str] | None: + # This source is the one that genuinely has two names. An explicit ``filename`` + # overrides both, and identical basenames need no distinction. + if self._filename != self._new_path.name: + return None + if self._old_path.name == self._new_path.name: + return None + return self._old_path.name, self._new_path.name diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index e234278..ddfbe07 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -17,6 +17,8 @@ from unittest.mock import MagicMock, patch import pytest + +from intentumdiff import __version__ from rich.console import Console from intentumdiff.cli import ( @@ -130,7 +132,7 @@ def test_cli_primary_branding_is_intentumdiff(capsys: pytest.CaptureFixture[str] with pytest.raises(SystemExit): parser.parse_args(["--version"]) - assert "IntentumDiff 0.0.1" in capsys.readouterr().out + assert f"IntentumDiff {__version__}" in capsys.readouterr().out def test_click_main_version_uses_primary_branding( @@ -138,7 +140,7 @@ def test_click_main_version_uses_primary_branding( ) -> None: assert _run("--version") == 0 - assert "IntentumDiff 0.0.1" in capsys.readouterr().out + assert f"IntentumDiff {__version__}" in capsys.readouterr().out def test_click_main_delegates_command_help_to_compatible_parser( diff --git a/tests/unit/test_fact_derivation_provenance.py b/tests/unit/test_fact_derivation_provenance.py new file mode 100644 index 0000000..af03b29 --- /dev/null +++ b/tests/unit/test_fact_derivation_provenance.py @@ -0,0 +1,141 @@ +"""The fact-derivation pipeline must actually reach every entity it claims to describe. + +# Why this exists + +Intent facts are derived twice: once from the RAW CST (whose shape depends on which parser +produced it) and once from the NORMALISED SemanticNode (which is parser-independent). The +second pass exists to fill what the first cannot reach. + +For an unknown length of time it filled nothing, and no test could see it: + +- `merge_facts` used `entry(k).or_insert(v)`, which treats a key present with a **null** as + already answered. The CST pass emits a partial bag whose unreachable facts sit as explicit + nulls, so `behavior_category: null` permanently blocked the real value. +- Every fact still had a plausible value, so output looked fine. Only the *absence* of the + richer values gave it away, and nothing asserted on absence. + +Diagnosing it took four full rebuilds, because nothing could answer "which pass ran?". These +tests make that question answerable — and keep it answered. + +They assert PROVENANCE, not values. `test_intent_facts_sufficiency` already covers values; +this covers the pipeline that produces them, so a future refactor that silently stops calling +the enrichment pass fails here loudly instead of degrading facts quietly. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import textwrap + +import pytest + +# One class and one function, each with facts that only the normalised pass supplies. +SOURCE = textwrap.dedent( + ''' + from enum import Enum + + class Colour(Enum): + RED = 1 + + def scale(value, factor=2, offset=0): + if value < 0: + return 0 + return value * factor + offset + ''' +).lstrip() + +PROBE = ''' +import json +from intentumdiff import SemanticDiffer + +diff = SemanticDiffer().diff_strings("x = 1\\n", "x = 1\\n" + {src!r}, "m.py") +out = [] +def walk(node): + if node.facts is not None: + out.append({{"type": node.node_type, "trace": node.facts.facts_trace}}) + for child in node.children: + walk(child) +for change in diff.changes: + node = getattr(change, "new_node", None) + if node is not None: + walk(node) +print("PROVENANCE" + json.dumps(out)) +''' + + +def _traces() -> list[dict[str, str | None]]: + """Run a diff in a subprocess with tracing on. + + A subprocess because the flag is read once and cached for the process lifetime — setting + it after the engine has loaded would silently do nothing, which is exactly the class of + bug this file exists to catch. + """ + env = {**os.environ, "INTENTUMDIFF_TRACE_FACTS": "1"} + result = subprocess.run( + [sys.executable, "-c", PROBE.format(src=SOURCE)], + capture_output=True, text=True, encoding="utf-8", errors="replace", + env=env, timeout=300, + ) + assert result.returncode == 0, f"probe failed: {result.stderr[-800:]}" + line = next( + (ln for ln in result.stdout.splitlines() if ln.startswith("PROVENANCE")), None + ) + assert line is not None, f"probe produced no provenance: {result.stdout[-500:]}" + import json as _json + + return _json.loads(line[len("PROVENANCE") :]) + + +def test_tracing_is_off_unless_asked_for() -> None: + """The flag must be opt-in: a normal run carries no diagnostic payload.""" + from intentumdiff import SemanticDiffer + + diff = SemanticDiffer().diff_strings("x = 1\n", "x = 1\n" + SOURCE, "m.py") + for change in diff.changes: + node = getattr(change, "new_node", None) + if node is not None and node.facts is not None: + assert node.facts.facts_trace is None, ( + "facts_trace leaked into a normal run; it is diagnostic-only and must not " + "appear unless INTENTUMDIFF_TRACE_FACTS is set." + ) + + +def test_the_normalised_pass_reaches_every_entity_that_has_facts() -> None: + """The gate. + + Every node carrying facts must show the enrichment pass in its provenance. A node whose + trace lacks ``enrich(`` was described entirely by the parser-shape-dependent CST pass, so + whatever that pass could not reach for that parser is silently missing — which is exactly + how `is_enum`, `recursive` and `has_error_handling` disappeared for 77 of 78 languages. + """ + traces = _traces() + assert traces, "no node carried facts; the fixture or the pipeline is broken" + + unreached = [t for t in traces if not (t["trace"] or "").count("enrich(")] + assert not unreached, ( + "the normalised fact pass did not reach these nodes:\n " + + "\n ".join(f"{t['type']}: trace={t['trace']!r}" for t in unreached) + + "\n\nThey are described only by the raw-CST pass, whose coverage varies by parser." + ) + + +@pytest.mark.parametrize("expected", ["cst", "enrich("]) +def test_the_trace_names_the_passes_that_ran(expected: str) -> None: + """Provenance must be legible enough to act on, since users paste it into bug reports.""" + traces = _traces() + assert any(expected in (t["trace"] or "") for t in traces), ( + f"no node reported {expected!r} in its trace; the marker changed or a pass stopped " + f"running. Traces: {[t['trace'] for t in traces][:6]}" + ) + + +def test_the_trace_carries_provenance_only_never_source() -> None: + """It is safe to paste into a bug report — pass names and counts, nothing from the code.""" + traces = _traces() + secrets = ("Colour", "scale", "RED", "factor", "offset", "value") + for entry in traces: + trace = entry["trace"] or "" + leaked = [s for s in secrets if s in trace] + assert not leaked, f"facts_trace leaked identifiers {leaked} in {trace!r}" diff --git a/tests/unit/test_public_package_import.py b/tests/unit/test_public_package_import.py index 0e6df90..c1a4fab 100644 --- a/tests/unit/test_public_package_import.py +++ b/tests/unit/test_public_package_import.py @@ -6,6 +6,8 @@ import textwrap from pathlib import Path +from intentumdiff import __version__ + def test_public_package_import_does_not_require_pytest() -> None: script = textwrap.dedent( @@ -39,4 +41,4 @@ def guarded_import(name, *args, **kwargs): text=True, ) - assert result.stdout.splitlines() == ["0.0.1", "PluginTestHarness"] + assert result.stdout.splitlines() == [__version__, "PluginTestHarness"]