diff --git a/README.md b/README.md
index e4d47f7..b7f3ba8 100644
--- a/README.md
+++ b/README.md
@@ -169,9 +169,10 @@ node packages/html-transform/src/cli.js scan
- 建立頁面級 `pageContract`。
- 驗證 contract 與 DOM evidence 是否明顯衝突。
- 讀取 `prototype/*.md` 的對照表與 checklist,擷取 prototype 檔、舊 JSP、舊 PB、對應 Vue view、功能描述與人工 checklist。
+- 讀取 `prototype/*.md` 的 legacy flow code block,產出可對照每個 prototype 的 `flowRefs`,避免 prompt 需要硬編特定 domain 的 JSP 流程知識。
- 讀取 backend API markdown docs,建立 endpoint catalog、schema 摘要、欄位規則與錯誤格式。
- 依 prototype evidence、guide 與 API catalog 建立 `apiContract`。
-- 依頁面 evidence 建立 `maintenanceContract`,推論頁面型態、操作能力與建議維護頁範本。
+- 依頁面 evidence 建立 `maintenanceContract`,推論頁面型態、操作能力、row action 啟用條件與 checklist 衍生的 business rules。
- 產出 `.ht/app-map.json`,供通用 prompt 判斷 auth、legacy shell、feature page、layout 策略、API 對應與舊系統對照。
## 產物
@@ -207,7 +208,8 @@ node packages/html-transform/src/cli.js scan
- `pageContract`:頁面文字、欄位、表格、actions、layout evidence 與 Vuetify checklist。
- `apiContract`:與該頁匹配的 API endpoints、用途分類、rejected candidates 與錯誤格式。
- `prototypeGuide`:該 prototype 對應的人工 guide、舊 JSP/PB、target view 與 checklist。
-- `maintenanceContract`:頁面型態、capabilities、recommended template、data model 與 warnings。
+- `prototypeGuide.flowRefs`:從 guide 的 legacy flow code block 比對出的相關流程節點,包含 menu、task、nodeType、JSP、PB、動作與原始流程行。
+- `maintenanceContract`:頁面型態、capabilities、data model、row actions、business rules 與 warnings。
- `regions`:目前仍保留,目的是相容既有資料形狀;不要把它視為 Stage 2 decomposition。
`.ht/app-map.json` 是跨頁面的應用結構推論。通用 prompt 應先讀它,再決定每個 prototype 是 `auth`、`legacy-shell-reference`、`feature-page` 或其他角色。MVP 固定策略是 template layout/style 優先,prototype 只提供內容與功能證據。
@@ -219,7 +221,6 @@ route 也會包含 implementation hints,例如:
"prototype": "venue/applications-list.html",
"kind": "feature-page",
"pageKind": "maintenance",
- "recommendedTemplate": "single-record",
"primaryEntity": "ApplicationsList",
"capabilities": ["back", "search", "edit", "delete", "print"],
"apiCount": 8
@@ -241,8 +242,23 @@ route 也會包含 implementation hints,例如:
},
"evidence": {
"prototypeGuide": "venue.md",
- "apiCount": 2,
- "recommendedTemplate": "none"
+ "apiCount": 2
+ },
+ "guide": {
+ "flowRefs": [
+ {
+ "tasks": ["場地查詢"],
+ "matchedKeys": ["zte451_02.jsp", "zte451_02_1.jsp"],
+ "nodes": [
+ {
+ "task": "場地查詢",
+ "nodeType": "query",
+ "jsp": ["zte451_02.jsp"],
+ "pb": ["of_zte451_02"]
+ }
+ ]
+ }
+ ]
}
}
```
@@ -263,8 +279,8 @@ node packages/html-transform/src/cli.js scan
- 新增可選輸入:`backendDocs`,預設 `./apps/backend`。
- 新增輸出:`.ht/api-catalog.json`。
- `.ht/spec/{page}.spec.json` 新增 `apiContract`、`prototypeGuide`、`maintenanceContract`。
-- `.ht/app-map.json` route 新增 `pageKind`、`recommendedTemplate`、`capabilities`、`primaryEntity`、`apiCount`。
-- `.ui-contract.md` 會顯示 page kind、recommended template、prototype checklist 與 API endpoints。
+- `.ht/app-map.json` route 新增 `pageKind`、`capabilities`、`primaryEntity`、`apiCount`。
+- `.ui-contract.md` 會顯示 page kind、capabilities、prototype checklist 與 API endpoints。
因此既有 `doctor` / `scan` 指令不用改;但使用 `.ht` 產物的 prompt 或下游工具,應改讀新增欄位。
diff --git a/src/lib/api-docs.js b/src/lib/api-docs.js
index a2b3491..afecc43 100644
--- a/src/lib/api-docs.js
+++ b/src/lib/api-docs.js
@@ -113,7 +113,7 @@ export function matchApiEndpointCandidates(route, catalog) {
const candidates = catalog.endpoints
.map((endpoint) => ({ endpoint, score: scoreEndpoint(tokens, endpoint) }))
.sort((a, b) => b.score - a.score || a.endpoint.path.localeCompare(b.endpoint.path))
- const accepted = candidates.filter((item) => item.score > 0).slice(0, 8)
+ const accepted = candidates.filter((item) => acceptsEndpoint(route, item)).slice(0, 8)
return {
matches: accepted.map((item) => endpointMatch(item.endpoint, item.score)),
rejected: candidates
@@ -130,6 +130,18 @@ export function matchApiEndpointCandidates(route, catalog) {
}
}
+function acceptsEndpoint(route, item) {
+ if (item.score <= 0) return false
+ const usage = inferEndpointUsage(item.endpoint)
+ if (route.kind === 'prototype-navigation') return false
+ if (route.targetRole === 'login') return ['create', 'lookup'].includes(usage) && /auth|menu|privacy-consent|permission\/password/i.test(item.endpoint.path)
+ if (route.targetRole === 'forgot-password') return /forget|password/i.test(item.endpoint.path)
+ if (route.targetRole === 'privacy-consent') return /privacy-consent/i.test(item.endpoint.path)
+ if (/choose|index/i.test(route.prototype ?? route.page ?? '')) return ['lookup'].includes(usage)
+ if (/print|prt/i.test(route.prototype ?? route.page ?? '')) return usage === 'print'
+ return true
+}
+
function endpointMatch(endpoint, score) {
return {
id: endpoint.id,
@@ -146,7 +158,7 @@ function endpointMatch(endpoint, score) {
function inferEndpointUsage(endpoint) {
const path = endpoint.path.toLowerCase()
const text = `${endpoint.description ?? ''} ${path}`.toLowerCase()
- if (endpoint.method === 'GET' && /lookup|options|choose|list|代碼|選單/.test(text)) return 'lookup'
+ if (endpoint.method === 'GET' && /lookup|options|choose|list|代碼|選單|清單/.test(text)) return 'lookup'
if (endpoint.method === 'GET' && /print|列印/.test(text)) return 'print'
if (endpoint.method === 'GET' && /\{[^}]+\}|detail|明細/.test(path)) return 'detail'
if (endpoint.method === 'GET') return 'search'
diff --git a/src/lib/html.js b/src/lib/html.js
index 5a288ed..f654c96 100644
--- a/src/lib/html.js
+++ b/src/lib/html.js
@@ -216,7 +216,8 @@ export function buildAppMapWithGuides(layoutSpecs, prototypeDir, prototypeGuides
legacyPb: guideEntry.legacyPb,
targetView: guideEntry.targetView,
description: guideEntry.description,
- checklist: guideEntry.checklist ?? []
+ checklist: guideEntry.checklist ?? [],
+ flowRefs: guideEntry.flowRefs ?? []
} : null,
evidence: {
uiContract: `.ht/spec/${relativeSource.replace(/\.html$/, '.ui-contract.md')}`,
@@ -242,8 +243,15 @@ export function buildAppMapWithGuides(layoutSpecs, prototypeDir, prototypeGuides
guideSources: prototypeGuides.map((guide) => ({
source: guide.source,
title: guide.title,
- entryCount: guide.entries.length
+ entryCount: guide.entries.length,
+ legacyFlowCount: guide.legacyFlows.length
})),
+ legacyFlows: prototypeGuides.flatMap((guide) => guide.legacyFlows.map((flow) => ({
+ source: guide.source,
+ title: flow.title,
+ nodeCount: flow.nodes.length,
+ tasks: unique(flow.nodes.map((node) => node.task).filter(Boolean))
+ }))),
modules: buildModules(routes),
routes
}
@@ -277,16 +285,137 @@ export function parsePrototypeGuide(source, markdown) {
})
}
const checklistIndex = parseChecklistIndex(markdown)
+ const legacyFlows = parseLegacyFlows(markdown)
for (const entry of entries) {
entry.checklist = checklistIndex.get(entry.prototype) ?? checklistIndex.get(entry.prototype.split('/').at(-1)) ?? []
+ entry.flowRefs = matchLegacyFlowRefs(entry, legacyFlows)
}
return {
source,
title: title ? cleanMarkdownCell(title) : null,
+ legacyFlows,
entries
}
}
+function parseLegacyFlows(markdown) {
+ const headingMatches = [...markdown.matchAll(/^\s*#{1,6}\s+(.+)$/gm)]
+ const flows = []
+ for (const block of markdown.matchAll(/```(?:text)?\s*\n([\s\S]*?)```/g)) {
+ const body = block[1].trim()
+ if (!/[A-Za-z0-9_./-]+\.jsp\b|\bof_[A-Za-z0-9_]+/.test(body)) continue
+ const title = nearestHeading(headingMatches, block.index)
+ const nodes = parseLegacyFlowNodes(body)
+ if (nodes.length === 0) continue
+ flows.push({
+ title,
+ text: body,
+ nodes
+ })
+ }
+ return flows
+}
+
+function nearestHeading(headingMatches, index) {
+ let title = null
+ for (const heading of headingMatches) {
+ if (heading.index > index) break
+ title = cleanMarkdownCell(heading[1])
+ }
+ return title
+}
+
+function parseLegacyFlowNodes(flowText) {
+ const nodes = []
+ let currentMenu = null
+ let currentTask = null
+ for (const rawLine of flowText.split('\n')) {
+ const line = rawLine.trim()
+ if (!line) continue
+ const menu = line.match(/\[使用者\].*?(?:menu|選單)\s+([A-Za-z0-9_-]+)/i)
+ if (menu) currentMenu = menu[1]
+ const task = line.match(/(?:[①②③④⑤⑥⑦⑧⑨⑩]|\b\d+[.、)]?)\s*([^→\n]+?)\s*→/)
+ if (task) currentTask = cleanFlowSegment(task[1])
+ if (!/[A-Za-z0-9_./-]+\.jsp\b|\bof_[A-Za-z0-9_]+/.test(line)) continue
+ const actions = [...line.matchAll(/「([^」]+)」/g)].map((item) => cleanFlowSegment(item[1])).filter(Boolean)
+ const parts = line.split('→').map(cleanFlowSegment).filter(Boolean)
+ for (const part of parts) {
+ const jsps = extractJspTokens(part)
+ const pbs = extractPbTokens(part)
+ if (jsps.length === 0 && pbs.length === 0) continue
+ nodes.push({
+ menu: currentMenu,
+ task: currentTask,
+ nodeType: inferLegacyNodeType(`${part} ${currentTask ?? ''} ${line}`),
+ segment: part,
+ jsp: jsps,
+ pb: pbs,
+ actions,
+ notes: extractParentheticalNotes(part),
+ line
+ })
+ }
+ }
+ return nodes
+}
+
+function matchLegacyFlowRefs(entry, legacyFlows) {
+ const entryKeys = extractLegacyKeys(`${entry.legacyJsp ?? ''} ${entry.legacyPb ?? ''}`)
+ if (entryKeys.length === 0) return []
+ return legacyFlows.flatMap((flow) => {
+ const matchingNodes = flow.nodes.filter((node) => {
+ const nodeKeys = extractLegacyKeys(`${node.jsp.join(' ')} ${node.pb.join(' ')}`)
+ return nodeKeys.some((key) => entryKeys.includes(key))
+ })
+ if (matchingNodes.length === 0) return []
+ const tasks = unique(matchingNodes.map((node) => node.task).filter(Boolean))
+ const nodes = tasks.length > 0
+ ? flow.nodes.filter((node) => tasks.includes(node.task))
+ : matchingNodes
+ return [{
+ title: flow.title,
+ tasks,
+ matchedKeys: unique(matchingNodes.flatMap((node) => extractLegacyKeys(`${node.jsp.join(' ')} ${node.pb.join(' ')}`)).filter((key) => entryKeys.includes(key))),
+ nodes
+ }]
+ })
+}
+
+function extractLegacyKeys(text) {
+ return unique([
+ ...extractJspTokens(text).map((token) => token.split(/[?#]/)[0].split('/').at(-1)),
+ ...extractPbTokens(text).map((token) => token.replace(/\([^)]*\)/g, ''))
+ ].filter(Boolean))
+}
+
+function extractJspTokens(text) {
+ return [...String(text).matchAll(/[A-Za-z0-9_./-]+\.jsp(?:\?[^)\s]+)?/gi)].map((item) => item[0])
+}
+
+function extractPbTokens(text) {
+ return [...String(text).matchAll(/\bof_[A-Za-z0-9_]+(?:\([^)]*\))?/g)].map((item) => item[0])
+}
+
+function extractParentheticalNotes(text) {
+ return [...String(text).matchAll(/[((]([^()()]+)[))]/g)].map((item) => cleanFlowSegment(item[1])).filter(Boolean)
+}
+
+function inferLegacyNodeType(text) {
+ if (/choose/i.test(text) || /分派|挑選|選擇/.test(text)) return 'dispatch'
+ if (/_00\.jsp/i.test(text) || /前置/.test(text)) return 'precondition'
+ if (/(ins|insert|save)\w*\.jsp/i.test(text) || /寫入|存檔|新增/.test(text)) return 'create'
+ if (/(upd|update|med|modify)\w*\.jsp/i.test(text) || /修改|舊值/.test(text)) return 'edit'
+ if (/(del|delete)\w*\.jsp/i.test(text) || /刪除/.test(text)) return 'delete'
+ if (/(prt|print)\w*\.jsp/i.test(text) || /列印/.test(text)) return 'print'
+ if (/(qry|query)\w*\.jsp/i.test(text) || /查詢|列表|結果/.test(text)) return 'query'
+ if (/回 HTML 表單|表單/.test(text)) return 'form'
+ return 'legacy-node'
+}
+
+function cleanFlowSegment(value) {
+ return cleanMarkdownCell(String(value ?? '').replace(/^[①②③④⑤⑥⑦⑧⑨⑩\d.、)\s]+/, ''))
+}
+
function parseChecklistIndex(markdown) {
const index = new Map()
const sections = [...markdown.matchAll(/^\s*#{2,4}\s+(.+)$/gm)]
@@ -574,9 +703,9 @@ function extractRowFields(rowHtml) {
for (const cell of cells) {
const cellHtml = cell[2]
const ownLabel = normalizeCellLabel(cleanText(stripControls(cellHtml)).replace(/[::]$/, ''))
- const cellFields = extractFieldsFromFragment(cellHtml, ownLabel || lastLabel)
+ const cellFields = extractFieldsFromFragment(cellHtml, ownLabel && hasControls(cellHtml) ? ownLabel : lastLabel)
fields.push(...cellFields)
- if (ownLabel) lastLabel = ownLabel
+ if (ownLabel && !hasControls(cellHtml)) lastLabel = ownLabel
}
return fields
}
@@ -605,27 +734,43 @@ function extractFieldsFromFragment(html, rowLabel) {
}
function extractActionElements(html) {
+ const actions = []
+ for (const table of collect(html, /
]*>([\s\S]*?)<\/table>/gi)) {
+ const headers = collect(table[1], /| ]*>([\s\S]*?)<\/th>/gi).map((header) => cleanText(header[1])).filter(Boolean)
+ const rows = collect(table[1], / | ]*>([\s\S]*?)<\/tr>/gi)
+ if (inferTableRole(table[1], headers, []) !== 'resultTable') continue
+ for (const row of rows.slice(1)) actions.push(...extractActionElementsFromFragment(row[1], 'rowAction'))
+ }
+ const withoutResultRows = html.replace(/]*>([\s\S]*?)<\/table>/gi, (match, body) => {
+ const headers = collect(body, /]*>([\s\S]*?)<\/th>/gi).map((header) => cleanText(header[1])).filter(Boolean)
+ return inferTableRole(body, headers, []) === 'resultTable' ? ' ' : match
+ })
+ actions.push(...extractActionElementsFromFragment(withoutResultRows, null))
+ return dedupeActions(actions)
+}
+
+function extractActionElementsFromFragment(html, scopeOverride) {
const actions = []
for (const item of collect(html, /<(button|a)\b([^>]*)>([\s\S]*?)<\/\1>/gi)) {
const label = cleanText(item[3]) || attr(item[2], 'aria-label') || attr(item[2], 'title')
- if (label) actions.push(actionContract(label, item[1], item[2]))
+ if (label) actions.push(actionContract(label, item[1], item[2], scopeOverride))
}
for (const item of collect(html, /]*)>/gi)) {
const type = attr(item[1], 'type')
if (!['button', 'submit', 'reset', 'image'].includes(type ?? '')) continue
const label = attr(item[1], 'value') || attr(item[1], 'aria-label') || attr(item[1], 'title')
- if (label) actions.push(actionContract(label, 'input', item[1]))
+ if (label) actions.push(actionContract(label, 'input', item[1], scopeOverride))
}
- return dedupeActions(actions)
+ return actions
}
-function actionContract(label, tag, attrs) {
+function actionContract(label, tag, attrs, scopeOverride = null) {
const actionType = inferActionType(label)
return {
label,
actionType,
kind: actionType === 'search' || actionType === 'save' || actionType === 'create' ? 'primary' : 'secondary',
- scope: inferActionScope(label, attrs),
+ scope: scopeOverride ?? inferActionScope(label, attrs),
tag,
disabled: /\bdisabled\b/i.test(attrs)
}
@@ -647,7 +792,6 @@ function inferActionType(label) {
}
function inferActionScope(label, attrs) {
- if (/(修改|刪除|列印|檢視|edit|delete|print|view)/i.test(label)) return 'rowAction'
if (/onclick\s*=/i.test(attrs) || /(存檔|儲存|送出|查詢|清除|reset|save|submit|search)/i.test(label)) return 'formAction'
return 'pageAction'
}
@@ -681,6 +825,10 @@ function stripControls(html) {
.replace(/]*>/gi, ' ')
}
+function hasControls(html) {
+ return /<(input|select|textarea)\b/i.test(html)
+}
+
function normalizeCellLabel(label) {
if (!label || /^[~~\-–—::|/\\]+$/.test(label)) return null
return label
@@ -694,13 +842,13 @@ function labelForControl(fragment, attrs) {
}
function dedupeActions(actions) {
- const seen = new Set()
- return actions.filter((action) => {
- const key = `${action.label}:${action.scope}:${action.actionType}`
- if (seen.has(key)) return false
- seen.add(key)
- return true
- })
+ const byKey = new Map()
+ for (const action of actions) {
+ const key = `${action.label}:${action.actionType}`
+ const current = byKey.get(key)
+ if (!current || action.scope === 'rowAction' || (current.scope !== 'rowAction' && action.kind === 'primary')) byKey.set(key, action)
+ }
+ return [...byKey.values()]
}
function inferPageComponents(html, regions) {
diff --git a/src/lib/maintenance.js b/src/lib/maintenance.js
index 8ef1bdb..ab03513 100644
--- a/src/lib/maintenance.js
+++ b/src/lib/maintenance.js
@@ -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) => / 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,
diff --git a/src/stages/scan.js b/src/stages/scan.js
index 437d681..9facb45 100644
--- a/src/stages/scan.js
+++ b/src/stages/scan.js
@@ -97,18 +97,17 @@ function enrichSpecsWithRouteContracts(layoutSpecs, appMap, apiCatalog, prototyp
legacyPb: route.guide.legacyPb,
targetView: route.guide.targetView,
description: route.guide.description,
- checklist: route.guide.checklist ?? []
+ checklist: route.guide.checklist ?? [],
+ flowRefs: route.guide.flowRefs ?? []
} : null
spec.maintenanceContract = buildMaintenanceContract({ route, spec, apiMatches })
route.apiCount = apiMatches.length
route.primaryEntity = spec.maintenanceContract.dataModel.primaryEntity
route.capabilities = spec.maintenanceContract.capabilities
route.pageKind = spec.maintenanceContract.pageKind
- route.recommendedTemplate = spec.maintenanceContract.recommendedTemplate
route.evidence.apiCount = apiMatches.length
route.evidence.primaryEntity = route.primaryEntity
route.evidence.capabilities = route.capabilities
- route.evidence.recommendedTemplate = route.recommendedTemplate
}
}
@@ -479,8 +478,6 @@ function renderUiContract(spec) {
lines.push(`- Vuetify: ${contract.vuetifyComponents.join(', ') || 'none'}`)
if (spec.maintenanceContract) {
lines.push(`- Page kind: ${spec.maintenanceContract.pageKind}`)
- lines.push(`- Recommended template: ${spec.maintenanceContract.recommendedTemplate} (${spec.maintenanceContract.confidence})`)
- if (spec.maintenanceContract.recommendedTemplate === 'none') lines.push(`- Template reason: ${spec.maintenanceContract.reasons.join('; ')}`)
lines.push(`- Capabilities: ${spec.maintenanceContract.capabilities.join(', ') || 'none'}`)
lines.push(`- Primary entity: ${spec.maintenanceContract.dataModel.primaryEntity ?? 'none'}`)
}
diff --git a/test/cli-e2e.test.js b/test/cli-e2e.test.js
index 48c6483..c570e63 100644
--- a/test/cli-e2e.test.js
+++ b/test/cli-e2e.test.js
@@ -41,6 +41,7 @@ test('CLI runs doctor and scan against one prototype', async () => {
assert.match(doctor.stdout, /ok prototype directory/)
assert.match(contract, /Customer Portal/)
+ assert.doesNotMatch(contract, /Recommended template/)
assert.equal(spec.pageContract.title, null)
assert.deepEqual(pick(spec.pageContract.forms[0].fields[0], ['name', 'label', 'type', 'required']), {
name: 'email',
@@ -52,6 +53,8 @@ test('CLI runs doctor and scan against one prototype', async () => {
assert.equal(appMap.routes[0].prototype, 'index.html')
assert.equal(appMap.routes[0].kind, 'feature-page')
assert.equal(appMap.routes[0].layout, 'template-app')
+ assert.equal(appMap.routes[0].recommendedTemplate, undefined)
+ assert.equal(appMap.routes[0].evidence.recommendedTemplate, undefined)
assert.equal(appMap.routes[0].guide.legacyJsp, 'legacy/index.jsp')
assert.equal(appMap.routes[0].guide.description, 'Customer portal entry')
assert.equal(appMap.guideSources[0].source, 'portal.md')
diff --git a/test/html.test.js b/test/html.test.js
index bf70024..0cb9131 100644
--- a/test/html.test.js
+++ b/test/html.test.js
@@ -1,6 +1,6 @@
import test from 'node:test'
import assert from 'node:assert/strict'
-import { buildApiCatalog, matchApiEndpoints } from '../src/lib/api-docs.js'
+import { buildApiCatalog, matchApiEndpointCandidates, matchApiEndpoints } from '../src/lib/api-docs.js'
import { buildMaintenanceContract } from '../src/lib/maintenance.js'
import { buildAppMap, buildAppMapWithGuides, buildPageContract, extractRegions, inferRegionSpec, parsePrototypeGuide, summarizeHtml, validatePageContract } from '../src/lib/html.js'
@@ -91,6 +91,28 @@ test('buildPageContract infers legacy table field labels and action semantics',
assert.equal(contract.tables[1].role, 'resultTable')
})
+test('buildPageContract keeps page actions out of row actions', () => {
+ const html = `
+
+
+
+
+
+ `
+ const contract = buildPageContract({
+ page: 'applications-list.html',
+ source: '/prototype/applications-list.html',
+ html,
+ regions: extractRegions(html),
+ domSummary: summarizeHtml(html),
+ screenshotPath: null
+ })
+
+ assert.equal(contract.actions.find((action) => action.label === '修改密碼').scope, 'formAction')
+ assert.equal(contract.actions.find((action) => action.label === '列印').scope, 'formAction')
+ assert.equal(contract.actions.find((action) => action.label === '刪除').scope, 'rowAction')
+})
+
test('validatePageContract reports evidence mismatches', () => {
const report = validatePageContract({
textSamples: ['Missing'],
@@ -138,6 +160,16 @@ test('buildAppMap enriches routes with prototype markdown guide entries', () =>
| 雛型檔 | 舊 JSP 來源 | 舊 PB NVO 來源 | 對應 Vue view(M9) |
| --- | --- | --- | --- |
| [\`query-room.html\`](venue/query-room.html) | \`zte_pro/zte451_02.jsp\` + \`zte451_02_1.jsp\` | \`n_zte451.of_zte451_02\` / \`of_zte451_02_1\` | \`RoomQueryView.vue\` |
+
+ ## 舊系統整體流程
+
+ \`\`\`text
+ [使用者] 點 menu ZTE451
+ → zte451_choose.jsp
+ ① 場地查詢 → zte451_02.jsp → of_zte451_02 (場地下拉 + 日期)
+ ↓ 「查詢」
+ zte451_02_1.jsp → of_zte451_02_1 (回 HTML:每日可借節次)
+ \`\`\`
`)
const appMap = buildAppMapWithGuides([
buildSpec('/repo/prototype/venue/query-room.html', '全校場地查詢')
@@ -149,6 +181,11 @@ test('buildAppMap enriches routes with prototype markdown guide entries', () =>
assert.equal(route.guide.legacyJsp, 'zte_pro/zte451_02.jsp + zte451_02_1.jsp')
assert.equal(route.guide.legacyPb, 'n_zte451.of_zte451_02 / of_zte451_02_1')
assert.equal(route.guide.targetView, 'RoomQueryView.vue')
+ assert.equal(appMap.guideSources[0].legacyFlowCount, 1)
+ assert.equal(appMap.legacyFlows[0].tasks[0], '場地查詢')
+ assert.equal(route.guide.flowRefs[0].tasks[0], '場地查詢')
+ assert.deepEqual(route.guide.flowRefs[0].matchedKeys, ['zte451_02.jsp', 'of_zte451_02', 'zte451_02_1.jsp', 'of_zte451_02_1'])
+ assert.equal(route.guide.flowRefs[0].nodes[0].nodeType, 'query')
})
test('parsePrototypeGuide attaches checklist items to guide entries', () => {
@@ -168,6 +205,33 @@ test('parsePrototypeGuide attaches checklist items to guide entries', () => {
assert.deepEqual(guide.entries[0].checklist, ['活動名稱必填', '使用節次:14 個 checkbox'])
})
+test('parsePrototypeGuide extracts legacy flow references for matched entries', () => {
+ const guide = parsePrototypeGuide('venue.md', `
+ # Venue
+
+ | 雛型檔 | 舊 JSP 來源 | 舊 PB NVO 來源 |
+ | --- | --- | --- |
+ | [\`apply-room.html\`](venue/apply-room.html) | \`zte_pro/zte450_01.jsp\` + \`zte450_med01.jsp\` | \`n_zte450.of_zte450_01\` / \`of_zte450_med01\` |
+
+ ## 舊系統整體流程
+
+ \`\`\`text
+ [使用者] 點 menu ZTE450
+ → zte450_choose.jsp
+ ① 場地申請 → zte450_00.jsp?ls_stat=1 → of_zte450_00 (回 HTML 表單:身份+日期+stat)
+ ↓ 「我要借用」
+ zte450_01.jsp → of_zte450_01 (回 HTML 表單:5 志願 + 14 節次)
+ ↓ 存檔
+ zte450_ins.jsp → of_zte450_ins → 寫 s55_app_mst + s55_app_dtl
+ \`\`\`
+ `)
+
+ assert.equal(guide.legacyFlows.length, 1)
+ assert.equal(guide.entries[0].flowRefs[0].tasks[0], '場地申請')
+ assert.ok(guide.entries[0].flowRefs[0].nodes.some((node) => node.nodeType === 'precondition'))
+ assert.ok(guide.entries[0].flowRefs[0].nodes.some((node) => node.jsp.includes('zte450_01.jsp')))
+})
+
test('buildApiCatalog parses generic markdown API docs and matches routes', () => {
const catalog = buildApiCatalog([
{
@@ -225,6 +289,36 @@ test('buildApiCatalog parses generic markdown API docs and matches routes', () =
assert.equal(matches[0].path, '/api/v1/orders')
})
+test('matchApiEndpointCandidates narrows chooser and print pages', () => {
+ const catalog = buildApiCatalog([{
+ source: 'apps/backend/API.md',
+ markdown: `
+ | 方法 | 路徑 | 說明 |
+ | --- | --- | --- |
+ | GET | \`/service/api/v1/venue/applications/rooms\` | 查詢我的場地申請 |
+ | POST | \`/service/api/v1/venue/applications/rooms\` | 新增場地借用申請 |
+ | GET | \`/service/api/v1/venue/applications/rooms/{appNo}/print-view\` | 取得場地申請列印資料 |
+ | GET | \`/service/api/v1/venue/rooms\` | 取得場地清單 |
+ `
+ }])
+
+ const chooser = matchApiEndpointCandidates({
+ prototype: 'venue/apply-choose.html',
+ page: 'apply-choose.html',
+ module: 'venue',
+ evidence: { actions: ['場地申請'], textSamples: ['場地設備借用申請'] }
+ }, catalog)
+ const print = matchApiEndpointCandidates({
+ prototype: 'venue/apply-room-print.html',
+ page: 'apply-room-print.html',
+ module: 'venue',
+ evidence: { actions: ['列印'], textSamples: ['場地申請表列印'] }
+ }, catalog)
+
+ assert.deepEqual(chooser.matches.map((endpoint) => endpoint.usage), ['lookup'])
+ assert.deepEqual(print.matches.map((endpoint) => endpoint.usage), ['print'])
+})
+
test('buildMaintenanceContract recommends generic templates from evidence', () => {
const spec = buildSpec('/repo/prototype/orders/apply.html', `
@@ -255,6 +349,66 @@ test('buildMaintenanceContract recommends generic templates from evidence', () =
assert.equal(contract.dataModel.primaryEntity, 'OrderApply')
})
+test('buildMaintenanceContract extracts business rules and row action status rules', () => {
+ const spec = buildSpec('/repo/prototype/venue/applications-list.html', `
+
+
+
+ `)
+ spec.prototypeGuide = {
+ checklist: [
+ "操作按鈕:修改 / 刪除 / 列印(aprv_yn!='Z' 時前兩者 disabled,舊系統 UI 用顯示與否)",
+ '設備明細(最多 15 項):每行設備下拉 + 數量 + 移除',
+ '截止日(asOfDate;不填用今天)',
+ 'JS 驗證:home_phone 不可空白、IsNumeric 檢查、email 含 @'
+ ]
+ }
+ const contract = buildMaintenanceContract({
+ route: {
+ kind: 'feature-page',
+ prototype: 'venue/applications-list.html',
+ page: 'applications-list.html',
+ guide: { targetView: 'ApplicationsListView.vue' }
+ },
+ spec,
+ apiMatches: []
+ })
+
+ assert.equal(contract.rowActions.find((action) => action.actionType === 'edit').enabledWhen, "aprvYn === 'Z'")
+ assert.equal(contract.rowActions.find((action) => action.actionType === 'delete').enabledWhen, "aprvYn === 'Z'")
+ assert.equal(contract.rowActions.find((action) => action.actionType === 'print').enabledWhen, null)
+ assert.equal(contract.businessRules.collectionLimits[0].max, 15)
+ assert.ok(contract.businessRules.dataSources.some((rule) => rule.ruleType === 'default-today'))
+ assert.ok(contract.businessRules.validationRules.some((rule) => rule.ruleType === 'email'))
+})
+
+test('buildMaintenanceContract ignores non-vue guide text for primary entity', () => {
+ const spec = buildSpec('/repo/prototype/venue/apply-room-print.html', `
+
+ 場地申請表列印
+
+
+ `)
+ const contract = buildMaintenanceContract({
+ route: {
+ kind: 'feature-page',
+ prototype: 'venue/apply-room-print.html',
+ page: 'apply-room-print.html',
+ guide: { targetView: 'M9 列印頁尚未實作,列出供下批補做' }
+ },
+ spec,
+ apiMatches: [{ method: 'GET', path: '/service/api/v1/venue/applications/rooms/{appNo}/print-view', description: '取得場地申請列印資料' }]
+ })
+
+ assert.equal(contract.pageKind, 'print')
+ assert.equal(contract.dataModel.primaryEntity, 'PrintView')
+ assert.deepEqual(contract.capabilities, ['print'])
+})
+
function buildSpec(source, html) {
const regions = extractRegions(html)
const domSummary = summarizeHtml(html)
|