delivery-friction-analyzer 0.9.0 → 0.11.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 introduced `friction-report.v1`, a deterministic report generated from a `friction-metrics.v1` repository metrics summary. Milestone 4 adds 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.
3
+ Milestone 3 introduced `friction-report.v1`, a deterministic report generated from a `friction-metrics.v1` repository metrics summary. Milestone 4 added configured workflow context. Milestone 5 adds sanitized contributor-source metadata for configured `.all-contributorsrc` coverage without raw contributor file contents or individual rankings. 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
 
@@ -28,6 +28,7 @@ The command reads local `friction-metrics.v1` JSON and writes deterministic `fri
28
28
  - `targetRepository`: analyzed repository identity; live analysis sample size is encoded as `targetRepository.analysisPullRequestLimit` from collection metadata.
29
29
  - `analysisFilter`: optional metadata for explicit filters applied before metrics computation, including excluded PR classes and before/after PR counts.
30
30
  - `configuredWorkflow`: optional user-configured workflow context from the repository profile. It is not observed GitHub evidence and does not change scoring, ranking, CSV exports, or PR class matching.
31
+ - `contributorSource`: optional sanitized contributor-source metadata from the repository profile and collection path. It records source type, path, coverage status, parsed hint count, and guardrail note. It does not include raw contributor file contents or contributor rankings.
31
32
  - `summary`: repository totals and top bottleneck identifiers.
32
33
  - `coverage`: PR-open diff, workflow-run, and review-thread coverage counts plus caveats.
33
34
  - `commentSources`: total and source-grouped review comments for Copilot, human, bot, scanner, author replies, and unknown sources.
@@ -57,10 +58,12 @@ The Markdown renderer presents the same report data for human review:
57
58
  - a compact recommendation-category snapshot before detailed bottlenecks, with the full category reference retained later in the report;
58
59
  - a short "How To Read This Report" guide that distinguishes observed evidence, interpretation, recommendations, and caveats;
59
60
  - a configured workflow context section only when repository profile workflow fields are present, labeled as user-configured profile context rather than observed GitHub evidence;
61
+ - a contributor source context section only when a contributor source is configured, labeled as metadata that may improve comment-source classification coverage without changing scores, authorship conclusions, reviewer attribution, CSV export shape, person-level CSV output, or individual ranking guardrails;
62
+ - workflow data caveats when configured workflow context clarifies unavailable PR-open diff or workflow-run evidence;
60
63
  - evidence-quality and coverage tables before detailed recommendations;
61
64
  - key findings that highlight top bottlenecks, strongest displayed signal, outlier caveats, PR class caveats, and coverage caveats;
62
65
  - a PR class context table that shows analyzed PR counts, changed lines, sample share, and classification sources by class;
63
- - profile suggestions when fallback `unknown` PR classes or unknown file role/surface evidence cross deterministic thresholds;
66
+ - profile suggestions when fallback `unknown` PR classes, unknown file role/surface evidence, or omitted workflow context with relevant unavailable coverage cross deterministic thresholds;
64
67
  - a top-level shared-signal interpretation callout when multiple displayed bottlenecks share a ranking key or representative PR evidence;
65
68
  - outlier and sensitivity analysis when displayed examples are dominated by one PR;
66
69
  - 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;
@@ -74,10 +77,12 @@ The Markdown renderer presents the same report data for human review:
74
77
  - a reference to the detailed `methodology.md` artifact generated by full live analysis;
75
78
  - guardrails, follow-up, and artifact-sensitivity guidance.
76
79
 
77
- Markdown output should not include individual contributor or reviewer rankings.
80
+ Markdown output should not include raw contributor file contents or individual contributor/reviewer rankings.
78
81
  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.
79
82
  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.
80
83
 
84
+ 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.
85
+
81
86
  ## Recommendation Boundaries
82
87
 
83
88
  Recommendations are inferred from transparent component metrics and representative PR examples. They suggest workflow interventions such as readiness gates, preflight scripts, smaller milestones, planning artifacts, or scope control. They do not automate repository changes.
@@ -94,7 +99,7 @@ The M3 report contract supports these recommendation categories:
94
99
 
95
100
  ## Coverage And Confidence
96
101
 
97
- 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.
102
+ 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.
98
103
 
99
104
  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.
100
105
 
@@ -111,7 +116,8 @@ Full live analysis writes `methodology.md` as a hybrid artifact: stable explanat
111
116
  - target repository and report/metric versions;
112
117
  - profile path when available;
113
118
  - configured workflow context when supplied by the repository profile, labeled as user-configured context rather than observed GitHub evidence;
114
- - profile suggestions when PR class or file/path profile evidence crosses deterministic fallback thresholds, or an explicit no-threshold note when none were triggered;
119
+ - contributor-source context when configured, including source type, path, coverage status, and parsed hint count, without raw contributor contents or rankings;
120
+ - 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;
115
121
  - requested and collected PR counts;
116
122
  - collection coverage status and API-family diagnostics;
117
123
  - scoring, ranking, dominance, sensitivity, and limitation explanations;
@@ -136,6 +142,8 @@ Minimum CSV column groups:
136
142
 
137
143
  Empty CSV cells mean unavailable or not applicable. Numeric zero should be used only for observed or computed zero counts. Count columns that depend on optional GitHub coverage should keep source or coverage labels nearby so spreadsheet readers can tell unavailable evidence apart from observed zeroes. CSVs must not include raw comment bodies, raw workflow logs, tokens, secret-bearing environment details, or individual contributor/reviewer rankings.
138
144
 
145
+ Contributor-source coverage appears in `collection-coverage.csv` as the `contributor_source` API family when configured. CSVs may include aggregate comment-source counts influenced by contributor hints, but they must not include raw `.all-contributorsrc` contents, contributor names, contributor login lists, or person rankings.
146
+
139
147
  ## Optional Downstream Narrative Drafting
140
148
 
141
149
  The existing `friction-report.json` artifact plus the curated CSV exports are sufficient context for an optional local workflow where a separate model drafts a narrative summary. M2 does not justify a new `report-context.json`, CLI flag, fixture output, or artifact write path.
@@ -9,6 +9,7 @@ Milestone 1 defines the first normalized fixture shape. It is intentionally limi
9
9
  - `TargetRepository`: owner/name/default branch/visibility/window for the repository being analyzed.
10
10
  - `AnalysisFilter`: optional metadata for downstream analysis filters applied after collection and normalization. When present, it records excluded PR classes, the original collected PR count, and the filtered PR count.
11
11
  - `RepositoryLanguageDistribution`: byte counts from `GET /repos/{owner}/{repo}/languages`, stored as context only.
12
+ - `ContributorSource`: optional sanitized metadata from a configured structured contributor source. It records source type, path, coverage status, diagnostics, and parsed hint count. It must not preserve raw contributor file contents or contributor login lists.
12
13
  - `PullRequest`: source IDs, author login when known, URL, state, PR class evidence, lifecycle timestamps, final diff shape, PR-open diff source confidence, optional PR-open additions/deletions/changed-file counts when direct or reconstructed data is available, files, reviews, review decision summary, review threads, comments, checks, and workflow-run coverage.
13
14
  - `PrClassSummary`: profile-driven PR class, classification source, and winning rule ID. Unmatched PRs use `class: "unknown"`, `classificationSource: "fallback_rule"`, and `ruleId: null`.
14
15
  - `Commit`: commit OID, authored timestamp, committed timestamp when present, and message headline.
@@ -24,6 +25,8 @@ Milestone 1 defines the first normalized fixture shape. It is intentionally limi
24
25
 
25
26
  Normalized data must preserve whether a value came from a public API, GraphQL thread query, repository profile rule, fallback rule, internal UI partial, or unavailable coverage. Later metric and report stages should use those source labels before making confidence claims.
26
27
 
28
+ Configured contributor hints may classify otherwise-unknown comment authors into existing comment-source groups during the analysis run, but parsed login lists are transient and must not be persisted in generated artifacts. Hints must not change PR authorship, reviewer attribution, scoring formulas, or person-level report/CSV rows.
29
+
27
30
  ## Analysis Filters
28
31
 
29
32
  `source-bundle.json` remains the full collected sample. When a local analysis excludes one or more PR classes, `normalized.json` contains the filtered PR set and an `analysisFilter` object:
@@ -6,6 +6,7 @@ The MVP runs locally with the user's GitHub credentials. Reports must expose una
6
6
  | --- | --- | --- | --- | --- | --- |
7
7
  | REST repository metadata | unauthenticated or token | public read | `repo` or fine-grained metadata read | repository visibility, default branch confirmation | mark repository metadata partial |
8
8
  | REST languages | unauthenticated or token | public read | `repo` or fine-grained contents/metadata read | language byte distribution context | omit language context; do not infer file role from language |
9
+ | REST repository contents for configured contributor source | token recommended | public contents read | `repo` or fine-grained contents read | optional `.all-contributorsrc` contributor hints for comment-source classification metadata | mark contributor-source coverage unavailable, malformed, partial, or unsupported; do not infer identities from other sources |
9
10
  | Pull request metadata | `gh` token / GraphQL-backed PR fields | public read | `repo` or pull request read | lifecycle, final diff shape, files, commits, reviews | mark PR inventory partial |
10
11
  | REST review comments | token recommended | public read | `repo` or pull request read | individual review comments and comment paths | source breakdown based only on reviews if unavailable |
11
12
  | GraphQL review threads | token | public read | `repo` or pull request read | thread count, resolved state, outdated state | thread metrics unavailable; comment count can remain REST-only |
@@ -18,8 +19,10 @@ The MVP runs locally with the user's GitHub credentials. Reports must expose una
18
19
  The analyzer should record:
19
20
 
20
21
  - API family attempted.
21
- - Coverage status: `available`, `partial`, `unavailable`, or `rate_limited`.
22
+ - Coverage status: `available`, `partial`, `unavailable`, `malformed`, `unsupported`, or `rate_limited`.
22
23
  - Required scope or permission when known.
23
24
  - Impact on downstream metrics.
24
25
 
25
26
  Missing coverage must flow into report metadata. For example, unavailable GraphQL review threads should disable thread-resolution metrics while preserving REST review-comment counts.
27
+
28
+ Contributor-source coverage is optional. Missing, inaccessible, malformed, or unsupported contributor files should not fail analysis and should not cause the analyzer to infer private identity data from names, emails, commits, or external services.
@@ -160,3 +160,27 @@ Example:
160
160
  Use stable identifiers exactly as shown above. Display labels such as "squash merges" or "release PRs" belong in CLI prompts or documentation, not in profile data.
161
161
 
162
162
  When interactive setup writes profile changes, it preserves deterministic two-space JSON formatting in place. If an existing profile uses other formatting, setup writes a generated profile copy and prints that generated path in completion output instead of rewriting the original file.
163
+
164
+ ## Contributor Source
165
+
166
+ `contributors` is optional user-configured context for structured contributor hints. The first supported source is `.all-contributorsrc` as `all_contributors` JSON. When omitted, analysis runs normally without contributor hints.
167
+
168
+ Supported fields:
169
+
170
+ - `sourceType`: optional, defaults to `all_contributors` when `contributors` is present.
171
+ - `path`: optional trimmed, slash-delimited repository-relative path, defaults to `.all-contributorsrc`.
172
+
173
+ Example:
174
+
175
+ ```json
176
+ {
177
+ "contributors": {
178
+ "sourceType": "all_contributors",
179
+ "path": ".all-contributorsrc"
180
+ }
181
+ }
182
+ ```
183
+
184
+ Markdown contributor files such as `CONTRIBUTORS.md` are not supported contributor sources in this milestone. The analyzer records them as unsupported/unparsed coverage when encountered and does not parse Markdown into identities.
185
+
186
+ Contributor hints may improve repository-level comment-source classification coverage, such as classifying a configured contributor login as an existing human-reviewer source. They do not change scoring formulas, PR authorship conclusions, reviewer attribution, PR class matching, CSV export shape, or individual rankings. Generated artifacts expose only contributor-source metadata such as type, path, status, diagnostics, and parsed hint count; they do not include raw contributor file contents, contributor login lists, or contributor rankings.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "delivery-friction-analyzer",
3
- "version": "0.9.0",
3
+ "version": "0.11.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 — Contributor Source Configuration
6
+
7
+ - What changed: Repository profiles can now configure `.all-contributorsrc` as a structured contributor source, and analysis records contributor-source coverage while using sanitized hints only for aggregate comment-source classification.
8
+ - Why it matters: Maintainers can improve comment-source coverage without parsing Markdown contributor files, changing scores, or emitting raw contributor contents or person rankings in reports or CSVs.
9
+ - Who is affected: Maintainers authoring repository profiles or reviewing generated reports, methodology, and coverage artifacts.
10
+ - Action needed: Optional; add `contributors.sourceType: "all_contributors"` and a repository-relative `contributors.path` when a target repository has a trusted `.all-contributorsrc`.
11
+ - PR: #47
12
+
13
+ ### 2026-06-20 — Workflow Data Caveats
14
+
15
+ - 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.
16
+ - 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.
17
+ - Who is affected: Maintainers and contributors reviewing generated reports or authoring repository profiles with workflow context.
18
+ - Action needed: Optional; add repository-profile workflow context when unavailable coverage would be easier to interpret with maintainer-confirmed merge or branch assumptions.
19
+ - PR: https://github.com/hannasdev/delivery-friction-analyzer/pull/46
20
+
5
21
  ### 2026-06-20 — Profile Improvement Suggestions
6
22
 
7
23
  - What changed: Markdown friction reports and methodology now suggest PR class or file/path profile improvements when fallback `unknown` evidence dominates the analyzed sample.
@@ -36,6 +36,32 @@
36
36
  }
37
37
  }
38
38
  },
39
+ "contributorSource": {
40
+ "type": "object",
41
+ "additionalProperties": false,
42
+ "required": ["sourceType", "path", "coverage", "hintCount"],
43
+ "properties": {
44
+ "sourceType": { "enum": ["all_contributors"] },
45
+ "path": { "type": "string" },
46
+ "coverage": {
47
+ "type": "object",
48
+ "additionalProperties": false,
49
+ "required": ["family", "source", "status", "attempts", "diagnostics", "downstreamImpact"],
50
+ "properties": {
51
+ "family": { "const": "contributor_source" },
52
+ "source": { "type": "string" },
53
+ "status": { "enum": ["available", "partial", "unavailable", "malformed", "unsupported", "rate_limited"] },
54
+ "attempts": { "type": "integer", "minimum": 1 },
55
+ "diagnostics": {
56
+ "type": "array",
57
+ "items": { "type": "string" }
58
+ },
59
+ "downstreamImpact": { "type": ["string", "null"] }
60
+ }
61
+ },
62
+ "hintCount": { "type": "integer", "minimum": 0 }
63
+ }
64
+ },
39
65
  "pullRequests": {
40
66
  "type": "array",
41
67
  "items": {
@@ -103,6 +103,29 @@
103
103
  }
104
104
  },
105
105
  "minProperties": 1
106
+ },
107
+ "contributors": {
108
+ "type": "object",
109
+ "additionalProperties": false,
110
+ "properties": {
111
+ "sourceType": {
112
+ "enum": ["all_contributors"]
113
+ },
114
+ "path": {
115
+ "type": "string",
116
+ "minLength": 1,
117
+ "pattern": "\\S",
118
+ "not": {
119
+ "anyOf": [
120
+ { "pattern": "^/" },
121
+ { "pattern": "^\\s|\\s$" },
122
+ { "pattern": "\\\\" },
123
+ { "pattern": "(^|/)\\.\\.(/|$)" }
124
+ ]
125
+ }
126
+ }
127
+ },
128
+ "minProperties": 1
106
129
  }
107
130
  }
108
131
  }
@@ -24,6 +24,7 @@ import {
24
24
  WORKFLOW_RELEASE_STRATEGIES,
25
25
  assertValidWorkflowContext,
26
26
  } from "../profile/workflow.js";
27
+ import { assertValidContributorSource } from "../profile/contributor-source.js";
27
28
 
28
29
  const ALLOWED_OPTIONS = new Set([
29
30
  "repo",
@@ -311,6 +312,7 @@ function normalizeMultiSelectAnswer(raw, prompt) {
311
312
  function validateProfile(profile) {
312
313
  assertValidPrClassRules(profile);
313
314
  assertValidWorkflowContext(profile);
315
+ assertValidContributorSource(profile);
314
316
  }
315
317
 
316
318
  function parseProfileJson(text) {
@@ -1128,7 +1130,7 @@ function attachCollectionCoverage(report, sourceBundle) {
1128
1130
  return {
1129
1131
  ...report,
1130
1132
  collectionCoverage: sourceBundle.coverage,
1131
- artifactSensitivity: "Generated artifacts may include repository names, PR URLs, titles, file paths, comment metadata, curated CSV evidence, and coverage diagnostics. Treat them as local/private unless intentionally shared.",
1133
+ artifactSensitivity: "Generated artifacts may include repository names, PR URLs, titles, file paths, comment metadata, contributor-source metadata, curated CSV evidence, and coverage diagnostics. Raw contributor file contents and individual contributor rankings are not emitted. Treat artifacts as local/private unless intentionally shared.",
1132
1134
  };
1133
1135
  }
1134
1136
 
@@ -1214,6 +1216,7 @@ export async function runAnalyzeGithub(options, {
1214
1216
  provider,
1215
1217
  collectedAt: now(),
1216
1218
  isValidationTarget: options.isValidationTarget,
1219
+ contributors: repositoryProfile.contributors,
1217
1220
  });
1218
1221
 
1219
1222
  if (options.dryRun) {
@@ -1238,7 +1241,10 @@ export async function runAnalyzeGithub(options, {
1238
1241
  );
1239
1242
  const metrics = computeRepositoryMetrics(normalized);
1240
1243
  const report = attachCollectionCoverage(
1241
- generateRepositoryFrictionReport(metrics, { workflowContext: repositoryProfile.workflow }),
1244
+ generateRepositoryFrictionReport(metrics, {
1245
+ workflowContext: repositoryProfile.workflow,
1246
+ contributorSource: normalized.contributorSource,
1247
+ }),
1242
1248
  sourceBundle,
1243
1249
  );
1244
1250
  const markdown = `${renderRepositoryFrictionMarkdown(report)}${collectionCoverageMarkdown(sourceBundle)}`;
@@ -2,6 +2,8 @@ export const COVERAGE_STATUS = Object.freeze({
2
2
  available: "available",
3
3
  partial: "partial",
4
4
  unavailable: "unavailable",
5
+ malformed: "malformed",
6
+ unsupported: "unsupported",
5
7
  rateLimited: "rate_limited",
6
8
  });
7
9
 
@@ -65,8 +67,14 @@ export function mergeCoverageEntries({ family, source, entries, downstreamImpact
65
67
  let status = COVERAGE_STATUS.available;
66
68
  if (statuses.has(COVERAGE_STATUS.rateLimited)) {
67
69
  status = COVERAGE_STATUS.rateLimited;
70
+ } else if (statuses.size === 1 && statuses.has(COVERAGE_STATUS.unsupported)) {
71
+ status = COVERAGE_STATUS.unsupported;
72
+ } else if (statuses.size === 1 && statuses.has(COVERAGE_STATUS.malformed)) {
73
+ status = COVERAGE_STATUS.malformed;
68
74
  } else if (statuses.has(COVERAGE_STATUS.unavailable)) {
69
75
  status = statuses.size > 1 ? COVERAGE_STATUS.partial : COVERAGE_STATUS.unavailable;
76
+ } else if (statuses.has(COVERAGE_STATUS.unsupported) || statuses.has(COVERAGE_STATUS.malformed)) {
77
+ status = COVERAGE_STATUS.partial;
70
78
  } else if (statuses.has(COVERAGE_STATUS.partial)) {
71
79
  status = COVERAGE_STATUS.partial;
72
80
  }
@@ -105,6 +105,13 @@ function requireGraphqlPage(page, label) {
105
105
  return page;
106
106
  }
107
107
 
108
+ function encodeContentPath(path) {
109
+ return String(path)
110
+ .split("/")
111
+ .map(segment => encodeURIComponent(segment))
112
+ .join("/");
113
+ }
114
+
108
115
  function wait(ms) {
109
116
  return new Promise(resolve => setTimeout(resolve, ms));
110
117
  }
@@ -153,6 +160,10 @@ export function createGhCliProvider({
153
160
  return runGhJson(["api", `repos/${owner}/${name}/languages`]);
154
161
  },
155
162
 
163
+ async getRepositoryContent({ owner, name, path }) {
164
+ return runGhJson(["api", `repos/${owner}/${name}/contents/${encodeContentPath(path)}`]);
165
+ },
166
+
156
167
  async listMergedPullRequests({ owner, name, limit }) {
157
168
  const prs = await runGhJson([
158
169
  "pr",
@@ -1,4 +1,10 @@
1
1
  import { validateTargetRepository } from "../contracts/target-repository.js";
2
+ import {
3
+ assertValidContributorSource,
4
+ normalizeContributorSourceConfig,
5
+ parseAllContributorsHints,
6
+ withTransientContributorHints,
7
+ } from "../profile/contributor-source.js";
2
8
  import {
3
9
  COVERAGE_STATUS,
4
10
  classifyCoverageStatus,
@@ -187,6 +193,90 @@ function unavailableWorkflowRuns() {
187
193
  };
188
194
  }
189
195
 
196
+ function isMarkdownContributorPath(path = "") {
197
+ return /\.(md|mdx|markdown)$/i.test(String(path));
198
+ }
199
+
200
+ function unsupportedContributorSource(config = {}) {
201
+ const sourceType = config.sourceType ?? "unknown";
202
+ const path = config.path ?? null;
203
+ const diagnostic = path && isMarkdownContributorPath(path)
204
+ ? `Contributor source path '${path}' is a Markdown file; Markdown contributor files are intentionally not parsed in this milestone.`
205
+ : `Contributor source type '${sourceType}' is unsupported.`;
206
+ const coverage = coverageEntry({
207
+ family: "contributor_source",
208
+ source: "rest:/repos/{owner}/{repo}/contents/{path}",
209
+ status: COVERAGE_STATUS.unsupported,
210
+ diagnostics: [diagnostic],
211
+ downstreamImpact: "Contributor-aware comment-source hints are unavailable; scoring and person-level outputs are unchanged.",
212
+ });
213
+ return withTransientContributorHints({
214
+ sourceType,
215
+ path,
216
+ coverage,
217
+ });
218
+ }
219
+
220
+ function unavailableContributorSource(config = {}, error) {
221
+ const coverage = coverageEntry({
222
+ family: "contributor_source",
223
+ source: "rest:/repos/{owner}/{repo}/contents/{path}",
224
+ status: classifyCoverageStatus(error),
225
+ diagnostics: [redactDiagnostic(error?.message ?? error)],
226
+ downstreamImpact: "Contributor-aware comment-source hints are unavailable; scoring and person-level outputs are unchanged.",
227
+ });
228
+ return withTransientContributorHints({
229
+ sourceType: config.sourceType,
230
+ path: config.path,
231
+ coverage,
232
+ });
233
+ }
234
+
235
+ function contentTextFromResponse(response) {
236
+ if (typeof response === "string") return response;
237
+ if (response && typeof response.content === "string" && response.encoding === "base64") {
238
+ return Buffer.from(response.content.replace(/\s+/g, ""), "base64").toString("utf8");
239
+ }
240
+ if (response && typeof response.content === "string") {
241
+ return response.content;
242
+ }
243
+ throw new Error("GitHub content response did not include readable file content.");
244
+ }
245
+
246
+ async function collectContributorSource({ targetInput, provider, contributors }) {
247
+ if (contributors == null) return null;
248
+ assertValidContributorSource({ contributors });
249
+ const config = normalizeContributorSourceConfig(contributors);
250
+ if (!config) return null;
251
+ if (config.sourceType !== "all_contributors" || isMarkdownContributorPath(config.path)) {
252
+ return unsupportedContributorSource(config);
253
+ }
254
+ if (typeof provider.getRepositoryContent !== "function") {
255
+ return unavailableContributorSource(config, new Error("provider does not support repository content collection."));
256
+ }
257
+
258
+ try {
259
+ const response = await provider.getRepositoryContent({ ...targetInput, path: config.path });
260
+ const parsed = parseAllContributorsHints(contentTextFromResponse(response));
261
+ const coverage = coverageEntry({
262
+ family: "contributor_source",
263
+ source: "rest:/repos/{owner}/{repo}/contents/{path}",
264
+ status: parsed.status,
265
+ diagnostics: parsed.diagnostics,
266
+ downstreamImpact: parsed.status === COVERAGE_STATUS.available || parsed.status === COVERAGE_STATUS.partial
267
+ ? "Contributor-aware comment-source hints are available; scoring and person-level outputs are unchanged."
268
+ : "Contributor-aware comment-source hints are unavailable; scoring and person-level outputs are unchanged.",
269
+ });
270
+ return withTransientContributorHints({
271
+ sourceType: config.sourceType,
272
+ path: config.path,
273
+ coverage,
274
+ }, parsed.hints);
275
+ } catch (error) {
276
+ return unavailableContributorSource(config, error);
277
+ }
278
+ }
279
+
190
280
  function mapPullRequestDetails(details) {
191
281
  return {
192
282
  number: details.number,
@@ -261,7 +351,14 @@ export function buildCoverageSummary(entries) {
261
351
  const statuses = new Set(entries.map(entry => entry.status));
262
352
  if (statuses.has(COVERAGE_STATUS.rateLimited)) return COVERAGE_STATUS.rateLimited;
263
353
  if (statuses.size === 1 && statuses.has(COVERAGE_STATUS.unavailable)) return COVERAGE_STATUS.unavailable;
264
- if (statuses.has(COVERAGE_STATUS.unavailable) || statuses.has(COVERAGE_STATUS.partial)) return COVERAGE_STATUS.partial;
354
+ if (statuses.size === 1 && statuses.has(COVERAGE_STATUS.unsupported)) return COVERAGE_STATUS.unsupported;
355
+ if (statuses.size === 1 && statuses.has(COVERAGE_STATUS.malformed)) return COVERAGE_STATUS.malformed;
356
+ if (
357
+ statuses.has(COVERAGE_STATUS.unavailable)
358
+ || statuses.has(COVERAGE_STATUS.partial)
359
+ || statuses.has(COVERAGE_STATUS.unsupported)
360
+ || statuses.has(COVERAGE_STATUS.malformed)
361
+ ) return COVERAGE_STATUS.partial;
265
362
  return COVERAGE_STATUS.available;
266
363
  }
267
364
 
@@ -274,6 +371,7 @@ export async function collectGitHubSourceBundle({
274
371
  collectedAt = new Date().toISOString(),
275
372
  analysisPullRequestLimit,
276
373
  isValidationTarget = false,
374
+ contributors = null,
277
375
  } = {}) {
278
376
  if (!provider) {
279
377
  throw new Error("provider is required.");
@@ -313,6 +411,12 @@ export async function collectGitHubSourceBundle({
313
411
  run: () => provider.getLanguages(targetInput),
314
412
  });
315
413
 
414
+ const contributorSource = await collectContributorSource({
415
+ targetInput,
416
+ provider,
417
+ contributors,
418
+ });
419
+
316
420
  const inventory = (await provider.listMergedPullRequests({ ...targetInput, limit: targetPullRequestLimit }))
317
421
  .sort((left, right) => String(right.mergedAt ?? "").localeCompare(String(left.mergedAt ?? "")))
318
422
  .slice(0, targetPullRequestLimit);
@@ -424,6 +528,7 @@ export async function collectGitHubSourceBundle({
424
528
  diagnostics: ["PR-open diff reconstruction and snapshot capture are intentionally not implemented in M1."],
425
529
  downstreamImpact: "Diff growth metrics must remain unavailable.",
426
530
  }),
531
+ ...(contributorSource ? [contributorSource.coverage] : []),
427
532
  ];
428
533
 
429
534
  return {
@@ -450,6 +555,7 @@ export async function collectGitHubSourceBundle({
450
555
  bytesByLanguage: languagesAttempt.value ?? {},
451
556
  coverage: languagesAttempt.coverage,
452
557
  },
558
+ ...(contributorSource ? { contributorSource } : {}),
453
559
  pullRequests,
454
560
  };
455
561
  }
@@ -11,11 +11,12 @@ const SOURCE = {
11
11
 
12
12
  export const COMMENT_SOURCES = Object.freeze(Object.values(SOURCE));
13
13
 
14
- export function classifyCommentSource(author = {}, { pullRequestAuthorLogin } = {}) {
14
+ export function classifyCommentSource(author = {}, { pullRequestAuthorLogin, contributorHints } = {}) {
15
15
  const login = String(author.login ?? "").toLowerCase();
16
16
  const type = String(author.type ?? author.__typename ?? "").toLowerCase();
17
17
  const url = String(author.htmlUrl ?? author.html_url ?? "").toLowerCase();
18
18
  const prAuthorLogin = String(pullRequestAuthorLogin ?? "").toLowerCase();
19
+ const contributorLogins = contributorHints?.logins;
19
20
 
20
21
  if (login === "copilot" || login === "copilot-pull-request-reviewer" || url.includes("/apps/copilot-pull-request-reviewer")) {
21
22
  return SOURCE.copilot;
@@ -41,6 +42,10 @@ export function classifyCommentSource(author = {}, { pullRequestAuthorLogin } =
41
42
  return SOURCE.unknownBot;
42
43
  }
43
44
 
45
+ if (login && contributorLogins?.has?.(login)) {
46
+ return SOURCE.human;
47
+ }
48
+
44
49
  if (type === "user" || author.authorAssociation) {
45
50
  return SOURCE.human;
46
51
  }
@@ -1,5 +1,10 @@
1
1
  import { classifyCommentSource, groupByCommentSource } from "../github/comment-source.js";
2
2
  import { classifyFilePath } from "../profile/file-role.js";
3
+ import {
4
+ assertValidContributorSource,
5
+ contributorHintsFromSource,
6
+ contributorSourceArtifactMetadata,
7
+ } from "../profile/contributor-source.js";
3
8
  import { assertValidPrClassRules, classifyPullRequest } from "../profile/pr-class.js";
4
9
  import { assertValidWorkflowContext } from "../profile/workflow.js";
5
10
 
@@ -114,6 +119,8 @@ export function normalizeFixtureBundle(bundle, { repositoryProfile } = {}) {
114
119
  const profile = repositoryProfile ?? {};
115
120
  assertValidPrClassRules(profile);
116
121
  assertValidWorkflowContext(profile);
122
+ assertValidContributorSource(profile);
123
+ const contributorHints = contributorHintsFromSource(bundle.contributorSource);
117
124
 
118
125
  const pullRequests = (bundle.pullRequests ?? []).map(pr => {
119
126
  const reviewDates = (pr.reviews ?? []).map(review => review.submittedAt);
@@ -155,7 +162,7 @@ export function normalizeFixtureBundle(bundle, { repositoryProfile } = {}) {
155
162
  },
156
163
  reviewComments: {
157
164
  totalCount: threadComments.length,
158
- bySource: groupByCommentSource(threadComments, { pullRequestAuthorLogin: pr.author?.login }),
165
+ bySource: groupByCommentSource(threadComments, { pullRequestAuthorLogin: pr.author?.login, contributorHints }),
159
166
  },
160
167
  checkRuns: (pr.statusCheckRollup ?? []).map(check => ({
161
168
  source: check.__typename === "StatusContext" ? "status_context" : "check_run",
@@ -174,6 +181,7 @@ export function normalizeFixtureBundle(bundle, { repositoryProfile } = {}) {
174
181
  schemaVersion: "normalized-fixture.v1",
175
182
  targetRepository: bundle.targetRepository,
176
183
  languageDistribution: bundle.languageDistribution,
184
+ ...(bundle.contributorSource ? { contributorSource: contributorSourceArtifactMetadata(bundle.contributorSource) } : {}),
177
185
  pullRequests,
178
186
  };
179
187
  }
@@ -0,0 +1,165 @@
1
+ export const CONTRIBUTOR_SOURCE_TYPES = Object.freeze([
2
+ "all_contributors",
3
+ ]);
4
+
5
+ export const DEFAULT_ALL_CONTRIBUTORS_PATH = ".all-contributorsrc";
6
+
7
+ const transientContributorHints = new WeakMap();
8
+
9
+ function isObject(value) {
10
+ return value && typeof value === "object" && !Array.isArray(value);
11
+ }
12
+
13
+ function hasParentDirectorySegment(path) {
14
+ return String(path).split(/[\\/]+/).includes("..");
15
+ }
16
+
17
+ export function normalizeContributorSourceConfig(contributors = null) {
18
+ if (!contributors || typeof contributors !== "object" || Array.isArray(contributors)) {
19
+ return null;
20
+ }
21
+ if (contributors.sourceType === undefined && contributors.path === undefined) {
22
+ return null;
23
+ }
24
+ return {
25
+ sourceType: contributors.sourceType ?? "all_contributors",
26
+ path: contributors.path ?? DEFAULT_ALL_CONTRIBUTORS_PATH,
27
+ };
28
+ }
29
+
30
+ export function validateContributorSource(profile = {}) {
31
+ const errors = [];
32
+ if (!isObject(profile) || !Object.prototype.hasOwnProperty.call(profile, "contributors")) {
33
+ return errors;
34
+ }
35
+
36
+ const contributors = profile.contributors;
37
+ if (!isObject(contributors)) {
38
+ return ["contributors must be an object when provided"];
39
+ }
40
+ if (Object.keys(contributors).length === 0) {
41
+ return ["contributors must include at least one field when provided"];
42
+ }
43
+
44
+ for (const key of Object.keys(contributors)) {
45
+ if (!["sourceType", "path"].includes(key)) {
46
+ errors.push(`contributors.${key} is not supported`);
47
+ }
48
+ }
49
+
50
+ const sourceType = contributors.sourceType ?? "all_contributors";
51
+ if (!CONTRIBUTOR_SOURCE_TYPES.includes(sourceType)) {
52
+ errors.push(`contributors.sourceType must be one of: ${CONTRIBUTOR_SOURCE_TYPES.join(", ")}`);
53
+ }
54
+
55
+ if (contributors.path !== undefined) {
56
+ if (typeof contributors.path !== "string" || contributors.path.trim() === "") {
57
+ errors.push("contributors.path must be a non-empty string when provided");
58
+ } else if (
59
+ contributors.path.trim() !== contributors.path
60
+ || contributors.path.startsWith("/")
61
+ || contributors.path.includes("\\")
62
+ || hasParentDirectorySegment(contributors.path)
63
+ ) {
64
+ errors.push("contributors.path must be a trimmed slash-delimited repository-relative path without parent-directory segments");
65
+ }
66
+ }
67
+
68
+ return errors;
69
+ }
70
+
71
+ export function assertValidContributorSource(profile = {}) {
72
+ const errors = validateContributorSource(profile);
73
+ if (errors.length > 0) {
74
+ throw new Error(`invalid contributor source profile context: ${errors.join("; ")}`);
75
+ }
76
+ }
77
+
78
+ function loginFromContributor(contributor) {
79
+ if (!isObject(contributor)) return null;
80
+ const candidate = contributor.login ?? contributor.github ?? contributor.username;
81
+ if (typeof candidate !== "string") return null;
82
+ const login = candidate.trim().replace(/^@/, "");
83
+ return login ? login.toLowerCase() : null;
84
+ }
85
+
86
+ export function parseAllContributorsHints(text) {
87
+ let parsed;
88
+ try {
89
+ parsed = JSON.parse(text);
90
+ } catch (error) {
91
+ return {
92
+ status: "malformed",
93
+ hints: { logins: [] },
94
+ diagnostics: [`Could not parse .all-contributorsrc JSON: ${error.message}`],
95
+ };
96
+ }
97
+
98
+ if (!isObject(parsed) || !Array.isArray(parsed.contributors)) {
99
+ return {
100
+ status: "malformed",
101
+ hints: { logins: [] },
102
+ diagnostics: [".all-contributorsrc must contain a contributors array."],
103
+ };
104
+ }
105
+
106
+ const logins = new Set();
107
+ let malformedEntries = 0;
108
+ for (const contributor of parsed.contributors) {
109
+ const login = loginFromContributor(contributor);
110
+ if (login) {
111
+ logins.add(login);
112
+ } else {
113
+ malformedEntries += 1;
114
+ }
115
+ }
116
+
117
+ return {
118
+ status: malformedEntries > 0 ? "partial" : "available",
119
+ hints: { logins: [...logins].sort() },
120
+ diagnostics: malformedEntries > 0
121
+ ? [`Skipped ${malformedEntries} contributor entr${malformedEntries === 1 ? "y" : "ies"} without a supported login hint.`]
122
+ : [],
123
+ };
124
+ }
125
+
126
+ function normalizeLoginSet(logins = []) {
127
+ if (!Array.isArray(logins)) return new Set();
128
+ return new Set(logins.map(login => String(login).toLowerCase()).filter(Boolean));
129
+ }
130
+
131
+ function hintLoginsFromSource(contributorSource = null) {
132
+ return transientContributorHints.get(contributorSource)?.logins ?? contributorSource?.hints?.logins ?? [];
133
+ }
134
+
135
+ export function contributorSourceArtifactMetadata(contributorSource = null, hints = null) {
136
+ if (!isObject(contributorSource)) return null;
137
+ const logins = Array.isArray(hints?.logins) ? hints.logins : hintLoginsFromSource(contributorSource);
138
+ return {
139
+ sourceType: contributorSource.sourceType,
140
+ path: contributorSource.path,
141
+ coverage: contributorSource.coverage,
142
+ hintCount: Number.isInteger(contributorSource.hintCount)
143
+ ? contributorSource.hintCount
144
+ : normalizeLoginSet(logins).size,
145
+ };
146
+ }
147
+
148
+ export function withTransientContributorHints(contributorSource = null, hints = { logins: [] }) {
149
+ const metadata = contributorSourceArtifactMetadata(contributorSource, hints);
150
+ if (!metadata) return null;
151
+ transientContributorHints.set(metadata, {
152
+ logins: Array.isArray(hints?.logins) ? [...hints.logins] : [],
153
+ });
154
+ return metadata;
155
+ }
156
+
157
+ export function contributorHintsFromSource(contributorSource = null) {
158
+ const usableStatuses = new Set(["available", "partial"]);
159
+ if (!usableStatuses.has(contributorSource?.coverage?.status)) {
160
+ return { logins: new Set() };
161
+ }
162
+ return {
163
+ logins: normalizeLoginSet(hintLoginsFromSource(contributorSource)),
164
+ };
165
+ }
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  CONFIGURED_WORKFLOW_NOTE,
3
+ CONTRIBUTOR_SOURCE_NOTE,
3
4
  configuredWorkflowEntries,
4
5
  hasConfiguredWorkflowContext,
5
6
  profileSuggestions,
7
+ workflowDataCaveats,
6
8
  } from "./friction-report.js";
7
9
 
8
10
  const BOT_OR_SCANNER_SOURCES = new Set([
@@ -346,6 +348,7 @@ function formatSensitivitySummaries(report) {
346
348
  function formatConfiguredWorkflowContext(report) {
347
349
  const configuredWorkflow = report.configuredWorkflow;
348
350
  if (!hasConfiguredWorkflowContext(configuredWorkflow)) return [];
351
+ const caveats = workflowDataCaveats(report);
349
352
 
350
353
  return [
351
354
  "## Configured Workflow Context",
@@ -354,6 +357,31 @@ function formatConfiguredWorkflowContext(report) {
354
357
  "",
355
358
  ...configuredWorkflowEntries(configuredWorkflow)
356
359
  .map(entry => `- ${entry.label}: ${entry.valueLabel}`),
360
+ ...(caveats.length
361
+ ? [
362
+ "",
363
+ "Workflow data caveats:",
364
+ "",
365
+ ...caveats.map(caveat => `- ${caveat}`),
366
+ ]
367
+ : []),
368
+ "",
369
+ ];
370
+ }
371
+
372
+ function formatContributorSourceContext(report) {
373
+ const contributorSource = report.contributorSource;
374
+ if (!contributorSource) return [];
375
+
376
+ return [
377
+ "## Contributor Source Context",
378
+ "",
379
+ contributorSource.note ?? CONTRIBUTOR_SOURCE_NOTE,
380
+ "",
381
+ `- Source type: ${contributorSource.sourceType ?? "unknown"}`,
382
+ `- Path: ${contributorSource.path ?? "not recorded"}`,
383
+ `- Coverage status: ${contributorSource.status ?? "unavailable"}`,
384
+ `- Parsed hint count: ${contributorSource.hintCount ?? 0}`,
357
385
  "",
358
386
  ];
359
387
  }
@@ -364,7 +392,7 @@ function formatProfileSuggestions(report) {
364
392
  return [
365
393
  "## Profile Suggestions",
366
394
  "",
367
- "- No profile suggestion thresholds were triggered by this report's PR class, role, or functional-surface evidence.",
395
+ "- No profile suggestion thresholds were triggered by this report's PR class, role, functional-surface, or workflow-coverage evidence.",
368
396
  "",
369
397
  ];
370
398
  }
@@ -421,6 +449,7 @@ export function renderRepositoryFrictionMethodology({
421
449
  "",
422
450
  ...formatProfileSuggestions(report),
423
451
  ...formatConfiguredWorkflowContext(report),
452
+ ...formatContributorSourceContext(report),
424
453
  "## Scores And Rankings",
425
454
  "",
426
455
  "The report ranks bottlenecks by transparent component metrics from `friction-metrics.v1`: review churn, change scope (the internal changed-file-spread signal: core files touched plus directories touched plus functional surfaces touched), validation gap, planning gap, review surprise, and fix amplification. These are not an opaque composite score, and they are not individual contributor or reviewer rankings.",
@@ -444,8 +473,8 @@ export function renderRepositoryFrictionMethodology({
444
473
  "## Artifact Sensitivity",
445
474
  "",
446
475
  csvEnabled
447
- ? "Generated artifacts may include repository names, PR URLs, titles, file paths, comment-source counts, and coverage diagnostics. CSV files are curated for spreadsheet inspection but should still be treated as local/private unless intentionally shared."
448
- : "Generated artifacts may include repository names, PR URLs, titles, file paths, comment metadata, and coverage diagnostics. CSV export generation was disabled for this run.",
476
+ ? "Generated artifacts may include repository names, PR URLs, titles, file paths, comment-source counts, contributor-source metadata, and coverage diagnostics. CSV files are curated for spreadsheet inspection but should still be treated as local/private unless intentionally shared. Raw contributor file contents and individual contributor rankings are not emitted."
477
+ : "Generated artifacts may include repository names, PR URLs, titles, file paths, comment metadata, contributor-source metadata, and coverage diagnostics. CSV export generation was disabled for this run. Raw contributor file contents and individual contributor rankings are not emitted.",
449
478
  "",
450
479
  ].join("\n").trimEnd()}\n`;
451
480
  }
@@ -82,6 +82,9 @@ const WORKFLOW_CONTEXT_VALUE_LABELS = new Map([
82
82
  ]);
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
+ export const CONTRIBUTOR_SOURCE_NOTE = "Contributor source metadata comes from the configured repository profile source. It may improve comment-source classification coverage, but it does not change scores, PR authorship conclusions, reviewer attribution, CSV export shape, person-level CSV output, or individual ranking guardrails.";
86
+
87
+ 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.";
85
88
 
86
89
  const PROFILE_SUGGESTION_THRESHOLDS = {
87
90
  minimumPrClassSample: 3,
@@ -421,9 +424,7 @@ function summarizeCoverage(metricsSummary) {
421
424
 
422
425
  const notes = [];
423
426
  if (prOpenDiff.unavailable) {
424
- notes.push(
425
- "PR-open diff growth is unavailable for PRs without captured or reconstructed open-time snapshots; it is not inferred from merge-time data.",
426
- );
427
+ notes.push(PR_OPEN_DIFF_LIMITATION_NOTE);
427
428
  }
428
429
  if (workflowRuns.unavailable) {
429
430
  notes.push("Workflow-run coverage is unavailable for some PRs, often because branch-based history is missing.");
@@ -643,6 +644,26 @@ export function profileSuggestions(report = {}) {
643
644
  });
644
645
  }
645
646
 
647
+ const hasWorkflowContext = hasConfiguredWorkflowContext(report.configuredWorkflow);
648
+ const unavailablePrOpenDiff = Number(report.coverage?.prOpenDiff?.unavailable ?? 0);
649
+ const unavailableWorkflowRuns = Number(report.coverage?.workflowRuns?.unavailable ?? 0);
650
+ if (!hasWorkflowContext && (unavailablePrOpenDiff > 0 || unavailableWorkflowRuns > 0)) {
651
+ const evidence = [
652
+ unavailablePrOpenDiff > 0
653
+ ? `PR-open diff coverage unavailable for ${formatCount(unavailablePrOpenDiff, "PR")}`
654
+ : null,
655
+ unavailableWorkflowRuns > 0
656
+ ? `workflow-run coverage unavailable for ${formatCount(unavailableWorkflowRuns, "PR")}`
657
+ : null,
658
+ ].filter(Boolean).join("; ");
659
+ suggestions.push({
660
+ id: "workflow-context",
661
+ area: "Workflow context",
662
+ evidence: `${evidence}.`,
663
+ 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.",
664
+ });
665
+ }
666
+
646
667
  return suggestions;
647
668
  }
648
669
 
@@ -682,6 +703,55 @@ export function hasConfiguredWorkflowContext(configuredWorkflow) {
682
703
  return configuredWorkflowEntries(configuredWorkflow).length > 0;
683
704
  }
684
705
 
706
+ export function normalizeContributorSourceMetadata(contributorSource) {
707
+ if (!contributorSource || typeof contributorSource !== "object" || Array.isArray(contributorSource)) {
708
+ return null;
709
+ }
710
+ return {
711
+ source: "repository_profile",
712
+ sourceType: contributorSource.sourceType ?? "unknown",
713
+ path: contributorSource.path ?? null,
714
+ status: contributorSource.coverage?.status ?? "unavailable",
715
+ hintCount: Number.isInteger(contributorSource.hintCount)
716
+ ? contributorSource.hintCount
717
+ : Array.isArray(contributorSource.hints?.logins) ? contributorSource.hints.logins.length : 0,
718
+ note: CONTRIBUTOR_SOURCE_NOTE,
719
+ };
720
+ }
721
+
722
+ function configuredWorkflowEntry(configuredWorkflow, field) {
723
+ const entry = configuredWorkflowEntries(configuredWorkflow).find(candidate => candidate.field === field);
724
+ return entry ?? null;
725
+ }
726
+
727
+ export function workflowDataCaveats(report = {}) {
728
+ if (!hasConfiguredWorkflowContext(report.configuredWorkflow)) return [];
729
+
730
+ const unavailablePrOpenDiff = Number(report.coverage?.prOpenDiff?.unavailable ?? 0);
731
+ const unavailableWorkflowRuns = Number(report.coverage?.workflowRuns?.unavailable ?? 0);
732
+ if (unavailablePrOpenDiff <= 0 && unavailableWorkflowRuns <= 0) return [];
733
+
734
+ const mergeMethod = configuredWorkflowEntry(report.configuredWorkflow, "primaryMergeMethod");
735
+ const caveats = [];
736
+ if (unavailablePrOpenDiff > 0) {
737
+ const prefix = mergeMethod
738
+ ? `Profile context says primary merge method is ${mergeMethod.valueLabel}; this is configured profile context, not observed evidence.`
739
+ : "Configured workflow fields are profile context, not observed evidence.";
740
+ const methodLimit = {
741
+ 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.",
742
+ 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.",
743
+ 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.",
744
+ }[mergeMethod?.value] ?? "Final/current PR metadata can come from GitHub PR data, but PR-open diff growth still needs captured open-time evidence.";
745
+ caveats.push(`${prefix} ${methodLimit} PR-open diff growth requires an open-time snapshot or equivalent captured state.`);
746
+ }
747
+
748
+ if (unavailableWorkflowRuns > 0) {
749
+ 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.");
750
+ }
751
+
752
+ return caveats;
753
+ }
754
+
685
755
  function evidenceSignature(bottleneck) {
686
756
  return (bottleneck.observedData ?? [])
687
757
  .map(evidence => evidence.number)
@@ -832,18 +902,20 @@ function summarizeSensitivity(metricsSummary, baselineBottlenecks) {
832
902
  }
833
903
 
834
904
  export function generateRepositoryFrictionReport(metricsSummary, options = {}) {
835
- const { workflowContext } = options ?? {};
905
+ const { workflowContext, contributorSource } = options ?? {};
836
906
  const prClasses = summarizePrClasses(metricsSummary);
837
907
  const bottlenecksWithSharedSignalKeys = summarizeBottlenecks(metricsSummary, prClasses);
838
908
  const sharedSignals = summarizeSharedSignals(bottlenecksWithSharedSignalKeys);
839
909
  const bottlenecks = bottlenecksWithSharedSignalKeys.map(({ rankingKey, ...bottleneck }) => bottleneck);
840
910
  const configuredWorkflow = normalizeConfiguredWorkflowContext(workflowContext);
911
+ const contributorSourceMetadata = normalizeContributorSourceMetadata(contributorSource);
841
912
  return {
842
913
  reportVersion: FRICTION_REPORT_VERSION,
843
914
  metricVersion: metricsSummary.metricVersion,
844
915
  targetRepository: metricsSummary.targetRepository,
845
916
  ...(metricsSummary.analysisFilter ? { analysisFilter: metricsSummary.analysisFilter } : {}),
846
917
  ...(configuredWorkflow ? { configuredWorkflow } : {}),
918
+ ...(contributorSourceMetadata ? { contributorSource: contributorSourceMetadata } : {}),
847
919
  summary: {
848
920
  pullRequests: metricsSummary.totals?.pullRequests ?? 0,
849
921
  changedLines: metricsSummary.totals?.changedLines ?? 0,
@@ -1250,6 +1322,27 @@ function renderCoverageSummary(coverage) {
1250
1322
  );
1251
1323
  }
1252
1324
 
1325
+ function renderContributorSourceContext(contributorSource) {
1326
+ if (!contributorSource) return "";
1327
+
1328
+ return [
1329
+ "## Contributor Source Context",
1330
+ "",
1331
+ contributorSource.note ?? CONTRIBUTOR_SOURCE_NOTE,
1332
+ "",
1333
+ renderMarkdownTable(
1334
+ ["Field", "Value"],
1335
+ [
1336
+ ["Source type", contributorSource.sourceType],
1337
+ ["Path", contributorSource.path ?? "not recorded"],
1338
+ ["Coverage status", contributorSource.status],
1339
+ ["Parsed hint count", contributorSource.hintCount],
1340
+ ],
1341
+ ),
1342
+ "",
1343
+ ].join("\n");
1344
+ }
1345
+
1253
1346
  function renderKeyFindings(report) {
1254
1347
  const topBottlenecks = topBottleneckLabels(report);
1255
1348
  const strongest = report.bottlenecks?.[0];
@@ -1393,6 +1486,18 @@ function renderConfiguredWorkflowContext(configuredWorkflow) {
1393
1486
  ].join("\n");
1394
1487
  }
1395
1488
 
1489
+ function renderWorkflowDataCaveats(report) {
1490
+ const caveats = workflowDataCaveats(report);
1491
+ if (!caveats.length) return "";
1492
+
1493
+ return [
1494
+ "## Workflow Data Caveats",
1495
+ "",
1496
+ renderList(caveats),
1497
+ "",
1498
+ ].join("\n");
1499
+ }
1500
+
1396
1501
  function classDominanceCaveat(bottleneck) {
1397
1502
  return bottleneck.classDominance?.status === "single_class_dominates"
1398
1503
  ? bottleneck.classDominance.note
@@ -1547,6 +1652,12 @@ export function renderRepositoryFrictionMarkdown(report) {
1547
1652
  ...(hasConfiguredWorkflowContext(report.configuredWorkflow)
1548
1653
  ? [renderConfiguredWorkflowContext(report.configuredWorkflow)]
1549
1654
  : []),
1655
+ ...(report.contributorSource
1656
+ ? [renderContributorSourceContext(report.contributorSource)]
1657
+ : []),
1658
+ ...(workflowDataCaveats(report).length
1659
+ ? [renderWorkflowDataCaveats(report)]
1660
+ : []),
1550
1661
  "## Evidence Quality And Coverage",
1551
1662
  "",
1552
1663
  renderCoverageSummary(report.coverage),
@@ -1617,13 +1728,19 @@ export function renderRepositoryFrictionMarkdown(report) {
1617
1728
  "- File roles and functional surfaces come from repository-profile classification, not from language names alone.",
1618
1729
  profileSuggestions(report).length
1619
1730
  ? "- Profile suggestions are optional interpretation improvements derived from existing report evidence; they do not change scores, rankings, CSV exports, or JSON report fields."
1620
- : "- No profile suggestion thresholds were triggered by this report's PR class, role, or functional-surface evidence.",
1731
+ : "- No profile suggestion thresholds were triggered by this report's PR class, role, functional-surface, or workflow-coverage evidence.",
1621
1732
  "- Bottlenecks are ranked by their strongest representative observed signal, with stable category order only used to break ties.",
1622
1733
  "- Recommendations are inferred from transparent component evidence and representative PR examples; they are not automated changes.",
1623
1734
  "- Missing or partial GitHub data remains visible in coverage tables rather than being inferred from unrelated fields.",
1624
1735
  ...(hasConfiguredWorkflowContext(report.configuredWorkflow)
1625
1736
  ? ["- Configured workflow context is user-configured repository-profile context; it does not change scoring, ranking, CSV exports, or PR class matching."]
1626
1737
  : []),
1738
+ ...(report.contributorSource
1739
+ ? ["- Contributor source metadata is coverage context only; raw contributor contents and individual contributor rankings are not emitted."]
1740
+ : []),
1741
+ ...(workflowDataCaveats(report).length
1742
+ ? ["- Workflow data caveats explain unavailable evidence using configured profile context without treating merge method as observed evidence or reconstructing open-time PR size."]
1743
+ : []),
1627
1744
  "- Sensitivity analysis, when present, excludes one dominant representative PR at a time to show robustness context without changing the baseline ranking.",
1628
1745
  report.analysisFilter?.excludedPrClasses?.length
1629
1746
  ? "- PR class filtering was explicitly applied before metrics and ranking; PR class context still supports interpretation of the filtered sample."