delivery-friction-analyzer 0.2.3 → 0.3.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 +10 -0
- package/package.json +1 -1
- package/release-log.md +8 -0
- package/src/cli/analyze-github.js +311 -9
package/README.md
CHANGED
|
@@ -44,6 +44,14 @@ npx delivery-friction-analyzer \
|
|
|
44
44
|
|
|
45
45
|
Open `reports/mcp-writing/friction-report.md` first. It is the main human-readable report. Use the JSON and CSV files when you want to audit a finding, compare PRs, or build follow-up analysis.
|
|
46
46
|
|
|
47
|
+
For a guided first run in a local terminal, use the opt-in interactive flow:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
npm run analyze:github -- --interactive
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Interactive mode asks for the same run choices supported by flags, including repository, PR limit, profile path, output directory, dry-run mode, CSV exports, JSON completion output, and configured PR class exclusions. Scripted and CI usage should keep passing explicit flags; missing required flags without `--interactive` fail deterministically instead of waiting for input.
|
|
54
|
+
|
|
47
55
|
## Repository Profiles
|
|
48
56
|
|
|
49
57
|
Every run needs a repository profile. Profiles keep repository-specific assumptions out of the analyzer code by describing how paths and pull request titles should be classified.
|
|
@@ -84,6 +92,8 @@ Use `--exclude-pr-class <class>` to remove a configured PR class from downstream
|
|
|
84
92
|
|
|
85
93
|
Use `--json` when automation needs the full machine-readable completion receipt on stdout.
|
|
86
94
|
|
|
95
|
+
Use `--interactive` only in a terminal when you want prompts. When combined with `--json`, prompts and progress stay off stdout so the final completion receipt remains parseable JSON.
|
|
96
|
+
|
|
87
97
|
## How To Read A Report
|
|
88
98
|
|
|
89
99
|
Start with `friction-report.md`. If a bottleneck looks surprising, inspect `methodology.md`, the CSV exports, `friction-report.json`, and `source-bundle.json`.
|
package/package.json
CHANGED
package/release-log.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
### 2026-06-17 — Opt-In Interactive CLI Setup
|
|
6
|
+
|
|
7
|
+
- What changed: GitHub analysis now supports `--interactive` to prompt for existing run options such as repository, PR limit, profile path, output directory, dry-run mode, CSV exports, JSON completion output, and configured PR class exclusions.
|
|
8
|
+
- Why it matters: First-time maintainers can complete a guided local analysis without memorizing every required flag, while scripts and CI keep deterministic flag-based behavior.
|
|
9
|
+
- Who is affected: Maintainers and contributors running local GitHub analysis from a terminal.
|
|
10
|
+
- Action needed: Use `--interactive` for guided local setup; keep explicit flags for automation.
|
|
11
|
+
- PR: https://github.com/hannasdev/delivery-friction-analyzer/pull/34
|
|
12
|
+
|
|
5
13
|
### 2026-06-15 — Review Decision Author Detection
|
|
6
14
|
|
|
7
15
|
- What changed: Review decision evidence now recognizes human approvals from live `gh pr view` review events that include only an author login.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { constants, realpathSync } from "node:fs";
|
|
3
3
|
import { access, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
5
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
7
|
import { collectGitHubSourceBundle } from "../collect/github-source-bundle.js";
|
|
7
8
|
import { createGhCliProvider } from "../collect/gh-provider.js";
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
generateRepositoryFrictionReport,
|
|
16
17
|
renderRepositoryFrictionMarkdown,
|
|
17
18
|
} from "../report/friction-report.js";
|
|
19
|
+
import { assertValidPrClassRules } from "../profile/pr-class.js";
|
|
18
20
|
|
|
19
21
|
const ALLOWED_OPTIONS = new Set([
|
|
20
22
|
"repo",
|
|
@@ -27,6 +29,7 @@ const ALLOWED_OPTIONS = new Set([
|
|
|
27
29
|
"no-csv",
|
|
28
30
|
"exclude-pr-class",
|
|
29
31
|
"json",
|
|
32
|
+
"interactive",
|
|
30
33
|
]);
|
|
31
34
|
|
|
32
35
|
const REPOSITORY_SLUG = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/;
|
|
@@ -66,6 +69,7 @@ Options:
|
|
|
66
69
|
--exclude-pr-class <cls> Exclude a PR class from normalized, metrics, report, methodology, and CSV artifacts. Repeat or comma-separate values.
|
|
67
70
|
--no-csv Suppress curated CSV evidence exports.
|
|
68
71
|
--json Print the machine-readable completion receipt to stdout.
|
|
72
|
+
--interactive Prompt for missing run options in a terminal.
|
|
69
73
|
`;
|
|
70
74
|
|
|
71
75
|
export function parseAnalyzeGithubArgs(argv) {
|
|
@@ -90,6 +94,7 @@ export function parseAnalyzeGithubArgs(argv) {
|
|
|
90
94
|
|| key === "validation-target"
|
|
91
95
|
|| key === "no-csv"
|
|
92
96
|
|| key === "json"
|
|
97
|
+
|| key === "interactive"
|
|
93
98
|
) {
|
|
94
99
|
options[key] = true;
|
|
95
100
|
continue;
|
|
@@ -117,6 +122,7 @@ export function parseAnalyzeGithubArgs(argv) {
|
|
|
117
122
|
excludedPrClasses: normalizeExcludedPrClasses(options["exclude-pr-class"] ?? []),
|
|
118
123
|
csv: !options["no-csv"],
|
|
119
124
|
json: Boolean(options.json),
|
|
125
|
+
interactive: Boolean(options.interactive),
|
|
120
126
|
};
|
|
121
127
|
}
|
|
122
128
|
|
|
@@ -159,7 +165,8 @@ function validateExcludedPrClasses(excludedPrClasses = []) {
|
|
|
159
165
|
}
|
|
160
166
|
|
|
161
167
|
function configuredPrClasses(repositoryProfile) {
|
|
162
|
-
|
|
168
|
+
const rules = Array.isArray(repositoryProfile?.prClasses) ? repositoryProfile.prClasses : [];
|
|
169
|
+
return new Set(rules.map(rule => rule.class));
|
|
163
170
|
}
|
|
164
171
|
|
|
165
172
|
function validateExcludedPrClassesAreConfigured(excludedPrClasses = [], repositoryProfile) {
|
|
@@ -176,14 +183,275 @@ function validateExcludedPrClassesAreConfigured(excludedPrClasses = [], reposito
|
|
|
176
183
|
}
|
|
177
184
|
|
|
178
185
|
async function readProfile(profilePath) {
|
|
186
|
+
let profile;
|
|
179
187
|
try {
|
|
180
|
-
|
|
188
|
+
profile = JSON.parse(await readFile(profilePath, "utf8"));
|
|
181
189
|
} catch (error) {
|
|
182
190
|
if (error instanceof SyntaxError) {
|
|
183
191
|
throw new Error(`profile must be valid JSON: ${error.message}`);
|
|
184
192
|
}
|
|
185
193
|
throw new Error(`profile could not be read: ${error.message}`);
|
|
186
194
|
}
|
|
195
|
+
try {
|
|
196
|
+
assertValidPrClassRules(profile);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
throw new Error(`profile is invalid: ${error.message}`);
|
|
199
|
+
}
|
|
200
|
+
return profile;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function configuredPrClassList(repositoryProfile) {
|
|
204
|
+
return [...configuredPrClasses(repositoryProfile)].sort();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function defaultOutDirForRepository(repository) {
|
|
208
|
+
const [, name] = String(repository ?? "").split("/");
|
|
209
|
+
return join("reports", name || "analysis");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function formatInteractivePrompt(prompt) {
|
|
213
|
+
const suffix = prompt.defaultValue === undefined
|
|
214
|
+
? ""
|
|
215
|
+
: Array.isArray(prompt.defaultValue)
|
|
216
|
+
? (prompt.defaultValue.length ? ` [${prompt.defaultValue.join(",")}]` : "")
|
|
217
|
+
: ` [${prompt.defaultValue}]`;
|
|
218
|
+
if (prompt.type === "confirm") {
|
|
219
|
+
return `${prompt.message}${prompt.defaultValue ? " [Y/n]" : " [y/N]"} `;
|
|
220
|
+
}
|
|
221
|
+
if (prompt.type === "multi-select" && prompt.choices?.length) {
|
|
222
|
+
return `${prompt.message} (${prompt.choices.join(",")})${suffix}: `;
|
|
223
|
+
}
|
|
224
|
+
return `${prompt.message}${suffix}: `;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function createTerminalPromptAdapter({ input, output }) {
|
|
228
|
+
const readline = createInterface({ input, output, terminal: true });
|
|
229
|
+
return {
|
|
230
|
+
async ask(prompt) {
|
|
231
|
+
return readline.question(formatInteractivePrompt(prompt));
|
|
232
|
+
},
|
|
233
|
+
writeError(message) {
|
|
234
|
+
output.write(`${message}\n`);
|
|
235
|
+
},
|
|
236
|
+
close() {
|
|
237
|
+
readline.close();
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function callPromptAdapter(promptAdapter, prompt) {
|
|
243
|
+
if (typeof promptAdapter === "function") {
|
|
244
|
+
return promptAdapter(prompt);
|
|
245
|
+
}
|
|
246
|
+
return promptAdapter.ask(prompt);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function askUntilValid(promptAdapter, prompt, { normalize, validate, output }) {
|
|
250
|
+
for (;;) {
|
|
251
|
+
const raw = await callPromptAdapter(promptAdapter, prompt);
|
|
252
|
+
try {
|
|
253
|
+
const value = normalize(raw, prompt);
|
|
254
|
+
await validate(value);
|
|
255
|
+
return value;
|
|
256
|
+
} catch (error) {
|
|
257
|
+
const message = error?.message ?? String(error);
|
|
258
|
+
if (typeof promptAdapter.writeError === "function") {
|
|
259
|
+
promptAdapter.writeError(message);
|
|
260
|
+
} else if (output?.write) {
|
|
261
|
+
output.write(`${message}\n`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function normalizeTextAnswer(raw, prompt) {
|
|
268
|
+
const value = String(raw ?? "").trim();
|
|
269
|
+
if (value) return value;
|
|
270
|
+
if (prompt.defaultValue !== undefined) return prompt.defaultValue;
|
|
271
|
+
return value;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function normalizeIntegerAnswer(raw, prompt) {
|
|
275
|
+
const value = normalizeTextAnswer(raw, prompt);
|
|
276
|
+
return Number(value);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function normalizeConfirmAnswer(raw, prompt) {
|
|
280
|
+
const value = String(raw ?? "").trim().toLowerCase();
|
|
281
|
+
if (!value && prompt.defaultValue !== undefined) return Boolean(prompt.defaultValue);
|
|
282
|
+
if (["y", "yes", "true", "1"].includes(value)) return true;
|
|
283
|
+
if (["n", "no", "false", "0"].includes(value)) return false;
|
|
284
|
+
throw new Error("Answer yes or no.");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function normalizeMultiSelectAnswer(raw, prompt) {
|
|
288
|
+
const value = String(raw ?? "").trim();
|
|
289
|
+
if (!value) return prompt.defaultValue ?? [];
|
|
290
|
+
return normalizeExcludedPrClasses([value]);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function promptProfilePath(promptAdapter, output, prompt) {
|
|
294
|
+
let profile = null;
|
|
295
|
+
const profilePath = await askUntilValid(promptAdapter, prompt, {
|
|
296
|
+
output,
|
|
297
|
+
normalize: normalizeTextAnswer,
|
|
298
|
+
async validate(value) {
|
|
299
|
+
profile = await readProfile(value);
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
return { profilePath, profile };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function hasOwnOption(options, key) {
|
|
306
|
+
return Object.prototype.hasOwnProperty.call(options, key);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function shouldPromptInteractiveOption(options, key) {
|
|
310
|
+
const promptDefaults = options.interactivePromptDefaults ?? {};
|
|
311
|
+
if (hasOwnOption(promptDefaults, key)) {
|
|
312
|
+
return Boolean(promptDefaults[key]);
|
|
313
|
+
}
|
|
314
|
+
return !hasOwnOption(options, key);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function collectInteractiveAnalyzeGithubOptions(options, {
|
|
318
|
+
promptAdapter = null,
|
|
319
|
+
input = process.stdin,
|
|
320
|
+
output = process.stderr,
|
|
321
|
+
isInteractiveTerminal = Boolean(input?.isTTY),
|
|
322
|
+
} = {}) {
|
|
323
|
+
if (!isInteractiveTerminal) {
|
|
324
|
+
throw new Error("interactive mode requires a terminal. Re-run with --repo <owner/name> --limit <1-100> --profile <path> --out <directory>, or provide a TTY for prompts.");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const adapter = promptAdapter ?? createTerminalPromptAdapter({ input, output });
|
|
328
|
+
const ownsAdapter = !promptAdapter;
|
|
329
|
+
const resolved = { ...options };
|
|
330
|
+
delete resolved.interactivePromptDefaults;
|
|
331
|
+
let repositoryProfile = null;
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
if (!resolved.repository) {
|
|
335
|
+
resolved.repository = await askUntilValid(adapter, {
|
|
336
|
+
id: "repository",
|
|
337
|
+
type: "text",
|
|
338
|
+
message: "Target GitHub repository",
|
|
339
|
+
}, {
|
|
340
|
+
output,
|
|
341
|
+
normalize: normalizeTextAnswer,
|
|
342
|
+
validate: validateRepositorySlug,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (resolved.limit === undefined) {
|
|
347
|
+
resolved.limit = await askUntilValid(adapter, {
|
|
348
|
+
id: "limit",
|
|
349
|
+
type: "integer",
|
|
350
|
+
message: "Latest merged pull request count",
|
|
351
|
+
defaultValue: 30,
|
|
352
|
+
}, {
|
|
353
|
+
output,
|
|
354
|
+
normalize: normalizeIntegerAnswer,
|
|
355
|
+
validate: validateLimit,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (!resolved.profilePath) {
|
|
360
|
+
const prompted = await promptProfilePath(adapter, output, {
|
|
361
|
+
id: "profilePath",
|
|
362
|
+
type: "path",
|
|
363
|
+
message: "Repository profile path",
|
|
364
|
+
});
|
|
365
|
+
resolved.profilePath = prompted.profilePath;
|
|
366
|
+
repositoryProfile = prompted.profile;
|
|
367
|
+
} else {
|
|
368
|
+
repositoryProfile = await readProfile(resolved.profilePath);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (!resolved.outDir) {
|
|
372
|
+
resolved.outDir = await askUntilValid(adapter, {
|
|
373
|
+
id: "outDir",
|
|
374
|
+
type: "path",
|
|
375
|
+
message: "Output directory",
|
|
376
|
+
defaultValue: defaultOutDirForRepository(resolved.repository),
|
|
377
|
+
}, {
|
|
378
|
+
output,
|
|
379
|
+
normalize: normalizeTextAnswer,
|
|
380
|
+
async validate(value) {
|
|
381
|
+
if (!value) throw new Error("Output directory is required.");
|
|
382
|
+
await validateOutputDirectory(value);
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (shouldPromptInteractiveOption(options, "dryRun")) {
|
|
388
|
+
resolved.dryRun = await askUntilValid(adapter, {
|
|
389
|
+
id: "dryRun",
|
|
390
|
+
type: "confirm",
|
|
391
|
+
message: "Run metadata-only dry run",
|
|
392
|
+
defaultValue: false,
|
|
393
|
+
}, {
|
|
394
|
+
output,
|
|
395
|
+
normalize: normalizeConfirmAnswer,
|
|
396
|
+
validate() {},
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
if (resolved.dryRun === undefined) resolved.dryRun = false;
|
|
400
|
+
|
|
401
|
+
if (!resolved.dryRun && shouldPromptInteractiveOption(options, "csv")) {
|
|
402
|
+
resolved.csv = await askUntilValid(adapter, {
|
|
403
|
+
id: "csv",
|
|
404
|
+
type: "confirm",
|
|
405
|
+
message: "Write CSV evidence files",
|
|
406
|
+
defaultValue: true,
|
|
407
|
+
}, {
|
|
408
|
+
output,
|
|
409
|
+
normalize: normalizeConfirmAnswer,
|
|
410
|
+
validate() {},
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (resolved.csv === undefined) resolved.csv = true;
|
|
414
|
+
|
|
415
|
+
if (shouldPromptInteractiveOption(options, "json")) {
|
|
416
|
+
resolved.json = await askUntilValid(adapter, {
|
|
417
|
+
id: "json",
|
|
418
|
+
type: "confirm",
|
|
419
|
+
message: "Print completion as JSON",
|
|
420
|
+
defaultValue: false,
|
|
421
|
+
}, {
|
|
422
|
+
output,
|
|
423
|
+
normalize: normalizeConfirmAnswer,
|
|
424
|
+
validate() {},
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
if (resolved.json === undefined) resolved.json = false;
|
|
428
|
+
|
|
429
|
+
if (!resolved.excludedPrClasses?.length) {
|
|
430
|
+
const availablePrClasses = configuredPrClassList(repositoryProfile);
|
|
431
|
+
if (availablePrClasses.length) {
|
|
432
|
+
resolved.excludedPrClasses = await askUntilValid(adapter, {
|
|
433
|
+
id: "excludedPrClasses",
|
|
434
|
+
type: "multi-select",
|
|
435
|
+
message: "Exclude PR classes (comma-separated, blank for none)",
|
|
436
|
+
choices: availablePrClasses,
|
|
437
|
+
defaultValue: [],
|
|
438
|
+
}, {
|
|
439
|
+
output,
|
|
440
|
+
normalize: normalizeMultiSelectAnswer,
|
|
441
|
+
validate(value) {
|
|
442
|
+
validateExcludedPrClasses(value);
|
|
443
|
+
validateExcludedPrClassesAreConfigured(value, repositoryProfile);
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return resolved;
|
|
450
|
+
} finally {
|
|
451
|
+
if (ownsAdapter && typeof adapter.close === "function") {
|
|
452
|
+
adapter.close();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
187
455
|
}
|
|
188
456
|
|
|
189
457
|
async function validateOutputDirectory(outDir) {
|
|
@@ -480,8 +748,8 @@ export async function runAnalyzeGithub(options, {
|
|
|
480
748
|
});
|
|
481
749
|
}
|
|
482
750
|
|
|
483
|
-
function writeProgress(message) {
|
|
484
|
-
|
|
751
|
+
function writeProgress(message, stderr = process.stderr) {
|
|
752
|
+
stderr.write(`${message}\n`);
|
|
485
753
|
}
|
|
486
754
|
|
|
487
755
|
function coverageLine(family) {
|
|
@@ -568,15 +836,49 @@ export function writeAnalyzeGithubCompletion(result, { json = false, stdout = pr
|
|
|
568
836
|
stdout.write(formatAnalyzeGithubCompletion(result));
|
|
569
837
|
}
|
|
570
838
|
|
|
571
|
-
async function
|
|
839
|
+
export async function runAnalyzeGithubCli(argv, {
|
|
840
|
+
provider,
|
|
841
|
+
now,
|
|
842
|
+
stdin = process.stdin,
|
|
843
|
+
stdout = process.stdout,
|
|
844
|
+
stderr = process.stderr,
|
|
845
|
+
promptAdapter = null,
|
|
846
|
+
isInteractiveTerminal = Boolean(stdin?.isTTY),
|
|
847
|
+
} = {}) {
|
|
572
848
|
const options = parseAnalyzeGithubArgs(argv);
|
|
573
849
|
if (options.help) {
|
|
574
|
-
|
|
575
|
-
return;
|
|
850
|
+
stdout.write(USAGE);
|
|
851
|
+
return null;
|
|
576
852
|
}
|
|
577
853
|
|
|
578
|
-
const
|
|
579
|
-
|
|
854
|
+
const resolvedOptions = options.interactive
|
|
855
|
+
? await collectInteractiveAnalyzeGithubOptions({
|
|
856
|
+
...options,
|
|
857
|
+
interactivePromptDefaults: {
|
|
858
|
+
dryRun: !options.dryRun,
|
|
859
|
+
csv: options.csv !== false,
|
|
860
|
+
json: !options.json,
|
|
861
|
+
},
|
|
862
|
+
}, {
|
|
863
|
+
promptAdapter,
|
|
864
|
+
input: stdin,
|
|
865
|
+
output: stderr,
|
|
866
|
+
isInteractiveTerminal,
|
|
867
|
+
})
|
|
868
|
+
: options;
|
|
869
|
+
const runOptions = {
|
|
870
|
+
onProgress: message => writeProgress(message, stderr),
|
|
871
|
+
};
|
|
872
|
+
if (provider !== undefined) runOptions.provider = provider;
|
|
873
|
+
if (now !== undefined) runOptions.now = now;
|
|
874
|
+
|
|
875
|
+
const result = await runAnalyzeGithub(resolvedOptions, runOptions);
|
|
876
|
+
writeAnalyzeGithubCompletion(result, { json: resolvedOptions.json, stdout });
|
|
877
|
+
return result;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async function main(argv) {
|
|
881
|
+
await runAnalyzeGithubCli(argv);
|
|
580
882
|
}
|
|
581
883
|
|
|
582
884
|
function isCliEntrypoint(entryPath) {
|