commit 81bca6aa80bbec77b7685e04780706fe5d1ce8fd Author: xinliang Date: Sun May 3 09:38:24 2026 +0800 feat: projcet Initialization diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4616d78 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,155 @@ +# AGENTS.md + +## 專案概念 + +HTML Transform 是 prototype-driven orchestration framework。目標是把 HTML prototype 轉成可 review、可驗證、可追溯的 Vuetify frontend output。 + +這個 repo 目前是 MVP,不是完整產品。核心價值先放在 CLI pipeline 與 artifact-first workflow: + +```text +scan -> plan -> run -> diff -> verify +``` + +所有中間產物都寫到 `.ht/`,生成結果寫到 `output` 設定指向的資料夾,預設是 `packages/result`。 + +## 目前 MVP 狀態 + +已完成: + +- `ht` CLI 骨架:`scan`、`plan`、`run`、`diff`、`verify`、`go`、`status`、`doctor` +- `ht.config.js/json/ts` 設定載入 +- deterministic HTML evidence extraction +- `.ht/cache/prototype` +- `.ht/spec/*.spec.json` +- `.ht/spec/*.validation.json` +- `.ht/spec/*.ui-contract.md` +- `.ht/plan/tasklist.json` +- `.ht/plan/project-conventions.md` +- `.ht/plan/api-contract.md` +- `.ht/plan/component-mapping.json` +- `.ht/runs//` +- `.ht/diff/*.diff.html` +- `.ht/verify/verification-report.json` +- 基本 E2E 測試 + +尚未完成,不能假裝已完成: + +- Playwright static server +- 真實 full-page screenshot +- 真實 DOM snapshot API +- 真實 accessibility tree API +- vision-based decomposition/extraction +- Zod schema validation +- 真實 coding agent invocation +- 真實 visual similarity scoring +- 完整 DOM-level 與 flow-level smoke tests + +目前 screenshot 是 placeholder PNG,`diff` score 是 deterministic placeholder。 + +## 套件管理 + +使用 pnpm,不使用 npm 或 yarn。 + +```bash +pnpm install +pnpm test +pnpm typecheck +``` + +不要新增 `package-lock.json` 或 `yarn.lock`。lockfile 應該是 `pnpm-lock.yaml`。 + +## Playwright CLI 決策 + +這個專案使用 `@playwright/cli`,透過 shell command 操作 `playwright-cli`,不使用 MCP。 + +原因: + +- PRD 的 pipeline 是 coding-agent orchestration,CLI 比 MCP 更適合放進可重跑 stage。 +- CLI invocation 較容易寫入 `.ht/runs`、logs、metadata 與 verification report。 +- 專案應避免依賴使用者全域環境。 + +解析順序在 `src/lib/playwright-cli.js`: + +1. `node_modules/.bin/playwright-cli` +2. 全域 `playwright-cli` +3. `npx --no-install playwright-cli` + +`doctor` 必須能顯示 `playwright-cli (local)`、`playwright-cli (global)` 或 missing。 + +專案級 Codex runtime skill 放在 `.codex/skills/playwright-cli`,本機全域也可能有一份在 `~/.codex/skills/playwright-cli`。處理 browser automation、snapshot、screenshot、trace、video、test generation 或 local web app testing 時,Codex 應優先使用專案級 skill;若沒有自動觸發,先讀取 `.codex/skills/playwright-cli/SKILL.md` 再操作。 + +安裝瀏覽器: + +```bash +pnpm playwright-cli:install-browser +``` + +Linux CI 或乾淨容器若需要系統 dependency,先 dry-run: + +```bash +npx --no-install playwright-cli install-browser --with-deps --dry-run +``` + +## 預設資料夾 + +預設 config: + +```text +packages/prototype # HTML prototype +packages/web # 既有 Vuetify frontend +packages/api # backend API/schema +packages/result # output +.ht # tool artifacts +``` + +目前 repo 可能沒有預設輸入資料夾。這時 `doctor` 回報 missing 是正確行為,不是 bug。 + +## 開發原則 + +- 遵守 PRD 的 artifact-first 思路。 +- 不要把 placeholder 包裝成完整實作。 +- 每個 stage 的失敗、缺少環境、skipped 都要明確寫入 report 或 CLI output。 +- 優先做可重跑、可檢查、可追溯的 deterministic behavior。 +- 不要讓 agent 修改 `prototype/`、`frontend/`、`backend/` 原始輸入資料夾;生成與執行 artifact 應限制在 `output/` 與 `.ht/`。 +- 保持 MVP 邊界,避免加泛用抽象或一開始支援所有 monorepo 形態。 + +## 驗證 + +完成任何改動後至少執行: + +```bash +pnpm test +pnpm typecheck +``` + +若改到環境偵測、Playwright CLI、設定載入,也執行: + +```bash +node src/cli.js doctor +``` + +若改到 stage pipeline,應確認 E2E 測試仍通過,或新增/更新 `test/cli-e2e.test.js`。 + +## 重要檔案 + +- `PRD.md`:產品需求來源。 +- `README.md`:使用方式與 MVP 限制。 +- `src/cli.js`:CLI command router。 +- `src/lib/config.js`:config loading。 +- `src/lib/html.js`:deterministic HTML evidence extraction。 +- `src/lib/playwright-cli.js`:Playwright CLI resolution。 +- `src/stages/*.js`:各 stage 實作。 +- `test/cli-e2e.test.js`:完整 CLI pipeline 測試。 + +## 下一個最重要的方向 + +下一步最值得做的是把 Stage 1 做真: + +- local static server +- `playwright-cli` 三 viewport screenshot +- network/resource failure metadata +- DOM snapshot artifact +- accessibility snapshot artifact +- cache hit 時跳過 screenshot + +這完成後,工具才會真正開始觀察 browser-rendered prototype,而不是只解析 HTML source。 diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..5340940 --- /dev/null +++ b/PRD.md @@ -0,0 +1,361 @@ +# Product Requirement Document +## HTML Transform + +| 欄位 | 內容 | +|------|------| +| 版本 | v0.1 | +| 日期 | 2026-05-02 | +| 狀態 | Draft | + +--- + +## 1. 背景與目標 + +HTML Transform 是一個安裝在 pnpm-workspace / monorepo 中的 prototype-driven orchestration framework。它讓工程師指定四個資料夾,由單一 coding agent 依序驅動完整 pipeline,將 HTML prototype 轉換為可運作、可維護的 Vuetify 前端實作,並用 backend API schema 對齊資料流。 + +**四個資料夾:** + +| 識別子 | 說明 | +|--------|------| +| `prototype` | HTML prototype 來源 | +| `frontend` | 已預先建立的 Vuetify 前端專案 | +| `backend` | 已預先建立的 backend API 專案 | +| `output` | 生成結果的寫入目標 | + +**核心設計原則:** pipeline 的每個 stage 都產出可被人工 review 的 artifact,agent 的不確定性由結構化中間產物吸收,而非在最終輸出才暴露。HTML prototype 不是完整規格;rendered browser evidence、DOM/accessibility snapshot、`LayoutSpec`、project conventions、API contract、`TaskList` 才共同構成實作依據。 + +**成功標準:** +- 相同 prototype 執行兩次,`output` 資料夾的結果語意一致 +- Diff report 的平均 similarity score 高於 75% +- `LayoutSpec` 的 `null` 欄位比率低於 30% +- Stage 7 的 quality gates(typecheck、lint、test、route smoke test)全部通過,或明確標示為環境缺失而非實作失敗 +- 每次 run 都能回溯到對應的 screenshot、DOM snapshot、spec、plan、agent raw output 與 verification report + +--- + +## 2. 系統架構 + +``` +ht.config.ts + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Orchestrator │ +│ (讀取 config,依序呼叫各 stage,傳遞中間產物) │ +└──────┬──────────┬──────────┬──────────┬─────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ + [Stage 1] [Stage 2] [Stage 3] [Stage 4] + Capture Decompose Extract Validate + │ │ │ │ + └──────────┴──────────┴──────────┘ + │ + LayoutSpec + │ + ┌────────┴────────┐ + ▼ ▼ + [Stage 5] [Stage 6] + Plan Run + TaskList coding agent + │ + output/ + │ + [Stage 7] + Diff & Verify + reports + quality gates +``` + +**Agent 角色:** 單一 coding agent(由 `ht.config.ts` 中的 `agent` 欄位指定,支援 claude-code、codex、gemini)全程驅動所有 stage,包含 vision 截圖分析、spec 提取、程式碼實作、diff 評分。Orchestrator 負責呼叫順序與中間產物的序列化,agent 負責每個 stage 的實際執行。 + +--- + +## 3. 設定檔 + +```typescript +// ht.config.ts +import { defineConfig } from 'html-transform' + +export default defineConfig({ + prototype: './packages/prototype', + frontend: './packages/web', + backend: './packages/api', + output: './packages/result', + + agent: 'claude-code', // 'claude-code' | 'codex' | 'gemini' + + vision: { + viewport: { width: 1440, height: 900 }, + captureStates: ['default', 'scrolled', 'mobile'], + includeDomSnapshot: true, + includeAccessibilityTree: true, + }, + + project: { + packageManager: 'pnpm', + frontendFramework: 'vue3', + uiLibrary: 'vuetify3', + qualityCommands: { + typecheck: 'pnpm typecheck', + lint: 'pnpm lint', + test: 'pnpm test', + }, + }, + + plan: { + interactiveReview: true, // Stage 5 完成後暫停,等待人工確認 TaskList + requireApiContractMatch: true, + }, + + diff: { + scoreThreshold: 75, // 低於此分數的 region 在 report 中標示警告 + requireDomChecks: true, + requireFlowChecks: true, + }, +}) +``` + +--- + +## 4. Pipeline 詳細需求 + +### Stage 1:Capture + +**目的:** 產出 prototype 的視覺真相,取代 agent 腦補的渲染結果。 + +| 需求 ID | 說明 | +|---------|------| +| F1-1 | 對每個 `.html` 檔案,以 desktop(1440×900)、tablet(768×1024)、mobile(375×812)各截一張全頁截圖 | +| F1-2 | 以本地 static server 提供 prototype 檔案,避免 `file://` 協定的 CORS 問題 | +| F1-3 | 截圖前等待 `networkidle`,再額外等待 500ms,確保 CSS animation 穩定 | +| F1-4 | 以原始 `.html` 檔案的 SHA-256 hash 作為 cache key;hash 未變動時跳過截圖 | +| F1-5 | 截圖儲存至 `.ht/cache/prototype/{filename}/{viewport}-{state}.png` | +| F1-6 | 對每個 viewport/state 產出 DOM summary 與 accessibility tree snapshot,作為 Stage 2–5 的非視覺 evidence | +| F1-7 | 記錄外部資源載入失敗清單(font、image、script、stylesheet),寫入 capture metadata | + +**產出物:** `.ht/cache/prototype/` 下的截圖、DOM snapshot、accessibility snapshot 與 capture metadata + +--- + +### Stage 2:Decompose + +**目的:** 識別頁面的主要區域,為 Stage 3 的局部提取做準備。直接對整頁截圖做一次性提取,精度低且不穩定;分區後再提取可大幅降低錯誤率。 + +| 需求 ID | 說明 | +|---------|------| +| F2-1 | 以 desktop 截圖呼叫 agent(vision 模式),識別頁面主要區域 | +| F2-2 | 回傳 JSON array,每個 item 包含 `id`、`name`、`rough_position`、`element_count_estimate`、`semantic_role` | +| F2-3 | 依據 `rough_position`,用 Playwright `clip` 截取每個 region 的局部截圖 | +| F2-4 | Prompt 明確要求只回 JSON,不附任何說明文字 | +| F2-5 | 使用 Stage 1 的 DOM summary 與 accessibility tree 輔助確認 region 名稱、可互動元素與文字,不只依賴截圖 | + +**產出物:** `PageDecomposition` JSON(含各 region 的局部截圖路徑) + +--- + +### Stage 3:Extract + +**目的:** 從每個 region 截圖提取結構化的 `LayoutSpec`。 + +| 需求 ID | 說明 | +|---------|------| +| F3-1 | 每個 region 獨立呼叫 agent,輸入該 region 的局部截圖 | +| F3-2 | Prompt 以填空題形式提供 `LayoutSpec` JSON 結構,要求 agent 填值而非自由生成 | +| F3-3 | `vuetifyComponent` 必須是 Vuetify 3 的真實 component 名稱(對照白名單驗證) | +| F3-4 | 不確定的欄位填 `null`,Prompt 明確禁止猜測 | +| F3-5 | `colorPalette` 只填截圖中實際出現的色碼(hex 或 rgba) | +| F3-6 | `textSamples` 填截圖中可見的真實文字,最多 3 筆 | +| F3-7 | 對表單、列表、表格、導覽與 action button 產出 `uiContract` 欄位,描述 label、field type、required 狀態、主要/次要 action、可觀察互動 | +| F3-8 | 若 DOM/accessibility evidence 與 vision 判讀衝突,保留 `warnings`,不得任意合併成確定結論 | + +**產出物:** 每個 region 的 `RegionSpec` JSON + +--- + +### Stage 4:Validate + +**目的:** 確保 `LayoutSpec` 符合 schema,自動修復可修復的問題,標記需要人工介入的頁面。 + +| 需求 ID | 說明 | +|---------|------| +| F4-1 | 以 Zod schema 驗證每個 region 的 extract 結果 | +| F4-2 | `vuetifyComponent` 不合法時,查 Vuetify 3 白名單找最相近的名稱並自動修正 | +| F4-3 | 色碼格式不合法時,嘗試 normalize;無法修正時填 `null` | +| F4-4 | 產出 `ValidationReport`,記錄 `autoFixedCount`、`nullFieldCount`、`warnings` | +| F4-5 | `nullFieldCount` 超過 30% 時,將該頁標記為 `requiresHumanReview: true`,並在 CLI 輸出警告 | +| F4-6 | 語意驗證 region 數量、文字樣本、主要 action、表單欄位與 DOM/accessibility evidence 是否一致 | +| F4-7 | 產出人工可讀的 `ui-contract.md` 摘要,讓使用者可在實作前 review prototype contract | + +**產出物:** 完整的 `LayoutSpec` JSON(儲存至 `.ht/spec/{filename}.spec.json`)+ `ValidationReport` + `ui-contract.md` + +--- + +### Stage 5:Plan + +**目的:** 將 `LayoutSpec` 拆解為 agent 可逐一執行的 `TaskList`,讓 Stage 6 的執行單元保持小而可控。 + +| 需求 ID | 說明 | +|---------|------| +| F5-1 | 讀取 `LayoutSpec`、`frontend/` 的現有元件、`backend/` 的 API schema,產出 `TaskList` | +| F5-2 | 每個 Task 對應一個 page 或 component,包含:`id`、`type`、`targetFile`、`specReference`、`apiDependencies` | +| F5-3 | Task 之間的依賴關係以 `dependsOn` 陣列表示,Orchestrator 依此決定執行順序 | +| F5-4 | `ht.config.ts` 中 `plan.interactiveReview: true` 時,產出 TaskList 後暫停,輸出預覽並等待使用者輸入 `y` 繼續 | +| F5-5 | `TaskList` 儲存至 `.ht/plan/tasklist.json`,支援手動編輯後重新執行 Stage 6 | +| F5-6 | 掃描 `frontend/` 產出 `project-conventions.md`,至少包含 routing、Vuetify 使用方式、表單封裝、API client/composable、Pinia、validation、i18n、測試慣例 | +| F5-7 | 掃描 `backend/` 產出 `api-contract.md`;若存在 OpenAPI 優先使用,否則從 TypeScript interface、controller、route 或 markdown 抽象 endpoint contract | +| F5-8 | 產出 `component-mapping.json`,描述 prototype region/section 對應 Vuetify component 或既有自訂 wrapper 的映射 | +| F5-9 | `plan.requireApiContractMatch: true` 時,Task 不得引用 `api-contract.md` 未列出的 endpoint;缺口須產出 `contractProposal` 並標記需人工確認 | + +**TaskList 結構(摘要):** + +```typescript +interface Task { + id: string + type: 'page' | 'component' | 'api-integration' + targetFile: string // 相對於 output/ 的路徑 + specReference: string // 對應 LayoutSpec 的 regionId + apiDependencies: string[] // backend API endpoint 清單 + inputArtifacts: string[] // spec、contract、conventions、mapping 等 artifact 路徑 + acceptanceChecks: string[] // 此 task 完成後必須通過的檢查 + dependsOn: string[] // 其他 task id + status: 'pending' | 'running' | 'done' | 'error' +} +``` + +**產出物:** `.ht/plan/tasklist.json`、`.ht/plan/project-conventions.md`、`.ht/plan/api-contract.md`、`.ht/plan/component-mapping.json` + +--- + +### Stage 6:Run + +**目的:** 讓 coding agent 依序執行 TaskList,將 Vuetify 元件與 API 整合寫入 `output/`。 + +| 需求 ID | 說明 | +|---------|------| +| F6-1 | Orchestrator 依 `dependsOn` 拓撲排序,逐一將 task 交給 agent 執行 | +| F6-2 | 每個 task 的 prompt context 包含:對應的 `RegionSpec`、`ui-contract.md` 摘要、`project-conventions.md`、`component-mapping.json`、`targetFile` 的現有內容(若存在)、相關 API contract | +| F6-3 | Agent 的輸出直接寫入 `output/{targetFile}`;寫入前備份至 `.ht/backup/` | +| F6-4 | Task 執行失敗時標記 `status: 'error'`,記錄錯誤訊息,繼續執行無依賴的後續 task | +| F6-5 | 支援 `ht run --retry-failed` 指令,只重新執行 `status: 'error'` 的 task | +| F6-6 | 每個 task 完成後更新 `.ht/plan/tasklist.json` 的 `status` 欄位 | +| F6-7 | Agent invocation 必須限制 `allowedPaths`,預設只能寫入 `output/` 與 `.ht/`;不得修改 `prototype/`、`frontend/`、`backend/` 原始輸入資料夾 | +| F6-8 | 每次 agent raw output、changed files、exit code、stderr/stdout summary 寫入 `.ht/runs/{runId}/agent/` | + +**產出物:** `output/` 資料夾中的 Vuetify 元件與整合程式碼 + +--- + +### Stage 7:Diff & Verify + +**目的:** 量化 `output/` 的結果與 prototype 的視覺相似度,並以 deterministic checks 優先驗證實作品質,讓工程師能快速判斷生成結果是否可交付。 + +| 需求 ID | 說明 | +|---------|------| +| F7-1 | 對 `output/` 中每個頁面以相同 viewport 截圖 | +| F7-2 | 將 output 截圖與 Stage 1 的 prototype 截圖交給 agent 評分(0–100),判斷視覺相似度 | +| F7-3 | 產出靜態 HTML report,每行並排顯示:prototype 截圖、output 截圖、similarity score | +| F7-4 | Score 低於 `ht.config.ts` 中 `diff.scoreThreshold`(預設 75)的項目以警告色標示 | +| F7-5 | Report 儲存至 `.ht/diff/{filename}.diff.html`,執行完畢後在 CLI 輸出 report 路徑 | +| F7-6 | 執行 `project.qualityCommands` 中設定的 typecheck、lint、test;缺少指令時記錄為 `skipped` 並說明原因 | +| F7-7 | 執行 DOM-level checks:主要文字、form label、table/list item、primary/secondary action 是否存在且可互動 | +| F7-8 | 執行 flow-level checks:可由 `LayoutSpec.interactions` 推導的 drawer、tab、modal、form submit、navigation 等互動 smoke test | +| F7-9 | 採 deterministic first, agent fallback second:固定檢查失敗時才呼叫 agent 協助分析原因,不讓 agent 取代可重跑驗證 | +| F7-10 | 產出 `VerificationReport`,彙整 quality commands、DOM checks、flow checks、visual diff 與人工需處理項目 | + +**產出物:** `.ht/diff/` 下的靜態 HTML report + `.ht/verify/verification-report.json` + +--- + +## 5. CLI 指令設計 + +| 指令 | 說明 | +|------|------| +| `ht scan` | 執行 Stage 1–4(Capture → Validate),產出 `LayoutSpec` | +| `ht plan` | 執行 Stage 5,產出 `TaskList`;`interactiveReview: true` 時暫停等待確認 | +| `ht run` | 執行 Stage 6,依 `TaskList` 驅動 agent 寫入 `output/` | +| `ht run --retry-failed` | 只重新執行 `status: 'error'` 的 task | +| `ht diff` | 執行 Stage 7 的 visual diff,產出 similarity report | +| `ht verify` | 執行 Stage 7 的 quality commands、DOM checks 與 flow checks | +| `ht go` | 依序執行全部 stage(scan → plan → run → diff → verify) | +| `ht status` | 顯示目前 `TaskList` 的執行狀態摘要 | +| `ht doctor` | 檢查 pnpm workspace、Playwright、agent CLI、Vuetify component whitelist、quality commands 是否可用 | + +--- + +## 6. 資料夾結構 + +``` +monorepo/ +├── ht.config.ts +├── packages/ +│ ├── prototype/ ← 輸入:HTML prototype +│ ├── web/ ← 輸入:Vuetify 前端專案(預先建立) +│ ├── api/ ← 輸入:Backend API(預先建立) +│ └── result/ ← 輸出:生成結果 +└── .ht/ ← 工具產出的所有中間產物與 cache + ├── cache/prototype/ ← Stage 1 截圖 + ├── spec/ ← Stage 4 LayoutSpec JSON + ├── plan/ ← Stage 5 TaskList JSON + ├── runs/ ← 每次 run 的狀態、log、agent raw output、artifact 索引 + ├── backup/ ← Stage 6 寫入前的備份 + ├── verify/ ← Stage 7 verification report + └── diff/ ← Stage 7 similarity report +``` + +**Run artifact 結構:** + +``` +.ht/runs/ +└── 2026-05-02-001/ + ├── run.json + ├── screenshots/ + ├── dom-snapshots/ + ├── agent/ + ├── logs/ + └── artifact-index.json +``` + +--- + +## 7. 非功能需求 + +| 類別 | 需求 | +|------|------| +| 效能 | 單一 HTML 頁面的 Stage 1–4 完整執行不超過 60 秒 | +| 可復現性 | Cache 命中時跳過截圖與 extract,相同輸入產出相同結果 | +| 可觀測性 | 每個 stage 完成後輸出 log,記錄耗時、agent call 次數、cache 命中狀態 | +| 錯誤隔離 | 單一 task 或 region 失敗不中斷整體流程,標記後繼續執行 | +| 可介入性 | `TaskList` 支援手動編輯;`interactiveReview` 模式允許人工在 Plan 後介入 | +| 可追溯性 | 每個輸出檔案都能追溯到 input artifact、agent invocation、task id 與 run id | +| 邊界控制 | Agent 預設只可寫入 `output/` 與 `.ht/`,所有輸入資料夾視為 read-only | + +--- + +## 8. 風險與假設 + +| 風險 | 程度 | 緩解策略 | +|------|------|---------| +| Vision 分析精度不足,`null` 欄位過多 | 高 | Spike 驗證後調整 prompt;人工補充 spec | +| Prototype 引用外部資源,截圖不完整 | 中 | Local static server + `networkidle` 等待 | +| Agent 生成的程式碼不符合既有前端專案的規範 | 中 | Prompt context 包含現有元件與 coding style;task 單元保持小 | +| Backend API 不足以支援 prototype 行為 | 中 | Stage 5 產出 `contractProposal` 並要求人工確認,不讓 agent 自行發明 endpoint | +| 只靠 screenshot similarity 造成假陽性 | 中 | Stage 7 加入 DOM-level、flow-level 與 project quality checks | +| Vuetify component 白名單不完整,自動修正失敗 | 低 | 預先整理 Vuetify 3 全量 component 清單 | + +**核心假設:** +- Prototype 在現代瀏覽器中能正確渲染 +- `frontend/` 專案已有基本的 Vuetify 3 設定與 project structure +- `backend/` 專案有可讀的 API schema(OpenAPI 或 TypeScript interface) +- 第一版 MVP 聚焦單一 HTML prototype → 單一 Vue page/component → 既有 API client → `output/` 結果,不追求一開始支援所有 monorepo 形態 + +--- + +## 9. 開發里程碑 + +| 里程碑 | 涵蓋 Stage | 完成條件 | +|--------|-----------|---------| +| M0:Spike | Stage 1–3(手動) | 手動截圖 + 手動 prompt,驗證 `null` 欄位 < 30% | +| M1:Vision Pipeline | Stage 1–4 | `ht scan` 可對一個 HTML 頁面產出完整 `LayoutSpec`、DOM snapshot 與 `ui-contract.md` | +| M2:Plan | Stage 5 | `ht plan` 可產出 `TaskList`、`project-conventions.md`、`api-contract.md`、`component-mapping.json`,interactive review 可運作 | +| M3:Run | Stage 6 | `ht run` 可驅動 agent 對一個頁面完整執行並寫入 `output/` | +| M4:Diff & Verify | Stage 7 | `ht diff` 可產出 similarity report,`ht verify` 可執行 quality、DOM、flow checks | +| M5:整合 | 全部 | `ht go` 全流程可在真實 monorepo 中穩定執行 | diff --git a/README.md b/README.md new file mode 100644 index 0000000..8fcead8 --- /dev/null +++ b/README.md @@ -0,0 +1,290 @@ +## A prototype-driven orchestration framework for CLI coding agents in TypeScript monorepos + +- **prototype-driven**:不是純 spec,也不是純 code-first。 +- **orchestration framework**:主體是 workflow 與 gates,不是模型本身。 +- **CLI coding agents**:Claude Code、Codex、Gemini 只是 adapter targets,不是核心。 + +## MVP + +這個版本提供一個可執行的 `ht` CLI 骨架,依照 PRD 的 stage 產出可 review 的中間 artifact: + +- `ht scan`:讀取 `prototype/` HTML,產生 `.ht/cache/prototype/`、`.ht/spec/*.spec.json`、`ui-contract.md` +- `ht plan`:掃描 frontend/backend,產生 `.ht/plan/tasklist.json`、conventions、API contract、component mapping +- `ht run`:依 TaskList 產生 Vuetify `.vue` 檔到 `output/` +- `ht diff`:產生 `.ht/diff/*.diff.html` +- `ht verify`:執行 quality commands 並產生 `.ht/verify/verification-report.json` +- `ht go --yes`:串起完整流程 + +外部 vision/agent/Playwright 尚未假裝成已整合;目前以 deterministic HTML evidence 建立 MVP artifact,缺少的執行環境會在 report 或 `ht doctor` 中明確標示。 + +## 使用方式 + +### 1. 確認 Node 版本 + +需要 Node.js 20 以上。 + +```bash +node --version +``` + +### 2. 安裝專案相依套件 + +建議使用專案級安裝,不依賴全域 `playwright-cli`: + +```bash +pnpm install +``` + +本專案使用 `@playwright/cli`,指令名稱是 `playwright-cli`。安裝後可以用: + +```bash +npx --no-install playwright-cli --version +``` + +或透過 npm script: + +```bash +pnpm playwright-cli -- --version +``` + +若環境還沒有瀏覽器 binary,先安裝預設瀏覽器: + +```bash +pnpm playwright-cli:install-browser +``` + +Linux CI 或乾淨容器可能還需要系統套件,先用 dry-run 看會做什麼: + +```bash +npx --no-install playwright-cli install-browser --with-deps --dry-run +``` + +### 3. 準備資料夾 + +預設設定會讀取以下路徑: + +```text +packages/ +├── prototype/ # 放 HTML prototype +├── web/ # 既有 Vuetify frontend 專案 +├── api/ # backend API 或 schema +└── result/ # 產生結果 +``` + +最小可跑範例: + +```bash +mkdir -p packages/prototype packages/web/src packages/api packages/result +``` + +新增一個 prototype: + +```html + +
+

Customer Portal

+
+ + + +
+
+``` + +### 4. 選擇性建立設定檔 + +沒有 `ht.config.js` 時會使用預設值。若要覆寫路徑或關閉互動確認,可以建立: + +```js +// ht.config.js +export default { + prototype: './packages/prototype', + frontend: './packages/web', + backend: './packages/api', + output: './packages/result', + agent: 'codex', + plan: { + interactiveReview: false, + requireApiContractMatch: true + }, + project: { + qualityCommands: { + typecheck: 'pnpm typecheck', + lint: 'pnpm lint', + test: 'pnpm test' + } + } +} +``` + +也可以使用 `ht.config.ts`: + +```ts +import { defineConfig } from 'html-transform' + +export default defineConfig({ + prototype: './packages/prototype', + frontend: './packages/web', + backend: './packages/api', + output: './packages/result' +}) +``` + +### 5. 檢查環境 + +```bash +node src/cli.js doctor +``` + +`doctor` 會檢查 prototype/frontend/backend/output parent、playwright-cli、agent CLI、pnpm 是否存在。缺少項目會標示為 `missing`。 + +`playwright-cli` 的偵測順序: + +1. `node_modules/.bin/playwright-cli` +2. 全域 `playwright-cli` +3. `npx --no-install playwright-cli` + +### 6. 執行 scan + +```bash +node src/cli.js scan +``` + +產出: + +```text +.ht/cache/prototype/ +.ht/spec/*.spec.json +.ht/spec/*.validation.json +.ht/spec/*.ui-contract.md +``` + +這一步會從 HTML 擷取 DOM summary、accessibility-like tree、region、LayoutSpec 與 UI contract。MVP 目前使用 deterministic HTML evidence;截圖檔是 placeholder PNG。 + +後續接真實 browser automation 時,會透過 `playwright-cli` shell 指令操作,不透過 MCP。 + +### 7. 產生 TaskList + +```bash +node src/cli.js plan +``` + +產出: + +```text +.ht/plan/tasklist.json +.ht/plan/project-conventions.md +.ht/plan/api-contract.md +.ht/plan/component-mapping.json +``` + +若 `plan.interactiveReview` 設為 `true`,CLI 會在產生 TaskList 後要求輸入 `y` 才繼續。 + +### 8. 產生 output + +```bash +node src/cli.js run +``` + +產出會寫入 `output` 設定的資料夾,例如: + +```text +packages/result/index/main-1.vue +``` + +每個 task 的執行記錄會寫入: + +```text +.ht/runs// +``` + +若只想重跑失敗 task: + +```bash +node src/cli.js run --retry-failed +``` + +### 9. 產生 diff report + +```bash +node src/cli.js diff +``` + +產出: + +```text +.ht/diff/*.diff.html +``` + +MVP 目前會建立可 review 的 HTML report 結構;真實 `playwright-cli` 截圖與 vision similarity scoring 尚未串接。 + +### 10. 執行驗證 + +```bash +node src/cli.js verify +``` + +產出: + +```text +.ht/verify/verification-report.json +``` + +驗證包含: + +- `project.qualityCommands` 中設定的 typecheck/lint/test +- output target 是否存在 +- flow checks 目前在 MVP 中會標示為 skipped + +### 11. 一次跑完整流程 + +```bash +node src/cli.js go --yes +``` + +等同依序執行: + +```bash +node src/cli.js scan +node src/cli.js plan +node src/cli.js run +node src/cli.js diff +node src/cli.js verify +``` + +### 12. 查看 task 狀態 + +```bash +node src/cli.js status +``` + +會顯示 `pending`、`running`、`done`、`error` 的 task 數量。 + +## 開發驗證 + +```bash +pnpm test +pnpm typecheck +``` + +## 目前 MVP 限制 + +- 尚未整合 Playwright static server、真實 full-page screenshot、DOM snapshot API 與 accessibility tree API。 +- Playwright 操作會以 `playwright-cli` shell command 為準,不使用 MCP;目前已加入專案級 `@playwright/cli` 安裝與環境偵測。 +- 尚未真的呼叫 Claude Code/Codex/Gemini 進行 vision decomposition 或 coding task。 +- `diff` 的 similarity score 目前是 deterministic report placeholder,不是 vision scoring。 +- `run` 目前以 LayoutSpec 產生基本 Vuetify Vue component,還不是完整專案級整合。 +- 缺少 quality command 或外部工具時,會在 report 或 `doctor` 中標示為 skipped/missing。 + +## 常用指令摘要 + +```bash +pnpm test +pnpm typecheck +node src/cli.js doctor +node src/cli.js scan +node src/cli.js plan +node src/cli.js run +node src/cli.js diff +node src/cli.js verify +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..9d7d94d --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "html-transform", + "version": "0.1.0", + "description": "Prototype-driven orchestration framework for transforming HTML prototypes into Vuetify implementations.", + "packageManager": "pnpm@10.10.0", + "type": "module", + "bin": { + "ht": "./src/cli.js" + }, + "exports": { + ".": "./src/index.js" + }, + "scripts": { + "test": "node --test", + "typecheck": "node --check src/*.js src/**/*.js", + "playwright-cli": "playwright-cli", + "playwright-cli:install-browser": "playwright-cli install-browser" + }, + "devDependencies": { + "@playwright/cli": "^0.1.8" + }, + "engines": { + "node": ">=20" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..fd85324 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,52 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@playwright/cli': + specifier: ^0.1.8 + version: 0.1.11 + +packages: + + '@playwright/cli@0.1.11': + resolution: {integrity: sha512-nz6xPChoijBsxZTSukJFBCCwLozsp7uwBLYrlCrmRU6MXc2a+mpaaLApqBxMfVFjgbP4pYY/3uQ/NnpD+aicxw==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.60.0-alpha-1777669338000: + resolution: {integrity: sha512-CbBF+YtjGGK2mWKdH6iiSNIB9v9Sq8owcUrTy1cw6FgMZavspM6ZSpidQQgQXz/1scQFrM110jrtJqTYwnjbeA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0-alpha-1777669338000: + resolution: {integrity: sha512-r5G2ZvpIJZHj53GKmUfoQiXHix5bjeObqysV4flfmFNykTjebI9bwdRBuW37pOQP25yjbArkxlsnT5APaWT8zg==} + engines: {node: '>=18'} + hasBin: true + +snapshots: + + '@playwright/cli@0.1.11': + dependencies: + playwright: 1.60.0-alpha-1777669338000 + + fsevents@2.3.2: + optional: true + + playwright-core@1.60.0-alpha-1777669338000: {} + + playwright@1.60.0-alpha-1777669338000: + dependencies: + playwright-core: 1.60.0-alpha-1777669338000 + optionalDependencies: + fsevents: 2.3.2 diff --git a/src/cli.js b/src/cli.js new file mode 100755 index 0000000..d859022 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import { scan } from './stages/scan.js' +import { plan } from './stages/plan.js' +import { runTasks } from './stages/run.js' +import { diff } from './stages/diff.js' +import { verify } from './stages/verify.js' +import { status } from './stages/status.js' +import { doctor } from './stages/doctor.js' + +const command = process.argv[2] ?? 'help' +const flags = new Set(process.argv.slice(3)) + +async function main() { + if (command === 'scan') await scan() + else if (command === 'plan') await plan() + else if (command === 'run') await runTasks({ retryFailed: flags.has('--retry-failed') }) + else if (command === 'diff') await diff() + else if (command === 'verify') await verify() + else if (command === 'go') { + await scan() + await plan({ assumeYes: flags.has('--yes') }) + await runTasks({ retryFailed: false }) + await diff() + await verify() + } else if (command === 'status') await status() + else if (command === 'doctor') await doctor() + else printHelp() +} + +function printHelp() { + console.log(`Usage: ht + +Commands: + scan Capture and extract LayoutSpec artifacts + plan Build TaskList and project/API contracts + run Execute pending tasks into output/ + run --retry-failed + diff Build visual similarity report + verify Run quality, DOM, and flow checks + go [--yes] Run scan, plan, run, diff, verify + status Show TaskList summary + doctor Check local prerequisites`) +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +}) + diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..64972ce --- /dev/null +++ b/src/index.js @@ -0,0 +1,4 @@ +export function defineConfig(config) { + return config +} + diff --git a/src/lib/agent.js b/src/lib/agent.js new file mode 100644 index 0000000..8ae1f74 --- /dev/null +++ b/src/lib/agent.js @@ -0,0 +1,20 @@ +import { spawn } from 'node:child_process' + +const commands = { + codex: ['codex'], + 'claude-code': ['claude'], + gemini: ['gemini'] +} + +export function resolveAgentCommand(agent) { + return commands[agent] ?? [agent] +} + +export async function commandExists(command) { + return new Promise((resolve) => { + const child = spawn('which', [command], { stdio: 'ignore' }) + child.on('close', (code) => resolve(code === 0)) + child.on('error', () => resolve(false)) + }) +} + diff --git a/src/lib/config.js b/src/lib/config.js new file mode 100644 index 0000000..643fc72 --- /dev/null +++ b/src/lib/config.js @@ -0,0 +1,91 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { defineConfig } from '../index.js' + +const defaultConfig = { + prototype: './packages/prototype', + frontend: './packages/web', + backend: './packages/api', + output: './packages/result', + agent: 'codex', + vision: { + viewport: { width: 1440, height: 900 }, + captureStates: ['default'], + includeDomSnapshot: true, + includeAccessibilityTree: true + }, + project: { + packageManager: 'pnpm', + frontendFramework: 'vue3', + uiLibrary: 'vuetify3', + qualityCommands: { + typecheck: 'pnpm typecheck', + lint: 'pnpm lint', + test: 'pnpm test' + } + }, + plan: { + interactiveReview: false, + requireApiContractMatch: true + }, + diff: { + scoreThreshold: 75, + requireDomChecks: true, + requireFlowChecks: true + } +} + +export async function loadConfig(cwd = process.cwd()) { + const candidates = ['ht.config.mjs', 'ht.config.js', 'ht.config.json', 'ht.config.ts'] + for (const filename of candidates) { + const file = resolve(cwd, filename) + if (!existsSync(file)) continue + const loaded = await loadConfigFile(file) + return normalizeConfig({ ...defaultConfig, ...loaded }, cwd) + } + return normalizeConfig(defaultConfig, cwd) +} + +async function loadConfigFile(file) { + if (file.endsWith('.json')) return JSON.parse(readFileSync(file, 'utf8')) + if (file.endsWith('.ts')) return loadTsConfig(file) + const module = await import(`${pathToFileURL(file).href}?t=${Date.now()}`) + return module.default ?? module +} + +function loadTsConfig(file) { + let source = readFileSync(file, 'utf8') + source = source.replace(/import\s+\{?\s*defineConfig\s*\}?\s+from\s+['"][^'"]+['"];?/g, '') + source = source.replace(/export\s+default\s+defineConfig\s*\(/, 'return defineConfig(') + source = source.replace(/export\s+default\s+/, 'return ') + return Function('defineConfig', source)(defineConfig) +} + +function normalizeConfig(config, cwd) { + const merged = { + ...defaultConfig, + ...config, + vision: { ...defaultConfig.vision, ...config.vision }, + project: { + ...defaultConfig.project, + ...config.project, + qualityCommands: { + ...defaultConfig.project.qualityCommands, + ...config.project?.qualityCommands + } + }, + plan: { ...defaultConfig.plan, ...config.plan }, + diff: { ...defaultConfig.diff, ...config.diff } + } + return { + ...merged, + cwd, + prototypeDir: resolve(cwd, merged.prototype), + frontendDir: resolve(cwd, merged.frontend), + backendDir: resolve(cwd, merged.backend), + outputDir: resolve(cwd, merged.output), + htDir: resolve(cwd, '.ht') + } +} + diff --git a/src/lib/files.js b/src/lib/files.js new file mode 100644 index 0000000..d3dbe56 --- /dev/null +++ b/src/lib/files.js @@ -0,0 +1,54 @@ +import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { dirname, extname, join, relative } from 'node:path' + +export async function ensureDir(path) { + await mkdir(path, { recursive: true }) +} + +export async function writeJson(path, data) { + await ensureDir(dirname(path)) + await writeFile(path, `${JSON.stringify(data, null, 2)}\n`) +} + +export async function readJson(path) { + return JSON.parse(await readFile(path, 'utf8')) +} + +export async function sha256File(path) { + const content = await readFile(path) + return createHash('sha256').update(content).digest('hex') +} + +export async function listFiles(root, extensions = []) { + const output = [] + async function walk(dir) { + let entries = [] + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const path = join(dir, entry.name) + if (entry.isDirectory()) await walk(path) + else if (extensions.length === 0 || extensions.includes(extname(entry.name))) output.push(path) + } + } + await walk(root) + return output.sort() +} + +export async function exists(path) { + try { + await stat(path) + return true + } catch { + return false + } +} + +export function artifactPath(root, file) { + return relative(root, file).replaceAll('\\', '/').replace(/\.html$/i, '') +} + diff --git a/src/lib/html.js b/src/lib/html.js new file mode 100644 index 0000000..76d0dc3 --- /dev/null +++ b/src/lib/html.js @@ -0,0 +1,118 @@ +export function summarizeHtml(html) { + const title = matchText(html, /]*>([\s\S]*?)<\/title>/i) + const headings = collect(html, /]*>([\s\S]*?)<\/h\1>/gi).map((item) => cleanText(item[2])) + const buttons = collect(html, /<(button|a)\b[^>]*>([\s\S]*?)<\/\1>/gi) + .map((item) => cleanText(item[2])) + .filter(Boolean) + const labels = collect(html, /]*>([\s\S]*?)<\/label>/gi).map((item) => cleanText(item[1])) + const inputs = collect(html, /<(input|select|textarea)\b([^>]*)>/gi).map((item) => ({ + tag: item[1].toLowerCase(), + type: attr(item[2], 'type') ?? null, + name: attr(item[2], 'name') ?? attr(item[2], 'id') ?? null, + required: /\brequired\b/i.test(item[2]) + })) + const textSamples = [...headings, ...labels, ...buttons, ...visibleText(html)].filter(Boolean).slice(0, 3) + return { title, headings, buttons, labels, inputs, textSamples } +} + +export function extractRegions(html) { + const landmarks = [ + ['header', /]*>([\s\S]*?)<\/header>/gi, 'banner'], + ['nav', /]*>([\s\S]*?)<\/nav>/gi, 'navigation'], + ['main', /]*>([\s\S]*?)<\/main>/gi, 'main'], + ['section', /]*>([\s\S]*?)<\/section>/gi, 'region'], + ['footer', /]*>([\s\S]*?)<\/footer>/gi, 'contentinfo'] + ] + const regions = [] + for (const [tag, pattern, role] of landmarks) { + for (const item of collect(html, pattern)) { + const text = visibleText(item[1]) + regions.push({ + id: `${tag}-${regions.length + 1}`, + name: text[0] ?? tag, + rough_position: null, + element_count_estimate: Math.max(1, text.length), + semantic_role: role, + html: item[1] + }) + } + } + if (regions.length === 0) { + regions.push({ + id: 'page-1', + name: summarizeHtml(html).title ?? 'Page', + rough_position: null, + element_count_estimate: visibleText(html).length, + semantic_role: 'main', + html + }) + } + return regions +} + +export function visibleText(html) { + const withoutScripts = html + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + return withoutScripts + .split(/\s+/) + .map((text) => text.trim()) + .filter((text) => text.length > 1) + .slice(0, 50) +} + +export function inferRegionSpec(region) { + const summary = summarizeHtml(region.html) + const hasForm = /<(form|input|select|textarea)\b/i.test(region.html) + const hasTable = / ({ label, type: 'click', observableResult: null })) + }, + warnings: [] + } +} + +function extractColors(html) { + return [...new Set((html.match(/#[0-9a-f]{3,8}\b|rgba?\([^)]+\)/gi) ?? []).slice(0, 12))] +} + +function attr(attrs, name) { + const match = attrs.match(new RegExp(`${name}\\s*=\\s*["']([^"']+)["']`, 'i')) + return match?.[1] ?? null +} + +function matchText(source, pattern) { + const match = source.match(pattern) + return match ? cleanText(match[1]) : null +} + +function collect(source, pattern) { + return [...source.matchAll(pattern)] +} + +function cleanText(text) { + return text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() +} diff --git a/src/lib/playwright-cli.js b/src/lib/playwright-cli.js new file mode 100644 index 0000000..d562e2c --- /dev/null +++ b/src/lib/playwright-cli.js @@ -0,0 +1,23 @@ +import { spawn } from 'node:child_process' +import { join } from 'node:path' +import { exists } from './files.js' +import { commandExists } from './agent.js' + +export async function resolvePlaywrightCli(cwd = process.cwd()) { + const localBin = join(cwd, 'node_modules/.bin/playwright-cli') + if (await exists(localBin)) return { command: localBin, args: [], source: 'local' } + if (await commandExists('playwright-cli')) return { command: 'playwright-cli', args: [], source: 'global' } + if (await commandExists('npx')) return { command: 'npx', args: ['--no-install', 'playwright-cli'], source: 'npx-local' } + return null +} + +export async function playwrightCliAvailable(cwd = process.cwd()) { + const resolved = await resolvePlaywrightCli(cwd) + if (!resolved) return false + return new Promise((resolve) => { + const child = spawn(resolved.command, [...resolved.args, '--version'], { cwd, stdio: 'ignore' }) + child.on('close', (code) => resolve(code === 0)) + child.on('error', () => resolve(false)) + }) +} + diff --git a/src/stages/diff.js b/src/stages/diff.js new file mode 100644 index 0000000..8d02b23 --- /dev/null +++ b/src/stages/diff.js @@ -0,0 +1,47 @@ +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { loadConfig } from '../lib/config.js' +import { ensureDir, listFiles, readJson } from '../lib/files.js' + +export async function diff() { + const config = await loadConfig() + const specs = await listFiles(join(config.htDir, 'spec'), ['.json']) + if (specs.length === 0) throw new Error('找不到 spec,請先執行 ht scan') + await ensureDir(join(config.htDir, 'diff')) + for (const file of specs.filter((file) => file.endsWith('.spec.json'))) { + const spec = await readJson(file) + const reportPath = join(config.htDir, 'diff', `${spec.page}.diff.html`) + await writeFile(reportPath, renderDiffReport(config, spec)) + console.log(`diff report: ${reportPath}`) + } +} + +function renderDiffReport(config, spec) { + const rows = spec.decomposition.map((region) => { + const score = 75 + const warning = score < config.diff.scoreThreshold ? ' class="warning"' : '' + return `${region.id}${region.name}${score}${region.screenshotPath}` + }).join('\n') + return ` + + + + ${spec.page} diff + + + +

${spec.page} Diff

+ + + ${rows} +
RegionNameSimilarityPrototype Screenshot
+ + +` +} + diff --git a/src/stages/doctor.js b/src/stages/doctor.js new file mode 100644 index 0000000..8905303 --- /dev/null +++ b/src/stages/doctor.js @@ -0,0 +1,23 @@ +import { join } from 'node:path' +import { loadConfig } from '../lib/config.js' +import { commandExists, resolveAgentCommand } from '../lib/agent.js' +import { exists } from '../lib/files.js' +import { playwrightCliAvailable, resolvePlaywrightCli } from '../lib/playwright-cli.js' + +export async function doctor() { + const config = await loadConfig() + const agentCommand = resolveAgentCommand(config.agent)[0] + const playwrightCli = await resolvePlaywrightCli(config.cwd) + const checks = [ + ['prototype directory', await exists(config.prototypeDir)], + ['frontend directory', await exists(config.frontendDir)], + ['backend directory', await exists(config.backendDir)], + ['output directory parent', await exists(join(config.outputDir, '..'))], + [`playwright-cli${playwrightCli ? ` (${playwrightCli.source})` : ''}`, await playwrightCliAvailable(config.cwd)], + [`agent ${config.agent}`, await commandExists(agentCommand)], + ['pnpm', await commandExists('pnpm')] + ] + for (const [name, ok] of checks) { + console.log(`${ok ? 'ok' : 'missing'} ${name}`) + } +} diff --git a/src/stages/plan.js b/src/stages/plan.js new file mode 100644 index 0000000..43bb84c --- /dev/null +++ b/src/stages/plan.js @@ -0,0 +1,120 @@ +import { createInterface } from 'node:readline/promises' +import { stdin as input, stdout as output } from 'node:process' +import { basename, join } from 'node:path' +import { loadConfig } from '../lib/config.js' +import { listFiles, readJson, writeJson } from '../lib/files.js' + +export async function plan(options = {}) { + const config = await loadConfig() + const specs = await listFiles(join(config.htDir, 'spec'), ['.json']) + const layoutSpecs = [] + for (const file of specs.filter((file) => file.endsWith('.spec.json'))) { + layoutSpecs.push(await readJson(file)) + } + if (layoutSpecs.length === 0) throw new Error('找不到 LayoutSpec,請先執行 ht scan') + + const conventions = await buildProjectConventions(config) + const apiContract = await buildApiContract(config) + const mapping = buildComponentMapping(layoutSpecs) + const taskList = buildTaskList(config, layoutSpecs, apiContract) + + await writeJson(join(config.htDir, 'plan/component-mapping.json'), mapping) + await writeJson(join(config.htDir, 'plan/tasklist.json'), taskList) + await writeMarkdown(join(config.htDir, 'plan/project-conventions.md'), conventions) + await writeMarkdown(join(config.htDir, 'plan/api-contract.md'), apiContract.markdown) + + if (config.plan.interactiveReview && !options.assumeYes) { + console.log(renderPreview(taskList)) + const rl = createInterface({ input, output }) + const answer = await rl.question('繼續執行 Stage 6?輸入 y 繼續:') + rl.close() + if (answer.trim().toLowerCase() !== 'y') { + console.log('plan 已產出,等待手動 review') + return + } + } + console.log(`plan 完成:${taskList.tasks.length} 個 task`) +} + +async function buildProjectConventions(config) { + const files = await listFiles(config.frontendDir, ['.vue', '.js', '.ts']) + const sample = files.slice(0, 20).map((file) => `- ${file.replace(config.frontendDir, '')}`).join('\n') || '- frontend directory missing or empty' + return `# Project Conventions + +- Routing: ${files.some((file) => /router/i.test(file)) ? 'router files detected' : 'not detected'} +- Vuetify: ${files.some((file) => /vuetify/i.test(file)) ? 'Vuetify usage detected' : 'not detected'} +- Forms: ${files.some((file) => /form/i.test(file)) ? 'form files detected' : 'not detected'} +- API client/composable: ${files.some((file) => /(api|composable|service)/i.test(file)) ? 'detected' : 'not detected'} +- Pinia: ${files.some((file) => /pinia|store/i.test(file)) ? 'detected' : 'not detected'} +- Validation: ${files.some((file) => /valid/i.test(file)) ? 'detected' : 'not detected'} +- i18n: ${files.some((file) => /i18n|locale/i.test(file)) ? 'detected' : 'not detected'} +- Tests: ${files.some((file) => /\.(test|spec)\./i.test(file)) ? 'detected' : 'not detected'} + +## Sample Files + +${sample} +` +} + +async function buildApiContract(config) { + const files = await listFiles(config.backendDir, ['.json', '.yaml', '.yml', '.ts', '.js', '.md']) + const endpoints = [] + for (const file of files) { + const match = file.match(/openapi|swagger/i) + if (match) endpoints.push({ method: 'OPENAPI', path: file.replace(config.backendDir, ''), source: file }) + } + return { + endpoints, + markdown: `# API Contract + +${endpoints.length ? endpoints.map((endpoint) => `- ${endpoint.method} ${endpoint.path}`).join('\n') : '- No API contract detected'} +` + } +} + +function buildComponentMapping(layoutSpecs) { + return Object.fromEntries( + layoutSpecs.flatMap((spec) => + spec.regions.map((region) => [ + region.regionId, + { page: spec.page, vuetifyComponent: region.vuetifyComponent, targetKind: 'component' } + ]) + ) + ) +} + +function buildTaskList(config, layoutSpecs, apiContract) { + const tasks = layoutSpecs.flatMap((spec) => + spec.regions.map((region, index) => ({ + id: `${spec.page.replace(/\W+/g, '-').replace(/-$/, '')}-${region.regionId}`, + type: index === 0 ? 'page' : 'component', + targetFile: `${basename(spec.page, '.html')}/${region.regionId}.vue`, + specReference: region.regionId, + apiDependencies: [], + inputArtifacts: [ + `.ht/spec/${basename(spec.page, '.html')}.spec.json`, + '.ht/plan/project-conventions.md', + '.ht/plan/api-contract.md', + '.ht/plan/component-mapping.json' + ], + acceptanceChecks: ['target file exists', 'contains visible text samples'], + dependsOn: [], + status: 'pending', + contractProposal: config.plan.requireApiContractMatch && apiContract.endpoints.length === 0 ? [] : undefined + })) + ) + return { version: 1, generatedAt: new Date().toISOString(), tasks } +} + +async function writeMarkdown(path, content) { + const { ensureDir } = await import('../lib/files.js') + const { dirname } = await import('node:path') + const { writeFile } = await import('node:fs/promises') + await ensureDir(dirname(path)) + await writeFile(path, content) +} + +function renderPreview(taskList) { + return taskList.tasks.map((task) => `${task.id} -> ${task.targetFile}`).join('\n') +} + diff --git a/src/stages/run.js b/src/stages/run.js new file mode 100644 index 0000000..2fa05a4 --- /dev/null +++ b/src/stages/run.js @@ -0,0 +1,97 @@ +import { copyFile, readFile, writeFile } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { loadConfig } from '../lib/config.js' +import { ensureDir, exists, listFiles, readJson, writeJson } from '../lib/files.js' + +export async function runTasks(options = {}) { + const config = await loadConfig() + const taskListPath = join(config.htDir, 'plan/tasklist.json') + if (!(await exists(taskListPath))) throw new Error('找不到 TaskList,請先執行 ht plan') + const taskList = await readJson(taskListPath) + const runId = new Date().toISOString().replaceAll(':', '-').replace(/\..+/, '') + const runDir = join(config.htDir, 'runs', runId) + await ensureDir(join(runDir, 'agent')) + const specs = await loadSpecs(config) + for (const task of topoSort(taskList.tasks)) { + if (options.retryFailed ? task.status !== 'error' : task.status !== 'pending') continue + task.status = 'running' + await writeJson(taskListPath, taskList) + try { + const spec = findRegionSpec(specs, task.specReference) + const targetPath = join(config.outputDir, task.targetFile) + await backupIfExists(config, targetPath, task.id) + await ensureDir(dirname(targetPath)) + await writeFile(targetPath, renderVueComponent(spec)) + task.status = 'done' + await writeJson(join(runDir, 'agent', `${task.id}.json`), { + taskId: task.id, + changedFiles: [targetPath], + exitCode: 0, + stdoutSummary: 'deterministic renderer wrote Vue component', + stderrSummary: '' + }) + } catch (error) { + task.status = 'error' + task.error = error instanceof Error ? error.message : String(error) + } + await writeJson(taskListPath, taskList) + } + await writeJson(join(runDir, 'run.json'), { runId, startedAt: runId, taskCount: taskList.tasks.length }) + console.log(`run 完成:${runDir}`) +} + +async function loadSpecs(config) { + const files = await listFiles(join(config.htDir, 'spec'), ['.json']) + const specs = [] + for (const file of files.filter((file) => file.endsWith('.spec.json'))) specs.push(await readJson(file)) + return specs +} + +function findRegionSpec(specs, regionId) { + for (const spec of specs) { + const region = spec.regions.find((region) => region.regionId === regionId) + if (region) return region + } + throw new Error(`找不到 RegionSpec:${regionId}`) +} + +async function backupIfExists(config, targetPath, taskId) { + if (!(await exists(targetPath))) return + const backupPath = join(config.htDir, 'backup', taskId, basename(targetPath)) + await ensureDir(dirname(backupPath)) + await copyFile(targetPath, backupPath) +} + +function renderVueComponent(spec) { + const text = spec.textSamples.length ? spec.textSamples : [spec.name] + const actions = [...spec.uiContract.primaryActions, ...spec.uiContract.secondaryActions] + const fields = spec.uiContract.fields + return ` +` +} + +function topoSort(tasks) { + const sorted = [] + const remaining = new Map(tasks.map((task) => [task.id, task])) + while (remaining.size) { + const ready = [...remaining.values()].find((task) => task.dependsOn.every((id) => !remaining.has(id))) + if (!ready) throw new Error('TaskList dependency cycle detected') + sorted.push(ready) + remaining.delete(ready.id) + } + return sorted +} + +function escapeHtml(value) { + return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('"', '"') +} + diff --git a/src/stages/scan.js b/src/stages/scan.js new file mode 100644 index 0000000..4311ec5 --- /dev/null +++ b/src/stages/scan.js @@ -0,0 +1,165 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { loadConfig } from '../lib/config.js' +import { artifactPath, ensureDir, exists, listFiles, readJson, sha256File, writeJson } from '../lib/files.js' +import { extractRegions, inferRegionSpec, summarizeHtml } from '../lib/html.js' + +const viewports = [ + { name: 'desktop', width: 1440, height: 900 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'mobile', width: 375, height: 812 } +] + +export async function scan() { + const config = await loadConfig() + const htmlFiles = await listFiles(config.prototypeDir, ['.html']) + if (htmlFiles.length === 0) throw new Error(`找不到 HTML prototype:${config.prototypeDir}`) + await ensureDir(config.htDir) + for (const file of htmlFiles) { + await scanFile(config, file) + } + console.log(`scan 完成:${htmlFiles.length} 個 HTML 檔案`) +} + +async function scanFile(config, file) { + const html = await readFile(file, 'utf8') + const hash = await sha256File(file) + const name = artifactPath(config.prototypeDir, file) + const cacheDir = join(config.htDir, 'cache/prototype', name) + await ensureDir(cacheDir) + const metadataPath = join(cacheDir, 'capture-metadata.json') + const cacheHit = await isCaptureCacheHit(metadataPath, hash) + + const capture = { + source: file, + hash, + viewports, + states: config.vision.captureStates, + externalResourceFailures: findExternalResources(html), + screenshots: [], + captureEngine: 'html-summary', + cacheHit + } + + for (const viewport of viewports) { + for (const state of config.vision.captureStates) { + const screenshotPath = join(cacheDir, `${viewport.name}-${state}.png`) + if (!cacheHit) await writePlaceholderPng(screenshotPath) + capture.screenshots.push({ viewport: viewport.name, state, path: screenshotPath, status: cacheHit ? 'cache-hit' : 'placeholder' }) + } + } + + const domSummary = summarizeHtml(html) + await writeJson(join(cacheDir, 'dom-summary.json'), domSummary) + await writeJson(join(cacheDir, 'accessibility-tree.json'), buildAccessibilityTree(domSummary)) + await writeJson(metadataPath, capture) + + const decomposition = extractRegions(html).map(({ html: _html, ...region }) => ({ + ...region, + screenshotPath: join(cacheDir, `${region.id}.png`) + })) + const regions = extractRegions(html) + const regionSpecs = regions.map(inferRegionSpec) + const validationReport = validateSpecs(regionSpecs) + const layoutSpec = { + source: file, + hash, + generatedAt: new Date().toISOString(), + page: basename(file), + decomposition, + regions: regionSpecs, + validation: validationReport + } + + await writeJson(join(config.htDir, 'spec', `${name}.spec.json`), layoutSpec) + await writeJson(join(config.htDir, 'spec', `${name}.validation.json`), validationReport) + await writeFile(join(config.htDir, 'spec', `${name}.ui-contract.md`), renderUiContract(layoutSpec)) +} + +async function isCaptureCacheHit(metadataPath, hash) { + if (!(await exists(metadataPath))) return false + try { + const metadata = await readJson(metadataPath) + return metadata.hash === hash + } catch { + return false + } +} + +function validateSpecs(specs) { + const warnings = [] + let nullFieldCount = 0 + let fieldCount = 0 + for (const spec of specs) { + countNulls(spec) + if (!vuetifyWhitelist.has(spec.vuetifyComponent)) { + warnings.push(`${spec.regionId}: invalid Vuetify component ${spec.vuetifyComponent}`) + spec.vuetifyComponent = 'VContainer' + } + } + function countNulls(value) { + if (Array.isArray(value)) return value.forEach(countNulls) + if (value && typeof value === 'object') return Object.values(value).forEach(countNulls) + fieldCount += 1 + if (value === null) nullFieldCount += 1 + } + return { + autoFixedCount: warnings.length, + nullFieldCount, + nullFieldRatio: fieldCount === 0 ? 0 : nullFieldCount / fieldCount, + requiresHumanReview: fieldCount > 0 && nullFieldCount / fieldCount > 0.3, + warnings + } +} + +function renderUiContract(spec) { + const lines = [`# UI Contract: ${spec.page}`, ''] + for (const region of spec.regions) { + lines.push(`## ${region.name}`, '') + lines.push(`- Region: ${region.regionId}`) + lines.push(`- Vuetify: ${region.vuetifyComponent}`) + lines.push(`- Text: ${region.textSamples.join(', ') || 'none'}`) + lines.push(`- Labels: ${region.uiContract.labels.join(', ') || 'none'}`) + lines.push(`- Actions: ${[...region.uiContract.primaryActions, ...region.uiContract.secondaryActions].join(', ') || 'none'}`) + lines.push('') + } + return `${lines.join('\n')}\n` +} + +function buildAccessibilityTree(summary) { + return { + role: 'document', + title: summary.title, + children: [ + ...summary.headings.map((name) => ({ role: 'heading', name })), + ...summary.buttons.map((name) => ({ role: 'button', name })), + ...summary.labels.map((name) => ({ role: 'label', name })) + ] + } +} + +function findExternalResources(html) { + const urls = [...html.matchAll(/\b(?:src|href)=["'](https?:\/\/[^"']+)["']/gi)].map((match) => match[1]) + return urls.map((url) => ({ url, reason: 'external resource not fetched in deterministic scan' })) +} + +async function writePlaceholderPng(path) { + const onePixelTransparentPng = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64' + ) + await writeFile(path, onePixelTransparentPng) +} + +const vuetifyWhitelist = new Set([ + 'VApp', + 'VBtn', + 'VCard', + 'VContainer', + 'VForm', + 'VList', + 'VNavigationDrawer', + 'VTable', + 'VTextField', + 'VToolbar' +]) diff --git a/src/stages/status.js b/src/stages/status.js new file mode 100644 index 0000000..cf5a34a --- /dev/null +++ b/src/stages/status.js @@ -0,0 +1,18 @@ +import { join } from 'node:path' +import { loadConfig } from '../lib/config.js' +import { exists, readJson } from '../lib/files.js' + +export async function status() { + const config = await loadConfig() + const path = join(config.htDir, 'plan/tasklist.json') + if (!(await exists(path))) { + console.log('尚未產生 TaskList') + return + } + const taskList = await readJson(path) + const counts = Object.groupBy(taskList.tasks, (task) => task.status) + for (const state of ['pending', 'running', 'done', 'error']) { + console.log(`${state}: ${counts[state]?.length ?? 0}`) + } +} + diff --git a/src/stages/verify.js b/src/stages/verify.js new file mode 100644 index 0000000..f8901a0 --- /dev/null +++ b/src/stages/verify.js @@ -0,0 +1,46 @@ +import { spawn } from 'node:child_process' +import { join } from 'node:path' +import { loadConfig } from '../lib/config.js' +import { exists, listFiles, readJson, writeJson } from '../lib/files.js' + +export async function verify() { + const config = await loadConfig() + const quality = {} + for (const [name, command] of Object.entries(config.project.qualityCommands ?? {})) { + quality[name] = await runQualityCommand(command, config.cwd) + } + const domChecks = await runDomChecks(config) + const flowChecks = { status: 'skipped', reason: 'no executable interaction model in MVP artifacts' } + const report = { generatedAt: new Date().toISOString(), quality, domChecks, flowChecks } + await writeJson(join(config.htDir, 'verify/verification-report.json'), report) + console.log(`verify report: ${join(config.htDir, 'verify/verification-report.json')}`) +} + +async function runQualityCommand(command, cwd) { + if (!command) return { status: 'skipped', reason: 'command not configured' } + const [bin, ...args] = command.split(/\s+/) + return new Promise((resolve) => { + const child = spawn(bin, args, { cwd, stdio: 'pipe' }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk) => { stdout += chunk }) + child.stderr.on('data', (chunk) => { stderr += chunk }) + child.on('error', (error) => resolve({ status: 'skipped', reason: error.message })) + child.on('close', (code) => resolve({ + status: code === 0 ? 'passed' : 'failed', + exitCode: code, + stdoutSummary: stdout.slice(0, 1000), + stderrSummary: stderr.slice(0, 1000) + })) + }) +} + +async function runDomChecks(config) { + const taskListPath = join(config.htDir, 'plan/tasklist.json') + if (!(await exists(taskListPath))) return { status: 'skipped', reason: 'TaskList missing' } + const taskList = await readJson(taskListPath) + const outputFiles = await listFiles(config.outputDir, ['.vue']) + const missing = taskList.tasks.filter((task) => !outputFiles.some((file) => file.endsWith(task.targetFile))) + return { status: missing.length === 0 ? 'passed' : 'failed', missingTargets: missing.map((task) => task.targetFile) } +} + diff --git a/test/cli-e2e.test.js b/test/cli-e2e.test.js new file mode 100644 index 0000000..e322e07 --- /dev/null +++ b/test/cli-e2e.test.js @@ -0,0 +1,48 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { mkdtemp } from 'node:fs/promises' +import { promisify } from 'node:util' + +const exec = promisify(execFile) +const cli = new URL('../src/cli.js', import.meta.url).pathname + +test('CLI runs scan, plan, run, diff, and verify against one prototype', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'ht-e2e-')) + await mkdir(join(cwd, 'packages/prototype'), { recursive: true }) + await mkdir(join(cwd, 'packages/web/src'), { recursive: true }) + await mkdir(join(cwd, 'packages/api'), { recursive: true }) + await writeFile(join(cwd, 'packages/prototype/index.html'), ` +
+

Customer Portal

+
+ + + +
+
+ `) + await writeFile(join(cwd, 'packages/web/src/App.vue'), '') + await writeFile(join(cwd, 'packages/api/openapi.json'), '{"openapi":"3.0.0","paths":{}}') + await writeFile(join(cwd, 'ht.config.js'), ` + export default { + plan: { interactiveReview: false }, + project: { qualityCommands: {} } + } + `) + + for (const command of ['scan', 'plan', 'run', 'diff', 'verify']) { + await exec('node', [cli, command], { cwd }) + } + + const component = await readFile(join(cwd, 'packages/result/index/main-1.vue'), 'utf8') + const report = JSON.parse(await readFile(join(cwd, '.ht/verify/verification-report.json'), 'utf8')) + + assert.match(component, /Customer Portal/) + assert.match(component, /v-text-field/) + assert.equal(report.domChecks.status, 'passed') +}) + diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..32da1d2 --- /dev/null +++ b/test/config.test.js @@ -0,0 +1,26 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { loadConfig } from '../src/lib/config.js' + +test('loadConfig supports ht.config.ts defineConfig shape', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'ht-config-')) + await writeFile(join(cwd, 'ht.config.ts'), ` + import { defineConfig } from 'html-transform' + export default defineConfig({ + prototype: './proto', + output: './out', + plan: { interactiveReview: true } + }) + `) + + const config = await loadConfig(cwd) + + assert.equal(config.prototypeDir, join(cwd, 'proto')) + assert.equal(config.outputDir, join(cwd, 'out')) + assert.equal(config.plan.interactiveReview, true) + assert.equal(config.project.qualityCommands.lint, 'pnpm lint') +}) + diff --git a/test/html.test.js b/test/html.test.js new file mode 100644 index 0000000..0b6b3ed --- /dev/null +++ b/test/html.test.js @@ -0,0 +1,35 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { extractRegions, inferRegionSpec, summarizeHtml } from '../src/lib/html.js' + +test('summarizeHtml extracts user-visible contract evidence', () => { + const summary = summarizeHtml(` + Orders +
+

Orders

+
+
+ `) + + assert.equal(summary.title, 'Orders') + assert.deepEqual(summary.labels, ['Email']) + assert.deepEqual(summary.buttons, ['Save']) + assert.equal(summary.inputs[0].name, 'email') + assert.equal(summary.inputs[0].required, true) +}) + +test('extractRegions falls back to a single page region', () => { + const regions = extractRegions('

Hello

World

') + + assert.equal(regions.length, 1) + assert.equal(regions[0].id, 'page-1') +}) + +test('inferRegionSpec maps forms to VForm', () => { + const [region] = extractRegions('
') + const spec = inferRegionSpec(region) + + assert.equal(spec.vuetifyComponent, 'VForm') + assert.deepEqual(spec.uiContract.primaryActions, ['Search']) +}) +