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.
Files changed (156) hide show
  1. package/CHANGELOG.md +355 -0
  2. package/README.md +79 -26
  3. package/bin/commands.mjs +41 -0
  4. package/bin/yad.mjs +437 -124
  5. package/cli/artifact-status.mjs +34 -15
  6. package/cli/checkpoint.mjs +69 -49
  7. package/cli/codeowners-command.mjs +170 -0
  8. package/cli/codeowners.mjs +397 -0
  9. package/cli/commit.mjs +13 -9
  10. package/cli/companion.mjs +2 -2
  11. package/cli/dial.mjs +183 -0
  12. package/cli/docs.mjs +88 -32
  13. package/cli/doctor.mjs +1472 -97
  14. package/cli/epic-state.mjs +3478 -232
  15. package/cli/epic.mjs +506 -0
  16. package/cli/errors.mjs +4 -1
  17. package/cli/gate.mjs +1002 -209
  18. package/cli/history.mjs +556 -0
  19. package/cli/hook.mjs +266 -55
  20. package/cli/hubcommit.mjs +6 -17
  21. package/cli/index-command.mjs +87 -0
  22. package/cli/ledger.mjs +57 -7
  23. package/cli/lib.mjs +184 -18
  24. package/cli/manifest.mjs +367 -56
  25. package/cli/migrate.mjs +726 -53
  26. package/cli/mode.mjs +170 -0
  27. package/cli/next.mjs +349 -90
  28. package/cli/openpr.mjs +191 -39
  29. package/cli/people.mjs +654 -0
  30. package/cli/plan.mjs +417 -132
  31. package/cli/platform.mjs +110 -129
  32. package/cli/product-index.mjs +287 -0
  33. package/cli/protection.mjs +706 -0
  34. package/cli/reconcile.mjs +38 -12
  35. package/cli/repo-publish.mjs +24 -26
  36. package/cli/repo.mjs +23 -14
  37. package/cli/report.mjs +21 -15
  38. package/cli/review.mjs +24 -27
  39. package/cli/riskmap-command.mjs +289 -0
  40. package/cli/riskmap.mjs +373 -0
  41. package/cli/setup.mjs +139 -287
  42. package/cli/ship.mjs +7 -6
  43. package/cli/skill.mjs +180 -0
  44. package/cli/skip.mjs +211 -30
  45. package/cli/thread.mjs +42 -17
  46. package/cli/tidy.mjs +20 -20
  47. package/cli/update-commit.mjs +22 -22
  48. package/cli/usage.mjs +115 -109
  49. package/package.json +3 -3
  50. package/skills/sdlc/config.yaml +166 -87
  51. package/skills/sdlc/module-help.csv +35 -35
  52. package/skills/yad-analysis/SKILL.md +125 -65
  53. package/skills/yad-architecture/SKILL.md +34 -23
  54. package/skills/yad-architecture/references/contract-format.md +10 -8
  55. package/skills/yad-backfill/SKILL.md +14 -8
  56. package/skills/yad-backfill/references/backfill.md +1 -1
  57. package/skills/yad-change/SKILL.md +127 -52
  58. package/skills/yad-change/references/triage.md +42 -28
  59. package/skills/yad-checks/SKILL.md +89 -45
  60. package/skills/yad-checks/references/check-gates.md +315 -92
  61. package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
  62. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  63. package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
  64. package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
  65. package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
  66. package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
  67. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  68. package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
  69. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
  70. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  71. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  72. package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
  73. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  74. package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
  75. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  76. package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
  77. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
  78. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  79. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
  80. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  81. package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
  82. package/skills/yad-commit/SKILL.md +6 -6
  83. package/skills/yad-connect-design/SKILL.md +6 -6
  84. package/skills/yad-connect-design/references/design-context.md +1 -1
  85. package/skills/yad-connect-design/references/design-registry.md +2 -2
  86. package/skills/yad-connect-docs/SKILL.md +12 -12
  87. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  88. package/skills/yad-connect-learning/SKILL.md +5 -5
  89. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  90. package/skills/yad-connect-repos/SKILL.md +92 -54
  91. package/skills/yad-connect-repos/references/code-context.md +6 -6
  92. package/skills/yad-connect-repos/references/hub-config.md +68 -58
  93. package/skills/yad-connect-repos/references/repos-registry.md +10 -9
  94. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  95. package/skills/yad-connect-testing/SKILL.md +6 -6
  96. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  97. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  98. package/skills/yad-defects/SKILL.md +8 -8
  99. package/skills/yad-discovery/SKILL.md +130 -94
  100. package/skills/yad-discovery/references/discovery-schema.md +23 -7
  101. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  102. package/skills/yad-docs/SKILL.md +16 -11
  103. package/skills/yad-docs/references/data-mapping.md +9 -7
  104. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  105. package/skills/yad-docs-overview/SKILL.md +32 -17
  106. package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
  107. package/skills/yad-docs-sync/SKILL.md +10 -5
  108. package/skills/yad-docs-sync/references/staleness.md +8 -7
  109. package/skills/yad-engineer-review/SKILL.md +88 -24
  110. package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
  111. package/skills/yad-epic/SKILL.md +178 -100
  112. package/skills/yad-epic/references/state-schema.md +626 -117
  113. package/skills/yad-hub-bridge/SKILL.md +66 -48
  114. package/skills/yad-hub-bridge/references/bridge.md +110 -83
  115. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  116. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  117. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  118. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  119. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
  120. package/skills/yad-implement/SKILL.md +29 -15
  121. package/skills/yad-implement/references/implement-conventions.md +2 -2
  122. package/skills/yad-learn/SKILL.md +9 -9
  123. package/skills/yad-learn/references/learning-state.md +2 -2
  124. package/skills/yad-open-pr/SKILL.md +64 -29
  125. package/skills/yad-pair-review/SKILL.md +18 -16
  126. package/skills/yad-pair-review/references/session-state.md +4 -4
  127. package/skills/yad-pr-template/SKILL.md +48 -27
  128. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  129. package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
  130. package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
  131. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  132. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  133. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  134. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
  135. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
  136. package/skills/yad-reconcile/SKILL.md +3 -3
  137. package/skills/yad-report/SKILL.md +5 -5
  138. package/skills/yad-review-companion/SKILL.md +12 -9
  139. package/skills/yad-review-gate/SKILL.md +198 -79
  140. package/skills/yad-review-gate/references/gating.md +230 -54
  141. package/skills/yad-run/SKILL.md +86 -56
  142. package/skills/yad-run/references/run-loop.md +67 -45
  143. package/skills/yad-ship/SKILL.md +18 -14
  144. package/skills/yad-spec/SKILL.md +31 -17
  145. package/skills/yad-spec/references/spec-handoff.md +17 -5
  146. package/skills/yad-status/SKILL.md +114 -56
  147. package/skills/yad-stories/SKILL.md +42 -27
  148. package/skills/yad-stories/references/story-schema.md +10 -9
  149. package/skills/yad-stub/SKILL.md +59 -48
  150. package/skills/yad-sync-repos/SKILL.md +3 -3
  151. package/skills/yad-test-cases/SKILL.md +37 -30
  152. package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
  153. package/skills/yad-timeline/SKILL.md +8 -7
  154. package/skills/yad-ui/SKILL.md +46 -25
  155. package/cli/roster.mjs +0 -164
  156. package/skills/sdlc/install.sh +0 -68
@@ -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 { epicRoot, artifactBase, artifactFromBase, findReviewStep, DISCOVERY_FILES } from './epic-state.mjs';
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 front-gate lifecycle this command manages. Forward-only: a status is only ever moved UP this
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 the build half (engineer-review) per story; `ready-for-build`,
18
- // `done`, `blocked` are roll-ups/states we must not overwrite from the front-gate view.
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 front-gate status for an artifact base, derived purely from state.json. Returns null
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
- if (review?.status === 'done') return 'approved';
33
- if (review?.status === 'in_review' || author?.status === 'done') return 'in-review';
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(/^---\n([\s\S]*?)\n---/);
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
- const block = fm[1].replace(/^status:\s*.*$/m, `status: ${status}`);
52
- fs.writeFileSync(file, text.replace(fm[1], block));
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
- const epicsDir = path.join(root, 'epics');
59
- const epics = epic
60
- ? [epic]
61
- : (fs.existsSync(epicsDir) ? fs.readdirSync(epicsDir).filter((e) => fs.statSync(path.join(epicsDir, e)).isDirectory()).sort() : []);
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(/^---\n([\s\S]*?)\n---/);
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}`);
@@ -1,19 +1,19 @@
1
- // `yad checkpoint` — commit the machine-written back-half hub state (trust-log / build-log /
2
- // build-state) as one audit-trail commit. This is the back-half analogue of the front-half gate sync
3
- // (cli/gate.mjs): the SDLC back half (yad-run, yad-engineer-review) WRITES these ledgers into the
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 back-half story `status:` flip (approved → in-build/shipped) that
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 back-half record, never a raw edit that would
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 back-half ledgers + build-log-backed story flips by an explicit allowlist —
16
- // never `git add -A`, which would sweep the CI-owned front-half ledger (state/approvals/
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 { loadHub } from './gate.mjs';
28
- import { resolveCommitterLogin } from './platform.mjs';
29
- import { hubGit, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
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 back-half ledgers, relative to an epic's dir. The two append-only logs are
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 back-half ledger that exists under any epic.
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 backHalfPathspecs(root) {
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 back-half story statuses. `in-build` = some of a story's tasks shipped; `shipped` = all did.
59
- // Both are set ONLY by the build half (yad-engineer-review), never by the front-gate ladder
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 back-half `status:` flip we carry alongside the
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 build-half evidence), and
68
- // 2. its current frontmatter `status:` is a back-half value (in-build | shipped).
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 backHalfPathspecs for a human to see).
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 back-half ledger (…/.sdlc/…) or a carried story-status flip (…/stories/<id>.md, #112).
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 roster (the auditable handle the user asked for), else the raw git user.name,
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 hub commit-message gate (valid type
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 back-half state — ${oneLine(label)} by ${oneLine(author)} [skip ci]`;
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 hubGit-style accessor.
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 back-half value in the working tree.
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 hub's connected-repo registry as the project-wide fallback. Used to
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 declared = readFrontmatter(storyFile).repos;
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 back-half status in the working tree; otherwise the ship
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 hub'} — a retroactive ship must name a real repo, never invent one`);
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 hub registry lists every connected repo, which says nothing
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(path.join(root, PROJECT_FILES.hubConfig))) {
293
- fail('no .sdlc/hub.json — checkpoint commits the hub back-half ledger; run it from the product hub');
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 } = loadHub(root);
299
- const git = hubGit(root);
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 back-half ledger existed, so it has no ship and its `status:` flip can't be carried). Done
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 = [...backHalfPathspecs(root), ...storyStatusPathspecs(root)];
328
- if (!pathspecs.length) { rollbackRetro(); info('no back-half ledgers found — nothing to checkpoint'); return; }
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('back-half state unchanged — nothing to commit');
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(resolveCommitterLogin(root, hub?.roster || []), git('config', 'user.name').stdout);
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 { message };
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 { message }; }
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 { message };
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
+ }