codeguard-cli 2.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.
Files changed (52) hide show
  1. codeguard/__init__.py +7 -0
  2. codeguard/cli/__init__.py +2 -0
  3. codeguard/cli/_run.py +230 -0
  4. codeguard/cli/commands.py +390 -0
  5. codeguard/cli/formatters.py +422 -0
  6. codeguard/cli/main.py +206 -0
  7. codeguard/config/__init__.py +16 -0
  8. codeguard/config/loader.py +86 -0
  9. codeguard/config/schema.py +172 -0
  10. codeguard/engine/__init__.py +25 -0
  11. codeguard/engine/baseline.py +122 -0
  12. codeguard/engine/context.py +61 -0
  13. codeguard/engine/discovery.py +160 -0
  14. codeguard/engine/finding.py +205 -0
  15. codeguard/engine/fingerprint.py +94 -0
  16. codeguard/engine/gitdiff.py +80 -0
  17. codeguard/engine/policy.py +74 -0
  18. codeguard/engine/registry.py +78 -0
  19. codeguard/engine/rule.py +195 -0
  20. codeguard/engine/runner.py +267 -0
  21. codeguard/engine/suppressions.py +109 -0
  22. codeguard/lang/__init__.py +37 -0
  23. codeguard/lang/base.py +80 -0
  24. codeguard/lang/javascript.py +20 -0
  25. codeguard/lang/node.py +137 -0
  26. codeguard/lang/python_ast.py +29 -0
  27. codeguard/lang/registry.py +38 -0
  28. codeguard/lang/treesitter.py +99 -0
  29. codeguard/lang/typescript.py +24 -0
  30. codeguard/py.typed +1 -0
  31. codeguard/rules/__init__.py +6 -0
  32. codeguard/rules/_jsnodes.py +82 -0
  33. codeguard/rules/_pyimports.py +60 -0
  34. codeguard/rules/javascript/__init__.py +9 -0
  35. codeguard/rules/javascript/cg_sec_101_dynamic_code.py +89 -0
  36. codeguard/rules/javascript/cg_sec_102_child_process.py +58 -0
  37. codeguard/rules/javascript/cg_sec_103_dom_xss.py +67 -0
  38. codeguard/rules/javascript/cg_sec_104_react_dangerous_html.py +54 -0
  39. codeguard/rules/javascript/cg_sec_105_hardcoded_secret.py +73 -0
  40. codeguard/rules/javascript/cg_sec_106_weak_random.py +83 -0
  41. codeguard/rules/meta/__init__.py +55 -0
  42. codeguard/rules/security/__init__.py +8 -0
  43. codeguard/rules/security/cg_sec_001_sql_injection.py +110 -0
  44. codeguard/rules/security/cg_sec_002_hardcoded_secrets.py +184 -0
  45. codeguard/rules/security/cg_sec_003_eval_exec.py +104 -0
  46. codeguard/rules/security/cg_sec_004_unsafe_deserialization.py +156 -0
  47. codeguard/rules/security/cg_sec_005_shell_injection.py +157 -0
  48. codeguard_cli-2.0.0.dist-info/METADATA +210 -0
  49. codeguard_cli-2.0.0.dist-info/RECORD +52 -0
  50. codeguard_cli-2.0.0.dist-info/WHEEL +4 -0
  51. codeguard_cli-2.0.0.dist-info/entry_points.txt +2 -0
  52. codeguard_cli-2.0.0.dist-info/licenses/LICENSE +184 -0
@@ -0,0 +1,86 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Find and load a CodeGuard config file."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .schema import Config, SchemaError
10
+
11
+ if sys.version_info >= (3, 11):
12
+ import tomllib
13
+ else: # pragma: no cover
14
+ import tomli as tomllib
15
+
16
+ _FILENAMES = ("codeguard.toml", ".codeguard.toml")
17
+
18
+
19
+ class ConfigError(Exception):
20
+ """A config file was found but is malformed or has unknown keys."""
21
+
22
+
23
+ def find_config(start: Path | None = None) -> Path | None:
24
+ """Walk up from *start* (cwd by default) looking for a config file.
25
+
26
+ Returns the first ``codeguard.toml`` / ``.codeguard.toml`` found, or the
27
+ first ``pyproject.toml`` that contains a ``[tool.codeguard]`` table.
28
+ """
29
+ here = (start or Path.cwd()).resolve()
30
+ for directory in (here, *here.parents):
31
+ for name in _FILENAMES:
32
+ candidate = directory / name
33
+ if candidate.is_file():
34
+ return candidate
35
+ pyproject = directory / "pyproject.toml"
36
+ if pyproject.is_file() and _has_tool_table(pyproject):
37
+ return pyproject
38
+ if (directory / ".git").exists():
39
+ break
40
+ return None
41
+
42
+
43
+ def _has_tool_table(pyproject: Path) -> bool:
44
+ try:
45
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
46
+ except (tomllib.TOMLDecodeError, OSError):
47
+ return False
48
+ return isinstance(data.get("tool"), dict) and "codeguard" in data["tool"]
49
+
50
+
51
+ def load_config(path: Path | None) -> Config:
52
+ """Load and validate the config at *path*.
53
+
54
+ ``None`` -> an empty default :class:`Config`. Raises :class:`ConfigError`
55
+ on a parse error or a schema violation.
56
+ """
57
+ if path is None:
58
+ return Config()
59
+
60
+ try:
61
+ raw = tomllib.loads(path.read_text(encoding="utf-8"))
62
+ except tomllib.TOMLDecodeError as exc:
63
+ raise ConfigError(f"{path}: invalid TOML: {exc}") from exc
64
+ except OSError as exc:
65
+ raise ConfigError(f"{path}: {exc}") from exc
66
+
67
+ tool_table = raw.get("tool", {})
68
+ if isinstance(tool_table, dict) and isinstance(tool_table.get("codeguard"), dict):
69
+ table = tool_table["codeguard"]
70
+ elif isinstance(raw.get("codeguard"), dict):
71
+ table = raw["codeguard"]
72
+ elif path.name == "pyproject.toml":
73
+ table = {}
74
+ else:
75
+ table = raw
76
+
77
+ if not isinstance(table, dict):
78
+ raise ConfigError(f"{path}: [codeguard] must be a table")
79
+
80
+ try:
81
+ cfg = Config.from_dict(table)
82
+ except SchemaError as exc:
83
+ raise ConfigError(f"{path}: {exc}") from exc
84
+
85
+ cfg.source_dir = str(path.parent)
86
+ return cfg
@@ -0,0 +1,172 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The ``codeguard.toml`` schema, as validated dataclasses.
3
+
4
+ A single ``[tool.codeguard]`` table (or the top-level table of a standalone
5
+ ``codeguard.toml``) maps to :class:`Config`. :func:`Config.from_dict` validates
6
+ and raises :class:`~codeguard.config.loader.ConfigError` with a precise message
7
+ on anything unknown or malformed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field, fields
13
+ from typing import Any
14
+
15
+ from codeguard.engine.finding import Severity
16
+ from codeguard.lang.base import Language
17
+
18
+ _SEVERITIES = {s.value for s in Severity}
19
+ _LANGUAGES = {lang.value for lang in Language}
20
+
21
+
22
+ class SchemaError(Exception):
23
+ """A schema violation, re-raised as ConfigError by the loader."""
24
+
25
+
26
+ def _expect_str_list(value: Any, key: str) -> list[str]:
27
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
28
+ raise SchemaError(f"{key!r} must be a list of strings")
29
+ return list(value)
30
+
31
+
32
+ def _expect_bool(value: Any, key: str) -> bool:
33
+ if not isinstance(value, bool):
34
+ raise SchemaError(f"{key!r} must be true or false")
35
+ return value
36
+
37
+
38
+ def _expect_severity(value: Any, key: str) -> str:
39
+ if value not in _SEVERITIES:
40
+ raise SchemaError(f"{key!r} must be one of {sorted(_SEVERITIES)}, got {value!r}")
41
+ return str(value)
42
+
43
+
44
+ @dataclass
45
+ class RuleSettings:
46
+ """Per-rule tuning from ``[tool.codeguard.rules.<ID>]``."""
47
+
48
+ severity: str | None = None
49
+ confidence_min: float | None = None
50
+
51
+ @classmethod
52
+ def from_dict(cls, rule_id: str, data: dict[str, Any]) -> RuleSettings:
53
+ out = cls()
54
+ for k, v in data.items():
55
+ if k == "severity":
56
+ out.severity = _expect_severity(v, f"rules.{rule_id}.severity")
57
+ elif k == "confidence_min":
58
+ if not isinstance(v, (int, float)) or not (0.0 <= float(v) <= 1.0):
59
+ raise SchemaError(f"rules.{rule_id}.confidence_min must be in [0.0, 1.0]")
60
+ out.confidence_min = float(v)
61
+ else:
62
+ raise SchemaError(f"unknown key rules.{rule_id}.{k}")
63
+ return out
64
+
65
+
66
+ @dataclass
67
+ class RuleOverride:
68
+ """A path-scoped override from ``[[tool.codeguard.overrides]]``."""
69
+
70
+ path: str
71
+ disable: list[str] = field(default_factory=list)
72
+ enable: list[str] = field(default_factory=list)
73
+
74
+ @classmethod
75
+ def from_dict(cls, data: dict[str, Any]) -> RuleOverride:
76
+ if "path" not in data or not isinstance(data["path"], str):
77
+ raise SchemaError("each [[overrides]] needs a string 'path'")
78
+ out = cls(path=data["path"])
79
+ for k, v in data.items():
80
+ if k == "path":
81
+ continue
82
+ if k == "disable":
83
+ out.disable = _expect_str_list(v, "overrides.disable")
84
+ elif k == "enable":
85
+ out.enable = _expect_str_list(v, "overrides.enable")
86
+ else:
87
+ raise SchemaError(f"unknown key overrides.{k}")
88
+ return out
89
+
90
+
91
+ @dataclass
92
+ class Config:
93
+ """The whole ``[tool.codeguard]`` table."""
94
+
95
+ include: list[str] = field(default_factory=list)
96
+ exclude: list[str] = field(default_factory=list)
97
+ languages: list[str] = field(default_factory=list)
98
+ gitignore: bool = True
99
+ fail_on: str = "info"
100
+ output: str = "human"
101
+ jobs: int = 0 # 0 = auto
102
+ baseline: str | None = None
103
+ rule_paths: list[str] = field(default_factory=list)
104
+ enable: list[str] = field(default_factory=list)
105
+ disable: list[str] = field(default_factory=list)
106
+ severity_remap: dict[str, str] = field(default_factory=dict)
107
+ rules: dict[str, RuleSettings] = field(default_factory=dict)
108
+ overrides: list[RuleOverride] = field(default_factory=list)
109
+ #: Directory the config was loaded from (for resolving relative paths).
110
+ source_dir: str | None = None
111
+
112
+ @classmethod
113
+ def from_dict(cls, data: dict[str, Any]) -> Config:
114
+ out = cls()
115
+ known = {f.name for f in fields(cls)} - {
116
+ "rules",
117
+ "overrides",
118
+ "source_dir",
119
+ "enable",
120
+ "disable",
121
+ }
122
+ for key, value in data.items():
123
+ if key in ("include", "exclude", "languages", "rule_paths"):
124
+ lst = _expect_str_list(value, key)
125
+ if key == "languages":
126
+ bad = set(lst) - _LANGUAGES
127
+ if bad:
128
+ raise SchemaError(f"unknown language(s): {sorted(bad)}")
129
+ setattr(out, key, lst)
130
+ elif key == "gitignore":
131
+ out.gitignore = _expect_bool(value, key)
132
+ elif key == "fail_on":
133
+ if value != "never":
134
+ _expect_severity(value, key)
135
+ out.fail_on = str(value)
136
+ elif key == "output":
137
+ if value not in {"human", "json", "json-legacy", "sarif"}:
138
+ raise SchemaError(f"'output' must be a valid format, got {value!r}")
139
+ out.output = str(value)
140
+ elif key == "jobs":
141
+ if not isinstance(value, int) or value < 0:
142
+ raise SchemaError("'jobs' must be a non-negative integer (0 = auto)")
143
+ out.jobs = value
144
+ elif key == "baseline":
145
+ if not isinstance(value, str):
146
+ raise SchemaError("'baseline' must be a string path")
147
+ out.baseline = value
148
+ elif key == "severity_remap":
149
+ if not isinstance(value, dict):
150
+ raise SchemaError("'severity_remap' must be a table of rule-id -> severity")
151
+ for rid, sev in value.items():
152
+ _expect_severity(sev, f"severity_remap.{rid}")
153
+ out.severity_remap = dict(value)
154
+ elif key == "rules":
155
+ if not isinstance(value, dict):
156
+ raise SchemaError("'[rules]' must be a table")
157
+ for rid, rdata in value.items():
158
+ if rid == "enable":
159
+ out.enable = _expect_str_list(rdata, "rules.enable")
160
+ elif rid == "disable":
161
+ out.disable = _expect_str_list(rdata, "rules.disable")
162
+ elif isinstance(rdata, dict):
163
+ out.rules[rid] = RuleSettings.from_dict(rid, rdata)
164
+ else:
165
+ raise SchemaError(f"'[rules.{rid}]' must be a table")
166
+ elif key == "overrides":
167
+ if not isinstance(value, list):
168
+ raise SchemaError("'overrides' must be an array of tables")
169
+ out.overrides = [RuleOverride.from_dict(o) for o in value]
170
+ elif key not in known:
171
+ raise SchemaError(f"unknown key {key!r}")
172
+ return out
@@ -0,0 +1,25 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """CodeGuard rule engine."""
3
+
4
+ from .context import RuleContext
5
+ from .finding import Category, Finding, Fix, Location, Severity, TextEdit, Triage
6
+ from .registry import REGISTRY, RuleRegistry
7
+ from .rule import AstRule, Rule, TreeSitterRule
8
+ from .runner import AnalysisRunner
9
+
10
+ __all__ = [
11
+ "REGISTRY",
12
+ "AnalysisRunner",
13
+ "AstRule",
14
+ "Category",
15
+ "Finding",
16
+ "Fix",
17
+ "Location",
18
+ "Rule",
19
+ "RuleContext",
20
+ "RuleRegistry",
21
+ "Severity",
22
+ "TextEdit",
23
+ "TreeSitterRule",
24
+ "Triage",
25
+ ]
@@ -0,0 +1,122 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Baseline files -- freeze the findings that exist today so CI only fails on new ones.
3
+
4
+ A baseline is a JSON file of finding fingerprints. ``scan --baseline path`` marks
5
+ any finding whose fingerprint is in the file as ``baselined`` (excluded from the
6
+ exit code, still shown). ``codeguard baseline create / update / prune`` manages
7
+ the file.
8
+
9
+ Fingerprints (see :mod:`codeguard.engine.fingerprint`) are stable across
10
+ reformatting and line moves, so a baselined finding stays matched as the file
11
+ around it changes -- and a genuinely new problem is not.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ from .finding import Finding
22
+
23
+ SCHEMA_VERSION = 1
24
+
25
+
26
+ def _now() -> str:
27
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
28
+
29
+
30
+ @dataclass
31
+ class Baseline:
32
+ """The contents of a baseline file."""
33
+
34
+ fingerprints: dict[str, dict[str, str]] = field(default_factory=dict)
35
+ tool_version: str = "0.0.0"
36
+ created: str = field(default_factory=_now)
37
+
38
+ # ------------------------------------------------------------------
39
+ # Construction
40
+ # ------------------------------------------------------------------
41
+
42
+ @classmethod
43
+ def from_findings(cls, findings: list[Finding], *, tool_version: str = "0.0.0") -> Baseline:
44
+ b = cls(tool_version=tool_version)
45
+ b._add(findings)
46
+ return b
47
+
48
+ @classmethod
49
+ def load(cls, path: Path) -> Baseline:
50
+ """Load a baseline file. Raises ``ValueError`` on a malformed file."""
51
+ try:
52
+ data = json.loads(path.read_text(encoding="utf-8"))
53
+ except (OSError, json.JSONDecodeError) as exc:
54
+ raise ValueError(f"{path}: cannot read baseline: {exc}") from exc
55
+ if not isinstance(data, dict) or "fingerprints" not in data:
56
+ raise ValueError(f"{path}: not a CodeGuard baseline file")
57
+ fps = data.get("fingerprints", {})
58
+ if not isinstance(fps, dict):
59
+ raise ValueError(f"{path}: 'fingerprints' must be an object")
60
+ return cls(
61
+ fingerprints={str(k): dict(v) for k, v in fps.items()},
62
+ tool_version=str(data.get("tool_version", "0.0.0")),
63
+ created=str(data.get("created", _now())),
64
+ )
65
+
66
+ # ------------------------------------------------------------------
67
+ # Query / mutate
68
+ # ------------------------------------------------------------------
69
+
70
+ def __contains__(self, fingerprint: object) -> bool:
71
+ return fingerprint in self.fingerprints
72
+
73
+ def __len__(self) -> int:
74
+ return len(self.fingerprints)
75
+
76
+ def _add(self, findings: list[Finding]) -> None:
77
+ for f in findings:
78
+ if not f.fingerprint or f.fingerprint in self.fingerprints:
79
+ continue
80
+ self.fingerprints[f.fingerprint] = {
81
+ "rule_id": f.rule_id,
82
+ "file": f.location.file,
83
+ "first_seen": _now(),
84
+ }
85
+
86
+ def updated_with(self, findings: list[Finding]) -> Baseline:
87
+ """Return a copy with any new findings added (existing ``first_seen`` kept)."""
88
+ out = Baseline(
89
+ fingerprints=dict(self.fingerprints),
90
+ tool_version=self.tool_version,
91
+ created=self.created,
92
+ )
93
+ out._add(findings)
94
+ return out
95
+
96
+ def pruned(self, live_fingerprints: set[str]) -> Baseline:
97
+ """Return a copy with entries that no longer correspond to a finding removed."""
98
+ return Baseline(
99
+ fingerprints={k: v for k, v in self.fingerprints.items() if k in live_fingerprints},
100
+ tool_version=self.tool_version,
101
+ created=self.created,
102
+ )
103
+
104
+ # ------------------------------------------------------------------
105
+ # Serialise
106
+ # ------------------------------------------------------------------
107
+
108
+ def to_dict(self) -> dict[str, object]:
109
+ return {
110
+ "version": SCHEMA_VERSION,
111
+ "created": self.created,
112
+ "tool_version": self.tool_version,
113
+ "fingerprints": dict(sorted(self.fingerprints.items())),
114
+ }
115
+
116
+ def save(self, path: Path) -> None:
117
+ path.write_text(json.dumps(self.to_dict(), indent=2) + "\n", encoding="utf-8")
118
+
119
+
120
+ def apply_baseline(findings: list[Finding], baseline: Baseline) -> list[Finding]:
121
+ """Return *findings* with those present in *baseline* marked ``baselined``."""
122
+ return [f.as_baselined() if f.fingerprint in baseline else f for f in findings]
@@ -0,0 +1,61 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """``RuleContext`` -- everything a rule needs to analyse one file."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import ast
7
+ from dataclasses import dataclass, field
8
+
9
+ from codeguard.lang.base import Language, LanguageSupport
10
+ from codeguard.lang.node import SourceNode
11
+
12
+
13
+ @dataclass
14
+ class RuleContext:
15
+ """Per-file analysis context handed to :meth:`~codeguard.engine.rule.Rule.analyze`.
16
+
17
+ Attributes
18
+ ----------
19
+ filename:
20
+ Path (or ``"<stdin>"``) the source came from.
21
+ source:
22
+ Raw source text.
23
+ language:
24
+ The detected :class:`~codeguard.lang.base.Language`.
25
+ lang:
26
+ The parser backend for *language* (for structural ``query()`` access).
27
+ root:
28
+ The parsed tree as a uniform :class:`~codeguard.lang.node.SourceNode`.
29
+ dataflow:
30
+ Reserved for the intraprocedural taint pass (a later milestone); always
31
+ ``None`` today.
32
+ """
33
+
34
+ filename: str
35
+ source: str
36
+ language: Language
37
+ lang: LanguageSupport
38
+ root: SourceNode
39
+ dataflow: None = None
40
+ _lines: list[str] | None = field(default=None, repr=False, compare=False)
41
+
42
+ @property
43
+ def lines(self) -> list[str]:
44
+ """Source split into lines, cached."""
45
+ if self._lines is None:
46
+ self._lines = self.source.splitlines()
47
+ return self._lines
48
+
49
+ @property
50
+ def python_ast(self) -> ast.Module:
51
+ """The native :class:`ast.Module` for a Python file.
52
+
53
+ Raises
54
+ ------
55
+ TypeError
56
+ If this context is not for Python source.
57
+ """
58
+ node = self.root.native
59
+ if not isinstance(node, ast.Module):
60
+ raise TypeError(f"python_ast requested for a {self.language.value} context")
61
+ return node
@@ -0,0 +1,160 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """File discovery -- decide which files a scan should look at.
3
+
4
+ Rules never touch the filesystem; the runner asks this module for a list of
5
+ files. Discovery honours ``.gitignore`` (repo root), a built-in skip list for
6
+ directories that never contain first-party source, and user ``--include`` /
7
+ ``--exclude`` globs. Directory symlinks are not followed and every file is
8
+ de-duplicated by real path.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from collections.abc import Iterable, Sequence
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import pathspec
20
+
21
+ from codeguard.lang.registry import language_for_path
22
+
23
+ #: Directory names that never hold first-party source. Pruned before globbing.
24
+ DEFAULT_SKIP_DIRS: frozenset[str] = frozenset(
25
+ {
26
+ ".git",
27
+ ".hg",
28
+ ".svn",
29
+ ".bzr",
30
+ "node_modules",
31
+ ".venv",
32
+ "venv",
33
+ "env",
34
+ ".env",
35
+ "__pycache__",
36
+ ".mypy_cache",
37
+ ".ruff_cache",
38
+ ".pytest_cache",
39
+ ".tox",
40
+ ".nox",
41
+ ".eggs",
42
+ "dist",
43
+ "build",
44
+ "site-packages",
45
+ ".cache",
46
+ ".idea",
47
+ ".vscode",
48
+ }
49
+ )
50
+
51
+ #: Filename globs skipped by default (generated / vendored artefacts).
52
+ DEFAULT_SKIP_FILES: tuple[str, ...] = ("*.min.js", "*.bundle.js", "*.d.ts")
53
+
54
+
55
+ @dataclass
56
+ class DiscoveryConfig:
57
+ """Inputs that shape a discovery pass."""
58
+
59
+ include: list[str] = field(default_factory=list)
60
+ exclude: list[str] = field(default_factory=list)
61
+ respect_gitignore: bool = True
62
+ skip_dirs: frozenset[str] = DEFAULT_SKIP_DIRS
63
+ skip_files: tuple[str, ...] = DEFAULT_SKIP_FILES
64
+
65
+
66
+ def _load_gitignore(root: Path) -> pathspec.PathSpec[Any] | None:
67
+ patterns: list[str] = []
68
+ for rel in (".gitignore", ".git/info/exclude"):
69
+ p = root / rel
70
+ if p.is_file():
71
+ patterns.extend(p.read_text(encoding="utf-8", errors="replace").splitlines())
72
+ if not patterns:
73
+ return None
74
+ return pathspec.PathSpec.from_lines("gitignore", patterns)
75
+
76
+
77
+ def _spec(patterns: Iterable[str]) -> pathspec.PathSpec[Any] | None:
78
+ patterns = [p for p in patterns if p.strip()]
79
+ return pathspec.PathSpec.from_lines("gitignore", patterns) if patterns else None
80
+
81
+
82
+ def discover(
83
+ paths: Sequence[str | Path],
84
+ config: DiscoveryConfig | None = None,
85
+ *,
86
+ root: str | Path | None = None,
87
+ ) -> list[Path]:
88
+ """Return the sorted, de-duplicated list of files to scan.
89
+
90
+ Parameters
91
+ ----------
92
+ paths:
93
+ Files or directories given on the command line.
94
+ config:
95
+ Discovery settings. Defaults are used when omitted.
96
+ root:
97
+ Base directory for ``.gitignore`` lookup and relative-path matching.
98
+ Defaults to the current working directory.
99
+ """
100
+ cfg = config or DiscoveryConfig()
101
+ base = Path(root or os.getcwd()).resolve()
102
+
103
+ gitignore = _load_gitignore(base) if cfg.respect_gitignore else None
104
+ include_spec = _spec(cfg.include)
105
+ exclude_spec = _spec([*cfg.exclude, *cfg.skip_files])
106
+
107
+ seen: set[Path] = set()
108
+ out: list[Path] = []
109
+
110
+ def rel(p: Path) -> str:
111
+ try:
112
+ return p.resolve().relative_to(base).as_posix()
113
+ except ValueError:
114
+ return p.name
115
+
116
+ def want(p: Path) -> bool:
117
+ if language_for_path(p) is None:
118
+ return False
119
+ r = rel(p)
120
+ if exclude_spec and exclude_spec.match_file(r):
121
+ return False
122
+ if gitignore and gitignore.match_file(r):
123
+ return False
124
+ if include_spec and not include_spec.match_file(r):
125
+ return False
126
+ return True
127
+
128
+ def add(p: Path) -> None:
129
+ real = p.resolve()
130
+ if real in seen:
131
+ return
132
+ seen.add(real)
133
+ out.append(p)
134
+
135
+ for raw in paths:
136
+ start = Path(raw)
137
+ if start.is_file():
138
+ # An explicitly named file bypasses include/exclude/gitignore
139
+ # (the user asked for it) but still must be a supported language.
140
+ if language_for_path(start) is not None:
141
+ add(start)
142
+ continue
143
+ if not start.is_dir():
144
+ continue
145
+ for dirpath, dirnames, filenames in os.walk(start, followlinks=False):
146
+ d = Path(dirpath)
147
+ dirnames[:] = [
148
+ name
149
+ for name in dirnames
150
+ if name not in cfg.skip_dirs
151
+ and not (d / name).is_symlink()
152
+ and not (gitignore and gitignore.match_file(rel(d / name) + "/"))
153
+ ]
154
+ for name in filenames:
155
+ fp = d / name
156
+ if not fp.is_symlink() and want(fp):
157
+ add(fp)
158
+
159
+ out.sort(key=lambda p: p.as_posix())
160
+ return out