fix(windows): stabilize init/workflow tests across path and npm edge cases
This commit is contained in:
@@ -76,6 +76,54 @@ function withProjectRoot(cwd, result) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return git-worktree state for init payloads with robust nested-subdir
|
||||||
|
* detection across Windows short/long path forms and slash variants.
|
||||||
|
*/
|
||||||
|
function getInitGitState(cwd) {
|
||||||
|
const info = gitWorktreeInfoInternal(cwd);
|
||||||
|
const worktreeRoot = info.worktreeRoot;
|
||||||
|
const normalizeForCompare = (p) => {
|
||||||
|
if (typeof p !== 'string' || p.length === 0) return null;
|
||||||
|
let resolved;
|
||||||
|
try {
|
||||||
|
resolved = fs.realpathSync.native(p);
|
||||||
|
} catch {
|
||||||
|
resolved = path.resolve(p);
|
||||||
|
}
|
||||||
|
resolved = path.resolve(resolved);
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return resolved.replace(/\//g, '\\').toLowerCase();
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
};
|
||||||
|
|
||||||
|
let inNestedSubdir = info.inside && worktreeRoot !== null;
|
||||||
|
if (inNestedSubdir) {
|
||||||
|
const rootNorm = normalizeForCompare(worktreeRoot);
|
||||||
|
const cwdNorm = normalizeForCompare(cwd);
|
||||||
|
if (rootNorm && cwdNorm) {
|
||||||
|
if (rootNorm === cwdNorm) {
|
||||||
|
inNestedSubdir = false;
|
||||||
|
} else {
|
||||||
|
const rel = path.relative(rootNorm, cwdNorm);
|
||||||
|
const relNorm = process.platform === 'win32' ? rel.replace(/\//g, '\\') : rel;
|
||||||
|
inNestedSubdir =
|
||||||
|
relNorm !== '' &&
|
||||||
|
relNorm !== '.' &&
|
||||||
|
!relNorm.startsWith('..') &&
|
||||||
|
!path.isAbsolute(relNorm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
has_git: info.inside,
|
||||||
|
git_worktree_root: worktreeRoot,
|
||||||
|
in_nested_subdir: inNestedSubdir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
|
function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
|
||||||
if (!phase) {
|
if (!phase) {
|
||||||
error('phase required for init execute-phase');
|
error('phase required for init execute-phase');
|
||||||
@@ -475,31 +523,7 @@ function cmdInitNewProject(cwd, raw) {
|
|||||||
needs_codebase_map: (hasCode || hasPackageFile) && !pathExistsInternal(cwd, '.planning/codebase'),
|
needs_codebase_map: (hasCode || hasPackageFile) && !pathExistsInternal(cwd, '.planning/codebase'),
|
||||||
|
|
||||||
// Git state (Bug #3491: detect parent worktree to avoid nested .git init)
|
// Git state (Bug #3491: detect parent worktree to avoid nested .git init)
|
||||||
...(() => {
|
...getInitGitState(cwd),
|
||||||
const info = gitWorktreeInfoInternal(cwd);
|
|
||||||
const worktreeRoot = info.worktreeRoot;
|
|
||||||
// Canonicalize both sides before comparing: on Windows the runner's
|
|
||||||
// cwd may be the 8.3 short-name form (RUNNER~1) while git's
|
|
||||||
// --show-toplevel emits the long-form path with forward slashes.
|
|
||||||
// Without canonicalization, in_nested_subdir is `true` even at the
|
|
||||||
// worktree root (bug #3491). realpathSync.native handles 8.3→long
|
|
||||||
// expansion; path.resolve normalizes separators. Wrap in try so
|
|
||||||
// a missing path falls back to the original string compare.
|
|
||||||
let inNestedSubdir = info.inside && worktreeRoot !== null && worktreeRoot !== cwd;
|
|
||||||
if (inNestedSubdir) {
|
|
||||||
try {
|
|
||||||
const canonRoot = fs.realpathSync.native(worktreeRoot);
|
|
||||||
const canonCwd = fs.realpathSync.native(cwd);
|
|
||||||
const rel = path.relative(canonRoot, canonCwd);
|
|
||||||
inNestedSubdir = rel !== '' && !rel.startsWith('..');
|
|
||||||
} catch { /* keep raw-string compare result */ }
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
has_git: info.inside,
|
|
||||||
git_worktree_root: worktreeRoot,
|
|
||||||
in_nested_subdir: inNestedSubdir,
|
|
||||||
};
|
|
||||||
})(),
|
|
||||||
|
|
||||||
// Enhanced search
|
// Enhanced search
|
||||||
brave_search_available: hasBraveSearch,
|
brave_search_available: hasBraveSearch,
|
||||||
@@ -633,32 +657,7 @@ function cmdInitIngestDocs(cwd, raw) {
|
|||||||
const result = {
|
const result = {
|
||||||
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
|
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
|
||||||
planning_exists: fs.existsSync(planningRoot(cwd)),
|
planning_exists: fs.existsSync(planningRoot(cwd)),
|
||||||
...(() => {
|
...getInitGitState(cwd),
|
||||||
// Bug #3491 — see cmdInitNewProject above. Same shallow-check bug.
|
|
||||||
const info = gitWorktreeInfoInternal(cwd);
|
|
||||||
const worktreeRoot = info.worktreeRoot;
|
|
||||||
// Canonicalize both sides before comparing: on Windows the runner's
|
|
||||||
// cwd may be the 8.3 short-name form (RUNNER~1) while git's
|
|
||||||
// --show-toplevel emits the long-form path with forward slashes.
|
|
||||||
// Without canonicalization, in_nested_subdir is `true` even at the
|
|
||||||
// worktree root (bug #3491). realpathSync.native handles 8.3→long
|
|
||||||
// expansion; path.resolve normalizes separators. Wrap in try so
|
|
||||||
// a missing path falls back to the original string compare.
|
|
||||||
let inNestedSubdir = info.inside && worktreeRoot !== null && worktreeRoot !== cwd;
|
|
||||||
if (inNestedSubdir) {
|
|
||||||
try {
|
|
||||||
const canonRoot = fs.realpathSync.native(worktreeRoot);
|
|
||||||
const canonCwd = fs.realpathSync.native(cwd);
|
|
||||||
const rel = path.relative(canonRoot, canonCwd);
|
|
||||||
inNestedSubdir = rel !== '' && !rel.startsWith('..');
|
|
||||||
} catch { /* keep raw-string compare result */ }
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
has_git: info.inside,
|
|
||||||
git_worktree_root: worktreeRoot,
|
|
||||||
in_nested_subdir: inNestedSubdir,
|
|
||||||
};
|
|
||||||
})(),
|
|
||||||
project_path: '.planning/PROJECT.md',
|
project_path: '.planning/PROJECT.md',
|
||||||
commit_docs: config.commit_docs,
|
commit_docs: config.commit_docs,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const fs = require('node:fs');
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const { spawnSync } = require('node:child_process');
|
const { spawnSync } = require('node:child_process');
|
||||||
|
const { cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
const HOOK_PATH = path.resolve(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
|
const HOOK_PATH = path.resolve(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
|
||||||
|
|
||||||
@@ -99,10 +100,7 @@ describe('#1974 context exhaustion auto-record', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// Windows: AV/file-indexer/not-yet-exited fire-and-forget subprocess may
|
cleanup(tmpDir);
|
||||||
// still hold a handle on tmpDir at teardown. Match the helpers.cleanup()
|
|
||||||
// retry budget (20 × 250ms = 5s) to absorb the deferred-handle window.
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 });
|
|
||||||
// Clean up bridge files
|
// Clean up bridge files
|
||||||
try {
|
try {
|
||||||
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
|
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const { describe, test, beforeEach, afterEach } = require('node:test');
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
const { createTempProject, cleanup, runGsdTools, toPosixPath } = require('./helpers.cjs');
|
||||||
|
|
||||||
function setupMilestoneArchiveProject(tmpDir, options = {}) {
|
function setupMilestoneArchiveProject(tmpDir, options = {}) {
|
||||||
const {
|
const {
|
||||||
@@ -151,6 +151,7 @@ describe('#3164 — validate consistency: milestone-archive layout', () => {
|
|||||||
|
|
||||||
const out = JSON.parse(result.output);
|
const out = JSON.parse(result.output);
|
||||||
const warnings = out.warnings || [];
|
const warnings = out.warnings || [];
|
||||||
|
const warningsPosix = warnings.map(w => toPosixPath(w));
|
||||||
const phase64Warnings = warnings.filter(w => w.includes('Phase 64 exists on disk but not in ROADMAP.md'));
|
const phase64Warnings = warnings.filter(w => w.includes('Phase 64 exists on disk but not in ROADMAP.md'));
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
phase64Warnings,
|
phase64Warnings,
|
||||||
@@ -158,12 +159,12 @@ describe('#3164 — validate consistency: milestone-archive layout', () => {
|
|||||||
`Old archived milestone phase 64 should not be treated as active:\n ${phase64Warnings.join('\n ')}`
|
`Old archived milestone phase 64 should not be treated as active:\n ${phase64Warnings.join('\n ')}`
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
warnings.some(w => w.includes('Gap in plan numbering in milestones/v1.7-phases/65-current')),
|
warningsPosix.some(w => w.includes('Gap in plan numbering in milestones/v1.7-phases/65-current')),
|
||||||
`Expected plan numbering warning from active archive root, got:\n ${warnings.join('\n ')}`
|
`Expected plan numbering warning from active archive root, got:\n ${warnings.join('\n ')}`
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
warnings.some(w => w.includes("milestones/v1.7-phases/65-current/65-01-PLAN.md: missing 'wave'"))
|
warningsPosix.some(w => w.includes("milestones/v1.7-phases/65-current/65-01-PLAN.md: missing 'wave'"))
|
||||||
|| warnings.some(w => w.includes("milestones/v1.7-phases/65-current/65-03-PLAN.md: missing 'wave'")),
|
|| warningsPosix.some(w => w.includes("milestones/v1.7-phases/65-current/65-03-PLAN.md: missing 'wave'")),
|
||||||
`Expected frontmatter warning from active archive plans, got:\n ${warnings.join('\n ')}`
|
`Expected frontmatter warning from active archive plans, got:\n ${warnings.join('\n ')}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,23 +32,38 @@ function auditProductionVulns(cwd) {
|
|||||||
if (!fs.existsSync(path.join(cwd, 'node_modules'))) {
|
if (!fs.existsSync(path.join(cwd, 'node_modules'))) {
|
||||||
return null; // signal "skip" to caller
|
return null; // signal "skip" to caller
|
||||||
}
|
}
|
||||||
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
const isWindows = process.platform === 'win32';
|
||||||
|
const npmCandidates = isWindows ? ['npm.cmd', 'npm'] : ['npm'];
|
||||||
|
const args = ['audit', '--omit=dev', '--json'];
|
||||||
let out;
|
let out;
|
||||||
try {
|
let lastErr = null;
|
||||||
out = execFileSync(
|
for (const npmCmd of npmCandidates) {
|
||||||
npmCmd,
|
try {
|
||||||
['audit', '--omit=dev', '--json'],
|
out = execFileSync(
|
||||||
{ cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
|
npmCmd,
|
||||||
);
|
args,
|
||||||
} catch (e) {
|
{
|
||||||
// `npm audit` exits non-zero when advisories are present; the JSON is
|
cwd,
|
||||||
// still on stdout in that case. Recover and let the assertion classify.
|
encoding: 'utf-8',
|
||||||
if (e && typeof e.stdout !== 'undefined') {
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
out = Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf-8') : String(e.stdout);
|
timeout: 60_000,
|
||||||
} else {
|
shell: isWindows,
|
||||||
throw e;
|
}
|
||||||
|
);
|
||||||
|
lastErr = null;
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
// `npm audit` exits non-zero when advisories are present; the JSON is
|
||||||
|
// still on stdout in that case. Recover and let the assertion classify.
|
||||||
|
if (e && typeof e.stdout !== 'undefined' && e.stdout !== undefined && e.stdout !== null) {
|
||||||
|
out = Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf-8') : String(e.stdout);
|
||||||
|
lastErr = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lastErr = e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (lastErr) throw lastErr;
|
||||||
const parsed = JSON.parse(out);
|
const parsed = JSON.parse(out);
|
||||||
// `null` is reserved for the "node_modules missing → skip" signal above.
|
// `null` is reserved for the "node_modules missing → skip" signal above.
|
||||||
// Any other unexpected JSON shape is a real failure of the audit harness
|
// Any other unexpected JSON shape is a real failure of the audit harness
|
||||||
|
|||||||
Reference in New Issue
Block a user