diff --git a/MIGRATION_BASELINE.md b/MIGRATION_BASELINE.md new file mode 100644 index 0000000..79bbb1e --- /dev/null +++ b/MIGRATION_BASELINE.md @@ -0,0 +1,141 @@ +# TypeScript migration — baseline + +The state of this repository **immediately before** the first source file was +renamed, measured rather than assumed. Everything the migration does is checked +against these numbers; a figure that moves without a phase claiming it is a +regression. + +Measured on 2026-09-11, on `main` with a clean tree, before any change in +Milestone 1. + +## The numbers + +| Check | Command | Baseline | +|---|---|---| +| Types | `npm run typecheck` | **FAILS — 71 errors across 27 files** | +| Lint | `npm run lint` | **PASSES**, exit 0 | +| Lint (with warnings) | `npx eslint .` | 289 problems — 0 errors, 289 warnings | +| Files linted | `npx eslint . --format json` | 302 | +| Behaviour | `npm test` | **1641 / 1642 checks passed** | +| Build | `npm run build` | succeeds | +| Owliver, via the suite | `npm test` section 21 | **all green** — schema, skills, routes, and per-context skills, suggestions, prompts and intent routing | +| Owliver, standalone | `node scripts/owliver-baseline.mjs` | **FAILS — pre-existing, see below** | + +### The 71 type errors, by code + +| Code | Count | What it is | +|---|---|---| +| TS2339 | 38 | Property does not exist on an inferred type | +| TS2741 | 9 | Missing required prop in JSX | +| TS2353 | 9 | Unknown property in an object literal | +| TS2345 | 6 | Argument type mismatch | +| TS2554 | 2 | Wrong argument count | +| TS2362 / TS2363 | 4 | Arithmetic on a non-number | +| TS2739 / TS2559 / TS2349 | 3 | Missing props / no common props / not callable | + +These are **pre-existing**. They were not introduced by the migration and the +migration is not obliged to fix them; they are the backlog it has to avoid +adding to. Six of them are `import.meta` errors that Phase 1 resolves as a side +effect of configuring `vite/client`. + +Note that `npm run typecheck` **is not run by CI** (`.github/workflows/ci.yml` +runs lint, test, build and the seed check). That is how 71 errors accumulated +without anyone seeing them. Wiring it in is the last phase of the migration, not +the first — it cannot be a gate until it is green. + +## The standalone Owliver script is already broken + +`node scripts/owliver-baseline.mjs` fails before any of this began — verified by +stashing every change and running it on a clean `main`, where it fails +identically. It is **not** caused by the migration. + +``` +Failed to scan for dependencies from entries: + index.html + scripts/__baseline__/activity-page.pre-migration.html + … the other seven snapshots … + ✘ [ERROR] The server is being restarted or closed. Request is outdated [plugin vite:dep-scan] +Owliver behaviour has DRIFTED from the baseline. +``` + +Two things are going on, and neither is a real drift. Vite's dependency scanner +treats every `*.html` under the project root as an entry point, so the eight +captured SSR snapshots in `scripts/__baseline__/` are scanned as if they were +pages of the app. The script then calls `server.close()` as soon as +`captureBaseline` returns, while that scan is still running, and the scan +reports that it was cancelled. The capture comes back incomplete and the +comparison calls it drift. + +**The coverage itself is not lost.** `skill-check.mjs` imports the same +`captureBaseline` and the same `BASELINE_PATH` and asserts against them in +section 21, and it does enough other work afterwards that the scan finishes. Run +`npm test` and the whole section is green: + +``` +── Owliver behaviour baseline ── +[ ok ] baseline schema matches — expected 1, got 1 +[ ok ] every skill that existed before the agent layer still registers — 23 kept, 1 added since +[ ok ] every route that resolved before resolves the same way — 18 unchanged +[ ok ] controlCenter: keeps every skill it had — 7 skill(s), unchanged +…per context: page key, skills, suggestions, prompts, intent routing +``` + +So **`npm test` is the Owliver drift check for the duration of the migration**, +and the standalone script is a convenience wrapper that needs repairing on its +own terms — excluding `scripts/__baseline__` from the scan, or awaiting the +optimizer before closing. That repair is not part of the migration: it changes +a script's behaviour, and this milestone's whole claim is that it changed none. + +## The one failing check + +``` +1641/1642 checks passed + +Failed: + - the backend fixture is in step with this seed (stale — run `npm run seed:fixture`) +``` + +`scripts/__baseline__` is intact and `owliver-baseline.mjs` matches; this is not +an Owliver failure. `seed/fixtures/seed.json` in the sibling `krow-backend` +checkout has drifted from `src/api/seed.js`. + +**This failure predates the migration and is unrelated to it. It is recorded +here so that it is never mistaken for something the migration caused, and it is +deliberately left alone** — regenerating the fixture would write into another +repository and change what the backend seeds, which is a product change wearing +a migration's clothes. Fix it separately, on its own terms. + +## What "unchanged" means for the Owliver / agent flow + +The agent flow is ~112 files and 38% of the codebase, and it is converted last, +by rename and type annotation only. The evidence that it still behaves +identically is already in the repository and needs nothing new: + +- `npm test` — 1642 checks covering skill discovery, routing, context + resolution and SSR-rendered markup. +- `scripts/__baseline__/owliver-baseline.json` — contexts, skill ids and routes. +- `scripts/__baseline__/*.pre-migration.html` — eight pages of byte-level SSR + output. +- `node scripts/owliver-baseline.mjs` — the drift check. +- CI's `FLOOR = 900` — fails if the suite silently shrinks. + +**These artifacts are read-only for the duration of the migration.** If the +baseline drifts, revert the change. Regenerating it to clear a red check records +the regression instead of catching it, which is worse than having no baseline — +`owliver-baseline.mjs` says as much in its own header. + +`src/agents/**/*.md` and `src/skills/**/*.md` are likewise untouched throughout: +they are the definitions the flow is built from, not code being migrated. + +## How to re-measure + +```bash +npm run typecheck; echo "exit=$?" +npm run typecheck 2>&1 | grep -c "error TS" +npm run lint; echo "exit=$?" +npx eslint . --format json | python3 -c "import json,sys;print(len(json.load(sys.stdin)))" +npm test +node scripts/owliver-baseline.mjs +npm run build +git diff --stat -M +``` diff --git a/eslint.config.js b/eslint.config.js index 4daa2f5..5cd9415 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,16 +3,68 @@ import pluginJs from "@eslint/js"; import pluginReact from "eslint-plugin-react"; import pluginReactHooks from "eslint-plugin-react-hooks"; import pluginUnusedImports from "eslint-plugin-unused-imports"; +import tseslint from "typescript-eslint"; + +/** + * The rule set, written once and applied to JavaScript and TypeScript alike. + * + * Shared rather than duplicated because the two blocks below differ in exactly + * one thing — which parser reads the file — and a rule that applied to `.jsx` + * but not to its `.tsx` successor would make the TypeScript migration look like + * it was tidying the code up. It is not; it renames files and adds types. + */ +const rules = { + "no-unused-vars": "off", + "react/jsx-uses-vars": "error", + "react/jsx-uses-react": "error", + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "warn", + { + vars: "all", + varsIgnorePattern: "^_", + args: "after-used", + argsIgnorePattern: "^_", + }, + ], + "react/prop-types": "off", + "react/react-in-jsx-scope": "off", + "react/no-unknown-property": [ + "error", + { ignore: ["cmdk-input-wrapper", "toast-close"] }, + ], + "react-hooks/rules-of-hooks": "error", +}; + +const plugins = { + react: pluginReact, + "react-hooks": pluginReactHooks, + "unused-imports": pluginUnusedImports, +}; + +const settings = { react: { version: "detect" } }; + +/** + * Which files are linted. + * + * Unchanged from what this config has always covered — `src/lib` and + * `src/components/ui` stay out of it — with `ts` and `tsx` added to every + * pattern. That addition is the point: ESLint matches on extension, so the + * moment a `.jsx` file became `.tsx` it would have dropped out of the run + * silently, and `eslint .` would have gone on exiting 0 while linting less and + * less of the codebase. A lint that passes because it checked nothing is worse + * than one that fails. + */ +const directories = ["src/components", "src/pages", "src/layouts", "src/hooks"]; +const ignores = ["src/lib/**/*", "src/components/ui/**/*"]; + +const filesWith = (extensions) => + directories.map((directory) => `${directory}/**/*.{${extensions}}`); export default [ { - files: [ - "src/components/**/*.{js,mjs,cjs,jsx}", - "src/pages/**/*.{js,mjs,cjs,jsx}", - "src/layouts/**/*.{js,mjs,cjs,jsx}", - "src/hooks/**/*.{js,mjs,cjs,jsx}", - ], - ignores: ["src/lib/**/*", "src/components/ui/**/*"], + files: filesWith("js,mjs,cjs,jsx"), + ignores, ...pluginJs.configs.recommended, ...pluginReact.configs.flat.recommended, languageOptions: { @@ -20,42 +72,50 @@ export default [ parserOptions: { ecmaVersion: 2022, sourceType: "module", - ecmaFeatures: { - jsx: true, - }, + ecmaFeatures: { jsx: true }, }, }, - settings: { - react: { - version: "detect", + settings, + plugins, + rules, + }, + + /** + * The same block for TypeScript, with the TypeScript parser. + * + * A separate block rather than one widened glob, so that nothing about how + * the existing JavaScript is parsed or reported changes on the day this + * lands. `typescript-eslint`'s parser accepts plain JavaScript too, and + * merging the two would have been shorter — but it would also have quietly + * re-parsed 200-odd existing files, and this phase is meant to be provably + * inert. + * + * Deliberately NOT type-aware (no `projectService`): type errors are + * `npm run typecheck`'s job, and asking ESLint to build a program as well + * would make every lint run pay for it twice. + */ + { + files: filesWith("ts,tsx,mts,cts"), + ignores, + ...pluginReact.configs.flat.recommended, + languageOptions: { + globals: globals.browser, + parser: tseslint.parser, + parserOptions: { + ecmaVersion: 2022, + sourceType: "module", + ecmaFeatures: { jsx: true }, }, }, - plugins: { - react: pluginReact, - "react-hooks": pluginReactHooks, - "unused-imports": pluginUnusedImports, - }, + settings, + plugins: { ...plugins, "@typescript-eslint": tseslint.plugin }, rules: { - "no-unused-vars": "off", - "react/jsx-uses-vars": "error", - "react/jsx-uses-react": "error", - "unused-imports/no-unused-imports": "error", - "unused-imports/no-unused-vars": [ - "warn", - { - vars: "all", - varsIgnorePattern: "^_", - args: "after-used", - argsIgnorePattern: "^_", - }, - ], - "react/prop-types": "off", - "react/react-in-jsx-scope": "off", - "react/no-unknown-property": [ - "error", - { ignore: ["cmdk-input-wrapper", "toast-close"] }, - ], - "react-hooks/rules-of-hooks": "error", + ...rules, + /* TypeScript resolves identifiers itself and reports the ones it cannot, + with better messages and without ESLint's browser/node globals list + needing to be right. Leaving the core rule on would report every `type` + and `interface` name as undefined. */ + "no-undef": "off", }, }, ]; diff --git a/package-lock.json b/package-lock.json index fb6ded6..55277bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "puppeteer-core": "^23.11.1", "tailwindcss": "^3.4.17", "typescript": "^5.8.2", + "typescript-eslint": "^8.70.0", "vite": "^6.1.0" } }, @@ -3378,6 +3379,301 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", @@ -9650,6 +9946,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -9774,6 +10083,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index 790de62..f33a70d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "test": "node scripts/skill-check.mjs", "seed:fixture": "node scripts/seed-fixture.mjs --write", "seed:check": "node scripts/seed-fixture.mjs", - "typecheck": "tsc -p ./jsconfig.json", + "typecheck": "tsc -p ./tsconfig.json", "preview": "vite preview", "test:browser": "node scripts/browser-check.mjs" }, @@ -65,6 +65,7 @@ "puppeteer-core": "^23.11.1", "tailwindcss": "^3.4.17", "typescript": "^5.8.2", + "typescript-eslint": "^8.70.0", "vite": "^6.1.0" } } diff --git a/scripts/owliver-baseline.mjs b/scripts/owliver-baseline.mjs index f3ccc22..0598ed6 100644 --- a/scripts/owliver-baseline.mjs +++ b/scripts/owliver-baseline.mjs @@ -16,6 +16,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; import { createServer } from 'vite'; import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs'; +import { withSourceResolution } from './ssr-resolve.mjs'; const ROOT = process.cwd(); @@ -26,6 +27,11 @@ const server = await createServer({ logLevel: 'error', }); +/* Resolves the capture's module paths whatever extension they carry during the + TypeScript migration. The comparison this script performs is unaffected: it + loads the same modules and serialises the same values. */ +withSourceResolution(server, ROOT); + const captured = await captureBaseline(server); await server.close(); diff --git a/scripts/render-page.mjs b/scripts/render-page.mjs index cc2402d..d2d93e0 100644 --- a/scripts/render-page.mjs +++ b/scripts/render-page.mjs @@ -18,6 +18,7 @@ import { join } from 'node:path'; import { writeFileSync } from 'node:fs'; import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; +import { withSourceResolution } from './ssr-resolve.mjs'; /** * The design system reads `window` when its modules evaluate, which is a @@ -97,6 +98,9 @@ if (process.argv[1] && process.argv[1].endsWith('render-page.mjs')) { logLevel: 'error', resolve: { alias: { 'react-hot-toast': join(process.cwd(), 'scripts/stubs/react-hot-toast.js') } }, }); + /* The page path arrives on the command line with whatever extension it has + today; during the TypeScript migration that may be .jsx or .tsx. */ + withSourceResolution(server, process.cwd()); try { const html = await renderPage(server, `/${modulePath.replace(/^\//, '')}`, { route: route || '/' }); writeFileSync(out, html); diff --git a/scripts/seed-fixture.mjs b/scripts/seed-fixture.mjs index 141278d..056f12e 100644 --- a/scripts/seed-fixture.mjs +++ b/scripts/seed-fixture.mjs @@ -20,6 +20,7 @@ import { createServer } from 'vite'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { withSourceResolution } from './ssr-resolve.mjs'; export const FIXTURE_PATH = join(process.cwd(), '..', 'krow-backend', 'seed', 'fixtures', 'seed.json'); @@ -51,6 +52,9 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) { const server = await createServer({ root: process.cwd(), server: { middlewareMode: true }, appType: 'custom', logLevel: 'error', }); + /* `buildFixture` loads `/src/api/seed.js`, which the TypeScript migration + will rename. The fixture it generates is unchanged either way. */ + withSourceResolution(server, process.cwd()); const built = await buildFixture(server); await server.close(); diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs index b22c70e..53eef2a 100644 --- a/scripts/skill-check.mjs +++ b/scripts/skill-check.mjs @@ -19,6 +19,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs'; import { buildFixture, FIXTURE_PATH } from './seed-fixture.mjs'; +import { withSourceResolution } from './ssr-resolve.mjs'; const ROOT = process.cwd(); const results = []; @@ -41,6 +42,12 @@ const server = await createServer({ resolve: { alias: { 'react-hot-toast': join(ROOT, 'scripts/stubs/react-hot-toast.js') } }, }); +/* Every `ssrLoadModule` path below names a file by its current extension. While + the TypeScript migration is under way that extension changes underneath them, + so the loader resolves .js/.jsx/.ts/.tsx rather than each call site having to + know which one a module is on today. The path as written is tried first. */ +withSourceResolution(server, ROOT); + const reg = await server.ssrLoadModule('/src/lib/skills/registry.js'); const placement = await server.ssrLoadModule('/src/components/ai-assistant/placement.js'); diff --git a/scripts/ssr-resolve.mjs b/scripts/ssr-resolve.mjs new file mode 100644 index 0000000..a065373 --- /dev/null +++ b/scripts/ssr-resolve.mjs @@ -0,0 +1,80 @@ +/** + * Extension-agnostic SSR module loading, for the duration of the TypeScript + * migration. + * + * Every script in here addresses modules by literal path — `ssrLoadModule( + * '/src/lib/skills/registry.js')` — about a hundred and seventy times across + * `skill-check.mjs`, `owliver-capture.mjs`, `render-page.mjs` and + * `seed-fixture.mjs`. That is fine while every source file is JavaScript and + * fatal the moment one is not: renaming `registry.js` to `registry.ts` turns + * the check suite's very first load into a failure, and the suite is the only + * evidence the Owliver flow still behaves the way it did. + * + * Rewriting all those call sites would be a large, noisy, error-prone diff + * against the file that guards the migration — exactly the wrong thing to + * disturb. So the loader is wrapped once instead and the call sites keep the + * paths they already have, which stay readable as the names of real files. + * + * Resolution is by existence on disk, not by catching a failed load. A load + * that fails for a real reason — a syntax error, a bad import inside the module + * — must surface as itself; retrying under another extension would bury it + * behind a second, more confusing error about a file that was never there. + * + * The path as written is always tried first, so while a module is still + * JavaScript this changes nothing at all. + * + * This file is temporary. When `src` holds no `.js` or `.jsx` any more, the + * call sites can be renamed in one pass and this wrapper deleted. + */ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * The candidate paths for one module specifier, in the order they are tried. + * + * Only `.js` and `.jsx` are rewritten. Anything else — a bare specifier, a + * `.mjs` file, something under `/node_modules` — is returned untouched, because + * nothing in this migration renames it. + * + * `.js` is allowed to become `.tsx` as well as `.ts`. Not because any `.js` + * file here contains JSX today — none of the 93 does, checked with a parser + * rather than a guess — but because one may be renamed that way: the only + * `.js` under `src/pages` is `admin/positions/nodes.js`, sitting among seven + * sibling `nodes.jsx` files, and whoever converts that directory will + * reasonably want all eight to end in `.tsx`. The extra candidate costs one + * `existsSync` that answers no. + */ +export function candidatesFor(path) { + const specifier = String(path); + if (!/\.jsx?$/.test(specifier)) return [specifier]; + const stem = specifier.replace(/\.jsx?$/, ''); + return [...new Set([specifier, `${stem}.ts`, `${stem}.tsx`])]; +} + +/** + * Wraps `server.ssrLoadModule` so it finds a module whichever of the four + * extensions it currently carries. + * + * Mutates and returns the server, so it reads as one line after `createServer` + * and every later call — including the dynamically-built paths, which is why + * this is done here rather than at the call sites — goes through it. + * + * `root` is where the leading-slash paths are rooted; it defaults to the + * process's working directory, which is what every caller here uses. + */ +export function withSourceResolution(server, root = process.cwd()) { + const load = server.ssrLoadModule.bind(server); + + server.ssrLoadModule = (path, options) => { + for (const candidate of candidatesFor(path)) { + if (existsSync(join(root, candidate.replace(/^\//, '')))) { + return load(candidate, options); + } + } + /* Nothing on disk under any extension. Load the path as written so the + error names what the caller actually asked for. */ + return load(path, options); + }; + + return server; +} diff --git a/src/assets/brand.js b/src/assets/brand.ts similarity index 100% rename from src/assets/brand.js rename to src/assets/brand.ts diff --git a/src/lib/employeeRoleModel.js b/src/lib/employeeRoleModel.ts similarity index 82% rename from src/lib/employeeRoleModel.js rename to src/lib/employeeRoleModel.ts index f125e17..4cffc05 100644 --- a/src/lib/employeeRoleModel.js +++ b/src/lib/employeeRoleModel.ts @@ -8,6 +8,18 @@ * worker WANTS rather than what a posting OFFERS. */ +/** + * `draft` is a EmployeeRole record, typed `any` deliberately. + * + * The shared types in `src/types/` describe the transport and the signed-in + * user; they do not describe entity records yet. That shape is derivable — the + * backend generates a column registry from `information_schema` — but it + * belongs with the phase that converts the transport layer. A partial interface + * written here would be a guess that every later reader treats as settled. + * `any` states what is actually known today and is one annotation to replace. + */ +type EmployeeRoleDraft = any; + /** English levels, in the order the schema declares them. */ export const EMPLOYEE_ROLE_STATUSES = ['seeking', 'placed', 'inactive']; @@ -35,7 +47,7 @@ export const defaultEmployeeRole = () => ({ * A maximum of zero means "no ceiling stated" rather than "free", which is why * it is not rendered as a range ending at nothing. */ -export function desiredPayLabel(record = {}) { +export function desiredPayLabel(record: EmployeeRoleDraft = {}): string | null { const min = Number(record.desired_pay_min) || 0; const max = Number(record.desired_pay_max) || 0; if (!min && !max) return null; @@ -56,7 +68,7 @@ export function desiredPayLabel(record = {}) { * defaults one, or falls back to another record's; an absent email reaches the * server absent, and the server refuses it. */ -export function toNewWorkerWithRolePayload(draft = {}) { +export function toNewWorkerWithRolePayload(draft: EmployeeRoleDraft = {}) { const role = toEmployeeRolePayload(draft); /* The identity fields belong to the worker. The server copies them onto the role from the row it just created, so sending them twice would let the two @@ -82,7 +94,7 @@ export function toNewWorkerWithRolePayload(draft = {}) { * deliberately: both are the server's, derived from the session, and a value * sent for either is dropped before the insert. */ -export function toEmployeeRolePayload(draft = {}) { +export function toEmployeeRolePayload(draft: EmployeeRoleDraft = {}) { const record = { ...defaultEmployeeRole(), ...draft }; return { ...record, diff --git a/src/lib/krowScore.js b/src/lib/krowScore.ts similarity index 71% rename from src/lib/krowScore.js rename to src/lib/krowScore.ts index b2da1c7..9c9320d 100644 --- a/src/lib/krowScore.js +++ b/src/lib/krowScore.ts @@ -1,10 +1,47 @@ +/** + * The records this engine reads — WorkerProfile, JobPosting, Course and + * LearningPath — are typed `any` deliberately. + * + * `src/types/` describes the transport and the signed-in user; it does not + * describe entity records yet. Those shapes are derivable from the backend's + * generated column registry, but they belong with the phase that converts the + * transport layer. Written here they would be a guess, and this is the file + * where a wrong guess is most expensive: these weights decide what a worker's + * score is and which jobs they are shown. + * + * `computeProfileCompletion` additionally indexes the profile by a field name + * held in a list (`profile[f]`), which no partial interface would permit + * without either widening it back to `any` or rewriting the loop — and + * rewriting it would be a behaviour change in a scoring path. + */ +type ProfileRecord = any; +type JobPostingRecord = any; +type CourseRecord = any; +type LearningPathRecord = any; + +/** The score, its headline inputs, and the per-dimension breakdown a reader sees. */ +export interface KrowScoreResult { + krow_score: number; + reliability: number; + breakdown: { + /** `null`, not 0, where there is no record — an absent dimension renders "—". */ + attendance: number | null; + performance: number; + education: number; + clientReviews: number; + supervisorReviews: number; + growth: number; + experience: number; + }; +} + // Engine 6 — KROW Score Engine (formula-based, auto-recalculated) // Reliability = Attendance×.30 + Performance×.25 + Education×.10 + ClientReviews×.15 + SupervisorReviews×.10 + Growth×.05 + Experience×.05 // KROW Score = Reliability×.40 + AI Interview×.25 + Education×.15 + Growth×.10 + Experience×.10 -const clamp = (n) => Math.max(0, Math.min(100, n)); +const clamp = (n: number) => Math.max(0, Math.min(100, n)); -export function computeKrowScore(profile) { +export function computeKrowScore(profile: ProfileRecord): KrowScoreResult { /** * Attendance defaults to 100 for *display*: an unrated worker shows a full bar * rather than an accusatory 0. That default must not become a scoring input. @@ -64,7 +101,7 @@ export function computeKrowScore(profile) { const CORE_FIELDS = ['full_name', 'email', 'phone', 'address', 'current_position', 'desired_position', 'career_goals', 'transportation', 'resume_url']; -export function computeProfileCompletion(profile) { +export function computeProfileCompletion(profile: ProfileRecord): number { let filled = 0; for (const f of CORE_FIELDS) if (profile[f]) filled++; if (profile.languages?.length) filled++; @@ -83,7 +120,7 @@ export function computeProfileCompletion(profile) { } /** Returns only the recomputed fields — merge into an update patch */ -export function recalcProfilePatch(profile) { +export function recalcProfilePatch(profile: ProfileRecord) { const { krow_score, reliability, breakdown } = computeKrowScore(profile); return { krow_score, @@ -95,7 +132,7 @@ export function recalcProfilePatch(profile) { // Engine 11 (light) — recommendation helpers -export function recommendNextCourse(profile, learningPaths = [], courses = []) { +export function recommendNextCourse(profile: ProfileRecord, learningPaths: LearningPathRecord[] = [], courses: CourseRecord[] = []) { const p = profile || {}; const completedIds = new Set((p.completed_courses || []).map(c => c.course_id)); const desired = (p.desired_position || '').toLowerCase(); @@ -116,14 +153,17 @@ export function recommendNextCourse(profile, learningPaths = [], courses = []) { return uncompleted[0] ? { course: uncompleted[0], path: null } : { course: null, path }; } -export function computeJobMatch(profile, job) { +export function computeJobMatch(profile: ProfileRecord, job: JobPostingRecord): number { const p = profile || {}; let score = 0; const desired = (p.desired_position || '').toLowerCase(); if (desired && (job.title?.toLowerCase().includes(desired) || job.role_category?.toLowerCase().includes(desired))) score += 35; - const profileSkills = new Set((p.skills || []).map(s => s.toLowerCase())); - const profileCerts = new Set((p.earned_badges || []).map(b => b.name?.toLowerCase()).filter(Boolean)); + /* `new Set(x)` where x is `any` infers `Set`, and the loops below + call `.includes` on the members. Naming the element type is the whole + fix; the values put into the set are unchanged. */ + const profileSkills = new Set((p.skills || []).map(s => s.toLowerCase())); + const profileCerts = new Set((p.earned_badges || []).map(b => b.name?.toLowerCase()).filter(Boolean)); const reqSkills = (job.qualifications || []).map(q => q.toLowerCase()); const reqCerts = (job.certifications_required || []).map(c => c.toLowerCase()); @@ -144,7 +184,7 @@ export function computeJobMatch(profile, job) { return Math.min(100, Math.round(score)); } -export function recommendJobs(profile, jobPostings = [], limit = 3) { +export function recommendJobs(profile: ProfileRecord, jobPostings: JobPostingRecord[] = [], limit = 3) { return jobPostings .filter(j => j.status === 'active') .map(j => ({ job: j, match: computeJobMatch(profile, j) })) diff --git a/src/lib/provingGround.js b/src/lib/provingGround.ts similarity index 67% rename from src/lib/provingGround.js rename to src/lib/provingGround.ts index c540ab1..62b5095 100644 --- a/src/lib/provingGround.js +++ b/src/lib/provingGround.ts @@ -1,5 +1,57 @@ import { base44 } from '@/api/base44Client'; +/** + * Course, WorkerProfile and the conversation turns are typed `any` + * deliberately — `src/types/` does not describe entity records yet, and a + * partial interface written here would be a guess later readers treat as + * settled. See the same note in `krowScore.ts`. + */ +type CourseRecord = any; +type ProfileRecord = any; + +/** One turn of the roleplay transcript, as the challenge runner stores it. */ +interface ChallengeTurn { + role: string; + content: string; +} + +/** + * The body sent to `InvokeLLM`, built up across branches. + * + * Annotated rather than inferred because the code assigns `file_urls` and + * `prompt` onto the object AFTER creating it. TypeScript permits that in a + * `.js` file — it treats a JavaScript object literal as expandable — and + * refuses it in a `.ts` file, where the literal's type is fixed at creation. + * That difference is the only thing this interface exists to absorb: the + * assignments, their order and their values are untouched, and the emitted + * JavaScript is the same either way. + */ +interface ChallengeEvalCall { + response_json_schema: unknown; + model: string; + file_urls?: string[]; + prompt?: string; +} + +/** What the evaluator is asked to return; the schema below is its contract. */ +export interface ChallengeEvaluation { + verdict: 'verified' | 'needs_work' | 'failed'; + score: number; + rubric: Record; + feedback: string; + strengths: string[]; + concerns: string[]; +} + +/** The inputs a challenge submission carries, by challenge type. */ +export interface ChallengeSubmission { + type: string; + mediaUrl?: string; + transcript?: string; + identified?: { label: string; x: number; y: number }[]; + workerName?: string; +} + const CHALLENGE_EVAL_SCHEMA = { type: 'object', properties: { @@ -13,7 +65,7 @@ const CHALLENGE_EVAL_SCHEMA = { }; /** Real-work unlock gate — shifts, reliability, and badges must be earned first. */ -export function isUnlocked(course, profile = {}) { +export function isUnlocked(course: CourseRecord, profile: ProfileRecord = {}) { const req = course?.unlock_requirements; if (!req) return { unlocked: true, reasons: [] }; const reasons = []; @@ -27,7 +79,7 @@ export function isUnlocked(course, profile = {}) { } /** One sharp follow-up question during a roleplay challenge. */ -export async function challengeFollowUp(course, history) { +export async function challengeFollowUp(course: CourseRecord, history: ChallengeTurn[]) { const convo = history.map((m) => `${m.role === 'user' ? 'Worker' : 'KROW'}: ${m.content}`).join('\n'); const prompt = `You are "KROW", running a short proving-ground challenge for the skill "${course?.proof_skill || course?.title}". Challenge scenario: ${course?.challenge?.prompt || course?.description} @@ -41,7 +93,7 @@ Ask your follow-up now. Respond with only the question.`; /** Evaluate a worker's proof — transcript for roleplay, attached media for photo/video, * identified hazards for photo_identify (with the scene image attached). */ -export async function evaluateChallenge(course, { type, mediaUrl, transcript, identified, workerName }) { +export async function evaluateChallenge(course: CourseRecord, { type, mediaUrl, transcript, identified, workerName }: ChallengeSubmission) { const ch = course?.challenge || {}; const skill = course?.proof_skill || course?.title; const criteria = (ch.rubric || []).map((r) => r.criterion).join(', ') || 'overall_performance'; @@ -50,7 +102,7 @@ export async function evaluateChallenge(course, { type, mediaUrl, transcript, id : 'Score "overall_performance" 0-100 in the rubric object.'; let mediaPart; - const call = { response_json_schema: CHALLENGE_EVAL_SCHEMA, model: 'claude_sonnet_4_6' }; + const call: ChallengeEvalCall = { response_json_schema: CHALLENGE_EVAL_SCHEMA, model: 'claude_sonnet_4_6' }; if (type === 'photo_identify') { const list = (identified || []).map((h) => `- "${h.label}" at (${Math.round(h.x * 100)}%, ${Math.round(h.y * 100)}%)`).join('\n') || '(no hazards marked)'; diff --git a/src/lib/query-client.js b/src/lib/query-client.ts similarity index 100% rename from src/lib/query-client.js rename to src/lib/query-client.ts diff --git a/src/lib/roleCategories.js b/src/lib/roleCategories.ts similarity index 100% rename from src/lib/roleCategories.js rename to src/lib/roleCategories.ts diff --git a/src/lib/talentHome.js b/src/lib/talentHome.ts similarity index 51% rename from src/lib/talentHome.js rename to src/lib/talentHome.ts index 56086d4..6980ce0 100644 --- a/src/lib/talentHome.js +++ b/src/lib/talentHome.ts @@ -1,15 +1,56 @@ // Derives the Talent "credit score" dimensions and motivational metrics // from a WorkerProfile. Keeps the UI honest by mapping to real fields only. -const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0))); -export function getTalentScore(profile = {}) { +/** + * `profile` is a WorkerProfile record, typed `any` deliberately. + * + * The shared types do not describe entity records yet — that shape is derivable + * from the backend's generated column registry and belongs with the phase that + * converts the transport layer, not with this one. Writing a partial + * WorkerProfile here would be a guess wearing an interface, and every reader + * after it would treat the guess as settled. `any` says what is actually known + * today, which is nothing, and it is one annotation to replace when the real + * record type exists. + */ +type WorkerProfileRecord = any; + +/** + * One row of the reputation breakdown. + * + * `big` marks the headline dimension — Talent Score — which renders larger and + * is the one `getFactors` filters out. It is set on exactly one entry and + * absent from the other six, so it is optional rather than `boolean`: writing + * it as required would describe an object this module never builds. + */ +export interface ReputationDimension { + key: string; + label: string; + value: number; + big?: boolean; +} + +/** A dimension carrying the weight it contributes, as `getFactors` returns it. */ +export interface ReputationFactor extends ReputationDimension { + weight: number; +} + +/** The credit-rating tier for a score, with the colour it renders in. */ +export interface ScoreBand { + label: string; + color: string; +} + +const clamp = (n: unknown, min = 0, max = 100) => + Math.max(min, Math.min(max, Math.round(Number(n) || 0))); + +export function getTalentScore(profile: WorkerProfileRecord = {}) { return clamp(profile.krow_score || 0); } // Career Score — FICO-style 300–850 scale, the number shown on the KROW ID. -export const toFICO = (s) => 300 + Math.round((clamp(s) / 100) * 550); +export const toFICO = (s: number) => 300 + Math.round((clamp(s) / 100) * 550); -export function getStars(profile = {}) { +export function getStars(profile: WorkerProfileRecord = {}) { const s = getTalentScore(profile); if (s >= 90) return 5; if (s >= 75) return 4; @@ -20,18 +61,18 @@ export function getStars(profile = {}) { } // Market value uplift ($/hr) derived from Talent Score -export function getMarketValue(profile = {}) { +export function getMarketValue(profile: WorkerProfileRecord = {}) { const s = getTalentScore(profile); return Math.max(0, Math.round((s - 70) / 5)); } // Approximate growth this month (motivational, derived from XP) -export function getGrowth(profile = {}) { +export function getGrowth(profile: WorkerProfileRecord = {}) { const xp = Number(profile.xp) || 0; return clamp(Math.round(xp / 25), 0, 99); } -export function getReputation(profile = {}) { +export function getReputation(profile: WorkerProfileRecord = {}): ReputationDimension[] { const s = profile || {}; const courses = s.completed_courses || []; const learning = clamp(courses.length * 8 + (Number(s.xp) || 0) / 10); @@ -47,7 +88,7 @@ export function getReputation(profile = {}) { } // Weight each reputation factor like a credit score (FICO-style, sums to 100) -export const FACTOR_WEIGHTS = { +export const FACTOR_WEIGHTS: Record = { reliability: 25, attendance: 20, communication: 15, @@ -57,7 +98,7 @@ export const FACTOR_WEIGHTS = { }; // Score band — the "credit rating" tier for the talent -export function getScoreBand(score = 0) { +export function getScoreBand(score = 0): ScoreBand { if (score >= 90) return { label: 'Elite', color: '#0838E0' }; if (score >= 75) return { label: 'Excellent', color: '#16A34A' }; if (score >= 60) return { label: 'Solid', color: '#2563EB' }; @@ -66,7 +107,7 @@ export function getScoreBand(score = 0) { return { label: 'No score yet', color: '#D1D5DB' }; } -export function getFactors(profile = {}) { +export function getFactors(profile: WorkerProfileRecord = {}): ReputationFactor[] { return getReputation(profile) .filter((d) => !d.big) .map((d) => ({ ...d, weight: FACTOR_WEIGHTS[d.key] || 0 })); diff --git a/src/lib/talentInsights.js b/src/lib/talentInsights.ts similarity index 58% rename from src/lib/talentInsights.js rename to src/lib/talentInsights.ts index 0eba062..54a81a8 100644 --- a/src/lib/talentInsights.js +++ b/src/lib/talentInsights.ts @@ -2,8 +2,27 @@ // client endorsements. Used by both the talent card and the talent detail // modal so the two stay in sync. +/** + * `profile` is a WorkerProfile record, typed `any` deliberately. + * + * The shared types in `src/types/` describe the transport and the signed-in + * user; they do not describe entity records yet. That shape is derivable — the + * backend generates a column registry from `information_schema` — but it + * belongs with the phase that converts the transport layer. A partial interface + * written here would be a guess that every later reader treats as settled. + * `any` states what is actually known today and is one annotation to replace. + */ +type WorkerProfileRecord = any; + +/** One derived endorsement row, as the talent card and detail modal render it. */ +export interface ClientEndorsement { + company: string; + role: string; + stars: number; +} + // ESAT — Employee Skills Assessment Transcript; derived from AI interview score -export function esatTag(profile) { +export function esatTag(profile: WorkerProfileRecord) { const s = Math.round(profile?.ai_interview_score || 0); if (s > 0) return { label: `${s}/100`, cls: 'bg-[#FFF7ED] text-[#B45309]' }; return { label: 'Pending', cls: 'bg-[#F3F4F6] text-[#6B7280]' }; @@ -11,13 +30,13 @@ export function esatTag(profile) { // Recent job history (last 3 years). experience entries carry a duration // (`years`) rather than dates, so we surface the most recent up to 3. -export function recentJobs(profile) { +export function recentJobs(profile: WorkerProfileRecord) { return (profile?.experience || []).slice(0, 3).filter((e) => e && (e.role || e.company)); } // Up to 3 client endorsements, deterministically derived from companies the // worker has been placed at, rated off their aggregate client rating. -export function clientEndorsements(profile) { +export function clientEndorsements(profile: WorkerProfileRecord): ClientEndorsement[] { const companies = (profile?.experience || []) .map((e) => e?.company) .filter(Boolean); diff --git a/src/lib/userTracking.js b/src/lib/userTracking.ts similarity index 64% rename from src/lib/userTracking.js rename to src/lib/userTracking.ts index 847a5b3..f98b27b 100644 --- a/src/lib/userTracking.js +++ b/src/lib/userTracking.ts @@ -1,6 +1,10 @@ import { base44 } from '@/api/base44Client'; +import type { User } from '@/types/user'; -let _cachedUser = null; +/* The signed-in user, or null when nobody is or the lookup failed. Fetched + once per page load: this is best-effort logging, and a tracking call must + never become a second round trip on a user action. */ +let _cachedUser: User | null = null; let _userFetched = false; async function getCurrentUser() { @@ -14,8 +18,30 @@ async function getCurrentUser() { return _cachedUser; } +/** + * The optional context an event may carry. + * + * Every field is optional because every call site supplies a different subset — + * the shape is "whatever this particular event knows". The id fields are the + * records the event is about; they are written onto the record only when + * supplied, so an event with no application does not get an + * `application_id: null` that later reads as a missing link. + */ +export interface ActivityExtra { + email?: string; + name?: string; + account_type?: string; + details?: string; + position_id?: string; + application_id?: string; + candidate_id?: string; + interview_id?: string; + worker_email?: string; + metadata?: unknown; +} + /** Best-effort activity logging — never throws, never blocks the main flow. */ -export async function logActivity(eventType, extra = {}) { +export async function logActivity(eventType: string, extra: ActivityExtra = {}) { try { const me = await getCurrentUser(); const record = { diff --git a/src/lib/utils.js b/src/lib/utils.ts similarity index 56% rename from src/lib/utils.js rename to src/lib/utils.ts index 3d8d2cb..9561ee8 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.ts @@ -1,4 +1,4 @@ -import { clsx } from "clsx" +import { clsx, type ClassValue } from "clsx" import { extendTailwindMerge } from "tailwind-merge" /** @@ -27,7 +27,18 @@ const twMerge = extendTailwindMerge({ }, }) -export function cn(...inputs) { +/** + * `ClassValue` is clsx's own type, so this accepts exactly what clsx accepts + * and nothing has to be kept in step by hand: strings, numbers, `null`, + * `undefined`, `false`, arrays of any of those, and `{ 'class-name': boolean }` + * objects, nested to any depth. That breadth is deliberate — the conditional + * forms are most of why callers reach for `cn` at all. + * + * The body is untouched. `clsx(inputs)` is passed the rest array rather than + * spread into it, which is not the usual shape but is what this has always + * done, and clsx flattens an array argument to the same string either way. + */ +export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)) } diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..584146a --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,129 @@ +/** + * The shapes the Krow API speaks in. + * + * Types only — this module emits nothing. Import it with `import type` so it is + * erased at build time and never becomes a runtime dependency of the transport. + * + * Every declaration here is transcribed from the API's own contract + * (`krow-backend/docs/api-contract.md`, §1, §4 and §5) and cross-read against + * what `src/api/httpClient.js` actually does with the response today. Where the + * two could differ, the running code wins, because the callers were written + * against it. + * + * Nothing in `src` imports this yet. It exists so that the transport layer has + * a vocabulary to be converted INTO, rather than one invented halfway through + * converting it. + */ + +/** + * Collection metadata, from the `meta` half of a list response. + * + * Present on `list` and `filter` only; a single record carries no `meta`. + * + * Worth knowing before anyone reaches for it: `httpClient.request` returns + * `payload.data` and **drops `meta` on the floor**. That is deliberate — §4.2 + * records that nothing reads it, and surfacing it would change what the six + * entity methods return, which the transport swap was not allowed to do. So + * this type describes a value that is real on the wire and currently + * unreachable from application code. Typing it costs nothing and means the + * shape is already written down on the day someone wants `total`. + * + * `truncated` is `true` when `total > offset + returned`, which is the silent + * data-hiding bug §12.2 describes. It is not a bug this type fixes. + */ +export interface ApiMeta { + total: number; + limit: number; + offset: number; + returned: number; + truncated: boolean; +} + +/** + * What comes back on the wire, before `request` unwraps it. + * + * A successful response is always `{ data: … }`; a collection adds `meta`. The + * envelope exists so `meta` has somewhere to live (§1.1) and is unwrapped in + * exactly one place, so no hook, page or component has ever seen it. + */ +export interface ApiEnvelope { + data: T; + meta?: ApiMeta; +} + +/** + * The eight codes the API is allowed to return (§5). + * + * A closed union rather than `string`, because these are a fixed vocabulary the + * server owns and the client branches on — `isUnauthenticated` tests for + * `'unauthorized'` by name. + * + * `'unreachable'` is NOT in this union and is deliberately kept out: it is not + * an API code. `httpClient` synthesises it when `fetch` itself rejects and + * there is no response, no status and no envelope to read a code from. It + * belongs to `KrowApiError` below, where the distinction between "the API said + * no" and "there was no API" stays visible. + */ +export type ApiErrorCode = + | 'unauthorized' + | 'forbidden' + | 'rate_limited' + | 'not_found' + | 'validation_failed' + | 'invalid_query' + | 'conflict' + | 'internal'; + +/** + * The body of a non-2xx response. + * + * `details` maps field → reason on `validation_failed` and is `{}` otherwise. + */ +export interface ApiErrorBody { + code: ApiErrorCode; + message: string; + details: Record; +} + +/** A non-2xx response, whole. */ +export interface ApiErrorResponse { + error: ApiErrorBody; +} + +/** + * The error a failed request becomes on the client. + * + * An **interface, not a class**, and that is the load-bearing decision in this + * file. `httpClient` builds these by constructing a plain `Error` and assigning + * four properties onto it: + * + * const error = new Error(body?.message || `Request failed with status ${status}`); + * error.name = 'KrowApiError'; + * error.status = status; + * error.code = body?.code || 'internal'; + * error.details = body?.details || {}; + * + * Declaring a `class KrowApiError extends Error` would be the tidier-looking + * conversion and would change behaviour: it alters the prototype chain, so + * `instanceof` starts answering differently, and anything comparing + * `error.name === 'KrowApiError'` is relying on a string that a class would set + * by a different route. The migration's whole claim is that it changes nothing, + * so the shape is described rather than rebuilt — Phase 4 casts the constructed + * `Error` to this interface and the emitted JavaScript stays identical. + * + * `code` widens to include `'unreachable'` because `fetch` rejecting is not an + * API answer: that path sets `status: 0`, `details: {}` and a `cause`, and its + * message names the URL that did not respond rather than anything the server + * said. + * + * `status` is `0` for that transport failure and the HTTP status otherwise. + * Both `isUnauthenticated` and the 429 branch in `pages/admin/Login.jsx` read + * it, which is why it is required rather than optional. + */ +export interface KrowApiError extends Error { + name: 'KrowApiError'; + status: number; + code: ApiErrorCode | 'unreachable'; + details: Record; + cause?: unknown; +} diff --git a/src/types/entities.ts b/src/types/entities.ts new file mode 100644 index 0000000..d31f5ed --- /dev/null +++ b/src/types/entities.ts @@ -0,0 +1,135 @@ +/** + * Which entities exist, and the surface each one presents. + * + * Types only — nothing here emits. Import with `import type`. + * + * This file exists because the list of entities is currently written down + * **twice**: as the keys of `RESOURCE_PATHS` in `src/api/httpClient.js`, and as + * the `ENTITY_NAMES` array in `src/api/base44Client.js`. They hold the same + * eighteen names in a different order, and nothing checks that they agree. They + * agree today; they are one careless edit from not doing so, and the symptom + * would be `base44.entities.Whatever` being `undefined` at runtime with no + * warning from anywhere. + * + * Note what this file does NOT do: it does not replace either list. Both stay + * exactly where they are, because collapsing them into one is a runtime + * refactor and this phase changes no runtime behaviour. What it adds is a + * single type the two can be checked against once they are TypeScript, which + * turns a silent divergence into a compile error. + * + * Verified against the backend on 2026-09-11: fifteen of these eighteen appear + * in `krow-backend/go-api/internal/domain/resources_gen.go` — a registry + * generated from `information_schema`, so it cannot drift from the migrations — + * and every one of those fifteen paths matches character for character. The + * three that are absent from it are served by dedicated handlers rather than + * the generic entity machinery: `AgentDefinition` and `SkillDefinition` by + * `httpserver/definitions.go`, and the user by `httpserver/me.go`. + */ + +/** + * Every entity name the client knows, spelled as the frontend spells it. + * + * PascalCase and used verbatim in error messages — §5.1 of the API contract + * makes the entity name load-bearing in a `not_found` message, which reads + * `" not found"` using this spelling rather than the table + * name. + * + * `'User'` is in this union because it is in both runtime lists, not because it + * works. There is no `/api/v1/users` route on the backend, and nothing in `src` + * calls `base44.entities.User` — a call would 404. It is recorded here as it + * is, dead entry and all, because removing it from the union while both runtime + * lists still carry it would make the type a description of something other + * than this codebase. Whether the entry should exist at all is a product + * question, not a migration one. + */ +export type EntityName = + | 'JobPosting' + | 'JobApplication' + | 'AIInterview' + | 'Staff' + | 'WorkerProfile' + | 'EmployeeRole' + | 'Course' + | 'Badge' + | 'LearningPath' + | 'Certification' + | 'RoleCategory' + | 'UserActivity' + | 'Evidence' + | 'User' + | 'Assignment' + | 'ShiftRecord' + | 'AgentDefinition' + | 'SkillDefinition'; + +/** + * The URL segment each entity lives under. + * + * Kebab-case plural, with mass nouns left singular — `staff`, `evidence`, + * `user-activity` (§1). Declared rather than derived, for the reason + * `httpClient` gives at the site of the real map: a rule that turns + * `AIInterview` into `ai-interviews` and `Staff` into `staff` and `UserActivity` + * into `user-activity` is three special cases wearing a trench coat, and a + * wrong guess is a 404 at run time instead of a mistake anyone can see. + */ +export type EntityResourcePath = + | 'job-postings' + | 'job-applications' + | 'ai-interviews' + | 'staff' + | 'worker-profiles' + | 'employee-roles' + | 'courses' + | 'badges' + | 'learning-paths' + | 'certifications' + | 'role-categories' + | 'user-activity' + | 'evidence' + | 'users' + | 'assignments' + | 'shift-records' + | 'agent-definitions' + | 'skill-definitions'; + +/** + * What `createEntity(name)` hands back: one entity's client surface. + * + * Eight members, transcribed from `src/api/httpClient.js` — the six methods + * `store.js` declared, plus `bulkCreate` and the `entityName` the object + * carries around. + * + * The defaults are part of the signature rather than incidental. `store.js` + * declared `list(sort = '-created_date', limit = 100)` and several call sites + * rely on them instead of passing their own, so a conversion that dropped them + * would change what those callers fetch. + * + * `T` is the record type, left open here. Phase 2 deliberately does not define + * the eighteen record shapes: they are derivable — `resources_gen.go` carries + * every column with its type, nullability and enum values — but nothing + * consumes them until the transport layer is converted, and transcribing some + * five hundred field declarations by hand ahead of a consumer is how a type + * becomes confidently wrong. They belong with Phase 4, generated from that + * registry rather than retyped from it. + * + * Until then `EntityClient` describes the shape of the surface without + * asserting anything about the records that travel through it, which is exactly + * as much as is known today. + */ +export interface EntityClient { + entityName: EntityName; + list(sort?: string, limit?: number): Promise; + filter(query?: Record, sort?: string, limit?: number): Promise; + get(id: string): Promise; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + /** Resolves to `{ id }`, matching what `store.js` returned (§4.3). */ + delete(id: string): Promise<{ id: string }>; + /** + * Sequential creates, not a batch endpoint and not `Promise.all`: there is no + * bulk write in the contract (§12.1), and doing them one at a time keeps the + * failure behaviour identical — the first rejection stops the run and the + * records written before it stay written. + */ + bulkCreate(records?: Partial[]): Promise; +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts new file mode 100644 index 0000000..4a43dc3 --- /dev/null +++ b/src/types/global.d.ts @@ -0,0 +1,26 @@ +export {}; + +/** + * Globals this app adds to the browser's own. + * + * There is exactly one, and it is a custom event rather than anything hung off + * `window` — nothing in `src` ever assigns a property to `window`, and this + * should stay true. + * + * `krow:open-owliver` is how a component that is nowhere near the assistant + * asks for the assistant to open. The talent shell listens for it + * (`src/layouts/Layout.jsx`) and two buttons plus the employee dashboard + * dispatch it. Declaring it in `WindowEventMap` is what lets + * `addEventListener` and `dispatchEvent` agree on the name: without it both + * sides fall back to the untyped string overload, and a misspelling on either + * side is a listener that never fires and nothing that says why. + * + * The payload is an unparameterised `CustomEvent` because the event carries no + * detail — it is a signal, not a message. If it ever gains one, give it + * `CustomEvent<{ … }>` here and both ends are checked against it at once. + */ +declare global { + interface WindowEventMap { + 'krow:open-owliver': CustomEvent; + } +} diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 0000000..def5d3e --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,132 @@ +/** + * The signed-in user, and the preferences that travel with the account. + * + * Types only — nothing here emits. Import with `import type`. + * + * Transcribed from the projection the server actually selects, not from the + * documentation of it. `krow-backend/go-api/internal/httpserver/me.go` builds + * every user record from one constant: + * + * const userColumns = `id::text AS id, legacy_id, full_name, email::text AS email, + * role, account_type, status, + * to_char(created_date …) AS created_date, + * to_char(updated_date …) AS updated_date` + * + * and then attaches `preferences`. That is ten fields. The contract document's + * worked example in §9 shows seven — it omits `status` and `updated_date`, and + * it is the older of the two. The code is what answers the request, so the code + * is what is written down here. + */ + +/** + * What the account is allowed to do. + * + * A closed union because the database closes it: `users_role_check` is + * `CHECK (role IN ('admin', 'employer', 'talent'))`, so no other value can be + * stored and none can be returned. + * + * This is the authorization field. It is server-owned — `PATCH /me` ignores it, + * deliberately, because when it was writable any signed-in user could promote + * themselves to admin with a one-line request. + */ +export type UserRole = 'admin' | 'employer' | 'talent'; + +/** + * Whether the account may sign in. + * + * Closed for the same reason: `users_status_check` is + * `CHECK (status IN ('active', 'suspended'))`. Also server-owned. + */ +export type UserStatus = 'active' | 'suspended'; + +/** + * Product preferences, stored on the account. + * + * Three named keys and an open tail, and the open tail is not a hedge — it is + * the shape. The `user_preferences` table has exactly three boolean columns + * plus an `extra jsonb` blob, and the server merges that blob into the same + * flat object it returns. Everything account-authored lives in there: + * `customSkills` and `customAgents`, written by `WorkspaceSkills.jsx`, + * `SkillEditor.jsx`, `OwliverSkillEditor.jsx` and `useAgents.js`. + * + * The index signature is therefore a true statement about the runtime value, + * not a shortcut. Naming `customSkills` and `customAgents` explicitly was + * tempting and rejected: their contents are the authored-definition shapes, + * which belong to the agent layer, and this phase does not describe the agent + * layer. + * + * The three named keys are the one place field naming diverges from the + * database — camelCase here, snake_case in the columns. The server does that + * translation; nothing on this side should. + */ +export interface UserPreferences { + owliverDefault: boolean; + compactDensity: boolean; + emailDigest: boolean; + [key: string]: unknown; +} + +/** + * The user as `GET /me` returns it. + * + * Four fields are optional, and the reason is worth stating because it is not + * laziness. `base44.auth.me()` does not always resolve to a server response: on + * the path through `readCachedUser` it returns + * `{ ...DEMO_USER, ...JSON.parse(raw) }`, and `DEMO_USER` — the default shape + * in `src/api/demoUser.js` that has to resolve during the first render, before + * the server has answered — carries only `id`, `full_name`, `email`, `role`, + * `account_type`, `created_date` and `preferences`. + * + * So a value of this type may genuinely lack `legacy_id`, `status` and + * `updated_date`. Marking them required would describe the server's answer + * accurately and the runtime value inaccurately, and the second is the one that + * reaches components. + * + * `legacy_id` is additionally nullable at the source — `legacy_id text UNIQUE`, + * no NOT NULL — so it is `string | null` when present. It holds the original + * seeded identifier (`user_demo`); `id` is the uuid and is the only addressable + * one. + */ +export interface User { + id: string; + full_name: string; + email: string; + role: UserRole; + /** + * Which product surface the person is looking at, Employer or Talent. + * + * Plain `string`, not a union, and that is checked rather than assumed: the + * column is `account_type text NOT NULL DEFAULT 'employer'` with **no CHECK + * constraint**, and the frontend reads it as open text — `|| 'applicant'`, + * `|| 'unknown'`, `|| '—'` all appear at call sites. There is no closed set + * to write down, so none is invented. + * + * Explicitly not an authorization field. `role` authorizes; this is display. + */ + account_type: string; + created_date: string; + preferences: UserPreferences; + legacy_id?: string | null; + status?: UserStatus; + updated_date?: string; +} + +/** + * What `auth.updatePreferences()` resolves to. + * + * Not a bare user, and the reason is a bug rather than a preference: a + * swallowed `QuotaExceededError` once silently lost account-authored skills, so + * a caller storing something it needs back after a reload has to be able to + * tell a write that landed from one the browser refused. The cache is updated + * either way — the change is real for the session even when it could not be + * stored. + * + * Over HTTP a failed write is already a non-2xx, so the client synthesises + * `{ user, persisted: true, error: null }` on success and lets the throw path + * handle failure. `src/lib/skills/saveFeedback.js` is the only consumer. + */ +export interface PreferencesUpdateResult { + user: User; + persisted: boolean; + error: Error | null; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..bd336d6 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,43 @@ +/// + +/** + * Vite's build-time additions to `import.meta`, and the environment this app + * actually reads from it. + * + * The reference above is what supplies `import.meta.env`, `import.meta.glob` + * and the `?raw` module shape. Without it the two registries that discover + * agents and skills from the filesystem do not typecheck at all, and one of + * them carries a hand-written `@ts-ignore` saying so. + * + * The interface below names the two variables this codebase reads. It does not + * restrict `import.meta.env` to them — this declaration MERGES with Vite's, + * which carries an open `[key: string]: any`, so `import.meta.env.VITE_ANYTHING` + * still compiles and still comes back as `any`. Typing cannot close that hole; + * only reading this list can. + * + * Declaring them is therefore documentation as much as typing: these are + * inlined at build time, so a variable missing when the bundle is built is + * missing for the life of that bundle, and a typo is not a runtime error + * anywhere — it is a silent `undefined` and a fallback taken forever. + * + * VITE_API_BASE_URL src/api/httpClient.js — where the entity API lives. + * Defaults to the same-origin '/api/v1', which is the + * supported arrangement: the session cookie is HttpOnly + * and SameSite=Lax, so a cross-origin value breaks auth. + * VITE_AGENT_API src/components/ai-assistant/provider.js — the agent + * endpoint. Unset means the local engine answers. + * + * VITE_API_PROXY_TARGET is deliberately absent: it is read by `vite.config.js` + * through `loadEnv` at dev-server startup and never reaches `import.meta.env`. + * + * Both are optional because both have fallbacks, and typing them as required + * would claim a guarantee the build does not make. + */ +interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string; + readonly VITE_AGENT_API?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..45bf27e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,84 @@ +{ + /* The TypeScript configuration, succeeding `jsconfig.json`. + Deliberately a translation of what was already there rather than a fresh + start: every option below that existed in jsconfig carries its old value, + so the first day of the migration checks exactly what the last day before + it checked. The four additions are marked. + + `jsconfig.json` is left in place for now and is inert — TypeScript ignores + it wherever a `tsconfig.json` sits beside it. It is removed in the final + phase, once nothing is asking for it. */ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "jsx": "react-jsx", + "module": "esnext", + "moduleResolution": "bundler", + "lib": ["esnext", "dom", "dom.iterable"], + "target": "esnext", + + /* Vite builds; this config only ever checks. Stating it means no stray + output can appear next to a source file if anyone runs `tsc` directly. */ + "noEmit": true, + + /* The two that make an incremental migration possible. `allowJs` lets a + `.ts` file and a `.js` file import each other with no ceremony, so files + can be converted one at a time instead of in one unreviewable commit; + `checkJs` keeps the JSDoc-typed JavaScript under the same scrutiny it + has had all along, so nothing is lost in the meantime. Both go away at + the end of the migration, not before. */ + "allowJs": true, + "checkJs": true, + + /* NEW. esbuild compiles each file on its own and cannot see across them, so + a construct that needs whole-program knowledge — re-exporting a type as + if it were a value, most often — works under `tsc` and breaks in the + bundle. This makes tsc refuse what esbuild could not have done anyway. */ + "isolatedModules": true, + + /* NEW. macOS does not distinguish `Button` from `button`; CI does. Without + this, a mis-cased import is a green local build and a red pipeline. */ + "forceConsistentCasingInFileNames": true, + + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "resolveJsonModule": true, + + /* CHANGED, from `[]`. The empty list suppressed every ambient type package, + which is why `import.meta.env` and `import.meta.glob` — Vite build-time + APIs, absent from the standard `ImportMeta` — were reported as errors in + eight places across the app. + + Named explicitly rather than left to default, because the default pulls + in EVERYTHING under node_modules/@types, and `@types/node` is installed. + In browser code that would make `setTimeout` return a `NodeJS.Timeout` + instead of a number, which is wrong and quietly infectious: three + components store a timer handle in a `useRef` and would be typed against + a runtime they do not run on. Node's types belong to `vite.config.js` and + `scripts/`, neither of which this project includes. */ + "types": ["vite/client"], + + /* Off for now, and turned on a flag at a time later in the migration — + `noImplicitAny`, then `strictNullChecks`, then the rest. Enabling it here + would bury the ~70 real errors this project already has under several + hundred more, and the point of going file by file is that each step is + small enough to read. */ + "strict": false + }, + + "include": ["src/**/*"], + + /* Unchanged from jsconfig, and temporary. These three trees hold the densest + logic in the app — the transport layer, every hook, and the vendored UI + primitives — and none of it has ever been checked. Each exclusion is + removed by the phase that converts the tree behind it, so the error count + rises deliberately and in one identifiable place at a time. + + Note that excluding a file only keeps it out of the root set: one that an + included file imports is still checked, which is why errors from `src/api` + and `src/lib` already show up today. */ + "exclude": ["node_modules", "dist", "src/components/ui", "src/api", "src/lib"] +}