yadflow 4.0.0-next.3 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +699 -0
- package/README.md +85 -22
- package/bin/commands.mjs +3 -1
- package/bin/yad.mjs +183 -29
- package/cli/capture.mjs +4 -2
- package/cli/checkpoint.mjs +10 -10
- package/cli/claims.mjs +4 -4
- package/cli/commit.mjs +48 -12
- package/cli/docs.mjs +15 -13
- package/cli/doctor.mjs +557 -141
- package/cli/epic-state.mjs +41 -18
- package/cli/errors.mjs +3 -2
- package/cli/find-product.mjs +197 -0
- package/cli/fold.mjs +4 -4
- package/cli/gate-local.mjs +13 -13
- package/cli/gate.mjs +111 -103
- package/cli/history.mjs +13 -13
- package/cli/hook.mjs +201 -30
- package/cli/index-command.mjs +6 -6
- package/cli/lib.mjs +91 -29
- package/cli/manifest.mjs +171 -104
- package/cli/migrate.mjs +208 -17
- package/cli/mode.mjs +22 -22
- package/cli/next.mjs +6 -6
- package/cli/openpr.mjs +32 -24
- package/cli/people.mjs +22 -10
- package/cli/plan.mjs +718 -45
- package/cli/platform.mjs +13 -7
- package/cli/product-index.mjs +4 -4
- package/cli/{hubcommit.mjs → productcommit.mjs} +11 -7
- package/cli/protection.mjs +19 -9
- package/cli/reconcile.mjs +98 -23
- package/cli/repo-publish.mjs +22 -16
- package/cli/repo.mjs +128 -33
- package/cli/report.mjs +4 -2
- package/cli/review.mjs +26 -19
- package/cli/setup.mjs +105 -36
- package/cli/ship.mjs +1 -1
- package/cli/skip.mjs +4 -4
- package/cli/tidy.mjs +7 -7
- package/cli/update-commit.mjs +22 -19
- package/cli/workspace.mjs +495 -0
- package/package.json +7 -4
- package/skills/sdlc/config.yaml +14 -13
- package/skills/sdlc/module-help.csv +8 -8
- package/skills/yad-analysis/SKILL.md +6 -4
- package/skills/yad-architecture/SKILL.md +4 -3
- package/skills/yad-backfill/SKILL.md +11 -5
- package/skills/yad-backfill/references/backfill.md +33 -3
- package/skills/yad-backfill/templates/checks/backfill-check.sh +200 -17
- package/skills/yad-change/SKILL.md +1 -1
- package/skills/yad-checks/SKILL.md +48 -27
- package/skills/yad-checks/references/check-gates.md +213 -53
- package/skills/yad-checks/templates/checks/commit-message.sh +39 -13
- package/skills/yad-checks/templates/checks/contract-check.sh +472 -23
- package/skills/yad-checks/templates/checks/epic-open.sh +398 -8
- package/skills/yad-checks/templates/checks/ledger-guard.sh +75 -25
- package/skills/yad-checks/templates/checks/lineage-check.sh +391 -7
- package/skills/yad-checks/templates/checks/product-checkout.sh +141 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +399 -8
- package/skills/yad-checks/templates/checks/risk-map-check.sh +35 -3
- package/skills/yad-checks/templates/checks/spec-link.sh +36 -3
- package/skills/yad-checks/templates/checks/verified-commits.sh +26 -2
- package/skills/yad-checks/templates/github/yad-checks.yml +33 -13
- package/skills/yad-checks/templates/github/{yad-hub-checks.yml → yad-product-checks.yml} +14 -10
- package/skills/yad-checks/templates/github/yad-update-guard.yml +1 -1
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +1 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +12 -0
- package/skills/yad-checks/templates/gitlab/{yad-hub-checks.gitlab-ci.yml → yad-product-checks.gitlab-ci.yml} +16 -16
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.mjs +130 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.mjs +117 -0
- package/skills/yad-checks/templates/hooks/yad-capture.mjs +117 -0
- package/skills/yad-commit/SKILL.md +18 -7
- package/skills/yad-connect-design/SKILL.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +1 -1
- package/skills/yad-connect-docs/SKILL.md +16 -11
- package/skills/yad-connect-docs/references/docs-registry.md +8 -8
- package/skills/yad-connect-learning/SKILL.md +1 -1
- package/skills/yad-connect-learning/references/learning-registry.md +1 -1
- package/skills/yad-connect-repos/SKILL.md +31 -8
- package/skills/yad-connect-repos/references/code-context.md +1 -1
- package/skills/yad-connect-repos/references/{hub-config.md → product-config.md} +19 -11
- package/skills/yad-connect-repos/references/repos-registry.md +1 -1
- package/skills/yad-connect-testing/SKILL.md +1 -1
- package/skills/yad-connect-testing/references/testing-registry.md +1 -1
- package/skills/yad-discovery/SKILL.md +2 -2
- package/skills/yad-discovery/references/discovery-schema.md +1 -1
- package/skills/yad-docs/SKILL.md +1 -1
- package/skills/yad-docs/references/data-mapping.md +1 -1
- package/skills/yad-docs/templates/app/package-lock.json +8 -8
- package/skills/yad-docs/templates/app/package.json +1 -1
- package/skills/yad-docs/templates/app/src/components/Canvas/FlowCanvas.tsx +2 -2
- package/skills/yad-docs-overview/references/pipeline-model.md +3 -3
- package/skills/yad-engineer-review/SKILL.md +2 -2
- package/skills/yad-engineer-review/references/ship-and-record.md +3 -3
- package/skills/yad-epic/SKILL.md +6 -4
- package/skills/yad-epic/references/state-schema.md +13 -12
- package/skills/yad-open-pr/SKILL.md +1 -1
- package/skills/yad-pair-review/SKILL.md +5 -5
- package/skills/yad-pair-review/references/session-state.md +2 -2
- package/skills/yad-pr-template/SKILL.md +14 -12
- package/skills/yad-pr-template/templates/checks/pr-template.sh +18 -16
- package/skills/yad-pr-template/templates/checks/pr-title.sh +15 -13
- package/skills/yad-pr-template/templates/{hub → product}/github/pull_request_template.md +2 -2
- package/skills/yad-pr-template/templates/{hub → product}/gitlab/merge_request_templates/Default.md +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/SKILL.md +15 -15
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/bridge.md +15 -10
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/login-roster.md +2 -2
- package/skills/{yad-hub-bridge/templates/checks/hub-route.sh → yad-product-bridge/templates/checks/product-route.sh} +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/github/yad-gate-sync.yml +29 -16
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/gitlab-ci.include-root.yml +1 -1
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/yad-gate-sync.gitlab-ci.yml +5 -3
- package/skills/yad-report/SKILL.md +1 -1
- package/skills/yad-review-companion/SKILL.md +3 -3
- package/skills/yad-review-gate/SKILL.md +22 -23
- package/skills/yad-review-gate/references/gating.md +13 -13
- package/skills/yad-run/SKILL.md +2 -2
- package/skills/yad-run/references/run-loop.md +2 -2
- package/skills/yad-spec/SKILL.md +2 -1
- package/skills/yad-spec/references/spec-handoff.md +19 -10
- package/skills/yad-stories/SKILL.md +2 -2
- package/skills/yad-sync-repos/SKILL.md +19 -3
- package/skills/yad-test-cases/SKILL.md +2 -2
- package/skills/yad-ui/SKILL.md +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +0 -91
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +0 -100
- package/skills/yad-checks/templates/hooks/yad-capture.sh +0 -53
package/cli/doctor.mjs
CHANGED
|
@@ -5,15 +5,17 @@
|
|
|
5
5
|
// `--json` emits the checks for CI / bug reports.
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import fs from 'node:fs';
|
|
8
|
-
import { c, log, ok, info, warn, fail, hand, run, has, exists, isPlainObject, readJSON, readJSONStrict, emitJSON } from './lib.mjs';
|
|
9
|
-
import { VERSION, BACKUP_SUFFIX, MIRRORED_FILES, PROJECT_FILES, MODULE_CONFIG, epicFiles, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS, HOOK_ADAPTERS, CAPTURE_ADAPTERS, isVerifiedLedger , productConfigPath, ADVANCE_FROM_AUTOMATION, DRIVER_FROM_ASSISTANCE } from './manifest.mjs';
|
|
10
|
-
import { mergeHookSettings, hookMatcherFires, ideTargetsFor, safeIdeTargetStateFor, hookScriptReady, miswiredGuardCommand } from './plan.mjs';
|
|
8
|
+
import { c, log, ok, info, warn, fail, hand, run, has, exists, isPlainObject, readJSON, readJSONStrict, emitJSON, asArg } from './lib.mjs';
|
|
9
|
+
import { VERSION, BACKUP_SUFFIX, MIRRORED_FILES, mirrorDrift, PROJECT_FILES, MODULE_CONFIG, epicFiles, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS, HOOK_ADAPTERS, CAPTURE_ADAPTERS, HOOK_WIRING, CAPTURE_WIRING, PROTECTION_GUIDE_URL, isVerifiedLedger , productConfigPath, settingsEditHint, PRODUCT_LINK, ADVANCE_FROM_AUTOMATION, DRIVER_FROM_ASSISTANCE } from './manifest.mjs';
|
|
10
|
+
import { mergeHookSettings, hookMatcherFires, ideTargetsFor, safeIdeTargetStateFor, hookScriptReady, miswiredGuardCommand, gitHookState, legacyModuleActions, legacyProductActions, renamedNameHits, PRODUCT_CHECK_WORKFLOWS, productProfileEffect, workflowsPassingProduct, productGateBlockers, oldProfileAdvice, gateProfileFix } from './plan.mjs';
|
|
11
|
+
import { hasSiblingRepo, workspaceFileState, WORKSPACE_FILE } from './find-product.mjs';
|
|
11
12
|
import { planMigration } from './migrate.mjs';
|
|
12
|
-
import { ADVANCE_VALUES, isGateStep, killSwitchOn, loadAutomation, stepDef as catalogueStep, loadLedger, owedSteps, epicIds, epicRel, epicRoot, FOUNDATION_DIR, FOUNDATION_EPIC, DISCOVERY_EPIC, staleFoundationGuards, unwrittenSections, artifactBase, artifactAgrees, epicStories, laneStarted, isValidEpicId, epicLineage, isGenesisType, readFrontmatter, resolveThread, stateInvariants, contractSurfaceHash, acceptedHashes, isStaleHash, workItemType, WORK_ITEM_TYPES, themeOf, themeKey, stepPhase, stepDef, matchLifecycleProfile, lifecycleProfile, LIFECYCLE_PROFILES, SENTINELS, normalizeBindings, optionalStepsFor, isSkippableStep, recordedRouteDisagrees, isPassed, stepStatus, claimsSkipped, STEP_STATES, isStepRecord, RECORDED_STEP_STATES } from './epic-state.mjs';
|
|
13
|
+
import { ADVANCE_VALUES, isGateStep, killSwitchOn, loadAutomation, stepDef as catalogueStep, loadLedger, owedSteps, epicIds, epicRel, epicRoot, FOUNDATION_DIR, FOUNDATION_EPIC, DISCOVERY_EPIC, staleFoundationGuards, unwrittenSections, artifactBase, artifactAgrees, epicStories, laneStarted, isValidEpicId, epicLineage, isGenesisType, readFrontmatter, resolveThread, stateInvariants, contractSurfaceHash, acceptedHashes, isStaleHash, workItemType, WORK_ITEM_TYPES, themeOf, themeKey, stepPhase, stepDef, matchLifecycleProfile, lifecycleProfile, LIFECYCLE_PROFILES, SENTINELS, normalizeBindings, optionalStepsFor, isSkippableStep, recordedRouteDisagrees, isPassed, stepStatus, claimsSkipped, STEP_STATES, isStepRecord, RECORDED_STEP_STATES, productDriftPairs } from './epic-state.mjs';
|
|
13
14
|
import { loadDebt } from './thread.mjs';
|
|
14
15
|
import { readShips } from './ledger.mjs';
|
|
15
16
|
import { gitHead, insideWorkspace } from './setup.mjs';
|
|
16
|
-
import {
|
|
17
|
+
import { judgeRepo, runnable, shown as forTerminal } from './workspace.mjs';
|
|
18
|
+
import { cliFor, hostFromGitUrl, ambiguousLegacyNames, plainHost } from './platform.mjs';
|
|
17
19
|
import { legacyLogins, stampLegacyLogins } from './gate.mjs';
|
|
18
20
|
import { checkRepo } from './riskmap-command.mjs';
|
|
19
21
|
import { checkCodeowners, codeownersFindings } from './codeowners-command.mjs';
|
|
@@ -21,14 +23,23 @@ import { RISK_MAP_FILE } from './riskmap.mjs';
|
|
|
21
23
|
import { readProtection, protectionLine, protectionJSON, hideAddresses } from './protection.mjs';
|
|
22
24
|
import { soloTeamHint, TEAM_CMD } from './people.mjs';
|
|
23
25
|
import { indexFreshness, INDEX_FILE } from './product-index.mjs';
|
|
24
|
-
import { productGit, resolveDefaultBranch } from './
|
|
26
|
+
import { productGit, resolveDefaultBranch } from './productcommit.mjs';
|
|
25
27
|
import { readOwners } from './owners.mjs';
|
|
26
28
|
|
|
29
|
+
// A registered path doctor may run git in (E81): a checkout the judgement accepts — never a refused
|
|
30
|
+
// entry (git run in a folder the Product shaped as a repo reads its `config`, which can run a command),
|
|
31
|
+
// and never one reached through a link inside a repo's tree (`yad repo sync` and `refresh` skip it too).
|
|
32
|
+
// The repos check reports every other entry.
|
|
33
|
+
const isCheckout = (root, repo) => runnable(judgeRepo(root, repo));
|
|
34
|
+
// The same for a registry `default_branch` inside a git command to copy: a valid branch name can hold
|
|
35
|
+
// `;`, `|`, `$(` or start a path part with `-` (E81 review 11), so only a plain one is printed.
|
|
36
|
+
const branchArg = (b) => (typeof b === 'string' && /^\w[\w./-]*$/.test(b) && !b.includes('..') ? b : '<default-branch>');
|
|
37
|
+
|
|
27
38
|
const MIN_NODE = 18;
|
|
28
39
|
|
|
29
40
|
// Solo mode (a lone developer): approval waived, merge + resolved threads still gate. Persisted in
|
|
30
41
|
// hub.json. Mirrors gate.mjs / next.mjs.
|
|
31
|
-
const isSolo = (
|
|
42
|
+
const isSolo = (productConfig) => !!(productConfig && (productConfig.solo === true || productConfig.review_gate?.solo === true));
|
|
32
43
|
// Is an already-resolved path nested under the project root? Repo paths are contained to the WORKSPACE
|
|
33
44
|
// (the root's parent, see setup.insideWorkspace), so a registered sibling resolves outside the root —
|
|
34
45
|
// which is what distinguishes "absent because it lives elsewhere" from "absent because it is broken".
|
|
@@ -49,6 +60,26 @@ function check(checks, id, section, status, message, hint = '', extra = null) {
|
|
|
49
60
|
checks.push({ id, section, status, message, ...(hint ? { hint } : {}), ...(extra || {}) });
|
|
50
61
|
}
|
|
51
62
|
|
|
63
|
+
// Where bash is missing, what that costs. The check gates are bash, but they run on the team's CI, not
|
|
64
|
+
// here. On Windows it costs more (E113): Claude Code runs hook commands in Git Bash and falls back to
|
|
65
|
+
// PowerShell without it, and the hook entries yad writes (`node "$CLAUDE_PROJECT_DIR/…"`) are bash
|
|
66
|
+
// spelling — so there the agent hooks do not run at all.
|
|
67
|
+
export const bashMissingHint = (platform = process.platform) => (platform === 'win32'
|
|
68
|
+
? 'install Git for Windows with its Git Bash (or set CLAUDE_CODE_GIT_BASH_PATH) — Claude Code runs hook commands in Git Bash, and without it yad\'s hooks do not run under Claude Code; Cursor is not affected, and the check gates run on your CI'
|
|
69
|
+
: 'the check gates are bash scripts — needed only to run one here by hand; your CI runs them');
|
|
70
|
+
|
|
71
|
+
// Is there a bash this machine's agent hooks can use? Elsewhere, bash on PATH. On Windows, Git Bash —
|
|
72
|
+
// which the Git for Windows installer does NOT put on PATH (only `Git\cmd` is), and a `bash` that IS on
|
|
73
|
+
// PATH may be WSL's launcher in System32. So look where Claude Code looks: `CLAUDE_CODE_GIT_BASH_PATH`,
|
|
74
|
+
// then `bin\bash.exe` beside the `cmd\git.exe` on PATH.
|
|
75
|
+
export function hasBash({ platform = process.platform, env = process.env, runner = run } = {}) {
|
|
76
|
+
if (platform !== 'win32') return has('bash');
|
|
77
|
+
if (env.CLAUDE_CODE_GIT_BASH_PATH && exists(env.CLAUDE_CODE_GIT_BASH_PATH)) return true;
|
|
78
|
+
const where = runner('where', ['git']);
|
|
79
|
+
if (!where.ok) return false;
|
|
80
|
+
return where.stdout.split(/\r?\n/).some((gitExe) => exists(path.join(path.dirname(path.dirname(gitExe.trim())), 'bin', 'bash.exe')));
|
|
81
|
+
}
|
|
82
|
+
|
|
52
83
|
export function envChecks(checks) {
|
|
53
84
|
const major = Number(process.versions.node.split('.')[0]);
|
|
54
85
|
if (major >= MIN_NODE) check(checks, 'node', 'environment', 'ok', `node ${process.versions.node}`);
|
|
@@ -58,14 +89,19 @@ export function envChecks(checks) {
|
|
|
58
89
|
else check(checks, 'git', 'environment', 'fail', 'git not found on PATH [YAD-ENV-001]', 'install git — every yad command needs it');
|
|
59
90
|
|
|
60
91
|
for (const tool of ['npx', 'bash']) {
|
|
61
|
-
|
|
62
|
-
|
|
92
|
+
// On Windows the bash that matters is Git Bash, found beside git rather than on PATH — say so by name.
|
|
93
|
+
const name = tool === 'bash' && process.platform === 'win32' ? 'Git Bash' : tool;
|
|
94
|
+
if (tool === 'bash' ? hasBash() : has(tool)) check(checks, tool, 'environment', 'ok', `${name} present`);
|
|
95
|
+
else check(checks, tool, 'environment', 'warn', name === 'Git Bash' ? 'Git Bash not found' : `${tool} not found on PATH`, tool === 'npx' ? 'repomix packing will be skipped' : bashMissingHint());
|
|
63
96
|
}
|
|
64
97
|
}
|
|
65
98
|
|
|
66
99
|
// `headCount` is a count of people the caller already read (E74); the CLI passes none, a test passes one.
|
|
67
100
|
export function projectChecks(checks, root, { headCount = null } = {}) {
|
|
68
101
|
const productPath = productConfigPath(root);
|
|
102
|
+
// The file actually read (E122: product.json when it exists), named in every message below — naming
|
|
103
|
+
// the old one would send a person to edit the file that is not read, which is a refused drift.
|
|
104
|
+
const settingsRel = path.relative(root, productPath).split(path.sep).join('/');
|
|
69
105
|
const regPath = path.join(root, PROJECT_FILES.reposRegistry);
|
|
70
106
|
const verPath = path.join(root, PROJECT_FILES.version);
|
|
71
107
|
if (!exists(productPath) && !exists(regPath) && !exists(verPath)) {
|
|
@@ -84,27 +120,27 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
|
|
|
84
120
|
else check(checks, 'cli-version', 'project', 'ok', `version stamp matches (v${VERSION})`);
|
|
85
121
|
|
|
86
122
|
// hub.json: parse + shape
|
|
87
|
-
let
|
|
123
|
+
let productConfig = null;
|
|
88
124
|
if (!exists(productPath)) {
|
|
89
|
-
check(checks, '
|
|
125
|
+
check(checks, 'product', 'project', 'warn', `${settingsRel} absent — local gate`, 'run `yad setup` to configure a platform');
|
|
90
126
|
} else {
|
|
91
|
-
let
|
|
127
|
+
let productConfigBroken = false;
|
|
92
128
|
try {
|
|
93
|
-
|
|
129
|
+
productConfig = readJSONStrict(productPath, null);
|
|
94
130
|
} catch (e) {
|
|
95
|
-
|
|
96
|
-
check(checks, '
|
|
131
|
+
productConfigBroken = true;
|
|
132
|
+
check(checks, 'product', 'project', 'fail', `${settingsRel} does not parse [${e.code || 'YAD-STATE-001'}]`, e.hint || 'fix the JSON or restore it from git');
|
|
97
133
|
}
|
|
98
|
-
if (
|
|
99
|
-
else if (typeof
|
|
100
|
-
else if (![null, undefined, 'github', 'gitlab'].includes(
|
|
134
|
+
if (productConfigBroken) { /* reported above */ }
|
|
135
|
+
else if (typeof productConfig !== 'object' || Array.isArray(productConfig) || productConfig === null) check(checks, 'product', 'project', 'fail', `${settingsRel} has the wrong shape [YAD-STATE-002]`, 'expected a JSON object');
|
|
136
|
+
else if (![null, undefined, 'github', 'gitlab'].includes(productConfig.platform)) check(checks, 'product', 'project', 'fail', `${settingsRel}: unknown platform '${forTerminal(productConfig.platform)}' [YAD-CFG-001]`, 'expected github, gitlab, or null');
|
|
101
137
|
else {
|
|
102
|
-
check(checks, '
|
|
138
|
+
check(checks, 'product', 'project', 'ok', `product: ${productConfig.platform || 'local'}`);
|
|
103
139
|
// E62 removed the roster. A list an older release wrote is kept on disk and decides nothing — its
|
|
104
140
|
// name → login pairs only let the first sync recognise older approvals (`legacyLogins`) — so say so
|
|
105
141
|
// — a team that still edits it would otherwise believe it decides something. An empty list (what a
|
|
106
142
|
// solo setup used to write) says nothing about people and is left quiet.
|
|
107
|
-
const r =
|
|
143
|
+
const r = productConfig.roster;
|
|
108
144
|
const listed = Array.isArray(r) ? r.length > 0 : (r && typeof r === 'object' ? Object.keys(r).length > 0 : !!r);
|
|
109
145
|
if (listed) {
|
|
110
146
|
// When it can go (E64): once every older record it can place names the login. Counted with the same
|
|
@@ -112,8 +148,8 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
|
|
|
112
148
|
let waiting = 0;
|
|
113
149
|
let unplaced = 0;
|
|
114
150
|
const waitingIn = [];
|
|
115
|
-
const aliases = legacyLogins(
|
|
116
|
-
const clashed = ambiguousLegacyNames(
|
|
151
|
+
const aliases = legacyLogins(productConfig);
|
|
152
|
+
const clashed = ambiguousLegacyNames(productConfig);
|
|
117
153
|
for (const e of epicIds(root)) {
|
|
118
154
|
try {
|
|
119
155
|
const led = loadLedger(epicRoot(root, e));
|
|
@@ -122,15 +158,15 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
|
|
|
122
158
|
unplaced += st.unplaced;
|
|
123
159
|
} catch { /* an unreadable ledger is reported by its own check */ }
|
|
124
160
|
}
|
|
125
|
-
const when = !
|
|
126
|
-
?
|
|
161
|
+
const when = !productConfig.platform
|
|
162
|
+
? `nothing reads it on a Product with no platform — delete the \`roster\` key from ${settingsEditHint(root)}`
|
|
127
163
|
: waiting
|
|
128
|
-
? `keep it for now: ${waiting} older approval/comment record(s) in ${waitingIn.join(', ')} still name people by roster name. The next gate write records their logins — ${isVerifiedLedger(
|
|
164
|
+
? `keep it for now: ${waiting} older approval/comment record(s) in ${waitingIn.join(', ')} still name people by roster name. The next gate write records their logins — ${isVerifiedLedger(productConfig) ? 'CI\'s run on the next merged review' : '`yad gate sync <epic>`'} — then delete the \`roster\` key from ${settingsEditHint(root)}`
|
|
129
165
|
: unplaced
|
|
130
|
-
? `every older record it can place names its login now; ${unplaced} it cannot place (a name two logins share, or records that disagree about which review they are) are matched by submission time or given again on a new review — delete the \`roster\` key once those reviews are closed`
|
|
131
|
-
:
|
|
166
|
+
? `every older record it can place names its login now; ${unplaced} it cannot place (a name two logins share, or records that disagree about which review they are) are matched by submission time or given again on a new review — delete the \`roster\` key from ${settingsEditHint(root)} once those reviews are closed`
|
|
167
|
+
: `no older record needs it any more — delete the \`roster\` key from ${settingsEditHint(root)}`;
|
|
132
168
|
check(checks, 'people:roster-unused', 'project', 'warn',
|
|
133
|
-
`${
|
|
169
|
+
`${settingsRel} has a \`roster\` that no longer decides who approves — a gate needs one approval (not the author's own) from anyone with access`,
|
|
134
170
|
when);
|
|
135
171
|
// Those pairs are exact only when the roster is: a name two logins share cannot say which person an
|
|
136
172
|
// older record means. Named here, because on an open review that approval may then have to be given
|
|
@@ -138,21 +174,21 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
|
|
|
138
174
|
// that entry, and an unlisted login was written `unverified` — so it is not warned about.) The hint
|
|
139
175
|
// does NOT say "rename it": renaming one entry hands every older record under the name to whoever
|
|
140
176
|
// keeps it, which can put someone's approval of old content on a person who never gave it.
|
|
141
|
-
const unclear = [...ambiguousLegacyNames(
|
|
177
|
+
const unclear = [...ambiguousLegacyNames(productConfig).keys()];
|
|
142
178
|
if (unclear.length) {
|
|
143
179
|
check(checks, 'people:roster-ambiguous', 'project', 'warn',
|
|
144
|
-
`${
|
|
180
|
+
`${settingsRel} roster name(s) ${unclear.map(forTerminal).join(', ')} are given to more than one login — an older approval under that name cannot be recognised by name`,
|
|
145
181
|
'leave the roster as it is: an older approval under that name is matched only when its submission time says whose it is, and otherwise may need to be given again on a new PR. Renaming an entry hands those records to whoever keeps the name — only do it if you know whose approval each one was');
|
|
146
182
|
}
|
|
147
183
|
}
|
|
148
|
-
if (isSolo(
|
|
184
|
+
if (isSolo(productConfig)) {
|
|
149
185
|
check(checks, 'solo', 'project', 'ok', 'mode: solo — approval waived; the PR merge + resolved threads gate the step');
|
|
150
186
|
// E74: suggest team mode when the count shows more than one person may work here. Counted ONLY
|
|
151
187
|
// in solo mode, because the count walks the git history of every connected repo; team mode
|
|
152
188
|
// pays nothing. A suggestion, never a switch — so a warning, never a failure. An unknown count
|
|
153
189
|
// is a warning too (the user's choice, 2026-09-22): doctor is where a count that cannot be read
|
|
154
190
|
// is fixed, and a ✓ beside "could not be counted" would read as healthy.
|
|
155
|
-
const hint = soloTeamHint(root,
|
|
191
|
+
const hint = soloTeamHint(root, productConfig, { solo: true, headCount });
|
|
156
192
|
if (hint.line && hint.known) {
|
|
157
193
|
check(checks, 'mode:suggest-team', 'project', 'warn', hint.line,
|
|
158
194
|
`run \`${TEAM_CMD}\` if more than one person works here; if it is only you (for example two accounts, two spellings of your name, a robot committing or auto-approving, or your own approval on a local ledger), leave solo mode on`);
|
|
@@ -164,44 +200,48 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
|
|
|
164
200
|
// E10 writes `mode: solo|team` beside `solo`, and `solo` is still the one read. A hand edit can leave
|
|
165
201
|
// the two saying different things; name that, and say which one the gates follow. Silent on a file
|
|
166
202
|
// with no `mode`, which is every Product set up before E10 (the frozen golden one included).
|
|
167
|
-
if (
|
|
168
|
-
const acting = isSolo(
|
|
169
|
-
if (
|
|
203
|
+
if (productConfig.mode !== undefined) {
|
|
204
|
+
const acting = isSolo(productConfig) ? 'solo' : 'team';
|
|
205
|
+
if (productConfig.mode !== acting) {
|
|
170
206
|
check(checks, 'mode:disagree', 'project', 'warn',
|
|
171
|
-
`${
|
|
172
|
-
['solo', 'team'].includes(
|
|
173
|
-
? `\`yad mode ${acting}\` keeps what the gates do now; \`yad mode ${
|
|
207
|
+
`${settingsRel} says mode: ${JSON.stringify(forTerminal(productConfig.mode))}, but solo mode is ${acting === 'solo' ? 'on' : 'off'} — the old \`solo\` flag is the one the gates read`,
|
|
208
|
+
['solo', 'team'].includes(productConfig.mode)
|
|
209
|
+
? `\`yad mode ${acting}\` keeps what the gates do now; \`yad mode ${productConfig.mode === 'solo' ? 'solo --reason "<why>"' : 'team'}\` makes the gates follow \`mode\``
|
|
174
210
|
: `\`yad mode ${acting}\` writes a mode the gates recognise`);
|
|
175
211
|
}
|
|
176
212
|
}
|
|
177
213
|
// platform CLI + auth (best-effort; auth probing is the user's own session)
|
|
178
|
-
const cli = cliFor(
|
|
214
|
+
const cli = cliFor(productConfig.platform);
|
|
179
215
|
if (cli) {
|
|
180
216
|
// git_url is required whenever a platform is set — doctor needs it to scope the auth probe
|
|
181
217
|
// and the verified ledger/PR flow needs it to open PRs. Warn on its absence directly (not on the
|
|
182
218
|
// resolved host), so it fires even when an origin remote can substitute: the field itself
|
|
183
219
|
// is required regardless.
|
|
184
|
-
if (!hostFromGitUrl(
|
|
185
|
-
check(checks, '
|
|
186
|
-
`${
|
|
187
|
-
|
|
220
|
+
if (!hostFromGitUrl(productConfig.git_url)) {
|
|
221
|
+
check(checks, 'product-git-url', 'project', 'warn',
|
|
222
|
+
`${settingsRel} sets platform '${productConfig.platform}' but has no git_url [YAD-CFG-005]`,
|
|
223
|
+
`add git_url to ${settingsEditHint(root)} (or re-run \`yad setup\`) — auth/PR checks need the Product host`);
|
|
188
224
|
}
|
|
189
225
|
// Scope the auth probe to the Product's own host (derived from git_url, falling back to the
|
|
190
226
|
// origin remote). `${cli} auth status` without --hostname exits non-zero when ANY configured
|
|
191
227
|
// instance fails, so an unrelated stale login (e.g. a dead gitlab.com token) would falsely
|
|
192
228
|
// flag a working self-hosted Product — so we SKIP the probe entirely when no host resolves
|
|
193
229
|
// rather than run the flaky unscoped form.
|
|
194
|
-
const
|
|
230
|
+
const rawHost = hostFromGitUrl(productConfig.git_url)
|
|
195
231
|
|| hostFromGitUrl(run('git', ['remote', 'get-url', 'origin'], { cwd: root }).stdout);
|
|
232
|
+
// Never asked about or repeated unless it is a plain host name (E81 review 14): it is printed
|
|
233
|
+
// inside `gh auth login --hostname …` for the person to run.
|
|
234
|
+
const host = plainHost(rawHost);
|
|
196
235
|
if (!has(cli)) check(checks, 'platform-cli', 'project', 'warn', `${cli} not found on PATH [YAD-ENV-002]`, `install ${cli} — the gate degrades to local without it`);
|
|
197
|
-
else if (!host) check(checks, 'platform-cli', 'project', 'warn', 'auth check skipped —
|
|
236
|
+
else if (rawHost && !host) check(checks, 'platform-cli', 'project', 'warn', 'auth check skipped — the Product\'s git remote URL names a host that is not a plain host name', `fix git_url in ${settingsEditHint(root)} (or fix the origin remote) to name a plain host`);
|
|
237
|
+
else if (!host) check(checks, 'platform-cli', 'project', 'warn', 'auth check skipped — the Product\'s host is unknown (no git_url / origin)', `add git_url to ${settingsEditHint(root)}, so the auth probe can target the right host`);
|
|
198
238
|
else if (!run(cli, ['auth', 'status', '--hostname', host]).ok) check(checks, 'platform-cli', 'project', 'warn', `${cli} present but not authenticated for ${host} [YAD-ENV-002]`, `run \`${cli} auth login --hostname ${host}\``);
|
|
199
239
|
else {
|
|
200
240
|
check(checks, 'platform-cli', 'project', 'ok', `${cli} present and authenticated`);
|
|
201
241
|
// GitLab API reachability: the gate reads MR state via `glab api …` (approvals, discussions).
|
|
202
242
|
// A present+authenticated glab whose token lacks api scope would still break readPrGitLab, so
|
|
203
243
|
// probe a cheap api call (warn-only) to surface it before a sync silently holds the gate.
|
|
204
|
-
if (
|
|
244
|
+
if (productConfig.platform === 'gitlab') {
|
|
205
245
|
// Scope the probe to the Product's own host (like the auth check above) so a multi-instance
|
|
206
246
|
// setup doesn't hit the wrong GitLab. `host` is guaranteed truthy here (we skip the whole
|
|
207
247
|
// auth branch when it cannot be resolved), so the probe is always host-scoped.
|
|
@@ -221,15 +261,12 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
|
|
|
221
261
|
// an agent's hand-edit is always rejected later by `ledger-guard`, so the local hook that refuses it
|
|
222
262
|
// up front should be installed. With a local ledger nothing guards it, and the hand-edit the
|
|
223
263
|
// authoring skills describe is correct — nothing to report, so the check is silent rather than `ok`.
|
|
224
|
-
const
|
|
225
|
-
if (isVerifiedLedger(
|
|
264
|
+
const productConfigForHooks = readJSON(productPath, null);
|
|
265
|
+
if (isVerifiedLedger(productConfigForHooks)) {
|
|
226
266
|
const unwired = [];
|
|
227
267
|
const broken = [];
|
|
228
|
-
// PRESENT
|
|
229
|
-
|
|
230
|
-
// `yad check` both call the guard healthy. Reachable from a zip download, `cp` without `-p`, or a
|
|
231
|
-
// restrictive umask, with nobody having done anything unusual.
|
|
232
|
-
if (!hookScriptReady(root, 'hooks/ledger-guard.sh')) unwired.push('hooks/ledger-guard.sh');
|
|
268
|
+
// PRESENT. The entry runs it as `node <file>` (E113), so no execute bit is involved.
|
|
269
|
+
for (const w of HOOK_WIRING) if (!hookScriptReady(root, w.dest)) unwired.push(w.dest);
|
|
233
270
|
// The SAME target list `hookActions` wires — the persisted `ideTargets`, not "does the directory
|
|
234
271
|
// exist". Keyed on the directory, a project whose targets are `['.agents']` but which also has a
|
|
235
272
|
// stray `.claude/` would be told to run `yad check --fix` forever, while that command builds no
|
|
@@ -255,7 +292,7 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
|
|
|
255
292
|
const adapter = HOOK_ADAPTERS[ide];
|
|
256
293
|
if (!adapter) { noProtocol.push(ide); continue; }
|
|
257
294
|
// The SCRIPT THE ENTRY POINTS AT, which is not always the shared one. `.cursor`'s entry names
|
|
258
|
-
// `hooks/ledger-guard-cursor.
|
|
295
|
+
// `hooks/ledger-guard-cursor.mjs`, and only the shared `hooks/ledger-guard.mjs` was checked above
|
|
259
296
|
// — so a project whose wrapper was deleted, gitignored or lost in a partial checkout had
|
|
260
297
|
// `yad check` calling it `new` while `yad doctor` printed a green "guard wired". The two
|
|
261
298
|
// commands disagreeing about one project is the worst version of this: whichever the human
|
|
@@ -314,7 +351,7 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
|
|
|
314
351
|
// `yad check --fix` would add our entry BESIDE theirs, leaving the one that refuses everything
|
|
315
352
|
// still in place, while the report said the guard was simply missing.
|
|
316
353
|
check(checks, 'hooks', 'project', 'warn', `agent ledger guard wired with a command that will refuse every write: ${miswired.join('; ')}`,
|
|
317
|
-
'this harness answers with a JSON verdict and reads an empty answer as a deny; replace that command with `hooks/ledger-guard-cursor.
|
|
354
|
+
'this harness answers with a JSON verdict and reads an empty answer as a deny; replace that command with `node hooks/ledger-guard-cursor.mjs`, which `yad check --fix` installs, or have your own wrapper run `yad hook ledger-guard --format cursor` and pass its stdout through');
|
|
318
355
|
} else if (unwired.length) {
|
|
319
356
|
check(checks, 'hooks', 'project', 'warn', `agent ledger guard not wired: ${unwired.join(', ')}${alsoUnguarded}`,
|
|
320
357
|
'run `yad check --fix` — until then an agent can hand-edit the CI-owned ledger and only find out when the review PR/MR fails');
|
|
@@ -328,15 +365,19 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
|
|
|
328
365
|
// Name every script that is actually wired, not just the shared one — on a `.cursor` project the
|
|
329
366
|
// file Cursor invokes is the wrapper, and a health line that never mentions it is a health line
|
|
330
367
|
// about something else.
|
|
331
|
-
const wiredScripts = [
|
|
368
|
+
const wiredScripts = [...HOOK_WIRING.map((w) => w.dest),
|
|
332
369
|
...targets.flatMap((ide) => (HOOK_ADAPTERS[ide]?.wiring || []).map((w) => w.dest))];
|
|
333
370
|
check(checks, 'hooks', 'project', 'ok', `agent ledger guard wired (${[...new Set(wiredScripts)].join(', ')})${alsoUnguarded}`);
|
|
334
371
|
}
|
|
372
|
+
gitHookCheck(root, checks);
|
|
335
373
|
}
|
|
374
|
+
// E80: any Product whose repos live beside it.
|
|
375
|
+
workspaceFileCheck(root, checks);
|
|
336
376
|
|
|
337
377
|
// Background capture (E43), on any Product in either ledger mode: is the post-edit hook wired, and will
|
|
338
378
|
// a push to the `yad/wip/*` branches start the team's own CI?
|
|
339
379
|
if (exists(productPath)) captureChecks(root, checks, readJSON(productPath, null));
|
|
380
|
+
if (exists(productPath)) lineEndingChecks(root, checks);
|
|
340
381
|
|
|
341
382
|
// design.json: parse + shape + tool + MCP confirmation (absent is the normal markdown-only default —
|
|
342
383
|
// pre-feature projects have none, so silence rather than warn when the file does not exist).
|
|
@@ -418,33 +459,49 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
|
|
|
418
459
|
if (regBroken) { /* reported above */ }
|
|
419
460
|
else if (!Array.isArray(registry?.repos)) check(checks, 'repos', 'project', 'fail', `${PROJECT_FILES.reposRegistry} has the wrong shape [YAD-STATE-002]`, 'expected a `repos` array');
|
|
420
461
|
else {
|
|
421
|
-
|
|
462
|
+
// An entry that is not an object (`null`, `5`) is named, never dereferenced (E81).
|
|
463
|
+
const repoEntries = registry.repos.filter((r) => isPlainObject(r));
|
|
464
|
+
const odd = registry.repos.length - repoEntries.length;
|
|
465
|
+
if (odd) check(checks, 'repos:not-an-entry', 'project', 'fail', `${odd} entr${odd > 1 ? 'ies' : 'y'} in ${PROJECT_FILES.reposRegistry} ${odd > 1 ? 'are' : 'is'} not an object [YAD-STATE-002]`, 'fix or remove it in repos.json');
|
|
466
|
+
for (const repo of repoEntries) {
|
|
422
467
|
// A missing/empty path must NOT fall back to the project root (which is itself a git repo and
|
|
423
468
|
// would read as "healthy") — an entry with no path is malformed.
|
|
424
|
-
if (!repo.path) { check(checks, `repo:${repo.name || '(unnamed)'}`, 'project', 'fail', `${repo.name || '(unnamed)'}: no \`path\` in repos.json [YAD-STATE-003]`, 're-connect the repo (`yad setup`)'); continue; }
|
|
469
|
+
if (typeof repo.path !== 'string' || !repo.path) { check(checks, `repo:${repo.name || '(unnamed)'}`, 'project', 'fail', `${forTerminal(repo.name || '(unnamed)')}: no \`path\` in repos.json [YAD-STATE-003]`, 're-connect the repo (`yad setup`)'); continue; }
|
|
425
470
|
const repoRoot = path.resolve(root, repo.path);
|
|
471
|
+
// Judged before git runs (E81) — the same judgement `yad repo clone` makes, so the hint names that
|
|
472
|
+
// command only for a repo it would clone, and git never runs in a folder with no `.git` (a
|
|
473
|
+
// committed folder shaped like a bare repo would have git read its `config`).
|
|
474
|
+
const judged = judgeRepo(root, repo);
|
|
475
|
+
const { name: nm, path: pth } = judged; // shown in the terminal: control characters made spaces
|
|
476
|
+
const cloneHint = judged.state === 'missing' ? 'run `yad repo clone` to clone it here' : null;
|
|
426
477
|
// A registered repo may be a SIBLING of the Product (`../backend`, the standard multi-repo layout).
|
|
427
478
|
// Such a checkout is legitimately absent wherever only the Product is checked out — Product CI, a fresh
|
|
428
479
|
// clone — so its absence is a warn, not corruption. A missing path INSIDE the project root is
|
|
429
480
|
// still a hard fail: nothing but damage explains it.
|
|
430
481
|
if (!exists(repoRoot)) {
|
|
431
|
-
if (underProjectRoot(root, repoRoot) || !isRegistrableSibling(root, repo.path)) check(checks, `repo:${repo.name}`, 'project', 'fail', `${
|
|
432
|
-
else check(checks, `repo:${repo.name}`, 'project', 'warn', `${
|
|
482
|
+
if (underProjectRoot(root, repoRoot) || !isRegistrableSibling(root, repo.path)) check(checks, `repo:${repo.name}`, 'project', 'fail', `${nm}: path ${pth} does not exist [YAD-STATE-003]`, cloneHint || 'fix the path in repos.json or re-connect the repo');
|
|
483
|
+
else check(checks, `repo:${repo.name}`, 'project', 'warn', `${nm}: ${pth} is not present in this checkout (sibling repo, outside the Product)`, cloneHint ? `expected when only the Product is checked out; ${cloneHint}` : `expected when only the Product is checked out; \`yad repo clone\` will not clone it (${judged.reason}) — clone it alongside the Product by hand to work on it here`);
|
|
433
484
|
continue;
|
|
434
485
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (
|
|
438
|
-
|
|
439
|
-
|
|
486
|
+
// There, but refused (outside the workspace, through `.git` or a link, or a folder with no `.git`):
|
|
487
|
+
// said with the reason, and git is not run in it.
|
|
488
|
+
if (judged.state === 'refused') { check(checks, `repo:${repo.name}`, 'project', 'fail', `${nm}: ${pth} — ${judged.reason} [YAD-STATE-003]`, 'fix the path in repos.json or re-connect the repo'); continue; }
|
|
489
|
+
// Reached through a link inside a repo's tree (a link the Product commits): `yad repo sync` and
|
|
490
|
+
// `refresh` skip it, and so do doctor's other checks — said, not read.
|
|
491
|
+
if (judged.linked) { check(checks, `repo:${repo.name}`, 'project', 'warn', `${nm}: ${pth} is reached through a link inside a repo's tree, so the checkout is not where the path says — yad skips it`, 'register the checkout at its own path'); continue; }
|
|
492
|
+
const head = judged.state === 'present' || judged.state === 'product' ? gitHead(repoRoot) : null;
|
|
493
|
+
if (!head) { check(checks, `repo:${repo.name}`, 'project', 'fail', `${nm}: ${pth} is not a git repository (or has no commits) [YAD-STATE-003]`, cloneHint || 'init/clone the repo, then re-connect it'); continue; }
|
|
494
|
+
if (!repo.syncedHead) check(checks, `repo:${repo.name}`, 'project', 'warn', `${nm}: registered without a code-context pack (greenfield)`, 'run `yad repo refresh ' + asArg(repo.name) + '` once it has code');
|
|
495
|
+
else if (head !== repo.syncedHead) check(checks, `repo:${repo.name}`, 'project', 'warn', `${nm}: code-context is stale (HEAD moved since last pack)`, 'run `yad repo refresh ' + asArg(repo.name) + '`');
|
|
496
|
+
else check(checks, `repo:${repo.name}`, 'project', 'ok', `${nm}: git repo, context fresh`);
|
|
440
497
|
}
|
|
441
498
|
if (!registry.repos.length) check(checks, 'repos', 'project', 'warn', 'no code repos registered', 'run `yad setup` to connect one');
|
|
442
499
|
// Per-repo owners went with the roster (E62). Named only where a repo actually lists someone: every
|
|
443
500
|
// repo an older setup connected carries an EMPTY `domain_owner`, which says nothing.
|
|
444
|
-
const owned =
|
|
501
|
+
const owned = repoEntries.filter((r) => (Array.isArray(r.domain_owners) && r.domain_owners.length) || (typeof r.domain_owner === 'string' && r.domain_owner));
|
|
445
502
|
if (owned.length) {
|
|
446
503
|
check(checks, 'people:domain-owners-unused', 'project', 'warn',
|
|
447
|
-
`${PROJECT_FILES.reposRegistry} names domain owners that nothing reads any more: ${owned.map((r) => r.name).join(', ')}`,
|
|
504
|
+
`${PROJECT_FILES.reposRegistry} names domain owners that nothing reads any more: ${owned.map((r) => forTerminal(r.name)).join(', ')}`,
|
|
448
505
|
'delete `domain_owner` / `domain_owners` when convenient; request reviewers on the PR itself');
|
|
449
506
|
}
|
|
450
507
|
// The verified-commits author allowlist went with the roster too (E62): the gate checks signatures
|
|
@@ -452,21 +509,21 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
|
|
|
452
509
|
// list in hub.json and every generated file — so nobody maintains a list that no longer protects.
|
|
453
510
|
const allowFiles = [
|
|
454
511
|
{ where: 'the Product', file: path.join(root, '.sdlc', 'verified-authors') },
|
|
455
|
-
...
|
|
512
|
+
...repoEntries.filter((r) => typeof r.path === 'string' && r.path).map((r) => ({ where: forTerminal(r.name), file: path.join(path.resolve(root, r.path), '.sdlc', 'verified-authors') })),
|
|
456
513
|
].filter((x) => exists(x.file)).map((x) => x.where);
|
|
457
|
-
const listedAuthors =
|
|
514
|
+
const listedAuthors = productConfig && typeof productConfig === 'object' && Array.isArray(productConfig.verified_authors) && productConfig.verified_authors.length > 0;
|
|
458
515
|
if (allowFiles.length || listedAuthors) {
|
|
459
|
-
const what = [listedAuthors ? `\`verified_authors\` in ${
|
|
516
|
+
const what = [listedAuthors ? `\`verified_authors\` in ${settingsRel}` : null, allowFiles.length ? `.sdlc/verified-authors in ${allowFiles.join(', ')}` : null].filter(Boolean).join(' and ');
|
|
460
517
|
check(checks, 'people:verified-authors-unused', 'project', 'warn',
|
|
461
518
|
`${what} — the verified-commits gate no longer reads an author list; it checks signatures only`,
|
|
462
|
-
|
|
519
|
+
`delete them when convenient${listedAuthors ? ` (\`verified_authors\` from ${settingsEditHint(root)})` : ''}; write access to the repo decides who can author a commit`);
|
|
463
520
|
}
|
|
464
521
|
// A wired `verified-commits.sh` from before E62 still ENFORCES the author list. On a verified ledger
|
|
465
522
|
// `yad check --fix` refreshes it; on a local ledger the Product's CI files are not managed, so an older
|
|
466
523
|
// copy stays and keeps failing commits from unlisted authors (E62 upgrade simulation). Named, not fixed.
|
|
467
524
|
const oldGates = [
|
|
468
525
|
{ where: 'the Product', file: path.join(root, 'checks', 'verified-commits.sh') },
|
|
469
|
-
...
|
|
526
|
+
...repoEntries.filter((r) => typeof r.path === 'string' && r.path).map((r) => ({ where: forTerminal(r.name), file: path.join(path.resolve(root, r.path), 'checks', 'verified-commits.sh') })),
|
|
470
527
|
].filter((x) => exists(x.file) && /SDLC_VERIFIED_AUTHORS|ALLOWLIST=/.test(fs.readFileSync(x.file, 'utf8'))).map((x) => x.where);
|
|
471
528
|
if (oldGates.length) {
|
|
472
529
|
check(checks, 'people:allowlist-gate-stale', 'project', 'warn',
|
|
@@ -477,7 +534,7 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
|
|
|
477
534
|
// slice out of specs/<story>/contracts/ (or a file out of src/<feature>/) passes the gate. `yad check
|
|
478
535
|
// --fix` refreshes a wired contract-check nobody changed; one changed by hand is kept, and
|
|
479
536
|
// backfill-check is never wired at all — every copy is placed by hand. Named, not fixed.
|
|
480
|
-
const gateRoots = [{ where: 'the Product', root }, ...
|
|
537
|
+
const gateRoots = [{ where: 'the Product', root }, ...repoEntries.filter((r) => typeof r.path === 'string' && r.path).map((r) => ({ where: forTerminal(r.name), root: path.resolve(root, r.path) }))];
|
|
481
538
|
const renameBlind = gateRoots
|
|
482
539
|
.flatMap((x) => RENAME_BLIND_GATES.map((rel) => ({ ...x, rel })))
|
|
483
540
|
.filter((x) => renameBlindGate(path.join(x.root, x.rel)))
|
|
@@ -496,10 +553,189 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
|
|
|
496
553
|
`checks/contract-check.sh in ${symlinkBlind.join(', ')} ${symlinkBlind.length > 1 ? 'are older copies that do' : 'is an older copy that does'} not refuse a symlink or submodule under specs/ — a link there hides the contract slice, and every later edit to its target passes`,
|
|
497
554
|
'run `yad check --fix` (it refreshes a wired contract-check nobody changed by hand); otherwise copy skills/yad-checks/templates/checks/contract-check.sh over it');
|
|
498
555
|
}
|
|
556
|
+
// E116. An older backfill-check reads the spec (and its `verified: true`) from the PR itself, so a PR
|
|
557
|
+
// can approve itself or delete the spec; and it reads paths only, so a symlink or submodule at `src`,
|
|
558
|
+
// `src/<feature>` or inside the feature hides every later edit to its target. Never wired: named, not fixed.
|
|
559
|
+
const backfillBlind = gateRoots.filter((x) => backfillBlindGate(path.join(x.root, 'checks/backfill-check.sh'))).map((x) => x.where);
|
|
560
|
+
if (backfillBlind.length) {
|
|
561
|
+
check(checks, 'checks:backfill-blind', 'project', 'warn',
|
|
562
|
+
`checks/backfill-check.sh in ${backfillBlind.join(', ')} ${backfillBlind.length > 1 ? 'are older copies that read' : 'is an older copy that reads'} the approval from the PR itself and does not refuse a symlink under src/ — a PR can approve its own change, and an edit behind a link passes`,
|
|
563
|
+
'copy skills/yad-backfill/templates/checks/backfill-check.sh over it — backfill-check is never wired, so `yad check --fix` does not refresh it');
|
|
564
|
+
}
|
|
565
|
+
// E117. An older Product-reading gate takes link.md's `product-repo` as the PR leaves it, so a PR can
|
|
566
|
+
// point it at nothing (a deferral, a PASS) or at a Product it commits itself. The current four read
|
|
567
|
+
// `product-repo` from the base and a tracked Product from the base commit. Named, not fixed.
|
|
568
|
+
const pathBlind = gateRoots
|
|
569
|
+
.flatMap((x) => PRODUCT_PATH_GATES.map((rel) => ({ ...x, rel })))
|
|
570
|
+
.filter((x) => productPathBlindGate(path.join(x.root, x.rel)))
|
|
571
|
+
.map((x) => `${x.rel} in ${x.where}`);
|
|
572
|
+
if (pathBlind.length) {
|
|
573
|
+
check(checks, 'checks:product-path-blind', 'project', 'warn',
|
|
574
|
+
`${pathBlind.join(', ')} ${pathBlind.length > 1 ? 'are older copies that read' : 'is an older copy that reads'} the Product from wherever the PR's link.md points — a PR can point it at nothing, or at a Product it commits itself, and pass`,
|
|
575
|
+
'run `yad check --fix` (it refreshes a wired gate nobody changed by hand); otherwise copy the shipped one over it from skills/yad-checks/templates/checks/');
|
|
576
|
+
}
|
|
577
|
+
// E120. An older Product-reading gate never reads `.sdlc/product-link.json`, so it still takes where
|
|
578
|
+
// the Product lives from link.md; and a checks workflow the team changed by hand (which `yad update`
|
|
579
|
+
// keeps) may not run checks/product-checkout.sh, so CI never checks the Product out. Named, not fixed.
|
|
580
|
+
const blindGates = gateRoots.flatMap((x) => PRODUCT_PATH_GATES.map((rel) => ({ ...x, rel })))
|
|
581
|
+
.filter((x) => productRecordBlindGate(path.join(x.root, x.rel)))
|
|
582
|
+
.map((x) => `${x.rel} in ${x.where}`);
|
|
583
|
+
// A `#` line naming the script is prose, not a step that runs it (review 1).
|
|
584
|
+
const blindFlows = gateRoots.filter((x) => x.where !== 'the Product').flatMap((x) => CHECK_WORKFLOWS.map((rel) => ({ ...x, rel })))
|
|
585
|
+
.filter((x) => {
|
|
586
|
+
let src;
|
|
587
|
+
try { src = fs.readFileSync(path.join(x.root, x.rel), 'utf8'); } catch { return false; }
|
|
588
|
+
return !src.split('\n').some((l) => !/^\s*#/.test(l) && /checks\/product-checkout\.sh/.test(l));
|
|
589
|
+
})
|
|
590
|
+
.map((x) => `${x.rel} in ${x.where}`);
|
|
591
|
+
const said = [
|
|
592
|
+
...(blindGates.length ? [`${blindGates.join(', ')} ${blindGates.length > 1 ? 'never read' : 'never reads'} .sdlc/product-link.json, so where the Product lives is taken from link.md`] : []),
|
|
593
|
+
...(blindFlows.length ? [`${blindFlows.join(', ')} ${blindFlows.length > 1 ? 'do' : 'does'} not run checks/product-checkout.sh, so CI never checks the Product out`] : []),
|
|
594
|
+
];
|
|
595
|
+
if (said.length) {
|
|
596
|
+
check(checks, 'checks:product-record-blind', 'project', 'warn', said.join('; '),
|
|
597
|
+
'run `yad check --fix` (it refreshes wired files nobody changed by hand); otherwise copy the shipped gate or workflow over it from skills/yad-checks/templates/');
|
|
598
|
+
}
|
|
599
|
+
// E120. A wired code repo (not the Product's own git repo) whose record is not COMMITTED: its gates
|
|
600
|
+
// fall back to link.md, and CI has nothing to check the Product out from. Committed, not on disk
|
|
601
|
+
// (review 1): the gates read it from the default branch, and one that setup wrote and nobody
|
|
602
|
+
// committed looked fine here. Wired only: a repo with no gates has nothing to read it, and the
|
|
603
|
+
// hint would install its whole wiring.
|
|
604
|
+
const top = (d) => { const x = run('git', ['rev-parse', '--show-toplevel'], { cwd: d }); return x.ok ? path.resolve(x.stdout.trim()) : ''; };
|
|
605
|
+
const rootTop = top(root);
|
|
606
|
+
const unlinked = repoEntries.filter((r) => typeof r.path === 'string' && r.path).map((r) => ({ r, dir: path.resolve(root, r.path) }))
|
|
607
|
+
.filter(({ r, dir }) => isCheckout(root, r) && (exists(path.join(dir, '.sdlc/managed.json')) || exists(path.join(dir, 'checks/contract-check.sh'))))
|
|
608
|
+
.filter(({ dir }) => { const t = top(dir); return t && t !== rootTop; })
|
|
609
|
+
.map(({ r, dir }) => {
|
|
610
|
+
const branch = r.default_branch || 'main';
|
|
611
|
+
const onRemote = run('git', ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`], { cwd: dir }).ok;
|
|
612
|
+
const ref = onRemote ? `origin/${branch}` : 'HEAD';
|
|
613
|
+
const shown = run('git', ['show', `${ref}:${PRODUCT_LINK}`], { cwd: dir });
|
|
614
|
+
// What is on disk (what yad last wrote) against what the default branch holds (what CI reads):
|
|
615
|
+
// a record updated on disk and never committed is as stale as a missing one (review 2).
|
|
616
|
+
let disk = null;
|
|
617
|
+
try { disk = JSON.parse(fs.readFileSync(path.join(dir, PRODUCT_LINK), 'utf8')); } catch { /* none, or not JSON */ }
|
|
618
|
+
let there;
|
|
619
|
+
try { there = shown.ok ? JSON.parse(shown.stdout) : null; } catch { there = null; }
|
|
620
|
+
const ignored = run('git', ['check-ignore', '-q', '--', PRODUCT_LINK], { cwd: dir }).ok;
|
|
621
|
+
const same = (a, b) => ['git_url', 'path', 'default_branch'].every((k) => (a?.[k] ?? null) === (b?.[k] ?? null));
|
|
622
|
+
const behind = !there || (disk && !same(disk, there));
|
|
623
|
+
if (!behind) return null;
|
|
624
|
+
if (ignored) return { name: r.name, branch, why: 'ignored' };
|
|
625
|
+
// Committed here and never pushed (review 3): a --push whose push was refused (a protected
|
|
626
|
+
// branch) leaves the record `ok` for the writer, so `--fix --push` stages nothing — the fix is to
|
|
627
|
+
// push the commit, not to run it again.
|
|
628
|
+
// The disk copy IS this clone's committed one (HEAD), and origin's differs: which side is ahead is
|
|
629
|
+
// git's to say, not the copies' (reviews 4–5). One table, every case:
|
|
630
|
+
// no commit to the record in origin/<b>..HEAD → this clone is only behind: on <b>, pull;
|
|
631
|
+
// elsewhere, nothing — CI's copy is the newer one
|
|
632
|
+
// such a commit, on <b>, origin not ahead → not pushed: push it
|
|
633
|
+
// such a commit, on <b>, origin also ahead → diverged: pull, settle the record, push
|
|
634
|
+
// such a commit, on a detached HEAD → check out <b> first
|
|
635
|
+
// such a commit, on another branch → merge it into <b> (a PR)
|
|
636
|
+
// The branch this clone is on: a hint that ends in `--push` only works on <b> (review 6).
|
|
637
|
+
const cur = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir });
|
|
638
|
+
const onBranch = cur.ok ? cur.stdout.trim() : 'HEAD';
|
|
639
|
+
const offBranch = onBranch !== branch ? onBranch : null;
|
|
640
|
+
if (onRemote && disk) {
|
|
641
|
+
let head;
|
|
642
|
+
try { const h = run('git', ['show', `HEAD:${PRODUCT_LINK}`], { cwd: dir }); head = h.ok ? JSON.parse(h.stdout) : null; } catch { head = null; }
|
|
643
|
+
if (head && same(head, disk)) {
|
|
644
|
+
const count = (range, ...only) => {
|
|
645
|
+
const x = run('git', ['rev-list', '--count', range, ...(only.length ? ['--', ...only] : [])], { cwd: dir });
|
|
646
|
+
return x.ok ? Number(x.stdout.trim()) || 0 : 0;
|
|
647
|
+
};
|
|
648
|
+
const ahead = count(`origin/${branch}..HEAD`, PRODUCT_LINK) > 0;
|
|
649
|
+
// Not ahead and off <b>: nothing to say only while <b> still holds a readable record, which is
|
|
650
|
+
// then the newer one (review 6: one deleted or broken on <b> was passed over in silence).
|
|
651
|
+
if (!ahead && onBranch === branch) return { name: r.name, branch, why: 'behind' };
|
|
652
|
+
if (!ahead && there) return null;
|
|
653
|
+
if (ahead && onBranch === branch) return { name: r.name, branch, why: count(`HEAD..origin/${branch}`) > 0 ? 'diverged' : 'unpushed' };
|
|
654
|
+
if (ahead && onBranch === 'HEAD') return { name: r.name, branch, why: 'detached' };
|
|
655
|
+
if (ahead) return { name: r.name, branch, onBranch, why: 'elsewhere' };
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
// A copy ON DISK that is not JSON is what a plain --fix leaves alone (review 8) — not the default
|
|
659
|
+
// branch's copy, which a clone off that branch only picks up once it switches and pulls.
|
|
660
|
+
const diskBad = !disk && exists(path.join(dir, PRODUCT_LINK));
|
|
661
|
+
// Off the default branch, the hint's first step is a switch, and a switch REPLACES a record this
|
|
662
|
+
// branch tracks with the default branch's (review 9) — so judge the copy it will have then.
|
|
663
|
+
const trackedHere = !!offBranch && run('git', ['cat-file', '-e', `HEAD:${PRODUCT_LINK}`], { cwd: dir }).ok;
|
|
664
|
+
if (!there) return { name: r.name, branch, offBranch, diskBad, trackedHere, why: shown.ok ? 'unreadable' : 'missing' };
|
|
665
|
+
return { name: r.name, branch, offBranch, why: 'stale' };
|
|
666
|
+
})
|
|
667
|
+
.filter(Boolean)
|
|
668
|
+
// Everything below prints these (E81 review 11): the registry's name and branch, and the clone's own
|
|
669
|
+
// branch names, made safe once here — names for prose, the branch for the git commands to copy.
|
|
670
|
+
.map((u) => ({ ...u, name: forTerminal(u.name), branch: branchArg(u.branch),
|
|
671
|
+
...(u.onBranch ? { onBranch: forTerminal(u.onBranch) } : {}), ...(u.offBranch ? { offBranch: forTerminal(u.offBranch) } : {}) }));
|
|
672
|
+
const named = (why) => unlinked.filter((u) => u.why === why).map((u) => u.name);
|
|
673
|
+
const missing = named('missing');
|
|
674
|
+
const unreadable = named('unreadable');
|
|
675
|
+
const stale = named('stale');
|
|
676
|
+
const ignoredRepos = named('ignored');
|
|
677
|
+
const unpushed = unlinked.filter((u) => u.why === 'unpushed');
|
|
678
|
+
if (missing.length || unreadable.length || stale.length) {
|
|
679
|
+
check(checks, 'repos:product-link-missing', 'project', 'warn', [
|
|
680
|
+
...(missing.length ? [`${missing.join(', ')} ${missing.length > 1 ? 'have' : 'has'} no ${PRODUCT_LINK} on the default branch — the gates there take where the Product lives from link.md, and CI cannot check the Product out`] : []),
|
|
681
|
+
...(unreadable.length ? [`${unreadable.join(', ')} ${unreadable.length > 1 ? 'have' : 'has'} no readable ${PRODUCT_LINK} on the default branch (it is not JSON) — CI cannot read it`] : []),
|
|
682
|
+
...(stale.length ? [`${stale.join(', ')} ${stale.length > 1 ? 'hold' : 'holds'} a ${PRODUCT_LINK} on disk that differs from the one on the default branch — CI reads the default branch's`] : []),
|
|
683
|
+
].join('; '),
|
|
684
|
+
// One step per repo that needs more than the plain command (review 7): which branch it is on, and
|
|
685
|
+
// --overwrite-local where the copy is not JSON (a plain --fix leaves such a copy alone).
|
|
686
|
+
(() => {
|
|
687
|
+
const listed = unlinked.filter((u) => ['missing', 'unreadable', 'stale'].includes(u.why));
|
|
688
|
+
const where = (u) => (u.offBranch === 'HEAD' ? 'a detached HEAD' : u.offBranch);
|
|
689
|
+
const off = listed.filter((u) => u.offBranch);
|
|
690
|
+
// --overwrite-local only where a plain --fix would leave the copy alone: it is not JSON on disk
|
|
691
|
+
// now, or will not be once an off-branch clone pulls the default branch's unreadable one.
|
|
692
|
+
const bad = listed.filter((u) => (!u.offBranch ? u.diskBad
|
|
693
|
+
: u.trackedHere ? u.why === 'unreadable' : u.diskBad || u.why === 'unreadable'));
|
|
694
|
+
return [
|
|
695
|
+
...(off.length ? [`first bring ${off.length > 1 ? 'each clone' : 'the clone'} to its default branch — ${off.map((u) => `${u.name} (on ${where(u)}): \`git checkout ${u.branch} && git pull origin ${u.branch}\``).join('; ')} — or commit the record on the branch ${off.length > 1 ? 'each is' : 'it is'} on and merge it with a PR`] : []),
|
|
696
|
+
`${off.length ? 'then ' : ''}from the Product run \`yad check --fix --push\`: it writes the record into each connected repo and commits it to the default branch`,
|
|
697
|
+
...(bad.length ? [`add \`--overwrite-local\` for ${bad.map((u) => u.name).join(', ')}, whose copy is not JSON and is otherwise left alone — the flag applies to the whole run: it also replaces files changed by hand in the Product and in every connected repo, after saving each`] : []),
|
|
698
|
+
].join('; ');
|
|
699
|
+
})());
|
|
700
|
+
}
|
|
701
|
+
const elsewhere = unlinked.filter((u) => u.why === 'elsewhere');
|
|
702
|
+
if (elsewhere.length) {
|
|
703
|
+
check(checks, 'repos:product-link-missing', 'project', 'warn',
|
|
704
|
+
elsewhere.map((u) => `${u.name} has ${PRODUCT_LINK} committed on branch ${u.onBranch}, not on ${u.branch} — CI reads ${u.branch}`).join('; '),
|
|
705
|
+
`merge it into the default branch (${elsewhere.map((u) => `a PR from ${u.onBranch} into ${u.branch} in ${u.name}`).join(', ')})`);
|
|
706
|
+
}
|
|
707
|
+
const behind = unlinked.filter((u) => u.why === 'behind');
|
|
708
|
+
if (behind.length) {
|
|
709
|
+
check(checks, 'repos:product-link-missing', 'project', 'warn',
|
|
710
|
+
`${behind.map((u) => u.name).join(', ')} ${behind.length > 1 ? 'are' : 'is'} behind the default branch for ${PRODUCT_LINK} — someone changed it there since this clone last pulled`,
|
|
711
|
+
`pull (${behind.map((u) => `\`git pull origin ${u.branch}\` in ${u.name}`).join(', ')}); the default branch's record is the one CI reads`);
|
|
712
|
+
}
|
|
713
|
+
const diverged = unlinked.filter((u) => u.why === 'diverged');
|
|
714
|
+
if (diverged.length) {
|
|
715
|
+
check(checks, 'repos:product-link-missing', 'project', 'warn',
|
|
716
|
+
`${diverged.map((u) => u.name).join(', ')} ${diverged.length > 1 ? 'have' : 'has'} a ${PRODUCT_LINK} commit the default branch lacks, and the default branch has moved on too — a push would be refused`,
|
|
717
|
+
`pull (rebase or merge) in ${diverged.map((u) => u.name).join(', ')}, settle the record, then push — or open a PR`);
|
|
718
|
+
}
|
|
719
|
+
const detached = unlinked.filter((u) => u.why === 'detached');
|
|
720
|
+
if (detached.length) {
|
|
721
|
+
check(checks, 'repos:product-link-missing', 'project', 'warn',
|
|
722
|
+
`${detached.map((u) => u.name).join(', ')} ${detached.length > 1 ? 'have' : 'has'} a ${PRODUCT_LINK} commit the default branch lacks, on a detached HEAD`,
|
|
723
|
+
`check out the default branch (${detached.map((u) => `\`git checkout ${u.branch}\` in ${u.name}`).join(', ')}), bring the commit onto it, then push`);
|
|
724
|
+
}
|
|
725
|
+
if (unpushed.length) {
|
|
726
|
+
check(checks, 'repos:product-link-missing', 'project', 'warn',
|
|
727
|
+
`${unpushed.map((u) => u.name).join(', ')} ${unpushed.length > 1 ? 'have' : 'has'} ${PRODUCT_LINK} committed but not pushed — CI reads the default branch, which does not have it yet`,
|
|
728
|
+
`push that commit (${unpushed.map((u) => `\`git push origin ${u.branch}\` in ${u.name}`).join(', ')}), or open a PR with it if the branch is protected — \`yad check --fix --push\` has nothing left to commit`);
|
|
729
|
+
}
|
|
730
|
+
if (ignoredRepos.length) {
|
|
731
|
+
check(checks, 'repos:product-link-missing', 'project', 'warn',
|
|
732
|
+
`${ignoredRepos.join(', ')} ${ignoredRepos.length > 1 ? 'ignore' : 'ignores'} ${PRODUCT_LINK} (.gitignore), so \`yad check --fix --push\` cannot commit it — CI cannot read it`,
|
|
733
|
+
`un-ignore ${PRODUCT_LINK} in that repo's .gitignore (or \`git add -f\` it), and commit it to the default branch`);
|
|
734
|
+
}
|
|
499
735
|
}
|
|
500
736
|
|
|
501
|
-
ciTagsChecks(checks, root,
|
|
502
|
-
return {
|
|
737
|
+
ciTagsChecks(checks, root, productConfig, registry);
|
|
738
|
+
return { productConfig, registry };
|
|
503
739
|
}
|
|
504
740
|
|
|
505
741
|
// GitLab CI runner tags: the wired fragments run docker-image jobs. On instances whose runners are
|
|
@@ -507,7 +743,7 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
|
|
|
507
743
|
// forever — silently blocking the gates (issue #50). A current fragment carries
|
|
508
744
|
// `tags: [$YAD_RUNNER_TAGS]`; warn on any wired GitLab fragment that sets an `image:` but has no
|
|
509
745
|
// `tags:` (an old install, or one hand-reverted by a sync). Pure local read — no API calls.
|
|
510
|
-
export function ciTagsChecks(checks, root,
|
|
746
|
+
export function ciTagsChecks(checks, root, productConfig, registry) {
|
|
511
747
|
const untagged = (p) => {
|
|
512
748
|
try {
|
|
513
749
|
const txt = fs.readFileSync(p, 'utf8');
|
|
@@ -515,22 +751,26 @@ export function ciTagsChecks(checks, root, hub, registry) {
|
|
|
515
751
|
} catch { return false; } // absent fragment is not this check's concern
|
|
516
752
|
};
|
|
517
753
|
const fragments = [];
|
|
518
|
-
if (
|
|
754
|
+
if (productConfig?.platform === 'gitlab' && isVerifiedLedger(productConfig)) {
|
|
519
755
|
fragments.push(
|
|
520
|
-
{ scope: '
|
|
521
|
-
{ scope: '
|
|
522
|
-
{ scope: '
|
|
756
|
+
{ scope: 'product', file: '.gitlab/ci/yad-gate-sync.yml', path: path.join(root, '.gitlab/ci/yad-gate-sync.yml') },
|
|
757
|
+
{ scope: 'product', file: '.gitlab/ci/yad-verified-commits.yml', path: path.join(root, '.gitlab/ci/yad-verified-commits.yml') },
|
|
758
|
+
{ scope: 'product', file: '.gitlab/ci/yad-product-checks.yml', path: path.join(root, '.gitlab/ci/yad-product-checks.yml') },
|
|
759
|
+
// Its old name (E123), while it is still there — `renamed:` says to run `yad update`.
|
|
760
|
+
{ scope: 'product', file: '.gitlab/ci/yad-hub-checks.yml', path: path.join(root, '.gitlab/ci/yad-hub-checks.yml') },
|
|
523
761
|
);
|
|
524
762
|
}
|
|
525
|
-
for (const repo of registry?.repos
|
|
526
|
-
if (repo.platform !== 'gitlab' || !repo.path) continue;
|
|
527
|
-
fragments.push({ scope: repo.name, file: '.gitlab/ci/yad-checks.yml', path: path.join(path.resolve(root, repo.path), '.gitlab/ci/yad-checks.yml') });
|
|
763
|
+
for (const repo of Array.isArray(registry?.repos) ? registry.repos : []) {
|
|
764
|
+
if (!isPlainObject(repo) || repo.platform !== 'gitlab' || !repo.path) continue;
|
|
765
|
+
fragments.push({ scope: forTerminal(repo.name), repo: true, file: '.gitlab/ci/yad-checks.yml', path: path.join(path.resolve(root, repo.path), '.gitlab/ci/yad-checks.yml') });
|
|
528
766
|
}
|
|
529
767
|
for (const f of fragments) {
|
|
530
768
|
if (untagged(f.path)) {
|
|
531
769
|
check(checks, `ci-tags:${f.scope}`, 'project', 'warn',
|
|
532
770
|
`${f.scope}: ${f.file} runs a docker job with no \`tags:\` [YAD-CI-001]`,
|
|
533
|
-
'tag-locked runners (run_untagged: false) will strand it at `pending` — run `yad update`, then set the `YAD_RUNNER_TAGS` CI/CD variable'
|
|
771
|
+
'tag-locked runners (run_untagged: false) will strand it at `pending` — run `yad update`, then set the `YAD_RUNNER_TAGS` CI/CD variable',
|
|
772
|
+
// The id cannot tell the Product from a repo named `product`; this key can (E124 review 1).
|
|
773
|
+
{ product: f.scope === 'product' && !f.repo });
|
|
534
774
|
}
|
|
535
775
|
}
|
|
536
776
|
}
|
|
@@ -706,13 +946,15 @@ export function epicChecks(checks, root) {
|
|
|
706
946
|
for (const v of stateInvariants(ledger.state)) {
|
|
707
947
|
check(checks, `epic:${e}:${v.authorStep}`, 'epics', 'fail',
|
|
708
948
|
`${e}: ${v.message} [${v.code}]`,
|
|
709
|
-
|
|
949
|
+
// On a verified Product the repair's hand commit is refused by the git pre-commit guard (E48);
|
|
950
|
+
// `--push` makes the commit yad owns, so that is the command named there.
|
|
951
|
+
`run \`yad gate repair ${e}${isVerifiedLedger(readJSON(productConfigPath(root), null)) ? ' --push' : ''}\` to close it`);
|
|
710
952
|
}
|
|
711
953
|
// Migration guard (pre-3.0 model): under the current model CI records the ledger on the
|
|
712
954
|
// default branch only at merge (when the step is already done), and writes nothing during
|
|
713
955
|
// review — so an OPEN (non-done) review PR recorded here means it was opened under an older
|
|
714
956
|
// model. Merge/close it under the version that opened it before relying on the CI flow.
|
|
715
|
-
const openPr = (ledger.
|
|
957
|
+
const openPr = (ledger.productPrs || []).find((p) => {
|
|
716
958
|
const step = ledger.state.steps.find((s) => s.id === p.step);
|
|
717
959
|
return !!step && !isPassed(step);
|
|
718
960
|
});
|
|
@@ -754,7 +996,7 @@ export function epicChecks(checks, root) {
|
|
|
754
996
|
// ledger — so it is reported as fine, because a warning nobody can clear teaches
|
|
755
997
|
// people to stop reading warnings.
|
|
756
998
|
export function foundationChecks(checks, root) {
|
|
757
|
-
const
|
|
999
|
+
const productConfig = readJSON(productConfigPath(root), null);
|
|
758
1000
|
const hasFoundation = exists(path.join(epicRoot(root, FOUNDATION_EPIC), '.sdlc', 'state.json'));
|
|
759
1001
|
const hasLegacy = exists(path.join(epicRoot(root, DISCOVERY_EPIC), '.sdlc', 'state.json'));
|
|
760
1002
|
if (hasFoundation && hasLegacy) {
|
|
@@ -762,7 +1004,7 @@ export function foundationChecks(checks, root) {
|
|
|
762
1004
|
`two product levels: ${FOUNDATION_DIR}/ and epics/${DISCOVERY_EPIC}/ — a product has one Foundation`,
|
|
763
1005
|
`decide which one is real. \`yad next\` uses ${FOUNDATION_DIR}/; to keep the old one instead, move ${FOUNDATION_DIR}/ aside and run \`yad migrate --apply\``);
|
|
764
1006
|
} else if (hasLegacy) {
|
|
765
|
-
if (isVerifiedLedger(
|
|
1007
|
+
if (isVerifiedLedger(productConfig)) {
|
|
766
1008
|
// CI moves it (cli/gate.mjs `convertProductLevel`), but only once the committed checks know the
|
|
767
1009
|
// Foundation — so the stale case is the one with something for a person to do.
|
|
768
1010
|
const stale = staleFoundationGuards(root);
|
|
@@ -785,7 +1027,7 @@ export function foundationChecks(checks, root) {
|
|
|
785
1027
|
`epics/${FOUNDATION_EPIC}/ exists, but that id's folder is ${FOUNDATION_DIR}/ — nothing ever reads this one`,
|
|
786
1028
|
`move anything real into ${FOUNDATION_DIR}/, then delete epics/${FOUNDATION_EPIC}/`);
|
|
787
1029
|
}
|
|
788
|
-
foundationGuardChecks(checks, root,
|
|
1030
|
+
foundationGuardChecks(checks, root, productConfig);
|
|
789
1031
|
if (hasFoundation) foundationSectionChecks(checks, root);
|
|
790
1032
|
}
|
|
791
1033
|
|
|
@@ -817,9 +1059,9 @@ export function foundationSectionChecks(checks, root) {
|
|
|
817
1059
|
: 'write the section before the review is approved — the yad-discovery skill says what each section needs');
|
|
818
1060
|
}
|
|
819
1061
|
|
|
820
|
-
function foundationGuardChecks(checks, root,
|
|
1062
|
+
function foundationGuardChecks(checks, root, productConfig) {
|
|
821
1063
|
if (!exists(path.join(root, FOUNDATION_DIR, '.sdlc'))) return;
|
|
822
|
-
if (!isVerifiedLedger(
|
|
1064
|
+
if (!isVerifiedLedger(productConfig)) return;
|
|
823
1065
|
const stale = staleFoundationGuards(root);
|
|
824
1066
|
if (!stale.length) return;
|
|
825
1067
|
check(checks, 'foundation:guard', 'project', 'warn',
|
|
@@ -907,20 +1149,21 @@ function shapeCheckFor(checks, id, label, rows, engine) {
|
|
|
907
1149
|
// reaches it, which is how the drift report is proven before there is any drift to report.
|
|
908
1150
|
// A file that lives under two names must say the same thing under both. The engine writes them
|
|
909
1151
|
// together, so they only drift when something outside the engine touched one — a person editing the
|
|
910
|
-
// name they happen to know, a script, a half-finished merge.
|
|
911
|
-
//
|
|
912
|
-
//
|
|
1152
|
+
// name they happen to know, a script, a half-finished merge. Since E122 no command reads a drifted
|
|
1153
|
+
// pair (every one refuses, YAD-STATE-008), so this is a failure, with the one command that ends it.
|
|
1154
|
+
// When the pair agrees, one plain line says why the old name is still there and when it goes.
|
|
913
1155
|
export function mirrorChecks(checks, root) {
|
|
914
|
-
const pairs =
|
|
1156
|
+
const pairs = MIRRORED_FILES.map(({ canonical, legacy }) => ({ canonical, legacy, epic: null }));
|
|
915
1157
|
// The per-epic PR ledger is renamed the same way, so it drifts the same way. It is not in
|
|
916
1158
|
// MIRRORED_FILES because that list is project-relative and this one exists once per epic.
|
|
917
1159
|
// `epicIds` reads with `withFileTypes`, so a dangling symlink is simply not a directory — it cannot
|
|
918
1160
|
// throw here and take the whole health check down, which is what the old `statSync` guard was for.
|
|
919
1161
|
for (const e of epicIds(root)) {
|
|
920
1162
|
const f = epicFiles(epicRel(e));
|
|
921
|
-
pairs.push({ canonical: f.productPrs, legacy: f.
|
|
1163
|
+
pairs.push({ canonical: f.productPrs, legacy: f.productPrsLegacy, epic: e });
|
|
922
1164
|
}
|
|
923
|
-
|
|
1165
|
+
let kept = false;
|
|
1166
|
+
for (const { canonical, legacy, epic } of pairs) {
|
|
924
1167
|
const a = path.join(root, canonical);
|
|
925
1168
|
const b = path.join(root, legacy);
|
|
926
1169
|
// Only the settings file reaches this branch in practice: the per-epic PR ledgers are top-level
|
|
@@ -946,13 +1189,20 @@ export function mirrorChecks(checks, root) {
|
|
|
946
1189
|
continue;
|
|
947
1190
|
}
|
|
948
1191
|
if (!exists(a)) continue;
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1192
|
+
if (!mirrorDrift(a, b)) { if (!epic) kept = true; continue; }
|
|
1193
|
+
// An epic's drifted pair is already a failure of that epic's own check (`loadLedger` refuses it,
|
|
1194
|
+
// with the same code and hint), so it is not said twice.
|
|
1195
|
+
if (epic) continue;
|
|
952
1196
|
check(
|
|
953
|
-
checks, `mirror:${canonical}`, 'shape', '
|
|
954
|
-
`${canonical} and ${legacy}
|
|
955
|
-
'
|
|
1197
|
+
checks, `mirror:${canonical}`, 'shape', 'fail',
|
|
1198
|
+
`${canonical} and ${legacy} say different things — every command refuses until one is chosen [YAD-STATE-008]`,
|
|
1199
|
+
'run `yad migrate` to see the difference, then `yad migrate --apply` to choose the copy to keep (or `--apply --keep product` / `--keep hub`)',
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
if (kept) {
|
|
1203
|
+
check(
|
|
1204
|
+
checks, 'mirror:legacy-names', 'shape', 'ok',
|
|
1205
|
+
`${PROJECT_FILES.productConfigLegacy} (and each epic's hub-prs.json) is kept beside the new name for check gates an older yadflow installed — ${PROJECT_FILES.productConfig} is the one read, and v5 deletes the old name`,
|
|
956
1206
|
);
|
|
957
1207
|
}
|
|
958
1208
|
}
|
|
@@ -1643,7 +1893,7 @@ export function ownerChecks(checks, root) {
|
|
|
1643
1893
|
// `\` goes on to the next, as a hand-edited copy may split `git … \ diff --name-only`; then each line is
|
|
1644
1894
|
// cut at `&&`, `||`, `;` and `|`, so `fixed && \ blind` is two commands, not one.
|
|
1645
1895
|
export const RENAME_BLIND_GATES = ['checks/contract-check.sh', 'checks/backfill-check.sh'];
|
|
1646
|
-
const renameBlindCommand = (c) => /\bgit\b.*\bdiff\b
|
|
1896
|
+
const renameBlindCommand = (c) => /\bgit\b.*\bdiff\b.*(--name-only|--raw)/.test(c) && !/--no-renames/.test(c);
|
|
1647
1897
|
export const renameBlindText = (src) => src.split('\n').map((l) => (/^\s*#/.test(l) ? '' : l)).join('\n')
|
|
1648
1898
|
.replace(/\\\r?\n/g, ' ').split('\n').some((l) => l.split(/&&|\|\||;|\|/).some(renameBlindCommand));
|
|
1649
1899
|
export function renameBlindGate(file) {
|
|
@@ -1652,34 +1902,138 @@ export function renameBlindGate(file) {
|
|
|
1652
1902
|
return renameBlindText(src);
|
|
1653
1903
|
}
|
|
1654
1904
|
// E115: a contract-check that never reads the tree for links under specs/. Read like the one above — a
|
|
1655
|
-
// `#` line is prose, a line ending in `\` goes on — and it is enough that ONE command runs `git ls-tree
|
|
1656
|
-
// no older copy ever did, and the current one reads the whole tree (the name `specs` is in its
|
|
1905
|
+
// `#` line is prose, a line ending in `\` goes on — and it is enough that ONE command runs `git ls-tree`
|
|
1906
|
+
// on HEAD: no older copy ever did, and the current one reads the whole tree (the name `specs` is in its
|
|
1907
|
+
// awk). HEAD, because E117 added a second `git ls-tree`, of the BASE, that finds sibling link.md files.
|
|
1657
1908
|
export const symlinkBlindText = (src) => !src.split('\n').map((l) => (/^\s*#/.test(l) ? '' : l)).join('\n')
|
|
1658
|
-
.replace(/\\\r?\n/g, ' ').split('\n').some((l) => /\bgit\b.*\bls-tree\b/.test(l));
|
|
1909
|
+
.replace(/\\\r?\n/g, ' ').split('\n').some((l) => /\bgit\b.*\bls-tree\b.*\bHEAD\b/.test(l));
|
|
1659
1910
|
export function symlinkBlindGate(file) {
|
|
1660
1911
|
let src;
|
|
1661
1912
|
try { src = fs.readFileSync(file, 'utf8'); } catch { return false; }
|
|
1662
1913
|
return symlinkBlindText(src);
|
|
1663
1914
|
}
|
|
1915
|
+
// E116: a backfill-check that reads the spec from the PR's working tree, or never reads the tree at HEAD
|
|
1916
|
+
// for links under src/, or splits its --raw list with `tr`. Read like the ones above; no older copy ran
|
|
1917
|
+
// `git ls-tree` or `git cat-file`.
|
|
1918
|
+
export const backfillBlindText = (src) => {
|
|
1919
|
+
const lines = src.split('\n').map((l) => (/^\s*#/.test(l) ? '' : l)).join('\n').replace(/\\\r?\n/g, ' ').split('\n');
|
|
1920
|
+
return !lines.some((l) => /\bgit\b.*\bls-tree\b.*\bHEAD\b/.test(l)) || !lines.some((l) => /\bgit\s+cat-file\s/.test(l))
|
|
1921
|
+
// The first E116 copy split its --raw list into lines with `tr`, so one path with a newline hid every
|
|
1922
|
+
// feature after it (review 1). The current one pairs the records NUL by NUL.
|
|
1923
|
+
|| lines.some((l) => /\bgit\b.*\bdiff\b.*--raw\b.*\|\s*tr\b/.test(l));
|
|
1924
|
+
};
|
|
1925
|
+
export function backfillBlindGate(file) {
|
|
1926
|
+
let src;
|
|
1927
|
+
try { src = fs.readFileSync(file, 'utf8'); } catch { return false; }
|
|
1928
|
+
return backfillBlindText(src);
|
|
1929
|
+
}
|
|
1930
|
+
// E120: a Product-reading gate that never reads `.sdlc/product-link.json` (no non-comment command naming
|
|
1931
|
+
// it). Read like the ones above.
|
|
1932
|
+
export const productRecordBlindText = (src) => !src.split('\n').map((l) => (/^\s*#/.test(l) ? '' : l)).join('\n')
|
|
1933
|
+
.replace(/\\\r?\n/g, ' ').split('\n').some((l) => /\bgit\b.*product-link\.json/.test(l));
|
|
1934
|
+
export function productRecordBlindGate(file) {
|
|
1935
|
+
let src;
|
|
1936
|
+
try { src = fs.readFileSync(file, 'utf8'); } catch { return false; }
|
|
1937
|
+
return productRecordBlindText(src);
|
|
1938
|
+
}
|
|
1939
|
+
// The checks workflows a connected repo may carry (one per platform).
|
|
1940
|
+
export const CHECK_WORKFLOWS = ['.github/workflows/yad-checks.yml', '.gitlab/ci/yad-checks.yml'];
|
|
1941
|
+
// E117: a Product-reading gate that does not read link.md from the base (`git show`) or a tracked Product
|
|
1942
|
+
// from the base commit (`git checkout-index`, from a throwaway index). Read like the two above; no older
|
|
1943
|
+
// copy ran either command.
|
|
1944
|
+
export const PRODUCT_PATH_GATES = ['checks/contract-check.sh', 'checks/lineage-check.sh', 'checks/epic-open.sh', 'checks/reconcile-debt-check.sh'];
|
|
1945
|
+
export const productPathBlindText = (src) => {
|
|
1946
|
+
const lines = src.split('\n').map((l) => (/^\s*#/.test(l) ? '' : l)).join('\n').replace(/\\\r?\n/g, ' ').split('\n');
|
|
1947
|
+
return !lines.some((l) => /\bgit\s+show\s/.test(l)) || !lines.some((l) => /\bgit\s+checkout-index\s/.test(l));
|
|
1948
|
+
};
|
|
1949
|
+
export function productPathBlindGate(file) {
|
|
1950
|
+
let src;
|
|
1951
|
+
try { src = fs.readFileSync(file, 'utf8'); } catch { return false; }
|
|
1952
|
+
return productPathBlindText(src);
|
|
1953
|
+
}
|
|
1664
1954
|
|
|
1665
1955
|
// `owners:rename-blind` (E47). The wired `pr-title` / `pr-template` checks let a PR of step owner files alone
|
|
1666
|
-
// through; that is safe only while the
|
|
1956
|
+
// through; that is safe only while the product-checks workflow lists renames by BOTH paths (`--no-renames`).
|
|
1667
1957
|
// `yad update` keeps a workflow the team changed by hand, so a Product can hold the new checks and the old
|
|
1668
1958
|
// workflow — and then `git mv epic.md .sdlc/owners/epic.json` on a non-review branch passes. Say so.
|
|
1669
|
-
|
|
1959
|
+
// The old names too (E123): an edited `yad-hub-checks.yml` is kept by `yad update`, and still runs.
|
|
1670
1960
|
export function ownerGuardChecks(checks, root) {
|
|
1671
1961
|
const read = (rel) => { try { return fs.readFileSync(path.join(root, rel), 'utf8'); } catch { return null; } };
|
|
1672
1962
|
// The exemption's own pattern, not any mention of the folder: a comment is not a rule.
|
|
1673
1963
|
const exempting = ['checks/pr-title.sh', 'checks/pr-template.sh'].filter((rel) => read(rel)?.includes('\\.sdlc/owners/[^/]+\\.json$'));
|
|
1674
1964
|
if (!exempting.length) return;
|
|
1675
1965
|
// Line by line: one fixed diff line must not hide its blind twin in the other job.
|
|
1676
|
-
const blind =
|
|
1966
|
+
const blind = PRODUCT_CHECK_WORKFLOWS.filter((rel) => renameBlindText(read(rel) || ''));
|
|
1677
1967
|
if (!blind.length) return;
|
|
1678
1968
|
check(checks, 'owners:rename-blind', 'project', 'warn',
|
|
1679
1969
|
`${blind.join(', ')} lists a PR's changes without \`--no-renames\`, while ${exempting.join(' and ')} let a PR of step owner files alone through — so moving an artifact into .sdlc/owners/ on a non-review branch passes both`,
|
|
1680
1970
|
'add `--no-renames` (and `-c core.quotePath=false`) to that workflow\'s `git diff --name-only` lines, as the shipped one has — `yad update` did not replace it because it was changed by hand');
|
|
1681
1971
|
}
|
|
1682
1972
|
|
|
1973
|
+
// Names the `hub` -> Product rename moved (E123), on the Product. Two kinds, both warnings:
|
|
1974
|
+
// renamed:<old path> something yad installed is still under its old name — a skill folder, or a CI
|
|
1975
|
+
// file. `yad update` renames it; an edited CI file it keeps, and says how to finish.
|
|
1976
|
+
// renamed-ref:<file> a file of the team's own names one of our old CI names, by file and line. yad
|
|
1977
|
+
// never edits those files; the one exception is the include line in the root
|
|
1978
|
+
// .gitlab-ci.yml, which `yad update` rewrites when it replaces the old fragment.
|
|
1979
|
+
// Silent when nothing old is left, so a project that never had the old names sees no line at all.
|
|
1980
|
+
export function renamedChecks(checks, root) {
|
|
1981
|
+
if (!exists(productConfigPath(root))) return;
|
|
1982
|
+
let installed = [];
|
|
1983
|
+
// An unreadable provenance record or IDE target is named by its own check; this one then says nothing.
|
|
1984
|
+
try { installed = [...legacyModuleActions(root), ...legacyProductActions(root)]; } catch { /* named elsewhere */ }
|
|
1985
|
+
for (const a of installed) {
|
|
1986
|
+
const { from, to } = a.rename;
|
|
1987
|
+
check(checks, `renamed:${from}`, 'project', 'warn',
|
|
1988
|
+
`${from} is an old name — renamed ${to}`,
|
|
1989
|
+
a.status === 'modified'
|
|
1990
|
+
? `it was edited, so \`yad update\` keeps it: \`yad update --overwrite-local\` replaces it with ${to} (your copy is saved as ${path.basename(from)}${BACKUP_SUFFIX}); then copy your edits into ${to}`
|
|
1991
|
+
: 'run `yad update` — it installs the new name and removes the old one');
|
|
1992
|
+
}
|
|
1993
|
+
const byFile = new Map();
|
|
1994
|
+
// The same pairs every other command refuses on (`productDriftError`): then only `yad migrate` comes first.
|
|
1995
|
+
const profileFirst = oldProfileAdvice(productGateBlockers(root, { drift: productDriftPairs(root) }));
|
|
1996
|
+
for (const h of renamedNameHits(root)) {
|
|
1997
|
+
if (!byFile.has(h.file)) byFile.set(h.file, []);
|
|
1998
|
+
byFile.get(h.file).push(h);
|
|
1999
|
+
}
|
|
2000
|
+
for (const [file, hits] of byFile) {
|
|
2001
|
+
const listed = hits.map((h) => `line ${h.line} \`${h.old}\` → \`${h.new}\``).join(', ');
|
|
2002
|
+
const theirs = hits.filter((h) => !h.rewrittenBy);
|
|
2003
|
+
// The include line is rewritten by yad — by a plain `yad update`, or, when the old fragment was edited,
|
|
2004
|
+
// only by `--overwrite-local`. Until then the new fragment is not installed, so a hand edit of that line
|
|
2005
|
+
// would include a file that is not there and fail every pipeline (review 1): say to leave it.
|
|
2006
|
+
const late = hits.some((h) => h.rewrittenBy === 'overwrite-local');
|
|
2007
|
+
const by = late ? '`yad update --overwrite-local`' : '`yad update`';
|
|
2008
|
+
const all = theirs.length === hits.length;
|
|
2009
|
+
// One mention per line, though a line may hold two old names (review 3).
|
|
2010
|
+
const what = all ? (hits.length > 1 ? 'each' : 'it') : [...new Set(theirs.map((h) => h.line))].map((l) => `line ${l}`).join(', ');
|
|
2011
|
+
const hint = theirs.length === 0
|
|
2012
|
+
? `${by} rewrites it when it replaces the old fragment${late ? ' — leave it until then: the new fragment is not installed before that' : ''}`
|
|
2013
|
+
: `yad does not edit this file — change ${what} to ${!all && theirs.length > 1 ? 'their new names' : 'its new name'}${all ? '' : ` (${by} rewrites the include line itself${late ? ' — leave that one until then' : ''})`}${
|
|
2014
|
+
// E124 reviews 2–3: a gate that would mishandle `product` must be fixed, or replaced, before the change.
|
|
2015
|
+
hits.some((h) => h.profile && !h.rewrittenBy) && profileFirst ? `; ${profileFirst}` : ''}`;
|
|
2016
|
+
check(checks, `renamed-ref:${file}`, 'project', 'warn',
|
|
2017
|
+
`${file} names what 4.0 renamed: ${listed}`, hint);
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// `profile:<gate>` (E123). A Product workflow passes `--profile product` to the pattern gates, and a gate the
|
|
2022
|
+
// team edited before 4.0 — which `yad update` keeps — accepts only `code|hub`, so every Product PR fails it.
|
|
2023
|
+
// A warning, like E123's other lines. Only when a workflow on disk passes that profile; an old workflow still
|
|
2024
|
+
// passing `hub` breaks nothing.
|
|
2025
|
+
export function productProfileChecks(checks, root) {
|
|
2026
|
+
if (!exists(productConfigPath(root))) return;
|
|
2027
|
+
const passing = workflowsPassingProduct(root);
|
|
2028
|
+
if (!passing.length) return;
|
|
2029
|
+
// What to do depends on the gate's state in the provenance record, as in `renamed-ref:` (E124 review 4).
|
|
2030
|
+
for (const b of productGateBlockers(root, { drift: productDriftPairs(root) })) {
|
|
2031
|
+
check(checks, `profile:${b.gate}`, 'project', 'warn',
|
|
2032
|
+
productProfileEffect(b.gap, b.gate, `${passing.join(' and ')} ${passing.length > 1 ? 'pass' : 'passes'}`),
|
|
2033
|
+
gateProfileFix(b));
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
|
|
1683
2037
|
// `.sdlc/skills.json`: which skill runs which step, when the project does not want the engine's
|
|
1684
2038
|
// default (E6). Absent is the normal case and says nothing — most projects run the shipped skills.
|
|
1685
2039
|
//
|
|
@@ -1910,27 +2264,30 @@ export function riskMapChecks(checks, root) {
|
|
|
1910
2264
|
for (const repo of repos) {
|
|
1911
2265
|
if (!repo || typeof repo.name !== 'string' || typeof repo.path !== 'string' || !repo.path) continue;
|
|
1912
2266
|
const repoRoot = path.resolve(root, repo.path);
|
|
1913
|
-
if (!
|
|
2267
|
+
if (!isCheckout(root, repo) || !gitHead(repoRoot)) continue;
|
|
1914
2268
|
const r = checkRepo(repoRoot);
|
|
1915
2269
|
if (!r.git) continue;
|
|
1916
2270
|
const id = `risk-map:${repo.name}`;
|
|
2271
|
+
// Printed (E81 review 11): the name in prose through `shown`, and in a command to copy only as a plain word.
|
|
2272
|
+
const nm = forTerminal(repo.name);
|
|
2273
|
+
const arg = asArg(repo.name);
|
|
1917
2274
|
if (!r.map) {
|
|
1918
|
-
check(checks, id, 'risk-map', 'ok', `${
|
|
2275
|
+
check(checks, id, 'risk-map', 'ok', `${nm}: no ${RISK_MAP_FILE} yet — no directory has a risk level (\`yad risk-map draft ${arg}\` starts one)`);
|
|
1919
2276
|
continue;
|
|
1920
2277
|
}
|
|
1921
|
-
if (!r.findings.length) { check(checks, id, 'risk-map', 'ok', `${
|
|
2278
|
+
if (!r.findings.length) { check(checks, id, 'risk-map', 'ok', `${nm}: every directory has a confirmed level`); continue; }
|
|
1922
2279
|
const groups = [];
|
|
1923
2280
|
for (const f of r.findings) {
|
|
1924
2281
|
let g = groups.find((x) => x.code === f.code);
|
|
1925
2282
|
if (!g) { g = { code: f.code, targets: [] }; groups.push(g); }
|
|
1926
|
-
if (f.target) g.targets.push(f.target);
|
|
2283
|
+
if (f.target) g.targets.push(forTerminal(f.target)); // a folder name from the repo's own tree (E81 review 13)
|
|
1927
2284
|
}
|
|
1928
2285
|
const said = groups.map((g) => {
|
|
1929
2286
|
const shown = g.targets.slice(0, 3).join(', ') + (g.targets.length > 3 ? ` +${g.targets.length - 3} more` : '');
|
|
1930
2287
|
return `${g.code}${shown ? ` (${shown})` : ''}`;
|
|
1931
2288
|
}).join('; ');
|
|
1932
|
-
check(checks, id, 'risk-map', 'warn', `${
|
|
1933
|
-
`\`yad risk-map check ${
|
|
2289
|
+
check(checks, id, 'risk-map', 'warn', `${nm}: ${RISK_MAP_FILE} is out of date — ${said}`,
|
|
2290
|
+
`\`yad risk-map check ${arg}\` lists each one; fix the map in ${nm} through a PR (advisory — it blocks nothing)`,
|
|
1934
2291
|
{ findings: r.findings });
|
|
1935
2292
|
}
|
|
1936
2293
|
}
|
|
@@ -1946,15 +2303,16 @@ export function codeownersChecks(checks, root) {
|
|
|
1946
2303
|
for (const repo of repos) {
|
|
1947
2304
|
if (!repo || typeof repo.name !== 'string' || typeof repo.path !== 'string' || !repo.path) continue;
|
|
1948
2305
|
const repoRoot = path.resolve(root, repo.path);
|
|
1949
|
-
if (!
|
|
2306
|
+
if (!isCheckout(root, repo) || !gitHead(repoRoot)) continue;
|
|
1950
2307
|
const r = checkCodeowners(repoRoot, { platform: repo.platform || null });
|
|
1951
2308
|
if (!r.git) continue;
|
|
1952
2309
|
const id = `codeowners:${repo.name}`;
|
|
1953
|
-
const
|
|
1954
|
-
|
|
1955
|
-
if (r.
|
|
2310
|
+
const nm = forTerminal(repo.name); // printed as in riskMapChecks (E81 review 11)
|
|
2311
|
+
const more = `\`yad codeowners check ${asArg(repo.name)}\``;
|
|
2312
|
+
if (r.unknown) { check(checks, id, 'codeowners', 'warn', `${nm}: CODEOWNERS could not be checked — ${forTerminal(r.unknown)}`, more); continue; }
|
|
2313
|
+
if (r.none) { check(checks, id, 'codeowners', 'ok', `${nm}: no CODEOWNERS — nothing to check`); continue; }
|
|
1956
2314
|
const findings = codeownersFindings(r);
|
|
1957
|
-
if (!findings.length) { check(checks, id, 'codeowners', 'ok', `${
|
|
2315
|
+
if (!findings.length) { check(checks, id, 'codeowners', 'ok', `${nm}: every ${r.path} line yad can read matches a file`); continue; }
|
|
1958
2316
|
const lines = (code) => findings.filter((f) => f.code === code).map((f) => f.line);
|
|
1959
2317
|
const listed = (ns) => ns.slice(0, 3).join(', ') + (ns.length > 3 ? ` +${ns.length - 3} more` : '');
|
|
1960
2318
|
const said = [];
|
|
@@ -1964,13 +2322,13 @@ export function codeownersChecks(checks, root) {
|
|
|
1964
2322
|
if (dead.length) said.push(`in ${r.path}, ${dead.length === 1 ? '1 line matches' : `${dead.length} lines match`} no file (line${dead.length === 1 ? '' : 's'} ${listed(dead)})`);
|
|
1965
2323
|
const unread = lines('not-read');
|
|
1966
2324
|
if (unread.length) said.push(`in ${r.path}, ${unread.length === 1 ? '1 line' : `${unread.length} lines`} yad could not read (line${unread.length === 1 ? '' : 's'} ${listed(unread)})`);
|
|
1967
|
-
check(checks, id, 'codeowners', 'warn', `${
|
|
1968
|
-
`${more} lists each one; fix it in ${
|
|
2325
|
+
check(checks, id, 'codeowners', 'warn', `${nm}: CODEOWNERS may be out of date — ${said.join('; ')}`,
|
|
2326
|
+
`${more} lists each one; fix it in ${nm} through a PR (advisory — CODEOWNERS is a hint, and yad never enforces it)`,
|
|
1969
2327
|
{ findings });
|
|
1970
2328
|
}
|
|
1971
2329
|
}
|
|
1972
2330
|
|
|
1973
|
-
// E70 — what the platform holds on the Product
|
|
2331
|
+
// E70 — what the platform holds on the Product's branch and on each connected repo's: branch
|
|
1974
2332
|
// protection, and whether a rule requires an approval before a merge (cli/protection.mjs). Never quiet
|
|
1975
2333
|
// (rule 6): every repo gets a line, and a read that failed says "not known" and why — never "fine", never
|
|
1976
2334
|
// "unprotected". Advisory, like E69: a warning at most, never a failure — the platform holds a merge, not
|
|
@@ -1979,8 +2337,8 @@ export function codeownersChecks(checks, root) {
|
|
|
1979
2337
|
// the branch gates merge into; with none, the platform's own default is read and the line says so.
|
|
1980
2338
|
export function protectionChecks(checks, root, { runner, env } = {}) {
|
|
1981
2339
|
const productPath = productConfigPath(root);
|
|
1982
|
-
const
|
|
1983
|
-
const solo = isSolo(
|
|
2340
|
+
const productConfig = readJSON(productPath, null);
|
|
2341
|
+
const solo = isSolo(productConfig);
|
|
1984
2342
|
// One login check per host for this run, and never longer: a login can change between runs.
|
|
1985
2343
|
const opts = { ...(runner ? { runner } : {}), ...(env ? { env } : {}), authCache: new Map() };
|
|
1986
2344
|
const origin = (cwd) => run('git', ['remote', 'get-url', 'origin'], { cwd }).stdout || null;
|
|
@@ -1989,12 +2347,12 @@ export function protectionChecks(checks, root, { runner, env } = {}) {
|
|
|
1989
2347
|
const line = protectionLine(r, { name, solo });
|
|
1990
2348
|
check(checks, id, 'protection', line.status, line.message, line.hint || '', { protection: protectionJSON(r), alwaysHint: true });
|
|
1991
2349
|
};
|
|
1992
|
-
if (isPlainObject(
|
|
1993
|
-
// `protection` alone, never `protection:
|
|
1994
|
-
emit('protection', 'Product
|
|
1995
|
-
platform:
|
|
1996
|
-
gitUrl:
|
|
1997
|
-
branch: typeof
|
|
2350
|
+
if (isPlainObject(productConfig)) {
|
|
2351
|
+
// `protection` alone, never `protection:product`: a connected repo may be named `product`.
|
|
2352
|
+
emit('protection', 'Product', {
|
|
2353
|
+
platform: productConfig.platform || null,
|
|
2354
|
+
gitUrl: productConfig.git_url || origin(root),
|
|
2355
|
+
branch: typeof productConfig.default_branch === 'string' && productConfig.default_branch ? productConfig.default_branch : null,
|
|
1998
2356
|
});
|
|
1999
2357
|
}
|
|
2000
2358
|
const registry = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
|
|
@@ -2002,10 +2360,10 @@ export function protectionChecks(checks, root, { runner, env } = {}) {
|
|
|
2002
2360
|
for (const [i, repo] of repos.entries()) {
|
|
2003
2361
|
if (!repo || typeof repo.name !== 'string' || !repo.name) continue;
|
|
2004
2362
|
const repoRoot = typeof repo.path === 'string' && repo.path ? path.resolve(root, repo.path) : null;
|
|
2005
|
-
const onDisk = repoRoot &&
|
|
2363
|
+
const onDisk = repoRoot && isCheckout(root, repo) && gitHead(repoRoot);
|
|
2006
2364
|
// A name with an `@` is hidden in the id too; its place in repos.json keeps the id one word. Nothing
|
|
2007
2365
|
// validates a repo's name, so a repo literally named `#1` could take the same id — it only labels a line.
|
|
2008
|
-
emit(hideAddresses(repo.name) === repo.name ? `protection:${repo.name}` : `protection:#${i + 1}`, repo.name, {
|
|
2366
|
+
emit(hideAddresses(repo.name) === repo.name ? `protection:${repo.name}` : `protection:#${i + 1}`, forTerminal(repo.name), {
|
|
2009
2367
|
platform: repo.platform || null,
|
|
2010
2368
|
gitUrl: (typeof repo.git_url === 'string' && repo.git_url) || (onDisk ? origin(repoRoot) : null),
|
|
2011
2369
|
branch: typeof repo.default_branch === 'string' && repo.default_branch ? repo.default_branch : null,
|
|
@@ -2052,8 +2410,8 @@ export function indexChecks(checks, root) {
|
|
|
2052
2410
|
if (!exists(productConfigPath(root))) return;
|
|
2053
2411
|
const fresh = indexFreshness(root);
|
|
2054
2412
|
if (fresh.state === 'none') return; // no work items and no index: nothing to be behind
|
|
2055
|
-
const
|
|
2056
|
-
const verified = isVerifiedLedger(
|
|
2413
|
+
const productConfigNow = readJSON(productConfigPath(root), null);
|
|
2414
|
+
const verified = isVerifiedLedger(productConfigNow);
|
|
2057
2415
|
const hint = verified
|
|
2058
2416
|
? 'CI rebuilds it when it records the next merged review; on a verified Product a local write could not be committed'
|
|
2059
2417
|
: 'run `yad index` on the default branch, then commit it';
|
|
@@ -2067,9 +2425,9 @@ export function indexChecks(checks, root) {
|
|
|
2067
2425
|
if (fresh.state !== 'unreadable' && exists(path.join(root, '.git'))) {
|
|
2068
2426
|
const git = productGit(root);
|
|
2069
2427
|
const head = git('rev-parse', '--abbrev-ref', 'HEAD');
|
|
2070
|
-
const main = resolveDefaultBranch(git,
|
|
2428
|
+
const main = resolveDefaultBranch(git, productConfigNow);
|
|
2071
2429
|
if (head.ok && head.stdout && head.stdout !== main) {
|
|
2072
|
-
check(checks, 'index', 'index', 'ok', `${INDEX_FILE} ${fresh.state === 'missing' ? 'is not built yet' : 'is not what this yadflow builds from the work items on this branch'} — expected on '${head.stdout}': it is written on '${main}' only, once this work merges`);
|
|
2430
|
+
check(checks, 'index', 'index', 'ok', `${INDEX_FILE} ${fresh.state === 'missing' ? 'is not built yet' : 'is not what this yadflow builds from the work items on this branch'} — expected on '${head.stdout}': it is written on '${forTerminal(main)}' only, once this work merges`);
|
|
2073
2431
|
return;
|
|
2074
2432
|
}
|
|
2075
2433
|
}
|
|
@@ -2110,6 +2468,8 @@ export function collectDoctor(root, { headCount = null } = {}) {
|
|
|
2110
2468
|
stepStateChecks(checks, root);
|
|
2111
2469
|
ownerChecks(checks, root);
|
|
2112
2470
|
ownerGuardChecks(checks, root);
|
|
2471
|
+
renamedChecks(checks, root);
|
|
2472
|
+
productProfileChecks(checks, root);
|
|
2113
2473
|
phaseChecks(checks, root);
|
|
2114
2474
|
laneChecks(checks, root);
|
|
2115
2475
|
epicChecks(checks, root);
|
|
@@ -2130,13 +2490,18 @@ export async function runDoctor(root, { json = false, headCount = null } = {}) {
|
|
|
2130
2490
|
} else {
|
|
2131
2491
|
log(c.bold(`\nyad doctor ${c.dim('v' + VERSION)}`));
|
|
2132
2492
|
let section = '';
|
|
2493
|
+
// E48: the `protection` section ends with where to read how to set protection up, once, when any of
|
|
2494
|
+
// its lines warns. Text only — `--json` keeps its shape.
|
|
2495
|
+
const protectionWarns = checks.some((x) => x.section === 'protection' && x.status === 'warn');
|
|
2496
|
+
const guide = () => { if (section === 'protection' && protectionWarns) hand(`how to set branch protection up for yad: ${PROTECTION_GUIDE_URL}`); };
|
|
2133
2497
|
for (const x of checks) {
|
|
2134
|
-
if (x.section !== section) { section = x.section; log(`\n ${c.bold(section)}`); }
|
|
2498
|
+
if (x.section !== section) { guide(); section = x.section; log(`\n ${c.bold(section)}`); }
|
|
2135
2499
|
({ ok, warn, fail })[x.status](x.message);
|
|
2136
2500
|
// A check may ask for its hint whatever its level (`alwaysHint`): an E70 `protection` line is `ok` in
|
|
2137
2501
|
// solo mode even when the platform could not be read, and its hint is the fix ("run `gh auth login` …").
|
|
2138
2502
|
if (x.hint && (x.status !== 'ok' || x.alwaysHint)) hand(x.hint);
|
|
2139
2503
|
}
|
|
2504
|
+
guide();
|
|
2140
2505
|
log('');
|
|
2141
2506
|
if (failed.length) fail(`${failed.length} problem(s) found`);
|
|
2142
2507
|
else if (warned.length) info(`healthy with ${warned.length} warning(s)`);
|
|
@@ -2146,6 +2511,20 @@ export async function runDoctor(root, { json = false, headCount = null } = {}) {
|
|
|
2146
2511
|
return { ok: failed.length === 0, failed: failed.length, warned: warned.length, checks };
|
|
2147
2512
|
}
|
|
2148
2513
|
|
|
2514
|
+
// Line endings on Windows (E113). With `core.autocrlf=true` — the Git for Windows default — git checks
|
|
2515
|
+
// every text file out with CRLF. The managed files cope (they are compared line endings aside), but an
|
|
2516
|
+
// artifact's approval is bound to its exact bytes: a review approved on LF bytes reads as stale in a CRLF
|
|
2517
|
+
// checkout, and an approval recorded there binds bytes CI never sees. So on Windows it is named, with the
|
|
2518
|
+
// one setting that avoids it. Silent everywhere else, and when git cannot be asked.
|
|
2519
|
+
export function lineEndingChecks(root, checks, { platform = process.platform, runner = run } = {}) {
|
|
2520
|
+
if (platform !== 'win32') return;
|
|
2521
|
+
const r = runner('git', ['-C', root, 'config', '--get', 'core.autocrlf']);
|
|
2522
|
+
if (!r.ok || r.stdout.trim().toLowerCase() !== 'true') return;
|
|
2523
|
+
check(checks, 'line-endings', 'project', 'warn', 'git checks text out here with CRLF line endings (core.autocrlf=true) — an approval is bound to an artifact\'s exact bytes, so reviews approved elsewhere read as stale here',
|
|
2524
|
+
// Three separate commands, not joined with `&&`: Windows PowerShell 5.1, the default shell there, has no `&&`.
|
|
2525
|
+
'commit or stash your work first, then run `git config core.autocrlf input`, then `git rm --cached -r . -q`, then `git reset --hard` — the files come back with LF. `git config --global core.autocrlf input` does the same for every future clone');
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2149
2528
|
// The capture half of `yad doctor` (E43). Three facts: whether capture is on, whether every IDE target with
|
|
2150
2529
|
// a post-edit protocol runs it, and which of the team's OWN GitHub workflows start on a push to any branch —
|
|
2151
2530
|
// those run on every `yad/wip/*` push. yadflow's own templates exclude the branches; a team's file is theirs,
|
|
@@ -2156,7 +2535,7 @@ export function captureChecks(root, checks, cfg) {
|
|
|
2156
2535
|
return;
|
|
2157
2536
|
}
|
|
2158
2537
|
const unwired = [];
|
|
2159
|
-
if (!hookScriptReady(root,
|
|
2538
|
+
for (const w of CAPTURE_WIRING) if (!hookScriptReady(root, w.dest)) unwired.push(w.dest);
|
|
2160
2539
|
const targets = ideTargetsFor(root);
|
|
2161
2540
|
const safe = new Set(safeIdeTargetStateFor(root, targets).targets);
|
|
2162
2541
|
const noProtocol = [];
|
|
@@ -2180,7 +2559,7 @@ export function captureChecks(root, checks, cfg) {
|
|
|
2180
2559
|
// could never be cleared by doing what it asks, so it is said as a fact.
|
|
2181
2560
|
check(checks, 'capture', 'project', 'ok', `wip capture is by hand here — no IDE target (${noProtocol.join(', ')}) has a post-edit hook yad wires; run \`yad capture\` after editing`);
|
|
2182
2561
|
} else {
|
|
2183
|
-
check(checks, 'capture', 'project', 'ok', `wip capture wired (
|
|
2562
|
+
check(checks, 'capture', 'project', 'ok', `wip capture wired (${CAPTURE_WIRING.map((w) => w.dest).join(', ')})${byHand}`);
|
|
2184
2563
|
}
|
|
2185
2564
|
const noisy = pushOnEveryBranch(root);
|
|
2186
2565
|
if (noisy.length) {
|
|
@@ -2274,3 +2653,40 @@ export function pushOnEveryBranch(root) {
|
|
|
2274
2653
|
}
|
|
2275
2654
|
return out;
|
|
2276
2655
|
}
|
|
2656
|
+
|
|
2657
|
+
// The workspace file (E80): lets yad find this Product from its code repos. Only for a Product whose
|
|
2658
|
+
// registered repos live beside it — per machine, so a teammate who cloned by hand has none.
|
|
2659
|
+
function workspaceFileCheck(root, checks) {
|
|
2660
|
+
if (!hasSiblingRepo(root)) return;
|
|
2661
|
+
const st = workspaceFileState(root);
|
|
2662
|
+
if (st === 'ok') check(checks, 'workspace-file', 'project', 'ok', `${WORKSPACE_FILE} beside the Product — yad finds it from the code repos`);
|
|
2663
|
+
else if (st === 'missing') check(checks, 'workspace-file', 'project', 'warn', `no ${WORKSPACE_FILE} beside the Product — run from a code repo, yad does not find the Product`, 'run `yad check --fix` (it writes the file on this machine only)');
|
|
2664
|
+
else check(checks, 'workspace-file', 'project', 'warn', `${WORKSPACE_FILE} not usable: ${st.replace(/^other: /, '')}`, 'run yad from the Product, or pass --dir <the Product>');
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
// The person's half of the ledger guard (E48): this clone's git pre-commit hook. Per clone, because git
|
|
2668
|
+
// never commits `.git/hooks` — so a teammate who pulled a verified Product has none until their own
|
|
2669
|
+
// `yad check --fix`, and this line is how they find out.
|
|
2670
|
+
function gitHookCheck(root, checks) {
|
|
2671
|
+
const st = gitHookState(root);
|
|
2672
|
+
if (!st.applies) return;
|
|
2673
|
+
const where = path.relative(root, st.file).split(path.sep).join('/');
|
|
2674
|
+
if (st.state === 'hooks-path' || st.state === 'foreign') {
|
|
2675
|
+
// yad writes neither: a folder core.hooksPath names is usually committed and owned by a tool, and a
|
|
2676
|
+
// hook without yad's marker line is the team's. The person adds one line instead.
|
|
2677
|
+
const why = st.state === 'hooks-path' ? `core.hooksPath is set (${st.hooksPath}), so hooks are run from there` : `${where} is not yad's, so yad left it alone`;
|
|
2678
|
+
const into = st.state === 'hooks-path' ? st.editFile : `${where} (and again if a tool rewrites it)`;
|
|
2679
|
+
check(checks, 'git-hook', 'project', 'warn', `git pre-commit ledger guard not installed: ${why}`, `add this line to ${into}: ${st.line}`);
|
|
2680
|
+
} else if (st.state === 'missing') {
|
|
2681
|
+
check(checks, 'git-hook', 'project', 'warn', 'git pre-commit ledger guard not installed in this clone',
|
|
2682
|
+
'run `yad check --fix` — until then a hand `git commit` that changes the CI-owned ledger is refused only by CI, after the push');
|
|
2683
|
+
} else if (st.state === 'outdated' && st.why === 'not-executable') {
|
|
2684
|
+
check(checks, 'git-hook', 'project', 'warn', `git pre-commit ledger guard installed but not executable (${where}) — git skips it`,
|
|
2685
|
+
'run `yad check --fix`, which restores the execute bit');
|
|
2686
|
+
} else if (st.state === 'outdated') {
|
|
2687
|
+
check(checks, 'git-hook', 'project', 'warn', `git pre-commit ledger guard out of date (${where})`,
|
|
2688
|
+
'run `yad check --fix` — it saves the current file beside it before replacing it');
|
|
2689
|
+
} else {
|
|
2690
|
+
check(checks, 'git-hook', 'project', 'ok', `git pre-commit ledger guard installed in this clone (${where})`);
|
|
2691
|
+
}
|
|
2692
|
+
}
|