| 1025 | |
| 1026 | |
| 1027 | def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace: bool | None = None, extra_excludes: list[str] | None = None) -> dict: |
| 1028 | root = root.resolve() |
| 1029 | if follow_symlinks is None: |
| 1030 | follow_symlinks = False |
| 1031 | google_workspace = google_workspace_enabled() if google_workspace is None else google_workspace |
| 1032 | files: dict[FileType, list[str]] = { |
| 1033 | FileType.CODE: [], |
| 1034 | FileType.DOCUMENT: [], |
| 1035 | FileType.PAPER: [], |
| 1036 | FileType.IMAGE: [], |
| 1037 | FileType.VIDEO: [], |
| 1038 | } |
| 1039 | total_words = 0 |
| 1040 | |
| 1041 | def _wc(path: Path) -> int: |
| 1042 | # Cache word counts against each file's stat signature so unchanged |
| 1043 | # PDFs/docx aren't re-parsed on every run just to size the corpus (#1656). |
| 1044 | from graphify import cache as _cache |
| 1045 | return _cache.cached_word_count(path, root, count_words) |
| 1046 | |
| 1047 | skipped_sensitive: list[str] = [] |
| 1048 | ignore_patterns = _load_graphifyignore(root) |
| 1049 | ignore_cache: dict[Path, bool] = {} # shared across all _is_ignored calls in this scan |
| 1050 | # CLI --exclude patterns are anchored at the scan root and appended last |
| 1051 | # so they win over any .graphifyignore/.gitignore rules (#947). |
| 1052 | if extra_excludes: |
| 1053 | for pat in extra_excludes: |
| 1054 | line = _parse_gitignore_line(pat) |
| 1055 | if line: |
| 1056 | ignore_patterns.append((root, line)) |
| 1057 | include_patterns = _load_graphifyinclude(root) |
| 1058 | |
| 1059 | # Always include graphify-out/memory/ - query results filed back into the graph |
| 1060 | memory_dir = root / GRAPHIFY_OUT / "memory" |
| 1061 | scan_paths = [root] |
| 1062 | if memory_dir.exists(): |
| 1063 | scan_paths.append(memory_dir) |
| 1064 | |
| 1065 | seen: set[Path] = set() |
| 1066 | all_files: list[Path] = [] |
| 1067 | |
| 1068 | for scan_root in scan_paths: |
| 1069 | in_memory_tree = memory_dir.exists() and str(scan_root).startswith(str(memory_dir)) |
| 1070 | for dirpath, dirnames, filenames in os.walk(scan_root, followlinks=follow_symlinks): |
| 1071 | dp = Path(dirpath) |
| 1072 | if follow_symlinks and os.path.islink(dirpath): |
| 1073 | real = os.path.realpath(dirpath) |
| 1074 | parent_real = os.path.realpath(os.path.dirname(dirpath)) |
| 1075 | if parent_real == real or parent_real.startswith(real + os.sep): |
| 1076 | dirnames.clear() |
| 1077 | continue |
| 1078 | if not in_memory_tree: |
| 1079 | # Prune noise dirs in-place so os.walk never descends into them. |
| 1080 | # Dot dirs are allowed — users often want .github/, .claude/, etc. |
| 1081 | # Framework caches (.next, .nuxt, …) are caught by _is_noise_dir. |
| 1082 | # Negations need no special-casing here: _is_ignored already applies |
| 1083 | # last-match-wins (so `!dir/` un-ignores a directory and it won't be |
| 1084 | # pruned) and the gitignore parent-exclusion rule (a `!` cannot rescue |