delivery-friction-analyzer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1301 @@
1
+ export const FRICTION_REPORT_VERSION = "friction-report.v1";
2
+
3
+ const BOT_SOURCES = new Set(["copilot", "github_actions_bot", "dependency_bot", "code_scanning", "unknown_bot"]);
4
+ const LOW_SIGNAL_ROLES = new Set([
5
+ "fixtures",
6
+ "generated_docs",
7
+ "generated_or_vendored",
8
+ "marketing_site",
9
+ "planning_docs",
10
+ "release_notes",
11
+ ]);
12
+
13
+ const RECOMMENDATION_CATEGORIES = [
14
+ {
15
+ id: "hooks",
16
+ label: "Hooks",
17
+ description: "Local hooks for repeated formatting, lint, typecheck, snapshot, or generated-output churn.",
18
+ },
19
+ {
20
+ id: "preflight_scripts",
21
+ label: "Preflight scripts",
22
+ description: "Local commands that catch CI or workflow failures before pushing.",
23
+ },
24
+ {
25
+ id: "repo_specific_ai_skills",
26
+ label: "Repo-specific AI skills",
27
+ description: "Repository guidance for repeated review themes around architecture, tests, docs, or unsafe APIs.",
28
+ },
29
+ {
30
+ id: "pr_readiness_gate",
31
+ label: "PR readiness gates",
32
+ description: "Review-before-review checks for scope, tests, descriptions, and evidence.",
33
+ },
34
+ {
35
+ id: "smaller_milestones",
36
+ label: "Smaller milestones",
37
+ description: "Smaller delivery slices for broad, unstable, or cross-surface changes.",
38
+ },
39
+ {
40
+ id: "planning_artifacts",
41
+ label: "Planning artifacts",
42
+ description: "Durable product or architecture notes when requirement or scope signals dominate.",
43
+ },
44
+ {
45
+ id: "test_infrastructure",
46
+ label: "Test infrastructure",
47
+ description: "Validation infrastructure when recurring failures or missing coverage create delivery loops.",
48
+ },
49
+ ];
50
+
51
+ const RECOMMENDATION_CATEGORY_LABELS = new Map(
52
+ RECOMMENDATION_CATEGORIES.map(category => [category.id, category.label]),
53
+ );
54
+
55
+ const RANKING_SIGNAL_LABELS = new Map([
56
+ ["reviewChurn", "review churn"],
57
+ ["changedFileSpread", "changed-file spread"],
58
+ ["validationGap", "validation gap"],
59
+ ["planningGap", "planning gap"],
60
+ ["reviewSurprise", "review surprise"],
61
+ ["fixAmplification", "fix amplification"],
62
+ ]);
63
+
64
+ const BOTTLENECK_DEFINITIONS = [
65
+ {
66
+ id: "review-churn",
67
+ rankingKey: "reviewChurn",
68
+ title: "Review churn",
69
+ metricLabel: "iteration drag",
70
+ recommendationCategory: "pr_readiness_gate",
71
+ action: "Add or tighten a PR readiness gate for changes that attract repeated review rounds.",
72
+ diagnosis: "Review loops are concentrated in a small set of PRs.",
73
+ },
74
+ {
75
+ id: "changed-file-spread",
76
+ rankingKey: "changedFileSpread",
77
+ title: "Changed-file spread",
78
+ metricLabel: "spread score",
79
+ recommendationCategory: "smaller_milestones",
80
+ action: "Break broad changes into smaller milestones when core files, directories, or surfaces spread out.",
81
+ diagnosis: "Broad file and surface spread can hide review and validation risk.",
82
+ },
83
+ {
84
+ id: "validation-gap",
85
+ rankingKey: "validationGap",
86
+ title: "Validation gap",
87
+ metricLabel: "validation gap score",
88
+ recommendationCategory: "preflight_scripts",
89
+ action: "Add local preflight scripts for recurring CI or workflow interruptions.",
90
+ diagnosis: "Validation friction appears where checks, workflows, or cancellations add corrective loops.",
91
+ },
92
+ {
93
+ id: "repo-guidance-gap",
94
+ rankingKey: "reviewChurn",
95
+ title: "Repo guidance gap",
96
+ metricLabel: "iteration drag",
97
+ recommendationCategory: "repo_specific_ai_skills",
98
+ action: "Add repo-specific AI skills or instructions for repeated review themes before opening the next PR.",
99
+ diagnosis: "Repeated review loops suggest some repository expectations are not yet available at implementation time.",
100
+ },
101
+ {
102
+ id: "local-hook-gap",
103
+ rankingKey: "validationGap",
104
+ title: "Local hook gap",
105
+ metricLabel: "validation gap score",
106
+ recommendationCategory: "hooks",
107
+ action: "Add or improve local hooks for recurring formatting, lint, typecheck, snapshot, or generated-output churn.",
108
+ diagnosis: "Validation signals point to checks that may be cheaper to catch before a branch reaches CI.",
109
+ },
110
+ {
111
+ id: "test-infrastructure-gap",
112
+ rankingKey: "validationGap",
113
+ title: "Test infrastructure gap",
114
+ metricLabel: "validation gap score",
115
+ recommendationCategory: "test_infrastructure",
116
+ action: "Invest in test infrastructure when recurring CI or workflow signals are a primary delivery loop.",
117
+ diagnosis: "Validation friction may indicate a missing or inconvenient local safety net.",
118
+ },
119
+ {
120
+ id: "planning-gap",
121
+ rankingKey: "planningGap",
122
+ title: "Planning gap",
123
+ metricLabel: "planning gap score",
124
+ recommendationCategory: "planning_artifacts",
125
+ action: "Improve planning artifacts when planning or scope files are part of high-friction changes.",
126
+ diagnosis: "Planning-related changes show up in the same PRs as delivery friction.",
127
+ },
128
+ {
129
+ id: "review-surprise",
130
+ rankingKey: "reviewSurprise",
131
+ title: "Review surprise",
132
+ metricLabel: "surface surprise score",
133
+ recommendationCategory: "pr_readiness_gate",
134
+ action: "Call out multi-surface scope in the PR description or split cross-surface work.",
135
+ diagnosis: "Changes spanning several functional surfaces are more likely to surprise reviewers.",
136
+ },
137
+ {
138
+ id: "fix-amplification",
139
+ rankingKey: "fixAmplification",
140
+ title: "Fix amplification",
141
+ metricLabel: "post-review commits",
142
+ recommendationCategory: "smaller_milestones",
143
+ action: "Use smaller delivery slices when review feedback causes meaningful post-review change.",
144
+ diagnosis: "Post-review commits show where initial PR shape did not stay stable.",
145
+ },
146
+ ];
147
+
148
+ function sumObjectValues(object = {}) {
149
+ return Object.values(object).reduce((sum, value) => sum + Number(value ?? 0), 0);
150
+ }
151
+
152
+ function addInto(target, source = {}) {
153
+ for (const [key, value] of Object.entries(source)) {
154
+ target[key] = (target[key] ?? 0) + Number(value ?? 0);
155
+ }
156
+ return target;
157
+ }
158
+
159
+ function sortedEntries(object = {}) {
160
+ return Object.entries(object)
161
+ .sort(([leftKey, leftValue], [rightKey, rightValue]) => {
162
+ const delta = Number(rightValue ?? 0) - Number(leftValue ?? 0);
163
+ return delta || leftKey.localeCompare(rightKey);
164
+ })
165
+ .map(([name, value]) => ({ name, value }));
166
+ }
167
+
168
+ function nonZeroEntries(object = {}) {
169
+ return sortedEntries(object).filter(entry => entry.value > 0);
170
+ }
171
+
172
+ function hasObservedReviewDecision(reviewDecision = {}) {
173
+ return (reviewDecision.source ?? "unavailable") !== "unavailable"
174
+ && (reviewDecision.state ?? "unavailable") !== "unavailable";
175
+ }
176
+
177
+ function formatObservedCount(value, observed) {
178
+ return observed ? String(value ?? 0) : "unavailable";
179
+ }
180
+
181
+ function formatObservedBoolean(value, observed) {
182
+ if (!observed) return "unavailable";
183
+ return value ? "yes" : "no";
184
+ }
185
+
186
+ function roundShare(value) {
187
+ return Math.round(value * 1000) / 1000;
188
+ }
189
+
190
+ function percentageLabel(share) {
191
+ return `${Math.round(Number(share ?? 0) * 100)}%`;
192
+ }
193
+
194
+ function classDominancePercentageLabel(share) {
195
+ const value = Number(share ?? 0);
196
+ const roundedWholePercent = Math.round(value * 100);
197
+ return value > 0.5 && roundedWholePercent <= 50
198
+ ? `${(value * 100).toFixed(1)}%`
199
+ : `${roundedWholePercent}%`;
200
+ }
201
+
202
+ function prClassSummary(pr = {}) {
203
+ return {
204
+ class: pr.prClass?.class ?? "unknown",
205
+ classificationSource: pr.prClass?.classificationSource ?? "fallback_rule",
206
+ ruleId: pr.prClass?.ruleId ?? null,
207
+ };
208
+ }
209
+
210
+ function findPullRequest(metricsSummary, number) {
211
+ return (metricsSummary.pullRequests ?? []).find(pr => pr.number === number);
212
+ }
213
+
214
+ function formatPr(pr, rankingEntry) {
215
+ const allCommentSources = nonZeroEntries(pr?.review?.comments?.bySource);
216
+ const commentSources = allCommentSources.slice(0, 5);
217
+ const workflowRunConclusions = nonZeroEntries(pr?.ci?.workflowRuns?.conclusions);
218
+ const botComments = allCommentSources
219
+ .filter(entry => BOT_SOURCES.has(entry.name))
220
+ .reduce((sum, entry) => sum + entry.value, 0);
221
+ const reviewDecision = pr?.review?.decision ?? {};
222
+
223
+ return {
224
+ number: rankingEntry.number,
225
+ title: rankingEntry.title,
226
+ url: pr?.url ?? null,
227
+ value: rankingEntry.value,
228
+ prClass: prClassSummary(pr),
229
+ additions: pr?.diffAtMerge?.additions ?? null,
230
+ deletions: pr?.diffAtMerge?.deletions ?? null,
231
+ changedFiles: pr?.diffAtMerge?.changedFiles ?? null,
232
+ changedLines: pr?.diffAtMerge?.changedLines ?? null,
233
+ reviewThreads: pr?.review?.threads?.totalCount ?? null,
234
+ functionalSurfaces: pr?.files?.functionalSurfaces ?? null,
235
+ coreChangedLines: pr?.files?.coreChangedLines ?? null,
236
+ lowSignalFiles: pr?.files?.lowSignalFiles ?? null,
237
+ validationEvidence: {
238
+ workflowRunSource: pr?.ci?.workflowRuns?.source ?? "unavailable",
239
+ workflowRunCoverage: pr?.ci?.workflowRuns?.coverage ?? "unavailable",
240
+ workflowRunConclusions,
241
+ failedCheckRuns: pr?.ci?.checkRuns?.failedCount ?? 0,
242
+ failedWorkflowRuns: pr?.ci?.workflowRuns?.failedCount ?? 0,
243
+ cancelledWorkflowRuns: pr?.ci?.workflowRuns?.cancelledCount ?? 0,
244
+ },
245
+ reviewEvidence: {
246
+ reviewThreadSource: pr?.review?.threads?.source ?? "unavailable",
247
+ reviewThreads: pr?.review?.threads?.totalCount ?? 0,
248
+ resolvedThreads: pr?.review?.threads?.resolvedCount ?? 0,
249
+ outdatedThreads: pr?.review?.threads?.outdatedCount ?? 0,
250
+ reviewDecision: reviewDecision.state ?? "unavailable",
251
+ humanReviewerCount: reviewDecision.humanReviewerCount ?? 0,
252
+ humanApproved: reviewDecision.humanApproved ?? false,
253
+ humanChangesRequested: reviewDecision.humanChangesRequested ?? false,
254
+ reviewDecisionSource: reviewDecision.source ?? "unavailable",
255
+ commentSources,
256
+ botComments,
257
+ humanReviewerComments: pr?.review?.comments?.bySource?.human_reviewer ?? 0,
258
+ authorReplies: pr?.review?.comments?.bySource?.author_reply ?? 0,
259
+ },
260
+ };
261
+ }
262
+
263
+ function summarizePrClasses(metricsSummary) {
264
+ const totalsByClass = new Map();
265
+ const pullRequests = metricsSummary.pullRequests ?? [];
266
+
267
+ for (const pr of pullRequests) {
268
+ const prClass = prClassSummary(pr);
269
+ const current = totalsByClass.get(prClass.class) ?? {
270
+ class: prClass.class,
271
+ pullRequests: 0,
272
+ changedLines: 0,
273
+ nonGeneratedChangedLines: 0,
274
+ classificationSources: {},
275
+ };
276
+ current.pullRequests += 1;
277
+ current.changedLines += Number(pr.diffAtMerge?.changedLines ?? 0);
278
+ current.nonGeneratedChangedLines += Number(pr.files?.nonGeneratedChangedLines ?? 0);
279
+ current.classificationSources[prClass.classificationSource] =
280
+ (current.classificationSources[prClass.classificationSource] ?? 0) + 1;
281
+ totalsByClass.set(prClass.class, current);
282
+ }
283
+
284
+ const totalPullRequests = pullRequests.length;
285
+ const distribution = [...totalsByClass.values()]
286
+ .map(entry => ({
287
+ ...entry,
288
+ share: totalPullRequests > 0 ? roundShare(entry.pullRequests / totalPullRequests) : 0,
289
+ classificationSources: sortedEntries(entry.classificationSources),
290
+ }))
291
+ .sort((left, right) => {
292
+ const countDelta = right.pullRequests - left.pullRequests;
293
+ const lineDelta = right.changedLines - left.changedLines;
294
+ return countDelta || lineDelta || left.class.localeCompare(right.class);
295
+ });
296
+
297
+ return {
298
+ totalPullRequests,
299
+ distribution,
300
+ note: distribution.length
301
+ ? prClassContextNote(metricsSummary.analysisFilter)
302
+ : "No PR class evidence was available.",
303
+ };
304
+ }
305
+
306
+ function prClassContextNote(analysisFilter) {
307
+ return analysisFilter?.excludedPrClasses?.length
308
+ ? "PR class filtering was explicitly applied before metrics and ranking; this distribution describes the filtered sample."
309
+ : "PR classes are repository-profile evidence for interpretation only; they do not change rankings or exclude PRs.";
310
+ }
311
+
312
+ function topEvidence(metricsSummary, rankingKey) {
313
+ const ranking = metricsSummary.rankings?.[rankingKey] ?? [];
314
+ const positiveEntries = ranking.filter(entry => Number(entry.value ?? 0) > 0);
315
+ const fallbackEntries = ranking.filter(entry => entry.value === null
316
+ || entry.value === undefined
317
+ || Number(entry.value) <= 0);
318
+ const displayedEntries = positiveEntries.length ? positiveEntries : fallbackEntries;
319
+
320
+ return displayedEntries
321
+ .slice(0, 3)
322
+ .map(entry => formatPr(findPullRequest(metricsSummary, entry.number), entry));
323
+ }
324
+
325
+ function summarizeCommentSources(metricsSummary) {
326
+ const bySource = {};
327
+ for (const pr of metricsSummary.pullRequests ?? []) {
328
+ addInto(bySource, pr.review?.comments?.bySource);
329
+ }
330
+ const sourceEntries = sortedEntries(bySource);
331
+ const botComments = sourceEntries
332
+ .filter(entry => BOT_SOURCES.has(entry.name))
333
+ .reduce((sum, entry) => sum + entry.value, 0);
334
+ const humanComments = sourceEntries
335
+ .filter(entry => entry.name === "human_reviewer")
336
+ .reduce((sum, entry) => sum + entry.value, 0);
337
+
338
+ return {
339
+ totalComments: sumObjectValues(bySource),
340
+ bySource: sourceEntries,
341
+ botComments,
342
+ humanComments,
343
+ authorReplies: bySource.author_reply ?? 0,
344
+ dominantSource: sourceEntries[0] ?? { name: "none", value: 0 },
345
+ };
346
+ }
347
+
348
+ function summarizeSurfaces(metricsSummary) {
349
+ const byFunctionalSurface = {};
350
+ const byRole = {};
351
+ let coreChangedLines = 0;
352
+ let lowSignalFiles = 0;
353
+ let weightedChangedLines = 0;
354
+ let smallDiffWideSpreadCount = 0;
355
+
356
+ for (const pr of metricsSummary.pullRequests ?? []) {
357
+ addInto(byFunctionalSurface, pr.files?.byFunctionalSurface);
358
+ addInto(byRole, pr.files?.byRole);
359
+ coreChangedLines += Number(pr.files?.coreChangedLines ?? 0);
360
+ lowSignalFiles += Number(pr.files?.lowSignalFiles ?? 0);
361
+ weightedChangedLines += Number(pr.files?.weightedChangedLines ?? 0);
362
+ if (pr.files?.smallDiffWideSpread) smallDiffWideSpreadCount += 1;
363
+ }
364
+
365
+ const lowSignalChangedLines = Object.entries(byRole)
366
+ .filter(([role]) => LOW_SIGNAL_ROLES.has(role))
367
+ .reduce((sum, [, value]) => sum + Number(value ?? 0), 0);
368
+
369
+ return {
370
+ coreChangedLines,
371
+ lowSignalChangedLines,
372
+ lowSignalFiles,
373
+ weightedChangedLines: Math.round(weightedChangedLines * 100) / 100,
374
+ smallDiffWideSpreadCount,
375
+ byFunctionalSurface: sortedEntries(byFunctionalSurface),
376
+ byRole: sortedEntries(byRole),
377
+ };
378
+ }
379
+
380
+ function summarizeCoverage(metricsSummary) {
381
+ const prOpenDiff = {};
382
+ const workflowRuns = {};
383
+ const reviewThreads = {};
384
+
385
+ for (const pr of metricsSummary.pullRequests ?? []) {
386
+ const prOpenStatus = pr.coverage?.prOpenDiff?.status ?? "unavailable";
387
+ const workflowStatus = pr.coverage?.workflowRuns?.status ?? "unavailable";
388
+ const reviewThreadSource = pr.coverage?.reviewThreads?.source ?? "unavailable";
389
+ prOpenDiff[prOpenStatus] = (prOpenDiff[prOpenStatus] ?? 0) + 1;
390
+ workflowRuns[workflowStatus] = (workflowRuns[workflowStatus] ?? 0) + 1;
391
+ reviewThreads[reviewThreadSource] = (reviewThreads[reviewThreadSource] ?? 0) + 1;
392
+ }
393
+
394
+ const notes = [];
395
+ if (prOpenDiff.unavailable) {
396
+ notes.push(
397
+ "PR-open diff growth is unavailable for PRs without captured or reconstructed open-time snapshots; it is not inferred from merge-time data.",
398
+ );
399
+ }
400
+ if (workflowRuns.unavailable) {
401
+ notes.push("Workflow-run coverage is unavailable for some PRs, often because branch-based history is missing.");
402
+ }
403
+
404
+ return {
405
+ prOpenDiff,
406
+ workflowRuns,
407
+ reviewThreads,
408
+ notes,
409
+ };
410
+ }
411
+
412
+ function summarizeBottlenecks(metricsSummary, prClasses = summarizePrClasses(metricsSummary)) {
413
+ return BOTTLENECK_DEFINITIONS
414
+ .map((definition, definitionIndex) => {
415
+ const evidence = topEvidence(metricsSummary, definition.rankingKey);
416
+ const dominance = summarizeEvidenceDominance(evidence);
417
+ const classDominance = summarizeEvidenceClassDominance(evidence, prClasses);
418
+ return {
419
+ definitionIndex,
420
+ rankValue: evidence[0]?.value ?? 0,
421
+ id: definition.id,
422
+ rankingKey: definition.rankingKey,
423
+ title: definition.title,
424
+ metricLabel: definition.metricLabel,
425
+ observedData: evidence,
426
+ dominance,
427
+ classDominance,
428
+ inferredDiagnosis: definition.diagnosis,
429
+ suggestedAction: {
430
+ category: definition.recommendationCategory,
431
+ action: definition.action,
432
+ },
433
+ };
434
+ })
435
+ .filter(bottleneck => bottleneck.observedData.length > 0)
436
+ .sort((left, right) => {
437
+ const delta = right.rankValue - left.rankValue;
438
+ return delta || left.definitionIndex - right.definitionIndex;
439
+ })
440
+ .map(({ definitionIndex, rankValue, ...bottleneck }) => bottleneck);
441
+ }
442
+
443
+ function summarizeEvidenceDominance(evidence) {
444
+ const values = evidence
445
+ .map(entry => Number(entry.value ?? 0))
446
+ .filter(value => value > 0);
447
+ const total = values.reduce((sum, value) => sum + value, 0);
448
+
449
+ if (values.length < 2 || total === 0) {
450
+ return {
451
+ status: "not_applicable",
452
+ topPrNumber: evidence[0]?.number ?? null,
453
+ topShare: null,
454
+ note: "Not enough positive examples to evaluate outlier dominance.",
455
+ };
456
+ }
457
+
458
+ const topValue = values[0];
459
+ const rawTopShare = topValue / total;
460
+ const topShare = Math.round(rawTopShare * 1000) / 1000;
461
+ const status = rawTopShare > 0.5 ? "single_pr_dominates" : "distributed";
462
+ return {
463
+ status,
464
+ topPrNumber: evidence[0]?.number ?? null,
465
+ topShare,
466
+ note: status === "single_pr_dominates"
467
+ ? `PR #${evidence[0].number} contributes ${Math.round(topShare * 100)}% of the displayed signal; inspect raw evidence before generalizing.`
468
+ : "Displayed examples are not dominated by one PR.",
469
+ };
470
+ }
471
+
472
+ function summarizeEvidenceClassDominance(evidence, prClasses) {
473
+ const classDistribution = prClasses?.distribution ?? [];
474
+ const sampleClasses = classDistribution.filter(entry => entry.pullRequests > 0);
475
+
476
+ if (!evidence?.length) {
477
+ return {
478
+ status: "not_applicable",
479
+ class: null,
480
+ topShare: null,
481
+ basis: null,
482
+ note: "No displayed examples were available to evaluate PR class dominance.",
483
+ };
484
+ }
485
+
486
+ if (sampleClasses.length < 2) {
487
+ return {
488
+ status: "not_applicable",
489
+ class: sampleClasses[0]?.class ?? null,
490
+ topShare: null,
491
+ basis: null,
492
+ note: "Only one PR class appears in the analyzed sample; class dominance is not meaningful.",
493
+ };
494
+ }
495
+
496
+ const positiveValueTotal = evidence
497
+ .map(entry => Number(entry.value ?? 0))
498
+ .filter(value => value > 0)
499
+ .reduce((sum, value) => sum + value, 0);
500
+ const basis = positiveValueTotal > 0 ? "score_value" : "displayed_example_count";
501
+ const totalsByClass = new Map();
502
+
503
+ for (const entry of evidence) {
504
+ const className = entry.prClass?.class ?? "unknown";
505
+ const current = totalsByClass.get(className) ?? {
506
+ class: className,
507
+ value: 0,
508
+ displayedExamples: 0,
509
+ };
510
+ current.displayedExamples += 1;
511
+ current.value += basis === "score_value" ? Math.max(0, Number(entry.value ?? 0)) : 1;
512
+ totalsByClass.set(className, current);
513
+ }
514
+
515
+ const totalValue = [...totalsByClass.values()].reduce((sum, entry) => sum + entry.value, 0);
516
+ if (totalValue === 0) {
517
+ return {
518
+ status: "not_applicable",
519
+ class: null,
520
+ topShare: null,
521
+ basis,
522
+ note: "Displayed examples had no class contribution value to evaluate.",
523
+ };
524
+ }
525
+
526
+ const [topClass] = [...totalsByClass.values()]
527
+ .sort((left, right) => {
528
+ const valueDelta = right.value - left.value;
529
+ return valueDelta || left.class.localeCompare(right.class);
530
+ });
531
+ const rawTopShare = topClass.value / totalValue;
532
+ const topShare = roundShare(rawTopShare);
533
+ const samplePullRequests = classDistribution.find(entry => entry.class === topClass.class)?.pullRequests ?? 0;
534
+ const smallSampleNote = samplePullRequests > 0 && samplePullRequests < 3
535
+ ? ` The ${topClass.class} class has ${samplePullRequests} PRs in the analyzed sample, so treat this as a small-sample caveat.`
536
+ : "";
537
+ const basisLabel = basis === "score_value" ? "displayed score value" : "displayed example count";
538
+ const status = topShare > 0.5 ? "single_class_dominates" : "distributed";
539
+
540
+ return {
541
+ status,
542
+ class: topClass.class,
543
+ topShare,
544
+ basis,
545
+ displayedExamples: topClass.displayedExamples,
546
+ samplePullRequests,
547
+ note: status === "single_class_dominates"
548
+ ? `PR class ${topClass.class} contributes ${classDominancePercentageLabel(topShare)} of the ${basisLabel}; compare this bottleneck against the class distribution before generalizing.${smallSampleNote}`
549
+ : "Displayed examples are not dominated by one PR class.",
550
+ };
551
+ }
552
+
553
+ function summarizeRecommendationCategories(bottlenecks) {
554
+ const triggeredCounts = {};
555
+ for (const bottleneck of bottlenecks) {
556
+ const category = bottleneck.suggestedAction.category;
557
+ triggeredCounts[category] = (triggeredCounts[category] ?? 0) + 1;
558
+ }
559
+
560
+ return RECOMMENDATION_CATEGORIES.map(category => ({
561
+ ...category,
562
+ triggeredBottlenecks: triggeredCounts[category.id] ?? 0,
563
+ }));
564
+ }
565
+
566
+ function evidenceSignature(bottleneck) {
567
+ return (bottleneck.observedData ?? [])
568
+ .map(evidence => evidence.number)
569
+ .sort((left, right) => Number(left) - Number(right))
570
+ .join(",");
571
+ }
572
+
573
+ function formatSharedSignalBottleneck(bottleneck) {
574
+ return {
575
+ id: bottleneck.id,
576
+ title: bottleneck.title,
577
+ recommendationCategory: bottleneck.suggestedAction?.category ?? "unspecified",
578
+ };
579
+ }
580
+
581
+ function rankingSignalLabel(rankingKey) {
582
+ return RANKING_SIGNAL_LABELS.get(rankingKey) ?? rankingKey;
583
+ }
584
+
585
+ function recommendationCategoryDisplay(category) {
586
+ return RECOMMENDATION_CATEGORY_LABELS.get(category) ?? category;
587
+ }
588
+
589
+ function summarizeSharedSignals(bottlenecks) {
590
+ const groups = [];
591
+ const byRankingKey = new Map();
592
+ const byEvidenceSignature = new Map();
593
+
594
+ for (const bottleneck of bottlenecks ?? []) {
595
+ if (bottleneck.rankingKey) {
596
+ byRankingKey.set(bottleneck.rankingKey, [
597
+ ...(byRankingKey.get(bottleneck.rankingKey) ?? []),
598
+ bottleneck,
599
+ ]);
600
+ }
601
+
602
+ const signature = evidenceSignature(bottleneck);
603
+ if (signature) {
604
+ byEvidenceSignature.set(signature, [
605
+ ...(byEvidenceSignature.get(signature) ?? []),
606
+ bottleneck,
607
+ ]);
608
+ }
609
+ }
610
+
611
+ for (const [rankingKey, sharedBottlenecks] of byRankingKey) {
612
+ if (sharedBottlenecks.length < 2) continue;
613
+ const titles = sharedBottlenecks.map(bottleneck => bottleneck.title).join(", ");
614
+ groups.push({
615
+ type: "ranking_key",
616
+ key: rankingKey,
617
+ bottlenecks: sharedBottlenecks.map(formatSharedSignalBottleneck),
618
+ note: `${titles} share the ${rankingSignalLabel(rankingKey)} ranking signal; treat them as related interpretations, not separate independent findings.`,
619
+ });
620
+ }
621
+
622
+ for (const [signature, sharedBottlenecks] of byEvidenceSignature) {
623
+ if (sharedBottlenecks.length < 2) continue;
624
+ const prNumbers = signature.split(",").map(number => Number(number));
625
+ const titles = sharedBottlenecks.map(bottleneck => bottleneck.title).join(", ");
626
+ groups.push({
627
+ type: "representative_evidence",
628
+ prNumbers,
629
+ bottlenecks: sharedBottlenecks.map(formatSharedSignalBottleneck),
630
+ note: `${titles} display the same representative PR evidence (${prNumbers.map(number => `#${number}`).join(", ")}); keep recommendation actions distinct while reading the shared evidence as one underlying signal.`,
631
+ });
632
+ }
633
+
634
+ return {
635
+ groups,
636
+ note: groups.length
637
+ ? "Shared-signal groups are report interpretation only; they do not change scores, ranking, or recommendation categories."
638
+ : "No displayed bottlenecks shared a ranking key or representative PR evidence.",
639
+ };
640
+ }
641
+
642
+ function metricsWithoutPullRequest(metricsSummary, excludedPrNumber) {
643
+ return {
644
+ ...metricsSummary,
645
+ pullRequests: (metricsSummary.pullRequests ?? [])
646
+ .filter(pr => pr.number !== excludedPrNumber),
647
+ rankings: Object.fromEntries(
648
+ Object.entries(metricsSummary.rankings ?? {})
649
+ .map(([key, ranking]) => [
650
+ key,
651
+ (ranking ?? []).filter(entry => entry.number !== excludedPrNumber),
652
+ ]),
653
+ ),
654
+ };
655
+ }
656
+
657
+ function summarizeSensitivity(metricsSummary, baselineBottlenecks) {
658
+ const dominantPrNumbers = [
659
+ ...new Set(
660
+ (baselineBottlenecks ?? [])
661
+ .filter(bottleneck => bottleneck.dominance?.status === "single_pr_dominates")
662
+ .map(bottleneck => bottleneck.dominance.topPrNumber)
663
+ .filter(Number.isInteger),
664
+ ),
665
+ ].sort((left, right) => left - right);
666
+
667
+ if (!dominantPrNumbers.length) {
668
+ return {
669
+ summaries: [],
670
+ note: "No displayed bottleneck examples were dominated by one PR.",
671
+ };
672
+ }
673
+
674
+ const baselineTopBottleneckIds = baselineBottlenecks.slice(0, 3).map(bottleneck => bottleneck.id);
675
+ const baselineById = new Map(baselineBottlenecks.map(bottleneck => [bottleneck.id, bottleneck]));
676
+
677
+ return {
678
+ summaries: dominantPrNumbers.map(excludedPrNumber => {
679
+ const excludedPr = findPullRequest(metricsSummary, excludedPrNumber);
680
+ const filteredBottlenecks = summarizeBottlenecks(metricsWithoutPullRequest(metricsSummary, excludedPrNumber));
681
+ const filteredTopBottleneckIds = filteredBottlenecks.slice(0, 3).map(bottleneck => bottleneck.id);
682
+ const affectedBottlenecks = baselineBottlenecks
683
+ .filter(bottleneck => bottleneck.dominance?.status === "single_pr_dominates"
684
+ && bottleneck.dominance?.topPrNumber === excludedPrNumber)
685
+ .map(bottleneck => ({
686
+ id: bottleneck.id,
687
+ title: bottleneck.title,
688
+ topShare: bottleneck.dominance?.topShare ?? null,
689
+ }));
690
+ const changedTopBottlenecks = baselineTopBottleneckIds.join(",") !== filteredTopBottleneckIds.join(",");
691
+
692
+ return {
693
+ excludedPr: {
694
+ number: excludedPrNumber,
695
+ title: excludedPr?.title ?? null,
696
+ url: excludedPr?.url ?? null,
697
+ },
698
+ affectedBottlenecks,
699
+ baselineTopBottleneckIds,
700
+ topBottleneckIdsWithoutPr: filteredTopBottleneckIds,
701
+ changedTopBottlenecks,
702
+ interpretation: changedTopBottlenecks
703
+ ? "Top bottleneck ordering changes when this dominant PR is excluded; treat the baseline as outlier-sensitive."
704
+ : "Top bottleneck ordering is unchanged when this dominant PR is excluded; the baseline appears more robust to this outlier.",
705
+ replacementTopBottlenecks: filteredTopBottleneckIds
706
+ .map(id => filteredBottlenecks.find(bottleneck => bottleneck.id === id) ?? baselineById.get(id))
707
+ .filter(Boolean)
708
+ .map(bottleneck => ({ id: bottleneck.id, title: bottleneck.title })),
709
+ };
710
+ }),
711
+ note: "Sensitivity summaries are robustness context only. They do not remove PRs from the baseline report or replace the original ranking.",
712
+ };
713
+ }
714
+
715
+ export function generateRepositoryFrictionReport(metricsSummary) {
716
+ const prClasses = summarizePrClasses(metricsSummary);
717
+ const bottlenecksWithSharedSignalKeys = summarizeBottlenecks(metricsSummary, prClasses);
718
+ const sharedSignals = summarizeSharedSignals(bottlenecksWithSharedSignalKeys);
719
+ const bottlenecks = bottlenecksWithSharedSignalKeys.map(({ rankingKey, ...bottleneck }) => bottleneck);
720
+ return {
721
+ reportVersion: FRICTION_REPORT_VERSION,
722
+ metricVersion: metricsSummary.metricVersion,
723
+ targetRepository: metricsSummary.targetRepository,
724
+ ...(metricsSummary.analysisFilter ? { analysisFilter: metricsSummary.analysisFilter } : {}),
725
+ summary: {
726
+ pullRequests: metricsSummary.totals?.pullRequests ?? 0,
727
+ changedLines: metricsSummary.totals?.changedLines ?? 0,
728
+ nonGeneratedChangedLines: metricsSummary.totals?.nonGeneratedChangedLines ?? 0,
729
+ reviewComments: metricsSummary.totals?.reviewComments ?? 0,
730
+ reviewThreads: metricsSummary.totals?.reviewThreads ?? 0,
731
+ failedChecks: metricsSummary.totals?.failedChecks ?? 0,
732
+ cancelledWorkflowRuns: metricsSummary.totals?.cancelledWorkflowRuns ?? 0,
733
+ topBottleneckIds: bottlenecks.slice(0, 3).map(bottleneck => bottleneck.id),
734
+ },
735
+ coverage: summarizeCoverage(metricsSummary),
736
+ commentSources: summarizeCommentSources(metricsSummary),
737
+ surfaces: summarizeSurfaces(metricsSummary),
738
+ prClasses,
739
+ bottlenecks,
740
+ sharedSignals,
741
+ sensitivity: summarizeSensitivity(metricsSummary, bottlenecks),
742
+ recommendationCategories: summarizeRecommendationCategories(bottlenecks),
743
+ guardrails: {
744
+ avoidsIndividualRanking: true,
745
+ separatesObservedInferredAndSuggested: true,
746
+ usesCompositeScore: false,
747
+ },
748
+ followUp: [
749
+ "Inspect recommendations against real PR history before turning them into automated repository changes.",
750
+ "Collect PR-open snapshots in a future GitHub App flow when diff-growth coverage matters.",
751
+ ],
752
+ };
753
+ }
754
+
755
+ function escapeMarkdownText(value) {
756
+ return String(value)
757
+ .replace(/&/g, "&amp;")
758
+ .replace(/</g, "&lt;")
759
+ .replace(/>/g, "&gt;")
760
+ .replace(/\\/g, "\\\\")
761
+ .replace(/[`*_\[\]]/g, "\\$&");
762
+ }
763
+
764
+ function escapeMarkdownTableCell(value) {
765
+ return escapeMarkdownText(value ?? "")
766
+ .replace(/\|/g, "\\|")
767
+ .replace(/\n/g, " ");
768
+ }
769
+
770
+ function rawMarkdownCell(markdown) {
771
+ return { markdown };
772
+ }
773
+
774
+ function formatMarkdownTableCell(value) {
775
+ if (value && typeof value === "object" && "markdown" in value) {
776
+ return String(value.markdown).replace(/\n/g, " ");
777
+ }
778
+ return escapeMarkdownTableCell(value);
779
+ }
780
+
781
+ function formatNamedValues(entries) {
782
+ return entries.length
783
+ ? entries.map(entry => `${entry.name}=${entry.value}`).join(", ")
784
+ : "none";
785
+ }
786
+
787
+ function formatPrClass(prClass = {}) {
788
+ const className = prClass.class ?? "unknown";
789
+ const source = prClass.classificationSource ?? "fallback_rule";
790
+ const rule = prClass.ruleId ? `, rule=${prClass.ruleId}` : "";
791
+ return `${className} (source=${source}${rule})`;
792
+ }
793
+
794
+ function formatClassSources(entries) {
795
+ return entries?.length ? formatNamedValues(entries) : "none";
796
+ }
797
+
798
+ function formatCoverageEntries(entries) {
799
+ return entries.length
800
+ ? entries.map(entry => `${entry.name}: ${entry.value}`).join(", ")
801
+ : "none";
802
+ }
803
+
804
+ function renderMarkdownTable(headers, rows) {
805
+ if (!rows.length) return "None";
806
+ return [
807
+ `| ${headers.map(formatMarkdownTableCell).join(" | ")} |`,
808
+ `| ${headers.map(() => "---").join(" | ")} |`,
809
+ ...rows.map(row => `| ${row.map(formatMarkdownTableCell).join(" | ")} |`),
810
+ ].join("\n");
811
+ }
812
+
813
+ function sharedEvidenceNotes(bottlenecks) {
814
+ const bySignature = new Map();
815
+ for (const bottleneck of bottlenecks ?? []) {
816
+ const signature = evidenceSignature(bottleneck);
817
+ if (!signature) continue;
818
+ bySignature.set(signature, [...(bySignature.get(signature) ?? []), bottleneck.title]);
819
+ }
820
+
821
+ const notes = new Map();
822
+ for (const bottleneck of bottlenecks ?? []) {
823
+ const titles = bySignature.get(evidenceSignature(bottleneck)) ?? [];
824
+ const relatedTitles = titles.filter(title => title !== bottleneck.title);
825
+ if (relatedTitles.length > 0) {
826
+ notes.set(bottleneck.id, `Shares the same representative PR evidence as ${relatedTitles.join(", ")}.`);
827
+ }
828
+ }
829
+ return notes;
830
+ }
831
+
832
+ function prReference(evidence) {
833
+ const label = `#${evidence.number}`;
834
+ return evidence.url ? rawMarkdownCell(`[${label}](${evidence.url})`) : label;
835
+ }
836
+
837
+ function sensitivityPrReference(summary) {
838
+ const label = `#${summary.excludedPr.number}`;
839
+ return summary.excludedPr.url ? rawMarkdownCell(`[${label}](${summary.excludedPr.url})`) : label;
840
+ }
841
+
842
+ function evidenceValueLabel(value) {
843
+ return value === null || value === undefined ? "unknown" : String(value);
844
+ }
845
+
846
+ function evidenceRows(observedData) {
847
+ return (observedData ?? []).map(evidence => [
848
+ prReference(evidence),
849
+ evidence.title,
850
+ evidenceValueLabel(evidence.value),
851
+ evidence.prClass?.class ?? "unknown",
852
+ evidenceValueLabel(evidence.additions),
853
+ evidenceValueLabel(evidence.deletions),
854
+ evidenceValueLabel(evidence.changedFiles),
855
+ evidenceValueLabel(evidence.changedLines),
856
+ ]);
857
+ }
858
+
859
+ function renderEvidenceTable(observedData) {
860
+ return renderMarkdownTable(
861
+ [
862
+ "PR",
863
+ "Title",
864
+ "Score",
865
+ "Class",
866
+ "Additions",
867
+ "Deletions",
868
+ "Files changed",
869
+ "Changed lines",
870
+ ],
871
+ evidenceRows(observedData),
872
+ );
873
+ }
874
+
875
+ function renderDetailList(items) {
876
+ return items.length ? items.map(item => `- ${escapeMarkdownText(item)}`).join("\n") : "- None";
877
+ }
878
+
879
+ function renderEvidenceDetails(observedData) {
880
+ return (observedData ?? []).map(evidence => {
881
+ const validationEvidence = evidence.validationEvidence ?? {};
882
+ const reviewEvidence = evidence.reviewEvidence ?? {};
883
+ const workflowRunConclusions = validationEvidence.workflowRunConclusions ?? [];
884
+ const reviewCommentSources = reviewEvidence.commentSources ?? [];
885
+ const observedReviewDecision = hasObservedReviewDecision({
886
+ state: reviewEvidence.reviewDecision,
887
+ source: reviewEvidence.reviewDecisionSource,
888
+ });
889
+
890
+ return [
891
+ `Evidence details for PR #${evidence.number}:`,
892
+ "",
893
+ "Validation:",
894
+ "",
895
+ renderDetailList([
896
+ `Workflow coverage: ${validationEvidence.workflowRunCoverage ?? "unavailable"}`,
897
+ `Workflow conclusions: ${formatNamedValues(workflowRunConclusions)}`,
898
+ `Failed checks: ${validationEvidence.failedCheckRuns ?? 0}`,
899
+ `Failed workflows: ${validationEvidence.failedWorkflowRuns ?? 0}`,
900
+ `Cancelled workflows: ${validationEvidence.cancelledWorkflowRuns ?? 0}`,
901
+ ]),
902
+ "",
903
+ "Review:",
904
+ "",
905
+ renderDetailList([
906
+ `Review thread source: ${reviewEvidence.reviewThreadSource ?? "unavailable"}`,
907
+ `Threads: ${reviewEvidence.reviewThreads ?? 0}`,
908
+ `Resolved threads: ${reviewEvidence.resolvedThreads ?? 0}`,
909
+ `Outdated threads: ${reviewEvidence.outdatedThreads ?? 0}`,
910
+ `Review decision: ${reviewEvidence.reviewDecision ?? "unavailable"} (source: ${reviewEvidence.reviewDecisionSource ?? "unavailable"})`,
911
+ `Human reviewers: ${formatObservedCount(reviewEvidence.humanReviewerCount, observedReviewDecision)}`,
912
+ `Human approved: ${formatObservedBoolean(reviewEvidence.humanApproved, observedReviewDecision)}`,
913
+ `Human changes requested: ${formatObservedBoolean(reviewEvidence.humanChangesRequested, observedReviewDecision)}`,
914
+ `Comment sources: ${formatNamedValues(reviewCommentSources)}`,
915
+ ]),
916
+ "",
917
+ "Source labels:",
918
+ "",
919
+ renderDetailList([
920
+ `PR class: ${formatPrClass(evidence.prClass)}`,
921
+ `Workflow source: ${validationEvidence.workflowRunSource ?? "unavailable"}`,
922
+ ]),
923
+ ].join("\n");
924
+ }).join("\n\n");
925
+ }
926
+
927
+ function recommendationCategoryLabel(bottleneck) {
928
+ return bottleneck.suggestedAction?.category ?? "unspecified";
929
+ }
930
+
931
+ function recommendationActionText(bottleneck) {
932
+ return bottleneck.suggestedAction?.action ?? "No recommendation was recorded.";
933
+ }
934
+
935
+ function renderList(items) {
936
+ return items.length ? items.map(item => `- ${item}`).join("\n") : "- None";
937
+ }
938
+
939
+ function renderNamedValuesTable(entries) {
940
+ return renderMarkdownTable(
941
+ ["Name", "Value"],
942
+ (entries ?? []).map(entry => [entry.name, entry.value]),
943
+ );
944
+ }
945
+
946
+ function renderRecommendationCategories(categories) {
947
+ return renderMarkdownTable(
948
+ ["Category", "Triggered bottlenecks", "Meaning"],
949
+ (categories ?? []).map(category => [category.label, category.triggeredBottlenecks, category.description]),
950
+ );
951
+ }
952
+
953
+ function renderInterpretationAndRecommendation(bottleneck) {
954
+ return renderMarkdownTable(
955
+ ["Field", "Value"],
956
+ [
957
+ ["Inferred diagnosis", bottleneck.inferredDiagnosis],
958
+ ["Suggested action", recommendationActionText(bottleneck)],
959
+ ],
960
+ );
961
+ }
962
+
963
+ function renderPriorityExplanation() {
964
+ return renderList([
965
+ "Bottlenecks are ordered by their strongest displayed representative score, not by an opaque composite priority score.",
966
+ "Each score comes from one metric family, such as review-loop drag, validation failures, changed-file spread, planning signals, review surprise, or post-review commits.",
967
+ "PR size columns show final/current additions, deletions, changed files, and changed lines so readers can compare size against the detected friction signals.",
968
+ "PR size is context for interpretation; it only affects ordering when the bottleneck metric itself is about changed-file spread.",
969
+ "Coverage caveats and outlier dominance should be considered before treating the first bottleneck as the most important repository problem.",
970
+ ]);
971
+ }
972
+
973
+ function renderSummaryTable(summary) {
974
+ return renderMarkdownTable(
975
+ ["Metric", "Value"],
976
+ [
977
+ ["Pull requests analyzed", summary.pullRequests],
978
+ ["Changed lines", summary.changedLines],
979
+ ["Non-generated changed lines", summary.nonGeneratedChangedLines],
980
+ ["Review comments", summary.reviewComments],
981
+ ["Review threads", summary.reviewThreads],
982
+ ["Failed checks", summary.failedChecks ?? 0],
983
+ ["Cancelled workflow runs", summary.cancelledWorkflowRuns ?? 0],
984
+ ["Top bottlenecks", (summary.topBottleneckIds ?? []).join(", ") || "none"],
985
+ ],
986
+ );
987
+ }
988
+
989
+ function renderCoverageSummary(coverage) {
990
+ return renderMarkdownTable(
991
+ ["Evidence area", "Observed coverage"],
992
+ [
993
+ ["PR-open diff", formatCoverageEntries(sortedEntries(coverage.prOpenDiff))],
994
+ ["Workflow runs", formatCoverageEntries(sortedEntries(coverage.workflowRuns))],
995
+ ["Review thread sources", formatCoverageEntries(sortedEntries(coverage.reviewThreads))],
996
+ ],
997
+ );
998
+ }
999
+
1000
+ function renderKeyFindings(report) {
1001
+ const topBottlenecks = (report.summary.topBottleneckIds ?? []).join(", ") || "none";
1002
+ const strongest = report.bottlenecks?.[0];
1003
+ const dominanceCallouts = (report.bottlenecks ?? [])
1004
+ .filter(bottleneck => bottleneck.dominance?.status === "single_pr_dominates")
1005
+ .map(bottleneck => `${bottleneck.title}: ${bottleneck.dominance.note}`);
1006
+ const classDominanceCallouts = (report.bottlenecks ?? [])
1007
+ .filter(bottleneck => bottleneck.classDominance?.status === "single_class_dominates")
1008
+ .map(bottleneck => `${bottleneck.title}: ${bottleneck.classDominance.note}`);
1009
+ const coverageNotes = report.coverage?.notes?.length
1010
+ ? report.coverage.notes.join(" ")
1011
+ : "No coverage caveats were recorded for the displayed evidence.";
1012
+
1013
+ return renderList([
1014
+ `Top bottlenecks: ${topBottlenecks}.`,
1015
+ strongest
1016
+ ? `Strongest displayed signal: ${strongest.title} (${strongest.metricLabel}).`
1017
+ : "No bottleneck evidence was available.",
1018
+ dominanceCallouts.length
1019
+ ? `Outlier caveat: ${dominanceCallouts.join(" ")}`
1020
+ : "Outlier caveat: displayed bottleneck examples are not dominated by a single PR.",
1021
+ classDominanceCallouts.length
1022
+ ? `PR class caveat: ${classDominanceCallouts.join(" ")}`
1023
+ : classDominanceFallback(report.prClasses),
1024
+ `Coverage caveat: ${coverageNotes}`,
1025
+ ]);
1026
+ }
1027
+
1028
+ function classDominanceFallback(prClasses) {
1029
+ const sampleClasses = (prClasses?.distribution ?? []).filter(entry => entry.pullRequests > 0);
1030
+ if (!sampleClasses.length) {
1031
+ return "PR class caveat: PR class context was not available for the analyzed sample.";
1032
+ }
1033
+ return sampleClasses.length < 2
1034
+ ? "PR class caveat: only one PR class appears in the analyzed sample, so class dominance comparison is not meaningful."
1035
+ : "PR class caveat: displayed bottleneck examples are not dominated by one PR class.";
1036
+ }
1037
+
1038
+ function renderPrClassContext(prClasses) {
1039
+ const distribution = prClasses?.distribution ?? [];
1040
+ if (!distribution.length) return "";
1041
+
1042
+ return [
1043
+ "## PR Class Context",
1044
+ "",
1045
+ prClasses.note ?? "PR class evidence is interpretation context only.",
1046
+ "",
1047
+ renderMarkdownTable(
1048
+ ["Class", "PRs", "Changed lines", "Share", "Sources"],
1049
+ distribution.map(entry => [
1050
+ entry.class,
1051
+ entry.pullRequests,
1052
+ entry.changedLines,
1053
+ percentageLabel(entry.share),
1054
+ formatClassSources(entry.classificationSources),
1055
+ ]),
1056
+ ),
1057
+ "",
1058
+ ].join("\n");
1059
+ }
1060
+
1061
+ function classDominanceCaveat(bottleneck) {
1062
+ return bottleneck.classDominance?.status === "single_class_dominates"
1063
+ ? bottleneck.classDominance.note
1064
+ : null;
1065
+ }
1066
+
1067
+ function renderSharedSignalInterpretation(sharedSignals) {
1068
+ const groups = sharedSignals?.groups ?? [];
1069
+ if (!groups.length) return "";
1070
+
1071
+ return [
1072
+ "## Shared Signal Interpretation",
1073
+ "",
1074
+ sharedSignals.note ?? "Shared-signal groups are report interpretation only.",
1075
+ "",
1076
+ renderList(groups.map(group => {
1077
+ const categories = [
1078
+ ...new Set((group.bottlenecks ?? []).map(bottleneck => bottleneck.recommendationCategory)),
1079
+ ].map(recommendationCategoryDisplay).join(", ");
1080
+ return `${group.note} Recommendation categories remain distinct: ${categories || "none"}.`;
1081
+ })),
1082
+ "",
1083
+ ].join("\n");
1084
+ }
1085
+
1086
+ function renderSensitivityAnalysis(sensitivity) {
1087
+ const summaries = sensitivity?.summaries ?? [];
1088
+ if (!summaries.length) return "";
1089
+
1090
+ const rows = summaries.map(summary => [
1091
+ sensitivityPrReference(summary),
1092
+ summary.excludedPr.title ?? "unknown",
1093
+ summary.affectedBottlenecks
1094
+ .map(bottleneck => `${bottleneck.title} (${Math.round(Number(bottleneck.topShare ?? 0) * 100)}%)`)
1095
+ .join(", "),
1096
+ (summary.baselineTopBottleneckIds ?? []).join(", ") || "none",
1097
+ (summary.topBottleneckIdsWithoutPr ?? []).join(", ") || "none",
1098
+ summary.interpretation,
1099
+ ]);
1100
+
1101
+ return [
1102
+ "## Outlier And Sensitivity Analysis",
1103
+ "",
1104
+ sensitivity.note ?? "Sensitivity summaries are robustness context only.",
1105
+ "",
1106
+ renderMarkdownTable(
1107
+ [
1108
+ "Excluded PR",
1109
+ "Title",
1110
+ "Affected bottlenecks",
1111
+ "Baseline top bottlenecks",
1112
+ "Top bottlenecks without PR",
1113
+ "Robustness interpretation",
1114
+ ],
1115
+ rows,
1116
+ ),
1117
+ "",
1118
+ ].join("\n");
1119
+ }
1120
+
1121
+ function renderCommentSources(commentSources) {
1122
+ const dominantSource = commentSources.dominantSource ?? { name: "none", value: 0 };
1123
+ return [
1124
+ renderMarkdownTable(
1125
+ ["Metric", "Value"],
1126
+ [
1127
+ ["Total comments", commentSources.totalComments],
1128
+ ["Bot/scanner comments", commentSources.botComments],
1129
+ ["Human reviewer comments", commentSources.humanComments],
1130
+ ["Author replies", commentSources.authorReplies],
1131
+ ["Dominant source", `${dominantSource.name} (${dominantSource.value})`],
1132
+ ],
1133
+ ),
1134
+ "",
1135
+ "By source:",
1136
+ "",
1137
+ renderNamedValuesTable(commentSources.bySource),
1138
+ ].join("\n");
1139
+ }
1140
+
1141
+ function renderSurfaces(surfaces) {
1142
+ return [
1143
+ renderMarkdownTable(
1144
+ ["Metric", "Value"],
1145
+ [
1146
+ ["Core changed lines", surfaces.coreChangedLines],
1147
+ ["Low-signal changed lines", surfaces.lowSignalChangedLines],
1148
+ ["Low-signal files", surfaces.lowSignalFiles],
1149
+ ["Weighted changed lines", surfaces.weightedChangedLines],
1150
+ ["Small-diff wide-spread PRs", surfaces.smallDiffWideSpreadCount],
1151
+ ],
1152
+ ),
1153
+ "",
1154
+ "Functional surfaces:",
1155
+ "",
1156
+ renderNamedValuesTable(surfaces.byFunctionalSurface),
1157
+ "",
1158
+ "File roles:",
1159
+ "",
1160
+ renderNamedValuesTable(surfaces.byRole),
1161
+ ].join("\n");
1162
+ }
1163
+
1164
+ function renderGuardrails(guardrails) {
1165
+ return renderMarkdownTable(
1166
+ ["Guardrail", "Value"],
1167
+ [
1168
+ ["Avoids individual ranking", guardrails.avoidsIndividualRanking],
1169
+ ["Separates observed, inferred, and suggested fields", guardrails.separatesObservedInferredAndSuggested],
1170
+ ["Uses composite score", guardrails.usesCompositeScore],
1171
+ ],
1172
+ );
1173
+ }
1174
+
1175
+ export function renderRepositoryFrictionMarkdown(report) {
1176
+ const repository = report.targetRepository
1177
+ ? `${report.targetRepository.owner}/${report.targetRepository.name}`
1178
+ : "unknown repository";
1179
+ const sharedSignals = report.sharedSignals ?? summarizeSharedSignals(report.bottlenecks);
1180
+ const sharedNotes = sharedEvidenceNotes(report.bottlenecks);
1181
+ const analysisFilterLines = analysisFilterMarkdownLines(report.analysisFilter);
1182
+ const lines = [
1183
+ `# Repository Friction Report: ${repository}`,
1184
+ "",
1185
+ `Report version: ${report.reportVersion}`,
1186
+ `Metric version: ${report.metricVersion}`,
1187
+ `Pull requests analyzed: ${report.summary?.pullRequests ?? "unknown"}`,
1188
+ "",
1189
+ ...analysisFilterLines,
1190
+ "## Executive Summary",
1191
+ "",
1192
+ renderSummaryTable(report.summary),
1193
+ "",
1194
+ "## How To Read This Report",
1195
+ "",
1196
+ "- Observed evidence is measured from GitHub data and repository-profile classifications.",
1197
+ "- Interpretation is the analyzer's explanation of what the observed evidence suggests.",
1198
+ "- Recommendation is a workflow intervention to consider; the report does not modify repositories.",
1199
+ "- Confidence and caveats call out outliers, missing coverage, and evidence-quality limits before you act.",
1200
+ "",
1201
+ "## Evidence Quality And Coverage",
1202
+ "",
1203
+ renderCoverageSummary(report.coverage),
1204
+ "",
1205
+ "Coverage notes:",
1206
+ "",
1207
+ renderList(report.coverage.notes),
1208
+ "",
1209
+ "## Key Findings",
1210
+ "",
1211
+ renderKeyFindings(report),
1212
+ "",
1213
+ renderPrClassContext(report.prClasses),
1214
+ renderSharedSignalInterpretation(sharedSignals),
1215
+ renderSensitivityAnalysis(report.sensitivity),
1216
+ "## How Bottlenecks Are Prioritized",
1217
+ "",
1218
+ renderPriorityExplanation(),
1219
+ "",
1220
+ "## Ranked Bottlenecks",
1221
+ "",
1222
+ ];
1223
+
1224
+ for (const bottleneck of report.bottlenecks) {
1225
+ lines.push(
1226
+ `### ${bottleneck.title}`,
1227
+ "",
1228
+ `Recommendation category: ${recommendationCategoryLabel(bottleneck)}`,
1229
+ "",
1230
+ `#### ${bottleneck.title} Observed Evidence (${bottleneck.metricLabel})`,
1231
+ "",
1232
+ renderEvidenceTable(bottleneck.observedData),
1233
+ "",
1234
+ renderEvidenceDetails(bottleneck.observedData),
1235
+ "",
1236
+ `#### ${bottleneck.title} Interpretation And Recommendation`,
1237
+ "",
1238
+ renderInterpretationAndRecommendation(bottleneck),
1239
+ "",
1240
+ `#### ${bottleneck.title} Confidence And Caveats`,
1241
+ "",
1242
+ renderList([
1243
+ bottleneck.dominance?.note ?? "Not enough positive examples to evaluate outlier dominance.",
1244
+ classDominanceCaveat(bottleneck),
1245
+ sharedNotes.get(bottleneck.id),
1246
+ ].filter(Boolean)),
1247
+ "",
1248
+ );
1249
+ }
1250
+
1251
+ lines.push(
1252
+ "## Recommendation Categories",
1253
+ "",
1254
+ renderRecommendationCategories(report.recommendationCategories ?? []),
1255
+ "",
1256
+ "## Comment Sources",
1257
+ "",
1258
+ renderCommentSources(report.commentSources),
1259
+ "",
1260
+ "## Core And Support Surfaces",
1261
+ "",
1262
+ renderSurfaces(report.surfaces),
1263
+ "",
1264
+ "## Methodology Summary",
1265
+ "",
1266
+ "- Pull requests are selected upstream by the collection or fixture workflow; this renderer explains the resulting metrics summary.",
1267
+ "- File roles and functional surfaces come from repository-profile classification, not from language names alone.",
1268
+ "- Bottlenecks are ranked by their strongest representative observed signal, with stable category order only used to break ties.",
1269
+ "- Recommendations are inferred from transparent component evidence and representative PR examples; they are not automated changes.",
1270
+ "- Missing or partial GitHub data remains visible in coverage tables rather than being inferred from unrelated fields.",
1271
+ "- Sensitivity analysis, when present, excludes one dominant representative PR at a time to show robustness context without changing the baseline ranking.",
1272
+ report.analysisFilter?.excludedPrClasses?.length
1273
+ ? "- PR class filtering was explicitly applied before metrics and ranking; PR class context still supports interpretation of the filtered sample."
1274
+ : "- PR class context is interpretation support only; it does not filter PRs or change bottleneck ranking.",
1275
+ "- Full live analysis runs also write a detailed companion methodology artifact: `methodology.md`.",
1276
+ "",
1277
+ "## Guardrails And Follow-Up",
1278
+ "",
1279
+ renderGuardrails(report.guardrails),
1280
+ "",
1281
+ "Follow-up:",
1282
+ "",
1283
+ renderList(report.followUp),
1284
+ "",
1285
+ "Artifact sensitivity:",
1286
+ "",
1287
+ report.artifactSensitivity ?? "Generated artifacts may include repository names, PR URLs, titles, file paths, and comment metadata. Treat them as local/private unless intentionally shared.",
1288
+ "",
1289
+ );
1290
+
1291
+ return `${lines.join("\n").trimEnd()}\n`;
1292
+ }
1293
+
1294
+ function analysisFilterMarkdownLines(analysisFilter) {
1295
+ if (!analysisFilter?.excludedPrClasses?.length) return [];
1296
+ return [
1297
+ `Analysis filter: excluded PR class(es): ${analysisFilter.excludedPrClasses.join(", ")}.`,
1298
+ `Filtered sample: ${analysisFilter.filteredPullRequests} of ${analysisFilter.originalPullRequests} collected pull request(s).`,
1299
+ "",
1300
+ ];
1301
+ }