yadflow 3.18.1 → 4.0.0-next.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 (156) hide show
  1. package/CHANGELOG.md +355 -0
  2. package/README.md +79 -26
  3. package/bin/commands.mjs +41 -0
  4. package/bin/yad.mjs +437 -124
  5. package/cli/artifact-status.mjs +34 -15
  6. package/cli/checkpoint.mjs +69 -49
  7. package/cli/codeowners-command.mjs +170 -0
  8. package/cli/codeowners.mjs +397 -0
  9. package/cli/commit.mjs +13 -9
  10. package/cli/companion.mjs +2 -2
  11. package/cli/dial.mjs +183 -0
  12. package/cli/docs.mjs +88 -32
  13. package/cli/doctor.mjs +1472 -97
  14. package/cli/epic-state.mjs +3478 -232
  15. package/cli/epic.mjs +506 -0
  16. package/cli/errors.mjs +4 -1
  17. package/cli/gate.mjs +1002 -209
  18. package/cli/history.mjs +556 -0
  19. package/cli/hook.mjs +266 -55
  20. package/cli/hubcommit.mjs +6 -17
  21. package/cli/index-command.mjs +87 -0
  22. package/cli/ledger.mjs +57 -7
  23. package/cli/lib.mjs +184 -18
  24. package/cli/manifest.mjs +367 -56
  25. package/cli/migrate.mjs +726 -53
  26. package/cli/mode.mjs +170 -0
  27. package/cli/next.mjs +349 -90
  28. package/cli/openpr.mjs +191 -39
  29. package/cli/people.mjs +654 -0
  30. package/cli/plan.mjs +417 -132
  31. package/cli/platform.mjs +110 -129
  32. package/cli/product-index.mjs +287 -0
  33. package/cli/protection.mjs +706 -0
  34. package/cli/reconcile.mjs +38 -12
  35. package/cli/repo-publish.mjs +24 -26
  36. package/cli/repo.mjs +23 -14
  37. package/cli/report.mjs +21 -15
  38. package/cli/review.mjs +24 -27
  39. package/cli/riskmap-command.mjs +289 -0
  40. package/cli/riskmap.mjs +373 -0
  41. package/cli/setup.mjs +139 -287
  42. package/cli/ship.mjs +7 -6
  43. package/cli/skill.mjs +180 -0
  44. package/cli/skip.mjs +211 -30
  45. package/cli/thread.mjs +42 -17
  46. package/cli/tidy.mjs +20 -20
  47. package/cli/update-commit.mjs +22 -22
  48. package/cli/usage.mjs +115 -109
  49. package/package.json +3 -3
  50. package/skills/sdlc/config.yaml +166 -87
  51. package/skills/sdlc/module-help.csv +35 -35
  52. package/skills/yad-analysis/SKILL.md +125 -65
  53. package/skills/yad-architecture/SKILL.md +34 -23
  54. package/skills/yad-architecture/references/contract-format.md +10 -8
  55. package/skills/yad-backfill/SKILL.md +14 -8
  56. package/skills/yad-backfill/references/backfill.md +1 -1
  57. package/skills/yad-change/SKILL.md +127 -52
  58. package/skills/yad-change/references/triage.md +42 -28
  59. package/skills/yad-checks/SKILL.md +89 -45
  60. package/skills/yad-checks/references/check-gates.md +315 -92
  61. package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
  62. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  63. package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
  64. package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
  65. package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
  66. package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
  67. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  68. package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
  69. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
  70. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  71. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  72. package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
  73. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  74. package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
  75. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  76. package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
  77. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
  78. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  79. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
  80. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  81. package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
  82. package/skills/yad-commit/SKILL.md +6 -6
  83. package/skills/yad-connect-design/SKILL.md +6 -6
  84. package/skills/yad-connect-design/references/design-context.md +1 -1
  85. package/skills/yad-connect-design/references/design-registry.md +2 -2
  86. package/skills/yad-connect-docs/SKILL.md +12 -12
  87. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  88. package/skills/yad-connect-learning/SKILL.md +5 -5
  89. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  90. package/skills/yad-connect-repos/SKILL.md +92 -54
  91. package/skills/yad-connect-repos/references/code-context.md +6 -6
  92. package/skills/yad-connect-repos/references/hub-config.md +68 -58
  93. package/skills/yad-connect-repos/references/repos-registry.md +10 -9
  94. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  95. package/skills/yad-connect-testing/SKILL.md +6 -6
  96. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  97. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  98. package/skills/yad-defects/SKILL.md +8 -8
  99. package/skills/yad-discovery/SKILL.md +130 -94
  100. package/skills/yad-discovery/references/discovery-schema.md +23 -7
  101. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  102. package/skills/yad-docs/SKILL.md +16 -11
  103. package/skills/yad-docs/references/data-mapping.md +9 -7
  104. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  105. package/skills/yad-docs-overview/SKILL.md +32 -17
  106. package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
  107. package/skills/yad-docs-sync/SKILL.md +10 -5
  108. package/skills/yad-docs-sync/references/staleness.md +8 -7
  109. package/skills/yad-engineer-review/SKILL.md +88 -24
  110. package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
  111. package/skills/yad-epic/SKILL.md +178 -100
  112. package/skills/yad-epic/references/state-schema.md +626 -117
  113. package/skills/yad-hub-bridge/SKILL.md +66 -48
  114. package/skills/yad-hub-bridge/references/bridge.md +110 -83
  115. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  116. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  117. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  118. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  119. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
  120. package/skills/yad-implement/SKILL.md +29 -15
  121. package/skills/yad-implement/references/implement-conventions.md +2 -2
  122. package/skills/yad-learn/SKILL.md +9 -9
  123. package/skills/yad-learn/references/learning-state.md +2 -2
  124. package/skills/yad-open-pr/SKILL.md +64 -29
  125. package/skills/yad-pair-review/SKILL.md +18 -16
  126. package/skills/yad-pair-review/references/session-state.md +4 -4
  127. package/skills/yad-pr-template/SKILL.md +48 -27
  128. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  129. package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
  130. package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
  131. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  132. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  133. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  134. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
  135. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
  136. package/skills/yad-reconcile/SKILL.md +3 -3
  137. package/skills/yad-report/SKILL.md +5 -5
  138. package/skills/yad-review-companion/SKILL.md +12 -9
  139. package/skills/yad-review-gate/SKILL.md +198 -79
  140. package/skills/yad-review-gate/references/gating.md +230 -54
  141. package/skills/yad-run/SKILL.md +86 -56
  142. package/skills/yad-run/references/run-loop.md +67 -45
  143. package/skills/yad-ship/SKILL.md +18 -14
  144. package/skills/yad-spec/SKILL.md +31 -17
  145. package/skills/yad-spec/references/spec-handoff.md +17 -5
  146. package/skills/yad-status/SKILL.md +114 -56
  147. package/skills/yad-stories/SKILL.md +42 -27
  148. package/skills/yad-stories/references/story-schema.md +10 -9
  149. package/skills/yad-stub/SKILL.md +59 -48
  150. package/skills/yad-sync-repos/SKILL.md +3 -3
  151. package/skills/yad-test-cases/SKILL.md +37 -30
  152. package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
  153. package/skills/yad-timeline/SKILL.md +8 -7
  154. package/skills/yad-ui/SKILL.md +46 -25
  155. package/cli/roster.mjs +0 -164
  156. package/skills/sdlc/install.sh +0 -68
@@ -0,0 +1,706 @@
1
+ // E70 — what the PLATFORM holds on a repo's branch: branch protection, and whether any rule requires an
2
+ // approval before a merge. yad never enforces a merge itself (Part 4: platform branch protection is "the
3
+ // real enforcement"), so this is a READ, reported by `yad doctor`, and it may never claim more than it read.
4
+ //
5
+ // THE TRAP THIS FILE EXISTS FOR. A failed call is not an answer. GitHub answers 404 on classic branch
6
+ // protection to anyone who is not an admin — even on a branch its own `branches/<b>` call reports as
7
+ // `protected: true` (checked on cli/cli's trunk, 2026-09-22). So "no rules" is PROVEN only from calls that
8
+ // succeeded, and anything else is `null` — "not known", with the reason — never "none" and never "fine".
9
+ //
10
+ // What counts as an approval rule (the user's decision, 2026-09-22): a required approving review count
11
+ // above 0.
12
+ // GitHub — a ruleset `pull_request` rule (`rules/branches/<b>`: every ACTIVE rule on the branch, from
13
+ // the repo or the organisation; needs only read access), and classic branch protection's
14
+ // `required_pull_request_reviews` (`branches/<b>/protection`: admins only).
15
+ // GitLab — a project approval rule with `approvals_required` above 0 that applies to the branch. The
16
+ // API is GitLab Premium and Ultimate only; on Free an approval never blocks a merge. A refusal
17
+ // is "needs Premium, or your login may not read it" — the tier is never guessed.
18
+ // Protection alone (no direct push) is a FACT beside the answer. So is a rule that covers only SOME files
19
+ // (a code-owner review, a GitHub ruleset's named reviewers): it is an approval rule, but not one on every
20
+ // change, so it is never the count — and a line that names no count says "on every change" beside it.
21
+ //
22
+ // Every call is the user's own `gh`/`glab`, logged in as the user, to the repo's own host (rule 8: nothing
23
+ // leaves the machine for anywhere else). `YAD_PLATFORM_READ=0` turns the reads off — for offline work, and
24
+ // for the test suite, which must never ask the developer's real account (the `YAD_PLATFORM_LOGIN=0`
25
+ // precedent). `runner` is injectable so every answer is testable without a network.
26
+ import { run } from './lib.mjs';
27
+ import { cliFor, hostFromGitUrl, detectPlatform } from './platform.mjs';
28
+
29
+ export const PLATFORM_NAME = { github: 'GitHub', gitlab: 'GitLab' };
30
+ const TIMEOUT = 10_000;
31
+
32
+ // A word with an `@` after its first character may be an e-mail address, and no address is ever printed
33
+ // (E67). A branch or a repo path is printed as written otherwise.
34
+ export const shown = (w) => (typeof w === 'string' && w.indexOf('@', 1) >= 0 ? 'a name with an @ in it' : `${w}`);
35
+
36
+ // The repo's path on its host (`owner/repo`, or `group/sub/project` on GitLab), from a git remote URL in
37
+ // either form. Not the `repoSlug` the doctor used to carry, which kept only the last two parts and so named
38
+ // the wrong GitLab project under a subgroup.
39
+ export function repoPathFromGitUrl(url = '') {
40
+ if (typeof url !== 'string' || !url.trim()) return null;
41
+ const u = url.trim();
42
+ let p;
43
+ // scp-like `[user@]host:path`. A `scheme://` URL never matches: the colon is followed by `/`.
44
+ const scp = u.match(/^(?:[^@/]+@)?[^/:]+:(?!\/)(.+)$/);
45
+ if (scp) p = scp[1];
46
+ else {
47
+ try { p = new URL(u).pathname; } catch { return null; }
48
+ }
49
+ p = p.replace(/^\/+/, '').replace(/\/+$/, '').replace(/\.git$/, '');
50
+ const parts = p.split('/').filter(Boolean);
51
+ return parts.length >= 2 ? parts.join('/') : null;
52
+ }
53
+
54
+ // The HTTP status of a failed `gh api` / `glab api` call, or null when there was none (offline, a timeout,
55
+ // the host is down). gh ends with `gh: Not Found (HTTP 404)`; glab with `glab: 404 Not Found (HTTP 404)`
56
+ // and prints the body `{"message":"401 Unauthorized"}` on stdout.
57
+ export function httpStatus(r) {
58
+ const text = `${r?.stderr || ''}\n${r?.stdout || ''}`;
59
+ const m = text.match(/\(HTTP (\d{3})\)/) || text.match(/\bglab: (\d{3})\b/) || text.match(/"message":\s*"(\d{3})\b/)
60
+ || text.match(/"status":\s*"(\d{3})"/);
61
+ return m ? Number(m[1]) : null;
62
+ }
63
+
64
+ // A failed call keeps its body too (E109): both CLIs print the platform's answer on stdout, so a caller
65
+ // can read WHY it failed. A body that is not JSON is null, never an error. Only the GitLab branch read
66
+ // looks at it today — see `gitlabBranchCause`.
67
+ function api(runner, cli, host, pathname) {
68
+ const r = runner(cli, ['api', '--hostname', host, pathname], { timeout: TIMEOUT });
69
+ if (r.ok) {
70
+ try { return { ok: true, body: JSON.parse(r.stdout) }; } catch { return { ok: false, status: null, unreadable: true }; }
71
+ }
72
+ let body = null;
73
+ try { body = JSON.parse(r.stdout); } catch { /* no body yad can read: the status is all there is */ }
74
+ return { ok: false, status: httpStatus(r), body };
75
+ }
76
+
77
+ // E109 — which of the two causes a GitLab branch 404 proved, from the body's `message`, or null.
78
+ // GitLab's branches API runs `require_repository_enabled!` before it looks the branch up, and that answers
79
+ // `not_found!("Repository")` when `feature_available?(:repository, user)` is false — the repository is
80
+ // turned off for the project, or this login's access is too low to read it. Only after that does a
81
+ // missing branch answer `not_found!("Branch")`. Both checked live on gitlab.com, 2026-09-23 (glab 1.77
82
+ // prints the body on stdout): project 86809638's `main` answers `404 Repository Not Found`, and a branch
83
+ // gitlab-org/gitlab does not have answers `404 Branch Not Found`.
84
+ // A message is not an API contract, so only EXACT equality counts: a reworded message, another language
85
+ // on a self-hosted server, or no body at all is null, and the caller keeps its hedge word for word. A body
86
+ // may only REMOVE a cause from a sentence, never add certainty.
87
+ const GITLAB_404 = { '404 Repository Not Found': 'repository', '404 Branch Not Found': 'branch' };
88
+ const gitlabBranchCause = (res) => (res.status === 404 && typeof res.body?.message === 'string'
89
+ && Object.hasOwn(GITLAB_404, res.body.message) ? GITLAB_404[res.body.message] : null);
90
+
91
+ // One page of 100 is read, never more. A FULL page proves nothing about what is past it, so a list that
92
+ // comes back with exactly PAGE entries and does not already hold the answer is "not known".
93
+ export const PAGE = 100;
94
+
95
+ // Why a call gave no answer, as a clause that finishes "not known — ".
96
+ function whyFailed(res, { platform, host, what, plural = false }) {
97
+ const name = PLATFORM_NAME[platform];
98
+ if (res.unreadable) return `${name} answered ${what} with something yad could not read`;
99
+ if (res.status == null) return `yad could not reach ${host} to read ${what} (offline, or the host did not answer)`;
100
+ if (res.status === 401) return `${name} refused to show ${what} (HTTP 401 — the login may have expired)`;
101
+ if (res.status === 403) return `${name} refused to show ${what} (HTTP 403 — your login may not read ${plural ? 'them' : 'it'})`;
102
+ // A repo, a project or a GitLab branch reaches this arm, and each can genuinely be absent. GitHub's
103
+ // BRANCH names its own cause at the call site, because one permission covers the repo read before it and
104
+ // the branch; GitLab's does not, because reading a project and reading its repository are two settings.
105
+ // Proven, not assumed: GitLab's `require_repository_enabled!` answers `not_found!("Repository")`, so a
106
+ // login that may read the project and not its repository gets a 404, never a 403. Checked live on
107
+ // gitlab.com 2026-09-23 — project 86809638 answers 200, and its `repository/branches/main` answers
108
+ // 404 `{"message":"404 Repository Not Found"}`. Since E109 the GitLab branch read names the cause when
109
+ // the body says which (`gitlabBranchCause`), and reaches this arm — the hedge — only when it does not.
110
+ // Every LIST this file reads names its own cause too, because "they do not exist" is what an empty list
111
+ // says, not a 404.
112
+ if (res.status === 404) return `${name} answered 404 for ${what} (it does not exist, or your login may not see it)`;
113
+ return `${name} answered HTTP ${res.status} for ${what}`;
114
+ }
115
+
116
+ const segment = (b) => b.split('/').map(encodeURIComponent).join('/');
117
+
118
+ // A GitLab protected-branch name may be a wildcard (`release-*`); `*` matches any run of characters.
119
+ export function branchMatches(pattern, branch) {
120
+ if (typeof pattern !== 'string') return false;
121
+ if (!pattern.includes('*')) return pattern === branch;
122
+ const re = new RegExp(`^${pattern.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`);
123
+ return re.test(branch);
124
+ }
125
+
126
+ // A whole number the platform gave, or null: `true`, `"2"`, `2.5` and a missing value are none (and
127
+ // `Number(true)` is 1, which is why a bare `Number()` will not do). Used for an approval count — where
128
+ // null means "could not read", never 0 and never 1 — and for an id, which is printed only when it is one.
129
+ const count = (v) => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null);
130
+
131
+ // `gh auth status` is asked once per CLI and host per doctor run (`authCache`, which the caller makes
132
+ // fresh for each run): on a network that drops packets each ask can take the full timeout, and a Product
133
+ // with many repos on one host would pay it once per repo. Never kept longer — a login can change.
134
+ function loggedIn(runner, cli, host, authCache) {
135
+ const key = `${cli}\0${host}`;
136
+ if (!authCache.has(key)) authCache.set(key, runner(cli, ['auth', 'status', '--hostname', host], { timeout: TIMEOUT }).ok);
137
+ return authCache.get(key);
138
+ }
139
+
140
+ // The answer, for one repo and one branch. Every field that could not be read is null with a reason:
141
+ // { platform, host, repo, branch, branchFrom, platformDefault,
142
+ // known: false, why, kind, cause, defaultMayBeHidden } — nothing could be asked at all, or
143
+ // (`cause`, E109: set only when a branch 404
144
+ // PROVED why — 'branch' when the branch is not
145
+ // there, 'repository' when the login cannot
146
+ // read the project's repository; absent when
147
+ // the 404 left both open; `defaultMayBeHidden`,
148
+ // E110: GitLab named no default, which it also
149
+ // does for a login that cannot read the code),
150
+ // { …, known: true,
151
+ // protected: true|false|null, protectedWhy, — is the branch protected at all?
152
+ // approvals: n|null, atLeast, approvalsWhy, from: [..],— the required approval count, and where
153
+ // rulesElsewhere: '<clause: how they miss it>', — GitLab only: rules the project has that do
154
+ // not reach this branch ("it names other
155
+ // branches", "one of them …, and another …"),
156
+ // rulesElsewhereOne: true|false, — whether that clause speaks of one rule,
157
+ // partlyRead: true|false, — something the platform holds was not read,
158
+ // rulesAnswered: true, — GitLab only: the approval-rules endpoint
159
+ // answered with a list, so the project is not
160
+ // on Free,
161
+ // rulesRefused: true, — GitLab only: it refused (401/403/404), which
162
+ // is the one answer that leaves the tier open
163
+ // codeOwners: true|false|null, — a code-owner review is required (a fact beside)
164
+ // fileReviewers: true|false|null } — a named reviewer for some files (GitHub only:
165
+ // a GitLab answer carries no such key)
166
+ // `kind` names the reason for a hint, so the hint never depends on the words of `why`.
167
+ export function readProtection({ platform, gitUrl, branch = null, branchFrom = 'registry' } = {}, { runner = run, env = process.env, authCache = new Map() } = {}) {
168
+ const host = hostFromGitUrl(gitUrl || '');
169
+ const repo = repoPathFromGitUrl(gitUrl || '');
170
+ const plat = platform || detectPlatform(gitUrl || '') || null;
171
+ const base = { platform: plat, host, repo, branch, branchFrom, platformDefault: null };
172
+ const unknown = (kind, why, extra = {}) => ({ ...base, ...extra, known: false, kind, why });
173
+ const cli = cliFor(plat);
174
+ if (!cli) {
175
+ return platform
176
+ ? unknown('no-platform', `yad does not know the platform ${JSON.stringify(platform)} (it reads GitHub and GitLab)`)
177
+ : unknown('no-platform', 'no platform (GitHub or GitLab) is set, so there is no platform to ask');
178
+ }
179
+ if (env.YAD_PLATFORM_READ === '0') return unknown('off', 'platform reads are turned off (YAD_PLATFORM_READ=0)');
180
+ if (!host || !repo) return unknown('no-url', 'no git remote URL yad can read, so it cannot tell which repo to ask about');
181
+ if (!runner(process.platform === 'win32' ? 'where' : 'which', [cli], {}).ok) return unknown('no-cli', `${cli} is not installed, so yad cannot ask ${PLATFORM_NAME[plat]}`);
182
+ if (!loggedIn(runner, cli, host, authCache)) return unknown('no-login', `${cli} is not logged in for ${host} (or ${host} did not answer)`);
183
+ return plat === 'github' ? readGitHub(base, runner, unknown) : readGitLab(base, runner, unknown);
184
+ }
185
+
186
+ // Read the repo, then settle the branch: yad's, else the platform's default (said in the line).
187
+ function repoAndBranch(base, runner, unknown, { cli, platform, at, what }) {
188
+ const meta = api(runner, cli, base.host, at);
189
+ if (!meta.ok) return { fail: unknown(meta.status === 404 ? 'no-repo' : 'other', whyFailed(meta, { platform, host: base.host, what })) };
190
+ const platformDefault = typeof meta.body?.default_branch === 'string' ? meta.body.default_branch : null;
191
+ if (base.branch) return { branch: base.branch, branchFrom: base.branchFrom, platformDefault };
192
+ if (!platformDefault) {
193
+ // E110: GitLab shows a project's default branch only to a login that may read its code (`expose
194
+ // :default_branch_or_main … if: … :read_code` in `basic_project_details.rb`; live, project 86809638
195
+ // answers 200 with no such key). So on GitLab "named none" is also what an unreadable repository looks
196
+ // like, and both causes stay open. Nothing is guessed from the missing key: it proves only that GitLab
197
+ // did not show one. `defaultMayBeHidden` is the fact the hint turns on, never the platform's name.
198
+ // GitHub shows the default to anyone who may read the repo, which the answer above just proved.
199
+ if (platform === 'gitlab') {
200
+ return { fail: unknown('no-default', "no default branch is set in yad's files, and GitLab named none (GitLab names it only to a login that can read the project's repository)", { defaultMayBeHidden: true }) };
201
+ }
202
+ return { fail: unknown('no-default', `no default branch is set in yad's files, and ${PLATFORM_NAME[platform]} named none`) };
203
+ }
204
+ return { branch: platformDefault, branchFrom: 'platform', platformDefault };
205
+ }
206
+
207
+ // A 404 on the branch: the platform does not have the branch yad's files name — or, when the platform
208
+ // itself just named this branch as the repo's default, a repo with no commits yet. That turns on what the
209
+ // repo read PROVED, never on where yad got the name, because yad's files may name the default too. On
210
+ // GitLab add "or one the login cannot read", because reading a project and reading its repository are two
211
+ // settings there — unless the answer's body names the cause (`gitlabBranchCause`, E109); on GitHub one
212
+ // permission covers both, so the repo read before this one rules it out.
213
+ const namedByPlatform = (branch, platformDefault) => platformDefault !== null && branch === platformDefault;
214
+ const branchMissing = (res, branch, platformDefault) => (res.status !== 404 ? 'other'
215
+ : namedByPlatform(branch, platformDefault) ? 'empty' : 'no-branch');
216
+ const RULESET_OF = { Organization: 'an organisation ruleset', Repository: 'a repo ruleset' };
217
+ // What the platform calls a change asking to be merged.
218
+ const REQUEST = { github: 'pull request', gitlab: 'merge request' };
219
+ // How a GitLab approval rule misses this branch, [one rule, more than one].
220
+ const MISSES = {
221
+ 'protected-only': ['reaches protected branches only', 'reach protected branches only'],
222
+ named: ['names other branches', 'name other branches'],
223
+ };
224
+
225
+ // One source of required approvals, as { floor, exact, why }: `floor` is the most it proved; `exact` says
226
+ // nothing was left unread; `why` says what was, when it was not.
227
+ const settle = (sources) => {
228
+ const floor = Math.max(0, ...sources.map((s) => s.floor));
229
+ const exact = sources.every((s) => s.exact);
230
+ const why = sources.filter((s) => !s.exact).map((s) => s.why).join('; ');
231
+ // `partlyRead` is the fact a hint keys on: something the platform holds was NOT read. `atLeast` also
232
+ // turns true for GitLab rules that overlap, where every call succeeded — that is not a partial read.
233
+ return floor > 0 ? { approvals: floor, atLeast: !exact, partlyRead: !exact, approvalsWhy: null }
234
+ : (exact ? { approvals: 0, atLeast: false, partlyRead: false, approvalsWhy: null } : { approvals: null, atLeast: false, partlyRead: true, approvalsWhy: why });
235
+ };
236
+
237
+ function readGitHub(base, runner, unknown) {
238
+ const { host, repo } = base;
239
+ const at = `repos/${repo.split('/').map(encodeURIComponent).join('/')}`;
240
+ const rb = repoAndBranch(base, runner, unknown, { cli: 'gh', platform: 'github', at, what: `the repo ${shown(repo)}` });
241
+ if (rb.fail) return rb.fail;
242
+ const { branch, branchFrom, platformDefault } = rb;
243
+ const out = { ...base, branch, branchFrom, platformDefault, known: true, protected: null, protectedWhy: null, approvals: null, atLeast: false, approvalsWhy: null, from: [], codeOwners: null };
244
+ const b = api(runner, 'gh', host, `${at}/branches/${segment(branch)}`);
245
+ if (!b.ok) {
246
+ const why = b.status !== 404
247
+ ? whyFailed(b, { platform: 'github', host, what: `the branch ${shown(branch)}` })
248
+ : namedByPlatform(branch, platformDefault)
249
+ // GitHub named this branch as the repo's default moments ago, so the repo does not lack it.
250
+ ? `GitHub answered 404 for the branch ${shown(branch)}, so this repo has no commits on it yet`
251
+ : `GitHub answered 404 for the branch ${shown(branch)}, so this repo does not have it`;
252
+ // `cause: 'branch'`: one GitHub permission covers the repo read just made and the branch, so a 404
253
+ // here proves the branch cause — the fact the hints turn on, never the platform's name (E109).
254
+ return { ...out, known: false, kind: branchMissing(b, branch, platformDefault), why, ...(b.status === 404 ? { cause: 'branch' } : {}) };
255
+ }
256
+ if (typeof b.body?.protected !== 'boolean') return { ...out, known: false, kind: 'no-flag', why: 'GitHub did not say whether the branch is protected' };
257
+ out.protected = b.body.protected;
258
+ // Rulesets: every ACTIVE rule on the branch ("evaluate" and "disabled" rulesets are not returned).
259
+ const rules = api(runner, 'gh', host, `${at}/rules/branches/${segment(branch)}?per_page=${PAGE}`);
260
+ let rs;
261
+ let noRulesets = false; // no ruleset is on this branch: the list came back empty, or the host has no rulesets API
262
+ let rulesetCodeOwners = false;
263
+ let rulesetReviewers = false; // true, false, or null (a reviewer's count could not be read)
264
+ if (rules.ok && Array.isArray(rules.body)) {
265
+ noRulesets = rules.body.length === 0;
266
+ let floor = 0;
267
+ let unreadable = false;
268
+ for (const r of rules.body) {
269
+ if (r?.type !== 'pull_request') continue;
270
+ if (r.parameters?.require_code_owner_review === true) rulesetCodeOwners = true;
271
+ // A named reviewer for some files (`required_reviewers`): like a code owner, a fact beside the count.
272
+ if (r.parameters?.required_reviewers !== undefined && !Array.isArray(r.parameters.required_reviewers)) {
273
+ // Present, but not a list: like a count that is not a whole number, that is "could not read".
274
+ if (rulesetReviewers === false) rulesetReviewers = null;
275
+ } else if (Array.isArray(r.parameters?.required_reviewers)) {
276
+ for (const x of r.parameters.required_reviewers) {
277
+ const m = count(x?.minimum_approvals);
278
+ if (m > 0) rulesetReviewers = true;
279
+ else if (m === null && rulesetReviewers === false) rulesetReviewers = null;
280
+ }
281
+ }
282
+ const n = count(r.parameters?.required_approving_review_count);
283
+ if (n === null) { unreadable = true; continue; }
284
+ if (n > 0) {
285
+ floor = Math.max(floor, n);
286
+ out.from.push(`${RULESET_OF[r.ruleset_source_type] || 'a ruleset'}${count(r.ruleset_id) !== null ? ` (id ${r.ruleset_id})` : ''}`);
287
+ }
288
+ }
289
+ // GitHub's own flag is the starting point, but yad never takes "not protected" from it alone: it
290
+ // confirms with the rules on the branch. If the flag says no while GitHub also lists ACTIVE rules, the
291
+ // two answers disagree — that is "not known", never "protected" (which would go quiet about a branch
292
+ // anyone can push to) and never "no protection" either.
293
+ if (rules.body.length && out.protected === false) {
294
+ out.protected = null;
295
+ out.protectedWhy = 'GitHub\'s branch flag says no, but GitHub also lists active rules on the branch';
296
+ }
297
+ const full = rules.body.length >= PAGE;
298
+ rs = {
299
+ floor, exact: !unreadable && !full,
300
+ why: [
301
+ unreadable ? 'GitHub listed a pull request rule whose approval count yad could not read' : '',
302
+ full ? `the branch has ${PAGE} or more active rules and yad reads only the first ${PAGE}` : '',
303
+ ].filter(Boolean).join('; '),
304
+ };
305
+ } else {
306
+ // A 404 here is not "no rules" (that answer is an empty list) and not a permission (the repo and the
307
+ // branch were just read with this login): it is a host whose API has no rulesets, such as an older
308
+ // GitHub Enterprise Server.
309
+ if (rules.status === 404) noRulesets = true;
310
+ const why = rules.status === 404
311
+ ? 'GitHub answered 404 for the rules on the branch, which a host without the rulesets API does'
312
+ : whyFailed(rules.ok ? { unreadable: true } : rules, { platform: 'github', host, what: 'the rules on the branch', plural: true });
313
+ rs = { floor: 0, exact: false, why };
314
+ // `protected: false` may not count a ruleset, so with the rulesets unread it proves nothing.
315
+ if (out.protected === false) {
316
+ out.protected = null;
317
+ // The flag may not count a ruleset, so only the rules could settle it. On a 404 the reason beside
318
+ // this one says what that answer is, so this sentence names the gap and offers no hunt.
319
+ out.protectedWhy = noRulesets
320
+ ? 'GitHub\'s branch flag says no, and only the rules on the branch could confirm it'
321
+ : 'GitHub\'s branch flag says no, and the rulesets that could confirm it could not be read';
322
+ }
323
+ }
324
+ // Classic protection. GitHub's flag is false only when the branch has no classic protection, so there
325
+ // is nothing classic to read — and asking would only earn a 404 that proves nothing.
326
+ let cl = { floor: 0, exact: true, why: '' };
327
+ let classicCodeOwners = false;
328
+ if (b.body.protected) {
329
+ const p = api(runner, 'gh', host, `${at}/branches/${segment(branch)}/protection`);
330
+ if (p.ok && p.body && typeof p.body === 'object' && !Array.isArray(p.body)) {
331
+ const rev = p.body.required_pull_request_reviews;
332
+ if (rev == null) cl = { floor: 0, exact: true, why: '' };
333
+ else {
334
+ if (rev?.require_code_owner_reviews === true) classicCodeOwners = true;
335
+ const n = count(rev?.required_approving_review_count);
336
+ if (n === null) cl = { floor: 0, exact: false, why: 'GitHub\'s classic branch protection requires reviews, with a count yad could not read' };
337
+ else {
338
+ cl = { floor: n, exact: true, why: '' };
339
+ if (n > 0) out.from.push('classic branch protection');
340
+ }
341
+ }
342
+ } else if (p.ok) {
343
+ cl = { floor: 0, exact: false, why: 'GitHub answered classic branch protection with something yad could not read' };
344
+ } else {
345
+ cl = {
346
+ floor: 0, exact: false,
347
+ why: p.status === 404
348
+ // "or a ruleset protects it" is only a live cause while a ruleset could be there: with the rules
349
+ // read and empty, the branch has none, so that arm would send the reader hunting for nothing.
350
+ // The ruleset arm goes whenever the rules read ruled rulesets out — proven empty, or a host
351
+ // whose API has none. Offering it there would send the reader hunting for what cannot exist.
352
+ ? `only a repo admin can read classic branch protection, and GitHub answered 404 (your login is not an admin${noRulesets ? '' : ', or the branch is protected by rulesets alone'})`
353
+ : whyFailed(p, { platform: 'github', host, what: 'classic branch protection' }),
354
+ };
355
+ }
356
+ }
357
+ // GitHub applies the strictest of the layered rules, so the count is exact only when both were read.
358
+ out.from = [...new Set(out.from)]; // one source, said once: 100 rules of one ruleset are one source
359
+ Object.assign(out, settle([rs, cl]));
360
+ out.codeOwners = rulesetCodeOwners || classicCodeOwners ? true : (rs.exact && cl.exact ? false : null);
361
+ out.fileReviewers = rulesetReviewers === true ? true : (rs.exact && rulesetReviewers === false ? false : null);
362
+ return out;
363
+ }
364
+
365
+ function readGitLab(base, runner, unknown) {
366
+ const { host, repo } = base;
367
+ const at = `projects/${encodeURIComponent(repo)}`;
368
+ const rb = repoAndBranch(base, runner, unknown, { cli: 'glab', platform: 'gitlab', at, what: `the project ${shown(repo)}` });
369
+ if (rb.fail) return rb.fail;
370
+ const { branch, branchFrom, platformDefault } = rb;
371
+ const out = { ...base, branch, branchFrom, platformDefault, known: true, protected: null, protectedWhy: null, approvals: null, atLeast: false, approvalsWhy: null, from: [], codeOwners: null };
372
+ // The branch itself: it must exist (a typo in yad's files must never read as "nothing protects it"),
373
+ // and its `protected` flag is GitLab's own answer — wildcards and group-level protection included.
374
+ const b = api(runner, 'glab', host, `${at}/repository/branches/${encodeURIComponent(branch)}`);
375
+ if (!b.ok) {
376
+ // E109: the body may name which of the 404's two causes it was. Each sentence says what that body
377
+ // proved and no more; a body yad does not recognise keeps the hedge below, word for word.
378
+ const cause = gitlabBranchCause(b);
379
+ if (cause === 'repository') {
380
+ // The branch was never looked up, so nothing is said about it — whoever named it.
381
+ return { ...out, known: false, kind: 'no-repository', cause, why: `GitLab answered 404 for the branch ${shown(branch)} because this project's repository could not be read (it is turned off for the project, or your access is too low to read it)` };
382
+ }
383
+ if (cause === 'branch') {
384
+ const why = namedByPlatform(branch, platformDefault)
385
+ // GitLab named this branch as the project's default moments ago, so the project does not lack it.
386
+ ? `GitLab answered 404 for the branch ${shown(branch)}, so this project has no commits on it yet`
387
+ : `GitLab answered 404 for the branch ${shown(branch)}, so this project does not have it`;
388
+ return { ...out, known: false, kind: branchMissing(b, branch, platformDefault), cause, why };
389
+ }
390
+ const why = whyFailed(b, { platform: 'gitlab', host, what: `the branch ${shown(branch)}` });
391
+ return { ...out, known: false, kind: branchMissing(b, branch, platformDefault), why };
392
+ }
393
+ if (typeof b.body?.protected !== 'boolean') return { ...out, known: false, kind: 'no-flag', why: 'GitLab did not say whether the branch is protected' };
394
+ out.protected = b.body.protected;
395
+ // The project's protected branches, for the code-owner fact only. The list leaves out protection set
396
+ // for a whole group, and a group's setting takes precedence over the project's — so an entry saying "no"
397
+ // proves nothing. Only a matching entry saying YES is proof, and only an UNPROTECTED branch proves no.
398
+ const pb = api(runner, 'glab', host, `${at}/protected_branches?per_page=${PAGE}`);
399
+ const matched = pb.ok && Array.isArray(pb.body) ? pb.body.filter((p) => branchMatches(p?.name, branch)) : [];
400
+ if (matched.some((p) => p?.code_owner_approval_required === true)) out.codeOwners = true;
401
+ else if (out.protected === false) out.codeOwners = false;
402
+ const ar = api(runner, 'glab', host, `${at}/approval_rules?per_page=${PAGE}`);
403
+ let src;
404
+ const elsewhere = new Map(); // how the rules that do not reach this branch miss it → how many do
405
+ let applied = 0; // rules that apply AND ask for an approval: each is met on its own, so 2 is a floor
406
+ let elsewhereRules = 0; // how many rules the project has that do not reach this branch (1 reads differently)
407
+ if (ar.ok && Array.isArray(ar.body)) {
408
+ let floor = 0;
409
+ const whys = new Set();
410
+ for (const r of ar.body) {
411
+ // A report rule (Coverage-Check, License-Check…) asks for an approval only when its report fails,
412
+ // so it does not hold every merge: not an approval rule here.
413
+ if (r?.rule_type === 'report_approver') continue;
414
+ const n = count(r?.approvals_required);
415
+ if (n === null) { whys.add('GitLab listed an approval rule whose count yad could not read'); continue; }
416
+ if (n === 0) continue;
417
+ let applies;
418
+ let unreadableReach = false; // this rule's list holds a name yad could not read, rather than no list
419
+ if (r.applies_to_all_protected_branches === true) applies = out.protected;
420
+ else if (Array.isArray(r.protected_branches)) {
421
+ // A listed branch yad cannot read settles nothing: a match wins, but "no match" is an answer only
422
+ // when every entry was readable.
423
+ const listed = r.protected_branches;
424
+ if (!listed.length) applies = true; // none listed: every branch
425
+ else if (listed.some((x) => branchMatches(x?.name, branch))) applies = true;
426
+ else if (listed.every((x) => typeof x?.name === 'string')) applies = false;
427
+ else { applies = null; unreadableReach = true; }
428
+ } else applies = null;
429
+ if (applies === null) {
430
+ // Two different gaps: GitLab gave no list of branches, or its list holds one yad could not read.
431
+ whys.add(unreadableReach
432
+ ? 'GitLab listed an approval rule whose branch list holds a name yad could not read'
433
+ : 'GitLab did not say which branches an approval rule covers');
434
+ continue;
435
+ }
436
+ if (applies) {
437
+ applied += 1;
438
+ floor = Math.max(floor, n);
439
+ // A name the platform did not give is never printed as if it had: its id, else no name at all.
440
+ const named = typeof r.name === 'string' && r.name.trim() ? r.name.trim() : null;
441
+ // A rule NAMED "42" and a rule with ID 42 must not read the same, so an id is printed as an id —
442
+ // the shape the GitHub side already uses ("a repo ruleset (id 7)").
443
+ if (named !== null) out.from.push(`approval rule ${JSON.stringify(named)}`);
444
+ else out.from.push(count(r.id) !== null ? `an approval rule (id ${r.id})` : 'an approval rule');
445
+ }
446
+ // A rule the platform DID return that does not reach this branch: say which way it misses, so the
447
+ // line never claims "no approval rules", nor that protecting the branch would bring this one to it.
448
+ else {
449
+ elsewhereRules += 1;
450
+ const kind = r.applies_to_all_protected_branches === true ? 'protected-only' : 'named';
451
+ elsewhere.set(kind, (elsewhere.get(kind) || 0) + 1);
452
+ }
453
+ }
454
+ if (ar.body.length >= PAGE) whys.add(`the project has ${PAGE} or more approval rules and yad reads only the first ${PAGE}`);
455
+ src = { floor, exact: !whys.size, why: [...whys].join('; ') };
456
+ } else {
457
+ src = {
458
+ floor: 0, exact: false,
459
+ why: ar.ok
460
+ ? 'GitLab answered the approval rules with something yad could not read'
461
+ : ([401, 403, 404].includes(ar.status)
462
+ ? `GitLab refused to show the approval rules (HTTP ${ar.status}): they need GitLab Premium or Ultimate, or your login may not read them`
463
+ : whyFailed(ar, { platform: 'gitlab', host, what: 'the approval rules', plural: true })),
464
+ };
465
+ }
466
+ out.from = [...new Set(out.from)]; // a source named the same way is named once (the count is above)
467
+ Object.assign(out, settle([src]));
468
+ // The approval-rules endpoint answers on Premium and Ultimate only, so a list that came back settles the
469
+ // tier: a hint must not go on offering Free as the explanation (the round-15 rule, on the GitLab side).
470
+ if (ar.ok && Array.isArray(ar.body)) out.rulesAnswered = true;
471
+ // `rulesAnswered` is a fact for `--json` and nothing else: the hints turn on `rulesRefused`, the one
472
+ // answer that leaves the tier open — offline, or a body yad could not read, says nothing about Premium,
473
+ // and those lines point at what was not read instead.
474
+ else if (!ar.ok && [401, 403, 404].includes(ar.status)) out.rulesRefused = true;
475
+ if (elsewhere.size) {
476
+ const how = [...elsewhere];
477
+ const said = ([kind, n], first) => `${n === 1 ? (first ? 'one of them' : 'another') : (first ? 'some of them' : 'others')} ${MISSES[kind][n === 1 ? 0 : 1]}`;
478
+ const one = elsewhereRules === 1;
479
+ out.rulesElsewhere = how.length > 1
480
+ ? `${said(how[0], true)}, and ${said(how[1], false)}`
481
+ : `${one ? 'it' : 'they'} ${MISSES[how[0][0]][one ? 0 : 1]}`;
482
+ out.rulesElsewhereOne = one;
483
+ }
484
+ // Each GitLab rule must be met on its own, and their approvers may overlap: two or more is a floor. It
485
+ // counts the rules that APPLIED — two rules can print the same name, and a floor is not a label count.
486
+ if (out.approvals > 0 && applied > 1) out.atLeast = true;
487
+ return out;
488
+ }
489
+
490
+ // The doctor line for one answer: { status: 'ok'|'warn', message, hint }. `name` is the repo's name in
491
+ // yad (or "Product hub"). In solo mode (the user's decision) the facts print as `ok` with no banner — one
492
+ // person cannot approve their own pull request, so "no approval rules" may be the right setup — except
493
+ // the old solo warning: a required approval there blocks the solo developer's own merge.
494
+ //
495
+ // The Part 3 banner is printed word for word only when BOTH halves are proven: no approval rule AND no
496
+ // branch protection. Nothing here ever says a repo is safe, or that yad holds a merge.
497
+ export const BANNER = 'This repo has no approval rules and no branch protection. Anyone with write access can merge anything. yad will record what happens, but it cannot stop anything here.';
498
+
499
+ // No e-mail address is ever printed (E67). `shown` hides a name yad places itself; this last pass hides
500
+ // any other word with an `@` after its first character — a host, a rule name, a repo's name in yad, a
501
+ // platform string — wherever it came from. Leading quotes and brackets are not the first character.
502
+ // Only the word's core is replaced: the quotes, brackets and punctuation around it stay, so a sentence
503
+ // keeps its shape (`"bob@x.com")` becomes `"a name with an @ in it")`).
504
+ export const hideAddresses = (text) => (typeof text === 'string'
505
+ ? text.replace(/\S+/g, (w) => {
506
+ const [, lead, core, trail] = w.match(/^([`"'([<]*)(.*?)([`"')\]>,;:.!?]*)$/);
507
+ return core.indexOf('@', 1) >= 0 ? `${lead}${shown('x@x')}${trail}` : w;
508
+ })
509
+ : text);
510
+
511
+ // The answer as `--json` prints it: every string in it passed through `hideAddresses`.
512
+ export function protectionJSON(r) {
513
+ const walk = (v) => (typeof v === 'string' ? hideAddresses(v) : Array.isArray(v) ? v.map(walk)
514
+ : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])) : v);
515
+ return walk(r);
516
+ }
517
+
518
+ export function protectionLine(r, opts = {}) {
519
+ const l = lineFor(r, opts);
520
+ return { ...l, message: hideAddresses(l.message), ...(l.hint ? { hint: hideAddresses(l.hint) } : {}) };
521
+ }
522
+
523
+ function lineFor(r, { name, solo = false } = {}) {
524
+ const P = PLATFORM_NAME[r.platform] || 'the platform';
525
+ const br = !r.branch ? 'the default branch' : (shown(r.branch) === r.branch ? `\`${r.branch}\`` : shown(r.branch));
526
+ const where = r.repo ? `${P} ${shown(r.repo)}` : P;
527
+ const other = r.platformDefault && shown(r.platformDefault) === r.platformDefault ? `\`${r.platformDefault}\`` : shown(r.platformDefault);
528
+ const note = r.branchFrom === 'platform'
529
+ ? `yad's files name no default branch, so ${P}'s default was read`
530
+ : (r.platformDefault && r.branch && r.platformDefault !== r.branch ? `${P}'s own default branch is ${other}` : '');
531
+ const branchNote = note ? ` (${note})` : '';
532
+ const team = solo ? 'ok' : 'warn';
533
+ const unread = `ask someone who can see ${P}'s settings for ${br} about what yad could not read`;
534
+ const Unread = `${unread[0].toUpperCase()}${unread.slice(1)}`;
535
+ const settleIt = `ask someone who can see ${P}'s settings for ${br}`;
536
+ // The platform's own name for the setting that requires an approval (named, never explained: that is
537
+ // documentation's job, not the doctor's).
538
+ const setting = r.platform === 'gitlab'
539
+ ? `an approval rule (GitLab Premium or Ultimate) for ${br}`
540
+ : `${P}'s branch protection or a ruleset for ${br}`;
541
+ if (!r.known) {
542
+ return { status: team, message: `${name}: not known whether ${br} requires an approval${branchNote} — ${r.why}`, hint: unknownHint(r) };
543
+ }
544
+ // EVERY SENTENCE BELOW STATES A FIELD THE READER SET, and nothing derived from one: who may merge or
545
+ // push is not read, so no line says who can merge, or that the platform "holds" anything (review round 3).
546
+ // Approvals asked only for some files are facts beside the count: said when true, said as not known when
547
+ // null, and left out only when proven false.
548
+ const scoped = [
549
+ ...(r.codeOwners === true ? ['a code owner must approve a change to a file CODEOWNERS lists'] : []),
550
+ ...(r.fileReviewers === true ? ['a named reviewer must approve a change to some files'] : []),
551
+ ];
552
+ const unknownScoped = [
553
+ ...(r.codeOwners === null ? ['whether a code owner must approve some files is not known'] : []),
554
+ ...(r.fileReviewers === null ? ['whether a named reviewer must approve some files is not known'] : []),
555
+ ];
556
+ // "also" only where something else is already required — an approval count.
557
+ const clauses = (withAlso) => {
558
+ const all = [...(withAlso ? scoped.map((x) => x.replace(' must ', ' must also ')) : scoped), ...unknownScoped];
559
+ return all.length ? `; ${all.join('; ')}` : '';
560
+ };
561
+ const owners = clauses(true);
562
+ const from = `(from: ${r.from.join('; ')})`;
563
+ const request = REQUEST[r.platform] || 'pull request';
564
+ if (r.approvals > 0) {
565
+ const n = `${r.atLeast ? 'at least ' : ''}${r.approvals} approval${r.approvals === 1 ? '' : 's'}`;
566
+ // Who may bypass a rule is not read, and neither is GitLab's "an author may approve" setting, so the
567
+ // solo line says what stands in the way, and what yad could not read about it.
568
+ const own = r.platform === 'gitlab'
569
+ ? 'GitLab may not let you approve your own merge request (a project setting yad does not read), so the merge may be blocked'
570
+ : 'you cannot approve your own pull request, so the merge is blocked unless you may bypass the rule (who may bypass is not read)';
571
+ // A rule on a branch that is NOT protected covers a merge request only: a direct push skips it. (GitLab:
572
+ // a rule with no branch listed covers every branch. On GitHub a count always comes with protection.)
573
+ if (r.protected === false) {
574
+ return {
575
+ status: 'warn',
576
+ message: `${name}: ${br} is not protected on ${where}${branchNote} — anyone with write access can push to it directly, with no ${request}; a ${request} into it needs ${n} ${from}${owners}${solo ? `; and ${own}` : ''}`,
577
+ // …and a part that went unread is pointed at here too, as on every other partly read line.
578
+ // No `unknownScoped` here: this arm is GitLab-only, where a branch proven unprotected always has
579
+ // its code-owner fact settled and carries no named-reviewer fact at all.
580
+ hint: `${solo ? `relax the required approvals in ${setting}` : `protecting ${br} in ${P}'s settings limits who may push to it directly; yad only reports what is set`}${r.partlyRead ? `. ${Unread}` : ''}`,
581
+ };
582
+ }
583
+ // Whether the branch is protected may be unknown beside a count that was read: say so, never silently.
584
+ const unsure = r.protected === null ? `; whether the branch is protected is not known — ${r.protectedWhy}` : '';
585
+ if (solo) {
586
+ return {
587
+ status: 'warn',
588
+ message: `${name}: solo mode, but a ${request} into ${br} on ${where}${branchNote} needs ${n} ${from}${owners}${unsure}${unsure ? '; and ' : ' — '}${own}`,
589
+ // …and what went unread, or who can settle a protection that is not known, as the team twins say.
590
+ hint: `relax the required approvals in ${setting}${unknownScoped.length || r.partlyRead ? `. ${Unread}` : (unsure ? `. ${settleIt[0].toUpperCase()}${settleIt.slice(1)}` : '')}`,
591
+ };
592
+ }
593
+ if (unsure) {
594
+ // The protection is not known because two answers disagree, not because a call failed — so the
595
+ // pointer to "what yad could not read" belongs here only when something really was not read. Both
596
+ // terms are needed: a rule that sets a scoped fact makes it TRUE, not unknown, while its count is
597
+ // still unread (review 18).
598
+ const why = unknownScoped.length || r.partlyRead ? unread : settleIt;
599
+ return { status: 'warn', message: `${name}: a ${request} into ${br} on ${where}${branchNote} needs ${n} ${from}${owners}${unsure}`, hint: why };
600
+ }
601
+ const said = { status: 'ok', message: `${name}: a ${request} into ${br} on ${where}${branchNote} needs ${n} ${from}${owners} — yad reports this and enforces nothing` };
602
+ // A line that is partly unread carries its hint, as every other partly unread line does.
603
+ return unknownScoped.length || r.partlyRead ? { ...said, hint: unread } : said;
604
+ }
605
+ if (r.approvals === 0 && r.protected === false && r.rulesElsewhere) {
606
+ // The platform DID return approval rules; they do not reach this branch. "No approval rules" would be
607
+ // false, so the banner is not printed, and the sentence says which way they miss it.
608
+ const only = scoped.length ? `; only some changes need an approval (${scoped.join('; ')})` : '';
609
+ const many = !r.rulesElsewhereOne;
610
+ // No `unknownScoped` clause: this sentence is GitLab-only and needs `protected === false`, where the
611
+ // reader always proves `codeOwners` and never sets `fileReviewers`.
612
+ const msg = `${name}: ${br} is not protected on ${where}${branchNote} — anyone with write access can push to it directly, and the approval rule${many ? 's' : ''} ${P} has miss${many ? '' : 'es'} it: ${r.rulesElsewhere}, so ${many ? 'none of them holds' : 'it does not hold'} a merge into it${only}`;
613
+ return solo ? { status: 'ok', message: msg } : { status: 'warn', message: msg, hint: `protecting ${br} in ${P}'s settings limits who may push to it directly; only ${P} can require an approval before a merge, in ${setting}; yad only reports what is set` };
614
+ }
615
+ if (r.approvals === 0 && r.protected === null) {
616
+ // The count was read; the protection was not. Both are said, and neither borrows the other's answer.
617
+ // A colon, not a dash: this sentence already ends with a dash before the reason, and two dashes at
618
+ // one level let a reader take the middle as an aside.
619
+ const only = scoped.length ? ' on every change: only some changes need an approval (' + scoped.join('; ') + ')' : '';
620
+ const msg = `${name}: no rule on ${where}${branchNote} requires an approval to merge into ${br}${only || (unknownScoped.length ? ' on every change' : '')}${unknownScoped.length ? `; ${unknownScoped.join('; ')}` : ''}; whether the branch is protected is not known — ${r.protectedWhy}`;
621
+ // The PROTECTION read did not fail here — the platform gave two answers that disagree — so the hint
622
+ // names who can settle it. A scoped fact that could not be read keeps its own pointer. (A count of 0
623
+ // is only ever set from a complete read, so `partlyRead` is false on this path and is not tested.)
624
+ const ask = unknownScoped.length ? unread : settleIt;
625
+ return { status: solo ? 'ok' : 'warn', message: msg, hint: ask };
626
+ }
627
+ if (r.approvals === 0 && r.protected === false) {
628
+ // A rule for SOME files is still an approval rule (round 4), so the banner's "no approval rules" half
629
+ // is not proven while a scoped fact is true: that case gets its own sentence instead.
630
+ if (scoped.length) {
631
+ const msg = `${name}: ${br} is not protected on ${where}${branchNote} — anyone with write access can push to it directly; no rule requires an approval on every change, and only some changes need one (${scoped.join('; ')})`;
632
+ return solo ? { status: 'ok', message: msg } : { status: 'warn', message: msg, hint: `protecting ${br} in ${P}'s settings limits who may push to it directly; only ${P} can require an approval before a merge, in ${setting}; yad only reports what is set` };
633
+ }
634
+ if (solo) return { status: 'ok', message: `${name}: ${where} has no approval rules and no branch protection on ${br}${branchNote} — expected in solo mode; yad records what happens, but cannot stop anything here` };
635
+ return { status: 'warn', message: `${name} (${where}, branch ${br}${note ? `; ${note}` : ''}): ${BANNER}`, hint: `only ${P} can require an approval before a merge, in ${setting}; yad only reports what is set` };
636
+ }
637
+ if (r.approvals === 0 && r.protected === true) {
638
+ // "on every change": a code-owner or named-reviewer rule IS a rule that requires an approval, for the
639
+ // files it names — so the plain "no rule requires an approval" would contradict the clause after it.
640
+ // "on every change" whenever a rule for SOME files is true OR could not be read: either way, "no rule
641
+ // requires an approval" on its own would claim more than the reader knows.
642
+ const only = scoped.length ? ` — only some changes need one (${scoped.join('; ')})` : '';
643
+ const msg = `${name}: ${br} is protected on ${where}${branchNote}, but no rule requires an approval${scoped.length || unknownScoped.length ? ' on every change' : ''}${only}${unknownScoped.length ? `; ${unknownScoped.join('; ')}` : ''}`;
644
+ // A line that is partly unread keeps its hint in solo mode, as every could-not-read line does.
645
+ const advice = `only ${P} can require an approval before a merge, in ${setting}; yad only reports what is set`;
646
+ const hint = unknownScoped.length ? `${advice}. ${unread[0].toUpperCase()}${unread.slice(1)}` : advice;
647
+ if (solo) return unknownScoped.length ? { status: 'ok', message: msg, hint } : { status: 'ok', message: msg };
648
+ return { status: 'warn', message: msg, hint };
649
+ }
650
+ // approvals not known
651
+ if (r.protected === false) {
652
+ const msg = `${name}: ${br} is not protected on ${where}${branchNote}: anyone with write access can push to it directly; whether a merge needs an approval is not known — ${r.approvalsWhy}${clauses(false)}`;
653
+ const why = r.platform === 'gitlab' && r.rulesRefused
654
+ ? `on GitLab Free an approval never blocks a merge; on Premium or Ultimate, a Maintainer can see the approval rules for ${br} in the project's merge request settings`
655
+ : unread;
656
+ // A line that could not be read keeps its hint in solo mode too: it names how to read it.
657
+ return { status: solo ? 'ok' : 'warn', message: msg, hint: why };
658
+ }
659
+ const msg = r.protected === true
660
+ ? `${name}: ${br} is protected on ${where}${branchNote}, but whether a merge needs an approval is not known — ${r.approvalsWhy}${clauses(false)}`
661
+ : `${name}: whether ${br} is protected on ${where}${branchNote} is not known (${r.protectedWhy}), and neither is whether a merge needs an approval — ${r.approvalsWhy}${clauses(false)}`;
662
+ const hint = r.platform === 'gitlab'
663
+ // The twins say the same thing: a refusal leaves the tier open, and every other answer left something
664
+ // unread, which is what the reader needs pointed at.
665
+ ? (r.rulesRefused
666
+ ? `on GitLab Free an approval never blocks a merge; on Premium or Ultimate, a Maintainer can see the approval rules for ${br} in the project's merge request settings`
667
+ : unread)
668
+ : `ask a repo admin to check the required approvals for ${br} in ${P}'s settings`;
669
+ return { status: solo ? 'ok' : 'warn', message: msg, hint };
670
+ }
671
+
672
+ // The two actions for a GitLab repository yad could not read (E109's `no-repository`) or may not be able
673
+ // to (E110's hidden default): turned off — an Owner turns it on, or a non-owner asks for that; access too
674
+ // low — a Maintainer or Owner grants it. ONE string for both hints, so the twins cannot drift apart (they
675
+ // did once: E110's review).
676
+ const REPO_ACTIONS = 'if you own the GitLab project and its repository is turned off, turn it on in the project\'s settings; otherwise ask a Maintainer or Owner of the project to give your login access to its repository, or to turn it on';
677
+
678
+ function unknownHint(r) {
679
+ const cli = cliFor(r.platform);
680
+ switch (r.kind) {
681
+ case 'off': return 'unset YAD_PLATFORM_READ to let `yad doctor` ask the platform';
682
+ case 'no-cli': return `install ${cli} and log in, then run \`yad doctor\` again`;
683
+ case 'no-login': return `run \`${cli} auth login --hostname ${r.host}\`, then \`yad doctor\` again`;
684
+ case 'no-platform': return 'set `platform` (github or gitlab) in yad\'s files — `yad setup` for the Product, `yad repo connect` for a code repo';
685
+ case 'no-url': return 'add `git_url` to the repo\'s entry in .sdlc/repos.json (.sdlc/product.json, or hub.json, for the Product), or give the repo an origin remote';
686
+ // The access tail turns on what the read PROVED (`cause`), never on the platform: a 404 whose body was
687
+ // not recognised leaves a permission open, and one that named the branch closes it (E109).
688
+ // The tail's noun is GitLab's: GitHub never reaches it, because its reader sets `cause: 'branch'` on
689
+ // every branch 404.
690
+ case 'no-branch': return `check that \`default_branch\` in yad's files names a branch that exists on the platform${r.cause === 'branch' ? '' : ', or ask for access to the project\'s repository'}`;
691
+ // One action per open cause: the repository is turned off (an Owner — perhaps you — turns it on), or
692
+ // the login's access is too low (a Maintainer or Owner raises it).
693
+ case 'no-repository': return `${REPO_ACTIONS} — then run \`yad doctor\` again`;
694
+ case 'empty': return r.cause === 'branch'
695
+ ? 'the platform names this default branch, but it has no commits yet — push a first commit, then run `yad doctor` again'
696
+ : 'the platform names this default branch, but it has no commits yet (or your login cannot see it) — push a first commit, or ask for access to the project\'s repository, then run `yad doctor` again';
697
+ case 'no-flag': return `ask someone who can see ${PLATFORM_NAME[r.platform] || 'the platform'}'s settings for ${shown(r.branch) === r.branch ? `\`${r.branch}\`` : shown(r.branch)}`;
698
+ // One action per open cause (E110): yad's files name no branch, and on GitLab the repository may be
699
+ // unreadable — turned off (an Owner turns it on) or too little access (a Maintainer or Owner grants it).
700
+ case 'no-default': return `set \`default_branch\` in yad's files (.sdlc/repos.json, or .sdlc/product.json — hub.json on an older Product)${r.defaultMayBeHidden
701
+ ? `. GitLab also hides the default from a login that cannot read the repository: ${REPO_ACTIONS} — then run \`yad doctor\` again`
702
+ : ''}`;
703
+ case 'no-repo': return `check \`git_url\` in yad's files, or ask for access to the ${r.platform === 'gitlab' ? 'project' : 'repo'}`;
704
+ default: return 'fix what the message names, then run `yad doctor` again; nothing about this repo\'s protection is assumed meanwhile';
705
+ }
706
+ }