@xera-ai/core 0.1.7 → 0.4.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 (131) hide show
  1. package/bin/internal.ts +1 -0
  2. package/dist/adapter/types.d.ts +1 -1
  3. package/dist/adapter/types.d.ts.map +1 -1
  4. package/dist/artifact/meta.d.ts +2 -28
  5. package/dist/artifact/meta.d.ts.map +1 -1
  6. package/dist/artifact/status.d.ts +49 -74
  7. package/dist/artifact/status.d.ts.map +1 -1
  8. package/dist/auth/key.d.ts.map +1 -1
  9. package/dist/auth/refresh.d.ts.map +1 -1
  10. package/dist/auth/state.d.ts +5 -14
  11. package/dist/auth/state.d.ts.map +1 -1
  12. package/dist/bin/internal.js +10037 -746
  13. package/dist/bin-internal/doctor.d.ts +5 -0
  14. package/dist/bin-internal/doctor.d.ts.map +1 -0
  15. package/dist/bin-internal/eval-deterministic.d.ts +5 -0
  16. package/dist/bin-internal/eval-deterministic.d.ts.map +1 -0
  17. package/dist/bin-internal/eval-prepare.d.ts +7 -0
  18. package/dist/bin-internal/eval-prepare.d.ts.map +1 -0
  19. package/dist/bin-internal/eval-report.d.ts +5 -0
  20. package/dist/bin-internal/eval-report.d.ts.map +1 -0
  21. package/dist/bin-internal/exec.d.ts.map +1 -1
  22. package/dist/bin-internal/fetch.d.ts.map +1 -1
  23. package/dist/bin-internal/graph-backfill.d.ts +2 -0
  24. package/dist/bin-internal/graph-backfill.d.ts.map +1 -0
  25. package/dist/bin-internal/graph-query.d.ts +2 -0
  26. package/dist/bin-internal/graph-query.d.ts.map +1 -0
  27. package/dist/bin-internal/graph-record-script.d.ts +2 -0
  28. package/dist/bin-internal/graph-record-script.d.ts.map +1 -0
  29. package/dist/bin-internal/graph-record.d.ts +3 -0
  30. package/dist/bin-internal/graph-record.d.ts.map +1 -0
  31. package/dist/bin-internal/graph-snapshot.d.ts +2 -0
  32. package/dist/bin-internal/graph-snapshot.d.ts.map +1 -0
  33. package/dist/bin-internal/heal-prepare.d.ts +19 -0
  34. package/dist/bin-internal/heal-prepare.d.ts.map +1 -0
  35. package/dist/bin-internal/index.d.ts.map +1 -1
  36. package/dist/bin-internal/lint.d.ts.map +1 -1
  37. package/dist/bin-internal/normalize.d.ts.map +1 -1
  38. package/dist/bin-internal/post.d.ts.map +1 -1
  39. package/dist/bin-internal/status-cmd.d.ts.map +1 -1
  40. package/dist/bin-internal/typecheck.d.ts.map +1 -1
  41. package/dist/bin-internal/unlock.d.ts.map +1 -1
  42. package/dist/bin-internal/validate-feature.d.ts.map +1 -1
  43. package/dist/bin-internal/verify-prompts.d.ts +7 -0
  44. package/dist/bin-internal/verify-prompts.d.ts.map +1 -0
  45. package/dist/classifier/aggregate.d.ts.map +1 -1
  46. package/dist/config/define.d.ts.map +1 -1
  47. package/dist/config/load.d.ts.map +1 -1
  48. package/dist/config/schema.d.ts +38 -298
  49. package/dist/config/schema.d.ts.map +1 -1
  50. package/dist/eval/paths.d.ts +15 -0
  51. package/dist/eval/paths.d.ts.map +1 -0
  52. package/dist/eval/run-id.d.ts +6 -0
  53. package/dist/eval/run-id.d.ts.map +1 -0
  54. package/dist/eval/types.d.ts +203 -0
  55. package/dist/eval/types.d.ts.map +1 -0
  56. package/dist/graph/cost.d.ts +21 -0
  57. package/dist/graph/cost.d.ts.map +1 -0
  58. package/dist/graph/index.d.ts +8 -0
  59. package/dist/graph/index.d.ts.map +1 -0
  60. package/dist/graph/paths.d.ts +10 -0
  61. package/dist/graph/paths.d.ts.map +1 -0
  62. package/dist/graph/schema.d.ts +177 -0
  63. package/dist/graph/schema.d.ts.map +1 -0
  64. package/dist/graph/store.d.ts +14 -0
  65. package/dist/graph/store.d.ts.map +1 -0
  66. package/dist/graph/types.d.ts +151 -0
  67. package/dist/graph/types.d.ts.map +1 -0
  68. package/dist/graph/ulid.d.ts +2 -0
  69. package/dist/graph/ulid.d.ts.map +1 -0
  70. package/dist/index.d.ts +11 -11
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/jira/client.d.ts.map +1 -1
  73. package/dist/jira/fields.d.ts.map +1 -1
  74. package/dist/jira/rest-backend.d.ts.map +1 -1
  75. package/dist/reporter/jira-comment.d.ts.map +1 -1
  76. package/dist/reporter/status-writer.d.ts.map +1 -1
  77. package/dist/src/index.js +349 -321
  78. package/package.json +19 -13
  79. package/src/adapter/types.ts +5 -2
  80. package/src/artifact/meta.ts +1 -1
  81. package/src/artifact/status.ts +1 -1
  82. package/src/auth/encrypt.ts +2 -2
  83. package/src/auth/key.ts +1 -2
  84. package/src/auth/refresh.ts +5 -1
  85. package/src/auth/state.ts +2 -2
  86. package/src/bin-internal/doctor.ts +169 -0
  87. package/src/bin-internal/eval-deterministic.ts +149 -0
  88. package/src/bin-internal/eval-prepare.ts +214 -0
  89. package/src/bin-internal/eval-report.ts +177 -0
  90. package/src/bin-internal/exec.ts +28 -15
  91. package/src/bin-internal/fetch.ts +21 -10
  92. package/src/bin-internal/graph-backfill.ts +43 -0
  93. package/src/bin-internal/graph-query.ts +43 -0
  94. package/src/bin-internal/graph-record-script.ts +191 -0
  95. package/src/bin-internal/graph-record.ts +243 -0
  96. package/src/bin-internal/graph-snapshot.ts +23 -0
  97. package/src/bin-internal/heal-prepare.ts +230 -0
  98. package/src/bin-internal/index.ts +33 -11
  99. package/src/bin-internal/lint.ts +11 -4
  100. package/src/bin-internal/normalize.ts +23 -9
  101. package/src/bin-internal/post.ts +10 -4
  102. package/src/bin-internal/report.ts +3 -3
  103. package/src/bin-internal/status-cmd.ts +11 -3
  104. package/src/bin-internal/typecheck.ts +9 -3
  105. package/src/bin-internal/unlock.ts +12 -4
  106. package/src/bin-internal/validate-feature.ts +14 -5
  107. package/src/bin-internal/verify-prompts.ts +60 -0
  108. package/src/classifier/aggregate.ts +13 -6
  109. package/src/config/define.ts +3 -1
  110. package/src/config/load.ts +1 -1
  111. package/src/config/schema.ts +43 -37
  112. package/src/eval/paths.ts +32 -0
  113. package/src/eval/run-id.ts +30 -0
  114. package/src/eval/types.ts +101 -0
  115. package/src/graph/cost.ts +59 -0
  116. package/src/graph/index.ts +15 -0
  117. package/src/graph/paths.ts +27 -0
  118. package/src/graph/schema.ts +135 -0
  119. package/src/graph/store.ts +231 -0
  120. package/src/graph/types.ts +174 -0
  121. package/src/graph/ulid.ts +58 -0
  122. package/src/index.ts +11 -11
  123. package/src/jira/client.ts +4 -2
  124. package/src/jira/fields.ts +4 -2
  125. package/src/jira/mcp-backend.ts +1 -1
  126. package/src/jira/rest-backend.ts +18 -6
  127. package/src/jira/retry.ts +2 -2
  128. package/src/lock/file-lock.ts +2 -2
  129. package/src/logging/ndjson-logger.ts +2 -2
  130. package/src/reporter/jira-comment.ts +13 -7
  131. package/src/reporter/status-writer.ts +2 -2
@@ -0,0 +1,177 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { resolveEvalPaths } from '../eval/paths';
3
+ import {
4
+ type DeterministicScores,
5
+ DeterministicScoresSchema,
6
+ type JudgeScores,
7
+ JudgeScoresSchema,
8
+ type Judgment,
9
+ ManifestSchema,
10
+ type Result,
11
+ type Summary,
12
+ SummarySchema,
13
+ } from '../eval/types';
14
+ import { releaseLock } from '../lock/file-lock';
15
+
16
+ export interface EvalReportOpts {
17
+ cwd?: string;
18
+ }
19
+
20
+ function scoreJudgment(j: Judgment): { passed: boolean; score: number } {
21
+ const nonNa = j.dimensions.filter((d) => d.verdict !== 'NA');
22
+ if (nonNa.length === 0) return { passed: true, score: 1 };
23
+ const passes = nonNa.filter((d) => d.verdict === 'PASS').length;
24
+ const score = passes / nonNa.length;
25
+ const passed = nonNa.every((d) => d.verdict === 'PASS');
26
+ return { passed, score };
27
+ }
28
+
29
+ function renderReport(summary: Summary): string {
30
+ const lines: string[] = [];
31
+ lines.push(`# xera eval report ${summary.run_id}`);
32
+ lines.push('');
33
+ lines.push(`**Git SHA:** \`${summary.git_sha}\``);
34
+ lines.push('');
35
+ lines.push('**Prompt versions:**');
36
+ for (const [k, v] of Object.entries(summary.prompt_versions)) lines.push(`- \`${k}\`: ${v}`);
37
+ lines.push('');
38
+ lines.push(
39
+ `**Overall:** ${summary.overall.passed}/${summary.overall.total} PASS (score ${(summary.overall.score * 100).toFixed(0)}%)`,
40
+ );
41
+ lines.push('');
42
+ lines.push('## Results');
43
+ lines.push('');
44
+ lines.push('| Ticket | Stage | Deterministic | Judge | Score |');
45
+ lines.push('|---|---|---|---|---|');
46
+ for (const r of summary.results) {
47
+ const det = r.deterministic.passed ? 'PASS' : `FAIL (${r.deterministic.error ?? ''})`;
48
+ const judge = r.skipped ? 'SKIPPED' : r.judge ? (r.judge.passed ? 'PASS' : 'FAIL') : 'SKIPPED';
49
+ const score = r.judge ? `${(r.judge.score * 100).toFixed(0)}%` : '—';
50
+ lines.push(`| ${r.ticket} | ${r.stage} | ${det} | ${judge} | ${score} |`);
51
+ }
52
+ lines.push('');
53
+ lines.push('## Dimension breakdown');
54
+ lines.push('');
55
+ for (const r of summary.results) {
56
+ if (!r.judge || r.judge.dimensions.length === 0) continue;
57
+ lines.push(`### ${r.ticket} — ${r.stage}`);
58
+ lines.push('');
59
+ for (const d of r.judge.dimensions) lines.push(`- **${d.name}** — ${d.verdict}: ${d.notes}`);
60
+ lines.push('');
61
+ }
62
+ return lines.join('\n');
63
+ }
64
+
65
+ export async function evalReportCmd(argv: string[], opts: EvalReportOpts = {}): Promise<number> {
66
+ const cwd = opts.cwd ?? process.cwd();
67
+ const runId = argv[0];
68
+ if (!runId) {
69
+ console.error('[xera:eval-report] usage: eval-report <run-id>');
70
+ return 1;
71
+ }
72
+ const paths = resolveEvalPaths(cwd, runId);
73
+ if (!existsSync(paths.manifest)) {
74
+ console.error(`[xera:eval-report] missing manifest.json at ${paths.manifest}`);
75
+ return 1;
76
+ }
77
+ const manifest = ManifestSchema.parse(JSON.parse(readFileSync(paths.manifest, 'utf8')));
78
+
79
+ try {
80
+ let det: DeterministicScores;
81
+ let judge: JudgeScores;
82
+ try {
83
+ det = DeterministicScoresSchema.parse(
84
+ JSON.parse(readFileSync(paths.deterministicScores, 'utf8')),
85
+ );
86
+ } catch (err) {
87
+ console.error(
88
+ `[xera:eval-report] invalid deterministic-scores.json: ${(err as Error).message}`,
89
+ );
90
+ return 2;
91
+ }
92
+ try {
93
+ judge = JudgeScoresSchema.parse(JSON.parse(readFileSync(paths.judgeScores, 'utf8')));
94
+ } catch (err) {
95
+ console.error(`[xera:eval-report] invalid judge-scores.json: ${(err as Error).message}`);
96
+ return 2;
97
+ }
98
+
99
+ const results: Result[] = [];
100
+ for (const detEntry of det.entries) {
101
+ const judgment = judge.judgments.find(
102
+ (j) => j.ticket === detEntry.ticket && j.stage === detEntry.stage,
103
+ );
104
+ if (!judgment && detEntry.error?.startsWith('actual missing')) {
105
+ const r: Result = {
106
+ ticket: detEntry.ticket,
107
+ stage: detEntry.stage,
108
+ deterministic: {
109
+ passed: detEntry.passed,
110
+ checks: detEntry.checks,
111
+ ...(detEntry.error !== undefined ? { error: detEntry.error } : {}),
112
+ },
113
+ judge: null,
114
+ skipped: true,
115
+ };
116
+ results.push(r);
117
+ continue;
118
+ }
119
+ if (!judgment) {
120
+ // Judge entry expected but missing: count as FAIL not SKIPPED.
121
+ const r: Result = {
122
+ ticket: detEntry.ticket,
123
+ stage: detEntry.stage,
124
+ deterministic: {
125
+ passed: detEntry.passed,
126
+ checks: detEntry.checks,
127
+ ...(detEntry.error !== undefined ? { error: detEntry.error } : {}),
128
+ },
129
+ judge: { passed: false, dimensions: [], score: 0 },
130
+ };
131
+ results.push(r);
132
+ continue;
133
+ }
134
+ const { passed, score } = scoreJudgment(judgment);
135
+ const r: Result = {
136
+ ticket: detEntry.ticket,
137
+ stage: detEntry.stage,
138
+ deterministic: {
139
+ passed: detEntry.passed,
140
+ checks: detEntry.checks,
141
+ ...(detEntry.error !== undefined ? { error: detEntry.error } : {}),
142
+ },
143
+ judge: { passed, dimensions: judgment.dimensions, score },
144
+ };
145
+ results.push(r);
146
+ }
147
+
148
+ const counted = results.filter((r) => !r.skipped);
149
+ const passedCount = counted.filter((r) => r.deterministic.passed && r.judge?.passed).length;
150
+ const failedCount = counted.length - passedCount;
151
+ const avgScore =
152
+ counted.length === 0
153
+ ? 0
154
+ : counted.reduce(
155
+ (acc, r) => acc + (r.deterministic.passed && r.judge ? r.judge.score : 0),
156
+ 0,
157
+ ) / counted.length;
158
+
159
+ const summary: Summary = {
160
+ run_id: runId,
161
+ git_sha: manifest.git_sha,
162
+ prompt_versions: manifest.prompt_versions,
163
+ results,
164
+ overall: { passed: passedCount, failed: failedCount, total: counted.length, score: avgScore },
165
+ };
166
+ SummarySchema.parse(summary);
167
+ writeFileSync(paths.summary, JSON.stringify(summary, null, 2));
168
+ writeFileSync(paths.report, renderReport(summary));
169
+
170
+ console.log(
171
+ `[xera:eval-report] ${passedCount}/${counted.length} PASS (avg ${(avgScore * 100).toFixed(0)}%)`,
172
+ );
173
+ return 0;
174
+ } finally {
175
+ releaseLock(paths.lock);
176
+ }
177
+ }
@@ -1,17 +1,20 @@
1
- import { resolveArtifactPaths, generateRunId } from '../artifact/paths';
2
- import { acquireLock, releaseLock, isLockStale, readLock, forceUnlock } from '../lock/file-lock';
3
- import { NdjsonLogger } from '../logging/ndjson-logger';
4
- import { loadConfig } from '../config/load';
5
- import { readAuthState } from '../auth/state';
6
- import { needsRefresh } from '../auth/refresh';
7
- import { stagePlaywrightState, runAuthSetup, runPlaywright } from '@xera-ai/web';
8
- import { chromium } from '@playwright/test';
9
- import { mkdirSync, existsSync } from 'node:fs';
1
+ import { existsSync, mkdirSync } from 'node:fs';
10
2
  import { join } from 'node:path';
3
+ import { chromium } from '@playwright/test';
4
+ import { runAuthSetup, runPlaywright, stagePlaywrightState } from '@xera-ai/web';
5
+ import { generateRunId, resolveArtifactPaths } from '../artifact/paths';
6
+ import { needsRefresh } from '../auth/refresh';
7
+ import { readAuthState } from '../auth/state';
8
+ import { loadConfig } from '../config/load';
9
+ import { acquireLock, forceUnlock, isLockStale, readLock, releaseLock } from '../lock/file-lock';
10
+ import { NdjsonLogger } from '../logging/ndjson-logger';
11
11
 
12
12
  export async function execCmd(argv: string[]): Promise<number> {
13
13
  const ticket = argv[0];
14
- if (!ticket) { console.error('[xera:exec] usage: exec <TICKET>'); return 1; }
14
+ if (!ticket) {
15
+ console.error('[xera:exec] usage: exec <TICKET>');
16
+ return 1;
17
+ }
15
18
  const cwd = process.cwd();
16
19
  const config = await loadConfig(cwd);
17
20
  const paths = resolveArtifactPaths(cwd, ticket);
@@ -21,12 +24,16 @@ export async function execCmd(argv: string[]): Promise<number> {
21
24
  // Acquire lock
22
25
  if (!acquireLock(paths.lockPath, runId)) {
23
26
  if (isLockStale(paths.lockPath)) {
24
- console.error(`[xera:exec] stale lock detected; force unlocking. Run \`xera-internal unlock ${ticket}\` to clear manually.`);
27
+ console.error(
28
+ `[xera:exec] stale lock detected; force unlocking. Run \`xera-internal unlock ${ticket}\` to clear manually.`,
29
+ );
25
30
  forceUnlock(paths.lockPath);
26
31
  acquireLock(paths.lockPath, runId);
27
32
  } else {
28
33
  const existing = readLock(paths.lockPath);
29
- console.error(`[xera:exec] another run in progress (PID ${existing?.pid} on ${existing?.hostname}, started ${existing?.started_at}). Wait or run \`xera-internal unlock ${ticket}\`.`);
34
+ console.error(
35
+ `[xera:exec] another run in progress (PID ${existing?.pid} on ${existing?.hostname}, started ${existing?.started_at}). Wait or run \`xera-internal unlock ${ticket}\`.`,
36
+ );
30
37
  return 1;
31
38
  }
32
39
  }
@@ -39,11 +46,18 @@ export async function execCmd(argv: string[]): Promise<number> {
39
46
  try {
40
47
  for (const [roleName, roleCreds] of Object.entries(config.web.auth.roles)) {
41
48
  const entry = readAuthState(paths.authDir, roleName);
42
- if (needsRefresh(entry, { ttl: config.web.auth.ttl, refreshBuffer: config.web.auth.refreshBuffer })) {
49
+ if (
50
+ needsRefresh(entry, {
51
+ ttl: config.web.auth.ttl,
52
+ refreshBuffer: config.web.auth.refreshBuffer,
53
+ })
54
+ ) {
43
55
  const email = process.env[roleCreds.envEmail];
44
56
  const password = process.env[roleCreds.envPassword];
45
57
  if (!email || !password) {
46
- console.error(`[xera:exec] missing env ${roleCreds.envEmail} or ${roleCreds.envPassword} for role "${roleName}"`);
58
+ console.error(
59
+ `[xera:exec] missing env ${roleCreds.envEmail} or ${roleCreds.envPassword} for role "${roleName}"`,
60
+ );
47
61
  return 1;
48
62
  }
49
63
  await runAuthSetup({
@@ -113,4 +127,3 @@ export async function execCmd(argv: string[]): Promise<number> {
113
127
  releaseLock(paths.lockPath);
114
128
  }
115
129
  }
116
-
@@ -1,13 +1,15 @@
1
- import { writeFileSync, mkdirSync } from 'node:fs';
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
- import { loadConfig } from '../config/load';
4
- import { resolveArtifactPaths } from '../artifact/paths';
5
3
  import { hashString } from '../artifact/hash';
6
- import { writeMeta, readMeta } from '../artifact/meta';
4
+ import { readMeta, writeMeta } from '../artifact/meta';
5
+ import { resolveArtifactPaths } from '../artifact/paths';
6
+ import { loadConfig } from '../config/load';
7
7
  import { createJiraClient } from '../jira/client';
8
8
  import type { JiraTicket } from '../jira/types';
9
9
 
10
- export interface FetchCmdOpts { cwd?: string; }
10
+ export interface FetchCmdOpts {
11
+ cwd?: string;
12
+ }
11
13
 
12
14
  export async function fetchCmd(argv: string[], opts: FetchCmdOpts = {}): Promise<number> {
13
15
  const cwd = opts.cwd ?? process.cwd();
@@ -31,9 +33,13 @@ export async function fetchCmd(argv: string[], opts: FetchCmdOpts = {}): Promise
31
33
  ? { rest: { email: process.env.JIRA_EMAIL, apiToken: process.env.JIRA_API_TOKEN } }
32
34
  : {}),
33
35
  });
34
- const fieldMap = config.jira.fields.acceptanceCriteria !== undefined
35
- ? { story: config.jira.fields.story, acceptanceCriteria: config.jira.fields.acceptanceCriteria }
36
- : { story: config.jira.fields.story };
36
+ const fieldMap =
37
+ config.jira.fields.acceptanceCriteria !== undefined
38
+ ? {
39
+ story: config.jira.fields.story,
40
+ acceptanceCriteria: config.jira.fields.acceptanceCriteria,
41
+ }
42
+ : { story: config.jira.fields.story };
37
43
  t = await client.fetchTicket(ticket, fieldMap);
38
44
  }
39
45
 
@@ -69,7 +75,7 @@ function renderStory(t: JiraTicket): string {
69
75
  lines.push('## Story', '', story, '');
70
76
  }
71
77
 
72
- if (t.acceptanceCriteria && t.acceptanceCriteria.trim()) {
78
+ if (t.acceptanceCriteria?.trim()) {
73
79
  const ac = t.acceptanceCriteria.trim();
74
80
  if (/^##\s+acceptance\s+criteria\b/i.test(ac)) {
75
81
  lines.push(ac, '');
@@ -78,7 +84,12 @@ function renderStory(t: JiraTicket): string {
78
84
  }
79
85
  }
80
86
  if (t.attachments.length > 0) {
81
- lines.push('## Attachments', '', ...t.attachments.map((a) => `- [${a.filename}](${a.url})`), '');
87
+ lines.push(
88
+ '## Attachments',
89
+ '',
90
+ ...t.attachments.map((a) => `- [${a.filename}](${a.url})`),
91
+ '',
92
+ );
82
93
  }
83
94
  return lines.join('\n');
84
95
  }
@@ -0,0 +1,43 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { recordScriptImpl } from './graph-record-script';
4
+
5
+ async function backfillTicket(repoRoot: string, ticket: string, dryRun: boolean): Promise<number> {
6
+ // 1) Synthesize ticket.fetched from story.md (use story.md mtime)
7
+ const storyPath = join(repoRoot, '.xera', ticket, 'story.md');
8
+ if (!existsSync(storyPath)) return 0;
9
+
10
+ const { recordFetch } = await import('./graph-record');
11
+ // Re-use the same code path; in dry-run we don't actually call appendEvents.
12
+ // For simplicity in v0.6.0, dry-run lists ticket count and returns.
13
+ if (dryRun) {
14
+ console.log(`[backfill dry-run] would backfill ${ticket}`);
15
+ return 0;
16
+ }
17
+ // recordScriptImpl handles scenario/POM extraction.
18
+ await recordScriptImpl(repoRoot, ticket);
19
+ await recordFetch(repoRoot, ticket);
20
+ return 0;
21
+ }
22
+
23
+ export async function graphBackfillCmd(argv: string[]): Promise<number> {
24
+ const dryRun = argv.includes('--dry-run');
25
+ const repoRoot = process.cwd();
26
+ const xeraDir = join(repoRoot, '.xera');
27
+ if (!existsSync(xeraDir)) {
28
+ console.log('[backfill] no .xera/ directory');
29
+ return 0;
30
+ }
31
+ const tickets: string[] = [];
32
+ for (const entry of readdirSync(xeraDir, { withFileTypes: true })) {
33
+ if (!entry.isDirectory()) continue;
34
+ if (entry.name === 'graph') continue;
35
+ if (entry.name.startsWith('.')) continue;
36
+ if (!/^[A-Z]+-\d+$/.test(entry.name)) continue;
37
+ tickets.push(entry.name);
38
+ }
39
+ console.log(`[backfill] found ${tickets.length} tickets`);
40
+ for (const t of tickets) await backfillTicket(repoRoot, t, dryRun);
41
+ console.log(`[backfill] done`);
42
+ return 0;
43
+ }
@@ -0,0 +1,43 @@
1
+ import { deriveSnapshot, loadAllEvents } from '../graph/store';
2
+ import type { Snapshot } from '../graph/types';
3
+
4
+ function filterByTicket(snap: Snapshot, ticket: string): Snapshot {
5
+ const out: Snapshot = {
6
+ ...snap,
7
+ tickets: snap.tickets[ticket] ? { [ticket]: snap.tickets[ticket]! } : {},
8
+ scenarios: Object.fromEntries(
9
+ Object.entries(snap.scenarios).filter(([, s]) => s.ticketId === ticket),
10
+ ),
11
+ poms: Object.fromEntries(Object.entries(snap.poms).filter(([, p]) => p.ticketId === ticket)),
12
+ edges: snap.edges.filter((e) => e.from === ticket || e.to === ticket),
13
+ };
14
+ return out;
15
+ }
16
+
17
+ function renderText(snap: Snapshot): string {
18
+ const out: string[] = [];
19
+ out.push(`Graph snapshot — ${snap.event_count} events`);
20
+ out.push(`Tickets: ${Object.keys(snap.tickets).length}`);
21
+ out.push(`Scenarios: ${Object.keys(snap.scenarios).length}`);
22
+ out.push(`POMs: ${Object.keys(snap.poms).length}`);
23
+ out.push(`Edges: ${snap.edges.length}`);
24
+ for (const t of Object.values(snap.tickets)) {
25
+ out.push(` ${t.id} — ${t.summary}`);
26
+ }
27
+ return out.join('\n');
28
+ }
29
+
30
+ export async function graphQueryCmd(argv: string[]): Promise<number> {
31
+ let ticket: string | undefined;
32
+ let format: 'text' | 'json' = 'text';
33
+ for (let i = 0; i < argv.length; i++) {
34
+ if (argv[i] === '--ticket') ticket = argv[++i];
35
+ else if (argv[i] === '--format') format = argv[++i] as 'text' | 'json';
36
+ }
37
+ const repoRoot = process.cwd();
38
+ let snap = deriveSnapshot(loadAllEvents(repoRoot));
39
+ if (ticket) snap = filterByTicket(snap, ticket);
40
+ if (format === 'json') process.stdout.write(JSON.stringify(snap, null, 2));
41
+ else process.stdout.write(renderText(snap));
42
+ return 0;
43
+ }
@@ -0,0 +1,191 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
+ import { basename, join } from 'node:path';
4
+ import { appendEvents } from '../graph/store';
5
+ import type {
6
+ EdgeDiscoveredPayload,
7
+ Event,
8
+ PomGeneratedPayload,
9
+ ScenarioGeneratedPayload,
10
+ } from '../graph/types';
11
+ import { SCHEMA_VERSION } from '../graph/types';
12
+ import { ulid } from '../graph/ulid';
13
+
14
+ const sha1 = (s: string) => createHash('sha1').update(s).digest('hex');
15
+ const sId = (ticket: string, name: string) =>
16
+ sha1(`${ticket}:${name.trim().toLowerCase().replace(/\s+/g, ' ')}`);
17
+ const pId = (file: string) => sha1(basename(file));
18
+ const nowIso = () => new Date().toISOString();
19
+ const mk = <T extends Event['type']>(
20
+ actor: string,
21
+ type: T,
22
+ payload: Extract<Event, { type: T }>['payload'],
23
+ ): Event =>
24
+ ({
25
+ event_id: ulid(),
26
+ schema_version: SCHEMA_VERSION,
27
+ ts: nowIso(),
28
+ actor,
29
+ type,
30
+ payload,
31
+ }) as Event;
32
+
33
+ function parseFeature(
34
+ text: string,
35
+ ): Array<{ name: string; priority: 'p0' | 'p1' | 'p2'; gherkin: string }> {
36
+ const scenarios: Array<{ name: string; priority: 'p0' | 'p1' | 'p2'; gherkin: string }> = [];
37
+ const lines = text.split('\n');
38
+ let currentTagPriority: 'p0' | 'p1' | 'p2' = 'p1';
39
+ let i = 0;
40
+ while (i < lines.length) {
41
+ const line = lines[i]!.trim();
42
+ if (line.startsWith('@')) {
43
+ const tag = line.slice(1).split(/\s+/)[0]!.toLowerCase();
44
+ if (tag === 'p0' || tag === 'p1' || tag === 'p2') currentTagPriority = tag;
45
+ i++;
46
+ continue;
47
+ }
48
+ if (line.startsWith('Scenario:') || line.startsWith('Scenario Outline:')) {
49
+ const name = line.replace(/^Scenario( Outline)?:\s*/, '');
50
+ const start = i;
51
+ i++;
52
+ while (
53
+ i < lines.length &&
54
+ !lines[i]!.trim().startsWith('Scenario') &&
55
+ !lines[i]!.trim().startsWith('@')
56
+ )
57
+ i++;
58
+ scenarios.push({
59
+ name,
60
+ priority: currentTagPriority,
61
+ gherkin: lines.slice(start, i).join('\n'),
62
+ });
63
+ currentTagPriority = 'p1';
64
+ continue;
65
+ }
66
+ i++;
67
+ }
68
+ return scenarios;
69
+ }
70
+
71
+ function listPomFiles(dir: string): string[] {
72
+ if (!existsSync(dir)) return [];
73
+ return readdirSync(dir)
74
+ .filter((f) => f.endsWith('.ts'))
75
+ .map((f) => join(dir, f));
76
+ }
77
+
78
+ function extractRoute(pomContent: string): string {
79
+ const m = pomContent.match(/goto\s*\(\s*['"]([^'"]+)['"]/);
80
+ return m ? m[1]! : '';
81
+ }
82
+
83
+ function extractLocators(pomContent: string): string[] {
84
+ const out: string[] = [];
85
+ const re = /\b(getByRole|getByLabel|getByText|getByTestId|locator)\s*\(\s*([^)]+)\)/g;
86
+ let m = re.exec(pomContent);
87
+ while (m !== null) {
88
+ out.push(`${m[1]}(${m[2]})`);
89
+ m = re.exec(pomContent);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ function extractPomUsage(specContent: string): string[] {
95
+ const names = new Set<string>();
96
+ const re = /new\s+([A-Z][A-Za-z0-9]*Page)\s*\(/g;
97
+ let m = re.exec(specContent);
98
+ while (m !== null) {
99
+ names.add(m[1]!);
100
+ m = re.exec(specContent);
101
+ }
102
+ return [...names];
103
+ }
104
+
105
+ export async function recordScriptImpl(repoRoot: string, ticket: string): Promise<number> {
106
+ const ticketDir = join(repoRoot, '.xera', ticket);
107
+ const featurePath = join(ticketDir, 'feature', `${ticket}.feature`);
108
+ const specPath = join(ticketDir, 'tests', `${ticket}.spec.ts`);
109
+ const pomDir = join(ticketDir, 'poms');
110
+
111
+ if (!existsSync(featurePath)) {
112
+ console.error(`[graph-record script] feature missing`);
113
+ return 1;
114
+ }
115
+
116
+ const featureText = readFileSync(featurePath, 'utf8');
117
+ const featureHash = sha1(featureText);
118
+ const scenarios = parseFeature(featureText);
119
+
120
+ const events: Event[] = [];
121
+ for (const s of scenarios) {
122
+ const id = sId(ticket, s.name);
123
+ const p: ScenarioGeneratedPayload = {
124
+ scenarioId: id,
125
+ ticketId: ticket,
126
+ name: s.name,
127
+ gherkin: s.gherkin,
128
+ priority: s.priority,
129
+ featureHash,
130
+ generatedAt: nowIso(),
131
+ };
132
+ events.push(mk('xera-script', 'scenario.generated', p));
133
+ }
134
+
135
+ const pomFiles = listPomFiles(pomDir);
136
+ const pomNameToId = new Map<string, string>();
137
+ for (const pomFile of pomFiles) {
138
+ const content = readFileSync(pomFile, 'utf8');
139
+ const id = pId(pomFile);
140
+ const className = content.match(/export\s+class\s+([A-Z][A-Za-z0-9]*Page)/)?.[1] ?? '';
141
+ pomNameToId.set(className, id);
142
+ const pg: PomGeneratedPayload = {
143
+ pomId: id,
144
+ ticketId: ticket,
145
+ filePath: pomFile.replace(`${repoRoot}/`, ''),
146
+ route: extractRoute(content),
147
+ locators: extractLocators(content),
148
+ scope: 'local',
149
+ };
150
+ events.push(mk('xera-script', 'pom.generated', pg));
151
+ }
152
+
153
+ if (existsSync(specPath)) {
154
+ const specContent = readFileSync(specPath, 'utf8');
155
+ const usedPoms = extractPomUsage(specContent);
156
+ for (const scenario of scenarios) {
157
+ const scId = sId(ticket, scenario.name);
158
+ for (const pomName of usedPoms) {
159
+ const pid = pomNameToId.get(pomName);
160
+ if (!pid) continue;
161
+ const ep: EdgeDiscoveredPayload = {
162
+ kind: 'uses',
163
+ from: scId,
164
+ to: pid,
165
+ source: 'xera-script',
166
+ };
167
+ events.push(mk('xera-script', 'edge.discovered', ep));
168
+ }
169
+ }
170
+ }
171
+
172
+ for (const [, id] of pomNameToId) {
173
+ const pom = events.find(
174
+ (e) => e.type === 'pom.generated' && (e.payload as PomGeneratedPayload).pomId === id,
175
+ );
176
+ if (!pom) continue;
177
+ const route = (pom.payload as PomGeneratedPayload).route;
178
+ if (!route) continue;
179
+ const slug =
180
+ route
181
+ .replace(/^\//, '')
182
+ .split('/')[0]!
183
+ .replace(/[^a-z0-9-]/gi, '-')
184
+ .toLowerCase() || 'root';
185
+ const ep: EdgeDiscoveredPayload = { kind: 'covers', from: id, to: slug, source: 'xera-script' };
186
+ events.push(mk('xera-script', 'edge.discovered', ep));
187
+ }
188
+
189
+ appendEvents(repoRoot, events, { skill: 'xera-script', ticketId: ticket });
190
+ return 0;
191
+ }