laxy-verify 1.2.1 → 1.2.3

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 (42) hide show
  1. package/README.md +193 -64
  2. package/dist/a11y-deep.d.ts +20 -0
  3. package/dist/a11y-deep.js +161 -0
  4. package/dist/ai-analysis.d.ts +28 -0
  5. package/dist/ai-analysis.js +32 -0
  6. package/dist/audit/broken-links.d.ts +5 -1
  7. package/dist/audit/broken-links.js +23 -12
  8. package/dist/bundle-size.d.ts +14 -0
  9. package/dist/bundle-size.js +209 -0
  10. package/dist/cli.js +432 -16
  11. package/dist/compare-env.d.ts +23 -0
  12. package/dist/compare-env.js +55 -0
  13. package/dist/config.d.ts +50 -0
  14. package/dist/config.js +149 -4
  15. package/dist/entitlement.d.ts +2 -0
  16. package/dist/entitlement.js +5 -1
  17. package/dist/init-analysis.d.ts +6 -0
  18. package/dist/init-analysis.js +302 -0
  19. package/dist/init.js +66 -0
  20. package/dist/lighthouse.d.ts +31 -1
  21. package/dist/lighthouse.js +76 -3
  22. package/dist/outdated-check.d.ts +17 -0
  23. package/dist/outdated-check.js +123 -0
  24. package/dist/report-markdown.d.ts +14 -0
  25. package/dist/report-markdown.js +21 -0
  26. package/dist/route-discovery.d.ts +7 -0
  27. package/dist/route-discovery.js +108 -0
  28. package/dist/secret-scan.d.ts +15 -0
  29. package/dist/secret-scan.js +218 -0
  30. package/dist/security-audit.d.ts +9 -1
  31. package/dist/security-audit.js +87 -24
  32. package/dist/seo-deep.d.ts +24 -0
  33. package/dist/seo-deep.js +147 -0
  34. package/dist/typecheck.d.ts +8 -0
  35. package/dist/typecheck.js +99 -0
  36. package/dist/verification-core/report.js +117 -0
  37. package/dist/verification-core/types.d.ts +58 -2
  38. package/dist/visual-diff.d.ts +8 -1
  39. package/dist/visual-diff.js +53 -8
  40. package/dist/vitals-budget.d.ts +23 -0
  41. package/dist/vitals-budget.js +168 -0
  42. package/package.json +1 -1
@@ -0,0 +1,302 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.analyzeProjectForInit = analyzeProjectForInit;
37
+ const fs = __importStar(require("node:fs"));
38
+ const path = __importStar(require("node:path"));
39
+ const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
40
+ const IGNORED_DIRS = new Set([
41
+ ".git",
42
+ ".next",
43
+ ".turbo",
44
+ ".vercel",
45
+ ".output",
46
+ ".laxy-verify",
47
+ "coverage",
48
+ "dist",
49
+ "build",
50
+ "node_modules",
51
+ ]);
52
+ function isSourceFile(filePath) {
53
+ return SOURCE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
54
+ }
55
+ function normalizeRoute(route) {
56
+ const cleaned = route
57
+ .replace(/\\u002F/gi, "/")
58
+ .replace(/\\\//g, "/")
59
+ .trim();
60
+ if (!cleaned.startsWith("/"))
61
+ return null;
62
+ const normalized = cleaned.split("?")[0]?.split("#")[0]?.replace(/\/+/g, "/") ?? "/";
63
+ if (!normalized || normalized === "/")
64
+ return "/";
65
+ if (normalized.startsWith("/_next/") || normalized.startsWith("/api/"))
66
+ return null;
67
+ if (/[.*:[\]]/.test(normalized))
68
+ return null;
69
+ if (/\.[a-z0-9]{2,8}$/i.test(normalized))
70
+ return null;
71
+ if (/\s/.test(normalized))
72
+ return null;
73
+ return normalized.endsWith("/") && normalized !== "/" ? normalized.slice(0, -1) : normalized;
74
+ }
75
+ function collectSourceFiles(rootDir, currentDir = rootDir, acc = []) {
76
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
77
+ for (const entry of entries) {
78
+ const fullPath = path.join(currentDir, entry.name);
79
+ if (entry.isDirectory()) {
80
+ if (IGNORED_DIRS.has(entry.name))
81
+ continue;
82
+ collectSourceFiles(rootDir, fullPath, acc);
83
+ continue;
84
+ }
85
+ if (entry.isFile() && isSourceFile(fullPath)) {
86
+ acc.push(fullPath);
87
+ }
88
+ }
89
+ return acc;
90
+ }
91
+ function readFileSafe(filePath) {
92
+ try {
93
+ return fs.readFileSync(filePath, "utf-8");
94
+ }
95
+ catch {
96
+ return "";
97
+ }
98
+ }
99
+ function routeFromNextAppFile(filePath, baseDir) {
100
+ const relative = path.relative(baseDir, filePath).replace(/\\/g, "/");
101
+ if (!relative.startsWith("app/"))
102
+ return null;
103
+ if (!/\/page\.(t|j)sx?$/.test(relative) && !relative.endsWith("/page.mdx"))
104
+ return null;
105
+ const segments = relative
106
+ .replace(/^app\//, "")
107
+ .replace(/\/page\.(t|j)sx?$/, "")
108
+ .replace(/\/page\.mdx$/, "")
109
+ .split("/")
110
+ .filter(Boolean)
111
+ .filter((segment) => !segment.startsWith("("))
112
+ .filter((segment) => !segment.startsWith("@"))
113
+ .filter((segment) => segment !== "page")
114
+ .flatMap((segment) => {
115
+ if (segment === "index")
116
+ return [];
117
+ const cleaned = segment.replace(/\.(t|j)sx?$/, "");
118
+ if (cleaned.startsWith("(") || cleaned.startsWith("[["))
119
+ return [];
120
+ if (cleaned.startsWith("["))
121
+ return [];
122
+ return cleaned ? [cleaned] : [];
123
+ });
124
+ const route = `/${segments.join("/")}`;
125
+ return normalizeRoute(route);
126
+ }
127
+ function routeFromNextPagesFile(filePath, baseDir) {
128
+ const relative = path.relative(baseDir, filePath).replace(/\\/g, "/");
129
+ if (!relative.startsWith("pages/"))
130
+ return null;
131
+ if (!/\.(t|j)sx?$/.test(relative))
132
+ return null;
133
+ if (/^pages\/api\//.test(relative))
134
+ return null;
135
+ const withoutExt = relative.replace(/^pages\//, "").replace(/\.(t|j)sx?$/, "");
136
+ const segments = withoutExt
137
+ .split("/")
138
+ .filter(Boolean)
139
+ .filter((segment) => segment !== "index")
140
+ .filter((segment) => !segment.startsWith("["));
141
+ const route = `/${segments.join("/")}`;
142
+ return normalizeRoute(route);
143
+ }
144
+ function collectRoutesFromFiles(rootDir, files) {
145
+ const routes = new Map();
146
+ for (const filePath of files) {
147
+ const nextAppRoute = routeFromNextAppFile(filePath, rootDir);
148
+ if (nextAppRoute)
149
+ routes.set(nextAppRoute, filePath);
150
+ const nextPagesRoute = routeFromNextPagesFile(filePath, rootDir);
151
+ if (nextPagesRoute)
152
+ routes.set(nextPagesRoute, filePath);
153
+ const content = readFileSafe(filePath);
154
+ if (!content)
155
+ continue;
156
+ const routeRegexes = [
157
+ /(?:path|to|href|router\.push|navigate)\s*\(?\s*[:=]?\s*["'`]((?:\/(?!\/)[^"'`?#]+))["'`]/g,
158
+ /<Route[^>]*path=["'`]((?:\/(?!\/)[^"'`?#]+))["'`]/g,
159
+ ];
160
+ for (const regex of routeRegexes) {
161
+ for (const match of content.matchAll(regex)) {
162
+ const route = normalizeRoute(match[1] ?? "");
163
+ if (route)
164
+ routes.set(route, filePath);
165
+ }
166
+ }
167
+ }
168
+ return Array.from(routes.entries())
169
+ .map(([route, source]) => ({ route, source }))
170
+ .sort((a, b) => a.route.localeCompare(b.route));
171
+ }
172
+ function buildSelectorHints(files) {
173
+ const contents = files.map(readFileSafe).join("\n");
174
+ const has = (pattern) => pattern.test(contents);
175
+ const dataTestIds = Array.from(contents.matchAll(/data-testid=["'`]([^"'`]+)["'`]/g)).map((match) => match[1]);
176
+ const dashboardDataTestId = dataTestIds.find((id) => /(dashboard|workspace|overview|app)/i.test(id));
177
+ const authDataTestId = dataTestIds.find((id) => /(login|signin|auth|account)/i.test(id));
178
+ return {
179
+ email: has(/name=["'`]email["'`]/i)
180
+ ? "input[name=email]"
181
+ : has(/type=["'`]email["'`]/i)
182
+ ? "input[type=email]"
183
+ : "input[name=email]",
184
+ password: has(/name=["'`](password|passcode)["'`]/i)
185
+ ? "input[name=password]"
186
+ : has(/type=["'`]password["'`]/i)
187
+ ? "input[type=password]"
188
+ : "input[name=password]",
189
+ submit: has(/type=["'`]submit["'`]/i)
190
+ ? "button[type=submit]"
191
+ : has(/(sign in|login|continue|submit)/i)
192
+ ? "button"
193
+ : "button[type=submit]",
194
+ search: has(/name=["'`](q|query|search)["'`]/i)
195
+ ? "input[name=search]"
196
+ : has(/placeholder=["'`][^"'`]*(search|검색)[^"'`]*["'`]/i)
197
+ ? "input[type=search]"
198
+ : "input[type=search]",
199
+ primaryInput: has(/textarea/i) ? "textarea" : "input",
200
+ dashboardVisible: dashboardDataTestId ? `[data-testid=${dashboardDataTestId}]` : "main",
201
+ authVisible: authDataTestId ? `[data-testid=${authDataTestId}]` : "main",
202
+ };
203
+ }
204
+ function pickFirstRoute(routes, patterns) {
205
+ return routes.find((route) => patterns.some((pattern) => pattern.test(route)));
206
+ }
207
+ function uniqueRoutes(routes) {
208
+ const seen = new Set();
209
+ const result = [];
210
+ for (const route of routes) {
211
+ if (!route)
212
+ continue;
213
+ const normalized = normalizeRoute(route);
214
+ if (!normalized || normalized === "/" || seen.has(normalized))
215
+ continue;
216
+ seen.add(normalized);
217
+ result.push(normalized);
218
+ }
219
+ return result;
220
+ }
221
+ function analyzeProjectForInit(dir) {
222
+ const files = collectSourceFiles(dir);
223
+ const routeEntries = collectRoutesFromFiles(dir, files);
224
+ const routes = uniqueRoutes(routeEntries.map((entry) => entry.route));
225
+ const selectors = buildSelectorHints(files);
226
+ const loginRoute = pickFirstRoute(routes, [/\/login$/, /\/signin$/, /\/sign-in$/, /\/auth/]);
227
+ const dashboardRoute = pickFirstRoute(routes, [/\/dashboard$/, /\/app$/, /\/workspace$/, /\/overview$/, /\/home$/, /\/projects?$/]);
228
+ const signupRoute = pickFirstRoute(routes, [/\/signup$/, /\/register$/, /\/join$/, /\/sign-up$/]);
229
+ const searchRoute = pickFirstRoute(routes, [/\/search$/, /\/discover$/, /\/explore$/, /\/products$/, /\/items$/]);
230
+ const settingsRoute = pickFirstRoute(routes, [/\/settings$/, /\/profile$/, /\/account$/, /\/billing$/]);
231
+ const scenarios = [];
232
+ if (loginRoute) {
233
+ scenarios.push({
234
+ name: "로그인 후 보호 페이지 접근",
235
+ steps: [
236
+ { goto: loginRoute },
237
+ { fill: selectors.email, with: "test@example.com" },
238
+ { fill: selectors.password, with: "testpass123!" },
239
+ { click: selectors.submit },
240
+ dashboardRoute
241
+ ? { goto: dashboardRoute }
242
+ : { wait: 1200 },
243
+ { expect_visible: dashboardRoute ? selectors.dashboardVisible : "body" },
244
+ ],
245
+ });
246
+ }
247
+ if (signupRoute && scenarios.length < 3) {
248
+ scenarios.push({
249
+ name: "회원가입 주요 폼 제출",
250
+ steps: [
251
+ { goto: signupRoute },
252
+ { fill: selectors.email, with: "test@example.com" },
253
+ { fill: selectors.password, with: "testpass123!" },
254
+ { click: selectors.submit },
255
+ { expect_visible: selectors.authVisible || "body" },
256
+ ],
257
+ });
258
+ }
259
+ if (searchRoute && scenarios.length < 3) {
260
+ scenarios.push({
261
+ name: "핵심 탐색 흐름 확인",
262
+ steps: [
263
+ { goto: searchRoute },
264
+ { fill: selectors.search, with: "test query" },
265
+ { click: selectors.submit },
266
+ { expect_visible: "body" },
267
+ ],
268
+ });
269
+ }
270
+ if (settingsRoute && scenarios.length < 3) {
271
+ scenarios.push({
272
+ name: "설정 화면 렌더 확인",
273
+ steps: [
274
+ { goto: settingsRoute },
275
+ { expect_visible: "body" },
276
+ ],
277
+ });
278
+ }
279
+ if (dashboardRoute && scenarios.length < 3) {
280
+ scenarios.push({
281
+ name: "대시보드 기본 렌더",
282
+ steps: [
283
+ { goto: dashboardRoute },
284
+ { expect_visible: selectors.dashboardVisible || "main" },
285
+ ],
286
+ });
287
+ }
288
+ if (scenarios.length === 0) {
289
+ const firstRoute = routes[0] ?? "/";
290
+ scenarios.push({
291
+ name: "핵심 페이지 기본 렌더",
292
+ steps: [
293
+ { goto: firstRoute },
294
+ { expect_visible: "body" },
295
+ ],
296
+ });
297
+ }
298
+ return {
299
+ scenarios: scenarios.slice(0, 3),
300
+ extraRoutes: routes.slice(0, 10),
301
+ };
302
+ }
package/dist/init.js CHANGED
@@ -37,6 +37,22 @@ exports.runInit = runInit;
37
37
  const fs = __importStar(require("node:fs"));
38
38
  const path = __importStar(require("node:path"));
39
39
  const detect_js_1 = require("./detect.js");
40
+ const init_analysis_js_1 = require("./init-analysis.js");
41
+ function renderStringList(items, indent = " ") {
42
+ return items.map((item) => `${indent}- ${item}`).join("\n");
43
+ }
44
+ function renderScenarios() {
45
+ return `scenarios:
46
+ - name: "로그인 후 대시보드 접근"
47
+ steps:
48
+ - goto: /login
49
+ - fill: input[name=email]
50
+ with: test@example.com
51
+ - fill: input[name=password]
52
+ with: testpass123!
53
+ - click: button[type=submit]
54
+ - expect_visible: main`;
55
+ }
40
56
  function runInit(dir) {
41
57
  const laxyYmlPath = path.join(dir, ".laxy.yml");
42
58
  const workflowDir = path.join(dir, ".github", "workflows");
@@ -48,6 +64,7 @@ function runInit(dir) {
48
64
  else {
49
65
  let detectedFramework = null;
50
66
  let detectedPort = 3000;
67
+ const initAnalysis = (0, init_analysis_js_1.analyzeProjectForInit)(dir);
51
68
  try {
52
69
  const detected = (0, detect_js_1.detect)(dir);
53
70
  detectedFramework = detected.framework ?? "auto";
@@ -56,20 +73,69 @@ function runInit(dir) {
56
73
  catch {
57
74
  // Keep defaults when auto-detection fails.
58
75
  }
76
+ const extraRoutesBlock = initAnalysis.extraRoutes.length > 0
77
+ ? `extra_routes:\n${renderStringList(initAnalysis.extraRoutes)}\n\n`
78
+ : "";
79
+ const scenarioBlocks = initAnalysis.scenarios.length > 0
80
+ ? [
81
+ "scenarios:",
82
+ ...initAnalysis.scenarios.flatMap((scenario) => [
83
+ ` - name: "${scenario.name.replace(/"/g, '\\"')}"`,
84
+ " steps:",
85
+ ...scenario.steps.flatMap((step) => {
86
+ if (step.goto)
87
+ return [` - goto: ${step.goto}`];
88
+ if (step.fill && step.with !== undefined) {
89
+ return [
90
+ ` - fill: ${step.fill}`,
91
+ ` with: ${step.with}`,
92
+ ];
93
+ }
94
+ if (step.click)
95
+ return [` - click: ${step.click}`];
96
+ if (step.expect_visible)
97
+ return [` - expect_visible: ${step.expect_visible}`];
98
+ if (step.expect_text)
99
+ return [` - expect_text: ${step.expect_text}`];
100
+ if (step.wait !== undefined)
101
+ return [` - wait: ${step.wait}`];
102
+ return [];
103
+ }),
104
+ ]),
105
+ ].join("\n")
106
+ : renderScenarios();
59
107
  const ymlContent = `# Generated by laxy-verify --init
60
108
  # See https://github.com/SUNgm24/Laxy/tree/main/laxy-verify for full docs
61
109
  framework: ${detectedFramework} # auto-detected
62
110
  port: ${detectedPort}
63
111
  fail_on: bronze
112
+ lighthouse_runs: 3
64
113
 
65
114
  thresholds:
66
115
  performance: 70
67
116
  accessibility: 85
68
117
  seo: 80
69
118
  best_practices: 80
119
+
120
+ ${extraRoutesBlock}visual_diff:
121
+ pixelmatch_threshold: 0.1
122
+ warn_threshold: 30
123
+ rollback_threshold: 60
124
+ disable_animations: true
125
+ ignore_selectors:
126
+ - "[data-dynamic]"
127
+ - "[data-ignore-diff]"
128
+
129
+ ${scenarioBlocks}
70
130
  `;
71
131
  fs.writeFileSync(laxyYmlPath, ymlContent, "utf-8");
72
132
  console.log("Created .laxy.yml");
133
+ if (initAnalysis.scenarios.length > 0) {
134
+ console.log(`Seeded ${initAnalysis.scenarios.length} draft E2E scenario(s) from your app structure.`);
135
+ }
136
+ if (initAnalysis.extraRoutes.length > 0) {
137
+ console.log(`Detected ${initAnalysis.extraRoutes.length} extra route(s) for SPA / multi-page coverage.`);
138
+ }
73
139
  }
74
140
  if (fs.existsSync(workflowPath)) {
75
141
  console.log(".github/workflows/laxy-verify.yml already exists. Skipping.");
@@ -3,5 +3,35 @@ interface LhResult {
3
3
  scores: LighthouseScores | null;
4
4
  errors: string[];
5
5
  }
6
- export declare function runLighthouse(port: number, runs: number): Promise<LhResult>;
6
+ export interface RouteScoreResult {
7
+ route: string;
8
+ scores: LighthouseScores | null;
9
+ errors: string[];
10
+ }
11
+ export interface MultiRouteLhResult {
12
+ /** Weighted average: root (/) = 2x weight, other routes = 1x */
13
+ aggregated: LighthouseScores | null;
14
+ perRoute: RouteScoreResult[];
15
+ /** True if every route that produced scores individually passed all thresholds */
16
+ allRoutesPass(thresholds: {
17
+ performance: number;
18
+ accessibility: number;
19
+ seo: number;
20
+ bestPractices: number;
21
+ }): boolean;
22
+ }
23
+ export declare function runLighthouse(port: number, runs: number, routePath?: string): Promise<LhResult>;
24
+ /** Run Lighthouse against an arbitrary external URL (Pro: compare-env feature). */
25
+ export declare function runLighthouseOnUrl(fullUrl: string, runs: number): Promise<LhResult>;
26
+ /**
27
+ * Run Lighthouse on multiple routes and aggregate results.
28
+ *
29
+ * Aggregation strategy (weighted average):
30
+ * - Root route "/" receives weight 2 (most visible page)
31
+ * - All other routes receive weight 1
32
+ *
33
+ * Gold eligibility: every route must individually pass all thresholds.
34
+ * Silver/Bronze eligibility: based on the weighted-average aggregate score.
35
+ */
36
+ export declare function runLighthouseOnRoutes(port: number, runs: number, routes: string[]): Promise<MultiRouteLhResult>;
7
37
  export {};
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.runLighthouse = runLighthouse;
37
+ exports.runLighthouseOnUrl = runLighthouseOnUrl;
38
+ exports.runLighthouseOnRoutes = runLighthouseOnRoutes;
37
39
  const node_child_process_1 = require("node:child_process");
38
40
  const fs = __importStar(require("node:fs"));
39
41
  const path = __importStar(require("node:path"));
@@ -114,13 +116,13 @@ const [url, reportPath, chromeDir] = process.argv.slice(2);
114
116
  // .cjs 확장자로 저장해서 Node가 CommonJS로 실행하도록 함
115
117
  fs.writeFileSync(runnerPath, source, "utf-8");
116
118
  }
117
- async function runLighthouse(port, runs) {
119
+ async function runLighthouseCore(fullUrl, runs, label) {
118
120
  const baseTmpDir = path.join(process.cwd(), ".laxy-tmp", `lighthouse-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
119
121
  const reportsDir = path.join(baseTmpDir, "reports");
120
122
  const runnerPath = path.join(baseTmpDir, "run-lighthouse.cjs");
121
123
  fs.mkdirSync(reportsDir, { recursive: true });
122
124
  writeRunnerScript(runnerPath);
123
- console.log(`\n Running Lighthouse (${runs} run${runs > 1 ? "s" : ""})...`);
125
+ console.log(`\n Running Lighthouse on ${label} (${runs} run${runs > 1 ? "s" : ""})...`);
124
126
  const errors = [];
125
127
  const allScores = [];
126
128
  for (let runIndex = 0; runIndex < runs; runIndex++) {
@@ -129,7 +131,7 @@ async function runLighthouse(port, runs) {
129
131
  const chromeDir = path.join(baseTmpDir, `chrome-${runNumber}`);
130
132
  fs.mkdirSync(chromeDir, { recursive: true });
131
133
  console.log(` [lh] Run ${runNumber}/${runs}`);
132
- const child = (0, node_child_process_1.spawn)("node", [runnerPath, `http://127.0.0.1:${port}/`, reportPath, chromeDir], {
134
+ const child = (0, node_child_process_1.spawn)("node", [runnerPath, fullUrl, reportPath, chromeDir], {
133
135
  shell: false,
134
136
  stdio: ["ignore", "pipe", "pipe"],
135
137
  env: {
@@ -196,3 +198,74 @@ async function runLighthouse(port, runs) {
196
198
  await removeDirWithRetries(baseTmpDir);
197
199
  }
198
200
  }
201
+ async function runLighthouse(port, runs, routePath = "/") {
202
+ const normalizedPath = routePath.startsWith("/") ? routePath : `/${routePath}`;
203
+ const label = normalizedPath === "/" ? "/" : normalizedPath;
204
+ return runLighthouseCore(`http://127.0.0.1:${port}${normalizedPath}`, runs, label);
205
+ }
206
+ /** Run Lighthouse against an arbitrary external URL (Pro: compare-env feature). */
207
+ async function runLighthouseOnUrl(fullUrl, runs) {
208
+ return runLighthouseCore(fullUrl, runs, fullUrl);
209
+ }
210
+ /**
211
+ * Run Lighthouse on multiple routes and aggregate results.
212
+ *
213
+ * Aggregation strategy (weighted average):
214
+ * - Root route "/" receives weight 2 (most visible page)
215
+ * - All other routes receive weight 1
216
+ *
217
+ * Gold eligibility: every route must individually pass all thresholds.
218
+ * Silver/Bronze eligibility: based on the weighted-average aggregate score.
219
+ */
220
+ async function runLighthouseOnRoutes(port, runs, routes) {
221
+ // Deduplicate and ensure "/" comes first
222
+ const seen = new Set();
223
+ const ordered = [];
224
+ for (const r of ["/", ...routes]) {
225
+ const normalized = r.startsWith("/") ? r : `/${r}`;
226
+ if (!seen.has(normalized)) {
227
+ seen.add(normalized);
228
+ ordered.push(normalized);
229
+ }
230
+ }
231
+ const perRoute = [];
232
+ for (const route of ordered) {
233
+ const result = await runLighthouse(port, runs, route);
234
+ perRoute.push({ route, scores: result.scores, errors: result.errors });
235
+ }
236
+ // Weighted average across routes that produced scores
237
+ const withScores = perRoute.filter((r) => r.scores !== null);
238
+ let aggregated = null;
239
+ if (withScores.length > 0) {
240
+ let totalWeight = 0;
241
+ let sumPerf = 0;
242
+ let sumA11y = 0;
243
+ let sumSeo = 0;
244
+ let sumBp = 0;
245
+ for (const r of withScores) {
246
+ const weight = r.route === "/" ? 2 : 1;
247
+ totalWeight += weight;
248
+ sumPerf += r.scores.performance * weight;
249
+ sumA11y += r.scores.accessibility * weight;
250
+ sumSeo += r.scores.seo * weight;
251
+ sumBp += r.scores.bestPractices * weight;
252
+ }
253
+ aggregated = {
254
+ performance: Math.round(sumPerf / totalWeight),
255
+ accessibility: Math.round(sumA11y / totalWeight),
256
+ seo: Math.round(sumSeo / totalWeight),
257
+ bestPractices: Math.round(sumBp / totalWeight),
258
+ };
259
+ console.log(` Lighthouse aggregate (weighted avg): P=${aggregated.performance} A=${aggregated.accessibility} S=${aggregated.seo} BP=${aggregated.bestPractices}`);
260
+ }
261
+ return {
262
+ aggregated,
263
+ perRoute,
264
+ allRoutesPass(thresholds) {
265
+ return withScores.every((r) => r.scores.performance >= thresholds.performance &&
266
+ r.scores.accessibility >= thresholds.accessibility &&
267
+ r.scores.seo >= thresholds.seo &&
268
+ r.scores.bestPractices >= thresholds.bestPractices);
269
+ },
270
+ };
271
+ }
@@ -0,0 +1,17 @@
1
+ export interface OutdatedPackage {
2
+ name: string;
3
+ current: string;
4
+ wanted: string;
5
+ latest: string;
6
+ severity: "major" | "minor" | "patch";
7
+ }
8
+ export interface OutdatedCheckResult {
9
+ passed: boolean;
10
+ totalChecked: number;
11
+ outdatedCount: number;
12
+ majorOutdated: number;
13
+ packages: OutdatedPackage[];
14
+ advisory: string;
15
+ skipped: boolean;
16
+ }
17
+ export declare function runOutdatedCheck(projectDir: string, timeoutMs?: number): Promise<OutdatedCheckResult>;
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runOutdatedCheck = runOutdatedCheck;
4
+ /**
5
+ * Outdated dependency checker.
6
+ *
7
+ * Runs `npm outdated --json` and reports packages that are behind
8
+ * their latest version. Advisory-only — does not block deployment.
9
+ */
10
+ const node_child_process_1 = require("node:child_process");
11
+ function classifySemverDiff(current, latest) {
12
+ const parseVer = (v) => {
13
+ const cleaned = v.replace(/^\^|~|>=?|v/g, "").split(".")[0] ?? "0";
14
+ return parseInt(cleaned, 10) || 0;
15
+ };
16
+ const parseMinor = (v) => {
17
+ const parts = v.replace(/^\^|~|>=?|v/g, "").split(".");
18
+ return parseInt(parts[1] ?? "0", 10) || 0;
19
+ };
20
+ const curMajor = parseVer(current);
21
+ const latMajor = parseVer(latest);
22
+ if (latMajor > curMajor)
23
+ return "major";
24
+ const curMinor = parseMinor(current);
25
+ const latMinor = parseMinor(latest);
26
+ if (latMinor > curMinor)
27
+ return "minor";
28
+ return "patch";
29
+ }
30
+ async function runOutdatedCheck(projectDir, timeoutMs = 30000) {
31
+ console.log(" Running outdated dependency check...");
32
+ return new Promise((resolve) => {
33
+ const chunks = [];
34
+ const proc = process.platform === "win32"
35
+ ? (0, node_child_process_1.spawn)(process.env.ComSpec || "cmd.exe", ["/d", "/c", "npm outdated --json"], { stdio: ["ignore", "pipe", "pipe"], cwd: projectDir, shell: false })
36
+ : (0, node_child_process_1.spawn)("npm", ["outdated", "--json"], {
37
+ shell: true,
38
+ stdio: ["ignore", "pipe", "pipe"],
39
+ cwd: projectDir,
40
+ });
41
+ const timer = setTimeout(() => {
42
+ try {
43
+ proc.kill();
44
+ }
45
+ catch { }
46
+ resolve({
47
+ passed: true,
48
+ totalChecked: 0,
49
+ outdatedCount: 0,
50
+ majorOutdated: 0,
51
+ packages: [],
52
+ advisory: "Outdated check timed out",
53
+ skipped: false,
54
+ });
55
+ }, timeoutMs);
56
+ proc.stdout?.on("data", (chunk) => chunks.push(chunk.toString()));
57
+ proc.stderr?.on("data", () => { });
58
+ proc.on("exit", (code) => {
59
+ clearTimeout(timer);
60
+ const output = chunks.join("");
61
+ // npm outdated exits with code 1 when outdated packages exist
62
+ // and code 0 when everything is up to date
63
+ if (code === 0 || !output.trim()) {
64
+ console.log(" Outdated: all packages up to date");
65
+ resolve({
66
+ passed: true,
67
+ totalChecked: 0,
68
+ outdatedCount: 0,
69
+ majorOutdated: 0,
70
+ packages: [],
71
+ advisory: "All packages up to date",
72
+ skipped: false,
73
+ });
74
+ return;
75
+ }
76
+ try {
77
+ const data = JSON.parse(output);
78
+ const packages = [];
79
+ for (const [name, info] of Object.entries(data)) {
80
+ if (!info.current || !info.latest)
81
+ continue;
82
+ const severity = classifySemverDiff(info.current, info.latest);
83
+ packages.push({
84
+ name,
85
+ current: info.current,
86
+ wanted: info.wanted ?? info.current,
87
+ latest: info.latest,
88
+ severity,
89
+ });
90
+ }
91
+ const majorOutdated = packages.filter((p) => p.severity === "major").length;
92
+ const outdatedCount = packages.length;
93
+ const advisory = majorOutdated > 0
94
+ ? `${majorOutdated} major version(s) behind latest; ${outdatedCount} total outdated`
95
+ : `${outdatedCount} package(s) behind latest (no major)`;
96
+ console.log(` Outdated: ${advisory}`);
97
+ for (const pkg of packages.filter((p) => p.severity === "major").slice(0, 5)) {
98
+ console.log(` ${pkg.name}: ${pkg.current} → ${pkg.latest} (${pkg.severity})`);
99
+ }
100
+ resolve({
101
+ passed: majorOutdated === 0,
102
+ totalChecked: 0,
103
+ outdatedCount,
104
+ majorOutdated,
105
+ packages,
106
+ advisory,
107
+ skipped: false,
108
+ });
109
+ }
110
+ catch {
111
+ resolve({
112
+ passed: true,
113
+ totalChecked: 0,
114
+ outdatedCount: 0,
115
+ majorOutdated: 0,
116
+ packages: [],
117
+ advisory: "Could not parse npm outdated output",
118
+ skipped: false,
119
+ });
120
+ }
121
+ });
122
+ });
123
+ }