@xera-ai/core 0.4.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/artifact/status.d.ts +12 -0
- package/dist/artifact/status.d.ts.map +1 -1
- package/dist/bin/internal.js +822 -464
- package/dist/bin-internal/auth-setup.d.ts +2 -0
- package/dist/bin-internal/auth-setup.d.ts.map +1 -0
- package/dist/bin-internal/exec.d.ts.map +1 -1
- package/dist/bin-internal/graph-record.d.ts.map +1 -1
- package/dist/bin-internal/index.d.ts.map +1 -1
- package/dist/bin-internal/normalize.d.ts.map +1 -1
- package/dist/bin-internal/report.d.ts.map +1 -1
- package/dist/bin-internal/verify-prompts.d.ts.map +1 -1
- package/dist/classifier/aggregate.d.ts.map +1 -1
- package/dist/classifier/auth-expired.d.ts +12 -0
- package/dist/classifier/auth-expired.d.ts.map +1 -0
- package/dist/classifier/contract-drift.d.ts +35 -0
- package/dist/classifier/contract-drift.d.ts.map +1 -0
- package/dist/classifier/rate-limited.d.ts +15 -0
- package/dist/classifier/rate-limited.d.ts.map +1 -0
- package/dist/config/schema.d.ts +32 -3
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/graph/schema.d.ts +9 -0
- package/dist/graph/schema.d.ts.map +1 -1
- package/dist/graph/types.d.ts +1 -1
- package/dist/graph/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/scrub/index.d.ts +2 -0
- package/dist/scrub/index.d.ts.map +1 -0
- package/dist/scrub/rules.d.ts +12 -0
- package/dist/scrub/rules.d.ts.map +1 -0
- package/dist/src/index.js +109 -4
- package/package.json +4 -3
- package/src/artifact/status.ts +3 -0
- package/src/bin-internal/auth-setup.ts +116 -0
- package/src/bin-internal/exec.ts +42 -9
- package/src/bin-internal/graph-record.ts +3 -0
- package/src/bin-internal/index.ts +2 -0
- package/src/bin-internal/normalize.ts +13 -1
- package/src/bin-internal/report.ts +94 -2
- package/src/bin-internal/verify-prompts.ts +2 -1
- package/src/classifier/aggregate.ts +3 -0
- package/src/classifier/auth-expired.ts +44 -0
- package/src/classifier/contract-drift.ts +111 -0
- package/src/classifier/rate-limited.ts +25 -0
- package/src/config/schema.ts +51 -8
- package/src/graph/schema.ts +3 -0
- package/src/graph/types.ts +4 -1
- package/src/index.ts +2 -0
- package/src/scrub/index.ts +1 -0
- package/src/scrub/rules.ts +69 -0
package/src/bin-internal/exec.ts
CHANGED
|
@@ -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 (
|
|
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(
|
|
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:
|
|
54
|
-
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,
|
|
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 (
|
|
84
|
-
for (const roleName of Object.keys(
|
|
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 ??
|
|
105
|
-
const baseURL =
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ClassifyResult } from './rate-limited';
|
|
2
|
+
|
|
3
|
+
export interface OpenAPISchema {
|
|
4
|
+
type?: 'object' | 'array' | 'string' | 'integer' | 'number' | 'boolean' | 'null';
|
|
5
|
+
properties?: Record<string, OpenAPISchema>;
|
|
6
|
+
required?: readonly string[];
|
|
7
|
+
items?: OpenAPISchema;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface OpenAPIOperation {
|
|
11
|
+
responses?: Record<string, { content?: Record<string, { schema?: OpenAPISchema }> }>;
|
|
12
|
+
requestBody?: { content?: Record<string, { schema?: OpenAPISchema }> };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface OpenAPIDocument {
|
|
16
|
+
paths: Record<
|
|
17
|
+
string,
|
|
18
|
+
Partial<Record<'get' | 'post' | 'put' | 'patch' | 'delete', OpenAPIOperation>>
|
|
19
|
+
>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ContractDriftCall {
|
|
23
|
+
method: string;
|
|
24
|
+
url: string;
|
|
25
|
+
status: number;
|
|
26
|
+
respBody: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ClassifyContractDriftInput {
|
|
30
|
+
calls: readonly ContractDriftCall[];
|
|
31
|
+
openapi: OpenAPIDocument | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function matchPath(specPaths: readonly string[], actualUrl: string): string | null {
|
|
35
|
+
const path = actualUrl.split('?')[0] ?? actualUrl;
|
|
36
|
+
for (const tmpl of specPaths) {
|
|
37
|
+
const re = new RegExp(`^${tmpl.replace(/\{[^}]+\}/g, '[^/]+')}$`);
|
|
38
|
+
if (re.test(path)) return tmpl;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function matchesSchema(body: unknown, schema: OpenAPISchema | undefined): boolean {
|
|
44
|
+
if (!schema) return true;
|
|
45
|
+
if (schema.type === 'object') {
|
|
46
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) return false;
|
|
47
|
+
const obj = body as Record<string, unknown>;
|
|
48
|
+
for (const req of schema.required ?? []) {
|
|
49
|
+
if (!(req in obj)) return false;
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (schema.type === 'array') return Array.isArray(body);
|
|
54
|
+
if (schema.type === 'string') return typeof body === 'string';
|
|
55
|
+
if (schema.type === 'integer' || schema.type === 'number') return typeof body === 'number';
|
|
56
|
+
if (schema.type === 'boolean') return typeof body === 'boolean';
|
|
57
|
+
if (schema.type === 'null') return body === null;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const VERBS = ['get', 'post', 'put', 'patch', 'delete'] as const;
|
|
62
|
+
type Verb = (typeof VERBS)[number];
|
|
63
|
+
|
|
64
|
+
function isVerb(s: string): s is Verb {
|
|
65
|
+
return (VERBS as readonly string[]).includes(s);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function classifyContractDrift(input: ClassifyContractDriftInput): ClassifyResult | null {
|
|
69
|
+
if (input.openapi === null) return null;
|
|
70
|
+
const specPaths = Object.keys(input.openapi.paths);
|
|
71
|
+
|
|
72
|
+
for (const call of input.calls) {
|
|
73
|
+
const tmpl = matchPath(specPaths, call.url);
|
|
74
|
+
if (!tmpl) {
|
|
75
|
+
return {
|
|
76
|
+
class: 'CONTRACT_DRIFT',
|
|
77
|
+
rationale: `Endpoint ${call.method} ${call.url} not found in OpenAPI`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const methodLower = call.method.toLowerCase();
|
|
81
|
+
if (!isVerb(methodLower)) {
|
|
82
|
+
return {
|
|
83
|
+
class: 'CONTRACT_DRIFT',
|
|
84
|
+
rationale: `Method ${call.method} not supported by classifier for ${tmpl}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const pathItem = input.openapi.paths[tmpl];
|
|
88
|
+
const op = pathItem?.[methodLower];
|
|
89
|
+
if (!op) {
|
|
90
|
+
return {
|
|
91
|
+
class: 'CONTRACT_DRIFT',
|
|
92
|
+
rationale: `${call.method} not defined for ${tmpl} in OpenAPI`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const respDef = op.responses?.[String(call.status)];
|
|
96
|
+
if (!respDef) {
|
|
97
|
+
return {
|
|
98
|
+
class: 'CONTRACT_DRIFT',
|
|
99
|
+
rationale: `Status ${call.status} not enumerated for ${call.method} ${tmpl} in OpenAPI`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const schema = respDef.content?.['application/json']?.schema;
|
|
103
|
+
if (!matchesSchema(call.respBody, schema)) {
|
|
104
|
+
return {
|
|
105
|
+
class: 'CONTRACT_DRIFT',
|
|
106
|
+
rationale: `Response body for ${call.method} ${tmpl} (${call.status}) does not match OpenAPI schema`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Classification } from '../artifact/status';
|
|
2
|
+
|
|
3
|
+
export interface HttpCallSummary {
|
|
4
|
+
method: string;
|
|
5
|
+
url: string;
|
|
6
|
+
status: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ClassifyResult {
|
|
10
|
+
class: Classification;
|
|
11
|
+
rationale: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ClassifyRateLimitedInput {
|
|
15
|
+
calls: readonly HttpCallSummary[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function classifyRateLimited(input: ClassifyRateLimitedInput): ClassifyResult | null {
|
|
19
|
+
const hit = input.calls.find((c) => c.status === 429);
|
|
20
|
+
if (!hit) return null;
|
|
21
|
+
return {
|
|
22
|
+
class: 'RATE_LIMITED',
|
|
23
|
+
rationale: `Captured HTTP 429 on ${hit.method} ${hit.url}`,
|
|
24
|
+
};
|
|
25
|
+
}
|
package/src/config/schema.ts
CHANGED
|
@@ -31,6 +31,37 @@ const WebSchema = z
|
|
|
31
31
|
path: ['defaultEnv'],
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
const HttpAuthRoleSchema = z.object({
|
|
35
|
+
tokenEnv: z.string().optional(),
|
|
36
|
+
userEnv: z.string().optional(),
|
|
37
|
+
passEnv: z.string().optional(),
|
|
38
|
+
tokenUrl: z.string().url().optional(),
|
|
39
|
+
clientIdEnv: z.string().optional(),
|
|
40
|
+
clientSecretEnv: z.string().optional(),
|
|
41
|
+
scope: z.string().optional(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const HttpAuthSchema = z.object({
|
|
45
|
+
strategy: z.enum(['bearer', 'apiKey', 'basic', 'oauth-cc', 'custom', 'none']).default('none'),
|
|
46
|
+
ttl: z.string().default('8h'),
|
|
47
|
+
refreshBuffer: z.string().default('30m'),
|
|
48
|
+
roles: z.record(z.string(), HttpAuthRoleSchema).default({}),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const HttpSchema = z
|
|
52
|
+
.object({
|
|
53
|
+
baseUrl: z.record(z.string(), z.string().url()).refine((m) => Object.keys(m).length > 0, {
|
|
54
|
+
message: 'baseUrl must have at least one environment',
|
|
55
|
+
}),
|
|
56
|
+
defaultEnv: z.string(),
|
|
57
|
+
spec: z.string().optional(),
|
|
58
|
+
auth: HttpAuthSchema.prefault({}),
|
|
59
|
+
})
|
|
60
|
+
.refine((h) => h.baseUrl[h.defaultEnv] !== undefined, {
|
|
61
|
+
message: 'defaultEnv must exist in baseUrl map',
|
|
62
|
+
path: ['defaultEnv'],
|
|
63
|
+
});
|
|
64
|
+
|
|
34
65
|
const JiraSchema = z.object({
|
|
35
66
|
baseUrl: z.string().url(),
|
|
36
67
|
projectKeys: z.array(z.string().min(1)).min(1),
|
|
@@ -80,13 +111,25 @@ const RunSchema = z
|
|
|
80
111
|
})
|
|
81
112
|
.prefault({});
|
|
82
113
|
|
|
83
|
-
export const XeraConfigSchema = z
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
})
|
|
114
|
+
export const XeraConfigSchema = z
|
|
115
|
+
.object({
|
|
116
|
+
jira: JiraSchema,
|
|
117
|
+
web: WebSchema.optional(),
|
|
118
|
+
http: HttpSchema.optional(),
|
|
119
|
+
ai: AISchema,
|
|
120
|
+
reporting: ReportingSchema,
|
|
121
|
+
run: RunSchema.prefault({}),
|
|
122
|
+
adapters: z
|
|
123
|
+
.array(z.enum(['web', 'http']))
|
|
124
|
+
.min(1)
|
|
125
|
+
.default(['web']),
|
|
126
|
+
})
|
|
127
|
+
.refine((c) => c.web !== undefined || c.http !== undefined, {
|
|
128
|
+
message: 'At least one of `web` or `http` must be configured',
|
|
129
|
+
})
|
|
130
|
+
.refine((c) => c.adapters.every((a) => (a === 'web' ? c.web : c.http) !== undefined), {
|
|
131
|
+
message: 'Every adapter in `adapters` must have a corresponding config block',
|
|
132
|
+
path: ['adapters'],
|
|
133
|
+
});
|
|
91
134
|
|
|
92
135
|
export type XeraConfig = z.infer<typeof XeraConfigSchema>;
|
package/src/graph/schema.ts
CHANGED
package/src/graph/types.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from './auth/encrypt';
|
|
|
8
8
|
export * from './auth/key';
|
|
9
9
|
export * from './auth/refresh';
|
|
10
10
|
export * from './auth/state';
|
|
11
|
+
export type { OpenAPIDocument, OpenAPISchema } from './classifier/contract-drift';
|
|
11
12
|
export * from './config/define';
|
|
12
13
|
export * from './config/load';
|
|
13
14
|
export * from './config/schema';
|
|
@@ -17,3 +18,4 @@ export * from './jira/retry';
|
|
|
17
18
|
export * from './jira/types';
|
|
18
19
|
export * from './lock/file-lock';
|
|
19
20
|
export * from './logging/ndjson-logger';
|
|
21
|
+
export * from './scrub';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './rules';
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export const SENSITIVE_HEADERS: readonly string[] = [
|
|
2
|
+
'authorization',
|
|
3
|
+
'cookie',
|
|
4
|
+
'set-cookie',
|
|
5
|
+
'x-api-key',
|
|
6
|
+
'x-auth-token',
|
|
7
|
+
'x-csrf-token',
|
|
8
|
+
'proxy-authorization',
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export const SENSITIVE_BODY_KEYS: readonly RegExp[] = [
|
|
12
|
+
/password/i,
|
|
13
|
+
/passwd/i,
|
|
14
|
+
/token/i,
|
|
15
|
+
/secret/i,
|
|
16
|
+
/api[-_]?key/i,
|
|
17
|
+
/access[-_]?key/i,
|
|
18
|
+
/private[-_]?key/i,
|
|
19
|
+
/authorization/i,
|
|
20
|
+
/credit[-_]?card/i,
|
|
21
|
+
/card[-_]?number/i,
|
|
22
|
+
/cvv/i,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export const JWT_RE = /\beyJ[A-Za-z0-9_-]{7,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{5,}\b/;
|
|
26
|
+
export const CREDIT_CARD_RE = /\b(?:\d{4}[-\s]?){3}\d{4}\b/;
|
|
27
|
+
export const EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
|
|
28
|
+
// E.164-ish phone with optional + and separators. Conservative: require at least 7 digits.
|
|
29
|
+
export const PHONE_RE = /(?:\+?\d[\d\s().-]{6,}\d)/;
|
|
30
|
+
|
|
31
|
+
const JWT_RE_G = new RegExp(JWT_RE.source, 'g');
|
|
32
|
+
const CREDIT_CARD_RE_G = new RegExp(CREDIT_CARD_RE.source, 'g');
|
|
33
|
+
export const EMAIL_RE_G = new RegExp(EMAIL_RE.source, 'g');
|
|
34
|
+
export const PHONE_RE_G = new RegExp(PHONE_RE.source, 'g');
|
|
35
|
+
|
|
36
|
+
const REDACTED = '[REDACTED]';
|
|
37
|
+
|
|
38
|
+
export function scrubHeaders(headers: Record<string, string>): Record<string, string> {
|
|
39
|
+
const out: Record<string, string> = {};
|
|
40
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
41
|
+
out[k] = SENSITIVE_HEADERS.includes(k.toLowerCase()) ? REDACTED : v;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function scrubBodyJson(body: unknown): unknown {
|
|
47
|
+
if (Array.isArray(body)) return body.map(scrubBodyJson);
|
|
48
|
+
if (body && typeof body === 'object') {
|
|
49
|
+
const out: Record<string, unknown> = {};
|
|
50
|
+
for (const [k, v] of Object.entries(body)) {
|
|
51
|
+
if (SENSITIVE_BODY_KEYS.some((re) => re.test(k))) {
|
|
52
|
+
out[k] = REDACTED;
|
|
53
|
+
} else {
|
|
54
|
+
out[k] = scrubBodyJson(v);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
if (typeof body === 'string') return scrubFreeText(body);
|
|
60
|
+
return body;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function scrubFreeText(s: string): string {
|
|
64
|
+
return s
|
|
65
|
+
.replace(JWT_RE_G, REDACTED)
|
|
66
|
+
.replace(CREDIT_CARD_RE_G, REDACTED)
|
|
67
|
+
.replace(EMAIL_RE_G, REDACTED)
|
|
68
|
+
.replace(PHONE_RE_G, REDACTED);
|
|
69
|
+
}
|