fix(deps): root and mobile resolve one zod, and CI fails if they drift (#31260)

* fix(deps): root and mobile resolve one zod, and CI fails if they drift

Mobile type-checks src/shared zod schemas. With mobile on zod 4.6.5 and the
root on 4.6.7, TypeScript treated them as unrelated packages, so wrapped
schemas inferred {} (tests/e2e/helpers/phone-feed-list.ts:154) whenever a
job installed mobile deps.

* fix(deps): guard every package mobile's tsconfig remaps, not only zod

* test(ci): pin the shared-dependency version check to the static phase
This commit is contained in:
Neil
2026-10-20 19:12:29 -07:00
committed by GitHub
parent 0c2b01367e
commit a308277f9a
11 changed files with 1460 additions and 1332 deletions
+4
View File
@@ -432,6 +432,10 @@ jobs:
if: needs.code_paths.outputs.static_analysis == 'true'
run: node config/scripts/check-node-runtime-pin.mjs
- name: Check root and mobile lock the same shared dependencies
if: needs.code_paths.outputs.static_analysis == 'true'
run: node config/scripts/check-mobile-shared-dependency-versions.mjs
# Why: extraction writes sorted evidence to an isolated temporary path,
# so feature PRs need one normalized AST pass rather than a three-OS matrix.
# Use preflight's pinned Bun even when the optional local pin differs.
@@ -0,0 +1,58 @@
#!/usr/bin/env node
// Root and mobile are separate pnpm projects, so nothing else stops their lockfiles drifting apart.
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { lockedVersion, lockfileRootImporter } from './pnpm-lockfile-root-importer.mjs'
// The packages src/shared imports that mobile/tsconfig.json remaps to mobile's copy. TypeScript
// merges two copies only at the same version; else zod-wrapped schemas infer `{}` (phone-feed-list.ts).
export const SHARED_DEPENDENCIES = ['zod', 'tweetnacl', '@noble/hashes', '@types/ws']
function importerVersion(importer, name) {
return lockedVersion(importer.dependencies?.[name] ?? importer.devDependencies?.[name])
}
export function findSharedDependencyVersionProblems({
rootImporter,
mobileImporter,
names = SHARED_DEPENDENCIES
}) {
const problems = []
for (const name of names) {
const root = importerVersion(rootImporter, name)
const mobile = importerVersion(mobileImporter, name)
if (!root || !mobile) {
problems.push(`${name} is not a direct dependency of ${root ? 'mobile' : 'the root'}`)
} else if (root !== mobile) {
problems.push(`${name}: the root locks ${root}, mobile locks ${mobile}`)
}
}
return problems
}
export function main(root = resolve(import.meta.dirname, '../..')) {
const readImporter = (lockfile) =>
lockfileRootImporter(readFileSync(join(root, lockfile), 'utf8'))
const problems = findSharedDependencyVersionProblems({
rootImporter: readImporter('pnpm-lock.yaml'),
mobileImporter: readImporter(join('mobile', 'pnpm-lock.yaml'))
})
if (problems.length > 0) {
console.error('Root and mobile must lock the same version of:')
for (const problem of problems) {
console.error(`- ${problem}`)
}
console.error(
'Set the same range in package.json and mobile/package.json, then pnpm install in both.'
)
return 1
}
console.log(`Root and mobile lock the same ${SHARED_DEPENDENCIES.join(', ')}.`)
return 0
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(main())
}
@@ -0,0 +1,55 @@
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import {
findSharedDependencyVersionProblems,
main
} from './check-mobile-shared-dependency-versions.mjs'
const projectDir = path.resolve(import.meta.dirname, '../..')
const locked = (version) => ({ dependencies: { zod: { specifier: '~4.6.0', version } } })
const checkZod = (rootImporter, mobileImporter) =>
findSharedDependencyVersionProblems({ rootImporter, mobileImporter, names: ['zod'] })
describe('findSharedDependencyVersionProblems', () => {
it('passes when both lockfiles resolve the same version', () => {
expect(checkZod(locked('4.6.7'), locked('4.6.7'))).toEqual([])
})
it('reports two resolved versions', () => {
expect(checkZod(locked('4.6.7'), locked('4.6.5'))).toEqual([
'zod: the root locks 4.6.7, mobile locks 4.6.5'
])
})
it('ignores peer and patch suffixes and reads devDependencies', () => {
expect(
checkZod(
{ devDependencies: { zod: { version: '4.6.7(typescript@6.0.3)' } } },
locked('4.6.7(patch_hash=abc)')
)
).toEqual([])
})
it('reports a side that stopped declaring the dependency', () => {
expect(checkZod(locked('4.6.7'), {})).toEqual(['zod is not a direct dependency of mobile'])
expect(checkZod({}, locked('4.6.7'))).toEqual(['zod is not a direct dependency of the root'])
})
it('checks every shared dependency by default', () => {
const problems = findSharedDependencyVersionProblems({
rootImporter: locked('4.6.7'),
mobileImporter: locked('4.6.7')
})
expect(problems).toEqual([
'tweetnacl is not a direct dependency of the root',
'@noble/hashes is not a direct dependency of the root',
'@types/ws is not a direct dependency of the root'
])
})
})
describe('committed lockfiles', () => {
it('pass the repository check', () => {
expect(main(projectDir)).toBe(0)
})
})
+1 -19
View File
@@ -5,8 +5,8 @@ import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { parseAllDocuments } from 'yaml'
import { nodeDistArchiveName, windowsImportLibFile } from './node-dist-archive-name.mjs'
import { lockedVersion, lockfileRootImporter } from './pnpm-lockfile-root-importer.mjs'
// Why require: an ESM import of a .ts file under a typeless package.json prints MODULE_TYPELESS_PACKAGE_JSON.
const {
@@ -25,24 +25,6 @@ function majorOf(range) {
return match ? Number(match[1]) : null
}
/** Strips pnpm's peer suffix: `43.7.5(supports-color@7.2.0)` -> `43.7.5`. */
function lockedVersion(entry) {
const version = typeof entry === 'string' ? entry : entry?.version
return typeof version === 'string' ? version.replace(/\(.*$/, '') : null
}
/** pnpm 12 splits the lockfile into a package-manager document and the project one; merge both. */
export function lockfileRootImporter(contents) {
const importer = {}
for (const document of parseAllDocuments(contents)) {
if (document.errors.length) {
throw document.errors[0]
}
Object.assign(importer, document.toJS()?.importers?.['.'])
}
return importer
}
function findAssetTableProblems({ pin, assets, targets, table, targetList }) {
const problems = []
const expected = new Set(targets)
+1 -29
View File
@@ -7,11 +7,7 @@ import {
NODE_RUNTIME_PIN,
SERVER_TARGETS
} from '../../src/shared/node-runtime-pin.ts'
import {
findNodeRuntimePinProblems,
lockfileRootImporter,
main
} from './check-node-runtime-pin.mjs'
import { findNodeRuntimePinProblems, main } from './check-node-runtime-pin.mjs'
const projectDir = path.resolve(import.meta.dirname, '../..')
const HASH = 'a'.repeat(64)
@@ -186,30 +182,6 @@ describe('findNodeRuntimePinProblems', () => {
})
})
describe('lockfileRootImporter', () => {
it('merges the root importer across pnpm 12 lockfile documents', () => {
const contents = [
'---',
"lockfileVersion: '9.0'",
'importers:',
' .:',
' packageManagerDependencies: {}',
'---',
"lockfileVersion: '9.0'",
'importers:',
' .:',
' devDependencies:',
' electron:',
' specifier: 43.7.5',
' version: 43.7.5(supports-color@7.2.0)',
''
].join('\n')
expect(lockfileRootImporter(contents).devDependencies.electron.version).toBe(
'43.7.5(supports-color@7.2.0)'
)
})
})
describe('committed pin', () => {
it('passes the repository check', () => {
expect(main(projectDir)).toBe(0)
@@ -0,0 +1,19 @@
import { parseAllDocuments } from 'yaml'
/** Strips pnpm's peer suffix: `43.7.5(supports-color@7.2.0)` -> `43.7.5`. */
export function lockedVersion(entry) {
const version = typeof entry === 'string' ? entry : entry?.version
return typeof version === 'string' ? version.replace(/\(.*$/, '') : null
}
/** pnpm 12 splits the lockfile into a package-manager document and the project one; merge both. */
export function lockfileRootImporter(contents) {
const importer = {}
for (const document of parseAllDocuments(contents)) {
if (document.errors.length) {
throw document.errors[0]
}
Object.assign(importer, document.toJS()?.importers?.['.'])
}
return importer
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { lockedVersion, lockfileRootImporter } from './pnpm-lockfile-root-importer.mjs'
describe('lockfileRootImporter', () => {
it('merges the root importer across pnpm 12 lockfile documents', () => {
const contents = [
'---',
"lockfileVersion: '9.0'",
'importers:',
' .:',
' packageManagerDependencies: {}',
'---',
"lockfileVersion: '9.0'",
'importers:',
' .:',
' devDependencies:',
' electron:',
' specifier: 43.7.5',
' version: 43.7.5(supports-color@7.2.0)',
''
].join('\n')
expect(lockfileRootImporter(contents).devDependencies.electron.version).toBe(
'43.7.5(supports-color@7.2.0)'
)
})
})
describe('lockedVersion', () => {
it('strips the peer suffix from an entry or a bare string', () => {
expect(lockedVersion({ version: '4.6.7(typescript@6.0.3)' })).toBe('4.6.7')
expect(lockedVersion('8.22.0(patch_hash=abc)')).toBe('8.22.0')
expect(lockedVersion(undefined)).toBeNull()
})
})
@@ -260,6 +260,7 @@ it('pins every foreground and background step to its selected phase', () => {
['Enforce id-only store write ratchet', staticPhase],
['Enforce repo host-read ratchet', staticPhase],
['Check Node runtime pin', staticPhase],
['Check root and mobile lock the same shared dependencies', staticPhase],
['Decide whether the orcad terminal smoke runs', staticPhase],
['Verify the generated RPC params catalog', staticPhase],
['Verify the generated ACP protocol schema', staticPhase],
+1 -1
View File
@@ -69,7 +69,7 @@
"react-native-worklets": "^0.8.3",
"remend": "1.4.0",
"tweetnacl": "^1.0.3",
"zod": "~4.6.5"
"zod": "~4.6.7"
},
"devDependencies": {
"@orca/process-host": "link:../src/packages/process-host",
+1284 -1282
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -14,7 +14,7 @@
"audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src",
"test:perf:contracts": "vitest run --config config/vitest.performance.config.ts",
"format": "oxfmt --write .",
"lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:focus-read-ratchet && pnpm run check:layout-writer-ratchets && pnpm run check:path-literal-ratchet && pnpm run check:legacy-relay-fence-ratchet && pnpm run check:unproven-pty-retirement-ratchet && pnpm run check:id-only-store-write-ratchet && pnpm run check:repo-host-read-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage && pnpm run check:process-host-imports && pnpm run check:main-sync-spawn-ratchet && pnpm run check:compat-boundary-imports",
"lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:focus-read-ratchet && pnpm run check:layout-writer-ratchets && pnpm run check:path-literal-ratchet && pnpm run check:legacy-relay-fence-ratchet && pnpm run check:unproven-pty-retirement-ratchet && pnpm run check:id-only-store-write-ratchet && pnpm run check:repo-host-read-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run check:mobile-shared-dependency-versions && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage && pnpm run check:process-host-imports && pnpm run check:main-sync-spawn-ratchet && pnpm run check:compat-boundary-imports",
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
@@ -52,6 +52,7 @@
"check:daemon-protocol-crossing": "node config/scripts/check-daemon-protocol-crossing.mjs",
"check:runtime-launcher-protocol-ratchet": "node config/scripts/check-runtime-launcher-protocol-ratchet.mjs",
"check:node-runtime-pin": "node config/scripts/check-node-runtime-pin.mjs",
"check:mobile-shared-dependency-versions": "node config/scripts/check-mobile-shared-dependency-versions.mjs",
"build:orcad": "pnpm run build:packages && node config/scripts/build-orcad-node.mjs",
"build:orcad-template": "pnpm run build:packages && node config/scripts/build-orcad-template.mjs",
"build:orcad-prebuilds": "pnpm run build:packages && node config/scripts/build-orcad-prebuilds.mjs",