file-brief 2.0.0

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,2590 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ ===============================================================================
4
+ 代码介绍
5
+ ===============================================================================
6
+ 输入:
7
+ 1. 子命令 catalog、lookup、search 或 info。
8
+ 2. --task-root 指定“大任务”根目录;省略时使用当前工作目录。
9
+ 3. catalog/lookup 接受任务根目录内的文件或目录路径;search 接受检索词。
10
+ 4. 所有子命令支持 --json 输出;catalog/lookup 支持 --exclude 附加排除名。
11
+
12
+ 输出:
13
+ - 标准输出只返回受 --limit 限制的简短状态行(或 --json 的机器可读记录),
14
+ 适合 Agent 直接读取。
15
+ - catalog 在 <task-root>/.file-catalog/ 中写入:
16
+ INDEX.md
17
+ documents/<任务相对路径哈希>.md
18
+ catalog.sqlite3
19
+ .gitignore
20
+ - Markdown 说明包含路径、格式、结构、统计和解析限制,不保存数据行、单元格样例、
21
+ 正文段落、类别值或源代码片段。
22
+
23
+ 作用:
24
+ 把每次任务都会重复出现的“先检查输入文件”步骤封装为可复用工具。Agent 先调用
25
+ lookup;说明缺失或过期时再调用 catalog;之后直接使用说明文档完成任务设计,
26
+ 避免在生产脚本中混入冗长且不可复用的文件探查代码。技能本身与具体 Agent 平台
27
+ (OpenAI Codex、Claude Code、DeepSeek Harness 等)无关:任何能够执行 Python
28
+ 命令的 Agent 都可以按相同工作流使用。
29
+
30
+ 设计逻辑:
31
+ - 以任务内相对路径作为稳定身份,因此整个任务文件夹移动后仍能匹配。
32
+ - 以大小和 mtime_ns 快速判断新鲜度;需要更新时再流式计算 SHA-256。
33
+ - 同一任务内 SHA-256 相同的文件复用已有结构结果。
34
+ - 解析器按格式分层;缺少依赖或格式未知时生成通用说明和明确警告。
35
+ - 所有读取均采用流式读取或有界采样;R 数据由同目录 inspect_r_data.R 处理。
36
+ - 新增格式解析器只依赖标准库:SQLite(表/列/行数)、ZIP/JAR/APK(成员清单)、
37
+ TAR/GZIP(成员与类型)、XML/HTML(标签与属性键)、Jupyter notebook(单元格
38
+ 统计);CSV/TSV 自动探测分隔符;更多编程语言按扩展名做表驱动结构提取。
39
+ - SQLite 使用 WAL、busy_timeout 和事务;Markdown 使用临时文件 + os.replace 原子写入。
40
+ - catalog 的文件解析使用有界线程池并行,数据库写入仍串行,保证确定性输出。
41
+
42
+ 主要函数:
43
+ resolve_task_root() 解析并验证任务根目录。
44
+ gather_files() 递归收集任务文件并应用排除规则。
45
+ sha256_file() 流式计算内容哈希。
46
+ detect_delimiter() 探测分隔文本的分隔符。
47
+ analyze_file() 按扩展名路由到具体解析器。
48
+ catalog_files() 增量建档、内容复用、缺失标记和索引更新。
49
+ lookup_files() 判断说明的新鲜度。
50
+ search_catalog() 查询任务内 SQLite 索引。
51
+ catalog_info() 输出任务目录的统计信息。
52
+ render_document() 生成不含原始样例的 Markdown 说明。
53
+ render_index() 生成可跟踪的任务级 INDEX.md。
54
+ main() 解析命令行并调度子命令。
55
+
56
+ 调用方式:
57
+ python file_catalog.py catalog --task-root "D:\\project"
58
+ python file_catalog.py catalog --task-root "D:\\project" "data\\input.csv"
59
+ python file_catalog.py lookup --task-root "D:\\project" "data\\input.csv" --json
60
+ python file_catalog.py search --task-root "D:\\project" "species"
61
+ python file_catalog.py info --task-root "D:\\project"
62
+ ===============================================================================
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import argparse
68
+ import ast
69
+ import csv
70
+ import datetime as dt
71
+ import gzip
72
+ import hashlib
73
+ import html.parser
74
+ import json
75
+ import mimetypes
76
+ import os
77
+ import re
78
+ import shutil
79
+ import sqlite3
80
+ import subprocess
81
+ import sys
82
+ import tarfile
83
+ import zipfile
84
+ from concurrent.futures import ThreadPoolExecutor
85
+ from dataclasses import dataclass
86
+ from pathlib import Path
87
+ from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
88
+ from xml.etree import ElementTree
89
+
90
+
91
+ CATALOG_VERSION = 2
92
+ SAMPLE_BYTES = 2 * 1024 * 1024
93
+ TABLE_SAMPLE_ROWS = 1000
94
+ MAX_STRUCTURAL_ITEMS = 200
95
+ MAX_JSON_BYTES = 64 * 1024 * 1024
96
+ MAX_ARCHIVE_MEMBERS = 200_000
97
+ MAX_XML_ELEMENTS = 2_000_000
98
+ MAX_PARSE_WORKERS = 4
99
+ EXCLUDED_DIR_NAMES = {
100
+ ".git",
101
+ ".hg",
102
+ ".svn",
103
+ ".file-catalog",
104
+ ".venv",
105
+ "venv",
106
+ "env",
107
+ "node_modules",
108
+ "__pycache__",
109
+ ".pytest_cache",
110
+ ".mypy_cache",
111
+ ".ruff_cache",
112
+ ".cache",
113
+ "dist",
114
+ "build",
115
+ ".idea",
116
+ ".gradle",
117
+ ".tox",
118
+ ".nox",
119
+ ".eggs",
120
+ ".terraform",
121
+ ".next",
122
+ ".nuxt",
123
+ ".svelte-kit",
124
+ }
125
+ TEXT_EXTENSIONS = {
126
+ ".txt",
127
+ ".md",
128
+ ".markdown",
129
+ ".rst",
130
+ ".log",
131
+ ".sql",
132
+ ".ini",
133
+ ".cfg",
134
+ ".conf",
135
+ }
136
+ CODE_EXTENSIONS = {
137
+ ".py",
138
+ ".r",
139
+ ".js",
140
+ ".jsx",
141
+ ".ts",
142
+ ".tsx",
143
+ ".java",
144
+ ".c",
145
+ ".h",
146
+ ".cpp",
147
+ ".hpp",
148
+ ".cs",
149
+ ".go",
150
+ ".rs",
151
+ ".sh",
152
+ ".ps1",
153
+ ".rb",
154
+ ".php",
155
+ ".kt",
156
+ ".kts",
157
+ ".swift",
158
+ ".scala",
159
+ ".jl",
160
+ ".lua",
161
+ ".pl",
162
+ ".pm",
163
+ ".dart",
164
+ ".groovy",
165
+ ".ex",
166
+ ".exs",
167
+ ".hs",
168
+ ".erl",
169
+ ".hrl",
170
+ ".fs",
171
+ ".fsx",
172
+ ".vb",
173
+ }
174
+ ARCHIVE_EXTENSIONS = {".zip", ".jar", ".war", ".ear", ".apk", ".whl", ".egg"}
175
+ TAR_EXTENSIONS = {".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz"}
176
+ SQLITE_EXTENSIONS = {".db", ".sqlite", ".sqlite3", ".db3"}
177
+ XML_EXTENSIONS = {".xml", ".xhtml", ".xsd", ".xsl", ".xslt", ".svg", ".kml", ".gpx"}
178
+ HTML_EXTENSIONS = {".html", ".htm"}
179
+
180
+ # 语言无关的源代码结构提取表:按扩展名提供声明/导入正则。
181
+ # 所有捕获组只取“结构名称”(函数名、类名、导入路径),不保存源码片段。
182
+ LANGUAGE_PROFILES: Dict[str, Dict[str, str]] = {
183
+ ".java": {
184
+ "format_name": "Java source",
185
+ "decl_re": r"(?m)^\s*(?:public|protected|private|static|final|abstract|native|synchronized|\s)*"
186
+ r"(?:class|interface|enum|record|@interface)\s+([A-Za-z_]\w*)",
187
+ "import_re": r"(?m)^\s*import\s+(?:static\s+)?([\w.]+)",
188
+ "package_re": r"(?m)^\s*package\s+([\w.]+)",
189
+ },
190
+ ".go": {
191
+ "format_name": "Go source",
192
+ "decl_re": r"(?m)^\s*(?:func|type|struct|interface)\s+([A-Za-z_]\w*)",
193
+ "import_re": r"(?m)^\s*import\s+(?:[\w.]+\s+)??\"([^\"]+)\"|^\s*\"([^\"]+)\"",
194
+ "package_re": r"(?m)^\s*package\s+([a-z]\w*)",
195
+ },
196
+ ".rs": {
197
+ "format_name": "Rust source",
198
+ "decl_re": r"(?m)^\s*(?:pub\s+)?(?:fn|struct|enum|impl|trait|type|mod|const|static)\s+([A-Za-z_]\w*)",
199
+ "import_re": r"(?m)^\s*(?:pub\s+)?use\s+([\w:]+)",
200
+ },
201
+ ".c": {
202
+ "format_name": "C source",
203
+ "decl_re": r"(?m)^\s*(?:static\s+|inline\s+|const\s+)*[A-Za-z_]\w*\s*\*?\s*"
204
+ r"([A-Za-z_]\w*)\s*\(",
205
+ "import_re": r"(?m)^\s*#\s*include\s*[<\"]([^>\"]+)[>\"]",
206
+ },
207
+ ".h": {
208
+ "format_name": "C/C++ header",
209
+ "decl_re": r"(?m)^\s*(?:class|struct|enum|union|typedef)\s+([A-Za-z_]\w*)",
210
+ "import_re": r"(?m)^\s*#\s*include\s*[<\"]([^>\"]+)[>\"]",
211
+ },
212
+ ".cpp": {
213
+ "format_name": "C++ source",
214
+ "decl_re": r"(?m)^\s*(?:template\s*<[^>]*>\s*)?(?:class|struct|enum|union|namespace|"
215
+ r"inline\s+)?(?:[A-Za-z_]\w*\s*::\s*)*([A-Za-z_]\w*)\s*(?:\(|\{)",
216
+ "import_re": r"(?m)^\s*#\s*include\s*[<\"]([^>\"]+)[>\"]",
217
+ },
218
+ ".hpp": {
219
+ "format_name": "C++ header",
220
+ "decl_re": r"(?m)^\s*(?:class|struct|enum|union|namespace|template)\s+([A-Za-z_]\w*)",
221
+ "import_re": r"(?m)^\s*#\s*include\s*[<\"]([^>\"]+)[>\"]",
222
+ },
223
+ ".cs": {
224
+ "format_name": "C# source",
225
+ "decl_re": r"(?m)^\s*(?:public|private|protected|internal|static|sealed|abstract|"
226
+ r"partial|readonly|async|\s)*"
227
+ r"(?:class|struct|interface|enum|record|namespace)\s+([A-Za-z_]\w*)",
228
+ "import_re": r"(?m)^\s*using\s+([\w.]+)\s*;",
229
+ },
230
+ ".rb": {
231
+ "format_name": "Ruby source",
232
+ "decl_re": r"(?m)^\s*(?:def|class|module)\s+([A-Za-z_]\w*)",
233
+ "import_re": r"(?m)^\s*require(?:_relative)?\s+[\"']([^\"']+)",
234
+ },
235
+ ".php": {
236
+ "format_name": "PHP source",
237
+ "decl_re": r"(?m)^\s*(?:function|class|interface|trait|enum)\s+([A-Za-z_]\w*)",
238
+ "import_re": r"(?m)^\s*(?:namespace|use)\s+([A-Za-z_\\][\w\\]*)",
239
+ },
240
+ ".kt": {
241
+ "format_name": "Kotlin source",
242
+ "decl_re": r"(?m)^\s*(?:fun|class|data\s+class|object|interface|enum\s+class|"
243
+ r"sealed\s+class)\s+([A-Za-z_]\w*)",
244
+ "import_re": r"(?m)^\s*import\s+([\w.]+)",
245
+ },
246
+ ".kts": {
247
+ "format_name": "Kotlin script",
248
+ "decl_re": r"(?m)^\s*(?:fun|class|data\s+class|object|interface)\s+([A-Za-z_]\w*)",
249
+ "import_re": r"(?m)^\s*import\s+([\w.]+)",
250
+ },
251
+ ".swift": {
252
+ "format_name": "Swift source",
253
+ "decl_re": r"(?m)^\s*(?:public|private|internal|fileprivate|open|\s)*"
254
+ r"(?:func|class|struct|enum|protocol|extension)\s+([A-Za-z_]\w*)",
255
+ "import_re": r"(?m)^\s*import\s+([\w.]+)",
256
+ },
257
+ ".scala": {
258
+ "format_name": "Scala source",
259
+ "decl_re": r"(?m)^\s*(?:def|class|object|trait|case\s+class|enum)\s+([A-Za-z_]\w*)",
260
+ "import_re": r"(?m)^\s*import\s+([\w.]+)",
261
+ },
262
+ ".jl": {
263
+ "format_name": "Julia source",
264
+ "decl_re": r"(?m)^\s*(?:function|macro|struct|mutable\s+struct|abstract\s+type)\s+([A-Za-z_]\w*)",
265
+ "import_re": r"(?m)^\s*(?:using|import)\s+([\w.]+)",
266
+ },
267
+ ".lua": {
268
+ "format_name": "Lua source",
269
+ "decl_re": r"(?m)^\s*function\s+([A-Za-z_]\w*(?:[.:][A-Za-z_]\w*)*)",
270
+ "import_re": r"(?m)^\s*require\s*\(\s*[\"']([^\"']+)",
271
+ },
272
+ ".pl": {
273
+ "format_name": "Perl source",
274
+ "decl_re": r"(?m)^\s*sub\s+([A-Za-z_]\w*)",
275
+ "import_re": r"(?m)^\s*use\s+([A-Za-z_:]+)",
276
+ },
277
+ ".pm": {
278
+ "format_name": "Perl module",
279
+ "decl_re": r"(?m)^\s*sub\s+([A-Za-z_]\w*)",
280
+ "import_re": r"(?m)^\s*use\s+([A-Za-z_:]+)",
281
+ },
282
+ ".dart": {
283
+ "format_name": "Dart source",
284
+ "decl_re": r"(?m)^\s*(?:class|enum|mixin|extension)\s+([A-Za-z_]\w*)",
285
+ "import_re": r"(?m)^\s*import\s+[\"']([^\"']+)",
286
+ },
287
+ ".groovy": {
288
+ "format_name": "Groovy source",
289
+ "decl_re": r"(?m)^\s*(?:def|class|interface|enum|trait)\s+([A-Za-z_]\w*)",
290
+ "import_re": r"(?m)^\s*import\s+([\w.]+)",
291
+ },
292
+ ".ex": {
293
+ "format_name": "Elixir source",
294
+ "decl_re": r"(?m)^\s*defp?\s+([A-Za-z_]\w*)",
295
+ "import_re": r"(?m)^\s*(?:use|import|require)\s+([\w.]+)",
296
+ },
297
+ ".exs": {
298
+ "format_name": "Elixir script",
299
+ "decl_re": r"(?m)^\s*defp?\s+([A-Za-z_]\w*)",
300
+ "import_re": r"(?m)^\s*(?:use|import|require)\s+([\w.]+)",
301
+ },
302
+ ".hs": {
303
+ "format_name": "Haskell source",
304
+ "decl_re": r"(?m)^\s*[A-Za-z_][\w']*\s*::",
305
+ "import_re": r"(?m)^\s*import\s+(?:qualified\s+)?([A-Za-z_.]+)",
306
+ },
307
+ ".erl": {
308
+ "format_name": "Erlang source",
309
+ "decl_re": r"(?m)^\s*([a-z][\w@]*)\s*\([^)]*\)\s*->",
310
+ "import_re": r"(?m)^\s*-include(?:_lib)?\s*\(\s*[\"']([^\"']+)",
311
+ },
312
+ ".hrl": {
313
+ "format_name": "Erlang header",
314
+ "decl_re": r"(?m)^\s*([a-z][\w@]*)\s*\([^)]*\)\s*->",
315
+ "import_re": r"(?m)^\s*-include(?:_lib)?\s*\(\s*[\"']([^\"']+)",
316
+ },
317
+ ".fs": {
318
+ "format_name": "F# source",
319
+ "decl_re": r"(?m)^\s*(?:let|type|module|namespace)\s+([A-Za-z_]\w*)",
320
+ "import_re": r"(?m)^\s*open\s+([\w.]+)",
321
+ },
322
+ ".fsx": {
323
+ "format_name": "F# script",
324
+ "decl_re": r"(?m)^\s*(?:let|type|module)\s+([A-Za-z_]\w*)",
325
+ "import_re": r"(?m)^\s*open\s+([\w.]+)",
326
+ },
327
+ ".vb": {
328
+ "format_name": "Visual Basic source",
329
+ "decl_re": r"(?m)^\s*(?:Sub|Function|Class|Module|Interface|Enum)\s+([A-Za-z_]\w*)",
330
+ "import_re": r"(?m)^\s*Imports\s+([\w.]+)",
331
+ },
332
+ ".sh": {
333
+ "format_name": "Shell script",
334
+ "decl_re": r"(?m)^\s*(?:function\s+)?([A-Za-z_]\w*)\s*\(\s*\)",
335
+ "import_re": r"(?m)^\s*\.\s+([^\s]+)|^\s*source\s+([^\s]+)",
336
+ },
337
+ ".ps1": {
338
+ "format_name": "PowerShell script",
339
+ "decl_re": r"(?m)^\s*function\s+([A-Za-z_]\w*)",
340
+ "import_re": r"(?m)^\s*(?:Import-Module|using\s+module)\s+([^\s\"]+)",
341
+ },
342
+ ".js": {
343
+ "format_name": "JavaScript source",
344
+ "decl_re": r"(?m)^\s*(?:export\s+default\s+)?(?:function|class)\s+([A-Za-z_$][A-Za-z0-9_$]*)",
345
+ "import_re": r"(?m)^\s*import\s+.*?\s+from\s+[\"']([^\"']+)[\"']",
346
+ },
347
+ ".jsx": {
348
+ "format_name": "JavaScript/JSX source",
349
+ "decl_re": r"(?m)^\s*(?:export\s+default\s+)?(?:function|class)\s+([A-Za-z_$][A-Za-z0-9_$]*)",
350
+ "import_re": r"(?m)^\s*import\s+.*?\s+from\s+[\"']([^\"']+)[\"']",
351
+ },
352
+ ".ts": {
353
+ "format_name": "TypeScript source",
354
+ "decl_re": r"(?m)^\s*(?:export\s+default\s+)?(?:function|class|interface|type|enum)\s+([A-Za-z_$][A-Za-z0-9_$]*)",
355
+ "import_re": r"(?m)^\s*import\s+.*?\s+from\s+[\"']([^\"']+)[\"']",
356
+ },
357
+ ".tsx": {
358
+ "format_name": "TypeScript/TSX source",
359
+ "decl_re": r"(?m)^\s*(?:export\s+default\s+)?(?:function|class|interface|type|enum)\s+([A-Za-z_$][A-Za-z0-9_$]*)",
360
+ "import_re": r"(?m)^\s*import\s+.*?\s+from\s+[\"']([^\"']+)[\"']",
361
+ },
362
+ ".r": {
363
+ "format_name": "R source",
364
+ "decl_re": r"(?m)^\s*([A-Za-z.][A-Za-z0-9._]*)\s*(?:<-|=)\s*function\s*\(",
365
+ "import_re": r"(?m)\b(?:library|require)\s*\(\s*[\"']?([A-Za-z0-9._]+)",
366
+ },
367
+ }
368
+
369
+ # 通用声明正则(未单列语言的兜底),覆盖 def/func/fn/function/class/struct/interface 等。
370
+ GENERIC_DECL_RE = (
371
+ r"(?m)^\s*(?:pub\s+|private\s+|protected\s+|internal\s+|static\s+|"
372
+ r"final\s+|abstract\s+|async\s+|export\s+|default\s+)*"
373
+ r"(?:def|func|fn|function|class|struct|interface|enum|trait|type|object|"
374
+ r"module|package|mixin|extension|record)\s+([A-Za-z_]\w*)"
375
+ )
376
+
377
+
378
+ @dataclass
379
+ class Analysis:
380
+ format_name: str
381
+ analyzer: str
382
+ status: str
383
+ language: str
384
+ summary_zh: str
385
+ summary_en: str
386
+ structure: Dict[str, Any]
387
+ warnings: List[str]
388
+
389
+
390
+ def utc_now() -> str:
391
+ return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat()
392
+
393
+
394
+ def yaml_quote(value: Any) -> str:
395
+ return json.dumps(str(value), ensure_ascii=False)
396
+
397
+
398
+ def clean_structural_name(value: Any, limit: int = 160) -> str:
399
+ text = str(value).replace("\r", " ").replace("\n", " ").replace("\t", " ").strip()
400
+ return text[:limit]
401
+
402
+
403
+ def detect_language(text: str) -> str:
404
+ cjk = len(re.findall(r"[\u3400-\u9fff]", text))
405
+ latin = len(re.findall(r"[A-Za-z]", text))
406
+ if cjk >= 4 and cjk >= max(1, int(latin * 0.12)):
407
+ return "zh"
408
+ if latin >= 8:
409
+ return "en"
410
+ return "zh"
411
+
412
+
413
+ def is_within(path: Path, root: Path) -> bool:
414
+ try:
415
+ path.relative_to(root)
416
+ return True
417
+ except ValueError:
418
+ return False
419
+
420
+
421
+ def resolve_task_root(value: Optional[str]) -> Path:
422
+ root = Path(value).expanduser() if value else Path.cwd()
423
+ root = root.resolve()
424
+ if not root.exists():
425
+ raise ValueError(f"Task root does not exist: {root}")
426
+ if not root.is_dir():
427
+ raise ValueError(f"Task root is not a directory: {root}")
428
+ return root
429
+
430
+
431
+ def resolve_input_path(raw: str, task_root: Path) -> Path:
432
+ candidate = Path(raw).expanduser()
433
+ if not candidate.is_absolute():
434
+ candidate = task_root / candidate
435
+ candidate = candidate.resolve(strict=False)
436
+ if not is_within(candidate, task_root):
437
+ raise ValueError(f"Input is outside task root: {candidate}")
438
+ return candidate
439
+
440
+
441
+ def relative_key(path: Path, task_root: Path) -> str:
442
+ return path.relative_to(task_root).as_posix()
443
+
444
+
445
+ def document_id(relative_path: str) -> str:
446
+ return hashlib.sha256(relative_path.casefold().encode("utf-8")).hexdigest()[:24]
447
+
448
+
449
+ def gather_files(
450
+ task_root: Path,
451
+ raw_paths: Sequence[str],
452
+ extra_excludes: Sequence[str] = (),
453
+ ) -> Tuple[List[Path], List[Dict[str, str]], bool]:
454
+ full_scan = len(raw_paths) == 0
455
+ requested = [task_root] if full_scan else [resolve_input_path(x, task_root) for x in raw_paths]
456
+ files: Dict[str, Path] = {}
457
+ issues: List[Dict[str, str]] = []
458
+ extra_excluded = {name.casefold() for name in extra_excludes if name.strip()}
459
+
460
+ for requested_path in requested:
461
+ if not requested_path.exists():
462
+ issues.append(
463
+ {
464
+ "status": "missing",
465
+ "source": str(requested_path),
466
+ "relative_path": (
467
+ relative_key(requested_path, task_root)
468
+ if is_within(requested_path, task_root)
469
+ else str(requested_path)
470
+ ),
471
+ "document": "",
472
+ }
473
+ )
474
+ continue
475
+
476
+ if requested_path.is_file():
477
+ key = relative_key(requested_path, task_root)
478
+ files[key.casefold()] = requested_path
479
+ continue
480
+
481
+ for current_root, directory_names, file_names in os.walk(
482
+ str(requested_path), followlinks=False
483
+ ):
484
+ current = Path(current_root)
485
+ directory_names[:] = [
486
+ name
487
+ for name in directory_names
488
+ if name.casefold() not in EXCLUDED_DIR_NAMES
489
+ and name.casefold() not in extra_excluded
490
+ and not (current / name).is_symlink()
491
+ ]
492
+ for file_name in file_names:
493
+ candidate = current / file_name
494
+ if candidate.is_symlink() or not candidate.is_file():
495
+ continue
496
+ if file_name.casefold() in extra_excluded:
497
+ continue
498
+ key = relative_key(candidate.resolve(), task_root)
499
+ files[key.casefold()] = candidate.resolve()
500
+
501
+ ordered = sorted(files.values(), key=lambda p: relative_key(p, task_root).casefold())
502
+ return ordered, issues, full_scan
503
+
504
+
505
+ def sha256_file(path: Path) -> str:
506
+ digest = hashlib.sha256()
507
+ with path.open("rb") as handle:
508
+ while True:
509
+ chunk = handle.read(1024 * 1024)
510
+ if not chunk:
511
+ break
512
+ digest.update(chunk)
513
+ return digest.hexdigest()
514
+
515
+
516
+ def decode_text_sample(raw: bytes, truncated: bool) -> Tuple[Optional[str], str, bool]:
517
+ """Decode a bounded byte sample to text when it plausibly is text."""
518
+ if b"\x00" in raw[:4096] and not raw.startswith((b"\xff\xfe", b"\xfe\xff")):
519
+ return None, "binary", truncated
520
+
521
+ for encoding in ("utf-8-sig", "utf-16", "gb18030"):
522
+ try:
523
+ text = raw.decode(encoding)
524
+ printable = sum(ch.isprintable() or ch in "\r\n\t" for ch in text)
525
+ if not text or printable / max(1, len(text)) >= 0.8:
526
+ return text, encoding, truncated
527
+ except UnicodeDecodeError:
528
+ continue
529
+
530
+ try:
531
+ text = raw.decode("latin-1")
532
+ printable = sum(ch.isprintable() or ch in "\r\n\t" for ch in text)
533
+ if not text or printable / max(1, len(text)) >= 0.9:
534
+ return text, "latin-1", truncated
535
+ except UnicodeDecodeError:
536
+ pass
537
+ return None, "binary", truncated
538
+
539
+
540
+ def read_text_sample(path: Path, max_bytes: int = SAMPLE_BYTES) -> Tuple[Optional[str], str, bool]:
541
+ raw = path.open("rb").read(max_bytes)
542
+ truncated = path.stat().st_size > len(raw)
543
+ return decode_text_sample(raw, truncated)
544
+
545
+
546
+ def json_type(value: Any) -> str:
547
+ if value is None:
548
+ return "null"
549
+ if isinstance(value, bool):
550
+ return "boolean"
551
+ if isinstance(value, int) and not isinstance(value, bool):
552
+ return "integer"
553
+ if isinstance(value, float):
554
+ return "number"
555
+ if isinstance(value, str):
556
+ return "string"
557
+ if isinstance(value, list):
558
+ return "array"
559
+ if isinstance(value, dict):
560
+ return "object"
561
+ return type(value).__name__
562
+
563
+
564
+ def structural_schema(value: Any, depth: int = 0) -> Dict[str, Any]:
565
+ result: Dict[str, Any] = {"type": json_type(value)}
566
+ if depth >= 4:
567
+ result["truncated_depth"] = True
568
+ return result
569
+
570
+ if isinstance(value, dict):
571
+ keys = list(value.keys())
572
+ selected = keys[:MAX_STRUCTURAL_ITEMS]
573
+ result["key_count"] = len(keys)
574
+ result["keys"] = {
575
+ clean_structural_name(key): structural_schema(value[key], depth + 1)
576
+ for key in selected
577
+ }
578
+ result["truncated_keys"] = len(keys) > len(selected)
579
+ elif isinstance(value, list):
580
+ sample = value[:100]
581
+ result["item_count"] = len(value)
582
+ result["sampled_item_count"] = len(sample)
583
+ types = sorted({json_type(item) for item in sample})
584
+ result["item_types"] = types
585
+ if sample:
586
+ representatives: Dict[str, Any] = {}
587
+ for item in sample:
588
+ kind = json_type(item)
589
+ if kind not in representatives:
590
+ representatives[kind] = structural_schema(item, depth + 1)
591
+ result["item_schemas"] = representatives
592
+ return result
593
+
594
+
595
+ def column_structure(frame: Any) -> List[Dict[str, Any]]:
596
+ columns: List[Dict[str, Any]] = []
597
+ for name in list(frame.columns)[:MAX_STRUCTURAL_ITEMS]:
598
+ series = frame[name]
599
+ non_missing = series.dropna()
600
+ columns.append(
601
+ {
602
+ "name": clean_structural_name(name),
603
+ "dtype": str(series.dtype),
604
+ "missing_in_sample": int(series.isna().sum()),
605
+ "missing_percent_in_sample": round(float(series.isna().mean() * 100), 3),
606
+ "approximate_unique_in_sample": int(non_missing.nunique(dropna=True)),
607
+ }
608
+ )
609
+ return columns
610
+
611
+
612
+ def detect_delimiter(path: Path, hints: Sequence[str] = ()) -> str:
613
+ """Detect the most consistent field delimiter from a bounded text sample.
614
+
615
+ Scores candidates by how many sampled lines split into the same nonzero
616
+ number of fields. Quotes are honored via csv.reader so commas inside
617
+ quoted fields do not distort the score.
618
+ """
619
+ candidates = list(hints) + [",", "\t", ";", "|"]
620
+ seen: Set[str] = set()
621
+ unique_candidates: List[str] = []
622
+ for candidate in candidates:
623
+ if candidate and candidate not in seen:
624
+ seen.add(candidate)
625
+ unique_candidates.append(candidate)
626
+
627
+ sample = path.open("rb").read(SAMPLE_BYTES)
628
+ for encoding in ("utf-8-sig", "gb18030", "latin-1"):
629
+ try:
630
+ text = sample.decode(encoding)
631
+ break
632
+ except UnicodeDecodeError:
633
+ continue
634
+ else:
635
+ text = sample.decode("latin-1", errors="replace")
636
+ lines = text.splitlines()[:200]
637
+
638
+ best_delimiter = unique_candidates[0]
639
+ best_score = -1.0
640
+ for delimiter in unique_candidates:
641
+ consistent_lines = 0
642
+ column_totals = 0
643
+ inspected = 0
644
+ for line in lines:
645
+ stripped = line.strip()
646
+ if not stripped or stripped.startswith("#"):
647
+ continue
648
+ try:
649
+ fields = next(csv.reader([line], delimiter=delimiter))
650
+ except csv.Error:
651
+ continue
652
+ inspected += 1
653
+ column_totals += len(fields)
654
+ if len(fields) > 1:
655
+ consistent_lines += 1
656
+ if inspected == 0:
657
+ continue
658
+ score = consistent_lines + (column_totals / max(1, inspected)) * 0.01
659
+ if score > best_score:
660
+ best_score = score
661
+ best_delimiter = delimiter
662
+ return best_delimiter
663
+
664
+
665
+ def analyze_delimited(path: Path, separator: Optional[str] = None) -> Analysis:
666
+ import pandas as pd
667
+
668
+ if separator is None:
669
+ hints: Sequence[str] = []
670
+ if path.suffix.casefold() in {".tsv", ".tab"}:
671
+ hints = ["\t"]
672
+ separator = detect_delimiter(path, hints)
673
+
674
+ warnings: List[str] = []
675
+ last_error: Optional[Exception] = None
676
+ frame = None
677
+ encoding = ""
678
+ for candidate_encoding in ("utf-8-sig", "gb18030", "latin-1"):
679
+ try:
680
+ frame = pd.read_csv(
681
+ path,
682
+ sep=separator,
683
+ nrows=TABLE_SAMPLE_ROWS,
684
+ encoding=candidate_encoding,
685
+ low_memory=False,
686
+ )
687
+ encoding = candidate_encoding
688
+ break
689
+ except Exception as error:
690
+ last_error = error
691
+ if frame is None:
692
+ raise RuntimeError(f"Delimited parser failed: {last_error}")
693
+
694
+ names = " ".join(clean_structural_name(x) for x in frame.columns)
695
+ language = detect_language(names)
696
+ structure = {
697
+ "encoding": encoding,
698
+ "delimiter": "tab" if separator == "\t" else separator,
699
+ "sample_rows": int(len(frame)),
700
+ "row_count": "not fully counted",
701
+ "column_count": int(len(frame.columns)),
702
+ "columns": column_structure(frame),
703
+ "truncated_columns": len(frame.columns) > MAX_STRUCTURAL_ITEMS,
704
+ }
705
+ warnings.append(
706
+ f"Row and column statistics use at most the first {TABLE_SAMPLE_ROWS} records."
707
+ )
708
+ return Analysis(
709
+ format_name="TSV" if separator == "\t" else "CSV",
710
+ analyzer="pandas-delimited",
711
+ status="fresh",
712
+ language=language,
713
+ summary_zh=f"分隔文本表格;已采样 {len(frame)} 行并识别 {len(frame.columns)} 个字段。",
714
+ summary_en=f"Delimited table; sampled {len(frame)} rows and identified {len(frame.columns)} fields.",
715
+ structure=structure,
716
+ warnings=warnings,
717
+ )
718
+
719
+
720
+ def analyze_excel(path: Path) -> Analysis:
721
+ import pandas as pd
722
+
723
+ workbook = pd.ExcelFile(path)
724
+ sheet_names = list(workbook.sheet_names)
725
+ sheets: List[Dict[str, Any]] = []
726
+ language_material: List[str] = sheet_names.copy()
727
+ for sheet_name in sheet_names[:50]:
728
+ frame = workbook.parse(sheet_name=sheet_name, nrows=500)
729
+ language_material.extend(str(x) for x in frame.columns)
730
+ sheets.append(
731
+ {
732
+ "name": clean_structural_name(sheet_name),
733
+ "sample_rows": int(len(frame)),
734
+ "column_count": int(len(frame.columns)),
735
+ "columns": column_structure(frame),
736
+ "truncated_columns": len(frame.columns) > MAX_STRUCTURAL_ITEMS,
737
+ }
738
+ )
739
+ language = detect_language(" ".join(language_material))
740
+ return Analysis(
741
+ format_name="Excel workbook",
742
+ analyzer="pandas-excel",
743
+ status="fresh",
744
+ language=language,
745
+ summary_zh=f"Excel 工作簿;识别 {len(sheet_names)} 个工作表并提取字段结构。",
746
+ summary_en=f"Excel workbook; identified {len(sheet_names)} worksheets and their field structures.",
747
+ structure={
748
+ "sheet_count": len(sheet_names),
749
+ "sheets": sheets,
750
+ "truncated_sheets": len(sheet_names) > len(sheets),
751
+ "statistics_scope": "up to 500 rows per sheet",
752
+ },
753
+ warnings=["Worksheet statistics are sample-based and do not contain cell values."],
754
+ )
755
+
756
+
757
+ def analyze_parquet(path: Path) -> Analysis:
758
+ import pyarrow.parquet as parquet
759
+
760
+ metadata = parquet.ParquetFile(path)
761
+ schema = metadata.schema_arrow
762
+ fields = [
763
+ {"name": clean_structural_name(field.name), "type": str(field.type), "nullable": field.nullable}
764
+ for field in list(schema)[:MAX_STRUCTURAL_ITEMS]
765
+ ]
766
+ language = detect_language(" ".join(field["name"] for field in fields))
767
+ return Analysis(
768
+ format_name="Parquet",
769
+ analyzer="pyarrow-parquet-metadata",
770
+ status="fresh",
771
+ language=language,
772
+ summary_zh=f"Parquet 列式数据;元数据记录 {metadata.metadata.num_rows} 行、{len(schema)} 个字段。",
773
+ summary_en=f"Parquet columnar data; metadata reports {metadata.metadata.num_rows} rows and {len(schema)} fields.",
774
+ structure={
775
+ "row_count": metadata.metadata.num_rows,
776
+ "row_group_count": metadata.metadata.num_row_groups,
777
+ "column_count": len(schema),
778
+ "columns": fields,
779
+ "truncated_columns": len(schema) > len(fields),
780
+ },
781
+ warnings=[],
782
+ )
783
+
784
+
785
+ def analyze_feather(path: Path) -> Analysis:
786
+ import pyarrow as pa
787
+ import pyarrow.ipc as ipc
788
+
789
+ source = pa.memory_map(str(path), "r")
790
+ reader = ipc.open_file(source)
791
+ schema = reader.schema
792
+ fields = [
793
+ {"name": clean_structural_name(field.name), "type": str(field.type), "nullable": field.nullable}
794
+ for field in list(schema)[:MAX_STRUCTURAL_ITEMS]
795
+ ]
796
+ language = detect_language(" ".join(field["name"] for field in fields))
797
+ return Analysis(
798
+ format_name="Feather/Arrow IPC",
799
+ analyzer="pyarrow-ipc-metadata",
800
+ status="fresh",
801
+ language=language,
802
+ summary_zh=f"Feather/Arrow 文件;识别 {len(schema)} 个字段和 {reader.num_record_batches} 个记录批次。",
803
+ summary_en=f"Feather/Arrow file; identified {len(schema)} fields and {reader.num_record_batches} record batches.",
804
+ structure={
805
+ "record_batch_count": reader.num_record_batches,
806
+ "column_count": len(schema),
807
+ "columns": fields,
808
+ "truncated_columns": len(schema) > len(fields),
809
+ },
810
+ warnings=["Row count was not materialized to avoid loading record batches."],
811
+ )
812
+
813
+
814
+ def analyze_json(path: Path, json_lines: bool) -> Analysis:
815
+ size = path.stat().st_size
816
+ warnings: List[str] = []
817
+ if json_lines:
818
+ records: List[Any] = []
819
+ with path.open("r", encoding="utf-8-sig", errors="replace") as handle:
820
+ for index, line in enumerate(handle):
821
+ if index >= TABLE_SAMPLE_ROWS:
822
+ warnings.append(
823
+ f"Only the first {TABLE_SAMPLE_ROWS} JSONL records were parsed."
824
+ )
825
+ break
826
+ if line.strip():
827
+ records.append(json.loads(line))
828
+ value: Any = records
829
+ format_name = "JSON Lines"
830
+ else:
831
+ if size > MAX_JSON_BYTES:
832
+ raise RuntimeError(
833
+ f"JSON file exceeds bounded parse limit of {MAX_JSON_BYTES} bytes."
834
+ )
835
+ with path.open("r", encoding="utf-8-sig") as handle:
836
+ value = json.load(handle)
837
+ format_name = "JSON"
838
+
839
+ structure = structural_schema(value)
840
+ structural_text = json.dumps(structure, ensure_ascii=False)
841
+ language = detect_language(structural_text)
842
+ return Analysis(
843
+ format_name=format_name,
844
+ analyzer="python-json",
845
+ status="fresh",
846
+ language=language,
847
+ summary_zh=f"{format_name} 结构化数据;已提取键、嵌套层级和元素类型。",
848
+ summary_en=f"{format_name} structured data; extracted keys, nesting, and element types.",
849
+ structure=structure,
850
+ warnings=warnings,
851
+ )
852
+
853
+
854
+ def analyze_yaml(path: Path) -> Analysis:
855
+ import yaml
856
+
857
+ if path.stat().st_size > MAX_JSON_BYTES:
858
+ raise RuntimeError(
859
+ f"YAML file exceeds bounded parse limit of {MAX_JSON_BYTES} bytes."
860
+ )
861
+ with path.open("r", encoding="utf-8-sig") as handle:
862
+ value = yaml.safe_load(handle)
863
+ structure = structural_schema(value)
864
+ structural_text = json.dumps(structure, ensure_ascii=False)
865
+ return Analysis(
866
+ format_name="YAML",
867
+ analyzer="pyyaml",
868
+ status="fresh",
869
+ language=detect_language(structural_text),
870
+ summary_zh="YAML 配置或结构化数据;已提取键、嵌套层级和元素类型。",
871
+ summary_en="YAML configuration or structured data; extracted keys, nesting, and element types.",
872
+ structure=structure,
873
+ warnings=[],
874
+ )
875
+
876
+
877
+ def analyze_toml(path: Path) -> Analysis:
878
+ if path.stat().st_size > MAX_JSON_BYTES:
879
+ raise RuntimeError(
880
+ f"TOML file exceeds bounded parse limit of {MAX_JSON_BYTES} bytes."
881
+ )
882
+ try:
883
+ import tomllib as toml_reader # type: ignore
884
+ except ImportError:
885
+ try:
886
+ import tomli as toml_reader # type: ignore
887
+ except ImportError as error:
888
+ raise RuntimeError("Neither tomllib nor tomli is available.") from error
889
+ with path.open("rb") as handle:
890
+ value = toml_reader.load(handle)
891
+ structure = structural_schema(value)
892
+ structural_text = json.dumps(structure, ensure_ascii=False)
893
+ return Analysis(
894
+ format_name="TOML",
895
+ analyzer="toml",
896
+ status="fresh",
897
+ language=detect_language(structural_text),
898
+ summary_zh="TOML 配置;已提取节、键和嵌套结构。",
899
+ summary_en="TOML configuration; extracted sections, keys, and nesting.",
900
+ structure=structure,
901
+ warnings=[],
902
+ )
903
+
904
+
905
+ def analyze_python_source(path: Path, text: str, encoding: str, truncated: bool) -> Analysis:
906
+ if truncated:
907
+ raise RuntimeError("Python source exceeds the bounded parser size.")
908
+ tree = ast.parse(text)
909
+ imports: List[str] = []
910
+ functions: List[str] = []
911
+ classes: List[str] = []
912
+ for node in ast.walk(tree):
913
+ if isinstance(node, ast.Import):
914
+ imports.extend(alias.name for alias in node.names)
915
+ elif isinstance(node, ast.ImportFrom):
916
+ imports.append(node.module or "")
917
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
918
+ functions.append(node.name)
919
+ elif isinstance(node, ast.ClassDef):
920
+ classes.append(node.name)
921
+ structure = {
922
+ "encoding": encoding,
923
+ "line_count": len(text.splitlines()),
924
+ "imports": [clean_structural_name(x) for x in imports[:MAX_STRUCTURAL_ITEMS]],
925
+ "functions": [clean_structural_name(x) for x in functions[:MAX_STRUCTURAL_ITEMS]],
926
+ "classes": [clean_structural_name(x) for x in classes[:MAX_STRUCTURAL_ITEMS]],
927
+ "module_docstring_present": ast.get_docstring(tree) is not None,
928
+ }
929
+ return Analysis(
930
+ format_name="Python source",
931
+ analyzer="python-ast",
932
+ status="fresh",
933
+ language=detect_language(text[:10000]),
934
+ summary_zh=f"Python 源代码;识别 {len(functions)} 个函数、{len(classes)} 个类和 {len(imports)} 个导入。",
935
+ summary_en=f"Python source; identified {len(functions)} functions, {len(classes)} classes, and {len(imports)} imports.",
936
+ structure=structure,
937
+ warnings=[],
938
+ )
939
+
940
+
941
+ def analyze_code_or_text(path: Path) -> Analysis:
942
+ text, encoding, truncated = read_text_sample(path, max_bytes=5 * 1024 * 1024)
943
+ if text is None:
944
+ raise RuntimeError("The file did not pass text decoding checks.")
945
+ extension = path.suffix.casefold()
946
+ if extension == ".py":
947
+ return analyze_python_source(path, text, encoding, truncated)
948
+
949
+ lines = text.splitlines()
950
+ structure: Dict[str, Any] = {
951
+ "encoding": encoding,
952
+ "sample_line_count": len(lines),
953
+ "sample_truncated": truncated,
954
+ }
955
+ format_name = "Text"
956
+ analyzer = "bounded-text-structure"
957
+
958
+ if extension in {".md", ".markdown", ".rst"}:
959
+ headings = [
960
+ clean_structural_name(match.group(2))
961
+ for line in lines
962
+ for match in [re.match(r"^(#{1,6})\s+(.+?)\s*$", line)]
963
+ if match
964
+ ]
965
+ structure["headings"] = headings[:MAX_STRUCTURAL_ITEMS]
966
+ structure["heading_count_in_sample"] = len(headings)
967
+ format_name = "Markdown/text document"
968
+ elif extension in CODE_EXTENSIONS:
969
+ profile = LANGUAGE_PROFILES.get(extension)
970
+ if profile:
971
+ return analyze_generic_source(path, text, encoding, truncated)
972
+ declarations = re.findall(GENERIC_DECL_RE, text)
973
+ structure["declarations"] = [
974
+ clean_structural_name(x) for x in declarations[:MAX_STRUCTURAL_ITEMS]
975
+ ]
976
+ format_name = f"{extension.lstrip('.').upper()} source"
977
+ analyzer = "generic-source-structure"
978
+
979
+ language = detect_language(text[:20000])
980
+ return Analysis(
981
+ format_name=format_name,
982
+ analyzer=analyzer,
983
+ status="fresh",
984
+ language=language,
985
+ summary_zh=f"{format_name};已记录编码、行数和可识别的结构性名称。",
986
+ summary_en=f"{format_name}; recorded encoding, line counts, and recognizable structural names.",
987
+ structure=structure,
988
+ warnings=(
989
+ ["Only a bounded prefix was inspected; counts may be incomplete."]
990
+ if truncated
991
+ else []
992
+ ),
993
+ )
994
+
995
+
996
+ def analyze_pdf(path: Path) -> Analysis:
997
+ from pypdf import PdfReader
998
+
999
+ reader = PdfReader(str(path))
1000
+ metadata_keys = sorted(str(key) for key in (reader.metadata or {}).keys())
1001
+ first_page_box: Optional[List[float]] = None
1002
+ if reader.pages:
1003
+ box = reader.pages[0].mediabox
1004
+ first_page_box = [float(box.width), float(box.height)]
1005
+ return Analysis(
1006
+ format_name="PDF",
1007
+ analyzer="pypdf-metadata",
1008
+ status="fresh",
1009
+ language="zh",
1010
+ summary_zh=f"PDF 文档;识别 {len(reader.pages)} 页,仅记录页面和元数据结构。",
1011
+ summary_en=f"PDF document; identified {len(reader.pages)} pages and recorded metadata structure only.",
1012
+ structure={
1013
+ "page_count": len(reader.pages),
1014
+ "encrypted": bool(reader.is_encrypted),
1015
+ "metadata_keys": metadata_keys,
1016
+ "first_page_size_points": first_page_box,
1017
+ },
1018
+ warnings=["Document text was not copied into the catalog."],
1019
+ )
1020
+
1021
+
1022
+ def analyze_docx(path: Path) -> Analysis:
1023
+ namespaces = {
1024
+ "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
1025
+ }
1026
+ with zipfile.ZipFile(path) as archive:
1027
+ document_xml = archive.read("word/document.xml")
1028
+ root = ElementTree.fromstring(document_xml)
1029
+ paragraphs = root.findall(".//w:p", namespaces)
1030
+ tables = root.findall(".//w:tbl", namespaces)
1031
+ heading_styles: Dict[str, int] = {}
1032
+ language_material: List[str] = []
1033
+ for paragraph in paragraphs[:5000]:
1034
+ style = paragraph.find("./w:pPr/w:pStyle", namespaces)
1035
+ if style is not None:
1036
+ style_name = style.attrib.get(
1037
+ f"{{{namespaces['w']}}}val", ""
1038
+ )
1039
+ if style_name.lower().startswith("heading"):
1040
+ heading_styles[style_name] = heading_styles.get(style_name, 0) + 1
1041
+ for text_node in paragraph.findall(".//w:t", namespaces):
1042
+ if text_node.text and sum(len(x) for x in language_material) < 20000:
1043
+ language_material.append(text_node.text)
1044
+ names = set(archive.namelist())
1045
+ return Analysis(
1046
+ format_name="DOCX",
1047
+ analyzer="docx-zip-xml",
1048
+ status="fresh",
1049
+ language=detect_language(" ".join(language_material)),
1050
+ summary_zh=f"DOCX 文档;识别 {len(paragraphs)} 个段落和 {len(tables)} 个表格。",
1051
+ summary_en=f"DOCX document; identified {len(paragraphs)} paragraphs and {len(tables)} tables.",
1052
+ structure={
1053
+ "paragraph_count": len(paragraphs),
1054
+ "table_count": len(tables),
1055
+ "heading_style_counts": heading_styles,
1056
+ "has_headers": any(name.startswith("word/header") for name in names),
1057
+ "has_footers": any(name.startswith("word/footer") for name in names),
1058
+ "embedded_media_count": sum(
1059
+ name.startswith("word/media/") for name in names
1060
+ ),
1061
+ },
1062
+ warnings=["Paragraph and cell text was not copied into the catalog."],
1063
+ )
1064
+
1065
+
1066
+ def analyze_image(path: Path) -> Analysis:
1067
+ from PIL import Image
1068
+
1069
+ with Image.open(path) as image:
1070
+ structure = {
1071
+ "format": image.format,
1072
+ "width": image.width,
1073
+ "height": image.height,
1074
+ "mode": image.mode,
1075
+ "frame_count": getattr(image, "n_frames", 1),
1076
+ "metadata_keys": sorted(clean_structural_name(x) for x in image.info.keys()),
1077
+ }
1078
+ format_name = f"{image.format or path.suffix.lstrip('.').upper()} image"
1079
+ return Analysis(
1080
+ format_name=format_name,
1081
+ analyzer="pillow-metadata",
1082
+ status="fresh",
1083
+ language="zh",
1084
+ summary_zh=f"图像文件;尺寸为 {structure['width']}×{structure['height']},模式为 {structure['mode']}。",
1085
+ summary_en=f"Image file; dimensions are {structure['width']}×{structure['height']} with mode {structure['mode']}.",
1086
+ structure=structure,
1087
+ warnings=["Pixel content and metadata values were not copied into the catalog."],
1088
+ )
1089
+
1090
+
1091
+ def analyze_sqlite(path: Path) -> Analysis:
1092
+ """Read a SQLite schema read-only through the standard library.
1093
+
1094
+ Only schema objects (table/view/index names, column names and declared
1095
+ types) and row counts are recorded. No cell values are read.
1096
+ """
1097
+ try:
1098
+ connection = sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True)
1099
+ except sqlite3.Error as error:
1100
+ raise RuntimeError(f"SQLite open failed: {error}") from error
1101
+ try:
1102
+ connection.execute("PRAGMA query_only = ON")
1103
+ tables = connection.execute(
1104
+ "SELECT name FROM sqlite_master WHERE type='table' "
1105
+ "AND name NOT LIKE 'sqlite_%' ORDER BY name LIMIT 200"
1106
+ ).fetchall()
1107
+ views = connection.execute(
1108
+ "SELECT name FROM sqlite_master WHERE type='view' ORDER BY name LIMIT 200"
1109
+ ).fetchall()
1110
+ indexes = connection.execute(
1111
+ "SELECT name FROM sqlite_master WHERE type='index' AND sql IS NOT NULL "
1112
+ "ORDER BY name LIMIT 200"
1113
+ ).fetchall()
1114
+ described: List[Dict[str, Any]] = []
1115
+ truncated_tables = len(tables) > 100
1116
+ for (table_name,) in tables[:100]:
1117
+ columns = connection.execute(
1118
+ f'PRAGMA table_info("{table_name.replace(chr(34), chr(34) * 2)}")'
1119
+ ).fetchall()
1120
+ row_count: Optional[int] = None
1121
+ try:
1122
+ row_count = connection.execute(
1123
+ f'SELECT count(*) FROM "{table_name.replace(chr(34), chr(34) * 2)}"'
1124
+ ).fetchone()[0]
1125
+ except sqlite3.Error:
1126
+ row_count = None
1127
+ described.append(
1128
+ {
1129
+ "name": clean_structural_name(table_name),
1130
+ "column_count": len(columns),
1131
+ "row_count": row_count,
1132
+ "columns": [
1133
+ {
1134
+ "name": clean_structural_name(column[1]),
1135
+ "declared_type": clean_structural_name(column[2]),
1136
+ "notnull": bool(column[3]),
1137
+ "primary_key": bool(column[5]),
1138
+ }
1139
+ for column in columns[:MAX_STRUCTURAL_ITEMS]
1140
+ ],
1141
+ "truncated_columns": len(columns) > MAX_STRUCTURAL_ITEMS,
1142
+ }
1143
+ )
1144
+ except sqlite3.Error as error:
1145
+ raise RuntimeError(f"SQLite schema inspection failed: {error}") from error
1146
+ finally:
1147
+ connection.close()
1148
+ structure = {
1149
+ "table_count": len(tables),
1150
+ "view_count": len(views),
1151
+ "index_count": len(indexes),
1152
+ "tables": described,
1153
+ "truncated_tables": truncated_tables,
1154
+ }
1155
+ return Analysis(
1156
+ format_name="SQLite database",
1157
+ analyzer="sqlite-schema",
1158
+ status="fresh",
1159
+ language="zh",
1160
+ summary_zh=f"SQLite 数据库;识别 {len(tables)} 张表、{len(views)} 个视图和 {len(indexes)} 个索引。",
1161
+ summary_en=f"SQLite database; identified {len(tables)} tables, {len(views)} views, and {len(indexes)} indexes.",
1162
+ structure=structure,
1163
+ warnings=["Only schema objects and row counts were recorded; cell values were never read."],
1164
+ )
1165
+
1166
+
1167
+ def analyze_zip_archive(path: Path) -> Analysis:
1168
+ """List archive members from the central directory without extracting."""
1169
+ with zipfile.ZipFile(path) as archive:
1170
+ infos = archive.infolist()
1171
+ truncated = len(infos) > MAX_ARCHIVE_MEMBERS
1172
+ infos = infos[:MAX_ARCHIVE_MEMBERS]
1173
+ extension_counts: Dict[str, int] = {}
1174
+ top_level: Set[str] = set()
1175
+ duplicates = 0
1176
+ seen_names: Set[str] = set()
1177
+ total_uncompressed = 0
1178
+ total_compressed = 0
1179
+ for info in infos:
1180
+ name = info.filename
1181
+ total_uncompressed += info.file_size
1182
+ total_compressed += info.compress_size
1183
+ top_level.add(name.split("/", 1)[0])
1184
+ if name.casefold() in seen_names:
1185
+ duplicates += 1
1186
+ seen_names.add(name.casefold())
1187
+ extension = Path(name).suffix.casefold()
1188
+ extension_counts[extension or "(none)"] = (
1189
+ extension_counts.get(extension or "(none)", 0) + 1
1190
+ )
1191
+ top_extensions = sorted(
1192
+ extension_counts.items(), key=lambda item: item[1], reverse=True
1193
+ )[:20]
1194
+ structure = {
1195
+ "member_count": len(infos),
1196
+ "member_names": [
1197
+ clean_structural_name(info.filename) for info in infos[:MAX_STRUCTURAL_ITEMS]
1198
+ ],
1199
+ "total_uncompressed_bytes": total_uncompressed,
1200
+ "total_compressed_bytes": total_compressed,
1201
+ "duplicate_name_count": duplicates,
1202
+ "encrypted_member_count": sum(1 for info in infos if info.flag_bits & 0x1),
1203
+ "top_level_entries": sorted(top_level)[:MAX_STRUCTURAL_ITEMS],
1204
+ "extension_histogram": [
1205
+ {"extension": name, "count": count}
1206
+ for name, count in top_extensions
1207
+ ],
1208
+ "truncated_members": truncated,
1209
+ "truncated_member_names": len(infos) > MAX_STRUCTURAL_ITEMS,
1210
+ }
1211
+ warnings = (
1212
+ [f"Archive has more than {MAX_ARCHIVE_MEMBERS} members; only the first batch was listed."]
1213
+ if truncated
1214
+ else []
1215
+ )
1216
+ warnings.append("Member contents were not extracted or read.")
1217
+ return Analysis(
1218
+ format_name="ZIP archive",
1219
+ analyzer="zipfile-central-directory",
1220
+ status="fresh",
1221
+ language="zh",
1222
+ summary_zh=f"ZIP 归档;列出 {len(infos)} 个成员及类型直方图。",
1223
+ summary_en=f"ZIP archive; listed {len(infos)} members and a type histogram.",
1224
+ structure=structure,
1225
+ warnings=warnings,
1226
+ )
1227
+
1228
+
1229
+ def analyze_tar_archive(path: Path) -> Analysis:
1230
+ """Describe TAR/TAR.GZ/TAR.BZ2/TAR.XZ member lists without extraction."""
1231
+ type_labels = {
1232
+ tarfile.REGTYPE: "file",
1233
+ tarfile.AREGTYPE: "file",
1234
+ tarfile.DIRTYPE: "directory",
1235
+ tarfile.SYMTYPE: "symlink",
1236
+ tarfile.LNKTYPE: "hardlink",
1237
+ tarfile.CHRTYPE: "char-device",
1238
+ tarfile.BLKTYPE: "block-device",
1239
+ tarfile.FIFOTYPE: "fifo",
1240
+ }
1241
+ extension_counts: Dict[str, int] = {}
1242
+ type_counts: Dict[str, int] = {}
1243
+ top_level: Set[str] = set()
1244
+ total_size = 0
1245
+ member_count = 0
1246
+ truncated = False
1247
+ first_members: List[Any] = []
1248
+ with tarfile.open(path, mode="r:*") as archive:
1249
+ for member in archive:
1250
+ if member_count >= MAX_ARCHIVE_MEMBERS:
1251
+ truncated = True
1252
+ break
1253
+ member_count += 1
1254
+ if len(first_members) < MAX_STRUCTURAL_ITEMS:
1255
+ first_members.append(member)
1256
+ label = type_labels.get(member.type, "other")
1257
+ type_counts[label] = type_counts.get(label, 0) + 1
1258
+ top_level.add(member.name.split("/", 1)[0])
1259
+ if member.isreg():
1260
+ total_size += member.size
1261
+ extension = Path(member.name).suffix.casefold()
1262
+ extension_counts[extension or "(none)"] = (
1263
+ extension_counts.get(extension or "(none)", 0) + 1
1264
+ )
1265
+ top_extensions = sorted(
1266
+ extension_counts.items(), key=lambda item: item[1], reverse=True
1267
+ )[:20]
1268
+ structure = {
1269
+ "member_count": member_count,
1270
+ "member_names": [
1271
+ clean_structural_name(member.name) for member in first_members
1272
+ ],
1273
+ "total_regular_size_bytes": total_size,
1274
+ "member_type_counts": type_counts,
1275
+ "top_level_entries": sorted(top_level)[:MAX_STRUCTURAL_ITEMS],
1276
+ "extension_histogram": [
1277
+ {"extension": name, "count": count}
1278
+ for name, count in top_extensions
1279
+ ],
1280
+ "truncated_members": truncated,
1281
+ "truncated_member_names": len(first_members) > MAX_STRUCTURAL_ITEMS,
1282
+ }
1283
+ warnings = (
1284
+ [f"Archive has more than {MAX_ARCHIVE_MEMBERS} members; listing stopped early."]
1285
+ if truncated
1286
+ else []
1287
+ )
1288
+ warnings.append("Member contents were not extracted or read.")
1289
+ return Analysis(
1290
+ format_name="TAR archive",
1291
+ analyzer="tarfile-members",
1292
+ status="fresh",
1293
+ language="zh",
1294
+ summary_zh=f"TAR 归档;列出 {member_count} 个成员及类型分布。",
1295
+ summary_en=f"TAR archive; listed {member_count} members and type distribution.",
1296
+ structure=structure,
1297
+ warnings=warnings,
1298
+ )
1299
+
1300
+
1301
+ def analyze_gzip_stream(path: Path) -> Analysis:
1302
+ """Describe a plain gzip stream (not a TAR) from its header and a bounded sample."""
1303
+ with gzip.open(path, "rb") as handle:
1304
+ header_name = getattr(handle, "name", None)
1305
+ if isinstance(header_name, bytes):
1306
+ header_name = header_name.decode("utf-8", errors="replace")
1307
+ raw = handle.read(SAMPLE_BYTES)
1308
+ text, encoding, sample_truncated = decode_text_sample(raw, truncated=True)
1309
+ structure: Dict[str, Any] = {
1310
+ "header_filename": clean_structural_name(header_name) if header_name else None,
1311
+ "compressed_size_bytes": path.stat().st_size,
1312
+ "decompressed_sample_bytes": len(raw),
1313
+ "sample_truncated": sample_truncated,
1314
+ }
1315
+ if text is not None:
1316
+ structure["decoded_sample"] = True
1317
+ structure["detected_encoding"] = encoding
1318
+ structure["sample_line_count"] = len(text.splitlines())
1319
+ else:
1320
+ structure["decoded_sample"] = False
1321
+ warnings = [
1322
+ "Only the first bounded decompressed sample was inspected; "
1323
+ "total decompressed size was not materialized."
1324
+ ]
1325
+ return Analysis(
1326
+ format_name="GZIP stream",
1327
+ analyzer="gzip-header-sample",
1328
+ status="fresh",
1329
+ language=detect_language(text[:20000]) if text else "zh",
1330
+ summary_zh="gzip 压缩流;记录了头部信息与有界解压样本结构。",
1331
+ summary_en="gzip stream; recorded header information and a bounded decompressed sample.",
1332
+ structure=structure,
1333
+ warnings=warnings,
1334
+ )
1335
+
1336
+
1337
+ def analyze_xml(path: Path) -> Analysis:
1338
+ """Count XML tags, attribute keys, namespaces, and depth without keeping text."""
1339
+ tags: Dict[str, int] = {}
1340
+ attribute_keys: Set[str] = set()
1341
+ namespaces: Set[str] = set()
1342
+ max_depth = 0
1343
+ depth = 0
1344
+ element_count = 0
1345
+ truncated = False
1346
+ for event, element in ElementTree.iterparse(str(path), events=("start", "end")):
1347
+ if event == "start":
1348
+ element_count += 1
1349
+ if element_count > MAX_XML_ELEMENTS:
1350
+ truncated = True
1351
+ element.clear()
1352
+ break
1353
+ depth += 1
1354
+ max_depth = max(max_depth, depth)
1355
+ tag = element.tag
1356
+ if isinstance(tag, str) and tag.startswith("{"):
1357
+ namespace, _, local = tag[1:].partition("}")
1358
+ namespaces.add(namespace)
1359
+ tag = local
1360
+ tags[str(tag)] = tags.get(str(tag), 0) + 1
1361
+ if len(tags) <= 500:
1362
+ attribute_keys.update(clean_structural_name(key) for key in element.attrib.keys())
1363
+ element.clear()
1364
+ else:
1365
+ depth -= 1
1366
+ top_tags = sorted(tags.items(), key=lambda item: item[1], reverse=True)[:30]
1367
+ structure = {
1368
+ "root_tag": next(iter(tags), None),
1369
+ "element_count": element_count,
1370
+ "max_nesting_depth": max_depth,
1371
+ "namespace_count": len(namespaces),
1372
+ "distinct_tag_count": len(tags),
1373
+ "top_tags": [{"tag": name, "count": count} for name, count in top_tags],
1374
+ "attribute_keys": sorted(attribute_keys)[:MAX_STRUCTURAL_ITEMS],
1375
+ "truncated_elements": truncated,
1376
+ }
1377
+ warnings = (
1378
+ [f"XML has more than {MAX_XML_ELEMENTS} elements; counting stopped early."]
1379
+ if truncated
1380
+ else []
1381
+ )
1382
+ warnings.append("Text content and attribute values were not copied into the catalog.")
1383
+ return Analysis(
1384
+ format_name="XML document",
1385
+ analyzer="xml-iterparse-structure",
1386
+ status="fresh",
1387
+ language="zh",
1388
+ summary_zh=f"XML 文档;统计了 {element_count} 个元素、{len(namespaces)} 个命名空间和 {len(tags)} 种标签。",
1389
+ summary_en=f"XML document; counted {element_count} elements, {len(namespaces)} namespaces, and {len(tags)} distinct tags.",
1390
+ structure=structure,
1391
+ warnings=warnings,
1392
+ )
1393
+
1394
+
1395
+ class _TagCollector(html.parser.HTMLParser):
1396
+ def __init__(self) -> None:
1397
+ super().__init__(convert_charrefs=True)
1398
+ self.tags: Dict[str, int] = {}
1399
+ self.heading_counts: Dict[str, int] = {}
1400
+ self.attribute_keys: Set[str] = set()
1401
+ self.counts = {
1402
+ "table": 0,
1403
+ "tr": 0,
1404
+ "td": 0,
1405
+ "th": 0,
1406
+ "link": 0,
1407
+ "img": 0,
1408
+ "script": 0,
1409
+ "style": 0,
1410
+ "form": 0,
1411
+ "input": 0,
1412
+ "iframe": 0,
1413
+ }
1414
+ self.has_title = False
1415
+ self.title_open = False
1416
+
1417
+ def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
1418
+ tag = tag.casefold()
1419
+ self.tags[tag] = self.tags.get(tag, 0) + 1
1420
+ if tag in self.counts:
1421
+ self.counts[tag] += 1
1422
+ if tag.startswith("h") and len(tag) == 2 and tag[1].isdigit():
1423
+ self.heading_counts[tag] = self.heading_counts.get(tag, 0) + 1
1424
+ if tag == "title":
1425
+ self.title_open = True
1426
+ if len(self.attribute_keys) < 500:
1427
+ self.attribute_keys.update(clean_structural_name(key) for key, _ in attrs)
1428
+
1429
+ def handle_startendtag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
1430
+ self.handle_starttag(tag, attrs)
1431
+
1432
+ def handle_endtag(self, tag: str) -> None:
1433
+ if tag.casefold() == "title":
1434
+ self.title_open = False
1435
+
1436
+
1437
+ def analyze_html(path: Path) -> Analysis:
1438
+ raw = path.open("rb").read(SAMPLE_BYTES)
1439
+ truncated = path.stat().st_size > len(raw)
1440
+ for encoding in ("utf-8-sig", "gb18030", "latin-1"):
1441
+ try:
1442
+ text = raw.decode(encoding)
1443
+ break
1444
+ except UnicodeDecodeError:
1445
+ continue
1446
+ else:
1447
+ text = raw.decode("latin-1", errors="replace")
1448
+ collector = _TagCollector()
1449
+ collector.feed(text)
1450
+ collector.close()
1451
+ top_tags = sorted(
1452
+ collector.tags.items(), key=lambda item: item[1], reverse=True
1453
+ )[:30]
1454
+ structure = {
1455
+ "encoding": encoding,
1456
+ "sample_truncated": truncated,
1457
+ "distinct_tag_count": len(collector.tags),
1458
+ "top_tags": [{"tag": name, "count": count} for name, count in top_tags],
1459
+ "heading_counts": collector.heading_counts,
1460
+ "structural_counts": collector.counts,
1461
+ "has_title": collector.has_title,
1462
+ "attribute_keys": sorted(collector.attribute_keys)[:MAX_STRUCTURAL_ITEMS],
1463
+ }
1464
+ warnings = (
1465
+ ["Only a bounded HTML prefix was inspected."] if truncated else []
1466
+ )
1467
+ warnings.append("Visible text and attribute values were not copied into the catalog.")
1468
+ return Analysis(
1469
+ format_name="HTML document",
1470
+ analyzer="htmlparser-structure",
1471
+ status="fresh",
1472
+ language=detect_language(text[:20000]),
1473
+ summary_zh=f"HTML 文档;统计了 {len(collector.tags)} 种标签及标题/表格/链接结构。",
1474
+ summary_en=f"HTML document; counted {len(collector.tags)} distinct tags plus heading/table/link structure.",
1475
+ structure=structure,
1476
+ warnings=warnings,
1477
+ )
1478
+
1479
+
1480
+ def analyze_ipynb(path: Path) -> Analysis:
1481
+ if path.stat().st_size > MAX_JSON_BYTES:
1482
+ raise RuntimeError(
1483
+ f"Notebook file exceeds bounded parse limit of {MAX_JSON_BYTES} bytes."
1484
+ )
1485
+ with path.open("r", encoding="utf-8-sig") as handle:
1486
+ notebook = json.load(handle)
1487
+ cells = notebook.get("cells", [])
1488
+ cell_types: Dict[str, int] = {}
1489
+ languages: Set[str] = set()
1490
+ executed_cells = 0
1491
+ for cell in cells:
1492
+ cell_type = str(cell.get("cell_type", "unknown"))
1493
+ cell_types[cell_type] = cell_types.get(cell_type, 0) + 1
1494
+ if cell.get("execution_count") is not None:
1495
+ executed_cells += 1
1496
+ metadata = notebook.get("metadata") or {}
1497
+ kernelspec = metadata.get("kernelspec") or {}
1498
+ language_info = metadata.get("language_info") or {}
1499
+ if kernelspec.get("language"):
1500
+ languages.add(str(kernelspec["language"]))
1501
+ if language_info.get("name"):
1502
+ languages.add(str(language_info["name"]))
1503
+ structure = {
1504
+ "nbformat": notebook.get("nbformat"),
1505
+ "nbformat_minor": notebook.get("nbformat_minor"),
1506
+ "cell_count": len(cells),
1507
+ "cell_type_counts": cell_types,
1508
+ "executed_cell_count": executed_cells,
1509
+ "languages": sorted(languages),
1510
+ "kernelspec_name": clean_structural_name(kernelspec.get("name"))
1511
+ if kernelspec.get("name")
1512
+ else None,
1513
+ }
1514
+ return Analysis(
1515
+ format_name="Jupyter notebook",
1516
+ analyzer="ipynb-structure",
1517
+ status="fresh",
1518
+ language="zh",
1519
+ summary_zh=f"Jupyter notebook;包含 {len(cells)} 个单元格,类型分布为 {cell_types}。",
1520
+ summary_en=f"Jupyter notebook; contains {len(cells)} cells with type distribution {cell_types}.",
1521
+ structure=structure,
1522
+ warnings=["Cell source, outputs, and display data were not copied into the catalog."],
1523
+ )
1524
+
1525
+
1526
+ def analyze_stata(path: Path) -> Analysis:
1527
+ import pandas as pd
1528
+
1529
+ reader = pd.read_stata(path, iterator=True)
1530
+ try:
1531
+ frame = reader.get_chunk(TABLE_SAMPLE_ROWS)
1532
+ variable_count = len(frame.columns)
1533
+ finally:
1534
+ # pandas 3.0 removed StataReader.close (pandas-dev/pandas#49228);
1535
+ # keep the call defensive for older releases.
1536
+ close = getattr(reader, "close", None)
1537
+ if callable(close):
1538
+ close()
1539
+ language = detect_language(" ".join(clean_structural_name(x) for x in frame.columns))
1540
+ structure = {
1541
+ "sample_rows": int(len(frame)),
1542
+ "column_count": variable_count,
1543
+ "columns": column_structure(frame),
1544
+ "truncated_columns": variable_count > MAX_STRUCTURAL_ITEMS,
1545
+ }
1546
+ return Analysis(
1547
+ format_name="Stata dataset",
1548
+ analyzer="pandas-stata",
1549
+ status="fresh",
1550
+ language=language,
1551
+ summary_zh=f"Stata 数据集;已采样 {len(frame)} 行并识别 {variable_count} 个变量。",
1552
+ summary_en=f"Stata dataset; sampled {len(frame)} rows and identified {variable_count} variables.",
1553
+ structure=structure,
1554
+ warnings=[
1555
+ "Value labels and cell values were not copied into the catalog.",
1556
+ f"Statistics use at most the first {TABLE_SAMPLE_ROWS} records.",
1557
+ ],
1558
+ )
1559
+
1560
+
1561
+ def analyze_generic_source(path: Path, text: str, encoding: str, truncated: bool) -> Analysis:
1562
+ """Table-driven structural analysis for many programming languages."""
1563
+ extension = path.suffix.casefold()
1564
+ profile = LANGUAGE_PROFILES.get(extension, {})
1565
+ structure: Dict[str, Any] = {
1566
+ "encoding": encoding,
1567
+ "sample_line_count": len(text.splitlines()),
1568
+ "sample_truncated": truncated,
1569
+ }
1570
+ decl_pattern = profile.get("decl_re") or GENERIC_DECL_RE
1571
+ declarations = re.findall(decl_pattern, text)
1572
+ structure["declarations"] = [
1573
+ clean_structural_name(x) for x in declarations[:MAX_STRUCTURAL_ITEMS]
1574
+ ]
1575
+ if profile.get("import_re"):
1576
+ imports = re.findall(profile["import_re"], text)
1577
+ flattened = [item for match in imports for item in (match if isinstance(match, tuple) else (match,)) if item]
1578
+ structure["imports"] = [
1579
+ clean_structural_name(x) for x in flattened[:MAX_STRUCTURAL_ITEMS]
1580
+ ]
1581
+ if profile.get("package_re"):
1582
+ packages = re.findall(profile["package_re"], text)
1583
+ structure["packages"] = [
1584
+ clean_structural_name(x) for x in packages[:MAX_STRUCTURAL_ITEMS]
1585
+ ]
1586
+ format_name = profile.get("format_name") or f"{extension.lstrip('.').upper()} source"
1587
+ return Analysis(
1588
+ format_name=format_name,
1589
+ analyzer="generic-source-structure",
1590
+ status="fresh",
1591
+ language=detect_language(text[:20000]),
1592
+ summary_zh=f"{format_name};已记录编码、行数和可识别的结构性名称。",
1593
+ summary_en=f"{format_name}; recorded encoding, line counts, and recognizable structural names.",
1594
+ structure=structure,
1595
+ warnings=(
1596
+ ["Only a bounded prefix was inspected; counts may be incomplete."]
1597
+ if truncated
1598
+ else []
1599
+ ),
1600
+ )
1601
+
1602
+
1603
+ def locate_rscript() -> Optional[str]:
1604
+ environment_value = os.environ.get("R_SCRIPT_EXE")
1605
+ candidates = [
1606
+ environment_value,
1607
+ shutil.which("Rscript"),
1608
+ ]
1609
+ if os.name == "nt":
1610
+ windows_roots: List[Path] = []
1611
+ program_files = os.environ.get("ProgramFiles")
1612
+ local_app_data = os.environ.get("LOCALAPPDATA")
1613
+ if program_files:
1614
+ windows_roots.append(Path(program_files) / "R")
1615
+ if local_app_data:
1616
+ windows_roots.append(Path(local_app_data) / "Programs" / "R")
1617
+ for root in windows_roots:
1618
+ if not root.is_dir():
1619
+ continue
1620
+ versions = sorted(
1621
+ (path for path in root.glob("R-*") if path.is_dir()),
1622
+ key=lambda path: path.name.casefold(),
1623
+ reverse=True,
1624
+ )
1625
+ for version in versions:
1626
+ candidates.extend(
1627
+ [
1628
+ str(version / "bin" / "Rscript.exe"),
1629
+ str(version / "bin" / "x64" / "Rscript.exe"),
1630
+ ]
1631
+ )
1632
+ for candidate in candidates:
1633
+ if candidate and Path(candidate).is_file():
1634
+ return str(Path(candidate))
1635
+ return None
1636
+
1637
+
1638
+ def analyze_r_data(path: Path) -> Analysis:
1639
+ rscript = locate_rscript()
1640
+ if not rscript:
1641
+ raise RuntimeError("Rscript could not be located.")
1642
+ helper = Path(__file__).with_name("inspect_r_data.R")
1643
+ completed = subprocess.run(
1644
+ [rscript, "--vanilla", str(helper), str(path)],
1645
+ stdout=subprocess.PIPE,
1646
+ stderr=subprocess.PIPE,
1647
+ text=True,
1648
+ encoding="utf-8",
1649
+ errors="replace",
1650
+ timeout=180,
1651
+ check=False,
1652
+ )
1653
+ stdout = completed.stdout[:2_000_000]
1654
+ try:
1655
+ payload = json.loads(stdout)
1656
+ except json.JSONDecodeError as error:
1657
+ stderr = completed.stderr[-1000:].strip()
1658
+ raise RuntimeError(f"R inspector returned invalid JSON: {stderr}") from error
1659
+ if completed.returncode != 0 or payload.get("status") != "ok":
1660
+ raise RuntimeError(str(payload.get("message", "R inspector failed.")))
1661
+ object_names = list((payload.get("objects") or {}).keys())
1662
+ language = detect_language(" ".join(object_names))
1663
+ return Analysis(
1664
+ format_name=str(payload.get("format", "R data")),
1665
+ analyzer="r-structure-helper",
1666
+ status="fresh",
1667
+ language=language,
1668
+ summary_zh=f"R 数据文件;识别 {len(object_names)} 个顶层对象并提取类、维度和字段结构。",
1669
+ summary_en=f"R data file; identified {len(object_names)} top-level objects and extracted classes, dimensions, and fields.",
1670
+ structure=payload,
1671
+ warnings=[str(x) for x in payload.get("warnings", [])],
1672
+ )
1673
+
1674
+
1675
+ def generic_analysis(path: Path, reason: Optional[str] = None) -> Analysis:
1676
+ mime_type, encoding_hint = mimetypes.guess_type(str(path))
1677
+ text, encoding, truncated = read_text_sample(path)
1678
+ if text is not None:
1679
+ lines = text.splitlines()
1680
+ warnings = (
1681
+ ["Only a bounded text prefix was inspected."] if truncated else []
1682
+ )
1683
+ if reason:
1684
+ warnings.append(reason)
1685
+ return Analysis(
1686
+ format_name=mime_type or "Generic text",
1687
+ analyzer="generic-text-metadata",
1688
+ status="fresh" if reason is None else "error",
1689
+ language=detect_language(text[:20000]),
1690
+ summary_zh="通用文本文件;已记录编码、样本行数和 MIME 类型。",
1691
+ summary_en="Generic text file; recorded encoding, sampled line count, and MIME type.",
1692
+ structure={
1693
+ "extension": path.suffix,
1694
+ "mime_type": mime_type,
1695
+ "encoding_hint": encoding_hint,
1696
+ "detected_encoding": encoding,
1697
+ "sample_line_count": len(lines),
1698
+ "sample_truncated": truncated,
1699
+ },
1700
+ warnings=warnings,
1701
+ )
1702
+
1703
+ warnings = ["No deep parser matched; only file metadata was recorded."]
1704
+ if reason:
1705
+ warnings.append(reason)
1706
+ return Analysis(
1707
+ format_name=mime_type or "Unknown binary",
1708
+ analyzer="generic-binary-metadata",
1709
+ status="unsupported" if reason is None else "error",
1710
+ language="zh",
1711
+ summary_zh="二进制或未知格式文件;仅记录基础元数据。",
1712
+ summary_en="Binary or unknown-format file; recorded basic metadata only.",
1713
+ structure={
1714
+ "extension": path.suffix,
1715
+ "mime_type": mime_type,
1716
+ "encoding_hint": encoding_hint,
1717
+ },
1718
+ warnings=warnings,
1719
+ )
1720
+
1721
+
1722
+ def analyze_file(path: Path) -> Analysis:
1723
+ extension = path.suffix.casefold()
1724
+ try:
1725
+ if extension in {".csv", ".tsv", ".tab"}:
1726
+ return analyze_delimited(path, None)
1727
+ if extension in {".xlsx", ".xls", ".xlsm", ".ods"}:
1728
+ return analyze_excel(path)
1729
+ if extension == ".parquet":
1730
+ return analyze_parquet(path)
1731
+ if extension in {".feather", ".arrow"}:
1732
+ return analyze_feather(path)
1733
+ if extension == ".json":
1734
+ return analyze_json(path, json_lines=False)
1735
+ if extension in {".jsonl", ".ndjson"}:
1736
+ return analyze_json(path, json_lines=True)
1737
+ if extension in {".yaml", ".yml"}:
1738
+ return analyze_yaml(path)
1739
+ if extension == ".toml":
1740
+ return analyze_toml(path)
1741
+ if extension in {".rds", ".rda", ".rdata"}:
1742
+ return analyze_r_data(path)
1743
+ if extension == ".pdf":
1744
+ return analyze_pdf(path)
1745
+ if extension == ".docx":
1746
+ return analyze_docx(path)
1747
+ if extension in {
1748
+ ".png",
1749
+ ".jpg",
1750
+ ".jpeg",
1751
+ ".gif",
1752
+ ".bmp",
1753
+ ".tif",
1754
+ ".tiff",
1755
+ ".webp",
1756
+ }:
1757
+ return analyze_image(path)
1758
+ if extension in SQLITE_EXTENSIONS:
1759
+ return analyze_sqlite(path)
1760
+ if extension in ARCHIVE_EXTENSIONS:
1761
+ return analyze_zip_archive(path)
1762
+ if extension in TAR_EXTENSIONS:
1763
+ return analyze_tar_archive(path)
1764
+ if extension == ".gz":
1765
+ return analyze_gzip_stream(path)
1766
+ if extension in XML_EXTENSIONS:
1767
+ return analyze_xml(path)
1768
+ if extension in HTML_EXTENSIONS:
1769
+ return analyze_html(path)
1770
+ if extension == ".ipynb":
1771
+ return analyze_ipynb(path)
1772
+ if extension == ".dta":
1773
+ return analyze_stata(path)
1774
+ if extension in TEXT_EXTENSIONS or extension in CODE_EXTENSIONS:
1775
+ return analyze_code_or_text(path)
1776
+ return generic_analysis(path)
1777
+ except Exception as error:
1778
+ return generic_analysis(
1779
+ path,
1780
+ reason=f"{type(error).__name__}: {str(error)[:500]}",
1781
+ )
1782
+
1783
+
1784
+ def catalog_paths(task_root: Path) -> Tuple[Path, Path, Path]:
1785
+ catalog_root = task_root / ".file-catalog"
1786
+ return (
1787
+ catalog_root,
1788
+ catalog_root / "documents",
1789
+ catalog_root / "catalog.sqlite3",
1790
+ )
1791
+
1792
+
1793
+ def atomic_write_text(path: Path, content: str) -> None:
1794
+ path.parent.mkdir(parents=True, exist_ok=True)
1795
+ temporary = path.with_name(f"{path.name}.{os.getpid()}.tmp")
1796
+ with temporary.open("w", encoding="utf-8", newline="\n") as handle:
1797
+ handle.write(content)
1798
+ os.replace(str(temporary), str(path))
1799
+
1800
+
1801
+ def ensure_catalog(task_root: Path) -> Tuple[Path, Path, Path]:
1802
+ catalog_root, documents_root, database_path = catalog_paths(task_root)
1803
+ documents_root.mkdir(parents=True, exist_ok=True)
1804
+ gitignore = "\n".join(
1805
+ [
1806
+ "# Machine index and transient files; Markdown explanations remain trackable.",
1807
+ "/catalog.sqlite3",
1808
+ "/catalog.sqlite3-shm",
1809
+ "/catalog.sqlite3-wal",
1810
+ "*.tmp",
1811
+ "",
1812
+ ]
1813
+ )
1814
+ gitignore_path = catalog_root / ".gitignore"
1815
+ if not gitignore_path.exists() or gitignore_path.read_text(
1816
+ encoding="utf-8", errors="replace"
1817
+ ) != gitignore:
1818
+ atomic_write_text(gitignore_path, gitignore)
1819
+ return catalog_root, documents_root, database_path
1820
+
1821
+
1822
+ def connect_database(database_path: Path, create: bool) -> Optional[sqlite3.Connection]:
1823
+ if not create and not database_path.exists():
1824
+ return None
1825
+ connection = sqlite3.connect(str(database_path), timeout=30)
1826
+ connection.row_factory = sqlite3.Row
1827
+ connection.execute("PRAGMA busy_timeout = 30000")
1828
+ if create:
1829
+ connection.execute("PRAGMA journal_mode = WAL")
1830
+ connection.execute(
1831
+ """
1832
+ CREATE TABLE IF NOT EXISTS files (
1833
+ relative_path TEXT PRIMARY KEY,
1834
+ source_absolute TEXT NOT NULL,
1835
+ task_root_at_scan TEXT NOT NULL,
1836
+ document_relative TEXT NOT NULL,
1837
+ size_bytes INTEGER NOT NULL,
1838
+ mtime_ns INTEGER NOT NULL,
1839
+ sha256 TEXT NOT NULL,
1840
+ file_type TEXT NOT NULL,
1841
+ analyzer TEXT NOT NULL,
1842
+ language TEXT NOT NULL,
1843
+ status TEXT NOT NULL,
1844
+ summary_zh TEXT NOT NULL,
1845
+ summary_en TEXT NOT NULL,
1846
+ structure_json TEXT NOT NULL,
1847
+ warnings_json TEXT NOT NULL,
1848
+ search_text TEXT NOT NULL,
1849
+ analyzed_at TEXT NOT NULL,
1850
+ reused_from TEXT,
1851
+ catalog_version INTEGER NOT NULL DEFAULT 0
1852
+ )
1853
+ """
1854
+ )
1855
+ connection.execute(
1856
+ "CREATE INDEX IF NOT EXISTS idx_files_sha256 ON files(sha256)"
1857
+ )
1858
+ connection.execute(
1859
+ "CREATE INDEX IF NOT EXISTS idx_files_status ON files(status)"
1860
+ )
1861
+ columns = {
1862
+ row["name"]
1863
+ for row in connection.execute("PRAGMA table_info(files)").fetchall()
1864
+ }
1865
+ if "catalog_version" not in columns:
1866
+ connection.execute(
1867
+ "ALTER TABLE files ADD COLUMN catalog_version INTEGER NOT NULL DEFAULT 0"
1868
+ )
1869
+ connection.commit()
1870
+ return connection
1871
+
1872
+
1873
+ def analysis_from_row(row: sqlite3.Row) -> Analysis:
1874
+ return Analysis(
1875
+ format_name=row["file_type"],
1876
+ analyzer=row["analyzer"],
1877
+ status=row["status"],
1878
+ language=row["language"],
1879
+ summary_zh=row["summary_zh"],
1880
+ summary_en=row["summary_en"],
1881
+ structure=json.loads(row["structure_json"]),
1882
+ warnings=json.loads(row["warnings_json"]),
1883
+ )
1884
+
1885
+
1886
+ def render_document(
1887
+ task_root: Path,
1888
+ source: Path,
1889
+ relative_path: str,
1890
+ digest: str,
1891
+ analysis: Analysis,
1892
+ reused_from: Optional[str],
1893
+ ) -> str:
1894
+ stat = source.stat()
1895
+ modified = dt.datetime.fromtimestamp(
1896
+ stat.st_mtime, tz=dt.timezone.utc
1897
+ ).replace(microsecond=0).isoformat()
1898
+ analyzed = utc_now()
1899
+ title = source.name
1900
+ structure_json = json.dumps(
1901
+ analysis.structure,
1902
+ ensure_ascii=False,
1903
+ indent=2,
1904
+ sort_keys=True,
1905
+ )
1906
+ warning_lines = analysis.warnings or ["None"]
1907
+
1908
+ frontmatter = "\n".join(
1909
+ [
1910
+ "---",
1911
+ f"catalog_version: {CATALOG_VERSION}",
1912
+ f"relative_path: {yaml_quote(relative_path)}",
1913
+ f"absolute_path_at_scan: {yaml_quote(str(source))}",
1914
+ f"task_root_at_scan: {yaml_quote(str(task_root))}",
1915
+ f"sha256: {yaml_quote(digest)}",
1916
+ f"size_bytes: {stat.st_size}",
1917
+ f"modified_utc: {yaml_quote(modified)}",
1918
+ f"analyzed_utc: {yaml_quote(analyzed)}",
1919
+ f"status: {yaml_quote(analysis.status)}",
1920
+ f"format: {yaml_quote(analysis.format_name)}",
1921
+ f"analyzer: {yaml_quote(analysis.analyzer)}",
1922
+ "---",
1923
+ ]
1924
+ )
1925
+
1926
+ if analysis.language == "en":
1927
+ summary = analysis.summary_en
1928
+ warning_block = "\n".join(f"- {item}" for item in warning_lines)
1929
+ reuse_line = (
1930
+ f"- Reused structure from task-relative path: `{reused_from}`"
1931
+ if reused_from
1932
+ else "- Structure was parsed from this file."
1933
+ )
1934
+ body = f"""
1935
+ # {title}
1936
+
1937
+ ## Location and freshness
1938
+
1939
+ - Task-relative path: `{relative_path}`
1940
+ - Absolute path at scan: `{source}`
1941
+ - Size: {stat.st_size} bytes
1942
+ - Modified: {modified}
1943
+ - SHA-256: `{digest}`
1944
+ - Status: `{analysis.status}`
1945
+ - Format/analyzer: `{analysis.format_name}` / `{analysis.analyzer}`
1946
+ {reuse_line}
1947
+
1948
+ ## Content overview
1949
+
1950
+ {summary}
1951
+
1952
+ ## Data or file structure
1953
+
1954
+ ```json
1955
+ {structure_json}
1956
+ ```
1957
+
1958
+ ## Limits and warnings
1959
+
1960
+ {warning_block}
1961
+
1962
+ This explanation intentionally omits raw rows, cell samples, paragraph excerpts, category values, and source-code snippets.
1963
+ """.lstrip()
1964
+ else:
1965
+ summary = analysis.summary_zh
1966
+ warning_block = "\n".join(f"- {item}" for item in warning_lines)
1967
+ reuse_line = (
1968
+ f"- 结构复用来源(任务相对路径):`{reused_from}`"
1969
+ if reused_from
1970
+ else "- 结构由当前文件解析得到。"
1971
+ )
1972
+ body = f"""
1973
+ # {title}
1974
+
1975
+ ## 位置与新鲜度
1976
+
1977
+ - 任务相对路径:`{relative_path}`
1978
+ - 扫描时绝对路径:`{source}`
1979
+ - 大小:{stat.st_size} 字节
1980
+ - 修改时间:{modified}
1981
+ - SHA-256:`{digest}`
1982
+ - 状态:`{analysis.status}`
1983
+ - 格式/解析器:`{analysis.format_name}` / `{analysis.analyzer}`
1984
+ {reuse_line}
1985
+
1986
+ ## 内容概览
1987
+
1988
+ {summary}
1989
+
1990
+ ## 数据或文件结构
1991
+
1992
+ ```json
1993
+ {structure_json}
1994
+ ```
1995
+
1996
+ ## 限制与警告
1997
+
1998
+ {warning_block}
1999
+
2000
+ 本说明有意省略原始数据行、单元格样例、正文段落、类别值和源代码片段。
2001
+ """.lstrip()
2002
+ return frontmatter + "\n\n" + body
2003
+
2004
+
2005
+ def upsert_entry(
2006
+ connection: sqlite3.Connection,
2007
+ task_root: Path,
2008
+ source: Path,
2009
+ relative_path: str,
2010
+ document_relative: str,
2011
+ digest: str,
2012
+ analysis: Analysis,
2013
+ reused_from: Optional[str],
2014
+ ) -> None:
2015
+ stat = source.stat()
2016
+ structure_json = json.dumps(analysis.structure, ensure_ascii=False, sort_keys=True)
2017
+ warnings_json = json.dumps(analysis.warnings, ensure_ascii=False)
2018
+ search_text = "\n".join(
2019
+ [
2020
+ relative_path,
2021
+ source.name,
2022
+ analysis.format_name,
2023
+ analysis.analyzer,
2024
+ analysis.summary_zh,
2025
+ analysis.summary_en,
2026
+ structure_json,
2027
+ ]
2028
+ )
2029
+ connection.execute(
2030
+ """
2031
+ INSERT INTO files (
2032
+ relative_path, source_absolute, task_root_at_scan, document_relative,
2033
+ size_bytes, mtime_ns, sha256, file_type, analyzer, language, status,
2034
+ summary_zh, summary_en, structure_json, warnings_json, search_text,
2035
+ analyzed_at, reused_from, catalog_version
2036
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2037
+ ON CONFLICT(relative_path) DO UPDATE SET
2038
+ source_absolute=excluded.source_absolute,
2039
+ task_root_at_scan=excluded.task_root_at_scan,
2040
+ document_relative=excluded.document_relative,
2041
+ size_bytes=excluded.size_bytes,
2042
+ mtime_ns=excluded.mtime_ns,
2043
+ sha256=excluded.sha256,
2044
+ file_type=excluded.file_type,
2045
+ analyzer=excluded.analyzer,
2046
+ language=excluded.language,
2047
+ status=excluded.status,
2048
+ summary_zh=excluded.summary_zh,
2049
+ summary_en=excluded.summary_en,
2050
+ structure_json=excluded.structure_json,
2051
+ warnings_json=excluded.warnings_json,
2052
+ search_text=excluded.search_text,
2053
+ analyzed_at=excluded.analyzed_at,
2054
+ reused_from=excluded.reused_from,
2055
+ catalog_version=excluded.catalog_version
2056
+ """,
2057
+ (
2058
+ relative_path,
2059
+ str(source),
2060
+ str(task_root),
2061
+ document_relative,
2062
+ stat.st_size,
2063
+ stat.st_mtime_ns,
2064
+ digest,
2065
+ analysis.format_name,
2066
+ analysis.analyzer,
2067
+ analysis.language,
2068
+ analysis.status,
2069
+ analysis.summary_zh,
2070
+ analysis.summary_en,
2071
+ structure_json,
2072
+ warnings_json,
2073
+ search_text,
2074
+ utc_now(),
2075
+ reused_from,
2076
+ CATALOG_VERSION,
2077
+ ),
2078
+ )
2079
+
2080
+
2081
+ def render_index(connection: sqlite3.Connection, task_root: Path, index_path: Path) -> None:
2082
+ rows = connection.execute(
2083
+ """
2084
+ SELECT relative_path, file_type, status, document_relative, analyzed_at
2085
+ FROM files
2086
+ ORDER BY lower(relative_path)
2087
+ """
2088
+ ).fetchall()
2089
+ lines = [
2090
+ "# File Catalog Index",
2091
+ "",
2092
+ f"> Task root at generation: `{task_root}`",
2093
+ f"> Generated: {utc_now()}",
2094
+ "",
2095
+ "| Status | Task-relative path | Format | Explanation |",
2096
+ "|---|---|---|---|",
2097
+ ]
2098
+ for row in rows:
2099
+ relative = str(row["relative_path"]).replace("|", "\\|")
2100
+ file_type = str(row["file_type"]).replace("|", "\\|")
2101
+ link = Path(row["document_relative"]).as_posix()
2102
+ lines.append(
2103
+ f"| {row['status']} | `{relative}` | {file_type} | [document]({link}) |"
2104
+ )
2105
+ lines.extend(
2106
+ [
2107
+ "",
2108
+ "Use the skill's `search` command instead of loading this entire index into Agent context.",
2109
+ "",
2110
+ ]
2111
+ )
2112
+ atomic_write_text(index_path, "\n".join(lines))
2113
+
2114
+
2115
+ def result_record(
2116
+ status: str,
2117
+ source: Path,
2118
+ relative_path: str,
2119
+ document: str,
2120
+ reused_from: Optional[str] = None,
2121
+ ) -> Dict[str, str]:
2122
+ return {
2123
+ "status": status,
2124
+ "source": str(source),
2125
+ "relative_path": relative_path,
2126
+ "document": document,
2127
+ "reused_from": reused_from or "",
2128
+ }
2129
+
2130
+
2131
+ def catalog_files(
2132
+ task_root: Path,
2133
+ raw_paths: Sequence[str],
2134
+ extra_excludes: Sequence[str] = (),
2135
+ ) -> List[Dict[str, str]]:
2136
+ catalog_root, documents_root, database_path = ensure_catalog(task_root)
2137
+ connection = connect_database(database_path, create=True)
2138
+ assert connection is not None
2139
+ candidates, issues, full_scan = gather_files(task_root, raw_paths, extra_excludes)
2140
+ results = list(issues)
2141
+ seen: set[str] = set()
2142
+
2143
+ # 第一阶段:串行确定每个文件的新鲜度,并完成跨运行的内容复用。
2144
+ pending: List[Tuple[Path, str, str, Path, str]] = []
2145
+ for source in candidates:
2146
+ relative_path = relative_key(source, task_root)
2147
+ seen.add(relative_path)
2148
+ stat = source.stat()
2149
+ row = connection.execute(
2150
+ "SELECT * FROM files WHERE relative_path = ?",
2151
+ (relative_path,),
2152
+ ).fetchone()
2153
+ doc_relative = f"documents/{document_id(relative_path)}.md"
2154
+ doc_path = catalog_root / doc_relative
2155
+
2156
+ if (
2157
+ row is not None
2158
+ and row["size_bytes"] == stat.st_size
2159
+ and row["mtime_ns"] == stat.st_mtime_ns
2160
+ and row["status"] != "missing"
2161
+ and row["catalog_version"] == CATALOG_VERSION
2162
+ and doc_path.exists()
2163
+ ):
2164
+ if (
2165
+ row["source_absolute"] != str(source)
2166
+ or row["task_root_at_scan"] != str(task_root)
2167
+ ):
2168
+ stored_analysis = analysis_from_row(row)
2169
+ relocated_document = render_document(
2170
+ task_root,
2171
+ source,
2172
+ relative_path,
2173
+ row["sha256"],
2174
+ stored_analysis,
2175
+ row["reused_from"],
2176
+ )
2177
+ atomic_write_text(doc_path, relocated_document)
2178
+ upsert_entry(
2179
+ connection,
2180
+ task_root,
2181
+ source,
2182
+ relative_path,
2183
+ doc_relative,
2184
+ row["sha256"],
2185
+ stored_analysis,
2186
+ row["reused_from"],
2187
+ )
2188
+ results.append(
2189
+ result_record(
2190
+ row["status"],
2191
+ source,
2192
+ relative_path,
2193
+ str(doc_path),
2194
+ row["reused_from"],
2195
+ )
2196
+ )
2197
+ continue
2198
+
2199
+ digest = sha256_file(source)
2200
+ duplicate = connection.execute(
2201
+ """
2202
+ SELECT * FROM files
2203
+ WHERE sha256 = ?
2204
+ AND relative_path <> ?
2205
+ AND status IN ('fresh', 'unsupported')
2206
+ ORDER BY analyzed_at DESC
2207
+ LIMIT 1
2208
+ """,
2209
+ (digest, relative_path),
2210
+ ).fetchone()
2211
+ if duplicate is not None:
2212
+ reused_from = duplicate["relative_path"]
2213
+ analysis = analysis_from_row(duplicate)
2214
+ document = render_document(
2215
+ task_root,
2216
+ source,
2217
+ relative_path,
2218
+ digest,
2219
+ analysis,
2220
+ reused_from,
2221
+ )
2222
+ atomic_write_text(doc_path, document)
2223
+ upsert_entry(
2224
+ connection,
2225
+ task_root,
2226
+ source,
2227
+ relative_path,
2228
+ doc_relative,
2229
+ digest,
2230
+ analysis,
2231
+ reused_from,
2232
+ )
2233
+ results.append(
2234
+ result_record(
2235
+ analysis.status,
2236
+ source,
2237
+ relative_path,
2238
+ str(doc_path),
2239
+ reused_from,
2240
+ )
2241
+ )
2242
+ continue
2243
+ pending.append((source, relative_path, doc_relative, doc_path, digest))
2244
+
2245
+ # 第二阶段:同一批内按 SHA-256 分组复用,每组只解析第一个文件;
2246
+ # 剩余文件在并行解析后按确定顺序写文档与索引。
2247
+ try:
2248
+ groups: Dict[str, List[Tuple[Path, str, str, Path]]] = {}
2249
+ for source, relative_path, doc_relative, doc_path, digest in pending:
2250
+ groups.setdefault(digest, []).append(
2251
+ (source, relative_path, doc_relative, doc_path)
2252
+ )
2253
+ analyzed: Dict[str, Analysis] = {}
2254
+ if groups:
2255
+ workers = max(1, min(MAX_PARSE_WORKERS, os.cpu_count() or 1))
2256
+ with ThreadPoolExecutor(max_workers=workers) as pool:
2257
+ futures = {
2258
+ pool.submit(analyze_file, entries[0][0]): digest
2259
+ for digest, entries in groups.items()
2260
+ }
2261
+ for future in futures:
2262
+ analyzed[futures[future]] = future.result()
2263
+
2264
+ for source, relative_path, doc_relative, doc_path, digest in pending:
2265
+ group = groups[digest]
2266
+ reused_from: Optional[str] = (
2267
+ relative_key(group[0][0], task_root)
2268
+ if group[0][1] != relative_path
2269
+ else None
2270
+ )
2271
+ analysis = analyzed[digest]
2272
+ document = render_document(
2273
+ task_root,
2274
+ source,
2275
+ relative_path,
2276
+ digest,
2277
+ analysis,
2278
+ reused_from,
2279
+ )
2280
+ atomic_write_text(doc_path, document)
2281
+ upsert_entry(
2282
+ connection,
2283
+ task_root,
2284
+ source,
2285
+ relative_path,
2286
+ doc_relative,
2287
+ digest,
2288
+ analysis,
2289
+ reused_from,
2290
+ )
2291
+ results.append(
2292
+ result_record(
2293
+ analysis.status,
2294
+ source,
2295
+ relative_path,
2296
+ str(doc_path),
2297
+ reused_from,
2298
+ )
2299
+ )
2300
+
2301
+ if full_scan:
2302
+ existing_rows = connection.execute(
2303
+ "SELECT relative_path FROM files"
2304
+ ).fetchall()
2305
+ missing_paths = [
2306
+ row["relative_path"]
2307
+ for row in existing_rows
2308
+ if row["relative_path"] not in seen
2309
+ ]
2310
+ connection.executemany(
2311
+ "UPDATE files SET status = 'missing' WHERE relative_path = ?",
2312
+ [(path,) for path in missing_paths],
2313
+ )
2314
+
2315
+ connection.commit()
2316
+ render_index(connection, task_root, catalog_root / "INDEX.md")
2317
+ finally:
2318
+ connection.close()
2319
+ return results
2320
+
2321
+
2322
+ def lookup_files(
2323
+ task_root: Path,
2324
+ raw_paths: Sequence[str],
2325
+ extra_excludes: Sequence[str] = (),
2326
+ ) -> List[Dict[str, str]]:
2327
+ catalog_root, _, database_path = catalog_paths(task_root)
2328
+ candidates, issues, _ = gather_files(task_root, raw_paths, extra_excludes)
2329
+ connection = connect_database(database_path, create=False)
2330
+ results = list(issues)
2331
+ if connection is None:
2332
+ for source in candidates:
2333
+ results.append(
2334
+ result_record(
2335
+ "missing",
2336
+ source,
2337
+ relative_key(source, task_root),
2338
+ "",
2339
+ )
2340
+ )
2341
+ return results
2342
+
2343
+ try:
2344
+ for source in candidates:
2345
+ relative_path = relative_key(source, task_root)
2346
+ row = connection.execute(
2347
+ "SELECT * FROM files WHERE relative_path = ?",
2348
+ (relative_path,),
2349
+ ).fetchone()
2350
+ if row is None:
2351
+ results.append(
2352
+ result_record("missing", source, relative_path, "")
2353
+ )
2354
+ continue
2355
+ stat = source.stat()
2356
+ document = str(catalog_root / row["document_relative"])
2357
+ if (
2358
+ row["size_bytes"] == stat.st_size
2359
+ and row["mtime_ns"] == stat.st_mtime_ns
2360
+ and Path(document).exists()
2361
+ ):
2362
+ status = row["status"]
2363
+ else:
2364
+ status = "stale"
2365
+ results.append(
2366
+ result_record(
2367
+ status,
2368
+ source,
2369
+ relative_path,
2370
+ document,
2371
+ row["reused_from"],
2372
+ )
2373
+ )
2374
+ finally:
2375
+ connection.close()
2376
+ return results
2377
+
2378
+
2379
+ def search_catalog(
2380
+ task_root: Path,
2381
+ query: str,
2382
+ limit: int,
2383
+ ) -> List[Dict[str, str]]:
2384
+ catalog_root, _, database_path = catalog_paths(task_root)
2385
+ connection = connect_database(database_path, create=False)
2386
+ if connection is None:
2387
+ return []
2388
+ try:
2389
+ escaped = query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
2390
+ pattern = f"%{escaped}%"
2391
+ rows = connection.execute(
2392
+ """
2393
+ SELECT relative_path, source_absolute, document_relative, status,
2394
+ file_type, reused_from
2395
+ FROM files
2396
+ WHERE relative_path LIKE ? ESCAPE '\\' COLLATE NOCASE
2397
+ OR source_absolute LIKE ? ESCAPE '\\' COLLATE NOCASE
2398
+ OR search_text LIKE ? ESCAPE '\\' COLLATE NOCASE
2399
+ ORDER BY
2400
+ CASE WHEN relative_path LIKE ? ESCAPE '\\' COLLATE NOCASE THEN 0 ELSE 1 END,
2401
+ lower(relative_path)
2402
+ LIMIT ?
2403
+ """,
2404
+ (pattern, pattern, pattern, pattern, limit),
2405
+ ).fetchall()
2406
+ return [
2407
+ {
2408
+ "status": row["status"],
2409
+ "source": row["source_absolute"],
2410
+ "relative_path": row["relative_path"],
2411
+ "document": str(catalog_root / row["document_relative"]),
2412
+ "format": row["file_type"],
2413
+ "reused_from": row["reused_from"] or "",
2414
+ }
2415
+ for row in rows
2416
+ ]
2417
+ finally:
2418
+ connection.close()
2419
+
2420
+
2421
+ def catalog_info(task_root: Path) -> List[Dict[str, Any]]:
2422
+ """Summarize the task-local catalog: counts by status and format."""
2423
+ catalog_root, _, database_path = catalog_paths(task_root)
2424
+ connection = connect_database(database_path, create=False)
2425
+ if connection is None:
2426
+ return [{"catalog_exists": False}]
2427
+ try:
2428
+ status_counts = {
2429
+ row["status"]: row["count"]
2430
+ for row in connection.execute(
2431
+ "SELECT status, count(*) AS count FROM files GROUP BY status"
2432
+ ).fetchall()
2433
+ }
2434
+ format_counts = {
2435
+ row["file_type"]: row["count"]
2436
+ for row in connection.execute(
2437
+ "SELECT file_type, count(*) AS count FROM files "
2438
+ "GROUP BY file_type ORDER BY count(*) DESC LIMIT 20"
2439
+ ).fetchall()
2440
+ }
2441
+ last_analyzed = connection.execute(
2442
+ "SELECT max(analyzed_at) AS value FROM files"
2443
+ ).fetchone()["value"]
2444
+ return [
2445
+ {
2446
+ "catalog_exists": True,
2447
+ "catalog_root": str(catalog_root),
2448
+ "database": str(database_path),
2449
+ "total_entries": sum(status_counts.values()),
2450
+ "status_counts": status_counts,
2451
+ "format_counts": format_counts,
2452
+ "last_analyzed": last_analyzed,
2453
+ }
2454
+ ]
2455
+ finally:
2456
+ connection.close()
2457
+
2458
+
2459
+ def print_results(records: Sequence[Dict[str, Any]], limit: int, json_mode: bool) -> None:
2460
+ shown = list(records[:limit])
2461
+ if json_mode:
2462
+ payload = {
2463
+ "results": shown,
2464
+ "summary": {
2465
+ "total": len(records),
2466
+ "shown": len(shown),
2467
+ "omitted": max(0, len(records) - len(shown)),
2468
+ },
2469
+ }
2470
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
2471
+ return
2472
+ for record in shown:
2473
+ parts = [
2474
+ f"status={record.get('status', '')}",
2475
+ f"path={record.get('relative_path', '')}",
2476
+ ]
2477
+ if record.get("format"):
2478
+ parts.append(f"format={record['format']}")
2479
+ if record.get("document"):
2480
+ parts.append(f"document={record['document']}")
2481
+ if record.get("reused_from"):
2482
+ parts.append(f"reused_from={record['reused_from']}")
2483
+ if record.get("catalog_exists") is not None and "path" not in record:
2484
+ parts = [f"{key}={value}" for key, value in record.items()]
2485
+ print(" | ".join(parts))
2486
+ if len(records) > len(shown):
2487
+ print(f"... {len(records) - len(shown)} additional result(s) omitted by --limit")
2488
+ print(f"summary total={len(records)} shown={len(shown)}")
2489
+
2490
+
2491
+ def build_parser() -> argparse.ArgumentParser:
2492
+ parser = argparse.ArgumentParser(
2493
+ description="Create and reuse per-task structural explanations for local files."
2494
+ )
2495
+
2496
+ json_parent = argparse.ArgumentParser(add_help=False)
2497
+ json_parent.add_argument(
2498
+ "--json",
2499
+ action="store_true",
2500
+ help="Print machine-readable JSON instead of status lines.",
2501
+ )
2502
+ scan_parent = argparse.ArgumentParser(add_help=False, parents=[json_parent])
2503
+ scan_parent.add_argument(
2504
+ "--exclude",
2505
+ default="",
2506
+ help="Comma-separated file/directory names to skip during scans.",
2507
+ )
2508
+ subparsers = parser.add_subparsers(dest="command", required=True)
2509
+
2510
+ catalog_parser = subparsers.add_parser(
2511
+ "catalog", help="Catalog missing or stale task files.", parents=[scan_parent]
2512
+ )
2513
+ catalog_parser.add_argument(
2514
+ "--task-root", help="Large-task root; defaults to the current directory."
2515
+ )
2516
+ catalog_parser.add_argument(
2517
+ "--limit", type=int, default=30, help="Maximum result rows printed."
2518
+ )
2519
+ catalog_parser.add_argument(
2520
+ "paths",
2521
+ nargs="*",
2522
+ help="In-root files/directories; omit to recursively catalog the task root.",
2523
+ )
2524
+
2525
+ lookup_parser = subparsers.add_parser(
2526
+ "lookup", help="Check whether explanations are fresh.", parents=[scan_parent]
2527
+ )
2528
+ lookup_parser.add_argument(
2529
+ "--task-root", help="Large-task root; defaults to the current directory."
2530
+ )
2531
+ lookup_parser.add_argument(
2532
+ "--limit", type=int, default=30, help="Maximum result rows printed."
2533
+ )
2534
+ lookup_parser.add_argument(
2535
+ "paths", nargs="+", help="In-root files or directories to check."
2536
+ )
2537
+
2538
+ search_parser = subparsers.add_parser(
2539
+ "search", help="Search the current task's catalog.", parents=[json_parent]
2540
+ )
2541
+ search_parser.add_argument(
2542
+ "--task-root", help="Large-task root; defaults to the current directory."
2543
+ )
2544
+ search_parser.add_argument(
2545
+ "--limit", type=int, default=20, help="Maximum search results."
2546
+ )
2547
+ search_parser.add_argument("query", help="Path, name, format, field, or keyword.")
2548
+
2549
+ info_parser = subparsers.add_parser(
2550
+ "info", help="Summarize the task-local catalog.", parents=[json_parent]
2551
+ )
2552
+ info_parser.add_argument(
2553
+ "--task-root", help="Large-task root; defaults to the current directory."
2554
+ )
2555
+ info_parser.add_argument(
2556
+ "--limit", type=int, default=20, help="Maximum listed formats."
2557
+ )
2558
+ return parser
2559
+
2560
+
2561
+ def main(argv: Optional[Sequence[str]] = None) -> int:
2562
+ # Windows pipes inherit a locale-dependent encoding (often cp1252). Force
2563
+ # UTF-8 so paths and structural names remain portable across all terminals.
2564
+ for stream in (sys.stdout, sys.stderr):
2565
+ reconfigure = getattr(stream, "reconfigure", None)
2566
+ if reconfigure is not None:
2567
+ reconfigure(encoding="utf-8", errors="backslashreplace")
2568
+ parser = build_parser()
2569
+ args = parser.parse_args(argv)
2570
+ try:
2571
+ task_root = resolve_task_root(args.task_root)
2572
+ limit = max(1, min(int(args.limit), 500))
2573
+ excludes = [name for name in getattr(args, "exclude", "").split(",") if name.strip()]
2574
+ if args.command == "catalog":
2575
+ records = catalog_files(task_root, args.paths, excludes)
2576
+ elif args.command == "lookup":
2577
+ records = lookup_files(task_root, args.paths, excludes)
2578
+ elif args.command == "search":
2579
+ records = search_catalog(task_root, args.query, limit)
2580
+ else:
2581
+ records = catalog_info(task_root)
2582
+ print_results(records, limit, json_mode=args.json)
2583
+ return 0
2584
+ except (OSError, ValueError, sqlite3.Error) as error:
2585
+ print(f"error={type(error).__name__}: {error}", file=sys.stderr)
2586
+ return 2
2587
+
2588
+
2589
+ if __name__ == "__main__":
2590
+ raise SystemExit(main())