Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 64 additions & 4 deletions docs/UPGRADE-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,9 +538,54 @@ All decomposition outputs are authored in TypeScript from the start (`engine/*.t

The probe was then REPLACED by a permanent guard instead of being deleted: `src/toolchain/typescriptSupport.ts` + its test. Reason: with the probe gone there is no `.ts` under `include`, so the gate would be green over ZERO TypeScript — indistinguishable from green over working TypeScript until someone tries to convert something. The guard is read by both pipelines (tsc proves the checker sees `.ts`; Jest proves Babel strips it), and it was verified to FAIL when `@babel/preset-typescript` is removed. It sits outside `src/core`/`src/library`, so it is outside `collectCoverageFrom` and nothing imports it — confirmed absent from `dist/index.js`. **Delete it once real converted modules cover the same ground (E1 onwards).**

#### E1 — Utils and contract first (highest leverage)
#### E1 — Utils and contract first (highest leverage) — 🔶 **FIRST HALF SHIPPED 2026-09-17**

- `src/core/utils` (~36 files, pure, React-free) — mechanical conversion, immediately types everything downstream.
- ~~`src/core/utils` (~36 files, pure, React-free) — mechanical conversion~~ ✅ **DONE 2026-09-17. 20 files, not ~36** — the count predates H1 and the earlier deletions; it was 20 files / 4142 lines with 26 test files over them. All 20 are now `.ts`, strict, with the suite and the coverage thresholds unchanged.

**MEASURED, which is what the go/no-go below needs and never had:**

| wave | files | lines | agent-minutes | `any` |
|---|---|---|---|---|
| 1 — leaves | 8 | 1072 → 1160 | 127 | 20 |
| 2 — `_envs`, `components`, `string` | 3 | 719 → 806 | 53 | 0 |
| 3 — `array`, `definitions` | 2 | 650 → 745 | 57 | 2 |
| 4 — `function`, `number`, `object`, `translations` | 4 | 1407 → 1523 | 87 | 2 |
| 5 — `storage`, `utility` + barrel | 3 | 294 → 344 | 40 | 0 |
| **total** | **20** | **4142 → 4578 (+10.5%)** | **364** | **23** |

**Cost does not track size, it tracks how polymorphic the signature is.** `lodash-lite` (552 lines,
a lodash reimplementation) took 55 minutes and carries **17 of the 23 `any`** on its own. `string`
(663 lines — longer) took 35 minutes and needed none. Across the package there are 23 `any` against
**199 `unknown`**, so the conversion narrowed honestly rather than reaching for the escape hatch.
"mechanical conversion" was right for about 17 of the 20 files and wrong for the polymorphic ones.

**Three infrastructure holes, none of them predicted here, all of the same shape — a gate keyed on
file extension stops watching whatever the migration moves, and says nothing:**

1. 38 intra-`utils` imports carried an explicit `.js` extension, which Jest and webpack resolve
literally, so every rename broke resolution. `tsc` cannot warn: `checkJs` is off. It fails at
test and build time instead. A second round found the same thing in `jest.doMock`/`require`
forms and inside `__tests__`, which the first sweep's `from '…'` pattern had missed.
2. `lint:js` was extended to `.ts` in E0 — and immediately caught that
`eslint-config-react-app` turns `no-use-before-define` OFF for `.js` but ON for `.ts`, so a
pre-existing pattern started failing the zero-warning gate purely because of an extension.
3. `collectCoverageFrom` listed `{js,jsx}`, so converted files dropped out of coverage entirely.
Because `utils` is among the best-covered code here, losing it pushed the GLOBAL numbers DOWN
and the thresholds red — while plain `jest` stayed green. Three per-file thresholds also still
named `./src/core/utils/*.js`, which jest reports as "coverage data not found", not as an error.

**Behaviour was held still, deliberately, and the record of where that cost something is in the
commits:** `definitions.localise` gained an unused optional parameter so an existing recursive call
would typecheck without editing that line; `utility` uses a non-null assertion on
`Active.passwordCheck` because it being undefined and throwing IS the backend behaviour;
`function.debounce` keeps `arguments` rather than rest parameters, at the cost of two inert
`as unknown as` casts, so the emitted JavaScript stays byte-identical.

**One real contract question surfaced and is left for whoever converts the caller:**
`src/core/components/renders.js:139` calls `round(value, decimals)` where `value` can be a numeric
string from `data.json`, while the six rounding helpers are typed `number` per their JSDoc. Nothing
breaks today because `renders.js` is unchecked JavaScript. Widening the helpers or casting at the
call site is a decision about what the contract is.
- **Contract types for meta/data**, authored together with the JSON Schema (§9.4). Pick one source of truth (types→schema or schema→types) and add a round-trip check so they cannot diverge.

#### E2 — Components and modules (rides other workstreams)
Expand Down Expand Up @@ -571,7 +616,22 @@ All decomposition outputs are authored in TypeScript from the start (`engine/*.t

#### Governance — go/no-go after E1

E0/E1 plus the contract types are committed scope. Before green-lighting the long E2/E3 tail (~250 files riding two large workstreams), hold an explicit go/no-go on measured E1 conversion velocity. Until E4 lands, hedge the hand-written `dist/index.d.ts` cheaply with type-level tests (`tsd`/`expectTypeOf`) asserting the public types against the example metas.
E0/E1 plus the contract types are committed scope. Before green-lighting the long E2/E3 tail (~250 files riding two large workstreams), hold an explicit go/no-go on measured E1 conversion velocity.

**The measurement now exists (2026-09-17), so the gate can actually be held.** From the E1 table
above: **364 agent-minutes for 4142 lines**, ≈11 lines/minute, over 20 files. Extrapolating to the
~155 `.js`/`.jsx` files still in `src` is NOT a straight multiplication, and the E1 data is the
reason to say so rather than a caveat added for safety:

- `utils` is **pure and React-free**. E2's components carry JSX, hooks, prop types and the DOM
boundary, none of which this measurement covers.
- Cost concentrated in polymorphic signatures, and E2 has its own version of that problem in the
engine's open prop spreads.
- Three of E1's five blockers were INFRASTRUCTURE, not typing, and those are now paid for once —
the next waves should not re-pay them.

So the honest reading is: the mechanical half is cheap and now has a number; the go/no-go should
still turn on a **pilot of two or three real components**, not on extrapolating utils. Until E4 lands, hedge the hand-written `dist/index.d.ts` cheaply with type-level tests (`tsd`/`expectTypeOf`) asserting the public types against the example metas.

#### Definition of done

Expand Down Expand Up @@ -1330,7 +1390,7 @@ Every check this plan depends on, in one place. ✅ = already verified during th
- ☐ E4: golden `dist/index.d.ts` diff reviewed — only intended changes
- ☐ E5: semantic `type` proxy recreated as TS aliases (same vocabulary) before the engine converts
- ☐ E5: `rg "prop-types" src` returns nothing → `prop-types` removed from `dependencies`, bundle-size delta recorded
- ☐ Go/no-go on the E2/E3 tail held after E1 (measured conversion velocity); hand-written d.ts hedged with type-level tests until E4
- ☐ Go/no-go on the E2/E3 tail held after E1 — **the velocity measurement now exists** (§9.6-E1: 20 files, 4142 lines, 364 agent-minutes, 23 `any` against 199 `unknown`); the DECISION is still outstanding, and §9.6's governance note argues it should turn on a 2–3 component pilot rather than on extrapolating pure utils; hand-written d.ts hedged with type-level tests until E4

### Phase 6 — engine decomposition (§9.3)

Expand Down
17 changes: 11 additions & 6 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,16 @@ module.exports = {
'<rootDir>/\\.[^/]+/worktrees/',
'<rootDir>/src/style/__tests__/setup.js',
],
// `ts,tsx` added at §9.6-E1: converting a file to TypeScript must not remove it from the coverage
// denominator. It silently did — the whole of src/core/utils dropped out of measurement on
// conversion, and because those files are among the best covered in the repo, losing them pushed
// the GLOBAL numbers DOWN and the thresholds red. A coverage config that tracks file extensions
// has to be widened in step with any migration, or the gate quietly stops watching what it moved.
collectCoverageFrom: [
'<rootDir>/src/core/**/*.{js,jsx}',
'<rootDir>/src/library/**/*.{js,jsx}',
'<rootDir>/src/core/**/*.{js,jsx,ts,tsx}',
'<rootDir>/src/library/**/*.{js,jsx,ts,tsx}',
'!<rootDir>/src/**/__tests__/**',
'!<rootDir>/src/**/*.test.{js,jsx}',
'!<rootDir>/src/**/*.test.{js,jsx,ts,tsx}',
'!<rootDir>/src/**/__mocks__/**',
],
coverageThreshold: {
Expand Down Expand Up @@ -242,13 +247,13 @@ module.exports = {
functions: 100,
lines: 100,
},
'./src/core/utils/storage.js': {
'./src/core/utils/storage.ts': {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
'./src/core/utils/function.js': {
'./src/core/utils/function.ts': {
statements: 100,
branches: 96,
functions: 77,
Expand Down Expand Up @@ -296,7 +301,7 @@ module.exports = {
functions: 100,
lines: 100,
},
'./src/core/utils/definitions.js': {
'./src/core/utils/definitions.ts': {
statements: 100,
branches: 100,
functions: 100,
Expand Down
2 changes: 1 addition & 1 deletion src/core/components/__tests__/Table.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ describe('Table', () => {
// a re-introduction would otherwise only surface in a generated-page diff.
// Matched as a module reference, not as the bare string: the file's own header
// explains what it replaced, and a substring check would fail on the prose.
const source = require('fs').readFileSync(require.resolve('../Table.js'), 'utf8')
const source = require('fs').readFileSync(require.resolve('../Table'), 'utf8')
expect(source).not.toMatch(/(?:from|require\(|import\(|jest\.mock\()\s*['"]semantic-ui-react/)
})

Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
describe('Id collision and history contracts', () => {
afterEach(() => {
jest.dontMock('../string.js')
jest.dontMock('../string')
jest.resetModules()
})

function loadUtilityWithRandomValues (...values) {
jest.resetModules()
const randomString = jest.fn()
values.forEach(value => randomString.mockReturnValueOnce(value))
jest.doMock('../string.js', () => ({
...jest.requireActual('../string.js'),
jest.doMock('../string', () => ({
...jest.requireActual('../string'),
randomString,
}))

let utility
jest.isolateModules(() => {
utility = require('../utility.js')
utility = require('../utility')
})

return { ...utility, randomString }
Expand Down
55 changes: 0 additions & 55 deletions src/core/utils/_envs.js

This file was deleted.

93 changes: 93 additions & 0 deletions src/core/utils/_envs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { LANGUAGE } from './constants'

/** One of the language definitions declared in `constants.ts` (e.g. `LANGUAGE.ENGLISH`) */
export type Language = (typeof LANGUAGE)[keyof typeof LANGUAGE]

/** The `_` code of a language definition (e.g. `'en'`, `'zh_CN'`) */
export type LanguageCode = Language['_']

/** Password strength calculator, compatible with the subset of `zxcvbn` that is actually used */
export type PasswordCheck = (password?: string) => {score: number}

/** Global translate function - strings are localised, everything else passes through unchanged */
export type Translate = <T>(value: T) => T

/**
* Shape of the globally accessible `Active` object.
* @note: every slot is a mutable runtime injection point, so the env-dependent ones are typed
* `unknown` (narrow before use) and the index signature admits the extra props that platform
* code attaches at runtime (`Field`, `renderField`, `UIRender`, `SERVICE`, `state`, ...).
*/
export interface ActiveEnv {
[key: string]: unknown

DEFAULT: {LANGUAGE: LanguageCode}
LANG: Language
Storage: unknown
WebSocket: unknown
history: unknown
iconClass: string
iconClassPrefix: string
client: unknown
log: unknown
user: Record<string, unknown>
usersById: Record<string, unknown>
translate: Translate
/** Storage slot behind the `passwordCheck` accessor pair */
zxcvbn?: PasswordCheck
passwordCheck: PasswordCheck | undefined
}

/**
* Environment Variables
* @note: for Next.js, explicitly set variable on initialisation like so:
* import config from 'next/config'
* import { ENV } from './'
*
* Object.assign(ENV, config().publicRuntimeConfig)
*/
export let ENV: Record<string, string | undefined> = (typeof process !== 'undefined' && process.env) || {}
export const NODE_ENV: string | undefined = ENV.NODE_ENV // @Note: Next.js does not automatically add NODE_ENV, set inside next.config.js
export const __PROD__: boolean = NODE_ENV === 'production'
export const __STAGE__: boolean = NODE_ENV === 'stage'
export const __TEST__: boolean = NODE_ENV === 'test'
export const __DEV__: boolean = NODE_ENV === 'development'
export const __CLIENT__: boolean = typeof window !== 'undefined'
export const __BACKEND__: boolean = !__CLIENT__
export const __IOS__: boolean = false
export const _INIT_: boolean = __BACKEND__ && (__PROD__ || __STAGE__)
export const _WORK_DIR_: string = typeof process !== 'undefined' ? process.cwd() : '.' // relative to root `index.js`
export const UNDEFINED: undefined = ((Undefined?: undefined) => Undefined)()

/* Globally Accessible Objects */
export const Active: ActiveEnv = {
// will be overridden at runtime, used for avoiding circular import and env-dependent libraries
DEFAULT: {LANGUAGE: LANGUAGE.ENGLISH._},
LANG: LANGUAGE.ENGLISH, // currently used language
Storage: typeof localStorage !== 'undefined' ? localStorage : undefined, // LocalStorage for Node
WebSocket: typeof WebSocket !== 'undefined' ? WebSocket : undefined, // WebSocket for Node
history: {}, // Cross Platform route history object
iconClass: '', // CSS className for <Icon />
iconClassPrefix: 'icon-', // CSS className prefix for <Icon />
client: undefined, // Apollo client
log: undefined, // backend console logger
user: {}, // the current user, for quick access to user info, such as auth
usersById: {}, // for storing temporary info, like user.lastOnline
translate: (value) => value, // Global translate function

/**
* Password Strength Calculator
* @example: <script async src="/static/zxcvbn.js"/>
* - Frontend uses async script in <head/> section to load static zxcvbn.js for faster page load.
* - Backend should override this prop with `Active.passwordCheck = require('zxcvbn')`
* @returns {zxcvbn|(function(): {score: number})|*}
*/
get passwordCheck (): PasswordCheck | undefined {
// When not loaded, skip password validation in frontend
if (typeof window !== 'undefined') return (window as unknown as {zxcvbn?: PasswordCheck}).zxcvbn || (() => ({score: Infinity}))
return this.zxcvbn
},
set passwordCheck (zxcvbn: PasswordCheck | undefined) {
this.zxcvbn = zxcvbn
}
}
Loading
Loading