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/checkpoint.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `yad checkpoint` — commit the machine-written Build
|
|
1
|
+
// `yad checkpoint` — commit the machine-written Build state on the Product (trust-log / build-log /
|
|
2
2
|
// build-state) as one audit-trail commit. This is the Build analogue of the Shape gate sync
|
|
3
3
|
// (cli/gate.mjs): the SDLC's Build part (yad-run, yad-engineer-review) WRITES these ledgers into the
|
|
4
4
|
// working tree but never commits them, so teammates/CI/`yad status` on other machines see stale trust
|
|
@@ -22,13 +22,13 @@
|
|
|
22
22
|
// marker would strand the PR's required checks.
|
|
23
23
|
import fs from 'node:fs';
|
|
24
24
|
import path from 'node:path';
|
|
25
|
-
import { c, log, ok, info, fail, hand, exists, readJSON, pushWithRebase } from './lib.mjs';
|
|
26
|
-
import { PROJECT_FILES } from './manifest.mjs';
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
25
|
+
import { c, log, ok, info, fail, hand, exists, readJSON, readJSONStrict, pushWithRebase } from './lib.mjs';
|
|
26
|
+
import { PROJECT_FILES , productConfigPath } from './manifest.mjs';
|
|
27
|
+
import { loadProduct } from './gate.mjs';
|
|
28
|
+
import { platformLogin } from './platform.mjs';
|
|
29
|
+
import { productGit, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
|
|
30
30
|
import { readShips, writeRetroShip } from './ledger.mjs';
|
|
31
|
-
import { readFrontmatter } from './epic-state.mjs';
|
|
31
|
+
import { readFrontmatter, declaredRepos } from './epic-state.mjs';
|
|
32
32
|
|
|
33
33
|
// The machine-written Build ledgers, relative to an epic's dir. The two append-only logs are
|
|
34
34
|
// shard-then-fold (cli/ledger.mjs): each is a folded file PLUS a shard dir of loose per-entry files —
|
|
@@ -130,7 +130,7 @@ export function summarizeStaged(files = []) {
|
|
|
130
130
|
// path from breaking the one-line subject or injecting a fake trailer line.
|
|
131
131
|
const oneLine = (s = '') => String(s).replace(/\s+/g, ' ').trim();
|
|
132
132
|
|
|
133
|
-
// `@login` from the
|
|
133
|
+
// `@login` from the platform CLI (the auditable handle the user asked for), else the raw git user.name,
|
|
134
134
|
// else a stable placeholder so the subject is never empty.
|
|
135
135
|
export function checkpointAuthor(login, name) {
|
|
136
136
|
if (login) return `@${oneLine(login)}`;
|
|
@@ -138,7 +138,7 @@ export function checkpointAuthor(login, name) {
|
|
|
138
138
|
return n || 'unknown';
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
// PURE — the audit-trail commit message. The subject passes the
|
|
141
|
+
// PURE — the audit-trail commit message. The subject passes the Product commit-message gate (valid type
|
|
142
142
|
// `chore`, optional scope `hub`, non-empty description, no trailing period). No Task trailer and no
|
|
143
143
|
// Co-Authored-By footer: this is human-owned machine state, not an authored code change. `label` and
|
|
144
144
|
// `author` are collapsed to one line so nothing can split the subject or forge a trailer.
|
|
@@ -151,7 +151,7 @@ export function buildCheckpointMessage({ label, author, basenames = [] }) {
|
|
|
151
151
|
// True iff a story file's STAGED diff changes ONLY the frontmatter `status:` line — every added or
|
|
152
152
|
// removed content line is a `status:` line. A newly-added file (all lines added) or any prose/other
|
|
153
153
|
// edit fails, so only a clean flip is carried into the `[skip ci]` chore commit; anything broader is
|
|
154
|
-
// left for a reviewed change (#112 review-bypass guard). `git` is a
|
|
154
|
+
// left for a reviewed change (#112 review-bypass guard). `git` is a productGit-style accessor.
|
|
155
155
|
export function stagedStoryIsStatusOnly(git, file) {
|
|
156
156
|
const d = git('diff', '--cached', '-U0', '--', file);
|
|
157
157
|
if (!d.ok) return false;
|
|
@@ -176,15 +176,14 @@ export function stagedStoryIsStatusOnly(git, file) {
|
|
|
176
176
|
// `--repo`; the second run finds the flip already committed, so it lands only the new ship shard.
|
|
177
177
|
//
|
|
178
178
|
// The repo names a retro ship MAY carry — the story's own `repos:` frontmatter (its statement of where
|
|
179
|
-
// it was implemented), else the
|
|
179
|
+
// it was implemented), else the Product's connected-repo registry as the project-wide fallback. Used to
|
|
180
180
|
// reject a typo'd/mis-cased/invented `--repo` (#166 review): once the duplicate guard is per repo, a
|
|
181
181
|
// wrong name no longer collides with anything, so nothing else would stop it from committing a
|
|
182
182
|
// `retroactive: true` ship for a repo that never existed. Returns `{ names: [], source: 'none' }` when
|
|
183
183
|
// neither declares anything — a legacy story with no metadata is still backfillable, never blocked on
|
|
184
184
|
// a missing list.
|
|
185
185
|
export function retroShipRepos(root, storyFile) {
|
|
186
|
-
const
|
|
187
|
-
const story = (Array.isArray(declared) ? declared : declared ? [declared] : []).map(String).filter(Boolean);
|
|
186
|
+
const story = declaredRepos(readFrontmatter(storyFile));
|
|
188
187
|
if (story.length) return { names: story, source: 'story' };
|
|
189
188
|
const reg = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
|
|
190
189
|
const names = (Array.isArray(reg?.repos) ? reg.repos : []).map((r) => r?.name).filter(Boolean);
|
|
@@ -215,20 +214,38 @@ export function recordRetroShip(root, { epic, story, repo, task, mergeCommit, to
|
|
|
215
214
|
// repo (`source: 'none'`), so a legacy story with no metadata is never blocked.
|
|
216
215
|
const { names, source } = retroShipRepos(root, storyFile);
|
|
217
216
|
if (names.length && !names.includes(repo)) {
|
|
218
|
-
fail(`${repo} is not a repo ${source === 'story' ? `${story} declares` : 'connected to this
|
|
217
|
+
fail(`${repo} is not a repo ${source === 'story' ? `${story} declares` : 'connected to this Product'} — a retroactive ship must name a real repo, never invent one`);
|
|
219
218
|
hand(`known: ${names.join(', ')} (names are case-sensitive)`);
|
|
220
219
|
return { ok: false };
|
|
221
220
|
}
|
|
221
|
+
// A lane SKIPPED whole (E39) did not ship: recording a ship for it would make the two records contradict.
|
|
222
|
+
// And a skipped repo is not "still unrecorded" — it is owed nothing — so it is left out of `remaining`.
|
|
223
|
+
// Read STRICTLY: a corrupt file would otherwise read as "nothing skipped" and let a permanent ship be
|
|
224
|
+
// recorded over a skip — the same refusal to guess this function makes for a corrupt build-log (E39 review).
|
|
225
|
+
let laneRepos;
|
|
226
|
+
try { laneRepos = readJSONStrict(path.join(epicDir, '.sdlc', 'build-state', `${story}.json`), null)?.repos; }
|
|
227
|
+
catch (e) {
|
|
228
|
+
fail(`could not read ${story}'s build-state — ${e.message}`);
|
|
229
|
+
hand('a ship is permanent audit evidence, so it is not recorded while the lane state cannot be read — fix the file, then re-run');
|
|
230
|
+
return { ok: false };
|
|
231
|
+
}
|
|
232
|
+
const skipped = new Set(laneRepos && typeof laneRepos === 'object'
|
|
233
|
+
? Object.entries(laneRepos).filter(([, lane]) => lane?.status === 'skipped').map(([name]) => name) : []);
|
|
234
|
+
if (skipped.has(repo)) {
|
|
235
|
+
fail(`${story} / ${repo} is skipped — a lane that was skipped did not ship`);
|
|
236
|
+
hand(`if it did ship after all, put the lane back first: yad unskip ${epic} ${story} --repo ${repo}`);
|
|
237
|
+
return { ok: false };
|
|
238
|
+
}
|
|
222
239
|
// Which of the story's OWN declared repos still lack evidence — read BEFORE the write so both the
|
|
223
240
|
// refusal and the success path can report honestly how much of a multi-repo backfill is left. Only
|
|
224
|
-
// the story's own list is used: the
|
|
241
|
+
// the story's own list is used: the Product registry lists every connected repo, which says nothing
|
|
225
242
|
// about where THIS story shipped.
|
|
226
243
|
const remaining = () => {
|
|
227
244
|
if (source !== 'story') return [];
|
|
228
245
|
let recorded;
|
|
229
246
|
try { recorded = new Set(readShips(epicDir).filter((s) => s.story === story).map((s) => s.repo)); }
|
|
230
247
|
catch { return []; } // corrupt build-log — the write below reports it; don't guess at progress
|
|
231
|
-
return names.filter((n) => !recorded.has(n) && n !== repo);
|
|
248
|
+
return names.filter((n) => !recorded.has(n) && n !== repo && !skipped.has(n));
|
|
232
249
|
};
|
|
233
250
|
const left = remaining();
|
|
234
251
|
|
|
@@ -289,14 +306,14 @@ function cleanupRetroShard(file) {
|
|
|
289
306
|
export async function runCheckpoint(root, opts = {}) {
|
|
290
307
|
log(c.bold('\nyad checkpoint'));
|
|
291
308
|
if (!exists(path.join(root, '.git'))) { fail('not a git repo'); process.exitCode = 1; return; }
|
|
292
|
-
if (!exists(
|
|
293
|
-
fail('no .sdlc/hub.json — checkpoint commits the
|
|
309
|
+
if (!exists(productConfigPath(root))) {
|
|
310
|
+
fail('no .sdlc/hub.json — checkpoint commits the Product Build ledger; run it from the Product');
|
|
294
311
|
process.exitCode = 1;
|
|
295
312
|
return;
|
|
296
313
|
}
|
|
297
314
|
|
|
298
|
-
const { hub } =
|
|
299
|
-
const git =
|
|
315
|
+
const { hub } = loadProduct(root);
|
|
316
|
+
const git = productGit(root);
|
|
300
317
|
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
|
|
301
318
|
const defaultBranch = resolveDefaultBranch(git, hub);
|
|
302
319
|
|
|
@@ -321,11 +338,13 @@ export async function runCheckpoint(root, opts = {}) {
|
|
|
321
338
|
// plain `yad checkpoint` as permanent `retroactive: true` audit evidence nobody chose to record.
|
|
322
339
|
// A real run deliberately keeps its shard on a failure (see the commit-failed path below).
|
|
323
340
|
const rollbackRetro = () => { if (opts.dryRun && retroFile) cleanupRetroShard(retroFile); };
|
|
341
|
+
// The --json answer (E1) of a run that had nothing to commit.
|
|
342
|
+
const nothing = () => ({ message: null, files: [], committed: false, pushed: false, dryRun: !!opts.dryRun, retroShip: null });
|
|
324
343
|
|
|
325
344
|
// The machine ledgers PLUS any build-log-backed story `status:` flip (#112) — one commit records
|
|
326
345
|
// both, so the story artifact never drifts from build-log and no raw git-to-main push is needed.
|
|
327
346
|
const pathspecs = [...buildLedgerPathspecs(root), ...storyStatusPathspecs(root)];
|
|
328
|
-
if (!pathspecs.length) { rollbackRetro(); info('no Build ledgers found — nothing to checkpoint'); return; }
|
|
347
|
+
if (!pathspecs.length) { rollbackRetro(); info('no Build ledgers found — nothing to checkpoint'); return nothing(); }
|
|
329
348
|
|
|
330
349
|
// Stage the allowlist. `git add -- <spec>` picks up new + modified files, and deletions of tracked
|
|
331
350
|
// files WITHIN a still-present spec (e.g. a removed build-state/<story>.json). A wholesale-deleted
|
|
@@ -349,7 +368,7 @@ export async function runCheckpoint(root, opts = {}) {
|
|
|
349
368
|
if (git('diff', '--cached', '--quiet', '--', ...pathspecs).ok) {
|
|
350
369
|
rollbackRetro();
|
|
351
370
|
info('Build state unchanged — nothing to commit');
|
|
352
|
-
return;
|
|
371
|
+
return nothing();
|
|
353
372
|
}
|
|
354
373
|
// The exact files staged from the allowlist — all known to git by construction, so they are the
|
|
355
374
|
// pathspec for the commit (a directory spec like build-state/ would make `git commit -- <dir>` fail
|
|
@@ -358,7 +377,7 @@ export async function runCheckpoint(root, opts = {}) {
|
|
|
358
377
|
const staged = git('diff', '--cached', '--name-only', '--', ...pathspecs).stdout.split('\n').filter(Boolean);
|
|
359
378
|
|
|
360
379
|
const { label, basenames } = summarizeStaged(staged);
|
|
361
|
-
const author = checkpointAuthor(
|
|
380
|
+
const author = checkpointAuthor(platformLogin(root, hub?.platform), git('config', 'user.name').stdout);
|
|
362
381
|
const message = buildCheckpointMessage({ label, author, basenames });
|
|
363
382
|
|
|
364
383
|
if (opts.dryRun) {
|
|
@@ -368,7 +387,7 @@ export async function runCheckpoint(root, opts = {}) {
|
|
|
368
387
|
// run leaves no side effect on disk (git reset only unstaged it, back to untracked).
|
|
369
388
|
rollbackRetro();
|
|
370
389
|
info('dry run — not committed');
|
|
371
|
-
return { message };
|
|
390
|
+
return { message, files: staged, committed: false, pushed: false, dryRun: true, retroShip: null };
|
|
372
391
|
}
|
|
373
392
|
|
|
374
393
|
const cm = git('commit', '-m', message, '--', ...staged);
|
|
@@ -381,14 +400,15 @@ export async function runCheckpoint(root, opts = {}) {
|
|
|
381
400
|
return { message };
|
|
382
401
|
}
|
|
383
402
|
ok(`checkpointed ${staged.length} file(s): ${c.dim(label)}`);
|
|
403
|
+
const done = { message, files: staged, committed: true, dryRun: false, retroShip: retroFile ? path.relative(root, retroFile) : null };
|
|
384
404
|
|
|
385
|
-
if (!opts.push) return {
|
|
405
|
+
if (!opts.push) return { ...done, pushed: false };
|
|
386
406
|
// Push HEAD to its OWN branch — never to `defaultBranch` blindly. On the default branch these are the
|
|
387
407
|
// same; with --allow-branch on a WIP branch, pushing HEAD:defaultBranch would publish the whole WIP
|
|
388
408
|
// branch to the default branch (bypassing review), so the target is always the branch we are on.
|
|
389
|
-
if (pushWithRebase(root, branch).ok) { ok(`pushed to origin/${branch}`); return {
|
|
409
|
+
if (pushWithRebase(root, branch).ok) { ok(`pushed to origin/${branch}`); return { ...done, pushed: true }; }
|
|
390
410
|
fail(`could not push to origin/${branch} — a protected branch, or the append-only ledgers hit an unresolvable rebase conflict`);
|
|
391
411
|
hand(`run \`git pull --rebase\` and re-run \`yad checkpoint --push\``);
|
|
392
412
|
process.exitCode = 1;
|
|
393
|
-
return {
|
|
413
|
+
return { ...done, pushed: false };
|
|
394
414
|
}
|
package/cli/claims.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// `yad claims` (E46) — who else is editing which artifact, read from the E43 capture branches.
|
|
2
|
+
//
|
|
3
|
+
// "architecture.md — claimed by alice, 10:04." ADVICE, NOT A LOCK: with no server a real lock is impossible,
|
|
4
|
+
// and a claim never stops anyone. The decisions were the user's (2026-09-25):
|
|
5
|
+
// 1. SOURCE: the capture branches, and nothing else. There is no claim file and no `yad claim` command —
|
|
6
|
+
// "record" is E43's capture, which already commits every edit to `yad/wip/<name>/<epic>` and pushes it.
|
|
7
|
+
// 2. SHOW: this command (it fetches everyone's capture branches first), and a warning at edit time from the
|
|
8
|
+
// post-edit capture hook, which reads only what is already fetched and never blocks. On Claude Code the
|
|
9
|
+
// warning reaches the agent (`additionalContext`); elsewhere it is a line on stderr.
|
|
10
|
+
// 3. EXPIRY: 4 hours after the branch's last capture — and at once for a file whose change is on the default
|
|
11
|
+
// branch (its content there equals the capture's), so a merged fold releases its files.
|
|
12
|
+
// 4. FRESHNESS: the hook's throttled background push also starts a background fetch of everyone's capture
|
|
13
|
+
// branches (cli/capture.mjs), so what the hook reads is minutes old, not hours.
|
|
14
|
+
//
|
|
15
|
+
// WHAT A CLAIM IS: a file of the epic that differs between the capture branch's tip and the tip's `Yad-Base`
|
|
16
|
+
// — the HEAD that capture was built on. That is exactly the person's own in-progress edits. It is NOT the
|
|
17
|
+
// diff against the previous capture: after a pull, a capture's tree is the NEW HEAD plus the edits, and the
|
|
18
|
+
// pull's changes would read as that person's work. When `Yad-Base` is not in this clone (a later HEAD is not
|
|
19
|
+
// an ancestor of the branch, so it was never pushed with it), the commit the branch's FIRST capture was built
|
|
20
|
+
// on is the fallback, marked `basis: 'first-capture'` — over-reporting is the safe direction for advice.
|
|
21
|
+
// The hook's background fetch and push can both move this person's own remote-tracking copies at once; one
|
|
22
|
+
// may lose the race in silence. Nothing reads those copies while the local branch exists, and a capture by
|
|
23
|
+
// hand fetches (with prune) before it relies on them.
|
|
24
|
+
//
|
|
25
|
+
// LIMITS, said in the README: capture off, or offline, is invisible; the push runs at most every 5 minutes;
|
|
26
|
+
// two people with one git name share branches, so they never see each other; clocks are the committer's.
|
|
27
|
+
import { spawnSync } from 'node:child_process';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import { ok, info, warn, fail, hand, readJSON, writeJSON } from './lib.mjs';
|
|
30
|
+
import { productConfigPath } from './manifest.mjs';
|
|
31
|
+
import { capturedEpic, gitIn, isOwnerPath, pushEnv, wipName, WIP_PREFIX, fetchAllArgs } from './capture.mjs';
|
|
32
|
+
import { resolveDefaultBranch } from './hubcommit.mjs';
|
|
33
|
+
|
|
34
|
+
export const CLAIM_HOURS = 4;
|
|
35
|
+
const CLAIM_MS = CLAIM_HOURS * 60 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
// The default branch as a ref this clone holds — origin's copy first, then a local branch — or null.
|
|
38
|
+
function defaultRef(root, git) {
|
|
39
|
+
const hub = readJSON(productConfigPath(root), {}) || {};
|
|
40
|
+
const def = resolveDefaultBranch((...a) => { const r = git(a); return { ok: r.ok, stdout: r.out.trim() }; }, hub);
|
|
41
|
+
for (const ref of [`refs/remotes/origin/${def}`, `refs/heads/${def}`]) if (git(['rev-parse', '--verify', '-q', `${ref}^{commit}`]).ok) return { ref, name: def };
|
|
42
|
+
return { ref: null, name: def };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Every open claim this clone can see. `epics` narrows to those epics; `now` is a number of milliseconds.
|
|
46
|
+
// Returns { me, claims: [{ path, epic, name, person, lastSavedAt, mine, basis }], defaultBranch }.
|
|
47
|
+
export function readClaims(root, { env = process.env, now = Date.now(), epics = null } = {}) {
|
|
48
|
+
const git = gitIn(root, env);
|
|
49
|
+
const prefix = git(['rev-parse', '--show-prefix']).out.trim();
|
|
50
|
+
const me = wipName(git(['config', 'user.name']).out.trim(), git(['config', 'user.email']).out.trim());
|
|
51
|
+
const def = defaultRef(root, git);
|
|
52
|
+
const empty = git(['hash-object', '-t', 'tree', '--stdin'], '').out.trim();
|
|
53
|
+
const fmt = '%(refname)%00%(objectname)%00%(committerdate:unix)%00%(authorname)%00%(trailers:key=Yad-Base,valueonly)';
|
|
54
|
+
// Others' branches from the remote-tracking copies; my own from the local branches, which are the freshest.
|
|
55
|
+
const roots = [`refs/remotes/origin/${WIP_PREFIX}/`, ...(me ? [`refs/heads/${WIP_PREFIX}/${me}/`] : [])];
|
|
56
|
+
// `trailer.separators=:` — capture writes `Yad-Base: <sha>`; a person's own separators setting would hide it.
|
|
57
|
+
const listed = git(['-c', 'trailer.separators=:', 'for-each-ref', `--format=${fmt}`, ...roots]);
|
|
58
|
+
const claims = [];
|
|
59
|
+
for (const line of listed.ok ? listed.out.split('\n').filter(Boolean) : []) {
|
|
60
|
+
const [ref, tip, date, person, baseRaw = ''] = line.split('\0');
|
|
61
|
+
const rest = ref.replace(/^refs\/(remotes\/origin|heads)\//, '').slice(WIP_PREFIX.length + 1);
|
|
62
|
+
const slash = rest.indexOf('/');
|
|
63
|
+
const name = rest.slice(0, slash);
|
|
64
|
+
const epic = rest.slice(slash + 1);
|
|
65
|
+
if (slash < 1 || !/^EP-[^/]+$/.test(epic)) continue;
|
|
66
|
+
if (epics && !epics.has(epic)) continue;
|
|
67
|
+
const remote = ref.startsWith('refs/remotes/');
|
|
68
|
+
if (remote && name === me) continue; // my own: the local branch speaks for it
|
|
69
|
+
const savedMs = Number(date) * 1000;
|
|
70
|
+
if (!Number.isFinite(savedMs) || now - savedMs > CLAIM_MS) continue;
|
|
71
|
+
const base = baseRaw.trim();
|
|
72
|
+
let against;
|
|
73
|
+
let basis = 'yad-base';
|
|
74
|
+
if (base === 'none') against = empty;
|
|
75
|
+
else if (base && git(['cat-file', '-e', `${base}^{tree}`]).ok) against = base;
|
|
76
|
+
else {
|
|
77
|
+
// Not in this clone. The commit the branch's FIRST capture was built on always is (it is an ancestor, so
|
|
78
|
+
// it was pushed with the branch): diffing against it takes in every capture since, and maybe a pull's
|
|
79
|
+
// files too — over-reporting, the safe side for advice, where the previous capture would drop earlier edits.
|
|
80
|
+
basis = 'first-capture';
|
|
81
|
+
// One git call, however long the branch: the newest first-parent ancestor that is not a capture.
|
|
82
|
+
// `--basic-regexp` and `--no-show-signature` pin it against a person's own git config: another
|
|
83
|
+
// `grep.patternType` matches nothing (the tip comes back, and the claims vanish), and
|
|
84
|
+
// `log.showSignature` prints a signature check before the hash.
|
|
85
|
+
const first = git(['log', '--no-show-signature', '--basic-regexp', '--first-parent', '--invert-grep', '--grep=^wip(EP-[^)]*): capture$', '-n', '1', '--format=%H', tip]);
|
|
86
|
+
against = first.ok && first.out.trim() ? first.out.trim() : empty;
|
|
87
|
+
}
|
|
88
|
+
const diff = git(['diff-tree', '-r', '-z', '--name-only', '--no-renames', against, tip]);
|
|
89
|
+
if (!diff.ok) continue;
|
|
90
|
+
let files = diff.out.split('\0').filter((p) => p && p.startsWith(prefix)).map((p) => p.slice(prefix.length))
|
|
91
|
+
// A step owner file (E47) rides the assigner's capture, but assigning is not editing an artifact.
|
|
92
|
+
.filter((p) => capturedEpic(p) === epic && !isOwnerPath(p));
|
|
93
|
+
// Landed: the file's content on the default branch is the capture's — the work is merged, the claim ends.
|
|
94
|
+
if (files.length && def.ref) {
|
|
95
|
+
const differs = git(['diff-tree', '-r', '-z', '--name-only', '--no-renames', def.ref, tip]);
|
|
96
|
+
if (differs.ok) {
|
|
97
|
+
const set = new Set(differs.out.split('\0').filter((p) => p && p.startsWith(prefix)).map((p) => p.slice(prefix.length)));
|
|
98
|
+
files = files.filter((p) => set.has(p));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const p of files) {
|
|
102
|
+
claims.push({ path: p, epic, name, person: person || name, lastSavedAt: new Date(savedMs).toISOString(), mine: name === me, basis });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
claims.sort((a, b) => a.path.localeCompare(b.path) || (a.mine - b.mine) || a.name.localeCompare(b.name));
|
|
106
|
+
return { me, claims, defaultBranch: def.name };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const clock = (iso) => iso.slice(11, 16);
|
|
110
|
+
const ago = (iso, now) => {
|
|
111
|
+
const m = Math.max(0, Math.round((now - Date.parse(iso)) / 60000));
|
|
112
|
+
return m < 60 ? `${m} min ago` : `${Math.floor(m / 60)} h ${m % 60} min ago`;
|
|
113
|
+
};
|
|
114
|
+
export const claimLine = (c, now) => `${c.path} — ${c.person}${c.mine ? ' (you)' : ''}, last saved ${clock(c.lastSavedAt)} UTC (${ago(c.lastSavedAt, now)})`;
|
|
115
|
+
|
|
116
|
+
// The capture hook's check (cli/capture.mjs): of the files this capture changed, which does someone ELSE
|
|
117
|
+
// also have open? Reads only what is already fetched. Repeats are held back per person and file for an hour
|
|
118
|
+
// (kept in the capture's per-clone state file), or an agent would hear the same line on every edit.
|
|
119
|
+
export const WARN_AGAIN_MS = 60 * 60 * 1000;
|
|
120
|
+
export function claimWarnings(root, changed, { env = process.env, now = Date.now(), statePath = null } = {}) {
|
|
121
|
+
if (!changed.length) return [];
|
|
122
|
+
const epics = new Set(changed.map((p) => capturedEpic(p)).filter(Boolean));
|
|
123
|
+
const mine = new Set(changed);
|
|
124
|
+
const hits = readClaims(root, { env, now, epics }).claims.filter((c) => !c.mine && mine.has(c.path));
|
|
125
|
+
if (!hits.length || !statePath) return hits;
|
|
126
|
+
let state;
|
|
127
|
+
try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); } catch (e) { if (e.code !== 'ENOENT') return hits; state = {}; }
|
|
128
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) return hits; // not ours to overwrite
|
|
129
|
+
const warned = state.claimsWarned && typeof state.claimsWarned === 'object' ? state.claimsWarned : {};
|
|
130
|
+
const fresh = hits.filter((c) => !(now - (Number(warned[`${c.name}\0${c.path}`]) || 0) < WARN_AGAIN_MS));
|
|
131
|
+
const kept = Object.fromEntries(Object.entries(warned).filter(([, t]) => now - Number(t) < CLAIM_MS));
|
|
132
|
+
for (const c of fresh) kept[`${c.name}\0${c.path}`] = now;
|
|
133
|
+
try { writeJSON(statePath, { ...state, claimsWarned: kept }); } catch { /* a cache */ }
|
|
134
|
+
return fresh;
|
|
135
|
+
}
|
|
136
|
+
export const warningText = (hits, now) => `yad claims: ${hits.length === 1 ? 'this file is' : 'these files are'} also being edited by someone else — advice, not a lock; talk to them before you go further:\n${hits.map((c) => ` • ${claimLine(c, now)}`).join('\n')}`;
|
|
137
|
+
|
|
138
|
+
// `yad claims [<epic>] [--no-fetch]`. Returns a plain object (E1 makes it the `--json` answer).
|
|
139
|
+
export async function runClaims(root, { epic = null, noFetch = false, env = process.env, now = Date.now() } = {}) {
|
|
140
|
+
const refuse = (msg, hint) => { fail(msg); if (hint) hand(hint); process.exitCode = 1; };
|
|
141
|
+
const git = gitIn(root, env);
|
|
142
|
+
if (!git(['rev-parse', '--show-toplevel']).ok) return refuse('not a git repository — there are no capture branches to read');
|
|
143
|
+
if (!fs.existsSync(productConfigPath(root))) return refuse('not a Product (no .sdlc/hub.json or product.json here)', 'run it from the Product root, or pass --dir');
|
|
144
|
+
if (epic && !/^EP-[a-z0-9-]+$/.test(epic)) return refuse(`not an epic id: ${epic}`);
|
|
145
|
+
let fetched = 'skipped';
|
|
146
|
+
if (!noFetch) {
|
|
147
|
+
if (!git(['remote', 'get-url', 'origin']).ok) fetched = 'local';
|
|
148
|
+
else {
|
|
149
|
+
const opts = { cwd: root, encoding: 'utf8', timeout: 30_000, env: { ...env, ...pushEnv(env) } };
|
|
150
|
+
const r = spawnSync('git', fetchAllArgs({ prune: true }), opts);
|
|
151
|
+
fetched = r.status === 0 ? 'done' : 'failed';
|
|
152
|
+
// The default branch, for the "landed" rule — on its own: a name guessed wrong (no hub default, no
|
|
153
|
+
// origin/HEAD) must not stop the capture branches from arriving. Its failure changes nothing.
|
|
154
|
+
const def = defaultRef(root, git).name;
|
|
155
|
+
if (fetched === 'done') spawnSync('git', ['-c', 'http.lowSpeedLimit=1000', '-c', 'http.lowSpeedTime=20', 'fetch', '--quiet', '--no-tags', 'origin', `+refs/heads/${def}:refs/remotes/origin/${def}`], opts);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (fetched === 'local') info('no remote named origin — only your own captures can be read here');
|
|
159
|
+
if (fetched === 'failed') warn('could not fetch the capture branches — showing what was last fetched, which may be old');
|
|
160
|
+
const { claims, defaultBranch } = readClaims(root, { env, now, epics: epic ? new Set([epic]) : null });
|
|
161
|
+
if (!claims.length) ok(`no open claims${epic ? ` on ${epic}` : ''} — nobody's capture branch has an unmerged change saved in the last ${CLAIM_HOURS} hours`);
|
|
162
|
+
for (const c of claims) info(claimLine(c, now));
|
|
163
|
+
if (claims.length) info(`advice, not a lock — a claim ends ${CLAIM_HOURS} hours after the last save, or once the file on ${defaultBranch} matches it`);
|
|
164
|
+
return { fetched, expireHours: CLAIM_HOURS, defaultBranch, claims };
|
|
165
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// `yad codeowners check [<repo>]` — warn where a code repo's CODEOWNERS has gone stale (E69). The rules for
|
|
2
|
+
// reading the file are E68's (cli/codeowners.mjs); this finds the repos, reads each file from disk, and
|
|
3
|
+
// prints. Part 3: "CODEOWNERS is a hint, never an authority — in practice these files rot." So this only
|
|
4
|
+
// ever WARNS: it never sets a failing exit code for a finding, never writes the file (there is no
|
|
5
|
+
// `--write`: any name yad wrote would become an owner the platform can enforce), and never says who owns
|
|
6
|
+
// code.
|
|
7
|
+
//
|
|
8
|
+
// Two kinds of answer, kept apart:
|
|
9
|
+
// FACTS — a line that matches no file, a GitHub file of 3 MB or more, a second file the platform never
|
|
10
|
+
// reads, a line this reader cannot read. `yad doctor` prints these too.
|
|
11
|
+
// A HINT — the @logins with no commit in the last 90 days that carries their noreply address. Only a
|
|
12
|
+
// hint: a person may commit under a work address. Printed by this command only, never by the doctor,
|
|
13
|
+
// and only where a noreply address is evidence (a github.com or gitlab.com remote).
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
|
|
17
|
+
import { c, fail, hand, info, log, ok, run, warn, emitJSON } from './lib.mjs';
|
|
18
|
+
import { deadLines, diskCodeowners, parseCodeowners } from './codeowners.mjs';
|
|
19
|
+
import { OWNER_WINDOW, recentLoginsFor, repoFiles, targets } from './riskmap-command.mjs';
|
|
20
|
+
import { PUBLIC_HOST, remoteHost } from './openpr.mjs';
|
|
21
|
+
import { detectPlatform } from './platform.mjs';
|
|
22
|
+
|
|
23
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
24
|
+
const PLATFORM_NAME = { github: 'GitHub', gitlab: 'GitLab' };
|
|
25
|
+
// "90 days ago" → "90 days", for the sentences.
|
|
26
|
+
const WINDOW_WORDS = OWNER_WINDOW.replace(/ ago$/, '');
|
|
27
|
+
|
|
28
|
+
// The paths of a repo's submodules: their contents are not in its file list.
|
|
29
|
+
function submodulesOf(repoRoot) {
|
|
30
|
+
const r = spawnSync('git', ['ls-files', '-z', '--stage'], { cwd: repoRoot, encoding: 'utf8', maxBuffer: 1 << 30 });
|
|
31
|
+
if (r.status !== 0) return [];
|
|
32
|
+
return r.stdout.split('\0').filter((l) => l.startsWith('160000 ')).map((l) => l.slice(l.indexOf('\t') + 1));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A pattern is printed as written unless it holds an `@`: E67's rule that no e-mail address is ever printed.
|
|
36
|
+
const shownPattern = (d) => (d.pattern.includes('@') ? '' : ` (\`${d.negate ? '!' : ''}${d.pattern}\`)`);
|
|
37
|
+
|
|
38
|
+
// One repo → { git, platform, path?, none?, unknown?, tooBig?, ignored, notRead, dead, inactive? }. With
|
|
39
|
+
// `hint`, `inactive` is { unknown } or { quiet, checked, notChecked } (see the header).
|
|
40
|
+
export function checkCodeowners(repoRoot, { platform = null, remote, hint = false } = {}) {
|
|
41
|
+
// A work tree, not only a repo: a bare repo has no files on disk to check.
|
|
42
|
+
if (run('git', ['rev-parse', '--is-inside-work-tree'], { cwd: repoRoot }).stdout !== 'true') return { git: false };
|
|
43
|
+
// The platform reads CODEOWNERS from the repo's top folder, and the file list must be the whole repo's.
|
|
44
|
+
// `.native`: git names the folder as the disk stores it, and on a disk that ignores case the plain
|
|
45
|
+
// `realpathSync` keeps the case it was given (`../MyRepo` for `myrepo`) — never the same folder twice.
|
|
46
|
+
const top = run('git', ['rev-parse', '--show-toplevel'], { cwd: repoRoot }).stdout;
|
|
47
|
+
if (!top || fs.realpathSync.native(top) !== fs.realpathSync.native(repoRoot)) {
|
|
48
|
+
return { git: true, platform: null, ignored: [], notRead: [], dead: [], unknown: 'this folder is inside a git repo but is not its top folder, where the platform reads CODEOWNERS — check the repo\'s top folder instead' };
|
|
49
|
+
}
|
|
50
|
+
const url = remote ?? run('git', ['remote', 'get-url', 'origin'], { cwd: repoRoot }).stdout;
|
|
51
|
+
const plat = platform || detectPlatform(url || '');
|
|
52
|
+
const base = { git: true, platform: plat, ignored: [], notRead: [], dead: [] };
|
|
53
|
+
if (!plat) return { ...base, unknown: 'yad cannot tell whether this repo is on GitHub or GitLab (no `platform` in repos.json, and the origin remote names neither) — pass --platform' };
|
|
54
|
+
const co = diskCodeowners(repoRoot, plat);
|
|
55
|
+
if (co.unknown) return { ...base, unknown: co.unknown };
|
|
56
|
+
if (co.none) return { ...base, none: co.none };
|
|
57
|
+
const out = { ...base, path: co.path, ignored: co.ignored };
|
|
58
|
+
if (co.tooBig) return { ...out, tooBig: true };
|
|
59
|
+
// Only now the file list: it is slow on a large repo, and a repo with no CODEOWNERS never needs it.
|
|
60
|
+
const files = repoFiles(repoRoot);
|
|
61
|
+
if (!files) return { ...base, unknown: 'git could not list the files in this repo' };
|
|
62
|
+
const parsed = parseCodeowners(co.text, plat);
|
|
63
|
+
out.notRead = parsed.skipped;
|
|
64
|
+
out.dead = deadLines(parsed, files, { submodules: submodulesOf(repoRoot) });
|
|
65
|
+
if (hint) out.inactive = inactiveOwners(repoRoot, parsed, plat, url);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The hint: which @logins no recent commit names. Only a person's login can be checked — a team, group,
|
|
70
|
+
// role or address has no noreply commits of its own — and only on the public host a noreply login
|
|
71
|
+
// belongs to. On GitLab an `@name` may be a group; the sentence printed is true either way.
|
|
72
|
+
function inactiveOwners(repoRoot, parsed, platform, url) {
|
|
73
|
+
const persons = new Map();
|
|
74
|
+
const others = new Map();
|
|
75
|
+
for (const r of parsed.rules) {
|
|
76
|
+
if (r.negate) continue;
|
|
77
|
+
for (const o of r.owners) {
|
|
78
|
+
if (o.kind === 'user' || o.kind === 'name') {
|
|
79
|
+
const k = o.text.slice(1).toLowerCase();
|
|
80
|
+
if (!persons.has(k)) persons.set(k, o.text);
|
|
81
|
+
} else others.set(`${o.kind}\0${o.key || o.text}`, o.kind);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const notChecked = {};
|
|
85
|
+
for (const kind of others.values()) notChecked[kind] = (notChecked[kind] || 0) + 1;
|
|
86
|
+
if (!persons.size) return { quiet: [], checked: 0, notChecked };
|
|
87
|
+
const host = PUBLIC_HOST[platform];
|
|
88
|
+
if (remoteHost(url) !== host) {
|
|
89
|
+
return { unknown: `the origin remote is not on ${host}, and a noreply address is evidence only for a ${host} account (a self-managed server keeps its own accounts)` };
|
|
90
|
+
}
|
|
91
|
+
const h = recentLoginsFor(repoRoot, 'HEAD');
|
|
92
|
+
if (h.unknown) return { unknown: h.unknown };
|
|
93
|
+
const seen = new Set(h.logins.filter((l) => l.host === platform).map((l) => l.login.toLowerCase()));
|
|
94
|
+
return { quiet: [...persons].filter(([k]) => !seen.has(k)).map(([, text]) => text), checked: persons.size, notChecked };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The facts as a list: [{ code, line?, target?, message }] — what `yad doctor` summarises and `--json` prints.
|
|
98
|
+
export function codeownersFindings(r) {
|
|
99
|
+
const out = [];
|
|
100
|
+
const name = PLATFORM_NAME[r.platform] || r.platform;
|
|
101
|
+
if (r.tooBig) out.push({ code: 'too-big', target: r.path, message: `${r.path} is 3 MB or more — GitHub does not load a file that large, so it lists no owner for any file` });
|
|
102
|
+
for (const p of r.ignored) out.push({ code: 'never-read', target: p, message: `${p} is never read — ${name} reads ${r.path} first` });
|
|
103
|
+
for (const s of r.notRead) out.push({ code: 'not-read', line: s.line, message: `line ${s.line} not read — ${s.why}` });
|
|
104
|
+
for (const d of r.dead) {
|
|
105
|
+
out.push({ code: 'matches-nothing', line: d.line, message: `line ${d.line}${shownPattern(d)} ${d.negate ? 'excludes' : 'matches'} no file in this repo` });
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const KIND_WORD = { team: ['team'], group: ['group'], role: ['role'], email: ['e-mail address', 'e-mail addresses'] };
|
|
111
|
+
|
|
112
|
+
function printRepo(name, r) {
|
|
113
|
+
log(c.bold(`\ncodeowners — ${name}`));
|
|
114
|
+
if (!r.git) { warn('no files on disk to check (not a git repo, or a bare one) — skipped'); return; }
|
|
115
|
+
if (r.unknown) { warn(`CODEOWNERS: not known — ${r.unknown}`); return; }
|
|
116
|
+
if (r.none) { info(`none — ${r.none}; nothing to check`); return; }
|
|
117
|
+
const findings = codeownersFindings(r);
|
|
118
|
+
for (const f of findings) warn(f.message);
|
|
119
|
+
if (!findings.length) ok(`${r.path}: every line yad can read matches a file`);
|
|
120
|
+
const i = r.inactive;
|
|
121
|
+
if (i?.unknown) info(`owners with recent commits: not known — ${i.unknown}`);
|
|
122
|
+
else if (i) {
|
|
123
|
+
const host = PUBLIC_HOST[r.platform];
|
|
124
|
+
const partial = r.notRead.length ? ' (from the lines yad could read)' : '';
|
|
125
|
+
if (i.quiet.length) {
|
|
126
|
+
const gl = r.platform === 'gitlab' ? '; on GitLab an @name can also be a group, which never commits' : '';
|
|
127
|
+
hand(`no commit on the checked-out branch in the last ${WINDOW_WORDS} carries a ${host} noreply address for ${i.quiet.join(', ')}${partial} — a hint only: they may commit under another address or work in other repos, so this does not mean they have left${gl}`);
|
|
128
|
+
} else if (i.checked) {
|
|
129
|
+
info(`every @login ${r.path} lists${partial} has a commit in the last ${WINDOW_WORDS} with its ${host} noreply address`);
|
|
130
|
+
}
|
|
131
|
+
const nc = Object.entries(i.notChecked).map(([k, n]) => plural(n, ...(KIND_WORD[k] || [k])));
|
|
132
|
+
if (nc.length) info(`not checked for recent commits: ${nc.join(', ')} — the hint reads only @logins`);
|
|
133
|
+
}
|
|
134
|
+
if (findings.length) hand(`fix ${r.path} in ${name} through a PR — the warnings are advisory: CODEOWNERS is a hint, and yad never enforces it`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function runCodeowners(root, { action = 'check', name, json = false, platform = null, write = false } = {}) {
|
|
138
|
+
if (action !== 'check' || write) {
|
|
139
|
+
// `--write` was part of E69's title and was dropped by decision: a name yad wrote into the file would
|
|
140
|
+
// become an owner the platform can enforce (GitHub's "Require review from Code Owners", GitLab's code
|
|
141
|
+
// owner approval), turning a hint into an authority. Said plainly, so a habit or a script learns why.
|
|
142
|
+
const refused = write || action === 'write' || action === '--write';
|
|
143
|
+
const error = refused ? 'yad never writes CODEOWNERS — any name it wrote would become an owner the platform can enforce'
|
|
144
|
+
: `unknown action: ${action} (use: yad codeowners check [repo])`;
|
|
145
|
+
const hint = refused ? 'edit CODEOWNERS by hand and commit it through a PR; `yad codeowners check` shows which lines look stale' : '';
|
|
146
|
+
if (json) emitJSON({ ok: false, error, ...(hint ? { hint } : {}) });
|
|
147
|
+
else { fail(error); if (hint) hand(hint); }
|
|
148
|
+
process.exitCode = 1;
|
|
149
|
+
return { ok: false };
|
|
150
|
+
}
|
|
151
|
+
const t = targets(root, name);
|
|
152
|
+
if (t.error) {
|
|
153
|
+
if (json) emitJSON({ ok: false, error: t.error, hint: t.hint });
|
|
154
|
+
else { fail(t.error); hand(t.hint); }
|
|
155
|
+
process.exitCode = 1;
|
|
156
|
+
return { ok: false };
|
|
157
|
+
}
|
|
158
|
+
const repos = t.list.map((x) => ({ name: x.name, root: x.root, ...checkCodeowners(x.root, { platform: platform || x.platform, hint: true }) }));
|
|
159
|
+
if (json) {
|
|
160
|
+
emitJSON({ ok: true, repos: repos.map((r) => ({
|
|
161
|
+
name: r.name, git: r.git, platform: r.platform ?? null, path: r.path ?? null,
|
|
162
|
+
...(r.none ? { none: r.none } : {}), ...(r.unknown ? { unknown: r.unknown } : {}),
|
|
163
|
+
findings: r.git && !r.unknown && !r.none ? codeownersFindings(r) : [],
|
|
164
|
+
...(r.inactive ? { inactive: r.inactive } : {}),
|
|
165
|
+
})) });
|
|
166
|
+
return { ok: true, repos };
|
|
167
|
+
}
|
|
168
|
+
for (const r of repos) printRepo(r.name, r);
|
|
169
|
+
return { ok: true, repos };
|
|
170
|
+
}
|