diff --git a/.commitlintrc.json b/.commitlintrc.json deleted file mode 100644 index d3d7f0cd..00000000 --- a/.commitlintrc.json +++ /dev/null @@ -1 +0,0 @@ -{ "extends": ["@commitlint/config-conventional"] } diff --git a/.deepsource.toml b/.deepsource.toml index 3aadf76b..1844104e 100644 --- a/.deepsource.toml +++ b/.deepsource.toml @@ -1,4 +1,10 @@ version = 1 +exclude_patterns = [ + "doc/_site/**", + "lib/**", + "vendor/**", + "pnpm-lock.yaml", +] [[analyzers]] name = "test-coverage" diff --git a/.devcontainer/.eslintrc.json b/.devcontainer/.eslintrc.json deleted file mode 100644 index a7205988..00000000 --- a/.devcontainer/.eslintrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "rules": { - "jsonc/no-comments": 0 - } -} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ec082345..585886ee 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -43,10 +43,10 @@ "extensions": [ // https://marketplace.visualstudio.com/items?itemName=aaron-bond.better-comments "aaron-bond.better-comments", + // https://marketplace.visualstudio.com/items?itemName=biomejs.biome + "biomejs.biome", // https://marketplace.visualstudio.com/items?itemName=DavidAnson.vscode-markdownlint "davidanson.vscode-markdownlint", - // https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint - "dbaeumer.vscode-eslint", // https://marketplace.visualstudio.com/items?itemName=dprint.dprint "dprint.dprint", // https://marketplace.visualstudio.com/items?itemName=editorconfig.editorconfig diff --git a/.ecrc.json b/.ecrc.json index febb14bc..95adfe15 100644 --- a/.ecrc.json +++ b/.ecrc.json @@ -2,21 +2,16 @@ "Verbose": false, "Debug": false, "IgnoreDefaults": true, - "SpacesAftertabs": false, + "SpacesAfterTabs": false, "NoColor": false, "Exclude": [ - "(.devcontainer)", - "(.github)", - "(.vscode)", - "(doc)", - "(lib)", + "doc/_site/", + "lib/", "vendor/", - "\\.md$", - "\\.mjs$", + "LICENSE/", "pnpm-lock.yaml", "Gemfile.lock", - ".markdownlint.jsonc", - "LICENSE-*" + ".markdownlint.jsonc" ], "AllowedContentTypes": [], "PassedFiles": [], diff --git a/.editorconfig b/.editorconfig index cff605b0..a19a134a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,18 +3,15 @@ root = true # Base ruleset for all files. [*] -# Non-configurable Prettier behaviors. charset = utf-8 -insert_final_newline = true -# Configurable Prettier behaviors (change them here if Prettier config differs). end_of_line = lf indent_size = 2 indent_style = space -max_line_length = 80 -# Caveat: Prettier won’t trim trailing whitespace inside template strings, but -# your editor might. +insert_final_newline = true trim_trailing_whitespace = true -[**.md] -# Trailing whitespace is significant in Markdown. -trim_trailing_whitespace = false +[*.md] +# Markdown decides its own indentation: a `1. ` list item continues at three +# columns, which is not a multiple of anything above. Left to the linters, +# which read the document as Markdown rather than as lines. +indent_size = unset diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 7a1613b8..00000000 --- a/.eslintrc.json +++ /dev/null @@ -1,237 +0,0 @@ -{ - "root": true, - "env": { - "node": true, - "browser": true - }, - "parser": "@babel/eslint-parser", - "parserOptions": { - "babelOptions": { - "configFile": "./babel.config.json" - }, - "ecmaFeatures": { - "globalReturn": false, - "jsx": false - }, - "project": ["./tsconfig.eslint.json"], - "sourceType": "module" - }, - "plugins": [ - "json-schema-validator", - "unicorn", - "import", - "wix-editor", - "prettier", - "simple-import-sort", - "promise" - ], - "extends": [ - "eslint:recommended", - "plugin:import/recommended", - "plugin:unicorn/recommended", - "plugin:import/warnings", - "plugin:regexp/recommended", - "plugin:promise/recommended", - "plugin:json-schema-validator/recommended", - "plugin:prettier/recommended" - ], - "rules": { - "consistent-return": [1, { "treatUndefinedAsUnspecified": true }], - "quotes": [ - 1, - "single", - { "allowTemplateLiterals": true, "avoidEscape": true } - ], - "semi": [1, "always"], - "max-lines": [1, { "max": 300 }], - "max-params": [1, { "max": 5 }], - "no-unneeded-ternary": [1], - "wix-editor/no-instanceof-array": 1, - "wix-editor/no-not-not": 1, - "wix-editor/no-unneeded-match": 1, - "wix-editor/prefer-filter": 1, - "wix-editor/prefer-ternary": 1, - "wix-editor/return-boolean": 1, - "wix-editor/simplify-boolean-expression": 1, - "unicorn/prefer-spread": 0, - "unicorn/catch-error-name": 0, - "unicorn/prefer-node-protocol": 0, - "unicorn/prevent-abbreviations": [ - 1, - { - "replacements": { - "args": false, - "err": false, - "prod": false, - "ref": false, - "params": false - } - } - ], - "import/max-dependencies": [1, { "max": 15 }], - "import/no-unresolved": [ - 2, - { "commonjs": true, "amd": true, "ignore": ["zx/globals"] } - ], - "simple-import-sort/imports": 1, - "simple-import-sort/exports": 1, - "sort-imports": 0, - "import/first": 1, - "import/newline-after-import": 1, - "import/no-duplicates": 1 - }, - "reportUnusedDisableDirectives": true, - "overrides": [ - // Glob patterns in overrides use minimatch syntax. - { - "files": ["src/**/**.?(c|m)+(t|j)s"], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "project": ["./tsconfig.eslint.json"], - "warnOnUnsupportedTypeScriptVersion": false - }, - "plugins": [ - "@typescript-eslint", - "unicorn", - "import", - "wix-editor", - "prettier", - "simple-import-sort", - "promise" - ], - "extends": [ - "eslint:recommended", - "plugin:import/recommended", - "plugin:import/typescript", - "plugin:@typescript-eslint/recommended", - "plugin:@typescript-eslint/recommended-requiring-type-checking", - "plugin:@typescript-eslint/strict", - "plugin:unicorn/recommended", - "plugin:import/warnings", - "plugin:regexp/recommended", - "plugin:promise/recommended", - "plugin:prettier/recommended" - ], - "settings": { - "import/extensions": [ - ".cjs", - ".js", - ".mjs", - ".ts", - ".cts", - ".mts", - ".d.ts", - ".d.cts", - ".d.mts" - ], - "import/resolver": { - "typescript": { - "alwaysTryTypes": true, - "directory": "./src" - }, - "node": true - }, - "import/parsers": { - "@typescript-eslint/parser": [ - ".ts", - ".cts", - ".mts", - ".d.ts", - ".d.cts", - ".d.mts" - ] - } - } - }, - { - "files": ["build/tasks/**.mjs"], - "parser": "@babel/eslint-parser", - "parserOptions": { - "babelOptions": { - "configFile": "./babel.config.json" - }, - "ecmaFeatures": { - "globalReturn": false - }, - "ecmaVersion": 2022, - "sourceType": "module" - }, - "extends": [ - "eslint:recommended", - "plugin:import/recommended", - "plugin:prettier/recommended" - ] - }, - { - "files": ["**/**.json?(5|c)", "**.json?(5|c)", ".*.json?(5|c)", "OWNERS"], - "parser": "jsonc-eslint-parser", - "parserOptions": { - "allowComments": true, - "allowTrailingComma": true - }, - "plugins": ["jsonc", "json-schema-validator"], - "extends": [ - "plugin:jsonc/base", - "plugin:jsonc/recommended-with-json", - "plugin:jsonc/recommended-with-jsonc", - "plugin:jsonc/recommended-with-json5", - "plugin:json-schema-validator/recommended", - "plugin:jsonc/prettier" - ], - "rules": { - "jsonc/no-comments": 0, - "jsonc/comma-dangle": 0, - "jsonc/auto": 1 - } - }, - { - "files": ["package.json", ".ecrc.json"], - "parser": "jsonc-eslint-parser", - "parserOptions": { - "allowComments": true, - "allowTrailingComma": true - }, - "plugins": ["jsonc", "json-schema-validator"], - "extends": [ - "plugin:jsonc/base", - "plugin:jsonc/recommended-with-json", - "plugin:jsonc/recommended-with-json5", - "plugin:json-schema-validator/recommended", - "plugin:jsonc/prettier" - ], - "rules": { - "jsonc/no-comments": 2, - "jsonc/comma-dangle": [2, "never"] - } - }, - { - "files": ["**.md"], - "plugins": ["markdown"], - "extends": ["plugin:markdown/recommended", "plugin:prettier/recommended"] - }, - { - "files": [ - ".*.y?(a)ml", - "*.y?(a)ml", - "**/*.y?(a)ml", - "**.y?(a)ml", - ".snyk" - ], - "parser": "yaml-eslint-parser", - "parserOptions": { - "defaultYAMLVersion": "1.2" - }, - "plugins": ["eslint-plugin-yml"], - "extends": ["plugin:yml/standard", "plugin:yml/prettier"], - "rules": { - "unicorn/filename-case": [ - "error", - { - "case": "kebabCase", - "ignore": ["^FUNDING\\.yml$"] - } - ] - } - } - ] -} diff --git a/.gitattributes b/.gitattributes index 093dad28..478b1d41 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,6 @@ * text=auto eol=lf linguist-detectable .*.jsonc diff=jsonc linguist-language=jsonc .*.json5 diff=json5 linguist-language=json5 -.eslintrc.json diff=jsonc linguist-language=jsonc .snyk diff=yaml linguist-language=yaml .devcontainer/*.json diff=jsonc linguist-language=jsonc .vscode/*.json diff=jsonc linguist-language=jsonc diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5fd720db..87b695ba 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,70 +1,101 @@ - -## Pull Request Purpose - -_Make a selection on the purpose(s) of your changes._ - -This PR contains the following: - -- [ ] 🐞🔧 bugfixing (🐜/🦟/🐛/🦗/🐝 et al.) -- [ ] 🆕🎏 implementation of new feature(s) -- [ ] ♻️ refactoring(s) -- [ ] 📄 documentation modification(s) -- [ ] 🔮 other - -### Testing - -_Have you included any tests in your submission?_ - - - -- [ ] yes (_bugfixes and features will not be merged without - tests_) -- [ ] no - -### Breaking Changes - -_Have you modified any aspect public API surface (i.e., caused breakage)?_ - - - -- [ ] yes (_breaking changes will not be merged unless - necessary_) -- [ ] no - -### Description - -_A concise recap summarily detailing what all are the changes being made._ - - - -List of any relevant issue numbers: diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 756c37d1..e4094a82 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -1,7 +1,14 @@ name: Lint and test -# By default, runs when a pull request is opened, synchronized, or reopened. -on: pull_request +# Also on main, for two reasons: a pull-request-only workflow leaves the +# default branch with no status, so the README badge reports whichever branch +# ran last; and the queue squashes without rebuilding, so nothing else sees +# what actually landed. +on: + pull_request: + push: + branches: + - main # Stated, not inherited: paths-filter needs `pull-requests: read`, and naming # any permission drops the rest to none. @@ -14,28 +21,36 @@ jobs: name: Lint and test runs-on: ubuntu-latest steps: - - name: Checkout + - name: Check out project repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + # `verify.commits` compares against the base branch, which a + # single-commit checkout does not contain. + fetch-depth: 0 # Otherwise the token stays in .git/config while `pnpm install` and # the linters run. Nothing downstream needs it: no step pushes, and # no dependency is git-hosted. persist-credentials: false - - name: Setup node + - name: Set up Node.js runtime uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - # engine-strict plus an exact engines.node means the runner's own - # Node is refused. Read rather than repeated. - node-version-file: .nvmrc - - name: Setup Ruby, JRuby, and TruffleRuby - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + # engineStrict plus an exact engines.node means the runner's own Node + # is refused. Read rather than repeated. + node-version-file: 'package.json' - name: Install run: | - corepack enable - corepack prepare pnpm@latest --activate + # No corepack: node is unbundling it, and pnpm reads + # `packageManager` and fetches that version itself. Installing the + # named one rather than the newest saves fetching pnpm twice. + npm install --global "pnpm@$(node -p "require('./package.json').packageManager.replace('pnpm@','').split('+')[0]")" + pnpm --version pnpm install - gem install bundler && bundle config set --local path 'vendor/bundle' - bundle install + echo "$(pwd)/node_modules/.bin" >> $GITHUB_PATH + + # No build step. `nps build` runs Jekyll over doc/, which wants a Ruby + # toolchain that nothing below reads the output of -- the checks read + # the sources. + # Checks to see if any files in the PR match one of the listed file types. # We can use this filter to decide whether or not to run linters or tests. # You can check if a file with a listed file type is in the PR by doing: @@ -45,63 +60,77 @@ jobs: id: filter with: filters: | - css: - - '**/*.css' + dockerfile: + - '**/Dockerfile' js: - - '**/**.?(c|m)js' - # json: - # - '**/**.json?(5|c)' - # - 'OWNERS.json5' + - '**/*.js' + - '**/*.mjs' + json: + - '**/*.json' + - '**/*.json5' + - '**/*.jsonc' + liquid: + - '**/*.html' + - '**/*.liquid' md: - - '**/**.md' - svg: - - '**/*.svg' + - '**/*.md' toml: - - '**/**.t?(o)ml' + - '**/*.toml' ts: - - '**/**.?(c|m)ts' - - '**/*.d.?(c|m)ts' + - '**/*.ts' + - '**/*.mts' yaml: - - '**/**.y?(a)ml' + - '**/*.yml' + - '**/*.yaml' + + # Not behind a filter: every pull request has commit messages, whatever + # it touches. + - name: Verify commit messages + run: nps verify.commits # Use the filter to check if files with a specific file type were changed # in the PR. If they were, run the relevant linters. Otherwise, skip. - - - name: Verify CSS - if: ${{ steps.filter.outputs.css == 'true' }} - run: pnpm exec nps verify.css + - name: Verify Dockerfile + if: ${{ steps.filter.outputs.dockerfile == 'true' }} + run: nps verify.dockerfile - name: Verify JavaScript if: ${{ steps.filter.outputs.js == 'true' }} - run: pnpm exec nps verify.js + run: nps verify.js - name: Verify JSON if: ${{ steps.filter.outputs.json == 'true' }} - run: pnpm exec nps verify.json + run: nps verify.json + + - name: Verify Liquid + if: ${{ steps.filter.outputs.liquid == 'true' }} + run: nps verify.liquid - name: Verify Markdown if: ${{ steps.filter.outputs.md == 'true' }} - run: pnpm exec nps verify.md - - - name: Verify SVG - if: ${{ steps.filter.outputs.svg == 'true' }} - run: pnpm exec nps verify.svg + run: nps verify.md - name: Verify TOML if: ${{ steps.filter.outputs.toml == 'true' }} - run: pnpm exec nps verify.toml + run: nps verify.toml - name: Verify TypeScript if: ${{ steps.filter.outputs.ts == 'true' }} - run: pnpm exec nps verify.ts + run: nps verify.ts - name: Verify YAML if: ${{ steps.filter.outputs.yaml == 'true' }} - run: pnpm exec nps verify.yaml + run: nps verify.yaml - # Only run tests if the PR touches behavior-related files. + # Only run tests if the PR touches behavior-related files. On main + # everything runs, since nothing else checks what landed. - name: Test - if: ${{ steps.filter.outputs.js == 'true' || - steps.filter.outputs.json == 'true' || - steps.filter.outputs.ts == 'true' }} - run: pnpm test + if: ${{ github.event_name == 'push' || steps.filter.outputs.js == 'true' || steps.filter.outputs.json == 'true' || steps.filter.outputs.ts == 'true' }} + # verify.validForEC fetches the editorconfig-checker binary from the + # GitHub API on first use, and node_modules is new on every run here. + # Unauthenticated that is 60 requests an hour shared with every other + # job leaving this address, which is a job failing for no reason of + # its own. The wrapper reads GITHUB_TOKEN and the runner is given one. + env: + GITHUB_TOKEN: ${{ github.token }} + run: nps test diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 58609c6a..b3b9e778 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -2,35 +2,29 @@ // Include a custom rule package "customRules": [], - // Fix any fixable errors - "fix": true, - - // Define a custom front matter pattern - "frontMatter": "(^---\\s*$[^]*?^---\\s*$)(\r\n|\r|\n|$)", - // Define glob expressions to use (only valid at root) - "globs": ["**/**.md"], + "globs": ["**.md"], // Define glob expressions to ignore "ignores": [ - ".pnpm-store/**/*", - "LICENSE", - "vendor/**/*", - "lib/**/*", - "doc/.jekyll-cache/**/*", - "doc/_site/**/*" + "doc/_site/", + "lib/", + "node_modules/", + "vendor/", + // The package store, which lives in the project because hard links + // cannot cross a filesystem. It holds other people's files. + ".pnpm-store/", + "**/COPYING.md", + "LICENSE/" ], "markdownItPlugins": [ - // Use a plugin to recognize math - ["@iktakahiro/markdown-it-katex"], ["markdown-it-task-lists"], ["markdown-it-footnote"], ["markdown-it-sup"], ["markdown-it-sub"], ["markdown-it-abbr"], - ["markdown-it-anchor"], - ["markdown-it-toc-done-right"] + ["markdown-it-anchor"] ], // Disable inline config comments diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc index befd167d..f8982090 100644 --- a/.markdownlint.jsonc +++ b/.markdownlint.jsonc @@ -164,8 +164,11 @@ "ol_single": 1, // Spaces for multi-line unordered list items "ul_multi": 1, - // Spaces for multi-line ordered list items - "ol_multi": 2 + // Spaces for multi-line ordered list items. One, not two: prettier writes + // `1. ` and reformats anything else back to it, so asking for two here + // put the two tools in a loop -- `markdownlint --fix` widened the marker + // and prettier narrowed it again. + "ol_multi": 1 }, // MD031/blanks-around-fences - Fenced code blocks should be surrounded by blank lines diff --git a/.npmrc b/.npmrc index c8442846..b6f0fbd1 100644 --- a/.npmrc +++ b/.npmrc @@ -1,4 +1,2 @@ -save-exact=true -engine-strict=true -ignore-dep-scripts=true -ignore-scripts=true +# pnpm 10 onwards reads this file for auth and registry only; everything else +# belongs in pnpm-workspace.yaml. diff --git a/.prettierignore b/.prettierignore index 21d7129c..487655fe 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,17 +1,17 @@ !.* -!.*.* -!.*/**/* -!package.json - -.bundle/ .git/ .pnpm-store/ -.moon/cache/ - -_site/ +doc/_site/ +lib/ node_modules/ vendor/ - +LICENSE/ +package.json +pnpm-lock.yaml Gemfile Gemfile.lock -pnpm-lock.yaml + +# Japanese has no word boundaries for prettier to wrap at, so `proseWrap` +# joins the whole paragraph into one line that then trips the line-length +# rule. It is wrapped by hand instead. +VISION.jp.md diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index 358ffec8..00000000 --- a/.prettierrc.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "arrowParens": "always", - "bracketSpacing": true, - "quoteProps": "as-needed", - "semi": true, - "singleQuote": true, - "trailingComma": "es5", - "overrides": [ - { - "files": ["**/**.md"], - "options": { - "parser": "markdown", - "proseWrap": "always" - } - }, - { - "files": ["OWNERS", "**/**.json?(5|c)", ".ecrc"], - "options": { - "parser": "json" - } - }, - { - "files": "**/**.?(c|m)js", - "options": { - "parser": "babel", - "parserOptions": { - "sourceType": "module" - } - } - }, - { - "files": ["**/**.?(c|m)ts", "**/*.d.?(c|m)ts"], - "options": { - "parser": "typescript" - } - }, - { - "files": "**/**.y?(a)ml", - "options": { - "parser": "yaml" - } - } - ] -} diff --git a/.prettierrc.yml b/.prettierrc.yml new file mode 100644 index 00000000..8caf43ce --- /dev/null +++ b/.prettierrc.yml @@ -0,0 +1,42 @@ +--- +plugins: + - '@shopify/prettier-plugin-liquid' +arrowParens: always +bracketSpacing: true +endOfLine: lf +printWidth: 80 +quoteProps: consistent +semi: true +singleQuote: true +tabWidth: 2 +trailingComma: es5 +useTabs: false +overrides: + - files: + - '*.svg' + options: + parser: html + - files: + - '*.html' + options: + bracketSameLine: true + parser: 'liquid-html' + singleAttributePerLine: false + singleQuote: false + - files: + - '*.json5' + options: + parser: json5 + # Biome has no JSON5 parser and skips these files without saying so, + # which left `verify.json` claiming a coverage it did not have. Prettier + # is what reads them now -- for syntax and layout, not to restyle them: + # `.renovaterc.json5` is written the way renovate documents it and + # `OWNERS.json5` the way the owners bot's example is, and neither + # spelling is ours to change. + quoteProps: preserve + singleQuote: false + - files: + - '*.md' + options: + parser: markdown + proseWrap: always diff --git a/.remarkignore b/.remarkignore index 7e5da87a..522803e5 100644 --- a/.remarkignore +++ b/.remarkignore @@ -1,2 +1,17 @@ +.github/ISSUE_TEMPLATE/*.md +doc/_site/ +lib/ node_modules/ vendor/ +LICENSE/ + +# The prose rules below remark are English ones -- readability scores, +# simpler-word suggestions -- and none of them mean anything applied to a +# translation. markdownlint still reads these for structure. +VISION.jp.md +VISION.sr_Cyr.md +VISION.sr_Latn.md + +# The package store, which lives in the project because hard links cannot +# cross a filesystem. It holds other people's files. +.pnpm-store/ diff --git a/.remarkrc.mjs b/.remarkrc.mjs index 3da54cb4..f85c5345 100644 --- a/.remarkrc.mjs +++ b/.remarkrc.mjs @@ -1,10 +1,18 @@ +/**-*- coding: utf-8 -*- esm -*- /.remarkrc.mjs ******************************** + + This file is amongst the sources of OpenINF, Infuse.js, and webServagility. + +******************************************************************************** + + The main Remark Lint configuration file for lint rule & preset initialization + +*******************************************************************************/ + // ----------------------------------------------------------------------------- // Requirements // ----------------------------------------------------------------------------- -import { readFileSync as fsReadFileSync } from 'node:fs'; import { unified } from 'unified'; -import strip from 'strip-comments'; const infoStrings = [ 'ada', @@ -48,62 +56,127 @@ const infoStrings = [ 'yaml', ]; -const projectTerms = strip(fsReadFileSync('./project-terms.txt', 'utf8')); - +// These are handed straight to unified, which -- unlike remark-cli -- does not +// resolve plugin names, so they have to be the functions themselves. const naturalLanguage = unified().use([ - [await import('retext-english'), {}], - [await import('retext-syntax-urls'), {}], - [await import('retext-passive'), {}], - [await import('retext-readability'), { age: 21, minWords: 8 }], - [await import('retext-repeated-words'), {}], - [ - await import('retext-simplify'), - { ignore: ['function', 'interface', 'maintain'] }, - ], - [await import('retext-sentence-spacing'), { preferred: 1 }], - [await import('retext-syntax-mentions'), {}], + [(await import('retext-english')).default, {}], + [(await import('retext-syntax-urls')).default, {}], + [(await import('retext-readability')).default, { age: 30, minWords: 8 }], + [(await import('retext-repeated-words')).default, {}], [ - await import('retext-spell'), - { dictionary: await import('dictionary-en'), personal: projectTerms }, + (await import('retext-simplify')).default, + { + // Phrases whose suggested replacements read worse in technical + // documentation than what they replace: `immediately` is not improved + // by `at once`, and `aggregate` is a word from the project's own + // tagline. Ignoring a phrase here is by its text, not by the rule id + // the reporter prints, so multi-word entries keep their spaces. + ignore: [ + 'accompany', + 'accomplish', + 'accordingly', + 'additional', + 'address', + 'aggregate', + 'attempt', + 'contains', + 'currently', + 'determine', + 'ensure', + 'equivalent', + 'establish', + 'facilitate', + 'function', + 'identical', + 'identify', + 'immediately', + 'inception', + 'indicate', + 'interface', + 'maintain', + 'minimize', + 'monitor', + 'multiple', + 'necessitate', + 'option', + 'portion', + 'previous', + 'provide', + 'remain', + 'request', + 'require', + 'submit', + 'subsequent', + 'therefore', + 'type', + // Wordiness the house style tolerates. + 'all of', + 'appropriate', + 'however', + 'it is', + 'it is essential', + 'one particular', + 'overall', + 'similar to', + 'there are', + 'there is', + ], + }, ], - [await import('retext-syntax-urls')], + [(await import('retext-sentence-spacing')).default, { preferred: 1 }], + [(await import('retext-syntax-mentions')).default, {}], ]); export default { plugins: [ - await import('remark-lint'), + 'remark-lint', ['remark-gfm'], - ['remark-footnotes'], ['remark-frontmatter'], - [await import('remark-preset-lint-consistent'), {}], + ['remark-preset-lint-consistent', {}], // Leave this preset at the top so that it can be overridden. - [await import('remark-preset-lint-recommended'), {}], + ['remark-preset-lint-recommended', {}], [ - await import('remark-lint-checkbox-character-style'), + 'remark-lint-checkbox-character-style', { checked: 'x', unchecked: ' ', }, ], - [await import('remark-lint-checkbox-content-indent')], + ['remark-lint-checkbox-content-indent'], // Remark Lint Style Guide preset and overrides. - [await import('remark-preset-lint-markdown-style-guide')], + ['remark-preset-lint-markdown-style-guide'], ['remark-lint-no-file-name-consecutive-dashes', true], ['remark-lint-fenced-code-flag', { flags: infoStrings }], ['remark-lint-no-heading-punctuation', ':.,;'], ['remark-lint-no-file-name-mixed-case', false], ['remark-lint-no-file-name-irregular-characters', false], - ['remark-lint-first-heading-level', 2], + // Two kinds of document live here and they do not agree on this. A health + // file is served on its own by GitHub, so its first heading is the + // document's title and belongs at level one; a page under doc/ takes its + // title from Jekyll front matter and starts at level two. One value cannot + // be right for both, and `remark-lint-heading-increment` -- which the style + // guide preset above turns on -- is what actually catches a skipped level. + ['remark-lint-first-heading-level', false], + // GitHub renders these as callouts; to remark they look like references + // to definitions that were never written. All five it supports are listed, + // not just the ones in use, so reaching for another is not a lint failure. + [ + 'remark-lint-no-undefined-references', + { allow: ['!CAUTION', '!IMPORTANT', '!NOTE', '!TIP', '!WARNING'] }, + ], + // A bold label introducing a code sample is the house style here, not a + // heading that lost its hashes. + ['remark-lint-no-emphasis-as-heading', false], // Third-party plugins. - [await import('remark-validate-links'), {}], - [await import('remark-lint-maximum-line-length'), {}], - [await import('remark-lint-no-duplicate-headings-in-section'), {}], - [await import('remark-retext'), naturalLanguage], + ['remark-validate-links', {}], + ['remark-lint-maximum-line-length', {}], + ['remark-lint-no-duplicate-headings-in-section', {}], + ['remark-retext', naturalLanguage], // Disables all rules that conflict with Prettier. Leave this preset at the // bottom so that it can't be overridden. - [await import('remark-preset-prettier'), {}], + ['remark-preset-prettier', {}], ], }; diff --git a/.vscode/.eslintrc.json b/.vscode/.eslintrc.json deleted file mode 100644 index a7205988..00000000 --- a/.vscode/.eslintrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "rules": { - "jsonc/no-comments": 0 - } -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 63628668..ee6095ff 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,26 +3,26 @@ "recommendations": [ // https://marketplace.visualstudio.com/items?itemName=aaron-bond.better-comments "aaron-bond.better-comments", + // https://marketplace.visualstudio.com/items?itemName=biomejs.biome + "biomejs.biome", // https://marketplace.visualstudio.com/items?itemName=DavidAnson.vscode-markdownlint "davidanson.vscode-markdownlint", - // https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint - "dbaeumer.vscode-eslint", // https://marketplace.visualstudio.com/items?itemName=dprint.dprint - // "dprint.dprint", + "dprint.dprint", // https://marketplace.visualstudio.com/items?itemName=editorconfig.editorconfig "editorconfig.editorconfig", // https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode "esbenp.prettier-vscode", // https://marketplace.visualstudio.com/items?itemName=Gruntfuggly.todo-tree "Gruntfuggly.todo-tree", - // https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-docker - "ms-azuretools.vscode-docker", // https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.vscode-remote-extensionpack "ms-vscode-remote.vscode-remote-extensionpack", // https://marketplace.visualstudio.com/items?itemName=KatjanaKosic.vscode-json5 "KatjanaKosic.vscode-json5", // https://marketplace.visualstudio.com/items?itemName=redhat.vscode-xml "redhat.vscode-xml", + // https://marketplace.visualstudio.com/items?itemName=rubocop.vscode-rubocop + // "rubocop.vscode-rubocop", // https://marketplace.visualstudio.com/items?itemName=sissel.shopify-liquid "sissel.shopify-liquid", // https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker @@ -31,9 +31,11 @@ "tamasfe.even-better-toml", // https://marketplace.visualstudio.com/items?itemName=unifiedjs.vscode-remark "unifiedjs.vscode-remark" - ], // unwanted/yet-to-be evaluated; (xtra release cadence); we'll get to it ^^ + ], "unwantedRecommendations": [ - // https://marketplace.visualstudio.com/items?itemName=Shopify.theme-check-vscode - "Shopify.theme-check-vscode" + // https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint + "dbaeumer.vscode-eslint", + // https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-docker + "ms-azuretools.vscode-docker" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 085e4e92..4f487f20 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,51 +1,90 @@ { + // --------------------------------------------------------------------------- + // Default Configuration Overrides + // --------------------------------------------------------------------------- // See https://code.visualstudio.com/docs/getstarted/settings // for the documentation about the extensions.json format. // Editor - // /////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// - "editor.stickyScroll.enabled": false, "editor.codeLens": true, + "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit", + "source.organizeImports.biome": "explicit" + }, + + // Insert spaces when pressing `Tab`. This setting is overridden based on the + // file contents when `editor.detectIndentation` is on. + "editor.insertSpaces": false, + + // The number of spaces a tab is equal to. This setting is overridden based + // on the file contents when `editor.detectIndentation` is on. + "editor.tabSize": 4, + "editor.wordWrap": "off", - "[javascript][json][json5][jsonc][typescript][yaml]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint", + // --------------------------------------------------------------------------- + + "[dockerfile]": { "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" - } + "source.fixAll.dprint": "explicit" + }, + "editor.defaultFormatter": "dprint.dprint" }, + // Jekyll's Liquid, which prettier formats through + // @shopify/prettier-plugin-liquid -- the same plugin `nps format.liquid` + // uses, so the editor and the task agree. "[liquid]": { - "editor.defaultFormatter": "sissel.shopify-liquid" + "editor.defaultFormatter": "esbenp.prettier-vscode" }, - // "[toml]": { - // "editor.codeActionsOnSave": { - // "source.fixAll.dprint": "explicit" - // }, - // "editor.defaultFormatter": "dprint.dprint" - // }, - "[markdown]": { "editor.codeActionsOnSave": { "source.fixAll.markdownlint": "explicit" }, - "editor.formatOnSave": true, - // "editor.defaultFormatter": "esbenp.prettier-vscode" - "editor.defaultFormatter": "unifiedjs.vscode-remark" + "editor.defaultFormatter": "esbenp.prettier-vscode" + // "editor.defaultFormatter": "unifiedjs.vscode-remark" // "editor.defaultFormatter": "DavidAnson.vscode-markdownlint" }, + // "[ruby]": { + // "editor.defaultFormatter": "rubocop.vscode-rubocop", + // }, + + "[svg]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + + "[toml]": { + "editor.codeActionsOnSave": { + "source.fixAll.dprint": "explicit" + }, + "editor.defaultFormatter": "dprint.dprint" + }, + + "[yaml]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + // Render vertical rulers after a certain number of monospace characters. - "editor.rulers": [50, 72, 80, 100], // Use a few values for multi rulers. + "editor.rulers": [50, 72, 80, 100], // Uses multiple values for multiple rulers. // Terminal ////////////////////////////////////////////////////////////////////////////// + "terminal.integrated.hideOnStartup": "always", + + // The Linux profiles to present when creating a new terminal via the + // terminal dropdown. When set, these will override the default detected + // profiles. They are comprised of a `path` and optional `args`. + ////////////////////////////////////////////////////////////////////////////// + // A set of terminal profile customizations for Linux that allows adding, - // removing, or changing how terminals are launched. Profiles are made up of - // a mandatory path, optional arguments, and other presentation options. + // removing, or changing how terminals are launched. Profiles are comprised of + // a mandatory `path` and optional `args`, and other presentation options. "terminal.integrated.profiles.linux": { "bash": { "path": "bash" @@ -66,36 +105,45 @@ // The default terminal profile on Linux. "terminal.integrated.defaultProfile.linux": "fish", - // TODO(DerekNonGeneric): // A set of terminal profile customizations for macOS which allows adding, - // removing or changing how terminals are launched. Profiles are made up of - // a mandatory path, optional arguments and other presentation options. - "terminal.integrated.profiles.osx": {}, + // removing, or changing how terminals are launched. Profiles are comprised of + // a mandatory `path` and optional `args`, and other presentation options. + "terminal.integrated.profiles.osx": { + "bash": { + "path": "bash" + }, + "zsh": { + "path": "zsh" + }, + "fish": { + "path": "fish" + } + }, - // TODO(DerekNonGeneric): The default terminal profile on macOS. - "terminal.integrated.defaultProfile.osx": null, + // The default terminal profile on macOS. + "terminal.integrated.defaultProfile.osx": "fish", // Controls whether or not WSL distros are shown in the terminal dropdown. // Not actually recommended, but useful one-offs if that may be the case. "terminal.integrated.useWslProfiles": true, // A set of terminal profile customizations for Windows which allows adding, - // removing or changing how terminals are launched. Profiles are made up of - // a mandatory path, optional arguments and other presentation options. + // removing, or changing how terminals are launched. Profiles are comprised of + // a mandatory `path` and optional `args`, and other presentation options. "terminal.integrated.profiles.windows": { "Git Bash": null, "pwsh": { "path": "pwsh.exe", "source": "PowerShell", - "icon": "terminal-powershell", + "icon": "terminal-powershell" }, "Command Prompt": { "path": [ "${env:windir}\\Sysnative\\cmd.exe", - "${env:windir}\\System32\\cmd.exe", + "${env:windir}\\System32\\cmd.exe" ], - "icon": "terminal-cmd", - }, + "icon": "terminal-cmd" + } }, // The default terminal profile on Windows. @@ -129,20 +177,60 @@ ".vscode/*.json": "jsonc", "tsconfig.*.json": "jsonc", "tsconfig.json": "jsonc", - ".ecrc.json": "json", - ".eslintrc.json": "jsonc", - ".prettierrc.json": "json5", - ".prettierrc.json5": "json5", ".deepsource.toml": "toml", - ".renovaterc.json": "json5", + ".renovaterc.json5": "json5", ".snyk": "yaml", - ".html": "liquid", + "*.html": "liquid", + ".ecrc.json": "json" + }, + + // Configure glob patterns for excluding files and folders. For example, the + // files explorer decides which files and folders to show or hide based on + // this setting. Read more about glob patterns [here][]. + // [here]:https://code.visualstudio.com/docs/editor/codebasics#_advanced-search-options + "files.exclude": { + "!**/node_modules/": true, + ".git": false, + "**/.DS_Store": true + }, + + // Configure glob patterns for excluding files and folders in searches. + // Inherits all glob patterns from the `files.exclude` setting. + "search.exclude": { + "!**/node_modules": true, + "**/node_modules": false }, + // The default character set encoding to use when reading and writing files. + // This setting can also be configured per language. + "files.encoding": "utf8", + + // The default end of line character. + // - \n: LF + // - \r\n: CRLF + // - auto: Uses operating system specific end of line character. + "files.eol": "auto", + + // When enabled, insert a final new line at the end of the file when saving + // it. + "files.insertFinalNewline": true, + + // When enabled, will trim trailing whitespace when saving a file. + // Extension: EditorConfig known issue: trim_trailing_whitespace = false is + // not applied when user/workspace setting of files.trimTrailingWhitespace is + // set to true. + "files.trimTrailingWhitespace": false, + + // Git + ////////////////////////////////////////////////////////////////////////////// + + // Ignores the warning when there are too many changes in a repository. + "git.ignoreLimitWarning": true, + // JavaScript/TypeScript ////////////////////////////////////////////////////////////////////////////// - // Disable JS/TS validation. (These should be handled by ESLint.) + // Disable JS/TS validation. (These should be handled by Biome.) "javascript.validate.enable": false, "typescript.validate.enable": false, "typescript.tsdk": "node_modules/typescript/lib", @@ -172,7 +260,11 @@ // Dprint ///////////////////////////////////////////// - // "dprint.path": "~/.dprint/bin/dprint", + // No "dprint.path" here on purpose. The extension (0.12.0+) auto-resolves + // node_modules/.bin/dprint when present, which is where pnpm installs the + // exact version package.json pins -- pointing this at a separately + // installed global binary would drift from that pin instead of tracking + // it. // EditorConfig (https://editorconfig.org/) ///////////////////////////////////////////// @@ -182,26 +274,42 @@ // files.insertFinalNewline, and files.trimTrailingWhitespace. "editorconfig.generateAuto": false, - // ESLint (ESLint is a separate extension) + // Remote ///////////////////////////////////////////// - - "eslint.format.enable": true, - "eslint.validate": [ - "javascript", - "json", - "json5", - "jsonc", - "markdown", - "typescript", - "yaml" - ], - "eslint.workingDirectories": [ - "./" + "remote.SSH.defaultForwardedPorts": [ + { + "remotePort": 2222 + } ], // cSpell ///////////////////////////////////////////// + // The Language local to use when spell checking. "en" and "en-GB" are + // currently supported. + "cSpell.language": "en", + + // Controls the maximum number of spelling errors per document. + "cSpell.maxNumberOfProblems": 100, + + // Controls the number of suggestions shown. + "cSpell.numSuggestions": 8, + + // The minimum length of a word before checking it against a dictionary. + "cSpell.minWordLength": 4, + + // Enable / Disable the spell checker. + "cSpell.enabled": true, + + // Enable / Disable compound words like 'errormessage'. + "cSpell.allowCompoundWords": true, + + // Specify paths/files to ignore. + "cSpell.ignorePaths": [".git/", "node_modules/"], + + // Set custom workspace dictionaries. + // Define custom dictionaries to be included by default. If `addWords` is true + // words will be added to this dictionary. "cSpell.customDictionaries": { "project-terms": { "name": "project-terms", @@ -209,11 +317,5 @@ "description": "Terms used in this project", "addWords": true } - }, - "terminal.integrated.enableImages": true, - "terminal.integrated.automationProfile.linux": { - "path": "fish", - }, - + } } - diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 8d84bdbd..c311fc3c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -13,11 +13,6 @@ { "label": "Create fish shell terminal", "dependsOn": ["fish"], - // Mark as the default build task so cmd/ctrl+shift+b will create them. - "group": { - "kind": "build", - "isDefault": true - }, // Try to start the task on folder open. "runOptions": { "runOn": "folderOpen" @@ -36,29 +31,46 @@ "args": ["-c"] } }, - // Mark as a background task to avoid the spinner animation on the - // terminal tab. + // Mark as a background task to avoid the spinner animation on the + // terminal tab. "isBackground": true, "problemMatcher": [] }, { - "label": "Serve", + // Every check the pull request has to pass, which is what CI runs. + "label": "Verify all", "type": "shell", - "command": "bundle exec jekyll serve --force_polling --livereload --baseurl='' --source ./doc --destination ./doc/_site", + "command": "nps test", "group": { "kind": "test", "isDefault": true }, - "isBackground": true + "problemMatcher": [] + }, + { + // The autofixable half of the same set. + "label": "Format all", + "type": "shell", + "command": "nps format.all", + "problemMatcher": [] }, { - "label": "Build", + "label": "Serve docs", "type": "shell", - "command": "bundle exec jekyll build --source ./doc --destination ./doc/_site --profile --trace", + "command": "bundle exec jekyll serve --force_polling --livereload --baseurl='' --source ./doc --destination ./doc/_site", + "isBackground": true, + "problemMatcher": [] + }, + { + "label": "Build docs", + "type": "shell", + "command": "nps build", + // Mark as the default build task so cmd/ctrl+shift+b will run it. "group": { "kind": "build", "isDefault": true - } + }, + "problemMatcher": [] } ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78712c83..16e8a9f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,14 +39,14 @@ labels on the issue you’re interested in. Once you have identified an issue you would like to work on, follow these steps: -1. Comment on it and say you would like to work on that issue. -2. Wait for someone to confirm that you may work on the issue before writing - any code. The person who confirms will add a - label to the issue to - indicate that the issue has been assigned. -3. Once the issue has been labeled as a _work in progress_, write your code and - submit your PR. -4. Wait for code review and address any issues raised as soon as you can. +1. Comment on it and say you would like to work on that issue. +2. Wait for someone to confirm that you may work on the issue before writing any + code. The person who confirms will add a + label to the issue to + indicate that the issue has been assigned. +3. Once the issue has been labeled as a _work in progress_, write your code and + submit your PR. +4. Wait for code review and address any issues raised as soon as you can. Even if you are not done with the issue, create a [draft pull request][] and push your code [early and often][]. If we haven’t heard from you in over a week diff --git a/OWNERS.json5 b/OWNERS.json5 index 003caa5f..3c00650a 100644 --- a/OWNERS.json5 +++ b/OWNERS.json5 @@ -5,7 +5,7 @@ "rules": [ { // TODO: Replace this team with the team that owns the repo. - "owners": [{ "name": "wg-a-team" }] - } - ] + "owners": [{ "name": "wg-a-team" }], + }, + ], } diff --git a/README.md b/README.md index 5058889f..0ef8adc4 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,8 @@ Thanks! -1. [Cascading Metadata & Community Health Files](#cascading-metadata-community-health-files) -1. [Local Metadata & Community Health Files](#local-metadata-community-health-files) +1. [Cascading Metadata & Community Health Files](#cascadingmetadata-communityhealthfiles) +1. [Local Metadata & Community Health Files](#localmetadata-communityhealthfiles) 1. [Contributing](#contributing) 1. [License](#license) @@ -125,12 +125,12 @@ how these files are expected to look.
-| File name | Title | Description | -| :--------------- | :------------------ | :---------------------------------------------------------------------- | -| [`AUTHORS`][] | The OpenINF Authors | Lists the contributors of their respective project repositories[^2][^3] | -| [`README.md`][] | Homepage | Serves as a project landing page of sorts | -| [`/LICENSE/`][] | Software License(s) | The open-source software license(s) associated with a project | -| [`VISION.md`][] | Project Vision | The goal(s) and/or scope of a project | +| File name | Title | Description | +| :-------------- | :------------------ | :---------------------------------------------------------------------- | +| [`AUTHORS`][] | The OpenINF Authors | Lists the contributors of their respective project repositories[^2][^3] | +| [`README.md`][] | Homepage | Serves as a project landing page of sorts | +| [`/LICENSE/`][] | Software License(s) | The open-source software license(s) associated with a project | +| [`VISION.md`][] | Project Vision | The goal(s) and/or scope of a project |

@@ -244,7 +244,7 @@ The [SPDX](https://spdx.dev) license identifier for this project is [license-badge--shields]: https://img.shields.io/badge/license-MIT%2FApache--2.0%2FBlueOak--1.0.0-blue.svg?logo=github 'License: MIT/Apache 2.0/BlueOak 1.0.0' -[license-badge-url]: ./#license 'License: MIT/Apache 2.0/BlueOak 1.0.0' +[license-badge-url]: #license 'License: MIT/Apache 2.0/BlueOak 1.0.0' [matrix-badge--shields]: https://img.shields.io/badge/matrix-join%20chat-%2346BC99?logo=matrix 'Chat on Matrix' diff --git a/SECURITY.md b/SECURITY.md index 652f32ea..92cfbad8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -50,8 +50,7 @@ reporting in the near term. [dual Maintainership]: - - + https://gitlab.com/gitlab-com/www-gitlab-com/-/issues/13696#user-content--how-this-could-work [email us]: mailto:security@inf.is [Mend Renovate]: https://www.mend.io/free-developer-tools/renovate diff --git a/SUPPORT.md b/SUPPORT.md index be5c5a82..189604f6 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -9,8 +9,8 @@ tutorial resources. **[`#openinf`][matrix-channel] on matrix.org:** The OpenINF Matrix support channel on the _matrix.org_ network. It’s a synchronous chat channel for -lightweight communication. It is good for asking questions, casual -conversation, collaboration, and quick exchanges. Be sure to follow our [Code of +lightweight communication. It is good for asking questions, casual conversation, +collaboration, and quick exchanges. Be sure to follow our [Code of Conduct][coc]. The room is end-to-end encrypted, and you will need first to be verified before messages can be seen (this is performed by in-room mods/admins). Invitation to the space would have to happen before room verification can occur, diff --git a/VISION.jp.md b/VISION.jp.md index 899ef9ea..aa51e9bb 100644 --- a/VISION.jp.md +++ b/VISION.jp.md @@ -1,4 +1,4 @@ -## ビジョン[^1] +# ビジョン[^1] OpenINF の目標は、フレンドリーでオープンなコミュニティ指向の開発プロセスの確立を 通じて、さまざまなソースから得られた情報を集約、洗練、拡張、適用するためのツール diff --git a/VISION.sr_Cyr.md b/VISION.sr_Cyr.md index 976ba735..29702d2c 100644 --- a/VISION.sr_Cyr.md +++ b/VISION.sr_Cyr.md @@ -1,4 +1,4 @@ -## Визија[^1] +# Визија[^1] Циљ ОпенИНФ-а је да ојача заједницу отвореног извора помоћу алатки за агрегацију, усавршавање, проширење и примену информација произашле из различитих diff --git a/VISION.sr_Latn.md b/VISION.sr_Latn.md index 43711741..f9a9c137 100644 --- a/VISION.sr_Latn.md +++ b/VISION.sr_Latn.md @@ -1,5 +1,5 @@ -## Vizija[^1] - +# Vizija[^1] + Cilj OpenINF-a je da ojača zajednicu otvorenog koda pomoću alata za agregiranje, prečišćavanje, proširenje i primenu informacija dobijenih iz različitih izvora kroz uspostavljanje prijateljskih, otvorenih, razvojnih procesa orijentisanih na diff --git a/babel.config.json b/babel.config.json deleted file mode 100644 index f1030254..00000000 --- a/babel.config.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "plugins": [ - "@babel/plugin-syntax-top-level-await", - "@babel/plugin-syntax-jsx", - "@babel/plugin-transform-modules-commonjs" - ], - "presets": [ - [ - "@babel/preset-env", - { - "modules": false, - "targets": { - "esmodules": true, - "node": true - } - } - ] - ] -} diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 00000000..326ce623 --- /dev/null +++ b/biome.jsonc @@ -0,0 +1,65 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "files": { + "includes": [ + "**", + "!**/.git/", + "!**/doc/_site/", + "!**/lib/", + "!**/node_modules/", + "!**/vendor/", + "!**/package.json", + "!**/pnpm-lock.yaml" + ] + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 80, + "attributePosition": "auto" + }, + "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended", + "style": { + "useNamingConvention": { + "level": "error", + "options": { + "strictCase": true + } + } + } + } + }, + "javascript": { + "formatter": { + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "trailingCommas": "es5", + "semicolons": "always", + "arrowParentheses": "always", + "bracketSpacing": true, + "bracketSameLine": false, + "quoteStyle": "single", + "attributePosition": "auto" + } + }, + "json": { + "parser": { + "allowComments": true + } + }, + "overrides": [ + { "includes": ["**/*.json"] }, + { + "includes": ["**/*.html"], + "javascript": { "formatter": { "quoteStyle": "double" } } + }, + { "includes": ["**/*.md"] } + ] +} diff --git a/build/shared/commit-message.mts b/build/shared/commit-message.mts new file mode 100644 index 00000000..b35f5d00 --- /dev/null +++ b/build/shared/commit-message.mts @@ -0,0 +1,443 @@ +/** + * @file The commit message format, as rules a message can be checked against. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/shared/commit-message + */ + +/** + * What the change is about. Kept in step with the list in + * .github/PULL_REQUEST_TEMPLATE.md, which is where a contributor reads it; + * a test fails if the two drift apart. + * + * Each is spelt so that it is drawn as an emoji and no more: the characters + * that would otherwise come out as flat text carry U+FE0F, and the ones + * already drawn in color do not carry one they have no use for. A test holds + * the list to that. + */ +export const CATEGORIES: Record = { + '🏷️': 'meta', + '🐋': 'dev container', + '🧩': 'extension ∥ plugin', + '🏗️': 'infrastructure ∥ tooling ∥ builds ∥ CI/CD', + '⚕️': 'community health files', + '🧪': 'tests', + '❄️': 'flaky tests', + '💄': 'CSS ∥ styling', + '♿': 'accessibility', + '🌐': 'internationalization', + '📖': 'documentation', + '📦': 'packages & package management', +}; + +/** What is being done to it. Optional: the template allows leaving it off. */ +export const ACTIONS: Record = { + '✨': 'new feature', + '🔧': 'bug fix', + '🔥': 'P0 fix', + '🚀': 'performance improvements', + '⏪': 'reverting a previous change', + '♻️': 'refactoring', + '🚮': 'deleting code', + '🥼': 'experimental code', +}; + +/** Issue #1539: 50 for the subject, 72 for everything after it. */ +export const SUBJECT_MAX = 50; +export const BODY_MAX = 72; + +/** + * The order the trailers appear in a landed commit, which is what nodejs/node + * produces and worth matching rather than inventing: whatever the branch + * commit already carried comes first, then what the landing adds. + */ +export const TRAILER_ORDER = [ + 'Co-authored-by', + 'Signed-off-by', + 'Assisted-by', + 'PR-URL', + 'Fixes', + 'Refs', + 'Reviewed-by', +]; + +/** U+FF1A, which separates the emoji from the description. */ +const FULLWIDTH_COLON = ':'; + +/** The invisible character that distinguishes two spellings of one emoji. */ +const EMOJI_SELECTOR = '️'; + +/** Where the emoji are laid out for copying. */ +const HANDBOOK_URL = 'https://open.inf.is/docs/handbook/style/commit-messages/'; + +const VOCABULARY: Record = { ...CATEGORIES, ...ACTIONS }; + +/** + * Emoji that come out of a keyboard or a picker looking right while being a + * different string from the one the vocabulary uses. The answer is always to + * copy the emoji rather than to reason about which spelling it is. + */ +const NEAR_MISSES = new Map( + Object.keys(VOCABULARY).map((emoji) => + emoji.endsWith(EMOJI_SELECTOR) + ? [emoji.slice(0, -EMOJI_SELECTOR.length), emoji] + : [`${emoji}${EMOJI_SELECTOR}`, emoji] + ) +); + +/** + * Points at the spelling to use, since the two look alike. + * @param {string} cluster What was written. + * @returns {string} What to write instead. + */ +const describeNearMiss = (cluster: string) => { + const intended = NEAR_MISSES.get(cluster) ?? ''; + + return `“${cluster}” is not the emoji for ${VOCABULARY[intended]}; copy “${intended}” from ${HANDBOOK_URL}`; +}; + +/** + * One segmenter, not one per line. Building a new one for every line of a + * message is most of the time spent reading a long one. + */ +const SEGMENTER = new Intl.Segmenter(); + +const countGraphemes = (text: string) => [...SEGMENTER.segment(text)].length; + +/** + * A trailer is `Token: value` with no whitespace in the token. Written out + * rather than taken from a list of known tokens, so that a line *meant* as a + * trailer is recognized as one and can be reported as misspelt. + */ +const TRAILER_LINE = /^(?[A-Za-z][\w-]*):[ \t]*(?.*)$/; + +/** + * `Assisted-by` names a tool, not a person, and so takes neither a name nor an + * address: the Linux kernel defines it as `AGENT_NAME:MODEL_VERSION` followed + * by any specialized analysis tools, and nodejs/node lands it that way. Basic + * development tools are left out. + */ +const ASSISTED_BY_VALUE = /^[^\s:]+:\S+( \S+)*$/; + +/** git folds a trailer whose value runs onto an indented line beneath it. */ +const CONTINUATION_LINE = /^\s/; + +/** + * Splits a commit message into its lines, without the blank ones git leaves + * at the end. Written as a scan rather than as `/[\r\n]+$/`, which takes time + * proportional to the square of the run of newlines it is asked about: a + * message is written by whoever opened the pull request, so a million of them + * is a thing somebody can send. + * @param {string} message The whole commit message. + * @returns {string[]} Its lines, however they were ended. + */ +export function linesOf(message: string) { + let end = message.length; + + while (end > 0) { + const last = message[end - 1]; + + if (last !== '\n' && last !== '\r') break; + + end -= 1; + } + + return message.slice(0, end).split(/\r?\n/); +} + +/** + * Splits a message body into paragraphs of non-empty lines. + * @param {string[]} lines Every line after the subject. + * @returns {string[][]} The paragraphs, in order. + */ +const paragraphsOf = (lines: string[]) => + lines + .join('\n') + .split(/\n{2,}/) + .map((paragraph) => paragraph.split('\n').filter(Boolean)) + .filter((paragraph) => paragraph.length > 0); + +/** + * Reads the trailer block out of a message, agreeing with git about whether + * there is one: the last paragraph, every line of it either a trailer or a + * continuation of the one above, and the first of them a trailer. A paragraph + * that merely contains a colon somewhere is prose, and git reads no trailers + * in it -- so neither does this. + * @param {string} message The whole commit message. + * @returns {string[]} The trailer lines, one per trailer, empty if there is no block. + */ +export function readTrailers(message: string) { + const [, ...rest] = linesOf(message); + const last = paragraphsOf(rest).at(-1) ?? []; + const isBlock = + last.length > 0 && + TRAILER_LINE.test(last[0] ?? '') && + last.every( + (line) => TRAILER_LINE.test(line) || CONTINUATION_LINE.test(line) + ); + + return isBlock ? last.filter((line) => !CONTINUATION_LINE.test(line)) : []; +} + +/** + * Checks the subject against the vocabulary and the length limit. + * @param {string} subject The first line of the message. + * @returns {string[]} What is wrong with it, empty if nothing. + */ +const checkSubject = (subject: string) => { + const problems: string[] = []; + const colon = subject.indexOf(FULLWIDTH_COLON); + + if (colon === -1) { + problems.push( + `subject needs an emoji prefix and “${FULLWIDTH_COLON}” (U+FF1A), as in “🏗️🔧${FULLWIDTH_COLON}fix the thing”` + ); + } else { + const prefix = subject.slice(0, colon); + const description = subject.slice(colon + FULLWIDTH_COLON.length); + // The variation selector belongs to the character before it, so the + // prefix has to be read as grapheme clusters and not code points. + const clusters = [...SEGMENTER.segment(prefix)].map( + (entry) => entry.segment + ); + + if (clusters.length === 0) { + problems.push('subject has no emoji before the colon'); + } else if (clusters.length > 2) { + problems.push( + `subject has ${clusters.length} emoji before the colon; expected a category and at most one action` + ); + } else { + const [first, second] = clusters; + + if (first !== undefined && !(first in CATEGORIES)) { + if (NEAR_MISSES.has(first)) { + problems.push(describeNearMiss(first)); + } else if (first in ACTIONS) { + problems.push( + `“${first}” is an action, not a category; a category comes first` + ); + } else { + problems.push(`“${first}” is not a category emoji`); + } + } + + if (second !== undefined && !(second in ACTIONS)) { + problems.push( + NEAR_MISSES.has(second) + ? describeNearMiss(second) + : `“${second}” is not an action emoji` + ); + } + } + + if (description.length === 0) { + problems.push('subject has nothing after the colon'); + } else if (description.startsWith(' ')) { + problems.push('subject has a space after the colon'); + } + + if (/\s#\d+$/.test(description)) { + problems.push( + 'subject ends with a pull request number; `PR-URL:` carries that' + ); + } + + if (description.endsWith('.')) { + problems.push('subject ends with a full stop'); + } + } + + const width = countGraphemes(subject); + + if (width > SUBJECT_MAX) { + problems.push( + `subject is ${width} characters; the limit is ${SUBJECT_MAX}` + ); + } + + return problems; +}; + +/** + * Checks the paragraph a trailer block would have to be, which git only ever + * looks for at the very end of the message. + * @param {string[]} lines Every line of the message after the subject. + * @returns {string[]} What is wrong with them, empty if nothing. + */ +const checkTrailers = (lines: string[]) => { + const problems: string[] = []; + const paragraphs = paragraphsOf(lines); + const last = paragraphs.at(-1) ?? []; + + // `PR URL:` is the mistake worth naming outright: the space means git reads + // no trailer there, and one unreadable line disqualifies every trailer + // beside it, so the whole block goes silently missing. Matched against the + // known tokens with their hyphens loosened, since a looser test than that + // flags any body sentence containing a colon. + for (const line of last) { + for (const token of TRAILER_ORDER) { + const spaced = new RegExp(`^${token.replaceAll('-', '[ -]')}:`, 'i'); + + if ( + spaced.test(line) && + !line.toLowerCase().startsWith(`${token.toLowerCase()}:`) + ) { + problems.push( + `“${line.split(':')[0]}:” is spelt “${token}:”; a space in the token disqualifies the whole block` + ); + } + } + } + + // A trailer above the final paragraph is not a trailer. Co-authored-by is + // the one that costs something: GitHub reads it only at the end, so + // attribution is quietly lost. + for (const paragraph of paragraphs.slice(0, -1)) { + for (const line of paragraph) { + const token = line.match(TRAILER_LINE)?.groups?.token; + + if (token !== undefined && TRAILER_ORDER.includes(token)) { + problems.push( + `“${token}:” is not in the last paragraph, so git does not read it as a trailer` + ); + } + } + } + + const tokens: string[] = []; + const block = readTrailers(['', ...lines].join('\n')); + + // A last paragraph that is not a clean block is prose, and git reads no + // trailers in it. Saying so is only worth doing for a line that was plainly + // meant as one of ours, since it is going unread. + if (block.length === 0) { + for (const line of last) { + const token = line.match(TRAILER_LINE)?.groups?.token ?? ''; + + if ( + TRAILER_ORDER.some( + (known) => known.toLowerCase() === token.toLowerCase() + ) + ) { + problems.push( + `“${token}:” sits in a paragraph that is not all trailers, so git reads none of them` + ); + } + } + } else { + for (const line of block) { + const found = line.match(TRAILER_LINE)?.groups; + const token = found?.token ?? ''; + + if ( + token.toLowerCase() === 'assisted-by' && + !ASSISTED_BY_VALUE.test(found?.value ?? '') + ) { + problems.push( + `“Assisted-by: ${found?.value}” names a tool, not a person: write it as agent:model-version, as in “Assisted-by: Claude-Code:claude-opus-5”` + ); + } + + // Case is part of the spelling. git and GitHub would match these either + // way, so this is about a history that reads the same throughout rather + // than about being understood. + if (!TRAILER_ORDER.includes(token)) { + const canonical = TRAILER_ORDER.find( + (known) => known.toLowerCase() === token.toLowerCase() + ); + + problems.push( + canonical === undefined + ? `“${token}:” is not a trailer this project uses` + : `“${token}:” is spelt “${canonical}:” here` + ); + } + + tokens.push(token); + } + + const ranks = tokens + .filter((token) => TRAILER_ORDER.includes(token)) + .map((token) => TRAILER_ORDER.indexOf(token)); + + if ( + ranks.some((rank, index) => index > 0 && rank < (ranks[index - 1] ?? 0)) + ) { + problems.push( + `trailers are out of order; this project uses ${TRAILER_ORDER.join(', ')}` + ); + } + } + + return problems; +}; + +/** + * Checks one commit message against the project's format. + * @param {string} message The whole message, subject line onwards. + * @returns {string[]} What is wrong with it, empty if nothing. + */ +export function validateCommitMessage(message: string) { + // A trailing newline is how git hands the message over and says nothing + // about the message itself. Carriage returns say nothing either: git reads + // trailers through them, so a message written on Windows must not be judged + // differently from the same message written anywhere else. + const lines = linesOf(message); + const [subject = '', ...rest] = lines; + const problems = checkSubject(subject); + + if (rest.length > 0 && rest[0] !== '') { + problems.push('the line after the subject has to be blank'); + } + + for (const line of rest) { + // A line of dashes is why the trailers in this project have been going + // unread: `---` is where git stops looking for them, and any longer run + // splits the block in two so that only the half below it counts. + if (/^-{3,}$/.test(line)) { + problems.push( + `“${line}” separates the trailers from the message; git reads only one side of it` + ); + } + + // An unbreakable line -- a URL, near enough always -- cannot be wrapped, + // and reflowing one to fit would break it. + if (countGraphemes(line) > BODY_MAX && /\s/.test(line.trim())) { + problems.push( + `line is ${countGraphemes(line)} characters; the limit is ${BODY_MAX}: “${line.slice(0, 40)}…”` + ); + } + } + + problems.push(...checkTrailers(rest)); + + return problems; +} + +/** + * Checks that a human certified the change. Only the person named as author + * can do that: an assistant discloses itself with `Assisted-by` and does not + * sign anything, and a bot certifying on someone's behalf is the thing this + * exists to stop. + * @param {string} message The whole commit message. + * @param {string} author The commit's author, as `Name `. + * @returns {string[]} What is wrong with it, empty if nothing. + */ +export function checkSignOff(message: string, author: string) { + const signed = readTrailers(message) + .filter((line) => /^Signed-off-by:/.test(line)) + .map((line) => line.slice(line.indexOf(':') + 1).trim()); + + if (signed.length === 0) { + return [ + `no “Signed-off-by: ${author}”; the Developer Certificate of Origin is certified by the author, and “Assisted-by:” is what discloses a tool`, + ]; + } + + return signed.includes(author) + ? [] + : [ + `\`Signed-off-by:\` names ${signed.join(', ')}, but the author is ${author}`, + ]; +} diff --git a/build/shared/commit-message.test.mts b/build/shared/commit-message.test.mts new file mode 100644 index 00000000..552ceb74 --- /dev/null +++ b/build/shared/commit-message.test.mts @@ -0,0 +1,402 @@ +/** + * @file Tests for the commit message rules. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/shared/commit-message.test + */ + +import { deepStrictEqual, match, ok } from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { describe, test } from 'node:test'; +import { + ACTIONS, + CATEGORIES, + readTrailers, + validateCommitMessage, +} from '@openinf/.github/build/commit-message'; + +/** The one problem a message has, when a test expects exactly one. */ +const soleProblem = (message: string) => { + const problems = validateCommitMessage(message); + + deepStrictEqual(problems.length, 1, `expected one problem, got: ${problems}`); + + return problems[0] ?? ''; +}; + +describe('validateCommitMessage: the subject', () => { + test('accepts a category, an action and a description', () => { + deepStrictEqual(validateCommitMessage('🏗️🔧:fix the thing'), []); + }); + + test('accepts a category on its own, which the template allows', () => { + deepStrictEqual(validateCommitMessage('📖:write it down'), []); + }); + + test('wants the fullwidth colon', () => { + match(soleProblem('🏗️🔧: fix the thing'), /U\+FF1A/); + }); + + test('knows an action from a category', () => { + match(soleProblem('🔧:fix the thing'), /is an action, not a category/); + }); + + test('rejects an emoji outside the vocabulary', () => { + match(soleProblem('🦄:fix the thing'), /not a category emoji/); + }); + + // The two emoji below are deliberately the wrong spelling -- they are what + // the check has to catch, so leave them be. + test('points at the emoji to copy when a lookalike is used', () => { + // 🏗🔧 means an infrastructure fix as plainly as 🏗️🔧 does, and is a + // different string. Saying so is not worth a paragraph about Unicode: the + // message shows what to copy. + match(soleProblem('🏗🔧:fix the thing'), /copy “🏗️” from https:/); + }); + + test('does the same for the other direction', () => { + match(soleProblem('♿️:name the landmarks'), /copy “♿” from https:/); + }); + + test('counts an emoji as the one character it looks like', () => { + // 48 written characters plus a two-emoji prefix and the colon: over the + // limit by code point, inside it by grapheme, and the limit means what a + // reader sees. + const subject = `🏗️🔧:${'x'.repeat(47)}`; + + ok(subject.length > 50); + deepStrictEqual(validateCommitMessage(subject), []); + }); + + test('rejects a subject past fifty characters', () => { + match( + soleProblem(`🏗️🔧:${'x'.repeat(48)}`), + /subject is 51 characters; the limit is 50/ + ); + }); + + test('rejects a trailing pull request number', () => { + match(soleProblem('🏗️🔧:fix the thing #1803'), /PR-URL:` carries that/); + }); + + test('rejects a trailing full stop', () => { + match(soleProblem('🏗️🔧:fix the thing.'), /full stop/); + }); + + test('rejects a space after the colon', () => { + match(soleProblem('🏗️🔧: fix the thing'), /space after the colon/); + }); +}); + +describe('validateCommitMessage: the body', () => { + test('wants a blank line under the subject', () => { + match( + soleProblem('🏗️🔧:fix it\nstraight into the body'), + /has to be blank/ + ); + }); + + test('rejects a line past seventy-two characters', () => { + match( + soleProblem(`🏗️🔧:fix it\n\n${'word '.repeat(20)}`), + /the limit is 72/ + ); + }); + + test('leaves an unbreakable line alone', () => { + // Reflowing a URL to fit the margin would break the URL. + const url = `https://example.com/${'p'.repeat(80)}`; + + deepStrictEqual( + validateCommitMessage(`🏗️🔧:fix it\n\n${url}\n\nWhere it is written up.`), + [] + ); + }); + + test('warns about a bare URL as the last paragraph', () => { + // Not pedantry: `git interpret-trailers` reads that line as a trailer + // called `https`, so a URL parked at the end changes what git sees. + match( + soleProblem('🏗️🔧:fix it\n\nhttps://example.com/p'), + /“https:” is not a trailer this project uses/ + ); + }); +}); + +describe('validateCommitMessage: the trailers', () => { + test('accepts a block in the documented order', () => { + deepStrictEqual( + validateCommitMessage( + '🏗️🔧:fix it\n\nCo-authored-by: A \nPR-URL: https://x/1\nReviewed-by: B ' + ), + [] + ); + }); + + test('insists on the documented spelling of a token', () => { + // git and GitHub would match this either way; the point is a history that + // reads the same throughout. + match( + soleProblem('🏗️🔧:fix it\n\nCo-Authored-By: A '), + /is spelt “Co-authored-by:” here/ + ); + }); + + test('rejects a space in place of a hyphen', () => { + // The mistake that has been costing this project its trailers: git reads + // no trailer on that line, and one unreadable line voids the block. + match( + soleProblem('🏗️🔧:fix it\n\nPR URL: https://x/1'), + /is spelt “PR-URL:”/ + ); + }); + + test('rejects a line of dashes above the block', () => { + match( + soleProblem('🏗️🔧:fix it\n\n-------\n\nCo-authored-by: A '), + /git reads only one side of it/ + ); + }); + + test('rejects trailers out of order', () => { + match( + soleProblem('🏗️🔧:fix it\n\nReviewed-by: B \nPR-URL: https://x/1'), + /out of order/ + ); + }); + + test('rejects a trailer stranded above the last paragraph', () => { + match( + soleProblem( + '🏗️🔧:fix it\n\nCo-authored-by: A \n\nsomething else entirely' + ), + /not in the last paragraph/ + ); + }); + + test('rejects prose mixed in with a trailer', () => { + match( + soleProblem('🏗️🔧:fix it\n\nPR-URL: https://x/1\nand one more thing'), + /not all trailers, so git reads none of them/ + ); + }); + + test('leaves a closing paragraph of prose alone', () => { + // git reads no trailers in a paragraph that is not all trailers, so + // neither does this. Rejecting it was a false positive found in review: + // any commit ending on an explanatory line with a colon in it was refused. + deepStrictEqual( + validateCommitMessage( + '🏗️🔧:fix it\n\nCloses the loop.\n\nNote: this only affects staging.\nNothing else changes here.' + ), + [] + ); + }); + + test('allows a trailer folded onto an indented line', () => { + // git's own syntax for a long trailer value, and it parses this as one + // trailer. Rejecting it was a false positive found in review. + deepStrictEqual( + validateCommitMessage( + '🏗️🔧:fix it\n\nCo-authored-by: Jane Doe\n ' + ), + [] + ); + }); + + test('judges a message with carriage returns the same way', () => { + // git reads trailers straight through CRLF. Before this, a `\r` made the + // blank line look non-blank and hid every trailer problem behind it. + match( + soleProblem( + '🏗️🔧:fix it\r\n\r\nBody line.\r\n\r\nReviewed-by: A \r\nPR-URL: https://x/1\r\n' + ), + /out of order/ + ); + }); + + test('does not mind a trailer repeated', () => { + deepStrictEqual( + validateCommitMessage( + '🏗️🔧:fix it\n\nCo-authored-by: A \nCo-authored-by: B \nReviewed-by: C \nReviewed-by: D ' + ), + [] + ); + }); + + test('accepts an assistant named the way the kernel defines it', () => { + deepStrictEqual( + validateCommitMessage( + '🏗️🔧:fix it\n\nAssisted-by: Claude-Code:claude-opus-5' + ), + [] + ); + }); + + test('rejects an assistant written as a person', () => { + // What this repository had been carrying. `Assisted-by` names a tool, so + // an address makes a claim about authorship that the trailer exists to + // avoid making. + match( + soleProblem( + '🏗️🔧:fix it\n\nAssisted-by: Claude Opus 5 ' + ), + /names a tool, not a person/ + ); + }); + + test('rejects a token this project does not use', () => { + match( + soleProblem('🏗️🔧:fix it\n\nCloses: https://x/1'), + /not a trailer this project uses/ + ); + }); + + test('leaves ordinary prose containing a colon alone', () => { + deepStrictEqual( + validateCommitMessage( + '🏗️🔧:fix it\n\nWhat went wrong: the glob skipped dot files.' + ), + [] + ); + }); +}); + +describe('validateCommitMessage: against what landed', () => { + test('rejects the shape every recent commit has used', () => { + const problems = validateCommitMessage( + [ + '🏗️🔧:stop the verify task rewriting the files it checks #1803', + '', + 'PR URL: https://github.com/OpenINF/openinf.github.io/pull/1803', + 'Reviewed-by: @OpenINFbot', + '', + '-------', + '', + 'Co-authored-by: Claude Sonnet 5 ', + ].join('\n') + ); + + ok(problems.some((problem) => /PR-URL:/.test(problem))); + ok(problems.some((problem) => /one side of it/.test(problem))); + ok(problems.some((problem) => /the limit is 50/.test(problem))); + ok(problems.some((problem) => /PR-URL:` carries that/.test(problem))); + }); +}); + +describe('readTrailers', () => { + test('agrees with git about where the trailers are', () => { + // The rules describe git's behavior, so git is the thing to check them + // against. Every disagreement found in review is in this table. + const messages = [ + '🏗️🔧:fix it', + '🏗️🔧:fix it\n\nPR-URL: https://x/1', + '🏗️🔧:fix it\n\nCo-authored-by: A \nPR-URL: https://x/1', + '🏗️🔧:fix it\n\nCo-authored-by: Jane Doe\n ', + '🏗️🔧:fix it\n\nNote: only staging.\nNothing else changes.', + '🏗️🔧:fix it\n\nPR-URL: https://x/1\nand one more thing', + '🏗️🔧:fix it\n\nrefs: not a trailer\n\nThe body.', + '🏗️🔧:fix it\r\n\r\nBody.\r\n\r\nPR-URL: https://x/1\r\n', + '🏗️🔧:fix it\n\n-------\n\nPR-URL: https://x/1', + '🏗️🔧:fix it\n\nPR URL: https://x/1\nReviewed-by: A ', + '🏗️🔧:fix it\n\nhttps://example.com/p', + ]; + + for (const message of messages) { + const theirs = execFileSync('git', ['interpret-trailers', '--parse'], { + encoding: 'utf8', + input: message, + }) + .split('\n') + .filter(Boolean); + + deepStrictEqual( + readTrailers(message).length, + theirs.length, + `git reads ${theirs.length} trailers in ${JSON.stringify(message)}` + ); + } + }); +}); + +describe('the vocabulary', () => { + test('is spelt so that every entry is drawn as an emoji, and no more', () => { + // Two ways to get this wrong, and both leave a second spelling of one + // symbol: a character that needs U+FE0F to be drawn in color and does + // not carry it, and one drawn in color already that carries a selector + // it has no use for. + for (const emoji of [...Object.keys(CATEGORIES), ...Object.keys(ACTIONS)]) { + const [base = ''] = [...emoji]; + const selected = emoji.endsWith('️'); + const drawnAsEmoji = /\p{Emoji_Presentation}/u.test(base); + + deepStrictEqual( + selected, + !drawnAsEmoji, + drawnAsEmoji + ? `${emoji} carries a selector it does not need` + : `${emoji} is drawn as text without a selector` + ); + } + }); + + test('matches the list contributors are shown', async () => { + // The template is where the emoji are documented, so drift between it and + // the rules is worth failing over rather than discovering in review. + const template = await readFile( + new URL('../../.github/PULL_REQUEST_TEMPLATE.md', import.meta.url), + 'utf8' + ); + const documented = new Set( + [...template.matchAll(/^(\P{ASCII}️?) \S/gmu)].map( + (found) => found[1] ?? '' + ) + ); + + for (const emoji of [...Object.keys(CATEGORIES), ...Object.keys(ACTIONS)]) { + ok(documented.has(emoji), `${emoji} is not in the pull request template`); + } + + deepStrictEqual( + documented.size, + Object.keys(CATEGORIES).length + Object.keys(ACTIONS).length, + 'the template documents an emoji the rules do not know' + ); + }); +}); + +describe('a message written to be slow', () => { + // A commit message comes from whoever opened the pull request, and the + // commit queue reads it holding credentials that can write here. Taking + // time proportional to the square of its length is a way to stop the queue + // working, so these hold the rules to reading it in linear time. + // Generous, because the work itself is linear and CI is slow. What it + // separates is linear from quadratic: at this size the regexes these + // replaced took twenty seconds and ninety seconds respectively. + const budget = 3000; + + test('reads a long run of newlines quickly', () => { + const message = `🏗️🔧:fix it\n\nA body.${'\n'.repeat(200_000)}x`; + const started = performance.now(); + + validateCommitMessage(message); + + const spent = performance.now() - started; + + ok(spent < budget, `took ${spent.toFixed(0)}ms, budget ${budget}ms`); + }); + + test('reads a long Assisted-by value quickly', () => { + // `\S` matches a colon, so the obvious spelling of agent:model lets the + // engine try every colon as the split point. + const message = `🏗️🔧:fix it\n\nAssisted-by: ${'a:'.repeat(100_000)} `; + const started = performance.now(); + + validateCommitMessage(message); + + const spent = performance.now() - started; + + ok(spent < budget, `took ${spent.toFixed(0)}ms, budget ${budget}ms`); + }); +}); diff --git a/build/tasks/.babelrc.json b/build/tasks/.babelrc.json deleted file mode 100644 index ac08da0a..00000000 --- a/build/tasks/.babelrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "../../babel.config.json" -} diff --git a/build/tasks/compile/.gitkeep b/build/tasks/compile/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/build/tasks/compile/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/build/tasks/format/format-css.mjs b/build/tasks/format/format-css.mjs deleted file mode 100644 index 3bf84ef0..00000000 --- a/build/tasks/format/format-css.mjs +++ /dev/null @@ -1,9 +0,0 @@ -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('format-css', import.meta.url); - -// TODO(DerekNonGeneric): Implement this task. -process.exitCode = 0; - -// eslint-disable-next-line unicorn/no-process-exit -process.exit(0); diff --git a/build/tasks/format/format-dockerfile.mts b/build/tasks/format/format-dockerfile.mts new file mode 100644 index 00000000..3238cae2 --- /dev/null +++ b/build/tasks/format/format-dockerfile.mts @@ -0,0 +1,22 @@ +/** + * @file Format Dockerfiles to adhere to autofixable style guidelines. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/format/format-dockerfile + */ + +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +const dockerfileFiles = await glob([ + '.devcontainer/**/Dockerfile', + '!node_modules/', +]); + +let exitCode = 0; +const scripts = [`dprint fmt ${quote(dockerfileFiles)}`]; + +for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; +} diff --git a/build/tasks/format/format-html.mjs b/build/tasks/format/format-html.mjs deleted file mode 100644 index 88977306..00000000 --- a/build/tasks/format/format-html.mjs +++ /dev/null @@ -1,9 +0,0 @@ -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('format-html', import.meta.url); - -// TODO(DerekNonGeneric): Implement this task. -process.exitCode = 0; - -// eslint-disable-next-line unicorn/no-process-exit -process.exit(0); diff --git a/build/tasks/format/format-js.mjs b/build/tasks/format/format-js.mjs deleted file mode 100644 index 78a28fd7..00000000 --- a/build/tasks/format/format-js.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import { execute } from '@yarnpkg/shell'; -import { $ } from 'zx'; - -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('format-js', import.meta.url); - -const JavaScriptObject = - await $`bundle exec github-linguist --breakdown --json | jq '.JavaScript.files'`; -const JavaScriptFiles = JSON.parse(JavaScriptObject.stdout); - -let exitCode = 0; -const scripts = [`eslint --fix ${JavaScriptFiles.join(' ')}`]; - -for await (const element of scripts) { - try { - exitCode = await execute(`pnpm exec ${element}`); - } catch (p) { - exitCode = p.exitCode; - } - process.exitCode = exitCode > 0 ? exitCode : 0; -} diff --git a/build/tasks/format/format-js.mts b/build/tasks/format/format-js.mts new file mode 100644 index 00000000..17912bc1 --- /dev/null +++ b/build/tasks/format/format-js.mts @@ -0,0 +1,26 @@ +/** + * @file Format JavaScript files to adhere to autofixable style guidelines. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/format/format-js + */ + +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +const jsFiles = await glob([ + '**/*.js', + '**/*.mjs', + '!doc/_site/', + '!lib/', + '!node_modules/', + '!vendor/', +]); + +let exitCode = 0; +const scripts = [`biome check --write ${quote(jsFiles)}`]; + +for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; +} diff --git a/build/tasks/format/format-json.mjs b/build/tasks/format/format-json.mjs deleted file mode 100644 index 97819570..00000000 --- a/build/tasks/format/format-json.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import { execute } from '@yarnpkg/shell'; -import { $ } from 'zx'; - -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('format-json', import.meta.url); - -const JSONObject = - await $`bundle exec github-linguist --breakdown --json | jq '.JSON.files'`; -const JSONFiles = JSON.parse(JSONObject.stdout); - -let exitCode = 0; -const scripts = [`dprint fmt ${JSONFiles.join(' ')}`]; - -for await (const element of scripts) { - try { - exitCode = await execute(`pnpm exec ${element}`); - } catch (p) { - exitCode = p.exitCode; - } - process.exitCode = exitCode > 0 ? exitCode : 0; -} diff --git a/build/tasks/format/format-json.mts b/build/tasks/format/format-json.mts new file mode 100644 index 00000000..8463337b --- /dev/null +++ b/build/tasks/format/format-json.mts @@ -0,0 +1,29 @@ +/** + * @file Format JSON files to adhere to autofixable style guidelines. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/format/format-json + */ + +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +const EXCLUDED = ['!doc/_site/', '!lib/', '!node_modules/', '!vendor/']; + +const jsonFiles = await glob(['**/*.json', '**/*.jsonc', ...EXCLUDED]); +// Biome has no JSON5 parser. Handed one it reports the path as ignored and +// carries on with the rest, so listing `**/*.json5` beside the others read as +// coverage while being none: nothing looked at `.renovaterc.json5` or +// `OWNERS.json5` at all. Prettier does have the parser. +const json5Files = await glob(['**/*.json5', ...EXCLUDED]); + +let exitCode = 0; +const scripts = [ + `biome check --write ${quote(jsonFiles)}`, + ...(json5Files.length > 0 ? [`prettier --write ${quote(json5Files)}`] : []), +]; + +for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; +} diff --git a/build/tasks/format/format-liquid.mts b/build/tasks/format/format-liquid.mts new file mode 100644 index 00000000..52c44979 --- /dev/null +++ b/build/tasks/format/format-liquid.mts @@ -0,0 +1,26 @@ +/** + * @file Format Liquid templates to adhere to autofixable style guidelines. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/format/format-liquid + */ + +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +const liquidFiles = await glob([ + '**/*.html', + '**/*.liquid', + '!doc/_site/', + '!lib/', + '!node_modules/', + '!vendor/', +]); + +let exitCode = 0; +const scripts = [`prettier --write ${quote(liquidFiles)}`]; + +for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; +} diff --git a/build/tasks/format/format-md.mjs b/build/tasks/format/format-md.mjs deleted file mode 100644 index 33b40b97..00000000 --- a/build/tasks/format/format-md.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import { execute } from '@yarnpkg/shell'; -import { $ } from 'zx'; - -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('format-md', import.meta.url); - -const MarkdownObject = - await $`bundle exec github-linguist --breakdown --json | jq '.Markdown.files'`; -const MarkdownFiles = JSON.parse(MarkdownObject.stdout); - -let exitCode = 0; -const scripts = [ - // fix style of JS/TS code blocks in Markdown - `eslint --fix ${MarkdownFiles.join(' ')}`, - 'prettier --write **/*{.*.md,.md}', // Markdown fix sty;e - // validate Markdown - 'markdownlint-cli2-fix "**/**.md" "#node_modules" "#vendor"', -]; - -for await (const element of scripts) { - try { - exitCode = await execute(`pnpm exec ${element}`); - } catch (p) { - exitCode = p.exitCode; - } - process.exitCode = exitCode > 0 ? exitCode : 0; -} diff --git a/build/tasks/format/format-md.mts b/build/tasks/format/format-md.mts new file mode 100644 index 00000000..67a6e9ea --- /dev/null +++ b/build/tasks/format/format-md.mts @@ -0,0 +1,31 @@ +/** + * @file Format Markdown files to adhere to autofixable style guidelines. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/format/format-md + */ + +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +const markdownFiles = await glob([ + '**/*.md', + '!doc/_site/', + '!lib/', + '!node_modules/', + '!vendor/', + // Other people's words, reproduced verbatim. + '!**/COPYING.md', + '!LICENSE/', +]); + +let exitCode = 0; +const scripts = [ + `prettier --write ${quote(markdownFiles)}`, + `markdownlint-cli2 --fix ${quote(markdownFiles)}`, +]; + +for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; +} diff --git a/build/tasks/format/format-svg.mjs b/build/tasks/format/format-svg.mjs deleted file mode 100644 index e6a38f5d..00000000 --- a/build/tasks/format/format-svg.mjs +++ /dev/null @@ -1,9 +0,0 @@ -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('format-svg', import.meta.url); - -// TODO(DerekNonGeneric): Implement this task. -process.exitCode = 0; - -// eslint-disable-next-line unicorn/no-process-exit -process.exit(0); diff --git a/build/tasks/format/format-toml.mjs b/build/tasks/format/format-toml.mjs deleted file mode 100644 index a6352ff7..00000000 --- a/build/tasks/format/format-toml.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import { execute } from '@yarnpkg/shell'; -import { $ } from 'zx'; - -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('format-toml', import.meta.url); - -const TOMLObject = - await $`bundle exec github-linguist --breakdown --json | jq '.TOML.files'`; -const TOMLFiles = JSON.parse(TOMLObject.stdout); - -let exitCode = 0; -const scripts = [ - `dprint fmt ${TOMLFiles.join(' ')}`, // validate & style-check -]; - -for await (const element of scripts) { - try { - exitCode = await execute(`pnpm exec ${element}`); - } catch (p) { - exitCode = p.exitCode; - } - process.exitCode = exitCode > 0 ? exitCode : 0; -} diff --git a/build/tasks/format/format-toml.mts b/build/tasks/format/format-toml.mts new file mode 100644 index 00000000..3dce7e76 --- /dev/null +++ b/build/tasks/format/format-toml.mts @@ -0,0 +1,25 @@ +/** + * @file Format TOML files to adhere to autofixable style guidelines. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/format/format-toml + */ + +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +const tomlFiles = await glob([ + '**/*.toml', + '!doc/_site/', + '!lib/', + '!node_modules/', + '!vendor/', +]); + +let exitCode = 0; +const scripts = [`dprint fmt ${quote(tomlFiles)}`]; + +for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; +} diff --git a/build/tasks/format/format-ts.mjs b/build/tasks/format/format-ts.mjs deleted file mode 100644 index 9c57fb9c..00000000 --- a/build/tasks/format/format-ts.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import { execute } from '@yarnpkg/shell'; -import { $ } from 'zx'; - -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('format-ts', import.meta.url); - -const TypeScriptObject = - await $`bundle exec github-linguist --breakdown --json | jq '.TypeScript.files'`; -const TypeScriptFiles = JSON.parse(TypeScriptObject.stdout); - -let exitCode = 0; -const scripts = [ - `eslint --fix ${TypeScriptFiles.join(' ')}`, // fix style -]; - -for await (const element of scripts) { - try { - exitCode = await execute(`pnpm exec ${element}`); - } catch (p) { - exitCode = p.exitCode; - } - process.exitCode = exitCode > 0 ? exitCode : 0; -} diff --git a/build/tasks/format/format-ts.mts b/build/tasks/format/format-ts.mts new file mode 100644 index 00000000..36c6236a --- /dev/null +++ b/build/tasks/format/format-ts.mts @@ -0,0 +1,26 @@ +/** + * @file Format TypeScript files to adhere to autofixable style guidelines. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/format/format-ts + */ + +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +const tsFiles = await glob([ + '**/*.ts', + '**/*.mts', + '!doc/_site/', + '!lib/', + '!node_modules/', + '!vendor/', +]); + +let exitCode = 0; +const scripts = [`biome check --write ${quote(tsFiles)}`]; + +for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; +} diff --git a/build/tasks/format/format-yaml.mjs b/build/tasks/format/format-yaml.mjs deleted file mode 100644 index ad4beefd..00000000 --- a/build/tasks/format/format-yaml.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import { execute } from '@yarnpkg/shell'; -import { $ } from 'zx'; - -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('format-yaml', import.meta.url); - -const YAMLObject = - await $`bundle exec github-linguist --breakdown --json | jq '.YAML.files'`; -const YAMLFiles = JSON.parse(YAMLObject.stdout); - -let exitCode = 0; -const scripts = [ - `eslint --fix ${YAMLFiles.join(' ')}`, // validate & style-check -]; - -for await (const element of scripts) { - try { - exitCode = await execute(`pnpm exec ${element}`); - } catch (p) { - exitCode = p.exitCode; - } - process.exitCode = exitCode > 0 ? exitCode : 0; -} diff --git a/build/tasks/format/format-yaml.mts b/build/tasks/format/format-yaml.mts new file mode 100644 index 00000000..db14e46d --- /dev/null +++ b/build/tasks/format/format-yaml.mts @@ -0,0 +1,28 @@ +/** + * @file Format YAML files to adhere to autofixable style guidelines. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/format/format-yaml + */ + +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +const yamlFiles = await glob([ + '**/*.yml', + '**/*.yaml', + '!doc/_site/', + '!lib/', + '!node_modules/', + '!vendor/', + // Written by pnpm, not by hand. + '!pnpm-lock.yaml', +]); + +let exitCode = 0; +const scripts = [`prettier --write ${quote(yamlFiles)}`]; + +for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; +} diff --git a/build/tasks/util.mjs b/build/tasks/util.mjs deleted file mode 100644 index 1ca4705b..00000000 --- a/build/tasks/util.mjs +++ /dev/null @@ -1,48 +0,0 @@ -import text from '@openinf/util-text'; -import url from 'url'; -import { chalk, echo, os, path } from 'zx'; - -const __filename = url.fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -/** - * Helper to make it possible to use absolute project paths. - * @param {string} relativePath A path relative to the project root - * @return {string} An absolute ready-to-use path - */ -function absolute(relativePath) { - return path.resolve(path.dirname(path.dirname(__dirname)), relativePath); -} - -/** - * Various paths that are critical to the project and used throughout. - */ -export const PATHS = { - projectRoot: absolute('.'), - manifest: absolute('./package.json'), -}; - -function taskURLToRelativePath(taskURL) { - const taskPath = url.fileURLToPath(taskURL); - return path.relative(PATHS.projectRoot, taskPath); -} - -export function echoTaskRunning(taskName, taskURL) { - echo( - chalk.cyan( - `${ - os.EOL + - String(text.UnicodeEscapes.midlineEllipsis) - .padStart(3, ' ') - .padEnd(5, ' ') - } Running task named ${text.curlyQuote(taskName)}, which executes ${ - os.EOL + - String(text.UnicodeEscapes.midlineEllipsis) - .padStart(3, ' ') - .padEnd(6, ' ') + - text.curlyQuote(taskURLToRelativePath(taskURL)) + - os.EOL - }` - ) - ); -} diff --git a/build/tasks/verify/verify-commits.mts b/build/tasks/verify/verify-commits.mts new file mode 100644 index 00000000..78daaa31 --- /dev/null +++ b/build/tasks/verify/verify-commits.mts @@ -0,0 +1,112 @@ +/** + * @file Verify commit messages on this branch follow the project's format. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/verify/verify-commits + */ + +import { execFileSync } from 'node:child_process'; +import { + checkSignOff, + readTrailers, + validateCommitMessage, +} from '@openinf/.github/build/commit-message'; + +/** + * A GitHub app commits as `+[bot]@users.noreply.github.com`, and the + * `[bot]` is the part that is reserved -- an account cannot be named with it. + */ +const BOT_AUTHOR = /\[bot\]@users\.noreply\.github\.com$/; + +const git = (...args: string[]) => + execFileSync('git', args, { encoding: 'utf8' }).trim(); + +/** + * Finds what to compare against: the base branch of the pull request when a + * workflow says so, and the default branch otherwise. + * @returns {string} A revision, or an empty string if none could be resolved. + */ +const resolveBase = () => { + const candidates = [ + process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : '', + 'origin/main', + 'main', + ].filter(Boolean); + + for (const candidate of candidates) { + try { + return git('rev-parse', '--verify', '--quiet', `${candidate}^{commit}`); + } catch { + // Try the next one; a shallow clone has few of these. + } + } + + return ''; +}; + +const base = resolveBase(); + +if (base === '') { + console.error( + 'Could not resolve a base revision to compare against. In a workflow ' + + 'this means the checkout is too shallow -- `fetch-depth: 0` gives the ' + + 'history this needs.' + ); + process.exitCode = 1; +} else { + const range = `${base}..HEAD`; + const shas = git('rev-list', '--no-merges', range) + .split('\n') + .filter(Boolean); + let failed = 0; + let skipped = 0; + + for (const sha of shas) { + // Renovate writes `chore(deps): …` and dependabot writes `Bump x from y + // to z`, neither of which is this format, and neither of which is theirs + // to change. Holding them to it would leave every dependency update + // failing its checks and, since they automerge on green, never landing. + if (BOT_AUTHOR.test(git('log', '-1', '--format=%ae', sha))) { + skipped += 1; + continue; + } + + const message = git('log', '-1', '--format=%B', sha); + const problems = [ + ...validateCommitMessage(message), + ...checkSignOff(message, git('log', '-1', '--format=%an <%ae>', sha)), + ]; + + // git has the final say on what counts as a trailer, so the rules above + // are cross-checked against it rather than trusted on their own. A + // disagreement means the rules have drifted from the tool they describe. + const parsed = execFileSync('git', ['interpret-trailers', '--parse'], { + encoding: 'utf8', + input: message, + }) + .split('\n') + .filter(Boolean); + const expected = readTrailers(message); + + if (problems.length === 0 && parsed.length !== expected.length) { + problems.push( + `git reads ${parsed.length} trailers here where the rules read ${expected.length}; the two have drifted apart` + ); + } + + if (problems.length > 0) { + failed += 1; + console.error(`${sha.slice(0, 9)} ${message.split('\n')[0]}`); + for (const problem of problems) console.error(` ${problem}`); + } + } + + const checked = shas.length - skipped; + + console.log( + `Checked ${checked} commit${checked === 1 ? '' : 's'} in ${range}` + + (skipped > 0 ? `, leaving ${skipped} written by a bot.` : '.') + ); + + if (failed > 0) process.exitCode = 1; +} diff --git a/build/tasks/verify/verify-css.mjs b/build/tasks/verify/verify-css.mjs deleted file mode 100644 index 86316138..00000000 --- a/build/tasks/verify/verify-css.mjs +++ /dev/null @@ -1,11 +0,0 @@ -// import { execute } from '@yarnpkg/shell'; - -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('verify-css', import.meta.url); - -// TODO(DerekNonGeneric): Implement this task. -process.exitCode = 0; - -// eslint-disable-next-line unicorn/no-process-exit -process.exit(0); diff --git a/build/tasks/verify/verify-dockerfile.mts b/build/tasks/verify/verify-dockerfile.mts new file mode 100644 index 00000000..cf1a4c8b --- /dev/null +++ b/build/tasks/verify/verify-dockerfile.mts @@ -0,0 +1,22 @@ +/** + * @file Verify Dockerfiles are valid & adhere to checkable style guidelines. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/verify/verify-dockerfile + */ + +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +const dockerfileFiles = await glob([ + '.devcontainer/**/Dockerfile', + '!node_modules/', +]); + +let exitCode = 0; +const scripts = [`dprint check ${quote(dockerfileFiles)}`]; + +for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; +} diff --git a/build/tasks/verify/verify-ec-harmony.mjs b/build/tasks/verify/verify-ec-harmony.mjs deleted file mode 100644 index b5081914..00000000 --- a/build/tasks/verify/verify-ec-harmony.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import { execute } from '@yarnpkg/shell'; - -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('verify-ec-harmony', import.meta.url); - -let exitCode = 0; -const scripts = ["editorconfig-checker -config '.ecrc.json'"]; - -for await (const element of scripts) { - try { - exitCode = await execute(`pnpm exec ${element}`); - } catch (p) { - exitCode = p.exitCode; - } - process.exitCode = exitCode > 0 ? exitCode : 0; -} - -// eslint-disable-next-line unicorn/no-process-exit -process.exit(0); diff --git a/build/tasks/verify/verify-file-modes.mts b/build/tasks/verify/verify-file-modes.mts new file mode 100644 index 00000000..ca399792 --- /dev/null +++ b/build/tasks/verify/verify-file-modes.mts @@ -0,0 +1,75 @@ +/** + * @file Verify only the files meant to be run are marked executable. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/verify/verify-file-modes + */ + +import { execFileSync } from 'node:child_process'; +import { open } from 'node:fs/promises'; + +// The mode git records, rather than the mode on disk: that is what other +// clones receive, and it is the only one a checkout on a filesystem without +// permission bits still reports faithfully. +const tracked = execFileSync('git', ['ls-files', '--stage', '-z'], { + encoding: 'utf8', +}) + .split('\0') + .filter(Boolean) + .map((entry) => { + const [mode] = entry.split(' '); + + return { mode, path: entry.slice(entry.indexOf('\t') + 1) }; + }); + +/** + * Reads the first two bytes, which is all it takes to know whether a file + * expects to be run as a program. + * @param {string} path The file to inspect. + * @returns {Promise} Whether the file opens with `#!`. + */ +const hasShebang = async (path: string) => { + // Tracked but not on disk, which is what a half-finished `git rm` or an + // interrupted checkout leaves behind. Nothing to read, and the mode of a + // file that is not there is not this task's argument to make. + const file = await open(path).catch(() => undefined); + + if (file === undefined) return undefined; + + try { + const { buffer, bytesRead } = await file.read(Buffer.alloc(2), 0, 2, 0); + + return bytesRead === 2 && buffer.toString('latin1') === '#!'; + } finally { + await file.close(); + } +}; + +const offenders: string[] = []; + +for (const { mode, path } of tracked) { + // Symlinks (120000) and submodules (160000) carry neither the bit nor a + // shebang to read, so only the two regular-file modes are of interest. + if (mode !== '100755' && mode !== '100644') continue; + + const executable = mode === '100755'; + const runnable = await hasShebang(path); + + if (runnable === undefined) continue; + + if (executable && !runnable) { + offenders.push(` ${path} is executable but has no \`#!\` line`); + } else if (runnable && !executable) { + offenders.push(` ${path} opens with \`#!\` but is not executable`); + } +} + +if (offenders.length > 0) { + console.error( + `File modes disagree with what the files are:\n${offenders.join('\n')}\n\n` + + 'Run `chmod +x` or `chmod -x` to settle it. Nothing else in the ' + + 'pipeline looks at modes, which is how 23 files came to claim they ' + + 'were programs.' + ); + process.exitCode = 1; +} diff --git a/build/tasks/verify/verify-filenames.mts b/build/tasks/verify/verify-filenames.mts new file mode 100644 index 00000000..28ed0794 --- /dev/null +++ b/build/tasks/verify/verify-filenames.mts @@ -0,0 +1,82 @@ +/** + * @file Verify filenames adhere to the project naming convention. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/verify/verify-filenames + */ + +import { glob } from '@openinf/.github/build/utils'; + +/** + * Lowercase words joined by hyphens. A leading underscore is Eleventy's and + * Sass's marking for something that is not itself output -- `_layouts/`, + * `_custom.scss` -- and is left alone. + */ +const KEBAB_CASE = /^_?[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * The shouted names convention reserves for metadata that sits beside the + * work rather than being part of it: `README.md`, `AUTHORS`, `LICENSE/`, and + * the license identifiers inside it, such as `CC-BY-SA-4.0.txt`. + */ +const METADATA_CASE = /^[A-Z][A-Z0-9_-]*$/; + +/** + * Names a tool dictates and we do not get to choose. Anything under a + * dot-directory is exempt wholesale, on the same reasoning. + */ +const EXEMPT = new Set([ + // Bundler looks for this name and no other. + 'Gemfile', + 'Gemfile.lock', +]); + +const files = await glob([ + '**/*', + '!doc/_site/', + '!lib/', + '!node_modules/', + '!vendor/', + // SPDX identifiers, which are neither kebab-case nor shouted and are not + // ours to rename: the name is the identifier. + '!LICENSE/', +]); + +// A directory is only ever seen here as part of some file's path, and the +// same directory is part of many, so each is judged once. +const checked = new Set(); +const offenders: string[] = []; + +for (const file of files) { + const segments = file.split('/'); + + // `.github/ISSUE_TEMPLATE/`, `.vscode/settings.json`: whatever reads these + // decides what they are called. + if (segments.some((segment) => segment.startsWith('.'))) continue; + + for (const [index, segment] of segments.entries()) { + const path = segments.slice(0, index + 1).join('/'); + + if (checked.has(path) || EXEMPT.has(path)) continue; + + checked.add(path); + + // Extensions are not part of the name, and there may be several of them + // (`vnu-jar.d.ts`, `main.min.css`). Directories have none to shed. + const isFile = index === segments.length - 1; + const name = isFile ? (segment.split('.')[0] ?? segment) : segment; + + if (!(KEBAB_CASE.test(name) || METADATA_CASE.test(name))) { + offenders.push(path); + } + } +} + +if (offenders.length > 0) { + console.error( + `Not in kebab-case:\n${offenders.map((path) => ` ${path}`).join('\n')}\n\n` + + 'Renaming a file changes what links to it, and under `collections/` it ' + + 'changes a published URL, so this task reports rather than fixes.' + ); + process.exitCode = 1; +} diff --git a/build/tasks/verify/verify-html.mjs b/build/tasks/verify/verify-html.mjs deleted file mode 100644 index 381ad2eb..00000000 --- a/build/tasks/verify/verify-html.mjs +++ /dev/null @@ -1,11 +0,0 @@ -// import { execute } from '@yarnpkg/shell'; - -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('verify-html', import.meta.url); - -// TODO(DerekNonGeneric): Implement this task. -process.exitCode = 0; - -// eslint-disable-next-line unicorn/no-process-exit -process.exit(0); diff --git a/build/tasks/verify/verify-js.mjs b/build/tasks/verify/verify-js.mjs deleted file mode 100644 index 3c9681c8..00000000 --- a/build/tasks/verify/verify-js.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import { execute } from '@yarnpkg/shell'; -import { $ } from 'zx'; - -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('verify-js', import.meta.url); - -const JavaScriptObject = - await $`bundle exec github-linguist --breakdown --json | jq '.JavaScript.files'`; -const JavaScriptFiles = JSON.parse(JavaScriptObject.stdout); - -let exitCode = 0; -const scripts = [`eslint ${JavaScriptFiles.join(' ')}`]; - -for await (const element of scripts) { - try { - exitCode = await execute(`pnpm exec ${element}`); - } catch (p) { - exitCode = p.exitCode; - } - process.exitCode = exitCode > 0 ? exitCode : 0; -} - -// eslint-disable-next-line unicorn/no-process-exit -process.exit(exitCode); diff --git a/build/tasks/verify/verify-js.mts b/build/tasks/verify/verify-js.mts new file mode 100644 index 00000000..e8b6ba78 --- /dev/null +++ b/build/tasks/verify/verify-js.mts @@ -0,0 +1,26 @@ +/** + * @file Verify JavaScript files are valid & adhere to checkable style guidelines. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/verify/verify-js + */ + +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +const jsFiles = await glob([ + '**/*.js', + '**/*.mjs', + '!doc/_site/', + '!lib/', + '!node_modules/', + '!vendor/', +]); + +let exitCode = 0; +const scripts = [`biome check ${quote(jsFiles)}`]; + +for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; +} diff --git a/build/tasks/verify/verify-json.mjs b/build/tasks/verify/verify-json.mjs deleted file mode 100644 index db1f2d99..00000000 --- a/build/tasks/verify/verify-json.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import { execute } from '@yarnpkg/shell'; -import { $ } from 'zx'; - -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('verify-json', import.meta.url); - -const JSONObject = - await $`bundle exec github-linguist --breakdown --json | jq '.JSON.files'`; -const JSONFiles = JSON.parse(JSONObject.stdout); - -let exitCode = 0; -const scripts = [`dprint check ${JSONFiles.join(' ')}`]; - -for await (const element of scripts) { - try { - exitCode = await execute(`pnpm exec ${element}`); - } catch (p) { - exitCode = p.exitCode; - } - process.exitCode = exitCode > 0 ? exitCode : 0; -} diff --git a/build/tasks/verify/verify-json.mts b/build/tasks/verify/verify-json.mts new file mode 100644 index 00000000..fb2d8a98 --- /dev/null +++ b/build/tasks/verify/verify-json.mts @@ -0,0 +1,29 @@ +/** + * @file Verify JSON files are valid & adhere to checkable style guidelines. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/verify/verify-json + */ + +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +const EXCLUDED = ['!doc/_site/', '!lib/', '!node_modules/', '!vendor/']; + +const jsonFiles = await glob(['**/*.json', '**/*.jsonc', ...EXCLUDED]); +// Biome has no JSON5 parser. Handed one it reports the path as ignored and +// carries on with the rest, so listing `**/*.json5` beside the others read as +// coverage while being none: nothing looked at `.renovaterc.json5` or +// `OWNERS.json5` at all. Prettier does have the parser. +const json5Files = await glob(['**/*.json5', ...EXCLUDED]); + +let exitCode = 0; +const scripts = [ + `biome check ${quote(jsonFiles)}`, + ...(json5Files.length > 0 ? [`prettier --check ${quote(json5Files)}`] : []), +]; + +for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; +} diff --git a/build/tasks/verify/verify-liquid.mts b/build/tasks/verify/verify-liquid.mts new file mode 100644 index 00000000..220040af --- /dev/null +++ b/build/tasks/verify/verify-liquid.mts @@ -0,0 +1,26 @@ +/** + * @file Verify Liquid templates adhere to checkable style guidelines. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/verify/verify-liquid + */ + +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +const liquidFiles = await glob([ + '**/*.html', + '**/*.liquid', + '!doc/_site/', + '!lib/', + '!node_modules/', + '!vendor/', +]); + +let exitCode = 0; +const scripts = [`prettier --check ${quote(liquidFiles)}`]; + +for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; +} diff --git a/build/tasks/verify/verify-md.mjs b/build/tasks/verify/verify-md.mjs deleted file mode 100644 index 7aceffb6..00000000 --- a/build/tasks/verify/verify-md.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import { execute } from '@yarnpkg/shell'; -import { $ } from 'zx'; - -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('verify-markdown', import.meta.url); - -const MarkdownObject = - await $`bundle exec github-linguist --breakdown --json | jq '.Markdown.files'`; -const MarkdownFiles = JSON.parse(MarkdownObject.stdout); - -let exitCode = 0; -const scripts = [ - `eslint ${MarkdownFiles.join(' ')}`, // code-block style-check - `prettier --check ${MarkdownFiles.join(' ')}`, // style-check - // validate Markdown - 'markdownlint-cli2 "**/**.md" "#node_modules" "#vendor"', - 'remark -qf .', -]; - -for (const element of scripts) { - try { - exitCode = await execute(`pnpm exec ${element}`); - } catch (p) { - exitCode = p.exitCode; - } - process.exitCode = exitCode > 0 ? exitCode : 0; -} diff --git a/build/tasks/verify/verify-md.mts b/build/tasks/verify/verify-md.mts new file mode 100644 index 00000000..ef83a325 --- /dev/null +++ b/build/tasks/verify/verify-md.mts @@ -0,0 +1,32 @@ +/** + * @file Verify Markdown files are valid & adhere to checkable style guidelines. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/verify/verify-md + */ + +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +const markdownFiles = await glob([ + '**/*.md', + '!doc/_site/', + '!lib/', + '!node_modules/', + '!vendor/', + // Other people's words, reproduced verbatim. + '!**/COPYING.md', + '!LICENSE/', +]); + +let exitCode = 0; +const scripts = [ + `prettier --check ${quote(markdownFiles)}`, + `markdownlint-cli2 ${quote(markdownFiles)}`, + `remark -f --silently-ignore ${quote(markdownFiles)}`, +]; + +for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; +} diff --git a/build/tasks/verify/verify-spelling.mts b/build/tasks/verify/verify-spelling.mts new file mode 100644 index 00000000..ef5c3d9c --- /dev/null +++ b/build/tasks/verify/verify-spelling.mts @@ -0,0 +1,30 @@ +/** + * @file Verify prose everywhere is spelt the way this project spells it. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/verify/verify-spelling + */ + +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +// Comments and template prose are read as often as the documentation is, and +// cspell's `en` dictionary is the American one, so this is also what holds the +// project to American spelling: a British variant is an unknown word to it. +const files = await glob([ + '**/*.{md,html,mts,mjs,json,json5,jsonc,yml,yaml,sh}', + '!.pnpm-store/', + '!doc/_site/', + '!lib/', + '!node_modules/', + '!vendor/', + // Other people's words, reproduced verbatim: a license is not ours to edit, + // a lockfile is not prose, and the translated VISION files are not English. + '!**/COPYING.md', + '!LICENSE/', + '!pnpm-lock.yaml', + '!VISION.jp.md', + '!VISION.sr_Cyr.md', + '!VISION.sr_Latn.md', +]); + +process.exitCode = await exec(`cspell lint ${quote(files)}`); diff --git a/build/tasks/verify/verify-svg.mjs b/build/tasks/verify/verify-svg.mjs deleted file mode 100644 index 96de7042..00000000 --- a/build/tasks/verify/verify-svg.mjs +++ /dev/null @@ -1,8 +0,0 @@ -// import { execute } from '@yarnpkg/shell'; - -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('verify-svg', import.meta.url); - -// TODO(DerekNonGeneric): Implement this task. -process.exitCode = 0; diff --git a/build/tasks/verify/verify-toml.mjs b/build/tasks/verify/verify-toml.mjs deleted file mode 100644 index 7460674e..00000000 --- a/build/tasks/verify/verify-toml.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import { execute } from '@yarnpkg/shell'; -import { $ } from 'zx'; - -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('verify-toml', import.meta.url); - -const TOMLObject = - await $`bundle exec github-linguist --breakdown --json | jq '.TOML.files'`; -const TOMLFiles = JSON.parse(TOMLObject.stdout); - -let exitCode = 0; -const scripts = [ - `dprint check ${TOMLFiles.join(' ')}`, // validate & style-check -]; - -for await (const element of scripts) { - try { - exitCode = await execute(`pnpm exec ${element}`); - } catch (p) { - exitCode = p.exitCode; - } - process.exitCode = exitCode > 0 ? exitCode : 0; -} diff --git a/build/tasks/verify/verify-toml.mts b/build/tasks/verify/verify-toml.mts new file mode 100644 index 00000000..90529d62 --- /dev/null +++ b/build/tasks/verify/verify-toml.mts @@ -0,0 +1,25 @@ +/** + * @file Verify TOML files are valid & adhere to checkable style guidelines. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/verify/verify-toml + */ + +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +const tomlFiles = await glob([ + '**/*.toml', + '!doc/_site/', + '!lib/', + '!node_modules/', + '!vendor/', +]); + +let exitCode = 0; +const scripts = [`dprint check ${quote(tomlFiles)}`]; + +for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; +} diff --git a/build/tasks/verify/verify-ts.mjs b/build/tasks/verify/verify-ts.mjs deleted file mode 100644 index bbb8a1b7..00000000 --- a/build/tasks/verify/verify-ts.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import { execute } from '@yarnpkg/shell'; -import { $ } from 'zx'; - -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('verify-ts', import.meta.url); - -const TypeScriptObject = - await $`bundle exec github-linguist --breakdown --json | jq '.TypeScript.files'`; -const TypeScriptFiles = JSON.parse(TypeScriptObject.stdout); - -let exitCode = 0; -const scripts = [ - `eslint ${TypeScriptFiles.join(' ')}`, // validate & style-check -]; - -for await (const element of scripts) { - try { - exitCode = await execute(`pnpm exec ${element}`); - } catch (p) { - exitCode = p.exitCode; - } - process.exitCode = exitCode > 0 ? exitCode : 0; -} diff --git a/build/tasks/verify/verify-ts.mts b/build/tasks/verify/verify-ts.mts new file mode 100644 index 00000000..1e0ce0ef --- /dev/null +++ b/build/tasks/verify/verify-ts.mts @@ -0,0 +1,29 @@ +/** + * @file Verify TypeScript files are valid & adhere to checkable style guidelines. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/verify/verify-ts + */ + +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +const tsFiles = await glob([ + '**/*.ts', + '**/*.mts', + '!doc/_site/', + '!lib/', + '!node_modules/', + '!vendor/', +]); + +let exitCode = 0; +// tsc reads its file list from tsconfig.json rather than taking one, and +// `erasableSyntaxOnly` there is what stops syntax node refuses to strip from +// reaching a task script. +const scripts = [`biome check ${quote(tsFiles)}`, 'tsc --noEmit']; + +for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; +} diff --git a/build/tasks/verify/verify-unit.mts b/build/tasks/verify/verify-unit.mts new file mode 100644 index 00000000..79d29b36 --- /dev/null +++ b/build/tasks/verify/verify-unit.mts @@ -0,0 +1,28 @@ +/** + * @file Verify the build task helpers behave as their callers assume. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/verify/verify-unit + */ + +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +const testFiles = await glob(['**/*.test.mts', '!node_modules/']); + +// `node --test` handed a pattern that matches nothing exits 0, so a task that +// only forwarded the pattern would report success having run no tests. The +// count is the guard against that -- and against `glob` itself finding +// nothing, which is among the failures these very tests exist to catch. +if (testFiles.length === 0) { + console.error('No test files matched `**/*.test.mts`.'); + process.exitCode = 1; +} else { + let exitCode = 0; + const scripts = [`node --test ${quote(testFiles)}`]; + + for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; + } +} diff --git a/build/tasks/verify/verify-valid-for-ec.mts b/build/tasks/verify/verify-valid-for-ec.mts new file mode 100644 index 00000000..7edda0b7 --- /dev/null +++ b/build/tasks/verify/verify-valid-for-ec.mts @@ -0,0 +1,17 @@ +/** + * @file Verify files are valid for EditorConfig checker. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/verify/verify-valid-for-ec + */ + +import { exec } from '@openinf/.github/build/utils'; + +let exitCode = 0; +const scripts = ["editorconfig-checker -config '.ecrc.json'"]; + +for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; +} diff --git a/build/tasks/verify/verify-yaml.mjs b/build/tasks/verify/verify-yaml.mjs deleted file mode 100644 index e2c4ec2f..00000000 --- a/build/tasks/verify/verify-yaml.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import { execute } from '@yarnpkg/shell'; -import { $ } from 'zx'; - -import { echoTaskRunning } from '../util.mjs'; - -echoTaskRunning('verify-yaml', import.meta.url); - -const YAMLObject = - await $`bundle exec github-linguist --breakdown --json | jq '.YAML.files'`; -const YAMLFiles = JSON.parse(YAMLObject.stdout); - -let exitCode = 0; -const scripts = [ - `eslint ${YAMLFiles.join(' ')}`, // validate & style-check -]; - -for await (const element of scripts) { - try { - exitCode = await execute(`pnpm exec ${element}`); - } catch (p) { - exitCode = p.exitCode; - } - process.exitCode = exitCode > 0 ? exitCode : 0; -} - -// eslint-disable-next-line unicorn/no-process-exit -process.exit(exitCode); diff --git a/build/tasks/verify/verify-yaml.mts b/build/tasks/verify/verify-yaml.mts new file mode 100644 index 00000000..34cab390 --- /dev/null +++ b/build/tasks/verify/verify-yaml.mts @@ -0,0 +1,28 @@ +/** + * @file Verify YAML files are valid & adhere to checkable style guidelines. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/verify/verify-yaml + */ + +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +const yamlFiles = await glob([ + '**/*.yml', + '**/*.yaml', + '!doc/_site/', + '!lib/', + '!node_modules/', + '!vendor/', + // Written by pnpm, not by hand. + '!pnpm-lock.yaml', +]); + +let exitCode = 0; +const scripts = [`prettier --check ${quote(yamlFiles)}`]; + +for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; +} diff --git a/build/utils.mts b/build/utils.mts new file mode 100644 index 00000000..e3660534 --- /dev/null +++ b/build/utils.mts @@ -0,0 +1,133 @@ +/** + * @file Common Build Task Utilities. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/utils + */ + +// ----------------------------------------------------------------------------- +// Requirements +// ----------------------------------------------------------------------------- + +import { glob as nodeGlob } from 'node:fs/promises'; +import { join as pathJoin, relative as pathRelative } from 'node:path'; +import { catchWrap } from '@isaacs/catcher'; +import { execute } from '@yarnpkg/shell'; + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +export const exec = catchWrap(execute, 99); + +/** + * Quotes paths for the shell `exec` runs them through. Every task builds its + * command as one string, so a path is shell text by the time the tool sees it + * -- a space in a filename splits one argument into two, and a `$(...)` or a + * `;` in one is a command of somebody else's choosing running in CI. Names + * like that are what `verify.filenames` exists to catch, but it cannot be the + * guard here: it is one task among the rest, and a failing one does not stop + * the others from being handed what it just objected to. + * + * Single quotes, because inside them a shell expands nothing at all. The one + * character they cannot hold is a single quote, which is why an embedded one + * closes the run, escapes itself, and opens the next. + * @param {string | string[]} paths The paths to pass to a command. + * @returns {string} Them, quoted and joined by spaces, ready to interpolate. + */ +export const quote = (paths: string | string[]) => + [paths] + .flat() + .map((path) => `'${path.replaceAll("'", "'\\''")}'`) + .join(' '); + +/** + * Expands a trailing-slash directory pattern (e.g. `_site/`) to cover + * everything beneath it. On its own, a trailing slash matches just the one + * directory entry, which is never what a build task means by naming a + * directory. + * @param {string} pattern The glob pattern to expand. + * @returns {string} The pattern, expanded if it named a bare directory. + */ +const expandDirPattern = (pattern: string) => + pattern.endsWith('/') ? `${pattern}**` : pattern; + +/** + * Widens a pattern so that wildcards also match dot-prefixed names. Neither + * `*` nor `**` will do so on its own, which quietly kept every dot-config + * file out of the checks: `**` walked past `.github/`, and `*.mjs` never saw + * `.remarkrc.mjs`. Brace alternation is the way back in -- one alternative + * for descending through a dot directory, one for the dot file itself. + * + * A dot directory nested inside another (`.a/.b/`) is past what the syntax + * can express; there is none here, and one would have to be named outright. + * @param {string} pattern The glob pattern to widen. + * @returns {string[]} Patterns which between them match what the one did, dot names included. + */ +const expandDotPattern = (pattern: string) => { + const segments = pattern.split('/'); + const widened = segments + .map((segment, index) => { + if (segment === '**') return '{**,**/.*/**}'; + + // Only the basename decides whether a match is a dot file; a wildcard + // in the middle of the path is a directory name, covered above. + const isBasename = index === segments.length - 1; + + return isBasename && segment.startsWith('*') ? `{,.}${segment}` : segment; + }) + .join('/'); + + // A pattern whose own tail is `**` -- which is what naming a directory + // expands to -- has no basename segment to have been widened, so the dot + // files directly beneath it need a pattern of their own. + return segments.at(-1) === '**' + ? [widened, `${segments.slice(0, -1).join('/')}/**/{,.}*`] + : [widened]; +}; + +/** + * Matches files by glob pattern, `globby`-style. Three of globby's + * conveniences that `fs.promises.glob` lacks are reproduced here: + * `!`-prefixed patterns act as exclusions (the native API takes those as a + * separate option), wildcards match dot-prefixed names, and only files are + * returned (the native API yields directories alongside them). + * @param {string | string[]} patterns Glob patterns to include, optionally mixed with `!`-prefixed patterns to exclude. + * @returns {Promise} The matched file paths, relative to the cwd. + */ +export async function glob(patterns: string | string[]) { + const include = []; + // Matching dot names is what puts these in reach of a plain `**`, and no + // task has any business reading either. Excluded directories are pruned + // whole, dot entries included, so callers need not widen their own + // exclusions. + // + // `.pnpm-store/` is the package store, which lives in the project because + // the home directory is on another filesystem and hard links cannot cross + // one. It holds other people's files, including the copy of pnpm that + // `packageManager` asks for, so a check that reads it is checking the + // registry rather than this repository. + const exclude = ['.git/**', '.pnpm-store/**']; + + for (const pattern of [patterns].flat()) { + if (pattern.startsWith('!')) { + exclude.push(expandDirPattern(pattern.slice(1))); + } else { + include.push(...expandDotPattern(expandDirPattern(pattern))); + } + } + + const entries = await Array.fromAsync( + nodeGlob(include, { exclude, withFileTypes: true }) + ); + + // Callers join these into shell commands like `prettier --write `, + // where a directory argument would make the tool recurse and quietly undo + // the exclusions above — so drop directories, and rebuild the cwd-relative + // strings that matching with `withFileTypes` traded away. + return entries + .filter((entry) => entry.isFile()) + .map((entry) => + pathRelative(process.cwd(), pathJoin(entry.parentPath, entry.name)) + ); +} diff --git a/build/utils.test.mts b/build/utils.test.mts new file mode 100644 index 00000000..8c674191 --- /dev/null +++ b/build/utils.test.mts @@ -0,0 +1,169 @@ +/** + * @file Tests for the common build task utilities. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/utils.test + */ + +import { deepStrictEqual, ok } from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join as pathJoin } from 'node:path'; +import { after, before, describe, test } from 'node:test'; +import { exec, glob, quote } from '@openinf/.github/build/utils'; + +// Every pattern a build task writes is relative to the directory the task +// runs in, so the fixture has to become that directory. +const cwd = process.cwd(); + +/** Files laid out to cover what the tasks actually ask of `glob`. */ +const FIXTURE = [ + 'a.md', + '.hidden.md', // a dot file beside ordinary ones + 'sub/b.md', + 'sub/.hidden-too.md', // a dot file under a directory named outright + 'sub/nested/c.md', + '.dotdir/d.md', // a dot directory to descend through + '.dotdir/deep/e.md', + 'skipped/f.md', + 'skipped/.g.md', // a dot file inside an excluded directory + '.git/h.md', // git's own directory, never any task's business + 'a.txt', // a different extension, to prove patterns discriminate +]; + +const sorted = (paths: string[]) => [...paths].sort(); + +describe('glob', () => { + before(async () => { + const root = await mkdtemp(pathJoin(tmpdir(), 'openinf-glob-')); + + for (const path of FIXTURE) { + const full = pathJoin(root, path); + + await mkdir(dirname(full), { recursive: true }); + await writeFile(full, ''); + } + + process.chdir(root); + }); + + after(() => { + process.chdir(cwd); + }); + + test('returns paths relative to the working directory', async () => { + deepStrictEqual(await glob('a.md'), ['a.md']); + }); + + test('takes a lone pattern as well as a list', async () => { + deepStrictEqual(await glob(['a.md']), await glob('a.md')); + }); + + test('discriminates by extension', async () => { + deepStrictEqual(await glob('*.txt'), ['a.txt']); + }); + + test('excludes what a `!` pattern names', async () => { + const files = await glob(['**/*.md', '!skipped/']); + + ok(!files.some((file) => file.startsWith('skipped/'))); + ok(files.includes('sub/b.md')); + }); + + test('a trailing slash covers a whole subtree, not one entry', async () => { + // `sub/` on its own matches the directory and nothing in it, which is + // never what naming a directory is meant to mean. Dot files included: + // the pattern this expands to has no basename for the dot alternative to + // attach to, so they went missing until it was given one of its own. + deepStrictEqual(sorted(await glob('sub/')), [ + 'sub/.hidden-too.md', + 'sub/b.md', + 'sub/nested/c.md', + ]); + }); + + test('never returns a directory', async () => { + // Callers paste the result into shell commands, where a directory + // argument makes the tool recurse and quietly undo the exclusions. + const files = await glob(['**/*', '!.git/']); + + ok(!files.includes('sub')); + ok(!files.includes('.dotdir')); + ok(files.includes('sub/b.md')); + }); + + test('matches a dot file that a bare wildcard would skip', async () => { + ok((await glob('**/*.md')).includes('.hidden.md')); + }); + + test('descends into a dot directory', async () => { + const files = await glob('**/*.md'); + + ok(files.includes('.dotdir/d.md')); + ok(files.includes('.dotdir/deep/e.md')); + }); + + test('leaves .git alone without being asked', async () => { + ok(!(await glob('**/*.md')).some((file) => file.startsWith('.git/'))); + }); + + test('prunes dot files inside an excluded directory', async () => { + // The exclusion is written without regard for dot entries, so pruning + // has to cover them or matching dot names would reopen what it closed. + ok(!(await glob(['**/*.md', '!skipped/'])).includes('skipped/.g.md')); + }); +}); + +describe('quote', () => { + test('keeps a path with a space as one argument', () => { + deepStrictEqual( + quote(['sub/a b.md', 'sub/c.md']), + "'sub/a b.md' 'sub/c.md'" + ); + }); + + test('takes a lone path as well as a list', () => { + deepStrictEqual(quote('a.md'), "'a.md'"); + }); + + test('gives the shell nothing to expand', () => { + // Inside single quotes a shell expands nothing, so each of these reaches + // the tool as the filename it is rather than as syntax. + for (const name of ['$(id).md', '`id`.md', ';id;.md', 'a|b.md', 'a&b.md']) { + deepStrictEqual(quote(name), `'${name}'`); + } + }); + + test('escapes a single quote by closing and reopening the run', () => { + // The one character single quotes cannot hold. `'\''` is a closing + // quote, an escaped quote, and an opening quote. + deepStrictEqual(quote("it's.md"), "'it'\\''s.md'"); + }); + + test('survives a round trip through the shell it is written for', async () => { + // The escaping is only worth anything if the shell `exec` uses agrees + // with it, so this asks that shell rather than a model of it. + const home = await mkdtemp(pathJoin(tmpdir(), 'openinf-quote-')); + const names = ['a b.md', "it's.md", '$(id).md', ';id;.md']; + const here = process.cwd(); + + for (const name of names) await writeFile(pathJoin(home, name), ''); + + process.chdir(home); + + try { + // `ls -1` prints one name per line, redirected by the same shell that + // parsed the quoting, so what lands in the file is exactly what the + // command received: one argument each, and nothing expanded. + deepStrictEqual(await exec(`ls -1 ${quote(names)} > out.txt`), 0); + + const seen = (await readFile(pathJoin(home, 'out.txt'), 'utf8')) + .split('\n') + .filter(Boolean); + + deepStrictEqual(seen.sort(), [...names].sort()); + } finally { + process.chdir(here); + } + }); +}); diff --git a/cspell.json b/cspell.json new file mode 100644 index 00000000..85a042ae --- /dev/null +++ b/cspell.json @@ -0,0 +1,23 @@ +{ + "language": "en", + "dictionaries": ["project-terms"], + "dictionaryDefinitions": [ + { + "name": "project-terms", + "path": "./project-terms.txt" + } + ], + "ignorePaths": [ + ".pnpm-store/**", + "doc/_site/**", + "lib/**", + "node_modules/**", + "vendor/**", + "**/COPYING.md", + "LICENSE/**", + "pnpm-lock.yaml", + "VISION.jp.md", + "VISION.sr_Cyr.md", + "VISION.sr_Latn.md" + ] +} diff --git a/doc/_config.yml b/doc/_config.yml index b84774c8..25f1f7d9 100644 --- a/doc/_config.yml +++ b/doc/_config.yml @@ -24,10 +24,10 @@ description: >- # this means to ignore newlines until "baseurl:" Write an awesome description for your new site here. You can edit this line in _config.yml. It will appear in your document head meta (for Google search results) and in your feed.xml site description. -baseurl: "" # the subpath of your site, e.g. /blog -url: "" # the base hostname & protocol for your site, e.g. http://example.com +baseurl: '' # the subpath of your site, e.g. /blog +url: '' # the base hostname & protocol for your site, e.g. http://example.com twitter_username: jekyllrb -github_username: jekyll +github_username: jekyll # Build settings theme: minima @@ -45,28 +45,26 @@ collections: output: true permalink: /:name - defaults: - scope: - path: "" - type: "adrs" + path: '' + type: 'adrs' values: - layout: "adr" - - - scope: - path: "" - type: "pages" + layout: 'adr' + - scope: + path: '' + type: 'pages' values: - layout: "page" + layout: 'page' - scope: - path: "" - type: "posts" + path: '' + type: 'posts' values: - layout: "post" + layout: 'post' - scope: - path: "" + path: '' values: - layout: "default" + layout: 'default' # Exclude from processing. # The following items will not be processed, by default. diff --git a/doc/_layouts/default.html b/doc/_layouts/default.html index 2fde0b26..911df9f3 100644 --- a/doc/_layouts/default.html +++ b/doc/_layouts/default.html @@ -1,8 +1,10 @@ - + {%- include head.html -%} - {%- include header.html -%} {{- content -}} {%- include footer.html -%} + {%- include header.html -%} + {{- content -}} + {%- include footer.html -%} diff --git a/doc/collections/_adrs/0001/decision_for_decisions.md b/doc/collections/_adrs/0001/decision-for-decisions.md similarity index 100% rename from doc/collections/_adrs/0001/decision_for_decisions.md rename to doc/collections/_adrs/0001/decision-for-decisions.md diff --git a/doc/collections/_adrs/0002/decision_for_monorepos.md b/doc/collections/_adrs/0002/decision-for-monorepos.md similarity index 100% rename from doc/collections/_adrs/0002/decision_for_monorepos.md rename to doc/collections/_adrs/0002/decision-for-monorepos.md diff --git a/doc/collections/_adrs/0003/decision_for_build_dir_logic.md b/doc/collections/_adrs/0003/decision-for-build-dir-logic.md similarity index 95% rename from doc/collections/_adrs/0003/decision_for_build_dir_logic.md rename to doc/collections/_adrs/0003/decision-for-build-dir-logic.md index 574f91c8..7c5d630b 100644 --- a/doc/collections/_adrs/0003/decision_for_build_dir_logic.md +++ b/doc/collections/_adrs/0003/decision-for-build-dir-logic.md @@ -64,4 +64,5 @@ distrib/** linguist-generated=true > consequences. C. We must develop AI with a blueprint of ethical principles to > prevent dangerous, unintended consequences. — OpenINF Community Effort -[^1]: https://github.com/github-linguist/linguist/blob/master/docs/overrides.md +[^1]: + diff --git a/doc/collections/_adrs/0004/decision_for_tools_dir.md b/doc/collections/_adrs/0004/decision-for-tools-dir.md similarity index 100% rename from doc/collections/_adrs/0004/decision_for_tools_dir.md rename to doc/collections/_adrs/0004/decision-for-tools-dir.md diff --git a/doc/collections/_pages/404.html b/doc/collections/_pages/404.html index 086a5c9e..11ad56e8 100644 --- a/doc/collections/_pages/404.html +++ b/doc/collections/_pages/404.html @@ -2,7 +2,6 @@ permalink: /404.html layout: default --- -