delivery-friction-analyzer 0.6.2 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "delivery-friction-analyzer",
3
- "version": "0.6.2",
3
+ "version": "0.7.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-19 — Interactive Setup Choice Presets
6
+
7
+ - What changed: Interactive setup now shows workflow profile choices as labeled selections and can add an opt-in Conventional Commit PR class preset to generated or updated repository profiles.
8
+ - Why it matters: Maintainers can capture common repository assumptions without typing schema identifiers or hand-writing the first set of title-based PR class rules.
9
+ - Who is affected: Maintainers running `--interactive` to create or update repository profiles.
10
+ - Action needed: None.
11
+ - PR: https://github.com/hannasdev/delivery-friction-analyzer/pull/40
12
+
5
13
  ### 2026-06-19 — Workflow Context Surfacing
6
14
 
7
15
  - What changed: Friction reports and methodology now show configured repository workflow context from the profile when it is present.
@@ -17,6 +17,7 @@ import {
17
17
  renderRepositoryFrictionMarkdown,
18
18
  } from "../report/friction-report.js";
19
19
  import { assertValidPrClassRules } from "../profile/pr-class.js";
20
+ import { conventionalCommitPrClassRules } from "../profile/pr-class-presets.js";
20
21
  import {
21
22
  WORKFLOW_BRANCH_STRATEGIES,
22
23
  WORKFLOW_PRIMARY_MERGE_METHODS,
@@ -197,6 +198,20 @@ function defaultOutDirForRepository(repository) {
197
198
  return join("reports", name || "analysis");
198
199
  }
199
200
 
201
+ function choiceValue(choice) {
202
+ return typeof choice === "object" && choice !== null ? choice.value : choice;
203
+ }
204
+
205
+ function choiceLabel(choice) {
206
+ return typeof choice === "object" && choice !== null ? choice.label : choice;
207
+ }
208
+
209
+ function formatChoiceList(choices) {
210
+ return choices
211
+ .map((choice, index) => `${index + 1}. ${choiceLabel(choice)} (${choiceValue(choice)})`)
212
+ .join("\n");
213
+ }
214
+
200
215
  function formatInteractivePrompt(prompt) {
201
216
  const suffix = prompt.defaultValue === undefined
202
217
  ? ""
@@ -207,10 +222,10 @@ function formatInteractivePrompt(prompt) {
207
222
  return `${prompt.message}${prompt.defaultValue ? " [Y/n]" : " [y/N]"} `;
208
223
  }
209
224
  if (prompt.type === "multi-select" && prompt.choices?.length) {
210
- return `${prompt.message} (${prompt.choices.join(",")})${suffix}: `;
225
+ return `${prompt.message} (${prompt.choices.map(choiceValue).join(",")})${suffix}: `;
211
226
  }
212
227
  if (prompt.type === "select" && prompt.choices?.length) {
213
- return `${prompt.message} (${prompt.choices.join(",")})${suffix}: `;
228
+ return `${prompt.message}\n${formatChoiceList(prompt.choices)}\nChoose a number or identifier${suffix}: `;
214
229
  }
215
230
  return `${prompt.message}${suffix}: `;
216
231
  }
@@ -277,8 +292,14 @@ function normalizeConfirmAnswer(raw, prompt) {
277
292
 
278
293
  function normalizeChoiceAnswer(raw, prompt) {
279
294
  const value = normalizeTextAnswer(raw, prompt);
280
- if (prompt.choices?.includes(value)) return value;
281
- throw new Error(`${prompt.id} must be one of: ${prompt.choices.join(", ")}`);
295
+ const choices = prompt.choices ?? [];
296
+ const numericIndex = Number(value);
297
+ if (Number.isInteger(numericIndex) && numericIndex >= 1 && numericIndex <= choices.length) {
298
+ return choiceValue(choices[numericIndex - 1]);
299
+ }
300
+ const match = choices.find(choice => choiceValue(choice) === value || choiceLabel(choice) === value);
301
+ if (match) return choiceValue(match);
302
+ throw new Error(`${prompt.id} must be one of: ${choices.map(choiceValue).join(", ")}`);
282
303
  }
283
304
 
284
305
  function normalizeMultiSelectAnswer(raw, prompt) {
@@ -519,12 +540,74 @@ function releasePrClassRule(profile, titleIncludes, existingRule = null) {
519
540
  };
520
541
  }
521
542
 
543
+ function withAvailablePrClassRuleIds(profile, rules) {
544
+ const profileWithIds = {
545
+ ...profile,
546
+ prClasses: Array.isArray(profile.prClasses) ? [...profile.prClasses] : [],
547
+ };
548
+ return rules.map(rule => {
549
+ const id = nextPrClassRuleId(profileWithIds, rule.id);
550
+ const nextRule = {
551
+ ...rule,
552
+ id,
553
+ match: { ...rule.match },
554
+ };
555
+ profileWithIds.prClasses.push(nextRule);
556
+ return nextRule;
557
+ });
558
+ }
559
+
560
+ async function promptConventionalCommitPrClassPreset(promptAdapter, output, profile) {
561
+ const hasExistingPrClasses = Array.isArray(profile.prClasses) && profile.prClasses.length > 0;
562
+ const message = hasExistingPrClasses
563
+ ? "Add Conventional Commit PR class preset to existing PR class rules"
564
+ : "Add Conventional Commit PR class preset";
565
+ const shouldAddPreset = await askUntilValid(promptAdapter, {
566
+ id: "addConventionalCommitPrClasses",
567
+ type: "confirm",
568
+ message,
569
+ defaultValue: false,
570
+ }, {
571
+ output,
572
+ normalize: normalizeConfirmAnswer,
573
+ validate() {},
574
+ });
575
+ if (!shouldAddPreset) return { profile, prClassRulesWritten: false };
576
+
577
+ const updated = cloneJson(profile);
578
+ updated.prClasses = Array.isArray(updated.prClasses) ? [...updated.prClasses] : [];
579
+ const presetRules = withAvailablePrClassRuleIds(updated, conventionalCommitPrClassRules());
580
+ updated.prClasses.push(...presetRules);
581
+ return { profile: updated, prClassRulesWritten: true };
582
+ }
583
+
584
+ const WORKFLOW_CHOICE_LABELS = Object.freeze({
585
+ merge_commit: "Merge commits",
586
+ squash_merge: "Squash merges",
587
+ rebase_merge: "Rebase merges",
588
+ release_prs: "Release PRs",
589
+ direct_tags: "Direct tags",
590
+ release_branches: "Release branches",
591
+ trunk_based: "Trunk-based",
592
+ main_plus_release_branches: "Main plus release branches",
593
+ long_lived_development_branches: "Long-lived development branches",
594
+ mixed: "Mixed",
595
+ unknown: "Unknown",
596
+ });
597
+
598
+ function workflowChoices(values) {
599
+ return values.map(value => ({
600
+ value,
601
+ label: WORKFLOW_CHOICE_LABELS[value] ?? value,
602
+ }));
603
+ }
604
+
522
605
  async function promptWorkflowField(promptAdapter, output, { id, message, choices, defaultValue }) {
523
606
  return askUntilValid(promptAdapter, {
524
607
  id,
525
608
  type: "select",
526
609
  message,
527
- choices,
610
+ choices: workflowChoices(choices),
528
611
  defaultValue,
529
612
  }, {
530
613
  output,
@@ -535,6 +618,7 @@ async function promptWorkflowField(promptAdapter, output, { id, message, choices
535
618
 
536
619
  async function promptWorkflowProfileUpdate(promptAdapter, output, profile, { isNewProfile }) {
537
620
  const updated = cloneJson(profile);
621
+ let prClassRulesWritten = false;
538
622
  updated.workflow = {
539
623
  primaryMergeMethod: await promptWorkflowField(promptAdapter, output, {
540
624
  id: "primaryMergeMethod",
@@ -588,6 +672,7 @@ async function promptWorkflowProfileUpdate(promptAdapter, output, profile, { isN
588
672
  titleIncludes,
589
673
  updated.prClasses[updateableReleaseIndex],
590
674
  );
675
+ prClassRulesWritten = true;
591
676
  }
592
677
  } else {
593
678
  const shouldAddReleaseRule = isNewProfile
@@ -604,13 +689,18 @@ async function promptWorkflowProfileUpdate(promptAdapter, output, profile, { isN
604
689
  });
605
690
  if (shouldAddReleaseRule) {
606
691
  updated.prClasses.push(releasePrClassRule(updated, titleIncludes));
692
+ prClassRulesWritten = true;
607
693
  }
608
694
  }
609
695
  }
610
696
  }
611
697
 
612
- validateProfile(updated);
613
- return updated;
698
+ const presetUpdate = await promptConventionalCommitPrClassPreset(promptAdapter, output, updated);
699
+ validateProfile(presetUpdate.profile);
700
+ return {
701
+ profile: presetUpdate.profile,
702
+ prClassRulesWritten: prClassRulesWritten || presetUpdate.prClassRulesWritten,
703
+ };
614
704
  }
615
705
 
616
706
  async function maybeConfigureInteractiveProfile(promptAdapter, output, profileState, repository) {
@@ -644,15 +734,33 @@ async function maybeConfigureInteractiveProfile(promptAdapter, output, profileSt
644
734
  validate() {},
645
735
  });
646
736
  if (!shouldConfigureWorkflow) {
737
+ const presetUpdate = await promptConventionalCommitPrClassPreset(promptAdapter, output, profile);
738
+ validateProfile(presetUpdate.profile);
739
+ if (presetUpdate.prClassRulesWritten) {
740
+ const savedProfilePath = await writeInteractiveProfile(profileState.profilePath, presetUpdate.profile, {
741
+ exists: profileState.exists,
742
+ originalProfile,
743
+ originalText: profileState.text,
744
+ originalIsSymbolicLink: profileState.isSymbolicLink,
745
+ });
746
+ return {
747
+ profile: presetUpdate.profile,
748
+ profilePath: savedProfilePath,
749
+ savedProfilePath,
750
+ prClassRulesWritten: true,
751
+ };
752
+ }
647
753
  return {
648
754
  profile,
649
755
  profilePath: profileState.profilePath,
650
756
  savedProfilePath: null,
757
+ prClassRulesWritten: false,
651
758
  };
652
759
  }
653
760
  }
654
761
 
655
- profile = await promptWorkflowProfileUpdate(promptAdapter, output, profile, { isNewProfile });
762
+ const profileUpdate = await promptWorkflowProfileUpdate(promptAdapter, output, profile, { isNewProfile });
763
+ profile = profileUpdate.profile;
656
764
  const savedProfilePath = await writeInteractiveProfile(profileState.profilePath, profile, {
657
765
  exists: profileState.exists,
658
766
  originalProfile,
@@ -663,6 +771,7 @@ async function maybeConfigureInteractiveProfile(promptAdapter, output, profileSt
663
771
  profile,
664
772
  profilePath: savedProfilePath,
665
773
  savedProfilePath,
774
+ prClassRulesWritten: profileUpdate.prClassRulesWritten,
666
775
  };
667
776
  }
668
777
 
@@ -774,6 +883,7 @@ export async function collectInteractiveAnalyzeGithubOptions(options, {
774
883
  resolved.profilePath = profileUpdate.profilePath;
775
884
  if (profileUpdate.savedProfilePath) {
776
885
  resolved.savedProfilePath = profileUpdate.savedProfilePath;
886
+ resolved.prClassRulesWritten = profileUpdate.prClassRulesWritten;
777
887
  if (typeof onSavedProfilePath === "function") {
778
888
  onSavedProfilePath(profileUpdate.savedProfilePath);
779
889
  }
@@ -1022,7 +1132,7 @@ function attachCollectionCoverage(report, sourceBundle) {
1022
1132
  };
1023
1133
  }
1024
1134
 
1025
- function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report, requestedLimit, sampledLimit, csv, analysisFilter, savedProfilePath }) {
1135
+ function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report, requestedLimit, sampledLimit, csv, analysisFilter, savedProfilePath, prClassRulesWritten }) {
1026
1136
  const summary = {
1027
1137
  ok: true,
1028
1138
  dryRun,
@@ -1041,6 +1151,9 @@ function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report,
1041
1151
  if (savedProfilePath) {
1042
1152
  summary.savedProfilePath = savedProfilePath;
1043
1153
  }
1154
+ if (prClassRulesWritten) {
1155
+ summary.prClassRulesWritten = true;
1156
+ }
1044
1157
  return summary;
1045
1158
  }
1046
1159
 
@@ -1114,6 +1227,7 @@ export async function runAnalyzeGithub(options, {
1114
1227
  csv: false,
1115
1228
  analysisFilter: null,
1116
1229
  savedProfilePath: options.savedProfilePath,
1230
+ prClassRulesWritten: options.prClassRulesWritten,
1117
1231
  });
1118
1232
  }
1119
1233
 
@@ -1166,6 +1280,7 @@ export async function runAnalyzeGithub(options, {
1166
1280
  csv: csvEnabled,
1167
1281
  analysisFilter: normalized.analysisFilter ?? null,
1168
1282
  savedProfilePath: options.savedProfilePath,
1283
+ prClassRulesWritten: options.prClassRulesWritten,
1169
1284
  });
1170
1285
  }
1171
1286
 
@@ -1238,6 +1353,9 @@ export function formatAnalyzeGithubCompletion(result) {
1238
1353
  if (result.savedProfilePath) {
1239
1354
  lines.push(`Repository profile saved: ${result.savedProfilePath}.`);
1240
1355
  }
1356
+ if (result.prClassRulesWritten) {
1357
+ lines.push("PR class rules written: Conventional Commit preset or release title rule.");
1358
+ }
1241
1359
 
1242
1360
  lines.push(`Collection coverage: ${result.collectionCoverage?.status ?? "unknown"}.`);
1243
1361
 
@@ -0,0 +1,47 @@
1
+ export const CONVENTIONAL_COMMIT_PR_CLASS_PRESET = Object.freeze([
2
+ Object.freeze({
3
+ id: "conventional-dependency",
4
+ class: "dependency",
5
+ match: Object.freeze({ titleRegex: "^(?:deps!?|(?:build|chore|fix)\\(deps\\)!?):|^Bump\\b" }),
6
+ notes: "Generated by interactive setup from the Conventional Commit PR title preset.",
7
+ }),
8
+ Object.freeze({
9
+ id: "conventional-feature",
10
+ class: "feature",
11
+ match: Object.freeze({ titleRegex: "^feat(?:\\([^)]+\\))?!?:" }),
12
+ notes: "Generated by interactive setup from the Conventional Commit PR title preset.",
13
+ }),
14
+ Object.freeze({
15
+ id: "conventional-fix",
16
+ class: "fix",
17
+ match: Object.freeze({ titleRegex: "^fix(?:\\((?!deps\\))[^)]+\\))?!?:" }),
18
+ notes: "Generated by interactive setup from the Conventional Commit PR title preset.",
19
+ }),
20
+ Object.freeze({
21
+ id: "conventional-docs",
22
+ class: "docs",
23
+ match: Object.freeze({ titleRegex: "^docs(?:\\([^)]+\\))?!?:" }),
24
+ notes: "Generated by interactive setup from the Conventional Commit PR title preset.",
25
+ }),
26
+ Object.freeze({
27
+ id: "conventional-test",
28
+ class: "test",
29
+ match: Object.freeze({ titleRegex: "^test(?:\\([^)]+\\))?!?:" }),
30
+ notes: "Generated by interactive setup from the Conventional Commit PR title preset.",
31
+ }),
32
+ Object.freeze({
33
+ id: "conventional-maintenance",
34
+ class: "maintenance",
35
+ match: Object.freeze({ titleRegex: "^(refactor|perf|style|build|ci)(?:\\([^)]+\\))?!?:|^chore(?:\\((?!deps\\))[^)]+\\))?!?:" }),
36
+ notes: "Generated by interactive setup from the Conventional Commit PR title preset.",
37
+ }),
38
+ ]);
39
+
40
+ export function conventionalCommitPrClassRules() {
41
+ return CONVENTIONAL_COMMIT_PR_CLASS_PRESET.map(rule => ({
42
+ id: rule.id,
43
+ class: rule.class,
44
+ match: { ...rule.match },
45
+ notes: rule.notes,
46
+ }));
47
+ }