@lyupro/codex-bridge 0.5.2 → 0.5.4
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/CHANGELOG.md +491 -336
- package/README.md +152 -152
- package/bin/codex-bridge.mjs +188 -174
- package/cli/doctor-format.mjs +23 -0
- package/cli/doctor-hooks.mjs +114 -0
- package/cli/doctor-installation.mjs +111 -0
- package/cli/doctor-runs.mjs +55 -0
- package/cli/doctor.mjs +35 -277
- package/cli/host-contract.mjs +135 -0
- package/cli/hosts.mjs +4 -3
- package/cli/install.mjs +31 -8
- package/cli/probe-contract.mjs +174 -0
- package/cli/settings-merge.mjs +55 -10
- package/package.json +49 -49
- package/scripts/run-tests.mjs +34 -2
- package/scripts/suite-count.mjs +65 -0
- package/src/agents/codex-build.md +265 -265
- package/src/agents/codex-review.md +219 -219
- package/src/agents/codex-scout.md +232 -232
- package/src/home/hooks/shell-write-intent.mjs +120 -0
- package/src/home/hooks/worktree-lock.mjs +109 -77
- package/src/home/hooks/worktree-witness.mjs +113 -0
- package/src/home/lib/brand-home.mjs +39 -0
- package/src/home/lib/heartbeat.mjs +4 -1
- package/src/home/lib/hook-definitions.mjs +10 -1
- package/src/home/lib/meta/reply.mjs +27 -0
- package/src/home/lib/run-config.mjs +18 -6
- package/src/home/lib/runner/codex-args.mjs +154 -0
- package/src/home/lib/runner/codex-cmd.mjs +6 -130
- package/src/home/lib/runner/launcher.mjs +18 -30
- package/src/home/lib/runner/sandbox-flags.mjs +27 -0
- package/src/home/lib/runner/worker-order.mjs +56 -0
- package/src/home/lib/write-meta.mjs +11 -3
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measures whether the installed host applies a PreToolUse refusal, not merely whether the hook
|
|
3
|
+
* returns one. The sibling `allow` decision silently stopped working between hosts 2.1.119 and
|
|
4
|
+
* 2.1.231, so a missing marker is trusted only when the hook fired and the host completed cleanly.
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { detectHostVersion, writeHostContract } from './host-contract.mjs';
|
|
11
|
+
|
|
12
|
+
export const PROBE_MARKER = 'codex-bridge-contract-probe';
|
|
13
|
+
|
|
14
|
+
const MARKER_NAME = `${PROBE_MARKER}.marker`;
|
|
15
|
+
const JOURNAL_NAME = 'probe-journal.log';
|
|
16
|
+
|
|
17
|
+
const exists = async (target) => {
|
|
18
|
+
try {
|
|
19
|
+
await fs.access(target);
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function judgeProbe({ markerExists, hookFired, hostResult }) {
|
|
27
|
+
if (markerExists) {
|
|
28
|
+
return { result: 'ignored', reason: 'The marker command ran despite the hook refusal.' };
|
|
29
|
+
}
|
|
30
|
+
if (!hostResult) {
|
|
31
|
+
return { result: null, reason: 'The host could not be started.' };
|
|
32
|
+
}
|
|
33
|
+
if (hostResult.error) {
|
|
34
|
+
const timedOut = hostResult.error.code === 'ETIMEDOUT';
|
|
35
|
+
return {
|
|
36
|
+
result: null,
|
|
37
|
+
reason: timedOut ? 'The host timed out before the probe completed.' : 'The host process failed to start.',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (hostResult.signal) {
|
|
41
|
+
return { result: null, reason: `The host was terminated by signal ${hostResult.signal}.` };
|
|
42
|
+
}
|
|
43
|
+
if (hostResult.status !== 0) {
|
|
44
|
+
return { result: null, reason: `The host exited with status ${String(hostResult.status)}.` };
|
|
45
|
+
}
|
|
46
|
+
if (!hookFired) {
|
|
47
|
+
return { result: null, reason: 'The probe hook never received the marker command.' };
|
|
48
|
+
}
|
|
49
|
+
return { result: 'honored', reason: 'The hook fired and the refused marker command did not run.' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const hookSource = `#!/usr/bin/env node
|
|
53
|
+
import fs from 'node:fs';
|
|
54
|
+
import { fileURLToPath } from 'node:url';
|
|
55
|
+
|
|
56
|
+
const marker = ${JSON.stringify(PROBE_MARKER)};
|
|
57
|
+
const journalPath = fileURLToPath(new URL('../${JOURNAL_NAME}', import.meta.url));
|
|
58
|
+
let input;
|
|
59
|
+
try {
|
|
60
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
61
|
+
} catch {
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (typeof input?.tool_input?.command === 'string' && input.tool_input.command.includes(marker)) {
|
|
66
|
+
fs.appendFileSync(journalPath, \`${PROBE_MARKER}\\n\`, 'utf8');
|
|
67
|
+
process.stdout.write(JSON.stringify({
|
|
68
|
+
hookSpecificOutput: {
|
|
69
|
+
hookEventName: 'PreToolUse',
|
|
70
|
+
permissionDecision: 'deny',
|
|
71
|
+
permissionDecisionReason: 'Contract probe refused its marker command.',
|
|
72
|
+
},
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
// Forward slashes on purpose: the host runs this hook command line through a POSIX shell, and a
|
|
78
|
+
// Windows path handed over verbatim arrives carrying backslashes that shell may swallow. The
|
|
79
|
+
// doubled form this replacement first carried matched nothing at all on a real path.
|
|
80
|
+
const shellQuote = (value) => `"${value.replaceAll('\\', '/').replaceAll('"', '\\"')}"`;
|
|
81
|
+
|
|
82
|
+
export async function buildRig(dir) {
|
|
83
|
+
const claudeDir = path.join(dir, '.claude');
|
|
84
|
+
const hooksDir = path.join(claudeDir, 'hooks');
|
|
85
|
+
const hookPath = path.join(hooksDir, 'probe-hook.mjs');
|
|
86
|
+
const markerPath = path.join(dir, MARKER_NAME);
|
|
87
|
+
const journalPath = path.join(claudeDir, JOURNAL_NAME);
|
|
88
|
+
const command = `touch ${MARKER_NAME}`;
|
|
89
|
+
const prompt = `Run exactly this Bash command and nothing else: ${command}`;
|
|
90
|
+
const settings = {
|
|
91
|
+
hooks: {
|
|
92
|
+
PreToolUse: [{
|
|
93
|
+
matcher: 'Bash',
|
|
94
|
+
hooks: [{ type: 'command', command: `${shellQuote(process.execPath)} ${shellQuote(hookPath)}` }],
|
|
95
|
+
}],
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
await fs.mkdir(hooksDir, { recursive: true });
|
|
100
|
+
await fs.writeFile(hookPath, hookSource, 'utf8');
|
|
101
|
+
await fs.writeFile(
|
|
102
|
+
path.join(claudeDir, 'settings.json'),
|
|
103
|
+
`${JSON.stringify(settings, null, 2)}\n`,
|
|
104
|
+
'utf8',
|
|
105
|
+
);
|
|
106
|
+
return { dir, markerPath, journalPath, command, prompt };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function probeContract({
|
|
110
|
+
host,
|
|
111
|
+
version = detectHostVersion(),
|
|
112
|
+
runHost = spawnSync,
|
|
113
|
+
rigRoot = os.tmpdir(),
|
|
114
|
+
now = new Date(),
|
|
115
|
+
}) {
|
|
116
|
+
if (version == null) {
|
|
117
|
+
return {
|
|
118
|
+
state: 'inconclusive',
|
|
119
|
+
result: null,
|
|
120
|
+
version: null,
|
|
121
|
+
message: 'The host version could not be read, so no contract probe was run.',
|
|
122
|
+
recorded: false,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await fs.mkdir(rigRoot, { recursive: true });
|
|
127
|
+
const dir = await fs.mkdtemp(path.join(rigRoot, `${PROBE_MARKER}-`));
|
|
128
|
+
try {
|
|
129
|
+
const rig = await buildRig(dir);
|
|
130
|
+
let hostResult;
|
|
131
|
+
try {
|
|
132
|
+
hostResult = runHost('claude', [
|
|
133
|
+
'--setting-sources', 'project',
|
|
134
|
+
'--allowedTools', 'Bash',
|
|
135
|
+
'-p', rig.prompt,
|
|
136
|
+
], {
|
|
137
|
+
cwd: rig.dir,
|
|
138
|
+
encoding: 'utf8',
|
|
139
|
+
shell: false,
|
|
140
|
+
timeout: 120000,
|
|
141
|
+
});
|
|
142
|
+
} catch (error) {
|
|
143
|
+
hostResult = { error };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const verdict = judgeProbe({
|
|
147
|
+
markerExists: await exists(rig.markerPath),
|
|
148
|
+
hookFired: await exists(rig.journalPath),
|
|
149
|
+
hostResult,
|
|
150
|
+
});
|
|
151
|
+
if (verdict.result == null) {
|
|
152
|
+
return {
|
|
153
|
+
state: 'inconclusive',
|
|
154
|
+
result: null,
|
|
155
|
+
version,
|
|
156
|
+
message: verdict.reason,
|
|
157
|
+
recorded: false,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await writeHostContract(host, { version, result: verdict.result, now });
|
|
162
|
+
return {
|
|
163
|
+
state: 'probed',
|
|
164
|
+
result: verdict.result,
|
|
165
|
+
version,
|
|
166
|
+
message: verdict.reason,
|
|
167
|
+
recorded: true,
|
|
168
|
+
};
|
|
169
|
+
} finally {
|
|
170
|
+
// Windows can retain a just-written hook tree briefly; cleanup failure must not erase a valid
|
|
171
|
+
// measurement or prevent the result from being recorded.
|
|
172
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
173
|
+
}
|
|
174
|
+
}
|
package/cli/settings-merge.mjs
CHANGED
|
@@ -134,29 +134,41 @@ const hasOwnCommand = (group, commands) => {
|
|
|
134
134
|
};
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
|
-
* Presence is decided by the command, not by the matcher it currently sits under
|
|
137
|
+
* Presence is decided by the command, not by the matcher it currently sits under; writing then
|
|
138
|
+
* places that command under the matcher the package declares. These are two halves of one rule.
|
|
138
139
|
*
|
|
139
140
|
* The matcher is generated from the tool list in hook-definitions.mjs, so it changes whenever a
|
|
140
141
|
* host spelling is added — `Agent` became `Agent|Task` in one release, and the write-tool matcher
|
|
141
142
|
* will grow the same way. A host installed under the old matcher would then be invisible to the
|
|
142
143
|
* new lookup: update would register a duplicate and uninstall would leave a hook pointing at a
|
|
143
|
-
* deleted file.
|
|
144
|
-
*
|
|
144
|
+
* deleted file. Conversely, the 2026-08-17 host proved that finding alone is insufficient: its
|
|
145
|
+
* worktree lock stayed under the pre-shell matcher, so Bash never reached the guard. The command
|
|
146
|
+
* is this package's own absolute path, so it identifies our entry wherever the group ended up,
|
|
147
|
+
* and merge can move it without creating a duplicate.
|
|
145
148
|
*/
|
|
146
149
|
export async function inspectHook(settingsPath, specOrPath) {
|
|
147
150
|
const state = await readSettings(settingsPath);
|
|
148
151
|
const spec = normalizeSpec(specOrPath);
|
|
149
152
|
const commands = [spec.command, ...spec.alternateCommands];
|
|
150
153
|
let matchedCommand;
|
|
154
|
+
let matchedMatcher;
|
|
155
|
+
let requiresMove = false;
|
|
151
156
|
for (const group of groups(state.settings, spec.event)) {
|
|
152
|
-
const hook
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
+
for (const hook of groupHooks(group)) {
|
|
158
|
+
if (hook?.type !== 'command' || !commands.includes(hook.command)) continue;
|
|
159
|
+
matchedCommand ??= hook.command;
|
|
160
|
+
matchedMatcher ??= group?.matcher;
|
|
161
|
+
if (group?.matcher !== spec.matcher) requiresMove = true;
|
|
157
162
|
}
|
|
158
163
|
}
|
|
159
|
-
return {
|
|
164
|
+
return {
|
|
165
|
+
...state,
|
|
166
|
+
...spec,
|
|
167
|
+
present: Boolean(matchedCommand),
|
|
168
|
+
matchedCommand,
|
|
169
|
+
matchedMatcher,
|
|
170
|
+
requiresMove,
|
|
171
|
+
};
|
|
160
172
|
}
|
|
161
173
|
|
|
162
174
|
function backupName(settingsPath) {
|
|
@@ -227,10 +239,43 @@ export async function mergeHook(settingsPath, specOrPath, inspected) {
|
|
|
227
239
|
return withMutationRun(settingsPath, async () => {
|
|
228
240
|
const spec = normalizeSpec(specOrPath);
|
|
229
241
|
const state = inspected || await inspectHook(settingsPath, spec);
|
|
230
|
-
if (state.present) return { changed: false, createdGroup: false };
|
|
242
|
+
if (state.present && !state.requiresMove) return { changed: false, createdGroup: false };
|
|
231
243
|
const settings = structuredClone(state.settings);
|
|
232
244
|
settings.hooks ??= {};
|
|
233
245
|
if (!Array.isArray(settings.hooks[spec.event])) settings.hooks[spec.event] = [];
|
|
246
|
+
if (state.present) {
|
|
247
|
+
const eventGroups = settings.hooks[spec.event];
|
|
248
|
+
const commands = [spec.command, ...spec.alternateCommands];
|
|
249
|
+
let hookToMove;
|
|
250
|
+
let keptDeclaredHook = false;
|
|
251
|
+
for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
|
|
252
|
+
const group = eventGroups[index];
|
|
253
|
+
if (!Array.isArray(group?.hooks)) continue;
|
|
254
|
+
const hadOwnHook = hasOwnCommand(group, commands);
|
|
255
|
+
group.hooks = group.hooks.filter((hook) => {
|
|
256
|
+
if (hook?.type !== 'command' || !commands.includes(hook.command)) return true;
|
|
257
|
+
hookToMove ??= hook;
|
|
258
|
+
if (group.matcher === spec.matcher && !keptDeclaredHook) {
|
|
259
|
+
keptDeclaredHook = true;
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
});
|
|
264
|
+
if (hadOwnHook && group.hooks.length === 0) eventGroups.splice(index, 1);
|
|
265
|
+
}
|
|
266
|
+
let group = eventGroups.find((entry) => entry?.matcher === spec.matcher);
|
|
267
|
+
const createdGroup = !group;
|
|
268
|
+
if (!keptDeclaredHook) {
|
|
269
|
+
if (!group) {
|
|
270
|
+
group = { matcher: spec.matcher, hooks: [] };
|
|
271
|
+
eventGroups.push(group);
|
|
272
|
+
}
|
|
273
|
+
if (!Array.isArray(group.hooks)) group.hooks = [];
|
|
274
|
+
group.hooks.push(hookToMove);
|
|
275
|
+
}
|
|
276
|
+
await atomicWrite(settingsPath, settings, state);
|
|
277
|
+
return { changed: true, createdGroup, moved: true };
|
|
278
|
+
}
|
|
234
279
|
let group = settings.hooks[spec.event].find((entry) => entry?.matcher === spec.matcher);
|
|
235
280
|
const createdGroup = !group;
|
|
236
281
|
if (!group) {
|
package/package.json
CHANGED
|
@@ -1,49 +1,49 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@lyupro/codex-bridge",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Claude Code-to-Codex delegation with
|
|
5
|
-
"keywords": [
|
|
6
|
-
"claude-code",
|
|
7
|
-
"codex",
|
|
8
|
-
"codex-cli",
|
|
9
|
-
"subagent",
|
|
10
|
-
"ai-agents",
|
|
11
|
-
"orchestration",
|
|
12
|
-
"hooks"
|
|
13
|
-
],
|
|
14
|
-
"license": "MIT",
|
|
15
|
-
"author": "Lyu Pro <lyupro.dev@gmail.com> (https://lyupro.com)",
|
|
16
|
-
"repository": {
|
|
17
|
-
"type": "git",
|
|
18
|
-
"url": "git+https://github.com/lyupro/codex-bridge.git"
|
|
19
|
-
},
|
|
20
|
-
"bugs": {
|
|
21
|
-
"url": "https://github.com/lyupro/codex-bridge/issues"
|
|
22
|
-
},
|
|
23
|
-
"homepage": "https://github.com/lyupro/codex-bridge#readme",
|
|
24
|
-
"type": "module",
|
|
25
|
-
"bin": {
|
|
26
|
-
"codex-bridge": "./bin/codex-bridge.mjs",
|
|
27
|
-
"codexb": "./bin/codex-bridge.mjs"
|
|
28
|
-
},
|
|
29
|
-
"engines": {
|
|
30
|
-
"node": ">=24.0.0"
|
|
31
|
-
},
|
|
32
|
-
"files": [
|
|
33
|
-
"bin",
|
|
34
|
-
"cli",
|
|
35
|
-
"src",
|
|
36
|
-
"scripts",
|
|
37
|
-
"README.md",
|
|
38
|
-
"LICENSE",
|
|
39
|
-
"CHANGELOG.md"
|
|
40
|
-
],
|
|
41
|
-
"scripts": {
|
|
42
|
-
"dev:install": "node bin/codex-bridge.mjs update --force",
|
|
43
|
-
"test": "node scripts/run-tests.mjs",
|
|
44
|
-
"check:size": "node scripts/check-file-size.mjs"
|
|
45
|
-
},
|
|
46
|
-
"publishConfig": {
|
|
47
|
-
"access": "public"
|
|
48
|
-
}
|
|
49
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@lyupro/codex-bridge",
|
|
3
|
+
"version": "0.5.4",
|
|
4
|
+
"description": "Claude Code-to-Codex delegation with 6 guards, 809 tests, zero runtime dependencies, and zero build steps.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"codex",
|
|
8
|
+
"codex-cli",
|
|
9
|
+
"subagent",
|
|
10
|
+
"ai-agents",
|
|
11
|
+
"orchestration",
|
|
12
|
+
"hooks"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Lyu Pro <lyupro.dev@gmail.com> (https://lyupro.com)",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/lyupro/codex-bridge.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/lyupro/codex-bridge/issues"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/lyupro/codex-bridge#readme",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": {
|
|
26
|
+
"codex-bridge": "./bin/codex-bridge.mjs",
|
|
27
|
+
"codexb": "./bin/codex-bridge.mjs"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=24.0.0"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"bin",
|
|
34
|
+
"cli",
|
|
35
|
+
"src",
|
|
36
|
+
"scripts",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"CHANGELOG.md"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"dev:install": "node bin/codex-bridge.mjs update --force",
|
|
43
|
+
"test": "node scripts/run-tests.mjs",
|
|
44
|
+
"check:size": "node scripts/check-file-size.mjs"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -16,21 +16,53 @@
|
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import os from 'node:os';
|
|
18
18
|
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
19
20
|
import { spawnSync } from 'node:child_process';
|
|
20
21
|
import { ISOLATED_ROOTS } from './isolated-roots.mjs';
|
|
22
|
+
import { readmeText, suiteCountMismatch } from './suite-count.mjs';
|
|
21
23
|
|
|
22
24
|
const created = ISOLATED_ROOTS.map((name) => [
|
|
23
25
|
name,
|
|
24
26
|
fs.mkdtempSync(path.join(os.tmpdir(), `codex-bridge-test-${name.toLowerCase()}-`)),
|
|
25
27
|
]);
|
|
28
|
+
// Fixtures created temporary trees no one removed, so keep them under one disposable root.
|
|
29
|
+
const testTmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-bridge-test-tmp-'));
|
|
26
30
|
const pattern = process.argv[2] || 'tests/**/*.test.mjs';
|
|
31
|
+
// Only a whole-suite run can judge the README's number; a single file legitimately reports two.
|
|
32
|
+
const wholeSuite = !process.argv[2];
|
|
33
|
+
const tapFile = wholeSuite
|
|
34
|
+
? path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'codex-bridge-test-summary-')), 'summary.tap')
|
|
35
|
+
: null;
|
|
36
|
+
const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
37
|
+
|
|
38
|
+
// The spec reporter is named explicitly because the second reporter would otherwise replace it,
|
|
39
|
+
// and the operator would lose the live output this command exists to show.
|
|
40
|
+
const reporters = tapFile
|
|
41
|
+
? ['--test-reporter=spec', '--test-reporter-destination=stdout',
|
|
42
|
+
'--test-reporter=tap', `--test-reporter-destination=${tapFile}`]
|
|
43
|
+
: [];
|
|
27
44
|
|
|
28
45
|
try {
|
|
29
|
-
const result = spawnSync(process.execPath, ['--test', pattern], {
|
|
46
|
+
const result = spawnSync(process.execPath, ['--test', ...reporters, pattern], {
|
|
30
47
|
stdio: 'inherit',
|
|
31
|
-
env: { ...process.env, ...Object.fromEntries(created) },
|
|
48
|
+
env: { ...process.env, ...Object.fromEntries(created), CODEX_BRIDGE_TEST_TMP: testTmpRoot },
|
|
32
49
|
});
|
|
33
50
|
process.exitCode = result.status ?? 1;
|
|
51
|
+
if (tapFile && process.exitCode === 0) {
|
|
52
|
+
let tap = null;
|
|
53
|
+
try {
|
|
54
|
+
tap = fs.readFileSync(tapFile, 'utf8');
|
|
55
|
+
} catch {
|
|
56
|
+
tap = null;
|
|
57
|
+
}
|
|
58
|
+
const mismatch = suiteCountMismatch(readmeText(path.join(repositoryRoot, 'README.md')), tap);
|
|
59
|
+
if (mismatch) {
|
|
60
|
+
process.stderr.write(`\nsuite-count: ${mismatch}\n`);
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
34
64
|
} finally {
|
|
35
65
|
for (const [, directory] of created) fs.rmSync(directory, { recursive: true, force: true });
|
|
66
|
+
fs.rmSync(testTmpRoot, { recursive: true, force: true });
|
|
67
|
+
if (tapFile) fs.rmSync(path.dirname(tapFile), { recursive: true, force: true });
|
|
36
68
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compares the README's advertised suite size with what the suite actually reported.
|
|
3
|
+
*
|
|
4
|
+
* The number is prose, so nothing checked it: README said 705 tests while the suite ran 751, and
|
|
5
|
+
* package.json still advertised 604 from three releases earlier. Both were found by eye during
|
|
6
|
+
* the 0.5.3 release, which is the third release in a row where a shipped number was wrong. The
|
|
7
|
+
* count is the first thing a reader judges the package by, and it goes stale silently with every
|
|
8
|
+
* test added — so the runner that knows the real number is the place the claim is verified.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
|
|
12
|
+
export const SUITE_CLAIM =
|
|
13
|
+
/\*\*(\d+) automated tests: (\d+) passing and (\d+) skipped\*\*/;
|
|
14
|
+
|
|
15
|
+
/** Read the TAP summary a second reporter wrote; null when the file says nothing usable. */
|
|
16
|
+
export function reportedCounts(tapOutput) {
|
|
17
|
+
if (typeof tapOutput !== 'string' || !tapOutput.trim()) return null;
|
|
18
|
+
const read = (key) => {
|
|
19
|
+
const found = tapOutput.match(new RegExp(`^# ${key} (\\d+)$`, 'm'));
|
|
20
|
+
return found ? Number(found[1]) : null;
|
|
21
|
+
};
|
|
22
|
+
const tests = read('tests');
|
|
23
|
+
const pass = read('pass');
|
|
24
|
+
const skipped = read('skipped');
|
|
25
|
+
if (tests === null || pass === null || skipped === null) return null;
|
|
26
|
+
return { tests, pass, skipped };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Read the claim out of README text; null when the sentence is absent. */
|
|
30
|
+
export function claimedCounts(readmeText) {
|
|
31
|
+
const found = typeof readmeText === 'string' ? readmeText.match(SUITE_CLAIM) : null;
|
|
32
|
+
if (!found) return null;
|
|
33
|
+
return { tests: Number(found[1]), pass: Number(found[2]), skipped: Number(found[3]) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Return the message a mismatch should print, or null when the claim holds. A missing claim is a
|
|
38
|
+
* mismatch too: the sentence is what the gate protects, and deleting it must not silence the gate.
|
|
39
|
+
*/
|
|
40
|
+
export function suiteCountMismatch(readmeText, tapOutput) {
|
|
41
|
+
const reported = reportedCounts(tapOutput);
|
|
42
|
+
if (!reported) return null;
|
|
43
|
+
const claimed = claimedCounts(readmeText);
|
|
44
|
+
if (!claimed) {
|
|
45
|
+
return 'README no longer states the suite size. Restore the sentence: '
|
|
46
|
+
+ `**${reported.tests} automated tests: ${reported.pass} passing and ${reported.skipped} skipped**.`;
|
|
47
|
+
}
|
|
48
|
+
if (claimed.tests === reported.tests
|
|
49
|
+
&& claimed.pass === reported.pass
|
|
50
|
+
&& claimed.skipped === reported.skipped) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return 'README states the wrong suite size. '
|
|
54
|
+
+ `It says ${claimed.tests} tests (${claimed.pass} passing, ${claimed.skipped} skipped); `
|
|
55
|
+
+ `this run had ${reported.tests} (${reported.pass} passing, ${reported.skipped} skipped). `
|
|
56
|
+
+ 'Update the sentence in README.md before the release.';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function readmeText(readmePath) {
|
|
60
|
+
try {
|
|
61
|
+
return fs.readFileSync(readmePath, 'utf8');
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|