@momentiq/dark-factory-cli 3.0.2 → 3.1.0
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.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/mode.d.ts.map +1 -1
- package/dist/commands/mode.js +120 -22
- package/dist/commands/mode.js.map +1 -1
- package/dist/commands/skills.d.ts.map +1 -1
- package/dist/commands/skills.js +11 -1
- package/dist/commands/skills.js.map +1 -1
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +113 -8
- package/dist/commands/verify.js.map +1 -1
- package/dist/mcp/tools/skills-install.d.ts.map +1 -1
- package/dist/mcp/tools/skills-install.js +8 -3
- package/dist/mcp/tools/skills-install.js.map +1 -1
- package/dist/mode/critic-registration.d.ts +30 -10
- package/dist/mode/critic-registration.d.ts.map +1 -1
- package/dist/mode/critic-registration.js +31 -10
- package/dist/mode/critic-registration.js.map +1 -1
- package/dist/mode/guard.d.ts.map +1 -1
- package/dist/mode/guard.js +30 -2
- package/dist/mode/guard.js.map +1 -1
- package/dist/mode/init.d.ts +7 -4
- package/dist/mode/init.d.ts.map +1 -1
- package/dist/mode/init.js +146 -12
- package/dist/mode/init.js.map +1 -1
- package/dist/mode/preview-capture.d.ts +3 -0
- package/dist/mode/preview-capture.d.ts.map +1 -0
- package/dist/mode/preview-capture.js +129 -0
- package/dist/mode/preview-capture.js.map +1 -0
- package/dist/mode/preview.d.ts +160 -0
- package/dist/mode/preview.d.ts.map +1 -0
- package/dist/mode/preview.js +522 -0
- package/dist/mode/preview.js.map +1 -0
- package/dist/onboard/seeders/design-md.d.ts.map +1 -1
- package/dist/onboard/seeders/design-md.js +512 -17
- package/dist/onboard/seeders/design-md.js.map +1 -1
- package/dist/skills/install.d.ts +25 -5
- package/dist/skills/install.d.ts.map +1 -1
- package/dist/skills/install.js +34 -17
- package/dist/skills/install.js.map +1 -1
- package/dist/skills/known-skills.d.ts +2 -0
- package/dist/skills/known-skills.d.ts.map +1 -0
- package/dist/skills/known-skills.js +25 -0
- package/dist/skills/known-skills.js.map +1 -0
- package/package.json +2 -2
- package/skills/designer-build/SKILL.md.tmpl +14 -0
- package/skills/designer-handoff/SKILL.md.tmpl +28 -1
- package/skills/verify/producer/README.md +29 -4
- package/skills/verify/producer/playwright-route.sh +10 -0
- package/skills/verify/producer/surface-declaration.ts +373 -0
- package/skills/verify/producer/ui-route.producer.spec.ts +44 -11
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Designer-safe UI-surface COVERAGE DECLARATION for the reusable playwright
|
|
3
|
+
* (UI) verification-route producer (momentiq-ai/dark-factory#359).
|
|
4
|
+
*
|
|
5
|
+
* ## The wall this removes
|
|
6
|
+
*
|
|
7
|
+
* The engineer `playwright` route fails closed when a changed product-UI path
|
|
8
|
+
* maps to NO `SURFACES[]` entry (`coverage.ts`). The ONLY way to clear that
|
|
9
|
+
* block was to EDIT the Playwright spec — hand-author a `UiSurface` (a route
|
|
10
|
+
* `path`, a `requiredHeading` **RegExp**, and `covers` globs) in
|
|
11
|
+
* `ui-route.producer.spec.ts`. That is pure engineer work: a designer touching
|
|
12
|
+
* an unmapped area (e.g. `web/components/chat/**`) mid-feature is walled by a
|
|
13
|
+
* TS/RegExp edit they cannot safely make.
|
|
14
|
+
*
|
|
15
|
+
* ## What this adds (WITHOUT weakening the gate)
|
|
16
|
+
*
|
|
17
|
+
* A plain DATA file — `.darkfactory/ui-surfaces.json` — in which a designer
|
|
18
|
+
* DECLARES "this changed area is shown on `<route>`". The producer reads the
|
|
19
|
+
* declaration, turns each entry into a real `UiSurface`, and CAPTURES that route
|
|
20
|
+
* (navigates, asserts its heading, screenshots) exactly like a hand-authored
|
|
21
|
+
* surface. So the coverage requirement is UNCHANGED — the declared route must
|
|
22
|
+
* really render — only the AUTHORING surface moves from "edit test/TS code" to
|
|
23
|
+
* "add a JSON entry". Anything that maps to no surface AND no declaration still
|
|
24
|
+
* FAILS CLOSED (the engineer contract is untouched).
|
|
25
|
+
*
|
|
26
|
+
* Two deliberate designer-safety choices:
|
|
27
|
+
* - `requiredHeading` is a plain string matched case-insensitively as a
|
|
28
|
+
* LITERAL (escaped), so no regex knowledge is required and a stray metachar
|
|
29
|
+
* cannot silently widen the assertion.
|
|
30
|
+
* - A malformed declaration fails LOUD (never silently ignored) — a designer
|
|
31
|
+
* who mis-declares sees a clear error, not a still-blocked route.
|
|
32
|
+
*
|
|
33
|
+
* ── CONSUMER REFERENCE FILE — ships in the npm tarball next to `coverage.ts`;
|
|
34
|
+
* copy it into your repo alongside the producer spec and own it. It is pure
|
|
35
|
+
* (Node builtins only, no Dark Factory / vendor dependency) and unit-tested
|
|
36
|
+
* without a browser (mirroring `coverage.ts`).
|
|
37
|
+
*/
|
|
38
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
39
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
40
|
+
|
|
41
|
+
import type { UiSurface } from "./coverage";
|
|
42
|
+
|
|
43
|
+
/** Repo-root-relative default location of the designer declaration. */
|
|
44
|
+
export const DECLARATION_REL_PATH = ".darkfactory/ui-surfaces.json";
|
|
45
|
+
|
|
46
|
+
/** Env var that overrides the declaration location (absolute or start-dir-
|
|
47
|
+
* relative). Exported by `playwright-route.sh` as the canonical repo-root path;
|
|
48
|
+
* falls back to an upward filesystem walk when unset. */
|
|
49
|
+
export const DECLARATION_ENV_VAR = "DF_UI_SURFACES";
|
|
50
|
+
|
|
51
|
+
const EXAMPLE_JSON =
|
|
52
|
+
` {\n` +
|
|
53
|
+
` "surfaces": [\n` +
|
|
54
|
+
` { "path": "/chat", "covers": ["web/components/chat/**"], "requiredHeading": "Chat" }\n` +
|
|
55
|
+
` ]\n` +
|
|
56
|
+
` }`;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The DESIGNER-FACING shape of one declaration entry (the JSON a designer
|
|
60
|
+
* writes). Deliberately simpler than `UiSurface`: `requiredHeading` is a plain
|
|
61
|
+
* string (not a RegExp) and `slug` is optional (derived from `path` when
|
|
62
|
+
* omitted). This is documentation of the accepted shape; parsing accepts
|
|
63
|
+
* `unknown` and validates defensively.
|
|
64
|
+
*/
|
|
65
|
+
export interface DeclaredSurfaceInput {
|
|
66
|
+
/** URL path the producer navigates + captures, e.g. "/chat". Must start "/". */
|
|
67
|
+
path: string;
|
|
68
|
+
/** Source-path globs this route is the evidence for — the changed paths it
|
|
69
|
+
* COVERS, e.g. ["web/components/chat/**"]. Non-empty. */
|
|
70
|
+
covers: string[];
|
|
71
|
+
/** A word/phrase from the page's main heading (role=heading, accessible
|
|
72
|
+
* name). Matched case-insensitively as a LITERAL — no regex. Non-empty. */
|
|
73
|
+
requiredHeading: string;
|
|
74
|
+
/** Optional evidence-subdir slug; derived from `path` when omitted. */
|
|
75
|
+
slug?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Escape a plain string so it matches LITERALLY inside a RegExp. */
|
|
79
|
+
function escapeRegExp(literal: string): string {
|
|
80
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sanitizeSlug(raw: string): string {
|
|
84
|
+
const s = raw
|
|
85
|
+
.replace(/[^a-z0-9]+/gi, "-")
|
|
86
|
+
.replace(/-+/g, "-")
|
|
87
|
+
.replace(/^-|-$/g, "")
|
|
88
|
+
.toLowerCase();
|
|
89
|
+
return s.length > 0 ? s : "surface";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function slugFromPath(path: string): string {
|
|
93
|
+
const s = sanitizeSlug(path.replace(/^\/+/, ""));
|
|
94
|
+
return s === "surface" ? "home" : s;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Pull the entry array out of either a bare array or a `{ surfaces: [...] }`
|
|
98
|
+
* wrapper. Anything else is a loud parse error. `prefix` names the source (the
|
|
99
|
+
* resolved file, when known) so the operator sees WHICH declaration is wrong. */
|
|
100
|
+
function extractEntries(raw: unknown, prefix: string): unknown[] {
|
|
101
|
+
if (Array.isArray(raw)) return raw;
|
|
102
|
+
if (raw !== null && typeof raw === "object" && "surfaces" in raw) {
|
|
103
|
+
const surfaces = (raw as { surfaces: unknown }).surfaces;
|
|
104
|
+
if (Array.isArray(surfaces)) return surfaces;
|
|
105
|
+
throw new Error(
|
|
106
|
+
`${prefix}: the top-level "surfaces" key must be an array of surface ` +
|
|
107
|
+
`entries. Example:\n${EXAMPLE_JSON}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
throw new Error(
|
|
111
|
+
`${prefix}: expected a JSON array of surface entries, or an object with a ` +
|
|
112
|
+
`"surfaces" array. Example:\n${EXAMPLE_JSON}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function toUiSurface(entry: unknown, index: number, prefix: string): UiSurface {
|
|
117
|
+
const at = `${prefix} entry [${index}]`;
|
|
118
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`${at} must be an object with "path", "covers", and "requiredHeading".`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const rec = entry as Record<string, unknown>;
|
|
124
|
+
|
|
125
|
+
const rawPath = rec["path"];
|
|
126
|
+
if (typeof rawPath !== "string" || rawPath.trim().length === 0) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`${at} is missing a non-empty "path" string (the route to navigate, e.g. ` +
|
|
129
|
+
`"/chat").`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const path = rawPath.trim();
|
|
133
|
+
if (!path.startsWith("/")) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`${at} "path" must start with "/" (a route relative to your site root), ` +
|
|
136
|
+
`got ${JSON.stringify(rawPath)}.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const rawCovers = rec["covers"];
|
|
141
|
+
if (!Array.isArray(rawCovers) || rawCovers.length === 0) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`${at} is missing a non-empty "covers" array (the source globs this route ` +
|
|
144
|
+
`is the evidence for, e.g. ["web/components/chat/**"]).`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const covers = rawCovers.map((glob, gi) => {
|
|
148
|
+
if (typeof glob !== "string" || glob.trim().length === 0) {
|
|
149
|
+
throw new Error(`${at} "covers[${gi}]" must be a non-empty glob string.`);
|
|
150
|
+
}
|
|
151
|
+
return glob.trim();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const rawHeading = rec["requiredHeading"];
|
|
155
|
+
if (typeof rawHeading !== "string" || rawHeading.trim().length === 0) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`${at} is missing a non-empty "requiredHeading" string (a word from the ` +
|
|
158
|
+
`page's main heading — matched case-insensitively, NOT a regex).`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const heading = rawHeading.trim();
|
|
162
|
+
|
|
163
|
+
const rawSlug = rec["slug"];
|
|
164
|
+
let slug: string;
|
|
165
|
+
if (rawSlug === undefined) {
|
|
166
|
+
slug = slugFromPath(path);
|
|
167
|
+
} else if (typeof rawSlug === "string" && rawSlug.trim().length > 0) {
|
|
168
|
+
slug = sanitizeSlug(rawSlug);
|
|
169
|
+
} else {
|
|
170
|
+
throw new Error(`${at} "slug", when present, must be a non-empty string.`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
path,
|
|
175
|
+
slug,
|
|
176
|
+
requiredHeading: new RegExp(escapeRegExp(heading), "i"),
|
|
177
|
+
covers,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Parse an already-JSON-decoded declaration (a bare array or `{ surfaces: [] }`)
|
|
183
|
+
* into real `UiSurface[]`. PURE (no fs) — the load-bearing validation is
|
|
184
|
+
* unit-tested without touching disk. Throws a clear, actionable error on any
|
|
185
|
+
* malformed entry (loud, never silent). `source` (the resolved file path, when
|
|
186
|
+
* called from `loadDeclaredSurfaces`) is woven into every error so the operator
|
|
187
|
+
* can tell WHICH declaration is wrong — `DF_UI_SURFACES` can override the path.
|
|
188
|
+
*/
|
|
189
|
+
export function parseSurfaceDeclaration(
|
|
190
|
+
raw: unknown,
|
|
191
|
+
source?: string,
|
|
192
|
+
): UiSurface[] {
|
|
193
|
+
const prefix =
|
|
194
|
+
typeof source === "string" && source.trim().length > 0
|
|
195
|
+
? `ui-surfaces declaration (${source})`
|
|
196
|
+
: "ui-surfaces declaration";
|
|
197
|
+
return extractEntries(raw, prefix).map((entry, i) =>
|
|
198
|
+
toUiSurface(entry, i, prefix),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Merge designer-DECLARED surfaces onto the hand-authored `SURFACES[]`, FAILING
|
|
204
|
+
* CLOSED on a slug collision. Evidence is written per surface to
|
|
205
|
+
* `<out-dir>/<slug>/`, so two surfaces sharing a slug would SILENTLY OVERWRITE
|
|
206
|
+
* each other's screenshots. Rather than dedupe (which drops a real surface's
|
|
207
|
+
* evidence) or overwrite, this throws a loud, plain-language error naming the
|
|
208
|
+
* colliding slug — the designer renames the declaration's `slug`. Detects a
|
|
209
|
+
* declared slug colliding with an existing `SURFACES[]` entry OR with another
|
|
210
|
+
* declared surface. (A pre-existing collision WITHIN `base` is the engineer's
|
|
211
|
+
* own concern and is not re-checked here.)
|
|
212
|
+
*/
|
|
213
|
+
export function mergeDeclaredSurfaces(
|
|
214
|
+
base: readonly UiSurface[],
|
|
215
|
+
declared: readonly UiSurface[],
|
|
216
|
+
): UiSurface[] {
|
|
217
|
+
const origin = new Map<string, "surface" | "declaration">();
|
|
218
|
+
for (const s of base) origin.set(s.slug, "surface");
|
|
219
|
+
for (const d of declared) {
|
|
220
|
+
const clash = origin.get(d.slug);
|
|
221
|
+
if (clash !== undefined) {
|
|
222
|
+
const owner =
|
|
223
|
+
clash === "surface"
|
|
224
|
+
? "an existing SURFACES[] entry"
|
|
225
|
+
: "another declared surface";
|
|
226
|
+
throw new Error(
|
|
227
|
+
`ui-surfaces declaration: the declared surface for path "${d.path}" uses ` +
|
|
228
|
+
`slug "${d.slug}", which is ALREADY used by ${owner}. Evidence is ` +
|
|
229
|
+
`written per surface to <out-dir>/${d.slug}/, so two surfaces sharing a ` +
|
|
230
|
+
`slug would SILENTLY OVERWRITE each other's screenshots — this route ` +
|
|
231
|
+
`FAILS CLOSED instead. Give this declaration a unique "slug" in ` +
|
|
232
|
+
`.darkfactory/ui-surfaces.json (e.g. "slug": "${d.slug}-2").`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
origin.set(d.slug, "declaration");
|
|
236
|
+
}
|
|
237
|
+
return [...base, ...declared];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Resolve the declaration file location: the `DF_UI_SURFACES` override first
|
|
242
|
+
* (absolute or `startDir`-relative), else an upward filesystem walk from
|
|
243
|
+
* `startDir` for `.darkfactory/ui-surfaces.json`. Returns `null` when no
|
|
244
|
+
* declaration exists.
|
|
245
|
+
*
|
|
246
|
+
* The upward walk exists because the producer runs with `cwd` = the web dir
|
|
247
|
+
* (e.g. `web/`) but the declaration lives at the REPO ROOT alongside the other
|
|
248
|
+
* `.darkfactory/` config — a `join(cwd, ...)` would miss it.
|
|
249
|
+
*/
|
|
250
|
+
export function resolveDeclarationPath(
|
|
251
|
+
startDir: string,
|
|
252
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
253
|
+
): string | null {
|
|
254
|
+
const override = env[DECLARATION_ENV_VAR];
|
|
255
|
+
if (typeof override === "string" && override.trim().length > 0) {
|
|
256
|
+
const raw = override.trim();
|
|
257
|
+
const abs = isAbsolute(raw) ? raw : resolve(startDir, raw);
|
|
258
|
+
return existsSync(abs) ? abs : null;
|
|
259
|
+
}
|
|
260
|
+
let dir = resolve(startDir);
|
|
261
|
+
for (;;) {
|
|
262
|
+
const candidate = join(dir, DECLARATION_REL_PATH);
|
|
263
|
+
if (existsSync(candidate)) return candidate;
|
|
264
|
+
// Stop at the repo root (a dir/file named `.git` — a plain repo has a dir, a
|
|
265
|
+
// worktree/submodule has a file): the declaration is a repo-root artifact, so
|
|
266
|
+
// never search ABOVE it, which would risk picking up an unrelated
|
|
267
|
+
// `.darkfactory/ui-surfaces.json` in a parent checkout.
|
|
268
|
+
if (existsSync(join(dir, ".git"))) return null;
|
|
269
|
+
const parent = dirname(dir);
|
|
270
|
+
if (parent === dir) return null; // reached the filesystem root
|
|
271
|
+
dir = parent;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Load + parse the designer declaration, returning the extra `UiSurface[]` to
|
|
277
|
+
* merge into the producer's `SURFACES[]`. A MISSING declaration is normal (no
|
|
278
|
+
* designer override) → `[]`. A PRESENT-but-malformed declaration (bad JSON or a
|
|
279
|
+
* failed field validation) THROWS — fail loud, never silently un-cover a path.
|
|
280
|
+
*/
|
|
281
|
+
export function loadDeclaredSurfaces(
|
|
282
|
+
startDir: string,
|
|
283
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
284
|
+
): UiSurface[] {
|
|
285
|
+
const file = resolveDeclarationPath(startDir, env);
|
|
286
|
+
if (file === null) return [];
|
|
287
|
+
|
|
288
|
+
let text: string;
|
|
289
|
+
try {
|
|
290
|
+
text = readFileSync(file, "utf8");
|
|
291
|
+
} catch (err) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
`ui-surfaces declaration: failed to read ${file}: ${
|
|
294
|
+
err instanceof Error ? err.message : String(err)
|
|
295
|
+
}`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let json: unknown;
|
|
300
|
+
try {
|
|
301
|
+
json = JSON.parse(text);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`ui-surfaces declaration: ${file} is not valid JSON: ${
|
|
305
|
+
err instanceof Error ? err.message : String(err)
|
|
306
|
+
}`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Pass the resolved file so a schema-validation error names WHICH file is
|
|
311
|
+
// wrong (DF_UI_SURFACES can override the location).
|
|
312
|
+
return parseSurfaceDeclaration(json, file);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Context for the fail-closed coverage message. */
|
|
316
|
+
export interface UncoveredErrorContext {
|
|
317
|
+
/** The changed UI path(s) with no mapped/declared surface. */
|
|
318
|
+
uncovered: readonly string[];
|
|
319
|
+
/** The active operator mode (e.g. `process.env.DF_MODE`). When set (a
|
|
320
|
+
* non-empty operator-driven session — Designer Mode is the first), the
|
|
321
|
+
* message routes to `/designer-handoff` in plain language instead of "edit
|
|
322
|
+
* the spec". */
|
|
323
|
+
mode?: string | undefined;
|
|
324
|
+
/** Where the designer declaration lives (for the message). */
|
|
325
|
+
declarationPath?: string;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Build the fail-closed coverage error. Pure + unit-tested so both personas'
|
|
330
|
+
* copy is asserted:
|
|
331
|
+
* - ENGINEER (no operator mode): points to `SURFACES[]` AND the designer
|
|
332
|
+
* declaration — both clear the block; the coverage requirement is unchanged.
|
|
333
|
+
* - DESIGNER / operator mode active: plain-language, framed as the gate WORKING
|
|
334
|
+
* (not a rejection), with two designer-safe options — the JSON declaration,
|
|
335
|
+
* or `/designer-handoff` as PROGRESS. It never routes a designer to a
|
|
336
|
+
* TS/spec edit. (References the `/designer-handoff` skill; does NOT edit it.)
|
|
337
|
+
*/
|
|
338
|
+
export function formatUncoveredError(ctx: UncoveredErrorContext): string {
|
|
339
|
+
const declPath = ctx.declarationPath ?? DECLARATION_REL_PATH;
|
|
340
|
+
const list = ctx.uncovered.map((p) => ` - ${p}`).join("\n");
|
|
341
|
+
const example = `${declPath}:\n${EXAMPLE_JSON}`;
|
|
342
|
+
const operatorMode =
|
|
343
|
+
typeof ctx.mode === "string" && ctx.mode.trim().length > 0;
|
|
344
|
+
|
|
345
|
+
if (operatorMode) {
|
|
346
|
+
return (
|
|
347
|
+
`Design verification can't see this change yet.\n\n` +
|
|
348
|
+
`The UI you changed isn't mapped to a screen this check captures, so it ` +
|
|
349
|
+
`can't produce real before/after evidence for it — it stops here. This is ` +
|
|
350
|
+
`the safety gate doing its job, NOT a rejection of your work:\n${list}\n\n` +
|
|
351
|
+
`Two designer-safe ways forward — neither touches test or TypeScript code:\n\n` +
|
|
352
|
+
` 1. Say which screen shows this change, in ${example}\n\n` +
|
|
353
|
+
` (read: "the /chat screen is where web/components/chat/** appears"). ` +
|
|
354
|
+
`Commit it and re-run — this check then captures /chat as the evidence.\n\n` +
|
|
355
|
+
` 2. If you can't map it to a screen, hand it to an engineer as PROGRESS: ` +
|
|
356
|
+
`run \`/designer-handoff\`. It escalates with the context filled in — never ` +
|
|
357
|
+
`a rejection.`
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return (
|
|
362
|
+
`Evidence-Gated Validation playwright route: the following changed UI ` +
|
|
363
|
+
`path(s) have NO mapped capture surface, so this route cannot produce real ` +
|
|
364
|
+
`evidence for them and FAILS CLOSED:\n${list}\n\n` +
|
|
365
|
+
`Fix (either — the coverage requirement is unchanged; the added/declared ` +
|
|
366
|
+
`route must really render):\n` +
|
|
367
|
+
` • Engineer: add a surface (path + requiredHeading + covers glob) to ` +
|
|
368
|
+
`SURFACES[] in ui-route.producer.spec.ts.\n` +
|
|
369
|
+
` • No test-code edit: declare the covering route in ${example}\n\n` +
|
|
370
|
+
`Do NOT widen NON_SURFACE_GLOBS to suppress a real UI change — that ` +
|
|
371
|
+
`re-opens the false-positive hole this gate closes.`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
@@ -30,6 +30,12 @@
|
|
|
30
30
|
* cannot be satisfied by rendering only an unaffected surface. The mapping logic
|
|
31
31
|
* lives in the unit-tested `coverage.ts`.
|
|
32
32
|
*
|
|
33
|
+
* A changed area can be covered EITHER by editing `SURFACES[]` below (engineer)
|
|
34
|
+
* OR by declaring the covering route in `.darkfactory/ui-surfaces.json` — a
|
|
35
|
+
* designer-safe DATA file that needs no test/TS edit (#359, `surface-declaration.ts`).
|
|
36
|
+
* Declared surfaces are captured identically, so the coverage requirement is
|
|
37
|
+
* unchanged; only the authoring surface differs.
|
|
38
|
+
*
|
|
33
39
|
* ## Auth (consumer-supplied storage-state — NOT vendor-coupled)
|
|
34
40
|
*
|
|
35
41
|
* Public surfaces need no auth. For PROTECTED surfaces, supply an authenticated
|
|
@@ -53,14 +59,21 @@
|
|
|
53
59
|
* gate without a real, passing capture bound to the SHA.
|
|
54
60
|
*
|
|
55
61
|
* ── CONSUMER REFERENCE FILE — copy into your repo and OWN it. The one block you
|
|
56
|
-
*
|
|
57
|
-
* the source globs each one covers)
|
|
58
|
-
*
|
|
62
|
+
* edit for engineer-authored surfaces is `SURFACES[]` below (your routes +
|
|
63
|
+
* their required headings + the source globs each one covers); a designer can
|
|
64
|
+
* instead add covering routes to `.darkfactory/ui-surfaces.json` with no code
|
|
65
|
+
* edit (#359). `df skills install verify` does NOT overwrite this file; it is
|
|
66
|
+
* yours to maintain.
|
|
59
67
|
*/
|
|
60
68
|
import { test, expect, type Page } from "@playwright/test";
|
|
61
69
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
62
70
|
import { join } from "node:path";
|
|
63
71
|
import { partitionChangedPaths, type UiSurface } from "./coverage";
|
|
72
|
+
import {
|
|
73
|
+
formatUncoveredError,
|
|
74
|
+
loadDeclaredSurfaces,
|
|
75
|
+
mergeDeclaredSurfaces,
|
|
76
|
+
} from "./surface-declaration";
|
|
64
77
|
|
|
65
78
|
const SHA = process.env.DF_UI_ROUTE_SHA ?? "unknown";
|
|
66
79
|
const OUT_DIR =
|
|
@@ -134,11 +147,29 @@ const CHANGED_UI_PATHS = (process.env.DF_UI_ROUTE_CHANGED_PATHS ?? "")
|
|
|
134
147
|
.map((p) => p.trim())
|
|
135
148
|
.filter(Boolean);
|
|
136
149
|
|
|
150
|
+
// Designer-safe coverage declaration (#359): a designer can DECLARE a covering
|
|
151
|
+
// route in `.darkfactory/ui-surfaces.json` (a data file) instead of editing the
|
|
152
|
+
// SURFACES[] above — pure engineer work they can't safely do mid-feature.
|
|
153
|
+
// Declared entries become real UiSurfaces, so the producer navigates + asserts +
|
|
154
|
+
// screenshots them exactly like a hand-authored surface: the fail-closed
|
|
155
|
+
// coverage REQUIREMENT is unchanged; only the AUTHORING surface (JSON data vs
|
|
156
|
+
// TS/RegExp) differs. Missing declaration → []; a malformed one throws (loud).
|
|
157
|
+
// Resolved from the repo root (the producer runs with cwd = the web dir) via
|
|
158
|
+
// DF_UI_SURFACES or an upward filesystem walk. `mergeDeclaredSurfaces` FAILS
|
|
159
|
+
// CLOSED on a slug collision (a declared slug equal to an existing SURFACES[]
|
|
160
|
+
// slug or another declaration): evidence is written to <out>/<slug>/, so a
|
|
161
|
+
// shared slug would silently overwrite another surface's screenshots.
|
|
162
|
+
const DECLARED_SURFACES = loadDeclaredSurfaces(process.cwd(), process.env);
|
|
163
|
+
const ALL_SURFACES: readonly UiSurface[] = mergeDeclaredSurfaces(
|
|
164
|
+
SURFACES,
|
|
165
|
+
DECLARED_SURFACES,
|
|
166
|
+
);
|
|
167
|
+
|
|
137
168
|
const {
|
|
138
169
|
armed: ARMED_SURFACES,
|
|
139
170
|
uncovered: UNCOVERED_PATHS,
|
|
140
171
|
smokeAllSurfaces: SMOKE_ALL_SURFACES,
|
|
141
|
-
} = partitionChangedPaths(CHANGED_UI_PATHS,
|
|
172
|
+
} = partitionChangedPaths(CHANGED_UI_PATHS, ALL_SURFACES, NON_SURFACE_GLOBS);
|
|
142
173
|
|
|
143
174
|
function routeOutDir(slug: string): string {
|
|
144
175
|
return join(OUT_DIR, slug);
|
|
@@ -197,14 +228,16 @@ async function captureBefore(
|
|
|
197
228
|
// NON_SURFACE_GLOBS to hide it.
|
|
198
229
|
test("ui-route-coverage — every changed UI path maps to a capture surface", async () => {
|
|
199
230
|
if (UNCOVERED_PATHS.length > 0) {
|
|
231
|
+
// Mode-aware message (#359): an engineer sees the SURFACES[] + declaration
|
|
232
|
+
// fix; a Designer-Mode / operator session (DF_MODE set) is routed to the
|
|
233
|
+
// designer-safe `.darkfactory/ui-surfaces.json` declaration and
|
|
234
|
+
// `/designer-handoff` as PROGRESS — never to a TS/spec edit. The coverage
|
|
235
|
+
// REQUIREMENT is identical either way; only the remediation copy changes.
|
|
200
236
|
throw new Error(
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
"\n\nAdd a surface for the affected area to SURFACES[] in " +
|
|
206
|
-
"ui-route.producer.spec.ts. Do NOT widen NON_SURFACE_GLOBS to suppress " +
|
|
207
|
-
"a real UI change — that re-opens the false-positive hole this gate closes.",
|
|
237
|
+
formatUncoveredError({
|
|
238
|
+
uncovered: UNCOVERED_PATHS,
|
|
239
|
+
mode: process.env.DF_MODE,
|
|
240
|
+
}),
|
|
208
241
|
);
|
|
209
242
|
}
|
|
210
243
|
expect(UNCOVERED_PATHS).toEqual([]);
|