* feat(#3243): sync installed codex .toml model/effort to the passive posture Implements ADR-2313 D7, and owns the Codex .toml typed IR that Phase 1's review assigned to this phase. The IR exists for a structural reason, not tidiness: this phase has to PARSE these files, and a parser kept bug-compatible with a separate renderer is the generative-fix-divergence shape this epic already dealt with once for the model predicate. So Phase 2's parsing MOVES here rather than being copied — agent-install-check now imports it, and its test file passing unchanged is the proof the extraction altered nothing. The load-bearing property is byte-identical round-trip: render(parse(x)) === x. Without it a sync silently reformats a user's file — line endings, key order, BOM, trailing newline — turning a two-line repair into a whole-file diff in their dotfile repo. The IR keeps original lines and removes targeted ones rather than reconstructing from parsed fields, which is what makes that property hold. It also reconciles a real contradiction between Phase 2 and ADR-2313. An unterminated developer_instructions block: the reader excludes the rest of the file, deliberately failing toward a false positive, because misreading prose as a pin only wastes a user's time. The writer must refuse, because proceeding on a malformed document rewrites it. A false positive is the safe direction for a reader and the dangerous one for a writer. So the parse reports the fact and the two consumers branch on it — one parse, one truth, two policies, instead of two parsers that agree today. The sync leaves a legal real-Codex pin and its coupled effort untouched, reported skipped rather than synced; strips a stale Anthropic or tier model and an orphaned effort; keeps dry-run as the default; refuses any file whose parse fails; and skips symlinks exactly as the Claude path already did. The Claude path itself is byte-identical. PARSE_REASON.NO_HEADER from the ADR's illustrative snippet is deliberately not implemented — a missing header is legal, not an error, so it would be a dead enum member that the enum-lock test then pins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3243): preserve per-line endings and make the codex write atomic Two findings from an isolated review, both in the write path. BLOCKER: mixed line endings broke the byte-identical round-trip. `eol` was a single whole-file flag and split(/\r?\n/) discarded each line's own terminator, so render re-joined with ONE style and normalized every line — even with zero strips performed. A file with one CRLF line and the rest LF came back fully converted. That falsified the A14 guarantee, violated the design's "must not silently rewrite every line", and made the CONTEXT.md glossary claim wrong. It was untested because A12 and B15 only cover PURE CRLF; no mixed-ending fixture existed anywhere. Fixed by keeping each line's terminator alongside its content, so render is a plain concatenation and a strip removes only the target line and its own terminator. `eol` survives as informational metadata that render never reads. Seven fixtures added for the paths nothing exercised: mixed endings unmodified and with a strip, a lone \r, a file ending on the block's closing ''' with no newline, multiple trailing newlines, a BOM-only file, and an empty file. MINOR, but it contradicted this phase's own contract: the write was in-place open-truncate, so a failure between truncate and completion leaves a truncated .toml — exactly what ADR-2313 says must never happen. The Codex path now writes a sibling temp file and renames over the target, which is atomic on one filesystem, with cleanup on failure. It uses the repo's existing retryRenameSync rather than a hand-rolled rename, and deliberately NOT platformWriteSync, whose normalizeContent would mangle the very CRLF and trailing-newline bytes the round-trip property exists to preserve. The Claude path keeps its in-place write untouched. It has the same shape, but changing it is not this phase's business and its tests must stay byte-identical. B20 previously mocked writeFileSync to throw BEFORE touching anything, so it proved nothing about a mid-write failure — its passing comment was true only because of how the mock was built. It now performs a real truncated write wherever writeFileSync is called, catching both the naive direct-to-target path and the new temp path, and asserts the target is byte-identical afterwards with no stray temp file left. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3243): preserve the trailing-newline state when stripping a last line Caught by B17, one of this phase's own tests — the suite working, not a test problem. Content is reconstructed as the concatenation of lines[k] + terminators[k], so a file with no trailing newline has '' as its last terminator. removeLine spliced out both arrays at the same index, which is right for a middle line but wrong for the last one: it dropped the empty terminator and left the PREVIOUS line's newline in place. A file ending `...\nmodel = "sonnet"` with no trailing newline came back as `...\n`, gaining a newline the user never wrote. The new last line now inherits the removed line's terminator, so a removal leaves the file exactly as if that line had never been written. Removing the only line yields an empty file rather than a stray terminator. Both stripModel and stripReasoningEffort funnel through the one removeLine, confirmed rather than assumed, so a single fix covers both — including the row-B7 shape where a stale model and its orphaned effort are removed in sequence and the second removal targets the last line. Two of the four new cases are honestly not red-first and say so in their comments: removing a last line that HAS a trailing newline only exposes the bug under mixed EOL, since uniform files coincidentally have equal terminators on both sides; and removing the only line already degenerated correctly through Array.slice. They are kept as guards for the new branch rather than dressed up as catches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3243): document the codex repair path and close the loop How-to: the Codex-400 entry added in Phase 2 told users to re-run the installer, because that was the only repair available then. It now leads with `effort sync` and keeps the reinstall as the alternative, with the reason to prefer one — a reinstall regenerates the agent files wholesale, so anyone who hand-edited theirs loses those edits. Detect, preview, apply is now one continuous path in one place. Reference: docs/COMMANDS.md had no `effort sync` entry at all — the same gap `validate agents` had in Phase 2, found the same way. The entry documents BOTH runtimes, because the command genuinely forks on runtime and describing only the new half would misdescribe it. The write flag is `--apply`. The design doc and test matrix both said `--no-dry-run` throughout, which does not exist — verified against the actual arg parser in gsd-tools.cjs before writing. Documenting a flag that does not exist is worse than documenting nothing, because it fails at the moment someone needs it. Both surfaces state that only the targeted lines are removed and every other byte is preserved. That is a user-visible guarantee rather than an implementation note: it is the difference between a two-line diff and a reformatted file in someone's dotfile repo, it is what the IR's round-trip property exists to deliver, and writing it down makes it a contract a future change has to break knowingly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3243): inherit the trailing-newline state, not the line ending style My previous rule was subtly wrong and this phase's own test caught it. "The new last line inherits the removed line's terminator" copies the removed line's STYLE as well as its presence. A26 uses mixed endings on purpose — line one terminated \r\n, the model line terminated \n — so inheriting silently rewrote line one's ending to \n. That is precisely the defect class the mixed-EOL blocker fix existed to eliminate, reintroduced one layer down by the fix for it. The correct rule inherits the EMPTINESS only. If the removed line had no terminator, the new last line loses its own, preserving "this file has no trailing newline". Otherwise the new last line keeps its own terminator: it is already a newline, and already the right style for that line. A26's assertion moved too, and that deserves saying plainly rather than burying: it previously encoded my wrong rule. Changing a test to match the implementation is usually the mistake, so it was checked from first principles instead — a file whose first line ends \r\n and whose last line ends \n, with that last line removed entirely, must be the first line with its own \r\n intact. The new expectation is what the user's file should actually look like; the old one was wrong. A29 adds the interaction nothing covered: the compounding case (strip a stale model, then its orphaned effort, the second removal landing on the last line) with non-uniform endings either side. The two fixes meet there and nothing exercised the meeting point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3243): drop the phantom trailing line from the IR representation Root cause, not another patch on the removal rule. Three consecutive fixes there each surfaced the next issue, which was the signal that the data model was wrong. splitPreservingTerminators left a phantom empty final entry for any file ending in a newline: "a\nb\n" became lines ['a','b','']. So for the common case the real last content line was NOT the last array element, removeLine's isLastLine check never matched it, and every rule I gave was reasoning about the wrong element. What hid it: render was already a plain concatenation, so a phantom empty line with an empty terminator contributes nothing to the output. A14's byte-identical round-trip could never have caught it — the defect is byte-neutral until a removal shifts the index arithmetic under it. That is worth recording, because "the round-trip test is green" was exactly the reassurance that kept the search pointed elsewhere. The representation is now 1:1 — terminators[i] follows lines[i] and may be '' — with no phantom, verified across empty, no-trailing-newline, trailing-newline, blank-line and mixed-CRLF inputs. render stays a plain concat and needs no special cases. With the phantom gone the removal rule is correct as stated and finally applies to the genuinely last element. Consumers checked rather than assumed: the block-range detector and header scanner are agnostic to array shape, and Phase 2's reader uses its own independent split, so tests/agent-install-check.test.cjs is untouched and still passes unchanged. One test expectation was wrong and is corrected rather than quietly adjusted: A18 asserted a 7-element terminators array whose trailing '' was the phantom itself. It now asserts the six real terminators, which is what the invariant lines.length === terminators.length requires. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3243): backfill changeset pr number (#3296) --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3704,6 +3704,459 @@ describe('feat-488: effort sync command', () => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// #3243 (ADR-2313 D7) — the Codex `.toml` branch of `cmdEffortSync`.
|
||||
// Spec: .gsd/phase/feat-3243-codex-toml-sync/{40-design,50-test-matrix}.md
|
||||
// Row numbers below (# B<N>) map 1:1 to 50-test-matrix.md's "B — the sync"
|
||||
// table. B1/B2 (the claude/opencode rows) are the EXISTING tests directly
|
||||
// above this block ('feat-488: effort sync command' + the non-claude-runtime
|
||||
// test) and are asserted UNCHANGED — this describe adds only new coverage.
|
||||
//
|
||||
// Every `.toml` fixture below is hand-authored against the real
|
||||
// `generateCodexAgentToml` shape (CONTRIBUTING's fixture-provenance rule,
|
||||
// #2371), not produced by calling that writer.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
describe('#3243 (ADR-2313 D7): Codex .toml effort sync', () => {
|
||||
const { PARSE_REASON } = require('../gsd-core/bin/lib/codex-agent-toml.cjs');
|
||||
const FIXTURES_DIR = path.join(__dirname, 'fixtures', 'adversarial', 'toml');
|
||||
|
||||
function writeCodexAgentToml(agentsDir, agentName, content) {
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(agentsDir, `${agentName}.toml`), content);
|
||||
}
|
||||
|
||||
function syncCodex(tmpDir, dryRun) {
|
||||
const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs');
|
||||
return captureOutput(() =>
|
||||
cmdEffortSync(tmpDir, false, { dryRun, configDir: tmpDir, runtime: 'codex' })
|
||||
);
|
||||
}
|
||||
|
||||
test('B3: model = "sonnet" — the model line stripped; one structured change', () => {
|
||||
const tmpDir = makeTmpDir('codex-sync-b3-');
|
||||
const agentsDir = makeAgentsDir(tmpDir);
|
||||
writeCodexAgentToml(
|
||||
agentsDir,
|
||||
'gsd-planner',
|
||||
'name = "gsd-planner"\nmodel = "sonnet"\ndeveloper_instructions = \'\'\'\nPlan.\n\'\'\'\n',
|
||||
);
|
||||
|
||||
const result = syncCodex(tmpDir, false);
|
||||
|
||||
assert.equal(result.synced, 1);
|
||||
assert.equal(result.skipped, 0);
|
||||
assert.equal(result.changes.length, 1);
|
||||
assert.equal(result.changes[0].agent, 'gsd-planner');
|
||||
assert.equal(result.changes[0].field, 'model');
|
||||
assert.equal(result.changes[0].from, 'sonnet');
|
||||
assert.equal(result.changes[0].to, null);
|
||||
const updated = fs.readFileSync(path.join(agentsDir, 'gsd-planner.toml'), 'utf8');
|
||||
assert.ok(!updated.includes('model = "sonnet"'), 'the stale model line must be gone');
|
||||
assert.ok(updated.includes('name = "gsd-planner"'), 'other lines must survive');
|
||||
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('B4 (negative proof): model = "gpt-5.6-sol" (legal pin) — untouched, reported skipped not synced', () => {
|
||||
const tmpDir = makeTmpDir('codex-sync-b4-');
|
||||
const agentsDir = makeAgentsDir(tmpDir);
|
||||
const content = 'name = "gsd-gpt-agent"\nmodel = "gpt-5.6-sol"\ndeveloper_instructions = \'\'\'\nWork.\n\'\'\'\n';
|
||||
writeCodexAgentToml(agentsDir, 'gsd-gpt-agent', content);
|
||||
|
||||
const result = syncCodex(tmpDir, false);
|
||||
|
||||
assert.equal(result.synced, 0, 'a legal pin must never be reported synced');
|
||||
assert.equal(result.skipped, 1);
|
||||
assert.equal(result.changes.length, 0);
|
||||
assert.equal(fs.readFileSync(path.join(agentsDir, 'gsd-gpt-agent.toml'), 'utf8'), content);
|
||||
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('B5 (negative proof): legal pin plus its coupled effort — both untouched', () => {
|
||||
const tmpDir = makeTmpDir('codex-sync-b5-');
|
||||
const agentsDir = makeAgentsDir(tmpDir);
|
||||
const content = 'name = "gsd-pinned-agent"\nmodel = "gpt-5-codex"\nmodel_reasoning_effort = "high"\n' +
|
||||
"developer_instructions = '''\nWork.\n'''\n";
|
||||
writeCodexAgentToml(agentsDir, 'gsd-pinned-agent', content);
|
||||
|
||||
const result = syncCodex(tmpDir, false);
|
||||
|
||||
assert.equal(result.synced, 0);
|
||||
assert.equal(result.skipped, 1);
|
||||
assert.equal(fs.readFileSync(path.join(agentsDir, 'gsd-pinned-agent.toml'), 'utf8'), content);
|
||||
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('B6: orphaned model_reasoning_effort, no model — the effort stripped', () => {
|
||||
const tmpDir = makeTmpDir('codex-sync-b6-');
|
||||
const agentsDir = makeAgentsDir(tmpDir);
|
||||
writeCodexAgentToml(
|
||||
agentsDir,
|
||||
'gsd-orphan-agent',
|
||||
'name = "gsd-orphan-agent"\nmodel_reasoning_effort = "high"\ndeveloper_instructions = \'\'\'\nWork.\n\'\'\'\n',
|
||||
);
|
||||
|
||||
const result = syncCodex(tmpDir, false);
|
||||
|
||||
assert.equal(result.synced, 1);
|
||||
assert.equal(result.changes.length, 1);
|
||||
assert.equal(result.changes[0].field, 'model_reasoning_effort');
|
||||
assert.equal(result.changes[0].from, 'high');
|
||||
const updated = fs.readFileSync(path.join(agentsDir, 'gsd-orphan-agent.toml'), 'utf8');
|
||||
assert.ok(!updated.includes('model_reasoning_effort'), 'the orphaned effort must be gone');
|
||||
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('B7: stale model plus its effort — both stripped', () => {
|
||||
const tmpDir = makeTmpDir('codex-sync-b7-');
|
||||
const agentsDir = makeAgentsDir(tmpDir);
|
||||
writeCodexAgentToml(
|
||||
agentsDir,
|
||||
'gsd-stale-agent',
|
||||
'name = "gsd-stale-agent"\nmodel = "opus"\nmodel_reasoning_effort = "medium"\n' +
|
||||
"developer_instructions = '''\nWork.\n'''\n",
|
||||
);
|
||||
|
||||
const result = syncCodex(tmpDir, false);
|
||||
|
||||
assert.equal(result.synced, 1);
|
||||
assert.equal(result.changes.length, 2, 'both the model and the coupled effort must be reported');
|
||||
const fields = result.changes.map(c => c.field).sort();
|
||||
assert.deepEqual(fields, ['model', 'model_reasoning_effort']);
|
||||
const updated = fs.readFileSync(path.join(agentsDir, 'gsd-stale-agent.toml'), 'utf8');
|
||||
assert.ok(!updated.includes('model = "opus"'));
|
||||
assert.ok(!updated.includes('model_reasoning_effort'));
|
||||
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('B8: posture-clean .toml — synced:0, no write (mtime unchanged)', () => {
|
||||
const tmpDir = makeTmpDir('codex-sync-b8-');
|
||||
const agentsDir = makeAgentsDir(tmpDir);
|
||||
writeCodexAgentToml(
|
||||
agentsDir,
|
||||
'gsd-clean',
|
||||
'name = "gsd-clean"\ndeveloper_instructions = \'\'\'\nWork.\n\'\'\'\n',
|
||||
);
|
||||
const filePath = path.join(agentsDir, 'gsd-clean.toml');
|
||||
const mtimeBefore = fs.statSync(filePath).mtimeMs;
|
||||
|
||||
const result = syncCodex(tmpDir, false);
|
||||
|
||||
assert.equal(result.synced, 0);
|
||||
assert.equal(fs.statSync(filePath).mtimeMs, mtimeBefore, 'a posture-clean file must never be written');
|
||||
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('B9 (boundary): dry-run is the default — changes reported, file byte-identical after', () => {
|
||||
const tmpDir = makeTmpDir('codex-sync-b9-');
|
||||
const agentsDir = makeAgentsDir(tmpDir);
|
||||
const content = 'name = "gsd-planner"\nmodel = "sonnet"\ndeveloper_instructions = \'\'\'\nPlan.\n\'\'\'\n';
|
||||
writeCodexAgentToml(agentsDir, 'gsd-planner', content);
|
||||
|
||||
const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs');
|
||||
const result = captureOutput(() =>
|
||||
cmdEffortSync(tmpDir, false, { configDir: tmpDir, runtime: 'codex' }) // no dryRun key — must default true
|
||||
);
|
||||
|
||||
assert.equal(result.dry_run, true);
|
||||
assert.equal(result.synced, 1, 'the pending strip must still be reported');
|
||||
assert.equal(fs.readFileSync(path.join(agentsDir, 'gsd-planner.toml'), 'utf8'), content, 'dry-run must not write');
|
||||
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('B10: --no-dry-run writes; reports identically to the dry run', () => {
|
||||
const content = 'name = "gsd-planner"\nmodel = "sonnet"\ndeveloper_instructions = \'\'\'\nPlan.\n\'\'\'\n';
|
||||
|
||||
const dryTmpDir = makeTmpDir('codex-sync-b10-dry-');
|
||||
writeCodexAgentToml(makeAgentsDir(dryTmpDir), 'gsd-planner', content);
|
||||
const dryResult = syncCodex(dryTmpDir, true);
|
||||
|
||||
const applyTmpDir = makeTmpDir('codex-sync-b10-apply-');
|
||||
const applyAgentsDir = makeAgentsDir(applyTmpDir);
|
||||
writeCodexAgentToml(applyAgentsDir, 'gsd-planner', content);
|
||||
const applyResult = syncCodex(applyTmpDir, false);
|
||||
|
||||
assert.equal(dryResult.synced, applyResult.synced);
|
||||
assert.deepEqual(dryResult.changes, applyResult.changes, 'the report must match the dry run exactly');
|
||||
assert.equal(dryResult.dry_run, true);
|
||||
assert.equal(applyResult.dry_run, false);
|
||||
assert.ok(!fs.readFileSync(path.join(applyAgentsDir, 'gsd-planner.toml'), 'utf8').includes('model = "sonnet"'));
|
||||
|
||||
cleanup(dryTmpDir);
|
||||
cleanup(applyTmpDir);
|
||||
});
|
||||
|
||||
test('B11 (negative proof): unterminated block — skipped and reported, file byte-identical after', () => {
|
||||
const tmpDir = makeTmpDir('codex-sync-b11-');
|
||||
const agentsDir = makeAgentsDir(tmpDir);
|
||||
const content = 'name = "gsd-broken"\nmodel = "sonnet"\ndeveloper_instructions = \'\'\'\nThis block never closes.\n';
|
||||
writeCodexAgentToml(agentsDir, 'gsd-broken', content);
|
||||
|
||||
const result = syncCodex(tmpDir, false);
|
||||
|
||||
assert.equal(result.synced, 0, 'an unparseable document must never be synced');
|
||||
assert.equal(result.skipped, 1);
|
||||
assert.equal(result.refused.length, 1);
|
||||
assert.equal(result.refused[0].agent, 'gsd-broken');
|
||||
assert.equal(result.refused[0].reason, PARSE_REASON.UNTERMINATED_BLOCK);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(agentsDir, 'gsd-broken.toml'), 'utf8'),
|
||||
content,
|
||||
'a refused file must never be partially rewritten',
|
||||
);
|
||||
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('B12 (negative proof): symlinked .toml — skipped, target byte-identical after', (t) => {
|
||||
const tmpDir = makeTmpDir('codex-sync-b12-');
|
||||
const agentsDir = makeAgentsDir(tmpDir);
|
||||
const targetPath = path.join(tmpDir, 'outside-target.toml');
|
||||
const targetContent = 'model = "sonnet"\n';
|
||||
fs.writeFileSync(targetPath, targetContent);
|
||||
const symlinkPath = path.join(agentsDir, 'gsd-linked.toml');
|
||||
try {
|
||||
fs.symlinkSync(targetPath, symlinkPath, 'file');
|
||||
} catch (error) {
|
||||
if (error && ['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) {
|
||||
t.skip('symlink creation is not available on this platform');
|
||||
cleanup(tmpDir);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = syncCodex(tmpDir, false);
|
||||
|
||||
assert.equal(result.synced, 0);
|
||||
assert.ok(
|
||||
!result.changes.some(c => c.agent === 'gsd-linked'),
|
||||
'a symlinked agent must never be reported as synced',
|
||||
);
|
||||
assert.equal(fs.readFileSync(targetPath, 'utf8'), targetContent, 'the symlink target must never be written through');
|
||||
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('B13: agents dir absent — reports not-found, as the claude path does', () => {
|
||||
const tmpDir = makeTmpDir('codex-sync-b13-');
|
||||
// agentsDir intentionally not created
|
||||
|
||||
const result = syncCodex(tmpDir, false);
|
||||
|
||||
assert.equal(result.synced, 0);
|
||||
assert.equal(result.reason, 'agents directory not found');
|
||||
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('B14 (hostile, headline data-loss case): model = inside developer_instructions — file byte-identical after a non-dry-run sync', () => {
|
||||
const tmpDir = makeTmpDir('codex-sync-b14-');
|
||||
const agentsDir = makeAgentsDir(tmpDir);
|
||||
const fixtureContent = fs.readFileSync(path.join(FIXTURES_DIR, 'model-in-developer-instructions.toml'), 'utf8');
|
||||
writeCodexAgentToml(agentsDir, 'gsd-planner', fixtureContent);
|
||||
|
||||
const result = syncCodex(tmpDir, false);
|
||||
|
||||
assert.equal(result.synced, 0, 'the prose model= inside the block must never be treated as a pin');
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(agentsDir, 'gsd-planner.toml'), 'utf8'),
|
||||
fixtureContent,
|
||||
'the agent prompt must survive a non-dry-run sync untouched',
|
||||
);
|
||||
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('B15 (cross-platform): CRLF file, stale pin — pin stripped, remaining line endings still CRLF', () => {
|
||||
const tmpDir = makeTmpDir('codex-sync-b15-');
|
||||
const agentsDir = makeAgentsDir(tmpDir);
|
||||
const lfContent = 'name = "gsd-scribe"\nmodel = "sonnet"\ndeveloper_instructions = \'\'\'\nWrite a changelog entry.\n\'\'\'\n';
|
||||
const crlfContent = lfContent.replace(/\n/g, '\r\n');
|
||||
writeCodexAgentToml(agentsDir, 'gsd-scribe', crlfContent);
|
||||
|
||||
const result = syncCodex(tmpDir, false);
|
||||
|
||||
assert.equal(result.synced, 1);
|
||||
const updated = fs.readFileSync(path.join(agentsDir, 'gsd-scribe.toml'), 'utf8');
|
||||
assert.ok(!updated.includes('model = "sonnet"'));
|
||||
assert.equal(
|
||||
updated,
|
||||
'name = "gsd-scribe"\r\ndeveloper_instructions = \'\'\'\r\nWrite a changelog entry.\r\n\'\'\'\r\n',
|
||||
'every remaining line ending must still be CRLF',
|
||||
);
|
||||
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('B16 (cross-platform): BOM file, stale pin — pin stripped, BOM preserved', () => {
|
||||
const tmpDir = makeTmpDir('codex-sync-b16-');
|
||||
const agentsDir = makeAgentsDir(tmpDir);
|
||||
const content = String.fromCharCode(0xfeff) +
|
||||
'name = "gsd-archivist"\nmodel = "sonnet"\ndeveloper_instructions = \'\'\'\nArchive.\n\'\'\'\n';
|
||||
writeCodexAgentToml(agentsDir, 'gsd-archivist', content);
|
||||
|
||||
const result = syncCodex(tmpDir, false);
|
||||
|
||||
assert.equal(result.synced, 1);
|
||||
const updatedRaw = fs.readFileSync(path.join(agentsDir, 'gsd-archivist.toml'));
|
||||
assert.equal(updatedRaw[0], 0xef, 'BOM byte 1 (EF) must survive');
|
||||
assert.equal(updatedRaw[1], 0xbb, 'BOM byte 2 (BB) must survive');
|
||||
assert.equal(updatedRaw[2], 0xbf, 'BOM byte 3 (BF) must survive');
|
||||
assert.ok(!updatedRaw.toString('utf8').includes('model = "sonnet"'));
|
||||
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('B17 (boundary): file with no trailing newline — preserved', () => {
|
||||
const tmpDir = makeTmpDir('codex-sync-b17-');
|
||||
const agentsDir = makeAgentsDir(tmpDir);
|
||||
const content = 'name = "gsd-bare"\nmodel = "sonnet"'; // deliberately no trailing \n
|
||||
writeCodexAgentToml(agentsDir, 'gsd-bare', content);
|
||||
|
||||
const result = syncCodex(tmpDir, false);
|
||||
|
||||
assert.equal(result.synced, 1);
|
||||
const updated = fs.readFileSync(path.join(agentsDir, 'gsd-bare.toml'), 'utf8');
|
||||
assert.equal(updated, 'name = "gsd-bare"', 'the file must still have no trailing newline');
|
||||
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('B18 (negative proof): hand-added approval_policy survives a strip of the stale pin untouched', () => {
|
||||
const tmpDir = makeTmpDir('codex-sync-b18-');
|
||||
const agentsDir = makeAgentsDir(tmpDir);
|
||||
writeCodexAgentToml(
|
||||
agentsDir,
|
||||
'gsd-custom-agent',
|
||||
'name = "gsd-custom-agent"\nmodel = "sonnet"\napproval_policy = "on-request"\n' +
|
||||
"developer_instructions = '''\nFollow policy.\n'''\n",
|
||||
);
|
||||
|
||||
const result = syncCodex(tmpDir, false);
|
||||
|
||||
assert.equal(result.synced, 1);
|
||||
const updated = fs.readFileSync(path.join(agentsDir, 'gsd-custom-agent.toml'), 'utf8');
|
||||
assert.ok(updated.includes('approval_policy = "on-request"'), 'the hand-added key must survive verbatim');
|
||||
assert.ok(!updated.includes('model = "sonnet"'));
|
||||
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('B19 (negative proof): interleaved comments preserved verbatim', () => {
|
||||
const tmpDir = makeTmpDir('codex-sync-b19-');
|
||||
const agentsDir = makeAgentsDir(tmpDir);
|
||||
writeCodexAgentToml(
|
||||
agentsDir,
|
||||
'gsd-commented',
|
||||
'# top comment\nname = "gsd-commented"\n# a note about the model below\nmodel = "sonnet"\n# trailing comment\n' +
|
||||
"developer_instructions = '''\nWork.\n'''\n",
|
||||
);
|
||||
|
||||
const result = syncCodex(tmpDir, false);
|
||||
|
||||
assert.equal(result.synced, 1);
|
||||
const updated = fs.readFileSync(path.join(agentsDir, 'gsd-commented.toml'), 'utf8');
|
||||
assert.ok(updated.includes('# top comment'));
|
||||
assert.ok(updated.includes('# a note about the model below'));
|
||||
assert.ok(updated.includes('# trailing comment'));
|
||||
assert.ok(!updated.includes('model = "sonnet"'));
|
||||
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('B20 (filesystem failure, atomic-write proof): a mid-write failure is reported; the remaining agents still get processed; the target is left byte-identical, never partially rewritten', (t) => {
|
||||
const tmpDir = makeTmpDir('codex-sync-b20-');
|
||||
const agentsDir = makeAgentsDir(tmpDir);
|
||||
const alphaOriginal = 'name = "gsd-alpha"\nmodel = "sonnet"\ndeveloper_instructions = \'\'\'\nWork.\n\'\'\'\n';
|
||||
writeCodexAgentToml(agentsDir, 'gsd-alpha', alphaOriginal);
|
||||
writeCodexAgentToml(agentsDir, 'gsd-bravo', 'name = "gsd-bravo"\nmodel = "opus"\ndeveloper_instructions = \'\'\'\nWork.\n\'\'\'\n');
|
||||
const failingPath = path.join(agentsDir, 'gsd-alpha.toml');
|
||||
|
||||
// Unlike the old version of this test — which mocked fs.writeFileSync to
|
||||
// throw BEFORE any bytes ever reached disk, proving nothing about a
|
||||
// mid-write failure — this injects the failure at the point a NON-ATOMIC
|
||||
// implementation (`fs.writeFileSync(filePath, ...)` straight into the
|
||||
// target, in place) would already have truncated the real file: 'w'-mode
|
||||
// open+truncate happens before any content is written, so a crash between
|
||||
// open and completion leaves a partial file. The mock actually performs a
|
||||
// REAL (truncated) write to whatever path it's called with — including a
|
||||
// hypothetical direct write to `failingPath` itself — before throwing, so
|
||||
// an in-place implementation's target would end up holding these 4 bytes,
|
||||
// not the original content. An atomic tmp+rename implementation instead
|
||||
// sends this call to a SIBLING tmp path (never `failingPath` itself), so
|
||||
// `failingPath` is never opened for write in the first place and survives
|
||||
// untouched.
|
||||
const realWriteFileSync = fs.writeFileSync;
|
||||
t.mock.method(fs, 'writeFileSync', (target, data, ...args) => {
|
||||
if (typeof target === 'string' && target.startsWith(failingPath)) {
|
||||
realWriteFileSync.call(fs, target, String(data).slice(0, 4));
|
||||
throw Object.assign(new Error('injected ENOSPC (mid-write)'), { code: 'ENOSPC' });
|
||||
}
|
||||
return realWriteFileSync.call(fs, target, data, ...args);
|
||||
});
|
||||
|
||||
const result = syncCodex(tmpDir, false);
|
||||
|
||||
assert.equal(result.synced, 1, 'only the non-failing agent must be reported synced');
|
||||
assert.equal(result.write_failures.length, 1);
|
||||
assert.equal(result.write_failures[0].agent, 'gsd-alpha');
|
||||
assert.ok(
|
||||
!result.changes.some(c => c.agent === 'gsd-alpha'),
|
||||
'a failed write must never be reported as a completed change',
|
||||
);
|
||||
assert.ok(
|
||||
fs.readFileSync(path.join(agentsDir, 'gsd-bravo.toml'), 'utf8').indexOf('model = "opus"') === -1,
|
||||
'the sibling agent must still be synced despite the other write failing',
|
||||
);
|
||||
// The load-bearing assertion (ADR-2313 "never partially rewritten"): the
|
||||
// target must be BYTE-IDENTICAL to its pre-sync content, not merely
|
||||
// "contains model = sonnet somewhere" — a truncated-to-4-bytes file would
|
||||
// pass a substring check but fail this equality. Against the pre-fix
|
||||
// in-place `fs.writeFileSync(filePath, renderCodexAgentToml(doc))`, this
|
||||
// assertion FAILS: that call's target IS `failingPath`, so the mock's
|
||||
// real truncated write lands directly on the file, leaving it as the
|
||||
// 4-byte slice `'name'` instead of `alphaOriginal`.
|
||||
assert.equal(
|
||||
fs.readFileSync(failingPath, 'utf8'),
|
||||
alphaOriginal,
|
||||
'a mid-write failure must leave the original file byte-identical, never partially rewritten',
|
||||
);
|
||||
// The atomic write path cleans up its sibling tmp file on failure — no
|
||||
// stray `.tmp.<pid>` left behind in the agents directory.
|
||||
const leftovers = fs.readdirSync(agentsDir).filter(f => f !== 'gsd-alpha.toml' && f !== 'gsd-bravo.toml');
|
||||
assert.deepEqual(leftovers, [], 'a failed write must not leave a stray tmp file behind');
|
||||
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('B21 (independence): several agents, mixed states — per-agent results, deterministic order', () => {
|
||||
const tmpDir = makeTmpDir('codex-sync-b21-');
|
||||
const agentsDir = makeAgentsDir(tmpDir);
|
||||
writeCodexAgentToml(agentsDir, 'gsd-alpha', 'name = "gsd-alpha"\ndeveloper_instructions = \'\'\'\nClean.\n\'\'\'\n');
|
||||
writeCodexAgentToml(agentsDir, 'gsd-bravo', 'name = "gsd-bravo"\nmodel = "opus"\ndeveloper_instructions = \'\'\'\nWork.\n\'\'\'\n');
|
||||
writeCodexAgentToml(agentsDir, 'gsd-charlie', 'name = "gsd-charlie"\nmodel_reasoning_effort = "medium"\ndeveloper_instructions = \'\'\'\nWork.\n\'\'\'\n');
|
||||
|
||||
const result = syncCodex(tmpDir, false);
|
||||
|
||||
assert.equal(result.synced, 2, 'gsd-bravo and gsd-charlie must both sync; gsd-alpha is clean');
|
||||
assert.equal(result.skipped, 1);
|
||||
assert.deepEqual(
|
||||
result.changes.map(c => c.agent),
|
||||
['gsd-bravo', 'gsd-charlie'],
|
||||
'agents must be processed in deterministic (sorted) order',
|
||||
);
|
||||
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user