delivery-friction-analyzer 0.19.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.
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "delivery-friction-analyzer",
3
- "version": "0.19.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,14 @@
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
+
5
13
  ### 2026-06-27 — Explicit Sample CLI Source
6
14
 
7
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.
@@ -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 {
@@ -48,6 +56,7 @@ const ALLOWED_OPTIONS = new Set([
48
56
  "interactive",
49
57
  "preset",
50
58
  "save-preset",
59
+ "allow-product-repository",
51
60
  ]);
52
61
 
53
62
  const BOOLEAN_OPTIONS = new Set([
@@ -61,6 +70,7 @@ const BOOLEAN_OPTIONS = new Set([
61
70
  "json",
62
71
  "no-json",
63
72
  "interactive",
73
+ "allow-product-repository",
64
74
  ]);
65
75
 
66
76
  const CLI_OPTION_KEYS = Object.freeze({
@@ -82,6 +92,7 @@ const CLI_OPTION_KEYS = Object.freeze({
82
92
  interactive: "interactive",
83
93
  preset: "presetPath",
84
94
  "save-preset": "savePresetPath",
95
+ "allow-product-repository": "allowProductRepository",
85
96
  });
86
97
 
87
98
  const RUN_PRESET_OPTION_KEYS = Object.freeze([
@@ -135,6 +146,8 @@ Options:
135
146
  --metadata-only Alias for --dry-run.
136
147
  --validation-target Mark output metadata as an internal validation run; does not bypass target validation.
137
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.
138
151
  --exclude-pr-class <cls> Exclude a PR class from normalized, metrics, report, methodology, and CSV artifacts. Repeat or comma-separate values.
139
152
  --csv Enable curated CSV evidence exports when a preset disabled them.
140
153
  --no-csv Suppress curated CSV evidence exports.
@@ -215,6 +228,7 @@ export function parseAnalyzeGithubArgs(argv) {
215
228
  if (key === "json") options.json = true;
216
229
  if (key === "no-json") options.json = false;
217
230
  if (key === "interactive") options.interactive = true;
231
+ if (key === "allow-product-repository") options.allowProductRepository = true;
218
232
  continue;
219
233
  }
220
234
 
@@ -243,6 +257,7 @@ export function parseAnalyzeGithubArgs(argv) {
243
257
  json: options.json,
244
258
  interactive: options.interactive,
245
259
  };
260
+ if (options.allowProductRepository) parsed.allowProductRepository = true;
246
261
  if (options.preset !== undefined) parsed.presetPath = options.preset;
247
262
  if (options["save-preset"] !== undefined) parsed.savePresetPath = options["save-preset"];
248
263
  if (parsed.source === undefined) delete parsed.source;
@@ -472,12 +487,14 @@ function hasLiveSourceIndicator(options) {
472
487
  || options.interactive
473
488
  || options.presetPath
474
489
  || options.savePresetPath
490
+ || options.allowProductRepository
475
491
  || options.excludedPrClasses?.length
476
492
  || providedOptions.has("dryRun")
477
493
  || providedOptions.has("isValidationTarget")
478
494
  || providedOptions.has("interactive")
479
495
  || providedOptions.has("presetPath")
480
496
  || providedOptions.has("savePresetPath")
497
+ || providedOptions.has("allowProductRepository")
481
498
  || providedOptions.has("excludedPrClasses"),
482
499
  );
483
500
  }
@@ -510,6 +527,9 @@ function rejectSampleLiveOptions(options) {
510
527
  if (options.interactive) incompatible.push("--interactive");
511
528
  if (options.presetPath) incompatible.push("--preset");
512
529
  if (options.savePresetPath) incompatible.push("--save-preset");
530
+ if (options.allowProductRepository || providedOptions.has("allowProductRepository")) {
531
+ incompatible.push("--allow-product-repository");
532
+ }
513
533
  if (options.excludedPrClasses?.length || providedOptions.has("excludedPrClasses")) {
514
534
  incompatible.push("--exclude-pr-class");
515
535
  }
@@ -517,6 +537,14 @@ function rejectSampleLiveOptions(options) {
517
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.`);
518
538
  }
519
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
+
520
548
  function configuredPrClasses(repositoryProfile) {
521
549
  const rules = Array.isArray(repositoryProfile?.prClasses) ? repositoryProfile.prClasses : [];
522
550
  return new Set(rules.map(rule => rule.class));
@@ -1628,12 +1656,26 @@ export async function runAnalyzeGithub(options, {
1628
1656
  validateLimit(options.limit);
1629
1657
  validateExcludedPrClasses(options.excludedPrClasses);
1630
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
+ }
1631
1676
 
1632
- onProgress?.("Validating profile and output directory.");
1633
- const [repositoryProfile, outDir] = await Promise.all([
1634
- readProfile(options.profilePath),
1635
- validateOutputDirectory(options.outDir),
1636
- ]);
1677
+ onProgress?.("Validating output directory.");
1678
+ const outDir = await validateOutputDirectory(options.outDir);
1637
1679
  validateExcludedPrClassesAreConfigured(options.excludedPrClasses ?? [], repositoryProfile);
1638
1680
  const generatedPaths = artifactPaths(outDir, { includeCsv: csvEnabled });
1639
1681
  const disabledPaths = csvEnabled ? {} : Object.fromEntries(
@@ -1654,7 +1696,8 @@ export async function runAnalyzeGithub(options, {
1654
1696
  collectedAt: now(),
1655
1697
  isValidationTarget: options.isValidationTarget,
1656
1698
  contributors: repositoryProfile.contributors,
1657
- productRepository,
1699
+ productRepository: effectiveProductRepository,
1700
+ allowProductRepository: Boolean(options.allowProductRepository),
1658
1701
  });
1659
1702
 
1660
1703
  if (options.dryRun) {
@@ -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
  }