Normalized linting, formatting, and CI for eustasy applications. Covers HTML, Markdown, JS/TS, CSS/SCSS, Python, PHP, XML, JSON, SQL, YAML, and Shell.
Powered by Qlty.
git clone https://github.com/eustasy/.normal.git &&
cp .normal/install.sh install.sh &&
chmod 755 install.sh &&
./install.sh &&
rm install.sh &&
rm -Rf .normal
git add -A
git commit -m "Install eustasy/.Normal 4.0beta16"
git push| File | Purpose |
|---|---|
.editorconfig |
Universal editor settings (indent, line length) |
.gitattributes |
LF line-ending normalization (only if absent — see below) |
.vscode/settings.json |
Format-on-save + recommended extensions |
.qlty/qlty.toml |
Qlty orchestration: plugins, smells, exclusions |
.qlty/configs/oxlint.json |
JS/TS linter (OXC) |
.qlty/configs/.stylelintrc.json |
CSS/SCSS linter |
.qlty/configs/.markdownlint.json |
Markdown linter |
.qlty/configs/.htmlvalidate.json |
HTML linter (void-style: selfclosing) |
.qlty/configs/ruff.toml |
Python lint + format |
.qlty/configs/.php-cs-fixer.dist.php |
PHP formatter |
.qlty/configs/.sqlfluff |
SQL lint + format (default dialect: mysql) |
.qlty/configs/.prettierrc.json |
Prettier config (JS/TS, JSON) |
.qlty/configs/.shellcheckrc |
Shell linter (source resolution; dialect from shebangs) |
.github/dependabot.yml |
Automated dependency updates (only ecosystems whose manifests are present) |
.github/zizmor.yml |
zizmor policy granting eustasy/* a ref-pin exemption |
.github/actionlint.yaml |
actionlint config; scopes the if: false deactivation pattern out of if-cond |
.github/workflows/security.yml |
Security scanning (every push) |
.github/workflows/actions.yml |
Workflow linting with actionlint + zizmor (always installed) |
.github/workflows/{language}.yml |
Per-language lint + format CI |
.github/workflows/test-{language}.yml |
Test + coverage CI (activate by removing if: false) |
.github/workflows/type-{language}.yml |
Typecheck CI — TS (tsc), Python (mypy) (activate by removing if: false) |
The CI workflows trigger on pushes and pull requests to your default branch. GitHub
Actions does not allow referencing the default branch dynamically in the on: trigger,
so the branch name has to be written into every branches: filter — install.sh does
this for you when it copies each workflow into place.
It reports what it picked:
Default branch for workflow triggers: cf-pages
Detection order, most explicit first:
NORMAL_DEFAULT_BRANCH, if set.origin's recorded HEAD — the remote's default branch.- The branch currently checked out.
main.
Steps 2 and 3 cover a normal clone. Set the variable if the answer is wrong — most
often because the clone's origin/HEAD is stale, or because the repo has no remote
yet:
NORMAL_DEFAULT_BRANCH=trunk ./install.shgit remote set-head origin --auto refreshes a stale origin/HEAD if you would
rather fix the cause.
The deployed workflows are thin callers. The linting logic lives in composite
actions in this repository, referenced by the moving v4 tag, so a fix reaches
every project as soon as the tag moves.
Anything a project needs to vary lives in the caller: the trigger and its path
filters, runs-on, the language matrix, service containers, and any extra steps.
Each language with a toolchain publishes three actions — <language>/setup,
<language>/lint and <language>/test. The flat <language> action runs setup
and lint in sequence, which is what the lint workflows call. Reach for the parts
when a job needs something in between. The test-* workflows already do, so
project-specific steps can sit between setup and the test run:
- uses: eustasy/.normal/php/setup@v4
with:
php-version: ${{ matrix.php-version }}
coverage: pcov
- name: Load database fixtures
run: ./scripts/seed-test-db.sh
- uses: eustasy/.normal/php/test@v4
with:
coverage-token: ${{ secrets.QLTY_COVERAGE_TOKEN }}Because the actions are referenced by tag rather than by commit hash, every
project also receives .github/zizmor.yml, which grants the eustasy
namespace a ref-pin exemption. Without it zizmor fails each caller at high
severity. Everything else keeps zizmor's stricter hash-pinning default.
| Plugin | Language | Lint | Format |
|---|---|---|---|
| oxc | JS / TS | Yes | — |
| knip | JS / TS | Yes | — |
| prettier | JS/TS, JSON | — | Yes |
| stylelint | CSS / SCSS | Yes | Yes |
| markdownlint | Markdown | Yes | — |
| html-validate | HTML | Yes | — |
| xmllint | XML | Yes | — |
| ruff | Python | Yes | Yes |
| phpstan | PHP | Yes | — |
| php-cs-fixer | PHP | — | Yes |
| sqlfluff | SQL | Yes | Yes |
| shellcheck | Shell | Yes | — |
| shfmt | Shell | — | Yes |
| yamllint | YAML | Yes | — |
| actionlint | YAML (GH Actions) | Yes | — |
| zizmor | YAML (GH Actions) | Yes | — |
| dotenv-linter | .env |
Yes | — |
| gitleaks | All | Yes | — |
| trivy | All | Yes | — |
| osv-scanner | All | Yes | — |
.gitattributes pins the tree to LF (* text=auto eol=lf), the companion to
.editorconfig's end_of_line = lf. Together they stop the CRLF↔LF churn that
otherwise shows up as whole-file diffs on unchanged files when a repo is edited
from both Windows and WSL/Linux.
Unlike every other file install.sh deploys, .gitattributes is installed only
when the project doesn't already have one. It commonly carries project-specific
rules .Normal can't reconstruct — Git LFS filter=lfs lines,
linguist-generated/linguist-vendored overrides, custom merge drivers — and
overwriting those breaks LFS checkouts. If you already have one, install.sh says
so and leaves it alone; merge in the rules from
configs/.gitattributes
by hand.
If the repo already has CRLF committed, installing the file changes nothing on its own. Normalize the tree once:
git add --renormalize .
git commit -m "Normalize line endings to LF"Shell scripts are formatted by shfmt at 2 spaces, pinned on the command line in
.qlty/qlty.toml:
[plugins.definitions.shfmt.drivers.format]
script = "shfmt -w -s -i 2 ${target}"shfmt has no config file of its own. qlty's built-in driver passes no indentation
flag, so shfmt discovers .editorconfig at format time and falls back to its own
default — tabs — when it finds none. That is what made shell indentation flip
between tabs and spaces from machine to machine, and extensionless scripts never
matched an .editorconfig glob in the first place. Any formatting flag disables
shfmt's .editorconfig lookup, so -i 2 both pins the width and removes the
discovery step.
.editorconfig carries the same value in [*.{sh,bash,ksh,zsh,bats}] for editors.
Change both or neither — they are not read by the same tool. To use 4 spaces
instead, set -i 4 in .qlty/qlty.toml and indent_size/tab_width to 4 in
.editorconfig. Tabs are -i 0 plus indent_style = tab.
VS Code deliberately has "[shellscript]": { "editor.formatOnSave": false } so an
ad-hoc shell-format extension on one machine can't reindent on save and fight CI.
Run qlty fmt to apply the real formatting.
.qlty/configs/.shellcheckrc sets external-sources=true and
source-path=SCRIPTDIR so sourced files are followed and resolved against the
sourcing script rather than the caller's working directory. It deliberately does
not set shell=: that directive overrides every shebang in the repo and would
lint #!/bin/sh scripts as bash, hiding real portability bugs. Give shebang-less
fragments a per-file # shellcheck shell=bash directive instead.
ShellCheck never rewrites files — it has no say in indentation. See Shell Indentation above.
The default SQL dialect is mysql. To override it in a project, add or edit
.qlty/configs/.sqlfluff after running install.sh:
[sqlfluff]
dialect = postgresAny dialect supported by SQLFluff is valid (e.g. postgres, sqlite, tsql, ansi).
git clone https://github.com/eustasy/.normal.git &&
cp .normal/install.sh install.sh &&
chmod 755 install.sh &&
./install.sh &&
rm install.sh &&
rm -Rf .normal;
git add -A;
git commit -m "Migrate to eustasy/.Normal 4.0beta16 (Qlty)";
git pushThen:
- Connect the repo on qlty.sh (replaces Code Climate)
- Add
QLTY_COVERAGE_TOKENto repo secrets (Settings → Secrets → Actions) - Remove the
if: falseguard from whichever test/typecheck workflows apply to the repo
install.sh will automatically delete the old .codeclimate.yml, .eslintrc.json,
.stylelintrc.json, .mdlrc, and related files.
Files are kept out of scope two ways:
- Git. Gitignored paths are never linted. qlty respects
.gitignore, and the JSON, JS, XML, and HTML workflows go further by feeding onlygit ls-filesoutput (tracked files) to their tool — so build output, dependencies, and machine-managed files stay out without needing a pattern. - qlty
exclude_patterns. Tracked files matching these are skipped by every qlty-driven plugin:
**/*.min.* **/*.pack.* **/*.custom.* **/_libs/**
**/vendor/** **/node_modules/** **/dist/** **/build/**
**/out/** **/coverage/** **/__pycache__/** **/.pytest_cache/**
xmllint and html-validate run outside qlty, so they obey git tracking only —
the patterns above do not apply to XML or HTML. To drop an XML or HTML file
from CI, add it to .gitignore.