AI 修改依赖时,真正的风险往往不在 package.json 的几行 diff,而在整个依赖图、构建脚本和运行时边界的变化。本文用 Node.js 实现一套轻量的影响分析脚本,并把分析结果接入测试、人工确认和自动回滚门禁,让依赖变更具备可审计的工程流程。

依赖变更为什么不能只看代码 Diff

AI 编程助手通常会围绕当前错误给出一个局部修复。例如,为了解决 TypeScript 类型错误,它可能升级 typescript;为了修复测试失败,它可能调整 vitestjest;为了绕过构建问题,它还可能修改 bundler、插件和 Node.js 版本约束。

这些修改看起来只是几行配置,但实际影响可能沿着依赖图继续扩散:

变化位置可能影响
dependencies生产启动、容器镜像和线上运行时行为
devDependencies测试、构建、代码生成和发布流程
peerDependencies宿主应用的兼容性以及安装结果
package-lock.json间接依赖版本、解析路径和完整性校验
scriptsCI 命令、发布命令以及构建入口
Node.js 或包管理器版本原生模块编译、条件导出和锁文件格式

因此,依赖变更的审查对象至少应该包括三部分:发生了什么变化、哪些包和脚本可能受到影响、这些影响是否跨越了生产运行时边界。代码 Diff 仍然重要,但它无法替代依赖图和安装结果的审计。

从 lockfile 还原依赖图

以 npm 生成的 package-lock.json 为例,lockfile v2 和 v3 都包含 packages 字段。根项目位于空字符串键 "" 下,其他键通常类似 node_modules/expressnode_modules/@types/node。每个节点可以提供 versiondependenciesoptionalDependenciesdev 等信息。

下面的脚本读取旧、新两个 lockfile,并报告版本变化、被影响的上游包以及可能相关的 npm scripts。它只使用 Node.js 内置模块,保存为 scripts/dependency-impact.mjs 后即可运行。

import { readFile } from "node:fs/promises";

const [beforeFile, afterFile, packageFile = "package.json"] = process.argv.slice(2);

if (!beforeFile || !afterFile) {
  console.error("用法: node scripts/dependency-impact.mjs before-lock.json after-lock.json package.json");
  process.exit(2);
}

const loadJson = async (file) => JSON.parse(await readFile(file, "utf8"));
const before = await loadJson(beforeFile);
const after = await loadJson(afterFile);
const manifest = await loadJson(packageFile);

function packageName(lockKey) {
  const marker = "node_modules/";
  const index = lockKey.lastIndexOf(marker);
  return index === -1 ? lockKey : lockKey.slice(index + marker.length);
}

function toMap(lockfile) {
  const result = new Map();
  for (const [key, value] of Object.entries(lockfile.packages ?? {})) {
    if (!key || !value.version) continue;
    result.set(packageName(key), {
      version: value.version,
      dependencies: {
        ...(value.dependencies ?? {}),
        ...(value.optionalDependencies ?? {})
      },
      dev: value.dev === true,
      optional: value.optional === true
    });
  }
  return result;
}

function directNames(section) {
  return Object.keys(manifest[section] ?? {});
}

function buildReverseGraph(nodes) {
  const reverse = new Map();
  for (const [parent, info] of nodes) {
    for (const child of Object.keys(info.dependencies)) {
      if (!reverse.has(child)) reverse.set(child, new Set());
      reverse.get(child).add(parent);
    }
  }
  return reverse;
}

function collectParents(reverse, names) {
  const result = new Set(names);
  const queue = [...names];
  while (queue.length) {
    const current = queue.shift();
    for (const parent of reverse.get(current) ?? []) {
      if (!result.has(parent)) {
        result.add(parent);
        queue.push(parent);
      }
    }
  }
  return result;
}

const oldNodes = toMap(before);
const newNodes = toMap(after);
const changed = [];
const allNames = new Set([...oldNodes.keys(), ...newNodes.keys()]);

for (const name of [...allNames].sort()) {
  const oldVersion = oldNodes.get(name)?.version ?? "<removed>";
  const newVersion = newNodes.get(name)?.version ?? "<added>";
  if (oldVersion !== newVersion) changed.push({ name, oldVersion, newVersion });
}

const reverse = buildReverseGraph(newNodes);
const affected = collectParents(reverse, changed.map((item) => item.name));
const runtimeRoots = new Set([
  ...directNames("dependencies"),
  ...directNames("optionalDependencies")
]);
const runtimeAffected = [...affected].filter((name) => runtimeRoots.has(name));
const scriptText = Object.entries(manifest.scripts ?? {})
  .filter(([, command]) => [...affected].some((name) => command.includes(name)))
  .map(([name, command]) => ({ name, command }));

console.log(JSON.stringify({
  changed,
  affected: [...affected].sort(),
  runtimeAffected: runtimeAffected.sort(),
  scripts: scriptText
}, null, 2));

运行命令如下:

node scripts/dependency-impact.mjs package-lock.before.json package-lock.json package.json

这里的图是按包名聚合的简化图。npm 在嵌套目录中可能同时安装同名包的多个版本,生产级工具应进一步以 lockfile 的完整路径作为节点,并解析每个节点的精确依赖范围。这个简化版本适合做第一道门禁,也足以发现大量“只改了一个包,实际影响了多个脚本”的情况。

识别运行时和构建边界

依赖类型本身不是风险等级。devDependencies 中的包可能决定生产构建产物,dependencies 中的包也可能只在开发命令中使用。判断边界时,建议同时使用声明位置、脚本引用和构建入口。

可以把依赖分为三类进行审查:

  1. 生产运行时:从 dependenciesoptionalDependencies 出发,沿依赖图追踪变更。涉及这些节点时,应执行应用启动、关键接口或最小容器验证。
  2. 构建与测试:从 devDependenciesscripts、TypeScript 配置、bundler 配置和代码生成命令出发。涉及这些节点时,至少执行类型检查、单元测试和生产构建。
  3. 安装与发布:关注 lockfile 版本、原生模块、prepareinstallpostinstall 等生命周期脚本,以及 Node.js 和 npm 的版本约束。

上面的脚本会输出 runtimeAffectedscripts,但它不能判断一个包是否被动态加载,也不能理解 shell 变量、工作区任务编排和配置文件中的间接引用。因此,脚本输出应当作为审查清单,而不是“没有输出就绝对安全”的证明。

在 CI 中可以增加几条基础检查:

npm ci
npm run typecheck --if-present
npm test --if-present
npm run build --if-present
npm audit --omit=dev

npm ci 会根据 lockfile 安装依赖,并在清单和锁文件不一致时失败。它适合验证提交物是否可复现,但不会自动证明升级后的行为正确。npm audit 也不应被当作完整的风险判断,因为漏洞数据库、可达性和业务暴露面仍需要人工分析。

设计可审计的 CI 门禁

门禁的关键不是把所有依赖变更都拦住,而是让风险与验证要求对应起来。一个可落地的流程可以分为四步:

阶段自动动作失败或升级条件
变更识别对比 package.json、lockfile、Node.js 版本和脚本出现未声明的 lockfile 变化
影响分析输出变更包、反向依赖、运行时边界和脚本触达生产依赖或发布脚本
自动验证npm ci、测试、类型检查、构建和最小启动任一关键命令失败或超时
人工确认在 PR 中确认升级理由、替代方案和回滚版本重大版本、原生模块或生产运行时变化

可以将分析结果写成 CI artifact,同时在 Pull Request 中展示摘要。人工确认不应只写“已检查”,至少要包含以下信息:

  • AI 修改依赖的原因,以及对应的问题或测试用例。
  • 直接升级包和间接变化包的名称、旧版本、新版本。
  • 是否触达生产运行时、构建产物、数据库驱动、认证组件或发布流程。
  • 回滚方式,是恢复清单和 lockfile,还是使用上一版构建产物。
  • 对上游破坏性变更的确认,包括迁移说明、弃用 API 和 Node.js 版本要求。

对于工作区项目,建议在根目录统一执行 lockfile 对比,同时为受影响的 workspace 运行对应测试。不要只在 AI 修改的子目录中执行测试,因为依赖解析和构建缓存通常是仓库级行为。

自动回滚条件如何落地

“失败就回滚”需要先定义失败。依赖门禁中比较适合自动处理的条件包括:安装失败、lockfile 校验失败、类型检查失败、测试失败、生产构建失败、应用启动后健康检查失败,以及关键 smoke test 超时。

自动回滚不应直接修改主分支历史。更稳妥的做法是让候选变更在临时分支或候选构建中验证,通过后再合并;未通过时标记检查失败并停止发布。已经进入部署阶段时,则回滚到上一个经过验证的制品,而不是现场重新执行一次依赖安装。

例如,应用可以使用一个最小健康检查命令:

set -Eeuo pipefail

npm ci
npm run typecheck --if-present
npm test --if-present
npm run build --if-present
npm start > /tmp/app.log 2>&1 &
PID=$!
trap 'kill "$PID" 2>/dev/null || true' EXIT

for attempt in 1 2 3 4 5; do
  if curl --fail --silent http://127.0.0.1:3000/health >/dev/null; then
    exit 0
  fi
  sleep 2
done

cat /tmp/app.log
exit 1

这段命令假设项目提供 /health 接口,并且 npm start 监听 3000 端口;实际接入时应替换为项目已有的启动和探活方式。门禁还应保留候选 lockfile、构建日志、测试报告和依赖分析 JSON,便于定位到底是哪个边界发生了变化。

需要特别注意的是,依赖降级也可能引入风险。回滚前应确认旧版本制品仍然存在、数据库迁移没有依赖新代码、消息格式没有发生不兼容变化。依赖回滚只能恢复软件版本,不能自动撤销已经执行的外部副作用。

总结

依赖升级应当被视为一次工程变更,而不是 AI 输出中的附带修改。可执行的最低实践包括:

  • 同时审查 package.json、lockfile、脚本、Node.js 版本和构建配置。
  • 用依赖图找出变更包的反向依赖,并区分运行时、构建测试和发布边界。
  • 在 CI 中固定执行 npm ci、类型检查、测试、构建和最小启动验证。
  • 对生产依赖、重大版本、原生模块和发布脚本设置人工确认。
  • 把失败条件、候选制品和回滚目标提前定义,避免失败后临时决策。

这套方法不会消除依赖升级的风险,也不能替代维护者对上游变更的理解。它的价值在于把“AI 顺手改了什么”转化为结构化报告、可重复验证和明确的发布决策。