Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
2889 lines
133 KiB
JavaScript
2889 lines
133 KiB
JavaScript
'use strict';
|
|
/**
|
|
* #1146: git.base-branch resolver — single source of truth for default-branch detection.
|
|
*
|
|
* Tests:
|
|
* A. Config override wins (git.base_branch set → returned as-is, no git calls needed)
|
|
* B. origin/HEAD symref resolves → used
|
|
* C. origin/HEAD unset but git remote show origin knows HEAD → AUTHORITATIVE fallback
|
|
* (key regression: master repo with no origin/HEAD → must return "master", NOT "main")
|
|
* D. No origin/HEAD, no remote show, local branch "master" present → returns "master"
|
|
* E. No origin/HEAD, no remote show, local branch "main" present → returns "main"
|
|
* F. No origin/HEAD, no remote show, no local branches → returns "main" (last resort)
|
|
* G. Anti-regression guard: five affected workflows must NOT contain the
|
|
* duplicated bare `:-main` / `:-master` fallback pattern that was the root cause.
|
|
* They must call `msd_run query git.base-branch` instead.
|
|
* (see the source-text-is-the-product exemption declared below this docblock —
|
|
* the workflow .md content IS the runtime surface; the absence of the bad
|
|
* pattern is what ships to agents.)
|
|
*/
|
|
|
|
// allow-test-rule: source-text-is-the-product
|
|
// Justification: the workflow .md files ARE the product surface — agents read and
|
|
// execute them directly. Guard G asserts that the resolved command appears in all five
|
|
// workflows, which requires reading those workflow files. Per TESTING-STANDARDS.md §6.
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
|
|
const { runMsdTools, cleanup, readFileNormalized } = require('./helpers.cjs');
|
|
const { ExitError } = require('../msd-core/bin/lib/cli-exit.cjs');
|
|
const { makeFaultyGit } = require('./helpers/faulty-deps.cjs');
|
|
const { gitOrThrow, throwIfFailed } = require('./helpers/git-fixture.cjs');
|
|
const { runHook } = require('./helpers/process-seam.cjs');
|
|
|
|
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
|
|
const { GIT_TIMEOUT_MS, HOOK_FANOUT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
|
|
/**
|
|
* The #3819 executor pre-commit protected-branch guard test harness below
|
|
* invokes a bash script with fake `git`/`msd_run` shell functions defined
|
|
* INLINE — no real subprocess fan-out happens, so this is lighter than
|
|
* `HOOK_FANOUT_TIMEOUT_MS` (60000ms, sized for a hook that fans out to
|
|
* nested shell spawns). Pre-existing value, unchanged by this migration.
|
|
*/
|
|
const EXECUTOR_GUARD_TIMEOUT_MS = 10000;
|
|
|
|
/**
|
|
* These four constants are NOT bounds on this suite's own subprocess calls —
|
|
* they are the EXACT timeout values production code (gitBaseBranch's
|
|
* trySymbolicRef / tryRemoteShow / tryLocalBranch / gitWorktreeInfoInternal,
|
|
* in src/, out of this batch's scope) hardcodes when calling its own git
|
|
* execution callback. Each test below asserts the captured call args to
|
|
* prove production didn't drift from its documented bound. Kept as FOUR
|
|
* separate constants rather than consolidated, even where values coincide
|
|
* today (TIER2 and TIER4 are both 5000ms) — collapsing them would let two
|
|
* independently-implemented production tiers drift from each other while
|
|
* the test kept passing, which defeats the point of a pinned-value
|
|
* assertion. All four are pre-existing values, unchanged by this migration.
|
|
*/
|
|
const TIER2_SYMBOLIC_REF_TIMEOUT_MS = 5000;
|
|
const TIER3_REMOTE_SHOW_TIMEOUT_MS = 15000;
|
|
const TIER4_LOCAL_BRANCH_TIMEOUT_MS = 5000;
|
|
const WORKTREE_INFO_TIMEOUT_MS = 5000;
|
|
|
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Create a minimal git repo in a temp dir, optionally setting up a remote
|
|
* and local branches.
|
|
*/
|
|
function createGitRepo(opts = {}) {
|
|
const { prefix = 'msd-1146-', defaultBranch = 'master' } = opts;
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
gitOrThrow(['init', '-b', defaultBranch], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['config', 'user.email', 'test@test.com'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['config', 'user.name', 'Test'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['config', 'commit.gpgsign', 'false'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
|
|
// Need at least one commit so branches exist
|
|
fs.writeFileSync(path.join(dir, 'README.md'), '# test\n');
|
|
gitOrThrow(['add', 'README.md'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['commit', '-m', 'init'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
|
|
return dir;
|
|
}
|
|
|
|
/**
|
|
* Create a .planning dir so msd-tools resolveProjectRoot doesn't bail.
|
|
*/
|
|
function addPlanning(dir) {
|
|
fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true });
|
|
}
|
|
|
|
/** Snapshot every file below .planning, including bytes and relative paths. */
|
|
function snapshotPlanningTree(dir) {
|
|
const root = path.join(dir, '.planning');
|
|
const snapshot = new Map();
|
|
function visit(current) {
|
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
const absolute = path.join(current, entry.name);
|
|
if (entry.isDirectory()) visit(absolute);
|
|
else snapshot.set(path.relative(root, absolute), fs.readFileSync(absolute));
|
|
}
|
|
}
|
|
visit(root);
|
|
return snapshot;
|
|
}
|
|
|
|
/**
|
|
* Write a msd config.json with git.base_branch set.
|
|
*/
|
|
function setMsdConfig(dir, key, value) {
|
|
const cfgDir = path.join(dir, '.planning');
|
|
fs.mkdirSync(cfgDir, { recursive: true });
|
|
const cfgPath = path.join(cfgDir, 'config.json');
|
|
let cfg = {};
|
|
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch (_) { /* new file */ }
|
|
// Set nested key (dot notation). Guard every segment against prototype
|
|
// pollution with inline literal checks at each write site — mirrors the
|
|
// production guard in src/config.cts. A Set/pre-loop guard is NOT recognised
|
|
// by CodeQL's js/prototype-pollution-utility query (see PR #752 / alert #40).
|
|
const parts = key.split('.');
|
|
let obj = cfg;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
const k = parts[i];
|
|
if (k === '__proto__' || k === 'prototype' || k === 'constructor') {
|
|
throw new Error(`setMsdConfig: unsafe config key segment '${k}'`);
|
|
}
|
|
if (typeof obj[k] !== 'object' || obj[k] === null) obj[k] = {};
|
|
obj = obj[k];
|
|
}
|
|
const lastKey = parts[parts.length - 1];
|
|
if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') {
|
|
throw new Error(`setMsdConfig: unsafe config key segment '${lastKey}'`);
|
|
}
|
|
obj[lastKey] = value;
|
|
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
|
|
}
|
|
|
|
// Paths to the five affected workflow files
|
|
const WORKFLOW_DIR = path.join(__dirname, '..', 'msd-core', 'workflows');
|
|
const AFFECTED_WORKFLOWS = [
|
|
path.join(WORKFLOW_DIR, 'execute-phase.md'),
|
|
path.join(WORKFLOW_DIR, 'quick.md'),
|
|
path.join(WORKFLOW_DIR, 'ship.md'),
|
|
path.join(WORKFLOW_DIR, 'complete-milestone.md'),
|
|
path.join(WORKFLOW_DIR, 'pr-branch.md'),
|
|
];
|
|
|
|
// ─── Test suite ───────────────────────────────────────────────────────────────
|
|
|
|
describe('#1146: git.base-branch resolver', () => {
|
|
|
|
test('A. config override git.base_branch → returned immediately', (t) => {
|
|
const dir = createGitRepo({ prefix: 'msd-1146-a-', defaultBranch: 'master' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
setMsdConfig(dir, 'git.base_branch', 'develop');
|
|
|
|
const result = runMsdTools(['query', 'git.base-branch'], dir);
|
|
assert.ok(result.success, `git.base-branch with config override failed:\n${result.error}`);
|
|
const branch = result.output.trim();
|
|
assert.strictEqual(branch, 'develop',
|
|
`Expected config override 'develop', got: '${branch}'`);
|
|
});
|
|
|
|
test('B. origin/HEAD symref resolves → returned', (t) => {
|
|
// Create an "origin" bare repo with main branch
|
|
const originDir = createGitRepo({ prefix: 'msd-1146-b-origin-', defaultBranch: 'main' });
|
|
const worktreeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-1146-b-wt-'));
|
|
t.after(() => { cleanup(originDir); cleanup(worktreeDir); });
|
|
|
|
// Clone from origin — this sets origin/HEAD
|
|
gitOrThrow(['clone', originDir, worktreeDir], { timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['config', 'user.email', 'test@test.com'], { cwd: worktreeDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['config', 'user.name', 'Test'], { cwd: worktreeDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
addPlanning(worktreeDir);
|
|
|
|
// Verify origin/HEAD is set (it should be after clone)
|
|
const symref = gitOrThrow(['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd: worktreeDir, timeoutMs: GIT_TIMEOUT_MS }).trim();
|
|
assert.ok(symref.includes('origin/main'), `Expected origin/HEAD→origin/main, got: ${symref}`);
|
|
|
|
const result = runMsdTools(['query', 'git.base-branch'], worktreeDir);
|
|
assert.ok(result.success, `git.base-branch symref test failed:\n${result.error}`);
|
|
const branch = result.output.trim();
|
|
assert.strictEqual(branch, 'main',
|
|
`Expected 'main' from origin/HEAD, got: '${branch}'`);
|
|
});
|
|
|
|
test('C. KEY REGRESSION — master repo, origin/HEAD unset → returns "master" not "main"', (t) => {
|
|
// This is the bug: git init + remote add without git remote set-head → no origin/HEAD
|
|
// Current code falls back to :-main → wrong. Fixed code uses `git remote show origin`.
|
|
const originDir = createGitRepo({ prefix: 'msd-1146-c-origin-', defaultBranch: 'master' });
|
|
const cloneDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-1146-c-clone-'));
|
|
t.after(() => { cleanup(originDir); cleanup(cloneDir); });
|
|
|
|
// Manually add remote WITHOUT cloning (so origin/HEAD is never set)
|
|
gitOrThrow(['init'], { cwd: cloneDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['config', 'user.email', 'test@test.com'], { cwd: cloneDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['config', 'user.name', 'Test'], { cwd: cloneDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['config', 'commit.gpgsign', 'false'], { cwd: cloneDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['remote', 'add', 'origin', originDir], { cwd: cloneDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['fetch', 'origin'], { cwd: cloneDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
// Explicitly delete origin/HEAD in case git fetch auto-set it (newer git versions may do this)
|
|
try {
|
|
gitOrThrow(['remote', 'set-head', 'origin', '--delete'], { cwd: cloneDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
} catch (_) { /* ignore — may not exist */ }
|
|
addPlanning(cloneDir);
|
|
|
|
// Confirm origin/HEAD is unset
|
|
let hasSymref = true;
|
|
try {
|
|
gitOrThrow(['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd: cloneDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
} catch (_) {
|
|
hasSymref = false;
|
|
}
|
|
assert.strictEqual(hasSymref, false, 'Test setup: origin/HEAD must be unset for this test case');
|
|
|
|
const result = runMsdTools(['query', 'git.base-branch'], cloneDir);
|
|
assert.ok(result.success, `git.base-branch regression test failed:\n${result.error}`);
|
|
const branch = result.output.trim();
|
|
assert.strictEqual(branch, 'master',
|
|
`BUG REGRESSION: master repo with origin/HEAD unset must return 'master', got: '${branch}'`);
|
|
});
|
|
|
|
test('D. No remote, local branch "master" present, "main" absent → returns "master"', (t) => {
|
|
const dir = createGitRepo({ prefix: 'msd-1146-d-', defaultBranch: 'master' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
// No remote configured — falls through to local branch detection
|
|
|
|
const result = runMsdTools(['query', 'git.base-branch'], dir);
|
|
assert.ok(result.success, `git.base-branch local branch test failed:\n${result.error}`);
|
|
const branch = result.output.trim();
|
|
assert.strictEqual(branch, 'master',
|
|
`Expected 'master' from local branch detection, got: '${branch}'`);
|
|
});
|
|
|
|
test('E. No remote, local branch "main" present → returns "main"', (t) => {
|
|
const dir = createGitRepo({ prefix: 'msd-1146-e-', defaultBranch: 'main' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
|
|
const result = runMsdTools(['query', 'git.base-branch'], dir);
|
|
assert.ok(result.success, `git.base-branch main branch test failed:\n${result.error}`);
|
|
const branch = result.output.trim();
|
|
assert.strictEqual(branch, 'main',
|
|
`Expected 'main' from local branch detection, got: '${branch}'`);
|
|
});
|
|
|
|
test('F. No remote, no main/master local branch → returns "main" (last resort default)', (t) => {
|
|
const dir = createGitRepo({ prefix: 'msd-1146-f-', defaultBranch: 'develop' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
// Branch named "develop" — neither main nor master
|
|
|
|
const result = runMsdTools(['query', 'git.base-branch'], dir);
|
|
assert.ok(result.success, `git.base-branch default fallback test failed:\n${result.error}`);
|
|
const branch = result.output.trim();
|
|
assert.strictEqual(branch, 'main',
|
|
`Expected 'main' as last resort default, got: '${branch}'`);
|
|
});
|
|
|
|
test('A2. config override with flat base_branch key (legacy form) → returned immediately', (t) => {
|
|
const dir = createGitRepo({ prefix: 'msd-1146-a2-', defaultBranch: 'master' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
// Write flat base_branch directly to config root (legacy form, not nested under "git")
|
|
const cfgPath = require('node:path').join(dir, '.planning', 'config.json');
|
|
require('node:fs').writeFileSync(cfgPath, JSON.stringify({ base_branch: 'release' }, null, 2) + '\n');
|
|
|
|
const result = runMsdTools(['query', 'git.base-branch'], dir);
|
|
assert.ok(result.success, `git.base-branch with flat config key failed:\n${result.error}`);
|
|
const branch = result.output.trim();
|
|
assert.strictEqual(branch, 'release',
|
|
`Expected flat config override 'release', got: '${branch}'`);
|
|
});
|
|
|
|
test('A3. #3648 precedence: both flat base_branch and nested git.base_branch set → nested (canonical) wins', (t) => {
|
|
// Regression for #3648 review Blocker 1: production config resolution
|
|
// (config-loader's `get()`) is flat-first, so a project that migrated to
|
|
// the namespaced `git.base_branch` but still carries a stale flat
|
|
// `base_branch` from before migration would silently get the old flat
|
|
// value back. `normalizeLegacyKeys` must hoist/resolve `base_branch` the
|
|
// same way it already does `branching_strategy` — canonical nested wins,
|
|
// stale flat top-level is dropped.
|
|
const dir = createGitRepo({ prefix: 'msd-3648-a3-', defaultBranch: 'master' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
const cfgPath = require('node:path').join(dir, '.planning', 'config.json');
|
|
require('node:fs').writeFileSync(
|
|
cfgPath,
|
|
JSON.stringify({ base_branch: 'stale-flat', git: { base_branch: 'canonical-nested' } }, null, 2) + '\n',
|
|
);
|
|
|
|
const result = runMsdTools(['query', 'git.base-branch'], dir);
|
|
assert.ok(result.success, `git.base-branch with both config keys failed:\n${result.error}`);
|
|
const branch = result.output.trim();
|
|
assert.strictEqual(branch, 'canonical-nested',
|
|
`Expected namespaced 'git.base_branch' to win over stale flat 'base_branch', got: '${branch}'`);
|
|
});
|
|
|
|
test('H. No remote, both "main" and "master" local branches exist → returns "main" (main wins tie-break)', (t) => {
|
|
// Tier-4 tie-break: when both main and master exist locally and no remote info is available,
|
|
// "main" wins (documented in tryLocalBranch JSDoc — modern default).
|
|
const dir = createGitRepo({ prefix: 'msd-1146-h-', defaultBranch: 'master' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
// Create a "main" branch alongside the existing "master"
|
|
gitOrThrow(['branch', 'main'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
|
|
// No remote configured — falls to tier-4 (local branch existence)
|
|
|
|
const result = runMsdTools(['query', 'git.base-branch'], dir);
|
|
assert.ok(result.success, `git.base-branch both-branches test failed:\n${result.error}`);
|
|
const branch = result.output.trim();
|
|
assert.strictEqual(branch, 'main',
|
|
`Expected 'main' to win when both main and master exist locally, got: '${branch}'`);
|
|
});
|
|
|
|
test('G. Anti-regression: all five affected workflows use msd_run query git.base-branch, not bare :-main / :-master', () => {
|
|
// The root-cause pattern: DEFAULT_BRANCH=${DEFAULT_BRANCH:-main} or BASE_BRANCH="${BASE_BRANCH:-main}"
|
|
// After fix: workflows call msd_run query git.base-branch and remove the bare fallback.
|
|
const BAD_PATTERN = /\$\{(?:DEFAULT_BRANCH|BASE_BRANCH):-(?:main|master)\}/;
|
|
const RESOLVER_CALL = /msd_run query git\.base-branch/;
|
|
|
|
for (const wfPath of AFFECTED_WORKFLOWS) {
|
|
const name = path.basename(wfPath);
|
|
const content = fs.readFileSync(wfPath, 'utf8');
|
|
|
|
assert.ok(
|
|
!BAD_PATTERN.test(content),
|
|
`${name} still contains the bare :-main/:-master fallback pattern. ` +
|
|
'Must be replaced with msd_run query git.base-branch (Issue #1146).',
|
|
);
|
|
|
|
assert.ok(
|
|
RESOLVER_CALL.test(content),
|
|
`${name} does not call \`msd_run query git.base-branch\`. ` +
|
|
'All five affected workflows must delegate to the single resolver (Issue #1146).',
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── gitWorktreeInfoInternal: behaviour (#1268 T0, T1 #1277) ─────────────────
|
|
|
|
const gitBaseBranch = require(path.join(__dirname, '..', 'msd-core', 'bin', 'lib', 'git-base-branch.cjs'));
|
|
const { createTempGitProject, createTempDir } = require('./helpers.cjs');
|
|
|
|
describe('#1268 gitWorktreeInfoInternal: relocation to git-base-branch', () => {
|
|
test('gitWorktreeInfoInternal(createTempGitProject()) returns {inside:true, worktreeRoot:<non-empty string>}', (t) => {
|
|
const dir = createTempGitProject('msd-wt-info-');
|
|
t.after(() => cleanup(dir));
|
|
const result = gitBaseBranch.gitWorktreeInfoInternal(dir);
|
|
// `git rev-parse --show-toplevel` reports the resolved (symlink-free) path,
|
|
// which on macOS differs from the mkdtemp path (/var → /private/var). Pin the
|
|
// exact value rather than "a non-empty string": a resolver that returned the
|
|
// .git dir, the cwd, or any other plausible-looking path would pass the weaker
|
|
// shape check while being wrong.
|
|
//
|
|
// git always reports POSIX forward slashes, on every platform including
|
|
// Windows, while `fs.realpathSync.native` returns the platform's native
|
|
// form (backslashes on Windows). The expected side must therefore be
|
|
// normalized to git's convention rather than compared to the raw native
|
|
// realpath, or the assertion just encodes the separator convention of
|
|
// whatever platform it was written on. This is separators only — POSIX's
|
|
// `replace` is a no-op there, so the assertion keeps its full strength on
|
|
// POSIX. The remote msd-test matrix is Linux-only and cannot exercise this
|
|
// path; it only surfaced on the Windows GitHub Actions shard.
|
|
assert.strictEqual(result.inside, true, 'inside must be true for a git project dir');
|
|
assert.strictEqual(result.worktreeRoot, fs.realpathSync.native(dir).replace(/\\/g, '/'),
|
|
'worktreeRoot must be the resolved worktree root path');
|
|
});
|
|
|
|
test('gitWorktreeInfoInternal(createTempDir()) returns {inside:false, worktreeRoot:null} for a non-git dir', (t) => {
|
|
const dir = createTempDir('msd-wt-info-nongit-');
|
|
t.after(() => cleanup(dir));
|
|
const result = gitBaseBranch.gitWorktreeInfoInternal(dir);
|
|
assert.strictEqual(result.inside, false, 'inside must be false for a non-git dir');
|
|
assert.strictEqual(result.worktreeRoot, null, 'worktreeRoot must be null for a non-git dir');
|
|
});
|
|
|
|
// NOTE: the former "never throws" liveness test that sat here was replaced
|
|
// (#3057 W3). "It did not throw" is satisfied by a function that returns
|
|
// undefined, the wrong branch, or nothing useful at all. The
|
|
// `execGit throws → {inside:false, worktreeRoot:null}` test below asserts the
|
|
// exact value the catch arm is contracted to produce, which is what the old
|
|
// test was gesturing at.
|
|
});
|
|
|
|
// ─── #3057 B4: last-resort "main" — verified vs unverified ───────────────────
|
|
//
|
|
// `resolveBaseBranch` alone collapses two very different situations into the
|
|
// same `'main'` string: a repository that genuinely has no candidate branch
|
|
// (every git query on tiers 2-4 completed and cleanly answered "nothing"),
|
|
// and a total resolution failure (every query timed out). `resolveBaseBranchDiagnostics`
|
|
// exposes `verified` so a caller can tell them apart; `cmdGitBaseBranch`
|
|
// surfaces the unverified case as a stderr diagnostic without touching its
|
|
// stdout contract (five workflows parse that stdout literally).
|
|
|
|
describe('#3057 B4: resolveBaseBranchDiagnostics — verified vs unverified last-resort default', () => {
|
|
test('every tier-2/3/4 git query TIMES OUT → last-resort "main" is UNVERIFIED', (t) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3057-b4-fault-'));
|
|
t.after(() => cleanup(dir));
|
|
// No .planning/config.json in this dir → the config-override tier is
|
|
// skipped naturally (loadConfig's real-fs read misses cleanly).
|
|
const faultyGit = makeFaultyGit({ faults: [{ kind: 'timeout' }] });
|
|
|
|
const result = gitBaseBranch.resolveBaseBranchDiagnostics(dir, { execGit: faultyGit });
|
|
|
|
assert.strictEqual(result.branch, 'main');
|
|
assert.strictEqual(result.verified, false);
|
|
});
|
|
|
|
test('every tier-2/3/4 git query cleanly reports no candidate → last-resort "main" is VERIFIED', (t) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3057-b4-clean-'));
|
|
t.after(() => cleanup(dir));
|
|
// Default passthrough: exitCode 0, empty stdout for every call — a real,
|
|
// completed "no answer" from git, not a failure (timedOut:false, error:null).
|
|
const faultyGit = makeFaultyGit();
|
|
|
|
const result = gitBaseBranch.resolveBaseBranchDiagnostics(dir, { execGit: faultyGit });
|
|
|
|
assert.strictEqual(result.branch, 'main');
|
|
assert.strictEqual(result.verified, true);
|
|
});
|
|
|
|
test('resolveBaseBranch (string-returning) is unaffected — both cases still return "main"', (t) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3057-b4-compat-'));
|
|
t.after(() => cleanup(dir));
|
|
assert.strictEqual(
|
|
gitBaseBranch.resolveBaseBranch(dir, { execGit: makeFaultyGit({ faults: [{ kind: 'timeout' }] }) }),
|
|
'main',
|
|
);
|
|
assert.strictEqual(
|
|
gitBaseBranch.resolveBaseBranch(dir, { execGit: makeFaultyGit() }),
|
|
'main',
|
|
);
|
|
});
|
|
|
|
test('cmdGitBaseBranch writes an unverified-fallback diagnostic to stderr ONLY when unverified', (t) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3057-b4-cmd-'));
|
|
t.after(() => cleanup(dir));
|
|
|
|
let stdoutText = '';
|
|
let stderrText = '';
|
|
gitBaseBranch.cmdGitBaseBranch(dir, [], {
|
|
execGit: makeFaultyGit({ faults: [{ kind: 'timeout' }] }),
|
|
write: (s) => { stdoutText += s; },
|
|
writeDiagnostic: (s) => { stderrText += s; },
|
|
});
|
|
assert.strictEqual(stdoutText, 'main\n');
|
|
assert.strictEqual(
|
|
stderrText,
|
|
`⚠ git-base-branch: defaulted to 'main' WITHOUT verifying against this repository — ` +
|
|
`a git query timed out or could not run. See #3057.\n`,
|
|
);
|
|
|
|
stdoutText = '';
|
|
stderrText = '';
|
|
gitBaseBranch.cmdGitBaseBranch(dir, [], {
|
|
execGit: makeFaultyGit(),
|
|
write: (s) => { stdoutText += s; },
|
|
writeDiagnostic: (s) => { stderrText += s; },
|
|
});
|
|
assert.strictEqual(stdoutText, 'main\n');
|
|
assert.strictEqual(stderrText, '', 'a verified fallback must not write any diagnostic');
|
|
});
|
|
|
|
test('#3648 Major: --is-protected fails CLOSED (reports protected) when the base branch is unverified', (t) => {
|
|
// Regression for #3648 review's Major finding: `--is-protected` used to
|
|
// discard `verified` entirely, so a degraded git (timeout / spawn
|
|
// failure) silently answered `false` — the wrong failure direction for a
|
|
// protection guard. An unverified guess must not let a caller conclude
|
|
// "definitely not protected".
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3648-major-'));
|
|
t.after(() => cleanup(dir));
|
|
|
|
let stdoutText = '';
|
|
let stderrText = '';
|
|
gitBaseBranch.cmdGitBaseBranch(dir, ['--is-protected', 'some-topic-branch'], {
|
|
execGit: makeFaultyGit({ faults: [{ kind: 'timeout' }] }),
|
|
write: (s) => { stdoutText += s; },
|
|
writeDiagnostic: (s) => { stderrText += s; },
|
|
});
|
|
assert.strictEqual(stdoutText, 'true\n',
|
|
'an unverified answer must fail closed (report protected), not silently false');
|
|
assert.match(stderrText, /could not verify repository branch metadata/);
|
|
|
|
// Negative control: a verified resolution for the same non-matching
|
|
// branch must still cleanly report false — the fail-closed path must
|
|
// trigger on non-verification, not on every "not protected" answer.
|
|
stdoutText = '';
|
|
stderrText = '';
|
|
gitBaseBranch.cmdGitBaseBranch(dir, ['--is-protected', 'some-topic-branch'], {
|
|
execGit: makeFaultyGit(),
|
|
write: (s) => { stdoutText += s; },
|
|
writeDiagnostic: (s) => { stderrText += s; },
|
|
});
|
|
assert.strictEqual(stdoutText, 'false\n');
|
|
assert.strictEqual(stderrText, '', 'a verified non-match must not write any diagnostic');
|
|
});
|
|
});
|
|
|
|
// ─── #3552: protected-branch policy and execute-phase warning ────────────────
|
|
|
|
function extractProtectedBranchWarningBash(workflowFile, stepName) {
|
|
const content = readFileNormalized(path.join(WORKFLOW_DIR, workflowFile));
|
|
const lines = content.split('\n');
|
|
const blocks = [];
|
|
let inStep = false;
|
|
let inBash = false;
|
|
let buffer = [];
|
|
|
|
for (const line of lines) {
|
|
if (!inStep && line === `<step name="${stepName}">`) {
|
|
inStep = true;
|
|
continue;
|
|
}
|
|
if (inStep && /^<\/step>\s*$/.test(line)) break;
|
|
if (inStep && !inBash && /^\s*```bash\s*$/.test(line)) {
|
|
inBash = true;
|
|
buffer = [];
|
|
continue;
|
|
}
|
|
if (inBash && /^\s*```\s*$/.test(line)) {
|
|
blocks.push(buffer.join('\n'));
|
|
inBash = false;
|
|
continue;
|
|
}
|
|
if (inBash) buffer.push(line);
|
|
}
|
|
|
|
let block = blocks.find((candidate) => candidate.includes('--is-protected'));
|
|
if (!block) {
|
|
// #3648: the step may point at an extracted step file (e.g. execute-phase.md's
|
|
// ADR-857 byte-ceiling extraction) instead of carrying the bash block inline.
|
|
const stepBody = lines.slice(lines.indexOf(`<step name="${stepName}">`) + 1);
|
|
const ref = stepBody.find((l) => /`execute-phase\/steps\/[\w-]+\.md`/.test(l));
|
|
const refMatch = ref && ref.match(/`(execute-phase\/steps\/[\w-]+\.md)`/);
|
|
if (refMatch) {
|
|
const refLines = readFileNormalized(path.join(WORKFLOW_DIR, refMatch[1])).split('\n');
|
|
const refBlocks = [];
|
|
let refInBash = false;
|
|
let refBuffer = [];
|
|
for (const line of refLines) {
|
|
if (!refInBash && /^\s*```bash\s*$/.test(line)) {
|
|
refInBash = true;
|
|
refBuffer = [];
|
|
continue;
|
|
}
|
|
if (refInBash && /^\s*```\s*$/.test(line)) {
|
|
refBlocks.push(refBuffer.join('\n'));
|
|
refInBash = false;
|
|
continue;
|
|
}
|
|
if (refInBash) refBuffer.push(line);
|
|
}
|
|
block = refBlocks.find((candidate) => candidate.includes('--is-protected'));
|
|
// #3648: sync-runtime-launcher.cjs adds the canonical msd_run resolver
|
|
// preamble to this standalone step file (runtime-launcher-parity's
|
|
// one-preamble-per-file rule). That preamble defines its own msd_run(),
|
|
// which would shadow this harness's injected mock and reach the real
|
|
// msd-tools.cjs on the machine running the test. The preamble's own
|
|
// correctness is covered by tests/runtime-launcher-parity.test.cjs; this
|
|
// harness only needs the #3552 warning logic, so strip it here.
|
|
if (block) {
|
|
block = block.split('\n').filter((l) => !l.trimStart().startsWith('_MSD_SHIM_NAME=')).join('\n');
|
|
}
|
|
}
|
|
}
|
|
if (!block) {
|
|
throw new Error(`${workflowFile} ${stepName} has no protected-branch warning bash block`);
|
|
}
|
|
return block;
|
|
}
|
|
|
|
function writeProtectedBranchWarningScript(prefix, bash) {
|
|
const scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
const scriptPath = path.join(scriptDir, 'warning.sh');
|
|
fs.writeFileSync(scriptPath, [
|
|
'#!/usr/bin/env bash',
|
|
'set -eu',
|
|
'git() {',
|
|
' if [ "$#" -ne 2 ] || [ "$1" != branch ] || [ "$2" != --show-current ]; then',
|
|
' printf "unexpected git invocation\\n" >&2',
|
|
' return 97',
|
|
' fi',
|
|
' printf "%s\\n" "$CURRENT_BRANCH_VALUE"',
|
|
'}',
|
|
'msd_run() {',
|
|
' if [ "$#" -ne 4 ] || [ "$1" != query ] || [ "$2" != git.base-branch ] ||',
|
|
' [ "$3" != --is-protected ] || [ "$4" != "$CURRENT_BRANCH_VALUE" ]; then',
|
|
' printf "unexpected protected-branch query\\n"',
|
|
' return 0',
|
|
' fi',
|
|
// The real command writes its fail-closed and rejected-entry
|
|
// explanations to stderr. Emitting one here is what makes a swallowed
|
|
// `2>/dev/null` at the call site visible to a test.
|
|
' if [ -n "${DIAGNOSTIC_TEXT:-}" ]; then printf "%s\\n" "$DIAGNOSTIC_TEXT" >&2; fi',
|
|
// QUERY_EXIT lets a test make the query FAIL (msd-tools missing, a crash, a
|
|
// non-zero exit). Defaults to 0, so every pre-existing caller of this
|
|
// harness is unaffected. Nothing is printed on stdout in that case —
|
|
// matching a real failed command substitution.
|
|
' if [ "${QUERY_EXIT:-0}" != 0 ]; then return "${QUERY_EXIT}"; fi',
|
|
' printf "%s\\n" "$PROTECTED_RESULT"',
|
|
'}',
|
|
bash,
|
|
'printf "IS_PROTECTED=%s\\n" "${IS_PROTECTED:-unbound}"',
|
|
'printf "continued\\n"',
|
|
].join('\n'), { mode: 0o755 });
|
|
return { scriptDir, scriptPath };
|
|
}
|
|
|
|
describe('#3552: configured protected branches', () => {
|
|
const configuredLoad = (requestedCwd) => {
|
|
assert.strictEqual(requestedCwd, '/repo');
|
|
return {
|
|
base_branch: 'main',
|
|
protected_branches: ['develop', 'next', 'develop'],
|
|
};
|
|
};
|
|
|
|
test('#3552 configured match and unrelated branch produce opposite results', () => {
|
|
const match = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'develop', {
|
|
loadConfig: configuredLoad,
|
|
});
|
|
const control = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'topic/3552', {
|
|
loadConfig: configuredLoad,
|
|
});
|
|
|
|
assert.deepStrictEqual(match, {
|
|
baseBranch: 'main',
|
|
protectedBranches: ['main', 'develop', 'next'],
|
|
rejectedProtectedBranches: [],
|
|
isProtected: true,
|
|
verified: true,
|
|
allowDefaultBranchCommits: false,
|
|
});
|
|
assert.strictEqual(control.isProtected, false);
|
|
assert.notStrictEqual(match.isProtected, control.isProtected,
|
|
'negative control must disagree with the configured protected-branch match');
|
|
});
|
|
|
|
test('#3552 absent list retains resolved-base protection and unrelated control', () => {
|
|
const deps = { loadConfig: () => ({ base_branch: 'main' }) };
|
|
const base = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'main', deps);
|
|
const control = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'topic/3552', deps);
|
|
|
|
assert.deepStrictEqual(base.protectedBranches, ['main']);
|
|
assert.strictEqual(base.isProtected, true);
|
|
assert.strictEqual(control.isProtected, false);
|
|
assert.notStrictEqual(base.isProtected, control.isProtected,
|
|
'negative control must disagree with the resolved-base match');
|
|
});
|
|
|
|
test('#3552 a bad element drops only itself — valid names still protect', () => {
|
|
// A protection predicate must not fail OPEN. config-set validation is
|
|
// bypassable by a direct edit of .planning/config.json, so one bad element
|
|
// discarding the whole list is the exact failure #3552 exists to close,
|
|
// reintroduced through a different door (#3648 review Blocker 3).
|
|
for (const protectedBranches of [['develop', 42], ['develop', ' '], ['develop', null]]) {
|
|
const loadConfig = () => ({ base_branch: 'main', protected_branches: protectedBranches });
|
|
const configuredName = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'develop', { loadConfig });
|
|
|
|
assert.strictEqual(configuredName.isProtected, true,
|
|
`'develop' must stay protected alongside a bad sibling: ${JSON.stringify(protectedBranches)}`);
|
|
assert.deepStrictEqual(configuredName.protectedBranches, ['main', 'develop']);
|
|
assert.strictEqual(configuredName.rejectedProtectedBranches.length, 1,
|
|
'the bad element must be reported, not silently swallowed');
|
|
}
|
|
});
|
|
|
|
test('#3552 a non-array value contributes no names and is reported', () => {
|
|
const loadConfig = () => ({ base_branch: 'main', protected_branches: 'develop' });
|
|
const status = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'develop', { loadConfig });
|
|
|
|
assert.strictEqual(status.isProtected, false,
|
|
'a bare string is not a list of branch names — it must not protect');
|
|
assert.deepStrictEqual(status.protectedBranches, ['main']);
|
|
assert.deepStrictEqual(status.rejectedProtectedBranches, ['"develop"']);
|
|
});
|
|
|
|
test('#3552 negative control: a well-formed list reports nothing rejected', () => {
|
|
// Must disagree with every case above — otherwise the reject channel is
|
|
// reporting unconditionally and proves nothing.
|
|
const loadConfig = () => ({ base_branch: 'main', protected_branches: ['develop'] });
|
|
const status = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'develop', { loadConfig });
|
|
|
|
assert.strictEqual(status.isProtected, true);
|
|
assert.deepStrictEqual(status.rejectedProtectedBranches, []);
|
|
});
|
|
|
|
test('#3552 an empty list is well-formed, not malformed', () => {
|
|
const loadConfig = () => ({ base_branch: 'main', protected_branches: [] });
|
|
const status = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'main', { loadConfig });
|
|
|
|
assert.deepStrictEqual(status.protectedBranches, ['main']);
|
|
assert.deepStrictEqual(status.rejectedProtectedBranches, [],
|
|
'declaring no extra protected branches is a valid choice, not an error');
|
|
});
|
|
|
|
test('#3552 --is-protected writes a diagnostic naming the rejected elements', () => {
|
|
const diagnostics = [];
|
|
const out = [];
|
|
gitBaseBranch.cmdGitBaseBranch('/repo', ['--is-protected', 'develop'], {
|
|
loadConfig: () => ({ base_branch: 'main', protected_branches: ['develop', 42] }),
|
|
write: (chunk) => out.push(chunk),
|
|
writeDiagnostic: (chunk) => diagnostics.push(chunk),
|
|
});
|
|
|
|
assert.strictEqual(out.join('').trim(), 'true');
|
|
assert.strictEqual(diagnostics.length, 1, 'the rejection must be surfaced, not swallowed');
|
|
assert.match(diagnostics.join(''), /protected_branches/);
|
|
assert.match(diagnostics.join(''), /42/);
|
|
});
|
|
|
|
test('#3552 negative control: a clean list writes no diagnostic', () => {
|
|
const diagnostics = [];
|
|
gitBaseBranch.cmdGitBaseBranch('/repo', ['--is-protected', 'develop'], {
|
|
loadConfig: () => ({ base_branch: 'main', protected_branches: ['develop'] }),
|
|
write: () => {},
|
|
writeDiagnostic: (chunk) => diagnostics.push(chunk),
|
|
});
|
|
|
|
assert.deepStrictEqual(diagnostics, [],
|
|
'a well-formed list must be silent — otherwise the diagnostic carries no signal');
|
|
});
|
|
|
|
test('#3648 Nit F-7: renderRejected sanitizes control and ANSI characters in diagnostics', () => {
|
|
const diagnostics = [];
|
|
gitBaseBranch.cmdGitBaseBranch('/repo', ['--is-protected', 'develop'], {
|
|
loadConfig: () => ({ base_branch: 'main', protected_branches: ['develop', { malicious: 'bad\x1b[31m\nbranch' }] }),
|
|
write: () => {},
|
|
writeDiagnostic: (chunk) => diagnostics.push(chunk),
|
|
});
|
|
|
|
assert.strictEqual(diagnostics.length, 1);
|
|
const diag = diagnostics.join('');
|
|
assert.match(diag, /bad\\u001b\[31m/);
|
|
assert.strictEqual(diag.includes('\x1b'), false, 'diagnostic must not contain raw ESC control bytes');
|
|
});
|
|
|
|
test('#3552 execute-phase handle_branching wires protected-branch step with Read and execute (not Skip.)', () => {
|
|
const content = readFileNormalized(path.join(WORKFLOW_DIR, 'execute-phase.md'));
|
|
const stepMatch = content.match(/<step name="handle_branching">([\s\S]*?)<\/step>/);
|
|
assert.ok(stepMatch, 'handle_branching step must exist in execute-phase.md');
|
|
const stepBody = stepMatch[1];
|
|
|
|
assert.match(
|
|
stepBody,
|
|
/\*\*"none":\*\*\s+Read and execute `execute-phase\/steps\/protected-branch\.md`\./,
|
|
'execute-phase handle_branching "none" arm must instruct executor to Read and execute the step file',
|
|
);
|
|
assert.doesNotMatch(
|
|
stepBody,
|
|
/\*\*"none":\*\*\s*Skip\./,
|
|
'execute-phase handle_branching "none" arm must not say "Skip."',
|
|
);
|
|
|
|
// Negative control: verify the assertion rejects an advisory "Skip." pointer
|
|
const fakeAdvisoryBody = stepBody.replace(
|
|
/\*\*"none":\*\*\s+Read and execute `execute-phase\/steps\/protected-branch\.md`\./,
|
|
'**"none":** Skip. See `execute-phase/steps/protected-branch.md`.',
|
|
);
|
|
assert.doesNotMatch(
|
|
fakeAdvisoryBody,
|
|
/\*\*"none":\*\*\s+Read and execute `execute-phase\/steps\/protected-branch\.md`\./,
|
|
'negative control: fake advisory pointer must fail the wiring assertion',
|
|
);
|
|
});
|
|
|
|
test('#3552 active workstream CLI uses configured list and excludes root-only names', (t) => {
|
|
const dir = createGitRepo({ prefix: 'msd-3552-cli-', defaultBranch: 'main' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
setMsdConfig(dir, 'git.protected_branches', ['root-only']);
|
|
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
|
|
// Both HOME and USERPROFILE must be redirected — Node's os.homedir() (and
|
|
// anything relying on it) consults USERPROFILE first on Windows, where
|
|
// setting HOME alone leaves the real home directory in effect and makes
|
|
// this isolation silently vacuous on that platform.
|
|
const workstreamEnv = { MSD_WORKSTREAM: 'alpha', HOME: dir, USERPROFILE: dir };
|
|
|
|
const setResult = runMsdTools(
|
|
['config-set', 'git.protected_branches', '["develop","next"]'],
|
|
dir,
|
|
workstreamEnv,
|
|
);
|
|
assert.ok(setResult.success, setResult.error);
|
|
const workstreamConfig = JSON.parse(fs.readFileSync(
|
|
path.join(dir, '.planning', 'workstreams', 'alpha', 'config.json'),
|
|
'utf8',
|
|
));
|
|
assert.deepStrictEqual(workstreamConfig.git.protected_branches, ['develop', 'next']);
|
|
|
|
const match = runMsdTools(
|
|
['query', 'git.base-branch', '--is-protected', 'develop'], dir, workstreamEnv,
|
|
);
|
|
const control = runMsdTools(
|
|
['query', 'git.base-branch', '--is-protected', 'topic/3552'], dir, workstreamEnv,
|
|
);
|
|
const rootOnly = runMsdTools(
|
|
['query', 'git.base-branch', '--is-protected', 'root-only'], dir, workstreamEnv,
|
|
);
|
|
|
|
assert.ok(match.success, match.error);
|
|
assert.ok(control.success, control.error);
|
|
assert.ok(rootOnly.success, rootOnly.error);
|
|
assert.strictEqual(match.output, 'true');
|
|
assert.strictEqual(control.output, 'false');
|
|
assert.strictEqual(rootOnly.output, 'false');
|
|
assert.notStrictEqual(match.output, control.output,
|
|
'CLI negative control must disagree with the configured protected-branch match');
|
|
assert.notStrictEqual(match.output, rootOnly.output,
|
|
'workstream override must replace the root-only configured name');
|
|
|
|
let written = '';
|
|
const returned = gitBaseBranch.cmdGitBaseBranch('/repo', ['--is-protected', 'develop'], {
|
|
loadConfig: configuredLoad,
|
|
write: (text) => { written += text; },
|
|
});
|
|
assert.strictEqual(returned, 'true');
|
|
assert.strictEqual(written, 'true\n', 'direct command contract must remain newline-terminated');
|
|
});
|
|
|
|
test('#3552 execute-phase warns on true, stays silent on false, and continues both', (t) => {
|
|
const bash = extractProtectedBranchWarningBash('execute-phase.md', 'handle_branching');
|
|
const { scriptDir, scriptPath } = writeProtectedBranchWarningScript(
|
|
'msd-3552-execute-',
|
|
bash,
|
|
);
|
|
t.after(() => cleanup(scriptDir));
|
|
|
|
const baseEnv = { ...process.env, CURRENT_BRANCH_VALUE: 'develop' };
|
|
const match = runHook(scriptPath, [], {
|
|
interpreter: 'bash',
|
|
env: { ...baseEnv, PROTECTED_RESULT: 'true' },
|
|
});
|
|
const control = runHook(scriptPath, [], {
|
|
interpreter: 'bash',
|
|
env: { ...baseEnv, PROTECTED_RESULT: 'false' },
|
|
});
|
|
|
|
assert.strictEqual(match.exitCode, 0, match.stderr);
|
|
assert.strictEqual(control.exitCode, 0, control.stderr);
|
|
assert.match(match.stdout, /continued\n$/);
|
|
assert.match(control.stdout, /continued\n$/);
|
|
assert.match(match.stderr, /protected branch/i);
|
|
assert.doesNotMatch(control.stderr, /protected branch/i);
|
|
assert.notStrictEqual(match.stderr, control.stderr,
|
|
'warning negative control must disagree with the protected-branch match');
|
|
});
|
|
|
|
test('#3552 ship warns on true, stays silent on false, and keeps the none-strategy offer', (t) => {
|
|
const bash = extractProtectedBranchWarningBash('ship.md', 'preflight_checks');
|
|
const { scriptDir, scriptPath } = writeProtectedBranchWarningScript(
|
|
'msd-3552-ship-',
|
|
bash,
|
|
);
|
|
t.after(() => cleanup(scriptDir));
|
|
|
|
const baseEnv = { ...process.env, CURRENT_BRANCH_VALUE: 'develop' };
|
|
const match = runHook(scriptPath, [], {
|
|
interpreter: 'bash',
|
|
env: { ...baseEnv, PROTECTED_RESULT: 'true' },
|
|
});
|
|
const control = runHook(scriptPath, [], {
|
|
interpreter: 'bash',
|
|
env: { ...baseEnv, PROTECTED_RESULT: 'false' },
|
|
});
|
|
|
|
assert.strictEqual(match.exitCode, 0, match.stderr);
|
|
assert.strictEqual(control.exitCode, 0, control.stderr);
|
|
assert.match(match.stdout, /continued\n$/);
|
|
assert.match(control.stdout, /continued\n$/);
|
|
assert.match(match.stderr, /protected branch/i);
|
|
assert.doesNotMatch(control.stderr, /protected branch/i);
|
|
assert.notStrictEqual(match.stderr, control.stderr,
|
|
'ship warning negative control must disagree with the protected-branch match');
|
|
|
|
const ship = readFileNormalized(path.join(WORKFLOW_DIR, 'ship.md'));
|
|
const preflight = ship.slice(
|
|
ship.indexOf('<step name="preflight_checks">'),
|
|
ship.indexOf('</step>', ship.indexOf('<step name="preflight_checks">')),
|
|
);
|
|
assert.match(preflight, /branching_strategy is `none`: offer to create a branch now/i,
|
|
'protected-branch warning must retain the none-strategy feature-branch offer');
|
|
});
|
|
|
|
test('#3648 Nit: detached HEAD answers false; a missing argument is reported', () => {
|
|
// `git branch --show-current` prints nothing on a detached HEAD, so the
|
|
// call sites pass an explicit empty string. That must answer false — no
|
|
// protected branch is named '' — and must stay SILENT, because a detached
|
|
// HEAD is a normal state, not a misconfiguration.
|
|
const detachedDiagnostics = [];
|
|
const detachedOut = [];
|
|
gitBaseBranch.cmdGitBaseBranch('/repo', ['--is-protected', ''], {
|
|
loadConfig: () => ({ base_branch: 'main', protected_branches: ['develop'] }),
|
|
write: (chunk) => detachedOut.push(chunk),
|
|
writeDiagnostic: (chunk) => detachedDiagnostics.push(chunk),
|
|
});
|
|
assert.strictEqual(detachedOut.join('').trim(), 'false');
|
|
assert.deepStrictEqual(detachedDiagnostics, [],
|
|
'a detached HEAD is not an error and must not warn');
|
|
|
|
// The flag with NO argument at all is a caller bug that `args[1] ?? ''`
|
|
// silently collapsed into the detached-HEAD case. Same answer, but said
|
|
// out loud so the two are distinguishable.
|
|
const missingDiagnostics = [];
|
|
const missingOut = [];
|
|
gitBaseBranch.cmdGitBaseBranch('/repo', ['--is-protected'], {
|
|
loadConfig: () => ({ base_branch: 'main', protected_branches: ['develop'] }),
|
|
write: (chunk) => missingOut.push(chunk),
|
|
writeDiagnostic: (chunk) => missingDiagnostics.push(chunk),
|
|
});
|
|
assert.strictEqual(missingOut.join('').trim(), 'false');
|
|
assert.strictEqual(missingDiagnostics.length, 1,
|
|
'a missing branch argument must be reported, not read as a detached HEAD');
|
|
assert.match(missingDiagnostics.join(''), /--is-protected/);
|
|
|
|
assert.notDeepStrictEqual(detachedDiagnostics, missingDiagnostics,
|
|
'the two paths must be distinguishable — that is the whole point of the arm');
|
|
|
|
assert.throws(
|
|
() => gitBaseBranch.cmdGitBaseBranch('/repo', ['--is-protectd', 'develop'], {
|
|
loadConfig: () => ({ base_branch: 'main', protected_branches: ['develop'] }),
|
|
}),
|
|
(err) => err instanceof ExitError && err.code === 1,
|
|
'a misspelled predicate flag must be rejected with exit 1 via error()',
|
|
);
|
|
|
|
assert.throws(
|
|
() => gitBaseBranch.cmdGitBaseBranch('/repo', ['--is-protected', 'develop', 'extra'], {
|
|
loadConfig: () => ({ base_branch: 'main', protected_branches: ['develop'] }),
|
|
}),
|
|
(err) => err instanceof ExitError && err.code === 1,
|
|
'surplus predicate arguments must be rejected with exit 1 via error()',
|
|
);
|
|
});
|
|
|
|
test('#3648 cmdGitBaseBranch usage errors emit structured ERROR_REASON.USAGE and do not print stack trace', (t) => {
|
|
const dir = createGitRepo({ prefix: 'msd-3648-usage-', defaultBranch: 'main' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
|
|
const unknownFlag = runMsdTools(['query', 'git.base-branch', '--unknown-flag', '--json-errors'], dir);
|
|
assert.strictEqual(unknownFlag.success, false);
|
|
assert.strictEqual(unknownFlag.exitCode, 1);
|
|
assert.doesNotMatch(unknownFlag.error, /^\s*at\s+/m, 'usage errors must not print stack traces');
|
|
let parsed = null;
|
|
try { parsed = JSON.parse(unknownFlag.error); } catch {}
|
|
assert.strictEqual(parsed?.reason, 'usage');
|
|
|
|
const surplusPositional = runMsdTools(['query', 'git.base-branch', '--is-protected', 'main', 'extra', '--json-errors'], dir);
|
|
assert.strictEqual(surplusPositional.success, false);
|
|
assert.strictEqual(surplusPositional.exitCode, 1);
|
|
assert.doesNotMatch(surplusPositional.error, /^\s*at\s+/m);
|
|
let parsedSurplus = null;
|
|
try { parsedSurplus = JSON.parse(surplusPositional.error); } catch {}
|
|
assert.strictEqual(parsedSurplus?.reason, 'usage');
|
|
});
|
|
|
|
test('#3648 Minor F-1: nested and flat predicate reads stay aligned with the loader', () => {
|
|
const configLoader = require('../msd-core/bin/lib/config-loader.cjs');
|
|
const cases = [
|
|
{ git: { base_branch: 'nested', protected_branches: ['develop'] }, base_branch: 'flat', protected_branches: ['bogus'] },
|
|
{ git: { base_branch: 'nested' }, base_branch: 'flat' },
|
|
{ git: 'not-an-object', base_branch: 'flat', protected_branches: ['bogus'] },
|
|
];
|
|
for (const fixture of cases) {
|
|
const seam = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'develop', {
|
|
readFile: () => JSON.stringify(fixture),
|
|
});
|
|
assert.strictEqual(
|
|
gitBaseBranch._readGitKey(fixture, 'base_branch'),
|
|
configLoader._getConfigValue(fixture, 'base_branch', { section: 'git', field: 'base_branch' }),
|
|
);
|
|
assert.strictEqual(
|
|
gitBaseBranch._readGitNested(fixture, 'protected_branches'),
|
|
configLoader._getConfigNested(fixture, 'git', 'protected_branches'),
|
|
);
|
|
assert.ok(seam);
|
|
}
|
|
});
|
|
|
|
test('#3648 Blocker 4: the predicate diagnostic reaches the user at both call sites', (t) => {
|
|
// Both call sites piped the query's stderr to /dev/null, so the
|
|
// fail-closed explanation ("could not verify the base branch ... defaulting
|
|
// to protected") was discarded and the user saw a bare protected-branch
|
|
// warning on a branch that is not protected. The diagnostic was exercised
|
|
// only by its unit test — dead in production.
|
|
const cases = [
|
|
['execute-phase.md', 'handle_branching', 'msd-3648-b4-execute-'],
|
|
['ship.md', 'preflight_checks', 'msd-3648-b4-ship-'],
|
|
];
|
|
|
|
for (const [workflow, step, prefix] of cases) {
|
|
const bash = extractProtectedBranchWarningBash(workflow, step);
|
|
const { scriptDir, scriptPath } = writeProtectedBranchWarningScript(prefix, bash);
|
|
t.after(() => cleanup(scriptDir));
|
|
|
|
const baseEnv = {
|
|
...process.env,
|
|
CURRENT_BRANCH_VALUE: 'develop',
|
|
PROTECTED_RESULT: 'true',
|
|
};
|
|
const surfaced = runHook(scriptPath, [], {
|
|
interpreter: 'bash',
|
|
env: { ...baseEnv, DIAGNOSTIC_TEXT: 'could not verify the base branch' },
|
|
});
|
|
const control = runHook(scriptPath, [], {
|
|
interpreter: 'bash',
|
|
env: { ...baseEnv, DIAGNOSTIC_TEXT: '' },
|
|
});
|
|
|
|
assert.strictEqual(surfaced.exitCode, 0, surfaced.stderr);
|
|
assert.match(surfaced.stderr, /could not verify the base branch/,
|
|
workflow + " must not discard the predicate's explanation");
|
|
// Negative control: with nothing emitted that text must be absent,
|
|
// otherwise the assertion above could pass on unrelated output.
|
|
assert.doesNotMatch(control.stderr, /could not verify the base branch/);
|
|
assert.notStrictEqual(surfaced.stderr, control.stderr);
|
|
// The warning itself still fires in both, so the diagnostic is additive.
|
|
assert.match(surfaced.stderr, /protected branch/i);
|
|
assert.match(control.stderr, /protected branch/i);
|
|
}
|
|
});
|
|
|
|
test('#3648 round-4: a FAILED query degrades visibly, and does not abort under set -e', (t) => {
|
|
// Found by the round-4 external review. `IS_PROTECTED=$(msd_run ...)` had
|
|
// two problems when the query itself failed (msd-tools absent, a crash, any
|
|
// non-zero exit): the substitution yielded an empty string, so `[ "$X" = true ]`
|
|
// was simply false and the workflow continued with NO warning and no trace —
|
|
// a silent fail-open in the guard whose whole purpose is to warn; and the
|
|
// bare assignment is the last command in its own right, so a non-zero exit
|
|
// aborted the step under `set -e` before any branch ran.
|
|
//
|
|
// The contract is neither fail-open nor fail-closed: it degrades VISIBLY.
|
|
// Claiming "protected" on no evidence would warn on every branch whenever
|
|
// msd-tools is unavailable; claiming "not protected" is the silent hole.
|
|
const cases = [
|
|
['execute-phase.md', 'handle_branching', 'msd-3648-r4-execute-'],
|
|
['ship.md', 'preflight_checks', 'msd-3648-r4-ship-'],
|
|
];
|
|
|
|
for (const [workflow, step, prefix] of cases) {
|
|
const bash = extractProtectedBranchWarningBash(workflow, step);
|
|
const { scriptDir, scriptPath } = writeProtectedBranchWarningScript(prefix, bash);
|
|
t.after(() => cleanup(scriptDir));
|
|
|
|
const baseEnv = {
|
|
...process.env,
|
|
CURRENT_BRANCH_VALUE: 'topic/3648',
|
|
PROTECTED_RESULT: 'false',
|
|
};
|
|
const failed = runHook(scriptPath, [], {
|
|
interpreter: 'bash',
|
|
env: { ...baseEnv, QUERY_EXIT: '3' },
|
|
});
|
|
// Control: the SAME script with a working query must stay silent on a
|
|
// non-protected branch. Without it, an assertion that the failure warns
|
|
// could pass against a script that warns unconditionally.
|
|
const working = runHook(scriptPath, [], {
|
|
interpreter: 'bash',
|
|
env: { ...baseEnv, QUERY_EXIT: '0' },
|
|
});
|
|
|
|
// The harness runs under `set -eu`, so this also pins the set -e half.
|
|
assert.strictEqual(failed.exitCode, 0,
|
|
workflow + ' must survive a failed query under set -e: ' + failed.stderr);
|
|
assert.match(failed.stdout, /continued/,
|
|
workflow + ' must reach the end of the step');
|
|
assert.match(failed.stderr, /Could not determine whether/,
|
|
workflow + ' must say the check did not run, rather than pass silently');
|
|
assert.doesNotMatch(failed.stderr, /is a protected branch/,
|
|
'and must NOT assert protectedness it never established');
|
|
|
|
assert.strictEqual(working.exitCode, 0, working.stderr);
|
|
assert.doesNotMatch(working.stderr, /Could not determine whether/,
|
|
'a working query must not emit the degradation notice');
|
|
assert.doesNotMatch(working.stderr, /is a protected branch/,
|
|
'and must not warn about a branch that is not protected');
|
|
assert.notStrictEqual(failed.stderr, working.stderr,
|
|
'the two paths must be distinguishable');
|
|
}
|
|
});
|
|
|
|
test('#3648 Minor 2: ship binds the predicate result instead of discarding it', (t) => {
|
|
// ship.md's prose branches on protectedness twice ("warn - should be on a
|
|
// feature branch", "if branching_strategy is none, offer to create a
|
|
// branch"). Echoing the warning without binding leaves the agent inferring
|
|
// state from warning text in tool output; the comparison it replaced was
|
|
// directly evaluable.
|
|
const bash = extractProtectedBranchWarningBash('ship.md', 'preflight_checks');
|
|
const { scriptDir, scriptPath } = writeProtectedBranchWarningScript('msd-3648-bind-', bash);
|
|
t.after(() => cleanup(scriptDir));
|
|
|
|
const baseEnv = { ...process.env, CURRENT_BRANCH_VALUE: 'develop' };
|
|
const match = runHook(scriptPath, [], {
|
|
interpreter: 'bash',
|
|
env: { ...baseEnv, PROTECTED_RESULT: 'true' },
|
|
});
|
|
const control = runHook(scriptPath, [], {
|
|
interpreter: 'bash',
|
|
env: { ...baseEnv, PROTECTED_RESULT: 'false' },
|
|
});
|
|
|
|
assert.match(match.stdout, /^IS_PROTECTED=true$/m,
|
|
'ship must bind the predicate result to a variable the following prose can branch on');
|
|
assert.match(control.stdout, /^IS_PROTECTED=false$/m,
|
|
'the binding must track the predicate, not be hardcoded');
|
|
assert.notStrictEqual(match.stdout, control.stdout);
|
|
});
|
|
});
|
|
|
|
// ─── #3819: allow_default_branch_commits escape hatch ────────────────────────
|
|
|
|
describe('#3819: allow_default_branch_commits escape hatch', () => {
|
|
test('#3819 allow_default_branch_commits:true + currentBranch on base branch → not protected', () => {
|
|
const loadConfig = () => ({ base_branch: 'main', allow_default_branch_commits: true });
|
|
const status = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'main', { loadConfig });
|
|
|
|
assert.strictEqual(status.isProtected, false,
|
|
'the escape hatch must exempt the base branch from auto-included protection');
|
|
assert.deepStrictEqual(status.protectedBranches, [],
|
|
'the base branch must not appear in protectedBranches when the escape hatch is on');
|
|
assert.strictEqual(status.allowDefaultBranchCommits, true);
|
|
});
|
|
|
|
test('#3819 explicit protected_branches still applies with the escape hatch on', () => {
|
|
const loadConfig = () => ({
|
|
base_branch: 'main',
|
|
allow_default_branch_commits: true,
|
|
protected_branches: ['develop'],
|
|
});
|
|
const onBase = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'main', { loadConfig });
|
|
const onExplicit = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'develop', { loadConfig });
|
|
|
|
assert.strictEqual(onBase.isProtected, false,
|
|
'the base branch escapes protection even though an explicit list is also configured');
|
|
assert.strictEqual(onExplicit.isProtected, true,
|
|
'an explicitly configured protected branch must still be enforced despite the escape hatch');
|
|
assert.notStrictEqual(onBase.isProtected, onExplicit.isProtected,
|
|
'the two cases must disagree — otherwise the explicit list is not actually being enforced');
|
|
});
|
|
|
|
test('#3819 negative control: allow_default_branch_commits absent → base-branch protection unchanged', () => {
|
|
const loadConfig = () => ({ base_branch: 'main' });
|
|
const status = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'main', { loadConfig });
|
|
|
|
assert.strictEqual(status.allowDefaultBranchCommits, false,
|
|
'default must be false/off when the key is not set at all');
|
|
assert.strictEqual(status.isProtected, true,
|
|
'without the escape hatch, the base branch must remain protected as before #3819');
|
|
});
|
|
|
|
test('#3819 allow_default_branch_commits:false explicitly → same as absent', () => {
|
|
const loadConfig = () => ({ base_branch: 'main', allow_default_branch_commits: false });
|
|
const status = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'main', { loadConfig });
|
|
|
|
assert.strictEqual(status.allowDefaultBranchCommits, false);
|
|
assert.strictEqual(status.isProtected, true,
|
|
'an explicit false must behave identically to leaving the key unset');
|
|
});
|
|
|
|
test('#3819 CLI: git.allow_default_branch_commits:true makes --is-protected report false on the base branch', (t) => {
|
|
const dir = createGitRepo({ prefix: 'msd-3819-cli-', defaultBranch: 'main' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
setMsdConfig(dir, 'git.allow_default_branch_commits', true);
|
|
|
|
const escaped = runMsdTools(['query', 'git.base-branch', '--is-protected', 'main'], dir);
|
|
assert.ok(escaped.success, escaped.error);
|
|
assert.strictEqual(escaped.output, 'false',
|
|
'the escape hatch must make the CLI report the base branch as not protected');
|
|
|
|
// Negative control: a sibling fixture without the config key must still
|
|
// report the base branch as protected — proves the flag, not some other
|
|
// difference between the two fixtures, drives the result above.
|
|
const controlDir = createGitRepo({ prefix: 'msd-3819-cli-control-', defaultBranch: 'main' });
|
|
t.after(() => cleanup(controlDir));
|
|
addPlanning(controlDir);
|
|
const control = runMsdTools(['query', 'git.base-branch', '--is-protected', 'main'], controlDir);
|
|
assert.ok(control.success, control.error);
|
|
assert.strictEqual(control.output, 'true');
|
|
assert.notStrictEqual(escaped.output, control.output,
|
|
'CLI negative control must disagree with the escape-hatch fixture');
|
|
});
|
|
});
|
|
|
|
// ─── #3819: msd-executor.md pre-commit protected-branch guard ────────────────
|
|
|
|
/**
|
|
* Extract the FIRST ```bash fenced block that follows the "Pre-commit
|
|
* protected-branch safety assertion" heading in agents/msd-executor.md.
|
|
* Keyed off the heading text (not a `<step name>` tag — this step has no
|
|
* such wrapper) so a future rewording of the heading fails the test loudly
|
|
* instead of silently extracting nothing.
|
|
*/
|
|
function extractExecutorPreCommitBash() {
|
|
const executorPath = path.join(__dirname, '..', 'agents', 'msd-executor.md');
|
|
const content = readFileNormalized(executorPath);
|
|
const lines = content.split('\n');
|
|
const headingIndex = lines.findIndex(
|
|
(line) => line.includes('Pre-commit HEAD safety assertion'),
|
|
);
|
|
if (headingIndex === -1) {
|
|
throw new Error(
|
|
'agents/msd-executor.md: could not find the "Pre-commit HEAD safety ' +
|
|
'assertion" heading — has it been reworded?',
|
|
);
|
|
}
|
|
let inBash = false;
|
|
const buffer = [];
|
|
for (let i = headingIndex + 1; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (!inBash && /^\s*```bash\s*$/.test(line)) {
|
|
inBash = true;
|
|
continue;
|
|
}
|
|
if (inBash && /^\s*```\s*$/.test(line)) {
|
|
return buffer.join('\n');
|
|
}
|
|
if (inBash) buffer.push(line);
|
|
}
|
|
throw new Error(
|
|
'agents/msd-executor.md: no ```bash block found after the pre-commit protected-branch ' +
|
|
'heading — has the step been restructured?',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Write a standalone script that mocks `git` and `msd_run`, then runs the
|
|
* extracted pre-commit guard bash verbatim. Deliberately does NOT `set -e`:
|
|
* the guard's fatal paths call `exit 1` explicitly, and this script must run
|
|
* to completion on the non-fatal path to print the GUARD_PASSED sentinel.
|
|
*
|
|
* `isWorktree` controls whether `scriptDir` gets a `.git` FILE (worktree) or
|
|
* a `.git` DIRECTORY (ordinary checkout) — the guard's own `[ -f .git ]`
|
|
* branch reads this from the script's cwd, so the test runs the script with
|
|
* `cwd: scriptDir`.
|
|
*/
|
|
function writeExecutorGuardScript(prefix, bash, { branch, headRef, isWorktree, queryResult, queryExit = 0 }) {
|
|
const scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
const gitPath = path.join(scriptDir, '.git');
|
|
if (isWorktree) {
|
|
fs.writeFileSync(gitPath, 'gitdir: /nonexistent\n');
|
|
} else {
|
|
fs.mkdirSync(gitPath);
|
|
}
|
|
const scriptPath = path.join(scriptDir, 'guard.sh');
|
|
fs.writeFileSync(scriptPath, [
|
|
'#!/usr/bin/env bash',
|
|
'git() {',
|
|
' if [ "$1" = symbolic-ref ]; then',
|
|
headRef === 'DETACHED'
|
|
? ' return 1'
|
|
: ` printf "%s\\n" "${headRef}"\n return 0`,
|
|
' elif [ "$1" = rev-parse ] && [ "$2" = --abbrev-ref ]; then',
|
|
` printf "%s\\n" "${branch}"`,
|
|
' return 0',
|
|
' else',
|
|
' printf "unexpected git invocation: %s\\n" "$*" >&2',
|
|
' return 97',
|
|
' fi',
|
|
'}',
|
|
'msd_run() {',
|
|
` if [ "$#" -ne 4 ] || [ "$1" != query ] || [ "$2" != git.base-branch ] || ` +
|
|
`[ "$3" != --is-protected ] || [ "$4" != "${branch}" ]; then`,
|
|
' printf "unexpected msd_run invocation: %s\\n" "$*" >&2',
|
|
' return 0',
|
|
' fi',
|
|
queryExit
|
|
? ` return ${queryExit}`
|
|
: ` printf "%s\\n" "${queryResult}"`,
|
|
'}',
|
|
bash,
|
|
'printf "GUARD_PASSED\\n"',
|
|
].join('\n'), { mode: 0o755 });
|
|
return { scriptDir, scriptPath };
|
|
}
|
|
|
|
describe('#3819: msd-executor.md pre-commit protected-branch guard', () => {
|
|
const bash = extractExecutorPreCommitBash();
|
|
|
|
test('#3819 non-worktree, branch is protected (query says true) → HALT with exit 1, no GUARD_PASSED', (t) => {
|
|
const { scriptDir, scriptPath } = writeExecutorGuardScript('msd-3819-guard-nonwt-protected-', bash, {
|
|
branch: 'main',
|
|
headRef: 'main',
|
|
isWorktree: false,
|
|
queryResult: 'true',
|
|
});
|
|
t.after(() => cleanup(scriptDir));
|
|
|
|
const result = runHook(scriptPath, [], { interpreter: 'bash', cwd: scriptDir, timeoutMs: EXECUTOR_GUARD_TIMEOUT_MS });
|
|
|
|
assert.strictEqual(result.exitCode, 1, result.stderr);
|
|
assert.match(result.stderr, /protected\/default branch/);
|
|
assert.doesNotMatch(result.stdout, /GUARD_PASSED/);
|
|
});
|
|
|
|
test('#3819 non-worktree, branch is not protected (query says false) → continues, GUARD_PASSED', (t) => {
|
|
const { scriptDir, scriptPath } = writeExecutorGuardScript('msd-3819-guard-nonwt-clean-', bash, {
|
|
branch: 'feature-x',
|
|
headRef: 'feature-x',
|
|
isWorktree: false,
|
|
queryResult: 'false',
|
|
});
|
|
t.after(() => cleanup(scriptDir));
|
|
|
|
const result = runHook(scriptPath, [], { interpreter: 'bash', cwd: scriptDir, timeoutMs: EXECUTOR_GUARD_TIMEOUT_MS });
|
|
|
|
assert.strictEqual(result.exitCode, 0, result.stderr);
|
|
assert.match(result.stdout, /GUARD_PASSED/);
|
|
});
|
|
|
|
test('#3819 worktree, branch is protected → HALT with exit 1 (protected check fires before the allow-list check)', (t) => {
|
|
const { scriptDir, scriptPath } = writeExecutorGuardScript('msd-3819-guard-wt-protected-', bash, {
|
|
branch: 'main',
|
|
headRef: 'main',
|
|
isWorktree: true,
|
|
queryResult: 'true',
|
|
});
|
|
t.after(() => cleanup(scriptDir));
|
|
|
|
const result = runHook(scriptPath, [], { interpreter: 'bash', cwd: scriptDir, timeoutMs: EXECUTOR_GUARD_TIMEOUT_MS });
|
|
|
|
assert.strictEqual(result.exitCode, 1, result.stderr);
|
|
assert.match(result.stderr, /protected\/default branch/);
|
|
});
|
|
|
|
test('#3819 worktree, branch not protected but outside the agent-*/worktree-agent-*/worktree-wf_* namespace → HALT', (t) => {
|
|
const { scriptDir, scriptPath } = writeExecutorGuardScript('msd-3819-guard-wt-outside-namespace-', bash, {
|
|
branch: 'some-random-branch',
|
|
headRef: 'some-random-branch',
|
|
isWorktree: true,
|
|
queryResult: 'false',
|
|
});
|
|
t.after(() => cleanup(scriptDir));
|
|
|
|
const result = runHook(scriptPath, [], { interpreter: 'bash', cwd: scriptDir, timeoutMs: EXECUTOR_GUARD_TIMEOUT_MS });
|
|
|
|
assert.strictEqual(result.exitCode, 1, result.stderr);
|
|
assert.match(result.stderr, /not in the agent-\* \/ worktree-agent-\* \/ worktree-wf_\* namespace/);
|
|
});
|
|
|
|
test('#3819 worktree, branch not protected AND in the agent-* namespace → continues, GUARD_PASSED', (t) => {
|
|
const { scriptDir, scriptPath } = writeExecutorGuardScript('msd-3819-guard-wt-clean-', bash, {
|
|
branch: 'agent-42',
|
|
headRef: 'agent-42',
|
|
isWorktree: true,
|
|
queryResult: 'false',
|
|
});
|
|
t.after(() => cleanup(scriptDir));
|
|
|
|
const result = runHook(scriptPath, [], { interpreter: 'bash', cwd: scriptDir, timeoutMs: EXECUTOR_GUARD_TIMEOUT_MS });
|
|
|
|
assert.strictEqual(result.exitCode, 0, result.stderr);
|
|
assert.match(result.stdout, /GUARD_PASSED/);
|
|
});
|
|
|
|
test('#3819 detached HEAD (non-worktree) → HALT with exit 1 before any protected-branch query', (t) => {
|
|
const { scriptDir, scriptPath } = writeExecutorGuardScript('msd-3819-guard-detached-', bash, {
|
|
branch: 'HEAD',
|
|
headRef: 'DETACHED',
|
|
isWorktree: false,
|
|
queryResult: 'false',
|
|
});
|
|
t.after(() => cleanup(scriptDir));
|
|
|
|
const result = runHook(scriptPath, [], { interpreter: 'bash', cwd: scriptDir, timeoutMs: EXECUTOR_GUARD_TIMEOUT_MS });
|
|
|
|
assert.strictEqual(result.exitCode, 1, result.stderr);
|
|
assert.match(result.stderr, /detached/);
|
|
});
|
|
|
|
test('#3819 HOSTILE: msd_run RUNS but reports the branch as protected (resolver-internal fail-closed) → HALT', (t) => {
|
|
// This is the scenario that must stay fail-closed unconditionally:
|
|
// msd_run itself succeeds (exit 0) — e.g. because cmdGitBaseBranch's own
|
|
// pre-existing #3057 B4 logic already answered "true" when it could not
|
|
// VERIFY the real default branch — and the bash guard must never
|
|
// second-guess that answer. It is deliberately indistinguishable, at
|
|
// this bash layer, from an ordinary "yes this branch is protected"
|
|
// answer (see the "non-worktree, branch is protected" test above) —
|
|
// that IS the fail-closed contract: the guard trusts what msd_run says
|
|
// when msd_run actually says something.
|
|
//
|
|
// This is a DIFFERENT failure mode from "msd_run could not be invoked at
|
|
// all" (nonzero exit / no output), which is covered by the two
|
|
// "msd_run itself is unavailable" tests below and — per the #3819
|
|
// approval's item 2 ("keep the existing names as a fallback where the
|
|
// default cannot be resolved") — deliberately does NOT fail closed for
|
|
// every branch; it falls back to the pre-#3819 five-name list instead.
|
|
const { scriptDir, scriptPath } = writeExecutorGuardScript('msd-3819-guard-resolver-unverified-', bash, {
|
|
branch: 'feature-x',
|
|
headRef: 'feature-x',
|
|
isWorktree: false,
|
|
queryResult: 'true',
|
|
});
|
|
t.after(() => cleanup(scriptDir));
|
|
|
|
const result = runHook(scriptPath, [], { interpreter: 'bash', cwd: scriptDir, timeoutMs: EXECUTOR_GUARD_TIMEOUT_MS });
|
|
|
|
assert.strictEqual(result.exitCode, 1, result.stderr);
|
|
assert.match(result.stderr, /protected\/default branch/);
|
|
assert.doesNotMatch(result.stdout, /GUARD_PASSED/);
|
|
});
|
|
|
|
test('#3819 msd_run itself is unavailable (query cannot run) + branch matches the old five-name list → still HALTs via the fallback', (t) => {
|
|
const { scriptDir, scriptPath } = writeExecutorGuardScript('msd-3819-guard-unavailable-listed-', bash, {
|
|
branch: 'develop',
|
|
headRef: 'develop',
|
|
isWorktree: false,
|
|
queryResult: 'false',
|
|
queryExit: 1,
|
|
});
|
|
t.after(() => cleanup(scriptDir));
|
|
|
|
const result = runHook(scriptPath, [], { interpreter: 'bash', cwd: scriptDir, timeoutMs: EXECUTOR_GUARD_TIMEOUT_MS });
|
|
|
|
assert.strictEqual(result.exitCode, 1, result.stderr);
|
|
assert.match(result.stderr, /protected\/default branch/);
|
|
});
|
|
|
|
test('#3819 msd_run itself is unavailable (query cannot run) + branch does NOT match the old five-name list → falls back to allowing it', (t) => {
|
|
// Deliberate #3819-approved tradeoff: when msd-tools itself cannot even be
|
|
// invoked, an unlisted branch is allowed through rather than blocking every
|
|
// branch outright — unlike the "HOSTILE: the protected-branch query itself
|
|
// fails" test above (msd-tools runs fine but the resolver's own internal
|
|
// verification fails), which stays fail-closed.
|
|
const { scriptDir, scriptPath } = writeExecutorGuardScript('msd-3819-guard-unavailable-unlisted-', bash, {
|
|
branch: 'my-feature-branch',
|
|
headRef: 'my-feature-branch',
|
|
isWorktree: false,
|
|
queryResult: 'false',
|
|
queryExit: 1,
|
|
});
|
|
t.after(() => cleanup(scriptDir));
|
|
|
|
const result = runHook(scriptPath, [], { interpreter: 'bash', cwd: scriptDir, timeoutMs: EXECUTOR_GUARD_TIMEOUT_MS });
|
|
|
|
assert.strictEqual(result.exitCode, 0, result.stderr);
|
|
assert.match(result.stdout, /GUARD_PASSED/);
|
|
});
|
|
|
|
// allow-test-rule: source-text-is-the-product
|
|
// Justification: this test's whole point is asserting on the prose text of the
|
|
// <final_commit> block itself — the workflow .md IS the product surface here,
|
|
// same rationale as Guard G (see the top-of-file exemption above).
|
|
test('#3819 <final_commit> block instructs the executor to re-run the Step 0 guard before the final commit', () => {
|
|
const executorPath = path.join(__dirname, '..', 'agents', 'msd-executor.md');
|
|
const content = readFileNormalized(executorPath);
|
|
const startIndex = content.indexOf('<final_commit>');
|
|
assert.notStrictEqual(startIndex, -1, 'agents/msd-executor.md: could not find <final_commit> tag — has it been renamed?');
|
|
const fenceIndex = content.indexOf('```bash', startIndex);
|
|
const excerpt = fenceIndex === -1
|
|
? content.slice(startIndex, startIndex + 2000)
|
|
: content.slice(startIndex, fenceIndex);
|
|
|
|
assert.match(excerpt, /re-run the Step 0/);
|
|
assert.match(excerpt, /#3819/);
|
|
});
|
|
});
|
|
|
|
// ─── #3648 Major 1: negative space for readEffectiveGitConfig's readFile seam ─
|
|
//
|
|
// The #3057 W3 suite that pinned readConfigBaseBranch's unusable-config arms
|
|
// was deleted with the function it targeted, but every arm it covered survives
|
|
// verbatim in readEffectiveGitConfig's readFile branch: the JSON.parse catch,
|
|
// the non-object guard, the git-section object guard, .trim(), and blank-string
|
|
// rejection. Deleting the tests left all of them unexercised — the four
|
|
// surviving readFile injections are positive-path only, and protected_branches
|
|
// was never driven through this seam at all.
|
|
//
|
|
// Reaching the seam requires readFile WITHOUT loadConfig. execGit is stubbed to
|
|
// answer cleanly-but-emptily so tiers 2-4 fall to a VERIFIED 'main', which
|
|
// makes 'main' the unambiguous signal that the config tier contributed nothing.
|
|
//
|
|
// WHAT THIS SUITE DOES AND DOES NOT PIN. Verified by mutating the built lib and
|
|
// re-running:
|
|
//
|
|
// .trim() on the resolved value -> KILLED by the positive controls
|
|
// the non-object guard on JSON.parse output -> SURVIVES
|
|
// the blank-string rejection -> SURVIVES
|
|
//
|
|
// The two survivors are unreachable through this entry point, for the same
|
|
// reason the deleted #3057 W3 suite recorded against its own equivalents:
|
|
//
|
|
// * A JSON-parsed value can never carry a `.git` or `.base_branch` own
|
|
// property unless it is already an object, so `null` / `42` / `"x"` / `[]`
|
|
// read as "no keys" whether or not the guard runs.
|
|
// * A blank base_branch is rejected a second time downstream — the resolver's
|
|
// `if (configured)` treats '' as falsy — so removing the guard here changes
|
|
// no observable output.
|
|
//
|
|
// Both remain defence-in-depth for a future non-JSON caller. They are recorded
|
|
// here as known-unkillable rather than left to look like coverage this suite
|
|
// does not provide.
|
|
|
|
describe('#3648 property: config-set validation and resolver filtering must agree', () => {
|
|
// The two new validating surfaces this PR adds are deliberately different
|
|
// shapes: `config-set` is all-or-nothing (reject the whole write), while the
|
|
// resolver is per-entry (drop the bad names, keep the good ones, report), so
|
|
// that a direct edit of config.json cannot fail the predicate OPEN. Nothing
|
|
// structural keeps the two definitions of "usable branch name" in step —
|
|
// only this property, which asks both about the same values.
|
|
const fc = require('./helpers/fast-check-setup.cjs');
|
|
const { isValidProtectedBranches } = require('../msd-core/bin/lib/config.cjs');
|
|
|
|
/** A value generator weighted towards the boundary cases both surfaces care about. */
|
|
const entry = () => fc.oneof(
|
|
fc.string(),
|
|
fc.constantFrom('', ' ', '\t\n', ' develop ', 'develop'),
|
|
fc.integer(),
|
|
fc.boolean(),
|
|
fc.constant(null),
|
|
fc.constant(undefined),
|
|
);
|
|
|
|
/**
|
|
* `fc.array` never produces a HOLE, and a hole is exactly where the two
|
|
* surfaces disagreed: `.every()` skips holes, `for...of` yields `undefined`
|
|
* for them. The property passed only because the generator could not reach
|
|
* the case (round-4 external review). Punching holes into a generated array
|
|
* is what makes this axis falsifiable.
|
|
*/
|
|
const withHoles = () => fc.tuple(
|
|
fc.array(entry(), { maxLength: 5 }),
|
|
fc.array(fc.nat({ max: 5 }), { maxLength: 3 }),
|
|
).map(([values, holeIndices]) => {
|
|
const arr = values.slice();
|
|
for (const i of holeIndices) {
|
|
if (i < arr.length) delete arr[i];
|
|
}
|
|
return arr;
|
|
});
|
|
|
|
test('accepted by config-set ⟺ the resolver rejects nothing from a non-empty list', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.oneof(fc.array(entry(), { maxLength: 6 }), withHoles(), entry()),
|
|
(configured) => {
|
|
const status = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'topic/x', {
|
|
loadConfig: () => ({ base_branch: 'main', protected_branches: configured }),
|
|
});
|
|
|
|
const resolverKeptEverything = Array.isArray(configured)
|
|
&& configured.length > 0
|
|
&& status.rejectedProtectedBranches.length === 0;
|
|
|
|
assert.strictEqual(isValidProtectedBranches(configured), resolverKeptEverything,
|
|
`disagreement on ${JSON.stringify(configured)}`);
|
|
},
|
|
),
|
|
{ numRuns: 400 },
|
|
);
|
|
});
|
|
|
|
test('a top-level `protected_branches` is NOT an alias for the canonical nested key', (t) => {
|
|
// Round-4 external review. `get(key, {section, field})` is flat-then-nested,
|
|
// which is back-compat for keys `normalizeLegacyKeys` migrates. Routing a
|
|
// BRAND-NEW key through it invents an undocumented top-level spelling that
|
|
// silently outranks `git.protected_branches`. `protected_branches` has no
|
|
// legacy form, so it resolves nested-only — in production and in the seam.
|
|
const dir = createGitRepo({ prefix: 'msd-3648-flat-', defaultBranch: 'main' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
fs.writeFileSync(
|
|
path.join(dir, '.planning', 'config.json'),
|
|
JSON.stringify({ protected_branches: ['bogus'], git: { protected_branches: ['develop'] } }),
|
|
);
|
|
|
|
const configLoader = require('../msd-core/bin/lib/config-loader.cjs');
|
|
assert.deepStrictEqual(
|
|
configLoader.loadConfig(dir, { persist: false })['protected_branches'], ['develop'],
|
|
'the canonical nested key must win outright');
|
|
|
|
// Control: `base_branch` DOES keep flat-then-nested, because it has a legacy
|
|
// flat spelling #3760's refusal path can leave behind. Without this, the
|
|
// assertion above could pass against a loader that lost flat support wholesale.
|
|
const seam = gitBaseBranch.resolveProtectedBranchStatus(dir, 'develop', {
|
|
readFile: () => JSON.stringify({
|
|
git: 'not-an-object', base_branch: 'release', protected_branches: ['bogus'],
|
|
}),
|
|
});
|
|
assert.strictEqual(seam.baseBranch, 'release',
|
|
'a refused migration must still let the surviving flat base_branch through');
|
|
assert.deepStrictEqual(seam.protectedBranches, ['release'],
|
|
'while a top-level protected_branches contributes nothing');
|
|
});
|
|
|
|
test('every surviving name is trimmed, non-empty, and de-duplicated against the base', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.array(entry(), { maxLength: 6 }),
|
|
(configured) => {
|
|
const status = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'topic/x', {
|
|
loadConfig: () => ({ base_branch: 'main', protected_branches: configured }),
|
|
});
|
|
|
|
for (const name of status.protectedBranches) {
|
|
assert.strictEqual(typeof name, 'string');
|
|
assert.strictEqual(name, name.trim(), 'names must be stored trimmed');
|
|
assert.notStrictEqual(name, '', 'a blank name would silently match a detached HEAD');
|
|
}
|
|
assert.deepStrictEqual(
|
|
status.protectedBranches, [...new Set(status.protectedBranches)],
|
|
'duplicates (including one equal to the base branch) must collapse');
|
|
assert.strictEqual(status.protectedBranches[0], 'main',
|
|
'the resolved base branch is always protected and always leads');
|
|
|
|
// Nothing is lost silently: each input element is either kept
|
|
// (trimmed) or reported as rejected.
|
|
const kept = configured.filter((b) => typeof b === 'string' && b.trim() !== '');
|
|
assert.strictEqual(
|
|
kept.length + status.rejectedProtectedBranches.length, configured.length);
|
|
},
|
|
),
|
|
{ numRuns: 400 },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('#3648 Blocker 1: --is-protected is a QUERY and must not rewrite config.json', () => {
|
|
// The predicate runs on every execute-phase and every ship. It resolves config
|
|
// through config-loader's `loadConfig`, which normalizes legacy keys and then
|
|
// WRITES the migrated shape back to .planning/config.json. A boolean question
|
|
// was therefore silently rewriting the user's checked-in config — and this PR
|
|
// widened the trigger by adding a fifth normalization block (top-level
|
|
// `base_branch` -> `git.base_branch`). The read now passes `persist: false`.
|
|
//
|
|
// Asserted on BYTES, not on parsed shape: the persisted rewrite reorders keys
|
|
// and reflows whitespace even when the resolved values are equivalent.
|
|
|
|
/** A config whose ONLY interesting property is that it triggers a normalization. */
|
|
function writeLegacyConfig(dir) {
|
|
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
|
const cfgPath = path.join(dir, '.planning', 'config.json');
|
|
// Hand-written formatting deliberately unlike JSON.stringify(cfg, null, 2):
|
|
// if anything rewrites this file, the bytes cannot come back identical.
|
|
fs.writeFileSync(cfgPath, '{"base_branch": "develop", "granularity": "standard"}\n');
|
|
return cfgPath;
|
|
}
|
|
|
|
test('a legacy-key config survives --is-protected byte-for-byte, and is still honoured', (t) => {
|
|
const dir = createGitRepo({ prefix: 'msd-3648-b1-', defaultBranch: 'main' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
const cfgPath = writeLegacyConfig(dir);
|
|
fs.appendFileSync(path.join(dir, '.git', 'info', 'exclude'), '\n.planning/\n');
|
|
const before = fs.readFileSync(cfgPath);
|
|
const planningBefore = snapshotPlanningTree(dir);
|
|
|
|
const match = runMsdTools(['query', 'git.base-branch', '--is-protected', 'develop'], dir);
|
|
const control = runMsdTools(['query', 'git.base-branch', '--is-protected', 'topic/3648'], dir);
|
|
|
|
assert.ok(match.success, match.error);
|
|
assert.ok(control.success, control.error);
|
|
// Positive control: the flat legacy `base_branch` DID reach the predicate.
|
|
// Without this the byte assertion below would also pass for a query that
|
|
// ignored the config entirely.
|
|
assert.strictEqual(match.output, 'true');
|
|
assert.strictEqual(control.output, 'false');
|
|
|
|
assert.deepStrictEqual(
|
|
fs.readFileSync(cfgPath), before,
|
|
'a read-only predicate must leave .planning/config.json byte-identical',
|
|
);
|
|
assert.deepStrictEqual(
|
|
snapshotPlanningTree(dir), planningBefore,
|
|
'a read-only predicate must leave the entire .planning tree byte-identical',
|
|
);
|
|
const status = gitOrThrow(['status', '--porcelain'], { cwd: dir });
|
|
assert.strictEqual(status, '', 'read-only predicate must not leave a sibling write');
|
|
});
|
|
|
|
test('negative control: an ordinary persisting load DOES rewrite the same fixture', (t) => {
|
|
// Without this the test above cannot fail for the right reason — a fixture
|
|
// that never triggered a normalization would keep its bytes no matter what
|
|
// `persist` did. This proves the fixture is live.
|
|
const dir = createGitRepo({ prefix: 'msd-3648-b1-neg-', defaultBranch: 'main' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
const cfgPath = writeLegacyConfig(dir);
|
|
const before = fs.readFileSync(cfgPath);
|
|
|
|
const configLoader = require('../msd-core/bin/lib/config-loader.cjs');
|
|
const persisted = configLoader.loadConfig(dir);
|
|
|
|
assert.notDeepStrictEqual(
|
|
fs.readFileSync(cfgPath), before,
|
|
'the default (omitted) option must keep migrating legacy configs on disk',
|
|
);
|
|
assert.strictEqual(persisted['base_branch'], 'develop',
|
|
'and it must resolve the same value the non-persisting read resolves');
|
|
});
|
|
|
|
test('negative control: a planted .planning sibling changes the tree snapshot', (t) => {
|
|
const dir = createGitRepo({ prefix: 'msd-3648-b1-stray-', defaultBranch: 'main' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
const before = snapshotPlanningTree(dir);
|
|
|
|
fs.writeFileSync(path.join(dir, '.planning', 'sibling'), 'stray write\n');
|
|
|
|
assert.notDeepStrictEqual(
|
|
snapshotPlanningTree(dir), before,
|
|
'the snapshot must detect a newly planted .planning sibling',
|
|
);
|
|
});
|
|
|
|
test('persist:false changes only the side effect, not the resolved config', (t) => {
|
|
const dir = createGitRepo({ prefix: 'msd-3648-b1-parity-', defaultBranch: 'main' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
const cfgPath = writeLegacyConfig(dir);
|
|
const before = fs.readFileSync(cfgPath);
|
|
|
|
const configLoader = require('../msd-core/bin/lib/config-loader.cjs');
|
|
const quiet = configLoader.loadConfig(dir, { persist: false });
|
|
assert.deepStrictEqual(fs.readFileSync(cfgPath), before,
|
|
'persist:false must not write');
|
|
|
|
const loud = configLoader.loadConfig(dir);
|
|
assert.notDeepStrictEqual(fs.readFileSync(cfgPath), before,
|
|
'the same directory, without the option, must write — proving the two differ');
|
|
assert.deepStrictEqual(quiet, loud,
|
|
'resolution must be identical; only the write is suppressed');
|
|
});
|
|
|
|
test('persist survives the workstream fallback recursion', (t) => {
|
|
// `loadConfigResolved` re-enters ITSELF with `{ workstream: null }` when a
|
|
// workstream was requested but has no config.json of its own. That literal
|
|
// dropped every other option, so the recursive pass ran with the DEFAULT
|
|
// persistence and rewrote the root config — the predicate's `persist:false`
|
|
// was silently discarded for exactly the projects that use workstreams.
|
|
// Found by the round-4 external review.
|
|
const dir = createGitRepo({ prefix: 'msd-3648-b1-ws-', defaultBranch: 'main' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
// The workstream directory exists but carries no config.json — the shape
|
|
// that forces the fallback. Without it the recursion never runs and this
|
|
// test degenerates into the non-workstream case above.
|
|
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
|
|
const cfgPath = writeLegacyConfig(dir);
|
|
const before = fs.readFileSync(cfgPath);
|
|
|
|
const configLoader = require('../msd-core/bin/lib/config-loader.cjs');
|
|
const quiet = configLoader.loadConfig(dir, { persist: false, workstream: 'alpha' });
|
|
|
|
assert.strictEqual(quiet['base_branch'], 'develop',
|
|
'positive control: the fallback really did resolve the root config');
|
|
assert.deepStrictEqual(fs.readFileSync(cfgPath), before,
|
|
'the recursive fallback pass must inherit persist:false');
|
|
|
|
const loud = configLoader.loadConfig(dir, { workstream: 'alpha' });
|
|
assert.notDeepStrictEqual(fs.readFileSync(cfgPath), before,
|
|
'negative control: the same fallback without the option must still write');
|
|
assert.deepStrictEqual(quiet, loud,
|
|
'and suppressing the write must not change what the fallback resolves');
|
|
});
|
|
});
|
|
|
|
describe('#3648 Major 1: readEffectiveGitConfig readFile seam — config present but unusable', () => {
|
|
const CWD = path.join(path.sep, 'msd-3648-seam');
|
|
|
|
/** Drive the seam with raw config text, recording the paths requested. */
|
|
function readWith(raw, seenPaths) {
|
|
return gitBaseBranch.resolveProtectedBranchStatus(CWD, 'some-topic-branch', {
|
|
execGit: makeFaultyGit(),
|
|
readFile: (requested) => { if (seenPaths) seenPaths.push(requested); return raw; },
|
|
});
|
|
}
|
|
|
|
test('config.json exists but is not valid JSON → parse failure swallowed, base falls through', () => {
|
|
const seen = [];
|
|
assert.strictEqual(readWith('{ not json', seen).baseBranch, 'main');
|
|
assert.deepStrictEqual(seen, [path.join(CWD, '.planning', 'config.json')],
|
|
'the seam must look for config.json inside the planning dir under the cwd it was given');
|
|
});
|
|
|
|
test('config.json parses to a non-object → ignored for null / string / number / array', () => {
|
|
assert.strictEqual(readWith('null').baseBranch, 'main', 'JSON null must not be treated as a config');
|
|
assert.strictEqual(readWith('"master"').baseBranch, 'main', 'a bare JSON string must not be treated as a config');
|
|
assert.strictEqual(readWith('42').baseBranch, 'main', 'a bare JSON number must not be treated as a config');
|
|
assert.strictEqual(readWith('[]').baseBranch, 'main', 'a JSON array must not be treated as a config');
|
|
});
|
|
|
|
test('"git" section present but base_branch missing / non-string / blank → no override', () => {
|
|
assert.strictEqual(readWith('{"git":{}}').baseBranch, 'main');
|
|
assert.strictEqual(readWith('{"git":{"base_branch":42}}').baseBranch, 'main');
|
|
assert.strictEqual(readWith('{"git":{"base_branch":null}}').baseBranch, 'main');
|
|
assert.strictEqual(readWith('{"git":{"base_branch":""}}').baseBranch, 'main');
|
|
assert.strictEqual(readWith('{"git":{"base_branch":" "}}').baseBranch, 'main',
|
|
'a whitespace-only override must not win the precedence ladder');
|
|
});
|
|
|
|
test('"git" key present but not a usable object → the flat legacy key is still honoured', () => {
|
|
// These also guard the hoist: a non-object "git" must not be spread into
|
|
// index keys on the way to carrying the flat value across.
|
|
assert.strictEqual(readWith('{"git":"main","base_branch":"release"}').baseBranch, 'release');
|
|
assert.strictEqual(readWith('{"git":[],"base_branch":"release"}').baseBranch, 'release');
|
|
assert.strictEqual(readWith('{"git":null,"base_branch":"release"}').baseBranch, 'release');
|
|
});
|
|
|
|
test('flat base_branch present but non-string / blank → no override', () => {
|
|
assert.strictEqual(readWith('{"base_branch":true}').baseBranch, 'main');
|
|
assert.strictEqual(readWith('{"base_branch":["main"]}').baseBranch, 'main');
|
|
assert.strictEqual(readWith('{"base_branch":""}').baseBranch, 'main');
|
|
assert.strictEqual(readWith('{"base_branch":" "}').baseBranch, 'main');
|
|
});
|
|
|
|
test('config parses cleanly but carries neither key → distinct from an absent file', () => {
|
|
// The absent-file path returns before JSON.parse; these parse a real object
|
|
// and fall all the way through both lookups.
|
|
assert.strictEqual(readWith('{"other":1}').baseBranch, 'main');
|
|
assert.strictEqual(readWith('{}').baseBranch, 'main');
|
|
assert.strictEqual(readWith('').baseBranch, 'main', 'absent file (empty read) also yields no override');
|
|
assert.strictEqual(readWith(null).baseBranch, 'main', 'a null read is an absent file');
|
|
});
|
|
|
|
test('positive controls: values are trimmed and the nested key outranks the flat one', () => {
|
|
// Without these the whole describe could pass against a seam that ignored
|
|
// config entirely — every assertion above expects the fallback value.
|
|
assert.strictEqual(readWith('{"git":{"base_branch":" develop "}}').baseBranch, 'develop');
|
|
assert.strictEqual(readWith('{"base_branch":" release\\n"}').baseBranch, 'release');
|
|
assert.strictEqual(readWith('{"git":{"base_branch":"nested"},"base_branch":"flat"}').baseBranch, 'nested');
|
|
});
|
|
|
|
test('protected_branches is honoured and validated through this seam too', () => {
|
|
const clean = readWith('{"git":{"base_branch":"main","protected_branches":["develop"," next "]}}');
|
|
assert.deepStrictEqual(clean.protectedBranches, ['main', 'develop', 'next']);
|
|
assert.deepStrictEqual(clean.rejectedProtectedBranches, []);
|
|
|
|
const partial = readWith('{"git":{"base_branch":"main","protected_branches":["develop",42]}}');
|
|
assert.deepStrictEqual(partial.protectedBranches, ['main', 'develop'],
|
|
'a bad element must drop only itself on this path as well');
|
|
assert.deepStrictEqual(partial.rejectedProtectedBranches, ['42']);
|
|
|
|
const notAList = readWith('{"git":{"base_branch":"main","protected_branches":"develop"}}');
|
|
assert.deepStrictEqual(notAList.protectedBranches, ['main']);
|
|
assert.deepStrictEqual(notAList.rejectedProtectedBranches, ['"develop"']);
|
|
});
|
|
|
|
test('loadConfig wins when both seams are supplied — readFile is the test-only path', () => {
|
|
const status = gitBaseBranch.resolveProtectedBranchStatus(CWD, 'from-loader', {
|
|
execGit: makeFaultyGit(),
|
|
readFile: () => '{"git":{"base_branch":"from-readfile"}}',
|
|
loadConfig: () => ({ base_branch: 'from-loader' }),
|
|
});
|
|
|
|
assert.strictEqual(status.baseBranch, 'from-loader',
|
|
'production resolution must not be displaced by the low-level seam');
|
|
assert.strictEqual(status.isProtected, true);
|
|
});
|
|
});
|
|
|
|
// ─── #3057 W3: negative-space coverage for the resolver's failure arms ───────
|
|
//
|
|
// Everything below drives the *unhappy* halves of git-base-branch: malformed
|
|
// config, git output that parses but says nothing useful, git that cannot run
|
|
// at all, and a repository with no work tree. Each test asserts the exact value
|
|
// the arm is contracted to produce — never "it did not throw", never a shape
|
|
// check — because an arm that silently returns `undefined` instead of `null`
|
|
// changes the precedence ladder's behaviour while passing any weaker assertion.
|
|
|
|
/**
|
|
* Build a result object shaped exactly like `execGit`'s (see `_spawnResult` in
|
|
* shell-command-projection). Defaults are a benign, completed, zero-exit call.
|
|
*/
|
|
function gitResult(overrides) {
|
|
return {
|
|
exitCode: 0,
|
|
stdout: '',
|
|
stderr: '',
|
|
signal: null,
|
|
error: null,
|
|
timedOut: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** An `execGit` stand-in that always returns the same shaped result. */
|
|
function constGit(overrides) {
|
|
return () => gitResult(overrides);
|
|
}
|
|
|
|
/**
|
|
* An `execGit` stand-in that throws. `makeFaultyGit` deliberately never throws
|
|
* (it returns a shaped failure result), so the resolver's `catch` arms need
|
|
* this instead.
|
|
*/
|
|
function throwingGit(message) {
|
|
return () => { throw new Error(message); };
|
|
}
|
|
|
|
describe('#3057 W3: trySymbolicRef — tier-2 output that resolves to nothing', () => {
|
|
test('stdout is exactly "origin/" → null (prefix strip leaves an empty name)', () => {
|
|
assert.strictEqual(gitBaseBranch.trySymbolicRef('/x', constGit({ stdout: 'origin/' })), null);
|
|
assert.strictEqual(gitBaseBranch.trySymbolicRef('/x', constGit({ stdout: 'origin/\n' })), null);
|
|
});
|
|
|
|
test('only ONE leading "origin/" is stripped — slashes inside the name survive', () => {
|
|
assert.strictEqual(
|
|
gitBaseBranch.trySymbolicRef('/x', constGit({ stdout: 'origin/feature/long-name\n' })),
|
|
'feature/long-name');
|
|
assert.strictEqual(
|
|
gitBaseBranch.trySymbolicRef('/x', constGit({ stdout: 'origin/origin/main\n' })),
|
|
'origin/main');
|
|
});
|
|
|
|
test('execGit THROWS → null (catch arm; makeFaultyGit cannot reach this)', () => {
|
|
assert.strictEqual(gitBaseBranch.trySymbolicRef('/x', throwingGit('symbolic-ref exploded')), null);
|
|
});
|
|
|
|
test('the tier-2 subprocess is bounded (argv + timeout are pinned)', () => {
|
|
const seen = [];
|
|
gitBaseBranch.trySymbolicRef('/some/cwd', (args, opts) => {
|
|
seen.push({ args, opts });
|
|
return gitResult({ stdout: 'origin/main\n' });
|
|
});
|
|
assert.deepStrictEqual(seen, [{
|
|
args: ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'],
|
|
opts: { cwd: '/some/cwd', timeout: TIER2_SYMBOLIC_REF_TIMEOUT_MS },
|
|
}]);
|
|
});
|
|
});
|
|
|
|
describe('#3057 W3: tryRemoteShow — tier-3 output that is present but not authoritative', () => {
|
|
const REMOTE_SHOW_NO_HEAD = [
|
|
'* remote origin',
|
|
' Fetch URL: /tmp/origin.git',
|
|
' Push URL: /tmp/origin.git',
|
|
' Remote branch:',
|
|
' main tracked',
|
|
'',
|
|
].join('\n');
|
|
|
|
test('stdout has no "HEAD branch:" line → null', () => {
|
|
assert.strictEqual(
|
|
gitBaseBranch.tryRemoteShow('/x', constGit({ stdout: REMOTE_SHOW_NO_HEAD })), null);
|
|
});
|
|
|
|
test('"HEAD branch:" with no value on the line → null (no capture, no guess)', () => {
|
|
assert.strictEqual(
|
|
gitBaseBranch.tryRemoteShow('/x', constGit({ stdout: ' HEAD branch: \n' })), null);
|
|
});
|
|
|
|
test('"HEAD branch: (unknown)" → null — the documented offline-remote case', () => {
|
|
// git prints "(unknown)" when it could not reach the remote. Returning it
|
|
// verbatim would hand a literal branch named "(unknown)" to five workflows;
|
|
// returning null lets tier 4 answer instead.
|
|
assert.strictEqual(
|
|
gitBaseBranch.tryRemoteShow('/x', constGit({ stdout: ' HEAD branch: (unknown)\n' })), null);
|
|
});
|
|
|
|
test('execGit THROWS → null (catch arm)', () => {
|
|
assert.strictEqual(gitBaseBranch.tryRemoteShow('/x', throwingGit('remote show exploded')), null);
|
|
});
|
|
|
|
test('positive control: the HEAD branch line is found mid-output and returned verbatim', () => {
|
|
const stdout = [
|
|
'* remote origin',
|
|
' Fetch URL: /tmp/origin.git',
|
|
' HEAD branch: master',
|
|
' Remote branch:',
|
|
' master tracked',
|
|
'',
|
|
].join('\n');
|
|
assert.strictEqual(gitBaseBranch.tryRemoteShow('/x', constGit({ stdout })), 'master');
|
|
});
|
|
|
|
test('the tier-3 subprocess is bounded (argv + timeout are pinned)', () => {
|
|
const seen = [];
|
|
gitBaseBranch.tryRemoteShow('/some/cwd', (args, opts) => {
|
|
seen.push({ args, opts });
|
|
return gitResult({ stdout: ' HEAD branch: main\n' });
|
|
});
|
|
assert.deepStrictEqual(seen, [{
|
|
args: ['remote', 'show', 'origin'],
|
|
opts: { cwd: '/some/cwd', timeout: TIER3_REMOTE_SHOW_TIMEOUT_MS },
|
|
}]);
|
|
});
|
|
});
|
|
|
|
describe('#3057 W3: tryLocalBranch — non-empty stdout that names neither main nor master', () => {
|
|
test('stdout is exactly "\\n" → null, reached PAST the empty-stdout guard', () => {
|
|
// This is the branch that was once deleted as "unreachable". The guard is
|
|
// `if (r.exitCode !== 0 || !r.stdout) return null` — `"\n"` is a truthy
|
|
// string, so the guard does NOT fire; `split('\n')` yields ["", ""], both
|
|
// main/master checks are false, and the FINAL `return null` executes.
|
|
// Deleting that line makes this function return `undefined`, which
|
|
// strictEqual(null) catches.
|
|
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: '\n' })), null);
|
|
});
|
|
|
|
test('stdout is exactly "" → null via the EARLY guard (a different arm)', () => {
|
|
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: '' })), null);
|
|
});
|
|
|
|
// Boundary trio over the number of branch lines `git branch --list main master`
|
|
// can emit: 0 (below the smallest useful listing), 1, and 2 (the maximum this
|
|
// argv can produce).
|
|
test('0 branch lines → null', () => {
|
|
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: '\n' })), null);
|
|
});
|
|
|
|
test('1 branch line → that branch', () => {
|
|
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: ' main\n' })), 'main');
|
|
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: ' master\n' })), 'master');
|
|
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: '* master\n' })), 'master',
|
|
'the checked-out marker "* " must be stripped before matching');
|
|
});
|
|
|
|
test('2 branch lines → "main" wins the tie-break', () => {
|
|
assert.strictEqual(
|
|
gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: ' main\n master\n' })), 'main');
|
|
assert.strictEqual(
|
|
gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: '* master\n main\n' })), 'main');
|
|
});
|
|
|
|
test('execGit THROWS → null (catch arm)', () => {
|
|
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', throwingGit('branch --list exploded')), null);
|
|
});
|
|
|
|
test('the tier-4 subprocess is bounded (argv + timeout are pinned)', () => {
|
|
const seen = [];
|
|
gitBaseBranch.tryLocalBranch('/some/cwd', (args, opts) => {
|
|
seen.push({ args, opts });
|
|
return gitResult({ stdout: ' main\n' });
|
|
});
|
|
assert.deepStrictEqual(seen, [{
|
|
args: ['branch', '--list', 'main', 'master'],
|
|
opts: { cwd: '/some/cwd', timeout: TIER4_LOCAL_BRANCH_TIMEOUT_MS },
|
|
}]);
|
|
});
|
|
});
|
|
|
|
describe('#3057 W3: resolveBaseBranchDiagnostics — which tier actually answered', () => {
|
|
// Every tier can produce a plausible-looking branch name, so asserting the
|
|
// returned string alone cannot tell a tier-2 answer from a tier-3 or tier-4
|
|
// one. Each test below rigs the LOWER tiers to answer with a DIFFERENT branch
|
|
// than the tier under test, so a resolver that consulted them in the wrong
|
|
// order returns the wrong string, and additionally pins the recorded argv so
|
|
// an early return is provably an early return.
|
|
|
|
/** A passthrough answering each tier with a distinct, recognisable branch. */
|
|
function tieredPassthrough({ symref, remote, local }) {
|
|
return (args) => {
|
|
if (args[0] === 'symbolic-ref') {
|
|
return symref === null ? gitResult({ exitCode: 1 }) : gitResult({ stdout: `origin/${symref}\n` });
|
|
}
|
|
if (args[0] === 'remote') {
|
|
return remote === null ? gitResult({ exitCode: 128 }) : gitResult({ stdout: ` HEAD branch: ${remote}\n` });
|
|
}
|
|
if (args[0] === 'branch') {
|
|
return local === null ? gitResult({ stdout: '\n' }) : gitResult({ stdout: ` ${local}\n` });
|
|
}
|
|
return gitResult({});
|
|
};
|
|
}
|
|
|
|
const NO_CONFIG = { readFile: () => null };
|
|
|
|
test('tier 2 answers → tiers 3 and 4 are never consulted', () => {
|
|
const git = makeFaultyGit({
|
|
passthrough: tieredPassthrough({ symref: 'from-symref', remote: 'from-remote', local: 'master' }),
|
|
});
|
|
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', { ...NO_CONFIG, execGit: git });
|
|
assert.deepStrictEqual(result, { branch: 'from-symref', verified: true });
|
|
assert.deepStrictEqual(git.calls.map((c) => c.args[0]), ['symbolic-ref'],
|
|
'a tier-2 hit must stop the ladder before `remote show` and `branch --list`');
|
|
});
|
|
|
|
test('tier 3 answers → tier 4 is never consulted, even though it WOULD answer "master"', () => {
|
|
const git = makeFaultyGit({
|
|
passthrough: tieredPassthrough({ symref: null, remote: 'from-remote', local: 'master' }),
|
|
});
|
|
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', { ...NO_CONFIG, execGit: git });
|
|
assert.deepStrictEqual(result, { branch: 'from-remote', verified: true });
|
|
assert.deepStrictEqual(git.calls.map((c) => c.args[0]), ['symbolic-ref', 'remote'],
|
|
'a tier-3 hit must stop the ladder before `branch --list`');
|
|
});
|
|
|
|
test('tier 4 answers only after tiers 2 and 3 both decline', () => {
|
|
const git = makeFaultyGit({
|
|
passthrough: tieredPassthrough({ symref: null, remote: null, local: 'master' }),
|
|
});
|
|
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', { ...NO_CONFIG, execGit: git });
|
|
assert.deepStrictEqual(result, { branch: 'master', verified: true });
|
|
assert.deepStrictEqual(git.calls.map((c) => c.args[0]), ['symbolic-ref', 'remote', 'branch']);
|
|
});
|
|
|
|
test('a config override answers before ANY git subprocess runs', () => {
|
|
const git = makeFaultyGit({
|
|
passthrough: tieredPassthrough({ symref: 'from-symref', remote: 'from-remote', local: 'master' }),
|
|
});
|
|
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', {
|
|
readFile: () => '{"git":{"base_branch":"from-config"}}',
|
|
execGit: git,
|
|
});
|
|
assert.deepStrictEqual(result, { branch: 'from-config', verified: true });
|
|
assert.deepStrictEqual(git.calls, [], 'tier 1 must not spawn git at all');
|
|
});
|
|
|
|
test('git cannot be SPAWNED at all (exit 127 + error) → "main", verified:false', () => {
|
|
// Distinct from the timeout case already covered by #3057 B4: here every
|
|
// call returns exitCode 127 with `error` set and `timedOut:false`, which is
|
|
// the `r.error` disjunct of the failure detector rather than `r.timedOut`.
|
|
const git = makeFaultyGit({ faults: [{ kind: 'spawnFail' }] });
|
|
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', { ...NO_CONFIG, execGit: git });
|
|
assert.deepStrictEqual(result, { branch: 'main', verified: false });
|
|
assert.deepStrictEqual(git.calls.map((c) => c.args[0]), ['symbolic-ref', 'remote', 'branch'],
|
|
'all three tiers must still be attempted before the unverified default');
|
|
});
|
|
|
|
test('a spawn failure on ONE tier alone is enough to mark the default unverified', () => {
|
|
// Tiers 2 and 3 complete cleanly with "no answer"; only tier 4 fails to run.
|
|
const git = makeFaultyGit({
|
|
faults: [{ kind: 'spawnFail', when: ['branch', '--list'] }],
|
|
passthrough: tieredPassthrough({ symref: null, remote: null, local: null }),
|
|
});
|
|
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', { ...NO_CONFIG, execGit: git });
|
|
assert.deepStrictEqual(result, { branch: 'main', verified: false });
|
|
});
|
|
|
|
test('tier-4 stdout of "\\n" (no branches) is a VERIFIED "no candidate", not a failure', () => {
|
|
// The counterpart to the tryLocalBranch "\n" test, one level up: git ran,
|
|
// answered, and the answer was "neither branch exists". That must still be
|
|
// verified:true — collapsing it into verified:false would re-fail-open the
|
|
// exact distinction #3057 B4 introduced.
|
|
const git = makeFaultyGit({
|
|
passthrough: tieredPassthrough({ symref: null, remote: null, local: null }),
|
|
});
|
|
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', { ...NO_CONFIG, execGit: git });
|
|
assert.deepStrictEqual(result, { branch: 'main', verified: true });
|
|
});
|
|
});
|
|
|
|
describe('#3057 W3: gitWorktreeInfoInternal — no work tree, and git failing mid-sequence', () => {
|
|
test('a REAL bare repository reports {inside:false, worktreeRoot:null}', (t) => {
|
|
// `git rev-parse --is-inside-work-tree` exits 0 in a bare repo and prints
|
|
// "false" — the exitCode guard does NOT fire, so this is the stdout check,
|
|
// and it is reachable without any injection.
|
|
const dir = createTempDir('msd-3057-w3-bare-');
|
|
t.after(() => cleanup(dir));
|
|
gitOrThrow(['init', '--bare'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
|
|
|
|
assert.deepStrictEqual(
|
|
gitBaseBranch.gitWorktreeInfoInternal(dir),
|
|
{ inside: false, worktreeRoot: null });
|
|
});
|
|
|
|
test('is-inside-work-tree prints "false" with exit 0 → no second git call is made', () => {
|
|
const git = makeFaultyGit({ passthrough: () => gitResult({ stdout: 'false\n' }) });
|
|
assert.deepStrictEqual(
|
|
gitBaseBranch.gitWorktreeInfoInternal('/x', { execGit: git }),
|
|
{ inside: false, worktreeRoot: null });
|
|
assert.deepStrictEqual(git.calls.map((c) => c.args), [['rev-parse', '--is-inside-work-tree']],
|
|
'--show-toplevel must not be queried once we know there is no work tree');
|
|
});
|
|
|
|
test('inside a work tree but --show-toplevel FAILS → {inside:true, worktreeRoot:null}', () => {
|
|
// inside is still reported truthfully; only the root is unknown. Reporting
|
|
// inside:false here would be a lie about a repository we just confirmed.
|
|
const git = makeFaultyGit({
|
|
faults: [{
|
|
kind: 'exit',
|
|
exitCode: 128,
|
|
stderr: 'fatal: no work tree',
|
|
when: ['rev-parse', '--show-toplevel'],
|
|
}],
|
|
passthrough: () => gitResult({ stdout: 'true\n' }),
|
|
});
|
|
assert.deepStrictEqual(
|
|
gitBaseBranch.gitWorktreeInfoInternal('/x', { execGit: git }),
|
|
{ inside: true, worktreeRoot: null });
|
|
assert.deepStrictEqual(git.calls.map((c) => c.args[1]),
|
|
['--is-inside-work-tree', '--show-toplevel']);
|
|
});
|
|
|
|
test('--show-toplevel succeeds with blank stdout → {inside:true, worktreeRoot:null}', () => {
|
|
const git = makeFaultyGit({
|
|
passthrough: (args) => gitResult({ stdout: args[1] === '--show-toplevel' ? ' \n' : 'true\n' }),
|
|
});
|
|
assert.deepStrictEqual(
|
|
gitBaseBranch.gitWorktreeInfoInternal('/x', { execGit: git }),
|
|
{ inside: true, worktreeRoot: null });
|
|
});
|
|
|
|
test('--show-toplevel succeeds → the trimmed path is returned', () => {
|
|
const git = makeFaultyGit({
|
|
passthrough: (args) => gitResult({ stdout: args[1] === '--show-toplevel' ? ' /repo/root \n' : 'true\n' }),
|
|
});
|
|
assert.deepStrictEqual(
|
|
gitBaseBranch.gitWorktreeInfoInternal('/x', { execGit: git }),
|
|
{ inside: true, worktreeRoot: '/repo/root' });
|
|
});
|
|
|
|
test('execGit THROWS → {inside:false, worktreeRoot:null} (catch arm)', () => {
|
|
assert.deepStrictEqual(
|
|
gitBaseBranch.gitWorktreeInfoInternal('/x', { execGit: throwingGit('git is gone') }),
|
|
{ inside: false, worktreeRoot: null });
|
|
});
|
|
|
|
test('both worktree probes are bounded and receive the caller cwd', () => {
|
|
const git = makeFaultyGit({
|
|
passthrough: (args) => gitResult({ stdout: args[1] === '--show-toplevel' ? '/repo/root\n' : 'true\n' }),
|
|
});
|
|
gitBaseBranch.gitWorktreeInfoInternal('/some/cwd', { execGit: git });
|
|
assert.deepStrictEqual(git.calls, [
|
|
{ args: ['rev-parse', '--is-inside-work-tree'], opts: { cwd: '/some/cwd', timeout: WORKTREE_INFO_TIMEOUT_MS } },
|
|
{ args: ['rev-parse', '--show-toplevel'], opts: { cwd: '/some/cwd', timeout: WORKTREE_INFO_TIMEOUT_MS } },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('#3057 W3: cmdGitBaseBranch — the DEFAULT diagnostic sink', () => {
|
|
test('with no writeDiagnostic injected, the unverified warning goes to process.stderr', (t) => {
|
|
const written = [];
|
|
t.mock.method(process.stderr, 'write', (chunk) => { written.push(String(chunk)); return true; });
|
|
|
|
const stdout = [];
|
|
const branch = gitBaseBranch.cmdGitBaseBranch('/x', [], {
|
|
readFile: () => null,
|
|
execGit: makeFaultyGit({ faults: [{ kind: 'timeout' }] }),
|
|
write: (s) => { stdout.push(s); },
|
|
// writeDiagnostic deliberately omitted → the process.stderr default arm.
|
|
});
|
|
|
|
assert.strictEqual(branch, 'main');
|
|
assert.deepStrictEqual(stdout, ['main\n'], 'the stdout contract five workflows parse is unchanged');
|
|
assert.strictEqual(written.length, 1, 'exactly one diagnostic must reach the default stderr sink');
|
|
assert.match(written[0], /WITHOUT verifying/);
|
|
});
|
|
|
|
test('with no writeDiagnostic injected and a VERIFIED answer, process.stderr is untouched', (t) => {
|
|
const written = [];
|
|
t.mock.method(process.stderr, 'write', (chunk) => { written.push(String(chunk)); return true; });
|
|
|
|
const stdout = [];
|
|
const branch = gitBaseBranch.cmdGitBaseBranch('/x', [], {
|
|
readFile: () => '{"git":{"base_branch":"develop"}}',
|
|
execGit: makeFaultyGit(),
|
|
write: (s) => { stdout.push(s); },
|
|
});
|
|
|
|
assert.strictEqual(branch, 'develop');
|
|
assert.deepStrictEqual(stdout, ['develop\n']);
|
|
assert.deepStrictEqual(written, [], 'a verified answer must write nothing to the default stderr sink');
|
|
});
|
|
});
|
|
|
|
// ─── setMsdConfig prototype-pollution guard (#1406) ───────────────────────────
|
|
|
|
describe('#1406: setMsdConfig prototype-pollution guard', () => {
|
|
test('rejects __proto__ as a key segment', (t) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-1406-'));
|
|
t.after(() => cleanup(dir));
|
|
assert.throws(() => setMsdConfig(dir, '__proto__', 'x'), /unsafe config key segment/);
|
|
assert.throws(() => setMsdConfig(dir, '__proto__.polluted', true), /unsafe config key segment/);
|
|
});
|
|
|
|
test('rejects constructor / prototype chain segments', (t) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-1406-'));
|
|
t.after(() => cleanup(dir));
|
|
assert.throws(() => setMsdConfig(dir, 'constructor.prototype.polluted', true), /unsafe config key segment/);
|
|
assert.throws(() => setMsdConfig(dir, 'safe.__proto__', true), /unsafe config key segment/);
|
|
assert.throws(() => setMsdConfig(dir, 'a.prototype.b', true), /unsafe config key segment/);
|
|
});
|
|
|
|
test('does not pollute Object.prototype after rejected attempts', (t) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-1406-'));
|
|
t.after(() => cleanup(dir));
|
|
try { setMsdConfig(dir, '__proto__.polluted', true); } catch (_) { /* expected */ }
|
|
try { setMsdConfig(dir, 'constructor.prototype.polluted', true); } catch (_) { /* expected */ }
|
|
try { setMsdConfig(dir, 'a.__proto__.polluted', true); } catch (_) { /* expected */ }
|
|
assert.strictEqual(({}).polluted, undefined);
|
|
assert.strictEqual(Object.prototype.polluted, undefined);
|
|
});
|
|
|
|
test('still writes a normal nested key', (t) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-1406-'));
|
|
t.after(() => cleanup(dir));
|
|
setMsdConfig(dir, 'git.base_branch', 'develop');
|
|
const cfgPath = path.join(dir, '.planning', 'config.json');
|
|
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
assert.strictEqual(cfg.git.base_branch, 'develop');
|
|
});
|
|
});
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-2004-pr-branch-milestone.test.cjs — consolidation epic #1969 (B4 #1973)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-2004-pr-branch-milestone (consolidation epic #1969 B4 #1973)", () => {
|
|
/**
|
|
* Regression tests for bug #2004
|
|
*
|
|
* /msd-pr-branch must not exclude milestone archive and structural planning
|
|
* commits. The previous implementation filtered ALL .planning/-only commits,
|
|
* including STATE.md, ROADMAP.md, MILESTONES.md, and milestones/** updates
|
|
* that are needed to preserve repository planning state after a merge.
|
|
*
|
|
* Fixed: pr-branch.md now distinguishes:
|
|
* - Transient planning commits (phase plans, summaries, research, context) → EXCLUDE
|
|
* - Structural planning commits (STATE.md, ROADMAP.md, MILESTONES.md,
|
|
* PROJECT.md, milestones/**) → INCLUDE
|
|
* - Code commits (any non-.planning/ file) → INCLUDE
|
|
* - Mixed commits (code + planning) → INCLUDE
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const workflowPath = path.resolve(
|
|
__dirname, '..', 'msd-core', 'workflows', 'pr-branch.md'
|
|
);
|
|
|
|
describe('bug #2004: pr-branch preserves structural planning commits', () => {
|
|
let content;
|
|
|
|
test('setup: pr-branch workflow is readable', () => {
|
|
content = fs.readFileSync(workflowPath, 'utf-8');
|
|
assert.ok(content.length > 0, 'pr-branch.md must not be empty');
|
|
});
|
|
|
|
test('workflow distinguishes structural vs transient planning commits', () => {
|
|
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
|
// Must contain language distinguishing structural from transient/phase planning files
|
|
assert.ok(
|
|
/structural|milestone.*archive|STATE\.md.*INCLUDE|preserve.*milestone|milestone.*preserve/i.test(content),
|
|
'pr-branch.md must distinguish structural planning commits from transient ones'
|
|
);
|
|
});
|
|
|
|
test('workflow lists STATE.md and ROADMAP.md as structural files to preserve', () => {
|
|
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
|
assert.ok(
|
|
content.includes('STATE.md'),
|
|
'pr-branch.md must reference STATE.md as a structural file to preserve'
|
|
);
|
|
assert.ok(
|
|
content.includes('ROADMAP.md'),
|
|
'pr-branch.md must reference ROADMAP.md as a structural file to preserve'
|
|
);
|
|
});
|
|
|
|
test('workflow lists MILESTONES.md or milestones/ as structural files to preserve', () => {
|
|
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
|
assert.ok(
|
|
content.includes('MILESTONES.md') || content.includes('milestones/'),
|
|
'pr-branch.md must reference MILESTONES.md or milestones/ as structural files to preserve'
|
|
);
|
|
});
|
|
|
|
test('workflow has four commit categories (code, planning-only, mixed, structural)', () => {
|
|
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
|
// Must have at least a "structural" or "milestone" category beyond the original three
|
|
assert.ok(
|
|
/structural.*commit|milestone.*commit|commit.*structural|commit.*milestone/i.test(content) ||
|
|
/INCLUDE.*STATE\.md|STATE\.md.*INCLUDE/i.test(content),
|
|
'pr-branch.md must classify structural planning commits as INCLUDE'
|
|
);
|
|
});
|
|
|
|
test('create_pr_branch step does not rm -r --cached all of .planning/', () => {
|
|
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
|
// The original bug: `git rm -r --cached .planning/` nuked structural files.
|
|
// The fix must either remove this wholesale rm or scope it to transient dirs.
|
|
// Acceptable: narrowed rm targeting only phase/, quick/, research/, etc.
|
|
// Not acceptable: `git rm -r --cached .planning/` with no scoping.
|
|
const hasUnscoped = /git rm -r --cached \.planning\/(?!\*)?(?!phases|quick|research|threads|todos|debug|seeds|ui-reviews|codebase)/
|
|
.test(content);
|
|
assert.ok(
|
|
!hasUnscoped,
|
|
'create_pr_branch must not use unscoped "git rm -r --cached .planning/" — scope to transient subdirectories only'
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-2916-handle-branching-default-base.test.cjs — consolidation epic #1969 (B6 #1975)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-2916-handle-branching-default-base (consolidation epic #1969 B6 #1975)", () => {
|
|
/**
|
|
* Regression test for #2916: execute-phase `handle_branching` step creates the
|
|
* per-phase branch off whatever HEAD is currently checked out (typically the
|
|
* previous phase's unmerged branch) instead of off `origin/HEAD`.
|
|
*
|
|
* The bug compounded phases on top of each other and stranded them unpushed
|
|
* for weeks. The fix:
|
|
* 1. Detect the default branch via `git symbolic-ref refs/remotes/origin/HEAD`.
|
|
* 2. If $BRANCH_NAME exists, switch to it (preserve existing behavior).
|
|
* 3. Otherwise, ff-update the default branch from origin and create the new
|
|
* phase branch off the default-branch tip.
|
|
* 4. Refuse-or-warn on dirty working tree.
|
|
* 5. Post-creation, assert `git rev-list --count $DEFAULT_BRANCH..HEAD == 0`.
|
|
*
|
|
* This test extracts the bash payload from the <step name="handle_branching">
|
|
* block in execute-phase.md (parsed structurally — no regex on prose), executes
|
|
* it inside a fixture git repo where HEAD sits on a previous-phase branch with
|
|
* extra commits, and asserts that the new phase branch's tip equals
|
|
* `origin/main` (no commits inherited from the previous phase).
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const EXECUTE_PHASE_PATH = path.join(
|
|
__dirname,
|
|
'..',
|
|
'msd-core',
|
|
'workflows',
|
|
'execute-phase.md'
|
|
);
|
|
|
|
const GIT_ENV = Object.freeze({
|
|
...process.env,
|
|
GIT_AUTHOR_NAME: 'Test',
|
|
GIT_AUTHOR_EMAIL: 'test@test.com',
|
|
GIT_COMMITTER_NAME: 'Test',
|
|
GIT_COMMITTER_EMAIL: 'test@test.com',
|
|
});
|
|
|
|
function git(cwd, ...args) {
|
|
return gitOrThrow(args, { cwd, env: GIT_ENV, timeoutMs: GIT_TIMEOUT_MS }).trim();
|
|
}
|
|
|
|
/**
|
|
* Structurally extract the bash code that the handle_branching step instructs
|
|
* the agent to run. We:
|
|
* 1. Locate the <step name="handle_branching"> ... </step> block.
|
|
* 2. Walk its body looking for fenced ```bash blocks.
|
|
* 3. Concatenate every bash block in the step (the fix may use more than one).
|
|
*
|
|
* No `.includes()` content checks — we parse fence-delimited code blocks the
|
|
* same way a markdown parser would.
|
|
*/
|
|
function extractHandleBranchingBash() {
|
|
const content = readFileNormalized(EXECUTE_PHASE_PATH);
|
|
const lines = content.split('\n');
|
|
|
|
let start = -1;
|
|
let end = -1;
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
if (start === -1 && /^<step\s+name="handle_branching">\s*$/.test(lines[i])) {
|
|
start = i + 1;
|
|
} else if (start !== -1 && /^<\/step>\s*$/.test(lines[i])) {
|
|
end = i;
|
|
break;
|
|
}
|
|
}
|
|
if (start === -1 || end === -1) {
|
|
throw new Error(
|
|
'execute-phase.md does not contain a <step name="handle_branching"> ... </step> block'
|
|
);
|
|
}
|
|
|
|
const bashBlocks = [];
|
|
let inBash = false;
|
|
let buffer = [];
|
|
for (let i = start; i < end; i += 1) {
|
|
const line = lines[i];
|
|
if (!inBash && /^```bash\s*$/.test(line)) {
|
|
inBash = true;
|
|
buffer = [];
|
|
continue;
|
|
}
|
|
if (inBash && /^```\s*$/.test(line)) {
|
|
bashBlocks.push(buffer.join('\n'));
|
|
inBash = false;
|
|
continue;
|
|
}
|
|
if (inBash) buffer.push(line);
|
|
}
|
|
if (bashBlocks.length === 0) {
|
|
throw new Error(
|
|
'handle_branching step contains no ```bash code blocks to execute'
|
|
);
|
|
}
|
|
return bashBlocks.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Build a fixture: a bare "origin" repo with the named default branch (one
|
|
* commit), a clone with `origin/HEAD` pointed at it, and a checked-out
|
|
* previous-phase branch carrying its own unmerged commit.
|
|
*
|
|
* `defaultBranch` is parameterized so callers can lock in that the workflow
|
|
* honors `git symbolic-ref refs/remotes/origin/HEAD` rather than silently
|
|
* defaulting to `main` (#2921 CR feedback — quick-branching.test.cjs got the
|
|
* same treatment in 80f14cac; this test deserves the same coverage).
|
|
*/
|
|
function setupFixture(defaultBranch = 'main') {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-2916-'));
|
|
const seedPath = path.join(root, 'seed');
|
|
const originPath = path.join(root, 'origin.git');
|
|
const clonePath = path.join(root, 'clone');
|
|
|
|
fs.mkdirSync(seedPath);
|
|
git(seedPath, 'init', '-b', defaultBranch);
|
|
git(seedPath, 'config', 'commit.gpgsign', 'false');
|
|
fs.writeFileSync(path.join(seedPath, 'README.md'), '# seed\n');
|
|
git(seedPath, 'add', 'README.md');
|
|
git(seedPath, 'commit', '-m', 'initial');
|
|
|
|
git(root, 'clone', '--bare', seedPath, originPath);
|
|
git(originPath, 'symbolic-ref', 'HEAD', `refs/heads/${defaultBranch}`);
|
|
|
|
git(root, 'clone', originPath, clonePath);
|
|
git(clonePath, 'config', 'commit.gpgsign', 'false');
|
|
git(clonePath, 'config', 'user.email', 'test@test.com');
|
|
git(clonePath, 'config', 'user.name', 'Test');
|
|
|
|
// Simulate finishing a previous phase: branch off the default branch, add
|
|
// a commit, and *stay* on it (the failure scenario described in the bug).
|
|
git(clonePath, 'checkout', '-b', 'feature/phase-01-foundation');
|
|
fs.writeFileSync(path.join(clonePath, 'phase01.txt'), 'phase 1 work\n');
|
|
git(clonePath, 'add', 'phase01.txt');
|
|
git(clonePath, 'commit', '-m', 'phase 01 work');
|
|
|
|
return { root, clonePath, defaultBranch };
|
|
}
|
|
|
|
function runHandleBranchingStep(bash, cwd, branchName) {
|
|
// Write the script to a sibling tempdir, not inside the repo — putting it in
|
|
// `cwd` would create an untracked file that trips `git status --porcelain`
|
|
// and steers the step into its dirty-tree fallback path.
|
|
const scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-2916-step-'));
|
|
const scriptPath = path.join(scriptDir, 'handle-branching.sh');
|
|
const script = `#!/usr/bin/env bash\nset -uo pipefail\nBRANCH_NAME="${branchName}"\n${bash}\n`;
|
|
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
|
|
try {
|
|
// Bash FAN-OUT (a sequence of git commands under one `bash` interpreter),
|
|
// not a single git plumbing call — the wrong class for `GIT_TIMEOUT_MS`.
|
|
// Same class as the observed CI failures in tests/quick-branching.test.cjs
|
|
// (PR #3787 run 32668773524) and tests/worktree-safety.test.cjs (`next`
|
|
// run 32608945654). See HOOK_FANOUT_TIMEOUT_MS in ./helpers/timeouts.cjs
|
|
// for the class rationale.
|
|
const r = runHook(scriptPath, [], { interpreter: 'bash', cwd, env: GIT_ENV, timeoutMs: HOOK_FANOUT_TIMEOUT_MS });
|
|
throwIfFailed(r, `runHandleBranchingStep: bash ${scriptPath}`);
|
|
return r.stdout;
|
|
} finally {
|
|
cleanup(scriptDir);
|
|
}
|
|
}
|
|
|
|
describe('handle_branching branches off origin/HEAD, not current HEAD (#2916)', () => {
|
|
// Run against `main` (conventional default) and `trunk` (non-main default
|
|
// exercising the symbolic-ref code path) so a regression that hard-codes
|
|
// `main` instead of consulting origin/HEAD will fail the trunk variant.
|
|
for (const defaultBranch of ['main', 'trunk']) {
|
|
test(`new phase branch branches off origin/${defaultBranch} with 0 inherited commits`, (t) => {
|
|
const bash = extractHandleBranchingBash();
|
|
const { root, clonePath } = setupFixture(defaultBranch);
|
|
// Teardown via t.after, not try/finally — CONTRIBUTING.md "Setup and
|
|
// Cleanup" reserves try/finally for context-free helper functions.
|
|
t.after(() => cleanup(root));
|
|
|
|
const upstream = `origin/${defaultBranch}`;
|
|
|
|
assert.equal(
|
|
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
|
'feature/phase-01-foundation'
|
|
);
|
|
assert.equal(
|
|
git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`),
|
|
'1',
|
|
`fixture should be 1 commit ahead of ${upstream}`
|
|
);
|
|
|
|
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
|
|
|
|
assert.equal(
|
|
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
|
'feature/phase-02-content-sync',
|
|
'handle_branching should switch to the new phase branch'
|
|
);
|
|
|
|
const inherited = git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`);
|
|
assert.equal(
|
|
inherited,
|
|
'0',
|
|
`new phase branch must branch off ${upstream}, but inherited ${inherited} commit(s) from previous-phase HEAD`
|
|
);
|
|
assert.equal(
|
|
git(clonePath, 'rev-parse', 'HEAD'),
|
|
git(clonePath, 'rev-parse', upstream),
|
|
`new phase branch tip must equal ${upstream} tip`
|
|
);
|
|
});
|
|
}
|
|
|
|
test('handle_branching reuses an existing branch instead of forking again', (t) => {
|
|
const bash = extractHandleBranchingBash();
|
|
const { root, clonePath } = setupFixture();
|
|
t.after(() => cleanup(root));
|
|
|
|
// Pre-create the target branch off origin/main with its own commit, then
|
|
// walk away to a different branch — the step must switch back to it.
|
|
git(clonePath, 'checkout', '-B', 'feature/phase-02-content-sync', 'origin/main');
|
|
fs.writeFileSync(path.join(clonePath, 'phase02.txt'), 'phase 2 work\n');
|
|
git(clonePath, 'add', 'phase02.txt');
|
|
git(clonePath, 'commit', '-m', 'phase 02 wip');
|
|
const phase02Sha = git(clonePath, 'rev-parse', 'HEAD');
|
|
git(clonePath, 'checkout', 'feature/phase-01-foundation');
|
|
|
|
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
|
|
|
|
assert.equal(
|
|
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
|
'feature/phase-02-content-sync'
|
|
);
|
|
assert.equal(
|
|
git(clonePath, 'rev-parse', 'HEAD'),
|
|
phase02Sha,
|
|
'existing-branch tip must be preserved (no rebase/reset)'
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ─── #3679 — pr-branch: pre-existing planning content + verify deletion gate ──
|
|
//
|
|
// The original deletion class (blanket `git rm -r --cached` of transient dirs
|
|
// stripping pre-existing base-branch files) was fixed as a side effect of the
|
|
// #2971 strict-mode rewrite (rm -f --ignore-unmatch + `git checkout HEAD --`
|
|
// restore). These rows PIN that guarantee behaviorally so it cannot silently
|
|
// regress, and add the remaining piece from the #3679 brief: the verify step
|
|
// must FAIL when the PR-branch diff deletes planning files the target tracks
|
|
// (a deleted allowed/structural path verifies clean today — name-only
|
|
// counting cannot see status).
|
|
|
|
describe('#3679 — pr-branch pre-existing planning content + verify deletion gate', () => {
|
|
const PR_BRANCH_MD = path.join(WORKFLOW_DIR, 'pr-branch.md');
|
|
|
|
function extractStepBash(stepName) {
|
|
const content = readFileNormalized(PR_BRANCH_MD);
|
|
const lines = content.split('\n');
|
|
let start = -1;
|
|
let end = -1;
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
if (start === -1 && new RegExp(`^<step\\s+name="${stepName}">\\s*$`).test(lines[i])) {
|
|
start = i + 1;
|
|
} else if (start !== -1 && /^<\/step>\s*$/.test(lines[i])) {
|
|
end = i;
|
|
break;
|
|
}
|
|
}
|
|
assert.ok(start !== -1 && end !== -1, `pr-branch.md must contain the ${stepName} step`);
|
|
const bashBlocks = [];
|
|
let inBash = false;
|
|
let buffer = [];
|
|
for (let i = start; i < end; i += 1) {
|
|
const line = lines[i];
|
|
if (!inBash && /^```bash\s*$/.test(line)) {
|
|
inBash = true;
|
|
buffer = [];
|
|
continue;
|
|
}
|
|
if (inBash && /^```\s*$/.test(line)) {
|
|
bashBlocks.push(buffer.join('\n'));
|
|
inBash = false;
|
|
continue;
|
|
}
|
|
if (inBash) buffer.push(line);
|
|
}
|
|
assert.ok(bashBlocks.length > 0, `${stepName} step contains bash blocks`);
|
|
return bashBlocks.join('\n');
|
|
}
|
|
|
|
test('verify step gates on planning-tree deletions', () => {
|
|
const bash = extractStepBash('verify');
|
|
assert.ok(
|
|
/diff-filter=D|--name-status/.test(bash),
|
|
'verify must distinguish deletions (diff-filter=D or --name-status)',
|
|
);
|
|
assert.ok(
|
|
/PLANNING_DELETIONS/.test(bash),
|
|
'verify must compute a planning-deletions count',
|
|
);
|
|
// The must-be-0 gate lives in the step PROSE and the display template,
|
|
// outside every ```bash block — assert it against the full file text so
|
|
// the enforcement half of criterion 4 is pinned, not just the computation.
|
|
const fullText = readFileNormalized(PR_BRANCH_MD);
|
|
assert.ok(
|
|
/PLANNING_DELETIONS[^\n]*must be .?0/.test(fullText),
|
|
'verify prose must gate on a zero PLANNING_DELETIONS count',
|
|
);
|
|
assert.ok(
|
|
/Planning deletions: \{PLANNING_DELETIONS\}/.test(fullText),
|
|
'verify display must surface the deletion count',
|
|
);
|
|
});
|
|
|
|
test('verify fails when the PR diff deletes target-tracked planning files', (t) => {
|
|
const repo = createTempDir('msd-3679-verify-fail-');
|
|
t.after(() => cleanup(repo));
|
|
const g = (args) => gitOrThrow(args, { cwd: repo });
|
|
g(['init', '-q', '-b', 'main']);
|
|
g(['config', 'user.email', 't@t']);
|
|
g(['config', 'user.name', 't']);
|
|
fs.mkdirSync(path.join(repo, '.planning'), { recursive: true });
|
|
fs.writeFileSync(path.join(repo, '.planning', 'STATE.md'), 'state\n');
|
|
fs.writeFileSync(path.join(repo, 'code.sh'), 'code\n');
|
|
g(['add', '-A']);
|
|
g(['commit', '-qm', 'base']);
|
|
g(['checkout', '-qb', 'pr']);
|
|
fs.writeFileSync(path.join(repo, 'code.sh'), 'code2\n');
|
|
// The failure class under test: the PR branch deletes a planning file the
|
|
// target tracks (here structural — an allowed category, so the existing
|
|
// name-only forbidden count cannot catch it).
|
|
g(['rm', '-q', '.planning/STATE.md']);
|
|
g(['add', '-A']);
|
|
g(['commit', '-qm', 'changes + planning deletion']);
|
|
|
|
const verifyBash = extractStepBash('verify');
|
|
const script = [
|
|
'set -u',
|
|
'TARGET=main',
|
|
'PR_BRANCH=pr',
|
|
'FORBIDDEN_RE="^\\.planning/(phases|quick|research|threads|todos|debug|seeds|codebase|ui-reviews)/"',
|
|
'STRUCTURAL_RE="^\\.planning/(STATE|ROADMAP|MILESTONES|PROJECT|REQUIREMENTS)\\.md$|^\\.planning/milestones/"',
|
|
verifyBash,
|
|
'echo "GATE_FORBIDDEN=$FORBIDDEN"',
|
|
'echo "GATE_PLANNING_DELETIONS=${PLANNING_DELETIONS:-unset}"',
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(repo, 'verify.sh'), script + '\n');
|
|
const r = runHook(path.join(repo, 'verify.sh'), [], {
|
|
interpreter: 'bash',
|
|
cwd: repo,
|
|
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
|
|
});
|
|
const out = r.stdout + r.stderr;
|
|
assert.match(out, /GATE_PLANNING_DELETIONS=1/, `deletion count must be non-zero: ${out.slice(0, 400)}`);
|
|
});
|
|
|
|
test('verify passes a clean diff with zero deletions', (t) => {
|
|
const repo = createTempDir('msd-3679-verify-clean-');
|
|
t.after(() => cleanup(repo));
|
|
const g = (args) => gitOrThrow(args, { cwd: repo });
|
|
g(['init', '-q', '-b', 'main']);
|
|
g(['config', 'user.email', 't@t']);
|
|
g(['config', 'user.name', 't']);
|
|
fs.mkdirSync(path.join(repo, '.planning'), { recursive: true });
|
|
fs.writeFileSync(path.join(repo, '.planning', 'STATE.md'), 'state\n');
|
|
fs.writeFileSync(path.join(repo, 'code.sh'), 'code\n');
|
|
g(['add', '-A']);
|
|
g(['commit', '-qm', 'base']);
|
|
g(['checkout', '-qb', 'pr']);
|
|
fs.writeFileSync(path.join(repo, 'code.sh'), 'code2\n');
|
|
g(['add', '-A']);
|
|
g(['commit', '-qm', 'code only']);
|
|
|
|
const verifyBash = extractStepBash('verify');
|
|
const script = [
|
|
'set -u',
|
|
'TARGET=main',
|
|
'PR_BRANCH=pr',
|
|
'FORBIDDEN_RE="^\\.planning/(phases|quick|research|threads|todos|debug|seeds|codebase|ui-reviews)/"',
|
|
'STRUCTURAL_RE="^\\.planning/(STATE|ROADMAP|MILESTONES|PROJECT|REQUIREMENTS)\\.md$|^\\.planning/milestones/"',
|
|
verifyBash,
|
|
'echo "GATE_FORBIDDEN=$FORBIDDEN"',
|
|
'echo "GATE_PLANNING_DELETIONS=${PLANNING_DELETIONS:-unset}"',
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(repo, 'verify.sh'), script + '\n');
|
|
const r = runHook(path.join(repo, 'verify.sh'), [], {
|
|
interpreter: 'bash',
|
|
cwd: repo,
|
|
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
|
|
});
|
|
const out = r.stdout + r.stderr;
|
|
assert.match(out, /GATE_PLANNING_DELETIONS=0/, `clean diff must count zero deletions: ${out.slice(0, 400)}`);
|
|
});
|
|
|
|
test('create loop preserves pre-existing transient content (#3720 guarantee pinned)', (t) => {
|
|
const repo = createTempDir('msd-3679-create-mixed-');
|
|
t.after(() => cleanup(repo));
|
|
const g = (args) => gitOrThrow(args, { cwd: repo });
|
|
g(['init', '-q', '-b', 'main']);
|
|
g(['config', 'user.email', 't@t']);
|
|
g(['config', 'user.name', 't']);
|
|
fs.mkdirSync(path.join(repo, '.planning', 'phases'), { recursive: true });
|
|
fs.mkdirSync(path.join(repo, '.planning', 'research'), { recursive: true });
|
|
fs.mkdirSync(path.join(repo, 'infra'), { recursive: true });
|
|
fs.writeFileSync(path.join(repo, '.planning', 'phases', '1.0-PLAN.md'), 'plan\n');
|
|
fs.writeFileSync(path.join(repo, '.planning', 'research', 'context.md'), 'ctx\n');
|
|
// Structural planning state on the TARGET (criterion 3): must survive the
|
|
// create loop byte-identical alongside the transient content.
|
|
fs.writeFileSync(path.join(repo, '.planning', 'STATE.md'), 'state\n');
|
|
fs.writeFileSync(path.join(repo, '.planning', 'ROADMAP.md'), 'roadmap\n');
|
|
fs.writeFileSync(path.join(repo, 'infra', 'script.sh'), 'code\n');
|
|
g(['add', '-A']);
|
|
g(['commit', '-qm', 'base: code + pre-existing planning']);
|
|
g(['checkout', '-qb', 'feature']);
|
|
fs.writeFileSync(path.join(repo, 'infra', 'script2.sh'), 'more\n');
|
|
fs.writeFileSync(path.join(repo, '.planning', 'phases', '2.0-SUMMARY.md'), 'summary\n');
|
|
g(['add', '-A']);
|
|
g(['commit', '-qm', 'mixed: code + new transient']);
|
|
const hash = g(['rev-parse', 'HEAD']).trim();
|
|
|
|
// Execute the shipped create_pr_branch loop verbatim (default-mode paths).
|
|
const createBash = extractStepBash('create_pr_branch');
|
|
const script = [
|
|
'set -u',
|
|
`CURRENT_BRANCH=feature`,
|
|
'TARGET=main',
|
|
`INCLUDED_COMMITS="${hash}"`,
|
|
'FILTER_PATHS=".planning/phases/ .planning/quick/ .planning/research/ .planning/threads/ .planning/todos/ .planning/debug/ .planning/seeds/ .planning/codebase/ .planning/ui-reviews/ "',
|
|
createBash,
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(repo, 'create.sh'), script + '\n');
|
|
const r = runHook(path.join(repo, 'create.sh'), [], {
|
|
interpreter: 'bash',
|
|
cwd: repo,
|
|
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
|
|
});
|
|
assert.equal(r.exitCode, 0, `create loop must succeed: ${r.stderr.slice(0, 400)}`);
|
|
|
|
const status = g(['diff', '--name-status', 'main..feature-pr']);
|
|
assert.equal((status.match(/^D/gm) || []).length, 0, `no deletions allowed: ${status}`);
|
|
const diffPaths = g(['diff', '--name-only', 'main..feature-pr']);
|
|
assert.ok(!diffPaths.includes('2.0-SUMMARY.md'), "commit's own transient file must be excluded");
|
|
assert.ok(diffPaths.includes('script2.sh'), 'code change must be present');
|
|
assert.ok(!diffPaths.includes('1.0-PLAN.md'), 'pre-existing plan must be untouched');
|
|
assert.ok(!diffPaths.includes('STATE.md'), 'structural STATE.md must survive (criterion 3)');
|
|
assert.ok(!diffPaths.includes('ROADMAP.md'), 'structural ROADMAP.md must survive (criterion 3)');
|
|
});
|
|
|
|
test('create loop preserves planning content on pure-code commits', (t) => {
|
|
const repo = createTempDir('msd-3679-create-pure-');
|
|
t.after(() => cleanup(repo));
|
|
const g = (args) => gitOrThrow(args, { cwd: repo });
|
|
g(['init', '-q', '-b', 'main']);
|
|
g(['config', 'user.email', 't@t']);
|
|
g(['config', 'user.name', 't']);
|
|
fs.mkdirSync(path.join(repo, '.planning', 'phases'), { recursive: true });
|
|
fs.mkdirSync(path.join(repo, 'infra'), { recursive: true });
|
|
fs.writeFileSync(path.join(repo, '.planning', 'phases', '1.0-PLAN.md'), 'plan\n');
|
|
fs.writeFileSync(path.join(repo, 'infra', 'script.sh'), 'code\n');
|
|
g(['add', '-A']);
|
|
g(['commit', '-qm', 'base']);
|
|
g(['checkout', '-qb', 'feature']);
|
|
fs.writeFileSync(path.join(repo, 'infra', 'script2.sh'), 'more\n');
|
|
g(['add', '-A']);
|
|
g(['commit', '-qm', 'pure code']);
|
|
const hash = g(['rev-parse', 'HEAD']).trim();
|
|
|
|
const createBash = extractStepBash('create_pr_branch');
|
|
const script = [
|
|
'set -u',
|
|
'CURRENT_BRANCH=feature',
|
|
'TARGET=main',
|
|
`INCLUDED_COMMITS="${hash}"`,
|
|
'FILTER_PATHS=".planning/phases/ .planning/quick/ .planning/research/ .planning/threads/ .planning/todos/ .planning/debug/ .planning/seeds/ .planning/codebase/ .planning/ui-reviews/ "',
|
|
createBash,
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(repo, 'create.sh'), script + '\n');
|
|
const r = runHook(path.join(repo, 'create.sh'), [], {
|
|
interpreter: 'bash',
|
|
cwd: repo,
|
|
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
|
|
});
|
|
assert.equal(r.exitCode, 0, `create loop must succeed: ${r.stderr.slice(0, 400)}`);
|
|
const status = g(['diff', '--name-status', 'main..feature-pr']);
|
|
assert.equal((status.match(/^D/gm) || []).length, 0, `no deletions allowed: ${status}`);
|
|
assert.ok(status.includes('script2.sh'), 'code change must be present');
|
|
});
|
|
});
|