fix(GOV-009): 完成最终复审整改
This commit is contained in:
@@ -20,6 +20,12 @@ async function collectSourceFiles(directory) {
|
||||
|
||||
function extractTypeScriptSource(sourcePath, sourceText) {
|
||||
if (path.extname(sourcePath) !== '.vue') return sourceText;
|
||||
const externalScript = sourceText.match(/<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/i);
|
||||
if (externalScript) {
|
||||
throw new Error(
|
||||
`Web Vue 组件禁止使用外部 script src:${path.basename(sourcePath)} -> ${externalScript[1]}`,
|
||||
);
|
||||
}
|
||||
// 边界门禁只需解析 SFC 的脚本块;模板和样式不会产生 TypeScript 模块依赖。
|
||||
return [...sourceText.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)]
|
||||
.map((match) => match[1])
|
||||
@@ -65,6 +71,9 @@ function resolvesInsideServer(specifier, sourcePath, repositoryRoot, compilerOpt
|
||||
return candidate === serverRoot || candidate.startsWith(`${serverRoot}${path.sep}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描 Web TypeScript 与 Vue 脚本,阻断对 Server 源码和内部消息契约的反向依赖。
|
||||
*/
|
||||
export async function verifyProjectBoundaries(repositoryRoot) {
|
||||
const webSourceRoot = path.join(repositoryRoot, 'web', 'src');
|
||||
const configPath = path.join(repositoryRoot, 'web', 'tsconfig.json');
|
||||
|
||||
@@ -108,6 +108,20 @@ const value: Internal | undefined = undefined;
|
||||
assert.match(result.stderr, /IllegalView\.vue/);
|
||||
});
|
||||
|
||||
test('拒绝 Vue 外部 script src 绕过源码边界', async () => {
|
||||
const repositoryRoot = await createFixture();
|
||||
await writeFile(
|
||||
path.join(repositoryRoot, 'web/src/ExternalScript.vue'),
|
||||
`<script setup lang="ts" src="../../server/libs/internal.ts"></script>\n<template><main /></template>\n`,
|
||||
);
|
||||
|
||||
const result = await runChecker(repositoryRoot);
|
||||
|
||||
assert.equal(result.exitCode, 1);
|
||||
assert.match(result.stderr, /禁止使用外部 script src/);
|
||||
assert.match(result.stderr, /ExternalScript\.vue/);
|
||||
});
|
||||
|
||||
test('接受只消费公开 OpenAPI 类型的 Web 工程', async () => {
|
||||
const repositoryRoot = await createFixture();
|
||||
await writeFile(
|
||||
|
||||
@@ -8,6 +8,18 @@ import { fileURLToPath } from 'node:url';
|
||||
* 避免用户其他工作区改动干扰契约门禁。
|
||||
*/
|
||||
export function assertGeneratedContractsUnchanged(repositoryRoot, contractFiles) {
|
||||
for (const contractFile of contractFiles) {
|
||||
try {
|
||||
execFileSync('git', ['ls-files', '--error-unmatch', '--', contractFile], {
|
||||
cwd: repositoryRoot,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
} catch {
|
||||
throw new Error(`生成契约未纳入 Git 跟踪:${contractFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync('git', ['diff', '--exit-code', '--', ...contractFiles], {
|
||||
cwd: repositoryRoot,
|
||||
|
||||
@@ -34,3 +34,13 @@ test('生成契约发生漂移时以明确错误阻断', () => {
|
||||
/生成契约与 Git 提交版本不一致:contract\.json/,
|
||||
);
|
||||
});
|
||||
|
||||
test('生成文件未纳入 Git 跟踪时阻断', () => {
|
||||
const repositoryRoot = createGitFixture();
|
||||
writeFileSync(path.join(repositoryRoot, 'untracked-contract.json'), '{"version":1}\n');
|
||||
|
||||
assert.throws(
|
||||
() => assertGeneratedContractsUnchanged(repositoryRoot, ['untracked-contract.json']),
|
||||
/生成契约未纳入 Git 跟踪:untracked-contract\.json/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,8 +7,10 @@ const serverImage = `a-share-analysis-server:verify-${suffix}`;
|
||||
const network = `a-share-analysis-verify-${suffix}`;
|
||||
const redis = `a-share-analysis-verify-redis-${suffix}`;
|
||||
const api = `a-share-analysis-verify-api-${suffix}`;
|
||||
const scheduler = `a-share-analysis-verify-scheduler-${suffix}`;
|
||||
const worker = `a-share-analysis-verify-worker-${suffix}`;
|
||||
const web = `a-share-analysis-verify-web-${suffix}`;
|
||||
const containers = [web, api, redis];
|
||||
const containers = [web, worker, scheduler, api, redis];
|
||||
|
||||
function docker(args, options = {}) {
|
||||
return execFileSync('docker', args, {
|
||||
@@ -32,6 +34,34 @@ function waitForContainerCommand(container, command, description) {
|
||||
throw new Error(`${description} 未在 6 秒内就绪:${String(lastError?.stderr ?? '')}`);
|
||||
}
|
||||
|
||||
function verifyLongRunningProcess(container, entry, environment) {
|
||||
const environmentArguments = environment.flatMap(([key, value]) => ['--env', `${key}=${value}`]);
|
||||
docker(
|
||||
[
|
||||
'run',
|
||||
'--detach',
|
||||
'--name',
|
||||
container,
|
||||
...environmentArguments,
|
||||
serverImage,
|
||||
'node',
|
||||
`/app/dist/apps/${entry}/main.js`,
|
||||
],
|
||||
{ capture: true },
|
||||
);
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
|
||||
const running = docker(['inspect', container, '--format', '{{.State.Running}}'], {
|
||||
capture: true,
|
||||
}).trim();
|
||||
if (running !== 'true') throw new Error(`${entry} 容器未保持运行`);
|
||||
|
||||
docker(['stop', '--time', '5', container], { capture: true });
|
||||
const exitCode = docker(['inspect', container, '--format', '{{.State.ExitCode}}'], {
|
||||
capture: true,
|
||||
}).trim();
|
||||
if (exitCode !== '0') throw new Error(`${entry} 容器未优雅退出,退出码 ${exitCode}`);
|
||||
}
|
||||
|
||||
try {
|
||||
docker(['build', '--file', 'web/Dockerfile', '--tag', webImage, '.']);
|
||||
docker(['build', '--file', 'server/Dockerfile', '--tag', serverImage, '.']);
|
||||
@@ -48,6 +78,50 @@ try {
|
||||
);
|
||||
}
|
||||
|
||||
const sharedProcessEnvironment = [
|
||||
['APP_ENV', 'production'],
|
||||
['LOG_LEVEL', 'error'],
|
||||
['REDIS_HOST', '127.0.0.1'],
|
||||
['REDIS_PORT', '6379'],
|
||||
['REDIS_USERNAME', ''],
|
||||
['REDIS_PASSWORD', ''],
|
||||
['REDIS_DATABASE', '1'],
|
||||
['REDIS_TLS', 'false'],
|
||||
['ROCKETMQ_ENABLED', 'false'],
|
||||
['ROCKETMQ_NAMESRV_ADDR', '127.0.0.1:9876'],
|
||||
['ROCKETMQ_ENDPOINT', '127.0.0.1:8081'],
|
||||
['ROCKETMQ_TOPIC_PREFIX', 'a-share-analysis'],
|
||||
];
|
||||
verifyLongRunningProcess(scheduler, 'scheduler', [
|
||||
...sharedProcessEnvironment,
|
||||
['SCHEDULER_TIMEZONE', 'Asia/Shanghai'],
|
||||
['SCHEDULER_ENABLED', 'true'],
|
||||
['ROCKETMQ_SEND_TIMEOUT_MS', '3000'],
|
||||
]);
|
||||
verifyLongRunningProcess(worker, 'worker', [
|
||||
...sharedProcessEnvironment,
|
||||
['MARKET_DATA_MODE', 'live'],
|
||||
['MARKET_DATA_PROVIDER', 'deterministic-fixture'],
|
||||
['MARKET_DATA_BASE_URL', ''],
|
||||
['MARKET_DATA_API_KEY', ''],
|
||||
['MYSQL_ENABLED', 'false'],
|
||||
['MYSQL_HOST', '127.0.0.1'],
|
||||
['MYSQL_PORT', '3306'],
|
||||
['MYSQL_DATABASE', 'a_share_analysis_dev'],
|
||||
['MYSQL_USERNAME', ''],
|
||||
['MYSQL_PASSWORD', ''],
|
||||
['MYSQL_CHARSET', 'utf8mb4'],
|
||||
['MYSQL_COLLATION', 'utf8mb4_general_ci'],
|
||||
['MYSQL_TIMEZONE', '+08:00'],
|
||||
['AI_ENABLED', 'false'],
|
||||
['AI_BASE_URL', ''],
|
||||
['AI_API_KEY', ''],
|
||||
['AI_MODEL', ''],
|
||||
['AI_TIMEOUT_MS', '30000'],
|
||||
['ROCKETMQ_CONSUMER_GROUP', 'a-share-analysis-worker'],
|
||||
['ROCKETMQ_CONSUMER_CONCURRENCY', '4'],
|
||||
]);
|
||||
|
||||
docker(['network', 'create', network], { capture: true });
|
||||
docker(['run', '--detach', '--name', redis, '--network', network, 'redis:7.4.8-alpine3.21'], {
|
||||
capture: true,
|
||||
@@ -134,7 +208,7 @@ try {
|
||||
'Web 到 API 的 Nginx 代理',
|
||||
);
|
||||
|
||||
process.stdout.write('Docker 镜像、非 root 用户、三进程入口与 Web/API 链路验证通过\n');
|
||||
process.stdout.write('Docker 镜像、非 root 用户、三进程生命周期与 Web/API 链路验证通过\n');
|
||||
} finally {
|
||||
for (const container of containers) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import process from 'node:process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const IMMUTABLE_IMAGE_PATTERN = /^\S+@sha256:[a-f0-9]{64}$/;
|
||||
|
||||
/**
|
||||
* Compose 变量语法只能阻断空值,无法拒绝 latest 或普通 tag;发布前必须先通过
|
||||
* 该校验器,确保 Web/Server 都使用包含仓库名的内容摘要引用。
|
||||
*/
|
||||
export function assertImmutableImageReferences(environment) {
|
||||
for (const key of ['A_SHARE_WEB_IMAGE', 'A_SHARE_SERVER_IMAGE']) {
|
||||
const value = environment[key] ?? '';
|
||||
if (!IMMUTABLE_IMAGE_PATTERN.test(value)) {
|
||||
throw new Error(`${key} 必须使用仓库名@sha256摘要`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))
|
||||
) {
|
||||
assertImmutableImageReferences(process.env);
|
||||
process.stdout.write('生产镜像引用验证通过\n');
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { assertImmutableImageReferences } from './verify-image-references.mjs';
|
||||
|
||||
const digest = `sha256:${'a'.repeat(64)}`;
|
||||
|
||||
test('接受包含仓库名和 sha256 摘要的不可变镜像', () => {
|
||||
assert.doesNotThrow(() =>
|
||||
assertImmutableImageReferences({
|
||||
A_SHARE_WEB_IMAGE: `registry.invalid/web@${digest}`,
|
||||
A_SHARE_SERVER_IMAGE: `registry.invalid/server@${digest}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
for (const [name, image] of [
|
||||
['latest', 'registry.invalid/web:latest'],
|
||||
['普通 tag', 'registry.invalid/web:3a8120a'],
|
||||
['裸摘要', digest],
|
||||
['空值', ''],
|
||||
]) {
|
||||
test(`拒绝 Web ${name} 镜像引用`, () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
assertImmutableImageReferences({
|
||||
A_SHARE_WEB_IMAGE: image,
|
||||
A_SHARE_SERVER_IMAGE: `registry.invalid/server@${digest}`,
|
||||
}),
|
||||
/A_SHARE_WEB_IMAGE 必须使用仓库名@sha256摘要/,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user