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,
|
|
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
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 {
|
|
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
|
|
1633
|
-
const
|
|
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.
|
|
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
|
}
|