@theglitchking/gimme-the-lint 2.4.0 → 2.5.1

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.4.0"
9
+ "version": "2.5.1"
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.4.0",
15
+ "version": "2.5.1",
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.0",
4
+ "version": "2.5.1",
5
5
  "author": {
6
6
  "name": "TheGlitchKing",
7
7
  "email": "theglitchking@users.noreply.github.com"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ 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.1] - 2026-05-17
9
+
10
+ ### Fixed
11
+ - **tflint config resolution is now root-aware.** In the standard Terraform
12
+ monorepo — one repo-root `.tflint.hcl`, many nested `modules/*` / `envs/*`
13
+ units — the adapter resolved config only in the unit directory, so every
14
+ unit was linted with tflint defaults: the repo's preset, plugin declarations
15
+ and `rule { enabled = false }` overrides were silently ignored and
16
+ `tflint --init` never ran. Meanwhile `configHashFor()` *did* find the
17
+ repo-root config, so the baseline's `config_hash` reflected a config the
18
+ linter never actually used — a silent correctness bug. A new shared resolver,
19
+ `LinterAdapter.resolveConfigPath()`, walks up from the unit directory to the
20
+ project root; `buildCommand()` and `initCommand()` now pass the resolved
21
+ file as an absolute `--config`, and `configHashFor()` hashes that same file —
22
+ the hashed config and the linted config can no longer disagree. A unit with
23
+ its own `.tflint.hcl` still wins (nearest-first); a repo with no config
24
+ anywhere still runs the zero-config core ruleset. The resolver is generic
25
+ (no provider name in code) and root-aware config-hashing now benefits every
26
+ adapter.
27
+
28
+ ## [2.5.0] - 2026-05-17
29
+
30
+ ### Added
31
+ - **`.gtl/config.js` — consolidated config location.** gimme-the-lint's own
32
+ config file now lives canonically at `.gtl/config.js` (`.gtl/config.cjs` for
33
+ ESM projects), so it travels with the committed `.gtl/` baselines rather than
34
+ sitting loose at the repo root. `install` and `migrate` write new configs
35
+ there; `config-manager.findConfig()` resolves the location. A repo-root
36
+ `gimme-the-lint.config.js` is still read as a fallback — existing projects
37
+ need no change — and `.gtl/` wins when both exist. `uninstall` removes a
38
+ repo-root config but leaves a `.gtl/` one in place (it is part of the
39
+ preserved `.gtl/` directory). Linter configs (`eslint.config.js`,
40
+ `.tflint.hcl`, …) are unaffected: each linter resolves its own config from a
41
+ fixed location gimme-the-lint does not own.
42
+
8
43
  ## [2.4.0] - 2026-05-17
9
44
 
10
45
  ### 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'));
@@ -372,7 +380,7 @@ program
372
380
  const projectType = await configManager.detectProjectType(projectRoot);
373
381
  const venvStatus = venvManager.getStatus(projectRoot);
374
382
  const hookStatus = await gitHooksManager.getStatus(projectRoot);
375
- const configExists = fs.existsSync(path.join(projectRoot, 'gimme-the-lint.config.js'));
383
+ const configExists = Boolean(configManager.findConfig(projectRoot));
376
384
 
377
385
  console.log(chalk.blue('\ngimme-the-lint Status\n'));
378
386
  console.log(` Project type: ${projectType}`);
@@ -65,6 +65,38 @@ class LinterAdapter {
65
65
  return [];
66
66
  }
67
67
 
68
+ /**
69
+ * Resolve the EFFECTIVE config file for the unit being linted: walk up from
70
+ * `startDir` (default: appRoot) to projectRoot inclusive and return the
71
+ * absolute path of the nearest file named by configFiles(), or null if none
72
+ * exists anywhere on that path.
73
+ *
74
+ * Root-aware — this is what lets a single repo-root config (the standard
75
+ * monorepo layout: one config, many nested units) govern every unit. It is
76
+ * the SINGLE source of truth shared by the config-hash (baseline.js) and the
77
+ * linter invocation, so the hashed config and the linted config can never
78
+ * disagree.
79
+ * @param {string} [startDir]
80
+ * @returns {string|null}
81
+ */
82
+ resolveConfigPath(startDir) {
83
+ const names = this.configFiles();
84
+ if (!names || names.length === 0) return null;
85
+ const root = path.resolve(this.projectRoot);
86
+ let dir = path.resolve(startDir || this.appRoot);
87
+ while (true) {
88
+ for (const name of names) {
89
+ const candidate = path.join(dir, name);
90
+ if (fs.existsSync(candidate)) return candidate;
91
+ }
92
+ if (dir === root) break; // checked projectRoot — stop
93
+ const parent = path.dirname(dir);
94
+ if (parent === dir) break; // filesystem root reached
95
+ dir = parent;
96
+ }
97
+ return null;
98
+ }
99
+
68
100
  /** Return the first candidate path that exists, else the last candidate. */
69
101
  resolveBinary(candidates) {
70
102
  for (const candidate of candidates) {
@@ -66,16 +66,22 @@ class TflintAdapter extends LinterAdapter {
66
66
 
67
67
  // --- .tflint.hcl introspection (generic — no provider is special-cased) ---
68
68
 
69
- /** True when the unit directory carries a .tflint.hcl. */
69
+ /** True when an effective .tflint.hcl exists (the unit dir or up to root). */
70
70
  _hasConfig(dir) {
71
- return fs.existsSync(path.join(dir || this.appRoot, '.tflint.hcl'));
71
+ return this.resolveConfigPath(dir) !== null;
72
72
  }
73
73
 
74
- /** Enumerate every `plugin "<name>"` block declared in a unit's .tflint.hcl. */
74
+ /**
75
+ * Enumerate every `plugin "<name>"` block in the EFFECTIVE .tflint.hcl —
76
+ * the one resolveConfigPath() resolves, which may live up at the repo root,
77
+ * not in the unit dir.
78
+ */
75
79
  _declaredPlugins(dir) {
80
+ const configPath = this.resolveConfigPath(dir);
81
+ if (!configPath) return [];
76
82
  let hcl;
77
83
  try {
78
- hcl = fs.readFileSync(path.join(dir || this.appRoot, '.tflint.hcl'), 'utf8');
84
+ hcl = fs.readFileSync(configPath, 'utf8');
79
85
  } catch {
80
86
  return [];
81
87
  }
@@ -105,10 +111,12 @@ class TflintAdapter extends LinterAdapter {
105
111
  * base adapter). A repo with no .tflint.hcl gets core linting, no init.
106
112
  */
107
113
  initCommand(cwd) {
108
- if (this._hasConfig(cwd)) {
109
- return { cmd: this.binary, args: ['--init'] };
110
- }
111
- return null;
114
+ const configPath = this.resolveConfigPath(cwd);
115
+ if (!configPath) return null;
116
+ // Pass the resolved config explicitly so `--init` reads the right
117
+ // .tflint.hcl for its plugin declarations — even when it lives up at the
118
+ // repo root rather than in the unit dir.
119
+ return { cmd: this.binary, args: ['--init', `--config=${configPath}`] };
112
120
  }
113
121
 
114
122
  // --- availability: agree with what lint() will actually see --------------
@@ -178,6 +186,14 @@ class TflintAdapter extends LinterAdapter {
178
186
  // Unresolvable target — fall back to the default cwd.
179
187
  }
180
188
  }
189
+
190
+ // tflint runs IN the unit dir; pass the effective config — which may live
191
+ // up at the repo root — as an absolute --config so the unit is linted with
192
+ // the repo's preset / plugin declarations / rule overrides, not tflint
193
+ // defaults. No config anywhere → plain tflint (zero-config core ruleset).
194
+ const configPath = this.resolveConfigPath(cwd);
195
+ if (configPath) args.push(`--config=${configPath}`);
196
+
181
197
  return { cmd: this.binary, args, cwd };
182
198
  }
183
199
 
package/lib/baseline.js CHANGED
@@ -18,15 +18,17 @@ const gtlManifest = require('./gtl-manifest');
18
18
  // the baseline CLI, the hooks installer) can refuse to gate commits against a
19
19
  // baseline that never actually captured a linter.
20
20
 
21
- /** Hash the first config file found for an adapter (unit root, then project). */
21
+ /**
22
+ * Hash the EFFECTIVE config file for an adapter. Resolution walks up from the
23
+ * unit dir to the project root via the adapter's shared resolver, so the
24
+ * hashed config is exactly the one the linter is invoked with — a repo-root
25
+ * config governing nested units is hashed correctly, never missed or faked.
26
+ */
27
+ // eslint-disable-next-line no-unused-vars
22
28
  async function configHashFor(projectRoot, unitRoot, adapter) {
23
- for (const name of adapter.configFiles()) {
24
- for (const base of [unitRoot, projectRoot]) {
25
- const hash = await manifestManager.hashFile(path.join(base, name));
26
- if (hash !== 'unknown') return hash;
27
- }
28
- }
29
- return 'unknown';
29
+ const configPath = adapter.resolveConfigPath(unitRoot);
30
+ if (!configPath) return 'unknown';
31
+ return manifestManager.hashFile(configPath);
30
32
  }
31
33
 
32
34
  /** Baseline a single unit across all its bound linters. */
@@ -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/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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theglitchking/gimme-the-lint",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
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",