proteum 2.1.9 → 2.2.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 (84) hide show
  1. package/.codex/environments/environment.toml +11 -0
  2. package/AGENTS.md +27 -11
  3. package/README.md +30 -11
  4. package/agents/project/AGENTS.md +172 -123
  5. package/agents/project/CODING_STYLE.md +1 -1
  6. package/agents/project/app-root/AGENTS.md +16 -0
  7. package/agents/project/client/AGENTS.md +5 -5
  8. package/agents/project/client/pages/AGENTS.md +13 -13
  9. package/agents/project/diagnostics.md +19 -10
  10. package/agents/project/optimizations.md +5 -6
  11. package/agents/project/root/AGENTS.md +297 -0
  12. package/agents/project/server/routes/AGENTS.md +2 -2
  13. package/agents/project/server/services/AGENTS.md +4 -2
  14. package/agents/project/tests/AGENTS.md +9 -2
  15. package/cli/app/index.ts +31 -7
  16. package/cli/commands/configure.ts +226 -0
  17. package/cli/commands/dev.ts +0 -2
  18. package/cli/commands/diagnose.ts +33 -1
  19. package/cli/commands/explain.ts +1 -1
  20. package/cli/commands/migrate.ts +51 -0
  21. package/cli/commands/orient.ts +169 -0
  22. package/cli/commands/perf.ts +8 -1
  23. package/cli/commands/verify.ts +1003 -49
  24. package/cli/compiler/artifacts/manifest.ts +4 -4
  25. package/cli/compiler/artifacts/routing.ts +2 -2
  26. package/cli/compiler/artifacts/services.ts +12 -3
  27. package/cli/compiler/client/index.ts +65 -19
  28. package/cli/compiler/common/files/style.ts +47 -2
  29. package/cli/compiler/common/generatedRouteModules.ts +31 -38
  30. package/cli/compiler/common/index.ts +10 -0
  31. package/cli/compiler/common/proteumManifest.ts +1 -0
  32. package/cli/compiler/server/index.ts +34 -9
  33. package/cli/context.ts +6 -1
  34. package/cli/index.ts +7 -8
  35. package/cli/migrate/pageContract.ts +516 -0
  36. package/cli/paths.ts +47 -6
  37. package/cli/presentation/commands.ts +100 -10
  38. package/cli/presentation/devSession.ts +4 -6
  39. package/cli/presentation/help.ts +2 -2
  40. package/cli/presentation/ink.ts +10 -5
  41. package/cli/presentation/welcome.ts +2 -4
  42. package/cli/runtime/commands.ts +94 -1
  43. package/cli/scaffold/index.ts +2 -2
  44. package/cli/scaffold/templates.ts +4 -2
  45. package/cli/utils/agents.ts +273 -58
  46. package/client/dev/profiler/index.tsx +3 -2
  47. package/client/router.ts +10 -2
  48. package/client/services/router/index.tsx +6 -22
  49. package/common/dev/connect.ts +20 -4
  50. package/common/dev/console.ts +7 -0
  51. package/common/dev/contractsDoctor.ts +354 -0
  52. package/common/dev/diagnostics.ts +10 -7
  53. package/common/dev/inspection.ts +830 -38
  54. package/common/dev/performance.ts +19 -5
  55. package/common/dev/profiler.ts +1 -0
  56. package/common/dev/proteumManifest.ts +5 -4
  57. package/common/dev/requestTrace.ts +78 -1
  58. package/common/env/proteumEnv.ts +10 -3
  59. package/common/router/contracts.ts +8 -11
  60. package/common/router/index.ts +2 -2
  61. package/common/router/pageData.ts +72 -0
  62. package/common/router/register.ts +10 -46
  63. package/common/router/response/page.ts +28 -16
  64. package/docs/assets/unique-domains-chip.png +0 -0
  65. package/docs/dev-sessions.md +8 -4
  66. package/docs/diagnostics.md +77 -11
  67. package/docs/migrate-from-2.1.3.md +388 -0
  68. package/docs/request-tracing.md +42 -9
  69. package/package.json +6 -1
  70. package/scripts/update-codex-agents.ts +2 -2
  71. package/server/app/container/console/index.ts +11 -1
  72. package/server/app/container/trace/index.ts +370 -72
  73. package/server/app/devDiagnostics.ts +1 -1
  74. package/server/app/index.ts +5 -1
  75. package/server/services/auth/index.ts +9 -0
  76. package/server/services/prisma/index.ts +15 -12
  77. package/server/services/router/http/index.ts +1 -1
  78. package/server/services/router/index.ts +105 -23
  79. package/server/services/router/request/api.ts +7 -1
  80. package/server/services/router/request/index.ts +2 -1
  81. package/server/services/router/response/index.ts +8 -28
  82. package/types/global/vendors.d.ts +12 -0
  83. package/types/vendors.d.ts +12 -0
  84. package/common/router/pageSetup.ts +0 -51
@@ -1,11 +1,40 @@
1
1
  import { spawn } from 'child_process';
2
- import fs from 'fs';
2
+ import fs from 'fs-extra';
3
+ import got, { type Method } from 'got';
3
4
  import path from 'path';
4
-
5
- import got from 'got';
6
5
  import { UsageError } from 'clipanion';
7
6
 
8
7
  import cli from '..';
8
+ import Compiler from '../compiler';
9
+ import Paths from '../paths';
10
+ import { readProteumManifest } from '../compiler/common/proteumManifest';
11
+ import { buildContractsDoctorResponse } from '@common/dev/contractsDoctor';
12
+ import { buildDoctorResponse, type TDoctorResponse } from '@common/dev/diagnostics';
13
+ import { buildOrientationResponse, type TDiagnoseChainItem, type TDiagnoseResponse, type TOrientResponse } from '@common/dev/inspection';
14
+ import type { TProteumManifest, TProteumManifestDiagnostic } from '@common/dev/proteumManifest';
15
+ import type { TDevCommandRunResponse } from '@common/dev/commands';
16
+ import type { TDevSessionErrorResponse, TDevSessionStartResponse } from '@common/dev/session';
17
+
18
+ type TVerifySeverity = 'error' | 'warning';
19
+ type TVerifyStepStatus = 'failed' | 'info' | 'passed';
20
+
21
+ type TVerifyFinding = {
22
+ severity: TVerifySeverity;
23
+ blocking: boolean;
24
+ code: string;
25
+ message: string;
26
+ source: 'browser' | 'contracts' | 'doctor' | 'framework-change' | 'request' | 'command';
27
+ filepath?: string;
28
+ sourceLocation?: { line?: number; column?: number };
29
+ relatedFilepaths?: string[];
30
+ details?: string[];
31
+ };
32
+
33
+ type TVerifyStep = {
34
+ label: string;
35
+ status: TVerifyStepStatus;
36
+ details: string[];
37
+ };
9
38
 
10
39
  type TVerifyAppResult = {
11
40
  appRoot: string;
@@ -20,7 +49,19 @@ type TVerifyAppResult = {
20
49
 
21
50
  type TVerifyResult = {
22
51
  action: string;
23
- apps: TVerifyAppResult[];
52
+ target?: string;
53
+ orientation?: TOrientResponse;
54
+ introducedFindings: TVerifyFinding[];
55
+ preExistingFindings: TVerifyFinding[];
56
+ verificationSteps: TVerifyStep[];
57
+ result: {
58
+ ok: boolean;
59
+ strictGlobal: boolean;
60
+ introducedBlockingFindings: number;
61
+ preExistingBlockingFindings: number;
62
+ blockingFindings: number;
63
+ };
64
+ apps?: TVerifyAppResult[];
24
65
  };
25
66
 
26
67
  type TEnsureServerResult =
@@ -35,27 +76,126 @@ type TVerifyAppConfig = {
35
76
  route: string;
36
77
  };
37
78
 
79
+ type TBrowserVerificationResult = {
80
+ runId: string;
81
+ workspaceRoot: string;
82
+ url: string;
83
+ title: string;
84
+ statusCode?: number;
85
+ consoleMessages: Array<{ type: string; text: string }>;
86
+ pageErrors: string[];
87
+ };
88
+
89
+ type TVerifyPlaywrightCookie = {
90
+ name: string;
91
+ value: string;
92
+ url: string;
93
+ expires: number;
94
+ httpOnly: boolean;
95
+ secure: boolean;
96
+ sameSite: 'Lax';
97
+ };
98
+
99
+ type TVerifyBrowserWorkspace = {
100
+ runId: string;
101
+ workspaceRoot: string;
102
+ outputDir: string;
103
+ profileDir: string;
104
+ };
105
+
38
106
  const defaultApps = {
39
107
  crosspath: '/Users/gaetan/Desktop/Projets/crosspath/platform',
40
- product: '/Users/gaetan/Desktop/Projets/unique.domains/product',
41
- website: '/Users/gaetan/Desktop/Projets/unique.domains/website',
108
+ product: '/Users/gaetan/Desktop/Projets/unique.domains/platform/apps/product',
109
+ website: '/Users/gaetan/Desktop/Projets/unique.domains/platform/apps/website',
42
110
  };
43
111
 
44
112
  const normalizeBaseUrl = (value: string) => value.replace(/\/+$/, '');
45
- const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
113
+ const normalizeFilepath = (value: string) => value.replace(/\\/g, '/');
46
114
  const dedupe = <TValue>(values: TValue[]) => [...new Set(values)];
115
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
47
116
  const createLocalBaseUrl = (port: number) => `http://localhost:${port}`;
117
+ const browserLockPattern = /lock|singleton/i;
48
118
  const getBaseUrlCandidates = (port: number) =>
49
119
  dedupe([createLocalBaseUrl(port), `http://127.0.0.1:${port}`, `http://[::1]:${port}`]);
120
+ const createBrowserRunId = (now = new Date()) => {
121
+ const date = now.toISOString().slice(0, 10).replace(/-/g, '');
122
+ const suffix = Math.random().toString(36).slice(2, 8);
123
+
124
+ return `${date}-${suffix}`;
125
+ };
50
126
 
51
- const fetchJson = async <TResponse>(baseUrl: string, pathname: string) => {
127
+ const createBrowserWorkspace = ({
128
+ appRoot,
129
+ runId = createBrowserRunId(),
130
+ }: {
131
+ appRoot: string;
132
+ runId?: string;
133
+ }): TVerifyBrowserWorkspace => {
134
+ const workspaceRoot = path.join(appRoot, 'var', 'proteum', 'browser', runId);
135
+ const outputDir = path.join(workspaceRoot, 'output');
136
+ const profileDir = path.join(workspaceRoot, 'profile');
137
+
138
+ fs.ensureDirSync(outputDir);
139
+ fs.ensureDirSync(profileDir);
140
+
141
+ return {
142
+ runId,
143
+ workspaceRoot,
144
+ outputDir,
145
+ profileDir,
146
+ };
147
+ };
148
+
149
+ const removeBrowserLocks = (currentPath: string) => {
150
+ if (!fs.existsSync(currentPath)) return;
151
+
152
+ for (const dirent of fs.readdirSync(currentPath, { withFileTypes: true })) {
153
+ const entryPath = path.join(currentPath, dirent.name);
154
+
155
+ if (dirent.isDirectory()) {
156
+ removeBrowserLocks(entryPath);
157
+ continue;
158
+ }
159
+
160
+ if (browserLockPattern.test(dirent.name)) fs.removeSync(entryPath);
161
+ }
162
+ };
163
+
164
+ const cleanupBrowserRunLocks = async (workspaceRoot: string) => {
165
+ try {
166
+ removeBrowserLocks(workspaceRoot);
167
+ } catch {
168
+ // Best-effort cleanup for stale browser locks.
169
+ }
170
+ };
171
+
172
+ const getRouterPortFromManifestFile = (manifestFilepath: string) => {
173
+ if (!fs.existsSync(manifestFilepath)) return undefined;
174
+
175
+ const manifest = fs.readJsonSync(manifestFilepath, { throws: false }) as
176
+ | { env?: { resolved?: { routerPort?: number } } }
177
+ | undefined;
178
+ const port = manifest?.env?.resolved?.routerPort;
179
+
180
+ if (typeof port !== 'number' || port <= 0) return undefined;
181
+
182
+ return port;
183
+ };
184
+
185
+ const fetchJson = async <TResponse>(baseUrl: string, pathname: string, options?: { json?: object; method?: 'GET' | 'POST' }) => {
52
186
  const response = await got(`${normalizeBaseUrl(baseUrl)}${pathname}`, {
187
+ method: options?.method || 'GET',
188
+ json: options?.json,
53
189
  responseType: 'json',
54
190
  retry: { limit: 0 },
55
191
  throwHttpErrors: false,
56
192
  });
57
193
 
58
- if (response.statusCode >= 400) throw new UsageError(`Request ${pathname} failed with status ${response.statusCode}.`);
194
+ if (response.statusCode >= 400) {
195
+ const body = response.body as { error?: string } | undefined;
196
+ throw new UsageError(body?.error || `Request ${pathname} failed with status ${response.statusCode}.`);
197
+ }
198
+
59
199
  return response.body as TResponse;
60
200
  };
61
201
 
@@ -126,6 +266,771 @@ const ensureServer = async ({
126
266
  }
127
267
  };
128
268
 
269
+ const resolveLocalManifest = async () => {
270
+ const compiler = new Compiler('dev');
271
+ await compiler.refreshGeneratedTypings();
272
+ return readProteumManifest(cli.paths.appRoot);
273
+ };
274
+
275
+ const resolveOrientation = async (query: string) => {
276
+ const manifest = await resolveLocalManifest();
277
+ return {
278
+ manifest,
279
+ orientation: buildOrientationResponse(manifest, query),
280
+ };
281
+ };
282
+
283
+ const collectRelevantFilepaths = ({
284
+ manifest,
285
+ orientation,
286
+ chain,
287
+ }: {
288
+ manifest: TProteumManifest;
289
+ orientation: TOrientResponse;
290
+ chain?: TDiagnoseChainItem[];
291
+ }) => {
292
+ const filepaths = new Set<string>();
293
+
294
+ for (const match of orientation.owner.matches) {
295
+ if (match.source.filepath) filepaths.add(normalizeFilepath(path.resolve(match.source.filepath)));
296
+ }
297
+
298
+ for (const item of chain || []) {
299
+ if (item.source?.filepath) filepaths.add(normalizeFilepath(path.resolve(item.source.filepath)));
300
+ }
301
+
302
+ if (orientation.connected.imports.length > 0 || orientation.connected.producers.length > 0) {
303
+ filepaths.add(normalizeFilepath(path.resolve(manifest.app.setupFilepath)));
304
+ for (const producer of orientation.connected.producers) {
305
+ if (producer.cachedContractFilepath) filepaths.add(normalizeFilepath(path.resolve(producer.cachedContractFilepath)));
306
+ }
307
+ }
308
+
309
+ return filepaths;
310
+ };
311
+
312
+ const diagnosticTouchesRelevantFiles = (diagnostic: TProteumManifestDiagnostic, relevantFilepaths: Set<string>) => {
313
+ const diagnosticFilepath = normalizeFilepath(path.resolve(diagnostic.filepath));
314
+ if (relevantFilepaths.has(diagnosticFilepath)) return true;
315
+
316
+ return (diagnostic.relatedFilepaths || []).some((filepath) => relevantFilepaths.has(normalizeFilepath(path.resolve(filepath))));
317
+ };
318
+
319
+ const toFindingFromDiagnostic = (
320
+ diagnostic: TProteumManifestDiagnostic,
321
+ source: 'contracts' | 'doctor',
322
+ ): TVerifyFinding => ({
323
+ severity: diagnostic.level === 'error' ? 'error' : 'warning',
324
+ blocking: diagnostic.level === 'error',
325
+ code: diagnostic.code,
326
+ message: diagnostic.message,
327
+ source,
328
+ filepath: diagnostic.filepath,
329
+ sourceLocation: diagnostic.sourceLocation,
330
+ relatedFilepaths: diagnostic.relatedFilepaths,
331
+ details: diagnostic.fixHint ? [`fix=${diagnostic.fixHint}`] : undefined,
332
+ });
333
+
334
+ const classifyDiagnostics = ({
335
+ contracts,
336
+ doctor,
337
+ manifest,
338
+ orientation,
339
+ chain,
340
+ }: {
341
+ contracts: TDoctorResponse;
342
+ doctor: TDoctorResponse;
343
+ manifest: TProteumManifest;
344
+ orientation: TOrientResponse;
345
+ chain?: TDiagnoseChainItem[];
346
+ }) => {
347
+ const relevantFilepaths = collectRelevantFilepaths({ manifest, orientation, chain });
348
+ const introducedFindings: TVerifyFinding[] = [];
349
+ const preExistingFindings: TVerifyFinding[] = [];
350
+
351
+ const classify = (diagnostics: TProteumManifestDiagnostic[], source: 'contracts' | 'doctor') => {
352
+ for (const diagnostic of diagnostics) {
353
+ const finding = toFindingFromDiagnostic(diagnostic, source);
354
+ if (diagnosticTouchesRelevantFiles(diagnostic, relevantFilepaths)) introducedFindings.push(finding);
355
+ else preExistingFindings.push(finding);
356
+ }
357
+ };
358
+
359
+ classify(doctor.diagnostics, 'doctor');
360
+ classify(contracts.diagnostics, 'contracts');
361
+
362
+ return { introducedFindings, preExistingFindings };
363
+ };
364
+
365
+ const finalizeResult = ({
366
+ action,
367
+ apps,
368
+ introducedFindings,
369
+ orientation,
370
+ preExistingFindings,
371
+ strictGlobal,
372
+ target,
373
+ verificationSteps,
374
+ }: {
375
+ action: string;
376
+ target?: string;
377
+ orientation?: TOrientResponse;
378
+ apps?: TVerifyAppResult[];
379
+ introducedFindings: TVerifyFinding[];
380
+ preExistingFindings: TVerifyFinding[];
381
+ verificationSteps: TVerifyStep[];
382
+ strictGlobal: boolean;
383
+ }): TVerifyResult => {
384
+ const introducedBlockingFindings = introducedFindings.filter((finding) => finding.blocking).length;
385
+ const preExistingBlockingFindings = preExistingFindings.filter((finding) => finding.blocking).length;
386
+ const ok = introducedBlockingFindings === 0 && (!strictGlobal || preExistingBlockingFindings === 0);
387
+
388
+ return {
389
+ action,
390
+ ...(target ? { target } : {}),
391
+ ...(orientation ? { orientation } : {}),
392
+ ...(apps ? { apps } : {}),
393
+ introducedFindings,
394
+ preExistingFindings,
395
+ verificationSteps,
396
+ result: {
397
+ ok,
398
+ strictGlobal,
399
+ introducedBlockingFindings,
400
+ preExistingBlockingFindings,
401
+ blockingFindings: introducedBlockingFindings + preExistingBlockingFindings,
402
+ },
403
+ };
404
+ };
405
+
406
+ const renderFindings = (title: string, findings: TVerifyFinding[]) =>
407
+ findings.length === 0
408
+ ? [title, '- none'].join('\n')
409
+ : [
410
+ title,
411
+ ...findings.map(
412
+ (finding) =>
413
+ `- [${finding.severity}] ${finding.code} ${finding.message}${finding.filepath ? ` source=${finding.filepath}${finding.sourceLocation?.line ? `:${finding.sourceLocation.line}` : ''}${finding.sourceLocation?.column ? `:${finding.sourceLocation.column}` : ''}` : ''}${finding.details && finding.details.length > 0 ? ` details=${finding.details.join(', ')}` : ''}`,
414
+ ),
415
+ ].join('\n');
416
+
417
+ const renderSteps = (steps: TVerifyStep[]) =>
418
+ [
419
+ 'Verification Steps',
420
+ ...(steps.length === 0
421
+ ? ['- none']
422
+ : steps.map((step) => `- [${step.status}] ${step.label}${step.details.length > 0 ? ` | ${step.details.join(', ')}` : ''}`)),
423
+ ].join('\n');
424
+
425
+ const renderFrameworkApps = (apps: TVerifyAppResult[]) =>
426
+ apps
427
+ .flatMap((app) => [
428
+ '',
429
+ `${app.name}`,
430
+ `- root=${app.appRoot}`,
431
+ `- baseUrl=${app.baseUrl}`,
432
+ `- startup=${app.startup}`,
433
+ `- page=${app.page.statusCode} ${app.page.url}`,
434
+ `- explain routes=${app.explain.routes} controllers=${app.explain.controllers} commands=${app.explain.commands}`,
435
+ `- doctor errors=${app.doctor.errors} warnings=${app.doctor.warnings}`,
436
+ `- contracts errors=${app.contracts.errors} warnings=${app.contracts.warnings}`,
437
+ ])
438
+ .join('\n');
439
+
440
+ const renderHuman = (result: TVerifyResult) =>
441
+ [
442
+ `Proteum verify ${result.action}${result.target ? ` ${result.target}` : ''}`,
443
+ ...(result.orientation
444
+ ? [
445
+ `- appRoot=${result.orientation.app.appRoot}`,
446
+ `- repoRoot=${result.orientation.app.repoRoot}`,
447
+ `- owner=${result.orientation.owner.matches[0]?.label || 'none'}`,
448
+ ]
449
+ : []),
450
+ ...(result.apps ? [renderFrameworkApps(result.apps)] : []),
451
+ '',
452
+ renderSteps(result.verificationSteps),
453
+ '',
454
+ renderFindings('Introduced Findings', result.introducedFindings),
455
+ '',
456
+ renderFindings('Pre-existing Findings', result.preExistingFindings),
457
+ '',
458
+ `Result\n- ok=${result.result.ok}\n- strictGlobal=${result.result.strictGlobal}\n- introducedBlockingFindings=${result.result.introducedBlockingFindings}\n- preExistingBlockingFindings=${result.result.preExistingBlockingFindings}`,
459
+ ].join('\n');
460
+
461
+ const getSessionErrorMessage = (body: TDevSessionErrorResponse | object | string | undefined, statusCode: number) => {
462
+ if (typeof body === 'object' && body !== null && 'error' in body && typeof body.error === 'string') {
463
+ return body.error;
464
+ }
465
+
466
+ return `Session request failed with status ${statusCode}.`;
467
+ };
468
+
469
+ const requestSession = async ({ baseUrl, email, role }: { baseUrl: string; email: string; role: string }) => {
470
+ const response = await got(`${normalizeBaseUrl(baseUrl)}/__proteum/session/start`, {
471
+ method: 'POST',
472
+ json: role ? { email, role } : { email },
473
+ responseType: 'json',
474
+ throwHttpErrors: false,
475
+ retry: { limit: 0 },
476
+ });
477
+
478
+ if (response.statusCode >= 400) {
479
+ throw new UsageError(
480
+ getSessionErrorMessage(response.body as TDevSessionErrorResponse | object | string | undefined, response.statusCode),
481
+ );
482
+ }
483
+
484
+ const payload = response.body as TDevSessionStartResponse;
485
+ const expires = Math.floor(Date.parse(payload.session.expiresAt) / 1000);
486
+ const secure = new URL(baseUrl).protocol === 'https:';
487
+
488
+ return {
489
+ cookieHeader: `${payload.session.cookieName}=${payload.session.token}`,
490
+ playwrightCookies: [
491
+ {
492
+ name: payload.session.cookieName,
493
+ value: payload.session.token,
494
+ url: normalizeBaseUrl(baseUrl),
495
+ expires,
496
+ httpOnly: false,
497
+ secure,
498
+ sameSite: 'Lax',
499
+ } satisfies TVerifyPlaywrightCookie,
500
+ ],
501
+ };
502
+ };
503
+
504
+ const hitRequest = async ({
505
+ baseUrl,
506
+ cookieHeader,
507
+ dataJson,
508
+ method,
509
+ requestPath,
510
+ }: {
511
+ baseUrl: string;
512
+ cookieHeader?: string;
513
+ dataJson?: unknown;
514
+ method: Method;
515
+ requestPath: string;
516
+ }) => {
517
+ const targetUrl = requestPath.startsWith('http://') || requestPath.startsWith('https://') ? requestPath : `${baseUrl}${requestPath}`;
518
+ const headers = {
519
+ ...(cookieHeader ? { Cookie: cookieHeader } : {}),
520
+ ...(dataJson !== undefined ? { 'Content-Type': 'application/json' } : {}),
521
+ };
522
+ const response = await got(targetUrl, {
523
+ body: dataJson !== undefined ? JSON.stringify(dataJson) : undefined,
524
+ followRedirect: false,
525
+ headers: Object.keys(headers).length > 0 ? headers : undefined,
526
+ method,
527
+ retry: { limit: 0 },
528
+ throwHttpErrors: false,
529
+ });
530
+
531
+ return { statusCode: response.statusCode, url: targetUrl };
532
+ };
533
+
534
+ const armTrace = async (baseUrl: string) => {
535
+ await fetchJson(baseUrl, '/__proteum/trace/arm', { method: 'POST', json: { capture: 'deep' } });
536
+ };
537
+
538
+ const requestDiagnose = async (baseUrl: string, target: string) => {
539
+ const params = new URLSearchParams({ query: target });
540
+ if (target.startsWith('/')) params.set('path', target);
541
+ return await fetchJson<TDiagnoseResponse>(baseUrl, `/__proteum/diagnose?${params.toString()}`);
542
+ };
543
+
544
+ const requestCommandRun = async (baseUrl: string, commandPath: string) =>
545
+ await fetchJson<TDevCommandRunResponse>(baseUrl, '/__proteum/commands/run', {
546
+ method: 'POST',
547
+ json: { path: commandPath },
548
+ });
549
+
550
+ const parseDataJson = () => {
551
+ if (typeof cli.args.dataJson !== 'string' || !cli.args.dataJson.trim()) return undefined;
552
+
553
+ try {
554
+ return JSON.parse(cli.args.dataJson);
555
+ } catch (error) {
556
+ throw new UsageError(`Invalid --data-json payload: ${error instanceof Error ? error.message : String(error)}`);
557
+ }
558
+ };
559
+
560
+ const resolveFocusedPort = (manifest: TProteumManifest) => {
561
+ if (typeof cli.args.port === 'string' && cli.args.port.trim()) {
562
+ const parsedPort = Number.parseInt(cli.args.port.trim(), 10);
563
+ if (!Number.isInteger(parsedPort) || parsedPort <= 0) throw new UsageError(`Invalid --port value "${cli.args.port}".`);
564
+ return parsedPort;
565
+ }
566
+
567
+ return getRouterPortFromManifestFile(path.join(manifest.app.root, '.proteum', 'manifest.json')) || manifest.env.resolved.routerPort;
568
+ };
569
+
570
+ const ensureFocusedServer = async (manifest: TProteumManifest) => {
571
+ const explicitUrl = typeof cli.args.url === 'string' && cli.args.url.trim();
572
+ if (explicitUrl) {
573
+ await fetchJson(explicitUrl, '/__proteum/explain?section=app');
574
+ return { baseUrl: normalizeBaseUrl(explicitUrl), startup: 'reused' as const };
575
+ }
576
+
577
+ return await ensureServer({
578
+ appRoot: manifest.app.root,
579
+ port: resolveFocusedPort(manifest),
580
+ });
581
+ };
582
+
583
+ const runBrowserVerification = async ({
584
+ appRoot,
585
+ baseUrl,
586
+ playwrightCookies,
587
+ target,
588
+ }: {
589
+ appRoot: string;
590
+ baseUrl: string;
591
+ target: string;
592
+ playwrightCookies?: Array<{
593
+ name: string;
594
+ value: string;
595
+ url: string;
596
+ expires: number;
597
+ httpOnly: boolean;
598
+ secure: boolean;
599
+ sameSite: 'Lax';
600
+ }>;
601
+ }) => {
602
+ const paths = new Paths(appRoot, cli.paths.core.root);
603
+ let playwrightModulePath: string | undefined;
604
+
605
+ try {
606
+ playwrightModulePath = paths.resolveRequest('playwright');
607
+ } catch (_error) {
608
+ try {
609
+ playwrightModulePath = paths.resolveRequest('@playwright/test');
610
+ } catch (_innerError) {}
611
+ }
612
+
613
+ if (!playwrightModulePath) {
614
+ throw new UsageError(
615
+ `Playwright is not installed in ${appRoot}. Install \`@playwright/test\` or \`playwright\`, then use \`npx playwright install chromium\`. Fallback: \`proteum verify request ${target}\`.`,
616
+ );
617
+ }
618
+
619
+ const playwright = require(playwrightModulePath) as {
620
+ chromium?: {
621
+ launchPersistentContext: (
622
+ userDataDir: string,
623
+ options: { headless: boolean },
624
+ ) => Promise<{
625
+ newPage: () => Promise<{
626
+ on: (event: string, listener: (...args: any[]) => void) => void;
627
+ goto: (url: string, options: { waitUntil: 'domcontentloaded' | 'load' }) => Promise<{ status: () => number } | null>;
628
+ title: () => Promise<string>;
629
+ screenshot: (options: { fullPage: boolean; path: string }) => Promise<void>;
630
+ waitForTimeout: (ms: number) => Promise<void>;
631
+ }>;
632
+ addCookies: (cookies: NonNullable<typeof playwrightCookies>) => Promise<void>;
633
+ close: () => Promise<void>;
634
+ }>;
635
+ };
636
+ };
637
+
638
+ if (!playwright.chromium?.launchPersistentContext) {
639
+ throw new UsageError(
640
+ `Resolved Playwright package at ${playwrightModulePath}, but Chromium is unavailable. Run \`npx playwright install chromium\` in ${appRoot}.`,
641
+ );
642
+ }
643
+
644
+ const workspace = createBrowserWorkspace({ appRoot });
645
+ await cleanupBrowserRunLocks(workspace.workspaceRoot);
646
+
647
+ const targetUrl = target.startsWith('http://') || target.startsWith('https://') ? target : `${baseUrl}${target}`;
648
+ const consoleMessages: Array<{ type: string; text: string }> = [];
649
+ const pageErrors: string[] = [];
650
+
651
+ let browserContext:
652
+ | {
653
+ newPage: () => Promise<any>;
654
+ addCookies: (cookies: NonNullable<typeof playwrightCookies>) => Promise<void>;
655
+ close: () => Promise<void>;
656
+ }
657
+ | undefined;
658
+
659
+ try {
660
+ browserContext = await playwright.chromium.launchPersistentContext(workspace.profileDir, { headless: true });
661
+ if (playwrightCookies && playwrightCookies.length > 0) await browserContext.addCookies(playwrightCookies);
662
+
663
+ const page = await browserContext.newPage();
664
+ page.on('console', (message: { type: () => string; text: () => string }) => {
665
+ consoleMessages.push({ type: message.type(), text: message.text() });
666
+ });
667
+ page.on('pageerror', (error: Error) => pageErrors.push(error.message));
668
+
669
+ const response = await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
670
+ await page.waitForTimeout(800);
671
+ const title = await page.title();
672
+ await page.screenshot({ fullPage: true, path: path.join(workspace.outputDir, 'page.png') });
673
+
674
+ return {
675
+ runId: workspace.runId,
676
+ workspaceRoot: workspace.workspaceRoot,
677
+ url: targetUrl,
678
+ title,
679
+ statusCode: response?.status(),
680
+ consoleMessages,
681
+ pageErrors,
682
+ } satisfies TBrowserVerificationResult;
683
+ } catch (error) {
684
+ const message = error instanceof Error ? error.message : String(error);
685
+ if (message.includes('Executable') && message.includes('doesn')) {
686
+ throw new UsageError(
687
+ `Playwright is installed in ${appRoot}, but the Chromium browser is missing. Run \`npx playwright install chromium\` in that app. Fallback: \`proteum verify request ${target}\`.`,
688
+ );
689
+ }
690
+
691
+ throw error;
692
+ } finally {
693
+ await browserContext?.close();
694
+ }
695
+ };
696
+
697
+ const runStaticOwnerVerify = async ({
698
+ manifest,
699
+ orientation,
700
+ }: {
701
+ manifest: TProteumManifest;
702
+ orientation: TOrientResponse;
703
+ }) => {
704
+ const doctor = buildDoctorResponse(manifest, false);
705
+ const contracts = buildContractsDoctorResponse(manifest, false);
706
+ const { introducedFindings, preExistingFindings } = classifyDiagnostics({
707
+ contracts,
708
+ doctor,
709
+ manifest,
710
+ orientation,
711
+ });
712
+
713
+ return finalizeResult({
714
+ action: 'owner',
715
+ target: orientation.query,
716
+ orientation,
717
+ introducedFindings,
718
+ preExistingFindings,
719
+ strictGlobal: cli.args.strictGlobal === true,
720
+ verificationSteps: [
721
+ {
722
+ label: 'Orient Owner',
723
+ status: orientation.owner.matches.length > 0 ? 'passed' : 'failed',
724
+ details: [`owner=${orientation.owner.matches[0]?.label || 'none'}`],
725
+ },
726
+ {
727
+ label: 'Run Local Doctor',
728
+ status: 'passed',
729
+ details: [`doctor=${doctor.summary.errors} errors/${doctor.summary.warnings} warnings`],
730
+ },
731
+ {
732
+ label: 'Run Local Contracts',
733
+ status: 'passed',
734
+ details: [`contracts=${contracts.summary.errors} errors/${contracts.summary.warnings} warnings`],
735
+ },
736
+ ],
737
+ });
738
+ };
739
+
740
+ const runOwnerVerify = async (target: string) => {
741
+ const { manifest, orientation } = await resolveOrientation(target);
742
+ const topOwner = orientation.owner.matches[0];
743
+ if (!topOwner) return await runStaticOwnerVerify({ manifest, orientation });
744
+
745
+ if (topOwner.kind === 'command') {
746
+ const server = await ensureFocusedServer(manifest);
747
+ try {
748
+ const execution = (await requestCommandRun(server.baseUrl, topOwner.label)).execution;
749
+ const doctor = buildDoctorResponse(manifest, false);
750
+ const contracts = buildContractsDoctorResponse(manifest, false);
751
+ const findings = classifyDiagnostics({ contracts, doctor, manifest, orientation });
752
+ const introducedFindings = [...findings.introducedFindings];
753
+ if (execution.status === 'error') {
754
+ introducedFindings.push({
755
+ severity: 'error',
756
+ blocking: true,
757
+ code: 'command/runtime-error',
758
+ message: execution.errorMessage || `Command "${execution.command.path}" failed.`,
759
+ source: 'command',
760
+ filepath: execution.command.filepath,
761
+ sourceLocation: execution.command.sourceLocation,
762
+ });
763
+ }
764
+
765
+ return finalizeResult({
766
+ action: 'owner',
767
+ target,
768
+ orientation,
769
+ introducedFindings,
770
+ preExistingFindings: findings.preExistingFindings,
771
+ strictGlobal: cli.args.strictGlobal === true,
772
+ verificationSteps: [
773
+ {
774
+ label: 'Orient Owner',
775
+ status: 'passed',
776
+ details: [`owner=${topOwner.label}`, `kind=${topOwner.kind}`],
777
+ },
778
+ {
779
+ label: 'Ensure Dev Server',
780
+ status: 'passed',
781
+ details: [`startup=${server.startup}`, `baseUrl=${server.baseUrl}`],
782
+ },
783
+ {
784
+ label: 'Run Command',
785
+ status: execution.status === 'error' ? 'failed' : 'passed',
786
+ details: [`path=${execution.command.path}`, `status=${execution.status}`, `durationMs=${execution.durationMs}`],
787
+ },
788
+ ],
789
+ });
790
+ } finally {
791
+ if ('close' in server) server.close();
792
+ }
793
+ }
794
+
795
+ const requestTarget =
796
+ target.startsWith('/') ? target : topOwner.kind === 'route' || topOwner.kind === 'controller' ? topOwner.label : undefined;
797
+ if (requestTarget) {
798
+ return await runRequestVerify(requestTarget, orientation, manifest);
799
+ }
800
+
801
+ return await runStaticOwnerVerify({ manifest, orientation });
802
+ };
803
+
804
+ const runRequestVerify = async (target: string, existingOrientation?: TOrientResponse, existingManifest?: TProteumManifest) => {
805
+ const manifest = existingManifest || (await resolveLocalManifest());
806
+ const orientation = existingOrientation || buildOrientationResponse(manifest, target);
807
+ const server = await ensureFocusedServer(manifest);
808
+ const method = (typeof cli.args.method === 'string' && cli.args.method ? cli.args.method.trim().toUpperCase() : 'GET') as Method;
809
+ const dataJson = parseDataJson();
810
+ const sessionEmail = typeof cli.args.sessionEmail === 'string' ? cli.args.sessionEmail.trim() : '';
811
+ const sessionRole = typeof cli.args.sessionRole === 'string' ? cli.args.sessionRole.trim() : '';
812
+
813
+ try {
814
+ await armTrace(server.baseUrl);
815
+ let session:
816
+ | {
817
+ cookieHeader: string;
818
+ playwrightCookies: Array<{
819
+ name: string;
820
+ value: string;
821
+ url: string;
822
+ expires: number;
823
+ httpOnly: boolean;
824
+ secure: boolean;
825
+ sameSite: 'Lax';
826
+ }>;
827
+ }
828
+ | undefined;
829
+ if (sessionEmail) {
830
+ session = await requestSession({ baseUrl: server.baseUrl, email: sessionEmail, role: sessionRole });
831
+ }
832
+
833
+ const hit = await hitRequest({
834
+ baseUrl: server.baseUrl,
835
+ cookieHeader: session?.cookieHeader,
836
+ dataJson,
837
+ method,
838
+ requestPath: target,
839
+ });
840
+ const diagnose = await requestDiagnose(server.baseUrl, target);
841
+ const findings = classifyDiagnostics({
842
+ contracts: diagnose.contracts,
843
+ doctor: diagnose.doctor,
844
+ manifest,
845
+ orientation,
846
+ chain: diagnose.chain,
847
+ });
848
+ const introducedFindings = [...findings.introducedFindings];
849
+
850
+ if (hit.statusCode >= 400) {
851
+ introducedFindings.push({
852
+ severity: 'error',
853
+ blocking: true,
854
+ code: 'request/http-status',
855
+ message: `Request returned status ${hit.statusCode} for ${hit.url}.`,
856
+ source: 'request',
857
+ });
858
+ }
859
+
860
+ if (diagnose.request?.errorMessage) {
861
+ introducedFindings.push({
862
+ severity: 'error',
863
+ blocking: true,
864
+ code: 'request/trace-error',
865
+ message: diagnose.request.errorMessage,
866
+ source: 'request',
867
+ });
868
+ }
869
+
870
+ return finalizeResult({
871
+ action: existingOrientation ? 'owner' : 'request',
872
+ target,
873
+ orientation,
874
+ introducedFindings,
875
+ preExistingFindings: findings.preExistingFindings,
876
+ strictGlobal: cli.args.strictGlobal === true,
877
+ verificationSteps: [
878
+ {
879
+ label: 'Orient Target',
880
+ status: 'passed',
881
+ details: [`owner=${orientation.owner.matches[0]?.label || 'none'}`],
882
+ },
883
+ {
884
+ label: 'Ensure Dev Server',
885
+ status: 'passed',
886
+ details: [`startup=${server.startup}`, `baseUrl=${server.baseUrl}`],
887
+ },
888
+ {
889
+ label: 'Arm Trace',
890
+ status: 'passed',
891
+ details: ['capture=deep'],
892
+ },
893
+ {
894
+ label: 'Hit Request',
895
+ status: hit.statusCode >= 400 ? 'failed' : 'passed',
896
+ details: [`method=${method}`, `status=${hit.statusCode}`, `url=${hit.url}`],
897
+ },
898
+ {
899
+ label: 'Collect Diagnose',
900
+ status: 'passed',
901
+ details: [
902
+ `doctor=${diagnose.doctor.summary.errors} errors/${diagnose.doctor.summary.warnings} warnings`,
903
+ `contracts=${diagnose.contracts.summary.errors} errors/${diagnose.contracts.summary.warnings} warnings`,
904
+ ],
905
+ },
906
+ ],
907
+ });
908
+ } finally {
909
+ if ('close' in server) server.close();
910
+ }
911
+ };
912
+
913
+ const runBrowserVerify = async (target: string) => {
914
+ const manifest = await resolveLocalManifest();
915
+ const orientation = buildOrientationResponse(manifest, target);
916
+ const server = await ensureFocusedServer(manifest);
917
+ const sessionEmail = typeof cli.args.sessionEmail === 'string' ? cli.args.sessionEmail.trim() : '';
918
+ const sessionRole = typeof cli.args.sessionRole === 'string' ? cli.args.sessionRole.trim() : '';
919
+
920
+ try {
921
+ await armTrace(server.baseUrl);
922
+ let session:
923
+ | {
924
+ cookieHeader: string;
925
+ playwrightCookies: Array<{
926
+ name: string;
927
+ value: string;
928
+ url: string;
929
+ expires: number;
930
+ httpOnly: boolean;
931
+ secure: boolean;
932
+ sameSite: 'Lax';
933
+ }>;
934
+ }
935
+ | undefined;
936
+ if (sessionEmail) {
937
+ session = await requestSession({ baseUrl: server.baseUrl, email: sessionEmail, role: sessionRole });
938
+ }
939
+
940
+ const browser = await runBrowserVerification({
941
+ appRoot: manifest.app.root,
942
+ baseUrl: server.baseUrl,
943
+ playwrightCookies: session?.playwrightCookies,
944
+ target,
945
+ });
946
+ const diagnose = await requestDiagnose(server.baseUrl, target);
947
+ const findings = classifyDiagnostics({
948
+ contracts: diagnose.contracts,
949
+ doctor: diagnose.doctor,
950
+ manifest,
951
+ orientation,
952
+ chain: diagnose.chain,
953
+ });
954
+ const introducedFindings = [...findings.introducedFindings];
955
+
956
+ if ((browser.statusCode || 0) >= 400) {
957
+ introducedFindings.push({
958
+ severity: 'error',
959
+ blocking: true,
960
+ code: 'browser/http-status',
961
+ message: `Browser navigation returned status ${browser.statusCode} for ${browser.url}.`,
962
+ source: 'browser',
963
+ details: [`workspace=${browser.workspaceRoot}`],
964
+ });
965
+ }
966
+
967
+ for (const consoleMessage of browser.consoleMessages) {
968
+ const isError = consoleMessage.type === 'error';
969
+ introducedFindings.push({
970
+ severity: isError ? 'error' : 'warning',
971
+ blocking: isError,
972
+ code: `browser/console-${consoleMessage.type}`,
973
+ message: consoleMessage.text,
974
+ source: 'browser',
975
+ details: [`workspace=${browser.workspaceRoot}`],
976
+ });
977
+ }
978
+
979
+ for (const pageError of browser.pageErrors) {
980
+ introducedFindings.push({
981
+ severity: 'error',
982
+ blocking: true,
983
+ code: 'browser/page-error',
984
+ message: pageError,
985
+ source: 'browser',
986
+ details: [`workspace=${browser.workspaceRoot}`],
987
+ });
988
+ }
989
+
990
+ return finalizeResult({
991
+ action: 'browser',
992
+ target,
993
+ orientation,
994
+ introducedFindings,
995
+ preExistingFindings: findings.preExistingFindings,
996
+ strictGlobal: cli.args.strictGlobal === true,
997
+ verificationSteps: [
998
+ {
999
+ label: 'Orient Target',
1000
+ status: 'passed',
1001
+ details: [`owner=${orientation.owner.matches[0]?.label || 'none'}`],
1002
+ },
1003
+ {
1004
+ label: 'Ensure Dev Server',
1005
+ status: 'passed',
1006
+ details: [`startup=${server.startup}`, `baseUrl=${server.baseUrl}`],
1007
+ },
1008
+ {
1009
+ label: 'Arm Trace',
1010
+ status: 'passed',
1011
+ details: ['capture=deep'],
1012
+ },
1013
+ {
1014
+ label: 'Run Browser Verification',
1015
+ status:
1016
+ browser.pageErrors.length > 0 || browser.consoleMessages.some((message) => message.type === 'error') ? 'failed' : 'passed',
1017
+ details: [`status=${browser.statusCode ?? 'unknown'}`, `title=${browser.title || 'untitled'}`, `workspace=${browser.workspaceRoot}`],
1018
+ },
1019
+ {
1020
+ label: 'Collect Diagnose',
1021
+ status: 'passed',
1022
+ details: [
1023
+ `doctor=${diagnose.doctor.summary.errors} errors/${diagnose.doctor.summary.warnings} warnings`,
1024
+ `contracts=${diagnose.contracts.summary.errors} errors/${diagnose.contracts.summary.warnings} warnings`,
1025
+ ],
1026
+ },
1027
+ ],
1028
+ });
1029
+ } finally {
1030
+ if ('close' in server) server.close();
1031
+ }
1032
+ };
1033
+
129
1034
  const collectAppResult = async ({
130
1035
  appRoot,
131
1036
  baseUrl,
@@ -170,26 +1075,7 @@ const collectAppResult = async ({
170
1075
  };
171
1076
  };
172
1077
 
173
- const renderHuman = (result: TVerifyResult) =>
174
- [
175
- `Proteum verify ${result.action}`,
176
- ...result.apps.flatMap((app) => [
177
- '',
178
- `${app.name}`,
179
- `- root=${app.appRoot}`,
180
- `- baseUrl=${app.baseUrl}`,
181
- `- startup=${app.startup}`,
182
- `- page=${app.page.statusCode} ${app.page.url}`,
183
- `- explain routes=${app.explain.routes} controllers=${app.explain.controllers} commands=${app.explain.commands}`,
184
- `- doctor errors=${app.doctor.errors} warnings=${app.doctor.warnings}`,
185
- `- contracts errors=${app.contracts.errors} warnings=${app.contracts.warnings}`,
186
- ]),
187
- ].join('\n');
188
-
189
- export const run = async () => {
190
- const action = typeof cli.args.action === 'string' && cli.args.action ? cli.args.action : 'framework-change';
191
- if (action !== 'framework-change') throw new UsageError(`Unsupported verify action "${action}".`);
192
-
1078
+ const runFrameworkChangeVerify = async () => {
193
1079
  const websiteRoute = typeof cli.args.route === 'string' && cli.args.route ? cli.args.route : '/';
194
1080
  const apps = {
195
1081
  crosspath: {
@@ -221,13 +1107,11 @@ export const run = async () => {
221
1107
  const startedServers: Array<() => void> = [];
222
1108
 
223
1109
  try {
224
- const results: TVerifyAppResult[] = [];
225
-
226
1110
  const productServer = await ensureServer({
227
1111
  appRoot: apps.product.appRoot,
228
1112
  port: apps.product.port,
229
1113
  });
230
- if (productServer.startup === 'spawned') startedServers.push(productServer.close);
1114
+ if ('close' in productServer) startedServers.push(productServer.close);
231
1115
 
232
1116
  const websiteServer = await ensureServer({
233
1117
  appRoot: apps.website.appRoot,
@@ -237,45 +1121,115 @@ export const run = async () => {
237
1121
  },
238
1122
  port: apps.website.port,
239
1123
  });
240
- if (websiteServer.startup === 'spawned') startedServers.push(websiteServer.close);
1124
+ if ('close' in websiteServer) startedServers.push(websiteServer.close);
241
1125
 
242
1126
  const crosspathServer = await ensureServer({
243
1127
  appRoot: apps.crosspath.appRoot,
244
1128
  port: apps.crosspath.port,
245
1129
  });
246
- if (crosspathServer.startup === 'spawned') startedServers.push(crosspathServer.close);
1130
+ if ('close' in crosspathServer) startedServers.push(crosspathServer.close);
247
1131
 
248
- results.push(
249
- await collectAppResult({
1132
+ const results = await Promise.all([
1133
+ collectAppResult({
250
1134
  ...apps.crosspath,
251
1135
  baseUrl: crosspathServer.baseUrl,
252
1136
  startup: crosspathServer.startup,
253
1137
  }),
254
- );
255
- results.push(
256
- await collectAppResult({
1138
+ collectAppResult({
257
1139
  ...apps.product,
258
1140
  baseUrl: productServer.baseUrl,
259
1141
  startup: productServer.startup,
260
1142
  }),
261
- );
262
- results.push(
263
- await collectAppResult({
1143
+ collectAppResult({
264
1144
  ...apps.website,
265
1145
  baseUrl: websiteServer.baseUrl,
266
1146
  startup: websiteServer.startup,
267
1147
  }),
268
- );
1148
+ ]);
269
1149
 
270
- const result = { action, apps: results } satisfies TVerifyResult;
271
-
272
- if (cli.args.json === true) {
273
- console.log(JSON.stringify(result, null, 2));
274
- return;
1150
+ const introducedFindings: TVerifyFinding[] = [];
1151
+ for (const app of results) {
1152
+ if (app.page.statusCode >= 400) {
1153
+ introducedFindings.push({
1154
+ severity: 'error',
1155
+ blocking: true,
1156
+ code: 'framework-change/http-status',
1157
+ message: `${app.name} returned status ${app.page.statusCode} for ${app.page.url}.`,
1158
+ source: 'framework-change',
1159
+ filepath: app.appRoot,
1160
+ });
1161
+ }
1162
+ if (app.doctor.errors > 0) {
1163
+ introducedFindings.push({
1164
+ severity: 'error',
1165
+ blocking: true,
1166
+ code: 'framework-change/doctor-errors',
1167
+ message: `${app.name} reported ${app.doctor.errors} doctor errors.`,
1168
+ source: 'framework-change',
1169
+ filepath: app.appRoot,
1170
+ });
1171
+ }
1172
+ if (app.contracts.errors > 0) {
1173
+ introducedFindings.push({
1174
+ severity: 'error',
1175
+ blocking: true,
1176
+ code: 'framework-change/contracts-errors',
1177
+ message: `${app.name} reported ${app.contracts.errors} contract errors.`,
1178
+ source: 'framework-change',
1179
+ filepath: app.appRoot,
1180
+ });
1181
+ }
275
1182
  }
276
1183
 
277
- console.log(renderHuman(result));
1184
+ return finalizeResult({
1185
+ action: 'framework-change',
1186
+ apps: results,
1187
+ introducedFindings,
1188
+ preExistingFindings: [],
1189
+ strictGlobal: cli.args.strictGlobal === true,
1190
+ verificationSteps: results.map((app) => ({
1191
+ label: `Check ${app.name}`,
1192
+ status: app.page.statusCode >= 400 || app.doctor.errors > 0 || app.contracts.errors > 0 ? 'failed' : 'passed',
1193
+ details: [
1194
+ `startup=${app.startup}`,
1195
+ `page=${app.page.statusCode}`,
1196
+ `doctor=${app.doctor.errors} errors/${app.doctor.warnings} warnings`,
1197
+ `contracts=${app.contracts.errors} errors/${app.contracts.warnings} warnings`,
1198
+ ],
1199
+ })),
1200
+ });
278
1201
  } finally {
279
1202
  for (const close of startedServers.reverse()) close();
280
1203
  }
281
1204
  };
1205
+
1206
+ export const run = async () => {
1207
+ const action = typeof cli.args.action === 'string' && cli.args.action ? cli.args.action : 'framework-change';
1208
+ const target = typeof cli.args.target === 'string' ? cli.args.target.trim() : '';
1209
+ let result: TVerifyResult;
1210
+
1211
+ if (action === 'framework-change') {
1212
+ result = await runFrameworkChangeVerify();
1213
+ } else if (action === 'owner') {
1214
+ if (!target) throw new UsageError('`proteum verify owner` requires a query.');
1215
+ result = await runOwnerVerify(target);
1216
+ } else if (action === 'request') {
1217
+ if (!target) throw new UsageError('`proteum verify request` requires a path or absolute URL.');
1218
+ result = await runRequestVerify(target);
1219
+ } else if (action === 'browser') {
1220
+ if (!target) throw new UsageError('`proteum verify browser` requires a path or absolute URL.');
1221
+ result = await runBrowserVerify(target);
1222
+ } else {
1223
+ throw new UsageError(`Unsupported verify action "${action}". Expected framework-change, owner, request, or browser.`);
1224
+ }
1225
+
1226
+ if (cli.args.json === true) {
1227
+ console.log(JSON.stringify(result, null, 2));
1228
+ } else {
1229
+ console.log(renderHuman(result));
1230
+ }
1231
+
1232
+ if (!result.result.ok) {
1233
+ process.exitCode = 1;
1234
+ }
1235
+ };