fix(data-foundation): 识别已完成迁移链的 no-op 审计状态

This commit is contained in:
WuYongJie 2026-06-04 17:08:14 +08:00
parent 40e5f238a4
commit 92af7ea29a
10 changed files with 530 additions and 64 deletions

View File

@ -356,6 +356,16 @@ function compareMigrationManifests(expected = [], actual = []) {
}
}
function isNoopStructureRepair(structureRehearsal) {
return (
structureRehearsal?.status === 'structure-repair-rehearsal-passed' &&
structureRehearsal?.repairSqlSummary?.hasSqlDiff === false &&
structureRehearsal?.repairSqlArtifact == null &&
structureRehearsal?.baselineResolvePlan?.alreadyApplied === true &&
structureRehearsal?.baselineResolveRehearsal?.ok === true
)
}
function addCommonBlockers(blockers, preflight, structureRehearsal, repairSql, options) {
if (!preflight) {
blockers.push({ key: 'preflight.missing', message: '缺少真实施工前 preflight 报告。' })
@ -364,6 +374,7 @@ function addCommonBlockers(blockers, preflight, structureRehearsal, repairSql, o
blockers.push({ key: 'structureRehearsal.missing', message: '缺少克隆库结构修复演练报告。' })
return
}
const noopStructureRepair = isNoopStructureRepair(structureRehearsal)
if (preflight?.backupEvidence?.usable !== true) {
blockers.push({ key: 'backupEvidence', message: '真实施工前必须有可用的当前数据库备份。' })
@ -383,7 +394,7 @@ function addCommonBlockers(blockers, preflight, structureRehearsal, repairSql, o
message: '克隆库结构修复演练未通过,不能生成真实施工授权。',
})
}
if (structureRehearsal.repairApply?.ok !== true) {
if (!noopStructureRepair && structureRehearsal.repairApply?.ok !== true) {
blockers.push({ key: 'repairApply.notOk', message: '结构修复 SQL 在克隆库未成功执行。' })
}
if (structureRehearsal.repairSqlSummary?.hasDestructiveStatements === true) {
@ -406,13 +417,17 @@ function addCommonBlockers(blockers, preflight, structureRehearsal, repairSql, o
}
const baselineCount = structureRehearsal.baselineResolvePlan?.migrationCount ?? null
if (baselineCount == null || structureRehearsal.baselineResolvePlan?.candidate !== true) {
if (
!noopStructureRepair &&
(baselineCount == null || structureRehearsal.baselineResolvePlan?.candidate !== true)
) {
blockers.push({ key: 'baselineResolvePlan.notCandidate', message: '演练报告未给出可 baseline resolve 的迁移清单。' })
}
if (structureRehearsal.baselineResolveRehearsal?.ok !== true) {
blockers.push({ key: 'baselineResolveRehearsal.notOk', message: '克隆库 baseline resolve 演练未通过。' })
}
if (
!noopStructureRepair &&
baselineCount != null &&
structureRehearsal.baselineResolveRehearsal?.appliedCount != null &&
structureRehearsal.baselineResolveRehearsal.appliedCount !== baselineCount
@ -422,7 +437,12 @@ function addCommonBlockers(blockers, preflight, structureRehearsal, repairSql, o
message: 'baseline resolve 演练成功数量和迁移清单数量不一致。',
})
}
if (preflight?.pendingMigrationCount != null && baselineCount != null && preflight.pendingMigrationCount !== baselineCount) {
if (
!noopStructureRepair &&
preflight?.pendingMigrationCount != null &&
baselineCount != null &&
preflight.pendingMigrationCount !== baselineCount
) {
blockers.push({
key: 'preflight.pendingMigrationCountMismatch',
message: `preflight pending migration 数量为 ${preflight.pendingMigrationCount},演练 baseline 数量为 ${baselineCount}。`,
@ -430,24 +450,26 @@ function addCommonBlockers(blockers, preflight, structureRehearsal, repairSql, o
}
const artifact = structureRehearsal.repairSqlArtifact || null
if (!repairSql) {
blockers.push({ key: 'repairSql.missing', message: '缺少待执行结构修复 SQL 文件。' })
} else if (!repairSql.exists) {
blockers.push({ key: 'repairSql.notFound', message: '待执行结构修复 SQL 文件不存在。' })
} else if (repairSql.safety?.hasDestructiveStatements === true) {
blockers.push({
key: 'repairSql.fileDestructive',
message: '待执行结构修复 SQL 文件本体含 DROP/清表类破坏性语句,禁止真实施工。',
})
} else if (artifact) {
if (artifact.sizeBytes != null && repairSql.sizeBytes !== artifact.sizeBytes) {
blockers.push({ key: 'repairSql.sizeMismatch', message: '待执行 SQL 文件大小和演练 artifact 不一致。' })
}
if (artifact.sha256 && repairSql.sha256 !== artifact.sha256) {
blockers.push({ key: 'repairSql.sha256Mismatch', message: '待执行 SQL 文件 sha256 和演练 artifact 不一致。' })
if (!noopStructureRepair) {
if (!repairSql) {
blockers.push({ key: 'repairSql.missing', message: '缺少待执行结构修复 SQL 文件。' })
} else if (!repairSql.exists) {
blockers.push({ key: 'repairSql.notFound', message: '待执行结构修复 SQL 文件不存在。' })
} else if (repairSql.safety?.hasDestructiveStatements === true) {
blockers.push({
key: 'repairSql.fileDestructive',
message: '待执行结构修复 SQL 文件本体含 DROP/清表类破坏性语句,禁止真实施工。',
})
} else if (artifact) {
if (artifact.sizeBytes != null && repairSql.sizeBytes !== artifact.sizeBytes) {
blockers.push({ key: 'repairSql.sizeMismatch', message: '待执行 SQL 文件大小和演练 artifact 不一致。' })
}
if (artifact.sha256 && repairSql.sha256 !== artifact.sha256) {
blockers.push({ key: 'repairSql.sha256Mismatch', message: '待执行 SQL 文件 sha256 和演练 artifact 不一致。' })
}
}
}
if (options.expectedSha256 && repairSql?.sha256 !== options.expectedSha256) {
if (!noopStructureRepair && options.expectedSha256 && repairSql?.sha256 !== options.expectedSha256) {
blockers.push({ key: 'repairSql.expectedSha256Mismatch', message: '待执行 SQL 文件 sha256 和命令行指定值不一致。' })
}
}
@ -656,13 +678,14 @@ function buildExactPhaseCommands({
const structureRehearsalPath = options.structureRehearsalPath || ''
const repairSqlPath = options.repairSqlPath || structureRehearsal?.repairSqlArtifact?.path || ''
const missing = []
const noopStructureRepair = isNoopStructureRepair(structureRehearsal)
if (!repairSqlSha256) missing.push('repairSqlSha256')
if (!noopStructureRepair && !repairSqlSha256) missing.push('repairSqlSha256')
if (!databaseIdentityValue) missing.push('databaseIdentity')
if (!backupSha256) missing.push('backupSha256')
if (!preflightPath) missing.push('preflightPath')
if (!structureRehearsalPath) missing.push('structureRehearsalPath')
if (!repairSqlPath) missing.push('repairSqlPath')
if (!noopStructureRepair && !repairSqlPath) missing.push('repairSqlPath')
if (missing.length > 0) {
return {
@ -674,6 +697,17 @@ function buildExactPhaseCommands({
}
}
if (noopStructureRepair) {
return {
available: true,
missing: [],
noopStructureRepair: true,
applyStructureDryRun: null,
applyStructure: null,
resolveBaselineTemplate: null,
}
}
return {
available: true,
missing: [],
@ -741,6 +775,7 @@ function buildAuthorizedStructureRepairPlan({
checkedAt = new Date().toISOString(),
}) {
const blockers = []
const noopStructureRepair = isNoopStructureRepair(structureRehearsal)
addCommonBlockers(blockers, preflight, structureRehearsal, repairSql, options)
addPhaseBlockers(blockers, preflight, structureRehearsal, options, {
actualDatabase,
@ -761,6 +796,7 @@ function buildAuthorizedStructureRepairPlan({
phase: options.phase,
dryRun: !options.apply,
readyToApply,
noopStructureRepair,
confirmToken: CONFIRM_TOKEN,
checkedAt,
database: preflight?.database || null,

View File

@ -191,6 +191,28 @@ function structureRehearsalProvesMigrationSchemaEquivalence(summary) {
)
}
function noActionRequired(preflight, evidence) {
const structure = evidence.structureRepairRehearsal
const structureNoopOrAbsent =
!structure.provided ||
(
structure.passed &&
structure.baselineResolveCandidate === false &&
Number(structure.baselineResolveMigrationCount ?? 0) === 0 &&
structure.currentSchemaDiffAfterStatus === 0 &&
structure.migrationDiffAfterStatus === 0 &&
structure.prismaMigrateStatusAfterResolveStatus === 0
)
return Boolean(
preflight.recommendedAction === 'none' &&
Number(evidence.pendingMigrationCount ?? 0) === 0 &&
evidence.migrationSchemaDiffStatus === 'empty' &&
evidence.migrationDiffStatus === 'empty' &&
evidence.currentSchemaDiffHasSql === false &&
structureNoopOrAbsent,
)
}
function buildEvidenceSummary(preflight, rehearsal = null, structureRehearsal = null) {
const migrationSchemaDiffStatus = diffStatus(preflight.migrationSchemaDiff)
const migrationDiffStatus = diffStatus(preflight.migrationDiff)
@ -275,6 +297,7 @@ function addEvidenceBlockers(blockers, preflight, evidence) {
const rehearsedBaselineCount =
evidence.structureRepairRehearsal.baselineResolveMigrationCount
if (
!noActionRequired(preflight, evidence) &&
evidence.structureRepairRehearsal.passed &&
rehearsedBaselineCount != null &&
evidence.pendingMigrationCount !== rehearsedBaselineCount
@ -289,6 +312,7 @@ function addEvidenceBlockers(blockers, preflight, evidence) {
}
if (
!noActionRequired(preflight, evidence) &&
evidence.structureRepairRehearsal.passed &&
evidence.structureRepairRehearsal.repairSqlArtifact == null
) {
@ -348,8 +372,10 @@ function buildPhasePlan(preflight, evidence, status) {
{
id: 'operator-runbook',
label: '人工施工口径',
status: status.startsWith('ready-for-') ? 'ready' : 'blocked',
message: status.startsWith('ready-for-')
status: status.startsWith('ready-for-') || status === 'no-action-required' ? 'ready' : 'blocked',
message: status === 'no-action-required'
? '当前结构和迁移历史已闭合,不需要施工。'
: status.startsWith('ready-for-')
? '已形成可执行下一步。'
: '仍需先处理阻断项,再允许真实写库。',
},
@ -357,6 +383,19 @@ function buildPhasePlan(preflight, evidence, status) {
}
function buildRunbook(preflight, evidence, status) {
if (status === 'no-action-required') {
return {
summary: '当前库结构、迁移历史和迁移链证据已经闭合,不需要结构修复、baseline resolve 或 migrate deploy。',
commands: [
command(
'readiness',
'npm run db:verify-master-data:readiness',
'保持只读 readiness 作为后续生产巡检入口。',
),
],
}
}
if (status === 'ready-for-migrate-deploy') {
return {
summary: '当前可进入标准 Prisma migrate deploy 流程,但仍必须在真实执行前重新跑 preflight。',
@ -485,6 +524,7 @@ function buildRunbook(preflight, evidence, status) {
function decideStatus(preflight, evidence, blockers) {
if (blockers.length > 0) return 'evidence-blocked'
if (noActionRequired(preflight, evidence)) return 'no-action-required'
if (preflight.recommendedAction === 'migrate-deploy-required') return 'ready-for-migrate-deploy'
if (
preflight.recommendedAction === 'baseline-resolve-candidate' &&
@ -576,7 +616,7 @@ async function main() {
writeJsonReport(options.outputPath, plan)
console.log(JSON.stringify(plan, null, 2))
if (!plan.status.startsWith('ready-for-')) {
if (!plan.status.startsWith('ready-for-') && plan.status !== 'no-action-required') {
process.exit(1)
}
}
@ -595,6 +635,7 @@ module.exports = {
buildRunbook,
decideStatus,
diffStatus,
noActionRequired,
parseArgs,
readJsonFile,
structureRehearsalProvesMigrationSchemaEquivalence,

View File

@ -207,10 +207,11 @@ function buildMigrationManifest(rootDir = ROOT_DIR) {
})
}
function buildBaselineResolvePlan(migrationIds, structureAligned) {
function buildBaselineResolvePlan(migrationIds, structureAligned, options = {}) {
if (!structureAligned) {
return {
candidate: false,
alreadyApplied: false,
migrationCount: 0,
migrationIds: [],
commandsPreview: [],
@ -218,8 +219,20 @@ function buildBaselineResolvePlan(migrationIds, structureAligned) {
}
}
if (options.alreadyApplied) {
return {
candidate: false,
alreadyApplied: true,
migrationCount: 0,
migrationIds: [],
commandsPreview: [],
warning: '克隆库已包含完整 Prisma 迁移历史,不需要执行 baseline resolve。',
}
}
return {
candidate: true,
alreadyApplied: false,
migrationCount: migrationIds.length,
migrationIds,
commandsPreview: migrationIds.slice(0, 12).map((migrationId) =>
@ -229,14 +242,29 @@ function buildBaselineResolvePlan(migrationIds, structureAligned) {
}
}
function summarizeResolveResults(results) {
function summarizeResolveResults(results, options = {}) {
if (options.skippedAlreadyApplied) {
return {
attempted: false,
attemptedCount: 0,
appliedCount: 0,
skippedCount: Number(options.migrationCount ?? 0),
failedCount: 0,
ok: true,
skipReason: 'already-applied',
firstFailed: null,
}
}
const firstFailed = results.find((result) => !result.ok) || null
return {
attempted: results.length > 0,
attemptedCount: results.length,
appliedCount: results.filter((result) => result.ok).length,
skippedCount: results.filter((result) => result.skipped).length,
failedCount: results.filter((result) => !result.ok).length,
ok: results.length > 0 && results.every((result) => result.ok),
skipReason: '',
firstFailed: firstFailed
? {
migrationId: firstFailed.migrationId,
@ -327,6 +355,7 @@ function buildReport({
readinessAfterResolve = null,
requiredMigrationApplyAfterResolve = null,
readinessAfterRequiredMigrations = null,
baselineResolveAlreadyApplied = false,
cleanupStatus,
checkedAt = new Date().toISOString(),
}) {
@ -353,7 +382,10 @@ function buildReport({
blockers.push({ key: 'migrationDiffAfter.notEmpty', message: '结构修复后克隆库仍未对齐 migrations 重放结果。' })
}
const structureAligned = currentSchemaDiffAfter?.status === 0 && migrationDiffAfter?.status === 0
const baselineResolveRehearsal = summarizeResolveResults(baselineResolveResults)
const baselineResolveRehearsal = summarizeResolveResults(baselineResolveResults, {
skippedAlreadyApplied: baselineResolveAlreadyApplied,
migrationCount: migrationIds.length,
})
if (structureAligned && !baselineResolveRehearsal.ok) {
blockers.push({
key: 'baselineResolveRehearsal.failed',
@ -474,7 +506,9 @@ function buildReport({
error: migrationDiffAfter.error,
}
: null,
baselineResolvePlan: buildBaselineResolvePlan(migrationIds, structureAligned),
baselineResolvePlan: buildBaselineResolvePlan(migrationIds, structureAligned, {
alreadyApplied: baselineResolveAlreadyApplied,
}),
baselineResolveRehearsal,
prismaMigrateStatusAfterResolve: prismaMigrateStatusAfterResolve
? {
@ -533,6 +567,7 @@ async function runStructureRepairRehearsal(options = parseArgs(), env = process.
let readinessAfterResolve = null
let requiredMigrationApplyAfterResolve = null
let readinessAfterRequiredMigrations = null
let baselineResolveAlreadyApplied = false
const migrationIds = listMigrationDirectories()
const migrationManifest = buildMigrationManifest()
@ -680,34 +715,63 @@ async function runStructureRepairRehearsal(options = parseArgs(), env = process.
},
})
if (currentSchemaDiffAfter.status === 0 && migrationDiffAfter.status === 0) {
baselineResolveResults = migrationIds.map((migrationId) => ({
migrationId,
...runCommand(process.execPath, [
PRISMA_CLI,
'migrate',
'resolve',
'--applied',
const prismaMigrateStatusBeforeResolve = runCommand(process.execPath, [
PRISMA_CLI,
'migrate',
'status',
], {
env: {
...env,
DATABASE_URL: databaseUrl,
},
})
if (prismaMigrateStatusBeforeResolve.status === 0) {
baselineResolveAlreadyApplied = true
prismaMigrateStatusAfterResolve = prismaMigrateStatusBeforeResolve
} else {
baselineResolveResults = migrationIds.map((migrationId) => ({
migrationId,
...runCommand(process.execPath, [
PRISMA_CLI,
'migrate',
'resolve',
'--applied',
migrationId,
], {
env: {
...env,
DATABASE_URL: databaseUrl,
},
}),
}))
if (baselineResolveResults.every((result) => result.ok)) {
prismaMigrateStatusAfterResolve = runCommand(process.execPath, [
PRISMA_CLI,
'migrate',
'status',
], {
env: {
...env,
DATABASE_URL: databaseUrl,
},
})
}
}
if (prismaMigrateStatusAfterResolve?.status === 0) {
readinessAfterResolve = runCommand(process.execPath, [
'scripts/verify-master-data-readiness.js',
], {
env: {
...env,
DATABASE_URL: databaseUrl,
},
}),
}))
if (baselineResolveResults.every((result) => result.ok)) {
prismaMigrateStatusAfterResolve = runCommand(process.execPath, [
PRISMA_CLI,
'migrate',
'status',
], {
env: {
...env,
DATABASE_URL: databaseUrl,
SHADOW_DATABASE_URL: shadowDatabaseUrl,
},
})
if (prismaMigrateStatusAfterResolve.status === 0) {
readinessAfterResolve = runCommand(process.execPath, [
requiredMigrationApplyAfterResolve = await applyRequiredMigrationsToClone(databaseUrl)
if (requiredMigrationApplyAfterResolve.ok) {
readinessAfterRequiredMigrations = runCommand(process.execPath, [
'scripts/verify-master-data-readiness.js',
], {
env: {
@ -716,18 +780,6 @@ async function runStructureRepairRehearsal(options = parseArgs(), env = process.
SHADOW_DATABASE_URL: shadowDatabaseUrl,
},
})
requiredMigrationApplyAfterResolve = await applyRequiredMigrationsToClone(databaseUrl)
if (requiredMigrationApplyAfterResolve.ok) {
readinessAfterRequiredMigrations = runCommand(process.execPath, [
'scripts/verify-master-data-readiness.js',
], {
env: {
...env,
DATABASE_URL: databaseUrl,
SHADOW_DATABASE_URL: shadowDatabaseUrl,
},
})
}
}
}
}
@ -766,6 +818,7 @@ async function runStructureRepairRehearsal(options = parseArgs(), env = process.
readinessAfterResolve,
requiredMigrationApplyAfterResolve,
readinessAfterRequiredMigrations,
baselineResolveAlreadyApplied,
cleanupStatus,
})
}

View File

@ -105,7 +105,13 @@ function writeJsonReport(outputPath, payload, rootDir = ROOT_DIR) {
function buildFoundationMigrationPlan(preflight, options = {}) {
const blockers = [...(preflight.blockers ?? [])]
const canDeploy = preflight.ready && preflight.recommendedAction === 'migrate-deploy-required'
if (preflight.ready && preflight.recommendedAction !== 'migrate-deploy-required') {
const noop = Boolean(
preflight.ready &&
preflight.recommendedAction === 'none' &&
Number(preflight.pendingMigrationCount ?? 0) === 0 &&
preflight.currentSchemaDiffHasSql !== true,
)
if (preflight.ready && preflight.recommendedAction !== 'migrate-deploy-required' && !noop) {
blockers.push({
key: 'recommendedAction',
message: `当前建议动作是 ${preflight.recommendedAction},本脚本只执行 migrate deploy。`,
@ -161,7 +167,9 @@ function buildFoundationMigrationPlan(preflight, options = {}) {
return {
mode: 'database-foundation-migration-plan',
dryRun: !options.apply,
noop,
readyToRun: canDeploy && blockers.length === 0,
executionState: noop ? 'no-action-required' : options.apply ? 'apply-requested' : 'plan-only',
recommendedAction: preflight.recommendedAction,
database: preflight.database,
backupEvidence: preflight.backupEvidence,
@ -211,7 +219,7 @@ async function main() {
writeJsonReport(options.outputPath, plan)
console.log(JSON.stringify(plan, null, 2))
if (!plan.readyToRun) {
if (!plan.readyToRun && !plan.noop) {
process.exit(1)
}

View File

@ -1137,6 +1137,7 @@ function buildAuthorizedStructureRepairApplyPlanSummary(applyPlan) {
phase: applyPlan.phase || '',
dryRun,
readyToApply,
noopStructureRepair: applyPlan.noopStructureRepair === true,
requiresExplicitAuthorization: dryRun || !readyToApply,
executionState: readyToApply
? 'ready-to-apply-with-explicit-authorization'
@ -1166,6 +1167,7 @@ function buildAuthorizedStructureRepairApplyPlanSummary(applyPlan) {
exactPhaseCommandAvailability: exactPhaseCommands
? {
available: exactPhaseCommands.available === true,
noopStructureRepair: exactPhaseCommands.noopStructureRepair === true,
missing: Array.isArray(exactPhaseCommands.missing)
? exactPhaseCommands.missing
: [],
@ -1268,6 +1270,7 @@ function buildFoundationCompletionChecklist({
}) {
const stepOk = (id) => stepResults.find((result) => result.id === id)?.ok === true
const stepMissing = (id) => !stepResults.some((result) => result.id === id)
const migrationPlan = stepResults.find((result) => result.id === 'foundation-migrate-plan')?.data ?? null
const schemaBlockerKeys = readinessBlockerSourceKeys(readiness, (blocker) =>
[
'prisma-migrate-status',
@ -1317,6 +1320,19 @@ function buildFoundationCompletionChecklist({
const authorizedRepairSummary = buildAuthorizedStructureRepairApplyPlanSummary(
authorizedStructureRepairApplyPlan,
)
const foundationMigratePlanPendingCount = Number(
migrationPlan?.pendingMigrationCount ??
(Array.isArray(migrationPlan?.pendingMigrations)
? migrationPlan.pendingMigrations.length
: 0),
)
const foundationMigratePlanNoop =
migrationPlan?.recommendedAction === 'none' &&
foundationMigratePlanPendingCount === 0 &&
readiness?.hardReady === true &&
stepOk('foundation-preflight')
const foundationMigratePlanSatisfied =
stepOk('foundation-migrate-plan') || foundationMigratePlanNoop
const items = [
buildChecklistItem(
@ -1324,7 +1340,7 @@ function buildFoundationCompletionChecklist({
'数据库结构与 Prisma 迁移历史',
schemaBlockerKeys.length === 0 &&
stepOk('foundation-preflight') &&
stepOk('foundation-migrate-plan') &&
foundationMigratePlanSatisfied &&
stepOk('foundation-required-migration-apply-plan'),
{
missing: stepMissing('readiness') || stepMissing('foundation-preflight'),
@ -1333,6 +1349,8 @@ function buildFoundationCompletionChecklist({
readinessHardReady: readiness?.hardReady === true,
foundationPreflightOk: stepOk('foundation-preflight'),
foundationMigratePlanOk: stepOk('foundation-migrate-plan'),
foundationMigratePlanNoop,
foundationMigratePlanPendingCount,
foundationRequiredMigrationApplyPlanOk:
stepOk('foundation-required-migration-apply-plan'),
},

View File

@ -293,6 +293,63 @@ describe('apply-prisma-structure-repair', () => {
)
})
it('builds a no-op structure repair plan when migration history is already applied', () => {
const plan = buildAuthorizedStructureRepairPlan({
preflight: preflight({
pendingMigrationCount: 0,
currentSchemaDiffHasSql: false,
backupEvidence: {
usable: true,
sha256: backupSha256,
},
migrationDiff: {
skipped: false,
status: 0,
stdout: '-- This is an empty migration.',
},
}),
structureRehearsal: structureRehearsal({
repairApply: null,
repairSqlSummary: {
hasSqlDiff: false,
hasDestructiveStatements: false,
},
repairSqlArtifact: null,
baselineResolvePlan: {
candidate: false,
alreadyApplied: true,
migrationCount: 0,
migrationIds: [],
},
baselineResolveRehearsal: {
attempted: false,
skippedCount: 86,
failedCount: 0,
ok: true,
skipReason: 'already-applied',
},
}),
repairSql: null,
options: options({
preflightPath: '.master-data-audits/preflight.json',
structureRehearsalPath: '.master-data-audits/structure-repair.json',
}),
})
expect(plan.noopStructureRepair).toBe(true)
expect(plan.blockers).toEqual([])
expect(plan.repairSqlArtifact).toBeNull()
expect(plan.expectedSha256).toBe('')
expect(plan.exactPhaseCommands).toEqual({
available: true,
missing: [],
noopStructureRepair: true,
applyStructureDryRun: null,
applyStructure: null,
resolveBaselineTemplate: null,
})
})
it('emits exact apply-structure args with fingerprints when evidence paths are known', () => {
const sqlSha256 = crypto.createHash('sha256').update('CREATE TABLE "A"();\n').digest('hex')
const plan = buildAuthorizedStructureRepairPlan({

View File

@ -217,6 +217,72 @@ describe('plan-prisma-baseline-repair', () => {
expect(JSON.stringify(plan)).not.toContain('sensitive/local/structure-repair.sql')
})
it('treats fully current schema and migration history as no-action-required', () => {
const plan = buildBaselineRepairPlan({
preflight: preflight({
recommendedAction: 'none',
pendingMigrationCount: 0,
currentSchemaDiffHasSql: false,
diagnosisBlockers: [],
migrationDiff: {
skipped: false,
ok: true,
status: 0,
},
migrationSchemaDiff: {
skipped: false,
ok: true,
status: 0,
},
}),
structureRehearsal: {
status: 'structure-repair-rehearsal-passed',
repairApply: null,
repairSqlSummary: {
hasSqlDiff: false,
hasDestructiveStatements: false,
},
repairSqlArtifact: null,
currentSchemaDiffAfter: { status: 0 },
migrationDiffAfter: { status: 0 },
baselineResolvePlan: {
candidate: false,
alreadyApplied: true,
migrationCount: 0,
},
baselineResolveRehearsal: {
ok: true,
attempted: false,
skippedCount: 97,
skipReason: 'already-applied',
},
prismaMigrateStatusAfterResolve: { status: 0 },
requiredMigrationApplyAfterResolve: {
ok: true,
appliedMigrationCount: 0,
},
readinessAfterRequiredMigrations: {
hardReady: true,
blockerCount: 0,
blockerKeys: [],
},
},
checkedAt: '2026-06-01T10:00:00.000Z',
})
expect(plan.status).toBe('no-action-required')
expect(plan.blockers).toEqual([])
expect(plan.runbook.summary).toContain('不需要结构修复')
expect(plan.phasePlan).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'operator-runbook',
status: 'ready',
}),
]),
)
})
it('keeps current diff evidence distinct from post-repair equivalence evidence', () => {
const plan = buildBaselineRepairPlan({
preflight: preflight({

View File

@ -228,6 +228,66 @@ describe('rehearse-prisma-structure-repair', () => {
expect(report.blockers).toEqual([])
})
it('passes no-op rehearsal when clone migration history is already applied', () => {
const report = buildReport({
options: options(),
containerName: 'container',
databaseUrl: 'postgresql://postgres:pass@localhost:65448/furniture_3d_structure_repair?schema=public',
restore: commandResult(),
currentSchemaDiffBefore: commandResult({ stdout: '-- This is an empty migration.' }),
repairSqlSummary: summarizeRepairSql('-- This is an empty migration.'),
repairApply: null,
currentSchemaDiffAfter: commandResult({ stdout: '-- This is an empty migration.' }),
migrationDiffAfter: commandResult({ stdout: '-- This is an empty migration.' }),
migrationIds: ['20260101000000_init', '20260102000000_next'],
baselineResolveResults: [],
baselineResolveAlreadyApplied: true,
prismaMigrateStatusAfterResolve: commandResult({ stdout: 'Database schema is up to date!' }),
readinessAfterResolve: commandResult({
stdout: JSON.stringify({
status: 'hard-ready',
hardReady: true,
blockerCount: 0,
blockers: [],
}),
}),
requiredMigrationApplyAfterResolve: {
attempted: true,
ok: true,
appliedMigrationCount: 0,
pendingStructureMigrationCount: 0,
replayableIdempotentMigrationCount: 0,
appliedMigrationPaths: [],
},
readinessAfterRequiredMigrations: commandResult({
stdout: JSON.stringify({
status: 'hard-ready',
hardReady: true,
blockerCount: 0,
blockers: [],
}),
}),
cleanupStatus: 'removed',
})
expect(report.status).toBe('structure-repair-rehearsal-passed')
expect(report.repairSqlArtifact).toBeNull()
expect(report.baselineResolvePlan).toEqual(expect.objectContaining({
candidate: false,
alreadyApplied: true,
migrationCount: 0,
migrationIds: [],
}))
expect(report.baselineResolveRehearsal).toEqual(expect.objectContaining({
attempted: false,
skippedCount: 2,
failedCount: 0,
ok: true,
skipReason: 'already-applied',
}))
expect(report.blockers).toEqual([])
})
it('requires readable readiness evidence after baseline resolve succeeds', () => {
const report = buildReport({
options: options(),
@ -352,8 +412,10 @@ describe('rehearse-prisma-structure-repair', () => {
attempted: false,
attemptedCount: 0,
appliedCount: 0,
skippedCount: 0,
failedCount: 0,
ok: false,
skipReason: '',
firstFailed: null,
})
expect(summarizeResolveResults([

View File

@ -111,6 +111,19 @@ describe('run-database-foundation-migrations', () => {
expect(plan.blockers).toEqual([])
})
it('treats fully current databases as no-op instead of blocked deploy plans', () => {
const plan = buildFoundationMigrationPlan(preflight({
recommendedAction: 'none',
pendingMigrationCount: 0,
currentSchemaDiffHasSql: false,
}))
expect(plan.noop).toBe(true)
expect(plan.readyToRun).toBe(false)
expect(plan.executionState).toBe('no-action-required')
expect(plan.blockers).toEqual([])
})
it('blocks apply without explicit confirmation', () => {
const plan = buildFoundationMigrationPlan(preflight(), {
apply: true,

View File

@ -736,6 +736,115 @@ describe('run-master-data-foundation-audit', () => {
)
})
it('accepts a no-op foundation migrate plan when schema and migration history are already current', () => {
const cleanStepResults = [
stepResult('readiness', true, { hardReady: true, blockers: [] }),
stepResult('foundation-preflight', true, { ready: true, blockers: [] }),
stepResult('foundation-migrate-plan', false, {
readyToRun: false,
recommendedAction: 'none',
pendingMigrationCount: 0,
blockers: [],
}),
stepResult('foundation-required-migration-apply-plan', true, {
blockerKeys: [],
}),
stepResult('truth-sources', true, {
truthSourceCount: 27,
prismaModelCount: 65,
databaseGuardCount: 290,
}),
stepResult('code-boundaries', true, {
checkedFiles: 100,
checkedScriptFiles: 10,
violations: [],
}),
stepResult('compatibility-snapshot-read', true, {
compatibilitySnapshotReadBlockingRiskCount: 0,
unclassifiedCompatibilitySnapshotReadCount: 0,
compatibilitySnapshotReadBudgetViolationCount: 0,
}),
stepResult('process-snapshot-read', true, {
processSnapshotReadBlockingRiskCount: 0,
unclassifiedProcessSnapshotReadCount: 0,
processSnapshotReadBudgetViolationCount: 0,
}),
stepResult('filesystem-master-data-source', true, {
fileSystemMasterDataSourceFindingCount: 0,
unclassifiedFileSystemMasterDataSourceCount: 0,
}),
stepResult('historical-storage-assets', true, {
mode: 'dry-run',
status: 'completed',
counts: {
referenceTotal: 7,
objectTotal: 7,
sourceMissing: 0,
targetExistsBefore: 7,
uploadFailed: 0,
updateTotal: 7,
},
warnings: [],
}),
stepResult('pre-sku-formalization-repair-plan-validation', true, {
valid: true,
candidateCount: 0,
staleCandidateCount: 0,
}),
stepResult('authorized-structure-repair-apply-plan', true, {
noopStructureRepair: true,
exactPhaseCommands: {
available: true,
missing: [],
noopStructureRepair: true,
applyStructureDryRun: null,
applyStructure: null,
resolveBaselineTemplate: null,
},
blockers: [],
}),
]
const checklist = buildFoundationCompletionChecklist({
readiness: cleanStepResults[0].data,
truthSources: cleanStepResults[4].data,
codeBoundaries: cleanStepResults[5].data,
compatibilitySnapshotRead: cleanStepResults[6].data,
processSnapshotRead: cleanStepResults[7].data,
fileSystemMasterDataSource: cleanStepResults[8].data,
historicalStorageAssets: cleanStepResults[9].data,
preSkuFormalizationRepairPlanValidation: cleanStepResults[10].data,
authorizedStructureRepairApplyPlan: cleanStepResults[11].data,
stepResults: cleanStepResults,
})
expect(checklist.complete).toBe(true)
expect(checklist.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'schema-and-migration-history',
status: 'passed',
evidence: expect.objectContaining({
foundationMigratePlanOk: false,
foundationMigratePlanNoop: true,
foundationMigratePlanPendingCount: 0,
}),
}),
expect.objectContaining({
id: 'authorized-foundation-runbook',
status: 'passed',
evidence: expect.objectContaining({
exactPhaseCommandAvailability: expect.objectContaining({
noopStructureRepair: true,
applyStructure: false,
resolveBaselineTemplate: false,
}),
}),
}),
]),
)
})
it('keeps completion checklist blocked without exposing row-level evidence', () => {
const readiness = {
hardReady: false,
@ -1705,6 +1814,7 @@ describe('run-master-data-foundation-audit', () => {
phase: 'plan',
dryRun: true,
readyToApply: false,
noopStructureRepair: false,
requiresExplicitAuthorization: true,
executionState: 'plan-only-not-applied',
repairSqlArtifact: {
@ -1751,6 +1861,7 @@ describe('run-master-data-foundation-audit', () => {
phase: 'plan',
dryRun: true,
readyToApply: false,
noopStructureRepair: false,
requiresExplicitAuthorization: true,
executionState: 'plan-only-not-applied',
repairSqlArtifact: {
@ -1769,6 +1880,7 @@ describe('run-master-data-foundation-audit', () => {
baselineResolveMigrationCount: 86,
exactPhaseCommandAvailability: {
available: true,
noopStructureRepair: false,
missing: [],
applyStructureDryRun: true,
applyStructure: true,