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/plan.mjs
CHANGED
|
@@ -5,13 +5,13 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { err } from './errors.mjs';
|
|
7
7
|
import {
|
|
8
|
-
asset, exists, copyDir, copyFile, dirMatches, sameContent, readJSON, readJSONStrict, writeJSON,
|
|
8
|
+
asset, exists, copyDir, copyFile, dirMatches, sameContent, readJSON, readJSONStrict, writeJSON, contentSha, warn, info, isPlainObject, run, samePath,
|
|
9
9
|
} from './lib.mjs';
|
|
10
10
|
import {
|
|
11
11
|
VERSION, SKILLS, IDE_TARGETS, IDE_OPENCODE_DIR, IDE_OPENCODE_TARGET, IDE_RECOVERY_TARGET, MODULE_CONFIG, wiringFor, PRODUCT_WIRING, PROJECT_FILES, isVerifiedLedger,
|
|
12
12
|
HOOK_WIRING, HOOK_ADAPTERS, CLAUDE_HOOK_ADAPTER, CAPTURE_WIRING, CAPTURE_ADAPTERS,
|
|
13
|
-
|
|
14
|
-
productConfigPath,
|
|
13
|
+
legacySkillPairs, REMOVED_SKILLS, LEGACY_REPO_FILES, LEGACY_PRODUCT_FILES, LEGACY_HOOK_SCRIPTS, MANAGED_LEDGER, BACKUP_SUFFIX,
|
|
14
|
+
productConfigPath, PRODUCT_LINK, PRODUCT_LINK_DEFAULT_PATH,
|
|
15
15
|
} from './manifest.mjs';
|
|
16
16
|
|
|
17
17
|
// A git pathspec (forward slashes, relative to a repo root) for `dest` under `root`. Actions carry
|
|
@@ -29,8 +29,12 @@ const rel = (root, dest) => path.relative(root, dest).split(path.sep).join('/');
|
|
|
29
29
|
// at it carries fail-open semantics, so every ledger edit is quietly PERMITTED while `yad check` and
|
|
30
30
|
// `yad doctor` both report the guard healthy. Reachable without anyone doing anything odd: a zip or
|
|
31
31
|
// tarball download, `cp` without `-p`, a restrictive umask, `core.fileMode=false`.
|
|
32
|
+
//
|
|
33
|
+
// WINDOWS HAS NO EXECUTE BIT ON DISK (E113): Node reports every file there without one, so the test
|
|
34
|
+
// below would call every gate script outdated for ever, and `--fix` could never clear it. The gates run
|
|
35
|
+
// on the team's CI as `bash checks/<gate>.sh`, which needs no bit, so on Windows a file present counts.
|
|
32
36
|
const isExecutable = (dest) => {
|
|
33
|
-
try { return !!(fs.statSync(dest).mode & 0o111); } catch { return false; }
|
|
37
|
+
try { return process.platform === 'win32' || !!(fs.statSync(dest).mode & 0o111); } catch { return false; }
|
|
34
38
|
};
|
|
35
39
|
const fileAction = (scope, item, src, dest, { root, exec = false } = {}) => ({
|
|
36
40
|
scope,
|
|
@@ -90,7 +94,7 @@ const wiredFileAction = (scope, item, src, dest, { root, exec = false, ledger =
|
|
|
90
94
|
// Compares CONTENT. A file that is outdated only because its execute bit was lost still has the
|
|
91
95
|
// sha we recorded, so it reads as ours and is re-applied with no backup — which is right: nothing
|
|
92
96
|
// of the team's is being discarded, the mode is simply restored.
|
|
93
|
-
const ours = !!recorded && recorded ===
|
|
97
|
+
const ours = !!recorded && recorded === contentSha(dest);
|
|
94
98
|
const backup = ours ? null : backupPathFor(dest);
|
|
95
99
|
return {
|
|
96
100
|
...base,
|
|
@@ -119,7 +123,7 @@ export function recordManagedWrites(actions = []) {
|
|
|
119
123
|
if (!m || !m.root) continue;
|
|
120
124
|
if (!sameContent(m.src, m.dest)) continue;
|
|
121
125
|
if (!byRoot.has(m.root)) byRoot.set(m.root, {});
|
|
122
|
-
byRoot.get(m.root)[rel(m.root, m.dest)] =
|
|
126
|
+
byRoot.get(m.root)[rel(m.root, m.dest)] = contentSha(m.dest);
|
|
123
127
|
}
|
|
124
128
|
const roots = [];
|
|
125
129
|
for (const [root, written] of byRoot) {
|
|
@@ -246,12 +250,10 @@ export function safeIdeTargetsFor(root, input) {
|
|
|
246
250
|
return targets;
|
|
247
251
|
}
|
|
248
252
|
|
|
249
|
-
// Is a wired hook script present
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
return !!lstatIfPresent(full) && isExecutable(full);
|
|
254
|
-
};
|
|
253
|
+
// Is a wired hook script present? Shared by the planner and `yad doctor`, so the two cannot disagree about
|
|
254
|
+
// whether the guard is installed. Since E113 every hook is a Node script the entry runs as `node <file>`,
|
|
255
|
+
// so an execute bit is no longer part of "ready" — and Windows could never show one.
|
|
256
|
+
export const hookScriptReady = (root, relPath) => !!lstatIfPresent(path.join(root, relPath))?.isFile();
|
|
255
257
|
|
|
256
258
|
// Which of these targets are safe to write through, without throwing on the ones that are not.
|
|
257
259
|
// `safeIdeTargetsFor` throws on the first bad target, which is right for an installer and wrong for a
|
|
@@ -421,7 +423,8 @@ export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
|
|
|
421
423
|
return actions;
|
|
422
424
|
}
|
|
423
425
|
|
|
424
|
-
// Migration of
|
|
426
|
+
// Migration of an install carrying an old skill name (the 2.0 sdlc-* -> yad-* rename, and E123's
|
|
427
|
+
// yad-hub-bridge -> yad-product-bridge; see LEGACY_SKILLS). Status is 'legacy' — unlike
|
|
425
428
|
// 'missing' it is applied by `yad update` (--scope=changed) too, because the skill IS
|
|
426
429
|
// installed, just under its old name. apply() removes the old copy AND installs the renamed
|
|
427
430
|
// one, so a single update completes the rename even when the new copy would otherwise be
|
|
@@ -429,14 +432,14 @@ export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
|
|
|
429
432
|
export function legacyModuleActions(root, ideTargets = ideTargetsFor(root)) {
|
|
430
433
|
const targets = safeIdeTargetsFor(root, ideTargets);
|
|
431
434
|
if (targets.includes('.opencode')) {
|
|
432
|
-
const writes =
|
|
435
|
+
const writes = legacySkillPairs()
|
|
433
436
|
.filter(([, old]) => exists(path.join(root, IDE_OPENCODE_DIR, `${old}.md`)))
|
|
434
437
|
.map(([skill]) => skill);
|
|
435
438
|
assertSafeOpenCodeWriteDestinations(root, writes);
|
|
436
439
|
}
|
|
437
440
|
const actions = [];
|
|
438
441
|
for (const ide of targets) {
|
|
439
|
-
for (const [skill, old] of
|
|
442
|
+
for (const [skill, old] of legacySkillPairs()) {
|
|
440
443
|
if (ide === '.opencode') {
|
|
441
444
|
const oldDest = path.join(root, IDE_OPENCODE_DIR, `${old}.md`);
|
|
442
445
|
const newDest = path.join(root, IDE_OPENCODE_DIR, `${skill}.md`);
|
|
@@ -447,6 +450,7 @@ export function legacyModuleActions(root, ideTargets = ideTargetsFor(root)) {
|
|
|
447
450
|
status: 'legacy',
|
|
448
451
|
root,
|
|
449
452
|
paths: [rel(root, oldDest), rel(root, newDest)],
|
|
453
|
+
rename: { from: rel(root, oldDest), to: rel(root, newDest) },
|
|
450
454
|
apply: () => {
|
|
451
455
|
fs.rmSync(oldDest, { force: true });
|
|
452
456
|
copyFile(asset('skills', skill, 'SKILL.md'), newDest);
|
|
@@ -462,6 +466,7 @@ export function legacyModuleActions(root, ideTargets = ideTargetsFor(root)) {
|
|
|
462
466
|
status: 'legacy',
|
|
463
467
|
root,
|
|
464
468
|
paths: [rel(root, oldDest), rel(root, newDest)],
|
|
469
|
+
rename: { from: rel(root, oldDest), to: rel(root, newDest) },
|
|
465
470
|
apply: () => {
|
|
466
471
|
fs.rmSync(oldDest, { recursive: true, force: true });
|
|
467
472
|
copyDir(asset('skills', skill), newDest);
|
|
@@ -511,10 +516,11 @@ export function removedModuleActions(root, ideTargets = ideTargetsFor(root)) {
|
|
|
511
516
|
return actions;
|
|
512
517
|
}
|
|
513
518
|
|
|
514
|
-
// True only for a file WE installed
|
|
515
|
-
// (`# sdlc-managed
|
|
516
|
-
|
|
517
|
-
|
|
519
|
+
// True only for a file WE installed: its first line starts with the ownership marker its rename entry
|
|
520
|
+
// names (`# sdlc-managed` for the pre-2.0 files, `# yad-managed: yad-checks` for E123's). A same-named
|
|
521
|
+
// user-authored file is never ours.
|
|
522
|
+
function ownedByOldInstall(p, marker) {
|
|
523
|
+
try { return fs.readFileSync(p, 'utf8').startsWith(marker); } catch { return false; }
|
|
518
524
|
}
|
|
519
525
|
|
|
520
526
|
// old-dest -> new-dest migrations for wired CI files: remove the marker-owned old file and
|
|
@@ -522,13 +528,29 @@ function ownedByOldInstall(p) {
|
|
|
522
528
|
// path from the root `.gitlab-ci.yml` (`include: - local: ...`, written by the wire step), so
|
|
523
529
|
// the migration must also rewrite that include — otherwise the pipeline hard-fails on a
|
|
524
530
|
// `local file does not exist` the moment the old fragment is removed.
|
|
531
|
+
//
|
|
532
|
+
// The marker says who installed the file, not that nobody changed it since, so the provenance record
|
|
533
|
+
// decides what happens to its content — the three states every managed file has (`wiredFileAction`):
|
|
534
|
+
// recorded, and the bytes are what yad wrote → removed ('legacy')
|
|
535
|
+
// no record (installed before the record) → removed, after a <file>.yad-orig copy ('legacy')
|
|
536
|
+
// recorded, and the bytes differ (edited) → 'modified': left, with the rename not done; only
|
|
537
|
+
// `--overwrite-local` removes it, after the same copy.
|
|
538
|
+
// Meanwhile the new name is not installed beside it
|
|
539
|
+
// (`withoutKeptRenames`), or both would run
|
|
540
|
+
// The copy is inert where it lands: GitHub runs only `.yml`/`.yaml` files, and nothing includes it.
|
|
541
|
+
// When the old file goes, its line leaves the record, so the record lists only files that are there.
|
|
525
542
|
function legacyFileActions(scope, baseRoot, fileMap, wiring) {
|
|
526
543
|
const actions = [];
|
|
527
|
-
|
|
544
|
+
let ledger = null;
|
|
545
|
+
for (const [oldDest, { to: newDest, marker }] of Object.entries(fileMap || {})) {
|
|
528
546
|
const oldPath = path.join(baseRoot, oldDest);
|
|
529
|
-
if (!ownedByOldInstall(oldPath)) continue;
|
|
547
|
+
if (!ownedByOldInstall(oldPath, marker)) continue;
|
|
530
548
|
const w = wiring.find((x) => x.dest === newDest);
|
|
531
549
|
if (!w) continue; // never delete a working file without a replacement to install
|
|
550
|
+
ledger ??= readManagedLedger(baseRoot);
|
|
551
|
+
const recorded = ledger[oldDest];
|
|
552
|
+
const ours = !!recorded && recorded === contentSha(oldPath);
|
|
553
|
+
const backup = ours ? null : backupPathFor(oldPath);
|
|
532
554
|
// Only claim the root .gitlab-ci.yml when apply() will actually rewrite it (it references the old
|
|
533
555
|
// fragment) — else a --push would sweep the user's unrelated edits to that shared-ownership file
|
|
534
556
|
// into the chore(yad-update) commit. The old (deletion) + new (add) paths are always ours.
|
|
@@ -537,32 +559,292 @@ function legacyFileActions(scope, baseRoot, fileMap, wiring) {
|
|
|
537
559
|
actions.push({
|
|
538
560
|
scope,
|
|
539
561
|
item: `${oldDest} → ${newDest}`,
|
|
540
|
-
status: 'legacy',
|
|
562
|
+
status: ours || !recorded ? 'legacy' : 'modified',
|
|
541
563
|
root: baseRoot,
|
|
542
|
-
paths:
|
|
564
|
+
paths: [oldDest, newDest, ...(rewritesRootCi ? ['.gitlab-ci.yml'] : []), ...(recorded ? [MANAGED_LEDGER] : [])],
|
|
565
|
+
backup,
|
|
566
|
+
rename: { from: oldDest, to: newDest },
|
|
567
|
+
// The new file it installs, so `recordManagedWrites` records it when it is the template. Without this a
|
|
568
|
+
// rename done by `--overwrite-local` (the new name's own action is left out then, `withoutKeptRenames`)
|
|
569
|
+
// left the new file unrecorded, and the next update replaced the edits a person copied into it (review 2).
|
|
570
|
+
managed: { src: asset(w.src), dest: path.join(baseRoot, newDest), root: baseRoot },
|
|
543
571
|
apply: () => {
|
|
572
|
+
if (backup) fs.copyFileSync(oldPath, backup);
|
|
544
573
|
fs.rmSync(oldPath, { force: true });
|
|
545
|
-
|
|
574
|
+
// Only into an empty path. A file already at the new name is the new name's own wired-file action's
|
|
575
|
+
// to judge — with its provenance and its backup — and may hold the team's edits (review 1).
|
|
576
|
+
if (!exists(path.join(baseRoot, newDest))) copyFile(asset(w.src), path.join(baseRoot, newDest), { exec: !!w.exec });
|
|
546
577
|
const rootCi = path.join(baseRoot, '.gitlab-ci.yml');
|
|
547
578
|
try {
|
|
548
579
|
const txt = fs.readFileSync(rootCi, 'utf8');
|
|
549
580
|
if (txt.includes(oldDest)) fs.writeFileSync(rootCi, txt.split(oldDest).join(newDest));
|
|
550
581
|
} catch { /* no root .gitlab-ci.yml (github repo, or fragment-only gitlab) — nothing to rewrite */ }
|
|
582
|
+
const current = readManagedLedger(baseRoot);
|
|
583
|
+
if (Object.hasOwn(current, oldDest)) {
|
|
584
|
+
delete current[oldDest];
|
|
585
|
+
writeJSON(path.join(baseRoot, MANAGED_LEDGER), { version: VERSION, files: current });
|
|
586
|
+
}
|
|
551
587
|
},
|
|
552
588
|
});
|
|
553
589
|
}
|
|
554
590
|
return actions;
|
|
555
591
|
}
|
|
556
592
|
|
|
593
|
+
// E123: the team's own files that name something the hub -> Product rename moved. yad owns none of them
|
|
594
|
+
// and never edits them — except the one GitLab include line `legacyFileActions` rewrites — so each hit is
|
|
595
|
+
// named, with its file and line, by `yad doctor` and `yad check`/`update`, and left for a person.
|
|
596
|
+
// Old -> new, the exact literals the old templates carried (`yad-hub-checks.yml`, and the one job in
|
|
597
|
+
// `yad-verified-commits.yml`). `yad-hub-checks` covers the file name (`.github/workflows/…`, `.gitlab/ci/…`,
|
|
598
|
+
// the include path, the badge URL that names the file) and the GitHub workflow `name:` (a `workflow_run:`
|
|
599
|
+
// trigger, the older badge URL). Nothing broader: a bare `yad-hub-` prefix would claim a team's own
|
|
600
|
+
// `yad-hub-deploy` job.
|
|
601
|
+
export const RENAMED_CI_NAMES = Object.freeze([
|
|
602
|
+
['yad-hub-checks', 'yad-product-checks'],
|
|
603
|
+
['yad-hub-commit-message', 'yad-product-commit-message'],
|
|
604
|
+
['yad-hub-pr-title', 'yad-product-pr-title'],
|
|
605
|
+
['yad-hub-pr-template', 'yad-product-pr-template'],
|
|
606
|
+
['yad-hub-ledger-guard', 'yad-product-ledger-guard'],
|
|
607
|
+
['yad-hub-verified-commits', 'yad-product-verified-commits'],
|
|
608
|
+
['.yad_hub_mr_only', '.yad_product_mr_only'],
|
|
609
|
+
]);
|
|
610
|
+
// The profile value a team's own CI file may pass (E124 review 1). The gates still accept `--profile hub` until
|
|
611
|
+
// v5, but a gate edited after 4.0 without the line that would turn `hub` into `product` lets `--profile hub`
|
|
612
|
+
// skip the Product's rules (the cell `productProfileGap` does not judge) — so the CI line is named instead.
|
|
613
|
+
// Its own pattern: `=` or any run of spaces, the value bare or quoted, and nothing after it that could be part
|
|
614
|
+
// of a longer word (`--profile hubble`, `--profile hub.x`). Only the literal is seen: a value passed through a
|
|
615
|
+
// variable (`--profile "$P"`) is not. Only in CI files — a README that quotes the command is prose.
|
|
616
|
+
const OLD_PROFILE_RE = /(?<![\w-])--profile(?:=|[ \t]+)(["']?)hub\1(?![\w.-])/g;
|
|
617
|
+
const escapeRe = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
618
|
+
// Bounded on both sides by anything that cannot be part of a name, so `yad-hub-checks-extra` is not a hit.
|
|
619
|
+
const RENAMED_CI_RE = new RegExp(`(?<![\\w.-])(${RENAMED_CI_NAMES.map(([o]) => escapeRe(o)).join('|')})(?![\\w-])`, 'g');
|
|
620
|
+
// Where a person's own reference to our CI lives. The CI folders are read only for files yad did not install:
|
|
621
|
+
// every file yad writes there starts `# yad-managed` (`# yad-managed:`, `# yad-managed-include:`,
|
|
622
|
+
// `# yad-managed —`), so the FIRST line decides, never a mention further down.
|
|
623
|
+
const CODEOWNERS_DIRS = ['', '.github', '.gitlab', 'docs'];
|
|
624
|
+
function renamedNameFiles(root) {
|
|
625
|
+
const listYml = (dir) => {
|
|
626
|
+
try {
|
|
627
|
+
return fs.readdirSync(path.join(root, dir), { withFileTypes: true })
|
|
628
|
+
.filter((e) => e.isFile() && /\.ya?ml$/.test(e.name)).map((e) => `${dir}/${e.name}`).sort();
|
|
629
|
+
} catch { return []; }
|
|
630
|
+
};
|
|
631
|
+
const notOurs = (rel) => {
|
|
632
|
+
try { return !fs.readFileSync(path.join(root, rel), 'utf8').split('\n', 1)[0].startsWith('# yad-managed'); } catch { return false; }
|
|
633
|
+
};
|
|
634
|
+
return [
|
|
635
|
+
'.gitlab-ci.yml',
|
|
636
|
+
...[...listYml('.gitlab/ci'), ...listYml('.github/workflows')].filter(notOurs),
|
|
637
|
+
'README.md',
|
|
638
|
+
...CODEOWNERS_DIRS.map((d) => (d ? `${d}/CODEOWNERS` : 'CODEOWNERS')),
|
|
639
|
+
];
|
|
640
|
+
}
|
|
641
|
+
// Every hit: { file, line, old, new, rewrittenBy }. `rewrittenBy` marks a match inside the old fragment's path
|
|
642
|
+
// on a line of the root .gitlab-ci.yml, which yad rewrites itself when it replaces that fragment: 'update'
|
|
643
|
+
// for a fragment `yad update` replaces, 'overwrite-local' for an edited one it keeps until that flag; null
|
|
644
|
+
// for every other hit, which is the team's to change.
|
|
645
|
+
export function renamedNameHits(root) {
|
|
646
|
+
const fragments = new Map();
|
|
647
|
+
try {
|
|
648
|
+
for (const a of legacyProductActions(root)) {
|
|
649
|
+
if (a.paths.includes('.gitlab-ci.yml') || a.status === 'modified') fragments.set(a.rename.from, a.status === 'modified' ? 'overwrite-local' : 'update');
|
|
650
|
+
}
|
|
651
|
+
} catch { /* an unreadable provenance record: nothing is promised, so every hit reads as the team's to change */ }
|
|
652
|
+
const hits = [];
|
|
653
|
+
for (const file of renamedNameFiles(root)) {
|
|
654
|
+
let text;
|
|
655
|
+
try {
|
|
656
|
+
if (!fs.lstatSync(path.join(root, file)).isFile()) continue;
|
|
657
|
+
text = fs.readFileSync(path.join(root, file), 'utf8');
|
|
658
|
+
} catch { continue; }
|
|
659
|
+
const ci = /\.ya?ml$/.test(file);
|
|
660
|
+
text.split(/\r?\n/).forEach((line, i) => {
|
|
661
|
+
if (ci) {
|
|
662
|
+
for (const m of line.matchAll(OLD_PROFILE_RE)) {
|
|
663
|
+
hits.push({ file, line: i + 1, old: m[0], new: m[0].replace(/hub(["']?)$/, 'product$1'), rewrittenBy: null, profile: true });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
for (const m of line.matchAll(RENAMED_CI_RE)) {
|
|
667
|
+
const next = RENAMED_CI_NAMES.find(([o]) => o === m[1])[1];
|
|
668
|
+
// Per match, not per line: only the fragment path itself is rewritten, not a job named beside it.
|
|
669
|
+
let rewrittenBy = null;
|
|
670
|
+
if (file === '.gitlab-ci.yml') {
|
|
671
|
+
for (const [f, by] of fragments) {
|
|
672
|
+
for (let at = line.indexOf(f); at !== -1 && !rewrittenBy; at = line.indexOf(f, at + 1)) {
|
|
673
|
+
if (m.index >= at && m.index < at + f.length) rewrittenBy = by;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
hits.push({ file, line: i + 1, old: m[1], new: next, rewrittenBy });
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
return hits;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// The Product's pattern gates, whose workflow passes `--profile product` since E123. A copy the team edited
|
|
685
|
+
// is kept by `yad update` — and one from before 4.0 accepts only `code|hub`, so it fails every Product PR
|
|
686
|
+
// with "unknown --profile 'product'" (review 1). 'rejects' when the gate's own `case "$PROFILE" in …)` list lacks
|
|
687
|
+
// `product`; 'unmapped' when it has it, but the gate branches on `= hub` with no line turning `product`
|
|
688
|
+
// into `hub` — then `product` passes the list and silently skips every Product rule (review 2); null otherwise,
|
|
689
|
+
// and for a copy with no such list, which is not judged. The shipped copies branch on `= product` since
|
|
690
|
+
// E124 (and turn `hub` into `product`), so they read null here; the `= hub` shape is an older copy's.
|
|
691
|
+
//
|
|
692
|
+
// Every case, as gate shape × the value a workflow passes (E124):
|
|
693
|
+
// gate passes product passes hub
|
|
694
|
+
// branches on `= hub`, maps product -> hub ok ok
|
|
695
|
+
// branches on `= hub`, no mapping 'unmapped' ok
|
|
696
|
+
// list without `product` (before 4.0) 'rejects' ok
|
|
697
|
+
// branches on `= product`, maps hub -> product ok (shipped) ok (shipped)
|
|
698
|
+
// branches on `= product`, no mapping ok skips the Product rules — see below
|
|
699
|
+
// The last cell is not judged here, from the gate: it is closed from the workflow side instead. A team's own CI
|
|
700
|
+
// line that passes `--profile hub` literally is a `renamed-ref:` hit (OLD_PROFILE_RE), and yad's own old
|
|
701
|
+
// workflow is a `renamed:` one. A value passed through a variable is not seen.
|
|
702
|
+
export const PRODUCT_PROFILE_GATES = Object.freeze(['checks/commit-message.sh', 'checks/pr-title.sh', 'checks/pr-template.sh']);
|
|
703
|
+
export function productProfileGap(file) {
|
|
704
|
+
let text;
|
|
705
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return null; }
|
|
706
|
+
const list = text.match(/^\s*case\s+"\$PROFILE"\s+in\s+([^)]*)\)/m);
|
|
707
|
+
if (!list) return null;
|
|
708
|
+
if (!list[1].split('|').map((x) => x.trim()).includes('product')) return 'rejects';
|
|
709
|
+
// Any spelling of the mapping on one line — `[ "$PROFILE" = product ] && PROFILE=hub`, `[[ $PROFILE == product ]]
|
|
710
|
+
// && …`, a `product) PROFILE=hub ;;` arm — and any spelling of the branch: quoted or not, `=` or `==`.
|
|
711
|
+
// A comment is prose, not code: the shipped copies explain the mapping in one.
|
|
712
|
+
const code = text.split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
|
|
713
|
+
const mapped = /product[^\n]*\bPROFILE=["']?hub\b/.test(code);
|
|
714
|
+
// The line that maps the old name (the shipped shape since E124: turn `hub` into `product`) compares with `= hub` too, but it is
|
|
715
|
+
// the mapping, not a branch — so it is not what the branch test below looks for.
|
|
716
|
+
const branches = code.split('\n').filter((l) => !/\bPROFILE=["']?product\b/.test(l)).join('\n');
|
|
717
|
+
return /\$\{?PROFILE\}?"?\s*==?\s*["']?hub\b/.test(branches) && !mapped ? 'unmapped' : null;
|
|
718
|
+
}
|
|
719
|
+
// What each gap does to a Product PR, and the one fix for both — shared by `yad doctor` and `yad update`.
|
|
720
|
+
export const productProfileEffect = (gap, gate, passing) => (gap === 'rejects'
|
|
721
|
+
? `${gate} does not accept \`--profile product\`, which ${passing} — every Product PR fails that check`
|
|
722
|
+
: `${gate} accepts \`--profile product\` but never turns it into \`hub\`, which ${passing} — so it skips the Product's rules and passes what it should stop`);
|
|
723
|
+
export const PRODUCT_PROFILE_FIX = 'add `product` to its `case "$PROFILE" in` list and `[ "$PROFILE" = product ] && PROFILE=hub` after it — or replace it with the shipped copy, which branches on `product` itself: `yad update --overwrite-local`';
|
|
724
|
+
|
|
725
|
+
// The Product's checks workflows, under the new name and the old (E123): an edited old one is kept, and runs.
|
|
726
|
+
export const PRODUCT_CHECK_WORKFLOWS = Object.freeze([
|
|
727
|
+
'.github/workflows/yad-product-checks.yml', '.gitlab/ci/yad-product-checks.yml',
|
|
728
|
+
'.github/workflows/yad-hub-checks.yml', '.gitlab/ci/yad-hub-checks.yml',
|
|
729
|
+
]);
|
|
730
|
+
// Those of them on disk that pass `--profile product` to the gates.
|
|
731
|
+
// yad's own workflows, and the team's own CI files (E124 review 2): a team that follows `renamed-ref:` and changes
|
|
732
|
+
// its `--profile hub` to `product` must then hear from `profile:` if an edited gate still rejects it.
|
|
733
|
+
export const workflowsPassingProduct = (root) => [...new Set([...PRODUCT_CHECK_WORKFLOWS,
|
|
734
|
+
...renamedNameFiles(root).filter((rel) => /\.ya?ml$/.test(rel))])].filter((rel) => {
|
|
735
|
+
try { return /--profile(?:=|[ \t]+)(["']?)product\1(?![\w.-])/.test(fs.readFileSync(path.join(root, rel), 'utf8')); } catch { return false; }
|
|
736
|
+
});
|
|
737
|
+
// The Product gates that would mishandle `--profile product` (E124 reviews 2–4): refuse it ('rejects'), or take it
|
|
738
|
+
// and skip the Product's rules ('unmapped'). Each gate's state decides what to do first, and is read from the
|
|
739
|
+
// provenance record through `productActions` — never guessed from the file:
|
|
740
|
+
// state what it is before a run on update --overwrite-local
|
|
741
|
+
// 'outdated' yad's own copy, older than this release run yad update replaced replaced
|
|
742
|
+
// 'unrecorded' no record (a pre-3.16 install): update backs it up run yad update replaced replaced
|
|
743
|
+
// 'kept' edited since yad wrote it: update keeps it fix it fix it replaced
|
|
744
|
+
// 'unreadable' the settings do not read (or are not an object), or restore the file, then run yad update
|
|
745
|
+
// the provenance record does not: yad cannot tell (with
|
|
746
|
+
// an unreadable record, update itself refuses until then)
|
|
747
|
+
// 'drift' a file under two names says different things (E122): yad migrate --apply, then doctor
|
|
748
|
+
// every command but doctor/migrate/report refuses until
|
|
749
|
+
// one copy is kept — so nothing else can be the first step
|
|
750
|
+
// 'unmanaged' yad has no action for it (Product not verified) fix it by hand same same
|
|
751
|
+
// 'unreadable' and 'unmanaged' are read from the file on disk; 'drift' reads no provenance record.
|
|
752
|
+
// On a run that fixes, a gate that run replaces is left out: after it, the gate is the shipped copy.
|
|
753
|
+
// `drift`: `productDriftPairs(root)` (cli/epic-state.mjs; plan.mjs lists no epics) — the pairs every command refuses
|
|
754
|
+
// on. Only `yad doctor` passes it: `yad check`/`update` never run under drift, the dispatcher refuses them first.
|
|
755
|
+
export function productGateBlockers(root, { fix = false, overwriteLocal = false, drift = [] } = {}) {
|
|
756
|
+
let actions = [];
|
|
757
|
+
let unreadable = null; // the file that does not read, for 'unreadable' (review 5)
|
|
758
|
+
const settings = productConfigPath(root);
|
|
759
|
+
try {
|
|
760
|
+
// Not an object is as unreadable as not parsing: `yad doctor` fails both (review 6).
|
|
761
|
+
if (exists(settings) && !isPlainObject(readJSONStrict(settings, null))) throw new Error('not an object');
|
|
762
|
+
} catch { unreadable = path.relative(root, settings).split(path.sep).join('/'); }
|
|
763
|
+
const drifted = !unreadable && drift.length ? drift : null;
|
|
764
|
+
if (!unreadable && !drifted) {
|
|
765
|
+
try { actions = productActions(root); } catch { unreadable = MANAGED_LEDGER; }
|
|
766
|
+
}
|
|
767
|
+
const byGate = new Map(actions.filter((a) => a.managed)
|
|
768
|
+
.map((a) => [path.relative(root, a.managed.dest).split(path.sep).join('/'), a]));
|
|
769
|
+
const out = [];
|
|
770
|
+
for (const gate of PRODUCT_PROFILE_GATES) {
|
|
771
|
+
const gap = productProfileGap(path.join(root, gate));
|
|
772
|
+
if (!gap) continue;
|
|
773
|
+
const a = byGate.get(gate);
|
|
774
|
+
let state;
|
|
775
|
+
if (unreadable) state = 'unreadable';
|
|
776
|
+
else if (drifted) state = 'drift';
|
|
777
|
+
else if (!a) state = 'unmanaged';
|
|
778
|
+
else if (a.status === 'modified') state = fix && overwriteLocal ? null : 'kept';
|
|
779
|
+
else state = fix ? null : (a.backup ? 'unrecorded' : 'outdated');
|
|
780
|
+
if (state) out.push({ gate, gap, state, ...(unreadable ? { file: unreadable } : {}), ...(drifted ? { pairs: drifted } : {}) });
|
|
781
|
+
}
|
|
782
|
+
return out;
|
|
783
|
+
}
|
|
784
|
+
const gapWords = (b) => `${b.gate} (it ${b.gap === 'rejects' ? 'refuses `--profile product`, so every Product PR would fail it' : 'takes `--profile product` but then skips the Product\'s rules'})`;
|
|
785
|
+
// The drifted pairs, said once: which files say different things (E122).
|
|
786
|
+
const driftWords = (pairs) => `${pairs.map((p) => `${p.canonical} and ${p.legacy}`).join('; ')} say different things`;
|
|
787
|
+
const saved = (b) => `${b.gate}${BACKUP_SUFFIX}`;
|
|
788
|
+
// What to do about ONE gate — `yad doctor`'s `profile:<gate>` hint.
|
|
789
|
+
export function gateProfileFix(b) {
|
|
790
|
+
if (b.state === 'outdated') return 'run `yad update`: it replaces this copy, which yad wrote, with the shipped one';
|
|
791
|
+
if (b.state === 'unrecorded') return `run \`yad update\`: it replaces it with the shipped one and saves yours as ${saved(b)}`;
|
|
792
|
+
if (b.state === 'kept') return `it was changed by hand, so \`yad update\` keeps it: ${PRODUCT_PROFILE_FIX} (your copy is saved as ${saved(b)})`;
|
|
793
|
+
if (b.state === 'drift') return `${driftWords(b.pairs)}, so \`yad update\` refuses until one copy is kept: run \`yad migrate\` to see the difference, then \`yad migrate --apply\` to keep one, then \`yad doctor\` again — it says what this check needs`;
|
|
794
|
+
if (b.state === 'unreadable') return `restore ${b.file} from git — it does not read, so yad cannot tell whether this copy is its own${b.file === MANAGED_LEDGER ? ' (and `yad update` refuses until then)' : ''} — then run \`yad update\` and \`yad doctor\` again`;
|
|
795
|
+
return `fix it by hand — yad does not manage the checks on this Product, so no \`yad update\` replaces it: ${PRODUCT_PROFILE_FIX.split(' — ')[0]}`;
|
|
796
|
+
}
|
|
797
|
+
// What to do before a team changes its `--profile hub` to `product`, or '' when nothing stands in the way —
|
|
798
|
+
// one sentence for `yad doctor` and `yad check`/`update` alike, naming EVERY gate in the way, each group with
|
|
799
|
+
// its own step (review 4: naming only the edited one let a stale one fail every PR).
|
|
800
|
+
export function oldProfileAdvice(blockers) {
|
|
801
|
+
if (!blockers.length) return '';
|
|
802
|
+
const parts = [];
|
|
803
|
+
const split = blockers.filter((b) => b.state === 'drift');
|
|
804
|
+
if (split.length) {
|
|
805
|
+
const names = split.map((b) => b.gate);
|
|
806
|
+
const listed = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)} need` : `${names[0]} needs`;
|
|
807
|
+
parts.push(`run \`yad migrate\` to see the difference, then \`yad migrate --apply\` to keep one copy — ${driftWords(split[0].pairs)}, so every yad command but \`yad doctor\`, \`yad migrate\` and \`yad report\` refuses until then — and \`yad doctor\` again: it says what ${listed}`);
|
|
808
|
+
}
|
|
809
|
+
const lost = blockers.filter((b) => b.state === 'unreadable');
|
|
810
|
+
if (lost.length) {
|
|
811
|
+
parts.push(`restore ${lost[0].file} from git (it does not read, so yad cannot tell ${lost.length > 1 ? `which of ${lost.map(gapWords).join('; and ')} are` : `whether ${gapWords(lost[0])} is`} its own), then run \`yad update\` and \`yad doctor\` again`);
|
|
812
|
+
}
|
|
813
|
+
const replace = blockers.filter((b) => b.state === 'outdated' || b.state === 'unrecorded');
|
|
814
|
+
if (replace.length) {
|
|
815
|
+
const backed = replace.filter((b) => b.state === 'unrecorded');
|
|
816
|
+
parts.push(`run \`yad update\`, which replaces ${replace.map(gapWords).join('; and ')}${backed.length ? ` (saving your ${backed.length > 1 ? 'copies' : 'copy'} as ${backed.map(saved).join(', ')})` : ''}`);
|
|
817
|
+
}
|
|
818
|
+
const kept = blockers.filter((b) => b.state === 'kept');
|
|
819
|
+
if (kept.length) {
|
|
820
|
+
parts.push(`fix ${kept.map(gapWords).join('; and ')} — ${kept.length > 1 ? 'they were' : 'it was'} edited, so \`yad update\` keeps ${kept.length > 1 ? 'them; for each one' : 'it'}: ${PRODUCT_PROFILE_FIX}`);
|
|
821
|
+
}
|
|
822
|
+
const own = blockers.filter((b) => b.state === 'unmanaged');
|
|
823
|
+
if (own.length) {
|
|
824
|
+
parts.push(`fix ${own.map(gapWords).join('; and ')} by hand — yad does not manage the checks on this Product`);
|
|
825
|
+
}
|
|
826
|
+
// `; then`, never `; and` — the gate lists inside a part already use `; and` (review 5).
|
|
827
|
+
return `but first ${parts.join('; then ')}`;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// The new name of a renamed CI file is not installed while an edited old one is kept (`modified`): both
|
|
831
|
+
// would run on GitHub — every gate twice, the stock one undoing whatever the team's edit loosened — and on
|
|
832
|
+
// GitLab the new one would sit there included by nothing. `--overwrite-local` installs it with the rename.
|
|
833
|
+
export function withoutKeptRenames(actions) {
|
|
834
|
+
const kept = new Set(actions.filter((a) => a.rename && a.status === 'modified').map((a) => path.join(a.root, a.rename.to)));
|
|
835
|
+
if (!kept.size) return actions;
|
|
836
|
+
return actions.filter((a) => a.rename || !(a.managed && !exists(a.managed.dest) && kept.has(a.managed.dest)));
|
|
837
|
+
}
|
|
838
|
+
|
|
557
839
|
export function legacyRepoActions(root, repo) {
|
|
558
840
|
return legacyFileActions(repo.name, path.resolve(root, repo.path), LEGACY_REPO_FILES[repo.platform], wiringFor(repo.platform));
|
|
559
841
|
}
|
|
560
842
|
|
|
561
|
-
export function
|
|
562
|
-
const
|
|
563
|
-
if (!isVerifiedLedger(
|
|
564
|
-
const wiring = [...PRODUCT_WIRING.common, ...(PRODUCT_WIRING[
|
|
565
|
-
return legacyFileActions('
|
|
843
|
+
export function legacyProductActions(root) {
|
|
844
|
+
const productConfig = readJSON(productConfigPath(root));
|
|
845
|
+
if (!isVerifiedLedger(productConfig)) return [];
|
|
846
|
+
const wiring = [...PRODUCT_WIRING.common, ...(PRODUCT_WIRING[productConfig.platform] || [])];
|
|
847
|
+
return legacyFileActions('product', root, LEGACY_PRODUCT_FILES[productConfig.platform], wiring);
|
|
566
848
|
}
|
|
567
849
|
|
|
568
850
|
// Per-repo wiring (gate scripts, CI, PR template).
|
|
@@ -588,22 +870,103 @@ export function repoActions(root, repo) {
|
|
|
588
870
|
// whole one-time setup on a repo that never asked for it.
|
|
589
871
|
const wired = Object.keys(ledger).length > 0
|
|
590
872
|
|| actions.some((a) => a.status !== 'missing' && a.item.startsWith('checks/'));
|
|
873
|
+
const link = productLinkAction(root, repo, repoRoot);
|
|
874
|
+
if (link) actions.push(link);
|
|
591
875
|
if (!wired) return actions;
|
|
592
|
-
|
|
876
|
+
// The record is not a template: an absent one on a wired repo is always `new` (nothing to respect).
|
|
877
|
+
const neverWritten = (a) => !a.managed || !ledger[rel(a.managed.root, a.managed.dest)];
|
|
593
878
|
return actions.map((a) => (a.status === 'missing' && neverWritten(a) ? { ...a, status: 'new' } : a));
|
|
594
879
|
}
|
|
595
880
|
|
|
881
|
+
// The git repo a folder belongs to (its top level), or '' when it is not in one.
|
|
882
|
+
const gitTop = (dir) => {
|
|
883
|
+
const r = run('git', ['rev-parse', '--show-toplevel'], { cwd: dir });
|
|
884
|
+
return r.ok ? path.resolve(r.stdout.trim()) : '';
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
// The Product's git_url as a code repo may carry it: no user name or password (review 1). `setup` takes
|
|
888
|
+
// it from `git remote get-url origin`, which can hold a token; the record is committed to every code
|
|
889
|
+
// repo, some of which may be seen by more people than the Product.
|
|
890
|
+
export function publicGitUrl(u) {
|
|
891
|
+
if (typeof u !== 'string' || !u) return null;
|
|
892
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(u)) return u; // scp-style ssh (git@host:org/repo) holds no password
|
|
893
|
+
try {
|
|
894
|
+
const x = new URL(u);
|
|
895
|
+
// An ssh user (`git@`) is the login name, not a secret, and the checkout reads the url by it.
|
|
896
|
+
if (!/^(git\+)?ssh:$/i.test(x.protocol)) x.username = '';
|
|
897
|
+
x.password = '';
|
|
898
|
+
// A token can ride in the query or the fragment too (`?access_token=…`); a clone url needs neither.
|
|
899
|
+
x.search = '';
|
|
900
|
+
x.hash = '';
|
|
901
|
+
return x.toString();
|
|
902
|
+
} catch { return null; }
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// E120. `.sdlc/product-link.json` in a connected code repo: where its Product lives. Generated, not
|
|
906
|
+
// copied from a template, so it is not in the managed ledger. `git_url` and `default_branch` come from
|
|
907
|
+
// the Product's own settings every time; `path` (where CI checks the Product out) is the team's to
|
|
908
|
+
// change and is kept. Any other key in the file is kept too. A code repo that IS the Product's git repo
|
|
909
|
+
// (a monorepo) gets none: the gates already read a Product kept in the repo from the base.
|
|
910
|
+
function productLinkAction(root, repo, repoRoot) {
|
|
911
|
+
if (!exists(repoRoot)) return null;
|
|
912
|
+
const top = gitTop(repoRoot);
|
|
913
|
+
if (!top || top === gitTop(root)) return null;
|
|
914
|
+
const dest = path.join(repoRoot, PRODUCT_LINK);
|
|
915
|
+
const productConfig = readJSON(productConfigPath(root), {}) || {};
|
|
916
|
+
let current;
|
|
917
|
+
try { current = readJSONStrict(dest, null); } catch { current = undefined; }
|
|
918
|
+
const base = {
|
|
919
|
+
scope: repo.name,
|
|
920
|
+
item: PRODUCT_LINK,
|
|
921
|
+
root: repoRoot,
|
|
922
|
+
paths: [PRODUCT_LINK],
|
|
923
|
+
};
|
|
924
|
+
const want = {
|
|
925
|
+
git_url: publicGitUrl(productConfig.git_url),
|
|
926
|
+
path: isPlainObject(current) && typeof current.path === 'string' && current.path ? current.path : PRODUCT_LINK_DEFAULT_PATH,
|
|
927
|
+
// Unknown stays null (review 1): a guessed `main` failed every clone of a Product whose trunk is
|
|
928
|
+
// `master`; with none, checks/product-checkout.sh clones the remote's own default branch.
|
|
929
|
+
default_branch: typeof productConfig.default_branch === 'string' && productConfig.default_branch ? productConfig.default_branch : null,
|
|
930
|
+
};
|
|
931
|
+
// A record that does not parse, or is not an object, is someone's: reported and never overwritten by
|
|
932
|
+
// a plain update; `--overwrite-local` saves it beside itself first, like any managed file.
|
|
933
|
+
if (current === undefined || (current !== null && !isPlainObject(current))) {
|
|
934
|
+
const backup = backupPathFor(dest);
|
|
935
|
+
return {
|
|
936
|
+
...base, status: 'modified', backup,
|
|
937
|
+
apply: () => { fs.copyFileSync(dest, backup); writeJSON(dest, want); },
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
const same = current && ['git_url', 'path', 'default_branch'].every((k) => current[k] === want[k]);
|
|
941
|
+
// Right on disk is `ok` — like every wired file `yad setup` leaves for the team to commit, so a fresh
|
|
942
|
+
// setup reports no drift. But the gates read the record from the default branch, so `--push` must
|
|
943
|
+
// commit one that is not committed AS IT IS on disk (reviews 1–2: a record setup or a plain --fix had
|
|
944
|
+
// written, or rewritten, was never committed by a later --push): `pendingCommit` makes reconcile
|
|
945
|
+
// stage it with the rest. `git diff HEAD` also sees a staged-only change. One the repo's .gitignore
|
|
946
|
+
// covers cannot be committed by --push at all — `yad doctor` says what to do instead.
|
|
947
|
+
const git = (...a) => run('git', a, { cwd: repoRoot }).ok;
|
|
948
|
+
const committed = () => git('ls-files', '--error-unmatch', '--', PRODUCT_LINK) && git('diff', '--quiet', 'HEAD', '--', PRODUCT_LINK);
|
|
949
|
+
const ignored = () => git('check-ignore', '-q', '--', PRODUCT_LINK);
|
|
950
|
+
const status = current === null ? 'missing' : !same ? 'outdated' : 'ok';
|
|
951
|
+
return {
|
|
952
|
+
...base,
|
|
953
|
+
status,
|
|
954
|
+
pendingCommit: status === 'ok' && !committed() && !ignored(),
|
|
955
|
+
apply: () => writeJSON(dest, { ...(current || {}), ...want }),
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
|
|
596
959
|
// Product wiring (gate-sync + verified-commits CI on the Product itself). Only when the Product has a
|
|
597
960
|
// platform and the verified ledger is explicitly enabled — a local Product stays local, with no error.
|
|
598
961
|
export function productActions(root) {
|
|
599
|
-
const
|
|
600
|
-
// `ledger` is the canonical switch and `bridge_enabled` its older spelling (the documented
|
|
962
|
+
const productConfig = readJSON(productConfigPath(root));
|
|
963
|
+
// `ledger` is the canonical switch and `bridge_enabled` its older spelling (the documented Product settings schema); older setup versions
|
|
601
964
|
// wrote `bridge` — `isVerifiedLedger` accepts an explicit true in either spelling, and is the one
|
|
602
965
|
// predicate the CLI, the wiring, and the ledger hook all read (#186). Wire nothing otherwise.
|
|
603
|
-
if (!isVerifiedLedger(
|
|
966
|
+
if (!isVerifiedLedger(productConfig)) return [];
|
|
604
967
|
const ledger = readManagedLedger(root);
|
|
605
|
-
return [...PRODUCT_WIRING.common, ...(PRODUCT_WIRING[
|
|
606
|
-
wiredFileAction('
|
|
968
|
+
return [...PRODUCT_WIRING.common, ...(PRODUCT_WIRING[productConfig.platform] || [])].map((w) =>
|
|
969
|
+
wiredFileAction('product', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }),
|
|
607
970
|
);
|
|
608
971
|
}
|
|
609
972
|
|
|
@@ -867,11 +1230,11 @@ function hookSettingsAction(root, adapter) {
|
|
|
867
1230
|
// the entry that invokes it. Verified-only exactly like `productActions` — with a local ledger it is
|
|
868
1231
|
// locally owned, the hand-edit the authoring skills describe is CORRECT, and a guard would be wrong.
|
|
869
1232
|
export function hookActions(root, ideTargets = ideTargetsFor(root)) {
|
|
870
|
-
const
|
|
871
|
-
if (!isVerifiedLedger(
|
|
1233
|
+
const productConfig = readJSON(productConfigPath(root));
|
|
1234
|
+
if (!isVerifiedLedger(productConfig)) return [];
|
|
872
1235
|
const ledger = readManagedLedger(root);
|
|
873
1236
|
const actions = HOOK_WIRING.map((w) =>
|
|
874
|
-
wiredFileAction('
|
|
1237
|
+
wiredFileAction('product', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }),
|
|
875
1238
|
);
|
|
876
1239
|
for (const ide of safeIdeTargetsFor(root, ideTargets)) {
|
|
877
1240
|
const adapter = HOOK_ADAPTERS[ide];
|
|
@@ -881,7 +1244,7 @@ export function hookActions(root, ideTargets = ideTargetsFor(root)) {
|
|
|
881
1244
|
// `.claude`-only tree is a file nobody can explain. It is pushed BEFORE the settings entry that
|
|
882
1245
|
// points at it, so the two land in the order `asNew` already guarantees they land together.
|
|
883
1246
|
for (const w of adapter.wiring) {
|
|
884
|
-
actions.push(wiredFileAction('
|
|
1247
|
+
actions.push(wiredFileAction('product', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }));
|
|
885
1248
|
}
|
|
886
1249
|
actions.push(hookSettingsAction(root, adapter));
|
|
887
1250
|
}
|
|
@@ -905,8 +1268,8 @@ export function hookActions(root, ideTargets = ideTargetsFor(root)) {
|
|
|
905
1268
|
// Verified-only, like `hookActions`, and for the same reason: with a local ledger none of this was
|
|
906
1269
|
// installed in the first place.
|
|
907
1270
|
export function orphanHookActions(root, ideTargets = ideTargetsFor(root)) {
|
|
908
|
-
const
|
|
909
|
-
if (!isVerifiedLedger(
|
|
1271
|
+
const productConfig = readJSON(productConfigPath(root));
|
|
1272
|
+
if (!isVerifiedLedger(productConfig)) return [];
|
|
910
1273
|
const kept = new Set(safeIdeTargetsFor(root, ideTargets));
|
|
911
1274
|
const stillNeeded = new Set(
|
|
912
1275
|
[...kept].flatMap((ide) => (HOOK_ADAPTERS[ide]?.wiring || []).map((w) => w.dest)),
|
|
@@ -947,7 +1310,7 @@ export function orphanHookActions(root, ideTargets = ideTargetsFor(root)) {
|
|
|
947
1310
|
const dest = path.join(root, w.dest);
|
|
948
1311
|
if (!exists(dest)) continue;
|
|
949
1312
|
actions.push({
|
|
950
|
-
scope: '
|
|
1313
|
+
scope: 'product',
|
|
951
1314
|
item: `${w.dest} (removed)`,
|
|
952
1315
|
status: 'removed',
|
|
953
1316
|
root,
|
|
@@ -959,9 +1322,145 @@ export function orphanHookActions(root, ideTargets = ideTargetsFor(root)) {
|
|
|
959
1322
|
return actions;
|
|
960
1323
|
}
|
|
961
1324
|
|
|
1325
|
+
// ---- the git pre-commit hook (E48) ----------------------------------------------------------------
|
|
1326
|
+
//
|
|
1327
|
+
// The person's half of the ledger guard. `hooks/ledger-guard.mjs` refuses an AGENT's edit; this refuses a
|
|
1328
|
+
// PERSON's `git commit` of the same files, by running that same script with `--staged`. Verified-only,
|
|
1329
|
+
// like `hookActions`: with a local ledger the person owns the ledger and there is nothing to refuse.
|
|
1330
|
+
//
|
|
1331
|
+
// PER CLONE, NOT COMMITTED. Git never commits `.git/hooks`, so every clone needs its own copy, and
|
|
1332
|
+
// `yad check --fix` (or setup, or update) installs it. That is why nothing here touches the provenance
|
|
1333
|
+
// record: that file is committed, and recording this hook in it would tell every teammate's `yad check`
|
|
1334
|
+
// the hook is there when their clone has none. Ours is recognised by its marker line instead, and
|
|
1335
|
+
// `paths` is empty so `yad update --push` never stages it.
|
|
1336
|
+
//
|
|
1337
|
+
// NEVER SOMEONE ELSE'S HOOK. A `pre-commit` without our marker is the team's (or husky's, or
|
|
1338
|
+
// pre-commit's), and `core.hooksPath` means a tool manages hooks from a folder that is usually committed.
|
|
1339
|
+
// Either way nothing is written: `gitHookState` says so, with the one line to add by hand, and doctor
|
|
1340
|
+
// and `yad check` print it.
|
|
1341
|
+
export const GIT_HOOK_MARKER = '# yadflow ledger-guard (E48)';
|
|
1342
|
+
const shQuote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
1343
|
+
export function gitPreCommitScript(prefix = '') {
|
|
1344
|
+
return [
|
|
1345
|
+
'#!/bin/sh',
|
|
1346
|
+
`${GIT_HOOK_MARKER} — installed in this clone by \`yad check --fix\`.`,
|
|
1347
|
+
'# Refuses a commit that changes a file only CI may change on a verified Product (the gate ledger and',
|
|
1348
|
+
'# the Product index), the same files the `ledger-guard` check in CI refuses. It fails open: with no',
|
|
1349
|
+
'# node, or no guard script, it allows. Skip it once with YAD_HOOK_DISABLE=1. Git runs it from the top',
|
|
1350
|
+
'# of the repo. yad rewrites this file; to stop it, delete the file.',
|
|
1351
|
+
`guard=${shQuote(`${prefix}hooks/ledger-guard.mjs`)}`,
|
|
1352
|
+
'command -v node >/dev/null 2>&1 || exit 0',
|
|
1353
|
+
'[ -f "$guard" ] || exit 0',
|
|
1354
|
+
'node "$guard" --staged || [ $? -ne 2 ] || exit 1',
|
|
1355
|
+
'exit 0',
|
|
1356
|
+
'',
|
|
1357
|
+
].join('\n');
|
|
1358
|
+
}
|
|
1359
|
+
// The line a person adds to a hook yad will not touch, run from the top of the repo.
|
|
1360
|
+
// It fails open as the script does: only the guard's refusal (2) stops the commit, and its own status is
|
|
1361
|
+
// 0 otherwise, so it is safe as the last line of a hook. Every command that can fail sits on the left of
|
|
1362
|
+
// `||`, so it is safe under `sh -e` too (husky runs hooks that way): no guard file, no node, or a crash
|
|
1363
|
+
// never stops the commit.
|
|
1364
|
+
export const gitHookLine = (prefix = '') => {
|
|
1365
|
+
const guard = shQuote(`${prefix}hooks/ledger-guard.mjs`);
|
|
1366
|
+
return `[ ! -f ${guard} ] || node ${guard} --staged || [ $? -ne 2 ] || exit 1`;
|
|
1367
|
+
};
|
|
1368
|
+
|
|
1369
|
+
// Where this clone's pre-commit hook is, and whose it is. `{ applies: false }` when there is nothing to
|
|
1370
|
+
// install (a local ledger, or no git). Otherwise `{ applies: true, file, expected, prefix, state }` with
|
|
1371
|
+
// `state` one of: 'missing' | 'ok' | 'outdated' (ours, different bytes) | 'foreign' (a hook that is not
|
|
1372
|
+
// ours) | 'hooks-path' (core.hooksPath is set). The last two carry `line`, the one to add by hand.
|
|
1373
|
+
export function gitHookState(root) {
|
|
1374
|
+
if (!isVerifiedLedger(readJSON(productConfigPath(root)))) return { applies: false };
|
|
1375
|
+
const where = run('git', ['-C', root, 'rev-parse', '--git-path', 'hooks/pre-commit', '--show-prefix']);
|
|
1376
|
+
if (!where.ok) return { applies: false };
|
|
1377
|
+
const [hookRel, prefix = ''] = where.stdout.split('\n');
|
|
1378
|
+
const file = path.resolve(root, hookRel);
|
|
1379
|
+
const expected = gitPreCommitScript(prefix);
|
|
1380
|
+
const line = gitHookLine(prefix);
|
|
1381
|
+
const hooksPath = run('git', ['-C', root, 'config', '--get', 'core.hooksPath']);
|
|
1382
|
+
// A core.hooksPath that names the default folder anyway (some tools set it so) is not a tool's folder.
|
|
1383
|
+
// `--path-format` needs git 2.31; an older git fails the call, and the setting is then treated as a tool's.
|
|
1384
|
+
const common = run('git', ['-C', root, 'rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
1385
|
+
// The folder is compared through its PARENT, which always exists: `.git/hooks` itself may not (a clone
|
|
1386
|
+
// made with an empty template), and a path that does not exist cannot be resolved to compare.
|
|
1387
|
+
// When the folder does exist, it is compared whole: a setting typed `.git/Hooks` on a disk that ignores
|
|
1388
|
+
// case is the same folder, and the OS gives both sides one spelling.
|
|
1389
|
+
const hooksDir = path.dirname(file);
|
|
1390
|
+
const isDefault = common.ok && (samePath(hooksDir, path.join(common.stdout, 'hooks'))
|
|
1391
|
+
|| (path.basename(hooksDir) === 'hooks' && samePath(path.dirname(hooksDir), common.stdout)));
|
|
1392
|
+
if (hooksPath.ok && hooksPath.stdout && !isDefault) {
|
|
1393
|
+
// husky points core.hooksPath at `.husky/_`, a folder it generates; the hook a person edits is the one
|
|
1394
|
+
// beside it, `.husky/pre-commit`. Name that one, or the advice sends them to a file husky rewrites.
|
|
1395
|
+
const dir = hooksPath.stdout.replace(/[\\/]+$/, '');
|
|
1396
|
+
const edit = /(^|[\\/])_$/.test(dir) ? `${dir.replace(/[\\/]?_$/, '') || '.'}/pre-commit` : `${dir}/pre-commit`;
|
|
1397
|
+
return { applies: true, file, expected, prefix, line, hooksPath: hooksPath.stdout, editFile: edit, state: 'hooks-path' };
|
|
1398
|
+
}
|
|
1399
|
+
let text = null;
|
|
1400
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { /* absent */ }
|
|
1401
|
+
if (text === null) return { applies: true, file, expected, prefix, state: 'missing' };
|
|
1402
|
+
if (!text.includes(GIT_HOOK_MARKER)) return { applies: true, file, expected, prefix, line, state: 'foreign' };
|
|
1403
|
+
// Git silently skips a hook that is not executable (everywhere but Windows), so right bytes without the
|
|
1404
|
+
// bit are not an installed hook.
|
|
1405
|
+
let runnable = true;
|
|
1406
|
+
if (process.platform !== 'win32') { try { runnable = (fs.statSync(file).mode & 0o111) !== 0; } catch { runnable = false; } }
|
|
1407
|
+
if (text === expected && runnable) return { applies: true, file, expected, prefix, state: 'ok' };
|
|
1408
|
+
return { applies: true, file, expected, prefix, state: 'outdated', why: text === expected ? 'not-executable' : 'changed' };
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// The install action. 'foreign' and 'hooks-path' build no action — there is nothing yad may write —
|
|
1412
|
+
// and are reported by `gitHookAdvice` instead.
|
|
1413
|
+
export function gitHookActions(root) {
|
|
1414
|
+
const st = gitHookState(root);
|
|
1415
|
+
if (!st.applies || st.state === 'foreign' || st.state === 'hooks-path') return [];
|
|
1416
|
+
const status = st.state === 'missing' ? 'new' : st.state;
|
|
1417
|
+
// An outdated copy may carry a person's edit below our marker, so it is saved beside itself first —
|
|
1418
|
+
// the same rule `wiredFileAction` keeps for content it cannot prove it wrote. Not when only the execute
|
|
1419
|
+
// bit is missing: nothing of anyone's is being replaced then.
|
|
1420
|
+
const sameBytes = st.state === 'outdated' && (() => { try { return fs.readFileSync(st.file, 'utf8') === st.expected; } catch { return false; } })();
|
|
1421
|
+
const backup = st.state === 'outdated' && !sameBytes ? backupPathFor(st.file) : null;
|
|
1422
|
+
return [{
|
|
1423
|
+
scope: 'product',
|
|
1424
|
+
item: 'pre-commit git hook (this clone)',
|
|
1425
|
+
status,
|
|
1426
|
+
root,
|
|
1427
|
+
paths: [],
|
|
1428
|
+
backup,
|
|
1429
|
+
apply: () => {
|
|
1430
|
+
if (backup) fs.copyFileSync(st.file, backup);
|
|
1431
|
+
fs.mkdirSync(path.dirname(st.file), { recursive: true });
|
|
1432
|
+
fs.writeFileSync(st.file, st.expected, { mode: 0o755 });
|
|
1433
|
+
fs.chmodSync(st.file, 0o755);
|
|
1434
|
+
},
|
|
1435
|
+
}];
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// A local ledger: the hook yad installed while the Product was verified has nothing to guard. Removed
|
|
1439
|
+
// only when its bytes are exactly what yad writes — an edited copy is left for its owner.
|
|
1440
|
+
export function orphanGitHookActions(root) {
|
|
1441
|
+
if (isVerifiedLedger(readJSON(productConfigPath(root)))) return [];
|
|
1442
|
+
const where = run('git', ['-C', root, 'rev-parse', '--git-path', 'hooks/pre-commit', '--show-prefix']);
|
|
1443
|
+
if (!where.ok) return [];
|
|
1444
|
+
const [hookRel, prefix = ''] = where.stdout.split('\n');
|
|
1445
|
+
const file = path.resolve(root, hookRel);
|
|
1446
|
+
let text;
|
|
1447
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
1448
|
+
if (text !== gitPreCommitScript(prefix)) return [];
|
|
1449
|
+
return [{ scope: 'product', item: 'pre-commit git hook (this clone) (removed)', status: 'removed', root, paths: [], apply: () => fs.rmSync(file, { force: true }) }];
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// The sentence for a clone where yad may not write the hook, or null.
|
|
1453
|
+
// A tool that owns the hook (the pre-commit framework, husky v4) may rewrite it on reinstall, which drops
|
|
1454
|
+
// an added line, so the advice says to check after one.
|
|
1455
|
+
export function gitHookAdvice(st) {
|
|
1456
|
+
if (st?.state === 'hooks-path') return `git's core.hooksPath is set (${st.hooksPath}), so yad did not install its pre-commit ledger guard; add this line to ${st.editFile}: ${st.line}`;
|
|
1457
|
+
if (st?.state === 'foreign') return `${st.file} is not yad's, so yad left it alone; add this line to it to refuse a hand commit to the CI-owned ledger (and again if a tool rewrites the hook): ${st.line}`;
|
|
1458
|
+
return null;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
962
1461
|
// ---- background capture wiring (E43) -----------------------------------------------------------
|
|
963
1462
|
//
|
|
964
|
-
// The post-edit capture hook: `hooks/yad-capture.
|
|
1463
|
+
// The post-edit capture hook: `hooks/yad-capture.mjs`, plus one entry per IDE target that has a post-edit
|
|
965
1464
|
// protocol. Unlike `hookActions` it is wired in BOTH ledger modes — never losing a draft matters whoever
|
|
966
1465
|
// owns the ledger — on any Product (a `.sdlc/hub.json` or `product.json`) whose config does not say
|
|
967
1466
|
// `"capture": false`. Only the CONFIG turns the wiring off: `YAD_CAPTURE=0` is one shell's choice, and
|
|
@@ -977,7 +1476,7 @@ export function captureHookActions(root, ideTargets = ideTargetsFor(root)) {
|
|
|
977
1476
|
if (!captureWanted(root)) return [];
|
|
978
1477
|
const ledger = readManagedLedger(root);
|
|
979
1478
|
const actions = CAPTURE_WIRING.map((w) =>
|
|
980
|
-
wiredFileAction('
|
|
1479
|
+
wiredFileAction('product', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }),
|
|
981
1480
|
);
|
|
982
1481
|
for (const ide of safeIdeTargetsFor(root, ideTargets)) {
|
|
983
1482
|
const adapter = CAPTURE_ADAPTERS[ide];
|
|
@@ -1022,9 +1521,183 @@ export function orphanCaptureHookActions(root, ideTargets = ideTargetsFor(root))
|
|
|
1022
1521
|
for (const w of CAPTURE_WIRING) {
|
|
1023
1522
|
const dest = path.join(root, w.dest);
|
|
1024
1523
|
if (!exists(dest)) continue;
|
|
1025
|
-
actions.push({ scope: '
|
|
1524
|
+
actions.push({ scope: 'product', item: `${w.dest} (removed)`, status: 'removed', root, paths: [w.dest], apply: () => fs.rmSync(dest, { force: true }) });
|
|
1026
1525
|
}
|
|
1027
1526
|
}
|
|
1028
1527
|
return actions;
|
|
1029
1528
|
}
|
|
1030
1529
|
|
|
1530
|
+
// ---- the shell-script hooks, retired (E113) -----------------------------------------------------------
|
|
1531
|
+
//
|
|
1532
|
+
// Every hook was a bash script until E113; each is now a Node script, and the adapters list the old
|
|
1533
|
+
// commands as `legacyCommands`, so the same `yad check --fix` that rewrites an entry also installs the new
|
|
1534
|
+
// file. This removes the old one — status `removed`, so `yad update` applies it too.
|
|
1535
|
+
//
|
|
1536
|
+
// ONLY A COPY YAD PROVABLY WROTE. The provenance record (`.sdlc/managed.json`) must hold the script's sha,
|
|
1537
|
+
// and the file on disk must still match it. An edited copy, or one from before the record existed, is left
|
|
1538
|
+
// alone: the header of each script invited a team to wire it into another harness by hand, and deleting a
|
|
1539
|
+
// file their own hook runs would switch that hook off without a word.
|
|
1540
|
+
//
|
|
1541
|
+
// And never while anything still RUNS it — in two places, both read at PLAN time so `yad check` shows only
|
|
1542
|
+
// what `--fix` will really do (an action that plans a removal and then keeps the file would be pending on
|
|
1543
|
+
// every run, for ever):
|
|
1544
|
+
// - the settings files AS COMMITTED (HEAD). The rewritten entry is the team's to commit (a settings file is
|
|
1545
|
+
// never staged by `--push`), so until it is, everyone who pulls runs the old command — deleting the
|
|
1546
|
+
// script under it would leave them all a hook that does not exist. The removal waits for a later run,
|
|
1547
|
+
// after the new entry is committed.
|
|
1548
|
+
// - the settings files ON DISK AS THIS RUN WILL LEAVE THEM: each adapter's own entry merged or unmerged
|
|
1549
|
+
// exactly as this run's actions do it (review 2 — excusing our old commands wherever they appeared
|
|
1550
|
+
// also excused copies no action touches, under another event or in `settings.local.json`, and those
|
|
1551
|
+
// were planned, kept, and pending for ever).
|
|
1552
|
+
// Both harness settings files are read, plus Claude Code's personal `settings.local.json`, and a command
|
|
1553
|
+
// is matched with either kind of slash (`hooks\yad-capture.sh` on Windows).
|
|
1554
|
+
const HOOK_SETTINGS_FILES = [...new Set([...Object.values(HOOK_ADAPTERS), ...Object.values(CAPTURE_ADAPTERS)].map((a) => a.settings)), '.claude/settings.local.json'];
|
|
1555
|
+
function commandsIn(value, out = []) {
|
|
1556
|
+
if (Array.isArray(value)) { for (const v of value) commandsIn(v, out); return out; }
|
|
1557
|
+
if (!isPlainObject(value)) return out;
|
|
1558
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1559
|
+
if (k === 'command' && typeof v === 'string') out.push(v);
|
|
1560
|
+
else if (k === 'args' && Array.isArray(v)) out.push(...v.filter((a) => typeof a === 'string'));
|
|
1561
|
+
else commandsIn(v, out);
|
|
1562
|
+
}
|
|
1563
|
+
return out;
|
|
1564
|
+
}
|
|
1565
|
+
const namesScript = (text, scriptRel) => text.replace(/\\/g, '/').includes(scriptRel);
|
|
1566
|
+
// Which settings files name the script, read from `raw(file)` (null = absent). A file that will not parse
|
|
1567
|
+
// names it when its text does: nothing can be proved about it, so it keeps the script.
|
|
1568
|
+
function namedIn(raw, scriptRel) {
|
|
1569
|
+
const where = [];
|
|
1570
|
+
for (const file of HOOK_SETTINGS_FILES) {
|
|
1571
|
+
const text = raw(file);
|
|
1572
|
+
if (text === null) continue;
|
|
1573
|
+
let parsed;
|
|
1574
|
+
try { parsed = JSON.parse(text); } catch { if (namesScript(text, scriptRel)) where.push(file); continue; }
|
|
1575
|
+
if (commandsIn(parsed).some((cmd) => namesScript(cmd, scriptRel))) where.push(file);
|
|
1576
|
+
}
|
|
1577
|
+
return where;
|
|
1578
|
+
}
|
|
1579
|
+
const onDisk = (root) => (file) => { try { return fs.readFileSync(path.join(root, file), 'utf8'); } catch { return null; } };
|
|
1580
|
+
// `HEAD:./<file>` is read relative to the Product (the `./`), so a Product in a subfolder of its repository
|
|
1581
|
+
// reads its own files. Not a repository, no commit yet, or a file never committed: nothing is committed.
|
|
1582
|
+
// The old scripts that belonged to the ledger guard, which only a verified Product has.
|
|
1583
|
+
const GUARD_SCRIPTS = new Set(['hooks/ledger-guard.sh', 'hooks/ledger-guard-cursor.sh']);
|
|
1584
|
+
|
|
1585
|
+
// What to do with an entry that still runs an old script: repoint it at the Node script yad now writes —
|
|
1586
|
+
// except a guard script on a Product whose ledger is not verified (or cannot be read), where no guard is
|
|
1587
|
+
// installed to point at, so the entry is removed. One sentence for the plan's note AND apply's warning, so
|
|
1588
|
+
// the two cannot give different advice (review 5).
|
|
1589
|
+
//
|
|
1590
|
+
// It never promises what `yad check --fix` will do: both callers are reached only for entries that run has
|
|
1591
|
+
// NOT taken out (another event, `settings.local.json`, a file changed since the plan), so the reader acts.
|
|
1592
|
+
export function entryAdvice(root, scriptRel) {
|
|
1593
|
+
const productConfig = readJSON(productConfigPath(root), null);
|
|
1594
|
+
// Capture off: no Node capture script is installed, so there is nothing to point at (review 8).
|
|
1595
|
+
if (!GUARD_SCRIPTS.has(scriptRel) && !captureWanted(root)) {
|
|
1596
|
+
return 'remove each entry that runs it — capture is off here (`"capture": false` in the Product config), so no capture hook should run';
|
|
1597
|
+
}
|
|
1598
|
+
if (!GUARD_SCRIPTS.has(scriptRel) || isVerifiedLedger(productConfig)) {
|
|
1599
|
+
return 'point each entry that runs it at the `node hooks/….mjs` command yad now writes, or remove it if that harness should no longer run the hook';
|
|
1600
|
+
}
|
|
1601
|
+
return productConfig === null
|
|
1602
|
+
? 'the Product config does not read, so yad cannot tell whether a ledger guard belongs here — fix the config, or remove each entry that runs it'
|
|
1603
|
+
: 'remove each entry that runs it — this Product has no ledger guard (its ledger is not verified), so `yad check --fix` leaves those entries alone';
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// Read once per file per plan, however many old scripts ask (review 3).
|
|
1607
|
+
const committed = (root) => {
|
|
1608
|
+
const seen = new Map();
|
|
1609
|
+
return (file) => {
|
|
1610
|
+
if (!seen.has(file)) {
|
|
1611
|
+
const r = run('git', ['-C', root, 'show', `HEAD:./${file}`]);
|
|
1612
|
+
seen.set(file, r.ok ? r.stdout : null);
|
|
1613
|
+
}
|
|
1614
|
+
return seen.get(file);
|
|
1615
|
+
};
|
|
1616
|
+
};
|
|
1617
|
+
// The settings files on disk as this run's hook actions will leave them. The same choices the actions make:
|
|
1618
|
+
// the guard's entry merged for a verified Product's current targets and unmerged for its dropped ones
|
|
1619
|
+
// (`hookActions` / `orphanHookActions`), nothing done to it with a local ledger; capture's merged for the
|
|
1620
|
+
// current targets while capture is wanted and unmerged otherwise (`captureHookActions` /
|
|
1621
|
+
// `orphanCaptureHookActions`). A file the merge cannot read is left as it is, as the action leaves it.
|
|
1622
|
+
function afterThisRun(root, ideTargets) {
|
|
1623
|
+
const targets = new Set(safeIdeTargetsFor(root, ideTargets));
|
|
1624
|
+
const verified = isVerifiedLedger(readJSON(productConfigPath(root)));
|
|
1625
|
+
const capture = captureWanted(root);
|
|
1626
|
+
const plans = [
|
|
1627
|
+
...(verified ? Object.values(HOOK_ADAPTERS) : []).map((a) => [a, targets.has(a.target)]),
|
|
1628
|
+
...Object.values(CAPTURE_ADAPTERS).map((a) => [a, capture && targets.has(a.target)]),
|
|
1629
|
+
];
|
|
1630
|
+
const read = onDisk(root);
|
|
1631
|
+
return (file) => {
|
|
1632
|
+
const text = read(file);
|
|
1633
|
+
if (text === null) return null;
|
|
1634
|
+
let settings;
|
|
1635
|
+
try { settings = JSON.parse(text); } catch { return text; }
|
|
1636
|
+
if (!isPlainObject(settings)) return text;
|
|
1637
|
+
for (const [adapter, wired] of plans) {
|
|
1638
|
+
if (adapter.settings !== file) continue;
|
|
1639
|
+
const r = wired ? mergeHookSettings(settings, adapter) : unmergeHookSettings(settings, adapter);
|
|
1640
|
+
if (!r.unreadable) settings = r.settings;
|
|
1641
|
+
}
|
|
1642
|
+
return JSON.stringify(settings);
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
export function legacyHookScriptActions(root, ideTargets = ideTargetsFor(root)) {
|
|
1646
|
+
if (!exists(productConfigPath(root))) return [];
|
|
1647
|
+
const ledger = readManagedLedger(root);
|
|
1648
|
+
const actions = [];
|
|
1649
|
+
const head = committed(root);
|
|
1650
|
+
const ours = new Set([...Object.values(HOOK_ADAPTERS), ...Object.values(CAPTURE_ADAPTERS)].flatMap((a) => a.legacyCommands));
|
|
1651
|
+
for (const scriptRel of LEGACY_HOOK_SCRIPTS) {
|
|
1652
|
+
const file = path.join(root, scriptRel);
|
|
1653
|
+
if (!lstatIfPresent(file)?.isFile()) continue;
|
|
1654
|
+
const recorded = ledger[scriptRel];
|
|
1655
|
+
if (!recorded || recorded !== contentSha(file)) continue;
|
|
1656
|
+
const after = afterThisRun(root, ideTargets);
|
|
1657
|
+
const onDiskAfter = namedIn(after, scriptRel);
|
|
1658
|
+
if (onDiskAfter.length) {
|
|
1659
|
+
// Kept because something still runs it. A command of the team's own is their choice and is left in
|
|
1660
|
+
// silence; one of OURS that no action reaches — another event, `settings.local.json` — is stuck, and
|
|
1661
|
+
// is named with the way out (review 3).
|
|
1662
|
+
const stuck = onDiskAfter.filter((f) => {
|
|
1663
|
+
try { return commandsIn(JSON.parse(after(f))).some((cmd) => ours.has(cmd) && namesScript(cmd, scriptRel)); } catch { return false; }
|
|
1664
|
+
});
|
|
1665
|
+
if (stuck.length) {
|
|
1666
|
+
info(`${scriptRel} stays — ${stuck.join(' and ')} still ${stuck.length > 1 ? 'run' : 'runs'} it through an old yad hook command. To clear it: ${entryAdvice(root, scriptRel)}; then \`yad check --fix\` removes the script`);
|
|
1667
|
+
}
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
const inHead = namedIn(head, scriptRel);
|
|
1671
|
+
if (inHead.length) {
|
|
1672
|
+
// The one wait a person ends: committing the rewritten settings. Said, so the second run is not a mystery.
|
|
1673
|
+
info(`${scriptRel} stays until ${inHead.join(' and ')} ${inHead.length > 1 ? 'are' : 'is'} committed as ${inHead.length > 1 ? 'they now stand' : 'it now stands'} — then \`yad check --fix\` removes it`);
|
|
1674
|
+
continue;
|
|
1675
|
+
}
|
|
1676
|
+
actions.push({
|
|
1677
|
+
scope: 'product',
|
|
1678
|
+
item: `${scriptRel} (removed)`,
|
|
1679
|
+
status: 'removed',
|
|
1680
|
+
root,
|
|
1681
|
+
paths: [scriptRel, MANAGED_LEDGER],
|
|
1682
|
+
apply: () => {
|
|
1683
|
+
// Checked once more, on disk and with nothing excused: the rewrites above have run by now, so any
|
|
1684
|
+
// command still naming the script is one this run did not take out.
|
|
1685
|
+
const where = namedIn(onDisk(root), scriptRel);
|
|
1686
|
+
if (where.length) {
|
|
1687
|
+
// Reached when a plan's assumption did not hold at apply — e.g. `yad setup`, which does not unwire a
|
|
1688
|
+
// dropped target the way `yad check --fix` does (review 3).
|
|
1689
|
+
warn(`${scriptRel} kept — ${where.join(', ')} still ${where.length > 1 ? 'run' : 'runs'} it. To clear it: ${entryAdvice(root, scriptRel)}`);
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
fs.rmSync(file, { force: true });
|
|
1693
|
+
// Drop its line from the provenance record, so the record lists only files that are there.
|
|
1694
|
+
const current = readManagedLedger(root);
|
|
1695
|
+
if (Object.hasOwn(current, scriptRel)) {
|
|
1696
|
+
delete current[scriptRel];
|
|
1697
|
+
writeJSON(path.join(root, MANAGED_LEDGER), { version: VERSION, files: current });
|
|
1698
|
+
}
|
|
1699
|
+
},
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
return actions;
|
|
1703
|
+
}
|