yadflow 4.4.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +2 -1
- package/bin/commands.mjs +2 -0
- package/bin/yad.mjs +58 -3
- package/cli/claims.mjs +16 -13
- package/cli/doctor.mjs +33 -0
- package/cli/manifest.mjs +3 -0
- package/cli/members.mjs +822 -0
- package/cli/people.mjs +13 -3
- package/cli/platform.mjs +1 -1
- package/cli/pr-list.mjs +125 -0
- package/cli/protection.mjs +4 -4
- package/cli/standup.mjs +709 -0
- package/cli/usage.mjs +18 -11
- package/cli/workspace.mjs +14 -5
- package/package.json +5 -3
- package/skills/yad-checks/templates/checks/member-check.mjs +197 -0
- package/skills/yad-checks/templates/github/yad-product-checks.yml +15 -0
- package/skills/yad-checks/templates/gitlab/yad-product-checks.gitlab-ci.yml +9 -0
- package/skills/yad-status/SKILL.md +3 -0
package/cli/usage.mjs
CHANGED
|
@@ -67,6 +67,7 @@ const ARTIFACT_FILES = new Set([
|
|
|
67
67
|
// has to derive the same login and the rule is compared between the two; this export stays (rule 3 —
|
|
68
68
|
// add the new name beside the old) and is the one every older reader here still calls.
|
|
69
69
|
import { loginFromEmail } from './riskmap.mjs';
|
|
70
|
+
import { gateMemberMap, hashEmail } from './members.mjs';
|
|
70
71
|
export { loginFromEmail };
|
|
71
72
|
|
|
72
73
|
// ---- epic enumeration --------------------------------------------------------------------------
|
|
@@ -92,7 +93,7 @@ const inWindow = (date, since, until) => !!date && (!since || date >= since) &&
|
|
|
92
93
|
|
|
93
94
|
// Ledger-sourced events for one epic: approvals, comments, and ship engineer-reviews, attributed to the
|
|
94
95
|
// name the ledger records — as recorded, with nothing to translate it through.
|
|
95
|
-
function ledgerEvents(root, epic, aliases = new Map()) {
|
|
96
|
+
export function ledgerEvents(root, epic, aliases = new Map()) {
|
|
96
97
|
const f = epicFiles(epicRoot(root, epic));
|
|
97
98
|
const events = [];
|
|
98
99
|
const emit = (rec, rawName, action, date, extra = {}) => {
|
|
@@ -143,12 +144,12 @@ const GIT_PRETTY = '--pretty=format:\x01%an%x00%ae%x00%ad';
|
|
|
143
144
|
|
|
144
145
|
// git-sourced "authored" events: who committed which epic artifact, when. Degrades to [] when the Product
|
|
145
146
|
// is not a git repo (e.g. a test fixture dir), so the command never depends on git being present.
|
|
146
|
-
function gitAuthoredEvents(root) {
|
|
147
|
+
function gitAuthoredEvents(root, pairs = new Map()) {
|
|
147
148
|
const r = run('git', ['-C', root, 'log', '--no-merges', '--date=short', GIT_PRETTY, '--name-only', '--', 'epics', FOUNDATION_DIR]);
|
|
148
149
|
if (!r.ok || !r.stdout) return [];
|
|
149
150
|
const events = [];
|
|
150
151
|
for (const cm of parseGitLog(r.stdout)) {
|
|
151
|
-
const login = loginFromEmail(cm.ae);
|
|
152
|
+
const login = loginFromEmail(cm.ae) || pairs.get(hashEmail(cm.ae)) || null;
|
|
152
153
|
for (const rel of cm.files) {
|
|
153
154
|
if (!isArtifactPath(rel)) continue;
|
|
154
155
|
events.push({
|
|
@@ -163,7 +164,7 @@ function gitAuthoredEvents(root) {
|
|
|
163
164
|
|
|
164
165
|
// Optional (`--repos`): code commits in each connected code repo, attributed to the git author (or the
|
|
165
166
|
// login a noreply address carries).
|
|
166
|
-
function repoCommitEvents(root) {
|
|
167
|
+
function repoCommitEvents(root, pairs = new Map()) {
|
|
167
168
|
const reg = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
|
|
168
169
|
const events = [];
|
|
169
170
|
for (const repo of reg?.repos || []) {
|
|
@@ -172,7 +173,7 @@ function repoCommitEvents(root) {
|
|
|
172
173
|
const r = run('git', ['-C', abs, 'log', '--no-merges', '--date=short', GIT_PRETTY]);
|
|
173
174
|
if (!r.ok || !r.stdout) continue;
|
|
174
175
|
for (const cm of parseGitLog(r.stdout)) {
|
|
175
|
-
const login = loginFromEmail(cm.ae);
|
|
176
|
+
const login = loginFromEmail(cm.ae) || pairs.get(hashEmail(cm.ae)) || null;
|
|
176
177
|
events.push({ ts: cm.ad, actor: login || cm.an, login, action: 'committed', repo: repo.name });
|
|
177
178
|
}
|
|
178
179
|
}
|
|
@@ -181,11 +182,15 @@ function repoCommitEvents(root) {
|
|
|
181
182
|
|
|
182
183
|
// The full, window-filtered event stream. Deterministic: sorted by (date, action, epic/repo, actor).
|
|
183
184
|
export function deriveEvents(root, { since, until, repos = false } = {}) {
|
|
184
|
-
|
|
185
|
+
// E131: a member file joins a commit's email to the person's login, so one person is one row. A report,
|
|
186
|
+
// so GitLab's pairings count here too (the gate count reads GitHub's only).
|
|
187
|
+
let pairs = new Map();
|
|
188
|
+
try { pairs = gateMemberMap(root, { anyPlatform: true }); } catch { /* no members — rows stay as git names them */ }
|
|
189
|
+
const events = [...gitAuthoredEvents(root, pairs)];
|
|
185
190
|
let aliases = new Map();
|
|
186
191
|
try { aliases = legacyLogins(readJSON(productConfigPath(root), null)); } catch { /* no Product — nothing to translate */ }
|
|
187
192
|
for (const epic of listEpics(root)) events.push(...ledgerEvents(root, epic, aliases));
|
|
188
|
-
if (repos) events.push(...repoCommitEvents(root));
|
|
193
|
+
if (repos) events.push(...repoCommitEvents(root, pairs));
|
|
189
194
|
return events
|
|
190
195
|
.filter((e) => inWindow(e.ts, since, until))
|
|
191
196
|
.sort((a, b) =>
|
|
@@ -266,7 +271,7 @@ export function shipHygiene(root, { since, until } = {}) {
|
|
|
266
271
|
|
|
267
272
|
// ---- rendering ---------------------------------------------------------------------------------
|
|
268
273
|
|
|
269
|
-
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
|
274
|
+
export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
|
270
275
|
const rangeLabel = (w) => (!w.since && !w.until ? 'all time' : `${w.since || '…'} → ${w.until || '…'}`);
|
|
271
276
|
|
|
272
277
|
// A single self-contained HTML file — inline CSS + inline SVG bars, no build step, no external assets —
|
|
@@ -327,11 +332,13 @@ ${hygiene}
|
|
|
327
332
|
</div></body></html>\n`;
|
|
328
333
|
}
|
|
329
334
|
|
|
335
|
+
// Markdown cell and line text: a `|` or a newline in a name or repo can't corrupt a table or a list.
|
|
336
|
+
export const mdCell = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
|
|
337
|
+
export const mdText = (s) => String(s ?? '').replace(/\r?\n/g, ' ');
|
|
338
|
+
|
|
330
339
|
// A compact Markdown variant for quick reads / pasting into a PR. Dynamic values are sanitized so a
|
|
331
340
|
// name/repo containing `|` or a newline can't corrupt the table or list structure.
|
|
332
341
|
export function renderMarkdown(model, today = '') {
|
|
333
|
-
const mdCell = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
|
|
334
|
-
const mdText = (s) => String(s ?? '').replace(/\r?\n/g, ' ');
|
|
335
342
|
const L = [`# Team usage & behavior report`, ``, `- Range: **${rangeLabel(model.window)}**${today ? ` · generated ${today}` : ''}`, `- Members: ${model.members.length}`, `- Totals: ${ACTIONS.map((a) => `${a} ${model.totals[a]}`).join(' · ')}`, ``, `| member | ${ACTIONS.join(' | ')} | total | flags |`, `|---|${ACTIONS.map(() => '--:').join('|')}|--:|---|`];
|
|
336
343
|
for (const m of model.members) {
|
|
337
344
|
L.push(`| ${mdCell(`${m.name}${m.login && m.login !== m.name ? ` (@${m.login})` : ''}`)} | ${ACTIONS.map((a) => m.counts[a]).join(' | ')} | ${m.total} | ${mdCell(m.flags.join(', ') || '—')} |`);
|
|
@@ -364,7 +371,7 @@ export function buildModel(root, { since, until, repos = false, member } = {}) {
|
|
|
364
371
|
|
|
365
372
|
// Ensure a report's parent directory exists, then write it (mirrors copyFile/writeJSON in lib.mjs,
|
|
366
373
|
// which always mkdir the dirname first) so `--out sub/dir/report.html` never throws a raw ENOENT.
|
|
367
|
-
function writeReport(dest, content) {
|
|
374
|
+
export function writeReport(dest, content) {
|
|
368
375
|
fs.mkdirSync(path.dirname(path.resolve(dest)), { recursive: true });
|
|
369
376
|
fs.writeFileSync(dest, content);
|
|
370
377
|
}
|
package/cli/workspace.mjs
CHANGED
|
@@ -19,10 +19,12 @@
|
|
|
19
19
|
//
|
|
20
20
|
// `yad setup` is unchanged: new and init are front doors over it, not a replacement.
|
|
21
21
|
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
22
|
+
// `new` never creates a remote — it prints the exact `gh repo create` / `glab repo create` line. `join`
|
|
23
|
+
// installs only what git ignores in the Product (the per-machine skill copies) and the git pre-commit
|
|
24
|
+
// hook, which git never shares (E48). ONE EXCEPTION, since E131 (the user's decision, 2026-10-05): join
|
|
25
|
+
// ends with `yad member add`, which writes the person's member file on a branch of its own
|
|
26
|
+
// (`yad/member/<login>`), pushes it and opens a PR/MR — never in the checkout, never on the default
|
|
27
|
+
// branch. Any problem there is a warning and the command to run later; it never fails join.
|
|
26
28
|
import path from 'node:path';
|
|
27
29
|
import fs from 'node:fs';
|
|
28
30
|
import { spawnSync } from 'node:child_process';
|
|
@@ -33,6 +35,7 @@ import { runSetup, insideWorkspace, throughGitDir, selectIdeTargets } from './se
|
|
|
33
35
|
import { moduleActions, gitHookActions } from './plan.mjs';
|
|
34
36
|
import { writeWorkspaceFile, WORKSPACE_FILE } from './find-product.mjs';
|
|
35
37
|
import { offerToolbox } from './toolbox.mjs';
|
|
38
|
+
import { runMemberAdd } from './members.mjs';
|
|
36
39
|
|
|
37
40
|
// E80: the file that lets yad find the Product from inside the repos it registers. Written by all three.
|
|
38
41
|
function noteWorkspaceFile(product) {
|
|
@@ -490,6 +493,12 @@ export async function runJoin(cwd, url, folder, opts = {}) {
|
|
|
490
493
|
log(c.bold('Toolbox'));
|
|
491
494
|
const toolbox = offerToolbox(product, product, { canRemove: false });
|
|
492
495
|
|
|
496
|
+
// E131: the person's member file — proven by the platform, on its own branch and PR/MR. Never fatal.
|
|
497
|
+
log('');
|
|
498
|
+
let member = null;
|
|
499
|
+
try { member = await runMemberAdd(product, { soft: true, noPush: !!opts.noPush }) ?? null; }
|
|
500
|
+
catch (e) { warn(`member file not written: ${shown(e.message)} — run \`yad member add\` from the Product later`); }
|
|
501
|
+
|
|
493
502
|
// Under a heading of its own, so these lines do not read as part of the toolbox list above.
|
|
494
503
|
log('');
|
|
495
504
|
log(c.bold('Next'));
|
|
@@ -498,6 +507,6 @@ export async function runJoin(cwd, url, folder, opts = {}) {
|
|
|
498
507
|
return {
|
|
499
508
|
workspace, product,
|
|
500
509
|
repos, skills: { installed: skills.installed, stale: skills.stale.length, shared: skills.shared.length },
|
|
501
|
-
hook: hook.length ? 'installed' : 'unchanged', toolbox,
|
|
510
|
+
hook: hook.length ? 'installed' : 'unchanged', toolbox, member,
|
|
502
511
|
};
|
|
503
512
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.0",
|
|
4
4
|
"description": "Shared project memory and workflows for teams building with AI: a versioned record of plans, decisions, contracts and reviews across repos, human-approved review gates, and a zero-dependency `yad` CLI plus 38 skills for your AI assistant.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AbdelRahman Nasr",
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
"!cli/test-migrate.mjs",
|
|
27
27
|
"!cli/test-hooks.mjs",
|
|
28
28
|
"!cli/test-detect.mjs",
|
|
29
|
+
"!cli/test-members.mjs",
|
|
30
|
+
"!cli/test-standup.mjs",
|
|
29
31
|
"!cli/test-fuzz.js",
|
|
30
32
|
"!cli/fixtures",
|
|
31
33
|
"skills/",
|
|
@@ -43,10 +45,10 @@
|
|
|
43
45
|
"scripts": {
|
|
44
46
|
"yad": "node bin/yad.mjs",
|
|
45
47
|
"lint": "eslint cli bin",
|
|
46
|
-
"test": "node --test cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs cli/test-golden.mjs cli/test-migrate.mjs cli/test-hooks.mjs cli/test-detect.mjs cli/test-fuzz.js",
|
|
48
|
+
"test": "node --test cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs cli/test-golden.mjs cli/test-migrate.mjs cli/test-hooks.mjs cli/test-detect.mjs cli/test-members.mjs cli/test-standup.mjs cli/test-fuzz.js",
|
|
47
49
|
"test:e2e": "bash test/e2e/run.sh",
|
|
48
50
|
"release-check": "bash scripts/release-check.sh",
|
|
49
|
-
"coverage": "node --test --experimental-test-coverage --test-coverage-exclude='cli/test*.mjs' --test-coverage-exclude='cli/test*.js' --test-coverage-lines=70 --test-coverage-branches=70 cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs cli/test-golden.mjs cli/test-migrate.mjs cli/test-hooks.mjs cli/test-detect.mjs cli/test-fuzz.js",
|
|
51
|
+
"coverage": "node --test --experimental-test-coverage --test-coverage-exclude='cli/test*.mjs' --test-coverage-exclude='cli/test*.js' --test-coverage-lines=70 --test-coverage-branches=70 cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs cli/test-golden.mjs cli/test-migrate.mjs cli/test-hooks.mjs cli/test-detect.mjs cli/test-members.mjs cli/test-standup.mjs cli/test-fuzz.js",
|
|
50
52
|
"diagrams": "npx -y @mermaid-js/mermaid-cli@11.15.0 -i docs/diagrams/sdlc-overview.mmd -o docs/diagrams/sdlc-overview.svg -b transparent && npx -y @mermaid-js/mermaid-cli@11.15.0 -i docs/diagrams/review-loop.mmd -o docs/diagrams/review-loop.svg -b transparent && npx -y @mermaid-js/mermaid-cli@11.15.0 -i docs/diagrams/work-and-memory.mmd -o docs/diagrams/work-and-memory.svg -b transparent && npx -y @mermaid-js/mermaid-cli@11.15.0 -i docs/diagrams/feature-evolution.mmd -o docs/diagrams/feature-evolution.svg -b transparent",
|
|
51
53
|
"prepublishOnly": "npm test"
|
|
52
54
|
},
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// member-check gate (E131) — a member file (`.sdlc/members/<platform>-<login>.json`) says which accounts and
|
|
3
|
+
// which git emails are one person. The active-people count joins a commit to an approval through it, so a
|
|
4
|
+
// false file would fold one person into another and make the count SMALLER. This gate refuses that:
|
|
5
|
+
//
|
|
6
|
+
// 1. A PR may ADD or CHANGE only its author's own file: named `<platform>-<author>.json`, holding exactly
|
|
7
|
+
// one account on the Product's platform and host — the author's, with the author's numeric account id
|
|
8
|
+
// (so a login someone renamed away from, and another person registered, cannot take over the old file).
|
|
9
|
+
// Never a bot's.
|
|
10
|
+
// 2. On GitHub, every email in a file the PR adds or changes must be the author email of a commit in the PR that GitHub itself
|
|
11
|
+
// attributes to the PR's author (`commits/<sha>` → `author.login`). GitHub links a commit to an account
|
|
12
|
+
// only through an email that account has verified, so this is the platform's proof, not the file's
|
|
13
|
+
// claim. `yad member add` writes one commit per email for exactly this. On GitLab the API says no such
|
|
14
|
+
// thing, so emails there feed the team list only and are never used by the gate count (the user's
|
|
15
|
+
// decision, 2026-10-05) — this gate checks the owner rule alone.
|
|
16
|
+
// 3. A PR may DELETE anyone's file (removing a person can only make the count larger). `yad member remove
|
|
17
|
+
// <login> --reason` puts the reason in the commit.
|
|
18
|
+
// 4. No account on the Product's platform, and no email, may be in two member files. (An OTHER account
|
|
19
|
+
// two files claim is not refused: the CLI matches it to nobody until one file drops it, and refusing
|
|
20
|
+
// it here would let whoever claimed it first block its real owner from joining.)
|
|
21
|
+
// 5. `.sdlc` and `.sdlc/members` are real folders and nothing under them is a link, and no path is a
|
|
22
|
+
// case or Unicode twin of `.sdlc/members/` — a macOS or Windows checkout would read either as a member
|
|
23
|
+
// file this gate never judged (E131 review 1–2; the E115 and E121 lessons).
|
|
24
|
+
//
|
|
25
|
+
// LIMIT: like every Product gate, this file and the workflow that runs it come from the PR's own checkout,
|
|
26
|
+
// so a PR can change the gate it is judged by; branch protection on the default branch is the backstop.
|
|
27
|
+
//
|
|
28
|
+
// A Node script, not bash: it reads JSON and hashes, and both CI images already have Node (E113's hooks are
|
|
29
|
+
// Node for the same reason). Usage: node checks/member-check.mjs <base-ref>
|
|
30
|
+
// GitHub: PR_AUTHOR and PR_AUTHOR_ID (github.event.pull_request.user.login / .id) and GH_TOKEN (a read
|
|
31
|
+
// token) in the environment.
|
|
32
|
+
// GitLab: CI_API_V4_URL, CI_PROJECT_ID, CI_MERGE_REQUEST_IID, and GITLAB_TOKEN or SDLC_API_TOKEN. With no
|
|
33
|
+
// token it FAILS when a member file changed (fail closed — the user's decision, 2026-10-05).
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import { createHash } from 'node:crypto';
|
|
36
|
+
import { spawnSync } from 'node:child_process';
|
|
37
|
+
|
|
38
|
+
const DIR = '.sdlc/members/';
|
|
39
|
+
const LOGIN_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
|
|
40
|
+
const HASH_RE = /^sha256:[0-9a-f]{64}$/;
|
|
41
|
+
let rc = 0;
|
|
42
|
+
const say = (s) => process.stdout.write(`${s}\n`);
|
|
43
|
+
const fail = (s) => { say(`FAIL [member-check]: ${s}`); rc = 1; };
|
|
44
|
+
const git = (...args) => {
|
|
45
|
+
const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 1 << 28 });
|
|
46
|
+
return { ok: r.status === 0, out: r.stdout || '' };
|
|
47
|
+
};
|
|
48
|
+
const hash = (email) => {
|
|
49
|
+
const e = String(email || '').trim().toLowerCase();
|
|
50
|
+
return e.indexOf('@', 1) < 0 ? null : `sha256:${createHash('sha256').update(e).digest('hex')}`;
|
|
51
|
+
};
|
|
52
|
+
const parse = (text) => { try { return JSON.parse(text); } catch { return null; } };
|
|
53
|
+
|
|
54
|
+
// The Product's settings under either name (product.json from 4.0, hub.json before). Drift between the two
|
|
55
|
+
// is refused by the other gates and the CLI; here the first one found is read.
|
|
56
|
+
const settings = parse(fs.existsSync('.sdlc/product.json') ? fs.readFileSync('.sdlc/product.json', 'utf8')
|
|
57
|
+
: fs.existsSync('.sdlc/hub.json') ? fs.readFileSync('.sdlc/hub.json', 'utf8') : 'null');
|
|
58
|
+
const platform = settings?.platform === 'github' || settings?.platform === 'gitlab' ? settings.platform : null;
|
|
59
|
+
// The Product's host, from its clone URL (`git@host:…` or `https://host/…`), else the platform's own.
|
|
60
|
+
function hostOf(url) {
|
|
61
|
+
const u = String(url || '').trim();
|
|
62
|
+
const scp = u.match(/^(?:[^@/]+@)?([^/:]+):(?!\/)/);
|
|
63
|
+
if (scp && !/^[a-z][a-z0-9+.-]*:\/\//i.test(u)) return scp[1].toLowerCase();
|
|
64
|
+
try { return new URL(u).hostname.toLowerCase() || null; } catch { return null; }
|
|
65
|
+
}
|
|
66
|
+
// The same order the CLI reads it in: the settings' `git_url`, then the clone's origin (review 3).
|
|
67
|
+
const productHost = hostOf(settings?.git_url) || hostOf(git('remote', 'get-url', 'origin').out) || (platform === 'gitlab' ? 'gitlab.com' : 'github.com');
|
|
68
|
+
const onProduct = (a) => a?.platform === platform && String(a?.host || '').toLowerCase() === productHost;
|
|
69
|
+
|
|
70
|
+
const base = process.argv[2];
|
|
71
|
+
if (!base) { say('FAIL [member-check]: usage: node checks/member-check.mjs <base-ref>'); process.exit(1); }
|
|
72
|
+
if (!git('rev-parse', '--verify', '-q', `${base}^{commit}`).ok) { say(`FAIL [member-check]: cannot resolve the base ref ${base} (fetch-depth: 0?)`); process.exit(1); }
|
|
73
|
+
|
|
74
|
+
// Every path the PR touches, with its status. NUL-separated, no renames (a rename is a delete plus an add,
|
|
75
|
+
// and each is judged).
|
|
76
|
+
const diff = git('diff', '--no-renames', '--name-status', '-z', `${base}...HEAD`);
|
|
77
|
+
if (!diff.ok) { say('FAIL [member-check]: git could not list the changed files'); process.exit(1); }
|
|
78
|
+
const parts = diff.out.split('\0').filter(Boolean);
|
|
79
|
+
const all = [];
|
|
80
|
+
for (let i = 0; i + 1 < parts.length; i += 2) all.push({ status: parts[i][0], path: parts[i + 1] });
|
|
81
|
+
|
|
82
|
+
// A path as a case-insensitive, Unicode-folding file system sees it: `.sdlc/Members/x` and `.ſdlc/members/x`
|
|
83
|
+
// land in `.sdlc/members/` on macOS and Windows.
|
|
84
|
+
// A deletion is never judged here: removing a twin is how one is cleaned up (review 2).
|
|
85
|
+
const fold = (p) => p.normalize('NFKC').toLowerCase();
|
|
86
|
+
for (const { status, path } of all) {
|
|
87
|
+
if (status === 'D') continue;
|
|
88
|
+
const f = fold(path);
|
|
89
|
+
if ((f === '.sdlc' || f.startsWith('.sdlc/')) && !(path === '.sdlc' || path.startsWith('.sdlc/'))) fail(`${JSON.stringify(path)} is another spelling of .sdlc/ — a macOS or Windows checkout reads it as the Product's own folder, unjudged`);
|
|
90
|
+
else if ((f === '.sdlc/members' || f.startsWith(DIR)) && !path.startsWith(DIR)) fail(`${JSON.stringify(path)} is another spelling of ${DIR} — a macOS or Windows checkout reads it as a member file this gate does not judge`);
|
|
91
|
+
}
|
|
92
|
+
// The folders themselves must be folders at HEAD, and nothing under them a link.
|
|
93
|
+
for (const p of ['.sdlc', '.sdlc/members']) {
|
|
94
|
+
const t = git('ls-tree', 'HEAD', '--', p).out.trim();
|
|
95
|
+
if (t && !t.startsWith('040000 ')) fail(`${p} is not a folder at HEAD (a link?) — member files are read only from a real ${DIR}`);
|
|
96
|
+
}
|
|
97
|
+
const tree = git('ls-tree', '-r', '-z', 'HEAD', '--', DIR).out.split('\0').filter(Boolean);
|
|
98
|
+
const folded = new Map();
|
|
99
|
+
for (const line of tree) {
|
|
100
|
+
const [meta, p] = line.split('\t');
|
|
101
|
+
if (!meta.startsWith('100644 ') && !meta.startsWith('100755 ')) fail(`${p} is not a plain file (a link?) — a member file is never read through one`);
|
|
102
|
+
const k = fold(p);
|
|
103
|
+
if (folded.has(k)) fail(`${p} and ${folded.get(k)} are one file on macOS and Windows — keep one`);
|
|
104
|
+
folded.set(k, p);
|
|
105
|
+
}
|
|
106
|
+
if (rc) process.exit(rc);
|
|
107
|
+
|
|
108
|
+
const changed = all.filter((c) => c.path.startsWith(DIR));
|
|
109
|
+
if (!changed.length) { say('PASS [member-check]: no member file changed'); process.exit(0); }
|
|
110
|
+
if (!platform) { say('FAIL [member-check]: member files changed, but the Product names no platform — nothing can be proven'); process.exit(1); }
|
|
111
|
+
|
|
112
|
+
// Who opened the PR/MR, asked of the platform's own record.
|
|
113
|
+
// Returns { login, id }: the numeric id is what stays with a person when a login is renamed and reused.
|
|
114
|
+
async function prAuthor() {
|
|
115
|
+
if (platform === 'github') return { login: process.env.PR_AUTHOR || null, id: Number(process.env.PR_AUTHOR_ID) || null };
|
|
116
|
+
const url = process.env.CI_API_V4_URL;
|
|
117
|
+
const project = process.env.CI_PROJECT_ID;
|
|
118
|
+
const iid = process.env.CI_MERGE_REQUEST_IID;
|
|
119
|
+
const token = process.env.GITLAB_TOKEN || process.env.SDLC_API_TOKEN;
|
|
120
|
+
if (!url || !project || !iid) return null;
|
|
121
|
+
if (!token) { fail('no API token (GITLAB_TOKEN or SDLC_API_TOKEN) to read who opened this MR — a member file cannot be checked without it; add the token as a masked CI/CD variable'); return null; }
|
|
122
|
+
try {
|
|
123
|
+
const r = await fetch(`${url}/projects/${encodeURIComponent(project)}/merge_requests/${encodeURIComponent(iid)}`, { headers: { 'PRIVATE-TOKEN': token } });
|
|
124
|
+
if (!r.ok) { fail(`could not read the MR (HTTP ${r.status})`); return null; }
|
|
125
|
+
const mr = await r.json();
|
|
126
|
+
return { login: mr?.author?.username || null, id: Number(mr?.author?.id) || null };
|
|
127
|
+
} catch (e) { fail(`could not read the MR: ${e.message}`); return null; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const who = await prAuthor();
|
|
131
|
+
if (rc) process.exit(rc);
|
|
132
|
+
const author = who?.login || null;
|
|
133
|
+
const authorId = who?.id || null;
|
|
134
|
+
if (!author || !LOGIN_RE.test(author)) { say(`FAIL [member-check]: cannot tell who opened this ${platform === 'github' ? 'PR' : 'MR'}${author ? ` (${author} is a bot)` : ''} — a member file is changed only by its own person`); process.exit(1); }
|
|
135
|
+
|
|
136
|
+
const fileAt = (ref, path) => { const r = git('show', `${ref}:${path}`); return r.ok ? parse(r.out) : null; };
|
|
137
|
+
const emailsOf = (rec) => new Set(Array.isArray(rec?.emails) ? rec.emails.filter((h) => typeof h === 'string' && HASH_RE.test(h)) : []);
|
|
138
|
+
|
|
139
|
+
// GitHub's own answer: which account a commit in this PR belongs to. Cached per commit.
|
|
140
|
+
const loginOf = new Map();
|
|
141
|
+
function githubLogin(sha) {
|
|
142
|
+
if (loginOf.has(sha)) return loginOf.get(sha);
|
|
143
|
+
const r = spawnSync('gh', ['api', `repos/{owner}/{repo}/commits/${sha}`, '--jq', '.author.login // ""'], { encoding: 'utf8' });
|
|
144
|
+
const login = r.status === 0 ? (r.stdout || '').trim() : null;
|
|
145
|
+
loginOf.set(sha, login);
|
|
146
|
+
return login;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const prCommits = git('log', '--format=%H%x1f%ae', `${base}..HEAD`).out.split('\n').filter(Boolean).map((l) => { const [sha, email] = l.split('\x1f'); return { sha, hash: hash(email) }; });
|
|
150
|
+
|
|
151
|
+
for (const { status, path } of changed) {
|
|
152
|
+
const name = path.slice(DIR.length);
|
|
153
|
+
if (status === 'D') { say(`PASS [member-check]: ${path} removed (removing a member can only make the count larger)`); continue; }
|
|
154
|
+
if (name.includes('/') || !name.endsWith('.json')) { fail(`${path}: only <platform>-<login>.json files belong in ${DIR}`); continue; }
|
|
155
|
+
if (name !== `${platform}-${author.toLowerCase()}.json`) { fail(`${path}: a PR may add or change only its author's own member file (${platform}-${author.toLowerCase()}.json) — use \`yad member remove <login> --reason\` to remove someone else's`); continue; }
|
|
156
|
+
const rec = fileAt('HEAD', path);
|
|
157
|
+
if (!rec || !Array.isArray(rec.accounts)) { fail(`${path}: not a member file (no "accounts" list)`); continue; }
|
|
158
|
+
const here = rec.accounts.filter(onProduct);
|
|
159
|
+
if (here.length !== 1) { fail(`${path}: must hold exactly one ${platform} account on ${productHost}, the author's own (it holds ${here.length})`); continue; }
|
|
160
|
+
if (String(here[0].login || '').toLowerCase() !== author.toLowerCase()) { fail(`${path}: does not hold ${author}'s own ${platform} account on ${productHost}`); continue; }
|
|
161
|
+
// The numeric id: a login can be renamed away and registered again by someone else.
|
|
162
|
+
if (!authorId) { fail(`${path}: cannot tell the numeric id of who opened this ${platform === 'github' ? 'PR (PR_AUTHOR_ID)' : 'MR'}`); continue; }
|
|
163
|
+
if (here[0].id !== authorId) { fail(`${path}: the account id in the file (${here[0].id ?? 'none'}) is not the author's (${authorId})`); continue; }
|
|
164
|
+
const was = fileAt(base, path);
|
|
165
|
+
const wasHere = Array.isArray(was?.accounts) ? was.accounts.filter(onProduct) : [];
|
|
166
|
+
if (wasHere.length && wasHere[0].id !== authorId) { fail(`${path}: belonged to account id ${wasHere[0].id}, not ${author}'s (${authorId}) — a reused login does not take over a member file; remove it first`); continue; }
|
|
167
|
+
if (platform !== 'github') { say(`PASS [member-check]: ${path} belongs to ${author} (on GitLab its emails feed the team list only)`); continue; }
|
|
168
|
+
// EVERY email in a changed file is proven, not only the new ones (review 5): an email that reached the
|
|
169
|
+
// branch while this gate was off or edited would otherwise ride along on any later change.
|
|
170
|
+
const added = [...emailsOf(rec)];
|
|
171
|
+
for (const h of added) {
|
|
172
|
+
const proof = prCommits.filter((c) => c.hash === h).find((c) => String(githubLogin(c.sha) || '').toLowerCase() === author.toLowerCase());
|
|
173
|
+
if (!proof) fail(`${path}: holds an email no commit in this PR proves — GitHub must attribute a commit authored by that email to ${author} (\`yad member add\` writes one such commit per email; verify the email on the account)`);
|
|
174
|
+
}
|
|
175
|
+
if (!rc) say(`PASS [member-check]: ${path} belongs to ${author}${added.length ? `; ${added.length} email(s), each proven by a commit GitHub attributes to them` : ''}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// No account on the Product's platform and no email in two files, across every member file at HEAD.
|
|
179
|
+
const files = tree.map((l) => l.split('\t')[1]).filter((p) => p.endsWith('.json'));
|
|
180
|
+
const accounts = new Map();
|
|
181
|
+
const emails = new Map();
|
|
182
|
+
for (const p of files) {
|
|
183
|
+
const rec = fileAt('HEAD', p);
|
|
184
|
+
// Keyed on each file's OWN account only (review 2): an OTHER account two files claim is not refused here —
|
|
185
|
+
// the CLI matches it to nobody — or whoever claimed someone's account first would block its owner.
|
|
186
|
+
const login = p.slice(DIR.length).replace(new RegExp(`^${platform}-`), '').replace(/\.json$/, '');
|
|
187
|
+
const k = `${platform}:${productHost}:${login.toLowerCase()}`;
|
|
188
|
+
accounts.set(k, [...new Set([...(accounts.get(k) || []), p])]);
|
|
189
|
+
for (const h of emailsOf(rec)) emails.set(h, [...(emails.get(h) || []), p]);
|
|
190
|
+
}
|
|
191
|
+
for (const [k, list] of accounts) if (list.length > 1) fail(`the account ${k} is in ${list.join(' and ')} — one account belongs to one member`);
|
|
192
|
+
// On GitLab an email is never proven and never used by the count, so a shared one is not refused: refusing it
|
|
193
|
+
// would let whoever listed someone's known address first block that person (review 3). The CLI pairs it
|
|
194
|
+
// with nobody.
|
|
195
|
+
if (platform === 'github') for (const [, list] of emails) if (list.length > 1) fail(`one email is in ${list.join(' and ')} — one email belongs to one member`);
|
|
196
|
+
|
|
197
|
+
process.exit(rc);
|
|
@@ -69,3 +69,18 @@ jobs:
|
|
|
69
69
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
70
70
|
with: { fetch-depth: 0 }
|
|
71
71
|
- run: bash checks/ledger-guard.sh "origin/${{ github.base_ref }}"
|
|
72
|
+
|
|
73
|
+
# E131: a member file (.sdlc/members/) is changed only by its own person, and every email it adds must be
|
|
74
|
+
# the author of a commit in this PR that GitHub attributes to that person. Needs a read token to ask
|
|
75
|
+
# GitHub who a commit belongs to; the PR author comes from the event, never from the PR's own files.
|
|
76
|
+
member-check:
|
|
77
|
+
runs-on: ubuntu-latest
|
|
78
|
+
if: github.event.action != 'edited'
|
|
79
|
+
env:
|
|
80
|
+
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
|
81
|
+
PR_AUTHOR_ID: ${{ github.event.pull_request.user.id }}
|
|
82
|
+
GH_TOKEN: ${{ github.token }}
|
|
83
|
+
steps:
|
|
84
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
85
|
+
with: { fetch-depth: 0 }
|
|
86
|
+
- run: node checks/member-check.mjs "origin/${{ github.base_ref }}"
|
|
@@ -52,3 +52,12 @@ yad-product-ledger-guard:
|
|
|
52
52
|
needs: []
|
|
53
53
|
script:
|
|
54
54
|
- bash checks/ledger-guard.sh "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
|
|
55
|
+
|
|
56
|
+
# E131: a member file (.sdlc/members/) is changed only by the MR's own author. GitLab's predefined
|
|
57
|
+
# variables do not name the MR author, so the check reads it from the API with GITLAB_TOKEN or SDLC_API_TOKEN
|
|
58
|
+
# (a masked CI/CD variable) — and FAILS when a member file changed and no token is set.
|
|
59
|
+
yad-product-member-check:
|
|
60
|
+
extends: .yad_product_mr_only
|
|
61
|
+
needs: []
|
|
62
|
+
script:
|
|
63
|
+
- node checks/member-check.mjs "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
|
|
@@ -15,6 +15,9 @@ reads `.sdlc/` and `reviews/` and reports.
|
|
|
15
15
|
|
|
16
16
|
## On Activation
|
|
17
17
|
|
|
18
|
+
This skill reports on epics. For people — what each team member did since the previous working day,
|
|
19
|
+
what they are working on and what is waiting — point to `yad standup` (E132) instead of rebuilding it.
|
|
20
|
+
|
|
18
21
|
### Step 1 — Resolve the epic
|
|
19
22
|
If no `EP-<slug>` was given, list the epics under `{project-root}/epics/` and ask which one (or
|
|
20
23
|
report all if the user asked for an overview).
|