xt-cli 0.2.1__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.
dependencies.py ADDED
@@ -0,0 +1,1109 @@
1
+ """工程版本追踪与管理。"""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import fnmatch
6
+ import json
7
+ import os as _os
8
+ import re
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import json5
15
+
16
+ from config import ConfigStore
17
+ from paths import build_global_config_path
18
+
19
+
20
+ def _get_git_version(repo_path: Path) -> tuple[str, bool]:
21
+ """获取 git 仓库的 HEAD commit sha(8 位)和 dirty 状态。
22
+
23
+ Returns:
24
+ (sha, is_dirty): sha 为 8 位短哈希,is_dirty 表示是否有未提交改动。
25
+ Raises:
26
+ subprocess.CalledProcessError: 非 git 仓库或 git 命令失败。
27
+ """
28
+ sha = subprocess.run(
29
+ ["git", "-C", str(repo_path), "rev-parse", "--short=8", "HEAD"],
30
+ check=True, capture_output=True, text=True, encoding="utf-8",
31
+ ).stdout.strip()
32
+ dirty_output = subprocess.run(
33
+ ["git", "-C", str(repo_path), "status", "--porcelain"],
34
+ check=True, capture_output=True, text=True, encoding="utf-8",
35
+ ).stdout.strip()
36
+ return sha, bool(dirty_output)
37
+
38
+
39
+ def _is_git_repo(path: Path) -> bool:
40
+ """检查目录是否是 git 仓库。"""
41
+ result = subprocess.run(
42
+ ["git", "-C", str(path), "rev-parse", "--git-dir"],
43
+ capture_output=True, text=True, encoding="utf-8",
44
+ )
45
+ return result.returncode == 0
46
+
47
+
48
+ def _check_ancestor(older: str, newer: str, repo_path: Path) -> bool:
49
+ """检查 older 是否是 newer 的祖先提交(通过 merge-base)。"""
50
+ result = subprocess.run(
51
+ ["git", "-C", str(repo_path), "merge-base", "--is-ancestor", older, newer],
52
+ capture_output=True, text=True, encoding="utf-8",
53
+ )
54
+ return result.returncode == 0
55
+
56
+
57
+ def _check_sha_constraint(
58
+ op: str, required: str, current: str, repo_path: Path
59
+ ) -> bool:
60
+ """检查当前 sha 是否满足单个约束。
61
+
62
+ 操作符映射:
63
+ - == : 精确匹配(前 8 位)
64
+ - != : 不等于
65
+ - >= : current 是 required 的后代(含 required 的改动)
66
+ - <= : current 是 required 的祖先
67
+ - > : >= 且 !=
68
+ - < : <= 且 !=
69
+ """
70
+ if op == "==":
71
+ return current == required[:len(current)]
72
+ if op == "!=":
73
+ return current != required[:len(current)]
74
+ if op == ">=":
75
+ return _check_ancestor(required, "HEAD", repo_path)
76
+ if op == "<=":
77
+ return _check_ancestor("HEAD", required, repo_path)
78
+ if op == ">":
79
+ return _check_ancestor(required, "HEAD", repo_path) and current != required[:len(current)]
80
+ if op == "<":
81
+ return _check_ancestor("HEAD", required, repo_path) and current != required[:len(current)]
82
+ return False
83
+
84
+
85
+ def _parse_semver(ver: str) -> tuple[int, int, int, str] | None:
86
+ """解析 semver 版本号。支持 v1.2.3, 1.2.3-alpha, v1.2.3-beta.1。"""
87
+ v = ver.strip()
88
+ if v.startswith("v"):
89
+ v = v[1:]
90
+ m = re.match(r'^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$', v)
91
+ if not m:
92
+ return None
93
+ return int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4) or ""
94
+
95
+
96
+ def _semver_cmp(a: tuple[int, int, int, str], b: tuple[int, int, int, str]) -> int:
97
+ """比较两个 semver,返回 -1/0/1。"""
98
+ # 数值部分
99
+ for i in range(3):
100
+ if a[i] < b[i]:
101
+ return -1
102
+ if a[i] > b[i]:
103
+ return 1
104
+ # prerelease 部分:有 prerelease < 无 prerelease
105
+ if a[3] == b[3]:
106
+ return 0
107
+ if not a[3]:
108
+ return 1
109
+ if not b[3]:
110
+ return -1
111
+ return -1 if a[3] < b[3] else (1 if a[3] > b[3] else 0)
112
+
113
+
114
+ def _check_semver_constraint(op: str, required_str: str, current_str: str) -> bool:
115
+ """检查当前 semver 是否满足约束。"""
116
+ required = _parse_semver(required_str)
117
+ current = _parse_semver(current_str)
118
+ if required is None or current is None:
119
+ return False
120
+
121
+ if op == "==":
122
+ return _semver_cmp(current, required) == 0
123
+ if op == "!=":
124
+ return _semver_cmp(current, required) != 0
125
+ if op == ">":
126
+ return _semver_cmp(current, required) > 0
127
+ if op == ">=":
128
+ return _semver_cmp(current, required) >= 0
129
+ if op == "<":
130
+ return _semver_cmp(current, required) < 0
131
+ if op == "<=":
132
+ return _semver_cmp(current, required) <= 0
133
+ if op == "~":
134
+ upper = (required[0], required[1] + 1, 0, "")
135
+ return _semver_cmp(current, required) >= 0 and _semver_cmp(current, upper) < 0
136
+ if op == "^":
137
+ if required[0] == 0:
138
+ return _check_semver_constraint("~", required_str, current_str)
139
+ upper = (required[0] + 1, 0, 0, "")
140
+ return _semver_cmp(current, required) >= 0 and _semver_cmp(current, upper) < 0
141
+ return False
142
+
143
+
144
+ def _get_semver_version(repo_path: Path) -> str | None:
145
+ """从 git tag 获取当前 semver 版本。"""
146
+ try:
147
+ tags = subprocess.run(
148
+ ["git", "-C", str(repo_path), "tag", "--points-at", "HEAD"],
149
+ check=True, capture_output=True, text=True, encoding="utf-8",
150
+ ).stdout.strip().splitlines()
151
+ tags = [t.strip() for t in tags if t.strip()]
152
+ if tags:
153
+ best = None
154
+ best_parsed = None
155
+ for t in tags:
156
+ p = _parse_semver(t)
157
+ if p and (best_parsed is None or _semver_cmp(p, best_parsed) > 0):
158
+ best = t
159
+ best_parsed = p
160
+ if best:
161
+ s = best
162
+ return s[1:] if s.startswith("v") else s
163
+ except subprocess.CalledProcessError:
164
+ pass
165
+
166
+ try:
167
+ desc = subprocess.run(
168
+ ["git", "-C", str(repo_path), "describe", "--tags", "--abbrev=0"],
169
+ check=True, capture_output=True, text=True, encoding="utf-8",
170
+ ).stdout.strip()
171
+ p = _parse_semver(desc)
172
+ if p:
173
+ s = desc
174
+ return s[1:] if s.startswith("v") else s
175
+ except subprocess.CalledProcessError:
176
+ pass
177
+
178
+ return None
179
+
180
+
181
+ def _load_jsonc(path: Path) -> dict[str, Any] | None:
182
+ """加载 JSONC 文件,不存在返回 None。"""
183
+ if not path.is_file():
184
+ return None
185
+ try:
186
+ raw = path.read_text(encoding="utf-8")
187
+ data = json5.loads(raw)
188
+ except (ValueError, json.JSONDecodeError):
189
+ return None
190
+ if not isinstance(data, dict):
191
+ return None
192
+ return data
193
+
194
+
195
+ def _save_jsonc(path: Path, data: dict[str, Any]) -> bool:
196
+ """保存 JSONC 文件(机器生成用 JSON,无注释)。
197
+
198
+ 与现有内容比较,无变化不写。返回 True 表示已写入。
199
+ """
200
+ new_content = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
201
+ if path.is_file() and path.read_text(encoding="utf-8") == new_content:
202
+ return False
203
+ path.parent.mkdir(parents=True, exist_ok=True)
204
+ path.write_text(new_content, encoding="utf-8")
205
+ return True
206
+
207
+
208
+ def _identify_val_type(val: str) -> str | None:
209
+ """识别 val 的类型。
210
+
211
+ Returns:
212
+ "git" | "semver" | None
213
+ """
214
+ if re.fullmatch(r'[0-9a-fA-F]{7,40}', val):
215
+ return "git"
216
+ if re.search(r'\d', val) and '.' in val:
217
+ return "semver"
218
+ return None
219
+
220
+
221
+ def _parse_require(val: str) -> list[tuple[str, str]]:
222
+ """解析 require 字符串为 (操作符, 版本) 列表。
223
+
224
+ "b8d82d2" → [("==", "b8d82d2")]
225
+ ">= c9e1234, != b8d82d2" → [(">=", "c9e1234"), ("!=", "b8d82d2")]
226
+ """
227
+ constraints: list[tuple[str, str]] = []
228
+ for part in val.split(','):
229
+ part = part.strip()
230
+ if not part:
231
+ continue
232
+ match = re.match(r'^(>=|<=|==|!=|~|>|<)?\s*(.+)$', part)
233
+ if match:
234
+ op = match.group(1) or "=="
235
+ version = match.group(2).strip()
236
+ constraints.append((op, version))
237
+ return constraints
238
+
239
+
240
+
241
+ def _print_warning(title: str, body: list[str], hint: str = "") -> None:
242
+ """输出 ANSI 黄色警告框。
243
+
244
+ body 行缩进两格,hint 灰色显示在末尾。
245
+ 格式(设计文档 §6.2):
246
+ ────────────────────────── WARNING ──────────────────────────
247
+ title
248
+ body_line_1
249
+ body_line_2
250
+ hint
251
+ ────────────────────────────────────────────────────────────
252
+ """
253
+ YELLOW = "\033[33m"
254
+ BOLD_YELLOW = "\033[1;33m"
255
+ GRAY = "\033[90m"
256
+ RESET = "\033[0m"
257
+ SEP = "─" * 60
258
+
259
+ print(f"{YELLOW}{SEP[:26]} {BOLD_YELLOW}WARNING{RESET}{YELLOW} {SEP[:26]}{RESET}")
260
+ print(f"{YELLOW}{title}{RESET}")
261
+ for line in body:
262
+ print(f"{YELLOW} {line}{RESET}")
263
+ if hint:
264
+ print(f"{GRAY}{hint}{RESET}")
265
+ print(f"{YELLOW}{SEP}{RESET}")
266
+
267
+
268
+ def _print_error(msg: str) -> None:
269
+ """输出错误信息到 stderr。"""
270
+ print(f"\033[31mError: {msg}\033[0m", file=sys.stderr)
271
+
272
+
273
+ def _print_info(msg: str) -> None:
274
+ """输出信息到 stdout。"""
275
+ print(f"Info: {msg}")
276
+
277
+
278
+ def _print_warnings_box(warnings: list[tuple[str, list[str]]]) -> None:
279
+ """输出所有警告到一个 ANSI 黄色警告框中。
280
+
281
+ 格式:头尾各一条分隔线,中间每条警告标题 + 缩进 body。
282
+ """
283
+ YELLOW = "\033[33m"
284
+ BOLD_YELLOW = "\033[1;33m"
285
+ RESET = "\033[0m"
286
+ SEP = "─" * 60
287
+
288
+ print(f"{YELLOW}{SEP[:26]} {BOLD_YELLOW}WARNING{RESET}{YELLOW} {SEP[:26]}{RESET}")
289
+ for i, (title, body) in enumerate(warnings):
290
+ if i > 0:
291
+ print() # 条目间空行
292
+ print(f"{YELLOW}{title}{RESET}")
293
+ for line in body:
294
+ print(f"{YELLOW} {line}{RESET}")
295
+ print(f"{YELLOW}{SEP}{RESET}")
296
+
297
+
298
+ def _relative_path(target: Path, base: Path) -> str:
299
+ """计算相对路径。
300
+
301
+ 同级或子目录 → ./相对路径
302
+ 父目录 → ../相对路径
303
+ 跨盘符或无法相对化 → 返回原绝对路径
304
+ """
305
+ try:
306
+ rel = target.resolve().relative_to(base.resolve())
307
+ return "./" + rel.as_posix()
308
+ except ValueError:
309
+ try:
310
+ return _os.path.relpath(target, base).replace('\\', '/')
311
+ except ValueError:
312
+ return target.as_posix()
313
+
314
+
315
+ # ============================================================
316
+ # Task 6: path resolver, on_fail resolver, targets resolver
317
+ # ============================================================
318
+
319
+
320
+ def _resolve_repo_path(name: str, deps_entry: dict, context) -> Path | None:
321
+ """解析仓库路径。
322
+
323
+ 优先级:xt_deps 中的 path 字段 > 默认路径。
324
+ xt-sdk → context.sdk_dir
325
+ platforms.<name> → context.sdk_dir / "platforms" / <name>
326
+ """
327
+ if name == "xt-sdk":
328
+ return context.sdk_dir
329
+ custom_path = deps_entry.get("path")
330
+ if custom_path:
331
+ return context.project_dir / custom_path
332
+ default_path = context.sdk_dir / "platforms" / name
333
+ if default_path.is_dir():
334
+ return default_path
335
+ return None
336
+
337
+
338
+ def _resolve_on_fail(deps_entry: dict, scope: str, context) -> str:
339
+ """解析 on_fail 值。
340
+
341
+ 优先级:条目显式值 > xt_deps.jsonc deps 段 > local config > global config > 默认 "warn"。
342
+ scope: "version" 读 deps.on_fail, "target" 读 deps.targets.on_fail。
343
+ """
344
+ if scope == "version":
345
+ explicit = deps_entry.get("on_fail")
346
+ else:
347
+ targets = deps_entry.get("targets")
348
+ explicit = targets.get("on_fail") if isinstance(targets, dict) else None
349
+
350
+ if explicit in ("warn", "error", "ignore"):
351
+ return explicit
352
+
353
+ config_key = f"on_{scope}_fail"
354
+
355
+ # xt_deps.jsonc deps_configs 段
356
+ xt_deps = _load_jsonc(context.project_dir / "xt_deps.jsonc")
357
+ if xt_deps:
358
+ deps_block = xt_deps.get("deps_configs")
359
+ if isinstance(deps_block, dict):
360
+ value = deps_block.get(config_key)
361
+ if value in ("warn", "error", "ignore"):
362
+ return value
363
+
364
+ # local config
365
+ local_path = context.project_dir / "xt_conf.jsonc"
366
+ if local_path.is_file():
367
+ local_data = ConfigStore(local_path).load()
368
+ deps = local_data.get("deps_configs")
369
+ if isinstance(deps, dict):
370
+ value = deps.get(config_key)
371
+ if value in ("warn", "error", "ignore"):
372
+ return value
373
+
374
+ # global config
375
+ global_path = build_global_config_path()
376
+ if global_path.is_file():
377
+ global_data = ConfigStore(global_path).load()
378
+ deps = global_data.get("deps_configs")
379
+ if isinstance(deps, dict):
380
+ value = deps.get(config_key)
381
+ if value in ("warn", "error", "ignore"):
382
+ return value
383
+
384
+ return "warn"
385
+
386
+
387
+ def _resolve_targets_constraint(deps_entry: dict) -> list[str] | None:
388
+ """从 deps 条目中提取 targets 约束列表。
389
+
390
+ 支持简写(数组)和完整对象两种格式。
391
+ 返回 None 表示无约束。
392
+ """
393
+ targets = deps_entry.get("targets")
394
+ if targets is None:
395
+ return None
396
+ if isinstance(targets, list):
397
+ return targets
398
+ if isinstance(targets, dict):
399
+ require = targets.get("require")
400
+ if isinstance(require, list):
401
+ return require
402
+ return None
403
+
404
+
405
+ def sync_dependencies(context, dirty_allowed: bool = False) -> None:
406
+ """根据 xt_deps.jsonc 将各仓库 checkout 到要求的版本。
407
+
408
+ 在 check_dependencies 之前调用。dirty 仓库默认中止(--dirty 允许)。
409
+ """
410
+ xt_deps = _load_jsonc(context.project_dir / "xt_deps.jsonc")
411
+ if xt_deps is None:
412
+ return
413
+
414
+ print("[xt cli] Syncing dependencies...")
415
+
416
+ # xt-sdk
417
+ xt_sdk_entry = xt_deps.get("xt-sdk")
418
+ if isinstance(xt_sdk_entry, dict):
419
+ _sync_repo("xt-sdk", context.sdk_dir, xt_sdk_entry, dirty_allowed)
420
+
421
+ # platforms
422
+ platforms_deps = xt_deps.get("platforms", {})
423
+ if isinstance(platforms_deps, dict):
424
+ for name, entry in platforms_deps.items():
425
+ if not isinstance(entry, dict):
426
+ continue
427
+ repo_path = _resolve_repo_path(name, entry, context)
428
+ if repo_path and repo_path.is_dir():
429
+ _sync_repo(name, repo_path, entry, dirty_allowed)
430
+
431
+ # components
432
+ components_deps = xt_deps.get("components", {})
433
+ if isinstance(components_deps, dict):
434
+ for name, entry in components_deps.items():
435
+ if not isinstance(entry, dict):
436
+ continue
437
+ repo_path = _resolve_component_path(name, entry, context)
438
+ if repo_path and repo_path.is_dir():
439
+ _sync_repo(name, repo_path, entry, dirty_allowed)
440
+
441
+ print("[xt cli] Sync complete.")
442
+
443
+
444
+ def _sync_repo(name: str, repo_path, entry: dict, dirty_allowed: bool) -> None:
445
+ """同步单个仓库到 require 指定的版本。"""
446
+ require = entry.get("require")
447
+ if require is None:
448
+ return
449
+
450
+ # 检查 dirty
451
+ if (repo_path / ".git").exists():
452
+ try:
453
+ _, is_dirty = _get_git_version(repo_path)
454
+ if is_dirty and not dirty_allowed:
455
+ _print_error(f"{name}: repository is dirty, aborting sync. Use --dirty to allow.")
456
+ sys.exit(1)
457
+ except subprocess.CalledProcessError:
458
+ pass
459
+
460
+ # 确定目标版本
461
+ if isinstance(require, dict):
462
+ val_type = require.get("type")
463
+ target = require.get("val", "")
464
+ if val_type == "hash":
465
+ print(f" {name}: hash type, skip checkout")
466
+ return
467
+ else:
468
+ target = require # 字符串:可能是 sha 或 semver
469
+
470
+ # semver → 找 tag
471
+ if (repo_path / ".git").exists() and _identify_val_type(target) == "semver":
472
+ # 当前已经是目标版本 → 跳过
473
+ current_ver = _get_semver_version(repo_path)
474
+ if current_ver:
475
+ target_parsed = _parse_semver(target)
476
+ current_parsed = _parse_semver(current_ver)
477
+ if target_parsed and current_parsed and _semver_cmp(current_parsed, target_parsed) == 0:
478
+ print(f" {name}: no change (already at {current_ver})")
479
+ return
480
+ # 尝试 checkout 对应 tag
481
+ for prefix in ("", "v", "V"):
482
+ tag = prefix + target
483
+ try:
484
+ subprocess.run(
485
+ ["git", "-C", str(repo_path), "checkout", tag],
486
+ check=True, capture_output=True, text=True, encoding="utf-8",
487
+ )
488
+ print(f" {name}: checkout {tag}")
489
+ return
490
+ except subprocess.CalledProcessError:
491
+ continue
492
+ _print_error(f"{name}: cannot find tag for {target}")
493
+ sys.exit(1)
494
+
495
+ # git sha → checkout
496
+ if (repo_path / ".git").exists():
497
+ # 检查是否已经在目标版本
498
+ try:
499
+ current_sha = subprocess.run(
500
+ ["git", "-C", str(repo_path), "rev-parse", "--short=8", "HEAD"],
501
+ check=True, capture_output=True, text=True, encoding="utf-8",
502
+ ).stdout.strip()
503
+ if current_sha == target[:len(current_sha)]:
504
+ print(f" {name}: no change (already at {current_sha})")
505
+ return
506
+ except subprocess.CalledProcessError:
507
+ pass
508
+
509
+ subprocess.run(
510
+ ["git", "-C", str(repo_path), "checkout", target],
511
+ check=True, capture_output=True, text=True, encoding="utf-8",
512
+ )
513
+ print(f" {name}: checkout {target}")
514
+
515
+ # xt-sdk 额外同步 submodule
516
+ if name == "xt-sdk":
517
+ subprocess.run(
518
+ ["git", "-C", str(repo_path), "submodule", "update", "--init", "--recursive"],
519
+ check=True, capture_output=True, text=True, encoding="utf-8",
520
+ )
521
+ else:
522
+ print(f" {name}: not a git repo, skip checkout")
523
+
524
+
525
+ # ============================================================
526
+ # Task 6: check_dependencies 核心逻辑
527
+ # ============================================================
528
+
529
+
530
+ def _is_track_enabled(context) -> bool:
531
+ """检查是否允许写入 xt_vers(默认 false,需显式开启)。
532
+
533
+ 优先级:xt_deps.jsonc deps_configs.track > local config > global config > 默认 false。
534
+ """
535
+ # xt_deps.jsonc deps_configs 段
536
+ xt_deps = _load_jsonc(context.project_dir / "xt_deps.jsonc")
537
+ if xt_deps:
538
+ deps_block = xt_deps.get("deps_configs")
539
+ if isinstance(deps_block, dict) and "track" in deps_block:
540
+ val = deps_block["track"]
541
+ if isinstance(val, bool):
542
+ return val
543
+ if isinstance(val, str):
544
+ return val.lower() in ("true", "1")
545
+
546
+ local_path = context.project_dir / "xt_conf.jsonc"
547
+ if local_path.is_file():
548
+ local_data = ConfigStore(local_path).load()
549
+ deps = local_data.get("deps_configs")
550
+ if isinstance(deps, dict) and "track" in deps:
551
+ val = deps["track"]
552
+ if isinstance(val, bool):
553
+ return val
554
+ if isinstance(val, str):
555
+ return val.lower() in ("true", "1")
556
+ return False
557
+ global_path = build_global_config_path()
558
+ if global_path.is_file():
559
+ global_data = ConfigStore(global_path).load()
560
+ deps = global_data.get("deps_configs")
561
+ if isinstance(deps, dict) and "track" in deps:
562
+ val = deps["track"]
563
+ if isinstance(val, bool):
564
+ return val
565
+ if isinstance(val, str):
566
+ return val.lower() in ("true", "1")
567
+ return False
568
+ return False
569
+
570
+
571
+ def check_dependencies(context, dirty_allowed: bool = False, strict: bool = False) -> tuple[bool, list]:
572
+ """before build: 读取 xt_deps,检查约束和 commit。
573
+
574
+ Args:
575
+ context: BuildContext
576
+ dirty_allowed: --dirty CLI 标志,True 时脏仓库仅警告不中止。
577
+ strict: --strict CLI 标志,True 时将 warn 升级为 error。
578
+
579
+ Returns:
580
+ (passed, warnings): passed=False 时调用方应中止编译。
581
+ warnings 为 [(title, [body_lines])] 列表,由调用方在合适时机输出。
582
+ """
583
+ xt_deps = _load_jsonc(context.project_dir / "xt_deps.jsonc")
584
+ if xt_deps is None:
585
+ return True, []
586
+
587
+ # --strict 时检查工程自身仓库是否干净
588
+ if strict and not dirty_allowed:
589
+ project_dir = context.project_dir
590
+ if (project_dir / ".git").exists():
591
+ try:
592
+ _, is_dirty = _get_git_version(project_dir)
593
+ if is_dirty:
594
+ _print_error(
595
+ "Project repository is dirty.\n"
596
+ f" Path: {project_dir}\n"
597
+ " Use --dirty to allow."
598
+ )
599
+ return False, []
600
+ except subprocess.CalledProcessError:
601
+ pass
602
+
603
+ xt_vers = _load_jsonc(context.project_dir / "xt_vers.jsonc")
604
+ release = xt_deps.get("release", False)
605
+ all_pass = True
606
+ warnings: list[tuple[str, list[str]]] = []
607
+
608
+ # 检查 xt-sdk
609
+ xt_sdk_entry = xt_deps.get("xt-sdk")
610
+ if isinstance(xt_sdk_entry, dict):
611
+ if not _check_entry(
612
+ "xt-sdk",
613
+ context.sdk_dir,
614
+ xt_sdk_entry,
615
+ xt_vers.get("xt-sdk") if xt_vers else None,
616
+ release=release,
617
+ dirty_allowed=dirty_allowed,
618
+ context=context,
619
+ check_targets=False,
620
+ warnings=warnings,
621
+ strict=strict,
622
+ ):
623
+ all_pass = False
624
+
625
+ # 检查当前平台
626
+ platforms_deps = xt_deps.get("platforms", {})
627
+ if isinstance(platforms_deps, dict):
628
+ platform_name = context.platform_name
629
+ entry = platforms_deps.get(platform_name)
630
+ if isinstance(entry, dict):
631
+ repo_path = _resolve_repo_path(platform_name, entry, context)
632
+ if repo_path is None:
633
+ _print_error(f"{platform_name}: repository not found.")
634
+ all_pass = False
635
+ elif not repo_path.is_dir():
636
+ _print_error(f"{platform_name}: repository not found at '{repo_path}'")
637
+ all_pass = False
638
+ else:
639
+ platforms_vers = xt_vers.get("platforms", {}) if xt_vers else {}
640
+ if not _check_entry(
641
+ platform_name,
642
+ repo_path,
643
+ entry,
644
+ platforms_vers.get(platform_name),
645
+ release=release,
646
+ dirty_allowed=dirty_allowed,
647
+ context=context,
648
+ check_targets=True,
649
+ warnings=warnings,
650
+ strict=strict,
651
+ ):
652
+ all_pass = False
653
+
654
+ # 检查 components
655
+ components_deps = xt_deps.get("components", {})
656
+ if isinstance(components_deps, dict):
657
+ components_vers = xt_vers.get("components", {}) if xt_vers else {}
658
+ for name, entry in components_deps.items():
659
+ if not isinstance(entry, dict):
660
+ continue
661
+ repo_path = _resolve_component_path(name, entry, context)
662
+ if repo_path is None:
663
+ _print_error(f"{name}: component path not found")
664
+ all_pass = False
665
+ continue
666
+ if not repo_path.is_dir():
667
+ _print_error(f"{name}: component path does not exist at '{repo_path}'")
668
+ all_pass = False
669
+ continue
670
+ if not _check_entry(
671
+ name, repo_path, entry,
672
+ components_vers.get(name),
673
+ release=release, dirty_allowed=dirty_allowed,
674
+ context=context, check_targets=False,
675
+ warnings=warnings, strict=strict,
676
+ ):
677
+ all_pass = False
678
+
679
+ return all_pass, warnings
680
+
681
+
682
+ def _resolve_component_path(name: str, deps_entry: dict, context) -> Path | None:
683
+ """解析组件路径。默认:<project_dir>/components/<name>。"""
684
+ custom_path = deps_entry.get("path")
685
+ if custom_path:
686
+ return context.project_dir / custom_path
687
+ return context.project_dir / "components" / name
688
+
689
+
690
+ def _check_entry(
691
+ name: str,
692
+ repo_path: Path,
693
+ deps_entry: dict,
694
+ vers_entry: dict | str | None,
695
+ *,
696
+ release: bool,
697
+ dirty_allowed: bool,
698
+ context,
699
+ check_targets: bool,
700
+ warnings: list[tuple[str, list[str]]] | None = None,
701
+ strict: bool = False,
702
+ ) -> bool:
703
+ """检查单个依赖条目。返回 True=通过。"""
704
+ require = deps_entry.get("require")
705
+ has_require = require is not None
706
+
707
+ # 判断 require 是否为 semver 约束
708
+ require_is_semver = False
709
+ if isinstance(require, str):
710
+ require_is_semver = _identify_val_type(require) == "semver"
711
+ elif isinstance(require, dict) and require.get("type") == "semver":
712
+ require_is_semver = True
713
+
714
+ # 获取当前版本
715
+ if require_is_semver:
716
+ current_ver = _get_semver_version(repo_path)
717
+ if current_ver is None:
718
+ _print_error(f"{name}: no semver tag found for '{repo_path}'")
719
+ return False
720
+ current_sha = current_ver
721
+ is_dirty = False
722
+ version_str = current_ver
723
+ elif isinstance(require, dict) and require.get("type") == "hash":
724
+ current_sha = hash_dir(repo_path)
725
+ is_dirty = False
726
+ version_str = current_sha
727
+ elif not (repo_path / ".git").exists():
728
+ current_sha = hash_dir(repo_path)
729
+ is_dirty = False
730
+ version_str = current_sha
731
+ else:
732
+ try:
733
+ current_sha, is_dirty = _get_git_version(repo_path)
734
+ except subprocess.CalledProcessError:
735
+ _print_error(f"{name}: unable to get git version from '{repo_path}'")
736
+ return False
737
+ version_str = f"{current_sha}{'-dirty' if is_dirty else ''}"
738
+
739
+ # 首次追踪
740
+ if vers_entry is None:
741
+ _print_info(f"{name}: first time tracking (commit: {version_str})")
742
+ return True
743
+
744
+ recorded_version = _extract_version(vers_entry)
745
+
746
+ if has_require:
747
+ # 有 require → 约束检查
748
+ if not _check_version_require(require, current_sha, repo_path):
749
+ on_fail = _resolve_on_fail(deps_entry, "version", context)
750
+ if strict and on_fail != "error":
751
+ on_fail = "error"
752
+ if on_fail == "error":
753
+ _print_error(
754
+ f"{name}: version constraint not satisfied.\n"
755
+ f" Required: {require}\n"
756
+ f" Current: {current_sha}"
757
+ )
758
+ return False
759
+ if on_fail == "warn":
760
+ if warnings is not None:
761
+ warnings.append((f"{name}: version constraint not satisfied.", [f"Required: {require}", f"Current: {current_sha}"]))
762
+ # 继续检查 dirty 和 targets(release mode 下 dirty 仍可中止)
763
+ else:
764
+ # 无 require → 纯追踪模式:对比 xt_vers
765
+ if recorded_version != version_str:
766
+ if warnings is not None:
767
+ warnings.append((f"{name}: commit changed since last recorded build.", [f"Recorded: {recorded_version}", f"Current: {version_str}"]))
768
+
769
+ # dirty 检查
770
+ if is_dirty:
771
+ should_abort = (release or strict) and not dirty_allowed
772
+ if should_abort:
773
+ _print_error(
774
+ f"{name}: repository is dirty.\n"
775
+ f" Current: {version_str}\n"
776
+ f" Use --dirty to allow."
777
+ )
778
+ return False
779
+ if warnings is not None:
780
+ warnings.append((f"{name}: repository is dirty.", [f"Current: {version_str}"]))
781
+
782
+ # targets 检查
783
+ if check_targets:
784
+ targets_constraint = _resolve_targets_constraint(deps_entry)
785
+ if targets_constraint is not None:
786
+ _, target_name = _split_target(context.target)
787
+ if not _check_target_match(target_name, targets_constraint):
788
+ on_fail = _resolve_on_fail(deps_entry, "target", context)
789
+ if strict and on_fail != "error":
790
+ on_fail = "error"
791
+ msg = (
792
+ f"{name}: target '{target_name}' not in constraint.\n"
793
+ f" Allowed: {targets_constraint}"
794
+ )
795
+ if on_fail == "error":
796
+ _print_error(msg)
797
+ return False
798
+ if on_fail == "warn":
799
+ if warnings is not None:
800
+ warnings.append((msg.split("\n")[0], msg.split("\n")[1:]))
801
+ return True
802
+
803
+ return True
804
+
805
+
806
+ def _check_version_require(require_val, current_sha: str, repo_path: Path) -> bool:
807
+ """检查当前版本是否满足 require 约束。全部满足才通过。"""
808
+ if isinstance(require_val, dict):
809
+ val_type = require_val.get("type")
810
+ if val_type == "hash":
811
+ current_hash = hash_dir(repo_path)
812
+ return current_hash == require_val.get("val", "")
813
+ if val_type == "git":
814
+ if not (repo_path / ".git").exists():
815
+ _print_error(f"require type is git but '{repo_path}' is not a git repository")
816
+ return False
817
+ return current_sha == require_val.get("val", "")[:len(current_sha)]
818
+ return True # 未知 type 值,跳过
819
+ if not isinstance(require_val, str):
820
+ _print_warning(
821
+ "require: unexpected type, skipping.",
822
+ [f"Type: {type(require_val).__name__}, Value: {require_val}"],
823
+ )
824
+ return True
825
+ # 字符串 require:自动检测类型
826
+ if not (repo_path / ".git").exists():
827
+ return hash_dir(repo_path) == require_val
828
+ constraints = _parse_require(require_val)
829
+ for op, version in constraints:
830
+ val_type = _identify_val_type(version)
831
+ if val_type == "git":
832
+ if not _check_sha_constraint(op, version, current_sha, repo_path):
833
+ return False
834
+ elif val_type == "semver":
835
+ if not _check_semver_constraint(op, version, current_sha):
836
+ return False
837
+ return True
838
+
839
+
840
+ def _split_target(target: str) -> tuple[str, str]:
841
+ """从 target 字符串中分离平台名和目标名。
842
+
843
+ "lm620/r4f4" → ("lm620", "r4f4")
844
+ "windows/simulator" → ("windows", "simulator")
845
+ """
846
+ parts = target.split("/", 1)
847
+ if len(parts) == 2:
848
+ return parts[0], parts[1]
849
+ return parts[0], ""
850
+
851
+
852
+ def _check_target_match(target_name: str, constraints: list[str]) -> bool:
853
+ """检查 target 是否满足约束。
854
+
855
+ constraints 如 ["r4f4", "!= r4f2"]:
856
+ - 普通字符串表示允许
857
+ - != 前缀表示排除
858
+ 有任意允许项且无排除项 → True。
859
+ """
860
+ allowed: list[str] = []
861
+ excluded: list[str] = []
862
+ for c in constraints:
863
+ c = c.strip()
864
+ if c.startswith("!="):
865
+ excluded.append(c[2:].strip())
866
+ else:
867
+ allowed.append(c)
868
+ if excluded and target_name in excluded:
869
+ return False
870
+ if allowed:
871
+ return target_name in allowed
872
+ return True
873
+
874
+
875
+ # ============================================================
876
+ # Task 7: update_dependencies 核心逻辑
877
+ # ============================================================
878
+
879
+
880
+ def update_dependencies(context) -> None:
881
+ """after build: 收集实际版本,写入 xt_vers.jsonc。
882
+
883
+ 编译成功后调用,在 after_build hook 之后。
884
+ """
885
+ xt_vers = _load_jsonc(context.project_dir / "xt_vers.jsonc")
886
+ if xt_vers is None:
887
+ xt_vers = {"config_version": 1}
888
+
889
+ # 更新 xt-sdk
890
+ xt_vers["xt-sdk"] = {"version": _get_entry_version(context.sdk_dir)}
891
+
892
+ # 更新当前平台
893
+ platform_name = context.platform_name
894
+ if "platforms" not in xt_vers:
895
+ xt_vers["platforms"] = {}
896
+
897
+ xt_deps = _load_jsonc(context.project_dir / "xt_deps.jsonc")
898
+ platforms_deps = xt_deps.get("platforms", {}) if xt_deps else {}
899
+ platform_entry_deps = platforms_deps.get(platform_name, {})
900
+ repo_path = _resolve_repo_path(platform_name, platform_entry_deps, context)
901
+
902
+ if repo_path:
903
+ version_str = _get_entry_version(repo_path)
904
+ else:
905
+ version_str = xt_vers["xt-sdk"]["version"]
906
+
907
+ # 追加 target(编译成功才更新,防止重复)
908
+ _, target_name = _split_target(context.target)
909
+ existing_entry = xt_vers["platforms"].get(platform_name)
910
+ existing_targets = _extract_targets(existing_entry)
911
+
912
+ if target_name and target_name not in existing_targets:
913
+ existing_targets.append(target_name)
914
+
915
+ platform_data: dict[str, Any] = {"version": version_str}
916
+ if existing_targets:
917
+ platform_data["targets"] = existing_targets
918
+ xt_vers["platforms"][platform_name] = platform_data
919
+
920
+ # 更新 components(写入 xt_vers)
921
+ components_deps = xt_deps.get("components", {}) if xt_deps else {}
922
+ if isinstance(components_deps, dict) and components_deps:
923
+ if "components" not in xt_vers:
924
+ xt_vers["components"] = {}
925
+ for name, entry in components_deps.items():
926
+ if not isinstance(entry, dict):
927
+ continue
928
+ repo_path = _resolve_component_path(name, entry, context)
929
+ if repo_path and repo_path.is_dir():
930
+ xt_vers["components"][name] = _get_version_entry(repo_path, entry)
931
+
932
+ _save_jsonc(context.project_dir / "xt_vers.jsonc", xt_vers)
933
+
934
+
935
+ def _get_version_entry(repo_path: Path, deps_entry: dict | None = None) -> dict[str, Any]:
936
+ """获取条目版本对象。
937
+
938
+ 返回值始终为 {"version": "..."},非默认 git 类型追加 "source"。
939
+ """
940
+ require = deps_entry.get("require") if deps_entry else None
941
+ is_hash = isinstance(require, dict) and require.get("type") == "hash"
942
+ val_type = "hash" if is_hash else None
943
+ version = _get_entry_version(repo_path, val_type)
944
+ entry: dict[str, Any] = {"version": version}
945
+ if is_hash:
946
+ entry["source"] = {"type": "hash"}
947
+ return entry
948
+
949
+
950
+ def hash_dir(dir_path: str | Path) -> str:
951
+ """计算目录内容 SHA-256,返回前 8 位十六进制。
952
+
953
+ 算法:遍历目录 → 排序文件 → 文件名哈希 → 内容分块 SHA-256。
954
+ 跳过 .git 目录,自动读取各级 .gitignore 过滤文件。
955
+ """
956
+ dir_path = Path(dir_path)
957
+ root_rules = _load_gitignore(dir_path)
958
+ sha = hashlib.sha256()
959
+ for root, dirs, files in _os.walk(dir_path):
960
+ if '.git' in dirs:
961
+ dirs.remove('.git')
962
+ # 子目录的 .gitignore 规则(相对当前 root)
963
+ dirs.sort()
964
+ sub_rules = root_rules + _load_gitignore(Path(root))
965
+ filtered_dirs = []
966
+ for d in dirs:
967
+ rel = _os.path.relpath(_os.path.join(root, d), dir_path).replace('\\', '/')
968
+ if not _match_gitignore(rel + '/', sub_rules):
969
+ filtered_dirs.append(d)
970
+ dirs[:] = filtered_dirs
971
+ for filename in sorted(files):
972
+ filepath = _os.path.join(root, filename)
973
+ relpath = _os.path.relpath(filepath, dir_path).replace('\\', '/')
974
+ # 跳过 .gitignore 自身
975
+ if filename == ".gitignore":
976
+ sha.update(relpath.encode('utf-8'))
977
+ continue
978
+ if _match_gitignore(relpath, sub_rules):
979
+ continue
980
+ sha.update(relpath.encode('utf-8'))
981
+ with open(filepath, 'rb') as f:
982
+ while True:
983
+ block = f.read(65536)
984
+ if not block:
985
+ break
986
+ sha.update(block)
987
+ return sha.hexdigest()[:8]
988
+
989
+
990
+ def _load_gitignore(dir_path: Path) -> list[tuple[str, bool]]:
991
+ """读取 .gitignore,返回 [(pattern, is_negate)] 规则列表。"""
992
+ gitignore = dir_path / ".gitignore"
993
+ if not gitignore.is_file():
994
+ return []
995
+ rules: list[tuple[str, bool]] = []
996
+ for line in gitignore.read_text(encoding="utf-8").splitlines():
997
+ line = line.strip()
998
+ if not line or line.startswith("#"):
999
+ continue
1000
+ is_negate = line.startswith("!")
1001
+ if is_negate:
1002
+ line = line[1:]
1003
+ if line:
1004
+ rules.append((line, is_negate))
1005
+ return rules
1006
+
1007
+
1008
+ def _match_gitignore(relpath: str, rules: list[tuple[str, bool]]) -> bool:
1009
+ """检查路径是否匹配 .gitignore 规则。最后命中的规则生效。"""
1010
+ ignored = False
1011
+ for pattern, is_negate in rules:
1012
+ if _gitignore_match(relpath, pattern):
1013
+ ignored = not is_negate
1014
+ return ignored
1015
+
1016
+
1017
+ def _gitignore_match(relpath: str, pattern: str) -> bool:
1018
+ """单个 .gitignore 模式匹配。支持 * ? [seq] ** / 锚定。"""
1019
+ is_dir_pattern = pattern.endswith("/")
1020
+ if is_dir_pattern:
1021
+ pattern = pattern[:-1]
1022
+ relpath = relpath.rstrip("/")
1023
+
1024
+ # 不含 / 且不含 **,匹配任意深度的文件名
1025
+ if "/" not in pattern and "**" not in pattern:
1026
+ return (fnmatch.fnmatch(relpath, pattern) or
1027
+ fnmatch.fnmatch(relpath.split("/")[-1], pattern))
1028
+
1029
+ # 含 **,特殊处理
1030
+ if "**" in pattern:
1031
+ return _match_double_star(relpath, pattern)
1032
+
1033
+ # 含 /,从根精确路径匹配(目录标记时 relpath 尾部加 / 用于区分)
1034
+ if is_dir_pattern:
1035
+ return fnmatch.fnmatch(relpath + "/", pattern + "/")
1036
+ return fnmatch.fnmatch(relpath, pattern)
1037
+
1038
+
1039
+ def _match_double_star(relpath: str, pattern: str) -> bool:
1040
+ """处理 ** 通配符,转换为正则匹配。"""
1041
+ regex = "^"
1042
+ i = 0
1043
+ while i < len(pattern):
1044
+ if pattern[i:i+2] == "**":
1045
+ if i + 2 < len(pattern) and pattern[i+2] == "/":
1046
+ regex += r"(.*/)?"
1047
+ i += 3 # skip **/
1048
+ else:
1049
+ regex += r".*"
1050
+ i += 2
1051
+ elif pattern[i] == "*":
1052
+ regex += r"[^/]*"
1053
+ i += 1
1054
+ elif pattern[i] == "?":
1055
+ regex += r"[^/]"
1056
+ i += 1
1057
+ elif pattern[i] in ".+^$(){}|\\":
1058
+ regex += "\\" + pattern[i]
1059
+ i += 1
1060
+ else:
1061
+ regex += pattern[i]
1062
+ i += 1
1063
+ regex += "$"
1064
+ return bool(re.match(regex, relpath))
1065
+
1066
+
1067
+ def _get_entry_version(repo_path: Path, val_type: str | None = None) -> str:
1068
+ """获取条目版本字符串。
1069
+
1070
+ val_type=None → 自动检测(semver > git sha > hash)
1071
+ val_type="semver" → semver tag
1072
+ val_type="git" → git sha
1073
+ val_type="hash" → 目录哈希
1074
+ """
1075
+ if val_type == "hash":
1076
+ return hash_dir(repo_path)
1077
+ if val_type == "git":
1078
+ sha, is_dirty = _get_git_version(repo_path)
1079
+ return f"{sha}{'-dirty' if is_dirty else ''}"
1080
+ if val_type == "semver" or (repo_path / ".git").exists():
1081
+ sv = _get_semver_version(repo_path)
1082
+ if sv:
1083
+ return sv
1084
+ if val_type == "semver":
1085
+ return ""
1086
+ sha, is_dirty = _get_git_version(repo_path)
1087
+ return f"{sha}{'-dirty' if is_dirty else ''}"
1088
+ return hash_dir(repo_path)
1089
+
1090
+
1091
+ def _extract_targets(entry) -> list[str]:
1092
+ """从 xt_vers 条目提取 targets 列表(兼容新旧格式)。"""
1093
+ if isinstance(entry, dict):
1094
+ targets = entry.get("targets")
1095
+ if isinstance(targets, list):
1096
+ return list(targets)
1097
+ return []
1098
+
1099
+
1100
+ def _extract_version(entry) -> str:
1101
+ """从 xt_vers 条目提取 version 字符串。
1102
+
1103
+ 当前格式为 {"version": "sha"},兼容旧版短字符串格式。
1104
+ """
1105
+ if isinstance(entry, str):
1106
+ return entry
1107
+ if isinstance(entry, dict):
1108
+ return entry.get("version", "")
1109
+ return ""