yadflow 3.12.0 → 3.12.2

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 CHANGED
@@ -1,3 +1,18 @@
1
+ ## [3.12.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.12.1...v3.12.2) (2026-07-14)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **checks:** waive verified-commits signature for content-free merge commits ([1e73837](https://github.com/abdelrahmannasr/yadflow/commit/1e738372f821e93020069b565d40315cd7be2591)), closes [#138](https://github.com/abdelrahmannasr/yadflow/issues/138)
7
+
8
+ ## [3.12.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.12.0...v3.12.1) (2026-07-14)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **cli:** reject unsafe detected IDE targets and opencode write destinations ([792a40b](https://github.com/abdelrahmannasr/yadflow/commit/792a40b399b92f8db9d560e314110c432b98d93e)), closes [#134](https://github.com/abdelrahmannasr/yadflow/issues/134)
14
+ * **cli:** repair and validate persisted IDE targets ([81242ed](https://github.com/abdelrahmannasr/yadflow/commit/81242ed9a075ea067acb2f4497a745ee40e540a6))
15
+
1
16
  # [3.12.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.11.1...v3.12.0) (2026-07-11)
2
17
 
3
18
 
package/cli/manifest.mjs CHANGED
@@ -116,8 +116,10 @@ export const LEGACY_HUB_FILES = {
116
116
  };
117
117
 
118
118
  // IDE install targets (relative to the target project root).
119
- export const IDE_FOLDER_TARGETS = ['.claude', '.agents', '.zencoder']; // <ide>/skills/<skill>/ (folder copy)
120
- export const IDE_OPENCODE_DIR = '.opencode/commands'; // <skill>.md (flat SKILL.md copy)
119
+ export const IDE_FOLDER_TARGETS = Object.freeze(['.claude', '.agents', '.zencoder']); // <ide>/skills/<skill>/ (folder copy)
120
+ export const IDE_OPENCODE_TARGET = '.opencode';
121
+ export const IDE_TARGETS = Object.freeze([...IDE_FOLDER_TARGETS, IDE_OPENCODE_TARGET]);
122
+ export const IDE_OPENCODE_DIR = `${IDE_OPENCODE_TARGET}/commands`; // <skill>.md (flat SKILL.md copy)
121
123
 
122
124
  // Module registration files copied from skills/sdlc/ into _bmad/sdlc/.
123
125
  export const MODULE_FILES = ['config.yaml', 'module-help.csv'];
package/cli/plan.mjs CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  asset, exists, copyDir, copyFile, dirMatches, sameContent, readJSON,
8
8
  } from './lib.mjs';
9
9
  import {
10
- SKILLS, IDE_FOLDER_TARGETS, IDE_OPENCODE_DIR, MODULE_FILES, wiringFor, HUB_WIRING, PROJECT_FILES,
10
+ SKILLS, IDE_TARGETS, IDE_OPENCODE_DIR, MODULE_FILES, wiringFor, HUB_WIRING, PROJECT_FILES,
11
11
  LEGACY_SKILLS, REMOVED_SKILLS, LEGACY_MARKER, LEGACY_REPO_FILES, LEGACY_HUB_FILES,
12
12
  } from './manifest.mjs';
13
13
 
@@ -36,13 +36,149 @@ const dirAction = (scope, item, src, dest, { root } = {}) => ({
36
36
  apply: () => copyDir(src, dest),
37
37
  });
38
38
 
39
- // Which IDE targets this project wants. Recorded at setup time; falls back to
40
- // whichever IDE base dirs already exist, else .claude.
39
+ // Persisted state gets one deliberately narrow compatibility repair. Explicit setup/planner input
40
+ // does not: a caller typo is an error, while the known v3.11.1 `.cluade` stamp is safely migrated.
41
+ const PERSISTED_IDE_ALIASES = new Map([['.cluade', '.claude']]);
42
+ const IDE_TARGET_ERROR_CODE = 'YAD_IDE_TARGET';
43
+ const sameTargets = (a, b) => Array.isArray(a) && a.length === b.length && a.every((v, i) => v === b[i]);
44
+ const displayTarget = (value) => {
45
+ if (value === undefined) return 'undefined';
46
+ try { return JSON.stringify(value) ?? String(value); } catch { return String(value); }
47
+ };
48
+ const ideTargetError = (message) => Object.assign(new Error(message), { code: IDE_TARGET_ERROR_CODE });
49
+ const lstatIfPresent = (full) => {
50
+ try {
51
+ return fs.lstatSync(full);
52
+ } catch (e) {
53
+ if (e?.code === 'ENOENT') return null;
54
+ throw e;
55
+ }
56
+ };
57
+ const ideContainers = (ide) => ide === '.opencode'
58
+ ? [ide, IDE_OPENCODE_DIR]
59
+ : [ide, path.join(ide, 'skills')];
60
+
61
+ function assertSafeIdeContainers(root, ide) {
62
+ for (const relPath of ideContainers(ide)) {
63
+ const stat = lstatIfPresent(path.join(root, relPath));
64
+ if (!stat) continue;
65
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
66
+ const kind = stat.isSymbolicLink() ? 'a symbolic link' : 'not a directory';
67
+ throw ideTargetError(`unsafe IDE target '${ide}': ${relPath} is ${kind}`);
68
+ }
69
+ }
70
+ }
71
+
72
+ function assertSafeOpenCodeWriteDestinations(root, skills) {
73
+ for (const skill of new Set(skills)) {
74
+ const relPath = path.join(IDE_OPENCODE_DIR, `${skill}.md`);
75
+ const stat = lstatIfPresent(path.join(root, relPath));
76
+ if (!stat) continue;
77
+ let kind = null;
78
+ if (stat.isSymbolicLink()) kind = 'a symbolic link';
79
+ else if (!stat.isFile()) kind = 'not a regular file';
80
+ else if (stat.nlink > 1) kind = 'linked to multiple paths';
81
+ if (kind) throw ideTargetError(`unsafe IDE target '.opencode': ${relPath} is ${kind}`);
82
+ }
83
+ }
84
+
85
+ // Pure target inspection. Valid entries are trimmed and deduplicated in input order; unsupported
86
+ // entries are returned to the caller for reporting rather than ever becoming filesystem paths.
87
+ export function normalizeIdeTargets(input, { repairAliases = false } = {}) {
88
+ const shapeValid = Array.isArray(input);
89
+ const invalid = shapeValid ? [] : [input];
90
+ const repaired = [];
91
+ const targets = [];
92
+ const seen = new Set();
93
+ for (const raw of shapeValid ? input : []) {
94
+ if (typeof raw !== 'string') { invalid.push(raw); continue; }
95
+ const trimmed = raw.trim();
96
+ const target = repairAliases ? (PERSISTED_IDE_ALIASES.get(trimmed) || trimmed) : trimmed;
97
+ if (!IDE_TARGETS.includes(target)) { invalid.push(raw); continue; }
98
+ if (target !== trimmed) repaired.push({ from: trimmed, to: target });
99
+ if (!seen.has(target)) { seen.add(target); targets.push(target); }
100
+ }
101
+ return { targets, invalid, repaired, shapeValid };
102
+ }
103
+
104
+ // Strict boundary for every explicit action-builder/setup input. Returning only canonical values
105
+ // makes path construction below safe by construction.
106
+ export function canonicalIdeTargets(input) {
107
+ const state = normalizeIdeTargets(input);
108
+ if (!state.shapeValid) {
109
+ throw ideTargetError(`IDE targets must be a non-empty array (supported: ${IDE_TARGETS.join(', ')})`);
110
+ }
111
+ if (state.invalid.length) {
112
+ throw ideTargetError(`unsupported IDE target(s): ${state.invalid.map(displayTarget).join(', ')} (supported: ${IDE_TARGETS.join(', ')})`);
113
+ }
114
+ if (!state.targets.length) {
115
+ throw ideTargetError(`at least one IDE target is required (supported: ${IDE_TARGETS.join(', ')})`);
116
+ }
117
+ return state.targets;
118
+ }
119
+
120
+ // A canonical relative name is necessary but not sufficient: an existing IDE root (or its install
121
+ // container) could be a file or symlink that redirects writes/removals outside the project. Validate
122
+ // every target before constructing ANY actions, so a bad later target cannot cause a partial install.
123
+ export function safeIdeTargetsFor(root, input) {
124
+ const targets = canonicalIdeTargets(input);
125
+ for (const ide of targets) assertSafeIdeContainers(root, ide);
126
+ return targets;
127
+ }
128
+
129
+ // Fallback discovery must not promote a supported-looking file/symlink into an install target.
130
+ // Keep unsafe entries for diagnostics, while returning only real IDE directories with safe install
131
+ // containers. Unexpected filesystem errors remain fatal instead of being mistaken for bad input.
132
+ export function detectedIdeTargetStateFor(root) {
133
+ const targets = [];
134
+ const unsafe = [];
135
+ for (const ide of IDE_TARGETS) {
136
+ if (!lstatIfPresent(path.join(root, ide))) continue;
137
+ try {
138
+ assertSafeIdeContainers(root, ide);
139
+ targets.push(ide);
140
+ } catch (e) {
141
+ if (e?.code !== IDE_TARGET_ERROR_CODE) throw e;
142
+ unsafe.push({ target: ide, message: e.message });
143
+ }
144
+ }
145
+ return { targets, unsafe };
146
+ }
147
+
148
+ // Which IDE targets this project wants. Persisted values are recovery-oriented: repair the one known
149
+ // alias, filter everything else, then fall back to supported IDE dirs already present (or .claude).
150
+ // The full state lets reconcile report drift without mutating during a read-only check.
151
+ export function ideTargetStateFor(root) {
152
+ const stampPath = path.join(root, PROJECT_FILES.version);
153
+ const hasStamp = exists(stampPath);
154
+ const rec = readJSON(stampPath);
155
+ const recordIsObject = !!rec && typeof rec === 'object' && !Array.isArray(rec);
156
+ const hasField = recordIsObject && Object.hasOwn(rec, 'ideTargets');
157
+ const raw = hasField ? rec.ideTargets : undefined;
158
+ const normalized = normalizeIdeTargets(raw, { repairAliases: true });
159
+ let targets = normalized.targets;
160
+ let usedFallback = false;
161
+ let unsafeDetected = [];
162
+ if (!targets.length) {
163
+ const detected = detectedIdeTargetStateFor(root);
164
+ targets = detected.targets.length ? detected.targets : ['.claude'];
165
+ unsafeDetected = detected.unsafe;
166
+ usedFallback = true;
167
+ }
168
+ return {
169
+ ...normalized,
170
+ targets,
171
+ hasStamp,
172
+ recordIsObject,
173
+ hasField,
174
+ usedFallback,
175
+ unsafeDetected,
176
+ needsRepair: hasStamp && !sameTargets(raw, targets),
177
+ };
178
+ }
179
+
41
180
  export function ideTargetsFor(root) {
42
- const rec = readJSON(path.join(root, PROJECT_FILES.version));
43
- if (rec?.ideTargets?.length) return rec.ideTargets;
44
- const present = [...IDE_FOLDER_TARGETS, '.opencode'].filter((d) => exists(path.join(root, d)));
45
- return present.length ? present : ['.claude'];
181
+ return ideTargetStateFor(root).targets;
46
182
  }
47
183
 
48
184
  // A brand-new first-party skill is `missing` on every existing install. Relabel that to status `'new'`
@@ -53,8 +189,10 @@ const asNewSkill = (a) => (a.status === 'missing' ? { ...a, status: 'new' } : a)
53
189
 
54
190
  // Module = skills installed into each IDE target + the _bmad/sdlc registration.
55
191
  export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
192
+ const targets = safeIdeTargetsFor(root, ideTargets);
193
+ if (targets.includes('.opencode')) assertSafeOpenCodeWriteDestinations(root, SKILLS);
56
194
  const actions = [];
57
- for (const ide of ideTargets) {
195
+ for (const ide of targets) {
58
196
  if (ide === '.opencode') {
59
197
  for (const s of SKILLS) {
60
198
  actions.push(asNewSkill(fileAction(
@@ -92,8 +230,15 @@ export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
92
230
  // one, so a single update completes the rename even when the new copy would otherwise be
93
231
  // skipped as missing-scope.
94
232
  export function legacyModuleActions(root, ideTargets = ideTargetsFor(root)) {
233
+ const targets = safeIdeTargetsFor(root, ideTargets);
234
+ if (targets.includes('.opencode')) {
235
+ const writes = Object.entries(LEGACY_SKILLS)
236
+ .filter(([, old]) => exists(path.join(root, IDE_OPENCODE_DIR, `${old}.md`)))
237
+ .map(([skill]) => skill);
238
+ assertSafeOpenCodeWriteDestinations(root, writes);
239
+ }
95
240
  const actions = [];
96
- for (const ide of ideTargets) {
241
+ for (const ide of targets) {
97
242
  for (const [skill, old] of Object.entries(LEGACY_SKILLS)) {
98
243
  if (ide === '.opencode') {
99
244
  const oldDest = path.join(root, IDE_OPENCODE_DIR, `${old}.md`);
@@ -137,8 +282,9 @@ export function legacyModuleActions(root, ideTargets = ideTargetsFor(root)) {
137
282
  // clean tree yields nothing and the purge is idempotent. apply() just deletes the install (no
138
283
  // replacement — that is what makes this a removal, not a rename).
139
284
  export function removedModuleActions(root, ideTargets = ideTargetsFor(root)) {
285
+ const targets = safeIdeTargetsFor(root, ideTargets);
140
286
  const actions = [];
141
- for (const ide of ideTargets) {
287
+ for (const ide of targets) {
142
288
  for (const skill of REMOVED_SKILLS) {
143
289
  if (ide === '.opencode') {
144
290
  const dest = path.join(root, IDE_OPENCODE_DIR, `${skill}.md`);
package/cli/reconcile.mjs CHANGED
@@ -14,6 +14,7 @@ import { VERSION, PROJECT_FILES } from './manifest.mjs';
14
14
  import {
15
15
  moduleActions, repoActions, hubActions, authorsActions,
16
16
  legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
17
+ ideTargetStateFor,
17
18
  } from './plan.mjs';
18
19
  import { gitHead, packRepo } from './setup.mjs';
19
20
  import { groupByRoot, commitUpdates } from './update-commit.mjs';
@@ -31,14 +32,36 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
31
32
  const registry = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
32
33
  if (!exists(path.join(root, PROJECT_FILES.reposRegistry))) gaps.push('no repos registered (.sdlc/repos.json absent)');
33
34
 
35
+ // Resolve untrusted persisted IDE targets once, before any filesystem action is constructed.
36
+ // The returned list contains canonical allowlisted roots only.
37
+ const ideState = ideTargetStateFor(root);
38
+ const ideTargets = ideState.targets;
39
+ const stampPath = path.join(root, PROJECT_FILES.version);
40
+ const writeCanonicalStamp = () => {
41
+ const current = readJSON(stampPath, {});
42
+ const record = current && typeof current === 'object' && !Array.isArray(current) ? current : {};
43
+ writeJSON(stampPath, { ...record, version: VERSION, ideTargets });
44
+ };
45
+
34
46
  // --- deterministic file actions (module + hub CI + author allowlists + every registered repo),
35
47
  // plus pre-2.0 sdlc-* -> yad-* migrations ('legacy': old name installed; rename in place)
36
48
  // and purge of skills removed in a later release ('removed': delete the lingering install) ---
37
49
  const actions = [
38
- ...moduleActions(root), ...legacyModuleActions(root), ...removedModuleActions(root),
50
+ ...moduleActions(root, ideTargets), ...legacyModuleActions(root, ideTargets), ...removedModuleActions(root, ideTargets),
39
51
  ...hubActions(root), ...legacyHubActions(root),
40
52
  ...authorsActions(root, registry.repos),
41
53
  ];
54
+ if (ideState.needsRepair) {
55
+ actions.push({
56
+ scope: 'hub',
57
+ item: `${PROJECT_FILES.version} ideTargets`,
58
+ status: 'outdated',
59
+ root,
60
+ paths: [PROJECT_FILES.version],
61
+ // The canonical stamp is written once, after every filesystem action succeeds below.
62
+ apply: () => undefined,
63
+ });
64
+ }
42
65
  for (const repo of registry.repos) actions.push(...repoActions(root, repo), ...legacyRepoActions(root, repo));
43
66
 
44
67
  // --- stale code-context (HEAD moved since last pack) ---
@@ -77,6 +100,30 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
77
100
  for (const i of notOk) log(` ${MARK[i.status]} ${i.item}`);
78
101
  }
79
102
  for (const g of gaps) warn(g);
103
+ const shownInvalid = ideState.invalid.map((v) => {
104
+ try { return JSON.stringify(v) ?? String(v); } catch { return String(v); }
105
+ });
106
+ if (ideState.hasStamp && !ideState.recordIsObject) {
107
+ warn(`${PROJECT_FILES.version}: version stamp is unreadable or not a JSON object; using safe targets: ${ideTargets.join(', ')}`);
108
+ } else if (ideState.hasStamp && !ideState.hasField) {
109
+ warn(`${PROJECT_FILES.version}: ideTargets is missing; using safe targets: ${ideTargets.join(', ')}`);
110
+ } else if (ideState.hasStamp && !ideState.shapeValid) {
111
+ warn(`${PROJECT_FILES.version}: ideTargets is not an array; ignored persisted value: ${shownInvalid.join(', ')}; using safe targets: ${ideTargets.join(', ')}`);
112
+ } else if (ideState.hasStamp && ideState.usedFallback && !ideState.invalid.length) {
113
+ warn(`${PROJECT_FILES.version}: ideTargets is empty; using safe targets: ${ideTargets.join(', ')}`);
114
+ }
115
+ if (ideState.invalid.length && ideState.shapeValid) {
116
+ warn(`${PROJECT_FILES.version}: ignored unsupported persisted IDE target(s): ${shownInvalid.join(', ')}`);
117
+ }
118
+ if (ideState.repaired.length) {
119
+ warn(`${PROJECT_FILES.version}: persisted .cluade target will be repaired to .claude`);
120
+ }
121
+ for (const unsafe of ideState.unsafeDetected) {
122
+ warn(`${PROJECT_FILES.version}: ignored unsafe detected IDE path '${unsafe.target}'; ${unsafe.message}; using safe targets: ${ideTargets.join(', ')}`);
123
+ }
124
+ if (exists(path.join(root, '.cluade'))) {
125
+ warn('existing .cluade path was left untouched; review its contents and remove it manually');
126
+ }
80
127
 
81
128
  const fixable = actions.filter((a) =>
82
129
  a.status !== 'ok' && (scope === 'all' ? true : a.status !== 'missing'),
@@ -103,9 +150,9 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
103
150
  if (force) {
104
151
  for (const a of actions.filter((a) => a.status === 'ok')) { a.apply(); appliedActions.push(a); }
105
152
  }
106
- // refresh the version stamp (preserve recorded ideTargets) and let it ride the hub's update commit
107
- const rec = readJSON(path.join(root, PROJECT_FILES.version), {});
108
- writeJSON(path.join(root, PROJECT_FILES.version), { ...rec, version: VERSION });
153
+ // Refresh the version stamp and persist only the canonical targets used to build actions. This also
154
+ // completes legacy/corrupt target migration even when no skill content itself needed an update.
155
+ writeCanonicalStamp();
109
156
  appliedActions.push({ scope: 'hub', item: PROJECT_FILES.version, status: 'stamp', root, paths: [PROJECT_FILES.version] });
110
157
  applied ? ok(`reconciled ${applied} item(s)`) : info('nothing to fix');
111
158
  if (gaps.length) hand('one-time setup still missing — run `yad setup`.');
package/cli/setup.mjs CHANGED
@@ -6,10 +6,11 @@ import {
6
6
  c, log, step, guide, ok, info, warn, hand, fail, ask, askYesNo, run, has,
7
7
  exists, readJSON, readJSONStrict, writeJSON,
8
8
  } from './lib.mjs';
9
- import { VERSION, IDE_FOLDER_TARGETS, PROJECT_FILES, DESIGN_TOOLS, DESIGN_PRIMARY, TESTING_TOOLS, TESTING_PRIMARY, LEARNING_TOOLS, LEARNING_PRIMARY } from './manifest.mjs';
9
+ import { VERSION, IDE_TARGETS, PROJECT_FILES, DESIGN_TOOLS, DESIGN_PRIMARY, TESTING_TOOLS, TESTING_PRIMARY, LEARNING_TOOLS, LEARNING_PRIMARY } from './manifest.mjs';
10
10
  import {
11
11
  moduleActions, repoActions, hubActions, authorsActions,
12
12
  legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
13
+ safeIdeTargetsFor, detectedIdeTargetStateFor,
13
14
  } from './plan.mjs';
14
15
  import { validateLogin, rolesForScope } from './platform.mjs';
15
16
 
@@ -34,7 +35,26 @@ export function parseRolesSpec(s) {
34
35
  return out;
35
36
  }
36
37
 
37
- const ALL_IDES = [...IDE_FOLDER_TARGETS, '.opencode'];
38
+ // Programmatic setup is strict; interactive setup keeps asking until it receives at least one valid
39
+ // canonical target. Both paths return the same trimmed, ordered, deduplicated representation.
40
+ export async function selectIdeTargets(root, provided, asker = ask) {
41
+ if (provided !== undefined) return safeIdeTargetsFor(root, provided);
42
+ const detected = detectedIdeTargetStateFor(root);
43
+ const present = detected.targets;
44
+ for (const unsafe of detected.unsafe) warn(`${unsafe.message}; excluded from IDE defaults`);
45
+ const def = (present.length ? present : ['.claude']).join(',');
46
+ for (;;) {
47
+ const answer = await asker(`IDE targets to install ${c.dim('(comma-separated: ' + IDE_TARGETS.join(', ') + ')')}`, def);
48
+ if (answer === undefined || answer === null) throw new Error('IDE target selection ended before a valid choice was provided');
49
+ const values = String(answer ?? '').split(',').map((s) => s.trim()).filter(Boolean);
50
+ try {
51
+ return safeIdeTargetsFor(root, values);
52
+ } catch (e) {
53
+ if (process.env.SDLC_NONINTERACTIVE || e?.code !== 'YAD_IDE_TARGET') throw e;
54
+ warn(e.message);
55
+ }
56
+ }
57
+ }
38
58
 
39
59
  export function detectPlatform(remoteUrl = '') {
40
60
  if (/gitlab/i.test(remoteUrl)) return 'gitlab';
@@ -416,13 +436,7 @@ export async function runSetup(root, opts = {}) {
416
436
  'Copies the yad-* skills into your AI tool(s) so they appear in Claude Code / agents / opencode.',
417
437
  'Enter the IDE folders to install into, comma-separated; default = whatever is already present.',
418
438
  ]);
419
- let ideTargets = opts.ideTargets;
420
- if (!ideTargets) {
421
- const present = ALL_IDES.filter((d) => exists(path.join(root, d)));
422
- const def = (present.length ? present : ['.claude']).join(',');
423
- const answer = await ask(`IDE targets to install ${c.dim('(comma-separated: ' + ALL_IDES.join(', ') + ')')}`, def);
424
- ideTargets = answer.split(',').map((s) => s.trim()).filter(Boolean);
425
- }
439
+ const ideTargets = await selectIdeTargets(root, opts.ideTargets);
426
440
  applyActions(moduleActions(root, ideTargets), { force: true });
427
441
  // Migrate any pre-2.0 install in place: remove the old sdlc-* skill copies in the project's
428
442
  // IDE targets and install their yad-* renames. Without this, setup only ADDED yad-* and left
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.12.0",
3
+ "version": "3.12.2",
4
4
  "description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). A BMAD module + 38 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
@@ -93,6 +93,13 @@ repo. For each commit in `<base>..HEAD`, two independent checks:
93
93
  (2+ parents) — a merge's author is whoever pressed merge (often a platform noreply), not a roster
94
94
  human, and its content already passed the PR gate suite. This waiver matters for the push-on-default
95
95
  `yad-update-guard` (§9), which — unlike this PR-triggered gate — sees merge commits.
96
+ A merge commit is **additionally signature-waived when it introduces no content of its own** (its
97
+ combined diff — `git diff-tree --cc` — is empty, i.e. no conflict-resolution or evil-merge hunks):
98
+ every change it carries already lives in an individually author+signature-checked parent, so there
99
+ is nothing to protect. This unblocks **self-hosted GitLab**, which does not sign UI-created merge
100
+ commits (the signature API returns 404) — without it every routine merge would red the branch. A
101
+ merge that *does* introduce content of its own still requires a verified signature (fail-closed),
102
+ so an evil merge pushed direct-to-default cannot smuggle in unverified changes.
96
103
 
97
104
  Degradation is explicit, never silent: a missing allowlist SKIPs the author check with a warning
98
105
  (configure roster emails, re-wire); no GitHub/GitLab remote SKIPs the signature check (the badge is a
@@ -11,6 +11,12 @@
11
11
  # commits set the platform itself as the committer (e.g. noreply@github.com), and their integrity is
12
12
  # covered by the signature check (the platform signs them).
13
13
  #
14
+ # Merge-commit signature exemption: a merge that introduces NO content of its own (its combined diff
15
+ # is empty — no conflict-resolution / evil-merge hunks) is signature-waived, because every change it
16
+ # carries already lives in its individually author+signature-checked parents. This unblocks self-hosted
17
+ # GitLab, which does not sign UI-created merge commits (the signature API returns 404). A merge that
18
+ # DOES introduce content of its own still requires a verified signature (fail-closed).
19
+ #
14
20
  # Degradation is explicit, never silent:
15
21
  # - no allowlist file -> author check SKIPPED with a warning (configure emails, re-wire)
16
22
  # - no GitHub/GitLab remote -> signature check SKIPPED with a warning (no platform, no badge)
@@ -88,6 +94,19 @@ signature_verified() {
88
94
  esac
89
95
  }
90
96
 
97
+ # 0 when a merge commit introduced NO content of its own. The combined diff (--cc) lists only hunks
98
+ # that differ from ALL parents, so empty output means every change lives in an individually-checked
99
+ # parent — there is no conflict-resolution or evil-merge content unique to the merge commit, hence
100
+ # nothing an unverified author could smuggle in past the per-parent author+signature checks.
101
+ merge_introduces_no_content() {
102
+ # `local` on its own line: `local out=$(...)` would mask the substitution's exit status (local
103
+ # always returns 0). A git error (e.g. a parent tree missing in a shallow clone) must fail closed —
104
+ # an empty stdout from a *failed* command is not evidence the merge is content-free.
105
+ local out
106
+ out="$(git diff-tree --cc --no-commit-id -r "$1")" || return 1
107
+ [ -z "$out" ]
108
+ }
109
+
91
110
  rc=0
92
111
  while IFS= read -r sha; do
93
112
  [ -z "$sha" ] && continue
@@ -126,6 +145,11 @@ while IFS= read -r sha; do
126
145
  if [ -n "$platform" ]; then
127
146
  if signature_verified "$sha"; then
128
147
  echo "PASS [verified-commits]: ${short} signature verified by ${platform}"
148
+ elif [ "$is_merge" = 1 ] && merge_introduces_no_content "$sha"; then
149
+ # Content-free merge: nothing to protect. Self-hosted GitLab does not sign UI merge commits, so
150
+ # requiring a signature here would block every routine merge. An evil merge (content of its own)
151
+ # falls through to FAIL below.
152
+ echo "WARN [verified-commits]: ${short} unsigned merge commit — introduces no content of its own (covered by verified parents); signature waived. (Self-hosted GitLab does not sign UI merge commits; on a platform that signs its merges an unsigned content-free merge is unusual but still harmless.)"
129
153
  else
130
154
  echo "FAIL [verified-commits]: ${short} signature missing/unverified — sign commits (GPG/SSH key registered on ${platform}), or the signature API was unreachable (GitLab: set GITLAB_TOKEN/SDLC_API_TOKEN with read_api)."
131
155
  rc=1