原创

AI 辅助开发:用项目约定、契约测试和提交检查控制代码改动

使用AI开发了很多外包H5项目,经常出现同一个错误反复出现的问题,而且有些问题要接上业务的时候才看的出来。

比如页面直接调用底层请求,跳过已经封装好的业务 API;同一个帖子 ID 在不同页面里用了不同字段;接口还没返回,定时器先把 Loading 关掉,再显示操作成功。代码可以运行,页面看起来也完整,但后续接接口、处理异常时就会反复修改。

然后就开始写这个AGENTG.md了。文中的代码选了接口字段、请求状态和改动范围几个例子,便于说明它们怎样配合;文件路径按示例目录组织,接入项目时对应到现有目录。

一、把经常重复说明的要求放进仓库

项目里用三份文件分开记录:

文件 记录什么
AGENTS.md 修改代码前的阅读入口、任务记录方式和检查命令
DEVELOPMENT_CONVENTIONS.md 按任务和文件路径查找规则模块的索引
INTERACTION_CONVENTIONS.md 用户操作后的反馈、成功结果和失败处理

具体实现约定放在 conventions/ 下。这样修改一个接口时,不用把所有页面布局、WebView 适配和交互细节重新读一遍。

AGENTS.md 在支持这一格式的编码工具中可以作为项目指令入口;其他工具需要通过自己的项目配置或任务说明引入。接入时确认工具的实际读取方式,代码检查通过独立命令执行。AGENTS.md 格式说明

入口文件可以保持简短:

# 开发入口

1. 修改前读取 DEVELOPMENT_CONVENTIONS.md,按任务和文件路径确定规则模块。
2. 读取命中的模块及其依赖;涉及用户操作时,同时读取 INTERACTION_CONVENTIONS.md。
3. 在任务记录中写清目标、计划修改的文件、接口依据和验收方式。
4. 新增依赖或扩大修改范围时,更新任务记录,并重新确认适用规则。
5. 实现完成后运行格式、约定、契约测试和构建检查,记录实际结果。
6. 检查失败时保留失败信息,不通过删除断言、扩大豁免范围或关闭规则完成任务。

长任务的进度也写进文件,记录已经完成的内容、实际修改文件、执行过的命令以及下一步。换对话或中断后,可以从这些记录继续。

## 帖子列表接入

目标:列表展示真实接口数据,保留筛选条件。
计划文件:帖子 API、字段转换、列表页面、相关测试。
接口依据:项目中已确认的接口定义及响应样例。
已完成:字段转换和异常响应测试。
验证结果:记录命令、退出码,以及失败用例或通过数量。
下一步:接入页面,验证首次加载、空列表和失败重试。

恢复任务时,先用当前代码和 Git 差异核对记录,补上期间发生的改动。

二、按任务读取规则,同一条要求只维护一份

开发约定后来拆成了多个模块。索引里除了触发条件,还写明依赖,避免只读了某个页面模块,却漏掉它依赖的 API 约定。

下面列出与本文示例有关的四个模块:

模块 主要内容 依赖
00-core-platform.md 技术栈、目录和通用开发要求 无
11-api-network.md 请求入口、响应协议、鉴权和网络错误处理 00
01-page-api-data.md 接口数据转换为页面模型、页面使用哪些字段 00、11
04-feedback-loading.md 请求状态与反馈控件的实现方式 00、11

修改业务 API,先读 00 和 11;页面接入接口,再读 01;涉及 Loading 或错误反馈,再读 04。布局、页面返回和 WKWebView 等模块继续按任务追加。

路由同时看任务内容和实际改动文件。任务虽然叫“调整按钮样式”,如果最终改到了请求函数,就需要补读 API 规则。无法确定时先查看相关模块,不把“关键词没匹配上”当成没有约束。

同一条要求只放在一个地方维护。例如“页面通过业务 API 访问数据”由 11 模块负责,页面数据模块直接链接这条规则。01 模块集中记录字段转换与页面数据使用。

规则还可以带一个稳定编号,方便脚本报错时找到原文:

API-001:页面通过业务 API 获取数据,不直接依赖底层请求模块。
DATA-001:接口字段在转换函数中映射,页面不混用多个别名。
UI-001:提交状态跟随真实请求,成功提示由接口确认结果触发。
STORAGE-001:持久化存储通过指定模块访问,允许保存的数据由该模块明确列出。

修改规则时,先改负责它的模块,再更新索引、引用和对应检查。规则解释不清时先把业务要求确定下来,避免出现文档要求一种行为、测试却断言另一种行为。

三、接口字段在一处转换,页面不猜字段名

接口字段和页面字段不一致时,我把转换放在业务 API 与页面之间。后端协议保持原样,页面只使用转换后的结构。

下面用一个简化的帖子响应说明转换关系:接口返回 camelCase 字段,页面使用 snake_case 字段。

{
  "postId": "post-101",
  "avatarUrl": "https://example.com/avatar.png",
  "content": "今天的开发记录"
}

对应 src/api/post-model.mjs:

export function toPostModel(dto) {
  if (
    !dto ||
    typeof dto.postId !== 'string' ||
    dto.postId.trim() === '' ||
    typeof dto.avatarUrl !== 'string' ||
    typeof dto.content !== 'string'
  ) {
    throw new TypeError('DATA-001:帖子响应不符合约定');
  }

  return {
    post_id: dto.postId,
    avatar_url: dto.avatarUrl,
    content: dto.content,
  };
}

页面通过转换函数读取 post_id 和 avatar_url。新旧接口的兼容也集中在这里处理,具体映射以已确认的协议为准。

给转换函数配上输入和输出样例,检查字段映射以及缺失字段时的处理。scripts/post-contract.test.mjs 中直接使用 Node 的测试模块:

import test from 'node:test';
import assert from 'node:assert/strict';
import { toPostModel } from '../src/api/post-model.mjs';

// 响应结构取自本文的简化接口约定。
test('把接口字段转换成页面字段', () => {
  assert.deepEqual(
    toPostModel({ postId: 'post-101', avatarUrl: '/avatar.png', content: '记录' }),
    { post_id: 'post-101', avatar_url: '/avatar.png', content: '记录' },
  );
});

test('缺少 postId 时拒绝别名输入', () => {
  assert.throws(
    () => toPostModel({ dynamicId: 'post-101', avatarUrl: '', content: '记录' }),
    /DATA-001/,
  );
});

test('缺失必需字段时暴露协议变化', () => {
  assert.throws(() => toPostModel({ postId: 'post-101' }), /DATA-001/);
});

测试样例取自已确认的接口定义。联调时再核对真实响应,接口有变动就同步修改转换函数和测试样例。

四、交互约定写清用户会看到什么

实现规则之外,交互还需要单独描述。比如一次提交操作,要确定提交期间哪些按钮可用、成功后是否跳转、失败后保留哪些输入。

项目里用 feedback_mode 记录反馈的位置:

模式 适合的交互
immediate 本地展开、收起、切换显示等立即完成的操作
control_pending 当前按钮、输入区或单条消息正在提交
region_loading 列表或局部区域正在加载
page_action_loading 当前页面正在完成需要等待的业务操作
confirm_then_loading 先确认,再执行操作并显示等待状态

这几类反馈由项目中的组件和页面逻辑实现。每个入口同时记录反馈位置、成功结果和失败处理,开发时按这三项接入。

例如订单提交使用 control_pending:请求未完成时保持等待、阻止重复提交;服务端确认成功后才显示成功。失败后保留输入并允许重试。点赞这类可恢复操作可以使用乐观更新。服务端明确拒绝时恢复原状态;网络超时后查询服务端状态,再决定重试或更新界面。

下面用一个等待服务端确认的提交控制器说明时序,文件为 src/actions/submit-action.mjs:

export function createSubmitAction({ submit, onChange }) {
  let state = { pending: false, result: null, error: null };

  function update(patch) {
    state = { ...state, ...patch };
    onChange({ ...state });
  }

  return {
    getState: () => ({ ...state }),
    async run(payload) {
      if (state.pending) return { status: 'busy' };
      update({ pending: true, result: null, error: null });
      try {
        const result = await submit(payload);
        update({ result });
        return { status: 'success', result };
      } catch (error) {
        update({ error });
        throw error;
      } finally {
        update({ pending: false });
      }
    },
  };
}

这里的 submit 是业务 API:只有服务端确认业务成功才 resolve,HTTP 200 中携带的业务失败也要转换成 reject。页面根据 status: 'success' 展示成功提示;onChange 只负责更新页面状态,不执行可能抛错的业务操作。

测试里保留一个尚未完成的 Promise,检查请求返回前控件是否持续处于 pending,再通过 resolve 和 reject 检查成功、失败两条路径。防抖、节流和提示停留时间继续使用各自的定时逻辑。

scripts/submit-action.test.mjs:

import test from 'node:test';
import assert from 'node:assert/strict';
import { createSubmitAction } from '../src/actions/submit-action.mjs';

function deferred() {
  let resolve;
  let reject;
  const promise = new Promise((done, fail) => {
    resolve = done;
    reject = fail;
  });
  return { promise, resolve, reject };
}

test('提交状态持续到请求完成,期间重复提交不再发请求', async () => {
  const request = deferred();
  let calls = 0;
  const action = createSubmitAction({
    submit: () => { calls += 1; return request.promise; },
    onChange: () => {},
  });
  const running = action.run({ content: '记录' });
  assert.equal(action.getState().pending, true);
  assert.equal(action.getState().result, null);
  assert.equal((await action.run({ content: '重复提交' })).status, 'busy');
  assert.equal(calls, 1);

  request.resolve({ id: 'saved-1' });
  assert.equal((await running).status, 'success');
  assert.equal(action.getState().pending, false);
  assert.deepEqual(action.getState().result, { id: 'saved-1' });
});

test('失败时不生成成功结果,结束等待后可以再次提交', async () => {
  let calls = 0;
  const action = createSubmitAction({
    submit: async () => {
      calls += 1;
      if (calls === 1) throw new Error('服务端拒绝提交');
      return { id: 'saved-2' };
    },
    onChange: () => {},
  });
  await assert.rejects(action.run({}), /服务端拒绝提交/);
  assert.equal(action.getState().pending, false);
  assert.equal(action.getState().result, null);
  assert.equal((await action.run({})).status, 'success');
});

这个控制器管理一个交互入口的请求状态。是否真正禁用了按钮、页面离开后如何处理请求,以及其他页面是否同步更新,还需要组件或页面测试。跨页面共用的数据放在统一状态层更新,各页面订阅同一份状态。

五、静态约定交给代码检查

“页面通过业务 API 请求数据”“本地存储通过指定模块访问”,可以用语法树检查调用位置。字符串搜索容易把注释和示例当成代码,也容易漏掉换行或不同引用方式,所以这里用 ESLint 处理常见调用。

示例采用 JavaScript、Vue 3 和 ESLint flat config。checkGlobalObject 选项需要支持它的 ESLint 版本,例如 9.33 及以上;依赖通过项目的 package.json 和锁文件固定。Vue 文件使用 eslint-plugin-vue 的解析配置。Vue 插件配置

eslint.config.mjs 中加入:

import pluginVue from 'eslint-plugin-vue';
import globals from 'globals';

const storageCalls = [
  'getStorage', 'getStorageSync', 'setStorage', 'setStorageSync',
  'removeStorage', 'removeStorageSync', 'clearStorage', 'clearStorageSync',
].map((property) => ({
  object: 'uni', property,
  message: 'STORAGE-001:通过指定存储模块访问本地数据。',
}));

const restrictedGlobals = (names) => ['error', {
  globals: names,
  checkGlobalObject: true,
}];

export default [
  { ignores: ['dist/**', 'unpackage/**', 'node_modules/**'] },
  ...pluginVue.configs['flat/essential'],
  {
    files: ['src/**/*.{js,mjs,vue}'],
    linterOptions: { noInlineConfig: true },
    languageOptions: {
      globals: { ...globals.browser, uni: 'readonly' },
    },
  },
  {
    files: ['src/**/*.{js,mjs,vue}'],
    ignores: ['src/infra/storage/uiPreferences.js'],
    rules: {
      'no-restricted-globals': restrictedGlobals(['localStorage', 'sessionStorage']),
      'no-restricted-properties': ['error', ...storageCalls],
    },
  },
  {
    files: ['src/pages/**/*.{js,mjs,vue}'],
    rules: {
      'no-restricted-globals': restrictedGlobals([
        'fetch', 'XMLHttpRequest', 'localStorage', 'sessionStorage',
      ]),
      'no-restricted-properties': ['error', ...storageCalls,
        ...['request', 'uploadFile', 'downloadFile'].map((property) => ({
          object: 'uni', property,
          message: 'API-001:页面调用业务 API,不直接访问网络。',
        })),
      ],
      'no-restricted-imports': ['error', {
        patterns: [{
          group: [
            'axios', 'axios/**',
            '@/infra/http', '@/infra/http.*', '@/infra/http/**',
            '**/infra/http', '**/infra/http.*', '**/infra/http/**',
          ],
          message: 'API-001:页面通过业务 API 访问数据。',
        }],
      }],
    },
  },
];

checkGlobalObject 同时检查直接使用全局变量和 window.fetch、globalThis.localStorage 这类访问。限制导入的规则检查列出的模块路径。这组配置检查列出的静态引用和全局调用;转发模块与运行时动态引用在代码审查时结合调用链确认。全局变量限制、导入限制

存储模块的例外精确到文件,文件内部再限定可保存的内容。示例中的 uiPreferences.js 只提供主题设置接口:

const THEME_KEY = 'ui.theme';
const THEMES = new Set(['light', 'dark', 'system']);

export function saveTheme(theme) {
  if (!THEMES.has(theme)) throw new TypeError('不支持的主题');
  localStorage.setItem(THEME_KEY, theme);
}

export function readTheme() {
  const theme = localStorage.getItem(THEME_KEY);
  return THEMES.has(theme) ? theme : 'system';
}

页面调用 saveTheme() 时只传主题名称,存储 key 固定在模块内。登录会话由专用模块管理,订单和帖子列表从业务 API 获取,权限判断按服务端结果处理。

六、检查范围从 Git 改动获取

日常检查的文件清单直接从 Git 获取,再与任务记录核对,确认本次修改涉及哪些文件。

这里用任务开始时的提交作为基线,从 Git 获取已跟踪文件的变化,再加上未跟踪文件。删除和重命名也参与判断;删除的文件不交给 ESLint 读取,但删除规则文件仍会触发检查。

scripts/check-conventions.mjs:

import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { ESLint } from 'eslint';

function git(args, cwd = process.cwd()) {
  return execFileSync('git', args, { cwd, encoding: 'utf8' });
}

async function main() {
  const root = git(['rev-parse', '--show-toplevel']).trim();
  const baseRef = process.env.CONVENTIONS_BASE;
  if (!baseRef) throw new Error('缺少 CONVENTIONS_BASE:请指定任务或 PR 的检查基线');
  const base = git([
    'rev-parse', '--verify', '--end-of-options', `${baseRef}^{commit}`,
  ], root).trim();
  const splitPaths = (value) => value.split('\0').filter(Boolean);
  const changed = [...new Set([
    ...splitPaths(git(['diff', '--name-only', '--no-renames', '-z', base, '--'], root)),
    ...splitPaths(git(['ls-files', '--others', '--exclude-standard', '-z'], root)),
  ])];

  const required = [
    'AGENTS.md',
    'DEVELOPMENT_CONVENTIONS.md',
    'INTERACTION_CONVENTIONS.md',
    'conventions/00-core-platform.md',
    'conventions/11-api-network.md',
    'conventions/01-page-api-data.md',
    'conventions/04-feedback-loading.md',
  ];
  for (const file of required) {
    if (!existsSync(path.join(root, file))) {
      throw new Error(`缺少规则文件:${file}`);
    }
  }

  const affectsAll = changed.some((file) =>
    required.includes(file) ||
    /^(conventions\/|scripts\/|\.github\/|\.gitlab-ci)/.test(file) ||
    /^src\/(api|infra|utils|components|store|stores|composables)\//.test(file) ||
    /(^|\/)(package\.json|pnpm-lock\.yaml|\.npmrc|\.eslintignore)$/.test(file) ||
    /(^|\/)(eslint\.config\.|vite\.config\.|tsconfig)/.test(file)
  );
  const targets = affectsAll ? ['src'] : changed.filter((file) =>
    /^src\/.*\.(js|mjs|vue)$/.test(file) && existsSync(path.join(root, file))
  );
  console.log(JSON.stringify({ base, mode: affectsAll ? 'full' : 'changed', targets }, null, 2));
  if (!targets.length) return;

  const eslint = new ESLint({ cwd: root, allowInlineConfig: false });
  const results = await eslint.lintFiles(targets);
  const formatter = await eslint.loadFormatter('stylish');
  const output = formatter.format(results);
  if (output) console.log(output);
  if (results.some((result) => result.errorCount > 0 || result.warningCount > 0)) {
    throw new Error('约定检查未通过');
  }
}

main().catch((error) => {
  console.error(error.message);
  process.exitCode = 1;
});

git diff <基线> 比较当前工作区和指定提交,配合未跟踪文件列表,可以覆盖暂存、未暂存和新增文件。文件名用 NUL 分隔,避免中文、空格等路径被拆错。--no-renames 把重命名作为旧路径删除和新路径增加来处理,规则路径变化不会因为只看到新名字而漏掉。Git diff 参数说明

普通页面修改检查改动文件;规则、检查脚本、依赖、构建配置以及公共 API、组件、状态管理等目录发生变化时,扩大到 src。这些目录按示例结构列出,仓库目录调整时与脚本一起维护。局部修改尽早检查,影响公共代码的改动执行全量检查和构建。

这个示例检查 JavaScript 和 Vue 文件。TypeScript 项目配置对应解析器、扩展文件匹配规则,并在交付命令中加入项目的类型检查。ESLint Node.js API

Git 差异包含当前工作区的所有相关改动。多个任务并行时,各自使用独立分支和工作目录;开始任务前记录已有的未提交修改,交付时据此核对文件归属。

七、交付命令失败后停止,不只打印一份日志

示例中的 package.json 增加以下脚本,原有的构建脚本继续保留:

{
  "scripts": {
    "check:format": "prettier --check .",
    "check:conventions": "node scripts/check-conventions.mjs",
    "test:contracts": "node --test scripts/post-contract.test.mjs scripts/submit-action.test.mjs"
  }
}

任务开始时记录基线,之后一直使用同一个值,不在检查前临时改成最新提交:

$env:CONVENTIONS_BASE = (git rev-parse HEAD).Trim()

完成实现后执行交付检查。每条外部命令结束后读取退出码,非零时立即停止,保留当前错误信息:

function Invoke-Checked {
    param([string]$Program, [string[]]$Arguments)
    Get-Command -Name $Program -ErrorAction Stop | Out-Null
    & $Program @Arguments
    if ($LASTEXITCODE -ne 0) {
        throw "检查失败:$Program,退出码 $LASTEXITCODE"
    }
}

if (-not $env:CONVENTIONS_BASE) {
    throw '缺少任务开始时记录的 CONVENTIONS_BASE'
}
Invoke-Checked -Program 'corepack' -Arguments @('pnpm', 'check:format')
Invoke-Checked -Program 'corepack' -Arguments @('pnpm', 'check:conventions')
Invoke-Checked -Program 'corepack' -Arguments @('pnpm', 'test:contracts')
Invoke-Checked -Program 'git' -Arguments @('diff', '--check', $env:CONVENTIONS_BASE, '--')
Invoke-Checked -Program 'corepack' -Arguments @('pnpm', 'build:h5')

这里沿用项目里的 Corepack、pnpm 和 build:h5 入口,相关工具由开发环境预先提供。测试使用一次性命令;页面联调需要启动服务时,记录由当前任务启动的进程,结束后清理这些进程,不停止其他任务正在使用的服务。

本地检查方便尽早发现问题,合并时还需要由 CI 执行同一套命令。CI 的基线取 PR 目标分支与当前提交的共同祖先,检出历史要足够计算这一提交;计算失败时直接失败,不回退成只检查 HEAD。

仓库使用受保护分支时,把这些 CI 检查配置为合并条件。规则、白名单和 CI 文件的修改也进入审查,确认新增例外的用途以及检查范围的变化。

交付记录附上检查基线、改动文件、测试结果和构建结果。审查时先核对这些记录,再检查业务判断;页面联调则按交互约定逐项操作,保留失败路径和异常反馈的验证结果。

正文到此结束