yadflow 3.18.1 → 4.0.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +355 -0
- package/README.md +79 -26
- package/bin/commands.mjs +41 -0
- package/bin/yad.mjs +437 -124
- package/cli/artifact-status.mjs +34 -15
- package/cli/checkpoint.mjs +69 -49
- package/cli/codeowners-command.mjs +170 -0
- package/cli/codeowners.mjs +397 -0
- package/cli/commit.mjs +13 -9
- package/cli/companion.mjs +2 -2
- package/cli/dial.mjs +183 -0
- package/cli/docs.mjs +88 -32
- package/cli/doctor.mjs +1472 -97
- package/cli/epic-state.mjs +3478 -232
- package/cli/epic.mjs +506 -0
- package/cli/errors.mjs +4 -1
- package/cli/gate.mjs +1002 -209
- package/cli/history.mjs +556 -0
- package/cli/hook.mjs +266 -55
- package/cli/hubcommit.mjs +6 -17
- package/cli/index-command.mjs +87 -0
- package/cli/ledger.mjs +57 -7
- package/cli/lib.mjs +184 -18
- package/cli/manifest.mjs +367 -56
- package/cli/migrate.mjs +726 -53
- package/cli/mode.mjs +170 -0
- package/cli/next.mjs +349 -90
- package/cli/openpr.mjs +191 -39
- package/cli/people.mjs +654 -0
- package/cli/plan.mjs +417 -132
- package/cli/platform.mjs +110 -129
- package/cli/product-index.mjs +287 -0
- package/cli/protection.mjs +706 -0
- package/cli/reconcile.mjs +38 -12
- package/cli/repo-publish.mjs +24 -26
- package/cli/repo.mjs +23 -14
- package/cli/report.mjs +21 -15
- package/cli/review.mjs +24 -27
- package/cli/riskmap-command.mjs +289 -0
- package/cli/riskmap.mjs +373 -0
- package/cli/setup.mjs +139 -287
- package/cli/ship.mjs +7 -6
- package/cli/skill.mjs +180 -0
- package/cli/skip.mjs +211 -30
- package/cli/thread.mjs +42 -17
- package/cli/tidy.mjs +20 -20
- package/cli/update-commit.mjs +22 -22
- package/cli/usage.mjs +115 -109
- package/package.json +3 -3
- package/skills/sdlc/config.yaml +166 -87
- package/skills/sdlc/module-help.csv +35 -35
- package/skills/yad-analysis/SKILL.md +125 -65
- package/skills/yad-architecture/SKILL.md +34 -23
- package/skills/yad-architecture/references/contract-format.md +10 -8
- package/skills/yad-backfill/SKILL.md +14 -8
- package/skills/yad-backfill/references/backfill.md +1 -1
- package/skills/yad-change/SKILL.md +127 -52
- package/skills/yad-change/references/triage.md +42 -28
- package/skills/yad-checks/SKILL.md +89 -45
- package/skills/yad-checks/references/check-gates.md +315 -92
- package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
- package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
- package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
- package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
- package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
- package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
- package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
- package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
- package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
- package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
- package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
- package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
- package/skills/yad-commit/SKILL.md +6 -6
- package/skills/yad-connect-design/SKILL.md +6 -6
- package/skills/yad-connect-design/references/design-context.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +2 -2
- package/skills/yad-connect-docs/SKILL.md +12 -12
- package/skills/yad-connect-docs/references/docs-registry.md +1 -1
- package/skills/yad-connect-learning/SKILL.md +5 -5
- package/skills/yad-connect-learning/references/learning-registry.md +2 -2
- package/skills/yad-connect-repos/SKILL.md +92 -54
- package/skills/yad-connect-repos/references/code-context.md +6 -6
- package/skills/yad-connect-repos/references/hub-config.md +68 -58
- package/skills/yad-connect-repos/references/repos-registry.md +10 -9
- package/skills/yad-connect-repos/references/risk-map.md +81 -0
- package/skills/yad-connect-testing/SKILL.md +6 -6
- package/skills/yad-connect-testing/references/testing-context.md +3 -4
- package/skills/yad-connect-testing/references/testing-registry.md +2 -2
- package/skills/yad-defects/SKILL.md +8 -8
- package/skills/yad-discovery/SKILL.md +130 -94
- package/skills/yad-discovery/references/discovery-schema.md +23 -7
- package/skills/yad-discovery/references/foundation-schema.md +374 -0
- package/skills/yad-docs/SKILL.md +16 -11
- package/skills/yad-docs/references/data-mapping.md +9 -7
- package/skills/yad-docs/templates/app/package-lock.json +3 -3
- package/skills/yad-docs-overview/SKILL.md +32 -17
- package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
- package/skills/yad-docs-sync/SKILL.md +10 -5
- package/skills/yad-docs-sync/references/staleness.md +8 -7
- package/skills/yad-engineer-review/SKILL.md +88 -24
- package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
- package/skills/yad-epic/SKILL.md +178 -100
- package/skills/yad-epic/references/state-schema.md +626 -117
- package/skills/yad-hub-bridge/SKILL.md +66 -48
- package/skills/yad-hub-bridge/references/bridge.md +110 -83
- package/skills/yad-hub-bridge/references/login-roster.md +163 -70
- package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
- package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
- package/skills/yad-implement/SKILL.md +29 -15
- package/skills/yad-implement/references/implement-conventions.md +2 -2
- package/skills/yad-learn/SKILL.md +9 -9
- package/skills/yad-learn/references/learning-state.md +2 -2
- package/skills/yad-open-pr/SKILL.md +64 -29
- package/skills/yad-pair-review/SKILL.md +18 -16
- package/skills/yad-pair-review/references/session-state.md +4 -4
- package/skills/yad-pr-template/SKILL.md +48 -27
- package/skills/yad-pr-template/references/risk-routing.md +97 -24
- package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
- package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
- package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
- package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
- package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
- package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
- package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
- package/skills/yad-reconcile/SKILL.md +3 -3
- package/skills/yad-report/SKILL.md +5 -5
- package/skills/yad-review-companion/SKILL.md +12 -9
- package/skills/yad-review-gate/SKILL.md +198 -79
- package/skills/yad-review-gate/references/gating.md +230 -54
- package/skills/yad-run/SKILL.md +86 -56
- package/skills/yad-run/references/run-loop.md +67 -45
- package/skills/yad-ship/SKILL.md +18 -14
- package/skills/yad-spec/SKILL.md +31 -17
- package/skills/yad-spec/references/spec-handoff.md +17 -5
- package/skills/yad-status/SKILL.md +114 -56
- package/skills/yad-stories/SKILL.md +42 -27
- package/skills/yad-stories/references/story-schema.md +10 -9
- package/skills/yad-stub/SKILL.md +59 -48
- package/skills/yad-sync-repos/SKILL.md +3 -3
- package/skills/yad-test-cases/SKILL.md +37 -30
- package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
- package/skills/yad-timeline/SKILL.md +8 -7
- package/skills/yad-ui/SKILL.md +46 -25
- package/cli/roster.mjs +0 -164
- package/skills/sdlc/install.sh +0 -68
package/cli/doctor.mjs
CHANGED
|
@@ -5,22 +5,29 @@
|
|
|
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, readJSON, readJSONStrict } from './lib.mjs';
|
|
9
|
-
import { VERSION, PROJECT_FILES, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS,
|
|
10
|
-
import { mergeHookSettings, hookMatcherFires, ideTargetsFor } from './plan.mjs';
|
|
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, isVerifiedLedger , productConfigPath, ADVANCE_FROM_AUTOMATION, DRIVER_FROM_ASSISTANCE } from './manifest.mjs';
|
|
10
|
+
import { mergeHookSettings, hookMatcherFires, ideTargetsFor, safeIdeTargetStateFor, hookScriptReady, miswiredGuardCommand } from './plan.mjs';
|
|
11
11
|
import { planMigration } from './migrate.mjs';
|
|
12
|
-
import { loadLedger, epicRoot, isValidEpicId, epicLineage, resolveThread, stateInvariants, contractSurfaceHash,
|
|
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
13
|
import { loadDebt } from './thread.mjs';
|
|
14
|
+
import { readShips } from './ledger.mjs';
|
|
14
15
|
import { gitHead, insideWorkspace } from './setup.mjs';
|
|
15
|
-
import { cliFor,
|
|
16
|
+
import { cliFor, hostFromGitUrl, ambiguousLegacyNames } from './platform.mjs';
|
|
17
|
+
import { legacyLogins, stampLegacyLogins } from './gate.mjs';
|
|
18
|
+
import { checkRepo } from './riskmap-command.mjs';
|
|
19
|
+
import { checkCodeowners, codeownersFindings } from './codeowners-command.mjs';
|
|
20
|
+
import { RISK_MAP_FILE } from './riskmap.mjs';
|
|
21
|
+
import { readProtection, protectionLine, protectionJSON, hideAddresses } from './protection.mjs';
|
|
22
|
+
import { soloTeamHint, TEAM_CMD } from './people.mjs';
|
|
23
|
+
import { indexFreshness, INDEX_FILE } from './product-index.mjs';
|
|
24
|
+
import { productGit, resolveDefaultBranch } from './hubcommit.mjs';
|
|
16
25
|
|
|
17
26
|
const MIN_NODE = 18;
|
|
18
27
|
|
|
19
28
|
// Solo mode (a lone developer): approval waived, merge + resolved threads still gate. Persisted in
|
|
20
29
|
// hub.json. Mirrors gate.mjs / next.mjs.
|
|
21
30
|
const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
|
|
22
|
-
// owner/repo slug from a git url (https or ssh), for the branch-protection probe.
|
|
23
|
-
const repoSlug = (url) => ((url || '').match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/) || [])[1] || null;
|
|
24
31
|
// Is an already-resolved path nested under the project root? Repo paths are contained to the WORKSPACE
|
|
25
32
|
// (the root's parent, see setup.insideWorkspace), so a registered sibling resolves outside the root —
|
|
26
33
|
// which is what distinguishes "absent because it lives elsewhere" from "absent because it is broken".
|
|
@@ -55,11 +62,12 @@ export function envChecks(checks) {
|
|
|
55
62
|
}
|
|
56
63
|
}
|
|
57
64
|
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
// `headCount` is a count of people the caller already read (E74); the CLI passes none, a test passes one.
|
|
66
|
+
export function projectChecks(checks, root, { headCount = null } = {}) {
|
|
67
|
+
const productPath = productConfigPath(root);
|
|
60
68
|
const regPath = path.join(root, PROJECT_FILES.reposRegistry);
|
|
61
69
|
const verPath = path.join(root, PROJECT_FILES.version);
|
|
62
|
-
if (!exists(
|
|
70
|
+
if (!exists(productPath) && !exists(regPath) && !exists(verPath)) {
|
|
63
71
|
check(checks, 'project', 'project', 'warn', 'no yad project here (.sdlc/ not initialised)', 'run `yad setup` to start one — environment checks above still apply');
|
|
64
72
|
return null;
|
|
65
73
|
}
|
|
@@ -67,7 +75,7 @@ export function projectChecks(checks, root) {
|
|
|
67
75
|
// version stamp
|
|
68
76
|
const ver = readJSON(verPath, null);
|
|
69
77
|
if (!ver) check(checks, 'cli-version', 'project', 'warn', `${PROJECT_FILES.version} missing or unreadable`, 'run `yad check --fix`');
|
|
70
|
-
// The stamp is not only cosmetic: in
|
|
78
|
+
// The stamp is not only cosmetic: in verified mode the wired gate-sync job resolves the yadflow it
|
|
71
79
|
// RUNS from it — unless hub.json pins `gate_sync_version`, a YAD_VERSION variable overrides, or the
|
|
72
80
|
// stamp is not an exact release of the current major (then the job skips it and floats). So a stale
|
|
73
81
|
// stamp can mean CI is running an old gate; say so, or the warning reads as bookkeeping.
|
|
@@ -76,12 +84,12 @@ export function projectChecks(checks, root) {
|
|
|
76
84
|
|
|
77
85
|
// hub.json: parse + shape
|
|
78
86
|
let hub = null;
|
|
79
|
-
if (!exists(
|
|
80
|
-
check(checks, 'hub', 'project', 'warn', `${PROJECT_FILES.hubConfig} absent —
|
|
87
|
+
if (!exists(productPath)) {
|
|
88
|
+
check(checks, 'hub', 'project', 'warn', `${PROJECT_FILES.hubConfig} absent — local gate`, 'run `yad setup` to configure a platform');
|
|
81
89
|
} else {
|
|
82
90
|
let hubBroken = false;
|
|
83
91
|
try {
|
|
84
|
-
hub = readJSONStrict(
|
|
92
|
+
hub = readJSONStrict(productPath, null);
|
|
85
93
|
} catch (e) {
|
|
86
94
|
hubBroken = true;
|
|
87
95
|
check(checks, 'hub', 'project', 'fail', `${PROJECT_FILES.hubConfig} does not parse [${e.code || 'YAD-STATE-001'}]`, e.hint || 'fix the JSON or restore it from git');
|
|
@@ -89,90 +97,172 @@ export function projectChecks(checks, root) {
|
|
|
89
97
|
if (hubBroken) { /* reported above */ }
|
|
90
98
|
else if (typeof hub !== 'object' || Array.isArray(hub) || hub === null) check(checks, 'hub', 'project', 'fail', `${PROJECT_FILES.hubConfig} has the wrong shape [YAD-STATE-002]`, 'expected a JSON object');
|
|
91
99
|
else if (![null, undefined, 'github', 'gitlab'].includes(hub.platform)) check(checks, 'hub', 'project', 'fail', `${PROJECT_FILES.hubConfig}: unknown platform '${hub.platform}' [YAD-CFG-001]`, 'expected github, gitlab, or null');
|
|
92
|
-
// Mirror gate.mjs's roster shape check so doctor never reports "ok" on a hub the gate would reject.
|
|
93
|
-
else if (hub.roster !== undefined && !Array.isArray(hub.roster)) check(checks, 'hub', 'project', 'fail', `${PROJECT_FILES.hubConfig}: \`roster\` must be an array [YAD-STATE-002]`, 'fix the file or re-run `yad setup`');
|
|
94
100
|
else {
|
|
95
|
-
check(checks, 'hub', 'project', 'ok', `hub: ${hub.platform || '
|
|
96
|
-
|
|
101
|
+
check(checks, 'hub', 'project', 'ok', `hub: ${hub.platform || 'local'}`);
|
|
102
|
+
// E62 removed the roster. A list an older release wrote is kept on disk and decides nothing — its
|
|
103
|
+
// name → login pairs only let the first sync recognise older approvals (`legacyLogins`) — so say so
|
|
104
|
+
// — a team that still edits it would otherwise believe it decides something. An empty list (what a
|
|
105
|
+
// solo setup used to write) says nothing about people and is left quiet.
|
|
106
|
+
const r = hub.roster;
|
|
107
|
+
const listed = Array.isArray(r) ? r.length > 0 : (r && typeof r === 'object' ? Object.keys(r).length > 0 : !!r);
|
|
108
|
+
if (listed) {
|
|
109
|
+
// When it can go (E64): once every older record it can place names the login. Counted with the same
|
|
110
|
+
// stamp the gate writes, so this says exactly what the next gate write would still change.
|
|
111
|
+
let waiting = 0;
|
|
112
|
+
let unplaced = 0;
|
|
113
|
+
const waitingIn = [];
|
|
114
|
+
const aliases = legacyLogins(hub);
|
|
115
|
+
const clashed = ambiguousLegacyNames(hub);
|
|
116
|
+
for (const e of epicIds(root)) {
|
|
117
|
+
try {
|
|
118
|
+
const led = loadLedger(epicRoot(root, e));
|
|
119
|
+
const st = stampLegacyLogins({ approvals: led.approvals, comments: led.comments }, { aliases, clashed });
|
|
120
|
+
if (st.stamped) { waiting += st.stamped; waitingIn.push(e); }
|
|
121
|
+
unplaced += st.unplaced;
|
|
122
|
+
} catch { /* an unreadable ledger is reported by its own check */ }
|
|
123
|
+
}
|
|
124
|
+
const when = !hub.platform
|
|
125
|
+
? 'nothing reads it on a Product with no platform — delete the `roster` key'
|
|
126
|
+
: waiting
|
|
127
|
+
? `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(hub) ? 'CI\'s run on the next merged review' : '`yad gate sync <epic>`'} — then delete the \`roster\` key`
|
|
128
|
+
: unplaced
|
|
129
|
+
? `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`
|
|
130
|
+
: 'no older record needs it any more — delete the `roster` key';
|
|
131
|
+
check(checks, 'people:roster-unused', 'project', 'warn',
|
|
132
|
+
`${PROJECT_FILES.hubConfig} has a \`roster\` that no longer decides who approves — a gate needs one approval (not the author's own) from anyone with access`,
|
|
133
|
+
when);
|
|
134
|
+
// Those pairs are exact only when the roster is: a name two logins share cannot say which person an
|
|
135
|
+
// older record means. Named here, because on an open review that approval may then have to be given
|
|
136
|
+
// again. (A name equal to ANOTHER entry's login is exact — a record under a name was written from
|
|
137
|
+
// that entry, and an unlisted login was written `unverified` — so it is not warned about.) The hint
|
|
138
|
+
// does NOT say "rename it": renaming one entry hands every older record under the name to whoever
|
|
139
|
+
// keeps it, which can put someone's approval of old content on a person who never gave it.
|
|
140
|
+
const unclear = [...ambiguousLegacyNames(hub).keys()];
|
|
141
|
+
if (unclear.length) {
|
|
142
|
+
check(checks, 'people:roster-ambiguous', 'project', 'warn',
|
|
143
|
+
`${PROJECT_FILES.hubConfig} roster name(s) ${unclear.join(', ')} are given to more than one login — an older approval under that name cannot be recognised by name`,
|
|
144
|
+
'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');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (isSolo(hub)) {
|
|
148
|
+
check(checks, 'solo', 'project', 'ok', 'mode: solo — approval waived; the PR merge + resolved threads gate the step');
|
|
149
|
+
// E74: suggest team mode when the count shows more than one person may work here. Counted ONLY
|
|
150
|
+
// in solo mode, because the count walks the git history of every connected repo; team mode
|
|
151
|
+
// pays nothing. A suggestion, never a switch — so a warning, never a failure. An unknown count
|
|
152
|
+
// is a warning too (the user's choice, 2026-09-22): doctor is where a count that cannot be read
|
|
153
|
+
// is fixed, and a ✓ beside "could not be counted" would read as healthy.
|
|
154
|
+
const hint = soloTeamHint(root, hub, { solo: true, headCount });
|
|
155
|
+
if (hint.line && hint.known) {
|
|
156
|
+
check(checks, 'mode:suggest-team', 'project', 'warn', hint.line,
|
|
157
|
+
`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`);
|
|
158
|
+
} else if (hint.line) {
|
|
159
|
+
check(checks, 'mode:suggest-team', 'project', 'warn', hint.line,
|
|
160
|
+
'the reason in brackets names what could not be read — clone the missing repo, unshallow it, or fix the file — then run `yad doctor` again');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// E10 writes `mode: solo|team` beside `solo`, and `solo` is still the one read. A hand edit can leave
|
|
164
|
+
// the two saying different things; name that, and say which one the gates follow. Silent on a file
|
|
165
|
+
// with no `mode`, which is every Product set up before E10 (the frozen golden one included).
|
|
166
|
+
if (hub.mode !== undefined) {
|
|
167
|
+
const acting = isSolo(hub) ? 'solo' : 'team';
|
|
168
|
+
if (hub.mode !== acting) {
|
|
169
|
+
check(checks, 'mode:disagree', 'project', 'warn',
|
|
170
|
+
`${PROJECT_FILES.hubConfig} says mode: ${JSON.stringify(hub.mode)}, but solo mode is ${acting === 'solo' ? 'on' : 'off'} — the old \`solo\` flag is the one the gates read`,
|
|
171
|
+
['solo', 'team'].includes(hub.mode)
|
|
172
|
+
? `\`yad mode ${acting}\` keeps what the gates do now; \`yad mode ${hub.mode === 'solo' ? 'solo --reason "<why>"' : 'team'}\` makes the gates follow \`mode\``
|
|
173
|
+
: `\`yad mode ${acting}\` writes a mode the gates recognise`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
97
176
|
// platform CLI + auth (best-effort; auth probing is the user's own session)
|
|
98
177
|
const cli = cliFor(hub.platform);
|
|
99
178
|
if (cli) {
|
|
100
179
|
// git_url is required whenever a platform is set — doctor needs it to scope the auth probe
|
|
101
|
-
// and the
|
|
180
|
+
// and the verified ledger/PR flow needs it to open PRs. Warn on its absence directly (not on the
|
|
102
181
|
// resolved host), so it fires even when an origin remote can substitute: the field itself
|
|
103
182
|
// is required regardless.
|
|
104
183
|
if (!hostFromGitUrl(hub.git_url)) {
|
|
105
184
|
check(checks, 'hub-git-url', 'project', 'warn',
|
|
106
185
|
`${PROJECT_FILES.hubConfig} sets platform '${hub.platform}' but has no git_url [YAD-CFG-005]`,
|
|
107
|
-
'add git_url to hub.json (or re-run `yad setup`) — auth/PR checks need the
|
|
186
|
+
'add git_url to hub.json (or re-run `yad setup`) — auth/PR checks need the Product host');
|
|
108
187
|
}
|
|
109
|
-
// Scope the auth probe to the
|
|
188
|
+
// Scope the auth probe to the Product's own host (derived from git_url, falling back to the
|
|
110
189
|
// origin remote). `${cli} auth status` without --hostname exits non-zero when ANY configured
|
|
111
190
|
// instance fails, so an unrelated stale login (e.g. a dead gitlab.com token) would falsely
|
|
112
|
-
// flag a working self-hosted
|
|
191
|
+
// flag a working self-hosted Product — so we SKIP the probe entirely when no host resolves
|
|
113
192
|
// rather than run the flaky unscoped form.
|
|
114
193
|
const host = hostFromGitUrl(hub.git_url)
|
|
115
194
|
|| hostFromGitUrl(run('git', ['remote', 'get-url', 'origin'], { cwd: root }).stdout);
|
|
116
|
-
if (!has(cli)) check(checks, 'platform-cli', 'project', 'warn', `${cli} not found on PATH [YAD-ENV-002]`, `install ${cli} — the gate degrades to
|
|
195
|
+
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`);
|
|
117
196
|
else if (!host) check(checks, 'platform-cli', 'project', 'warn', 'auth check skipped — hub host unknown (no git_url / origin)', 'add git_url to hub.json so the auth probe can target the right host');
|
|
118
197
|
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}\``);
|
|
119
198
|
else {
|
|
120
199
|
check(checks, 'platform-cli', 'project', 'ok', `${cli} present and authenticated`);
|
|
121
|
-
// Re-validate each roster login against the hub (warn-only). Skips when a login is already
|
|
122
|
-
// flagged unverified by setup; reports any that no longer resolve.
|
|
123
|
-
const bad = [];
|
|
124
|
-
for (const e of hub.roster || []) {
|
|
125
|
-
const v = validateLogin(hub.platform, e.login);
|
|
126
|
-
if (v.checked && !v.exists) bad.push(e.login);
|
|
127
|
-
}
|
|
128
|
-
if (bad.length) check(checks, 'roster', 'project', 'warn', `roster login(s) not found on ${hub.platform}: ${bad.join(', ')}`, 'fix the login or re-run `yad setup` (they cannot satisfy a gate)');
|
|
129
|
-
else check(checks, 'roster', 'project', 'ok', `roster: ${(hub.roster || []).length} member(s) validated on ${hub.platform}`);
|
|
130
200
|
// GitLab API reachability: the gate reads MR state via `glab api …` (approvals, discussions).
|
|
131
201
|
// A present+authenticated glab whose token lacks api scope would still break readPrGitLab, so
|
|
132
202
|
// probe a cheap api call (warn-only) to surface it before a sync silently holds the gate.
|
|
133
203
|
if (hub.platform === 'gitlab') {
|
|
134
|
-
// Scope the probe to the
|
|
204
|
+
// Scope the probe to the Product's own host (like the auth check above) so a multi-instance
|
|
135
205
|
// setup doesn't hit the wrong GitLab. `host` is guaranteed truthy here (we skip the whole
|
|
136
206
|
// auth branch when it cannot be resolved), so the probe is always host-scoped.
|
|
137
207
|
if (!run('glab', ['api', 'version', '--hostname', host]).ok) {
|
|
138
208
|
check(checks, 'gitlab-api', 'project', 'warn', `glab is authenticated but \`glab api\` failed for ${host} [YAD-ENV-002]`, 'ensure the token has `api` scope — the gate reads MR approvals/discussions via the API');
|
|
139
209
|
}
|
|
140
210
|
}
|
|
141
|
-
// Solo
|
|
142
|
-
// (
|
|
143
|
-
|
|
144
|
-
const slug = repoSlug(hub.git_url) || repoSlug(run('git', ['remote', 'get-url', 'origin'], { cwd: root }).stdout);
|
|
145
|
-
const br = hub.default_branch || 'main';
|
|
146
|
-
if (slug) {
|
|
147
|
-
const probe = run('gh', ['api', `repos/${slug}/branches/${br}/protection/required_pull_request_reviews`, '--jq', '.required_approving_review_count']);
|
|
148
|
-
if (probe.ok && Number(probe.stdout) > 0) {
|
|
149
|
-
check(checks, 'solo-branch-protection', 'project', 'warn', `solo mode but ${br} requires ${probe.stdout} approval(s) — you cannot approve your own PR, so the merge will be blocked`, `relax "Require approvals" in ${slug} branch protection for ${br}`);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
211
|
+
// Solo mode's "a required approval blocks your own merge" warning moved to the `protection`
|
|
212
|
+
// section (E70), which reads rulesets as well as classic protection, asks the Product's own host,
|
|
213
|
+
// and says "not known" when a call fails instead of staying quiet.
|
|
153
214
|
}
|
|
154
215
|
}
|
|
155
216
|
}
|
|
156
217
|
}
|
|
157
218
|
|
|
158
|
-
// The harness ledger guard (#171). Only meaningful in
|
|
219
|
+
// The harness ledger guard (#171). Only meaningful in verified mode: there the ledger is CI-owned and
|
|
159
220
|
// an agent's hand-edit is always rejected later by `ledger-guard`, so the local hook that refuses it
|
|
160
|
-
// up front should be installed.
|
|
221
|
+
// up front should be installed. With a local ledger nothing guards it, and the hand-edit the
|
|
161
222
|
// authoring skills describe is correct — nothing to report, so the check is silent rather than `ok`.
|
|
162
|
-
const hubForHooks = readJSON(
|
|
163
|
-
if (
|
|
223
|
+
const hubForHooks = readJSON(productPath, null);
|
|
224
|
+
if (isVerifiedLedger(hubForHooks)) {
|
|
164
225
|
const unwired = [];
|
|
165
226
|
const broken = [];
|
|
166
|
-
|
|
227
|
+
// PRESENT AND EXECUTABLE. A script at mode 644 has the right bytes and cannot run: the harness
|
|
228
|
+
// entry pointing at it fails, fails OPEN, and every ledger edit is permitted while this check and
|
|
229
|
+
// `yad check` both call the guard healthy. Reachable from a zip download, `cp` without `-p`, or a
|
|
230
|
+
// restrictive umask, with nobody having done anything unusual.
|
|
231
|
+
if (!hookScriptReady(root, 'hooks/ledger-guard.sh')) unwired.push('hooks/ledger-guard.sh');
|
|
167
232
|
// The SAME target list `hookActions` wires — the persisted `ideTargets`, not "does the directory
|
|
168
233
|
// exist". Keyed on the directory, a project whose targets are `['.agents']` but which also has a
|
|
169
234
|
// stray `.claude/` would be told to run `yad check --fix` forever, while that command builds no
|
|
170
235
|
// action for `.claude` and correctly reports "already up to date". Never name a remedy that
|
|
171
236
|
// cannot reach the thing being reported.
|
|
172
237
|
const unreadable = [];
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
238
|
+
// Targets with no pre-edit hook protocol at all. Collected rather than skipped: a project whose
|
|
239
|
+
// only target is `.agents` had this whole section report `ok` — "agent ledger guard wired" — while
|
|
240
|
+
// nothing local guarded anything, because the loop found no adapter and said nothing. Silence
|
|
241
|
+
// about an unguarded target reads as a guarded one (E11).
|
|
242
|
+
const noProtocol = [];
|
|
243
|
+
// Targets whose directory is unsafe (a symlink, or a file). `hookActions` filters these through
|
|
244
|
+
// `safeIdeTargetsFor` and THROWS on them, so reporting one as "not wired — run `yad check --fix`"
|
|
245
|
+
// names a remedy that aborts instead of fixing it. The rule ten lines above is the same one:
|
|
246
|
+
// never name a remedy that cannot reach the thing being reported.
|
|
247
|
+
const unsafeTargets = [];
|
|
248
|
+
const miswired = [];
|
|
249
|
+
// Read ONCE, like the settings files below — the block's own rule, and two reads can disagree.
|
|
250
|
+
const targets = ideTargetsFor(root);
|
|
251
|
+
const safe = new Set(safeIdeTargetStateFor(root, targets).targets);
|
|
252
|
+
for (const ide of targets) {
|
|
253
|
+
if (!safe.has(ide)) { unsafeTargets.push(ide); continue; }
|
|
254
|
+
const adapter = HOOK_ADAPTERS[ide];
|
|
255
|
+
if (!adapter) { noProtocol.push(ide); continue; }
|
|
256
|
+
// The SCRIPT THE ENTRY POINTS AT, which is not always the shared one. `.cursor`'s entry names
|
|
257
|
+
// `hooks/ledger-guard-cursor.sh`, and only the shared `hooks/ledger-guard.sh` was checked above
|
|
258
|
+
// — so a project whose wrapper was deleted, gitignored or lost in a partial checkout had
|
|
259
|
+
// `yad check` calling it `new` while `yad doctor` printed a green "guard wired". The two
|
|
260
|
+
// commands disagreeing about one project is the worst version of this: whichever the human
|
|
261
|
+
// believes, Cursor is invoking a command that does not exist on every single file write.
|
|
262
|
+
for (const w of adapter.wiring) {
|
|
263
|
+
if (!hookScriptReady(root, w.dest)) unwired.push(w.dest);
|
|
264
|
+
}
|
|
265
|
+
const relDest = adapter.settings;
|
|
176
266
|
const settingsPath = path.join(root, relDest);
|
|
177
267
|
// A file that exists but does not parse is its OWN report. `readJSON` returns null for both
|
|
178
268
|
// "absent" and "broken", and null merges as "not wired" — which would send the human to
|
|
@@ -187,24 +277,59 @@ export function projectChecks(checks, root) {
|
|
|
187
277
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { unreadable.push(relDest); continue; }
|
|
188
278
|
settings = parsed;
|
|
189
279
|
}
|
|
190
|
-
|
|
280
|
+
// BEFORE the merge check, which `continue`s: a miswired command reads as "ours is absent", so
|
|
281
|
+
// the scan would never run on the very file that needs it.
|
|
282
|
+
for (const entry of settings?.hooks?.[adapter.event] || []) {
|
|
283
|
+
const why = miswiredGuardCommand(entry, adapter);
|
|
284
|
+
if (why) miswired.push(`${relDest}: ${why}`);
|
|
285
|
+
}
|
|
286
|
+
if (mergeHookSettings(settings, adapter).changed) { unwired.push(relDest); continue; }
|
|
191
287
|
// Present is not the same as armed. The entry's matcher is the team's to narrow (the merge
|
|
192
288
|
// deliberately leaves it alone), but one that no longer selects any file-editing tool means
|
|
193
289
|
// nothing is intercepted — and reporting that as `ok` is how a disarmed guard passes for
|
|
194
290
|
// healthy until a ledger edit fails in CI.
|
|
195
|
-
if (!hookMatcherFires(settings)) broken.push(relDest);
|
|
291
|
+
if (!hookMatcherFires(settings, adapter)) broken.push(`${relDest} (expected \`${adapter.matcher}\`)`);
|
|
196
292
|
}
|
|
197
|
-
|
|
198
|
-
|
|
293
|
+
// One clause, appended to whichever verdict below is reached, so the unguarded targets are named
|
|
294
|
+
// on the healthy path too — which is the path they are most likely to be read on.
|
|
295
|
+
const alsoUnguarded = noProtocol.length
|
|
296
|
+
? ` — no pre-edit hook protocol on ${noProtocol.join(', ')}, so an agent using ${noProtocol.length > 1 ? 'those directories are' : 'that directory is'} guarded by CI only`
|
|
297
|
+
: '';
|
|
298
|
+
// NOTHING local guards this Product. Every target it has is one no harness can hook, so the
|
|
299
|
+
// script is installed and attached to no event anywhere. Naming that while reporting `ok` was a
|
|
300
|
+
// half-fix: `status` is the only machine-readable signal, the release check filters on `fail`,
|
|
301
|
+
// and a dashboard reading `--json` saw green on a verified project with no local guard at all.
|
|
302
|
+
// It is a warn, not a fail, because it is a real and reasonable setup — CI still fails closed.
|
|
303
|
+
const nothingWired = noProtocol.length > 0 && noProtocol.length === targets.length;
|
|
304
|
+
if (unsafeTargets.length) {
|
|
305
|
+
check(checks, 'hooks', 'project', 'warn', `agent ledger guard cannot be wired — ${unsafeTargets.join(', ')} is not a usable directory${alsoUnguarded}`,
|
|
306
|
+
'the target is a symbolic link or a file; `yad check --fix` refuses to write through it, so make it a real directory or drop it from `ideTargets` in `.sdlc/cli-version.json`');
|
|
307
|
+
} else if (unreadable.length) {
|
|
308
|
+
check(checks, 'hooks', 'project', 'warn', `agent ledger guard cannot be wired — ${unreadable.join(', ')} does not parse [YAD-STATE-001]${alsoUnguarded}`,
|
|
199
309
|
'fix the JSON by hand, then run `yad check --fix` — yad never rewrites a settings file it cannot parse, so nothing else can clear this');
|
|
310
|
+
} else if (miswired.length) {
|
|
311
|
+
// BEFORE `unwired`, though a miswired command also reads as "ours is absent". It is the more
|
|
312
|
+
// specific fact and by far the more damaging one, and the `unwired` remedy is wrong here: a
|
|
313
|
+
// `yad check --fix` would add our entry BESIDE theirs, leaving the one that refuses everything
|
|
314
|
+
// still in place, while the report said the guard was simply missing.
|
|
315
|
+
check(checks, 'hooks', 'project', 'warn', `agent ledger guard wired with a command that will refuse every write: ${miswired.join('; ')}`,
|
|
316
|
+
'this harness answers with a JSON verdict and reads an empty answer as a deny; replace that command with `hooks/ledger-guard-cursor.sh`, which `yad check --fix` installs, or have your own wrapper run `yad hook ledger-guard --format cursor` and pass its stdout through');
|
|
200
317
|
} else if (unwired.length) {
|
|
201
|
-
check(checks, 'hooks', 'project', 'warn', `agent ledger guard not wired: ${unwired.join(', ')}`,
|
|
318
|
+
check(checks, 'hooks', 'project', 'warn', `agent ledger guard not wired: ${unwired.join(', ')}${alsoUnguarded}`,
|
|
202
319
|
'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');
|
|
203
320
|
} else if (broken.length) {
|
|
204
|
-
check(checks, 'hooks', 'project', 'warn', `agent ledger guard installed but its matcher no longer selects file edits: ${broken.join(', ')}`,
|
|
205
|
-
|
|
321
|
+
check(checks, 'hooks', 'project', 'warn', `agent ledger guard installed but its matcher no longer selects file edits: ${broken.join(', ')}${alsoUnguarded}`,
|
|
322
|
+
'restore the matcher named beside each file — as it stands the hook is wired but never fires');
|
|
323
|
+
} else if (nothingWired) {
|
|
324
|
+
check(checks, 'hooks', 'project', 'warn', `agent ledger guard installed but attached to nothing: no target (${noProtocol.join(', ')}) has a pre-edit hook protocol, so nothing local guards the ledger`,
|
|
325
|
+
'add a target whose agent can refuse a write before it lands (`.claude` or `.cursor`) if you want the local half; otherwise CI is the only guard, and it only speaks at merge time');
|
|
206
326
|
} else {
|
|
207
|
-
|
|
327
|
+
// Name every script that is actually wired, not just the shared one — on a `.cursor` project the
|
|
328
|
+
// file Cursor invokes is the wrapper, and a health line that never mentions it is a health line
|
|
329
|
+
// about something else.
|
|
330
|
+
const wiredScripts = ['hooks/ledger-guard.sh',
|
|
331
|
+
...targets.flatMap((ide) => (HOOK_ADAPTERS[ide]?.wiring || []).map((w) => w.dest))];
|
|
332
|
+
check(checks, 'hooks', 'project', 'ok', `agent ledger guard wired (${[...new Set(wiredScripts)].join(', ')})${alsoUnguarded}`);
|
|
208
333
|
}
|
|
209
334
|
}
|
|
210
335
|
|
|
@@ -225,7 +350,7 @@ export function projectChecks(checks, root) {
|
|
|
225
350
|
else if (!DESIGN_TOOLS.includes(design.tool)) check(checks, 'design', 'project', 'fail', `${PROJECT_FILES.designConfig}: unknown or missing design tool '${design.tool}' [YAD-CFG-002]`, `expected one of ${DESIGN_TOOLS.join(', ')}, or none`);
|
|
226
351
|
else if (design.source && design.source !== 'unavailable') check(checks, 'design', 'project', 'ok', `design: ${design.tool} (${design.source})`);
|
|
227
352
|
else if (design.source === 'unavailable') check(checks, 'design', 'project', 'warn', `design: ${design.tool} MCP unavailable — yad-ui runs markdown-only`, 'connect the MCP, then run `yad-connect-design` (action: refresh)');
|
|
228
|
-
else check(checks, 'design', 'project', 'warn', `design: ${design.tool} recorded but the MCP is not confirmed`, 'run `yad-connect-design` in
|
|
353
|
+
else check(checks, 'design', 'project', 'warn', `design: ${design.tool} recorded but the MCP is not confirmed`, 'run `yad-connect-design` in your AI agent to detect the MCP');
|
|
229
354
|
}
|
|
230
355
|
|
|
231
356
|
// testing.json: parse + shape + tool + MCP confirmation (absent is the normal artifacts-only default —
|
|
@@ -245,7 +370,7 @@ export function projectChecks(checks, root) {
|
|
|
245
370
|
else if (!TESTING_TOOLS.includes(testing.tool)) check(checks, 'testing', 'project', 'fail', `${PROJECT_FILES.testingConfig}: unknown or missing testing tool '${testing.tool}' [YAD-CFG-003]`, `expected one of ${TESTING_TOOLS.join(', ')}, or none`);
|
|
246
371
|
else if (testing.source && testing.source !== 'unavailable') check(checks, 'testing', 'project', 'ok', `testing: ${testing.tool} (${testing.source})`);
|
|
247
372
|
else if (testing.source === 'unavailable') check(checks, 'testing', 'project', 'warn', `testing: ${testing.tool} MCP unavailable — yad-test-cases runs artifacts-only`, 'connect the MCP, then run `yad-connect-testing` (action: refresh)');
|
|
248
|
-
else check(checks, 'testing', 'project', 'warn', `testing: ${testing.tool} recorded but the MCP is not confirmed`, 'run `yad-connect-testing` in
|
|
373
|
+
else check(checks, 'testing', 'project', 'warn', `testing: ${testing.tool} recorded but the MCP is not confirmed`, 'run `yad-connect-testing` in your AI agent to detect the MCP');
|
|
249
374
|
}
|
|
250
375
|
|
|
251
376
|
// learning.json: parse + shape + tool + CLI confirmation (absent is the normal harness-native default —
|
|
@@ -266,10 +391,16 @@ export function projectChecks(checks, root) {
|
|
|
266
391
|
else if (!LEARNING_TOOLS.includes(learning.tool)) check(checks, 'learning', 'project', 'fail', `${PROJECT_FILES.learningConfig}: unknown or missing learning tool '${learning.tool}' [YAD-CFG-004]`, `expected one of ${LEARNING_TOOLS.join(', ')}, or none`);
|
|
267
392
|
else if (learning.source === 'deeptutor-cli') check(checks, 'learning', 'project', 'ok', `learning: ${learning.tool} (${learning.source})`);
|
|
268
393
|
else if (learning.source === 'harness-native') check(checks, 'learning', 'project', 'warn', `learning: ${learning.tool} CLI unavailable — yad-learn tutors harness-native`, 'install the deeptutor CLI, then run `yad-connect-learning` (action: refresh)');
|
|
269
|
-
else if (learning.source == null) check(checks, 'learning', 'project', 'warn', `learning: ${learning.tool} recorded but the CLI is not confirmed`, 'run `yad-connect-learning` in
|
|
394
|
+
else if (learning.source == null) check(checks, 'learning', 'project', 'warn', `learning: ${learning.tool} recorded but the CLI is not confirmed`, 'run `yad-connect-learning` in your AI agent to detect the CLI');
|
|
270
395
|
else check(checks, 'learning', 'project', 'fail', `${PROJECT_FILES.learningConfig}: unknown source '${learning.source}' [YAD-STATE-002]`, 'expected deeptutor-cli, harness-native, or null');
|
|
271
396
|
}
|
|
272
397
|
|
|
398
|
+
// skills.json: which skill runs which step, when the project does not want the engine's default.
|
|
399
|
+
// Called from HERE rather than from `collectDoctor` beside the other E6 code so its findings land
|
|
400
|
+
// inside the `project` block. The renderer prints a header every time the section CHANGES, so a
|
|
401
|
+
// `project` check added after the `shape` section prints the word "project" a second time.
|
|
402
|
+
skillBindingChecks(checks, root);
|
|
403
|
+
|
|
273
404
|
// repos.json: parse + every entry is a live git repo; staleness vs syncedHead
|
|
274
405
|
let registry = { repos: [] };
|
|
275
406
|
let regBroken = false;
|
|
@@ -287,13 +418,13 @@ export function projectChecks(checks, root) {
|
|
|
287
418
|
// would read as "healthy") — an entry with no path is malformed.
|
|
288
419
|
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; }
|
|
289
420
|
const repoRoot = path.resolve(root, repo.path);
|
|
290
|
-
// A registered repo may be a SIBLING of the
|
|
291
|
-
// Such a checkout is legitimately absent wherever only the
|
|
421
|
+
// A registered repo may be a SIBLING of the Product (`../backend`, the standard multi-repo layout).
|
|
422
|
+
// Such a checkout is legitimately absent wherever only the Product is checked out — Product CI, a fresh
|
|
292
423
|
// clone — so its absence is a warn, not corruption. A missing path INSIDE the project root is
|
|
293
424
|
// still a hard fail: nothing but damage explains it.
|
|
294
425
|
if (!exists(repoRoot)) {
|
|
295
426
|
if (underProjectRoot(root, repoRoot) || !isRegistrableSibling(root, repo.path)) check(checks, `repo:${repo.name}`, 'project', 'fail', `${repo.name}: path ${repo.path} does not exist [YAD-STATE-003]`, 'fix the path in repos.json or re-connect the repo');
|
|
296
|
-
else check(checks, `repo:${repo.name}`, 'project', 'warn', `${repo.name}: ${repo.path} is not present in this checkout (sibling repo, outside the
|
|
427
|
+
else check(checks, `repo:${repo.name}`, 'project', 'warn', `${repo.name}: ${repo.path} is not present in this checkout (sibling repo, outside the Product)`, 'expected when only the Product is checked out; clone it alongside the Product to work on it here');
|
|
297
428
|
continue;
|
|
298
429
|
}
|
|
299
430
|
const head = gitHead(repoRoot);
|
|
@@ -303,6 +434,40 @@ export function projectChecks(checks, root) {
|
|
|
303
434
|
else check(checks, `repo:${repo.name}`, 'project', 'ok', `${repo.name}: git repo, context fresh`);
|
|
304
435
|
}
|
|
305
436
|
if (!registry.repos.length) check(checks, 'repos', 'project', 'warn', 'no code repos registered', 'run `yad setup` to connect one');
|
|
437
|
+
// Per-repo owners went with the roster (E62). Named only where a repo actually lists someone: every
|
|
438
|
+
// repo an older setup connected carries an EMPTY `domain_owner`, which says nothing.
|
|
439
|
+
const owned = registry.repos.filter((r) => (Array.isArray(r.domain_owners) && r.domain_owners.length) || (typeof r.domain_owner === 'string' && r.domain_owner));
|
|
440
|
+
if (owned.length) {
|
|
441
|
+
check(checks, 'people:domain-owners-unused', 'project', 'warn',
|
|
442
|
+
`${PROJECT_FILES.reposRegistry} names domain owners that nothing reads any more: ${owned.map((r) => r.name).join(', ')}`,
|
|
443
|
+
'delete `domain_owner` / `domain_owners` when convenient; request reviewers on the PR itself');
|
|
444
|
+
}
|
|
445
|
+
// The verified-commits author allowlist went with the roster too (E62): the gate checks signatures
|
|
446
|
+
// only, and write access decides who can author. Name what an older release left — the free-form
|
|
447
|
+
// list in hub.json and every generated file — so nobody maintains a list that no longer protects.
|
|
448
|
+
const allowFiles = [
|
|
449
|
+
{ where: 'the Product', file: path.join(root, '.sdlc', 'verified-authors') },
|
|
450
|
+
...registry.repos.filter((r) => r.path).map((r) => ({ where: r.name, file: path.join(path.resolve(root, r.path), '.sdlc', 'verified-authors') })),
|
|
451
|
+
].filter((x) => exists(x.file)).map((x) => x.where);
|
|
452
|
+
const listedAuthors = hub && typeof hub === 'object' && Array.isArray(hub.verified_authors) && hub.verified_authors.length > 0;
|
|
453
|
+
if (allowFiles.length || listedAuthors) {
|
|
454
|
+
const what = [listedAuthors ? `\`verified_authors\` in ${PROJECT_FILES.hubConfig}` : null, allowFiles.length ? `.sdlc/verified-authors in ${allowFiles.join(', ')}` : null].filter(Boolean).join(' and ');
|
|
455
|
+
check(checks, 'people:verified-authors-unused', 'project', 'warn',
|
|
456
|
+
`${what} — the verified-commits gate no longer reads an author list; it checks signatures only`,
|
|
457
|
+
'delete them when convenient; write access to the repo decides who can author a commit');
|
|
458
|
+
}
|
|
459
|
+
// A wired `verified-commits.sh` from before E62 still ENFORCES the author list. On a verified ledger
|
|
460
|
+
// `yad check --fix` refreshes it; on a local ledger the Product's CI files are not managed, so an older
|
|
461
|
+
// copy stays and keeps failing commits from unlisted authors (E62 upgrade simulation). Named, not fixed.
|
|
462
|
+
const oldGates = [
|
|
463
|
+
{ where: 'the Product', file: path.join(root, 'checks', 'verified-commits.sh') },
|
|
464
|
+
...registry.repos.filter((r) => r.path).map((r) => ({ where: r.name, file: path.join(path.resolve(root, r.path), 'checks', 'verified-commits.sh') })),
|
|
465
|
+
].filter((x) => exists(x.file) && /SDLC_VERIFIED_AUTHORS|ALLOWLIST=/.test(fs.readFileSync(x.file, 'utf8'))).map((x) => x.where);
|
|
466
|
+
if (oldGates.length) {
|
|
467
|
+
check(checks, 'people:allowlist-gate-stale', 'project', 'warn',
|
|
468
|
+
`checks/verified-commits.sh in ${oldGates.join(', ')} is an older copy that still enforces the author list`,
|
|
469
|
+
'run `yad check --fix` (it refreshes the gate on a verified ledger and on code repos); on a local-ledger Product, copy skills/yad-checks/templates/checks/verified-commits.sh over it');
|
|
470
|
+
}
|
|
306
471
|
}
|
|
307
472
|
|
|
308
473
|
ciTagsChecks(checks, root, hub, registry);
|
|
@@ -322,7 +487,7 @@ export function ciTagsChecks(checks, root, hub, registry) {
|
|
|
322
487
|
} catch { return false; } // absent fragment is not this check's concern
|
|
323
488
|
};
|
|
324
489
|
const fragments = [];
|
|
325
|
-
if (hub?.platform === 'gitlab' && (hub
|
|
490
|
+
if (hub?.platform === 'gitlab' && isVerifiedLedger(hub)) {
|
|
326
491
|
fragments.push(
|
|
327
492
|
{ scope: 'hub', file: '.gitlab/ci/yad-gate-sync.yml', path: path.join(root, '.gitlab/ci/yad-gate-sync.yml') },
|
|
328
493
|
{ scope: 'hub', file: '.gitlab/ci/yad-verified-commits.yml', path: path.join(root, '.gitlab/ci/yad-verified-commits.yml') },
|
|
@@ -372,27 +537,27 @@ function contractLockCheck(checks, root, epic, ledger) {
|
|
|
372
537
|
const short = (h) => `${h.slice(0, 19)}…`;
|
|
373
538
|
|
|
374
539
|
if (lock.inheritedFrom || lock.ref) {
|
|
375
|
-
// The ref is repo-controlled text, so keep it inside this
|
|
540
|
+
// The ref is repo-controlled text, so keep it inside this Product's epics/ — a lock file must not be
|
|
376
541
|
// able to point the check at arbitrary JSON elsewhere on disk.
|
|
377
542
|
const epicsDir = path.join(root, 'epics');
|
|
378
543
|
const refPath = path.resolve(path.join(epicDir, '.sdlc'), lock.ref || `../../${lock.inheritedFrom}/.sdlc/contract-lock.json`);
|
|
379
544
|
if (refPath !== epicsDir && !refPath.startsWith(epicsDir + path.sep)) {
|
|
380
545
|
check(checks, id, 'epics', 'fail',
|
|
381
546
|
`${epic}: pointer-lock ref '${lock.ref}' resolves outside epics/`,
|
|
382
|
-
'a pointer-lock must reference another epic in this
|
|
547
|
+
'a pointer-lock must reference another epic in this Product — fix `ref` (`yad epic new --parent` writes ../../EP-<owner>/.sdlc/contract-lock.json, where the owner is the epic along the parent\'s line that holds the contract)');
|
|
383
548
|
return;
|
|
384
549
|
}
|
|
385
550
|
const parent = readJSON(refPath, null);
|
|
386
551
|
if (!parent || typeof parent.hash !== 'string') {
|
|
387
552
|
check(checks, id, 'epics', 'fail',
|
|
388
553
|
`${epic}: pointer-lock references ${lock.inheritedFrom || lock.ref}, whose contract-lock.json is missing or has no hash`,
|
|
389
|
-
're-
|
|
554
|
+
're-seed the change-epic (`yad epic new --parent`) so it points at the real lock of the epic that owns the contract');
|
|
390
555
|
return;
|
|
391
556
|
}
|
|
392
557
|
if (parent.hash !== stored) {
|
|
393
558
|
check(checks, id, 'epics', 'fail',
|
|
394
559
|
`${epic}: pointer-lock pins ${short(stored)} but ${lock.inheritedFrom || 'its parent'} now locks ${short(parent.hash)}`,
|
|
395
|
-
'the inherited surface was re-locked upstream — re-copy the
|
|
560
|
+
'the inherited surface was re-locked upstream — re-copy the owner\'s hash, or re-author architecture in this epic');
|
|
396
561
|
return;
|
|
397
562
|
}
|
|
398
563
|
// A pointer-lock epic has no contract.md by construction (the surface physically cannot drift).
|
|
@@ -433,9 +598,14 @@ function contractLockCheck(checks, root, epic, ledger) {
|
|
|
433
598
|
// Two findings on a review step that is already `done`, both about the approval record behind it.
|
|
434
599
|
//
|
|
435
600
|
// FAIL — it holds NO qualifying approval at all (outside solo mode). This is the state the gate
|
|
436
|
-
// exists to prevent: the step advanced without the record that justifies it.
|
|
437
|
-
//
|
|
438
|
-
// before it), so a step
|
|
601
|
+
// exists to prevent: the step advanced without the record that justifies it. It reads approvals the
|
|
602
|
+
// way `gatePredicate` does (`status === 'approved'`, with `inherited`/`skipped` steps short-circuited
|
|
603
|
+
// before it), so a step reported here is one the gate itself would refuse today. It is deliberately
|
|
604
|
+
// deliberately the WEAKER question: it asks whether any qualifying approval RECORD exists, not whether
|
|
605
|
+
// enough distinct people approved for the step's reported count (`gateRuleFor`, E7 — which holds no
|
|
606
|
+
// gate and so has nothing to audit against). Zero approvals is the one state that was never legitimate
|
|
607
|
+
// under any rule, and re-auditing a step that already passed against a number that arrived afterwards
|
|
608
|
+
// would flood a healthy project with findings nobody can act on.
|
|
439
609
|
//
|
|
440
610
|
// WARN — it holds approvals, but none still bind to the artifact as it stands. The gate is
|
|
441
611
|
// deliberately one-way — nothing pulls a chain backward once work is built on it — so the only way
|
|
@@ -446,7 +616,10 @@ function contractLockCheck(checks, root, epic, ledger) {
|
|
|
446
616
|
function staleGateCheck(checks, root, epic, ledger, { solo = false } = {}) {
|
|
447
617
|
const epicDir = epicRoot(root, epic);
|
|
448
618
|
for (const s of ledger.state.steps) {
|
|
449
|
-
|
|
619
|
+
// `stepStatus(s) === 'done'` is the whole of the old three-part condition (E38): a step that
|
|
620
|
+
// passed AND was authored here. An inherited step reads `satisfied` and a skipped one `skipped`,
|
|
621
|
+
// so both drop out by name instead of by a flag check beside the status.
|
|
622
|
+
if (s.type !== 'review+approve' || stepStatus(s) !== 'done') continue;
|
|
450
623
|
const forStep = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved');
|
|
451
624
|
// Checked BEFORE the artifact hash below: "done holding no approval" is a claim about the ledger,
|
|
452
625
|
// not about content, so it must not depend on there being something to hash. Gating it behind the
|
|
@@ -462,9 +635,9 @@ function staleGateCheck(checks, root, epic, ledger, { solo = false } = {}) {
|
|
|
462
635
|
'the step advanced without the record the gate exists to keep — re-open the review (a fresh PR/MR) and re-approve, or run `yad gate sync` if the approvals are on the PR but never reached the ledger');
|
|
463
636
|
continue;
|
|
464
637
|
}
|
|
465
|
-
const
|
|
466
|
-
if (!
|
|
467
|
-
const live = forStep.filter((a) => !a.artifactHash
|
|
638
|
+
const accepted = acceptedHashes(epicDir, s.artifact);
|
|
639
|
+
if (!accepted.length) continue; // nothing to bind to (no locked surface / incomplete set) — not a staleness claim
|
|
640
|
+
const live = forStep.filter((a) => !isStaleHash(a.artifactHash, accepted));
|
|
468
641
|
if (live.length) continue;
|
|
469
642
|
check(checks, `epic:${epic}:${s.id}:stale`, 'epics', 'warn',
|
|
470
643
|
`${epic}: ${s.id} is done, but all ${forStep.length} approval(s) are bound to an older ${s.artifact}`,
|
|
@@ -472,16 +645,32 @@ function staleGateCheck(checks, root, epic, ledger, { solo = false } = {}) {
|
|
|
472
645
|
}
|
|
473
646
|
}
|
|
474
647
|
|
|
648
|
+
// Every walker goes through `epicIds`, which lists only VALID ids — so a folder under `epics/` whose name
|
|
649
|
+
// is not one (`EP-Foo`, with a capital, which macOS happily creates) is read by nothing, and nothing
|
|
650
|
+
// said so. Name it (rule 6). Two kinds are meant to be skipped and are not named: a `*.yad-orig` backup,
|
|
651
|
+
// which `yad migrate` makes, and a dot-folder.
|
|
652
|
+
export function strayEpicDirChecks(checks, root) {
|
|
653
|
+
const dir = path.join(root, 'epics');
|
|
654
|
+
if (!exists(dir)) return;
|
|
655
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
656
|
+
if (!e.isDirectory() || e.name.startsWith('.') || e.name.endsWith(BACKUP_SUFFIX) || isValidEpicId(e.name)) continue;
|
|
657
|
+
check(checks, `epic-dir:${e.name}`, 'epics', 'warn',
|
|
658
|
+
`epics/${e.name}/ is not a valid epic id, so no command reads it`,
|
|
659
|
+
'rename it to `EP-<slug>` — lowercase letters, digits and dashes — or remove it if it is not an epic');
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
475
663
|
export function epicChecks(checks, root) {
|
|
476
|
-
|
|
477
|
-
if (!exists(epicsDir)) return;
|
|
664
|
+
strayEpicDirChecks(checks, root);
|
|
478
665
|
// Read once for the whole sweep: whether approval is waived is a project fact, not a per-epic one.
|
|
479
|
-
const solo = isSolo(readJSON(
|
|
480
|
-
|
|
481
|
-
|
|
666
|
+
const solo = isSolo(readJSON(productConfigPath(root), null));
|
|
667
|
+
// `epicIds` — every VALID epic id plus the Foundation (E75). The old listing took any directory under
|
|
668
|
+
// `epics/`, so a folder that is not an epic got an "epic not seeded" warning it could never satisfy.
|
|
669
|
+
for (const e of epicIds(root)) {
|
|
482
670
|
try {
|
|
483
671
|
const ledger = loadLedger(epicRoot(root, e));
|
|
484
|
-
if (!ledger.state) check(checks, `epic:${e}`, 'epics', 'warn', `${e}: no state.json — epic not seeded`,
|
|
672
|
+
if (!ledger.state) check(checks, `epic:${e}`, 'epics', 'warn', `${e}: no state.json — epic not seeded`,
|
|
673
|
+
e === FOUNDATION_EPIC ? `seed it with \`yad foundation new\`, or remove ${FOUNDATION_DIR}/.sdlc/` : 'author it via yad-epic, or remove the directory');
|
|
485
674
|
else {
|
|
486
675
|
check(checks, `epic:${e}`, 'epics', 'ok', `${e}: currentStep ${ledger.state.currentStep}`);
|
|
487
676
|
// Chain consistency: a passed review gate whose author step was never closed. currentStep alone
|
|
@@ -496,8 +685,8 @@ export function epicChecks(checks, root) {
|
|
|
496
685
|
// review — so an OPEN (non-done) review PR recorded here means it was opened under an older
|
|
497
686
|
// model. Merge/close it under the version that opened it before relying on the CI flow.
|
|
498
687
|
const openPr = (ledger.hubPrs || []).find((p) => {
|
|
499
|
-
const
|
|
500
|
-
return
|
|
688
|
+
const step = ledger.state.steps.find((s) => s.id === p.step);
|
|
689
|
+
return !!step && !isPassed(step);
|
|
501
690
|
});
|
|
502
691
|
if (openPr) check(checks, `epic:${e}:migration`, 'epics', 'warn',
|
|
503
692
|
`${e}: an open review PR (${openPr.artifact}${openPr.number ? ` #${openPr.number}` : ''}) is recorded on the default branch`,
|
|
@@ -511,6 +700,105 @@ export function epicChecks(checks, root) {
|
|
|
511
700
|
}
|
|
512
701
|
}
|
|
513
702
|
|
|
703
|
+
// ---- the Foundation is guarded only once the wired checks know its folder (E75) ------------------
|
|
704
|
+
// On a VERIFIED Product, CI is the only writer of the ledger, and what enforces that is
|
|
705
|
+
// `checks/ledger-guard.sh` — committed in the user's own repo and refreshed by `yad update`, which is a
|
|
706
|
+
// separate act from upgrading the CLI or running `yad migrate`. A copy from before E75 guards `epics/`
|
|
707
|
+
// only. So a Product that has a Foundation and has not refreshed its checks has a ledger nobody
|
|
708
|
+
// guards: a human can hand-edit `foundation/.sdlc/approvals.json` and the review PR goes green. The
|
|
709
|
+
// two PR gates have the same gap for a Foundation section riding a non-review branch.
|
|
710
|
+
//
|
|
711
|
+
// Rule 6 says the engine never goes quiet about what is unprotected, so this is a WARNING that names
|
|
712
|
+
// the consequence and the one command that fixes it. It never fires without a Foundation — there is
|
|
713
|
+
// nothing to guard — nor on a local ledger, where humans write the ledger by design.
|
|
714
|
+
//
|
|
715
|
+
// Detected by the ARM that does the guarding, not by the folder name anywhere in the text: a comment
|
|
716
|
+
// that merely mentions `foundation/` must not make an outdated script read as current. The wired copy
|
|
717
|
+
// is byte-for-byte a template, so each template's arm is a fixed string.
|
|
718
|
+
//
|
|
719
|
+
// The same section reports the three ways a product can have the WRONG number of product levels:
|
|
720
|
+
// foundation:two a Foundation AND an old `epics/EP-discovery/` ledger — fail. Two product levels
|
|
721
|
+
// is a bug by the roadmap's own words, and only a person knows which is real.
|
|
722
|
+
// foundation:stray an `epics/EP-foundation/` folder — fail. That id's folder is `foundation/`; this
|
|
723
|
+
// one is never read, so anything written into it is silently lost.
|
|
724
|
+
// foundation:legacy the old spelling alone. On a local ledger that is a warning with the command
|
|
725
|
+
// that converts it. On a verified one there is nothing to run — CI owns the
|
|
726
|
+
// ledger — so it is reported as fine, because a warning nobody can clear teaches
|
|
727
|
+
// people to stop reading warnings.
|
|
728
|
+
export function foundationChecks(checks, root) {
|
|
729
|
+
const hub = readJSON(productConfigPath(root), null);
|
|
730
|
+
const hasFoundation = exists(path.join(epicRoot(root, FOUNDATION_EPIC), '.sdlc', 'state.json'));
|
|
731
|
+
const hasLegacy = exists(path.join(epicRoot(root, DISCOVERY_EPIC), '.sdlc', 'state.json'));
|
|
732
|
+
if (hasFoundation && hasLegacy) {
|
|
733
|
+
check(checks, 'foundation:two', 'project', 'fail',
|
|
734
|
+
`two product levels: ${FOUNDATION_DIR}/ and epics/${DISCOVERY_EPIC}/ — a product has one Foundation`,
|
|
735
|
+
`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\``);
|
|
736
|
+
} else if (hasLegacy) {
|
|
737
|
+
if (isVerifiedLedger(hub)) {
|
|
738
|
+
// CI moves it (cli/gate.mjs `convertProductLevel`), but only once the committed checks know the
|
|
739
|
+
// Foundation — so the stale case is the one with something for a person to do.
|
|
740
|
+
const stale = staleFoundationGuards(root);
|
|
741
|
+
if (stale.length) {
|
|
742
|
+
check(checks, 'foundation:legacy', 'project', 'warn',
|
|
743
|
+
`the product level is in its old spelling (epics/${DISCOVERY_EPIC}/), and CI will not move it: ${stale.join(', ')} ${stale.length === 1 ? 'predates' : 'predate'} the Foundation`,
|
|
744
|
+
`run \`yad update\` and commit the refreshed checks — the next gate run on the default branch then moves it to ${FOUNDATION_DIR}/`);
|
|
745
|
+
} else {
|
|
746
|
+
check(checks, 'foundation:legacy', 'project', 'ok',
|
|
747
|
+
`the product level is in its old spelling (epics/${DISCOVERY_EPIC}/) — read as the Foundation; CI moves it to ${FOUNDATION_DIR}/ at its next gate run on the default branch`);
|
|
748
|
+
}
|
|
749
|
+
} else {
|
|
750
|
+
check(checks, 'foundation:legacy', 'project', 'warn',
|
|
751
|
+
`the product level is in its old spelling (epics/${DISCOVERY_EPIC}/)`,
|
|
752
|
+
`run \`yad migrate\` to preview, then \`yad migrate --apply\` — it moves to ${FOUNDATION_DIR}/ with its files and approvals kept (docs/migrations/shape-8.md)`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (exists(path.join(root, 'epics', FOUNDATION_EPIC))) {
|
|
756
|
+
check(checks, 'foundation:stray', 'project', 'fail',
|
|
757
|
+
`epics/${FOUNDATION_EPIC}/ exists, but that id's folder is ${FOUNDATION_DIR}/ — nothing ever reads this one`,
|
|
758
|
+
`move anything real into ${FOUNDATION_DIR}/, then delete epics/${FOUNDATION_EPIC}/`);
|
|
759
|
+
}
|
|
760
|
+
foundationGuardChecks(checks, root, hub);
|
|
761
|
+
if (hasFoundation) foundationSectionChecks(checks, root);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// A Foundation whose review has OPENED or PASSED while a section is still only its template (E76).
|
|
765
|
+
// While it is being authored an empty section is simply work not done yet, so nothing is said then;
|
|
766
|
+
// once reviewers are looking at it, or have approved it, an empty section is part of what they approved.
|
|
767
|
+
// A warning: the fix — write the section, re-open the review — is a person's decision.
|
|
768
|
+
//
|
|
769
|
+
// Only a review bound to `foundation/`. A Foundation converted from the old spelling keeps
|
|
770
|
+
// `artifact: "discovery/"` and its six old files (shape 8), and those were never templates of this kind.
|
|
771
|
+
// A ledger that does not load is left to the epic checks, which already report it.
|
|
772
|
+
export function foundationSectionChecks(checks, root) {
|
|
773
|
+
const dir = epicRoot(root, FOUNDATION_EPIC);
|
|
774
|
+
let state;
|
|
775
|
+
try { state = loadLedger(dir).state; } catch { return; }
|
|
776
|
+
if (!state) return; // no state.json — no Foundation to read
|
|
777
|
+
const review = state.steps.find((s) => s.id === 'foundation-review');
|
|
778
|
+
// `validateState` does not require `artifact`, and `artifactBase` throws without one: a hand-edited
|
|
779
|
+
// step missing it is `catalogueChecks`' finding to report, so this check must not crash doctor first.
|
|
780
|
+
if (!review || typeof review.artifact !== 'string' || artifactBase(review.artifact) !== 'foundation') return;
|
|
781
|
+
const status = stepStatus(review);
|
|
782
|
+
if (status !== 'in_review' && status !== 'done') return;
|
|
783
|
+
const empty = unwrittenSections(dir);
|
|
784
|
+
if (!empty.length) return;
|
|
785
|
+
check(checks, 'foundation:unwritten', 'project', 'warn',
|
|
786
|
+
`${FOUNDATION_EPIC}: ${empty.join(', ')} ${empty.length === 1 ? 'holds nothing but its' : 'hold nothing but their'} template, and its review has ${status === 'done' ? 'passed' : 'opened'}`,
|
|
787
|
+
status === 'done'
|
|
788
|
+
? 'write the section, then re-open the review (a fresh PR/MR) — the approval on record approved an empty section'
|
|
789
|
+
: 'write the section before the review is approved — the yad-discovery skill says what each section needs');
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function foundationGuardChecks(checks, root, hub) {
|
|
793
|
+
if (!exists(path.join(root, FOUNDATION_DIR, '.sdlc'))) return;
|
|
794
|
+
if (!isVerifiedLedger(hub)) return;
|
|
795
|
+
const stale = staleFoundationGuards(root);
|
|
796
|
+
if (!stale.length) return;
|
|
797
|
+
check(checks, 'foundation:guard', 'project', 'warn',
|
|
798
|
+
`the wired checks predate the Foundation: ${stale.join(', ')} ${stale.length === 1 ? 'does' : 'do'} not know \`${FOUNDATION_DIR}/\``,
|
|
799
|
+
`run \`yad update\` and commit the refreshed checks — until then CI does not stop a hand-edit of ${FOUNDATION_DIR}/.sdlc/ or a Foundation change on a non-review branch`);
|
|
800
|
+
}
|
|
801
|
+
|
|
514
802
|
// ---- file shape (schemaVersion) -------------------------------------------------------------
|
|
515
803
|
// What shape this project's files are in, against the shape this engine writes. The stamp itself is
|
|
516
804
|
// silent by design (cli/lib.mjs), and `yad migrate` only speaks when you run it — so without this
|
|
@@ -527,6 +815,7 @@ export function epicChecks(checks, root) {
|
|
|
527
815
|
// so the fix is to upgrade the CLI, not to touch the file
|
|
528
816
|
const scopeOf = (rel) => {
|
|
529
817
|
const parts = rel.split(path.sep);
|
|
818
|
+
if (parts[0] === FOUNDATION_DIR && parts.length > 1) return FOUNDATION_EPIC; // E75
|
|
530
819
|
return parts[0] === 'epics' && parts.length > 1 ? parts[1] : null;
|
|
531
820
|
};
|
|
532
821
|
|
|
@@ -544,7 +833,7 @@ function shapeCheckFor(checks, id, label, rows, engine) {
|
|
|
544
833
|
const readable = rows.filter((r) => r.from !== null);
|
|
545
834
|
if (!readable.length) return;
|
|
546
835
|
const ahead = readable.filter((r) => r.action === 'ahead');
|
|
547
|
-
// A file behind the engine on a VERIFIED
|
|
836
|
+
// A file behind the engine on a VERIFIED Product is real drift, but `yad migrate` deliberately refuses
|
|
548
837
|
// to touch it — CI is its only writer. Pointing at migrate there would send someone to a command
|
|
549
838
|
// that changes nothing while the warning never clears, so those are counted and named separately.
|
|
550
839
|
const behind = readable.filter((r) => r.from < engine && r.action !== 'ci-owned');
|
|
@@ -588,8 +877,916 @@ function shapeCheckFor(checks, id, label, rows, engine) {
|
|
|
588
877
|
// engine is on shape 1 nothing can be BEHIND it, so the warn branch — the one this section exists for —
|
|
589
878
|
// is unreachable from a real project until the first real shape change lands. Tests supply a plan that
|
|
590
879
|
// reaches it, which is how the drift report is proven before there is any drift to report.
|
|
880
|
+
// A file that lives under two names must say the same thing under both. The engine writes them
|
|
881
|
+
// together, so they only drift when something outside the engine touched one — a person editing the
|
|
882
|
+
// name they happen to know, a script, a half-finished merge. The older name is the authoritative one
|
|
883
|
+
// this major, so a silent drift means the OTHER copy is being ignored, which is the kind of thing
|
|
884
|
+
// people lose an afternoon to. Say it out loud instead.
|
|
885
|
+
export function mirrorChecks(checks, root) {
|
|
886
|
+
const pairs = [...MIRRORED_FILES.map(({ canonical, legacy }) => ({ canonical, legacy }))];
|
|
887
|
+
// The per-epic PR ledger is renamed the same way, so it drifts the same way. It is not in
|
|
888
|
+
// MIRRORED_FILES because that list is project-relative and this one exists once per epic.
|
|
889
|
+
// `epicIds` reads with `withFileTypes`, so a dangling symlink is simply not a directory — it cannot
|
|
890
|
+
// throw here and take the whole health check down, which is what the old `statSync` guard was for.
|
|
891
|
+
for (const e of epicIds(root)) {
|
|
892
|
+
const f = epicFiles(epicRel(e));
|
|
893
|
+
pairs.push({ canonical: f.productPrs, legacy: f.hubPrs });
|
|
894
|
+
}
|
|
895
|
+
for (const { canonical, legacy } of pairs) {
|
|
896
|
+
const a = path.join(root, canonical);
|
|
897
|
+
const b = path.join(root, legacy);
|
|
898
|
+
// Only the settings file reaches this branch in practice: the per-epic PR ledgers are top-level
|
|
899
|
+
// arrays, which carry no shape at all, so `shape >= 3` is never true for them. They can be
|
|
900
|
+
// reported as DRIFTED (below) but never as half-made, and that is correct — their new name
|
|
901
|
+
// appears when a gate command next writes them, not when the project migrates.
|
|
902
|
+
//
|
|
903
|
+
// One side missing is NORMAL before `yad migrate` — an un-migrated project has only the old name.
|
|
904
|
+
// It is not normal once the file says shape 3, because from then on every save writes both. And
|
|
905
|
+
// `writeMirrored` cannot repair it on its own: when the authoritative copy already matches, it
|
|
906
|
+
// correctly does nothing, so a half-made pair stays half-made and silent.
|
|
907
|
+
if (exists(a) !== exists(b)) {
|
|
908
|
+
const present = exists(a) ? a : b;
|
|
909
|
+
let shape;
|
|
910
|
+
try { shape = JSON.parse(fs.readFileSync(present, 'utf8'))?.schemaVersion ?? 1; } catch { continue; }
|
|
911
|
+
if (typeof shape === 'number' && shape >= 3) {
|
|
912
|
+
check(
|
|
913
|
+
checks, `mirror:${canonical}`, 'shape', 'warn',
|
|
914
|
+
`${exists(a) ? legacy : canonical} is missing — it should exist beside ${path.relative(root, present)} on shape ${shape}`,
|
|
915
|
+
'run `yad migrate --apply` — a missing partner counts as a change, so it writes the pair back into step',
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
if (!exists(a)) continue;
|
|
921
|
+
let same;
|
|
922
|
+
try { same = fs.readFileSync(a, 'utf8') === fs.readFileSync(b, 'utf8'); } catch { continue; }
|
|
923
|
+
if (same) continue;
|
|
924
|
+
check(
|
|
925
|
+
checks, `mirror:${canonical}`, 'shape', 'warn',
|
|
926
|
+
`${canonical} and ${legacy} do not match — ${legacy} is the one being read`,
|
|
927
|
+
'they are two names for one file while the rename settles. Copy the one you meant to keep over the other, then re-run the command that writes it',
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// The two dials are one setting under two spellings, exactly like the mirrored file names, so they
|
|
933
|
+
// drift the same way and are reported the same way. Two things can go wrong, and they need different
|
|
934
|
+
// answers:
|
|
935
|
+
//
|
|
936
|
+
// DISAGREE a step says `assistance: heavy` and `driver: human`. Somebody edited one spelling, or a
|
|
937
|
+
// tool wrote one and a person wrote the other. The OLD name is the one that counts, and
|
|
938
|
+
// `yad migrate` will not fix it — the step already has the new key, so the step is
|
|
939
|
+
// skipped as done. Only a person can say which was meant.
|
|
940
|
+
// A REVIEW STEP CLAIMING AUTO `advance: auto` on a step a human must sign off. The rule that never
|
|
941
|
+
// bends, and worth failing over rather than warning: it is the one dial value that can
|
|
942
|
+
// let work past a person.
|
|
943
|
+
//
|
|
944
|
+
// Scoped to the per-step dials only. `trust-log.json` records what a dial WAS on a past run and is
|
|
945
|
+
// not a live setting, so a mismatch there is history, not drift.
|
|
946
|
+
export function dialChecks(checks, root) {
|
|
947
|
+
const disagree = [];
|
|
948
|
+
const reviewAuto = [];
|
|
949
|
+
const newOnly = [];
|
|
950
|
+
const shapeStateAuto = [];
|
|
951
|
+
const lockedAuto = [];
|
|
952
|
+
|
|
953
|
+
const inspect = (rel, where, steps) => {
|
|
954
|
+
if (!Array.isArray(steps)) return;
|
|
955
|
+
for (const s of steps) {
|
|
956
|
+
if (!isPlainObject(s)) continue;
|
|
957
|
+
const at = `${rel}${where ? ` (${where})` : ''} step \`${s.id || '?'}\``;
|
|
958
|
+
if (typeof s.assistance === 'string' && typeof s.driver === 'string'
|
|
959
|
+
&& DRIVER_FROM_ASSISTANCE[s.assistance] !== s.driver) {
|
|
960
|
+
disagree.push(`${at}: \`assistance: ${s.assistance}\` but \`driver: ${s.driver}\``);
|
|
961
|
+
}
|
|
962
|
+
if (typeof s.automation === 'string' && typeof s.advance === 'string'
|
|
963
|
+
&& ADVANCE_FROM_AUTOMATION[s.automation] !== s.advance) {
|
|
964
|
+
disagree.push(`${at}: \`automation: ${s.automation}\` but \`advance: ${s.advance}\``);
|
|
965
|
+
}
|
|
966
|
+
// A step holding ONLY the new name is the half-made pair, and it is silent from every other
|
|
967
|
+
// direction: this CLI reads it fine, `yad migrate` only ever adds new-from-old so it can never
|
|
968
|
+
// repair it, and an OLDER CLI finds no dial at all and falls back to `human_approve` — turning
|
|
969
|
+
// a lane earned to auto back into a manual one with nothing to say why. That is the exact
|
|
970
|
+
// failure the two-name window exists to prevent, so doctor has to be the one that sees it.
|
|
971
|
+
if (typeof s.driver === 'string' && typeof s.assistance !== 'string') {
|
|
972
|
+
newOnly.push(`${at}: \`driver\` with no \`assistance\``);
|
|
973
|
+
}
|
|
974
|
+
if (typeof s.advance === 'string' && typeof s.automation !== 'string') {
|
|
975
|
+
newOnly.push(`${at}: \`advance\` with no \`automation\``);
|
|
976
|
+
}
|
|
977
|
+
const isReview = isGateStep(s);
|
|
978
|
+
const saysAuto = s.advance === 'auto' || s.automation === 'machine_advance';
|
|
979
|
+
if (isReview && saysAuto) {
|
|
980
|
+
reviewAuto.push(at);
|
|
981
|
+
}
|
|
982
|
+
// Two `auto`s E34 changed the meaning of (neither is a failure — nothing breaks, but the file says
|
|
983
|
+
// something that does not happen, or no longer holds). `where` is empty for state.json, a repo for
|
|
984
|
+
// build-state.
|
|
985
|
+
const def = catalogueStep(s.id);
|
|
986
|
+
if (!isReview && saysAuto && def?.kind === 'author') {
|
|
987
|
+
if (def.phase !== 'build' && !where) shapeStateAuto.push(at);
|
|
988
|
+
else if (def.phase === 'build' && s.locked === true) lockedAuto.push(at);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
for (const e of epicIds(root)) {
|
|
994
|
+
const f = epicFiles(epicRel(e));
|
|
995
|
+
const state = readJSON(path.join(root, f.state), null);
|
|
996
|
+
if (state) inspect(f.state, '', state.steps);
|
|
997
|
+
const bsDir = path.join(root, f.buildStateDir);
|
|
998
|
+
if (!exists(bsDir)) continue;
|
|
999
|
+
let names;
|
|
1000
|
+
try { names = fs.readdirSync(bsDir).filter((n) => n.endsWith('.json')).sort(); } catch { continue; }
|
|
1001
|
+
for (const n of names) {
|
|
1002
|
+
const bs = readJSON(path.join(bsDir, n), null);
|
|
1003
|
+
if (!bs || typeof bs.repos !== 'object' || bs.repos === null) continue;
|
|
1004
|
+
for (const [repo, r] of Object.entries(bs.repos)) inspect(`${f.buildStateDir}/${n}`, repo, r?.steps);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (reviewAuto.length) {
|
|
1009
|
+
check(
|
|
1010
|
+
checks, 'dials:review-auto', 'shape', 'fail',
|
|
1011
|
+
`a review step is set to advance on its own: ${reviewAuto.slice(0, 3).join('; ')}${reviewAuto.length > 3 ? ` (+${reviewAuto.length - 3} more)` : ''}`,
|
|
1012
|
+
'a review gate can never be `auto` — set it back to `advance: human` (`automation: human_approve`). `yad migrate` never writes this value; something else did',
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
if (newOnly.length) {
|
|
1016
|
+
check(
|
|
1017
|
+
checks, 'dials:new-only', 'shape', 'warn',
|
|
1018
|
+
`${newOnly.length} step(s) carry only the new dial name: ${newOnly.slice(0, 2).join('; ')}${newOnly.length > 2 ? ` (+${newOnly.length - 2} more)` : ''}`,
|
|
1019
|
+
'add the older name beside it (`driver` needs `assistance`, `advance` needs `automation`) — an older yadflow reads only the old one and would see no dial at all. `yad migrate` cannot repair this: it only ever adds the new name from the old',
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
if (shapeStateAuto.length) {
|
|
1023
|
+
check(
|
|
1024
|
+
checks, 'dials:shape-auto-unread', 'shape', 'warn',
|
|
1025
|
+
`${shapeStateAuto.length} Shape author step(s) say auto in state.json, where nothing reads a Shape dial: ${shapeStateAuto.slice(0, 2).join('; ')}${shapeStateAuto.length > 2 ? ` (+${shapeStateAuto.length - 2} more)` : ''}`,
|
|
1026
|
+
'a Shape author step\'s dial is read from .sdlc/automation.json (E34) — set it with `yad dial <step> --to auto`',
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
if (lockedAuto.length) {
|
|
1030
|
+
check(
|
|
1031
|
+
checks, 'dials:locked-auto', 'shape', 'warn',
|
|
1032
|
+
`${lockedAuto.length} Build step(s) are locked and set to auto, and \`locked\` no longer holds a Build author step at human (E34): ${lockedAuto.slice(0, 2).join('; ')}${lockedAuto.length > 2 ? ` (+${lockedAuto.length - 2} more)` : ''}`,
|
|
1033
|
+
'if it should wait for a person: `yad dial <epic> <story> --repo <name> <step> --to human`',
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
if (disagree.length) {
|
|
1037
|
+
check(
|
|
1038
|
+
checks, 'dials:disagree', 'shape', 'warn',
|
|
1039
|
+
`${disagree.length} step(s) carry two different dial values: ${disagree.slice(0, 2).join('; ')}${disagree.length > 2 ? ` (+${disagree.length - 2} more)` : ''}`,
|
|
1040
|
+
'the OLD name (`assistance`/`automation`) is the one being read. Set both to the value you meant — `yad migrate` skips a step that already has the new key, so it cannot decide this for you',
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// The advance dial and the kill switch (E34). Silent on a project that never touched either, so a project
|
|
1046
|
+
// with no `.sdlc/automation.json` — the frozen golden one included — reads exactly as before.
|
|
1047
|
+
export function automationChecks(checks, root) {
|
|
1048
|
+
const rel = PROJECT_FILES.automationConfig;
|
|
1049
|
+
const a = loadAutomation(root);
|
|
1050
|
+
if (a.error) {
|
|
1051
|
+
check(checks, 'automation', 'project', 'fail',
|
|
1052
|
+
`${rel} ${a.error} — every step is held at advance: human until it is fixed`,
|
|
1053
|
+
'fix the JSON, or delete the file to go back to the defaults (kill switch off, every Shape step human)');
|
|
1054
|
+
} else {
|
|
1055
|
+
if (a.kill?.on) {
|
|
1056
|
+
const who = [a.kill.by ? `by ${a.kill.by}` : '', a.kill.date ? `on ${a.kill.date}` : ''].filter(Boolean).join(' ');
|
|
1057
|
+
check(checks, 'automation:kill', 'project', 'warn',
|
|
1058
|
+
`the kill switch is ON${who ? ` (${who})` : ''}${a.kill.reason ? `: ${a.kill.reason}` : ''} — every step is held at advance: human`,
|
|
1059
|
+
'turn it off with `yad unkill` once the reason is gone');
|
|
1060
|
+
}
|
|
1061
|
+
const gates = [];
|
|
1062
|
+
const build = [];
|
|
1063
|
+
const unknown = [];
|
|
1064
|
+
for (const [id, v] of Object.entries(a.steps)) {
|
|
1065
|
+
const def = catalogueStep(id);
|
|
1066
|
+
if (!ADVANCE_VALUES.includes(v)) unknown.push(`${id}: ${JSON.stringify(v)} is not human or auto`);
|
|
1067
|
+
else if (!def) unknown.push(`${id}: not a step this release knows`);
|
|
1068
|
+
else if (def.kind === 'review' && v === 'auto') gates.push(id);
|
|
1069
|
+
else if (def.phase === 'build') build.push(id);
|
|
1070
|
+
}
|
|
1071
|
+
if (gates.length) {
|
|
1072
|
+
check(checks, 'automation:gate', 'project', 'fail',
|
|
1073
|
+
`${rel} sets a review gate to auto: ${gates.join(', ')}`,
|
|
1074
|
+
'a gate is never auto (rule 1), and nothing honours this line — remove it');
|
|
1075
|
+
}
|
|
1076
|
+
if (build.length) {
|
|
1077
|
+
check(checks, 'automation:build-step', 'project', 'warn',
|
|
1078
|
+
`${rel} lists Build step(s) nothing reads here: ${build.join(', ')}`,
|
|
1079
|
+
'a Build step\'s dial is set per lane — `yad dial <epic> <story> --repo <name> <step> --to auto`');
|
|
1080
|
+
}
|
|
1081
|
+
if (unknown.length) {
|
|
1082
|
+
check(checks, 'automation:unknown', 'project', 'warn',
|
|
1083
|
+
`${rel}: ${unknown.slice(0, 3).join('; ')}${unknown.length > 3 ? ` (+${unknown.length - 3} more)` : ''}`,
|
|
1084
|
+
'only `auto` on a Shape author step is read; everything else is ignored');
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
// The switch's OLD home. Before E34 it was `kill_switch: true` in the module config `yad update` installs.
|
|
1088
|
+
// Nothing reads that key now, so a team that set it by hand has a kill switch that silently turned OFF —
|
|
1089
|
+
// the one change this row must never make quietly.
|
|
1090
|
+
const legacy = path.join(root, '_bmad', 'sdlc', 'config.yaml');
|
|
1091
|
+
let text = '';
|
|
1092
|
+
try {
|
|
1093
|
+
if (exists(legacy)) text = fs.readFileSync(legacy, 'utf8');
|
|
1094
|
+
} catch { /* an unreadable module config has nothing to say about the switch */ }
|
|
1095
|
+
// Every spelling YAML reads as true, and the old skill (a model reading the file) would have honoured.
|
|
1096
|
+
if (/^\s*kill_switch:\s*(?:true|yes|on)\b/im.test(text) && !killSwitchOn(a)) {
|
|
1097
|
+
check(checks, 'automation:legacy-kill', 'project', 'fail',
|
|
1098
|
+
'_bmad/sdlc/config.yaml says `kill_switch: true`, and nothing reads that key any more — the kill switch is OFF',
|
|
1099
|
+
'turn it on where it is read: `yad kill --reason "<why>"`. Then set that line back to false, or delete _bmad/sdlc/ (see `module:legacy-bmad`)');
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// The module's OLD install folder (E3). Until E3, `yad setup` copied the module config into `_bmad/sdlc/`,
|
|
1104
|
+
// because yadflow was packaged as a BMAD module. Nothing reads that folder now, and nothing deletes it:
|
|
1105
|
+
// `automation:legacy-kill` above reads a `kill_switch` line in it, and a delete run before the doctor
|
|
1106
|
+
// would hide that the switch is off. So the folder is named here, and the team removes it. `_bmad/` on its
|
|
1107
|
+
// own is BMAD's install and none of ours; only the `sdlc/` folder inside it was written by yadflow.
|
|
1108
|
+
export function legacyModuleChecks(checks, root) {
|
|
1109
|
+
if (!exists(path.join(root, '_bmad', 'sdlc'))) return;
|
|
1110
|
+
const installed = exists(path.join(root, MODULE_CONFIG));
|
|
1111
|
+
check(checks, 'module:legacy-bmad', 'project', 'warn',
|
|
1112
|
+
`_bmad/sdlc/ is left over from before E3, and nothing reads it — the module config is ${MODULE_CONFIG} now`,
|
|
1113
|
+
`${installed ? '' : `run \`yad check --fix\` to install ${MODULE_CONFIG}, then `}copy any value you changed in _bmad/sdlc/config.yaml into ${MODULE_CONFIG}, then delete _bmad/sdlc/. If \`automation:legacy-kill\` shows, clear it first — it reads that folder`);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// The work-item type, mid-rename. Shape 5 writes `type:` beside `kind:` in `epic.md` and copies the
|
|
1117
|
+
// value into `state.json`; `kind:` is still the name that is READ. Four things can go wrong while two
|
|
1118
|
+
// names are alive, and only one of them is a failure:
|
|
1119
|
+
//
|
|
1120
|
+
// A GATE THAT CANNOT SEE THE TYPE `type:` alone, with no `kind:`, on a change/defect/hotfix.
|
|
1121
|
+
// `templates/checks/lineage-check.sh` runs inside the user's repository and is refreshed
|
|
1122
|
+
// by `yad update`, which is a separate act from `yad migrate` with no ordering between
|
|
1123
|
+
// them — so a repo that migrated but did not update has a copy that reads only `kind:`.
|
|
1124
|
+
// It finds none, defaults to `feature`, decides the epic is a parent-free genesis, and
|
|
1125
|
+
// stops asking it for its parent. A lineage gate silently disarmed by an upgrade is worth
|
|
1126
|
+
// failing over; everything else here is drift a person can take their time with.
|
|
1127
|
+
// ONLY THE NEW NAME, on a genesis type. The same half-made pair with nothing at stake — `feature`
|
|
1128
|
+
// is what an older reader defaults to anyway.
|
|
1129
|
+
// TWO DIFFERENT VALUES `kind:` and `type:` disagree. The OLD one is being read, and `yad migrate`
|
|
1130
|
+
// skips an epic that already has the new key, so only a person can say which was meant.
|
|
1131
|
+
// A LEDGER THAT DISAGREES WITH THE EPIC `state.json` records a `type` that is not what `epic.md`
|
|
1132
|
+
// says. The epic.md value is the one the engine reads, so this is a stale copy.
|
|
1133
|
+
// A TYPE NOBODY DEFINED a value outside the five. It reads as a non-genesis type, so the lineage
|
|
1134
|
+
// gate gets stricter rather than looser — a warning, not a failure.
|
|
1135
|
+
//
|
|
1136
|
+
// `state.json`'s own top-level `kind` is NOT looked at here. That is the `stub` / `foundation` / `discovery`
|
|
1137
|
+
// lifecycle marker, a different axis, and a stub legitimately carries both at once.
|
|
1138
|
+
export function typeChecks(checks, root) {
|
|
1139
|
+
const epicsDir = path.join(root, 'epics');
|
|
1140
|
+
if (!exists(epicsDir)) return;
|
|
1141
|
+
const gateBlind = [];
|
|
1142
|
+
const newOnly = [];
|
|
1143
|
+
const disagree = [];
|
|
1144
|
+
const ledger = [];
|
|
1145
|
+
const unknown = [];
|
|
1146
|
+
|
|
1147
|
+
for (const e of fs.readdirSync(epicsDir).sort()) {
|
|
1148
|
+
if (!isValidEpicId(e)) continue;
|
|
1149
|
+
const md = path.join(epicsDir, e, 'epic.md');
|
|
1150
|
+
if (!exists(md)) continue; // no epic.md — EP-discovery, not a work item on the ladder
|
|
1151
|
+
const fm = readFrontmatter(md);
|
|
1152
|
+
const hasOld = typeof fm.kind === 'string' && fm.kind;
|
|
1153
|
+
const hasNew = typeof fm.type === 'string' && fm.type;
|
|
1154
|
+
const resolved = workItemType(fm);
|
|
1155
|
+
|
|
1156
|
+
if (hasNew && !hasOld) {
|
|
1157
|
+
if (isGenesisType(fm.type)) newOnly.push(`${e}: \`type: ${fm.type}\` with no \`kind:\``);
|
|
1158
|
+
else gateBlind.push(`${e}: \`type: ${fm.type}\` with no \`kind:\``);
|
|
1159
|
+
}
|
|
1160
|
+
if (hasOld && hasNew && fm.kind !== fm.type) {
|
|
1161
|
+
disagree.push(`${e}: \`kind: ${fm.kind}\` but \`type: ${fm.type}\``);
|
|
1162
|
+
}
|
|
1163
|
+
if (!WORK_ITEM_TYPES.includes(resolved)) unknown.push(`${e}: \`${resolved}\``);
|
|
1164
|
+
|
|
1165
|
+
const state = readJSON(path.join(epicsDir, e, '.sdlc', 'state.json'), null);
|
|
1166
|
+
if (isPlainObject(state) && typeof state.type === 'string' && state.type !== resolved) {
|
|
1167
|
+
ledger.push(`${e}: epic.md says \`${resolved}\`, state.json says \`${state.type}\``);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const some = (list, n) => `${list.slice(0, n).join('; ')}${list.length > n ? ` (+${list.length - n} more)` : ''}`;
|
|
1172
|
+
if (gateBlind.length) {
|
|
1173
|
+
check(
|
|
1174
|
+
checks, 'type:gate-blind', 'shape', 'fail',
|
|
1175
|
+
`${gateBlind.length} epic(s) record a type only the newest yadflow can see: ${some(gateBlind, 3)}`,
|
|
1176
|
+
'add `kind:` beside `type:` in epic.md. `lineage-check.sh` inside your repo reads `kind:` and, finding none, treats the epic as a parent-free genesis — so it stops requiring the `parent:` a change/defect/hotfix must have. Run `yad update` to refresh the check gates too',
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
if (newOnly.length) {
|
|
1180
|
+
check(
|
|
1181
|
+
checks, 'type:new-only', 'shape', 'warn',
|
|
1182
|
+
`${newOnly.length} epic(s) carry only the new name: ${some(newOnly, 2)}`,
|
|
1183
|
+
'add `kind:` beside `type:` — it is still the name every other reader uses. Nothing can do it for you: `yad migrate` never writes `epic.md` at all',
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
if (disagree.length) {
|
|
1187
|
+
check(
|
|
1188
|
+
checks, 'type:disagree', 'shape', 'warn',
|
|
1189
|
+
`${disagree.length} epic(s) name two different types: ${some(disagree, 2)}`,
|
|
1190
|
+
'the OLD name (`kind:`) is the one being read. Set both to the type you meant',
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
if (ledger.length) {
|
|
1194
|
+
check(
|
|
1195
|
+
checks, 'type:ledger', 'shape', 'warn',
|
|
1196
|
+
`${ledger.length} epic ledger(s) disagree with their epic.md: ${some(ledger, 2)}`,
|
|
1197
|
+
'epic.md is where the type is authored and is what the engine reads. Correct `type` in `.sdlc/state.json`, or fix epic.md if the ledger was right',
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
if (unknown.length) {
|
|
1201
|
+
check(
|
|
1202
|
+
checks, 'type:unknown', 'shape', 'warn',
|
|
1203
|
+
`${unknown.length} epic(s) use a type nobody defined: ${some(unknown, 3)}`,
|
|
1204
|
+
`a work item is one of ${WORK_ITEM_TYPES.join(' · ')}. An unrecognised value reads as a non-genesis type, so the lineage gate will demand a \`parent:\` for it`,
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// The grouping theme (E31) — a free tag on `epic.md` that puts several epics under one heading. It is
|
|
1210
|
+
// deliberately unvalidated: there is no list of allowed themes, and having none is normal. So there is
|
|
1211
|
+
// nothing here to check about a SINGLE epic. Both reports below are about the tag failing at the one
|
|
1212
|
+
// job it has, which is putting epics together.
|
|
1213
|
+
//
|
|
1214
|
+
// Reported in the `shape` section beside the type and phase checks: those three are the engine's own
|
|
1215
|
+
// vocabulary, and the golden test freezes the `epics` and `threads` sections against exactly this kind
|
|
1216
|
+
// of addition (rule 6).
|
|
1217
|
+
export function themeChecks(checks, root) {
|
|
1218
|
+
const epicsDir = path.join(root, 'epics');
|
|
1219
|
+
if (!exists(epicsDir)) return;
|
|
1220
|
+
const spellings = new Map(); // folded key -> the distinct spellings seen, in first-seen order
|
|
1221
|
+
const unreadable = [];
|
|
1222
|
+
const commented = [];
|
|
1223
|
+
|
|
1224
|
+
for (const e of fs.readdirSync(epicsDir).sort()) {
|
|
1225
|
+
if (!isValidEpicId(e)) continue;
|
|
1226
|
+
const md = path.join(epicsDir, e, 'epic.md');
|
|
1227
|
+
if (!exists(md)) continue; // no epic.md — EP-discovery, not a work item on the ladder
|
|
1228
|
+
const fm = readFrontmatter(md);
|
|
1229
|
+
// `readFrontmatter` turns `theme: [a, b]` into an array, and a theme is ONE tag. Left alone the
|
|
1230
|
+
// value would simply read as absent, so the epic would drop out of every grouping in silence.
|
|
1231
|
+
// An EMPTY list is not this — `theme: []` plainly says "no theme", and so does a blank `theme:`,
|
|
1232
|
+
// which is what the skill templates ship.
|
|
1233
|
+
if (Array.isArray(fm.theme)) {
|
|
1234
|
+
if (fm.theme.length) unreadable.push(`${e}: \`theme: [${fm.theme.join(', ')}]\``);
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
const t = themeOf(fm);
|
|
1238
|
+
if (!t) continue;
|
|
1239
|
+
// A `#` inside the tag is almost always the comment trap: neither `readFrontmatter` nor the check
|
|
1240
|
+
// gates' `fm_val` strips a trailing `# …`, so it lands in the value. The tag IS read — it is not
|
|
1241
|
+
// unreadable — but it now includes the note, so it groups only with epics carrying that exact
|
|
1242
|
+
// text. Reported on its own rather than as a spelling variant, which would be true of
|
|
1243
|
+
// `#checkout-revamp` beside `checkout-revamp` while naming the wrong problem.
|
|
1244
|
+
if (t.includes('#')) { commented.push(`${e}: \`theme: ${t}\``); continue; }
|
|
1245
|
+
const key = themeKey(t);
|
|
1246
|
+
// Nothing left after the fold — a tag of pure punctuation or symbols, like `🎯`. Nothing is wrong
|
|
1247
|
+
// with it: it reads, and two epics carrying it group by being the same string. It is only left OUT
|
|
1248
|
+
// of the variant report, which has nothing to compare. Keeping it IN would be the bug: every such
|
|
1249
|
+
// tag folds to the same empty key, so `🎯` and `···` would be reported as one theme spelled two
|
|
1250
|
+
// ways — which is exactly backwards.
|
|
1251
|
+
if (!key) continue;
|
|
1252
|
+
const seen = spellings.get(key) || [];
|
|
1253
|
+
if (!seen.includes(t)) seen.push(t);
|
|
1254
|
+
spellings.set(key, seen);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const variants = [...spellings.values()].filter((v) => v.length > 1);
|
|
1258
|
+
const some = (list, n) => `${list.slice(0, n).join('; ')}${list.length > n ? ` (+${list.length - n} more)` : ''}`;
|
|
1259
|
+
if (variants.length) {
|
|
1260
|
+
check(
|
|
1261
|
+
checks, 'theme:variants', 'shape', 'warn',
|
|
1262
|
+
`${variants.length} grouping theme(s) are spelled more than one way: ${some(variants.map((v) => v.map((x) => `\`${x}\``).join(' / ')), 2)}`,
|
|
1263
|
+
'these are one theme typed differently, and they group as two. Pick one spelling and use it in every `epic.md` that belongs to the group',
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
if (unreadable.length) {
|
|
1267
|
+
check(
|
|
1268
|
+
checks, 'theme:unreadable', 'shape', 'warn',
|
|
1269
|
+
`${unreadable.length} epic(s) have a \`theme:\` nothing can read: ${some(unreadable, 3)}`,
|
|
1270
|
+
'a theme is ONE free tag — a word or short phrase, in any language. A list is read as no theme at all, so the epic drops out of every grouping',
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
if (commented.length) {
|
|
1274
|
+
check(
|
|
1275
|
+
checks, 'theme:commented', 'shape', 'warn',
|
|
1276
|
+
`${commented.length} epic(s) have a \`#\` inside the theme itself: ${some(commented, 3)}`,
|
|
1277
|
+
'these frontmatter readers keep the whole rest of the line, so a `#` and everything after it becomes part of the tag — the epic then groups only with epics carrying that exact text. Write the tag bare. `yad next` and `yad thread` print a `#` in front of it, but that is decoration on the screen, not part of the value',
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// A project's chain against the step catalogue (E4). `phaseChecks` below asks whether a step id is
|
|
1283
|
+
// KNOWN; this asks whether a known step is set up the way the catalogue says it is.
|
|
1284
|
+
//
|
|
1285
|
+
// IT REPORTS AND CHANGES NOTHING, and when the two disagree THE FILE WINS for this whole major
|
|
1286
|
+
// (rule 3). `yad epic new` seeds from the catalogue and three of the five authoring skills now call
|
|
1287
|
+
// it, so a fresh epic on those routes cannot disagree with it by accident — but `yad-discovery` and
|
|
1288
|
+
// `yad-change` still write their own, a chain may legitimately leave a step out (not every epic has a
|
|
1289
|
+
// `ui-design`), and a project may hold a chain from a newer yadflow. So a mismatch is a warning about a file somebody should look at, never
|
|
1290
|
+
// a rewrite — the same discipline as `workItemType`.
|
|
1291
|
+
//
|
|
1292
|
+
// Nothing here reports a step that is ABSENT from a chain. Skipping `ui-design` on an epic with no
|
|
1293
|
+
// screens is a normal thing to do, and a check that nagged about it would train people to ignore the
|
|
1294
|
+
// section that also carries the two below, which are real breakage.
|
|
1295
|
+
export function catalogueChecks(checks, root) {
|
|
1296
|
+
const wrongArtifact = [];
|
|
1297
|
+
const noArtifact = [];
|
|
1298
|
+
const wrongKind = [];
|
|
1299
|
+
const orphanGate = [];
|
|
1300
|
+
const offRoute = [];
|
|
1301
|
+
|
|
1302
|
+
for (const e of epicIds(root)) {
|
|
1303
|
+
const state = readJSON(path.join(epicRoot(root, e), '.sdlc', 'state.json'), null);
|
|
1304
|
+
if (!isPlainObject(state) || !Array.isArray(state.steps)) continue;
|
|
1305
|
+
const present = new Set(state.steps.map((s) => s?.id).filter((x) => typeof x === 'string'));
|
|
1306
|
+
|
|
1307
|
+
// Which lifecycle profile is this epic walking? Worked out from the CHAIN, not from the `profile`
|
|
1308
|
+
// key shape 6 records — the key says which route the epic was started on, and this is about the
|
|
1309
|
+
// steps as they stand. (`profileChecks` below is what compares the two.) A chain matching NO
|
|
1310
|
+
// profile carries a step no route has, or has them out of order, and `yad next` walks a chain in
|
|
1311
|
+
// array order: the step it names next is then whatever happens to sit there, not the step that
|
|
1312
|
+
// comes next in any route.
|
|
1313
|
+
if (state.steps.length && !matchLifecycleProfile(state.steps)) {
|
|
1314
|
+
const known = state.steps.filter((x) => stepDef(x?.id));
|
|
1315
|
+
// Only reported when every step is one the catalogue knows. An id from a newer release is
|
|
1316
|
+
// `phase:unknown`'s business, and a chain full of them would otherwise be reported twice.
|
|
1317
|
+
if (known.length === state.steps.length) {
|
|
1318
|
+
offRoute.push(`${e}: \`${state.steps.map((x) => x.id).join(' → ')}\``);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
for (const step of state.steps) {
|
|
1323
|
+
const def = step && typeof step.id === 'string' ? stepDef(step.id) : null;
|
|
1324
|
+
if (!def) continue; // unknown id — `phase:unknown` below is the check for that
|
|
1325
|
+
// ONLY THE ARTIFACT COMPARISON skips a Build id, and only because there is nothing to compare:
|
|
1326
|
+
// Build runs per story per code repo out of `build-state/`, so those catalogue rows carry no
|
|
1327
|
+
// epic-level artifact. The two checks below still run on a Build id in an epic chain, which is
|
|
1328
|
+
// right — `implement` written as a `review+approve` step is wrong wherever it appears.
|
|
1329
|
+
//
|
|
1330
|
+
// The artifact is what the gate HASHES. A chain naming a different one binds the approval to
|
|
1331
|
+
// the wrong file, so the gate can pass while the artifact everybody reviewed sits unapproved.
|
|
1332
|
+
//
|
|
1333
|
+
// Compared through `artifactBase`, which is how every consumer reads this field
|
|
1334
|
+
// (`findReviewStep`, `artifactHash`, `yad gate`). It maps `stories`, `stories/`, `stories.md`
|
|
1335
|
+
// and `stories/EP-x-S01.md` all to one gate, so those spellings differ on paper and are the
|
|
1336
|
+
// same artifact in fact. Comparing the raw strings would warn that a chain which gates, hashes
|
|
1337
|
+
// and approves perfectly is bound to the wrong file — a false statement, and the kind that
|
|
1338
|
+
// teaches people to stop reading warnings.
|
|
1339
|
+
if (def.artifact && typeof step.artifact === 'string' && step.artifact
|
|
1340
|
+
&& !artifactAgrees(def, step.artifact)) {
|
|
1341
|
+
wrongArtifact.push(`${e} \`${step.id}\`: \`${step.artifact}\`, catalogue says \`${def.artifact}\``);
|
|
1342
|
+
}
|
|
1343
|
+
// A Shape step with NO artifact at all is the one shape that crashes rather than misfires:
|
|
1344
|
+
// `artifactBase(undefined)` throws, so `yad gate` on such a chain dies with an unhandled
|
|
1345
|
+
// TypeError instead of a message. Reported here because this is where the catalogue knows the
|
|
1346
|
+
// step should have named a file.
|
|
1347
|
+
if (def.artifact && (step.artifact === undefined || step.artifact === null || step.artifact === '')) {
|
|
1348
|
+
noArtifact.push(`${e} \`${step.id}\` (should be \`${def.artifact}\`)`);
|
|
1349
|
+
}
|
|
1350
|
+
// `type` in the file is `author` or `review+approve`; the catalogue calls the second one
|
|
1351
|
+
// `review`. A step on the wrong side of that line is not driven by what drives it: an author
|
|
1352
|
+
// step written as a gate is never closed by `yad gate`, and a gate written as an author step is
|
|
1353
|
+
// handed to a skill that has no artifact to write.
|
|
1354
|
+
const fileKind = step.type === 'review+approve' ? 'review' : step.type === 'author' ? 'author' : null;
|
|
1355
|
+
if (fileKind && fileKind !== def.kind) {
|
|
1356
|
+
wrongKind.push(`${e} \`${step.id}\`: \`${step.type}\`, catalogue says \`${def.kind}\``);
|
|
1357
|
+
}
|
|
1358
|
+
// A gate whose author step is not in the chain has nothing to review. Note what this does NOT
|
|
1359
|
+
// do: it does not block the steps after it. `preconditionsMet` only requires the steps BEFORE
|
|
1360
|
+
// one in the array to be done, and an absent step is in no such position — that is the
|
|
1361
|
+
// different problem of an author step present and stranded (issue #131), which
|
|
1362
|
+
// `stateInvariants` already reports.
|
|
1363
|
+
if (def.reviews && !present.has(def.reviews)) {
|
|
1364
|
+
orphanGate.push(`${e} \`${step.id}\` reviews \`${def.reviews}\`, which is not in the chain`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
const some = (list, n) => `${list.slice(0, n).join('; ')}${list.length > n ? ` (+${list.length - n} more)` : ''}`;
|
|
1370
|
+
if (offRoute.length) {
|
|
1371
|
+
const routes = LIFECYCLE_PROFILES.map((p) => `\`${p.id}\` (${p.title})`).join(' · ');
|
|
1372
|
+
check(
|
|
1373
|
+
checks, 'step:off-route', 'shape', 'warn',
|
|
1374
|
+
`${offRoute.length} epic(s) walk a chain that matches no lifecycle profile: ${some(offRoute, 2)}`,
|
|
1375
|
+
`a profile is the route an epic takes through the steps — ${routes}. Leaving a step OUT is fine; a step no route has, or two in the wrong order, is not: \`yad next\` reads the chain in the order it is written, so it will name whatever sits next rather than what comes next. In \`.sdlc/state.json\`, put the steps back in a route's order — and REMOVE any step no route has, which reordering cannot fix. A Build step (\`spec\`, \`tasks\`, \`implement\`, \`checks\`, \`engineer-review\`) is one of those: Build runs per story per repo out of \`build-state/\`, so it belongs in no epic chain`,
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
if (wrongArtifact.length) {
|
|
1379
|
+
check(
|
|
1380
|
+
checks, 'step:artifact', 'shape', 'warn',
|
|
1381
|
+
`${wrongArtifact.length} step(s) name an artifact the catalogue does not: ${some(wrongArtifact, 3)}`,
|
|
1382
|
+
'the artifact is the file the review gate hashes, so a wrong one binds the approval to the wrong file. Correct `artifact` in `.sdlc/state.json`, or leave it if this project deliberately runs a different chain — nothing is rewritten either way',
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
if (noArtifact.length) {
|
|
1386
|
+
check(
|
|
1387
|
+
checks, 'step:no-artifact', 'shape', 'warn',
|
|
1388
|
+
`${noArtifact.length} step(s) name no artifact at all: ${some(noArtifact, 3)}`,
|
|
1389
|
+
'the gate reads this field to know what to hash, and a missing one is not treated as "nothing" — `yad gate` stops with an unhandled error on this epic. Add `artifact` to the step in `.sdlc/state.json`',
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
if (wrongKind.length) {
|
|
1393
|
+
check(
|
|
1394
|
+
checks, 'step:kind', 'shape', 'warn',
|
|
1395
|
+
`${wrongKind.length} step(s) are the wrong kind of step: ${some(wrongKind, 3)}`,
|
|
1396
|
+
'an author step is run by a skill and a `review+approve` step by `yad gate`. On the wrong side of that line the step is never driven by the thing that drives it',
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
if (orphanGate.length) {
|
|
1400
|
+
check(
|
|
1401
|
+
checks, 'step:orphan-gate', 'shape', 'warn',
|
|
1402
|
+
`${orphanGate.length} review gate(s) review a step that is not there: ${some(orphanGate, 3)}`,
|
|
1403
|
+
'nothing in this chain tells anyone to write the artifact the gate reviews, and for a directory artifact the hash comes back empty, so the gate has nothing to bind an approval to. Add the author step, or drop the gate if this chain deliberately inherits that artifact from its parent epic',
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// The lifecycle profile an epic RECORDS (E17, shape 6) against the chain it actually walks.
|
|
1409
|
+
//
|
|
1410
|
+
// Before shape 6 the route was only ever derived, so it could not be wrong — it was whatever the chain
|
|
1411
|
+
// said. Now `state.json` names it, and a name can go stale: someone edits the chain by hand, or copies
|
|
1412
|
+
// a ledger from another epic, and the file claims a route it is no longer on. That matters because the
|
|
1413
|
+
// name is what a seed and a renderer trust WITHOUT re-reading the chain.
|
|
1414
|
+
//
|
|
1415
|
+
// Reported, never corrected. The file wins for this whole major (rule 3) — an epic may carry a route
|
|
1416
|
+
// from a newer yadflow, and `yad migrate` deliberately never overwrites a `profile` somebody wrote.
|
|
1417
|
+
//
|
|
1418
|
+
// NO OVERLAP WITH `step:off-route`, on purpose. That check fires when a chain fits no route at all,
|
|
1419
|
+
// and it already tells the user their chain is broken and how. Re-reporting the same epic here as
|
|
1420
|
+
// "the recorded route disagrees" would name the same fault twice with two different remedies, so this
|
|
1421
|
+
// check speaks only when the chain fits a route CLEANLY and it is a different one from the record.
|
|
1422
|
+
export function profileChecks(checks, root) {
|
|
1423
|
+
const unknown = [];
|
|
1424
|
+
const disagree = [];
|
|
1425
|
+
|
|
1426
|
+
for (const e of epicIds(root)) {
|
|
1427
|
+
const state = readJSON(path.join(epicRoot(root, e), '.sdlc', 'state.json'), null);
|
|
1428
|
+
// No key at all is the normal state for a chain that matches no route: `stampProfile` declines to
|
|
1429
|
+
// invent one, and `step:off-route` is what reports that chain. Nothing to say here.
|
|
1430
|
+
if (!isPlainObject(state) || !('profile' in state)) continue;
|
|
1431
|
+
const recorded = state.profile;
|
|
1432
|
+
if (!lifecycleProfile(recorded)) {
|
|
1433
|
+
unknown.push(`${e}: \`${recorded === null ? 'null' : String(recorded)}\``);
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
const matched = matchLifecycleProfile(state.steps);
|
|
1437
|
+
if (matched && matched !== recorded) {
|
|
1438
|
+
disagree.push(`${e}: records \`${recorded}\`, its chain is \`${matched}\``);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
const some = (list, n) => `${list.slice(0, n).join('; ')}${list.length > n ? ` (+${list.length - n} more)` : ''}`;
|
|
1443
|
+
if (unknown.length) {
|
|
1444
|
+
check(
|
|
1445
|
+
checks, 'profile:unknown', 'shape', 'warn',
|
|
1446
|
+
`${unknown.length} epic(s) record a lifecycle profile nobody defined: ${some(unknown, 3)}`,
|
|
1447
|
+
`a profile is one of ${LIFECYCLE_PROFILES.map((p) => p.id).join(' · ')}. An unrecognised value means nothing can say which route this epic is on, so every reader falls back to matching the chain — set \`profile\` in \`.sdlc/state.json\` to the route it walks, or delete the key and let it be derived`,
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
if (disagree.length) {
|
|
1451
|
+
check(
|
|
1452
|
+
checks, 'profile:disagree', 'shape', 'warn',
|
|
1453
|
+
`${disagree.length} epic(s) record a route their chain is not on: ${some(disagree, 2)}`,
|
|
1454
|
+
'the chain is what `yad next` walks step by step, and the recorded name is the label on top of it — so a stale label misleads whoever reads the record instead of the steps. It is not only cosmetic: the recorded name is what decides which steps this epic may SKIP (E35), deliberately, because a chain carrying a step from a newer release fits no route here and must not lose its optional steps for it. Correct `profile` in `.sdlc/state.json` to the route the chain shows',
|
|
1455
|
+
);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// A step marked N/A ("skipped") that the epic's own route does NOT mark optional (E35).
|
|
1460
|
+
//
|
|
1461
|
+
// This could not be reported before, because the engine kept one list of skippable steps for the whole
|
|
1462
|
+
// project: a step optional on any route was skippable on every epic. Now the question is answered by
|
|
1463
|
+
// the route the epic is on, so the two can disagree — a chain edited by hand, a recorded route changed,
|
|
1464
|
+
// or a ledger copied from an epic on a different route.
|
|
1465
|
+
//
|
|
1466
|
+
// WHAT IT COSTS, stated the same way the hint states it. `gatePredicate` honours `skipped: true` only
|
|
1467
|
+
// for a step this epic's route marks optional, so a skip the route does not allow stops
|
|
1468
|
+
// short-circuiting. NOTHING BREAKS: the step is already `done`, so `yad gate sync` takes its
|
|
1469
|
+
// already-done branch, reports that the rule no longer holds and changes nothing — no step is
|
|
1470
|
+
// un-advanced, no review re-opens. What is lost is the justification: the gate stops treating the skip
|
|
1471
|
+
// as the reason the step passed, which is the whole point of recording a skip. This says so before the
|
|
1472
|
+
// next sync rather than after it.
|
|
1473
|
+
//
|
|
1474
|
+
// Reported, never corrected, like everything else here: clearing the flag would erase a recorded
|
|
1475
|
+
// decision, and re-marking the step `blocked` would undo work the team may have finished.
|
|
1476
|
+
export function skipChecks(checks, root) {
|
|
1477
|
+
const some = (list, n) => `${list.slice(0, n).join('; ')}${list.length > n ? ` (+${list.length - n} more)` : ''}`;
|
|
1478
|
+
const bad = [];
|
|
1479
|
+
|
|
1480
|
+
for (const e of epicIds(root)) {
|
|
1481
|
+
const state = readJSON(path.join(epicRoot(root, e), '.sdlc', 'state.json'), null);
|
|
1482
|
+
if (!isPlainObject(state) || !Array.isArray(state.steps)) continue;
|
|
1483
|
+
// Silent on an epic `profile:disagree` already names. Both findings come from the same stale
|
|
1484
|
+
// label, and that check's remedy — correct `profile` to the route the chain shows — clears this
|
|
1485
|
+
// one too. Naming one fault twice with two remedies is what this file forbids itself elsewhere.
|
|
1486
|
+
if (recordedRouteDisagrees(state)) continue;
|
|
1487
|
+
const optional = optionalStepsFor(state);
|
|
1488
|
+
// `claimsSkipped`, not the canonical state: this check exists to report a claim the route does not
|
|
1489
|
+
// allow, so it has to see the claim before it can refuse it. A HALF-STAMPED one — `skipped: true`
|
|
1490
|
+
// on a step that is not `done` — is the case most worth reporting, and reading the canonical state
|
|
1491
|
+
// would let exactly that one through silently (E38).
|
|
1492
|
+
// A `deferred` step (E37) needs the same route permission, because the chain walks past it with no
|
|
1493
|
+
// approvals on its review — so the same finding covers it. It has no legacy flag, so its status word
|
|
1494
|
+
// is the claim.
|
|
1495
|
+
const skipped = new Set(state.steps.filter((x) => (claimsSkipped(x) || stepStatus(x) === 'deferred') && typeof x.id === 'string').map((x) => x.id));
|
|
1496
|
+
for (const step of state.steps) {
|
|
1497
|
+
if (!skipped.has(step.id)) continue;
|
|
1498
|
+
if (isSkippableStep(step.id, optional)) continue;
|
|
1499
|
+
// AUTHOR STEPS ONLY, when the pair is both marked. One `yad skip` stamps a step and its gate, so
|
|
1500
|
+
// listing both reads as two faults for one action — and the remedy below takes the author step's
|
|
1501
|
+
// id. A gate marked on its own is a different, real fault and is still reported.
|
|
1502
|
+
if (step.id.endsWith('-review') && skipped.has(step.id.replace(/-review$/, ''))) continue;
|
|
1503
|
+
bad.push(`${e}/${step.id}`);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
if (bad.length) {
|
|
1508
|
+
check(
|
|
1509
|
+
checks, 'skip:not-optional', 'shape', 'warn',
|
|
1510
|
+
`${bad.length} skipped or deferred step(s) their epic's route does not mark optional: ${some(bad, 3)}`,
|
|
1511
|
+
'the gate fails closed on a skip the route does not allow, so it stops treating the skip as the reason the step passed. On a step already marked N/A in full nothing breaks: it reads as passed either way, and `yad gate sync` reports that the rule no longer holds and changes nothing — what is lost is the justification. A HALF-STAMPED one is different and is why this reads the claim rather than the state: `skipped: true` on a step that is not finished is a flag nothing honours, so the step is simply not done and the chain waits on it. Either the epic is on the wrong route (`step:off-route`), or the skip was written by hand — `yad unskip <epic> <step>` puts the step back in the chain (`yad undefer <epic> <step>` for a deferred one). It refuses, naming the step, once work has started past the step that follows the skipped pair, or that step is finished: the chain has been built on the skip by then, and putting it right is a decision about that later work, not a command',
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// ---- the step-state model (E38) -----------------------------------------------------------------
|
|
1517
|
+
//
|
|
1518
|
+
// Two findings about a step's `status`, both REPORTED and never corrected — the same discipline as
|
|
1519
|
+
// every other check in this file. Correcting either one would mean this command deciding what
|
|
1520
|
+
// somebody's file meant, which is exactly what a project runs `yad doctor` to avoid.
|
|
1521
|
+
//
|
|
1522
|
+
// `step:unknown-status` — a status no word in STEP_STATES names, and no legacy spelling either.
|
|
1523
|
+
// This is NOT automatically a fault. A project may hold a step written by a NEWER yadflow, and for
|
|
1524
|
+
// this whole major the file wins (the same rule as the step catalogue and the work-item type). What
|
|
1525
|
+
// it costs is real and worth saying once: `stepStatus` fails closed on a word it cannot name, so
|
|
1526
|
+
// every reader treats the step as neither passed nor authored — the chain stops there, and
|
|
1527
|
+
// `yad next` will keep naming the step before it.
|
|
1528
|
+
//
|
|
1529
|
+
// `step:no-record` — a status that must say WHY and does not. Four of the eight states carry a
|
|
1530
|
+
// record: `skipped`, `deferred`, `satisfied` and `blocked`. A skip with no reason is the thing the
|
|
1531
|
+
// whole design refuses — "a recorded skip is not a hole in the audit trail, it IS the audit trail" —
|
|
1532
|
+
// so a recorded state with nothing recorded on it is a hole wearing the shape of a record.
|
|
1533
|
+
//
|
|
1534
|
+
// TWO DELIBERATE NARROWINGS, and each of them is what keeps this check honest rather than noisy:
|
|
1535
|
+
//
|
|
1536
|
+
// 1. It reads the `status` FIELD literally, not `stepStatus`. The legacy encodings carry their own
|
|
1537
|
+
// provenance — a skipped step has `skipReason`/`skippedBy`/`skippedAt`, an inherited one has
|
|
1538
|
+
// `inheritedFrom`/`boundHash` — and for this major those ARE the record. Asking a pre-shape-7
|
|
1539
|
+
// project for a `record` it had no way to write would report every change-epic in the project,
|
|
1540
|
+
// including the frozen one the golden test pins.
|
|
1541
|
+
// 2. A literal `blocked` is asked for a record only at shape 7 and above. Below it, `blocked` is the
|
|
1542
|
+
// old spelling of "not started" and there is nothing to explain. At shape 7 the stamper has
|
|
1543
|
+
// already rewritten those, so one that remains was hand-written — and it still READS as `todo`,
|
|
1544
|
+
// which is the surprising half and what the hint leads with.
|
|
1545
|
+
|
|
1546
|
+
// The shape in which `blocked` stopped meaning "not started". A FIXED historical number, not
|
|
1547
|
+
// `SCHEMA_VERSION`: that one moves with every future shape change, and this fact does not.
|
|
1548
|
+
const BLOCKED_CHANGED_MEANING_AT = 7;
|
|
1549
|
+
|
|
1550
|
+
export function stepStateChecks(checks, root) {
|
|
1551
|
+
const some = (list, n) => `${list.slice(0, n).join('; ')}${list.length > n ? ` (+${list.length - n} more)` : ''}`;
|
|
1552
|
+
const unknown = [];
|
|
1553
|
+
const noRecord = [];
|
|
1554
|
+
const owed = [];
|
|
1555
|
+
|
|
1556
|
+
for (const e of epicIds(root)) {
|
|
1557
|
+
const state = readJSON(path.join(epicRoot(root, e), '.sdlc', 'state.json'), null);
|
|
1558
|
+
if (!isPlainObject(state) || !Array.isArray(state.steps)) continue;
|
|
1559
|
+
// The shape as the FILE records it, by rule 1: no key means shape 1.
|
|
1560
|
+
const shape = Number.isInteger(state.schemaVersion) ? state.schemaVersion : 1;
|
|
1561
|
+
for (const d of owedSteps(state)) owed.push(`${e}/${d.id.replace(/-review$/, '')} (${stepStatus(d) ?? d.status})`);
|
|
1562
|
+
for (const step of state.steps) {
|
|
1563
|
+
if (!isPlainObject(step) || typeof step.id !== 'string') continue;
|
|
1564
|
+
if (typeof step.status !== 'string') continue; // `validateState` is what reports a missing one
|
|
1565
|
+
if (!stepStatus(step)) { unknown.push(`${e}/${step.id}: \`${step.status}\``); continue; }
|
|
1566
|
+
if (!RECORDED_STEP_STATES.includes(step.status)) continue;
|
|
1567
|
+
if (step.status === 'blocked' && shape < BLOCKED_CHANGED_MEANING_AT) continue;
|
|
1568
|
+
if (!isStepRecord(step.record)) noRecord.push(`${e}/${step.id} (${step.status})`);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
if (unknown.length) {
|
|
1573
|
+
check(
|
|
1574
|
+
checks, 'step:unknown-status', 'shape', 'warn',
|
|
1575
|
+
`${unknown.length} step(s) carry a status this release does not know: ${some(unknown, 3)}`,
|
|
1576
|
+
`the states this engine knows are ${STEP_STATES.map((x) => x.id).join(' · ')}. Nothing is rewritten: a chain may legitimately come from a newer yadflow, and the file wins. But an unnamed state fails closed — the step counts as neither passed nor authored, so the chain stops there and \`yad next\` keeps naming the step in front of it. Either upgrade the CLI to the release that wrote it, or correct the value in \`.sdlc/state.json\``,
|
|
1577
|
+
);
|
|
1578
|
+
}
|
|
1579
|
+
// `step:debt` (E41) — a reminder, not a fault: the team chose to owe the work (`yad defer --debt`), and
|
|
1580
|
+
// this repeats on every run until the step's review passes, which is what clears the flag.
|
|
1581
|
+
if (owed.length) {
|
|
1582
|
+
check(
|
|
1583
|
+
checks, 'step:debt', 'shape', 'warn',
|
|
1584
|
+
`${owed.length} step(s) still owed as debt: ${some(owed, 3)}`,
|
|
1585
|
+
`a debt is a step deferred with \`--debt\`: owed back, and reminded until paid. \`yad undefer <epic> <step>\` starts paying it — after later work has finished, the step re-opens beside that work, which stays done — and the debt clears when the step's review passes${isVerifiedLedger(readJSON(productConfigPath(root), null)) ? '. On this verified Product `yad undefer` is refused once an epic\'s ledger is on the default branch: only CI writes `state.json` there, and CI has no step for it yet' : ''}`,
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
if (noRecord.length) {
|
|
1589
|
+
check(
|
|
1590
|
+
checks, 'step:no-record', 'shape', 'warn',
|
|
1591
|
+
`${noRecord.length} step(s) hold a recorded state with nothing recorded: ${some(noRecord, 3)}`,
|
|
1592
|
+
`${RECORDED_STEP_STATES.join(' · ')} each have to say WHY — a \`record\` of \`{ reason, by, date, link? }\` — because the reason is the audit trail the state exists to leave. A \`blocked\` with no record is the one that also changes meaning: every reader falls back to treating it as \`todo\` (not started), so the step does not read as waiting on anybody. Add the \`record\` in \`.sdlc/state.json\`, or move the step to the state that describes it`,
|
|
1593
|
+
);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// `.sdlc/skills.json`: which skill runs which step, when the project does not want the engine's
|
|
1598
|
+
// default (E6). Absent is the normal case and says nothing — most projects run the shipped skills.
|
|
1599
|
+
//
|
|
1600
|
+
// REPORTS, NEVER CORRECTS, and never judges a skill NAME. The engine cannot know which skills a team
|
|
1601
|
+
// has installed — that is E50's job — and binding a skill this release has never heard of is the whole
|
|
1602
|
+
// point of the file. So the only things checked here are the ones the engine CAN know: does the file
|
|
1603
|
+
// parse, is a value usable, and is the step id one this engine runs at all.
|
|
1604
|
+
//
|
|
1605
|
+
// Three warnings, all of them "your line did nothing", which is the failure a config file makes easy
|
|
1606
|
+
// to miss. A typo in a step id is silent otherwise: the binding sits in the file, `yad next` never
|
|
1607
|
+
// looks it up, and the team concludes the feature does not work.
|
|
1608
|
+
export function skillBindingChecks(checks, root) {
|
|
1609
|
+
const rel = PROJECT_FILES.skillsConfig;
|
|
1610
|
+
const file = path.join(root, rel);
|
|
1611
|
+
if (!exists(file)) return;
|
|
1612
|
+
|
|
1613
|
+
let raw;
|
|
1614
|
+
try {
|
|
1615
|
+
raw = readJSONStrict(file, null);
|
|
1616
|
+
} catch (e) {
|
|
1617
|
+
check(checks, 'skills', 'project', 'fail', `${rel} does not parse [${e.code || 'YAD-STATE-001'}]`,
|
|
1618
|
+
e.hint || 'fix the JSON or restore it from git');
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
if (!isPlainObject(raw)) {
|
|
1622
|
+
check(checks, 'skills', 'project', 'fail', `${rel} has the wrong shape [YAD-STATE-002]`, 'expected a JSON object');
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
// `steps` missing entirely is fine — a file holding only `schemaVersion` is what `yad skill unbind`
|
|
1626
|
+
// leaves behind when the last binding goes, and it binds nothing, correctly.
|
|
1627
|
+
if (raw.steps !== undefined && !isPlainObject(raw.steps)) {
|
|
1628
|
+
check(checks, 'skills', 'project', 'fail', `${rel}: \`steps\` must be a JSON object [YAD-STATE-002]`,
|
|
1629
|
+
'expected `"steps": { "<step-id>": "<skill>" }`');
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
const bindings = normalizeBindings(raw);
|
|
1634
|
+
const bound = Object.entries(bindings.steps);
|
|
1635
|
+
// Dropped by `normalizeBindings` — a number, an empty string, an empty list. The line is in the file
|
|
1636
|
+
// and does nothing, which is the one thing a person editing it would never guess.
|
|
1637
|
+
const unusable = Object.keys(raw.steps || {})
|
|
1638
|
+
.filter((id) => !Object.hasOwn(bindings.steps, id));
|
|
1639
|
+
// A step id this engine does not run. The file still wins — a project may hold a step from a newer
|
|
1640
|
+
// release — so this changes nothing and only says the binding is asleep.
|
|
1641
|
+
const unknown = bound.map(([id]) => id).filter((id) => !stepDef(id));
|
|
1642
|
+
// A step id the engine knows but runs no skill for: a Shape review gate, driven by `yad gate`.
|
|
1643
|
+
const gates = bound.map(([id]) => id).filter((id) => stepDef(id) && !stepDef(id).skill);
|
|
1644
|
+
|
|
1645
|
+
if (unusable.length) {
|
|
1646
|
+
check(checks, 'skills', 'project', 'warn',
|
|
1647
|
+
`${rel}: ${unusable.length} binding(s) name no skill and are ignored — ${unusable.join(', ')} [YAD-CFG-006]`,
|
|
1648
|
+
'each value must be a skill name or a non-empty list of them');
|
|
1649
|
+
}
|
|
1650
|
+
if (unknown.length) {
|
|
1651
|
+
check(checks, 'skills:unknown-step', 'project', 'warn',
|
|
1652
|
+
`${rel} binds ${unknown.length} step(s) this yadflow does not run: ${unknown.join(', ')}`,
|
|
1653
|
+
'check the spelling against `yad skill list`, or upgrade yadflow if the step is from a newer release');
|
|
1654
|
+
}
|
|
1655
|
+
if (gates.length) {
|
|
1656
|
+
check(checks, 'skills:review-step', 'project', 'warn',
|
|
1657
|
+
`${rel} binds ${gates.join(', ')}, which no skill runs — review gates are driven by \`yad gate\``,
|
|
1658
|
+
'bind the author step instead (for example `architecture`, not `architecture-review`)');
|
|
1659
|
+
}
|
|
1660
|
+
// Its OWN id, not `skills` again. A file with one good binding and one broken line fires both, and
|
|
1661
|
+
// two checks sharing an id put a green tick under the complaint in prose — and, worse, let a `--json`
|
|
1662
|
+
// consumer keying by id overwrite the warning with the tick.
|
|
1663
|
+
if (bound.length) {
|
|
1664
|
+
const chained = bound.filter(([, list]) => list.length > 1).length;
|
|
1665
|
+
check(checks, 'skills:bound', 'project', 'ok',
|
|
1666
|
+
`skills: ${bound.length} step(s) bound${chained ? `, ${chained} to more than one skill` : ''}`);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// A step this release does not recognise. Every step the engine can run has a row in the step
|
|
1671
|
+
// catalogue (E4), and that row is what gives it both a phase and a skill. So an id no phase claims is
|
|
1672
|
+
// an id with no row at all: one `yad next` cannot guide, `yad gate` has no artifact rule for, and no
|
|
1673
|
+
// renderer can place in the lifecycle. It is reported rather than ignored, and only warned about rather than failed: a project
|
|
1674
|
+
// may legitimately hold a step from a newer yadflow than the one being run, and a hand-written
|
|
1675
|
+
// `state.json` is allowed to be ahead of the tool reading it.
|
|
1676
|
+
//
|
|
1677
|
+
// THREE PLACES A STEP ID CAN APPEAR, and all three are read:
|
|
1678
|
+
// * `state.json` `steps[]` — the Shape chain;
|
|
1679
|
+
// * `state.json` `currentStep` — the field `yad thread --json` derives its `phase` from, and the one
|
|
1680
|
+
// nothing else validates. A typo there does not break `yad next`, which falls back to the first
|
|
1681
|
+
// step that is not done, so it would otherwise sit in a project unreported;
|
|
1682
|
+
// * `build-state/<story>.json` `repos.<name>.steps[]` — the Build half. Without these, five of the
|
|
1683
|
+
// twelve known step ids have no reader on this path at all.
|
|
1684
|
+
// The four `currentStep` sentinels are markers rather than steps and are skipped by name.
|
|
1685
|
+
export function phaseChecks(checks, root) {
|
|
1686
|
+
const unplaced = [];
|
|
1687
|
+
// One report per unknown id per epic. `currentStep` usually names a step that is also in `steps[]`,
|
|
1688
|
+
// so a single typo would otherwise be listed twice and push a genuinely different one out of the
|
|
1689
|
+
// three the message has room for.
|
|
1690
|
+
let seen = new Set();
|
|
1691
|
+
const consider = (where, id) => {
|
|
1692
|
+
if (typeof id !== 'string' || !id || SENTINELS.includes(id) || seen.has(id)) return;
|
|
1693
|
+
if (stepPhase(id)) return;
|
|
1694
|
+
seen.add(id);
|
|
1695
|
+
unplaced.push(`${where}: \`${id}\``);
|
|
1696
|
+
};
|
|
1697
|
+
const considerSteps = (where, steps) => {
|
|
1698
|
+
if (!Array.isArray(steps)) return;
|
|
1699
|
+
for (const s of steps) if (isPlainObject(s)) consider(where, s.id);
|
|
1700
|
+
};
|
|
1701
|
+
for (const e of epicIds(root)) {
|
|
1702
|
+
seen = new Set();
|
|
1703
|
+
const state = readJSON(path.join(epicRoot(root, e), '.sdlc', 'state.json'), null);
|
|
1704
|
+
if (isPlainObject(state)) {
|
|
1705
|
+
considerSteps(e, state.steps);
|
|
1706
|
+
consider(`${e} (currentStep)`, state.currentStep);
|
|
1707
|
+
}
|
|
1708
|
+
const bsDir = path.join(epicRoot(root, e), '.sdlc', 'build-state');
|
|
1709
|
+
if (!exists(bsDir)) continue;
|
|
1710
|
+
let names;
|
|
1711
|
+
try { names = fs.readdirSync(bsDir).filter((n) => n.endsWith('.json')).sort(); } catch { continue; }
|
|
1712
|
+
for (const n of names) {
|
|
1713
|
+
const bs = readJSON(path.join(bsDir, n), null);
|
|
1714
|
+
if (!isPlainObject(bs) || !isPlainObject(bs.repos)) continue;
|
|
1715
|
+
for (const [repo, r] of Object.entries(bs.repos)) considerSteps(`${e}/${n} (${repo})`, r?.steps);
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
if (unplaced.length) {
|
|
1719
|
+
check(
|
|
1720
|
+
checks, 'phase:unknown', 'shape', 'warn',
|
|
1721
|
+
`${unplaced.length} step(s) belong to no phase: ${unplaced.slice(0, 3).join('; ')}${unplaced.length > 3 ? ` (+${unplaced.length - 3} more)` : ''}`,
|
|
1722
|
+
'this yadflow does not recognise that step id, so it cannot say which phase it is in, which skill runs it, or what `yad next` should tell you to do. Check the spelling, or upgrade if the step comes from a newer release',
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
// ---- Build lanes set aside (E39) ----------------------------------------------------------------
|
|
1728
|
+
// A Build lane — one story in one repo, in `build-state/<story>.json` — may be SKIPPED WHOLE with
|
|
1729
|
+
// `yad skip <epic> <story> --repo <name>`, and nothing smaller: no single Build step may be set aside (the
|
|
1730
|
+
// user's E39 decision). The file is written by skills and by hand as well, so it is read for four faults:
|
|
1731
|
+
// lane:<epic>:<story>:<repo>:contradiction fail — skipped, yet work started in it or a ship is
|
|
1732
|
+
// recorded. Two records say opposite things.
|
|
1733
|
+
// …:no-record warn — skipped with no reason: a hole, not an audit trail.
|
|
1734
|
+
// …:undeclared warn — skipped for a repo the story does not declare, so
|
|
1735
|
+
// it owes no lane there and the skip means nothing.
|
|
1736
|
+
// …:step-set-aside warn — one Build step `skipped` or `deferred` inside a lane.
|
|
1737
|
+
// `buildNextForRepo` walks past a passed step, so this would
|
|
1738
|
+
// read as done work nobody did.
|
|
1739
|
+
export function laneChecks(checks, root) {
|
|
1740
|
+
for (const e of epicIds(root)) {
|
|
1741
|
+
const epicDir = epicRoot(root, e);
|
|
1742
|
+
const bsDir = path.join(epicDir, '.sdlc', 'build-state');
|
|
1743
|
+
if (!exists(bsDir)) continue;
|
|
1744
|
+
let names;
|
|
1745
|
+
try { names = fs.readdirSync(bsDir).filter((n) => n.endsWith('.json')).sort(); } catch { continue; }
|
|
1746
|
+
const stories = new Map(epicStories(epicDir).map((st) => [st.id, st]));
|
|
1747
|
+
let ships = [];
|
|
1748
|
+
try { ships = readShips(epicDir); } catch { /* an unreadable build-log is reported by the epic checks */ }
|
|
1749
|
+
for (const n of names) {
|
|
1750
|
+
const bs = readJSON(path.join(bsDir, n), null);
|
|
1751
|
+
if (!isPlainObject(bs) || !isPlainObject(bs.repos)) continue;
|
|
1752
|
+
const storyId = typeof bs.story === 'string' && bs.story ? bs.story : n.replace(/\.json$/, '');
|
|
1753
|
+
for (const [repo, lane] of Object.entries(bs.repos)) {
|
|
1754
|
+
if (!isPlainObject(lane)) continue;
|
|
1755
|
+
const where = `${e}: ${storyId} / ${repo}`;
|
|
1756
|
+
const id = (kind) => `lane:${e}:${storyId}:${repo}:${kind}`;
|
|
1757
|
+
const steps = Array.isArray(lane.steps) ? lane.steps.filter(isPlainObject) : [];
|
|
1758
|
+
if (lane.status === 'skipped') {
|
|
1759
|
+
const shipped = ships.some((sh) => sh.story === storyId && sh.repo === repo);
|
|
1760
|
+
const started = laneStarted(lane); // an unknown status word counts as started (E39 review)
|
|
1761
|
+
if (shipped || started) {
|
|
1762
|
+
check(checks, id('contradiction'), 'epics', 'fail',
|
|
1763
|
+
`${where} is skipped, but ${shipped ? 'a ship is recorded for it' : 'work has started in it'}`,
|
|
1764
|
+
`decide which is true: if the lane is owed, put it back with \`yad unskip ${e} ${storyId} --repo ${repo}\`; if it really needs no change, remove the work from build-state/${n}`);
|
|
1765
|
+
}
|
|
1766
|
+
if (!isStepRecord(lane.record)) {
|
|
1767
|
+
check(checks, id('no-record'), 'epics', 'warn', `${where} is skipped with no recorded reason`,
|
|
1768
|
+
`a skip is an audit trail only with its record — put it back with \`yad unskip ${e} ${storyId} --repo ${repo}\`, then skip it again with --reason`);
|
|
1769
|
+
}
|
|
1770
|
+
const story = stories.get(storyId);
|
|
1771
|
+
if (story && !story.repos.includes(repo)) {
|
|
1772
|
+
check(checks, id('undeclared'), 'epics', 'warn', `${where} is skipped, but ${storyId} does not declare ${repo}`,
|
|
1773
|
+
`the story owes no lane there, so the skip means nothing — remove the \`${repo}\` entry from build-state/${n}`);
|
|
1774
|
+
}
|
|
1775
|
+
continue;
|
|
1776
|
+
}
|
|
1777
|
+
const aside = steps.filter((st) => ['skipped', 'deferred'].includes(stepStatus(st)));
|
|
1778
|
+
if (aside.length) {
|
|
1779
|
+
check(checks, id('step-set-aside'), 'epics', 'warn',
|
|
1780
|
+
`${where}: ${aside.map((st) => `${st.id} is ${stepStatus(st)}`).join(', ')} — no single Build step may be set aside`,
|
|
1781
|
+
`a Build lane is skipped whole or not at all: \`yad skip ${e} ${storyId} --repo ${repo} --reason "<why>"\` when the story needs no change in ${repo}; otherwise set the step back to todo`);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
591
1788
|
export function shapeChecks(checks, root, { plan: injected = null } = {}) {
|
|
592
|
-
if (!injected && !exists(
|
|
1789
|
+
if (!injected && !exists(productConfigPath(root)) && !exists(path.join(root, PROJECT_FILES.version))) return;
|
|
593
1790
|
let plan = injected;
|
|
594
1791
|
if (!plan) {
|
|
595
1792
|
try {
|
|
@@ -616,6 +1813,120 @@ export function shapeChecks(checks, root, { plan: injected = null } = {}) {
|
|
|
616
1813
|
}
|
|
617
1814
|
}
|
|
618
1815
|
|
|
1816
|
+
// The risk map of each connected code repo (E65): one line per directory giving it a level, no names.
|
|
1817
|
+
// Advisory like the PR check — a stale map warns and never fails doctor. Doctor prints no approver count:
|
|
1818
|
+
// the count (E66) is a fact about one change, read by the PR check, `checks/risk-route.sh` and `yad open-pr`,
|
|
1819
|
+
// and doctor has no change in front of it. A repo that is not on disk, or not a git repo, is the repos
|
|
1820
|
+
// check's to report, not this one's.
|
|
1821
|
+
export function riskMapChecks(checks, root) {
|
|
1822
|
+
const registry = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
|
|
1823
|
+
const repos = Array.isArray(registry?.repos) ? registry.repos : [];
|
|
1824
|
+
for (const repo of repos) {
|
|
1825
|
+
if (!repo || typeof repo.name !== 'string' || typeof repo.path !== 'string' || !repo.path) continue;
|
|
1826
|
+
const repoRoot = path.resolve(root, repo.path);
|
|
1827
|
+
if (!exists(repoRoot) || !gitHead(repoRoot)) continue;
|
|
1828
|
+
const r = checkRepo(repoRoot);
|
|
1829
|
+
if (!r.git) continue;
|
|
1830
|
+
const id = `risk-map:${repo.name}`;
|
|
1831
|
+
if (!r.map) {
|
|
1832
|
+
check(checks, id, 'risk-map', 'ok', `${repo.name}: no ${RISK_MAP_FILE} yet — no directory has a risk level (\`yad risk-map draft ${repo.name}\` starts one)`);
|
|
1833
|
+
continue;
|
|
1834
|
+
}
|
|
1835
|
+
if (!r.findings.length) { check(checks, id, 'risk-map', 'ok', `${repo.name}: every directory has a confirmed level`); continue; }
|
|
1836
|
+
const groups = [];
|
|
1837
|
+
for (const f of r.findings) {
|
|
1838
|
+
let g = groups.find((x) => x.code === f.code);
|
|
1839
|
+
if (!g) { g = { code: f.code, targets: [] }; groups.push(g); }
|
|
1840
|
+
if (f.target) g.targets.push(f.target);
|
|
1841
|
+
}
|
|
1842
|
+
const said = groups.map((g) => {
|
|
1843
|
+
const shown = g.targets.slice(0, 3).join(', ') + (g.targets.length > 3 ? ` +${g.targets.length - 3} more` : '');
|
|
1844
|
+
return `${g.code}${shown ? ` (${shown})` : ''}`;
|
|
1845
|
+
}).join('; ');
|
|
1846
|
+
check(checks, id, 'risk-map', 'warn', `${repo.name}: ${RISK_MAP_FILE} is out of date — ${said}`,
|
|
1847
|
+
`\`yad risk-map check ${repo.name}\` lists each one; fix the map in ${repo.name} through a PR (advisory — it blocks nothing)`,
|
|
1848
|
+
{ findings: r.findings });
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// E69 — a stale CODEOWNERS, per connected repo on disk. FACTS only (a line that matches no file, a file
|
|
1853
|
+
// the platform never reads or will not load, a line yad cannot read): the "owner seems inactive" hint is
|
|
1854
|
+
// a guess, and it prints only in `yad codeowners check`, never here. Advisory — a warning, never a
|
|
1855
|
+
// failure: CODEOWNERS is a hint (Part 3). No file at all is a note; a repo with no review rules at all is
|
|
1856
|
+
// E70's to warn about. A repo not on disk, or not a git repo, is the repos check's to report.
|
|
1857
|
+
export function codeownersChecks(checks, root) {
|
|
1858
|
+
const registry = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
|
|
1859
|
+
const repos = Array.isArray(registry?.repos) ? registry.repos : [];
|
|
1860
|
+
for (const repo of repos) {
|
|
1861
|
+
if (!repo || typeof repo.name !== 'string' || typeof repo.path !== 'string' || !repo.path) continue;
|
|
1862
|
+
const repoRoot = path.resolve(root, repo.path);
|
|
1863
|
+
if (!exists(repoRoot) || !gitHead(repoRoot)) continue;
|
|
1864
|
+
const r = checkCodeowners(repoRoot, { platform: repo.platform || null });
|
|
1865
|
+
if (!r.git) continue;
|
|
1866
|
+
const id = `codeowners:${repo.name}`;
|
|
1867
|
+
const more = `\`yad codeowners check ${repo.name}\``;
|
|
1868
|
+
if (r.unknown) { check(checks, id, 'codeowners', 'warn', `${repo.name}: CODEOWNERS could not be checked — ${r.unknown}`, more); continue; }
|
|
1869
|
+
if (r.none) { check(checks, id, 'codeowners', 'ok', `${repo.name}: no CODEOWNERS — nothing to check`); continue; }
|
|
1870
|
+
const findings = codeownersFindings(r);
|
|
1871
|
+
if (!findings.length) { check(checks, id, 'codeowners', 'ok', `${repo.name}: every ${r.path} line yad can read matches a file`); continue; }
|
|
1872
|
+
const lines = (code) => findings.filter((f) => f.code === code).map((f) => f.line);
|
|
1873
|
+
const listed = (ns) => ns.slice(0, 3).join(', ') + (ns.length > 3 ? ` +${ns.length - 3} more` : '');
|
|
1874
|
+
const said = [];
|
|
1875
|
+
if (r.tooBig) said.push(`${r.path} is 3 MB or more, so GitHub does not load it`);
|
|
1876
|
+
for (const p of r.ignored) said.push(`${p} is never read (${r.path} is read first)`);
|
|
1877
|
+
const dead = lines('matches-nothing');
|
|
1878
|
+
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)})`);
|
|
1879
|
+
const unread = lines('not-read');
|
|
1880
|
+
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)})`);
|
|
1881
|
+
check(checks, id, 'codeowners', 'warn', `${repo.name}: CODEOWNERS may be out of date — ${said.join('; ')}`,
|
|
1882
|
+
`${more} lists each one; fix it in ${repo.name} through a PR (advisory — CODEOWNERS is a hint, and yad never enforces it)`,
|
|
1883
|
+
{ findings });
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// E70 — what the platform holds on the Product hub's branch and on each connected repo's: branch
|
|
1888
|
+
// protection, and whether a rule requires an approval before a merge (cli/protection.mjs). Never quiet
|
|
1889
|
+
// (rule 6): every repo gets a line, and a read that failed says "not known" and why — never "fine", never
|
|
1890
|
+
// "unprotected". Advisory, like E69: a warning at most, never a failure — the platform holds a merge, not
|
|
1891
|
+
// yad. Solo mode prints the facts as `ok` (the user's decision, 2026-09-22), except a required approval,
|
|
1892
|
+
// which blocks the solo developer's own merge. The branch is the one yad's files name (`default_branch`),
|
|
1893
|
+
// the branch gates merge into; with none, the platform's own default is read and the line says so.
|
|
1894
|
+
export function protectionChecks(checks, root, { runner, env } = {}) {
|
|
1895
|
+
const productPath = productConfigPath(root);
|
|
1896
|
+
const hub = readJSON(productPath, null);
|
|
1897
|
+
const solo = isSolo(hub);
|
|
1898
|
+
// One login check per host for this run, and never longer: a login can change between runs.
|
|
1899
|
+
const opts = { ...(runner ? { runner } : {}), ...(env ? { env } : {}), authCache: new Map() };
|
|
1900
|
+
const origin = (cwd) => run('git', ['remote', 'get-url', 'origin'], { cwd }).stdout || null;
|
|
1901
|
+
const emit = (id, name, target) => {
|
|
1902
|
+
const r = readProtection(target, opts);
|
|
1903
|
+
const line = protectionLine(r, { name, solo });
|
|
1904
|
+
check(checks, id, 'protection', line.status, line.message, line.hint || '', { protection: protectionJSON(r), alwaysHint: true });
|
|
1905
|
+
};
|
|
1906
|
+
if (isPlainObject(hub)) {
|
|
1907
|
+
// `protection` alone, never `protection:hub`: a connected repo may be named `hub`.
|
|
1908
|
+
emit('protection', 'Product hub', {
|
|
1909
|
+
platform: hub.platform || null,
|
|
1910
|
+
gitUrl: hub.git_url || origin(root),
|
|
1911
|
+
branch: typeof hub.default_branch === 'string' && hub.default_branch ? hub.default_branch : null,
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
const registry = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
|
|
1915
|
+
const repos = Array.isArray(registry?.repos) ? registry.repos : [];
|
|
1916
|
+
for (const [i, repo] of repos.entries()) {
|
|
1917
|
+
if (!repo || typeof repo.name !== 'string' || !repo.name) continue;
|
|
1918
|
+
const repoRoot = typeof repo.path === 'string' && repo.path ? path.resolve(root, repo.path) : null;
|
|
1919
|
+
const onDisk = repoRoot && exists(repoRoot) && gitHead(repoRoot);
|
|
1920
|
+
// A name with an `@` is hidden in the id too; its place in repos.json keeps the id one word. Nothing
|
|
1921
|
+
// validates a repo's name, so a repo literally named `#1` could take the same id — it only labels a line.
|
|
1922
|
+
emit(hideAddresses(repo.name) === repo.name ? `protection:${repo.name}` : `protection:#${i + 1}`, repo.name, {
|
|
1923
|
+
platform: repo.platform || null,
|
|
1924
|
+
gitUrl: (typeof repo.git_url === 'string' && repo.git_url) || (onDisk ? origin(repoRoot) : null),
|
|
1925
|
+
branch: typeof repo.default_branch === 'string' && repo.default_branch ? repo.default_branch : null,
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
619
1930
|
// Phase 6 — feature-thread integrity. A change-epic must thread to a real parent and its denormalized
|
|
620
1931
|
// `thread` cache must equal the computed root; an open hotfix reconcile-debt is a warn (the next change
|
|
621
1932
|
// on that thread is blocked at the gate until it is paid). Pure reporting, like the other sections.
|
|
@@ -626,13 +1937,15 @@ export function threadChecks(checks, root) {
|
|
|
626
1937
|
if (!fs.statSync(path.join(epicsDir, e)).isDirectory() || !isValidEpicId(e)) continue;
|
|
627
1938
|
if (!exists(path.join(epicsDir, e, 'epic.md'))) continue;
|
|
628
1939
|
const lin = epicLineage(root, e);
|
|
629
|
-
|
|
1940
|
+
// A genesis type with no parent has no lineage to check. `chore` joins `feature` here from
|
|
1941
|
+
// shape 5 on: upkeep often has no feature to hang off (see isGenesisType).
|
|
1942
|
+
if (isGenesisType(lin.type) && !lin.parent) continue;
|
|
630
1943
|
const { broken } = resolveThread(root, e);
|
|
631
1944
|
if (broken) {
|
|
632
1945
|
check(checks, `thread:${e}`, 'threads', 'fail', `${e}: ${broken}`,
|
|
633
1946
|
'a change-epic must thread to a real parent; fix `parent:`/`thread:` in epic.md frontmatter');
|
|
634
1947
|
} else {
|
|
635
|
-
check(checks, `thread:${e}`, 'threads', 'ok', `${e}: ${lin.
|
|
1948
|
+
check(checks, `thread:${e}`, 'threads', 'ok', `${e}: ${lin.type} threaded to ${lin.thread || lin.parent}`);
|
|
636
1949
|
}
|
|
637
1950
|
for (const d of loadDebt(root, e)) {
|
|
638
1951
|
if (d.status === 'open') {
|
|
@@ -644,35 +1957,97 @@ export function threadChecks(checks, root) {
|
|
|
644
1957
|
}
|
|
645
1958
|
}
|
|
646
1959
|
|
|
1960
|
+
// E19 — is the Product index (`.sdlc/index.json`) what its work items say today? Its OWN section: the
|
|
1961
|
+
// golden freezes `epics` and `threads`, and this is the one line that makes the index's correctness
|
|
1962
|
+
// visible, since nothing else in this release reads it. A warning, never a failure: the index is derived,
|
|
1963
|
+
// and nothing is lost while it is behind. The hint turns on who may write it — a person on a local
|
|
1964
|
+
// Product, CI alone on a verified one — so each of the four answers is said both ways.
|
|
1965
|
+
export function indexChecks(checks, root) {
|
|
1966
|
+
if (!exists(productConfigPath(root))) return;
|
|
1967
|
+
const fresh = indexFreshness(root);
|
|
1968
|
+
if (fresh.state === 'none') return; // no work items and no index: nothing to be behind
|
|
1969
|
+
const hubNow = readJSON(productConfigPath(root), null);
|
|
1970
|
+
const verified = isVerifiedLedger(hubNow);
|
|
1971
|
+
const hint = verified
|
|
1972
|
+
? 'CI rebuilds it when it records the next merged review; on a verified Product a local write could not be committed'
|
|
1973
|
+
: 'run `yad index` on the default branch, then commit it';
|
|
1974
|
+
if (fresh.state === 'current') {
|
|
1975
|
+
check(checks, 'index', 'index', 'ok', `${INDEX_FILE} is current`);
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
// Off the default branch a difference is EXPECTED: the index is written there only, so a branch that
|
|
1979
|
+
// changes a work item always differs from it until the work merges. Saying "behind — run `yad index`"
|
|
1980
|
+
// here would point at the one act the rule forbids (E19 review). An unreadable file is still a finding.
|
|
1981
|
+
if (fresh.state !== 'unreadable' && exists(path.join(root, '.git'))) {
|
|
1982
|
+
const git = productGit(root);
|
|
1983
|
+
const head = git('rev-parse', '--abbrev-ref', 'HEAD');
|
|
1984
|
+
const main = resolveDefaultBranch(git, hubNow);
|
|
1985
|
+
if (head.ok && head.stdout && head.stdout !== main) {
|
|
1986
|
+
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`);
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
const message = fresh.state === 'missing'
|
|
1991
|
+
? `${INDEX_FILE} has not been built yet`
|
|
1992
|
+
: fresh.state === 'behind'
|
|
1993
|
+
// The fact the read proved, and no cause: `yad epic new`, `yad skip`, a skill that still writes
|
|
1994
|
+
// state.json by hand (E17b), a branch merged in, or a yadflow that builds a different summary
|
|
1995
|
+
// (`INDEX_FORMAT`, E111 review) all do it — so it never says the work items changed.
|
|
1996
|
+
? `${INDEX_FILE} is behind: it is not what this yadflow builds from the work items on disk`
|
|
1997
|
+
: `${INDEX_FILE} cannot be read — ${fresh.why}`;
|
|
1998
|
+
check(checks, 'index', 'index', 'warn', message, hint);
|
|
1999
|
+
}
|
|
2000
|
+
|
|
647
2001
|
// Run every check section and return the diagnostic object without printing. The shared core of
|
|
648
2002
|
// `runDoctor`, and the same shape `--json` prints. Checks carry names and paths, so anything that
|
|
649
2003
|
// leaves the machine must scrub them — `yad report` does NOT consume this; it builds its own
|
|
650
2004
|
// allowlisted subset (cli/report.mjs `sanitizeContext`).
|
|
651
|
-
export function collectDoctor(root) {
|
|
2005
|
+
export function collectDoctor(root, { headCount = null } = {}) {
|
|
652
2006
|
const checks = [];
|
|
653
2007
|
envChecks(checks);
|
|
654
|
-
projectChecks(checks, root);
|
|
2008
|
+
projectChecks(checks, root, { headCount });
|
|
2009
|
+
riskMapChecks(checks, root);
|
|
2010
|
+
codeownersChecks(checks, root);
|
|
2011
|
+
protectionChecks(checks, root);
|
|
2012
|
+
foundationChecks(checks, root);
|
|
655
2013
|
shapeChecks(checks, root);
|
|
2014
|
+
indexChecks(checks, root);
|
|
2015
|
+
mirrorChecks(checks, root);
|
|
2016
|
+
dialChecks(checks, root);
|
|
2017
|
+
automationChecks(checks, root);
|
|
2018
|
+
legacyModuleChecks(checks, root);
|
|
2019
|
+
typeChecks(checks, root);
|
|
2020
|
+
themeChecks(checks, root);
|
|
2021
|
+
catalogueChecks(checks, root);
|
|
2022
|
+
profileChecks(checks, root);
|
|
2023
|
+
skipChecks(checks, root);
|
|
2024
|
+
stepStateChecks(checks, root);
|
|
2025
|
+
phaseChecks(checks, root);
|
|
2026
|
+
laneChecks(checks, root);
|
|
656
2027
|
epicChecks(checks, root);
|
|
657
2028
|
threadChecks(checks, root);
|
|
658
2029
|
const failed = checks.filter((x) => x.status === 'fail');
|
|
659
2030
|
return { version: VERSION, ok: failed.length === 0, checks };
|
|
660
2031
|
}
|
|
661
2032
|
|
|
662
|
-
export async function runDoctor(root, { json = false } = {}) {
|
|
663
|
-
const { checks } = collectDoctor(root);
|
|
2033
|
+
export async function runDoctor(root, { json = false, headCount = null } = {}) {
|
|
2034
|
+
const { checks } = collectDoctor(root, { headCount });
|
|
664
2035
|
|
|
665
2036
|
const failed = checks.filter((x) => x.status === 'fail');
|
|
666
2037
|
const warned = checks.filter((x) => x.status === 'warn');
|
|
667
2038
|
if (json) {
|
|
668
|
-
|
|
2039
|
+
// `alwaysHint` tells the printer below to show a hint on an `ok` line; it is not part of the shape a
|
|
2040
|
+
// script reads, so it does not travel in `--json`.
|
|
2041
|
+
emitJSON({ ok: failed.length === 0, checks: checks.map((c) => { const out = { ...c }; delete out.alwaysHint; return out; }) });
|
|
669
2042
|
} else {
|
|
670
2043
|
log(c.bold(`\nyad doctor ${c.dim('v' + VERSION)}`));
|
|
671
2044
|
let section = '';
|
|
672
2045
|
for (const x of checks) {
|
|
673
2046
|
if (x.section !== section) { section = x.section; log(`\n ${c.bold(section)}`); }
|
|
674
2047
|
({ ok, warn, fail })[x.status](x.message);
|
|
675
|
-
|
|
2048
|
+
// A check may ask for its hint whatever its level (`alwaysHint`): an E70 `protection` line is `ok` in
|
|
2049
|
+
// solo mode even when the platform could not be read, and its hint is the fix ("run `gh auth login` …").
|
|
2050
|
+
if (x.hint && (x.status !== 'ok' || x.alwaysHint)) hand(x.hint);
|
|
676
2051
|
}
|
|
677
2052
|
log('');
|
|
678
2053
|
if (failed.length) fail(`${failed.length} problem(s) found`);
|