@poltergeist-ai/cli 0.1.0 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -225,31 +225,31 @@ var LANG_MAP = {
225
225
  var JS_TS = ["typescript", "javascript"];
226
226
  var TS_ONLY = ["typescript"];
227
227
  var PATTERN_RULES = [
228
- // Imports
229
- { category: "import_style", choice: "named_import", pattern: /^import\s+\{/, languages: JS_TS },
230
- { category: "import_style", choice: "default_import", pattern: /^import\s+[A-Za-z_$][^\s{]*\s+from/, languages: JS_TS },
231
- { category: "import_style", choice: "path_alias", pattern: /from\s+['"][@~]\//, languages: JS_TS },
232
- { category: "import_style", choice: "relative_import", pattern: /from\s+['"]\.\.?\//, languages: JS_TS },
233
- // Exports
234
- { category: "export_style", choice: "named_export", pattern: /^export\s+(?:const|function|class|type|interface|enum)\s/, languages: JS_TS },
235
- { category: "export_style", choice: "default_export", pattern: /^export\s+default\b/, languages: JS_TS },
236
- { category: "export_style", choice: "re_export", pattern: /^export\s+\{[^}]*\}\s+from/, languages: JS_TS },
237
- // Functions
228
+ // Imports — typically project-level config (path aliases, import style)
229
+ { category: "import_style", choice: "named_import", pattern: /^import\s+\{/, languages: JS_TS, lintEnforceable: true },
230
+ { category: "import_style", choice: "default_import", pattern: /^import\s+[A-Za-z_$][^\s{]*\s+from/, languages: JS_TS, lintEnforceable: true },
231
+ { category: "import_style", choice: "path_alias", pattern: /from\s+['"][@~]\//, languages: JS_TS, lintEnforceable: true },
232
+ { category: "import_style", choice: "relative_import", pattern: /from\s+['"]\.\.?\//, languages: JS_TS, lintEnforceable: true },
233
+ // Exports — often enforced by barrel-file lint rules
234
+ { category: "export_style", choice: "named_export", pattern: /^export\s+(?:const|function|class|type|interface|enum)\s/, languages: JS_TS, lintEnforceable: true },
235
+ { category: "export_style", choice: "default_export", pattern: /^export\s+default\b/, languages: JS_TS, lintEnforceable: true },
236
+ { category: "export_style", choice: "re_export", pattern: /^export\s+\{[^}]*\}\s+from/, languages: JS_TS, lintEnforceable: true },
237
+ // Functions — genuine opinion, not typically lint-enforced
238
238
  { category: "function_style", choice: "arrow_function", pattern: /(?:const|let)\s+\w+\s*=\s*(?:async\s+)?\(/, languages: JS_TS },
239
239
  { category: "function_style", choice: "function_declaration", pattern: /^(?:export\s+)?(?:async\s+)?function\s+\w/, languages: JS_TS },
240
- // Async
240
+ // Async — genuine architectural choice
241
241
  { category: "async_style", choice: "async_await", pattern: /\bawait\s/, languages: [...JS_TS, "python"] },
242
242
  { category: "async_style", choice: "then_chain", pattern: /\.then\s*\(/, languages: JS_TS },
243
- // Control flow
243
+ // Control flow — genuine opinion about code structure
244
244
  { category: "control_flow", choice: "early_return", pattern: /^\s+if\s*\(.*\)\s*return\b/, languages: JS_TS },
245
245
  { category: "control_flow", choice: "guard_clause", pattern: /^\s+if\s*\(!/, languages: JS_TS },
246
- // Strings
247
- { category: "string_style", choice: "template_literal", pattern: /`[^`]*\$\{/, languages: JS_TS },
248
- // Modern operators
246
+ // Strings — often lint-enforced (prefer-template)
247
+ { category: "string_style", choice: "template_literal", pattern: /`[^`]*\$\{/, languages: JS_TS, lintEnforceable: true },
248
+ // Modern operators — genuine preference
249
249
  { category: "modern_operators", choice: "optional_chaining", pattern: /\?\.\w/, languages: JS_TS },
250
250
  { category: "modern_operators", choice: "nullish_coalescing", pattern: /\?\?/, languages: JS_TS },
251
251
  { category: "modern_operators", choice: "destructuring", pattern: /(?:const|let|var)\s+[\[{]/, languages: JS_TS },
252
- // TypeScript types
252
+ // TypeScript types — genuine opinion about modelling
253
253
  { category: "type_definition", choice: "interface", pattern: /^(?:export\s+)?interface\s+\w/, languages: TS_ONLY },
254
254
  { category: "type_definition", choice: "type_alias", pattern: /^(?:export\s+)?type\s+\w+\s*=/, languages: TS_ONLY },
255
255
  { category: "enum_vs_union", choice: "enum", pattern: /^(?:export\s+)?(?:const\s+)?enum\s+\w/, languages: TS_ONLY },
@@ -257,15 +257,15 @@ var PATTERN_RULES = [
257
257
  { category: "type_features", choice: "as_const", pattern: /\bas\s+const\b/, languages: TS_ONLY },
258
258
  { category: "type_features", choice: "generic_usage", pattern: /<[A-Z]\w*(?:,\s*[A-Z]\w*)*>/, languages: TS_ONLY },
259
259
  { category: "type_features", choice: "explicit_return_type", pattern: /\)\s*:\s*(?:Promise<|void|string|number|boolean|\w+\[\])/, languages: TS_ONLY },
260
- // Error handling
260
+ // Error handling — genuine architectural choice
261
261
  { category: "error_handling", choice: "try_catch", pattern: /^\s*(?:try\s*\{|\}\s*catch\s*\()/ },
262
262
  { category: "error_handling", choice: "custom_error", pattern: /class\s+\w+Error\s+extends/ },
263
- // Testing
263
+ // Testing — genuine opinion about test structure
264
264
  { category: "test_structure", choice: "describe_it", pattern: /\b(?:describe|it)\s*\(/, languages: JS_TS },
265
265
  { category: "test_structure", choice: "test_fn", pattern: /\btest\s*\(/, languages: JS_TS },
266
266
  { category: "test_assertion", choice: "expect", pattern: /\bexpect\s*\(/, languages: JS_TS },
267
267
  { category: "test_assertion", choice: "assert", pattern: /\bassert\.\w/, languages: [...JS_TS, "python"] },
268
- // Architecture
268
+ // Architecture — strong personal opinion territory
269
269
  { category: "composition_style", choice: "inheritance", pattern: /class\s+\w+\s+extends\s/, languages: JS_TS },
270
270
  { category: "composition_style", choice: "composition", pattern: /\buse[A-Z]\w+\s*\(/, languages: JS_TS },
271
271
  { category: "architecture", choice: "factory_function", pattern: /(?:create|make|build)[A-Z]\w+\s*\(/, languages: JS_TS },
@@ -274,7 +274,7 @@ var PATTERN_RULES = [
274
274
  { category: "python_style", choice: "type_hints", pattern: /def\s+\w+\(.*:\s*\w+/, languages: ["python"] },
275
275
  { category: "python_style", choice: "list_comprehension", pattern: /\[.*\bfor\b.*\bin\b.*\]/, languages: ["python"] },
276
276
  { category: "python_style", choice: "dataclass", pattern: /@dataclass/, languages: ["python"] },
277
- { category: "python_style", choice: "f_string", pattern: /f['"].*\{/, languages: ["python"] }
277
+ { category: "python_style", choice: "f_string", pattern: /f['"].*\{/, languages: ["python"], lintEnforceable: true }
278
278
  ];
279
279
  var CATEGORY_LABELS = {
280
280
  import_style: "Import Style",
@@ -377,17 +377,31 @@ function extractCodeStyleFromDiff(diffOutput) {
377
377
  }
378
378
  function summariseCodeStyle(signals) {
379
379
  const observations = [];
380
+ const categoryLintStatus = /* @__PURE__ */ new Map();
381
+ for (const rule of PATTERN_RULES) {
382
+ const current = categoryLintStatus.get(rule.category);
383
+ if (current === void 0) {
384
+ categoryLintStatus.set(rule.category, rule.lintEnforceable === true);
385
+ } else {
386
+ categoryLintStatus.set(
387
+ rule.category,
388
+ current && rule.lintEnforceable === true
389
+ );
390
+ }
391
+ }
380
392
  for (const [category, choices] of Object.entries(signals.counters)) {
381
393
  const entries = Object.entries(choices).sort((a, b) => b[1] - a[1]);
382
394
  const total = entries.reduce((sum, [, c]) => sum + c, 0);
383
395
  if (total < 3) continue;
384
396
  const [topChoice, topCount] = entries[0];
397
+ const isLintEnforceable = categoryLintStatus.get(category) ?? false;
385
398
  if (entries.length === 1) {
386
399
  if (topCount >= 5) {
387
400
  observations.push({
388
401
  category: CATEGORY_LABELS[category] ?? category,
389
402
  observation: `Frequently uses ${CHOICE_LABELS[topChoice] ?? topChoice} (${topCount} occurrences)`,
390
- confidence: topCount >= 15 ? "strong" : "moderate"
403
+ confidence: topCount >= 15 ? "strong" : "moderate",
404
+ lintEnforceable: isLintEnforceable
391
405
  });
392
406
  }
393
407
  continue;
@@ -401,7 +415,8 @@ function summariseCodeStyle(signals) {
401
415
  observations.push({
402
416
  category: CATEGORY_LABELS[category] ?? category,
403
417
  observation: `Prefers ${topLabel} over ${runnerLabel} (${pct}% of ${total})`,
404
- confidence
418
+ confidence,
419
+ lintEnforceable: isLintEnforceable
405
420
  });
406
421
  }
407
422
  observations.sort((a, b) => {
@@ -419,6 +434,347 @@ function summariseCodeStyle(signals) {
419
434
  import { readFileSync } from "fs";
420
435
 
421
436
  // src/extractors/review-common.ts
437
+ var THEME_DEFS = [
438
+ {
439
+ theme: "decomposition",
440
+ label: "Decomposition / single responsibility",
441
+ patterns: [
442
+ /\bextract\b.*\b(?:into|to|out)\b/,
443
+ /\bsplit\b.*\b(?:into|up|out)\b/,
444
+ /\bseparate\b/,
445
+ /\bsingle.?responsib/,
446
+ /\bdoing\s+(?:too\s+)?(?:much|many|multiple)\b/,
447
+ /\btoo\s+(?:big|large|long|complex)\b/,
448
+ /\bbreak\b.*\b(?:into|up|out|down)\b/,
449
+ /\bpull\b.*\bout\b/
450
+ ]
451
+ },
452
+ {
453
+ theme: "naming",
454
+ label: "Naming clarity",
455
+ patterns: [
456
+ /\brename\b/,
457
+ /\bnaming\b/,
458
+ /\bname\b.*\b(?:unclear|confusing|misleading|vague|generic|better)\b/,
459
+ /\bwhat\s+does\s+\w+\s+mean\b/,
460
+ /\bmore\s+descriptive\b/
461
+ ]
462
+ },
463
+ {
464
+ theme: "error_handling",
465
+ label: "Error handling / edge cases",
466
+ patterns: [
467
+ /\berror\s+handl/,
468
+ /\bwhat\s+(?:if|happens\s+(?:if|when))\b/,
469
+ /\bedge\s+case/,
470
+ /\bfail(?:s|ure|ing)?\b.*\b(?:silent|graceful|handle|catch)\b/,
471
+ /\bunhandled\b/,
472
+ /\bmissing\b.*\b(?:error|catch|check|validation)\b/,
473
+ /\bcatch\b.*\b(?:this|here|error)\b/
474
+ ]
475
+ },
476
+ {
477
+ theme: "testing",
478
+ label: "Testing / test coverage",
479
+ patterns: [
480
+ /\badd\b.*\btests?\b/,
481
+ /\btest\s+(?:for|this|that|coverage|case|missing)\b/,
482
+ /\buntested\b/,
483
+ /\bspec\b.*\b(?:for|missing|add)\b/,
484
+ /\bcoverage\b/,
485
+ /\bassert(?:ion)?\b.*\b(?:missing|add|should)\b/
486
+ ]
487
+ },
488
+ {
489
+ theme: "performance",
490
+ label: "Performance awareness",
491
+ patterns: [
492
+ /\bperformance\b/,
493
+ /\bslow\b/,
494
+ /\boptimiz/,
495
+ /\bcache\b/,
496
+ /\bmemory\b/,
497
+ /\bcomplexity\b/,
498
+ /\bn\s*\+\s*1\b/,
499
+ /\bunnecessary\s+(?:re-?render|iteration|loop|allocation|copy)\b/,
500
+ /\bexpensive\b/
501
+ ]
502
+ },
503
+ {
504
+ theme: "readability",
505
+ label: "Readability / clarity",
506
+ patterns: [
507
+ /\breadab/,
508
+ /\bclarity\b/,
509
+ /\bsimplif/,
510
+ /\bconfusing\b/,
511
+ /\bhard\s+to\s+(?:read|follow|understand|parse)\b/,
512
+ /\bcomplicated\b/,
513
+ /\bself[- ]?explanat/
514
+ ]
515
+ },
516
+ {
517
+ theme: "type_safety",
518
+ label: "Type safety / strictness",
519
+ patterns: [
520
+ /\btype\s+safe/,
521
+ /\b(?:use|avoid|remove)\b.*\bany\b/,
522
+ /\bnarrow\b.*\btype/,
523
+ /\btype\s+assert/,
524
+ /\bas\s+unknown\b/,
525
+ /\bstrict(?:er|ly)?\s+typ/,
526
+ /\binfer\b.*\btype/
527
+ ]
528
+ },
529
+ {
530
+ theme: "security",
531
+ label: "Security / input validation",
532
+ patterns: [
533
+ /\bsecur/,
534
+ /\bsaniti[sz]/,
535
+ /\bvalidat/,
536
+ /\binject/,
537
+ /\bescap(?:e|ing)\b/,
538
+ /\bauth(?:entication|orization)?\b/,
539
+ /\bxss\b/i
540
+ ]
541
+ },
542
+ {
543
+ theme: "api_design",
544
+ label: "API / interface design",
545
+ patterns: [
546
+ /\bapi\s+(?:design|surface|contract|boundary)\b/i,
547
+ /\bpublic\s+(?:api|interface|surface)\b/,
548
+ /\bbreaking\s+change/,
549
+ /\bbackwards?\s+compat/,
550
+ /\bconsumer\b/,
551
+ /\bcaller\b/
552
+ ]
553
+ },
554
+ {
555
+ theme: "consistency",
556
+ label: "Consistency with existing patterns",
557
+ patterns: [
558
+ /\bconsisten/,
559
+ /\belsewhere\s+(?:we|in)\b/,
560
+ /\bwe\s+(?:usually|always|typically|normally)\b/,
561
+ /\bexisting\s+(?:pattern|convention|approach|style)\b/,
562
+ /\bdoesn'?t\s+match\b/,
563
+ /\balign\b.*\bwith\b/,
564
+ /\bin\s+line\s+with\b/
565
+ ]
566
+ },
567
+ {
568
+ theme: "documentation",
569
+ label: "Documentation / comments",
570
+ patterns: [
571
+ /\bdocument\b/,
572
+ /\bdocstring\b/,
573
+ /\bjsdoc\b/,
574
+ /\bcomment\b.*\b(?:explain|why|add|missing)\b/,
575
+ /\bexplain\b.*\b(?:why|what|how)\b/,
576
+ /\breadme\b/i
577
+ ]
578
+ },
579
+ {
580
+ theme: "dependency_awareness",
581
+ label: "Dependency / coupling awareness",
582
+ patterns: [
583
+ /\bcoupl(?:ed|ing)\b/,
584
+ /\bdependen(?:cy|cies|t)\b/,
585
+ /\bcircular\b/,
586
+ /\bimport\b.*\b(?:heavy|large|unnecessary)\b/,
587
+ /\btight(?:ly)?\s+coupled\b/,
588
+ /\bdecouple\b/
589
+ ]
590
+ }
591
+ ];
592
+ var PRAISE_PATTERNS = [
593
+ /\bnice\b/,
594
+ /\bgreat\b/,
595
+ /\bgood\s+(?:call|catch|point|idea|work|job|stuff)\b/,
596
+ /\blove\s+(?:this|that|it|the)\b/,
597
+ /\bclean\b/,
598
+ /\belegant\b/,
599
+ /\blgtm\b/i,
600
+ /\bwell\s+done\b/,
601
+ /\bsolid\b/,
602
+ /\b(?:looks?\s+)?good\s+to\s+(?:me|go)\b/,
603
+ /\+1\b/,
604
+ /👍|🎉|✅/,
605
+ /\bneat\b/,
606
+ /\bawesome\b/
607
+ ];
608
+ var EXPLANATION_PATTERNS = [
609
+ /\bbecause\b/,
610
+ /\bsince\b/,
611
+ /\bthe\s+reason\b/,
612
+ /\bso\s+that\b/,
613
+ /\bthis\s+(?:ensures?|prevents?|avoids?|allows?|makes?)\b/,
614
+ /\botherwise\b/,
615
+ /\bthe\s+(?:problem|issue|risk|concern)\s+(?:is|here|with)\b/
616
+ ];
617
+ var SUGGESTIVE_PATTERNS = [
618
+ /\bconsider\b/,
619
+ /\bwhat\s+(?:about|if\s+we|do\s+you\s+think)\b/,
620
+ /\bmaybe\b/,
621
+ /\balternative(?:ly)?\b/,
622
+ /\bone\s+option\b/,
623
+ /\bcould\s+(?:we|you|this)\b/,
624
+ /\bhow\s+about\b/,
625
+ /\bwhat\s+(?:do\s+you|would\s+you)\s+think\b/,
626
+ /\bmight\s+(?:be|want)\b/,
627
+ /\bwould\s+(?:it\s+)?be\s+(?:better|cleaner|simpler|easier)\b/
628
+ ];
629
+ var DIRECTIVE_PATTERNS = [
630
+ /^(?:rename|remove|delete|drop|change|move|replace|use|add|fix|update|revert|undo)\b/i,
631
+ /\bplease\s+(?:rename|remove|delete|change|move|replace|use|add|fix|update|revert)\b/,
632
+ /\bshould\s+be\b/,
633
+ /\bneeds?\s+to\s+be\b/,
634
+ /\bmust\s+be\b/,
635
+ /\bdon'?t\b.*\bhere\b/,
636
+ /\bthis\s+(?:should|must|needs?)\b/
637
+ ];
638
+ function extractRecurringPhrases(comments) {
639
+ if (comments.length < 5) return [];
640
+ const phraseCounts = /* @__PURE__ */ new Map();
641
+ const MIN_PHRASE_LEN = 2;
642
+ const MAX_PHRASE_LEN = 5;
643
+ for (const comment of comments) {
644
+ const lower = comment.toLowerCase().replace(/[^\w\s]/g, " ");
645
+ const words = lower.split(/\s+/).filter((w) => w.length > 2);
646
+ const seenInComment = /* @__PURE__ */ new Set();
647
+ for (let start = 0; start < words.length; start++) {
648
+ for (let len = MIN_PHRASE_LEN; len <= MAX_PHRASE_LEN && start + len <= words.length; len++) {
649
+ const phrase = words.slice(start, start + len).join(" ");
650
+ if (!seenInComment.has(phrase)) {
651
+ seenInComment.add(phrase);
652
+ phraseCounts.set(phrase, (phraseCounts.get(phrase) ?? 0) + 1);
653
+ }
654
+ }
655
+ }
656
+ }
657
+ const minCount = Math.max(3, Math.floor(comments.length * 0.1));
658
+ const STOP_WORDS = /* @__PURE__ */ new Set([
659
+ "the",
660
+ "this",
661
+ "that",
662
+ "with",
663
+ "from",
664
+ "have",
665
+ "has",
666
+ "been",
667
+ "will",
668
+ "would",
669
+ "could",
670
+ "should",
671
+ "can",
672
+ "are",
673
+ "for",
674
+ "not",
675
+ "but",
676
+ "and",
677
+ "you",
678
+ "was",
679
+ "were",
680
+ "than",
681
+ "also",
682
+ "its",
683
+ "into",
684
+ "here",
685
+ "there",
686
+ "then",
687
+ "when",
688
+ "what",
689
+ "which",
690
+ "where",
691
+ "how"
692
+ ]);
693
+ return [...phraseCounts.entries()].filter(([phrase, count]) => {
694
+ if (count < minCount) return false;
695
+ const words = phrase.split(" ");
696
+ return words.some((w) => !STOP_WORDS.has(w));
697
+ }).sort((a, b) => b[1] - a[1]).slice(0, 15).map(([phrase]) => phrase);
698
+ }
699
+ function extractRecurringQuestions(comments) {
700
+ const questions = comments.filter(
701
+ (c) => c.includes("?") && c.length > 15 && c.length < 300
702
+ );
703
+ if (questions.length < 2) return questions.slice(0, 5);
704
+ const normalise = (q) => q.toLowerCase().replace(/[^\w\s?]/g, "").replace(/\s+/g, " ").trim();
705
+ const seen = /* @__PURE__ */ new Map();
706
+ for (const q of questions) {
707
+ const norm = normalise(q);
708
+ let isDupe = false;
709
+ for (const existing of seen.keys()) {
710
+ if (norm === existing || norm.length > 20 && existing.startsWith(norm.slice(0, 20))) {
711
+ isDupe = true;
712
+ break;
713
+ }
714
+ }
715
+ if (!isDupe) {
716
+ seen.set(norm, q);
717
+ }
718
+ }
719
+ return [...seen.values()].slice(0, 10);
720
+ }
721
+ function matchesAny(text, patterns) {
722
+ return patterns.some((p) => p.test(text));
723
+ }
724
+ function countMatching(comments, patterns) {
725
+ return comments.filter((c) => matchesAny(c.toLowerCase(), patterns)).length;
726
+ }
727
+ function sampleMatching(comments, patterns, max) {
728
+ const matches = [];
729
+ for (const c of comments) {
730
+ if (matchesAny(c.toLowerCase(), patterns)) {
731
+ matches.push(c.length > 200 ? c.slice(0, 200) + "..." : c);
732
+ if (matches.length >= max) break;
733
+ }
734
+ }
735
+ return matches;
736
+ }
737
+ function extractThemes(comments) {
738
+ if (comments.length === 0) return [];
739
+ const themes = [];
740
+ for (const def of THEME_DEFS) {
741
+ const matchingComments = [];
742
+ for (const comment of comments) {
743
+ const lower = comment.toLowerCase();
744
+ if (def.patterns.some((p) => p.test(lower))) {
745
+ matchingComments.push(comment);
746
+ }
747
+ }
748
+ if (matchingComments.length < 2) continue;
749
+ const ratio = Math.round(matchingComments.length / comments.length * 100) / 100;
750
+ const snippets = matchingComments.filter((c) => c.length > 20 && c.length < 300).slice(0, 3).map((c) => c.length > 150 ? c.slice(0, 150) + "..." : c);
751
+ themes.push({
752
+ theme: def.theme,
753
+ label: def.label,
754
+ count: matchingComments.length,
755
+ ratio,
756
+ exampleSnippets: snippets
757
+ });
758
+ }
759
+ themes.sort((a, b) => b.count - a.count);
760
+ return themes;
761
+ }
762
+ function buildToneProfile(comments) {
763
+ if (comments.length < 5) return void 0;
764
+ const n = comments.length;
765
+ const praiseCount = countMatching(comments, PRAISE_PATTERNS);
766
+ const explanationCount = countMatching(comments, EXPLANATION_PATTERNS);
767
+ const suggestiveCount = countMatching(comments, SUGGESTIVE_PATTERNS);
768
+ const directiveCount = countMatching(comments, DIRECTIVE_PATTERNS);
769
+ return {
770
+ praiseRatio: Math.round(praiseCount / n * 100) / 100,
771
+ explanationRatio: Math.round(explanationCount / n * 100) / 100,
772
+ suggestiveRatio: Math.round(suggestiveCount / n * 100) / 100,
773
+ directiveRatio: Math.round(directiveCount / n * 100) / 100,
774
+ praiseExamples: sampleMatching(comments, PRAISE_PATTERNS, 3),
775
+ explanationExamples: sampleMatching(comments, EXPLANATION_PATTERNS, 3)
776
+ };
777
+ }
422
778
  function summariseReview(signals) {
423
779
  const obs = { source: signals.source };
424
780
  const comments = signals.reviewComments;
@@ -446,6 +802,10 @@ function summariseReview(signals) {
446
802
  indices.filter((i) => i >= 0 && i < n).map((i) => sorted[i])
447
803
  )
448
804
  ];
805
+ obs.reviewThemes = extractThemes(comments);
806
+ obs.toneProfile = buildToneProfile(comments);
807
+ obs.recurringQuestions = extractRecurringQuestions(comments);
808
+ obs.recurringPhrases = extractRecurringPhrases(comments);
449
809
  return obs;
450
810
  }
451
811
 
@@ -747,6 +1107,16 @@ function dominantNamingStyle(style) {
747
1107
  if (style.PascalCase / total > 0.7) return "Strongly prefers PascalCase";
748
1108
  return null;
749
1109
  }
1110
+ function describeTone(profile) {
1111
+ const traits = [];
1112
+ if (profile.directiveRatio > 0.3) traits.push("direct");
1113
+ else if (profile.suggestiveRatio > 0.3) traits.push("collaborative");
1114
+ if (profile.explanationRatio > 0.3) traits.push("explanatory");
1115
+ if (profile.praiseRatio > 0.15) traits.push("encouraging");
1116
+ else if (profile.praiseRatio < 0.05) traits.push("critique-focused");
1117
+ if (traits.length === 0) traits.push("balanced");
1118
+ return traits.join(", ");
1119
+ }
750
1120
  function buildGhostMarkdown(input) {
751
1121
  const { contributor, slug, gitObs, codeStyleObs, reviewObs, slackObs, docsSignals, sourcesUsed } = input;
752
1122
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
@@ -765,23 +1135,37 @@ function buildGhostMarkdown(input) {
765
1135
  "",
766
1136
  "## Review Philosophy",
767
1137
  "",
768
- "### What they care about most (ranked)",
769
- "_[Fill in manually after reviewing the data below \u2014 re-order based on review comment patterns]_",
770
- "1. Correctness",
771
- "2. Naming",
772
- "3. Component / module boundaries",
773
- "4. Test coverage",
774
- "5. Consistency with existing patterns",
775
- "",
776
- "### What they tend to ignore",
777
- "_[Fill in manually]_",
778
- "",
779
- "### Dealbreakers",
780
- "_[Fill in manually]_",
781
- "",
782
- "### Recurring questions / phrases"
1138
+ "### What they care about most (ranked)"
783
1139
  ];
784
- if (reviewObs.sampleComments && reviewObs.sampleComments.length > 0) {
1140
+ const themes = reviewObs.reviewThemes;
1141
+ if (themes && themes.length > 0) {
1142
+ lines.push(
1143
+ `_Inferred from ${reviewObs.totalReviewComments ?? "?"} review comments \u2014 verify and re-order as needed_`
1144
+ );
1145
+ for (let i = 0; i < Math.min(themes.length, 8); i++) {
1146
+ const t = themes[i];
1147
+ lines.push(`${i + 1}. ${t.label} _(${t.count} comments, ${Math.round(t.ratio * 100)}%)_`);
1148
+ }
1149
+ } else {
1150
+ lines.push(
1151
+ "_[Fill in manually after reviewing the data below \u2014 re-order based on review comment patterns]_",
1152
+ "1. Correctness",
1153
+ "2. Naming",
1154
+ "3. Component / module boundaries",
1155
+ "4. Test coverage",
1156
+ "5. Consistency with existing patterns"
1157
+ );
1158
+ }
1159
+ lines.push("", "### What they tend to ignore", "_[Fill in manually]_");
1160
+ lines.push("", "### Dealbreakers", "_[Fill in manually]_");
1161
+ lines.push("", "### Recurring questions / phrases");
1162
+ const recurringQs = reviewObs.recurringQuestions;
1163
+ if (recurringQs && recurringQs.length > 0) {
1164
+ for (const q of recurringQs.slice(0, 8)) {
1165
+ const truncated = q.length > 150 ? q.slice(0, 150) + "..." : q;
1166
+ lines.push(`- "${truncated}"`);
1167
+ }
1168
+ } else if (reviewObs.sampleComments && reviewObs.sampleComments.length > 0) {
785
1169
  const questions = reviewObs.sampleComments.filter((c) => c.endsWith("?"));
786
1170
  if (questions.length > 0) {
787
1171
  for (const q of questions.slice(0, 5)) {
@@ -794,6 +1178,19 @@ function buildGhostMarkdown(input) {
794
1178
  } else {
795
1179
  lines.push("_[Fill in from sample comments below]_");
796
1180
  }
1181
+ if (themes && themes.length > 0) {
1182
+ lines.push("");
1183
+ lines.push("### Review concern examples");
1184
+ lines.push("_Verbatim excerpts grouped by detected theme \u2014 use these to validate the ranking above_");
1185
+ for (const t of themes.slice(0, 5)) {
1186
+ if (t.exampleSnippets.length === 0) continue;
1187
+ lines.push("");
1188
+ lines.push(`**${t.label}** (${t.count} comments):`);
1189
+ for (const snippet of t.exampleSnippets) {
1190
+ lines.push(`> ${snippet}`);
1191
+ }
1192
+ }
1193
+ }
797
1194
  lines.push("", "---", "", "## Communication Style", "");
798
1195
  if (reviewObs.totalReviewComments != null) {
799
1196
  lines.push(
@@ -808,9 +1205,28 @@ function buildGhostMarkdown(input) {
808
1205
  }
809
1206
  lines.push("");
810
1207
  }
1208
+ lines.push("### Tone");
1209
+ const tone = reviewObs.toneProfile;
1210
+ if (tone) {
1211
+ const toneDesc = describeTone(tone);
1212
+ lines.push(
1213
+ `${toneDesc} _(inferred from comment language)_`,
1214
+ "",
1215
+ `- Praise ratio: ${Math.round(tone.praiseRatio * 100)}% of comments include positive reinforcement`,
1216
+ `- Explanation ratio: ${Math.round(tone.explanationRatio * 100)}% of comments explain "why"`,
1217
+ `- Suggestive ratio: ${Math.round(tone.suggestiveRatio * 100)}% offer alternatives ("consider", "what about")`,
1218
+ `- Directive ratio: ${Math.round(tone.directiveRatio * 100)}% use direct imperatives ("rename this", "remove this")`
1219
+ );
1220
+ if (tone.praiseExamples.length > 0) {
1221
+ lines.push("", "**Praise examples:**");
1222
+ for (const ex of tone.praiseExamples) {
1223
+ lines.push(`> ${ex.length > 200 ? ex.slice(0, 200) + "..." : ex}`);
1224
+ }
1225
+ }
1226
+ } else {
1227
+ lines.push("_[Fill in manually \u2014 direct? warm? collaborative? terse?]_");
1228
+ }
811
1229
  lines.push(
812
- "### Tone",
813
- "_[Fill in manually \u2014 direct? warm? collaborative? terse?]_",
814
1230
  "",
815
1231
  "### Severity prefixes they use"
816
1232
  );
@@ -821,10 +1237,17 @@ function buildGhostMarkdown(input) {
821
1237
  } else {
822
1238
  lines.push("_[Derived from comments above \u2014 fill in which they actually use]_");
823
1239
  }
1240
+ lines.push("", "### Vocabulary / phrases they use");
1241
+ const phrases = reviewObs.recurringPhrases;
1242
+ if (phrases && phrases.length > 0) {
1243
+ lines.push("_Phrases that appear repeatedly across their review comments:_");
1244
+ for (const phrase of phrases.slice(0, 10)) {
1245
+ lines.push(`- "${phrase}"`);
1246
+ }
1247
+ } else {
1248
+ lines.push("_[Fill in from sample comments below]_");
1249
+ }
824
1250
  lines.push(
825
- "",
826
- "### Vocabulary / phrases they use",
827
- "_[Fill in from sample comments below]_",
828
1251
  "",
829
1252
  "---",
830
1253
  "",
@@ -865,15 +1288,18 @@ function buildGhostMarkdown(input) {
865
1288
  if (gitObs.primaryExtensions || gitObs.primaryDirectories || gitObs.namingStyle || gitObs.fileTypeProfile) {
866
1289
  lines.push("");
867
1290
  }
868
- if (codeStyleObs.observations.length > 0) {
1291
+ const personalObs = codeStyleObs.observations.filter((o) => !o.lintEnforceable);
1292
+ const lintObs = codeStyleObs.observations.filter((o) => o.lintEnforceable);
1293
+ if (personalObs.length > 0) {
869
1294
  lines.push("### Detected Code Style Preferences");
1295
+ lines.push("_These reflect genuine contributor choices, not linter-enforced rules_");
870
1296
  if (codeStyleObs.primaryLanguage) {
871
1297
  lines.push(
872
1298
  `_Primary language: ${codeStyleObs.primaryLanguage} \xB7 ${codeStyleObs.totalLinesAnalyzed} added lines analyzed_`
873
1299
  );
874
1300
  }
875
1301
  lines.push("");
876
- for (const obs of codeStyleObs.observations) {
1302
+ for (const obs of personalObs) {
877
1303
  if (obs.confidence === "strong") {
878
1304
  lines.push(`- **${obs.category}**: ${obs.observation}`);
879
1305
  } else {
@@ -882,6 +1308,15 @@ function buildGhostMarkdown(input) {
882
1308
  }
883
1309
  lines.push("");
884
1310
  }
1311
+ if (lintObs.length > 0) {
1312
+ lines.push("### Project-Level Patterns (likely lint-enforced)");
1313
+ lines.push("_These patterns are typically enforced by project linters/formatters \u2014 they describe the project, not the person_");
1314
+ lines.push("");
1315
+ for (const obs of lintObs) {
1316
+ lines.push(`- ~${obs.category}: ${obs.observation}~`);
1317
+ }
1318
+ lines.push("");
1319
+ }
885
1320
  lines.push(
886
1321
  "### Patterns they introduce / prefer"
887
1322
  );
@@ -889,10 +1324,23 @@ function buildGhostMarkdown(input) {
889
1324
  if (namingInsight) {
890
1325
  lines.push(`- ${namingInsight}`);
891
1326
  }
1327
+ if (personalObs.length > 0) {
1328
+ const strong = personalObs.filter((o) => o.confidence === "strong");
1329
+ for (const obs of strong.slice(0, 3)) {
1330
+ lines.push(`- ${obs.observation} _(inferred from code)_`);
1331
+ }
1332
+ }
892
1333
  lines.push(
893
1334
  "_[Fill in manually from code inspection and review comment themes]_",
894
1335
  "",
895
- "### Patterns they push back on",
1336
+ "### Patterns they push back on"
1337
+ );
1338
+ if (themes && themes.length > 0) {
1339
+ for (const t of themes.slice(0, 3)) {
1340
+ lines.push(`- Violations of ${t.label.toLowerCase()} _(${t.count} review comments)_`);
1341
+ }
1342
+ }
1343
+ lines.push(
896
1344
  "_[Fill in manually]_",
897
1345
  "",
898
1346
  "---",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poltergeist-ai/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.6",
4
4
  "description": "Build contributor ghost profiles from git, GitLab, Slack, and docs for simulated code reviews",
5
5
  "license": "MIT",
6
6
  "type": "module",