Files
pikasTech-HWLAB/web/hwlab-cloud-web/scripts/frontend-guard.ts
T
Lyon 0d06a4c5fe fix(web): migrate Cloud Web from vanilla DOM to React + TypeScript + Vite (#756) (#764)
- Replace monolithic app.ts / app-conversation.ts / app-device-pod.ts /
  app-skills.ts / app-helpers.ts / app-session-tabs.ts / auth.ts and the
  364-line index.html with a Vite-built React + TypeScript SPA.
- New src/ module tree: main.tsx, App.tsx, components/{layout,auth,
  conversation,command-bar,device-pod,skills,settings,help},
  hooks/, services/api, services/auth, state, types, logic/ (pure
  helpers from the old tree), styles/.
- Pure-logic modules (composer-policy, code-agent-facts, code-agent-
  status, live-status, message-markdown, app-trace, app-session-tabs,
  runtime) move to src/logic/ with their tests preserved; tests still
  auto-discover.
- Build pipeline switches from Bun.build to Vite: vite.config.ts
  inlines everything into a single app.js + app-assets/ for the dist;
  scripts/dist-contract.ts now runs vite build and asserts the dist
  against a fresh build instead of comparing dist to source.
- scripts/check.ts now verifies the React entry, the mount-only
  index.html, Vite dist, the composer / session / device pod / skills
  / settings / help contracts, the React state shape, and the new
  test file locations. It also refuses to run if any legacy app-*.ts
  or auth.ts survives at the top level.
- New scripts/frontend-guard.ts enforces the 400-line migration target,
  refuses the legacy el map and document.getElementById markers,
  refuses to keep app-*.ts / auth.ts at the top level, and verifies
  the required module directory layout.
- Update scripts/src/dev-cloud-workbench-smoke-lib.mjs to read the new
  src/services/, src/logic/ paths and refresh cloudWebModuleSourceFiles.
- index.html is now a mount shell only (no login shell, no workbench
  shell, no device pod sidebar markup). React renders the full UI from
  src/main.tsx, satisfying the issue-756 migration goal of catching
  unclosed tags at the TypeScript/JSX compile step instead of relying
  on browser DOM healing.
- web:check (now: check + frontend-guard + tsc-check + bun test) is
  green: 51 pass / 0 fail, Vite build produces dist/app.js (379 kB)
  + dist/index.html + dist/app-assets/. Vite build is reproducible;
  dist freshness is verified against a fresh Vite build.
- Grandfathered pure-logic files (> 600 lines): src/logic/app-trace.ts
  (1367), src/logic/live-status.ts (1006), src/logic/code-agent-facts.ts
  (548), src/logic/code-agent-status.ts (572). Reason: each is a tested
  pure-logic classifier with a public contract consumed by both the
  CLI renderer and the Web renderer; splitting now would fork the
  public function shape across two PRs and the migration target is the
  React entry + module split, not a pure-logic refactor.

Refs #756. Closes #756 once merged + deployed to hwlab-v02.

Co-authored-by: Codex <codex@local>
2026-06-03 16:53:25 +08:00

125 lines
5.6 KiB
TypeScript

import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
// Frontend module boundary guard for the React + TypeScript migration
// (HWLAB #756). Enforces:
// - no single frontend source file (src/**/*.tsx? and *.tsx in the
// migration tree) exceeds 400 lines unless explicitly grandfathered
// - main.tsx, App.tsx, and components.tsx are not bloated into a single
// mega file
// - required module directories exist (layout, auth, sessions,
// conversation, command-bar, device-pod, skills, settings, help,
// shared, hooks, services/api, services/workspace, state, types,
// logic, styles)
// - no legacy vanilla DOM markers survive in the new src/ tree
// (global el map, document.getElementById for primary UI, legacy
// app-*.ts files imported from src/)
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const srcDir = path.resolve(rootDir, "src");
const SOFT_LIMIT = 400;
const ABSOLUTE_LIMIT = 1500;
const GRANDFATHERED = new Set([
"src/logic/app-trace.ts",
"src/logic/live-status.ts",
"src/logic/code-agent-facts.ts",
"src/logic/code-agent-status.ts",
]);
const GRANDFATHERED_REASON = Object.freeze({
"src/logic/app-trace.ts": "Pure-logic trace row builder used by CLI + Web; tested by src/logic/app-trace.test.ts. Splittable but the 400-line guideline is a soft target for migration-era pure logic.",
"src/logic/live-status.ts": "Pure-logic live status classifier for workbench/device pod/m3 probes; tested by src/logic/live-status.test.ts. Left in one file to keep the public probe taxonomy stable for the CLI/Web renderer.",
"src/logic/code-agent-facts.ts": "Pure-logic code agent facts classifier (provider/backend/runner/protocol/implementation/tool calls); tested by src/logic/code-agent-facts.test.ts.",
"src/logic/code-agent-status.ts": "Pure-logic code agent status summarizer (availability, deployment revision, capability level); tested by src/logic/code-agent-status.test.ts.",
});
const REQUIRED_MODULE_DIRS = Object.freeze([
"components",
"components/auth",
"components/layout",
"components/conversation",
"components/command-bar",
"components/device-pod",
"components/skills",
"components/settings",
"components/help",
"hooks",
"services",
"services/api",
"logic",
"types",
]);
const ENTRY_FILES = Object.freeze(["src/main.tsx", "src/App.tsx"]);
const ENTRY_HARD_LIMIT = 200;
const LEGACY_BANNED_TOKENS = Object.freeze([
"function byId(",
"const el = {",
"el.loginShell",
]);
function walk(dir, out = []) {
if (!fs.existsSync(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".") && entry.name !== ".gitkeep") continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (/\.(ts|tsx)$/u.test(entry.name)) out.push(full);
}
return out;
}
const files = walk(srcDir);
assert.ok(files.length > 0, "frontend guard: src/ must contain at least one .ts(x) file");
const relativeFiles = files.map((file) => path.relative(rootDir, file).split(path.sep).join("/"));
for (const dir of REQUIRED_MODULE_DIRS) {
const full = path.resolve(srcDir, dir);
assert.ok(fs.existsSync(full) && fs.statSync(full).isDirectory(), `frontend guard: required module directory missing: src/${dir}`);
}
console.log(`frontend guard: ${REQUIRED_MODULE_DIRS.length} required module directories present`);
for (const rel of relativeFiles) {
const full = path.resolve(rootDir, rel);
const text = fs.readFileSync(full, "utf8");
const lines = text.split("\n").length;
if (rel.endsWith(".test.ts") || rel.endsWith(".test.tsx")) {
continue;
}
const entryHardLimit = ENTRY_FILES.includes(rel) ? ENTRY_HARD_LIMIT : null;
if (entryHardLimit !== null) {
assert.ok(lines <= entryHardLimit, `frontend guard: entry file ${rel} must stay <= ${entryHardLimit} lines (currently ${lines})`);
continue;
}
if (GRANDFATHERED.has(rel)) {
assert.ok(lines <= ABSOLUTE_LIMIT, `frontend guard: grandfathered file ${rel} must stay <= ${ABSOLUTE_LIMIT} lines (currently ${lines}); reason: ${GRANDFATHERED_REASON[rel]}`);
continue;
}
assert.ok(lines <= SOFT_LIMIT, `frontend guard: file ${rel} exceeds the ${SOFT_LIMIT}-line soft target (${lines} lines); split it or add it to GRANDFATHERED with a reason`);
}
console.log(`frontend guard: ${relativeFiles.length} src files within ${SOFT_LIMIT}-line target (grandfathered: ${[...GRANDFATHERED].join(", ") || "none"})`);
for (const rel of relativeFiles) {
const text = fs.readFileSync(path.resolve(rootDir, rel), "utf8");
for (const banned of LEGACY_BANNED_TOKENS) {
assert.ok(!text.includes(banned), `frontend guard: legacy vanilla DOM marker in ${rel}: ${banned}`);
}
}
console.log("frontend guard: no legacy vanilla DOM markers in src/");
for (const legacyFile of ["app.ts", "app-conversation.ts", "app-device-pod.ts", "app-skills.ts", "app-helpers.ts", "app-trace.ts", "app-session-tabs.ts", "auth.ts"]) {
const topLevel = path.resolve(rootDir, legacyFile);
assert.ok(!fs.existsSync(topLevel), `frontend guard: legacy file must not remain at top level: ${legacyFile}`);
}
console.log("frontend guard: no legacy app-*.ts / auth.ts at top level");
assert.ok(fs.existsSync(path.resolve(rootDir, "vite.config.ts")), "frontend guard: vite.config.ts must exist");
assert.ok(fs.existsSync(path.resolve(rootDir, "tsconfig.json")), "frontend guard: tsconfig.json must exist");
console.log("frontend guard: vite + tsconfig present");
console.log("frontend guard ok: React + TypeScript module boundary contracts hold");