Skip to content

Commit a5ffbfd

Browse files
authored
ci: add local lint and shared pr checks (#73)
* ci: add local lint and shared pr checks * fix: support esm syntax in lint checks
1 parent beea32d commit a5ffbfd

File tree

7 files changed

+190
-3
lines changed

7 files changed

+190
-3
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ jobs:
2121
node-version: ${{ matrix.node }}
2222
cache: npm
2323
- name: Install
24-
run: npm ci
24+
run: npm run ci:install
2525
- name: Lint
26-
run: npm run lint --if-present
26+
run: npm run ci:lint
2727
- name: Test
2828
env:
2929
WEB_UI_PARITY_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
30-
run: npm run test --if-present
30+
run: npm run ci:test

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@
3131
"docs:dev": "node ./node_modules/vitepress/dist/node/cli.js dev site",
3232
"docs:build": "node ./node_modules/vitepress/dist/node/cli.js build site",
3333
"docs:preview": "node ./node_modules/vitepress/dist/node/cli.js preview site",
34+
"ci:install": "node scripts/run-ci-check.js install",
35+
"ci:lint": "node scripts/run-ci-check.js lint",
36+
"ci:test": "node scripts/run-ci-check.js test",
37+
"lint": "node scripts/lint.js",
3438
"test": "npm run test:unit && npm run test:e2e",
39+
"test:ci": "node scripts/run-ci-check.js all",
3540
"test:unit": "node tests/unit/run.mjs",
3641
"test:e2e": "node tests/e2e/run.js",
3742
"pretest": "node scripts/ensure-test-deps.js"

scripts/lint.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#!/usr/bin/env node
2+
const fs = require('fs');
3+
const path = require('path');
4+
const { spawnSync } = require('child_process');
5+
6+
const root = path.resolve(__dirname, '..');
7+
const nodeCmd = process.execPath;
8+
const sourceExtensions = new Set(['.js', '.mjs', '.cjs']);
9+
const jsonExtensions = new Set(['.json']);
10+
const ignoreDirs = new Set(['.git', 'node_modules', '.tmp']);
11+
12+
function stripUtf8Bom(text) {
13+
return typeof text === 'string' && text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
14+
}
15+
16+
function walk(dirPath, files) {
17+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
18+
for (const entry of entries) {
19+
if (ignoreDirs.has(entry.name)) continue;
20+
const fullPath = path.join(dirPath, entry.name);
21+
if (entry.isDirectory()) {
22+
walk(fullPath, files);
23+
continue;
24+
}
25+
const ext = path.extname(entry.name).toLowerCase();
26+
if (sourceExtensions.has(ext) || jsonExtensions.has(ext)) {
27+
files.push(fullPath);
28+
}
29+
}
30+
}
31+
32+
function lintJson(filePath) {
33+
try {
34+
JSON.parse(stripUtf8Bom(fs.readFileSync(filePath, 'utf8')));
35+
} catch (err) {
36+
throw new Error(`${path.relative(root, filePath)}: invalid JSON (${err.message || err})`);
37+
}
38+
}
39+
40+
function lintSource(filePath) {
41+
const ext = path.extname(filePath).toLowerCase();
42+
const sourceText = fs.readFileSync(filePath, 'utf8');
43+
const normalized = stripUtf8Bom(sourceText);
44+
const treatsAsModule = ext === '.mjs'
45+
|| (ext === '.js' && /\b(?:import|export)\b|import\.meta/.test(normalized));
46+
const args = treatsAsModule
47+
? ['--input-type=module', '--check']
48+
: ['--check', filePath];
49+
const result = spawnSync(nodeCmd, args, {
50+
cwd: root,
51+
input: treatsAsModule ? normalized : undefined,
52+
encoding: 'utf8',
53+
env: process.env
54+
});
55+
if (result.error) {
56+
throw new Error(`${path.relative(root, filePath)}: ${result.error.message}`);
57+
}
58+
if (result.status !== 0) {
59+
const detail = String(result.stderr || result.stdout || '').trim();
60+
throw new Error(`${path.relative(root, filePath)}: ${detail || 'syntax check failed'}`);
61+
}
62+
}
63+
64+
function main() {
65+
const files = [];
66+
walk(root, files);
67+
files.sort((a, b) => a.localeCompare(b));
68+
69+
let checked = 0;
70+
for (const filePath of files) {
71+
const ext = path.extname(filePath).toLowerCase();
72+
if (jsonExtensions.has(ext)) {
73+
lintJson(filePath);
74+
} else if (sourceExtensions.has(ext)) {
75+
lintSource(filePath);
76+
}
77+
checked += 1;
78+
}
79+
80+
console.log(`[codexmate] Lint passed for ${checked} file(s).`);
81+
}
82+
83+
try {
84+
main();
85+
} catch (err) {
86+
console.error(`[codexmate] Lint failed: ${err.message || err}`);
87+
process.exit(1);
88+
}

scripts/run-ci-check.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/usr/bin/env node
2+
const path = require('path');
3+
const { spawnSync } = require('child_process');
4+
5+
const root = path.resolve(__dirname, '..');
6+
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
7+
const step = process.argv[2] || 'all';
8+
9+
const stepArgs = {
10+
install: ['ci'],
11+
lint: ['run', 'lint', '--if-present'],
12+
test: ['run', 'test', '--if-present']
13+
};
14+
15+
const steps = step === 'all' ? ['install', 'lint', 'test'] : [step];
16+
17+
for (const name of steps) {
18+
const args = stepArgs[name];
19+
if (!args) {
20+
console.error(`[codexmate] Unsupported CI step: ${name}`);
21+
process.exit(1);
22+
}
23+
24+
console.log(`[codexmate] CI ${name}: ${npmCmd} ${args.join(' ')}`);
25+
const result = spawnSync(npmCmd, args, {
26+
cwd: root,
27+
stdio: 'inherit',
28+
env: process.env
29+
});
30+
31+
if (result.error) {
32+
console.error(`[codexmate] CI ${name} failed: ${result.error.message}`);
33+
process.exit(1);
34+
}
35+
if (typeof result.status === 'number' && result.status !== 0) {
36+
process.exit(result.status);
37+
}
38+
if (result.status == null) {
39+
process.exit(1);
40+
}
41+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import assert from 'assert';
2+
import fs from 'fs';
3+
import path from 'path';
4+
import { fileURLToPath } from 'url';
5+
6+
const __filename = fileURLToPath(import.meta.url);
7+
const __dirname = path.dirname(__filename);
8+
const projectRoot = path.resolve(__dirname, '..', '..');
9+
10+
function readJson(filePath) {
11+
return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
12+
}
13+
14+
test('ci workflow contract stays aligned with local npm test coverage', () => {
15+
const ciWorkflow = fs.readFileSync(path.join(projectRoot, '.github', 'workflows', 'ci.yml'), 'utf8');
16+
const pkg = readJson(path.join(projectRoot, 'package.json'));
17+
18+
assert.match(ciWorkflow, /\bpull_request:\s*$/m);
19+
assert.match(ciWorkflow, /\bpush:\s*$/m);
20+
assert.match(ciWorkflow, /-\s+name:\s+Install[\s\S]*?run:\s+npm run ci:install\b/m);
21+
assert.match(ciWorkflow, /-\s+name:\s+Lint[\s\S]*?run:\s+npm run ci:lint\b/m);
22+
assert.match(ciWorkflow, /-\s+name:\s+Test[\s\S]*?run:\s+npm run ci:test\b/m);
23+
24+
assert.strictEqual(pkg.scripts['ci:install'], 'node scripts/run-ci-check.js install');
25+
assert.strictEqual(pkg.scripts['ci:lint'], 'node scripts/run-ci-check.js lint');
26+
assert.strictEqual(pkg.scripts['ci:test'], 'node scripts/run-ci-check.js test');
27+
assert.strictEqual(pkg.scripts.test, 'npm run test:unit && npm run test:e2e');
28+
assert.strictEqual(pkg.scripts['test:ci'], 'node scripts/run-ci-check.js all');
29+
assert.strictEqual(pkg.scripts['test:unit'], 'node tests/unit/run.mjs');
30+
assert.strictEqual(pkg.scripts['test:e2e'], 'node tests/e2e/run.js');
31+
});

tests/unit/lint-contract.test.mjs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import assert from 'assert';
2+
import fs from 'fs';
3+
import path from 'path';
4+
import { fileURLToPath } from 'url';
5+
6+
const __filename = fileURLToPath(import.meta.url);
7+
const __dirname = path.dirname(__filename);
8+
const projectRoot = path.resolve(__dirname, '..', '..');
9+
10+
function readJson(filePath) {
11+
return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
12+
}
13+
14+
test('package exposes a real lint script backed by local checks', () => {
15+
const pkg = readJson(path.join(projectRoot, 'package.json'));
16+
17+
assert.strictEqual(pkg.scripts.lint, 'node scripts/lint.js');
18+
assert.strictEqual(pkg.scripts['ci:lint'], 'node scripts/run-ci-check.js lint');
19+
assert.strictEqual(fs.existsSync(path.join(projectRoot, 'scripts', 'lint.js')), true);
20+
});

tests/unit/run.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ await import(pathToFileURL(path.join(__dirname, 'provider-share-command.test.mjs
3737
await import(pathToFileURL(path.join(__dirname, 'provider-switch-regression.test.mjs')));
3838
await import(pathToFileURL(path.join(__dirname, 'codex-proxy-options.test.mjs')));
3939
await import(pathToFileURL(path.join(__dirname, 'coderabbit-workflows.test.mjs')));
40+
await import(pathToFileURL(path.join(__dirname, 'ci-workflow-contract.test.mjs')));
41+
await import(pathToFileURL(path.join(__dirname, 'lint-contract.test.mjs')));
4042
await import(pathToFileURL(path.join(__dirname, 'session-tab-switch-performance.test.mjs')));
4143
await import(pathToFileURL(path.join(__dirname, 'session-trash-state.test.mjs')));
4244
await import(pathToFileURL(path.join(__dirname, 'web-ui-restart.test.mjs')));

0 commit comments

Comments
 (0)