@boyingliu01/xp-gate 0.35.21 → 0.35.22

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.
Files changed (31) hide show
  1. package/bin/xp-gate.js +12 -0
  2. package/lib/__tests__/agent-runtime-journal.test.js +302 -0
  3. package/lib/__tests__/mirror-sync.test.js +277 -0
  4. package/lib/__tests__/runtime-journal.test.js +164 -0
  5. package/lib/agent-runtime-journal.js +347 -0
  6. package/lib/mattpocock-license-verdict.json +1 -1
  7. package/lib/mirror-sync.js +335 -0
  8. package/lib/runtime-journal.js +156 -0
  9. package/mock-policy/AGENTS.md +2 -2
  10. package/mutation/AGENTS.md +2 -2
  11. package/package.json +1 -1
  12. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  13. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  14. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  15. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  16. package/plugins/dsh/package.json +1 -1
  17. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  18. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  19. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  20. package/plugins/opencode/package.json +1 -1
  21. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  22. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  23. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  24. package/plugins/qoder/plugin.json +1 -1
  25. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  26. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  27. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  28. package/principles/AGENTS.md +2 -2
  29. package/skills/delphi-review/AGENTS.md +2 -2
  30. package/skills/sprint-flow/AGENTS.md +2 -2
  31. package/skills/test-specification-alignment/AGENTS.md +2 -2
package/bin/xp-gate.js CHANGED
@@ -17,6 +17,8 @@ const { arch } = require('../lib/arch.js');
17
17
  const { upgrade } = require('../lib/upgrade.js');
18
18
  const { bootstrap } = require('../lib/bootstrap.js');
19
19
  const { updateHooks } = require('../lib/update-hooks.js');
20
+ const { handleRuntimeJournal } = require('../lib/runtime-journal.js');
21
+ const { handleSyncMirrors } = require('../lib/mirror-sync.js');
20
22
  const gateRunner = require('../lib/gate-runner.js');
21
23
 
22
24
  // #392-#6: a user-supplied path embedded in a shell command string lets cmd.exe
@@ -350,6 +352,16 @@ const COMMANDS = {
350
352
  },
351
353
  usage: 'xp-gate retro [--days N] [--all] [--json] [--sprint <id>]'
352
354
  },
355
+ 'runtime-journal': {
356
+ description: 'Measure the AGENTS.md runtime rules (blocking waits, sleep/polling, call batching) from agent session journals (#712)',
357
+ run: subargs => process.exit(handleRuntimeJournal(subargs)),
358
+ usage: 'xp-gate runtime-journal [--days N] [--json] [--sessions-dir <path>]'
359
+ },
360
+ 'sync-mirrors': {
361
+ description: 'Report or repair drift between canonical trees and their npm-package mirrors; dry-run unless --write (#713)',
362
+ run: subargs => process.exit(handleSyncMirrors(subargs)),
363
+ usage: 'xp-gate sync-mirrors [--write] [--json]'
364
+ },
353
365
  'gate-coverage': {
354
366
  description: 'One view of compliance against every gate: PASS / DEBT / SKIP / FAIL, with debt and evidence (#636)',
355
367
  run: subargs => {
@@ -0,0 +1,302 @@
1
+ /**
2
+ * TDD: written RED first (per AGENTS.md TDD-GATE spec).
3
+ * @test REQ-712 Agent runtime efficiency must be measurable from session journals
4
+ * @intent Expose the four AGENTS.md runtime anti-patterns (blocking job_output,
5
+ * literal sleep, CI polling loops, serial tool calls) as machine-checkable
6
+ * numbers instead of a prose rule that nobody measures.
7
+ * @covers AC-712-01
8
+ * @covers AC-712-02
9
+ * @covers AC-712-03
10
+ * @covers AC-712-04
11
+ * @covers AC-712-05
12
+ */
13
+ 'use strict';
14
+
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+ const zlib = require('zlib');
19
+
20
+ const {
21
+ analyseSessions,
22
+ parseSessionBuffer,
23
+ classifyCommand,
24
+ summariseForSlot,
25
+ DEFAULT_MAX_PLAUSIBLE_MS,
26
+ } = require('../agent-runtime-journal');
27
+
28
+ let TMP_DIR;
29
+
30
+ beforeAll(() => {
31
+ TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'runtime-journal-'));
32
+ });
33
+
34
+ afterAll(() => {
35
+ fs.rmSync(TMP_DIR, { recursive: true, force: true });
36
+ });
37
+
38
+ /**
39
+ * Build a session journal buffer from a compact event list.
40
+ * @param {Array<object>} events
41
+ * @returns {string}
42
+ */
43
+ function journalText(events) {
44
+ return events.map((e) => JSON.stringify(e)).join('\n') + '\n';
45
+ }
46
+
47
+ /**
48
+ * Emit a tool/call + tool/result pair at the given times.
49
+ * @param {string} callId
50
+ * @param {string} name
51
+ * @param {object} args
52
+ * @param {number} startT
53
+ * @param {number} endT
54
+ * @param {{isError?: boolean, text?: string}} [result]
55
+ * @param {{turn?: number, step?: number}} [pos]
56
+ * @returns {Array<object>}
57
+ */
58
+ function toolPair(callId, name, args, startT, endT, result = {}, pos = {}) {
59
+ const turn = pos.turn ?? 1;
60
+ const step = pos.step ?? 1;
61
+ return [
62
+ { type: 'tool/call', time: startT, data: { turn, step, callId, name, arguments: JSON.stringify(args) } },
63
+ {
64
+ type: 'tool/result',
65
+ time: endT,
66
+ data: {
67
+ turn,
68
+ step,
69
+ message: {
70
+ role: 'tool',
71
+ toolCallId: callId,
72
+ isError: !!result.isError,
73
+ content: [{ type: 'text', text: result.text ?? 'ok' }],
74
+ },
75
+ },
76
+ },
77
+ ];
78
+ }
79
+
80
+ describe('parseSessionBuffer', () => {
81
+ it('reads a zstd-compressed session journal', () => {
82
+ const events = toolPair('c1', 'read', { file_path: '/a.ts' }, 1000, 1010);
83
+ const buf = zlib.zstdCompressSync(Buffer.from(journalText(events), 'utf8'));
84
+ const parsed = parseSessionBuffer(buf);
85
+ expect(parsed).toHaveLength(2);
86
+ expect(parsed[0].type).toBe('tool/call');
87
+ });
88
+
89
+ it('reads an uncompressed journal too, so fixtures need no encoder', () => {
90
+ const events = toolPair('c1', 'read', { file_path: '/a.ts' }, 1000, 1010);
91
+ const parsed = parseSessionBuffer(Buffer.from(journalText(events), 'utf8'));
92
+ expect(parsed).toHaveLength(2);
93
+ });
94
+
95
+ it('skips malformed lines instead of throwing away the whole journal', () => {
96
+ const good = toolPair('c1', 'read', {}, 1000, 1010).map((e) => JSON.stringify(e));
97
+ const text = [good[0], 'not json at all', good[1]].join('\n');
98
+ const parsed = parseSessionBuffer(Buffer.from(text, 'utf8'));
99
+ expect(parsed).toHaveLength(2);
100
+ });
101
+ });
102
+
103
+ describe('classifyCommand', () => {
104
+ it('flags a literal sleep', () => {
105
+ expect(classifyCommand('cd /repo && sleep 600; echo ok').sleep).toBe(true);
106
+ });
107
+
108
+ it('does not flag words that merely contain "sleep"', () => {
109
+ expect(classifyCommand('echo sleepless').sleep).toBe(false);
110
+ });
111
+
112
+ it('flags a seq-driven polling loop', () => {
113
+ expect(classifyCommand('for i in $(seq 1 20); do gh run list; done').pollLoop).toBe(true);
114
+ });
115
+
116
+ it('flags gh run status polling', () => {
117
+ expect(classifyCommand('gh run list --limit 4 --json status').ciPolling).toBe(true);
118
+ expect(classifyCommand('gh run watch 123').ciPolling).toBe(true);
119
+ });
120
+
121
+ it('flags an oversized explicit timeout', () => {
122
+ expect(classifyCommand('timeout 1800 npx vitest run').oversizedTimeout).toBe(true);
123
+ expect(classifyCommand('timeout 60 npx vitest run').oversizedTimeout).toBe(false);
124
+ });
125
+ });
126
+
127
+ describe('analyseSessions — AC-712-01 blocking job_output', () => {
128
+ it('counts job_output calls that block longer than the threshold', () => {
129
+ const events = [
130
+ ...toolPair('j1', 'job_output', { job_id: 'b1', wait: true }, 0, 120000),
131
+ ...toolPair('j2', 'job_output', { job_id: 'b2' }, 200000, 200010),
132
+ ];
133
+ const dir = path.join(TMP_DIR, 'a');
134
+ fs.mkdirSync(dir, { recursive: true });
135
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText(events));
136
+
137
+ const report = analyseSessions([dir]);
138
+ expect(report.blockingJobOutput.count).toBe(1);
139
+ expect(report.blockingJobOutput.totalMs).toBe(120000);
140
+ });
141
+
142
+ it('counts explicit wait:true separately — the rule names that exact mistake', () => {
143
+ const events = [
144
+ ...toolPair('j1', 'job_output', { job_id: 'b1', wait: true }, 0, 1000),
145
+ ...toolPair('j2', 'job_output', { job_id: 'b2', wait: true }, 2000, 3000),
146
+ ...toolPair('j3', 'job_output', { job_id: 'b3' }, 4000, 5000),
147
+ ];
148
+ const dir = path.join(TMP_DIR, 'b');
149
+ fs.mkdirSync(dir, { recursive: true });
150
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText(events));
151
+
152
+ const report = analyseSessions([dir]);
153
+ expect(report.blockingJobOutput.explicitWaitTrue).toBe(2);
154
+ });
155
+ });
156
+
157
+ describe('analyseSessions — AC-712-02 idle waiting', () => {
158
+ it('totals literal-sleep commands', () => {
159
+ const events = [
160
+ ...toolPair('s1', 'bash', { command: 'sleep 300; echo done' }, 0, 300000),
161
+ ...toolPair('s2', 'bash', { command: 'npx tsc --noEmit' }, 310000, 320000),
162
+ ];
163
+ const dir = path.join(TMP_DIR, 'c');
164
+ fs.mkdirSync(dir, { recursive: true });
165
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText(events));
166
+
167
+ const report = analyseSessions([dir]);
168
+ expect(report.idleWait.sleep.count).toBe(1);
169
+ expect(report.idleWait.sleep.totalMs).toBe(300000);
170
+ });
171
+
172
+ it('totals CI polling separately from sleep so the cause stays visible', () => {
173
+ const events = [
174
+ ...toolPair('p1', 'bash', { command: 'gh run list --limit 3' }, 0, 5000),
175
+ ...toolPair('p2', 'bash', { command: 'for i in $(seq 1 30); do sleep 10; done' }, 10000, 600000),
176
+ ];
177
+ const dir = path.join(TMP_DIR, 'd');
178
+ fs.mkdirSync(dir, { recursive: true });
179
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText(events));
180
+
181
+ const report = analyseSessions([dir]);
182
+ expect(report.idleWait.ciPolling.count).toBe(1);
183
+ expect(report.idleWait.pollLoop.count).toBe(1);
184
+ });
185
+ });
186
+
187
+ describe('analyseSessions — AC-712-03 call batching', () => {
188
+ it('reports calls per step and the share of batched steps', () => {
189
+ // step 1: two concurrent calls; step 2: one call.
190
+ const s1 = [
191
+ ...toolPair('a', 'read', {}, 0, 5, {}, { step: 1 }),
192
+ ...toolPair('b', 'grep', {}, 0, 5, {}, { step: 1 }),
193
+ ];
194
+ const s2 = toolPair('c', 'bash', { command: 'git status' }, 100, 150, {}, { step: 2 });
195
+ const dir = path.join(TMP_DIR, 'e');
196
+ fs.mkdirSync(dir, { recursive: true });
197
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText([...s1, ...s2]));
198
+
199
+ const report = analyseSessions([dir]);
200
+ expect(report.batching.stepsWithCalls).toBe(2);
201
+ expect(report.batching.stepsWithMultipleCalls).toBe(1);
202
+ expect(report.batching.batchedRatioPct).toBe(50);
203
+ expect(report.batching.callsPerStep).toBeCloseTo(1.5, 5);
204
+ });
205
+ });
206
+
207
+ describe('analyseSessions — AC-712-04 implausible durations', () => {
208
+ it('excludes resume/flush artifacts above the plausible ceiling', () => {
209
+ const huge = DEFAULT_MAX_PLAUSIBLE_MS + 1;
210
+ const events = [
211
+ ...toolPair('x1', 'bash', { command: 'echo hi' }, 0, huge),
212
+ ...toolPair('x2', 'bash', { command: 'echo hi' }, huge + 1000, huge + 1010),
213
+ ];
214
+ const dir = path.join(TMP_DIR, 'f');
215
+ fs.mkdirSync(dir, { recursive: true });
216
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText(events));
217
+
218
+ const report = analyseSessions([dir]);
219
+ expect(report.implausible.count).toBe(1);
220
+ // The surviving sample must not be averaged with the excluded one.
221
+ expect(report.toolCalls).toBe(2);
222
+ expect(report.timedCalls).toBe(1);
223
+ });
224
+ });
225
+
226
+ describe('summariseForSlot — AC-712-05 output contract', () => {
227
+ it('emits a one-screen summary naming every measured anti-pattern', () => {
228
+ const events = [
229
+ ...toolPair('j1', 'job_output', { wait: true }, 0, 600000),
230
+ ...toolPair('s1', 'bash', { command: 'sleep 60' }, 700000, 760000),
231
+ ];
232
+ const dir = path.join(TMP_DIR, 'g');
233
+ fs.mkdirSync(dir, { recursive: true });
234
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText(events));
235
+
236
+ const report = analyseSessions([dir]);
237
+ const lines = summariseForSlot(report);
238
+ expect(Array.isArray(lines)).toBe(true);
239
+ expect(lines.length).toBeLessThanOrEqual(15);
240
+ const text = lines.join('\n');
241
+ expect(text).toMatch(/job_output/i);
242
+ expect(text).toMatch(/sleep/i);
243
+ });
244
+
245
+ it('says NOT MEASURABLE rather than 0% when there are no steps to measure', () => {
246
+ const dir = path.join(TMP_DIR, 'h');
247
+ fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
248
+ // Journal with no tool calls at all.
249
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText([{ type: 'session', time: 0 }]));
250
+
251
+ const report = analyseSessions([dir]);
252
+ const text = summariseForSlot(report).join('\n');
253
+ expect(text).toMatch(/NOT MEASURABLE/i);
254
+ });
255
+ });
256
+
257
+ describe('analyseSessions — AC-712-06 time window', () => {
258
+ it('counts only calls inside [sinceMs, untilMs) so a before/after A/B is possible', () => {
259
+ const events = [
260
+ // Before the window: one 600s sleep that must NOT be counted.
261
+ ...toolPair('old', 'bash', { command: 'sleep 600' }, 1000, 601000),
262
+ // Inside the window: one 60s sleep that must be counted.
263
+ ...toolPair('new', 'bash', { command: 'sleep 60' }, 2_000_000, 2_060_000),
264
+ ];
265
+ const dir = path.join(TMP_DIR, 'win');
266
+ fs.mkdirSync(dir, { recursive: true });
267
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText(events));
268
+
269
+ const report = analyseSessions([dir], { sinceMs: 1_500_000 });
270
+ expect(report.idleWait.sleep.count).toBe(1);
271
+ expect(report.idleWait.sleep.totalMs).toBe(60_000);
272
+ });
273
+
274
+ it('excludes calls at or after untilMs', () => {
275
+ const events = [
276
+ ...toolPair('in', 'bash', { command: 'sleep 60' }, 2_000_000, 2_060_000),
277
+ ...toolPair('after', 'bash', { command: 'sleep 300' }, 3_000_000, 3_300_000),
278
+ ];
279
+ const dir = path.join(TMP_DIR, 'win2');
280
+ fs.mkdirSync(dir, { recursive: true });
281
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText(events));
282
+
283
+ const report = analyseSessions([dir], { sinceMs: 1_000_000, untilMs: 2_500_000 });
284
+ expect(report.idleWait.sleep.count).toBe(1);
285
+ expect(report.idleWait.sleep.totalMs).toBe(60_000);
286
+ });
287
+ });
288
+
289
+ describe('analyseSessions — robustness', () => {
290
+ it('ignores a directory with no journal instead of crashing', () => {
291
+ const dir = path.join(TMP_DIR, 'empty');
292
+ fs.mkdirSync(dir, { recursive: true });
293
+ const report = analyseSessions([dir]);
294
+ expect(report.toolCalls).toBe(0);
295
+ });
296
+
297
+ it('returns an empty-but-shaped report for an empty session list', () => {
298
+ const report = analyseSessions([]);
299
+ expect(report.sessions).toBe(0);
300
+ expect(report.batching.stepsWithCalls).toBe(0);
301
+ });
302
+ });
@@ -0,0 +1,277 @@
1
+ /**
2
+ * TDD: written RED first (per AGENTS.md TDD-GATE spec).
3
+ * @test REQ-713 Mirror checks must ignore untracked build artifacts, and a
4
+ * one-shot sync must bring every mirror back to byte parity
5
+ * @intent The hook-lib mirror check failed on a machine where `githooks/lib`
6
+ * held an untracked `__pycache__/` — Python byte-compiled the helper during a
7
+ * test run. The canonical file set was otherwise byte-identical to the mirror.
8
+ * A false failure here costs a real fix cycle at commit time, which is exactly
9
+ * the rework this task set out to remove. The check must compare what git
10
+ * tracks, and a single command must repair drift instead of hand-copying.
11
+ * @covers AC-713-01
12
+ * @covers AC-713-02
13
+ * @covers AC-713-03
14
+ * @covers AC-713-04
15
+ */
16
+ 'use strict';
17
+
18
+ const fs = require('fs');
19
+ const os = require('os');
20
+ const path = require('path');
21
+ const { execFileSync } = require('child_process');
22
+
23
+ const {
24
+ collectMirrorPairs,
25
+ diffTrees,
26
+ syncMirrors,
27
+ handleSyncMirrors,
28
+ SYNC_EXCLUDES,
29
+ } = require('../mirror-sync');
30
+
31
+ let TMP_REPO;
32
+
33
+ beforeEach(() => {
34
+ TMP_REPO = fs.mkdtempSync(path.join(os.tmpdir(), 'mirror-sync-'));
35
+ execFileSync('git', ['init', '-q'], { cwd: TMP_REPO });
36
+ execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: TMP_REPO });
37
+ execFileSync('git', ['config', 'user.name', 'Test'], { cwd: TMP_REPO });
38
+ });
39
+
40
+ afterEach(() => {
41
+ fs.rmSync(TMP_REPO, { recursive: true, force: true });
42
+ });
43
+
44
+ /**
45
+ * Write a file, creating parent directories.
46
+ * @param {string} root
47
+ * @param {string} rel
48
+ * @param {string} content
49
+ */
50
+ function write(root, rel, content) {
51
+ const full = path.join(root, rel);
52
+ fs.mkdirSync(path.dirname(full), { recursive: true });
53
+ fs.writeFileSync(full, content);
54
+ }
55
+
56
+ /**
57
+ * Stage everything so the files count as tracked.
58
+ * @param {string} root
59
+ */
60
+ function trackAll(root) {
61
+ execFileSync('git', ['add', '-A', '-f'], { cwd: root });
62
+ }
63
+
64
+ describe('diffTrees — AC-713-01 untracked artifacts are not drift', () => {
65
+ it('ignores an untracked build artifact in the canonical tree', () => {
66
+ write(TMP_REPO, 'canonical/a.sh', 'echo a\n');
67
+ write(TMP_REPO, 'mirror/a.sh', 'echo a\n');
68
+ // The artifact that broke the real check: untracked, gitignored.
69
+ write(TMP_REPO, 'canonical/__pycache__/helper.pyc', 'binary');
70
+ write(TMP_REPO, '.gitignore', '__pycache__/\n');
71
+ trackAll(TMP_REPO);
72
+
73
+ const diff = diffTrees(TMP_REPO, 'canonical', 'mirror');
74
+ expect(diff.missing).toEqual([]);
75
+ expect(diff.differing).toEqual([]);
76
+ expect(diff.inSync).toBe(true);
77
+ });
78
+
79
+ it('still reports a genuinely missing mirrored file', () => {
80
+ write(TMP_REPO, 'canonical/a.sh', 'echo a\n');
81
+ write(TMP_REPO, 'canonical/b.sh', 'echo b\n');
82
+ write(TMP_REPO, 'mirror/a.sh', 'echo a\n');
83
+ trackAll(TMP_REPO);
84
+
85
+ const diff = diffTrees(TMP_REPO, 'canonical', 'mirror');
86
+ expect(diff.inSync).toBe(false);
87
+ expect(diff.missing).toContain('b.sh');
88
+ });
89
+
90
+ it('reports byte differences, not just missing files', () => {
91
+ write(TMP_REPO, 'canonical/a.sh', 'echo a\n');
92
+ write(TMP_REPO, 'mirror/a.sh', 'echo CHANGED\n');
93
+ trackAll(TMP_REPO);
94
+
95
+ const diff = diffTrees(TMP_REPO, 'canonical', 'mirror');
96
+ expect(diff.inSync).toBe(false);
97
+ expect(diff.differing).toContain('a.sh');
98
+ });
99
+
100
+ it('excludes declared noise directories even when they are tracked', () => {
101
+ write(TMP_REPO, 'canonical/a.sh', 'echo a\n');
102
+ write(TMP_REPO, 'mirror/a.sh', 'echo a\n');
103
+ write(TMP_REPO, 'canonical/node_modules/x.js', 'x');
104
+ write(TMP_REPO, '.gitignore', 'node_modules/\n');
105
+ trackAll(TMP_REPO);
106
+
107
+ const diff = diffTrees(TMP_REPO, 'canonical', 'mirror');
108
+ expect(diff.inSync).toBe(true);
109
+ expect(SYNC_EXCLUDES).toContain('node_modules');
110
+ });
111
+ });
112
+
113
+ describe('diffTrees — per-pair excludes', () => {
114
+ it('honours a pair-specific exclude, matching the guard script it mirrors', () => {
115
+ // check-hook-mirror.sh:98 compares src/principles with `-x __tests__`.
116
+ // A sync that copied tests into the package would ship test code and make
117
+ // the guard fail on the very next run.
118
+ write(TMP_REPO, 'canonical/lib.ts', 'export const a = 1;\n');
119
+ write(TMP_REPO, 'canonical/__tests__/lib.test.ts', 'test\n');
120
+ write(TMP_REPO, 'mirror/lib.ts', 'export const a = 1;\n');
121
+ trackAll(TMP_REPO);
122
+
123
+ const withoutExclude = diffTrees(TMP_REPO, 'canonical', 'mirror');
124
+ expect(withoutExclude.inSync).toBe(false);
125
+
126
+ const withExclude = diffTrees(TMP_REPO, 'canonical', 'mirror', { exclude: ['__tests__'] });
127
+ expect(withExclude.inSync).toBe(true);
128
+ expect(withExclude.missing).toEqual([]);
129
+ });
130
+ });
131
+
132
+ describe('collectMirrorPairs — AC-713-02 pair discovery', () => {
133
+ it('discovers a pair only when both sides exist', () => {
134
+ write(TMP_REPO, 'githooks/lib/a.sh', 'echo a\n');
135
+ write(TMP_REPO, 'src/npm-package/hooks/lib/a.sh', 'echo a\n');
136
+ trackAll(TMP_REPO);
137
+
138
+ const pairs = collectMirrorPairs(TMP_REPO);
139
+ const found = pairs.find((p) => p.canonical === 'githooks/lib' && p.mirror === 'src/npm-package/hooks/lib');
140
+ expect(found).toBeDefined();
141
+ });
142
+
143
+ it('does not invent a pair for a canonical tree with no mirror', () => {
144
+ write(TMP_REPO, 'githooks/lib/a.sh', 'echo a\n');
145
+ trackAll(TMP_REPO);
146
+
147
+ const pairs = collectMirrorPairs(TMP_REPO);
148
+ const found = pairs.find((p) => p.canonical === 'githooks/lib');
149
+ expect(found).toBeUndefined();
150
+ });
151
+ });
152
+
153
+ describe('syncMirrors — AC-713-03 repair instead of hand-copying', () => {
154
+ it('copies a missing file and produces parity', () => {
155
+ write(TMP_REPO, 'canonical/a.sh', 'echo a\n');
156
+ write(TMP_REPO, 'canonical/b.sh', 'echo b\n');
157
+ write(TMP_REPO, 'mirror/a.sh', 'echo a\n');
158
+ write(TMP_REPO, 'mirror/b.sh', 'echo b\n');
159
+ trackAll(TMP_REPO);
160
+
161
+ // Remove the mirror copy to simulate drift.
162
+ fs.rmSync(path.join(TMP_REPO, 'mirror/b.sh'));
163
+
164
+ const result = syncMirrors(TMP_REPO, [{ canonical: 'canonical', mirror: 'mirror' }]);
165
+ expect(result.copied).toContain('b.sh');
166
+ expect(diffTrees(TMP_REPO, 'canonical', 'mirror').inSync).toBe(true);
167
+ });
168
+
169
+ it('rewrites a differing file to match the canonical bytes', () => {
170
+ write(TMP_REPO, 'canonical/a.sh', 'echo canonical\n');
171
+ write(TMP_REPO, 'mirror/a.sh', 'echo stale\n');
172
+ trackAll(TMP_REPO);
173
+
174
+ syncMirrors(TMP_REPO, [{ canonical: 'canonical', mirror: 'mirror' }]);
175
+ expect(fs.readFileSync(path.join(TMP_REPO, 'mirror/a.sh'), 'utf8')).toBe('echo canonical\n');
176
+ });
177
+
178
+ it('is idempotent — a second run changes nothing', () => {
179
+ write(TMP_REPO, 'canonical/a.sh', 'echo a\n');
180
+ write(TMP_REPO, 'mirror/a.sh', 'echo a\n');
181
+ trackAll(TMP_REPO);
182
+
183
+ syncMirrors(TMP_REPO, [{ canonical: 'canonical', mirror: 'mirror' }]);
184
+ const second = syncMirrors(TMP_REPO, [{ canonical: 'canonical', mirror: 'mirror' }]);
185
+ expect(second.copied).toEqual([]);
186
+ expect(second.cleaned).toEqual([]);
187
+ });
188
+
189
+ it('supports --dry-run so the caller can preview before writing', () => {
190
+ write(TMP_REPO, 'canonical/a.sh', 'echo fresh\n');
191
+ write(TMP_REPO, 'mirror/a.sh', 'echo stale\n');
192
+ trackAll(TMP_REPO);
193
+
194
+ const result = syncMirrors(TMP_REPO, [{ canonical: 'canonical', mirror: 'mirror' }], { dryRun: true });
195
+ expect(result.copied).toContain('a.sh');
196
+ // Nothing was written.
197
+ expect(fs.readFileSync(path.join(TMP_REPO, 'mirror/a.sh'), 'utf8')).toBe('echo stale\n');
198
+ });
199
+
200
+ it('prunes a mirror file whose canonical source is gone', () => {
201
+ write(TMP_REPO, 'canonical/a.sh', 'echo a\n');
202
+ write(TMP_REPO, 'mirror/a.sh', 'echo a\n');
203
+ write(TMP_REPO, 'mirror/orphan.sh', 'echo orphan\n');
204
+ trackAll(TMP_REPO);
205
+
206
+ const result = syncMirrors(TMP_REPO, [{ canonical: 'canonical', mirror: 'mirror' }]);
207
+ expect(result.cleaned).toContain('orphan.sh');
208
+ expect(fs.existsSync(path.join(TMP_REPO, 'mirror/orphan.sh'))).toBe(false);
209
+ });
210
+ });
211
+
212
+ describe('handleSyncMirrors — CLI contract', () => {
213
+ let captured;
214
+ let outSpy;
215
+
216
+ beforeEach(() => {
217
+ captured = [];
218
+ outSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
219
+ captured.push(String(chunk));
220
+ return true;
221
+ });
222
+ });
223
+
224
+ afterEach(() => {
225
+ outSpy.mockRestore();
226
+ });
227
+
228
+ it('reports NOT MEASURABLE and exits 1 outside a repo with no pairs', () => {
229
+ expect(handleSyncMirrors([], { root: TMP_REPO })).toBe(1);
230
+ expect(captured.join('')).toMatch(/NOT MEASURABLE/i);
231
+ });
232
+
233
+ it('defaults to a dry run and does not modify files', () => {
234
+ write(TMP_REPO, 'githooks/lib/a.sh', 'echo canonical\n');
235
+ write(TMP_REPO, 'src/npm-package/hooks/lib/a.sh', 'echo stale\n');
236
+ trackAll(TMP_REPO);
237
+
238
+ const code = handleSyncMirrors([], { root: TMP_REPO });
239
+ // Dry run with pending work is a non-zero exit so CI can act on it.
240
+ expect(code).toBe(1);
241
+ expect(captured.join('')).toMatch(/Re-run with --write/);
242
+ expect(fs.readFileSync(path.join(TMP_REPO, 'src/npm-package/hooks/lib/a.sh'), 'utf8')).toBe('echo stale\n');
243
+ });
244
+
245
+ it('applies the repair with --write and reports it', () => {
246
+ write(TMP_REPO, 'githooks/lib/a.sh', 'echo canonical\n');
247
+ write(TMP_REPO, 'src/npm-package/hooks/lib/a.sh', 'echo stale\n');
248
+ trackAll(TMP_REPO);
249
+
250
+ expect(handleSyncMirrors(['--write'], { root: TMP_REPO })).toBe(0);
251
+ expect(fs.readFileSync(path.join(TMP_REPO, 'src/npm-package/hooks/lib/a.sh'), 'utf8')).toBe('echo canonical\n');
252
+ });
253
+ });
254
+
255
+ describe('syncMirrors — AC-713-04 safety', () => {
256
+ it('refuses a pair whose canonical directory does not exist', () => {
257
+ write(TMP_REPO, 'mirror/a.sh', 'echo a\n');
258
+ trackAll(TMP_REPO);
259
+
260
+ // Guessing "canonical is empty, so wipe the mirror" would delete real files.
261
+ expect(() => syncMirrors(TMP_REPO, [{ canonical: 'canonical', mirror: 'mirror' }])).toThrow(/canonical/i);
262
+ });
263
+
264
+ it('preserves the executable bit so hooks stay runnable', () => {
265
+ write(TMP_REPO, 'canonical/hook.sh', '#!/bin/sh\necho a\n');
266
+ fs.chmodSync(path.join(TMP_REPO, 'canonical/hook.sh'), 0o755);
267
+ write(TMP_REPO, 'mirror/hook.sh', '#!/bin/sh\necho a\n');
268
+ trackAll(TMP_REPO);
269
+
270
+ syncMirrors(TMP_REPO, [{ canonical: 'canonical', mirror: 'mirror' }]);
271
+ const mode = fs.statSync(path.join(TMP_REPO, 'mirror/hook.sh')).mode;
272
+ // On Windows the exec bit is not meaningful; only assert where it applies.
273
+ if (process.platform !== 'win32') {
274
+ expect(mode & 0o111).toBeTruthy();
275
+ }
276
+ });
277
+ });