@arvorco/relentless 0.4.5 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/prd/parser.ts CHANGED
@@ -6,24 +6,37 @@
6
6
 
7
7
  import { PRDSchema, type PRD, type UserStory, type ExecutionHistory, type EscalationAttempt } from "./types";
8
8
  import type { Mode, HarnessName } from "../config/schema";
9
+ import { validateCriterion, type FilteredCriterion } from "./validator";
10
+
11
+ /**
12
+ * Parse warning for filtered criteria
13
+ */
14
+ export interface ParseWarning {
15
+ type: "filtered_criterion" | "format_normalized" | "dependency_format";
16
+ storyId: string;
17
+ text: string;
18
+ reason: string;
19
+ suggestion?: string;
20
+ line?: number;
21
+ }
22
+
23
+ /**
24
+ * Extended parse result with warnings
25
+ */
26
+ export interface ParseResult {
27
+ prd: Partial<PRD>;
28
+ warnings: ParseWarning[];
29
+ filteredCriteria: FilteredCriterion[];
30
+ }
9
31
 
10
32
  /**
11
33
  * Check if a criterion line is valid (not a file path, divider, etc.)
34
+ *
35
+ * Now uses the validator module for consistent filtering logic.
12
36
  */
13
37
  function isValidCriterion(text: string): boolean {
14
- // Skip if it looks like a file path
15
- if (text.match(/^`[^`]+\.(ts|tsx|js|jsx|css|json|md)`$/)) {
16
- return false;
17
- }
18
- // Skip if it's just a section marker
19
- if (text.startsWith("**")) {
20
- return false;
21
- }
22
- // Skip if it's empty or too short
23
- if (text.length < 3) {
24
- return false;
25
- }
26
- return true;
38
+ const result = validateCriterion(text);
39
+ return result.valid;
27
40
  }
28
41
 
29
42
  /**
@@ -248,6 +261,285 @@ export function parsePRDMarkdown(content: string): Partial<PRD> {
248
261
  return prd;
249
262
  }
250
263
 
264
+ /**
265
+ * Parse a PRD markdown file with detailed warnings about filtered content
266
+ *
267
+ * This version tracks all filtering decisions and provides actionable feedback.
268
+ */
269
+ export function parsePRDMarkdownWithWarnings(content: string): ParseResult {
270
+ const lines = content.split("\n");
271
+ const prd: Partial<PRD> = {
272
+ userStories: [],
273
+ };
274
+ const warnings: ParseWarning[] = [];
275
+ const filteredCriteria: FilteredCriterion[] = [];
276
+
277
+ let currentSection = "";
278
+ let currentStory: Partial<UserStory> | null = null;
279
+ let currentStoryId = "";
280
+ let storyCount = 0;
281
+ let inAcceptanceCriteria = false;
282
+ let descriptionLines: string[] = [];
283
+
284
+ for (let i = 0; i < lines.length; i++) {
285
+ const line = lines[i];
286
+ const trimmed = line.trim();
287
+ const lineNum = i + 1;
288
+
289
+ // Parse title (# PRD: Title or # Title)
290
+ if (trimmed.startsWith("# ") && !trimmed.startsWith("## ") && !trimmed.startsWith("### ")) {
291
+ prd.project = trimmed.replace(/^#\s*(PRD:\s*)?/, "").trim();
292
+ continue;
293
+ }
294
+
295
+ // Parse routing preference line
296
+ if (trimmed.match(/^(\*\*Routing Preference\*\*|Routing Preference):/i)) {
297
+ const raw = trimmed.replace(/^(\*\*Routing Preference\*\*|Routing Preference):/i, "").trim();
298
+ const lower = raw.toLowerCase();
299
+ const modeMatch = lower.match(/\b(free|cheap|good|genius)\b/);
300
+ const allowFreeMatch = lower.match(/allow\s+free:\s*(yes|no)/);
301
+ const harnessMatch = lower.match(/\b(claude|amp|opencode|codex|droid|gemini)\b/);
302
+ const modelMatch = raw.match(/\/([^\s]+)/);
303
+
304
+ prd.routingPreference = {
305
+ raw,
306
+ type: lower.includes("auto") ? "auto" : harnessMatch ? "harness" : undefined,
307
+ mode: modeMatch ? (modeMatch[1] as Mode) : undefined,
308
+ allowFree: allowFreeMatch ? allowFreeMatch[1] === "yes" : undefined,
309
+ harness: harnessMatch ? (harnessMatch[1] as HarnessName) : undefined,
310
+ model: modelMatch ? modelMatch[1].replace(/[,)]$/, "") : undefined,
311
+ };
312
+ continue;
313
+ }
314
+
315
+ // Parse section headers (## Section)
316
+ if (trimmed.startsWith("## ")) {
317
+ if (currentStory && currentStory.id) {
318
+ if (descriptionLines.length > 0 && !currentStory.description) {
319
+ currentStory.description = descriptionLines.join(" ").trim();
320
+ }
321
+ prd.userStories!.push(currentStory as UserStory);
322
+ currentStory = null;
323
+ descriptionLines = [];
324
+ }
325
+ currentSection = trimmed.replace("## ", "").toLowerCase();
326
+ inAcceptanceCriteria = false;
327
+ continue;
328
+ }
329
+
330
+ // Parse user stories
331
+ const storyMatch = trimmed.match(/^###\s+(?:US-(\d+)|Story\s+(\d+)|(\d+)\.?)\s*:?\s*(.*)$/i);
332
+ if (storyMatch) {
333
+ if (currentStory && currentStory.id) {
334
+ if (descriptionLines.length > 0 && !currentStory.description) {
335
+ currentStory.description = descriptionLines.join(" ").trim();
336
+ }
337
+ prd.userStories!.push(currentStory as UserStory);
338
+ }
339
+
340
+ storyCount++;
341
+ const storyNum = storyMatch[1] || storyMatch[2] || storyMatch[3] || String(storyCount);
342
+ currentStoryId = `US-${storyNum.padStart(3, "0")}`;
343
+
344
+ // Track format normalization
345
+ if (storyMatch[2]) {
346
+ warnings.push({
347
+ type: "format_normalized",
348
+ storyId: currentStoryId,
349
+ text: trimmed,
350
+ reason: `"Story ${storyMatch[2]}" format normalized to "${currentStoryId}"`,
351
+ line: lineNum,
352
+ });
353
+ } else if (storyMatch[3]) {
354
+ warnings.push({
355
+ type: "format_normalized",
356
+ storyId: currentStoryId,
357
+ text: trimmed,
358
+ reason: `Numbered format normalized to "${currentStoryId}"`,
359
+ line: lineNum,
360
+ });
361
+ }
362
+
363
+ currentStory = {
364
+ id: currentStoryId,
365
+ title: storyMatch[4]?.trim() || "",
366
+ description: "",
367
+ acceptanceCriteria: [],
368
+ priority: storyCount,
369
+ passes: false,
370
+ notes: "",
371
+ dependencies: undefined,
372
+ parallel: undefined,
373
+ phase: undefined,
374
+ };
375
+ inAcceptanceCriteria = false;
376
+ descriptionLines = [];
377
+ continue;
378
+ }
379
+
380
+ // Check for acceptance criteria section header
381
+ if (currentStory && trimmed.match(/^\*\*Acceptance Criteria:?\*\*$/i)) {
382
+ inAcceptanceCriteria = true;
383
+ continue;
384
+ }
385
+
386
+ // Parse story description
387
+ if (currentStory && trimmed.startsWith("**Description:**")) {
388
+ currentStory.description = trimmed.replace("**Description:**", "").trim();
389
+ inAcceptanceCriteria = false;
390
+ continue;
391
+ }
392
+
393
+ // Parse dependencies with format warnings
394
+ if (currentStory && trimmed.match(/^\*\*Dependencies:?\*\*/i)) {
395
+ const depsText = trimmed.replace(/^\*\*Dependencies:?\*\*/i, "").trim();
396
+ const deps = depsText
397
+ .split(/[,;]/)
398
+ .map((d) => {
399
+ // Check for underscore format and warn
400
+ const underscoreMatch = d.match(/US_(\d+)/i);
401
+ if (underscoreMatch) {
402
+ const normalized = `US-${underscoreMatch[1].padStart(3, "0")}`;
403
+ warnings.push({
404
+ type: "dependency_format",
405
+ storyId: currentStoryId,
406
+ text: d.trim(),
407
+ reason: `Dependency uses underscore format instead of dash`,
408
+ suggestion: `Use "${normalized}" instead of "${d.trim()}"`,
409
+ line: lineNum,
410
+ });
411
+ }
412
+
413
+ const match = d.match(/US[-_]?(\d+)/i);
414
+ return match ? `US-${match[1].padStart(3, "0")}` : null;
415
+ })
416
+ .filter((d): d is string => d !== null);
417
+ if (deps.length > 0) {
418
+ currentStory.dependencies = deps;
419
+ }
420
+ inAcceptanceCriteria = false;
421
+ continue;
422
+ }
423
+
424
+ // Parse parallel flag
425
+ if (currentStory && trimmed.match(/^\*\*Parallel:?\*\*/i)) {
426
+ const value = trimmed.replace(/^\*\*Parallel:?\*\*/i, "").trim().toLowerCase();
427
+ currentStory.parallel = value === "true" || value === "yes";
428
+ inAcceptanceCriteria = false;
429
+ continue;
430
+ }
431
+
432
+ // Parse phase
433
+ if (currentStory && trimmed.match(/^\*\*Phase:?\*\*/i)) {
434
+ const phase = trimmed.replace(/^\*\*Phase:?\*\*/i, "").trim();
435
+ if (phase) {
436
+ currentStory.phase = phase;
437
+ }
438
+ inAcceptanceCriteria = false;
439
+ continue;
440
+ }
441
+
442
+ // Parse research flag
443
+ if (currentStory && trimmed.match(/^\*\*Research:?\*\*/i)) {
444
+ const value = trimmed.replace(/^\*\*Research:?\*\*/i, "").trim().toLowerCase();
445
+ currentStory.research = value === "true" || value === "yes";
446
+ inAcceptanceCriteria = false;
447
+ continue;
448
+ }
449
+
450
+ // Check for section headers that end acceptance criteria
451
+ if (currentStory && trimmed.match(/^\*\*(Files|Note|Technical|Design)/i)) {
452
+ inAcceptanceCriteria = false;
453
+ continue;
454
+ }
455
+
456
+ // Parse acceptance criteria with filtering feedback
457
+ if (currentStory && trimmed.startsWith("-")) {
458
+ if (trimmed.match(/^-+$/)) {
459
+ inAcceptanceCriteria = false;
460
+ continue;
461
+ }
462
+
463
+ if (trimmed.match(/^-\s*\[.\]/)) {
464
+ const criterion = trimmed.replace(/^-\s*\[.\]\s*/, "").trim();
465
+ if (criterion) {
466
+ const validation = validateCriterion(criterion);
467
+ if (validation.valid) {
468
+ currentStory.acceptanceCriteria!.push(criterion);
469
+ } else {
470
+ filteredCriteria.push({
471
+ storyId: currentStoryId,
472
+ text: criterion,
473
+ reason: validation.reason!,
474
+ line: lineNum,
475
+ suggestion: validation.suggestion,
476
+ });
477
+ warnings.push({
478
+ type: "filtered_criterion",
479
+ storyId: currentStoryId,
480
+ text: criterion,
481
+ reason: validation.reason!,
482
+ suggestion: validation.suggestion,
483
+ line: lineNum,
484
+ });
485
+ }
486
+ }
487
+ inAcceptanceCriteria = true;
488
+ continue;
489
+ }
490
+
491
+ if (inAcceptanceCriteria) {
492
+ const criterion = trimmed.replace(/^-\s*/, "").trim();
493
+ if (criterion) {
494
+ const validation = validateCriterion(criterion);
495
+ if (validation.valid) {
496
+ currentStory.acceptanceCriteria!.push(criterion);
497
+ } else {
498
+ filteredCriteria.push({
499
+ storyId: currentStoryId,
500
+ text: criterion,
501
+ reason: validation.reason!,
502
+ line: lineNum,
503
+ suggestion: validation.suggestion,
504
+ });
505
+ warnings.push({
506
+ type: "filtered_criterion",
507
+ storyId: currentStoryId,
508
+ text: criterion,
509
+ reason: validation.reason!,
510
+ suggestion: validation.suggestion,
511
+ line: lineNum,
512
+ });
513
+ }
514
+ }
515
+ continue;
516
+ }
517
+ }
518
+
519
+ // Collect description lines
520
+ if (currentStory && !inAcceptanceCriteria && trimmed && !trimmed.startsWith("**") && !trimmed.startsWith("#")) {
521
+ descriptionLines.push(trimmed);
522
+ }
523
+
524
+ // Parse description if in introduction/overview section
525
+ if ((currentSection === "introduction" || currentSection === "overview") && !currentStory) {
526
+ if (trimmed && !prd.description && !trimmed.startsWith("**")) {
527
+ prd.description = trimmed;
528
+ }
529
+ }
530
+ }
531
+
532
+ // Save last story
533
+ if (currentStory && currentStory.id) {
534
+ if (descriptionLines.length > 0 && !currentStory.description) {
535
+ currentStory.description = descriptionLines.join(" ").trim();
536
+ }
537
+ prd.userStories!.push(currentStory as UserStory);
538
+ }
539
+
540
+ return { prd, warnings, filteredCriteria };
541
+ }
542
+
251
543
  /**
252
544
  * Generate branch name from project name
253
545
  */