delivery-friction-analyzer 0.18.0 → 0.20.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.
package/README.md CHANGED
@@ -9,25 +9,44 @@ Use it when you want to answer questions like:
9
9
  - Which files, surfaces, or PR classes create the most back-and-forth?
10
10
  - Which issues look preventable with better local checks, repo-specific AI instructions, skills, hooks, or smaller delivery slices?
11
11
 
12
- The analyzer runs locally with your GitHub credentials. Generated artifacts preserve source evidence, coverage caveats, and interpretation limits so reports can be inspected before they are shared.
12
+ For live GitHub analysis, the analyzer runs locally with your GitHub credentials. Generated artifacts preserve source evidence, coverage caveats, and interpretation limits so reports can be inspected before they are shared.
13
13
 
14
14
  ## Requirements
15
15
 
16
16
  - Node.js 20 or newer.
17
- - GitHub CLI (`gh`) installed and authenticated with access to the target repository.
18
- - A repository profile JSON for the repository you want to analyze. Interactive setup can create a starter profile for you.
17
+ - GitHub CLI (`gh`) installed and authenticated with access to the target repository for live GitHub analysis.
18
+ - A repository profile JSON for the repository you want to analyze in live GitHub mode. Interactive setup can create a starter profile for you.
19
19
 
20
20
  For public repositories, ordinary read access is usually enough. Private repositories need a `gh` token with enough read access for the requested source families. With a classic PAT, that usually means the `repo` scope. With a fine-grained token or GitHub App, grant read permissions for repository metadata and contents, pull requests, Actions, and checks where available. Missing or partial source coverage is recorded in the generated methodology and coverage artifacts instead of being treated as complete data.
21
21
 
22
22
  ## Quickstart
23
23
 
24
+ ### Run the bundled sample
25
+
26
+ Start with the synthetic tutorial sample. It runs locally without GitHub credentials and writes the same artifact family as live analysis:
27
+
28
+ ```sh
29
+ npx delivery-friction-analyzer --source sample --out reports/tutorial
30
+ ```
31
+
32
+ Open `reports/tutorial/friction-report.md` first. The sample output is labeled `Bundled synthetic sample, not live GitHub data` in the CLI completion, report, methodology, and source bundle.
33
+
34
+ Short excerpt from the generated sample report context:
35
+
36
+ > Source: Bundled synthetic sample, not live GitHub data.
37
+ >
38
+ > The generated sample report opens by pointing readers to Change scope, Review churn, and Repo guidance gap. It also shows why the first findings should not be overgeneralized: PR #104 is intentionally broad and outlier-sensitive, PR-open diff coverage is partial, and one PR has unavailable workflow-run history.
39
+
40
+ Screenshots are intentionally deferred until the project has a visual report surface worth capturing.
41
+
24
42
  ### Guided setup
25
43
 
26
- From this repository, install dependencies and let interactive setup create or confirm the repository profile for a GitHub repository you want to measure:
44
+ After reading the sample report, analyze a repository you own or can read. From this repository, install dependencies and let interactive setup create or confirm the repository profile for a GitHub repository you want to measure:
27
45
 
28
46
  ```sh
29
47
  npm install
30
48
  npm run analyze:github -- \
49
+ --source github \
31
50
  --repo owner/name \
32
51
  --limit 30 \
33
52
  --profile profiles/owner-name.json \
@@ -44,6 +63,7 @@ After you have a repository profile, run the analyzer against the target reposit
44
63
 
45
64
  ```sh
46
65
  npm run analyze:github -- \
66
+ --source github \
47
67
  --repo owner/name \
48
68
  --limit 30 \
49
69
  --profile profiles/owner-name.json \
@@ -56,6 +76,7 @@ To run the CLI from another project with the npm package, pass the same choices
56
76
 
57
77
  ```sh
58
78
  npx delivery-friction-analyzer \
79
+ --source github \
59
80
  --repo owner/name \
60
81
  --limit 30 \
61
82
  --profile path/to/repository-profile.json \
@@ -108,6 +129,8 @@ Each ranked bottleneck example includes source references, workflow-run conclusi
108
129
 
109
130
  ## Common Options
110
131
 
132
+ Use `--source sample` for the bundled synthetic tutorial data. Use `--source github` for live repository analysis. Existing live commands that pass `--repo`, `--profile`, or `--limit` without `--source` still run as GitHub analysis, but new users should prefer the explicit source flag.
133
+
111
134
  Use `--dry-run` or `--metadata-only` to validate repository access, profile JSON, output directory writability, and sampled API coverage without writing full report artifacts. The output directory may be created during this check, and a temporary probe file may be written and removed.
112
135
 
113
136
  Use `--no-csv` when you want the Markdown, JSON, source, normalized, metrics, and methodology artifacts without spreadsheet-friendly CSV exports.
@@ -19,7 +19,13 @@ Schema: `schemas/target-repository.schema.json`. Live analysis selection is late
19
19
 
20
20
  The validator rejects a target repository that exactly matches the configured product repository. For this repository, the product repository is `hannasdev/delivery-friction-analyzer`; `hannasdev/mcp-writing` is an internal validation target and fixture source.
21
21
 
22
- Live GitHub analysis enforces this separation before GitHub collection starts. If `--repo` names this tool's product repository, the command fails before provider calls, explains that the guard prevents accidental self-analysis during normal live runs rather than protecting already readable GitHub data, tells you to choose the repository you want to measure with `--repo owner/name`, and confirms that no GitHub data was collected. `--validation-target` only marks output metadata and does not bypass this guard. The product repository identity is repo-local implementation configuration, not a public CLI option.
22
+ Live GitHub analysis enforces this separation before GitHub collection starts. If `--repo` names this tool's product repository, the command fails before provider calls, explains that the guard prevents accidental self-analysis during normal live runs rather than protecting already readable GitHub data, recommends the bundled sample or another repository first, and confirms that no GitHub data was collected.
23
+
24
+ `--validation-target` only marks output metadata for internal validation or fixture-source runs. It does not bypass product-repository validation and does not change which repository may be analyzed.
25
+
26
+ `--allow-product-repository` is an explicit live-analysis override for intentional self-analysis of this product repository. It is not the tutorial path and is rejected in sample mode. When the override is present for the product repository, the CLI must prove required GitHub data is readable before collection or artifact writes. Required preflight reads are repository metadata, pull request inventory, and pull request details for at least one selected merged pull request. Review threads, workflow runs, languages, contributor source, and PR-open diff remain normal coverage families; partial or unavailable optional coverage should be reported as caveats after the required assertion succeeds rather than failing the preflight.
27
+
28
+ The product repository identity is repo-local implementation configuration, not a general public CLI setting.
23
29
 
24
30
  ## Degraded Behavior
25
31
 
@@ -7,6 +7,7 @@ Delivery Friction Analyzer publishes the unbundled source package to npm. This r
7
7
  The npm package allowlist includes:
8
8
 
9
9
  - runtime source in `src`;
10
+ - bundled tutorial sample runtime data in `examples/tutorial`;
10
11
  - JSON schemas in `schemas`;
11
12
  - public contract and reference docs in `docs/contracts` and `docs/reference`;
12
13
  - the internal validation repository profile at `fixtures/github/mcp-writing/profile.json`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "delivery-friction-analyzer",
3
- "version": "0.18.0",
3
+ "version": "0.20.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-27 — Product Repository Override
6
+
7
+ - What changed: Added the explicit `--allow-product-repository` live-analysis override for intentional analysis of this tool's own repository, with required read-access checks before collection or artifact writes.
8
+ - Why it matters: Maintainers can deliberately validate the analyzer against its public product repository without weakening the normal guardrail that steers first-time users toward the bundled sample or another target repository.
9
+ - Who is affected: Maintainers intentionally running self-analysis, and users who hit the product-repository guard during live GitHub setup.
10
+ - Action needed: Use the bundled sample or another repository for normal first-run analysis; add `--allow-product-repository` only for intentional self-analysis after confirming GitHub access.
11
+ - PR: https://github.com/hannasdev/delivery-friction-analyzer/pull/63
12
+
13
+ ### 2026-06-27 — Explicit Sample CLI Source
14
+
15
+ - What changed: Added explicit `--source sample` CLI routing for the bundled tutorial sample, with sample-first README guidance and live-only option rejection before GitHub collection.
16
+ - Why it matters: New users can generate a deterministic local report without GitHub credentials, while existing live GitHub runs keep their source boundary and required target-option validation.
17
+ - Who is affected: First-time users trying the tutorial sample, maintainers reviewing CLI source routing, and users passing live-only flags such as repository targets, dry-run checks, or PR class exclusions.
18
+ - Action needed: Use `delivery-friction-analyzer --source sample --out reports/tutorial` for the bundled sample, or `delivery-friction-analyzer --source github --repo owner/name --limit 30 --profile path/to/profile.json --out reports/owner-name` for live analysis.
19
+ - PR: https://github.com/hannasdev/delivery-friction-analyzer/pull/62
20
+
5
21
  ### 2026-06-27 — Tutorial Sample Bundle
6
22
 
7
23
  - What changed: Added the packaged tutorial sample source bundle, educational sample profile, and generated-aligned report excerpt for a fully synthetic repository.
@@ -4,8 +4,16 @@ import { access, lstat, mkdir, open, readFile, rename, rm, stat, writeFile } fro
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
- import { collectGitHubSourceBundle } from "../collect/github-source-bundle.js";
7
+ import {
8
+ assertProductRepositoryReadableData,
9
+ collectGitHubSourceBundle,
10
+ } from "../collect/github-source-bundle.js";
8
11
  import { createGhCliProvider } from "../collect/gh-provider.js";
12
+ import {
13
+ DEFAULT_PRODUCT_REPOSITORY,
14
+ isProductRepositoryTarget,
15
+ productRepositoryTargetError,
16
+ } from "../contracts/target-repository.js";
9
17
  import { computeRepositoryMetrics } from "../metrics/friction.js";
10
18
  import { normalizeFixtureBundle } from "../normalize/github-fixture.js";
11
19
  import {
@@ -25,8 +33,12 @@ import {
25
33
  } from "../profile/workflow.js";
26
34
 
27
35
  const RUN_PRESET_SCHEMA_VERSION = "analyze-github-run-preset.v1";
36
+ const SAMPLE_SOURCE_LABEL = "Bundled synthetic sample, not live GitHub data";
37
+ const SAMPLE_SOURCE_BUNDLE_URL = new URL("../../examples/tutorial/source-bundle.json", import.meta.url);
38
+ const SAMPLE_PROFILE_URL = new URL("../../examples/tutorial/profile.json", import.meta.url);
28
39
 
29
40
  const ALLOWED_OPTIONS = new Set([
41
+ "source",
30
42
  "repo",
31
43
  "limit",
32
44
  "profile",
@@ -44,6 +56,7 @@ const ALLOWED_OPTIONS = new Set([
44
56
  "interactive",
45
57
  "preset",
46
58
  "save-preset",
59
+ "allow-product-repository",
47
60
  ]);
48
61
 
49
62
  const BOOLEAN_OPTIONS = new Set([
@@ -57,9 +70,11 @@ const BOOLEAN_OPTIONS = new Set([
57
70
  "json",
58
71
  "no-json",
59
72
  "interactive",
73
+ "allow-product-repository",
60
74
  ]);
61
75
 
62
76
  const CLI_OPTION_KEYS = Object.freeze({
77
+ source: "source",
63
78
  repo: "repository",
64
79
  limit: "limit",
65
80
  profile: "profilePath",
@@ -77,6 +92,7 @@ const CLI_OPTION_KEYS = Object.freeze({
77
92
  interactive: "interactive",
78
93
  preset: "presetPath",
79
94
  "save-preset": "savePresetPath",
95
+ "allow-product-repository": "allowProductRepository",
80
96
  });
81
97
 
82
98
  const RUN_PRESET_OPTION_KEYS = Object.freeze([
@@ -114,10 +130,13 @@ const CSV_ARTIFACT_KEYS = new Set([
114
130
  ]);
115
131
 
116
132
  export const USAGE = `Usage:
133
+ delivery-friction-analyzer --source sample --out <directory>
134
+ delivery-friction-analyzer --source github --repo <owner/name> --limit <1-100> --profile <path> --out <directory>
117
135
  delivery-friction-analyzer --repo <owner/name> --limit <1-100> --profile <path> --out <directory>
118
136
  delivery-friction-analyzer --repo <owner/name> --limit <1-100> --profile <path> --out <directory> --dry-run
119
137
 
120
138
  Options:
139
+ --source <sample|github> Choose the bundled synthetic sample or live GitHub analysis.
121
140
  --repo <owner/name> Target GitHub repository to analyze.
122
141
  --limit <1-100> Latest merged pull request count.
123
142
  --profile <path> Repository profile JSON used for file role classification.
@@ -127,6 +146,8 @@ Options:
127
146
  --metadata-only Alias for --dry-run.
128
147
  --validation-target Mark output metadata as an internal validation run; does not bypass target validation.
129
148
  --no-validation-target Disable validation-target mode when a preset enabled it.
149
+ --allow-product-repository
150
+ Explicit live-analysis override for intentional self-analysis of this product repository.
130
151
  --exclude-pr-class <cls> Exclude a PR class from normalized, metrics, report, methodology, and CSV artifacts. Repeat or comma-separate values.
131
152
  --csv Enable curated CSV evidence exports when a preset disabled them.
132
153
  --no-csv Suppress curated CSV evidence exports.
@@ -137,6 +158,14 @@ Options:
137
158
  --save-preset <path> Save local run settings for non-interactive reruns.
138
159
  `;
139
160
 
161
+ export const SOURCE_SELECTION_GUIDANCE = `Choose what to analyze.
162
+
163
+ Try the bundled sample:
164
+ delivery-friction-analyzer --source sample --out reports/tutorial
165
+
166
+ Analyze a GitHub repository:
167
+ delivery-friction-analyzer --source github --repo owner/name --limit 30 --profile path/to/profile.json --out reports/owner-name`;
168
+
140
169
  function attachOptionSource(options, property, value) {
141
170
  Object.defineProperty(options, property, {
142
171
  value,
@@ -199,6 +228,7 @@ export function parseAnalyzeGithubArgs(argv) {
199
228
  if (key === "json") options.json = true;
200
229
  if (key === "no-json") options.json = false;
201
230
  if (key === "interactive") options.interactive = true;
231
+ if (key === "allow-product-repository") options.allowProductRepository = true;
202
232
  continue;
203
233
  }
204
234
 
@@ -215,6 +245,7 @@ export function parseAnalyzeGithubArgs(argv) {
215
245
  }
216
246
 
217
247
  const parsed = {
248
+ source: options.source,
218
249
  repository: options.repo,
219
250
  limit: options.limit === undefined ? undefined : Number(options.limit),
220
251
  profilePath: options.profile,
@@ -226,8 +257,10 @@ export function parseAnalyzeGithubArgs(argv) {
226
257
  json: options.json,
227
258
  interactive: options.interactive,
228
259
  };
260
+ if (options.allowProductRepository) parsed.allowProductRepository = true;
229
261
  if (options.preset !== undefined) parsed.presetPath = options.preset;
230
262
  if (options["save-preset"] !== undefined) parsed.savePresetPath = options["save-preset"];
263
+ if (parsed.source === undefined) delete parsed.source;
231
264
 
232
265
  return attachOptionSource(parsed, "explicitCliOptions", explicitOptions);
233
266
  }
@@ -408,6 +441,12 @@ function requireOptions(options) {
408
441
  }
409
442
  }
410
443
 
444
+ function requireSampleOptions(options) {
445
+ if (!options.outDir) {
446
+ throw new Error("Missing required option(s): --out");
447
+ }
448
+ }
449
+
411
450
  function validateRepositorySlug(repository) {
412
451
  if (typeof repository !== "string" || !REPOSITORY_SLUG.test(repository)) {
413
452
  throw new Error("repo must use owner/name with GitHub-safe owner and name segments.");
@@ -428,6 +467,84 @@ function validateExcludedPrClasses(excludedPrClasses = []) {
428
467
  }
429
468
  }
430
469
 
470
+ function validateSource(source) {
471
+ if (source !== "sample" && source !== "github") {
472
+ throw new Error("source must be one of: sample, github");
473
+ }
474
+ }
475
+
476
+ function hasLiveSourceIndicator(options) {
477
+ const providedOptions = new Set([
478
+ ...optionSourceSet(options, "explicitCliOptions"),
479
+ ...optionSourceSet(options, "presetOptionKeys"),
480
+ ]);
481
+ return Boolean(
482
+ options.repository
483
+ || options.limit !== undefined
484
+ || options.profilePath
485
+ || options.dryRun
486
+ || options.isValidationTarget
487
+ || options.interactive
488
+ || options.presetPath
489
+ || options.savePresetPath
490
+ || options.allowProductRepository
491
+ || options.excludedPrClasses?.length
492
+ || providedOptions.has("dryRun")
493
+ || providedOptions.has("isValidationTarget")
494
+ || providedOptions.has("interactive")
495
+ || providedOptions.has("presetPath")
496
+ || providedOptions.has("savePresetPath")
497
+ || providedOptions.has("allowProductRepository")
498
+ || providedOptions.has("excludedPrClasses"),
499
+ );
500
+ }
501
+
502
+ function resolveAnalysisSource(options) {
503
+ if (options.source !== undefined) {
504
+ validateSource(options.source);
505
+ return options.source;
506
+ }
507
+ if (hasLiveSourceIndicator(options)) {
508
+ return "github";
509
+ }
510
+ throw new Error(SOURCE_SELECTION_GUIDANCE);
511
+ }
512
+
513
+ function rejectSampleLiveOptions(options) {
514
+ const incompatible = [];
515
+ const providedOptions = new Set([
516
+ ...optionSourceSet(options, "explicitCliOptions"),
517
+ ...optionSourceSet(options, "presetOptionKeys"),
518
+ ]);
519
+ const dryRunOptionLabel = providedOptions.has("dryRun") && options.dryRun
520
+ ? "--dry-run/--metadata-only"
521
+ : "--dry-run";
522
+ if (options.repository) incompatible.push("--repo");
523
+ if (options.limit !== undefined) incompatible.push("--limit");
524
+ if (options.profilePath) incompatible.push("--profile");
525
+ if (options.dryRun || providedOptions.has("dryRun")) incompatible.push(dryRunOptionLabel);
526
+ if (options.isValidationTarget || providedOptions.has("isValidationTarget")) incompatible.push("--validation-target");
527
+ if (options.interactive) incompatible.push("--interactive");
528
+ if (options.presetPath) incompatible.push("--preset");
529
+ if (options.savePresetPath) incompatible.push("--save-preset");
530
+ if (options.allowProductRepository || providedOptions.has("allowProductRepository")) {
531
+ incompatible.push("--allow-product-repository");
532
+ }
533
+ if (options.excludedPrClasses?.length || providedOptions.has("excludedPrClasses")) {
534
+ incompatible.push("--exclude-pr-class");
535
+ }
536
+ if (!incompatible.length) return;
537
+ throw new Error(`--source sample cannot be combined with live GitHub option(s): ${incompatible.join(", ")}. Use only output options such as --out, --json, --csv, or --no-csv.`);
538
+ }
539
+
540
+ function targetInputFromRepositorySlug(repository) {
541
+ const match = REPOSITORY_SLUG.exec(repository);
542
+ if (!match) {
543
+ throw new Error("repo must use owner/name with GitHub-safe owner and name segments.");
544
+ }
545
+ return { owner: match[1], name: match[2] };
546
+ }
547
+
431
548
  function configuredPrClasses(repositoryProfile) {
432
549
  const rules = Array.isArray(repositoryProfile?.prClasses) ? repositoryProfile.prClasses : [];
433
550
  return new Set(rules.map(rule => rule.class));
@@ -1444,6 +1561,7 @@ function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report,
1444
1561
  sampledLimit,
1445
1562
  outputDirectory: outDir,
1446
1563
  artifactPaths: dryRun ? null : paths,
1564
+ source: sourceBundle.source,
1447
1565
  targetRepository: sourceBundle.targetRepository,
1448
1566
  selection: sourceBundle.selection,
1449
1567
  collectionCoverage: sourceBundle.coverage,
@@ -1484,6 +1602,49 @@ function applyPrClassFilter(normalized, excludedPrClasses = []) {
1484
1602
  };
1485
1603
  }
1486
1604
 
1605
+ async function readBundledJson(url, label) {
1606
+ try {
1607
+ return JSON.parse(await readFile(url, "utf8"));
1608
+ } catch (error) {
1609
+ if (error instanceof SyntaxError) {
1610
+ throw new Error(`${label} must be valid JSON: ${error.message}`);
1611
+ }
1612
+ throw error;
1613
+ }
1614
+ }
1615
+
1616
+ async function buildAnalysisArtifacts({ sourceBundle, repositoryProfile, profilePath, csvEnabled, excludedPrClasses = [] }) {
1617
+ validateExcludedPrClassesAreConfigured(excludedPrClasses, repositoryProfile);
1618
+ const normalized = applyPrClassFilter(
1619
+ normalizeFixtureBundle(sourceBundle, { repositoryProfile }),
1620
+ excludedPrClasses,
1621
+ );
1622
+ const metrics = computeRepositoryMetrics(normalized);
1623
+ const report = attachCollectionCoverage(
1624
+ generateRepositoryFrictionReport(metrics, {
1625
+ workflowContext: repositoryProfile.workflow,
1626
+ contributorSource: normalized.contributorSource,
1627
+ }),
1628
+ sourceBundle,
1629
+ );
1630
+ const markdown = `${renderRepositoryFrictionMarkdown(report)}${collectionCoverageMarkdown(sourceBundle)}`;
1631
+ const methodology = renderRepositoryFrictionMethodology({
1632
+ report,
1633
+ sourceBundle,
1634
+ profilePath,
1635
+ artifactFileNames: ANALYZE_GITHUB_ARTIFACTS,
1636
+ csvEnabled,
1637
+ });
1638
+ const csv = csvEnabled
1639
+ ? generateEvidenceCsvArtifacts({
1640
+ metricsSummary: metrics,
1641
+ report,
1642
+ collectionCoverage: sourceBundle.coverage,
1643
+ })
1644
+ : {};
1645
+ return { normalized, metrics, report, markdown, methodology, csv };
1646
+ }
1647
+
1487
1648
  export async function runAnalyzeGithub(options, {
1488
1649
  provider = createGhCliProvider(),
1489
1650
  now = () => new Date().toISOString(),
@@ -1495,12 +1656,26 @@ export async function runAnalyzeGithub(options, {
1495
1656
  validateLimit(options.limit);
1496
1657
  validateExcludedPrClasses(options.excludedPrClasses);
1497
1658
  const csvEnabled = options.csv !== false;
1659
+ const effectiveProductRepository = productRepository ?? DEFAULT_PRODUCT_REPOSITORY;
1660
+ const targetInput = targetInputFromRepositorySlug(options.repository);
1661
+ const targetsProductRepository = isProductRepositoryTarget(targetInput, effectiveProductRepository);
1662
+ if (targetsProductRepository && !options.allowProductRepository) {
1663
+ throw new Error(productRepositoryTargetError(targetInput));
1664
+ }
1665
+ onProgress?.("Validating profile.");
1666
+ const repositoryProfile = await readProfile(options.profilePath);
1667
+ if (targetsProductRepository) {
1668
+ onProgress?.("Checking required GitHub read access for product-repository analysis.");
1669
+ await assertProductRepositoryReadableData({
1670
+ repository: options.repository,
1671
+ limit: options.dryRun ? Math.min(options.limit, 1) : options.limit,
1672
+ provider,
1673
+ productRepository: effectiveProductRepository,
1674
+ });
1675
+ }
1498
1676
 
1499
- onProgress?.("Validating profile and output directory.");
1500
- const [repositoryProfile, outDir] = await Promise.all([
1501
- readProfile(options.profilePath),
1502
- validateOutputDirectory(options.outDir),
1503
- ]);
1677
+ onProgress?.("Validating output directory.");
1678
+ const outDir = await validateOutputDirectory(options.outDir);
1504
1679
  validateExcludedPrClassesAreConfigured(options.excludedPrClasses ?? [], repositoryProfile);
1505
1680
  const generatedPaths = artifactPaths(outDir, { includeCsv: csvEnabled });
1506
1681
  const disabledPaths = csvEnabled ? {} : Object.fromEntries(
@@ -1521,7 +1696,8 @@ export async function runAnalyzeGithub(options, {
1521
1696
  collectedAt: now(),
1522
1697
  isValidationTarget: options.isValidationTarget,
1523
1698
  contributors: repositoryProfile.contributors,
1524
- productRepository,
1699
+ productRepository: effectiveProductRepository,
1700
+ allowProductRepository: Boolean(options.allowProductRepository),
1525
1701
  });
1526
1702
 
1527
1703
  if (options.dryRun) {
@@ -1541,43 +1717,23 @@ export async function runAnalyzeGithub(options, {
1541
1717
  }
1542
1718
 
1543
1719
  onProgress?.("Normalizing source bundle and computing metrics.");
1544
- const normalized = applyPrClassFilter(
1545
- normalizeFixtureBundle(sourceBundle, { repositoryProfile }),
1546
- options.excludedPrClasses ?? [],
1547
- );
1548
- const metrics = computeRepositoryMetrics(normalized);
1549
- const report = attachCollectionCoverage(
1550
- generateRepositoryFrictionReport(metrics, {
1551
- workflowContext: repositoryProfile.workflow,
1552
- contributorSource: normalized.contributorSource,
1553
- }),
1554
- sourceBundle,
1555
- );
1556
- const markdown = `${renderRepositoryFrictionMarkdown(report)}${collectionCoverageMarkdown(sourceBundle)}`;
1557
- const methodology = renderRepositoryFrictionMethodology({
1558
- report,
1720
+ const artifacts = await buildAnalysisArtifacts({
1559
1721
  sourceBundle,
1560
1722
  profilePath: options.profilePath,
1561
- artifactFileNames: ANALYZE_GITHUB_ARTIFACTS,
1562
1723
  csvEnabled,
1724
+ repositoryProfile,
1725
+ excludedPrClasses: options.excludedPrClasses ?? [],
1563
1726
  });
1564
- const csv = csvEnabled
1565
- ? generateEvidenceCsvArtifacts({
1566
- metricsSummary: metrics,
1567
- report,
1568
- collectionCoverage: sourceBundle.coverage,
1569
- })
1570
- : {};
1571
1727
 
1572
1728
  onProgress?.("Writing local artifacts.");
1573
1729
  await writeAnalysisArtifacts(outDir, generatedPaths, {
1574
1730
  sourceBundle,
1575
- normalized,
1576
- metricsSummary: metrics,
1577
- reportJson: report,
1578
- reportMarkdown: markdown,
1579
- methodology,
1580
- csv,
1731
+ normalized: artifacts.normalized,
1732
+ metricsSummary: artifacts.metrics,
1733
+ reportJson: artifacts.report,
1734
+ reportMarkdown: artifacts.markdown,
1735
+ methodology: artifacts.methodology,
1736
+ csv: artifacts.csv,
1581
1737
  }, { disabledPaths });
1582
1738
 
1583
1739
  return summarizeResult({
@@ -1585,18 +1741,75 @@ export async function runAnalyzeGithub(options, {
1585
1741
  outDir,
1586
1742
  paths: generatedPaths,
1587
1743
  sourceBundle,
1588
- metrics,
1589
- report,
1744
+ metrics: artifacts.metrics,
1745
+ report: artifacts.report,
1590
1746
  requestedLimit: options.limit,
1591
1747
  sampledLimit: collectionLimit,
1592
1748
  csv: csvEnabled,
1593
- analysisFilter: normalized.analysisFilter ?? null,
1749
+ analysisFilter: artifacts.normalized.analysisFilter ?? null,
1594
1750
  savedProfilePath: options.savedProfilePath,
1595
1751
  savedRunPresetPath: options.savedRunPresetPath,
1596
1752
  prClassRulesWritten: options.prClassRulesWritten,
1597
1753
  });
1598
1754
  }
1599
1755
 
1756
+ export async function runAnalyzeSample(options, {
1757
+ onProgress = null,
1758
+ } = {}) {
1759
+ rejectSampleLiveOptions(options);
1760
+ requireSampleOptions(options);
1761
+ const csvEnabled = options.csv !== false;
1762
+
1763
+ onProgress?.("Loading bundled synthetic sample.");
1764
+ const [sourceBundle, repositoryProfile, outDir] = await Promise.all([
1765
+ readBundledJson(SAMPLE_SOURCE_BUNDLE_URL, "sample source bundle"),
1766
+ readBundledJson(SAMPLE_PROFILE_URL, "sample repository profile"),
1767
+ validateOutputDirectory(options.outDir),
1768
+ ]);
1769
+ if (sourceBundle.source?.kind !== "sample" || sourceBundle.source?.label !== SAMPLE_SOURCE_LABEL) {
1770
+ throw new Error(`sample source bundle source.kind must be sample and source.label must be ${SAMPLE_SOURCE_LABEL}.`);
1771
+ }
1772
+ validateProfile(repositoryProfile);
1773
+
1774
+ const generatedPaths = artifactPaths(outDir, { includeCsv: csvEnabled });
1775
+ const disabledPaths = csvEnabled ? {} : Object.fromEntries(
1776
+ Object.entries(artifactPaths(outDir, { includeCsv: true })).filter(([key]) => CSV_ARTIFACT_KEYS.has(key)),
1777
+ );
1778
+ await assertWritableArtifactTargets({ ...generatedPaths, ...disabledPaths });
1779
+
1780
+ onProgress?.("Normalizing source bundle and computing metrics.");
1781
+ const artifacts = await buildAnalysisArtifacts({
1782
+ sourceBundle,
1783
+ profilePath: "examples/tutorial/profile.json",
1784
+ csvEnabled,
1785
+ repositoryProfile,
1786
+ });
1787
+
1788
+ onProgress?.("Writing local artifacts.");
1789
+ await writeAnalysisArtifacts(outDir, generatedPaths, {
1790
+ sourceBundle,
1791
+ normalized: artifacts.normalized,
1792
+ metricsSummary: artifacts.metrics,
1793
+ reportJson: artifacts.report,
1794
+ reportMarkdown: artifacts.markdown,
1795
+ methodology: artifacts.methodology,
1796
+ csv: artifacts.csv,
1797
+ }, { disabledPaths });
1798
+
1799
+ return summarizeResult({
1800
+ dryRun: false,
1801
+ outDir,
1802
+ paths: generatedPaths,
1803
+ sourceBundle,
1804
+ metrics: artifacts.metrics,
1805
+ report: artifacts.report,
1806
+ requestedLimit: sourceBundle.selection?.requestedLimit,
1807
+ sampledLimit: sourceBundle.selection?.collectedCount,
1808
+ csv: csvEnabled,
1809
+ analysisFilter: null,
1810
+ });
1811
+ }
1812
+
1600
1813
  function writeProgress(message, stderr = process.stderr) {
1601
1814
  stderr.write(`${message}\n`);
1602
1815
  }
@@ -1637,6 +1850,7 @@ export function formatAnalyzeGithubCompletion(result) {
1637
1850
  lines.push(
1638
1851
  `Markdown report: ${paths.reportMarkdown}`,
1639
1852
  `Analysis complete for ${target}.`,
1853
+ `Source: ${result.source?.label ?? "not recorded"}.`,
1640
1854
  `Methodology: ${paths.methodology}`,
1641
1855
  `JSON report: ${paths.reportJson}`,
1642
1856
  `Metrics summary: ${paths.metricsSummary}`,
@@ -1712,7 +1926,28 @@ export async function runAnalyzeGithubCli(argv, {
1712
1926
  stdout.write(USAGE);
1713
1927
  return null;
1714
1928
  }
1929
+ if (parsedOptions.source !== undefined) {
1930
+ validateSource(parsedOptions.source);
1931
+ if (parsedOptions.source === "sample") {
1932
+ const runOptions = {
1933
+ onProgress: message => writeProgress(message, stderr),
1934
+ };
1935
+ const result = await runAnalyzeSample(parsedOptions, runOptions);
1936
+ writeAnalyzeGithubCompletion(result, { json: parsedOptions.json, stdout });
1937
+ return result;
1938
+ }
1939
+ }
1715
1940
  const options = await mergeRunPresetOptions(parsedOptions);
1941
+ const source = resolveAnalysisSource(options);
1942
+
1943
+ if (source === "sample") {
1944
+ const runOptions = {
1945
+ onProgress: message => writeProgress(message, stderr),
1946
+ };
1947
+ const result = await runAnalyzeSample(options, runOptions);
1948
+ writeAnalyzeGithubCompletion(result, { json: options.json, stdout });
1949
+ return result;
1950
+ }
1716
1951
 
1717
1952
  const providedOptionKeys = new Set([
1718
1953
  ...optionSourceSet(options, "explicitCliOptions"),
@@ -49,7 +49,7 @@ function visibilityOf(repository) {
49
49
  return "unknown";
50
50
  }
51
51
 
52
- function mapTargetRepository({ owner, name, repositoryMetadata, analysisPullRequestLimit, isValidationTarget, productRepository }) {
52
+ function mapTargetRepository({ owner, name, repositoryMetadata, analysisPullRequestLimit, isValidationTarget, productRepository, allowProductRepository }) {
53
53
  const targetRepository = {
54
54
  owner,
55
55
  name,
@@ -58,13 +58,90 @@ function mapTargetRepository({ owner, name, repositoryMetadata, analysisPullRequ
58
58
  analysisPullRequestLimit,
59
59
  isValidationTarget,
60
60
  };
61
- const errors = validateTargetRepository(targetRepository, { productRepository });
61
+ const errors = validateTargetRepository(targetRepository, { productRepository, allowProductRepository });
62
62
  if (errors.length > 0) {
63
63
  throw new Error(`collected target repository metadata is invalid: ${errors.join(" ")}`);
64
64
  }
65
65
  return targetRepository;
66
66
  }
67
67
 
68
+ function formatReadableDataFailure({ repository, family, action, error }) {
69
+ const diagnostic = redactDiagnostic(error?.message ?? error);
70
+ return `Cannot analyze ${repository} with --allow-product-repository because required GitHub read access could not be proven for ${family}. ${action}. Required preflight must read repository metadata, pull request inventory, and pull request details before collection or artifact writes.${diagnostic ? ` GitHub response: ${diagnostic}` : ""}`;
71
+ }
72
+
73
+ async function requireReadableProbe({ repository, family, action, run }) {
74
+ try {
75
+ return await run();
76
+ } catch (error) {
77
+ throw new Error(formatReadableDataFailure({
78
+ repository,
79
+ family,
80
+ action,
81
+ error,
82
+ }));
83
+ }
84
+ }
85
+
86
+ export async function assertProductRepositoryReadableData({
87
+ repository,
88
+ owner,
89
+ name,
90
+ limit,
91
+ provider,
92
+ productRepository = DEFAULT_PRODUCT_REPOSITORY,
93
+ } = {}) {
94
+ if (!provider) {
95
+ throw new Error("provider is required.");
96
+ }
97
+ requirePullRequestLimit(limit);
98
+ const targetInput = repository ? parseRepositoryInput(repository) : { owner, name };
99
+ if (!isProductRepositoryTarget(targetInput, productRepository)) {
100
+ return { requiredFamilies: [] };
101
+ }
102
+
103
+ const repositoryLabel = `${targetInput.owner}/${targetInput.name}`;
104
+ await requireReadableProbe({
105
+ repository: repositoryLabel,
106
+ family: "repository metadata",
107
+ action: "Authenticate with GitHub CLI or choose a repository your token can read",
108
+ run: () => provider.getRepository(targetInput),
109
+ });
110
+
111
+ const inventory = await requireReadableProbe({
112
+ repository: repositoryLabel,
113
+ family: "pull request inventory",
114
+ action: "Ensure `gh pr list --repo owner/name` can list merged pull requests",
115
+ run: () => provider.listMergedPullRequests({ ...targetInput, limit }),
116
+ });
117
+ if (!Array.isArray(inventory)) {
118
+ throw new Error(`Cannot analyze ${repositoryLabel} with --allow-product-repository because required GitHub read access could not be proven for pull request inventory. GitHub returned an unreadable pull request inventory shape. Required preflight must read repository metadata, pull request inventory, and pull request details before collection or artifact writes.`);
119
+ }
120
+ const selectedInventory = [...inventory]
121
+ .sort((left, right) => String(right.mergedAt ?? "").localeCompare(String(left.mergedAt ?? "")))
122
+ .slice(0, limit);
123
+ const firstPullRequest = selectedInventory[0];
124
+ if (!firstPullRequest?.number) {
125
+ throw new Error(`Cannot analyze ${repositoryLabel} with --allow-product-repository because required GitHub read access could not be proven for pull request details. The pull request inventory returned no merged pull requests to inspect. Choose another repository or rerun after merged pull requests are readable.`);
126
+ }
127
+
128
+ await requireReadableProbe({
129
+ repository: repositoryLabel,
130
+ family: "pull request details",
131
+ action: "Ensure `gh pr view --repo owner/name <number> --json ...` can read merged pull request details",
132
+ run: () => provider.getPullRequest({ ...targetInput, number: firstPullRequest.number }),
133
+ });
134
+
135
+ return {
136
+ requiredFamilies: [
137
+ "repository_metadata",
138
+ "pull_request_inventory",
139
+ "pull_request_details",
140
+ ],
141
+ probedPullRequestNumber: firstPullRequest.number,
142
+ };
143
+ }
144
+
68
145
  function mapRepositoryMetadata(repository) {
69
146
  return {
70
147
  id: repository.id ?? null,
@@ -378,6 +455,7 @@ export async function collectGitHubSourceBundle({
378
455
  isValidationTarget = false,
379
456
  contributors = null,
380
457
  productRepository = DEFAULT_PRODUCT_REPOSITORY,
458
+ allowProductRepository = false,
381
459
  } = {}) {
382
460
  if (!provider) {
383
461
  throw new Error("provider is required.");
@@ -385,7 +463,7 @@ export async function collectGitHubSourceBundle({
385
463
  const targetPullRequestLimit = analysisPullRequestLimit ?? limit;
386
464
  requirePullRequestLimit(targetPullRequestLimit);
387
465
  const targetInput = repository ? parseRepositoryInput(repository) : { owner, name };
388
- if (isProductRepositoryTarget(targetInput, productRepository)) {
466
+ if (isProductRepositoryTarget(targetInput, productRepository) && !allowProductRepository) {
389
467
  throw new Error(productRepositoryTargetError(targetInput));
390
468
  }
391
469
  const targetNameErrors = validateTargetRepository({
@@ -394,7 +472,7 @@ export async function collectGitHubSourceBundle({
394
472
  visibility: "unknown",
395
473
  analysisPullRequestLimit: targetPullRequestLimit,
396
474
  isValidationTarget,
397
- }, { productRepository }).filter(error => !error.includes("defaultBranch") && !error.includes("visibility"));
475
+ }, { productRepository, allowProductRepository }).filter(error => !error.includes("defaultBranch") && !error.includes("visibility"));
398
476
  if (targetNameErrors.length > 0) {
399
477
  throw new Error(targetNameErrors.join(" "));
400
478
  }
@@ -406,6 +484,7 @@ export async function collectGitHubSourceBundle({
406
484
  analysisPullRequestLimit: targetPullRequestLimit,
407
485
  isValidationTarget,
408
486
  productRepository,
487
+ allowProductRepository,
409
488
  });
410
489
  const repositoryCoverage = coverageEntry({
411
490
  family: "repository_metadata",
@@ -13,7 +13,7 @@ function validateRepoPart(value, label) {
13
13
  return null;
14
14
  }
15
15
 
16
- export function validateTargetRepository(input, { productRepository } = {}) {
16
+ export function validateTargetRepository(input, { productRepository, allowProductRepository = false } = {}) {
17
17
  const errors = [];
18
18
  if (!input || typeof input !== "object") {
19
19
  return ["target repository input must be an object."];
@@ -46,6 +46,8 @@ export function validateTargetRepository(input, { productRepository } = {}) {
46
46
  const normalizedProductName = typeof productRepository?.name === "string" ? productRepository.name.toLowerCase() : null;
47
47
 
48
48
  if (
49
+ !allowProductRepository
50
+ &&
49
51
  normalizedInputOwner
50
52
  && normalizedInputName
51
53
  && normalizedProductOwner
@@ -99,5 +101,12 @@ export function productRepositoryTargetError(input) {
99
101
  const repository = typeof input?.owner === "string" && typeof input?.name === "string"
100
102
  ? `${input.owner}/${input.name}`
101
103
  : "the requested repository";
102
- return `Cannot analyze ${repository} because it is this tool's product repository. The guard prevents accidental self-analysis during normal live runs; it is not a data-security boundary. Choose a different repository with --repo owner/name. No GitHub data was collected.`;
104
+ return `Cannot analyze ${repository} because it is this tool's product repository. The guard prevents accidental self-analysis during normal live runs; it is not a data-security boundary.
105
+
106
+ To learn the tool, run the bundled sample:
107
+ delivery-friction-analyzer --source sample --out reports/tutorial
108
+
109
+ To analyze a different repository, pass --repo owner/name.
110
+
111
+ If you intentionally want to analyze this product repository, rerun with --allow-product-repository. The CLI will check that required data is readable before writing artifacts. No GitHub data was collected.`;
103
112
  }