@xera-ai/core 0.11.6 → 0.12.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.
package/dist/src/index.js CHANGED
@@ -401,7 +401,7 @@ var CoverageSchema = z4.object({
401
401
  criticalAreas: z4.array(z4.string().regex(/^[a-z0-9-]+$/)).default([]),
402
402
  autoSnapshotOnCoverage: z4.boolean().default(true)
403
403
  }).prefault({});
404
- var XeraConfigSchema = z4.object({
404
+ var XeraConfigSchema = z4.strictObject({
405
405
  jira: JiraSchema,
406
406
  web: WebSchema.optional(),
407
407
  http: HttpSchema.optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xera-ai/core",
3
- "version": "0.11.6",
3
+ "version": "0.12.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -31,8 +31,8 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "zod": "4.4.3",
34
- "@xera-ai/web": "^0.11.6",
35
- "@xera-ai/http": "^0.11.6",
34
+ "@xera-ai/web": "^0.12.1",
35
+ "@xera-ai/http": "^0.12.1",
36
36
  "@playwright/test": "1.60.0",
37
37
  "dotenv": "^16.0.0",
38
38
  "fflate": "0.8.3",
@@ -44,6 +44,66 @@ export async function authSetupCmd(argv: string[]): Promise<number> {
44
44
 
45
45
  let exitCode = 0;
46
46
 
47
+ // Pre-flight: detect requested-but-impossible shapes before we silently no-op.
48
+ // This is the issue #93 fix: previously `--shape http` against a project where
49
+ // shared/auth-setup.ts only exports `web` would print nothing and exit 0,
50
+ // leaving the user in an infinite "doctor says run auth-setup" loop.
51
+ const shapeRequestsWeb = opts.shape === 'all' || opts.shape === 'web';
52
+ const shapeRequestsHttp = opts.shape === 'all' || opts.shape === 'http';
53
+ const explicit = opts.shape !== 'all';
54
+
55
+ if (shapeRequestsWeb && config.web && typeof mod.web !== 'function') {
56
+ console.error(
57
+ `[xera:auth-setup] web adapter is configured in xera.config.ts but shared/auth-setup.ts is missing the \`web\` export.\n` +
58
+ ` Add: \`export const web = defineAuthSetup(async (page, role, creds) => { ... })\` — see docs/CONFIGURATION.md`,
59
+ );
60
+ exitCode = 1;
61
+ }
62
+ if (shapeRequestsHttp && config.http && typeof mod.http !== 'function') {
63
+ console.error(
64
+ `[xera:auth-setup] http adapter is configured in xera.config.ts but shared/auth-setup.ts is missing the \`http\` export.\n` +
65
+ ` Add: \`export const http = defineHttpAuthSetup(async (request, role, creds) => { ... })\` — see docs/CONFIGURATION.md`,
66
+ );
67
+ exitCode = 1;
68
+ }
69
+ if (explicit && opts.shape === 'web' && !config.web) {
70
+ console.error(
71
+ `[xera:auth-setup] --shape web requested, but xera.config.ts has no \`web\` block. Add a web: {...} block or use --shape http/all.`,
72
+ );
73
+ exitCode = 1;
74
+ }
75
+ if (explicit && opts.shape === 'http' && !config.http) {
76
+ console.error(
77
+ `[xera:auth-setup] --shape http requested, but xera.config.ts has no \`http\` block. Add an http: {...} block or use --shape web/all.`,
78
+ );
79
+ exitCode = 1;
80
+ }
81
+ if (!config.web && !config.http) {
82
+ console.error(
83
+ `[xera:auth-setup] no \`web\` or \`http\` block found in xera.config.ts — nothing to authenticate.`,
84
+ );
85
+ exitCode = 1;
86
+ }
87
+
88
+ // Unknown-role detection (#98): without this, a typoed --role silently
89
+ // matches no iteration of the per-adapter loops and we exit 0 — leaving
90
+ // the user wondering why `xera doctor` still reports the auth file missing.
91
+ if (opts.role !== undefined) {
92
+ const webRoles = shapeRequestsWeb && config.web ? Object.keys(config.web.auth.roles) : [];
93
+ const httpRoles = shapeRequestsHttp && config.http ? Object.keys(config.http.auth.roles) : [];
94
+ const allRoles = Array.from(new Set([...webRoles, ...httpRoles]));
95
+ if (allRoles.length > 0 && !allRoles.includes(opts.role)) {
96
+ const detail: string[] = [];
97
+ if (webRoles.length > 0) detail.push(`web roles: ${webRoles.join(', ')}`);
98
+ if (httpRoles.length > 0) detail.push(`http roles: ${httpRoles.join(', ')}`);
99
+ console.error(
100
+ `[xera:auth-setup] unknown role '${opts.role}' — configured roles: ${allRoles.join(', ')}\n` +
101
+ ` (${detail.join('; ')})`,
102
+ );
103
+ return 1;
104
+ }
105
+ }
106
+
47
107
  // Web roles
48
108
  if (
49
109
  (opts.shape === 'all' || opts.shape === 'web') &&
@@ -0,0 +1,152 @@
1
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { z } from 'zod';
4
+
5
+ const CATEGORY_ENUM = z.enum([
6
+ 'negative',
7
+ 'boundary',
8
+ 'state-combination',
9
+ 'race',
10
+ 'error-recovery',
11
+ 'a11y',
12
+ 'security-smell',
13
+ 'non-functional',
14
+ ]);
15
+
16
+ const ProposalsSchema = z.object({
17
+ proposals: z.array(
18
+ z.object({
19
+ id: z.string().min(1),
20
+ ticketId: z.string().min(1),
21
+ category: CATEGORY_ENUM,
22
+ severity: z.enum(['low', 'medium', 'high']),
23
+ title: z.string().min(1),
24
+ rationale: z.string().min(1),
25
+ gherkin: z.string().min(1),
26
+ }),
27
+ ),
28
+ });
29
+
30
+ type Proposals = z.infer<typeof ProposalsSchema>;
31
+ type Proposal = Proposals['proposals'][number];
32
+
33
+ interface ParsedArgs {
34
+ ticket: string;
35
+ accept: string;
36
+ }
37
+
38
+ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
39
+ let ticket: string | undefined;
40
+ let accept: string | undefined;
41
+ for (let i = 0; i < argv.length; i++) {
42
+ const a = argv[i];
43
+ if (a === '--accept') {
44
+ const v = argv[++i];
45
+ if (v !== undefined) accept = v;
46
+ } else if (a === '--help-stub') {
47
+ /* no-op */
48
+ } else if (a && !a.startsWith('--') && ticket === undefined) {
49
+ ticket = a;
50
+ } else {
51
+ return { error: `unknown flag: ${a}` };
52
+ }
53
+ }
54
+ if (!ticket) return { error: 'ticket key is required as a positional argument' };
55
+ if (!accept)
56
+ return { error: '--accept is required (comma-separated IDs, "all", or "high-only")' };
57
+ return { ticket, accept };
58
+ }
59
+
60
+ function selectProposals(all: Proposal[], accept: string): Proposal[] | { error: string } {
61
+ const trimmed = accept.trim();
62
+ if (trimmed === 'all') return all;
63
+ if (trimmed === 'high-only') return all.filter((p) => p.severity === 'high');
64
+ const ids = new Set(
65
+ trimmed
66
+ .split(',')
67
+ .map((s) => s.trim())
68
+ .filter(Boolean),
69
+ );
70
+ if (ids.size === 0) return { error: 'no IDs supplied' };
71
+ const picked = all.filter((p) => ids.has(p.id));
72
+ const found = new Set(picked.map((p) => p.id));
73
+ const missing = [...ids].filter((id) => !found.has(id));
74
+ if (missing.length > 0) return { error: `unknown proposal IDs: ${missing.join(', ')}` };
75
+ return picked;
76
+ }
77
+
78
+ function ensureFeatureHeader(ticketDir: string, ticket: string): string {
79
+ const explorePath = join(ticketDir, 'explore.feature');
80
+ if (existsSync(explorePath)) return explorePath;
81
+
82
+ // Synthesize a Feature header. Prefer copying from test.feature if present.
83
+ const testFeaturePath = join(ticketDir, 'test.feature');
84
+ let header: string;
85
+ if (existsSync(testFeaturePath)) {
86
+ const testContent = readFileSync(testFeaturePath, 'utf8');
87
+ const firstFeatureMatch = testContent.match(/^Feature:.*$/m);
88
+ header = firstFeatureMatch
89
+ ? `${firstFeatureMatch[0]} (adversarial)\n Adversarial scenarios beyond the acceptance criteria.\n Generated by /xera-explore — review before merging into test.feature.\n\n`
90
+ : `Feature: ${ticket} adversarial\n\n`;
91
+ } else {
92
+ header = `Feature: ${ticket} adversarial\n Adversarial scenarios beyond the acceptance criteria.\n Generated by /xera-explore — review before merging into test.feature.\n\n`;
93
+ }
94
+ writeFileSync(explorePath, header);
95
+ return explorePath;
96
+ }
97
+
98
+ function formatScenario(p: Proposal): string {
99
+ const tags = `@adversarial @adversarial-${p.category} @severity-${p.severity}`;
100
+ const rationaleComment = ` # ${p.rationale}`;
101
+ const indented = p.gherkin
102
+ .split('\n')
103
+ .map((line) => (line.startsWith('Scenario:') ? ` ${line}` : line ? ` ${line}` : line))
104
+ .join('\n');
105
+ return `\n ${tags}\n${rationaleComment}\n${indented}\n`;
106
+ }
107
+
108
+ export async function exploreFinalizeCmd(argv: string[]): Promise<number> {
109
+ const parsed = parseArgs(argv);
110
+ if ('error' in parsed) {
111
+ console.error(`[explore-finalize] ${parsed.error}`);
112
+ return 1;
113
+ }
114
+
115
+ const cwd = process.cwd();
116
+ const ticketDir = join(cwd, '.xera', parsed.ticket);
117
+ const proposalsPath = join(ticketDir, 'adversarial-proposals.json');
118
+ if (!existsSync(proposalsPath)) {
119
+ console.error(
120
+ `[explore-finalize] adversarial-proposals.json not found for ${parsed.ticket} — run /xera-explore Step 4 first`,
121
+ );
122
+ return 2;
123
+ }
124
+
125
+ let proposals: Proposals;
126
+ try {
127
+ const raw = JSON.parse(readFileSync(proposalsPath, 'utf8'));
128
+ proposals = ProposalsSchema.parse(raw);
129
+ } catch (e) {
130
+ console.error(`[explore-finalize] invalid proposals JSON: ${(e as Error).message}`);
131
+ return 1;
132
+ }
133
+
134
+ const picked = selectProposals(proposals.proposals, parsed.accept);
135
+ if ('error' in picked) {
136
+ console.error(`[explore-finalize] ${picked.error}`);
137
+ return 1;
138
+ }
139
+ if (picked.length === 0) {
140
+ console.error('[explore-finalize] no proposals matched the --accept filter');
141
+ return 1;
142
+ }
143
+
144
+ const explorePath = ensureFeatureHeader(ticketDir, parsed.ticket);
145
+ for (const p of picked) {
146
+ appendFileSync(explorePath, formatScenario(p));
147
+ }
148
+ console.log(
149
+ `[explore-finalize] appended ${picked.length} scenario(s) to ${explorePath} (${picked.map((p) => p.id).join(', ')})`,
150
+ );
151
+ return 0;
152
+ }
@@ -0,0 +1,142 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ const VALID_CATEGORIES = [
5
+ 'negative',
6
+ 'boundary',
7
+ 'state-combination',
8
+ 'race',
9
+ 'error-recovery',
10
+ 'a11y',
11
+ 'security-smell',
12
+ 'non-functional',
13
+ ] as const;
14
+
15
+ type Category = (typeof VALID_CATEGORIES)[number];
16
+
17
+ interface ParsedArgs {
18
+ ticket: string;
19
+ categoriesInclude: Category[];
20
+ userHint: string;
21
+ }
22
+
23
+ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
24
+ let ticket: string | undefined;
25
+ let categoriesRaw = '';
26
+ let userHint = '';
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const a = argv[i];
29
+ if (a === '--categories') {
30
+ const v = argv[++i];
31
+ if (v !== undefined) categoriesRaw = v;
32
+ } else if (a === '--user-hint') {
33
+ const v = argv[++i];
34
+ if (v !== undefined) userHint = v;
35
+ } else if (a === '--help-stub') {
36
+ /* no-op */
37
+ } else if (a && !a.startsWith('--') && ticket === undefined) {
38
+ ticket = a;
39
+ } else {
40
+ return { error: `unknown flag: ${a}` };
41
+ }
42
+ }
43
+ if (!ticket) return { error: 'ticket key is required as a positional argument' };
44
+
45
+ const categoriesInclude: Category[] = [];
46
+ for (const slug of categoriesRaw
47
+ .split(',')
48
+ .map((s) => s.trim())
49
+ .filter(Boolean)) {
50
+ if (!(VALID_CATEGORIES as readonly string[]).includes(slug)) {
51
+ return { error: `invalid category: ${slug}` };
52
+ }
53
+ categoriesInclude.push(slug as Category);
54
+ }
55
+
56
+ return { ticket, categoriesInclude, userHint };
57
+ }
58
+
59
+ interface AdversarialInput {
60
+ ticket: { id: string; summary: string; story: string; ac: string[] };
61
+ existingFeature?: string;
62
+ existingSpec?: string;
63
+ adapter: 'web' | 'http';
64
+ categoriesInclude: Category[];
65
+ userHint?: string;
66
+ }
67
+
68
+ function parseStoryMd(content: string): { summary: string; ac: string[]; body: string } {
69
+ // story.md format: optional frontmatter with `summary:` and `ac:` (yaml-ish list),
70
+ // followed by the body. We do a minimal parse — full YAML is overkill here.
71
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
72
+ if (!fmMatch) return { summary: '', ac: [], body: content };
73
+ const [, fm, body] = fmMatch;
74
+ const summaryMatch = fm!.match(/^summary:\s*(.+)$/m);
75
+ const summary = summaryMatch?.[1]?.trim() ?? '';
76
+ const ac: string[] = [];
77
+ const acBlock = fm!.match(/^ac:\s*\n((?:\s*-\s.+\n?)+)/m);
78
+ if (acBlock) {
79
+ for (const line of acBlock[1]!.split('\n')) {
80
+ const m = line.match(/^\s*-\s*(.+)$/);
81
+ if (m) ac.push(m[1]!.trim());
82
+ }
83
+ }
84
+ return { summary, ac, body: body!.trim() };
85
+ }
86
+
87
+ export async function explorePrepareCmd(argv: string[]): Promise<number> {
88
+ const parsed = parseArgs(argv);
89
+ if ('error' in parsed) {
90
+ console.error(`[explore-prepare] ${parsed.error}`);
91
+ return 1;
92
+ }
93
+
94
+ const cwd = process.cwd();
95
+ const configPath = join(cwd, 'xera.config.ts');
96
+ if (!existsSync(configPath)) {
97
+ console.error('[explore-prepare] xera.config.ts not found — run inside a xera project');
98
+ return 2;
99
+ }
100
+
101
+ const ticketDir = join(cwd, '.xera', parsed.ticket);
102
+ const storyPath = join(ticketDir, 'story.md');
103
+ if (!existsSync(storyPath)) {
104
+ console.error(
105
+ `[explore-prepare] no story for ${parsed.ticket} — run /xera-fetch ${parsed.ticket} first`,
106
+ );
107
+ return 2;
108
+ }
109
+
110
+ const story = readFileSync(storyPath, 'utf8');
111
+ const { summary, ac, body } = parseStoryMd(story);
112
+
113
+ let adapter: 'web' | 'http' = 'web';
114
+ const metaPath = join(ticketDir, 'meta.json');
115
+ if (existsSync(metaPath)) {
116
+ try {
117
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8')) as { adapter?: string };
118
+ if (meta.adapter === 'http') adapter = 'http';
119
+ } catch {
120
+ /* leave default web */
121
+ }
122
+ }
123
+
124
+ const input: AdversarialInput = {
125
+ ticket: { id: parsed.ticket, summary, story: body, ac },
126
+ adapter,
127
+ categoriesInclude: parsed.categoriesInclude,
128
+ };
129
+
130
+ const featurePath = join(ticketDir, 'test.feature');
131
+ if (existsSync(featurePath)) input.existingFeature = readFileSync(featurePath, 'utf8');
132
+
133
+ const specPath = join(ticketDir, 'spec.ts');
134
+ if (existsSync(specPath)) input.existingSpec = readFileSync(specPath, 'utf8');
135
+
136
+ if (parsed.userHint) input.userHint = parsed.userHint;
137
+
138
+ const outPath = join(ticketDir, 'adversarial-input.json');
139
+ writeFileSync(outPath, JSON.stringify(input, null, 2));
140
+ console.log(`[explore-prepare] wrote ${outPath}`);
141
+ return 0;
142
+ }
@@ -8,6 +8,8 @@ import { evalDeterministicCmd } from './eval-deterministic';
8
8
  import { evalPrepareCmd } from './eval-prepare';
9
9
  import { evalReportCmd } from './eval-report';
10
10
  import { execCmd } from './exec';
11
+ import { exploreFinalizeCmd } from './explore-finalize';
12
+ import { explorePrepareCmd } from './explore-prepare';
11
13
  import { fetchCmd } from './fetch';
12
14
  import { fillGapFinalizeCmd } from './fill-gap-finalize';
13
15
  import { fillGapPrepareCmd } from './fill-gap-prepare';
@@ -41,6 +43,8 @@ const COMMANDS: Record<string, (argv: string[]) => Promise<number>> = {
41
43
  'eval-prepare': evalPrepareCmd,
42
44
  'eval-report': evalReportCmd,
43
45
  exec: execCmd,
46
+ 'explore-finalize': exploreFinalizeCmd,
47
+ 'explore-prepare': explorePrepareCmd,
44
48
  'fill-gap-finalize': fillGapFinalizeCmd,
45
49
  'fill-gap-prepare': fillGapPrepareCmd,
46
50
  fetch: fetchCmd,
@@ -120,7 +120,7 @@ const CoverageSchema = z
120
120
  .prefault({});
121
121
 
122
122
  export const XeraConfigSchema = z
123
- .object({
123
+ .strictObject({
124
124
  jira: JiraSchema,
125
125
  web: WebSchema.optional(),
126
126
  http: HttpSchema.optional(),