From 20f26c9e0e8868d09f0dedb4868c9365784239f8 Mon Sep 17 00:00:00 2001 From: SurviveM <254925152+SurviveM@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:38:13 +0800 Subject: [PATCH 1/2] ci: add local lint and shared pr checks --- .github/workflows/ci.yml | 6 +- package.json | 5 ++ scripts/lint.js | 79 ++++++++++++++++++++++++ scripts/run-ci-check.js | 41 ++++++++++++ tests/unit/ci-workflow-contract.test.mjs | 31 ++++++++++ tests/unit/lint-contract.test.mjs | 20 ++++++ tests/unit/run.mjs | 2 + 7 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 scripts/lint.js create mode 100644 scripts/run-ci-check.js create mode 100644 tests/unit/ci-workflow-contract.test.mjs create mode 100644 tests/unit/lint-contract.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 592a827..d1ee786 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,10 @@ jobs: node-version: ${{ matrix.node }} cache: npm - name: Install - run: npm ci + run: npm run ci:install - name: Lint - run: npm run lint --if-present + run: npm run ci:lint - name: Test env: WEB_UI_PARITY_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} - run: npm run test --if-present + run: npm run ci:test diff --git a/package.json b/package.json index 9524335..3e57ed4 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,12 @@ "docs:dev": "node ./node_modules/vitepress/dist/node/cli.js dev site", "docs:build": "node ./node_modules/vitepress/dist/node/cli.js build site", "docs:preview": "node ./node_modules/vitepress/dist/node/cli.js preview site", + "ci:install": "node scripts/run-ci-check.js install", + "ci:lint": "node scripts/run-ci-check.js lint", + "ci:test": "node scripts/run-ci-check.js test", + "lint": "node scripts/lint.js", "test": "npm run test:unit && npm run test:e2e", + "test:ci": "node scripts/run-ci-check.js all", "test:unit": "node tests/unit/run.mjs", "test:e2e": "node tests/e2e/run.js", "pretest": "node scripts/ensure-test-deps.js" diff --git a/scripts/lint.js b/scripts/lint.js new file mode 100644 index 0000000..c448c62 --- /dev/null +++ b/scripts/lint.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const root = path.resolve(__dirname, '..'); +const nodeCmd = process.execPath; +const sourceExtensions = new Set(['.js', '.mjs', '.cjs']); +const jsonExtensions = new Set(['.json']); +const ignoreDirs = new Set(['.git', 'node_modules', '.tmp']); + +function stripUtf8Bom(text) { + return typeof text === 'string' && text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; +} + +function walk(dirPath, files) { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + if (ignoreDirs.has(entry.name)) continue; + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + walk(fullPath, files); + continue; + } + const ext = path.extname(entry.name).toLowerCase(); + if (sourceExtensions.has(ext) || jsonExtensions.has(ext)) { + files.push(fullPath); + } + } +} + +function lintJson(filePath) { + try { + JSON.parse(stripUtf8Bom(fs.readFileSync(filePath, 'utf8'))); + } catch (err) { + throw new Error(`${path.relative(root, filePath)}: invalid JSON (${err.message || err})`); + } +} + +function lintSource(filePath) { + const result = spawnSync(nodeCmd, ['--check', filePath], { + cwd: root, + encoding: 'utf8', + env: process.env + }); + if (result.error) { + throw new Error(`${path.relative(root, filePath)}: ${result.error.message}`); + } + if (result.status !== 0) { + const detail = String(result.stderr || result.stdout || '').trim(); + throw new Error(`${path.relative(root, filePath)}: ${detail || 'syntax check failed'}`); + } +} + +function main() { + const files = []; + walk(root, files); + files.sort((a, b) => a.localeCompare(b)); + + let checked = 0; + for (const filePath of files) { + const ext = path.extname(filePath).toLowerCase(); + if (jsonExtensions.has(ext)) { + lintJson(filePath); + } else if (sourceExtensions.has(ext)) { + lintSource(filePath); + } + checked += 1; + } + + console.log(`[codexmate] Lint passed for ${checked} file(s).`); +} + +try { + main(); +} catch (err) { + console.error(`[codexmate] Lint failed: ${err.message || err}`); + process.exit(1); +} diff --git a/scripts/run-ci-check.js b/scripts/run-ci-check.js new file mode 100644 index 0000000..7ab8c53 --- /dev/null +++ b/scripts/run-ci-check.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node +const path = require('path'); +const { spawnSync } = require('child_process'); + +const root = path.resolve(__dirname, '..'); +const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const step = process.argv[2] || 'all'; + +const stepArgs = { + install: ['ci'], + lint: ['run', 'lint', '--if-present'], + test: ['run', 'test', '--if-present'] +}; + +const steps = step === 'all' ? ['install', 'lint', 'test'] : [step]; + +for (const name of steps) { + const args = stepArgs[name]; + if (!args) { + console.error(`[codexmate] Unsupported CI step: ${name}`); + process.exit(1); + } + + console.log(`[codexmate] CI ${name}: ${npmCmd} ${args.join(' ')}`); + const result = spawnSync(npmCmd, args, { + cwd: root, + stdio: 'inherit', + env: process.env + }); + + if (result.error) { + console.error(`[codexmate] CI ${name} failed: ${result.error.message}`); + process.exit(1); + } + if (typeof result.status === 'number' && result.status !== 0) { + process.exit(result.status); + } + if (result.status == null) { + process.exit(1); + } +} diff --git a/tests/unit/ci-workflow-contract.test.mjs b/tests/unit/ci-workflow-contract.test.mjs new file mode 100644 index 0000000..080ec71 --- /dev/null +++ b/tests/unit/ci-workflow-contract.test.mjs @@ -0,0 +1,31 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..', '..'); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')); +} + +test('ci workflow contract stays aligned with local npm test coverage', () => { + const ciWorkflow = fs.readFileSync(path.join(projectRoot, '.github', 'workflows', 'ci.yml'), 'utf8'); + const pkg = readJson(path.join(projectRoot, 'package.json')); + + assert.match(ciWorkflow, /\bpull_request:\s*$/m); + assert.match(ciWorkflow, /\bpush:\s*$/m); + assert.match(ciWorkflow, /-\s+name:\s+Install[\s\S]*?run:\s+npm run ci:install\b/m); + assert.match(ciWorkflow, /-\s+name:\s+Lint[\s\S]*?run:\s+npm run ci:lint\b/m); + assert.match(ciWorkflow, /-\s+name:\s+Test[\s\S]*?run:\s+npm run ci:test\b/m); + + assert.strictEqual(pkg.scripts['ci:install'], 'node scripts/run-ci-check.js install'); + assert.strictEqual(pkg.scripts['ci:lint'], 'node scripts/run-ci-check.js lint'); + assert.strictEqual(pkg.scripts['ci:test'], 'node scripts/run-ci-check.js test'); + assert.strictEqual(pkg.scripts.test, 'npm run test:unit && npm run test:e2e'); + assert.strictEqual(pkg.scripts['test:ci'], 'node scripts/run-ci-check.js all'); + assert.strictEqual(pkg.scripts['test:unit'], 'node tests/unit/run.mjs'); + assert.strictEqual(pkg.scripts['test:e2e'], 'node tests/e2e/run.js'); +}); diff --git a/tests/unit/lint-contract.test.mjs b/tests/unit/lint-contract.test.mjs new file mode 100644 index 0000000..eae7f24 --- /dev/null +++ b/tests/unit/lint-contract.test.mjs @@ -0,0 +1,20 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..', '..'); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')); +} + +test('package exposes a real lint script backed by local checks', () => { + const pkg = readJson(path.join(projectRoot, 'package.json')); + + assert.strictEqual(pkg.scripts.lint, 'node scripts/lint.js'); + assert.strictEqual(pkg.scripts['ci:lint'], 'node scripts/run-ci-check.js lint'); + assert.strictEqual(fs.existsSync(path.join(projectRoot, 'scripts', 'lint.js')), true); +}); diff --git a/tests/unit/run.mjs b/tests/unit/run.mjs index c2018d2..8a60801 100644 --- a/tests/unit/run.mjs +++ b/tests/unit/run.mjs @@ -37,6 +37,8 @@ await import(pathToFileURL(path.join(__dirname, 'provider-share-command.test.mjs await import(pathToFileURL(path.join(__dirname, 'provider-switch-regression.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'codex-proxy-options.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'coderabbit-workflows.test.mjs'))); +await import(pathToFileURL(path.join(__dirname, 'ci-workflow-contract.test.mjs'))); +await import(pathToFileURL(path.join(__dirname, 'lint-contract.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'session-tab-switch-performance.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'session-trash-state.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'web-ui-restart.test.mjs'))); From 27693b5034ef159b7fec32903d437e5f0bcdc641 Mon Sep 17 00:00:00 2001 From: SurviveM <254925152+SurviveM@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:54:33 +0800 Subject: [PATCH 2/2] fix: support esm syntax in lint checks --- scripts/lint.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/lint.js b/scripts/lint.js index c448c62..913706f 100644 --- a/scripts/lint.js +++ b/scripts/lint.js @@ -38,8 +38,17 @@ function lintJson(filePath) { } function lintSource(filePath) { - const result = spawnSync(nodeCmd, ['--check', filePath], { + const ext = path.extname(filePath).toLowerCase(); + const sourceText = fs.readFileSync(filePath, 'utf8'); + const normalized = stripUtf8Bom(sourceText); + const treatsAsModule = ext === '.mjs' + || (ext === '.js' && /\b(?:import|export)\b|import\.meta/.test(normalized)); + const args = treatsAsModule + ? ['--input-type=module', '--check'] + : ['--check', filePath]; + const result = spawnSync(nodeCmd, args, { cwd: root, + input: treatsAsModule ? normalized : undefined, encoding: 'utf8', env: process.env });