@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.
@@ -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.2.0"
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.2.0",
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.2.0",
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
@@ -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
- console.log('Next: review the new .gtl/ directory and commit it.');
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
- .action(async () => {
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);
@@ -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,
@@ -38,9 +38,14 @@ class BiomeAdapter extends LinterAdapter {
38
38
  return ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx'];
39
39
  }
40
40
 
41
- /** Prefer the project-local Biome; fall back to one on PATH. */
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
- const list = targets && targets.length ? targets : ['.'];
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: opts.cwd || this.projectRoot };
67
+ return { cmd: this.binary, args, cwd };
58
68
  }
59
69
 
60
70
  parse(stdout) {
@@ -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
- /** Prefer the project-local ESLint; fall back to one on PATH. */
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
- const list = targets && targets.length ? targets : ['.'];
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: opts.cwd || this.projectRoot };
86
+ return { cmd: this.binary, args, cwd };
61
87
  }
62
88
 
63
89
  parse(stdout) {
@@ -31,9 +31,10 @@ class RuffAdapter extends LinterAdapter {
31
31
  return ['.py', '.pyi'];
32
32
  }
33
33
 
34
- /** Prefer the project venv's Ruff; fall back to one on PATH. */
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
  ]);
@@ -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 here is
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, mirroring
13
- // the crate/module handling in the Clippy and golangci-lint adapters.
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 config files double as a detection hint; the real signal is the
45
- // presence of *.tf source (see sourceExtensions / project-model.js).
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', '.terraform.lock.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
- parse(stdout) {
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
- if (!text) return [];
191
+ let report = null;
192
+ if (text) {
193
+ try {
194
+ report = JSON.parse(text);
195
+ } catch {
196
+ report = null;
197
+ }
198
+ }
83
199
 
84
- let report;
85
- try {
86
- report = JSON.parse(text);
87
- } catch {
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