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 = ` +
+ + +
申請單號操作
A001
+
+ ` + 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', ` +
+ + + + +
申請單號狀態操作
A001審核中(Z)
A002已核准(Y)aprv≠'Z' 修改/刪除 disabled
+
+ `) + 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)