@syntax-syllogism/aloop 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/policy.mjs ADDED
@@ -0,0 +1,63 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { parsePullRequestDescription } from './publish.mjs';
4
+ import { APPROVED } from './verdict.mjs';
5
+
6
+ export function hasPostcondition(phase, name) {
7
+ return phase.postconditions?.includes(name) ?? false;
8
+ }
9
+
10
+ export function phaseIsSkippable(phase) {
11
+ return phase.optional && !phase.verdict && phase.kind !== 'gate';
12
+ }
13
+
14
+ export async function codePhasePostcondition(phase, ctx, {
15
+ hasFindings = true,
16
+ headBefore = null,
17
+ round = null,
18
+ } = {}) {
19
+ if (ctx.dryRun) return null;
20
+ const logPath = ctx.state.logPath(phase.name);
21
+ if (hasPostcondition(phase, 'clean-tree') && await ctx.git.status()) {
22
+ return `${phase.name} left uncommitted changes; the reviewer inspects base...HEAD and will not see them. Commit the work. Check ${logPath}.`;
23
+ }
24
+ if (hasPostcondition(phase, 'pr-description-valid')) {
25
+ try {
26
+ parsePullRequestDescription(await readFile(join(ctx.state.dir, 'pr.md'), 'utf8'));
27
+ } catch (error) {
28
+ const reason = error.code === 'ENOENT' ? 'file is missing' : error.message;
29
+ return `${phase.name} did not produce a valid pr.md (${reason}). Check ${logPath}.`;
30
+ }
31
+ }
32
+ const requiresHeadAdvance = hasPostcondition(phase, 'head-advanced');
33
+ const permitsRebuttal = hasPostcondition(phase, 'head-advanced-or-rebuttal');
34
+ const requiresHeadUnchanged = hasPostcondition(phase, 'head-unchanged');
35
+ const headChanged = (requiresHeadAdvance || permitsRebuttal || requiresHeadUnchanged)
36
+ && headBefore !== null && headBefore !== (await ctx.git.revParse());
37
+ if (requiresHeadAdvance && !headChanged) return `${phase.name} produced no commit. Commit the work. Check ${logPath}.`;
38
+ if (requiresHeadUnchanged && headChanged) return `${phase.name} changed HEAD from ${headBefore} to ${await ctx.git.revParse()}; it may only write the PR description. Check ${logPath}.`;
39
+ if (!permitsRebuttal || headChanged || !hasFindings) return null;
40
+ const rebuttalPath = round === null ? null : join(ctx.state.dir, `response-round-${round}.md`);
41
+ try {
42
+ if (rebuttalPath && (await stat(rebuttalPath)).isFile()) return null;
43
+ } catch (error) {
44
+ if (error.code !== 'ENOENT') throw error;
45
+ }
46
+ return `${phase.name} produced no fix commit and no rebuttal recorded. Commit the fix or record a rebuttal. Check ${logPath}.`;
47
+ }
48
+
49
+ function latestApprovedReview(state) {
50
+ const index = state.manifest.entries.findLastIndex((entry) => entry.role === 'verdict' && entry.status !== 'skipped');
51
+ const entry = index === -1 ? null : state.manifest.entries[index];
52
+ if (entry?.status !== 'completed' || entry.verdict?.verdict !== APPROVED || typeof entry.verdict.sha !== 'string') return null;
53
+ return { index, sha: entry.verdict.sha };
54
+ }
55
+
56
+ export function publishingAttestation(state, currentSha) {
57
+ const approved = latestApprovedReview(state);
58
+ if (!approved) return { approvedSha: null, currentSha, reason: `current HEAD ${currentSha} has no completed approval from a review phase` };
59
+ if (approved.sha !== currentSha) return { approvedSha: approved.sha, currentSha, reason: `current HEAD ${currentSha} does not match the approved review SHA ${approved.sha}` };
60
+ const gatePassed = state.manifest.entries.slice(0, approved.index).some((entry) => entry.role === 'gate' && entry.status === 'completed' && entry.outputSha === approved.sha);
61
+ if (!gatePassed) return { approvedSha: approved.sha, currentSha, reason: `approved review SHA ${approved.sha} has no passing gate receipt preceding its review` };
62
+ return null;
63
+ }
@@ -0,0 +1,183 @@
1
+ import { readFile, unlink, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { runCommand } from './command.mjs';
4
+ import { GitFacade } from './git.mjs';
5
+ import { gitlabBackend } from './backends/gitlab.mjs';
6
+
7
+ const PR_FIELDS = 'url,baseRefName,headRefOid,isDraft,number';
8
+
9
+ export class PublishError extends Error {
10
+ constructor(message, options = {}) {
11
+ super(message, options);
12
+ this.name = 'PublishError';
13
+ }
14
+ }
15
+
16
+ export function parsePullRequestDescription(contents) {
17
+ const match = /^Title:\s*(.+?)\r?\n\r?\n([\s\S]*?)\s*$/i.exec(contents);
18
+ if (!match) {
19
+ throw new PublishError('PR description must start with "Title: <one-line title>", followed by a blank line and body.');
20
+ }
21
+ const title = match[1].trim();
22
+ const body = match[2].trim();
23
+ if (!title) throw new PublishError('PR description title must not be empty.');
24
+ if (!body) throw new PublishError('PR description body must not be empty.');
25
+ return { title, body };
26
+ }
27
+
28
+ export function githubBackend({ cwd, git = new GitFacade(cwd), runner = runCommand, env } = {}) {
29
+ const repositories = new Map();
30
+
31
+ async function resolveRepository(remote) {
32
+ if (!repositories.has(remote)) {
33
+ const remoteUrl = await git.remoteUrl(remote);
34
+ const match = /^(?:https?:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/]+\/[^/]+?)(?:\.git)?\/?$/i.exec(remoteUrl);
35
+ if (!match) {
36
+ throw new PublishError(`Configured remote ${remote} does not point to a GitHub repository.`);
37
+ }
38
+ repositories.set(remote, match[1]);
39
+ }
40
+ return repositories.get(remote);
41
+ }
42
+
43
+ async function run(args) {
44
+ try {
45
+ return await runner('gh', args, { cwd, ...(env ? { env } : {}) });
46
+ } catch (error) {
47
+ if (error.code === 'ENOENT') {
48
+ throw new PublishError('GitHub publishing requires the `gh` CLI, but it is not installed.', { cause: error });
49
+ }
50
+ throw new PublishError(error.output?.trim() || error.message, { cause: error });
51
+ }
52
+ }
53
+
54
+ async function runPullRequest(remote, args) {
55
+ return run([...args, '--repo', await resolveRepository(remote)]);
56
+ }
57
+
58
+ return {
59
+ async precheck({ remote }) {
60
+ try {
61
+ await resolveRepository(remote);
62
+ await run(['auth', 'status']);
63
+ } catch (error) {
64
+ throw new PublishError(`GitHub publishing precheck failed: ${error.message}`, { cause: error });
65
+ }
66
+ },
67
+ async view({ remote, branch }) {
68
+ try {
69
+ const result = await runPullRequest(remote, ['pr', 'view', branch, '--json', PR_FIELDS]);
70
+ return JSON.parse(result.stdout);
71
+ } catch (error) {
72
+ // `gh pr view` uses exit 1 when a branch has no PR. Authentication and
73
+ // executable failures were already checked by precheck and remain
74
+ // errors at this point.
75
+ if (error.cause?.output?.toLowerCase().includes('no pull requests found')) return null;
76
+ if (error.message.toLowerCase().includes('no pull requests found')) return null;
77
+ throw new PublishError(`Unable to inspect pull request for ${branch}: ${error.message}`, { cause: error });
78
+ }
79
+ },
80
+ async create({ remote, base, branch, title, bodyFilePath, draft }) {
81
+ const args = ['pr', 'create', '--base', base, '--head', branch, '--title', title, '--body-file', bodyFilePath];
82
+ if (draft) args.push('--draft');
83
+ await runPullRequest(remote, args);
84
+ },
85
+ async update({ remote, branch, title, bodyFilePath }) {
86
+ await runPullRequest(remote, ['pr', 'edit', branch, '--title', title, '--body-file', bodyFilePath]);
87
+ },
88
+ };
89
+ }
90
+
91
+ function resolveBackend(backend, cwd, git, env) {
92
+ if (backend === undefined || backend === null || backend === 'github') return githubBackend({ cwd, git, env });
93
+ if (backend === 'gitlab') return gitlabBackend({ cwd, git, env });
94
+ if (typeof backend === 'object' && backend !== null) {
95
+ if (env !== undefined) {
96
+ throw new PublishError('Hermetic publishing does not support in-process custom backends; use the built-in GitHub backend or disable hermetic publishing.');
97
+ }
98
+ return backend;
99
+ }
100
+ throw new PublishError(`Unsupported publish backend "${backend}".`);
101
+ }
102
+
103
+ function assertVerifiedPullRequest(pullRequest, { base, branch, localSha, draft }) {
104
+ if (!pullRequest || typeof pullRequest !== 'object') {
105
+ throw new PublishError(`No pull request exists for branch ${branch} after publishing.`);
106
+ }
107
+ if (pullRequest.baseRefName !== base) {
108
+ throw new PublishError(`Pull request base ${pullRequest.baseRefName ?? '(none)'} does not match configured base ${base}.`);
109
+ }
110
+ if (pullRequest.headRefOid?.toLowerCase() !== localSha.toLowerCase()) {
111
+ throw new PublishError(`Pull request head SHA ${pullRequest.headRefOid ?? '(none)'} does not match approved SHA ${localSha}.`);
112
+ }
113
+ if (pullRequest.isDraft !== draft) {
114
+ throw new PublishError(`Pull request draft state ${String(pullRequest.isDraft)} does not match configured draft state ${String(draft)}.`);
115
+ }
116
+ if (typeof pullRequest.url !== 'string' || !pullRequest.url) {
117
+ throw new PublishError('Pull request verification returned no URL.');
118
+ }
119
+ if (!Number.isInteger(pullRequest.number)) {
120
+ throw new PublishError('Pull request verification returned no number.');
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Push an attested branch and create or update exactly one verified PR.
126
+ *
127
+ * Git is passed in as a facade and the backend is a small port. The default
128
+ * backend is GitHub's `gh` CLI; other remotes can supply the same precheck,
129
+ * view, create, and update operations without changing this use case.
130
+ */
131
+ export async function publish({
132
+ git,
133
+ remote,
134
+ branch,
135
+ base,
136
+ prBodyPath,
137
+ draft = true,
138
+ backend = 'github',
139
+ approvedSha = null,
140
+ env,
141
+ }) {
142
+ if (!git || typeof git.push !== 'function' || typeof git.lsRemote !== 'function') {
143
+ throw new PublishError('Publish requires a GitFacade with push and lsRemote operations.');
144
+ }
145
+ if (!prBodyPath) throw new PublishError('Publish requires a PR description path.');
146
+
147
+ const selectedBackend = resolveBackend(backend, git.cwd, git, env);
148
+ for (const operation of ['precheck', 'view', 'create', 'update']) {
149
+ if (typeof selectedBackend[operation] !== 'function') {
150
+ throw new PublishError(`Publish backend is missing its ${operation} operation.`);
151
+ }
152
+ }
153
+ await selectedBackend.precheck({ remote, branch, base });
154
+
155
+ const localSha = approvedSha ?? await git.revParse();
156
+ const description = parsePullRequestDescription(await readFile(prBodyPath, 'utf8'));
157
+ const bodyFilePath = join(dirname(prBodyPath), '.pr-body.md');
158
+ await writeFile(bodyFilePath, `${description.body}\n`, 'utf8');
159
+ try {
160
+ await git.push(remote, branch, { setUpstream: true, ...(env ? { env } : {}) });
161
+ const remoteSha = await git.lsRemote(remote, branch, env ? { env } : {});
162
+ if (remoteSha.toLowerCase() !== localSha.toLowerCase()) {
163
+ throw new PublishError(`Remote branch ${remote}/${branch} SHA ${remoteSha} does not match approved SHA ${localSha}.`);
164
+ }
165
+
166
+ const existing = await selectedBackend.view({ remote, branch, base });
167
+ if (existing) {
168
+ await selectedBackend.update({ remote, branch, base, title: description.title, body: description.body, bodyFilePath, draft });
169
+ } else {
170
+ await selectedBackend.create({ remote, branch, base, title: description.title, body: description.body, bodyFilePath, draft });
171
+ }
172
+ const pullRequest = await selectedBackend.view({ remote, branch, base });
173
+ assertVerifiedPullRequest(pullRequest, { base, branch, localSha, draft });
174
+ return {
175
+ localSha,
176
+ remoteSha,
177
+ created: !existing,
178
+ pullRequest,
179
+ };
180
+ } finally {
181
+ await unlink(bodyFilePath).catch(() => {});
182
+ }
183
+ }
@@ -0,0 +1,102 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { createInterface } from 'node:readline/promises';
3
+
4
+ export function log(message) {
5
+ console.log(message);
6
+ }
7
+
8
+ export function banner(text) {
9
+ log(`\n── ${text} ${'─'.repeat(Math.max(0, 60 - text.length))}`);
10
+ }
11
+
12
+ export function describeAgent(agent) {
13
+ return `${agent.name}${agent.model ? ` model=${agent.model}` : ''}${agent.effort ? ` effort=${agent.effort}` : ''}`;
14
+ }
15
+
16
+ export function formatPhase(phase) {
17
+ const detail = phase.rounds ? ` (${phase.rounds} round${phase.rounds === 1 ? '' : 's'}, ${phase.verdict})` : '';
18
+ return ` ✓ ${phase.name}${detail}`;
19
+ }
20
+
21
+ export function openTerminalInput({ platform = process.platform, input = process.stdin, createInput = createReadStream } = {}) {
22
+ if (platform === 'win32' && input.isTTY) return Promise.resolve(input);
23
+ const terminalPath = platform === 'win32' ? '\\\\.\\CONIN$' : '/dev/tty';
24
+ return new Promise((resolveInput, rejectInput) => {
25
+ const terminalInput = createInput(terminalPath);
26
+ const onOpen = () => {
27
+ terminalInput.off('error', onError);
28
+ resolveInput(terminalInput);
29
+ };
30
+ const onError = (error) => {
31
+ terminalInput.off('open', onOpen);
32
+ terminalInput.destroy();
33
+ rejectInput(error);
34
+ };
35
+ terminalInput.once('open', onOpen);
36
+ terminalInput.once('error', onError);
37
+ });
38
+ }
39
+
40
+ export async function ensureConfirmationAvailable(input, terminalOpener) {
41
+ if (input || process.stdin.isTTY) return;
42
+ let terminalInput;
43
+ try {
44
+ terminalInput = await terminalOpener();
45
+ } catch {
46
+ throw new Error('no terminal available to confirm phases; re-run with --yes to run unattended');
47
+ }
48
+ if (terminalInput !== process.stdin) terminalInput.destroy();
49
+ }
50
+
51
+ export async function confirm(question, { input = null, terminalOpener = openTerminalInput } = {}) {
52
+ let confirmationInput = input;
53
+ let closeInput = false;
54
+ if (!confirmationInput) {
55
+ if (process.stdin.isTTY) {
56
+ confirmationInput = process.stdin;
57
+ } else {
58
+ try {
59
+ confirmationInput = await terminalOpener();
60
+ } catch {
61
+ throw new Error('no terminal available to confirm phases; re-run with --yes to run unattended');
62
+ }
63
+ closeInput = confirmationInput !== process.stdin;
64
+ }
65
+ }
66
+ const rl = createInterface({ input: confirmationInput, output: process.stdout });
67
+ try {
68
+ const answer = await rl.question(`${question} [Y/n/q] `);
69
+ const normalized = answer.trim().toLowerCase();
70
+ if (normalized === 'q') return 'quit';
71
+ return normalized === '' || normalized === 'y' ? 'yes' : 'skip';
72
+ } finally {
73
+ rl.close();
74
+ if (closeInput) confirmationInput.destroy();
75
+ }
76
+ }
77
+
78
+ export function report(summary, formatFindings) {
79
+ banner('summary');
80
+ for (const phase of summary.phases) {
81
+ log(formatPhase(phase));
82
+ }
83
+ if (!summary.stalled) {
84
+ log(`\nPipeline finished. Branch ${summary.branch} is ready.`);
85
+ if (summary.prUrl) log(`Pull request: ${summary.prUrl}`);
86
+ log(`Logs: ${summary.runDir}`);
87
+ return;
88
+ }
89
+ log(`\n ✗ stalled in "${summary.stalled.phase}": ${summary.stalled.reason}`);
90
+ if (summary.stalled.findings?.length) {
91
+ log(`\nOutstanding blocking findings:\n${formatFindings(summary.stalled.findings)}`);
92
+ }
93
+ if (summary.stalled.output) log(`\n${summary.stalled.output.trim().split('\n').slice(-20).join('\n')}`);
94
+ log(`\nWorktree: ${summary.worktree}`);
95
+ log(`Logs: ${summary.runDir}`);
96
+ const resumeArgs = [`--name ${summary.task}`];
97
+ if (summary.taskFile && summary.taskFile !== `${summary.runDir}/task.md`) {
98
+ resumeArgs.push(`--task-file ${summary.taskFile}`);
99
+ }
100
+ log(`Resume: aloop ${resumeArgs.join(' ')} --resume`);
101
+ process.exitCode = 1;
102
+ }