@williambeto/ai-workflow 2.3.6 → 2.4.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.
@@ -2,6 +2,29 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { StackDetector } from "./stack-detector.js";
4
4
 
5
+ function parseRequest(request = "") {
6
+ const req = request.toLowerCase();
7
+
8
+ let targetStack = null;
9
+ if (/\b(vue|nuxt|quasar)\b/.test(req)) targetStack = "vue";
10
+ else if (/\b(react|next)\b/.test(req)) targetStack = "react";
11
+ else if (/\b(angular|svelte)\b/.test(req)) targetStack = "svelte-or-angular";
12
+ else if (/\b(laravel|django|rails|express|nest|fastify)\b/.test(req)) {
13
+ if (/\blaravel\b/.test(req)) targetStack = "laravel";
14
+ else targetStack = "backend";
15
+ }
16
+ else if (/\b(wordpress|wp)\b/.test(req)) targetStack = "wordpress";
17
+ else if (/\b(cli|command|bin)\b/.test(req)) targetStack = "cli";
18
+
19
+ let intent = "implementation";
20
+ if (/\b(document|doc|readme)\b/.test(req)) intent = "docs";
21
+ else if (/\b(audit|security|inspect|dependencies)\b/.test(req)) intent = "audit";
22
+ else if (/\b(release|publish|deploy)\b/.test(req)) intent = "release";
23
+ else if (/\b(prototype|standalone html)\b/.test(req)) intent = "prototype";
24
+
25
+ return { targetStack, intent };
26
+ }
27
+
5
28
  export class DeliveryDecisionEngine {
6
29
  constructor({ cwd = process.cwd() } = {}) {
7
30
  this.cwd = cwd;
@@ -13,7 +36,6 @@ export class DeliveryDecisionEngine {
13
36
  return "empty";
14
37
  }
15
38
 
16
- // 1. Docs check
17
39
  const isDocs = changedFiles.every(file =>
18
40
  file.endsWith(".md") || file.endsWith(".txt") || file.startsWith("docs/")
19
41
  );
@@ -21,10 +43,8 @@ export class DeliveryDecisionEngine {
21
43
  return "docs-only";
22
44
  }
23
45
 
24
- // 2. Detect project stacks
25
46
  const stacks = await this.stackDetector.detect();
26
47
 
27
- // Check package.json for frontend frameworks
28
48
  let usesJsFramework = false;
29
49
  try {
30
50
  const pkgPath = path.join(this.cwd, "package.json");
@@ -35,14 +55,11 @@ export class DeliveryDecisionEngine {
35
55
  const frameworks = ["react", "next", "vue", "nuxt", "svelte", "solid-js", "preact", "quasar", "@angular/core"];
36
56
  usesJsFramework = frameworks.some(fw => deps[fw] !== undefined);
37
57
  } catch {
38
- // package.json doesn't exist or is invalid
58
+ // ignore
39
59
  }
40
60
 
41
- // Check for WordPress
42
- const isWordPress = await fs.access(path.join(this.cwd, "wp-config.php")).then(() => true).catch(() => false) ||
43
- await fs.access(path.join(this.cwd, "wp-content")).then(() => true).catch(() => false);
61
+ const isWordPress = await checkWordPressDirectRoot(this.cwd);
44
62
 
45
- // Analyze changed files for UI/frontend assets
46
63
  const hasFrontendFiles = changedFiles.some(file =>
47
64
  /\.(html?|css|scss|sass|less|jsx|tsx|vue|svelte)$/i.test(file) ||
48
65
  (/\.js$/i.test(file) && !file.includes("config") && !file.includes("test"))
@@ -66,4 +83,262 @@ export class DeliveryDecisionEngine {
66
83
 
67
84
  return "generic-code";
68
85
  }
86
+
87
+ async detectContext() {
88
+ const stacks = await this.stackDetector.detect();
89
+
90
+ let usesJsFramework = null;
91
+ let hasPackageJson = false;
92
+ let isCli = false;
93
+ try {
94
+ const pkgPath = path.join(this.cwd, "package.json");
95
+ const pkgContent = await fs.readFile(pkgPath, "utf8");
96
+ hasPackageJson = true;
97
+ const pkg = JSON.parse(pkgContent);
98
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
99
+ if (pkg.bin || pkg.name === "@williambeto/ai-workflow") {
100
+ isCli = true;
101
+ }
102
+
103
+ const frameworks = ["react", "next", "vue", "nuxt", "svelte", "solid-js", "preact", "quasar", "@angular/core"];
104
+ for (const fw of frameworks) {
105
+ if (deps[fw] !== undefined) {
106
+ usesJsFramework = fw;
107
+ break;
108
+ }
109
+ }
110
+ } catch {}
111
+
112
+ let isLaravel = false;
113
+ let hasComposerJson = false;
114
+ try {
115
+ const composerPath = path.join(this.cwd, "composer.json");
116
+ const composerContent = await fs.readFile(composerPath, "utf8");
117
+ hasComposerJson = true;
118
+ const composer = JSON.parse(composerContent);
119
+ const reqs = { ...(composer.require || {}), ...(composer.requireDev || {}) };
120
+ if (reqs["laravel/framework"]) {
121
+ isLaravel = true;
122
+ }
123
+ } catch {}
124
+
125
+ const isWordPress = await checkWordPressDirectRoot(this.cwd);
126
+
127
+ let isEmpty = true;
128
+ try {
129
+ const entries = await fs.readdir(this.cwd);
130
+ const meaningful = entries.filter(e => !e.startsWith(".") && !["node_modules", "EVIDENCE.json"].includes(e));
131
+ if (meaningful.length > 0) {
132
+ if (meaningful.length === 1 && meaningful[0] === "package.json") {
133
+ try {
134
+ const content = await fs.readFile(path.join(this.cwd, "package.json"), "utf8");
135
+ const pkg = JSON.parse(content);
136
+ if (!pkg.dependencies && !pkg.devDependencies) {
137
+ isEmpty = true;
138
+ } else {
139
+ isEmpty = false;
140
+ }
141
+ } catch {
142
+ isEmpty = true;
143
+ }
144
+ } else {
145
+ isEmpty = false;
146
+ }
147
+ }
148
+ } catch {
149
+ isEmpty = true;
150
+ }
151
+
152
+ let projectState = isEmpty ? "empty" : "active";
153
+ let detectedStackType = "unknown";
154
+ let detectedStacks = [];
155
+
156
+ if (isWordPress) {
157
+ detectedStackType = "WordPress context";
158
+ detectedStacks.push("wordpress");
159
+ } else if (isLaravel) {
160
+ detectedStackType = "backend context";
161
+ detectedStacks.push("laravel");
162
+ } else if (usesJsFramework) {
163
+ detectedStackType = "existing matching stack";
164
+ detectedStacks.push(usesJsFramework);
165
+ } else if (isCli) {
166
+ detectedStackType = "CLI package context";
167
+ detectedStacks.push("node");
168
+ } else if (stacks.includes("node")) {
169
+ detectedStackType = "existing matching stack";
170
+ detectedStacks.push("node");
171
+ } else if (stacks.includes("python") || stacks.includes("php")) {
172
+ detectedStackType = "backend context";
173
+ detectedStacks.push(...stacks);
174
+ } else if (projectState === "empty") {
175
+ detectedStackType = "empty project";
176
+ }
177
+
178
+ return {
179
+ projectState,
180
+ detectedStackType,
181
+ detectedStacks,
182
+ isWordPress,
183
+ isLaravel,
184
+ usesJsFramework,
185
+ isCli
186
+ };
187
+ }
188
+
189
+ async determineDecision(input = {}) {
190
+ const userRequest = input.userRequest || "";
191
+ const explicitApprovals = input.explicitApprovals || [];
192
+
193
+ const ctx = input.projectContext || await this.detectContext();
194
+ const projectState = ctx.projectState || "active";
195
+ const detectedStacks = ctx.detectedStacks || [];
196
+ const isWordPress = ctx.isWordPress || false;
197
+ const isLaravel = ctx.isLaravel || false;
198
+ const isCli = ctx.isCli || false;
199
+ const usesJsFramework = ctx.usesJsFramework || null;
200
+
201
+ const parsed = parseRequest(userRequest);
202
+ const targetStack = parsed.targetStack;
203
+ const intent = parsed.intent;
204
+
205
+ let decision = "IMPLEMENT_IN_EXISTING_CONTEXT";
206
+ let allowedArtifactShape = "generic-code";
207
+ let forbiddenSubstitutes = ["success-without-validation"];
208
+ let validationRequired = ["build-or-equivalent"];
209
+ let approvalRequired = false;
210
+
211
+ if (intent === "audit") {
212
+ decision = "READ_ONLY_AUDIT";
213
+ allowedArtifactShape = "audit-report";
214
+ forbiddenSubstitutes.push("any-code-mutation");
215
+ validationRequired = [];
216
+ }
217
+ else if (intent === "docs") {
218
+ decision = "DOCS_ONLY";
219
+ allowedArtifactShape = "markdown";
220
+ forbiddenSubstitutes.push("unjustified-code-mutation");
221
+ validationRequired = [];
222
+ }
223
+ else if (intent === "release") {
224
+ decision = "RELEASE_OR_DEPLOY_REQUIRES_APPROVAL";
225
+ allowedArtifactShape = "package-tarball";
226
+ forbiddenSubstitutes.push("publish-without-approval");
227
+ validationRequired = ["release-readiness-or-equivalent"];
228
+
229
+ const hasApproval = explicitApprovals.includes("npm-publish") || explicitApprovals.includes("deploy-production");
230
+ if (hasApproval) {
231
+ decision = "RELEASE_OR_DEPLOY_APPROVED";
232
+ approvalRequired = true;
233
+ }
234
+ }
235
+ else if (intent === "prototype") {
236
+ decision = "CREATE_STANDALONE_PROTOTYPE";
237
+ allowedArtifactShape = "standalone-html";
238
+ validationRequired = [];
239
+ }
240
+ else if (projectState === "empty") {
241
+ decision = "SCAFFOLD_AND_IMPLEMENT";
242
+ if (targetStack === "vue" || targetStack === "react") {
243
+ allowedArtifactShape = "runnable-framework-app";
244
+ forbiddenSubstitutes.push("standalone-html-as-framework-delivery", "loose-framework-files-without-runtime");
245
+ validationRequired.push("dependency-install-or-verification", "runtime-smoke-or-equivalent");
246
+ }
247
+ } else {
248
+ const hasConflict = (targetStack === "vue" && detectedStacks.includes("react")) ||
249
+ (targetStack === "react" && detectedStacks.includes("vue")) ||
250
+ (targetStack === "laravel" && !detectedStacks.includes("laravel") && detectedStacks.length > 0) ||
251
+ (targetStack === "wordpress" && !detectedStacks.includes("wordpress") && detectedStacks.length > 0);
252
+
253
+ if (hasConflict) {
254
+ decision = "BLOCK_STACK_CONFLICT";
255
+ } else if (targetStack === "laravel" && !detectedStacks.includes("laravel")) {
256
+ decision = "BLOCK_UNSUPPORTED_CONTEXT";
257
+ } else if (targetStack === "wordpress" && !detectedStacks.includes("wordpress")) {
258
+ decision = "BLOCK_UNSUPPORTED_CONTEXT";
259
+ } else {
260
+ if (targetStack === "vue" || targetStack === "react") {
261
+ decision = "IMPLEMENT_IN_EXISTING_CONTEXT";
262
+ allowedArtifactShape = "runnable-framework-app";
263
+ forbiddenSubstitutes.push("standalone-html-as-framework-delivery", "loose-framework-files-without-runtime");
264
+ validationRequired.push("dependency-install-or-verification", "runtime-smoke-or-equivalent");
265
+ } else if (targetStack === "cli" || isCli) {
266
+ decision = "CLI_COMMAND_INTEGRATION";
267
+ allowedArtifactShape = "bin-entry";
268
+ }
269
+ }
270
+ }
271
+
272
+ if (userRequest && userRequest.trim().length < 3) {
273
+ decision = "BLOCK_UNCLEAR_SCOPE";
274
+ }
275
+
276
+ return {
277
+ intent: intent === "implementation" ? "implementation" : intent,
278
+ requestedOutcome: targetStack === "vue" || targetStack === "react" ? "page" : (targetStack === "wordpress" ? "plugin-or-theme" : null),
279
+ requestedStack: targetStack,
280
+ requestedPlatform: targetStack === "wordpress" ? "wordpress" : null,
281
+ projectState,
282
+ detectedStacks,
283
+ decision,
284
+ allowedArtifactShape,
285
+ forbiddenSubstitutes,
286
+ validationRequired,
287
+ approvalRequired: explicitApprovals.length > 0
288
+ };
289
+ }
290
+ }
291
+
292
+ async function checkWordPressDirectRoot(cwd) {
293
+ // Check standard paths
294
+ const hasWpConfig = await fs.access(path.join(cwd, "wp-config.php")).then(() => true).catch(() => false);
295
+ const hasWpContent = await fs.access(path.join(cwd, "wp-content")).then(() => true).catch(() => false);
296
+ if (hasWpConfig || hasWpContent) {
297
+ return true;
298
+ }
299
+
300
+ // Check direct Theme root
301
+ const hasStyleCss = await fs.access(path.join(cwd, "style.css")).then(() => true).catch(() => false);
302
+ if (hasStyleCss) {
303
+ try {
304
+ const content = await fs.readFile(path.join(cwd, "style.css"), "utf8");
305
+ if (/\bTheme Name\s*:/i.test(content) || /\bTheme URI\s*:/i.test(content)) {
306
+ const otherIndicators = [
307
+ "functions.php",
308
+ "theme.json",
309
+ "index.php",
310
+ "front-page.php",
311
+ "single.php",
312
+ "page.php",
313
+ "archive.php",
314
+ "template-parts"
315
+ ];
316
+ for (const ind of otherIndicators) {
317
+ const exists = await fs.access(path.join(cwd, ind)).then(() => true).catch(() => false);
318
+ if (exists) {
319
+ return true;
320
+ }
321
+ }
322
+ }
323
+ } catch {
324
+ // ignore
325
+ }
326
+ }
327
+
328
+ // Check direct Plugin root
329
+ try {
330
+ const files = await fs.readdir(cwd);
331
+ for (const file of files) {
332
+ if (file.endsWith(".php")) {
333
+ const content = await fs.readFile(path.join(cwd, file), "utf8");
334
+ if (/\bPlugin Name\s*:/i.test(content)) {
335
+ return true;
336
+ }
337
+ }
338
+ }
339
+ } catch {
340
+ // ignore
341
+ }
342
+
343
+ return false;
69
344
  }
@@ -2,6 +2,9 @@ import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { QualityGuard } from "./quality-guard.js";
5
+ import { DeliveryDecisionEngine } from "./delivery-decision-engine.js";
6
+ import { ArtifactFidelityGate } from "./artifact-fidelity-gate.js";
7
+ import { VisualVerifier } from "./visual-verifier.js";
5
8
 
6
9
  const VALIDATION_KINDS = new Set(["test", "build", "typecheck", "lint", "security", "smoke", "validate", "other"]);
7
10
 
@@ -27,7 +30,7 @@ function inferTaskKind(task = {}) {
27
30
  }
28
31
 
29
32
  export class EvidenceCollector {
30
- constructor({ cwd, maxLogLength = 2000, timeout = 60000, taskSlug = null, mode = null, profile = "generic", branchRecovery = "NOT_RECORDED" } = {}) {
33
+ constructor({ cwd, maxLogLength = 2000, timeout = 60000, taskSlug = null, mode = null, profile = "generic", branchRecovery = "NOT_RECORDED", userRequest = null, explicitApprovals = [], visualDist = null, port = 8080 } = {}) {
31
34
  this.cwd = cwd;
32
35
  this.maxLogLength = maxLogLength;
33
36
  this.timeout = timeout;
@@ -35,6 +38,55 @@ export class EvidenceCollector {
35
38
  this.mode = mode;
36
39
  this.profile = profile;
37
40
  this.branchRecovery = branchRecovery;
41
+ this.userRequest = userRequest;
42
+ this.explicitApprovals = explicitApprovals;
43
+ this.visualDist = visualDist;
44
+ this.port = Number(port || 8080);
45
+ }
46
+
47
+ async findUserRequest() {
48
+ if (this.userRequest) return this.userRequest;
49
+
50
+ if (this.taskSlug) {
51
+ const ledgerPath = path.join(this.cwd, `.ai-workflow/history/${this.taskSlug}-ledger.json`);
52
+ try {
53
+ const content = await fs.readFile(ledgerPath, "utf8");
54
+ const events = JSON.parse(content);
55
+ const routeEvent = events.find(e => e.eventType === "routing");
56
+ if (routeEvent && routeEvent.data?.request) {
57
+ return routeEvent.data.request;
58
+ }
59
+ const startEvent = events.find(e => e.eventType === "implementation_start");
60
+ if (startEvent && startEvent.data?.prompt) {
61
+ return startEvent.data.prompt;
62
+ }
63
+ } catch {}
64
+ }
65
+
66
+ try {
67
+ const dir = path.join(this.cwd, ".ai-workflow/history");
68
+ const files = await fs.readdir(dir);
69
+ for (const file of files) {
70
+ if (file.endsWith("-ledger.json")) {
71
+ const content = await fs.readFile(path.join(dir, file), "utf8");
72
+ const events = JSON.parse(content);
73
+ const routeEvent = events.find(e => e.eventType === "routing");
74
+ if (routeEvent && routeEvent.data?.request) {
75
+ return routeEvent.data.request;
76
+ }
77
+ }
78
+ }
79
+ } catch {}
80
+
81
+ try {
82
+ const { execSync } = await import("node:child_process");
83
+ const branch = execSync("git branch --show-current", { cwd: this.cwd, encoding: "utf8" }).trim();
84
+ if (branch && branch !== "main" && branch !== "master" && branch.startsWith("feat/")) {
85
+ return branch.replace("feat/", "").replaceAll("-", " ");
86
+ }
87
+ } catch {}
88
+
89
+ return "";
38
90
  }
39
91
 
40
92
  runTask(task) {
@@ -95,6 +147,30 @@ export class EvidenceCollector {
95
147
  const behaviorTests = results.filter((result) => result.kind === "test");
96
148
  const passingBehaviorTest = behaviorTests.some((result) => result.status === "PASS");
97
149
 
150
+ const userRequest = await this.findUserRequest();
151
+ const engine = new DeliveryDecisionEngine({ cwd: this.cwd });
152
+ const projectContext = await engine.detectContext();
153
+ const deliveryDecision = await engine.determineDecision({
154
+ userRequest,
155
+ projectContext,
156
+ explicitApprovals: this.explicitApprovals || []
157
+ });
158
+
159
+ const fidelityGate = new ArtifactFidelityGate({
160
+ cwd: this.cwd,
161
+ deliveryClass: deliveryDecision.allowedArtifactShape === "runnable-framework-app" ? "framework-native-frontend" : (deliveryDecision.allowedArtifactShape === "wordpress" ? "wordpress-php" : "generic-code"),
162
+ userRequest,
163
+ explicitApprovals: this.explicitApprovals || []
164
+ });
165
+
166
+ const changedFiles = policyValidation.git?.changedFiles || [];
167
+ const artifactFidelityCheck = await fidelityGate.verifyRequestFidelity({
168
+ userRequest,
169
+ deliveryDecision,
170
+ projectContext,
171
+ changedFiles
172
+ });
173
+
98
174
  let overallStatus = "PASS";
99
175
  if (implementation && executableBehavior && tasks.length === 0) overallStatus = "BLOCKED";
100
176
  else if (results.some((result) => ["FAIL", "BLOCKED", "FAIL_QUALITY_GATE"].includes(result.status))) overallStatus = "FAIL_QUALITY_GATE";
@@ -102,6 +178,16 @@ export class EvidenceCollector {
102
178
  else if (["FAIL", "BLOCKED", "FAIL_QUALITY_GATE"].includes(policyValidation.overallStatus)) overallStatus = "FAIL_QUALITY_GATE";
103
179
  else if (results.some((result) => result.status === "PASS_WITH_NOTES") || policyValidation.overallStatus === "PASS_WITH_NOTES") overallStatus = "PASS_WITH_NOTES";
104
180
 
181
+ if (!artifactFidelityCheck || !artifactFidelityCheck.passed) {
182
+ overallStatus = "BLOCKED";
183
+ } else if (implementation) {
184
+ if (!deliveryDecision || !deliveryDecision.decision || deliveryDecision.decision.startsWith("BLOCK") || deliveryDecision.decision.includes("BLOCKED") || deliveryDecision.decision === "REQUIRE_EXPLICIT_AUTHORIZATION" || deliveryDecision.decision === "RELEASE_OR_DEPLOY_REQUIRES_APPROVAL") {
185
+ overallStatus = "BLOCKED";
186
+ } else if (results.length === 0 && executableBehavior && deliveryDecision.decision !== "DOCUMENTATION_ONLY" && deliveryDecision.decision !== "DOCS_ONLY" && deliveryDecision.decision !== "READ_ONLY_AUDIT") {
187
+ overallStatus = "BLOCKED";
188
+ }
189
+ }
190
+
105
191
  const limitations = [];
106
192
  if (implementation && executableBehavior && tasks.length === 0) limitations.push("No meaningful validation command was available for executable implementation work.");
107
193
  if (executableBehavior && behaviorTests.length === 0) {
@@ -111,6 +197,26 @@ export class EvidenceCollector {
111
197
  if (check.status === "PASS_WITH_NOTES" && check.reason) limitations.push(`${name}: ${check.reason}`);
112
198
  }
113
199
 
200
+ let visualEvidence = null;
201
+ if (this.visualDist) {
202
+ console.log(`\n--- Capturing Visual Evidence ---`);
203
+ console.log(`Serving ${this.visualDist} on port ${this.port}...`);
204
+ try {
205
+ const verifier = new VisualVerifier({ cwd: this.cwd, visualDist: this.visualDist, port: this.port });
206
+ const screenshots = await verifier.captureScreenshots();
207
+ visualEvidence = {
208
+ desktop: path.relative(this.cwd, screenshots[0]),
209
+ mobile: path.relative(this.cwd, screenshots[1])
210
+ };
211
+ console.log(`Visual evidence captured successfully:`);
212
+ console.log(`- Desktop: ${visualEvidence.desktop}`);
213
+ console.log(`- Mobile: ${visualEvidence.mobile}`);
214
+ } catch (err) {
215
+ console.error(`Error capturing visual evidence: ${err.message}`);
216
+ throw err;
217
+ }
218
+ }
219
+
114
220
  const evidence = {
115
221
  timestamp: new Date().toISOString(),
116
222
  taskSlug: this.taskSlug,
@@ -118,12 +224,15 @@ export class EvidenceCollector {
118
224
  workflowProfile: this.profile,
119
225
  branchRecovery: this.branchRecovery,
120
226
  branch: policyValidation.git?.branch || "unknown",
121
- changedFiles: policyValidation.git?.changedFiles || [],
227
+ changedFiles,
122
228
  status: publicStatus(overallStatus),
123
229
  internalStatus: overallStatus,
124
230
  commands: results,
125
231
  checks: policyValidation.checks,
126
- limitations
232
+ limitations,
233
+ deliveryDecision,
234
+ artifactFidelityCheck,
235
+ visualEvidence
127
236
  };
128
237
 
129
238
  if (writeArtifact) {
@@ -0,0 +1,159 @@
1
+ import http from "node:http";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ export class VisualVerifier {
6
+ constructor({ cwd = process.cwd(), visualDist = null, port = 8080 } = {}) {
7
+ this.cwd = cwd;
8
+ this.visualDist = visualDist;
9
+ this.port = Number(port || 8080);
10
+ this.server = null;
11
+ }
12
+
13
+ async startServer() {
14
+ if (!this.visualDist) {
15
+ throw new Error("No --visual-dist directory specified. Cannot start visual server.");
16
+ }
17
+ const distPath = path.resolve(this.cwd, this.visualDist);
18
+ this.server = http.createServer(async (req, res) => {
19
+ try {
20
+ const urlPath = req.url.split("?")[0];
21
+ const filePath = path.join(distPath, urlPath === "/" ? "index.html" : urlPath);
22
+ const content = await fs.readFile(filePath);
23
+
24
+ const ext = path.extname(filePath).toLowerCase();
25
+ let contentType = "text/html";
26
+ if (ext === ".js" || ext === ".mjs") contentType = "application/javascript";
27
+ else if (ext === ".css") contentType = "text/css";
28
+ else if (ext === ".png") contentType = "image/png";
29
+ else if (ext === ".jpg" || ext === ".jpeg") contentType = "image/jpeg";
30
+ else if (ext === ".svg") contentType = "image/svg+xml";
31
+
32
+ res.writeHead(200, { "Content-Type": contentType });
33
+ res.end(content);
34
+ } catch (err) {
35
+ res.writeHead(404, { "Content-Type": "text/plain" });
36
+ res.end("Not Found");
37
+ }
38
+ });
39
+
40
+ await new Promise((resolve, reject) => {
41
+ this.server.listen(this.port, "127.0.0.1", (err) => {
42
+ if (err) reject(err);
43
+ else resolve();
44
+ });
45
+ });
46
+ }
47
+
48
+ async closeServer() {
49
+ if (this.server) {
50
+ await new Promise((resolve) => this.server.close(resolve));
51
+ this.server = null;
52
+ }
53
+ }
54
+
55
+ async captureScreenshots() {
56
+ if (!this.visualDist) return [];
57
+
58
+ let playwright;
59
+ try {
60
+ playwright = await import("playwright");
61
+ } catch {
62
+ throw new Error(
63
+ "Playwright is required to capture visual evidence. Please install it using 'npm install -D playwright' and initialize browsers using 'npx playwright install'."
64
+ );
65
+ }
66
+
67
+ await this.startServer();
68
+ const browser = await playwright.chromium.launch();
69
+ const page = await browser.newPage();
70
+
71
+ const outputDir = path.join(this.cwd, ".evidence/visual");
72
+ await fs.mkdir(outputDir, { recursive: true });
73
+
74
+ const desktopPath = path.join(outputDir, "desktop.png");
75
+ const mobilePath = path.join(outputDir, "mobile.png");
76
+
77
+ try {
78
+ await page.goto(`http://127.0.0.1:${this.port}`);
79
+
80
+ // Desktop
81
+ await page.setViewportSize({ width: 1440, height: 900 });
82
+ await page.screenshot({ path: desktopPath, fullPage: true });
83
+
84
+ // Mobile
85
+ await page.setViewportSize({ width: 390, height: 844 });
86
+ await page.screenshot({ path: mobilePath, fullPage: true });
87
+ } finally {
88
+ await browser.close();
89
+ await this.closeServer();
90
+ }
91
+
92
+ return [desktopPath, mobilePath];
93
+ }
94
+
95
+ async runAccessibilityCheck() {
96
+ if (!this.visualDist) {
97
+ throw new Error("Accessibility check requires a target build directory specified via --visual-dist.");
98
+ }
99
+
100
+ let playwright;
101
+ try {
102
+ playwright = await import("playwright");
103
+ } catch {
104
+ throw new Error(
105
+ "Playwright is required to run accessibility checks. Please install it using 'npm install -D playwright' and initialize browsers using 'npx playwright install'."
106
+ );
107
+ }
108
+
109
+ await this.startServer();
110
+ const browser = await playwright.chromium.launch();
111
+ const page = await browser.newPage();
112
+
113
+ const outputDir = path.join(this.cwd, ".evidence/a11y");
114
+ await fs.mkdir(outputDir, { recursive: true });
115
+
116
+ try {
117
+ await page.goto(`http://127.0.0.1:${this.port}`);
118
+
119
+ // Read axe-core bundle
120
+ let axeScript = "";
121
+ try {
122
+ const axePath = path.join(this.cwd, "node_modules/axe-core/axe.min.js");
123
+ axeScript = await fs.readFile(axePath, "utf8");
124
+ } catch {
125
+ // Fallback: look in dev-kit node_modules
126
+ try {
127
+ // Resolve relative to current module path
128
+ const mainAxePath = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../../node_modules/axe-core/axe.min.js");
129
+ axeScript = await fs.readFile(mainAxePath, "utf8");
130
+ } catch {
131
+ throw new Error("axe-core package is not installed. Please install axe-core using 'npm install -D axe-core'.");
132
+ }
133
+ }
134
+
135
+ await page.evaluate(axeScript);
136
+
137
+ const axeResults = await page.evaluate(() => {
138
+ return window.axe.run();
139
+ });
140
+
141
+ const reportPath = path.join(outputDir, "report.json");
142
+ await fs.writeFile(reportPath, JSON.stringify(axeResults, null, 2));
143
+
144
+ const criticalViolations = axeResults.violations.filter(v => v.impact === "critical");
145
+ const seriousViolations = axeResults.violations.filter(v => v.impact === "serious");
146
+
147
+ return {
148
+ passed: criticalViolations.length === 0,
149
+ reportPath,
150
+ violations: axeResults.violations,
151
+ critical: criticalViolations,
152
+ serious: seriousViolations
153
+ };
154
+ } finally {
155
+ await browser.close();
156
+ await this.closeServer();
157
+ }
158
+ }
159
+ }