@theglitchking/gimme-the-lint 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +52 -0
- package/bin/gimme-the-lint.js +37 -1
- package/lib/adapters/adapter.js +28 -0
- package/lib/adapters/eslint.js +14 -0
- package/lib/adapters/tflint.js +140 -12
- package/lib/baseline-store.js +11 -1
- package/lib/baseline.js +8 -0
- package/lib/drift.js +38 -0
- package/lib/gtl-manifest.js +7 -1
- package/lib/index.js +0 -2
- package/lib/manifest-manager.js +10 -46
- package/lib/migrate.js +129 -0
- package/lib/project-model.js +63 -2
- package/lib/rule-aliases.js +42 -0
- package/package.json +1 -1
- package/lib/drift-detector.js +0 -92
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Official marketplace for gimme-the-lint - polyglot progressive linting with per-app baselines and drift detection",
|
|
9
|
-
"version": "2.
|
|
9
|
+
"version": "2.4.0"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "gimme-the-lint",
|
|
14
14
|
"description": "Polyglot progressive linting for monorepos — adapter-driven baselines, per-app drift detection, and idempotent skips across JavaScript/TypeScript, Python, Go, Rust, Terraform, and Ansible.",
|
|
15
|
-
"version": "2.
|
|
15
|
+
"version": "2.4.0",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TheGlitchKing"
|
|
18
18
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gimme-the-lint",
|
|
3
3
|
"description": "Polyglot progressive linting for monorepos — adapter-driven baselines, per-app drift detection, and idempotent skips across JavaScript/TypeScript, Python, Go, Rust, Terraform, and Ansible.",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.4.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TheGlitchKing",
|
|
7
7
|
"email": "theglitchking@users.noreply.github.com"
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,58 @@ 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
|
+
|
|
8
60
|
## [2.3.0] - 2026-05-17
|
|
9
61
|
|
|
10
62
|
### Fixed
|
package/bin/gimme-the-lint.js
CHANGED
|
@@ -189,9 +189,45 @@ program
|
|
|
189
189
|
.description('Migrate a v1 (.lttf) project to the v2 .gtl/ layout')
|
|
190
190
|
.option('--strict', 'Fail when a linter is missing for code that is present')
|
|
191
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)')
|
|
192
193
|
.action(async (opts) => {
|
|
193
194
|
const chalk = require('chalk');
|
|
194
|
-
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
|
+
|
|
195
231
|
try {
|
|
196
232
|
const result = await migrate(process.cwd(), { strict: opts.strict });
|
|
197
233
|
if (!result.migrated) {
|
package/lib/adapters/adapter.js
CHANGED
|
@@ -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,
|
package/lib/adapters/eslint.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('fs');
|
|
3
4
|
const path = require('path');
|
|
4
5
|
const { LinterAdapter } = require('./adapter');
|
|
5
6
|
const { createViolation, SEVERITY } = require('../violation');
|
|
@@ -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');
|
package/lib/adapters/tflint.js
CHANGED
|
@@ -7,10 +7,17 @@ const { createViolation, SEVERITY } = require('../violation');
|
|
|
7
7
|
|
|
8
8
|
// tflint adapter. tflint is the de-facto Terraform/OpenTofu linter; it emits a
|
|
9
9
|
// machine-readable report under `--format json`. Terraform has no manifest
|
|
10
|
-
// file — a directory of *.tf (or *.tofu) IS the module — so detection
|
|
10
|
+
// file — a directory of *.tf (or *.tofu) IS the module — so detection is
|
|
11
11
|
// extension-based. tflint is module-scoped: it lints the .tf files of one
|
|
12
|
-
// directory, so the adapter resolves and runs inside that directory
|
|
13
|
-
//
|
|
12
|
+
// directory, so the adapter resolves and runs inside that directory.
|
|
13
|
+
//
|
|
14
|
+
// tflint's bundled `terraform` ruleset is a universal, provider-independent
|
|
15
|
+
// baseline — it lints ANY Terraform repo (hyperscaler, on-prem, SaaS, network
|
|
16
|
+
// appliances, local/null/random) with zero config. Provider-specific rulesets
|
|
17
|
+
// (aws/google/azurerm/…) are opt-in and owned entirely by the target repo's
|
|
18
|
+
// own .tflint.hcl. This adapter never names a provider: it always runs core
|
|
19
|
+
// tflint and, when a .tflint.hcl is present, parses it GENERICALLY to drive
|
|
20
|
+
// `--init`, availability checks and ruleset-version tracking.
|
|
14
21
|
|
|
15
22
|
const TFLINT_CONFIG_FILES = ['.tflint.hcl'];
|
|
16
23
|
|
|
@@ -41,10 +48,12 @@ class TflintAdapter extends LinterAdapter {
|
|
|
41
48
|
return true;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
// tflint
|
|
45
|
-
//
|
|
51
|
+
// A .tflint.hcl is a hint, not the discovery signal — *.tf source is (see
|
|
52
|
+
// sourceExtensions / project-model.js). `.terraform.lock.hcl` is NOT listed:
|
|
53
|
+
// it is a provider-lock file written by `terraform init`, frequently absent,
|
|
54
|
+
// and no signal that tflint applies.
|
|
46
55
|
get manifestFiles() {
|
|
47
|
-
return ['.tflint.hcl'
|
|
56
|
+
return ['.tflint.hcl'];
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
get sourceExtensions() {
|
|
@@ -55,6 +64,101 @@ class TflintAdapter extends LinterAdapter {
|
|
|
55
64
|
return TFLINT_CONFIG_FILES;
|
|
56
65
|
}
|
|
57
66
|
|
|
67
|
+
// --- .tflint.hcl introspection (generic — no provider is special-cased) ---
|
|
68
|
+
|
|
69
|
+
/** True when the unit directory carries a .tflint.hcl. */
|
|
70
|
+
_hasConfig(dir) {
|
|
71
|
+
return fs.existsSync(path.join(dir || this.appRoot, '.tflint.hcl'));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Enumerate every `plugin "<name>"` block declared in a unit's .tflint.hcl. */
|
|
75
|
+
_declaredPlugins(dir) {
|
|
76
|
+
let hcl;
|
|
77
|
+
try {
|
|
78
|
+
hcl = fs.readFileSync(path.join(dir || this.appRoot, '.tflint.hcl'), 'utf8');
|
|
79
|
+
} catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
const names = [];
|
|
83
|
+
const re = /plugin\s+"([^"]+)"/g;
|
|
84
|
+
let m;
|
|
85
|
+
while ((m = re.exec(hcl)) !== null) names.push(m[1]);
|
|
86
|
+
return names;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Cached `tflint --version` output (memoized after any --init has run). */
|
|
90
|
+
_versionOutput() {
|
|
91
|
+
if (this._versionOut == null) {
|
|
92
|
+
const result = this._probe();
|
|
93
|
+
this._versionOut = `${result.stdout || ''}\n${result.stderr || ''}`;
|
|
94
|
+
this._versionProbeFailed = Boolean(result.error) || result.status !== 0;
|
|
95
|
+
}
|
|
96
|
+
return this._versionOut;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- init: fetch ruleset plugins a .tflint.hcl declares -------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* When the unit carries a .tflint.hcl, tflint requires `tflint --init` to
|
|
103
|
+
* fetch the declared ruleset plugins before linting — otherwise it exits
|
|
104
|
+
* non-zero with a plugin error. Run it once per directory (cached by the
|
|
105
|
+
* base adapter). A repo with no .tflint.hcl gets core linting, no init.
|
|
106
|
+
*/
|
|
107
|
+
initCommand(cwd) {
|
|
108
|
+
if (this._hasConfig(cwd)) {
|
|
109
|
+
return { cmd: this.binary, args: ['--init'] };
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- availability: agree with what lint() will actually see --------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* available() must agree with lint(). The binary must run AND every ruleset
|
|
118
|
+
* plugin the unit's .tflint.hcl declares must resolve. Plugins are fetched
|
|
119
|
+
* by `tflint --init`, so run it (idempotent, cached) before checking, then
|
|
120
|
+
* confirm each declared plugin appears in `tflint --version` output. The
|
|
121
|
+
* bundled `terraform` ruleset ships in the binary and always resolves.
|
|
122
|
+
*/
|
|
123
|
+
available() {
|
|
124
|
+
const probe = this._probe();
|
|
125
|
+
if (probe.error || probe.status !== 0) return false;
|
|
126
|
+
|
|
127
|
+
const declared = this._declaredPlugins(this.appRoot).filter(
|
|
128
|
+
(name) => name !== 'terraform'
|
|
129
|
+
);
|
|
130
|
+
if (declared.length === 0) return true;
|
|
131
|
+
|
|
132
|
+
this._ensureInitialized(this.appRoot);
|
|
133
|
+
const installed = this.rulesetVersions();
|
|
134
|
+
return declared.every((name) => name in installed);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- version reporting ----------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/** The tflint binary version, parsed deliberately from the version line. */
|
|
140
|
+
version() {
|
|
141
|
+
const match = this._versionOutput().match(/TFLint version (\d+\.\d+\.\d+)/i);
|
|
142
|
+
return match ? match[1] : 'unknown';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Installed ruleset-plugin versions, as a generic { name: version } map —
|
|
147
|
+
* one entry per `+ ruleset.<name> (<version>...)` line of `tflint --version`.
|
|
148
|
+
* No ruleset name is special-cased, so this tracks the bundled `terraform`
|
|
149
|
+
* ruleset and any provider ruleset a repo opts into, current or future.
|
|
150
|
+
* @returns {Object<string,string>}
|
|
151
|
+
*/
|
|
152
|
+
rulesetVersions() {
|
|
153
|
+
const versions = {};
|
|
154
|
+
for (const line of this._versionOutput().split('\n')) {
|
|
155
|
+
// "+ ruleset.terraform (0.7.0, bundled)" / "+ ruleset.aws (0.30.0)"
|
|
156
|
+
const m = line.match(/^\s*\+\s*ruleset\.(\S+)\s*\(\s*([0-9][^,)\s]*)/);
|
|
157
|
+
if (m) versions[m[1]] = m[2];
|
|
158
|
+
}
|
|
159
|
+
return versions;
|
|
160
|
+
}
|
|
161
|
+
|
|
58
162
|
buildCommand(targets, opts = {}) {
|
|
59
163
|
const args = ['--format=json'];
|
|
60
164
|
if (opts.fix && this.supportsFix) args.push('--fix');
|
|
@@ -77,14 +181,38 @@ class TflintAdapter extends LinterAdapter {
|
|
|
77
181
|
return { cmd: this.binary, args, cwd };
|
|
78
182
|
}
|
|
79
183
|
|
|
80
|
-
|
|
184
|
+
/**
|
|
185
|
+
* @param {string} stdout
|
|
186
|
+
* @param {string} stderr
|
|
187
|
+
* @param {number|null} code tflint's exit code.
|
|
188
|
+
*/
|
|
189
|
+
parse(stdout, stderr, code) {
|
|
81
190
|
const text = (stdout || '').trim();
|
|
82
|
-
|
|
191
|
+
let report = null;
|
|
192
|
+
if (text) {
|
|
193
|
+
try {
|
|
194
|
+
report = JSON.parse(text);
|
|
195
|
+
} catch {
|
|
196
|
+
report = null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
83
199
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
200
|
+
// A non-zero exit with no parseable JSON is a hard failure — an
|
|
201
|
+
// uninitialized ruleset plugin, a broken config, an unusable binary. It
|
|
202
|
+
// must be LOUD: emit a synthetic high-severity violation carrying the
|
|
203
|
+
// stderr text, never let a failed run be recorded as a clean baseline.
|
|
204
|
+
if (!report) {
|
|
205
|
+
if (code != null && code !== 0) {
|
|
206
|
+
return [
|
|
207
|
+
createViolation({
|
|
208
|
+
file: '',
|
|
209
|
+
ruleId: 'tflint-error',
|
|
210
|
+
severity: SEVERITY.ERROR,
|
|
211
|
+
message: `tflint exited ${code}: ${(stderr || '').trim() || 'no output'}`,
|
|
212
|
+
source: 'tflint',
|
|
213
|
+
}),
|
|
214
|
+
];
|
|
215
|
+
}
|
|
88
216
|
return [];
|
|
89
217
|
}
|
|
90
218
|
|
package/lib/baseline-store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
package/lib/gtl-manifest.js
CHANGED
|
@@ -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
|
-
|
|
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/manifest-manager.js
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/lib/project-model.js
CHANGED
|
@@ -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()
|
|
88
|
-
|
|
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
|
+
"version": "2.4.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",
|
package/lib/drift-detector.js
DELETED
|
@@ -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
|
-
};
|