@gethmy/harness 1.0.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/README.md +66 -0
- package/dist/cli.js +2936 -0
- package/dist/index.js +3734 -0
- package/package.json +65 -0
- package/src/artifact-judge.ts +410 -0
- package/src/cli.ts +272 -0
- package/src/command-metric.ts +594 -0
- package/src/error-classifier.ts +95 -0
- package/src/exec-types.ts +109 -0
- package/src/gate-collectors.ts +431 -0
- package/src/gate-config-error.ts +73 -0
- package/src/git-diff-stat.ts +148 -0
- package/src/git-pr.ts +839 -0
- package/src/harmony-client.ts +197 -0
- package/src/index.ts +37 -0
- package/src/log.ts +129 -0
- package/src/model-tier.test.ts +169 -0
- package/src/model-tier.ts +108 -0
- package/src/oracle-collector.ts +148 -0
- package/src/oracle.ts +434 -0
- package/src/pm.ts +73 -0
- package/src/process-group.ts +149 -0
- package/src/project-type.ts +303 -0
- package/src/revert-guard.ts +99 -0
- package/src/review-types.ts +52 -0
- package/src/runner.ts +184 -0
- package/src/sdk-agent-runner.ts +575 -0
- package/src/stage-cli.ts +302 -0
- package/src/stage-run.ts +91 -0
- package/src/verification.ts +711 -0
- package/src/worktree.ts +639 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChildProcess,
|
|
3
|
+
type SpawnOptions,
|
|
4
|
+
spawn,
|
|
5
|
+
} from "node:child_process";
|
|
6
|
+
import { log } from "./log.js";
|
|
7
|
+
|
|
8
|
+
const TAG = "pgroup";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Spawn a child in its own process group so we can reliably kill the
|
|
12
|
+
* whole subtree later. The Claude CLI shells out to git, build tools,
|
|
13
|
+
* dev servers, etc. — signalling only the direct child leaves orphans.
|
|
14
|
+
*
|
|
15
|
+
* - POSIX: `detached: true` puts the child in a new process group whose
|
|
16
|
+
* pgid equals its pid. Killing the negative pid signals every member.
|
|
17
|
+
* - Windows: `detached: true` creates a new process group that can be
|
|
18
|
+
* signalled via the child's pid (no negation).
|
|
19
|
+
*
|
|
20
|
+
* `stripEnvKeys` (Task 15) DELETES the named variables from the environment
|
|
21
|
+
* assembled below. A caller cannot get that by passing a smaller `options.env`:
|
|
22
|
+
* the merge starts from `process.env`, so a key the caller merely omits still
|
|
23
|
+
* survives from the parent. This is the only place a child's environment is
|
|
24
|
+
* assembled here, so it is the only place a deletion sticks. It removes exactly
|
|
25
|
+
* the named keys and nothing else.
|
|
26
|
+
*/
|
|
27
|
+
export function spawnInGroup(
|
|
28
|
+
command: string,
|
|
29
|
+
args: readonly string[],
|
|
30
|
+
options: SpawnOptions & { stripEnvKeys?: readonly string[] } = {},
|
|
31
|
+
): ChildProcess {
|
|
32
|
+
const { stripEnvKeys, ...spawnOptions } = options;
|
|
33
|
+
const env: NodeJS.ProcessEnv = {
|
|
34
|
+
...process.env,
|
|
35
|
+
...options.env,
|
|
36
|
+
// When the daemon is launched from a cmux terminal, a bare `claude`
|
|
37
|
+
// resolves to cmux's wrapper script, which injects a `--settings` block
|
|
38
|
+
// with a SessionEnd hook (`cmux hooks claude session-end`). Our headless
|
|
39
|
+
// run is not a tracked cmux surface, so that hook is cancelled and Claude
|
|
40
|
+
// exits with code 1 — a *false* run failure (the work itself succeeded).
|
|
41
|
+
// `CMUX_CLAUDE_HOOKS_DISABLED=1` is the wrapper's documented passthrough
|
|
42
|
+
// escape hatch: it execs the real claude with no hook injection. Harmless
|
|
43
|
+
// for non-claude children (only the cmux wrapper reads it).
|
|
44
|
+
CMUX_CLAUDE_HOOKS_DISABLED: "1",
|
|
45
|
+
};
|
|
46
|
+
// DELETE, after the merge. Omitting a key from `options.env` would not remove
|
|
47
|
+
// it — the merge above re-supplies it from `process.env`.
|
|
48
|
+
for (const key of stripEnvKeys ?? []) delete env[key];
|
|
49
|
+
|
|
50
|
+
return spawn(command, args as string[], {
|
|
51
|
+
...spawnOptions,
|
|
52
|
+
detached: true,
|
|
53
|
+
// Keep stdio wired up so streaming still works.
|
|
54
|
+
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
|
55
|
+
env,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Send a signal to every process in the group whose leader is `proc`.
|
|
61
|
+
* On POSIX, this is `process.kill(-pid, signal)`. If the group has
|
|
62
|
+
* already exited, returns silently.
|
|
63
|
+
*/
|
|
64
|
+
export function signalGroup(proc: ChildProcess, signal: NodeJS.Signals): void {
|
|
65
|
+
if (!proc.pid || proc.killed) return;
|
|
66
|
+
try {
|
|
67
|
+
if (process.platform === "win32") {
|
|
68
|
+
// No process groups on Windows; best effort tree kill via the child.
|
|
69
|
+
proc.kill(signal);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
process.kill(-proc.pid, signal);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// ESRCH means the group is already gone — that is the goal, not an error.
|
|
75
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
76
|
+
if (code !== "ESRCH") {
|
|
77
|
+
log.warn(
|
|
78
|
+
TAG,
|
|
79
|
+
`signal ${signal} to pgid ${proc.pid} failed: ${err instanceof Error ? err.message : err}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Force-kill every process still in group `pgid`, addressed by the RECORDED
|
|
87
|
+
* pgid rather than a live ChildProcess. Unlike {@link signalGroup} /
|
|
88
|
+
* {@link terminateGroup} — which early-return once the group LEADER has exited —
|
|
89
|
+
* this still fires after a normal run finish, so it reaps backgrounded /
|
|
90
|
+
* `disown`ed / `nohup`ed grandchildren that reparented to pid 1 but kept the
|
|
91
|
+
* pgid (card #436: an SDK/CLI run that ends on its own used to leak them).
|
|
92
|
+
*
|
|
93
|
+
* Limitation: a grandchild that calls `setsid()` itself leaves the group and is
|
|
94
|
+
* NOT reachable this way. We accept that rare, deliberate escape — attributing
|
|
95
|
+
* it would require reading other processes' environments, which is unreliable on
|
|
96
|
+
* macOS (`ps -E` is restricted there and `setsid` isn't even available).
|
|
97
|
+
*
|
|
98
|
+
* Safety: pgid 0/1 (and the caller's own pid) are refused — `kill(-1)` would
|
|
99
|
+
* signal every process the user owns, and `kill(-0)` the caller's own group.
|
|
100
|
+
*/
|
|
101
|
+
export function reapGroup(pgid: number | undefined): void {
|
|
102
|
+
if (!pgid || pgid <= 1 || pgid === process.pid) return;
|
|
103
|
+
if (process.platform === "win32") return; // no process groups to sweep
|
|
104
|
+
try {
|
|
105
|
+
process.kill(-pgid, "SIGKILL");
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// ESRCH means the group is already empty — that is the goal, not an error.
|
|
108
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
109
|
+
if (code !== "ESRCH") {
|
|
110
|
+
log.warn(
|
|
111
|
+
TAG,
|
|
112
|
+
`reapGroup(${pgid}) failed: ${err instanceof Error ? err.message : err}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Escalating termination: SIGINT → wait → SIGTERM → wait → SIGKILL.
|
|
120
|
+
* Returns when the process has exited or all signals have been sent.
|
|
121
|
+
*/
|
|
122
|
+
export async function terminateGroup(
|
|
123
|
+
proc: ChildProcess,
|
|
124
|
+
opts: { sigintTimeoutMs: number; sigtermTimeoutMs: number },
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
if (!proc.pid || proc.killed) return;
|
|
127
|
+
|
|
128
|
+
// Unpause first in case the process was suspended — otherwise it
|
|
129
|
+
// can't react to signals.
|
|
130
|
+
signalGroup(proc, "SIGCONT");
|
|
131
|
+
|
|
132
|
+
const waitForExit = (timeout: number): Promise<boolean> =>
|
|
133
|
+
new Promise((resolve) => {
|
|
134
|
+
if (proc.killed || proc.exitCode !== null) return resolve(true);
|
|
135
|
+
const timer = setTimeout(() => resolve(false), timeout);
|
|
136
|
+
proc.once("exit", () => {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
resolve(true);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
signalGroup(proc, "SIGINT");
|
|
143
|
+
if (await waitForExit(opts.sigintTimeoutMs)) return;
|
|
144
|
+
|
|
145
|
+
signalGroup(proc, "SIGTERM");
|
|
146
|
+
if (await waitForExit(opts.sigtermTimeoutMs)) return;
|
|
147
|
+
|
|
148
|
+
signalGroup(proc, "SIGKILL");
|
|
149
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { log } from "./log.js";
|
|
4
|
+
import { spawnRunArgs } from "./pm.js";
|
|
5
|
+
|
|
6
|
+
const TAG = "project-type";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Known repo toolchains the daemon can build/verify.
|
|
10
|
+
*
|
|
11
|
+
* `node` delegates the package-manager variant (bun/npm/pnpm/yarn) down to
|
|
12
|
+
* {@link pm.ts}; the Swift kinds map onto `swift build` / `xcodebuild`.
|
|
13
|
+
* `unknown` means we have no idea how to build this repo and must skip
|
|
14
|
+
* verification gracefully rather than fail the card.
|
|
15
|
+
*/
|
|
16
|
+
export type ProjectKind = "node" | "swift-spm" | "swift-xcode" | "unknown";
|
|
17
|
+
|
|
18
|
+
export interface ProjectType {
|
|
19
|
+
kind: ProjectKind;
|
|
20
|
+
/** Absolute path to the `.xcworkspace` / `.xcodeproj` (swift-xcode only). */
|
|
21
|
+
xcodeContainer?: string;
|
|
22
|
+
/** True when {@link xcodeContainer} is a workspace (vs a bare project). */
|
|
23
|
+
xcodeIsWorkspace?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A resolved executable + args, ready for `execFileSync` / `spawn`. */
|
|
27
|
+
export interface ToolchainCommand {
|
|
28
|
+
cmd: string;
|
|
29
|
+
args: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Detection touches the filesystem (and `xcodebuild -list` for schemes), so
|
|
33
|
+
// cache per directory. Tests reset via `_resetCache()`.
|
|
34
|
+
const _cache = new Map<string, ProjectType>();
|
|
35
|
+
|
|
36
|
+
/** Clear the per-directory detection cache. Test-only. */
|
|
37
|
+
export function _resetCache(): void {
|
|
38
|
+
_cache.clear();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Detect the project toolchain rooted at `dir`.
|
|
43
|
+
*
|
|
44
|
+
* Priority: `package.json` (Node) → `Package.swift` (Swift SPM) →
|
|
45
|
+
* `*.xcworkspace` → `*.xcodeproj` → `unknown`. Node wins first because a
|
|
46
|
+
* Node repo may legitimately vendor Swift tooling without being a Swift app,
|
|
47
|
+
* and the Node path is what the vast majority of Harmony repos need.
|
|
48
|
+
*/
|
|
49
|
+
export function detect(dir: string): ProjectType {
|
|
50
|
+
const cached = _cache.get(dir);
|
|
51
|
+
if (cached) return cached;
|
|
52
|
+
|
|
53
|
+
const result = detectUncached(dir);
|
|
54
|
+
_cache.set(dir, result);
|
|
55
|
+
log.info(TAG, `Detected project type in ${dir}: ${result.kind}`);
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function detectUncached(dir: string): ProjectType {
|
|
60
|
+
if (existsSync(`${dir}/package.json`)) return { kind: "node" };
|
|
61
|
+
if (existsSync(`${dir}/Package.swift`)) return { kind: "swift-spm" };
|
|
62
|
+
|
|
63
|
+
const entries = safeReaddir(dir);
|
|
64
|
+
const workspace = entries.find((e) => e.endsWith(".xcworkspace"));
|
|
65
|
+
if (workspace) {
|
|
66
|
+
return {
|
|
67
|
+
kind: "swift-xcode",
|
|
68
|
+
xcodeContainer: `${dir}/${workspace}`,
|
|
69
|
+
xcodeIsWorkspace: true,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const project = entries.find((e) => e.endsWith(".xcodeproj"));
|
|
73
|
+
if (project) {
|
|
74
|
+
return {
|
|
75
|
+
kind: "swift-xcode",
|
|
76
|
+
xcodeContainer: `${dir}/${project}`,
|
|
77
|
+
xcodeIsWorkspace: false,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { kind: "unknown" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function safeReaddir(dir: string): string[] {
|
|
85
|
+
try {
|
|
86
|
+
return readdirSync(dir);
|
|
87
|
+
} catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the build command for the repo at `dir`, or `null` when there's no
|
|
94
|
+
* known/usable build step (caller must skip-and-warn, never fail the card).
|
|
95
|
+
*/
|
|
96
|
+
export function buildCommand(dir: string): ToolchainCommand | null {
|
|
97
|
+
const pt = detect(dir);
|
|
98
|
+
switch (pt.kind) {
|
|
99
|
+
case "node": {
|
|
100
|
+
const [cmd, args] = spawnRunArgs("build");
|
|
101
|
+
return { cmd, args };
|
|
102
|
+
}
|
|
103
|
+
case "swift-spm":
|
|
104
|
+
return { cmd: "swift", args: ["build"] };
|
|
105
|
+
case "swift-xcode":
|
|
106
|
+
return xcodeBuildCommand(pt);
|
|
107
|
+
case "unknown":
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve the lint command for the repo at `dir`, or `null` when the
|
|
114
|
+
* toolchain has no separate lint step (Swift verifies via the build).
|
|
115
|
+
*/
|
|
116
|
+
export function lintCommand(dir: string): ToolchainCommand | null {
|
|
117
|
+
const pt = detect(dir);
|
|
118
|
+
switch (pt.kind) {
|
|
119
|
+
case "node": {
|
|
120
|
+
const [cmd, args] = spawnRunArgs("lint");
|
|
121
|
+
return { cmd, args };
|
|
122
|
+
}
|
|
123
|
+
// Swift has no standard lint gate wired here — `swift build` /
|
|
124
|
+
// `xcodebuild build` already surface compile errors. SwiftLint is opt-in
|
|
125
|
+
// and absent on most machines, so skip rather than risk a false ENOENT.
|
|
126
|
+
case "swift-spm":
|
|
127
|
+
case "swift-xcode":
|
|
128
|
+
case "unknown":
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve a write-mode auto-fix command for the repo at `dir`, run *before* the
|
|
135
|
+
* lint check so deterministic formatter drift never reaches CI as a red Lint
|
|
136
|
+
* job (#691: a PR blocked purely on `biome check` formatter rules in new test
|
|
137
|
+
* files that the daemon's warn-only lint step let through). Toolchain-agnostic:
|
|
138
|
+
* it runs whatever auto-fixer the repo declares, preferring `lint:fix` (the
|
|
139
|
+
* write-mode of a `biome check` / `eslint` lint gate) over `format`. Returns
|
|
140
|
+
* `null` when the repo declares neither (or isn't Node) — the caller skips
|
|
141
|
+
* silently. Best-effort by contract: the lint *check* still runs afterwards and
|
|
142
|
+
* reports whatever the fixer left behind, so this never substitutes for lint.
|
|
143
|
+
*/
|
|
144
|
+
export function formatFixCommand(dir: string): ToolchainCommand | null {
|
|
145
|
+
if (detect(dir).kind !== "node") return null;
|
|
146
|
+
const script = firstNodeScript(dir, ["lint:fix", "format"]);
|
|
147
|
+
if (!script) return null;
|
|
148
|
+
const [cmd, args] = spawnRunArgs(script);
|
|
149
|
+
return { cmd, args };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resolve the test command for the repo at `dir`, or `null` when the repo has
|
|
154
|
+
* no runnable suite (caller must skip-and-warn, never fail the card — #688).
|
|
155
|
+
*
|
|
156
|
+
* Unlike build/lint, a missing test step is the *normal* case for many repos,
|
|
157
|
+
* so Node resolution reads `package.json` and only returns a command when a
|
|
158
|
+
* real `test` script exists — running `<pm> run test` against a repo without
|
|
159
|
+
* one would fail the card on a missing script rather than on a real regression.
|
|
160
|
+
*/
|
|
161
|
+
export function testCommand(dir: string): ToolchainCommand | null {
|
|
162
|
+
const pt = detect(dir);
|
|
163
|
+
switch (pt.kind) {
|
|
164
|
+
case "node": {
|
|
165
|
+
if (!hasNodeTestScript(dir)) return null;
|
|
166
|
+
const [cmd, args] = spawnRunArgs("test");
|
|
167
|
+
return { cmd, args };
|
|
168
|
+
}
|
|
169
|
+
case "swift-spm":
|
|
170
|
+
return { cmd: "swift", args: ["test"] };
|
|
171
|
+
// `xcodebuild test` needs a booted simulator destination (and often
|
|
172
|
+
// signing) that a headless verify pass can't assume. Skip rather than
|
|
173
|
+
// fail the card on an environment problem — same contract as lint.
|
|
174
|
+
case "swift-xcode":
|
|
175
|
+
case "unknown":
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The `npm init` placeholder — present but not a suite. Treat as no tests. */
|
|
181
|
+
const NPM_PLACEHOLDER_TEST = /no test specified/i;
|
|
182
|
+
|
|
183
|
+
function hasNodeTestScript(dir: string): boolean {
|
|
184
|
+
let script: unknown;
|
|
185
|
+
try {
|
|
186
|
+
const pkg = JSON.parse(readFileSync(`${dir}/package.json`, "utf-8")) as {
|
|
187
|
+
scripts?: Record<string, string>;
|
|
188
|
+
};
|
|
189
|
+
script = pkg.scripts?.test;
|
|
190
|
+
} catch (err) {
|
|
191
|
+
log.warn(
|
|
192
|
+
TAG,
|
|
193
|
+
`Could not read package.json in ${dir}: ${err instanceof Error ? err.message : err}`,
|
|
194
|
+
);
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (typeof script !== "string" || script.trim().length === 0) return false;
|
|
199
|
+
if (NPM_PLACEHOLDER_TEST.test(script)) {
|
|
200
|
+
log.info(
|
|
201
|
+
TAG,
|
|
202
|
+
`package.json 'test' is the npm placeholder — skipping tests`,
|
|
203
|
+
);
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* First of `candidates` that names a real, non-empty script in the repo's
|
|
211
|
+
* `package.json`. Returns `null` when none match or the manifest can't be read.
|
|
212
|
+
*/
|
|
213
|
+
function firstNodeScript(dir: string, candidates: string[]): string | null {
|
|
214
|
+
let scripts: Record<string, string>;
|
|
215
|
+
try {
|
|
216
|
+
const pkg = JSON.parse(readFileSync(`${dir}/package.json`, "utf-8")) as {
|
|
217
|
+
scripts?: Record<string, string>;
|
|
218
|
+
};
|
|
219
|
+
scripts = pkg.scripts ?? {};
|
|
220
|
+
} catch (err) {
|
|
221
|
+
log.warn(
|
|
222
|
+
TAG,
|
|
223
|
+
`Could not read package.json in ${dir}: ${err instanceof Error ? err.message : err}`,
|
|
224
|
+
);
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const name of candidates) {
|
|
229
|
+
const script = scripts[name];
|
|
230
|
+
if (typeof script === "string" && script.trim().length > 0) return name;
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Whether the repo supports the web dev-server deep review. Only Node repos
|
|
237
|
+
* boot a `dev` server on a port the reviewer can probe over HTTP.
|
|
238
|
+
*/
|
|
239
|
+
export function supportsDevServer(dir: string): boolean {
|
|
240
|
+
return detect(dir).kind === "node";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function xcodeBuildCommand(pt: ProjectType): ToolchainCommand | null {
|
|
244
|
+
const container = pt.xcodeContainer;
|
|
245
|
+
if (!container) return null;
|
|
246
|
+
|
|
247
|
+
const scheme = resolveXcodeScheme(pt);
|
|
248
|
+
if (!scheme) {
|
|
249
|
+
log.warn(
|
|
250
|
+
TAG,
|
|
251
|
+
"Could not resolve an Xcode scheme — skipping build (best-effort)",
|
|
252
|
+
);
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const containerFlag = pt.xcodeIsWorkspace ? "-workspace" : "-project";
|
|
257
|
+
return {
|
|
258
|
+
cmd: "xcodebuild",
|
|
259
|
+
args: [
|
|
260
|
+
containerFlag,
|
|
261
|
+
container,
|
|
262
|
+
"-scheme",
|
|
263
|
+
scheme,
|
|
264
|
+
// Build for a generic iOS device — no simulator/signing needed for a
|
|
265
|
+
// compile-only verification pass.
|
|
266
|
+
"-destination",
|
|
267
|
+
"generic/platform=iOS",
|
|
268
|
+
"CODE_SIGNING_ALLOWED=NO",
|
|
269
|
+
"build",
|
|
270
|
+
],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Resolve the first build scheme from `xcodebuild -list -json`. Best-effort:
|
|
276
|
+
* if xcodebuild is missing or the listing fails, return `null` so the caller
|
|
277
|
+
* skips the build instead of failing the card.
|
|
278
|
+
*/
|
|
279
|
+
function resolveXcodeScheme(pt: ProjectType): string | null {
|
|
280
|
+
if (!pt.xcodeContainer) return null;
|
|
281
|
+
const flag = pt.xcodeIsWorkspace ? "-workspace" : "-project";
|
|
282
|
+
try {
|
|
283
|
+
const out = execFileSync(
|
|
284
|
+
"xcodebuild",
|
|
285
|
+
["-list", "-json", flag, pt.xcodeContainer],
|
|
286
|
+
{ encoding: "utf-8", timeout: 30_000, stdio: "pipe" },
|
|
287
|
+
);
|
|
288
|
+
const parsed = JSON.parse(out) as {
|
|
289
|
+
workspace?: { schemes?: string[] };
|
|
290
|
+
project?: { schemes?: string[] };
|
|
291
|
+
};
|
|
292
|
+
const schemes = pt.xcodeIsWorkspace
|
|
293
|
+
? (parsed.workspace?.schemes ?? [])
|
|
294
|
+
: (parsed.project?.schemes ?? []);
|
|
295
|
+
return schemes[0] ?? null;
|
|
296
|
+
} catch (err) {
|
|
297
|
+
log.warn(
|
|
298
|
+
TAG,
|
|
299
|
+
`xcodebuild -list failed: ${err instanceof Error ? err.message : err}`,
|
|
300
|
+
);
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { log } from "./log.js";
|
|
3
|
+
|
|
4
|
+
const TAG = "revert-guard";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pre-Review guardrail against an implement branch silently reverting
|
|
8
|
+
* already-merged work (card #408).
|
|
9
|
+
*
|
|
10
|
+
* The classic failure mode: a branch built on a stale base (or an agent that
|
|
11
|
+
* resolved a conflict by keeping its own side) drops commits that landed on
|
|
12
|
+
* main — most damagingly the regression tests those commits added. Deleting a
|
|
13
|
+
* bound test removes the very safety net that would catch the regression, so a
|
|
14
|
+
* branch that deletes a test file relative to *current* main is treated as a
|
|
15
|
+
* likely accidental revert and blocked before it reaches Review.
|
|
16
|
+
*
|
|
17
|
+
* This is intentionally conservative: it flags only DELETED test files (not
|
|
18
|
+
* reverted hunks of source), which has a near-zero legitimate rate for an agent
|
|
19
|
+
* implementing a card. Broader reverted-hunk detection is deliberately deferred
|
|
20
|
+
* (noisier; needs per-commit analysis).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Matches test/spec files across the common JS/TS conventions. */
|
|
24
|
+
const TEST_FILE = /(?:^|\/)__tests__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
25
|
+
|
|
26
|
+
/** True when a repo-relative path names a test/spec file. */
|
|
27
|
+
export function isTestFile(path: string): boolean {
|
|
28
|
+
return TEST_FILE.test(path);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Filter a list of repo-relative paths down to the test/spec files. Pure
|
|
33
|
+
* helper, split out so the classification is unit-testable without git.
|
|
34
|
+
*/
|
|
35
|
+
export function filterTestFiles(paths: string[]): string[] {
|
|
36
|
+
return paths.filter(isTestFile);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Re-fetch the base branch so the deleted-file comparison runs against the
|
|
41
|
+
* CURRENT remote tip, not whatever was last fetched at worktree creation.
|
|
42
|
+
* Best-effort — a fetch failure here only weakens the guard, it must not crash
|
|
43
|
+
* verification (the worktree-creation fetch is the authoritative freshness gate).
|
|
44
|
+
*/
|
|
45
|
+
function refetchBase(worktreePath: string, baseBranch: string): void {
|
|
46
|
+
try {
|
|
47
|
+
execFileSync("git", ["fetch", "origin", baseBranch], {
|
|
48
|
+
cwd: worktreePath,
|
|
49
|
+
stdio: "pipe",
|
|
50
|
+
});
|
|
51
|
+
} catch {
|
|
52
|
+
log.warn(
|
|
53
|
+
TAG,
|
|
54
|
+
"Failed to re-fetch base for revert guard — using last fetch",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Files DELETED by the branch relative to the (re-fetched) base tip.
|
|
61
|
+
* `--diff-filter=D` selects deletions only; the three-dot range compares the
|
|
62
|
+
* branch tip against the merge-base with the base, so unrelated deletions that
|
|
63
|
+
* happened on main don't count.
|
|
64
|
+
*/
|
|
65
|
+
export function listDeletedFilesAgainstBase(
|
|
66
|
+
worktreePath: string,
|
|
67
|
+
baseBranch: string,
|
|
68
|
+
): string[] {
|
|
69
|
+
try {
|
|
70
|
+
const out = execFileSync(
|
|
71
|
+
"git",
|
|
72
|
+
["diff", "--diff-filter=D", "--name-only", `origin/${baseBranch}...HEAD`],
|
|
73
|
+
{ cwd: worktreePath, encoding: "utf-8" },
|
|
74
|
+
);
|
|
75
|
+
return out
|
|
76
|
+
.split("\n")
|
|
77
|
+
.map((l) => l.trim())
|
|
78
|
+
.filter((l) => l.length > 0);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
log.warn(
|
|
81
|
+
TAG,
|
|
82
|
+
`Failed to list deleted files: ${err instanceof Error ? err.message : err}`,
|
|
83
|
+
);
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Returns the test files the branch deletes relative to current main. A
|
|
90
|
+
* non-empty result means the branch likely reverted merged work and should be
|
|
91
|
+
* blocked before Review. Re-fetches the base first so the comparison is fresh.
|
|
92
|
+
*/
|
|
93
|
+
export function findDeletedTestFiles(
|
|
94
|
+
worktreePath: string,
|
|
95
|
+
baseBranch: string,
|
|
96
|
+
): string[] {
|
|
97
|
+
refetchBase(worktreePath, baseBranch);
|
|
98
|
+
return filterTestFiles(listDeletedFilesAgainstBase(worktreePath, baseBranch));
|
|
99
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `ReviewResult` family — pure data shapes for a code-review verdict.
|
|
3
|
+
*
|
|
4
|
+
* These types are produced by the daemon's `review-completion.ts` (which parses a
|
|
5
|
+
* review run's structured output) and consumed by the motor's `gate-collectors.ts`
|
|
6
|
+
* (which reads a `ReviewResult` to build `review_passed` gate evidence). Moving
|
|
7
|
+
* `review-completion.ts` itself into the motor is not possible — it also imports
|
|
8
|
+
* `board-helpers`, `completion`, `episode-writer`, `git-pr`, `state-store`, `types`,
|
|
9
|
+
* and `worktree`, all daemon-only, and the dependency direction is
|
|
10
|
+
* agent → harness → shared. So the type declarations move here (pure data, no
|
|
11
|
+
* daemon dependency) and the daemon's `review-completion.ts` imports them back
|
|
12
|
+
* from `@gethmy/harness` — same pattern used for `PlaybookMetricDef` in
|
|
13
|
+
* `exec-types.ts`: one definition, the daemon imports it, no duplication.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface ReviewFinding {
|
|
17
|
+
severity: "critical" | "major" | "minor";
|
|
18
|
+
title: string;
|
|
19
|
+
description: string;
|
|
20
|
+
category?: string;
|
|
21
|
+
location?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Whether the change under review introduced/exposed this issue (true) or it
|
|
24
|
+
* is a pre-existing issue the reviewer happened to notice (false). Only
|
|
25
|
+
* diff-caused findings gate the verdict (#478). Absent ⇒ treated as true (the
|
|
26
|
+
* verdict-leaning default).
|
|
27
|
+
*/
|
|
28
|
+
relatedToDiff?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ScopeCheck {
|
|
32
|
+
status: "clean" | "drift" | "missing";
|
|
33
|
+
notes?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One verified acceptance criterion: a requirement or subtask the reviewer
|
|
38
|
+
* checked against the diff. `fail`/`partial` are hard verdict gates (#478).
|
|
39
|
+
*/
|
|
40
|
+
export interface AcceptanceCheck {
|
|
41
|
+
criterion: string;
|
|
42
|
+
status: "pass" | "partial" | "fail" | "unverifiable";
|
|
43
|
+
evidence?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ReviewResult {
|
|
47
|
+
verdict: "approved" | "rejected" | "error";
|
|
48
|
+
summary: string;
|
|
49
|
+
scopeCheck?: ScopeCheck;
|
|
50
|
+
acceptanceChecks?: AcceptanceCheck[];
|
|
51
|
+
findings: ReviewFinding[];
|
|
52
|
+
}
|