Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,16 @@ differently from the other bindings:
matrix reads the binding's `scripts/ldd-fixture.sh` for the source suffix, the
comment forms, a test file's name and two linter configs per row: one that names the
file-length rule and one that names a rule of a similar name.
- `setup/golangci.yaml`, `setup/ruff.toml` and `setup/eslint.config.ldd.mjs`, the
recommended linter configurations, are core templates rendered into every plugin,
so the generic plugin carries all three for the languages it reviews. Each enables
the linter rules the bindings' routing tables route to a design rule, at the
limits the rules name, and takes the file-length limit from `{{.FileMaxLines}}`.
Each names its linter on purpose and carries a `residue-exempt:` marker. The gap
linter's threshold line names the file and rule that would hold a limit, and the
`ldd-lint` matrix checks that the file sets that rule at the scalar's value; when
`golangci-lint`, `ruff` or `node` is on PATH, the matrix also has each tool check
its file.
- `scripts/check-repo-brain_test.sh` is a core template whose cases read one include,
`scripts/repo-brain-fixture.sh`: the fixture rows (marker, code file, second
package, sub-project) for every language the rendered gate's adapter serves. The
Expand Down Expand Up @@ -220,11 +230,11 @@ Hard residue: none. No plugin-name or command-prefix literal, golangci
reference, source-file glob, nolint directive, scalar word or retired Go idiom is
left in core/.

Soft residue by token (130 lines):
Soft residue by token (131 lines):

| Token | Lines | Files |
|---|---:|---:|
| interface | 85 | 24 |
| interface | 86 | 25 |
| Go (the word) | 13 | 8 |
| Go code fence | 12 | 1 |
| Go stdlib | 9 | 5 |
Expand All @@ -234,7 +244,7 @@ Soft residue by token (130 lines):
| race detector | 2 | 2 |
| sync. | 1 | 1 |

Soft residue by file (130 lines):
Soft residue by file (131 lines):

| File | Lines | Tokens |
|---|---:|---:|
Expand Down Expand Up @@ -273,5 +283,6 @@ Soft residue by file (130 lines):
| `rules/R2-self-validating-types.md` | 1 | 1 |
| `rules/R7-test-placement.md` | 1 | 1 |
| `scripts/ldd-scope_test.sh` | 1 | 1 |
| `setup/golangci.yaml` | 1 | 1 |
| `skills/linter-driven-development/SKILL.md` | 1 | 1 |
<!-- residue:end -->
41 changes: 32 additions & 9 deletions core/includes/scripts/ldd-lint-langs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,15 @@
# nothing. A named rule belongs to the linter:
# its limit, its counting and whether it is on
# are the repository's decision.
# lint_file_length_snippet <id> <max> the linter setting that would hold the
# limit, or the .ldd-lint.yaml entry when no
# linter rule is known: text to propose
# lint_setup_files one line per recommended config under the
# plugin's setup/: file TAB the tool that checks it
# lint_setup_check <file> <setup dir> <work dir> run that tool's own check of
# the file; exit 0 when the tool accepts it
# lint_file_length_proposal <id> what would hold the limit in the
# repository's linter: the rule, named after
# the plugin's recommended config under setup/
# that sets it, or the .ldd-lint.yaml entry when
# the language's linter has no such rule

lint_comment_style() {
case "$1" in
Expand Down Expand Up @@ -112,8 +118,10 @@ lint_file_length_rule() {
f=$(_lint_names 'file-length-limit' .golangci.yaml .golangci.yml .golangci.toml .golangci.json) \
&& printf 'revive file-length-limit in %s' "$f" ;;
typescript|javascript)
# eslint.config.ldd.mjs is the plugin's recommended fragment: a repository
# that adopts it keeps it beside eslint.config.js and imports it.
local configs=(eslint.config.js eslint.config.mjs eslint.config.cjs eslint.config.ts eslint.config.mts eslint.config.cts
.eslintrc .eslintrc.js .eslintrc.cjs .eslintrc.json .eslintrc.yml .eslintrc.yaml)
eslint.config.ldd.mjs .eslintrc .eslintrc.js .eslintrc.cjs .eslintrc.json .eslintrc.yml .eslintrc.yaml)
if f=$(_lint_names 'sonarjs/max-lines([^-a-zA-Z]|$)' "${configs[@]}"); then
printf 'sonarjs/max-lines in %s' "$f"
elif f=$(_lint_names '(^|[^-/a-zA-Z])max-lines([^-a-zA-Z]|$)' "${configs[@]}"); then
Expand All @@ -126,12 +134,27 @@ lint_file_length_rule() {
return 0
}

lint_file_length_snippet() {
lint_setup_files() {
printf 'golangci.yaml\tgolangci-lint\n'
printf 'ruff.toml\truff\n'
printf 'eslint.config.ldd.mjs\tnode\n'
}

lint_setup_check() { # <file> <setup dir> <work dir>
case "$1" in
golangci.yaml) golangci-lint config verify -c "$2/$1" ;;
ruff.toml) printf 'x = 1\n' > "$3/ldd_setup_check.py" && (cd "$3" && ruff check --no-cache --config "$2/$1" ldd_setup_check.py) ;;
eslint.config.ldd.mjs) node --check "$2/$1" ;;
*) return 2 ;;
esac
}

lint_file_length_proposal() {
case "$1" in
go) printf "revive in .golangci.yaml: - name: file-length-limit, arguments: [{max: %s, skip-comments: true, skip-blank-lines: true}]" "$2" ;;
typescript|javascript) printf "the eslint config: 'sonarjs/max-lines': ['error', { maximum: %s }]" "$2" ;;
python) printf "pyproject.toml: [tool.pylint.format] max-module-lines = %s (pylint counts every line, comments and blank lines too)" "$2" ;;
*) printf "no linter rule is known for %s; a .ldd-lint.yaml entry file_max_lines: %s would state this limit" "$1" "$2" ;;
go) printf "a setting to propose, never to add on your own: revive file-length-limit, as the plugin's setup/golangci.yaml sets it" ;;
typescript|javascript) printf "a setting to propose, never to add on your own: sonarjs/max-lines, as the plugin's setup/eslint.config.ldd.mjs sets it" ;;
python) printf "ruff has no file-length rule, so this script holds the limit; a .ldd-lint.yaml entry file_max_lines: <n> states another" ;;
*) printf "no linter rule is known for %s; a .ldd-lint.yaml entry file_max_lines: <n> states another limit" "$1" ;;
esac
}
# ================== end linter table ==================
17 changes: 13 additions & 4 deletions core/scripts/ldd-lint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,16 @@ trap 'rm -f "$SCOPE" "$INVENTORY"' EXIT
list_candidates | by_language > "$SCOPE"
if [[ -n "$FILES_LIST" ]]; then list_repo | by_language > "$INVENTORY"; else cp "$SCOPE" "$INVENTORY"; fi

# dir_of <path> — the directory part of a relative path, "." for a file at
# the root. A function, because bash 3.2 misreads a case pattern's ")" inside
# a process substitution.
dir_of() {
case "$1" in
*/*) printf '%s\n' "${1%/*}" ;;
*) printf '.\n' ;;
esac
}

# measured <path> — exit 0 when the file counts for this language: not under
# an excluded directory, not a test, not generated. Every path the script
# hands to find, head or awk starts with "./", and a directory is cut from a
Expand Down Expand Up @@ -193,7 +203,7 @@ run_file_length() { # <id> — every measured file of that language
say "threshold: $id file length — set by $OVERRIDE_FILE ($max code lines)"
else
max="$DEFAULT_FILE_MAX_LINES"; src="profile default"
say "threshold: $id file length — profile default ($max code lines); the repository's linter does not hold it; a setting to propose, never to add on your own: $(lint_file_length_snippet "$id" "$max")"
say "threshold: $id file length — profile default ($max code lines); the repository's linter does not hold it; $(lint_file_length_proposal "$id")"
fi
while IFS=$'\t' read -r _ f; do
measured "$f" || continue
Expand All @@ -217,7 +227,7 @@ run_package_size() { # <id> — every directory that holds a measured file of th
awk -F'\t' -v id="$id" '$1 == id { print $2 }' "$INVENTORY" | while IFS= read -r f; do
[[ -n "$skip" ]] && printf '%s\n' "$f" | grep -qE -- "$skip" && continue
measured "$f" || continue
case "$f" in */*) printf '%s\n' "${f%/*}" ;; *) printf '.\n' ;; esac
dir_of "$f"
done | LC_ALL=C sort | uniq -c | awk '{ n = $1; sub(/^[ \t]*[0-9]+[ \t]/, ""); print $0 "\t" n }' > "$counts"
while IFS= read -r d; do
n=$(awk -F'\t' -v d="$d" '$1 == d { print $2; exit }' "$counts")
Expand All @@ -228,8 +238,7 @@ run_package_size() { # <id> — every directory that holds a measured file of th
finding "$d" "$n source files (warning at $warn, $src)"
fi
done < <(awk -F'\t' -v id="$id" '$1 == id { print $2 }' "$SCOPE" | while IFS= read -r f; do
measured "$f" || continue
case "$f" in */*) printf '%s\n' "${f%/*}" ;; *) printf '.\n' ;; esac
measured "$f" && dir_of "$f"
done | LC_ALL=C sort -u)
rm -f "$counts"
}
Expand Down
58 changes: 57 additions & 1 deletion core/scripts/ldd-lint_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ fill_dir() {

{{include "scripts/ldd-fixture.sh"}}

{{include "scripts/ldd-lint-langs.sh"}}

# ========================= cases =========================
run_row_cases() { # runs every case against the current row

Expand Down Expand Up @@ -146,7 +148,16 @@ finish
begin "the plugin's default names a linter setting to propose, never to add"
write_src "src/a$FX_EXT" 1
run_lint file-length
expect_exit 0 && expect_has "profile default ($MAX_LINES code lines)" && expect_has "a setting to propose, never to add on your own: " && ok
SETUP_DIR="$(dirname "$LINT")/../setup"
if [[ -n "$FX_SETUP_FILE" ]]; then
expect_exit 0 && expect_has "profile default ($MAX_LINES code lines)" \
&& expect_has "a setting to propose, never to add on your own: " \
&& expect_has "$FX_LENGTH_RULE, as the plugin's setup/$FX_SETUP_FILE sets it" \
&& { grep -q -- "$FX_LENGTH_RULE" "$SETUP_DIR/$FX_SETUP_FILE" && grep -q -- "$MAX_LINES" "$SETUP_DIR/$FX_SETUP_FILE" || bad "setup/$FX_SETUP_FILE does not set $FX_LENGTH_RULE at $MAX_LINES"; } \
&& grep -q -- "$FX_LENGTH_RULE" "$SETUP_DIR/$FX_SETUP_FILE" && ok
else
expect_exit 0 && expect_has "profile default ($MAX_LINES code lines)" && expect_has "a .ldd-lint.yaml entry file_max_lines" && ok
fi
finish

begin "a file-length rule in the linter config hands file length to the linter"
Expand Down Expand Up @@ -178,6 +189,39 @@ run_lint file-length
expect_exit 0 && expect_not "held by " && expect_has "src/over$FX_EXT: $((MAX_LINES + 1)) code lines" && ok
finish

begin "the recommended config enables every rule the routing table routes to a design rule"
if [[ -z "$FX_ROUTED_FILE" ]]; then skip "this plugin's routing table names no linter rule"; finish
else
table=$(grep -l '^| Linter failure | Route |' "$(dirname "$LINT")"/../skills/refactoring/*.md | head -1)
missing=""; checked=0
while IFS= read -r id; do
case " $FX_ROUTED_SKIP " in *" $id "*) continue ;; esac
checked=$((checked + 1))
case "$id" in
*'*') re="(^|[^A-Za-z0-9/@-])${id%\*}" ;;
*) re="(^|[^A-Za-z0-9/@-])${id}([^A-Za-z0-9/-]|\$)" ;;
esac
grep -qE -- "$re" "$(dirname "$LINT")/../setup/$FX_ROUTED_FILE" || missing="$missing $id"
done < <(awk -F'|' '
/^\| Linter failure \| Route \|/ { t = 1; next }
t && /^\|---/ { next }
t && /^\|/ { if ($3 ~ /rules\/R/) { c = $2; while (match(c, /`[^`]+`/)) { print substr(c, RSTART + 1, RLENGTH - 2); c = substr(c, RSTART + RLENGTH) } } next }
t { t = 0 }' "$table" | grep -E -- "$FX_RULE_ID_RE" | LC_ALL=C sort -u)
{ [[ -n "$table" ]] && (( checked > 0 )) || bad "no routed rule id found in the routing table"; } \
&& { [[ -z "$missing" ]] || bad "setup/$FX_ROUTED_FILE lacks:$missing"; } && [[ -z "$missing" ]] && ok
finish
fi

begin "the recommended fragment, kept as its own file and imported, hands file length to the linter"
if [[ -z "$FX_ADOPTED_CONFIG" ]]; then skip "this language's setup is copied into the linter's own config"; finish
else
cp "$(dirname "$LINT")/../setup/$FX_ADOPTED_CONFIG" "$REPO/$FX_ADOPTED_CONFIG"
write_src "src/over$FX_EXT" $((MAX_LINES + 50))
run_lint file-length
expect_exit 0 && expect_has "held by " && expect_has "in $FX_ADOPTED_CONFIG" && expect_no_finding && ok
finish
fi

begin ".ldd-lint.yaml sets the limit when the linter holds none"
printf 'file_max_lines: 100 # this repository\n' > "$REPO/.ldd-lint.yaml"
write_src "src/a$FX_EXT" 101
Expand Down Expand Up @@ -316,6 +360,18 @@ for opt in --root --files --glob --test-re; do
finish
done

# --- the recommended configs under setup/, checked by their own linters when
# the machine has them ---
SETUP_DIR="$(dirname "$LINT")/../setup"

while IFS=$'\t' read -r setup_file setup_tool; do
begin "setup/$setup_file passes its own linter's check"
if ! command -v "$setup_tool" >/dev/null 2>&1; then skip "$setup_tool is not on PATH"; finish; continue; fi
OUT=$(lint_setup_check "$setup_file" "$SETUP_DIR" "$REPO" 2>&1); CODE=$?; ERR=""
expect_exit 0 && ok
finish
done < <(lint_setup_files)

# ========================= summary =========================
echo
echo "ldd-lint_test: $pass passed, $failed failed, $skipped skipped"
Expand Down
115 changes: 115 additions & 0 deletions core/setup/eslint.config.ldd.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Recommended ESLint flat-config fragment of the linter-driven-development
// plugins: every rule the plugin's routing table routes to one of the twelve
// design rules, at the limits the rules name. Each rule line says which rule its
// failures feed. The plugin never writes this file into a repository: the
// repository's owners copy it, or the parts they want, beside eslint.config.js
// and spread it before their own entries. Later entries win, so every limit the
// repository's config already sets stays the repository's:
//
// import ldd from './eslint.config.ldd.mjs'
// export default [...ldd, ...yourConfig]
Comment thread
buzzdan marked this conversation as resolved.
//
// residue-exempt: an ESLint configuration names its plugins and rules on purpose.
// It needs eslint-plugin-sonarjs, eslint-plugin-react, eslint-plugin-react-hooks,
// eslint-plugin-promise, eslint-plugin-import and typescript-eslint; a repository
// that does not use one of them leaves its rules out. The @typescript-eslint rules
// below need type information: the repository's config sets typescript-eslint's
// parser with parserOptions.projectService for these files, or ESLint stops with
// an error. Limits a repository already sets stay the repository's: the linter is
// the authority for every threshold it holds. Rules this file leaves out (style,
// imports, formatting) are the repository's own choice, and so is
// no-restricted-syntax on import.meta.env outside the config module (R8): the
// config module's path is the repository's.
//
// Adopt it on an existing codebase with ESLint's bulk suppressions: run
// `npx eslint --suppress-all` once to record today's violations, so only new ones
// fail; `npx eslint --prune-suppressions` drops the entries that are fixed.

import importPlugin from 'eslint-plugin-import'
import promise from 'eslint-plugin-promise'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import sonarjs from 'eslint-plugin-sonarjs'
import tseslint from 'typescript-eslint'

export default [
{
files: ['**/*.{ts,tsx}'],
plugins: {
'@typescript-eslint': tseslint.plugin,
import: importPlugin,
promise,
react,
'react-hooks': reactHooks,
sonarjs,
},
rules: {
// R3 storifying
'sonarjs/cognitive-complexity': ['error', 15],
'sonarjs/cyclomatic-complexity': ['error', { threshold: 10 }],
'sonarjs/expression-complexity': ['error', { max: 5 }],
// R3's limits: more than 50 lines or more than 2 nesting levels
'sonarjs/max-lines-per-function': ['error', { maximum: 50 }],
'sonarjs/nested-control-flow': ['error', { maximumNestingLevel: 2 }],
'sonarjs/no-nested-conditional': 'error',
'sonarjs/no-nested-functions': 'error',
'sonarjs/elseif-without-else': 'error',
'react/no-unstable-nested-components': 'error',
// R1 primitive obsession
'max-params': ['error', 4],
'sonarjs/no-duplicate-string': ['error', { threshold: 3 }],
'sonarjs/max-union-size': ['error', { threshold: 4 }],
'sonarjs/no-identical-functions': 'error',
'react/no-array-index-key': 'error',
'no-magic-numbers': ['error', {
ignore: [-1, 0, 1, 2],
ignoreArrayIndexes: true,
ignoreDefaultValues: true,
ignoreClassFieldInitialValues: true,
detectObjects: false,
}],
// R2 self-validating types: parse at the boundary
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/no-unsafe-argument': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/no-unnecessary-condition': 'error',
// R5 vertical slice: one juicy module per file; counts code lines only
'sonarjs/max-lines': ['error', { maximum: {{.FileMaxLines}} }],
'react/no-multi-comp': ['error', { ignoreStateless: false }],
// R8 no globals
'import/no-mutable-exports': 'error',
// R10 concurrency safety
'react-hooks/exhaustive-deps': 'error',
'react-hooks/set-state-in-effect': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'promise/catch-or-return': 'error',
// R11 conditional dispatch
'@typescript-eslint/switch-exhaustiveness-check': 'error',
'sonarjs/no-nested-switch': 'error',
'sonarjs/max-switch-cases': 'error',
'sonarjs/no-small-switch': 'error',
// R12 mutation discipline
'no-param-reassign': ['error', { props: true }],
'sonarjs/prefer-read-only-props': 'error',
'react/no-direct-mutation-state': 'error',
},
},
{
// Tests are long and repeat literals by design.
files: ['**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],
rules: {
'sonarjs/max-lines-per-function': 'off',
'sonarjs/no-duplicate-string': 'off',
'sonarjs/no-nested-functions': 'off',
'max-params': 'off',
'no-magic-numbers': 'off',
'react/no-multi-comp': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
]
Loading
Loading