yadflow 3.19.0-next.2 → 4.0.0-next.3

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 (158) hide show
  1. package/CHANGELOG.md +371 -0
  2. package/README.md +160 -22
  3. package/bin/commands.mjs +46 -0
  4. package/bin/yad.mjs +506 -117
  5. package/cli/artifact-status.mjs +30 -11
  6. package/cli/capture.mjs +417 -0
  7. package/cli/checkpoint.mjs +47 -27
  8. package/cli/claims.mjs +165 -0
  9. package/cli/codeowners-command.mjs +170 -0
  10. package/cli/codeowners.mjs +397 -0
  11. package/cli/commit.mjs +13 -9
  12. package/cli/dial.mjs +183 -0
  13. package/cli/docs.mjs +88 -32
  14. package/cli/doctor.mjs +1682 -90
  15. package/cli/epic-state.mjs +3460 -211
  16. package/cli/epic.mjs +506 -0
  17. package/cli/errors.mjs +3 -0
  18. package/cli/fold.mjs +247 -0
  19. package/cli/gate-local.mjs +296 -0
  20. package/cli/gate.mjs +977 -183
  21. package/cli/history.mjs +556 -0
  22. package/cli/hook.mjs +264 -53
  23. package/cli/hubcommit.mjs +9 -19
  24. package/cli/index-command.mjs +87 -0
  25. package/cli/ledger.mjs +54 -4
  26. package/cli/lib.mjs +161 -9
  27. package/cli/manifest.mjs +376 -38
  28. package/cli/migrate.mjs +683 -52
  29. package/cli/mode.mjs +170 -0
  30. package/cli/next.mjs +365 -86
  31. package/cli/openpr.mjs +193 -34
  32. package/cli/owners.mjs +266 -0
  33. package/cli/people.mjs +657 -0
  34. package/cli/plan.mjs +466 -125
  35. package/cli/platform.mjs +109 -128
  36. package/cli/product-index.mjs +287 -0
  37. package/cli/protection.mjs +706 -0
  38. package/cli/reconcile.mjs +40 -12
  39. package/cli/repo-publish.mjs +24 -26
  40. package/cli/repo.mjs +23 -14
  41. package/cli/report.mjs +24 -14
  42. package/cli/review.mjs +19 -22
  43. package/cli/riskmap-command.mjs +289 -0
  44. package/cli/riskmap.mjs +373 -0
  45. package/cli/setup.mjs +127 -279
  46. package/cli/ship.mjs +6 -5
  47. package/cli/skill.mjs +180 -0
  48. package/cli/skip.mjs +211 -30
  49. package/cli/thread.mjs +41 -16
  50. package/cli/tidy.mjs +18 -18
  51. package/cli/update-commit.mjs +21 -21
  52. package/cli/usage.mjs +115 -109
  53. package/package.json +3 -3
  54. package/skills/sdlc/config.yaml +149 -70
  55. package/skills/sdlc/module-help.csv +24 -24
  56. package/skills/yad-analysis/SKILL.md +121 -59
  57. package/skills/yad-architecture/SKILL.md +30 -18
  58. package/skills/yad-architecture/references/contract-format.md +8 -5
  59. package/skills/yad-backfill/SKILL.md +10 -4
  60. package/skills/yad-backfill/references/backfill.md +8 -1
  61. package/skills/yad-backfill/templates/checks/backfill-check.sh +10 -2
  62. package/skills/yad-change/SKILL.md +119 -42
  63. package/skills/yad-change/references/triage.md +42 -27
  64. package/skills/yad-checks/SKILL.md +68 -35
  65. package/skills/yad-checks/references/check-gates.md +310 -85
  66. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  67. package/skills/yad-checks/templates/checks/contract-check.sh +130 -9
  68. package/skills/yad-checks/templates/checks/epic-open.sh +2 -2
  69. package/skills/yad-checks/templates/checks/ledger-guard.sh +55 -8
  70. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  71. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +1 -1
  72. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  73. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  74. package/skills/yad-checks/templates/github/yad-checks.yml +13 -2
  75. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  76. package/skills/yad-checks/templates/github/yad-update-guard.yml +7 -5
  77. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  78. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +10 -1
  79. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  80. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +3 -3
  81. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  82. package/skills/yad-checks/templates/hooks/ledger-guard.sh +37 -6
  83. package/skills/yad-checks/templates/hooks/yad-capture.sh +53 -0
  84. package/skills/yad-commit/SKILL.md +4 -4
  85. package/skills/yad-connect-design/SKILL.md +5 -5
  86. package/skills/yad-connect-design/references/design-context.md +1 -1
  87. package/skills/yad-connect-design/references/design-registry.md +2 -2
  88. package/skills/yad-connect-docs/SKILL.md +11 -11
  89. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  90. package/skills/yad-connect-learning/SKILL.md +5 -5
  91. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  92. package/skills/yad-connect-repos/SKILL.md +66 -45
  93. package/skills/yad-connect-repos/references/code-context.md +4 -4
  94. package/skills/yad-connect-repos/references/hub-config.md +47 -51
  95. package/skills/yad-connect-repos/references/repos-registry.md +8 -7
  96. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  97. package/skills/yad-connect-testing/SKILL.md +5 -5
  98. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  99. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  100. package/skills/yad-defects/SKILL.md +7 -7
  101. package/skills/yad-discovery/SKILL.md +131 -94
  102. package/skills/yad-discovery/references/discovery-schema.md +22 -6
  103. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  104. package/skills/yad-docs/SKILL.md +13 -8
  105. package/skills/yad-docs/references/data-mapping.md +9 -7
  106. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  107. package/skills/yad-docs-overview/SKILL.md +30 -15
  108. package/skills/yad-docs-overview/references/pipeline-model.md +33 -20
  109. package/skills/yad-docs-sync/SKILL.md +10 -5
  110. package/skills/yad-docs-sync/references/staleness.md +8 -7
  111. package/skills/yad-engineer-review/SKILL.md +81 -17
  112. package/skills/yad-engineer-review/references/ship-and-record.md +17 -8
  113. package/skills/yad-epic/SKILL.md +173 -92
  114. package/skills/yad-epic/references/state-schema.md +610 -94
  115. package/skills/yad-hub-bridge/SKILL.md +57 -38
  116. package/skills/yad-hub-bridge/references/bridge.md +94 -67
  117. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  118. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  119. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  120. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  121. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +21 -11
  122. package/skills/yad-implement/SKILL.md +27 -13
  123. package/skills/yad-implement/references/implement-conventions.md +2 -2
  124. package/skills/yad-learn/SKILL.md +9 -9
  125. package/skills/yad-learn/references/learning-state.md +2 -2
  126. package/skills/yad-open-pr/SKILL.md +61 -26
  127. package/skills/yad-pair-review/SKILL.md +7 -5
  128. package/skills/yad-pair-review/references/session-state.md +1 -1
  129. package/skills/yad-pr-template/SKILL.md +47 -26
  130. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  131. package/skills/yad-pr-template/templates/checks/pr-template.sh +34 -11
  132. package/skills/yad-pr-template/templates/checks/pr-title.sh +32 -9
  133. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  134. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  135. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  136. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +14 -13
  137. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +14 -12
  138. package/skills/yad-reconcile/SKILL.md +3 -3
  139. package/skills/yad-report/SKILL.md +5 -5
  140. package/skills/yad-review-companion/SKILL.md +6 -3
  141. package/skills/yad-review-gate/SKILL.md +203 -93
  142. package/skills/yad-review-gate/references/gating.md +232 -54
  143. package/skills/yad-run/SKILL.md +81 -51
  144. package/skills/yad-run/references/run-loop.md +61 -39
  145. package/skills/yad-ship/SKILL.md +15 -11
  146. package/skills/yad-spec/SKILL.md +21 -6
  147. package/skills/yad-spec/references/spec-handoff.md +17 -5
  148. package/skills/yad-status/SKILL.md +115 -50
  149. package/skills/yad-stories/SKILL.md +37 -21
  150. package/skills/yad-stories/references/story-schema.md +7 -6
  151. package/skills/yad-stub/SKILL.md +58 -47
  152. package/skills/yad-sync-repos/SKILL.md +2 -2
  153. package/skills/yad-test-cases/SKILL.md +32 -23
  154. package/skills/yad-test-cases/references/test-cases-schema.md +7 -4
  155. package/skills/yad-timeline/SKILL.md +8 -7
  156. package/skills/yad-ui/SKILL.md +41 -18
  157. package/cli/roster.mjs +0 -164
  158. package/skills/sdlc/install.sh +0 -68
@@ -0,0 +1,373 @@
1
+ // The risk map (E65): one file per code repo, `.sdlc/risk-map`, that gives each directory a risk LEVEL —
2
+ // `high`, `medium` or `low`. No names, no roles, no topic tags. Pure: no git, no filesystem. Callers pass
3
+ // in the map's text and the repo's file list, so every rule here is testable with plain strings.
4
+ //
5
+ // WHAT IT IS FOR. Part 3's escalation needs to know which code is risky. A hard-coded guess from file
6
+ // names (`RISK_PATTERNS`, cli/walkthrough.mjs) breaks on every naming habit, so the team keeps its own
7
+ // answer, one line per directory. E66 turns a `high` directory into the high step (+1), read from the
8
+ // map on the BASE branch (`changeLevel`); E67 asks who has committed to it. The step is REPORTED and
9
+ // never holds a merge: a Build merge is held by the platform's branch protection, and the capacity cap
10
+ // (E72) is shown on the Build half but not enforced there (the user's decision, 2026-09-21).
11
+ //
12
+ // WHO WRITES IT. `yad risk-map draft` adds a line for every directory nothing covers, as `unset`. The
13
+ // `yad-connect-repos` skill has an AI agent read the code and fill each `unset` line with a level, a
14
+ // reason and `guessed`. A person turns `guessed` into `confirmed` in a PR, so the commit records who.
15
+ // It is the team's file: it is never wired, never hash-managed, and `yad update` never touches it.
16
+ //
17
+ // THE FORMAT is lines, so the PR check (`checks/risk-map-check.sh`) reads it in bash with no tools:
18
+ //
19
+ // # yad-risk-map v1
20
+ // src/payments/ high confirmed # charge.js calls the card processor
21
+ // src/catalog/ low guessed # read-only product listing
22
+ // docs/ unset
23
+ // ./ low confirmed # the files at the repo root
24
+ //
25
+ // - Fields split on spaces and tabs only, so a path holds neither. A comment starts at the first `#` that
26
+ // follows a space or tab, and is stripped before the fields are read — a `#` glued to a word is part of it.
27
+ // - A directory ends in `/` and is written from the repo root. `./` means the files AT the root, not
28
+ // every file below it: there is no catch-all line, so a new directory can never hide under one.
29
+ // - The deepest listed directory above a file decides its level.
30
+ // - `unset` means listed but not classified; its state column may be left out.
31
+ // - Everything here has a twin in `checks/risk-map-check.sh`. Change one and change the other: a test
32
+ // runs both over the same repos and compares what they report.
33
+
34
+ export const RISK_MAP_FILE = '.sdlc/risk-map';
35
+ export const RISK_MAP_VERSION = 1;
36
+ export const RISK_LEVELS = ['high', 'medium', 'low'];
37
+ const LEVEL_WORDS = [...RISK_LEVELS, 'unset'];
38
+ const STATES = ['guessed', 'confirmed'];
39
+
40
+ const HEADER = /^#[ \t]*yad-risk-map[ \t]+v([0-9]+)[ \t]*$/;
41
+ // Only spaces and tabs separate or surround fields — never the wider whitespace `String.trim` removes —
42
+ // so this and the awk twin read a form feed or a non-breaking space the same way.
43
+ const trimST = (s) => s.replace(/^[ \t]+|[ \t]+$/g, '');
44
+ // A word starting `@alice` names a person. A map holds no people, and CODEOWNERS lines pasted into it
45
+ // look exactly like this — `@org/team` included. A word ENDING in `/` is a directory (`@types/`), and
46
+ // `user@example.com` is not matched: the `@` does not start the word.
47
+ const namesSomeone = (line) => line.split(/[ \t]+/).some((w) => /^@[A-Za-z0-9_-]/.test(w) && !w.endsWith('/'));
48
+ // The version on a header as written, leading zeros dropped, so `v01` is 1 and `v0` is 0 in both twins.
49
+ const versionText = (digits) => digits.replace(/^0+(?=.)/, '');
50
+
51
+ // A directory as a line may name it: `./`, or one or more segments each ending in `/`. No leading `/`,
52
+ // no `.` or `..` segment, no glob characters (it is a directory, not a pattern), no backslash, no space,
53
+ // tab, CR or newline (they split fields or lines), and no leading `#` (that line is a comment).
54
+ export function validDir(dir) {
55
+ if (dir === './') return true;
56
+ if (dir.startsWith('#') || !/^([^/*?[\\ \t\r\n]+\/)+$/.test(dir)) return false;
57
+ return !dir.slice(0, -1).split('/').some((seg) => seg === '.' || seg === '..');
58
+ }
59
+
60
+ // Parse the map's text. Never throws. Returns:
61
+ // version 1 — or, when `supported` is false, the header's version as written (digits, leading zeros
62
+ // dropped: a string, so a huge number prints the same in the awk twin)
63
+ // header false when the first non-blank line is not the header
64
+ // supported false when the header names a version this release cannot read — then `entries` is empty,
65
+ // because reading a newer file by older rules could invent levels it never said
66
+ // entries [{ dir, level, state, reason, line }] in file order, first line wins on a duplicate
67
+ // problems [{ code, line, message }] — `unreadable` for a line that is not an entry, `names` for an
68
+ // `@login`, `duplicate` for a directory listed twice
69
+ export function parseRiskMap(text) {
70
+ const out = { version: RISK_MAP_VERSION, header: false, supported: true, entries: [], problems: [] };
71
+ const lines = String(text ?? '').split('\n');
72
+ const seen = new Set();
73
+ let first = true;
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const n = i + 1;
76
+ const line = lines[i].replace(/\r$/, '');
77
+ if (/^[ \t]*$/.test(line)) continue;
78
+ if (first) {
79
+ first = false;
80
+ const h = HEADER.exec(line);
81
+ if (h) {
82
+ out.header = true;
83
+ out.version = versionText(h[1]);
84
+ if (out.version !== String(RISK_MAP_VERSION)) { out.supported = false; return out; }
85
+ out.version = RISK_MAP_VERSION;
86
+ continue;
87
+ }
88
+ }
89
+ if (namesSomeone(line)) out.problems.push({ code: 'names', line: n, message: 'names a person (`@…`) — the map holds directories and levels only' });
90
+ if (/^[ \t]*#/.test(line)) continue;
91
+ const hash = /[ \t]#/.exec(line);
92
+ const body = hash ? line.slice(0, hash.index) : line;
93
+ const reason = hash ? trimST(line.slice(hash.index + 2)) : '';
94
+ const f = trimST(body).split(/[ \t]+/);
95
+ const bad = (why) => out.problems.push({ code: 'unreadable', line: n, message: why });
96
+ if (f.length < 2 || f.length > 3) { bad('expected `<dir>/ <level> <guessed|confirmed>`'); continue; }
97
+ const [dir, level, state = null] = f;
98
+ if (!validDir(dir)) { bad(`\`${dir}\` is not a directory from the repo root ending in \`/\``); continue; }
99
+ if (!LEVEL_WORDS.includes(level)) { bad(`\`${level}\` is not a level (high, medium, low or unset)`); continue; }
100
+ if (level === 'unset' && state === 'confirmed') { bad('an `unset` line cannot be `confirmed`'); continue; }
101
+ if (level === 'unset' ? state !== null && state !== 'guessed' : !STATES.includes(state)) {
102
+ bad('the third field must be `guessed` or `confirmed`');
103
+ continue;
104
+ }
105
+ if (seen.has(dir)) { out.problems.push({ code: 'duplicate', line: n, message: `\`${dir}\` is listed again — the first line wins` }); continue; }
106
+ seen.add(dir);
107
+ out.entries.push({ dir, level, state: level === 'unset' ? null : state, reason, line: n });
108
+ }
109
+ if (!first && !out.header) {
110
+ out.problems.unshift({ code: 'header', line: 0, message: 'no `# yad-risk-map v1` line at the top — read as version 1' });
111
+ }
112
+ return out;
113
+ }
114
+
115
+ // The line that decides a file's level: the deepest listed directory above it, or `./` for a file at the
116
+ // root. null when no line covers it.
117
+ export function coverOf(entries, file) {
118
+ if (!file.includes('/')) return entries.find((e) => e.dir === './') || null;
119
+ let best = null;
120
+ for (const e of entries) {
121
+ if (e.dir !== './' && file.startsWith(e.dir) && (!best || e.dir.length > best.dir.length)) best = e;
122
+ }
123
+ return best;
124
+ }
125
+
126
+ // E66 — ESCALATE BY COUNT. The approvals a level adds: `high` is the high step, +1 — the same step and
127
+ // the same tier name `gateRuleFor` gives an `auth` or `payments` tag (cli/epic-state.mjs), so the two
128
+ // vocabularies print one sum (`base 1 + high risk 1`). `medium` is reported only; `low` adds nothing. A
129
+ // test pins `high` to `auth`'s step, so moving one without the other fails.
130
+ export const LEVEL_STEP = { high: 1, medium: 0, low: 0 };
131
+ const LEVEL_RANK = { low: 1, medium: 2, high: 3 };
132
+
133
+ // The level ONE change takes from a map: the highest level among the lines that decide the files it
134
+ // changes. Pure — the caller reads the map from the BASE branch, so a change cannot lower its own count
135
+ // by editing the map (E66). `changed` should hold every path the change adds, edits, deletes or moves
136
+ // away from: deleting code in a `high` directory is a `high` change. Returns:
137
+ // level 'high' | 'medium' | 'low', or null when no touched line has a level
138
+ // step the approvals that level adds (`LEVEL_STEP`)
139
+ // dirs [{ dir, level, state }] for every touched line with a level, in map order
140
+ // Rules the roadmap closed: a `guessed` level counts exactly as a `confirmed` one (it can only ask for
141
+ // one more human, never let a gate pass); an `unset` line and a file no line covers add nothing (they
142
+ // are warned about, never counted as `high`). A map for a newer version was not read, so its level is
143
+ // unknown — `unsupported: true`, which a caller must say, never read as "nothing is high".
144
+ // The twin is the `--level` mode of `checks/risk-map-check.sh`; the parity test compares the two.
145
+ export function changeLevel(parsed, changed = []) {
146
+ if (!parsed.supported) return { level: null, step: 0, dirs: [], unsupported: true };
147
+ const touched = new Set();
148
+ for (const f of changed) {
149
+ const e = coverOf(parsed.entries, f);
150
+ if (e && e.level !== 'unset') touched.add(e);
151
+ }
152
+ const dirs = parsed.entries.filter((e) => touched.has(e)).map(({ dir, level, state }) => ({ dir, level, state }));
153
+ let level = null;
154
+ for (const d of dirs) if (!level || LEVEL_RANK[d.level] > LEVEL_RANK[level]) level = d.level;
155
+ return { level, step: level ? LEVEL_STEP[level] : 0, dirs };
156
+ }
157
+
158
+ // E67 — ESCALATE BY PROVEN HISTORY. Who has worked in the `high` directories a change touches, lately.
159
+ //
160
+ // The platform login a noreply commit address carries — `12345+octocat@users.noreply.github.com`,
161
+ // `octocat@users.noreply.github.com`, `12345-tanuki@users.noreply.gitlab.com` — else null. The address
162
+ // itself is never emitted; only the login, which the ledgers already record. The login keeps the case
163
+ // the address carries. It lives here, not in cli/usage.mjs (which re-exports it), because the awk twin
164
+ // in `checks/risk-map-check.sh` derives the same login and a test compares the two.
165
+ export function loginFromEmail(email) {
166
+ const e = String(email || '');
167
+ const gh = e.match(/^(?:\d+\+)?([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)@users\.noreply\.github\.com$/i);
168
+ if (gh) return gh[1];
169
+ const gl = e.match(/^\d+-([a-z0-9._-]+)@users\.noreply\.gitlab\.com$/i);
170
+ return gl ? gl[1] : null;
171
+ }
172
+
173
+ // A commit whose author is a robot, not a person: an approval can never come from one. GitHub writes
174
+ // `dependabot[bot]` as the name and `…[bot]@users.noreply.github.com` as the address; the `[` also makes
175
+ // the login rule above reject it, so without this it would be listed by name as if it could approve.
176
+ export const isBot = (name, email) => /\[bot\]$/i.test(String(name || '').trim()) || /\[bot\]@/i.test(String(email || ''));
177
+
178
+ // The `high` directories this change touches, in map order. One git query runs per directory.
179
+ export function highTouched(entries, changed) {
180
+ const hit = new Set();
181
+ for (const f of changed) { const e = coverOf(entries, f); if (e && e.level === 'high') hit.add(e); }
182
+ return entries.filter((e) => hit.has(e));
183
+ }
184
+
185
+ // The git pathspecs that mean "this directory, as the MAP sees it". Git then applies the map's cover
186
+ // rule itself, and nothing has to read a list of file names back out of git — which is what made a
187
+ // quoted path, a path holding a newline, or a merge's simplified path list able to name the wrong
188
+ // person. Every listed directory strictly below `dir` is excluded, whatever its level: the deepest
189
+ // listed line decides, so each of those directories answers for itself in its own query (git lets an
190
+ // exclude beat a later include, so they cannot share one).
191
+ // `./` is the files AT the root: `:(glob)*` matches a top-level entry only, because `*` never spans `/`.
192
+ // Every path built FROM A MAP NAME is `:(literal)` (`./` is the exception: it names no directory, and
193
+ // becomes `:(glob)*`). A directory name may legally start with `:` (`validDir` allows it), and git reads
194
+ // a leading `:` as pathspec MAGIC — `:weird/` would answer about `weird/`, and `:/` about the whole
195
+ // repo. A map name must describe a directory to git, never tell git what to do.
196
+ export function pathspecsFor(entries, dir) {
197
+ if (dir === './') return [':(glob)*'];
198
+ return [`:(literal)${dir}`, ...entries.filter((e) => e.dir !== dir && e.dir.startsWith(dir)).map((e) => `:(exclude,literal)${e.dir}`)];
199
+ }
200
+
201
+ // The people to name, from the author records those queries returned: `commits` is [{ name, email }] in
202
+ // the order git printed them (newest first, one run per directory in map order), and `excludeEmails` the
203
+ // authors of the change itself — an approval has to come from someone else, so their own work never
204
+ // counts. A robot is never a person who can approve. Deduped by address; the address itself never leaves
205
+ // this function (nor does `yad` ever print one) — and neither does a git NAME that is itself an address
206
+ // (`user.name` set to one is a common slip). A printed `@word` must also only ever be a real login, because
207
+ // the engineer review reads a bare `@login` as one: so ANY name holding an `@` (`@dave`, `Dave @dave`) is
208
+ // replaced too. The login stands in, else plain words. The awk twin in checks/risk-map-check.sh applies
209
+ // the same rule with the same character classes, and a test compares them.
210
+ export const NAME_IS_ADDRESS = 'a name that is an e-mail address';
211
+ export const NAME_LIKE_LOGIN = 'a name written like a login';
212
+ export function shownName(name, login) {
213
+ const n = String(name || '');
214
+ if (!n.includes('@')) return name;
215
+ if (login) return `@${login}`;
216
+ return /[^ \t@]@[^ \t@]/.test(n) ? NAME_IS_ADDRESS : NAME_LIKE_LOGIN;
217
+ }
218
+ // How a person prints: the name, then `(@login)` unless the login already stands in for the name.
219
+ export const personLabel = (a) => `${a.name}${a.login && a.name !== `@${a.login}` ? ` (@${a.login})` : ''}`;
220
+ export function recentAuthors(commits, excludeEmails = []) {
221
+ const skip = new Set(excludeEmails.map((e) => String(e).toLowerCase()));
222
+ const seen = new Set();
223
+ const out = [];
224
+ for (const cm of commits) {
225
+ const key = String(cm.email || '').toLowerCase();
226
+ if (seen.has(key) || skip.has(key) || isBot(cm.name, cm.email)) continue;
227
+ seen.add(key);
228
+ const login = loginFromEmail(cm.email);
229
+ out.push({ name: shownName(cm.name, login), login });
230
+ }
231
+ return out;
232
+ }
233
+
234
+ // E68 — the folders a change touches, for the reviewer SUGGESTION: the folder each changed file sits in
235
+ // (`./` for a file at the root), nobody twice, the folder holding the most changed files first (then by
236
+ // name, so the order is the same every run). A folder that holds only subfolders is never one.
237
+ export function touchedFolders(changed) {
238
+ const n = new Map();
239
+ for (const f of changed) {
240
+ const i = f.lastIndexOf('/');
241
+ const dir = i < 0 ? './' : f.slice(0, i + 1);
242
+ n.set(dir, (n.get(dir) || 0) + 1);
243
+ }
244
+ return [...n.keys()].sort((a, b) => n.get(b) - n.get(a) || (a < b ? -1 : a > b ? 1 : 0));
245
+ }
246
+
247
+ // The pathspec for the files DIRECTLY in one folder — never its subfolders, so work in `src/` is not
248
+ // work in every folder below it. A glob, because `:(literal)` cannot stop at one level; so every glob
249
+ // character in the name is escaped, and the name can never tell git what to do (E67's `:weird/` lesson:
250
+ // the magic ends at `)`, and what follows is only a pattern).
251
+ export function folderPathspec(dir) {
252
+ if (dir === './') return ':(glob)*';
253
+ return `:(glob)${dir.replace(/[\\*?[]/g, '\\$&')}*`;
254
+ }
255
+
256
+ // The people to suggest, from ONE log over every touched folder: `recentAuthors`' rules (the change's own
257
+ // authors and robots left out, one row per address), ranked by how many commits each made there, then
258
+ // newest first. A count, because a suggestion list is read top-down and the first name should be the one
259
+ // who has done the most there lately.
260
+ export function rankAuthors(commits, excludeEmails = []) {
261
+ const count = new Map();
262
+ const first = [];
263
+ for (const cm of commits) {
264
+ const key = String(cm.email || '').toLowerCase();
265
+ if (!count.has(key)) first.push(cm);
266
+ count.set(key, (count.get(key) || 0) + 1);
267
+ }
268
+ // One record per address, in git's order (newest first). Each goes through recentAuthors on its own, so
269
+ // the robot and own-author rules are the SAME code E67 runs, never a copy of them.
270
+ const rows = [];
271
+ for (const cm of first) {
272
+ const [p] = recentAuthors([cm], excludeEmails);
273
+ if (!p) continue;
274
+ // Which platform the login belongs to: a GitHub noreply login says nothing about a GitLab account
275
+ // spelled the same, so a caller joins a login to a platform name only when the two match.
276
+ const loginHost = p.login ? (/@users\.noreply\.github\.com$/i.test(cm.email) ? 'github' : 'gitlab') : null;
277
+ rows.push({ ...p, loginHost, commits: count.get(String(cm.email || '').toLowerCase()) });
278
+ }
279
+ // Array.prototype.sort is stable, so equal counts keep git's newest-first order.
280
+ return rows.sort((a, b) => b.commits - a.commits);
281
+ }
282
+
283
+ // The directory to add for a file no line covers. Walk down from the root while some listed line sits
284
+ // deeper under the current directory; the first directory with none below it is the one to add. A file
285
+ // directly inside a directory that only has deeper lines asks for that directory itself. A directory a
286
+ // line cannot hold (`app/[slug]/`, `my docs/`) asks for its parent instead, which covers it; at the top
287
+ // level there is no parent line, so the name comes back as it is and `validDir` says it cannot be written.
288
+ export function dirToAdd(entries, file) {
289
+ const segs = file.split('/');
290
+ if (segs.length === 1) return './';
291
+ let prefix = '';
292
+ for (const seg of segs.slice(0, -1)) {
293
+ const cand = `${prefix}${seg}/`;
294
+ if (!validDir(cand)) return prefix || cand;
295
+ if (!entries.some((e) => e.dir !== cand && e.dir.startsWith(cand))) return cand;
296
+ prefix = cand;
297
+ }
298
+ return prefix;
299
+ }
300
+
301
+ // What to warn about. `files` is every file in the repo; `changed` is the files one change adds or edits.
302
+ // Leave `changed` out to check the whole repo: every file counts as touched, and `map-edited` — a fact
303
+ // about a change, not about a repo — is never said. Each finding is { code, target, message }, in this
304
+ // order: header / version, unreadable / names / duplicate (by line), dead, unset, guessed (by line),
305
+ // uncovered (in the order the files first ask for them), map-edited.
306
+ export function riskMapFindings(parsed, { files = [], changed = null } = {}) {
307
+ const change = changed !== null;
308
+ if (!change) changed = files;
309
+ const out = [];
310
+ if (!parsed.supported) {
311
+ out.push({ code: 'version', target: `v${parsed.version}`, message: `written for risk-map v${parsed.version}; this release reads v${RISK_MAP_VERSION} — nothing in it was read` });
312
+ return out;
313
+ }
314
+ for (const p of parsed.problems) out.push({ code: p.code, target: p.code === 'header' ? '' : `line ${p.line}`, message: p.message });
315
+ const { entries } = parsed;
316
+ for (const e of entries) {
317
+ const holds = e.dir === './' ? files.some((f) => !f.includes('/')) : files.some((f) => f.startsWith(e.dir));
318
+ if (!holds) out.push({ code: 'dead', target: e.dir, message: 'no file is in this directory any more — remove the line or fix the path' });
319
+ }
320
+ const touched = new Set();
321
+ for (const f of changed) { const e = coverOf(entries, f); if (e) touched.add(e); }
322
+ for (const e of entries) {
323
+ if (touched.has(e) && e.level === 'unset') out.push({ code: 'unset', target: e.dir, message: 'has no level yet' });
324
+ }
325
+ for (const e of entries) {
326
+ if (touched.has(e) && e.state === 'guessed') out.push({ code: 'guessed', target: e.dir, message: `\`${e.level}\` is still a guess — a person confirms it` });
327
+ }
328
+ const asked = [];
329
+ for (const f of changed) {
330
+ if (coverOf(entries, f)) continue;
331
+ const d = dirToAdd(entries, f);
332
+ if (!asked.includes(d)) asked.push(d);
333
+ }
334
+ for (const d of asked) {
335
+ out.push({ code: 'uncovered', target: d, message: validDir(d) ? 'no line gives this directory a level'
336
+ : 'no line gives this directory a level, and its name cannot be written in the map (a space, `#`, `*`, `?`, `[` or `\\`) — rename it' });
337
+ }
338
+ if (change && changed.includes(RISK_MAP_FILE)) out.push({ code: 'map-edited', target: RISK_MAP_FILE, message: 'this change edits the risk map, which decides how much review later changes need' });
339
+ return out;
340
+ }
341
+
342
+ const NEW_MAP_HEAD = [
343
+ `# yad-risk-map v${RISK_MAP_VERSION}`,
344
+ '# One line per directory: <dir>/ <high|medium|low|unset> <guessed|confirmed> # why',
345
+ '# The deepest listed directory above a file decides its level. `./` is the files at the repo root.',
346
+ '# No names. A person changes `guessed` to `confirmed` in a PR. See `yad risk-map check`.',
347
+ ];
348
+
349
+ // Add an `unset` line for every directory nothing covers, and never change a line already there. Returns
350
+ // { text, added, unwritable, refused }: `refused` is a reason when the file is written for a newer version
351
+ // (adding to it by older rules is guessing); `unwritable` lists top-level directories whose names a line
352
+ // cannot hold — writing one would be an unreadable line that the next draft adds again. A missing header
353
+ // is added at the top — it is not an entry.
354
+ export function draftRiskMap(existing, files) {
355
+ const text = existing ?? null;
356
+ const parsed = parseRiskMap(text ?? '');
357
+ if (!parsed.supported) return { text, added: [], unwritable: [], refused: `written for risk-map v${parsed.version}; this release reads v${RISK_MAP_VERSION}` };
358
+ const all = files.includes(RISK_MAP_FILE) ? files : [...files, RISK_MAP_FILE];
359
+ // Sorted, `./` first, so the same repo drafts the same bytes however git happens to list its files.
360
+ const asked = riskMapFindings(parsed, { files: all }).filter((x) => x.code === 'uncovered').map((x) => x.target);
361
+ const unwritable = asked.filter((d) => !validDir(d));
362
+ const added = asked.filter((d) => validDir(d))
363
+ .sort((a, b) => (a === './' ? -1 : b === './' ? 1 : a < b ? -1 : a > b ? 1 : 0));
364
+ const width = Math.max(0, ...added.map((d) => d.length)) + 2;
365
+ const newLines = added.map((d) => `${d.padEnd(width)}unset`);
366
+ if (text === null || /^[ \t\r\n]*$/.test(text)) {
367
+ return { text: `${[...NEW_MAP_HEAD, '', ...newLines].join('\n')}\n`, added, unwritable, refused: null };
368
+ }
369
+ let body = text;
370
+ if (!parsed.header) body = `# yad-risk-map v${RISK_MAP_VERSION}\n${body}`;
371
+ if (newLines.length) body = `${body.replace(/\n*$/, '\n')}${newLines.join('\n')}\n`;
372
+ return { text: body, added, unwritable, refused: null };
373
+ }