playwright-intelligence-engine 2.0.0 → 2.0.2
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/README.md +229 -229
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/core/version.d.ts +3 -3
- package/dist/index.js +81 -24
- package/package.json +76 -76
- package/src/authoring/author.ts +81 -81
- package/src/authoring/generation-contract.ts +283 -283
- package/src/authoring/hybrid-author.ts +115 -115
- package/src/authoring/index.ts +7 -7
- package/src/authoring/llm-author.ts +282 -282
- package/src/authoring/naming/name-plan.ts +28 -28
- package/src/authoring/naming/name-planner.ts +339 -339
- package/src/authoring/naming/naming-linter.ts +292 -292
- package/src/cli/index.ts +1238 -1172
- package/src/collectors/git-diff-collector.ts +190 -190
- package/src/core/version.ts +25 -25
- package/src/diagnostics/diagnostic-report.ts +204 -204
- package/src/diagnostics/failure-classifier.ts +701 -701
- package/src/evaluation/repository-runner.ts +197 -197
- package/src/gate/quality.ts +101 -101
- package/src/generator/architecture-generator.ts +582 -582
- package/src/generator/assertion-quality-gate.ts +88 -88
- package/src/generator/candidate-writer.ts +288 -288
- package/src/generator/test-generator.ts +52 -52
- package/src/index.ts +107 -107
- package/src/intelligence/evidence-bundle.ts +300 -300
- package/src/intelligence/generation-agent.ts +561 -561
- package/src/intelligence/generation-verifier.ts +1115 -1115
- package/src/intelligence/locator-disambiguation-engine.ts +331 -331
- package/src/intelligence/project-intelligence.ts +140 -140
- package/src/intelligence/repository-profile.ts +796 -796
- package/src/intelligence/trace-proposal-workflow.ts +318 -318
- package/src/intelligence/workflow-planner.ts +966 -966
- package/src/mcp/server.ts +970 -970
- package/src/perception/app-model.ts +32 -32
- package/src/perception/crawler.ts +263 -263
- package/src/perception/index.ts +3 -3
- package/src/repair/assertion-hasher.ts +386 -386
- package/src/repair/diff-allowlist.ts +612 -612
- package/src/repair/intent-validator.ts +761 -761
- package/src/repair/repair-planner.ts +467 -467
- package/src/repair/statement-patcher.ts +544 -544
- package/src/reproduce/interactive-workflow-runner.ts +828 -828
- package/src/sandbox/execution-runner.ts +408 -408
- package/src/selector/abstention-policy.ts +175 -175
- package/src/selector/candidate-extractor.ts +343 -343
- package/src/selector/repair-candidate-ranker.ts +92 -92
- package/src/selector/selector-scorer.ts +344 -344
|
@@ -1,966 +1,966 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
|
-
import { z } from 'zod';
|
|
3
|
-
import { toSlug } from '../generator/architecture-generator.js';
|
|
4
|
-
import { extractBusinessIntent } from './business-intent.js';
|
|
5
|
-
import { ExistingTestInput, RequirementInput } from './test-intelligence.js';
|
|
6
|
-
|
|
7
|
-
export const WorkflowProvenanceSchema = z.enum([
|
|
8
|
-
'explicit-requirement',
|
|
9
|
-
'source-derived',
|
|
10
|
-
'diff-derived',
|
|
11
|
-
'assumption',
|
|
12
|
-
]);
|
|
13
|
-
export type WorkflowProvenance = z.infer<typeof WorkflowProvenanceSchema>;
|
|
14
|
-
|
|
15
|
-
export const WorkflowScenarioCategorySchema = z.enum([
|
|
16
|
-
'happy-path',
|
|
17
|
-
'negative',
|
|
18
|
-
'validation',
|
|
19
|
-
'required-fields',
|
|
20
|
-
'boundary',
|
|
21
|
-
'persistence',
|
|
22
|
-
'authentication',
|
|
23
|
-
'authorization',
|
|
24
|
-
'state-transition',
|
|
25
|
-
'error-handling',
|
|
26
|
-
'recovery',
|
|
27
|
-
'ui-api-consistency',
|
|
28
|
-
]);
|
|
29
|
-
export type WorkflowScenarioCategory = z.infer<typeof WorkflowScenarioCategorySchema>;
|
|
30
|
-
|
|
31
|
-
export const WorkflowStepSchema = z.object({
|
|
32
|
-
keyword: z.enum(['Given', 'When', 'And', 'Then', 'But']),
|
|
33
|
-
text: z.string().min(1),
|
|
34
|
-
actionHint: z.string().optional(),
|
|
35
|
-
targetHint: z.string().optional(),
|
|
36
|
-
valueHint: z.string().optional(),
|
|
37
|
-
});
|
|
38
|
-
export type WorkflowStep = z.infer<typeof WorkflowStepSchema>;
|
|
39
|
-
|
|
40
|
-
export const NormalizedRequirementSchema = z.object({
|
|
41
|
-
id: z.string().min(1),
|
|
42
|
-
title: z.string().min(1),
|
|
43
|
-
description: z.string(),
|
|
44
|
-
acceptanceCriteria: z.array(z.string()).default([]),
|
|
45
|
-
provenance: WorkflowProvenanceSchema,
|
|
46
|
-
risk: z.enum(['critical', 'high', 'medium', 'low', 'unknown']),
|
|
47
|
-
ambiguous: z.boolean().default(false),
|
|
48
|
-
clarificationsNeeded: z.array(z.string()).default([]),
|
|
49
|
-
sourceRef: z.string().optional(),
|
|
50
|
-
});
|
|
51
|
-
export type NormalizedRequirement = z.infer<typeof NormalizedRequirementSchema>;
|
|
52
|
-
|
|
53
|
-
export const PlannedWorkflowScenarioSchema = z.object({
|
|
54
|
-
id: z.string().min(1),
|
|
55
|
-
requirementRefs: z.array(z.string()).min(1),
|
|
56
|
-
title: z.string().min(1),
|
|
57
|
-
category: WorkflowScenarioCategorySchema,
|
|
58
|
-
priority: z.enum(['P0', 'P1', 'P2', 'P3']),
|
|
59
|
-
tags: z.array(z.string()).default([]),
|
|
60
|
-
provenance: WorkflowProvenanceSchema,
|
|
61
|
-
applicability: z.enum(['applicable', 'covered', 'not_applicable', 'needs_clarification']),
|
|
62
|
-
preconditions: z.array(z.string()).default([]),
|
|
63
|
-
steps: z.array(WorkflowStepSchema).min(1),
|
|
64
|
-
expectedOutcomes: z.array(z.string()).default([]),
|
|
65
|
-
evidenceRequirements: z.array(z.string()).default([]),
|
|
66
|
-
confidence: z.number().min(0).max(1),
|
|
67
|
-
assumptions: z.array(z.string()).default([]),
|
|
68
|
-
});
|
|
69
|
-
export type PlannedWorkflowScenario = z.infer<typeof PlannedWorkflowScenarioSchema>;
|
|
70
|
-
|
|
71
|
-
export const GenerationWorkflowPlanSchema = z.object({
|
|
72
|
-
planVersion: z.literal('1.0').default('1.0'),
|
|
73
|
-
featureName: z.string().min(1),
|
|
74
|
-
slug: z.string().min(1),
|
|
75
|
-
createdAt: z.string(),
|
|
76
|
-
planHash: z.string().min(1),
|
|
77
|
-
background: z.array(z.string()).default([]),
|
|
78
|
-
inputs: z.array(z.object({
|
|
79
|
-
kind: z.enum(['prompt', 'markdown', 'text', 'json', 'feature', 'git-diff', 'source', 'jira']),
|
|
80
|
-
reference: z.string(),
|
|
81
|
-
})).default([]),
|
|
82
|
-
requirements: z.array(NormalizedRequirementSchema),
|
|
83
|
-
scenarios: z.array(PlannedWorkflowScenarioSchema),
|
|
84
|
-
unresolvedQuestions: z.array(z.string()).default([]),
|
|
85
|
-
diffSummary: z.object({
|
|
86
|
-
changedFiles: z.array(z.string()).default([]),
|
|
87
|
-
changedRoutes: z.array(z.string()).default([]),
|
|
88
|
-
apiEndpoints: z.array(z.string()).default([]),
|
|
89
|
-
validationHints: z.array(z.string()).default([]),
|
|
90
|
-
}).optional(),
|
|
91
|
-
});
|
|
92
|
-
export type GenerationWorkflowPlan = z.infer<typeof GenerationWorkflowPlanSchema>;
|
|
93
|
-
|
|
94
|
-
export interface SourceAnalysisHints {
|
|
95
|
-
routes?: string[] | undefined;
|
|
96
|
-
validationRules?: string[] | undefined;
|
|
97
|
-
apiEndpoints?: string[] | undefined;
|
|
98
|
-
authRequired?: boolean | undefined;
|
|
99
|
-
persistenceDetected?: boolean | undefined;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export interface GitDiffHintsInput {
|
|
103
|
-
changedFiles?: string[] | undefined;
|
|
104
|
-
changedRoutes?: string[] | undefined;
|
|
105
|
-
changedSymbols?: string[] | undefined;
|
|
106
|
-
apiEndpoints?: string[] | undefined;
|
|
107
|
-
validationHints?: string[] | undefined;
|
|
108
|
-
recentCommits?: Array<{ hash: string; subject: string }> | undefined;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export interface WorkflowPlanInput {
|
|
112
|
-
featureName?: string | undefined;
|
|
113
|
-
prompt?: string | undefined;
|
|
114
|
-
markdown?: string | undefined;
|
|
115
|
-
text?: string | undefined;
|
|
116
|
-
jsonRequirements?: unknown;
|
|
117
|
-
featureText?: string | undefined;
|
|
118
|
-
jiraIssue?: { key?: string | undefined; summary: string; description?: string | undefined; acceptanceCriteria?: string[] | undefined } | undefined;
|
|
119
|
-
requirements?: RequirementInput[] | undefined;
|
|
120
|
-
existingTests?: ExistingTestInput[] | undefined;
|
|
121
|
-
gitDiff?: GitDiffHintsInput | undefined;
|
|
122
|
-
sourceHints?: SourceAnalysisHints | undefined;
|
|
123
|
-
baseUrl?: string | undefined;
|
|
124
|
-
now?: Date | undefined;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export interface GherkinImportResult {
|
|
128
|
-
success: boolean;
|
|
129
|
-
plan?: GenerationWorkflowPlan | undefined;
|
|
130
|
-
edited: boolean;
|
|
131
|
-
addedScenarioIds: string[];
|
|
132
|
-
removedScenarioIds: string[];
|
|
133
|
-
modifiedScenarioIds: string[];
|
|
134
|
-
errors: string[];
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function stableHash(value: unknown): string {
|
|
138
|
-
return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 12);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export function computeWorkflowPlanHash(plan: Omit<GenerationWorkflowPlan, 'planHash' | 'createdAt'>): string {
|
|
142
|
-
return stableHash({
|
|
143
|
-
featureName: plan.featureName,
|
|
144
|
-
background: plan.background,
|
|
145
|
-
requirements: plan.requirements,
|
|
146
|
-
scenarios: plan.scenarios,
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function parseMarkdownRequirements(markdown: string, fallbackTitle: string): {
|
|
151
|
-
featureName: string;
|
|
152
|
-
requirements: NormalizedRequirement[];
|
|
153
|
-
} {
|
|
154
|
-
const lines = markdown.split(/\r?\n/);
|
|
155
|
-
let featureName = fallbackTitle;
|
|
156
|
-
const headings: string[] = [];
|
|
157
|
-
const bullets: string[] = [];
|
|
158
|
-
const prose: string[] = [];
|
|
159
|
-
|
|
160
|
-
for (const rawLine of lines) {
|
|
161
|
-
const line = rawLine.trim();
|
|
162
|
-
if (!line) continue;
|
|
163
|
-
const h1 = line.match(/^#\s+(.+)$/);
|
|
164
|
-
if (h1 && headings.length === 0) {
|
|
165
|
-
featureName = h1[1]!.trim();
|
|
166
|
-
headings.push(featureName);
|
|
167
|
-
continue;
|
|
168
|
-
}
|
|
169
|
-
const hx = line.match(/^#{2,4}\s+(.+)$/);
|
|
170
|
-
if (hx) {
|
|
171
|
-
headings.push(hx[1]!.trim());
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
const bullet = line.match(/^(?:[-*]|\d+\.)\s+(.+)$/);
|
|
175
|
-
if (bullet) {
|
|
176
|
-
bullets.push(bullet[1]!.trim());
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
prose.push(line);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const intent = extractBusinessIntent({ id: 'md-1', title: featureName, sourceText: markdown });
|
|
183
|
-
const isAmbiguous = bullets.length === 0 && prose.join(' ').trim().split(/\s+/).length < 4;
|
|
184
|
-
|
|
185
|
-
const requirements: NormalizedRequirement[] = bullets.length > 0
|
|
186
|
-
? bullets.map((ac, idx) => ({
|
|
187
|
-
id: `REQ-${idx + 1}`,
|
|
188
|
-
title: ac.length > 80 ? `${ac.slice(0, 77)}...` : ac,
|
|
189
|
-
description: ac,
|
|
190
|
-
acceptanceCriteria: [ac],
|
|
191
|
-
provenance: 'explicit-requirement',
|
|
192
|
-
risk: extractBusinessIntent({ id: `REQ-${idx + 1}`, sourceText: ac }).risk,
|
|
193
|
-
ambiguous: false,
|
|
194
|
-
clarificationsNeeded: [],
|
|
195
|
-
sourceRef: 'markdown',
|
|
196
|
-
}))
|
|
197
|
-
: [{
|
|
198
|
-
id: 'REQ-1',
|
|
199
|
-
title: featureName,
|
|
200
|
-
description: prose.join(' ') || featureName,
|
|
201
|
-
acceptanceCriteria: prose.length > 0 ? [prose.join(' ')] : [],
|
|
202
|
-
provenance: 'explicit-requirement',
|
|
203
|
-
risk: intent.risk,
|
|
204
|
-
ambiguous: isAmbiguous,
|
|
205
|
-
clarificationsNeeded: isAmbiguous
|
|
206
|
-
? [`Requirement '${featureName}' has no acceptance criteria or observable expected outcomes.`]
|
|
207
|
-
: [],
|
|
208
|
-
sourceRef: 'markdown',
|
|
209
|
-
}];
|
|
210
|
-
|
|
211
|
-
return { featureName, requirements };
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function parseJsonRequirements(raw: unknown, fallbackTitle: string): NormalizedRequirement[] {
|
|
215
|
-
let data: unknown = raw;
|
|
216
|
-
if (typeof raw === 'string') {
|
|
217
|
-
data = JSON.parse(raw);
|
|
218
|
-
}
|
|
219
|
-
const list = Array.isArray(data)
|
|
220
|
-
? data
|
|
221
|
-
: (data && typeof data === 'object' && Array.isArray((data as any).requirements))
|
|
222
|
-
? (data as any).requirements
|
|
223
|
-
: data && typeof data === 'object'
|
|
224
|
-
? [data]
|
|
225
|
-
: [];
|
|
226
|
-
|
|
227
|
-
return list.map((item: any, idx: number) => {
|
|
228
|
-
const id = String(item?.id ?? `REQ-${idx + 1}`);
|
|
229
|
-
const title = String(item?.title ?? item?.summary ?? fallbackTitle).trim() || fallbackTitle;
|
|
230
|
-
const description = String(item?.description ?? title).trim();
|
|
231
|
-
const acList: string[] = Array.isArray(item?.acceptanceCriteria)
|
|
232
|
-
? item.acceptanceCriteria.map(String).filter(Boolean)
|
|
233
|
-
: description !== title ? [description] : [];
|
|
234
|
-
const intent = extractBusinessIntent({ id, title, sourceText: `${title}\n${description}\n${acList.join('\n')}` });
|
|
235
|
-
return {
|
|
236
|
-
id,
|
|
237
|
-
title,
|
|
238
|
-
description,
|
|
239
|
-
acceptanceCriteria: acList,
|
|
240
|
-
provenance: 'explicit-requirement',
|
|
241
|
-
risk: intent.risk,
|
|
242
|
-
ambiguous: acList.length === 0 && description.split(/\s+/).length < 4,
|
|
243
|
-
clarificationsNeeded: acList.length === 0 && description.split(/\s+/).length < 4
|
|
244
|
-
? [`Requirement '${id}' needs explicit acceptance criteria.`]
|
|
245
|
-
: [],
|
|
246
|
-
sourceRef: 'json',
|
|
247
|
-
};
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function isVagueRequirementText(text: string): boolean {
|
|
252
|
-
const cleaned = text.toLowerCase().replace(/\b(test|improve|fix|feature|app|page|workflow|check|verify|make|better|it|the|for|and)\b/g, ' ').trim();
|
|
253
|
-
const meaningfulWords = cleaned.match(/[a-z0-9]{3,}/g) ?? [];
|
|
254
|
-
return meaningfulWords.length < 2;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function normalizeExistingTestSignature(test: ExistingTestInput): Set<string> {
|
|
258
|
-
const raw = `${test.title ?? ''} ${test.sourceText}`
|
|
259
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
260
|
-
.toLowerCase();
|
|
261
|
-
return new Set(raw.split(/[^a-z0-9]+/).filter((t) => t.length >= 3));
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function matchesExistingCoverage(scenarioTitle: string, expectedOutcomes: string[], existingTests: ExistingTestInput[]): boolean {
|
|
265
|
-
if (existingTests.length === 0) return false;
|
|
266
|
-
const stopWords = new Set(['should', 'user', 'with', 'page', 'after', 'that', 'when', 'the', 'and', 'for']);
|
|
267
|
-
const titleTokens = new Set(
|
|
268
|
-
scenarioTitle
|
|
269
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
270
|
-
.toLowerCase()
|
|
271
|
-
.split(/[^a-z0-9]+/)
|
|
272
|
-
.filter((t) => t.length >= 3 && !stopWords.has(t)),
|
|
273
|
-
);
|
|
274
|
-
const outcomeTokens = new Set(
|
|
275
|
-
`${scenarioTitle} ${expectedOutcomes.join(' ')}`
|
|
276
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
277
|
-
.toLowerCase()
|
|
278
|
-
.split(/[^a-z0-9]+/)
|
|
279
|
-
.filter((t) => t.length >= 3 && !stopWords.has(t)),
|
|
280
|
-
);
|
|
281
|
-
if (titleTokens.size === 0) return false;
|
|
282
|
-
|
|
283
|
-
for (const existing of existingTests) {
|
|
284
|
-
const existingTokens = normalizeExistingTestSignature(existing);
|
|
285
|
-
let titleOverlap = 0;
|
|
286
|
-
for (const token of titleTokens) {
|
|
287
|
-
if (existingTokens.has(token)) titleOverlap += 1;
|
|
288
|
-
}
|
|
289
|
-
let overallOverlap = 0;
|
|
290
|
-
for (const token of outcomeTokens) {
|
|
291
|
-
if (existingTokens.has(token)) overallOverlap += 1;
|
|
292
|
-
}
|
|
293
|
-
if (titleOverlap / titleTokens.size >= 0.75 || overallOverlap / outcomeTokens.size >= 0.6) {
|
|
294
|
-
return true;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
return false;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
function inferCategoriesForRequirement(
|
|
301
|
-
req: NormalizedRequirement,
|
|
302
|
-
gitDiff?: GitDiffHintsInput,
|
|
303
|
-
sourceHints?: SourceAnalysisHints,
|
|
304
|
-
): Array<{ category: WorkflowScenarioCategory; provenance: WorkflowProvenance; priority: PlannedWorkflowScenario['priority']; triggerEvidence: string }> {
|
|
305
|
-
const fullText = `${req.title}\n${req.description}\n${req.acceptanceCriteria.join('\n')}`.toLowerCase();
|
|
306
|
-
const diffText = (gitDiff?.validationHints ?? []).join(' ').toLowerCase();
|
|
307
|
-
const sourceText = (sourceHints?.validationRules ?? []).join(' ').toLowerCase();
|
|
308
|
-
|
|
309
|
-
const entries: Array<{ category: WorkflowScenarioCategory; provenance: WorkflowProvenance; priority: PlannedWorkflowScenario['priority']; triggerEvidence: string }> = [
|
|
310
|
-
{ category: 'happy-path', provenance: req.provenance, priority: req.risk === 'critical' ? 'P0' : 'P1', triggerEvidence: req.id },
|
|
311
|
-
];
|
|
312
|
-
|
|
313
|
-
// Negative / Validation
|
|
314
|
-
if (/\b(invalid|reject|empty|error|prevent|fail|required|disallowed|duplicate)\b/.test(fullText)) {
|
|
315
|
-
entries.push({ category: 'validation', provenance: 'explicit-requirement', priority: 'P1', triggerEvidence: req.id });
|
|
316
|
-
} else if (/\b(required|email|min|max)\b/.test(sourceText)) {
|
|
317
|
-
entries.push({ category: 'validation', provenance: 'source-derived', priority: 'P1', triggerEvidence: 'source-validation' });
|
|
318
|
-
} else if (/\b(required|email|min|max)\b/.test(diffText)) {
|
|
319
|
-
entries.push({ category: 'validation', provenance: 'diff-derived', priority: 'P1', triggerEvidence: 'git-diff-validation' });
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Boundary: ONLY when explicit bounds exist
|
|
323
|
-
if (/\b(min(?:imum)?|max(?:imum)?|length|limit|character|capacity|at least|at most|\d+\s*(?:chars|items))\b/.test(fullText)) {
|
|
324
|
-
entries.push({ category: 'boundary', provenance: 'explicit-requirement', priority: 'P2', triggerEvidence: req.id });
|
|
325
|
-
} else if (/\b(min|max)\(\d+\)/.test(sourceText)) {
|
|
326
|
-
entries.push({ category: 'boundary', provenance: 'source-derived', priority: 'P2', triggerEvidence: 'source-schema-bounds' });
|
|
327
|
-
} else if (/\b(min|max)\(\d+\)/.test(diffText)) {
|
|
328
|
-
entries.push({ category: 'boundary', provenance: 'diff-derived', priority: 'P2', triggerEvidence: 'git-diff-bounds' });
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Persistence
|
|
332
|
-
if (/\b(persist|reload|refresh|saved|localstorage|survive|database)\b/.test(fullText)) {
|
|
333
|
-
entries.push({ category: 'persistence', provenance: 'explicit-requirement', priority: 'P1', triggerEvidence: req.id });
|
|
334
|
-
} else if (sourceHints?.persistenceDetected) {
|
|
335
|
-
entries.push({ category: 'persistence', provenance: 'source-derived', priority: 'P2', triggerEvidence: 'source-persistence' });
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// Authentication / Authorization
|
|
339
|
-
if (/\b(auth|login|logged in|unauthenticated|guest|permission|role|forbidden|unauthorized|admin)\b/.test(fullText)) {
|
|
340
|
-
entries.push({ category: 'authentication', provenance: 'explicit-requirement', priority: 'P0', triggerEvidence: req.id });
|
|
341
|
-
} else if (sourceHints?.authRequired) {
|
|
342
|
-
entries.push({ category: 'authentication', provenance: 'source-derived', priority: 'P0', triggerEvidence: 'source-auth-guard' });
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// State lifecycle transitions (toggle/delete/edit/update/filter)
|
|
346
|
-
if (/\b(toggle|complete|delete|remove|edit|update|filter|clear)\b/.test(fullText)) {
|
|
347
|
-
entries.push({ category: 'state-transition', provenance: 'explicit-requirement', priority: 'P1', triggerEvidence: req.id });
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// API consistency
|
|
351
|
-
if ((sourceHints?.apiEndpoints?.length ?? 0) > 0 || (gitDiff?.apiEndpoints?.length ?? 0) > 0 || /\b(api|endpoint|http|status|response)\b/.test(fullText)) {
|
|
352
|
-
const prov: WorkflowProvenance = /\b(api|endpoint|http|status|response)\b/.test(fullText)
|
|
353
|
-
? 'explicit-requirement'
|
|
354
|
-
: (gitDiff?.apiEndpoints?.length ?? 0) > 0 ? 'diff-derived' : 'source-derived';
|
|
355
|
-
entries.push({ category: 'ui-api-consistency', provenance: prov, priority: 'P1', triggerEvidence: prov });
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
return entries;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function buildStepsForScenario(
|
|
362
|
-
req: NormalizedRequirement,
|
|
363
|
-
category: WorkflowScenarioCategory,
|
|
364
|
-
baseUrl?: string,
|
|
365
|
-
): { steps: WorkflowStep[]; expectedOutcomes: string[]; preconditions: string[] } {
|
|
366
|
-
const preconditions = [baseUrl ? `the application is accessible at ${baseUrl}` : 'the target feature page is accessible'];
|
|
367
|
-
|
|
368
|
-
switch (category) {
|
|
369
|
-
case 'validation':
|
|
370
|
-
case 'negative':
|
|
371
|
-
case 'required-fields':
|
|
372
|
-
return {
|
|
373
|
-
preconditions,
|
|
374
|
-
steps: [
|
|
375
|
-
{ keyword: 'Given', text: preconditions[0]! },
|
|
376
|
-
{ keyword: 'When', text: `the user submits invalid or empty input for "${req.title}"`, actionHint: 'submit-invalid' },
|
|
377
|
-
{ keyword: 'Then', text: 'the application rejects invalid submission and preserves valid state' },
|
|
378
|
-
],
|
|
379
|
-
expectedOutcomes: ['Validation feedback shown or invalid submission blocked without corrupting state'],
|
|
380
|
-
};
|
|
381
|
-
case 'boundary':
|
|
382
|
-
return {
|
|
383
|
-
preconditions,
|
|
384
|
-
steps: [
|
|
385
|
-
{ keyword: 'Given', text: preconditions[0]! },
|
|
386
|
-
{ keyword: 'When', text: `the user tests declared boundary values for "${req.title}"`, actionHint: 'test-boundary' },
|
|
387
|
-
{ keyword: 'Then', text: 'inputs inside bounds succeed and inputs outside bounds are rejected per rule' },
|
|
388
|
-
],
|
|
389
|
-
expectedOutcomes: ['Boundary enforcement matches declared rule'],
|
|
390
|
-
};
|
|
391
|
-
case 'persistence':
|
|
392
|
-
return {
|
|
393
|
-
preconditions,
|
|
394
|
-
steps: [
|
|
395
|
-
{ keyword: 'Given', text: `the user has completed "${req.title}"` },
|
|
396
|
-
{ keyword: 'When', text: 'the browser page is reloaded', actionHint: 'reload' },
|
|
397
|
-
{ keyword: 'Then', text: 'the updated state remains visible after reload' },
|
|
398
|
-
],
|
|
399
|
-
expectedOutcomes: ['State survives client reload and matches backend/storage state'],
|
|
400
|
-
};
|
|
401
|
-
case 'authentication':
|
|
402
|
-
case 'authorization':
|
|
403
|
-
return {
|
|
404
|
-
preconditions: ['the user starts without required authentication or role'],
|
|
405
|
-
steps: [
|
|
406
|
-
{ keyword: 'Given', text: 'the user is not authenticated with the required permission' },
|
|
407
|
-
{ keyword: 'When', text: `the user attempts to access "${req.title}"`, actionHint: 'check-auth' },
|
|
408
|
-
{ keyword: 'Then', text: 'access is denied or redirected to login cleanly' },
|
|
409
|
-
],
|
|
410
|
-
expectedOutcomes: ['Unauthorized access blocked'],
|
|
411
|
-
};
|
|
412
|
-
case 'state-transition':
|
|
413
|
-
return {
|
|
414
|
-
preconditions,
|
|
415
|
-
steps: [
|
|
416
|
-
{ keyword: 'Given', text: `initial state for "${req.title}" is established` },
|
|
417
|
-
{ keyword: 'When', text: `the user performs the state transition in "${req.title}"`, actionHint: 'transition' },
|
|
418
|
-
{ keyword: 'Then', text: 'the UI indicators and counters reflect the new state cleanly' },
|
|
419
|
-
],
|
|
420
|
-
expectedOutcomes: ['UI state transition reflected accurately'],
|
|
421
|
-
};
|
|
422
|
-
case 'ui-api-consistency':
|
|
423
|
-
return {
|
|
424
|
-
preconditions,
|
|
425
|
-
steps: [
|
|
426
|
-
{ keyword: 'Given', text: preconditions[0]! },
|
|
427
|
-
{ keyword: 'When', text: `the user triggers the operation in "${req.title}"`, actionHint: 'trigger-api' },
|
|
428
|
-
{ keyword: 'Then', text: 'the observed network API response agrees with the updated UI state' },
|
|
429
|
-
],
|
|
430
|
-
expectedOutcomes: ['Network response status/payload agrees with UI state'],
|
|
431
|
-
};
|
|
432
|
-
default:
|
|
433
|
-
return {
|
|
434
|
-
preconditions,
|
|
435
|
-
steps: [
|
|
436
|
-
{ keyword: 'Given', text: preconditions[0]! },
|
|
437
|
-
{ keyword: 'When', text: `the user executes the primary flow for "${req.title}"`, actionHint: 'happy-path' },
|
|
438
|
-
{ keyword: 'Then', text: req.acceptanceCriteria[0] ? req.acceptanceCriteria[0] : `expected outcome for "${req.title}" is observed in the UI` },
|
|
439
|
-
],
|
|
440
|
-
expectedOutcomes: req.acceptanceCriteria.length > 0 ? req.acceptanceCriteria : [`Observable outcome for ${req.title}`],
|
|
441
|
-
};
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
export function buildWorkflowPlan(input: WorkflowPlanInput): GenerationWorkflowPlan {
|
|
446
|
-
const now = input.now ?? new Date();
|
|
447
|
-
const inputsUsed: GenerationWorkflowPlan['inputs'] = [];
|
|
448
|
-
let featureName = input.featureName?.trim() || input.jiraIssue?.summary?.trim() || 'Feature Workflow';
|
|
449
|
-
const normalizedRequirements: NormalizedRequirement[] = [];
|
|
450
|
-
const unresolvedQuestions: string[] = [];
|
|
451
|
-
|
|
452
|
-
if (input.featureText) {
|
|
453
|
-
inputsUsed.push({ kind: 'feature', reference: 'inline-feature' });
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
if (input.markdown) {
|
|
457
|
-
inputsUsed.push({ kind: 'markdown', reference: 'markdown-requirements' });
|
|
458
|
-
const parsedMd = parseMarkdownRequirements(input.markdown, featureName);
|
|
459
|
-
if (!input.featureName) featureName = parsedMd.featureName;
|
|
460
|
-
normalizedRequirements.push(...parsedMd.requirements);
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
if (input.jsonRequirements !== undefined) {
|
|
464
|
-
inputsUsed.push({ kind: 'json', reference: 'json-requirements' });
|
|
465
|
-
normalizedRequirements.push(...parseJsonRequirements(input.jsonRequirements, featureName));
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
if (input.jiraIssue) {
|
|
469
|
-
inputsUsed.push({ kind: 'jira', reference: input.jiraIssue.key ?? 'jira-issue' });
|
|
470
|
-
const ac = input.jiraIssue.acceptanceCriteria ?? [];
|
|
471
|
-
const desc = input.jiraIssue.description ?? input.jiraIssue.summary;
|
|
472
|
-
const intent = extractBusinessIntent({ id: input.jiraIssue.key ?? 'JIRA-1', title: input.jiraIssue.summary, sourceText: `${desc}\n${ac.join('\n')}` });
|
|
473
|
-
normalizedRequirements.push({
|
|
474
|
-
id: input.jiraIssue.key ?? 'JIRA-1',
|
|
475
|
-
title: input.jiraIssue.summary,
|
|
476
|
-
description: desc,
|
|
477
|
-
acceptanceCriteria: ac,
|
|
478
|
-
provenance: 'explicit-requirement',
|
|
479
|
-
risk: intent.risk,
|
|
480
|
-
ambiguous: ac.length === 0 && isVagueRequirementText(desc),
|
|
481
|
-
clarificationsNeeded: ac.length === 0 && isVagueRequirementText(desc)
|
|
482
|
-
? [`Jira issue '${input.jiraIssue.summary}' lacks clear acceptance criteria.`]
|
|
483
|
-
: [],
|
|
484
|
-
sourceRef: input.jiraIssue.key,
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
if (input.requirements && input.requirements.length > 0) {
|
|
489
|
-
inputsUsed.push({ kind: 'json', reference: 'structured-requirements' });
|
|
490
|
-
for (const req of input.requirements) {
|
|
491
|
-
const desc = req.description ?? req.title;
|
|
492
|
-
const intent = extractBusinessIntent({ id: req.id, title: req.title, sourceText: desc });
|
|
493
|
-
const vague = !req.description && isVagueRequirementText(req.title);
|
|
494
|
-
normalizedRequirements.push({
|
|
495
|
-
id: req.id,
|
|
496
|
-
title: req.title,
|
|
497
|
-
description: desc,
|
|
498
|
-
acceptanceCriteria: req.description ? [req.description] : [],
|
|
499
|
-
provenance: 'explicit-requirement',
|
|
500
|
-
risk: intent.risk,
|
|
501
|
-
ambiguous: vague,
|
|
502
|
-
clarificationsNeeded: vague ? [`Requirement '${req.title}' is ambiguous; clarify expected behavior.`] : [],
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
const plainText = input.prompt ?? input.text;
|
|
508
|
-
if (plainText && normalizedRequirements.length === 0) {
|
|
509
|
-
inputsUsed.push({ kind: input.prompt ? 'prompt' : 'text', reference: 'inline-requirement' });
|
|
510
|
-
if (!input.featureName) {
|
|
511
|
-
featureName = plainText.split(/\r?\n/)[0]!.trim().slice(0, 64) || featureName;
|
|
512
|
-
}
|
|
513
|
-
const intent = extractBusinessIntent({ id: 'REQ-1', title: featureName, sourceText: plainText });
|
|
514
|
-
const vague = isVagueRequirementText(plainText);
|
|
515
|
-
normalizedRequirements.push({
|
|
516
|
-
id: 'REQ-1',
|
|
517
|
-
title: featureName,
|
|
518
|
-
description: plainText.trim(),
|
|
519
|
-
acceptanceCriteria: vague ? [] : [plainText.trim()],
|
|
520
|
-
provenance: 'explicit-requirement',
|
|
521
|
-
risk: intent.risk,
|
|
522
|
-
ambiguous: vague,
|
|
523
|
-
clarificationsNeeded: vague ? [`Prompt '${plainText.trim()}' lacks specific expected behavior.`] : [],
|
|
524
|
-
});
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
if (input.gitDiff && ((input.gitDiff.changedFiles?.length ?? 0) > 0 || (input.gitDiff.changedRoutes?.length ?? 0) > 0)) {
|
|
528
|
-
inputsUsed.push({ kind: 'git-diff', reference: `diff:${input.gitDiff.changedFiles?.length ?? 0}-files` });
|
|
529
|
-
}
|
|
530
|
-
if (input.sourceHints && ((input.sourceHints.routes?.length ?? 0) > 0 || (input.sourceHints.validationRules?.length ?? 0) > 0)) {
|
|
531
|
-
inputsUsed.push({ kind: 'source', reference: `source:${input.sourceHints.routes?.length ?? 0}-routes` });
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// Fallback if only gitDiff or featureText arrived
|
|
535
|
-
if (normalizedRequirements.length === 0 && input.gitDiff?.changedFiles?.length) {
|
|
536
|
-
normalizedRequirements.push({
|
|
537
|
-
id: 'DIFF-1',
|
|
538
|
-
title: `Changes in ${input.gitDiff.changedFiles.slice(0, 3).join(', ')}`,
|
|
539
|
-
description: `Verify flows impacted by changed files: ${input.gitDiff.changedFiles.join(', ')}`,
|
|
540
|
-
acceptanceCriteria: [],
|
|
541
|
-
provenance: 'diff-derived',
|
|
542
|
-
risk: 'medium',
|
|
543
|
-
ambiguous: true,
|
|
544
|
-
clarificationsNeeded: ['Derived only from git diff without business requirements; clarify expected UI behavior.'],
|
|
545
|
-
sourceRef: 'git-diff',
|
|
546
|
-
});
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
for (const r of normalizedRequirements) {
|
|
550
|
-
unresolvedQuestions.push(...r.clarificationsNeeded);
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
const existingTests = input.existingTests ?? [];
|
|
554
|
-
const scenarios: PlannedWorkflowScenario[] = [];
|
|
555
|
-
const seenIds = new Set<string>();
|
|
556
|
-
|
|
557
|
-
for (const req of normalizedRequirements) {
|
|
558
|
-
if (req.ambiguous && req.acceptanceCriteria.length === 0 && !input.sourceHints?.routes?.length && !input.gitDiff?.changedRoutes?.length) {
|
|
559
|
-
const id = `sc-${toSlug(req.id)}-clarify-${stableHash(req.title).slice(0, 6)}`;
|
|
560
|
-
seenIds.add(id);
|
|
561
|
-
scenarios.push({
|
|
562
|
-
id,
|
|
563
|
-
requirementRefs: [req.id],
|
|
564
|
-
title: `Clarify workflow for ${req.title}`,
|
|
565
|
-
category: 'happy-path',
|
|
566
|
-
priority: 'P2',
|
|
567
|
-
tags: [`@id:${id}`, `@req:${req.id}`, '@needs-clarification'],
|
|
568
|
-
provenance: req.provenance,
|
|
569
|
-
applicability: 'needs_clarification',
|
|
570
|
-
preconditions: [],
|
|
571
|
-
steps: [
|
|
572
|
-
{ keyword: 'Given', text: `requirement "${req.title}" is supplied without acceptance criteria` },
|
|
573
|
-
{ keyword: 'When', text: 'the application context is inspected' },
|
|
574
|
-
{ keyword: 'Then', text: 'expected outcomes require explicit user or application evidence clarification' },
|
|
575
|
-
],
|
|
576
|
-
expectedOutcomes: [],
|
|
577
|
-
evidenceRequirements: ['explicit-ac-or-live-exploration'],
|
|
578
|
-
confidence: 0.2,
|
|
579
|
-
assumptions: req.clarificationsNeeded,
|
|
580
|
-
});
|
|
581
|
-
continue;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
const categories = inferCategoriesForRequirement(req, input.gitDiff, input.sourceHints);
|
|
585
|
-
for (const cat of categories) {
|
|
586
|
-
let id = `sc-${toSlug(req.id)}-${cat.category}`;
|
|
587
|
-
if (seenIds.has(id)) {
|
|
588
|
-
id = `${id}-${stableHash(`${req.id}:${cat.category}:${scenarios.length}`).slice(0, 4)}`;
|
|
589
|
-
}
|
|
590
|
-
seenIds.add(id);
|
|
591
|
-
|
|
592
|
-
const built = buildStepsForScenario(req, cat.category, input.baseUrl);
|
|
593
|
-
const scenarioTitle = `${req.title} (${cat.category})`;
|
|
594
|
-
const alreadyCovered = matchesExistingCoverage(req.title, built.expectedOutcomes, existingTests);
|
|
595
|
-
const applicability: PlannedWorkflowScenario['applicability'] = alreadyCovered ? 'covered' : 'applicable';
|
|
596
|
-
|
|
597
|
-
const tags = [
|
|
598
|
-
`@id:${id}`,
|
|
599
|
-
`@req:${req.id}`,
|
|
600
|
-
`@${cat.category}`,
|
|
601
|
-
`@${cat.provenance}`,
|
|
602
|
-
...(alreadyCovered ? ['@covered'] : []),
|
|
603
|
-
];
|
|
604
|
-
|
|
605
|
-
scenarios.push({
|
|
606
|
-
id,
|
|
607
|
-
requirementRefs: [req.id],
|
|
608
|
-
title: scenarioTitle,
|
|
609
|
-
category: cat.category,
|
|
610
|
-
priority: cat.priority,
|
|
611
|
-
tags,
|
|
612
|
-
provenance: cat.provenance,
|
|
613
|
-
applicability,
|
|
614
|
-
preconditions: built.preconditions,
|
|
615
|
-
steps: built.steps,
|
|
616
|
-
expectedOutcomes: built.expectedOutcomes,
|
|
617
|
-
evidenceRequirements: [`live-ui:${cat.category}`, ...(cat.category === 'ui-api-consistency' ? ['network-api'] : [])],
|
|
618
|
-
confidence: cat.provenance === 'explicit-requirement' ? 0.9 : 0.75,
|
|
619
|
-
assumptions: cat.provenance === 'assumption' ? [`Derived as ${cat.category} assumption from ${cat.triggerEvidence}`] : [],
|
|
620
|
-
});
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
const background = [
|
|
625
|
-
input.baseUrl ? `the application is accessible at ${input.baseUrl}` : 'the target application is running',
|
|
626
|
-
];
|
|
627
|
-
|
|
628
|
-
const basePlan = {
|
|
629
|
-
planVersion: '1.0' as const,
|
|
630
|
-
featureName,
|
|
631
|
-
slug: toSlug(featureName),
|
|
632
|
-
background,
|
|
633
|
-
inputs: inputsUsed,
|
|
634
|
-
requirements: normalizedRequirements,
|
|
635
|
-
scenarios,
|
|
636
|
-
unresolvedQuestions: [...new Set(unresolvedQuestions)],
|
|
637
|
-
...(input.gitDiff ? {
|
|
638
|
-
diffSummary: {
|
|
639
|
-
changedFiles: input.gitDiff.changedFiles ?? [],
|
|
640
|
-
changedRoutes: input.gitDiff.changedRoutes ?? [],
|
|
641
|
-
apiEndpoints: input.gitDiff.apiEndpoints ?? [],
|
|
642
|
-
validationHints: input.gitDiff.validationHints ?? [],
|
|
643
|
-
},
|
|
644
|
-
} : {}),
|
|
645
|
-
};
|
|
646
|
-
|
|
647
|
-
const plan: GenerationWorkflowPlan = {
|
|
648
|
-
...basePlan,
|
|
649
|
-
createdAt: now.toISOString(),
|
|
650
|
-
planHash: computeWorkflowPlanHash(basePlan),
|
|
651
|
-
};
|
|
652
|
-
|
|
653
|
-
// If initial input already was a .feature file, merge its scenarios cleanly
|
|
654
|
-
if (input.featureText) {
|
|
655
|
-
const imported = importGherkinToWorkflowPlan(input.featureText, {
|
|
656
|
-
...plan,
|
|
657
|
-
scenarios: [],
|
|
658
|
-
});
|
|
659
|
-
if (imported.success && imported.plan) {
|
|
660
|
-
return imported.plan;
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
return plan;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
export function exportWorkflowToGherkin(plan: GenerationWorkflowPlan): string {
|
|
668
|
-
const lines: string[] = [
|
|
669
|
-
`# pie-plan-hash: ${plan.planHash}`,
|
|
670
|
-
`Feature: ${plan.featureName}`,
|
|
671
|
-
'',
|
|
672
|
-
];
|
|
673
|
-
|
|
674
|
-
if (plan.background.length > 0) {
|
|
675
|
-
lines.push(' Background:');
|
|
676
|
-
for (const bg of plan.background) {
|
|
677
|
-
lines.push(` Given ${bg}`);
|
|
678
|
-
}
|
|
679
|
-
lines.push('');
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
for (const scenario of plan.scenarios) {
|
|
683
|
-
if (scenario.tags.length > 0) {
|
|
684
|
-
lines.push(` ${scenario.tags.join(' ')}`);
|
|
685
|
-
}
|
|
686
|
-
lines.push(` Scenario: ${scenario.title}`);
|
|
687
|
-
for (const step of scenario.steps) {
|
|
688
|
-
lines.push(` ${step.keyword} ${step.text}`);
|
|
689
|
-
}
|
|
690
|
-
lines.push('');
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
return `${lines.join('\n').trimEnd()}\n`;
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
function categoryFromTags(tags: string[]): WorkflowScenarioCategory {
|
|
697
|
-
const map: Record<string, WorkflowScenarioCategory> = {
|
|
698
|
-
'@happy-path': 'happy-path',
|
|
699
|
-
'@negative': 'negative',
|
|
700
|
-
'@validation': 'validation',
|
|
701
|
-
'@required-fields': 'required-fields',
|
|
702
|
-
'@boundary': 'boundary',
|
|
703
|
-
'@persistence': 'persistence',
|
|
704
|
-
'@authentication': 'authentication',
|
|
705
|
-
'@auth': 'authentication',
|
|
706
|
-
'@authorization': 'authorization',
|
|
707
|
-
'@state-transition': 'state-transition',
|
|
708
|
-
'@error-handling': 'error-handling',
|
|
709
|
-
'@recovery': 'recovery',
|
|
710
|
-
'@ui-api-consistency': 'ui-api-consistency',
|
|
711
|
-
};
|
|
712
|
-
for (const tag of tags) {
|
|
713
|
-
const mapped = map[tag.toLowerCase()];
|
|
714
|
-
if (mapped) return mapped;
|
|
715
|
-
}
|
|
716
|
-
return 'happy-path';
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
export function isStaleWorkflowPlan(featureText: string, canonicalPlan: GenerationWorkflowPlan): boolean {
|
|
720
|
-
const hashMatch = featureText.match(/^#\s*pie-plan-hash:\s*([a-f0-9]+)\s*$/m);
|
|
721
|
-
if (hashMatch && hashMatch[1] !== canonicalPlan.planHash) {
|
|
722
|
-
return true;
|
|
723
|
-
}
|
|
724
|
-
const imported = importGherkinToWorkflowPlan(featureText, canonicalPlan);
|
|
725
|
-
return imported.edited || !imported.success;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
export function importGherkinToWorkflowPlan(
|
|
729
|
-
featureText: string,
|
|
730
|
-
canonicalPlan: GenerationWorkflowPlan,
|
|
731
|
-
): GherkinImportResult {
|
|
732
|
-
const errors: string[] = [];
|
|
733
|
-
const rawLines = featureText.split(/\r?\n/);
|
|
734
|
-
|
|
735
|
-
let parsedFeatureName = canonicalPlan.featureName;
|
|
736
|
-
let seenFeatureHeader = false;
|
|
737
|
-
let inBackground = false;
|
|
738
|
-
const parsedBackground: string[] = [];
|
|
739
|
-
let pendingTags: string[] = [];
|
|
740
|
-
|
|
741
|
-
interface ParsedRawScenario {
|
|
742
|
-
title: string;
|
|
743
|
-
tags: string[];
|
|
744
|
-
steps: WorkflowStep[];
|
|
745
|
-
}
|
|
746
|
-
const parsedScenarios: ParsedRawScenario[] = [];
|
|
747
|
-
let currentScenario: ParsedRawScenario | null = null;
|
|
748
|
-
|
|
749
|
-
for (let i = 0; i < rawLines.length; i += 1) {
|
|
750
|
-
const trimmed = rawLines[i]!.trim();
|
|
751
|
-
if (!trimmed) continue;
|
|
752
|
-
if (trimmed.startsWith('#')) continue;
|
|
753
|
-
|
|
754
|
-
if (/^Scenario Outline\s*:/i.test(trimmed) || /^Examples\s*:/i.test(trimmed)) {
|
|
755
|
-
errors.push(`UNSUPPORTED_GHERKIN_SYNTAX at line ${i + 1}: Scenario Outline / Examples tables are not supported for round-trip import.`);
|
|
756
|
-
continue;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
if (trimmed.startsWith('|')) {
|
|
760
|
-
errors.push(`UNSUPPORTED_GHERKIN_SYNTAX at line ${i + 1}: Gherkin DataTables are not supported.`);
|
|
761
|
-
continue;
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
const featureMatch = trimmed.match(/^Feature\s*:\s*(.+)$/i);
|
|
765
|
-
if (featureMatch) {
|
|
766
|
-
seenFeatureHeader = true;
|
|
767
|
-
parsedFeatureName = featureMatch[1]!.trim();
|
|
768
|
-
inBackground = false;
|
|
769
|
-
currentScenario = null;
|
|
770
|
-
continue;
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
if (/^Background\s*:/i.test(trimmed)) {
|
|
774
|
-
inBackground = true;
|
|
775
|
-
currentScenario = null;
|
|
776
|
-
pendingTags = [];
|
|
777
|
-
continue;
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
if (trimmed.startsWith('@')) {
|
|
781
|
-
const tags = trimmed.split(/\s+/).filter((t) => t.startsWith('@'));
|
|
782
|
-
pendingTags.push(...tags);
|
|
783
|
-
continue;
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
const scenarioMatch = trimmed.match(/^Scenario\s*:\s*(.+)$/i);
|
|
787
|
-
if (scenarioMatch) {
|
|
788
|
-
inBackground = false;
|
|
789
|
-
if (currentScenario) parsedScenarios.push(currentScenario);
|
|
790
|
-
currentScenario = {
|
|
791
|
-
title: scenarioMatch[1]!.trim(),
|
|
792
|
-
tags: pendingTags,
|
|
793
|
-
steps: [],
|
|
794
|
-
};
|
|
795
|
-
pendingTags = [];
|
|
796
|
-
continue;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
const stepMatch = trimmed.match(/^(Given|When|Then|And|But)\s+(.+)$/i);
|
|
800
|
-
if (stepMatch) {
|
|
801
|
-
const kwRaw = stepMatch[1]!;
|
|
802
|
-
const kw = (kwRaw[0]!.toUpperCase() + kwRaw.slice(1).toLowerCase()) as WorkflowStep['keyword'];
|
|
803
|
-
const text = stepMatch[2]!.trim();
|
|
804
|
-
if (inBackground) {
|
|
805
|
-
parsedBackground.push(text);
|
|
806
|
-
} else if (currentScenario) {
|
|
807
|
-
currentScenario.steps.push({ keyword: kw, text });
|
|
808
|
-
} else {
|
|
809
|
-
errors.push(`SYNTAX_ERROR at line ${i + 1}: Step found outside Scenario or Background: '${trimmed}'`);
|
|
810
|
-
}
|
|
811
|
-
continue;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
errors.push(`UNSUPPORTED_GHERKIN_SYNTAX at line ${i + 1}: Unrecognized Gherkin line '${trimmed}'`);
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
if (currentScenario) parsedScenarios.push(currentScenario);
|
|
818
|
-
|
|
819
|
-
if (!seenFeatureHeader) {
|
|
820
|
-
errors.push('INVALID_GHERKIN_FEATURE: Missing Feature: header.');
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
if (errors.length > 0) {
|
|
824
|
-
return {
|
|
825
|
-
success: false,
|
|
826
|
-
edited: false,
|
|
827
|
-
addedScenarioIds: [],
|
|
828
|
-
removedScenarioIds: [],
|
|
829
|
-
modifiedScenarioIds: [],
|
|
830
|
-
errors,
|
|
831
|
-
};
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
const existingById = new Map(canonicalPlan.scenarios.map((s) => [s.id, s]));
|
|
835
|
-
const seenScenarioIds = new Set<string>();
|
|
836
|
-
const rebuiltScenarios: PlannedWorkflowScenario[] = [];
|
|
837
|
-
const addedScenarioIds: string[] = [];
|
|
838
|
-
const modifiedScenarioIds: string[] = [];
|
|
839
|
-
|
|
840
|
-
for (const rawSc of parsedScenarios) {
|
|
841
|
-
if (rawSc.steps.length === 0) {
|
|
842
|
-
errors.push(`INVALID_GHERKIN_SCENARIO: Scenario '${rawSc.title}' has zero steps.`);
|
|
843
|
-
continue;
|
|
844
|
-
}
|
|
845
|
-
const hasWhenOrThen = rawSc.steps.some((s) => s.keyword === 'When' || s.keyword === 'Then');
|
|
846
|
-
if (!hasWhenOrThen) {
|
|
847
|
-
errors.push(`INVALID_GHERKIN_SCENARIO: Scenario '${rawSc.title}' must contain at least one When or Then step.`);
|
|
848
|
-
continue;
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
const idTag = rawSc.tags.find((t) => t.startsWith('@id:'));
|
|
852
|
-
let scenarioId = idTag ? idTag.slice('@id:'.length).trim() : `sc-edited-${toSlug(rawSc.title)}-${stableHash(rawSc.title).slice(0, 6)}`;
|
|
853
|
-
if (!scenarioId) {
|
|
854
|
-
errors.push(`INVALID_SCENARIO_ID: Empty @id: tag on scenario '${rawSc.title}'.`);
|
|
855
|
-
continue;
|
|
856
|
-
}
|
|
857
|
-
if (seenScenarioIds.has(scenarioId)) {
|
|
858
|
-
errors.push(`DUPLICATE_SCENARIO_ID: Scenario ID '${scenarioId}' appears multiple times.`);
|
|
859
|
-
continue;
|
|
860
|
-
}
|
|
861
|
-
seenScenarioIds.add(scenarioId);
|
|
862
|
-
|
|
863
|
-
const reqTags = rawSc.tags.filter((t) => t.startsWith('@req:')).map((t) => t.slice('@req:'.length).trim()).filter(Boolean);
|
|
864
|
-
const prior = existingById.get(scenarioId);
|
|
865
|
-
const category = categoryFromTags(rawSc.tags);
|
|
866
|
-
const expectedOutcomes = rawSc.steps.filter((s) => s.keyword === 'Then').map((s) => s.text);
|
|
867
|
-
const preconditions = rawSc.steps.filter((s) => s.keyword === 'Given').map((s) => s.text);
|
|
868
|
-
|
|
869
|
-
const tags = rawSc.tags.includes(`@id:${scenarioId}`) ? rawSc.tags : [`@id:${scenarioId}`, ...rawSc.tags];
|
|
870
|
-
const requirementRefs = reqTags.length > 0
|
|
871
|
-
? reqTags
|
|
872
|
-
: prior?.requirementRefs ?? [canonicalPlan.requirements[0]?.id ?? 'REQ-1'];
|
|
873
|
-
|
|
874
|
-
const mergedSteps = rawSc.steps.map((step, idx) => {
|
|
875
|
-
const priorStep = prior?.steps[idx];
|
|
876
|
-
if (priorStep && priorStep.keyword === step.keyword && priorStep.text === step.text && priorStep.actionHint) {
|
|
877
|
-
return { ...step, actionHint: priorStep.actionHint, ...(priorStep.targetHint ? { targetHint: priorStep.targetHint } : {}), ...(priorStep.valueHint ? { valueHint: priorStep.valueHint } : {}) };
|
|
878
|
-
}
|
|
879
|
-
return step;
|
|
880
|
-
});
|
|
881
|
-
|
|
882
|
-
const stepsChanged = !prior || JSON.stringify(prior.steps.map((s) => `${s.keyword}:${s.text}`)) !== JSON.stringify(mergedSteps.map((s) => `${s.keyword}:${s.text}`));
|
|
883
|
-
|
|
884
|
-
const scenarioObj: PlannedWorkflowScenario = {
|
|
885
|
-
id: scenarioId,
|
|
886
|
-
requirementRefs,
|
|
887
|
-
title: rawSc.title,
|
|
888
|
-
category: prior && !rawSc.tags.some((t) => t === `@${category}`) ? prior.category : category,
|
|
889
|
-
priority: prior?.priority ?? 'P1',
|
|
890
|
-
tags,
|
|
891
|
-
provenance: prior?.provenance ?? 'explicit-requirement',
|
|
892
|
-
applicability: rawSc.tags.includes('@covered')
|
|
893
|
-
? 'covered'
|
|
894
|
-
: rawSc.tags.includes('@needs-clarification')
|
|
895
|
-
? 'needs_clarification'
|
|
896
|
-
: 'applicable',
|
|
897
|
-
preconditions,
|
|
898
|
-
steps: mergedSteps,
|
|
899
|
-
expectedOutcomes: stepsChanged ? (expectedOutcomes.length > 0 ? expectedOutcomes : prior?.expectedOutcomes ?? []) : (prior?.expectedOutcomes ?? expectedOutcomes),
|
|
900
|
-
evidenceRequirements: prior?.evidenceRequirements ?? [`live-ui:${category}`],
|
|
901
|
-
confidence: prior?.confidence ?? 0.9,
|
|
902
|
-
assumptions: prior?.assumptions ?? [],
|
|
903
|
-
};
|
|
904
|
-
|
|
905
|
-
if (!prior) {
|
|
906
|
-
addedScenarioIds.push(scenarioId);
|
|
907
|
-
} else if (
|
|
908
|
-
prior.title !== scenarioObj.title ||
|
|
909
|
-
stepsChanged ||
|
|
910
|
-
JSON.stringify(prior.tags) !== JSON.stringify(scenarioObj.tags)
|
|
911
|
-
) {
|
|
912
|
-
modifiedScenarioIds.push(scenarioId);
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
rebuiltScenarios.push(scenarioObj);
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
if (errors.length > 0) {
|
|
919
|
-
return {
|
|
920
|
-
success: false,
|
|
921
|
-
edited: false,
|
|
922
|
-
addedScenarioIds: [],
|
|
923
|
-
removedScenarioIds: [],
|
|
924
|
-
modifiedScenarioIds: [],
|
|
925
|
-
errors,
|
|
926
|
-
};
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
const removedScenarioIds = canonicalPlan.scenarios
|
|
930
|
-
.filter((s) => !seenScenarioIds.has(s.id))
|
|
931
|
-
.map((s) => s.id);
|
|
932
|
-
|
|
933
|
-
const background = parsedBackground.length > 0 ? parsedBackground : canonicalPlan.background;
|
|
934
|
-
const baseUpdated = {
|
|
935
|
-
planVersion: canonicalPlan.planVersion,
|
|
936
|
-
featureName: parsedFeatureName,
|
|
937
|
-
slug: toSlug(parsedFeatureName),
|
|
938
|
-
background,
|
|
939
|
-
inputs: canonicalPlan.inputs,
|
|
940
|
-
requirements: canonicalPlan.requirements,
|
|
941
|
-
scenarios: rebuiltScenarios,
|
|
942
|
-
unresolvedQuestions: canonicalPlan.unresolvedQuestions,
|
|
943
|
-
...(canonicalPlan.diffSummary ? { diffSummary: canonicalPlan.diffSummary } : {}),
|
|
944
|
-
};
|
|
945
|
-
|
|
946
|
-
const edited =
|
|
947
|
-
parsedFeatureName !== canonicalPlan.featureName ||
|
|
948
|
-
JSON.stringify(background) !== JSON.stringify(canonicalPlan.background) ||
|
|
949
|
-
addedScenarioIds.length > 0 ||
|
|
950
|
-
removedScenarioIds.length > 0 ||
|
|
951
|
-
modifiedScenarioIds.length > 0;
|
|
952
|
-
|
|
953
|
-
return {
|
|
954
|
-
success: true,
|
|
955
|
-
plan: {
|
|
956
|
-
...baseUpdated,
|
|
957
|
-
createdAt: canonicalPlan.createdAt,
|
|
958
|
-
planHash: computeWorkflowPlanHash(baseUpdated),
|
|
959
|
-
},
|
|
960
|
-
edited,
|
|
961
|
-
addedScenarioIds,
|
|
962
|
-
removedScenarioIds,
|
|
963
|
-
modifiedScenarioIds,
|
|
964
|
-
errors: [],
|
|
965
|
-
};
|
|
966
|
-
}
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { toSlug } from '../generator/architecture-generator.js';
|
|
4
|
+
import { extractBusinessIntent } from './business-intent.js';
|
|
5
|
+
import { ExistingTestInput, RequirementInput } from './test-intelligence.js';
|
|
6
|
+
|
|
7
|
+
export const WorkflowProvenanceSchema = z.enum([
|
|
8
|
+
'explicit-requirement',
|
|
9
|
+
'source-derived',
|
|
10
|
+
'diff-derived',
|
|
11
|
+
'assumption',
|
|
12
|
+
]);
|
|
13
|
+
export type WorkflowProvenance = z.infer<typeof WorkflowProvenanceSchema>;
|
|
14
|
+
|
|
15
|
+
export const WorkflowScenarioCategorySchema = z.enum([
|
|
16
|
+
'happy-path',
|
|
17
|
+
'negative',
|
|
18
|
+
'validation',
|
|
19
|
+
'required-fields',
|
|
20
|
+
'boundary',
|
|
21
|
+
'persistence',
|
|
22
|
+
'authentication',
|
|
23
|
+
'authorization',
|
|
24
|
+
'state-transition',
|
|
25
|
+
'error-handling',
|
|
26
|
+
'recovery',
|
|
27
|
+
'ui-api-consistency',
|
|
28
|
+
]);
|
|
29
|
+
export type WorkflowScenarioCategory = z.infer<typeof WorkflowScenarioCategorySchema>;
|
|
30
|
+
|
|
31
|
+
export const WorkflowStepSchema = z.object({
|
|
32
|
+
keyword: z.enum(['Given', 'When', 'And', 'Then', 'But']),
|
|
33
|
+
text: z.string().min(1),
|
|
34
|
+
actionHint: z.string().optional(),
|
|
35
|
+
targetHint: z.string().optional(),
|
|
36
|
+
valueHint: z.string().optional(),
|
|
37
|
+
});
|
|
38
|
+
export type WorkflowStep = z.infer<typeof WorkflowStepSchema>;
|
|
39
|
+
|
|
40
|
+
export const NormalizedRequirementSchema = z.object({
|
|
41
|
+
id: z.string().min(1),
|
|
42
|
+
title: z.string().min(1),
|
|
43
|
+
description: z.string(),
|
|
44
|
+
acceptanceCriteria: z.array(z.string()).default([]),
|
|
45
|
+
provenance: WorkflowProvenanceSchema,
|
|
46
|
+
risk: z.enum(['critical', 'high', 'medium', 'low', 'unknown']),
|
|
47
|
+
ambiguous: z.boolean().default(false),
|
|
48
|
+
clarificationsNeeded: z.array(z.string()).default([]),
|
|
49
|
+
sourceRef: z.string().optional(),
|
|
50
|
+
});
|
|
51
|
+
export type NormalizedRequirement = z.infer<typeof NormalizedRequirementSchema>;
|
|
52
|
+
|
|
53
|
+
export const PlannedWorkflowScenarioSchema = z.object({
|
|
54
|
+
id: z.string().min(1),
|
|
55
|
+
requirementRefs: z.array(z.string()).min(1),
|
|
56
|
+
title: z.string().min(1),
|
|
57
|
+
category: WorkflowScenarioCategorySchema,
|
|
58
|
+
priority: z.enum(['P0', 'P1', 'P2', 'P3']),
|
|
59
|
+
tags: z.array(z.string()).default([]),
|
|
60
|
+
provenance: WorkflowProvenanceSchema,
|
|
61
|
+
applicability: z.enum(['applicable', 'covered', 'not_applicable', 'needs_clarification']),
|
|
62
|
+
preconditions: z.array(z.string()).default([]),
|
|
63
|
+
steps: z.array(WorkflowStepSchema).min(1),
|
|
64
|
+
expectedOutcomes: z.array(z.string()).default([]),
|
|
65
|
+
evidenceRequirements: z.array(z.string()).default([]),
|
|
66
|
+
confidence: z.number().min(0).max(1),
|
|
67
|
+
assumptions: z.array(z.string()).default([]),
|
|
68
|
+
});
|
|
69
|
+
export type PlannedWorkflowScenario = z.infer<typeof PlannedWorkflowScenarioSchema>;
|
|
70
|
+
|
|
71
|
+
export const GenerationWorkflowPlanSchema = z.object({
|
|
72
|
+
planVersion: z.literal('1.0').default('1.0'),
|
|
73
|
+
featureName: z.string().min(1),
|
|
74
|
+
slug: z.string().min(1),
|
|
75
|
+
createdAt: z.string(),
|
|
76
|
+
planHash: z.string().min(1),
|
|
77
|
+
background: z.array(z.string()).default([]),
|
|
78
|
+
inputs: z.array(z.object({
|
|
79
|
+
kind: z.enum(['prompt', 'markdown', 'text', 'json', 'feature', 'git-diff', 'source', 'jira']),
|
|
80
|
+
reference: z.string(),
|
|
81
|
+
})).default([]),
|
|
82
|
+
requirements: z.array(NormalizedRequirementSchema),
|
|
83
|
+
scenarios: z.array(PlannedWorkflowScenarioSchema),
|
|
84
|
+
unresolvedQuestions: z.array(z.string()).default([]),
|
|
85
|
+
diffSummary: z.object({
|
|
86
|
+
changedFiles: z.array(z.string()).default([]),
|
|
87
|
+
changedRoutes: z.array(z.string()).default([]),
|
|
88
|
+
apiEndpoints: z.array(z.string()).default([]),
|
|
89
|
+
validationHints: z.array(z.string()).default([]),
|
|
90
|
+
}).optional(),
|
|
91
|
+
});
|
|
92
|
+
export type GenerationWorkflowPlan = z.infer<typeof GenerationWorkflowPlanSchema>;
|
|
93
|
+
|
|
94
|
+
export interface SourceAnalysisHints {
|
|
95
|
+
routes?: string[] | undefined;
|
|
96
|
+
validationRules?: string[] | undefined;
|
|
97
|
+
apiEndpoints?: string[] | undefined;
|
|
98
|
+
authRequired?: boolean | undefined;
|
|
99
|
+
persistenceDetected?: boolean | undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface GitDiffHintsInput {
|
|
103
|
+
changedFiles?: string[] | undefined;
|
|
104
|
+
changedRoutes?: string[] | undefined;
|
|
105
|
+
changedSymbols?: string[] | undefined;
|
|
106
|
+
apiEndpoints?: string[] | undefined;
|
|
107
|
+
validationHints?: string[] | undefined;
|
|
108
|
+
recentCommits?: Array<{ hash: string; subject: string }> | undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface WorkflowPlanInput {
|
|
112
|
+
featureName?: string | undefined;
|
|
113
|
+
prompt?: string | undefined;
|
|
114
|
+
markdown?: string | undefined;
|
|
115
|
+
text?: string | undefined;
|
|
116
|
+
jsonRequirements?: unknown;
|
|
117
|
+
featureText?: string | undefined;
|
|
118
|
+
jiraIssue?: { key?: string | undefined; summary: string; description?: string | undefined; acceptanceCriteria?: string[] | undefined } | undefined;
|
|
119
|
+
requirements?: RequirementInput[] | undefined;
|
|
120
|
+
existingTests?: ExistingTestInput[] | undefined;
|
|
121
|
+
gitDiff?: GitDiffHintsInput | undefined;
|
|
122
|
+
sourceHints?: SourceAnalysisHints | undefined;
|
|
123
|
+
baseUrl?: string | undefined;
|
|
124
|
+
now?: Date | undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface GherkinImportResult {
|
|
128
|
+
success: boolean;
|
|
129
|
+
plan?: GenerationWorkflowPlan | undefined;
|
|
130
|
+
edited: boolean;
|
|
131
|
+
addedScenarioIds: string[];
|
|
132
|
+
removedScenarioIds: string[];
|
|
133
|
+
modifiedScenarioIds: string[];
|
|
134
|
+
errors: string[];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function stableHash(value: unknown): string {
|
|
138
|
+
return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 12);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function computeWorkflowPlanHash(plan: Omit<GenerationWorkflowPlan, 'planHash' | 'createdAt'>): string {
|
|
142
|
+
return stableHash({
|
|
143
|
+
featureName: plan.featureName,
|
|
144
|
+
background: plan.background,
|
|
145
|
+
requirements: plan.requirements,
|
|
146
|
+
scenarios: plan.scenarios,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseMarkdownRequirements(markdown: string, fallbackTitle: string): {
|
|
151
|
+
featureName: string;
|
|
152
|
+
requirements: NormalizedRequirement[];
|
|
153
|
+
} {
|
|
154
|
+
const lines = markdown.split(/\r?\n/);
|
|
155
|
+
let featureName = fallbackTitle;
|
|
156
|
+
const headings: string[] = [];
|
|
157
|
+
const bullets: string[] = [];
|
|
158
|
+
const prose: string[] = [];
|
|
159
|
+
|
|
160
|
+
for (const rawLine of lines) {
|
|
161
|
+
const line = rawLine.trim();
|
|
162
|
+
if (!line) continue;
|
|
163
|
+
const h1 = line.match(/^#\s+(.+)$/);
|
|
164
|
+
if (h1 && headings.length === 0) {
|
|
165
|
+
featureName = h1[1]!.trim();
|
|
166
|
+
headings.push(featureName);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const hx = line.match(/^#{2,4}\s+(.+)$/);
|
|
170
|
+
if (hx) {
|
|
171
|
+
headings.push(hx[1]!.trim());
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const bullet = line.match(/^(?:[-*]|\d+\.)\s+(.+)$/);
|
|
175
|
+
if (bullet) {
|
|
176
|
+
bullets.push(bullet[1]!.trim());
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
prose.push(line);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const intent = extractBusinessIntent({ id: 'md-1', title: featureName, sourceText: markdown });
|
|
183
|
+
const isAmbiguous = bullets.length === 0 && prose.join(' ').trim().split(/\s+/).length < 4;
|
|
184
|
+
|
|
185
|
+
const requirements: NormalizedRequirement[] = bullets.length > 0
|
|
186
|
+
? bullets.map((ac, idx) => ({
|
|
187
|
+
id: `REQ-${idx + 1}`,
|
|
188
|
+
title: ac.length > 80 ? `${ac.slice(0, 77)}...` : ac,
|
|
189
|
+
description: ac,
|
|
190
|
+
acceptanceCriteria: [ac],
|
|
191
|
+
provenance: 'explicit-requirement',
|
|
192
|
+
risk: extractBusinessIntent({ id: `REQ-${idx + 1}`, sourceText: ac }).risk,
|
|
193
|
+
ambiguous: false,
|
|
194
|
+
clarificationsNeeded: [],
|
|
195
|
+
sourceRef: 'markdown',
|
|
196
|
+
}))
|
|
197
|
+
: [{
|
|
198
|
+
id: 'REQ-1',
|
|
199
|
+
title: featureName,
|
|
200
|
+
description: prose.join(' ') || featureName,
|
|
201
|
+
acceptanceCriteria: prose.length > 0 ? [prose.join(' ')] : [],
|
|
202
|
+
provenance: 'explicit-requirement',
|
|
203
|
+
risk: intent.risk,
|
|
204
|
+
ambiguous: isAmbiguous,
|
|
205
|
+
clarificationsNeeded: isAmbiguous
|
|
206
|
+
? [`Requirement '${featureName}' has no acceptance criteria or observable expected outcomes.`]
|
|
207
|
+
: [],
|
|
208
|
+
sourceRef: 'markdown',
|
|
209
|
+
}];
|
|
210
|
+
|
|
211
|
+
return { featureName, requirements };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseJsonRequirements(raw: unknown, fallbackTitle: string): NormalizedRequirement[] {
|
|
215
|
+
let data: unknown = raw;
|
|
216
|
+
if (typeof raw === 'string') {
|
|
217
|
+
data = JSON.parse(raw);
|
|
218
|
+
}
|
|
219
|
+
const list = Array.isArray(data)
|
|
220
|
+
? data
|
|
221
|
+
: (data && typeof data === 'object' && Array.isArray((data as any).requirements))
|
|
222
|
+
? (data as any).requirements
|
|
223
|
+
: data && typeof data === 'object'
|
|
224
|
+
? [data]
|
|
225
|
+
: [];
|
|
226
|
+
|
|
227
|
+
return list.map((item: any, idx: number) => {
|
|
228
|
+
const id = String(item?.id ?? `REQ-${idx + 1}`);
|
|
229
|
+
const title = String(item?.title ?? item?.summary ?? fallbackTitle).trim() || fallbackTitle;
|
|
230
|
+
const description = String(item?.description ?? title).trim();
|
|
231
|
+
const acList: string[] = Array.isArray(item?.acceptanceCriteria)
|
|
232
|
+
? item.acceptanceCriteria.map(String).filter(Boolean)
|
|
233
|
+
: description !== title ? [description] : [];
|
|
234
|
+
const intent = extractBusinessIntent({ id, title, sourceText: `${title}\n${description}\n${acList.join('\n')}` });
|
|
235
|
+
return {
|
|
236
|
+
id,
|
|
237
|
+
title,
|
|
238
|
+
description,
|
|
239
|
+
acceptanceCriteria: acList,
|
|
240
|
+
provenance: 'explicit-requirement',
|
|
241
|
+
risk: intent.risk,
|
|
242
|
+
ambiguous: acList.length === 0 && description.split(/\s+/).length < 4,
|
|
243
|
+
clarificationsNeeded: acList.length === 0 && description.split(/\s+/).length < 4
|
|
244
|
+
? [`Requirement '${id}' needs explicit acceptance criteria.`]
|
|
245
|
+
: [],
|
|
246
|
+
sourceRef: 'json',
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isVagueRequirementText(text: string): boolean {
|
|
252
|
+
const cleaned = text.toLowerCase().replace(/\b(test|improve|fix|feature|app|page|workflow|check|verify|make|better|it|the|for|and)\b/g, ' ').trim();
|
|
253
|
+
const meaningfulWords = cleaned.match(/[a-z0-9]{3,}/g) ?? [];
|
|
254
|
+
return meaningfulWords.length < 2;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function normalizeExistingTestSignature(test: ExistingTestInput): Set<string> {
|
|
258
|
+
const raw = `${test.title ?? ''} ${test.sourceText}`
|
|
259
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
260
|
+
.toLowerCase();
|
|
261
|
+
return new Set(raw.split(/[^a-z0-9]+/).filter((t) => t.length >= 3));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function matchesExistingCoverage(scenarioTitle: string, expectedOutcomes: string[], existingTests: ExistingTestInput[]): boolean {
|
|
265
|
+
if (existingTests.length === 0) return false;
|
|
266
|
+
const stopWords = new Set(['should', 'user', 'with', 'page', 'after', 'that', 'when', 'the', 'and', 'for']);
|
|
267
|
+
const titleTokens = new Set(
|
|
268
|
+
scenarioTitle
|
|
269
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
270
|
+
.toLowerCase()
|
|
271
|
+
.split(/[^a-z0-9]+/)
|
|
272
|
+
.filter((t) => t.length >= 3 && !stopWords.has(t)),
|
|
273
|
+
);
|
|
274
|
+
const outcomeTokens = new Set(
|
|
275
|
+
`${scenarioTitle} ${expectedOutcomes.join(' ')}`
|
|
276
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
277
|
+
.toLowerCase()
|
|
278
|
+
.split(/[^a-z0-9]+/)
|
|
279
|
+
.filter((t) => t.length >= 3 && !stopWords.has(t)),
|
|
280
|
+
);
|
|
281
|
+
if (titleTokens.size === 0) return false;
|
|
282
|
+
|
|
283
|
+
for (const existing of existingTests) {
|
|
284
|
+
const existingTokens = normalizeExistingTestSignature(existing);
|
|
285
|
+
let titleOverlap = 0;
|
|
286
|
+
for (const token of titleTokens) {
|
|
287
|
+
if (existingTokens.has(token)) titleOverlap += 1;
|
|
288
|
+
}
|
|
289
|
+
let overallOverlap = 0;
|
|
290
|
+
for (const token of outcomeTokens) {
|
|
291
|
+
if (existingTokens.has(token)) overallOverlap += 1;
|
|
292
|
+
}
|
|
293
|
+
if (titleOverlap / titleTokens.size >= 0.75 || overallOverlap / outcomeTokens.size >= 0.6) {
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function inferCategoriesForRequirement(
|
|
301
|
+
req: NormalizedRequirement,
|
|
302
|
+
gitDiff?: GitDiffHintsInput,
|
|
303
|
+
sourceHints?: SourceAnalysisHints,
|
|
304
|
+
): Array<{ category: WorkflowScenarioCategory; provenance: WorkflowProvenance; priority: PlannedWorkflowScenario['priority']; triggerEvidence: string }> {
|
|
305
|
+
const fullText = `${req.title}\n${req.description}\n${req.acceptanceCriteria.join('\n')}`.toLowerCase();
|
|
306
|
+
const diffText = (gitDiff?.validationHints ?? []).join(' ').toLowerCase();
|
|
307
|
+
const sourceText = (sourceHints?.validationRules ?? []).join(' ').toLowerCase();
|
|
308
|
+
|
|
309
|
+
const entries: Array<{ category: WorkflowScenarioCategory; provenance: WorkflowProvenance; priority: PlannedWorkflowScenario['priority']; triggerEvidence: string }> = [
|
|
310
|
+
{ category: 'happy-path', provenance: req.provenance, priority: req.risk === 'critical' ? 'P0' : 'P1', triggerEvidence: req.id },
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
// Negative / Validation
|
|
314
|
+
if (/\b(invalid|reject|empty|error|prevent|fail|required|disallowed|duplicate)\b/.test(fullText)) {
|
|
315
|
+
entries.push({ category: 'validation', provenance: 'explicit-requirement', priority: 'P1', triggerEvidence: req.id });
|
|
316
|
+
} else if (/\b(required|email|min|max)\b/.test(sourceText)) {
|
|
317
|
+
entries.push({ category: 'validation', provenance: 'source-derived', priority: 'P1', triggerEvidence: 'source-validation' });
|
|
318
|
+
} else if (/\b(required|email|min|max)\b/.test(diffText)) {
|
|
319
|
+
entries.push({ category: 'validation', provenance: 'diff-derived', priority: 'P1', triggerEvidence: 'git-diff-validation' });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Boundary: ONLY when explicit bounds exist
|
|
323
|
+
if (/\b(min(?:imum)?|max(?:imum)?|length|limit|character|capacity|at least|at most|\d+\s*(?:chars|items))\b/.test(fullText)) {
|
|
324
|
+
entries.push({ category: 'boundary', provenance: 'explicit-requirement', priority: 'P2', triggerEvidence: req.id });
|
|
325
|
+
} else if (/\b(min|max)\(\d+\)/.test(sourceText)) {
|
|
326
|
+
entries.push({ category: 'boundary', provenance: 'source-derived', priority: 'P2', triggerEvidence: 'source-schema-bounds' });
|
|
327
|
+
} else if (/\b(min|max)\(\d+\)/.test(diffText)) {
|
|
328
|
+
entries.push({ category: 'boundary', provenance: 'diff-derived', priority: 'P2', triggerEvidence: 'git-diff-bounds' });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Persistence
|
|
332
|
+
if (/\b(persist|reload|refresh|saved|localstorage|survive|database)\b/.test(fullText)) {
|
|
333
|
+
entries.push({ category: 'persistence', provenance: 'explicit-requirement', priority: 'P1', triggerEvidence: req.id });
|
|
334
|
+
} else if (sourceHints?.persistenceDetected) {
|
|
335
|
+
entries.push({ category: 'persistence', provenance: 'source-derived', priority: 'P2', triggerEvidence: 'source-persistence' });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Authentication / Authorization
|
|
339
|
+
if (/\b(auth|login|logged in|unauthenticated|guest|permission|role|forbidden|unauthorized|admin)\b/.test(fullText)) {
|
|
340
|
+
entries.push({ category: 'authentication', provenance: 'explicit-requirement', priority: 'P0', triggerEvidence: req.id });
|
|
341
|
+
} else if (sourceHints?.authRequired) {
|
|
342
|
+
entries.push({ category: 'authentication', provenance: 'source-derived', priority: 'P0', triggerEvidence: 'source-auth-guard' });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// State lifecycle transitions (toggle/delete/edit/update/filter)
|
|
346
|
+
if (/\b(toggle|complete|delete|remove|edit|update|filter|clear)\b/.test(fullText)) {
|
|
347
|
+
entries.push({ category: 'state-transition', provenance: 'explicit-requirement', priority: 'P1', triggerEvidence: req.id });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// API consistency
|
|
351
|
+
if ((sourceHints?.apiEndpoints?.length ?? 0) > 0 || (gitDiff?.apiEndpoints?.length ?? 0) > 0 || /\b(api|endpoint|http|status|response)\b/.test(fullText)) {
|
|
352
|
+
const prov: WorkflowProvenance = /\b(api|endpoint|http|status|response)\b/.test(fullText)
|
|
353
|
+
? 'explicit-requirement'
|
|
354
|
+
: (gitDiff?.apiEndpoints?.length ?? 0) > 0 ? 'diff-derived' : 'source-derived';
|
|
355
|
+
entries.push({ category: 'ui-api-consistency', provenance: prov, priority: 'P1', triggerEvidence: prov });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return entries;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function buildStepsForScenario(
|
|
362
|
+
req: NormalizedRequirement,
|
|
363
|
+
category: WorkflowScenarioCategory,
|
|
364
|
+
baseUrl?: string,
|
|
365
|
+
): { steps: WorkflowStep[]; expectedOutcomes: string[]; preconditions: string[] } {
|
|
366
|
+
const preconditions = [baseUrl ? `the application is accessible at ${baseUrl}` : 'the target feature page is accessible'];
|
|
367
|
+
|
|
368
|
+
switch (category) {
|
|
369
|
+
case 'validation':
|
|
370
|
+
case 'negative':
|
|
371
|
+
case 'required-fields':
|
|
372
|
+
return {
|
|
373
|
+
preconditions,
|
|
374
|
+
steps: [
|
|
375
|
+
{ keyword: 'Given', text: preconditions[0]! },
|
|
376
|
+
{ keyword: 'When', text: `the user submits invalid or empty input for "${req.title}"`, actionHint: 'submit-invalid' },
|
|
377
|
+
{ keyword: 'Then', text: 'the application rejects invalid submission and preserves valid state' },
|
|
378
|
+
],
|
|
379
|
+
expectedOutcomes: ['Validation feedback shown or invalid submission blocked without corrupting state'],
|
|
380
|
+
};
|
|
381
|
+
case 'boundary':
|
|
382
|
+
return {
|
|
383
|
+
preconditions,
|
|
384
|
+
steps: [
|
|
385
|
+
{ keyword: 'Given', text: preconditions[0]! },
|
|
386
|
+
{ keyword: 'When', text: `the user tests declared boundary values for "${req.title}"`, actionHint: 'test-boundary' },
|
|
387
|
+
{ keyword: 'Then', text: 'inputs inside bounds succeed and inputs outside bounds are rejected per rule' },
|
|
388
|
+
],
|
|
389
|
+
expectedOutcomes: ['Boundary enforcement matches declared rule'],
|
|
390
|
+
};
|
|
391
|
+
case 'persistence':
|
|
392
|
+
return {
|
|
393
|
+
preconditions,
|
|
394
|
+
steps: [
|
|
395
|
+
{ keyword: 'Given', text: `the user has completed "${req.title}"` },
|
|
396
|
+
{ keyword: 'When', text: 'the browser page is reloaded', actionHint: 'reload' },
|
|
397
|
+
{ keyword: 'Then', text: 'the updated state remains visible after reload' },
|
|
398
|
+
],
|
|
399
|
+
expectedOutcomes: ['State survives client reload and matches backend/storage state'],
|
|
400
|
+
};
|
|
401
|
+
case 'authentication':
|
|
402
|
+
case 'authorization':
|
|
403
|
+
return {
|
|
404
|
+
preconditions: ['the user starts without required authentication or role'],
|
|
405
|
+
steps: [
|
|
406
|
+
{ keyword: 'Given', text: 'the user is not authenticated with the required permission' },
|
|
407
|
+
{ keyword: 'When', text: `the user attempts to access "${req.title}"`, actionHint: 'check-auth' },
|
|
408
|
+
{ keyword: 'Then', text: 'access is denied or redirected to login cleanly' },
|
|
409
|
+
],
|
|
410
|
+
expectedOutcomes: ['Unauthorized access blocked'],
|
|
411
|
+
};
|
|
412
|
+
case 'state-transition':
|
|
413
|
+
return {
|
|
414
|
+
preconditions,
|
|
415
|
+
steps: [
|
|
416
|
+
{ keyword: 'Given', text: `initial state for "${req.title}" is established` },
|
|
417
|
+
{ keyword: 'When', text: `the user performs the state transition in "${req.title}"`, actionHint: 'transition' },
|
|
418
|
+
{ keyword: 'Then', text: 'the UI indicators and counters reflect the new state cleanly' },
|
|
419
|
+
],
|
|
420
|
+
expectedOutcomes: ['UI state transition reflected accurately'],
|
|
421
|
+
};
|
|
422
|
+
case 'ui-api-consistency':
|
|
423
|
+
return {
|
|
424
|
+
preconditions,
|
|
425
|
+
steps: [
|
|
426
|
+
{ keyword: 'Given', text: preconditions[0]! },
|
|
427
|
+
{ keyword: 'When', text: `the user triggers the operation in "${req.title}"`, actionHint: 'trigger-api' },
|
|
428
|
+
{ keyword: 'Then', text: 'the observed network API response agrees with the updated UI state' },
|
|
429
|
+
],
|
|
430
|
+
expectedOutcomes: ['Network response status/payload agrees with UI state'],
|
|
431
|
+
};
|
|
432
|
+
default:
|
|
433
|
+
return {
|
|
434
|
+
preconditions,
|
|
435
|
+
steps: [
|
|
436
|
+
{ keyword: 'Given', text: preconditions[0]! },
|
|
437
|
+
{ keyword: 'When', text: `the user executes the primary flow for "${req.title}"`, actionHint: 'happy-path' },
|
|
438
|
+
{ keyword: 'Then', text: req.acceptanceCriteria[0] ? req.acceptanceCriteria[0] : `expected outcome for "${req.title}" is observed in the UI` },
|
|
439
|
+
],
|
|
440
|
+
expectedOutcomes: req.acceptanceCriteria.length > 0 ? req.acceptanceCriteria : [`Observable outcome for ${req.title}`],
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function buildWorkflowPlan(input: WorkflowPlanInput): GenerationWorkflowPlan {
|
|
446
|
+
const now = input.now ?? new Date();
|
|
447
|
+
const inputsUsed: GenerationWorkflowPlan['inputs'] = [];
|
|
448
|
+
let featureName = input.featureName?.trim() || input.jiraIssue?.summary?.trim() || 'Feature Workflow';
|
|
449
|
+
const normalizedRequirements: NormalizedRequirement[] = [];
|
|
450
|
+
const unresolvedQuestions: string[] = [];
|
|
451
|
+
|
|
452
|
+
if (input.featureText) {
|
|
453
|
+
inputsUsed.push({ kind: 'feature', reference: 'inline-feature' });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (input.markdown) {
|
|
457
|
+
inputsUsed.push({ kind: 'markdown', reference: 'markdown-requirements' });
|
|
458
|
+
const parsedMd = parseMarkdownRequirements(input.markdown, featureName);
|
|
459
|
+
if (!input.featureName) featureName = parsedMd.featureName;
|
|
460
|
+
normalizedRequirements.push(...parsedMd.requirements);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (input.jsonRequirements !== undefined) {
|
|
464
|
+
inputsUsed.push({ kind: 'json', reference: 'json-requirements' });
|
|
465
|
+
normalizedRequirements.push(...parseJsonRequirements(input.jsonRequirements, featureName));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (input.jiraIssue) {
|
|
469
|
+
inputsUsed.push({ kind: 'jira', reference: input.jiraIssue.key ?? 'jira-issue' });
|
|
470
|
+
const ac = input.jiraIssue.acceptanceCriteria ?? [];
|
|
471
|
+
const desc = input.jiraIssue.description ?? input.jiraIssue.summary;
|
|
472
|
+
const intent = extractBusinessIntent({ id: input.jiraIssue.key ?? 'JIRA-1', title: input.jiraIssue.summary, sourceText: `${desc}\n${ac.join('\n')}` });
|
|
473
|
+
normalizedRequirements.push({
|
|
474
|
+
id: input.jiraIssue.key ?? 'JIRA-1',
|
|
475
|
+
title: input.jiraIssue.summary,
|
|
476
|
+
description: desc,
|
|
477
|
+
acceptanceCriteria: ac,
|
|
478
|
+
provenance: 'explicit-requirement',
|
|
479
|
+
risk: intent.risk,
|
|
480
|
+
ambiguous: ac.length === 0 && isVagueRequirementText(desc),
|
|
481
|
+
clarificationsNeeded: ac.length === 0 && isVagueRequirementText(desc)
|
|
482
|
+
? [`Jira issue '${input.jiraIssue.summary}' lacks clear acceptance criteria.`]
|
|
483
|
+
: [],
|
|
484
|
+
sourceRef: input.jiraIssue.key,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (input.requirements && input.requirements.length > 0) {
|
|
489
|
+
inputsUsed.push({ kind: 'json', reference: 'structured-requirements' });
|
|
490
|
+
for (const req of input.requirements) {
|
|
491
|
+
const desc = req.description ?? req.title;
|
|
492
|
+
const intent = extractBusinessIntent({ id: req.id, title: req.title, sourceText: desc });
|
|
493
|
+
const vague = !req.description && isVagueRequirementText(req.title);
|
|
494
|
+
normalizedRequirements.push({
|
|
495
|
+
id: req.id,
|
|
496
|
+
title: req.title,
|
|
497
|
+
description: desc,
|
|
498
|
+
acceptanceCriteria: req.description ? [req.description] : [],
|
|
499
|
+
provenance: 'explicit-requirement',
|
|
500
|
+
risk: intent.risk,
|
|
501
|
+
ambiguous: vague,
|
|
502
|
+
clarificationsNeeded: vague ? [`Requirement '${req.title}' is ambiguous; clarify expected behavior.`] : [],
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const plainText = input.prompt ?? input.text;
|
|
508
|
+
if (plainText && normalizedRequirements.length === 0) {
|
|
509
|
+
inputsUsed.push({ kind: input.prompt ? 'prompt' : 'text', reference: 'inline-requirement' });
|
|
510
|
+
if (!input.featureName) {
|
|
511
|
+
featureName = plainText.split(/\r?\n/)[0]!.trim().slice(0, 64) || featureName;
|
|
512
|
+
}
|
|
513
|
+
const intent = extractBusinessIntent({ id: 'REQ-1', title: featureName, sourceText: plainText });
|
|
514
|
+
const vague = isVagueRequirementText(plainText);
|
|
515
|
+
normalizedRequirements.push({
|
|
516
|
+
id: 'REQ-1',
|
|
517
|
+
title: featureName,
|
|
518
|
+
description: plainText.trim(),
|
|
519
|
+
acceptanceCriteria: vague ? [] : [plainText.trim()],
|
|
520
|
+
provenance: 'explicit-requirement',
|
|
521
|
+
risk: intent.risk,
|
|
522
|
+
ambiguous: vague,
|
|
523
|
+
clarificationsNeeded: vague ? [`Prompt '${plainText.trim()}' lacks specific expected behavior.`] : [],
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (input.gitDiff && ((input.gitDiff.changedFiles?.length ?? 0) > 0 || (input.gitDiff.changedRoutes?.length ?? 0) > 0)) {
|
|
528
|
+
inputsUsed.push({ kind: 'git-diff', reference: `diff:${input.gitDiff.changedFiles?.length ?? 0}-files` });
|
|
529
|
+
}
|
|
530
|
+
if (input.sourceHints && ((input.sourceHints.routes?.length ?? 0) > 0 || (input.sourceHints.validationRules?.length ?? 0) > 0)) {
|
|
531
|
+
inputsUsed.push({ kind: 'source', reference: `source:${input.sourceHints.routes?.length ?? 0}-routes` });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Fallback if only gitDiff or featureText arrived
|
|
535
|
+
if (normalizedRequirements.length === 0 && input.gitDiff?.changedFiles?.length) {
|
|
536
|
+
normalizedRequirements.push({
|
|
537
|
+
id: 'DIFF-1',
|
|
538
|
+
title: `Changes in ${input.gitDiff.changedFiles.slice(0, 3).join(', ')}`,
|
|
539
|
+
description: `Verify flows impacted by changed files: ${input.gitDiff.changedFiles.join(', ')}`,
|
|
540
|
+
acceptanceCriteria: [],
|
|
541
|
+
provenance: 'diff-derived',
|
|
542
|
+
risk: 'medium',
|
|
543
|
+
ambiguous: true,
|
|
544
|
+
clarificationsNeeded: ['Derived only from git diff without business requirements; clarify expected UI behavior.'],
|
|
545
|
+
sourceRef: 'git-diff',
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
for (const r of normalizedRequirements) {
|
|
550
|
+
unresolvedQuestions.push(...r.clarificationsNeeded);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const existingTests = input.existingTests ?? [];
|
|
554
|
+
const scenarios: PlannedWorkflowScenario[] = [];
|
|
555
|
+
const seenIds = new Set<string>();
|
|
556
|
+
|
|
557
|
+
for (const req of normalizedRequirements) {
|
|
558
|
+
if (req.ambiguous && req.acceptanceCriteria.length === 0 && !input.sourceHints?.routes?.length && !input.gitDiff?.changedRoutes?.length) {
|
|
559
|
+
const id = `sc-${toSlug(req.id)}-clarify-${stableHash(req.title).slice(0, 6)}`;
|
|
560
|
+
seenIds.add(id);
|
|
561
|
+
scenarios.push({
|
|
562
|
+
id,
|
|
563
|
+
requirementRefs: [req.id],
|
|
564
|
+
title: `Clarify workflow for ${req.title}`,
|
|
565
|
+
category: 'happy-path',
|
|
566
|
+
priority: 'P2',
|
|
567
|
+
tags: [`@id:${id}`, `@req:${req.id}`, '@needs-clarification'],
|
|
568
|
+
provenance: req.provenance,
|
|
569
|
+
applicability: 'needs_clarification',
|
|
570
|
+
preconditions: [],
|
|
571
|
+
steps: [
|
|
572
|
+
{ keyword: 'Given', text: `requirement "${req.title}" is supplied without acceptance criteria` },
|
|
573
|
+
{ keyword: 'When', text: 'the application context is inspected' },
|
|
574
|
+
{ keyword: 'Then', text: 'expected outcomes require explicit user or application evidence clarification' },
|
|
575
|
+
],
|
|
576
|
+
expectedOutcomes: [],
|
|
577
|
+
evidenceRequirements: ['explicit-ac-or-live-exploration'],
|
|
578
|
+
confidence: 0.2,
|
|
579
|
+
assumptions: req.clarificationsNeeded,
|
|
580
|
+
});
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const categories = inferCategoriesForRequirement(req, input.gitDiff, input.sourceHints);
|
|
585
|
+
for (const cat of categories) {
|
|
586
|
+
let id = `sc-${toSlug(req.id)}-${cat.category}`;
|
|
587
|
+
if (seenIds.has(id)) {
|
|
588
|
+
id = `${id}-${stableHash(`${req.id}:${cat.category}:${scenarios.length}`).slice(0, 4)}`;
|
|
589
|
+
}
|
|
590
|
+
seenIds.add(id);
|
|
591
|
+
|
|
592
|
+
const built = buildStepsForScenario(req, cat.category, input.baseUrl);
|
|
593
|
+
const scenarioTitle = `${req.title} (${cat.category})`;
|
|
594
|
+
const alreadyCovered = matchesExistingCoverage(req.title, built.expectedOutcomes, existingTests);
|
|
595
|
+
const applicability: PlannedWorkflowScenario['applicability'] = alreadyCovered ? 'covered' : 'applicable';
|
|
596
|
+
|
|
597
|
+
const tags = [
|
|
598
|
+
`@id:${id}`,
|
|
599
|
+
`@req:${req.id}`,
|
|
600
|
+
`@${cat.category}`,
|
|
601
|
+
`@${cat.provenance}`,
|
|
602
|
+
...(alreadyCovered ? ['@covered'] : []),
|
|
603
|
+
];
|
|
604
|
+
|
|
605
|
+
scenarios.push({
|
|
606
|
+
id,
|
|
607
|
+
requirementRefs: [req.id],
|
|
608
|
+
title: scenarioTitle,
|
|
609
|
+
category: cat.category,
|
|
610
|
+
priority: cat.priority,
|
|
611
|
+
tags,
|
|
612
|
+
provenance: cat.provenance,
|
|
613
|
+
applicability,
|
|
614
|
+
preconditions: built.preconditions,
|
|
615
|
+
steps: built.steps,
|
|
616
|
+
expectedOutcomes: built.expectedOutcomes,
|
|
617
|
+
evidenceRequirements: [`live-ui:${cat.category}`, ...(cat.category === 'ui-api-consistency' ? ['network-api'] : [])],
|
|
618
|
+
confidence: cat.provenance === 'explicit-requirement' ? 0.9 : 0.75,
|
|
619
|
+
assumptions: cat.provenance === 'assumption' ? [`Derived as ${cat.category} assumption from ${cat.triggerEvidence}`] : [],
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const background = [
|
|
625
|
+
input.baseUrl ? `the application is accessible at ${input.baseUrl}` : 'the target application is running',
|
|
626
|
+
];
|
|
627
|
+
|
|
628
|
+
const basePlan = {
|
|
629
|
+
planVersion: '1.0' as const,
|
|
630
|
+
featureName,
|
|
631
|
+
slug: toSlug(featureName),
|
|
632
|
+
background,
|
|
633
|
+
inputs: inputsUsed,
|
|
634
|
+
requirements: normalizedRequirements,
|
|
635
|
+
scenarios,
|
|
636
|
+
unresolvedQuestions: [...new Set(unresolvedQuestions)],
|
|
637
|
+
...(input.gitDiff ? {
|
|
638
|
+
diffSummary: {
|
|
639
|
+
changedFiles: input.gitDiff.changedFiles ?? [],
|
|
640
|
+
changedRoutes: input.gitDiff.changedRoutes ?? [],
|
|
641
|
+
apiEndpoints: input.gitDiff.apiEndpoints ?? [],
|
|
642
|
+
validationHints: input.gitDiff.validationHints ?? [],
|
|
643
|
+
},
|
|
644
|
+
} : {}),
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const plan: GenerationWorkflowPlan = {
|
|
648
|
+
...basePlan,
|
|
649
|
+
createdAt: now.toISOString(),
|
|
650
|
+
planHash: computeWorkflowPlanHash(basePlan),
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
// If initial input already was a .feature file, merge its scenarios cleanly
|
|
654
|
+
if (input.featureText) {
|
|
655
|
+
const imported = importGherkinToWorkflowPlan(input.featureText, {
|
|
656
|
+
...plan,
|
|
657
|
+
scenarios: [],
|
|
658
|
+
});
|
|
659
|
+
if (imported.success && imported.plan) {
|
|
660
|
+
return imported.plan;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return plan;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export function exportWorkflowToGherkin(plan: GenerationWorkflowPlan): string {
|
|
668
|
+
const lines: string[] = [
|
|
669
|
+
`# pie-plan-hash: ${plan.planHash}`,
|
|
670
|
+
`Feature: ${plan.featureName}`,
|
|
671
|
+
'',
|
|
672
|
+
];
|
|
673
|
+
|
|
674
|
+
if (plan.background.length > 0) {
|
|
675
|
+
lines.push(' Background:');
|
|
676
|
+
for (const bg of plan.background) {
|
|
677
|
+
lines.push(` Given ${bg}`);
|
|
678
|
+
}
|
|
679
|
+
lines.push('');
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
for (const scenario of plan.scenarios) {
|
|
683
|
+
if (scenario.tags.length > 0) {
|
|
684
|
+
lines.push(` ${scenario.tags.join(' ')}`);
|
|
685
|
+
}
|
|
686
|
+
lines.push(` Scenario: ${scenario.title}`);
|
|
687
|
+
for (const step of scenario.steps) {
|
|
688
|
+
lines.push(` ${step.keyword} ${step.text}`);
|
|
689
|
+
}
|
|
690
|
+
lines.push('');
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function categoryFromTags(tags: string[]): WorkflowScenarioCategory {
|
|
697
|
+
const map: Record<string, WorkflowScenarioCategory> = {
|
|
698
|
+
'@happy-path': 'happy-path',
|
|
699
|
+
'@negative': 'negative',
|
|
700
|
+
'@validation': 'validation',
|
|
701
|
+
'@required-fields': 'required-fields',
|
|
702
|
+
'@boundary': 'boundary',
|
|
703
|
+
'@persistence': 'persistence',
|
|
704
|
+
'@authentication': 'authentication',
|
|
705
|
+
'@auth': 'authentication',
|
|
706
|
+
'@authorization': 'authorization',
|
|
707
|
+
'@state-transition': 'state-transition',
|
|
708
|
+
'@error-handling': 'error-handling',
|
|
709
|
+
'@recovery': 'recovery',
|
|
710
|
+
'@ui-api-consistency': 'ui-api-consistency',
|
|
711
|
+
};
|
|
712
|
+
for (const tag of tags) {
|
|
713
|
+
const mapped = map[tag.toLowerCase()];
|
|
714
|
+
if (mapped) return mapped;
|
|
715
|
+
}
|
|
716
|
+
return 'happy-path';
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export function isStaleWorkflowPlan(featureText: string, canonicalPlan: GenerationWorkflowPlan): boolean {
|
|
720
|
+
const hashMatch = featureText.match(/^#\s*pie-plan-hash:\s*([a-f0-9]+)\s*$/m);
|
|
721
|
+
if (hashMatch && hashMatch[1] !== canonicalPlan.planHash) {
|
|
722
|
+
return true;
|
|
723
|
+
}
|
|
724
|
+
const imported = importGherkinToWorkflowPlan(featureText, canonicalPlan);
|
|
725
|
+
return imported.edited || !imported.success;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
export function importGherkinToWorkflowPlan(
|
|
729
|
+
featureText: string,
|
|
730
|
+
canonicalPlan: GenerationWorkflowPlan,
|
|
731
|
+
): GherkinImportResult {
|
|
732
|
+
const errors: string[] = [];
|
|
733
|
+
const rawLines = featureText.split(/\r?\n/);
|
|
734
|
+
|
|
735
|
+
let parsedFeatureName = canonicalPlan.featureName;
|
|
736
|
+
let seenFeatureHeader = false;
|
|
737
|
+
let inBackground = false;
|
|
738
|
+
const parsedBackground: string[] = [];
|
|
739
|
+
let pendingTags: string[] = [];
|
|
740
|
+
|
|
741
|
+
interface ParsedRawScenario {
|
|
742
|
+
title: string;
|
|
743
|
+
tags: string[];
|
|
744
|
+
steps: WorkflowStep[];
|
|
745
|
+
}
|
|
746
|
+
const parsedScenarios: ParsedRawScenario[] = [];
|
|
747
|
+
let currentScenario: ParsedRawScenario | null = null;
|
|
748
|
+
|
|
749
|
+
for (let i = 0; i < rawLines.length; i += 1) {
|
|
750
|
+
const trimmed = rawLines[i]!.trim();
|
|
751
|
+
if (!trimmed) continue;
|
|
752
|
+
if (trimmed.startsWith('#')) continue;
|
|
753
|
+
|
|
754
|
+
if (/^Scenario Outline\s*:/i.test(trimmed) || /^Examples\s*:/i.test(trimmed)) {
|
|
755
|
+
errors.push(`UNSUPPORTED_GHERKIN_SYNTAX at line ${i + 1}: Scenario Outline / Examples tables are not supported for round-trip import.`);
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (trimmed.startsWith('|')) {
|
|
760
|
+
errors.push(`UNSUPPORTED_GHERKIN_SYNTAX at line ${i + 1}: Gherkin DataTables are not supported.`);
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const featureMatch = trimmed.match(/^Feature\s*:\s*(.+)$/i);
|
|
765
|
+
if (featureMatch) {
|
|
766
|
+
seenFeatureHeader = true;
|
|
767
|
+
parsedFeatureName = featureMatch[1]!.trim();
|
|
768
|
+
inBackground = false;
|
|
769
|
+
currentScenario = null;
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (/^Background\s*:/i.test(trimmed)) {
|
|
774
|
+
inBackground = true;
|
|
775
|
+
currentScenario = null;
|
|
776
|
+
pendingTags = [];
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (trimmed.startsWith('@')) {
|
|
781
|
+
const tags = trimmed.split(/\s+/).filter((t) => t.startsWith('@'));
|
|
782
|
+
pendingTags.push(...tags);
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const scenarioMatch = trimmed.match(/^Scenario\s*:\s*(.+)$/i);
|
|
787
|
+
if (scenarioMatch) {
|
|
788
|
+
inBackground = false;
|
|
789
|
+
if (currentScenario) parsedScenarios.push(currentScenario);
|
|
790
|
+
currentScenario = {
|
|
791
|
+
title: scenarioMatch[1]!.trim(),
|
|
792
|
+
tags: pendingTags,
|
|
793
|
+
steps: [],
|
|
794
|
+
};
|
|
795
|
+
pendingTags = [];
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const stepMatch = trimmed.match(/^(Given|When|Then|And|But)\s+(.+)$/i);
|
|
800
|
+
if (stepMatch) {
|
|
801
|
+
const kwRaw = stepMatch[1]!;
|
|
802
|
+
const kw = (kwRaw[0]!.toUpperCase() + kwRaw.slice(1).toLowerCase()) as WorkflowStep['keyword'];
|
|
803
|
+
const text = stepMatch[2]!.trim();
|
|
804
|
+
if (inBackground) {
|
|
805
|
+
parsedBackground.push(text);
|
|
806
|
+
} else if (currentScenario) {
|
|
807
|
+
currentScenario.steps.push({ keyword: kw, text });
|
|
808
|
+
} else {
|
|
809
|
+
errors.push(`SYNTAX_ERROR at line ${i + 1}: Step found outside Scenario or Background: '${trimmed}'`);
|
|
810
|
+
}
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
errors.push(`UNSUPPORTED_GHERKIN_SYNTAX at line ${i + 1}: Unrecognized Gherkin line '${trimmed}'`);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (currentScenario) parsedScenarios.push(currentScenario);
|
|
818
|
+
|
|
819
|
+
if (!seenFeatureHeader) {
|
|
820
|
+
errors.push('INVALID_GHERKIN_FEATURE: Missing Feature: header.');
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (errors.length > 0) {
|
|
824
|
+
return {
|
|
825
|
+
success: false,
|
|
826
|
+
edited: false,
|
|
827
|
+
addedScenarioIds: [],
|
|
828
|
+
removedScenarioIds: [],
|
|
829
|
+
modifiedScenarioIds: [],
|
|
830
|
+
errors,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const existingById = new Map(canonicalPlan.scenarios.map((s) => [s.id, s]));
|
|
835
|
+
const seenScenarioIds = new Set<string>();
|
|
836
|
+
const rebuiltScenarios: PlannedWorkflowScenario[] = [];
|
|
837
|
+
const addedScenarioIds: string[] = [];
|
|
838
|
+
const modifiedScenarioIds: string[] = [];
|
|
839
|
+
|
|
840
|
+
for (const rawSc of parsedScenarios) {
|
|
841
|
+
if (rawSc.steps.length === 0) {
|
|
842
|
+
errors.push(`INVALID_GHERKIN_SCENARIO: Scenario '${rawSc.title}' has zero steps.`);
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
const hasWhenOrThen = rawSc.steps.some((s) => s.keyword === 'When' || s.keyword === 'Then');
|
|
846
|
+
if (!hasWhenOrThen) {
|
|
847
|
+
errors.push(`INVALID_GHERKIN_SCENARIO: Scenario '${rawSc.title}' must contain at least one When or Then step.`);
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const idTag = rawSc.tags.find((t) => t.startsWith('@id:'));
|
|
852
|
+
let scenarioId = idTag ? idTag.slice('@id:'.length).trim() : `sc-edited-${toSlug(rawSc.title)}-${stableHash(rawSc.title).slice(0, 6)}`;
|
|
853
|
+
if (!scenarioId) {
|
|
854
|
+
errors.push(`INVALID_SCENARIO_ID: Empty @id: tag on scenario '${rawSc.title}'.`);
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
if (seenScenarioIds.has(scenarioId)) {
|
|
858
|
+
errors.push(`DUPLICATE_SCENARIO_ID: Scenario ID '${scenarioId}' appears multiple times.`);
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
seenScenarioIds.add(scenarioId);
|
|
862
|
+
|
|
863
|
+
const reqTags = rawSc.tags.filter((t) => t.startsWith('@req:')).map((t) => t.slice('@req:'.length).trim()).filter(Boolean);
|
|
864
|
+
const prior = existingById.get(scenarioId);
|
|
865
|
+
const category = categoryFromTags(rawSc.tags);
|
|
866
|
+
const expectedOutcomes = rawSc.steps.filter((s) => s.keyword === 'Then').map((s) => s.text);
|
|
867
|
+
const preconditions = rawSc.steps.filter((s) => s.keyword === 'Given').map((s) => s.text);
|
|
868
|
+
|
|
869
|
+
const tags = rawSc.tags.includes(`@id:${scenarioId}`) ? rawSc.tags : [`@id:${scenarioId}`, ...rawSc.tags];
|
|
870
|
+
const requirementRefs = reqTags.length > 0
|
|
871
|
+
? reqTags
|
|
872
|
+
: prior?.requirementRefs ?? [canonicalPlan.requirements[0]?.id ?? 'REQ-1'];
|
|
873
|
+
|
|
874
|
+
const mergedSteps = rawSc.steps.map((step, idx) => {
|
|
875
|
+
const priorStep = prior?.steps[idx];
|
|
876
|
+
if (priorStep && priorStep.keyword === step.keyword && priorStep.text === step.text && priorStep.actionHint) {
|
|
877
|
+
return { ...step, actionHint: priorStep.actionHint, ...(priorStep.targetHint ? { targetHint: priorStep.targetHint } : {}), ...(priorStep.valueHint ? { valueHint: priorStep.valueHint } : {}) };
|
|
878
|
+
}
|
|
879
|
+
return step;
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
const stepsChanged = !prior || JSON.stringify(prior.steps.map((s) => `${s.keyword}:${s.text}`)) !== JSON.stringify(mergedSteps.map((s) => `${s.keyword}:${s.text}`));
|
|
883
|
+
|
|
884
|
+
const scenarioObj: PlannedWorkflowScenario = {
|
|
885
|
+
id: scenarioId,
|
|
886
|
+
requirementRefs,
|
|
887
|
+
title: rawSc.title,
|
|
888
|
+
category: prior && !rawSc.tags.some((t) => t === `@${category}`) ? prior.category : category,
|
|
889
|
+
priority: prior?.priority ?? 'P1',
|
|
890
|
+
tags,
|
|
891
|
+
provenance: prior?.provenance ?? 'explicit-requirement',
|
|
892
|
+
applicability: rawSc.tags.includes('@covered')
|
|
893
|
+
? 'covered'
|
|
894
|
+
: rawSc.tags.includes('@needs-clarification')
|
|
895
|
+
? 'needs_clarification'
|
|
896
|
+
: 'applicable',
|
|
897
|
+
preconditions,
|
|
898
|
+
steps: mergedSteps,
|
|
899
|
+
expectedOutcomes: stepsChanged ? (expectedOutcomes.length > 0 ? expectedOutcomes : prior?.expectedOutcomes ?? []) : (prior?.expectedOutcomes ?? expectedOutcomes),
|
|
900
|
+
evidenceRequirements: prior?.evidenceRequirements ?? [`live-ui:${category}`],
|
|
901
|
+
confidence: prior?.confidence ?? 0.9,
|
|
902
|
+
assumptions: prior?.assumptions ?? [],
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
if (!prior) {
|
|
906
|
+
addedScenarioIds.push(scenarioId);
|
|
907
|
+
} else if (
|
|
908
|
+
prior.title !== scenarioObj.title ||
|
|
909
|
+
stepsChanged ||
|
|
910
|
+
JSON.stringify(prior.tags) !== JSON.stringify(scenarioObj.tags)
|
|
911
|
+
) {
|
|
912
|
+
modifiedScenarioIds.push(scenarioId);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
rebuiltScenarios.push(scenarioObj);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (errors.length > 0) {
|
|
919
|
+
return {
|
|
920
|
+
success: false,
|
|
921
|
+
edited: false,
|
|
922
|
+
addedScenarioIds: [],
|
|
923
|
+
removedScenarioIds: [],
|
|
924
|
+
modifiedScenarioIds: [],
|
|
925
|
+
errors,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const removedScenarioIds = canonicalPlan.scenarios
|
|
930
|
+
.filter((s) => !seenScenarioIds.has(s.id))
|
|
931
|
+
.map((s) => s.id);
|
|
932
|
+
|
|
933
|
+
const background = parsedBackground.length > 0 ? parsedBackground : canonicalPlan.background;
|
|
934
|
+
const baseUpdated = {
|
|
935
|
+
planVersion: canonicalPlan.planVersion,
|
|
936
|
+
featureName: parsedFeatureName,
|
|
937
|
+
slug: toSlug(parsedFeatureName),
|
|
938
|
+
background,
|
|
939
|
+
inputs: canonicalPlan.inputs,
|
|
940
|
+
requirements: canonicalPlan.requirements,
|
|
941
|
+
scenarios: rebuiltScenarios,
|
|
942
|
+
unresolvedQuestions: canonicalPlan.unresolvedQuestions,
|
|
943
|
+
...(canonicalPlan.diffSummary ? { diffSummary: canonicalPlan.diffSummary } : {}),
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
const edited =
|
|
947
|
+
parsedFeatureName !== canonicalPlan.featureName ||
|
|
948
|
+
JSON.stringify(background) !== JSON.stringify(canonicalPlan.background) ||
|
|
949
|
+
addedScenarioIds.length > 0 ||
|
|
950
|
+
removedScenarioIds.length > 0 ||
|
|
951
|
+
modifiedScenarioIds.length > 0;
|
|
952
|
+
|
|
953
|
+
return {
|
|
954
|
+
success: true,
|
|
955
|
+
plan: {
|
|
956
|
+
...baseUpdated,
|
|
957
|
+
createdAt: canonicalPlan.createdAt,
|
|
958
|
+
planHash: computeWorkflowPlanHash(baseUpdated),
|
|
959
|
+
},
|
|
960
|
+
edited,
|
|
961
|
+
addedScenarioIds,
|
|
962
|
+
removedScenarioIds,
|
|
963
|
+
modifiedScenarioIds,
|
|
964
|
+
errors: [],
|
|
965
|
+
};
|
|
966
|
+
}
|