@theglitchking/gimme-the-lint 2.1.0 → 2.3.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.1.0"
9
+ "version": "2.3.0"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "gimme-the-lint",
14
- "description": "Polyglot progressive linting for monorepos — adapter-driven baselines, per-app drift detection, and idempotent skips across JavaScript/TypeScript, Python, Go, Rust, and Terraform.",
15
- "version": "2.1.0",
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",
16
16
  "author": {
17
17
  "name": "TheGlitchKing"
18
18
  },
@@ -32,6 +32,7 @@
32
32
  "clippy",
33
33
  "tflint",
34
34
  "terraform",
35
+ "ansible-lint",
35
36
  "claude-code"
36
37
  ],
37
38
  "category": "productivity",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gimme-the-lint",
3
- "description": "Polyglot progressive linting for monorepos — adapter-driven baselines, per-app drift detection, and idempotent skips across JavaScript/TypeScript, Python, Go, Rust, and Terraform.",
4
- "version": "2.1.0",
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",
5
5
  "author": {
6
6
  "name": "TheGlitchKing",
7
7
  "email": "theglitchking@users.noreply.github.com"
@@ -21,6 +21,7 @@
21
21
  "golangci-lint",
22
22
  "clippy",
23
23
  "tflint",
24
- "terraform"
24
+ "terraform",
25
+ "ansible-lint"
25
26
  ]
26
27
  }
package/CHANGELOG.md CHANGED
@@ -5,6 +5,59 @@ 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.3.0] - 2026-05-17
9
+
10
+ ### Fixed
11
+ - **Bug A — monorepo linter binary resolution.** The ESLint and Biome adapters
12
+ resolved `node_modules/.bin/<linter>` only at the repo root, so in a monorepo
13
+ (where each JS app carries its own `node_modules`) `available()` returned
14
+ false and the linter was silently skipped — capturing an empty baseline.
15
+ Adapters now resolve the binary app-dir-first (app → repo root → PATH) and
16
+ run inside the app directory so the app's own flat config is discovered. The
17
+ Ruff adapter resolves its `.venv` the same way.
18
+ - **Bug B — discovery bound the wrong directories.** Manifest discovery treated
19
+ a bare `requirements.txt` as a ruff app marker (binding nested load-test and
20
+ utility dirs) and discarded a repo-root config whenever any nested manifest
21
+ existed (leaving the real app unbound). `requirements.txt` is no longer a
22
+ discovery marker — `pyproject.toml`, `ruff.toml`, `.ruff.toml` and `setup.py`
23
+ are — and workspace-root detection is now **per linter**, so a repo-root
24
+ `[tool.ruff]` config binds the root even when nested `package.json` apps sit
25
+ below it.
26
+ - **Bug C — "couldn't run" collapsed into "skipped".** An unavailable or
27
+ errored linter was recorded with the status used for "no code here", making
28
+ an incomplete baseline indistinguishable from a clean one — every
29
+ pre-existing violation later counted as new and blocked the commit. New
30
+ baseline statuses `unavailable` and `error` keep incomplete baselines
31
+ distinct; `migrate` and `baseline` print a prominent summary and exit
32
+ non-zero (override with `--allow-incomplete`); `hooks` refuses to install
33
+ against an incomplete baseline (override with `--force`); `check` reports
34
+ `needs-baseline` instead of flooding new violations.
35
+
36
+ ### Added
37
+ - `migrate` now writes the discovered app/linter layout into
38
+ `gimme-the-lint.config.js` as an explicit `apps` map, so the guess is visible
39
+ and editable instead of silently re-derived on every run. It also emits a
40
+ warning when the layout is ambiguous (a repo-root config plus nested apps).
41
+
42
+ ## [2.2.0] - 2026-05-17
43
+
44
+ ### Added
45
+ - **Ansible support** via a new `ansible-lint` adapter. ansible-lint plugs into
46
+ the progressive-lint engine like every other linter: it runs
47
+ `ansible-lint -f codeclimate`, parses the CodeClimate JSON report, and only
48
+ new violations block. Supports `--fix`.
49
+ - **Manifest-based discovery** for Ansible: a directory containing `ansible.cfg`
50
+ or `galaxy.yml` is auto-discovered and bound to `ansible-lint`. Ansible
51
+ playbooks are plain YAML with no manifest, so detection keys off those
52
+ unambiguous markers rather than a file extension (a YAML scan would match
53
+ nearly every repo). An Ansible repo with neither marker needs an explicit
54
+ `apps` entry in `gimme-the-lint.config.js`.
55
+ - **Ansible best-practice config** — `install` seeds `.ansible-lint` with the
56
+ `moderate` profile (the strictness lever; raise to `safety` / `production`).
57
+ - README now documents every supported codebase with a default-rules summary
58
+ and the strictness lever for each; `.documentation/lint-rules-guide.md` adds
59
+ a full Ansible section.
60
+
8
61
  ## [2.1.0] - 2026-05-17
9
62
 
10
63
  ### Added
package/README.md CHANGED
@@ -19,7 +19,7 @@ stuff at its own pace; meanwhile no new mess gets in.
19
19
  **v2.0** generalizes that idea to any linter and any language. It is no longer
20
20
  "ESLint + Ruff for webapps" — it is a progressive-lint **engine** with a
21
21
  pluggable **linter adapter** for each tool, across **JavaScript/TypeScript, Python,
22
- Go, Rust, and Terraform**, in **any monorepo shape**.
22
+ Go, Rust, Terraform, and Ansible**, in **any monorepo shape**.
23
23
 
24
24
  ---
25
25
 
@@ -38,10 +38,10 @@ is ever flagged. (In v1 this job was outsourced to the third-party
38
38
 
39
39
  Each app is bound to the linters its package manifest implies — `package.json`
40
40
  → ESLint, `pyproject.toml` → Ruff, `go.mod` → golangci-lint, `Cargo.toml` →
41
- Clippy, `biome.json` → Biome. Terraform has no manifest, so a directory of
42
- `*.tf` / `*.tofu` files binds to tflint by extension. Drift detection runs per
43
- app, so a config or linter-version change in one app never churns the baselines
44
- of another.
41
+ Clippy, `biome.json` → Biome, `ansible.cfg` / `galaxy.yml` ansible-lint.
42
+ Terraform has no manifest, so a directory of `*.tf` / `*.tofu` files binds to
43
+ tflint by extension. Drift detection runs per app, so a config or linter-version
44
+ change in one app never churns the baselines of another.
45
45
 
46
46
  ---
47
47
 
@@ -50,7 +50,7 @@ of another.
50
50
  - **Progressive linting** — only new violations block; existing ones are baselined
51
51
  - **In-house diff engine** — line/column-independent fingerprints survive code shifts
52
52
  - **Pluggable linter adapters** — the choice of linter is config, not a hardcode
53
- - **Polyglot** — JavaScript/TypeScript, Python, Go, Rust, Terraform out of the box
53
+ - **Polyglot** — JavaScript/TypeScript, Python, Go, Rust, Terraform, Ansible out of the box
54
54
  - **Per-app model** — auto-discovers every package in a monorepo; no `frontend/`
55
55
  + `backend/` assumption
56
56
  - **Per-app drift detection** — app add/remove, config change, linter version, age
@@ -75,21 +75,37 @@ of another.
75
75
  | Go | `golangci-lint` | `go.mod` |
76
76
  | Rust | `clippy` (`cargo clippy`) | `Cargo.toml` |
77
77
  | Terraform / OpenTofu | `tflint` | `*.tf` / `*.tofu` files (no manifest) |
78
+ | Ansible | `ansible-lint` | `ansible.cfg`, `galaxy.yml` |
78
79
 
79
80
  ## Shipped lint configs
80
81
 
81
82
  `install` seeds every discovered app with a best-practice ("recommended" tier)
82
- config for its linter — `eslint.config.js` + `.prettierrc.json`, `biome.json`,
83
- `pyproject.toml` `[tool.ruff]`, `.golangci.yml`, `clippy.toml` + `Cargo.toml`
84
- `[lints.clippy]`, `.tflint.hcl` plus a repo-root `.gitleaks.toml`. Configs are
85
- **created only if absent**; your own config is never overwritten.
86
-
87
- Every shipped config carries a **security layer**: gitleaks scans all files for
88
- secrets (passwords, SSL/private keys, tokens) and always blocks, while each
89
- linter adds language-specific security rules (`gosec`, Ruff `S` / flake8-bandit,
90
- `eslint-plugin-security`, Biome's `security` group). See
91
- [`.documentation/lint-rules-guide.md`](.documentation/lint-rules-guide.md) for
92
- the baseline rules per codebase and how to adjust them.
83
+ config for its linter — **created only if absent**, so your own config is never
84
+ overwritten. Each ships a sensible default rule set and a single **lever** to
85
+ dial strictness up or down:
86
+
87
+ | Codebase | Linter | Default rules (recommended tier) | Strictness lever |
88
+ |----------|--------|----------------------------------|------------------|
89
+ | JS/TS | ESLint | `@eslint/js` + React recommended, import-architecture guards, security plugins, Prettier-compatible | rules block in `eslint.config.js` |
90
+ | JS/TS | Biome | recommended set + full `security` group + console/complexity rules | rule levels in `biome.json` |
91
+ | Python | Ruff | pyflakes / pycodestyle / isort / bugbear / pyupgrade + `S` security + comprehensions / simplify | `select` / `ignore` in `pyproject.toml` |
92
+ | Go | golangci-lint | `standard` set + correctness & quality linters + `gosec` | `linters.enable` in `.golangci.yml` |
93
+ | Rust | Clippy | `pedantic` + `cargo` at `warn`, noisy lints allowed back | `[lints.clippy]` levels in `Cargo.toml` |
94
+ | Terraform | tflint | bundled `terraform` ruleset, `recommended` preset | `preset` in `.tflint.hcl` (`recommended` → `all`) |
95
+ | Ansible | ansible-lint | `moderate` profile | `profile` in `.ansible-lint` (`min` → `production`) |
96
+ | Secrets (all) | gitleaks | default ruleset + key / password rules — **always blocks** | `[allowlist]` in `.gitleaks.toml` |
97
+
98
+ Every shipped config carries a **security layer**. gitleaks scans every file in
99
+ every codebase for secrets (passwords, SSL/private keys, tokens) and always
100
+ blocks — secrets are never baselined. Each linter adds language-specific
101
+ security rules on top (`gosec`, Ruff `S` / flake8-bandit, `eslint-plugin-security`,
102
+ Biome's `security` group), which follow normal progressive baselining.
103
+
104
+ To go stricter, pull the lever in the table above — because violations are
105
+ progressively baselined, raising strictness never blocks existing code, only new
106
+ code is held to the higher bar. Full per-codebase detail — every default rule
107
+ and how to adjust it — is in
108
+ [`.documentation/lint-rules-guide.md`](.documentation/lint-rules-guide.md).
93
109
 
94
110
  ---
95
111
 
@@ -284,7 +300,8 @@ lib/
284
300
  ├── diff-engine.js pure diff: new vs baselined vs fixed
285
301
  ├── baseline-store.js one baseline.json format for every linter
286
302
  ├── adapters/ one adapter per linter (eslint, biome, ruff,
287
- │ golangci-lint, clippy, tflint) + the base contract
303
+ │ golangci-lint, clippy, tflint, ansible-lint)
304
+ │ + the base contract
288
305
  ├── project-model.js discovers apps + binds them to linters
289
306
  ├── units.js resolves apps → {dir, linters, baseline path}
290
307
  ├── check.js runCheck: lint → diff → report
@@ -304,7 +321,8 @@ git hooks, GitHub Action and Claude Code plugin are thin front doors over it.
304
321
  - **Node.js** >= 20
305
322
  - **Git** (for hooks and staged-file detection)
306
323
  - A linter for each language you use (`eslint`/`biome`, `ruff`, `golangci-lint`,
307
- `clippy`, `tflint`) — any language whose linter is absent is simply skipped
324
+ `clippy`, `tflint`, `ansible-lint`) — any language whose linter is absent is
325
+ simply skipped
308
326
 
309
327
  ## License
310
328
 
@@ -154,6 +154,7 @@ program
154
154
  .description('Create or refresh progressive-lint baselines')
155
155
  .option('--strict', 'Fail when a linter is missing for code that is present')
156
156
  .option('--empty', 'Write empty baselines (greenfield: treat every violation as new)')
157
+ .option('--allow-incomplete', 'Exit 0 even if some linters could not be baselined')
157
158
  .action(async (target, opts) => {
158
159
  const chalk = require('chalk');
159
160
  const { runBaseline } = require('../lib/baseline');
@@ -166,6 +167,17 @@ program
166
167
  });
167
168
  console.log(formatBaselineReport(report, process.cwd()));
168
169
  console.log('');
170
+ // A baseline that could not capture a linter is incomplete — fail loudly
171
+ // rather than reporting success, unless the user opts in.
172
+ if (report.incomplete && report.incomplete.length && !opts.allowIncomplete) {
173
+ console.error(
174
+ chalk.red(
175
+ `✗ Baseline INCOMPLETE — ${report.incomplete.length} linter(s) not captured. ` +
176
+ 'Install them and re-run, or pass --allow-incomplete.\n'
177
+ )
178
+ );
179
+ process.exit(1);
180
+ }
169
181
  } catch (err) {
170
182
  console.error(chalk.red(`\n✗ ${err.message}\n`));
171
183
  process.exit(1);
@@ -176,6 +188,7 @@ program
176
188
  .command('migrate')
177
189
  .description('Migrate a v1 (.lttf) project to the v2 .gtl/ layout')
178
190
  .option('--strict', 'Fail when a linter is missing for code that is present')
191
+ .option('--allow-incomplete', 'Exit 0 even if some linters could not be baselined')
179
192
  .action(async (opts) => {
180
193
  const chalk = require('chalk');
181
194
  const { migrate } = require('../lib/migrate');
@@ -188,9 +201,46 @@ program
188
201
  console.log(chalk.green('\n✓ Migrated to the v2 .gtl/ layout\n'));
189
202
  console.log(` Legacy baselines backed up: ${result.backedUp.join(', ')}`);
190
203
  console.log(chalk.dim(` → ${result.backupPath}`));
204
+ if (result.appsConfig && result.appsConfig.created) {
205
+ console.log(
206
+ ` Wrote explicit app map → ${path.basename(result.appsConfig.path)}`
207
+ );
208
+ }
191
209
  console.log(` Re-baselined ${result.baseline.unitCount} app(s) into .gtl/`);
210
+ for (const w of result.discoveryWarnings || []) {
211
+ console.log(chalk.yellow(` ⚠ ${w}`));
212
+ }
192
213
  console.log('');
193
- console.log('Next: review the new .gtl/ directory and commit it.');
214
+
215
+ // An incomplete baseline must never be reported as a clean migration.
216
+ if (result.incomplete && result.incomplete.length) {
217
+ console.error(
218
+ chalk.red(`✗ ${result.incomplete.length} linter(s) were NOT baselined:`)
219
+ );
220
+ for (const i of result.incomplete) {
221
+ console.error(chalk.red(` ${i.app} · ${i.linter} (${i.status})`));
222
+ }
223
+ console.error(
224
+ chalk.yellow(
225
+ ' The baseline is INCOMPLETE — install the missing linters and run\n' +
226
+ ' `gimme-the-lint baseline`, then commit .gtl/.'
227
+ )
228
+ );
229
+ if (!opts.allowIncomplete) {
230
+ console.error(
231
+ chalk.red(
232
+ '\n✗ Migration finished with an incomplete baseline ' +
233
+ '(pass --allow-incomplete to accept).\n'
234
+ )
235
+ );
236
+ process.exit(1);
237
+ }
238
+ console.log('');
239
+ }
240
+
241
+ console.log(
242
+ 'Next: review the new .gtl/ directory and gimme-the-lint.config.js, then commit them.'
243
+ );
194
244
  console.log('');
195
245
  } catch (err) {
196
246
  console.error(chalk.red(`\n✗ Migration failed: ${err.message}\n`));
@@ -210,13 +260,43 @@ program
210
260
  program
211
261
  .command('hooks')
212
262
  .description('Install git hooks for pre-commit linting')
213
- .action(async () => {
263
+ .option('--force', 'Install even when the baseline is incomplete')
264
+ .action(async (opts) => {
214
265
  const chalk = require('chalk');
215
266
  const gitHooksManager = require('../lib/git-hooks-manager');
267
+ const { findIncompleteBaselines } = require('../lib/baseline');
216
268
 
217
269
  try {
270
+ // Refuse to gate commits against an incomplete baseline: hooks would
271
+ // diff against linter sections that were never captured, so every
272
+ // pre-existing violation would count as new and block the commit.
273
+ const incomplete = await findIncompleteBaselines(process.cwd());
274
+ if (incomplete.length && !opts.force) {
275
+ console.error(
276
+ chalk.red('\n✗ Refusing to install hooks — the baseline is incomplete:\n')
277
+ );
278
+ for (const i of incomplete) {
279
+ console.error(chalk.red(` ${i.app} · ${i.linter} (${i.status})`));
280
+ }
281
+ console.error(
282
+ chalk.yellow(
283
+ '\n These linters were never baselined, so a pre-commit hook would\n' +
284
+ ' flag every pre-existing violation as new. Install the missing\n' +
285
+ ' linters and run `gimme-the-lint baseline`, or pass --force.\n'
286
+ )
287
+ );
288
+ process.exit(1);
289
+ }
290
+
218
291
  const installed = await gitHooksManager.installHooks(process.cwd());
219
292
  console.log(chalk.green(`\n✓ Installed git hooks: ${installed.join(', ')}\n`));
293
+ if (incomplete.length && opts.force) {
294
+ console.log(
295
+ chalk.yellow(
296
+ '⚠ Installed against an INCOMPLETE baseline (--force) — re-baseline soon.\n'
297
+ )
298
+ );
299
+ }
220
300
  } catch (e) {
221
301
  console.error(chalk.red(`\n✗ ${e.message}\n`));
222
302
  process.exit(1);
@@ -26,8 +26,14 @@ const DEFAULT_IGNORE_DIRS = new Set([
26
26
  const SCAN_DEPTH = 5;
27
27
 
28
28
  class LinterAdapter {
29
- constructor({ projectRoot } = {}) {
29
+ constructor({ projectRoot, appRoot } = {}) {
30
30
  this.projectRoot = projectRoot || process.cwd();
31
+ // appRoot is the directory of the specific app/unit being linted. In a
32
+ // monorepo each app carries its own node_modules / .venv, so project-local
33
+ // tool resolution and config discovery must start here — not at the repo
34
+ // root, which often has neither. Defaults to projectRoot for single-package
35
+ // projects and for callers that do not supply it.
36
+ this.appRoot = appRoot || this.projectRoot;
31
37
  }
32
38
 
33
39
  // --- identity (override in subclasses) ---
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ const { LinterAdapter } = require('./adapter');
4
+ const { createViolation, SEVERITY } = require('../violation');
5
+
6
+ // ansible-lint adapter. ansible-lint is the de-facto linter for Ansible
7
+ // playbooks, roles and collections; `-f codeclimate` emits a machine-readable
8
+ // JSON report. Ansible has no manifest file and playbooks are plain YAML, so
9
+ // detection keys off the unambiguous Ansible markers `ansible.cfg` and
10
+ // `galaxy.yml` — a YAML extension scan would match virtually every repo.
11
+ // ansible-lint auto-discovers playbooks/roles from the directory it runs in.
12
+
13
+ const ANSIBLE_CONFIG_FILES = ['.ansible-lint', '.config/ansible-lint.yml'];
14
+
15
+ /** Map an ansible-lint (CodeClimate) severity onto a NormalizedViolation severity. */
16
+ function mapSeverity(severity) {
17
+ switch (String(severity).toLowerCase()) {
18
+ case 'info':
19
+ return SEVERITY.INFO;
20
+ case 'minor':
21
+ return SEVERITY.WARNING;
22
+ case 'major':
23
+ case 'critical':
24
+ case 'blocker':
25
+ return SEVERITY.ERROR;
26
+ default:
27
+ return SEVERITY.WARNING;
28
+ }
29
+ }
30
+
31
+ class AnsibleLintAdapter extends LinterAdapter {
32
+ get id() {
33
+ return 'ansible-lint';
34
+ }
35
+
36
+ get languages() {
37
+ return ['ansible'];
38
+ }
39
+
40
+ get supportsFix() {
41
+ return true;
42
+ }
43
+
44
+ get manifestFiles() {
45
+ return ['ansible.cfg', 'galaxy.yml'];
46
+ }
47
+
48
+ // Ansible source is plain YAML — far too broad to scan by extension, so
49
+ // detection is manifest-only (see manifestFiles).
50
+ get sourceExtensions() {
51
+ return [];
52
+ }
53
+
54
+ configFiles() {
55
+ return ANSIBLE_CONFIG_FILES;
56
+ }
57
+
58
+ buildCommand(targets, opts = {}) {
59
+ const args = ['-f', 'codeclimate'];
60
+ if (opts.fix && this.supportsFix) args.push('--fix');
61
+ const paths = targets && targets.length ? targets.slice() : ['.'];
62
+ args.push(...paths);
63
+ return { cmd: this.binary, args, cwd: opts.cwd || this.projectRoot };
64
+ }
65
+
66
+ parse(stdout) {
67
+ const text = (stdout || '').trim();
68
+ if (!text) return [];
69
+
70
+ let report;
71
+ try {
72
+ report = JSON.parse(text);
73
+ } catch {
74
+ return [];
75
+ }
76
+ if (!Array.isArray(report)) return [];
77
+
78
+ return report.map((issue) => {
79
+ const loc = issue.location || {};
80
+ const lines = loc.lines || {};
81
+ const begin = (loc.positions && loc.positions.begin) || {};
82
+ // CodeClimate locations come as either { lines: { begin } } or
83
+ // { positions: { begin: { line, column } } }.
84
+ let line = lines.begin != null ? lines.begin : begin.line;
85
+ if (line && typeof line === 'object') line = line.line;
86
+ return createViolation({
87
+ file: this._relativize(loc.path || ''),
88
+ line: Number(line) || 0,
89
+ col: Number(begin.column) || 0,
90
+ ruleId: issue.check_name || 'ansible-lint',
91
+ severity: mapSeverity(issue.severity),
92
+ message: issue.description || '',
93
+ source: 'ansible-lint',
94
+ });
95
+ });
96
+ }
97
+ }
98
+
99
+ module.exports = AnsibleLintAdapter;
@@ -38,9 +38,14 @@ class BiomeAdapter extends LinterAdapter {
38
38
  return ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx'];
39
39
  }
40
40
 
41
- /** Prefer the project-local Biome; fall back to one on PATH. */
41
+ /**
42
+ * Prefer the app-local Biome, then the repo-root Biome, then PATH. Each JS
43
+ * app in a monorepo has its own node_modules; resolving only at projectRoot
44
+ * makes available() falsely return false for app-installed Biome.
45
+ */
42
46
  get binary() {
43
47
  return this.resolveBinary([
48
+ path.join(this.appRoot, 'node_modules', '.bin', 'biome'),
44
49
  path.join(this.projectRoot, 'node_modules', '.bin', 'biome'),
45
50
  'biome',
46
51
  ]);
@@ -52,9 +57,14 @@ class BiomeAdapter extends LinterAdapter {
52
57
 
53
58
  buildCommand(targets, opts = {}) {
54
59
  const args = ['lint', '--reporter=json'];
55
- const list = targets && targets.length ? targets : ['.'];
60
+ // Run inside the app directory so Biome resolves the app's own biome.json.
61
+ const cwd = opts.cwd || this.appRoot;
62
+ const list = (targets && targets.length ? targets : ['.']).map((t) => {
63
+ const rel = path.relative(cwd, path.resolve(this.projectRoot, t));
64
+ return rel === '' ? '.' : rel;
65
+ });
56
66
  args.push(...list);
57
- return { cmd: this.binary, args, cwd: opts.cwd || this.projectRoot };
67
+ return { cmd: this.binary, args, cwd };
58
68
  }
59
69
 
60
70
  parse(stdout) {
@@ -40,9 +40,15 @@ class EslintAdapter extends LinterAdapter {
40
40
  return ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx'];
41
41
  }
42
42
 
43
- /** Prefer the project-local ESLint; fall back to one on PATH. */
43
+ /**
44
+ * Prefer the app-local ESLint, then the repo-root ESLint, then PATH. In a
45
+ * monorepo each JS app has its own node_modules; the repo root often has
46
+ * none, so resolving only at projectRoot makes available() falsely return
47
+ * false and the linter gets silently skipped.
48
+ */
44
49
  get binary() {
45
50
  return this.resolveBinary([
51
+ path.join(this.appRoot, 'node_modules', '.bin', 'eslint'),
46
52
  path.join(this.projectRoot, 'node_modules', '.bin', 'eslint'),
47
53
  'eslint',
48
54
  ]);
@@ -55,9 +61,15 @@ class EslintAdapter extends LinterAdapter {
55
61
  buildCommand(targets, opts = {}) {
56
62
  const args = ['--format=json'];
57
63
  if (opts.fix) args.push('--fix');
58
- const list = targets && targets.length ? targets : ['.'];
64
+ // Run inside the app directory so ESLint resolves the app's own flat
65
+ // config (eslint.config.js) — in a monorepo the repo root often has none.
66
+ const cwd = opts.cwd || this.appRoot;
67
+ const list = (targets && targets.length ? targets : ['.']).map((t) => {
68
+ const rel = path.relative(cwd, path.resolve(this.projectRoot, t));
69
+ return rel === '' ? '.' : rel;
70
+ });
59
71
  args.push(...list);
60
- return { cmd: this.binary, args, cwd: opts.cwd || this.projectRoot };
72
+ return { cmd: this.binary, args, cwd };
61
73
  }
62
74
 
63
75
  parse(stdout) {
@@ -7,6 +7,7 @@ const RuffAdapter = require('./ruff');
7
7
  const GolangciLintAdapter = require('./golangci-lint');
8
8
  const ClippyAdapter = require('./clippy');
9
9
  const TflintAdapter = require('./tflint');
10
+ const AnsibleLintAdapter = require('./ansible');
10
11
 
11
12
  // The adapter registry. `tool` strings in gimme-the-lint.config.js resolve to
12
13
  // concrete adapters here. Adding a language to gimme-the-lint means adding one
@@ -19,6 +20,7 @@ const REGISTRY = {
19
20
  'golangci-lint': GolangciLintAdapter,
20
21
  clippy: ClippyAdapter,
21
22
  tflint: TflintAdapter,
23
+ 'ansible-lint': AnsibleLintAdapter,
22
24
  };
23
25
 
24
26
  /** Construct an adapter instance by id. Throws on an unknown id. */
@@ -31,9 +31,10 @@ class RuffAdapter extends LinterAdapter {
31
31
  return ['.py', '.pyi'];
32
32
  }
33
33
 
34
- /** Prefer the project venv's Ruff; fall back to one on PATH. */
34
+ /** Prefer the app venv's Ruff, then the repo-root venv, then PATH. */
35
35
  get binary() {
36
36
  return this.resolveBinary([
37
+ path.join(this.appRoot, '.venv', 'bin', 'ruff'),
37
38
  path.join(this.projectRoot, '.venv', 'bin', 'ruff'),
38
39
  'ruff',
39
40
  ]);
@@ -29,12 +29,25 @@ const { fingerprint } = require('./fingerprint');
29
29
  const SCHEMA_VERSION = 2;
30
30
 
31
31
  // Per-linter status values recorded in each section / the manifest.
32
+ //
33
+ // UNAVAILABLE and ERROR mean the baseline for that linter was NOT captured —
34
+ // they must stay distinct from CLEAN (linter ran, nothing found) and from a
35
+ // genuine empty baseline. A section in either state is an INCOMPLETE baseline:
36
+ // treating it as clean would make every pre-existing violation count as new
37
+ // the first time the linter actually runs.
32
38
  const STATUS = Object.freeze({
33
39
  BASELINED: 'baselined', // violations captured into the baseline
34
40
  CLEAN: 'clean', // linter ran, found nothing to baseline
35
- SKIPPED: 'skipped', // code present but linter binary unavailable
41
+ SKIPPED: 'skipped', // intentionally not baselined (kept for back-compat)
42
+ UNAVAILABLE: 'unavailable', // code present but the linter binary is missing
43
+ ERROR: 'error', // the linter applies here but failed to run
36
44
  });
37
45
 
46
+ /** True when a section's status means the baseline was not actually captured. */
47
+ function isIncompleteStatus(status) {
48
+ return status === STATUS.UNAVAILABLE || status === STATUS.ERROR;
49
+ }
50
+
38
51
  /** Build a {fingerprint: count} map from a list of NormalizedViolations. */
39
52
  function buildFingerprintMap(violations) {
40
53
  const map = {};
@@ -109,6 +122,7 @@ async function writeBaseline(baselinePath, baseline) {
109
122
  module.exports = {
110
123
  SCHEMA_VERSION,
111
124
  STATUS,
125
+ isIncompleteStatus,
112
126
  buildFingerprintMap,
113
127
  createLinterSection,
114
128
  emptyBaseline,
package/lib/baseline.js CHANGED
@@ -11,6 +11,12 @@ const gtlManifest = require('./gtl-manifest');
11
11
  // .gtl/apps/<app>/baseline.json. After this, only NEW violations block.
12
12
  // With opts.noBaseline it writes an EMPTY baseline instead — the greenfield
13
13
  // "strict from day one" stance (Phase 5).
14
+ //
15
+ // A linter that is unavailable or errors out is recorded with an INCOMPLETE
16
+ // status (unavailable / error), never as a clean baseline — see baseline-store
17
+ // STATUS. runBaseline() surfaces these in `incomplete` so callers (migrate,
18
+ // the baseline CLI, the hooks installer) can refuse to gate commits against a
19
+ // baseline that never actually captured a linter.
14
20
 
15
21
  /** Hash the first config file found for an adapter (unit root, then project). */
16
22
  async function configHashFor(projectRoot, unitRoot, adapter) {
@@ -33,7 +39,10 @@ async function baselineUnit(projectRoot, unit, opts = {}) {
33
39
  for (const linterId of unit.linters) {
34
40
  let adapter;
35
41
  try {
36
- adapter = adapters.getAdapter(linterId, { projectRoot });
42
+ adapter = adapters.getAdapter(linterId, {
43
+ projectRoot,
44
+ appRoot: unit.root,
45
+ });
37
46
  } catch (err) {
38
47
  sections.push({ linter: linterId, status: 'error', reason: err.message });
39
48
  continue;
@@ -44,6 +53,9 @@ async function baselineUnit(projectRoot, unit, opts = {}) {
44
53
  continue;
45
54
  }
46
55
 
56
+ // Code is present but the linter binary is missing. Under strict/offline
57
+ // this is a hard failure; otherwise record an UNAVAILABLE section — NOT a
58
+ // clean one — so downstream knows this baseline was never captured.
47
59
  if (!adapter.available()) {
48
60
  if (opts.strict) {
49
61
  const err = new Error(
@@ -56,13 +68,13 @@ async function baselineUnit(projectRoot, unit, opts = {}) {
56
68
  baseline,
57
69
  linterId,
58
70
  baselineStore.createLinterSection([], {
59
- status: baselineStore.STATUS.SKIPPED,
71
+ status: baselineStore.STATUS.UNAVAILABLE,
60
72
  })
61
73
  );
62
74
  sections.push({
63
75
  linter: linterId,
64
- status: 'skipped',
65
- reason: `${adapter.id} not installed`,
76
+ status: baselineStore.STATUS.UNAVAILABLE,
77
+ reason: `${adapter.id} is not installed`,
66
78
  });
67
79
  continue;
68
80
  }
@@ -72,7 +84,20 @@ async function baselineUnit(projectRoot, unit, opts = {}) {
72
84
  try {
73
85
  violations = adapter.lint([unit.appPath], {});
74
86
  } catch (err) {
75
- sections.push({ linter: linterId, status: 'error', reason: err.message });
87
+ // The linter applies here but could not run — record an ERROR section
88
+ // rather than leaving the baseline silently without this linter.
89
+ baselineStore.setLinterSection(
90
+ baseline,
91
+ linterId,
92
+ baselineStore.createLinterSection([], {
93
+ status: baselineStore.STATUS.ERROR,
94
+ })
95
+ );
96
+ sections.push({
97
+ linter: linterId,
98
+ status: baselineStore.STATUS.ERROR,
99
+ reason: err.message,
100
+ });
76
101
  continue;
77
102
  }
78
103
  }
@@ -101,10 +126,29 @@ async function baselineUnit(projectRoot, unit, opts = {}) {
101
126
  };
102
127
  }
103
128
 
129
+ /** Collect every incomplete (unavailable/errored) section from unit results. */
130
+ function collectIncomplete(unitResults) {
131
+ const incomplete = [];
132
+ for (const u of unitResults || []) {
133
+ for (const s of u.sections || []) {
134
+ if (baselineStore.isIncompleteStatus(s.status)) {
135
+ incomplete.push({
136
+ app: u.appPath,
137
+ linter: s.linter,
138
+ status: s.status,
139
+ reason: s.reason,
140
+ });
141
+ }
142
+ }
143
+ }
144
+ return incomplete;
145
+ }
146
+
104
147
  /**
105
148
  * Create/refresh baselines for the whole project.
106
149
  * @param {string} projectRoot
107
150
  * @param {object} opts { noBaseline, strict }
151
+ * @returns {{unitCount, units, incomplete}}
108
152
  */
109
153
  async function runBaseline(projectRoot, opts = {}) {
110
154
  const root = projectRoot || process.cwd();
@@ -115,11 +159,38 @@ async function runBaseline(projectRoot, opts = {}) {
115
159
  }
116
160
  // Refresh the global manifest so drift detection has a current snapshot.
117
161
  await gtlManifest.writeManifest(root, gtlManifest.buildManifest(results));
118
- return { unitCount: units.length, units: results };
162
+ return {
163
+ unitCount: units.length,
164
+ units: results,
165
+ incomplete: collectIncomplete(results),
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Scan committed baselines for incomplete (unavailable/errored) linter
171
+ * sections. Used by the hooks installer to refuse to gate commits against a
172
+ * baseline that never captured a linter.
173
+ * @returns {Promise<{app, linter, status}[]>}
174
+ */
175
+ async function findIncompleteBaselines(projectRoot) {
176
+ const root = projectRoot || process.cwd();
177
+ const out = [];
178
+ for (const unit of resolveUnits(root)) {
179
+ const baseline = await baselineStore.readBaseline(unit.baselinePath);
180
+ if (!baseline || !baseline.linters) continue;
181
+ for (const [linterId, section] of Object.entries(baseline.linters)) {
182
+ if (baselineStore.isIncompleteStatus(section && section.status)) {
183
+ out.push({ app: unit.appPath, linter: linterId, status: section.status });
184
+ }
185
+ }
186
+ }
187
+ return out;
119
188
  }
120
189
 
121
190
  module.exports = {
122
191
  runBaseline,
123
192
  baselineUnit,
124
193
  configHashFor,
194
+ collectIncomplete,
195
+ findIncompleteBaselines,
125
196
  };
package/lib/check.js CHANGED
@@ -74,6 +74,21 @@ async function checkUnit(projectRoot, unit, adapter, opts) {
74
74
 
75
75
  const baseline = await baselineStore.readBaseline(unit.baselinePath);
76
76
  const section = baselineStore.getLinterSection(baseline, adapter.id);
77
+
78
+ // An incomplete baseline section (the linter was unavailable or errored when
79
+ // the baseline was captured) is not a real baseline — diffing against its
80
+ // empty fingerprint map would flag every pre-existing violation as new and
81
+ // block the commit. Surface it as a warning to re-baseline instead.
82
+ if (section && baselineStore.isIncompleteStatus(section.status)) {
83
+ return {
84
+ ...base,
85
+ status: 'needs-baseline',
86
+ reason:
87
+ `baseline for ${adapter.id} is incomplete (was "${section.status}" ` +
88
+ 'when captured) — run: gimme-the-lint baseline',
89
+ };
90
+ }
91
+
77
92
  const result = diffEngine.diff(violations, section);
78
93
 
79
94
  return {
@@ -99,7 +114,10 @@ async function runCheck(projectRoot, opts = {}) {
99
114
  for (const linterId of unit.linters) {
100
115
  let adapter;
101
116
  try {
102
- adapter = adapters.getAdapter(linterId, { projectRoot: root });
117
+ adapter = adapters.getAdapter(linterId, {
118
+ projectRoot: root,
119
+ appRoot: unit.root,
120
+ });
103
121
  } catch (err) {
104
122
  results.push({
105
123
  unit: unit.id,
@@ -104,10 +104,50 @@ module.exports = ${JSON.stringify(config, null, 2)};
104
104
  return { created: true, path: configPath, projectType };
105
105
  }
106
106
 
107
+ /**
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.
111
+ * @param {string} projectRoot
112
+ * @param {{appPath: string, linters: string[]}[]} apps
113
+ */
114
+ 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 {}
130
+ }
131
+ const configPath = isESM ? cjsPath : jsPath;
132
+
133
+ const appsObj = {};
134
+ for (const app of apps || []) {
135
+ appsObj[app.appPath] = { linters: app.linters };
136
+ }
137
+ const content = `// gimme-the-lint configuration
138
+ // Generated by gimme-the-lint migrate — explicit app/linter bindings.
139
+ // Edit this map to correct any mis-discovered apps or linters.
140
+ module.exports = ${JSON.stringify({ apps: appsObj }, null, 2)};
141
+ `;
142
+ await fs.writeFile(configPath, content, 'utf8');
143
+ return { created: true, path: configPath, apps: appsObj };
144
+ }
145
+
107
146
  module.exports = {
108
147
  copyTemplate,
109
148
  detectProjectType,
110
149
  getConfig,
111
150
  initConfig,
151
+ writeAppsConfig,
112
152
  TEMPLATES_DIR,
113
153
  };
package/lib/drift.js CHANGED
@@ -47,7 +47,10 @@ async function detectDrift(projectRoot) {
47
47
  for (const linterId of app.linters) {
48
48
  let adapter;
49
49
  try {
50
- adapter = adapters.getAdapter(linterId, { projectRoot: root });
50
+ adapter = adapters.getAdapter(linterId, {
51
+ projectRoot: root,
52
+ appRoot: path.resolve(root, app.appPath),
53
+ });
51
54
  } catch {
52
55
  continue;
53
56
  }
@@ -23,6 +23,7 @@ const LINTER_CONFIGS = {
23
23
  'golangci-lint': { template: '.golangci.template.yml', dest: '.golangci.yml' },
24
24
  tflint: { template: '.tflint.template.hcl', dest: '.tflint.hcl' },
25
25
  clippy: { template: 'clippy.template.toml', dest: 'clippy.toml' },
26
+ 'ansible-lint': { template: '.ansible-lint.template.yml', dest: '.ansible-lint' },
26
27
  };
27
28
 
28
29
  /** Copy one template into an app dir, create-if-absent. */
package/lib/migrate.js CHANGED
@@ -3,6 +3,8 @@
3
3
  const fs = require('fs-extra');
4
4
  const path = require('path');
5
5
  const { runBaseline } = require('./baseline');
6
+ const projectModel = require('./project-model');
7
+ const configManager = require('./config-manager');
6
8
 
7
9
  // Migration from the v1 layout (.lttf/ + .lttf-ruff/ per-directory baselines)
8
10
  // to the v2 .gtl/ layout. v1 baseline files hold per-directory violation data
@@ -89,6 +91,12 @@ async function migrate(projectRoot, opts = {}) {
89
91
  backedUp.push(rel);
90
92
  }
91
93
 
94
+ // Pin the discovered app/linter layout into gimme-the-lint.config.js so the
95
+ // guess is visible and editable, not silently re-derived on every run.
96
+ const discoveredApps = projectModel.discoverApps(root);
97
+ const appsConfig = await configManager.writeAppsConfig(root, discoveredApps);
98
+ const discoveryWarnings = projectModel.discoveryWarnings(root);
99
+
92
100
  // Re-baseline from the current code into the v2 .gtl/ layout.
93
101
  const baseline = await runBaseline(root, { strict: opts.strict });
94
102
 
@@ -96,7 +104,13 @@ async function migrate(projectRoot, opts = {}) {
96
104
  migrated: true,
97
105
  backedUp,
98
106
  backupPath: path.relative(root, backupRoot),
107
+ discoveredApps,
108
+ appsConfig,
109
+ discoveryWarnings,
99
110
  baseline,
111
+ // Incomplete linter sections (unavailable / errored) — a baseline in this
112
+ // state never captured those linters and must not silently gate commits.
113
+ incomplete: baseline.incomplete || [],
100
114
  };
101
115
  }
102
116
 
@@ -11,16 +11,23 @@ const configManager = require('./config-manager');
11
11
  // monorepo — apps/orders-api (TS), apps/orders-worker (Py), apps/billing
12
12
  // (Go), apps/audit (Rust) — discover cleanly with zero config.
13
13
 
14
- // Manifest filename → linter id.
14
+ // Manifest filename → linter id. These must be STRONG markers — a file whose
15
+ // presence reliably means "a real, separately-configured app lives here".
16
+ // `requirements.txt` is deliberately excluded: it is too weak (it shows up in
17
+ // load-test dirs, docs tooling, sub-utilities) and binding ruff to every dir
18
+ // that has one mis-binds nested noise and hides the real app.
15
19
  const MANIFEST_LINTERS = {
16
20
  'package.json': 'eslint',
17
21
  'biome.json': 'biome',
18
22
  'biome.jsonc': 'biome',
19
23
  'pyproject.toml': 'ruff',
24
+ 'ruff.toml': 'ruff',
25
+ '.ruff.toml': 'ruff',
20
26
  'setup.py': 'ruff',
21
- 'requirements.txt': 'ruff',
22
27
  'go.mod': 'golangci-lint',
23
28
  'Cargo.toml': 'clippy',
29
+ 'ansible.cfg': 'ansible-lint',
30
+ 'galaxy.yml': 'ansible-lint',
24
31
  };
25
32
 
26
33
  // Terraform / OpenTofu have no manifest file — a directory of *.tf (or *.tofu)
@@ -102,12 +109,15 @@ function findManifestDirs(projectRoot) {
102
109
  }
103
110
 
104
111
  /**
105
- * Discover the apps in a project.
106
- * An "app" is a LEAF manifest directory — one with no manifest-bearing
107
- * directory beneath it. A manifest dir that DOES contain nested packages is a
108
- * workspace root (npm/pnpm/nx/lerna) and is not itself linted; its packages
109
- * are. Workspace globs need no special parsing because each package carries
110
- * its own manifest.
112
+ * Discover the apps in a project, binding each to its linters.
113
+ *
114
+ * Workspace-root detection is PER LINTER: a manifest directory is an app for
115
+ * linter L unless another manifest directory NESTED below it also carries L
116
+ * (then the outer dir is a workspace root for L, and the nested dirs are the
117
+ * apps). This is what lets a repo-root `pyproject.toml` (ruff) be a real
118
+ * Python app even when nested `package.json` dirs (eslint) sit below it — the
119
+ * old "any nested manifest ⇒ skip the whole dir" rule discarded the root
120
+ * config and bound nothing for Python.
111
121
  * @returns {{appPath: string, linters: string[]}[]}
112
122
  */
113
123
  function discoverApps(projectRoot, opts = {}) {
@@ -119,35 +129,66 @@ function discoverApps(projectRoot, opts = {}) {
119
129
  ];
120
130
 
121
131
  const manifestDirs = findManifestDirs(root);
122
- const allDirs = manifestDirs.map((m) => m.dir);
123
132
 
124
- const apps = [];
133
+ // dir Set<linter>, accumulated via the per-linter workspace-root rule.
134
+ const appLinters = new Map();
125
135
  for (const entry of manifestDirs) {
126
- const childPrefix = entry.dir === '.' ? '' : `${entry.dir}/`;
127
- const isWorkspaceRoot = allDirs.some(
128
- (other) => other !== entry.dir && other.startsWith(childPrefix)
129
- );
130
- if (isWorkspaceRoot) continue;
131
-
132
136
  // Skip template/scaffold directories (match any path segment).
133
137
  const segments = entry.dir === '.' ? [] : entry.dir.split('/');
134
138
  if (segments.some((segment) => matchesAny(segment, skipPatterns))) continue;
135
139
 
140
+ const childPrefix = entry.dir === '.' ? '' : `${entry.dir}/`;
141
+ for (const linter of entry.linters) {
142
+ const isWorkspaceRootForLinter = manifestDirs.some(
143
+ (other) =>
144
+ other.dir !== entry.dir &&
145
+ other.dir.startsWith(childPrefix) &&
146
+ other.linters.has(linter)
147
+ );
148
+ if (isWorkspaceRootForLinter) continue; // nested dirs are the apps for L
149
+ if (!appLinters.has(entry.dir)) appLinters.set(entry.dir, new Set());
150
+ appLinters.get(entry.dir).add(linter);
151
+ }
152
+ }
153
+
154
+ const apps = [];
155
+ for (const [dir, linterSet] of appLinters) {
136
156
  // A biome.json is an explicit choice of Biome over ESLint for JS/TS —
137
157
  // honor it by dropping the default ESLint binding for that app.
138
- let linters = [...entry.linters];
158
+ let linters = [...linterSet];
139
159
  if (linters.includes('biome') && linters.includes('eslint')) {
140
160
  linters = linters.filter((l) => l !== 'eslint');
141
161
  }
142
-
143
- apps.push({ appPath: entry.dir, linters: linters.sort() });
162
+ if (linters.length) apps.push({ appPath: dir, linters: linters.sort() });
144
163
  }
145
164
 
146
165
  return apps.sort((a, b) => a.appPath.localeCompare(b.appPath));
147
166
  }
148
167
 
168
+ /**
169
+ * Discovery warnings — surfaced by `migrate` / `baseline` so a guessed app
170
+ * layout is visible rather than silent. The ambiguous case is a repo-root
171
+ * linter config AND nested apps: discovery has to guess the boundary, so an
172
+ * explicit `apps` map should pin it.
173
+ * @returns {string[]}
174
+ */
175
+ function discoveryWarnings(projectRoot, opts = {}) {
176
+ const apps = discoverApps(projectRoot, opts);
177
+ const warnings = [];
178
+ const hasRootApp = apps.some((a) => a.appPath === '.');
179
+ if (hasRootApp && apps.length > 1) {
180
+ warnings.push(
181
+ 'Discovery found a repo-root linter config AND nested app(s); the app ' +
182
+ 'boundaries are a guess. Pin them with an explicit `apps` map in ' +
183
+ 'gimme-the-lint.config.js (migrate writes one for you).'
184
+ );
185
+ }
186
+ return warnings;
187
+ }
188
+
149
189
  module.exports = {
150
190
  discoverApps,
191
+ discoveryWarnings,
151
192
  findManifestDirs,
152
193
  globToRegExp,
153
194
  MANIFEST_LINTERS,
package/lib/report.js CHANGED
@@ -52,6 +52,10 @@ function formatCheckReport(report) {
52
52
  lines.push(chalk.dim(`· ${label} — no staged changes`));
53
53
  } else if (u.status === 'error') {
54
54
  lines.push(`${chalk.red('✗')} ${label} ${chalk.red(`— ${u.reason}`)}`);
55
+ } else if (u.status === 'needs-baseline') {
56
+ lines.push(
57
+ `${chalk.yellow('⚠ NEEDS BASELINE')} ${label} ${chalk.yellow(`— ${u.reason}`)}`
58
+ );
55
59
  }
56
60
  // 'no-code' is intentionally silent.
57
61
  if ((u.status === 'pass' || u.status === 'fail') && u.hasBaseline === false) {
@@ -91,10 +95,18 @@ function formatBaselineReport(report, projectRoot) {
91
95
  lines.push(chalk.cyan(` ${u.appPath}`));
92
96
  for (const s of u.sections) {
93
97
  if (s.status === 'no-code') continue;
94
- if (s.status === 'skipped') {
95
- lines.push(` ${chalk.yellow('⚠ SKIPPED')} ${s.linter} — ${s.reason}`);
98
+ if (s.status === 'unavailable') {
99
+ lines.push(
100
+ ` ${chalk.red('⚠ UNAVAILABLE')} ${s.linter} — ${s.reason} ` +
101
+ chalk.red('(NOT baselined)')
102
+ );
96
103
  } else if (s.status === 'error') {
97
- lines.push(` ${chalk.red('✗')} ${s.linter} — ${s.reason}`);
104
+ lines.push(
105
+ ` ${chalk.red('✗ ERROR')} ${s.linter} — ${s.reason} ` +
106
+ chalk.red('(NOT baselined)')
107
+ );
108
+ } else if (s.status === 'skipped') {
109
+ lines.push(` ${chalk.yellow('⚠ SKIPPED')} ${s.linter} — ${s.reason}`);
98
110
  } else {
99
111
  lines.push(
100
112
  ` ${chalk.green('✓')} ${s.linter} — ${s.total} violation(s) ` +
@@ -104,6 +116,28 @@ function formatBaselineReport(report, projectRoot) {
104
116
  }
105
117
  lines.push(chalk.dim(` → ${path.relative(root, u.baselinePath)}`));
106
118
  }
119
+
120
+ // Prominent incomplete summary — a baseline missing linters must never look
121
+ // like a success.
122
+ if (report.incomplete && report.incomplete.length) {
123
+ lines.push('');
124
+ lines.push(
125
+ chalk.red(`✗ ${report.incomplete.length} linter(s) were NOT baselined:`)
126
+ );
127
+ for (const i of report.incomplete) {
128
+ lines.push(chalk.red(` ${i.app} · ${i.linter} (${i.status})`));
129
+ }
130
+ lines.push(
131
+ chalk.yellow(
132
+ ' This baseline is INCOMPLETE. Install the missing linters and re-run'
133
+ )
134
+ );
135
+ lines.push(
136
+ chalk.yellow(
137
+ ' `gimme-the-lint baseline` before relying on it to gate commits.'
138
+ )
139
+ );
140
+ }
107
141
  return lines.join('\n');
108
142
  }
109
143
 
package/lib/toolchain.js CHANGED
@@ -51,7 +51,10 @@ function findGaps(projectRoot) {
51
51
  for (const linterId of unit.linters) {
52
52
  let adapter;
53
53
  try {
54
- adapter = adapters.getAdapter(linterId, { projectRoot: root });
54
+ adapter = adapters.getAdapter(linterId, {
55
+ projectRoot: root,
56
+ appRoot: unit.root,
57
+ });
55
58
  } catch {
56
59
  continue;
57
60
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@theglitchking/gimme-the-lint",
3
- "version": "2.1.0",
4
- "description": "Polyglot progressive linting for monorepos — adapter-driven baselines, per-app drift detection, and idempotent skips across JavaScript/TypeScript, Python, Go, Rust, and Terraform.",
3
+ "version": "2.3.0",
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",
7
7
  "progressive-linting",
@@ -23,7 +23,9 @@
23
23
  "rust",
24
24
  "tflint",
25
25
  "terraform",
26
- "opentofu"
26
+ "opentofu",
27
+ "ansible-lint",
28
+ "ansible"
27
29
  ],
28
30
  "author": {
29
31
  "name": "TheGlitchKing",
@@ -0,0 +1,20 @@
1
+ # Generated by gimme-the-lint — ansible-lint (recommended tier)
2
+ #
3
+ # `profile` is ansible-lint's strictness lever, from least to most strict:
4
+ # min -> basic -> moderate -> safety -> shared -> production
5
+ # "moderate" is the recommended baseline. Raise to "safety" or "production"
6
+ # for stricter enforcement.
7
+ profile: moderate
8
+
9
+ exclude_paths:
10
+ - .cache/
11
+ - .git/
12
+ - molecule/
13
+
14
+ # Downgrade specific rules to non-blocking warnings:
15
+ # warn_list:
16
+ # - experimental
17
+ #
18
+ # Skip specific rules entirely:
19
+ # skip_list:
20
+ # - yaml[line-length]