Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 deletions test/init-scaffolding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
* Also confirms init works without errors in repos that have no remote.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
import { mkdir, rm, readFile } from 'fs/promises';
import { join } from 'path';
import { join, resolve, relative, isAbsolute } from 'path';
import { existsSync } from 'fs';
import { tmpdir } from 'os';
import { randomBytes } from 'crypto';
import { execFileSync } from 'child_process';
import { initSquad } from '@bradygaster/squad-sdk';
Expand All @@ -18,7 +19,35 @@ import { runInit } from '@bradygaster/squad-cli/core/init';
import { runDoctor } from '@bradygaster/squad-cli/commands/doctor';
import type { DoctorCheck } from '@bradygaster/squad-cli/commands/doctor';

const TEST_ROOT = join(process.cwd(), `.test-init-scaffold-${randomBytes(4).toString('hex')}`);
// Sandbox lives in the OS temp dir, NOT inside the repository (#1796).
// runInit()/initSquad() walk upward to find the enclosing git root when the
// target directory is not itself a repo. A sandbox under process.cwd() therefore
// resolves to the *real* repository and stampVersion() rewrites the tracked
// .github/agents/squad.agent.md — a passing test silently mutating source.
const TEST_ROOT = join(tmpdir(), `.test-init-scaffold-${randomBytes(4).toString('hex')}`);

/** Repository root — used only by the working-tree guard below. */
const REPO_ROOT = resolve(__dirname, '..');

/** Files this suite must never touch. Relative to REPO_ROOT. */
const GUARDED_PATHS = ['.github/agents/squad.agent.md', '.squad-templates/squad.agent.md'];

/** Porcelain status of the guarded paths, so pre-existing local edits don't skew the guard. */
function guardedStatus(): string {
return execFileSync('git', ['status', '--porcelain', '--', ...GUARDED_PATHS], {
cwd: REPO_ROOT,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
}

/** Captured before any test in this file runs. */
let guardedStatusBefore = '';

beforeAll(() => {
guardedStatusBefore = guardedStatus();
});


/** Create a bare git repo at the given path (no remote). */
function gitInit(dir: string): void {
Expand Down Expand Up @@ -498,3 +527,24 @@ describe('.gitignore state-backend marker block — initSquad()', () => {
expect(occurrences).toBe(1);
});
});

// ─── Working-tree containment guard (#1796) ────────────────────────────────

describe('repository working tree is not mutated by this suite (#1796)', () => {
it('sandbox root is outside the repository working tree', () => {
const rel = relative(REPO_ROOT, TEST_ROOT);
// A path inside REPO_ROOT produces a relative path that neither escapes
// upward nor is absolute. Anything else means the sandbox is contained.
const isInsideRepo = rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
Comment on lines +536 to +538
expect(isInsideRepo, `TEST_ROOT (${TEST_ROOT}) must not live under ${REPO_ROOT}`).toBe(false);
});

it('tracked squad.agent.md files are byte-unchanged after init runs', () => {
// Pre-fix this is red: initSquad()/runInit() on a sandbox that has no git
// root of its own walk upward, find the real repository, and stampVersion()
// rewrites .github/agents/squad.agent.md from 0.0.0-source to the package
// version. The suite still passes — the mutation is silent.
expect(guardedStatus()).toBe(guardedStatusBefore);
});
});

24 changes: 14 additions & 10 deletions test/template-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,14 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');

// Re-sync templates before any byte-comparison checks.
// Other test files (e.g., acceptance tests) may run `squad init` in the
// repo root, overwriting .github/agents/squad.agent.md from the CLI
// template and making it diverge from .squad-templates/squad.agent.md.
//
// Historically this existed because test/init-scaffolding.test.ts sandboxed
// itself *inside* the repo, so init() walked up to the real git root and
// stampVersion() rewrote .github/agents/squad.agent.md mid-run (#1796). That
// sandbox now lives in the OS temp dir and can no longer reach the repository,
// so this sync is defence-in-depth rather than active repair — it is kept so a
// future suite that reintroduces the escape fails loudly here instead of
// leaving a silently mutated working tree.
beforeAll(() => {
execSync('node scripts/sync-templates.mjs', {
cwd: ROOT,
Expand Down Expand Up @@ -118,13 +123,12 @@ const CASTING_POLICY_LOCATIONS = [
// ---------------------------------------------------------------------------

describe('dynamic template enumeration (all synced files)', () => {
// Re-sync immediately before byte comparisons to guard against a race
// condition with test/init-scaffolding.test.ts. That suite runs runInit()
// with a subdirectory of the repo root as cwd; in monorepo mode the init
// command places squad.agent.md at the real git root (.github/agents/) via
// stampVersion(), which races with the file-level beforeAll sync. A
// describe-scoped beforeAll runs synchronously, right before any test in
// this suite, minimising the window to near-zero.
// Re-sync immediately before byte comparisons as a second layer of defence.
// The original race — test/init-scaffolding.test.ts running runInit() against
// a sandbox under the repo root, so monorepo resolution stamped the real
// .github/agents/squad.agent.md — was fixed in #1796 by moving that sandbox
// to the OS temp dir. This describe-scoped beforeAll is retained to keep the
// window closed if any future suite reintroduces an in-repo sandbox.
beforeAll(() => {
execSync('node scripts/sync-templates.mjs', {
cwd: ROOT,
Expand Down