From 952467fece76da50ced940f7f0088cc948be915f Mon Sep 17 00:00:00 2001 From: Peter Freiberg <109841928+sabbaticas@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:17:58 +1100 Subject: [PATCH] Detect linter, formatter and type checker config files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add LINTER_RULES covering 30 tools and 96 exact filenames across JavaScript, TypeScript, CSS, Python, Go, Ruby, Java, Groovy, C#/.NET and SQL, with their ignore counterparts. Every convention was verified against the tool's current official documentation. LINTER_RULES describes each tool once — languages, roles, and the files that identify it — and is expanded into the existing METADATA_RULES tables at import. Prettier alone accepts 18 config filenames carrying identical tags, so the literal form would be 18 near-duplicate lines; the table also makes the tag shape structurally guaranteed rather than a convention each entry has to remember, which is what went wrong between the pre-existing `eslint` and `SQLFluff` rules. core.py and cli.py needed no change: get_tags() reads the expanded tables, and the existing vocabulary drift test picked up all 96 rules. Eleven candidates were investigated and deliberately rejected, each of which would have been a wrong rule — revive auto-discovers only $HOME/revive.toml, a user-level path; SpotBugs, PMD and CodeNarc take arbitrarily-named files passed on the command line; Black, StandardJS and Spotless have no dedicated config file. pyproject.toml, setup.cfg, tox.ini and package.json really do hold linter config, but which tool is inside is not knowable from a path, so tagging them would mislabel nearly every repository that has one. Negative tests assert all of this stays true, and docs/linter-detection-rules.md records the sources. Tags grew 85 -> 118, filetypes 76 -> 86. All tag changes are additions or case-only (`eslint` -> `ESLint`), and SQLite LIKE is case-insensitive, so kospex's stored queries still match; a pin bump is needed only to pick up the new tags via re-sync. Two fixes this surfaced: - panopticas file reported no file type for any file typed by basename rather than by extension, because it read get_extension_filetype(), which consults only EXT_FILETYPES. `panopticas file go.mod` showed a blank file type while `assess` showed `go.mod`. The row now reads get_language(skip_shebang=True), so both commands agree; skip_shebang keeps it distinct from the Shebang Language row, and the UNKNOWN sentinel is normalised to null for the JSON contract. - check_shebang() caught only FileNotFoundError and UnicodeDecodeError, so a directory raised IsADirectoryError and a single unreadable file aborted an entire `panopticas assess` scan with PermissionError. It now catches (OSError, ValueError), covering every filesystem refusal plus a NUL byte in the path. `panopticas urls` had the identical bug one layer up and is fixed in the CLI loop, leaving extract_urls_from_file()'s documented raising behaviour intact. Passing a filename that does not exist on disk was already safe and still is. Also adds .mjs and .cjs as JavaScript and .mts and .cts as TypeScript, which were missing from EXT_FILETYPES entirely — unrelated to linters, but every file using them reported Unknown. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 17 ++ CLAUDE.md | 35 +++ changes/2026-10-linter-detection.md | 197 +++++++++++++ docs/_layouts/default.html | 2 + docs/linter-detection-rules.md | 256 +++++++++++++++++ pyproject.toml | 2 +- src/panopticas/cli.py | 24 +- src/panopticas/constants.py | 279 +++++++++++++++++- src/panopticas/core.py | 24 +- src/tests/.editorconfig | 0 src/tests/.eslintignore | 0 src/tests/.eslintrc | 0 src/tests/.flake8 | 0 src/tests/.globalconfig | 0 src/tests/.golangci.yml | 0 src/tests/.groovylintrc.json | 0 src/tests/.isort.cfg | 0 src/tests/.jshintignore | 0 src/tests/.prettierignore | 0 src/tests/.prettierrc | 0 src/tests/.prettierrc.json5 | 0 src/tests/.pylintrc | 0 src/tests/.rubocop.yml | 0 src/tests/.rufo | 0 src/tests/.style.yapf | 0 src/tests/.stylelintignore | 0 src/tests/.stylelintrc | 0 src/tests/.yapfignore | 0 src/tests/CodeAnalysis.ruleset | 0 src/tests/Steepfile | 0 src/tests/biome.jsonc | 0 src/tests/module.cjs | 0 src/tests/module.cts | 0 src/tests/module.mjs | 0 src/tests/module.mts | 0 src/tests/pylintrc | 0 src/tests/staticcheck.conf | 0 tests/test_cli_json.py | 76 +++++ tests/test_linter_detection.py | 432 ++++++++++++++++++++++++++++ tests/test_panopticas.py | 83 ++++++ 40 files changed, 1410 insertions(+), 17 deletions(-) create mode 100644 changes/2026-10-linter-detection.md create mode 100644 docs/linter-detection-rules.md create mode 100644 src/tests/.editorconfig create mode 100644 src/tests/.eslintignore create mode 100644 src/tests/.eslintrc create mode 100644 src/tests/.flake8 create mode 100644 src/tests/.globalconfig create mode 100644 src/tests/.golangci.yml create mode 100644 src/tests/.groovylintrc.json create mode 100644 src/tests/.isort.cfg create mode 100644 src/tests/.jshintignore create mode 100644 src/tests/.prettierignore create mode 100644 src/tests/.prettierrc create mode 100644 src/tests/.prettierrc.json5 create mode 100644 src/tests/.pylintrc create mode 100644 src/tests/.rubocop.yml create mode 100644 src/tests/.rufo create mode 100644 src/tests/.style.yapf create mode 100644 src/tests/.stylelintignore create mode 100644 src/tests/.stylelintrc create mode 100644 src/tests/.yapfignore create mode 100644 src/tests/CodeAnalysis.ruleset create mode 100644 src/tests/Steepfile create mode 100644 src/tests/biome.jsonc create mode 100644 src/tests/module.cjs create mode 100644 src/tests/module.cts create mode 100644 src/tests/module.mjs create mode 100644 src/tests/module.mts create mode 100644 src/tests/pylintrc create mode 100644 src/tests/staticcheck.conf create mode 100644 tests/test_linter_detection.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 34d5c49..bbf5536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,24 @@ The format of this changelog is based on [Keep a Changelog](https://keepachangel ## [Unreleased] +### Added + - Detection of linter, formatter and type checker configuration files and their ignore counterparts: 30 tools across JavaScript, TypeScript, CSS, Python, Go, Ruby, Java, Groovy, C#/.NET and SQL, covering 96 exact filenames. Every convention was verified against the tool's current official documentation; see [`docs/linter-detection-rules.md`](https://panopticas.io/linter-detection-rules) for the inventory, the sources, and the candidates that were investigated and rejected. + - `LINTER_RULES` in `constants.py` is the single table describing those tools, expanded into the existing `METADATA_RULES` tables at import. `LINTER_ROLES` constrains the legal roles. + - Two new role tags, `formatter` and `typechecker`, joining the existing `linter`. A tool may hold more than one — Ruff, Biome, RuboCop, Standard and SQLFluff all lint and format. + - `.mjs` and `.cjs` now report as JavaScript, `.mts` and `.cts` as TypeScript. These were missing from the extension table entirely, so every file using them reported `Unknown` regardless of linters. + - Filetypes for extensionless config dotfiles that previously reported `Unknown`: `.editorconfig`, `.flake8`, `.globalconfig`, `.pylintrc`, `pylintrc`, `.isort.cfg` and `.style.yapf` resolve to `INI`, `.jshintrc` to `JSON`, `staticcheck.conf` to `TOML`, `.rufo` and `Steepfile` to `Ruby`, and `*.ruleset` to `XML`. Files accepting several formats get their own name: `ESLintRC`, `Prettierrc`, `StylelintRC`, plus `ESLintIgnore`, `PrettierIgnore`, `StylelintIgnore`, `JSHintIgnore` and `YapfIgnore`. `JSONC` and `JSON5` were also added. + +### Changed + - The tag vocabulary grew from 85 to 118 tags and the filetype vocabulary from 76 to 86. Both are still derived from the rules, so neither can drift. + - `eslint.config.js` tagged `eslint`; it now tags `ESLint`, matching the brand casing every other tool uses, and gains a `TypeScript` tag. kospex matches tags with a case-insensitive `LIKE`, so stored queries are unaffected. + - `.sqlfluff` and `.sqlfluffignore` moved into `LINTER_RULES`. Purely additive: `.sqlfluff` keeps `SQLFluff`, `SQL` and `linter` and gains `formatter` and `config`. + + All tag changes are additions or case-only, so kospex is compatible either way; a bump is needed only to pick up the new tags, which requires a re-sync. + ### Fixed + - `check_shebang()` caught only `FileNotFoundError` and `UnicodeDecodeError`, so any other way `open()` can fail propagated out of `get_language()`. A single unreadable file aborted a whole `panopticas assess` scan with `PermissionError`, and passing a directory raised `IsADirectoryError`. It now returns `None` for every path it cannot read, letting `get_language()` fall back to the extension tables. Passing a filename that does not exist on disk was already safe and still is. + - `panopticas urls` aborted the whole scan on one unreadable file. The loop caught `UnicodeDecodeError` — added in 0.0.19 so a binary file could not kill the run — but not `OSError`. Both are now treated as a file with no URLs. `extract_urls_from_file()` still raises by design; the handling belongs in the caller that is scanning a tree. + - `panopticas file` reported no file type for any file typed by basename rather than by extension, because it read `get_extension_filetype()`, which consults only `EXT_FILETYPES`. `panopticas file go.mod` showed a blank file type while `panopticas assess` showed `go.mod`; `setup.cfg` was blank the same way. The row now reads `get_language(skip_shebang=True)`, so both commands agree. The shebang rows are unaffected — a shebang-only script still reports no file type rather than borrowing its interpreter. - `panopticas file` labelled its first table row `File extenion`. Cosmetic, and the only change to shipped code since 0.0.19 — no library function, tag or JSON field changes, so kospex is unaffected and its pin does not need bumping for this ## 0.0.19 - 2026-08-15 diff --git a/CLAUDE.md b/CLAUDE.md index 51475d8..d471c6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,41 @@ Rules to follow when adding a product: - `exact_filename` and `path_contains` keys are lowercase; path fragments end with `/`. - **Verify the convention against the product's current official docs before adding it.** A wrong rule mislabels a repository and the label flows into kospex. A missing rule is better than a wrong one. Watch for user-level paths (`~/.config//`) — those are not repository artifacts and must not be added. +### Linter, Formatter and Type Checker Detection + +> **Before adding or changing a rule, read [`docs/linter-detection-rules.md`](docs/linter-detection-rules.md)** — the full inventory, the source confirming each convention, and the candidates already investigated and **rejected**. Eleven were rejected as wrong; re-adding one would mislabel repositories. + +`LINTER_RULES` in `constants.py` describes each tool once — its languages, its +roles, and the files that identify it: + +```python +"ESLint": { + "languages": ["JavaScript", "TypeScript"], + "roles": ["linter"], + "config": ["eslint.config.js", ..., ".eslintrc.json"], + "ignore": [".eslintignore"], +}, +``` + +`_expand_linter_rules()` writes those into the existing `METADATA_RULES` +tables at import — `config`/`ignore` into `exact_filename_rules`, +`path_contains` into `path_contains_rules`, `extensions` into +`extension_rules`. Tags are `languages + roles + [tool] + ["config"|"ignore"]`. +`get_tags()` reads the expanded tables, so a new tool joins the vocabulary with +no other change. + +Rules to follow when adding a tool: + +- Detection is **path-based only** — never open a file. +- `roles` must come from `LINTER_ROLES` (`linter`, `formatter`, `typechecker`). A tool may hold several; Ruff, Biome, RuboCop, Standard and SQLFluff all lint and format. +- Tool names use the vendor's own brand casing (`ESLint`, `RuboCop`, `golangci-lint`, `mypy`). This is the one thing the pre-existing rules got inconsistent. +- `languages` names what the tool actually targets, not the ecosystem it ships in — Stylelint is `CSS`, not `JavaScript`. Leave it empty for genuinely neutral tools (`.editorconfig`). +- `config` and `ignore` keys are lowercase; path fragments end with `/`. +- Generated exclusion files are `ignore`, not `config` — `.rubocop_todo.yml` excludes offences. +- **Do not add shared config files** (`pyproject.toml`, `setup.cfg`, `tox.ini`, `package.json`). They really do hold linter config, but which tool is inside is not knowable from the path, so tagging them mislabels nearly every repository that has one. +- **Verify the convention against the tool's current official docs before adding it.** Watch for user-level paths — revive's only auto-discovered config is `$HOME/revive.toml` and was rejected for exactly this reason. A missing rule is better than a wrong one. +- If a tool's config file has no extension, give it a filetype too: map to the documented content format where the tool documents one, otherwise give it its own name. Add every new filetype name to `NON_LANGUAGE_FILETYPES` — a test asserts the classification is complete. + ## Development Commands ### Installation (Development) diff --git a/changes/2026-10-linter-detection.md b/changes/2026-10-linter-detection.md new file mode 100644 index 0000000..f16cc66 --- /dev/null +++ b/changes/2026-10-linter-detection.md @@ -0,0 +1,197 @@ +# Linter, formatter and type checker detection + +**Version:** 0.0.20 +**Date:** 2026-10-05 + +## What changed + +Panopticas now detects the configuration and exclusion files of code-quality +tooling — 30 tools, 96 exact filenames — across JavaScript, TypeScript, CSS, +Python, Go, Ruby, Java, Groovy, C#/.NET and SQL. + +Before this change the only linters detected were ESLint's flat config and +SQLFluff, and the two rules disagreed on tag shape: `eslint.config.js` tagged +`["JavaScript", "linter", "eslint", "config"]` while `.sqlfluff` tagged +`["SQLFluff", "SQL", "linter"]` — lowercase versus brand-cased tool name, with +and without a `config` role. + +## Design + +### One table, not ninety-six literal rules + +`LINTER_RULES` in `constants.py` describes each tool once: + +```python +"ESLint": { + "languages": ["JavaScript", "TypeScript"], + "roles": ["linter"], + "config": ["eslint.config.js", ..., ".eslintrc.json"], + "ignore": [".eslintignore"], +}, +``` + +`_expand_linter_rules()` writes those into the existing `METADATA_RULES` +tables at import: `config` and `ignore` into `exact_filename_rules`, +`path_contains` into `path_contains_rules`, `extensions` into +`extension_rules`. Tags are `languages + roles + [tool] + ["config"|"ignore"]`. + +This was chosen over literal dict entries for three reasons. Prettier alone +accepts 18 config filenames that all carry identical tags, so the literal form +is 18 near-duplicate lines. The tag shape becomes structurally guaranteed +rather than a convention each entry has to remember — which is precisely what +went wrong between the `eslint` and `SQLFluff` rules. And the table reads as +the tool inventory, mirroring how `AI_RULES` already works. + +Nothing downstream changed: `get_tags()` traverses `METADATA_RULES` after +expansion, so the new tags joined the vocabulary with no change to `core.py` +or `cli.py`. The existing vocabulary drift test in `tests/test_vocabulary.py` +picked up all 96 rules for free. + +### Language tags name what a tool targets + +ESLint gets `JavaScript` and `TypeScript`; Stylelint gets `CSS` and +deliberately not `JavaScript`; `.editorconfig` is language-neutral and carries +no language tag at all. Prettier is capped at its three main targets rather +than the seven parsers it ships, to keep one config file from carrying seven +language tags. + +### Three roles + +`linter`, `formatter` and `typechecker`, constrained by `LINTER_ROLES`. A tool +may hold several, and Ruff, Biome, RuboCop, Standard and SQLFluff all lint and +format. `.rubocop_todo.yml` and `.standard_todo.yml` are classified `ignore` +rather than `config` — both are generated to exclude existing offences, which +makes them the exclusion file rather than a second config. + +## Research + +Every convention was verified against the tool's current official +documentation before being added. The full evidence base, the sources, and the +rejected candidates are in `docs/linter-detection-rules.md`. + +Eleven candidates were investigated and **rejected**, each of which would have +been a wrong rule: + +- **revive** auto-discovers only `$HOME/revive.toml` — a user-level path, not + a repository artifact. +- **SpotBugs**, **PMD** and **CodeNarc** take arbitrarily-named filter and + ruleset files passed explicitly on the command line. +- **Black**, **StandardJS** and **Spotless** have no dedicated config file. +- **gofmt**, **gofumpt** and **go vet** have no configuration at all. +- **pycodestyle**'s dedicated file is user-level. +- **dprint** is plausible but its filename list could not be confirmed. +- Security scanners (Sonar, Bandit, Brakeman) are out of scope. + +`pyproject.toml`, `setup.cfg`, `tox.ini` and `package.json` genuinely hold +linter configuration, but which tool is inside is not knowable from the path, +and detection here is path-based only. Tagging them would mislabel nearly +every repository that has one, so they keep only their existing build and +dependency tags. + +## Side-effect: four missing extensions + +The work surfaced a gap unrelated to linters. `.mjs`, `.cjs`, `.mts` and +`.cts` were absent from `EXT_FILETYPES` entirely, so every file using them +reported `Unknown` and `get_languages()` missed them. They are now JavaScript +and TypeScript respectively, matching GitHub Linguist, which types all four as +`programming`. They surfaced here because config authors use them to escape +the `"type"` field in `package.json`, which is why `eslint.config.mjs` and +`.prettierrc.cjs` are common. + +## Filetype resolution + +Extensionless config dotfiles previously reported `Unknown`. They now resolve +on one principle, which reconciles the two precedents already in the codebase +(`.sqlfluff` got its own name; `setup.cfg` was mapped to `INI` "because it is +read by configparser"): **where a tool documents a single content format, map +to that format; where several are accepted, the file gets its own name.** + +`.cfg` and `.conf` are mapped by basename, not extension, because both are +used for arbitrary formats elsewhere. + +## kospex impact + +All tag changes are additions or case-only: + +- `eslint` became `ESLint`. kospex matches with `tech_type LIKE '%|tag|%'`, + and SQLite `LIKE` is case-insensitive for ASCII, so stored queries still + match. +- `.sqlfluff` kept every tag it had and gained `formatter` and `config`. + +No tag was renamed in a way that changes matching, and none was removed. A +kospex pin bump is needed only to pick up the new tags, which requires a +re-sync — `last_panopticas_version` handles that automatically. + +## Also fixed: `panopticas file` ignored basename mappings + +`panopticas file` built its "File type" row from +`get_extension_filetype(extension)`, which consults only `EXT_FILETYPES` and +never `LANGUAGE_BY_BASENAME`. Files typed by basename therefore reported +nothing there while `panopticas assess` reported their type correctly — the two +commands disagreed about the same file. + +This predates the linter work: `panopticas file go.mod` has shown a blank file +type since basename mapping was introduced, as has `setup.cfg`. Mapping +`.isort.cfg` and `staticcheck.conf` by basename (correct, because `.cfg` and +`.conf` hold arbitrary formats elsewhere) would have added two more. + +The row now reads `core.get_language(file, skip_shebang=True)`, which checks +`LANGUAGE_BY_BASENAME` before the extension table. Two details matter: + +- `skip_shebang=True` keeps the row distinct from the Shebang Language row. + Without it a shebang-only script would report `bash` as its *file* type, + conflating two rows that exist precisely to be compared. +- `get_language()` returns the string `"Unknown"` for unrecognised files, + which is normalised to `None` so the JSON contract still emits null. + +The row remains a pure table lookup, so the trust comment above it still +holds: the value is always a `constants.py` value, never raw input from the +path. + +Across the 96 linter config filenames the row went from 55/96 resolved to +96/96, and `go.mod` and `setup.cfg` are fixed as a side-effect. + +## Also fixed: unreadable paths aborted whole scans + +`check_shebang()` caught `FileNotFoundError` and `UnicodeDecodeError`, which +covers a filename that does not exist on disk — the common case, and the +reason `get_language("no-such-file.py")` has always correctly returned +`Python`. But `open()` fails in other ways that are not `FileNotFoundError`: + +| Path | Raised | Caught before? | +|---|---|---| +| missing file | `FileNotFoundError` | yes | +| a directory | `IsADirectoryError` | **no** | +| unreadable file | `PermissionError` | **no** | +| name too long | `OSError` | **no** | +| NUL byte in path | `ValueError` | **no** | + +The handler is now `except (OSError, ValueError)`. `OSError` is the base class +of every filesystem refusal; `ValueError` covers a NUL byte in the path and +keeps undecodable content handled, since `UnicodeDecodeError` is a `ValueError` +subclass. + +This mattered well beyond the type returned for one file. `identify_files()` +walks whole trees, so a single unreadable file aborted an entire +`panopticas assess` run with a `PermissionError` traceback. `count_lines()` in +the same module was already robust — it ends with a catch-all — which is what +made `check_shebang()` the last unguarded `open()` on the scanning path. + +`panopticas urls` had the identical bug one layer up: its loop caught +`UnicodeDecodeError`, added in 0.0.19 so a binary file could not kill the run, +but not `OSError`. Fixed in the CLI loop rather than in +`extract_urls_from_file()`, whose raising behaviour is documented and +deliberate — the decision to keep scanning belongs to the caller walking the +tree. + +`panopticas file` was never exposed: Click rejects an unreadable path at the +CLI boundary before the library sees it. + +## Testing + +`tests/test_linter_detection.py` — 141 tests covering table integrity, +expansion into each `METADATA_RULES` table, representative tags per language, +the config/ignore split, filetype resolution for every config filename, and +negative guards asserting each rejected convention stays undetected. + +Fixture files for the novel filetype resolutions are in `src/tests/`. diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index b2bfcb0..55bfc04 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -30,6 +30,7 @@
  • CLI
  • API
  • AI Detection
  • +
  • Linters
  • GitHub
  • PyPI