yadflow 3.18.1 → 4.0.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +355 -0
- package/README.md +79 -26
- package/bin/commands.mjs +41 -0
- package/bin/yad.mjs +437 -124
- package/cli/artifact-status.mjs +34 -15
- package/cli/checkpoint.mjs +69 -49
- package/cli/codeowners-command.mjs +170 -0
- package/cli/codeowners.mjs +397 -0
- package/cli/commit.mjs +13 -9
- package/cli/companion.mjs +2 -2
- package/cli/dial.mjs +183 -0
- package/cli/docs.mjs +88 -32
- package/cli/doctor.mjs +1472 -97
- package/cli/epic-state.mjs +3478 -232
- package/cli/epic.mjs +506 -0
- package/cli/errors.mjs +4 -1
- package/cli/gate.mjs +1002 -209
- package/cli/history.mjs +556 -0
- package/cli/hook.mjs +266 -55
- package/cli/hubcommit.mjs +6 -17
- package/cli/index-command.mjs +87 -0
- package/cli/ledger.mjs +57 -7
- package/cli/lib.mjs +184 -18
- package/cli/manifest.mjs +367 -56
- package/cli/migrate.mjs +726 -53
- package/cli/mode.mjs +170 -0
- package/cli/next.mjs +349 -90
- package/cli/openpr.mjs +191 -39
- package/cli/people.mjs +654 -0
- package/cli/plan.mjs +417 -132
- package/cli/platform.mjs +110 -129
- package/cli/product-index.mjs +287 -0
- package/cli/protection.mjs +706 -0
- package/cli/reconcile.mjs +38 -12
- package/cli/repo-publish.mjs +24 -26
- package/cli/repo.mjs +23 -14
- package/cli/report.mjs +21 -15
- package/cli/review.mjs +24 -27
- package/cli/riskmap-command.mjs +289 -0
- package/cli/riskmap.mjs +373 -0
- package/cli/setup.mjs +139 -287
- package/cli/ship.mjs +7 -6
- package/cli/skill.mjs +180 -0
- package/cli/skip.mjs +211 -30
- package/cli/thread.mjs +42 -17
- package/cli/tidy.mjs +20 -20
- package/cli/update-commit.mjs +22 -22
- package/cli/usage.mjs +115 -109
- package/package.json +3 -3
- package/skills/sdlc/config.yaml +166 -87
- package/skills/sdlc/module-help.csv +35 -35
- package/skills/yad-analysis/SKILL.md +125 -65
- package/skills/yad-architecture/SKILL.md +34 -23
- package/skills/yad-architecture/references/contract-format.md +10 -8
- package/skills/yad-backfill/SKILL.md +14 -8
- package/skills/yad-backfill/references/backfill.md +1 -1
- package/skills/yad-change/SKILL.md +127 -52
- package/skills/yad-change/references/triage.md +42 -28
- package/skills/yad-checks/SKILL.md +89 -45
- package/skills/yad-checks/references/check-gates.md +315 -92
- package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
- package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
- package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
- package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
- package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
- package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
- package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
- package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
- 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 +37 -5
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
- package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
- package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
- 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 +4 -4
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
- package/skills/yad-commit/SKILL.md +6 -6
- package/skills/yad-connect-design/SKILL.md +6 -6
- 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 +12 -12
- 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 +92 -54
- package/skills/yad-connect-repos/references/code-context.md +6 -6
- package/skills/yad-connect-repos/references/hub-config.md +68 -58
- package/skills/yad-connect-repos/references/repos-registry.md +10 -9
- package/skills/yad-connect-repos/references/risk-map.md +81 -0
- package/skills/yad-connect-testing/SKILL.md +6 -6
- 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 +8 -8
- package/skills/yad-discovery/SKILL.md +130 -94
- package/skills/yad-discovery/references/discovery-schema.md +23 -7
- package/skills/yad-discovery/references/foundation-schema.md +374 -0
- package/skills/yad-docs/SKILL.md +16 -11
- 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 +32 -17
- package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
- 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 +88 -24
- package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
- package/skills/yad-epic/SKILL.md +178 -100
- package/skills/yad-epic/references/state-schema.md +626 -117
- package/skills/yad-hub-bridge/SKILL.md +66 -48
- package/skills/yad-hub-bridge/references/bridge.md +110 -83
- 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 +22 -12
- package/skills/yad-implement/SKILL.md +29 -15
- 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 +64 -29
- package/skills/yad-pair-review/SKILL.md +18 -16
- package/skills/yad-pair-review/references/session-state.md +4 -4
- package/skills/yad-pr-template/SKILL.md +48 -27
- package/skills/yad-pr-template/references/risk-routing.md +97 -24
- package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
- package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
- 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 +15 -14
- package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
- package/skills/yad-reconcile/SKILL.md +3 -3
- package/skills/yad-report/SKILL.md +5 -5
- package/skills/yad-review-companion/SKILL.md +12 -9
- package/skills/yad-review-gate/SKILL.md +198 -79
- package/skills/yad-review-gate/references/gating.md +230 -54
- package/skills/yad-run/SKILL.md +86 -56
- package/skills/yad-run/references/run-loop.md +67 -45
- package/skills/yad-ship/SKILL.md +18 -14
- package/skills/yad-spec/SKILL.md +31 -17
- package/skills/yad-spec/references/spec-handoff.md +17 -5
- package/skills/yad-status/SKILL.md +114 -56
- package/skills/yad-stories/SKILL.md +42 -27
- package/skills/yad-stories/references/story-schema.md +10 -9
- package/skills/yad-stub/SKILL.md +59 -48
- package/skills/yad-sync-repos/SKILL.md +3 -3
- package/skills/yad-test-cases/SKILL.md +37 -30
- package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
- package/skills/yad-timeline/SKILL.md +8 -7
- package/skills/yad-ui/SKILL.md +46 -25
- package/cli/roster.mjs +0 -164
- package/skills/sdlc/install.sh +0 -68
package/cli/artifact-status.mjs
CHANGED
|
@@ -6,31 +6,41 @@
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { c, log, ok, info, readJSONStrict } from './lib.mjs';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
epicIds, epicRoot, artifactBase, artifactFromBase, findReviewStep, DISCOVERY_FILES, FOUNDATION_FILES, isPassed, stepStatus, FRONTMATTER_BLOCK,
|
|
11
|
+
} from './epic-state.mjs';
|
|
10
12
|
import { epicFiles } from './manifest.mjs';
|
|
11
13
|
|
|
12
|
-
// The
|
|
14
|
+
// The Shape gate lifecycle this command manages. Forward-only: a status is only ever moved UP this
|
|
13
15
|
// ladder, so a re-run never regresses anything.
|
|
14
16
|
const RANK = { draft: 0, 'in-review': 1, approved: 2 };
|
|
15
17
|
|
|
16
18
|
// Values owned by other parts of the workflow — left untouched. `locked` is the contract surface;
|
|
17
|
-
// `in-build` / `shipped` are set by
|
|
18
|
-
// `done`, `blocked` are roll-ups/states we must not overwrite from the
|
|
19
|
+
// `in-build` / `shipped` are set by Build (engineer-review) per story; `ready-for-build`,
|
|
20
|
+
// `done`, `blocked` are roll-ups/states we must not overwrite from the Shape gate view.
|
|
19
21
|
const PRESERVE = new Set(['locked', 'in-build', 'shipped', 'ready-for-build', 'done', 'blocked']);
|
|
20
22
|
|
|
21
23
|
// The per-epic artifact files this command considers (bases). Story files are handled separately
|
|
22
24
|
// because they live under stories/ and all map to the single stories / stories-review step pair.
|
|
23
25
|
const ARTIFACT_FILES = ['analysis.md', 'epic.md', 'architecture.md', 'contract.md', 'ui-design.md', 'test-cases.md'];
|
|
24
26
|
|
|
25
|
-
// The desired
|
|
27
|
+
// The desired Shape gate status for an artifact base, derived purely from state.json. Returns null
|
|
26
28
|
// when the chain has no steps for this base (nothing to manage) — e.g. contract has no own step.
|
|
27
29
|
export function desiredStatus(state, base) {
|
|
28
30
|
if (!state?.steps) return null;
|
|
29
31
|
const review = findReviewStep(state, artifactFromBase(base));
|
|
30
32
|
const author = state.steps.find((s) => s.type === 'author' && artifactBase(s.artifact) === base);
|
|
31
33
|
if (!review && !author) return null;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
// `isPassed`, not `status === 'done'`: a gate that PASSED is what makes an artifact approved,
|
|
35
|
+
// however it passed. An inherited gate was approved upstream in the thread, and a skipped one has
|
|
36
|
+
// no artifact for this command to find — both used to reach here spelled `done`.
|
|
37
|
+
// A gate SET ASIDE — skipped or deferred — lets the chain continue without anybody reviewing the
|
|
38
|
+
// artifact, so it says nothing about the file. `isPassed` alone read it as approved: a skip taken over a half-written draft,
|
|
39
|
+
// or a deferral of one, would have had CI stamp `status: approved` on a file nobody reviewed (E37).
|
|
40
|
+
const reviewState = stepStatus(review);
|
|
41
|
+
if (reviewState === 'skipped' || reviewState === 'deferred') return null;
|
|
42
|
+
if (isPassed(review)) return 'approved';
|
|
43
|
+
if (stepStatus(review) === 'in_review' || isPassed(author)) return 'in-review';
|
|
34
44
|
return 'draft';
|
|
35
45
|
}
|
|
36
46
|
|
|
@@ -41,24 +51,27 @@ export function desiredStatus(state, base) {
|
|
|
41
51
|
export function setFrontmatterStatus(file, status) {
|
|
42
52
|
if (!fs.existsSync(file)) return null;
|
|
43
53
|
const text = fs.readFileSync(file, 'utf8');
|
|
44
|
-
const fm = text.match(
|
|
54
|
+
const fm = text.match(FRONTMATTER_BLOCK);
|
|
45
55
|
if (!fm) return null;
|
|
46
56
|
const cur = (fm[1].match(/^status:\s*(.*)$/m) || [])[1]?.trim();
|
|
47
57
|
if (cur === undefined) return null;
|
|
48
58
|
// Advance-only within the managed ladder; anything else (build-owned, roll-ups) is left as-is.
|
|
49
59
|
if (PRESERVE.has(cur)) return null;
|
|
50
60
|
if (!(cur in RANK) || !(status in RANK) || RANK[status] <= RANK[cur]) return null;
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
// Replacement FUNCTIONS, not strings: in a replacement string `$'`, `$&` and `$$` are codes, so a
|
|
62
|
+
// frontmatter value holding one (`title: costs $' less`) rewrote the file wrongly — on the default
|
|
63
|
+
// branch, in the gate's merge commit.
|
|
64
|
+
const block = fm[1].replace(/^status:\s*.*$/m, () => `status: ${status}`);
|
|
65
|
+
fs.writeFileSync(file, text.replace(fm[1], () => block));
|
|
53
66
|
return cur;
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
// Sweep one epic (or every epic under epics/) and reconcile artifact frontmatter with state.json.
|
|
57
70
|
export async function syncStatuses(root, { epic, dryRun = false } = {}) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
71
|
+
// `epicIds` — the Foundation included (E75), and only VALID ids: a backup directory or any other
|
|
72
|
+
// folder left under `epics/` is not an epic, and rewriting frontmatter inside it would be a write to
|
|
73
|
+
// files nobody asked this command to touch.
|
|
74
|
+
const epics = epic ? [epic] : epicIds(root);
|
|
62
75
|
if (!epics.length) { info('no epics found — nothing to sync'); return { changed: 0, files: [] }; }
|
|
63
76
|
|
|
64
77
|
let changed = 0;
|
|
@@ -86,6 +99,12 @@ export async function syncStatuses(root, { epic, dryRun = false } = {}) {
|
|
|
86
99
|
for (const f of DISCOVERY_FILES) {
|
|
87
100
|
if (fs.existsSync(path.join(dir, f))) files.push({ base: 'discovery', file: path.join(dir, f) });
|
|
88
101
|
}
|
|
102
|
+
// The Foundation's sections key to its one foundation / foundation-review pair the same way. Both
|
|
103
|
+
// spellings are offered; `desiredStatus` answers null for the base the chain does not carry, so
|
|
104
|
+
// a converted Foundation holding the six old files still reconciles them under `discovery`.
|
|
105
|
+
for (const f of FOUNDATION_FILES) {
|
|
106
|
+
if (fs.existsSync(path.join(dir, f))) files.push({ base: 'foundation', file: path.join(dir, f) });
|
|
107
|
+
}
|
|
89
108
|
|
|
90
109
|
for (const { base, file } of files) {
|
|
91
110
|
if (!fs.existsSync(file)) continue;
|
|
@@ -95,7 +114,7 @@ export async function syncStatuses(root, { epic, dryRun = false } = {}) {
|
|
|
95
114
|
// Peek without writing so --dry-run reports exactly what would change. Scope the match to the
|
|
96
115
|
// frontmatter block so a `status:` line in the Markdown body can't be mistaken for the value.
|
|
97
116
|
const text = fs.readFileSync(file, 'utf8');
|
|
98
|
-
const fm = text.match(
|
|
117
|
+
const fm = text.match(FRONTMATTER_BLOCK);
|
|
99
118
|
const cur = (fm?.[1].match(/^status:\s*(.*)$/m) || [])[1]?.trim();
|
|
100
119
|
if (cur && !PRESERVE.has(cur) && cur in RANK && RANK[want] > RANK[cur]) {
|
|
101
120
|
log(` ${c.dim('• would update')} ${path.relative(root, file)}: ${cur} → ${want}`);
|
package/cli/checkpoint.mjs
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
// `yad checkpoint` — commit the machine-written
|
|
2
|
-
// build-state) as one audit-trail commit. This is the
|
|
3
|
-
// (cli/gate.mjs): the SDLC
|
|
1
|
+
// `yad checkpoint` — commit the machine-written Build state on the Product (trust-log / build-log /
|
|
2
|
+
// build-state) as one audit-trail commit. This is the Build analogue of the Shape gate sync
|
|
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
|
|
5
5
|
// evidence. checkpoint lands them with a `chore(hub): ...` message.
|
|
6
6
|
//
|
|
7
|
-
// It also carries the
|
|
7
|
+
// It also carries the Build story `status:` flip (approved → in-build/shipped) that
|
|
8
8
|
// yad-engineer-review writes into stories/<id>.md but no command committed — the #112 drift where
|
|
9
9
|
// build-log said shipped while the story artifact still said approved. Only story files with build-log
|
|
10
10
|
// ship evidence are carried (storyStatusPathspecs), AND only when their staged change is the `status:`
|
|
11
|
-
// line alone (stagedStoryIsStatusOnly) — so it stays a
|
|
11
|
+
// line alone (stagedStoryIsStatusOnly) — so it stays a Build record, never a raw edit that would
|
|
12
12
|
// slip prose onto the default branch under a `[skip ci]` commit that bypasses review.
|
|
13
13
|
//
|
|
14
14
|
// Two invariants keep it out of the gates' way:
|
|
15
|
-
// 1. It stages ONLY the
|
|
16
|
-
// never `git add -A`, which would sweep the CI-owned
|
|
15
|
+
// 1. It stages ONLY the Build ledgers + build-log-backed story flips by an explicit allowlist —
|
|
16
|
+
// never `git add -A`, which would sweep the CI-owned Shape ledger (state/approvals/
|
|
17
17
|
// comments/hub-prs.json, reviews/*.md) and trip the ledger-guard gate. (ledger-guard does NOT
|
|
18
18
|
// protect stories/*.md, and this commits to the default branch, never a PR range, so the carried
|
|
19
19
|
// story flip is safe.)
|
|
@@ -22,15 +22,15 @@
|
|
|
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
|
-
// The machine-written
|
|
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 —
|
|
35
35
|
// both are allowlisted so a checkpoint commits new shards and any `yad tidy up` fold. `build-state` is
|
|
36
36
|
// the whole dir (one JSON per story). Keep in sync with cli/manifest.mjs epicFiles.
|
|
@@ -40,9 +40,9 @@ const BACK_HALF = [
|
|
|
40
40
|
'.sdlc/build-state',
|
|
41
41
|
];
|
|
42
42
|
|
|
43
|
-
// PURE — the repo-relative pathspecs to stage: every
|
|
43
|
+
// PURE — the repo-relative pathspecs to stage: every Build ledger that exists under any epic.
|
|
44
44
|
// Explicit allowlist by design (see invariant 1 above).
|
|
45
|
-
export function
|
|
45
|
+
export function buildLedgerPathspecs(root) {
|
|
46
46
|
const epicsDir = path.join(root, 'epics');
|
|
47
47
|
if (!fs.existsSync(epicsDir)) return [];
|
|
48
48
|
const out = [];
|
|
@@ -55,17 +55,17 @@ export function backHalfPathspecs(root) {
|
|
|
55
55
|
return out;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// The two
|
|
59
|
-
// Both are set ONLY by
|
|
58
|
+
// The two Build story statuses. `in-build` = some of a story's tasks shipped; `shipped` = all did.
|
|
59
|
+
// Both are set ONLY by Build (yad-engineer-review), never by the Shape gate ladder
|
|
60
60
|
// (cli/artifact-status.mjs PRESERVEs them). See #112.
|
|
61
61
|
const BACK_HALF_STATUSES = new Set(['in-build', 'shipped']);
|
|
62
62
|
|
|
63
|
-
// The repo-relative pathspecs for story files whose
|
|
63
|
+
// The repo-relative pathspecs for story files whose Build `status:` flip we carry alongside the
|
|
64
64
|
// ledgers (#112). The flip is authored by yad-engineer-review into the working tree but no command
|
|
65
65
|
// committed it, so it drifted (build-log said shipped, stories/<id>.md still said approved) and the
|
|
66
66
|
// only recovery was a raw git-to-main push. A story is a CANDIDATE iff BOTH hold:
|
|
67
|
-
// 1. it has >=1 ship recorded in build-log (the
|
|
68
|
-
// 2. its current frontmatter `status:` is a
|
|
67
|
+
// 1. it has >=1 ship recorded in build-log (the Build evidence), and
|
|
68
|
+
// 2. its current frontmatter `status:` is a Build value (in-build | shipped).
|
|
69
69
|
// A candidate is only actually carried when its staged diff is the `status:` line ALONE — runCheckpoint
|
|
70
70
|
// drops any candidate whose working tree also changed prose/other frontmatter (stagedStoryIsStatusOnly),
|
|
71
71
|
// so an unrelated edit can never ride into a `chore(hub) … [skip ci]` commit that bypasses review.
|
|
@@ -74,7 +74,7 @@ const BACK_HALF_STATUSES = new Set(['in-build', 'shipped']);
|
|
|
74
74
|
//
|
|
75
75
|
// A corrupt build-log in one epic must not block checkpointing every OTHER epic's ledgers, so a
|
|
76
76
|
// readShips throw is caught per epic (that epic simply carries no story flip; its corrupt ledger is
|
|
77
|
-
// still staged by
|
|
77
|
+
// still staged by buildLedgerPathspecs for a human to see).
|
|
78
78
|
export function storyStatusPathspecs(root) {
|
|
79
79
|
const epicsDir = path.join(root, 'epics');
|
|
80
80
|
if (!fs.existsSync(epicsDir)) return [];
|
|
@@ -107,7 +107,7 @@ export function summarizeStaged(files = []) {
|
|
|
107
107
|
const epics = new Set();
|
|
108
108
|
const basenames = [];
|
|
109
109
|
for (const f of files) {
|
|
110
|
-
// A
|
|
110
|
+
// A Build ledger (…/.sdlc/…) or a carried story-status flip (…/stories/<id>.md, #112).
|
|
111
111
|
const m = f.match(/^epics\/([^/]+)\/(?:\.sdlc\/(.+)|stories\/(.+\.md))$/);
|
|
112
112
|
if (!m) continue;
|
|
113
113
|
const [, epic] = m;
|
|
@@ -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,12 +138,12 @@ 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.
|
|
145
145
|
export function buildCheckpointMessage({ label, author, basenames = [] }) {
|
|
146
|
-
const subject = `chore(hub): sync
|
|
146
|
+
const subject = `chore(hub): sync Build state — ${oneLine(label)} by ${oneLine(author)} [skip ci]`;
|
|
147
147
|
const body = basenames.length ? `Updated: ${basenames.join(', ')}` : '';
|
|
148
148
|
return body ? `${subject}\n\n${body}` : subject;
|
|
149
149
|
}
|
|
@@ -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;
|
|
@@ -170,21 +170,20 @@ export function stagedStoryIsStatusOnly(git, file) {
|
|
|
170
170
|
// normal checkpoint path carries the story's already-made `status:` flip. Returns { ok, file } — ok:false
|
|
171
171
|
// (with a printed reason) aborts the commit; `file` is the shard just written, so a dry run can delete it
|
|
172
172
|
// and leave no side effect. Does NOT author the story frontmatter — it only supplies the missing
|
|
173
|
-
// evidence, and the human must have ALREADY flipped `status:` to a
|
|
173
|
+
// evidence, and the human must have ALREADY flipped `status:` to a Build value in the working tree.
|
|
174
174
|
//
|
|
175
175
|
// ONE repo per run (#166). A story that shipped in several repos is backfilled by re-running with each
|
|
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);
|
|
@@ -201,7 +200,7 @@ export function recordRetroShip(root, { epic, story, repo, task, mergeCommit, to
|
|
|
201
200
|
if (!exists(storyFile)) { fail(`no story ${story} under epics/${epic}/stories/`); return { ok: false }; }
|
|
202
201
|
|
|
203
202
|
// Evidence and the flip must land TOGETHER — the #112 no-drift invariant. Refuse unless the human has
|
|
204
|
-
// already flipped the story frontmatter to a
|
|
203
|
+
// already flipped the story frontmatter to a Build status in the working tree; otherwise the ship
|
|
205
204
|
// shard would commit while the artifact still says e.g. `approved` — the very drift #112 prevents.
|
|
206
205
|
const storyStatus = readFrontmatter(storyFile).status;
|
|
207
206
|
if (!BACK_HALF_STATUSES.has(storyStatus)) {
|
|
@@ -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
|
|
|
@@ -304,7 +321,7 @@ export async function runCheckpoint(root, opts = {}) {
|
|
|
304
321
|
if (!guardDefaultBranch(branch, defaultBranch, { allowBranch: opts.allowBranch, cmd: 'yad checkpoint' })) return;
|
|
305
322
|
|
|
306
323
|
// --retro-ship (#142): record a retroactive build-log ship for a PRE-TRACKING story (merged before
|
|
307
|
-
// the
|
|
324
|
+
// the Build ledger existed, so it has no ship and its `status:` flip can't be carried). Done
|
|
308
325
|
// AFTER the branch guard so we never leave a dangling shard on the wrong branch; the flip the human
|
|
309
326
|
// already wrote is then carried by the normal storyStatusPathspecs path below — no raw git needed.
|
|
310
327
|
let retroFile;
|
|
@@ -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
|
-
const pathspecs = [...
|
|
328
|
-
if (!pathspecs.length) { rollbackRetro(); info('no
|
|
346
|
+
const pathspecs = [...buildLedgerPathspecs(root), ...storyStatusPathspecs(root)];
|
|
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
|
|
@@ -348,8 +367,8 @@ export async function runCheckpoint(root, opts = {}) {
|
|
|
348
367
|
|
|
349
368
|
if (git('diff', '--cached', '--quiet', '--', ...pathspecs).ok) {
|
|
350
369
|
rollbackRetro();
|
|
351
|
-
info('
|
|
352
|
-
return;
|
|
370
|
+
info('Build state unchanged — nothing to commit');
|
|
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
|
}
|
|
@@ -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
|
+
}
|