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
6 changes: 6 additions & 0 deletions .changeset/casting-crlf-manifest-tolerance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@bradygaster/squad-sdk": patch
"@bradygaster/squad-cli": patch
---

Keep manifest-backed casting registries valid across Windows CRLF checkouts and normalize casting JSON to LF during upgrade.
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
# universe/roles, persistent name registry, universe assignment history).
# They are committed to 'main' like team.md and routing.md, not to squad-state.
# They do NOT need union merge and are intentionally NOT listed above.
# Their commit manifest hashes canonical LF content, so Windows checkouts must
# not rewrite the authoritative JSON files to CRLF.
.squad/casting/*.json text eol=lf

# Squad: union merge for append-only team state files
.squad/decisions/decisions.md merge=union
Expand Down
15 changes: 14 additions & 1 deletion packages/squad-cli/src/cli/core/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { clearResolveSquadCache, isRetiredProfileTeamRoot } from '@bradygaster/s
import {
backfillCastingRegistryFromTeam,
ensureCastingRegistryPair,
normalizeCastingJsonLineEndings as normalizeCastingJsonFiles,
preflightCastingRegistryPair,
repairHandwrittenCastingRegistry,
type RepairResult,
Expand Down Expand Up @@ -678,6 +679,7 @@ const GITATTRIBUTES_RULES = [
'.squad/agents/*/history.md merge=union',
'.squad/log/** merge=union',
'.squad/orchestration-log/** merge=union',
'.squad/casting/*.json text eol=lf',
];

const GITIGNORE_ENTRIES = [
Expand Down Expand Up @@ -740,7 +742,7 @@ export function ensureGitattributes(dest: string, options: { dryRun?: boolean }
appendLinesPreservingEol(filePath, added);
} catch (err: unknown) {
if (err instanceof Error && 'code' in err && ['EPERM', 'EACCES'].includes((err as NodeJS.ErrnoException).code ?? '')) {
warn('Could not update .gitattributes (read-only). Add merge=union entries manually.');
warn('Could not update .gitattributes (read-only). Add the required Squad entries manually.');
return [];
}
throw err;
Expand All @@ -749,6 +751,12 @@ export function ensureGitattributes(dest: string, options: { dryRun?: boolean }
return added;
}

export function normalizeCastingJsonLineEndings(dest: string): string[] {
const castingDir = path.join(dest, '.squad', 'casting');
return normalizeCastingJsonFiles(castingDir)
.map(name => `.squad/casting/${name}`);
}

/**
* Check whether an existing gitignore line already covers a candidate entry
* via parent-directory matching (e.g. `.squad/` covers `.squad/log/`).
Expand Down Expand Up @@ -1173,6 +1181,11 @@ async function runEnsureChecks(dest: string, templatesDir: string, filesUpdated:
success(`scaffolded ${castingFiles.length} default casting files`);
filesUpdated.push(...castingFiles);
}
const normalizedCasting = normalizeCastingJsonLineEndings(dest);
if (normalizedCasting.length > 0) {
success(`normalized ${normalizedCasting.length} casting JSON file(s) to LF`);
filesUpdated.push(...normalizedCasting);
}

const memoryFiles = ensureMemoryGovernanceUpgradeDefaults(dest);
if (memoryFiles.length > 0) {
Expand Down
65 changes: 54 additions & 11 deletions packages/squad-sdk/src/casting/durable-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export type CastingDurabilityBoundary =
| 'registry:file-fsync'
| 'registry:rename'
| 'registry:parent-fsync'
| 'normalization:write'
| 'normalization:file-fsync'
| 'normalization:rename'
| 'normalization:parent-fsync'
| 'cleanup:journal-unlink'
| 'cleanup:journal-parent-fsync'
| 'cleanup:payload-remove'
Expand Down Expand Up @@ -226,7 +230,7 @@ function writeDurableFile(
function durableReplace(
filePath: string,
content: string,
stage: 'journal' | 'history' | 'manifest' | 'registry',
stage: 'journal' | 'history' | 'manifest' | 'registry' | 'normalization',
transactionId: string,
): void {
const tempPath = `${filePath}.tmp-${transactionId}-${randomUUID()}`;
Expand All @@ -253,6 +257,12 @@ function sha256(content: string | undefined): string | null {
: createHash('sha256').update(content, 'utf8').digest('hex');
}

/** Treat Git's CRLF worktree conversion as byte-equivalent to canonical LF JSON. */
function matchesSha256(content: string | undefined, expected: string | null): boolean {
if (sha256(content) === expected) return true;
return content !== undefined && sha256(content.replace(/\r\n/g, '\n')) === expected;
}

function parseObject(raw: string | undefined, label: string): Record<string, unknown> | undefined {
if (raw === undefined) return undefined;
try {
Expand Down Expand Up @@ -1127,9 +1137,8 @@ function expectedState(
nextHash: string,
label: string,
): 'previous' | 'next' {
const actual = sha256(raw);
if (actual === previousHash) return 'previous';
if (actual === nextHash) return 'next';
if (matchesSha256(raw, previousHash)) return 'previous';
if (matchesSha256(raw, nextHash)) return 'next';
throw new Error(`Cannot recover casting transaction: ${label} changed outside the journal`);
}

Expand Down Expand Up @@ -1184,8 +1193,8 @@ export function recoverCastingRegistryTransaction(castingDir: string): void {
if (
nextRegistryRaw === undefined
|| nextHistoryRaw === undefined
|| sha256(nextRegistryRaw) !== journal.registry.next_sha256
|| sha256(nextHistoryRaw) !== journal.history.next_sha256
|| !matchesSha256(nextRegistryRaw, journal.registry.next_sha256)
|| !matchesSha256(nextHistoryRaw, journal.history.next_sha256)
) {
throw new Error('Cannot recover casting transaction: durable payload is missing or corrupt');
}
Expand All @@ -1197,7 +1206,7 @@ export function recoverCastingRegistryTransaction(castingDir: string): void {
history_sha256: journal.history.next_sha256,
};
const nextManifestRaw = JSON.stringify(nextManifest, null, 2) + '\n';
if (sha256(nextManifestRaw) !== journal.manifest.next_sha256) {
if (!matchesSha256(nextManifestRaw, journal.manifest.next_sha256)) {
throw new Error('Cannot recover casting transaction: manifest hash is corrupt');
}

Expand Down Expand Up @@ -1725,6 +1734,40 @@ export function preflightCastingRegistryPair(castingDir: string, dryRun = false)
}
}

/**
* Normalize casting JSON to LF while holding the shared writer lock.
* Each live file is replaced atomically after recovery and pair validation.
*/
export function normalizeCastingJsonLineEndings(castingDir: string): string[] {
if (!existsSync(castingDir)) return [];
const release = acquireCastingRegistryLock(castingDir, 'casting JSON line-ending normalization');
try {
recoverCastingRegistryTransaction(castingDir);
readCastingRegistryPair(castingDir);

const normalized: string[] = [];
const transactionId = randomUUID();
const entries = readdirSync(castingDir, { withFileTypes: true })
.filter(entry => entry.isFile() && entry.name.endsWith('.json'))
.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const filePath = path.join(castingDir, entry.name);
const content = readText(filePath);
if (content === undefined || !content.includes('\r\n')) continue;
durableReplace(
filePath,
content.replace(/\r\n/g, '\n'),
'normalization',
transactionId,
);
normalized.push(entry.name);
}
return normalized;
} finally {
release();
}
}

/**
* Create a missing pair or migrate a valid generationless legacy pair while
* the caller holds the shared casting writer lock.
Expand Down Expand Up @@ -1857,8 +1900,8 @@ export function validateCastingRegistryPairRaw(
|| historyTransaction !== manifest.transaction_id
|| historyRevision !== manifest.registry_revision
|| revision !== manifest.registry_revision
|| sha256(registryRaw) !== manifest.registry_sha256
|| sha256(historyRaw) !== manifest.history_sha256
|| !matchesSha256(registryRaw, manifest.registry_sha256)
|| !matchesSha256(historyRaw, manifest.history_sha256)
) {
throw new Error(
'Cannot read a consistent casting registry/history pair: registry/history bytes do not match the stable commit manifest',
Expand Down Expand Up @@ -1927,8 +1970,8 @@ export function readCastingRegistryPair(
&& historyTransaction === manifest.transaction_id
&& historyRevision === manifest.registry_revision
&& revision === manifest.registry_revision
&& sha256(registryRaw) === manifest.registry_sha256
&& sha256(historyRaw) === manifest.history_sha256
&& matchesSha256(registryRaw, manifest.registry_sha256)
&& matchesSha256(historyRaw, manifest.history_sha256)
) {
return {
registryRaw,
Expand Down
1 change: 1 addition & 0 deletions packages/squad-sdk/src/casting/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export {
commitCastingRegistryPair,
ensureCastingRegistryPair,
ensureCastingRegistryPairLocked,
normalizeCastingJsonLineEndings,
prepareCastingRegistryPairLocked,
preflightCastingRegistryPair,
readCastingRegistryPair,
Expand Down
12 changes: 6 additions & 6 deletions packages/squad-sdk/src/config/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1368,31 +1368,31 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre
}

// -------------------------------------------------------------------------
// Create .gitattributes for merge drivers
// Create .gitattributes for merge drivers and stable casting hashes
// Append-only mutable state files use union merge to handle concurrent appends.
// Note: .squad/casting/* files are identity (not mutable) so they don't need
// union merge and are NOT listed here.
// Casting JSON is authoritative identity whose commit manifest hashes LF bytes.
// -------------------------------------------------------------------------

const gitattributesPath = join(teamRoot, '.gitattributes');
const unionRules = [
const gitattributesRules = [
'.squad/decisions.md merge=union',
'.squad/agents/*/history.md merge=union',
'.squad/log/** merge=union',
'.squad/orchestration-log/** merge=union',
'.squad/rai/audit-trail.md merge=union',
'.squad/fact-checker/audit-trail.md merge=union',
'.squad/casting/*.json text eol=lf',
];

let existingAttrs = '';
if (storage.existsSync(gitattributesPath)) {
existingAttrs = storage.readSync(gitattributesPath) ?? '';
}

const missingRules = unionRules.filter(rule => !existingAttrs.includes(rule));
const missingRules = gitattributesRules.filter(rule => !existingAttrs.includes(rule));
if (missingRules.length > 0) {
const block = (existingAttrs && !existingAttrs.endsWith('\n') ? '\n' : '')
+ '# Squad: union merge for append-only team state files\n'
+ '# Squad: merge and line-ending rules for team state files\n'
+ missingRules.join('\n') + '\n';
await storage.append(gitattributesPath, block);
createdFiles.push(toRelativePath(gitattributesPath));
Expand Down
73 changes: 73 additions & 0 deletions test/casting-durable-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ import {
CastingCommitInDoubtError,
commitCastingRegistryPair,
ensureCastingRegistryPair,
normalizeCastingJsonLineEndings,
preflightCastingRegistryPair,
readCastingRegistryPair,
recoverCastingRegistryTransaction,
validateCastingRegistryPairRaw,
type CastingDurabilityBoundary,
} from '../packages/squad-sdk/src/casting/durable-registry.js';

Expand Down Expand Up @@ -809,6 +812,76 @@ describe('casting registry/history roll-forward transaction', () => {
);
});

it('accepts a stable manifest when Git converts all casting JSON files to CRLF (#2266)', () => {
const castingDir = temporaryCastingDir('crlf-manifest');
const old = initialPair();
writeFileSync(join(castingDir, 'registry.json'), old.registryRaw);
writeFileSync(join(castingDir, 'history.json'), old.historyRaw);
commitCastingRegistryPair(
castingDir,
old.registryRaw,
{ ...old.registry, revision: 2 },
old.historyRaw,
{ ...old.history, assignment_cast_snapshots: { next: historySnapshot() } },
2,
);

for (const name of ['registry.json', 'history.json', 'registry-history.commit.json']) {
const filePath = join(castingDir, name);
writeFileSync(filePath, readFileSync(filePath, 'utf8').replace(/\r?\n/g, '\r\n'));
}

const registryRaw = readFileSync(join(castingDir, 'registry.json'), 'utf8');
const historyRaw = readFileSync(join(castingDir, 'history.json'), 'utf8');
const manifestRaw = readFileSync(join(castingDir, 'registry-history.commit.json'), 'utf8');
expect(() => validateCastingRegistryPairRaw(
registryRaw,
historyRaw,
undefined,
manifestRaw,
)).not.toThrow();
expect(preflightCastingRegistryPair(castingDir)).toBe(false);
expect(readCastingRegistryPair(castingDir, 1)).toMatchObject({
registry: { revision: 2 },
history: { registry_revision: 2 },
});
});

it('holds the writer lock and leaves the live file intact when normalization fails before rename', () => {
const castingDir = temporaryCastingDir('normalization-atomic');
const old = initialPair();
writeFileSync(join(castingDir, 'registry.json'), old.registryRaw);
writeFileSync(join(castingDir, 'history.json'), old.historyRaw);
commitCastingRegistryPair(
castingDir,
old.registryRaw,
{ ...old.registry, revision: 2 },
old.historyRaw,
{ ...old.history, assignment_cast_snapshots: { next: historySnapshot() } },
2,
);
const policyPath = join(castingDir, 'policy.json');
const crlfPolicy = '{\r\n "version": 1\r\n}\r\n';
writeFileSync(policyPath, crlfPolicy);

let observedLock = false;
_setCastingDurabilityHooksForTesting({
boundary: ({ boundary, path }) => {
if (boundary === 'normalization:rename' && path === policyPath) {
observedLock = existsSync(join(castingDir, 'registry.lock'));
throw new Error('injected normalization failure');
}
},
});

expect(() => normalizeCastingJsonLineEndings(castingDir))
.toThrow(/injected normalization failure/);
expect(observedLock).toBe(true);
expect(readFileSync(policyPath, 'utf8')).toBe(crlfPolicy);
expect(readdirSync(castingDir).some(name => name.includes('.tmp-'))).toBe(false);
expect(existsSync(join(castingDir, 'registry.lock'))).toBe(false);
});

it('fails before journal publication when an external writer mutates the pair', () => {
const castingDir = temporaryCastingDir('external-mutation');
const old = initialPair();
Expand Down
19 changes: 18 additions & 1 deletion test/cli/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdir, rm, writeFile } from 'fs/promises';
import { mkdir, readFile, rm, writeFile } from 'fs/promises';
import { join, resolve } from 'path';
import { existsSync, mkdirSync } from 'fs';
import { execFileSync } from 'child_process';
Expand All @@ -23,6 +23,7 @@ import {
import type { DoctorCheck } from '@bradygaster/squad-cli/commands/doctor';
import { FSStorageProvider, OrphanBranchBackend } from '@bradygaster/squad-sdk';
import { getPackageVersion } from '../../packages/squad-cli/src/cli/core/version.js';
import { ensureCastingRegistryPair } from '@bradygaster/squad-sdk/casting';

const TEST_ROOT = join(process.cwd(), `.test-doctor-${randomBytes(4).toString('hex')}`);

Expand Down Expand Up @@ -269,6 +270,22 @@ describe('squad doctor', () => {
.toMatchObject({ status: 'fail' });
});

it('accepts manifest-backed casting files converted to CRLF by Git (#2266)', async () => {
await scaffold(TEST_ROOT);
const castingDir = join(TEST_ROOT, '.squad', 'casting');
const registryRaw = await readFile(join(castingDir, 'registry.json'), 'utf8');
const historyRaw = await readFile(join(castingDir, 'history.json'), 'utf8');
ensureCastingRegistryPair(castingDir, registryRaw, historyRaw, 'doctor CRLF test');
for (const name of ['registry.json', 'history.json', 'registry-history.commit.json']) {
const filePath = join(castingDir, name);
await writeFile(filePath, (await readFile(filePath, 'utf8')).replace(/\r?\n/g, '\r\n'));
}

const checks = await runDoctor(TEST_ROOT);
expect(checks.find(check => check.name === 'casting/registry.json exists'))
.toMatchObject({ status: 'pass' });
}, 15_000);

it('detects remote mode from config.json with teamRoot', async () => {
await scaffold(TEST_ROOT);
const configPath = join(TEST_ROOT, '.squad', 'config.json');
Expand Down
1 change: 1 addition & 0 deletions test/cli/init-upgrade-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const EXPECTED_GITATTRIBUTES_RULES = [
'.squad/agents/*/history.md merge=union',
'.squad/log/** merge=union',
'.squad/orchestration-log/** merge=union',
'.squad/casting/*.json text eol=lf',
];

/**
Expand Down
1 change: 1 addition & 0 deletions test/cli/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ describe('CLI: init command', () => {
const content = await readFile(gitattributesPath, 'utf-8');
expect(content).toContain('.squad/decisions.md merge=union');
expect(content).toContain('.squad/orchestration-log/** merge=union');
expect(content).toContain('.squad/casting/*.json text eol=lf');
});

it('should append to .gitignore with runtime state exclusions', async () => {
Expand Down
Loading
Loading