delivery-friction-analyzer 0.2.3 → 0.4.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
@@ -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`.
@@ -53,3 +53,29 @@ If both matchers are present on one rule, both must match. If no rule matches, t
53
53
  ```
54
54
 
55
55
  Class identifiers are validated as lower-kebab-case or lower_snake_case strings. Profile validation rejects duplicate PR class rule IDs, empty match objects, invalid class identifiers, and invalid title regexes.
56
+
57
+ ## Workflow Context
58
+
59
+ `workflow` is optional user-configured context. It records repository workflow assumptions that later setup and report milestones can rely on, but M2 does not infer these values from GitHub and does not change scoring, rankings, collection, PR class matching, or report wording.
60
+
61
+ When provided, `workflow` must include at least one supported field.
62
+
63
+ Supported fields:
64
+
65
+ - `primaryMergeMethod`: `merge_commit`, `squash_merge`, `rebase_merge`, `mixed`, or `unknown`.
66
+ - `releaseStrategy`: `release_prs`, `direct_tags`, `release_branches`, `mixed`, or `unknown`.
67
+ - `branchStrategy`: `trunk_based`, `main_plus_release_branches`, `long_lived_development_branches`, `mixed`, or `unknown`.
68
+
69
+ Example:
70
+
71
+ ```json
72
+ {
73
+ "workflow": {
74
+ "primaryMergeMethod": "squash_merge",
75
+ "releaseStrategy": "release_prs",
76
+ "branchStrategy": "main_plus_release_branches"
77
+ }
78
+ }
79
+ ```
80
+
81
+ Use stable identifiers exactly as shown above. Display labels such as "squash merges" or "release PRs" belong in CLI prompts or documentation, not in profile data.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "delivery-friction-analyzer",
3
- "version": "0.2.3",
3
+ "version": "0.4.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-17 — Workflow Profile Contract
6
+
7
+ - What changed: Repository profiles can now declare optional workflow context for merge method, release strategy, and branch strategy using validated stable identifiers.
8
+ - Why it matters: Future interactive setup and report milestones can rely on a documented profile contract without inferring workflow assumptions from GitHub history or changing scoring.
9
+ - Who is affected: Maintainers authoring or validating repository profiles.
10
+ - Action needed: Add `workflow` context only when you want to record repository workflow assumptions; existing profiles remain valid without it.
11
+ - PR: https://github.com/hannasdev/delivery-friction-analyzer/pull/35
12
+
13
+ ### 2026-06-17 — Opt-In Interactive CLI Setup
14
+
15
+ - 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.
16
+ - 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.
17
+ - Who is affected: Maintainers and contributors running local GitHub analysis from a terminal.
18
+ - Action needed: Use `--interactive` for guided local setup; keep explicit flags for automation.
19
+ - PR: https://github.com/hannasdev/delivery-friction-analyzer/pull/34
20
+
5
21
  ### 2026-06-15 — Review Decision Author Detection
6
22
 
7
23
  - What changed: Review decision evidence now recognizes human approvals from live `gh pr view` review events that include only an author login.
@@ -87,6 +87,22 @@
87
87
  "notes": { "type": "string" }
88
88
  }
89
89
  }
90
+ },
91
+ "workflow": {
92
+ "type": "object",
93
+ "additionalProperties": false,
94
+ "properties": {
95
+ "primaryMergeMethod": {
96
+ "enum": ["merge_commit", "squash_merge", "rebase_merge", "mixed", "unknown"]
97
+ },
98
+ "releaseStrategy": {
99
+ "enum": ["release_prs", "direct_tags", "release_branches", "mixed", "unknown"]
100
+ },
101
+ "branchStrategy": {
102
+ "enum": ["trunk_based", "main_plus_release_branches", "long_lived_development_branches", "mixed", "unknown"]
103
+ }
104
+ },
105
+ "minProperties": 1
90
106
  }
91
107
  }
92
108
  }
@@ -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,8 @@ import {
15
16
  generateRepositoryFrictionReport,
16
17
  renderRepositoryFrictionMarkdown,
17
18
  } from "../report/friction-report.js";
19
+ import { assertValidPrClassRules } from "../profile/pr-class.js";
20
+ import { assertValidWorkflowContext } from "../profile/workflow.js";
18
21
 
19
22
  const ALLOWED_OPTIONS = new Set([
20
23
  "repo",
@@ -27,6 +30,7 @@ const ALLOWED_OPTIONS = new Set([
27
30
  "no-csv",
28
31
  "exclude-pr-class",
29
32
  "json",
33
+ "interactive",
30
34
  ]);
31
35
 
32
36
  const REPOSITORY_SLUG = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/;
@@ -66,6 +70,7 @@ Options:
66
70
  --exclude-pr-class <cls> Exclude a PR class from normalized, metrics, report, methodology, and CSV artifacts. Repeat or comma-separate values.
67
71
  --no-csv Suppress curated CSV evidence exports.
68
72
  --json Print the machine-readable completion receipt to stdout.
73
+ --interactive Prompt for missing run options in a terminal.
69
74
  `;
70
75
 
71
76
  export function parseAnalyzeGithubArgs(argv) {
@@ -90,6 +95,7 @@ export function parseAnalyzeGithubArgs(argv) {
90
95
  || key === "validation-target"
91
96
  || key === "no-csv"
92
97
  || key === "json"
98
+ || key === "interactive"
93
99
  ) {
94
100
  options[key] = true;
95
101
  continue;
@@ -117,6 +123,7 @@ export function parseAnalyzeGithubArgs(argv) {
117
123
  excludedPrClasses: normalizeExcludedPrClasses(options["exclude-pr-class"] ?? []),
118
124
  csv: !options["no-csv"],
119
125
  json: Boolean(options.json),
126
+ interactive: Boolean(options.interactive),
120
127
  };
121
128
  }
122
129
 
@@ -159,7 +166,8 @@ function validateExcludedPrClasses(excludedPrClasses = []) {
159
166
  }
160
167
 
161
168
  function configuredPrClasses(repositoryProfile) {
162
- return new Set((repositoryProfile.prClasses ?? []).map(rule => rule.class));
169
+ const rules = Array.isArray(repositoryProfile?.prClasses) ? repositoryProfile.prClasses : [];
170
+ return new Set(rules.map(rule => rule.class));
163
171
  }
164
172
 
165
173
  function validateExcludedPrClassesAreConfigured(excludedPrClasses = [], repositoryProfile) {
@@ -176,14 +184,276 @@ function validateExcludedPrClassesAreConfigured(excludedPrClasses = [], reposito
176
184
  }
177
185
 
178
186
  async function readProfile(profilePath) {
187
+ let profile;
179
188
  try {
180
- return JSON.parse(await readFile(profilePath, "utf8"));
189
+ profile = JSON.parse(await readFile(profilePath, "utf8"));
181
190
  } catch (error) {
182
191
  if (error instanceof SyntaxError) {
183
192
  throw new Error(`profile must be valid JSON: ${error.message}`);
184
193
  }
185
194
  throw new Error(`profile could not be read: ${error.message}`);
186
195
  }
196
+ try {
197
+ assertValidPrClassRules(profile);
198
+ assertValidWorkflowContext(profile);
199
+ } catch (error) {
200
+ throw new Error(`profile is invalid: ${error.message}`);
201
+ }
202
+ return profile;
203
+ }
204
+
205
+ function configuredPrClassList(repositoryProfile) {
206
+ return [...configuredPrClasses(repositoryProfile)].sort();
207
+ }
208
+
209
+ function defaultOutDirForRepository(repository) {
210
+ const [, name] = String(repository ?? "").split("/");
211
+ return join("reports", name || "analysis");
212
+ }
213
+
214
+ function formatInteractivePrompt(prompt) {
215
+ const suffix = prompt.defaultValue === undefined
216
+ ? ""
217
+ : Array.isArray(prompt.defaultValue)
218
+ ? (prompt.defaultValue.length ? ` [${prompt.defaultValue.join(",")}]` : "")
219
+ : ` [${prompt.defaultValue}]`;
220
+ if (prompt.type === "confirm") {
221
+ return `${prompt.message}${prompt.defaultValue ? " [Y/n]" : " [y/N]"} `;
222
+ }
223
+ if (prompt.type === "multi-select" && prompt.choices?.length) {
224
+ return `${prompt.message} (${prompt.choices.join(",")})${suffix}: `;
225
+ }
226
+ return `${prompt.message}${suffix}: `;
227
+ }
228
+
229
+ function createTerminalPromptAdapter({ input, output }) {
230
+ const readline = createInterface({ input, output, terminal: true });
231
+ return {
232
+ async ask(prompt) {
233
+ return readline.question(formatInteractivePrompt(prompt));
234
+ },
235
+ writeError(message) {
236
+ output.write(`${message}\n`);
237
+ },
238
+ close() {
239
+ readline.close();
240
+ },
241
+ };
242
+ }
243
+
244
+ async function callPromptAdapter(promptAdapter, prompt) {
245
+ if (typeof promptAdapter === "function") {
246
+ return promptAdapter(prompt);
247
+ }
248
+ return promptAdapter.ask(prompt);
249
+ }
250
+
251
+ async function askUntilValid(promptAdapter, prompt, { normalize, validate, output }) {
252
+ for (;;) {
253
+ const raw = await callPromptAdapter(promptAdapter, prompt);
254
+ try {
255
+ const value = normalize(raw, prompt);
256
+ await validate(value);
257
+ return value;
258
+ } catch (error) {
259
+ const message = error?.message ?? String(error);
260
+ if (typeof promptAdapter.writeError === "function") {
261
+ promptAdapter.writeError(message);
262
+ } else if (output?.write) {
263
+ output.write(`${message}\n`);
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ function normalizeTextAnswer(raw, prompt) {
270
+ const value = String(raw ?? "").trim();
271
+ if (value) return value;
272
+ if (prompt.defaultValue !== undefined) return prompt.defaultValue;
273
+ return value;
274
+ }
275
+
276
+ function normalizeIntegerAnswer(raw, prompt) {
277
+ const value = normalizeTextAnswer(raw, prompt);
278
+ return Number(value);
279
+ }
280
+
281
+ function normalizeConfirmAnswer(raw, prompt) {
282
+ const value = String(raw ?? "").trim().toLowerCase();
283
+ if (!value && prompt.defaultValue !== undefined) return Boolean(prompt.defaultValue);
284
+ if (["y", "yes", "true", "1"].includes(value)) return true;
285
+ if (["n", "no", "false", "0"].includes(value)) return false;
286
+ throw new Error("Answer yes or no.");
287
+ }
288
+
289
+ function normalizeMultiSelectAnswer(raw, prompt) {
290
+ const value = String(raw ?? "").trim();
291
+ if (!value) return prompt.defaultValue ?? [];
292
+ return normalizeExcludedPrClasses([value]);
293
+ }
294
+
295
+ async function promptProfilePath(promptAdapter, output, prompt) {
296
+ let profile = null;
297
+ const profilePath = await askUntilValid(promptAdapter, prompt, {
298
+ output,
299
+ normalize: normalizeTextAnswer,
300
+ async validate(value) {
301
+ profile = await readProfile(value);
302
+ },
303
+ });
304
+ return { profilePath, profile };
305
+ }
306
+
307
+ function hasOwnOption(options, key) {
308
+ return Object.prototype.hasOwnProperty.call(options, key);
309
+ }
310
+
311
+ function shouldPromptInteractiveOption(options, key) {
312
+ const promptDefaults = options.interactivePromptDefaults ?? {};
313
+ if (hasOwnOption(promptDefaults, key)) {
314
+ return Boolean(promptDefaults[key]);
315
+ }
316
+ return !hasOwnOption(options, key);
317
+ }
318
+
319
+ export async function collectInteractiveAnalyzeGithubOptions(options, {
320
+ promptAdapter = null,
321
+ input = process.stdin,
322
+ output = process.stderr,
323
+ isInteractiveTerminal = Boolean(input?.isTTY),
324
+ } = {}) {
325
+ if (!isInteractiveTerminal) {
326
+ 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.");
327
+ }
328
+
329
+ const adapter = promptAdapter ?? createTerminalPromptAdapter({ input, output });
330
+ const ownsAdapter = !promptAdapter;
331
+ const resolved = { ...options };
332
+ delete resolved.interactivePromptDefaults;
333
+ let repositoryProfile = null;
334
+
335
+ try {
336
+ if (!resolved.repository) {
337
+ resolved.repository = await askUntilValid(adapter, {
338
+ id: "repository",
339
+ type: "text",
340
+ message: "Target GitHub repository",
341
+ }, {
342
+ output,
343
+ normalize: normalizeTextAnswer,
344
+ validate: validateRepositorySlug,
345
+ });
346
+ }
347
+
348
+ if (resolved.limit === undefined) {
349
+ resolved.limit = await askUntilValid(adapter, {
350
+ id: "limit",
351
+ type: "integer",
352
+ message: "Latest merged pull request count",
353
+ defaultValue: 30,
354
+ }, {
355
+ output,
356
+ normalize: normalizeIntegerAnswer,
357
+ validate: validateLimit,
358
+ });
359
+ }
360
+
361
+ if (!resolved.profilePath) {
362
+ const prompted = await promptProfilePath(adapter, output, {
363
+ id: "profilePath",
364
+ type: "path",
365
+ message: "Repository profile path",
366
+ });
367
+ resolved.profilePath = prompted.profilePath;
368
+ repositoryProfile = prompted.profile;
369
+ } else {
370
+ repositoryProfile = await readProfile(resolved.profilePath);
371
+ }
372
+
373
+ if (!resolved.outDir) {
374
+ resolved.outDir = await askUntilValid(adapter, {
375
+ id: "outDir",
376
+ type: "path",
377
+ message: "Output directory",
378
+ defaultValue: defaultOutDirForRepository(resolved.repository),
379
+ }, {
380
+ output,
381
+ normalize: normalizeTextAnswer,
382
+ async validate(value) {
383
+ if (!value) throw new Error("Output directory is required.");
384
+ await validateOutputDirectory(value);
385
+ },
386
+ });
387
+ }
388
+
389
+ if (shouldPromptInteractiveOption(options, "dryRun")) {
390
+ resolved.dryRun = await askUntilValid(adapter, {
391
+ id: "dryRun",
392
+ type: "confirm",
393
+ message: "Run metadata-only dry run",
394
+ defaultValue: false,
395
+ }, {
396
+ output,
397
+ normalize: normalizeConfirmAnswer,
398
+ validate() {},
399
+ });
400
+ }
401
+ if (resolved.dryRun === undefined) resolved.dryRun = false;
402
+
403
+ if (!resolved.dryRun && shouldPromptInteractiveOption(options, "csv")) {
404
+ resolved.csv = await askUntilValid(adapter, {
405
+ id: "csv",
406
+ type: "confirm",
407
+ message: "Write CSV evidence files",
408
+ defaultValue: true,
409
+ }, {
410
+ output,
411
+ normalize: normalizeConfirmAnswer,
412
+ validate() {},
413
+ });
414
+ }
415
+ if (resolved.csv === undefined) resolved.csv = true;
416
+
417
+ if (shouldPromptInteractiveOption(options, "json")) {
418
+ resolved.json = await askUntilValid(adapter, {
419
+ id: "json",
420
+ type: "confirm",
421
+ message: "Print completion as JSON",
422
+ defaultValue: false,
423
+ }, {
424
+ output,
425
+ normalize: normalizeConfirmAnswer,
426
+ validate() {},
427
+ });
428
+ }
429
+ if (resolved.json === undefined) resolved.json = false;
430
+
431
+ if (!resolved.excludedPrClasses?.length) {
432
+ const availablePrClasses = configuredPrClassList(repositoryProfile);
433
+ if (availablePrClasses.length) {
434
+ resolved.excludedPrClasses = await askUntilValid(adapter, {
435
+ id: "excludedPrClasses",
436
+ type: "multi-select",
437
+ message: "Exclude PR classes (comma-separated, blank for none)",
438
+ choices: availablePrClasses,
439
+ defaultValue: [],
440
+ }, {
441
+ output,
442
+ normalize: normalizeMultiSelectAnswer,
443
+ validate(value) {
444
+ validateExcludedPrClasses(value);
445
+ validateExcludedPrClassesAreConfigured(value, repositoryProfile);
446
+ },
447
+ });
448
+ }
449
+ }
450
+
451
+ return resolved;
452
+ } finally {
453
+ if (ownsAdapter && typeof adapter.close === "function") {
454
+ adapter.close();
455
+ }
456
+ }
187
457
  }
188
458
 
189
459
  async function validateOutputDirectory(outDir) {
@@ -480,8 +750,8 @@ export async function runAnalyzeGithub(options, {
480
750
  });
481
751
  }
482
752
 
483
- function writeProgress(message) {
484
- process.stderr.write(`${message}\n`);
753
+ function writeProgress(message, stderr = process.stderr) {
754
+ stderr.write(`${message}\n`);
485
755
  }
486
756
 
487
757
  function coverageLine(family) {
@@ -568,15 +838,49 @@ export function writeAnalyzeGithubCompletion(result, { json = false, stdout = pr
568
838
  stdout.write(formatAnalyzeGithubCompletion(result));
569
839
  }
570
840
 
571
- async function main(argv) {
841
+ export async function runAnalyzeGithubCli(argv, {
842
+ provider,
843
+ now,
844
+ stdin = process.stdin,
845
+ stdout = process.stdout,
846
+ stderr = process.stderr,
847
+ promptAdapter = null,
848
+ isInteractiveTerminal = Boolean(stdin?.isTTY),
849
+ } = {}) {
572
850
  const options = parseAnalyzeGithubArgs(argv);
573
851
  if (options.help) {
574
- process.stdout.write(USAGE);
575
- return;
852
+ stdout.write(USAGE);
853
+ return null;
576
854
  }
577
855
 
578
- const result = await runAnalyzeGithub(options, { onProgress: writeProgress });
579
- writeAnalyzeGithubCompletion(result, { json: options.json });
856
+ const resolvedOptions = options.interactive
857
+ ? await collectInteractiveAnalyzeGithubOptions({
858
+ ...options,
859
+ interactivePromptDefaults: {
860
+ dryRun: !options.dryRun,
861
+ csv: options.csv !== false,
862
+ json: !options.json,
863
+ },
864
+ }, {
865
+ promptAdapter,
866
+ input: stdin,
867
+ output: stderr,
868
+ isInteractiveTerminal,
869
+ })
870
+ : options;
871
+ const runOptions = {
872
+ onProgress: message => writeProgress(message, stderr),
873
+ };
874
+ if (provider !== undefined) runOptions.provider = provider;
875
+ if (now !== undefined) runOptions.now = now;
876
+
877
+ const result = await runAnalyzeGithub(resolvedOptions, runOptions);
878
+ writeAnalyzeGithubCompletion(result, { json: resolvedOptions.json, stdout });
879
+ return result;
880
+ }
881
+
882
+ async function main(argv) {
883
+ await runAnalyzeGithubCli(argv);
580
884
  }
581
885
 
582
886
  function isCliEntrypoint(entryPath) {
@@ -1,6 +1,7 @@
1
1
  import { classifyCommentSource, groupByCommentSource } from "../github/comment-source.js";
2
2
  import { classifyFilePath } from "../profile/file-role.js";
3
3
  import { assertValidPrClassRules, classifyPullRequest } from "../profile/pr-class.js";
4
+ import { assertValidWorkflowContext } from "../profile/workflow.js";
4
5
 
5
6
  function minDate(values) {
6
7
  return values.filter(Boolean).sort()[0] ?? null;
@@ -112,6 +113,7 @@ function normalizeCommit(commit) {
112
113
  export function normalizeFixtureBundle(bundle, { repositoryProfile } = {}) {
113
114
  const profile = repositoryProfile ?? {};
114
115
  assertValidPrClassRules(profile);
116
+ assertValidWorkflowContext(profile);
115
117
 
116
118
  const pullRequests = (bundle.pullRequests ?? []).map(pr => {
117
119
  const reviewDates = (pr.reviews ?? []).map(review => review.submittedAt);
@@ -0,0 +1,75 @@
1
+ export const WORKFLOW_PRIMARY_MERGE_METHODS = Object.freeze([
2
+ "merge_commit",
3
+ "squash_merge",
4
+ "rebase_merge",
5
+ "mixed",
6
+ "unknown",
7
+ ]);
8
+
9
+ export const WORKFLOW_RELEASE_STRATEGIES = Object.freeze([
10
+ "release_prs",
11
+ "direct_tags",
12
+ "release_branches",
13
+ "mixed",
14
+ "unknown",
15
+ ]);
16
+
17
+ export const WORKFLOW_BRANCH_STRATEGIES = Object.freeze([
18
+ "trunk_based",
19
+ "main_plus_release_branches",
20
+ "long_lived_development_branches",
21
+ "mixed",
22
+ "unknown",
23
+ ]);
24
+
25
+ const WORKFLOW_FIELDS = Object.freeze({
26
+ primaryMergeMethod: WORKFLOW_PRIMARY_MERGE_METHODS,
27
+ releaseStrategy: WORKFLOW_RELEASE_STRATEGIES,
28
+ branchStrategy: WORKFLOW_BRANCH_STRATEGIES,
29
+ });
30
+
31
+ function allowedValues(values) {
32
+ return values.join(", ");
33
+ }
34
+
35
+ export function validateWorkflowContext(profile = {}) {
36
+ const errors = [];
37
+ if (!profile || typeof profile !== "object" || Array.isArray(profile)) {
38
+ return errors;
39
+ }
40
+
41
+ if (!Object.prototype.hasOwnProperty.call(profile, "workflow")) {
42
+ return errors;
43
+ }
44
+
45
+ const workflow = profile.workflow;
46
+ if (!workflow || typeof workflow !== "object" || Array.isArray(workflow)) {
47
+ return ["workflow must be an object when provided"];
48
+ }
49
+ if (Object.keys(workflow).length === 0) {
50
+ return ["workflow must include at least one field when provided"];
51
+ }
52
+
53
+ for (const key of Object.keys(workflow)) {
54
+ if (!Object.prototype.hasOwnProperty.call(WORKFLOW_FIELDS, key)) {
55
+ errors.push(`workflow.${key} is not supported`);
56
+ }
57
+ }
58
+
59
+ for (const [field, values] of Object.entries(WORKFLOW_FIELDS)) {
60
+ const value = workflow[field];
61
+ if (value === undefined) continue;
62
+ if (!values.includes(value)) {
63
+ errors.push(`workflow.${field} must be one of: ${allowedValues(values)}`);
64
+ }
65
+ }
66
+
67
+ return errors;
68
+ }
69
+
70
+ export function assertValidWorkflowContext(profile = {}) {
71
+ const errors = validateWorkflowContext(profile);
72
+ if (errors.length > 0) {
73
+ throw new Error(`invalid workflow profile context: ${errors.join("; ")}`);
74
+ }
75
+ }