zairo 0.1.0__tar.gz

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.
zairo-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Avantika (@iamavu)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
zairo-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: zairo
3
+ Version: 0.1.0
4
+ Summary: Git-diff-aware security impact analysis: builds a dependency subgraph around changed code and optionally scans it for vulnerabilities with an LLM.
5
+ Author-email: iamavu <imailavantika@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/iamavu/zairo
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Security
16
+ Classifier: Topic :: Software Development :: Quality Assurance
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: typer>=0.27
21
+ Requires-Dist: rich>=15.0
22
+ Requires-Dist: jinja2>=3.1
23
+ Requires-Dist: litellm>=1.98
24
+ Requires-Dist: trailmark>=0.5
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8.0; extra == "dev"
27
+ Requires-Dist: build>=1.0; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # zairo
31
+
32
+ Git-diff-aware security impact analysis. `zairo` diffs a repository, builds a
33
+ dependency subgraph (via [Trailmark](https://pypi.org/project/trailmark/))
34
+ around whatever changed, and can run an LLM vulnerability scan limited to
35
+ just that changed code — instead of re-scanning the whole codebase on every
36
+ change.
37
+
38
+ Output is a `report.json` (raw graph data) and a self-contained
39
+ `report.html` (interactive dependency graph viewer).
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install zairo
45
+ ```
46
+
47
+ For local development:
48
+
49
+ ```bash
50
+ python -m venv venv
51
+ source venv/bin/activate
52
+ pip install -e ".[dev]"
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ ```bash
58
+ # Analyze uncommitted changes in a repo
59
+ zairo /path/to/repo
60
+
61
+ # Diff two refs, traverse 2 hops out from changed nodes, run an LLM scan
62
+ zairo /path/to/repo --base main --target HEAD --depth 2 --llm
63
+ ```
64
+
65
+ Run `zairo --help` for the full option list.
66
+
67
+ ## Development
68
+
69
+ ```bash
70
+ pytest
71
+ ```
72
+
73
+ ## License
74
+
75
+ MIT — see [LICENSE](LICENSE).
zairo-0.1.0/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # zairo
2
+
3
+ Git-diff-aware security impact analysis. `zairo` diffs a repository, builds a
4
+ dependency subgraph (via [Trailmark](https://pypi.org/project/trailmark/))
5
+ around whatever changed, and can run an LLM vulnerability scan limited to
6
+ just that changed code — instead of re-scanning the whole codebase on every
7
+ change.
8
+
9
+ Output is a `report.json` (raw graph data) and a self-contained
10
+ `report.html` (interactive dependency graph viewer).
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install zairo
16
+ ```
17
+
18
+ For local development:
19
+
20
+ ```bash
21
+ python -m venv venv
22
+ source venv/bin/activate
23
+ pip install -e ".[dev]"
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```bash
29
+ # Analyze uncommitted changes in a repo
30
+ zairo /path/to/repo
31
+
32
+ # Diff two refs, traverse 2 hops out from changed nodes, run an LLM scan
33
+ zairo /path/to/repo --base main --target HEAD --depth 2 --llm
34
+ ```
35
+
36
+ Run `zairo --help` for the full option list.
37
+
38
+ ## Development
39
+
40
+ ```bash
41
+ pytest
42
+ ```
43
+
44
+ ## License
45
+
46
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,54 @@
1
+ [project]
2
+ name = "zairo"
3
+ dynamic = ["version"]
4
+ description = "Git-diff-aware security impact analysis: builds a dependency subgraph around changed code and optionally scans it for vulnerabilities with an LLM."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [
10
+ { name = "iamavu", email = "imailavantika@gmail.com" },
11
+ ]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Environment :: Console",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Topic :: Security",
21
+ "Topic :: Software Development :: Quality Assurance",
22
+ ]
23
+ dependencies = [
24
+ "typer>=0.27",
25
+ "rich>=15.0",
26
+ "jinja2>=3.1",
27
+ "litellm>=1.98",
28
+ "trailmark>=0.5",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=8.0",
34
+ "build>=1.0",
35
+ ]
36
+
37
+ [project.scripts]
38
+ zairo = "zairo.cli:main"
39
+
40
+ [project.urls]
41
+ Repository = "https://github.com/iamavu/zairo"
42
+
43
+ [build-system]
44
+ requires = ["setuptools>=61.0"]
45
+ build-backend = "setuptools.build_meta"
46
+
47
+ [tool.setuptools.dynamic]
48
+ version = { attr = "zairo.__version__" }
49
+
50
+ [tool.setuptools.packages.find]
51
+ where = ["src"]
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["tests"]
zairo-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,12 @@
1
+ from typing import Any
2
+
3
+
4
+ def display_name(name: Any, limit: int = 60) -> str:
5
+ """Collapses a node name to one short line for log display. Some graph
6
+ nodes (e.g. Trailmark misparsing a chained expression like
7
+ `.map(fn).filter(...)`) end up with a "name" that's actually a chunk of
8
+ raw multi-line source text -- printing that verbatim floods the log."""
9
+ text = " ".join(str(name).split())
10
+ if len(text) > limit:
11
+ text = text[:limit - 1] + "…"
12
+ return text
@@ -0,0 +1,121 @@
1
+ import os
2
+ from typing import Callable, Dict, List, Optional, Set, Any
3
+ from trailmark.query.api import QueryEngine
4
+ from .git_utils import get_modified_lines
5
+ from ._util import display_name as _display_name
6
+
7
+
8
+ def analyze_impact(
9
+ repo_path: str,
10
+ depth: int = 1,
11
+ base: str = None,
12
+ target: str = None,
13
+ language: str = "auto",
14
+ log: Optional[Callable[[str], None]] = None,
15
+ ) -> Dict[str, Any]:
16
+ """
17
+ `repo_path` must already be checked out at the state to be indexed: the
18
+ caller is responsible for pointing it at a worktree checked out to
19
+ `target` when diffing two commits, so that node locations/contents line
20
+ up with the line numbers `git diff base target` reports.
21
+ """
22
+ log = log or (lambda msg: None)
23
+
24
+ analysis_root = os.path.abspath(repo_path)
25
+
26
+ modified_files_lines = get_modified_lines(analysis_root, base, target, log=log)
27
+ log(f"git diff found {len(modified_files_lines)} modified file(s):")
28
+ for f, lines in modified_files_lines.items():
29
+ log(f" {f}: {len(lines)} line(s) changed -> {sorted(lines.keys())}")
30
+
31
+ # Initialize Trailmark
32
+ log(f"Indexing {analysis_root} with Trailmark (language={language})...")
33
+ engine = QueryEngine.from_directory(analysis_root, language=language)
34
+ total_nodes = len(engine._store._graph.nodes)
35
+ total_edges = len(engine._store._graph.edges)
36
+ log(f"Trailmark graph: {total_nodes} node(s), {total_edges} edge(s)")
37
+
38
+ # 1. Identify seed nodes (modified/added)
39
+ seed_nodes = set()
40
+ node_metadata = {}
41
+
42
+ for node_id, node in engine._store._graph.nodes.items():
43
+ node_metadata[node_id] = {
44
+ "id": node_id,
45
+ "name": getattr(node, 'name', node_id),
46
+ "kind": node.kind.value if hasattr(node, 'kind') and hasattr(node.kind, 'value') else str(getattr(node, 'kind', 'unknown')),
47
+ "file": node.location.file_path if getattr(node, 'location', None) else None,
48
+ "start_line": node.location.start_line if getattr(node, 'location', None) else None,
49
+ "end_line": node.location.end_line if getattr(node, 'location', None) else None,
50
+ "complexity": getattr(node, 'cyclomatic_complexity', 0),
51
+ "status": "unchanged" # default
52
+ }
53
+
54
+ if getattr(node, 'location', None) and node.location.file_path in modified_files_lines:
55
+ file_mod_lines = modified_files_lines[node.location.file_path]
56
+ start = node.location.start_line
57
+ end = node.location.end_line
58
+ changed_lines = {ln: text for ln, text in file_mod_lines.items() if start <= ln <= end}
59
+ if changed_lines:
60
+ seed_nodes.add(node_id)
61
+ node_metadata[node_id]["status"] = "modified"
62
+ node_metadata[node_id]["changed_lines"] = changed_lines
63
+ log(f" seed: {_display_name(node_metadata[node_id]['name'])} ({node.location.file_path}:{start}-{end}), {len(changed_lines)} line(s) changed")
64
+
65
+ log(f"Identified {len(seed_nodes)} seed node(s)")
66
+
67
+ # 2. Traverse graph to build subgraph up to `depth`
68
+ subgraph_nodes = set(seed_nodes)
69
+ current_frontier = set(seed_nodes)
70
+
71
+ for hop in range(depth):
72
+ next_frontier = set()
73
+ for edge in engine._store._graph.edges:
74
+ source = edge.source_id
75
+ edge_target = edge.target_id
76
+
77
+ if source in current_frontier and edge_target not in subgraph_nodes:
78
+ next_frontier.add(edge_target)
79
+ subgraph_nodes.add(edge_target)
80
+ elif edge_target in current_frontier and source not in subgraph_nodes:
81
+ next_frontier.add(source)
82
+ subgraph_nodes.add(source)
83
+
84
+ log(f"Hop {hop + 1}/{depth}: added {len(next_frontier)} node(s), frontier now {len(subgraph_nodes)} total")
85
+ current_frontier = next_frontier
86
+
87
+ # Extract edges for subgraph
88
+ final_edges = []
89
+ for edge in engine._store._graph.edges:
90
+ if edge.source_id in subgraph_nodes and edge.target_id in subgraph_nodes:
91
+ final_edges.append({
92
+ "source": edge.source_id,
93
+ "target": edge.target_id,
94
+ "kind": edge.kind.value if hasattr(edge, 'kind') and hasattr(edge.kind, 'value') else str(getattr(edge, 'kind', 'unknown')),
95
+ "confidence": edge.confidence.value if hasattr(edge, 'confidence') and hasattr(edge.confidence, 'value') else "unknown"
96
+ })
97
+
98
+ nodes = []
99
+ for n_id in subgraph_nodes:
100
+ # An edge can reference a node id Trailmark's own graph has no entry
101
+ # for (a dangling/malformed reference -- seen from complex chained
102
+ # expressions like `.map(fn).filter(...)`). The fallback must carry
103
+ # the same fields as a normal node, or downstream code that assumes
104
+ # e.g. 'file' always exists (to read source for LLM context) crashes
105
+ # with a bare KeyError on this one bad node instead of just treating
106
+ # it as having no known location.
107
+ nodes.append(node_metadata.get(n_id, {
108
+ "id": n_id,
109
+ "name": n_id,
110
+ "kind": "unknown",
111
+ "file": None,
112
+ "start_line": None,
113
+ "end_line": None,
114
+ "complexity": 0,
115
+ "status": "unchanged",
116
+ }))
117
+
118
+ return {
119
+ "nodes": nodes,
120
+ "edges": final_edges
121
+ }
@@ -0,0 +1,108 @@
1
+ import os
2
+ import typer
3
+ from rich.console import Console
4
+ from .analyzer import analyze_impact
5
+ from .reporter import generate_reports
6
+ from .llm_scanner import scan_graph_for_vulnerabilities
7
+ from .git_utils import create_worktree, remove_worktree
8
+
9
+ app = typer.Typer(add_completion=False)
10
+ console = Console()
11
+
12
+ @app.command()
13
+ def analyze(
14
+ repo_path: str = typer.Argument(..., help="Path to the git repository"),
15
+ depth: int = typer.Option(1, "--depth", "-d", help="Depth of connections to traverse from changed nodes"),
16
+ output_dir: str = typer.Option("zairo_out", "--output", "-o", help="Output directory for reports"),
17
+ base: str = typer.Option(None, "--base", "-b", help="Base commit/ref to diff from (e.g. HEAD~3, main, a1b2c3d)"),
18
+ target: str = typer.Option(None, "--target", "-t", help="Target commit/ref to diff to (e.g. HEAD, feature-branch). Requires --base."),
19
+ language: str = typer.Option("auto", "--language", "-l", help="Language for Trailmark parsing (auto, python, typescript, rust, etc.)"),
20
+ llm: bool = typer.Option(False, "--llm", help="Run LLM vulnerability scanning on modified nodes"),
21
+ model: str = typer.Option("gemini/gemini-1.5-pro", "--model", help="LiteLLM model string to use for scanning"),
22
+ concurrency: int = typer.Option(5, "--concurrency", "-c", help="Number of LLM scan requests to run in parallel"),
23
+ cache: bool = typer.Option(True, "--cache/--no-cache", help="Cache LLM findings by content hash in <output>/.llm_cache.json to skip re-scanning unchanged nodes across runs"),
24
+ max_tokens: int = typer.Option(4096, "--max-tokens", help="Max output tokens per LLM scan request. Reasoning models count internal thinking against this budget too — too low can cause empty responses"),
25
+ tokens: bool = typer.Option(False, "--tokens", help="Show total LLM tokens used by the scan (prompt/completion/total, across real API calls -- cache hits don't count)"),
26
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Print detailed diagnostic output (git commands, worktree setup, node matching, per-node LLM scan progress)")
27
+ ):
28
+ def log(msg: str) -> None:
29
+ if verbose:
30
+ console.print(f"[dim] · {msg}[/dim]")
31
+
32
+ if base and target:
33
+ console.print(f"[bold green]Analyzing {repo_path} at depth {depth} — diff {base}..{target}[/bold green]")
34
+ elif base:
35
+ console.print(f"[bold green]Analyzing {repo_path} at depth {depth} — diff {base}..working tree[/bold green]")
36
+ else:
37
+ console.print(f"[bold green]Analyzing {repo_path} at depth {depth} — uncommitted changes[/bold green]")
38
+ # When diffing two commits, all downstream steps (graph analysis AND the
39
+ # LLM scan, which re-reads source files from disk) need to see `target`'s
40
+ # tree — not whatever happens to be checked out in repo_path already.
41
+ # The worktree must stay alive until every step that reads files is done.
42
+ abs_repo = os.path.abspath(repo_path)
43
+ worktree_path = None
44
+ analysis_root = abs_repo
45
+ try:
46
+ if base and target:
47
+ log(f"Checking out '{target}' into a temporary worktree (base+target diff mode)...")
48
+ worktree_path = create_worktree(abs_repo, target)
49
+ analysis_root = worktree_path
50
+ log(f"Worktree ready at {worktree_path}")
51
+
52
+ graph_data = analyze_impact(analysis_root, depth, base, target, language, log=log)
53
+
54
+ num_modified = sum(1 for n in graph_data['nodes'] if n['status'] != 'unchanged')
55
+ console.print(f"[bold blue]Found {num_modified} modified/added nodes.[/bold blue]")
56
+ console.print(f"[bold blue]Total nodes in subgraph: {len(graph_data['nodes'])}[/bold blue]")
57
+ console.print(f"[bold blue]Total edges in subgraph: {len(graph_data['edges'])}[/bold blue]")
58
+
59
+ vulnerabilities = None
60
+ if llm:
61
+ console.print(f"[bold yellow]Running LLM scanner using {model} (concurrency={concurrency})...[/bold yellow]")
62
+ cache_path = os.path.join(output_dir, ".llm_cache.json") if cache else None
63
+ vulnerabilities, token_usage = scan_graph_for_vulnerabilities(
64
+ graph_data, model, log=log, concurrency=concurrency, cache_path=cache_path,
65
+ max_tokens=max_tokens,
66
+ )
67
+ console.print(f"[bold yellow]Found vulnerabilities in {len(vulnerabilities)} nodes.[/bold yellow]")
68
+
69
+ if tokens:
70
+ if token_usage['requests'] == 0:
71
+ console.print("[dim]Token usage: no LLM requests were made (all results came from cache or were skipped).[/dim]")
72
+ elif token_usage['requests'] == token_usage['requests_without_usage']:
73
+ console.print(
74
+ f"[dim]Token usage: unavailable for all {token_usage['requests']} request(s) "
75
+ f"(provider/backend did not report it).[/dim]"
76
+ )
77
+ else:
78
+ counted = token_usage['requests'] - token_usage['requests_without_usage']
79
+ console.print(
80
+ f"[bold magenta]Tokens used:[/bold magenta] "
81
+ f"{token_usage['prompt_tokens']:,} prompt + {token_usage['completion_tokens']:,} completion "
82
+ f"= {token_usage['total_tokens']:,} total across {counted} request(s)"
83
+ )
84
+ if token_usage['requests_without_usage']:
85
+ console.print(
86
+ f"[dim] ({token_usage['requests_without_usage']} additional request(s) had no usage "
87
+ f"data reported by the provider — not counted above)[/dim]"
88
+ )
89
+
90
+ j_path, h_path = generate_reports(graph_data, output_dir, vulnerabilities)
91
+
92
+ console.print(f"[bold green]Success![/bold green] Reports generated:")
93
+ console.print(f" - {j_path}")
94
+ console.print(f" - {h_path}")
95
+
96
+ except Exception as e:
97
+ console.print(f"[bold red]Error:[/bold red] {e}")
98
+ raise typer.Exit(1)
99
+ finally:
100
+ if worktree_path:
101
+ log(f"Removing temporary worktree {worktree_path}")
102
+ remove_worktree(abs_repo, worktree_path)
103
+
104
+ def main():
105
+ app()
106
+
107
+ if __name__ == "__main__":
108
+ main()
@@ -0,0 +1,131 @@
1
+ import subprocess
2
+ import re
3
+ import os
4
+ import tempfile
5
+ from collections import defaultdict
6
+ from typing import Dict, List, Optional
7
+
8
+
9
+ def create_worktree(repo_path: str, ref: str) -> str:
10
+ """
11
+ Checks out `ref` into a new temporary git worktree and returns its path.
12
+
13
+ Used so that node locations/contents indexed by Trailmark line up with the
14
+ line numbers reported by `git diff base target` — those line numbers refer
15
+ to `target`'s tree, which may differ arbitrarily from whatever happens to
16
+ be checked out in the caller's working directory.
17
+ """
18
+ worktree_path = tempfile.mkdtemp(prefix="zairo-worktree-")
19
+ result = subprocess.run(
20
+ ["git", "worktree", "add", "--detach", "--force", worktree_path, ref],
21
+ cwd=repo_path,
22
+ capture_output=True,
23
+ text=True,
24
+ )
25
+ if result.returncode != 0:
26
+ raise RuntimeError(f"Failed to check out '{ref}' into a worktree: {result.stderr.strip()}")
27
+ return worktree_path
28
+
29
+
30
+ def remove_worktree(repo_path: str, worktree_path: str) -> None:
31
+ subprocess.run(
32
+ ["git", "worktree", "remove", "--force", worktree_path],
33
+ cwd=repo_path,
34
+ capture_output=True,
35
+ text=True,
36
+ )
37
+
38
+ def get_modified_lines(
39
+ repo_path: str,
40
+ base: str = None,
41
+ target: str = None,
42
+ log: Optional[callable] = None,
43
+ ) -> Dict[str, Dict[int, str]]:
44
+ """
45
+ Parses `git diff -U0` to find which lines have been added/modified.
46
+
47
+ - No base/target: compares working tree vs HEAD (uncommitted changes).
48
+ - base only: compares working tree vs that commit.
49
+ - base + target: compares two commits (e.g. HEAD~3..HEAD).
50
+
51
+ Returns a dict mapping absolute file paths to a dict of
52
+ {target line number: representative changed text}. The text is used to
53
+ cheaply filter out non-substantive changes (comments, blank lines)
54
+ before spending an LLM call on them, and to build a windowed view of
55
+ large functions instead of sending their full body.
56
+
57
+ A hunk with zero added lines (a pure deletion, e.g. `@@ -11 +10,0 @@`)
58
+ has no "+" line to anchor to in the target tree, but the enclosing node
59
+ still changed — a deleted validation check or sanitization call is
60
+ exactly the kind of change a security scan most needs to catch. Those
61
+ are recorded under a synthetic marker at the deletion's boundary line
62
+ in the target file, with the removed text as its value, so the
63
+ enclosing node is still found instead of silently skipped.
64
+ """
65
+ log = log or (lambda msg: None)
66
+
67
+ # Build the git diff command
68
+ cmd = ["git", "diff", "-U0"]
69
+ if base and target:
70
+ cmd += [base, target]
71
+ elif base:
72
+ cmd += [base]
73
+ log(f"Running: {' '.join(cmd)} (cwd={repo_path})")
74
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
75
+
76
+ if result.returncode != 0:
77
+ log(f"git diff failed (exit {result.returncode}): {result.stderr.strip()}")
78
+ return {}
79
+
80
+ diff_output = result.stdout
81
+
82
+ modified_lines = defaultdict(dict)
83
+ current_file = None
84
+ next_line_num = None
85
+ pending_deletion_line = None
86
+ pending_deletion_text = []
87
+
88
+ def flush_pending_deletion():
89
+ if current_file and pending_deletion_line is not None and pending_deletion_text:
90
+ modified_lines[current_file][pending_deletion_line] = "\n".join(pending_deletion_text)
91
+
92
+ for line in diff_output.splitlines():
93
+ if line.startswith("+++ "):
94
+ flush_pending_deletion()
95
+ pending_deletion_line, pending_deletion_text = None, []
96
+ if line.startswith("+++ b/"):
97
+ # New file path — resolve to absolute so it matches Trailmark's locations
98
+ rel_path = line[6:]
99
+ current_file = os.path.abspath(os.path.join(repo_path, rel_path))
100
+ else:
101
+ # "+++ /dev/null": the whole file was deleted in the target.
102
+ # There's no target-side file to attribute this hunk to, and
103
+ # without resetting this, a stale current_file from the
104
+ # PREVIOUS file section in the diff would silently absorb
105
+ # this file's content -- a genuine cross-file data leak.
106
+ current_file = None
107
+ next_line_num = None
108
+ elif line.startswith("@@ ") and current_file:
109
+ flush_pending_deletion()
110
+ pending_deletion_line, pending_deletion_text = None, []
111
+ # Parse the + part of the hunk header
112
+ match = re.search(r'\+([0-9]+)(?:,([0-9]+))?', line)
113
+ if match:
114
+ start_line = int(match.group(1))
115
+ count = match.group(2)
116
+ count = int(count) if count is not None else 1
117
+ if count > 0:
118
+ next_line_num = start_line
119
+ else:
120
+ next_line_num = None
121
+ pending_deletion_line = max(1, start_line)
122
+ elif current_file and next_line_num is not None and line.startswith("+") and not line.startswith("+++"):
123
+ # With -U0 there are no context lines, so every "+" line after a
124
+ # hunk header maps to the next line number in the added range.
125
+ modified_lines[current_file][next_line_num] = line[1:]
126
+ next_line_num += 1
127
+ elif current_file and pending_deletion_line is not None and line.startswith("-") and not line.startswith("---"):
128
+ pending_deletion_text.append(line[1:])
129
+
130
+ flush_pending_deletion()
131
+ return dict(modified_lines)