Skip to content

Commit 0597c11

Browse files
aviadr1claude
andauthored
feat: add CI sync check for bash/TS shared library parity (kaizen qwibitai#347) (#269)
Adds a vitest test that parses function names from both bash and TS shared libraries (state-utils, parse-command) and flags any functions present in one but not the other. Explicit exclusions with reasons are required for intentionally asymmetric functions. Also ports 3 missing parse-command functions to TS: - extractGitCPath: extract -C path from git commands - detectGhRepo: detect GitHub repo from remote URL - getPrChangedFiles: get changed files for PR commands batch-260321-1108-3ef8/run-12 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d0057ca commit 0597c11

File tree

3 files changed

+325
-0
lines changed

3 files changed

+325
-0
lines changed

src/hooks/bash-ts-parity.test.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* bash-ts-parity.test.ts — CI sync check for bash/TS shared library parity.
3+
*
4+
* Ensures that functions in the bash shared libraries (state-utils.sh,
5+
* parse-command.sh) have corresponding TypeScript implementations, and
6+
* vice versa. Drift between the two was undetected until manual comparison
7+
* (kaizen #347).
8+
*
9+
* Naming convention: bash uses snake_case, TS uses camelCase.
10+
* The test normalizes both to compare.
11+
*/
12+
13+
import { readFileSync } from 'node:fs';
14+
import { join } from 'node:path';
15+
import { describe, expect, it } from 'vitest';
16+
17+
const HOOKS_LIB_DIR = join(__dirname, '../../.claude/kaizen/hooks/lib');
18+
const HOOKS_TS_DIR = __dirname;
19+
20+
// Functions intentionally present in only one version.
21+
// Each entry must have a comment explaining WHY it's excluded.
22+
const EXCLUSIONS: Record<string, string> = {
23+
// Requires `gh` CLI calls — intentionally stays bash-only (shell orchestration)
24+
auto_close_kaizen_issues: 'bash-only: requires gh CLI for PR state checks',
25+
// Requires `gh` CLI for PR state lookup + auto-clear — bash-only orchestration
26+
find_needs_review_state:
27+
'bash-only: requires gh CLI for merged/closed PR auto-clear',
28+
29+
// TS internal helpers with no bash equivalent (TS uses structured objects)
30+
parseStateFile: 'ts-only: internal helper, bash uses grep/cut inline',
31+
serializeStateFile: 'ts-only: internal helper, bash uses printf inline',
32+
ensureStateDir: 'ts-only: internal helper, bash uses mkdir -p inline',
33+
writeStateFile: 'ts-only: internal helper for atomic writes',
34+
35+
// TS splits extractPrUrl from reconstructPrUrl; bash inlines the grep
36+
extractPrUrl:
37+
'ts-only: extracted helper, bash inlines grep in reconstruct_pr_url',
38+
};
39+
40+
/** Extract function names from a bash script (matches `function_name() {`). */
41+
function extractBashFunctions(filePath: string): string[] {
42+
const content = readFileSync(filePath, 'utf-8');
43+
const pattern = /^([a-z_][a-z0-9_]*)\s*\(\)/gm;
44+
const functions: string[] = [];
45+
let match;
46+
while ((match = pattern.exec(content)) !== null) {
47+
functions.push(match[1]);
48+
}
49+
return functions;
50+
}
51+
52+
/** Extract exported function names from a TypeScript file. */
53+
function extractTsFunctions(filePath: string): string[] {
54+
const content = readFileSync(filePath, 'utf-8');
55+
const pattern = /^export\s+function\s+([a-zA-Z_][a-zA-Z0-9_]*)/gm;
56+
const functions: string[] = [];
57+
let match;
58+
while ((match = pattern.exec(content)) !== null) {
59+
functions.push(match[1]);
60+
}
61+
return functions;
62+
}
63+
64+
/** Convert snake_case to camelCase for comparison. */
65+
function snakeToCamel(name: string): string {
66+
return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
67+
}
68+
69+
/** Convert camelCase to snake_case for comparison. */
70+
function camelToSnake(name: string): string {
71+
return name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
72+
}
73+
74+
function checkParity(bashFile: string, tsFile: string) {
75+
const bashFns = extractBashFunctions(join(HOOKS_LIB_DIR, bashFile));
76+
const tsFns = extractTsFunctions(join(HOOKS_TS_DIR, tsFile));
77+
78+
// Normalize bash names to camelCase for comparison
79+
const bashCamel = new Map(bashFns.map((fn) => [snakeToCamel(fn), fn]));
80+
81+
// Normalize TS names to snake_case for comparison
82+
const tsSnake = new Map(tsFns.map((fn) => [camelToSnake(fn), fn]));
83+
84+
// Find bash functions missing from TS
85+
const missingInTs: string[] = [];
86+
for (const [camelName, bashName] of bashCamel) {
87+
if (!tsFns.includes(camelName) && !EXCLUSIONS[bashName]) {
88+
missingInTs.push(bashName);
89+
}
90+
}
91+
92+
// Find TS functions missing from bash
93+
const missingInBash: string[] = [];
94+
for (const [snakeName, tsName] of tsSnake) {
95+
if (!bashFns.includes(snakeName) && !EXCLUSIONS[tsName]) {
96+
missingInBash.push(tsName);
97+
}
98+
}
99+
100+
return { bashFns, tsFns, missingInTs, missingInBash };
101+
}
102+
103+
describe('bash/TS shared library parity', () => {
104+
describe('state-utils', () => {
105+
it('all bash functions have TS equivalents (or are excluded)', () => {
106+
const { missingInTs } = checkParity('state-utils.sh', 'state-utils.ts');
107+
expect(
108+
missingInTs,
109+
`Bash functions missing TS equivalent: ${missingInTs.join(', ')}. Either port them or add to EXCLUSIONS with a reason.`,
110+
).toEqual([]);
111+
});
112+
113+
it('all TS functions have bash equivalents (or are excluded)', () => {
114+
const { missingInBash } = checkParity('state-utils.sh', 'state-utils.ts');
115+
expect(
116+
missingInBash,
117+
`TS functions missing bash equivalent: ${missingInBash.join(', ')}. Either port them or add to EXCLUSIONS with a reason.`,
118+
).toEqual([]);
119+
});
120+
121+
it('extracts functions from both files', () => {
122+
const { bashFns, tsFns } = checkParity(
123+
'state-utils.sh',
124+
'state-utils.ts',
125+
);
126+
expect(bashFns.length).toBeGreaterThan(5);
127+
expect(tsFns.length).toBeGreaterThan(5);
128+
});
129+
});
130+
131+
describe('parse-command', () => {
132+
it('all bash functions have TS equivalents (or are excluded)', () => {
133+
const { missingInTs } = checkParity(
134+
'parse-command.sh',
135+
'parse-command.ts',
136+
);
137+
expect(
138+
missingInTs,
139+
`Bash functions missing TS equivalent: ${missingInTs.join(', ')}. Either port them or add to EXCLUSIONS with a reason.`,
140+
).toEqual([]);
141+
});
142+
143+
it('all TS functions have bash equivalents (or are excluded)', () => {
144+
const { missingInBash } = checkParity(
145+
'parse-command.sh',
146+
'parse-command.ts',
147+
);
148+
expect(
149+
missingInBash,
150+
`TS functions missing bash equivalent: ${missingInBash.join(', ')}. Either port them or add to EXCLUSIONS with a reason.`,
151+
).toEqual([]);
152+
});
153+
154+
it('extracts functions from both files', () => {
155+
const { bashFns, tsFns } = checkParity(
156+
'parse-command.sh',
157+
'parse-command.ts',
158+
);
159+
expect(bashFns.length).toBeGreaterThan(3);
160+
expect(tsFns.length).toBeGreaterThan(3);
161+
});
162+
});
163+
164+
describe('exclusions are valid', () => {
165+
it('all excluded functions actually exist in their source', () => {
166+
const stateUtilsBash = extractBashFunctions(
167+
join(HOOKS_LIB_DIR, 'state-utils.sh'),
168+
);
169+
const parseCommandBash = extractBashFunctions(
170+
join(HOOKS_LIB_DIR, 'parse-command.sh'),
171+
);
172+
const allBash = [...stateUtilsBash, ...parseCommandBash];
173+
174+
const stateUtilsTs = extractTsFunctions(
175+
join(HOOKS_TS_DIR, 'state-utils.ts'),
176+
);
177+
const parseCommandTs = extractTsFunctions(
178+
join(HOOKS_TS_DIR, 'parse-command.ts'),
179+
);
180+
const allTs = [...stateUtilsTs, ...parseCommandTs];
181+
182+
for (const excluded of Object.keys(EXCLUSIONS)) {
183+
const existsInBash = allBash.includes(excluded);
184+
const existsInTs = allTs.includes(excluded);
185+
expect(
186+
existsInBash || existsInTs,
187+
`Exclusion '${excluded}' doesn't exist in either bash or TS — remove stale exclusion`,
188+
).toBe(true);
189+
}
190+
});
191+
});
192+
});

src/hooks/parse-command.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { describe, expect, it } from 'vitest';
22
import {
3+
detectGhRepo,
4+
extractGitCPath,
35
extractPrNumber,
46
extractPrUrl,
57
extractRepoFlag,
8+
getPrChangedFiles,
69
isGhPrCommand,
710
isGitCommand,
811
reconstructPrUrl,
@@ -129,6 +132,78 @@ describe('extractPrUrl', () => {
129132
});
130133
});
131134

135+
describe('extractGitCPath', () => {
136+
it('extracts -C path from git command', () => {
137+
expect(extractGitCPath('git -C /some/path push origin main')).toBe(
138+
'/some/path',
139+
);
140+
});
141+
142+
it('returns undefined when no -C flag', () => {
143+
expect(extractGitCPath('git push origin main')).toBeUndefined();
144+
});
145+
146+
it('handles piped commands', () => {
147+
expect(extractGitCPath('echo test | git -C /foo status')).toBe('/foo');
148+
});
149+
});
150+
151+
describe('detectGhRepo', () => {
152+
it('detects repo from HTTPS URL', () => {
153+
expect(detectGhRepo('https://github.com/Garsson-io/nanoclaw.git')).toBe(
154+
'Garsson-io/nanoclaw',
155+
);
156+
});
157+
158+
it('detects repo from SSH URL', () => {
159+
expect(detectGhRepo('git@github.com:Garsson-io/nanoclaw.git')).toBe(
160+
'Garsson-io/nanoclaw',
161+
);
162+
});
163+
164+
it('returns undefined for non-GitHub URL', () => {
165+
expect(detectGhRepo('https://gitlab.com/foo/bar.git')).toBeUndefined();
166+
});
167+
});
168+
169+
describe('getPrChangedFiles', () => {
170+
it('uses gh pr diff for merge commands', () => {
171+
const executor = (cmd: string) => {
172+
if (cmd.includes('gh pr diff')) return 'file1.ts\nfile2.ts\n';
173+
return '';
174+
};
175+
const files = getPrChangedFiles(
176+
'gh pr merge 42 --repo Garsson-io/nanoclaw',
177+
true,
178+
executor,
179+
);
180+
expect(files).toEqual(['file1.ts', 'file2.ts']);
181+
});
182+
183+
it('falls back to git diff when gh pr diff returns empty', () => {
184+
const executor = (cmd: string) => {
185+
if (cmd.includes('gh pr diff')) return '';
186+
if (cmd.includes('git diff')) return 'fallback.ts\n';
187+
return '';
188+
};
189+
const files = getPrChangedFiles('gh pr merge 42', true, executor);
190+
expect(files).toEqual(['fallback.ts']);
191+
});
192+
193+
it('uses git diff for create commands', () => {
194+
const executor = (cmd: string) => {
195+
if (cmd.includes('git diff')) return 'new-file.ts\n';
196+
return '';
197+
};
198+
const files = getPrChangedFiles(
199+
'gh pr create --title test',
200+
false,
201+
executor,
202+
);
203+
expect(files).toEqual(['new-file.ts']);
204+
});
205+
});
206+
132207
describe('reconstructPrUrl', () => {
133208
it('extracts from stdout first', () => {
134209
expect(

src/hooks/parse-command.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@ export function extractPrNumber(
6969
return match?.[1];
7070
}
7171

72+
/**
73+
* Extract the -C <path> argument from a git command, if present.
74+
* Bash equivalent: extract_git_c_path()
75+
*/
76+
export function extractGitCPath(cmdLine: string): string | undefined {
77+
const segments = splitCommandSegments(cmdLine);
78+
for (const seg of segments) {
79+
const match = seg.match(/^git\s+-C\s+(\S+)/);
80+
if (match) return match[1];
81+
}
82+
return undefined;
83+
}
84+
7285
/**
7386
* Extract --repo flag value from a command line.
7487
*/
@@ -77,6 +90,51 @@ export function extractRepoFlag(cmdLine: string): string | undefined {
7790
return match?.[1];
7891
}
7992

93+
/**
94+
* Detect the GitHub repo (owner/name) from a git remote URL string.
95+
* Handles both HTTPS and SSH URLs.
96+
* Bash equivalent: detect_gh_repo() — but takes a URL string instead of
97+
* calling git directly, to keep this module pure (no child_process).
98+
*/
99+
export function detectGhRepo(remoteUrl: string): string | undefined {
100+
const match = remoteUrl.match(/github\.com[:/]([^/]+\/[^/.]+)/);
101+
return match?.[1];
102+
}
103+
104+
/**
105+
* Get changed files for a PR command.
106+
* For merge: returns files from `gh pr diff --name-only`.
107+
* For create: returns files from `git diff --name-only main...HEAD`.
108+
*
109+
* Bash equivalent: get_pr_changed_files() — but takes an executor function
110+
* instead of calling shell commands directly.
111+
*/
112+
export function getPrChangedFiles(
113+
cmdLine: string,
114+
isMerge: boolean,
115+
executor: (cmd: string) => string,
116+
): string[] {
117+
if (isMerge) {
118+
const prNum = extractPrNumber(cmdLine, 'merge');
119+
const repo = extractRepoFlag(cmdLine);
120+
const repoFlag = repo ? `--repo ${repo}` : '';
121+
const prArg = prNum ?? '';
122+
123+
let result = executor(`gh pr diff ${prArg} --name-only ${repoFlag}`.trim());
124+
if (!result) {
125+
result = executor('git diff --name-only main...HEAD');
126+
}
127+
return result
128+
.split('\n')
129+
.map((l) => l.trim())
130+
.filter(Boolean);
131+
}
132+
return executor('git diff --name-only main...HEAD')
133+
.split('\n')
134+
.map((l) => l.trim())
135+
.filter(Boolean);
136+
}
137+
80138
/**
81139
* Extract a GitHub PR URL from text (stdout, stderr, or command args).
82140
*/

0 commit comments

Comments
 (0)