Skip to content

Commit 5d242e0

Browse files
nevoodooclaude
andcommitted
Add dev dependency tracking and --exclude-dev flag
Dev dependencies from uv.lock are now tracked separately. Findings in dev-only dependency chains are labeled with (dev) in the report. New --exclude-dev CLI flag and action input to skip dev deps entirely. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 130c5b6 commit 5d242e0

5 files changed

Lines changed: 86 additions & 16 deletions

File tree

action.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ inputs:
2929
description: "Comma-separated package names to skip"
3030
required: false
3131
default: ""
32+
exclude-dev:
33+
description: "Exclude dev dependencies from the scan"
34+
required: false
35+
default: "false"
3236

3337
outputs:
3438
vuln-count:
@@ -54,6 +58,7 @@ runs:
5458
INPUT_PATH: ${{ inputs.path }}
5559
INPUT_IGNORE_IDS: ${{ inputs.ignore-ids }}
5660
INPUT_IGNORE_PACKAGES: ${{ inputs.ignore-packages }}
61+
INPUT_EXCLUDE_DEV: ${{ inputs.exclude-dev }}
5762
run: |
5863
SCANNER_DIR="${{ github.action_path }}"
5964
export PYTHONPATH="$SCANNER_DIR:${PYTHONPATH:-}"
@@ -65,6 +70,9 @@ runs:
6570
if [ -n "$INPUT_IGNORE_PACKAGES" ]; then
6671
ARGS+=(--ignore-packages "$INPUT_IGNORE_PACKAGES")
6772
fi
73+
if [ "$INPUT_EXCLUDE_DEV" = "true" ]; then
74+
ARGS+=(--exclude-dev)
75+
fi
6876
6977
set +e
7078
REPORT=$(python -m scanner.cli "${ARGS[@]}" 2>"$RUNNER_TEMP/scanner_stderr.txt")

scanner/cli.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ def main(argv: list[str] | None = None) -> int:
4646
dest="output_format",
4747
help="Output format (default: markdown)",
4848
)
49+
parser.add_argument(
50+
"--exclude-dev",
51+
action="store_true",
52+
default=False,
53+
help="Exclude dev dependencies from the scan",
54+
)
4955

5056
args = parser.parse_args(argv)
5157
project_path = Path(args.path)
@@ -82,6 +88,7 @@ def main(argv: list[str] | None = None) -> int:
8288
packages_to_query = {
8389
name: (info.name, info.version)
8490
for name, info in graph.packages.items()
91+
if not (args.exclude_dev and graph.is_dev_only(name))
8592
}
8693
osv_results = query_osv(
8794
packages_to_query,

scanner/graph.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class PackageInfo:
1616
version: str
1717
dependencies: list[str] = field(default_factory=list)
1818
is_direct: bool = False
19+
is_dev: bool = False
1920

2021

2122
@dataclass
@@ -24,16 +25,19 @@ class DependencyGraph:
2425

2526
packages: dict[str, PackageInfo] # normalized name -> PackageInfo
2627
reverse_map: dict[str, list[str]] # package -> list of parents
27-
direct_deps: set[str] # normalized names of direct dependencies
28+
direct_deps: set[str] # normalized names of direct runtime dependencies
29+
dev_deps: set[str] = field(default_factory=set) # direct dev dependencies
2830

2931
def trace_chain(self, package: str) -> list[str]:
3032
"""Find shortest path from a direct dependency to the given package.
3133
32-
Returns a list like ["flask", "werkzeug", "markupsafe"] meaning
33-
flask -> werkzeug -> markupsafe.
34+
Searches runtime deps first, then dev deps. Returns a list like
35+
["flask", "werkzeug", "markupsafe"] meaning flask -> werkzeug -> markupsafe.
3436
"""
3537
package = normalize(package)
36-
if package in self.direct_deps:
38+
all_direct = self.direct_deps | self.dev_deps
39+
40+
if package in all_direct:
3741
return [package]
3842

3943
# BFS from package upward through reverse_map to find a direct dep
@@ -49,15 +53,28 @@ def trace_chain(self, package: str) -> list[str]:
4953
continue
5054
visited.add(parent)
5155
new_path = path + [parent]
52-
if parent in self.direct_deps:
53-
# Return in top-down order: direct dep -> ... -> target
56+
if parent in all_direct:
5457
new_path.reverse()
5558
return new_path
5659
queue.append(new_path)
5760

5861
# No path found to a direct dep — return just the package
5962
return [package]
6063

64+
def is_dev_only(self, package: str) -> bool:
65+
"""Check if a package is only reachable through dev dependencies."""
66+
package = normalize(package)
67+
if package in self.direct_deps:
68+
return False
69+
if package in self.dev_deps:
70+
return True
71+
72+
chain = self.trace_chain(package)
73+
if not chain:
74+
return False
75+
root = chain[0]
76+
return root in self.dev_deps and root not in self.direct_deps
77+
6178

6279
def normalize(name: str) -> str:
6380
"""Normalize a Python package name per PEP 503."""
@@ -98,16 +115,32 @@ def parse_uv_lock(lock_path: Path | str) -> DependencyGraph:
98115
dependencies=deps,
99116
)
100117

101-
# Direct deps are the runtime dependencies of root packages
118+
# Direct runtime deps from root packages
102119
direct_deps: set[str] = set()
103120
for root in root_names:
104121
if root in packages:
105122
direct_deps.update(packages[root].dependencies)
106123

107-
# Mark direct deps
124+
# Dev deps from root packages
125+
dev_deps: set[str] = set()
126+
for pkg in data.get("package", []):
127+
name = normalize(pkg["name"])
128+
if name not in root_names:
129+
continue
130+
for _group, deps in pkg.get("dev-dependencies", {}).items():
131+
for d in deps:
132+
dev_deps.add(normalize(d["name"]))
133+
134+
# Remove overlap — if a package is both runtime and dev, treat as runtime
135+
dev_deps -= direct_deps
136+
137+
# Mark flags
108138
for dep_name in direct_deps:
109139
if dep_name in packages:
110140
packages[dep_name].is_direct = True
141+
for dep_name in dev_deps:
142+
if dep_name in packages:
143+
packages[dep_name].is_dev = True
111144

112145
# Build reverse map (who depends on whom)
113146
reverse_map: dict[str, list[str]] = {}
@@ -123,6 +156,7 @@ def parse_uv_lock(lock_path: Path | str) -> DependencyGraph:
123156
packages=packages,
124157
reverse_map=reverse_map,
125158
direct_deps=direct_deps,
159+
dev_deps=dev_deps,
126160
)
127161

128162

scanner/report.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class Finding:
2020
fixed_versions: list[str]
2121
chain: list[str]
2222
status: str # "fixable", "blocked", "ignored"
23+
is_dev: bool = False
2324

2425

2526
def build_findings(
@@ -51,6 +52,7 @@ def build_findings(
5152
status = "blocked"
5253

5354
chain = graph.trace_chain(vuln.package)
55+
is_dev = graph.is_dev_only(vuln.package)
5456

5557
findings.append(
5658
Finding(
@@ -62,6 +64,7 @@ def build_findings(
6264
fixed_versions=vuln.fixed_versions,
6365
chain=chain,
6466
status=status,
67+
is_dev=is_dev,
6568
)
6669
)
6770

@@ -109,7 +112,7 @@ def generate_markdown(findings: list[Finding]) -> str:
109112

110113
fix_display = ", ".join(f.fixed_versions) if f.fixed_versions else "None"
111114

112-
chain_display = _format_chain(f.chain, f.status)
115+
chain_display = _format_chain(f.chain, f.status, f.is_dev)
113116

114117
lines.append(
115118
f"| {f.package} | {f.version} | {vuln_display} | {fix_display} | {chain_display} |"
@@ -118,25 +121,30 @@ def generate_markdown(findings: list[Finding]) -> str:
118121
# Summary
119122
fixable = sum(1 for f in active if f.status == "fixable")
120123
blocked = sum(1 for f in active if f.status == "blocked")
124+
dev_count = sum(1 for f in active if f.is_dev)
121125

122126
lines.append("")
123127
lines.append("### Summary")
124128
if fixable:
125129
lines.append(f"- {fixable} fixable via dependency upgrade")
126130
if blocked:
127131
lines.append(f"- {blocked} blocked by upstream constraints")
132+
if dev_count:
133+
lines.append(f"- {dev_count} in dev dependencies only")
128134
if ignored:
129135
lines.append(f"- {len(ignored)} ignored")
130136

131137
return "\n".join(lines)
132138

133139

134-
def _format_chain(chain: list[str], status: str) -> str:
140+
def _format_chain(chain: list[str], status: str, is_dev: bool = False) -> str:
135141
"""Format a dependency chain for display."""
142+
dev_suffix = " (dev)" if is_dev else ""
143+
136144
if len(chain) <= 1:
137145
pkg = chain[0] if chain else "unknown"
138146
if status == "blocked":
139-
return f"**{pkg}** (pinned, blocked)"
140-
return pkg
147+
return f"**{pkg}** (pinned, blocked){dev_suffix}"
148+
return f"{pkg}{dev_suffix}"
141149

142-
return " > ".join(chain)
150+
return " > ".join(chain) + dev_suffix

tests/test_graph.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,23 @@ def test_trace_chain_two_levels(self, graph: DependencyGraph):
9292
chain = graph.trace_chain("urllib3")
9393
assert chain == ["requests", "urllib3"]
9494

95-
def test_dev_deps_included(self, graph: DependencyGraph):
96-
# pytest is a dev dep — it should be in packages but NOT a direct dep
97-
# (dev-dependencies are on the root package, not in its dependencies list)
95+
def test_dev_deps_tracked(self, graph: DependencyGraph):
9896
assert "pytest" in graph.packages
97+
assert "pytest" in graph.dev_deps
98+
assert "pytest" not in graph.direct_deps
99+
100+
def test_dev_dep_is_dev_only(self, graph: DependencyGraph):
101+
assert graph.is_dev_only("pytest") is True
102+
assert graph.is_dev_only("iniconfig") is True # transitive dev dep
103+
104+
def test_runtime_dep_not_dev(self, graph: DependencyGraph):
105+
assert graph.is_dev_only("flask") is False
106+
assert graph.is_dev_only("werkzeug") is False
107+
108+
def test_trace_chain_through_dev(self, graph: DependencyGraph):
109+
chain = graph.trace_chain("iniconfig")
110+
assert chain[0] == "pytest"
111+
assert chain[-1] == "iniconfig"
99112

100113
def test_unknown_package(self, graph: DependencyGraph):
101114
chain = graph.trace_chain("nonexistent")

0 commit comments

Comments
 (0)