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
36 changes: 36 additions & 0 deletions bin/codiff-app
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,9 @@ open_codiff() {
if [ -n "$plan_file_path" ]; then
set -- --plan-file "$plan_file_path" "$@"
fi
if [ -n "$walkthrough_result_file_path" ]; then
set -- --walkthrough-result-file "$walkthrough_result_file_path" "$@"
fi
if [ -n "$walkthrough_file_path" ]; then
set -- --walkthrough-file "$walkthrough_file_path" "$@"
fi
Expand Down Expand Up @@ -684,6 +687,15 @@ open_codiff() {

print_update_notice

# The app opens detached, so it reports whether it applied the walkthrough file
# here; otherwise a file it rejects would still exit 0.
walkthrough_result_file_path=""
if [ -n "$walkthrough_file_path" ]; then
walkthrough_result_directory="$(mktemp -d "${TMPDIR:-/tmp}/codiff-walkthrough-result.XXXXXX")"
walkthrough_result_file_path="$walkthrough_result_directory/result.txt"
trap 'rm -rf "$walkthrough_result_directory"' EXIT INT TERM
fi

if [ -n "$plan_file_path" ]; then
if [ -z "$plan_result_file_path" ]; then
plan_result_directory="$(mktemp -d "${TMPDIR:-/tmp}/codiff-plan-result.XXXXXX")"
Expand Down Expand Up @@ -742,3 +754,27 @@ elif [ "$walkthrough" -eq 1 ]; then
else
open_codiff "$repository_path"
fi

if [ -n "$walkthrough_result_file_path" ]; then
# Covers app startup and the diff read, which can include fetching a pull request.
walkthrough_wait_attempts=0
while :; do
walkthrough_status=""
if [ -s "$walkthrough_result_file_path" ]; then
walkthrough_status="$(head -n 1 "$walkthrough_result_file_path")"
fi
if [ "$walkthrough_status" = "ready" ]; then
exit 0
fi
if [ "$walkthrough_status" = "unavailable" ]; then
tail -n +2 "$walkthrough_result_file_path" >&2
exit 1
fi
walkthrough_wait_attempts=$((walkthrough_wait_attempts + 1))
if [ "$walkthrough_wait_attempts" -ge 1200 ]; then
printf '%s\n' "codiff: Codiff did not check the walkthrough within 60 seconds." >&2
exit 1
fi
sleep 0.05
done
fi
26 changes: 26 additions & 0 deletions bin/codiff.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { completionShells, generateCompletionScript } from './completions.js';
import { waitForPlanResult } from './plan-result.js';
import { runUpdateCommand } from './update-command.js';
import { getUpdateNotice } from './update-notice.js';
import { waitForWalkthroughResult } from './walkthrough-result.js';

const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(import.meta.url);
Expand Down Expand Up @@ -297,6 +298,14 @@ const run = async () => {
? mkdtempSync(join(tmpdir(), 'codiff-plan-result-'))
: null;
const planResultPath = planResultDirectory ? join(planResultDirectory, 'result.json') : '';
// The app launches detached, so wait for it to report whether it applied the
// walkthrough file; otherwise a file it rejects would still exit 0 here.
const walkthroughResultDirectory = walkthroughFilePath
? mkdtempSync(join(tmpdir(), 'codiff-walkthrough-result-'))
: null;
const walkthroughResultPath = walkthroughResultDirectory
? join(walkthroughResultDirectory, 'result.txt')
: '';
const childEnv = {
...process.env,
CODIFF_AGENT_BACKEND: agentBackend ?? '',
Expand All @@ -315,6 +324,7 @@ const run = async () => {
CODIFF_WALKTHROUGH: walkthrough ? '1' : '',
CODIFF_WALKTHROUGH_CONTEXT: walkthroughContextPath ?? '',
CODIFF_WALKTHROUGH_FILE: walkthroughFilePath ?? '',
CODIFF_WALKTHROUGH_RESULT_FILE: walkthroughResultPath,
ELECTRON_RENDERER_URL: rendererURL,
};

Expand Down Expand Up @@ -356,6 +366,22 @@ const run = async () => {
rmSync(planResultDirectory, { force: true, recursive: true });
}
}
if (walkthroughResultDirectory) {
try {
const result = await waitForWalkthroughResult(walkthroughResultPath, child);
if (result.status === 'unavailable') {
process.stderr.write(`${result.reason}\n`);
process.exitCode = 1;
}
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : 'Codiff could not load the walkthrough.'}\n`,
);
process.exitCode = 1;
} finally {
rmSync(walkthroughResultDirectory, { force: true, recursive: true });
}
}
};

run();
8 changes: 7 additions & 1 deletion bin/walkthrough-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ choose.
Give every stop a concise semantic `title` in roughly 2-6 words, such as
`"Prevent duplicate payments"` or `"Preserve offline drafts"`. Never use a filename or path
as the stop title.
- **`hunkIds[]`** — deterministic hunk ids copied from the repository digest, in the exact order
- **`hunkIds[]`** — deterministic hunk ids from the live diff (see "hunkId format"), in the exact order
Codiff should render them. Default to one review idea per stop, not one hunk per stop. Use
multiple ids when those hunks implement the same idea, invariant, behavior, or repeated pattern;
cross-file and out-of-line order is allowed when it improves the review path. For 1-4 total
Expand Down Expand Up @@ -90,6 +90,12 @@ The `<scope>` segment depends on which diff you anchored against:
**`HEAD`** SHA, _not_ `<branch>`.
- ref range (`codiff base..head` or `base...head`): the resolved SHA of `head`.

## Validation

If a `hunkId` does not resolve, the command exits non-zero and prints the unresolved ids and
the diff's real ids. Write the JSON with a file-writing tool or a quoted heredoc (`<<'EOF'`):
in zsh, `"$sha:h1"` expands to `.1`, so write `"${sha}:h1"`.

## Schema

The document must conform to the following JSON schema:
54 changes: 54 additions & 0 deletions bin/walkthrough-result.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { readFileSync } from 'node:fs';

// Covers app startup and the diff read, which can include fetching a pull request.
const walkthroughResultTimeoutMs = 60_000;

/**
* Waits for the app to report whether a `--walkthrough-file` can be applied. The
* app checks the file before opening a window and writes a `ready` line, or an
* `unavailable` line followed by the reason.
*/
export const waitForWalkthroughResult = async (
resultPath,
child,
{ pollIntervalMs = 50, timeoutMs = walkthroughResultTimeoutMs } = {},
) => {
const startedAt = Date.now();
let childError = null;
let childExit = null;
child.once('error', (error) => {
childError = error;
});
child.once('exit', (code, signal) => {
childExit = { code, signal };
});

for (;;) {
if (childError) {
throw childError;
}

try {
const [status, ...reason] = readFileSync(resultPath, 'utf8').trimEnd().split('\n');
if (status === 'ready') {
return { status };
}
if (status === 'unavailable') {
return { reason: reason.join('\n'), status };
}
} catch {}

if (childExit && childExit.code !== 0) {
throw new Error(
`Codiff exited before checking the walkthrough${
childExit.signal ? ` (${childExit.signal})` : ` (code ${childExit.code})`
}.`,
);
}
if (Date.now() - startedAt >= timeoutMs) {
throw new Error(`Codiff did not check the walkthrough within ${timeoutMs / 1000} seconds.`);
}

await new Promise((resolveWait) => setTimeout(resolveWait, pollIntervalMs));
}
};
4 changes: 2 additions & 2 deletions claude/skills/codiff/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,8 @@ approval document.
**Agent integration:** The launcher passes `CLAUDE_SESSION_ID` to Codiff in desktop mode and
identifies shared walkthroughs as authored by Claude.

Codiff validates and repairs the document against the live diff, so anchors that drift
are pinned to a real section rather than dropped.
If a `hunkId` does not resolve, the command exits non-zero and prints the diff's real ids.
Fix the document and rerun.

Emit walkthrough JSON only into the temporary file. In desktop walkthrough mode, do not
summarize the conversation back to the user. In walkthrough share mode, respond with the URL
Expand Down
4 changes: 2 additions & 2 deletions codex/skills/codiff/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,8 @@ approval document.
**Agent integration:** The launcher passes `CODEX_THREAD_ID` to Codiff in desktop mode and
identifies shared walkthroughs as authored by Codex.

Codiff validates and repairs the document against the live diff, so anchors that drift
are pinned to a real section rather than dropped.
If a `hunkId` does not resolve, the command exits non-zero and prints the diff's real ids.
Fix the document and rerun.

Emit walkthrough JSON only into the temporary file. In desktop walkthrough mode, do not
summarize the conversation back to the user. In walkthrough share mode, respond with the URL
Expand Down
28 changes: 27 additions & 1 deletion core/__tests__/codiff-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -819,7 +819,7 @@ test('packaged terminal helper forwards pre-authored walkthrough files', async (
repositoryPath,
],
{
env: logger.env,
env: { ...logger.env, FAKE_WALKTHROUGH_RESULT: 'ready' },
},
);

Expand All @@ -833,11 +833,37 @@ test('packaged terminal helper forwards pre-authored walkthrough files', async (
'claude',
'--walkthrough-file',
walkthroughFile,
'--walkthrough-result-file',
expect.stringMatching(/codiff-walkthrough-result\.[^/]+\/result\.txt$/),
'--walkthrough',
repositoryPath,
]);
});

test('packaged terminal helper fails when the app cannot apply the walkthrough file', async () => {
await using logger = await createFakeOpenLogger();
const repositoryPath = join(logger.directory, 'repo');
const walkthroughFile = join(logger.directory, 'walkthrough.json');

await mkdir(repositoryPath);
await writeFile(walkthroughFile, '{}');

await expect(
execFileAsync(
resolve('bin/codiff-app'),
['-w', '--walkthrough-file', walkthroughFile, repositoryPath],
{
env: {
...logger.env,
FAKE_WALKTHROUGH_RESULT: 'unavailable\nUnresolved hunk ids: README.md:.1.',
// The app reports after the launcher starts waiting.
FAKE_WALKTHROUGH_RESULT_DELAY: '0.3',
},
},
),
).rejects.toMatchObject({ code: 1, stderr: 'Unresolved hunk ids: README.md:.1.\n' });
});

test('packaged terminal helper forwards a plan handoff and result file', async () => {
await using logger = await createFakeOpenLogger();
const repositoryPath = join(logger.directory, 'repo');
Expand Down
5 changes: 4 additions & 1 deletion core/__tests__/helpers/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises
import { tmpdir } from 'node:os';
import { join } from 'node:path';

// Also stands in for the app's walkthrough result: when FAKE_WALKTHROUGH_RESULT is
// set, it is written to the path following --walkthrough-result-file, after
// FAKE_WALKTHROUGH_RESULT_DELAY seconds when that is set.
const loggerScript =
'#!/bin/sh\nfor arg in "$@"; do\n printf "%s\\n" "$arg" >> "$OPEN_ARGS_FILE"\ndone\n';
'#!/bin/sh\nprevious=""\nfor arg in "$@"; do\n printf "%s\\n" "$arg" >> "$OPEN_ARGS_FILE"\n if [ "$previous" = "--walkthrough-result-file" ] && [ -n "$FAKE_WALKTHROUGH_RESULT" ]; then\n (sleep "${FAKE_WALKTHROUGH_RESULT_DELAY:-0}"; printf "%s\\n" "$FAKE_WALKTHROUGH_RESULT" > "$arg") &\n fi\n previous="$arg"\ndone\n';

export const createFakeOpenLogger = async () => {
const directory = await mkdtemp(join(tmpdir(), 'codiff-app-helper-'));
Expand Down
55 changes: 55 additions & 0 deletions core/__tests__/walkthrough-result.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { EventEmitter } from 'node:events';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { expect, test } from 'vite-plus/test';
import { waitForWalkthroughResult } from '../../bin/walkthrough-result.js';
import { createTemporaryDirectory } from './helpers/resources.ts';

const options = { pollIntervalMs: 2, timeoutMs: 100 };

test('walkthrough result waiting returns once the app reports', async () => {
await using directory = await createTemporaryDirectory('codiff-walkthrough-result-');
const resultPath = join(directory.path, 'result.txt');

const result = waitForWalkthroughResult(resultPath, new EventEmitter(), options);
setTimeout(() => {
void writeFile(resultPath, 'ready\n');
}, 20);

await expect(result).resolves.toEqual({ status: 'ready' });
});

test('walkthrough result waiting returns why the file cannot be applied', async () => {
await using directory = await createTemporaryDirectory('codiff-walkthrough-result-');
const resultPath = join(directory.path, 'result.txt');
await writeFile(
resultPath,
'unavailable\nUnresolved hunk ids: a.ts:.1.\nThis diff has no hunks.\n',
);

await expect(waitForWalkthroughResult(resultPath, new EventEmitter(), options)).resolves.toEqual({
reason: 'Unresolved hunk ids: a.ts:.1.\nThis diff has no hunks.',
status: 'unavailable',
});
});

test('walkthrough result waiting fails when the app never reports', async () => {
await using directory = await createTemporaryDirectory('codiff-walkthrough-result-');

await expect(
waitForWalkthroughResult(join(directory.path, 'result.txt'), new EventEmitter(), {
...options,
timeoutMs: 10,
}),
).rejects.toThrow('Codiff did not check the walkthrough within 0.01 seconds.');
});

test('walkthrough result waiting fails when the app exits without reporting', async () => {
await using directory = await createTemporaryDirectory('codiff-walkthrough-result-');
const child = new EventEmitter();

const result = waitForWalkthroughResult(join(directory.path, 'result.txt'), child, options);
child.emit('exit', 1, null);

await expect(result).rejects.toThrow('Codiff exited before checking the walkthrough (code 1).');
});
2 changes: 2 additions & 0 deletions core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,8 @@ export type CodiffLaunchOptions = {
walkthroughContext?: WalkthroughContext;
/** Path to a pre-authored {@link NarrativeWalkthrough} JSON file (--walkthrough-file). */
walkthroughFile?: string;
/** Result file where the app reports whether it applied {@link walkthroughFile}. */
walkthroughResultFile?: string;
};

export type AgentSkillStatus = {
Expand Down
21 changes: 21 additions & 0 deletions electron/__tests__/command-line.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,27 @@ test('parses plan handoff command-line options', () => {
});
});

test('parses walkthrough file result command-line options', () => {
expect(
readCommandLine([
'codiff',
'--walkthrough',
'--walkthrough-file',
'/tmp/walkthrough.json',
'--walkthrough-result-file',
'/tmp/result.json',
'/repo',
]),
).toMatchObject({
launchOptions: {
walkthrough: true,
walkthroughFile: '/tmp/walkthrough.json',
walkthroughResultFile: '/tmp/result.json',
},
repositoryPath: '/repo',
});
});

test('parses positional HEAD revisions as commit sources', () => {
expect(readCommandLine(['codiff', 'HEAD'])).toEqual({
launchOptions: {
Expand Down
16 changes: 16 additions & 0 deletions electron/__tests__/narrative-walkthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import narrativeSchemaJson from '../../core/walkthrough/narrative-walkthrough.sc

const require = createRequire(import.meta.url);
const {
assertWalkthroughHunkIdsResolve,
buildNarrativeWalkthroughPrompt,
getNarrativeWalkthroughCacheKey,
narrativeWalkthroughSchema,
normalizeNarrativeWalkthrough,
readNarrativeWalkthrough,
resolveNarrativeWalkthroughModel,
} = require('../narrative-walkthrough.cjs') as {
assertWalkthroughHunkIdsResolve: (input: unknown, files: ReadonlyArray<unknown>) => void;
buildNarrativeWalkthroughPrompt: (
state: any,
context?: unknown,
Expand Down Expand Up @@ -1362,6 +1364,20 @@ test('throws when no chapters have resolvable stops', () => {
expect(() => normalizeNarrativeWalkthrough(input, files)).toThrow(/no chapters/i);
});

test('rejects a walkthrough when any stop or support hunk id is unresolved', () => {
expect(() => assertWalkthroughHunkIdsResolve(baseInput(), files)).not.toThrow();

const input = baseInput();
input.chapters[0].stops[1].hunkIds = ['src/__tests__/hunkNavigation.test.ts:.1'];
input.support[0].hunkIds = ['pnpm-lock.yaml:staged:h1', 'missing.ts:staged:h1'];

// Normalization would silently drop the stop, so the assertion is what surfaces it.
expect(normalizeNarrativeWalkthrough(input, files).chapters[0].stops).toHaveLength(1);
expect(() => assertWalkthroughHunkIdsResolve(input, files)).toThrow(
/^Unresolved hunk ids: src\/__tests__\/hunkNavigation\.test\.ts:\.1, missing\.ts:staged:h1\. This diff's hunk ids: src\/App\.tsx:staged:h1, /,
);
});

test('throws an explicit error for legacy v3 anchor walkthroughs', () => {
const input = {
chapters: [
Expand Down
Loading
Loading