docs(GOV-001): 建立工程治理与追溯规范

This commit is contained in:
2026-08-12 18:18:55 +08:00
parent 7cc2ba06a6
commit efdfb331bb
46 changed files with 1030 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
import path from 'node:path';
const FEATURE_ID_PATTERN = /^(?:FEAT-[A-Z]{2,8}|GOV)-\d{3}$/;
const REQUIRED_FEATURE_FILES = Object.freeze([
'manifest.yaml',
'requirements.md',
'design.md',
'implementation-plan.md',
'test-plan.md',
'gitnexus/before.md',
'gitnexus/impact-plan.md',
'gitnexus/detected-changes.md',
'gitnexus/after.md',
'gitnexus/comparison.md',
'qa/test-results.md',
'qa/visual-qa.md',
'release-notes.md',
'rollback.md',
]);
/**
* 功能编号是需求、设计、代码提交、测试结果和发布记录之间的主关联键。
* 严格限制格式可以避免路径穿越、临时名称以及后续无法检索的模糊编号。
*/
export function validateFeatureId(featureId) {
return FEATURE_ID_PATTERN.test(featureId);
}
/**
* 功能目录只允许位于 docs/features 下。短名称继续使用英文 kebab-case
* 便于命令行、URL 和跨平台文件系统稳定处理;面向人的名称保存在 manifest 中。
*/
export function getFeatureDirectory(repositoryRoot, featureId, slug) {
if (!validateFeatureId(featureId)) {
throw new Error(`功能编号格式无效:${featureId}`);
}
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
throw new Error(`功能短名称必须使用 kebab-case:${slug}`);
}
return path.join(repositoryRoot, 'docs', 'features', `${featureId}-${slug}`);
}
/**
* 返回副本,防止调用方修改共享清单后绕过完成门禁。
*/
export function getRequiredFeatureFiles() {
return [...REQUIRED_FEATURE_FILES];
}
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getFeatureDirectory,
getRequiredFeatureFiles,
validateFeatureId,
} from './feature-record.mjs';
test('接受带业务域和三位序号的功能编号', () => {
assert.equal(validateFeatureId('FEAT-MO-001'), true);
assert.equal(validateFeatureId('GOV-001'), true);
});
test('拒绝无法追溯或格式含糊的功能编号', () => {
assert.equal(validateFeatureId('market-overview'), false);
assert.equal(validateFeatureId('FEAT-MO-1'), false);
assert.equal(validateFeatureId('../FEAT-MO-001'), false);
});
test('将功能编号和短名称解析到固定的功能档案目录', () => {
assert.equal(
getFeatureDirectory('/repo', 'FEAT-MO-001', 'market-overview'),
'/repo/docs/features/FEAT-MO-001-market-overview',
);
});
test('返回开发完成前必须存在的完整追溯文件清单', () => {
assert.deepEqual(getRequiredFeatureFiles(), [
'manifest.yaml',
'requirements.md',
'design.md',
'implementation-plan.md',
'test-plan.md',
'gitnexus/before.md',
'gitnexus/impact-plan.md',
'gitnexus/detected-changes.md',
'gitnexus/after.md',
'gitnexus/comparison.md',
'qa/test-results.md',
'qa/visual-qa.md',
'release-notes.md',
'rollback.md',
]);
});
+65
View File
@@ -0,0 +1,65 @@
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import {
getFeatureDirectory,
getRequiredFeatureFiles,
validateFeatureId,
} from './feature-record.mjs';
const [featureId, slug] = process.argv.slice(2);
const repositoryRoot = process.cwd();
if (!featureId || !slug || !validateFeatureId(featureId)) {
throw new Error('用法:npm run governance:finish -- <功能编号> <英文短名称>');
}
const featureDirectory = getFeatureDirectory(repositoryRoot, featureId, slug);
if (!existsSync(featureDirectory)) {
throw new Error(`功能档案不存在:${featureDirectory}`);
}
function run(command, args) {
return execFileSync(command, args, {
cwd: repositoryRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
/**
* 完成门禁先验证仓库自身的治理测试,再刷新 GitNexus。业务项目接入后,
* 根目录 verify 脚本会继续串联前端和后端测试,而不需要改变本门禁入口。
*/
run('npm', ['run', 'test:governance']);
run('npx', ['gitnexus', 'analyze']);
const status = run('npx', ['gitnexus', 'status']);
const diffSummary = run('git', ['diff', '--stat']) || '(没有未提交差异)';
const worktreeSummary = run('git', ['status', '--short']) || '(工作区干净)';
const afterReport = path.join(featureDirectory, 'gitnexus', 'after.md');
if (!existsSync(afterReport) || readFileSync(afterReport, 'utf8').trim().length === 0) {
writeFileSync(
afterReport,
`# GitNexus 开发后基线\n\n` +
`## GitNexus 状态\n\n\`\`\`text\n${status}\n\`\`\`\n\n` +
`## Git 已跟踪变更摘要\n\n\`\`\`text\n${diffSummary}\n\`\`\`\n\n` +
`## Git 工作区摘要\n\n\`\`\`text\n${worktreeSummary}\n\`\`\`\n`,
'utf8',
);
}
const missingFiles = getRequiredFeatureFiles().filter((relativeFile) => {
const file = path.join(featureDirectory, relativeFile);
return !existsSync(file) || readFileSync(file, 'utf8').trim().length === 0;
});
if (missingFiles.length > 0) {
throw new Error(`完成门禁未通过,缺少或为空:\n- ${missingFiles.join('\n- ')}`);
}
process.stdout.write(
`基础门禁已通过。提交前仍须人工确认 GitNexus 影响报告、业务测试、视觉 QA 和回退说明。\n`,
);
+63
View File
@@ -0,0 +1,63 @@
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { getFeatureDirectory, validateFeatureId } from './feature-record.mjs';
const [featureId, slug] = process.argv.slice(2);
const repositoryRoot = process.cwd();
if (!featureId || !slug || !validateFeatureId(featureId)) {
throw new Error('用法:npm run governance:start -- <功能编号> <英文短名称>');
}
const featureDirectory = getFeatureDirectory(repositoryRoot, featureId, slug);
const gitnexusDirectory = path.join(featureDirectory, 'gitnexus');
const beforeReport = path.join(gitnexusDirectory, 'before.md');
if (existsSync(beforeReport)) {
throw new Error(`开发前报告已存在,为保护历史不会覆盖:${beforeReport}`);
}
mkdirSync(gitnexusDirectory, { recursive: true });
/**
* 开发门禁直接调用参数数组,不拼接 shell 字符串,避免功能名称被解释为命令。
* stdio 使用 pipe 是为了把同一次运行的真实输出固化到功能档案中。
*/
function run(command, args) {
return execFileSync(command, args, {
cwd: repositoryRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
const branch = run('git', ['branch', '--show-current']) || '(尚无分支)';
const commit = run('git', ['rev-parse', '--verify', 'HEAD']).slice(0, 12);
const worktree = run('git', ['status', '--short']) || '(干净)';
run('npx', ['gitnexus', 'analyze']);
const gitnexusStatus = run('npx', ['gitnexus', 'status']);
const createdAt = new Intl.DateTimeFormat('zh-CN', {
dateStyle: 'long',
timeStyle: 'long',
timeZone: 'Asia/Shanghai',
}).format(new Date());
writeFileSync(
beforeReport,
`# GitNexus 开发前基线\n\n` +
`- 功能编号:${featureId}\n` +
`- 记录时间:${createdAt}\n` +
`- 分支:${branch}\n` +
`- 基线提交:${commit}\n\n` +
`## 工作区\n\n\`\`\`text\n${worktree}\n\`\`\`\n\n` +
`## GitNexus 状态\n\n\`\`\`text\n${gitnexusStatus}\n\`\`\`\n\n` +
`## 后续人工分析\n\n` +
`在修改代码前,将 GitNexus query、context、impact 的结论写入 ` +
`\`gitnexus/impact-plan.md\`。高风险结果必须先获得用户确认。\n`,
'utf8',
);
process.stdout.write(`已创建开发前基线:${beforeReport}\n`);