yadflow 4.0.0-next.3 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +699 -0
- package/README.md +85 -22
- package/bin/commands.mjs +3 -1
- package/bin/yad.mjs +183 -29
- package/cli/capture.mjs +4 -2
- package/cli/checkpoint.mjs +10 -10
- package/cli/claims.mjs +4 -4
- package/cli/commit.mjs +48 -12
- package/cli/docs.mjs +15 -13
- package/cli/doctor.mjs +557 -141
- package/cli/epic-state.mjs +41 -18
- package/cli/errors.mjs +3 -2
- package/cli/find-product.mjs +197 -0
- package/cli/fold.mjs +4 -4
- package/cli/gate-local.mjs +13 -13
- package/cli/gate.mjs +111 -103
- package/cli/history.mjs +13 -13
- package/cli/hook.mjs +201 -30
- package/cli/index-command.mjs +6 -6
- package/cli/lib.mjs +91 -29
- package/cli/manifest.mjs +171 -104
- package/cli/migrate.mjs +208 -17
- package/cli/mode.mjs +22 -22
- package/cli/next.mjs +6 -6
- package/cli/openpr.mjs +32 -24
- package/cli/people.mjs +22 -10
- package/cli/plan.mjs +718 -45
- package/cli/platform.mjs +13 -7
- package/cli/product-index.mjs +4 -4
- package/cli/{hubcommit.mjs → productcommit.mjs} +11 -7
- package/cli/protection.mjs +19 -9
- package/cli/reconcile.mjs +98 -23
- package/cli/repo-publish.mjs +22 -16
- package/cli/repo.mjs +128 -33
- package/cli/report.mjs +4 -2
- package/cli/review.mjs +26 -19
- package/cli/setup.mjs +105 -36
- package/cli/ship.mjs +1 -1
- package/cli/skip.mjs +4 -4
- package/cli/tidy.mjs +7 -7
- package/cli/update-commit.mjs +22 -19
- package/cli/workspace.mjs +495 -0
- package/package.json +7 -4
- package/skills/sdlc/config.yaml +14 -13
- package/skills/sdlc/module-help.csv +8 -8
- package/skills/yad-analysis/SKILL.md +6 -4
- package/skills/yad-architecture/SKILL.md +4 -3
- package/skills/yad-backfill/SKILL.md +11 -5
- package/skills/yad-backfill/references/backfill.md +33 -3
- package/skills/yad-backfill/templates/checks/backfill-check.sh +200 -17
- package/skills/yad-change/SKILL.md +1 -1
- package/skills/yad-checks/SKILL.md +48 -27
- package/skills/yad-checks/references/check-gates.md +213 -53
- package/skills/yad-checks/templates/checks/commit-message.sh +39 -13
- package/skills/yad-checks/templates/checks/contract-check.sh +472 -23
- package/skills/yad-checks/templates/checks/epic-open.sh +398 -8
- package/skills/yad-checks/templates/checks/ledger-guard.sh +75 -25
- package/skills/yad-checks/templates/checks/lineage-check.sh +391 -7
- package/skills/yad-checks/templates/checks/product-checkout.sh +141 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +399 -8
- package/skills/yad-checks/templates/checks/risk-map-check.sh +35 -3
- package/skills/yad-checks/templates/checks/spec-link.sh +36 -3
- package/skills/yad-checks/templates/checks/verified-commits.sh +26 -2
- package/skills/yad-checks/templates/github/yad-checks.yml +33 -13
- package/skills/yad-checks/templates/github/{yad-hub-checks.yml → yad-product-checks.yml} +14 -10
- package/skills/yad-checks/templates/github/yad-update-guard.yml +1 -1
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +1 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +12 -0
- package/skills/yad-checks/templates/gitlab/{yad-hub-checks.gitlab-ci.yml → yad-product-checks.gitlab-ci.yml} +16 -16
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.mjs +130 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.mjs +117 -0
- package/skills/yad-checks/templates/hooks/yad-capture.mjs +117 -0
- package/skills/yad-commit/SKILL.md +18 -7
- package/skills/yad-connect-design/SKILL.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +1 -1
- package/skills/yad-connect-docs/SKILL.md +16 -11
- package/skills/yad-connect-docs/references/docs-registry.md +8 -8
- package/skills/yad-connect-learning/SKILL.md +1 -1
- package/skills/yad-connect-learning/references/learning-registry.md +1 -1
- package/skills/yad-connect-repos/SKILL.md +31 -8
- package/skills/yad-connect-repos/references/code-context.md +1 -1
- package/skills/yad-connect-repos/references/{hub-config.md → product-config.md} +19 -11
- package/skills/yad-connect-repos/references/repos-registry.md +1 -1
- package/skills/yad-connect-testing/SKILL.md +1 -1
- package/skills/yad-connect-testing/references/testing-registry.md +1 -1
- package/skills/yad-discovery/SKILL.md +2 -2
- package/skills/yad-discovery/references/discovery-schema.md +1 -1
- package/skills/yad-docs/SKILL.md +1 -1
- package/skills/yad-docs/references/data-mapping.md +1 -1
- package/skills/yad-docs/templates/app/package-lock.json +8 -8
- package/skills/yad-docs/templates/app/package.json +1 -1
- package/skills/yad-docs/templates/app/src/components/Canvas/FlowCanvas.tsx +2 -2
- package/skills/yad-docs-overview/references/pipeline-model.md +3 -3
- package/skills/yad-engineer-review/SKILL.md +2 -2
- package/skills/yad-engineer-review/references/ship-and-record.md +3 -3
- package/skills/yad-epic/SKILL.md +6 -4
- package/skills/yad-epic/references/state-schema.md +13 -12
- package/skills/yad-open-pr/SKILL.md +1 -1
- package/skills/yad-pair-review/SKILL.md +5 -5
- package/skills/yad-pair-review/references/session-state.md +2 -2
- package/skills/yad-pr-template/SKILL.md +14 -12
- package/skills/yad-pr-template/templates/checks/pr-template.sh +18 -16
- package/skills/yad-pr-template/templates/checks/pr-title.sh +15 -13
- package/skills/yad-pr-template/templates/{hub → product}/github/pull_request_template.md +2 -2
- package/skills/yad-pr-template/templates/{hub → product}/gitlab/merge_request_templates/Default.md +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/SKILL.md +15 -15
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/bridge.md +15 -10
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/login-roster.md +2 -2
- package/skills/{yad-hub-bridge/templates/checks/hub-route.sh → yad-product-bridge/templates/checks/product-route.sh} +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/github/yad-gate-sync.yml +29 -16
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/gitlab-ci.include-root.yml +1 -1
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/yad-gate-sync.gitlab-ci.yml +5 -3
- package/skills/yad-report/SKILL.md +1 -1
- package/skills/yad-review-companion/SKILL.md +3 -3
- package/skills/yad-review-gate/SKILL.md +22 -23
- package/skills/yad-review-gate/references/gating.md +13 -13
- package/skills/yad-run/SKILL.md +2 -2
- package/skills/yad-run/references/run-loop.md +2 -2
- package/skills/yad-spec/SKILL.md +2 -1
- package/skills/yad-spec/references/spec-handoff.md +19 -10
- package/skills/yad-stories/SKILL.md +2 -2
- package/skills/yad-sync-repos/SKILL.md +19 -3
- package/skills/yad-test-cases/SKILL.md +2 -2
- package/skills/yad-ui/SKILL.md +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +0 -91
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +0 -100
- package/skills/yad-checks/templates/hooks/yad-capture.sh +0 -53
package/cli/platform.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Platform adapter — the ONLY place that shells out to gh/glab. Read recipes mirror
|
|
2
|
-
// skills/yad-
|
|
2
|
+
// skills/yad-product-bridge/references/bridge.md. Everything runs as the local user (gh/glab own auth);
|
|
3
3
|
// no tokens are stored. Pure mapping fns (resolveLogin/mapApprovers) are exported for unit tests;
|
|
4
4
|
// readPr is injectable so the gate can be tested with a fake.
|
|
5
5
|
import { URLSearchParams } from 'node:url';
|
|
@@ -22,6 +22,12 @@ export function cliFor(platform) {
|
|
|
22
22
|
// Bare host from a git remote URL, for hostname-scoped CLI auth checks. Handles both the
|
|
23
23
|
// `https://[user@]host[:port]/...` and the scp-like `git@host:group/repo.git` forms. Returns
|
|
24
24
|
// null when nothing parses (caller falls back to an unscoped check).
|
|
25
|
+
// A host yad may use in a CLI call or print — inside `gh auth login --hostname <host>`, say — or null. The
|
|
26
|
+
// host comes from shared files (hub.json, repos.json) or a clone's remote, and `hostFromGitUrl` passes
|
|
27
|
+
// `$(curl …|sh)` or an escape through, so a host must be a plain host name or a bracketed IPv6 address.
|
|
28
|
+
// ONE test for every caller (E81 reviews 13–14: the second copy was missed once).
|
|
29
|
+
export const plainHost = (h) => (typeof h === 'string' && /^(?:\w[\w-]*(?:\.[\w-]+)*|\[[0-9a-f:.]+\])$/i.test(h) ? h : null);
|
|
30
|
+
|
|
25
31
|
export function hostFromGitUrl(url = '') {
|
|
26
32
|
if (typeof url !== 'string' || !url.trim()) return null;
|
|
27
33
|
const u = url.trim();
|
|
@@ -97,9 +103,9 @@ export function actorName(cwd, platform, opts = {}) {
|
|
|
97
103
|
// logins that share it. An older record under such a
|
|
98
104
|
// name could be either person, so it is never matched by name — only as `upsertBridge` matches a record
|
|
99
105
|
// no name can place (an exact submission time, or an open step's one-to-one).
|
|
100
|
-
export function ambiguousLegacyNames(
|
|
106
|
+
export function ambiguousLegacyNames(productConfig) {
|
|
101
107
|
const logins = new Map();
|
|
102
|
-
for (const e of Array.isArray(
|
|
108
|
+
for (const e of Array.isArray(productConfig?.roster) ? productConfig.roster : []) {
|
|
103
109
|
if (!e || typeof e.name !== 'string' || !e.name || typeof e.login !== 'string' || !e.login) continue;
|
|
104
110
|
if (!logins.has(e.name)) logins.set(e.name, new Set());
|
|
105
111
|
logins.get(e.name).add(e.login);
|
|
@@ -397,9 +403,9 @@ export function platformDefaultBranch(platform, { cwd, runner = run } = {}) {
|
|
|
397
403
|
// though only this chain has a platform rung — they stop at the local `origin/HEAD`:
|
|
398
404
|
// 1 flag — an explicit --base; the human said so
|
|
399
405
|
// 2 registry — the repo's `default_branch` in .sdlc/repos.json
|
|
400
|
-
// 3
|
|
406
|
+
// 3 product — the Product settings' `default_branch`, for a PR against the Product itself
|
|
401
407
|
// 4 platform — what the remote says (see platformDefaultBranch)
|
|
402
|
-
// 5 origin-head — local `refs/remotes/origin/HEAD`, the same read repo.mjs/
|
|
408
|
+
// 5 origin-head — local `refs/remotes/origin/HEAD`, the same read repo.mjs/productcommit.mjs use.
|
|
403
409
|
// Deliberately NOT `ls-remote`: see branchExists above for why a network probe on
|
|
404
410
|
// this path is a hang hazard.
|
|
405
411
|
// 6 fallback — 'main'
|
|
@@ -415,7 +421,7 @@ export function platformDefaultBranch(platform, { cwd, runner = run } = {}) {
|
|
|
415
421
|
// and an early rung winning, `platformDefault` is null because it was never asked, not because the
|
|
416
422
|
// platform had no answer.
|
|
417
423
|
export function resolveBaseBranch(platform, {
|
|
418
|
-
cwd, explicit = null, meta = null,
|
|
424
|
+
cwd, explicit = null, meta = null, productConfig = null, runner = run, probe = true,
|
|
419
425
|
} = {}) {
|
|
420
426
|
let platformDefault = null;
|
|
421
427
|
let asked = false;
|
|
@@ -434,7 +440,7 @@ export function resolveBaseBranch(platform, {
|
|
|
434
440
|
const chain = [
|
|
435
441
|
['flag', explicit],
|
|
436
442
|
['registry', meta?.default_branch],
|
|
437
|
-
['
|
|
443
|
+
['product', productConfig?.default_branch],
|
|
438
444
|
['platform', askPlatform],
|
|
439
445
|
['origin-head', originHead],
|
|
440
446
|
];
|
package/cli/product-index.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { createHash } from 'node:crypto';
|
|
|
24
24
|
import { spawnSync } from 'node:child_process';
|
|
25
25
|
import { isPlainObject, writeJSON, info, warn } from './lib.mjs';
|
|
26
26
|
import { isVerifiedLedger } from './manifest.mjs';
|
|
27
|
-
import { productGit, resolveDefaultBranch } from './
|
|
27
|
+
import { productGit, resolveDefaultBranch } from './productcommit.mjs';
|
|
28
28
|
import {
|
|
29
29
|
epicIds, epicRel, epicRoot, unlistedLedgerDirs, parseFrontmatter, lineageFrom, stepStatus, STEP_STATES,
|
|
30
30
|
FOUNDATION_EPIC, FOUNDATION_TITLE, titleOf,
|
|
@@ -270,11 +270,11 @@ export function uncommittedIndexInputs(root, commits = null) {
|
|
|
270
270
|
// The index is derived: a failure to rebuild it is said, and never stops the gate write it follows.
|
|
271
271
|
// Returns whether the file changed, so a caller that commits can carry it in the same commit. `quiet`
|
|
272
272
|
// for a caller whose stdout is JSON: the warning still goes to stderr.
|
|
273
|
-
export function refreshIndexAfterWrite(root,
|
|
274
|
-
if (isVerifiedLedger(
|
|
273
|
+
export function refreshIndexAfterWrite(root, productConfig, { quiet = false } = {}) {
|
|
274
|
+
if (isVerifiedLedger(productConfig) || !fs.existsSync(path.join(root, '.git'))) return false;
|
|
275
275
|
const git = productGit(root);
|
|
276
276
|
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
|
|
277
|
-
if (!branch || branch !== resolveDefaultBranch(git,
|
|
277
|
+
if (!branch || branch !== resolveDefaultBranch(git, productConfig)) return false;
|
|
278
278
|
try {
|
|
279
279
|
const changed = writeIndex(root);
|
|
280
280
|
if (changed && !quiet) info(`rebuilt ${INDEX_FILE}`);
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// `yad checkpoint` (sync new state) and `yad tidy up` (fold finished shards). Both must commit ONLY on
|
|
3
3
|
// the default branch, so their `[skip ci]` commit never enters a PR's base..HEAD range (where it would
|
|
4
4
|
// strand required checks and fail verified-commits). This module is the single home of that guard.
|
|
5
|
-
import { warn, fail, hand, run } from './lib.mjs';
|
|
5
|
+
import { warn, fail, hand, run, forTerminal } from './lib.mjs';
|
|
6
|
+
import { SETTINGS_EDIT_HINT, settingsEditHint } from './manifest.mjs';
|
|
6
7
|
|
|
7
8
|
export const productGit = (root) => (...args) => run('git', args, { cwd: root });
|
|
8
9
|
|
|
@@ -22,9 +23,9 @@ export function preflightGuardReadiness(root, why = 'the yad-update-guard requir
|
|
|
22
23
|
// NEVER the current branch — falling back to it (as gate.mjs does, safe there because CI checks out the
|
|
23
24
|
// default branch) would make the guard below a no-op on a WIP branch and let an unsigned commit land in
|
|
24
25
|
// a future PR's range.
|
|
25
|
-
export function resolveDefaultBranch(git,
|
|
26
|
+
export function resolveDefaultBranch(git, productConfig) {
|
|
26
27
|
const originHead = git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD'); // e.g. "origin/main"
|
|
27
|
-
return
|
|
28
|
+
return productConfig?.default_branch
|
|
28
29
|
|| (originHead.ok && originHead.stdout ? originHead.stdout.replace(/^origin\//, '') : '')
|
|
29
30
|
|| 'main';
|
|
30
31
|
}
|
|
@@ -32,14 +33,17 @@ export function resolveDefaultBranch(git, hub) {
|
|
|
32
33
|
// Guard: only commit on the default branch. Returns true when OK; on a non-default branch it prints the
|
|
33
34
|
// refusal + sets a non-zero exit code, unless `allowBranch` overrides (with a warning). `--allow-branch`
|
|
34
35
|
// is the SINGLE documented override — never `--force` (which elsewhere only waives the atomic guard).
|
|
35
|
-
|
|
36
|
+
// `root` (the Product) lets the hint name the exact settings file to edit; without it the hint says it in words.
|
|
37
|
+
export function guardDefaultBranch(branch, defaultBranch, { allowBranch = false, cmd = 'yad checkpoint', root = null } = {}) {
|
|
36
38
|
if (branch === defaultBranch) return true;
|
|
39
|
+
// Printed cleaned (E81 review 13): `default_branch` comes from the Product's shared hub.json.
|
|
40
|
+
const [b, d] = [forTerminal(branch), forTerminal(defaultBranch)];
|
|
37
41
|
if (allowBranch) {
|
|
38
|
-
warn(`--allow-branch: committing on '${
|
|
42
|
+
warn(`--allow-branch: committing on '${b}' — pushes go to origin/${b}, and this commit needs a verified signature to pass the gate in a PR`);
|
|
39
43
|
return true;
|
|
40
44
|
}
|
|
41
|
-
fail(`on '${
|
|
42
|
-
hand(`switch to '${
|
|
45
|
+
fail(`on '${b}', not the default branch '${d}' — ${cmd} commits go to the default branch to stay out of PR-checked ranges`);
|
|
46
|
+
hand(`switch to '${d}' and re-run, or pass --allow-branch to override (if '${d}' is wrong, set default_branch in ${root ? settingsEditHint(root) : SETTINGS_EDIT_HINT})`);
|
|
43
47
|
process.exitCode = 1;
|
|
44
48
|
return false;
|
|
45
49
|
}
|
package/cli/protection.mjs
CHANGED
|
@@ -23,15 +23,18 @@
|
|
|
23
23
|
// leaves the machine for anywhere else). `YAD_PLATFORM_READ=0` turns the reads off — for offline work, and
|
|
24
24
|
// for the test suite, which must never ask the developer's real account (the `YAD_PLATFORM_LOGIN=0`
|
|
25
25
|
// precedent). `runner` is injectable so every answer is testable without a network.
|
|
26
|
-
import { run } from './lib.mjs';
|
|
27
|
-
import {
|
|
26
|
+
import { run, forTerminal } from './lib.mjs';
|
|
27
|
+
import { SETTINGS_EDIT_HINT } from './manifest.mjs';
|
|
28
|
+
import { cliFor, hostFromGitUrl, detectPlatform, plainHost } from './platform.mjs';
|
|
28
29
|
|
|
29
30
|
export const PLATFORM_NAME = { github: 'GitHub', gitlab: 'GitLab' };
|
|
30
31
|
const TIMEOUT = 10_000;
|
|
31
32
|
|
|
32
33
|
// A word with an `@` after its first character may be an e-mail address, and no address is ever printed
|
|
33
34
|
// (E67). A branch or a repo path is printed as written otherwise.
|
|
34
|
-
|
|
35
|
+
// Also cleaned for the terminal (E81 review 12): a branch or repo from the shared registry could hold a
|
|
36
|
+
// control character, and every line here prints through this.
|
|
37
|
+
export const shown = (w) => (typeof w === 'string' && w.indexOf('@', 1) >= 0 ? 'a name with an @ in it' : forTerminal(`${w}`));
|
|
35
38
|
|
|
36
39
|
// The repo's path on its host (`owner/repo`, or `group/sub/project` on GitLab), from a git remote URL in
|
|
37
40
|
// either form. Not the `repoSlug` the doctor used to carry, which kept only the last two parts and so named
|
|
@@ -165,7 +168,11 @@ function loggedIn(runner, cli, host, authCache) {
|
|
|
165
168
|
// a GitLab answer carries no such key)
|
|
166
169
|
// `kind` names the reason for a hint, so the hint never depends on the words of `why`.
|
|
167
170
|
export function readProtection({ platform, gitUrl, branch = null, branchFrom = 'registry' } = {}, { runner = run, env = process.env, authCache = new Map() } = {}) {
|
|
168
|
-
|
|
171
|
+
// A host that is not a plain host name is never used or repeated (E81 review 13): it comes from the
|
|
172
|
+
// shared registry's `git_url` (or a registered clone's own remote), and it is printed inside
|
|
173
|
+
// `gh auth login --hostname …` for the person to run — `$(curl …|sh)` parses as a host.
|
|
174
|
+
const rawHost = hostFromGitUrl(gitUrl || '');
|
|
175
|
+
const host = plainHost(rawHost);
|
|
169
176
|
const repo = repoPathFromGitUrl(gitUrl || '');
|
|
170
177
|
const plat = platform || detectPlatform(gitUrl || '') || null;
|
|
171
178
|
const base = { platform: plat, host, repo, branch, branchFrom, platformDefault: null };
|
|
@@ -173,10 +180,11 @@ export function readProtection({ platform, gitUrl, branch = null, branchFrom = '
|
|
|
173
180
|
const cli = cliFor(plat);
|
|
174
181
|
if (!cli) {
|
|
175
182
|
return platform
|
|
176
|
-
? unknown('no-platform', `yad does not know the platform ${JSON.stringify(platform)} (it reads GitHub and GitLab)`)
|
|
183
|
+
? unknown('no-platform', `yad does not know the platform ${JSON.stringify(forTerminal(platform))} (it reads GitHub and GitLab)`)
|
|
177
184
|
: unknown('no-platform', 'no platform (GitHub or GitLab) is set, so there is no platform to ask');
|
|
178
185
|
}
|
|
179
186
|
if (env.YAD_PLATFORM_READ === '0') return unknown('off', 'platform reads are turned off (YAD_PLATFORM_READ=0)');
|
|
187
|
+
if (rawHost && !host) return unknown('no-url', 'the git remote URL names a host that is not a plain host name, so yad does not ask about it', { badHost: true });
|
|
180
188
|
if (!host || !repo) return unknown('no-url', 'no git remote URL yad can read, so it cannot tell which repo to ask about');
|
|
181
189
|
if (!runner(process.platform === 'win32' ? 'where' : 'which', [cli], {}).ok) return unknown('no-cli', `${cli} is not installed, so yad cannot ask ${PLATFORM_NAME[plat]}`);
|
|
182
190
|
if (!loggedIn(runner, cli, host, authCache)) return unknown('no-login', `${cli} is not logged in for ${host} (or ${host} did not answer)`);
|
|
@@ -440,7 +448,7 @@ function readGitLab(base, runner, unknown) {
|
|
|
440
448
|
const named = typeof r.name === 'string' && r.name.trim() ? r.name.trim() : null;
|
|
441
449
|
// A rule NAMED "42" and a rule with ID 42 must not read the same, so an id is printed as an id —
|
|
442
450
|
// the shape the GitHub side already uses ("a repo ruleset (id 7)").
|
|
443
|
-
if (named !== null) out.from.push(`approval rule ${JSON.stringify(named)}`);
|
|
451
|
+
if (named !== null) out.from.push(`approval rule ${JSON.stringify(forTerminal(named))}`);
|
|
444
452
|
else out.from.push(count(r.id) !== null ? `an approval rule (id ${r.id})` : 'an approval rule');
|
|
445
453
|
}
|
|
446
454
|
// A rule the platform DID return that does not reach this branch: say which way it misses, so the
|
|
@@ -488,7 +496,7 @@ function readGitLab(base, runner, unknown) {
|
|
|
488
496
|
}
|
|
489
497
|
|
|
490
498
|
// The doctor line for one answer: { status: 'ok'|'warn', message, hint }. `name` is the repo's name in
|
|
491
|
-
// yad (or "Product
|
|
499
|
+
// yad (or "Product"). In solo mode (the user's decision) the facts print as `ok` with no banner — one
|
|
492
500
|
// person cannot approve their own pull request, so "no approval rules" may be the right setup — except
|
|
493
501
|
// the old solo warning: a required approval there blocks the solo developer's own merge.
|
|
494
502
|
//
|
|
@@ -682,7 +690,9 @@ function unknownHint(r) {
|
|
|
682
690
|
case 'no-cli': return `install ${cli} and log in, then run \`yad doctor\` again`;
|
|
683
691
|
case 'no-login': return `run \`${cli} auth login --hostname ${r.host}\`, then \`yad doctor\` again`;
|
|
684
692
|
case 'no-platform': return 'set `platform` (github or gitlab) in yad\'s files — `yad setup` for the Product, `yad repo connect` for a code repo';
|
|
685
|
-
case 'no-url': return
|
|
693
|
+
case 'no-url': return r.badHost
|
|
694
|
+
? `fix \`git_url\` in the repo's entry in .sdlc/repos.json (for the Product, ${SETTINGS_EDIT_HINT}) — or its origin remote — to name a plain host`
|
|
695
|
+
: `add \`git_url\` to the repo's entry in .sdlc/repos.json (for the Product, ${SETTINGS_EDIT_HINT}), or give the repo an origin remote`;
|
|
686
696
|
// The access tail turns on what the read PROVED (`cause`), never on the platform: a 404 whose body was
|
|
687
697
|
// not recognised leaves a permission open, and one that named the branch closes it (E109).
|
|
688
698
|
// The tail's noun is GitLab's: GitHub never reaches it, because its reader sets `cause: 'branch'` on
|
|
@@ -697,7 +707,7 @@ function unknownHint(r) {
|
|
|
697
707
|
case 'no-flag': return `ask someone who can see ${PLATFORM_NAME[r.platform] || 'the platform'}'s settings for ${shown(r.branch) === r.branch ? `\`${r.branch}\`` : shown(r.branch)}`;
|
|
698
708
|
// One action per open cause (E110): yad's files name no branch, and on GitLab the repository may be
|
|
699
709
|
// unreadable — turned off (an Owner turns it on) or too little access (a Maintainer or Owner grants it).
|
|
700
|
-
case 'no-default': return `set \`default_branch\` in yad's files (.sdlc/repos.json, or
|
|
710
|
+
case 'no-default': return `set \`default_branch\` in yad's files (.sdlc/repos.json, or for the Product ${SETTINGS_EDIT_HINT})${r.defaultMayBeHidden
|
|
701
711
|
? `. GitLab also hides the default from a login that cannot read the repository: ${REPO_ACTIONS} — then run \`yad doctor\` again`
|
|
702
712
|
: ''}`;
|
|
703
713
|
case 'no-repo': return `check \`git_url\` in yad's files, or ask for access to the ${r.platform === 'gitlab' ? 'project' : 'repo'}`;
|
package/cli/reconcile.mjs
CHANGED
|
@@ -4,25 +4,31 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import {
|
|
7
|
-
c, log, ok, info, warn, hand, readJSON, writeJSON, exists,
|
|
7
|
+
c, log, ok, info, warn, hand, readJSON, writeJSON, exists, forTerminal,
|
|
8
8
|
} from './lib.mjs';
|
|
9
9
|
|
|
10
10
|
const readFileSafe = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } };
|
|
11
11
|
|
|
12
|
-
import { preflightGuardReadiness } from './
|
|
13
|
-
import { VERSION, PROJECT_FILES, MANAGED_LEDGER, BACKUP_SUFFIX , productConfigPath } from './manifest.mjs';
|
|
12
|
+
import { preflightGuardReadiness } from './productcommit.mjs';
|
|
13
|
+
import { VERSION, PROJECT_FILES, MANAGED_LEDGER, BACKUP_SUFFIX , productConfigPath, PRODUCT_LINK } from './manifest.mjs';
|
|
14
14
|
import {
|
|
15
15
|
moduleActions, repoActions, productActions, hookActions,
|
|
16
|
-
legacyModuleActions, removedModuleActions, orphanHookActions, captureHookActions, orphanCaptureHookActions, legacyRepoActions,
|
|
17
|
-
ideTargetStateFor, recordManagedWrites,
|
|
16
|
+
legacyModuleActions, removedModuleActions, orphanHookActions, captureHookActions, orphanCaptureHookActions, legacyHookScriptActions, legacyRepoActions, legacyProductActions,
|
|
17
|
+
ideTargetStateFor, recordManagedWrites, gitHookActions, orphanGitHookActions, gitHookState, gitHookAdvice, renamedNameHits, withoutKeptRenames, PRODUCT_PROFILE_GATES, productProfileGap, productProfileEffect, PRODUCT_PROFILE_FIX, workflowsPassingProduct, productGateBlockers, oldProfileAdvice,
|
|
18
18
|
} from './plan.mjs';
|
|
19
19
|
import { gitHead, packRepo } from './setup.mjs';
|
|
20
20
|
import { groupByRoot, commitUpdates, repoLabel } from './update-commit.mjs';
|
|
21
|
+
import { hasSiblingRepo, workspaceFileState, writeWorkspaceFile, WORKSPACE_FILE } from './find-product.mjs';
|
|
21
22
|
|
|
22
23
|
const MARK = { missing: c.red('missing'), new: c.cyan('new'), outdated: c.yellow('outdated'), modified: c.cyan('modified'), stale: c.yellow('stale'), legacy: c.yellow('legacy'), removed: c.yellow('removed'), ok: c.green('ok') };
|
|
23
24
|
|
|
24
25
|
// The --json answer's list (E1): every managed item, as the report groups it — never the apply step.
|
|
25
|
-
|
|
26
|
+
// `product` says whose item it is: false for a connected repo's, true for the Product's own (E124 review 1).
|
|
27
|
+
// The scope alone cannot say it — a repo may be named `product`, the Product's own scope.
|
|
28
|
+
// A repo named `product` shares the Product's scope; in printed text it is shown with its path (E124 review 2).
|
|
29
|
+
const repoShown = (repo) => `product (the code repo at ${forTerminal(repo.path)})`;
|
|
30
|
+
const shown = (a) => a.shownScope ?? a.scope;
|
|
31
|
+
const itemsOf = (actions) => actions.map((a) => ({ scope: a.scope, item: a.item, status: a.status, product: !a.fromRepo }));
|
|
26
32
|
|
|
27
33
|
export async function reconcile(root, { fix = false, scope = 'all', force = false, push = false, allowBranch = false, overwriteLocal = false } = {}) {
|
|
28
34
|
log(c.bold(`\nSDLC reconcile ${c.dim('v' + VERSION)}`));
|
|
@@ -31,7 +37,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
31
37
|
// --- missing one-time setup (needs the interactive wizard) ---
|
|
32
38
|
const gaps = [];
|
|
33
39
|
if (!exists(path.join(root, PROJECT_FILES.version))) gaps.push('module not installed (.sdlc/cli-version.json absent)');
|
|
34
|
-
if (!exists(productConfigPath(root))) gaps.push('
|
|
40
|
+
if (!exists(productConfigPath(root))) gaps.push('Product not configured (.sdlc/product.json absent)');
|
|
35
41
|
const registry = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
|
|
36
42
|
if (!exists(path.join(root, PROJECT_FILES.reposRegistry))) gaps.push('no repos registered (.sdlc/repos.json absent)');
|
|
37
43
|
|
|
@@ -51,14 +57,19 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
51
57
|
// and purge of skills removed in a later release ('removed': delete the lingering install) ---
|
|
52
58
|
const actions = [
|
|
53
59
|
...moduleActions(root, ideTargets), ...legacyModuleActions(root, ideTargets), ...removedModuleActions(root, ideTargets),
|
|
54
|
-
|
|
60
|
+
// E123: a renamed CI file the team edited is kept, and its new name is not installed beside it.
|
|
61
|
+
...withoutKeptRenames([...productActions(root), ...legacyProductActions(root)]), ...hookActions(root, ideTargets),
|
|
55
62
|
...orphanHookActions(root, ideTargets),
|
|
63
|
+
// E48: this clone's git pre-commit hook — the person's half of the ledger guard.
|
|
64
|
+
...gitHookActions(root), ...orphanGitHookActions(root),
|
|
56
65
|
// E43: the post-edit capture hook, in both ledger modes.
|
|
57
66
|
...captureHookActions(root, ideTargets), ...orphanCaptureHookActions(root, ideTargets),
|
|
67
|
+
// E113: the old shell-script hooks. LAST, so the entries above that named them are rewritten first.
|
|
68
|
+
...legacyHookScriptActions(root, ideTargets),
|
|
58
69
|
];
|
|
59
70
|
if (ideState.needsRepair) {
|
|
60
71
|
actions.push({
|
|
61
|
-
scope: '
|
|
72
|
+
scope: 'product',
|
|
62
73
|
item: `${PROJECT_FILES.version} ideTargets`,
|
|
63
74
|
status: 'outdated',
|
|
64
75
|
root,
|
|
@@ -67,7 +78,12 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
67
78
|
apply: () => undefined,
|
|
68
79
|
});
|
|
69
80
|
}
|
|
70
|
-
for (const repo of registry.repos)
|
|
81
|
+
for (const repo of registry.repos) {
|
|
82
|
+
const own = withoutKeptRenames([...repoActions(root, repo), ...legacyRepoActions(root, repo)]);
|
|
83
|
+
// Set on the action itself: later steps compare actions by identity. `true`, not the name, which may be empty.
|
|
84
|
+
for (const a of own) { a.fromRepo = true; if (repo.name === 'product') a.shownScope = repoShown(repo); }
|
|
85
|
+
actions.push(...own);
|
|
86
|
+
}
|
|
71
87
|
|
|
72
88
|
// --- stale code-context (HEAD moved since last pack) ---
|
|
73
89
|
const staleRepos = [];
|
|
@@ -75,12 +91,14 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
75
91
|
const head = gitHead(path.resolve(root, repo.path));
|
|
76
92
|
if (head && repo.syncedHead && head !== repo.syncedHead) {
|
|
77
93
|
staleRepos.push(repo);
|
|
78
|
-
// packRepo writes the repomix cache under the
|
|
94
|
+
// packRepo writes the repomix cache under the Product root (root/repo.contextPack, e.g.
|
|
79
95
|
// .sdlc/code-context/<name>/pack.md), so the touched path belongs to the Product — and is commonly
|
|
80
96
|
// gitignored, in which case the push stage's check-ignore drops it. codeMap is AI-generated
|
|
81
97
|
// later, not here, so only the pack is claimed.
|
|
82
98
|
actions.push({
|
|
83
99
|
scope: repo.name,
|
|
100
|
+
fromRepo: true, // about the repo, though its file is under the Product
|
|
101
|
+
...(repo.name === 'product' ? { shownScope: repoShown(repo) } : {}),
|
|
84
102
|
item: 'code-context',
|
|
85
103
|
status: 'stale',
|
|
86
104
|
root,
|
|
@@ -93,8 +111,8 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
93
111
|
// --- report, grouped by scope ---
|
|
94
112
|
const byScope = new Map();
|
|
95
113
|
for (const a of actions) {
|
|
96
|
-
if (!byScope.has(a
|
|
97
|
-
byScope.get(a
|
|
114
|
+
if (!byScope.has(shown(a))) byScope.set(shown(a), []);
|
|
115
|
+
byScope.get(shown(a)).push(a);
|
|
98
116
|
}
|
|
99
117
|
const counts = { missing: 0, new: 0, outdated: 0, modified: 0, stale: 0, legacy: 0, removed: 0, ok: 0 };
|
|
100
118
|
for (const [scopeName, items] of byScope) {
|
|
@@ -105,6 +123,23 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
105
123
|
for (const i of notOk) log(` ${MARK[i.status]} ${i.item}`);
|
|
106
124
|
}
|
|
107
125
|
for (const g of gaps) warn(g);
|
|
126
|
+
// E123: the team's own files that name one of our renamed CI names — each with its file and line, the same
|
|
127
|
+
// list `yad doctor` shows. yad never edits them; the include line it rewrites is said as what it is.
|
|
128
|
+
// After this run: a gate it replaces handles `product`; one it keeps (edited) may not (E124 review 3).
|
|
129
|
+
// No `drift` here on purpose: under drift the dispatcher refuses `yad check`/`update` before this runs.
|
|
130
|
+
const profileFirst = oldProfileAdvice(productGateBlockers(root, { fix, overwriteLocal }));
|
|
131
|
+
for (const h of renamedNameHits(root)) {
|
|
132
|
+
if (h.rewrittenBy === 'update' || (h.rewrittenBy === 'overwrite-local' && overwriteLocal)) {
|
|
133
|
+
info(`${h.file}:${h.line} includes the old fragment — ${fix ? 'rewritten' : `rewritten by ${h.rewrittenBy === 'update' ? '`yad check --fix`/`yad update`' : '`yad update --overwrite-local`'}`} to name ${h.new}`);
|
|
134
|
+
} else if (h.rewrittenBy === 'overwrite-local') {
|
|
135
|
+
info(`${h.file}:${h.line} includes the edited old fragment — \`yad update --overwrite-local\` rewrites it when it replaces that fragment; leave it until then`);
|
|
136
|
+
} else if (h.profile && profileFirst) {
|
|
137
|
+
warn(`${h.file}:${h.line} passes \`${h.old}\`, renamed \`${h.new}\` in 4.0 — change it by hand, ${profileFirst}`);
|
|
138
|
+
} else warn(`${h.file}:${h.line} names \`${h.old}\`, renamed \`${h.new}\` in 4.0 — yad does not edit this file; change it by hand`);
|
|
139
|
+
}
|
|
140
|
+
// A hook yad may not write (someone else's, or a hooks folder a tool manages) has no action; say so.
|
|
141
|
+
const gitHookNote = gitHookAdvice(gitHookState(root));
|
|
142
|
+
if (gitHookNote) warn(gitHookNote);
|
|
108
143
|
const shownInvalid = ideState.invalid.map((v) => {
|
|
109
144
|
try { return JSON.stringify(v) ?? String(v); } catch { return String(v); }
|
|
110
145
|
});
|
|
@@ -135,7 +170,10 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
135
170
|
// `--overwrite-local` replaces it, which still saves the previous content beside it.
|
|
136
171
|
const modified = actions.filter((a) => a.status === 'modified');
|
|
137
172
|
for (const m of modified) {
|
|
138
|
-
|
|
173
|
+
// The product-link record has no template: it is `modified` only when it is not a JSON object (E120).
|
|
174
|
+
warn(m.item === PRODUCT_LINK
|
|
175
|
+
? `${shown(m)}/${m.item} is not a JSON object — left alone (--overwrite-local saves it and writes a new one)`
|
|
176
|
+
: `${shown(m)}/${m.item} is locally modified — it matches neither the shipped template nor the copy yad wrote`);
|
|
139
177
|
}
|
|
140
178
|
// A gate-sync fragment is kept like any other edited file — but it also decides which yadflow CI runs.
|
|
141
179
|
// It trusts a committed version only from its own major (`YAD_MAJOR`), and the version stamp is
|
|
@@ -150,13 +188,37 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
150
188
|
return found ? Number(found[1]) : null;
|
|
151
189
|
} catch { return null; }
|
|
152
190
|
};
|
|
153
|
-
for (const m of modified.filter((a) => a.managed && /yad-gate-sync\.ya?ml$/.test(a.item))) {
|
|
191
|
+
for (const m of modified.filter((a) => a.managed && !a.rename && /yad-gate-sync\.ya?ml$/.test(a.item))) {
|
|
154
192
|
const kept = pinMajorOf(m.managed.dest);
|
|
155
193
|
const ships = pinMajorOf(m.managed.src);
|
|
156
194
|
if (kept === null || ships === null || kept === ships) continue;
|
|
157
|
-
warn(`${m
|
|
195
|
+
warn(`${shown(m)}/${m.item} trusts only yadflow ${kept}.x pins, but this release's fragment trusts ${ships}.x — kept as it is, CI will skip the new version stamp and run yadflow@${kept}`);
|
|
158
196
|
hand(`re-apply your edit on top of the new fragment, or replace it with \`yad update --overwrite-local\` (your copy is saved beside it as ${path.basename(m.managed.dest)}${BACKUP_SUFFIX})`);
|
|
159
197
|
}
|
|
198
|
+
// A renamed file the team edited (E123): kept under its old name, so say that the rename is half done and
|
|
199
|
+
// how to finish it. True whatever else this run does: the old file goes on running (GitHub) or being the
|
|
200
|
+
// one the root .gitlab-ci.yml includes (GitLab) until --overwrite-local replaces it.
|
|
201
|
+
// An edited Product gate that does not handle the profile the Product's checks workflow passes (E123): kept,
|
|
202
|
+
// and then it fails every Product PR, or passes what it should stop. Said only when a workflow passing
|
|
203
|
+
// `product` is on disk or this run installs one — the same test `yad doctor` makes (review 2).
|
|
204
|
+
const productRel = (p) => path.relative(root, p).split(path.sep).join('/');
|
|
205
|
+
const installsProduct = actions.filter((a) => a.managed && a.managed.root === root
|
|
206
|
+
&& /(^|\/)yad-product-checks\.yml$/.test(productRel(a.managed.dest))
|
|
207
|
+
&& a.status !== 'ok' && a.status !== 'modified' && (scope === 'all' || a.status !== 'missing'))
|
|
208
|
+
.map((a) => productRel(a.managed.dest));
|
|
209
|
+
const passing = [...new Set([...workflowsPassingProduct(root), ...installsProduct])];
|
|
210
|
+
for (const m of overwriteLocal || !passing.length ? [] : modified.filter((a) => a.managed && a.managed.root === root && !a.rename
|
|
211
|
+
&& PRODUCT_PROFILE_GATES.includes(productRel(a.managed.dest)))) {
|
|
212
|
+
const gap = productProfileGap(m.managed.dest);
|
|
213
|
+
if (!gap) continue;
|
|
214
|
+
warn(`${m.scope}/${productProfileEffect(gap, m.item, `${passing.join(' and ')} ${passing.length > 1 ? 'pass' : 'passes'}`)} — it was edited, so it is kept`);
|
|
215
|
+
hand(`${PRODUCT_PROFILE_FIX} (your copy is saved as ${path.basename(m.managed.dest)}${BACKUP_SUFFIX})`);
|
|
216
|
+
}
|
|
217
|
+
for (const m of overwriteLocal ? [] : modified.filter((a) => a.rename)) {
|
|
218
|
+
const { from, to } = m.rename;
|
|
219
|
+
warn(`${shown(m)}/${from} was renamed ${to} in this release, but it was edited, so it is kept — ${from.startsWith('.gitlab/') ? 'the root .gitlab-ci.yml goes on including it' : 'it goes on running'} under its old name`);
|
|
220
|
+
hand(`\`yad update --overwrite-local\` replaces it with ${to} (your copy is saved as ${path.basename(from)}${BACKUP_SUFFIX}); then copy your edits into ${to}`);
|
|
221
|
+
}
|
|
160
222
|
if (modified.length && !overwriteLocal) {
|
|
161
223
|
hand(`keep the edits (reported as \`modified\` on every check), or replace them with \`yad update --overwrite-local\` — each previous version is saved beside the file as <file>${BACKUP_SUFFIX}`);
|
|
162
224
|
}
|
|
@@ -169,10 +231,15 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
169
231
|
log('');
|
|
170
232
|
log(c.dim(`summary: ${counts.missing} missing, ${counts.new} new, ${counts.outdated} outdated, ${counts.modified} modified, ${counts.stale} stale, ${counts.legacy} legacy, ${counts.removed} removed, ${counts.ok} ok`));
|
|
171
233
|
|
|
234
|
+
// E80: the workspace file beside the Product, for one whose repos live beside it. Per machine, outside
|
|
235
|
+
// the Product repo — so never staged by --push. Reported here; written by --fix below.
|
|
236
|
+
const wsNeeded = exists(productConfigPath(root)) && hasSiblingRepo(root);
|
|
237
|
+
let workspaceFile = wsNeeded ? workspaceFileState(root) : null;
|
|
172
238
|
if (!fix) {
|
|
239
|
+
if (workspaceFile === 'missing') info(`no ${WORKSPACE_FILE} beside the Product — \`yad check --fix\` writes it, so yad finds the Product from its code repos`);
|
|
173
240
|
if (push) warn('--push has no effect without --fix (there is nothing applied to commit).');
|
|
174
241
|
if (fixable.length || gaps.length) hand('run `yad check --fix` to reconcile (or `yad setup` for missing one-time setup).');
|
|
175
|
-
return { fix: false, counts, gaps, items: itemsOf(actions), applied: 0, modified: modified.length, commits: [] };
|
|
242
|
+
return { fix: false, counts, gaps, items: itemsOf(actions), applied: 0, modified: modified.length, commits: [], workspaceFile };
|
|
176
243
|
}
|
|
177
244
|
|
|
178
245
|
// --- apply --- (collect the applied actions so --push can stage each repo's exact allowlist) ---
|
|
@@ -185,17 +252,20 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
185
252
|
appliedActions.push(a);
|
|
186
253
|
// A backup means the replaced content was not provably ours (a pre-ledger install, or an edit
|
|
187
254
|
// --overwrite-local was told to discard). Never report that as an ordinary template adoption.
|
|
188
|
-
info(`${a.status} → fixed: ${a
|
|
255
|
+
info(`${a.status} → fixed: ${shown(a)}/${a.item}${a.backup ? ` ${c.yellow(`(previous content saved to ${path.basename(a.backup)})`)}` : ''}`);
|
|
189
256
|
}
|
|
190
257
|
if (force) {
|
|
191
258
|
// --force re-copies what is already correct; it deliberately does NOT reach a `modified` file —
|
|
192
259
|
// only --overwrite-local discards a local edit, and only after backing it up.
|
|
193
260
|
for (const a of actions.filter((a) => a.status === 'ok')) { a.apply(); appliedActions.push(a); }
|
|
194
261
|
}
|
|
262
|
+
// A file that is right on disk but not committed as it stands, which --push must still commit: the
|
|
263
|
+
// product-link record (E120) — the gates read it from the default branch, not from anyone's disk.
|
|
264
|
+
if (push) for (const a of actions) if (a.pendingCommit && !appliedActions.includes(a)) appliedActions.push(a);
|
|
195
265
|
// Refresh the version stamp and persist only the canonical targets used to build actions. This also
|
|
196
266
|
// completes legacy/corrupt target migration even when no skill content itself needed an update.
|
|
197
267
|
writeCanonicalStamp();
|
|
198
|
-
appliedActions.push({ scope: '
|
|
268
|
+
appliedActions.push({ scope: 'product', item: PROJECT_FILES.version, status: 'stamp', root, paths: [PROJECT_FILES.version] });
|
|
199
269
|
// Record what we wrote (and what was already correct) so the NEXT update can tell a stale managed
|
|
200
270
|
// file from an edited one. Seeding the already-correct files is what migrates a pre-ledger install.
|
|
201
271
|
// A file left as `modified` records nothing — it differs from the template by definition.
|
|
@@ -205,6 +275,11 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
205
275
|
root: ledgerRoot, paths: [MANAGED_LEDGER],
|
|
206
276
|
});
|
|
207
277
|
}
|
|
278
|
+
if (workspaceFile === 'missing') {
|
|
279
|
+
workspaceFile = writeWorkspaceFile(root) === 'written' ? 'written' : workspaceFileState(root);
|
|
280
|
+
// Said with its full path: it is the one file this command writes OUTSIDE the Product.
|
|
281
|
+
if (workspaceFile === 'written') warn(`wrote ${path.join(path.dirname(path.resolve(root)), WORKSPACE_FILE)} (this machine only) — run from inside a repo this Product registers, yad finds the Product; delete the file to stop that`);
|
|
282
|
+
}
|
|
208
283
|
applied ? ok(`reconciled ${applied} item(s)`) : info('nothing to fix');
|
|
209
284
|
if (modified.length && !overwriteLocal) {
|
|
210
285
|
warn(`${modified.length} locally modified file(s) left untouched — this update did not reach them`);
|
|
@@ -217,9 +292,9 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
217
292
|
let commits = [];
|
|
218
293
|
if (push) {
|
|
219
294
|
preflightGuardReadiness(root);
|
|
220
|
-
const
|
|
221
|
-
const defByRoot = new Map([[root,
|
|
222
|
-
const platformByRoot = new Map([[root,
|
|
295
|
+
const productConfig = readJSON(productConfigPath(root), {});
|
|
296
|
+
const defByRoot = new Map([[root, productConfig?.default_branch]]);
|
|
297
|
+
const platformByRoot = new Map([[root, productConfig?.platform]]);
|
|
223
298
|
for (const repo of registry.repos) {
|
|
224
299
|
const repoRoot = path.resolve(root, repo.path);
|
|
225
300
|
defByRoot.set(repoRoot, repo.default_branch);
|
|
@@ -239,5 +314,5 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
239
314
|
},
|
|
240
315
|
});
|
|
241
316
|
}
|
|
242
|
-
return { fix: true, counts, gaps, items: itemsOf(actions), applied, modified: modified.length, commits };
|
|
317
|
+
return { fix: true, counts, gaps, items: itemsOf(actions), applied, modified: modified.length, commits, workspaceFile };
|
|
243
318
|
}
|
package/cli/repo-publish.mjs
CHANGED
|
@@ -16,23 +16,27 @@
|
|
|
16
16
|
// commit never enters a PR's base..HEAD range where it would strand required checks.
|
|
17
17
|
import fs from 'node:fs';
|
|
18
18
|
import path from 'node:path';
|
|
19
|
-
import { c, ok, info, fail, hand, exists, pushWithRebase } from './lib.mjs';
|
|
19
|
+
import { c, ok, info, fail, hand, exists, pushWithRebase, forTerminal } from './lib.mjs';
|
|
20
20
|
import { PROJECT_FILES , productConfigPath } from './manifest.mjs';
|
|
21
21
|
import { loadProduct } from './gate.mjs';
|
|
22
22
|
import { platformLogin } from './platform.mjs';
|
|
23
|
-
import { productGit, resolveDefaultBranch, guardDefaultBranch, preflightGuardReadiness } from './
|
|
23
|
+
import { productGit, resolveDefaultBranch, guardDefaultBranch, preflightGuardReadiness } from './productcommit.mjs';
|
|
24
24
|
import { ensurePackIgnored, PACK_IGNORE_BLOCK } from './setup.mjs';
|
|
25
25
|
import { checkpointAuthor } from './checkpoint.mjs';
|
|
26
|
+
import { codeContextPathOk } from './workspace.mjs';
|
|
26
27
|
|
|
27
28
|
// Collapse any whitespace/newline runs to a single space — keeps a hostile `git user.name` or a stray
|
|
28
29
|
// path from breaking the one-line subject or injecting a fake trailer line.
|
|
29
30
|
const oneLine = (s = '') => String(s).replace(/\s+/g, ' ').trim();
|
|
30
31
|
|
|
31
32
|
// The tracked code-map for a repo: the registered path, else the conventional location.
|
|
32
|
-
|
|
33
|
+
// A registry entry with no `name` (or a name that is not text) and no path of its own has no place
|
|
34
|
+
// here: null, which `codeContextPathOk` refuses — never a throw halfway through a publish (E81 review 2).
|
|
35
|
+
const inContext = (repo, file) => (typeof repo?.name === 'string' && repo.name ? path.posix.join('.sdlc/code-context', repo.name, file) : null);
|
|
36
|
+
export const codeMapOf = (repo) => repo.codeMap || inContext(repo, 'code-map.md');
|
|
33
37
|
|
|
34
38
|
// The repomix pack for a repo: the registered path, else the conventional location.
|
|
35
|
-
const packOf = (repo) => repo.contextPack ||
|
|
39
|
+
export const packOf = (repo) => repo.contextPack || inContext(repo, 'pack.md');
|
|
36
40
|
|
|
37
41
|
// PURE — the repo-relative pathspecs to stage: the registry plus each registered repo's code-map that
|
|
38
42
|
// exists on disk. When `name` is given (a scoped `yad repo refresh <name> --push`), only that repo's
|
|
@@ -41,9 +45,11 @@ const packOf = (repo) => repo.contextPack || path.posix.join('.sdlc/code-context
|
|
|
41
45
|
export function codeMapPathspecs(root, registry = { repos: [] }, name = null) {
|
|
42
46
|
const out = [];
|
|
43
47
|
for (const repo of registry.repos || []) {
|
|
44
|
-
if (name && repo.name !== name) continue;
|
|
48
|
+
if (!repo || typeof repo !== 'object' || (name && repo.name !== name)) continue;
|
|
45
49
|
const rel = codeMapOf(repo);
|
|
46
|
-
|
|
50
|
+
// Shared registry text chooses this path, and it is committed and pushed: only under
|
|
51
|
+
// .sdlc/code-context/ in the Product, never through `..`, `.git` or a link (E81).
|
|
52
|
+
if (codeContextPathOk(root, rel) && fs.existsSync(path.join(root, rel))) out.push(rel);
|
|
47
53
|
}
|
|
48
54
|
// The registry always rides along — `yad repo refresh` stamps syncedHead/lastSyncedAt into it.
|
|
49
55
|
if (fs.existsSync(path.join(root, PROJECT_FILES.reposRegistry))) out.push(PROJECT_FILES.reposRegistry);
|
|
@@ -57,9 +63,9 @@ export function codeMapPathspecs(root, registry = { repos: [] }, name = null) {
|
|
|
57
63
|
export function packPathspecs(root, registry = { repos: [] }, name = null) {
|
|
58
64
|
const out = [];
|
|
59
65
|
for (const repo of registry.repos || []) {
|
|
60
|
-
if (name && repo.name !== name) continue;
|
|
66
|
+
if (!repo || typeof repo !== 'object' || (name && repo.name !== name)) continue;
|
|
61
67
|
const rel = packOf(repo);
|
|
62
|
-
if (fs.existsSync(path.join(root, rel))) out.push(rel);
|
|
68
|
+
if (codeContextPathOk(root, rel) && fs.existsSync(path.join(root, rel))) out.push(rel);
|
|
63
69
|
}
|
|
64
70
|
return out;
|
|
65
71
|
}
|
|
@@ -122,12 +128,12 @@ export function summarizeCodeContext(files = []) {
|
|
|
122
128
|
}
|
|
123
129
|
|
|
124
130
|
// PURE — the audit-trail commit message. Subject passes the Product commit-message gate (valid type
|
|
125
|
-
// `chore`, scope `
|
|
131
|
+
// `chore`, scope `product`, non-empty description, no trailing period). No Task trailer and no
|
|
126
132
|
// Co-Authored-By: this is human-owned machine state, not an authored code change. `[skip ci]` mirrors
|
|
127
133
|
// `yad checkpoint` — it lands on the default branch and needs no PR gate suite. `label`/`author` are
|
|
128
134
|
// collapsed to one line so nothing can split the subject or forge a trailer.
|
|
129
135
|
export function buildCodeMapMessage({ label, author, basenames = [] }) {
|
|
130
|
-
const subject = `chore(
|
|
136
|
+
const subject = `chore(product): sync code-context — ${oneLine(label)} by ${oneLine(author)} [skip ci]`;
|
|
131
137
|
const body = basenames.length ? `Updated: ${basenames.join(', ')}` : '';
|
|
132
138
|
return body ? `${subject}\n\n${body}` : subject;
|
|
133
139
|
}
|
|
@@ -137,18 +143,18 @@ export function buildCodeMapMessage({ label, author, basenames = [] }) {
|
|
|
137
143
|
export async function publishCodeContext(root, { push = false, allowBranch = false, name = null } = {}) {
|
|
138
144
|
if (!exists(path.join(root, '.git'))) { fail('not a git repo'); process.exitCode = 1; return; }
|
|
139
145
|
if (!exists(productConfigPath(root))) {
|
|
140
|
-
fail('no .sdlc/
|
|
146
|
+
fail('no .sdlc/product.json — --push publishes the Product code-context; run it from the Product');
|
|
141
147
|
process.exitCode = 1;
|
|
142
148
|
return;
|
|
143
149
|
}
|
|
144
150
|
|
|
145
|
-
const {
|
|
151
|
+
const { productConfig, repos } = loadProduct(root);
|
|
146
152
|
const registry = { repos: repos || [] };
|
|
147
153
|
const git = productGit(root);
|
|
148
154
|
|
|
149
155
|
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
|
|
150
|
-
const defaultBranch = resolveDefaultBranch(git,
|
|
151
|
-
if (!guardDefaultBranch(branch, defaultBranch, { allowBranch, cmd: 'yad repo refresh --push' })) return;
|
|
156
|
+
const defaultBranch = resolveDefaultBranch(git, productConfig);
|
|
157
|
+
if (!guardDefaultBranch(branch, defaultBranch, { allowBranch, cmd: 'yad repo refresh --push', root })) return;
|
|
152
158
|
|
|
153
159
|
// Stage the EXPLICIT allowlist (code-maps + registry), scoped so an unrelated pre-staged file is never
|
|
154
160
|
// swept in and the user's index is left untouched — mirrors `runCheckpoint`. NEVER `git add -A` and
|
|
@@ -213,7 +219,7 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
|
|
|
213
219
|
if (push) preflightGuardReadiness(root);
|
|
214
220
|
|
|
215
221
|
const { label, basenames } = summarizeCodeContext(fileset);
|
|
216
|
-
const author = checkpointAuthor(platformLogin(root,
|
|
222
|
+
const author = checkpointAuthor(platformLogin(root, productConfig?.platform), git('config', 'user.name').stdout);
|
|
217
223
|
const message = buildCodeMapMessage({ label, author, basenames });
|
|
218
224
|
|
|
219
225
|
// Untrack the packs by holding their bytes and removing the files across the --only commit, then
|
|
@@ -236,7 +242,7 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
|
|
|
236
242
|
} finally {
|
|
237
243
|
for (const h of held) if (!fs.existsSync(h.abs)) fs.writeFileSync(h.abs, h.buf);
|
|
238
244
|
}
|
|
239
|
-
ok(`published ${fileset.length} file(s): ${c.dim(label)}`);
|
|
245
|
+
ok(`published ${fileset.length} file(s): ${c.dim(forTerminal(label))}`); // a registry name can be in it (E81 review 15)
|
|
240
246
|
|
|
241
247
|
if (!push) return { message, committed: true, pushed: false };
|
|
242
248
|
return { message, committed: true, pushed: pushHead() };
|