From 7c38987f68b3915015bf3e12d2dde82dc10a80c1 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 26 Oct 2026 13:42:44 +0100 Subject: [PATCH 1/6] Add frontend Oxlint plugins Pin and apply the recommended Unicorn profile and the deslop plugin to the frontend lint gate. Record the selection and rule conflict in the linting strategy ADR, and address findings exposed by the expanded checks and full test suite. --- docs/adr-013-frontend-linting-strategy.md | 74 ++++ docs/contents.md | 7 +- docs/developers-guide.md | 15 + frontend-pwa/.oxlintrc.json | 183 ++++++++ frontend-pwa/bun.lock | 76 ++++ frontend-pwa/package.json | 8 +- .../scripts/check-classlist-length.ts | 25 +- .../scripts/check-hardcoded-strings.ts | 50 +-- frontend-pwa/scripts/check-locale-vars.ts | 29 +- .../scripts/find-near-duplicate-classes.ts | 52 ++- frontend-pwa/scripts/run-audit.mjs | 17 +- frontend-pwa/security/audit-utils.js | 72 ++-- frontend-pwa/src/docs-check.test.ts | 25 +- frontend-pwa/src/routes/task-create-page.tsx | 4 +- .../adapters/fixture/fixture-task-gateway.ts | 6 +- .../adapters/http/http-task-gateway.test.ts | 215 ++++------ .../adapters/http/http-task-gateway.ts | 391 ++++++------------ .../application/task-gateway-context.test.tsx | 2 +- .../application/task-gateway-context.tsx | 4 +- .../task_slice/ui/task-create-form.test.tsx | 17 +- .../src/task_slice/ui/task-create-form.tsx | 10 +- .../src/task_slice/ui/task-detail-card.tsx | 2 +- frontend-pwa/src/test/audit-utils.test.mjs | 43 ++ frontend-pwa/src/test/test-utils.tsx | 15 +- frontend-pwa/tests/setup-vitest-a11y.ts | 56 ++- frontend-pwa/tools/stylelint.config.cjs | 2 +- frontend-pwa/vitest.config.ts | 2 +- 27 files changed, 834 insertions(+), 568 deletions(-) create mode 100644 docs/adr-013-frontend-linting-strategy.md create mode 100644 frontend-pwa/.oxlintrc.json create mode 100644 frontend-pwa/src/test/audit-utils.test.mjs diff --git a/docs/adr-013-frontend-linting-strategy.md b/docs/adr-013-frontend-linting-strategy.md new file mode 100644 index 00000000..4256369a --- /dev/null +++ b/docs/adr-013-frontend-linting-strategy.md @@ -0,0 +1,74 @@ +# Architectural decision record (ADR) 013: Frontend linting strategy + +## Status + +Accepted on 26 October 2026. The frontend lint command runs Biome followed by +Oxlint, with the recommended Unicorn rules and the `ai-deslop` plugin enabled. + +## Date + +2026-10-26. + +## Context and problem statement + +The repository-owned frontend already uses Biome and project-specific checks. +It needs additional JavaScript and TypeScript rules for code quality and safer +handling of data received from APIs, configuration files, and tools. The added +checks must preserve existing Biome requirements, remain reproducible, and +surface type-aware findings in the same frontend lint command. + +## Decision drivers + +- Apply the recommended Unicorn profile to the frontend workspace. +- Add the requested `maharshi365/deslop` rules without weakening existing + Biome or TypeDoc checks. +- Pin the lint tools and external plugin so rule changes are reviewed with + version updates. +- Keep exceptions narrow and record direct rule conflicts explicitly. +- Validate untrusted input at its boundary rather than relying on unchecked + shape assumptions. + +## Options considered + +- Keep Biome alone. This does not provide the requested Unicorn and deslop + rules. +- Add Oxlint with floating versions. This would allow rule and plugin changes + without a reviewed dependency update. +- Add Oxlint with exact versions and enumerate the adopted rules. This keeps + the selected profile reviewable and makes exceptions visible in the config. + +## Decision outcome + +`frontend-pwa/package.json` pins Oxlint at `1.82.0`, `ai-deslop` at `0.4.9`, and +`oxlint-tsgolint` at `7.0.2004`. `ai-deslop` is loaded as an Oxlint JavaScript +plugin. Oxlint runs with type-aware rules enabled. The frontend `lint` script +runs `biome check .` first and `oxlint .` second. + +`frontend-pwa/.oxlintrc.json` explicitly enables the recommended Unicorn rule +set and the selected deslop rules at error severity. Explicit rule entries make +the adopted profile and its review surface visible next to the tool +configuration. The single disabled rule is `deslop/no-reflect-apply`, because +it conflicts with `unicorn/prefer-reflect-apply`; Unicorn's recommendation +takes precedence. No other rules are disabled. + +Oxlint's type-aware checks use the pinned `oxlint-tsgolint` package. Runtime +schemas belong at the boundary where the frontend or its tooling reads external +data. The task HTTP adapter, audit parser, and frontend configuration scripts +use Zod schemas so validated values have an explicit contract before the +application inspects them. The task schema retains unknown fields so additive +API changes remain available to callers. + +Biome remains the first frontend lint stage and retains its existing rules and +plugins. TypeDoc remains a separate zero-tolerance documentation gate; neither +tool is weakened to accommodate Oxlint. + +## Consequences + +- Contributors run `make frontend-lint` to execute Biome and Oxlint in order. +- Changes to either exact Oxlint package version require reviewing the + configured rule set and its exceptions. +- Type-aware linting relies on the pinned `oxlint-tsgolint` executable and the + TypeScript project configuration supplied by the frontend workspace. +- A future Unicorn or deslop rule conflict must be handled with one narrow + documented exception, preferring Unicorn when the conflict is between those + two plugins. diff --git a/docs/contents.md b/docs/contents.md index 68b43b2e..1af8922e 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -16,9 +16,6 @@ the first stop when choosing which document to read or update. runtime expectations. - [Developers' guide](developers-guide.md) explains maintainer workflows, quality gates, local services, and implementation conventions. -- [ADR 012: Rust build standard](adr-012-rust-build-standard.md) records why - development builds use the fast flags while coverage and release builds stay - off them. - [Roadmap](roadmap.md) tracks planned delivery work, dependencies, and acceptance criteria. @@ -59,6 +56,10 @@ the first stop when choosing which document to read or update. records migration and coexistence policy. - [ADR 011: Corbusier and Frankie review adapter role segregation](adr-011-corbusier-frankie-review-adapter-role-segregation.md) records adapter responsibility boundaries. +- [ADR 012: Rust build standard](adr-012-rust-build-standard.md) records the + development build flags and release-build boundary. +- [ADR 013: Frontend linting strategy](adr-013-frontend-linting-strategy.md) + records frontend lint tools, selected rules, and the narrow plugin conflict. ## Guides and reference material diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 8e76f936..1c69ad0e 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -202,6 +202,21 @@ The shipped `4.4.1` slice boundary is recorded in The Whitaker user's guide remains a tooling guide for the Rust lint runner and does not describe these frontend-only APIs. +### Frontend linting + +`make frontend-lint` runs Biome and then Oxlint in `frontend-pwa`. Oxlint uses +the recommended Unicorn profile and the pinned `ai-deslop` plugin with +type-aware rules enabled. The exact versions and the one rule conflict are +recorded in [ADR 013](adr-013-frontend-linting-strategy.md). + +Runtime schemas belong at input boundaries. Frontend transport responses and +tooling configuration are parsed before callers rely on their shape; keep +unchecked shape tests and type assertions out of these paths. + +The lint command supplements the existing Biome checks. Run +`make frontend-typecheck`, `make frontend-docs-check`, and `make frontend-test` +for the separate TypeScript, TypeDoc, and behavioural validation gates. + ### TypeScript type checking for tests `frontend-pwa/tsconfig.test.json` extends the base `tsconfig.json` and widens diff --git a/frontend-pwa/.oxlintrc.json b/frontend-pwa/.oxlintrc.json new file mode 100644 index 00000000..e4037f96 --- /dev/null +++ b/frontend-pwa/.oxlintrc.json @@ -0,0 +1,183 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["eslint", "typescript", "oxc", "unicorn"], + "jsPlugins": ["ai-deslop"], + "ignorePatterns": [ + "node_modules/**", + "dist/**", + "coverage/**", + "playwright-report/**", + "test-results/**" + ], + "categories": { + "correctness": "off", + "pedantic": "off", + "perf": "off", + "restriction": "off", + "style": "off", + "nursery": "off", + "suspicious": "off" + }, + "options": { + "typeAware": true + }, + "rules": { + "unicorn/catch-error-name": "error", + "unicorn/consistent-assert": "error", + "unicorn/consistent-date-clone": "error", + "unicorn/consistent-empty-array-spread": "error", + "unicorn/consistent-existence-index-check": "error", + "unicorn/consistent-function-scoping": "error", + "unicorn/consistent-template-literal-escape": "error", + "unicorn/empty-brace-spaces": "error", + "unicorn/error-message": "error", + "unicorn/escape-case": "error", + "unicorn/explicit-length-check": "error", + "unicorn/explicit-timer-delay": "error", + "unicorn/filename-case": "error", + "unicorn/import-style": "error", + "unicorn/max-nested-calls": "error", + "unicorn/new-for-builtins": "error", + "unicorn/no-abusive-eslint-disable": "error", + "unicorn/no-accessor-recursion": "error", + "unicorn/no-anonymous-default-export": "error", + "unicorn/no-array-callback-reference": "error", + "unicorn/no-array-fill-with-reference-type": "error", + "unicorn/no-array-method-this-argument": "error", + "unicorn/no-array-reduce": "error", + "unicorn/no-array-reverse": "error", + "unicorn/no-array-sort": "error", + "unicorn/no-await-expression-member": "error", + "unicorn/no-await-in-promise-methods": "error", + "unicorn/no-confusing-array-with": "error", + "unicorn/no-console-spaces": "error", + "unicorn/no-document-cookie": "error", + "unicorn/no-empty-file": "error", + "unicorn/no-immediate-mutation": "error", + "unicorn/no-instanceof-builtins": "error", + "unicorn/no-invalid-fetch-options": "error", + "unicorn/no-invalid-remove-event-listener": "error", + "unicorn/no-lonely-if": "error", + "unicorn/no-magic-array-flat-depth": "error", + "unicorn/no-negated-condition": "error", + "unicorn/no-negation-in-equality-check": "error", + "unicorn/no-nested-ternary": "error", + "unicorn/no-new-array": "error", + "unicorn/no-new-buffer": "error", + "unicorn/no-object-as-default-parameter": "error", + "unicorn/no-process-exit": "error", + "unicorn/no-single-promise-in-promise-methods": "error", + "unicorn/no-static-only-class": "error", + "unicorn/no-thenable": "error", + "unicorn/no-this-assignment": "error", + "unicorn/no-typeof-undefined": "error", + "unicorn/no-unnecessary-array-flat-depth": "error", + "unicorn/no-unnecessary-array-splice-count": "error", + "unicorn/no-unnecessary-await": "error", + "unicorn/no-unnecessary-slice-end": "error", + "unicorn/no-unreadable-array-destructuring": "error", + "unicorn/no-unreadable-iife": "error", + "unicorn/no-useless-collection-argument": "error", + "unicorn/no-useless-error-capture-stack-trace": "error", + "unicorn/no-useless-fallback-in-spread": "error", + "unicorn/no-useless-iterator-to-array": "error", + "unicorn/no-useless-length-check": "error", + "unicorn/no-useless-promise-resolve-reject": "error", + "unicorn/no-useless-spread": "error", + "unicorn/no-useless-switch-case": "error", + "unicorn/no-useless-undefined": "error", + "unicorn/no-null": "error", + "unicorn/no-zero-fractions": "error", + "unicorn/number-literal-case": "error", + "unicorn/numeric-separators-style": "error", + "unicorn/prefer-add-event-listener": "error", + "unicorn/prefer-array-find": "error", + "unicorn/prefer-array-flat": "error", + "unicorn/prefer-array-flat-map": "error", + "unicorn/prefer-array-index-of": "error", + "unicorn/prefer-array-some": "error", + "unicorn/prefer-at": "error", + "unicorn/prefer-bigint-literals": "error", + "unicorn/prefer-blob-reading-methods": "error", + "unicorn/prefer-class-fields": "error", + "unicorn/prefer-classlist-toggle": "error", + "unicorn/prefer-code-point": "error", + "unicorn/prefer-date-now": "error", + "unicorn/prefer-default-parameters": "error", + "unicorn/prefer-dom-node-append": "error", + "unicorn/prefer-dom-node-remove": "error", + "unicorn/prefer-dom-node-text-content": "error", + "unicorn/prefer-event-target": "error", + "unicorn/prefer-export-from": "error", + "unicorn/prefer-global-this": "error", + "unicorn/prefer-includes": "error", + "unicorn/prefer-keyboard-event-key": "error", + "unicorn/prefer-logical-operator-over-ternary": "error", + "unicorn/prefer-math-min-max": "error", + "unicorn/prefer-math-trunc": "error", + "unicorn/prefer-modern-dom-apis": "error", + "unicorn/prefer-modern-math-apis": "error", + "unicorn/prefer-module": "error", + "unicorn/prefer-native-coercion-functions": "error", + "unicorn/prefer-negative-index": "error", + "unicorn/prefer-node-protocol": "error", + "unicorn/prefer-number-coercion": "error", + "unicorn/prefer-number-properties": "error", + "unicorn/prefer-object-from-entries": "error", + "unicorn/prefer-optional-catch-binding": "error", + "unicorn/prefer-prototype-methods": "error", + "unicorn/prefer-query-selector": "error", + "unicorn/prefer-reflect-apply": "error", + // This conflicts with unicorn/prefer-reflect-apply; prefer Unicorn as requested. + "deslop/no-reflect-apply": "off", + "unicorn/prefer-regexp-test": "error", + "unicorn/prefer-response-static-json": "error", + "unicorn/prefer-set-has": "error", + "unicorn/prefer-set-size": "error", + "unicorn/prefer-single-call": "error", + "unicorn/prefer-spread": "error", + "unicorn/prefer-string-raw": "error", + "unicorn/prefer-string-replace-all": "error", + "unicorn/prefer-string-slice": "error", + "unicorn/prefer-string-starts-ends-with": "error", + "unicorn/prefer-string-trim-start-end": "error", + "unicorn/prefer-structured-clone": "error", + "unicorn/prefer-ternary": "error", + "unicorn/prefer-top-level-await": "error", + "unicorn/prefer-type-error": "error", + "unicorn/relative-url-style": "error", + "unicorn/require-array-join-separator": "error", + "unicorn/require-module-attributes": "error", + "unicorn/require-module-specifiers": "error", + "unicorn/require-number-to-fixed-digits-argument": "error", + "unicorn/switch-case-braces": "error", + "unicorn/switch-case-break-position": "error", + "unicorn/text-encoding-identifier-case": "error", + "unicorn/throw-new-error": "error", + "deslop/no-array-filter-map": "error", + "deslop/no-reduce-accumulator-copy": "error", + "deslop/no-chained-type-assertions": "error", + "deslop/no-conditional-empty-object-spread": "error", + "deslop/no-known-value-widening": "error", + "deslop/no-module-mocking": "error", + "deslop/no-object-parameters": "error", + "deslop/no-reflect-get": "error", + "deslop/no-runtime-typeof": "error", + "deslop/no-shape-in-symbol-names": "error", + "deslop/no-unknown-parameters": "error", + "deslop/no-unknown-returns": "error", + "deslop/no-unknown-type-aliases": "error", + "deslop/no-unsafe-dictionary-type": "error", + "deslop/no-widen-then-assert": "error", + "deslop/require-safety-comment-for-type-assertion": "error", + "deslop/no-call-only-assertions": "error", + "deslop/no-poor-substring-tests": "error", + "deslop/no-repeated-expect-call": "error", + "deslop/no-pass-through-type-alias": "error", + "typescript/no-deprecated": "error", + "typescript/no-require-imports": "error", + "typescript/no-floating-promises": "error", + "typescript/no-unnecessary-condition": "error", + "no-unreachable": "error", + } +} diff --git a/frontend-pwa/bun.lock b/frontend-pwa/bun.lock index 0159cdef..7fb47897 100644 --- a/frontend-pwa/bun.lock +++ b/frontend-pwa/bun.lock @@ -9,6 +9,7 @@ "@tanstack/react-router": "^1.170.17", "react": "^19.2.0", "react-dom": "^19.2.0", + "zod": "^4.6.7", }, "devDependencies": { "@axe-core/playwright": "^4.12.1", @@ -23,10 +24,13 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.1.0", + "ai-deslop": "0.4.9", "axe-core": "^4.12.1", "daisyui": "^5.6.6", "jest-axe": "^10.0.0", "jsdom": "^30.0.1", + "oxlint": "1.82.0", + "oxlint-tsgolint": "7.0.2004", "stylelint": "^17.14.0", "stylelint-declaration-strict-value": "^1.10.11", "tailwindcss": "^4.3.2", @@ -223,6 +227,60 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2004", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJk3ttju7Lz+Nv8WG4CsLyewoL1qf/Ny8S8OksJ7Bm/F14Tc9gMfW6arM1PCc57IzH/X/I79YsCWTgxX1FKibQ=="], + + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@7.0.2004", "", { "os": "darwin", "cpu": "x64" }, "sha512-0BFfHTzDxNDFPcu0UZAXT6CyCJHAXM0dZveyDb4KEHa2qF/DSnOKOrmvK/wFuFol9kvijbGM9JfuOsor+zWXRw=="], + + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@7.0.2004", "", { "os": "linux", "cpu": "arm64" }, "sha512-TAehQc+qdRXyDN3nl/r/3/oPrBVrbtrDeGMkFyG6mf+XphpoJ2lAMVFLclyvrVZCw02RbKROjyw8h72XwQQHcw=="], + + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@7.0.2004", "", { "os": "linux", "cpu": "x64" }, "sha512-LPunZvm6ZbWPfHMAyfZf9ci3HHvNEzLejO8zdqO3GoVGoSEHqqufHUD7+OgeatVSaJP7uXDJ2HaNOHIoH7go4Q=="], + + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@7.0.2004", "", { "os": "win32", "cpu": "arm64" }, "sha512-FVWplzDImWhiuawnIZVETaQecv4GWom6Jv4Fx2xj7hhXXDZ5okEgiw1gTCImHTIQsi2ycC5oSxVwrn69bMh7iw=="], + + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@7.0.2004", "", { "os": "win32", "cpu": "x64" }, "sha512-7PsxuUx11Ry9DYTWcljTzRbmZY9B6O9fFFfZV+aodQEMy6pfRT3fhoQRv9PtGN/89z1pPz3nFKrsFm6WqNtX7w=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.82.0", "", { "os": "android", "cpu": "arm" }, "sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.82.0", "", { "os": "android", "cpu": "arm64" }, "sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.82.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.82.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.82.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.82.0", "", { "os": "linux", "cpu": "arm" }, "sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.82.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.82.0", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.82.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.82.0", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.82.0", "", { "os": "linux", "cpu": "none", "libc": "glibc" }, "sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.82.0", "", { "os": "linux", "cpu": "none", "libc": "musl" }, "sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.82.0", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.82.0", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.82.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.82.0", "", { "os": "none", "cpu": "arm64" }, "sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.82.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.82.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.82.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ=="], + + "@oxlint/plugins": ["@oxlint/plugins@1.82.0", "", {}, "sha512-IDF4UXBNOaCeLoEpVxf5Bg85KOWdeuBEkbo1rGzS2vXcbD+GT1asBSeeTC+5BgW6NFTHVByd6N5giHsFPGtwAA=="], + + "@pkgr/core": ["@pkgr/core@0.3.6", "", {}, "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA=="], + "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], @@ -387,6 +445,8 @@ "@vitest/utils": ["@vitest/utils@4.1.12", "", { "dependencies": { "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA=="], + "ai-deslop": ["ai-deslop@0.4.9", "", { "dependencies": { "@oxlint/plugins": "1.82.0", "@tailwindcss/node": "^4.3.3", "synckit": "^0.11.13" }, "peerDependencies": { "tailwindcss": "^4.0.0" }, "optionalPeers": ["tailwindcss"] }, "sha512-YiwYxO6mIo31ZtJ8+Mcy8PbfBOX+oGVFC3gP2k8jSXW5cAZlOANEOFBWbpKN2JqfsDfrRNpHfuLe5YXA8Fclzg=="], + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -657,6 +717,10 @@ "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "oxlint": ["oxlint@1.82.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.82.0", "@oxlint/binding-android-arm64": "1.82.0", "@oxlint/binding-darwin-arm64": "1.82.0", "@oxlint/binding-darwin-x64": "1.82.0", "@oxlint/binding-freebsd-x64": "1.82.0", "@oxlint/binding-linux-arm-gnueabihf": "1.82.0", "@oxlint/binding-linux-arm-musleabihf": "1.82.0", "@oxlint/binding-linux-arm64-gnu": "1.82.0", "@oxlint/binding-linux-arm64-musl": "1.82.0", "@oxlint/binding-linux-ppc64-gnu": "1.82.0", "@oxlint/binding-linux-riscv64-gnu": "1.82.0", "@oxlint/binding-linux-riscv64-musl": "1.82.0", "@oxlint/binding-linux-s390x-gnu": "1.82.0", "@oxlint/binding-linux-x64-gnu": "1.82.0", "@oxlint/binding-linux-x64-musl": "1.82.0", "@oxlint/binding-openharmony-arm64": "1.82.0", "@oxlint/binding-win32-arm64-msvc": "1.82.0", "@oxlint/binding-win32-ia32-msvc": "1.82.0", "@oxlint/binding-win32-x64-msvc": "1.82.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ=="], + + "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2004", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2004", "@oxlint-tsgolint/darwin-x64": "7.0.2004", "@oxlint-tsgolint/linux-arm64": "7.0.2004", "@oxlint-tsgolint/linux-x64": "7.0.2004", "@oxlint-tsgolint/win32-arm64": "7.0.2004", "@oxlint-tsgolint/win32-x64": "7.0.2004" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-vQ9WDF3fiZJvRdSDosNOflerOCOpwol6vWzVDxW76RcU19cjTh7engvkHLvmv+tzedR8fAiJUVUGr6IVZdkzXQ=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], @@ -753,6 +817,8 @@ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "synckit": ["synckit@0.11.13", "", { "dependencies": { "@pkgr/core": "^0.3.6" } }, "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg=="], + "table": ["table@6.9.0", "", { "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1" } }, "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A=="], "tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="], @@ -821,6 +887,8 @@ "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + "@asamuzakjp/css-color/@csstools/css-calc": ["@csstools/css-calc@3.4.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.1", "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw=="], "@babel/core/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -865,6 +933,8 @@ "@types/babel__traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "ai-deslop/@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "data-urls/whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], @@ -913,6 +983,12 @@ "@types/babel__traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + "ai-deslop/@tailwindcss/node/enhanced-resolve": ["enhanced-resolve@5.26.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="], + + "ai-deslop/@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "ai-deslop/@tailwindcss/node/tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + "data-urls/whatwg-url/@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="], "jest-diff/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], diff --git a/frontend-pwa/package.json b/frontend-pwa/package.json index 05b270c0..b4004cd2 100644 --- a/frontend-pwa/package.json +++ b/frontend-pwa/package.json @@ -9,7 +9,7 @@ "build": "tsc --noEmit && vite build", "preview": "vite preview --host 127.0.0.1 --port 4173", "audit": "bun run scripts/run-audit.mjs", - "lint": "biome check .", + "lint": "biome check . && oxlint .", "docs:check": "typedoc --options typedoc.json", "test:a11y": "vitest --run --config vitest.a11y.config.ts", "lint:classlist": "bun run scripts/check-classlist-length.ts", @@ -26,7 +26,8 @@ "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "zod": "^4.6.7" }, "overrides": { "@babel/core": "^7.29.1", @@ -58,10 +59,13 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.1.0", + "ai-deslop": "0.4.9", "axe-core": "^4.12.1", "daisyui": "^5.6.6", "jest-axe": "^10.0.0", "jsdom": "^30.0.1", + "oxlint": "1.82.0", + "oxlint-tsgolint": "7.0.2004", "stylelint": "^17.14.0", "stylelint-declaration-strict-value": "^1.10.11", "tailwindcss": "^4.3.2", diff --git a/frontend-pwa/scripts/check-classlist-length.ts b/frontend-pwa/scripts/check-classlist-length.ts index 0ef6959d..0225dc1a 100644 --- a/frontend-pwa/scripts/check-classlist-length.ts +++ b/frontend-pwa/scripts/check-classlist-length.ts @@ -8,13 +8,14 @@ * class list exceeds the configured threshold. */ import { readFileSync } from 'node:fs'; -import { relative } from 'node:path'; +import path from 'node:path'; import ts from 'typescript'; +import { z } from 'zod'; -interface SemanticConfig { - maxClasslistLength?: number; -} +const SemanticConfigSchema = z.object({ + maxClasslistLength: z.number().positive().optional(), +}); interface Violation { file: string; @@ -28,21 +29,19 @@ const PROJECT_ROOT = process.cwd(); function loadThreshold(): number { const raw = readFileSync(CONFIG_PATH, 'utf8'); - const config = JSON.parse(raw) as SemanticConfig; + const config = SemanticConfigSchema.parse(JSON.parse(raw)); const value = config.maxClasslistLength; - return typeof value === 'number' && Number.isFinite(value) && value > 0 - ? Math.floor(value) - : 16; + return value === undefined ? 16 : Math.floor(value); } function getTsxFiles(): string[] { const glob = new Bun.Glob('src/**/*.tsx'); - return Array.from(glob.scanSync(PROJECT_ROOT)); + return [...glob.scanSync(PROJECT_ROOT)]; } function extractStringLiteral( initializer: ts.JsxAttributeValue, -): string | null { +): string | undefined { if (ts.isStringLiteral(initializer)) { return initializer.text; } @@ -55,7 +54,7 @@ function extractStringLiteral( return expression.text; } } - return null; + return undefined; } /** Return true when the node is a JSX `className` attribute with an initializer. */ @@ -63,7 +62,7 @@ function isClassNameAttribute(node: ts.Node): node is ts.JsxAttribute { return ( ts.isJsxAttribute(node) && node.name.text === 'className' && - node.initializer != null + node.initializer !== undefined ); } @@ -114,7 +113,7 @@ function main(): void { } for (const violation of violations) { - const displayPath = relative(PROJECT_ROOT, violation.file); + const displayPath = path.relative(PROJECT_ROOT, violation.file); console.error( `${displayPath}:${violation.line}:${violation.column} className has ${violation.length} utilities (max ${maxLength}).`, ); diff --git a/frontend-pwa/scripts/check-hardcoded-strings.ts b/frontend-pwa/scripts/check-hardcoded-strings.ts index ea7b23de..84c2fcb1 100644 --- a/frontend-pwa/scripts/check-hardcoded-strings.ts +++ b/frontend-pwa/scripts/check-hardcoded-strings.ts @@ -11,19 +11,25 @@ */ import { readFileSync } from 'node:fs'; -import { dirname, relative, resolve } from 'node:path'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { z } from 'zod'; interface HardcodedStringsConfig { minWordLength: number; userFacingAttributes: string[]; } -interface SemanticConfig { - hardcodedStrings?: Partial; -} +const UserFacingAttributesSchema = z.array(z.string()); +const HardcodedStringsConfigSchema = z.object({ + minWordLength: z.number().optional(), + userFacingAttributes: UserFacingAttributesSchema.optional(), +}); +const SemanticConfigSchema = z.object({ + hardcodedStrings: HardcodedStringsConfigSchema.optional(), +}); interface Violation { file: string; @@ -48,9 +54,12 @@ interface VisitContext { results: Violation[]; } -const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); -const PROJECT_ROOT = resolve(SCRIPT_DIR, '..'); -const CONFIG_PATH = resolve(PROJECT_ROOT, 'tools/semantic-lint.config.json'); +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = path.resolve(SCRIPT_DIR, '..'); +const CONFIG_PATH = path.resolve( + PROJECT_ROOT, + 'tools/semantic-lint.config.json', +); const DEFAULTS: HardcodedStringsConfig = { minWordLength: 2, @@ -67,14 +76,12 @@ const DEFAULTS: HardcodedStringsConfig = { /** Load semantic-lint config with repo defaults for this rule. */ function loadConfig(): HardcodedStringsConfig { const raw = readFileSync(CONFIG_PATH, 'utf8'); - const config = JSON.parse(raw) as SemanticConfig; + const config = SemanticConfigSchema.parse(JSON.parse(raw)); const section = config.hardcodedStrings; - const userFacingAttributes = Array.isArray(section?.userFacingAttributes) - ? section.userFacingAttributes.filter( - (attribute): attribute is string => - typeof attribute === 'string' && attribute.trim().length > 0, - ) - : []; + const userFacingAttributes = + section?.userFacingAttributes?.filter( + (attribute) => attribute.trim().length > 0, + ) ?? []; return { minWordLength: @@ -91,8 +98,8 @@ function loadConfig(): HardcodedStringsConfig { /** Return the TSX files scanned by this rule. */ function getTsxFiles(): string[] { const glob = new Bun.Glob('src/**/*.tsx'); - return Array.from(glob.scanSync(PROJECT_ROOT), (filePath) => - resolve(PROJECT_ROOT, filePath), + return [...glob.scanSync(PROJECT_ROOT)].map((filePath) => + path.resolve(PROJECT_ROOT, filePath), ); } @@ -131,14 +138,7 @@ function isTCallExpression(node: ts.Node): boolean { /** Return true when the node sits inside a `t(…)` call expression. */ function isInsideTCall(node: ts.Node): boolean { - let cursor = node.parent; - while (cursor) { - if (isTCallExpression(cursor)) { - return true; - } - cursor = cursor.parent; - } - return false; + return ts.findAncestor(node.parent, isTCallExpression) !== undefined; } /** Return true when `data-i18n-ignore` is enabled for a JSX attribute. */ @@ -423,7 +423,7 @@ function main(): void { } for (const v of violations) { - const displayPath = relative(PROJECT_ROOT, v.file); + const displayPath = path.relative(PROJECT_ROOT, v.file); const label = v.kind === 'jsx-text' ? 'hard-coded JSX text' diff --git a/frontend-pwa/scripts/check-locale-vars.ts b/frontend-pwa/scripts/check-locale-vars.ts index 5cd70618..b5cf9165 100644 --- a/frontend-pwa/scripts/check-locale-vars.ts +++ b/frontend-pwa/scripts/check-locale-vars.ts @@ -7,8 +7,10 @@ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { pathToFileURL } from 'node:url'; +import { z } from 'zod'; -type MessageMap = Record; +const MessageMapSchema = z.record(z.string(), z.string()); +type MessageMap = z.infer; type MessagePlaceholders = Map>; const BASE_LOCALE = process.env.I18N_BASE_LOCALE ?? 'en-gb'; @@ -20,12 +22,15 @@ async function listLocaleFiles(): Promise { .readdirSync(LOCALES_DIR, { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.endsWith('.ts')) .map((entry) => path.join(LOCALES_DIR, entry.name)) - .sort(); + .toSorted(); } async function loadMessages(filePath: string): Promise { const module = await import(pathToFileURL(filePath).href); - const exports = Object.values(module).filter(isMessageMap); + const exports = Object.values(module) + .map((candidate) => MessageMapSchema.safeParse(candidate)) + .filter((result) => result.success) + .map((result) => result.data); if (exports.length !== 1) { throw new Error( @@ -36,24 +41,6 @@ async function loadMessages(filePath: string): Promise { return exports[0]; } -/** - * Return true when the value is a non-null object and not an array. - * - * @param value - Candidate value to inspect. - * @returns {value is Record} Whether the value is a plain object candidate. - */ -function isNonArrayObject(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -function isMessageMap(value: unknown): value is MessageMap { - if (!isNonArrayObject(value)) { - return false; - } - - return Object.values(value).every((entry) => typeof entry === 'string'); -} - function collectPlaceholders(messages: MessageMap): MessagePlaceholders { return new Map( Object.entries(messages).map(([key, message]) => [ diff --git a/frontend-pwa/scripts/find-near-duplicate-classes.ts b/frontend-pwa/scripts/find-near-duplicate-classes.ts index fa6d5e1a..b450c147 100644 --- a/frontend-pwa/scripts/find-near-duplicate-classes.ts +++ b/frontend-pwa/scripts/find-near-duplicate-classes.ts @@ -7,9 +7,10 @@ * can be extracted before duplication spreads. */ import { readFileSync } from 'node:fs'; -import { relative } from 'node:path'; +import path from 'node:path'; import ts from 'typescript'; +import { z } from 'zod'; type Occurrence = { file: string; @@ -28,9 +29,16 @@ type NearDuplicateOptions = { failOnViolation: boolean; }; -type SemanticConfig = { - nearDuplicateClasses?: Partial; -}; +const NearDuplicateOptionsSchema = z.object({ + minTokenCount: z.number().int().positive().optional(), + maxJaccardDistance: z.number().min(0).max(1).optional(), + minOccurrences: z.number().int().positive().optional(), + suppressPrefixes: z.array(z.string()).optional(), + failOnViolation: z.boolean().optional(), +}); +const SemanticConfigSchema = z.object({ + nearDuplicateClasses: NearDuplicateOptionsSchema.optional(), +}); type ConceptHint = { id: string; @@ -93,13 +101,13 @@ const CONCEPT_HINTS: ConceptHint[] = [ function loadOptions(): NearDuplicateOptions { const raw = readFileSync(CONFIG_PATH, 'utf8'); - const config = JSON.parse(raw) as SemanticConfig; - return { ...DEFAULT_OPTIONS, ...(config.nearDuplicateClasses ?? {}) }; + const config = SemanticConfigSchema.parse(JSON.parse(raw)); + return { ...DEFAULT_OPTIONS, ...config.nearDuplicateClasses }; } function getTsxFiles(): string[] { const glob = new Bun.Glob('src/**/*.tsx'); - return Array.from(glob.scanSync(PROJECT_ROOT)); + return [...glob.scanSync(PROJECT_ROOT)]; } function tokenMatches(pattern: string, token: string): boolean { @@ -113,7 +121,7 @@ function tokenMatches(pattern: string, token: string): boolean { return token === pattern; } -function findConceptHint(tokens: string[]): ConceptHint | null { +function findConceptHint(tokens: string[]): ConceptHint | undefined { for (const hint of CONCEPT_HINTS) { const matches = hint.requiredTokens.every((pattern) => tokens.some((token) => tokenMatches(pattern, token)), @@ -122,12 +130,12 @@ function findConceptHint(tokens: string[]): ConceptHint | null { return hint; } } - return null; + return undefined; } function extractStringLiteral( initializer: ts.JsxAttributeValue, -): string | null { +): string | undefined { if (ts.isStringLiteral(initializer)) { return initializer.text; } @@ -140,19 +148,19 @@ function extractStringLiteral( return expression.text; } } - return null; + return undefined; } function normalizeTokens(raw: string): string[] { - return Array.from( - new Set( + return [ + ...new Set( raw .trim() .split(/\s+/u) .map((token) => token.trim().toLowerCase()) .filter(Boolean), ), - ).sort(); + ].toSorted(); } function shouldSuppress( @@ -270,7 +278,7 @@ function reportExactDuplicates( const locations = group .map( (entry) => - `${relative(PROJECT_ROOT, entry.file)}:${entry.line}:${entry.column}`, + `${path.relative(PROJECT_ROOT, entry.file)}:${entry.line}:${entry.column}`, ) .join(', '); console.error(`Repeated class stack found at ${locations}.`); @@ -286,21 +294,21 @@ function reportNearDuplicates( options: NearDuplicateOptions, ): number { const candidates = getCandidateOccurrences(occurrences, options); - const unique = Array.from( - new Map( + const unique = [ + ...new Map( candidates.map((occurrence) => [occurrence.signature, occurrence]), ).values(), - ); + ]; let findings = 0; const seen = new Set(); for (let index = 0; index < unique.length; index += 1) { - const current = unique[index]; + const current = unique.at(index); if (!current) { continue; } for (let nextIndex = index + 1; nextIndex < unique.length; nextIndex += 1) { - const next = unique[nextIndex]; + const next = unique.at(nextIndex); if (!next) { continue; } @@ -308,14 +316,14 @@ function reportNearDuplicates( if (distance > options.maxJaccardDistance) { continue; } - const key = [current.signature, next.signature].sort().join('::'); + const key = [current.signature, next.signature].toSorted().join('::'); if (seen.has(key)) { continue; } seen.add(key); findings += 1; console.error( - `Near-duplicate class stacks found at ${relative(PROJECT_ROOT, current.file)}:${current.line}:${current.column} and ${relative(PROJECT_ROOT, next.file)}:${next.line}:${next.column}.`, + `Near-duplicate class stacks found at ${path.relative(PROJECT_ROOT, current.file)}:${current.line}:${current.column} and ${path.relative(PROJECT_ROOT, next.file)}:${next.line}:${next.column}.`, ); } } diff --git a/frontend-pwa/scripts/run-audit.mjs b/frontend-pwa/scripts/run-audit.mjs index 45134a17..0ea6c41d 100644 --- a/frontend-pwa/scripts/run-audit.mjs +++ b/frontend-pwa/scripts/run-audit.mjs @@ -6,7 +6,7 @@ */ import { realpathSync } from 'node:fs'; -import { resolve } from 'node:path'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; import packageJson from '../package.json' with { type: 'json' }; @@ -20,10 +20,7 @@ import { runAuditJson, } from '../security/audit-utils.js'; -const normalise = (path) => - typeof realpathSync.native === 'function' - ? realpathSync.native(path) - : realpathSync(path); +const normalise = (filePath) => realpathSync.native(filePath); const workspacePackageName = packageJson.name; const workspaceKeys = new Set([ @@ -88,7 +85,7 @@ function getLedgerExpiryError(entry, advisoryId, referenceDateValue) { return `Audit exception ${entryLabel} for advisory ${entry.advisory} expired on ${rawExpiry}.`; } - return null; + return; } function reportAllowedAdvisories(expected) { @@ -112,14 +109,14 @@ function reportAllowedAdvisories(expected) { * } */ function isExecutedDirectly(meta) { - const invokedPath = process.argv?.[1]; + const invokedPath = process.argv[1]; if (!invokedPath) { return false; } try { const scriptPath = fileURLToPath(meta.url); - const absoluteInvokedPath = resolve(invokedPath); + const absoluteInvokedPath = path.resolve(invokedPath); return normalise(scriptPath) === normalise(absoluteInvokedPath); } catch { return false; @@ -200,9 +197,9 @@ export function main() { if (isExecutedDirectly(import.meta)) { try { const exitCode = main(); - process.exit(exitCode); + process.exitCode = exitCode; } catch (error) { console.error(error); - process.exit(1); + process.exitCode = 1; } } diff --git a/frontend-pwa/security/audit-utils.js b/frontend-pwa/security/audit-utils.js index 0693fbca..40a9bc31 100644 --- a/frontend-pwa/security/audit-utils.js +++ b/frontend-pwa/security/audit-utils.js @@ -10,34 +10,43 @@ */ import { spawnSync } from 'node:child_process'; +import { z } from 'zod'; const GHSA_PATTERN = /GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}/i; +const AdvisorySchema = z.looseObject({ + github_advisory_id: z.string().optional(), + title: z.string().optional(), + url: z.string().optional(), +}); +const AdvisoryMapSchema = z.record(z.string(), z.array(AdvisorySchema)); +const AuditObjectSchema = z.record(z.string(), z.unknown()); /** * Extract a GHSA identifier from an advisory URL. * * @param {string} url - * @returns {string | null} + * @returns {string | undefined} */ function extractGhsaFromUrl(url) { const match = url.match(GHSA_PATTERN); - return match ? match[0] : null; + return match?.[0]; } function extractGithubAdvisoryId(advisory) { - if (!advisory || typeof advisory !== 'object') { - return null; + const result = AdvisorySchema.safeParse(advisory); + if (!result.success) { + return; } - if (typeof advisory.github_advisory_id === 'string') { - return advisory.github_advisory_id; + if (result.data.github_advisory_id !== undefined) { + return result.data.github_advisory_id; } - if (typeof advisory.url === 'string') { - return extractGhsaFromUrl(advisory.url); + if (result.data.url !== undefined) { + return extractGhsaFromUrl(result.data.url); } - return null; + return; } /** @@ -50,9 +59,10 @@ function assertSpawnSuccess(result) { if (result.error) { throw result.error; } - if (result.status === null) { - const signalSuffix = result.signal ? ` (signal: ${result.signal})` : ''; - throw new Error(`bun audit did not exit normally${signalSuffix}`); + if (result.signal) { + throw new Error( + `bun audit did not exit normally (signal: ${result.signal})`, + ); } } @@ -69,7 +79,7 @@ function parseAuditOutput(raw) { } try { - return JSON.parse(stdout); + return AuditObjectSchema.parse(JSON.parse(stdout)); } catch (error) { const wrapped = new Error( `Failed to parse bun audit JSON: ${error.message}`, @@ -100,7 +110,7 @@ export function runAuditJson() { }); assertSpawnSuccess(result); - const status = result.status; + const status = result.status ?? 1; const json = parseAuditOutput(result.stdout); return { json, status }; } @@ -124,23 +134,29 @@ export function runAuditJson() { * console.log(advisories[0].github_advisory_id); */ export function collectAdvisories(auditJson) { - if (!auditJson || typeof auditJson !== 'object') { + const root = AuditObjectSchema.safeParse(auditJson); + if (!root.success) { return []; } - const entries = - auditJson.advisories && typeof auditJson.advisories === 'object' - ? Object.entries(auditJson.advisories) - : Object.entries(auditJson); - - return entries.flatMap(([packageName, value]) => - Array.isArray(value) - ? value.map((advisory) => ({ - ...advisory, - package: packageName, - github_advisory_id: extractGithubAdvisoryId(advisory), - })) - : [], + const nestedAdvisories = AdvisoryMapSchema.safeParse(root.data.advisories); + if (nestedAdvisories.success) { + return flattenAdvisories(nestedAdvisories.data); + } + + const directAdvisories = AdvisoryMapSchema.safeParse(root.data); + return directAdvisories.success + ? flattenAdvisories(directAdvisories.data) + : []; +} + +function flattenAdvisories(advisories) { + return Object.entries(advisories).flatMap(([packageName, records]) => + records.map((advisory) => ({ + ...advisory, + package: packageName, + github_advisory_id: extractGithubAdvisoryId(advisory), + })), ); } diff --git a/frontend-pwa/src/docs-check.test.ts b/frontend-pwa/src/docs-check.test.ts index dec31782..93831803 100644 --- a/frontend-pwa/src/docs-check.test.ts +++ b/frontend-pwa/src/docs-check.test.ts @@ -13,19 +13,19 @@ import { writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import path from 'node:path'; -const repositoryRoot = resolve(process.cwd(), '..'); +const repositoryRoot = path.resolve(process.cwd(), '..'); function createFakeBun(fixtureDirectory: string, exitCode: number) { - const fakeBun = join(fixtureDirectory, 'bun'); + const fakeBun = path.join(fixtureDirectory, 'bun'); writeFileSync( fakeBun, [ '#!/usr/bin/env sh', 'script_dir=$(dirname "$0")', 'pwd > "$script_dir/working-directory"', - 'printf "%s\\n" "$@" > "$script_dir/arguments"', + String.raw`printf "%s\n" "$@" > "$script_dir/arguments"`, `exit ${exitCode}`, ].join('\n'), ); @@ -43,19 +43,24 @@ function runDocsCheck(fakeBun: string) { describe('frontend-docs-check', () => { it('runs the configured Bun command from the frontend workspace', () => { - const fixtureDirectory = mkdtempSync(join(tmpdir(), 'corbusier-make-')); + const fixtureDirectory = mkdtempSync( + path.join(tmpdir(), 'corbusier-make-'), + ); try { const fakeBun = createFakeBun(fixtureDirectory, 0); - const argumentsPath = join(fixtureDirectory, 'arguments'); - const workingDirectoryPath = join(fixtureDirectory, 'working-directory'); + const argumentsPath = path.join(fixtureDirectory, 'arguments'); + const workingDirectoryPath = path.join( + fixtureDirectory, + 'working-directory', + ); const result = runDocsCheck(fakeBun); expect(result.error).toBeUndefined(); expect(result.status).toBe(0); expect(readFileSync(workingDirectoryPath, 'utf8').trim()).toBe( - join(repositoryRoot, 'frontend-pwa'), + path.join(repositoryRoot, 'frontend-pwa'), ); expect(readFileSync(argumentsPath, 'utf8').trim().split('\n')).toEqual([ 'run', @@ -67,7 +72,9 @@ describe('frontend-docs-check', () => { }); it('propagates a failing Bun command', () => { - const fixtureDirectory = mkdtempSync(join(tmpdir(), 'corbusier-make-')); + const fixtureDirectory = mkdtempSync( + path.join(tmpdir(), 'corbusier-make-'), + ); try { const fakeBun = createFakeBun(fixtureDirectory, 23); diff --git a/frontend-pwa/src/routes/task-create-page.tsx b/frontend-pwa/src/routes/task-create-page.tsx index 76e8960e..85c0164f 100644 --- a/frontend-pwa/src/routes/task-create-page.tsx +++ b/frontend-pwa/src/routes/task-create-page.tsx @@ -5,7 +5,7 @@ * `useCreateTaskMutation`, localized copy, and post-success navigation. */ import { useNavigate } from '@tanstack/react-router'; -import { type FormEvent, useState } from 'react'; +import { type SyntheticEvent, useState } from 'react'; import { useI18n } from '../i18n/runtime'; import { useCreateTaskMutation } from '../task_slice/application/task-queries'; @@ -37,7 +37,7 @@ export function TaskCreatePage() { setErrors((current) => ({ ...current, [field]: undefined })); } - async function handleSubmit(event: FormEvent) { + async function handleSubmit(event: SyntheticEvent) { event.preventDefault(); const nextErrors = validateTaskCreateDraft(draft); diff --git a/frontend-pwa/src/task_slice/adapters/fixture/fixture-task-gateway.ts b/frontend-pwa/src/task_slice/adapters/fixture/fixture-task-gateway.ts index 8bcb4186..4e03d326 100644 --- a/frontend-pwa/src/task_slice/adapters/fixture/fixture-task-gateway.ts +++ b/frontend-pwa/src/task_slice/adapters/fixture/fixture-task-gateway.ts @@ -41,8 +41,8 @@ export function createFixtureTaskGateway( function enqueue(operation: () => Promise): Promise { const nextOperation = operationQueue.then(operation, operation); operationQueue = nextOperation.then( - () => undefined, - () => undefined, + () => {}, + () => {}, ); return nextOperation; @@ -204,7 +204,7 @@ function applyTransition(task: Task, targetState: TaskState): Task { * and local development. */ function delay() { - return new Promise((resolve) => window.setTimeout(resolve, 15)); + return new Promise((resolve) => globalThis.setTimeout(resolve, 15)); } /** diff --git a/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts b/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts index b489bb48..8b37ac59 100644 --- a/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts +++ b/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts @@ -7,51 +7,48 @@ import { TaskGatewayError } from '../../ports/task-slice-gateway'; import { createHttpTaskGateway } from './http-task-gateway'; -function createGatewayWithResponse(body: unknown, status = 200) { - return createHttpTaskGateway( - '/api/v1', - vi - .fn() - .mockResolvedValue( - new Response(JSON.stringify(body), { status }), - ) as typeof fetch, - ); -} - -describe('http task gateway', () => { - const taskEnvelope = { - data: { - task: { - created_at: '2026-04-13T00:00:00.000Z', - id: 'task-1', - origin: { - issue_ref: { - issue_number: 42, - provider: 'github', - repository: 'acme/widgets', - }, - metadata: { - assignees: [], - labels: [], - title: 'Stabilize the transport contract', - }, - type: 'issue', +const taskEnvelope = `{ + "data": { + "task": { + "created_at": "2026-04-13T00:00:00.000Z", + "id": "task-1", + "origin": { + "issue_ref": { + "issue_number": 42, + "provider": "github", + "repository": "acme/widgets" + }, + "metadata": { + "assignees": [], + "labels": [], + "title": "Stabilize the transport contract" }, - state: 'draft', - updated_at: '2026-04-13T00:00:00.000Z', + "type": "issue" }, - }, - error: null, - metadata: { - request_id: 'req-123', - timestamp: '2026-04-13T00:00:00.000Z', - version: 'v1', - }, - success: true, - }; + "state": "draft", + "updated_at": "2026-04-13T00:00:00.000Z" + } + }, + "error": null, + "metadata": { + "request_id": "req-123", + "timestamp": "2026-04-13T00:00:00.000Z", + "version": "v1" + }, + "success": true +}`; +function createFetchForResponse(response: Response): typeof fetch { + return vi.fn().mockResolvedValue(response); +} + +function createGatewayWithResponse(response: Response) { + return createHttpTaskGateway('/api/v1', createFetchForResponse(response)); +} + +describe('http task gateway', () => { it('parses successful task detail responses', async () => { - const gateway = createGatewayWithResponse(taskEnvelope); + const gateway = createGatewayWithResponse(new Response(taskEnvelope)); await expect(gateway.getTask('task-1')).resolves.toMatchObject({ id: 'task-1', @@ -59,24 +56,32 @@ describe('http task gateway', () => { }); }); + it('preserves task fields added by the API outside the current model', async () => { + const responseBody = taskEnvelope.replace( + '"id": "task-1"', + '"id": "task-1",\n "upstream_extension": "retained"', + ); + const gateway = createGatewayWithResponse(new Response(responseBody)); + + await expect(gateway.getTask('task-1')).resolves.toMatchObject({ + upstream_extension: 'retained', + }); + }); + it('escapes task identifiers in task path segments', async () => { const fetchFn = vi - .fn() - .mockImplementation(() => - Promise.resolve( - new Response(JSON.stringify(taskEnvelope), { status: 200 }), - ), - ) as typeof fetch; + .fn() + .mockResolvedValueOnce(new Response(taskEnvelope)) + .mockResolvedValueOnce(new Response(taskEnvelope)); const gateway = createHttpTaskGateway('/api/v1', fetchFn); await gateway.getTask('task/with reserved?characters'); await gateway.transitionTask('task/with reserved?characters', 'done'); - expect(fetchFn).toHaveBeenNthCalledWith( - 1, + expect(fetchFn.mock.calls.map((call) => call[0])).toEqual([ '/api/v1/tasks/task%2Fwith%20reserved%3Fcharacters', - undefined, - ); + '/api/v1/tasks/task%2Fwith%20reserved%3Fcharacters/state', + ]); expect(fetchFn).toHaveBeenNthCalledWith( 2, '/api/v1/tasks/task%2Fwith%20reserved%3Fcharacters/state', @@ -86,50 +91,38 @@ describe('http task gateway', () => { it.each([ { - body: { - ...taskEnvelope, - metadata: { - request_id: 'req-123', - timestamp: '', - version: 'v1', - }, - }, - expectedError: new TaskGatewayError( - 'unavailable', - 'The task API returned an invalid success response.', + body: taskEnvelope.replace( + '"request_id": "req-123",\n "timestamp": "2026-04-13T00:00:00.000Z"', + '"request_id": "req-123",\n "timestamp": ""', ), name: 'metadata is incomplete', }, { - body: { - ...taskEnvelope, - metadata: { - ...taskEnvelope.metadata, - version: 'v2', - }, - }, - expectedError: new TaskGatewayError( - 'unavailable', - 'The task API returned an invalid success response.', - ), + body: taskEnvelope.replace('"version": "v1"', '"version": "v2"'), name: 'metadata version is unsupported', }, - ])('rejects a 200 envelope when $name', async ({ body, expectedError }) => { - const gateway = createGatewayWithResponse(body); + ])('rejects a 200 envelope when $name', async ({ body }) => { + const gateway = createGatewayWithResponse(new Response(body)); - await expect(gateway.getTask('task-1')).rejects.toEqual(expectedError); + await expect(gateway.getTask('task-1')).rejects.toEqual( + new TaskGatewayError( + 'unavailable', + 'The task API returned an invalid success response.', + ), + ); }); it('maps shared not-found responses to the task gateway error', async () => { - const gateway = createGatewayWithResponse( + const response = Response.json( { code: 'not_found', details: { reason: 'task_not_found' }, message: 'task task-9 was not found', traceId: 'trace-123', }, - 404, + { status: 404 }, ); + const gateway = createGatewayWithResponse(response); await expect(gateway.getTask('task-9')).rejects.toEqual( new TaskGatewayError('not_found', 'task task-9 was not found'), @@ -138,66 +131,29 @@ describe('http task gateway', () => { it.each([ { - body: { - data: { - task: { - created_at: '2026-04-13T00:00:00.000Z', - id: 123, - origin: { - issue_ref: { - issue_number: 42, - provider: 'github', - repository: 'acme/widgets', - }, - metadata: { - assignees: [], - labels: [], - title: 'Stabilize the transport contract', - }, - type: 'issue', - }, - state: 'draft', - updated_at: '2026-04-13T00:00:00.000Z', - }, - }, - error: null, - metadata: { - request_id: 'req-999', - timestamp: '2026-04-13T00:00:00.000Z', - version: 'v1', - }, - success: true, - }, - expectedError: new TaskGatewayError( - 'unavailable', - 'The task API returned an invalid task shape.', - ), + body: taskEnvelope.replace('"id": "task-1"', '"id": 123'), name: 'the nested task violates the slice contract shape', }, { - body: { - ...taskEnvelope, - data: { - task: { - ...taskEnvelope.data.task, - created_at: '2026-02-30T00:00:00.000Z', - }, - }, - }, - expectedError: new TaskGatewayError( - 'unavailable', - 'The task API returned an invalid task shape.', + body: taskEnvelope.replace( + '"created_at": "2026-04-13T00:00:00.000Z"', + '"created_at": "2026-02-30T00:00:00.000Z"', ), name: 'the nested task has invalid timestamps', }, - ])('rejects a 200 envelope when $name', async ({ body, expectedError }) => { - const gateway = createGatewayWithResponse(body); + ])('rejects a 200 envelope when $name', async ({ body }) => { + const gateway = createGatewayWithResponse(new Response(body)); - await expect(gateway.getTask('task-shape')).rejects.toEqual(expectedError); + await expect(gateway.getTask('task-shape')).rejects.toEqual( + new TaskGatewayError( + 'unavailable', + 'The task API returned an invalid task shape.', + ), + ); }); it('maps invalid transition conflicts separately from validation failures', async () => { - const gateway = createGatewayWithResponse( + const response = Response.json( { code: 'conflict', details: { @@ -207,8 +163,9 @@ describe('http task gateway', () => { message: 'invalid state transition for task task-2', traceId: 'trace-234', }, - 409, + { status: 409 }, ); + const gateway = createGatewayWithResponse(response); await expect(gateway.transitionTask('task-2', 'done')).rejects.toEqual( new TaskGatewayError( diff --git a/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.ts b/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.ts index dfd1df3f..d9b2393c 100644 --- a/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.ts +++ b/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.ts @@ -4,190 +4,101 @@ * This adapter consumes the stabilized Corbusier task contract while keeping * transport parsing and error mapping inside the adapter boundary. */ -import type { - IssueProvider, - Task, - TaskOrigin, - TaskState, -} from '../../domain/task'; +import { z } from 'zod'; + +import type { CreateTaskRequest, Task, TaskState } from '../../domain/task'; import { TaskGatewayError, type TaskGatewayErrorKind, type TaskSliceGateway, } from '../../ports/task-slice-gateway'; -interface ApiMetadata { - request_id: string; - timestamp: string; - version: string; -} - -interface ApiEnvelope { - data: T; - error: null; - metadata: ApiMetadata; - success: true; -} - -interface TaskEnvelope { - task: Task; -} - -interface SharedErrorResponse { - code: string; - details?: Record; - message: string; - traceId?: string; -} - -const TASK_STATES = new Set([ - 'draft', - 'in_progress', - 'in_review', - 'paused', - 'done', - 'abandoned', -]); - -const INVALID_TASK_SHAPE_MESSAGE = - 'The task API returned an invalid task shape.' as const; +const INVALID_TASK_RESPONSE_MESSAGE = + 'The task API returned an invalid task shape.'; const INVALID_SUCCESS_ENVELOPE_MESSAGE = - 'The task API returned an invalid success response.' as const; + 'The task API returned an invalid success response.'; const CONTRACT_VERSION = 'v1' as const; - -function taskShapeGatewayError(): TaskGatewayError { - return new TaskGatewayError('unavailable', INVALID_TASK_SHAPE_MESSAGE); -} - -function isPlainRecord(value: unknown): value is Record { - return Boolean(value && typeof value === 'object' && !Array.isArray(value)); -} - -function isIssueProvider(value: unknown): value is IssueProvider { - return value === 'github' || value === 'gitlab'; -} - -function requireNonEmptyString( - raw: Record, - field: string, -): string { - const value = raw[field]; - if (typeof value !== 'string' || value.length === 0) { - throw taskShapeGatewayError(); - } - return value; -} - -function requireStringArray(raw: Record, field: string): void { - const value = raw[field]; - if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { - throw taskShapeGatewayError(); - } -} - -function requireOptionalString( - raw: Record, - key: string, -): void { - if (raw[key] !== undefined && typeof raw[key] !== 'string') { - throw taskShapeGatewayError(); - } -} - -function validateIssueSnapshot(raw: unknown): void { - if (!isPlainRecord(raw)) throw taskShapeGatewayError(); - requireNonEmptyString(raw, 'title'); - requireStringArray(raw, 'labels'); - requireStringArray(raw, 'assignees'); - requireOptionalString(raw, 'description'); - requireOptionalString(raw, 'milestone'); -} - -/** - * Returns true when `value` is a finite integer ≥ `min`. - * Narrows the type to `number` for the caller. - */ -function isIntegerAtLeast(value: unknown, min: number): value is number { - return typeof value === 'number' && Number.isInteger(value) && value >= min; -} - -/** Throws if `record[field]` is not an integer ≥ `minValue`. */ -function requireIntegerField( - record: Record, - field: string, - minValue: number, -): void { - if (!isIntegerAtLeast(record[field], minValue)) { - throw taskShapeGatewayError(); - } -} - -function validateIssueRef(raw: unknown): void { - if (!isPlainRecord(raw)) throw taskShapeGatewayError(); - if (!isIssueProvider(raw.provider)) throw taskShapeGatewayError(); - requireNonEmptyString(raw, 'repository'); - requireIntegerField(raw, 'issue_number', 1); -} - -function validateTaskOrigin(raw: unknown): asserts raw is TaskOrigin { - if (!isPlainRecord(raw)) throw taskShapeGatewayError(); - if (raw.type !== 'issue') throw taskShapeGatewayError(); - validateIssueRef(raw.issue_ref); - validateIssueSnapshot(raw.metadata); -} - -function validateOptionalBranchRef(raw: unknown): void { - if (!isPlainRecord(raw)) throw taskShapeGatewayError(); - if (!isIssueProvider(raw.provider)) throw taskShapeGatewayError(); - requireNonEmptyString(raw, 'repository'); - requireNonEmptyString(raw, 'branch_name'); -} - -function validateOptionalPullRequestRef(raw: unknown): void { - if (!isPlainRecord(raw)) throw taskShapeGatewayError(); - if (!isIssueProvider(raw.provider)) throw taskShapeGatewayError(); - requireNonEmptyString(raw, 'repository'); - requireIntegerField(raw, 'pull_request_number', 1); -} - -function validateTaskState(task: Record): void { - if ( - typeof task.state !== 'string' || - !(TASK_STATES as ReadonlySet).has(task.state) - ) { - throw taskShapeGatewayError(); - } -} - -function validateOptionalRef( - raw: unknown, - validator: (v: unknown) => void, -): void { - if (raw !== undefined && raw !== null) { - validator(raw); - } -} - -function requireTimestampFields(task: Record): void { - if (!isValidTimestamp(task.created_at)) { - throw taskShapeGatewayError(); - } - if (!isValidTimestamp(task.updated_at)) { - throw taskShapeGatewayError(); - } -} - -function validateParsedTaskEnvelopeTask(task: unknown): Task { - if (!isPlainRecord(task)) throw taskShapeGatewayError(); - requireNonEmptyString(task, 'id'); - validateTaskState(task); - validateTaskOrigin(task.origin); - validateOptionalRef(task.branch_ref, validateOptionalBranchRef); - validateOptionalRef(task.pull_request_ref, validateOptionalPullRequestRef); - requireTimestampFields(task); - return task as unknown as Task; +const nonEmptyStringSchema = z.string().min(1); +const positiveIntegerSchema = z.number().int().min(1); +const stringArraySchema = z.array(z.string()); +const timestampSchema = z.string().refine(isValidTimestamp); +const providerSchema = z.enum(['github', 'gitlab']); + +const issueSnapshotSchema = z.looseObject({ + title: nonEmptyStringSchema, + description: z.string().optional(), + labels: stringArraySchema, + assignees: stringArraySchema, + milestone: z.string().optional(), +}); + +// The API may add fields independently; validate known fields and retain +// extensions for callers that can understand them. +const taskSchema = z.looseObject({ + id: nonEmptyStringSchema, + origin: z.looseObject({ + type: z.literal('issue'), + issue_ref: z.looseObject({ + provider: providerSchema, + repository: nonEmptyStringSchema, + issue_number: positiveIntegerSchema, + }), + metadata: issueSnapshotSchema, + }), + branch_ref: z + .looseObject({ + provider: providerSchema, + repository: nonEmptyStringSchema, + branch_name: nonEmptyStringSchema, + }) + .nullish() + .transform((reference) => reference || undefined), + pull_request_ref: z + .looseObject({ + provider: providerSchema, + repository: nonEmptyStringSchema, + pull_request_number: positiveIntegerSchema, + }) + .nullish() + .transform((reference) => reference || undefined), + state: z.enum([ + 'draft', + 'in_progress', + 'in_review', + 'paused', + 'done', + 'abandoned', + ]), + created_at: timestampSchema, + updated_at: timestampSchema, +}) satisfies z.ZodType; + +const successEnvelopeSchema = z.object({ + data: z.object({ task: z.unknown() }), + error: z.null(), + metadata: z.object({ + request_id: nonEmptyStringSchema, + timestamp: timestampSchema, + version: z.literal(CONTRACT_VERSION), + }), + success: z.literal(true), +}); + +const sharedErrorResponseSchema = z.object({ + code: z.string().optional(), + details: z.unknown().optional(), + message: z.string(), + traceId: z.string().optional(), +}); + +const invalidTaskTransitionSchema = z.object({ + reason: z.literal('invalid_task_transition'), +}); + +function taskContractGatewayError(): TaskGatewayError { + return new TaskGatewayError('unavailable', INVALID_TASK_RESPONSE_MESSAGE); } /** @@ -195,7 +106,10 @@ function validateParsedTaskEnvelopeTask(task: unknown): Task { * Centralizes the repeated Content-Type + Idempotency-Key header pair and * JSON serialization so that createTask and transitionTask stay DRY. */ -function mutationInit(body: unknown, method: 'POST' | 'PUT'): RequestInit { +function mutationInit( + body: CreateTaskRequest | { state: TaskState }, + method: 'POST' | 'PUT', +): RequestInit { return { body: JSON.stringify(body), headers: { @@ -262,32 +176,7 @@ async function sendTaskRequest( throw await readGatewayError(response); } - const envelope = await parseSuccessEnvelope(response); - return validateParsedTaskEnvelopeTask(envelope.data.task); -} - -function isValidEnvelopeShell( - parsed: unknown, -): parsed is Record { - return ( - isPlainRecord(parsed) && - parsed.success === true && - parsed.error === null && - isPlainRecord(parsed.metadata) && - hasValidMetadata(parsed.metadata) - ); -} - -function hasValidMetadata(metadata: Record): boolean { - return ( - isNonEmptyString(metadata.request_id) && - metadata.version === CONTRACT_VERSION && - isValidTimestamp(metadata.timestamp) - ); -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.length > 0; + return parseSuccessEnvelope(response); } interface ParsedTimestamp { @@ -303,10 +192,10 @@ interface ParsedTimestamp { const TIMESTAMP_RE = /^(?\d{4})-(?\d{2})-(?\d{2})T(?\d{2}):(?\d{2}):(?\d{2})(?:\.(?\d+))?(?Z|[+-]\d{2}:\d{2})$/; -function parseTimestampGroups(value: string): ParsedTimestamp | null { +function parseTimestampGroups(value: string): ParsedTimestamp | undefined { const match = TIMESTAMP_RE.exec(value); const groups = match?.groups; - if (!groups) return null; + if (!groups) return undefined; return { year: Number(groups.year), month: Number(groups.month), @@ -347,10 +236,9 @@ function matchesCalendarDate(p: ParsedTimestamp): boolean { ); } -function isValidTimestamp(value: unknown): value is string { - if (!isNonEmptyString(value)) return false; +function isValidTimestamp(value: string): boolean { const parts = parseTimestampGroups(value); - if (parts === null) return false; + if (parts === undefined) return false; return ( isValidTimeOfDay(parts.hour, parts.minute, parts.second) && isValidOffset(parts.offset) && @@ -358,21 +246,10 @@ function isValidTimestamp(value: unknown): value is string { ); } -function hasValidTaskPayload(data: unknown): boolean { - return ( - isPlainRecord(data) && - 'task' in data && - data.task !== null && - data.task !== undefined - ); -} - -async function parseSuccessEnvelope( - response: Response, -): Promise> { - let parsed: unknown; +async function parseSuccessEnvelope(response: Response): Promise { + let responseBody: unknown; try { - parsed = await response.json(); + responseBody = await response.json(); } catch { throw new TaskGatewayError( 'unavailable', @@ -380,60 +257,60 @@ async function parseSuccessEnvelope( ); } - if (!isValidEnvelopeShell(parsed)) { - throw new TaskGatewayError('unavailable', INVALID_SUCCESS_ENVELOPE_MESSAGE); - } - if (!hasValidTaskPayload(parsed.data)) { + const envelope = successEnvelopeSchema.safeParse(responseBody); + if (!envelope.success) { throw new TaskGatewayError('unavailable', INVALID_SUCCESS_ENVELOPE_MESSAGE); } - return parsed as unknown as ApiEnvelope; + const task = taskSchema.safeParse(envelope.data.data.task); + if (!task.success) throw taskContractGatewayError(); + return task.data; } async function readGatewayError(response: Response): Promise { - const HTTP_STATUS_TO_ERROR_KIND: Readonly< - Partial> - > = { - 400: 'validation', - 401: 'unauthorized', - 403: 'unauthorized', - 404: 'not_found', - 409: 'conflict', - } as const; - - function classifyErrorKind(statusCode: number): TaskGatewayErrorKind { - return HTTP_STATUS_TO_ERROR_KIND[statusCode] ?? 'unavailable'; - } - - function mapErrorKind( - statusCode: number, - errorBody: SharedErrorResponse, - ): TaskGatewayErrorKind { - const reason = errorBody.details?.reason; - if (typeof reason === 'string' && reason === 'invalid_task_transition') { - return 'conflict'; - } - - return classifyErrorKind(statusCode); - } - const fallbackMessage = `The task API returned HTTP ${response.status}.`; - let body: unknown; + let responseBody: unknown; try { - body = await response.json(); + responseBody = await response.json(); } catch { return new TaskGatewayError('unavailable', fallbackMessage); } - if (!isPlainRecord(body) || typeof body.message !== 'string') { + const errorBody = sharedErrorResponseSchema.safeParse(responseBody); + if (!errorBody.success) { return new TaskGatewayError('unavailable', fallbackMessage); } - const errorBody = body as unknown as SharedErrorResponse; + const errorKind = invalidTaskTransitionSchema.safeParse( + errorBody.data.details, + ).success + ? 'conflict' + : (errorKindForStatus(response.status) ?? 'unavailable'); return new TaskGatewayError( - mapErrorKind(response.status, errorBody), - errorBody.message || fallbackMessage, + errorKind, + errorBody.data.message || fallbackMessage, ); } + +function errorKindForStatus(status: number): TaskGatewayErrorKind | undefined { + switch (status) { + case 400: { + return 'validation'; + } + case 401: + case 403: { + return 'unauthorized'; + } + case 404: { + return 'not_found'; + } + case 409: { + return 'conflict'; + } + default: { + return undefined; + } + } +} diff --git a/frontend-pwa/src/task_slice/application/task-gateway-context.test.tsx b/frontend-pwa/src/task_slice/application/task-gateway-context.test.tsx index c991a441..a59ade4d 100644 --- a/frontend-pwa/src/task_slice/application/task-gateway-context.test.tsx +++ b/frontend-pwa/src/task_slice/application/task-gateway-context.test.tsx @@ -9,7 +9,7 @@ import { TaskGatewayProvider, useTaskGateway } from './task-gateway-context'; describe('useTaskGateway', () => { it('throws when called outside a TaskGatewayProvider', () => { - const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); try { expect(() => renderHook(() => useTaskGateway())).toThrow( diff --git a/frontend-pwa/src/task_slice/application/task-gateway-context.tsx b/frontend-pwa/src/task_slice/application/task-gateway-context.tsx index 7dec8656..a8a20263 100644 --- a/frontend-pwa/src/task_slice/application/task-gateway-context.tsx +++ b/frontend-pwa/src/task_slice/application/task-gateway-context.tsx @@ -11,7 +11,9 @@ import { createContext, type PropsWithChildren, useContext } from 'react'; import type { TaskSliceGateway } from '../ports/task-slice-gateway'; -const TaskGatewayContext = createContext(null); +const TaskGatewayContext = createContext( + undefined, +); /** * Injects a `TaskSliceGateway` implementation into the React context tree. diff --git a/frontend-pwa/src/task_slice/ui/task-create-form.test.tsx b/frontend-pwa/src/task_slice/ui/task-create-form.test.tsx index e998555c..ef4d0821 100644 --- a/frontend-pwa/src/task_slice/ui/task-create-form.test.tsx +++ b/frontend-pwa/src/task_slice/ui/task-create-form.test.tsx @@ -1,9 +1,9 @@ /** * @file Unit tests for the `TaskCreateForm` presentational component. */ -import { fireEvent, render, screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import type { FormEvent } from 'react'; +import type { SyntheticEvent } from 'react'; import { I18nProvider } from '../../i18n/runtime'; import type { TaskCreateDraft, TaskCreateErrors } from '../domain/task-form'; @@ -28,7 +28,7 @@ function renderForm(overrides?: { isSubmitting?: boolean; submitError?: string; onChange?: (field: string, value: string) => void; - onSubmit?: (event: FormEvent) => void; + onSubmit?: (event: SyntheticEvent) => void; }) { const onChange = overrides?.onChange ?? vi.fn(); const onSubmit = overrides?.onSubmit ?? vi.fn(); @@ -103,12 +103,17 @@ describe('TaskCreateForm', () => { }); it('calls onSubmit when the form is submitted', async () => { - const { onSubmit } = renderForm(); + let submittedFormClass: string | undefined; + renderForm({ + onSubmit: (event) => { + submittedFormClass = event.currentTarget.className; + }, + }); const submitButton = screen.getByRole('button', { name: 'Create task' }); - fireEvent.submit(submitButton.closest('form') as HTMLFormElement); + await userEvent.click(submitButton); - expect(onSubmit).toHaveBeenCalledTimes(1); + expect(submittedFormClass).toBe('task-create-form'); }); it('matches the expected rendered structure for the idle state', () => { diff --git a/frontend-pwa/src/task_slice/ui/task-create-form.tsx b/frontend-pwa/src/task_slice/ui/task-create-form.tsx index d9517293..fd7e8476 100644 --- a/frontend-pwa/src/task_slice/ui/task-create-form.tsx +++ b/frontend-pwa/src/task_slice/ui/task-create-form.tsx @@ -6,7 +6,7 @@ * `TaskCreateFormProps`; this component is purely presentational and carries * no side-effects. Field names and labels are resolved via `useI18n`. */ -import type { FormEvent } from 'react'; +import type { SyntheticEvent } from 'react'; import { useI18n } from '../../i18n/runtime'; import type { @@ -21,7 +21,7 @@ interface TaskCreateFormProps { isSubmitting: boolean; submitError?: string; onChange(field: TaskCreateField, value: string): void; - onSubmit(event: FormEvent): void; + onSubmit(event: SyntheticEvent): void; } /** @@ -49,7 +49,7 @@ export function TaskCreateForm({
{submitError}
- ) : null} + ) : undefined}
{error ? ( {error} - ) : null} + ) : undefined} ); } diff --git a/frontend-pwa/src/task_slice/ui/task-detail-card.tsx b/frontend-pwa/src/task_slice/ui/task-detail-card.tsx index c94fbe72..08cd1dba 100644 --- a/frontend-pwa/src/task_slice/ui/task-detail-card.tsx +++ b/frontend-pwa/src/task_slice/ui/task-detail-card.tsx @@ -81,7 +81,7 @@ export function TaskDetailCard({ task }: { task: Task }) { {task.origin.metadata.description}

- ) : null} + ) : undefined} ); } diff --git a/frontend-pwa/src/test/audit-utils.test.mjs b/frontend-pwa/src/test/audit-utils.test.mjs new file mode 100644 index 00000000..833a1184 --- /dev/null +++ b/frontend-pwa/src/test/audit-utils.test.mjs @@ -0,0 +1,43 @@ +/** + * Unit tests for parsing Bun audit output at the command boundary. + */ +import { describe, expect, it } from 'vitest'; + +import { collectAdvisories } from '../../security/audit-utils.js'; + +describe('collectAdvisories', () => { + it('extracts advisory details from Bun nested output', () => { + const advisories = collectAdvisories({ + advisories: { + lodash: [ + { + title: 'Prototype pollution', + url: 'https://github.com/advisories/GHSA-abcd-efgh-ijkl', + }, + ], + }, + }); + + expect(advisories).toMatchObject([ + { + github_advisory_id: 'GHSA-abcd-efgh-ijkl', + package: 'lodash', + title: 'Prototype pollution', + }, + ]); + }); + + it('accepts direct package entries and ignores malformed payloads', () => { + const directAdvisories = collectAdvisories({ + lodash: [{ github_advisory_id: 'GHSA-abcd-efgh-ijkl' }], + }); + + expect(directAdvisories).toMatchObject([ + { + github_advisory_id: 'GHSA-abcd-efgh-ijkl', + package: 'lodash', + }, + ]); + expect(collectAdvisories({ advisories: 'invalid' })).toEqual([]); + }); +}); diff --git a/frontend-pwa/src/test/test-utils.tsx b/frontend-pwa/src/test/test-utils.tsx index a5bf09b8..9bfd487c 100644 --- a/frontend-pwa/src/test/test-utils.tsx +++ b/frontend-pwa/src/test/test-utils.tsx @@ -15,12 +15,15 @@ import { createAppRouter } from '../app/router'; import { createFixtureTaskGateway } from '../task_slice/adapters/fixture/fixture-task-gateway'; import type { Task } from '../task_slice/domain/task'; -export async function renderApp( - { initialPath = '/tasks/new', seedTasks } = {} as { - initialPath?: string; - seedTasks?: Task[]; - }, -) { +interface RenderAppOptions { + initialPath?: string; + seedTasks?: Task[]; +} + +export async function renderApp({ + initialPath = '/tasks/new', + seedTasks, +}: RenderAppOptions = {}) { const history = createMemoryHistory({ initialEntries: [initialPath] }); const router = createAppRouter(); router.update({ history }); diff --git a/frontend-pwa/tests/setup-vitest-a11y.ts b/frontend-pwa/tests/setup-vitest-a11y.ts index a6cbaede..f69d4ace 100644 --- a/frontend-pwa/tests/setup-vitest-a11y.ts +++ b/frontend-pwa/tests/setup-vitest-a11y.ts @@ -31,28 +31,40 @@ declare module 'vitest' { } } -expect.extend( - toHaveNoViolations as unknown as Parameters[0], +expect.extend(toHaveNoViolations); + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean; +} + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +globalThis.scrollTo = () => {}; + +const matchMediaDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'matchMedia', ); +const existingMatchMedia = + matchMediaDescriptor?.get?.call(globalThis) ?? matchMediaDescriptor?.value; -( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT: boolean; - } -).IS_REACT_ACT_ENVIRONMENT = true; - -window.scrollTo = () => {}; - -if (!window.matchMedia) { - window.matchMedia = (query: string): MediaQueryList => - ({ - media: query, - matches: false, - onchange: null, - addEventListener: () => undefined, - removeEventListener: () => undefined, - addListener: () => undefined, - removeListener: () => undefined, - dispatchEvent: () => true, - }) as MediaQueryList; +if (!existingMatchMedia) { + globalThis.matchMedia = (query: string): MediaQueryList => { + const members = new Map([ + ['media', query], + ['matches', false], + ['onchange', undefined], + ['addEventListener', () => {}], + ['removeEventListener', () => {}], + ['dispatchEvent', () => true], + ['addListener', () => {}], + ['removeListener', () => {}], + ]); + + // SAFETY: The proxy supplies the MediaQueryList members used by tests and + // no-op legacy listener methods that JSDOM's replacement still needs. + return new Proxy( + {}, + { get: (_target, property) => members.get(property) }, + ) as MediaQueryList; + }; } diff --git a/frontend-pwa/tools/stylelint.config.cjs b/frontend-pwa/tools/stylelint.config.cjs index 561c4617..1ac524d2 100644 --- a/frontend-pwa/tools/stylelint.config.cjs +++ b/frontend-pwa/tools/stylelint.config.cjs @@ -12,7 +12,7 @@ module.exports = { "fill", "stroke", ], - { ignoreValues: ["transparent", "currentColor", "/^var\\(--/"] }, + { ignoreValues: ['transparent', 'currentColor', String.raw`/^var\(--/`] }, ], "declaration-no-important": true, "declaration-property-unit-disallowed-list": { diff --git a/frontend-pwa/vitest.config.ts b/frontend-pwa/vitest.config.ts index de289511..512dd737 100644 --- a/frontend-pwa/vitest.config.ts +++ b/frontend-pwa/vitest.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, - include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.test.mjs'], setupFiles: ['./src/test/setup.ts'], }, }); From c63c79a80fcde7c530286c672f32e21115a650a9 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 27 Oct 2026 17:45:23 +0100 Subject: [PATCH 2/6] Harden frontend lint boundaries Reject malformed audit results instead of dropping advisories, and cover lint configuration, audit command and HTTP gateway boundaries. Document the affected helpers and exercise HTTP mode in the browser. --- docs/developers-guide.md | 6 + frontend-pwa/playwright.config.ts | 29 ++- .../scripts/check-classlist-length.ts | 12 ++ frontend-pwa/scripts/check-locale-vars.ts | 9 + .../scripts/find-near-duplicate-classes.ts | 16 ++ frontend-pwa/scripts/run-audit.mjs | 7 + frontend-pwa/security/audit-utils.js | 46 ++++- frontend-pwa/src/docs-check.test.ts | 1 + frontend-pwa/src/routes/task-create-page.tsx | 11 ++ .../adapters/fixture/fixture-task-gateway.ts | 11 ++ .../adapters/http/http-task-gateway.test.ts | 178 +++++++++++++++++ .../adapters/http/http-task-gateway.ts | 73 +++++++ .../task_slice/ui/task-create-form.test.tsx | 1 + frontend-pwa/src/test/audit-command.test.mjs | 67 +++++++ frontend-pwa/src/test/audit-utils.test.mjs | 53 +++++- frontend-pwa/src/test/semantic-cli.test.mjs | 180 ++++++++++++++++++ frontend-pwa/src/test/test-utils.tsx | 1 + frontend-pwa/tests/e2e/http-task-flow.spec.ts | 129 +++++++++++++ frontend-pwa/tools/stylelint.config.cjs | 3 + 19 files changed, 815 insertions(+), 18 deletions(-) create mode 100644 frontend-pwa/src/test/audit-command.test.mjs create mode 100644 frontend-pwa/src/test/semantic-cli.test.mjs create mode 100644 frontend-pwa/tests/e2e/http-task-flow.spec.ts diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 1c69ad0e..8f6c9860 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -217,6 +217,12 @@ The lint command supplements the existing Biome checks. Run `make frontend-typecheck`, `make frontend-docs-check`, and `make frontend-test` for the separate TypeScript, TypeDoc, and behavioural validation gates. +The semantic command regression suite creates temporary project fixtures and +copies the script under test into each fixture. Its setup helpers belong only +to that suite; production scripts do not depend on them. The audit command +tests provide a temporary `bun` executable through the child process's path to +exercise exit and signal handling without changing the parent environment. + ### TypeScript type checking for tests `frontend-pwa/tsconfig.test.json` extends the base `tsconfig.json` and widens diff --git a/frontend-pwa/playwright.config.ts b/frontend-pwa/playwright.config.ts index 3229d3af..6d69a947 100644 --- a/frontend-pwa/playwright.config.ts +++ b/frontend-pwa/playwright.config.ts @@ -2,7 +2,7 @@ * Configure Playwright end-to-end runs for the repository-owned frontend PWA. * * This module exports the default browser test configuration, including device - * presets and the local Vite web-server contract used by CI and local runs. + * presets and separate fixture and HTTP-mode Vite servers used by test runs. */ import { defineConfig, devices } from '@playwright/test'; @@ -13,15 +13,32 @@ export default defineConfig({ baseURL: 'http://127.0.0.1:4173', trace: 'on-first-retry', }, - webServer: { - command: 'bun run dev --host 127.0.0.1 --port 4173', - port: 4173, - reuseExistingServer: !process.env.CI, - }, + webServer: [ + { + command: 'bun run dev --host 127.0.0.1 --port 4173', + port: 4173, + reuseExistingServer: !process.env.CI, + }, + { + command: 'bun run dev --host 127.0.0.1 --port 4174', + env: { + VITE_CORBUSIER_API_BASE_URL: '/api/v1', + VITE_TASK_GATEWAY_MODE: 'http', + }, + port: 4174, + reuseExistingServer: false, + }, + ], projects: [ { name: 'chromium', + testIgnore: '**/http-task-flow.spec.ts', use: { ...devices['Desktop Chrome'] }, }, + { + name: 'http-chromium', + testMatch: '**/http-task-flow.spec.ts', + use: { ...devices['Desktop Chrome'], baseURL: 'http://127.0.0.1:4174' }, + }, ], }); diff --git a/frontend-pwa/scripts/check-classlist-length.ts b/frontend-pwa/scripts/check-classlist-length.ts index 0225dc1a..f0b112cd 100644 --- a/frontend-pwa/scripts/check-classlist-length.ts +++ b/frontend-pwa/scripts/check-classlist-length.ts @@ -27,6 +27,11 @@ interface Violation { const CONFIG_PATH = 'tools/semantic-lint.config.json'; const PROJECT_ROOT = process.cwd(); +/** + * Read the configured class-list limit, defaulting to 16 utilities. + * Missing, unreadable, malformed, or schema-invalid configuration throws. + * @example A configured value of `12.8` produces a threshold of `12`. + */ function loadThreshold(): number { const raw = readFileSync(CONFIG_PATH, 'utf8'); const config = SemanticConfigSchema.parse(JSON.parse(raw)); @@ -34,11 +39,17 @@ function loadThreshold(): number { return value === undefined ? 16 : Math.floor(value); } +/** Return paths for every TSX file under the frontend `src/` directory. */ function getTsxFiles(): string[] { const glob = new Bun.Glob('src/**/*.tsx'); return [...glob.scanSync(PROJECT_ROOT)]; } +/** + * Extract a static string from a JSX attribute value; dynamic expressions + * return `undefined` and are skipped by the class-list check. + * @example `className="p-2"` yields `p-2`; `className={classes}` is skipped. + */ function extractStringLiteral( initializer: ts.JsxAttributeValue, ): string | undefined { @@ -104,6 +115,7 @@ function analyseFile( visit(source); } +/** Scan frontend TSX files, report over-limit class lists, and set exit code 1. */ function main(): void { const maxLength = loadThreshold(); const violations: Violation[] = []; diff --git a/frontend-pwa/scripts/check-locale-vars.ts b/frontend-pwa/scripts/check-locale-vars.ts index b5cf9165..71501786 100644 --- a/frontend-pwa/scripts/check-locale-vars.ts +++ b/frontend-pwa/scripts/check-locale-vars.ts @@ -17,6 +17,10 @@ const BASE_LOCALE = process.env.I18N_BASE_LOCALE ?? 'en-gb'; const LOCALES_DIR = path.resolve(process.cwd(), 'src/i18n'); const PLACEHOLDER_PATTERN = /\{([a-zA-Z0-9_]+)\}/g; +/** + * List sorted `.ts` catalogue paths in `src/i18n`; filesystem errors propagate. + * For example, `en-gb.ts` and `fr.ts` are returned in path-sorted order. + */ async function listLocaleFiles(): Promise { return fs .readdirSync(LOCALES_DIR, { withFileTypes: true }) @@ -25,6 +29,11 @@ async function listLocaleFiles(): Promise { .toSorted(); } +/** + * Import one locale module and return its sole string-to-string catalogue. + * Import errors propagate; zero or multiple catalogue exports throw an error. + * @example An export `{ greeting: 'Hello' }` is returned as the message map. + */ async function loadMessages(filePath: string): Promise { const module = await import(pathToFileURL(filePath).href); const exports = Object.values(module) diff --git a/frontend-pwa/scripts/find-near-duplicate-classes.ts b/frontend-pwa/scripts/find-near-duplicate-classes.ts index b450c147..bc9e7da0 100644 --- a/frontend-pwa/scripts/find-near-duplicate-classes.ts +++ b/frontend-pwa/scripts/find-near-duplicate-classes.ts @@ -99,12 +99,17 @@ const CONCEPT_HINTS: ConceptHint[] = [ }, ]; +/** + * Merge configured options with defaults; config read, JSON, or schema errors + * throw. Defaults require 4 tokens, distance at most 0.2, and 2 occurrences. + */ function loadOptions(): NearDuplicateOptions { const raw = readFileSync(CONFIG_PATH, 'utf8'); const config = SemanticConfigSchema.parse(JSON.parse(raw)); return { ...DEFAULT_OPTIONS, ...config.nearDuplicateClasses }; } +/** Return paths for every TSX file under the frontend `src/` directory. */ function getTsxFiles(): string[] { const glob = new Bun.Glob('src/**/*.tsx'); return [...glob.scanSync(PROJECT_ROOT)]; @@ -121,6 +126,7 @@ function tokenMatches(pattern: string, token: string): boolean { return token === pattern; } +/** Return the first ordered concept hint whose required token patterns match. */ function findConceptHint(tokens: string[]): ConceptHint | undefined { for (const hint of CONCEPT_HINTS) { const matches = hint.requiredTokens.every((pattern) => @@ -133,6 +139,10 @@ function findConceptHint(tokens: string[]): ConceptHint | undefined { return undefined; } +/** + * Extract a static JSX string value; dynamic expressions return `undefined`. + * @example `className="flex gap-2"` yields `flex gap-2`. + */ function extractStringLiteral( initializer: ts.JsxAttributeValue, ): string | undefined { @@ -151,6 +161,10 @@ function extractStringLiteral( return undefined; } +/** + * Split a class stack into unique, lowercase, sorted tokens. + * @example `normalizeTokens(' P-2 p-2 FLEX ')` yields `['flex', 'p-2']`. + */ function normalizeTokens(raw: string): string[] { return [ ...new Set( @@ -257,6 +271,7 @@ function jaccardDistance( return union === 0 ? 1 : 1 - intersection / union; } +/** Emit locations and optional concept hints; return the exact-group count. */ function reportExactDuplicates( occurrences: readonly Occurrence[], options: NearDuplicateOptions, @@ -289,6 +304,7 @@ function reportExactDuplicates( return findings; } +/** Emit pairs within the configured Jaccard distance; return the pair count. */ function reportNearDuplicates( occurrences: readonly Occurrence[], options: NearDuplicateOptions, diff --git a/frontend-pwa/scripts/run-audit.mjs b/frontend-pwa/scripts/run-audit.mjs index 0ea6c41d..082a8ec6 100644 --- a/frontend-pwa/scripts/run-audit.mjs +++ b/frontend-pwa/scripts/run-audit.mjs @@ -20,6 +20,7 @@ import { runAuditJson, } from '../security/audit-utils.js'; +/** Resolve symlinks and canonicalise a path; filesystem errors propagate. */ const normalise = (filePath) => realpathSync.native(filePath); const workspacePackageName = packageJson.name; @@ -58,6 +59,12 @@ function buildLedgerMaps(auditEntries) { return { ledgerByAdvisory, allowedIds }; } +/** + * Return a ledger validation error, or `undefined` when its expiry is valid. + * Date-only expiries remain valid through that calendar day; invalid or past + * dates return a message suitable for the audit diagnostic. + * @example An expiry of `2030-01-02` is valid at noon on that date. + */ function getLedgerExpiryError(entry, advisoryId, referenceDateValue) { if (!entry) { return `Audit ledger entry missing for advisory ${advisoryId ?? 'unknown'}.`; diff --git a/frontend-pwa/security/audit-utils.js b/frontend-pwa/security/audit-utils.js index 40a9bc31..a4f23f80 100644 --- a/frontend-pwa/security/audit-utils.js +++ b/frontend-pwa/security/audit-utils.js @@ -32,6 +32,17 @@ function extractGhsaFromUrl(url) { return match?.[0]; } +/** + * Read an advisory's GitHub identifier, falling back to its advisory URL. + * Invalid records and records without a GHSA identifier produce `undefined`. + * + * @param {unknown} advisory + * @returns {string | undefined} + * + * @example + * extractGithubAdvisoryId({ url: 'https://github.com/advisories/GHSA-abcd-1234-efgh' }); + * // Returns 'GHSA-abcd-1234-efgh'. + */ function extractGithubAdvisoryId(advisory) { const result = AdvisorySchema.safeParse(advisory); if (!result.success) { @@ -64,6 +75,9 @@ function assertSpawnSuccess(result) { `bun audit did not exit normally (signal: ${result.signal})`, ); } + if (!Number.isInteger(result.status)) { + throw new TypeError('bun audit did not exit normally'); + } } /** @@ -136,20 +150,34 @@ export function runAuditJson() { export function collectAdvisories(auditJson) { const root = AuditObjectSchema.safeParse(auditJson); if (!root.success) { - return []; + throw new Error('Invalid bun audit advisory payload'); } - const nestedAdvisories = AdvisoryMapSchema.safeParse(root.data.advisories); - if (nestedAdvisories.success) { - return flattenAdvisories(nestedAdvisories.data); + const entries = Object.hasOwn(root.data, 'advisories') + ? root.data.advisories + : root.data; + const advisories = AdvisoryMapSchema.safeParse(entries); + if (!advisories.success) { + throw new Error('Invalid bun audit advisory payload'); } - - const directAdvisories = AdvisoryMapSchema.safeParse(root.data); - return directAdvisories.success - ? flattenAdvisories(directAdvisories.data) - : []; + return flattenAdvisories(advisories.data); } +/** + * Add each package name and normalized GitHub advisory ID to its records. + * Records remain in package order and preserve their original fields. + * + * @param {Record>} advisories + * @returns {Array & { + * package: string, + * github_advisory_id?: string + * }>} Normalized advisory records. + * + * @example + * flattenAdvisories({ pkg: [{ url: 'https://github.com/advisories/GHSA-abcd-1234-efgh' }] }); + * // Returns [{ package: 'pkg', url: 'https://github.com/advisories/GHSA-abcd-1234-efgh', + * // github_advisory_id: 'GHSA-abcd-1234-efgh' }]. + */ function flattenAdvisories(advisories) { return Object.entries(advisories).flatMap(([packageName, records]) => records.map((advisory) => ({ diff --git a/frontend-pwa/src/docs-check.test.ts b/frontend-pwa/src/docs-check.test.ts index 93831803..4c807789 100644 --- a/frontend-pwa/src/docs-check.test.ts +++ b/frontend-pwa/src/docs-check.test.ts @@ -17,6 +17,7 @@ import path from 'node:path'; const repositoryRoot = path.resolve(process.cwd(), '..'); +/** Creates a Bun stub that records its working directory and arguments. */ function createFakeBun(fixtureDirectory: string, exitCode: number) { const fakeBun = path.join(fixtureDirectory, 'bun'); writeFileSync( diff --git a/frontend-pwa/src/routes/task-create-page.tsx b/frontend-pwa/src/routes/task-create-page.tsx index 85c0164f..3184bf50 100644 --- a/frontend-pwa/src/routes/task-create-page.tsx +++ b/frontend-pwa/src/routes/task-create-page.tsx @@ -37,6 +37,17 @@ export function TaskCreatePage() { setErrors((current) => ({ ...current, [field]: undefined })); } + /** + * Validate the current draft, create its task, then navigate to that task. + * Invalid drafts stay on the form with field errors; mutation failures are + * exposed through `mutation.error` for the form's error banner. + * + * @param event - Form submission event whose browser navigation is prevented. + * @returns Resolves after validation, and after navigation if creation succeeds. + * + * @example + * // The form calls this handler; a successful submission opens the new task. + */ async function handleSubmit(event: SyntheticEvent) { event.preventDefault(); diff --git a/frontend-pwa/src/task_slice/adapters/fixture/fixture-task-gateway.ts b/frontend-pwa/src/task_slice/adapters/fixture/fixture-task-gateway.ts index 4e03d326..1241efe1 100644 --- a/frontend-pwa/src/task_slice/adapters/fixture/fixture-task-gateway.ts +++ b/frontend-pwa/src/task_slice/adapters/fixture/fixture-task-gateway.ts @@ -38,6 +38,17 @@ export function createFixtureTaskGateway( const tasks = new Map(seedTasks.map((task) => [task.id, task])); let operationQueue = Promise.resolve(); + /** + * Run a gateway operation after earlier operations have settled. + * A rejected operation still rejects for its caller, while the queue remains + * usable for later operations. + * + * @param operation - Asynchronous operation to serialize with gateway calls. + * @returns The operation's result or rejection. + * + * @example + * await enqueue(async () => 42); // Resolves to 42 after earlier work finishes. + */ function enqueue(operation: () => Promise): Promise { const nextOperation = operationQueue.then(operation, operation); operationQueue = nextOperation.then( diff --git a/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts b/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts index 8b37ac59..2274e90d 100644 --- a/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts +++ b/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts @@ -4,6 +4,7 @@ * The suite validates success parsing plus shared-error mapping without * reaching a real backend. */ +import type { CreateTaskRequest } from '../../domain/task'; import { TaskGatewayError } from '../../ports/task-slice-gateway'; import { createHttpTaskGateway } from './http-task-gateway'; @@ -38,10 +39,12 @@ const taskEnvelope = `{ "success": true }`; +/** Returns a fetch mock that resolves with the supplied response. */ function createFetchForResponse(response: Response): typeof fetch { return vi.fn().mockResolvedValue(response); } +/** Builds the gateway with a fetch mock fixed to the supplied response. */ function createGatewayWithResponse(response: Response) { return createHttpTaskGateway('/api/v1', createFetchForResponse(response)); } @@ -174,4 +177,179 @@ describe('http task gateway', () => { ), ); }); + + it('sends the create request with an idempotency key', async () => { + const fetchFn = createFetchForResponse(new Response(taskEnvelope)); + const gateway = createHttpTaskGateway('/api/v1', fetchFn); + const request = { + provider: 'github', + repository: 'acme/widgets', + issue_number: 42, + title: 'A task', + } satisfies CreateTaskRequest; + + await gateway.createTask(request); + + expect(fetchFn).toHaveBeenCalledWith( + '/api/v1/tasks', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(request), + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ), + }, + }), + ); + }); + + it.each([ + { status: 400, kind: 'validation' }, + { status: 401, kind: 'unauthorized' }, + { status: 403, kind: 'unauthorized' }, + { status: 404, kind: 'not_found' }, + { status: 409, kind: 'conflict' }, + { status: 503, kind: 'unavailable' }, + ])('maps HTTP $status to $kind', async ({ status, kind }) => { + const gateway = createGatewayWithResponse( + Response.json({ message: 'API error' }, { status }), + ); + + await expect(gateway.getTask('task-1')).rejects.toMatchObject({ + kind, + message: 'API error', + }); + }); + + it('reports malformed success JSON', async () => { + const gateway = createGatewayWithResponse(new Response('{')); + + await expect(gateway.getTask('task-1')).rejects.toEqual( + new TaskGatewayError( + 'unavailable', + 'The task API returned malformed JSON.', + ), + ); + }); + + it('maps a rejected fetch to unavailable', async () => { + const fetchFn = vi + .fn() + .mockRejectedValue(new Error('offline')); + const gateway = createHttpTaskGateway('/api/v1', fetchFn); + + await expect(gateway.getTask('task-1')).rejects.toEqual( + new TaskGatewayError('unavailable', 'The task API could not be reached.'), + ); + }); + + it.each([ + { body: '{', name: 'malformed JSON' }, + { body: '{"message": 42}', name: 'an invalid error shape' }, + ])('falls back to the HTTP status for $name', async ({ body }) => { + const gateway = createGatewayWithResponse( + new Response(body, { status: 503 }), + ); + + await expect(gateway.getTask('task-1')).rejects.toEqual( + new TaskGatewayError('unavailable', 'The task API returned HTTP 503.'), + ); + }); + + it.each([ + 0, -1, -42, 1.5, 9_007_199_254_740_992, + ])('rejects invalid issue number %s', async (issueNumber) => { + const responseBody = taskEnvelope.replace( + '"issue_number": 42', + `"issue_number": ${issueNumber}`, + ); + const gateway = createGatewayWithResponse(new Response(responseBody)); + + await expect(gateway.getTask('task-1')).rejects.toEqual( + new TaskGatewayError( + 'unavailable', + 'The task API returned an invalid task shape.', + ), + ); + }); + + it('normalizes null optional references to absence', async () => { + const responseBody = taskEnvelope.replace( + '"id": "task-1"', + '"id": "task-1", "branch_ref": null, "pull_request_ref": null', + ); + const task = await createGatewayWithResponse( + new Response(responseBody), + ).getTask('task-1'); + + expect(task.branch_ref).toBeUndefined(); + expect(task.pull_request_ref).toBeUndefined(); + }); + + it.each( + Array.from({ length: 12 }, (_, index) => ({ + sequence: index, + text: `extension-${index}`, + nested: { enabled: index % 2 === 0 }, + })), + )('retains generated task extension $sequence', async (extension) => { + const responseBody = taskEnvelope.replace( + '"id": "task-1"', + `"id": "task-1", "extension": ${JSON.stringify(extension)}`, + ); + const gateway = createGatewayWithResponse(new Response(responseBody)); + + await expect(gateway.getTask('task-1')).resolves.toMatchObject({ + extension, + }); + }); + + it.each([ + '2024-02-29T23:59:59Z', + '2026-01-01T00:00:00+23:59', + '2026-12-31T23:59:59.123456-23:59', + ...Array.from( + { length: 12 }, + (_, index) => `2026-${String(index + 1).padStart(2, '0')}-01T12:30:45Z`, + ), + ])('accepts valid calendar timestamp %s', async (timestamp) => { + const responseBody = taskEnvelope.replace( + '"created_at": "2026-04-13T00:00:00.000Z"', + `"created_at": "${timestamp}"`, + ); + const gateway = createGatewayWithResponse(new Response(responseBody)); + + await expect(gateway.getTask('task-1')).resolves.toMatchObject({ + created_at: timestamp, + }); + }); + + it.each([ + '2023-02-29T00:00:00Z', + '2026-04-31T00:00:00Z', + '2026-01-01T24:00:00Z', + '2026-01-01T23:60:00Z', + '2026-01-01T23:59:60Z', + '2026-01-01T00:00:00+24:00', + '2026-01-01T00:00:00-01:60', + ...Array.from( + { length: 12 }, + (_, index) => `2026-${String(index + 1).padStart(2, '0')}-32T12:30:45Z`, + ), + ])('rejects invalid calendar timestamp %s', async (timestamp) => { + const responseBody = taskEnvelope.replace( + '"created_at": "2026-04-13T00:00:00.000Z"', + `"created_at": "${timestamp}"`, + ); + const gateway = createGatewayWithResponse(new Response(responseBody)); + + await expect(gateway.getTask('task-1')).rejects.toEqual( + new TaskGatewayError( + 'unavailable', + 'The task API returned an invalid task shape.', + ), + ); + }); }); diff --git a/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.ts b/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.ts index d9b2393c..b1e44c78 100644 --- a/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.ts +++ b/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.ts @@ -97,6 +97,15 @@ const invalidTaskTransitionSchema = z.object({ reason: z.literal('invalid_task_transition'), }); +/** + * Create the gateway error used when a task response violates its contract. + * Callers can report the failure as an unavailable API response. + * + * @returns A stable error for malformed task data. + * + * @example + * throw taskContractGatewayError(); + */ function taskContractGatewayError(): TaskGatewayError { return new TaskGatewayError('unavailable', INVALID_TASK_RESPONSE_MESSAGE); } @@ -157,6 +166,18 @@ export function createHttpTaskGateway( }; } +/** + * Fetch one task resource and decode the task API's success envelope. + * Network failures and invalid responses reject with `TaskGatewayError`. + * + * @param fetchFn - Fetch implementation, injectable for adapter tests. + * @param resource - Task API URL to request. + * @param init - Optional request method, headers, and body. + * @returns The validated task from the response. + * + * @example + * await sendTaskRequest(fetch, '/tasks/123'); // Resolves to the validated task. + */ async function sendTaskRequest( fetchFn: typeof fetch, resource: string, @@ -192,6 +213,17 @@ interface ParsedTimestamp { const TIMESTAMP_RE = /^(?\d{4})-(?\d{2})-(?\d{2})T(?\d{2}):(?\d{2}):(?\d{2})(?:\.(?\d+))?(?Z|[+-]\d{2}:\d{2})$/; +/** + * Parse the components of an ISO timestamp with an explicit UTC offset. + * Returns `undefined` when the string does not match the timestamp syntax; + * calendar and range checks are performed by `isValidTimestamp`. + * + * @param value - Timestamp string to parse. + * @returns Parsed components, or `undefined` when the syntax does not match. + * + * @example + * parseTimestampGroups('2024-02-29T12:30:00Z'); // Returns the parsed components. + */ function parseTimestampGroups(value: string): ParsedTimestamp | undefined { const match = TIMESTAMP_RE.exec(value); const groups = match?.groups; @@ -236,6 +268,15 @@ function matchesCalendarDate(p: ParsedTimestamp): boolean { ); } +/** + * Check that a timestamp has valid syntax, calendar values, time, and offset. + * + * @param value - Timestamp string to validate. + * @returns `true` only for a valid timestamp with an explicit offset. + * + * @example + * isValidTimestamp('2024-02-29T12:30:00Z'); // Returns true. + */ function isValidTimestamp(value: string): boolean { const parts = parseTimestampGroups(value); if (parts === undefined) return false; @@ -246,6 +287,17 @@ function isValidTimestamp(value: string): boolean { ); } +/** + * Decode and validate a successful task API response. + * Malformed JSON, an invalid envelope, or an invalid task rejects with a + * `TaskGatewayError` of kind `unavailable`. + * + * @param response - Successful HTTP response from the task API. + * @returns The validated task in `data.data.task`. + * + * @example + * await parseSuccessEnvelope(response); // Resolves to the response task. + */ async function parseSuccessEnvelope(response: Response): Promise { let responseBody: unknown; try { @@ -267,6 +319,17 @@ async function parseSuccessEnvelope(response: Response): Promise { return task.data; } +/** + * Convert a non-success HTTP response into the gateway's typed error. + * Invalid or unreadable error bodies use the HTTP-status fallback and the + * `unavailable` kind. + * + * @param response - Non-success response returned by the task API. + * @returns A gateway error with the mapped kind and API or fallback message. + * + * @example + * const error = await readGatewayError(response); // Inspect `error.kind` and `error.message`. + */ async function readGatewayError(response: Response): Promise { const fallbackMessage = `The task API returned HTTP ${response.status}.`; @@ -294,6 +357,16 @@ async function readGatewayError(response: Response): Promise { ); } +/** + * Map task API status codes to their corresponding gateway error kinds. + * Unknown statuses return `undefined` so callers can choose a fallback. + * + * @param status - HTTP status code from the task API. + * @returns A mapped kind for known client errors, otherwise `undefined`. + * + * @example + * errorKindForStatus(404); // Returns 'not_found'. + */ function errorKindForStatus(status: number): TaskGatewayErrorKind | undefined { switch (status) { case 400: { diff --git a/frontend-pwa/src/task_slice/ui/task-create-form.test.tsx b/frontend-pwa/src/task_slice/ui/task-create-form.test.tsx index ef4d0821..9c973e44 100644 --- a/frontend-pwa/src/task_slice/ui/task-create-form.test.tsx +++ b/frontend-pwa/src/task_slice/ui/task-create-form.test.tsx @@ -22,6 +22,7 @@ const emptyDraft: TaskCreateDraft = { const noErrors: TaskCreateErrors = {}; +/** Renders the form with default values and returns its event handlers. */ function renderForm(overrides?: { draft?: Partial; errors?: TaskCreateErrors; diff --git a/frontend-pwa/src/test/audit-command.test.mjs b/frontend-pwa/src/test/audit-command.test.mjs new file mode 100644 index 00000000..55982030 --- /dev/null +++ b/frontend-pwa/src/test/audit-command.test.mjs @@ -0,0 +1,67 @@ +/** End-to-end checks for the Bun audit wrapper and its exit status. */ +import { spawnSync } from 'node:child_process'; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const frontendRoot = process.cwd(); +const auditScript = path.join(frontendRoot, 'scripts/run-audit.mjs'); + +describe('audit command', () => { + let fakeBin; + let bun; + + beforeAll(() => { + const lookup = spawnSync('which', ['bun'], { encoding: 'utf8' }); + if (lookup.status !== 0 || !lookup.stdout.trim()) { + throw new Error('Bun must be available to exercise the audit command'); + } + bun = lookup.stdout.trim(); + fakeBin = mkdtempSync(path.join(tmpdir(), 'corbusier-audit-')); + const executable = path.join(fakeBin, 'bun'); + writeFileSync( + executable, + `#!/bin/sh +case "$AUDIT_FIXTURE" in + clean) printf '%s' '{"advisories":{}}'; exit 0 ;; + invalid_json) printf '%s' '{'; exit 0 ;; + malformed) printf '%s' '{"advisories":{"lodash":[{"github_advisory_id":5}]}}'; exit 0 ;; + unexpected) printf '%s' '{"advisories":{"lodash":[{"github_advisory_id":"GHSA-abcd-efgh-ijkl"}]}}'; exit 0 ;; + nonzero) printf '%s' '{"advisories":{}}'; exit 7 ;; + signal) kill -TERM $$ ;; +esac +exit 99 +`, + ); + chmodSync(executable, 0o755); + }); + + afterAll(() => { + rmSync(fakeBin, { recursive: true, force: true }); + }); + + it.each([ + ['clean', 0, ''], + ['invalid_json', 1, 'Failed to parse bun audit JSON'], + ['malformed', 1, 'Invalid bun audit advisory payload'], + ['unexpected', 1, 'Unexpected vulnerabilities detected by bun audit'], + ['nonzero', 7, ''], + ['signal', 1, 'bun audit did not exit normally'], + ])('handles %s audit output', (scenario, status, errorText) => { + const result = spawnSync(bun, ['run', auditScript], { + cwd: frontendRoot, + encoding: 'utf8', + env: { + ...process.env, + AUDIT_FIXTURE: scenario, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}`, + }, + }); + + expect(result.status).toBe(status); + if (errorText !== '') { + expect(result.stderr).toContain(errorText); + } + }); +}); diff --git a/frontend-pwa/src/test/audit-utils.test.mjs b/frontend-pwa/src/test/audit-utils.test.mjs index 833a1184..da3431b9 100644 --- a/frontend-pwa/src/test/audit-utils.test.mjs +++ b/frontend-pwa/src/test/audit-utils.test.mjs @@ -3,7 +3,10 @@ */ import { describe, expect, it } from 'vitest'; -import { collectAdvisories } from '../../security/audit-utils.js'; +import { + collectAdvisories, + partitionAdvisoriesById, +} from '../../security/audit-utils.js'; describe('collectAdvisories', () => { it('extracts advisory details from Bun nested output', () => { @@ -27,7 +30,7 @@ describe('collectAdvisories', () => { ]); }); - it('accepts direct package entries and ignores malformed payloads', () => { + it('accepts direct package entries and empty audit output', () => { const directAdvisories = collectAdvisories({ lodash: [{ github_advisory_id: 'GHSA-abcd-efgh-ijkl' }], }); @@ -38,6 +41,50 @@ describe('collectAdvisories', () => { package: 'lodash', }, ]); - expect(collectAdvisories({ advisories: 'invalid' })).toEqual([]); + expect(collectAdvisories({})).toEqual([]); + }); + + it('rejects a malformed record rather than dropping valid advisories', () => { + const mixed = { + advisories: { + lodash: [ + { github_advisory_id: 'GHSA-abcd-efgh-ijkl' }, + { github_advisory_id: 42 }, + ], + }, + }; + + expect(() => collectAdvisories(mixed)).toThrow( + 'Invalid bun audit advisory payload', + ); + }); + + it.each([ + { advisories: 'invalid' }, + { advisories: { lodash: 'invalid' } }, + { lodash: [{ title: 5 }] }, + { lodash: ['invalid'] }, + ])('rejects malformed advisory maps: %j', (input) => { + expect(() => collectAdvisories(input)).toThrow( + 'Invalid bun audit advisory payload', + ); + }); + + it('prefers the explicit advisory ID and rejects unidentified records', () => { + const advisories = collectAdvisories({ + lodash: [ + { + github_advisory_id: 'GHSA-aaaa-bbbb-cccc', + url: 'https://github.com/advisories/GHSA-dddd-eeee-ffff', + }, + { url: 'https://example.com/advisory' }, + ], + }); + + const result = partitionAdvisoriesById(advisories, ['GHSA-aaaa-bbbb-cccc']); + expect(result.expected).toMatchObject([ + { github_advisory_id: 'GHSA-aaaa-bbbb-cccc', package: 'lodash' }, + ]); + expect(result.unexpected).toMatchObject([{ package: 'lodash' }]); }); }); diff --git a/frontend-pwa/src/test/semantic-cli.test.mjs b/frontend-pwa/src/test/semantic-cli.test.mjs new file mode 100644 index 00000000..0dfc68cf --- /dev/null +++ b/frontend-pwa/src/test/semantic-cli.test.mjs @@ -0,0 +1,180 @@ +/** + * Exercise semantic lint scripts as commands against isolated project fixtures. + * The fixture copies only the script under test and shares installed packages; + * it never changes the real frontend configuration or process environment. + */ +import { spawnSync } from 'node:child_process'; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const frontendRoot = process.cwd(); +let fixtureRoot; + +/** Run one copied Bun script with its working directory set to the fixture. */ +function runScript(name) { + const script = path.join(fixtureRoot, 'scripts', name); + copyFileSync(path.join(frontendRoot, 'scripts', name), script); + const result = spawnSync('bun', [script], { + cwd: fixtureRoot, + encoding: 'utf8', + timeout: 30_000, + }); + if (result.error) { + throw result.error; + } + return result; +} + +/** Write the semantic-lint configuration consumed by the fixture commands. */ +function writeConfig(config) { + writeFileSync( + path.join(fixtureRoot, 'tools', 'semantic-lint.config.json'), + JSON.stringify(config), + ); +} + +/** Supply one TSX source file for scripts that inspect component markup. */ +function writeComponent(source) { + writeFileSync(path.join(fixtureRoot, 'src', 'fixture.tsx'), source); +} + +describe('semantic lint commands', () => { + beforeEach(() => { + fixtureRoot = mkdtempSync(path.join(tmpdir(), 'corbusier-semantic-')); + for (const directory of ['scripts', 'src', 'tools']) { + mkdirSync(path.join(fixtureRoot, directory)); + } + symlinkSync( + path.join(frontendRoot, 'node_modules'), + path.join(fixtureRoot, 'node_modules'), + 'junction', + ); + }); + + afterEach(() => { + rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + it('uses the default class limit and accepts an explicit higher limit', () => { + const classes = Array.from({ length: 17 }, (_, index) => `c${index}`).join( + ' ', + ); + writeComponent(`
`); + writeConfig({}); + + const defaultResult = runScript('check-classlist-length.ts'); + expect(defaultResult.status).toBe(1); + expect(defaultResult.stderr).toContain('17 utilities (max 16)'); + + writeConfig({ maxClasslistLength: 17 }); + const configuredResult = runScript('check-classlist-length.ts'); + expect(configuredResult.status).toBe(0); + }); + + it.each([ + 0, + -1, + '16', + ])('rejects an invalid class limit %s', (maxClasslistLength) => { + writeConfig({ maxClasslistLength }); + const result = runScript('check-classlist-length.ts'); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('maxClasslistLength'); + }); + + it('reports repeated stacks without failing by default and can fail explicitly', () => { + writeComponent( + '<>
', + ); + writeConfig({}); + const defaultResult = runScript('find-near-duplicate-classes.ts'); + expect(defaultResult.status).toBe(0); + expect(defaultResult.stderr).toContain('Repeated class stack'); + + writeConfig({ nearDuplicateClasses: { failOnViolation: true } }); + const configuredResult = runScript('find-near-duplicate-classes.ts'); + expect(configuredResult.status).toBe(1); + expect(configuredResult.stderr).toContain('Repeated class stack'); + }); + + it.each([ + { minTokenCount: 0 }, + { maxJaccardDistance: 1.1 }, + { minOccurrences: 1.5 }, + { suppressPrefixes: ['safe-', 1] }, + { failOnViolation: 'yes' }, + ])('rejects invalid near-duplicate options %#', (options) => { + writeConfig({ nearDuplicateClasses: options }); + const result = runScript('find-near-duplicate-classes.ts'); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('nearDuplicateClasses'); + }); + + it('uses default user-facing attributes when the configured list is empty', () => { + writeComponent(''); + writeConfig({}); + const defaultResult = runScript('check-hardcoded-strings.ts'); + expect(defaultResult.status).toBe(0); + + writeConfig({ hardcodedStrings: { minWordLength: 1 } }); + const configuredResult = runScript('check-hardcoded-strings.ts'); + expect(configuredResult.status).toBe(1); + expect(configuredResult.stderr).toContain('hard-coded JSX text'); + }); + + it('accepts matching locale maps and rejects a non-string catalogue', () => { + const localeDir = path.join(fixtureRoot, 'src', 'i18n'); + mkdirSync(localeDir); + writeFileSync( + path.join(localeDir, 'en-gb.ts'), + 'export const messages = { greeting: "Hello {name}" };\n', + ); + writeFileSync( + path.join(localeDir, 'fr.ts'), + 'export const messages = { greeting: "Bonjour {name}" };\n', + ); + + const matchingResult = runScript('check-locale-vars.ts'); + expect(matchingResult.status).toBe(0); + expect(matchingResult.stdout).toContain('Locale catalogues match'); + + writeFileSync( + path.join(localeDir, 'fr.ts'), + 'export const messages = { greeting: 12 };\n', + ); + const invalidResult = runScript('check-locale-vars.ts'); + expect(invalidResult.status).not.toBe(0); + expect(invalidResult.stderr).toContain( + 'Expected exactly one message catalogue export', + ); + }); +}); diff --git a/frontend-pwa/src/test/test-utils.tsx b/frontend-pwa/src/test/test-utils.tsx index 9bfd487c..006de4ea 100644 --- a/frontend-pwa/src/test/test-utils.tsx +++ b/frontend-pwa/src/test/test-utils.tsx @@ -20,6 +20,7 @@ interface RenderAppOptions { seedTasks?: Task[]; } +/** Mounts the app with fixture tasks and waits for its initial route to load. */ export async function renderApp({ initialPath = '/tasks/new', seedTasks, diff --git a/frontend-pwa/tests/e2e/http-task-flow.spec.ts b/frontend-pwa/tests/e2e/http-task-flow.spec.ts new file mode 100644 index 00000000..f7485dd4 --- /dev/null +++ b/frontend-pwa/tests/e2e/http-task-flow.spec.ts @@ -0,0 +1,129 @@ +/** + * Exercise HTTP-backed task flows through the rendered application. + * + * Playwright intercepts the API boundary while the application uses its real + * HTTP gateway and renders the returned task or mapped error state. + */ +import { expect, test } from '@playwright/test'; + +const taskEnvelope = `{ + "data": { + "task": { + "id": "http-task-1", + "origin": { + "type": "issue", + "issue_ref": { + "provider": "github", + "repository": "acme/widgets", + "issue_number": 84 + }, + "metadata": { + "title": "HTTP-backed task", + "labels": [], + "assignees": [] + } + }, + "state": "draft", + "created_at": "2026-04-13T00:00:00.000Z", + "updated_at": "2026-04-13T00:00:00.000Z" + } + }, + "error": null, + "metadata": { + "request_id": "req-http-1", + "timestamp": "2026-04-13T00:00:00.000Z", + "version": "v1" + }, + "success": true +}`; + +test('creates a task over HTTP and loads its detail', async ({ page }) => { + await page.route('**/api/v1/tasks', async (route) => { + await route.fulfill({ + body: taskEnvelope, + contentType: 'application/json', + status: 201, + }); + }); + await page.route('**/api/v1/tasks/http-task-1', async (route) => { + await route.fulfill({ + body: taskEnvelope, + contentType: 'application/json', + }); + }); + await page.goto('/tasks/new'); + await page.getByLabel('Repository').fill('acme/widgets'); + await page.getByLabel('Issue number').fill('84'); + await page.getByLabel('Title').fill('HTTP-backed task'); + + const createRequest = page.waitForRequest( + (request) => + request.url().endsWith('/api/v1/tasks') && request.method() === 'POST', + ); + await page.getByRole('button', { name: 'Create task' }).click(); + const request = await createRequest; + + expect(request.postData()).toBe( + JSON.stringify({ + provider: 'github', + repository: 'acme/widgets', + issue_number: 84, + title: 'HTTP-backed task', + }), + ); + expect(request.headers()['idempotency-key']).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + await expect(page.getByText('Task detail')).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'HTTP-backed task' }), + ).toBeVisible(); + + // Reloading clears the create mutation's query cache and exercises GET. + const detailRequest = page.waitForRequest( + (candidate) => + candidate.url().endsWith('/api/v1/tasks/http-task-1') && + candidate.method() === 'GET', + ); + await page.goto('/tasks/http-task-1'); + await detailRequest; + await expect( + page.getByRole('heading', { name: 'HTTP-backed task' }), + ).toBeVisible(); +}); + +test('maps an HTTP not-found response to the task not-found view', async ({ + page, +}) => { + await page.route('**/api/v1/tasks/missing', async (route) => { + await route.fulfill({ + json: { code: 'not_found', message: 'Task not found' }, + status: 404, + }); + }); + + await page.goto('/tasks/missing'); + + await expect(page.getByText('Task not found')).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Return to task creation' }), + ).toBeVisible(); +}); + +test('shows the error state for a malformed successful HTTP response', async ({ + page, +}) => { + await page.route('**/api/v1/tasks/invalid', async (route) => { + await route.fulfill({ + body: '{', + contentType: 'application/json', + status: 200, + }); + }); + + await page.goto('/tasks/invalid'); + + await expect(page.getByRole('alert')).toHaveText( + 'Something went wrong loading the task.', + ); +}); diff --git a/frontend-pwa/tools/stylelint.config.cjs b/frontend-pwa/tools/stylelint.config.cjs index 1ac524d2..4f89a084 100644 --- a/frontend-pwa/tools/stylelint.config.cjs +++ b/frontend-pwa/tools/stylelint.config.cjs @@ -1,3 +1,6 @@ +/** + * Configures Stylelint checks for frontend CSS, used by `semantic:lint`. + */ module.exports = { plugins: ["stylelint-declaration-strict-value"], rules: { From 9c139102a5f6d71c2a6cf79f0187b1aafc9d826e Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 27 Oct 2026 17:59:50 +0100 Subject: [PATCH 3/6] Clarify lint examples and test cases Document the remaining tooling examples and consolidate repeated invalid task cases while keeping each boundary input explicit. --- docs/developers-guide.md | 5 ++ .../scripts/check-classlist-length.ts | 11 ++- .../scripts/find-near-duplicate-classes.ts | 26 ++++++- frontend-pwa/scripts/run-audit.mjs | 6 +- .../adapters/http/http-task-gateway.test.ts | 74 ++++++++++--------- 5 files changed, 79 insertions(+), 43 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 8f6c9860..f6892dd6 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -223,6 +223,11 @@ to that suite; production scripts do not depend on them. The audit command tests provide a temporary `bun` executable through the child process's path to exercise exit and signal handling without changing the parent environment. +The HTTP task gateway unit tests use a file-local envelope replacement helper +for cases that vary one JSON field. It creates a fresh response for each test, +while callers state their expected results. Keep it in that test module; other +adapter and command tests use their own fixtures. + ### TypeScript type checking for tests `frontend-pwa/tsconfig.test.json` extends the base `tsconfig.json` and widens diff --git a/frontend-pwa/scripts/check-classlist-length.ts b/frontend-pwa/scripts/check-classlist-length.ts index f0b112cd..4b283228 100644 --- a/frontend-pwa/scripts/check-classlist-length.ts +++ b/frontend-pwa/scripts/check-classlist-length.ts @@ -39,7 +39,10 @@ function loadThreshold(): number { return value === undefined ? 16 : Math.floor(value); } -/** Return paths for every TSX file under the frontend `src/` directory. */ +/** + * Return paths for every TSX file under the frontend `src/` directory. + * @example `getTsxFiles()` includes `src/App.tsx` when that file exists. + */ function getTsxFiles(): string[] { const glob = new Bun.Glob('src/**/*.tsx'); return [...glob.scanSync(PROJECT_ROOT)]; @@ -115,7 +118,11 @@ function analyseFile( visit(source); } -/** Scan frontend TSX files, report over-limit class lists, and set exit code 1. */ +/** + * Scan frontend TSX files, report over-limit class lists, and set exit code 1. + * @example `main()` reports an over-limit class list and sets + * `process.exitCode` to `1` when a violation is found. + */ function main(): void { const maxLength = loadThreshold(); const violations: Violation[] = []; diff --git a/frontend-pwa/scripts/find-near-duplicate-classes.ts b/frontend-pwa/scripts/find-near-duplicate-classes.ts index bc9e7da0..9037a3a5 100644 --- a/frontend-pwa/scripts/find-near-duplicate-classes.ts +++ b/frontend-pwa/scripts/find-near-duplicate-classes.ts @@ -102,6 +102,7 @@ const CONCEPT_HINTS: ConceptHint[] = [ /** * Merge configured options with defaults; config read, JSON, or schema errors * throw. Defaults require 4 tokens, distance at most 0.2, and 2 occurrences. + * @example `loadOptions().minTokenCount` is `6` when config sets it to `6`. */ function loadOptions(): NearDuplicateOptions { const raw = readFileSync(CONFIG_PATH, 'utf8'); @@ -109,7 +110,10 @@ function loadOptions(): NearDuplicateOptions { return { ...DEFAULT_OPTIONS, ...config.nearDuplicateClasses }; } -/** Return paths for every TSX file under the frontend `src/` directory. */ +/** + * Return paths for every TSX file under the frontend `src/` directory. + * @example `getTsxFiles()` includes `src/App.tsx` when that file exists. + */ function getTsxFiles(): string[] { const glob = new Bun.Glob('src/**/*.tsx'); return [...glob.scanSync(PROJECT_ROOT)]; @@ -126,7 +130,13 @@ function tokenMatches(pattern: string, token: string): boolean { return token === pattern; } -/** Return the first ordered concept hint whose required token patterns match. */ +/** + * Return the first ordered concept hint whose required token patterns match. + * @example + * const buttonTokens = ['inline-flex', 'items-center', 'px-3', 'py-2', + * 'rounded-md', 'bg-blue-600']; + * findConceptHint(buttonTokens)?.id; // 'button-like' + */ function findConceptHint(tokens: string[]): ConceptHint | undefined { for (const hint of CONCEPT_HINTS) { const matches = hint.requiredTokens.every((pattern) => @@ -271,7 +281,11 @@ function jaccardDistance( return union === 0 ? 1 : 1 - intersection / union; } -/** Emit locations and optional concept hints; return the exact-group count. */ +/** + * Emit locations and optional concept hints; return the exact-group count. + * @example `reportExactDuplicates(occurrences, options)` returns `1` and logs + * one group when two candidates share a signature and `minOccurrences` is `2`. + */ function reportExactDuplicates( occurrences: readonly Occurrence[], options: NearDuplicateOptions, @@ -304,7 +318,11 @@ function reportExactDuplicates( return findings; } -/** Emit pairs within the configured Jaccard distance; return the pair count. */ +/** + * Emit pairs within the configured Jaccard distance; return the pair count. + * @example `reportNearDuplicates(occurrences, options)` returns `1` and logs + * one pair when its distance is `0.1` and `maxJaccardDistance` is `0.2`. + */ function reportNearDuplicates( occurrences: readonly Occurrence[], options: NearDuplicateOptions, diff --git a/frontend-pwa/scripts/run-audit.mjs b/frontend-pwa/scripts/run-audit.mjs index 082a8ec6..5eb4a5ee 100644 --- a/frontend-pwa/scripts/run-audit.mjs +++ b/frontend-pwa/scripts/run-audit.mjs @@ -20,7 +20,11 @@ import { runAuditJson, } from '../security/audit-utils.js'; -/** Resolve symlinks and canonicalise a path; filesystem errors propagate. */ +/** + * Resolve symlinks and canonicalise a path; filesystem errors propagate. + * @example If `/repo/src/alias.ts` links to `main.ts`, normalising it returns + * the canonical path to `/repo/src/main.ts`. + */ const normalise = (filePath) => realpathSync.native(filePath); const workspacePackageName = packageJson.name; diff --git a/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts b/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts index 2274e90d..dac61bd8 100644 --- a/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts +++ b/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts @@ -49,6 +49,19 @@ function createGatewayWithResponse(response: Response) { return createHttpTaskGateway('/api/v1', createFetchForResponse(response)); } +/** + * Builds a gateway with one literal replacement in the task envelope. + * + * @example + * `createGatewayWithTaskField('"issue_number": 42', '"issue_number": 0')` + * serves an envelope containing issue number zero. + */ +function createGatewayWithTaskField(original: string, replacement: string) { + return createGatewayWithResponse( + new Response(taskEnvelope.replace(original, replacement)), + ); +} + describe('http task gateway', () => { it('parses successful task detail responses', async () => { const gateway = createGatewayWithResponse(new Response(taskEnvelope)); @@ -258,23 +271,6 @@ describe('http task gateway', () => { ); }); - it.each([ - 0, -1, -42, 1.5, 9_007_199_254_740_992, - ])('rejects invalid issue number %s', async (issueNumber) => { - const responseBody = taskEnvelope.replace( - '"issue_number": 42', - `"issue_number": ${issueNumber}`, - ); - const gateway = createGatewayWithResponse(new Response(responseBody)); - - await expect(gateway.getTask('task-1')).rejects.toEqual( - new TaskGatewayError( - 'unavailable', - 'The task API returned an invalid task shape.', - ), - ); - }); - it('normalizes null optional references to absence', async () => { const responseBody = taskEnvelope.replace( '"id": "task-1"', @@ -315,11 +311,10 @@ describe('http task gateway', () => { (_, index) => `2026-${String(index + 1).padStart(2, '0')}-01T12:30:45Z`, ), ])('accepts valid calendar timestamp %s', async (timestamp) => { - const responseBody = taskEnvelope.replace( + const gateway = createGatewayWithTaskField( '"created_at": "2026-04-13T00:00:00.000Z"', `"created_at": "${timestamp}"`, ); - const gateway = createGatewayWithResponse(new Response(responseBody)); await expect(gateway.getTask('task-1')).resolves.toMatchObject({ created_at: timestamp, @@ -327,23 +322,30 @@ describe('http task gateway', () => { }); it.each([ - '2023-02-29T00:00:00Z', - '2026-04-31T00:00:00Z', - '2026-01-01T24:00:00Z', - '2026-01-01T23:60:00Z', - '2026-01-01T23:59:60Z', - '2026-01-01T00:00:00+24:00', - '2026-01-01T00:00:00-01:60', - ...Array.from( - { length: 12 }, - (_, index) => `2026-${String(index + 1).padStart(2, '0')}-32T12:30:45Z`, - ), - ])('rejects invalid calendar timestamp %s', async (timestamp) => { - const responseBody = taskEnvelope.replace( - '"created_at": "2026-04-13T00:00:00.000Z"', - `"created_at": "${timestamp}"`, - ); - const gateway = createGatewayWithResponse(new Response(responseBody)); + ...[0, -1, -42, 1.5, 9_007_199_254_740_992].map((issueNumber) => ({ + name: `issue number ${issueNumber}`, + original: '"issue_number": 42', + replacement: `"issue_number": ${issueNumber}`, + })), + ...[ + '2023-02-29T00:00:00Z', + '2026-04-31T00:00:00Z', + '2026-01-01T24:00:00Z', + '2026-01-01T23:60:00Z', + '2026-01-01T23:59:60Z', + '2026-01-01T00:00:00+24:00', + '2026-01-01T00:00:00-01:60', + ...Array.from( + { length: 12 }, + (_, index) => `2026-${String(index + 1).padStart(2, '0')}-32T12:30:45Z`, + ), + ].map((timestamp) => ({ + name: `calendar timestamp ${timestamp}`, + original: '"created_at": "2026-04-13T00:00:00.000Z"', + replacement: `"created_at": "${timestamp}"`, + })), + ])('rejects invalid $name', async ({ original, replacement }) => { + const gateway = createGatewayWithTaskField(original, replacement); await expect(gateway.getTask('task-1')).rejects.toEqual( new TaskGatewayError( From 73af0b9a4c9953dcd632f569f0c9d6b776e32891 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 27 Oct 2026 18:13:20 +0100 Subject: [PATCH 4/6] Complete gateway request assertions Check the transition body and headers alongside the create request, and identify the audit test module in its documentation header. --- .../adapters/http/http-task-gateway.test.ts | 16 ++++++++++++---- frontend-pwa/src/test/audit-utils.test.mjs | 1 + 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts b/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts index dac61bd8..4a9b05ab 100644 --- a/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts +++ b/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts @@ -39,6 +39,9 @@ const taskEnvelope = `{ "success": true }`; +const idempotencyKeyPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + /** Returns a fetch mock that resolves with the supplied response. */ function createFetchForResponse(response: Response): typeof fetch { return vi.fn().mockResolvedValue(response); @@ -101,7 +104,14 @@ describe('http task gateway', () => { expect(fetchFn).toHaveBeenNthCalledWith( 2, '/api/v1/tasks/task%2Fwith%20reserved%3Fcharacters/state', - expect.objectContaining({ method: 'PUT' }), + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ state: 'done' }), + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': expect.stringMatching(idempotencyKeyPattern), + }, + }), ); }); @@ -210,9 +220,7 @@ describe('http task gateway', () => { body: JSON.stringify(request), headers: { 'Content-Type': 'application/json', - 'Idempotency-Key': expect.stringMatching( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, - ), + 'Idempotency-Key': expect.stringMatching(idempotencyKeyPattern), }, }), ); diff --git a/frontend-pwa/src/test/audit-utils.test.mjs b/frontend-pwa/src/test/audit-utils.test.mjs index da3431b9..2b4607a6 100644 --- a/frontend-pwa/src/test/audit-utils.test.mjs +++ b/frontend-pwa/src/test/audit-utils.test.mjs @@ -1,4 +1,5 @@ /** + * @file * Unit tests for parsing Bun audit output at the command boundary. */ import { describe, expect, it } from 'vitest'; From cfaa8d8f8ceb2f34188dce1df4a468e5e2a0f34b Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 27 Oct 2026 20:59:34 +0100 Subject: [PATCH 5/6] Document frontend browser validation Describe the HTTP and fixture browser test modes and correct the existing gateway guidance. Align audit module comments with the repository's documentation conventions. --- docs/developers-guide.md | 14 ++++++++++++-- frontend-pwa/scripts/run-audit.mjs | 4 ++-- frontend-pwa/src/test/audit-command.test.mjs | 4 +++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index f6892dd6..2fd8432f 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -194,8 +194,16 @@ transport code directly. gateway error mapping. - `createFixtureTaskGateway` is the default local and test adapter. It keeps task state in memory, serializes queued operations over that shared state, - and preserves fixture-first development until the live HTTP gateway is - selected in later roadmap work. + and supports fixture-first development. The HTTP gateway is also available as + an opt-in mode for the browser flow described below. + +Run `make frontend-e2e` to exercise the browser paths. Playwright starts the +fixture app on port 4173 for the `chromium` project and an HTTP-mode app on +port 4174 for `http-chromium`, with `VITE_TASK_GATEWAY_MODE=http` and +`VITE_CORBUSIER_API_BASE_URL=/api/v1`. The HTTP flow intercepts API requests +with Playwright, so it needs no live backend. The fixture remains the default +gateway mode. Roadmap item 4.4.2 is complete; live task UI work remains in +pending item 4.4.3. The shipped `4.4.1` slice boundary is recorded in [`corbusier-design.md`](corbusier-design.md#713-repository-owned-frontend-workspace-boundary). @@ -241,6 +249,8 @@ The separate test pass is required because tests and test configuration use different inclusion patterns and ambient types from the production application sources, while still depending on the application `src/` tree. +Vitest includes `.test.mjs` files under `src/` alongside TypeScript tests. + `make frontend-docs-check` runs `bun run docs:check` inside `frontend-pwa`: TypeDoc's `notDocumented` validation over `frontend-pwa/src` (`frontend-pwa/typedoc.json`), requiring a JSDoc block on every exported diff --git a/frontend-pwa/scripts/run-audit.mjs b/frontend-pwa/scripts/run-audit.mjs index 5eb4a5ee..b59038c4 100644 --- a/frontend-pwa/scripts/run-audit.mjs +++ b/frontend-pwa/scripts/run-audit.mjs @@ -21,8 +21,8 @@ import { } from '../security/audit-utils.js'; /** - * Resolve symlinks and canonicalise a path; filesystem errors propagate. - * @example If `/repo/src/alias.ts` links to `main.ts`, normalising it returns + * Resolve symlinks and canonicalize a path; filesystem errors propagate. + * @example If `/repo/src/alias.ts` links to `main.ts`, normalizing it returns * the canonical path to `/repo/src/main.ts`. */ const normalise = (filePath) => realpathSync.native(filePath); diff --git a/frontend-pwa/src/test/audit-command.test.mjs b/frontend-pwa/src/test/audit-command.test.mjs index 55982030..ebb8758c 100644 --- a/frontend-pwa/src/test/audit-command.test.mjs +++ b/frontend-pwa/src/test/audit-command.test.mjs @@ -1,4 +1,6 @@ -/** End-to-end checks for the Bun audit wrapper and its exit status. */ +/** + * @file End-to-end checks for the Bun audit wrapper and its exit status. + */ import { spawnSync } from 'node:child_process'; import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; From f4e1678d3e593c5d6692722fd18747d6b20f4a97 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 28 Oct 2026 14:28:58 +0100 Subject: [PATCH 6/6] Reject sub-unit class-list limits Reject thresholds that floor to zero and cover the boundary through the semantic lint command. Identify the affected test and config modules with their required file headers. --- frontend-pwa/scripts/check-classlist-length.ts | 2 +- frontend-pwa/src/test/semantic-cli.test.mjs | 17 ++++++++++++++++- frontend-pwa/tests/e2e/http-task-flow.spec.ts | 2 +- frontend-pwa/tools/stylelint.config.cjs | 2 +- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/frontend-pwa/scripts/check-classlist-length.ts b/frontend-pwa/scripts/check-classlist-length.ts index 4b283228..72af0e76 100644 --- a/frontend-pwa/scripts/check-classlist-length.ts +++ b/frontend-pwa/scripts/check-classlist-length.ts @@ -14,7 +14,7 @@ import ts from 'typescript'; import { z } from 'zod'; const SemanticConfigSchema = z.object({ - maxClasslistLength: z.number().positive().optional(), + maxClasslistLength: z.number().min(1).optional(), }); interface Violation { diff --git a/frontend-pwa/src/test/semantic-cli.test.mjs b/frontend-pwa/src/test/semantic-cli.test.mjs index 0dfc68cf..7d470576 100644 --- a/frontend-pwa/src/test/semantic-cli.test.mjs +++ b/frontend-pwa/src/test/semantic-cli.test.mjs @@ -1,7 +1,9 @@ /** - * Exercise semantic lint scripts as commands against isolated project fixtures. + * @file Exercise semantic lint scripts as commands against isolated project + * fixtures. * The fixture copies only the script under test and shares installed packages; * it never changes the real frontend configuration or process environment. + * Run through the frontend Vitest suite to check observable command outcomes. */ import { spawnSync } from 'node:child_process'; import { @@ -82,6 +84,7 @@ describe('semantic lint commands', () => { it.each([ 0, + 0.5, -1, '16', ])('rejects an invalid class limit %s', (maxClasslistLength) => { @@ -91,6 +94,18 @@ describe('semantic lint commands', () => { expect(result.stderr).toContain('maxClasslistLength'); }); + it('floors a fractional class limit while preserving a minimum of one', () => { + writeConfig({ maxClasslistLength: 1.9 }); + writeComponent('
'); + const withinLimit = runScript('check-classlist-length.ts'); + expect(withinLimit.status).toBe(0); + + writeComponent('
'); + const overLimit = runScript('check-classlist-length.ts'); + expect(overLimit.status).toBe(1); + expect(overLimit.stderr).toContain('2 utilities (max 1)'); + }); + it('reports repeated stacks without failing by default and can fail explicitly', () => { writeComponent( '<>
', diff --git a/frontend-pwa/tests/e2e/http-task-flow.spec.ts b/frontend-pwa/tests/e2e/http-task-flow.spec.ts index f7485dd4..9a6d3a26 100644 --- a/frontend-pwa/tests/e2e/http-task-flow.spec.ts +++ b/frontend-pwa/tests/e2e/http-task-flow.spec.ts @@ -1,5 +1,5 @@ /** - * Exercise HTTP-backed task flows through the rendered application. + * @file Exercise HTTP-backed task flows through the rendered application. * * Playwright intercepts the API boundary while the application uses its real * HTTP gateway and renders the returned task or mapped error state. diff --git a/frontend-pwa/tools/stylelint.config.cjs b/frontend-pwa/tools/stylelint.config.cjs index 4f89a084..1413bfe7 100644 --- a/frontend-pwa/tools/stylelint.config.cjs +++ b/frontend-pwa/tools/stylelint.config.cjs @@ -1,5 +1,5 @@ /** - * Configures Stylelint checks for frontend CSS, used by `semantic:lint`. + * @file Configures Stylelint checks for frontend CSS, used by `semantic:lint`. */ module.exports = { plugins: ["stylelint-declaration-strict-value"],