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.
- package/LICENSE +21 -0
- package/README.md +611 -0
- package/cordis.patch.yml +9 -0
- package/install.ps1 +59 -0
- package/install.sh +58 -0
- package/package.json +40 -0
- package/skills/file-brief/SKILL.md +82 -0
- package/skills/file-brief/agents/openai.yaml +7 -0
- package/skills/file-brief/scripts/file_catalog.py +2590 -0
- package/skills/file-brief/scripts/inspect_r_data.R +229 -0
|
@@ -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())
|