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.
This commit is contained in:
skytek_xinliang
2026-05-21 16:05:39 +08:00
parent 58a5a525d7
commit c8ff825722
7 changed files with 482 additions and 38 deletions
+120 -6
View File
@@ -3,6 +3,7 @@ export function buildMaintenanceContract({ 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 {
@@ -13,7 +14,8 @@ export function buildMaintenanceContract({ route, spec, apiMatches = [] }) {
reasons: template.reasons,
warnings: buildWarnings(pageKind, template.name, spec, dataModel, apiMatches, rowActions),
dataModel,
rowActions
rowActions,
businessRules
}
}
@@ -46,6 +48,16 @@ function inferCapabilities(spec, apiMatches) {
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]
}
@@ -69,7 +81,9 @@ function inferDataModel(route, spec, apiMatches) {
}
function inferPrimaryEntity(route, apiMatches) {
const targetView = route.guide?.targetView?.replace(/View\.vue$/i, '').replace(/\.vue$/i, '')
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) {
@@ -116,25 +130,116 @@ function buildWarnings(pageKind, recommendedTemplate, spec, dataModel, apiMatche
}
function inferRowActions(spec) {
const guideText = (spec.prototypeGuide?.checklist ?? []).join(' ')
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.label} ${guideText}`)
enabledWhen: inferEnabledWhen(action, guideText)
}))
}
function inferEnabledWhen(text) {
const aprv = text.match(/aprvYn\s*(?:===|==|=|為|是)\s*['"]?([A-Z0-9])['"]?/i)
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)))
}
@@ -153,6 +258,15 @@ 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,