Skip to content

Commit 69dcf47

Browse files
authored
feat: add basic monorepo linter to enforce GTS standards (googleapis#8446)
* feat: add advisory ESLint check (Option 1) to monorepo linter * chore: add basic PR prettier linter script and wire it to npm run lint * chore: rename linter script to check-pr.mjs for future expandability * Updated linter names * chore: refactor linter to use programmatic eslint and prettier APIs * chore: refactor linter to use programmatic APIs, cache prettier configs, and add package-level type checking * chore: optimize linter execution, resolve git targets robustly, and add type safety checks * chore: integrate prettier, add import/promise ESLint plugins, and configure blocking rules * chore: filter changed files by .ts extension at git level * chore: resolve package tsconfig.json by walking up the directory tree * refactor: simplify eslint non-blocking return logic and abstract runGit helper * refactor: try git diff with fallback refs directly to avoid duplicate git calls * perf: cache tsconfig package directories and parallelize type checking * refactor: restructure linter.mjs to present orchestration flow first * docs: add comment explaining blocking vs non-blocking checks and exit code behavior * docs: document non-blocking error rule behavior in .eslintrc.json * feat: make ESLint rules set to error blocking in the CI pipeline * refactor: simplify checkEslint blocking logic using native severity metadata * feat: enforce blocking failures for all ESLint errors including formatting * docs: update comments to accurately describe blocking rules and flow * feat: suggest npx eslint --fix command when formatting errors are detected * refactor: recommend local binary execution over npx for formatting fixes * docs: simplify ESLint blocking comments in .eslintrc.json * perf: execute eslint and tsc checks in parallel using Promise.all * refactor: use typescript findConfigFile in linter script
1 parent 4b878e5 commit 69dcf47

3 files changed

Lines changed: 240 additions & 3 deletions

File tree

.eslintrc.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
{
2-
"extends": "./node_modules/gts",
2+
"extends": [
3+
"./node_modules/gts",
4+
"plugin:prettier/recommended",
5+
"plugin:import/recommended",
6+
"plugin:import/typescript",
7+
"plugin:promise/recommended"
8+
],
39
"root": true,
10+
// Note: All rules configured as "error" are blocking in the PR CI pipeline.
11+
// Only rules configured as "warn" remain non-blocking.
12+
"rules": {
13+
"import/no-unresolved": "off",
14+
"import/no-extraneous-dependencies": "error",
15+
"promise/catch-or-return": "error",
16+
"promise/always-return": "error"
17+
},
418
"ignorePatterns": [
519
"**/node_modules",
620
"**/build",

bin/linter.mjs

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import {execFileSync, execFile} from 'child_process';
16+
import {existsSync} from 'fs';
17+
import path from 'path';
18+
import {promisify} from 'util';
19+
import {ESLint} from 'eslint';
20+
import ts from 'typescript';
21+
22+
// --- Globals & Promisified API Wrappers ---
23+
const execFileAsync = promisify(execFile);
24+
const tsconfigCache = new Map();
25+
26+
// --- Main Runner (Entry Point) ---
27+
async function run() {
28+
try {
29+
const changedTsFiles = getChangedFiles();
30+
31+
if (changedTsFiles.length === 0) {
32+
console.log('No TypeScript files changed. Skipping checks.');
33+
return;
34+
}
35+
36+
// Run ESLint and Type checks in parallel to optimize CPU utilization
37+
const [eslintPassed, typeSafetyPassed] = await Promise.all([
38+
checkEslint(changedTsFiles),
39+
checkTypeSafety(changedTsFiles),
40+
]);
41+
42+
if (!eslintPassed || !typeSafetyPassed) {
43+
throw new Error('Linter checks failed. Please fix. To rerun the linter, run: npm run lint');
44+
}
45+
} catch (err) {
46+
console.error('\nLinter failed:', err.message);
47+
// Setting exit code 1 to indicate failure. In the CI pipeline,
48+
// a non-zero exit code will cause the check to fail and block the PR.
49+
// Note: TypeScript (tsc) compile failures and ESLint errors (severity 2)
50+
// are blocking.
51+
process.exitCode = 1;
52+
}
53+
}
54+
55+
// --- Git Changed Files Logic ---
56+
57+
/**
58+
* Executes a Git command synchronously.
59+
*/
60+
function runGit(args, options = {}) {
61+
return execFileSync('git', args, {encoding: 'utf8', ...options});
62+
}
63+
64+
/**
65+
* Returns a list of changed TypeScript files comparing against target branches/references.
66+
*/
67+
function getChangedFiles() {
68+
const base = process.env.GITHUB_BASE_REF || 'main';
69+
const refsToTry = [`origin/${base}`, base, 'HEAD~1'];
70+
71+
for (const ref of refsToTry) {
72+
try {
73+
const output = runGit([
74+
'diff',
75+
'--name-only',
76+
'--diff-filter=ACMRT',
77+
ref,
78+
'--',
79+
'*.ts',
80+
]);
81+
return output
82+
.split('\n')
83+
.map(f => f.trim())
84+
.filter(f => f.length > 0 && existsSync(f));
85+
} catch {
86+
// Continue to the next fallback ref
87+
}
88+
}
89+
90+
throw new Error(
91+
`Error finding changed files: Tried refs [${refsToTry.join(', ')}] but all failed.`
92+
);
93+
}
94+
95+
// --- ESLint Checker ---
96+
97+
/**
98+
* Runs ESLint programmatically.
99+
* Blocks the PR if any rule configured as "error" (severity 2) fails.
100+
*/
101+
async function checkEslint(filesToCheck) {
102+
if (filesToCheck.length === 0) {
103+
return true;
104+
}
105+
106+
try {
107+
const eslint = new ESLint();
108+
const results = await eslint.lintFiles(filesToCheck);
109+
const formatter = await eslint.loadFormatter('stylish');
110+
const resultText = formatter.format(results);
111+
112+
if (resultText) {
113+
console.log(resultText);
114+
}
115+
116+
let hasBlockingErrors = false;
117+
let hasFormattingErrors = false;
118+
119+
for (const fileResult of results) {
120+
for (const message of fileResult.messages) {
121+
// message.severity === 2 indicates an error-level rule configuration.
122+
if (message.severity === 2) {
123+
hasBlockingErrors = true;
124+
if (message.ruleId === 'prettier/prettier') {
125+
hasFormattingErrors = true;
126+
}
127+
}
128+
}
129+
}
130+
131+
if (hasBlockingErrors) {
132+
console.error('\n[ERROR] Blocking ESLint violations were detected. ESLint errors are blocking and must be fixed.');
133+
if (hasFormattingErrors) {
134+
console.log(
135+
`\nTo fix formatting issues, run:\n ./node_modules/.bin/eslint --fix ${filesToCheck.map(f => `"${f}"`).join(' ')}`,
136+
);
137+
}
138+
return false;
139+
}
140+
141+
return true;
142+
} catch (err) {
143+
console.error('\n[ERROR] Failed running ESLint:', err.message);
144+
return false;
145+
}
146+
}
147+
148+
// --- TypeScript Type Checker ---
149+
150+
/**
151+
* Finds the nearest package directory containing a tsconfig.json by walking up the path.
152+
* Caches directories to avoid redundant disk operations.
153+
*/
154+
function findTsconfigDir(filePath) {
155+
const dir = path.dirname(filePath);
156+
if (tsconfigCache.has(dir)) {
157+
return tsconfigCache.get(dir);
158+
}
159+
const configPath = ts.findConfigFile(dir, ts.sys.fileExists);
160+
const result = configPath ? path.dirname(configPath) : null;
161+
tsconfigCache.set(dir, result);
162+
return result;
163+
}
164+
165+
/**
166+
* Performs concurrent TypeScript type checking for changed packages.
167+
*/
168+
async function checkTypeSafety(filesToCheck) {
169+
if (filesToCheck.length === 0) {
170+
return true;
171+
}
172+
173+
// Map files to their package directories (by walking up to the nearest tsconfig.json)
174+
const packagesToCheck = new Set();
175+
for (const file of filesToCheck) {
176+
const tsconfigDir = findTsconfigDir(file);
177+
if (tsconfigDir) {
178+
packagesToCheck.add(tsconfigDir);
179+
}
180+
}
181+
182+
if (packagesToCheck.size === 0) {
183+
return true;
184+
}
185+
186+
console.log(
187+
`\nRunning TypeScript type checks for ${packagesToCheck.size} package(s)...`,
188+
);
189+
190+
const checks = Array.from(packagesToCheck).map(async pkg => {
191+
try {
192+
console.log(` Type checking ${pkg}...`);
193+
await execFileAsync('node', [
194+
'node_modules/typescript/bin/tsc',
195+
'--noEmit',
196+
'--project',
197+
path.join(pkg, 'tsconfig.json'),
198+
]);
199+
return { pkg, passed: true };
200+
} catch (err) {
201+
console.error(`\n[ERROR] TypeScript type check failed in ${pkg}`);
202+
if (err.stdout) {
203+
console.error(err.stdout);
204+
}
205+
if (err.stderr) {
206+
console.error(err.stderr);
207+
}
208+
return { pkg, passed: false };
209+
}
210+
});
211+
212+
const results = await Promise.all(checks);
213+
return results.every(r => r.passed);
214+
}
215+
216+
// --- Execution ---
217+
run();

package.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"test": "echo nothing to test",
1313
"test-generate": "SMOKE_TEST=true node ./bin/generate-readme.mjs",
1414
"generate": "./bin/generate-readme.mjs",
15-
"lint": "echo nothing to lint",
15+
"lint": "node ./bin/linter.mjs",
1616
"clean": "echo nothing to clean",
1717
"precompile": "echo nothing to precompile",
1818
"system-test": "echo nothing to system test",
@@ -40,7 +40,13 @@
4040
"parse-link-header": "^2.0.0"
4141
},
4242
"devDependencies": {
43-
"gts": "^6.0.2"
43+
"eslint": "^8.57.1",
44+
"eslint-config-prettier": "^9.1.0",
45+
"eslint-plugin-import": "^2.29.1",
46+
"eslint-plugin-prettier": "^5.2.1",
47+
"eslint-plugin-promise": "^6.1.1",
48+
"gts": "^6.0.2",
49+
"prettier": "^3.3.3"
4450
},
4551
"engines": {
4652
"node": ">=18"

0 commit comments

Comments
 (0)