@xera-ai/core 0.4.4 → 0.8.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.
Files changed (157) hide show
  1. package/dist/bin/internal.js +895 -537
  2. package/dist/src/index.js +109 -4
  3. package/package.json +6 -4
  4. package/src/artifact/status.ts +3 -0
  5. package/src/bin-internal/auth-setup.ts +116 -0
  6. package/src/bin-internal/exec.ts +42 -9
  7. package/src/bin-internal/graph-record.ts +3 -0
  8. package/src/bin-internal/index.ts +2 -0
  9. package/src/bin-internal/normalize.ts +13 -1
  10. package/src/bin-internal/report.ts +94 -2
  11. package/src/bin-internal/verify-prompts.ts +2 -1
  12. package/src/classifier/aggregate.ts +3 -0
  13. package/src/classifier/auth-expired.ts +44 -0
  14. package/src/classifier/contract-drift.ts +111 -0
  15. package/src/classifier/rate-limited.ts +25 -0
  16. package/src/config/schema.ts +51 -8
  17. package/src/graph/schema.ts +3 -0
  18. package/src/graph/types.ts +4 -1
  19. package/src/index.ts +2 -0
  20. package/src/scrub/index.ts +1 -0
  21. package/src/scrub/rules.ts +69 -0
  22. package/dist/adapter/types.d.ts +0 -70
  23. package/dist/adapter/types.d.ts.map +0 -1
  24. package/dist/artifact/hash.d.ts +0 -4
  25. package/dist/artifact/hash.d.ts.map +0 -1
  26. package/dist/artifact/meta.d.ts +0 -20
  27. package/dist/artifact/meta.d.ts.map +0 -1
  28. package/dist/artifact/paths.d.ts +0 -25
  29. package/dist/artifact/paths.d.ts.map +0 -1
  30. package/dist/artifact/status.d.ts +0 -75
  31. package/dist/artifact/status.d.ts.map +0 -1
  32. package/dist/auth/encrypt.d.ts +0 -4
  33. package/dist/auth/encrypt.d.ts.map +0 -1
  34. package/dist/auth/key.d.ts +0 -3
  35. package/dist/auth/key.d.ts.map +0 -1
  36. package/dist/auth/refresh.d.ts +0 -9
  37. package/dist/auth/refresh.d.ts.map +0 -1
  38. package/dist/auth/state.d.ts +0 -15
  39. package/dist/auth/state.d.ts.map +0 -1
  40. package/dist/bin-internal/disputes.d.ts +0 -2
  41. package/dist/bin-internal/disputes.d.ts.map +0 -1
  42. package/dist/bin-internal/doctor.d.ts +0 -5
  43. package/dist/bin-internal/doctor.d.ts.map +0 -1
  44. package/dist/bin-internal/eval-deterministic.d.ts +0 -5
  45. package/dist/bin-internal/eval-deterministic.d.ts.map +0 -1
  46. package/dist/bin-internal/eval-prepare.d.ts +0 -7
  47. package/dist/bin-internal/eval-prepare.d.ts.map +0 -1
  48. package/dist/bin-internal/eval-report.d.ts +0 -5
  49. package/dist/bin-internal/eval-report.d.ts.map +0 -1
  50. package/dist/bin-internal/exec.d.ts +0 -2
  51. package/dist/bin-internal/exec.d.ts.map +0 -1
  52. package/dist/bin-internal/fetch.d.ts +0 -5
  53. package/dist/bin-internal/fetch.d.ts.map +0 -1
  54. package/dist/bin-internal/graph-backfill.d.ts +0 -2
  55. package/dist/bin-internal/graph-backfill.d.ts.map +0 -1
  56. package/dist/bin-internal/graph-enrich.d.ts +0 -2
  57. package/dist/bin-internal/graph-enrich.d.ts.map +0 -1
  58. package/dist/bin-internal/graph-query.d.ts +0 -2
  59. package/dist/bin-internal/graph-query.d.ts.map +0 -1
  60. package/dist/bin-internal/graph-record-script.d.ts +0 -2
  61. package/dist/bin-internal/graph-record-script.d.ts.map +0 -1
  62. package/dist/bin-internal/graph-record.d.ts +0 -3
  63. package/dist/bin-internal/graph-record.d.ts.map +0 -1
  64. package/dist/bin-internal/graph-render.d.ts +0 -2
  65. package/dist/bin-internal/graph-render.d.ts.map +0 -1
  66. package/dist/bin-internal/graph-snapshot.d.ts +0 -2
  67. package/dist/bin-internal/graph-snapshot.d.ts.map +0 -1
  68. package/dist/bin-internal/heal-prepare.d.ts +0 -19
  69. package/dist/bin-internal/heal-prepare.d.ts.map +0 -1
  70. package/dist/bin-internal/impact-prepare.d.ts +0 -2
  71. package/dist/bin-internal/impact-prepare.d.ts.map +0 -1
  72. package/dist/bin-internal/index.d.ts +0 -2
  73. package/dist/bin-internal/index.d.ts.map +0 -1
  74. package/dist/bin-internal/lint.d.ts +0 -2
  75. package/dist/bin-internal/lint.d.ts.map +0 -1
  76. package/dist/bin-internal/normalize.d.ts +0 -2
  77. package/dist/bin-internal/normalize.d.ts.map +0 -1
  78. package/dist/bin-internal/post.d.ts +0 -2
  79. package/dist/bin-internal/post.d.ts.map +0 -1
  80. package/dist/bin-internal/promote.d.ts +0 -2
  81. package/dist/bin-internal/promote.d.ts.map +0 -1
  82. package/dist/bin-internal/report.d.ts +0 -2
  83. package/dist/bin-internal/report.d.ts.map +0 -1
  84. package/dist/bin-internal/status-cmd.d.ts +0 -2
  85. package/dist/bin-internal/status-cmd.d.ts.map +0 -1
  86. package/dist/bin-internal/typecheck.d.ts +0 -2
  87. package/dist/bin-internal/typecheck.d.ts.map +0 -1
  88. package/dist/bin-internal/unlock.d.ts +0 -2
  89. package/dist/bin-internal/unlock.d.ts.map +0 -1
  90. package/dist/bin-internal/validate-feature.d.ts +0 -2
  91. package/dist/bin-internal/validate-feature.d.ts.map +0 -1
  92. package/dist/bin-internal/verify-prompts.d.ts +0 -7
  93. package/dist/bin-internal/verify-prompts.d.ts.map +0 -1
  94. package/dist/classifier/aggregate.d.ts +0 -3
  95. package/dist/classifier/aggregate.d.ts.map +0 -1
  96. package/dist/classifier/history.d.ts +0 -13
  97. package/dist/classifier/history.d.ts.map +0 -1
  98. package/dist/classifier/types.d.ts +0 -26
  99. package/dist/classifier/types.d.ts.map +0 -1
  100. package/dist/config/define.d.ts +0 -3
  101. package/dist/config/define.d.ts.map +0 -1
  102. package/dist/config/load.d.ts +0 -3
  103. package/dist/config/load.d.ts.map +0 -1
  104. package/dist/config/schema.d.ts +0 -72
  105. package/dist/config/schema.d.ts.map +0 -1
  106. package/dist/eval/paths.d.ts +0 -15
  107. package/dist/eval/paths.d.ts.map +0 -1
  108. package/dist/eval/run-id.d.ts +0 -6
  109. package/dist/eval/run-id.d.ts.map +0 -1
  110. package/dist/eval/types.d.ts +0 -203
  111. package/dist/eval/types.d.ts.map +0 -1
  112. package/dist/graph/classify.d.ts +0 -42
  113. package/dist/graph/classify.d.ts.map +0 -1
  114. package/dist/graph/cost.d.ts +0 -21
  115. package/dist/graph/cost.d.ts.map +0 -1
  116. package/dist/graph/enrich.d.ts +0 -10
  117. package/dist/graph/enrich.d.ts.map +0 -1
  118. package/dist/graph/impact.d.ts +0 -31
  119. package/dist/graph/impact.d.ts.map +0 -1
  120. package/dist/graph/index.d.ts +0 -17
  121. package/dist/graph/index.d.ts.map +0 -1
  122. package/dist/graph/paths.d.ts +0 -10
  123. package/dist/graph/paths.d.ts.map +0 -1
  124. package/dist/graph/render.d.ts +0 -50
  125. package/dist/graph/render.d.ts.map +0 -1
  126. package/dist/graph/schema.d.ts +0 -180
  127. package/dist/graph/schema.d.ts.map +0 -1
  128. package/dist/graph/similarity.d.ts +0 -3
  129. package/dist/graph/similarity.d.ts.map +0 -1
  130. package/dist/graph/store.d.ts +0 -14
  131. package/dist/graph/store.d.ts.map +0 -1
  132. package/dist/graph/types.d.ts +0 -152
  133. package/dist/graph/types.d.ts.map +0 -1
  134. package/dist/graph/ulid.d.ts +0 -2
  135. package/dist/graph/ulid.d.ts.map +0 -1
  136. package/dist/index.d.ts +0 -20
  137. package/dist/index.d.ts.map +0 -1
  138. package/dist/jira/client.d.ts +0 -11
  139. package/dist/jira/client.d.ts.map +0 -1
  140. package/dist/jira/fields.d.ts +0 -7
  141. package/dist/jira/fields.d.ts.map +0 -1
  142. package/dist/jira/mcp-backend.d.ts +0 -3
  143. package/dist/jira/mcp-backend.d.ts.map +0 -1
  144. package/dist/jira/rest-backend.d.ts +0 -8
  145. package/dist/jira/rest-backend.d.ts.map +0 -1
  146. package/dist/jira/retry.d.ts +0 -8
  147. package/dist/jira/retry.d.ts.map +0 -1
  148. package/dist/jira/types.d.ts +0 -29
  149. package/dist/jira/types.d.ts.map +0 -1
  150. package/dist/lock/file-lock.d.ts +0 -12
  151. package/dist/lock/file-lock.d.ts.map +0 -1
  152. package/dist/logging/ndjson-logger.d.ts +0 -11
  153. package/dist/logging/ndjson-logger.d.ts.map +0 -1
  154. package/dist/reporter/jira-comment.d.ts +0 -9
  155. package/dist/reporter/jira-comment.d.ts.map +0 -1
  156. package/dist/reporter/status-writer.d.ts +0 -14
  157. package/dist/reporter/status-writer.d.ts.map +0 -1
package/dist/src/index.js CHANGED
@@ -113,7 +113,10 @@ var ClassificationEnum = z2.enum([
113
113
  "SELECTOR_DRIFT",
114
114
  "FLAKY",
115
115
  "TEST_BUG",
116
- "TEST_OUTDATED"
116
+ "TEST_OUTDATED",
117
+ "CONTRACT_DRIFT",
118
+ "RATE_LIMITED",
119
+ "AUTH_EXPIRED"
117
120
  ]);
118
121
  var ResultEnum = z2.enum(["PASS", "FAIL"]);
119
122
  var ConfidenceEnum = z2.enum(["low", "medium", "high"]);
@@ -297,6 +300,32 @@ var WebSchema = z4.object({
297
300
  message: "defaultEnv must exist in baseUrl map",
298
301
  path: ["defaultEnv"]
299
302
  });
303
+ var HttpAuthRoleSchema = z4.object({
304
+ tokenEnv: z4.string().optional(),
305
+ userEnv: z4.string().optional(),
306
+ passEnv: z4.string().optional(),
307
+ tokenUrl: z4.string().url().optional(),
308
+ clientIdEnv: z4.string().optional(),
309
+ clientSecretEnv: z4.string().optional(),
310
+ scope: z4.string().optional()
311
+ });
312
+ var HttpAuthSchema = z4.object({
313
+ strategy: z4.enum(["bearer", "apiKey", "basic", "oauth-cc", "custom", "none"]).default("none"),
314
+ ttl: z4.string().default("8h"),
315
+ refreshBuffer: z4.string().default("30m"),
316
+ roles: z4.record(z4.string(), HttpAuthRoleSchema).default({})
317
+ });
318
+ var HttpSchema = z4.object({
319
+ baseUrl: z4.record(z4.string(), z4.string().url()).refine((m) => Object.keys(m).length > 0, {
320
+ message: "baseUrl must have at least one environment"
321
+ }),
322
+ defaultEnv: z4.string(),
323
+ spec: z4.string().optional(),
324
+ auth: HttpAuthSchema.prefault({})
325
+ }).refine((h) => h.baseUrl[h.defaultEnv] !== undefined, {
326
+ message: "defaultEnv must exist in baseUrl map",
327
+ path: ["defaultEnv"]
328
+ });
300
329
  var JiraSchema = z4.object({
301
330
  baseUrl: z4.string().url(),
302
331
  projectKeys: z4.array(z4.string().min(1)).min(1),
@@ -332,11 +361,17 @@ var RunSchema = z4.object({
332
361
  }).prefault({});
333
362
  var XeraConfigSchema = z4.object({
334
363
  jira: JiraSchema,
335
- web: WebSchema,
364
+ web: WebSchema.optional(),
365
+ http: HttpSchema.optional(),
336
366
  ai: AISchema,
337
367
  reporting: ReportingSchema,
338
368
  run: RunSchema.prefault({}),
339
- adapters: z4.array(z4.string().min(1)).min(1).default(["web"])
369
+ adapters: z4.array(z4.enum(["web", "http"])).min(1).default(["web"])
370
+ }).refine((c) => c.web !== undefined || c.http !== undefined, {
371
+ message: "At least one of `web` or `http` must be configured"
372
+ }).refine((c) => c.adapters.every((a) => (a === "web" ? c.web : c.http) !== undefined), {
373
+ message: "Every adapter in `adapters` must have a corresponding config block",
374
+ path: ["adapters"]
340
375
  });
341
376
 
342
377
  // src/config/load.ts
@@ -585,7 +620,66 @@ class NdjsonLogger {
585
620
  `).map((line) => JSON.parse(line));
586
621
  }
587
622
  }
588
-
623
+ // src/scrub/rules.ts
624
+ var SENSITIVE_HEADERS = [
625
+ "authorization",
626
+ "cookie",
627
+ "set-cookie",
628
+ "x-api-key",
629
+ "x-auth-token",
630
+ "x-csrf-token",
631
+ "proxy-authorization"
632
+ ];
633
+ var SENSITIVE_BODY_KEYS = [
634
+ /password/i,
635
+ /passwd/i,
636
+ /token/i,
637
+ /secret/i,
638
+ /api[-_]?key/i,
639
+ /access[-_]?key/i,
640
+ /private[-_]?key/i,
641
+ /authorization/i,
642
+ /credit[-_]?card/i,
643
+ /card[-_]?number/i,
644
+ /cvv/i
645
+ ];
646
+ var JWT_RE = /\beyJ[A-Za-z0-9_-]{7,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{5,}\b/;
647
+ var CREDIT_CARD_RE = /\b(?:\d{4}[-\s]?){3}\d{4}\b/;
648
+ var EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
649
+ var PHONE_RE = /(?:\+?\d[\d\s().-]{6,}\d)/;
650
+ var JWT_RE_G = new RegExp(JWT_RE.source, "g");
651
+ var CREDIT_CARD_RE_G = new RegExp(CREDIT_CARD_RE.source, "g");
652
+ var EMAIL_RE_G = new RegExp(EMAIL_RE.source, "g");
653
+ var PHONE_RE_G = new RegExp(PHONE_RE.source, "g");
654
+ var REDACTED = "[REDACTED]";
655
+ function scrubHeaders(headers) {
656
+ const out = {};
657
+ for (const [k, v] of Object.entries(headers)) {
658
+ out[k] = SENSITIVE_HEADERS.includes(k.toLowerCase()) ? REDACTED : v;
659
+ }
660
+ return out;
661
+ }
662
+ function scrubBodyJson(body) {
663
+ if (Array.isArray(body))
664
+ return body.map(scrubBodyJson);
665
+ if (body && typeof body === "object") {
666
+ const out = {};
667
+ for (const [k, v] of Object.entries(body)) {
668
+ if (SENSITIVE_BODY_KEYS.some((re) => re.test(k))) {
669
+ out[k] = REDACTED;
670
+ } else {
671
+ out[k] = scrubBodyJson(v);
672
+ }
673
+ }
674
+ return out;
675
+ }
676
+ if (typeof body === "string")
677
+ return scrubFreeText(body);
678
+ return body;
679
+ }
680
+ function scrubFreeText(s) {
681
+ return s.replace(JWT_RE_G, REDACTED).replace(CREDIT_CARD_RE_G, REDACTED).replace(EMAIL_RE_G, REDACTED).replace(PHONE_RE_G, REDACTED);
682
+ }
589
683
  // src/index.ts
590
684
  var VERSION2 = "0.1.0";
591
685
  export {
@@ -594,6 +688,9 @@ export {
594
688
  writeAuthState,
595
689
  withRetry,
596
690
  updateMeta,
691
+ scrubHeaders,
692
+ scrubFreeText,
693
+ scrubBodyJson,
597
694
  resolveAuthKey,
598
695
  resolveArtifactPaths,
599
696
  releaseLock,
@@ -621,9 +718,17 @@ export {
621
718
  XeraConfigSchema,
622
719
  VERSION2 as VERSION,
623
720
  StatusJsonSchema,
721
+ SENSITIVE_HEADERS,
722
+ SENSITIVE_BODY_KEYS,
723
+ PHONE_RE_G,
724
+ PHONE_RE,
624
725
  NdjsonLogger,
625
726
  MetaJsonSchema,
727
+ JWT_RE,
626
728
  HistoryEntrySchema,
729
+ EMAIL_RE_G,
730
+ EMAIL_RE,
731
+ CREDIT_CARD_RE,
627
732
  AuthStateEntrySchema,
628
733
  AUTH_KEY_ENV
629
734
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xera-ai/core",
3
- "version": "0.4.4",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -25,12 +25,14 @@
25
25
  "bin"
26
26
  ],
27
27
  "scripts": {
28
- "build": "bun build ./src/index.ts ./bin/internal.ts --outdir ./dist --target bun --external @playwright/test --external @xera-ai/web --external zod",
29
- "typecheck": "tsc --noEmit"
28
+ "build": "bun build ./src/index.ts ./bin/internal.ts --outdir ./dist --target bun --external @playwright/test --external @xera-ai/web --external @xera-ai/http --external zod",
29
+ "typecheck": "tsc --noEmit",
30
+ "prepublishOnly": "bun run build"
30
31
  },
31
32
  "dependencies": {
32
33
  "zod": "4.4.3",
33
- "@xera-ai/web": "^0.2.0",
34
+ "@xera-ai/web": "^0.8.0",
35
+ "@xera-ai/http": "^0.8.0",
34
36
  "@playwright/test": "1.60.0",
35
37
  "fflate": "0.8.3",
36
38
  "yaml": "2.9.0"
@@ -9,6 +9,9 @@ const ClassificationEnum = z.enum([
9
9
  'FLAKY',
10
10
  'TEST_BUG',
11
11
  'TEST_OUTDATED',
12
+ 'CONTRACT_DRIFT',
13
+ 'RATE_LIMITED',
14
+ 'AUTH_EXPIRED',
12
15
  ]);
13
16
  const ResultEnum = z.enum(['PASS', 'FAIL']);
14
17
  const ConfidenceEnum = z.enum(['low', 'medium', 'high']);
@@ -0,0 +1,116 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { loadConfig } from '../config/load';
5
+
6
+ interface AuthSetupOpts {
7
+ role?: string;
8
+ shape: 'web' | 'http' | 'all';
9
+ }
10
+
11
+ function parseOpts(argv: string[]): AuthSetupOpts {
12
+ const opts: AuthSetupOpts = { shape: 'all' };
13
+ for (let i = 0; i < argv.length; i++) {
14
+ const a = argv[i];
15
+ const next = argv[i + 1];
16
+ if (a === '--role' && next) {
17
+ opts.role = next;
18
+ i++;
19
+ } else if (a === '--shape' && next) {
20
+ if (next === 'web' || next === 'http' || next === 'all') opts.shape = next;
21
+ i++;
22
+ }
23
+ }
24
+ return opts;
25
+ }
26
+
27
+ export async function authSetupCmd(argv: string[]): Promise<number> {
28
+ const opts = parseOpts(argv);
29
+ const cwd = process.cwd();
30
+ const config = await loadConfig(cwd);
31
+
32
+ const authSetupScript = join(cwd, 'shared', 'auth-setup.ts');
33
+ if (!existsSync(authSetupScript)) {
34
+ console.error(
35
+ `[xera:auth-setup] auth-setup.ts not found at ${authSetupScript}. Run 'bunx @xera-ai/cli init' first.`,
36
+ );
37
+ return 1;
38
+ }
39
+
40
+ const mod = (await import(pathToFileURL(authSetupScript).href)) as {
41
+ web?: unknown;
42
+ http?: unknown;
43
+ };
44
+
45
+ let exitCode = 0;
46
+
47
+ // Web roles
48
+ if (
49
+ (opts.shape === 'all' || opts.shape === 'web') &&
50
+ config.web &&
51
+ typeof mod.web === 'function'
52
+ ) {
53
+ const { runAuthSetup } = await import('@xera-ai/web');
54
+ const { chromium } = await import('@playwright/test');
55
+ const browser = await chromium.launch();
56
+ try {
57
+ for (const [roleName, roleCreds] of Object.entries(config.web.auth.roles)) {
58
+ if (opts.role && roleName !== opts.role) continue;
59
+ const email = process.env[roleCreds.envEmail];
60
+ const password = process.env[roleCreds.envPassword];
61
+ if (!email || !password) {
62
+ console.error(
63
+ `[xera:auth-setup] missing env vars ${roleCreds.envEmail} / ${roleCreds.envPassword} for role '${roleName}'`,
64
+ );
65
+ exitCode = 1;
66
+ continue;
67
+ }
68
+ try {
69
+ await runAuthSetup({
70
+ role: roleName,
71
+ creds: { email, password },
72
+ setupScriptPath: authSetupScript,
73
+ authDir: join(cwd, '.xera', '.auth'),
74
+ browser,
75
+ });
76
+ console.log(`[xera:auth-setup] ✓ ${roleName}.json (web)`);
77
+ } catch (e) {
78
+ console.error(`[xera:auth-setup] ✗ web/${roleName}: ${(e as Error).message}`);
79
+ exitCode = 1;
80
+ }
81
+ }
82
+ } finally {
83
+ await browser.close();
84
+ }
85
+ }
86
+
87
+ // Http roles
88
+ if (
89
+ (opts.shape === 'all' || opts.shape === 'http') &&
90
+ config.http &&
91
+ typeof mod.http === 'function'
92
+ ) {
93
+ // The auth-setup.ts template reads config via globalThis; set it for the user's function.
94
+ (globalThis as Record<string, unknown>).__XERA_HTTP_CONFIG__ = config.http;
95
+
96
+ const { runHttpAuthSetup } = await import('@xera-ai/http');
97
+ for (const roleName of Object.keys(config.http.auth.roles)) {
98
+ if (opts.role && roleName !== opts.role) continue;
99
+ try {
100
+ await runHttpAuthSetup({
101
+ authDir: join(cwd, '.xera', '.auth'),
102
+ role: roleName,
103
+ config: config.http,
104
+ setupFn: mod.http as Parameters<typeof runHttpAuthSetup>[0]['setupFn'],
105
+ creds: { email: '', password: '' },
106
+ });
107
+ console.log(`[xera:auth-setup] ✓ http/${roleName}.json`);
108
+ } catch (e) {
109
+ console.error(`[xera:auth-setup] ✗ http/${roleName}: ${(e as Error).message}`);
110
+ exitCode = 1;
111
+ }
112
+ }
113
+ }
114
+
115
+ return exitCode;
116
+ }
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { chromium } from '@playwright/test';
4
4
  import { runAuthSetup, runPlaywright, stagePlaywrightState } from '@xera-ai/web';
5
+ import { readMeta } from '../artifact/meta';
5
6
  import { generateRunId, resolveArtifactPaths } from '../artifact/paths';
6
7
  import { needsRefresh } from '../auth/refresh';
7
8
  import { readAuthState } from '../auth/state';
@@ -42,16 +43,48 @@ export async function execCmd(argv: string[]): Promise<number> {
42
43
 
43
44
  const t0 = Date.now();
44
45
  try {
46
+ const meta = readMeta(paths.metaPath);
47
+ const adapter = meta?.adapter ?? 'web';
48
+
49
+ if (adapter === 'http') {
50
+ if (!config.http) {
51
+ throw new Error('http adapter requires http config block');
52
+ }
53
+ const env = process.env['XERA_ENV'] ?? config.http.defaultEnv;
54
+ const { HttpAdapter } = await import('@xera-ai/http');
55
+ const result = await HttpAdapter.execute({
56
+ ticketDir: paths.ticketDir,
57
+ config,
58
+ runId,
59
+ env,
60
+ });
61
+ log.log({
62
+ step: 'exec.complete',
63
+ runId,
64
+ outcome: result.outcome,
65
+ elapsedMs: Date.now() - t0,
66
+ });
67
+ console.log(`[xera:exec] runId=${runId} outcome=${result.outcome}`);
68
+ // Exit 3 means "test failed" (expected vs infra error); lock released in finally
69
+ return result.outcome === 'PASS' ? 0 : 3;
70
+ }
71
+
72
+ // adapter === 'web' — existing path below unchanged
73
+ if (!config.web) {
74
+ throw new Error('web adapter requires web config block');
75
+ }
76
+ const webConfig = config.web;
77
+
45
78
  // Auth refresh per role declared in xera.config.ts
46
- if (config.web.auth.strategy === 'storageState' && config.web.auth.setupScript) {
79
+ if (webConfig.auth.strategy === 'storageState' && webConfig.auth.setupScript) {
47
80
  const browser = await chromium.launch();
48
81
  try {
49
- for (const [roleName, roleCreds] of Object.entries(config.web.auth.roles)) {
82
+ for (const [roleName, roleCreds] of Object.entries(webConfig.auth.roles)) {
50
83
  const entry = readAuthState(paths.authDir, roleName);
51
84
  if (
52
85
  needsRefresh(entry, {
53
- ttl: config.web.auth.ttl,
54
- refreshBuffer: config.web.auth.refreshBuffer,
86
+ ttl: webConfig.auth.ttl,
87
+ refreshBuffer: webConfig.auth.refreshBuffer,
55
88
  })
56
89
  ) {
57
90
  const email = process.env[roleCreds.envEmail];
@@ -65,7 +98,7 @@ export async function execCmd(argv: string[]): Promise<number> {
65
98
  await runAuthSetup({
66
99
  role: roleName,
67
100
  creds: { email, password },
68
- setupScriptPath: join(cwd, config.web.auth.setupScript),
101
+ setupScriptPath: join(cwd, webConfig.auth.setupScript),
69
102
  authDir: paths.authDir,
70
103
  browser,
71
104
  });
@@ -80,8 +113,8 @@ export async function execCmd(argv: string[]): Promise<number> {
80
113
  // Stage Playwright storageState files at predictable paths
81
114
  // (.xera/.auth/.cache/<role>.json) — generated spec.ts references these
82
115
  // via test.use({ storageState }) when an authenticated session is needed.
83
- if (config.web.auth.strategy === 'storageState') {
84
- for (const roleName of Object.keys(config.web.auth.roles)) {
116
+ if (webConfig.auth.strategy === 'storageState') {
117
+ for (const roleName of Object.keys(webConfig.auth.roles)) {
85
118
  if (readAuthState(paths.authDir, roleName)) {
86
119
  stagePlaywrightState(paths.authDir, roleName);
87
120
  }
@@ -101,8 +134,8 @@ export async function execCmd(argv: string[]): Promise<number> {
101
134
  const runDir = paths.runPath(runId).runDir;
102
135
  mkdirSync(runDir, { recursive: true });
103
136
 
104
- const envName = process.env.XERA_ENV ?? config.web.defaultEnv;
105
- const baseURL = config.web.baseUrl[envName] ?? config.web.baseUrl[config.web.defaultEnv]!;
137
+ const envName = process.env.XERA_ENV ?? webConfig.defaultEnv;
138
+ const baseURL = webConfig.baseUrl[envName] ?? webConfig.baseUrl[webConfig.defaultEnv]!;
106
139
 
107
140
  const reportJsonPath = join(runDir, 'report.json');
108
141
 
@@ -261,6 +261,9 @@ export async function graphRecordCmd(argv: string[]): Promise<number> {
261
261
  'FLAKY',
262
262
  'PASS',
263
263
  'TEST_OUTDATED',
264
+ 'CONTRACT_DRIFT',
265
+ 'RATE_LIMITED',
266
+ 'AUTH_EXPIRED',
264
267
  ];
265
268
  if (!validClass.includes(from) || !validClass.includes(to)) {
266
269
  console.error(
@@ -1,3 +1,4 @@
1
+ import { authSetupCmd } from './auth-setup';
1
2
  import { disputesCmd } from './disputes';
2
3
  import { doctorCmd } from './doctor';
3
4
  import { evalDeterministicCmd } from './eval-deterministic';
@@ -25,6 +26,7 @@ import { validateFeatureCmd } from './validate-feature';
25
26
  import { verifyPromptsCmd } from './verify-prompts';
26
27
 
27
28
  const COMMANDS: Record<string, (argv: string[]) => Promise<number>> = {
29
+ 'auth-setup': authSetupCmd,
28
30
  disputes: disputesCmd,
29
31
  doctor: doctorCmd,
30
32
  'eval-deterministic': evalDeterministicCmd,
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { normalizeRun } from '@xera-ai/web';
3
+ import { readMeta } from '../artifact/meta';
4
4
  import { resolveArtifactPaths } from '../artifact/paths';
5
5
 
6
6
  export async function normalizeCmd(argv: string[]): Promise<number> {
@@ -26,6 +26,18 @@ export async function normalizeCmd(argv: string[]): Promise<number> {
26
26
  console.error(`[xera:normalize] runs/${runId} missing`);
27
27
  return 1;
28
28
  }
29
+
30
+ const meta = readMeta(paths.metaPath);
31
+ const adapter = meta?.adapter ?? 'web';
32
+
33
+ if (adapter === 'http') {
34
+ const { normalizeHttpRun } = await import('@xera-ai/http');
35
+ await normalizeHttpRun({ runId, runDir });
36
+ console.log(`[xera:normalize] wrote normalized.json (http)`);
37
+ return 0;
38
+ }
39
+
40
+ const { normalizeRun } = await import('@xera-ai/web');
29
41
  const r = await normalizeRun({ runId, runDir });
30
42
  console.log(
31
43
  `[xera:normalize] wrote normalized.json (scrubbed_fields_count=${r.scrubbed_fields_count})`,
@@ -1,8 +1,14 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { readMeta } from '../artifact/meta';
3
4
  import { resolveArtifactPaths } from '../artifact/paths';
5
+ import { readAuthState } from '../auth/state';
4
6
  import { aggregateScenarios } from '../classifier/aggregate';
7
+ import { type AuthFileSummary, classifyAuthExpired } from '../classifier/auth-expired';
8
+ import { classifyContractDrift } from '../classifier/contract-drift';
9
+ import { classifyRateLimited } from '../classifier/rate-limited';
5
10
  import type { ScenarioClassification } from '../classifier/types';
11
+ import { loadConfig } from '../config/load';
6
12
  import type { OutdatedDecision } from '../graph/classify';
7
13
  import { enhanceClassification } from '../graph/classify';
8
14
  import { deriveSnapshot, loadAllEvents } from '../graph/store';
@@ -22,10 +28,96 @@ export async function reportCmd(argv: string[]): Promise<number> {
22
28
  console.error('[xera:report] usage: report <TICKET> --input=<classifier-output.json>');
23
29
  return 1;
24
30
  }
25
- const paths = resolveArtifactPaths(process.cwd(), ticket);
31
+ const cwd = process.cwd();
32
+ const paths = resolveArtifactPaths(cwd, ticket);
26
33
  const input = JSON.parse(readFileSync(inputArg.slice('--input='.length), 'utf8')) as ReportInput;
27
34
 
28
- const aggregated = aggregateScenarios(input.scenarios);
35
+ // v0.7: apply deterministic HTTP classifier rules before aggregation.
36
+ // When adapter === 'http', scan normalized.json http.calls for rate-limit,
37
+ // auth-expired, and contract-drift signals and override failing scenario classes.
38
+ interface HttpRuleOverride {
39
+ class: ScenarioClassification['class'];
40
+ rationale: string;
41
+ }
42
+ let httpRuleOverride: HttpRuleOverride | null = null;
43
+
44
+ const meta = readMeta(paths.metaPath);
45
+ if (meta?.adapter === 'http') {
46
+ const config = await loadConfig(cwd);
47
+ if (config.http) {
48
+ const normalizedPath = join(paths.ticketDir, 'runs', input.runId, 'normalized.json');
49
+ if (existsSync(normalizedPath)) {
50
+ const norm = JSON.parse(readFileSync(normalizedPath, 'utf8')) as {
51
+ http?: {
52
+ calls?: Array<{ method: string; url: string; status: number; respBody?: unknown }>;
53
+ };
54
+ };
55
+ const calls = norm.http?.calls ?? [];
56
+
57
+ // RATE_LIMITED
58
+ const rate = classifyRateLimited({ calls });
59
+ if (rate) httpRuleOverride = rate;
60
+
61
+ // AUTH_EXPIRED — needs auth files
62
+ if (!httpRuleOverride) {
63
+ const authFiles: Record<string, AuthFileSummary> = {};
64
+ const httpAuthDir = join(cwd, '.xera', '.auth', 'http');
65
+ for (const role of Object.keys(config.http.auth.roles)) {
66
+ const entry = readAuthState(httpAuthDir, role);
67
+ if (entry) {
68
+ const p = entry.payload as {
69
+ token: string;
70
+ type: 'bearer' | 'apiKey' | 'basic' | 'cookie';
71
+ };
72
+ if (typeof p.token === 'string' && typeof p.type === 'string') {
73
+ authFiles[role] = {
74
+ token: p.token,
75
+ type: p.type as AuthFileSummary['type'],
76
+ expires_at: entry.expires_at,
77
+ };
78
+ }
79
+ }
80
+ }
81
+ const authExp = classifyAuthExpired({ calls, authFiles });
82
+ if (authExp) httpRuleOverride = authExp;
83
+ }
84
+
85
+ // CONTRACT_DRIFT — needs openapi
86
+ if (!httpRuleOverride && config.http.spec) {
87
+ const { loadOpenApi } = await import('@xera-ai/http');
88
+ const openapi = await loadOpenApi(config.http.spec);
89
+ if (openapi) {
90
+ const drift = classifyContractDrift({
91
+ calls: calls.map((c) => ({
92
+ method: c.method,
93
+ url: c.url,
94
+ status: c.status,
95
+ respBody: c.respBody,
96
+ })),
97
+ openapi,
98
+ });
99
+ if (drift) httpRuleOverride = drift;
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ // Apply override: if a deterministic rule fired, stamp every FAIL scenario with it.
107
+ const scenariosForAggregation: ScenarioClassification[] = httpRuleOverride
108
+ ? input.scenarios.map((s) =>
109
+ s.outcome === 'FAIL'
110
+ ? {
111
+ ...s,
112
+ class: httpRuleOverride.class,
113
+ rationale: httpRuleOverride.rationale,
114
+ confidence: 'high' as const,
115
+ }
116
+ : s,
117
+ )
118
+ : input.scenarios;
119
+
120
+ const aggregated = aggregateScenarios(scenariosForAggregation);
29
121
 
30
122
  // v0.6.1: TEST_OUTDATED enhancement.
31
123
  // The /xera-report skill writes outdated-decisions.json BEFORE invoking this subcommand,
@@ -8,7 +8,8 @@ export interface CheckResult {
8
8
 
9
9
  const IN_SCOPE_PROMPTS = [
10
10
  'feature-from-story.md',
11
- 'script-from-feature.md',
11
+ 'script-from-feature-web.md',
12
+ 'script-from-feature-http.md',
12
13
  'heal-locator.md',
13
14
  'extract-areas.md',
14
15
  'similarity-match.md',
@@ -2,6 +2,9 @@ import type { ClassifyOutput, Confidence, ScenarioClassification } from './types
2
2
 
3
3
  const CLASS_PRIORITY: Array<ClassifyOutput['overall']> = [
4
4
  'REAL_BUG',
5
+ 'CONTRACT_DRIFT',
6
+ 'AUTH_EXPIRED',
7
+ 'RATE_LIMITED',
5
8
  'TEST_OUTDATED',
6
9
  'TEST_BUG',
7
10
  'SELECTOR_DRIFT',
@@ -0,0 +1,44 @@
1
+ import type { ClassifyResult, HttpCallSummary } from './rate-limited';
2
+
3
+ export interface AuthFileSummary {
4
+ token: string;
5
+ type: 'bearer' | 'apiKey' | 'basic' | 'cookie';
6
+ expires_at: string;
7
+ }
8
+
9
+ export interface ClassifyAuthExpiredInput {
10
+ calls: readonly HttpCallSummary[];
11
+ authFiles: Record<string, AuthFileSummary>;
12
+ }
13
+
14
+ function jwtExpPast(jwt: string, now: number): boolean {
15
+ const parts = jwt.split('.');
16
+ if (parts.length !== 3) return false;
17
+ try {
18
+ const payloadB64 = parts[1];
19
+ if (!payloadB64) return false;
20
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8')) as {
21
+ exp?: number;
22
+ };
23
+ return typeof payload.exp === 'number' && payload.exp * 1000 < now;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ export function classifyAuthExpired(input: ClassifyAuthExpiredInput): ClassifyResult | null {
30
+ const has401 = input.calls.some((c) => c.status === 401);
31
+ if (!has401) return null;
32
+ const now = Date.now();
33
+ for (const [role, entry] of Object.entries(input.authFiles)) {
34
+ const fileExpired = new Date(entry.expires_at).getTime() < now;
35
+ const jwtExpired = entry.type === 'bearer' && jwtExpPast(entry.token, now);
36
+ if (fileExpired || jwtExpired) {
37
+ return {
38
+ class: 'AUTH_EXPIRED',
39
+ rationale: `HTTP 401 captured; auth file for role '${role}' is past expiry. Run: bun run xera:auth-setup --role ${role}`,
40
+ };
41
+ }
42
+ }
43
+ return null;
44
+ }