@elalfner/relevo 0.0.0-stage → 0.3.1

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.
Files changed (142) hide show
  1. package/.claude-plugin/plugin.json +19 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +51 -0
  4. package/README.es.md +90 -0
  5. package/README.md +89 -2
  6. package/agents/dev.md +101 -0
  7. package/agents/qa.md +112 -0
  8. package/package.json +42 -4
  9. package/scripts/add-corrections.mjs +21 -0
  10. package/scripts/app-id.mjs +19 -0
  11. package/scripts/archive-state.mjs +31 -0
  12. package/scripts/branch-state.mjs +31 -0
  13. package/scripts/branch-work.mjs +25 -0
  14. package/scripts/can-edit-issue.mjs +45 -0
  15. package/scripts/capture.mjs +22 -0
  16. package/scripts/check-archive.mjs +19 -0
  17. package/scripts/check-issue.mjs +41 -0
  18. package/scripts/checklist.mjs +29 -0
  19. package/scripts/cli.mjs +28 -0
  20. package/scripts/commit-change.mjs +50 -0
  21. package/scripts/conflict-scope.mjs +19 -0
  22. package/scripts/design-fetch.mjs +103 -0
  23. package/scripts/device.mjs +12 -0
  24. package/scripts/digest.mjs +50 -0
  25. package/scripts/doctor.mjs +47 -0
  26. package/scripts/failure-comment.mjs +30 -0
  27. package/scripts/fix-guard.mjs +38 -0
  28. package/scripts/fix-stash.mjs +57 -0
  29. package/scripts/fix-state.mjs +28 -0
  30. package/scripts/fix-tasks.mjs +22 -0
  31. package/scripts/go-local.mjs +49 -0
  32. package/scripts/go-plan.mjs +82 -0
  33. package/scripts/init.mjs +109 -0
  34. package/scripts/intent-to-add.mjs +20 -0
  35. package/scripts/issue-coverage.mjs +30 -0
  36. package/scripts/issue-hash.mjs +44 -0
  37. package/scripts/issue-sections.mjs +42 -0
  38. package/scripts/label-change.mjs +35 -0
  39. package/scripts/lib/appid.mjs +12 -0
  40. package/scripts/lib/archive-check.mjs +29 -0
  41. package/scripts/lib/archive-state.mjs +15 -0
  42. package/scripts/lib/branch-work.mjs +13 -0
  43. package/scripts/lib/branch.mjs +43 -0
  44. package/scripts/lib/can-edit-issue.mjs +34 -0
  45. package/scripts/lib/checklist.mjs +68 -0
  46. package/scripts/lib/cli.mjs +82 -0
  47. package/scripts/lib/commit-change.mjs +57 -0
  48. package/scripts/lib/config.mjs +296 -0
  49. package/scripts/lib/design-fetch.mjs +156 -0
  50. package/scripts/lib/device.mjs +67 -0
  51. package/scripts/lib/doctor.mjs +636 -0
  52. package/scripts/lib/execution-mode.mjs +24 -0
  53. package/scripts/lib/failure-comment.mjs +19 -0
  54. package/scripts/lib/fix-guard.mjs +23 -0
  55. package/scripts/lib/fix-stash.mjs +17 -0
  56. package/scripts/lib/fix.mjs +31 -0
  57. package/scripts/lib/github.mjs +161 -0
  58. package/scripts/lib/go-local.mjs +102 -0
  59. package/scripts/lib/init.mjs +328 -0
  60. package/scripts/lib/intent-to-add.mjs +32 -0
  61. package/scripts/lib/issue-check.mjs +63 -0
  62. package/scripts/lib/issue-coverage.mjs +47 -0
  63. package/scripts/lib/issue-hash.mjs +25 -0
  64. package/scripts/lib/issue-parse.mjs +194 -0
  65. package/scripts/lib/issue-sections.mjs +43 -0
  66. package/scripts/lib/labels.mjs +65 -0
  67. package/scripts/lib/language.mjs +9 -0
  68. package/scripts/lib/logaudit.mjs +123 -0
  69. package/scripts/lib/messages.mjs +341 -0
  70. package/scripts/lib/next-step.mjs +153 -0
  71. package/scripts/lib/openspec-commands.mjs +44 -0
  72. package/scripts/lib/openspec-status.mjs +16 -0
  73. package/scripts/lib/plan.mjs +256 -0
  74. package/scripts/lib/platform-of.mjs +32 -0
  75. package/scripts/lib/platform-scope.mjs +32 -0
  76. package/scripts/lib/platforms.mjs +90 -0
  77. package/scripts/lib/preflight.mjs +71 -0
  78. package/scripts/lib/protected-paths.mjs +102 -0
  79. package/scripts/lib/requeue.mjs +23 -0
  80. package/scripts/lib/review-adapters.mjs +37 -0
  81. package/scripts/lib/review-brief.mjs +43 -0
  82. package/scripts/lib/review-events.mjs +56 -0
  83. package/scripts/lib/review-state.mjs +157 -0
  84. package/scripts/lib/reviewers.mjs +39 -0
  85. package/scripts/lib/role-skills.mjs +112 -0
  86. package/scripts/lib/run-flags.mjs +16 -0
  87. package/scripts/lib/run-usage.mjs +65 -0
  88. package/scripts/lib/scan.mjs +73 -0
  89. package/scripts/lib/ship.mjs +161 -0
  90. package/scripts/lib/sla.mjs +90 -0
  91. package/scripts/lib/stack-profile.mjs +169 -0
  92. package/scripts/lib/staging.mjs +21 -0
  93. package/scripts/lib/status-data.mjs +109 -0
  94. package/scripts/lib/status.mjs +199 -0
  95. package/scripts/lib/tasks-file.mjs +81 -0
  96. package/scripts/lib/web-review.mjs +98 -0
  97. package/scripts/lib/wip-commit.mjs +24 -0
  98. package/scripts/lib/yaml-lite.mjs +157 -0
  99. package/scripts/needs-reviewer.mjs +41 -0
  100. package/scripts/next-step.mjs +39 -0
  101. package/scripts/pick-reviewer.mjs +42 -0
  102. package/scripts/platform-of.mjs +49 -0
  103. package/scripts/preflight.mjs +32 -0
  104. package/scripts/render-message.mjs +35 -0
  105. package/scripts/requeue.mjs +53 -0
  106. package/scripts/review-adapter.mjs +32 -0
  107. package/scripts/review-brief.mjs +29 -0
  108. package/scripts/review-event.mjs +70 -0
  109. package/scripts/review-sla.mjs +41 -0
  110. package/scripts/review-state.mjs +171 -0
  111. package/scripts/review-who.mjs +21 -0
  112. package/scripts/role-skills.mjs +53 -0
  113. package/scripts/run-usage.mjs +22 -0
  114. package/scripts/scan-diff.mjs +16 -0
  115. package/scripts/ship-check.mjs +54 -0
  116. package/scripts/spec-ready.mjs +40 -0
  117. package/scripts/stack-profile.mjs +67 -0
  118. package/scripts/stage.mjs +14 -0
  119. package/scripts/status.mjs +47 -0
  120. package/scripts/usage-limit.mjs +17 -0
  121. package/scripts/validate-config.mjs +44 -0
  122. package/scripts/web-review.mjs +134 -0
  123. package/skills/doctor/SKILL.md +28 -0
  124. package/skills/fix/SKILL.md +129 -0
  125. package/skills/go/SKILL.md +250 -0
  126. package/skills/init/SKILL.md +72 -0
  127. package/skills/issue/SKILL.md +128 -0
  128. package/skills/issue/references/update.md +15 -0
  129. package/skills/review/SKILL.md +157 -0
  130. package/skills/ship/SKILL.md +128 -0
  131. package/skills/status/SKILL.md +27 -0
  132. package/skills/think/SKILL.md +76 -0
  133. package/stacks/flutter.md +63 -0
  134. package/stacks/laravel.md +65 -0
  135. package/stacks/vue.md +70 -0
  136. package/templates/issue.md +99 -0
  137. package/templates/relevo.yml +59 -0
  138. package/templates/workflows/relevo-digest.yml +54 -0
  139. package/templates/workflows/relevo-go.yml +268 -0
  140. package/templates/workflows/relevo-queue.yml +72 -0
  141. package/templates/workflows/relevo-review.yml +60 -0
  142. package/templates/workflows/relevo-sla.yml +53 -0
@@ -0,0 +1,34 @@
1
+ // Decides whether the description of an issue may be rewritten.
2
+ // Implements "Only an issue Relevo has not committed to a build may be edited" of openspec/specs/issue/spec.md.
3
+ import { LABELS, resolveLabelNames } from './labels.mjs';
4
+
5
+ // Why each lifecycle label closes the issue to editing. `needs-spec` is the only one that leaves it open.
6
+ const REASON = {
7
+ ready: 'go starts it by itself, so the new text would be built without anyone rereading it',
8
+ queued: 'go starts it by itself when there is capacity',
9
+ blocked: 'go starts it by itself when its dependency merges',
10
+ building: 'the agent is building from the current text; wait for it to finish or fail',
11
+ built: 'a branch already exists and the text would no longer match what was built',
12
+ 'in-review': 'it is being reviewed against the current text',
13
+ 'pr-open': 'a pull request already exists for the current text',
14
+ 'qa-failed': 'a branch already exists and the text would no longer match what was built',
15
+ 'changes-requested': 'a branch already exists and the text would no longer match what was built',
16
+ 'needs-reviewer': 'it is built and waiting for a reviewer',
17
+ };
18
+ export const EDITABLE_LABELS = ['needs-spec'];
19
+
20
+ /**
21
+ * @param {{state?: string, labels?: Array<string|{name: string}>}} issue `gh issue view --json state,labels`
22
+ * @param {Record<string,string>} [overrides] the `labels` setting of relevo.yml
23
+ * @returns {{editable: true} | {editable: false, label?: string, reason: string}}
24
+ */
25
+ export function canEditIssue(issue, overrides = {}) {
26
+ if (String(issue.state ?? '').toUpperCase() !== 'OPEN') return { editable: false, reason: 'the issue is closed' };
27
+ const names = resolveLabelNames(overrides);
28
+ const have = new Set((issue.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)));
29
+ for (const { key } of LABELS) {
30
+ if (EDITABLE_LABELS.includes(key) || !have.has(names[key])) continue;
31
+ return { editable: false, label: names[key], reason: REASON[key] };
32
+ }
33
+ return { editable: true };
34
+ }
@@ -0,0 +1,68 @@
1
+ // Builds the review checklist from the scenarios of a change specification.
2
+ // One checklist line per `#### Scenario:` found under `<change>/specs/**/spec.md`.
3
+
4
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ const SCENARIO_RE = /^####\s+Scenario:\s*(.+?)\s*$/;
8
+ const STEP_RE = /^\s*[-*]\s+\*\*(AND WHEN|WHEN|THEN|AND THEN|AND)\*\*\s*(.*?)\s*$/;
9
+
10
+ /** Parse scenarios out of the text of one spec file. */
11
+ export function scenariosFromSpec(text, source = '') {
12
+ const scenarios = [];
13
+ let current = null;
14
+ String(text).split('\n').forEach((line) => {
15
+ const heading = line.match(/^#{1,6}\s/);
16
+ const scenario = line.match(SCENARIO_RE);
17
+ if (scenario) {
18
+ current = { title: scenario[1], when: [], then: [], source };
19
+ scenarios.push(current);
20
+ return;
21
+ }
22
+ if (heading) {
23
+ current = null;
24
+ return;
25
+ }
26
+ const step = current && line.match(STEP_RE);
27
+ if (!step) return;
28
+ const [, kind, rest] = step;
29
+ if (kind.endsWith('WHEN') || (kind === 'AND' && current.then.length === 0)) current.when.push(rest);
30
+ else current.then.push(rest);
31
+ });
32
+ return scenarios;
33
+ }
34
+
35
+ function specFiles(dir) {
36
+ const out = [];
37
+ (function walk(d) {
38
+ readdirSync(d).sort().forEach((name) => {
39
+ const path = join(d, name);
40
+ if (statSync(path).isDirectory()) walk(path);
41
+ else if (name === 'spec.md') out.push(path);
42
+ });
43
+ }(dir));
44
+ return out;
45
+ }
46
+
47
+ /** All scenarios of a change directory, in file order. Returns [{ id, title, when, then, source }]. */
48
+ export function checklistFromChange(changeDir) {
49
+ const specsDir = join(changeDir, 'specs');
50
+ const items = [];
51
+ specFiles(specsDir).forEach((file) => {
52
+ scenariosFromSpec(readFileSync(file, 'utf8'), file.slice(changeDir.length).replace(/^\/+/, '')).forEach((s) => items.push(s));
53
+ });
54
+ return items.map((s, i) => ({ id: i + 1, ...s }));
55
+ }
56
+
57
+ /** Markdown checklist, optionally with the items already confirmed ticked. */
58
+ export function checklistMarkdown(items, statuses = {}, autos = {}) {
59
+ return items
60
+ .map((s) => {
61
+ const mark = statuses[s.id] === 'ok' ? 'x' : ' ';
62
+ const detail = [s.when.join(' and '), s.then.join(' and ')].filter(Boolean);
63
+ const text = detail.length === 2 ? `when ${detail[0]}, then ${detail[1]}` : detail.join('');
64
+ const auto = autos[s.id] ? ` _(verified automatically: ${autos[s.id]})_` : '';
65
+ return `- [${mark}] ${s.id}. **${s.title}**: ${text}${auto}`;
66
+ })
67
+ .join('\n');
68
+ }
@@ -0,0 +1,82 @@
1
+ // The commands behind `relevo`. They only drive `claude plugin`, so Claude Code stays the one place that installs
2
+ // and updates a plugin; nothing here writes a file. `run` and `has` are injected so a test can use a fake `claude`.
3
+
4
+ export const MARKETPLACE = 'elalfner/relevo';
5
+ export const MARKETPLACE_NAME = 'relevo';
6
+ export const PLUGIN_ID = 'relevo@relevo';
7
+ export const SCOPES = ['user', 'project', 'local'];
8
+
9
+ export const USAGE = [
10
+ 'usage: relevo <command> [--scope user|project|local]',
11
+ '',
12
+ ' install add the marketplace and install the plugin (default scope: user)',
13
+ ' update update the marketplace and the plugin',
14
+ ' doctor check this repository (the same as /relevo:doctor)',
15
+ ' version print the version of this package',
16
+ '',
17
+ 'user: every project of this machine. project: this repository, shared through .claude/settings.json. local: this repository, only you.'
18
+ ].join('\n');
19
+
20
+ /** Parses the arguments; { error } when they are not valid. */
21
+ export function parseArgs(argv) {
22
+ const [command, ...rest] = argv;
23
+ if (!command || command === '--help' || command === '-h' || command === 'help') return { command: 'help' };
24
+ if (!['install', 'update', 'doctor', 'version'].includes(command)) return { error: `unknown command "${command}"` };
25
+ let scope = 'user';
26
+ for (let i = 0; i < rest.length; i += 1) {
27
+ const arg = rest[i];
28
+ if (arg === '--scope') { scope = rest[i + 1]; i += 1; } else if (arg.startsWith('--scope=')) scope = arg.slice(8);
29
+ else return { error: `unknown option "${arg}"` };
30
+ }
31
+ if (!SCOPES.includes(scope)) return { error: `the scope must be one of ${SCOPES.join(', ')}` };
32
+ return { command, scope };
33
+ }
34
+
35
+ /** The `claude` invocations of a command, in order. */
36
+ export function steps(command, scope) {
37
+ if (command === 'install') {
38
+ return [
39
+ { label: 'add the marketplace (only .claude-plugin/ is downloaded)', args: ['plugin', 'marketplace', 'add', MARKETPLACE, '--scope', scope, '--sparse', '.claude-plugin'] },
40
+ { label: 'install the plugin', args: ['plugin', 'install', PLUGIN_ID, '--scope', scope] }
41
+ ];
42
+ }
43
+ if (command === 'update') {
44
+ return [
45
+ { label: 'update the marketplace', args: ['plugin', 'marketplace', 'update', MARKETPLACE_NAME] },
46
+ { label: 'update the plugin', args: ['plugin', 'update', PLUGIN_ID, '--scope', scope] }
47
+ ];
48
+ }
49
+ return [];
50
+ }
51
+
52
+ /**
53
+ * Runs a command. Returns { code, lines }: the lines to print and the exit code. Stops at the first failing step
54
+ * and says which one, so the person knows what was already done.
55
+ */
56
+ export function execute({ command, scope }, { run, has, version, doctorScript }) {
57
+ const lines = [];
58
+ if (command === 'version') return { code: 0, lines: [version] };
59
+ if (command === 'doctor') {
60
+ const r = run(process.execPath, [doctorScript]);
61
+ return { code: r.code, lines: [r.output.trimEnd()] };
62
+ }
63
+ if (!has('claude')) {
64
+ return { code: 1, lines: ['Claude Code is required and "claude" was not found on the path.', 'Install it (https://code.claude.com/docs) and run this command again; nothing was changed.'] };
65
+ }
66
+ const list = steps(command, scope);
67
+ for (let i = 0; i < list.length; i += 1) {
68
+ const step = list[i];
69
+ lines.push(`[${i + 1}/${list.length}] ${step.label}`);
70
+ const r = run('claude', step.args);
71
+ if (r.output.trim()) lines.push(r.output.trimEnd());
72
+ if (r.code !== 0) {
73
+ lines.push(`The step "${step.label}" failed${i > 0 ? `; the steps before it were done (${list.slice(0, i).map((s) => s.label).join(', ')})` : ''}. Fix the cause and run the command again: it is safe to repeat.`);
74
+ return { code: r.code || 1, lines };
75
+ }
76
+ }
77
+ const where = { user: 'for this machine', project: 'for this repository (declared in .claude/settings.json, so commit it to share it)', local: 'for this repository, only for you' }[scope];
78
+ lines.push(command === 'install'
79
+ ? `Relevo is installed ${where}. Restart Claude Code and run /relevo:doctor.`
80
+ : 'Relevo is up to date. Restart Claude Code to apply it.');
81
+ return { code: 0, lines };
82
+ }
@@ -0,0 +1,57 @@
1
+ // One safe way to commit: name the files, check protected paths, scan the diff, and only then commit.
2
+ // Implements "Safe commits" of the go specification and "Protected paths" of the security specification.
3
+ //
4
+ // `git(args) -> stdout` is injected so the logic can be tested; the command-line wrapper runs the real git.
5
+
6
+ import { checkScope } from './platform-scope.mjs';
7
+ import { checkProtected, formatViolations } from './protected-paths.mjs';
8
+ import { formatFindings, scanDiff } from './scan.mjs';
9
+ import { explicitStageArgs } from './staging.mjs';
10
+
11
+ /**
12
+ * Stage `paths`, check them and commit only them.
13
+ * message the commit message
14
+ * paths the files or folders to commit, named one by one (never "." or "-A")
15
+ * allow globs from the `Allow-paths:` line of the issue (dependency files only)
16
+ * rulesFile the project rules file (`qa.rules_from`), which is never changed
17
+ * within the folder of the platform of the issue: files outside it are refused (`.` or null: no limit)
18
+ * change the change id, whose specification folder may always be committed
19
+ * Returns { ok, commit, findings, violations }. When something is found nothing is committed and the named paths
20
+ * are unstaged again (files that were staged for other reasons stay staged).
21
+ */
22
+ export function commitChange({ git, message, paths, allow = [], rulesFile = null, within = null, change = null }) {
23
+ if (!String(message ?? '').trim()) throw new Error('give a commit message');
24
+ const addArgs = explicitStageArgs(paths);
25
+
26
+ // A path that is protected is refused before anything is staged.
27
+ const named = checkProtected(paths, { allow, rulesFile });
28
+ if (named.violations.some((v) => v.tier === 'hard')) return { ok: false, commit: null, findings: [], violations: named.violations };
29
+
30
+ git(addArgs);
31
+ const unstage = () => git(['reset', '-q', '--', ...paths]);
32
+
33
+ // The folders named above may hold protected files: check what is really staged under them.
34
+ const staged = git(['diff', '--cached', '--name-only', '--', ...paths]).split('\n').filter(Boolean);
35
+ const violations = [
36
+ ...checkProtected(staged, { allow, rulesFile }).violations,
37
+ ...checkScope(staged, { within, change, allow }).violations,
38
+ ];
39
+ const findings = scanDiff(git(['diff', '--cached', '-U0', '--', ...paths]));
40
+ if (violations.length > 0 || findings.length > 0) {
41
+ unstage();
42
+ return { ok: false, commit: null, findings, violations };
43
+ }
44
+ if (staged.length === 0) throw new Error('nothing to commit: none of the named paths has changes');
45
+
46
+ // `--` with paths commits only those paths, so files staged for another reason are left alone.
47
+ git(['commit', '-q', '-m', message, '--', ...paths]);
48
+ return { ok: true, commit: git(['rev-parse', '--short', 'HEAD']).trim(), findings: [], violations: [] };
49
+ }
50
+
51
+ /** The lines to print when a commit was refused. Never contains a secret. */
52
+ export function refusalText({ findings, violations }) {
53
+ const lines = [];
54
+ if (violations.length > 0) lines.push(formatViolations(violations));
55
+ if (findings.length > 0) lines.push(formatFindings(findings));
56
+ return lines.join('\n');
57
+ }
@@ -0,0 +1,296 @@
1
+ // Schema, defaults and validation for relevo.yml.
2
+ // Implements openspec/specs/configuration/spec.md.
3
+
4
+ import { parse, YamlLiteError } from './yaml-lite.mjs';
5
+ import { LABEL_KEYS } from './labels.mjs';
6
+ import { SUPPORTED_LANGUAGES } from './messages.mjs';
7
+ import { findCredentials } from './scan.mjs';
8
+ import { normalizePlatforms } from './platforms.mjs';
9
+
10
+ // One entry per supported setting. `required: true` means there is no default.
11
+ export const SETTINGS = [
12
+ { path: 'branches.base', type: 'string', default: 'dev' },
13
+ { path: 'branches.release', type: 'string', default: 'main' },
14
+ { path: 'tracker', type: 'enum', allowed: ['github'], default: 'github' },
15
+ { path: 'git_provider', type: 'enum', allowed: ['github'], default: 'github' },
16
+ { path: 'team.members', type: 'string[]', default: [] },
17
+ { path: 'team.spec_writer', type: 'string', default: null },
18
+ { path: 'team.approvers', type: 'string[]', default: [] },
19
+ { path: 'review.mode', type: 'enum', allowed: ['none', 'peer', 'roles'], default: 'peer' },
20
+ { path: 'review.merger', type: 'enum', allowed: ['author', 'approver'], default: 'approver' },
21
+ { path: 'review.autopilot', type: 'enum', allowed: ['off', 'precheck', 'verify'], default: 'off' },
22
+ { path: 'review.allowed_origins', type: 'origin-list', default: [] },
23
+ { path: 'limits.built_unreviewed_per_person', type: 'int', min: 1, default: 2 },
24
+ { path: 'limits.concurrent_agents', type: 'int', min: 1, default: 3 },
25
+ { path: 'limits.review_sla_hours', type: 'int', min: 1, default: 8 },
26
+ { path: 'limits.run_max_turns', type: 'int', min: 1, default: 100 },
27
+ { path: 'limits.building_alert_hours', type: 'int', min: 1, default: 3 },
28
+ { path: 'limits.run_max_cost', type: 'number', exclusiveMin: 0, required: true },
29
+ { path: 'qa.retries', type: 'int', min: 0, default: 1 },
30
+ { path: 'qa.rules_from', type: 'path-list', default: [] },
31
+ { path: 'skills.allow_scripts', type: 'string[]', default: [] },
32
+ { path: 'links', type: 'repo-list', default: [] },
33
+ { path: 'evidence.policy', type: 'enum', allowed: ['attach_to_pr', 'local_only'], default: 'attach_to_pr' },
34
+ { path: 'digest.issue', type: 'int', min: 1, default: null },
35
+ { path: 'language', type: 'string', default: 'en' },
36
+ { path: 'design.images', type: 'bool', default: false },
37
+ { path: 'trust', type: 'enum', allowed: ['private-team', 'public'], default: 'private-team' },
38
+ ];
39
+
40
+ const GROUPS = new Set(SETTINGS.filter((s) => s.path.includes('.')).map((s) => s.path.split('.')[0]));
41
+ const TOP_SCALARS = new Set(SETTINGS.filter((s) => !s.path.includes('.')).map((s) => s.path));
42
+ const KNOWN_TOP = new Set([...GROUPS, ...TOP_SCALARS, 'labels', 'platform', 'platforms']);
43
+ const ORIGIN_RE = /^https?:\/\/[A-Za-z0-9.-]+(?::\d{1,5})?$/;
44
+ const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
45
+ const LANGUAGE_RE = /^[a-z]{2}(-[A-Z]{2})?$/;
46
+ const GITHUB_LABEL_MAX = 50;
47
+
48
+ function getPath(obj, path) {
49
+ return path.split('.').reduce((acc, key) => (acc && typeof acc === 'object' ? acc[key] : undefined), obj);
50
+ }
51
+
52
+ function setPath(obj, path, value) {
53
+ const keys = path.split('.');
54
+ let cursor = obj;
55
+ keys.slice(0, -1).forEach((key) => {
56
+ if (typeof cursor[key] !== 'object' || cursor[key] === null) cursor[key] = {};
57
+ cursor = cursor[key];
58
+ });
59
+ cursor[keys[keys.length - 1]] = value;
60
+ }
61
+
62
+ function describeAllowed(allowed) {
63
+ return allowed.map((v) => `"${v}"`).join(', ');
64
+ }
65
+
66
+ function checkType(setting, value, errors) {
67
+ const { path } = setting;
68
+ switch (setting.type) {
69
+ case 'string':
70
+ if (typeof value !== 'string' || value.trim() === '') {
71
+ errors.push({ path, message: `${path} must be a non-empty string (got ${JSON.stringify(value)})` });
72
+ }
73
+ break;
74
+ case 'string[]':
75
+ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string' || v.trim() === '')) {
76
+ errors.push({ path, message: `${path} must be a list of non-empty strings (got ${JSON.stringify(value)})` });
77
+ }
78
+ break;
79
+ case 'path-list':
80
+ if (!Array.isArray(value) || value.length === 0 || value.some((v) => typeof v !== 'string' || v.trim() === '')) {
81
+ errors.push({ path, message: `${path} must be a file name or a list of file names (got ${JSON.stringify(value)})` });
82
+ }
83
+ break;
84
+ case 'repo-list': {
85
+ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
86
+ errors.push({ path, message: `${path} must be a list of repositories written owner/repo (got ${JSON.stringify(value)})` });
87
+ break;
88
+ }
89
+ value.filter((v) => !REPO_RE.test(v)).forEach((v) => errors.push({ path, message: `${path}: "${v}" is not a repository; the form is owner/repo` }));
90
+ value.filter((v, i) => value.indexOf(v) !== i).forEach((v) => errors.push({ path, message: `${path} lists "${v}" more than once` }));
91
+ break;
92
+ }
93
+ case 'origin-list': {
94
+ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
95
+ errors.push({ path, message: `${path} must be a list of web origins written like https://staging.example.com (got ${JSON.stringify(value)})` });
96
+ break;
97
+ }
98
+ value.filter((v) => !ORIGIN_RE.test(v)).forEach((v) => errors.push({ path, message: `${path}: "${v}" is not an origin; write the scheme, the host and optionally the port, with no path (https://staging.example.com)` }));
99
+ break;
100
+ }
101
+ case 'enum':
102
+ if (!setting.allowed.includes(value)) {
103
+ errors.push({
104
+ path,
105
+ message: `${path} has invalid value ${JSON.stringify(value)}; allowed values: ${describeAllowed(setting.allowed)}`,
106
+ });
107
+ }
108
+ break;
109
+ case 'bool':
110
+ if (typeof value !== 'boolean') errors.push({ path, message: `${path} must be true or false (got ${JSON.stringify(value)})` });
111
+ break;
112
+ case 'int':
113
+ if (!Number.isInteger(value) || value < setting.min) {
114
+ errors.push({ path, message: `${path} must be an integer >= ${setting.min} (got ${JSON.stringify(value)})` });
115
+ }
116
+ break;
117
+ case 'number':
118
+ if (typeof value !== 'number' || !(value > setting.exclusiveMin)) {
119
+ errors.push({ path, message: `${path} must be a number > ${setting.exclusiveMin} (got ${JSON.stringify(value)})` });
120
+ }
121
+ break;
122
+ default:
123
+ break;
124
+ }
125
+ }
126
+
127
+ function collectStrings(value, path, out) {
128
+ if (typeof value === 'string') out.push({ path, value });
129
+ else if (Array.isArray(value)) value.forEach((v, i) => collectStrings(v, `${path}[${i}]`, out));
130
+ else if (value && typeof value === 'object') {
131
+ Object.entries(value).forEach(([k, v]) => collectStrings(v, path ? `${path}.${k}` : k, out));
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Validate a parsed configuration object.
137
+ * Returns { ok, errors, warnings, config } where config has defaults applied.
138
+ */
139
+ export function validateConfig(raw, { knownTargets = null, knownChecks = null } = {}) {
140
+ const errors = [];
141
+ const warnings = [];
142
+ const user = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
143
+
144
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
145
+ errors.push({ path: '', message: 'relevo.yml must be a map of settings' });
146
+ }
147
+
148
+ // Unknown top-level keys and unknown keys inside groups (catches typos).
149
+ Object.keys(user).forEach((key) => {
150
+ if (!KNOWN_TOP.has(key)) errors.push({ path: key, message: `unknown setting "${key}"` });
151
+ });
152
+ GROUPS.forEach((group) => {
153
+ const value = user[group];
154
+ if (value === undefined || value === null) return;
155
+ if (typeof value !== 'object' || Array.isArray(value)) {
156
+ errors.push({ path: group, message: `${group} must be a map` });
157
+ return;
158
+ }
159
+ const known = new Set(SETTINGS.filter((s) => s.path.startsWith(`${group}.`)).map((s) => s.path.split('.')[1]));
160
+ Object.keys(value).forEach((k) => {
161
+ if (!known.has(k)) errors.push({ path: `${group}.${k}`, message: `unknown setting "${group}.${k}"` });
162
+ });
163
+ });
164
+
165
+ // Per-setting checks and defaults.
166
+ const config = {};
167
+ SETTINGS.forEach((setting) => {
168
+ const value = getPath(user, setting.path);
169
+ if (value === undefined || value === null) {
170
+ if (setting.required) {
171
+ errors.push({
172
+ path: setting.path,
173
+ message: `${setting.path} is required: unattended runs must always have a cost limit (amount in US dollars per run)`,
174
+ });
175
+ return;
176
+ }
177
+ setPath(config, setting.path, structuredClone(setting.default));
178
+ return;
179
+ }
180
+ // A single file name is the same as a list of one.
181
+ const given = setting.type === 'path-list' && typeof value === 'string' ? [value] : value;
182
+ checkType(setting, given, errors);
183
+ setPath(config, setting.path, given);
184
+ });
185
+
186
+ // Platforms: the registry, or the legacy shortcut turned into one entry.
187
+ const normalized = normalizePlatforms({ platform: user.platform, platforms: user.platforms }, { knownTargets });
188
+ errors.push(...normalized.errors);
189
+ config.platforms = normalized.platforms;
190
+ if (knownChecks) {
191
+ Object.entries(config.platforms).forEach(([name, entry]) => {
192
+ const valid = knownChecks(entry.stack);
193
+ if (!valid) return;
194
+ Object.keys(entry.checks ?? {}).filter((c) => !valid.includes(c)).forEach((c) => {
195
+ errors.push({ path: `platforms.${name}.checks.${c}`, message: `platforms.${name}.checks has unknown command "${c}"; the stack "${entry.stack}" defines: ${valid.join(', ')}` });
196
+ });
197
+ });
198
+ }
199
+
200
+ // Labels: renameable, unique, within GitHub's length limit.
201
+ const labels = user.labels;
202
+ config.labels = {};
203
+ if (labels !== undefined && labels !== null) {
204
+ if (typeof labels !== 'object' || Array.isArray(labels)) {
205
+ errors.push({ path: 'labels', message: 'labels must be a map of label key to label name' });
206
+ } else {
207
+ Object.entries(labels).forEach(([key, name]) => {
208
+ if (!LABEL_KEYS.includes(key)) {
209
+ errors.push({ path: `labels.${key}`, message: `unknown label key "${key}"; allowed keys: ${describeAllowed(LABEL_KEYS)}` });
210
+ } else if (typeof name !== 'string' || name.trim() === '' || name.length > GITHUB_LABEL_MAX) {
211
+ errors.push({ path: `labels.${key}`, message: `labels.${key} must be a non-empty string of at most ${GITHUB_LABEL_MAX} characters` });
212
+ } else {
213
+ config.labels[key] = name;
214
+ }
215
+ });
216
+ const names = Object.values(config.labels);
217
+ if (new Set(names).size !== names.length) {
218
+ errors.push({ path: 'labels', message: 'label names must be unique' });
219
+ }
220
+ }
221
+ }
222
+
223
+ // Language: any language tag; only some have message catalogs.
224
+ const lang = config.language;
225
+ if (typeof lang === 'string') {
226
+ if (!LANGUAGE_RE.test(lang)) {
227
+ errors.push({ path: 'language', message: `language must look like "en" or "pt-BR" (got ${JSON.stringify(lang)})` });
228
+ } else if (!SUPPORTED_LANGUAGES.includes(lang.split('-')[0])) {
229
+ warnings.push({
230
+ path: 'language',
231
+ message: `no message catalog for "${lang}"; messages fall back to "en" (available: ${SUPPORTED_LANGUAGES.join(', ')})`,
232
+ });
233
+ }
234
+ }
235
+
236
+ // Cross-setting rules.
237
+ if (config.review.mode === 'roles' && config.team.approvers.length === 0) {
238
+ errors.push({ path: 'team.approvers', message: 'team.approvers must list at least one person when review.mode is "roles"' });
239
+ }
240
+ if (config.trust === 'public' && config.review.mode === 'none') {
241
+ errors.push({ path: 'review.mode', message: 'trust "public" needs a human review of generated code: review.mode cannot be "none"' });
242
+ }
243
+ if (config.review.mode === 'peer' || config.review.mode === 'roles') {
244
+ const eligible = config.team.members.filter((m) => m !== config.team.spec_writer);
245
+ if (eligible.length < 2) {
246
+ warnings.push({
247
+ path: 'team.members',
248
+ message: 'review.mode needs a second person to review: team.members should list at least 2 people besides the spec writer',
249
+ });
250
+ }
251
+ }
252
+
253
+ // Credentials must never live in this file (checked on every value and key).
254
+ const strings = [];
255
+ collectStrings(user, '', strings);
256
+ strings.forEach(({ path, value }) => {
257
+ if (findCredentials(value, { strict: true }).length > 0) {
258
+ errors.push({
259
+ path,
260
+ message: `${path || 'value'} looks like a credential; move it to the repository secret store, never to relevo.yml`,
261
+ });
262
+ }
263
+ });
264
+ const flatKeys = [];
265
+ (function walk(obj, prefix) {
266
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return;
267
+ Object.entries(obj).forEach(([k, v]) => {
268
+ flatKeys.push({ key: k, path: prefix ? `${prefix}.${k}` : k });
269
+ walk(v, prefix ? `${prefix}.${k}` : k);
270
+ });
271
+ }(user, ''));
272
+ flatKeys.forEach(({ key, path }) => {
273
+ if (/(api[_-]?key|secret|password|token)/i.test(key)) {
274
+ errors.push({
275
+ path,
276
+ message: `${path} looks like a credential setting; move it to the repository secret store, never to relevo.yml`,
277
+ });
278
+ }
279
+ });
280
+
281
+ return { ok: errors.length === 0, errors, warnings, config };
282
+ }
283
+
284
+ /** Parse relevo.yml text and validate it. Parse errors are returned as errors. */
285
+ export function loadConfig(text, options = {}) {
286
+ let raw;
287
+ try {
288
+ raw = parse(text);
289
+ } catch (err) {
290
+ if (err instanceof YamlLiteError) {
291
+ return { ok: false, errors: [{ path: '', message: `relevo.yml is not valid: ${err.message}` }], warnings: [], config: null };
292
+ }
293
+ throw err;
294
+ }
295
+ return validateConfig(raw, options);
296
+ }