From c8ff8257225056a7ff99f86dd3561afc3188ec39 Mon Sep 17 00:00:00 2001 From: skytek_xinliang Date: Thu, 21 May 2026 16:05:39 +0800 Subject: [PATCH] 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. --- README.md | 30 +++++-- src/lib/api-docs.js | 16 +++- src/lib/html.js | 182 +++++++++++++++++++++++++++++++++++++---- src/lib/maintenance.js | 126 ++++++++++++++++++++++++++-- src/stages/scan.js | 7 +- test/cli-e2e.test.js | 3 + test/html.test.js | 156 ++++++++++++++++++++++++++++++++++- 7 files changed, 482 insertions(+), 38 deletions(-) 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)