diff --git a/.github/workflows/typescript.yaml b/.github/workflows/typescript.yaml new file mode 100644 index 00000000..4beaa53d --- /dev/null +++ b/.github/workflows/typescript.yaml @@ -0,0 +1,40 @@ +### ### Type check +### +### Type checks the frontend assets with TypeScript's checkJs. +### +### #### Assumptions +### +### 1. A docker compose service named `node` can be run and `npm` can be run +### inside the `node` service. +### 2. `npm run typecheck` is defined in `package.json`. + +on: pull_request + +name: Type check + +env: + COMPOSE_USER: runner + +jobs: + typescript: + name: Type check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Cache node_modules + uses: actions/cache@v6 + with: + path: node_modules + key: node-modules-${{ hashFiles('package-lock.json') }} + restore-keys: node-modules- + + - name: Setup network + run: docker network create frontend + + - name: Install dependencies + run: docker compose run --rm node npm install + + - name: Type check + run: docker compose run --rm node npm run typecheck diff --git a/.typecheck-strict-paths b/.typecheck-strict-paths new file mode 100644 index 00000000..2c5fa73c --- /dev/null +++ b/.typecheck-strict-paths @@ -0,0 +1 @@ +assets/client/util/id-from-path.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ded837..a0f8866d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ All notable changes to this project will be documented in this file. `@eslint-react` flat config, run via `task coding-standards:eslint:check` and a new CI job. `exhaustive-deps` is error-level in `assets/client` and `assets/shared` (outside the slide templates) and warn elsewhere pending burn-down. +- Added JSDoc type checking of the frontend assets with a grow-only strict ratchet + (`task code-analysis:assets`): files listed in `.typecheck-strict-paths` are checked with + `noImplicitAny`, so an unannotated shape fails the build at every use site. ## [3.0.0-rc8] - 2026-08-24 diff --git a/README.md b/README.md index 35ad72b2..06160c00 100644 --- a/README.md +++ b/README.md @@ -1199,6 +1199,28 @@ task phpstan:generate-baseline PHPStan [rule level](https://phpstan.org/user-guide/rule-levels) is set to level 6. +### Frontend type checking + +The frontend assets are type checked through JSDoc annotations with TypeScript's `checkJs` +([`tsconfig.checkjs.json`](tsconfig.checkjs.json)): + +```shell +task code-analysis:assets +``` + +`noImplicitAny` is on, which means reading or writing through an unannotated shape is an error — so a +missing `@type` annotation fails the build at every use site. Enforcing that across the whole tree at +once is not realistic, so the gate is a grow-only ratchet: only files listed in +[`.typecheck-strict-paths`](.typecheck-strict-paths) fail the build. + +**Add every `assets/` file you touch in a PR to `.typecheck-strict-paths` and burn its findings to zero +in that same PR.** The list only grows. + +Two notes on the annotation idiom: typedefs live next to the code as JSDoc, and quoted keys (the JSON-LD +`"@id"`) need the inline object-literal form — `@typedef {{ "@id": string, ... }} Name` — because +`@property` cannot express them. Write one `@typedef` per comment block; several in one block silently +fail to parse. + ## Upgrade Guide See [UPGRADE.md](UPGRADE.md) for upgrade guides. diff --git a/Taskfile.yml b/Taskfile.yml index f3699269..dba9647e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -199,6 +199,11 @@ tasks: cmds: - task composer -- code-analysis + code-analysis:assets: + desc: "Runs type checking on frontend assets (checkJs strict ratchet)." + cmds: + - task compose -- run --rm node npm run typecheck + phpstan:generate-baseline: desc: "Generates the PHPStan baseline file." cmds: @@ -219,6 +224,7 @@ tasks: cmds: - task coding-standards:check - task code-analysis + - task code-analysis:assets - task rector:check - task test:api - task test:frontend-local diff --git a/assets/client/util/id-from-path.js b/assets/client/util/id-from-path.js index 24ecdb05..c8d8f267 100644 --- a/assets/client/util/id-from-path.js +++ b/assets/client/util/id-from-path.js @@ -1,5 +1,5 @@ /** - * @param {object} string - The url to cut id from. + * @param {unknown} string - The url to cut id from. * @returns {string|boolean} The id or false. */ function idFromPath(string) { diff --git a/package-lock.json b/package-lock.json index 8224c9de..f39e37e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,8 @@ "@rtk-query/codegen-openapi": "^2.0.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/crypto-js": "^4.2.0", + "@types/lodash": "^4.17.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.6.0", @@ -3469,6 +3471,13 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3521,6 +3530,13 @@ "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/markdown-it": { "version": "14.1.2", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", diff --git a/package.json b/package.json index 755cb8e6..dfc2199d 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "test:unit": "vitest run", "test:unit:watch": "vitest", "lint:js": "eslint 'assets/**/*.{js,jsx,ts,tsx}'", - "lint:js:fix": "eslint 'assets/**/*.{js,jsx,ts,tsx}' --fix" + "lint:js:fix": "eslint 'assets/**/*.{js,jsx,ts,tsx}' --fix", + "typecheck": "node scripts/typecheck-strict.mjs" }, "devDependencies": { "@eslint-react/eslint-plugin": "^5.18.6", @@ -18,6 +19,8 @@ "@rtk-query/codegen-openapi": "^2.0.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/crypto-js": "^4.2.0", + "@types/lodash": "^4.17.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.6.0", diff --git a/scripts/typecheck-strict.mjs b/scripts/typecheck-strict.mjs new file mode 100644 index 00000000..4453743d --- /dev/null +++ b/scripts/typecheck-strict.mjs @@ -0,0 +1,44 @@ +// Ratchet: noImplicitAny type checking enforced only for files listed in +// .typecheck-strict-paths. The list is grow-only; add every assets/ file you +// touch in the same PR and burn its errors to zero. +import { execSync } from "node:child_process"; +import { readFileSync } from "node:fs"; + +const paths = readFileSync(".typecheck-strict-paths", "utf8") + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")); + +let out = ""; +try { + execSync("npx tsc -p tsconfig.checkjs.json --pretty false", { + encoding: "utf8", + }); +} catch (e) { + out = e.stdout ?? ""; +} + +const lines = out.split("\n").filter(Boolean); + +// Config-level or global failures carry no file prefix; never let them pass +// silently. +const globalErrors = lines.filter( + (line) => /error TS\d+/.test(line) && !/^assets\//.test(line), +); +if (globalErrors.length > 0) { + console.error(globalErrors.join("\n")); + console.error("\ntsc failed before file-level checking; aborting."); + process.exit(2); +} + +const offending = lines.filter((line) => paths.some((p) => line.startsWith(p))); +const total = lines.filter((line) => /error TS\d+/.test(line)).length; + +if (offending.length > 0) { + console.error(offending.join("\n")); + console.error(`\n${offending.length} error(s) in strict-checked files.`); + process.exit(1); +} +console.log( + `typecheck:strict OK (${paths.length} file(s) enforced; ${total} error(s) remain repo-wide, informational).`, +); diff --git a/tsconfig.checkjs.json b/tsconfig.checkjs.json new file mode 100644 index 00000000..1e3cc5c3 --- /dev/null +++ b/tsconfig.checkjs.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "noEmit": true, + "allowJs": true, + "checkJs": true, + "noImplicitAny": true, + "strict": false, + "jsx": "react-jsx", + "target": "es2022", + "module": "esnext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "types": ["vite/client", "vite-plugin-svgr/client"] + }, + "include": [ + "assets/**/*.js", + "assets/**/*.jsx", + "assets/**/*.ts", + "assets/**/*.tsx" + ] +}