@momentiq/dark-factory-cli 3.0.2 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +3 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/mode.d.ts.map +1 -1
  5. package/dist/commands/mode.js +120 -22
  6. package/dist/commands/mode.js.map +1 -1
  7. package/dist/commands/skills.d.ts.map +1 -1
  8. package/dist/commands/skills.js +11 -1
  9. package/dist/commands/skills.js.map +1 -1
  10. package/dist/commands/verify.d.ts.map +1 -1
  11. package/dist/commands/verify.js +113 -8
  12. package/dist/commands/verify.js.map +1 -1
  13. package/dist/mcp/tools/skills-install.d.ts.map +1 -1
  14. package/dist/mcp/tools/skills-install.js +8 -3
  15. package/dist/mcp/tools/skills-install.js.map +1 -1
  16. package/dist/mode/critic-registration.d.ts +30 -10
  17. package/dist/mode/critic-registration.d.ts.map +1 -1
  18. package/dist/mode/critic-registration.js +31 -10
  19. package/dist/mode/critic-registration.js.map +1 -1
  20. package/dist/mode/guard.d.ts.map +1 -1
  21. package/dist/mode/guard.js +30 -2
  22. package/dist/mode/guard.js.map +1 -1
  23. package/dist/mode/init.d.ts +8 -4
  24. package/dist/mode/init.d.ts.map +1 -1
  25. package/dist/mode/init.js +148 -12
  26. package/dist/mode/init.js.map +1 -1
  27. package/dist/mode/preview-capture.d.ts +3 -0
  28. package/dist/mode/preview-capture.d.ts.map +1 -0
  29. package/dist/mode/preview-capture.js +137 -0
  30. package/dist/mode/preview-capture.js.map +1 -0
  31. package/dist/mode/preview.d.ts +227 -0
  32. package/dist/mode/preview.d.ts.map +1 -0
  33. package/dist/mode/preview.js +802 -0
  34. package/dist/mode/preview.js.map +1 -0
  35. package/dist/onboard/seeders/design-md.d.ts.map +1 -1
  36. package/dist/onboard/seeders/design-md.js +512 -17
  37. package/dist/onboard/seeders/design-md.js.map +1 -1
  38. package/dist/skills/install.d.ts +25 -5
  39. package/dist/skills/install.d.ts.map +1 -1
  40. package/dist/skills/install.js +34 -17
  41. package/dist/skills/install.js.map +1 -1
  42. package/dist/skills/known-skills.d.ts +2 -0
  43. package/dist/skills/known-skills.d.ts.map +1 -0
  44. package/dist/skills/known-skills.js +25 -0
  45. package/dist/skills/known-skills.js.map +1 -0
  46. package/package.json +2 -2
  47. package/skills/designer-build/SKILL.md.tmpl +14 -0
  48. package/skills/designer-handoff/SKILL.md.tmpl +28 -1
  49. package/skills/verify/producer/README.md +29 -4
  50. package/skills/verify/producer/playwright-route.sh +10 -0
  51. package/skills/verify/producer/surface-declaration.ts +373 -0
  52. package/skills/verify/producer/ui-route.producer.spec.ts +44 -11
@@ -0,0 +1,802 @@
1
+ // `df mode preview <component-path>` — component-isolation visual evidence
2
+ // (Closes momentiq-ai/dark-factory#357).
3
+ //
4
+ // THE PAIN. Designer Mode's `evidence.visual: required` mandates a visual loop:
5
+ // boot the app, navigate to the changed surface, screenshot it (the `ui-visual`
6
+ // route). On a container-first / k3d repo the app needs auth + seeded data to
7
+ // boot, and a bare component (a `components/Widget.tsx` with no page that renders
8
+ // it) maps to NO navigable route — so the `ui-visual` route FAILS CLOSED
9
+ // (`planCapture` → `uncovered`) and a NON-ENGINEER cannot produce a screenshot.
10
+ // In the #357 dogfood ~5 avenues to a screenshot all dead-ended here.
11
+ //
12
+ // THE FIX (bounded, first version). Render the ONE component in an isolated
13
+ // harness — no full app, no auth, no data — and capture the SAME evidence shape
14
+ // the `ui-visual` route already emits (`agent-reviews/quality-gates/ui/<sha>/
15
+ // <slug>/{aria.snapshot.txt, after.png, meta.json}`, `evidenceKind:"playwright"`).
16
+ // The existing embed/publish path (`discoverUiEvidence` / `buildScreenshotEmbed`
17
+ // / `readUiArtifacts`) consumes it unchanged. This does NOT by itself create a
18
+ // `QualityGateEvidence` gate result — it produces the artifacts that path renders
19
+ // for a human and uploads.
20
+ //
21
+ // This module is the PURE + orchestration half (no browser, no bundler): harness
22
+ // generation and framework detection are pure and unit-tested; `runPreview` takes
23
+ // an injected `CaptureDeps` so the Vite-server + Playwright wiring
24
+ // (`preview-capture.ts`) is never imported by tests. It degrades gracefully —
25
+ // never crashes — when the component genuinely cannot be rendered (no React / no
26
+ // Vite / a server component) or when the local capture tooling is unavailable.
27
+ //
28
+ // FRAMEWORK BOUNDARY (see the PR body — this is the design edge for PJ + the
29
+ // parallel role-redesign): v1 targets the common case, a React client component
30
+ // rendered through Vite. Vue/Svelte/Angular, Webpack/CRA transform, React Server
31
+ // Components, and a prop-fixture/args model are DEFERRED — each degrades with a
32
+ // clear message rather than a wrong render.
33
+ import { mkdtempSync, readFileSync, existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
34
+ import { isAbsolute, join, relative, resolve, sep, basename, dirname } from "node:path";
35
+ import { resolveCommit } from "../git.js";
36
+ import { FRONTEND_ROOT_DIRS } from "./init.js";
37
+ function packageDeclaresReact(dir) {
38
+ const deps = readPackageDeps(dir);
39
+ return !!deps && "react" in deps;
40
+ }
41
+ /**
42
+ * Resolve the frontend workspace for a repo rooted at `cwd`. Mirrors
43
+ * `df mode init`'s detection (same FRONTEND_ROOT_DIRS): the first of
44
+ * `web/`,`frontend/`,`client/` whose package.json declares react wins; else the
45
+ * repo root itself when ITS package.json declares react. Returns `null` when no
46
+ * react is found anywhere — the caller degrades with the existing no-react
47
+ * blocker (which keeps the honest "install react" message + exit code). PURE.
48
+ */
49
+ export function resolveFrontendWorkspace(cwd) {
50
+ for (const root of FRONTEND_ROOT_DIRS) {
51
+ const dir = join(cwd, root);
52
+ if (packageDeclaresReact(dir))
53
+ return { workspaceDir: dir, relLabel: root };
54
+ }
55
+ if (packageDeclaresReact(cwd))
56
+ return { workspaceDir: cwd, relLabel: "." };
57
+ return null;
58
+ }
59
+ /**
60
+ * Strip line (`//`) and block comments from JSONC, STRING-AWARE: a naive
61
+ * stripper corrupts a value like `"./src/*"` (which contains a literal
62
+ * slash-star) by treating it as the start of a block comment. Tracks string
63
+ * state (and escapes) so only real comments are removed. PURE.
64
+ */
65
+ export function stripJsonComments(text) {
66
+ let out = "";
67
+ let inString = false;
68
+ let inLine = false;
69
+ let inBlock = false;
70
+ for (let i = 0; i < text.length; i++) {
71
+ const c = text[i];
72
+ const next = text[i + 1];
73
+ if (inLine) {
74
+ if (c === "\n") {
75
+ inLine = false;
76
+ out += c;
77
+ }
78
+ continue;
79
+ }
80
+ if (inBlock) {
81
+ if (c === "*" && next === "/") {
82
+ inBlock = false;
83
+ i++;
84
+ }
85
+ continue;
86
+ }
87
+ if (inString) {
88
+ out += c;
89
+ if (c === "\\") {
90
+ // Preserve the escaped char verbatim (e.g. \" \\ ) — it can't close the string.
91
+ if (next !== undefined) {
92
+ out += next;
93
+ i++;
94
+ }
95
+ continue;
96
+ }
97
+ if (c === '"')
98
+ inString = false;
99
+ continue;
100
+ }
101
+ // Outside string + comment.
102
+ if (c === '"') {
103
+ inString = true;
104
+ out += c;
105
+ continue;
106
+ }
107
+ if (c === "/" && next === "/") {
108
+ inLine = true;
109
+ i++;
110
+ continue;
111
+ }
112
+ if (c === "/" && next === "*") {
113
+ inBlock = true;
114
+ i++;
115
+ continue;
116
+ }
117
+ out += c;
118
+ }
119
+ return out;
120
+ }
121
+ /** Tolerant JSONC parse: strip comments (string-aware) + trailing commas, then
122
+ * JSON.parse. Returns `null` on any failure (caller proceeds with no alias). */
123
+ export function parseJsonc(text) {
124
+ try {
125
+ const noComments = stripJsonComments(text);
126
+ // Drop a trailing comma before `}`/`]`. Applied to comment-free text; tsconfig
127
+ // `paths` values are globs (`./src/*`), never `, }`, so this is safe here.
128
+ const noTrailingCommas = noComments.replace(/,(\s*[}\]])/g, "$1");
129
+ return JSON.parse(noTrailingCommas);
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ }
135
+ function escapeRegexLiteral(s) {
136
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
137
+ }
138
+ /**
139
+ * Classify ONE tsconfig `paths` string (a key OR a target) into the shapes an
140
+ * alias can faithfully represent. ONE classifier for BOTH sides — so key and
141
+ * target are judged by the identical rule and there is no third, un-handled case.
142
+ * - no `*` → `exact` (prefix = the whole string).
143
+ * - a single trailing `/*` with a non-empty segment before the `/*`
144
+ * (`"@/*"`, `"./src/*"`, `"./*"`) → `prefix` (prefix = string minus the
145
+ * trailing `/*`, e.g. `"@/*"` → `"@"`, `"./src/*"` → `"./src"`).
146
+ * - everything else — a mid-pattern wildcard (a `*` with more path segments
147
+ * after it, e.g. a `@foo` key with a `bar` suffix, or a monorepo
148
+ * `packages`-style target), a bare prefix (`"@*"`), a bare `"*"` catch-all,
149
+ * or a degenerate `"/*"` (empty prefix) → `unsupported`.
150
+ */
151
+ function classifyGlob(str) {
152
+ const starIdx = str.indexOf("*");
153
+ if (starIdx === -1)
154
+ return { kind: "exact", prefix: str };
155
+ const single = starIdx === str.lastIndexOf("*");
156
+ // Trailing "/*" (str ends with "/*") AND a non-empty segment before it
157
+ // (str.length > 2 rules out the degenerate "/*").
158
+ if (single && str.endsWith("/*") && str.length > 2) {
159
+ return { kind: "prefix", prefix: str.slice(0, -2) };
160
+ }
161
+ return { kind: "unsupported", prefix: "" };
162
+ }
163
+ /**
164
+ * Convert tsconfig `compilerOptions.paths` (+ `baseUrl`) into Vite alias entries.
165
+ * `paths` targets resolve relative to `baseUrl` (default `.` = tsconfigDir).
166
+ * Only the FIRST target is used (Vite alias has no fallback array). PURE.
167
+ *
168
+ * A single `classifyGlob` is applied to BOTH the key and the target, and an entry
169
+ * is emitted ONLY when the two AGREE — so mis-shapen mappings can't leak through
170
+ * on either side:
171
+ * - both `exact` → exact alias (`^<key>$` → resolved target).
172
+ * - both `prefix` → prefix alias (`^<keyPrefix>/` → `<resolvedTargetPrefix>/`).
173
+ * The find RE-ADDS the boundary `/` so it anchors on a path separator: `"@/*"`
174
+ * → `^@/`, which never matches `@scope/pkg` (a bare `^@` WOULD shadow every
175
+ * scoped npm package — the bug this guards).
176
+ * - ANY other combination — either side `unsupported`, or a KIND MISMATCH like a
177
+ * `prefix` key with an `exact` target (`"@/*": ["./src/index.ts"]`, invalid
178
+ * TS) — is SKIPPED: the key is returned in `unsupported`, no alias, no throw.
179
+ *
180
+ * The common shadcn/Next shapes (`"@/*": ["./src/*"]`, `["./*"]`, exact keys) are
181
+ * unaffected; monorepo mid-glob mappings (a `@pkg` key mapped to a
182
+ * `packages`-style target with an inner wildcard) skip.
183
+ */
184
+ export function tsconfigPathsToAlias(input) {
185
+ const { paths, tsconfigDir } = input;
186
+ if (!paths || typeof paths !== "object")
187
+ return { alias: [], unsupported: [] };
188
+ const baseAbs = resolve(tsconfigDir, input.baseUrl ?? ".");
189
+ const alias = [];
190
+ const unsupported = [];
191
+ for (const [key, targets] of Object.entries(paths)) {
192
+ if (!Array.isArray(targets) || targets.length === 0)
193
+ continue;
194
+ const target = targets[0];
195
+ if (typeof target !== "string")
196
+ continue;
197
+ const k = classifyGlob(key);
198
+ const t = classifyGlob(target);
199
+ if (k.kind === "exact" && t.kind === "exact") {
200
+ // Exact key → exact-match alias to the resolved (file/dir) target.
201
+ alias.push({
202
+ find: new RegExp(`^${escapeRegexLiteral(k.prefix)}$`),
203
+ replacement: resolve(baseAbs, t.prefix),
204
+ });
205
+ continue;
206
+ }
207
+ if (k.kind === "prefix" && t.kind === "prefix") {
208
+ // Re-add the boundary "/" to the KEY find (anchors on a path separator so
209
+ // `^@/` never matches `@scope/pkg`) and a trailing sep to the resolved
210
+ // TARGET prefix (the find consumes the source's boundary "/").
211
+ let replacement = resolve(baseAbs, t.prefix); // strips any trailing sep
212
+ if (!replacement.endsWith(sep))
213
+ replacement += sep;
214
+ alias.push({ find: new RegExp(`^${escapeRegexLiteral(k.prefix)}/`), replacement });
215
+ continue;
216
+ }
217
+ // Either side unsupported, OR a key/target kind mismatch → skip + surface.
218
+ unsupported.push(key);
219
+ }
220
+ return { alias, unsupported };
221
+ }
222
+ /** Read compilerOptions.{paths,baseUrl} + `extends` from a tsconfig/jsconfig
223
+ * file (JSONC-tolerant). Returns null when the file is unreadable/unparseable. */
224
+ function readTsconfigFile(filePath) {
225
+ if (!existsSync(filePath))
226
+ return null;
227
+ let raw;
228
+ try {
229
+ raw = readFileSync(filePath, "utf8");
230
+ }
231
+ catch {
232
+ return null;
233
+ }
234
+ const parsed = parseJsonc(raw);
235
+ if (!parsed || typeof parsed !== "object")
236
+ return null;
237
+ const co = parsed.compilerOptions ?? {};
238
+ return {
239
+ ...(co.paths ? { paths: co.paths } : {}),
240
+ ...(typeof co.baseUrl === "string" ? { baseUrl: co.baseUrl } : {}),
241
+ ...(typeof parsed.extends === "string" ? { extends: parsed.extends } : {}),
242
+ };
243
+ }
244
+ function toAliasResolution(r) {
245
+ return {
246
+ alias: r.alias,
247
+ warnings: r.unsupported.map((k) => `path alias "${k}" not wired (unsupported glob shape)`),
248
+ };
249
+ }
250
+ /**
251
+ * Read the workspace's tsconfig (jsconfig fallback) and return Vite alias
252
+ * entries for its path map, plus notes for any keys skipped as unsupported. When
253
+ * the top file has no `paths` but a TRIVIAL relative `extends` (a `./…json` path,
254
+ * one level), the base file's `paths` are used, resolved relative to the base
255
+ * file's dir. Non-trivial extends (a bare package specifier, an array of extends,
256
+ * or multi-level chains) are DEFERRED — proceed with no alias rather than
257
+ * half-resolve. Missing tsconfig / no `paths` → empty (unchanged behavior; the
258
+ * harness just has no extra aliases). Impure (fs); the conversion it delegates to
259
+ * is pure + unit-tested.
260
+ */
261
+ function readAliasEntries(workspaceDir) {
262
+ const none = { alias: [], warnings: [] };
263
+ for (const name of ["tsconfig.json", "jsconfig.json"]) {
264
+ const filePath = join(workspaceDir, name);
265
+ const cfg = readTsconfigFile(filePath);
266
+ if (!cfg)
267
+ continue;
268
+ if (cfg.paths) {
269
+ return toAliasResolution(tsconfigPathsToAlias({ paths: cfg.paths, baseUrl: cfg.baseUrl, tsconfigDir: workspaceDir }));
270
+ }
271
+ // One level of trivial relative extends (the common "extends ./tsconfig.base.json").
272
+ if (typeof cfg.extends === "string" && cfg.extends.startsWith(".")) {
273
+ let ext = cfg.extends;
274
+ if (!ext.endsWith(".json"))
275
+ ext += ".json";
276
+ const extPath = resolve(workspaceDir, ext);
277
+ const base = readTsconfigFile(extPath);
278
+ if (base?.paths) {
279
+ return toAliasResolution(tsconfigPathsToAlias({
280
+ paths: base.paths,
281
+ baseUrl: base.baseUrl,
282
+ tsconfigDir: dirname(extPath),
283
+ }));
284
+ }
285
+ }
286
+ return none;
287
+ }
288
+ return none;
289
+ }
290
+ export const ENTRY_BASENAME = "df-preview-entry.tsx";
291
+ export const INDEX_HTML_BASENAME = "index.html";
292
+ /** POSIX-normalize a relative fs path for use as an ES import specifier. */
293
+ function toImportSpecifier(from, to) {
294
+ let spec = relative(from, to).split(sep).join("/");
295
+ if (!spec.startsWith("."))
296
+ spec = `./${spec}`;
297
+ return spec;
298
+ }
299
+ /**
300
+ * Generate the minimal isolated-render harness for a React component: an entry
301
+ * that mounts JUST that component (placeholder/default props) inside a `<main>`
302
+ * landmark, plus an `index.html`. The `<main>` wrapper guarantees the same
303
+ * structural render floor the `ui-visual` producer asserts (`getByRole("main")`),
304
+ * regardless of what the component itself renders. PURE — returns file contents;
305
+ * the caller writes them.
306
+ */
307
+ export function generateHarness(input) {
308
+ const { componentAbsPath, harnessDir, componentName, exportName, reactMajor } = input;
309
+ const importSpec = toImportSpecifier(harnessDir, componentAbsPath);
310
+ const importLine = exportName === undefined
311
+ ? `import Target from ${JSON.stringify(importSpec)};`
312
+ : `import { ${exportName} as Target } from ${JSON.stringify(importSpec)};`;
313
+ // React major picks the mount API. `import React` is inert under the automatic
314
+ // JSX runtime (plugin-react's default) and covers a classic-runtime consumer.
315
+ const mount = reactMajor >= 18
316
+ ? [
317
+ `import { createRoot } from "react-dom/client";`,
318
+ `const el = document.getElementById("df-preview-root");`,
319
+ `if (el) {`,
320
+ ` createRoot(el).render(`,
321
+ ` <main aria-label=${JSON.stringify(`df preview: ${componentName}`)}>`,
322
+ ` <Target />`,
323
+ ` </main>,`,
324
+ ` );`,
325
+ `}`,
326
+ ]
327
+ : [
328
+ `import { render } from "react-dom";`,
329
+ `const el = document.getElementById("df-preview-root");`,
330
+ `if (el) {`,
331
+ ` render(`,
332
+ ` <main aria-label=${JSON.stringify(`df preview: ${componentName}`)}>`,
333
+ ` <Target />`,
334
+ ` </main>,`,
335
+ ` el,`,
336
+ ` );`,
337
+ `}`,
338
+ ];
339
+ const entry = [
340
+ `// AUTO-GENERATED by \`df mode preview\` — isolated render harness for`,
341
+ `// ${componentName}. Ephemeral; the command removes this dir after capture.`,
342
+ `import React from "react";`,
343
+ importLine,
344
+ "",
345
+ ...mount,
346
+ "",
347
+ ].join("\n");
348
+ const html = [
349
+ `<!doctype html>`,
350
+ `<html lang="en">`,
351
+ ` <head>`,
352
+ ` <meta charset="utf-8" />`,
353
+ ` <meta name="viewport" content="width=device-width, initial-scale=1" />`,
354
+ ` <title>df preview: ${escapeHtml(componentName)}</title>`,
355
+ ` </head>`,
356
+ ` <body>`,
357
+ ` <div id="df-preview-root"></div>`,
358
+ ` <script type="module" src="/${ENTRY_BASENAME}"></script>`,
359
+ ` </body>`,
360
+ `</html>`,
361
+ "",
362
+ ].join("\n");
363
+ return [
364
+ { path: ENTRY_BASENAME, contents: entry },
365
+ { path: INDEX_HTML_BASENAME, contents: html },
366
+ ];
367
+ }
368
+ function escapeHtml(s) {
369
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
370
+ }
371
+ const COMPONENT_EXT_RE = /\.(tsx|jsx)$/;
372
+ function parseReactMajor(deps) {
373
+ const raw = deps?.["react"];
374
+ if (!raw)
375
+ return null;
376
+ const m = raw.match(/(\d+)/);
377
+ return m ? Number(m[1]) : null;
378
+ }
379
+ /** Best-effort PascalCase component name from a filename (`user-card.tsx` →
380
+ * `UserCard`, `Button.tsx` → `Button`). */
381
+ export function componentNameFromPath(componentAbsPath) {
382
+ const base = basename(componentAbsPath).replace(COMPONENT_EXT_RE, "");
383
+ const parts = base.split(/[-_.\s]+/).filter(Boolean);
384
+ if (parts.length === 0)
385
+ return "Component";
386
+ return parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
387
+ }
388
+ /**
389
+ * Resolve which export the harness should mount. Preference order:
390
+ * 1. `export default` present → default import (undefined exportName).
391
+ * 2. a named export matching the PascalCase filename.
392
+ * 3. the first `export function|const <PascalCaseName>`.
393
+ * Returns `{ kind: "none" }` when no export is found (a blocker).
394
+ */
395
+ export function resolveExport(source, componentName) {
396
+ if (/\bexport\s+default\b/.test(source))
397
+ return { kind: "default" };
398
+ const named = new Set();
399
+ const re = /\bexport\s+(?:async\s+)?(?:function|const|class)\s+([A-Za-z_$][\w$]*)/g;
400
+ let m;
401
+ while ((m = re.exec(source)) !== null)
402
+ named.add(m[1]);
403
+ // `export { Foo, Bar as Baz }`
404
+ const reBlock = /\bexport\s*\{([^}]*)\}/g;
405
+ while ((m = reBlock.exec(source)) !== null) {
406
+ for (const piece of m[1].split(",")) {
407
+ const asMatch = piece.trim().match(/(?:\bas\s+([A-Za-z_$][\w$]*))$/);
408
+ const name = asMatch ? asMatch[1] : piece.trim().split(/\s+/)[0];
409
+ if (name)
410
+ named.add(name);
411
+ }
412
+ }
413
+ if (named.has(componentName))
414
+ return { kind: "named", name: componentName };
415
+ // First PascalCase named export (React components are PascalCase by convention).
416
+ for (const n of named) {
417
+ if (/^[A-Z]/.test(n))
418
+ return { kind: "named", name: n };
419
+ }
420
+ return { kind: "none" };
421
+ }
422
+ /** Strong-signal heuristic: is this a React Server Component (unrenderable in a
423
+ * client-only harness)? Conservative — only fires on unambiguous signals so an
424
+ * ordinary client component is never false-blocked. */
425
+ function looksLikeServerComponent(source) {
426
+ const hasUseClient = /^\s*['"]use client['"]\s*;?/m.test(source);
427
+ if (hasUseClient)
428
+ return false;
429
+ // Catches both `import 'server-only'` (side-effect) and `... from 'server-only'`.
430
+ if (/import[^;\n]*['"]server-only['"]/.test(source))
431
+ return true;
432
+ // An `async` default component only makes sense as an RSC.
433
+ if (/\bexport\s+default\s+async\s+function\b/.test(source))
434
+ return true;
435
+ if (/\bexport\s+async\s+function\s+[A-Z]/.test(source))
436
+ return true;
437
+ return false;
438
+ }
439
+ /**
440
+ * Decide whether the component can be rendered via the generated Vite harness,
441
+ * and gather advisories. PURE — no fs, no network.
442
+ */
443
+ export function detectStrategy(input) {
444
+ const { componentAbsPath, source, packageDeps } = input;
445
+ const componentName = componentNameFromPath(componentAbsPath);
446
+ const reactMajor = parseReactMajor(packageDeps) ?? 18;
447
+ const storybook = !!packageDeps && Object.keys(packageDeps).some((d) => d.startsWith("@storybook/"));
448
+ const ladle = !!packageDeps && "@ladle/react" in packageDeps;
449
+ const warnings = [];
450
+ if (storybook || ladle) {
451
+ warnings.push(`${storybook ? "Storybook" : "Ladle"} detected — v1 renders via the generated ` +
452
+ `Vite harness; native story capture is deferred (#357 follow-up).`);
453
+ }
454
+ const base = (blocker) => ({
455
+ engine: blocker ? "unsupported" : "vite-harness",
456
+ componentName,
457
+ reactMajor,
458
+ storybook,
459
+ ladle,
460
+ warnings,
461
+ ...(blocker ? { blocker } : {}),
462
+ });
463
+ if (!COMPONENT_EXT_RE.test(componentAbsPath)) {
464
+ return base({
465
+ code: "not-a-component-file",
466
+ message: `df mode preview targets a React component file (.tsx/.jsx). "${basename(componentAbsPath)}" ` +
467
+ `is not one. Point at the component's .tsx/.jsx source.`,
468
+ });
469
+ }
470
+ const react = parseReactMajor(packageDeps);
471
+ if (react === null) {
472
+ return base({
473
+ code: "no-react",
474
+ message: "No `react` dependency found in this repo's package.json. v1 previews React " +
475
+ "components only (Vue/Svelte/Angular are deferred — see #357). Install react, or " +
476
+ "use your framework's own component sandbox.",
477
+ });
478
+ }
479
+ const hasVite = !!packageDeps && ("vite" in packageDeps || "@vitejs/plugin-react" in packageDeps);
480
+ if (!hasVite) {
481
+ return base({
482
+ code: "no-vite",
483
+ message: "No `vite` / `@vitejs/plugin-react` found. v1's generated harness transforms the " +
484
+ "component with Vite (the common React/Vite/Next-client case). Webpack/CRA transform " +
485
+ "is deferred (#357). Add vite + @vitejs/plugin-react as devDependencies, or add a " +
486
+ "Storybook/Ladle setup.",
487
+ });
488
+ }
489
+ if (looksLikeServerComponent(source)) {
490
+ return base({
491
+ code: "server-component",
492
+ message: "This looks like a React Server Component (no `\"use client\"`, and an async/server-only " +
493
+ "component). It cannot mount in a client-only harness. Add `\"use client\"` if it is " +
494
+ "actually a client component, or preview a client component it renders. RSC capture is " +
495
+ "deferred (#357).",
496
+ });
497
+ }
498
+ const exp = resolveExport(source, componentName);
499
+ if (exp.kind === "none") {
500
+ return base({
501
+ code: "no-export",
502
+ message: `No exported component found in ${basename(componentAbsPath)}. v1 mounts the default export, ` +
503
+ `a named export matching the filename, or the first PascalCase named export.`,
504
+ });
505
+ }
506
+ const result = base();
507
+ if (exp.kind === "named")
508
+ result.exportName = exp.name;
509
+ return result;
510
+ }
511
+ /** Thrown by a `CaptureDeps.capture` when the browser cannot be resolved/launched
512
+ * (Playwright not installed, Chromium binary absent, missing system deps). This
513
+ * is the ONLY capture failure that degrades softly (exit 2, "reached the capture
514
+ * step") — a Vite transform error, a component runtime exception, missing props,
515
+ * or an `fs.allow`-blocked import is a REAL failure and surfaces loudly (exit 1). */
516
+ export class BrowserUnavailableError extends Error {
517
+ constructor(message) {
518
+ super(message);
519
+ this.name = "BrowserUnavailableError";
520
+ }
521
+ }
522
+ /** Secondary heuristic (belt-and-suspenders alongside `BrowserUnavailableError`):
523
+ * recognize a genuine missing-browser / launch / install failure from its message
524
+ * so a raw Playwright error is still classified as degrade-soft, while a render or
525
+ * transform error is NOT. Kept deliberately narrow. */
526
+ export function isBrowserUnavailable(err) {
527
+ if (err instanceof BrowserUnavailableError)
528
+ return true;
529
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
530
+ return (/executable doesn'?t exist/.test(msg) ||
531
+ /playwright install/.test(msg) ||
532
+ /host system is missing dependencies/.test(msg) ||
533
+ /missing dependencies to run browser/.test(msg) ||
534
+ /cannot resolve "(playwright|@playwright\/test)"/.test(msg) ||
535
+ /looks like playwright.* was just installed/.test(msg));
536
+ }
537
+ function sanitizeSlug(raw) {
538
+ const s = raw
539
+ .replace(COMPONENT_EXT_RE, "")
540
+ .replace(/[^a-z0-9-]+/gi, "-")
541
+ .replace(/-+/g, "-")
542
+ .replace(/^-|-$/g, "")
543
+ .toLowerCase();
544
+ return s || "component";
545
+ }
546
+ /**
547
+ * A safe evidence slug is a SINGLE path segment: filesystem-safe chars only, and
548
+ * never `.` / `..` / anything with a separator. The slug is interpolated into the
549
+ * evidence path (`agent-reviews/quality-gates/ui/<sha>/<slug>/…`), so an operator
550
+ * `--slug ../../..` would otherwise traverse OUT of the evidence tree and clobber
551
+ * arbitrary files. The auto-derived slug is already safe (`sanitizeSlug`); this
552
+ * fail-closes an explicit `--slug`. Exported for the traversal regression test.
553
+ */
554
+ export function isSafeSlug(slug) {
555
+ if (slug === "" || slug === "." || slug === "..")
556
+ return false;
557
+ if (slug.includes("/") || slug.includes("\\"))
558
+ return false;
559
+ return /^[A-Za-z0-9._-]+$/.test(slug);
560
+ }
561
+ function mergedDeps(pkg) {
562
+ if (!pkg)
563
+ return null;
564
+ const out = {};
565
+ for (const key of ["dependencies", "devDependencies", "peerDependencies"]) {
566
+ const block = pkg[key];
567
+ if (block && typeof block === "object") {
568
+ for (const [k, v] of Object.entries(block)) {
569
+ if (typeof v === "string")
570
+ out[k] = v;
571
+ }
572
+ }
573
+ }
574
+ return out;
575
+ }
576
+ function readPackageDeps(cwd) {
577
+ const p = join(cwd, "package.json");
578
+ if (!existsSync(p))
579
+ return null;
580
+ try {
581
+ return mergedDeps(JSON.parse(readFileSync(p, "utf8")));
582
+ }
583
+ catch {
584
+ return null;
585
+ }
586
+ }
587
+ /**
588
+ * Render one component in isolation and write `ui-visual`-shaped evidence.
589
+ * Never throws for an expected failure — returns a `degraded`/`error` result
590
+ * with an actionable message and the right exit code.
591
+ */
592
+ export async function runPreview(opts, deps) {
593
+ const { cwd } = opts;
594
+ const port = opts.port ?? 5199;
595
+ const componentAbsPath = isAbsolute(opts.componentPath)
596
+ ? opts.componentPath
597
+ : resolve(cwd, opts.componentPath);
598
+ const relForLog = relative(cwd, componentAbsPath).split(sep).join("/") || opts.componentPath;
599
+ const slug = opts.slug ?? sanitizeSlug(basename(componentAbsPath));
600
+ // Fail closed on an unsafe slug BEFORE it is interpolated into any path — an
601
+ // explicit `--slug ../../..` must not traverse out of the evidence tree.
602
+ if (!isSafeSlug(slug)) {
603
+ return {
604
+ status: "error",
605
+ exitCode: 1,
606
+ slug,
607
+ warnings: [],
608
+ message: `df mode preview: invalid --slug "${slug}". A slug must be a single path ` +
609
+ `segment matching [A-Za-z0-9._-] (no "/", "\\", "." or ".."). No evidence written.`,
610
+ };
611
+ }
612
+ if (!existsSync(componentAbsPath)) {
613
+ return {
614
+ status: "error",
615
+ exitCode: 1,
616
+ slug,
617
+ warnings: [],
618
+ message: `df mode preview: component not found: ${opts.componentPath}`,
619
+ };
620
+ }
621
+ const source = readFileSync(componentAbsPath, "utf8");
622
+ // Resolve the frontend workspace (#379): the module/config base is the
623
+ // workspace that actually holds react/vite/node_modules + the tsconfig path
624
+ // aliases — often a `web/` subdir — NOT the invocation cwd (the repo root,
625
+ // where the designer works and darkfactory.yaml lives). When no react is found
626
+ // anywhere, fall back to cwd so detectStrategy emits the existing honest
627
+ // no-react blocker. The invocation cwd stays the base for evidence, the SHA,
628
+ // and the component's display path.
629
+ const workspace = resolveFrontendWorkspace(cwd);
630
+ const workspaceDir = workspace?.workspaceDir ?? cwd;
631
+ // Note the shift only when it actually moved off the repo root, so the designer
632
+ // sees "resolved frontend workspace: web/" but a root-frontend repo stays quiet.
633
+ const workspaceNote = workspace && workspace.workspaceDir !== cwd
634
+ ? `resolved frontend workspace: ${workspace.relLabel}/ (module + config base for this preview)`
635
+ : undefined;
636
+ const detect = detectStrategy({
637
+ cwd: workspaceDir,
638
+ componentAbsPath,
639
+ source,
640
+ packageDeps: readPackageDeps(workspaceDir),
641
+ });
642
+ const baseWarnings = workspaceNote ? [workspaceNote, ...detect.warnings] : detect.warnings;
643
+ if (detect.blocker) {
644
+ return {
645
+ status: "degraded",
646
+ exitCode: 2,
647
+ slug,
648
+ warnings: baseWarnings,
649
+ message: `df mode preview: cannot render ${relForLog} — ${detect.blocker.message}`,
650
+ };
651
+ }
652
+ const sha = await resolveHeadOrWorktree(cwd);
653
+ // Read the workspace tsconfig `paths` → Vite alias; any unsupported-glob key is
654
+ // surfaced (not silently mis-resolved) via the operator notes channel.
655
+ const { alias, warnings: aliasWarnings } = readAliasEntries(workspaceDir);
656
+ const warnings = aliasWarnings.length > 0 ? [...baseWarnings, ...aliasWarnings] : baseWarnings;
657
+ // The harness dir lives INSIDE the resolved workspace so the entry's bare
658
+ // imports (`react`, `react-dom/client`) and the component's relative imports
659
+ // resolve up into the workspace's node_modules — identical to running from the
660
+ // workspace dir. Evidence still lands at the repo root (writeEvidence uses cwd).
661
+ const harnessDir = mkdtempSync(join(workspaceDir, ".df-preview-"));
662
+ let server;
663
+ try {
664
+ for (const f of generateHarness({
665
+ componentAbsPath,
666
+ harnessDir,
667
+ componentName: detect.componentName,
668
+ reactMajor: detect.reactMajor,
669
+ ...(detect.exportName !== undefined ? { exportName: detect.exportName } : {}),
670
+ })) {
671
+ writeFileSync(join(harnessDir, f.path), f.contents, "utf8");
672
+ }
673
+ try {
674
+ server = await deps.startServer({ workspaceDir, harnessDir, port, alias });
675
+ }
676
+ catch (err) {
677
+ return {
678
+ status: "degraded",
679
+ exitCode: 2,
680
+ slug,
681
+ warnings,
682
+ message: `df mode preview: reached harness build but could NOT start the isolated Vite dev ` +
683
+ `server for ${relForLog} (${errMsg(err)}). Ensure \`vite\` + \`@vitejs/plugin-react\` ` +
684
+ `are installed in the frontend workspace. No evidence written.`,
685
+ };
686
+ }
687
+ let captured;
688
+ try {
689
+ captured = await deps.capture({ url: server.url, workspaceDir });
690
+ }
691
+ catch (err) {
692
+ // Distinguish a genuinely-unavailable browser (soft-degrade — the render
693
+ // itself was fine, only the screenshot couldn't be taken HERE) from a REAL
694
+ // failure: a Vite transform error, a component runtime exception, missing
695
+ // required props, or a component import blocked by Vite `fs.allow`. The
696
+ // latter must surface LOUDLY (exit 1) — reporting it as "degraded
697
+ // gracefully" would hide a broken component behind a false soft-skip.
698
+ if (isBrowserUnavailable(err)) {
699
+ return {
700
+ status: "degraded",
701
+ exitCode: 2,
702
+ slug,
703
+ warnings,
704
+ message: `df mode preview: reached the capture step for ${relForLog} but the browser is ` +
705
+ `unavailable (${errMsg(err)}). Install it with \`npx playwright install chromium\` ` +
706
+ `(and \`npm i -D playwright\` if absent). The isolated render succeeded; only the ` +
707
+ `screenshot could not be taken here. No evidence written.`,
708
+ };
709
+ }
710
+ return {
711
+ status: "error",
712
+ exitCode: 1,
713
+ slug,
714
+ warnings,
715
+ message: `df mode preview: the isolated render of ${relForLog} FAILED (${errMsg(err)}). This is ` +
716
+ `NOT a missing-browser problem — it is typically a Vite transform error, a component ` +
717
+ `runtime exception, missing required props, or an import outside the repo (Vite ` +
718
+ `fs.allow). Fix the component or provide props, then re-run. No evidence written.`,
719
+ };
720
+ }
721
+ const evidenceDir = writeEvidence({
722
+ cwd,
723
+ sha,
724
+ slug,
725
+ componentRel: relForLog,
726
+ componentName: detect.componentName,
727
+ baseURL: server.url,
728
+ aria: captured.aria,
729
+ png: captured.png,
730
+ // Persist the FULL merged notes (workspace-resolution + any unsupported
731
+ // path-alias keys), not just render advisories, so the evidence records
732
+ // why an alias may not have wired — not only stdout.
733
+ warnings,
734
+ });
735
+ return {
736
+ status: "captured",
737
+ exitCode: 0,
738
+ slug,
739
+ warnings,
740
+ evidenceDir,
741
+ message: `df mode preview: captured isolated render of ${relForLog} → ${relative(cwd, evidenceDir)}/` +
742
+ ` (after.png + aria.snapshot.txt + meta.json, evidenceKind=playwright).`,
743
+ };
744
+ }
745
+ finally {
746
+ if (server) {
747
+ try {
748
+ await server.close();
749
+ }
750
+ catch {
751
+ /* best-effort teardown */
752
+ }
753
+ }
754
+ rmSync(harnessDir, { recursive: true, force: true });
755
+ }
756
+ }
757
+ async function resolveHeadOrWorktree(cwd) {
758
+ try {
759
+ return await resolveCommit("HEAD", cwd);
760
+ }
761
+ catch {
762
+ // No commit yet (fresh repo) — evidence is still useful for the designer loop.
763
+ return "working-tree";
764
+ }
765
+ }
766
+ function errMsg(err) {
767
+ return err instanceof Error ? err.message : String(err);
768
+ }
769
+ /** Write the exact `ui-visual` evidence shape so the existing embed/publish path
770
+ * (`discoverUiEvidence` / `buildScreenshotEmbed` / `readUiArtifacts`) consumes
771
+ * it unchanged. Returns the absolute evidence dir. */
772
+ function writeEvidence(input) {
773
+ const dir = join(input.cwd, "agent-reviews", "quality-gates", "ui", input.sha, input.slug);
774
+ mkdirSync(dir, { recursive: true });
775
+ writeFileSync(join(dir, "after.png"), input.png);
776
+ writeFileSync(join(dir, "aria.snapshot.txt"), `${input.aria.trim()}\n`, "utf8");
777
+ const meta = {
778
+ surface: input.slug,
779
+ // `path` is the embed's human title (buildScreenshotEmbed reads meta.path).
780
+ path: `component:${input.componentRel}`,
781
+ covers: input.componentRel,
782
+ sha: input.sha,
783
+ capturedAt: new Date().toISOString(),
784
+ baseURL: input.baseURL,
785
+ beforeBaseURL: null,
786
+ ariaSnapshotBytes: Buffer.byteLength(input.aria, "utf8"),
787
+ // Keep the same evidenceKind/route as the ui-visual producer so publish.ts
788
+ // uploads the file artifacts and the embed renders them identically.
789
+ evidenceKind: "playwright",
790
+ route: "ui-visual",
791
+ floor: ["aria-snapshot", "after-screenshot"],
792
+ beforeScreenshot: null,
793
+ // HONESTY marker: this is an isolated component render, not a navigated route.
794
+ capture: "component-isolation",
795
+ componentName: input.componentName,
796
+ engine: "vite-harness",
797
+ warnings: input.warnings,
798
+ };
799
+ writeFileSync(join(dir, "meta.json"), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
800
+ return dir;
801
+ }
802
+ //# sourceMappingURL=preview.js.map