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.
- package/CHANGELOG.md +371 -0
- package/README.md +160 -22
- package/bin/commands.mjs +46 -0
- package/bin/yad.mjs +506 -117
- package/cli/artifact-status.mjs +30 -11
- package/cli/capture.mjs +417 -0
- package/cli/checkpoint.mjs +47 -27
- package/cli/claims.mjs +165 -0
- package/cli/codeowners-command.mjs +170 -0
- package/cli/codeowners.mjs +397 -0
- package/cli/commit.mjs +13 -9
- package/cli/dial.mjs +183 -0
- package/cli/docs.mjs +88 -32
- package/cli/doctor.mjs +1682 -90
- package/cli/epic-state.mjs +3460 -211
- package/cli/epic.mjs +506 -0
- package/cli/errors.mjs +3 -0
- package/cli/fold.mjs +247 -0
- package/cli/gate-local.mjs +296 -0
- package/cli/gate.mjs +977 -183
- package/cli/history.mjs +556 -0
- package/cli/hook.mjs +264 -53
- package/cli/hubcommit.mjs +9 -19
- package/cli/index-command.mjs +87 -0
- package/cli/ledger.mjs +54 -4
- package/cli/lib.mjs +161 -9
- package/cli/manifest.mjs +376 -38
- package/cli/migrate.mjs +683 -52
- package/cli/mode.mjs +170 -0
- package/cli/next.mjs +365 -86
- package/cli/openpr.mjs +193 -34
- package/cli/owners.mjs +266 -0
- package/cli/people.mjs +657 -0
- package/cli/plan.mjs +466 -125
- package/cli/platform.mjs +109 -128
- package/cli/product-index.mjs +287 -0
- package/cli/protection.mjs +706 -0
- package/cli/reconcile.mjs +40 -12
- package/cli/repo-publish.mjs +24 -26
- package/cli/repo.mjs +23 -14
- package/cli/report.mjs +24 -14
- package/cli/review.mjs +19 -22
- package/cli/riskmap-command.mjs +289 -0
- package/cli/riskmap.mjs +373 -0
- package/cli/setup.mjs +127 -279
- package/cli/ship.mjs +6 -5
- package/cli/skill.mjs +180 -0
- package/cli/skip.mjs +211 -30
- package/cli/thread.mjs +41 -16
- package/cli/tidy.mjs +18 -18
- package/cli/update-commit.mjs +21 -21
- package/cli/usage.mjs +115 -109
- package/package.json +3 -3
- package/skills/sdlc/config.yaml +149 -70
- package/skills/sdlc/module-help.csv +24 -24
- package/skills/yad-analysis/SKILL.md +121 -59
- package/skills/yad-architecture/SKILL.md +30 -18
- package/skills/yad-architecture/references/contract-format.md +8 -5
- package/skills/yad-backfill/SKILL.md +10 -4
- package/skills/yad-backfill/references/backfill.md +8 -1
- package/skills/yad-backfill/templates/checks/backfill-check.sh +10 -2
- package/skills/yad-change/SKILL.md +119 -42
- package/skills/yad-change/references/triage.md +42 -27
- package/skills/yad-checks/SKILL.md +68 -35
- package/skills/yad-checks/references/check-gates.md +310 -85
- package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
- package/skills/yad-checks/templates/checks/contract-check.sh +130 -9
- package/skills/yad-checks/templates/checks/epic-open.sh +2 -2
- package/skills/yad-checks/templates/checks/ledger-guard.sh +55 -8
- package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +1 -1
- package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
- package/skills/yad-checks/templates/github/yad-checks.yml +13 -2
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
- package/skills/yad-checks/templates/github/yad-update-guard.yml +7 -5
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +10 -1
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +3 -3
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +37 -6
- package/skills/yad-checks/templates/hooks/yad-capture.sh +53 -0
- package/skills/yad-commit/SKILL.md +4 -4
- package/skills/yad-connect-design/SKILL.md +5 -5
- package/skills/yad-connect-design/references/design-context.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +2 -2
- package/skills/yad-connect-docs/SKILL.md +11 -11
- package/skills/yad-connect-docs/references/docs-registry.md +1 -1
- package/skills/yad-connect-learning/SKILL.md +5 -5
- package/skills/yad-connect-learning/references/learning-registry.md +2 -2
- package/skills/yad-connect-repos/SKILL.md +66 -45
- package/skills/yad-connect-repos/references/code-context.md +4 -4
- package/skills/yad-connect-repos/references/hub-config.md +47 -51
- package/skills/yad-connect-repos/references/repos-registry.md +8 -7
- package/skills/yad-connect-repos/references/risk-map.md +81 -0
- package/skills/yad-connect-testing/SKILL.md +5 -5
- package/skills/yad-connect-testing/references/testing-context.md +3 -4
- package/skills/yad-connect-testing/references/testing-registry.md +2 -2
- package/skills/yad-defects/SKILL.md +7 -7
- package/skills/yad-discovery/SKILL.md +131 -94
- package/skills/yad-discovery/references/discovery-schema.md +22 -6
- package/skills/yad-discovery/references/foundation-schema.md +374 -0
- package/skills/yad-docs/SKILL.md +13 -8
- package/skills/yad-docs/references/data-mapping.md +9 -7
- package/skills/yad-docs/templates/app/package-lock.json +3 -3
- package/skills/yad-docs-overview/SKILL.md +30 -15
- package/skills/yad-docs-overview/references/pipeline-model.md +33 -20
- package/skills/yad-docs-sync/SKILL.md +10 -5
- package/skills/yad-docs-sync/references/staleness.md +8 -7
- package/skills/yad-engineer-review/SKILL.md +81 -17
- package/skills/yad-engineer-review/references/ship-and-record.md +17 -8
- package/skills/yad-epic/SKILL.md +173 -92
- package/skills/yad-epic/references/state-schema.md +610 -94
- package/skills/yad-hub-bridge/SKILL.md +57 -38
- package/skills/yad-hub-bridge/references/bridge.md +94 -67
- package/skills/yad-hub-bridge/references/login-roster.md +163 -70
- package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
- package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +21 -11
- package/skills/yad-implement/SKILL.md +27 -13
- package/skills/yad-implement/references/implement-conventions.md +2 -2
- package/skills/yad-learn/SKILL.md +9 -9
- package/skills/yad-learn/references/learning-state.md +2 -2
- package/skills/yad-open-pr/SKILL.md +61 -26
- package/skills/yad-pair-review/SKILL.md +7 -5
- package/skills/yad-pair-review/references/session-state.md +1 -1
- package/skills/yad-pr-template/SKILL.md +47 -26
- package/skills/yad-pr-template/references/risk-routing.md +97 -24
- package/skills/yad-pr-template/templates/checks/pr-template.sh +34 -11
- package/skills/yad-pr-template/templates/checks/pr-title.sh +32 -9
- package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
- package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
- package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
- package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +14 -13
- package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +14 -12
- package/skills/yad-reconcile/SKILL.md +3 -3
- package/skills/yad-report/SKILL.md +5 -5
- package/skills/yad-review-companion/SKILL.md +6 -3
- package/skills/yad-review-gate/SKILL.md +203 -93
- package/skills/yad-review-gate/references/gating.md +232 -54
- package/skills/yad-run/SKILL.md +81 -51
- package/skills/yad-run/references/run-loop.md +61 -39
- package/skills/yad-ship/SKILL.md +15 -11
- package/skills/yad-spec/SKILL.md +21 -6
- package/skills/yad-spec/references/spec-handoff.md +17 -5
- package/skills/yad-status/SKILL.md +115 -50
- package/skills/yad-stories/SKILL.md +37 -21
- package/skills/yad-stories/references/story-schema.md +7 -6
- package/skills/yad-stub/SKILL.md +58 -47
- package/skills/yad-sync-repos/SKILL.md +2 -2
- package/skills/yad-test-cases/SKILL.md +32 -23
- package/skills/yad-test-cases/references/test-cases-schema.md +7 -4
- package/skills/yad-timeline/SKILL.md +8 -7
- package/skills/yad-ui/SKILL.md +41 -18
- package/cli/roster.mjs +0 -164
- package/skills/sdlc/install.sh +0 -68
package/cli/riskmap.mjs
ADDED
|
@@ -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
|
+
}
|