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.
- package/docs/contracts/target-repository.md +2 -0
- package/docs/reference/repository-profile.md +4 -0
- package/docs/reference/run-presets.md +2 -0
- package/package.json +1 -1
- package/release-log.md +8 -0
- package/schemas/repository-profile.schema.json +22 -9
- package/src/cli/analyze-github.js +18 -12
- package/src/collect/github-source-bundle.js +14 -4
- package/src/contracts/target-repository.js +28 -0
- package/src/profile/pr-class.js +26 -0
- package/src/profile/repository-profile.js +173 -0
|
@@ -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
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": {
|
|
18
|
-
|
|
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": {
|
|
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": {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
+
}
|
package/src/profile/pr-class.js
CHANGED
|
@@ -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
|
+
}
|