boring-agent-memory 0.2.2__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.
@@ -0,0 +1,449 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import sqlite3
6
+ from collections import defaultdict
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+
12
+ from .chunking import CHUNKER_VERSION, DEFAULT_CHUNK_SIZE
13
+ from .ingest import IngestedRecord, ingest_file, iter_candidate_files
14
+ from .privacy import DEFAULT_EXCLUDE_GLOBS, REDACTION_VERSION, SECRET_PATTERNS
15
+ from .schema import (
16
+ SCHEMA_VERSION,
17
+ clear_index,
18
+ connect,
19
+ init_db,
20
+ require_index,
21
+ schema_version,
22
+ )
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class _UpdateOperations:
27
+ delete_ids: tuple[str, ...]
28
+ insert_records: tuple[IngestedRecord, ...]
29
+ removal_paths: tuple[str, ...]
30
+
31
+
32
+ def build_index(
33
+ db_path: Path | str,
34
+ includes: list[str],
35
+ excludes: list[str] | None = None,
36
+ workspace: Path | str | None = None,
37
+ source_type: str = "file",
38
+ max_bytes: int = 512 * 1024,
39
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
40
+ identity_namespace: str | None = None,
41
+ ) -> dict[str, int]:
42
+ excludes = excludes or []
43
+ workspace_path = _workspace_path(workspace)
44
+ records, skipped = _ingest_candidates(
45
+ includes,
46
+ excludes,
47
+ workspace_path,
48
+ source_type,
49
+ max_bytes,
50
+ chunk_size,
51
+ identity_namespace,
52
+ )
53
+ config_json, fingerprint = index_configuration(
54
+ includes,
55
+ excludes,
56
+ workspace_path,
57
+ source_type,
58
+ max_bytes,
59
+ chunk_size,
60
+ identity_namespace,
61
+ )
62
+
63
+ conn = connect(db_path)
64
+ try:
65
+ init_db(conn)
66
+ # Hold the write lock first, so another build cannot fill the index between this check and the clear.
67
+ # init_db commits on a new index but leaves a transaction open on an existing one.
68
+ if not conn.in_transaction:
69
+ conn.execute("BEGIN IMMEDIATE")
70
+ with conn:
71
+ if not records and conn.execute("SELECT 1 FROM documents LIMIT 1").fetchone():
72
+ raise ValueError("no indexable files matched the include paths; refusing to replace a populated index")
73
+ clear_index(conn)
74
+ for record in records:
75
+ insert_record(conn, record)
76
+ _write_index_metadata(conn, config_json, fingerprint, chunk_size)
77
+ _validate_index(conn)
78
+ finally:
79
+ conn.close()
80
+ return {
81
+ "indexed": len(records),
82
+ "chunks": sum(len(record.chunks) for record in records),
83
+ "skipped": skipped,
84
+ }
85
+
86
+
87
+ def update_index(
88
+ db_path: Path | str,
89
+ includes: list[str],
90
+ excludes: list[str] | None = None,
91
+ workspace: Path | str | None = None,
92
+ source_type: str = "file",
93
+ max_bytes: int = 512 * 1024,
94
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
95
+ dry_run: bool = False,
96
+ before_commit: Callable[[sqlite3.Connection], None] | None = None,
97
+ identity_namespace: str | None = None,
98
+ ) -> dict[str, object]:
99
+ """Apply a hash-based index update in one transaction."""
100
+ path = require_index(db_path)
101
+
102
+ excludes = excludes or []
103
+ workspace_path = _workspace_path(workspace)
104
+ records, skipped = _ingest_candidates(
105
+ includes,
106
+ excludes,
107
+ workspace_path,
108
+ source_type,
109
+ max_bytes,
110
+ chunk_size,
111
+ identity_namespace,
112
+ )
113
+ config_json, fingerprint = index_configuration(
114
+ includes,
115
+ excludes,
116
+ workspace_path,
117
+ source_type,
118
+ max_bytes,
119
+ chunk_size,
120
+ identity_namespace,
121
+ )
122
+
123
+ if dry_run:
124
+ wal_path = Path(f"{path}-wal")
125
+ if wal_path.exists() and wal_path.stat().st_size:
126
+ raise ValueError(
127
+ "dry-run requires a checkpointed index with no pending WAL content"
128
+ )
129
+ conn = sqlite3.connect(
130
+ path.resolve().as_uri() + "?mode=ro&immutable=1",
131
+ uri=True,
132
+ )
133
+ conn.row_factory = sqlite3.Row
134
+ if schema_version(conn) != SCHEMA_VERSION:
135
+ conn.close()
136
+ raise ValueError("dry-run requires a current index schema; run bam build first")
137
+ else:
138
+ conn = connect(path)
139
+ try:
140
+ if not dry_run:
141
+ init_db(conn)
142
+ metadata = conn.execute("SELECT * FROM index_metadata WHERE singleton = 1").fetchone()
143
+ if metadata is None:
144
+ raise ValueError("index has no configuration fingerprint; run bam build once")
145
+ if metadata["config_fingerprint"] != fingerprint:
146
+ raise ValueError(
147
+ "index configuration changed; run bam build before using incremental update"
148
+ )
149
+
150
+ existing_rows = conn.execute(
151
+ """
152
+ SELECT
153
+ d.id,
154
+ d.source_path,
155
+ d.source_hash,
156
+ (SELECT count(*) FROM chunks c WHERE c.document_id = d.id) AS chunk_count
157
+ FROM documents d
158
+ WHERE d.workspace = ? AND d.source_type = ?
159
+ ORDER BY source_path
160
+ """,
161
+ (workspace_path.as_posix(), source_type),
162
+ ).fetchall()
163
+ report, operations = _plan_update(existing_rows, records, skipped)
164
+ _validate_removal_access(operations.removal_paths)
165
+ report["applied"] = not dry_run
166
+ report["dry_run"] = dry_run
167
+ if dry_run:
168
+ return report
169
+ if not operations.delete_ids and not operations.insert_records:
170
+ return report
171
+
172
+ with conn:
173
+ for document_id in operations.delete_ids:
174
+ delete_document(conn, document_id)
175
+ for record in operations.insert_records:
176
+ insert_record(conn, record)
177
+ _write_index_metadata(conn, config_json, fingerprint, chunk_size)
178
+ _validate_index(conn)
179
+ if before_commit:
180
+ before_commit(conn)
181
+ return report
182
+ finally:
183
+ conn.close()
184
+
185
+
186
+ def insert_record(conn: sqlite3.Connection, record: IngestedRecord) -> None:
187
+ conn.execute(
188
+ """
189
+ INSERT INTO documents (
190
+ id, source_type, source_path, workspace, title, content,
191
+ source_hash, content_hash, metadata_json, updated_at
192
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
193
+ """,
194
+ (
195
+ record.id,
196
+ record.source_type,
197
+ record.source_path,
198
+ record.workspace,
199
+ record.title,
200
+ record.content,
201
+ record.source_hash,
202
+ record.content_hash,
203
+ record.metadata_json,
204
+ record.updated_at,
205
+ ),
206
+ )
207
+ for chunk in record.chunks:
208
+ conn.execute(
209
+ """
210
+ INSERT INTO chunks (
211
+ id, document_id, heading, heading_key, ordinal,
212
+ start_line, end_line, content, content_hash
213
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
214
+ """,
215
+ (
216
+ chunk.id,
217
+ record.id,
218
+ chunk.heading,
219
+ chunk.heading_key,
220
+ chunk.ordinal,
221
+ chunk.start_line,
222
+ chunk.end_line,
223
+ chunk.content,
224
+ chunk.content_hash,
225
+ ),
226
+ )
227
+ conn.execute(
228
+ """
229
+ INSERT INTO chunks_fts (chunk_id, title, heading, content, source_path)
230
+ VALUES (?, ?, ?, ?, ?)
231
+ """,
232
+ (chunk.id, record.title, chunk.heading, chunk.content, record.source_path),
233
+ )
234
+
235
+
236
+ def delete_document(conn: sqlite3.Connection, document_id: str) -> None:
237
+ conn.execute(
238
+ "DELETE FROM chunks_fts WHERE chunk_id IN "
239
+ "(SELECT id FROM chunks WHERE document_id = ?)",
240
+ (document_id,),
241
+ )
242
+ conn.execute("DELETE FROM documents WHERE id = ?", (document_id,))
243
+
244
+
245
+ def index_configuration(
246
+ includes: list[str],
247
+ excludes: list[str],
248
+ workspace: Path,
249
+ source_type: str,
250
+ max_bytes: int,
251
+ chunk_size: int,
252
+ identity_namespace: str | None = None,
253
+ ) -> tuple[str, str]:
254
+ payload = {
255
+ "schema_version": SCHEMA_VERSION,
256
+ "chunker_version": CHUNKER_VERSION,
257
+ "chunk_size": chunk_size,
258
+ "includes": sorted(_normalize_include(value, workspace) for value in includes),
259
+ "excludes": sorted(excludes),
260
+ "workspace": workspace.as_posix(),
261
+ "source_type": source_type,
262
+ "max_bytes": max_bytes,
263
+ "document_id_version": 1,
264
+ "identity_namespace": identity_namespace,
265
+ "redaction_version": REDACTION_VERSION,
266
+ "tokenizer": "unicode61",
267
+ "privacy_policy": hashlib.sha256(
268
+ json.dumps(
269
+ {
270
+ "default_excludes": DEFAULT_EXCLUDE_GLOBS,
271
+ "secret_patterns": [
272
+ {"pattern": pattern.pattern, "flags": pattern.flags}
273
+ for pattern in SECRET_PATTERNS
274
+ ],
275
+ },
276
+ sort_keys=True,
277
+ ).encode("utf-8")
278
+ ).hexdigest(),
279
+ }
280
+ rendered = json.dumps(payload, sort_keys=True, separators=(",", ":"))
281
+ return rendered, hashlib.sha256(rendered.encode("utf-8")).hexdigest()
282
+
283
+
284
+ def _ingest_candidates(
285
+ includes: list[str],
286
+ excludes: list[str],
287
+ workspace: Path,
288
+ source_type: str,
289
+ max_bytes: int,
290
+ chunk_size: int,
291
+ identity_namespace: str | None,
292
+ ) -> tuple[list[IngestedRecord], int]:
293
+ records: list[IngestedRecord] = []
294
+ skipped = 0
295
+ try:
296
+ candidates = iter_candidate_files(includes, excludes, workspace)
297
+ except OSError as exc:
298
+ raise OSError(f"failed to scan candidate sources in {workspace}: {exc}") from exc
299
+ for file_path in candidates:
300
+ try:
301
+ record = ingest_file(
302
+ file_path,
303
+ workspace=workspace,
304
+ source_type=source_type,
305
+ max_bytes=max_bytes,
306
+ chunk_size=chunk_size,
307
+ identity_namespace=identity_namespace,
308
+ )
309
+ except OSError as exc:
310
+ raise OSError(f"failed to read candidate source {file_path}: {exc}") from exc
311
+ if record is None:
312
+ skipped += 1
313
+ continue
314
+ records.append(record)
315
+ return records, skipped
316
+
317
+
318
+ def _plan_update(
319
+ existing_rows: list[sqlite3.Row],
320
+ records: list[IngestedRecord],
321
+ skipped: int,
322
+ ) -> tuple[dict[str, object], _UpdateOperations]:
323
+ existing_by_path = {row["source_path"]: row for row in existing_rows}
324
+ current_by_path = {record.source_path: record for record in records}
325
+
326
+ unchanged = sorted(
327
+ path
328
+ for path in existing_by_path.keys() & current_by_path.keys()
329
+ if existing_by_path[path]["source_hash"] == current_by_path[path].source_hash
330
+ )
331
+ modified = sorted(
332
+ path
333
+ for path in existing_by_path.keys() & current_by_path.keys()
334
+ if existing_by_path[path]["source_hash"] != current_by_path[path].source_hash
335
+ )
336
+ added = set(current_by_path.keys() - existing_by_path.keys())
337
+ removed = set(existing_by_path.keys() - current_by_path.keys())
338
+
339
+ removed_by_hash: dict[str, list[str]] = defaultdict(list)
340
+ added_by_hash: dict[str, list[str]] = defaultdict(list)
341
+ for path in removed:
342
+ removed_by_hash[existing_by_path[path]["source_hash"]].append(path)
343
+ for path in added:
344
+ added_by_hash[current_by_path[path].source_hash].append(path)
345
+
346
+ moves: list[dict[str, str]] = []
347
+ for content_hash in sorted(removed_by_hash.keys() & added_by_hash.keys()):
348
+ old_paths = sorted(removed_by_hash[content_hash])
349
+ new_paths = sorted(added_by_hash[content_hash])
350
+ if len(old_paths) == 1 and len(new_paths) == 1:
351
+ old_path = old_paths[0]
352
+ new_path = new_paths[0]
353
+ moves.append({"from": old_path, "to": new_path})
354
+ removed.remove(old_path)
355
+ added.remove(new_path)
356
+
357
+ delete_paths = sorted([*removed, *modified, *(move["from"] for move in moves)])
358
+ insert_paths = sorted([*added, *modified, *(move["to"] for move in moves)])
359
+ report: dict[str, object] = {
360
+ "added": len(added),
361
+ "modified": len(modified),
362
+ "moved": len(moves),
363
+ "removed": len(removed),
364
+ "unchanged": len(unchanged),
365
+ "skipped": skipped,
366
+ "chunks_added": sum(len(current_by_path[path].chunks) for path in insert_paths),
367
+ "chunks_removed": sum(
368
+ int(existing_by_path[path]["chunk_count"]) for path in delete_paths
369
+ ),
370
+ "changes": {
371
+ "added": sorted(added),
372
+ "modified": modified,
373
+ "moved": moves,
374
+ "removed": sorted(removed),
375
+ },
376
+ }
377
+ operations = _UpdateOperations(
378
+ delete_ids=tuple(existing_by_path[path]["id"] for path in delete_paths),
379
+ insert_records=tuple(current_by_path[path] for path in insert_paths),
380
+ removal_paths=tuple(sorted(removed)),
381
+ )
382
+ return report, operations
383
+
384
+
385
+ def _write_index_metadata(
386
+ conn: sqlite3.Connection,
387
+ config_json: str,
388
+ fingerprint: str,
389
+ chunk_size: int,
390
+ ) -> None:
391
+ conn.execute(
392
+ """
393
+ INSERT INTO index_metadata (
394
+ singleton, config_fingerprint, config_json, built_at,
395
+ chunker_version, chunk_size
396
+ ) VALUES (1, ?, ?, ?, ?, ?)
397
+ ON CONFLICT(singleton) DO UPDATE SET
398
+ config_fingerprint = excluded.config_fingerprint,
399
+ config_json = excluded.config_json,
400
+ built_at = excluded.built_at,
401
+ chunker_version = excluded.chunker_version,
402
+ chunk_size = excluded.chunk_size
403
+ """,
404
+ (
405
+ fingerprint,
406
+ config_json,
407
+ datetime.now(UTC).isoformat(),
408
+ CHUNKER_VERSION,
409
+ chunk_size,
410
+ ),
411
+ )
412
+
413
+
414
+ def _workspace_path(workspace: Path | str | None) -> Path:
415
+ return Path(workspace).expanduser().resolve() if workspace else Path.cwd().resolve()
416
+
417
+
418
+ def _normalize_include(value: str, workspace: Path) -> str:
419
+ path = Path(value).expanduser()
420
+ if not path.is_absolute():
421
+ path = workspace / path
422
+ return path.resolve().as_posix()
423
+
424
+
425
+ def _validate_index(conn: sqlite3.Connection) -> None:
426
+ foreign_key_error = conn.execute("PRAGMA foreign_key_check").fetchone()
427
+ if foreign_key_error is not None:
428
+ raise sqlite3.IntegrityError(f"index foreign-key violation: {tuple(foreign_key_error)}")
429
+ chunk_count = conn.execute("SELECT count(*) FROM chunks").fetchone()[0]
430
+ fts_count = conn.execute("SELECT count(*) FROM chunks_fts").fetchone()[0]
431
+ if chunk_count != fts_count:
432
+ raise sqlite3.IntegrityError(
433
+ f"chunk/FTS row mismatch: chunks={chunk_count} fts={fts_count}"
434
+ )
435
+
436
+
437
+ def _validate_removal_access(paths: tuple[str, ...]) -> None:
438
+ """Distinguish real deletion from a source hidden by an access failure."""
439
+ for value in paths:
440
+ path = Path(value)
441
+ try:
442
+ with path.open("rb") as source:
443
+ source.read(1)
444
+ except FileNotFoundError:
445
+ continue
446
+ except OSError as exc:
447
+ raise OSError(
448
+ f"cannot confirm removal of indexed source {path}: {exc}"
449
+ ) from exc
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime
8
+ from glob import glob
9
+ from pathlib import Path
10
+
11
+ from .chunking import DEFAULT_CHUNK_SIZE, TextChunk, chunk_text
12
+ from .privacy import redact_secrets, should_exclude_path
13
+
14
+ TEXT_EXTENSIONS = {
15
+ ".adoc",
16
+ ".cfg",
17
+ ".conf",
18
+ ".csv",
19
+ ".ini",
20
+ ".json",
21
+ ".jsonl",
22
+ ".log",
23
+ ".md",
24
+ ".mdx",
25
+ ".py",
26
+ ".rst",
27
+ ".sql",
28
+ ".toml",
29
+ ".txt",
30
+ ".yaml",
31
+ ".yml",
32
+ }
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class IngestedRecord:
37
+ id: str
38
+ source_type: str
39
+ source_path: str
40
+ workspace: str
41
+ title: str
42
+ content: str
43
+ source_hash: str
44
+ content_hash: str
45
+ metadata_json: str
46
+ updated_at: str
47
+ chunks: tuple[TextChunk, ...]
48
+
49
+
50
+ def iter_candidate_files(
51
+ includes: Iterable[str],
52
+ excludes: Iterable[str] = (),
53
+ workspace: Path | None = None,
54
+ ) -> list[Path]:
55
+ root = (workspace or Path.cwd()).expanduser().resolve()
56
+ files: list[Path] = []
57
+ seen: set[Path] = set()
58
+
59
+ for include in includes:
60
+ candidates = _expand_include(include, root)
61
+ for candidate in candidates:
62
+ resolved = candidate.resolve()
63
+ if resolved in seen:
64
+ continue
65
+ if should_exclude_path(resolved, root=root, exclude_globs=tuple(excludes)):
66
+ continue
67
+ if not looks_like_text_file(resolved):
68
+ continue
69
+ seen.add(resolved)
70
+ files.append(resolved)
71
+
72
+ return sorted(files, key=lambda p: p.as_posix())
73
+
74
+
75
+ def _expand_include(include: str, root: Path) -> list[Path]:
76
+ path = Path(include).expanduser()
77
+ if not path.is_absolute():
78
+ path = root / path
79
+
80
+ pattern = path.as_posix()
81
+ if any(char in pattern for char in "*?[]"):
82
+ matches = [Path(match) for match in glob(pattern, recursive=True)]
83
+ elif path.exists():
84
+ matches = [path]
85
+ else:
86
+ raise FileNotFoundError(f"include path does not exist: {include}")
87
+
88
+ files: list[Path] = []
89
+ for match in matches:
90
+ if match.is_file():
91
+ files.append(match)
92
+ elif match.is_dir():
93
+ files.extend(p for p in match.rglob("*") if p.is_file())
94
+ return files
95
+
96
+
97
+ def ingest_file(
98
+ path: Path,
99
+ workspace: Path | None = None,
100
+ source_type: str = "file",
101
+ max_bytes: int = 512 * 1024,
102
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
103
+ identity_namespace: str | None = None,
104
+ ) -> IngestedRecord | None:
105
+ stat = path.stat()
106
+ if stat.st_size > max_bytes:
107
+ return None
108
+
109
+ raw = path.read_bytes()
110
+ if b"\x00" in raw:
111
+ return None
112
+
113
+ try:
114
+ content = raw.decode("utf-8")
115
+ except UnicodeDecodeError:
116
+ content = raw.decode("utf-8", errors="replace")
117
+
118
+ source_hash = hashlib.sha256(raw).hexdigest()
119
+ content, redactions = redact_secrets(content)
120
+ content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
121
+ resolved = path.expanduser().resolve()
122
+ root = (workspace or Path.cwd()).expanduser().resolve()
123
+ try:
124
+ relative_path = resolved.relative_to(root).as_posix()
125
+ except ValueError:
126
+ relative_path = resolved.as_posix()
127
+ identity_scope = (
128
+ f"namespace:{identity_namespace}"
129
+ if identity_namespace is not None
130
+ else root.as_posix()
131
+ )
132
+ identity = f"{source_type}\0{identity_scope}\0{relative_path}"
133
+ record_id = hashlib.sha256(identity.encode("utf-8")).hexdigest()
134
+ title = title_for_path(resolved)
135
+ metadata = {
136
+ "bytes": stat.st_size,
137
+ "mtime": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
138
+ "extension": resolved.suffix.lower(),
139
+ "redactions": redactions,
140
+ "relative_path": relative_path,
141
+ }
142
+ chunks = chunk_text(record_id, content, resolved.suffix.lower(), max_chars=chunk_size)
143
+
144
+ return IngestedRecord(
145
+ id=record_id,
146
+ source_type=source_type,
147
+ source_path=resolved.as_posix(),
148
+ workspace=root.as_posix(),
149
+ title=title,
150
+ content=content,
151
+ source_hash=source_hash,
152
+ content_hash=content_hash,
153
+ metadata_json=json.dumps(metadata, sort_keys=True),
154
+ updated_at=datetime.now(UTC).isoformat(),
155
+ chunks=chunks,
156
+ )
157
+
158
+
159
+ def looks_like_text_file(path: Path) -> bool:
160
+ if path.suffix.lower() in TEXT_EXTENSIONS:
161
+ return True
162
+ with path.open("rb") as source:
163
+ chunk = source.read(2048)
164
+ if b"\x00" in chunk:
165
+ return False
166
+ if not chunk:
167
+ return True
168
+ printable = sum(1 for byte in chunk if byte in b"\n\r\t" or 32 <= byte <= 126)
169
+ return printable / len(chunk) > 0.85
170
+
171
+
172
+ def title_for_path(path: Path) -> str:
173
+ if path.name.lower() in {"readme.md", "index.md"} and path.parent.name:
174
+ return path.parent.name
175
+ return path.name
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import re
5
+ from pathlib import Path
6
+
7
+ REDACTION_VERSION = 2
8
+
9
+
10
+ DEFAULT_EXCLUDE_GLOBS = (
11
+ "**/.git/**",
12
+ "**/__pycache__/**",
13
+ "**/.pytest_cache/**",
14
+ "**/.mypy_cache/**",
15
+ "**/.ruff_cache/**",
16
+ "**/node_modules/**",
17
+ "**/.venv/**",
18
+ "**/.env",
19
+ "**/.env.*",
20
+ "**/secrets/**",
21
+ "**/*secret*/**",
22
+ "**/*.pem",
23
+ "**/*.key",
24
+ "**/id_rsa",
25
+ "**/id_ed25519",
26
+ "**/.DS_Store",
27
+ )
28
+
29
+
30
+ SECRET_PATTERNS = (
31
+ re.compile(r"sk-[A-Za-z0-9_-]{20,}"),
32
+ re.compile(r"github_pat_[A-Za-z0-9_]{20,}"),
33
+ re.compile(r"ghp_[A-Za-z0-9_]{20,}"),
34
+ re.compile(r"AKIA[0-9A-Z]{16}"),
35
+ re.compile(r"(?i)bearer\s+[A-Za-z0-9._~+/=-]{20,}"),
36
+ re.compile(
37
+ r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
38
+ re.DOTALL,
39
+ ),
40
+ re.compile(
41
+ r"(?im)^([A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY)[A-Z0-9_]*\s*=\s*).+$"
42
+ ),
43
+ )
44
+
45
+
46
+ def normalized_path(path: Path) -> str:
47
+ return path.expanduser().resolve().as_posix()
48
+
49
+
50
+ def should_exclude_path(
51
+ path: Path,
52
+ root: Path | None = None,
53
+ exclude_globs: list[str] | tuple[str, ...] = (),
54
+ ) -> bool:
55
+ absolute = normalized_path(path)
56
+ candidates = [absolute, path.as_posix()]
57
+ if root is not None:
58
+ try:
59
+ candidates.append(path.resolve().relative_to(root.resolve()).as_posix())
60
+ except ValueError:
61
+ pass
62
+
63
+ for pattern in (*DEFAULT_EXCLUDE_GLOBS, *exclude_globs):
64
+ expanded = str(Path(pattern).expanduser()) if pattern.startswith("~") else pattern
65
+ if any(fnmatch.fnmatch(candidate, expanded) for candidate in candidates):
66
+ return True
67
+ return False
68
+
69
+
70
+ def redact_secrets(content: str) -> tuple[str, int]:
71
+ redactions = 0
72
+ redacted = content
73
+ for pattern in SECRET_PATTERNS:
74
+ redacted, count = pattern.subn(_replacement, redacted)
75
+ redactions += count
76
+ return redacted, redactions
77
+
78
+
79
+ def _replacement(match: re.Match[str]) -> str:
80
+ if match.lastindex:
81
+ replacement = f"{match.group(1)}[REDACTED]"
82
+ else:
83
+ replacement = "[REDACTED]"
84
+ return replacement + ("\n" * match.group(0).count("\n"))