@theglitchking/gimme-the-lint 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +86 -0
- package/bin/gimme-the-lint.js +119 -3
- package/lib/adapters/adapter.js +35 -1
- package/lib/adapters/biome.js +13 -3
- package/lib/adapters/eslint.js +29 -3
- package/lib/adapters/ruff.js +2 -1
- package/lib/adapters/tflint.js +140 -12
- package/lib/baseline-store.js +26 -2
- package/lib/baseline.js +85 -6
- package/lib/check.js +19 -1
- package/lib/config-manager.js +40 -0
- package/lib/drift.js +42 -1
- package/lib/gtl-manifest.js +7 -1
- package/lib/index.js +0 -2
- package/lib/manifest-manager.js +10 -46
- package/lib/migrate.js +143 -0
- package/lib/project-model.js +121 -21
- package/lib/report.js +37 -3
- package/lib/rule-aliases.js +42 -0
- package/lib/toolchain.js +4 -1
- package/package.json +1 -1
- package/lib/drift-detector.js +0 -92
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Official marketplace for gimme-the-lint - polyglot progressive linting with per-app baselines and drift detection",
|
|
9
|
-
"version": "2.
|
|
9
|
+
"version": "2.4.0"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "gimme-the-lint",
|
|
14
14
|
"description": "Polyglot progressive linting for monorepos — adapter-driven baselines, per-app drift detection, and idempotent skips across JavaScript/TypeScript, Python, Go, Rust, Terraform, and Ansible.",
|
|
15
|
-
"version": "2.
|
|
15
|
+
"version": "2.4.0",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TheGlitchKing"
|
|
18
18
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gimme-the-lint",
|
|
3
3
|
"description": "Polyglot progressive linting for monorepos — adapter-driven baselines, per-app drift detection, and idempotent skips across JavaScript/TypeScript, Python, Go, Rust, Terraform, and Ansible.",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.4.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TheGlitchKing",
|
|
7
7
|
"email": "theglitchking@users.noreply.github.com"
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,92 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.4.0] - 2026-05-17
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **tflint silent-failure on uninitialized ruleset plugins.** When a unit
|
|
12
|
+
carries a `.tflint.hcl`, tflint requires `tflint --init` before linting; the
|
|
13
|
+
adapter never ran it, so a failed run was recorded as a clean zero-violation
|
|
14
|
+
baseline that mis-gated every later commit. Adapters gain an `initCommand()`
|
|
15
|
+
hook (run once per directory); the tflint adapter runs `tflint --init` when a
|
|
16
|
+
`.tflint.hcl` is present. `parse()` now takes `(stdout, stderr, code)` — a
|
|
17
|
+
non-zero exit with no parseable JSON emits a high-severity `tflint-error`
|
|
18
|
+
violation, never a silent clean pass.
|
|
19
|
+
- **eslint false-positive on a tooling-only `package.json`.** Discovery bound
|
|
20
|
+
eslint on the filename alone, so a devDependencies-only, source-free
|
|
21
|
+
`package.json` (one that merely pins a tooling dependency) became an eslint
|
|
22
|
+
app. `package.json` is now a conditional marker — eslint is bound only when
|
|
23
|
+
the directory looks like a real JS app (runtime deps / entry-point fields /
|
|
24
|
+
JS-TS source / an eslint or biome config). `EslintAdapter.detect()` is
|
|
25
|
+
likewise tightened so a bare `package.json` is insufficient.
|
|
26
|
+
- **tflint `available()` over-reporting.** It returned true even when ruleset
|
|
27
|
+
plugins a `.tflint.hcl` declared were not installed, disagreeing with
|
|
28
|
+
`lint()`. It now verifies every declared plugin resolves.
|
|
29
|
+
- **`.terraform.lock.hcl` removed from the tflint manifest files** — it is a
|
|
30
|
+
provider-lock file written by `terraform init`, not a tflint signal.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- **Generic ruleset-plugin version tracking.** `TflintAdapter.rulesetVersions()`
|
|
34
|
+
parses `tflint --version` into a `{ ruleset: version }` map — no ruleset name
|
|
35
|
+
is special-cased. A new `ruleset_versions` field is threaded through
|
|
36
|
+
baselines and the global manifest; drift detection emits a `ruleset` drift
|
|
37
|
+
entry when a plugin version changes, catching a plugin update under a loose
|
|
38
|
+
`.tflint.hcl` version constraint that left `config_hash` and `tool_version`
|
|
39
|
+
untouched.
|
|
40
|
+
- **Rule rename/removal migration.** Per-linter rule-alias maps
|
|
41
|
+
(`lib/rule-aliases.js`) plus `gimme-the-lint migrate --rules`: re-lints each
|
|
42
|
+
unit and rewrites a renamed rule's baseline fingerprint old→new (preserving
|
|
43
|
+
the grandfather count), drops entries for rules that no longer occur, and
|
|
44
|
+
corrects `total`. A genuinely new violation is never grandfathered.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
- **`tflint.parse()` signature** is now `(stdout, stderr, code)`, matching the
|
|
48
|
+
base adapter contract.
|
|
49
|
+
- **Removed the dead v1 drift path.** `lib/drift-detector.js` (superseded by
|
|
50
|
+
`lib/drift.js`) is deleted; `lib/manifest-manager.js` is slimmed to its one
|
|
51
|
+
live function, `hashFile()` — the v2 global manifest is owned by
|
|
52
|
+
`lib/gtl-manifest.js`.
|
|
53
|
+
|
|
54
|
+
### Design
|
|
55
|
+
- The tflint adapter never names a cloud provider. The bundled `terraform`
|
|
56
|
+
ruleset lints any Terraform repo with zero config; provider-specific rulesets
|
|
57
|
+
are opt-in and owned entirely by the target repo's own `.tflint.hcl`, which
|
|
58
|
+
the adapter parses generically for whatever `plugin` blocks it declares.
|
|
59
|
+
|
|
60
|
+
## [2.3.0] - 2026-05-17
|
|
61
|
+
|
|
62
|
+
### Fixed
|
|
63
|
+
- **Bug A — monorepo linter binary resolution.** The ESLint and Biome adapters
|
|
64
|
+
resolved `node_modules/.bin/<linter>` only at the repo root, so in a monorepo
|
|
65
|
+
(where each JS app carries its own `node_modules`) `available()` returned
|
|
66
|
+
false and the linter was silently skipped — capturing an empty baseline.
|
|
67
|
+
Adapters now resolve the binary app-dir-first (app → repo root → PATH) and
|
|
68
|
+
run inside the app directory so the app's own flat config is discovered. The
|
|
69
|
+
Ruff adapter resolves its `.venv` the same way.
|
|
70
|
+
- **Bug B — discovery bound the wrong directories.** Manifest discovery treated
|
|
71
|
+
a bare `requirements.txt` as a ruff app marker (binding nested load-test and
|
|
72
|
+
utility dirs) and discarded a repo-root config whenever any nested manifest
|
|
73
|
+
existed (leaving the real app unbound). `requirements.txt` is no longer a
|
|
74
|
+
discovery marker — `pyproject.toml`, `ruff.toml`, `.ruff.toml` and `setup.py`
|
|
75
|
+
are — and workspace-root detection is now **per linter**, so a repo-root
|
|
76
|
+
`[tool.ruff]` config binds the root even when nested `package.json` apps sit
|
|
77
|
+
below it.
|
|
78
|
+
- **Bug C — "couldn't run" collapsed into "skipped".** An unavailable or
|
|
79
|
+
errored linter was recorded with the status used for "no code here", making
|
|
80
|
+
an incomplete baseline indistinguishable from a clean one — every
|
|
81
|
+
pre-existing violation later counted as new and blocked the commit. New
|
|
82
|
+
baseline statuses `unavailable` and `error` keep incomplete baselines
|
|
83
|
+
distinct; `migrate` and `baseline` print a prominent summary and exit
|
|
84
|
+
non-zero (override with `--allow-incomplete`); `hooks` refuses to install
|
|
85
|
+
against an incomplete baseline (override with `--force`); `check` reports
|
|
86
|
+
`needs-baseline` instead of flooding new violations.
|
|
87
|
+
|
|
88
|
+
### Added
|
|
89
|
+
- `migrate` now writes the discovered app/linter layout into
|
|
90
|
+
`gimme-the-lint.config.js` as an explicit `apps` map, so the guess is visible
|
|
91
|
+
and editable instead of silently re-derived on every run. It also emits a
|
|
92
|
+
warning when the layout is ambiguous (a repo-root config plus nested apps).
|
|
93
|
+
|
|
8
94
|
## [2.2.0] - 2026-05-17
|
|
9
95
|
|
|
10
96
|
### Added
|
package/bin/gimme-the-lint.js
CHANGED
|
@@ -154,6 +154,7 @@ program
|
|
|
154
154
|
.description('Create or refresh progressive-lint baselines')
|
|
155
155
|
.option('--strict', 'Fail when a linter is missing for code that is present')
|
|
156
156
|
.option('--empty', 'Write empty baselines (greenfield: treat every violation as new)')
|
|
157
|
+
.option('--allow-incomplete', 'Exit 0 even if some linters could not be baselined')
|
|
157
158
|
.action(async (target, opts) => {
|
|
158
159
|
const chalk = require('chalk');
|
|
159
160
|
const { runBaseline } = require('../lib/baseline');
|
|
@@ -166,6 +167,17 @@ program
|
|
|
166
167
|
});
|
|
167
168
|
console.log(formatBaselineReport(report, process.cwd()));
|
|
168
169
|
console.log('');
|
|
170
|
+
// A baseline that could not capture a linter is incomplete — fail loudly
|
|
171
|
+
// rather than reporting success, unless the user opts in.
|
|
172
|
+
if (report.incomplete && report.incomplete.length && !opts.allowIncomplete) {
|
|
173
|
+
console.error(
|
|
174
|
+
chalk.red(
|
|
175
|
+
`✗ Baseline INCOMPLETE — ${report.incomplete.length} linter(s) not captured. ` +
|
|
176
|
+
'Install them and re-run, or pass --allow-incomplete.\n'
|
|
177
|
+
)
|
|
178
|
+
);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
169
181
|
} catch (err) {
|
|
170
182
|
console.error(chalk.red(`\n✗ ${err.message}\n`));
|
|
171
183
|
process.exit(1);
|
|
@@ -176,9 +188,46 @@ program
|
|
|
176
188
|
.command('migrate')
|
|
177
189
|
.description('Migrate a v1 (.lttf) project to the v2 .gtl/ layout')
|
|
178
190
|
.option('--strict', 'Fail when a linter is missing for code that is present')
|
|
191
|
+
.option('--allow-incomplete', 'Exit 0 even if some linters could not be baselined')
|
|
192
|
+
.option('--rules', 'Migrate baselines through rule renames/removals (no layout change)')
|
|
179
193
|
.action(async (opts) => {
|
|
180
194
|
const chalk = require('chalk');
|
|
181
|
-
const { migrate } = require('../lib/migrate');
|
|
195
|
+
const { migrate, migrateRules } = require('../lib/migrate');
|
|
196
|
+
|
|
197
|
+
// --rules: rewrite baseline fingerprints through the per-linter rule-alias
|
|
198
|
+
// maps. A standalone mode — it does not touch the .gtl/ layout.
|
|
199
|
+
if (opts.rules) {
|
|
200
|
+
try {
|
|
201
|
+
const result = await migrateRules(process.cwd());
|
|
202
|
+
if (!result.migrated) {
|
|
203
|
+
console.log(
|
|
204
|
+
chalk.yellow('\nNo rule migrations applied — baselines are up to date.\n')
|
|
205
|
+
);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
console.log(chalk.green('\n✓ Rule migration applied\n'));
|
|
209
|
+
for (const u of result.units) {
|
|
210
|
+
for (const l of u.linters) {
|
|
211
|
+
const renames = l.renamed
|
|
212
|
+
.map((r) => `${r.from}→${r.to}`)
|
|
213
|
+
.join(', ');
|
|
214
|
+
console.log(
|
|
215
|
+
` ${u.app} · ${l.linter}: ` +
|
|
216
|
+
`${l.renamed.length} renamed${renames ? ` (${renames})` : ''}, ` +
|
|
217
|
+
`${l.dropped} dropped`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
console.log('');
|
|
222
|
+
console.log('Review the updated .gtl/ baselines and commit them.');
|
|
223
|
+
console.log('');
|
|
224
|
+
} catch (err) {
|
|
225
|
+
console.error(chalk.red(`\n✗ Rule migration failed: ${err.message}\n`));
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
182
231
|
try {
|
|
183
232
|
const result = await migrate(process.cwd(), { strict: opts.strict });
|
|
184
233
|
if (!result.migrated) {
|
|
@@ -188,9 +237,46 @@ program
|
|
|
188
237
|
console.log(chalk.green('\n✓ Migrated to the v2 .gtl/ layout\n'));
|
|
189
238
|
console.log(` Legacy baselines backed up: ${result.backedUp.join(', ')}`);
|
|
190
239
|
console.log(chalk.dim(` → ${result.backupPath}`));
|
|
240
|
+
if (result.appsConfig && result.appsConfig.created) {
|
|
241
|
+
console.log(
|
|
242
|
+
` Wrote explicit app map → ${path.basename(result.appsConfig.path)}`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
191
245
|
console.log(` Re-baselined ${result.baseline.unitCount} app(s) into .gtl/`);
|
|
246
|
+
for (const w of result.discoveryWarnings || []) {
|
|
247
|
+
console.log(chalk.yellow(` ⚠ ${w}`));
|
|
248
|
+
}
|
|
192
249
|
console.log('');
|
|
193
|
-
|
|
250
|
+
|
|
251
|
+
// An incomplete baseline must never be reported as a clean migration.
|
|
252
|
+
if (result.incomplete && result.incomplete.length) {
|
|
253
|
+
console.error(
|
|
254
|
+
chalk.red(`✗ ${result.incomplete.length} linter(s) were NOT baselined:`)
|
|
255
|
+
);
|
|
256
|
+
for (const i of result.incomplete) {
|
|
257
|
+
console.error(chalk.red(` ${i.app} · ${i.linter} (${i.status})`));
|
|
258
|
+
}
|
|
259
|
+
console.error(
|
|
260
|
+
chalk.yellow(
|
|
261
|
+
' The baseline is INCOMPLETE — install the missing linters and run\n' +
|
|
262
|
+
' `gimme-the-lint baseline`, then commit .gtl/.'
|
|
263
|
+
)
|
|
264
|
+
);
|
|
265
|
+
if (!opts.allowIncomplete) {
|
|
266
|
+
console.error(
|
|
267
|
+
chalk.red(
|
|
268
|
+
'\n✗ Migration finished with an incomplete baseline ' +
|
|
269
|
+
'(pass --allow-incomplete to accept).\n'
|
|
270
|
+
)
|
|
271
|
+
);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
console.log('');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
console.log(
|
|
278
|
+
'Next: review the new .gtl/ directory and gimme-the-lint.config.js, then commit them.'
|
|
279
|
+
);
|
|
194
280
|
console.log('');
|
|
195
281
|
} catch (err) {
|
|
196
282
|
console.error(chalk.red(`\n✗ Migration failed: ${err.message}\n`));
|
|
@@ -210,13 +296,43 @@ program
|
|
|
210
296
|
program
|
|
211
297
|
.command('hooks')
|
|
212
298
|
.description('Install git hooks for pre-commit linting')
|
|
213
|
-
.
|
|
299
|
+
.option('--force', 'Install even when the baseline is incomplete')
|
|
300
|
+
.action(async (opts) => {
|
|
214
301
|
const chalk = require('chalk');
|
|
215
302
|
const gitHooksManager = require('../lib/git-hooks-manager');
|
|
303
|
+
const { findIncompleteBaselines } = require('../lib/baseline');
|
|
216
304
|
|
|
217
305
|
try {
|
|
306
|
+
// Refuse to gate commits against an incomplete baseline: hooks would
|
|
307
|
+
// diff against linter sections that were never captured, so every
|
|
308
|
+
// pre-existing violation would count as new and block the commit.
|
|
309
|
+
const incomplete = await findIncompleteBaselines(process.cwd());
|
|
310
|
+
if (incomplete.length && !opts.force) {
|
|
311
|
+
console.error(
|
|
312
|
+
chalk.red('\n✗ Refusing to install hooks — the baseline is incomplete:\n')
|
|
313
|
+
);
|
|
314
|
+
for (const i of incomplete) {
|
|
315
|
+
console.error(chalk.red(` ${i.app} · ${i.linter} (${i.status})`));
|
|
316
|
+
}
|
|
317
|
+
console.error(
|
|
318
|
+
chalk.yellow(
|
|
319
|
+
'\n These linters were never baselined, so a pre-commit hook would\n' +
|
|
320
|
+
' flag every pre-existing violation as new. Install the missing\n' +
|
|
321
|
+
' linters and run `gimme-the-lint baseline`, or pass --force.\n'
|
|
322
|
+
)
|
|
323
|
+
);
|
|
324
|
+
process.exit(1);
|
|
325
|
+
}
|
|
326
|
+
|
|
218
327
|
const installed = await gitHooksManager.installHooks(process.cwd());
|
|
219
328
|
console.log(chalk.green(`\n✓ Installed git hooks: ${installed.join(', ')}\n`));
|
|
329
|
+
if (incomplete.length && opts.force) {
|
|
330
|
+
console.log(
|
|
331
|
+
chalk.yellow(
|
|
332
|
+
'⚠ Installed against an INCOMPLETE baseline (--force) — re-baseline soon.\n'
|
|
333
|
+
)
|
|
334
|
+
);
|
|
335
|
+
}
|
|
220
336
|
} catch (e) {
|
|
221
337
|
console.error(chalk.red(`\n✗ ${e.message}\n`));
|
|
222
338
|
process.exit(1);
|
package/lib/adapters/adapter.js
CHANGED
|
@@ -26,8 +26,14 @@ const DEFAULT_IGNORE_DIRS = new Set([
|
|
|
26
26
|
const SCAN_DEPTH = 5;
|
|
27
27
|
|
|
28
28
|
class LinterAdapter {
|
|
29
|
-
constructor({ projectRoot } = {}) {
|
|
29
|
+
constructor({ projectRoot, appRoot } = {}) {
|
|
30
30
|
this.projectRoot = projectRoot || process.cwd();
|
|
31
|
+
// appRoot is the directory of the specific app/unit being linted. In a
|
|
32
|
+
// monorepo each app carries its own node_modules / .venv, so project-local
|
|
33
|
+
// tool resolution and config discovery must start here — not at the repo
|
|
34
|
+
// root, which often has neither. Defaults to projectRoot for single-package
|
|
35
|
+
// projects and for callers that do not supply it.
|
|
36
|
+
this.appRoot = appRoot || this.projectRoot;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
// --- identity (override in subclasses) ---
|
|
@@ -141,6 +147,33 @@ class LinterAdapter {
|
|
|
141
147
|
throw new Error(`${this.id}: buildCommand() not implemented`);
|
|
142
148
|
}
|
|
143
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Optional one-time setup command run in a directory BEFORE linting it —
|
|
152
|
+
* e.g. `tflint --init` to fetch the ruleset plugins a .tflint.hcl declares.
|
|
153
|
+
* Without it tflint exits non-zero on an uninitialized plugin. Return null
|
|
154
|
+
* when no setup is needed (the default for every other adapter).
|
|
155
|
+
* @param {string} cwd The directory linting will run in.
|
|
156
|
+
* @returns {{cmd: string, args: string[], env?: object}|null}
|
|
157
|
+
*/
|
|
158
|
+
// eslint-disable-next-line no-unused-vars
|
|
159
|
+
initCommand(cwd) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Run initCommand() once per directory, before the first lint there. */
|
|
164
|
+
_ensureInitialized(cwd) {
|
|
165
|
+
const spec = this.initCommand(cwd);
|
|
166
|
+
if (!spec) return;
|
|
167
|
+
if (!this._initialized) this._initialized = new Set();
|
|
168
|
+
if (this._initialized.has(cwd)) return;
|
|
169
|
+
this._initialized.add(cwd);
|
|
170
|
+
spawnSync(spec.cmd, spec.args, {
|
|
171
|
+
cwd,
|
|
172
|
+
env: spec.env ? { ...process.env, ...spec.env } : process.env,
|
|
173
|
+
encoding: 'utf8',
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
144
177
|
// --- override: output parsing ---
|
|
145
178
|
/**
|
|
146
179
|
* @param {string} stdout
|
|
@@ -172,6 +205,7 @@ class LinterAdapter {
|
|
|
172
205
|
lint(targets, opts = {}) {
|
|
173
206
|
const spec = this.buildCommand(targets, opts);
|
|
174
207
|
this._runCwd = spec.cwd || this.projectRoot;
|
|
208
|
+
this._ensureInitialized(this._runCwd);
|
|
175
209
|
const result = spawnSync(spec.cmd, spec.args, {
|
|
176
210
|
cwd: this._runCwd,
|
|
177
211
|
env: spec.env ? { ...process.env, ...spec.env } : process.env,
|
package/lib/adapters/biome.js
CHANGED
|
@@ -38,9 +38,14 @@ class BiomeAdapter extends LinterAdapter {
|
|
|
38
38
|
return ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx'];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Prefer the app-local Biome, then the repo-root Biome, then PATH. Each JS
|
|
43
|
+
* app in a monorepo has its own node_modules; resolving only at projectRoot
|
|
44
|
+
* makes available() falsely return false for app-installed Biome.
|
|
45
|
+
*/
|
|
42
46
|
get binary() {
|
|
43
47
|
return this.resolveBinary([
|
|
48
|
+
path.join(this.appRoot, 'node_modules', '.bin', 'biome'),
|
|
44
49
|
path.join(this.projectRoot, 'node_modules', '.bin', 'biome'),
|
|
45
50
|
'biome',
|
|
46
51
|
]);
|
|
@@ -52,9 +57,14 @@ class BiomeAdapter extends LinterAdapter {
|
|
|
52
57
|
|
|
53
58
|
buildCommand(targets, opts = {}) {
|
|
54
59
|
const args = ['lint', '--reporter=json'];
|
|
55
|
-
|
|
60
|
+
// Run inside the app directory so Biome resolves the app's own biome.json.
|
|
61
|
+
const cwd = opts.cwd || this.appRoot;
|
|
62
|
+
const list = (targets && targets.length ? targets : ['.']).map((t) => {
|
|
63
|
+
const rel = path.relative(cwd, path.resolve(this.projectRoot, t));
|
|
64
|
+
return rel === '' ? '.' : rel;
|
|
65
|
+
});
|
|
56
66
|
args.push(...list);
|
|
57
|
-
return { cmd: this.binary, args, cwd
|
|
67
|
+
return { cmd: this.binary, args, cwd };
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
parse(stdout) {
|
package/lib/adapters/eslint.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('fs');
|
|
3
4
|
const path = require('path');
|
|
4
5
|
const { LinterAdapter } = require('./adapter');
|
|
5
6
|
const { createViolation, SEVERITY } = require('../violation');
|
|
@@ -40,9 +41,15 @@ class EslintAdapter extends LinterAdapter {
|
|
|
40
41
|
return ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx'];
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Prefer the app-local ESLint, then the repo-root ESLint, then PATH. In a
|
|
46
|
+
* monorepo each JS app has its own node_modules; the repo root often has
|
|
47
|
+
* none, so resolving only at projectRoot makes available() falsely return
|
|
48
|
+
* false and the linter gets silently skipped.
|
|
49
|
+
*/
|
|
44
50
|
get binary() {
|
|
45
51
|
return this.resolveBinary([
|
|
52
|
+
path.join(this.appRoot, 'node_modules', '.bin', 'eslint'),
|
|
46
53
|
path.join(this.projectRoot, 'node_modules', '.bin', 'eslint'),
|
|
47
54
|
'eslint',
|
|
48
55
|
]);
|
|
@@ -52,12 +59,31 @@ class EslintAdapter extends LinterAdapter {
|
|
|
52
59
|
return ESLINT_CONFIG_FILES;
|
|
53
60
|
}
|
|
54
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Tighter than the base detect(): a bare `package.json` is NOT sufficient —
|
|
64
|
+
* a devDependencies-only tooling manifest is not a JS app. Require an ESLint
|
|
65
|
+
* config file, or actual JS/TS source under the directory.
|
|
66
|
+
*/
|
|
67
|
+
detect(dir) {
|
|
68
|
+
if (!dir || !fs.existsSync(dir)) return false;
|
|
69
|
+
for (const name of ESLINT_CONFIG_FILES) {
|
|
70
|
+
if (fs.existsSync(path.join(dir, name))) return true;
|
|
71
|
+
}
|
|
72
|
+
return this._scanForSource(dir, 5);
|
|
73
|
+
}
|
|
74
|
+
|
|
55
75
|
buildCommand(targets, opts = {}) {
|
|
56
76
|
const args = ['--format=json'];
|
|
57
77
|
if (opts.fix) args.push('--fix');
|
|
58
|
-
|
|
78
|
+
// Run inside the app directory so ESLint resolves the app's own flat
|
|
79
|
+
// config (eslint.config.js) — in a monorepo the repo root often has none.
|
|
80
|
+
const cwd = opts.cwd || this.appRoot;
|
|
81
|
+
const list = (targets && targets.length ? targets : ['.']).map((t) => {
|
|
82
|
+
const rel = path.relative(cwd, path.resolve(this.projectRoot, t));
|
|
83
|
+
return rel === '' ? '.' : rel;
|
|
84
|
+
});
|
|
59
85
|
args.push(...list);
|
|
60
|
-
return { cmd: this.binary, args, cwd
|
|
86
|
+
return { cmd: this.binary, args, cwd };
|
|
61
87
|
}
|
|
62
88
|
|
|
63
89
|
parse(stdout) {
|
package/lib/adapters/ruff.js
CHANGED
|
@@ -31,9 +31,10 @@ class RuffAdapter extends LinterAdapter {
|
|
|
31
31
|
return ['.py', '.pyi'];
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/** Prefer the
|
|
34
|
+
/** Prefer the app venv's Ruff, then the repo-root venv, then PATH. */
|
|
35
35
|
get binary() {
|
|
36
36
|
return this.resolveBinary([
|
|
37
|
+
path.join(this.appRoot, '.venv', 'bin', 'ruff'),
|
|
37
38
|
path.join(this.projectRoot, '.venv', 'bin', 'ruff'),
|
|
38
39
|
'ruff',
|
|
39
40
|
]);
|
package/lib/adapters/tflint.js
CHANGED
|
@@ -7,10 +7,17 @@ const { createViolation, SEVERITY } = require('../violation');
|
|
|
7
7
|
|
|
8
8
|
// tflint adapter. tflint is the de-facto Terraform/OpenTofu linter; it emits a
|
|
9
9
|
// machine-readable report under `--format json`. Terraform has no manifest
|
|
10
|
-
// file — a directory of *.tf (or *.tofu) IS the module — so detection
|
|
10
|
+
// file — a directory of *.tf (or *.tofu) IS the module — so detection is
|
|
11
11
|
// extension-based. tflint is module-scoped: it lints the .tf files of one
|
|
12
|
-
// directory, so the adapter resolves and runs inside that directory
|
|
13
|
-
//
|
|
12
|
+
// directory, so the adapter resolves and runs inside that directory.
|
|
13
|
+
//
|
|
14
|
+
// tflint's bundled `terraform` ruleset is a universal, provider-independent
|
|
15
|
+
// baseline — it lints ANY Terraform repo (hyperscaler, on-prem, SaaS, network
|
|
16
|
+
// appliances, local/null/random) with zero config. Provider-specific rulesets
|
|
17
|
+
// (aws/google/azurerm/…) are opt-in and owned entirely by the target repo's
|
|
18
|
+
// own .tflint.hcl. This adapter never names a provider: it always runs core
|
|
19
|
+
// tflint and, when a .tflint.hcl is present, parses it GENERICALLY to drive
|
|
20
|
+
// `--init`, availability checks and ruleset-version tracking.
|
|
14
21
|
|
|
15
22
|
const TFLINT_CONFIG_FILES = ['.tflint.hcl'];
|
|
16
23
|
|
|
@@ -41,10 +48,12 @@ class TflintAdapter extends LinterAdapter {
|
|
|
41
48
|
return true;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
// tflint
|
|
45
|
-
//
|
|
51
|
+
// A .tflint.hcl is a hint, not the discovery signal — *.tf source is (see
|
|
52
|
+
// sourceExtensions / project-model.js). `.terraform.lock.hcl` is NOT listed:
|
|
53
|
+
// it is a provider-lock file written by `terraform init`, frequently absent,
|
|
54
|
+
// and no signal that tflint applies.
|
|
46
55
|
get manifestFiles() {
|
|
47
|
-
return ['.tflint.hcl'
|
|
56
|
+
return ['.tflint.hcl'];
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
get sourceExtensions() {
|
|
@@ -55,6 +64,101 @@ class TflintAdapter extends LinterAdapter {
|
|
|
55
64
|
return TFLINT_CONFIG_FILES;
|
|
56
65
|
}
|
|
57
66
|
|
|
67
|
+
// --- .tflint.hcl introspection (generic — no provider is special-cased) ---
|
|
68
|
+
|
|
69
|
+
/** True when the unit directory carries a .tflint.hcl. */
|
|
70
|
+
_hasConfig(dir) {
|
|
71
|
+
return fs.existsSync(path.join(dir || this.appRoot, '.tflint.hcl'));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Enumerate every `plugin "<name>"` block declared in a unit's .tflint.hcl. */
|
|
75
|
+
_declaredPlugins(dir) {
|
|
76
|
+
let hcl;
|
|
77
|
+
try {
|
|
78
|
+
hcl = fs.readFileSync(path.join(dir || this.appRoot, '.tflint.hcl'), 'utf8');
|
|
79
|
+
} catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
const names = [];
|
|
83
|
+
const re = /plugin\s+"([^"]+)"/g;
|
|
84
|
+
let m;
|
|
85
|
+
while ((m = re.exec(hcl)) !== null) names.push(m[1]);
|
|
86
|
+
return names;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Cached `tflint --version` output (memoized after any --init has run). */
|
|
90
|
+
_versionOutput() {
|
|
91
|
+
if (this._versionOut == null) {
|
|
92
|
+
const result = this._probe();
|
|
93
|
+
this._versionOut = `${result.stdout || ''}\n${result.stderr || ''}`;
|
|
94
|
+
this._versionProbeFailed = Boolean(result.error) || result.status !== 0;
|
|
95
|
+
}
|
|
96
|
+
return this._versionOut;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- init: fetch ruleset plugins a .tflint.hcl declares -------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* When the unit carries a .tflint.hcl, tflint requires `tflint --init` to
|
|
103
|
+
* fetch the declared ruleset plugins before linting — otherwise it exits
|
|
104
|
+
* non-zero with a plugin error. Run it once per directory (cached by the
|
|
105
|
+
* base adapter). A repo with no .tflint.hcl gets core linting, no init.
|
|
106
|
+
*/
|
|
107
|
+
initCommand(cwd) {
|
|
108
|
+
if (this._hasConfig(cwd)) {
|
|
109
|
+
return { cmd: this.binary, args: ['--init'] };
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- availability: agree with what lint() will actually see --------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* available() must agree with lint(). The binary must run AND every ruleset
|
|
118
|
+
* plugin the unit's .tflint.hcl declares must resolve. Plugins are fetched
|
|
119
|
+
* by `tflint --init`, so run it (idempotent, cached) before checking, then
|
|
120
|
+
* confirm each declared plugin appears in `tflint --version` output. The
|
|
121
|
+
* bundled `terraform` ruleset ships in the binary and always resolves.
|
|
122
|
+
*/
|
|
123
|
+
available() {
|
|
124
|
+
const probe = this._probe();
|
|
125
|
+
if (probe.error || probe.status !== 0) return false;
|
|
126
|
+
|
|
127
|
+
const declared = this._declaredPlugins(this.appRoot).filter(
|
|
128
|
+
(name) => name !== 'terraform'
|
|
129
|
+
);
|
|
130
|
+
if (declared.length === 0) return true;
|
|
131
|
+
|
|
132
|
+
this._ensureInitialized(this.appRoot);
|
|
133
|
+
const installed = this.rulesetVersions();
|
|
134
|
+
return declared.every((name) => name in installed);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- version reporting ----------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/** The tflint binary version, parsed deliberately from the version line. */
|
|
140
|
+
version() {
|
|
141
|
+
const match = this._versionOutput().match(/TFLint version (\d+\.\d+\.\d+)/i);
|
|
142
|
+
return match ? match[1] : 'unknown';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Installed ruleset-plugin versions, as a generic { name: version } map —
|
|
147
|
+
* one entry per `+ ruleset.<name> (<version>...)` line of `tflint --version`.
|
|
148
|
+
* No ruleset name is special-cased, so this tracks the bundled `terraform`
|
|
149
|
+
* ruleset and any provider ruleset a repo opts into, current or future.
|
|
150
|
+
* @returns {Object<string,string>}
|
|
151
|
+
*/
|
|
152
|
+
rulesetVersions() {
|
|
153
|
+
const versions = {};
|
|
154
|
+
for (const line of this._versionOutput().split('\n')) {
|
|
155
|
+
// "+ ruleset.terraform (0.7.0, bundled)" / "+ ruleset.aws (0.30.0)"
|
|
156
|
+
const m = line.match(/^\s*\+\s*ruleset\.(\S+)\s*\(\s*([0-9][^,)\s]*)/);
|
|
157
|
+
if (m) versions[m[1]] = m[2];
|
|
158
|
+
}
|
|
159
|
+
return versions;
|
|
160
|
+
}
|
|
161
|
+
|
|
58
162
|
buildCommand(targets, opts = {}) {
|
|
59
163
|
const args = ['--format=json'];
|
|
60
164
|
if (opts.fix && this.supportsFix) args.push('--fix');
|
|
@@ -77,14 +181,38 @@ class TflintAdapter extends LinterAdapter {
|
|
|
77
181
|
return { cmd: this.binary, args, cwd };
|
|
78
182
|
}
|
|
79
183
|
|
|
80
|
-
|
|
184
|
+
/**
|
|
185
|
+
* @param {string} stdout
|
|
186
|
+
* @param {string} stderr
|
|
187
|
+
* @param {number|null} code tflint's exit code.
|
|
188
|
+
*/
|
|
189
|
+
parse(stdout, stderr, code) {
|
|
81
190
|
const text = (stdout || '').trim();
|
|
82
|
-
|
|
191
|
+
let report = null;
|
|
192
|
+
if (text) {
|
|
193
|
+
try {
|
|
194
|
+
report = JSON.parse(text);
|
|
195
|
+
} catch {
|
|
196
|
+
report = null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
83
199
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
200
|
+
// A non-zero exit with no parseable JSON is a hard failure — an
|
|
201
|
+
// uninitialized ruleset plugin, a broken config, an unusable binary. It
|
|
202
|
+
// must be LOUD: emit a synthetic high-severity violation carrying the
|
|
203
|
+
// stderr text, never let a failed run be recorded as a clean baseline.
|
|
204
|
+
if (!report) {
|
|
205
|
+
if (code != null && code !== 0) {
|
|
206
|
+
return [
|
|
207
|
+
createViolation({
|
|
208
|
+
file: '',
|
|
209
|
+
ruleId: 'tflint-error',
|
|
210
|
+
severity: SEVERITY.ERROR,
|
|
211
|
+
message: `tflint exited ${code}: ${(stderr || '').trim() || 'no output'}`,
|
|
212
|
+
source: 'tflint',
|
|
213
|
+
}),
|
|
214
|
+
];
|
|
215
|
+
}
|
|
88
216
|
return [];
|
|
89
217
|
}
|
|
90
218
|
|