delivery-friction-analyzer 0.14.3 → 0.15.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,6 +19,8 @@ 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`; the first validation target is `hannasdev/mcp-writing`.
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, tells you to choose the repository you want to measure with `--repo owner/name`, and confirms that no GitHub data was collected. The product repository identity is repo-local implementation configuration, not a public CLI option.
23
+
22
24
  ## Degraded Behavior
23
25
 
24
26
  If a target repository is private or the token lacks access, the analyzer should report coverage as unavailable or partial instead of silently emitting complete-looking metrics. Missing `defaultBranch` or malformed owner/name input is a hard contract error.
@@ -6,6 +6,8 @@ Schema: `schemas/repository-profile.schema.json`.
6
6
 
7
7
  Repository profiles own repository semantics. Keep file rules, PR class rules, workflow context, branch or release strategy, and contributor-source declarations here. Optional [run presets](run-presets.md) only store reusable run settings such as the target repository, profile path, sample size, output directory, dry-run mode, CSV preference, JSON completion preference, validation-target mode, and requested PR class exclusions. Explicit CLI flags override preset values.
8
8
 
9
+ Live analysis validates the selected repository profile before GitHub provider calls in normal runs, dry runs, and preset-loaded runs. Validation failures name the profile path, failing field or rule, problem, and next action. Edit the named field or rule in the existing profile; use interactive setup with `--interactive --dry-run` only when you want to create or regenerate a starter profile.
10
+
9
11
  ## Categories
10
12
 
11
13
  - `code`
@@ -37,6 +39,8 @@ Rules are evaluated in order and can match by `exact`, `prefix`, `suffix`, `incl
37
39
 
38
40
  This keeps validation-target details in profile data rather than hardcoded product assumptions.
39
41
 
42
+ Each file rule must include a unique lowercase `id`, a non-empty `match` object, a supported `category`, and a supported `role`. Optional `functionalSurface` values use the same lowercase identifier shape as rule IDs. Optional `generated` values must be booleans. Unsupported rule or matcher keys fail validation, and invalid JavaScript regexes fail before collection with the rule ID and matcher field in the error.
43
+
40
44
  ## Pull Request Classes
41
45
 
42
46
  `prClasses` is optional. Rules are evaluated in order and the first matching rule wins. The current profile contract supports title-only matchers:
@@ -4,6 +4,8 @@ Run presets are optional local JSON files for reusing CLI run settings. They are
4
4
 
5
5
  Repository meaning stays in repository profiles. Put file rules, PR class rules, workflow context, branch or release strategy, and contributor-source declarations in a repository profile. A run preset may only point at a profile and store run inputs or preferences such as the target repository, sample size, output directory, dry-run mode, CSV preference, JSON completion preference, validation-target mode, and requested PR class exclusions.
6
6
 
7
+ When a preset supplies `profilePath`, the CLI validates that referenced profile exactly as if it had been passed with `--profile`. Presets do not copy, freeze, or override profile rules; fixing profile validation failures means editing the referenced profile or choosing a different profile path.
8
+
7
9
  ## Save A Preset
8
10
 
9
11
  Interactive setup asks whether to save a local run preset near the end of the prompt flow. If you answer yes, you choose the preset path explicitly. The CLI does not invent a global or cloud-synced preset location.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "delivery-friction-analyzer",
3
- "version": "0.14.3",
3
+ "version": "0.15.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-21 — Runtime Contract Preflight
6
+
7
+ - What changed: GitHub analysis now rejects this tool's product repository before collection and validates repository profiles more completely before provider calls, including file-rule shape, duplicate IDs, unsupported keys, invalid values, and malformed regexes.
8
+ - Why it matters: Maintainers get clearer failures before rate-limited GitHub work begins, so invalid targets or profiles are fixed at the input boundary instead of producing misleading reports.
9
+ - Who is affected: Maintainers running live GitHub analysis, dry runs, or preset-based runs, and contributors authoring repository profiles.
10
+ - Action needed: If a run now fails preflight, choose the repository you want to measure with `--repo owner/name` or fix the named profile field or rule before rerunning.
11
+ - PR: #55
12
+
5
13
  ### 2026-06-21 — Repo-Specific Review Guidance
6
14
 
7
15
  - What changed: Added repo-local maintainer and agent guidance for recurring review themes, broad-change tripwires, and validation command selection, with self-profile coverage for the new guidance file.
@@ -14,8 +14,14 @@
14
14
  "additionalProperties": false,
15
15
  "required": ["owner", "name"],
16
16
  "properties": {
17
- "owner": { "type": "string" },
18
- "name": { "type": "string" }
17
+ "owner": {
18
+ "type": "string",
19
+ "pattern": "^[A-Za-z0-9_.-]+$"
20
+ },
21
+ "name": {
22
+ "type": "string",
23
+ "pattern": "^[A-Za-z0-9_.-]+$"
24
+ }
19
25
  }
20
26
  },
21
27
  "rules": {
@@ -25,16 +31,20 @@
25
31
  "additionalProperties": false,
26
32
  "required": ["id", "match", "category", "role"],
27
33
  "properties": {
28
- "id": { "type": "string" },
34
+ "id": {
35
+ "type": "string",
36
+ "minLength": 1,
37
+ "pattern": "^[a-z0-9]+(?:[-_][a-z0-9]+)*$"
38
+ },
29
39
  "match": {
30
40
  "type": "object",
31
41
  "additionalProperties": false,
32
42
  "properties": {
33
- "exact": { "type": "string" },
34
- "prefix": { "type": "string" },
35
- "suffix": { "type": "string" },
36
- "includes": { "type": "string" },
37
- "regex": { "type": "string" }
43
+ "exact": { "type": "string", "minLength": 1 },
44
+ "prefix": { "type": "string", "minLength": 1 },
45
+ "suffix": { "type": "string", "minLength": 1 },
46
+ "includes": { "type": "string", "minLength": 1 },
47
+ "regex": { "type": "string", "minLength": 1 }
38
48
  },
39
49
  "minProperties": 1
40
50
  },
@@ -57,7 +67,10 @@
57
67
  "unknown"
58
68
  ]
59
69
  },
60
- "functionalSurface": { "type": "string" },
70
+ "functionalSurface": {
71
+ "type": "string",
72
+ "pattern": "^[a-z0-9]+(?:[-_][a-z0-9]+)*$"
73
+ },
61
74
  "generated": { "type": "boolean" },
62
75
  "notes": { "type": "string" }
63
76
  }
@@ -16,15 +16,13 @@ import {
16
16
  generateRepositoryFrictionReport,
17
17
  renderRepositoryFrictionMarkdown,
18
18
  } from "../report/friction-report.js";
19
- import { assertValidPrClassRules } from "../profile/pr-class.js";
20
19
  import { conventionalCommitPrClassRules } from "../profile/pr-class-presets.js";
20
+ import { assertValidRepositoryProfile } from "../profile/repository-profile.js";
21
21
  import {
22
22
  WORKFLOW_BRANCH_STRATEGIES,
23
23
  WORKFLOW_PRIMARY_MERGE_METHODS,
24
24
  WORKFLOW_RELEASE_STRATEGIES,
25
- assertValidWorkflowContext,
26
25
  } from "../profile/workflow.js";
27
- import { assertValidContributorSource } from "../profile/contributor-source.js";
28
26
 
29
27
  const RUN_PRESET_SCHEMA_VERSION = "analyze-github-run-preset.v1";
30
28
 
@@ -568,9 +566,7 @@ function normalizeMultiSelectAnswer(raw, prompt) {
568
566
  }
569
567
 
570
568
  function validateProfile(profile) {
571
- assertValidPrClassRules(profile);
572
- assertValidWorkflowContext(profile);
573
- assertValidContributorSource(profile);
569
+ assertValidRepositoryProfile(profile);
574
570
  }
575
571
 
576
572
  function parseProfileJson(text) {
@@ -588,6 +584,14 @@ function hasTrailingPathSeparator(profilePath) {
588
584
  return /[/\\]$/.test(profilePath);
589
585
  }
590
586
 
587
+ function invalidProfileMessage(profilePath, error) {
588
+ return `Invalid repository profile at ${profilePath}: ${error.message}. Fix the named field or rule in this profile. If you want to create or regenerate a starter profile instead, rerun interactive setup with --interactive --dry-run.`;
589
+ }
590
+
591
+ function invalidProfileJsonMessage(profilePath, error) {
592
+ return `Invalid repository profile at ${profilePath}: ${error.message}. Fix the JSON syntax in this profile. If you want to create or regenerate a starter profile instead, rerun interactive setup with --interactive --dry-run.`;
593
+ }
594
+
591
595
  async function inspectProfilePath(profilePath) {
592
596
  if (hasTrailingPathSeparator(profilePath)) {
593
597
  throw new Error("profile path must be a JSON file path, not a directory or special file.");
@@ -618,10 +622,10 @@ async function readProfile(profilePath) {
618
622
  inspected = await inspectProfilePath(profilePath);
619
623
  } catch (error) {
620
624
  if (error.message?.startsWith("profile must be valid JSON")) {
621
- throw error;
625
+ throw new Error(invalidProfileJsonMessage(profilePath, error));
622
626
  }
623
627
  if (error.message?.startsWith("invalid ")) {
624
- throw new Error(`profile is invalid: ${error.message}`);
628
+ throw new Error(invalidProfileMessage(profilePath, error));
625
629
  }
626
630
  throw new Error(`profile could not be read: ${error.message}`);
627
631
  }
@@ -1046,10 +1050,10 @@ async function promptProfilePath(promptAdapter, output, prompt) {
1046
1050
  profileState = await inspectProfilePath(value);
1047
1051
  } catch (error) {
1048
1052
  if (error.message?.startsWith("profile must be valid JSON")) {
1049
- throw error;
1053
+ throw new Error(invalidProfileJsonMessage(value, error));
1050
1054
  }
1051
1055
  if (error.message?.startsWith("invalid ")) {
1052
- throw new Error(`profile is invalid: ${error.message}`);
1056
+ throw new Error(invalidProfileMessage(value, error));
1053
1057
  }
1054
1058
  throw new Error(`profile could not be read: ${error.message}`);
1055
1059
  }
@@ -1130,10 +1134,10 @@ export async function collectInteractiveAnalyzeGithubOptions(options, {
1130
1134
  };
1131
1135
  } catch (error) {
1132
1136
  if (error.message?.startsWith("profile must be valid JSON")) {
1133
- throw error;
1137
+ throw new Error(invalidProfileJsonMessage(resolved.profilePath, error));
1134
1138
  }
1135
1139
  if (error.message?.startsWith("invalid ")) {
1136
- throw new Error(`profile is invalid: ${error.message}`);
1140
+ throw new Error(invalidProfileMessage(resolved.profilePath, error));
1137
1141
  }
1138
1142
  throw new Error(`profile could not be read: ${error.message}`);
1139
1143
  }
@@ -1477,6 +1481,7 @@ export async function runAnalyzeGithub(options, {
1477
1481
  provider = createGhCliProvider(),
1478
1482
  now = () => new Date().toISOString(),
1479
1483
  onProgress = null,
1484
+ productRepository,
1480
1485
  } = {}) {
1481
1486
  requireOptions(options);
1482
1487
  validateRepositorySlug(options.repository);
@@ -1509,6 +1514,7 @@ export async function runAnalyzeGithub(options, {
1509
1514
  collectedAt: now(),
1510
1515
  isValidationTarget: options.isValidationTarget,
1511
1516
  contributors: repositoryProfile.contributors,
1517
+ productRepository,
1512
1518
  });
1513
1519
 
1514
1520
  if (options.dryRun) {
@@ -1,4 +1,9 @@
1
- import { validateTargetRepository } from "../contracts/target-repository.js";
1
+ import {
2
+ DEFAULT_PRODUCT_REPOSITORY,
3
+ isProductRepositoryTarget,
4
+ productRepositoryTargetError,
5
+ validateTargetRepository,
6
+ } from "../contracts/target-repository.js";
2
7
  import {
3
8
  assertValidContributorSource,
4
9
  normalizeContributorSourceConfig,
@@ -44,7 +49,7 @@ function visibilityOf(repository) {
44
49
  return "unknown";
45
50
  }
46
51
 
47
- function mapTargetRepository({ owner, name, repositoryMetadata, analysisPullRequestLimit, isValidationTarget }) {
52
+ function mapTargetRepository({ owner, name, repositoryMetadata, analysisPullRequestLimit, isValidationTarget, productRepository }) {
48
53
  const targetRepository = {
49
54
  owner,
50
55
  name,
@@ -53,7 +58,7 @@ function mapTargetRepository({ owner, name, repositoryMetadata, analysisPullRequ
53
58
  analysisPullRequestLimit,
54
59
  isValidationTarget,
55
60
  };
56
- const errors = validateTargetRepository(targetRepository);
61
+ const errors = validateTargetRepository(targetRepository, { productRepository });
57
62
  if (errors.length > 0) {
58
63
  throw new Error(`collected target repository metadata is invalid: ${errors.join(" ")}`);
59
64
  }
@@ -372,6 +377,7 @@ export async function collectGitHubSourceBundle({
372
377
  analysisPullRequestLimit,
373
378
  isValidationTarget = false,
374
379
  contributors = null,
380
+ productRepository = DEFAULT_PRODUCT_REPOSITORY,
375
381
  } = {}) {
376
382
  if (!provider) {
377
383
  throw new Error("provider is required.");
@@ -379,13 +385,16 @@ export async function collectGitHubSourceBundle({
379
385
  const targetPullRequestLimit = analysisPullRequestLimit ?? limit;
380
386
  requirePullRequestLimit(targetPullRequestLimit);
381
387
  const targetInput = repository ? parseRepositoryInput(repository) : { owner, name };
388
+ if (isProductRepositoryTarget(targetInput, productRepository)) {
389
+ throw new Error(productRepositoryTargetError(targetInput));
390
+ }
382
391
  const targetNameErrors = validateTargetRepository({
383
392
  ...targetInput,
384
393
  defaultBranch: "main",
385
394
  visibility: "unknown",
386
395
  analysisPullRequestLimit: targetPullRequestLimit,
387
396
  isValidationTarget,
388
- }).filter(error => !error.includes("defaultBranch") && !error.includes("visibility"));
397
+ }, { productRepository }).filter(error => !error.includes("defaultBranch") && !error.includes("visibility"));
389
398
  if (targetNameErrors.length > 0) {
390
399
  throw new Error(targetNameErrors.join(" "));
391
400
  }
@@ -396,6 +405,7 @@ export async function collectGitHubSourceBundle({
396
405
  repositoryMetadata,
397
406
  analysisPullRequestLimit: targetPullRequestLimit,
398
407
  isValidationTarget,
408
+ productRepository,
399
409
  });
400
410
  const repositoryCoverage = coverageEntry({
401
411
  family: "repository_metadata",
@@ -1,6 +1,11 @@
1
1
  const OWNER_OR_NAME = /^[A-Za-z0-9_.-]+$/;
2
2
  const REF_NAME = /^[A-Za-z0-9._/-]+$/;
3
3
 
4
+ export const DEFAULT_PRODUCT_REPOSITORY = Object.freeze({
5
+ owner: "hannasdev",
6
+ name: "delivery-friction-analyzer",
7
+ });
8
+
4
9
  function validateRepoPart(value, label) {
5
10
  if (typeof value !== "string" || !OWNER_OR_NAME.test(value)) {
6
11
  return `${label} must be a GitHub owner/name segment using letters, numbers, dots, underscores, or dashes.`;
@@ -73,3 +78,26 @@ export function normalizeTargetRepository(input, options = {}) {
73
78
  },
74
79
  };
75
80
  }
81
+
82
+ export function isProductRepositoryTarget(input, productRepository = DEFAULT_PRODUCT_REPOSITORY) {
83
+ const normalizedInputOwner = typeof input?.owner === "string" ? input.owner.toLowerCase() : null;
84
+ const normalizedInputName = typeof input?.name === "string" ? input.name.toLowerCase() : null;
85
+ const normalizedProductOwner = typeof productRepository?.owner === "string" ? productRepository.owner.toLowerCase() : null;
86
+ const normalizedProductName = typeof productRepository?.name === "string" ? productRepository.name.toLowerCase() : null;
87
+
88
+ return Boolean(
89
+ normalizedInputOwner
90
+ && normalizedInputName
91
+ && normalizedProductOwner
92
+ && normalizedProductName
93
+ && normalizedInputOwner === normalizedProductOwner
94
+ && normalizedInputName === normalizedProductName
95
+ );
96
+ }
97
+
98
+ export function productRepositoryTargetError(input) {
99
+ const repository = typeof input?.owner === "string" && typeof input?.name === "string"
100
+ ? `${input.owner}/${input.name}`
101
+ : "the requested repository";
102
+ return `Cannot analyze ${repository} because it is this tool's product repository, not the target repository to measure. Choose a different repository with --repo owner/name. No GitHub data was collected.`;
103
+ }
@@ -5,6 +5,8 @@ export const PR_CLASS_FALLBACK = Object.freeze({
5
5
  });
6
6
 
7
7
  const PR_CLASS_IDENTIFIER_PATTERN = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
8
+ const PR_CLASS_RULE_KEYS = new Set(["id", "class", "match", "notes"]);
9
+ const PR_CLASS_MATCH_KEYS = new Set(["titleIncludes", "titleRegex"]);
8
10
 
9
11
  function ruleLabel(rule, index) {
10
12
  return typeof rule?.id === "string" && rule.id.length ? rule.id : `index ${index}`;
@@ -35,6 +37,12 @@ export function validatePrClassRules(profile = {}) {
35
37
  continue;
36
38
  }
37
39
 
40
+ for (const key of Object.keys(rule)) {
41
+ if (!PR_CLASS_RULE_KEYS.has(key)) {
42
+ errors.push(`prClasses rule "${label}" ${key} is not supported`);
43
+ }
44
+ }
45
+
38
46
  if (typeof rule.id !== "string" || rule.id.length === 0) {
39
47
  errors.push(`prClasses[${index}].id must be a non-empty string`);
40
48
  } else if (seenRuleIds.has(rule.id)) {
@@ -46,6 +54,9 @@ export function validatePrClassRules(profile = {}) {
46
54
  if (typeof rule.class !== "string" || !PR_CLASS_IDENTIFIER_PATTERN.test(rule.class)) {
47
55
  errors.push(`prClasses rule "${label}" class must be lower-kebab-case or lower_snake_case`);
48
56
  }
57
+ if (rule.notes !== undefined && typeof rule.notes !== "string") {
58
+ errors.push(`prClasses rule "${label}" notes must be a string when provided`);
59
+ }
49
60
 
50
61
  const match = rule.match;
51
62
  if (!match || typeof match !== "object" || Array.isArray(match)) {
@@ -53,6 +64,21 @@ export function validatePrClassRules(profile = {}) {
53
64
  continue;
54
65
  }
55
66
 
67
+ for (const key of Object.keys(match)) {
68
+ if (!PR_CLASS_MATCH_KEYS.has(key)) {
69
+ errors.push(`prClasses rule "${label}" match.${key} is not supported`);
70
+ }
71
+ }
72
+
73
+ for (const field of PR_CLASS_MATCH_KEYS) {
74
+ if (
75
+ Object.prototype.hasOwnProperty.call(match, field)
76
+ && (typeof match[field] !== "string" || match[field].length === 0)
77
+ ) {
78
+ errors.push(`prClasses rule "${label}" match.${field} must be a non-empty string`);
79
+ }
80
+ }
81
+
56
82
  const hasTitleIncludes = typeof match.titleIncludes === "string" && match.titleIncludes.length > 0;
57
83
  const hasTitleRegex = typeof match.titleRegex === "string" && match.titleRegex.length > 0;
58
84
  if (!hasTitleIncludes && !hasTitleRegex) {
@@ -0,0 +1,173 @@
1
+ import { FILE_CATEGORIES, FILE_ROLES } from "./file-role.js";
2
+ import { validateContributorSource } from "./contributor-source.js";
3
+ import { validatePrClassRules } from "./pr-class.js";
4
+ import { validateWorkflowContext } from "./workflow.js";
5
+
6
+ const REPOSITORY_PROFILE_SCHEMA_VERSION = "repository-profile.v1";
7
+ const REPO_SEGMENT_PATTERN = /^[A-Za-z0-9_.-]+$/;
8
+ const IDENTIFIER_PATTERN = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
9
+ const TOP_LEVEL_KEYS = new Set([
10
+ "schemaVersion",
11
+ "repository",
12
+ "rules",
13
+ "prClasses",
14
+ "workflow",
15
+ "contributors",
16
+ ]);
17
+ const REPOSITORY_KEYS = new Set(["owner", "name"]);
18
+ const FILE_RULE_KEYS = new Set([
19
+ "id",
20
+ "match",
21
+ "category",
22
+ "role",
23
+ "functionalSurface",
24
+ "generated",
25
+ "notes",
26
+ ]);
27
+ const MATCH_KEYS = new Set(["exact", "prefix", "suffix", "includes", "regex"]);
28
+
29
+ function isPlainObject(value) {
30
+ return value && typeof value === "object" && !Array.isArray(value);
31
+ }
32
+
33
+ function unsupportedKeys(value, allowedKeys, path) {
34
+ return Object.keys(value)
35
+ .filter(key => !allowedKeys.has(key))
36
+ .map(key => `${path}.${key} is not supported`);
37
+ }
38
+
39
+ function ruleLabel(rule, index) {
40
+ return typeof rule?.id === "string" && rule.id.length ? rule.id : `index ${index}`;
41
+ }
42
+
43
+ function validateRepositoryIdentity(profile) {
44
+ const errors = [];
45
+ if (!isPlainObject(profile.repository)) {
46
+ return ["repository must be an object with owner and name"];
47
+ }
48
+
49
+ errors.push(...unsupportedKeys(profile.repository, REPOSITORY_KEYS, "repository"));
50
+ for (const field of ["owner", "name"]) {
51
+ const value = profile.repository[field];
52
+ if (typeof value !== "string" || !REPO_SEGMENT_PATTERN.test(value)) {
53
+ errors.push(`repository.${field} must be a GitHub owner/name segment using letters, numbers, dots, underscores, or dashes`);
54
+ }
55
+ }
56
+ return errors;
57
+ }
58
+
59
+ function validateFileRuleMatch(rule, index) {
60
+ const errors = [];
61
+ const label = ruleLabel(rule, index);
62
+ const match = rule.match;
63
+ if (!isPlainObject(match)) {
64
+ return [`rules[${index}] "${label}" match must be an object`];
65
+ }
66
+
67
+ errors.push(...unsupportedKeys(match, MATCH_KEYS, `rules[${index}] "${label}" match`));
68
+ const configuredMatchers = [...MATCH_KEYS]
69
+ .filter(key => Object.prototype.hasOwnProperty.call(match, key));
70
+ if (configuredMatchers.length === 0) {
71
+ errors.push(`rules[${index}] "${label}" match must include at least one matcher: exact, prefix, suffix, includes, or regex`);
72
+ }
73
+
74
+ for (const field of MATCH_KEYS) {
75
+ if (!Object.prototype.hasOwnProperty.call(match, field)) continue;
76
+ const value = match[field];
77
+ if (typeof value !== "string" || value.length === 0) {
78
+ errors.push(`rules[${index}] "${label}" match.${field} must be a non-empty string`);
79
+ continue;
80
+ }
81
+ if (field === "regex") {
82
+ try {
83
+ new RegExp(value);
84
+ } catch (error) {
85
+ errors.push(`rules[${index}] "${label}" match.regex is not a valid JavaScript regex: ${error.message}`);
86
+ }
87
+ }
88
+ }
89
+
90
+ return errors;
91
+ }
92
+
93
+ export function validateFileRoleRules(profile = {}) {
94
+ const errors = [];
95
+ if (!isPlainObject(profile)) return ["profile must be an object"];
96
+ if (!Object.prototype.hasOwnProperty.call(profile, "rules")) {
97
+ return ["rules is required"];
98
+ }
99
+
100
+ const rules = profile.rules;
101
+ if (!Array.isArray(rules)) {
102
+ return ["rules must be an array"];
103
+ }
104
+
105
+ const seenRuleIds = new Set();
106
+ for (const [index, rule] of rules.entries()) {
107
+ const label = ruleLabel(rule, index);
108
+ if (!isPlainObject(rule)) {
109
+ errors.push(`rules[${index}] must be an object`);
110
+ continue;
111
+ }
112
+
113
+ errors.push(...unsupportedKeys(rule, FILE_RULE_KEYS, `rules[${index}] "${label}"`));
114
+
115
+ if (typeof rule.id !== "string" || !IDENTIFIER_PATTERN.test(rule.id)) {
116
+ errors.push(`rules[${index}].id must be a non-empty lowercase identifier using letters, digits, "-" or "_" separators`);
117
+ } else if (seenRuleIds.has(rule.id)) {
118
+ errors.push(`rules rule id "${rule.id}" is duplicated`);
119
+ } else {
120
+ seenRuleIds.add(rule.id);
121
+ }
122
+
123
+ errors.push(...validateFileRuleMatch(rule, index));
124
+
125
+ if (!FILE_CATEGORIES.includes(rule.category)) {
126
+ errors.push(`rules[${index}] "${label}" category must be one of: ${FILE_CATEGORIES.join(", ")}`);
127
+ }
128
+ if (!FILE_ROLES.includes(rule.role)) {
129
+ errors.push(`rules[${index}] "${label}" role must be one of: ${FILE_ROLES.join(", ")}`);
130
+ }
131
+ if (
132
+ rule.functionalSurface !== undefined
133
+ && (typeof rule.functionalSurface !== "string" || !IDENTIFIER_PATTERN.test(rule.functionalSurface))
134
+ ) {
135
+ errors.push(`rules[${index}] "${label}" functionalSurface must be a lowercase identifier using letters, digits, "-" or "_" separators`);
136
+ }
137
+ if (rule.generated !== undefined && typeof rule.generated !== "boolean") {
138
+ errors.push(`rules[${index}] "${label}" generated must be a boolean when provided`);
139
+ }
140
+ if (rule.notes !== undefined && typeof rule.notes !== "string") {
141
+ errors.push(`rules[${index}] "${label}" notes must be a string when provided`);
142
+ }
143
+ }
144
+
145
+ return errors;
146
+ }
147
+
148
+ export function validateRepositoryProfile(profile = {}) {
149
+ const errors = [];
150
+ if (!isPlainObject(profile)) {
151
+ return ["profile must be an object"];
152
+ }
153
+
154
+ errors.push(...unsupportedKeys(profile, TOP_LEVEL_KEYS, "profile"));
155
+
156
+ if (profile.schemaVersion !== REPOSITORY_PROFILE_SCHEMA_VERSION) {
157
+ errors.push(`schemaVersion must be ${REPOSITORY_PROFILE_SCHEMA_VERSION}`);
158
+ }
159
+ errors.push(...validateRepositoryIdentity(profile));
160
+ errors.push(...validateFileRoleRules(profile));
161
+ errors.push(...validatePrClassRules(profile));
162
+ errors.push(...validateWorkflowContext(profile));
163
+ errors.push(...validateContributorSource(profile));
164
+
165
+ return errors;
166
+ }
167
+
168
+ export function assertValidRepositoryProfile(profile = {}) {
169
+ const errors = validateRepositoryProfile(profile);
170
+ if (errors.length > 0) {
171
+ throw new Error(`invalid repository profile: ${errors.join("; ")}`);
172
+ }
173
+ }