diff --git a/project/ticket-025/README.md b/project/ticket-025/README.md new file mode 100644 index 0000000..b7ab2bb --- /dev/null +++ b/project/ticket-025/README.md @@ -0,0 +1,66 @@ +# Ticket 025: Source-bound cross-project comparison candidates for PLF-20326 + +- **ID**: ticket-025 +- **Owner**: agent:codex +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION +- **Created**: 2026-10-27 + +## Goal and scope + +SESSION_EXECUTION_AUTHORIZATION: user requested the cross-project intent plan +and then explicitly said "wykonaj" on 2026-10-27. Implement RED, Planfile +PLF-20326 / semcod/redup#32. The exact Heyasa campaign was paused with no jobs +or ticket reservations before native allocation; its autonomy label is held +while this single writer works. Other ticket worktrees remain untouched. + +Extend the existing comparator and shared CLI/MCP report with a versioned, +source-bound advisory projection. Reuse scanner blocks and existing matching; +do not add another intent parser, ticket exporter or quality-ranking engine. +Capture scan content, observed Git revision, freshness and explicit source +origins. Missing optional semantic evidence and behavioral verification stay +unknown. Existing Intract integration remains the owner of declared contracts. + +## Acceptance criteria + +- [x] AC-01: Comparison output binds candidates to full source revision where + available, file/content hashes, exact spans, analyzer and scan coverage. +- [x] AC-02: Stable candidate identities survive identical reruns; changed or + missing source and non-Git input are represented explicitly. +- [x] AC-03: Exact, structural and inferred semantic evidence remain distinct; + generated/deployment origins require explicit declarations, not LOC guesses. +- [x] AC-04: Recommendations remain advisory and cannot establish a superior + implementation, compatible effects/license, or behavioral verification. +- [x] AC-05: Regression fixtures cover identical, renamed, unrelated, + generated/deployment, stale and missing optional evidence; existing APIs and + Intract tests remain compatible. No Planfile writes from comparison. +- [ ] AC-06: Managed governance and affected tests pass; publish through the + protected OneDev/Validator path with exact HEAD evidence. + +## Tracking boundary + +This directory contains the minimal reviewed intent. Optional participant prose +and raw command logs are not required delivery output. + +## Validation and publication + +- 45 affected tests passed on Python 3.11.15, including the real sibling + Intract integration, comparator, shared reporter, MCP and CLI import checks. +- The missing-extras run passed 39 tests with 3 optional skips before the last + two regressions were added; explicit missing-engine fixtures remain in the + final passing run. Ruff and diff whitespace checks passed. +- Scanner-relative paths are qualified per project before matching, preventing + equal filenames from collapsing across repository identities. +- Observed Git HEAD is explicitly separate from the hash of possibly dirty + source. Local checkout URI is the available identity; canonical remote + identity enrichment belongs to Diagit/consumers. Inline declarations use the + existing optional Intract matcher; manifests remain the Intract artifact + pipeline's responsibility. Similarity and intent matches cannot prove + behavioral equivalence. +- Native continuity capture currently requires delivery.targetBranch even for + allocator-generated compact intents. A bounded external checkpoint preserves + exact branch/base, source snapshot and allocator lease 1/1; no lease or + managed file was rewritten to hide this tool incompatibility. +- Publication preflight resolved the protected registry and requires hosted + test (3.10) / test (3.13). Deployed OneDev has no redup profile. Protected + review/merge and any missing-profile reconciliation remain outstanding. diff --git a/project/ticket-025/intent.json b/project/ticket-025/intent.json new file mode 100644 index 0000000..aad0301 --- /dev/null +++ b/project/ticket-025/intent.json @@ -0,0 +1,28 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-025", + "summary": "Source-bound cross-project comparison candidates for PLF-20326", + "workstream": "application", + "classification": { + "kind": "FEATURE", + "priority": "P2", + "origin": "requested" + }, + "allowedPaths": [ + "project/ticket-025/**", + "TODO.md", + "project/TICKETS.md", + "src/redup/core/comparator.py", + "src/redup/core/comparison_evidence.py", + "src/redup/reporters/comparison_reporter.py", + "tests/test_compare.py", + "tests/test_comparison_reporter.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null +} diff --git a/src/redup/core/comparator.py b/src/redup/core/comparator.py index ab338a8..95be647 100644 --- a/src/redup/core/comparator.py +++ b/src/redup/core/comparator.py @@ -6,14 +6,15 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path +from redup.core.comparison_evidence import revision, source_observation from redup.core.hasher import hash_block, hash_block_structural from redup.core.lsh_matcher import LSHIndex from redup.core.models import ScanConfig from redup.core.scanner import scan_project -from redup.core.scanner_models import CodeBlock +from redup.core.scanner_models import CodeBlock, ScannedFile @dataclass @@ -30,6 +31,7 @@ class CrossProjectMatch: similarity_type: str # "exact" | "structural" | "lsh" | "semantic" lines_a: tuple[int, int] lines_b: tuple[int, int] + intent_contracts: list[str] = field(default_factory=list) @dataclass @@ -41,6 +43,9 @@ class CrossProjectComparison: matches: list[CrossProjectMatch] = field(default_factory=list) stats_a: dict = field(default_factory=dict) stats_b: dict = field(default_factory=dict) + sources: dict = field(default_factory=dict) + analysis_status: dict = field(default_factory=dict) + intent_matches: list[CrossProjectMatch] = field(default_factory=list) @property def total_matches(self) -> int: @@ -71,6 +76,10 @@ def compare_projects( extensions: list[str] | None = None, min_lines: int = 3, functions_only: bool = True, + *, + origins_a: dict[str, str] | None = None, + origins_b: dict[str, str] | None = None, + use_intent: bool = False, ) -> CrossProjectComparison: """Compare two projects using reDUP's scan engine. @@ -82,15 +91,26 @@ def compare_projects( extensions: File extensions to scan (default: [".py"]). min_lines: Minimum block line count. functions_only: Only compare function-level blocks. + origins_a: Explicit source-origin declarations keyed by relative file path. + origins_b: Corresponding declarations for the second project. Unknown by default. + use_intent: Match inline declarations through optional Intract, separately from LOC. Returns: CrossProjectComparison with all matches and per-project stats. """ exts = extensions or [".py"] + project_a, project_b = project_a.expanduser().resolve(), project_b.expanduser().resolve() + head_a, head_b = revision(project_a), revision(project_b) # Scan each project independently using reDUP - blocks_a, stats_a = _scan_project_blocks(project_a, exts, min_lines, functions_only) - blocks_b, stats_b = _scan_project_blocks(project_b, exts, min_lines, functions_only) + blocks_a, stats_a, files_a = _scan_project_blocks(project_a, exts, min_lines, functions_only) + blocks_b, stats_b, files_b = _scan_project_blocks(project_b, exts, min_lines, functions_only) + sources = { + "a": source_observation(project_a, files_a, blocks_a, head_a, origins_a), + "b": source_observation(project_b, files_b, blocks_b, head_b, origins_b), + } + semantic_status = {"status": "not_requested"} + intent_status = {"status": "not_requested", "scope": "inline_scanned_blocks"} proj_a_str = str(project_a) proj_b_str = str(project_b) @@ -125,6 +145,7 @@ def compare_projects( similarity_threshold, proj_a_str, proj_b_str, + diagnostics=semantic_status, ) for m in sem_matches: key = (m.file_a, m.lines_a, m.file_b, m.lines_b) @@ -132,12 +153,31 @@ def compare_projects( matches.append(m) seen_pairs.add(key) + intent_matches = ( + _find_intent_matches( + blocks_a, blocks_b, similarity_threshold, proj_a_str, proj_b_str, intent_status + ) + if use_intent + else [] + ) return CrossProjectComparison( project_a=project_a, project_b=project_b, matches=matches, stats_a={"files": stats_a.files_scanned, "lines": stats_a.total_lines}, stats_b={"files": stats_b.files_scanned, "lines": stats_b.total_lines}, + sources=sources, + intent_matches=intent_matches, + analysis_status={ + "semantic": semantic_status, + "declared_intent": intent_status, + "configuration": { + "extensions": exts, + "min_lines": min_lines, + "functions_only": functions_only, + "similarity_threshold": similarity_threshold, + }, + }, ) @@ -151,7 +191,7 @@ def _scan_project_blocks( extensions: list[str], min_lines: int, functions_only: bool, -) -> tuple[list[CodeBlock], object]: +) -> tuple[list[CodeBlock], object, list[ScannedFile]]: """Scan a single project and return all code blocks + stats.""" config = ScanConfig( root=root, @@ -166,9 +206,11 @@ def _scan_project_blocks( for block in sf.blocks: if functions_only and block.function_name is None: continue - blocks.append(block) + # Scanner paths are relative to each root. Qualify before cross-project + # matching so a/code.py and b/code.py cannot collapse to one file. + blocks.append(replace(block, file=str(root / block.file))) - return blocks, stats + return blocks, stats, scanned_files def _find_hash_matches( @@ -288,22 +330,29 @@ def _find_semantic_matches( threshold: float, proj_a: str, proj_b: str, + *, + diagnostics: dict | None = None, ) -> list[CrossProjectMatch]: """Find semantically similar functions cross-project.""" + diagnostics = diagnostics if diagnostics is not None else {} try: from redup.core.semantic import SemanticDetector except ImportError: + diagnostics.update(status="unavailable", reason="optional_dependency_missing") return [] - detector = SemanticDetector(threshold=threshold) all_blocks = blocks_a + blocks_b if len(all_blocks) < 2: + diagnostics.update(status="insufficient_blocks") return [] try: + detector = SemanticDetector(threshold=threshold) semantic_matches = detector.find_semantic_duplicates_fast(all_blocks) except Exception: + diagnostics.update(status="unavailable", reason="semantic_analysis_failed") return [] + diagnostics.update(status="evaluated") a_files = {block.file for block in blocks_a} b_files = {block.file for block in blocks_b} @@ -317,3 +366,51 @@ def _find_semantic_matches( cross.append(_create_cross_project_match(ba, bb, m.similarity, proj_a, proj_b)) return cross + + +def _find_intent_matches(blocks_a, blocks_b, threshold, proj_a, proj_b, diagnostics): + """Delegate parsing and matching to Intract; never infer a declaration from code.""" + try: + from intract.integrations.redup import BlockAdapter, find_intent_duplicate_groups + except ImportError: + diagnostics.update(status="unavailable", reason="optional_dependency_missing") + return [] + try: + groups = find_intent_duplicate_groups( + blocks=[ + BlockAdapter(b.text, b.file, b.line_start, b.line_end) for b in blocks_a + blocks_b + ], + threshold=threshold, + ) + except Exception: + diagnostics.update(status="unavailable", reason="intent_analysis_failed") + return [] + diagnostics.update(status="evaluated", engine="intract") + by_side = [ + {(b.file, b.line_start, b.line_end): b for b in blocks} for blocks in (blocks_a, blocks_b) + ] + matches = [] + for group in groups: + fragments = { + (f.get("file_path"), f.get("start_line"), f.get("end_line")) + for f in group.get("fragments", []) + } + for key_a in sorted(fragments & by_side[0].keys()): + for key_b in sorted(fragments & by_side[1].keys()): + a, b = by_side[0][key_a], by_side[1][key_b] + matches.append( + CrossProjectMatch( + project_a=proj_a, + project_b=proj_b, + file_a=a.file, + file_b=b.file, + function_a=a.function_name or "", + function_b=b.function_name or "", + lines_a=(a.line_start, a.line_end), + lines_b=(b.line_start, b.line_end), + similarity=group.get("similarity", 0.0), + similarity_type="intent", + intent_contracts=sorted(group.get("evidence", {}).get("contracts", [])), + ) + ) + return matches diff --git a/src/redup/core/comparison_evidence.py b/src/redup/core/comparison_evidence.py new file mode 100644 index 0000000..fe6589c --- /dev/null +++ b/src/redup/core/comparison_evidence.py @@ -0,0 +1,215 @@ +"""Source observations for the existing comparator; no ranking or ticket effects.""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +from copy import deepcopy +from pathlib import Path + +from redup.core.scanner_types import CodeBlock, ScannedFile + +SCHEMA = "redup.comparison-evidence/v1" +ORIGINS = {"authored", "generated_copy", "deployment_copy", "unknown"} + + +def _digest(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def revision(root: Path) -> str | None: + """Observe HEAD without interpreting it as the revision of dirty file content.""" + try: + result = subprocess.run( + ["git", "-C", str(root), "rev-parse", "--verify", "HEAD"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + value = result.stdout.strip() + if result.returncode == 0 and len(value) in {40, 64}: + return value if all(c in "0123456789abcdef" for c in value) else None + except (OSError, subprocess.TimeoutExpired): + pass + return None + + +def source_observation( + root: Path, + files: list[ScannedFile], + blocks: list[CodeBlock], + before_revision: str | None, + origins: dict[str, str] | None = None, +) -> dict: + """Bind scanned text to file bytes only when their decoded lines agree. + + Origins are explicit caller declarations keyed by relative source path. + A filename, common function name or similarity score cannot prove provenance. + Git identity enrichment belongs to the caller (for example Diagit). + """ + origins = origins or {} + if any( + value not in ORIGINS or Path(key).is_absolute() or ".." in Path(key).parts + for key, value in origins.items() + ): + raise ValueError("source origins require relative paths and a supported origin") + observed_files = {} + for scanned in files: + path = root / scanned.path + try: + relative = path.resolve().relative_to(root).as_posix() + except ValueError: + continue # An external symlink is not evidence owned by this root. + row = { + "scan_content_sha256": _digest("\n".join(scanned.lines)), + "file_sha256": None, + "freshness": "unknown", + "origin": origins.get(relative, "unknown"), + "origin_evidence": "caller_declaration" if relative in origins else "unknown", + } + try: + raw = path.read_bytes() + if raw.decode("utf-8", errors="replace").splitlines() == scanned.lines: + row.update(file_sha256=hashlib.sha256(raw).hexdigest(), freshness="current") + else: + row["freshness"] = "stale" + except OSError: + pass + observed_files[relative] = row + after_revision = revision(root) + return { + "root": str(root), + "identity": root.as_uri(), + "identity_evidence": "local_checkout_path", + "observed_head": before_revision, + "revision_binding": "observed_head_only", + "revision_freshness": ( + "unknown" + if before_revision is None + else "current" + if before_revision == after_revision + else "stale" + ), + "files": observed_files, + "blocks": { + json.dumps([b.file, b.line_start, b.line_end, b.function_name or ""]): _digest(b.text) + for b in blocks + }, + "coverage": { + "files_scanned": len(files), + "files_bound": sum(x["freshness"] == "current" for x in observed_files.values()), + "blocks_compared": len(blocks), + "scope": "selected_files_and_blocks", + "behavioral_tests": "unknown", + "runtime_verification": "unknown", + }, + } + + +def _fresh_source(source: dict) -> dict: + result = deepcopy(source) + root = Path(source["root"]) + if source["observed_head"] is not None and revision(root) != source["observed_head"]: + result["revision_freshness"] = "stale" + for relative, row in result["files"].items(): + if row["freshness"] != "current": + continue # Never upgrade an unbound or stale scan during serialization. + path = root / relative + try: + if ( + not path.resolve().is_relative_to(root) + or hashlib.sha256(path.read_bytes()).hexdigest() != row["file_sha256"] + ): + row["freshness"] = "stale" + except OSError: + row["freshness"] = "unknown" + result["coverage"]["files_current"] = sum( + row["freshness"] == "current" for row in result["files"].values() + ) + return result + + +def candidate_projection(comparison, *, max_candidates: int | None = None) -> dict: + """Serialize hash-bound candidates, keeping similarity separate from quality.""" + sources = {side: _fresh_source(s) for side, s in comparison.sources.items()} + candidates = {} + for match in comparison.matches + comparison.intent_matches: + endpoints = [] + for side in ("a", "b"): + source = sources.get(side, {}) + file = getattr(match, "file_" + side) + lines = getattr(match, "lines_" + side) + function = getattr(match, "function_" + side) + try: + relative = Path(file).resolve().relative_to(Path(source["root"])).as_posix() + except (KeyError, ValueError): + relative = None + row = source.get("files", {}).get(relative, {}) + block_hash = source.get("blocks", {}).get(json.dumps([file, *lines, function])) + endpoints.append( + { + "repository": source.get("identity"), + "file": relative, + "function": function, + "lines": list(lines), + "block_sha256": block_hash, + "file_sha256": row.get("file_sha256"), + "observed_head": source.get("observed_head"), + "freshness": row.get("freshness", "unknown"), + "origin": row.get("origin", "unknown"), + "origin_evidence": row.get("origin_evidence", "unknown"), + } + ) + identity = [ + [ + x["repository"], + x["file"] or getattr(match, "file_" + side), + x["function"], + x["lines"], + x["block_sha256"], + ] + for side, x in zip(("a", "b"), endpoints, strict=True) + ] + candidate_id = _digest(json.dumps(sorted(identity, key=repr), separators=(",", ":"))) + # A legacy hand-built comparison has no snapshot and cannot claim a stable identity. + bound = all(x["repository"] and x["file"] and x["block_sha256"] for x in endpoints) + key = (candidate_id, match.similarity_type) + previous = candidates.get(key, {}) + candidates[key] = { + "id": candidate_id if bound else None, + "source_a": endpoints[0], + "source_b": endpoints[1], + "similarity_type": match.similarity_type, + "similarity": max(match.similarity, previous.get("similarity", 0.0)), + "intent_evidence": {"semantic": "inferred", "intent": "declared"}.get( + match.similarity_type, "unknown" + ), + "intent_contracts": sorted( + set(match.intent_contracts) | set(previous.get("intent_contracts", [])) + ), + "semantic_equivalence": "unknown", + "quality_winner": None, + "usable_source_evidence": bound + and all(x["freshness"] == "current" for x in endpoints) + and all(s["revision_freshness"] != "stale" for s in sources.values()), + } + for source in sources.values(): + source.pop("blocks", None) + ordered = [candidates[k] for k in sorted(candidates)] + selected = ordered[:max_candidates] if max_candidates else ordered + return { + "schema": SCHEMA, + "analyzer": "redup/comparison-evidence-v1", + "authority": "advisory_only", + "sources": sources, + "analysis": dict(comparison.analysis_status), + "candidates": selected, + "selection": { + "total_candidates": len(ordered), + "returned_candidates": len(selected), + "truncated": len(selected) < len(ordered), + }, + "quality_winner": None, + } diff --git a/src/redup/reporters/comparison_reporter.py b/src/redup/reporters/comparison_reporter.py index cae66b8..e05fee9 100644 --- a/src/redup/reporters/comparison_reporter.py +++ b/src/redup/reporters/comparison_reporter.py @@ -5,6 +5,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +from redup.core.comparison_evidence import candidate_projection + if TYPE_CHECKING: from redup.core.community import CodeCommunity from redup.core.comparator import CrossProjectComparison @@ -102,6 +104,10 @@ def comparison_to_dict( result = recommend(comparison, communities) recommendation = { + "authority": "advisory_only", + "basis": "source_overlap_heuristic", + "quality_winner": None, + "compatibility": "unknown", "decision": result.decision.value, "rationale": result.rationale, "overlap_pct": round(result.overlap_percent, 4), @@ -110,6 +116,7 @@ def comparison_to_dict( } payload = { + "evidence": candidate_projection(comparison, max_candidates=max_matches), "project_a": project_a, "project_b": project_b, "stats": {"a": comparison.stats_a, "b": comparison.stats_b}, diff --git a/tests/test_comparison_reporter.py b/tests/test_comparison_reporter.py new file mode 100644 index 0000000..3fcc700 --- /dev/null +++ b/tests/test_comparison_reporter.py @@ -0,0 +1,259 @@ +"""Consumer regressions for source-bound cross-project evidence.""" + +from __future__ import annotations + +import hashlib +import subprocess +import sys +import types +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from redup.core.comparator import CrossProjectComparison, compare_projects +from redup.core.comparison_evidence import source_observation +from redup.core.scanner_types import CodeBlock, ScannedFile +from redup.reporters.comparison_reporter import comparison_to_dict + +FUNCTION = "def double(value):\n result = value * 2\n return result\n" + + +@pytest.fixture +def projects(tmp_path): + roots = [tmp_path / "a", tmp_path / "b"] + for root in roots: + root.mkdir() + (root / "code.py").write_text(FUNCTION) + return roots + + +def report(projects, **kwargs): + return comparison_to_dict(compare_projects(*projects, **kwargs), []) + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def commit(root): + git(root, "add", "code.py") + git( + root, + "-c", + "user.name=Evidence Test", + "-c", + "user.email=test@example.invalid", + "-c", + "core.hooksPath=/dev/null", + "commit", + "-qm", + "fixture", + ) + return git(root, "rev-parse", "HEAD") + + +def test_identical_reruns_and_reverse_pair_have_stable_ids(projects): + first = report(projects) + assert first == report(projects) + candidate = first["evidence"]["candidates"][0] + assert candidate["id"] == report(projects[::-1])["evidence"]["candidates"][0]["id"] + assert candidate["source_a"]["file_sha256"] == hashlib.sha256(FUNCTION.encode()).hexdigest() + assert candidate["source_a"]["lines"] == [1, 3] + assert candidate["usable_source_evidence"] + assert candidate["semantic_equivalence"] == "unknown" + assert candidate["quality_winner"] is None + assert first["evidence"]["sources"]["a"]["revision_freshness"] == "unknown" + assert first["evidence"]["sources"]["a"]["coverage"]["behavioral_tests"] == "unknown" + + +def test_changed_source_cannot_reuse_a_fresh_candidate(projects): + comparison = compare_projects(*projects) + before = comparison_to_dict(comparison, [])["evidence"] + (projects[0] / "code.py").write_text(FUNCTION.replace("* 2", "* 3")) + after = comparison_to_dict(comparison, [])["evidence"] + assert after["candidates"][0]["id"] == before["candidates"][0]["id"] + assert after["candidates"][0]["source_a"]["freshness"] == "stale" + assert not after["candidates"][0]["usable_source_evidence"] + assert after["sources"]["a"]["coverage"]["files_current"] == 0 + # A stale serialization must not mutate the original scan. + assert comparison.sources["a"]["files"]["code.py"]["freshness"] == "current" + + +def test_missing_source_is_unknown_not_fresh(projects): + comparison = compare_projects(*projects) + (projects[0] / "code.py").unlink() + candidate = comparison_to_dict(comparison, [])["evidence"]["candidates"][0] + assert candidate["source_a"]["freshness"] == "unknown" + assert not candidate["usable_source_evidence"] + + +def test_capture_rejects_file_changed_after_scan(projects): + root = projects[0] + block = CodeBlock(str(root / "code.py"), 1, 3, FUNCTION.rstrip(), "double") + scanned = ScannedFile(block.file, FUNCTION.splitlines(), [block]) + (root / "code.py").write_text(FUNCTION.replace("* 2", "* 3")) + observation = source_observation(root, [scanned], [block], None) + assert observation["files"]["code.py"]["file_sha256"] is None + assert observation["files"]["code.py"]["freshness"] == "stale" + assert observation["coverage"]["files_bound"] == 0 + + +def test_observed_git_head_does_not_claim_dirty_content_is_committed(projects): + root = projects[0] + git(root, "init", "-q") + head = commit(root) + (root / "code.py").write_text(FUNCTION + "\n# local change\n") + comparison = compare_projects(*projects) + source = comparison_to_dict(comparison, [])["evidence"]["sources"]["a"] + assert source["observed_head"] == head + assert len(head) == 40 + assert source["revision_binding"] == "observed_head_only" + commit(root) + stale = comparison_to_dict(comparison, [])["evidence"] + assert stale["sources"]["a"]["revision_freshness"] == "stale" + assert not stale["candidates"][0]["usable_source_evidence"] + + +def test_generated_and_deployment_origins_require_explicit_declarations(projects): + candidate = report( + projects, origins_a={"code.py": "generated_copy"}, origins_b={"code.py": "deployment_copy"} + )["evidence"]["candidates"][0] + assert candidate["source_a"]["origin"] == "generated_copy" + assert candidate["source_b"]["origin"] == "deployment_copy" + assert candidate["source_a"]["origin_evidence"] == "caller_declaration" + assert candidate["quality_winner"] is None + assert report(projects)["evidence"]["candidates"][0]["source_a"]["origin"] == "unknown" + with pytest.raises(ValueError, match="relative paths"): + report(projects, origins_a={"../code.py": "authored"}) + + +def test_optional_engines_missing_remain_unknown(projects, monkeypatch): + monkeypatch.setitem(sys.modules, "redup.core.semantic", None) + monkeypatch.setitem(sys.modules, "intract.integrations.redup", None) + evidence = report(projects, use_semantic=True, use_intent=True)["evidence"] + for engine in ("semantic", "declared_intent"): + assert evidence["analysis"][engine]["status"] == "unavailable" + assert evidence["analysis"][engine]["reason"] == "optional_dependency_missing" + assert all(c["intent_evidence"] == "unknown" for c in evidence["candidates"]) + + +def test_semantic_initialization_failure_does_not_erase_source_matches(projects, monkeypatch): + module = types.ModuleType("redup.core.semantic") + + def unavailable(**kwargs): + raise RuntimeError("model not available") + + module.SemanticDetector = unavailable + monkeypatch.setitem(sys.modules, module.__name__, module) + evidence = report(projects, use_semantic=True)["evidence"] + assert evidence["analysis"]["semantic"]["reason"] == "semantic_analysis_failed" + assert evidence["candidates"][0]["similarity_type"] == "exact" + + +def test_declared_intent_uses_intract_adapter_and_does_not_inflate_loc(projects, monkeypatch): + module = types.ModuleType("intract.integrations.redup") + + @dataclass + class BlockAdapter: + content: str + file_path: str + start_line: int + end_line: int + + def groups(*, blocks, threshold): + assert all(b.content and Path(b.file_path).is_absolute() for b in blocks) + return [ + { + "similarity": 0.9, + "evidence": {"contracts": ["scale.value"]}, + "fragments": [ + {"file_path": b.file_path, "start_line": b.start_line, "end_line": b.end_line} + for b in blocks + ], + } + ] + + module.BlockAdapter = BlockAdapter + module.find_intent_duplicate_groups = groups + monkeypatch.setitem(sys.modules, module.__name__, module) + ordinary = report(projects) + declared = report(projects, use_intent=True) + assert ordinary["shared_loc_potential"] == declared["shared_loc_potential"] + assert ordinary["matches"] == declared["matches"] + candidates = declared["evidence"]["candidates"] + intent = next(c for c in candidates if c["similarity_type"] == "intent") + assert intent["intent_evidence"] == "declared" + assert intent["intent_contracts"] == ["scale.value"] + assert intent["semantic_equivalence"] == "unknown" + assert intent["usable_source_evidence"] + + +def test_legacy_comparison_has_no_fabricated_source_evidence(): + evidence = comparison_to_dict(CrossProjectComparison(Path("/a"), Path("/b")), [])["evidence"] + assert evidence["sources"] == {} + assert evidence["candidates"] == [] + assert evidence["quality_winner"] is None + + +def test_overlap_recommendation_remains_advisory(projects): + from redup.core.community import detect_communities + + comparison = compare_projects(*projects) + result = comparison_to_dict(comparison, detect_communities(comparison)) + assert result["recommendation"]["authority"] == "advisory_only" + assert result["recommendation"]["quality_winner"] is None + assert result["recommendation"]["compatibility"] == "unknown" + + +def test_real_intract_inline_declarations(projects): + pytest.importorskip("intract") + code = ( + "def scale(value):\n" + " # @intract.v1 scope:function intent:scale:value\n" + " return value * 2\n" + ) + for root in projects: + (root / "code.py").write_text(code) + evidence = report(projects, use_intent=True)["evidence"] + assert evidence["analysis"]["declared_intent"]["status"] == "evaluated" + assert any(c["intent_evidence"] == "declared" for c in evidence["candidates"]) + + +def test_report_limit_also_bounds_evidence_candidates(projects): + for root in projects: + (root / "code.py").write_text(FUNCTION + "\n" + FUNCTION.replace("double", "twice")) + comparison = compare_projects(*projects) + result = comparison_to_dict(comparison, [], max_matches=1) + assert len(result["matches"]) == 1 + assert len(result["evidence"]["candidates"]) == 1 + assert result["evidence"]["selection"]["truncated"] + + +def test_semantic_matches_keep_project_identity_and_inferred_status(projects, monkeypatch): + from redup.core.comparator import _find_semantic_matches + + blocks = [CodeBlock(str(root / "code.py"), 1, 3, FUNCTION, "double") for root in projects] + module = types.ModuleType("redup.core.semantic") + + class Detector: + def __init__(self, **kwargs): + pass + + def find_semantic_duplicates_fast(self, actual): + assert actual == blocks + return [types.SimpleNamespace(block_a=blocks[0], block_b=blocks[1], similarity=0.9)] + + module.SemanticDetector = Detector + monkeypatch.setitem(sys.modules, module.__name__, module) + diagnostics = {} + matches = _find_semantic_matches( + blocks[:1], blocks[1:], 0.75, str(projects[0]), str(projects[1]), diagnostics=diagnostics + ) + assert diagnostics["status"] == "evaluated" + comparison = compare_projects(*projects) + comparison.matches = matches + candidate = comparison_to_dict(comparison, [])["evidence"]["candidates"][0] + assert candidate["intent_evidence"] == "inferred" + assert candidate["source_a"]["repository"] != candidate["source_b"]["repository"] + assert candidate["semantic_equivalence"] == "unknown"