Two more diagnostic passes (clusters J: residuals in already-touched files,
K: 12 untouched files) plus a production-code path fix and a new
ratchet-style lint guard.
## Test-only fixes (14 files)
bug-1736, bug-2248, bug-2698 — replace inline 1s-budget rmSync with the
shared cleanup() helper (5s budget, 20×250ms retries). The earlier
inline maxRetries:10 / retryDelay:100 wasn't enough to absorb Windows
Defender's deferred-scan handle hold on cold runners.
bug-2256, skill-manifest — also override USERPROFILE alongside HOME in
beforeEach/runGsdTools calls. os.homedir() reads USERPROFILE on win32,
so HOME-only stubs leak the runner's real home into the SUT.
bug-2784, bug-3608, enh-2500, enh-2790, few-shot-calibration,
gsd-settings-advanced — CRLF tolerance: literal \n in regexes against
file content (frontmatter anchors, bash-fence regex, multi-line
numbered-list captures, awk-block extractors) becomes \r?\n; split('\n')
becomes split(/\r?\n/). Windows checkout with autocrlf=true puts \r
before every \n; .+ doesn't match \r in JS regex by default.
bug-2966 — three-part fix to extractStepRun (CRLF split), awk regex
(\r?\n), and conflict-marker parser (rawLine + \r$ strip).
bug-2969, config — normalize separators on test assertions where the
SUT correctly emits \ on win32 but the test compares against /.
prompt-injection-scan — normalize relPath via replace(/\\/g, '/') before
ALLOWLIST.has() lookup. ALLOWLIST keys are POSIX; path.relative returns
backslashes on win32 → falsely scans allowlisted security module → trips
the boundary-tag detector on its own legitimate detection code.
prune-orphaned-worktrees — use the existing canonicalPath +
listedWorktreePaths(repoDir).has(...) helpers instead of substring
matching the raw path. git stores long-form canonical paths
(runneradmin), but mkdtempSync returns 8.3 short-form (RUNNER~1) on
Windows runners; plain string compare misses every entry.
## Production-code fix (1 file)
get-shit-done/bin/lib/init.cjs — bug-3491 in_nested_subdir computation
canonicalizes both worktreeRoot and cwd via fs.realpathSync.native +
path.relative before declaring "nested." Windows runner cwd (8.3 short
name) vs git's --show-toplevel (long form, forward slashes) made the
raw string compare always say true even at the worktree root, breaking
the "init new-project at worktree root" subtest.
## New ratchet lint guard
tests/windows-test-parity-guard.test.cjs — scans tests/ for 7
anti-patterns that drove the Windows failure clusters. Each rule has a
baseline count snapshot from this PR; the test fails when a new
occurrence appears (count grows above baseline), ratcheting down as
existing offenders are fixed. Patterns covered:
G1 split('\n') after readFileSync (use /\r?\n/)
G2 ```bash\n fence regex (use ```bash\r?\n)
G3 ^---\n frontmatter anchor (use ^---\r?\n)
G4 hardcoded "/tmp/..." literal passed to fs.* (use os.tmpdir())
G5 bare 'npm' to execFileSync without {shell:true} on win32
G6 process.env.HOME stub with no USERPROFILE
G7 fs.rmSync({recursive,force}) without maxRetries
Future Windows-parity regressions get caught at PR time rather than
five iterations into a CI loop.
Validated: holodeck (ubuntu docker) 11232/0 pass (count +8 = the 7
new ratchet tests + parent describe).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
294 lines
13 KiB
JavaScript
294 lines
13 KiB
JavaScript
/**
|
|
* Regression test for bug #2966
|
|
*
|
|
* The release-sdk hotfix workflow's auto_cherry_pick loop aborts when a
|
|
* `fix:`/`chore:` commit's patch is rooted in code that doesn't exist at
|
|
* the hotfix's base tag (e.g. the surrounding block was added later in a
|
|
* feat/refactor commit excluded by the filter). The conflict is
|
|
* unresolvable — the patch literally cannot be applied to a tree that
|
|
* lacks the surrounding infrastructure — but the workflow treats it as
|
|
* an operator-resolvable conflict and exits.
|
|
*
|
|
* Fix: after `git cherry-pick` exits non-zero, inspect each unmerged
|
|
* file's conflict markers. If every conflict block in every file has an
|
|
* empty `<<<<<<< HEAD ... =======` HEAD section, run `git cherry-pick
|
|
* --skip` and add the SHA to the skipped list with reason
|
|
* "context absent at base". Else, fall through to the existing abort/
|
|
* push-partial/error path.
|
|
*
|
|
* This test asserts both:
|
|
* 1. Static — the auto_cherry_pick loop in release-sdk.yml carries the
|
|
* context-missing detection (matching `git cherry-pick --skip` and
|
|
* `context absent at base` semantics) so the no-source-grep static
|
|
* check is still meaningful for future edits.
|
|
* 2. Behavioral — using a synthetic git repo that reproduces the exact
|
|
* shape of the failure on origin/main:
|
|
* a. A patch whose target context doesn't exist at base produces
|
|
* empty-HEAD conflict markers AND a non-zero exit from
|
|
* cherry-pick. (Proves the bug premise.)
|
|
* b. The `awk` predicate in the workflow correctly classifies the
|
|
* empty-HEAD case as "context-missing" (skippable) and the
|
|
* both-sides-have-content case as "real" (must abort).
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
// allow-test-rule: source-text-is-the-product
|
|
// release-sdk.yml IS the product for hotfix automation; GitHub Actions
|
|
// executes the YAML's shell verbatim. The static check uses structured
|
|
// extraction (extractStepRun) rather than raw-text grep, scoped to the
|
|
// "Prepare hotfix branch" step's run block.
|
|
|
|
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 { spawnSync } = require('node:child_process');
|
|
|
|
const WORKFLOW_PATH = path.join(__dirname, '..', '.github', 'workflows', 'release-sdk.yml');
|
|
|
|
/**
|
|
* Extract the `run:` literal block of a named step from a GitHub Actions
|
|
* workflow using indentation-aware parsing — no raw-text grep across the
|
|
* whole document. Walks lines once, recognises `- name:` step headers and
|
|
* `run: |` literal-block markers, and returns the unindented script body.
|
|
*
|
|
* No YAML library is used; the repo has none in dependencies and adding
|
|
* one for a single test isn't justified.
|
|
*/
|
|
function extractStepRun(workflowText, stepName) {
|
|
// CRLF-tolerant split: Windows checkout (autocrlf=true) leaves trailing
|
|
// \r on every line which downstream regex anchors don't tolerate.
|
|
const lines = workflowText.split(/\r?\n/);
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const m = lines[i].match(/^(\s*)- name:\s*(.+?)\s*$/);
|
|
if (!m || m[2] !== stepName) continue;
|
|
const stepIndent = m[1].length;
|
|
let j = i + 1;
|
|
while (j < lines.length) {
|
|
const peek = lines[j];
|
|
if (/^\s*- /.test(peek)) {
|
|
const peekIndent = peek.match(/^(\s*)/)[1].length;
|
|
if (peekIndent <= stepIndent) break;
|
|
}
|
|
const runMatch = peek.match(/^(\s*)run:\s*\|(?:[+-])?\s*$/);
|
|
if (runMatch) {
|
|
const blockIndent = runMatch[1].length + 2;
|
|
const body = [];
|
|
for (let k = j + 1; k < lines.length; k++) {
|
|
const bodyLine = lines[k];
|
|
if (bodyLine.length === 0) {
|
|
body.push('');
|
|
continue;
|
|
}
|
|
const lead = bodyLine.match(/^(\s*)/)[1].length;
|
|
if (lead < blockIndent && bodyLine.trim() !== '') break;
|
|
body.push(bodyLine.slice(blockIndent));
|
|
}
|
|
return body.join('\n');
|
|
}
|
|
j++;
|
|
}
|
|
throw new Error(`step "${stepName}" found but no run: | block before step end`);
|
|
}
|
|
throw new Error(`step "${stepName}" not found in workflow`);
|
|
}
|
|
|
|
function git(cwd, args) {
|
|
// Force-disable signing inline — a developer's global gpgsign config
|
|
// can't be allowed to fail commits in this throwaway repo. Also pin
|
|
// merge.conflictStyle=merge so the cherry-pick reproducer below sees
|
|
// the same marker shape the workflow guards against (diff3/zdiff3 in
|
|
// the developer or CI runner's global config would inject `|||||||`
|
|
// sections and break the empty-HEAD assertion).
|
|
const inlineConfig = [
|
|
'-c', 'commit.gpgsign=false',
|
|
'-c', 'tag.gpgsign=false',
|
|
'-c', 'gpg.format=openpgp',
|
|
'-c', 'user.signingkey=',
|
|
'-c', 'merge.conflictStyle=merge',
|
|
];
|
|
return spawnSync('git', [...inlineConfig, ...args], {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
env: { ...process.env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 't@t' },
|
|
});
|
|
}
|
|
|
|
describe('bug-2966: release-sdk hotfix cherry-pick classifies context-missing vs real conflicts for skip-reason annotation', () => {
|
|
test('Prepare hotfix branch step classifies and annotates context-missing conflicts', () => {
|
|
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
|
|
|
// The loop must detect unmerged paths after a failed cherry-pick.
|
|
assert.match(
|
|
script,
|
|
/git diff --name-only --diff-filter=U/,
|
|
'auto_cherry_pick must read the unmerged path list after a failed cherry-pick to classify the conflict (#2966)'
|
|
);
|
|
// The empty-HEAD-section detector must be present.
|
|
assert.match(
|
|
script,
|
|
/<<<<<<< /,
|
|
'auto_cherry_pick must inspect conflict markers to classify context-missing vs real conflicts (#2966)'
|
|
);
|
|
// The skip path must call `git cherry-pick --skip` so the loop continues
|
|
// past commits whose target context doesn't exist at the base tag.
|
|
assert.match(
|
|
script,
|
|
/git cherry-pick --skip/,
|
|
'auto_cherry_pick must invoke `git cherry-pick --skip` for context-missing conflicts so they don\'t brick the run (#2966)'
|
|
);
|
|
// The skipped list must annotate the reason so operators see it in the
|
|
// run summary (not silently disappear).
|
|
assert.match(
|
|
script,
|
|
/context absent at base/,
|
|
'auto_cherry_pick must annotate skipped picks with "context absent at base" so the run summary surfaces them (#2966)'
|
|
);
|
|
// The cherry-pick must pin merge.conflictStyle=merge so the awk
|
|
// classifier sees deterministic marker shapes regardless of the
|
|
// runner's git config (diff3/zdiff3 would inject `||||||| ancestor`
|
|
// lines into the HEAD section and misclassify context-missing
|
|
// conflicts as real ones).
|
|
assert.match(
|
|
script,
|
|
/git -c merge\.conflictStyle=merge cherry-pick/,
|
|
'auto_cherry_pick must pin `merge.conflictStyle=merge` on the cherry-pick command so marker parsing is deterministic across runner git configs (#2966)'
|
|
);
|
|
});
|
|
|
|
test('cherry-pick of a patch whose target section is absent at base produces empty-HEAD conflict markers and exits non-zero', () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bug-2966-ctx-missing-'));
|
|
try {
|
|
assert.equal(git(tmp, ['init', '-q', '-b', 'main']).status, 0, 'git init');
|
|
|
|
// Base — file exists but does NOT contain the section the patch will modify.
|
|
fs.mkdirSync(path.join(tmp, '.github', 'workflows'), { recursive: true });
|
|
fs.writeFileSync(path.join(tmp, '.github/workflows/x.yml'), 'name: base\njobs:\n release:\n runs-on: ubuntu-latest\n');
|
|
assert.equal(git(tmp, ['add', '.']).status, 0, 'git add base');
|
|
assert.equal(git(tmp, ['commit', '-q', '-m', 'base']).status, 0, 'commit base');
|
|
assert.equal(git(tmp, ['tag', 'v0.0.0']).status, 0, 'tag base');
|
|
|
|
// feat (excluded by fix/chore filter) — adds the prepare block.
|
|
fs.writeFileSync(path.join(tmp, '.github/workflows/x.yml'),
|
|
'name: base\njobs:\n prepare:\n run: |\n git cherry-pick -x "$SHA"\n release:\n runs-on: ubuntu-latest\n');
|
|
assert.equal(git(tmp, ['commit', '-qam', 'feat: add prepare block']).status, 0, 'commit feat');
|
|
|
|
// fix — modifies the line inside the prepare block.
|
|
const yaml = fs.readFileSync(path.join(tmp, '.github/workflows/x.yml'), 'utf8')
|
|
.replace('git cherry-pick -x "$SHA"', 'git cherry-pick -x --allow-empty "$SHA"');
|
|
fs.writeFileSync(path.join(tmp, '.github/workflows/x.yml'), yaml);
|
|
assert.equal(git(tmp, ['commit', '-qam', 'fix: tweak cherry-pick']).status, 0, 'commit fix');
|
|
const fixSha = git(tmp, ['rev-parse', 'HEAD']).stdout.trim();
|
|
|
|
// Cherry-pick fix onto v0.0.0 — must conflict because target context isn't there.
|
|
assert.equal(git(tmp, ['checkout', '-q', '-b', 'hotfix', 'v0.0.0']).status, 0, 'checkout hotfix');
|
|
const pick = git(tmp, ['cherry-pick', '-x', '--allow-empty', '--keep-redundant-commits', fixSha]);
|
|
assert.notEqual(
|
|
pick.status,
|
|
0,
|
|
'cherry-pick of a patch whose target section is absent at base MUST exit non-zero (the bug premise: workflow currently treats this as a real conflict and aborts) (#2966)'
|
|
);
|
|
|
|
// Confirm conflict markers exist and the HEAD section is empty in every block.
|
|
const conflicted = fs.readFileSync(path.join(tmp, '.github/workflows/x.yml'), 'utf8');
|
|
assert.match(conflicted, /<<<<<<< /, 'conflict markers must be written to the file');
|
|
// Every <<<<<<< HEAD ... ======= block must have empty HEAD content.
|
|
const blocks = [];
|
|
let inHead = false;
|
|
let head = '';
|
|
for (const rawLine of conflicted.split(/\r?\n/)) {
|
|
// Strip residual \r so /^=======$/ matches even on CRLF content.
|
|
const line = rawLine.replace(/\r$/, '');
|
|
if (/^<<<<<<< /.test(line)) { inHead = true; head = ''; continue; }
|
|
if (/^=======$/.test(line) && inHead) { inHead = false; continue; }
|
|
if (/^>>>>>>> /.test(line)) { blocks.push(head); head = ''; continue; }
|
|
if (inHead) head += line + '\n';
|
|
}
|
|
assert.ok(blocks.length > 0, 'expected at least one conflict marker block');
|
|
for (const b of blocks) {
|
|
assert.equal(b.trim(), '', `expected every HEAD section to be empty (context-missing signal), got: ${JSON.stringify(b)}`);
|
|
}
|
|
} finally {
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('the awk predicate from the workflow classifies empty-HEAD as skippable and non-empty-HEAD as real', () => {
|
|
// Pull the awk script out of the deployed workflow so this test
|
|
// exercises the exact predicate that runs in CI — not a copy.
|
|
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
|
const awkMatch = script.match(/awk '\r?\n([\s\S]+?)' "\$CONFLICTED"/);
|
|
assert.ok(awkMatch, 'expected to find the conflict-classifying awk script in the workflow');
|
|
const awkProgram = awkMatch[1];
|
|
|
|
function classify(conflictText) {
|
|
const tmpFile = path.join(os.tmpdir(), `bug-2966-awk-${process.pid}-${Date.now()}-${Math.random()}.txt`);
|
|
fs.writeFileSync(tmpFile, conflictText);
|
|
try {
|
|
const r = spawnSync('awk', [awkProgram, tmpFile], { encoding: 'utf8' });
|
|
// Fail loudly on awk execution errors — silently consuming an
|
|
// empty stdout from a crashed/missing awk would let context-missing
|
|
// assertions falsely pass.
|
|
assert.ok(!r.error, `awk failed to launch: ${r.error && r.error.message}`);
|
|
assert.equal(r.status, 0, `awk predicate exited non-zero: ${r.stderr || '(no stderr)'}`);
|
|
return r.stdout.trim();
|
|
} finally {
|
|
fs.rmSync(tmpFile, { force: true });
|
|
}
|
|
}
|
|
|
|
// Empty HEAD section → context-missing → no "real" emitted.
|
|
const ctxMissing = [
|
|
'unrelated line',
|
|
'<<<<<<< HEAD',
|
|
'=======',
|
|
'patch wants this content',
|
|
'and this',
|
|
'>>>>>>> sha (msg)',
|
|
'tail',
|
|
].join('\n');
|
|
assert.equal(classify(ctxMissing), '', 'awk must classify empty-HEAD blocks as context-missing (no "real" emitted) (#2966)');
|
|
|
|
// Non-empty HEAD section → real conflict.
|
|
const realConflict = [
|
|
'<<<<<<< HEAD',
|
|
'VALUE=existing',
|
|
'=======',
|
|
'VALUE=patched',
|
|
'>>>>>>> sha (msg)',
|
|
].join('\n');
|
|
assert.equal(classify(realConflict), 'real', 'awk must classify non-empty-HEAD blocks as real conflicts (#2966)');
|
|
|
|
// Mixed — first block empty-HEAD, second block real → real wins (overall classification).
|
|
const mixed = [
|
|
'<<<<<<< HEAD',
|
|
'=======',
|
|
'patch content',
|
|
'>>>>>>> sha (msg)',
|
|
'spacer',
|
|
'<<<<<<< HEAD',
|
|
'something existing',
|
|
'=======',
|
|
'something patched',
|
|
'>>>>>>> sha (msg)',
|
|
].join('\n');
|
|
assert.equal(classify(mixed), 'real', 'awk must report "real" if any conflict block has non-empty HEAD content (#2966)');
|
|
|
|
// Whitespace-only HEAD section → context-missing (the awk predicate
|
|
// treats blank/whitespace HEAD content the same as empty).
|
|
const whitespaceHead = [
|
|
'<<<<<<< HEAD',
|
|
' ',
|
|
'\t',
|
|
'=======',
|
|
'patch content',
|
|
'>>>>>>> sha (msg)',
|
|
].join('\n');
|
|
assert.equal(classify(whitespaceHead), '', 'awk must classify whitespace-only HEAD blocks as context-missing (#2966)');
|
|
});
|
|
});
|