From b93ab66b72fa3650d00e076b14f0b4cc43ec6dcd Mon Sep 17 00:00:00 2001 From: song Date: Tue, 20 Oct 2026 16:12:42 +0800 Subject: [PATCH 1/3] build: add scoped Python unit-test dependencies Signed-off-by: song --- docs/development/testing-and-quality.md | 57 +++++++++++++++++++++++++ pyproject.toml | 7 +++ 2 files changed, 64 insertions(+) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 64e6bd66e9..83b102b547 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -582,6 +582,63 @@ negative 或 mutation-style 断言,并让各 provider 复用同一 envelope ### Local Validation Environment / 本地验证环境 +Choose dependencies for the validation scope before preparing a new worktree. +Each checkout can retain its own `.venv` and several `node_modules` directories; +installing every tool in every review checkout accumulates storage even when +the change only needs a few Python tests. + +新建 worktree 时,先按验证范围选择依赖。每个检出目录都可能保留独立的 +`.venv` 和多份 `node_modules`;只验证几个 Python 测试时也安装全部工具, +会随工作区数量持续累积占用。 + +| Validation scope / 验证范围 | Setup / 安装范围 | +| --- | --- | +| Selected Python unit tests using pytest, JSON Schema or YAML / 指定的基础 Python 单元测试 | `uv sync --extra test-core` | +| Full Python suite, lint, integration tests or premerge / 完整 Python 测试、静态检查、集成测试或提交前验证 | `uv sync --extra test` | +| Full control-plane TypeScript tests or coverage / 完整控制面 TypeScript 测试或覆盖率 | Root `npm ci` plus `uv sync --extra test` for Python subprocesses / 根目录 Node 依赖及测试子进程所需的 Python 环境 | +| Control-plane typecheck / 控制面类型检查 | `npm ci` at the repository root / 仓库根目录 | +| Dashboard build or browser tests / Dashboard 构建或浏览器测试 | `npm ci --prefix apps/presentation/dashboard`; browser tests also require their documented browser and backend setup / 浏览器测试另需其文档指定的浏览器与后端环境 | +| Site or desktop changes / 官网或桌面端改动 | Install in the affected app; include its documented build prerequisites / 在对应应用目录安装,并满足其构建前置条件 | + +For example, this targeted Python check needs neither the dashboard packages +nor the full Python test extra: + +例如,以下定向 Python 检查无需安装 Dashboard 依赖或完整 Python 测试依赖: + +```bash +uv sync --extra test-core +uv run --extra test-core python -m pytest -q tests/test_file_lock.py tests/test_loopx_launcher_python_selection.py +``` + +`test-core` is an opt-in subset, not a full-suite qualification. Select explicit +test files; MCP, Docker, image rendering, coverage and type-checking need the +full `test` extra. Keep `--extra test-core` on subsequent `uv run` commands so +the selected environment is synchronized consistently. `uv sync` can remove +packages outside the selected extras: do not change a worktree's environment +while another process is using it. Existing full-suite and CI commands below +continue to use `test`. + +`test-core` 仅用于显式选择的测试文件,不代表完整测试通过。MCP、Docker、图片 +渲染、覆盖率和类型检查继续使用完整的 `test`。后续 `uv run` 也需指定 +`--extra test-core`,保持环境一致。`uv sync` 可能移除所选依赖组之外的包, +因此不要修改正在被其他进程使用的工作区环境。下方完整验证及 CI 命令继续使用 `test`。 + +Root `npm run test:control-plane` includes PostgreSQL tests that import `pg`, +so the full command still needs root Node dependencies. It does not require +installing dashboard, site and desktop dependencies indiscriminately. Preserve +per-worktree environments: editable Python installs bind to a source checkout, +and frontend builds write caches inside `node_modules`. Sharing one writable +environment across branches can validate the wrong source or invalidate another +task's dependencies. Package-manager download caches can be shared; removing +retired environments requires a separate check for active users and recoverable +dependency definitions. + +完整 `npm run test:control-plane` 包含导入 `pg` 的 PostgreSQL 测试,仍需根目录 +Node 依赖,但无需因此把 Dashboard、官网、桌面端依赖全部安装。保留工作区间的 +环境隔离:Python 可编辑安装绑定具体源码目录,前端构建也会向 `node_modules` +写缓存。跨分支共用可写环境可能测试到错误源码,或破坏另一任务的依赖。 +可共享包管理器下载缓存;回收旧环境则应另行核实是否在用、依赖定义能否恢复。 + `npm run typecheck:control-plane` discovers all TypeScript sources under `loopx/control_plane/` and `loopx/zcode_goal_mode/`, including standalone bridges launched from Python and control-plane test drivers. New source files in these diff --git a/pyproject.toml b/pyproject.toml index 4078997a83..a919290d52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,13 @@ public-source-reader = [ deepseek-harness = [ "deepseek-harness-sdk==0.1.5rc1", ] +# Selected unit tests only; integration, lint and full-suite validation use test. +test-core = [ + "distlib>=0.3.9,<1; sys_platform == 'win32'", + "jsonschema>=4.23,<5", + "pyyaml>=6,<7", + "pytest>=9.0.3,<10", +] test = [ "distlib>=0.3.9,<1; sys_platform == 'win32'", "docker==7.2.0", From 14ffc3025b23fee586ad0d8f432f33f9ddb80c04 Mon Sep 17 00:00:00 2001 From: song Date: Tue, 20 Oct 2026 16:27:54 +0800 Subject: [PATCH 2/3] build: reuse verified checkout dependencies before installing Signed-off-by: song --- docs/development/testing-and-quality.md | 62 +++++ scripts/chat_bundle.py | 4 +- scripts/development_dependencies.py | 326 ++++++++++++++++++++++++ tests/presentation/test_chat_bundle.py | 5 + tests/test_development_dependencies.py | 173 +++++++++++++ 5 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 scripts/development_dependencies.py create mode 100644 tests/test_development_dependencies.py diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 83b102b547..25607df1b4 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -639,6 +639,68 @@ Node 依赖,但无需因此把 Dashboard、官网、桌面端依赖全部安 写缓存。跨分支共用可写环境可能测试到错误源码,或破坏另一任务的依赖。 可共享包管理器下载缓存;回收旧环境则应另行核实是否在用、依赖定义能否恢复。 +#### Reuse checked dependencies / 复用已验证的依赖 + +Use the checkout-local helper to prepare only the scopes needed for the task: + +通过工作区内的统一入口,仅准备本次任务需要的依赖: + +```bash +bash scripts/loopx-python.sh --exec scripts/development_dependencies.py setup --scope python-core --scope node +bash scripts/loopx-python.sh --exec scripts/development_dependencies.py setup --scope dashboard +``` + +Available scopes are `python-core`, `python-full`, `node`, `dashboard`, `site` +and `desktop`; select only one Python profile. On Windows, invoke the helper +with a Python 3.11+ interpreter directly. Python delegates compatibility and +incremental installation to `uv sync`. Node checks manifests, lockfiles, effective +npm configuration, Node/npm versions, platform, installed package metadata, +executables and the dependency graph before reuse. Compatible existing pure-JS +installs can be adopted; legacy native modules or lifecycle scripts without a +matching receipt require one reinstall. `setup --force` forces Node installation. +Failed installs do not leave a success receipt. Download caches remain reusable. + +范围包括 `python-core`、`python-full`、`node`、`dashboard`、`site`、`desktop`; +每次只选一个 Python 依赖组。Windows 可直接使用 Python 3.11+ 运行脚本。 +Python 由 `uv sync` 检查兼容性并增量安装。Node 复用前核对项目定义、锁文件、 +npm 有效配置、Node/npm 版本、平台、包元数据、可执行入口及依赖树。 +符合条件的既有纯 JavaScript 安装可直接接管;缺少匹配记录的原生模块或带安装脚本 +的旧环境需重新安装一次。`setup --force` 强制重装 Node 依赖;失败不会留下 +成功记录,下载缓存仍可复用。 + +Chat source-bundle builds use the same check, preserving their existing +`--ignore-scripts` policy. Explicit setup preserves normal npm lifecycle scripts; +the policies have distinct receipts. Receipts live inside ignored dependency +directories and contain hashes of configuration, not configuration credentials. +This avoids repeated installation, but does not deduplicate writable environments +across branches or verify every byte of an installed package. + +Chat 源码构建使用相同检查,并保留原有的 `--ignore-scripts` 策略;显式 setup +沿用 npm 普通安装脚本行为,两种策略分别校验记录。记录存放在被忽略的依赖目录, +配置只保存摘要,不保存凭据。此机制减少重复安装,不跨分支共享可写环境,也不逐字节 +验证所有包文件。请在工作区没有其他构建或测试运行时执行依赖变更。 + +Preview retirement of an old managed environment with: + +预览旧受管环境的回收结果: + +```bash +bash scripts/loopx-python.sh --exec scripts/development_dependencies.py retire --scope node --days 7 +``` + +Only `--apply` deletes the selected dependency directory. Retirement requires +an aged setup receipt, restorable dependency definitions, no tracked files and +no observed checkout processes/open files. Unknown process visibility retains +the environment. It never removes checkout source or adopts unmanaged environments +for deletion. Receipt age is time since setup, not proof of inactivity; stop all +checkout users before applying, since a process can start after the check. + +只有显式添加 `--apply` 才删除选定依赖目录。回收要求安装记录超过指定天数、 +依赖定义完整、目录中没有受版本控制的文件,且未发现占用工作区的进程或打开文件; +无法确认进程状态时保留。不会删除工作区源码,也不会直接删除未受管环境。 +记录年龄是距上次 setup 的时间,不是闲置证明;应用前应停止工作区使用者, +因为检查结束后仍可能有新进程启动。 + `npm run typecheck:control-plane` discovers all TypeScript sources under `loopx/control_plane/` and `loopx/zcode_goal_mode/`, including standalone bridges launched from Python and control-plane test drivers. New source files in these diff --git a/scripts/chat_bundle.py b/scripts/chat_bundle.py index bb8d881f5d..179d34f399 100644 --- a/scripts/chat_bundle.py +++ b/scripts/chat_bundle.py @@ -72,7 +72,9 @@ def _build(previous: Path | None, *, install: bool) -> None: "Node.js/npm is required to build a source checkout; release wheels already contain the frontend" ) if install: - subprocess.run([npm, "ci", "--ignore-scripts"], cwd=dashboard, check=True) + from scripts.development_dependencies import ensure_node_dependencies + + ensure_node_dependencies(ROOT, "dashboard", ignore_scripts=True) before = contract.source_inputs(ROOT) previous_assets(previous) # Reject invalid lineage before spending a build. OUTPUT.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/development_dependencies.py b/scripts/development_dependencies.py new file mode 100644 index 0000000000..c4541f4464 --- /dev/null +++ b/scripts/development_dependencies.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""Prepare scoped checkout dependencies and preview retirement of idle installs.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import os +from pathlib import Path +import platform +import shutil +import subprocess +import sys +import time + +if sys.version_info < (3, 11): + raise SystemExit("LoopX dependency setup requires Python 3.11 or newer") + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from loopx.file_lock import exclusive_file_lock, write_bytes_atomic + +NODE_PROJECTS = { + "node": ".", + "dashboard": "apps/presentation/dashboard", + "site": "apps/presentation/site", + "desktop": "apps/desktop/loopx-control-plane", +} +SCOPES = ("python-core", "python-full", *NODE_PROJECTS) + + +def _target(root: Path, scope: str) -> tuple[Path, Path, Path]: + if scope not in SCOPES: + raise ValueError(f"unknown dependency scope: {scope}") + project = root / NODE_PROJECTS[scope] if scope in NODE_PROJECTS else root + target = project / ("node_modules" if scope in NODE_PROJECTS else ".venv") + # A dependency folder linked to another checkout is not ours to replace. + if any(p.is_symlink() for p in (project, *project.parents, target)): + raise RuntimeError("dependency project and environment must not be symlinks") + if not project.resolve().is_relative_to(root.resolve()): + raise RuntimeError("dependency project escapes the checkout") + receipt = target / ".loopx-development-dependencies.json" + return project, target, receipt + + +def _read(path: Path) -> dict: + try: + value = json.loads(path.read_text(encoding="utf-8")) + return value if isinstance(value, dict) else {} + except (OSError, ValueError): + return {} + + +def _digest(path: Path) -> str | None: + return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None + + +def _tool(name: str) -> str: + value = shutil.which(name + ".cmd" if os.name == "nt" and name == "npm" else name) + if value is None: + raise RuntimeError(f"{name} is required to prepare these dependencies") + return value + + +def _node_key(project: Path, npm: str, ignore_scripts: bool) -> dict: + files = ("package.json", "package-lock.json", ".npmrc") + if not all((project / name).is_file() for name in files[:2]): + raise RuntimeError("Node dependency setup requires package.json and package-lock.json") + # Hash configuration values rather than persisting registry credentials. + configuration = { + key: value for key, value in os.environ.items() + if key.lower().startswith("npm_config_") or key == "NODE_ENV" + } + configuration["effective_config"] = subprocess.check_output( + [npm, "config", "list", "--json"], cwd=project, text=True, + stderr=subprocess.DEVNULL, + ) + return { + "files": {name: _digest(project / name) for name in files}, + "node": subprocess.check_output([_tool("node"), "--version"], text=True).strip(), + "npm": subprocess.check_output([npm, "--version"], text=True).strip(), + "platform": platform.system(), + "machine": platform.machine(), + "ignore_scripts": ignore_scripts, + "configuration": hashlib.sha256( + json.dumps(configuration, sort_keys=True).encode() + ).hexdigest(), + } + + +def _save(receipt: Path, root: Path, scope: str, key: dict) -> None: + write_bytes_atomic(receipt, (json.dumps({ + "checkout": str(root.resolve()), + "scope": scope, + "key": key, + "last_used": time.time(), + }, sort_keys=True) + "\n").encode()) + + +def _healthy_node_tree(project: Path, npm: str) -> bool: + target = project / "node_modules" + packages = _read(target / ".package-lock.json").get("packages") + if not isinstance(packages, dict): + return False + for name in packages: + path = project / name + if not path.resolve().is_relative_to(target.resolve()): + return False + metadata = _read(path / "package.json") + if not metadata: + return False + bins = metadata.get("bin") or {} + if isinstance(bins, str): + bins = {metadata.get("name", "").rsplit("/", 1)[-1]: bins} + if not isinstance(bins, dict): + return False + for command, relative in bins.items(): + if not isinstance(relative, str) or not (path / relative).is_file(): + return False + # npm puts executables in the nearest node_modules/.bin directory. + modules = next((p for p in path.parents if p.name == "node_modules"), target) + executable = modules / ".bin" / (command + (".cmd" if os.name == "nt" else "")) + if not executable.is_file(): + return False + return subprocess.run( + [npm, "ls", "--all", "--include=dev", "--json"], cwd=project, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ).returncode == 0 + + +def _adoptable_node_tree(project: Path, target: Path, npm: str) -> bool: + """Accept an existing pure-JS install only with matching lock provenance.""" + lifecycle = ("preinstall", "install", "postinstall", "prepare") + if any(key in (_read(project / "package.json").get("scripts") or {}) for key in lifecycle): + return False + wanted = _read(project / "package-lock.json") + installed = _read(target / ".package-lock.json") + if wanted.get("lockfileVersion") != 3 or installed.get("lockfileVersion") != 3: + return False + packages = installed.get("packages") + expected = wanted.get("packages") + if not isinstance(packages, dict) or not packages or not isinstance(expected, dict): + return False + for name, entry in packages.items(): + source = expected.get(name) + if not isinstance(entry, dict) or not isinstance(source, dict): + return False + if entry.get("link") or any( + entry.get(key) != source.get(key) for key in ("version", "resolved", "integrity") + ): + return False + # Do not infer native ABI or lifecycle-script compatibility from a lock. + path = project / name + if not path.resolve().is_relative_to(target.resolve()) or path.is_symlink(): + return False + metadata = _read(path / "package.json") + if metadata.get("version") != source.get("version"): + return False + scripts = metadata.get("scripts") or {} + if any(key in scripts for key in ("preinstall", "install", "postinstall")): + return False + bins = metadata.get("bin") or {} + if isinstance(bins, str): + bins = {metadata.get("name", ""): bins} + if not isinstance(bins, dict): + return False + if any(not isinstance(value, str) or not (path / value).is_file() for value in bins.values()): + return False + if any(target.rglob("*.node")): + return False + return _healthy_node_tree(project, npm) + + +def ensure_node_dependencies( + root: Path, scope: str, *, ignore_scripts: bool = True, force: bool = False +) -> bool: + """Return True only when npm ci ran; serialize managed installs per checkout.""" + if scope not in NODE_PROJECTS: + raise ValueError("a Node scope is required") + root = root.resolve() + with exclusive_file_lock(root / ".local/development-dependencies", timeout_seconds=300): + project, target, receipt = _target(root, scope) + npm = _tool("npm") + key = _node_key(project, npm, ignore_scripts) + old = _read(receipt) + reusable = ( + not force + and old.get("checkout") == str(root) + and old.get("scope") == scope + and old.get("key") == key + and (target / ".package-lock.json").is_file() + and old.get("installed_lock") == _digest(target / ".package-lock.json") + ) + if reusable: + reusable = _healthy_node_tree(project, npm) + elif not force and not old and target.is_dir(): + reusable = _adoptable_node_tree(project, target, npm) + if not reusable: + receipt.unlink(missing_ok=True) + args = [npm, "ci", "--prefer-offline"] + if ignore_scripts: + args.append("--ignore-scripts") + subprocess.run(args, cwd=project, check=True) + if key != _node_key(project, npm, ignore_scripts): + raise RuntimeError("dependency inputs changed during installation; retry") + if not _healthy_node_tree(project, npm): + raise RuntimeError("installed dependency tree is incomplete; check npm omit settings and retry") + if key != _node_key(project, npm, ignore_scripts): + receipt.unlink(missing_ok=True) + raise RuntimeError("dependency inputs changed during validation; retry") + _save(receipt, root, scope, key) + payload = _read(receipt) + payload["installed_lock"] = _digest(target / ".package-lock.json") + write_bytes_atomic(receipt, (json.dumps(payload, sort_keys=True) + "\n").encode()) + return not reusable + + +def prepare_python(root: Path, scope: str) -> None: + if scope not in ("python-core", "python-full"): + raise ValueError("a Python scope is required") + root = root.resolve() + with exclusive_file_lock(root / ".local/development-dependencies", timeout_seconds=300): + _, _, receipt = _target(root, scope) + # A caller override must not redirect sync into an unrelated environment. + env = {k: v for k, v in os.environ.items() if k != "UV_PROJECT_ENVIRONMENT"} + receipt.unlink(missing_ok=True) + subprocess.run( + [_tool("uv"), "sync", "--extra", "test-core" if scope == "python-core" else "test"], + cwd=root, env=env, check=True, + ) + _save(receipt, root, scope, { + name: _digest(root / name) for name in ("pyproject.toml", "uv.lock") + }) + + +def _in_use(root: Path) -> bool: + """Unknown process visibility is a hold, including on unsupported hosts.""" + if os.name != "posix" or shutil.which("lsof") is None: + return True + opened = subprocess.run(["lsof", "-nP", "-Fpn"], capture_output=True, text=True) + if opened.returncode != 0: + return True + current_pid = None + for line in opened.stdout.splitlines(): + if line.startswith("p"): + current_pid = line[1:] + if current_pid != str(os.getpid()) and line.startswith("n/"): + path = Path(line[1:]) + if path == root or root in path.parents: + return True + processes = subprocess.run( + ["ps", "-axo", "pid=,command="], capture_output=True, text=True + ) + if processes.returncode: + return True + for line in processes.stdout.splitlines(): + parts = line.strip().split(None, 1) + if len(parts) == 2 and parts[0] != str(os.getpid()) and str(root) in parts[1]: + return True + return False + + +def retire(root: Path, scope: str, *, days: int, apply: bool) -> dict: + root = root.resolve() + if days < 2: + raise ValueError("retention must be at least two days") + with exclusive_file_lock(root / ".local/development-dependencies", timeout_seconds=300): + project, target, receipt = _target(root, scope) + old = _read(receipt) + result = {"scope": scope, "path": str(target), "action": "retain"} + if old.get("checkout") != str(root) or old.get("scope") != scope: + return {**result, "reason": "not_owned_by_this_setup_scope"} + last_used = old.get("last_used") + if not isinstance(last_used, (int, float)) or not math.isfinite(last_used) or last_used > time.time() - days * 86400: + return {**result, "reason": "recently_prepared"} + definitions = ("package.json", "package-lock.json") if scope in NODE_PROJECTS else ("pyproject.toml", "uv.lock") + if not all((project / name).is_file() for name in definitions): + return {**result, "reason": "missing_restore_definitions"} + tracked = subprocess.run( + ["git", "ls-files", "--", str(target.relative_to(root))], + cwd=root, capture_output=True, text=True, + ) + if tracked.returncode or tracked.stdout.strip(): + return {**result, "reason": "tracked_files_or_git_unavailable"} + if _in_use(root): + return {**result, "reason": "checkout_in_use_or_visibility_unknown"} + if apply: + shutil.rmtree(target) + return {**result, "action": "removed" if apply else "would_remove"} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("setup", "retire")) + parser.add_argument("--scope", choices=SCOPES, action="append", required=True) + parser.add_argument("--force", action="store_true", help="reinstall Node dependencies") + parser.add_argument("--days", type=int, default=7) + parser.add_argument("--apply", action="store_true", help="delete eligible owned environments") + args = parser.parse_args() + if {"python-core", "python-full"} <= set(args.scope): + parser.error("select one Python profile per checkout") + if args.action == "setup" and args.apply: + parser.error("--apply belongs to retire") + root = ROOT.resolve() + for scope in dict.fromkeys(args.scope): + if args.action == "retire": + print(json.dumps(retire(root, scope, days=args.days, apply=args.apply))) + elif scope in NODE_PROJECTS: + installed = ensure_node_dependencies(root, scope, ignore_scripts=False, force=args.force) + print(f"{scope}: {'installed' if installed else 'reused'}") + else: + prepare_python(root, scope) + print(f"{scope}: synchronized") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (RuntimeError, ValueError, OSError, subprocess.CalledProcessError) as exc: + print(f"Dependency setup failed: {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/tests/presentation/test_chat_bundle.py b/tests/presentation/test_chat_bundle.py index 339fd86265..af4fe80967 100644 --- a/tests/presentation/test_chat_bundle.py +++ b/tests/presentation/test_chat_bundle.py @@ -47,6 +47,8 @@ def run(command, **kwargs): raise subprocess.CalledProcessError(1, command) return subprocess.CompletedProcess(command, 0) + from scripts import development_dependencies + monkeypatch.setattr(development_dependencies, "ensure_node_dependencies", lambda *a, **kw: False) monkeypatch.setattr(builder.subprocess, "run", run) return generation @@ -143,6 +145,9 @@ def run(command, **kwargs): output = kwargs.get('env', {}).get('LOOPX_CHAT_OUT_DIR', '') return original_run([sys.executable, str(root / 'backend.py'), str(root), mode, output], check=True) builder.subprocess.run = run +from scripts import development_dependencies +# This test owns bundle serialization; dependency compatibility has its own tests. +development_dependencies.ensure_node_dependencies = lambda *a, **kw: run(['npm', 'ci']) (root / ('ready-' + sys.argv[3])).touch() deadline = time.monotonic() + 10 while not (root / 'start').exists(): diff --git a/tests/test_development_dependencies.py b/tests/test_development_dependencies.py new file mode 100644 index 0000000000..fb66250c86 --- /dev/null +++ b/tests/test_development_dependencies.py @@ -0,0 +1,173 @@ +"""Checkout dependency reuse must fail closed on changed or broken installs.""" +import json +import subprocess +import time + +import pytest + +from scripts import development_dependencies as deps + + +@pytest.fixture +def node(tmp_path, monkeypatch): + root = tmp_path.resolve() + (root / 'package.json').write_text('{"dependencies":{"example":"1.0.0"}}') + lock = {'lockfileVersion': 3, 'packages': {'node_modules/example': { + 'version': '1.0.0', 'integrity': 'test', 'resolved': 'https://example.invalid/pkg'}}} + (root / 'package-lock.json').write_text(json.dumps(lock)) + calls = [] + monkeypatch.setattr(deps, '_tool', lambda name: name) + monkeypatch.setattr(deps.subprocess, 'check_output', lambda *a, **kw: 'v1') + + def install(): + target = root / 'node_modules' + (target / 'example').mkdir(parents=True, exist_ok=True) + (target / '.package-lock.json').write_text(json.dumps(lock)) + (target / 'example/package.json').write_text('{"name":"example","version":"1.0.0"}') + + def run(command, **kwargs): + calls.append(command) + if command[1] == 'ci': + install() + return subprocess.CompletedProcess(command, 0, stdout='') + + monkeypatch.setattr(deps.subprocess, 'run', run) + return root, calls, install + + +def test_cold_install_then_reuse_and_changed_lock_reinstall(node): + root, calls, _ = node + assert deps.ensure_node_dependencies(root, 'node') + assert not deps.ensure_node_dependencies(root, 'node') + (root / 'package-lock.json').write_text((root / 'package-lock.json').read_text() + '\n') + assert deps.ensure_node_dependencies(root, 'node') + assert len([c for c in calls if c[1] == 'ci']) == 2 + + +def test_adopt_existing_pure_js_without_install(node): + root, calls, install = node + install() + assert not deps.ensure_node_dependencies(root, 'node') + assert not any(c[1] == 'ci' for c in calls) + + +def test_unknown_native_install_gets_rebuilt(node): + root, _, install = node + install() + (root / 'node_modules/example/addon.node').touch() + assert deps.ensure_node_dependencies(root, 'node') + + +def test_missing_metadata_and_force_trigger_install(node): + root, _, _ = node + deps.ensure_node_dependencies(root, 'node') + (root / 'node_modules/example/package.json').unlink() + assert deps.ensure_node_dependencies(root, 'node') + assert deps.ensure_node_dependencies(root, 'node', force=True) + + +def test_missing_executable_is_unhealthy(node): + root, _, install = node + install() + (root / 'node_modules/example/package.json').write_text( + '{"name":"example","version":"1.0.0","bin":{"example":"cli.js"}}') + (root / 'node_modules/example/cli.js').touch() + assert not deps._healthy_node_tree(root, 'npm') + + +def test_tool_version_and_script_policy_invalidate_receipt(node, monkeypatch): + root, _, _ = node + deps.ensure_node_dependencies(root, 'node') + monkeypatch.setattr(deps.subprocess, 'check_output', lambda *a, **kw: 'v2') + assert deps.ensure_node_dependencies(root, 'node') + assert deps.ensure_node_dependencies(root, 'node', ignore_scripts=False) + + +def test_failed_install_does_not_leave_success_receipt(node, monkeypatch): + root, _, _ = node + deps.ensure_node_dependencies(root, 'node') + def fail(*args, **kwargs): + raise subprocess.CalledProcessError(1, ['npm', 'ci']) + monkeypatch.setattr(deps.subprocess, 'run', fail) + with pytest.raises(subprocess.CalledProcessError): + deps.ensure_node_dependencies(root, 'node', force=True) + assert not (root / 'node_modules/.loopx-development-dependencies.json').exists() + + +def test_symlink_target_rejected(node, tmp_path): + root, _, _ = node + other = root / 'other' + other.mkdir() + (root / 'node_modules').symlink_to(other, target_is_directory=True) + with pytest.raises(RuntimeError, match='symlinks'): + deps.ensure_node_dependencies(root, 'node') + + +def test_python_uses_uv_sync_without_external_environment(tmp_path, monkeypatch): + calls = [] + monkeypatch.setenv('UV_PROJECT_ENVIRONMENT', '/unrelated') + monkeypatch.setattr(deps, '_tool', lambda name: name) + def run(command, **kwargs): + calls.append((command, kwargs)) + (tmp_path / '.venv').mkdir(exist_ok=True) + monkeypatch.setattr(deps.subprocess, 'run', run) + deps.prepare_python(tmp_path.resolve(), 'python-core') + assert calls[0][0] == ['uv', 'sync', '--extra', 'test-core'] + assert 'UV_PROJECT_ENVIRONMENT' not in calls[0][1]['env'] + + +def test_retirement_requires_old_owned_idle_restoreable_install(node, monkeypatch): + root, _, _ = node + assert deps.retire(root, 'node', days=7, apply=False)['action'] == 'retain' + deps.ensure_node_dependencies(root, 'node') + receipt = root / 'node_modules/.loopx-development-dependencies.json' + old = json.loads(receipt.read_text()) + old['last_used'] = time.time() - 8 * 86400 + receipt.write_text(json.dumps(old)) + monkeypatch.setattr(deps, '_in_use', lambda _: True) + assert deps.retire(root, 'node', days=7, apply=True)['action'] == 'retain' + monkeypatch.setattr(deps, '_in_use', lambda _: False) + assert deps.retire(root, 'node', days=7, apply=False)['action'] == 'would_remove' + assert receipt.exists() + (root / 'package-lock.json').unlink() + assert deps.retire(root, 'node', days=7, apply=True)['action'] == 'retain' + (root / 'package-lock.json').write_text('{}') + assert deps.retire(root, 'node', days=7, apply=True)['action'] == 'removed' + + +def test_broken_dependency_graph_never_gets_receipt(node, monkeypatch): + root, _, install = node + def run(command, **kwargs): + if command[1] == 'ci': + install() + return subprocess.CompletedProcess(command, 1 if command[1] == 'ls' else 0) + monkeypatch.setattr(deps.subprocess, 'run', run) + with pytest.raises(RuntimeError, match='incomplete'): + deps.ensure_node_dependencies(root, 'node') + assert not (root / 'node_modules/.loopx-development-dependencies.json').exists() + + +def test_root_lifecycle_script_prevents_legacy_adoption(node): + root, _, install = node + install() + (root / 'package.json').write_text('{"scripts":{"prepare":"echo build"}}') + assert deps.ensure_node_dependencies(root, 'node') + + +def test_unknown_process_visibility_holds_retirement(tmp_path, monkeypatch): + monkeypatch.setattr(deps.shutil, 'which', lambda _: None) + assert deps._in_use(tmp_path.resolve()) + + +def test_tracked_dependency_files_hold_retirement(node, monkeypatch): + root, _, _ = node + deps.ensure_node_dependencies(root, 'node') + receipt = root / 'node_modules/.loopx-development-dependencies.json' + old = json.loads(receipt.read_text()) + old['last_used'] = time.time() - 8 * 86400 + receipt.write_text(json.dumps(old)) + monkeypatch.setattr(deps.subprocess, 'run', lambda *a, **kw: + subprocess.CompletedProcess(a[0], 0, stdout='node_modules/important')) + monkeypatch.setattr(deps, '_in_use', lambda _: False) + assert deps.retire(root, 'node', days=7, apply=True)['reason'] == 'tracked_files_or_git_unavailable' + assert receipt.exists() From f9ab40ebd8b585fd53a58145d3870727fca3f40f Mon Sep 17 00:00:00 2001 From: song Date: Tue, 20 Oct 2026 16:39:39 +0800 Subject: [PATCH 3/3] build: limit dependency guidance to opt-in inspection Signed-off-by: song --- docs/development/testing-and-quality.md | 85 ++++----- scripts/chat_bundle.py | 4 +- scripts/development_dependencies.py | 244 +++--------------------- tests/presentation/test_chat_bundle.py | 5 - tests/test_development_dependencies.py | 217 +++++++-------------- 5 files changed, 129 insertions(+), 426 deletions(-) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 25607df1b4..93f4c0ab3e 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -639,67 +639,46 @@ Node 依赖,但无需因此把 Dashboard、官网、桌面端依赖全部安 写缓存。跨分支共用可写环境可能测试到错误源码,或破坏另一任务的依赖。 可共享包管理器下载缓存;回收旧环境则应另行核实是否在用、依赖定义能否恢复。 -#### Reuse checked dependencies / 复用已验证的依赖 +#### Manual dependency inspection / 手动检查依赖 -Use the checkout-local helper to prepare only the scopes needed for the task: +Prepare only the dependency scope needed for the task using the commands above. +`uv sync` already checks the Python environment and installs incrementally. +Avoid switching extras while another task is using the same environment. -通过工作区内的统一入口,仅准备本次任务需要的依赖: +按上表仅安装当前任务需要的依赖。`uv sync` 本身会检查 Python 环境并增量安装; +同一环境正在被其他任务使用时,不要切换依赖组。 -```bash -bash scripts/loopx-python.sh --exec scripts/development_dependencies.py setup --scope python-core --scope node -bash scripts/loopx-python.sh --exec scripts/development_dependencies.py setup --scope dashboard -``` +For an optional, read-only Node inspection: -Available scopes are `python-core`, `python-full`, `node`, `dashboard`, `site` -and `desktop`; select only one Python profile. On Windows, invoke the helper -with a Python 3.11+ interpreter directly. Python delegates compatibility and -incremental installation to `uv sync`. Node checks manifests, lockfiles, effective -npm configuration, Node/npm versions, platform, installed package metadata, -executables and the dependency graph before reuse. Compatible existing pure-JS -installs can be adopted; legacy native modules or lifecycle scripts without a -matching receipt require one reinstall. `setup --force` forces Node installation. -Failed installs do not leave a success receipt. Download caches remain reusable. - -范围包括 `python-core`、`python-full`、`node`、`dashboard`、`site`、`desktop`; -每次只选一个 Python 依赖组。Windows 可直接使用 Python 3.11+ 运行脚本。 -Python 由 `uv sync` 检查兼容性并增量安装。Node 复用前核对项目定义、锁文件、 -npm 有效配置、Node/npm 版本、平台、包元数据、可执行入口及依赖树。 -符合条件的既有纯 JavaScript 安装可直接接管;缺少匹配记录的原生模块或带安装脚本 -的旧环境需重新安装一次。`setup --force` 强制重装 Node 依赖;失败不会留下 -成功记录,下载缓存仍可复用。 - -Chat source-bundle builds use the same check, preserving their existing -`--ignore-scripts` policy. Explicit setup preserves normal npm lifecycle scripts; -the policies have distinct receipts. Receipts live inside ignored dependency -directories and contain hashes of configuration, not configuration credentials. -This avoids repeated installation, but does not deduplicate writable environments -across branches or verify every byte of an installed package. - -Chat 源码构建使用相同检查,并保留原有的 `--ignore-scripts` 策略;显式 setup -沿用 npm 普通安装脚本行为,两种策略分别校验记录。记录存放在被忽略的依赖目录, -配置只保存摘要,不保存凭据。此机制减少重复安装,不跨分支共享可写环境,也不逐字节 -验证所有包文件。请在工作区没有其他构建或测试运行时执行依赖变更。 - -Preview retirement of an old managed environment with: - -预览旧受管环境的回收结果: +需要时可手动运行只读 Node 检查: ```bash -bash scripts/loopx-python.sh --exec scripts/development_dependencies.py retire --scope node --days 7 +bash scripts/loopx-python.sh --exec scripts/development_dependencies.py --scope node --scope dashboard ``` -Only `--apply` deletes the selected dependency directory. Retirement requires -an aged setup receipt, restorable dependency definitions, no tracked files and -no observed checkout processes/open files. Unknown process visibility retains -the environment. It never removes checkout source or adopts unmanaged environments -for deletion. Receipt age is time since setup, not proof of inactivity; stop all -checkout users before applying, since a process can start after the check. - -只有显式添加 `--apply` 才删除选定依赖目录。回收要求安装记录超过指定天数、 -依赖定义完整、目录中没有受版本控制的文件,且未发现占用工作区的进程或打开文件; -无法确认进程状态时保留。不会删除工作区源码,也不会直接删除未受管环境。 -记录年龄是距上次 setup 的时间,不是闲置证明;应用前应停止工作区使用者, -因为检查结束后仍可能有新进程启动。 +Scopes are `node`, `dashboard`, `site` and `desktop`. On Windows, use a Python +3.11+ interpreter directly. The tool reports missing directories or checks the +lock records, package metadata, executable entries and npm dependency graph. +Native modules, lifecycle scripts, linked environments and uncertain installs +require manual validation. Exit code 0 means the limited pure-JS consistency +checks passed; exit code 1 means attention is needed, not necessarily corruption. +The tool cannot certify every package byte, runtime compatibility or build success. +It does not install packages, write receipts, delete files or change build behavior. +Use the app's existing installation and validation commands when required. + +范围包括 `node`、`dashboard`、`site`、`desktop`;Windows 可直接用 Python 3.11+ +运行。工具报告目录缺失,或核对锁记录、包元数据、可执行入口与 npm 依赖树。 +原生模块、安装脚本、链接环境及无法确认的安装需要人工验证。退出码 0 仅表示有限的 +纯 JavaScript 一致性检查通过;退出码 1 表示需要关注,不一定是安装损坏。 +检查不能保证包内每个文件、运行时兼容性或构建成功。工具不安装依赖、不写安装记录、 +不删除文件、不改变构建流程;需要安装或验证时,继续使用应用原有命令。 + +Clean old dependency environments separately after checking worktree ownership, +active users and recoverable dependency definitions. Age alone is not evidence +that an environment is unused. Keep this an explicit maintenance operation. + +旧依赖环境另行清理:先核实工作区归属、使用中的进程,以及依赖定义能否恢复。 +目录较旧不能证明无人使用;清理应作为显式维护操作进行。 `npm run typecheck:control-plane` discovers all TypeScript sources under `loopx/control_plane/` and `loopx/zcode_goal_mode/`, including standalone bridges diff --git a/scripts/chat_bundle.py b/scripts/chat_bundle.py index 179d34f399..bb8d881f5d 100644 --- a/scripts/chat_bundle.py +++ b/scripts/chat_bundle.py @@ -72,9 +72,7 @@ def _build(previous: Path | None, *, install: bool) -> None: "Node.js/npm is required to build a source checkout; release wheels already contain the frontend" ) if install: - from scripts.development_dependencies import ensure_node_dependencies - - ensure_node_dependencies(ROOT, "dashboard", ignore_scripts=True) + subprocess.run([npm, "ci", "--ignore-scripts"], cwd=dashboard, check=True) before = contract.source_inputs(ROOT) previous_assets(previous) # Reject invalid lineage before spending a build. OUTPUT.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/development_dependencies.py b/scripts/development_dependencies.py index c4541f4464..766b7855d0 100644 --- a/scripts/development_dependencies.py +++ b/scripts/development_dependencies.py @@ -1,50 +1,22 @@ #!/usr/bin/env python3 -"""Prepare scoped checkout dependencies and preview retirement of idle installs.""" +"""Manually inspect Node dependencies; never install, remove or record ownership.""" from __future__ import annotations import argparse -import hashlib import json -import math import os from pathlib import Path -import platform import shutil import subprocess -import sys -import time - -if sys.version_info < (3, 11): - raise SystemExit("LoopX dependency setup requires Python 3.11 or newer") ROOT = Path(__file__).resolve().parents[1] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from loopx.file_lock import exclusive_file_lock, write_bytes_atomic - NODE_PROJECTS = { "node": ".", "dashboard": "apps/presentation/dashboard", "site": "apps/presentation/site", "desktop": "apps/desktop/loopx-control-plane", } -SCOPES = ("python-core", "python-full", *NODE_PROJECTS) - - -def _target(root: Path, scope: str) -> tuple[Path, Path, Path]: - if scope not in SCOPES: - raise ValueError(f"unknown dependency scope: {scope}") - project = root / NODE_PROJECTS[scope] if scope in NODE_PROJECTS else root - target = project / ("node_modules" if scope in NODE_PROJECTS else ".venv") - # A dependency folder linked to another checkout is not ours to replace. - if any(p.is_symlink() for p in (project, *project.parents, target)): - raise RuntimeError("dependency project and environment must not be symlinks") - if not project.resolve().is_relative_to(root.resolve()): - raise RuntimeError("dependency project escapes the checkout") - receipt = target / ".loopx-development-dependencies.json" - return project, target, receipt def _read(path: Path) -> dict: @@ -55,52 +27,6 @@ def _read(path: Path) -> dict: return {} -def _digest(path: Path) -> str | None: - return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None - - -def _tool(name: str) -> str: - value = shutil.which(name + ".cmd" if os.name == "nt" and name == "npm" else name) - if value is None: - raise RuntimeError(f"{name} is required to prepare these dependencies") - return value - - -def _node_key(project: Path, npm: str, ignore_scripts: bool) -> dict: - files = ("package.json", "package-lock.json", ".npmrc") - if not all((project / name).is_file() for name in files[:2]): - raise RuntimeError("Node dependency setup requires package.json and package-lock.json") - # Hash configuration values rather than persisting registry credentials. - configuration = { - key: value for key, value in os.environ.items() - if key.lower().startswith("npm_config_") or key == "NODE_ENV" - } - configuration["effective_config"] = subprocess.check_output( - [npm, "config", "list", "--json"], cwd=project, text=True, - stderr=subprocess.DEVNULL, - ) - return { - "files": {name: _digest(project / name) for name in files}, - "node": subprocess.check_output([_tool("node"), "--version"], text=True).strip(), - "npm": subprocess.check_output([npm, "--version"], text=True).strip(), - "platform": platform.system(), - "machine": platform.machine(), - "ignore_scripts": ignore_scripts, - "configuration": hashlib.sha256( - json.dumps(configuration, sort_keys=True).encode() - ).hexdigest(), - } - - -def _save(receipt: Path, root: Path, scope: str, key: dict) -> None: - write_bytes_atomic(receipt, (json.dumps({ - "checkout": str(root.resolve()), - "scope": scope, - "key": key, - "last_used": time.time(), - }, sort_keys=True) + "\n").encode()) - - def _healthy_node_tree(project: Path, npm: str) -> bool: target = project / "node_modules" packages = _read(target / ".package-lock.json").get("packages") @@ -132,8 +58,8 @@ def _healthy_node_tree(project: Path, npm: str) -> bool: ).returncode == 0 -def _adoptable_node_tree(project: Path, target: Path, npm: str) -> bool: - """Accept an existing pure-JS install only with matching lock provenance.""" +def _consistent_pure_js_tree(project: Path, target: Path, npm: str) -> bool: + """Conservatively check a pure-JS install without modifying it.""" lifecycle = ("preinstall", "install", "postinstall", "prepare") if any(key in (_read(project / "package.json").get("scripts") or {}) for key in lifecycle): return False @@ -175,152 +101,36 @@ def _adoptable_node_tree(project: Path, target: Path, npm: str) -> bool: return _healthy_node_tree(project, npm) -def ensure_node_dependencies( - root: Path, scope: str, *, ignore_scripts: bool = True, force: bool = False -) -> bool: - """Return True only when npm ci ran; serialize managed installs per checkout.""" - if scope not in NODE_PROJECTS: - raise ValueError("a Node scope is required") - root = root.resolve() - with exclusive_file_lock(root / ".local/development-dependencies", timeout_seconds=300): - project, target, receipt = _target(root, scope) - npm = _tool("npm") - key = _node_key(project, npm, ignore_scripts) - old = _read(receipt) - reusable = ( - not force - and old.get("checkout") == str(root) - and old.get("scope") == scope - and old.get("key") == key - and (target / ".package-lock.json").is_file() - and old.get("installed_lock") == _digest(target / ".package-lock.json") - ) - if reusable: - reusable = _healthy_node_tree(project, npm) - elif not force and not old and target.is_dir(): - reusable = _adoptable_node_tree(project, target, npm) - if not reusable: - receipt.unlink(missing_ok=True) - args = [npm, "ci", "--prefer-offline"] - if ignore_scripts: - args.append("--ignore-scripts") - subprocess.run(args, cwd=project, check=True) - if key != _node_key(project, npm, ignore_scripts): - raise RuntimeError("dependency inputs changed during installation; retry") - if not _healthy_node_tree(project, npm): - raise RuntimeError("installed dependency tree is incomplete; check npm omit settings and retry") - if key != _node_key(project, npm, ignore_scripts): - receipt.unlink(missing_ok=True) - raise RuntimeError("dependency inputs changed during validation; retry") - _save(receipt, root, scope, key) - payload = _read(receipt) - payload["installed_lock"] = _digest(target / ".package-lock.json") - write_bytes_atomic(receipt, (json.dumps(payload, sort_keys=True) + "\n").encode()) - return not reusable - - -def prepare_python(root: Path, scope: str) -> None: - if scope not in ("python-core", "python-full"): - raise ValueError("a Python scope is required") +def inspect_node_dependencies(root: Path, scope: str) -> dict: root = root.resolve() - with exclusive_file_lock(root / ".local/development-dependencies", timeout_seconds=300): - _, _, receipt = _target(root, scope) - # A caller override must not redirect sync into an unrelated environment. - env = {k: v for k, v in os.environ.items() if k != "UV_PROJECT_ENVIRONMENT"} - receipt.unlink(missing_ok=True) - subprocess.run( - [_tool("uv"), "sync", "--extra", "test-core" if scope == "python-core" else "test"], - cwd=root, env=env, check=True, - ) - _save(receipt, root, scope, { - name: _digest(root / name) for name in ("pyproject.toml", "uv.lock") - }) - - -def _in_use(root: Path) -> bool: - """Unknown process visibility is a hold, including on unsupported hosts.""" - if os.name != "posix" or shutil.which("lsof") is None: - return True - opened = subprocess.run(["lsof", "-nP", "-Fpn"], capture_output=True, text=True) - if opened.returncode != 0: - return True - current_pid = None - for line in opened.stdout.splitlines(): - if line.startswith("p"): - current_pid = line[1:] - if current_pid != str(os.getpid()) and line.startswith("n/"): - path = Path(line[1:]) - if path == root or root in path.parents: - return True - processes = subprocess.run( - ["ps", "-axo", "pid=,command="], capture_output=True, text=True - ) - if processes.returncode: - return True - for line in processes.stdout.splitlines(): - parts = line.strip().split(None, 1) - if len(parts) == 2 and parts[0] != str(os.getpid()) and str(root) in parts[1]: - return True - return False - - -def retire(root: Path, scope: str, *, days: int, apply: bool) -> dict: - root = root.resolve() - if days < 2: - raise ValueError("retention must be at least two days") - with exclusive_file_lock(root / ".local/development-dependencies", timeout_seconds=300): - project, target, receipt = _target(root, scope) - old = _read(receipt) - result = {"scope": scope, "path": str(target), "action": "retain"} - if old.get("checkout") != str(root) or old.get("scope") != scope: - return {**result, "reason": "not_owned_by_this_setup_scope"} - last_used = old.get("last_used") - if not isinstance(last_used, (int, float)) or not math.isfinite(last_used) or last_used > time.time() - days * 86400: - return {**result, "reason": "recently_prepared"} - definitions = ("package.json", "package-lock.json") if scope in NODE_PROJECTS else ("pyproject.toml", "uv.lock") - if not all((project / name).is_file() for name in definitions): - return {**result, "reason": "missing_restore_definitions"} - tracked = subprocess.run( - ["git", "ls-files", "--", str(target.relative_to(root))], - cwd=root, capture_output=True, text=True, - ) - if tracked.returncode or tracked.stdout.strip(): - return {**result, "reason": "tracked_files_or_git_unavailable"} - if _in_use(root): - return {**result, "reason": "checkout_in_use_or_visibility_unknown"} - if apply: - shutil.rmtree(target) - return {**result, "action": "removed" if apply else "would_remove"} + project = root / NODE_PROJECTS[scope] + target = project / "node_modules" + result = {"scope": scope, "status": "needs_manual_check"} + if not project.resolve().is_relative_to(root) or any( + path.is_symlink() for path in (project, *project.parents, target) + ): + return {**result, "reason": "linked_environment"} + if not all((project / name).is_file() for name in ("package.json", "package-lock.json")): + return {**result, "reason": "missing_dependency_definitions"} + if not target.is_dir(): + return {**result, "status": "missing", "reason": "no_local_node_modules"} + npm = shutil.which("npm.cmd" if os.name == "nt" else "npm") + if npm is None: + return {**result, "reason": "npm_unavailable"} + if not _consistent_pure_js_tree(project, target, npm): + return {**result, "reason": "incomplete_changed_or_unverified_install"} + return {**result, "status": "check_passed", "reason": "pure_js_lock_and_graph_consistent"} def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("action", choices=("setup", "retire")) - parser.add_argument("--scope", choices=SCOPES, action="append", required=True) - parser.add_argument("--force", action="store_true", help="reinstall Node dependencies") - parser.add_argument("--days", type=int, default=7) - parser.add_argument("--apply", action="store_true", help="delete eligible owned environments") + parser.add_argument("--scope", choices=NODE_PROJECTS, action="append", required=True) args = parser.parse_args() - if {"python-core", "python-full"} <= set(args.scope): - parser.error("select one Python profile per checkout") - if args.action == "setup" and args.apply: - parser.error("--apply belongs to retire") - root = ROOT.resolve() - for scope in dict.fromkeys(args.scope): - if args.action == "retire": - print(json.dumps(retire(root, scope, days=args.days, apply=args.apply))) - elif scope in NODE_PROJECTS: - installed = ensure_node_dependencies(root, scope, ignore_scripts=False, force=args.force) - print(f"{scope}: {'installed' if installed else 'reused'}") - else: - prepare_python(root, scope) - print(f"{scope}: synchronized") - return 0 + results = [inspect_node_dependencies(ROOT, scope) for scope in dict.fromkeys(args.scope)] + for result in results: + print(json.dumps(result)) + return 0 if all(result["status"] == "check_passed" for result in results) else 1 if __name__ == "__main__": - try: - raise SystemExit(main()) - except (RuntimeError, ValueError, OSError, subprocess.CalledProcessError) as exc: - print(f"Dependency setup failed: {exc}", file=sys.stderr) - raise SystemExit(1) + raise SystemExit(main()) diff --git a/tests/presentation/test_chat_bundle.py b/tests/presentation/test_chat_bundle.py index af4fe80967..339fd86265 100644 --- a/tests/presentation/test_chat_bundle.py +++ b/tests/presentation/test_chat_bundle.py @@ -47,8 +47,6 @@ def run(command, **kwargs): raise subprocess.CalledProcessError(1, command) return subprocess.CompletedProcess(command, 0) - from scripts import development_dependencies - monkeypatch.setattr(development_dependencies, "ensure_node_dependencies", lambda *a, **kw: False) monkeypatch.setattr(builder.subprocess, "run", run) return generation @@ -145,9 +143,6 @@ def run(command, **kwargs): output = kwargs.get('env', {}).get('LOOPX_CHAT_OUT_DIR', '') return original_run([sys.executable, str(root / 'backend.py'), str(root), mode, output], check=True) builder.subprocess.run = run -from scripts import development_dependencies -# This test owns bundle serialization; dependency compatibility has its own tests. -development_dependencies.ensure_node_dependencies = lambda *a, **kw: run(['npm', 'ci']) (root / ('ready-' + sys.argv[3])).touch() deadline = time.monotonic() + 10 while not (root / 'start').exists(): diff --git a/tests/test_development_dependencies.py b/tests/test_development_dependencies.py index fb66250c86..b3d6ba9eaf 100644 --- a/tests/test_development_dependencies.py +++ b/tests/test_development_dependencies.py @@ -1,7 +1,6 @@ -"""Checkout dependency reuse must fail closed on changed or broken installs.""" +"""Manual inspection reports uncertainty without mutating environments.""" import json import subprocess -import time import pytest @@ -15,159 +14,81 @@ def node(tmp_path, monkeypatch): lock = {'lockfileVersion': 3, 'packages': {'node_modules/example': { 'version': '1.0.0', 'integrity': 'test', 'resolved': 'https://example.invalid/pkg'}}} (root / 'package-lock.json').write_text(json.dumps(lock)) + target = root / 'node_modules' + (target / 'example').mkdir(parents=True) + (target / '.package-lock.json').write_text(json.dumps(lock)) + (target / 'example/package.json').write_text('{"name":"example","version":"1.0.0"}') calls = [] - monkeypatch.setattr(deps, '_tool', lambda name: name) - monkeypatch.setattr(deps.subprocess, 'check_output', lambda *a, **kw: 'v1') - - def install(): - target = root / 'node_modules' - (target / 'example').mkdir(parents=True, exist_ok=True) - (target / '.package-lock.json').write_text(json.dumps(lock)) - (target / 'example/package.json').write_text('{"name":"example","version":"1.0.0"}') - + monkeypatch.setattr(deps.shutil, 'which', lambda name: name) def run(command, **kwargs): calls.append(command) - if command[1] == 'ci': - install() - return subprocess.CompletedProcess(command, 0, stdout='') - - monkeypatch.setattr(deps.subprocess, 'run', run) - return root, calls, install - - -def test_cold_install_then_reuse_and_changed_lock_reinstall(node): - root, calls, _ = node - assert deps.ensure_node_dependencies(root, 'node') - assert not deps.ensure_node_dependencies(root, 'node') - (root / 'package-lock.json').write_text((root / 'package-lock.json').read_text() + '\n') - assert deps.ensure_node_dependencies(root, 'node') - assert len([c for c in calls if c[1] == 'ci']) == 2 - - -def test_adopt_existing_pure_js_without_install(node): - root, calls, install = node - install() - assert not deps.ensure_node_dependencies(root, 'node') - assert not any(c[1] == 'ci' for c in calls) - - -def test_unknown_native_install_gets_rebuilt(node): - root, _, install = node - install() - (root / 'node_modules/example/addon.node').touch() - assert deps.ensure_node_dependencies(root, 'node') - - -def test_missing_metadata_and_force_trigger_install(node): - root, _, _ = node - deps.ensure_node_dependencies(root, 'node') - (root / 'node_modules/example/package.json').unlink() - assert deps.ensure_node_dependencies(root, 'node') - assert deps.ensure_node_dependencies(root, 'node', force=True) - - -def test_missing_executable_is_unhealthy(node): - root, _, install = node - install() - (root / 'node_modules/example/package.json').write_text( - '{"name":"example","version":"1.0.0","bin":{"example":"cli.js"}}') - (root / 'node_modules/example/cli.js').touch() - assert not deps._healthy_node_tree(root, 'npm') - - -def test_tool_version_and_script_policy_invalidate_receipt(node, monkeypatch): - root, _, _ = node - deps.ensure_node_dependencies(root, 'node') - monkeypatch.setattr(deps.subprocess, 'check_output', lambda *a, **kw: 'v2') - assert deps.ensure_node_dependencies(root, 'node') - assert deps.ensure_node_dependencies(root, 'node', ignore_scripts=False) - - -def test_failed_install_does_not_leave_success_receipt(node, monkeypatch): - root, _, _ = node - deps.ensure_node_dependencies(root, 'node') - def fail(*args, **kwargs): - raise subprocess.CalledProcessError(1, ['npm', 'ci']) - monkeypatch.setattr(deps.subprocess, 'run', fail) - with pytest.raises(subprocess.CalledProcessError): - deps.ensure_node_dependencies(root, 'node', force=True) - assert not (root / 'node_modules/.loopx-development-dependencies.json').exists() - - -def test_symlink_target_rejected(node, tmp_path): - root, _, _ = node - other = root / 'other' - other.mkdir() - (root / 'node_modules').symlink_to(other, target_is_directory=True) - with pytest.raises(RuntimeError, match='symlinks'): - deps.ensure_node_dependencies(root, 'node') - - -def test_python_uses_uv_sync_without_external_environment(tmp_path, monkeypatch): - calls = [] - monkeypatch.setenv('UV_PROJECT_ENVIRONMENT', '/unrelated') - monkeypatch.setattr(deps, '_tool', lambda name: name) - def run(command, **kwargs): - calls.append((command, kwargs)) - (tmp_path / '.venv').mkdir(exist_ok=True) - monkeypatch.setattr(deps.subprocess, 'run', run) - deps.prepare_python(tmp_path.resolve(), 'python-core') - assert calls[0][0] == ['uv', 'sync', '--extra', 'test-core'] - assert 'UV_PROJECT_ENVIRONMENT' not in calls[0][1]['env'] - - -def test_retirement_requires_old_owned_idle_restoreable_install(node, monkeypatch): - root, _, _ = node - assert deps.retire(root, 'node', days=7, apply=False)['action'] == 'retain' - deps.ensure_node_dependencies(root, 'node') - receipt = root / 'node_modules/.loopx-development-dependencies.json' - old = json.loads(receipt.read_text()) - old['last_used'] = time.time() - 8 * 86400 - receipt.write_text(json.dumps(old)) - monkeypatch.setattr(deps, '_in_use', lambda _: True) - assert deps.retire(root, 'node', days=7, apply=True)['action'] == 'retain' - monkeypatch.setattr(deps, '_in_use', lambda _: False) - assert deps.retire(root, 'node', days=7, apply=False)['action'] == 'would_remove' - assert receipt.exists() - (root / 'package-lock.json').unlink() - assert deps.retire(root, 'node', days=7, apply=True)['action'] == 'retain' - (root / 'package-lock.json').write_text('{}') - assert deps.retire(root, 'node', days=7, apply=True)['action'] == 'removed' - - -def test_broken_dependency_graph_never_gets_receipt(node, monkeypatch): - root, _, install = node - def run(command, **kwargs): - if command[1] == 'ci': - install() - return subprocess.CompletedProcess(command, 1 if command[1] == 'ls' else 0) + assert command[1:] == ['ls', '--all', '--include=dev', '--json'] + return subprocess.CompletedProcess(command, 0) monkeypatch.setattr(deps.subprocess, 'run', run) - with pytest.raises(RuntimeError, match='incomplete'): - deps.ensure_node_dependencies(root, 'node') - assert not (root / 'node_modules/.loopx-development-dependencies.json').exists() + return root, calls + + +def test_consistent_install_is_read_only(node): + root, calls = node + before = {p.relative_to(root): (p.read_bytes(), p.stat().st_mtime_ns) + for p in root.rglob('*') if p.is_file()} + assert deps.inspect_node_dependencies(root, 'node')['status'] == 'check_passed' + after = {p.relative_to(root): (p.read_bytes(), p.stat().st_mtime_ns) + for p in root.rglob('*') if p.is_file()} + assert before == after + assert len(calls) == 1 + + +def test_missing_install_is_only_reported(tmp_path): + (tmp_path / 'package.json').write_text('{}') + (tmp_path / 'package-lock.json').write_text('{}') + assert deps.inspect_node_dependencies(tmp_path, 'node')['status'] == 'missing' + assert not (tmp_path / 'node_modules').exists() + + +@pytest.mark.parametrize('change', ['native', 'lifecycle', 'metadata', 'lock', 'bin']) +def test_uncertain_or_broken_install_requires_manual_check(node, change): + root, _ = node + package = root / 'node_modules/example' + if change == 'native': + (package / 'addon.node').touch() + elif change == 'lifecycle': + (root / 'package.json').write_text('{"scripts":{"prepare":"echo build"}}') + elif change == 'metadata': + (package / 'package.json').unlink() + elif change == 'lock': + (root / 'package-lock.json').write_text('{"lockfileVersion":3,"packages":{}}') + else: + (package / 'package.json').write_text( + '{"name":"example","version":"1.0.0","bin":{"example":"missing.js"}}') + assert deps.inspect_node_dependencies(root, 'node')['status'] == 'needs_manual_check' + assert package.is_dir() + + +def test_invalid_dependency_graph_requires_manual_check(node, monkeypatch): + root, _ = node + monkeypatch.setattr(deps.subprocess, 'run', lambda *a, **kw: + subprocess.CompletedProcess(a[0], 1)) + assert deps.inspect_node_dependencies(root, 'node')['status'] == 'needs_manual_check' -def test_root_lifecycle_script_prevents_legacy_adoption(node): - root, _, install = node - install() - (root / 'package.json').write_text('{"scripts":{"prepare":"echo build"}}') - assert deps.ensure_node_dependencies(root, 'node') +def test_unavailable_npm_requires_manual_check(node, monkeypatch): + root, _ = node + monkeypatch.setattr(deps.shutil, 'which', lambda _: None) + assert deps.inspect_node_dependencies(root, 'node')['reason'] == 'npm_unavailable' -def test_unknown_process_visibility_holds_retirement(tmp_path, monkeypatch): - monkeypatch.setattr(deps.shutil, 'which', lambda _: None) - assert deps._in_use(tmp_path.resolve()) +def test_symlink_environment_is_not_followed(node): + root, _ = node + (root / 'node_modules').rename(root / 'other') + (root / 'node_modules').symlink_to(root / 'other', target_is_directory=True) + assert deps.inspect_node_dependencies(root, 'node')['reason'] == 'linked_environment' + assert (root / 'other/example/package.json').exists() -def test_tracked_dependency_files_hold_retirement(node, monkeypatch): - root, _, _ = node - deps.ensure_node_dependencies(root, 'node') - receipt = root / 'node_modules/.loopx-development-dependencies.json' - old = json.loads(receipt.read_text()) - old['last_used'] = time.time() - 8 * 86400 - receipt.write_text(json.dumps(old)) - monkeypatch.setattr(deps.subprocess, 'run', lambda *a, **kw: - subprocess.CompletedProcess(a[0], 0, stdout='node_modules/important')) - monkeypatch.setattr(deps, '_in_use', lambda _: False) - assert deps.retire(root, 'node', days=7, apply=True)['reason'] == 'tracked_files_or_git_unavailable' - assert receipt.exists() +@pytest.mark.parametrize('obsolete', ['setup', 'retire', '--apply']) +def test_mutating_cli_options_are_rejected(monkeypatch, obsolete): + monkeypatch.setattr('sys.argv', ['development_dependencies.py', '--scope', 'node', obsolete]) + with pytest.raises(SystemExit) as result: + deps.main() + assert result.value.code == 2