Skip to content

Commit 9f0976c

Browse files
satoshai-devclaude
andauthored
feat: add --check flag for CI staleness detection (#18) (#43)
Add --check to both fetch and sync commands. Fetches on-chain ABI, compares with local file, exits 1 if stale or missing. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8b94d68 commit 9f0976c

File tree

5 files changed

+253
-8
lines changed

5 files changed

+253
-8
lines changed

.changeset/check-flag.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@satoshai/abi-cli": minor
3+
---
4+
5+
Add `--check` flag to `fetch` and `sync` commands for CI staleness detection

src/commands/fetch.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { defineCommand } from 'citty';
22
import { parseContractId, fetchContractAbi } from '../fetcher.js';
33
import { generateTypescript, generateJson, defaultFilename } from '../codegen.js';
44
import { resolveNetwork } from '../network.js';
5-
import { writeFile } from 'node:fs/promises';
5+
import { readFile, writeFile } from 'node:fs/promises';
66
import { resolve } from 'node:path';
77

88
export const fetchCommand = defineCommand({
@@ -38,13 +38,22 @@ export const fetchCommand = defineCommand({
3838
description: 'Print output to stdout instead of writing a file',
3939
default: false,
4040
},
41+
check: {
42+
type: 'boolean',
43+
description: 'Check if local files are up-to-date with on-chain ABI (exit 1 if stale)',
44+
default: false,
45+
},
4146
},
4247
async run({ args }) {
4348
const format = args.format as 'ts' | 'json';
4449
if (format !== 'ts' && format !== 'json') {
4550
throw new Error(`Invalid format "${format}". Use "ts" or "json".`);
4651
}
4752

53+
if (args.check && args.stdout) {
54+
throw new Error('--check and --stdout are mutually exclusive.');
55+
}
56+
4857
// Validate network early to fail fast before fetching any contracts
4958
resolveNetwork(args.network);
5059

@@ -62,6 +71,8 @@ export const fetchCommand = defineCommand({
6271
);
6372
}
6473

74+
const stale: string[] = [];
75+
6576
for (const contractId of contractIds) {
6677
const { address, name } = parseContractId(contractId);
6778

@@ -73,7 +84,24 @@ export const fetchCommand = defineCommand({
7384
? generateTypescript(contractId, abi)
7485
: generateJson(abi);
7586

76-
if (args.stdout) {
87+
if (args.check) {
88+
const filename = args.output ?? defaultFilename(contractId, format);
89+
const filepath = resolve(filename);
90+
try {
91+
const existing = await readFile(filepath, 'utf-8');
92+
if (existing !== output) {
93+
console.error(`Stale: ${filepath}`);
94+
stale.push(filepath);
95+
}
96+
} catch (err) {
97+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
98+
console.error(`Missing: ${filepath}`);
99+
stale.push(filepath);
100+
} else {
101+
throw err;
102+
}
103+
}
104+
} else if (args.stdout) {
77105
process.stdout.write(output);
78106
} else {
79107
const filename = args.output ?? defaultFilename(contractId, format);
@@ -82,5 +110,9 @@ export const fetchCommand = defineCommand({
82110
console.error(`Wrote ${filepath}`);
83111
}
84112
}
113+
114+
if (args.check && stale.length > 0) {
115+
process.exitCode = 1;
116+
}
85117
},
86118
});

src/commands/sync.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { defineCommand } from 'citty';
2-
import { mkdir, writeFile } from 'node:fs/promises';
2+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
33
import { resolve, join } from 'node:path';
44
import { loadConfig } from '../config.js';
55
import { parseContractId, fetchContractAbi } from '../fetcher.js';
@@ -16,6 +16,11 @@ export const syncCommand = defineCommand({
1616
alias: 'c',
1717
description: 'Path to config file (default: abi.config.json or abi.config.ts)',
1818
},
19+
check: {
20+
type: 'boolean',
21+
description: 'Check if local files are up-to-date with on-chain ABIs (exit 1 if stale)',
22+
default: false,
23+
},
1924
},
2025
async run({ args }) {
2126
const config = await loadConfig(args.config);
@@ -25,6 +30,7 @@ export const syncCommand = defineCommand({
2530
await mkdir(outDir, { recursive: true });
2631

2732
const failed: string[] = [];
33+
const stale: string[] = [];
2834
const barrelEntries: { name: string; filename: string }[] = [];
2935
let synced = 0;
3036

@@ -44,8 +50,26 @@ export const syncCommand = defineCommand({
4450
const resolvedName = contract.name ?? name;
4551
const filename = defaultFilename(contract.id, format, contract.name);
4652
const filepath = join(outDir, filename);
47-
await writeFile(filepath, output, 'utf-8');
48-
console.error(`Wrote ${filepath}`);
53+
54+
if (args.check) {
55+
try {
56+
const existing = await readFile(filepath, 'utf-8');
57+
if (existing !== output) {
58+
console.error(`Stale: ${filepath}`);
59+
stale.push(filepath);
60+
}
61+
} catch (err) {
62+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
63+
console.error(`Missing: ${filepath}`);
64+
stale.push(filepath);
65+
} else {
66+
throw err;
67+
}
68+
}
69+
} else {
70+
await writeFile(filepath, output, 'utf-8');
71+
console.error(`Wrote ${filepath}`);
72+
}
4973
synced++;
5074
barrelEntries.push({ name: resolvedName, filename });
5175
} catch (err) {
@@ -55,14 +79,21 @@ export const syncCommand = defineCommand({
5579
}
5680
}
5781

58-
if (format === 'ts' && barrelEntries.length > 0) {
82+
if (!args.check && format === 'ts' && barrelEntries.length > 0) {
5983
const barrelContent = generateBarrel(barrelEntries);
6084
const barrelPath = join(outDir, 'index.ts');
6185
await writeFile(barrelPath, barrelContent, 'utf-8');
6286
console.error(`Wrote ${barrelPath}`);
6387
}
6488

65-
console.error(`\n${synced}/${config.contracts.length} contracts synced.`);
89+
if (args.check) {
90+
console.error(`\n${synced}/${config.contracts.length} contracts checked.`);
91+
if (stale.length > 0) {
92+
process.exitCode = 1;
93+
}
94+
} else {
95+
console.error(`\n${synced}/${config.contracts.length} contracts synced.`);
96+
}
6697

6798
if (failed.length > 0) {
6899
throw new Error(`Failed to sync: ${failed.join(', ')}`);

tests/unit/cli.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
import { fetchCommand } from '../../src/commands/fetch.js';
3+
import { generateTypescript } from '../../src/codegen.js';
34
import { sampleAbi } from './fixtures.js';
4-
import { writeFile } from 'node:fs/promises';
5+
import { readFile, writeFile } from 'node:fs/promises';
56
import { resolve } from 'node:path';
67

78
vi.mock('node:fs/promises', () => ({
9+
readFile: vi.fn(),
810
writeFile: vi.fn(),
911
}));
1012

@@ -29,12 +31,14 @@ describe('fetchCommand', () => {
2931
globalThis.fetch = vi.fn();
3032
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
3133
vi.mocked(writeFile).mockResolvedValue();
34+
process.exitCode = undefined;
3235
});
3336

3437
afterEach(() => {
3538
globalThis.fetch = originalFetch;
3639
errorSpy.mockRestore();
3740
vi.restoreAllMocks();
41+
process.exitCode = undefined;
3842
});
3943

4044
describe('--stdout', () => {
@@ -189,6 +193,74 @@ describe('fetchCommand', () => {
189193
});
190194
});
191195

196+
describe('--check', () => {
197+
it('exits 0 when local file matches generated output', async () => {
198+
mockFetchSuccess();
199+
const contractId = 'SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait';
200+
const expected = generateTypescript(contractId, sampleAbi);
201+
vi.mocked(readFile).mockResolvedValueOnce(expected);
202+
203+
await runFetch({
204+
contract: contractId,
205+
network: 'mainnet',
206+
format: 'ts',
207+
stdout: false,
208+
check: true,
209+
});
210+
211+
expect(process.exitCode).toBeUndefined();
212+
expect(writeFile).not.toHaveBeenCalled();
213+
});
214+
215+
it('sets exitCode 1 when local file differs', async () => {
216+
mockFetchSuccess();
217+
vi.mocked(readFile).mockResolvedValueOnce('old content');
218+
219+
await runFetch({
220+
contract: 'SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait',
221+
network: 'mainnet',
222+
format: 'ts',
223+
stdout: false,
224+
check: true,
225+
});
226+
227+
expect(process.exitCode).toBe(1);
228+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Stale:'));
229+
expect(writeFile).not.toHaveBeenCalled();
230+
});
231+
232+
it('sets exitCode 1 when local file is missing', async () => {
233+
mockFetchSuccess();
234+
vi.mocked(readFile).mockRejectedValueOnce(
235+
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
236+
);
237+
238+
await runFetch({
239+
contract: 'SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait',
240+
network: 'mainnet',
241+
format: 'ts',
242+
stdout: false,
243+
check: true,
244+
});
245+
246+
expect(process.exitCode).toBe(1);
247+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Missing:'));
248+
expect(writeFile).not.toHaveBeenCalled();
249+
});
250+
251+
it('errors when used with --stdout', async () => {
252+
await expect(
253+
runFetch({
254+
contract: 'SP1.token',
255+
network: 'mainnet',
256+
format: 'ts',
257+
stdout: true,
258+
check: true,
259+
}),
260+
).rejects.toThrow('--check and --stdout are mutually exclusive');
261+
});
262+
});
263+
192264
describe('validation', () => {
193265
it('throws on invalid format', async () => {
194266
await expect(

tests/unit/sync.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
import { syncCommand } from '../../src/commands/sync.js';
3+
import { generateTypescript } from '../../src/codegen.js';
34
import { sampleAbi } from './fixtures.js';
45
import { writeFile, mkdir, readFile } from 'node:fs/promises';
56
import { resolve, join } from 'node:path';
@@ -36,12 +37,14 @@ describe('syncCommand', () => {
3637
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
3738
vi.mocked(writeFile).mockResolvedValue();
3839
vi.mocked(mkdir).mockResolvedValue(undefined);
40+
process.exitCode = undefined;
3941
});
4042

4143
afterEach(() => {
4244
globalThis.fetch = originalFetch;
4345
errorSpy.mockRestore();
4446
vi.restoreAllMocks();
47+
process.exitCode = undefined;
4548
});
4649

4750
it('syncs all contracts from config', async () => {
@@ -259,4 +262,106 @@ describe('syncCommand', () => {
259262
'utf-8',
260263
);
261264
});
265+
266+
describe('--check', () => {
267+
it('exits 0 when all local files match', async () => {
268+
const contractId = 'SP1.token-a';
269+
const expected = generateTypescript(contractId, sampleAbi);
270+
mockConfig({
271+
outDir: './abis',
272+
contracts: [{ id: contractId }],
273+
});
274+
mockFetchSuccess();
275+
vi.mocked(readFile).mockResolvedValueOnce(expected);
276+
277+
await runSync({ config: '/tmp/abi.config.json', check: true });
278+
279+
expect(process.exitCode).toBeUndefined();
280+
expect(writeFile).not.toHaveBeenCalled();
281+
});
282+
283+
it('sets exitCode 1 when a file is stale', async () => {
284+
mockConfig({
285+
outDir: './abis',
286+
contracts: [{ id: 'SP1.token-a' }],
287+
});
288+
mockFetchSuccess();
289+
vi.mocked(readFile).mockResolvedValueOnce('old content');
290+
291+
await runSync({ config: '/tmp/abi.config.json', check: true });
292+
293+
expect(process.exitCode).toBe(1);
294+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Stale:'));
295+
expect(writeFile).not.toHaveBeenCalled();
296+
});
297+
298+
it('sets exitCode 1 when a file is missing', async () => {
299+
mockConfig({
300+
outDir: './abis',
301+
contracts: [{ id: 'SP1.token-a' }],
302+
});
303+
mockFetchSuccess();
304+
vi.mocked(readFile).mockRejectedValueOnce(
305+
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
306+
);
307+
308+
await runSync({ config: '/tmp/abi.config.json', check: true });
309+
310+
expect(process.exitCode).toBe(1);
311+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Missing:'));
312+
expect(writeFile).not.toHaveBeenCalled();
313+
});
314+
315+
it('reports mixed pass/fail across multiple contracts', async () => {
316+
const contractA = 'SP1.token-a';
317+
const expectedA = generateTypescript(contractA, sampleAbi);
318+
mockConfig({
319+
outDir: './abis',
320+
contracts: [{ id: contractA }, { id: 'SP2.token-b' }],
321+
});
322+
mockFetchSuccess(2);
323+
vi.mocked(readFile).mockResolvedValueOnce(expectedA);
324+
vi.mocked(readFile).mockResolvedValueOnce('old content');
325+
326+
await runSync({ config: '/tmp/abi.config.json', check: true });
327+
328+
expect(process.exitCode).toBe(1);
329+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Stale:'));
330+
expect(writeFile).not.toHaveBeenCalled();
331+
});
332+
333+
it('respects name alias for filename resolution', async () => {
334+
const contractId = 'SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.amm-pool-v2-01';
335+
const expected = generateTypescript(contractId, sampleAbi);
336+
mockConfig({
337+
outDir: './abis',
338+
contracts: [{ id: contractId, name: 'amm-pool' }],
339+
});
340+
mockFetchSuccess();
341+
vi.mocked(readFile).mockResolvedValueOnce(expected);
342+
343+
await runSync({ config: '/tmp/abi.config.json', check: true });
344+
345+
expect(readFile).toHaveBeenCalledWith(
346+
join(resolve('./abis'), 'amm-pool.ts'),
347+
'utf-8',
348+
);
349+
expect(process.exitCode).toBeUndefined();
350+
});
351+
352+
it('does not generate barrel file', async () => {
353+
const contractId = 'SP1.token-a';
354+
const expected = generateTypescript(contractId, sampleAbi);
355+
mockConfig({
356+
outDir: './abis',
357+
contracts: [{ id: contractId }],
358+
});
359+
mockFetchSuccess();
360+
vi.mocked(readFile).mockResolvedValueOnce(expected);
361+
362+
await runSync({ config: '/tmp/abi.config.json', check: true });
363+
364+
expect(writeFile).not.toHaveBeenCalled();
365+
});
366+
});
262367
});

0 commit comments

Comments
 (0)