Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions project/ticket-025/README.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions project/ticket-025/intent.json
Original file line number Diff line number Diff line change
@@ -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
}
113 changes: 105 additions & 8 deletions src/redup/core/comparator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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.

Expand All @@ -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)
Expand Down Expand Up @@ -125,19 +145,39 @@ 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)
if key not in seen_pairs:
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,
},
},
)


Expand All @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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}
Expand All @@ -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
Loading
Loading