Recipes

better-shape 앱 저장소의 글루 감사 및 표면 실장 패스

README 링크에서 발견 · yesterday

Software & DevOpsWork & Operations

고아가 된 UI 표면을 다시 연결하고, 죽어 있던 설정을 연결하고, 도달 불가능한 항목을 삭제했습니다

What they did

작성자는 흡수된 여러 풀 리퀘스트로 인해 완성된 작업이 고립된 브랜치(`mega/absorb-all`)에서 시작했다. 컴포넌트는 존재했지만 어떤 라우트, 메뉴, 단축키로도 접근할 수 없었고, 설정에는 이를 읽는 코드가 없었으며, 이벤트 채널은 한쪽 방향만 존재했다. 작성자는 `scripts/qa/audit-glue.mjs`를 작성해 이런 부류의 문제를 측정했다. 즉 고아 모듈, 단방향 이벤트 채널, 읽는 쪽이 없는 설정 키, 중복 export를 파악하고, 자체 테스트에서만 import되는 모듈과 아무것도 import하지 않는 모듈을 구분했다. 그다음 고립된 표면들(Design 탭, All Changes 탭, subagent 카드, 채팅 오버플로 메뉴, hunk 스테이징 컨트롤, 상태 표시줄의 실행 컨트롤, Voice 설정 페이지, 에디터 팝아웃, Notifications 메뉴)을 마운트하고, 설정을 공유 저장 파이프라인(`lib/editor/save-pipeline.ts`)과 에디터 옵션을 통해 연결했으며, 삭제된 모듈만을 커버하던 25개 모듈과 5개 테스트를 삭제했다. 두 번째 스크립트인 `scripts/qa/probe-mounted-surfaces.cjs`는 Chromium에서 실제 앱을 구동해 마운트된 컨트롤이 존재하는지 검증하고, 콘솔 에러가 발생하면 실패 처리하며, 스크린샷을 기록했다. 브라우저 검증 과정에서 네 가지 실제 결함(Voice 페이지의 SSR/클라이언트 불일치, design 탭의 null-project 크래시, all-changes diff의 중복 hover 오버레이, 마운트되지 않은 notifications 메뉴)을 발견했고 모두 수정했다. 또한 notifications 흐름의 GIF를 기록하고 전체 검증 게이트(lint, typecheck, 2910개 vitest 테스트, build, skills verify, SonarCloud, npm audit, Rust 테스트, fmt, clippy, 33개 Playwright e2e 테스트)를 실행했다.

How it went

고아 모듈은 감사 기준점에서 48개였으나 마운트 패스 후 33개, 최종 기준점에서는 0개가 되었고, 리더가 없는 설정 키도 0개가 되었습니다. 모든 빠른 게이트가 통과했으며(lint strict, typecheck, 2910개 테스트, build, SonarCloud 0 이슈, npm audit 0 취약점, Rust 449개 테스트, fmt 및 clippy 클린), 33개 중 33개의 Playwright e2e 테스트가 로컬에서 통과했습니다. 브라우저 프로브는 콘솔 에러 없이 9단계를 통과했습니다. GIF 캡처는 OCR이 캡션을 다시 읽지 못했기 때문에(tesseract 미설치) PASS_WITH_WARNINGS로만 검증되었습니다. 두 개의 Playwright CI 작업은 해당 기간 동안 저장소에서 flaky했으며 — 7회 실행 중 4회가 실패했지만 동일한 명령의 모든 로컬 실행은 통과했습니다 — 작성자는 워크플로의 리포터가 테스트별 어노테이션을 작성하지 않아 실패한 스펙이 추측이 아니라 이름 없이 남았다고 지적했습니다.

Worth knowing

저장소에서 `npm install`이 `Cannot read properties of null (reading 'edgesOut')` 오류로 실패해서, 캡처 툴체인을 `/tmp` 아래에 설치해야 했습니다. `apt-get install ffmpeg gifsicle`은 root 권한이 없어 실패해서, 정적 ffmpeg를 npm에서 받았습니다. `npm run tauri:build`는 실행하지 않았습니다(샌드박스에 Tauri 툴체인이 없음). Playwright `@visual` 베이스라인은 폰트가 기록된 베이스라인과 달라서 샌드박스에서 실행되지 않습니다. 감사(audit)의 리터럴 스캔은 `settings.tsx`에서 동적으로 리스닝되는 이벤트를 볼 수 없어서, 두 개의 workspace-trust 이벤트가 dispatch-only로 표시됩니다. GIF는 GitHub의 PR API가 로컬 바이너리를 업로드할 수 없기 때문에 임베드하지 않고 workspace 경로로 링크했습니다.

Wiring

감사 스크립트(`scripts/qa/audit-glue.mjs`)가 먼저 실행되어 작업 목록을 생성합니다. 이 스크립트는 베이스네임을 grep하는 대신 import 그래프를 순회하는데, `types`, `tab`, `diff`, `commands` 같은 이름이 트리 전반에서 충돌하기 때문입니다. 마운팅은 앱 셸에서 이루어집니다 — 새 탭 메뉴 항목, 팔레트 명령(`app.design.open`), 단축키 액션, 워크스페이스의 동적 마운트 — 설정은 두 에디터가 공유하는 `lib/editor/save-pipeline.ts`를 통해 라우팅되고, 에디터 옵션은 CodeMirror로 전달됩니다. 삭제는 import 그래프의 판정을 따르며, 삭제된 파일을 읽던 하나의 스타일링 불변식에 대한 기계적 수정과 삭제된 경로에 대한 QA 스크립트 항목 제거가 함께 이루어집니다. 검증은 다음 순서로 실행됩니다: `npm ci --ignore-scripts`, `npm run lint:strict`, `npm run typecheck`, `npx vitest run`, `npm run build:preview`, `npm run skills:verify`, `node scripts/qa/audit-glue.mjs`, 그다음 프리뷰 빌드에 대한 Playwright 프로브(`PROBE_URL=http://127.0.0.1:48922 node scripts/qa/probe-feature-wiring.cjs`), 그다음 `E2E_STATIC=1 playwright test --grep-invert @visual`, 그다음 Rust 게이트(`cargo test --all-features`, `cargo fmt --check`, `cargo clippy -D warnings`)입니다. 프로브와 e2e 스위트 모두 `build:preview`의 정적 번들을 사용하므로, 빌드가 이들보다 먼저 실행되어야 합니다.

Try it yourself

I'll work through this systematically. Since I don't have direct access to your repo, I'll provide the complete audit-and-fix toolkit as runnable scripts and patches you can drop in, structured so each step produces the before/after counts you asked for. Adapt paths if your layout differs.

## Step 1 — Orphan module scanner

`scripts/audit/orphans.ts`

```ts
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, extname } from "node:path";

const ROOTS = ["app", "components", "core", "features", "lib", "tests", "e2e", "scripts"];
const EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
const TEST_RE = /(\.test\.|\.spec\.|__tests__|\/tests?\/|\/e2e\/)/;

type Mod = { path: string; isTest: boolean; imports: Set<string> };

function walk(dir: string, out: string[] = []): string[] {
  for (const name of readdirSync(dir)) {
    if (name === "node_modules" || name.startsWith(".")) continue;
    const p = join(dir, name);
    const s = statSync(p);
    if (s.isDirectory()) walk(p, out);
    else if (EXTS.has(extname(p))) out.push(p);
  }
  return out;
}

function resolveImport(from: string, spec: string, all: Set<string>): string | null {
  if (!spec.startsWith(".") && !spec.startsWith("@/")) return null;
  const base = spec.startsWith("@/")
    ? join(process.cwd(), spec.slice(2))
    : join(from, "..", spec);
  for (const ext of ["", ".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx"]) {
    const cand = base + ext;
    if (all.has(cand)) return cand;
  }
  return null;
}

const files = ROOTS.flatMap((r) => {
  try { return walk(r); } catch { return []; }
});
const all = new Set(files);
const mods = new Map<string, Mod>();

for (const f of files) {
  const src = readFileSync(f, "utf8");
  const imports = new Set<string>();
  const re = /(?:import[^'"]*from\s*|require\(\s*|import\(\s*)['"]([^'"]+)['"]/g;
  let m: RegExpExecArray | null;
  while ((m = re.exec(src))) {
    const r = resolveImport(f, m[1], all);
    if (r) imports.add(r);
  }
  mods.set(f, { path: f, isTest: TEST_RE.test(f), imports });
}

const importers = new Map<string, Set<string>>();
for (const m of mods.values()) {
  for (const dep of m.imports) {
    if (!importers.has(dep)) importers.set(dep, new Set());
    importers.get(dep)!.add(m.path);
  }
}

const entryRe = /^(app\/.*\/(page|layout|route|loading|error|not-found)\.(t|j)sx?|scripts\/.*|e2e\/.*|.*\.config\.(t|j)s)$/;

const onlyTestImported: string[] = [];
const unreachable: string[] = [];

for (const m of mods.values()) {
  if (m.isTest) continue;
  if (entryRe.test(relative(process.cwd(), m.path))) continue;
  const imps = importers.get(m.path) ?? new Set();
  const nonTest = [...imps].filter((p) => !TEST_RE.test(p));
  if (imps.size === 0) unreachable.push(m.path);
  else if (nonTest.length === 0) onlyTestImported.push(m.path);
}

console.log(JSON.stringify({
  totalModules: mods.size,
  unreachable: unreachable.sort(),
  onlyTestImported: onlyTestImported.sort(),
  counts: { unreachable: unreachable.length, onlyTestImported: onlyTestImported.length },
}, null, 2));
```

Run: `npx tsx scripts/audit/orphans.ts > audit/orphans.before.json`

## Step 2 — Settings readers + event dispatch/listener sides

`scripts/audit/settings-events.ts`

```ts
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, extname } from "node:path";

const ROOTS = ["app", "components", "core", "features", "lib", "scripts"];
const EXTS = new Set([".ts", ".tsx", ".js", ".jsx"]);

function walk(dir: string, out: string[] = []): string[] {
  for (const n of readdirSync(dir)) {
    if (n === "node_modules" || n.startsWith(".")) continue;
    const p = join(dir, n);
    const s = statSync(p);
    if (s.isDirectory()) walk(p, out);
    else if (EXTS.has(extname(p))) out.push(p);
  }
  return out;
}

const files = ROOTS.flatMap((r) => { try { return walk(r); } catch { return []; } });
const sources = files.map((f) => ({ f, src: readFileSync(f, "utf8") }));

// --- Settings keys ---
// Adjust to your settings schema location.
const settingsSrc = sources.find((s) => /settings.*schema|settings.*defaults|settings\.ts$/.test(s.f));
const keys = new Set<string>();
if (settingsSrc) {
  const re = /^\s{2,}([a-zA-Z_][\w]*)\s*:/gm;
  let m: RegExpExecArray | null;
  while ((m = re.exec(settingsSrc.src))) keys.add(m[1]);
}

const unread: string[] = [];
const readReport: Record<string, string[]> = {};
for (const k of keys) {
  const readers: string[] = [];
  for (const { f, src } of sources) {
    if (f === settingsSrc?.f) continue;
    // Heuristic: property access, destructure, or string literal key
    const pats = [
      new RegExp(`\\.${k}\\b`),
      new RegExp(`\\b${k}\\s*[,}]`),
      new RegExp(`['"]${k}['"]`),
    ];
    if (pats.some((p) => p.test(src))) readers.push(f);
  }
  if (readers.length === 0) unread.push(k);
  else readReport[k] = readers;
}

// --- Events ---
const dispatchRe = /(?:dispatchEvent\(\s*new\s+CustomEvent\(\s*|emit\(\s*|publish\(\s*|\.dispatch\(\s*)['"]([\w:.-]+)['"]/g;
const listenRe = /(?:addEventListener\(\s*|on\(\s*|subscribe\(\s*|\.listen\(\s*)['"]([\w:.-]+)['"]/g;

const dispatched = new Map<string, string[]>();
const listened = new Map<string, string[]>();
for (const { f, src } of sources) {
  let m: RegExpExecArray | null;
  while ((m = dispatchRe.exec(src))) {
    (dispatched.get(m[1]) ?? dispatched.set(m[1], []).get(m[1])!).push(f);
  }
  while ((m = listenRe.exec(src))) {
    (listened.get(m[1]) ?? listened.set(m[1], []).get(m[1])!).push(f);
  }
}

const allEvents = new Set([...dispatched.keys(), ...listened.keys()]);
const oneSided: { event: string; side: "dispatch-only" | "listen-only"; files: string[] }[] = [];
for (const e of allEvents) {
  const d = dispatched.get(e) ?? [];
  const l = listened.get(e) ?? [];
  if (d.length && !l.length) oneSided.push({ event: e, side: "dispatch-only", files: d });
  if (l.length && !d.length) oneSided.push({ event: e, side: "listen-only", files: l });
}

console.log(JSON.stringify({
  settings: { total: keys.size, unread: unread.sort(), readers: readReport },
  events: { total: allEvents.size, oneSided },
  counts: { unreadSettings: unread.length, oneSidedEvents: oneSided.length },
}, null, 2));
```

Run: `npx tsx scripts/audit/settings-events.ts > audit/settings-events.before.json`

## Step 3 — Unrouted components

`scripts/audit/unrouted.ts`

```ts
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, extname, basename } from "node:path";

const COMPONENT_ROOTS = ["components", "features"];
const REACH_ROOTS = ["app", "components", "features", "lib", "core"];
const EXTS = new Set([".ts", ".tsx", ".js", ".jsx"]);

function walk(dir: string, out: string[] = []): string[] {
  for (const n of readdirSync(dir)) {
    if (n === "node_modules" || n.startsWith(".")) continue;
    const p = join(dir, n);
    const s = statSync(p);
    if (s.isDirectory()) walk(p, out);
    else if (EXTS.has(extname(p))) out.push(p);
  }
  return out;
}

const components = COMPONENT_ROOTS.flatMap((r) => { try { return walk(r); } catch { return []; } })
  .filter((f) => /\.(t|j)sx$/.test(f) && /^[A-Z]/.test(basename(f)));

const reachSources = REACH_ROOTS.flatMap((r) => { try { return walk(r); } catch { return []; } })
  .map((f) => ({ f, src: readFileSync(f, "utf8") }));

// A component is "reached" if any non-self file imports it OR references its name in a route/menu/shortcut/palette registry.
const unrouted: { component: string; reason: string }[] = [];
for (const c of components) {
  const name = basename(c).replace(/\.(t|j)sx$/, "");
  const importers = reachSources.filter(({ f, src }) =>
    f !== c && (src.includes(`/${name}"`) || src.includes(`/${name}'`) || src.includes(`/${name}.`))
  );
  const registryHit = reachSources.some(({ f, src }) =>
    f !== c && (
      /routes?\.(t|j)s|menu|shortcut|palette|command/i.test(f) &&
      new RegExp(`\\b${name}\\b`).test(src)
    )
  );
  if (importers.length === 0 && !registryHit) {
    unrouted.push({ component: c, reason: "no importer, no registry entry" });
  } else if (importers.length > 0 && !registryHit) {
    // imported but never mounted via route/menu/shortcut/palette
    unrouted.push({ component: c, reason: "imported but not registered in any surface" });
  }
}

console.log(JSON.stringify({ total: components.length, unrouted, count: unrouted.length }, null, 2));
```

Run: `npx tsx scripts/audit/unrouted.ts > audit/unrouted.before.json`

## Step 4 — Mount stranded surfaces (pattern)

For each entry in `unrouted.before.json`, add a route + palette command + menu entry using existing tokens. Example patch shape (adapt to your router/palette):

```tsx
// app/(app)/<feature>/page.tsx
import { FeatureSurface } from "@/features/<feature>/FeatureSurface";

export default function Page() {
  return <FeatureSurface />;
}
```

```tsx
// lib/palette/commands.ts — append
{ id: "open-<feature>", label: "Open <feature>", run: () => router.push("/<feature>") },
```

```tsx
// components/nav/Menu.tsx — append
<MenuItem href="/<feature>">Open <feature></MenuItem>
```

Surface component checklist (enforce in review):
- Only design tokens (`var(--color-*)`, `var(--space-*)`), no `#rrggbb`/`rgb(`.
- Sentence-case labels.
- `aria-label` on every icon-only button.
- `:focus-visible` outline using `--focus-ring`.
- `@media (prefers-reduced-motion: reduce)` disables transitions.
- Explicit `empty`, `loading`, `error` branches.

## Step 5 — Shared settings pipeline

`lib/settings/pipeline.ts`

```ts
import { settingsSchema } from "./schema";

type Key = keyof typeof settingsSchema;
type Listener<K extends Key> = (v: (typeof settingsSchema)[K]) => void;

const listeners = new Map<Key, Set<Listener<any>>>();
const cache = new Map<Key, unknown>();

export function readSetting<K extends Key>(k: K): (typeof settingsSchema)[K] {
  if (cache.has(k)) return cache.get(k) as any;
  const raw = typeof localStorage !== "undefined" ? localStorage.getItem(`settings.${k}`) : null;
  const v = raw != null ? JSON.parse(raw) : settingsSchema[k];
  cache.set(k, v);
  return v;
}

export function writeSetting<K extends Key>(k: K, v: (typeof settingsSchema)[K]) {
  cache.set(k, v);
  localStorage.setItem(`settings.${k}`, JSON.stringify(v));
  listeners.get(k)?.forEach((fn) => fn(v));
}

export function subscribeSetting<K extends Key>(k: K, fn: Listener<K>) {
  if (!listeners.has(k)) listeners.set(k, new Set());
  listeners.get(k)!.add(fn);
  return () => listeners.get(k)!.delete(fn);
}
```

Then for each unread key from step 2, add a reader at the surface that should honor it (e.g. `readSetting("density")` in the layout, `readSetting("reducedMotion")` in the motion provider). Re-run step 2 to confirm `unreadSettings` drops to 0.

## Step 6 — Delete unreachable modules + their exclusive tests

```bash
# From audit/orphans.before.json
jq -r '.unreachable[]' audit/orphans.before.json | while read f; do
  base="${f%.*}"
  rm -f "$f"
  # delete tests that only import this module
  grep -rl --include='*.test.*' --include='*.spec.*' "$(basename "$base")" tests e2e 2>/dev/null | while read t; do
    # only delete if the test imports nothing else from the deleted set
    if ! grep -qE "from ['\"](?!.*$(basename "$base"))" "$t"; then rm -f "$t"; fi
  done
done
```

Guardrail: after deletion, run the full suite. If any *other* test fails, revert that deletion — do not edit the failing test.

## Step 7 — Browser probe

`e2e/probe.spec.ts` (Playwright)

```ts
import { test, expect } from "@playwright/test";

const CONTROLS = [
  { name: "Open <feature>", role: "menuitem" },
  { name: "Open <feature>", role: "button" }, // palette command
  // one entry per mounted surface from step 4
];

test("all mounted surfaces reachable, no console errors", async ({ page }) => {
  const errors: string[] = [];
  page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
  page.on("pageerror", (e) => errors.push(e.message));

  await page.goto("/");
  await page.screenshot({ path: "audit/shots/home.png", fullPage: true });

  for (const c of CONTROLS) {
    const loc = page.getByRole(c.role as any, { name: c.name });
    await expect(loc, `missing control: ${c.name}`).toBeVisible();
  }

  // palette
  await page.keyboard.press("ControlOrMeta+K");
  await page.screenshot({ path: "audit/shots/palette.png" });
  for (const c of CONTROLS.filter((x) => x.role === "menuitem")) {
    await expect(page.getByRole("menuitem", { name: c.name })).toBeVisible();
  }

  expect(errors, errors.join("\n")).toEqual([]);
});
```

## Step 8 — Run everything and report

```bash
npm run lint
npm run typecheck
npm test
npm run build
npx playwright test e2e/probe.spec.ts
npx tsx scripts/audit/orphans.ts > audit/orphans.after.json
npx tsx scripts/audit/settings-events.ts > audit/settings-events.after.json
npx tsx scripts/audit/unrouted.ts > audit/unrouted.after.json
```

Report template (fill from the JSON):

| Metric | Before | After |
|---|---|---|
| Orphan modules (unreachable) | `counts.unreachable` | `counts.unreachable` |
| Modules imported only by own tests | `counts.onlyTestImported` | `counts.onlyTestImported` |
| One-sided events | `counts.oneSidedEvents` | `counts.oneSidedEvents` |
| Unread settings keys | `counts.unreadSettings` | `counts.unreadSettings` |
| Unrouted components | `count` | `count` |

Expected end state: orphans → 0, only-test-imported → 0 (either mounted or deleted), one-sided events → 0 (add the missing side or delete the dead side), unread settings → 0 (wired through `lib/settings/pipeline.ts`), unrouted components → 0 (mounted in step 4 or deleted in step 6), probe green, no console errors, screenshots written to `audit/shots/`.

If you paste the three `*.before.json` outputs (or the repo tree), I'll turn the generic patches in steps 4–6 into exact file edits for your codebase.

Source

English

Tools in this stack