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 文件的修改也进入审查,确认新增例外的用途以及检查范围的变化。
交付记录附上检查基线、改动文件、测试结果和构建结果。审查时先核对这些记录,再检查业务判断;页面联调则按交互约定逐项操作,保留失败路径和异常反馈的验证结果。
- 本文标签: Vue
- 本文链接: https://www.tianyajuanke.top/article/104
- 版权声明: 本文由吴沛芙原创发布,转载请遵循《署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)》许可协议授权