mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
* fix(mobile): move the last six reply-enum pins where tsc looks mobile/tsconfig.json excludes *.test.ts, so a `Record<HostUnion, true>` coverage record in a schema test is never typechecked: the two that existed (SshConnectionStatus, GitHubProjectOwnerType) checked nothing, and the four closed enums beside them had only a doc citation of the host type. Each arm list moves into its schema module as hostUnionArms<Union>(), which #21269 introduced for the same reason, and each test iterates the exported list instead of holding its own copy: - SSH_CONNECTION_STATUS to SshConnectionStatus - PROJECT_OWNER_TYPE to GitHubProjectOwnerType - DETAIL_FILE_STATUS to GitHubPRFile['status'] - PUSH_TEST_REFUSAL_REASONS and PUSH_REGISTER_REFUSAL_REASONS to the refusal arms of MobilePushTestResult and MobilePushRegisterResult - SETUP_RUN_POLICIES to SetupRunPolicy openEnum's parameter widens from a non-empty tuple to `readonly string[]` so a hostUnionArms list can feed it. z.enum already accepts the same, so the tuple constraint only excluded callers zod itself takes; behaviour unchanged. Twelve mutations prove the pins: dropping one arm and adding a bogus one each fail mobile tsc in all six places. Zero goldens move, the schemas' behaviour being unchanged, and the 21 recording suites pass at the existing baseline. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fix the type errors in eighteen test files Found by typechecking the tests for the first time (see the config that follows). All mechanical, none weakens a product type: - 67 `act(() => vi.advanceTimersByTime(...))` callbacks return VitestUtils where act wants void, so each becomes a block. The async ones await only a genuinely promise-returning call, so no extra microtask tick is introduced. - Four fixtures were stale against a product type that gained a required member: MobileViewState.alwaysShowDefaultBranch, PrSidebarData.checksError, the branch-compare summary's errorMessage, and SessionOptionDescriptor's transport, which #20884 added precisely so a producer could not inherit the wrong lane's rendering by omission. - `getLastConnectedAt` on the shared relay fake was typed `() => null`, which refused the timestamp two escalation suites assign to it. - Two holders used before assignment take `!`, one `advance!.kind === ...` becomes `advance?.kind`, one widened status arm takes `as const`, and the Expo notification fixture keeps `data` required because the dismissal cases assign through it. 631 test files pass, 6222 tests, unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): typecheck the test files, on a ratchet mobile/tsconfig.json excludes *.test.ts so Metro never compiles tests into the release bundle, and vitest transpiles without checking types. Nothing had ever typechecked a mobile test, which is why a `Record<HostUnion, true>` pin written in one proved nothing and why 144 of the 630 test files had drifted. tsconfig.test.json is that program with the tests put back, behind `typecheck:tests`. Four files stay out: they import the desktop main process or src/shared/child-process, which are written against @types/node, and this program's libs are React Native's, where setTimeout answers a number rather than a NodeJS.Timeout. Pulling that graph in reports ~280 errors about the desktop rather than about mobile; vitest runs those four under Node, which is where they belong. The CI gate is a ratchet rather than the raw typecheck, modelled on check-ts-nocheck-ratchet.mjs: 126 files still fail, so the gate freezes that set and fails when a file that checks today stops checking, or when a baseline entry starts checking and was not pruned. The list may only shrink. Why not zero: 180 of the remaining 510 errors are one seam — tests locate mocked react-native components by string name, which `ElementType` does not admit — and closing it means either 180 casts or a global JSX declaration for the mocked names. That is a design decision, not a mechanical fix, so it is left for a follow-up rather than made here. The rest are smaller clusters of the same kind: vi.fn mocks assigned into typed slots, call-arg tuple indexing, and createElement props fixtures. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile-recorder): correct the corpus counts and the salvage claim The oracle section still quoted the corpus as 368 scenarios and 727 goldens; it is 393 and 778, and the three replay suites report 781 tests. Each number now names the command that measures it. "No golden carries one" was the load-bearing error: 44 goldens carry a recorded `reply-salvage` today, starting with the push-test unknown-reason scenario #21176 added for exactly that purpose. The paragraph claimed the observation pins an absence when on those families it pins a recorded drop. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the tests-typecheck ratchet's parser The gate reads tsc's output, and tsc indents the "Overload 1 of 2, ..." detail under an error. Counting those as filenames would write unparseable entries into the baseline and leave the gate unprunable, so the parser is pinned on that shape as well as on the added/stale diff. Written against the gate itself: it flagged this file before the directive it carried was removed, which is the end-to-end proof the spawn half works. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): await the timer advances the act() rewrite dropped Rewriting `await act(async () => vi.advanceTimersByTimeAsync(n))` into a braced body left the returned promise floating at 27 sites, so the advance was no longer ordered before the assertions that follow it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): unshadow MobileHostCard's .tsx suite A wildcard `include` keeps only the higher-priority extension, so MobileHostCard.test.tsx sat outside every tsc program while MobileHostCard.test.ts existed beside it. Its one error is the same react-test-renderer seam its sibling is baselined for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census every test file into the typecheck program The ratchet diffs only files that error, so a test excluded from tsconfig.test.json or shadowed by a sibling extension left the gate silently. Every *.test.ts(x) on disk must now be in the program or named in TESTS_OUTSIDE_PROGRAM with its reason. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(shared): make the enum helpers refuse the ways they can prove nothing openEnum takes a `const` T so a bare literal keeps its arms rather than widening to string. hostUnionArms blocks inference of U with NoInfer and defaults it to never, so a call that omits the host union — where the record would only pin itself — no longer compiles. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): describe the census and correct the baseline count Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the push fixture cast its SAFETY rationale Widening the pre-existing cast made the changed-code gate attribute it as a new finding. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): build the push fixtures as typed notifications Replaces the `as unknown as` cast with Expo's own types, filling FirebaseRemoteMessage and its notification once in two builders, and passes the data payload in rather than mutating through an optional member. Typing the fixture showed one assertion comparing the scheduled content against the whole arriving content, which only held while the cast let the fixture omit the two members the presenter drops; it now names the four members the presenter forwards. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the grouped-question advance read non-optional `advance?.kind` let an absent advance take the null-draft branch instead of failing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): run the tests-typecheck ratchet on Windows Spawns tsc's JS entry on this Node instead of the node_modules/.bin shim, which is a POSIX shell script that Windows resolves to tsc.CMD and then appends .exe to. Parsed paths are normalised to POSIX so a Windows run does not read every baseline entry as both stale and added. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): close the ratchet's @ts-nocheck hole and read tsc once tsc exits 0 on a @ts-nocheck file, so a baselined test could be "fixed" with one line, pruned, and never checked again; the census now names any program test file whose leading comment carries the directive. `--noEmit --listFiles` answers both questions in one pass, so the gate spawns tsc once rather than twice. Corrects the two stale counts, and states hostUnionArms' real reason for living in the schema module now that tests are typechecked. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
213 lines
10 KiB
TypeScript
213 lines
10 KiB
TypeScript
import { z } from 'zod'
|
|
import type { SshConnectionStatus } from '../../../src/shared/ssh-types'
|
|
import {
|
|
hostUnionArms,
|
|
openEnum,
|
|
salvagedOptional,
|
|
salvagingArray
|
|
} from '../../../src/shared/zod-salvage'
|
|
|
|
// The repo and SSH reads the workspace-create drawer runs. Checked against
|
|
// src/main/runtime/rpc/methods/ssh.ts:30-46 (getPublicSshState, SshConnectionState in
|
|
// src/shared/ssh-types.ts:187), src/main/runtime/rpc/methods/preflight.ts:22-30 (both agent probes
|
|
// answer a bare `string[]`), and repo.ts:87-103/:184-192 (the sparse preset envelopes, the ref
|
|
// search and the orca.yaml hooks).
|
|
|
|
// Pinned to the host's own union through hostUnionArms: an arm added or dropped host-side fails tsc.
|
|
export const SSH_CONNECTION_STATUS = hostUnionArms<SshConnectionStatus>({
|
|
disconnected: true,
|
|
connecting: true,
|
|
'auth-failed': true,
|
|
'deploying-relay': true,
|
|
connected: true,
|
|
reconnecting: true,
|
|
'reconnection-failed': true,
|
|
error: true
|
|
})
|
|
|
|
const sourceText = (name: string) => salvagedOptional(name, z.string())
|
|
|
|
/**
|
|
* The SSH connection record, answered under a `state` member by both `ssh.connect` and
|
|
* `ssh.getState`.
|
|
*
|
|
* Only `targetId` and `status` are required, and only those two are read: the gate matches
|
|
* `state.targetId` against the repo's connection id and tests `state.status`
|
|
* (workspace-ssh-gate.ts:58-60). `error` is read as `matchingState?.error ?? null` (:65), already
|
|
* guarded and null-collapsed, and nothing anywhere reads `reconnectAttempt`.
|
|
*
|
|
* They are optional rather than required BECAUSE nothing reads them. `state` is a
|
|
* `salvagedOptional`, so one bad member drops the whole record, and the connect path's fallback for
|
|
* a dropped record is `fallbackSshState(connectionId, 'connected', null)`
|
|
* (use-new-workspace-execution-target.ts:126) — a reply of
|
|
* `{ targetId, status: 'auth-failed', error: 'bad key' }` with `reconnectAttempt` omitted would
|
|
* show the drawer as CONNECTED. Requiring a member no reader touches converts a partial record into
|
|
* the most dangerous verdict this schema can reach, so the two unread members degrade individually
|
|
* and the record survives with the status it came with.
|
|
*
|
|
* `status` is an OPEN enum whose eight arms are SshConnectionStatus verbatim, arm for arm
|
|
* (src/shared/ssh-types.ts:167-175), so nothing the current host can send degrades at all. It is a
|
|
* wire surface (remote-wire-compatibility.md rule 4), so an arm a newer host adds must not refuse
|
|
* the record or drop it; it degrades to `'disconnected'`.
|
|
*
|
|
* That degrade is allowed only because it is invisible to every reader of `status`. Main passed an
|
|
* unknown arm through as a raw string, and the one function that turns it into text,
|
|
* workspaceSshStatusLabel, falls through to `return 'Disconnected'` (workspace-ssh-gate.ts:50) —
|
|
* the same label the degraded value produces. isWorkspaceSshConnectInProgress (:24-:26) answers false
|
|
* for both, the readiness gate is an equality test against `'connected'`
|
|
* (use-mobile-tasks-workspace-ssh-state.tsx:88/:97, use-new-workspace-execution-target.ts:50)
|
|
* which both fail, and `error` is read off the record untouched. The parity is pinned in
|
|
* workspace-source-reply-schema.test.ts rather than argued here.
|
|
*
|
|
* The whole member stays nullable and optional because that is what the two call sites read:
|
|
* `state ?? fallback…` at use-new-workspace-execution-target.ts:66 and :126.
|
|
*
|
|
* `providerEpoch`, `supportsFolderDownload` and `remotePlatform` are NOT declared. No mobile code
|
|
* reads any of the three; the file-mutation owner check asks ssh.getState through a reader of its
|
|
* own (src/files/mobile-file-ownership-operations.ts:24), not this one. The loose object forwards
|
|
* them verbatim either way. Listing a member ahead of its reader is how a schema starts refusing
|
|
* replies no consumer would have noticed.
|
|
*/
|
|
export const sshConnectionStateSchema = z
|
|
.looseObject({
|
|
state: salvagedOptional(
|
|
'state',
|
|
z
|
|
.looseObject({
|
|
targetId: z.string(),
|
|
status: openEnum(SSH_CONNECTION_STATUS, 'disconnected'),
|
|
error: salvagedOptional('error', z.string().nullable()),
|
|
reconnectAttempt: salvagedOptional('reconnectAttempt', z.number()),
|
|
connectionGeneration: salvagedOptional('connectionGeneration', z.number())
|
|
})
|
|
.nullable()
|
|
)
|
|
})
|
|
.transform((reply) => reply.state)
|
|
|
|
/**
|
|
* The agent ids a host reports, local or remote.
|
|
*
|
|
* A bare array of strings, which is what both handlers return. The drawer builds a `Set` from it
|
|
* (use-mobile-tasks-workspace-ssh-state.tsx:131, use-new-workspace-execution-target.ts:99), so a
|
|
* non-iterable payload was a TypeError inside a `.then` and a number reply was a silent
|
|
* `Set { 7 }` that matched no agent. A non-string element drops rather than failing the probe:
|
|
* every reader compares the id to a known agent, so a dropped element and a kept non-string agree
|
|
* on every verdict, and the drop is the one that says so in the salvage report.
|
|
*/
|
|
export const detectedAgentIdsSchema = salvagingArray(z.string())
|
|
|
|
/**
|
|
* The repo's orca.yaml hooks.
|
|
*
|
|
* Nothing is required. use-mobile-tasks-workspace-ssh-state.tsx:196 spells
|
|
* `result.hooks?.scripts?.setup?.trim()`, :204 defaults `setupRunPolicy`, and
|
|
* `normalizeSetupHookTrust` rejects a `setupTrust` without both members — and the recorded
|
|
* `tw-workspace-ssh-not-ready` reply is `{ hooks: { scripts: {} } }` with no `source` and no
|
|
* policy at all, so a requirement on either would refuse a reply main handled. `setupTrust` is
|
|
* nullable because `components-setup-ask` records an explicit `null` there.
|
|
*
|
|
* `setupRunPolicy` stays a plain string. :205 tests it against `'ask'` and :209 against
|
|
* `'run-by-default'`, so an unknown policy already lands on the `skip` arm; closing the set would
|
|
* drop it to the schema's fallback instead and change which arm a newer host reaches.
|
|
*/
|
|
export const repoSetupHooksSchema = z.looseObject({
|
|
hooks: salvagedOptional(
|
|
'hooks',
|
|
z
|
|
.looseObject({
|
|
scripts: salvagedOptional('scripts', z.looseObject({ setup: sourceText('setup') }))
|
|
})
|
|
.nullable()
|
|
),
|
|
source: salvagedOptional('source', z.string().nullable()),
|
|
setupRunPolicy: sourceText('setupRunPolicy'),
|
|
setupTrust: salvagedOptional(
|
|
'setupTrust',
|
|
z
|
|
.looseObject({
|
|
contentHash: sourceText('contentHash'),
|
|
scriptContent: sourceText('scriptContent')
|
|
})
|
|
.nullable()
|
|
)
|
|
})
|
|
|
|
/**
|
|
* One saved sparse-checkout preset.
|
|
*
|
|
* `id`, `name` and `directories` are required, and all three are read with no guard:
|
|
* `id` selects and dedupes (use-mobile-tasks-workspace-sparse-actions.tsx:93/:98), `name` is sorted
|
|
* with `left.name.localeCompare(right.name)` (mobile-tasks-project-workspace-types.ts:127) and
|
|
* lowercased in the drawer (use-mobile-tasks-workspace-create-projection.tsx:118), and
|
|
* `directories` is joined twice (mobile-tasks-workspace-option-pickers.tsx:171,
|
|
* use-mobile-tasks-workspace-sparse-actions.tsx:60). All three are non-optional on SparsePreset
|
|
* (src/shared/worktree/create-types.ts:82-89) and all three are in the recorded preset. A row
|
|
* missing one drops out of the list instead of throwing inside a `useMemo`.
|
|
*
|
|
* The sort is worth naming: `Array.prototype.sort` skips the comparator on a one-element array, so
|
|
* a single bad preset renders fine and two do not.
|
|
*
|
|
* `repoId`, `createdAt` and `updatedAt` are declared non-optional by SparsePreset but absent from
|
|
* the recorded preset (`tw-workspace-source-presets`,
|
|
* `{ id: 'p1', name: 'docs', directories: ['docs'] }`), so they are typed and optional —
|
|
* defaulting them would put numbers in the drawer's recorded state that main never had.
|
|
*/
|
|
const sparsePreset = z.looseObject({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
directories: salvagingArray(z.string()),
|
|
repoId: sourceText('repoId'),
|
|
createdAt: salvagedOptional('createdAt', z.number()),
|
|
updatedAt: salvagedOptional('updatedAt', z.number())
|
|
})
|
|
|
|
/**
|
|
* The preset list, answered under `presets`. Required, because
|
|
* use-mobile-tasks-workspace-source-effects.tsx:62 reads `presets.some(...)` off whatever the
|
|
* member read answered.
|
|
*
|
|
* What a refusal does here is worth stating plainly, because "reports the named error" overstates
|
|
* it: the error goes to `setWorkspaceSparsePresetsError`, whose value is destructured as
|
|
* `_workspaceSparsePresetsError` (use-mobile-tasks-workspace-and-project-state.tsx:57) and read by
|
|
* nobody, on this branch and on main alike. The only visible effect is `presetsLoaded` staying
|
|
* false, which disables "New preset" (mobile-tasks-workspace-option-pickers.tsx:190/:194) and both
|
|
* draft entry points (use-mobile-tasks-workspace-sparse-actions.tsx:34/:51). Main set
|
|
* `presetsLoaded: true` over an empty list and let the user create one. No shipped host reaches
|
|
* that state: `repo.sparsePresets` has no refusal arm and answers
|
|
* `{ presets: await runtime.listSparsePresets(...) }` unconditionally
|
|
* (src/main/runtime/rpc/methods/repo.ts:87-91).
|
|
*/
|
|
export const repoSparsePresetListSchema = z
|
|
.looseObject({ presets: salvagingArray(sparsePreset) })
|
|
.transform((reply) => reply.presets)
|
|
|
|
/**
|
|
* The preset a save answers with.
|
|
*
|
|
* Optional, and deliberately: `tw-workspace-sparse-missing-preset` records the host answering
|
|
* `{}`, which main turned into its own "Failed to save sparse preset." error. That path is kept.
|
|
*/
|
|
export const repoSparsePresetSaveSchema = z
|
|
.looseObject({ preset: salvagedOptional('preset', sparsePreset) })
|
|
.transform((reply) => reply.preset)
|
|
|
|
/**
|
|
* Base-branch search.
|
|
*
|
|
* Neither member is required: both call sites spell the same
|
|
* `refDetails ?? refs.map(…)` fallback (use-mobile-tasks-workspace-source-effects.tsx:128,
|
|
* smart-source-search-requests.ts:108), and the two recorded replies carry one member each —
|
|
* `{ refs: [...] }` in `tw-workspace-source-presets` and `{ refDetails: [...] }` in
|
|
* `tw-smart-search-gitlab-provider-error`. The fallback stays at the call sites, where it was;
|
|
* what the schema adds is that a `refs` full of numbers no longer reaches the picker as rows
|
|
* whose `refName` renders as a number.
|
|
*/
|
|
export const repoBaseRefSearchSchema = z.looseObject({
|
|
refs: salvagedOptional('refs', salvagingArray(z.string())),
|
|
refDetails: salvagedOptional(
|
|
'refDetails',
|
|
salvagingArray(z.looseObject({ refName: z.string(), localBranchName: z.string() }))
|
|
)
|
|
})
|