delivery-friction-analyzer 0.8.0 → 0.10.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.
@@ -1,6 +1,6 @@
1
1
  # Friction Report Contract
2
2
 
3
- Milestone 3 introduces `friction-report.v1`, a deterministic report generated from a `friction-metrics.v1` repository metrics summary. The report layer does not fetch GitHub data, mutate repositories, rank individuals, or depend on services beyond the data collection path that produced the metrics summary.
3
+ Milestone 3 introduced `friction-report.v1`, a deterministic report generated from a `friction-metrics.v1` repository metrics summary. Milestones 4 and 5 add Markdown and methodology profile suggestions without adding report JSON fields. The report layer does not fetch GitHub data, mutate repositories, rank individuals, or depend on services beyond the data collection path that produced the metrics summary.
4
4
 
5
5
  ## Outputs
6
6
 
@@ -57,9 +57,11 @@ The Markdown renderer presents the same report data for human review:
57
57
  - a compact recommendation-category snapshot before detailed bottlenecks, with the full category reference retained later in the report;
58
58
  - a short "How To Read This Report" guide that distinguishes observed evidence, interpretation, recommendations, and caveats;
59
59
  - a configured workflow context section only when repository profile workflow fields are present, labeled as user-configured profile context rather than observed GitHub evidence;
60
+ - workflow data caveats when configured workflow context clarifies unavailable PR-open diff or workflow-run evidence;
60
61
  - evidence-quality and coverage tables before detailed recommendations;
61
62
  - key findings that highlight top bottlenecks, strongest displayed signal, outlier caveats, PR class caveats, and coverage caveats;
62
63
  - a PR class context table that shows analyzed PR counts, changed lines, sample share, and classification sources by class;
64
+ - profile suggestions when fallback `unknown` PR classes, unknown file role/surface evidence, or omitted workflow context with relevant unavailable coverage cross deterministic thresholds;
63
65
  - a top-level shared-signal interpretation callout when multiple displayed bottlenecks share a ranking key or representative PR evidence;
64
66
  - outlier and sensitivity analysis when displayed examples are dominated by one PR;
65
67
  - a prioritization explanation that describes strongest-signal ordering and how PR size is used as context, using reader-facing change-scope language while mapping back to the internal changed-file-spread signal when needed;
@@ -75,6 +77,9 @@ The Markdown renderer presents the same report data for human review:
75
77
 
76
78
  Markdown output should not include individual contributor or reviewer rankings.
77
79
  Status labels are Markdown presentation helpers, not `friction-report.v1` fields. They should preserve the underlying source labels and counts rather than replacing auditable evidence.
80
+ Profile suggestions are also presentation helpers, not `friction-report.v1` fields. They are derived from existing PR class and file-surface evidence, appear at most once per suggestion category, and do not change scores, rankings, CSV exports, filtering, or PR class matching. Because the report JSON does not carry repository-profile rule inventory, all analyzed PRs using fallback `unknown` PR class evidence is the renderer's small-sample proxy for no configured PR class rule producing usable classification evidence.
81
+
82
+ Workflow-context suggestions are presentation helpers, not `friction-report.v1` fields. They render when workflow context is omitted and the report has unavailable PR-open diff coverage or workflow-run coverage that maintainer-confirmed workflow context could help explain. They are omitted when workflow context is configured or when those coverage caveats are absent.
78
83
 
79
84
  ## Recommendation Boundaries
80
85
 
@@ -92,7 +97,7 @@ The M3 report contract supports these recommendation categories:
92
97
 
93
98
  ## Coverage And Confidence
94
99
 
95
- Reports must label unavailable or partial GitHub data instead of inferring unavailable values from merge-time data. PR-open diff growth remains unavailable unless direct or reconstructed counts exist. Workflow coverage and review-thread sources are summarized separately.
100
+ Reports must label unavailable or partial GitHub data instead of inferring unavailable values from merge-time data. Final/current PR metadata can come from GitHub PR data, but PR-open diff growth remains unavailable unless an open-time snapshot or equivalent captured state exists. Workflow coverage and review-thread sources are summarized separately.
96
101
 
97
102
  Representative examples should carry enough source evidence to trace a report claim back to generated artifacts. Validation examples should name the workflow-run source and conclusions. Review churn examples should name the review-thread source, review decision evidence, and comment sources. PR class evidence should be visible in representative bottleneck examples so readers can distinguish workflow populations such as release, dependency, development, or repository-specific classes. When `reviewThreads` is zero, review decision evidence should make clean human approval distinguishable from unavailable review evidence and from observed absence of human review. When displayed examples are dominated by one PR or one PR class, the report should say so instead of implying a repository-wide pattern from an outlier or workflow population.
98
103
 
@@ -109,6 +114,7 @@ Full live analysis writes `methodology.md` as a hybrid artifact: stable explanat
109
114
  - target repository and report/metric versions;
110
115
  - profile path when available;
111
116
  - configured workflow context when supplied by the repository profile, labeled as user-configured context rather than observed GitHub evidence;
117
+ - profile suggestions when PR class, file/path, or workflow-context profile evidence crosses deterministic fallback thresholds, or an explicit no-threshold note when none were triggered;
112
118
  - requested and collected PR counts;
113
119
  - collection coverage status and API-family diagnostics;
114
120
  - scoring, ranking, dominance, sensitivity, and limitation explanations;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "delivery-friction-analyzer",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Local GitHub pull request analytics for delivery friction reports.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/release-log.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ### 2026-06-20 — Workflow Data Caveats
6
+
7
+ - What changed: Markdown friction reports and methodology now explain PR-open diff and workflow-run coverage limits with configured workflow context when it is available, and suggest adding workflow context when omitted context would clarify unavailable evidence.
8
+ - Why it matters: Maintainers can distinguish final GitHub PR metadata from unreconstructable open-time PR size without mistaking merge strategy for observed evidence or a scoring input.
9
+ - Who is affected: Maintainers and contributors reviewing generated reports or authoring repository profiles with workflow context.
10
+ - Action needed: Optional; add repository-profile workflow context when unavailable coverage would be easier to interpret with maintainer-confirmed merge or branch assumptions.
11
+ - PR: https://github.com/hannasdev/delivery-friction-analyzer/pull/46
12
+
13
+ ### 2026-06-20 — Profile Improvement Suggestions
14
+
15
+ - What changed: Markdown friction reports and methodology now suggest PR class or file/path profile improvements when fallback `unknown` evidence dominates the analyzed sample.
16
+ - Why it matters: Maintainers can see where repository profile rules would improve interpretation without treating the suggestions as score changes or required fixes.
17
+ - Who is affected: Maintainers and contributors reviewing generated reports or authoring repository profiles.
18
+ - Action needed: Optional; add or refine profile rules when the suggestions match repository conventions.
19
+ - PR: https://github.com/hannasdev/delivery-friction-analyzer/pull/45
20
+
5
21
  ### 2026-06-20 — Report Evidence Status Tables
6
22
 
7
23
  - What changed: Markdown friction reports now show representative validation, review, and source-label evidence in compact tables with text status labels for observed, partial, unavailable, configured, warning, and healthy states.
@@ -2,6 +2,8 @@ import {
2
2
  CONFIGURED_WORKFLOW_NOTE,
3
3
  configuredWorkflowEntries,
4
4
  hasConfiguredWorkflowContext,
5
+ profileSuggestions,
6
+ workflowDataCaveats,
5
7
  } from "./friction-report.js";
6
8
 
7
9
  const BOT_OR_SCANNER_SOURCES = new Set([
@@ -345,6 +347,7 @@ function formatSensitivitySummaries(report) {
345
347
  function formatConfiguredWorkflowContext(report) {
346
348
  const configuredWorkflow = report.configuredWorkflow;
347
349
  if (!hasConfiguredWorkflowContext(configuredWorkflow)) return [];
350
+ const caveats = workflowDataCaveats(report);
348
351
 
349
352
  return [
350
353
  "## Configured Workflow Context",
@@ -353,6 +356,38 @@ function formatConfiguredWorkflowContext(report) {
353
356
  "",
354
357
  ...configuredWorkflowEntries(configuredWorkflow)
355
358
  .map(entry => `- ${entry.label}: ${entry.valueLabel}`),
359
+ ...(caveats.length
360
+ ? [
361
+ "",
362
+ "Workflow data caveats:",
363
+ "",
364
+ ...caveats.map(caveat => `- ${caveat}`),
365
+ ]
366
+ : []),
367
+ "",
368
+ ];
369
+ }
370
+
371
+ function formatProfileSuggestions(report) {
372
+ const suggestions = profileSuggestions(report);
373
+ if (!suggestions.length) {
374
+ return [
375
+ "## Profile Suggestions",
376
+ "",
377
+ "- No profile suggestion thresholds were triggered by this report's PR class, role, functional-surface, or workflow-coverage evidence.",
378
+ "",
379
+ ];
380
+ }
381
+
382
+ return [
383
+ "## Profile Suggestions",
384
+ "",
385
+ "Profile suggestions are optional interpretation improvements derived from existing report evidence. They do not change scores, rankings, CSV exports, or JSON report fields.",
386
+ "",
387
+ ...suggestions.map(suggestion => [
388
+ `- ${suggestion.area}: ${suggestion.evidence}`,
389
+ ` Suggested next step: ${suggestion.suggestion}`,
390
+ ].join("\n")),
356
391
  "",
357
392
  ];
358
393
  }
@@ -394,6 +429,7 @@ export function renderRepositoryFrictionMethodology({
394
429
  "",
395
430
  "The repository profile maps file paths to categories, roles, and functional surfaces. Those classifications drive non-generated changed-line counts, support-surface summaries, planning-document signals, and low-signal weighting.",
396
431
  "",
432
+ ...formatProfileSuggestions(report),
397
433
  ...formatConfiguredWorkflowContext(report),
398
434
  "## Scores And Rankings",
399
435
  "",
@@ -83,6 +83,14 @@ const WORKFLOW_CONTEXT_VALUE_LABELS = new Map([
83
83
 
84
84
  export const CONFIGURED_WORKFLOW_NOTE = "Configured workflow context comes from the repository profile. It is user-configured context, not observed GitHub evidence, and it does not change scores, rankings, CSV exports, or PR class matching.";
85
85
 
86
+ const PR_OPEN_DIFF_LIMITATION_NOTE = "PR-open diff growth is unavailable for PRs without an open-time snapshot or equivalent captured state; final/current PR metadata can still come from GitHub PR data, but open-time size is not reconstructed from merge-time data.";
87
+
88
+ const PROFILE_SUGGESTION_THRESHOLDS = {
89
+ minimumPrClassSample: 3,
90
+ unknownPrClassShare: 0.8,
91
+ unknownFileShare: 0.25,
92
+ };
93
+
86
94
  const BOTTLENECK_DEFINITIONS = [
87
95
  {
88
96
  id: "review-churn",
@@ -415,9 +423,7 @@ function summarizeCoverage(metricsSummary) {
415
423
 
416
424
  const notes = [];
417
425
  if (prOpenDiff.unavailable) {
418
- notes.push(
419
- "PR-open diff growth is unavailable for PRs without captured or reconstructed open-time snapshots; it is not inferred from merge-time data.",
420
- );
426
+ notes.push(PR_OPEN_DIFF_LIMITATION_NOTE);
421
427
  }
422
428
  if (workflowRuns.unavailable) {
423
429
  notes.push("Workflow-run coverage is unavailable for some PRs, often because branch-based history is missing.");
@@ -585,6 +591,81 @@ function summarizeRecommendationCategories(bottlenecks) {
585
591
  }));
586
592
  }
587
593
 
594
+ function entryValue(entries = [], name) {
595
+ return Number((entries ?? []).find(entry => entry.name === name)?.value ?? 0);
596
+ }
597
+
598
+ function classSourceValue(entry = {}, source) {
599
+ return entryValue(entry.classificationSources, source);
600
+ }
601
+
602
+ export function profileSuggestions(report = {}) {
603
+ const suggestions = [];
604
+ const summary = report.summary ?? {};
605
+ const prClasses = report.prClasses ?? {};
606
+ const classDistribution = prClasses.distribution ?? [];
607
+ const totalPullRequests = Number(prClasses.totalPullRequests ?? summary.pullRequests ?? 0);
608
+ const unknownClass = classDistribution.find(entry => entry.class === "unknown");
609
+ const unknownClassPullRequests = Number(unknownClass?.pullRequests ?? 0);
610
+ const fallbackUnknownPullRequests = classSourceValue(unknownClass, "fallback_rule");
611
+ const fallbackUnknownClassShare = totalPullRequests > 0
612
+ ? fallbackUnknownPullRequests / totalPullRequests
613
+ : 0;
614
+ const everyPrFallbackUnknown = totalPullRequests > 0
615
+ && unknownClassPullRequests === totalPullRequests
616
+ && fallbackUnknownPullRequests === unknownClassPullRequests;
617
+
618
+ if ((totalPullRequests >= PROFILE_SUGGESTION_THRESHOLDS.minimumPrClassSample
619
+ && fallbackUnknownClassShare >= PROFILE_SUGGESTION_THRESHOLDS.unknownPrClassShare)
620
+ || everyPrFallbackUnknown) {
621
+ suggestions.push({
622
+ id: "pr-class-rules",
623
+ area: "PR class rules",
624
+ evidence: `${fallbackUnknownPullRequests} of ${totalPullRequests} analyzed PRs (${percentageLabel(fallbackUnknownClassShare)}) use fallback unknown PR class evidence.`,
625
+ suggestion: "Add or refine repository-profile PR class title rules, or rerun interactive setup to add the Conventional Commit preset when it matches the repository.",
626
+ });
627
+ }
628
+
629
+ const nonGeneratedChangedLines = Number(summary.nonGeneratedChangedLines ?? 0);
630
+ const surfaces = report.surfaces ?? {};
631
+ const unknownRoleLines = entryValue(surfaces.byRole, "unknown");
632
+ const unknownSurfaceLines = entryValue(surfaces.byFunctionalSurface, "unknown");
633
+ const unknownRoleShare = nonGeneratedChangedLines > 0 ? unknownRoleLines / nonGeneratedChangedLines : 0;
634
+ const unknownSurfaceShare = nonGeneratedChangedLines > 0 ? unknownSurfaceLines / nonGeneratedChangedLines : 0;
635
+
636
+ if (unknownRoleShare >= PROFILE_SUGGESTION_THRESHOLDS.unknownFileShare
637
+ || unknownSurfaceShare >= PROFILE_SUGGESTION_THRESHOLDS.unknownFileShare) {
638
+ suggestions.push({
639
+ id: "file-path-rules",
640
+ area: "File/path rules",
641
+ evidence: `Unknown role lines: ${unknownRoleLines} of ${nonGeneratedChangedLines} (${percentageLabel(unknownRoleShare)}); unknown functional-surface lines: ${unknownSurfaceLines} of ${nonGeneratedChangedLines} (${percentageLabel(unknownSurfaceShare)}).`,
642
+ suggestion: "Add repository-profile path rules for high-volume unknown roles or functional surfaces, starting with the directories that account for the most non-generated changed lines.",
643
+ });
644
+ }
645
+
646
+ const hasWorkflowContext = hasConfiguredWorkflowContext(report.configuredWorkflow);
647
+ const unavailablePrOpenDiff = Number(report.coverage?.prOpenDiff?.unavailable ?? 0);
648
+ const unavailableWorkflowRuns = Number(report.coverage?.workflowRuns?.unavailable ?? 0);
649
+ if (!hasWorkflowContext && (unavailablePrOpenDiff > 0 || unavailableWorkflowRuns > 0)) {
650
+ const evidence = [
651
+ unavailablePrOpenDiff > 0
652
+ ? `PR-open diff coverage unavailable for ${formatCount(unavailablePrOpenDiff, "PR")}`
653
+ : null,
654
+ unavailableWorkflowRuns > 0
655
+ ? `workflow-run coverage unavailable for ${formatCount(unavailableWorkflowRuns, "PR")}`
656
+ : null,
657
+ ].filter(Boolean).join("; ");
658
+ suggestions.push({
659
+ id: "workflow-context",
660
+ area: "Workflow context",
661
+ evidence: `${evidence}.`,
662
+ suggestion: "Configure repository-profile workflow context, such as primary merge method or branch strategy, so unavailable diff-growth or workflow-run evidence is interpreted with maintainer-confirmed context instead of guesses.",
663
+ });
664
+ }
665
+
666
+ return suggestions;
667
+ }
668
+
588
669
  export function normalizeConfiguredWorkflowContext(workflowContext) {
589
670
  if (!workflowContext || typeof workflowContext !== "object" || Array.isArray(workflowContext)) {
590
671
  return null;
@@ -621,6 +702,39 @@ export function hasConfiguredWorkflowContext(configuredWorkflow) {
621
702
  return configuredWorkflowEntries(configuredWorkflow).length > 0;
622
703
  }
623
704
 
705
+ function configuredWorkflowEntry(configuredWorkflow, field) {
706
+ const entry = configuredWorkflowEntries(configuredWorkflow).find(candidate => candidate.field === field);
707
+ return entry ?? null;
708
+ }
709
+
710
+ export function workflowDataCaveats(report = {}) {
711
+ if (!hasConfiguredWorkflowContext(report.configuredWorkflow)) return [];
712
+
713
+ const unavailablePrOpenDiff = Number(report.coverage?.prOpenDiff?.unavailable ?? 0);
714
+ const unavailableWorkflowRuns = Number(report.coverage?.workflowRuns?.unavailable ?? 0);
715
+ if (unavailablePrOpenDiff <= 0 && unavailableWorkflowRuns <= 0) return [];
716
+
717
+ const mergeMethod = configuredWorkflowEntry(report.configuredWorkflow, "primaryMergeMethod");
718
+ const caveats = [];
719
+ if (unavailablePrOpenDiff > 0) {
720
+ const prefix = mergeMethod
721
+ ? `Profile context says primary merge method is ${mergeMethod.valueLabel}; this is configured profile context, not observed evidence.`
722
+ : "Configured workflow fields are profile context, not observed evidence.";
723
+ const methodLimit = {
724
+ squash_merge: "Squash merge keeps final PR metadata available through GitHub PR data, but it does not preserve the original branch commit topology on the base branch.",
725
+ rebase_merge: "Rebase merge keeps final PR metadata available through GitHub PR data, but rebased commits do not provide a reliable open-time diff snapshot from base-branch history.",
726
+ merge_commit: "Merge commits can preserve a merge boundary, but this analyzer still uses GitHub PR data for final/current PR metadata and does not reconstruct PR-open size from merge commits or branch history.",
727
+ }[mergeMethod?.value] ?? "Final/current PR metadata can come from GitHub PR data, but PR-open diff growth still needs captured open-time evidence.";
728
+ caveats.push(`${prefix} ${methodLimit} PR-open diff growth requires an open-time snapshot or equivalent captured state.`);
729
+ }
730
+
731
+ if (unavailableWorkflowRuns > 0) {
732
+ caveats.push("Unavailable workflow-run coverage remains a GitHub collection coverage limit; configured workflow context can explain the repository's expected workflow shape, but it is not observed run evidence.");
733
+ }
734
+
735
+ return caveats;
736
+ }
737
+
624
738
  function evidenceSignature(bottleneck) {
625
739
  return (bottleneck.observedData ?? [])
626
740
  .map(evidence => evidence.number)
@@ -1294,6 +1408,27 @@ function renderPrClassContext(prClasses) {
1294
1408
  ].join("\n");
1295
1409
  }
1296
1410
 
1411
+ function renderProfileSuggestions(report) {
1412
+ const suggestions = profileSuggestions(report);
1413
+ if (!suggestions.length) return "";
1414
+
1415
+ return [
1416
+ "## Profile Suggestions",
1417
+ "",
1418
+ "Optional profile improvements based on this report's existing evidence. These suggestions do not change scores, rankings, CSV exports, or JSON report fields.",
1419
+ "",
1420
+ renderMarkdownTable(
1421
+ ["Profile area", "Evidence", "Suggested next step"],
1422
+ suggestions.map(suggestion => [
1423
+ suggestion.area,
1424
+ suggestion.evidence,
1425
+ suggestion.suggestion,
1426
+ ]),
1427
+ ),
1428
+ "",
1429
+ ].join("\n");
1430
+ }
1431
+
1297
1432
  function renderConfiguredWorkflowContext(configuredWorkflow) {
1298
1433
  const entries = configuredWorkflowEntries(configuredWorkflow);
1299
1434
  if (!entries.length) return "";
@@ -1311,6 +1446,18 @@ function renderConfiguredWorkflowContext(configuredWorkflow) {
1311
1446
  ].join("\n");
1312
1447
  }
1313
1448
 
1449
+ function renderWorkflowDataCaveats(report) {
1450
+ const caveats = workflowDataCaveats(report);
1451
+ if (!caveats.length) return "";
1452
+
1453
+ return [
1454
+ "## Workflow Data Caveats",
1455
+ "",
1456
+ renderList(caveats),
1457
+ "",
1458
+ ].join("\n");
1459
+ }
1460
+
1314
1461
  function classDominanceCaveat(bottleneck) {
1315
1462
  return bottleneck.classDominance?.status === "single_class_dominates"
1316
1463
  ? bottleneck.classDominance.note
@@ -1465,6 +1612,9 @@ export function renderRepositoryFrictionMarkdown(report) {
1465
1612
  ...(hasConfiguredWorkflowContext(report.configuredWorkflow)
1466
1613
  ? [renderConfiguredWorkflowContext(report.configuredWorkflow)]
1467
1614
  : []),
1615
+ ...(workflowDataCaveats(report).length
1616
+ ? [renderWorkflowDataCaveats(report)]
1617
+ : []),
1468
1618
  "## Evidence Quality And Coverage",
1469
1619
  "",
1470
1620
  renderCoverageSummary(report.coverage),
@@ -1478,6 +1628,7 @@ export function renderRepositoryFrictionMarkdown(report) {
1478
1628
  renderKeyFindings(report),
1479
1629
  "",
1480
1630
  renderPrClassContext(report.prClasses),
1631
+ renderProfileSuggestions(report),
1481
1632
  renderSharedSignalInterpretation(sharedSignals),
1482
1633
  renderSensitivityAnalysis(report.sensitivity),
1483
1634
  "## How Bottlenecks Are Prioritized",
@@ -1532,12 +1683,18 @@ export function renderRepositoryFrictionMarkdown(report) {
1532
1683
  "",
1533
1684
  "- Pull requests are selected upstream by the collection or fixture workflow; this renderer explains the resulting metrics summary.",
1534
1685
  "- File roles and functional surfaces come from repository-profile classification, not from language names alone.",
1686
+ profileSuggestions(report).length
1687
+ ? "- Profile suggestions are optional interpretation improvements derived from existing report evidence; they do not change scores, rankings, CSV exports, or JSON report fields."
1688
+ : "- No profile suggestion thresholds were triggered by this report's PR class, role, functional-surface, or workflow-coverage evidence.",
1535
1689
  "- Bottlenecks are ranked by their strongest representative observed signal, with stable category order only used to break ties.",
1536
1690
  "- Recommendations are inferred from transparent component evidence and representative PR examples; they are not automated changes.",
1537
1691
  "- Missing or partial GitHub data remains visible in coverage tables rather than being inferred from unrelated fields.",
1538
1692
  ...(hasConfiguredWorkflowContext(report.configuredWorkflow)
1539
1693
  ? ["- Configured workflow context is user-configured repository-profile context; it does not change scoring, ranking, CSV exports, or PR class matching."]
1540
1694
  : []),
1695
+ ...(workflowDataCaveats(report).length
1696
+ ? ["- Workflow data caveats explain unavailable evidence using configured profile context without treating merge method as observed evidence or reconstructing open-time PR size."]
1697
+ : []),
1541
1698
  "- Sensitivity analysis, when present, excludes one dominant representative PR at a time to show robustness context without changing the baseline ranking.",
1542
1699
  report.analysisFilter?.excludedPrClasses?.length
1543
1700
  ? "- PR class filtering was explicitly applied before metrics and ranking; PR class context still supports interpretation of the filtered sample."