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
6 changes: 5 additions & 1 deletion Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ tasks:
|| { echo "lint-core - the Residue section of core/README.md was rewritten; commit it"; exit 1; }

test-gate:
desc: Run the fixture matrices of the generated scripts β€” the repo-brain gate, the review's scope bundle and its detection pass β€” for every plugin
desc: Run the fixture matrices of the generated scripts β€” the repo-brain gate, the review's scope bundle, its detection pass and the gap linter β€” for every plugin
cmds:
- bash go-linter-driven-development/scripts/check-repo-brain_test.sh
- bash linter-driven-development/scripts/check-repo-brain_test.sh
Expand All @@ -45,6 +45,10 @@ tasks:
- bash linter-driven-development/scripts/ldd-detect_test.sh
- bash python-linter-driven-development/scripts/ldd-detect_test.sh
- bash ts-react-linter-driven-development/scripts/ldd-detect_test.sh
- bash go-linter-driven-development/scripts/ldd-lint_test.sh
- bash linter-driven-development/scripts/ldd-lint_test.sh
- bash python-linter-driven-development/scripts/ldd-lint_test.sh
- bash ts-react-linter-driven-development/scripts/ldd-lint_test.sh

test-gen:
desc: Unit tests and lint for the generator
Expand Down
33 changes: 25 additions & 8 deletions core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,16 @@ Go `text/template` with the default delimiters. Core files use only two construc
- `{{.Plugin}}`, `{{.Lang}}`, `{{.CmdPrefix}}`, `{{.SrcGlob}}`, `{{.TestGlob}}`,
`{{.ProjectMarker}}`, `{{.Nolint}}`, `{{.CommentPrefix}}`, `{{.DefaultTest}}`,
`{{.DefaultLint}}`, `{{.DefaultLintFix}}`, `{{.Nil}}`, `{{.Task}}`, `{{.DocForm}}`,
`{{.DocComment}}`, `{{.SrcExt}}`, `{{.Unexported}}` β€” scalars from `profile.yaml`.
The last six are single words core prose uses as common nouns (the missing value, a
unit of concurrent work, the documentation comment form, the source-file suffix,
the visibility of a non-public symbol); which sentences use them, and which Go
idioms are includes instead, is recorded in `docs/language-residue.md`. A template that names a scalar the profile does not
define fails to render.
`{{.DocComment}}`, `{{.SrcExt}}`, `{{.Unexported}}`, `{{.FileMaxLines}}`,
`{{.PackageWarnFiles}}`, `{{.PackageMaxFiles}}` β€” scalars from `profile.yaml`.
Six are single words core prose uses as common nouns (the missing value, a unit of
concurrent work, the documentation comment form, the source-file suffix, the
visibility of a non-public symbol); which sentences use them, and which Go idioms
are includes instead, is recorded in `docs/language-residue.md`. The last three
are the gap linter's thresholds, positive integers with the warning below the
limit, so the prose that names a limit and the script that applies it cannot
disagree. A template that names a scalar the profile does not define fails to
render.
- `{{include "rules/R1/canonical-example.md"}}` β€” the body of that file under
`lang/<lang>/`, with exactly one trailing newline removed. When the binding has no
file of that name, the body comes from `core/includes/` under the same path: the
Expand Down Expand Up @@ -128,8 +132,8 @@ unchanged. Promotion into `core/` happens when a second language needs the text:
own that says so and points at the repository's test utilities; the Python binding
ships a short pytest catalogue in the same shape, and the ts-react binding a
Vitest, React Testing Library and MSW catalogue.
- `README.md`, `CHANGELOG.md`, `.claude-plugin/plugin.json`, `hooks/`: describe or
configure the Go plugin itself.
- `README.md`, `CHANGELOG.md`, `.claude-plugin/plugin.json`: describe or configure
the Go plugin itself.

Three promoted pieces carry a note here because the Go plugin renders them
differently from the other bindings:
Expand Down Expand Up @@ -173,6 +177,19 @@ differently from the other bindings:
by the language of each file, a group goes to the plugin installed for its
language, else to this plugin's block, else to the generic plugin, else it is
excluded and every file of it named.
- `scripts/ldd-lint.sh` and `scripts/ldd-lint_test.sh`, the gap linter and its
fixture matrix, are core templates. The linter measures lines per source file and
source files per directory, where the repository's linter does not hold them. It
reads the language table and the language block, and a third include with a core
default, `scripts/ldd-lint-langs.sh`, the linter table: per language id, the
comment syntax, the files a directory count leaves out, and whether the
repository's own linter config names a file-length rule. A named rule belongs to
the linter, so the script reads no limit out of a config and copies no linter's way
of counting. The table is the one place core names linters, and its
`residue-exempt:` marker says so. The
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.
- `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
2 changes: 1 addition & 1 deletion core/includes/rules/R5/falsifying-questions.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
The inverse shape is the god file, and it needs no feature noun:
`xargs wc -l < files.txt | sort -n | tail -6` over the scope's file list names the
longest files; check each against the repository's file-length limit, when its
linter sets one.
linter sets one, else {{.FileMaxLines}} code lines.
Violation: the same feature living in `handlers/` and `services/` (etc.) β€” it is
horizontally scattered; or one file over the length limit, or with a
`{{.Nolint}}` directive silencing the limit, standing in for the whole slice β€”
Expand Down
137 changes: 137 additions & 0 deletions core/includes/scripts/ldd-lint-langs.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# ==================== linter table ====================
# What the gap linter needs to know per language id: how a comment is written,
# which source files a directory count leaves out, and whether the
# repository's own linter holds the file-length limit. The same table is
# rendered into every plugin, so two plugins measure one file the same way.
# residue-exempt: the table names every language's linter and config file on
# purpose, the same in every rendering.
#
# lint_comment_style <id> c (// and /* */), d (also /+ +/) or hash (#)
# lint_strip_comments <style> <file> the file with every comment removed and
# string literals kept, one output line per
# input line: a comment-only line prints empty.
# Styles: c, d, hash (# and triple-quoted
# strings), ini (# and a leading ;)
# lint_package_skip_re <id> ERE over a relative path: source files a
# directory count leaves out (an index module,
# a package marker, generated stubs); "" for none
# lint_file_length_rule <id> "<rule> in <config file>" when a linter config
# at the repository root names a file-length
# rule outside a comment, else
# 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_comment_style() {
case "$1" in
python|ruby|shell) printf 'hash' ;;
d) printf 'd' ;;
*) printf 'c' ;;
esac
}

lint_package_skip_re() {
case "$1" in
go) printf '%s' '(_gen|\.pb)\.go$' ;;
python) printf '%s' '(^|/)(__init__|conftest)\.py$|_pb2(_grpc)?\.py$' ;;
typescript|javascript) printf '%s' '(^|/)index\.[cm]?[jt]sx?$|\.stories\.[cm]?[jt]sx?$' ;;
esac
return 0
}

# LINT_STRIP_AWK walks each line one character at a time. A quote opens a
# string that a comment marker cannot end; single and double quotes close at
# the end of the line, a backtick string (c and d) and a triple-quoted string
# (hash) run on. A block comment may open after code and run on. The awk uses
# substr and string compares only, so mawk, gawk and BSD awk read it alike.
LINT_STRIP_AWK='
BEGIN { bq = sprintf("%c", 96); sq = sprintf("%c", 39); dq = "\"" }
{
line = $0; out = ""; n = length(line); i = 1
if (q == dq || q == sq) q = ""
while (i <= n) {
if (inblock) {
if (substr(line, i, length(closer)) == closer) { inblock = 0; i += length(closer); out = out " "; continue }
i++; continue
}
ch = substr(line, i, 1)
if (q != "") {
if (ch == "\\" && (q == dq || q == sq)) { out = out substr(line, i, 2); i += 2; continue }
if (substr(line, i, length(q)) == q) { out = out q; i += length(q); q = ""; continue }
out = out ch; i++; continue
}
if (style == "hash" || style == "ini") {
if (ch == "#") break
if (style == "ini" && ch == ";" && out ~ /^[ \t]*$/) break
three = substr(line, i, 3)
if (style == "hash" && (three == dq dq dq || three == sq sq sq)) { q = three; out = out three; i += 3; continue }
} else {
two = substr(line, i, 2)
if (two == "//") break
if (two == "/*" || (style == "d" && two == "/+")) { inblock = 1; closer = (two == "/+") ? "+/" : "*/"; out = out " "; i += 2; continue }
if (ch == bq) { q = bq; out = out ch; i++; continue }
}
if (ch == dq || ch == sq) { q = ch; out = out ch; i++; continue }
out = out ch; i++
}
print out
}
'

lint_strip_comments() { # <style> <file>
awk -v style="$1" "$LINT_STRIP_AWK" "$2"
}

# _lint_config_style <config file> β€” how that config format writes a comment
_lint_config_style() {
case "$1" in
*.yaml|*.yml|*.toml) printf 'hash' ;;
*.cfg|*.ini|.pylintrc|pylintrc) printf 'ini' ;;
*) printf 'c' ;;
esac
}

# _lint_names <ERE> <file>... β€” print the first file whose text outside its
# comments matches the pattern
_lint_names() {
local re="$1" f; shift
for f in "$@"; do
[[ -f "$f" ]] || continue
lint_strip_comments "$(_lint_config_style "$f")" "./$f" 2>/dev/null | grep -qE -- "$re" && { printf '%s' "$f"; return 0; }
done
return 1
}

lint_file_length_rule() {
local f
case "$1" in
go)
f=$(_lint_names 'file-length-limit' .golangci.yaml .golangci.yml .golangci.toml .golangci.json) \
&& printf 'revive file-length-limit in %s' "$f" ;;
typescript|javascript)
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)
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
printf 'max-lines in %s' "$f"
fi ;;
python)
f=$(_lint_names '^[[:space:]]*max-module-lines[[:space:]]*=' pyproject.toml .pylintrc pylintrc setup.cfg tox.ini) \
&& printf 'pylint max-module-lines in %s' "$f" ;;
esac
return 0
}

lint_file_length_snippet() {
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" ;;
esac
}
# ================== end linter table ==================
18 changes: 11 additions & 7 deletions core/includes/skills/refactoring/file-and-package-routing.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
**A file-length limit (the linter's, or ~450 lines when it has none):**
**A file over the file-length limit** β€” the repository linter reports it where the linter has a file-length rule; elsewhere a file over {{.FileMaxLines}} code lines is one, and `bash <this skill dir>/../../scripts/ldd-lint.sh file-length --root .` lists them, or names the linter rule that holds the limit:

| File pattern | Action |
|---|---|
Expand All @@ -7,15 +7,19 @@
| Long functions, few types | Storify β†’ extract functions (R3) |

<package_decomposition>
**Package-size zones** β€” count the non-test, non-generated source files in one
directory:
**Package-size zones** β€” source files per directory:

```
find <dir> -maxdepth 1 -type f -name '{{.SrcGlob}}' -not -name '*{{.TestGlob}}' | wc -l
bash <this skill dir>/../../scripts/ldd-lint.sh package-size --root .
```

≀7 green β€” fine. 8–12 yellow β€” design review *before the next file lands*. β‰₯13 red β€”
**must decompose**. Either zone: run the 3-step design review under "Package
decomposition", printed with this range (it is a *design* review β€” missing domain types are the
The gap linter counts the non-test, non-generated source files of each directory;
a `.ldd-lint.yaml` at the repository root sets other sizes, and the first line of
its output says which sizes applied.

Below {{.PackageWarnFiles}} files green β€” fine. {{.PackageWarnFiles}} or more yellow β€” design review
*before the next file lands*. {{.PackageMaxFiles}} or more red β€” **must decompose**. Either
zone: run the 3-step design review under "Package decomposition", printed with this
range (it is a *design* review β€” missing domain types are the
disease, file count the symptom). Invoke @code-designing to validate extracted types.
</package_decomposition>
Loading
Loading