@theglitchking/gimme-the-lint 2.3.0 → 2.5.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.3.0"
9
+ "version": "2.5.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.3.0",
15
+ "version": "2.5.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.3.0",
4
+ "version": "2.5.0",
5
5
  "author": {
6
6
  "name": "TheGlitchKing",
7
7
  "email": "theglitchking@users.noreply.github.com"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,73 @@ 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.5.0] - 2026-05-17
9
+
10
+ ### Added
11
+ - **`.gtl/config.js` — consolidated config location.** gimme-the-lint's own
12
+ config file now lives canonically at `.gtl/config.js` (`.gtl/config.cjs` for
13
+ ESM projects), so it travels with the committed `.gtl/` baselines rather than
14
+ sitting loose at the repo root. `install` and `migrate` write new configs
15
+ there; `config-manager.findConfig()` resolves the location. A repo-root
16
+ `gimme-the-lint.config.js` is still read as a fallback — existing projects
17
+ need no change — and `.gtl/` wins when both exist. `uninstall` removes a
18
+ repo-root config but leaves a `.gtl/` one in place (it is part of the
19
+ preserved `.gtl/` directory). Linter configs (`eslint.config.js`,
20
+ `.tflint.hcl`, …) are unaffected: each linter resolves its own config from a
21
+ fixed location gimme-the-lint does not own.
22
+
23
+ ## [2.4.0] - 2026-05-17
24
+
25
+ ### Fixed
26
+ - **tflint silent-failure on uninitialized ruleset plugins.** When a unit
27
+ carries a `.tflint.hcl`, tflint requires `tflint --init` before linting; the
28
+ adapter never ran it, so a failed run was recorded as a clean zero-violation
29
+ baseline that mis-gated every later commit. Adapters gain an `initCommand()`
30
+ hook (run once per directory); the tflint adapter runs `tflint --init` when a
31
+ `.tflint.hcl` is present. `parse()` now takes `(stdout, stderr, code)` — a
32
+ non-zero exit with no parseable JSON emits a high-severity `tflint-error`
33
+ violation, never a silent clean pass.
34
+ - **eslint false-positive on a tooling-only `package.json`.** Discovery bound
35
+ eslint on the filename alone, so a devDependencies-only, source-free
36
+ `package.json` (one that merely pins a tooling dependency) became an eslint
37
+ app. `package.json` is now a conditional marker — eslint is bound only when
38
+ the directory looks like a real JS app (runtime deps / entry-point fields /
39
+ JS-TS source / an eslint or biome config). `EslintAdapter.detect()` is
40
+ likewise tightened so a bare `package.json` is insufficient.
41
+ - **tflint `available()` over-reporting.** It returned true even when ruleset
42
+ plugins a `.tflint.hcl` declared were not installed, disagreeing with
43
+ `lint()`. It now verifies every declared plugin resolves.
44
+ - **`.terraform.lock.hcl` removed from the tflint manifest files** — it is a
45
+ provider-lock file written by `terraform init`, not a tflint signal.
46
+
47
+ ### Added
48
+ - **Generic ruleset-plugin version tracking.** `TflintAdapter.rulesetVersions()`
49
+ parses `tflint --version` into a `{ ruleset: version }` map — no ruleset name
50
+ is special-cased. A new `ruleset_versions` field is threaded through
51
+ baselines and the global manifest; drift detection emits a `ruleset` drift
52
+ entry when a plugin version changes, catching a plugin update under a loose
53
+ `.tflint.hcl` version constraint that left `config_hash` and `tool_version`
54
+ untouched.
55
+ - **Rule rename/removal migration.** Per-linter rule-alias maps
56
+ (`lib/rule-aliases.js`) plus `gimme-the-lint migrate --rules`: re-lints each
57
+ unit and rewrites a renamed rule's baseline fingerprint old→new (preserving
58
+ the grandfather count), drops entries for rules that no longer occur, and
59
+ corrects `total`. A genuinely new violation is never grandfathered.
60
+
61
+ ### Changed
62
+ - **`tflint.parse()` signature** is now `(stdout, stderr, code)`, matching the
63
+ base adapter contract.
64
+ - **Removed the dead v1 drift path.** `lib/drift-detector.js` (superseded by
65
+ `lib/drift.js`) is deleted; `lib/manifest-manager.js` is slimmed to its one
66
+ live function, `hashFile()` — the v2 global manifest is owned by
67
+ `lib/gtl-manifest.js`.
68
+
69
+ ### Design
70
+ - The tflint adapter never names a cloud provider. The bundled `terraform`
71
+ ruleset lints any Terraform repo with zero config; provider-specific rulesets
72
+ are opt-in and owned entirely by the target repo's own `.tflint.hcl`, which
73
+ the adapter parses generically for whatever `plugin` blocks it declares.
74
+
8
75
  ## [2.3.0] - 2026-05-17
9
76
 
10
77
  ### Fixed
package/README.md CHANGED
@@ -173,7 +173,10 @@ git add -A && git commit -m "…" # retry
173
173
  ## Configuration
174
174
 
175
175
  Zero config is the default — apps and their linters are auto-detected. To
176
- override, add `gimme-the-lint.config.js` at the repo root:
176
+ override, add a config file. The canonical location is **`.gtl/config.js`**
177
+ (it travels with the committed `.gtl/` baselines); a repo-root
178
+ `gimme-the-lint.config.js` is also read, for back-compatibility. `install` and
179
+ `migrate` write new configs to `.gtl/`:
177
180
 
178
181
  ```js
179
182
  module.exports = {
@@ -86,6 +86,7 @@ program
86
86
  .action(async () => {
87
87
  const chalk = require('chalk');
88
88
  const gitHooksManager = require('../lib/git-hooks-manager');
89
+ const configManager = require('../lib/config-manager');
89
90
 
90
91
  console.log(chalk.blue('\ngimme-the-lint: Uninstalling...\n'));
91
92
 
@@ -94,10 +95,17 @@ program
94
95
  console.log(chalk.green(` ✓ Removed git hooks: ${removed.join(', ')}`));
95
96
  }
96
97
 
97
- const configPath = path.join(process.cwd(), 'gimme-the-lint.config.js');
98
- if (fs.existsSync(configPath)) {
99
- fs.unlinkSync(configPath);
100
- console.log(chalk.green(' ✓ Removed gimme-the-lint.config.js'));
98
+ // Remove a repo-root config. A config under .gtl/ is left in place — it is
99
+ // part of the .gtl/ directory, which uninstall preserves.
100
+ const cfgPath = configManager.findConfig(process.cwd());
101
+ if (cfgPath) {
102
+ const rel = path.relative(process.cwd(), cfgPath);
103
+ if (rel.startsWith(`.gtl${path.sep}`)) {
104
+ console.log(chalk.dim(` · Config kept (${rel} — part of .gtl/)`));
105
+ } else {
106
+ fs.unlinkSync(cfgPath);
107
+ console.log(chalk.green(` ✓ Removed ${path.basename(cfgPath)}`));
108
+ }
101
109
  }
102
110
 
103
111
  console.log(chalk.green('\n✓ Uninstall complete.\n'));
@@ -189,9 +197,45 @@ program
189
197
  .description('Migrate a v1 (.lttf) project to the v2 .gtl/ layout')
190
198
  .option('--strict', 'Fail when a linter is missing for code that is present')
191
199
  .option('--allow-incomplete', 'Exit 0 even if some linters could not be baselined')
200
+ .option('--rules', 'Migrate baselines through rule renames/removals (no layout change)')
192
201
  .action(async (opts) => {
193
202
  const chalk = require('chalk');
194
- const { migrate } = require('../lib/migrate');
203
+ const { migrate, migrateRules } = require('../lib/migrate');
204
+
205
+ // --rules: rewrite baseline fingerprints through the per-linter rule-alias
206
+ // maps. A standalone mode — it does not touch the .gtl/ layout.
207
+ if (opts.rules) {
208
+ try {
209
+ const result = await migrateRules(process.cwd());
210
+ if (!result.migrated) {
211
+ console.log(
212
+ chalk.yellow('\nNo rule migrations applied — baselines are up to date.\n')
213
+ );
214
+ return;
215
+ }
216
+ console.log(chalk.green('\n✓ Rule migration applied\n'));
217
+ for (const u of result.units) {
218
+ for (const l of u.linters) {
219
+ const renames = l.renamed
220
+ .map((r) => `${r.from}→${r.to}`)
221
+ .join(', ');
222
+ console.log(
223
+ ` ${u.app} · ${l.linter}: ` +
224
+ `${l.renamed.length} renamed${renames ? ` (${renames})` : ''}, ` +
225
+ `${l.dropped} dropped`
226
+ );
227
+ }
228
+ }
229
+ console.log('');
230
+ console.log('Review the updated .gtl/ baselines and commit them.');
231
+ console.log('');
232
+ } catch (err) {
233
+ console.error(chalk.red(`\n✗ Rule migration failed: ${err.message}\n`));
234
+ process.exit(1);
235
+ }
236
+ return;
237
+ }
238
+
195
239
  try {
196
240
  const result = await migrate(process.cwd(), { strict: opts.strict });
197
241
  if (!result.migrated) {
@@ -336,7 +380,7 @@ program
336
380
  const projectType = await configManager.detectProjectType(projectRoot);
337
381
  const venvStatus = venvManager.getStatus(projectRoot);
338
382
  const hookStatus = await gitHooksManager.getStatus(projectRoot);
339
- const configExists = fs.existsSync(path.join(projectRoot, 'gimme-the-lint.config.js'));
383
+ const configExists = Boolean(configManager.findConfig(projectRoot));
340
384
 
341
385
  console.log(chalk.blue('\ngimme-the-lint Status\n'));
342
386
  console.log(` Project type: ${projectType}`);
@@ -147,6 +147,33 @@ class LinterAdapter {
147
147
  throw new Error(`${this.id}: buildCommand() not implemented`);
148
148
  }
149
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
+
150
177
  // --- override: output parsing ---
151
178
  /**
152
179
  * @param {string} stdout
@@ -178,6 +205,7 @@ class LinterAdapter {
178
205
  lint(targets, opts = {}) {
179
206
  const spec = this.buildCommand(targets, opts);
180
207
  this._runCwd = spec.cwd || this.projectRoot;
208
+ this._ensureInitialized(this._runCwd);
181
209
  const result = spawnSync(spec.cmd, spec.args, {
182
210
  cwd: this._runCwd,
183
211
  env: spec.env ? { ...process.env, ...spec.env } : process.env,
@@ -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');
@@ -58,6 +59,19 @@ class EslintAdapter extends LinterAdapter {
58
59
  return ESLINT_CONFIG_FILES;
59
60
  }
60
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
+
61
75
  buildCommand(targets, opts = {}) {
62
76
  const args = ['--format=json'];
63
77
  if (opts.fix) args.push('--fix');
@@ -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
 
@@ -65,19 +65,29 @@ function buildFingerprintMap(violations) {
65
65
  * @param {string} [meta.toolVersion]
66
66
  * @param {string} [meta.configHash]
67
67
  * @param {string} [meta.status] One of STATUS; defaults based on violations.
68
+ * @param {Object<string,string>} [meta.rulesetVersions] Per-ruleset-plugin
69
+ * versions (e.g. tflint). A loose .tflint.hcl version constraint lets a
70
+ * plugin update without the config text changing, so neither config_hash
71
+ * nor tool_version moves — this map is what makes that rule change visible.
68
72
  */
69
73
  function createLinterSection(violations, meta = {}) {
70
74
  const list = violations || [];
71
75
  const fingerprints = buildFingerprintMap(list);
72
76
  const status =
73
77
  meta.status || (list.length > 0 ? STATUS.BASELINED : STATUS.CLEAN);
74
- return {
78
+ const section = {
75
79
  tool_version: meta.toolVersion || 'unknown',
76
80
  config_hash: meta.configHash || 'unknown',
77
81
  status,
78
82
  total: list.length,
79
83
  fingerprints,
80
84
  };
85
+ // Only present for linters with ruleset plugins — kept off every other
86
+ // section so the baseline format stays unchanged for them.
87
+ if (meta.rulesetVersions && Object.keys(meta.rulesetVersions).length) {
88
+ section.ruleset_versions = meta.rulesetVersions;
89
+ }
90
+ return section;
81
91
  }
82
92
 
83
93
  /** A fresh, empty baseline object. */
package/lib/baseline.js CHANGED
@@ -102,9 +102,16 @@ async function baselineUnit(projectRoot, unit, opts = {}) {
102
102
  }
103
103
  }
104
104
 
105
+ // Ruleset-plugin versions, when the adapter tracks them (e.g. tflint).
106
+ const rulesetVersions =
107
+ typeof adapter.rulesetVersions === 'function'
108
+ ? adapter.rulesetVersions()
109
+ : undefined;
110
+
105
111
  const section = baselineStore.createLinterSection(violations, {
106
112
  toolVersion: adapter.version(),
107
113
  configHash: await configHashFor(projectRoot, unit.root, adapter),
114
+ rulesetVersions,
108
115
  status: opts.noBaseline ? baselineStore.STATUS.CLEAN : undefined,
109
116
  });
110
117
  baselineStore.setLinterSection(baseline, linterId, section);
@@ -114,6 +121,7 @@ async function baselineUnit(projectRoot, unit, opts = {}) {
114
121
  total: section.total,
115
122
  toolVersion: section.tool_version,
116
123
  configHash: section.config_hash,
124
+ rulesetVersions: section.ruleset_versions,
117
125
  });
118
126
  }
119
127
 
@@ -38,9 +38,49 @@ async function detectProjectType(projectRoot) {
38
38
  return 'unknown';
39
39
  }
40
40
 
41
+ // Config-file lookup order. The .gtl/ location is canonical — the config
42
+ // travels with the committed .gtl/ baselines and keeps gimme-the-lint's own
43
+ // files in one place. The repo-root location is retained as a fallback so
44
+ // projects created before this layout keep working with no change.
45
+ const CONFIG_CANDIDATES = [
46
+ ['.gtl', 'config.cjs'],
47
+ ['.gtl', 'config.js'],
48
+ ['gimme-the-lint.config.cjs'],
49
+ ['gimme-the-lint.config.js'],
50
+ ];
51
+
52
+ /** Absolute path of the project's gimme-the-lint config, or null if none. */
53
+ function findConfig(projectRoot) {
54
+ for (const parts of CONFIG_CANDIDATES) {
55
+ const candidate = path.join(projectRoot, ...parts);
56
+ if (fs.existsSync(candidate)) return candidate;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ /** Is the target project ESM ("type": "module" in package.json)? */
62
+ async function isESMProject(projectRoot) {
63
+ const pkgPath = path.join(projectRoot, 'package.json');
64
+ if (await fs.pathExists(pkgPath)) {
65
+ try {
66
+ return JSON.parse(await fs.readFile(pkgPath, 'utf8')).type === 'module';
67
+ } catch {
68
+ /* unreadable package.json — treat as CommonJS */
69
+ }
70
+ }
71
+ return false;
72
+ }
73
+
74
+ /**
75
+ * Where a NEW config is written — the canonical .gtl/ location. ESM projects
76
+ * get a .cjs extension so `require()` of the CommonJS config still works.
77
+ */
78
+ async function newConfigPath(projectRoot) {
79
+ const esm = await isESMProject(projectRoot);
80
+ return path.join(projectRoot, '.gtl', esm ? 'config.cjs' : 'config.js');
81
+ }
82
+
41
83
  function getConfig(projectRoot) {
42
- const cjsPath = path.join(projectRoot, 'gimme-the-lint.config.cjs');
43
- const jsPath = path.join(projectRoot, 'gimme-the-lint.config.js');
44
84
  const defaults = {
45
85
  frontendDir: 'frontend',
46
86
  backendDir: 'backend',
@@ -52,7 +92,7 @@ function getConfig(projectRoot) {
52
92
  testExcludedBackend: ['tests', '*test*', '__pycache__'],
53
93
  };
54
94
 
55
- const configPath = fs.existsSync(cjsPath) ? cjsPath : fs.existsSync(jsPath) ? jsPath : null;
95
+ const configPath = findConfig(projectRoot);
56
96
  if (configPath) {
57
97
  try {
58
98
  const userConfig = require(configPath);
@@ -66,26 +106,19 @@ function getConfig(projectRoot) {
66
106
  }
67
107
 
68
108
  async function initConfig(projectRoot, options = {}) {
69
- const cjsPath = path.join(projectRoot, 'gimme-the-lint.config.cjs');
70
- const jsPath = path.join(projectRoot, 'gimme-the-lint.config.js');
109
+ const existing = findConfig(projectRoot);
71
110
 
72
- // Don't overwrite if either variant already exists
73
- if (!options.force && (await fs.pathExists(cjsPath) || await fs.pathExists(jsPath))) {
74
- const existing = await fs.pathExists(cjsPath) ? cjsPath : jsPath;
111
+ // Don't overwrite an existing config (wherever it lives).
112
+ if (!options.force && existing) {
75
113
  return { created: false, path: existing };
76
114
  }
77
115
 
78
- // Detect if the target project uses ESM ("type": "module" in package.json)
79
- let isESM = false;
80
- const pkgPath = path.join(projectRoot, 'package.json');
81
- if (await fs.pathExists(pkgPath)) {
82
- try {
83
- const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8'));
84
- isESM = pkg.type === 'module';
85
- } catch {}
86
- }
116
+ // New config the canonical .gtl/ location. --force overwrites in place,
117
+ // so a legacy repo-root config is never silently shadowed by a .gtl/ one.
118
+ const configPath = existing && options.force
119
+ ? existing
120
+ : await newConfigPath(projectRoot);
87
121
 
88
- const configPath = isESM ? cjsPath : jsPath;
89
122
  const projectType = await detectProjectType(projectRoot);
90
123
  const config = {
91
124
  projectType,
@@ -100,36 +133,26 @@ async function initConfig(projectRoot, options = {}) {
100
133
  module.exports = ${JSON.stringify(config, null, 2)};
101
134
  `;
102
135
 
136
+ await fs.ensureDir(path.dirname(configPath));
103
137
  await fs.writeFile(configPath, content, 'utf8');
104
138
  return { created: true, path: configPath, projectType };
105
139
  }
106
140
 
107
141
  /**
108
- * Write an explicit `apps` map into gimme-the-lint.config.js so the discovered
109
- * app/linter layout is visible and editable instead of silently re-guessed on
110
- * every run. Never overwrites an existing config.
142
+ * Write an explicit `apps` map into the gimme-the-lint config so the
143
+ * discovered app/linter layout is visible and editable instead of silently
144
+ * re-guessed on every run. Never overwrites an existing config; a new one is
145
+ * written to the canonical .gtl/ location.
111
146
  * @param {string} projectRoot
112
147
  * @param {{appPath: string, linters: string[]}[]} apps
113
148
  */
114
149
  async function writeAppsConfig(projectRoot, apps) {
115
- const cjsPath = path.join(projectRoot, 'gimme-the-lint.config.cjs');
116
- const jsPath = path.join(projectRoot, 'gimme-the-lint.config.js');
117
- if ((await fs.pathExists(cjsPath)) || (await fs.pathExists(jsPath))) {
118
- return {
119
- created: false,
120
- path: (await fs.pathExists(cjsPath)) ? cjsPath : jsPath,
121
- };
122
- }
123
-
124
- let isESM = false;
125
- const pkgPath = path.join(projectRoot, 'package.json');
126
- if (await fs.pathExists(pkgPath)) {
127
- try {
128
- isESM = JSON.parse(await fs.readFile(pkgPath, 'utf8')).type === 'module';
129
- } catch {}
150
+ const existing = findConfig(projectRoot);
151
+ if (existing) {
152
+ return { created: false, path: existing };
130
153
  }
131
- const configPath = isESM ? cjsPath : jsPath;
132
154
 
155
+ const configPath = await newConfigPath(projectRoot);
133
156
  const appsObj = {};
134
157
  for (const app of apps || []) {
135
158
  appsObj[app.appPath] = { linters: app.linters };
@@ -139,6 +162,7 @@ async function writeAppsConfig(projectRoot, apps) {
139
162
  // Edit this map to correct any mis-discovered apps or linters.
140
163
  module.exports = ${JSON.stringify({ apps: appsObj }, null, 2)};
141
164
  `;
165
+ await fs.ensureDir(path.dirname(configPath));
142
166
  await fs.writeFile(configPath, content, 'utf8');
143
167
  return { created: true, path: configPath, apps: appsObj };
144
168
  }
@@ -146,6 +170,7 @@ module.exports = ${JSON.stringify({ apps: appsObj }, null, 2)};
146
170
  module.exports = {
147
171
  copyTemplate,
148
172
  detectProjectType,
173
+ findConfig,
149
174
  getConfig,
150
175
  initConfig,
151
176
  writeAppsConfig,
package/lib/drift.js CHANGED
@@ -89,6 +89,34 @@ async function detectDrift(projectRoot) {
89
89
  to: currentVersion,
90
90
  });
91
91
  }
92
+
93
+ // Ruleset-plugin drift — a plugin updated (e.g. `tflint --init` pulled
94
+ // a newer ruleset under a loose version constraint) without the config
95
+ // file text or the binary version changing. Generic over plugin names.
96
+ if (
97
+ typeof adapter.rulesetVersions === 'function' &&
98
+ rec.ruleset_versions
99
+ ) {
100
+ const current = adapter.rulesetVersions();
101
+ const names = new Set([
102
+ ...Object.keys(rec.ruleset_versions),
103
+ ...Object.keys(current),
104
+ ]);
105
+ for (const name of names) {
106
+ const from = rec.ruleset_versions[name];
107
+ const to = current[name];
108
+ if (from !== to) {
109
+ linterDrift.push({
110
+ app: app.appPath,
111
+ linter: linterId,
112
+ type: 'ruleset',
113
+ ruleset: name,
114
+ from: from || 'absent',
115
+ to: to || 'absent',
116
+ });
117
+ }
118
+ }
119
+ }
92
120
  }
93
121
  }
94
122
  }
@@ -126,6 +154,10 @@ function formatDriftReport(drift) {
126
154
  lines.push(` ~ ${d.app} · ${d.linter}: config changed`);
127
155
  } else if (d.type === 'version') {
128
156
  lines.push(` ~ ${d.app} · ${d.linter}: ${d.from} → ${d.to}`);
157
+ } else if (d.type === 'ruleset') {
158
+ lines.push(
159
+ ` ~ ${d.app} · ${d.linter}: ruleset ${d.ruleset} ${d.from} → ${d.to}`
160
+ );
129
161
  } else if (d.type === 'new-linter') {
130
162
  lines.push(` + ${d.app}: new linter ${d.linter}`);
131
163
  }
@@ -133,6 +165,12 @@ function formatDriftReport(drift) {
133
165
  if (drift.hasTimeDrift) {
134
166
  lines.push(` ! baseline is ${drift.ageDays} days old (consider refreshing)`);
135
167
  }
168
+ if (drift.linterDrift && drift.linterDrift.some((d) => d.type === 'ruleset')) {
169
+ lines.push(
170
+ ' → a ruleset plugin changed; if rules were renamed, run: ' +
171
+ 'gimme-the-lint migrate --rules'
172
+ );
173
+ }
136
174
  return lines.join('\n');
137
175
  }
138
176
 
@@ -68,12 +68,18 @@ function buildManifest(unitResults) {
68
68
  const linters = {};
69
69
  for (const section of unit.sections || []) {
70
70
  if (section.status === 'no-code') continue;
71
- linters[section.linter] = {
71
+ const entry = {
72
72
  status: section.status,
73
73
  tool_version: section.toolVersion || 'unknown',
74
74
  config_hash: section.configHash || 'unknown',
75
75
  total: section.total || 0,
76
76
  };
77
+ // Carried only for linters with ruleset plugins (e.g. tflint), so drift
78
+ // detection can see a plugin update that left config_hash untouched.
79
+ if (section.rulesetVersions) {
80
+ entry.ruleset_versions = section.rulesetVersions;
81
+ }
82
+ linters[section.linter] = entry;
77
83
  }
78
84
  manifest.apps[unit.appPath] = { linters };
79
85
  }
package/lib/index.js CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  const directoryDiscovery = require('./directory-discovery');
4
4
  const manifestManager = require('./manifest-manager');
5
- const driftDetector = require('./drift-detector');
6
5
  const venvManager = require('./venv-manager');
7
6
  const configManager = require('./config-manager');
8
7
  const gitHooksManager = require('./git-hooks-manager');
@@ -26,7 +25,6 @@ const migrate = require('./migrate');
26
25
  module.exports = {
27
26
  directoryDiscovery,
28
27
  manifestManager,
29
- driftDetector,
30
28
  venvManager,
31
29
  configManager,
32
30
  gitHooksManager,
package/lib/installer.js CHANGED
@@ -33,7 +33,11 @@ async function init(projectRoot, options = {}) {
33
33
  frontendDir: options.frontendDir,
34
34
  backendDir: options.backendDir,
35
35
  });
36
- results.steps.push(configResult.created ? `Created ${path.basename(configResult.path)}` : 'Config already exists');
36
+ results.steps.push(
37
+ configResult.created
38
+ ? `Created ${path.relative(projectRoot, configResult.path)}`
39
+ : `Config already exists (${path.relative(projectRoot, configResult.path)})`
40
+ );
37
41
 
38
42
  // Step 3: Copy ESLint template (frontend)
39
43
  if (enableFrontend) {
@@ -2,57 +2,21 @@
2
2
 
3
3
  const fs = require('fs-extra');
4
4
  const crypto = require('crypto');
5
- const path = require('path');
6
5
 
6
+ // Retained from v1 for hashFile() ONLY — its live consumer is baseline.js,
7
+ // which hashes each linter's config file to detect config drift. The v1
8
+ // manifest functions (createManifest / readManifest / writeManifest /
9
+ // calculateAge) were removed in the v2.3.0 tflint audit: the v2 global
10
+ // manifest is owned by gtl-manifest.js and drift by drift.js, which supersede
11
+ // the old single-file `.baseline-manifest.json` model entirely.
12
+
13
+ /** md5 of a file's contents, or "unknown" when the file is absent. */
7
14
  async function hashFile(filePath) {
8
- if (!await fs.pathExists(filePath)) {
15
+ if (!(await fs.pathExists(filePath))) {
9
16
  return 'unknown';
10
17
  }
11
18
  const content = await fs.readFile(filePath, 'utf8');
12
19
  return crypto.createHash('md5').update(content).digest('hex');
13
20
  }
14
21
 
15
- function calculateAge(createdAt) {
16
- const created = new Date(createdAt);
17
- const now = new Date();
18
- return Math.floor((now - created) / (1000 * 60 * 60 * 24));
19
- }
20
-
21
- async function createManifest({ tool, version, directories, violations, configPath, testExcluded }) {
22
- const configHash = await hashFile(configPath);
23
-
24
- return {
25
- created_at: new Date().toISOString(),
26
- tool,
27
- version,
28
- directories_baselined: directories,
29
- total_directories: directories.length,
30
- total_violations: violations,
31
- config_hash: configHash,
32
- test_excluded: testExcluded || [],
33
- };
34
- }
35
-
36
- async function readManifest(manifestPath) {
37
- if (!await fs.pathExists(manifestPath)) {
38
- return null;
39
- }
40
- try {
41
- return await fs.readJson(manifestPath);
42
- } catch {
43
- return null;
44
- }
45
- }
46
-
47
- async function writeManifest(manifestPath, manifest) {
48
- await fs.ensureDir(path.dirname(manifestPath));
49
- await fs.writeJson(manifestPath, manifest, { spaces: 2 });
50
- }
51
-
52
- module.exports = {
53
- hashFile,
54
- calculateAge,
55
- createManifest,
56
- readManifest,
57
- writeManifest,
58
- };
22
+ module.exports = { hashFile };
package/lib/migrate.js CHANGED
@@ -5,6 +5,11 @@ const path = require('path');
5
5
  const { runBaseline } = require('./baseline');
6
6
  const projectModel = require('./project-model');
7
7
  const configManager = require('./config-manager');
8
+ const { resolveUnits } = require('./units');
9
+ const adapters = require('./adapters');
10
+ const baselineStore = require('./baseline-store');
11
+ const { fingerprint } = require('./fingerprint');
12
+ const ruleAliases = require('./rule-aliases');
8
13
 
9
14
  // Migration from the v1 layout (.lttf/ + .lttf-ruff/ per-directory baselines)
10
15
  // to the v2 .gtl/ layout. v1 baseline files hold per-directory violation data
@@ -114,9 +119,133 @@ async function migrate(projectRoot, opts = {}) {
114
119
  };
115
120
  }
116
121
 
122
+ /**
123
+ * Reconcile one linter section's fingerprint map against a fresh lint run,
124
+ * rewriting renamed rules through the alias map.
125
+ * - a baselined violation still occurring under the same rule → kept
126
+ * - a baselined violation now occurring under a RENAMED rule → fingerprint
127
+ * rewritten old→new, count (the grandfather) preserved
128
+ * - a baselined violation no longer occurring (fixed, or its rule removed)
129
+ * → dropped
130
+ * - a genuinely new violation (occurs now, never baselined under any name)
131
+ * → NOT added,
132
+ * so it still blocks
133
+ * @returns {{fingerprints, total, renamed, dropped}}
134
+ */
135
+ function reconcileFingerprints(baselineFps, current, aliases) {
136
+ // newRuleId → [oldRuleId, …]
137
+ const reverse = {};
138
+ for (const [oldId, newId] of Object.entries(aliases)) {
139
+ if (newId) (reverse[newId] = reverse[newId] || []).push(oldId);
140
+ }
141
+
142
+ const result = {};
143
+ const renamed = [];
144
+ const claimed = new Set();
145
+
146
+ for (const v of current || []) {
147
+ const fp = fingerprint(v);
148
+ if (baselineFps[fp] != null) {
149
+ result[fp] = baselineFps[fp];
150
+ claimed.add(fp);
151
+ continue;
152
+ }
153
+ // v.ruleId may be the NEW name of a violation baselined under an OLD name.
154
+ for (const oldId of reverse[v.ruleId] || []) {
155
+ const oldFp = fingerprint({ ...v, ruleId: oldId });
156
+ if (baselineFps[oldFp] != null && !claimed.has(oldFp)) {
157
+ result[fp] = baselineFps[oldFp];
158
+ claimed.add(oldFp);
159
+ renamed.push({ from: oldId, to: v.ruleId });
160
+ break;
161
+ }
162
+ }
163
+ // Unmatched current violation → genuinely new → left out of the baseline.
164
+ }
165
+
166
+ const dropped = [];
167
+ for (const [fp, count] of Object.entries(baselineFps)) {
168
+ if (!claimed.has(fp)) dropped.push({ fingerprint: fp, count });
169
+ }
170
+
171
+ const total = Object.values(result).reduce((sum, n) => sum + n, 0);
172
+ return { fingerprints: result, total, renamed, dropped };
173
+ }
174
+
175
+ /**
176
+ * Rule-rename / rule-removal migration (`migrate --rules`). Re-lints every
177
+ * unit and rewrites its baseline fingerprints through the per-linter alias
178
+ * map. Distinct from the v1→v2 layout migration: this touches only the
179
+ * fingerprint maps inside existing .gtl baselines.
180
+ * @returns {{migrated: boolean, units: object[]}}
181
+ */
182
+ async function migrateRules(projectRoot) {
183
+ const root = projectRoot || process.cwd();
184
+ const units = resolveUnits(root);
185
+ const reports = [];
186
+
187
+ for (const unit of units) {
188
+ const baseline = await baselineStore.readBaseline(unit.baselinePath);
189
+ if (!baseline) continue;
190
+ let changed = false;
191
+ const unitReport = { app: unit.appPath, linters: [] };
192
+
193
+ for (const linterId of unit.linters) {
194
+ const aliases = ruleAliases.getAliases(linterId);
195
+ if (!aliases || Object.keys(aliases).length === 0) continue;
196
+
197
+ const section = baselineStore.getLinterSection(baseline, linterId);
198
+ if (!section || !section.fingerprints) continue;
199
+
200
+ let adapter;
201
+ try {
202
+ adapter = adapters.getAdapter(linterId, {
203
+ projectRoot: root,
204
+ appRoot: unit.root,
205
+ });
206
+ } catch {
207
+ continue;
208
+ }
209
+ if (!adapter.detect(unit.root) || !adapter.available()) continue;
210
+
211
+ let current;
212
+ try {
213
+ current = adapter.lint([unit.appPath], {});
214
+ } catch {
215
+ continue;
216
+ }
217
+ // A re-lint that surfaced its own failure (a synthetic *-error
218
+ // violation) is not trustworthy ground truth — never let it drive a
219
+ // baseline rewrite, or real grandfathered violations would be dropped.
220
+ if (current.some((v) => /-error$/.test(v.ruleId) && !v.file)) continue;
221
+
222
+ const rec = reconcileFingerprints(section.fingerprints, current, aliases);
223
+ if (rec.renamed.length === 0 && rec.dropped.length === 0) continue;
224
+
225
+ section.fingerprints = rec.fingerprints;
226
+ section.total = rec.total;
227
+ changed = true;
228
+ unitReport.linters.push({
229
+ linter: linterId,
230
+ renamed: rec.renamed,
231
+ dropped: rec.dropped.length,
232
+ });
233
+ }
234
+
235
+ if (changed) {
236
+ await baselineStore.writeBaseline(unit.baselinePath, baseline);
237
+ reports.push(unitReport);
238
+ }
239
+ }
240
+
241
+ return { migrated: reports.length > 0, units: reports };
242
+ }
243
+
117
244
  module.exports = {
118
245
  LEGACY_DIR_NAMES,
119
246
  findLegacyDirs,
120
247
  detectLegacy,
121
248
  migrate,
249
+ migrateRules,
250
+ reconcileFingerprints,
122
251
  };
@@ -69,6 +69,61 @@ function matchesAny(name, patterns) {
69
69
  return patterns.some((pattern) => globToRegExp(pattern).test(name));
70
70
  }
71
71
 
72
+ // package.json is a CONDITIONAL eslint marker — bound only when the directory
73
+ // looks like a real JS app, never on the filename alone. A devDependencies-
74
+ // only, "private": true, source-free package.json (one that merely pins a
75
+ // tooling dependency) must not be mistaken for a lintable JS application.
76
+ const JS_APP_PKG_FIELDS = ['dependencies', 'main', 'exports', 'bin', 'module', 'workspaces'];
77
+ const JS_SOURCE_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx'];
78
+ const ESLINT_BIOME_CONFIG_FILES = new Set([
79
+ 'eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs',
80
+ '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json', '.eslintrc.yml', '.eslintrc.yaml',
81
+ 'biome.json', 'biome.jsonc',
82
+ ]);
83
+
84
+ /** Recursively look for JS/TS source under a directory (bounded, skips noise). */
85
+ function hasJsSource(absDir, depth = 3) {
86
+ if (depth < 0) return false;
87
+ let entries;
88
+ try {
89
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
90
+ } catch {
91
+ return false;
92
+ }
93
+ for (const e of entries) {
94
+ if (e.isFile() && JS_SOURCE_EXTENSIONS.some((ext) => e.name.endsWith(ext))) {
95
+ return true;
96
+ }
97
+ }
98
+ for (const e of entries) {
99
+ if (!e.isDirectory()) continue;
100
+ if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
101
+ if (hasJsSource(path.join(absDir, e.name), depth - 1)) return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ /** Does this directory look like a real, lintable JS app (not just tooling)? */
107
+ function looksLikeJsApp(absDir, entries) {
108
+ // An ESLint / Biome config file is unambiguous.
109
+ for (const e of entries) {
110
+ if (e.isFile() && ESLINT_BIOME_CONFIG_FILES.has(e.name)) return true;
111
+ }
112
+ // A package.json with app-shaped fields — runtime dependencies, an entry
113
+ // point, or a workspace declaration — is a real app. devDependencies alone
114
+ // (a tooling-only manifest) is not.
115
+ try {
116
+ const pkg = JSON.parse(
117
+ fs.readFileSync(path.join(absDir, 'package.json'), 'utf8')
118
+ );
119
+ if (JS_APP_PKG_FIELDS.some((field) => pkg[field] != null)) return true;
120
+ } catch {
121
+ // Unreadable / invalid package.json — fall through to the source scan.
122
+ }
123
+ // Otherwise bind eslint only if there is actual JS/TS source under the dir.
124
+ return hasJsSource(absDir);
125
+ }
126
+
72
127
  /** Every directory (relative to root) that holds at least one known manifest. */
73
128
  function findManifestDirs(projectRoot) {
74
129
  const found = [];
@@ -84,8 +139,14 @@ function findManifestDirs(projectRoot) {
84
139
 
85
140
  const linters = new Set();
86
141
  for (const entry of entries) {
87
- if (entry.isFile() && MANIFEST_LINTERS[entry.name]) {
88
- linters.add(MANIFEST_LINTERS[entry.name]);
142
+ if (!entry.isFile()) continue;
143
+ const linter = MANIFEST_LINTERS[entry.name];
144
+ if (!linter) continue;
145
+ if (entry.name === 'package.json') {
146
+ // Conditional: only a real JS app, never a tooling-only manifest.
147
+ if (looksLikeJsApp(absDir, entries)) linters.add('eslint');
148
+ } else {
149
+ linters.add(linter);
89
150
  }
90
151
  }
91
152
  if (hasTerraformSource(entries)) linters.add('tflint');
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ // Per-linter rule-alias maps for rule-rename / rule-removal migration.
4
+ //
5
+ // fingerprint() folds ruleId into a violation's identity (deliberately — two
6
+ // different rules on the same line are distinct problems). So when an upstream
7
+ // linter RENAMES a rule, the baselined fingerprint is orphaned and the renamed
8
+ // rule blocks as a "new" violation; when a linter REMOVES a rule, a stale
9
+ // baseline entry lingers and inflates `total`.
10
+ //
11
+ // `gimme-the-lint migrate --rules` re-lints each unit and uses these maps to
12
+ // rewrite a renamed rule's stored fingerprint old→new (preserving the
13
+ // grandfather) and to drop entries for rules that no longer occur.
14
+ //
15
+ // oldRuleId: 'newRuleId' — the rule was renamed
16
+ // oldRuleId: null — the rule was removed
17
+ //
18
+ // These are DATA, not code — extend a map as an upstream linter changes its
19
+ // rule ids. Every map is empty by default: nothing is migrated until an entry
20
+ // exists, so the mechanism is inert until a real rename is recorded.
21
+
22
+ const RULE_ALIASES = {
23
+ eslint: {},
24
+ biome: {},
25
+ ruff: {},
26
+ 'golangci-lint': {},
27
+ clippy: {},
28
+ tflint: {},
29
+ 'ansible-lint': {},
30
+ };
31
+
32
+ /** The alias map for a linter ({} when the linter has none). */
33
+ function getAliases(linterId) {
34
+ return RULE_ALIASES[linterId] || {};
35
+ }
36
+
37
+ /** Register or extend a linter's alias map (used by updates and tests). */
38
+ function registerAliases(linterId, aliases) {
39
+ RULE_ALIASES[linterId] = { ...(RULE_ALIASES[linterId] || {}), ...aliases };
40
+ }
41
+
42
+ module.exports = { RULE_ALIASES, getAliases, registerAliases };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theglitchking/gimme-the-lint",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "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.",
5
5
  "keywords": [
6
6
  "linting",
@@ -1,92 +0,0 @@
1
- 'use strict';
2
-
3
- const manifestManager = require('./manifest-manager');
4
-
5
- async function detectDrift({ manifestPath, configPath, currentDirs }) {
6
- const manifest = await manifestManager.readManifest(manifestPath);
7
- if (!manifest) {
8
- return { noManifest: true, message: 'No manifest found - run baseline first' };
9
- }
10
-
11
- const drift = {
12
- hasDirectoryDrift: false,
13
- hasConfigDrift: false,
14
- hasTimeDrift: false,
15
- hasViolationDrift: false,
16
- addedDirs: [],
17
- removedDirs: [],
18
- age: 0,
19
- details: [],
20
- };
21
-
22
- const baselineDirs = manifest.directories_baselined || [];
23
- drift.addedDirs = currentDirs.filter((d) => !baselineDirs.includes(d));
24
- drift.removedDirs = baselineDirs.filter((d) => !currentDirs.includes(d));
25
- drift.hasDirectoryDrift = drift.addedDirs.length > 0 || drift.removedDirs.length > 0;
26
-
27
- if (drift.addedDirs.length > 0) {
28
- drift.details.push(`Added directories: ${drift.addedDirs.join(', ')}`);
29
- }
30
- if (drift.removedDirs.length > 0) {
31
- drift.details.push(`Removed directories: ${drift.removedDirs.join(', ')}`);
32
- }
33
-
34
- const currentHash = await manifestManager.hashFile(configPath);
35
- drift.hasConfigDrift = currentHash !== 'unknown' && currentHash !== manifest.config_hash;
36
- if (drift.hasConfigDrift) {
37
- drift.details.push('Configuration changed (config file hash mismatch)');
38
- }
39
-
40
- drift.age = manifestManager.calculateAge(manifest.created_at);
41
- drift.hasTimeDrift = drift.age > 30;
42
- if (drift.hasTimeDrift) {
43
- drift.details.push(`Baseline is ${drift.age} days old (consider refreshing)`);
44
- }
45
-
46
- return drift;
47
- }
48
-
49
- function formatDriftReport(drift) {
50
- if (drift.noManifest) {
51
- return drift.message;
52
- }
53
-
54
- const hasDrift = drift.hasDirectoryDrift || drift.hasConfigDrift || drift.hasTimeDrift;
55
- if (!hasDrift) {
56
- return null;
57
- }
58
-
59
- const lines = ['Drift Detected:'];
60
- for (const detail of drift.details) {
61
- lines.push(` - ${detail}`);
62
- }
63
- return lines.join('\n');
64
- }
65
-
66
- async function autoHeal({ manifestPath, configPath, currentDirs, tool, version, currentViolations, testExcluded }) {
67
- const oldManifest = await manifestManager.readManifest(manifestPath);
68
-
69
- const newManifest = await manifestManager.createManifest({
70
- tool,
71
- version,
72
- directories: currentDirs,
73
- violations: currentViolations,
74
- configPath,
75
- testExcluded,
76
- });
77
-
78
- await manifestManager.writeManifest(manifestPath, newManifest);
79
-
80
- return {
81
- oldDirs: oldManifest ? oldManifest.directories_baselined : [],
82
- newDirs: currentDirs,
83
- oldViolations: oldManifest ? oldManifest.total_violations : 0,
84
- newViolations: currentViolations,
85
- };
86
- }
87
-
88
- module.exports = {
89
- detectDrift,
90
- formatDriftReport,
91
- autoHeal,
92
- };