Files
html-transform/src/lib/maintenance.js
T
skytek_xinliang c8ff825722 feat: derive flow refs and refine maintenance contracts
Parse legacy flow blocks from prototype guides into flowRefs so prompts can rely on extracted evidence instead of hardcoded JSP flow knowledge.

Refine API endpoint matching for special route roles and prototype types, and expand maintenance contracts with row action conditions and checklist-derived business rules. Update README docs to reflect the new contract fields and app-map output.feat: derive flow refs and refine maintenance contracts

Parse legacy flow blocks from prototype guides into flowRefs so prompts can rely on extracted evidence instead of hardcoded JSP flow knowledge.

Refine API endpoint matching for special route roles and prototype types, and expand maintenance contracts with row action conditions and checklist-derived business rules. Update README docs to reflect the new contract fields and app-map output.
2026-05-21 16:05:39 +08:00

298 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export function buildMaintenanceContract({ route, spec, apiMatches = [] }) {
const pageKind = inferPageKind(route, spec, apiMatches)
const capabilities = inferCapabilities(spec, apiMatches)
const dataModel = inferDataModel(route, spec, apiMatches)
const rowActions = inferRowActions(spec)
const businessRules = inferBusinessRules(spec)
const template = recommendTemplate(pageKind, spec, capabilities, dataModel)
return {
pageKind,
capabilities,
recommendedTemplate: template.name,
confidence: template.confidence,
reasons: template.reasons,
warnings: buildWarnings(pageKind, template.name, spec, dataModel, apiMatches, rowActions),
dataModel,
rowActions,
businessRules
}
}
function inferPageKind(route, spec, apiMatches) {
const path = route.prototype ?? spec.page
const text = contractText(spec)
if (['auth', 'auth-support'].includes(route.kind)) return route.targetRole ?? 'auth'
if (route.kind === 'reference') return 'reference'
if (route.kind === 'legacy-shell-reference') return 'layout-reference'
if (route.kind === 'prototype-navigation' || /choose|index/i.test(path) && /(挑選|選擇|導覽|返回雛型導覽)/.test(text)) return 'chooser'
if (/print|prt/i.test(path) || /(列印|申請表)/.test(text) && !hasAction(spec, ['edit', 'delete', 'save'])) return 'print'
if (hasAction(spec, ['edit', 'delete'])) return 'maintenance'
if (hasAction(spec, ['save', 'create'])) return 'application'
if (hasAction(spec, ['search']) || spec.pageContract.tables.some((table) => table.role === 'resultTable')) return 'query'
if (apiMatches.some((api) => ['PUT', 'DELETE'].includes(api.method))) return 'maintenance'
if (apiMatches.some((api) => api.method === 'POST')) return 'application'
return route.kind === 'feature-page' ? 'query' : 'reference'
}
function inferCapabilities(spec, apiMatches) {
const capabilities = new Set()
for (const action of spec.pageContract.actions) {
if (action.actionType && action.actionType !== 'custom') capabilities.add(action.actionType)
}
for (const api of apiMatches) {
if (api.method === 'GET' && /print-view|print/i.test(api.path)) capabilities.add('print')
else if (api.method === 'GET') capabilities.add('search')
else if (api.method === 'POST' && /availability/i.test(api.path)) capabilities.add('availabilityCheck')
else if (api.method === 'POST') capabilities.add('create')
else if (api.method === 'PUT' || api.method === 'PATCH') capabilities.add('edit')
else if (api.method === 'DELETE') capabilities.add('delete')
}
if (isChooserPage(spec)) {
for (const capability of [...capabilities]) {
if (!['back', 'select', 'lookup', 'view'].includes(capability)) capabilities.delete(capability)
}
}
if (isPrintPage(spec)) {
for (const capability of [...capabilities]) {
if (!['back', 'print'].includes(capability)) capabilities.delete(capability)
}
}
return [...capabilities]
}
function inferDataModel(route, spec, apiMatches) {
const primaryEntity = inferPrimaryEntity(route, apiMatches)
const detailEntities = []
const fieldNames = spec.pageContract.forms.flatMap((form) => form.fields.map((field) => field.name ?? field.label)).filter(Boolean)
const collectionFields = fieldNames.filter((name) => /s$|list|items|details|明細|志願|節次|設備|場地/i.test(name))
for (const field of collectionFields) {
detailEntities.push({ name: entityName(field), source: 'field' })
}
return {
primaryEntity,
detailEntities: dedupeByName(detailEntities),
fields: fieldNames,
searchFields: spec.pageContract.forms.flatMap((form) => form.fields).filter((field) => field.sourceTable && spec.pageContract.tables.find((table) => table.id === field.sourceTable)?.role === 'searchTable'),
formFields: spec.pageContract.forms.flatMap((form) => form.fields).filter((field) => !field.sourceTable || spec.pageContract.tables.find((table) => table.id === field.sourceTable)?.role !== 'searchTable'),
tableColumns: spec.pageContract.tables.flatMap((table) => table.headers.map((header) => ({ tableId: table.id, role: table.role, label: header }))),
detailCollections: dedupeByName(detailEntities)
}
}
function inferPrimaryEntity(route, apiMatches) {
const targetView = /\.vue$/i.test(route.guide?.targetView ?? '')
? route.guide.targetView.replace(/View\.vue$/i, '').replace(/\.vue$/i, '')
: null
if (targetView) return targetView
const strongest = apiMatches[0]
if (strongest) {
const parts = strongest.path.split('/').filter((part) => part && !part.startsWith('{') && !part.startsWith('_'))
return entityName(parts.at(-1) ?? parts.at(-2) ?? 'Record')
}
return entityName((route.page ?? route.prototype ?? 'Record').replace(/\.html$/i, ''))
}
function recommendTemplate(pageKind, spec, capabilities, dataModel) {
if (!['maintenance', 'application'].includes(pageKind)) {
return { name: 'none', confidence: 0.9, reasons: [`pageKind=${pageKind} 不套維護頁 CRUD 範本`] }
}
if (hasEditableResultTable(spec)) {
return { name: 'editable-grid', confidence: 0.75, reasons: ['結果表格內含可編輯欄位'] }
}
if (dataModel.detailEntities.length > 1 || hasGroupedDetails(spec)) {
return { name: 'master-detail-b', confidence: 0.65, reasons: ['偵測到多組明細集合或群組結構'] }
}
if (dataModel.detailEntities.length === 1 || hasDetailEvidence(spec)) {
return { name: 'master-detail-c', confidence: 0.7, reasons: ['主檔表單搭配簡單明細集合'] }
}
if (capabilities.includes('edit') || capabilities.includes('delete') || capabilities.includes('create')) {
return { name: 'single-record', confidence: 0.7, reasons: ['具備查詢列表與單筆 CRUD 操作'] }
}
return { name: 'none', confidence: 0.5, reasons: ['沒有足夠維護頁操作證據'] }
}
function buildWarnings(pageKind, recommendedTemplate, spec, dataModel, apiMatches, rowActions = []) {
const warnings = []
if (pageKind === 'maintenance' && !hasAction(spec, ['search']) && spec.pageContract.tables.length === 0) {
warnings.push('maintenance 頁缺少查詢 action 或表格 evidence')
}
if (recommendedTemplate !== 'none' && !dataModel.primaryEntity) {
warnings.push('建議維護頁範本但缺少 primaryEntity')
}
if (hasAction(spec, ['edit', 'delete']) && apiMatches.length === 0) {
warnings.push('有 edit/delete action 但沒有匹配 API endpoint')
}
for (const action of rowActions) {
if (action.enabledWhen === null && action.disabled) warnings.push(`row action ${action.label} 有狀態限制但未產出 enabledWhen`)
}
return warnings
}
function inferRowActions(spec) {
const guideText = [
...(spec.prototypeGuide?.checklist ?? []),
contractText(spec)
].join(' ')
return spec.pageContract.actions
.filter((action) => action.scope === 'rowAction')
.map((action) => ({
label: action.label,
actionType: action.actionType,
disabled: action.disabled,
enabledWhen: inferEnabledWhen(action, guideText)
}))
}
function inferEnabledWhen(action, text) {
const normalized = normalizeConditionText(`${action.label} ${text}`)
const aprv = normalized.match(/aprvYn\s*(?:===|==|=|為|是)\s*['"]?([A-Z0-9])['"]?/i)
if (aprv) return `aprvYn === '${aprv[1]}'`
const aprvNotDisabled = normalized.match(/aprvYn\s*(?:!==|!=|≠|不等於)\s*['"]?([A-Z0-9])['"]?[^。;\n]*(?:disabled|停用|不可|不能|隱藏|不顯示)/i)
if (aprvNotDisabled && ['edit', 'delete'].includes(action.actionType)) return `aprvYn === '${aprvNotDisabled[1]}'`
const quoted = text.match(/`([^`]+)`\s*才(?:能|可)/)
if (quoted) return quoted[1]
return null
}
function inferBusinessRules(spec) {
const sourceTexts = [
...(spec.prototypeGuide?.checklist ?? []),
...spec.pageContract.tables.flatMap((table) => table.sampleRows.flat())
].filter(Boolean)
const rules = {
dataSources: [],
validationRules: [],
collectionLimits: [],
statusRules: []
}
for (const text of sourceTexts) {
collectDataSourceRules(rules, text)
collectValidationRules(rules, text)
collectCollectionLimitRules(rules, text)
collectStatusRules(rules, text)
}
return {
dataSources: dedupeRules(rules.dataSources),
validationRules: dedupeRules(rules.validationRules),
collectionLimits: dedupeRules(rules.collectionLimits),
statusRules: dedupeRules(rules.statusRules)
}
}
function collectDataSourceRules(rules, text) {
const value = String(text)
for (const match of value.matchAll(/從\s*([^;,。)]+?)\s*帶入/g)) {
rules.dataSources.push({ ruleType: 'initial-state-source', source: match[1].trim(), text: value })
}
for (const match of value.matchAll(/依\s*([A-Za-z0-9_]+)\s*過濾/g)) {
rules.dataSources.push({ ruleType: 'lookup-filter', field: match[1], text: value })
}
for (const match of value.matchAll(/([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+)\s*=\s*['"]?([^'";;,。)\s]+)['"]?/g)) {
rules.dataSources.push({ ruleType: 'source-filter', field: match[1], value: match[2], text: value })
}
if (/不填用今天|預設.*今天|今天/.test(value)) rules.dataSources.push({ ruleType: 'default-today', text: value })
}
function collectValidationRules(rules, text) {
const value = String(text)
if (/不可空白|不可空|必填|required/i.test(value)) rules.validationRules.push({ ruleType: 'required', text: value })
if (/IsNumeric|數字|number/i.test(value)) rules.validationRules.push({ ruleType: 'numeric', text: value })
if (/email|Email|EMAIL|含\s*@|@/.test(value)) rules.validationRules.push({ ruleType: 'email', text: value })
if (/大於\s*0|>\s*0/.test(value)) rules.validationRules.push({ ruleType: 'positive-number', text: value })
if (/不可重複|不得重複|不能重複/.test(value)) rules.validationRules.push({ ruleType: 'unique', text: value })
}
function collectCollectionLimitRules(rules, text) {
const value = String(text)
for (const match of value.matchAll(/最多\s*(\d+)\s*(項|組|筆|個|行|列)/g)) {
rules.collectionLimits.push({ ruleType: 'max-count', max: Number(match[1]), unit: match[2], text: value })
}
for (const match of value.matchAll(/(\d+)\s*個\s*(checkbox|欄位|input|select)/gi)) {
rules.collectionLimits.push({ ruleType: 'fixed-count', count: Number(match[1]), unit: match[2], text: value })
}
}
function collectStatusRules(rules, text) {
const value = normalizeConditionText(String(text))
const aprvNotDisabled = value.match(/aprvYn\s*(?:!==|!=|≠|不等於)\s*['"]?([A-Z0-9])['"]?[^。;\n]*(?:disabled|停用|不可|不能|隱藏|不顯示)/i)
if (aprvNotDisabled) rules.statusRules.push({ ruleType: 'disabled-when-not-status', field: 'aprvYn', value: aprvNotDisabled[1], text })
const aprvEnabled = value.match(/aprvYn\s*(?:===|==|=|為|是)\s*['"]?([A-Z0-9])['"]?[^。;\n]*(?:才(?:能|可)|enabled|可|能)/i)
if (aprvEnabled) rules.statusRules.push({ ruleType: 'enabled-when-status', field: 'aprvYn', value: aprvEnabled[1], text })
}
function normalizeConditionText(text) {
return String(text)
.replace(/aprv_yn|aprv-yn/gi, 'aprvYn')
.replace(/\baprv\b/gi, 'aprvYn')
.replace(/['‘’]/g, "'")
.replace(/[]/g, '=')
.replace(/[!]\s*=/g, '!=')
}
function dedupeRules(rules) {
const seen = new Set()
return rules.filter((rule) => {
const key = JSON.stringify(rule)
if (seen.has(key)) return false
seen.add(key)
return true
})
}
function hasEditableResultTable(spec) {
return spec.pageContract.tables.some((table) => table.role === 'resultTable' && table.sampleRows.flat().some((cell) => /<input|<select|<textarea/i.test(cell)))
}
function hasGroupedDetails(spec) {
const text = contractText(spec)
return /(群組|accordion|collapse|多組|階層)/i.test(text)
}
function hasDetailEvidence(spec) {
return spec.pageContract.tables.some((table) => table.role === 'detailTable') ||
spec.pageContract.forms.some((form) => form.fields.some((field) => /明細|志願|節次|設備|場地|items|details/i.test(`${field.name ?? ''} ${field.label ?? ''}`)))
}
function hasAction(spec, actionTypes) {
return spec.pageContract.actions.some((action) => actionTypes.includes(action.actionType))
}
function isChooserPage(spec) {
return /choose|index/i.test(spec.page) && /(挑選|選擇|導覽|返回雛型導覽)/.test(contractText(spec))
}
function isPrintPage(spec) {
if (/print|prt/i.test(spec.page)) return true
return /(申請表|列印頁)/.test(contractText(spec)) && !hasAction(spec, ['search', 'edit', 'delete', 'save'])
}
function contractText(spec) {
return [
spec.pageContract.title,
...spec.pageContract.textSamples,
...spec.pageContract.actions.map((action) => action.label),
...spec.pageContract.tables.flatMap((table) => [...table.headers, ...table.sampleRows.flat()])
].filter(Boolean).join(' ')
}
function entityName(value) {
const parts = String(value)
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/[^a-z0-9\u4e00-\u9fff]+/gi, ' ')
.trim()
.split(/\s+/)
.filter(Boolean)
if (parts.length === 0) return 'Record'
return parts.map((part) => part[0].toUpperCase() + part.slice(1)).join('')
}
function dedupeByName(items) {
const seen = new Set()
return items.filter((item) => {
if (seen.has(item.name)) return false
seen.add(item.name)
return true
})
}