delivery-friction-analyzer 0.10.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. 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.
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,6 +58,7 @@ 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;
60
62
  - workflow data caveats when configured workflow context clarifies unavailable PR-open diff or workflow-run evidence;
61
63
  - evidence-quality and coverage tables before detailed recommendations;
62
64
  - key findings that highlight top bottlenecks, strongest displayed signal, outlier caveats, PR class caveats, and coverage caveats;
@@ -75,7 +77,7 @@ The Markdown renderer presents the same report data for human review:
75
77
  - a reference to the detailed `methodology.md` artifact generated by full live analysis;
76
78
  - guardrails, follow-up, and artifact-sensitivity guidance.
77
79
 
78
- 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.
79
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.
80
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.
81
83
 
@@ -114,6 +116,7 @@ Full live analysis writes `methodology.md` as a hybrid artifact: stable explanat
114
116
  - target repository and report/metric versions;
115
117
  - profile path when available;
116
118
  - configured workflow context when supplied by the repository profile, labeled as user-configured context rather than observed GitHub evidence;
119
+ - contributor-source context when configured, including source type, path, coverage status, and parsed hint count, without raw contributor contents or rankings;
117
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;
118
121
  - requested and collected PR counts;
119
122
  - collection coverage status and API-family diagnostics;
@@ -139,6 +142,8 @@ Minimum CSV column groups:
139
142
 
140
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.
141
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
+
142
147
  ## Optional Downstream Narrative Drafting
143
148
 
144
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.10.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,14 @@
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
+
5
13
  ### 2026-06-20 — Workflow Data Caveats
6
14
 
7
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.
@@ -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,5 +1,6 @@
1
1
  import {
2
2
  CONFIGURED_WORKFLOW_NOTE,
3
+ CONTRIBUTOR_SOURCE_NOTE,
3
4
  configuredWorkflowEntries,
4
5
  hasConfiguredWorkflowContext,
5
6
  profileSuggestions,
@@ -368,6 +369,23 @@ function formatConfiguredWorkflowContext(report) {
368
369
  ];
369
370
  }
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}`,
385
+ "",
386
+ ];
387
+ }
388
+
371
389
  function formatProfileSuggestions(report) {
372
390
  const suggestions = profileSuggestions(report);
373
391
  if (!suggestions.length) {
@@ -431,6 +449,7 @@ export function renderRepositoryFrictionMethodology({
431
449
  "",
432
450
  ...formatProfileSuggestions(report),
433
451
  ...formatConfiguredWorkflowContext(report),
452
+ ...formatContributorSourceContext(report),
434
453
  "## Scores And Rankings",
435
454
  "",
436
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.",
@@ -454,8 +473,8 @@ export function renderRepositoryFrictionMethodology({
454
473
  "## Artifact Sensitivity",
455
474
  "",
456
475
  csvEnabled
457
- ? "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."
458
- : "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.",
459
478
  "",
460
479
  ].join("\n").trimEnd()}\n`;
461
480
  }
@@ -82,6 +82,7 @@ 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.";
85
86
 
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.";
87
88
 
@@ -702,6 +703,22 @@ export function hasConfiguredWorkflowContext(configuredWorkflow) {
702
703
  return configuredWorkflowEntries(configuredWorkflow).length > 0;
703
704
  }
704
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
+
705
722
  function configuredWorkflowEntry(configuredWorkflow, field) {
706
723
  const entry = configuredWorkflowEntries(configuredWorkflow).find(candidate => candidate.field === field);
707
724
  return entry ?? null;
@@ -885,18 +902,20 @@ function summarizeSensitivity(metricsSummary, baselineBottlenecks) {
885
902
  }
886
903
 
887
904
  export function generateRepositoryFrictionReport(metricsSummary, options = {}) {
888
- const { workflowContext } = options ?? {};
905
+ const { workflowContext, contributorSource } = options ?? {};
889
906
  const prClasses = summarizePrClasses(metricsSummary);
890
907
  const bottlenecksWithSharedSignalKeys = summarizeBottlenecks(metricsSummary, prClasses);
891
908
  const sharedSignals = summarizeSharedSignals(bottlenecksWithSharedSignalKeys);
892
909
  const bottlenecks = bottlenecksWithSharedSignalKeys.map(({ rankingKey, ...bottleneck }) => bottleneck);
893
910
  const configuredWorkflow = normalizeConfiguredWorkflowContext(workflowContext);
911
+ const contributorSourceMetadata = normalizeContributorSourceMetadata(contributorSource);
894
912
  return {
895
913
  reportVersion: FRICTION_REPORT_VERSION,
896
914
  metricVersion: metricsSummary.metricVersion,
897
915
  targetRepository: metricsSummary.targetRepository,
898
916
  ...(metricsSummary.analysisFilter ? { analysisFilter: metricsSummary.analysisFilter } : {}),
899
917
  ...(configuredWorkflow ? { configuredWorkflow } : {}),
918
+ ...(contributorSourceMetadata ? { contributorSource: contributorSourceMetadata } : {}),
900
919
  summary: {
901
920
  pullRequests: metricsSummary.totals?.pullRequests ?? 0,
902
921
  changedLines: metricsSummary.totals?.changedLines ?? 0,
@@ -1303,6 +1322,27 @@ function renderCoverageSummary(coverage) {
1303
1322
  );
1304
1323
  }
1305
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
+
1306
1346
  function renderKeyFindings(report) {
1307
1347
  const topBottlenecks = topBottleneckLabels(report);
1308
1348
  const strongest = report.bottlenecks?.[0];
@@ -1612,6 +1652,9 @@ export function renderRepositoryFrictionMarkdown(report) {
1612
1652
  ...(hasConfiguredWorkflowContext(report.configuredWorkflow)
1613
1653
  ? [renderConfiguredWorkflowContext(report.configuredWorkflow)]
1614
1654
  : []),
1655
+ ...(report.contributorSource
1656
+ ? [renderContributorSourceContext(report.contributorSource)]
1657
+ : []),
1615
1658
  ...(workflowDataCaveats(report).length
1616
1659
  ? [renderWorkflowDataCaveats(report)]
1617
1660
  : []),
@@ -1692,6 +1735,9 @@ export function renderRepositoryFrictionMarkdown(report) {
1692
1735
  ...(hasConfiguredWorkflowContext(report.configuredWorkflow)
1693
1736
  ? ["- Configured workflow context is user-configured repository-profile context; it does not change scoring, ranking, CSV exports, or PR class matching."]
1694
1737
  : []),
1738
+ ...(report.contributorSource
1739
+ ? ["- Contributor source metadata is coverage context only; raw contributor contents and individual contributor rankings are not emitted."]
1740
+ : []),
1695
1741
  ...(workflowDataCaveats(report).length
1696
1742
  ? ["- Workflow data caveats explain unavailable evidence using configured profile context without treating merge method as observed evidence or reconstructing open-time PR size."]
1697
1743
  : []),