hackmenot 1.0.0__py3-none-any.whl
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.
- hackmenot/__init__.py +3 -0
- hackmenot/__main__.py +6 -0
- hackmenot/cli/__init__.py +5 -0
- hackmenot/cli/git.py +68 -0
- hackmenot/cli/interactive.py +239 -0
- hackmenot/cli/main.py +454 -0
- hackmenot/core/__init__.py +30 -0
- hackmenot/core/cache.py +167 -0
- hackmenot/core/config.py +155 -0
- hackmenot/core/ignores.py +115 -0
- hackmenot/core/models.py +92 -0
- hackmenot/core/regex_cache.py +54 -0
- hackmenot/core/scanner.py +291 -0
- hackmenot/data/__init__.py +14 -0
- hackmenot/data/npm_top50k.txt +97 -0
- hackmenot/data/pypi_top50k.txt +104 -0
- hackmenot/deps/__init__.py +6 -0
- hackmenot/deps/hallucination.py +60 -0
- hackmenot/deps/parser.py +103 -0
- hackmenot/deps/scanner.py +48 -0
- hackmenot/deps/typosquat.py +90 -0
- hackmenot/deps/vulns.py +111 -0
- hackmenot/fixes/__init__.py +5 -0
- hackmenot/fixes/diff.py +104 -0
- hackmenot/fixes/engine.py +182 -0
- hackmenot/fixes/patterns.py +87 -0
- hackmenot/parsers/__init__.py +33 -0
- hackmenot/parsers/base.py +79 -0
- hackmenot/parsers/golang.py +343 -0
- hackmenot/parsers/javascript.py +347 -0
- hackmenot/parsers/python.py +150 -0
- hackmenot/parsers/terraform.py +211 -0
- hackmenot/reporters/__init__.py +7 -0
- hackmenot/reporters/base.py +14 -0
- hackmenot/reporters/markdown.py +53 -0
- hackmenot/reporters/sarif.py +84 -0
- hackmenot/reporters/terminal.py +174 -0
- hackmenot/rules/__init__.py +6 -0
- hackmenot/rules/builtin/auth/AUTH001.yml +27 -0
- hackmenot/rules/builtin/auth/AUTH002.yml +27 -0
- hackmenot/rules/builtin/auth/AUTH003.yml +29 -0
- hackmenot/rules/builtin/auth/AUTH004.yml +29 -0
- hackmenot/rules/builtin/auth/AUTH005.yml +28 -0
- hackmenot/rules/builtin/auth/AUTH006.yml +32 -0
- hackmenot/rules/builtin/auth/AUTH007.yml +33 -0
- hackmenot/rules/builtin/auth/JSAU001.yml +25 -0
- hackmenot/rules/builtin/auth/JSAU002.yml +31 -0
- hackmenot/rules/builtin/auth/JSAU003.yml +36 -0
- hackmenot/rules/builtin/auth/JSAU004.yml +33 -0
- hackmenot/rules/builtin/crypto/CRYPTO001.yml +29 -0
- hackmenot/rules/builtin/crypto/CRYPTO002.yml +28 -0
- hackmenot/rules/builtin/crypto/CRYPTO003.yml +34 -0
- hackmenot/rules/builtin/crypto/CRYPTO004.yml +34 -0
- hackmenot/rules/builtin/crypto/CRYPTO005.yml +35 -0
- hackmenot/rules/builtin/crypto/CRYPTO006.yml +35 -0
- hackmenot/rules/builtin/crypto/CRYPTO007.yml +36 -0
- hackmenot/rules/builtin/crypto/JSCR001.yml +25 -0
- hackmenot/rules/builtin/crypto/JSCR002.yml +29 -0
- hackmenot/rules/builtin/crypto/JSCR003.yml +32 -0
- hackmenot/rules/builtin/deps/DEP001.yml +27 -0
- hackmenot/rules/builtin/deps/DEP002.yml +26 -0
- hackmenot/rules/builtin/exposure/EXP001.yml +26 -0
- hackmenot/rules/builtin/exposure/EXP002.yml +27 -0
- hackmenot/rules/builtin/exposure/EXP003.yml +30 -0
- hackmenot/rules/builtin/exposure/EXP004.yml +35 -0
- hackmenot/rules/builtin/exposure/EXP005.yml +32 -0
- hackmenot/rules/builtin/exposure/EXP006.yml +32 -0
- hackmenot/rules/builtin/exposure/EXP007.yml +36 -0
- hackmenot/rules/builtin/go/GO_AUT001.yml +20 -0
- hackmenot/rules/builtin/go/GO_AUT002.yml +20 -0
- hackmenot/rules/builtin/go/GO_AUT003.yml +20 -0
- hackmenot/rules/builtin/go/GO_AUT004.yml +20 -0
- hackmenot/rules/builtin/go/GO_CON001.yml +22 -0
- hackmenot/rules/builtin/go/GO_CON002.yml +23 -0
- hackmenot/rules/builtin/go/GO_CON003.yml +22 -0
- hackmenot/rules/builtin/go/GO_CRY001.yml +28 -0
- hackmenot/rules/builtin/go/GO_CRY002.yml +28 -0
- hackmenot/rules/builtin/go/GO_CRY003.yml +28 -0
- hackmenot/rules/builtin/go/GO_CRY004.yml +28 -0
- hackmenot/rules/builtin/go/GO_CRY005.yml +30 -0
- hackmenot/rules/builtin/go/GO_INJ001.yml +34 -0
- hackmenot/rules/builtin/go/GO_INJ002.yml +29 -0
- hackmenot/rules/builtin/go/GO_INJ003.yml +32 -0
- hackmenot/rules/builtin/go/GO_INJ004.yml +28 -0
- hackmenot/rules/builtin/go/GO_INJ005.yml +29 -0
- hackmenot/rules/builtin/go/GO_INJ006.yml +29 -0
- hackmenot/rules/builtin/go/GO_NET001.yml +29 -0
- hackmenot/rules/builtin/go/GO_NET002.yml +23 -0
- hackmenot/rules/builtin/go/GO_NET003.yml +25 -0
- hackmenot/rules/builtin/go/GO_UNS001.yml +21 -0
- hackmenot/rules/builtin/go/GO_UNS002.yml +21 -0
- hackmenot/rules/builtin/injection/INJ001.yml +26 -0
- hackmenot/rules/builtin/injection/INJ002.yml +25 -0
- hackmenot/rules/builtin/injection/INJ003.yml +31 -0
- hackmenot/rules/builtin/injection/INJ004.yml +31 -0
- hackmenot/rules/builtin/injection/INJ005.yml +30 -0
- hackmenot/rules/builtin/injection/INJ006.yml +32 -0
- hackmenot/rules/builtin/injection/INJ007.yml +33 -0
- hackmenot/rules/builtin/injection/JSIJ001.yml +24 -0
- hackmenot/rules/builtin/injection/JSIJ002.yml +27 -0
- hackmenot/rules/builtin/injection/JSIJ003.yml +30 -0
- hackmenot/rules/builtin/injection/JSIJ004.yml +30 -0
- hackmenot/rules/builtin/injection/JSIJ005.yml +29 -0
- hackmenot/rules/builtin/terraform/TF_ENC001.yml +18 -0
- hackmenot/rules/builtin/terraform/TF_ENC002.yml +17 -0
- hackmenot/rules/builtin/terraform/TF_ENC003.yml +17 -0
- hackmenot/rules/builtin/terraform/TF_ENC004.yml +16 -0
- hackmenot/rules/builtin/terraform/TF_IAM001.yml +18 -0
- hackmenot/rules/builtin/terraform/TF_IAM002.yml +18 -0
- hackmenot/rules/builtin/terraform/TF_IAM003.yml +18 -0
- hackmenot/rules/builtin/terraform/TF_LOG001.yml +18 -0
- hackmenot/rules/builtin/terraform/TF_LOG002.yml +17 -0
- hackmenot/rules/builtin/terraform/TF_LOG003.yml +17 -0
- hackmenot/rules/builtin/terraform/TF_NET001.yml +18 -0
- hackmenot/rules/builtin/terraform/TF_NET002.yml +17 -0
- hackmenot/rules/builtin/terraform/TF_S3001.yml +21 -0
- hackmenot/rules/builtin/terraform/TF_S3002.yml +17 -0
- hackmenot/rules/builtin/terraform/TF_S3003.yml +17 -0
- hackmenot/rules/builtin/terraform/TF_S3004.yml +17 -0
- hackmenot/rules/builtin/terraform/TF_SEC001.yml +17 -0
- hackmenot/rules/builtin/terraform/TF_SEC002.yml +17 -0
- hackmenot/rules/builtin/terraform/TF_SEC003.yml +17 -0
- hackmenot/rules/builtin/terraform/TF_SEC004.yml +17 -0
- hackmenot/rules/builtin/terraform/TF_SG001.yml +18 -0
- hackmenot/rules/builtin/terraform/TF_SG002.yml +18 -0
- hackmenot/rules/builtin/terraform/TF_SG003.yml +18 -0
- hackmenot/rules/builtin/terraform/TF_SG004.yml +18 -0
- hackmenot/rules/builtin/validation/JSVA001.yml +26 -0
- hackmenot/rules/builtin/validation/JSVA002.yml +32 -0
- hackmenot/rules/builtin/validation/JSVA003.yml +34 -0
- hackmenot/rules/builtin/validation/JSVA004.yml +38 -0
- hackmenot/rules/builtin/validation/VAL001.yml +29 -0
- hackmenot/rules/builtin/validation/VAL002.yml +32 -0
- hackmenot/rules/builtin/validation/VAL003.yml +31 -0
- hackmenot/rules/builtin/validation/VAL004.yml +34 -0
- hackmenot/rules/builtin/validation/VAL005.yml +40 -0
- hackmenot/rules/builtin/xss/XSS001.yml +26 -0
- hackmenot/rules/builtin/xss/XSS002.yml +29 -0
- hackmenot/rules/builtin/xss/XSS003.yml +29 -0
- hackmenot/rules/builtin/xss/XSS004.yml +32 -0
- hackmenot/rules/engine.py +489 -0
- hackmenot/rules/registry.py +100 -0
- hackmenot-1.0.0.dist-info/METADATA +235 -0
- hackmenot-1.0.0.dist-info/RECORD +147 -0
- hackmenot-1.0.0.dist-info/WHEEL +4 -0
- hackmenot-1.0.0.dist-info/entry_points.txt +2 -0
- hackmenot-1.0.0.dist-info/licenses/LICENSE +201 -0
hackmenot/__init__.py
ADDED
hackmenot/__main__.py
ADDED
hackmenot/cli/git.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Git helpers for CI integration."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_staged_files() -> list[Path]:
|
|
8
|
+
"""Get list of staged files from git.
|
|
9
|
+
|
|
10
|
+
Returns files that are:
|
|
11
|
+
- A: Added
|
|
12
|
+
- C: Copied
|
|
13
|
+
- M: Modified
|
|
14
|
+
- R: Renamed
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
List of Path objects for staged files.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
result = subprocess.run(
|
|
21
|
+
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
|
|
22
|
+
capture_output=True,
|
|
23
|
+
text=True,
|
|
24
|
+
check=True,
|
|
25
|
+
)
|
|
26
|
+
files = [Path(f) for f in result.stdout.strip().split("\n") if f]
|
|
27
|
+
return files
|
|
28
|
+
except subprocess.CalledProcessError:
|
|
29
|
+
return []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_git_repo() -> bool:
|
|
33
|
+
"""Check if current directory is a git repository.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
True if in a git repository, False otherwise.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
subprocess.run(
|
|
40
|
+
["git", "rev-parse", "--git-dir"],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
check=True,
|
|
43
|
+
)
|
|
44
|
+
return True
|
|
45
|
+
except subprocess.CalledProcessError:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_changed_files(since: str) -> list[Path]:
|
|
50
|
+
"""Get list of files changed since a specific commit/ref.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
since: A git ref (commit SHA, branch name, tag, etc.)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
List of Path objects for changed files.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
result = subprocess.run(
|
|
60
|
+
["git", "diff", "--name-only", since, "HEAD"],
|
|
61
|
+
capture_output=True,
|
|
62
|
+
text=True,
|
|
63
|
+
check=True,
|
|
64
|
+
)
|
|
65
|
+
files = [Path(f) for f in result.stdout.strip().split("\n") if f]
|
|
66
|
+
return files
|
|
67
|
+
except subprocess.CalledProcessError:
|
|
68
|
+
return []
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Interactive fix mode for CLI."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.prompt import Prompt
|
|
8
|
+
from rich.syntax import Syntax
|
|
9
|
+
|
|
10
|
+
from hackmenot.core.models import Finding
|
|
11
|
+
from hackmenot.fixes.engine import FixEngine
|
|
12
|
+
from hackmenot.rules.registry import RuleRegistry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InteractiveFixer:
|
|
16
|
+
"""Interactive fixer that prompts user for each finding."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, console: Console | None = None):
|
|
19
|
+
"""Initialize the interactive fixer.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
console: Rich console for output. Creates new one if not provided.
|
|
23
|
+
"""
|
|
24
|
+
self.console = console or Console()
|
|
25
|
+
self.engine = FixEngine()
|
|
26
|
+
# Load rules for pattern-based fixes
|
|
27
|
+
registry = RuleRegistry()
|
|
28
|
+
registry.load_all()
|
|
29
|
+
self.rules = {rule.id: rule for rule in registry.get_all_rules()}
|
|
30
|
+
|
|
31
|
+
def run(
|
|
32
|
+
self, findings: list[Finding], file_contents: dict[str, str]
|
|
33
|
+
) -> dict[str, str]:
|
|
34
|
+
"""Run interactive fix mode.
|
|
35
|
+
|
|
36
|
+
Shows each finding with its fix suggestion and prompts the user
|
|
37
|
+
for action: [a]pply, [s]kip, [A]pply all, [q]uit.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
findings: List of findings to potentially fix.
|
|
41
|
+
file_contents: Dictionary mapping file paths to their contents.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Dictionary mapping file paths to their (potentially modified) contents.
|
|
45
|
+
"""
|
|
46
|
+
result = dict(file_contents)
|
|
47
|
+
apply_all = False
|
|
48
|
+
|
|
49
|
+
# Group findings by file and sort by line number descending
|
|
50
|
+
# (so we can apply fixes without line number shifts)
|
|
51
|
+
findings_by_file: dict[str, list[Finding]] = {}
|
|
52
|
+
for finding in findings:
|
|
53
|
+
if finding.fix_suggestion:
|
|
54
|
+
if finding.file_path not in findings_by_file:
|
|
55
|
+
findings_by_file[finding.file_path] = []
|
|
56
|
+
findings_by_file[finding.file_path].append(finding)
|
|
57
|
+
|
|
58
|
+
# Sort each file's findings by line number descending
|
|
59
|
+
for file_path in findings_by_file:
|
|
60
|
+
findings_by_file[file_path].sort(
|
|
61
|
+
key=lambda f: f.line_number, reverse=True
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
total_findings = sum(len(f) for f in findings_by_file.values())
|
|
65
|
+
current = 0
|
|
66
|
+
|
|
67
|
+
for file_path, file_findings in findings_by_file.items():
|
|
68
|
+
for finding in file_findings:
|
|
69
|
+
current += 1
|
|
70
|
+
|
|
71
|
+
if apply_all:
|
|
72
|
+
# Apply without prompting
|
|
73
|
+
self._apply_fix(finding, result)
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
# Show finding details
|
|
77
|
+
self._show_finding(finding, current, total_findings)
|
|
78
|
+
|
|
79
|
+
# Prompt for action
|
|
80
|
+
action = Prompt.ask(
|
|
81
|
+
"[bold cyan]Action[/bold cyan]",
|
|
82
|
+
choices=["a", "s", "A", "q"],
|
|
83
|
+
default="s",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if action == "q":
|
|
87
|
+
self.console.print("[yellow]Quit - stopping fix mode[/yellow]")
|
|
88
|
+
return result
|
|
89
|
+
elif action == "A":
|
|
90
|
+
self.console.print(
|
|
91
|
+
"[green]Applying all remaining fixes...[/green]"
|
|
92
|
+
)
|
|
93
|
+
apply_all = True
|
|
94
|
+
self._apply_fix(finding, result)
|
|
95
|
+
elif action == "a":
|
|
96
|
+
self._apply_fix(finding, result)
|
|
97
|
+
self.console.print("[green]Fix applied[/green]")
|
|
98
|
+
else: # "s"
|
|
99
|
+
self.console.print("[dim]Skipped[/dim]")
|
|
100
|
+
|
|
101
|
+
self.console.print("\n[bold green]Done![/bold green] Interactive fix mode complete.")
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
def _show_finding(
|
|
105
|
+
self, finding: Finding, current: int, total: int
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Display a finding with its fix suggestion.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
finding: The finding to display.
|
|
111
|
+
current: Current finding number.
|
|
112
|
+
total: Total number of findings.
|
|
113
|
+
"""
|
|
114
|
+
self.console.print()
|
|
115
|
+
self.console.print(
|
|
116
|
+
f"[bold]Finding {current}/{total}[/bold] - "
|
|
117
|
+
f"[cyan]{finding.rule_id}[/cyan]: {finding.rule_name}"
|
|
118
|
+
)
|
|
119
|
+
self.console.print(
|
|
120
|
+
f"[dim]{finding.file_path}:{finding.line_number}[/dim]"
|
|
121
|
+
)
|
|
122
|
+
self.console.print()
|
|
123
|
+
|
|
124
|
+
# Show the problematic code
|
|
125
|
+
self.console.print("[bold red]Current code:[/bold red]")
|
|
126
|
+
syntax = Syntax(
|
|
127
|
+
finding.code_snippet,
|
|
128
|
+
"python",
|
|
129
|
+
theme="monokai",
|
|
130
|
+
line_numbers=False,
|
|
131
|
+
)
|
|
132
|
+
self.console.print(syntax)
|
|
133
|
+
self.console.print()
|
|
134
|
+
|
|
135
|
+
# Show the fix suggestion
|
|
136
|
+
self.console.print("[bold green]Suggested fix:[/bold green]")
|
|
137
|
+
fix_syntax = Syntax(
|
|
138
|
+
finding.fix_suggestion,
|
|
139
|
+
"python",
|
|
140
|
+
theme="monokai",
|
|
141
|
+
line_numbers=False,
|
|
142
|
+
)
|
|
143
|
+
self.console.print(fix_syntax)
|
|
144
|
+
self.console.print()
|
|
145
|
+
|
|
146
|
+
self.console.print(
|
|
147
|
+
"[dim]\\[a]pply \\[s]kip \\[A]pply all \\[q]uit[/dim]"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def _apply_fix(
|
|
151
|
+
self, finding: Finding, file_contents: dict[str, str]
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Apply a fix to the file contents.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
finding: The finding with the fix to apply.
|
|
157
|
+
file_contents: Dictionary of file contents to modify in place.
|
|
158
|
+
"""
|
|
159
|
+
if finding.file_path in file_contents:
|
|
160
|
+
source = file_contents[finding.file_path]
|
|
161
|
+
rule = self.rules.get(finding.rule_id)
|
|
162
|
+
result = self.engine.apply_fix(source, finding, rule)
|
|
163
|
+
if result.applied:
|
|
164
|
+
# Rebuild source with the fix applied
|
|
165
|
+
lines = source.split("\n")
|
|
166
|
+
line_idx = finding.line_number - 1
|
|
167
|
+
if 0 <= line_idx < len(lines):
|
|
168
|
+
fixed_lines = result.fixed.split("\n")
|
|
169
|
+
lines[line_idx : line_idx + 1] = fixed_lines
|
|
170
|
+
file_contents[finding.file_path] = "\n".join(lines)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def apply_fixes_auto(
|
|
174
|
+
findings: list[Finding],
|
|
175
|
+
file_contents: dict[str, str],
|
|
176
|
+
console: Console | None = None,
|
|
177
|
+
) -> tuple[dict[str, str], int]:
|
|
178
|
+
"""Apply all fixes automatically.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
findings: List of findings to fix.
|
|
182
|
+
file_contents: Dictionary mapping file paths to their contents.
|
|
183
|
+
console: Rich console for output.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Tuple of (modified file contents dict, number of fixes applied).
|
|
187
|
+
"""
|
|
188
|
+
console = console or Console()
|
|
189
|
+
engine = FixEngine()
|
|
190
|
+
result = dict(file_contents)
|
|
191
|
+
total_applied = 0
|
|
192
|
+
|
|
193
|
+
# Load rules for pattern-based fixes
|
|
194
|
+
registry = RuleRegistry()
|
|
195
|
+
registry.load_all()
|
|
196
|
+
rules = {rule.id: rule for rule in registry.get_all_rules()}
|
|
197
|
+
|
|
198
|
+
# Group findings by file
|
|
199
|
+
findings_by_file: dict[str, list[Finding]] = {}
|
|
200
|
+
for finding in findings:
|
|
201
|
+
if finding.fix_suggestion:
|
|
202
|
+
if finding.file_path not in findings_by_file:
|
|
203
|
+
findings_by_file[finding.file_path] = []
|
|
204
|
+
findings_by_file[finding.file_path].append(finding)
|
|
205
|
+
|
|
206
|
+
for file_path, file_findings in findings_by_file.items():
|
|
207
|
+
if file_path in result:
|
|
208
|
+
fixed, count = engine.apply_fixes(result[file_path], file_findings, rules)
|
|
209
|
+
result[file_path] = fixed
|
|
210
|
+
total_applied += count
|
|
211
|
+
|
|
212
|
+
if total_applied > 0:
|
|
213
|
+
console.print(f"[green]Applied {total_applied} fix(es)[/green]")
|
|
214
|
+
else:
|
|
215
|
+
console.print("[yellow]No fixes to apply[/yellow]")
|
|
216
|
+
|
|
217
|
+
return result, total_applied
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def write_fixed_files(
|
|
221
|
+
file_contents: dict[str, str], original_contents: dict[str, str]
|
|
222
|
+
) -> int:
|
|
223
|
+
"""Write modified files back to disk.
|
|
224
|
+
|
|
225
|
+
Only writes files that have been modified.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
file_contents: Dictionary of (potentially) modified file contents.
|
|
229
|
+
original_contents: Dictionary of original file contents.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Number of files written.
|
|
233
|
+
"""
|
|
234
|
+
written = 0
|
|
235
|
+
for file_path, content in file_contents.items():
|
|
236
|
+
if content != original_contents.get(file_path):
|
|
237
|
+
Path(file_path).write_text(content)
|
|
238
|
+
written += 1
|
|
239
|
+
return written
|