openmd-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. openmd/__init__.py +3 -0
  2. openmd/__main__.py +5 -0
  3. openmd/adapters/__init__.py +1 -0
  4. openmd/adapters/base.py +95 -0
  5. openmd/adapters/bilibili/__init__.py +11 -0
  6. openmd/adapters/bilibili/adapter.py +104 -0
  7. openmd/adapters/bilibili/discovery.py +33 -0
  8. openmd/adapters/douyin/__init__.py +11 -0
  9. openmd/adapters/douyin/adapter.py +117 -0
  10. openmd/adapters/douyin/discovery.py +36 -0
  11. openmd/adapters/douyin/sidecar.py +77 -0
  12. openmd/adapters/generic_media/__init__.py +14 -0
  13. openmd/adapters/high_privacy.py +77 -0
  14. openmd/adapters/local_audio/__init__.py +14 -0
  15. openmd/adapters/local_audio/adapter.py +169 -0
  16. openmd/adapters/local_audio/asr.py +58 -0
  17. openmd/adapters/local_audio/backends/__init__.py +1 -0
  18. openmd/adapters/local_audio/backends/auto.py +177 -0
  19. openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
  20. openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
  21. openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
  22. openmd/adapters/opencli.py +770 -0
  23. openmd/adapters/pdf/__init__.py +11 -0
  24. openmd/adapters/pdf/adapter.py +153 -0
  25. openmd/adapters/pdf/backends/__init__.py +1 -0
  26. openmd/adapters/pdf/backends/markitdown.py +165 -0
  27. openmd/adapters/pdf/parser.py +56 -0
  28. openmd/adapters/qq_group/__init__.py +11 -0
  29. openmd/adapters/qq_group/adapter.py +289 -0
  30. openmd/adapters/qq_group/parsers/__init__.py +1 -0
  31. openmd/adapters/qq_group/parsers/html.py +60 -0
  32. openmd/adapters/qq_group/parsers/json.py +71 -0
  33. openmd/adapters/registry.py +149 -0
  34. openmd/adapters/screenshot/__init__.py +11 -0
  35. openmd/adapters/screenshot/adapter.py +154 -0
  36. openmd/adapters/screenshot/backends/__init__.py +1 -0
  37. openmd/adapters/screenshot/backends/paddle.py +15 -0
  38. openmd/adapters/screenshot/backends/rapidocr.py +176 -0
  39. openmd/adapters/screenshot/backends/tesseract.py +15 -0
  40. openmd/adapters/screenshot/ocr.py +84 -0
  41. openmd/adapters/wechat_group/__init__.py +11 -0
  42. openmd/adapters/wechat_group/adapter.py +160 -0
  43. openmd/adapters/wechat_group/wx_cli.py +268 -0
  44. openmd/adapters/wechat_moments/__init__.py +11 -0
  45. openmd/adapters/wechat_moments/adapter.py +167 -0
  46. openmd/adapters/wechat_moments/wx_cli.py +265 -0
  47. openmd/adapters/weixin/__init__.py +11 -0
  48. openmd/adapters/weixin/adapter.py +148 -0
  49. openmd/adapters/weixin/discovery.py +510 -0
  50. openmd/adapters/weixin/report.py +278 -0
  51. openmd/adapters/xiaohongshu.py +158 -0
  52. openmd/backends/__init__.py +1 -0
  53. openmd/backends/base.py +204 -0
  54. openmd/backends/bilibili.py +1480 -0
  55. openmd/backends/bilibili_wbi.py +277 -0
  56. openmd/backends/discourse.py +426 -0
  57. openmd/backends/douyin.py +868 -0
  58. openmd/backends/douyin_detail.py +124 -0
  59. openmd/backends/douyin_providers.py +602 -0
  60. openmd/backends/executor.py +546 -0
  61. openmd/backends/feishu.py +861 -0
  62. openmd/backends/generic_media.py +77 -0
  63. openmd/backends/interactive.py +60 -0
  64. openmd/backends/ocr.py +41 -0
  65. openmd/backends/opencli.py +82 -0
  66. openmd/backends/pdf.py +122 -0
  67. openmd/backends/registry.py +234 -0
  68. openmd/backends/screenshot.py +79 -0
  69. openmd/backends/video_metadata.py +167 -0
  70. openmd/backends/web_article.py +363 -0
  71. openmd/backends/wechat_group.py +35 -0
  72. openmd/backends/wechat_moments.py +35 -0
  73. openmd/backends/weixin.py +767 -0
  74. openmd/backends/x.py +455 -0
  75. openmd/backends/xiaoe.py +940 -0
  76. openmd/backends/xiaohongshu.py +911 -0
  77. openmd/backends/xiaohongshu_providers.py +876 -0
  78. openmd/backends/xiaoyuzhou.py +1017 -0
  79. openmd/backends/youtube.py +280 -0
  80. openmd/backends/youtube_discovery.py +1245 -0
  81. openmd/backends/youtube_transcript.py +213 -0
  82. openmd/browser_extension/background.js +231 -0
  83. openmd/browser_extension/icons/icon-128.png +0 -0
  84. openmd/browser_extension/icons/icon-16.png +0 -0
  85. openmd/browser_extension/icons/icon-32.png +0 -0
  86. openmd/browser_extension/icons/icon-48.png +0 -0
  87. openmd/browser_extension/icons/icon-512.png +0 -0
  88. openmd/browser_extension/idle.html +6 -0
  89. openmd/browser_extension/manifest.json +43 -0
  90. openmd/browser_extension/options.html +42 -0
  91. openmd/browser_extension/options.js +117 -0
  92. openmd/browser_extension/popup.css +71 -0
  93. openmd/browser_extension/popup.html +17 -0
  94. openmd/browser_extension/popup.js +59 -0
  95. openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
  96. openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
  97. openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
  98. openmd/browser_host.py +503 -0
  99. openmd/browser_setup.py +106 -0
  100. openmd/cli.py +2012 -0
  101. openmd/console.py +32 -0
  102. openmd/core/__init__.py +1 -0
  103. openmd/core/capabilities.py +162 -0
  104. openmd/core/content_validation.py +323 -0
  105. openmd/core/coverage.py +163 -0
  106. openmd/core/detector.py +247 -0
  107. openmd/core/doctor.py +104 -0
  108. openmd/core/execution_policy.py +164 -0
  109. openmd/core/filename.py +64 -0
  110. openmd/core/input_validation.py +91 -0
  111. openmd/core/local_config.py +96 -0
  112. openmd/core/manifest.py +156 -0
  113. openmd/core/models.py +168 -0
  114. openmd/core/opencli_output.py +34 -0
  115. openmd/core/operation_contract.py +60 -0
  116. openmd/core/output.py +194 -0
  117. openmd/core/package_contract.py +335 -0
  118. openmd/core/package_validator.py +489 -0
  119. openmd/core/package_writer.py +666 -0
  120. openmd/core/planner.py +345 -0
  121. openmd/core/preferences.py +61 -0
  122. openmd/core/readable.py +129 -0
  123. openmd/core/receipt.py +285 -0
  124. openmd/core/renderer.py +185 -0
  125. openmd/core/runlog.py +208 -0
  126. openmd/core/secrets.py +313 -0
  127. openmd/doctor_view.py +68 -0
  128. openmd/interactive.py +453 -0
  129. openmd/ocr/__init__.py +1 -0
  130. openmd/ocr/breakers.py +84 -0
  131. openmd/ocr/config.py +54 -0
  132. openmd/ocr/documents.py +522 -0
  133. openmd/ocr/endpoint.py +72 -0
  134. openmd/ocr/layout.py +92 -0
  135. openmd/ocr/layout_filter.py +118 -0
  136. openmd/ocr/layout_worker.py +89 -0
  137. openmd/ocr/native_math.py +220 -0
  138. openmd/ocr/office.py +305 -0
  139. openmd/ocr/ollama.py +200 -0
  140. openmd/ocr/otsl.py +51 -0
  141. openmd/ocr/ovis-prompt.txt +1 -0
  142. openmd/ocr/pipeline.py +384 -0
  143. openmd/ocr/settings.json +25 -0
  144. openmd/ocr/tiling.py +631 -0
  145. openmd/post_video.py +154 -0
  146. openmd/probe.py +397 -0
  147. openmd/routing/__init__.py +1 -0
  148. openmd/routing/cards/builtin.json +620 -0
  149. openmd/routing/cards/defuddle.json +70 -0
  150. openmd/routing/cards/x-tweet-fetcher.json +71 -0
  151. openmd/routing/cards/xyz-dl.json +60 -0
  152. openmd/routing/cards/yutto.json +68 -0
  153. openmd/routing/entry_shim.py +71 -0
  154. openmd/routing/lifecycle.py +459 -0
  155. openmd/routing/loader.py +138 -0
  156. openmd/routing/providers.py +158 -0
  157. openmd/routing/readers.py +108 -0
  158. openmd/routing/routes.json +504 -0
  159. openmd/routing/runner.py +192 -0
  160. openmd/routing/view.py +40 -0
  161. openmd/runtime/__init__.py +1 -0
  162. openmd/runtime/_windows_job.py +234 -0
  163. openmd/runtime/asr.py +217 -0
  164. openmd/runtime/asr_worker.py +119 -0
  165. openmd/runtime/gpu_lock.py +81 -0
  166. openmd/runtime/gpu_quarantine.py +85 -0
  167. openmd/runtime/interpreters.py +407 -0
  168. openmd/runtime/managed_tools.py +204 -0
  169. openmd/runtime/models.py +202 -0
  170. openmd/runtime/services.py +175 -0
  171. openmd/runtime/tempdirs.py +227 -0
  172. openmd_cli-0.1.0.dist-info/METADATA +168 -0
  173. openmd_cli-0.1.0.dist-info/RECORD +177 -0
  174. openmd_cli-0.1.0.dist-info/WHEEL +5 -0
  175. openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
  176. openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  177. openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,489 @@
1
+ """Validator for Openmd V1 ``item.openmd/`` packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ import json
7
+ from pathlib import Path
8
+ import re
9
+ from typing import Any
10
+
11
+ from .package_contract import (
12
+ PACKAGE_PROFILES,
13
+ PACKAGE_SCHEMA_VERSION,
14
+ PACKAGE_SOURCE_LEVEL_VALUES,
15
+ PACKAGE_STATUS_VALUES,
16
+ PERMISSION_SCOPE_VALUES,
17
+ PROFILE_SECTIONS,
18
+ REQUIRED_METADATA_FIELDS,
19
+ REQUIRED_SOURCE_FIELDS,
20
+ )
21
+ from .models import COMPONENT_STATUS_VALUES, FAILURE_CLASS_VALUES
22
+ from .secrets import SENSITIVE_TEXT_PATTERNS, is_sensitive_key, is_sensitive_query_key
23
+
24
+
25
+ _SECRET_TEXT_RE = re.compile(
26
+ r"(?i)\b(access[_-]?token|refresh[_-]?token|authorization|set-cookie|bearer|"
27
+ r"xsec[_-]?token|session[_-]?key|api[_-]?key)\b"
28
+ )
29
+ _FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n", re.S)
30
+ _TEXT_SCAN_SUFFIXES = {".md", ".json", ".txt", ".yaml", ".yml", ".html", ".htm", ".har"}
31
+ _RAW_ARTIFACT_SUFFIXES = {
32
+ ".aac",
33
+ ".avi",
34
+ ".db",
35
+ ".dump",
36
+ ".flac",
37
+ ".har",
38
+ ".htm",
39
+ ".html",
40
+ ".m4a",
41
+ ".mkv",
42
+ ".mov",
43
+ ".mp3",
44
+ ".mp4",
45
+ ".sqlite",
46
+ ".sqlite3",
47
+ ".wav",
48
+ ".webm",
49
+ ".zip",
50
+ }
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class PackageValidationResult:
55
+ path: Path
56
+ errors: list[str] = field(default_factory=list)
57
+ warnings: list[str] = field(default_factory=list)
58
+
59
+ @property
60
+ def ok(self) -> bool:
61
+ return not self.errors
62
+
63
+
64
+ class PackageValidationError(ValueError):
65
+ pass
66
+
67
+
68
+ def validate_package(path: str | Path) -> PackageValidationResult:
69
+ package_path = Path(path)
70
+ errors: list[str] = []
71
+ warnings: list[str] = []
72
+
73
+ if package_path.suffix != ".openmd":
74
+ errors.append("package path must end with .openmd")
75
+ if not package_path.exists() or not package_path.is_dir():
76
+ errors.append("package path must exist and be a directory")
77
+ return PackageValidationResult(package_path, errors, warnings)
78
+
79
+ required_files = ("index.md", "metadata.json", "sources.json")
80
+ for filename in required_files:
81
+ if not (package_path / filename).is_file():
82
+ errors.append(f"missing required file: {filename}")
83
+ if not (package_path / "assets").is_dir():
84
+ errors.append("missing required assets/ directory")
85
+ if errors:
86
+ return PackageValidationResult(package_path, errors, warnings)
87
+
88
+ metadata = _read_json_object(package_path / "metadata.json", errors)
89
+ sources_doc = _read_json_object(package_path / "sources.json", errors)
90
+ index_text = _read_text(package_path / "index.md", errors)
91
+
92
+ if metadata is not None:
93
+ _validate_metadata(metadata, errors)
94
+ if sources_doc is not None:
95
+ _validate_sources(sources_doc, errors)
96
+ source_ids = _source_ids(sources_doc)
97
+ if metadata is not None and sources_doc is not None:
98
+ _validate_primary_source(sources_doc, errors)
99
+ if metadata is not None and index_text is not None:
100
+ _validate_index(index_text, metadata, errors)
101
+
102
+ _validate_assets(package_path, errors)
103
+ _validate_optional_sidecars(package_path, metadata, source_ids, errors, warnings)
104
+ _scan_for_secrets(package_path, errors)
105
+
106
+ return PackageValidationResult(package_path, errors, warnings)
107
+
108
+
109
+ def validate_package_or_raise(path: str | Path) -> PackageValidationResult:
110
+ result = validate_package(path)
111
+ if not result.ok:
112
+ raise PackageValidationError("; ".join(result.errors))
113
+ return result
114
+
115
+
116
+ def _read_json_object(path: Path, errors: list[str]) -> dict[str, Any] | None:
117
+ try:
118
+ value = json.loads(path.read_text(encoding="utf-8"))
119
+ except json.JSONDecodeError as exc:
120
+ errors.append(f"{path.name} is not valid JSON: {exc.msg}")
121
+ return None
122
+ except OSError as exc:
123
+ errors.append(f"{path.name} could not be read: {exc}")
124
+ return None
125
+ if not isinstance(value, dict):
126
+ errors.append(f"{path.name} must be a JSON object")
127
+ return None
128
+ return value
129
+
130
+
131
+ def _read_text(path: Path, errors: list[str]) -> str | None:
132
+ try:
133
+ return path.read_text(encoding="utf-8")
134
+ except OSError as exc:
135
+ errors.append(f"{path.name} could not be read: {exc}")
136
+ return None
137
+
138
+
139
+ def _validate_metadata(metadata: dict[str, Any], errors: list[str]) -> None:
140
+ _require_fields(metadata, REQUIRED_METADATA_FIELDS, "metadata.json", errors)
141
+ if metadata.get("package_schema_version") != PACKAGE_SCHEMA_VERSION:
142
+ errors.append("metadata.json package_schema_version must be 1.0-draft")
143
+ if metadata.get("profile") not in PACKAGE_PROFILES:
144
+ errors.append("metadata.json profile is not allowed")
145
+ if metadata.get("status") not in PACKAGE_STATUS_VALUES:
146
+ errors.append("metadata.json status is not allowed")
147
+ if metadata.get("source_level") not in PACKAGE_SOURCE_LEVEL_VALUES:
148
+ errors.append("metadata.json source_level is not allowed")
149
+ if not isinstance(metadata.get("modalities"), list):
150
+ errors.append("metadata.json modalities must be an array")
151
+ if not isinstance(metadata.get("warnings"), list):
152
+ errors.append("metadata.json warnings must be an array")
153
+ if not isinstance(metadata.get("backend"), dict):
154
+ errors.append("metadata.json backend must be an object")
155
+ _validate_extraction_components(metadata, errors)
156
+
157
+
158
+ def _validate_extraction_components(metadata: dict[str, Any], errors: list[str]) -> None:
159
+ version = metadata.get("extraction_contract_version")
160
+ components = metadata.get("components")
161
+ if version is None and components is None:
162
+ return
163
+ if not isinstance(version, str) or not version.strip():
164
+ errors.append("metadata.json extraction_contract_version must be a non-empty string")
165
+ if not isinstance(components, list) or not components:
166
+ errors.append("metadata.json components must be a non-empty array")
167
+ return
168
+ seen_ids: set[str] = set()
169
+ for index, component in enumerate(components):
170
+ prefix = f"metadata.json components[{index}]"
171
+ if not isinstance(component, dict):
172
+ errors.append(f"{prefix} must be an object")
173
+ continue
174
+ _require_fields(
175
+ component,
176
+ ("id", "kind", "status", "source_level", "backend", "text_ref", "warnings"),
177
+ prefix,
178
+ errors,
179
+ )
180
+ component_id = component.get("id")
181
+ if not isinstance(component_id, str) or not component_id.strip():
182
+ errors.append(f"{prefix}.id must be a non-empty string")
183
+ elif component_id in seen_ids:
184
+ errors.append(f"{prefix}.id must be unique")
185
+ else:
186
+ seen_ids.add(component_id)
187
+ if not isinstance(component.get("kind"), str) or not str(component.get("kind")).strip():
188
+ errors.append(f"{prefix}.kind must be a non-empty string")
189
+ if component.get("status") not in COMPONENT_STATUS_VALUES:
190
+ errors.append(f"{prefix}.status is not allowed")
191
+ if component.get("source_level") not in PACKAGE_SOURCE_LEVEL_VALUES:
192
+ errors.append(f"{prefix}.source_level is not allowed")
193
+ if not isinstance(component.get("backend"), str) or not str(component.get("backend")).strip():
194
+ errors.append(f"{prefix}.backend must be a non-empty string")
195
+ if not isinstance(component.get("text_ref"), str) or not str(component.get("text_ref")).strip():
196
+ errors.append(f"{prefix}.text_ref must be a non-empty string")
197
+ if not isinstance(component.get("warnings"), list):
198
+ errors.append(f"{prefix}.warnings must be an array")
199
+ failure_class = component.get("failure_class")
200
+ if failure_class is not None and failure_class not in FAILURE_CLASS_VALUES:
201
+ errors.append(f"{prefix}.failure_class is not allowed")
202
+ if component.get("status") == "unavailable":
203
+ for field_name in ("terminal_reason", "error_code", "failure_class"):
204
+ value = component.get(field_name)
205
+ if not isinstance(value, str) or not value.strip():
206
+ errors.append(
207
+ f"{prefix}.{field_name} is required for unavailable components"
208
+ )
209
+ if component.get("failure_class") != "source_unavailable":
210
+ errors.append(
211
+ f"{prefix}.failure_class must be source_unavailable for unavailable components"
212
+ )
213
+
214
+
215
+ def _validate_sources(sources_doc: dict[str, Any], errors: list[str]) -> None:
216
+ if sources_doc.get("package_schema_version") != PACKAGE_SCHEMA_VERSION:
217
+ errors.append("sources.json package_schema_version must be 1.0-draft")
218
+ sources = sources_doc.get("sources")
219
+ if not isinstance(sources, list) or not sources:
220
+ errors.append("sources.json sources must be a non-empty array")
221
+ return
222
+ for index, source in enumerate(sources):
223
+ if not isinstance(source, dict):
224
+ errors.append(f"sources.json sources[{index}] must be an object")
225
+ continue
226
+ _require_fields(source, REQUIRED_SOURCE_FIELDS, f"sources[{index}]", errors)
227
+ if source.get("permission_scope") not in PERMISSION_SCOPE_VALUES:
228
+ errors.append(f"sources.json sources[{index}].permission_scope is not allowed")
229
+ if not isinstance(source.get("adapter"), dict):
230
+ errors.append(f"sources.json sources[{index}].adapter must be an object")
231
+ if not isinstance(source.get("source_map"), list):
232
+ errors.append(f"sources.json sources[{index}].source_map must be an array")
233
+
234
+
235
+ def _validate_primary_source(sources_doc: dict[str, Any], errors: list[str]) -> None:
236
+ primary = sources_doc.get("primary_source_id")
237
+ source_ids = _source_ids(sources_doc)
238
+ if not primary:
239
+ errors.append("sources.json primary_source_id is required")
240
+ elif primary not in source_ids:
241
+ errors.append("sources.json primary_source_id must refer to sources[].source_id")
242
+
243
+
244
+ def _source_ids(sources_doc: dict[str, Any] | None) -> set[str]:
245
+ if not isinstance(sources_doc, dict):
246
+ return set()
247
+ sources = sources_doc.get("sources")
248
+ if not isinstance(sources, list):
249
+ return set()
250
+ return {
251
+ str(source.get("source_id"))
252
+ for source in sources
253
+ if isinstance(source, dict) and source.get("source_id")
254
+ }
255
+
256
+
257
+ def _validate_index(index_text: str, metadata: dict[str, Any], errors: list[str]) -> None:
258
+ frontmatter = _FRONTMATTER_RE.match(index_text)
259
+ if not frontmatter:
260
+ errors.append("index.md must start with YAML frontmatter")
261
+ else:
262
+ _validate_frontmatter(frontmatter.group(1), metadata, errors)
263
+ title = str(metadata.get("title") or "").strip()
264
+ if title and f"# {title}" not in index_text:
265
+ errors.append("index.md must contain # <Title>")
266
+ if "## Source" not in index_text:
267
+ errors.append("index.md must contain ## Source")
268
+ profile = str(metadata.get("profile") or "")
269
+ required_section = PROFILE_SECTIONS.get(profile)
270
+ if required_section and f"## {required_section}" not in index_text:
271
+ errors.append(f"index.md must contain ## {required_section}")
272
+ if metadata.get("status") in {"verified", "partial"}:
273
+ body = _FRONTMATTER_RE.sub("", index_text).strip()
274
+ if not body or "_No extracted content._" in body:
275
+ errors.append("index.md body must be non-empty for verified/partial packages")
276
+ if metadata.get("status") == "failed" and "failed" not in index_text.lower():
277
+ errors.append("failed packages must include a visible failure reason")
278
+
279
+
280
+ def _validate_frontmatter(frontmatter_text: str, metadata: dict[str, Any], errors: list[str]) -> None:
281
+ values: dict[str, str] = {}
282
+ for line in frontmatter_text.splitlines():
283
+ if not line.strip():
284
+ continue
285
+ if ":" not in line:
286
+ errors.append(f"index.md frontmatter line is not key/value: {line}")
287
+ continue
288
+ key, raw_value = line.split(":", 1)
289
+ key = key.strip()
290
+ if key in values:
291
+ errors.append(f"index.md frontmatter duplicate key: {key}")
292
+ continue
293
+ values[key] = _frontmatter_scalar_value(raw_value.strip())
294
+
295
+ expected = {
296
+ "title": str(metadata.get("title") or ""),
297
+ "profile": str(metadata.get("profile") or ""),
298
+ "source_platform": str(metadata.get("source_platform") or ""),
299
+ "status": str(metadata.get("status") or ""),
300
+ "source_level": str(metadata.get("source_level") or ""),
301
+ "created_at": str(metadata.get("created_at") or ""),
302
+ }
303
+ for key, expected_value in expected.items():
304
+ actual = values.get(key)
305
+ if actual is None:
306
+ errors.append(f"index.md frontmatter missing key: {key}")
307
+ elif actual != expected_value:
308
+ errors.append(f"index.md frontmatter {key} does not match metadata.json")
309
+
310
+
311
+ def _frontmatter_scalar_value(value: str) -> str:
312
+ if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
313
+ try:
314
+ parsed = json.loads(value)
315
+ except json.JSONDecodeError:
316
+ return value.strip('"')
317
+ return str(parsed)
318
+ return value
319
+
320
+
321
+ def _validate_assets(package_path: Path, errors: list[str]) -> None:
322
+ assets_path = package_path / "assets"
323
+ if not assets_path.exists():
324
+ return
325
+ for path in assets_path.rglob("*"):
326
+ if not path.is_file() or path.name == ".gitkeep":
327
+ continue
328
+ relative_path = path.relative_to(package_path)
329
+ suffix = path.suffix.lower()
330
+ if suffix in _RAW_ARTIFACT_SUFFIXES:
331
+ errors.append(f"raw/debug artifact is not allowed by default: {relative_path}")
332
+ if any(is_sensitive_key(part) or is_sensitive_query_key(part) for part in path.parts):
333
+ errors.append(f"credential-like asset path is not allowed: {relative_path}")
334
+
335
+
336
+ def _validate_optional_sidecars(
337
+ package_path: Path,
338
+ metadata: dict[str, Any] | None,
339
+ source_ids: set[str],
340
+ errors: list[str],
341
+ warnings: list[str],
342
+ ) -> None:
343
+ profile = str(metadata.get("profile") or "") if isinstance(metadata, dict) else ""
344
+ allowed_sidecars = _allowed_sidecars_for_profile(profile)
345
+ for path in package_path.iterdir():
346
+ if path.name in {"index.md", "metadata.json", "sources.json", "assets"}:
347
+ continue
348
+ if not path.is_file():
349
+ continue
350
+ if path.name not in allowed_sidecars:
351
+ errors.append(f"{path.name} is not allowed for profile: {profile or 'unknown'}")
352
+ continue
353
+ if path.suffix == ".json":
354
+ value = _read_json_object(path, errors)
355
+ if value is not None:
356
+ _validate_json_sidecar(path.name, value, source_ids, errors)
357
+ elif path.suffix == ".md" and path.name != "index.md":
358
+ text = _read_text(path, errors)
359
+ if text is not None and not text.strip():
360
+ warnings.append(f"{path.name} is empty")
361
+ else:
362
+ errors.append(f"unsupported sidecar file: {path.name}")
363
+
364
+
365
+ def _allowed_sidecars_for_profile(profile: str) -> set[str]:
366
+ return {
367
+ "article": set(),
368
+ "media": {"transcript.md", "timeline.json"},
369
+ "conversation": {"messages.json"},
370
+ "document": {"chunks.json"},
371
+ "process": {"transcript.md", "timeline.json", "steps.json"},
372
+ }.get(profile, set())
373
+
374
+
375
+ def _validate_json_sidecar(
376
+ name: str,
377
+ value: dict[str, Any],
378
+ source_ids: set[str],
379
+ errors: list[str],
380
+ ) -> None:
381
+ if name == "timeline.json":
382
+ _validate_sidecar_items(
383
+ name,
384
+ value.get("items"),
385
+ ("id", "start", "end", "text", "source_map"),
386
+ source_ids,
387
+ errors,
388
+ )
389
+ elif name == "messages.json":
390
+ _validate_sidecar_items(
391
+ name,
392
+ value.get("messages"),
393
+ ("id", "timestamp", "speaker", "text", "attachments", "links", "source_map"),
394
+ source_ids,
395
+ errors,
396
+ )
397
+ elif name == "steps.json":
398
+ _validate_sidecar_items(
399
+ name,
400
+ value.get("steps"),
401
+ ("id", "title", "text", "evidence_refs", "source_map"),
402
+ source_ids,
403
+ errors,
404
+ )
405
+ elif name == "chunks.json":
406
+ _validate_sidecar_items(
407
+ name,
408
+ value.get("chunks"),
409
+ ("id", "text", "source_map"),
410
+ source_ids,
411
+ errors,
412
+ )
413
+
414
+
415
+ def _validate_sidecar_items(
416
+ name: str,
417
+ items: Any,
418
+ required_fields: tuple[str, ...],
419
+ source_ids: set[str],
420
+ errors: list[str],
421
+ ) -> None:
422
+ if not isinstance(items, list) or not items:
423
+ errors.append(f"{name} must contain a non-empty array")
424
+ return
425
+ for index, item in enumerate(items):
426
+ label = f"{name}[{index}]"
427
+ if not isinstance(item, dict):
428
+ errors.append(f"{label} must be an object")
429
+ continue
430
+ _require_fields(item, required_fields, label, errors)
431
+ source_map = item.get("source_map")
432
+ if not isinstance(source_map, list):
433
+ errors.append(f"{label}.source_map must be an array")
434
+ continue
435
+ _validate_sidecar_source_map(name, index, source_map, source_ids, errors)
436
+
437
+
438
+ def _validate_sidecar_source_map(
439
+ name: str,
440
+ index: int,
441
+ source_map: list[Any],
442
+ source_ids: set[str],
443
+ errors: list[str],
444
+ ) -> None:
445
+ for map_index, entry in enumerate(source_map):
446
+ label = f"{name}[{index}].source_map[{map_index}]"
447
+ if not isinstance(entry, dict):
448
+ errors.append(f"{label} must be an object")
449
+ continue
450
+ source_ref = entry.get("source_ref")
451
+ if not isinstance(source_ref, dict):
452
+ errors.append(f"{label}.source_ref must be an object")
453
+ continue
454
+ source_id = source_ref.get("source_id")
455
+ if source_id and str(source_id) not in source_ids:
456
+ errors.append(f"{label}.source_ref.source_id must refer to sources[].source_id")
457
+
458
+
459
+ def _scan_for_secrets(package_path: Path, errors: list[str]) -> None:
460
+ for path in package_path.rglob("*"):
461
+ if not path.is_file():
462
+ continue
463
+ if path.name == ".gitkeep":
464
+ continue
465
+ if path.suffix.lower() not in _TEXT_SCAN_SUFFIXES:
466
+ continue
467
+ try:
468
+ text = path.read_text(encoding="utf-8", errors="ignore")
469
+ except OSError:
470
+ continue
471
+ if _SECRET_TEXT_RE.search(text) or any(pattern.search(text) for pattern in SENSITIVE_TEXT_PATTERNS):
472
+ errors.append(f"possible secret-bearing text in {path.relative_to(package_path)}")
473
+
474
+
475
+ def _require_fields(
476
+ value: dict[str, Any],
477
+ required_fields: tuple[str, ...],
478
+ label: str,
479
+ errors: list[str],
480
+ ) -> None:
481
+ for field_name in required_fields:
482
+ if field_name not in value:
483
+ errors.append(f"{label} missing required field: {field_name}")
484
+ continue
485
+ field_value = value[field_name]
486
+ if field_value is None:
487
+ errors.append(f"{label} field must not be null: {field_name}")
488
+ elif isinstance(field_value, str) and not field_value.strip():
489
+ errors.append(f"{label} field must not be empty: {field_name}")