3922 lines
155 KiB
JavaScript
3922 lines
155 KiB
JavaScript
/* eslint-disable @typescript-eslint/no-require-imports */
|
||
const fs = require('node:fs')
|
||
const path = require('node:path')
|
||
|
||
function parseArgs(argv) {
|
||
const args = {
|
||
mode: '',
|
||
outputPath: '',
|
||
root: path.resolve(__dirname, '..'),
|
||
}
|
||
|
||
for (let index = 0; index < argv.length; index += 1) {
|
||
const arg = argv[index]
|
||
if (arg === '--mode') {
|
||
args.mode = argv[index + 1] ?? ''
|
||
if (!args.mode || args.mode.startsWith('--')) {
|
||
throw new Error('缺少 --mode <mode>。')
|
||
}
|
||
index += 1
|
||
continue
|
||
}
|
||
if (arg === '--root') {
|
||
args.root = path.resolve(process.cwd(), argv[index + 1] ?? '')
|
||
if (!argv[index + 1] || argv[index + 1].startsWith('--')) {
|
||
throw new Error('缺少 --root <dir>。')
|
||
}
|
||
index += 1
|
||
continue
|
||
}
|
||
if (arg === '--output') {
|
||
args.outputPath = argv[index + 1] ?? ''
|
||
if (!args.outputPath || args.outputPath.startsWith('--')) {
|
||
throw new Error('缺少 --output <json-file>。')
|
||
}
|
||
index += 1
|
||
continue
|
||
}
|
||
if (arg.startsWith('--output=')) {
|
||
args.outputPath = arg.slice('--output='.length)
|
||
if (!args.outputPath) {
|
||
throw new Error('缺少 --output <json-file>。')
|
||
}
|
||
continue
|
||
}
|
||
throw new Error(`未知参数:${arg}`)
|
||
}
|
||
|
||
return args
|
||
}
|
||
|
||
const args =
|
||
require.main === module
|
||
? parseArgs(process.argv.slice(2))
|
||
: {
|
||
mode: '',
|
||
outputPath: '',
|
||
root: path.resolve(__dirname, '..'),
|
||
}
|
||
const ROOT_DIR = args.root
|
||
const SCAN_DIRS = ['src']
|
||
const SCRIPT_SCAN_DIRS = ['scripts', 'prisma']
|
||
const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx'])
|
||
const PRISMA_SCHEMA_PATH = path.join(ROOT_DIR, 'prisma/schema.prisma')
|
||
const COMPATIBILITY_SNAPSHOT_READ_PRIORITIES = ['high', 'medium', 'low', 'unknown']
|
||
const PRODUCT_SKU_ATTRIBUTE_WRITE_PRIORITIES = ['high', 'medium', 'low', 'unknown']
|
||
const PROCESS_SNAPSHOT_READ_PRIORITIES = ['high', 'medium', 'low', 'unknown']
|
||
const SNAPSHOT_READ_DEBT_BUDGETS = {
|
||
compatibilitySnapshotRead: {
|
||
total: 72,
|
||
high: 0,
|
||
medium: 58,
|
||
unknown: 0,
|
||
},
|
||
processSnapshotRead: {
|
||
total: 43,
|
||
high: 0,
|
||
medium: 39,
|
||
unknown: 0,
|
||
},
|
||
}
|
||
const approvedReferenceEstimatorCostSourcePaths = new Map([
|
||
[
|
||
'src/app/api/intake/reference-image-estimate/route.ts',
|
||
'参考图识别接口只允许使用内置五金样本帮助识别,不保存正式 BOM 或成本。',
|
||
],
|
||
[
|
||
'src/app/intake/RawMaterialLibraryPanel.tsx',
|
||
'参考图预估成本页面只允许把临时单价用于前端估算展示,不能写入正式产品、BOM 或包装方案。',
|
||
],
|
||
[
|
||
'src/lib/reference-image-cost-catalog.ts',
|
||
'参考图预估成本的小样本目录只服务图片估算识别,不是五金、包装或正式成本主数据源。',
|
||
],
|
||
])
|
||
|
||
const approvedFileSystemMasterDataSourcePaths = new Map([
|
||
[
|
||
'src/server/services/product-hardware-import.service.ts',
|
||
'旧产品五金 ZIP 导入入口允许从上传包读取 hardware.json / glb,并写入旧 CAD 兼容链 ProductHardware;不得扩展为扫描 public/uploads 目录反向补主数据。',
|
||
],
|
||
[
|
||
'src/server/services/sku-upload.service.ts',
|
||
'旧 SKU CAD 上传入口允许读取本次请求的临时文件并生成旧 CAD 兼容结构;正式基础资料仍必须走结构化字段和校验。',
|
||
],
|
||
])
|
||
|
||
function toPosixPath(filePath) {
|
||
return filePath.split(path.sep).join('/')
|
||
}
|
||
|
||
function writeJsonReport(outputPath, payload, rootDir = ROOT_DIR) {
|
||
if (!outputPath) return ''
|
||
const absolutePath = path.resolve(rootDir, outputPath)
|
||
fs.mkdirSync(path.dirname(absolutePath), { recursive: true })
|
||
fs.writeFileSync(absolutePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
|
||
return absolutePath
|
||
}
|
||
|
||
function emitReport(report) {
|
||
writeJsonReport(args.outputPath, report)
|
||
console.log(JSON.stringify(report, null, 2))
|
||
}
|
||
|
||
function listSourceFiles(dir) {
|
||
if (!fs.existsSync(dir)) return []
|
||
|
||
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||
const files = []
|
||
|
||
for (const entry of entries) {
|
||
if (entry.name === 'node_modules' || entry.name === '.next') continue
|
||
|
||
const absolutePath = path.join(dir, entry.name)
|
||
if (entry.isDirectory()) {
|
||
files.push(...listSourceFiles(absolutePath))
|
||
continue
|
||
}
|
||
|
||
if (entry.isFile() && CODE_EXTENSIONS.has(path.extname(entry.name))) {
|
||
files.push(absolutePath)
|
||
}
|
||
}
|
||
|
||
return files
|
||
}
|
||
|
||
function lineNumberAt(content, index) {
|
||
let line = 1
|
||
for (let i = 0; i < index; i += 1) {
|
||
if (content.charCodeAt(i) === 10) line += 1
|
||
}
|
||
return line
|
||
}
|
||
|
||
function isTestFile(relativePath) {
|
||
return (
|
||
relativePath.includes('/__tests__/') ||
|
||
relativePath.endsWith('.test.ts') ||
|
||
relativePath.endsWith('.test.tsx') ||
|
||
relativePath.endsWith('.spec.ts') ||
|
||
relativePath.endsWith('.spec.tsx')
|
||
)
|
||
}
|
||
|
||
const schemaJsonFieldClassifications = new Map(
|
||
[
|
||
['ProductSKU.attributes', ['compatibility-snapshot', '正式 SKU 兼容属性;主读必须走结构化字段、assetProfile 或 legacyAttributes 输出。']],
|
||
['ProductSKU.transferHistory', ['audit-log', 'SKU 转移历史。']],
|
||
['ProductSkuAssetProfile.size', ['structured-json', '正式 SKU 资产资料结构化尺寸对象。']],
|
||
['ProductSkuAssetProfile.lockedReasons', ['structured-json', '正式 SKU 资产资料人工锁定原因。']],
|
||
['ProductSkuAssetProfile.sourceVariants', ['structured-json', '正式 SKU 资产资料来源规格款列表。']],
|
||
['IntakeNonIntegratedProduct.payload', ['compatibility-snapshot', '非集成开品历史过程快照;主读应走 profile、skuItems 和 pricingRows 结构化表。']],
|
||
['ProductPatentRecord.annualFees', ['structured-json', '专利年费明细。']],
|
||
['ProductPatentRecord.annualFeeDates', ['structured-json', '专利年费日期明细。']],
|
||
['IntakeReferenceLibraryItem.payload', ['structured-json', '资料库条目扩展字段;条目身份和编码由结构化列约束。']],
|
||
['AssemblyStep.transform', ['geometry-json', '旧 CAD 装配步骤几何变换。']],
|
||
['AssemblyStep.cameraPosition', ['geometry-json', '旧 CAD 装配步骤相机位置。']],
|
||
['AssemblyStep.infoCardData', ['process-snapshot', '旧 CAD 装配步骤展示信息。']],
|
||
['AssemblyVersion.snapshot', ['process-snapshot', '旧 CAD 装配版本完整步骤快照。']],
|
||
['Panel.assemblySemantic', ['geometry-json', '旧 CAD 板件装配语义。']],
|
||
['PackagingPlan.boxes', ['structured-json', '包装方案箱规列表。']],
|
||
['PlanningTemplate.slots', ['process-template', '企划模板槽位配置。']],
|
||
['PlanningTemplate.rules', ['process-template', '企划模板规则配置。']],
|
||
['PlanningWorkspace.slotSelections', ['process-snapshot', '企划工作区槽位选择状态。']],
|
||
['PlanningWorkspace.metadata', ['process-snapshot', '企划工作区扩展元数据。']],
|
||
['PlanningDraft.selectedAssets', ['process-snapshot', '企划草稿已选资产快照。']],
|
||
['PlanningDraft.previewNodes', ['process-snapshot', '企划草稿预览节点快照。']],
|
||
['PlanningDraft.summary', ['process-snapshot', '企划草稿摘要。']],
|
||
['PlanningDraft.warnings', ['process-snapshot', '企划草稿警告。']],
|
||
['PlanningDraft.sourceSnapshot', ['process-snapshot', '企划草稿来源快照。']],
|
||
['Substrate.metadata', ['structured-json', '板材基材扩展元数据。']],
|
||
['AssetFamily.metadata', ['structured-json', '资产族扩展元数据。']],
|
||
['AssetSeries.metadata', ['structured-json', '系列扩展元数据。']],
|
||
['AssetVariant.dimensions', ['structured-json', '资产规格款尺寸对象。']],
|
||
['AssetVariant.mountPoints', ['geometry-json', '资产规格款连接点。']],
|
||
['AssetVariant.assemblyBasis', ['geometry-json', '资产规格款装配依据。']],
|
||
['AssetVariant.compatibilityRules', ['structured-json', '资产规格款兼容规则。']],
|
||
['AssetVariant.namingFields', ['structured-json', '资产规格款命名字段。']],
|
||
[
|
||
'AssetVariant.businessData',
|
||
[
|
||
'structured-json',
|
||
'资产规格款业务扩展资料;cadData 只允许恢复装配几何 assemblyBasis,不能反推正式 SKU 身份、成本、条码或基础资料;编码、条码、成本、重量等真相必须走 AssetVariant 结构化列和 ProductSkuAssetProfile。',
|
||
],
|
||
],
|
||
['AssetSkuSyncEvent.payload', ['sync-event', '资产 SKU 同步事件载荷。']],
|
||
['AssetSkuSyncEvent.progress', ['sync-event', '资产 SKU 同步进度。']],
|
||
['DraftGroup.summary', ['process-snapshot', '草稿组摘要快照。']],
|
||
['AssistedAssemblyAiRender.params', ['process-snapshot', '辅助装配 AI 生图参数。']],
|
||
['AssistedAssemblyAiRender.resultImages', ['process-snapshot', '辅助装配 AI 生图结果。']],
|
||
['AssistedAssemblyAiRender.workspaceSnapshot', ['process-snapshot', '辅助装配工作区快照。']],
|
||
['AssistedAssemblyAiRender.assetSnapshot', ['process-snapshot', '辅助装配资产快照。']],
|
||
['AssistedAssemblyAiRender.attempts', ['process-snapshot', '辅助装配 AI 生图尝试记录。']],
|
||
['DiyScheme.snapshot', ['process-snapshot', 'DIY 组合方案配置与几何恢复快照;不创建正式产品、SKU 或条码。']],
|
||
['PreSku.familySelection', ['compatibility-snapshot', '准 SKU 资产族选择快照;正式基础资料主读必须走 PreSkuBasicInfo。']],
|
||
['PreSku.variantSelection', ['process-snapshot', '准 SKU 规格款组合快照。']],
|
||
['PreSku.recommendedHardware', ['process-snapshot', '准 SKU 推荐五金快照。']],
|
||
['PreSku.previewNodes', ['geometry-json', '准 SKU 预览节点快照。']],
|
||
['PreSku.displaySummary', ['compatibility-snapshot', '准 SKU 展示摘要;正式字段主读必须走 PreSkuBasicInfo。']],
|
||
['PreSku.bomPreview', ['process-snapshot', '准 SKU 入库前 BOM 预览。']],
|
||
['PreSku.explosionPreview', ['geometry-json', '准 SKU 爆炸图预览。']],
|
||
['PreSku.installStepPreview', ['process-snapshot', '准 SKU 安装步骤预览。']],
|
||
['PreSku.packagingSnapshot', ['process-snapshot', '准 SKU 包装快照。']],
|
||
['PreSkuPackagingPlan.boxes', ['structured-json', '准 SKU 包装箱规。']],
|
||
[
|
||
'PreSkuPackagingPlan.costSummary',
|
||
[
|
||
'structured-json',
|
||
'准 SKU 包装成本结构化摘要;只作为包装方案内部成本快照,不是正式 SKU 成本或财务成本真相;正式产品/SKU 成本必须由正式 BOM/核价结构化链路承接。',
|
||
],
|
||
],
|
||
[
|
||
'PreSkuPackagingPlan.processDraft',
|
||
[
|
||
'process-snapshot',
|
||
'准 SKU 包装过程草稿;只保存入库前包装编辑过程,不得承接正式 SKU 基础资料、库存、成本结论或权限真相。',
|
||
],
|
||
],
|
||
[
|
||
'PackagedItem.payload',
|
||
[
|
||
'process-snapshot',
|
||
'轻仓库包装项过程扩展载荷;只允许保存包装执行上下文和展示补充,不得承接成本、库存、SKU 身份或基础资料真相;包装数量、位置、状态、尺寸和正式成本应走 PackagedItem/PackagingPlan/PreSkuPackagingPlan 结构化字段。',
|
||
],
|
||
],
|
||
['ProductSkuQualityInspection.metrics', ['process-snapshot', '正式 SKU 质检指标过程证据;质检结论和数量仍以结构化列为准。']],
|
||
['ProductSkuQualityInspection.attachments', ['process-snapshot', '正式 SKU 质检附件过程证据;附件不能承接正式 SKU 基础资料真相。']],
|
||
[
|
||
'EmployeeLookupCache.result',
|
||
[
|
||
'integration-cache',
|
||
'员工外部匹配短期缓存结果;只用于花名册/钉钉匹配加速,不是人员、部门、权限或业务类型真相;真相必须写入 User/Department/EmployeeDirectory/Team 结构化表。',
|
||
],
|
||
],
|
||
['Department.rawSnapshot', ['integration-cache', '组织部门外部原始快照。']],
|
||
['ApprovalWorkflow.fieldMatrix', ['process-snapshot', '审批流字段矩阵。']],
|
||
['ApprovalWorkflow.summary', ['process-snapshot', '审批流摘要。']],
|
||
['ApprovalWorkflow.metadata', ['process-snapshot', '审批流扩展元数据。']],
|
||
['ApprovalWorkflowFieldChange.oldValue', ['audit-log', '审批字段变更旧值。']],
|
||
['ApprovalWorkflowFieldChange.newValue', ['audit-log', '审批字段变更新值。']],
|
||
['ApprovalWorkflowFieldChange.metadata', ['audit-log', '审批字段变更元数据。']],
|
||
['EmployeeDirectory.departmentExternalIds', ['integration-cache', '员工目录外部部门 ID 列表。']],
|
||
['EmployeeDirectory.rawSnapshot', ['integration-cache', '员工目录外部原始快照。']],
|
||
['Feedback.comments', ['audit-log', '反馈评论列表。']],
|
||
['ActivityLog.changes', ['audit-log', '操作日志变更内容。']],
|
||
['ActivityLog.metadata', ['audit-log', '操作日志元数据。']],
|
||
['ThemeConfig.homeBackground', ['ui-config', '主题首页背景配置。']],
|
||
['ThemeConfig.loginPanel', ['ui-config', '主题登录面板配置。']],
|
||
['Hardware.specifications', ['structured-json', '五金规格。']],
|
||
['Hardware.dimensions', ['structured-json', '五金尺寸对象。']],
|
||
['Hardware.accessories', ['structured-json', '五金配件列表。']],
|
||
['Hardware.mountPoints', ['geometry-json', '五金连接点。']],
|
||
['ProductHardware.transform', ['geometry-json', '产品五金几何变换。']],
|
||
['ProductHardware.sourceSignature', ['geometry-json', '产品五金来源几何签名。']],
|
||
['CommonComponent.dimensions', ['structured-json', '通用组件尺寸对象。']],
|
||
['CommonComponent.specifications', ['structured-json', '通用组件规格。']],
|
||
['CommonComponent.mountPoints', ['geometry-json', '通用组件连接点。']],
|
||
[
|
||
'SystemConfig.value',
|
||
[
|
||
'configuration',
|
||
'系统配置 JSON 值;必须按 key 由服务层 schema 校验,不得保存用户、权限、产品、SKU、资产等业务主数据;数据库只保存配置真相,敏感密钥不得写入共享文档或前端。',
|
||
],
|
||
],
|
||
[
|
||
'ProviderConfig.extra',
|
||
[
|
||
'configuration',
|
||
'供应商/AI 提供方配置扩展;只保存该 provider/serviceType 的连接附加参数,不得承接业务主数据或权限真相;结构和密钥处理必须由 ProviderConfig 服务层按类型校验。',
|
||
],
|
||
],
|
||
].map(([field, [category, targetState]]) => [
|
||
field,
|
||
{ category, targetState },
|
||
]),
|
||
)
|
||
|
||
const schemaJsonFieldBoundaryRequirements = new Map([
|
||
[
|
||
'AssetVariant.businessData',
|
||
{
|
||
reason: 'AssetVariant.businessData 名称太宽,必须明确 CAD 快照和正式 SKU/成本/条码真相边界。',
|
||
requiredPatterns: [
|
||
/cadData/,
|
||
/assemblyBasis/,
|
||
/不能反推正式 SKU 身份、成本、条码或基础资料/,
|
||
/AssetVariant 结构化列/,
|
||
/ProductSkuAssetProfile/,
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'PreSkuPackagingPlan.costSummary',
|
||
{
|
||
reason: '包装成本摘要容易被误当正式 SKU 成本或财务成本,必须明确只是包装方案内部快照。',
|
||
requiredPatterns: [
|
||
/包装成本/,
|
||
/不是正式 SKU 成本或财务成本真相/,
|
||
/正式 BOM\/核价结构化链路/,
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'PreSkuPackagingPlan.processDraft',
|
||
{
|
||
reason: '包装过程草稿必须明确不能承接正式 SKU 基础资料、库存、成本结论或权限真相。',
|
||
requiredPatterns: [
|
||
/过程草稿/,
|
||
/不得承接正式 SKU 基础资料/,
|
||
/库存、成本结论或权限真相/,
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'PackagedItem.payload',
|
||
{
|
||
reason: '轻仓库包装 payload 名称太宽,必须明确不能承接成本、库存、SKU 身份或基础资料真相。',
|
||
requiredPatterns: [
|
||
/包装项过程扩展载荷/,
|
||
/不得承接成本、库存、SKU 身份或基础资料真相/,
|
||
/PackagedItem\/PackagingPlan\/PreSkuPackagingPlan 结构化字段/,
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'EmployeeLookupCache.result',
|
||
{
|
||
reason: '员工匹配缓存不能变成人员、部门、权限或业务类型真相。',
|
||
requiredPatterns: [
|
||
/短期缓存/,
|
||
/不是人员、部门、权限或业务类型真相/,
|
||
/User\/Department\/EmployeeDirectory\/Team 结构化表/,
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'SystemConfig.value',
|
||
{
|
||
reason: '通用系统配置 JSON 必须按 key 校验,不能成为业务主数据万能口袋。',
|
||
requiredPatterns: [
|
||
/按 key/,
|
||
/服务层 schema 校验/,
|
||
/不得保存用户、权限、产品、SKU、资产等业务主数据/,
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'ProviderConfig.extra',
|
||
{
|
||
reason: '供应商扩展配置必须绑定 provider/serviceType,不能承接业务主数据或权限真相。',
|
||
requiredPatterns: [
|
||
/provider\/serviceType/,
|
||
/不得承接业务主数据或权限真相/,
|
||
/ProviderConfig 服务层按类型校验/,
|
||
],
|
||
},
|
||
],
|
||
])
|
||
|
||
function findSchemaJsonFieldBoundaryIssues(
|
||
classifications = schemaJsonFieldClassifications,
|
||
requirements = schemaJsonFieldBoundaryRequirements,
|
||
) {
|
||
const issues = []
|
||
|
||
for (const [id, requirement] of requirements.entries()) {
|
||
const classification = classifications.get(id)
|
||
if (!classification) {
|
||
issues.push({
|
||
ruleId: 'schema-json-field-boundary-missing',
|
||
id,
|
||
reason: requirement.reason,
|
||
missingRequiredPatterns: requirement.requiredPatterns.map(String),
|
||
})
|
||
continue
|
||
}
|
||
|
||
const missingRequiredPatterns = requirement.requiredPatterns.filter((pattern) => {
|
||
pattern.lastIndex = 0
|
||
return !pattern.test(classification.targetState)
|
||
})
|
||
|
||
if (missingRequiredPatterns.length === 0) continue
|
||
|
||
issues.push({
|
||
ruleId: 'schema-json-field-boundary-too-vague',
|
||
id,
|
||
category: classification.category,
|
||
targetState: classification.targetState,
|
||
reason: requirement.reason,
|
||
missingRequiredPatterns: missingRequiredPatterns.map(String),
|
||
})
|
||
}
|
||
|
||
return issues
|
||
}
|
||
|
||
function getSchemaJsonFieldInventory() {
|
||
const content = fs.readFileSync(PRISMA_SCHEMA_PATH, 'utf8')
|
||
const fields = []
|
||
let currentModel = ''
|
||
|
||
content.split(/\r?\n/).forEach((line, index) => {
|
||
const modelMatch = line.match(/^model\s+(\w+)\s*\{/)
|
||
if (modelMatch) {
|
||
currentModel = modelMatch[1]
|
||
return
|
||
}
|
||
if (currentModel && /^}/.test(line)) {
|
||
currentModel = ''
|
||
return
|
||
}
|
||
if (!currentModel || !/\bJson\b/.test(line)) return
|
||
|
||
const fieldName = line.trim().split(/\s+/)[0]
|
||
const id = `${currentModel}.${fieldName}`
|
||
const classification = schemaJsonFieldClassifications.get(id)
|
||
fields.push({
|
||
id,
|
||
model: currentModel,
|
||
field: fieldName,
|
||
line: index + 1,
|
||
category: classification?.category ?? 'unclassified',
|
||
targetState: classification?.targetState ?? '',
|
||
})
|
||
})
|
||
|
||
return fields
|
||
}
|
||
|
||
function findStaleSchemaJsonFieldClassifications(schemaJsonFields) {
|
||
const schemaJsonFieldIds = new Set(schemaJsonFields.map((field) => field.id))
|
||
return Array.from(schemaJsonFieldClassifications.entries())
|
||
.filter(([id]) => !schemaJsonFieldIds.has(id))
|
||
.map(([id, classification]) => ({
|
||
id,
|
||
category: classification.category,
|
||
targetState: classification.targetState,
|
||
}))
|
||
}
|
||
|
||
function getSchemaJsonFieldClassifications() {
|
||
return new Map(schemaJsonFieldClassifications)
|
||
}
|
||
|
||
function findStaleFileClassifications(groups) {
|
||
const findings = []
|
||
|
||
for (const group of groups) {
|
||
for (const file of group.files) {
|
||
if (fs.existsSync(path.join(ROOT_DIR, file))) continue
|
||
findings.push({
|
||
group: group.name,
|
||
file,
|
||
reason: group.reason,
|
||
})
|
||
}
|
||
}
|
||
|
||
return findings
|
||
}
|
||
|
||
function buildSnapshotReadBudgetViolations(summary, budget, label) {
|
||
const actual = {
|
||
total: summary.total ?? 0,
|
||
high: summary.high ?? 0,
|
||
medium: summary.medium ?? 0,
|
||
unknown: summary.unknown ?? 0,
|
||
}
|
||
|
||
return Object.entries(budget)
|
||
.filter(([, expectedMax]) => typeof expectedMax === 'number')
|
||
.filter(([key, expectedMax]) => (actual[key] ?? 0) > expectedMax)
|
||
.map(([key, expectedMax]) => ({
|
||
label,
|
||
key,
|
||
actual: actual[key] ?? 0,
|
||
expectedMax,
|
||
reason:
|
||
'JSON/快照读点债务预算被突破;新增读点必须结构化、降级为明确兼容入口,或同步更新预算和治理说明。',
|
||
}))
|
||
}
|
||
|
||
function buildSnapshotReadBudgetReport(compatibilitySummary, processSummary) {
|
||
const compatibilityBudgetViolations = buildSnapshotReadBudgetViolations(
|
||
compatibilitySummary.compatibilitySnapshotReadSummary,
|
||
SNAPSHOT_READ_DEBT_BUDGETS.compatibilitySnapshotRead,
|
||
'compatibilitySnapshotRead',
|
||
)
|
||
const processBudgetViolations = buildSnapshotReadBudgetViolations(
|
||
processSummary.processSnapshotReadSummary,
|
||
SNAPSHOT_READ_DEBT_BUDGETS.processSnapshotRead,
|
||
'processSnapshotRead',
|
||
)
|
||
|
||
return {
|
||
budgets: SNAPSHOT_READ_DEBT_BUDGETS,
|
||
compatibilitySnapshotReadBudgetViolationCount: compatibilityBudgetViolations.length,
|
||
compatibilitySnapshotReadBudgetViolations: compatibilityBudgetViolations,
|
||
processSnapshotReadBudgetViolationCount: processBudgetViolations.length,
|
||
processSnapshotReadBudgetViolations: processBudgetViolations,
|
||
}
|
||
}
|
||
|
||
const formalPreSkuFields = [
|
||
'skuCode',
|
||
'draftSkuCode',
|
||
'skuSequenceCode',
|
||
'formalSkuCode',
|
||
'barcode',
|
||
'specName',
|
||
'versionNo',
|
||
'model',
|
||
]
|
||
|
||
const rules = [
|
||
{
|
||
id: 'pre-sku-legacy-text-reader-for-formal-fields',
|
||
severity: 'error',
|
||
description:
|
||
'准 SKU 正式字段不能再通过 readPreSkuTextField 从 displaySummary/familySelection 读取。',
|
||
pattern: new RegExp(
|
||
`(?<!function\\s)readPreSkuTextField\\s*\\([\\s\\S]{0,260}?['"\`](${formalPreSkuFields.join('|')})['"\`]`,
|
||
'g',
|
||
),
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'pre-sku-display-summary-formal-dot-read',
|
||
severity: 'error',
|
||
description:
|
||
'准 SKU 正式字段主读必须走 PreSkuBasicInfo,不能点读 displaySummary/familySelection。',
|
||
pattern: new RegExp(
|
||
`\\b(displaySummary|familySelection)\\??\\.\\s*(${formalPreSkuFields.join('|')})\\b`,
|
||
'g',
|
||
),
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'pre-sku-display-summary-formal-bracket-read',
|
||
severity: 'error',
|
||
description:
|
||
'准 SKU 正式字段主读必须走 PreSkuBasicInfo,不能 bracket 读取 displaySummary/familySelection。',
|
||
pattern: new RegExp(
|
||
`\\b(displaySummary|familySelection)\\s*\\[\\s*['"\`](${formalPreSkuFields.join('|')})['"\`]\\s*\\]`,
|
||
'g',
|
||
),
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'pre-sku-display-summary-repository-call',
|
||
severity: 'error',
|
||
description:
|
||
'准 SKU 冲突检测必须查 PreSkuBasicInfo;禁止恢复 findByDisplaySummaryField(s) 这类 JSON path 查询入口。',
|
||
pattern: /\bfindByDisplaySummaryFields?\s*\(/g,
|
||
isAllowed: () => false,
|
||
},
|
||
{
|
||
id: 'product-sku-attributes-sku-name-read',
|
||
severity: 'error',
|
||
description:
|
||
'正式 SKU 名称必须使用 ProductSKU.name;attributes.skuName 只能作为 legacyAttributes 展示或测试样本,不能参与正式写入。',
|
||
pattern: /\battributes\??\.\s*skuName\b/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'product-sku-upload-attributes-sku-name-write',
|
||
severity: 'error',
|
||
description:
|
||
'上传链 skuAttributes.skuName 只能作为兼容属性保存,不能反写 ProductSKU.name。',
|
||
pattern: /\bskuAttributes\w*\??\.\s*skuName\b/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'product-sku-legacy-selection-fallback',
|
||
severity: 'error',
|
||
description:
|
||
'正式入库相似度判断必须读取 sourcePreSku;variantSelectionSummary/bomPreview 旧快照不能参与正式阻断判断。',
|
||
pattern: /\battributes\??\.\s*(variantSelectionSummary|bomPreview)\b/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'product-sku-asset-snapshot-direct-read',
|
||
severity: 'error',
|
||
description:
|
||
'assetSkuSnapshot 是兼容快照;业务判断必须读取 ProductSkuAssetProfile,不能再直接读取旧快照。',
|
||
pattern: /\battributes\??\.\s*assetSkuSnapshot\b|path:\s*\[\s*['"]assetSkuSnapshot/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'asset-variant-business-data-cad-data-direct-read',
|
||
severity: 'error',
|
||
description:
|
||
'AssetVariant.businessData.cadData 只能作为资产中心 CAD 几何兼容快照恢复 assemblyBasis;不能反推正式 SKU 身份、成本、条码或基础资料。',
|
||
pattern: /\bbusinessData\??\.\s*cadData\b/g,
|
||
isAllowed: ({ relativePath }) =>
|
||
isTestFile(relativePath) ||
|
||
relativePath === 'src/app/asset-center/asset-center-utils.ts' ||
|
||
relativePath === 'src/lib/open-product/asset-similarity.ts' ||
|
||
relativePath === 'src/server/services/asset-center.service.ts',
|
||
},
|
||
{
|
||
id: 'approval-workflow-field-matrix-permission-fallback',
|
||
severity: 'error',
|
||
description:
|
||
'审批流程访问权限必须读取结构化 businessRoles / participantUserIds;fieldMatrix 只能表达字段责任矩阵,不能反推流程可见范围。',
|
||
pattern: /\bgetWorkflowBusinessRoles\(\s*this\.getFieldMatrix\(\s*workflow\.fieldMatrix/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'reference-library-payload-identity-read',
|
||
severity: 'error',
|
||
description:
|
||
'资料库 payload 只能保存扩展展示字段;id/itemKey/code/name/category 等身份字段必须读取 IntakeReferenceLibraryItem 结构化列。',
|
||
pattern:
|
||
/\b(?:readText|readPayloadText)\(\s*(?:payload|nestedItem|source|rulePayload)\.(?:id|itemKey|code|name|letter|category|productCategory|categoryCode|prefix)\s*\)/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'formal-product-date-now-identity-fallback',
|
||
severity: 'error',
|
||
description:
|
||
'正式产品型号是主数据身份,不能用 Date.now() 生成临时 SPU;新建正式产品必须显式传入并校验 modelNumber。',
|
||
pattern: /SPU-\$\{\s*Date\.now\(\)\s*\}/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'draft-group-date-now-code-fallback',
|
||
severity: 'error',
|
||
description:
|
||
'草稿组编号是辅助装配承接单身份,不能只用 Date.now() 生成;必须使用可读前缀并带足够熵,数据库唯一索引只做兜底。',
|
||
pattern: /DG-\$\{\s*Date\.now\(\)\s*\}/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'formal-sku-code-fallback-after-structured-basic-info',
|
||
severity: 'error',
|
||
description:
|
||
'正式入库 ProductSKU.skuCode 只能来自 PreSkuBasicInfo.formalSkuCode;不能回退到接口临时 skuCode 或 modelNumber 序号。',
|
||
pattern: /readPreSkuBasicInfo\s*\(\s*preSku\s*,\s*['"`]formalSkuCode['"`]\s*\)\s*\|\|/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'pre-sku-formalized-status-write-outside-intake',
|
||
severity: 'error',
|
||
description:
|
||
'准 SKU 标记 FORMALIZED 只能发生在正式入库事务内;生命周期修复不能绕过专用证据计划直接改 PreSku.status。',
|
||
pattern:
|
||
/\b(?:prisma|tx)\.preSku\.(?:update|updateMany)\s*\([\s\S]{0,600}?data:\s*\{[\s\S]{0,240}?status:\s*['"`]FORMALIZED['"`]/g,
|
||
isAllowed: ({ relativePath }) =>
|
||
relativePath === 'src/server/services/intake.service.ts' || isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'product-sku-source-pre-sku-write-outside-intake',
|
||
severity: 'error',
|
||
description:
|
||
'ProductSKU.sourcePreSkuId 是准 SKU 正式化关系,生产写入只能由正式入库事务创建;历史修复必须走带 currentEvidence 的专用修复入口。',
|
||
pattern:
|
||
/\b(?:prisma|tx)\.productSKU\.(?:create|update|updateMany|upsert)\s*\([\s\S]{0,2200}?sourcePreSkuId\s*:/g,
|
||
isAllowed: ({ relativePath }) =>
|
||
relativePath === 'src/server/services/intake.service.ts' || isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'sku-code-conflict-auto-suffix',
|
||
severity: 'error',
|
||
description:
|
||
'SKU 编码冲突不能自动追加 -1/-2 后缀静默改码;必须阻断并回到业务资料层处理。',
|
||
pattern: /\b(?:skuCode|candidate)\s*=\s*`\$\{baseSkuCode\}-\$\{suffix\}`/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'core-identity-shorthand-query-without-normalization',
|
||
severity: 'error',
|
||
description:
|
||
'核心编码/条码查询不能用 where: { code } 这类缩写依赖调用方已规范化;必须显式 trim + uppercase 或调用 normalizeCoreIdentity。',
|
||
pattern: /where:\s*\{\s*(?:code|skuCode|modelNumber|barcode)\s*\}/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'core-identity-direct-query-without-normalization',
|
||
severity: 'error',
|
||
description:
|
||
'核心编码/条码查询不能把同名变量直接作为查询值;仓储和服务入口必须显式 normalizeCoreIdentity 或同口径规范化变量。',
|
||
pattern:
|
||
/where:\s*\{[\s\S]{0,180}\b(code|skuCode|modelNumber|barcode):\s*\1\b[\s\S]{0,180}\}/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'non-integrated-payload-filter-without-profile-guard',
|
||
severity: 'error',
|
||
description:
|
||
'非集成产品列表/筛选/待办查询必须以 IntakeNonIntegratedProductProfile 为主读;payload JSON 查询只能包在 payloadJsonWhenProfileMissing(...) 里作为历史兜底。',
|
||
pattern:
|
||
/(?<!payloadJsonWhenProfileMissing\()jsonText(?:Equals|Contains|MissingOrNotEquals)\(\s*\[[^\]]+\]/g,
|
||
isAllowed: ({ relativePath }) =>
|
||
relativePath !== 'src/server/services/intake-non-integrated-product.service.ts' ||
|
||
isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'non-integrated-raw-payload-update-entrypoint',
|
||
severity: 'error',
|
||
description:
|
||
'非集成产品 payload 是历史兼容快照;禁止恢复 updatePayloadByUser 这种通用写入口,附件/预览等兼容写入必须使用明确命名入口。',
|
||
pattern: /\bupdatePayloadByUser\s*\(/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'app-route-critical-foundation-direct-write',
|
||
severity: 'error',
|
||
description:
|
||
'Route/Page 层不能直接写配置、权限、反馈、审计和 AI 生图地基表;必须走 service/repository 的规范化写入口。',
|
||
pattern:
|
||
/\b(?:prisma|tx)\.(systemConfig|providerConfig|themeConfig|intakeNodePermissionOverride|assistedAssemblyAiRender|feedback|activityLog)\.(create|update|upsert|delete|deleteMany|updateMany)\s*\(/g,
|
||
skip: ({ relativePath }) => !relativePath.startsWith('src/app/') || isTestFile(relativePath),
|
||
isAllowed: () => false,
|
||
},
|
||
{
|
||
id: 'app-route-core-master-data-direct-write',
|
||
severity: 'error',
|
||
description:
|
||
'Route/Page 层不能直接写产品资料、资产中心、草稿组和非集成主数据表;必须走 service/repository,保证编码、快照同步和质量状态护栏统一生效。',
|
||
pattern:
|
||
/\b(?:prisma|tx)\.(product|productSKU|productSkuAssetProfile|draftGroup|preSku|preSkuBasicInfo|assetFamily|assetVariant|intakeNonIntegratedProduct|intakeNonIntegratedProductProfile|intakeNonIntegratedSkuItem|intakeNonIntegratedPricingRow|masterDataQualityDecision|productPatentRecord|spuNumberCounter|intakeReferenceLibraryItem)\.(create|createMany|update|updateMany|upsert|delete|deleteMany)\s*\(/g,
|
||
skip: ({ relativePath }) => !relativePath.startsWith('src/app/') || isTestFile(relativePath),
|
||
isAllowed: () => false,
|
||
},
|
||
{
|
||
id: 'spu-number-standalone-reservation',
|
||
severity: 'error',
|
||
description:
|
||
'正式 SPU 号段不能单独占号;必须在非集成产品保存事务里调用 reserveInTransaction,确保号段游标和产品资料同事务提交或回滚。',
|
||
pattern: /\bspuNumberReservationService\.reserve\s*\(/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'filesystem-json-master-data-source',
|
||
severity: 'error',
|
||
description:
|
||
'应用层不能把本地 JSON 文件读写或导入当作主数据真相;正式资料必须进入数据库或明确的服务端契约,文件 JSON 只能作为导入载荷、迁移计划、备份或测试夹具。',
|
||
pattern:
|
||
/\b(?:fs\.)?(?:promises\.)?(?:readFileSync|writeFileSync|readFile|writeFile)\s*\([\s\S]{0,220}\.json['"`][\s\S]{0,180}\)|\bimport\s+[^;\n]+?\s+from\s+['"`][^'"`]+\.json['"`]|\brequire\s*\(\s*['"`][^'"`]+\.json['"`]\s*\)|\bimport\s*\(\s*['"`][^'"`]+\.json['"`]\s*\)/g,
|
||
// DIY 的两个固定样本仅提供几何预览/参考明细,方案存入 DiyScheme;禁止写正式产品、SKU 或订单。
|
||
isAllowed: ({ relativePath, match }) => isTestFile(relativePath) ||
|
||
(relativePath === 'src/lib/diy/catalog.ts' && /^import (spuData|catalogData) from '\.\/data\/(spu|catalog)-SJ0380\.json'$/.test(match[0])),
|
||
},
|
||
{
|
||
id: 'reference-library-item-key-id-fallback',
|
||
severity: 'error',
|
||
description:
|
||
'资料库条目的业务身份只能来自显式 itemKey 或稳定 code;不能用数据库 id / 前端临时 id 兜底 itemKey。',
|
||
pattern: /\bitemKey\s*=\s*[^;\n]*\|\|\s*id\s*\|\|/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'reference-library-item-key-direct-item-id',
|
||
severity: 'error',
|
||
description:
|
||
'资料库保存载荷不能直接把 item.id 当 itemKey;有业务编码的资料必须用 code,缺编码的资料要先定义稳定身份规则。',
|
||
pattern: /\bitemKey:\s*item\.id\b/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'reference-library-item-key-item-id-fallback',
|
||
severity: 'error',
|
||
description:
|
||
'资料库保存载荷不能用 code || item.id 这类兜底;有业务编码的资料缺 code 时应阻断,缺编码资料必须使用内容规格派生稳定 key。',
|
||
pattern: /\bitemKey:\s*[^,\n]*\|\|\s*item\.id\b/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'reference-library-item-prefixed-ui-id',
|
||
severity: 'error',
|
||
description:
|
||
'资料库保存载荷不能用 `item:${item.id}` / `category:${category.id}` 这类 UI 行 id 派生身份;必须使用业务 code 或内容规格派生的稳定 key。',
|
||
pattern:
|
||
/(?:\b(?:const|let)\s+itemKey\s*=\s*|\b(?:itemKey|code):\s*)`[\w-]+:\$\{[A-Za-z_$][\w$]*\.id\}`/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
{
|
||
id: 'jst-stock-payload-source-identity-read',
|
||
severity: 'error',
|
||
description:
|
||
'聚水潭存量商品来源身份必须以 IntakeNonIntegratedProductProfile.source* 或 jst-stock-* 业务 ID 为准;payload.metadata.source* 只能作为兼容快照,不能参与同步查找、更新或排除判断。',
|
||
pattern:
|
||
/\bisJstSkuPayload\s*\(|payload:\s*\{\s*path:\s*\[\s*['"]metadata['"]\s*,\s*['"]sourceSpu['"]/g,
|
||
isAllowed: ({ relativePath }) => isTestFile(relativePath),
|
||
},
|
||
]
|
||
|
||
const advisoryStoragePaths = new Map([])
|
||
|
||
const approvedScriptMasterDataWritePaths = new Map([
|
||
['prisma/seed.ts', '开发种子数据脚本,只允许用于初始化本地/测试数据,不作为生产主数据修复入口。'],
|
||
['scripts/add-more-skus.ts', '历史演示 SKU 补数脚本,保留为本地测试工具,不作为生产主数据修复入口。'],
|
||
[
|
||
'scripts/cleanup-legacy-product-structure.ts',
|
||
'旧 CAD 结构清理脚本;默认 dry-run,--apply 前必须人工确认目标环境、候选清单和备份。',
|
||
],
|
||
['scripts/create-demo-data.js', '演示数据重建脚本,保留为本地测试工具,不作为生产主数据修复入口。'],
|
||
['scripts/seed-test-products.ts', '测试产品种子脚本,保留为测试工具,不作为生产主数据修复入口。'],
|
||
[
|
||
'scripts/sync-board-material-library.ts',
|
||
'板材/花色资料库同步维护脚本;只允许受控同步基础资料,不作为临时补数入口。',
|
||
],
|
||
[
|
||
'scripts/sync-jst-sku-stock-products.ts',
|
||
'聚水潭存量商品同步脚本;只允许从已确认来源库同步到已确认目标库,不作为临时补数入口。',
|
||
],
|
||
[
|
||
'scripts/sync-standard-finishes.ts',
|
||
'标准花色同步维护脚本;只允许受控同步基础资料,不作为临时补数入口。',
|
||
],
|
||
[
|
||
'scripts/apply-pre-sku-formalization-repair.js',
|
||
'准 SKU 生命周期专项修复入口;只允许处理 currentEvidence/evidenceFingerprint 匹配的历史错位,不作为正式入库替代入口。',
|
||
],
|
||
[
|
||
'scripts/archive-jst-unresolved-non-integrated-products.js',
|
||
'聚水潭历史残留软归档入口;只允许处理来源匹配报告证明无法唯一补分类且无下游引用的非集成产品,默认 dry-run,--apply 必须带确认、库身份和备份 SHA。',
|
||
],
|
||
[
|
||
'scripts/migrate-historical-assets-to-qiniu.ts',
|
||
'七牛历史素材一次性迁移脚本;默认 dry-run,--apply 前必须确认迁移计划、备份和素材替换范围,只允许更新旧素材 URL 引用。',
|
||
],
|
||
['scripts/update-demo-models.js', '演示模型数据脚本,保留为本地测试工具,不作为生产主数据修复入口。'],
|
||
['scripts/update-model-urls.ts', '旧模型 URL 维护脚本,执行前必须人工确认目标环境和备份。'],
|
||
['scripts/update-test-data.ts', '测试数据维护脚本,保留为测试工具,不作为生产主数据修复入口。'],
|
||
])
|
||
|
||
const approvedScriptSafetyGuardRequirements = new Map([
|
||
[
|
||
'prisma/seed.ts',
|
||
{
|
||
reason: '种子脚本会清核心表,必须同时具备显式确认、本机库限制和 production 拦截。',
|
||
requiredTexts: [
|
||
'reset-local-seed',
|
||
'NODE_ENV',
|
||
'production',
|
||
'localhost',
|
||
'127.0.0.1',
|
||
"await import('../src/lib/prisma')",
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'scripts/create-demo-data.js',
|
||
{
|
||
reason: '演示数据脚本会清核心表,必须同时具备显式确认、本机库限制和 production 拦截。',
|
||
requiredTexts: [
|
||
'reset-local-demo',
|
||
'NODE_ENV',
|
||
'production',
|
||
'localhost',
|
||
'127.0.0.1',
|
||
'assertLocalDestructiveDemoAllowed()\n createPrismaClient()',
|
||
'PrismaPg',
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'scripts/update-demo-models.js',
|
||
{
|
||
reason: '演示模型脚本会清核心表,必须同时具备显式确认、本机库限制和 production 拦截。',
|
||
requiredTexts: [
|
||
'reset-local-demo',
|
||
'NODE_ENV',
|
||
'production',
|
||
'localhost',
|
||
'127.0.0.1',
|
||
'assertLocalDestructiveDemoAllowed()\n createPrismaClient()',
|
||
'PrismaPg',
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'scripts/seed-open-platform-demo.ts',
|
||
{
|
||
reason: '开放平台演示种子会写产品、资产、草稿、仓库和用户资料,必须同时具备显式确认、本机库限制和 production 拦截。',
|
||
requiredTexts: [
|
||
'seed-local-open-platform-demo',
|
||
'NODE_ENV',
|
||
'production',
|
||
'localhost',
|
||
'127.0.0.1',
|
||
"await Promise.all([",
|
||
"import('../src/lib/prisma')",
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'scripts/add-more-skus.ts',
|
||
{
|
||
reason: '演示 SKU 补数脚本会写正式 SKU,必须同时具备显式确认、本机库限制和 production 拦截。',
|
||
requiredTexts: [
|
||
'add-local-demo-skus',
|
||
'NODE_ENV',
|
||
'production',
|
||
'localhost',
|
||
'127.0.0.1',
|
||
"await import('../src/lib/prisma')",
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'scripts/seed-test-products.ts',
|
||
{
|
||
reason: '测试产品种子脚本会写正式产品和 SKU,必须同时具备显式确认、本机库限制和 production 拦截。',
|
||
requiredTexts: [
|
||
'seed-local-test-products',
|
||
'NODE_ENV',
|
||
'production',
|
||
'localhost',
|
||
'127.0.0.1',
|
||
"await import('../src/lib/prisma')",
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'scripts/update-test-data.ts',
|
||
{
|
||
reason: '测试数据维护脚本会补写正式产品、SKU、分享和装配步骤,必须同时具备显式确认、本机库限制和 production 拦截。',
|
||
requiredTexts: [
|
||
'update-local-test-data',
|
||
'NODE_ENV',
|
||
'production',
|
||
'localhost',
|
||
'127.0.0.1',
|
||
"await import('../src/lib/prisma')",
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'scripts/cleanup-legacy-product-structure.ts',
|
||
{
|
||
reason: '旧 CAD 结构清理脚本 apply 会删除旧结构数据,必须绑定确认词、目标库身份和备份 SHA。',
|
||
requiredTexts: [
|
||
'cleanup-legacy-product-structure',
|
||
'--expected-database-identity',
|
||
'--expected-backup-sha256',
|
||
'DATABASE_BACKUP_FILE',
|
||
"await import('../src/lib/prisma')",
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'scripts/backfill-sku-structural-data.ts',
|
||
{
|
||
reason: 'SKU 结构数据回填会通过服务补写板件/孔位/五金结构,真实执行必须绑定确认词、目标库身份和备份 SHA。',
|
||
requiredTexts: [
|
||
'--apply',
|
||
'backfill-sku-structural-data',
|
||
'--expected-database-identity',
|
||
'--expected-backup-sha256',
|
||
'DATABASE_BACKUP_FILE',
|
||
'await Promise.all([',
|
||
"import('../src/lib/prisma')",
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'scripts/sync-board-material-library.ts',
|
||
{
|
||
reason: '板材基础资料同步会写基础资料,默认必须拒绝写库,真实执行必须绑定确认词、目标库身份和备份 SHA。',
|
||
requiredTexts: [
|
||
'--apply',
|
||
'默认禁止写库',
|
||
'sync-board-material-library',
|
||
'--expected-database-identity',
|
||
'--expected-backup-sha256',
|
||
'DATABASE_BACKUP_FILE',
|
||
'await import("../src/lib/prisma")',
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'scripts/sync-jst-sku-stock-products.ts',
|
||
{
|
||
reason: '聚水潭存量商品同步会写入产品资料,真实执行必须绑定确认词、目标库身份和来源库身份。',
|
||
requiredTexts: [
|
||
'sync-jst-sku-stock',
|
||
'--expected-database-identity',
|
||
'--expected-source-database-identity',
|
||
'DATABASE_URL',
|
||
'JST_SKU_DATABASE_URL',
|
||
"await Promise.all([",
|
||
"import('../src/lib/prisma')",
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'scripts/sync-material-textures.ts',
|
||
{
|
||
reason: '材质贴图同步现在会写入统一对象存储,默认必须拒绝真实上传,执行时必须绑定确认词。',
|
||
requiredTexts: [
|
||
'--apply',
|
||
'默认禁止上传对象存储',
|
||
'sync-material-textures',
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'scripts/sync-standard-finishes.ts',
|
||
{
|
||
reason: '标准饰面同步会写基础资料,默认必须拒绝写库,真实执行必须绑定确认词、目标库身份和备份 SHA。',
|
||
requiredTexts: [
|
||
'--apply',
|
||
'默认禁止写库',
|
||
'sync-standard-finishes',
|
||
'--expected-database-identity',
|
||
'--expected-backup-sha256',
|
||
'DATABASE_BACKUP_FILE',
|
||
"await import('../src/lib/prisma')",
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'scripts/backfill-asset-variant-thumbnails.ts',
|
||
{
|
||
reason: '缩略图回填会写资产资料和生成文件,默认必须拒绝写库,真实执行必须绑定确认词、目标库身份和备份 SHA,并且在门禁后加载服务。',
|
||
requiredTexts: [
|
||
'--apply',
|
||
'默认禁止写库/生成文件',
|
||
'backfill-asset-variant-thumbnails',
|
||
'--expected-database-identity',
|
||
'--expected-backup-sha256',
|
||
'DATABASE_BACKUP_FILE',
|
||
"import('../src/lib/prisma')",
|
||
"import('../src/server/services/asset-center.service')",
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'scripts/apply-pre-sku-formalization-repair.js',
|
||
{
|
||
reason: '准 SKU 生命周期修复会写 PreSku / ProductSKU / DraftGroup,真实执行必须绑定确认词、当前证据、目标库身份和备份 SHA。',
|
||
requiredTexts: [
|
||
'--apply',
|
||
'I_UNDERSTAND_THIS_UPDATES_PRE_SKU_LIFECYCLE',
|
||
'--expected-database-identity',
|
||
'--expected-backup-sha256',
|
||
'DATABASE_BACKUP_FILE',
|
||
'currentEvidence',
|
||
'evidenceFingerprint',
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'scripts/sync-employee-directory.ts',
|
||
{
|
||
reason: '花名册同步默认只能 dry-run,真实推送到系统内部接口必须显式 --apply。',
|
||
requiredTexts: ['--apply', '--dry-run', '|| !apply'],
|
||
},
|
||
],
|
||
[
|
||
'scripts/update-model-urls.ts',
|
||
{
|
||
reason: '旧模型 URL 维护脚本会批量改正式产品模型地址,必须同时具备显式确认、本机库限制和 production 拦截。',
|
||
requiredTexts: [
|
||
'update-local-model-urls',
|
||
'NODE_ENV',
|
||
'production',
|
||
'localhost',
|
||
'127.0.0.1',
|
||
"await import('../src/lib/prisma')",
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'scripts/performance-test.ts',
|
||
{
|
||
reason: '性能测试虽然只读,但会对数据库制造查询压力;必须限制为显式本机读库测试。',
|
||
requiredTexts: [
|
||
'run-local-performance-test',
|
||
'NODE_ENV',
|
||
'production',
|
||
'localhost',
|
||
'127.0.0.1',
|
||
"await import('../src/lib/prisma')",
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'scripts/install-employee-directory-sync-timer.sh',
|
||
{
|
||
reason: '生产花名册定时器必须显式传入 --apply,避免脚本默认 dry-run 后假同步。',
|
||
requiredTexts: ['sync-employee-directory.ts --apply'],
|
||
},
|
||
],
|
||
])
|
||
|
||
const approvedRawSqlMasterDataWritePaths = new Map([
|
||
[
|
||
'scripts/master-data-required-migrations.js',
|
||
'治理迁移清单只允许登记幂等 repairSql,用于补齐迁移副作用里的触发器、约束和历史字段清洗;实际执行仍由 foundation dry-run / rehearsal 包裹事务并受 --apply 边界控制。',
|
||
],
|
||
[
|
||
'scripts/apply-master-data-cleanup-plan.js',
|
||
'显式历史清洗计划使用原生 SQL 更新结构化主数据和兼容快照;默认 dry-run,--apply 必须带业务确认。',
|
||
],
|
||
[
|
||
'scripts/apply-pre-sku-formalization-repair.js',
|
||
'准 SKU 生命周期专项修复计划使用原生 SQL 更新 PreSku 状态、ProductSKU.sourcePreSkuId 和 DraftGroup 状态;默认 dry-run,--apply 必须带业务确认、当前证据、库身份和备份 SHA。',
|
||
],
|
||
[
|
||
'scripts/archive-jst-unresolved-non-integrated-products.js',
|
||
'聚水潭历史残留软归档计划使用原生 SQL 软删除非集成产品、清理其结构化 SKU/核价行并关闭开放质量决策;默认 dry-run,--apply 必须带业务确认、库身份和备份 SHA。',
|
||
],
|
||
])
|
||
|
||
const approvedRawSqlMasterDataWriteTableScopes = new Map(
|
||
[
|
||
[
|
||
'scripts/master-data-required-migrations.js',
|
||
[
|
||
'IntakeNonIntegratedSkuItem',
|
||
'IntakeNonIntegratedPricingRow',
|
||
'IntakeNonIntegratedProductProfile',
|
||
'PreSkuBasicInfo',
|
||
'ProductSkuAssetProfile',
|
||
'IntakeNonIntegratedProduct',
|
||
'ProductSKU',
|
||
],
|
||
],
|
||
[
|
||
'scripts/apply-master-data-cleanup-plan.js',
|
||
[
|
||
'IntakeNonIntegratedProduct',
|
||
'IntakeNonIntegratedProductProfile',
|
||
'IntakeNonIntegratedSkuItem',
|
||
'MasterDataQualityDecision',
|
||
],
|
||
],
|
||
[
|
||
'scripts/apply-pre-sku-formalization-repair.js',
|
||
[
|
||
'DraftGroup',
|
||
'PreSku',
|
||
'ProductSKU',
|
||
],
|
||
],
|
||
[
|
||
'scripts/archive-jst-unresolved-non-integrated-products.js',
|
||
[
|
||
'IntakeNonIntegratedProduct',
|
||
'IntakeNonIntegratedPricingRow',
|
||
'IntakeNonIntegratedSkuItem',
|
||
'MasterDataQualityDecision',
|
||
],
|
||
],
|
||
].map(([file, tableNames]) => [file, new Set(tableNames)]),
|
||
)
|
||
|
||
const approvedRawSqlMasterDataReadPaths = new Map([
|
||
[
|
||
'scripts/master-data-required-migrations.js',
|
||
'治理迁移清单中的 repairSql/dataCheck 允许读取受控目标表和对象存储引用字段,用于把旧 JSON 身份迁移到结构化列、校验历史素材 URL 是否已归一化;不得作为业务读取入口。',
|
||
],
|
||
[
|
||
'scripts/apply-master-data-cleanup-plan.js',
|
||
'显式历史清洗计划允许读取待清洗结构化主数据和兼容快照,用于 dry-run、模板输出和 apply 前后对账。',
|
||
],
|
||
[
|
||
'scripts/export-non-integrated-product-category-review.js',
|
||
'非集成产品分类审阅导出脚本只允许只读读取产品/profile/SKU 样例,生成业务确认清单和 cleanup plan 模板;不得直接写库或猜测分类。',
|
||
],
|
||
[
|
||
'scripts/match-jst-product-category-cleanup-plan.js',
|
||
'聚水潭来源分类匹配脚本只允许只读读取非集成产品/profile/SKU 和正式分类字典,结合 JST 来源库生成可审计 cleanup plan;不得直接写库或关键词猜分类。',
|
||
],
|
||
[
|
||
'scripts/archive-jst-unresolved-non-integrated-products.js',
|
||
'聚水潭历史残留软归档脚本允许读取非集成产品/profile/SKU、审批流、质检、草稿组和准 SKU 引用,用于归档前证据校验和 dry-run 对账。',
|
||
],
|
||
[
|
||
'scripts/apply-pre-sku-formalization-repair.js',
|
||
'准 SKU 生命周期专项修复入口允许读取 PreSku、ProductSKU、DraftGroup、包装和库位关系,用于 currentEvidence 校验、dry-run 和 apply 前事务内复核。',
|
||
],
|
||
[
|
||
'scripts/verify-master-data-foundation.js',
|
||
'内部主数据巡检脚本允许只读扫描核心表、索引、约束、触发器和外部视图,用于生成地基质量报告。',
|
||
],
|
||
[
|
||
'src/server/services/intake-node-permission-settings.service.ts',
|
||
'产品资料节点权限服务允许只读检查权限覆盖表结构和读取已保存覆盖规则;结构创建必须来自迁移。',
|
||
],
|
||
[
|
||
'src/server/repositories/intake-product-pricing-query.repository.ts',
|
||
'产品核价查询仓储允许只读检索结构化非集成核价行;禁止回退读取 payload.pricingRows。',
|
||
],
|
||
[
|
||
'src/server/services/spu-number-reservation.service.ts',
|
||
'SPU 号段服务允许在同一事务内读取 SPU_RULE 规则行和现有非集成产品号段,并用行锁保证并发安全。',
|
||
],
|
||
])
|
||
|
||
const approvedRawSqlMasterDataReadTableScopes = new Map([
|
||
[
|
||
'scripts/master-data-required-migrations.js',
|
||
new Set([
|
||
'IntakeNonIntegratedProduct',
|
||
'IntakeNonIntegratedProductProfile',
|
||
'IntakeNonIntegratedSkuItem',
|
||
'IntakeNonIntegratedPricingRow',
|
||
'IntakeReferenceLibraryItem',
|
||
'PreSku',
|
||
'PreSkuBasicInfo',
|
||
'Product',
|
||
'ProductSKU',
|
||
'ProductSkuAssetProfile',
|
||
'AssetVariant',
|
||
'Material',
|
||
'Hardware',
|
||
'CommonComponent',
|
||
]),
|
||
],
|
||
[
|
||
'scripts/apply-master-data-cleanup-plan.js',
|
||
new Set([
|
||
'IntakeNonIntegratedProduct',
|
||
'IntakeNonIntegratedProductProfile',
|
||
'IntakeNonIntegratedSkuItem',
|
||
'MasterDataQualityDecision',
|
||
]),
|
||
],
|
||
[
|
||
'scripts/export-non-integrated-product-category-review.js',
|
||
new Set([
|
||
'IntakeNonIntegratedProduct',
|
||
'IntakeNonIntegratedProductProfile',
|
||
'IntakeNonIntegratedSkuItem',
|
||
]),
|
||
],
|
||
[
|
||
'scripts/match-jst-product-category-cleanup-plan.js',
|
||
new Set([
|
||
'IntakeNonIntegratedProduct',
|
||
'IntakeNonIntegratedProductProfile',
|
||
'IntakeNonIntegratedSkuItem',
|
||
'IntakeReferenceLibraryItem',
|
||
'ProductCategory',
|
||
]),
|
||
],
|
||
[
|
||
'scripts/archive-jst-unresolved-non-integrated-products.js',
|
||
new Set([
|
||
'ApprovalWorkflow',
|
||
'DraftGroup',
|
||
'IntakeNonIntegratedPricingRow',
|
||
'IntakeNonIntegratedProduct',
|
||
'IntakeNonIntegratedProductProfile',
|
||
'IntakeNonIntegratedSkuItem',
|
||
'MasterDataQualityDecision',
|
||
'PreSku',
|
||
'ProductSkuQualityInspection',
|
||
]),
|
||
],
|
||
[
|
||
'scripts/apply-pre-sku-formalization-repair.js',
|
||
new Set([
|
||
'DraftGroup',
|
||
'PackagedItem',
|
||
'PreSku',
|
||
'PreSkuLocationBinding',
|
||
'PreSkuPackagingPlan',
|
||
'Product',
|
||
'ProductSKU',
|
||
]),
|
||
],
|
||
['scripts/verify-master-data-foundation.js', '*'],
|
||
[
|
||
'src/server/services/intake-node-permission-settings.service.ts',
|
||
new Set(['IntakeNodePermissionOverride']),
|
||
],
|
||
[
|
||
'src/server/repositories/intake-product-pricing-query.repository.ts',
|
||
new Set(['IntakeNonIntegratedProduct', 'IntakeNonIntegratedPricingRow']),
|
||
],
|
||
[
|
||
'src/server/services/spu-number-reservation.service.ts',
|
||
new Set([
|
||
'IntakeNonIntegratedProduct',
|
||
'IntakeReferenceLibraryItem',
|
||
]),
|
||
],
|
||
])
|
||
|
||
const approvedRuntimeDatabaseDdlPaths = new Map([
|
||
[
|
||
'scripts/master-data-required-migrations.js',
|
||
'治理迁移清单允许登记受控 repairSql 的 trigger/function/check constraint DDL;执行入口必须仍然通过迁移演练或地基巡检事务。',
|
||
],
|
||
])
|
||
|
||
const approvedClientStorageUsages = [
|
||
{
|
||
file: 'src/app/asset-center/AssetCenterClient.tsx',
|
||
category: 'ui-state',
|
||
targetState: 'server-owned-asset-series',
|
||
reason: '资产中心当前选中系列 UI 状态缓存,不作为主数据真相。',
|
||
patterns: [/ASSET_CENTER_SELECTED_SERIES_STORAGE_KEY/],
|
||
requiredEvidence: [
|
||
/const availableIds = new Set\(initialAssetSeries\.map\(\(series\) => series\.id\)\)/,
|
||
/window\.localStorage\.getItem\(ASSET_CENTER_SELECTED_SERIES_STORAGE_KEY\)/,
|
||
/savedSeriesId && availableIds\.has\(savedSeriesId\)/,
|
||
/initialAssetSeries\[0\]\?\.id \?\? null/,
|
||
],
|
||
},
|
||
{
|
||
file: 'src/app/first-login/page.tsx',
|
||
category: 'auth-display-cache',
|
||
targetState: 'server-owned-user',
|
||
reason: '首次登录后写入的客户端登录态缓存,用户主数据仍以后端数据库为准。',
|
||
patterns: [/localStorage\.setItem\(\s*['"]user['"]/],
|
||
requiredEvidence: [
|
||
/token 通过 httpOnly cookie 自动管理/,
|
||
/const user = data\.data\?\.user/,
|
||
/if \(!user\)/,
|
||
/localStorage\.setItem\('user', JSON\.stringify\(user\)\)/,
|
||
/router\.push\('\/'\)/,
|
||
],
|
||
},
|
||
{
|
||
file: 'src/app/HomeClient.workspace.tsx',
|
||
category: 'ui-state',
|
||
targetState: 'workspace-tab-session-only',
|
||
reason: '首页工作台 tab 会话缓存,不作为主数据真相。',
|
||
patterns: [/HOME_WORKSPACE_TAB_STORAGE_KEY/],
|
||
requiredEvidence: [
|
||
/const savedTab = sessionStorage\.getItem\(HOME_WORKSPACE_TAB_STORAGE_KEY\)/,
|
||
/savedTab === 'products' \|\| savedTab === 'drafts'/,
|
||
/sessionStorage\.removeItem\(HOME_WORKSPACE_TAB_STORAGE_KEY\)/,
|
||
/url\.searchParams\.set\('tab', activeTab\)/,
|
||
/window\.history\.replaceState\(window\.history\.state, '', url\.toString\(\)\)/,
|
||
],
|
||
forbiddenEvidence: [
|
||
/JSON\.parse\(\s*sessionStorage\.getItem\(HOME_WORKSPACE_TAB_STORAGE_KEY\)/,
|
||
],
|
||
},
|
||
{
|
||
file: 'src/app/home/hooks/usePersistedExpandedProduct.ts',
|
||
category: 'ui-state',
|
||
targetState: 'home-expanded-product-session-only',
|
||
reason: '首页产品展开和滚动位置会话缓存,不作为主数据真相。',
|
||
patterns: [
|
||
/RESTORE_EXPANDED_PRODUCT_STORAGE_KEY/,
|
||
/EXPANDED_PRODUCT_STORAGE_KEY/,
|
||
/HOME_SCROLL_Y_STORAGE_KEY/,
|
||
],
|
||
requiredEvidence: [
|
||
/sessionStorage\.getItem\(RESTORE_EXPANDED_PRODUCT_STORAGE_KEY\) === '1'/,
|
||
/sessionStorage\.removeItem\(RESTORE_EXPANDED_PRODUCT_STORAGE_KEY\)/,
|
||
/sessionStorage\.removeItem\(HOME_SCROLL_Y_STORAGE_KEY\)/,
|
||
/sessionStorage\.removeItem\(EXPANDED_PRODUCT_STORAGE_KEY\)/,
|
||
/Number\.isFinite\(savedScrollY\) \? savedScrollY : null/,
|
||
],
|
||
},
|
||
{
|
||
file: 'src/app/draft-groups/DraftGroupsFormalClient.tsx',
|
||
category: 'ui-state',
|
||
targetState: 'draft-group-formal-pane-width-cache-only',
|
||
reason: '草稿组正式化左右面板宽度缓存,只影响本机 UI 布局,不作为草稿组、SKU 或产品资料真相。',
|
||
patterns: [
|
||
/FORMAL_LEFT_PANE_WIDTH_STORAGE_KEY/,
|
||
/FORMAL_RIGHT_PANE_WIDTH_STORAGE_KEY/,
|
||
/function\s+readStoredPaneWidth\(/,
|
||
],
|
||
requiredEvidence: [
|
||
/function\s+readStoredPaneWidth\(/,
|
||
/Number\.parseInt\(window\.localStorage\.getItem\(storageKey\) \?\? '', 10\)/,
|
||
/Number\.isFinite\(parsed\) \? clampNumber\(parsed, min, max\) : fallback/,
|
||
/String\(Math\.round\(leftPaneWidth\)\)/,
|
||
/String\(Math\.round\(rightPaneWidth\)\)/,
|
||
],
|
||
},
|
||
{
|
||
file: 'src/app/draft-groups/DraftGroupsFormalClient.tsx',
|
||
category: 'process-draft-cache',
|
||
targetState: 'database-owned-formal-package-component-details',
|
||
reason: '草稿组正式化包内组件分配缓存只作为未推送前页面草稿;已推送 SKU 会同步到正式产品 skuItems.componentDetails。',
|
||
patterns: [/FORMAL_PACKAGE_COMPONENT_ASSIGNMENTS_STORAGE_KEY/],
|
||
requiredEvidence: [
|
||
/async function\s+syncPushedPackageAssignments\s*\(/,
|
||
/saveSharedNonIntegratedProduct\s*\(/,
|
||
/skuItems:\s*nextSkuItems/,
|
||
/componentDetails/,
|
||
/NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY/,
|
||
],
|
||
},
|
||
{
|
||
file: 'src/app/draft-groups/DraftGroupsFormalClient.tsx',
|
||
category: 'process-draft-cache',
|
||
targetState: 'database-owned-formal-package-material-details',
|
||
reason: '草稿组正式化包材分配缓存只作为未推送前页面草稿;已推送 SKU 会同步到正式产品 skuItems.packagingPlan.packageMaterialAssignments。',
|
||
patterns: [/FORMAL_PACKAGE_MATERIAL_ASSIGNMENTS_STORAGE_KEY/],
|
||
requiredEvidence: [
|
||
/async function\s+syncPushedPackageAssignments\s*\(/,
|
||
/saveSharedNonIntegratedProduct\s*\(/,
|
||
/skuItems:\s*nextSkuItems/,
|
||
/packageMaterialAssignments/,
|
||
/packagingPlan/,
|
||
],
|
||
},
|
||
{
|
||
file: 'src/app/intake/IntakeClient.tsx',
|
||
category: 'ui-state',
|
||
targetState: 'product-intake-list-filter-or-workspace-session',
|
||
reason: '产品资料页列表筛选和工作区 tab 状态缓存,不作为主数据真相。',
|
||
patterns: [
|
||
/readPersistedNonIntegratedListState[\s\S]{0,900}localStorage\.getItem\(\s*key/,
|
||
/writePersistedNonIntegratedListState[\s\S]{0,900}localStorage\.setItem\(\s*key/,
|
||
/getIntakeWorkspaceStateStorageKey\(/,
|
||
],
|
||
requiredEvidence: [
|
||
/normalizePersistedQuickScope\(value: unknown\)/,
|
||
/normalizePersistedPage\(value: unknown\)/,
|
||
/restoreIntakeWorkspaceTab\([\s\S]{0,120}params\.user/,
|
||
/isDesignControlledWorkspaceTab\(tab: IntakeWorkspaceTab\)/,
|
||
/isAdminOnlyWorkspaceTab\(tab: IntakeWorkspaceTab\)/,
|
||
/restrictWorkspaceStateForBusinessUser\(/,
|
||
/filterWorkspaceStateByFormalWorkspacePermissions\(/,
|
||
/canViewIntakeFormalWorkspace\(overrides, targetKey, user\)/,
|
||
],
|
||
forbiddenEvidence: [
|
||
/JSON\.parse\(rawState\)[\s\S]{0,500}return parsed/,
|
||
/window\.sessionStorage\.setItem\([\s\S]{0,500}props\.user/,
|
||
],
|
||
},
|
||
{
|
||
file: 'src/app/intake/non-integrated/NonIntegratedDetailClient.tsx',
|
||
category: 'process-draft-cache',
|
||
targetState: 'database-owned-non-integrated-product-and-bom',
|
||
reason: '非集成详情页 BOM 会话草稿缓存,只做未保存输入兜底,不作为产品/BOM 主数据真相。',
|
||
patterns: [/buildNonIntegratedBomDraftStorageKey\(/],
|
||
requiredEvidence: [
|
||
/parsedValue\.version !== NON_INTEGRATED_BOM_DRAFT_VERSION/,
|
||
/parsedValue\.productUpdatedAt !== productUpdatedAt/,
|
||
/skuItems\.some\(\(item\) => item\.id === parsedValue\.selectedSupplementSkuId\)/,
|
||
/normalizeBomDetailMap\(parsedValue\.bomDetails\)/,
|
||
/saveSharedNonIntegratedProduct\((?:nextProduct|productForSave),/,
|
||
/clearNonIntegratedBomDraftSnapshot\(productId\)/,
|
||
/if \(!shouldUseRemoteAssetStorage\(\)\) return;/,
|
||
],
|
||
},
|
||
{
|
||
file: 'src/lib/3d/playback-progress-manager.ts',
|
||
category: 'ui-state',
|
||
targetState: 'playback-progress-cache-only',
|
||
reason: '3D 播放进度缓存,不作为主数据真相。',
|
||
patterns: [/getStorageKey\(\)|storageKeyPrefix/],
|
||
requiredEvidence: [
|
||
/expirationTime: 7 \* 24 \* 60 \* 60 \* 1000/,
|
||
/maxSavedProgress: 50/,
|
||
/this\.cleanupExpired\(\)/,
|
||
/this\.isExpired\(progress\)/,
|
||
/this\.progressMap\.delete\(productId\)/,
|
||
/Array\.from\(this\.progressMap\.values\(\)\)/,
|
||
],
|
||
},
|
||
{
|
||
file: 'src/lib/preferences.tsx',
|
||
category: 'ui-preference',
|
||
targetState: 'browser-local-display-preference',
|
||
reason: '用户显示偏好缓存,不作为主数据真相。',
|
||
patterns: [/['"]apple-ui-font-size['"]/],
|
||
requiredEvidence: [
|
||
/prefersReducedMotion: window\.matchMedia\('\(prefers-reduced-motion: reduce\)'\)\.matches/,
|
||
/prefersHighContrast: window\.matchMedia\('\(prefers-contrast: high\)'\)\.matches/,
|
||
/function normalizeFontSizePreference\(value: string \| null\): number/,
|
||
/fontSize: normalizeFontSizePreference\(localStorage\.getItem\('apple-ui-font-size'\)\)/,
|
||
/fontSize: normalizeFontSizePreference\(e\.newValue\)/,
|
||
/const clampedSize = Math\.max\(100, Math\.min\(200, size\)\)/,
|
||
/localStorage\.setItem\('apple-ui-font-size', clampedSize\.toString\(\)\)/,
|
||
],
|
||
},
|
||
{
|
||
file: 'src/hooks/useAuth.ts',
|
||
category: 'auth-display-cache',
|
||
targetState: 'server-owned-user-and-permissions',
|
||
reason: '客户端登录态展示缓存,不作为人员或权限主数据真相。',
|
||
patterns: [/USER_STORAGE_KEY/],
|
||
requiredEvidence: [
|
||
/fetch\(\s*['"]\/api\/auth\/me['"]/,
|
||
/credentials:\s*['"]include['"]/,
|
||
/applyCachedDisplayState/,
|
||
/displayUser:\s*cachedUser/,
|
||
/user:\s*null/,
|
||
/isAuthenticated:\s*false/,
|
||
],
|
||
forbiddenEvidence: [
|
||
/isAuthenticated:\s*Boolean\(\s*cachedUser\s*\)/,
|
||
/applyAuthenticatedState\(\s*cachedUser\s*\)/,
|
||
],
|
||
},
|
||
]
|
||
|
||
function getApprovedClientStorageFiles() {
|
||
return new Set(approvedClientStorageUsages.map((usage) => usage.file))
|
||
}
|
||
|
||
function getMatchWindow(content, index, radius = 900) {
|
||
return content.slice(Math.max(0, index - radius), Math.min(content.length, index + radius))
|
||
}
|
||
|
||
function resolveApprovedClientStorageUsage({ relativePath, content, match }) {
|
||
const usage = approvedClientStorageUsages.find((candidate) => {
|
||
if (candidate.file !== relativePath) return false
|
||
const windowText = getMatchWindow(content, match.index ?? 0)
|
||
return candidate.patterns.some((pattern) => pattern.test(windowText))
|
||
})
|
||
|
||
return usage
|
||
? {
|
||
category: usage.category,
|
||
targetState: usage.targetState,
|
||
reason: usage.reason,
|
||
}
|
||
: null
|
||
}
|
||
|
||
function findApprovedClientStorageEvidenceIssues(rootDir = ROOT_DIR) {
|
||
const issues = []
|
||
|
||
for (const usage of approvedClientStorageUsages) {
|
||
if (!usage.requiredEvidence?.length) continue
|
||
|
||
const absolutePath = path.join(rootDir, usage.file)
|
||
if (!fs.existsSync(absolutePath)) {
|
||
issues.push({
|
||
ruleId: 'approved-client-storage-evidence-missing',
|
||
file: usage.file,
|
||
category: usage.category,
|
||
targetState: usage.targetState,
|
||
reason: '已批准的客户端缓存声明了数据库承接目标,但对应文件不存在,无法验证写回证据。',
|
||
missingEvidence: usage.requiredEvidence.map(String),
|
||
})
|
||
continue
|
||
}
|
||
|
||
const content = fs.readFileSync(absolutePath, 'utf8')
|
||
const missingEvidence = usage.requiredEvidence.filter((pattern) => {
|
||
pattern.lastIndex = 0
|
||
return !pattern.test(content)
|
||
})
|
||
const matchedForbiddenEvidence = (usage.forbiddenEvidence ?? []).filter((pattern) => {
|
||
pattern.lastIndex = 0
|
||
return pattern.test(content)
|
||
})
|
||
|
||
if (missingEvidence.length === 0 && matchedForbiddenEvidence.length === 0) continue
|
||
|
||
issues.push({
|
||
ruleId: 'approved-client-storage-evidence-missing',
|
||
file: usage.file,
|
||
category: usage.category,
|
||
targetState: usage.targetState,
|
||
reason: '已批准的客户端缓存缺少同文件权威承接证据,或仍保留会让客户端缓存变成第二数据真相的代码模式。',
|
||
missingEvidence: missingEvidence.map(String),
|
||
matchedForbiddenEvidence: matchedForbiddenEvidence.map(String),
|
||
})
|
||
}
|
||
|
||
return issues
|
||
}
|
||
|
||
const approvedServerMasterDataWritePaths = new Map([
|
||
['src/server/repositories/asset-center.repository.ts', '资产中心仓储层写入口,承接系列/资产族/规格款结构化主数据。'],
|
||
['src/server/repositories/assembly.repository.ts', '旧 CAD 装配步骤和装配版本仓储层写入口。'],
|
||
['src/server/repositories/auth.repository.ts', '认证仓储层写入口,承接用户和登录身份维护。'],
|
||
['src/server/repositories/common-component.repository.ts', '通用组件仓储层写入口。'],
|
||
['src/server/repositories/department.repository.ts', '组织部门仓储层写入口。'],
|
||
['src/server/repositories/draft-group.repository.ts', '草稿组仓储层写入口,承接 DraftGroup / PreSku / PreSkuBasicInfo。'],
|
||
['src/server/repositories/employee-directory.repository.ts', '花名册员工目录仓储层写入口。'],
|
||
['src/server/repositories/feedback.repository.ts', '反馈与反馈流程仓储层写入口。'],
|
||
['src/server/repositories/handover.repository.ts', '交接协作仓储层写入口,只承接协作过程记录。'],
|
||
['src/server/repositories/hardware.repository.ts', '五金基础资料仓储层写入口。'],
|
||
['src/server/repositories/hole.repository.ts', '旧 CAD 孔位兼容链仓储层写入口。'],
|
||
['src/server/repositories/intake-non-integrated-product.repository.ts', '非集成产品资料仓储层写入口,承接 profile / skuItems / pricingRows 结构化主数据。'],
|
||
['src/server/repositories/log.repository.ts', '操作日志仓储层写入口,只承接受控审计日志。'],
|
||
['src/server/repositories/material.repository.ts', '板材/材质基础资料仓储层写入口。'],
|
||
['src/server/repositories/packaging.repository.ts', '旧包装方案仓储层写入口。'],
|
||
['src/server/repositories/panel.repository.ts', '旧 CAD 板件兼容链仓储层写入口。'],
|
||
['src/server/repositories/planning.repository.ts', '规划域仓储层写入口,只承接过程模板和草稿。'],
|
||
['src/server/repositories/product-hardware.repository.ts', '旧 CAD 产品五金兼容链仓储层写入口。'],
|
||
['src/server/repositories/product-upload.repository.ts', '产品上传文件元数据仓储层写入口。'],
|
||
['src/server/repositories/product.repository.ts', '正式产品和正式 SKU 仓储层写入口。'],
|
||
['src/server/repositories/share.repository.ts', '分享链接仓储层写入口,只承接访问入口和统计。'],
|
||
['src/server/repositories/sku.repository.ts', '正式 SKU 仓储层写入口。'],
|
||
['src/server/repositories/sku-quality-inspection.repository.ts', '仓库质检归档仓储层写入口,承接首批质检自动正式 SKU 建档和质检记录维护。'],
|
||
['src/server/repositories/theme.repository.ts', '主题配置仓储层写入口。'],
|
||
['src/server/repositories/team.repository.ts', '团队仓储层写入口。'],
|
||
['src/server/repositories/user.repository.ts', '用户仓储层写入口。'],
|
||
['src/server/repositories/warehouse.repository.ts', '轻仓库和库位绑定仓储层写入口。'],
|
||
['src/server/services/approval-workflow.service.ts', '审批流程服务事务写入口,只承接过程审批和字段变更审计。'],
|
||
['src/server/services/asset-center.service.ts', '资产中心服务层事务写入口,负责编码、系列归属和规格款校验。'],
|
||
['src/server/services/asset-sku-sync.service.ts', '资产到正式 SKU 同步服务写入口,负责 ProductSkuAssetProfile 和兼容副本同步。'],
|
||
['src/server/services/assisted-assembly-ai-render.service.ts', '辅助装配 AI 生图服务写入口,负责生图过程记录。'],
|
||
['src/server/services/department.service.ts', '组织部门服务写入口,负责部门规范化和树关系维护。'],
|
||
['src/server/services/dingtalk-auth.service.ts', '钉钉认证服务写入口,只负责登录用户映射维护。'],
|
||
['src/server/services/external-employee-directory.service.ts', '外部花名册服务写入口,负责员工目录同步导入。'],
|
||
['src/server/services/feedback-workflow.helper.ts', '反馈流程 helper 事务写入口,承接反馈流转过程数据。'],
|
||
['src/server/services/feedback-workflow.service.ts', '反馈流程服务写入口,承接反馈流转过程数据。'],
|
||
['src/server/services/hardware-matcher.runtime.ts', '五金匹配运行时写入口,只维护受控匹配记忆。'],
|
||
['src/server/services/hardware-write-ops.ts', '五金写操作服务入口,负责五金基础资料规范化。'],
|
||
['src/server/services/intake-non-integrated-product.service.ts', '非集成产品资料服务写入口,负责 payload 到 profile / skuItems / pricingRows 的结构化承接。'],
|
||
['src/server/services/intake-node-permission-settings.service.ts', '产品资料节点权限覆盖服务写入口。'],
|
||
['src/server/services/intake-reference-library.service.ts', '产品资料侧资料库服务写入口,负责 itemKey/code 唯一池。'],
|
||
['src/server/services/intake.service.ts', '正式入库服务事务写入口,负责草稿到 Product/ProductSKU 的唯一正式落库链路。'],
|
||
['src/server/services/jst-sku-stock-sync.service.ts', '聚水潭存量商品同步服务写入口,负责外部来源和结构化 profile/skuItems 双写。'],
|
||
['src/server/services/logger.service.ts', '操作日志服务写入口,只承接审计日志。'],
|
||
['src/server/services/open-platform-demo-seed.service.ts', '开放平台演示数据服务,仅用于 demo/test 种子,不作为生产主数据修复入口。'],
|
||
['src/server/services/pre-sku-packaging.service.ts', '准 SKU 包装方案服务写入口,负责 PreSkuPackagingPlan 的受控保存。'],
|
||
['src/server/services/product-collaborator.service.ts', '正式产品协作者服务事务写入口,负责 ProductCollaborator 关系和审计日志。'],
|
||
['src/server/services/product-patent.service.ts', '专利台账服务写入口,负责专利分组和身份规范化。'],
|
||
['src/server/services/provider-config.service.ts', 'AI/供应商配置服务写入口,负责配置 key 和激活状态约束。'],
|
||
['src/server/services/sku-cad-import.helper.ts', 'SKU CAD 导入 helper 写入口,负责旧兼容链结构数据和正式 SKU 边界校验。'],
|
||
['src/server/services/sku-command.service.ts', '正式 SKU 命令服务写入口,负责编辑、软删除、恢复和兼容属性维护。'],
|
||
['src/server/services/spu-number-reservation.service.ts', 'SPU 号段服务写入口,只允许事务内 reserveInTransaction 更新号段。'],
|
||
['src/server/services/system-config.service.ts', '系统配置服务写入口,负责通用配置 key/value。'],
|
||
['src/server/services/system-init.service.ts', '系统初始化服务写入口,仅用于受控初始化默认配置。'],
|
||
['src/server/services/team.service.ts', '团队服务写入口,负责组织团队结构维护。'],
|
||
])
|
||
|
||
const approvedServerMasterDataWriteModelScopes = new Map(
|
||
[
|
||
[
|
||
'src/server/repositories/asset-center.repository.ts',
|
||
['assetFamily', 'assetFamilySeriesCompatibility', 'assetSeries', 'assetVariant'],
|
||
],
|
||
['src/server/repositories/assembly.repository.ts', ['assemblyStep', 'assemblyVersion']],
|
||
['src/server/repositories/auth.repository.ts', ['activityLog', 'user']],
|
||
['src/server/repositories/common-component.repository.ts', ['commonComponent', 'commonComponentCategory']],
|
||
['src/server/repositories/department.repository.ts', ['department']],
|
||
['src/server/repositories/draft-group.repository.ts', ['draftGroup', 'preSku', 'preSkuBasicInfo', 'preSkuPackagingPlan']],
|
||
['src/server/repositories/employee-directory.repository.ts', ['department', 'employeeDirectory', 'employeeLookupCache', 'user']],
|
||
['src/server/repositories/feedback.repository.ts', ['feedback']],
|
||
['src/server/repositories/handover.repository.ts', ['activityLog', 'productCollaborator', 'productSKU']],
|
||
['src/server/repositories/hardware.repository.ts', ['hardware', 'hardwareCategory', 'hardwareMapping']],
|
||
['src/server/repositories/hole.repository.ts', ['hole']],
|
||
[
|
||
'src/server/repositories/intake-non-integrated-product.repository.ts',
|
||
[
|
||
'intakeNonIntegratedProduct',
|
||
'intakeNonIntegratedProductProfile',
|
||
'intakeNonIntegratedSkuItem',
|
||
'intakeNonIntegratedPricingRow',
|
||
],
|
||
],
|
||
['src/server/repositories/log.repository.ts', ['activityLog']],
|
||
[
|
||
'src/server/repositories/material.repository.ts',
|
||
[
|
||
'material',
|
||
'materialCategory',
|
||
'productCategory',
|
||
'substrate',
|
||
'substrateCategory',
|
||
'substrateEnvironmentGrade',
|
||
'substrateFinish',
|
||
'substrateSurfaceOption',
|
||
'substrateSystemNameOption',
|
||
'substrateThicknessSpec',
|
||
],
|
||
],
|
||
['src/server/repositories/packaging.repository.ts', ['packagingPlan']],
|
||
['src/server/repositories/panel.repository.ts', ['panel', 'panelMaterial']],
|
||
['src/server/repositories/planning.repository.ts', ['planningDraft', 'planningTemplate', 'planningWorkspace']],
|
||
['src/server/repositories/product-hardware.repository.ts', ['productHardware']],
|
||
['src/server/repositories/product-upload.repository.ts', ['panel', 'product', 'productSKU']],
|
||
[
|
||
'src/server/repositories/product.repository.ts',
|
||
['hole', 'panel', 'product', 'productCollaborator', 'productSKU', 'productSkuAssetProfile'],
|
||
],
|
||
['src/server/repositories/share.repository.ts', ['shareLink']],
|
||
['src/server/repositories/sku.repository.ts', ['activityLog', 'productSKU']],
|
||
[
|
||
'src/server/repositories/sku-quality-inspection.repository.ts',
|
||
['product', 'productSKU', 'productSkuAssetProfile'],
|
||
],
|
||
['src/server/repositories/theme.repository.ts', ['themeConfig']],
|
||
['src/server/repositories/team.repository.ts', ['team']],
|
||
['src/server/repositories/user.repository.ts', ['user']],
|
||
[
|
||
'src/server/repositories/warehouse.repository.ts',
|
||
[
|
||
'assetVariantLocationBinding',
|
||
'brandMaterialBinding',
|
||
'packagedItem',
|
||
'preSkuLocationBinding',
|
||
'warehouseLocation',
|
||
],
|
||
],
|
||
['src/server/services/approval-workflow.service.ts', ['activityLog', 'approvalWorkflow', 'approvalWorkflowFieldChange']],
|
||
[
|
||
'src/server/services/asset-center.service.ts',
|
||
[
|
||
'assetFamilySeriesCompatibility',
|
||
'assetSeries',
|
||
'assetFamily',
|
||
'assetVariant',
|
||
'productSKU',
|
||
'productSkuAssetProfile',
|
||
'substrate',
|
||
'substrateCategory',
|
||
'substrateEnvironmentGrade',
|
||
'substrateSurfaceOption',
|
||
'substrateSystemNameOption',
|
||
'substrateThicknessSpec',
|
||
],
|
||
],
|
||
['src/server/services/asset-sku-sync.service.ts', ['assetSkuSyncEvent', 'productSKU', 'productSkuAssetProfile']],
|
||
['src/server/services/assisted-assembly-ai-render.service.ts', ['assistedAssemblyAiRender']],
|
||
['src/server/services/department.service.ts', ['activityLog', 'department']],
|
||
['src/server/services/dingtalk-auth.service.ts', ['user']],
|
||
['src/server/services/external-employee-directory.service.ts', ['employeeLookupCache', 'user']],
|
||
['src/server/services/feedback-workflow.helper.ts', ['activityLog', 'feedback']],
|
||
['src/server/services/feedback-workflow.service.ts', ['activityLog', 'feedback']],
|
||
['src/server/services/hardware-matcher.runtime.ts', ['hardwareMapping', 'productHardware']],
|
||
['src/server/services/hardware-write-ops.ts', ['hardware', 'hardwareCategory', 'hardwareMapping']],
|
||
[
|
||
'src/server/services/intake-non-integrated-product.service.ts',
|
||
[
|
||
'intakeNonIntegratedProductProfile',
|
||
'intakeNonIntegratedSkuItem',
|
||
'intakeNonIntegratedPricingRow',
|
||
],
|
||
],
|
||
['src/server/services/intake-reference-library.service.ts', ['intakeReferenceLibraryItem']],
|
||
['src/server/services/intake-node-permission-settings.service.ts', ['intakeNodePermissionOverride']],
|
||
[
|
||
'src/server/services/intake.service.ts',
|
||
['draftGroup', 'hole', 'packagedItem', 'packagingPlan', 'panel', 'preSku', 'preSkuBasicInfo', 'product', 'productHardware', 'productSKU'],
|
||
],
|
||
[
|
||
'src/server/services/jst-sku-stock-sync.service.ts',
|
||
[
|
||
'intakeNonIntegratedProduct',
|
||
'intakeNonIntegratedProductProfile',
|
||
'intakeNonIntegratedSkuItem',
|
||
],
|
||
],
|
||
['src/server/services/logger.service.ts', ['activityLog']],
|
||
[
|
||
'src/server/services/open-platform-demo-seed.service.ts',
|
||
[
|
||
'draftGroup',
|
||
'material',
|
||
'materialCategory',
|
||
'packagedItem',
|
||
'product',
|
||
'productCategory',
|
||
'substrateCategory',
|
||
'substrateEnvironmentGrade',
|
||
'substrateSurfaceOption',
|
||
'substrateSystemNameOption',
|
||
'substrateThicknessSpec',
|
||
'user',
|
||
],
|
||
],
|
||
['src/server/services/pre-sku-packaging.service.ts', ['preSkuPackagingPlan']],
|
||
['src/server/services/product-collaborator.service.ts', ['activityLog', 'productCollaborator']],
|
||
['src/server/services/product-patent.service.ts', ['productPatentRecord']],
|
||
['src/server/services/provider-config.service.ts', ['providerConfig']],
|
||
['src/server/services/sku-cad-import.helper.ts', ['hole', 'panel', 'productHardware', 'productSKU']],
|
||
['src/server/services/sku-command.service.ts', ['productSKU', 'productSkuAssetProfile']],
|
||
['src/server/services/spu-number-reservation.service.ts', ['intakeReferenceLibraryItem']],
|
||
['src/server/services/system-config.service.ts', ['systemConfig']],
|
||
['src/server/services/system-init.service.ts', ['user']],
|
||
['src/server/services/team.service.ts', ['activityLog', 'team']],
|
||
].map(([file, models]) => [file, new Set(models)]),
|
||
)
|
||
|
||
function getApprovedMasterDataWriteBoundaryFiles() {
|
||
return {
|
||
script: Array.from(approvedScriptMasterDataWritePaths.keys()),
|
||
server: Array.from(approvedServerMasterDataWritePaths.keys()),
|
||
}
|
||
}
|
||
|
||
if (require.main !== module) {
|
||
module.exports = {
|
||
approvedRawSqlMasterDataWritePaths,
|
||
approvedRawSqlMasterDataWriteTableScopes,
|
||
approvedRawSqlMasterDataReadPaths,
|
||
approvedRawSqlMasterDataReadTableScopes,
|
||
approvedRuntimeDatabaseDdlPaths,
|
||
approvedScriptMasterDataWritePaths,
|
||
approvedServerMasterDataWritePaths,
|
||
approvedServerMasterDataWriteModelScopes,
|
||
buildSnapshotReadBudgetReport,
|
||
buildSnapshotReadBudgetViolations,
|
||
getApprovedMasterDataWriteBoundaryFiles,
|
||
findSchemaJsonFieldBoundaryIssues,
|
||
getSchemaJsonFieldClassifications,
|
||
getSchemaJsonFieldInventory,
|
||
parseArgs,
|
||
SNAPSHOT_READ_DEBT_BUDGETS,
|
||
writeJsonReport,
|
||
}
|
||
} else {
|
||
const advisoryRules = [
|
||
{
|
||
id: 'client-local-storage-master-data-candidate',
|
||
severity: 'advisory',
|
||
description:
|
||
'资料库型数据不应长期以 localStorage 作为唯一真相;若用于多人协作、正式流转或外部依赖,必须迁入数据库或稳定服务端契约。',
|
||
pattern:
|
||
/\b(?:window\.)?(?:localStorage|sessionStorage)\s*\.\s*(?:getItem|setItem|removeItem|clear)\s*\(|\bcreateJSONStorage\s*\(\s*\(\s*\)\s*=>\s*(?:window\.)?(?:localStorage|sessionStorage)\s*\)/g,
|
||
resolveCandidate: ({ relativePath, content, match }) =>
|
||
advisoryStoragePaths.get(relativePath) ??
|
||
resolveApprovedClientStorageUsage({ relativePath, content, match }),
|
||
},
|
||
]
|
||
|
||
const compatibilitySnapshotReadRules = [
|
||
{
|
||
id: 'pre-sku-compatibility-snapshot-direct-read',
|
||
description:
|
||
'PreSku.displaySummary/familySelection 是兼容或过程快照;正式基础资料主读必须优先检查 basicInfo。',
|
||
pattern: /\b(?:preSku|firstPreSku|sourcePreSku|record)\.(displaySummary|familySelection)\b/g,
|
||
},
|
||
{
|
||
id: 'non-integrated-payload-direct-read',
|
||
description:
|
||
'IntakeNonIntegratedProduct.payload 是兼容快照;产品头、SKU 和核价主数据主读必须优先检查 profile/skuItems/pricingRows。',
|
||
pattern: /\b(?:product|record|existing|existingRecord|row)\.payload\b/g,
|
||
},
|
||
{
|
||
id: 'product-sku-attributes-direct-read',
|
||
description:
|
||
'ProductSKU.attributes 是兼容属性;正式 SKU 主数据主读必须优先检查结构化字段和 assetProfile。',
|
||
pattern: /\b(?:sku|currentSku|updatedSku|existing|record|s)\.attributes\b/g,
|
||
},
|
||
]
|
||
|
||
const processSnapshotReadRules = [
|
||
{
|
||
id: 'pre-sku-process-snapshot-direct-read',
|
||
description:
|
||
'PreSku.variantSelection/bomPreview/installStepPreview/packagingSnapshot 是过程快照;只能用于装配、BOM、包装和正式化物料生成,不能反推正式编码、品类或权限。',
|
||
pattern:
|
||
/\b(?:preSku|sourcePreSku|record)\.(variantSelection|bomPreview|installStepPreview|packagingSnapshot)\b/g,
|
||
},
|
||
]
|
||
|
||
const compatibilitySnapshotReadClassifications = new Map([
|
||
[
|
||
'scripts/apply-master-data-cleanup-plan.js',
|
||
{
|
||
category: 'explicit-cleanup-plan',
|
||
priority: 'low',
|
||
targetState: '显式历史清洗计划允许读兼容快照,但只能在 dry-run / apply 计划里使用,不能成为业务主读。',
|
||
},
|
||
],
|
||
[
|
||
'scripts/backfill-sku-structural-data.ts',
|
||
{
|
||
category: 'one-time-backfill',
|
||
priority: 'low',
|
||
targetState: '一次性结构化回填脚本允许读取旧 attributes 作为迁移来源。',
|
||
},
|
||
],
|
||
[
|
||
'scripts/cleanup-legacy-product-structure.ts',
|
||
{
|
||
category: 'legacy-cad-cleanup',
|
||
priority: 'low',
|
||
targetState: '旧 CAD 结构清理只把 attributes 作为兼容链判断证据。',
|
||
},
|
||
],
|
||
[
|
||
'scripts/migrate-historical-assets-to-qiniu.ts',
|
||
{
|
||
category: 'one-time-storage-migration',
|
||
priority: 'low',
|
||
targetState:
|
||
'七牛历史素材迁移脚本只允许读取 payload 中的旧素材 URL 作为迁移来源;业务资料主读仍必须走 profile / skuItems / pricingRows 结构化表。',
|
||
},
|
||
],
|
||
[
|
||
'src/app/assisted-assembly/page.tsx',
|
||
{
|
||
category: 'process-geometry-snapshot',
|
||
priority: 'low',
|
||
targetState: '辅助装配恢复工作区只读取 assemblyWorkspace 过程快照,不承接正式产品主数据。',
|
||
},
|
||
],
|
||
[
|
||
'src/app/intake/EdgeBandingLibraryPanel.tsx',
|
||
{
|
||
category: 'reference-library-compatibility',
|
||
priority: 'medium',
|
||
targetState: '资料库 UI 可临时读取 payload 兼容展示;新增业务字段必须进入 IntakeReferenceLibraryItem 结构化列或专用表。',
|
||
},
|
||
],
|
||
[
|
||
'src/app/intake/spuRuleLibraryLogic.ts',
|
||
{
|
||
category: 'reference-library-compatibility',
|
||
priority: 'medium',
|
||
targetState: 'SPU 规则库 payload 只保留兼容展示;正式号段和品类唯一池以结构化 code/name/counter 为准。',
|
||
},
|
||
],
|
||
[
|
||
'src/app/products/[id]/assembly/page.tsx',
|
||
{
|
||
category: 'process-geometry-snapshot',
|
||
priority: 'low',
|
||
targetState: '旧产品装配页读取 sourcePreSku assemblyWorkspace 只服务兼容 3D 恢复。',
|
||
},
|
||
],
|
||
[
|
||
'src/app/products/[id]/page.tsx',
|
||
{
|
||
category: 'legacy-display-output',
|
||
priority: 'medium',
|
||
targetState: '旧产品详情只能以 legacyAttributes 名义展示兼容字段;正式 SKU 标签必须优先 assetProfile / 结构化字段。',
|
||
},
|
||
],
|
||
[
|
||
'src/app/products/[id]/skus/[skuId]/assembly/page.tsx',
|
||
{
|
||
category: 'legacy-cad-compatibility',
|
||
priority: 'medium',
|
||
targetState: 'SKU 装配页读取 CAD 兼容 attributes 只服务旧链,新增装配主数据应落结构化表。',
|
||
},
|
||
],
|
||
[
|
||
'src/app/products/[id]/skus/[skuId]/page.tsx',
|
||
{
|
||
category: 'legacy-display-output',
|
||
priority: 'medium',
|
||
targetState: '旧 SKU 详情只能以 legacyAttributes 名义展示兼容字段;编辑保存必须写结构化字段。',
|
||
},
|
||
],
|
||
[
|
||
'src/lib/accessory-library.ts',
|
||
{
|
||
category: 'reference-library-compatibility',
|
||
priority: 'medium',
|
||
targetState: '配件资料库 payload 只作兼容展示,资料身份和成本/BOM 字段必须走服务端结构化契约。',
|
||
},
|
||
],
|
||
[
|
||
'src/lib/edge-banding-library.ts',
|
||
{
|
||
category: 'reference-library-compatibility',
|
||
priority: 'medium',
|
||
targetState: '封边资料库 payload 只作兼容展示,资料身份和成本/BOM 字段必须走服务端结构化契约。',
|
||
},
|
||
],
|
||
[
|
||
'src/lib/open-product/platform-view.ts',
|
||
{
|
||
category: 'shared-ui-compatibility-helper',
|
||
priority: 'medium',
|
||
targetState: '共享展示 helper 可保留旧草稿兜底,但新增正式字段必须优先 basicInfo。',
|
||
},
|
||
],
|
||
[
|
||
'src/lib/packaging-material-library.ts',
|
||
{
|
||
category: 'reference-library-compatibility',
|
||
priority: 'medium',
|
||
targetState: '包装材料资料库 payload 只作兼容展示,资料身份和成本/BOM 字段必须走服务端结构化契约。',
|
||
},
|
||
],
|
||
[
|
||
'src/lib/steel-frame-library.ts',
|
||
{
|
||
category: 'reference-library-compatibility',
|
||
priority: 'medium',
|
||
targetState: '钢架资料库 payload 只作兼容展示,资料身份和成本/BOM 字段必须走服务端结构化契约。',
|
||
},
|
||
],
|
||
[
|
||
'src/server/dto/product.dto.ts',
|
||
{
|
||
category: 'dto-legacy-output',
|
||
priority: 'medium',
|
||
targetState: 'DTO 只能输出 legacyAttributes 兼容字段;主合同字段必须来自结构化列。',
|
||
},
|
||
],
|
||
[
|
||
'src/server/dto/sku.dto.ts',
|
||
{
|
||
category: 'dto-legacy-output',
|
||
priority: 'medium',
|
||
targetState: 'DTO 只能输出 legacyAttributes 兼容字段;主合同字段必须来自结构化列。',
|
||
},
|
||
],
|
||
[
|
||
'src/server/repositories/product.repository.ts',
|
||
{
|
||
category: 'lifecycle-compatibility',
|
||
priority: 'medium',
|
||
targetState: '正式 SKU 软删除/恢复的生命周期身份必须读 ProductSKU 结构化列;attributes 只允许清理旧兼容元数据。',
|
||
},
|
||
],
|
||
[
|
||
'src/server/services/approval-workflow.service.ts',
|
||
{
|
||
category: 'workflow-context-compatibility',
|
||
priority: 'medium',
|
||
targetState: '审批流上下文可读取 payload 兼容快照,但审批摘要不能成为产品资料第二真相。',
|
||
},
|
||
],
|
||
[
|
||
'src/server/services/asset-center.service.ts',
|
||
{
|
||
category: 'series-component-development-compatibility',
|
||
priority: 'medium',
|
||
targetState:
|
||
'资产中心服务只允许读取非集成 payload 作为历史系列组件开发资料的兼容同步来源;正式资产、SKU 和核价主数据必须继续落到结构化表。',
|
||
},
|
||
],
|
||
[
|
||
'src/server/services/asset-sku-sync.service.ts',
|
||
{
|
||
category: 'compatibility-copy-maintenance',
|
||
priority: 'medium',
|
||
targetState: '资产同步服务只允许维护 attributes 兼容副本;业务判断必须继续读 ProductSkuAssetProfile。',
|
||
},
|
||
],
|
||
[
|
||
'src/server/services/draft-group.mapper.ts',
|
||
{
|
||
category: 'dto-compatibility-fallback',
|
||
priority: 'medium',
|
||
targetState: '草稿组 mapper 可保留 displaySummary/familySelection 兼容输出,但 basicInfo 必须是正式字段主读。',
|
||
},
|
||
],
|
||
[
|
||
'src/server/services/draft-group.service.ts',
|
||
{
|
||
category: 'pre-sku-basic-info-cleanup',
|
||
priority: 'low',
|
||
targetState: '草稿组服务只允许读取 displaySummary 用于清理旧基础资料字段;冲突检测、保存和自动建议必须走 PreSkuBasicInfo 与资产中心快照。',
|
||
},
|
||
],
|
||
[
|
||
'src/server/services/intake-non-integrated-product.service.ts',
|
||
{
|
||
category: 'payload-hydration-compatibility',
|
||
priority: 'medium',
|
||
targetState: '非集成服务可用 payload 拼装旧记录视图;产品头、SKU 和核价主数据必须优先 profile / skuItems / pricingRows。',
|
||
},
|
||
],
|
||
[
|
||
'src/server/services/intake-reference-library.service.ts',
|
||
{
|
||
category: 'reference-library-contract-output',
|
||
priority: 'low',
|
||
targetState: '资料库服务返回 payload 作为扩展载荷,身份和编码仍由结构化列约束。',
|
||
},
|
||
],
|
||
[
|
||
'src/server/services/intake.service.ts',
|
||
{
|
||
category: 'process-snapshot-formalization',
|
||
priority: 'medium',
|
||
targetState: '正式入库可读取装配/BOM 过程快照生成结果,但正式编码和基础资料必须来自 PreSkuBasicInfo。',
|
||
},
|
||
],
|
||
[
|
||
'src/server/services/jst-sku-stock-sync.service.ts',
|
||
{
|
||
category: 'external-sync-compatibility-copy',
|
||
priority: 'medium',
|
||
targetState: '聚水潭同步可维护 payload 兼容副本;来源身份和筛选必须落 profile 结构化字段。',
|
||
},
|
||
],
|
||
[
|
||
'src/server/services/share.service.ts',
|
||
{
|
||
category: 'public-legacy-output',
|
||
priority: 'medium',
|
||
targetState: '分享页只能输出过滤后的 legacyAttributes;公开主字段必须来自结构化 DTO。',
|
||
},
|
||
],
|
||
[
|
||
'src/server/services/sku-soft-delete.helper.ts',
|
||
{
|
||
category: 'lifecycle-compatibility',
|
||
priority: 'medium',
|
||
targetState: '软删除 helper 必须优先使用 ProductSKU.originalSkuCode 等结构化列;attributes 只允许作为旧数据回退和清理来源。',
|
||
},
|
||
],
|
||
[
|
||
'src/server/services/spu-number-reservation.service.ts',
|
||
{
|
||
category: 'legacy-spu-rule-compatibility',
|
||
priority: 'medium',
|
||
targetState: 'SPU 号段服务只能读取旧 payload 规则作兼容;正式占号和回写以 IntakeReferenceLibraryItem 中的 SPU_RULE 行为准。',
|
||
},
|
||
],
|
||
])
|
||
|
||
const processSnapshotReadClassifications = new Map([
|
||
[
|
||
'src/app/assisted-assembly/page.tsx',
|
||
{
|
||
category: 'assisted-assembly-workspace-restore',
|
||
priority: 'low',
|
||
targetState:
|
||
'辅助装配页面可读取准 SKU 过程快照恢复 3D 工作区和已选物料;正式基础资料不得从这些快照反推。'
|
||
},
|
||
],
|
||
[
|
||
'src/app/draft-groups/draftGroupDisplay.ts',
|
||
{
|
||
category: 'draft-group-readonly-material-summary',
|
||
priority: 'medium',
|
||
targetState:
|
||
'草稿组列表可按 variantSelection 汇总板材、组件和五金展示;编码、条码和品类必须继续走 basicInfo 或资产中心结构化字段。'
|
||
},
|
||
],
|
||
[
|
||
'src/app/draft-groups/DraftGroupsFormalClient.tsx',
|
||
{
|
||
category: 'draft-group-formal-readonly-material-summary',
|
||
priority: 'medium',
|
||
targetState:
|
||
'草稿组正式化页面可从 variantSelection 读取板材、组件和五金构成用于只读展示与建议;正式编码、条码、品类和权限必须继续走 PreSkuBasicInfo 或结构化来源。'
|
||
},
|
||
],
|
||
[
|
||
'src/app/draft-groups/[id]/DraftGroupDetailClient.tsx',
|
||
{
|
||
category: 'draft-group-readonly-process-preview',
|
||
priority: 'medium',
|
||
targetState:
|
||
'草稿组详情可展示 BOM 和安装步骤过程预览;补齐正式资料必须写 PreSkuBasicInfo。'
|
||
},
|
||
],
|
||
[
|
||
'src/app/intake/IntakeClient.tsx',
|
||
{
|
||
category: 'intake-readonly-pre-sku-material-view',
|
||
priority: 'medium',
|
||
targetState:
|
||
'产品资料前端可读取准 SKU 过程快照展示物料构成;正式入库提交仍以后端 PreSkuBasicInfo 校验为准。'
|
||
},
|
||
],
|
||
[
|
||
'src/app/intake/page.tsx',
|
||
{
|
||
category: 'intake-server-readonly-material-view',
|
||
priority: 'medium',
|
||
targetState:
|
||
'产品资料服务端页面可读取准 SKU 过程快照生成只读物料摘要;正式产品资料不得回写这些快照字段。'
|
||
},
|
||
],
|
||
[
|
||
'src/app/intake/seriesComponentDetails.ts',
|
||
{
|
||
category: 'intake-readonly-component-detail',
|
||
priority: 'medium',
|
||
targetState:
|
||
'系列组件详情可读取 BOM 过程预览用于展示组件/物料信息;正式成本和编码以结构化 SKU 与 BOM 结果为准。'
|
||
},
|
||
],
|
||
[
|
||
'src/app/products/[id]/assembly/page.tsx',
|
||
{
|
||
category: 'legacy-product-assembly-restore',
|
||
priority: 'low',
|
||
targetState:
|
||
'旧产品装配页可读取 sourcePreSku 过程快照恢复 3D 装配视图,不参与正式资料写入。'
|
||
},
|
||
],
|
||
[
|
||
'src/server/services/draft-group.mapper.ts',
|
||
{
|
||
category: 'draft-group-dto-process-output',
|
||
priority: 'medium',
|
||
targetState:
|
||
'草稿组 DTO 可输出规格款组合、BOM、安装步骤和包装输入等过程结果;正式基础资料仍必须读取 PreSkuBasicInfo。'
|
||
},
|
||
],
|
||
[
|
||
'src/server/services/draft-group.service.ts',
|
||
{
|
||
category: 'draft-group-process-source-resolution',
|
||
priority: 'medium',
|
||
targetState:
|
||
'草稿组服务可从 variantSelection 解析来源规格款 ID 和板材选项;型号、规格码、条码、品类和权限不得从过程快照反推。'
|
||
},
|
||
],
|
||
[
|
||
'src/server/services/intake.service.ts',
|
||
{
|
||
category: 'formalization-process-materialization',
|
||
priority: 'medium',
|
||
targetState:
|
||
'正式入库可读取 variantSelection/bomPreview/installStepPreview 生成板件、五金、成本和兼容追溯;正式编码、名称和条码必须来自 PreSkuBasicInfo / ProductSkuAssetProfile。'
|
||
},
|
||
],
|
||
])
|
||
|
||
const allowedProductSkuAttributeWriteKeys = new Set([
|
||
'draftGroupId',
|
||
'preSkuId',
|
||
'componentCodes',
|
||
'bomPreview',
|
||
'installStepPreview',
|
||
'variantSelectionSummary',
|
||
'assetSkuSnapshot',
|
||
'packagingSummary',
|
||
'_cadImport',
|
||
'_sourceFileName',
|
||
'_modelMeta',
|
||
'_holesImported',
|
||
'_holesCount',
|
||
'_holesVersion',
|
||
])
|
||
|
||
const reservedProductSkuAttributeWriteKeys = new Set([
|
||
'barcode',
|
||
'barCode',
|
||
'model',
|
||
'modelNumber',
|
||
'specCode',
|
||
'formalSkuCode',
|
||
'skuCode',
|
||
'productName',
|
||
'skuName',
|
||
'productCategory',
|
||
'category',
|
||
'categoryCode',
|
||
'seriesName',
|
||
'seriesCode',
|
||
'familyCode',
|
||
'assetFamilyCode',
|
||
'variantCode',
|
||
'versionNo',
|
||
'color',
|
||
'material',
|
||
'size',
|
||
])
|
||
|
||
const structuredJsonPayloadReadAllowlist = new Map([
|
||
[
|
||
'src/app/intake/EdgeBandingLibraryPanel.tsx',
|
||
new Set(['non-integrated-payload-direct-read']),
|
||
],
|
||
[
|
||
'src/app/intake/spuRuleLibraryLogic.ts',
|
||
new Set(['non-integrated-payload-direct-read']),
|
||
],
|
||
[
|
||
'src/lib/accessory-library.ts',
|
||
new Set(['non-integrated-payload-direct-read']),
|
||
],
|
||
[
|
||
'src/lib/edge-banding-library.ts',
|
||
new Set(['non-integrated-payload-direct-read']),
|
||
],
|
||
[
|
||
'src/lib/packaging-material-library.ts',
|
||
new Set(['non-integrated-payload-direct-read']),
|
||
],
|
||
[
|
||
'src/lib/steel-frame-library.ts',
|
||
new Set(['non-integrated-payload-direct-read']),
|
||
],
|
||
[
|
||
'src/server/services/intake-reference-library.service.ts',
|
||
new Set(['non-integrated-payload-direct-read']),
|
||
],
|
||
])
|
||
|
||
function isStructuredJsonPayloadRead(relativePath, ruleId) {
|
||
return structuredJsonPayloadReadAllowlist.get(relativePath)?.has(ruleId) ?? false
|
||
}
|
||
|
||
function classifyCompatibilitySnapshotRead(relativePath) {
|
||
return (
|
||
compatibilitySnapshotReadClassifications.get(relativePath) ?? {
|
||
category: 'unclassified',
|
||
priority: 'unknown',
|
||
targetState: '',
|
||
}
|
||
)
|
||
}
|
||
|
||
function collectCompatibilitySnapshotReadFindings(scanFiles) {
|
||
const findings = []
|
||
const skippedStructuredPayloadReads = []
|
||
const seen = new Set()
|
||
|
||
for (const absolutePath of scanFiles) {
|
||
const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath))
|
||
if (isTestFile(relativePath)) continue
|
||
|
||
const content = fs.readFileSync(absolutePath, 'utf8')
|
||
for (const rule of compatibilitySnapshotReadRules) {
|
||
rule.pattern.lastIndex = 0
|
||
|
||
for (const match of content.matchAll(rule.pattern)) {
|
||
const line = lineNumberAt(content, match.index)
|
||
const lineText = content.split(/\r?\n/)[line - 1] ?? ''
|
||
if (isStructuredJsonPayloadRead(relativePath, rule.id)) {
|
||
skippedStructuredPayloadReads.push({
|
||
ruleId: rule.id,
|
||
file: relativePath,
|
||
line,
|
||
text: lineText.trim(),
|
||
reason:
|
||
'结构化资料库 payload 扩展字段读点已由 IntakeReferenceLibraryItem 结构化身份和服务端契约兜底,不计入兼容快照主读风险。',
|
||
})
|
||
continue
|
||
}
|
||
const classification = classifyCompatibilitySnapshotRead(relativePath)
|
||
const record = {
|
||
ruleId: rule.id,
|
||
severity: classification.category === 'unclassified' ? 'error' : 'inventory',
|
||
file: relativePath,
|
||
line,
|
||
text: lineText.trim(),
|
||
description: rule.description,
|
||
category: classification.category,
|
||
priority: classification.priority,
|
||
targetState: classification.targetState,
|
||
}
|
||
const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.text}`
|
||
if (seen.has(recordKey)) continue
|
||
seen.add(recordKey)
|
||
findings.push(record)
|
||
}
|
||
}
|
||
}
|
||
|
||
return { findings, skippedStructuredPayloadReads }
|
||
}
|
||
|
||
function countBy(items, key, initialKeys = []) {
|
||
return items.reduce(
|
||
(result, item) => {
|
||
const value = item[key] ?? 'unknown'
|
||
result[value] = (result[value] ?? 0) + 1
|
||
return result
|
||
},
|
||
Object.fromEntries(initialKeys.map((initialKey) => [initialKey, 0])),
|
||
)
|
||
}
|
||
|
||
function summarizeCompatibilitySnapshotReads(findings) {
|
||
const byPriority = countBy(
|
||
findings,
|
||
'priority',
|
||
COMPATIBILITY_SNAPSHOT_READ_PRIORITIES,
|
||
)
|
||
const unclassifiedFindings = findings.filter(
|
||
(finding) => finding.category === 'unclassified',
|
||
)
|
||
const blockingRiskFindings = findings.filter(
|
||
(finding) => finding.priority === 'high' || finding.category === 'unclassified',
|
||
)
|
||
const highPriorityCount = byPriority.high ?? 0
|
||
|
||
return {
|
||
compatibilitySnapshotReadCount: findings.length,
|
||
compatibilitySnapshotReadByCategory: countBy(findings, 'category'),
|
||
compatibilitySnapshotReadByRuleId: countBy(findings, 'ruleId'),
|
||
compatibilitySnapshotReadByFile: countBy(findings, 'file'),
|
||
compatibilitySnapshotReadByPriority: byPriority,
|
||
compatibilitySnapshotReadBlockingRiskCount:
|
||
blockingRiskFindings.length,
|
||
compatibilitySnapshotReadBlockingRisks: blockingRiskFindings,
|
||
compatibilitySnapshotReadSummary: {
|
||
total: findings.length,
|
||
high: highPriorityCount,
|
||
medium: byPriority.medium ?? 0,
|
||
low: byPriority.low ?? 0,
|
||
unknown: byPriority.unknown ?? 0,
|
||
unclassified: unclassifiedFindings.length,
|
||
},
|
||
unclassifiedCompatibilitySnapshotReadCount:
|
||
unclassifiedFindings.length,
|
||
unclassifiedCompatibilitySnapshotReads: unclassifiedFindings,
|
||
}
|
||
}
|
||
|
||
function findUnusedCompatibilitySnapshotReadClassifications(findings, skippedReads) {
|
||
const activeFiles = new Set([
|
||
...findings.map((finding) => finding.file),
|
||
...skippedReads.map((finding) => finding.file),
|
||
])
|
||
|
||
return Array.from(compatibilitySnapshotReadClassifications.entries())
|
||
.filter(([file]) => !activeFiles.has(file))
|
||
.map(([file, classification]) => ({
|
||
file,
|
||
category: classification.category,
|
||
priority: classification.priority,
|
||
targetState: classification.targetState,
|
||
}))
|
||
}
|
||
|
||
function classifyProcessSnapshotRead(relativePath) {
|
||
return (
|
||
processSnapshotReadClassifications.get(relativePath) ?? {
|
||
category: 'unclassified',
|
||
priority: 'unknown',
|
||
targetState: '',
|
||
}
|
||
)
|
||
}
|
||
|
||
const forbiddenProcessSnapshotIdentityFields = [
|
||
'barcode',
|
||
'barCode',
|
||
'skuCode',
|
||
'formalSkuCode',
|
||
'model',
|
||
'modelNumber',
|
||
'productName',
|
||
'productCategory',
|
||
'category',
|
||
'categoryCode',
|
||
'seriesName',
|
||
'seriesCode',
|
||
'ownerId',
|
||
'ownerName',
|
||
'businessType',
|
||
'createdBy',
|
||
'updatedBy',
|
||
]
|
||
|
||
const processSnapshotFormalIdentityReadPatterns = [
|
||
{
|
||
id: 'process-snapshot-formal-field-dot-read',
|
||
pattern: new RegExp(
|
||
`\\b(?:variantSelection|bomPreview|installStepPreview|packagingSnapshot|selection|primarySelection|item|slot|hardware)\\??\\.\\s*(${forbiddenProcessSnapshotIdentityFields.join('|')})\\b`,
|
||
'g',
|
||
),
|
||
},
|
||
{
|
||
id: 'process-snapshot-formal-field-bracket-read',
|
||
pattern: new RegExp(
|
||
`\\b(?:variantSelection|bomPreview|installStepPreview|packagingSnapshot|selection|primarySelection|item|slot|hardware)\\s*\\[\\s*['"\`](${forbiddenProcessSnapshotIdentityFields.join('|')})['"\`]\\s*\\]`,
|
||
'g',
|
||
),
|
||
},
|
||
{
|
||
id: 'process-snapshot-assembly-basis-formal-field-read',
|
||
pattern: new RegExp(
|
||
`\\breadBasisText\\s*\\([^)]*,\\s*['"\`](${forbiddenProcessSnapshotIdentityFields.join('|')})['"\`]`,
|
||
'g',
|
||
),
|
||
},
|
||
]
|
||
|
||
function collectProcessSnapshotReadFindings(scanFiles) {
|
||
const findings = []
|
||
const seen = new Set()
|
||
const PROCESS_SNAPSHOT_CONTEXT_LINE_WINDOW = 15
|
||
|
||
for (const absolutePath of scanFiles) {
|
||
const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath))
|
||
if (isTestFile(relativePath)) continue
|
||
|
||
const content = fs.readFileSync(absolutePath, 'utf8')
|
||
let hasProcessSnapshotRead = false
|
||
const processSnapshotReadLines = []
|
||
|
||
for (const rule of processSnapshotReadRules) {
|
||
rule.pattern.lastIndex = 0
|
||
|
||
for (const match of content.matchAll(rule.pattern)) {
|
||
hasProcessSnapshotRead = true
|
||
const line = lineNumberAt(content, match.index)
|
||
processSnapshotReadLines.push(line)
|
||
const lineText = content.split(/\r?\n/)[line - 1] ?? ''
|
||
const classification = classifyProcessSnapshotRead(relativePath)
|
||
const record = {
|
||
ruleId: rule.id,
|
||
severity: classification.category === 'unclassified' ? 'error' : 'inventory',
|
||
file: relativePath,
|
||
line,
|
||
text: lineText.trim(),
|
||
field: match[1],
|
||
description: rule.description,
|
||
category: classification.category,
|
||
priority: classification.priority,
|
||
targetState: classification.targetState,
|
||
}
|
||
const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.text}`
|
||
if (seen.has(recordKey)) continue
|
||
seen.add(recordKey)
|
||
findings.push(record)
|
||
}
|
||
}
|
||
|
||
if (!hasProcessSnapshotRead) continue
|
||
const isNearProcessSnapshotRead = (line) =>
|
||
processSnapshotReadLines.some(
|
||
(readLine) => Math.abs(readLine - line) <= PROCESS_SNAPSHOT_CONTEXT_LINE_WINDOW,
|
||
)
|
||
|
||
for (const rule of processSnapshotFormalIdentityReadPatterns) {
|
||
rule.pattern.lastIndex = 0
|
||
|
||
for (const match of content.matchAll(rule.pattern)) {
|
||
const line = lineNumberAt(content, match.index)
|
||
if (!isNearProcessSnapshotRead(line)) continue
|
||
const lineText = content.split(/\r?\n/)[line - 1] ?? ''
|
||
const record = {
|
||
ruleId: rule.id,
|
||
severity: 'error',
|
||
file: relativePath,
|
||
line,
|
||
text: lineText.trim(),
|
||
field: match[1],
|
||
description:
|
||
'过程快照不能读取编码、条码、品类、人员或权限字段作为正式资料;这些字段必须来自结构化真相源。',
|
||
category: 'forbidden-process-snapshot-identity-read',
|
||
priority: 'high',
|
||
targetState:
|
||
'variantSelection/bomPreview/installStepPreview/packagingSnapshot 只允许承载装配、BOM、包装和追溯过程,不允许反推正式主数据或权限。',
|
||
}
|
||
const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.field}:${record.text}`
|
||
if (seen.has(recordKey)) continue
|
||
seen.add(recordKey)
|
||
findings.push(record)
|
||
}
|
||
}
|
||
}
|
||
|
||
return findings
|
||
}
|
||
|
||
function summarizeProcessSnapshotReads(findings) {
|
||
const byPriority = countBy(
|
||
findings,
|
||
'priority',
|
||
PROCESS_SNAPSHOT_READ_PRIORITIES,
|
||
)
|
||
const unclassifiedFindings = findings.filter(
|
||
(finding) => finding.category === 'unclassified',
|
||
)
|
||
const blockingRiskFindings = findings.filter(
|
||
(finding) =>
|
||
finding.priority === 'high' ||
|
||
finding.category === 'unclassified' ||
|
||
finding.severity === 'error',
|
||
)
|
||
|
||
return {
|
||
processSnapshotReadCount: findings.length,
|
||
processSnapshotReadByCategory: countBy(findings, 'category'),
|
||
processSnapshotReadByRuleId: countBy(findings, 'ruleId'),
|
||
processSnapshotReadByFile: countBy(findings, 'file'),
|
||
processSnapshotReadByPriority: byPriority,
|
||
processSnapshotReadBlockingRiskCount: blockingRiskFindings.length,
|
||
processSnapshotReadBlockingRisks: blockingRiskFindings,
|
||
processSnapshotReadSummary: {
|
||
total: findings.length,
|
||
high: byPriority.high ?? 0,
|
||
medium: byPriority.medium ?? 0,
|
||
low: byPriority.low ?? 0,
|
||
unknown: byPriority.unknown ?? 0,
|
||
unclassified: unclassifiedFindings.length,
|
||
},
|
||
unclassifiedProcessSnapshotReadCount: unclassifiedFindings.length,
|
||
unclassifiedProcessSnapshotReads: unclassifiedFindings,
|
||
}
|
||
}
|
||
|
||
function findUnusedProcessSnapshotReadClassifications(findings) {
|
||
const activeFiles = new Set(findings.map((finding) => finding.file))
|
||
|
||
return Array.from(processSnapshotReadClassifications.entries())
|
||
.filter(([file]) => !activeFiles.has(file))
|
||
.map(([file, classification]) => ({
|
||
file,
|
||
category: classification.category,
|
||
priority: classification.priority,
|
||
targetState: classification.targetState,
|
||
}))
|
||
}
|
||
|
||
function findMatchingBrace(content, openBraceIndex) {
|
||
let depth = 0
|
||
let quote = ''
|
||
let escaped = false
|
||
|
||
for (let index = openBraceIndex; index < content.length; index += 1) {
|
||
const char = content[index]
|
||
|
||
if (quote) {
|
||
if (escaped) {
|
||
escaped = false
|
||
continue
|
||
}
|
||
if (char === '\\') {
|
||
escaped = true
|
||
continue
|
||
}
|
||
if (char === quote) {
|
||
quote = ''
|
||
}
|
||
continue
|
||
}
|
||
|
||
if (char === '"' || char === "'" || char === '`') {
|
||
quote = char
|
||
continue
|
||
}
|
||
if (char === '{') {
|
||
depth += 1
|
||
continue
|
||
}
|
||
if (char === '}') {
|
||
depth -= 1
|
||
if (depth === 0) return index
|
||
}
|
||
}
|
||
|
||
return -1
|
||
}
|
||
|
||
function extractTopLevelObjectKeys(objectLiteral) {
|
||
const keys = []
|
||
let depth = 0
|
||
let quote = ''
|
||
let escaped = false
|
||
let current = ''
|
||
|
||
const flushSegment = () => {
|
||
const segment = current.trim()
|
||
current = ''
|
||
if (!segment || segment.startsWith('...')) return
|
||
|
||
const keyMatch =
|
||
segment.match(/^['"`]([^'"`]+)['"`]\s*:/) ??
|
||
segment.match(/^([A-Za-z_$][\w$]*)\s*:/)
|
||
if (keyMatch?.[1]) {
|
||
keys.push(keyMatch[1])
|
||
return
|
||
}
|
||
|
||
const shorthandMatch = segment.match(/^([A-Za-z_$][\w$]*)$/)
|
||
if (shorthandMatch?.[1]) {
|
||
keys.push(shorthandMatch[1])
|
||
}
|
||
}
|
||
|
||
for (let index = 1; index < objectLiteral.length - 1; index += 1) {
|
||
const char = objectLiteral[index]
|
||
|
||
if (quote) {
|
||
current += char
|
||
if (escaped) {
|
||
escaped = false
|
||
continue
|
||
}
|
||
if (char === '\\') {
|
||
escaped = true
|
||
continue
|
||
}
|
||
if (char === quote) {
|
||
quote = ''
|
||
}
|
||
continue
|
||
}
|
||
|
||
if (char === '"' || char === "'" || char === '`') {
|
||
quote = char
|
||
current += char
|
||
continue
|
||
}
|
||
if (char === '{' || char === '[' || char === '(') {
|
||
depth += 1
|
||
current += char
|
||
continue
|
||
}
|
||
if (char === '}' || char === ']' || char === ')') {
|
||
depth -= 1
|
||
current += char
|
||
continue
|
||
}
|
||
if (char === ',' && depth === 0) {
|
||
flushSegment()
|
||
continue
|
||
}
|
||
|
||
current += char
|
||
}
|
||
|
||
flushSegment()
|
||
return keys
|
||
}
|
||
|
||
function collectProductSkuAttributeWriteFindings(scanFiles) {
|
||
const findings = []
|
||
const seen = new Set()
|
||
const attributesObjectPattern = /\battributes\s*:\s*\{/g
|
||
|
||
for (const absolutePath of scanFiles) {
|
||
const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath))
|
||
if (isTestFile(relativePath)) continue
|
||
if (
|
||
!relativePath.startsWith('src/server/') &&
|
||
!relativePath.startsWith('scripts/') &&
|
||
!relativePath.startsWith('prisma/')
|
||
) {
|
||
continue
|
||
}
|
||
|
||
const content = fs.readFileSync(absolutePath, 'utf8')
|
||
attributesObjectPattern.lastIndex = 0
|
||
|
||
for (const match of content.matchAll(attributesObjectPattern)) {
|
||
const openBraceIndex = content.indexOf('{', match.index)
|
||
const closeBraceIndex = findMatchingBrace(content, openBraceIndex)
|
||
if (openBraceIndex < 0 || closeBraceIndex < 0) continue
|
||
|
||
const objectLiteral = content.slice(openBraceIndex, closeBraceIndex + 1)
|
||
const keys = extractTopLevelObjectKeys(objectLiteral)
|
||
if (keys.length === 0) continue
|
||
|
||
for (const key of keys) {
|
||
const isAllowed = allowedProductSkuAttributeWriteKeys.has(key)
|
||
const isReserved = reservedProductSkuAttributeWriteKeys.has(key)
|
||
if (isAllowed && !isReserved) continue
|
||
|
||
const line = lineNumberAt(content, match.index)
|
||
const lineText = content.split(/\r?\n/)[line - 1] ?? ''
|
||
const record = {
|
||
ruleId: isReserved
|
||
? 'product-sku-attributes-reserved-key-write'
|
||
: 'product-sku-attributes-unregistered-key-write',
|
||
severity: isReserved ? 'error' : 'warning',
|
||
file: relativePath,
|
||
line,
|
||
text: lineText.trim(),
|
||
key,
|
||
priority: isReserved ? 'high' : 'medium',
|
||
description:
|
||
'ProductSKU.attributes 只能写已登记的兼容快照键;编码、条码、型号、规格、品类、颜色、材质、尺寸等正式资料必须写结构化字段或 ProductSkuAssetProfile。',
|
||
targetState:
|
||
'允许的兼容键需要集中登记;新增正式资料字段不得回写 ProductSKU.attributes,避免 JSON 成为第二真相。',
|
||
}
|
||
const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.key}`
|
||
if (seen.has(recordKey)) continue
|
||
seen.add(recordKey)
|
||
findings.push(record)
|
||
}
|
||
}
|
||
}
|
||
|
||
return findings
|
||
}
|
||
|
||
function summarizeProductSkuAttributeWriteFindings(findings) {
|
||
const blockingRisks = findings.filter((finding) => finding.severity === 'error')
|
||
|
||
return {
|
||
productSkuAttributeWriteRiskCount: findings.length,
|
||
productSkuAttributeWriteRiskByRuleId: countBy(findings, 'ruleId'),
|
||
productSkuAttributeWriteRiskByFile: countBy(findings, 'file'),
|
||
productSkuAttributeWriteRiskByPriority: countBy(
|
||
findings,
|
||
'priority',
|
||
PRODUCT_SKU_ATTRIBUTE_WRITE_PRIORITIES,
|
||
),
|
||
productSkuAttributeWriteBlockingRiskCount: blockingRisks.length,
|
||
productSkuAttributeWriteBlockingRisks: blockingRisks,
|
||
productSkuAttributeWriteRisks: findings,
|
||
}
|
||
}
|
||
|
||
const referenceEstimatorCostSourceRules = [
|
||
{
|
||
id: 'reference-estimator-cost-source-symbol',
|
||
pattern:
|
||
/\b(?:REFERENCE_HARDWARE_ITEMS|DEFAULT_PACKAGING_PRICES|EDGE_BANDING_UNIT_PRICE|BOARD_PRICE_AREA_SQUARE_METER|REFERENCE_ESTIMATOR_PACKAGING_PRICES|REFERENCE_ESTIMATOR_EDGE_BANDING_UNIT_PRICE|REFERENCE_ESTIMATOR_BOARD_PRICE_AREA_SQUARE_METER)\b/g,
|
||
description:
|
||
'参考图预估成本内置单价只能服务图片估算展示,不能被正式产品、BOM、包装方案、轻仓库或外部同步链路复用。',
|
||
},
|
||
{
|
||
id: 'reference-estimator-cost-catalog-import',
|
||
pattern:
|
||
/\bfrom\s+['"][^'"]*reference-image-cost-catalog['"]|\brequire\(\s*['"][^'"]*reference-image-cost-catalog['"]\s*\)/g,
|
||
description:
|
||
'reference-image-cost-catalog 是参考图预估成本专用样本目录,不是正式五金或成本主数据源。',
|
||
},
|
||
]
|
||
|
||
function collectReferenceEstimatorCostBoundaryFindings(scanFiles) {
|
||
const findings = []
|
||
const seen = new Set()
|
||
|
||
for (const absolutePath of scanFiles) {
|
||
const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath))
|
||
if (isTestFile(relativePath)) continue
|
||
|
||
const content = fs.readFileSync(absolutePath, 'utf8')
|
||
const approvalReason = approvedReferenceEstimatorCostSourcePaths.get(relativePath)
|
||
|
||
for (const rule of referenceEstimatorCostSourceRules) {
|
||
rule.pattern.lastIndex = 0
|
||
|
||
for (const match of content.matchAll(rule.pattern)) {
|
||
const line = lineNumberAt(content, match.index)
|
||
const lineText = content.split(/\r?\n/)[line - 1] ?? ''
|
||
if (
|
||
rule.id === 'reference-estimator-cost-source-symbol' &&
|
||
/from\s+['"][^'"]*reference-image-cost-catalog['"]/.test(lineText)
|
||
) {
|
||
continue
|
||
}
|
||
const record = {
|
||
ruleId: rule.id,
|
||
severity: approvalReason ? 'approved-reference-estimator-cost' : 'error',
|
||
file: relativePath,
|
||
line,
|
||
text: lineText.trim(),
|
||
description: rule.description,
|
||
reason:
|
||
approvalReason ??
|
||
'未批准使用参考图预估成本源;正式成本必须来自结构化物料/五金/包装资料库或明确的后端算法。',
|
||
priority: approvalReason ? 'low' : 'high',
|
||
targetState:
|
||
'参考图预估成本只保留为人工判断辅助;正式产品成本、BOM 成本、包装成本和外部只读视图不得读取这些页面估算常量或样本目录。',
|
||
}
|
||
const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.text}`
|
||
if (seen.has(recordKey)) continue
|
||
seen.add(recordKey)
|
||
findings.push(record)
|
||
}
|
||
}
|
||
}
|
||
|
||
return findings
|
||
}
|
||
|
||
function summarizeReferenceEstimatorCostBoundaryFindings(findings) {
|
||
const blockingRisks = findings.filter((finding) => finding.severity === 'error')
|
||
|
||
return {
|
||
referenceEstimatorCostBoundaryFindingCount: findings.length,
|
||
referenceEstimatorCostBoundaryByRuleId: countBy(findings, 'ruleId'),
|
||
referenceEstimatorCostBoundaryByFile: countBy(findings, 'file'),
|
||
approvedReferenceEstimatorCostBoundaryFindings: findings.filter(
|
||
(finding) => finding.severity === 'approved-reference-estimator-cost',
|
||
),
|
||
unclassifiedReferenceEstimatorCostBoundaryFindingCount: blockingRisks.length,
|
||
unclassifiedReferenceEstimatorCostBoundaryFindings: blockingRisks,
|
||
}
|
||
}
|
||
|
||
const fileSystemMasterDataReadPattern =
|
||
/\b(?:fs\.)?(?:promises\.)?(?:readdir|readFile|readFileSync|createReadStream)\s*\(/g
|
||
const fileSystemMasterDataPathHintPattern =
|
||
/public[\\/]+uploads|uploads[\\/]+models|hardware\.json|tempGlbPath|tempFilePath|uploadedFile|modelFile|zipPath|extractDir|cacheDirectory/g
|
||
const serverMasterDataWriteTokenPattern =
|
||
/\b(?:prisma|this\.prisma|client|tx)\.(product|productSKU|panel|hole|productHardware|hardware)\.(create|createMany|upsert|update|updateMany|delete|deleteMany)\b/gi
|
||
|
||
function collectFileSystemMasterDataSourceFindings(scanFiles) {
|
||
const findings = []
|
||
const seen = new Set()
|
||
|
||
for (const absolutePath of scanFiles) {
|
||
const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath))
|
||
if (isTestFile(relativePath)) continue
|
||
if (!relativePath.startsWith('src/server/') && !relativePath.startsWith('scripts/')) {
|
||
continue
|
||
}
|
||
|
||
const content = fs.readFileSync(absolutePath, 'utf8')
|
||
if (!fileSystemMasterDataReadPattern.test(content)) {
|
||
fileSystemMasterDataReadPattern.lastIndex = 0
|
||
continue
|
||
}
|
||
fileSystemMasterDataReadPattern.lastIndex = 0
|
||
if (!fileSystemMasterDataPathHintPattern.test(content)) {
|
||
fileSystemMasterDataPathHintPattern.lastIndex = 0
|
||
continue
|
||
}
|
||
fileSystemMasterDataPathHintPattern.lastIndex = 0
|
||
|
||
const writeMatches = Array.from(content.matchAll(serverMasterDataWriteTokenPattern))
|
||
serverMasterDataWriteTokenPattern.lastIndex = 0
|
||
if (writeMatches.length === 0) continue
|
||
|
||
const approvalReason = approvedFileSystemMasterDataSourcePaths.get(relativePath)
|
||
for (const match of writeMatches) {
|
||
const line = lineNumberAt(content, match.index)
|
||
const lineText = content.split(/\r?\n/)[line - 1] ?? ''
|
||
const record = {
|
||
ruleId: 'filesystem-master-data-source-write',
|
||
severity: approvalReason ? 'approved-filesystem-master-data-source' : 'error',
|
||
file: relativePath,
|
||
line,
|
||
text: lineText.trim(),
|
||
model: match[1],
|
||
action: match[2],
|
||
reason:
|
||
approvalReason ??
|
||
'未登记的文件系统读取 + 主数据写入组合;禁止把 public/uploads、临时文件或本地目录扫描结果反向当作主数据真相。',
|
||
priority: approvalReason ? 'low' : 'high',
|
||
targetState:
|
||
'文件上传可作为明确入口的导入请求来源,但不能通过扫描 public/uploads 或任意本地文件目录反向生成 Product/SKU/BOM 主数据。',
|
||
}
|
||
const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.model}:${record.action}`
|
||
if (seen.has(recordKey)) continue
|
||
seen.add(recordKey)
|
||
findings.push(record)
|
||
}
|
||
}
|
||
|
||
return findings
|
||
}
|
||
|
||
function summarizeFileSystemMasterDataSourceFindings(findings) {
|
||
const blockingRisks = findings.filter((finding) => finding.severity === 'error')
|
||
|
||
return {
|
||
fileSystemMasterDataSourceFindingCount: findings.length,
|
||
fileSystemMasterDataSourceByFile: countBy(findings, 'file'),
|
||
approvedFileSystemMasterDataSources: findings.filter(
|
||
(finding) => finding.severity === 'approved-filesystem-master-data-source',
|
||
),
|
||
unclassifiedFileSystemMasterDataSourceCount: blockingRisks.length,
|
||
unclassifiedFileSystemMasterDataSources: blockingRisks,
|
||
}
|
||
}
|
||
|
||
const files = SCAN_DIRS.flatMap((scanDir) => listSourceFiles(path.join(ROOT_DIR, scanDir)))
|
||
const scriptFiles = SCRIPT_SCAN_DIRS.flatMap((scanDir) => listSourceFiles(path.join(ROOT_DIR, scanDir)))
|
||
const findings = []
|
||
const approvedCompatibility = []
|
||
const advisoryFindings = []
|
||
const unclassifiedStorageFindings = []
|
||
const scriptMasterDataWriteFindings = []
|
||
const unclassifiedScriptMasterDataWriteFindings = []
|
||
const serverMasterDataWriteFindings = []
|
||
const unclassifiedServerMasterDataWriteFindings = []
|
||
const rawSqlMasterDataWriteFindings = []
|
||
const unclassifiedRawSqlMasterDataWriteFindings = []
|
||
const rawSqlMasterDataReadFindings = []
|
||
const unclassifiedRawSqlMasterDataReadFindings = []
|
||
const runtimeDatabaseDdlFindings = []
|
||
const unclassifiedRuntimeDatabaseDdlFindings = []
|
||
const scriptSafetyGuardFindings = []
|
||
const missingScriptSafetyGuardFindings = []
|
||
const seenRecords = new Set()
|
||
const schemaJsonFieldInventory = getSchemaJsonFieldInventory()
|
||
const unclassifiedSchemaJsonFields = schemaJsonFieldInventory.filter(
|
||
(field) => field.category === 'unclassified',
|
||
)
|
||
const staleSchemaJsonFieldClassifications =
|
||
findStaleSchemaJsonFieldClassifications(schemaJsonFieldInventory)
|
||
const schemaJsonFieldBoundaryIssues =
|
||
findSchemaJsonFieldBoundaryIssues()
|
||
const approvedClientStorageEvidenceIssues =
|
||
findApprovedClientStorageEvidenceIssues()
|
||
const staleFileClassifications = findStaleFileClassifications([
|
||
{
|
||
name: 'advisoryStoragePaths',
|
||
files: advisoryStoragePaths.keys(),
|
||
reason: '客户端本地存储分类表不能残留已删除或改名的文件。',
|
||
},
|
||
{
|
||
name: 'approvedScriptMasterDataWritePaths',
|
||
files: approvedScriptMasterDataWritePaths.keys(),
|
||
reason: '脚本层主数据直写批准表不能残留已删除或改名的文件。',
|
||
},
|
||
{
|
||
name: 'approvedScriptSafetyGuardRequirements',
|
||
files: approvedScriptSafetyGuardRequirements.keys(),
|
||
reason: '脚本安全门禁清单不能残留已删除或改名的文件。',
|
||
},
|
||
{
|
||
name: 'approvedRawSqlMasterDataWritePaths',
|
||
files: approvedRawSqlMasterDataWritePaths.keys(),
|
||
reason: '原生 SQL 主数据写入批准表不能残留已删除或改名的文件。',
|
||
},
|
||
{
|
||
name: 'approvedRawSqlMasterDataWriteTableScopes',
|
||
files: approvedRawSqlMasterDataWriteTableScopes.keys(),
|
||
reason: '原生 SQL 主数据写入表作用域不能残留已删除或改名的文件。',
|
||
},
|
||
{
|
||
name: 'approvedRawSqlMasterDataReadPaths',
|
||
files: approvedRawSqlMasterDataReadPaths.keys(),
|
||
reason: '原生 SQL 主数据读取批准表不能残留已删除或改名的文件。',
|
||
},
|
||
{
|
||
name: 'approvedRawSqlMasterDataReadTableScopes',
|
||
files: approvedRawSqlMasterDataReadTableScopes.keys(),
|
||
reason: '原生 SQL 主数据读取表作用域不能残留已删除或改名的文件。',
|
||
},
|
||
{
|
||
name: 'approvedRuntimeDatabaseDdlPaths',
|
||
files: approvedRuntimeDatabaseDdlPaths.keys(),
|
||
reason: '运行时数据库 DDL 批准表不能残留已删除或改名的文件。',
|
||
},
|
||
{
|
||
name: 'approvedReferenceEstimatorCostSourcePaths',
|
||
files: approvedReferenceEstimatorCostSourcePaths.keys(),
|
||
reason: '参考图预估成本源批准表不能残留已删除或改名的文件。',
|
||
},
|
||
{
|
||
name: 'approvedFileSystemMasterDataSourcePaths',
|
||
files: approvedFileSystemMasterDataSourcePaths.keys(),
|
||
reason: '文件系统导入主数据批准表不能残留已删除或改名的文件。',
|
||
},
|
||
{
|
||
name: 'approvedClientStorageUsages',
|
||
files: getApprovedClientStorageFiles().values(),
|
||
reason: '客户端本地存储批准用途不能残留已删除或改名的文件。',
|
||
},
|
||
{
|
||
name: 'approvedServerMasterDataWritePaths',
|
||
files: approvedServerMasterDataWritePaths.keys(),
|
||
reason: '服务/仓储主数据写入口批准表不能残留已删除或改名的文件。',
|
||
},
|
||
{
|
||
name: 'approvedServerMasterDataWriteModelScopes',
|
||
files: approvedServerMasterDataWriteModelScopes.keys(),
|
||
reason: '服务/仓储主数据写入模型作用域表不能残留已删除或改名的文件。',
|
||
},
|
||
{
|
||
name: 'compatibilitySnapshotReadClassifications',
|
||
files: compatibilitySnapshotReadClassifications.keys(),
|
||
reason: '兼容快照读点分类表不能残留已删除或改名的文件。',
|
||
},
|
||
{
|
||
name: 'processSnapshotReadClassifications',
|
||
files: processSnapshotReadClassifications.keys(),
|
||
reason: '过程快照读点分类表不能残留已删除或改名的文件。',
|
||
},
|
||
{
|
||
name: 'structuredJsonPayloadReadAllowlist',
|
||
files: structuredJsonPayloadReadAllowlist.keys(),
|
||
reason: '结构化 payload 读点跳过表不能残留已删除或改名的文件。',
|
||
},
|
||
])
|
||
|
||
for (const absolutePath of files) {
|
||
const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath))
|
||
const content = fs.readFileSync(absolutePath, 'utf8')
|
||
|
||
for (const rule of rules) {
|
||
rule.pattern.lastIndex = 0
|
||
|
||
for (const match of content.matchAll(rule.pattern)) {
|
||
if (rule.skip?.({ relativePath, match })) continue
|
||
|
||
const line = lineNumberAt(content, match.index)
|
||
const lineText = content.split(/\r?\n/)[line - 1] ?? ''
|
||
const record = {
|
||
ruleId: rule.id,
|
||
severity: rule.severity,
|
||
file: relativePath,
|
||
line,
|
||
text: lineText.trim(),
|
||
description: rule.description,
|
||
}
|
||
const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.text}`
|
||
if (seenRecords.has(recordKey)) continue
|
||
seenRecords.add(recordKey)
|
||
|
||
if (rule.isAllowed({ relativePath, lineText, match })) {
|
||
approvedCompatibility.push({ ...record, severity: 'approved-compatibility' })
|
||
} else {
|
||
findings.push(record)
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const rule of advisoryRules) {
|
||
rule.pattern.lastIndex = 0
|
||
|
||
for (const match of content.matchAll(rule.pattern)) {
|
||
const candidate = rule.resolveCandidate({ relativePath, content, match })
|
||
const line = lineNumberAt(content, match.index)
|
||
const lineText = content.split(/\r?\n/)[line - 1] ?? ''
|
||
if (!candidate) {
|
||
if (!isTestFile(relativePath)) {
|
||
const record = {
|
||
ruleId: 'client-storage-unclassified',
|
||
severity: 'error',
|
||
file: relativePath,
|
||
line,
|
||
text: lineText.trim(),
|
||
description:
|
||
'非测试代码里的本地存储使用必须被分类:主数据风险、兼容缓存、旧缓存,或明确的非主数据 UI 缓存。',
|
||
}
|
||
const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.text}`
|
||
if (!seenRecords.has(recordKey)) {
|
||
seenRecords.add(recordKey)
|
||
unclassifiedStorageFindings.push(record)
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
|
||
const record = {
|
||
ruleId: rule.id,
|
||
severity: rule.severity,
|
||
category: candidate.category,
|
||
file: relativePath,
|
||
line,
|
||
text: lineText.trim(),
|
||
description: rule.description,
|
||
targetState: candidate.targetState,
|
||
}
|
||
const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.text}`
|
||
if (seenRecords.has(recordKey)) continue
|
||
seenRecords.add(recordKey)
|
||
advisoryFindings.push(record)
|
||
}
|
||
}
|
||
}
|
||
|
||
const scriptMasterDataWriteModels =
|
||
'product|productSKU|productSkuAssetProfile|draftGroup|preSku|preSkuBasicInfo|preSkuPackagingPlan|assetFamily|assetFamilySeriesCompatibility|assetVariant|assetSeries|material|materialCategory|hardware|hardwareCategory|hardwareMapping|commonComponent|commonComponentCategory|substrate|substrateCategory|substrateEnvironmentGrade|substrateFinish|substrateSurfaceOption|substrateSystemNameOption|substrateThicknessSpec|productCategory|warehouseLocation|packagedItem|packagingPlan|panel|panelMaterial|hole|productHardware|assemblyStep|assemblyVersion|assetVariantLocationBinding|brandMaterialBinding|preSkuLocationBinding|intakeNonIntegratedProduct|intakeNonIntegratedProductProfile|intakeNonIntegratedSkuItem|intakeNonIntegratedPricingRow|productPatentRecord|spuNumberCounter|intakeReferenceLibraryItem|user|department|employeeDirectory|employeeLookupCache|team|productCollaborator|shareLink|systemConfig|providerConfig|themeConfig|intakeNodePermissionOverride|assistedAssemblyAiRender|feedback|activityLog|approvalWorkflow|assetSkuSyncEvent'
|
||
const serverMasterDataWriteModels =
|
||
`${scriptMasterDataWriteModels}|approvalWorkflowFieldChange|planningTemplate|planningWorkspace|planningDraft`
|
||
const masterDataWriteActions = 'create|createMany|update|updateMany|upsert|delete|deleteMany'
|
||
const rawSqlMasterDataTableNames = new Set([
|
||
'ActivityLog',
|
||
'ApprovalWorkflow',
|
||
'ApprovalWorkflowFieldChange',
|
||
'AssetFamily',
|
||
'AssetFamilySeriesCompatibility',
|
||
'AssetSeries',
|
||
'AssetSkuSyncEvent',
|
||
'AssetVariant',
|
||
'AssistedAssemblyAiRender',
|
||
'BrandMaterialBinding',
|
||
'CommonComponent',
|
||
'CommonComponentCategory',
|
||
'Department',
|
||
'DraftGroup',
|
||
'EmployeeDirectory',
|
||
'EmployeeLookupCache',
|
||
'Feedback',
|
||
'Hardware',
|
||
'HardwareCategory',
|
||
'HardwareMapping',
|
||
'Hole',
|
||
'IntakeNodePermissionOverride',
|
||
'IntakeNonIntegratedProduct',
|
||
'IntakeNonIntegratedProductProfile',
|
||
'IntakeNonIntegratedSkuItem',
|
||
'IntakeNonIntegratedPricingRow',
|
||
'IntakeReferenceLibraryItem',
|
||
'Material',
|
||
'MaterialCategory',
|
||
'PackagedItem',
|
||
'PackagingPlan',
|
||
'Panel',
|
||
'PanelMaterial',
|
||
'PlanningDraft',
|
||
'PlanningTemplate',
|
||
'PlanningWorkspace',
|
||
'PreSku',
|
||
'PreSkuBasicInfo',
|
||
'PreSkuPackagingPlan',
|
||
'Product',
|
||
'ProductCategory',
|
||
'ProductCollaborator',
|
||
'ProductHardware',
|
||
'ProductPatentRecord',
|
||
'ProductSKU',
|
||
'ProductSkuAssetProfile',
|
||
'ProviderConfig',
|
||
'ShareLink',
|
||
'SpuNumberCounter',
|
||
'Substrate',
|
||
'SubstrateCategory',
|
||
'SubstrateEnvironmentGrade',
|
||
'SubstrateFinish',
|
||
'SubstrateSurfaceOption',
|
||
'SubstrateSystemNameOption',
|
||
'SubstrateThicknessSpec',
|
||
'SystemConfig',
|
||
'Team',
|
||
'ThemeConfig',
|
||
'User',
|
||
'WarehouseLocation',
|
||
])
|
||
|
||
const scriptMasterDataDirectWritePattern = new RegExp(
|
||
`\\b(?:prisma|tx)\\.(${scriptMasterDataWriteModels})\\.(${masterDataWriteActions})\\s*\\(`,
|
||
'g',
|
||
)
|
||
const rawSqlRelationRef = String.raw`(?:"?[A-Za-z_][\w]*"?\s*\.\s*)?"?([A-Z][A-Za-z0-9_]*)"?`
|
||
const rawSqlMasterDataWritePattern = new RegExp(
|
||
String.raw`\b(INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+${rawSqlRelationRef}`,
|
||
'g',
|
||
)
|
||
const rawSqlMasterDataReadPattern = new RegExp(
|
||
String.raw`\b(FROM|JOIN)\s+${rawSqlRelationRef}`,
|
||
'g',
|
||
)
|
||
const runtimeDatabaseDdlPattern =
|
||
/\b(CREATE\s+(?:TABLE|INDEX|UNIQUE\s+INDEX|OR\s+REPLACE\s+FUNCTION)|ALTER\s+TABLE|DROP\s+(?:TABLE|INDEX|FUNCTION)|TRUNCATE)\b/gi
|
||
const unsafeRawSqlPattern = /\.\$executeRawUnsafe\s*\(/g
|
||
|
||
function approvedTableScopeIncludes(approvedTables, tableName) {
|
||
return approvedTables === '*' || Boolean(approvedTables?.has(tableName))
|
||
}
|
||
|
||
function escapeRegExp(value) {
|
||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
}
|
||
|
||
function collectPrismaClientAliases(content) {
|
||
const aliases = new Set(['prisma', 'tx', 'transaction', 'this.prisma'])
|
||
const aliasPattern =
|
||
/\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:tx|transaction|prisma|this\.prisma)\s*(?:\?\?\s*(?:tx|transaction|prisma|this\.prisma))?/g
|
||
|
||
for (const match of content.matchAll(aliasPattern)) {
|
||
aliases.add(match[1])
|
||
}
|
||
|
||
return aliases
|
||
}
|
||
|
||
function buildServerMasterDataDirectWritePattern(content) {
|
||
const clientPattern = Array.from(collectPrismaClientAliases(content))
|
||
.sort((a, b) => b.length - a.length)
|
||
.map(escapeRegExp)
|
||
.join('|')
|
||
|
||
return new RegExp(
|
||
`\\b(?:${clientPattern})\\.(${serverMasterDataWriteModels})\\.(${masterDataWriteActions})\\s*\\(`,
|
||
'g',
|
||
)
|
||
}
|
||
|
||
const nestedRelationWriteModels = new Map([
|
||
['basicInfo', 'preSkuBasicInfo'],
|
||
['profile', 'intakeNonIntegratedProductProfile'],
|
||
['skuItems', 'intakeNonIntegratedSkuItem'],
|
||
['pricingRows', 'intakeNonIntegratedPricingRow'],
|
||
['assetProfile', 'productSkuAssetProfile'],
|
||
])
|
||
|
||
const nestedRelationWritePattern = new RegExp(
|
||
`\\b(${Array.from(nestedRelationWriteModels.keys()).join('|')})\\s*:\\s*\\{[\\s\\S]{0,500}?\\b(${masterDataWriteActions})\\s*:`,
|
||
'g',
|
||
)
|
||
|
||
for (const absolutePath of scriptFiles) {
|
||
const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath))
|
||
const content = fs.readFileSync(absolutePath, 'utf8')
|
||
const approvalReason = approvedScriptMasterDataWritePaths.get(relativePath)
|
||
|
||
scriptMasterDataDirectWritePattern.lastIndex = 0
|
||
for (const match of content.matchAll(scriptMasterDataDirectWritePattern)) {
|
||
const line = lineNumberAt(content, match.index)
|
||
const lineText = content.split(/\r?\n/)[line - 1] ?? ''
|
||
const record = {
|
||
ruleId: 'script-core-master-data-direct-write',
|
||
severity: approvalReason ? 'approved-script' : 'error',
|
||
file: relativePath,
|
||
line,
|
||
text: lineText.trim(),
|
||
description:
|
||
'脚本直接写核心主数据表必须被明确分类;生产修复和正式清洗必须走专用服务、迁移或显式清洗计划。',
|
||
reason: approvalReason ?? '',
|
||
}
|
||
const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.text}`
|
||
if (seenRecords.has(recordKey)) continue
|
||
seenRecords.add(recordKey)
|
||
scriptMasterDataWriteFindings.push(record)
|
||
if (!approvalReason) {
|
||
unclassifiedScriptMasterDataWriteFindings.push(record)
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const [relativePath, requirement] of approvedScriptSafetyGuardRequirements.entries()) {
|
||
const absolutePath = path.join(ROOT_DIR, relativePath)
|
||
if (!fs.existsSync(absolutePath)) continue
|
||
const content = fs.readFileSync(absolutePath, 'utf8').replace(/\r\n/g, '\n')
|
||
const missingTexts = requirement.requiredTexts.filter((text) => !content.includes(text))
|
||
const record = {
|
||
ruleId: 'approved-script-safety-guard',
|
||
severity: missingTexts.length === 0 ? 'approved-script-guard' : 'error',
|
||
file: relativePath,
|
||
line: 1,
|
||
text: missingTexts.length === 0 ? 'required guard tokens present' : `missing: ${missingTexts.join(', ')}`,
|
||
description:
|
||
'已批准的脚本层主数据写入/同步入口必须保留显式门禁,不能只靠分类说明防误写。',
|
||
reason: requirement.reason,
|
||
missingTexts,
|
||
}
|
||
scriptSafetyGuardFindings.push(record)
|
||
if (missingTexts.length > 0) {
|
||
missingScriptSafetyGuardFindings.push(record)
|
||
findings.push(record)
|
||
}
|
||
}
|
||
|
||
for (const absolutePath of [...scriptFiles, ...files]) {
|
||
const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath))
|
||
if (isTestFile(relativePath)) continue
|
||
|
||
const content = fs.readFileSync(absolutePath, 'utf8')
|
||
const approvalReason = approvedRawSqlMasterDataWritePaths.get(relativePath)
|
||
const approvedTables = approvedRawSqlMasterDataWriteTableScopes.get(relativePath)
|
||
|
||
rawSqlMasterDataWritePattern.lastIndex = 0
|
||
for (const match of content.matchAll(rawSqlMasterDataWritePattern)) {
|
||
const tableName = match[2]
|
||
if (!rawSqlMasterDataTableNames.has(tableName)) continue
|
||
const isApproved = Boolean(approvalReason) && approvedTableScopeIncludes(approvedTables, tableName)
|
||
|
||
const line = lineNumberAt(content, match.index)
|
||
const lineText = content.split(/\r?\n/)[line - 1] ?? ''
|
||
const record = {
|
||
ruleId: 'raw-sql-master-data-write',
|
||
severity: isApproved ? 'approved-raw-sql' : 'error',
|
||
file: relativePath,
|
||
line,
|
||
text: lineText.trim(),
|
||
tableName,
|
||
operation: match[1].replace(/\s+/g, ' ').toUpperCase(),
|
||
description:
|
||
'原生 SQL 写核心主数据表必须被明确分类;需要说明事务语义、并发/锁策略或历史清洗计划,不能绕过 Prisma 写入口巡检。',
|
||
reason: approvalReason
|
||
? approvedTableScopeIncludes(approvedTables, tableName)
|
||
? approvalReason
|
||
: `该文件已分类,但未批准原生 SQL 写入 ${tableName};需要确认是否新增旁路或扩展允许表范围。`
|
||
: '',
|
||
}
|
||
const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.tableName}:${record.operation}`
|
||
if (seenRecords.has(recordKey)) continue
|
||
seenRecords.add(recordKey)
|
||
rawSqlMasterDataWriteFindings.push(record)
|
||
if (!isApproved) {
|
||
unclassifiedRawSqlMasterDataWriteFindings.push(record)
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const absolutePath of [...scriptFiles, ...files]) {
|
||
const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath))
|
||
if (isTestFile(relativePath)) continue
|
||
|
||
const content = fs.readFileSync(absolutePath, 'utf8')
|
||
const approvalReason = approvedRawSqlMasterDataReadPaths.get(relativePath)
|
||
const approvedTables = approvedRawSqlMasterDataReadTableScopes.get(relativePath)
|
||
|
||
rawSqlMasterDataReadPattern.lastIndex = 0
|
||
for (const match of content.matchAll(rawSqlMasterDataReadPattern)) {
|
||
const tableName = match[2]
|
||
if (!rawSqlMasterDataTableNames.has(tableName)) continue
|
||
const isApproved = Boolean(approvalReason) && approvedTableScopeIncludes(approvedTables, tableName)
|
||
|
||
const line = lineNumberAt(content, match.index)
|
||
const lineText = content.split(/\r?\n/)[line - 1] ?? ''
|
||
const record = {
|
||
ruleId: 'raw-sql-master-data-read',
|
||
severity: isApproved ? 'approved-raw-sql-read' : 'error',
|
||
file: relativePath,
|
||
line,
|
||
text: lineText.trim(),
|
||
tableName,
|
||
operation: match[1].toUpperCase(),
|
||
description:
|
||
'原生 SQL 读取核心主数据表必须被明确分类;业务服务默认应通过 Prisma delegate、Repository 或结构化 helper 读取,避免绕过统一真相源边界。',
|
||
reason: approvalReason
|
||
? approvedTableScopeIncludes(approvedTables, tableName)
|
||
? approvalReason
|
||
: `该文件已分类,但未批准原生 SQL 读取 ${tableName};需要确认是否新增旁路或扩展允许表范围。`
|
||
: '',
|
||
}
|
||
const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.tableName}:${record.operation}`
|
||
if (seenRecords.has(recordKey)) continue
|
||
seenRecords.add(recordKey)
|
||
rawSqlMasterDataReadFindings.push(record)
|
||
if (!isApproved) {
|
||
unclassifiedRawSqlMasterDataReadFindings.push(record)
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const absolutePath of [...scriptFiles, ...files]) {
|
||
const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath))
|
||
if (isTestFile(relativePath)) continue
|
||
if (relativePath === 'scripts/verify-master-data-code-boundaries.js') continue
|
||
|
||
const content = fs.readFileSync(absolutePath, 'utf8')
|
||
if (!/\$executeRaw(?:Unsafe)?|(?:\w+\.)?query\s*\(/.test(content)) continue
|
||
const approvalReason = approvedRuntimeDatabaseDdlPaths.get(relativePath)
|
||
|
||
const addRuntimeDatabaseDdlFinding = ({ index, ruleId, operation, description }) => {
|
||
const line = lineNumberAt(content, index)
|
||
const lineText = content.split(/\r?\n/)[line - 1] ?? ''
|
||
const record = {
|
||
ruleId,
|
||
severity: approvalReason ? 'approved-runtime-ddl' : 'error',
|
||
file: relativePath,
|
||
line,
|
||
text: lineText.trim(),
|
||
operation,
|
||
description,
|
||
reason: approvalReason ?? '',
|
||
}
|
||
const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.operation}:${record.text}`
|
||
if (seenRecords.has(recordKey)) return
|
||
seenRecords.add(recordKey)
|
||
runtimeDatabaseDdlFindings.push(record)
|
||
if (!approvalReason) {
|
||
unclassifiedRuntimeDatabaseDdlFindings.push(record)
|
||
}
|
||
}
|
||
|
||
runtimeDatabaseDdlPattern.lastIndex = 0
|
||
for (const match of content.matchAll(runtimeDatabaseDdlPattern)) {
|
||
addRuntimeDatabaseDdlFinding({
|
||
index: match.index,
|
||
ruleId: 'runtime-database-ddl',
|
||
operation: match[1].replace(/\s+/g, ' ').toUpperCase(),
|
||
description:
|
||
'应用代码和普通脚本不能在运行时补建表、索引、约束或函数;数据库结构真相必须来自 Prisma migration、治理迁移清单和 strict 巡检。',
|
||
})
|
||
}
|
||
|
||
unsafeRawSqlPattern.lastIndex = 0
|
||
for (const match of content.matchAll(unsafeRawSqlPattern)) {
|
||
addRuntimeDatabaseDdlFinding({
|
||
index: match.index,
|
||
ruleId: 'unsafe-raw-sql-runtime-entry',
|
||
operation: '$executeRawUnsafe',
|
||
description:
|
||
'$executeRawUnsafe 会绕开参数化和写入口语义巡检;应用代码必须使用 Prisma delegate、$queryRaw/$executeRaw tagged template,或显式迁移/清洗脚本。',
|
||
})
|
||
}
|
||
}
|
||
|
||
for (const absolutePath of files) {
|
||
const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath))
|
||
if (!relativePath.startsWith('src/server/') || isTestFile(relativePath)) continue
|
||
|
||
const content = fs.readFileSync(absolutePath, 'utf8')
|
||
const approvalReason = approvedServerMasterDataWritePaths.get(relativePath)
|
||
const approvedModels = approvedServerMasterDataWriteModelScopes.get(relativePath)
|
||
|
||
const addServerMasterDataWriteFinding = ({
|
||
index,
|
||
model,
|
||
action,
|
||
ruleId = 'server-core-master-data-direct-write',
|
||
description =
|
||
'服务/仓储层直接写核心主数据表必须被明确分类;新增写入口需要说明职责,避免绕过统一业务校验、结构化副本同步和审计边界。',
|
||
parentModel = null,
|
||
nestedRelation = null,
|
||
}) => {
|
||
const line = lineNumberAt(content, index)
|
||
const lineText = content.split(/\r?\n/)[line - 1] ?? ''
|
||
const isApproved =
|
||
Boolean(approvalReason) && Boolean(approvedModels?.has(model))
|
||
const record = {
|
||
ruleId,
|
||
severity: isApproved ? 'approved-server-write' : 'error',
|
||
file: relativePath,
|
||
line,
|
||
text: lineText.trim(),
|
||
model,
|
||
action,
|
||
...(parentModel && { parentModel }),
|
||
...(nestedRelation && { nestedRelation }),
|
||
description,
|
||
reason: approvalReason
|
||
? approvedModels?.has(model)
|
||
? approvalReason
|
||
: `该文件已分类,但未批准写入 ${model};需要确认是否新增旁路或扩展允许模型。`
|
||
: '',
|
||
}
|
||
const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.text}:${record.model}:${record.action}:${record.nestedRelation ?? ''}`
|
||
if (seenRecords.has(recordKey)) return
|
||
seenRecords.add(recordKey)
|
||
serverMasterDataWriteFindings.push(record)
|
||
if (!isApproved) {
|
||
unclassifiedServerMasterDataWriteFindings.push(record)
|
||
}
|
||
}
|
||
|
||
const serverMasterDataDirectWritePattern = buildServerMasterDataDirectWritePattern(content)
|
||
serverMasterDataDirectWritePattern.lastIndex = 0
|
||
for (const match of content.matchAll(serverMasterDataDirectWritePattern)) {
|
||
addServerMasterDataWriteFinding({
|
||
index: match.index,
|
||
model: match[1],
|
||
action: match[2],
|
||
})
|
||
}
|
||
|
||
nestedRelationWritePattern.lastIndex = 0
|
||
for (const match of content.matchAll(nestedRelationWritePattern)) {
|
||
addServerMasterDataWriteFinding({
|
||
index: match.index,
|
||
model: nestedRelationWriteModels.get(match[1]),
|
||
action: match[2],
|
||
ruleId: 'server-core-master-data-nested-write',
|
||
nestedRelation: match[1],
|
||
description:
|
||
'服务/仓储层的 Prisma relation 嵌套写入也必须被明确分类;不能只批准父模型写入而绕过子模型结构化校验边界。',
|
||
})
|
||
}
|
||
}
|
||
|
||
const compatibilitySnapshotReadInventory = collectCompatibilitySnapshotReadFindings([
|
||
...files,
|
||
...scriptFiles,
|
||
])
|
||
const compatibilitySnapshotReadFindings = compatibilitySnapshotReadInventory.findings
|
||
const skippedStructuredPayloadReads =
|
||
compatibilitySnapshotReadInventory.skippedStructuredPayloadReads
|
||
const compatibilitySnapshotReadSummary =
|
||
summarizeCompatibilitySnapshotReads(compatibilitySnapshotReadFindings)
|
||
const unclassifiedCompatibilitySnapshotReadFindings =
|
||
compatibilitySnapshotReadSummary.unclassifiedCompatibilitySnapshotReads
|
||
const unusedCompatibilitySnapshotReadClassifications =
|
||
findUnusedCompatibilitySnapshotReadClassifications(
|
||
compatibilitySnapshotReadFindings,
|
||
skippedStructuredPayloadReads,
|
||
)
|
||
const productSkuAttributeWriteFindings = collectProductSkuAttributeWriteFindings([
|
||
...files,
|
||
...scriptFiles,
|
||
])
|
||
const productSkuAttributeWriteSummary =
|
||
summarizeProductSkuAttributeWriteFindings(productSkuAttributeWriteFindings)
|
||
const referenceEstimatorCostBoundaryFindings =
|
||
collectReferenceEstimatorCostBoundaryFindings(files)
|
||
const referenceEstimatorCostBoundarySummary =
|
||
summarizeReferenceEstimatorCostBoundaryFindings(
|
||
referenceEstimatorCostBoundaryFindings,
|
||
)
|
||
const fileSystemMasterDataSourceFindings =
|
||
collectFileSystemMasterDataSourceFindings(files)
|
||
const fileSystemMasterDataSourceSummary =
|
||
summarizeFileSystemMasterDataSourceFindings(
|
||
fileSystemMasterDataSourceFindings,
|
||
)
|
||
const processSnapshotReadFindings = collectProcessSnapshotReadFindings([
|
||
...files,
|
||
...scriptFiles,
|
||
])
|
||
const processSnapshotReadSummary =
|
||
summarizeProcessSnapshotReads(processSnapshotReadFindings)
|
||
const unclassifiedProcessSnapshotReadFindings =
|
||
processSnapshotReadSummary.unclassifiedProcessSnapshotReads
|
||
const unusedProcessSnapshotReadClassifications =
|
||
findUnusedProcessSnapshotReadClassifications(processSnapshotReadFindings)
|
||
const snapshotReadBudgetReport = buildSnapshotReadBudgetReport(
|
||
compatibilitySnapshotReadSummary,
|
||
processSnapshotReadSummary,
|
||
)
|
||
|
||
if (args.mode === 'compatibility-snapshot-read') {
|
||
emitReport({
|
||
mode: 'compatibility-snapshot-read',
|
||
checkedFiles: files.length,
|
||
checkedScriptFiles: scriptFiles.length,
|
||
...compatibilitySnapshotReadSummary,
|
||
skippedStructuredPayloadReadCount: skippedStructuredPayloadReads.length,
|
||
skippedStructuredPayloadReads,
|
||
unusedCompatibilitySnapshotReadClassificationCount:
|
||
unusedCompatibilitySnapshotReadClassifications.length,
|
||
unusedCompatibilitySnapshotReadClassifications,
|
||
compatibilitySnapshotReadDebtBudget:
|
||
SNAPSHOT_READ_DEBT_BUDGETS.compatibilitySnapshotRead,
|
||
compatibilitySnapshotReadBudgetViolationCount:
|
||
snapshotReadBudgetReport.compatibilitySnapshotReadBudgetViolationCount,
|
||
compatibilitySnapshotReadBudgetViolations:
|
||
snapshotReadBudgetReport.compatibilitySnapshotReadBudgetViolations,
|
||
compatibilitySnapshotReadFindings,
|
||
})
|
||
process.exit(
|
||
compatibilitySnapshotReadSummary.compatibilitySnapshotReadBlockingRiskCount > 0 ||
|
||
unusedCompatibilitySnapshotReadClassifications.length > 0 ||
|
||
snapshotReadBudgetReport.compatibilitySnapshotReadBudgetViolationCount > 0
|
||
? 1
|
||
: 0,
|
||
)
|
||
}
|
||
|
||
if (args.mode === 'server-master-data-write') {
|
||
emitReport({
|
||
mode: 'server-master-data-write',
|
||
checkedFiles: files.length,
|
||
serverMasterDataDirectWriteCount: serverMasterDataWriteFindings.length,
|
||
approvedServerMasterDataDirectWrites: serverMasterDataWriteFindings.filter(
|
||
(finding) => finding.severity === 'approved-server-write',
|
||
),
|
||
unclassifiedServerMasterDataDirectWriteCount:
|
||
unclassifiedServerMasterDataWriteFindings.length,
|
||
unclassifiedServerMasterDataDirectWrites:
|
||
unclassifiedServerMasterDataWriteFindings,
|
||
})
|
||
process.exit(unclassifiedServerMasterDataWriteFindings.length > 0 ? 1 : 0)
|
||
}
|
||
|
||
if (args.mode === 'process-snapshot-read') {
|
||
emitReport({
|
||
mode: 'process-snapshot-read',
|
||
checkedFiles: files.length,
|
||
checkedScriptFiles: scriptFiles.length,
|
||
...processSnapshotReadSummary,
|
||
unusedProcessSnapshotReadClassificationCount:
|
||
unusedProcessSnapshotReadClassifications.length,
|
||
unusedProcessSnapshotReadClassifications,
|
||
processSnapshotReadDebtBudget:
|
||
SNAPSHOT_READ_DEBT_BUDGETS.processSnapshotRead,
|
||
processSnapshotReadBudgetViolationCount:
|
||
snapshotReadBudgetReport.processSnapshotReadBudgetViolationCount,
|
||
processSnapshotReadBudgetViolations:
|
||
snapshotReadBudgetReport.processSnapshotReadBudgetViolations,
|
||
processSnapshotReadFindings,
|
||
})
|
||
process.exit(
|
||
processSnapshotReadSummary.processSnapshotReadBlockingRiskCount > 0 ||
|
||
unusedProcessSnapshotReadClassifications.length > 0 ||
|
||
snapshotReadBudgetReport.processSnapshotReadBudgetViolationCount > 0
|
||
? 1
|
||
: 0,
|
||
)
|
||
}
|
||
|
||
if (args.mode === 'product-sku-attributes-write') {
|
||
emitReport({
|
||
mode: 'product-sku-attributes-write',
|
||
checkedFiles: files.length,
|
||
checkedScriptFiles: scriptFiles.length,
|
||
allowedProductSkuAttributeWriteKeys: Array.from(allowedProductSkuAttributeWriteKeys),
|
||
reservedProductSkuAttributeWriteKeys: Array.from(reservedProductSkuAttributeWriteKeys),
|
||
...productSkuAttributeWriteSummary,
|
||
})
|
||
process.exit(
|
||
productSkuAttributeWriteSummary.productSkuAttributeWriteBlockingRiskCount > 0
|
||
? 1
|
||
: 0,
|
||
)
|
||
}
|
||
|
||
if (args.mode === 'reference-estimator-cost-boundary') {
|
||
emitReport({
|
||
mode: 'reference-estimator-cost-boundary',
|
||
checkedFiles: files.length,
|
||
approvedReferenceEstimatorCostSourcePaths: Array.from(
|
||
approvedReferenceEstimatorCostSourcePaths.entries(),
|
||
).map(([file, reason]) => ({ file, reason })),
|
||
...referenceEstimatorCostBoundarySummary,
|
||
})
|
||
process.exit(
|
||
referenceEstimatorCostBoundarySummary
|
||
.unclassifiedReferenceEstimatorCostBoundaryFindingCount > 0
|
||
? 1
|
||
: 0,
|
||
)
|
||
}
|
||
|
||
if (args.mode === 'filesystem-master-data-source') {
|
||
emitReport({
|
||
mode: 'filesystem-master-data-source',
|
||
checkedFiles: files.length,
|
||
approvedFileSystemMasterDataSourcePaths: Array.from(
|
||
approvedFileSystemMasterDataSourcePaths.entries(),
|
||
).map(([file, reason]) => ({ file, reason })),
|
||
...fileSystemMasterDataSourceSummary,
|
||
})
|
||
process.exit(
|
||
fileSystemMasterDataSourceSummary.unclassifiedFileSystemMasterDataSourceCount > 0
|
||
? 1
|
||
: 0,
|
||
)
|
||
}
|
||
|
||
if (args.mode) {
|
||
throw new Error(`未知巡检模式:${args.mode}`)
|
||
}
|
||
|
||
const report = {
|
||
checkedFiles: files.length,
|
||
checkedScriptFiles: scriptFiles.length,
|
||
violations: findings,
|
||
approvedCompatibilityCount: approvedCompatibility.length,
|
||
approvedCompatibility: approvedCompatibility.slice(0, 50),
|
||
advisoryCount: advisoryFindings.length,
|
||
advisoryByCategory: advisoryFindings.reduce((result, finding) => {
|
||
result[finding.category] = (result[finding.category] ?? 0) + 1
|
||
return result
|
||
}, {}),
|
||
advisoryFindings: advisoryFindings.slice(0, 50),
|
||
unclassifiedStorageCount: unclassifiedStorageFindings.length,
|
||
unclassifiedStorageFindings: unclassifiedStorageFindings.slice(0, 50),
|
||
scriptMasterDataDirectWriteCount: scriptMasterDataWriteFindings.length,
|
||
approvedScriptMasterDataDirectWrites: scriptMasterDataWriteFindings
|
||
.filter((finding) => finding.severity === 'approved-script')
|
||
.slice(0, 50),
|
||
unclassifiedScriptMasterDataDirectWriteCount:
|
||
unclassifiedScriptMasterDataWriteFindings.length,
|
||
unclassifiedScriptMasterDataDirectWrites:
|
||
unclassifiedScriptMasterDataWriteFindings.slice(0, 50),
|
||
scriptSafetyGuardCount: scriptSafetyGuardFindings.length,
|
||
approvedScriptSafetyGuards: scriptSafetyGuardFindings
|
||
.filter((finding) => finding.severity === 'approved-script-guard')
|
||
.slice(0, 50),
|
||
missingScriptSafetyGuardCount: missingScriptSafetyGuardFindings.length,
|
||
missingScriptSafetyGuards: missingScriptSafetyGuardFindings.slice(0, 50),
|
||
rawSqlMasterDataWriteCount: rawSqlMasterDataWriteFindings.length,
|
||
approvedRawSqlMasterDataWrites: rawSqlMasterDataWriteFindings
|
||
.filter((finding) => finding.severity === 'approved-raw-sql')
|
||
.slice(0, 50),
|
||
unclassifiedRawSqlMasterDataWriteCount:
|
||
unclassifiedRawSqlMasterDataWriteFindings.length,
|
||
unclassifiedRawSqlMasterDataWrites:
|
||
unclassifiedRawSqlMasterDataWriteFindings.slice(0, 50),
|
||
rawSqlMasterDataReadCount: rawSqlMasterDataReadFindings.length,
|
||
approvedRawSqlMasterDataReads: rawSqlMasterDataReadFindings
|
||
.filter((finding) => finding.severity === 'approved-raw-sql-read')
|
||
.slice(0, 50),
|
||
unclassifiedRawSqlMasterDataReadCount:
|
||
unclassifiedRawSqlMasterDataReadFindings.length,
|
||
unclassifiedRawSqlMasterDataReads:
|
||
unclassifiedRawSqlMasterDataReadFindings.slice(0, 50),
|
||
runtimeDatabaseDdlCount: runtimeDatabaseDdlFindings.length,
|
||
approvedRuntimeDatabaseDdlFindings: runtimeDatabaseDdlFindings
|
||
.filter((finding) => finding.severity === 'approved-runtime-ddl')
|
||
.slice(0, 50),
|
||
unclassifiedRuntimeDatabaseDdlCount:
|
||
unclassifiedRuntimeDatabaseDdlFindings.length,
|
||
unclassifiedRuntimeDatabaseDdlFindings:
|
||
unclassifiedRuntimeDatabaseDdlFindings.slice(0, 50),
|
||
serverMasterDataDirectWriteCount: serverMasterDataWriteFindings.length,
|
||
approvedServerMasterDataDirectWrites: serverMasterDataWriteFindings
|
||
.filter((finding) => finding.severity === 'approved-server-write')
|
||
.slice(0, 50),
|
||
unclassifiedServerMasterDataDirectWriteCount:
|
||
unclassifiedServerMasterDataWriteFindings.length,
|
||
unclassifiedServerMasterDataDirectWrites:
|
||
unclassifiedServerMasterDataWriteFindings.slice(0, 50),
|
||
compatibilitySnapshotReadCount: compatibilitySnapshotReadFindings.length,
|
||
compatibilitySnapshotReadByCategory:
|
||
compatibilitySnapshotReadSummary.compatibilitySnapshotReadByCategory,
|
||
compatibilitySnapshotReadByRuleId:
|
||
compatibilitySnapshotReadSummary.compatibilitySnapshotReadByRuleId,
|
||
compatibilitySnapshotReadByFile:
|
||
compatibilitySnapshotReadSummary.compatibilitySnapshotReadByFile,
|
||
compatibilitySnapshotReadByPriority:
|
||
compatibilitySnapshotReadSummary.compatibilitySnapshotReadByPriority,
|
||
compatibilitySnapshotReadBlockingRiskCount:
|
||
compatibilitySnapshotReadSummary.compatibilitySnapshotReadBlockingRiskCount,
|
||
compatibilitySnapshotReadBlockingRisks:
|
||
compatibilitySnapshotReadSummary.compatibilitySnapshotReadBlockingRisks.slice(0, 50),
|
||
compatibilitySnapshotReadSummary:
|
||
compatibilitySnapshotReadSummary.compatibilitySnapshotReadSummary,
|
||
unclassifiedCompatibilitySnapshotReadCount:
|
||
compatibilitySnapshotReadSummary.unclassifiedCompatibilitySnapshotReadCount,
|
||
unclassifiedCompatibilitySnapshotReads:
|
||
compatibilitySnapshotReadSummary.unclassifiedCompatibilitySnapshotReads.slice(0, 50),
|
||
skippedStructuredPayloadReadCount: skippedStructuredPayloadReads.length,
|
||
skippedStructuredPayloadReads: skippedStructuredPayloadReads.slice(0, 50),
|
||
unusedCompatibilitySnapshotReadClassificationCount:
|
||
unusedCompatibilitySnapshotReadClassifications.length,
|
||
unusedCompatibilitySnapshotReadClassifications:
|
||
unusedCompatibilitySnapshotReadClassifications.slice(0, 50),
|
||
compatibilitySnapshotReadFindings: compatibilitySnapshotReadFindings.slice(0, 50),
|
||
productSkuAttributeWriteRiskCount:
|
||
productSkuAttributeWriteSummary.productSkuAttributeWriteRiskCount,
|
||
productSkuAttributeWriteRiskByRuleId:
|
||
productSkuAttributeWriteSummary.productSkuAttributeWriteRiskByRuleId,
|
||
productSkuAttributeWriteRiskByFile:
|
||
productSkuAttributeWriteSummary.productSkuAttributeWriteRiskByFile,
|
||
productSkuAttributeWriteRiskByPriority:
|
||
productSkuAttributeWriteSummary.productSkuAttributeWriteRiskByPriority,
|
||
productSkuAttributeWriteBlockingRiskCount:
|
||
productSkuAttributeWriteSummary.productSkuAttributeWriteBlockingRiskCount,
|
||
productSkuAttributeWriteBlockingRisks:
|
||
productSkuAttributeWriteSummary.productSkuAttributeWriteBlockingRisks.slice(0, 50),
|
||
productSkuAttributeWriteRisks:
|
||
productSkuAttributeWriteSummary.productSkuAttributeWriteRisks.slice(0, 50),
|
||
referenceEstimatorCostBoundaryFindingCount:
|
||
referenceEstimatorCostBoundarySummary.referenceEstimatorCostBoundaryFindingCount,
|
||
referenceEstimatorCostBoundaryByRuleId:
|
||
referenceEstimatorCostBoundarySummary.referenceEstimatorCostBoundaryByRuleId,
|
||
referenceEstimatorCostBoundaryByFile:
|
||
referenceEstimatorCostBoundarySummary.referenceEstimatorCostBoundaryByFile,
|
||
approvedReferenceEstimatorCostBoundaryFindings:
|
||
referenceEstimatorCostBoundarySummary.approvedReferenceEstimatorCostBoundaryFindings.slice(
|
||
0,
|
||
50,
|
||
),
|
||
unclassifiedReferenceEstimatorCostBoundaryFindingCount:
|
||
referenceEstimatorCostBoundarySummary
|
||
.unclassifiedReferenceEstimatorCostBoundaryFindingCount,
|
||
unclassifiedReferenceEstimatorCostBoundaryFindings:
|
||
referenceEstimatorCostBoundarySummary.unclassifiedReferenceEstimatorCostBoundaryFindings.slice(
|
||
0,
|
||
50,
|
||
),
|
||
fileSystemMasterDataSourceFindingCount:
|
||
fileSystemMasterDataSourceSummary.fileSystemMasterDataSourceFindingCount,
|
||
fileSystemMasterDataSourceByFile:
|
||
fileSystemMasterDataSourceSummary.fileSystemMasterDataSourceByFile,
|
||
approvedFileSystemMasterDataSources:
|
||
fileSystemMasterDataSourceSummary.approvedFileSystemMasterDataSources.slice(0, 50),
|
||
unclassifiedFileSystemMasterDataSourceCount:
|
||
fileSystemMasterDataSourceSummary.unclassifiedFileSystemMasterDataSourceCount,
|
||
unclassifiedFileSystemMasterDataSources:
|
||
fileSystemMasterDataSourceSummary.unclassifiedFileSystemMasterDataSources.slice(0, 50),
|
||
processSnapshotReadCount: processSnapshotReadFindings.length,
|
||
processSnapshotReadByCategory:
|
||
processSnapshotReadSummary.processSnapshotReadByCategory,
|
||
processSnapshotReadByRuleId:
|
||
processSnapshotReadSummary.processSnapshotReadByRuleId,
|
||
processSnapshotReadByFile:
|
||
processSnapshotReadSummary.processSnapshotReadByFile,
|
||
processSnapshotReadByPriority:
|
||
processSnapshotReadSummary.processSnapshotReadByPriority,
|
||
processSnapshotReadBlockingRiskCount:
|
||
processSnapshotReadSummary.processSnapshotReadBlockingRiskCount,
|
||
processSnapshotReadBlockingRisks:
|
||
processSnapshotReadSummary.processSnapshotReadBlockingRisks.slice(0, 50),
|
||
processSnapshotReadSummary:
|
||
processSnapshotReadSummary.processSnapshotReadSummary,
|
||
unclassifiedProcessSnapshotReadCount:
|
||
processSnapshotReadSummary.unclassifiedProcessSnapshotReadCount,
|
||
unclassifiedProcessSnapshotReads:
|
||
processSnapshotReadSummary.unclassifiedProcessSnapshotReads.slice(0, 50),
|
||
unusedProcessSnapshotReadClassificationCount:
|
||
unusedProcessSnapshotReadClassifications.length,
|
||
unusedProcessSnapshotReadClassifications:
|
||
unusedProcessSnapshotReadClassifications.slice(0, 50),
|
||
processSnapshotReadFindings: processSnapshotReadFindings.slice(0, 50),
|
||
...snapshotReadBudgetReport,
|
||
schemaJsonFieldCount: schemaJsonFieldInventory.length,
|
||
schemaJsonFieldByCategory: schemaJsonFieldInventory.reduce((result, field) => {
|
||
result[field.category] = (result[field.category] ?? 0) + 1
|
||
return result
|
||
}, {}),
|
||
unclassifiedSchemaJsonFieldCount: unclassifiedSchemaJsonFields.length,
|
||
unclassifiedSchemaJsonFields,
|
||
staleSchemaJsonFieldClassificationCount:
|
||
staleSchemaJsonFieldClassifications.length,
|
||
staleSchemaJsonFieldClassifications,
|
||
schemaJsonFieldBoundaryRequirementCount:
|
||
schemaJsonFieldBoundaryRequirements.size,
|
||
schemaJsonFieldBoundaryIssueCount:
|
||
schemaJsonFieldBoundaryIssues.length,
|
||
schemaJsonFieldBoundaryIssues:
|
||
schemaJsonFieldBoundaryIssues.slice(0, 50),
|
||
staleFileClassificationCount: staleFileClassifications.length,
|
||
staleFileClassifications,
|
||
approvedClientStorageEvidenceIssueCount:
|
||
approvedClientStorageEvidenceIssues.length,
|
||
approvedClientStorageEvidenceIssues:
|
||
approvedClientStorageEvidenceIssues.slice(0, 50),
|
||
approvedClientStorageUsages: approvedClientStorageUsages.map((usage) => ({
|
||
file: usage.file,
|
||
category: usage.category,
|
||
targetState: usage.targetState,
|
||
reason: usage.reason,
|
||
requiredEvidenceCount: usage.requiredEvidence?.length ?? 0,
|
||
forbiddenEvidenceCount: usage.forbiddenEvidence?.length ?? 0,
|
||
})),
|
||
}
|
||
|
||
emitReport(report)
|
||
|
||
if (
|
||
findings.length > 0 ||
|
||
unclassifiedStorageFindings.length > 0 ||
|
||
unclassifiedScriptMasterDataWriteFindings.length > 0 ||
|
||
unclassifiedRawSqlMasterDataWriteFindings.length > 0 ||
|
||
unclassifiedRawSqlMasterDataReadFindings.length > 0 ||
|
||
unclassifiedRuntimeDatabaseDdlFindings.length > 0 ||
|
||
unclassifiedServerMasterDataWriteFindings.length > 0 ||
|
||
unclassifiedCompatibilitySnapshotReadFindings.length > 0 ||
|
||
compatibilitySnapshotReadSummary.compatibilitySnapshotReadBlockingRiskCount > 0 ||
|
||
unusedCompatibilitySnapshotReadClassifications.length > 0 ||
|
||
productSkuAttributeWriteSummary.productSkuAttributeWriteBlockingRiskCount > 0 ||
|
||
referenceEstimatorCostBoundarySummary
|
||
.unclassifiedReferenceEstimatorCostBoundaryFindingCount > 0 ||
|
||
fileSystemMasterDataSourceSummary.unclassifiedFileSystemMasterDataSourceCount > 0 ||
|
||
processSnapshotReadSummary.processSnapshotReadBlockingRiskCount > 0 ||
|
||
unusedProcessSnapshotReadClassifications.length > 0 ||
|
||
unclassifiedProcessSnapshotReadFindings.length > 0 ||
|
||
snapshotReadBudgetReport.compatibilitySnapshotReadBudgetViolationCount > 0 ||
|
||
snapshotReadBudgetReport.processSnapshotReadBudgetViolationCount > 0 ||
|
||
unclassifiedSchemaJsonFields.length > 0 ||
|
||
staleSchemaJsonFieldClassifications.length > 0 ||
|
||
schemaJsonFieldBoundaryIssues.length > 0 ||
|
||
staleFileClassifications.length > 0 ||
|
||
approvedClientStorageEvidenceIssues.length > 0
|
||
) {
|
||
process.exit(1)
|
||
}
|
||
}
|