Skip to content

Commit 0aeb071

Browse files
fix: tolerate CRLF casting manifests (#2269)
* fix: tolerate CRLF casting manifests Normalize CRLF worktree content when verifying casting hashes and pin casting JSON files to LF during init and upgrade. Closes #2266 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 58833b4b-f8e7-42b8-bf04-9121f362f68c * fix(casting): make CRLF normalization atomic Hold the shared casting writer lock across recovery, validation, reread, and LF normalization. Replace each JSON file through a flushed sibling temporary file and atomic rename. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: B'Elanna (Squad) <tamirdresher@users.noreply.github.com> Copilot-Session: 58833b4b-f8e7-42b8-bf04-9121f362f68c Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9
1 parent a87eee0 commit 0aeb071

12 files changed

Lines changed: 199 additions & 19 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@bradygaster/squad-sdk": patch
3+
"@bradygaster/squad-cli": patch
4+
---
5+
6+
Keep manifest-backed casting registries valid across Windows CRLF checkouts and normalize casting JSON to LF during upgrade.

‎.gitattributes‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
# universe/roles, persistent name registry, universe assignment history).
99
# They are committed to 'main' like team.md and routing.md, not to squad-state.
1010
# They do NOT need union merge and are intentionally NOT listed above.
11+
# Their commit manifest hashes canonical LF content, so Windows checkouts must
12+
# not rewrite the authoritative JSON files to CRLF.
13+
.squad/casting/*.json text eol=lf
1114

1215
# Squad: union merge for append-only team state files
1316
.squad/decisions/decisions.md merge=union

‎packages/squad-cli/src/cli/core/upgrade.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { clearResolveSquadCache, isRetiredProfileTeamRoot } from '@bradygaster/s
1212
import {
1313
backfillCastingRegistryFromTeam,
1414
ensureCastingRegistryPair,
15+
normalizeCastingJsonLineEndings as normalizeCastingJsonFiles,
1516
preflightCastingRegistryPair,
1617
repairHandwrittenCastingRegistry,
1718
type RepairResult,
@@ -678,6 +679,7 @@ const GITATTRIBUTES_RULES = [
678679
'.squad/agents/*/history.md merge=union',
679680
'.squad/log/** merge=union',
680681
'.squad/orchestration-log/** merge=union',
682+
'.squad/casting/*.json text eol=lf',
681683
];
682684

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

754+
export function normalizeCastingJsonLineEndings(dest: string): string[] {
755+
const castingDir = path.join(dest, '.squad', 'casting');
756+
return normalizeCastingJsonFiles(castingDir)
757+
.map(name => `.squad/casting/${name}`);
758+
}
759+
752760
/**
753761
* Check whether an existing gitignore line already covers a candidate entry
754762
* via parent-directory matching (e.g. `.squad/` covers `.squad/log/`).
@@ -1173,6 +1181,11 @@ async function runEnsureChecks(dest: string, templatesDir: string, filesUpdated:
11731181
success(`scaffolded ${castingFiles.length} default casting files`);
11741182
filesUpdated.push(...castingFiles);
11751183
}
1184+
const normalizedCasting = normalizeCastingJsonLineEndings(dest);
1185+
if (normalizedCasting.length > 0) {
1186+
success(`normalized ${normalizedCasting.length} casting JSON file(s) to LF`);
1187+
filesUpdated.push(...normalizedCasting);
1188+
}
11761189

11771190
const memoryFiles = ensureMemoryGovernanceUpgradeDefaults(dest);
11781191
if (memoryFiles.length > 0) {

‎packages/squad-sdk/src/casting/durable-registry.ts‎

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ export type CastingDurabilityBoundary =
6060
| 'registry:file-fsync'
6161
| 'registry:rename'
6262
| 'registry:parent-fsync'
63+
| 'normalization:write'
64+
| 'normalization:file-fsync'
65+
| 'normalization:rename'
66+
| 'normalization:parent-fsync'
6367
| 'cleanup:journal-unlink'
6468
| 'cleanup:journal-parent-fsync'
6569
| 'cleanup:payload-remove'
@@ -226,7 +230,7 @@ function writeDurableFile(
226230
function durableReplace(
227231
filePath: string,
228232
content: string,
229-
stage: 'journal' | 'history' | 'manifest' | 'registry',
233+
stage: 'journal' | 'history' | 'manifest' | 'registry' | 'normalization',
230234
transactionId: string,
231235
): void {
232236
const tempPath = `${filePath}.tmp-${transactionId}-${randomUUID()}`;
@@ -253,6 +257,12 @@ function sha256(content: string | undefined): string | null {
253257
: createHash('sha256').update(content, 'utf8').digest('hex');
254258
}
255259

260+
/** Treat Git's CRLF worktree conversion as byte-equivalent to canonical LF JSON. */
261+
function matchesSha256(content: string | undefined, expected: string | null): boolean {
262+
if (sha256(content) === expected) return true;
263+
return content !== undefined && sha256(content.replace(/\r\n/g, '\n')) === expected;
264+
}
265+
256266
function parseObject(raw: string | undefined, label: string): Record<string, unknown> | undefined {
257267
if (raw === undefined) return undefined;
258268
try {
@@ -1127,9 +1137,8 @@ function expectedState(
11271137
nextHash: string,
11281138
label: string,
11291139
): 'previous' | 'next' {
1130-
const actual = sha256(raw);
1131-
if (actual === previousHash) return 'previous';
1132-
if (actual === nextHash) return 'next';
1140+
if (matchesSha256(raw, previousHash)) return 'previous';
1141+
if (matchesSha256(raw, nextHash)) return 'next';
11331142
throw new Error(`Cannot recover casting transaction: ${label} changed outside the journal`);
11341143
}
11351144

@@ -1184,8 +1193,8 @@ export function recoverCastingRegistryTransaction(castingDir: string): void {
11841193
if (
11851194
nextRegistryRaw === undefined
11861195
|| nextHistoryRaw === undefined
1187-
|| sha256(nextRegistryRaw) !== journal.registry.next_sha256
1188-
|| sha256(nextHistoryRaw) !== journal.history.next_sha256
1196+
|| !matchesSha256(nextRegistryRaw, journal.registry.next_sha256)
1197+
|| !matchesSha256(nextHistoryRaw, journal.history.next_sha256)
11891198
) {
11901199
throw new Error('Cannot recover casting transaction: durable payload is missing or corrupt');
11911200
}
@@ -1197,7 +1206,7 @@ export function recoverCastingRegistryTransaction(castingDir: string): void {
11971206
history_sha256: journal.history.next_sha256,
11981207
};
11991208
const nextManifestRaw = JSON.stringify(nextManifest, null, 2) + '\n';
1200-
if (sha256(nextManifestRaw) !== journal.manifest.next_sha256) {
1209+
if (!matchesSha256(nextManifestRaw, journal.manifest.next_sha256)) {
12011210
throw new Error('Cannot recover casting transaction: manifest hash is corrupt');
12021211
}
12031212

@@ -1725,6 +1734,40 @@ export function preflightCastingRegistryPair(castingDir: string, dryRun = false)
17251734
}
17261735
}
17271736

1737+
/**
1738+
* Normalize casting JSON to LF while holding the shared writer lock.
1739+
* Each live file is replaced atomically after recovery and pair validation.
1740+
*/
1741+
export function normalizeCastingJsonLineEndings(castingDir: string): string[] {
1742+
if (!existsSync(castingDir)) return [];
1743+
const release = acquireCastingRegistryLock(castingDir, 'casting JSON line-ending normalization');
1744+
try {
1745+
recoverCastingRegistryTransaction(castingDir);
1746+
readCastingRegistryPair(castingDir);
1747+
1748+
const normalized: string[] = [];
1749+
const transactionId = randomUUID();
1750+
const entries = readdirSync(castingDir, { withFileTypes: true })
1751+
.filter(entry => entry.isFile() && entry.name.endsWith('.json'))
1752+
.sort((left, right) => left.name.localeCompare(right.name));
1753+
for (const entry of entries) {
1754+
const filePath = path.join(castingDir, entry.name);
1755+
const content = readText(filePath);
1756+
if (content === undefined || !content.includes('\r\n')) continue;
1757+
durableReplace(
1758+
filePath,
1759+
content.replace(/\r\n/g, '\n'),
1760+
'normalization',
1761+
transactionId,
1762+
);
1763+
normalized.push(entry.name);
1764+
}
1765+
return normalized;
1766+
} finally {
1767+
release();
1768+
}
1769+
}
1770+
17281771
/**
17291772
* Create a missing pair or migrate a valid generationless legacy pair while
17301773
* the caller holds the shared casting writer lock.
@@ -1857,8 +1900,8 @@ export function validateCastingRegistryPairRaw(
18571900
|| historyTransaction !== manifest.transaction_id
18581901
|| historyRevision !== manifest.registry_revision
18591902
|| revision !== manifest.registry_revision
1860-
|| sha256(registryRaw) !== manifest.registry_sha256
1861-
|| sha256(historyRaw) !== manifest.history_sha256
1903+
|| !matchesSha256(registryRaw, manifest.registry_sha256)
1904+
|| !matchesSha256(historyRaw, manifest.history_sha256)
18621905
) {
18631906
throw new Error(
18641907
'Cannot read a consistent casting registry/history pair: registry/history bytes do not match the stable commit manifest',
@@ -1927,8 +1970,8 @@ export function readCastingRegistryPair(
19271970
&& historyTransaction === manifest.transaction_id
19281971
&& historyRevision === manifest.registry_revision
19291972
&& revision === manifest.registry_revision
1930-
&& sha256(registryRaw) === manifest.registry_sha256
1931-
&& sha256(historyRaw) === manifest.history_sha256
1973+
&& matchesSha256(registryRaw, manifest.registry_sha256)
1974+
&& matchesSha256(historyRaw, manifest.history_sha256)
19321975
) {
19331976
return {
19341977
registryRaw,

‎packages/squad-sdk/src/casting/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export {
6060
commitCastingRegistryPair,
6161
ensureCastingRegistryPair,
6262
ensureCastingRegistryPairLocked,
63+
normalizeCastingJsonLineEndings,
6364
prepareCastingRegistryPairLocked,
6465
preflightCastingRegistryPair,
6566
readCastingRegistryPair,

‎packages/squad-sdk/src/config/init.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,31 +1368,31 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre
13681368
}
13691369

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

13771376
const gitattributesPath = join(teamRoot, '.gitattributes');
1378-
const unionRules = [
1377+
const gitattributesRules = [
13791378
'.squad/decisions.md merge=union',
13801379
'.squad/agents/*/history.md merge=union',
13811380
'.squad/log/** merge=union',
13821381
'.squad/orchestration-log/** merge=union',
13831382
'.squad/rai/audit-trail.md merge=union',
13841383
'.squad/fact-checker/audit-trail.md merge=union',
1384+
'.squad/casting/*.json text eol=lf',
13851385
];
13861386

13871387
let existingAttrs = '';
13881388
if (storage.existsSync(gitattributesPath)) {
13891389
existingAttrs = storage.readSync(gitattributesPath) ?? '';
13901390
}
13911391

1392-
const missingRules = unionRules.filter(rule => !existingAttrs.includes(rule));
1392+
const missingRules = gitattributesRules.filter(rule => !existingAttrs.includes(rule));
13931393
if (missingRules.length > 0) {
13941394
const block = (existingAttrs && !existingAttrs.endsWith('\n') ? '\n' : '')
1395-
+ '# Squad: union merge for append-only team state files\n'
1395+
+ '# Squad: merge and line-ending rules for team state files\n'
13961396
+ missingRules.join('\n') + '\n';
13971397
await storage.append(gitattributesPath, block);
13981398
createdFiles.push(toRelativePath(gitattributesPath));

‎test/casting-durable-registry.test.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ import {
1919
CastingCommitInDoubtError,
2020
commitCastingRegistryPair,
2121
ensureCastingRegistryPair,
22+
normalizeCastingJsonLineEndings,
23+
preflightCastingRegistryPair,
2224
readCastingRegistryPair,
2325
recoverCastingRegistryTransaction,
26+
validateCastingRegistryPairRaw,
2427
type CastingDurabilityBoundary,
2528
} from '../packages/squad-sdk/src/casting/durable-registry.js';
2629

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

815+
it('accepts a stable manifest when Git converts all casting JSON files to CRLF (#2266)', () => {
816+
const castingDir = temporaryCastingDir('crlf-manifest');
817+
const old = initialPair();
818+
writeFileSync(join(castingDir, 'registry.json'), old.registryRaw);
819+
writeFileSync(join(castingDir, 'history.json'), old.historyRaw);
820+
commitCastingRegistryPair(
821+
castingDir,
822+
old.registryRaw,
823+
{ ...old.registry, revision: 2 },
824+
old.historyRaw,
825+
{ ...old.history, assignment_cast_snapshots: { next: historySnapshot() } },
826+
2,
827+
);
828+
829+
for (const name of ['registry.json', 'history.json', 'registry-history.commit.json']) {
830+
const filePath = join(castingDir, name);
831+
writeFileSync(filePath, readFileSync(filePath, 'utf8').replace(/\r?\n/g, '\r\n'));
832+
}
833+
834+
const registryRaw = readFileSync(join(castingDir, 'registry.json'), 'utf8');
835+
const historyRaw = readFileSync(join(castingDir, 'history.json'), 'utf8');
836+
const manifestRaw = readFileSync(join(castingDir, 'registry-history.commit.json'), 'utf8');
837+
expect(() => validateCastingRegistryPairRaw(
838+
registryRaw,
839+
historyRaw,
840+
undefined,
841+
manifestRaw,
842+
)).not.toThrow();
843+
expect(preflightCastingRegistryPair(castingDir)).toBe(false);
844+
expect(readCastingRegistryPair(castingDir, 1)).toMatchObject({
845+
registry: { revision: 2 },
846+
history: { registry_revision: 2 },
847+
});
848+
});
849+
850+
it('holds the writer lock and leaves the live file intact when normalization fails before rename', () => {
851+
const castingDir = temporaryCastingDir('normalization-atomic');
852+
const old = initialPair();
853+
writeFileSync(join(castingDir, 'registry.json'), old.registryRaw);
854+
writeFileSync(join(castingDir, 'history.json'), old.historyRaw);
855+
commitCastingRegistryPair(
856+
castingDir,
857+
old.registryRaw,
858+
{ ...old.registry, revision: 2 },
859+
old.historyRaw,
860+
{ ...old.history, assignment_cast_snapshots: { next: historySnapshot() } },
861+
2,
862+
);
863+
const policyPath = join(castingDir, 'policy.json');
864+
const crlfPolicy = '{\r\n "version": 1\r\n}\r\n';
865+
writeFileSync(policyPath, crlfPolicy);
866+
867+
let observedLock = false;
868+
_setCastingDurabilityHooksForTesting({
869+
boundary: ({ boundary, path }) => {
870+
if (boundary === 'normalization:rename' && path === policyPath) {
871+
observedLock = existsSync(join(castingDir, 'registry.lock'));
872+
throw new Error('injected normalization failure');
873+
}
874+
},
875+
});
876+
877+
expect(() => normalizeCastingJsonLineEndings(castingDir))
878+
.toThrow(/injected normalization failure/);
879+
expect(observedLock).toBe(true);
880+
expect(readFileSync(policyPath, 'utf8')).toBe(crlfPolicy);
881+
expect(readdirSync(castingDir).some(name => name.includes('.tmp-'))).toBe(false);
882+
expect(existsSync(join(castingDir, 'registry.lock'))).toBe(false);
883+
});
884+
812885
it('fails before journal publication when an external writer mutates the pair', () => {
813886
const castingDir = temporaryCastingDir('external-mutation');
814887
const old = initialPair();

‎test/cli/doctor.test.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

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

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

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

273+
it('accepts manifest-backed casting files converted to CRLF by Git (#2266)', async () => {
274+
await scaffold(TEST_ROOT);
275+
const castingDir = join(TEST_ROOT, '.squad', 'casting');
276+
const registryRaw = await readFile(join(castingDir, 'registry.json'), 'utf8');
277+
const historyRaw = await readFile(join(castingDir, 'history.json'), 'utf8');
278+
ensureCastingRegistryPair(castingDir, registryRaw, historyRaw, 'doctor CRLF test');
279+
for (const name of ['registry.json', 'history.json', 'registry-history.commit.json']) {
280+
const filePath = join(castingDir, name);
281+
await writeFile(filePath, (await readFile(filePath, 'utf8')).replace(/\r?\n/g, '\r\n'));
282+
}
283+
284+
const checks = await runDoctor(TEST_ROOT);
285+
expect(checks.find(check => check.name === 'casting/registry.json exists'))
286+
.toMatchObject({ status: 'pass' });
287+
}, 15_000);
288+
272289
it('detects remote mode from config.json with teamRoot', async () => {
273290
await scaffold(TEST_ROOT);
274291
const configPath = join(TEST_ROOT, '.squad', 'config.json');

‎test/cli/init-upgrade-parity.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const EXPECTED_GITATTRIBUTES_RULES = [
5353
'.squad/agents/*/history.md merge=union',
5454
'.squad/log/** merge=union',
5555
'.squad/orchestration-log/** merge=union',
56+
'.squad/casting/*.json text eol=lf',
5657
];
5758

5859
/**

‎test/cli/init.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ describe('CLI: init command', () => {
272272
const content = await readFile(gitattributesPath, 'utf-8');
273273
expect(content).toContain('.squad/decisions.md merge=union');
274274
expect(content).toContain('.squad/orchestration-log/** merge=union');
275+
expect(content).toContain('.squad/casting/*.json text eol=lf');
275276
});
276277

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

0 commit comments

Comments
 (0)