From 1ee1893b8e0efc9b629a3004c79a4b9cf7561cfb Mon Sep 17 00:00:00 2001 From: bitWarrior <164793+bitWarrior@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:14:35 -0700 Subject: [PATCH] Never hide a tracked file behind .gitignore; add --no-ignore git's rule is that an ignore pattern has no effect on a file it already tracks. CodeSnake applied .gitignore to everything a directory walk found, so a pull request could add a file, ignore it, `git add -f` it, and `codesnake check src/` would never see it -- exit 0 with the file in the tree. Reproduced both spellings: an ignored file, and a tracked file inside an ignored directory, where the walk pruned the directory before reaching it. Tracked paths are now exempt from ignore filtering, and a directory holding a tracked file is walked rather than pruned. Untracked ignored files -- build output, generated code -- are still skipped, so the noise the filter exists to suppress is unaffected. The fix needs no flag, so CI written before this release is covered simply by upgrading, and the repository's own self-check no longer needs --no-ignore to be a real gate. The tracked set comes from `git ls-files -z --full-name`, about 2 ms here and run once per walk. Anything going wrong -- no git, not a repository, a timeout -- yields an empty set, which restores the previous behavior rather than silently widening or narrowing the scan. --no-ignore is kept, with its meaning narrowed to what it is now actually for: covering *untracked* ignored files, or a tree that is not a git repository. Extract _load_ignore_state so iter_python_files stays under the complexity and length thresholds the tool enforces on everything else. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114gUUe4CxYr8W8oC95ffmD --- CHANGELOG.md | 18 ++++ README.md | 8 +- SECURITY.md | 2 + codesnake-launcher.sh | 4 +- docs/BASH_SCRIPTS_GUIDE.md | 2 +- docs/INTEGRATIONS.md | 23 ++++- docs/PROJECT_STRUCTURE.md | 2 +- src/codesnake/checker.py | 123 ++++++++++++++++++++++---- src/codesnake/cli.py | 7 ++ test/test_codesnake.py | 171 +++++++++++++++++++++++++++++++++++++ 10 files changed, 334 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1699fd4..75fcc17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ All notable changes to CodeSnake are documented here. The format follows ## [Unreleased] +### Fixed + +- **`.gitignore` no longer hides committed files from a directory walk.** git's own + rule is that an ignore pattern has no effect on a tracked file; CodeSnake applied + it regardless, so a pull request could add a file, ignore it, `git add -f` it, and + `codesnake check src/` would never see it — exiting 0 with the file in the tree. + Tracked files are now exempt from ignore filtering, and an ignored directory + holding a tracked file is walked rather than pruned. Untracked ignored files, such + as generated output, are still skipped. No flag is needed, and CI gates written + before this release are covered on upgrade. + +### Added + +- **`--no-ignore`** disables `.gitignore` handling entirely, covering *untracked* + ignored files as well — useful for linting generated code or scanning a tree that + is not a git repository. Venvs, caches, and `.git` stay skipped. Explicit file + arguments were never gitignore-filtered. + ## [1.2.1] - 2026-09-01 First release published to PyPI. The package itself is byte-identical to 1.2.0 — diff --git a/README.md b/README.md index 5281a9c..6f708be 100644 --- a/README.md +++ b/README.md @@ -70,10 +70,13 @@ adoption path is in [docs/INTEGRATIONS.md](docs/INTEGRATIONS.md#adopting-codesna ## Usage ```bash -# Files or directories (walks *.py, skips venvs, caches, and .gitignore) +# Files or directories (walks *.py; skips venvs, caches, and untracked .gitignore matches) codesnake check src/codesnake/checker.py test/example_bad_code.py codesnake check src/ +# Also analyze untracked files that .gitignore hides, e.g. generated output +codesnake check --no-ignore src/ + # Same thing without the subcommand codesnake src/ @@ -110,11 +113,12 @@ codesnake config -o .codesnake.json | `--no-color` | Disable ANSI color (`NO_COLOR` also works) | | `--bandit` | Merge Bandit results when the `bandit` executable is installed | | `--staged` | Check `git diff --cached` Python files only | +| `--no-ignore` | When walking directories, ignore `.gitignore` entirely, including for untracked files (venvs, caches, and `.git` are still skipped). Tracked files are analyzed either way. | | `--baseline FILE` | Hide issues whose fingerprint is already in the baseline | | `--update-baseline FILE` | Write the current finding set as a baseline | | `-j`, `--jobs N` | Worker processes (default: auto — one per CPU once 8+ files are checked; `1` disables) | -`--staged` needs no file arguments and works from any directory inside the repository (paths from git are resolved against the repo root). With no staged `.py` files it exits **0**. `--baseline` fingerprints are `filename|code|message-with-numbers-normalized|occurrence`, so line-only edits and count changes (`52 lines long` → `53 lines long`) do not re-fail CI, while a *second* identical violation in the same file still does. Version-1 baselines are read transparently; `--update-baseline` writes version 2. A missing baseline file fails closed (exit 1). +`--staged` needs no file arguments and works from any directory inside the repository (paths from git are resolved against the repo root). With no staged `.py` files it exits **0**. Directory walks skip `.gitignore` matches only for files git does not track, mirroring git's own behavior — so a file committed with `git add -f` is still analyzed, and no flag is needed to catch it. `--no-ignore` additionally covers *untracked* ignored files, such as generated output. `--baseline` fingerprints are `filename|code|message-with-numbers-normalized|occurrence`, so line-only edits and count changes (`52 lines long` → `53 lines long`) do not re-fail CI, while a *second* identical violation in the same file still does. Version-1 baselines are read transparently; `--update-baseline` writes version 2. A missing baseline file fails closed (exit 1). ### Output formats diff --git a/SECURITY.md b/SECURITY.md index d13b6cc..b1e8b0b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -32,6 +32,8 @@ Two places reach outside the process, both only when you ask: - `--bandit` runs the separate `bandit` executable, if it is installed and on `PATH`, over the files you named. That is a different project with its own dependencies and its own threat model. - `--staged` runs `git rev-parse` and `git diff --cached` to list staged files. +Directory walks skip `.gitignore` matches, but **only for files git does not track** — matching git's own rule that an ignore pattern has no effect on a committed file. A file added with `git add -f` is therefore still analyzed, and so is a tracked file inside an ignored directory. `--no-ignore` disables ignore handling entirely, for scanning a working tree that is not a git repository or whose ignored output you want covered. Venvs, caches, and `.git` are skipped in every mode. + ### In scope - Anything that makes CodeSnake execute code from a file it is analyzing diff --git a/codesnake-launcher.sh b/codesnake-launcher.sh index 6f4070a..221c8f4 100755 --- a/codesnake-launcher.sh +++ b/codesnake-launcher.sh @@ -46,8 +46,8 @@ Options: --test Run test suite --banner Show CodeSnake banner -Any other argument (files, directories, --bandit, --staged, --baseline FILE, ---no-color, ...) is passed straight through to CodeSnake. +Any other argument (files, directories, --bandit, --staged, --no-ignore, +--baseline FILE, --no-color, ...) is passed straight through to CodeSnake. Examples: $0 mycode.py # Check a file diff --git a/docs/BASH_SCRIPTS_GUIDE.md b/docs/BASH_SCRIPTS_GUIDE.md index 99e34b6..f6b3dc7 100644 --- a/docs/BASH_SCRIPTS_GUIDE.md +++ b/docs/BASH_SCRIPTS_GUIDE.md @@ -58,7 +58,7 @@ Activates `codesnake-venv/` (creating and populating it on first run), then `exe | `--banner` | Print the banner | | `-e`, `--enhanced` | Deprecated no-op (the "enhanced" checker was merged into the main CLI) | -Anything else — files, directories, `--bandit`, `--staged`, `--baseline FILE`, `--update-baseline FILE`, `--jobs N`, `--no-color` — is passed straight through to `codesnake check`. Arguments are forwarded as an array, so paths with spaces or shell metacharacters are safe. An option that needs a value but has none exits with status 2 and a message. +Anything else — files, directories, `--bandit`, `--staged`, `--no-ignore`, `--baseline FILE`, `--update-baseline FILE`, `--jobs N`, `--no-color` — is passed straight through to `codesnake check`. Arguments are forwarded as an array, so paths with spaces or shell metacharacters are safe. An option that needs a value but has none exits with status 2 and a message. ```bash ./codesnake-launcher.sh mycode.py diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index d740692..6410cbe 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -46,6 +46,20 @@ To have pre-commit manage the install instead, use `language: python` with ## GitHub Actions +`.gitignore` is applied only to files git does not track, matching git's own rule that an ignore pattern has no effect on a committed file. A file added with `git add -f`, or a tracked file inside an ignored directory, is analyzed by a plain `codesnake check src/` — no flag required. This is what stops a pull request from hiding code from the gate by ignoring it and force-adding it. + +Two variations are still useful: + +```bash +# Also cover UNTRACKED ignored files, e.g. generated code you want linted anyway +codesnake check --no-ignore --severity error --no-color src/ + +# Exactly what git tracks, and nothing else +codesnake check --no-color --severity error $(git ls-files '*.py') +``` + +Run the `git ls-files` form from the repository root. If it expands to nothing, CodeSnake exits 2 (`no files to check`); the directory form does not have that empty-tree edge. + ### Inline annotations `--format github` prints workflow commands; GitHub turns them into annotations on the PR diff. This repository's own `.github/workflows/ci.yml` does exactly this. @@ -80,7 +94,7 @@ Upload SARIF so findings appear under **Security → Code scanning**. The `|| tr - uses: github/codeql-action/upload-sarif@v4 with: sarif_file: codesnake.sarif - - run: codesnake check --severity error --no-color src/ + - run: codesnake check --severity error --no-color --no-ignore src/ ``` ### Only fail on new findings @@ -95,7 +109,7 @@ git add .codesnake-baseline.json Baselines store paths relative to the working directory, so write and read them from the **same** directory — the repository root, to match CI. A baseline recorded at the root suppresses nothing when the check is later run from inside `src/`. (This is why `--baseline` does not belong in the `--staged` hook above without pinning the directory first.) ```yaml - - run: codesnake check --baseline .codesnake-baseline.json --format github --no-color src/ + - run: codesnake check --baseline .codesnake-baseline.json --format github --no-color --no-ignore src/ ``` A fingerprint is the file path, the rule code, the message with digits normalized away, and an occurrence index. Line numbers and numeric counts therefore do not matter — edits above a finding, or a complexity score drifting from 15 to 16, will not re-fail CI. Anything else does: moving code to another file, or a rename that changes an identifier quoted in the message (`Unused import 'os'`), mints a new fingerprint even though the finding is unchanged. A genuinely *new* violation (including a second identical one in the same file) fails too. Refresh the baseline with `--update-baseline` as you pay down the backlog. @@ -159,7 +173,7 @@ lint: ## everything, human-readable codesnake check src/ test/ lint-errors: ## only what would fail CI - codesnake check --severity error src/ + codesnake check --severity error --no-ignore src/ lint-baseline: ## only findings not in the committed baseline codesnake check --baseline .codesnake-baseline.json src/ @@ -194,7 +208,8 @@ CodeSnake's differentiators are the taint tracking behind SEC001/SEC003 severiti | Symptom | Fix | |---|---| | Too many findings | Baseline first, then tune thresholds and `check_*` flags; `# noqa: CODE` for the rest | -| Slow on a large tree | `--jobs N` (auto-parallel from 8 files); point at specific directories; generated code under `.gitignore`d directories is already skipped | +| Slow on a large tree | `--jobs N` (auto-parallel from 8 files); point at specific directories; untracked generated code under `.gitignore`d directories is skipped unless you pass `--no-ignore` | +| CI missed a committed file | Tracked files are never hidden by `.gitignore`. If one was missed it is untracked — commit it, or pass `--no-ignore` | | `No staged Python files.` | Nothing staged, or the files are not `*.py`; a real git failure prints an `Error:` instead | | Escape codes in CI logs | `--no-color` or `NO_COLOR=1` | | Warnings don't fail the build | By design; see the exit-code note at the top | diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index f81e8ff..dfefb8d 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -37,7 +37,7 @@ codesnake/ | Module | Contents | |---|---| -| `checker.py` | `SemanticChecker` (the AST visitor and every rule), `CheckerConfig` / `load_config`, file discovery (`iter_python_files`, `.gitignore` handling, `expand_python_targets`), `check_file`, bandit merge, baselines, `--staged` support, the four report formatters, and `run_check` (the orchestration entry point used by the CLI and by library callers) | +| `checker.py` | `SemanticChecker` (the AST visitor and every rule), `CheckerConfig` / `load_config`, file discovery (`iter_python_files`, `.gitignore` handling, `--no-ignore` / `respect_gitignore`, `expand_python_targets`), `check_file`, bandit merge, baselines, `--staged` support, the four report formatters, and `run_check` (the orchestration entry point used by the CLI and by library callers) | | `cli.py` | `add_check_arguments()` (the single definition of the `check` flags), `build_parser()`, `normalize_argv()` (so `codesnake FILES` means `codesnake check FILES`), and `main()` | | `banner.py` | `print_snake_banner()`, `print_version()`; `VERSION` is imported from `_version` | | `_version.py` | `__version__` — read statically by `pyproject.toml` (`dynamic = ["version"]`) | diff --git a/src/codesnake/checker.py b/src/codesnake/checker.py index 09477e8..0515f8e 100644 --- a/src/codesnake/checker.py +++ b/src/codesnake/checker.py @@ -360,6 +360,34 @@ def find_repo_root(start: Path) -> Optional[Path]: return None +def git_tracked_files(repo_root: Path) -> Set[Path]: + """Resolved paths of every file git tracks in ``repo_root``, or an empty set. + + git's own rule is that ``.gitignore`` governs *untracked* files only -- a + file that is committed is not affected by an ignore rule matching it. A + scanner that filters tracked files by ``.gitignore`` therefore diverges + from git, and can be made to skip a committed file with ``git add -f``. + Failure here (no git, not a repository, a timeout) yields an empty set, + which restores the previous behavior rather than hiding files. + """ + try: + completed = _subprocess.run( + ['git', 'ls-files', '-z', '--full-name'], + capture_output=True, + timeout=30, + cwd=str(repo_root), + ) + except (OSError, _subprocess.TimeoutExpired): + return set() + if completed.returncode != 0: + return set() + tracked: Set[Path] = set() + for name in completed.stdout.decode('utf-8', errors='replace').split('\0'): + if name: + tracked.add(repo_root / name) + return tracked + + def detect_source_encoding(data: bytes) -> str: """PEP 263 encoding cookie, else UTF-8 (with BOM).""" if data.startswith(b'\xef\xbb\xbf'): @@ -404,12 +432,15 @@ def issue_ignored_by_pragma(code: str, line: int, source_lines: Sequence[str]) - return code.upper() in codes -def iter_python_files(root: Path) -> Iterable[Path]: - """Yield .py files under root, skipping venvs, caches, and .gitignore matches.""" - try: - root_resolved = root.resolve() - except OSError: - root_resolved = root +def _load_ignore_state( + root_resolved: Path, +) -> Tuple['_IgnoreStack', Set[Path], Set[Path]]: + """Ignore rules for a walk, plus the tracked paths exempt from them. + + Returns the loaded ``.gitignore`` stack, the set of files git tracks, and + the directories those files live in. Tracked entries are exempt because + git applies ignore rules to untracked files only. + """ ignore = _IgnoreStack() # .gitignore files between the repository root and the target apply to # everything below them, so load them first (top-most first). @@ -425,23 +456,68 @@ def iter_python_files(root: Path) -> Iterable[Path]: if gi_path.is_file(): ignore.add_gitignore(gi_path) + if repo_root is None: + return ignore, set(), set() + try: + tracked = git_tracked_files(repo_root.resolve()) + except OSError: + return ignore, set(), set() + # An ignored *directory* holding a committed file must still be walked, or + # the file-level exemption is never reached. + tracked_dirs: Set[Path] = set() + for path in tracked: + tracked_dirs.update(path.parents) + return ignore, tracked, tracked_dirs + + +def iter_python_files(root: Path, *, respect_gitignore: bool = True) -> Iterable[Path]: + """Yield .py files under root, skipping venvs, caches, and (by default) .gitignore matches. + + ``respect_gitignore=False`` still skips ``SKIP_DIR_NAMES`` (venvs, caches, + ``.git``, ...) so a CI gate does not have to crawl ``site-packages``. It + does not skip a committed file that ``.gitignore`` would hide — pass + ``--no-ignore`` for that, or name the file explicitly. + """ + try: + root_resolved = root.resolve() + except OSError: + root_resolved = root + ignore, tracked, tracked_dirs = ( + _load_ignore_state(root_resolved) if respect_gitignore + else (_IgnoreStack(), set(), set()) + ) + for dirpath, dirnames, filenames in os.walk(root_resolved): current = Path(dirpath) - nested_gi = current / '.gitignore' - if nested_gi.is_file(): - ignore.add_gitignore(nested_gi) - views = ignore.view(current) + views: _DirectoryView = [] + if respect_gitignore: + nested_gi = current / '.gitignore' + if nested_gi.is_file(): + ignore.add_gitignore(nested_gi) + views = ignore.view(current) dirnames[:] = [ name for name in dirnames if name not in SKIP_DIR_NAMES and not name.endswith('.egg-info') - and not _IgnoreStack.ignored(views, name, is_dir=True) + and not ( + respect_gitignore + and (current / name) not in tracked_dirs + and _IgnoreStack.ignored(views, name, is_dir=True) + ) ] for name in filenames: - if name.endswith('.py') and not _IgnoreStack.ignored(views, name, is_dir=False): - yield current / name + if not name.endswith('.py'): + continue + candidate = current / name + if ( + respect_gitignore + and candidate not in tracked + and _IgnoreStack.ignored(views, name, is_dir=False) + ): + continue + yield candidate @dataclass @@ -2139,7 +2215,11 @@ def git_staged_python_files() -> Tuple[List[str], Optional[str]]: return files, None -def expand_python_targets(paths: Sequence[str]) -> Tuple[List[str], List[Issue]]: +def expand_python_targets( + paths: Sequence[str], + *, + respect_gitignore: bool = True, +) -> Tuple[List[str], List[Issue]]: """Expand directories to .py files. Explicit files are kept as given.""" targets: List[str] = [] extras: List[Issue] = [] @@ -2160,7 +2240,12 @@ def _add(item: str) -> None: for raw in paths: path = Path(raw) if path.is_dir(): - found = [str(candidate) for candidate in iter_python_files(path)] + found = [ + str(candidate) + for candidate in iter_python_files( + path, respect_gitignore=respect_gitignore, + ) + ] if not found: extras.append(_io_issue(raw, f"No Python files found in '{raw}'")) continue @@ -2609,10 +2694,14 @@ def run_check( baseline_path: Optional[str] = None, update_baseline: Optional[str] = None, jobs: Optional[int] = None, + no_ignore: bool = False, ) -> int: """Analyze one or more files. Returns 1 if any error-severity issue exists. ``jobs``: worker processes (``None``/``0`` = auto, ``1`` = sequential). + ``no_ignore``: walk directories without applying ``.gitignore`` (venvs, + caches, and ``.git`` are still skipped). Explicit file arguments are + never gitignore-filtered. """ out = stream if stream is not None else sys.stdout @@ -2643,7 +2732,9 @@ def run_check( if show_banner and output_format == 'text': print_snake_banner(use_color=use_color) - targets, extra_issues = expand_python_targets(file_list) + targets, extra_issues = expand_python_targets( + file_list, respect_gitignore=not no_ignore, + ) file_reports: List[Tuple[str, List[Issue], Optional[str]]] = [] for extra in extra_issues: diff --git a/src/codesnake/cli.py b/src/codesnake/cli.py index 18715f9..b2082f7 100644 --- a/src/codesnake/cli.py +++ b/src/codesnake/cli.py @@ -54,6 +54,12 @@ def add_check_arguments(parser: argparse.ArgumentParser) -> argparse.ArgumentPar action='store_true', help='Check only Python files staged in git', ) + parser.add_argument( + '--no-ignore', + action='store_true', + help='Do not skip .gitignore matches when walking directories ' + '(venvs, caches, and .git are still skipped)', + ) parser.add_argument( '--baseline', metavar='FILE', @@ -141,6 +147,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: baseline_path=args.baseline, update_baseline=args.update_baseline, jobs=args.jobs, + no_ignore=args.no_ignore, ) if args.command == 'config': diff --git a/test/test_codesnake.py b/test/test_codesnake.py index d858be1..58c4f21 100644 --- a/test/test_codesnake.py +++ b/test/test_codesnake.py @@ -864,6 +864,138 @@ def test_gitignore_skips_files(self): names = {Path(path).name for path in targets} self.assertEqual(names, {'kept.py'}) + def _git_repo(self, tmp): + """A real git repo; skip if git is unavailable.""" + import subprocess + root = Path(tmp).resolve() + try: + for cmd in (['git', 'init', '-q', '.'], + ['git', 'config', 'user.email', 't@e.st'], + ['git', 'config', 'user.name', 'test']): + subprocess.run(cmd, cwd=root, check=True, capture_output=True, timeout=30) + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): + self.skipTest('git not available') + return root + + def _git(self, root, *args): + import subprocess + subprocess.run(['git', *args], cwd=root, check=True, capture_output=True, timeout=30) + + def test_committed_file_is_not_hidden_by_gitignore(self): + """git ignores .gitignore for tracked files; so must the walk. + + Otherwise a PR can add a file, gitignore it, `git add -f` it, and the + default directory walk never sees it. + """ + with tempfile.TemporaryDirectory() as tmp: + root = self._git_repo(tmp) + (root / 'src').mkdir() + (root / 'src' / 'good.py').write_text('x = 1\n', encoding='utf-8') + (root / 'src' / 'evil.py').write_text('eval(input())\n', encoding='utf-8') + (root / '.gitignore').write_text('src/evil.py\n', encoding='utf-8') + self._git(root, 'add', '.gitignore', 'src/good.py') + self._git(root, 'add', '-f', 'src/evil.py') + self._git(root, 'commit', '-q', '-m', 'x') + + targets, _ = expand_python_targets([str(root / 'src')]) + self.assertEqual({Path(t).name for t in targets}, {'good.py', 'evil.py'}) + + def test_committed_file_inside_ignored_directory_is_walked(self): + """An ignored directory holding a tracked file must not be pruned.""" + with tempfile.TemporaryDirectory() as tmp: + root = self._git_repo(tmp) + (root / 'src' / 'hidden').mkdir(parents=True) + (root / 'src' / 'good.py').write_text('x = 1\n', encoding='utf-8') + (root / 'src' / 'hidden' / 'evil.py').write_text('eval(input())\n', encoding='utf-8') + (root / '.gitignore').write_text('src/hidden/\n', encoding='utf-8') + self._git(root, 'add', '.gitignore', 'src/good.py') + self._git(root, 'add', '-f', 'src/hidden/evil.py') + self._git(root, 'commit', '-q', '-m', 'x') + + targets, _ = expand_python_targets([str(root / 'src')]) + self.assertEqual({Path(t).name for t in targets}, {'good.py', 'evil.py'}) + + def test_untracked_gitignored_files_are_still_skipped(self): + """The exemption is for tracked files only — build output stays ignored.""" + with tempfile.TemporaryDirectory() as tmp: + root = self._git_repo(tmp) + (root / 'src' / 'build').mkdir(parents=True) + (root / 'src' / 'good.py').write_text('x = 1\n', encoding='utf-8') + (root / 'src' / 'generated.py').write_text('eval(input())\n', encoding='utf-8') + (root / 'src' / 'build' / 'gen.py').write_text('eval(input())\n', encoding='utf-8') + (root / '.gitignore').write_text('src/generated.py\nsrc/build/\n', encoding='utf-8') + self._git(root, 'add', '.gitignore', 'src/good.py') + self._git(root, 'commit', '-q', '-m', 'x') + + targets, _ = expand_python_targets([str(root / 'src')]) + self.assertEqual({Path(t).name for t in targets}, {'good.py'}) + + def test_no_ignore_includes_gitignored_files(self): + """A committed-but-ignored file must be visible to a CI directory walk.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / '.gitignore').write_text('ignored.py\nsecret/\n', encoding='utf-8') + (root / 'kept.py').write_text('x = 1\n', encoding='utf-8') + (root / 'ignored.py').write_text('eval(1)\n', encoding='utf-8') + (root / 'secret').mkdir() + (root / 'secret' / 'bad.py').write_text('eval(1)\n', encoding='utf-8') + targets, extras = expand_python_targets( + [str(root)], respect_gitignore=False, + ) + self.assertEqual(extras, []) + names = {Path(path).name for path in targets} + self.assertEqual(names, {'kept.py', 'ignored.py', 'bad.py'}) + + def test_no_ignore_still_skips_venvs_and_caches(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / 'kept.py').write_text('x = 1\n', encoding='utf-8') + (root / '__pycache__').mkdir() + (root / '__pycache__' / 'skip.py').write_text('eval(1)\n', encoding='utf-8') + (root / '.venv').mkdir() + (root / '.venv' / 'lib.py').write_text('eval(1)\n', encoding='utf-8') + targets, extras = expand_python_targets( + [str(root)], respect_gitignore=False, + ) + self.assertEqual(extras, []) + self.assertEqual([Path(path).name for path in targets], ['kept.py']) + + def test_run_check_no_ignore_reports_gitignored_error(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / '.gitignore').write_text('evil.py\n', encoding='utf-8') + (root / 'ok.py').write_text('x = 1\n', encoding='utf-8') + (root / 'evil.py').write_text('eval(input())\n', encoding='utf-8') + skipped = StringIO() + self.assertEqual( + run_check( + [str(root)], + config=CheckerConfig(), + output_format='json', + show_banner=False, + color=False, + stream=skipped, + ), + 0, + ) + found = StringIO() + rc = run_check( + [str(root)], + config=CheckerConfig(), + output_format='json', + show_banner=False, + color=False, + stream=found, + no_ignore=True, + ) + self.assertEqual(rc, 1) + codes = [ + issue['code'] + for file_report in json.loads(found.getvalue())['files'] + for issue in file_report['issues'] + ] + self.assertIn('SEC001', codes) + def test_run_check_on_directory(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -2116,6 +2248,27 @@ def test_cli_check_without_files_is_usage_error(self): self.assertEqual(main(['check']), 2) self.assertIn('provide files', err.getvalue()) + def test_cli_no_ignore_checks_gitignored_file(self): + from unittest.mock import patch + from codesnake.cli import main + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / '.gitignore').write_text('evil.py\n', encoding='utf-8') + (root / 'ok.py').write_text('x = 1\n', encoding='utf-8') + (root / 'evil.py').write_text('eval(input())\n', encoding='utf-8') + with patch('sys.stdout', StringIO()): + self.assertEqual( + main(['check', str(root), '--format', 'json', '--no-color']), + 0, + ) + with patch('sys.stdout', StringIO()) as out: + rc = main([ + 'check', str(root), '--format', 'json', '--no-color', + '--no-ignore', + ]) + self.assertEqual(rc, 1) + self.assertIn('SEC001', out.getvalue()) + def test_deeply_nested_file_is_contained_not_fatal(self): """One unanalyzable file must not sink the whole run.""" from codesnake import check_file, run_check @@ -2350,6 +2503,24 @@ def test_without_repo_only_target_gitignore_applies(self): targets, _ = expand_python_targets([str(outer / 'proj')]) self.assertEqual({Path(t).name for t in targets}, {'a.py'}) + def test_no_ignore_includes_root_gitignored_files(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + (root / '.git').mkdir() + (root / '.gitignore').write_text('generated/\n*_pb2.py\n', encoding='utf-8') + src = root / 'src' + (src / 'generated').mkdir(parents=True) + (src / 'a.py').write_text('x = 1\n', encoding='utf-8') + (src / 'proto_pb2.py').write_text('x = 1\n', encoding='utf-8') + (src / 'generated' / 'g.py').write_text('x = 1\n', encoding='utf-8') + targets, _ = expand_python_targets( + [str(src)], respect_gitignore=False, + ) + self.assertEqual( + {Path(t).name for t in targets}, + {'a.py', 'proto_pb2.py', 'g.py'}, + ) + class TestBaselineFingerprints(unittest.TestCase):