From 433d75f2219b910f25337f66441467e601b5c8d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 22 Aug 2026 14:12:30 +0200 Subject: [PATCH 1/3] dx(doctor): flag a worktree whose node_modules lags the lockfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a doctor probe and a check:affected preflight that compare node_modules/.pnpm/lock.yaml (the exact lockfile snapshot pnpm installed from) against pnpm-lock.yaml via a content hash — no subprocess. On mismatch both surfaces report the same one-liner: "node_modules was installed from a different lockfile; run pnpm install", so a stale install names its own cause instead of surfacing as bogus format diffs on files a change never touched (the #1956 incident). Closes #1963 --- scripts/check-affected/run.test.ts | 105 ++++++++++++++++++ scripts/check-affected/run.ts | 32 +++++- .../session-doctor-node-modules.test.ts | 66 +++++++++++ .../handlers/session-doctor-node-modules.ts | 33 ++++++ src/daemon/handlers/session-doctor.ts | 2 + src/utils/lockfile-install-sync.test.ts | 76 +++++++++++++ src/utils/lockfile-install-sync.ts | 60 ++++++++++ 7 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 src/daemon/handlers/__tests__/session-doctor-node-modules.test.ts create mode 100644 src/daemon/handlers/session-doctor-node-modules.ts create mode 100644 src/utils/lockfile-install-sync.test.ts create mode 100644 src/utils/lockfile-install-sync.ts diff --git a/scripts/check-affected/run.test.ts b/scripts/check-affected/run.test.ts index ac4a743eaf..bf3e9c3639 100644 --- a/scripts/check-affected/run.test.ts +++ b/scripts/check-affected/run.test.ts @@ -9,6 +9,7 @@ import os from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; import { runCmdSync } from '../../src/utils/exec.ts'; +import { STALE_NODE_MODULES_MESSAGE } from '../../src/utils/lockfile-install-sync.ts'; import { CHECK_CATALOG } from './checks.ts'; import { DEFAULT_VITEST_MAX_WORKERS } from '../lib/vitest-concurrency.ts'; import { selectChecks } from './model.ts'; @@ -247,3 +248,107 @@ test('runChecks leaves coverage to CI and runs capped related tests once', async 'the coverage gate is GitHub-authoritative and must not run locally', ); }); + +test('runChecks fails fast on a stale install before running format or any other check', async () => { + const executed: string[][] = []; + const execute: CommandExecutor = async (command) => { + executed.push(command); + return 0; + }; + const plan = selectChecks({ + changedFiles: ['packages/selectors/src/index.ts'], + packageEntryFiles: [], + }); + const code = await runChecks(plan, { scripts: ALL_SCRIPTS }, ARGS, { + execute, + cwd: '.', + checkLockfileSync: () => ({ inSync: false, reason: 'stale' }), + }); + assert.equal(code, 1); + assert.deepEqual(executed, [], 'no check — including format — may run against a stale install'); +}); + +test('runChecks fails fast when node_modules was never installed in this checkout', async () => { + const executed: string[][] = []; + const execute: CommandExecutor = async (command) => { + executed.push(command); + return 0; + }; + const plan = selectChecks({ + changedFiles: ['packages/selectors/src/index.ts'], + packageEntryFiles: [], + }); + const code = await runChecks(plan, { scripts: ALL_SCRIPTS }, ARGS, { + execute, + cwd: '.', + checkLockfileSync: () => ({ inSync: false, reason: 'install-missing' }), + }); + assert.equal(code, 1); + assert.deepEqual(executed, []); +}); + +test('runChecks does not block on a missing lockfile — that is a different failure than a stale install', async () => { + const executed: string[][] = []; + const execute: CommandExecutor = async (command) => { + executed.push(command); + return 0; + }; + const plan = selectChecks({ + changedFiles: ['packages/selectors/src/index.ts'], + packageEntryFiles: [], + }); + const code = await runChecks(plan, { scripts: ALL_SCRIPTS }, ARGS, { + execute, + cwd: '.', + checkLockfileSync: () => ({ inSync: false, reason: 'lockfile-missing' }), + }); + assert.equal(code, 0); + assert.ok(executed.length > 0, 'checks still run when there is nothing to compare against'); +}); + +test('runChecks proceeds normally when the install is in sync', async () => { + const executed: string[][] = []; + const execute: CommandExecutor = async (command) => { + executed.push(command); + return 0; + }; + const plan = selectChecks({ + changedFiles: ['packages/selectors/src/index.ts'], + packageEntryFiles: [], + }); + const code = await runChecks(plan, { scripts: ALL_SCRIPTS }, ARGS, { + execute, + cwd: '.', + checkLockfileSync: () => ({ inSync: true }), + }); + assert.equal(code, 0); + assert.ok(executed.some((command) => command.includes('format:check'))); +}); + +test('runChecks names the real cause on stderr instead of surfacing as an unrelated failure', async () => { + const stderrChunks: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array) => { + stderrChunks.push(chunk.toString()); + return true; + }) as typeof process.stderr.write; + + try { + const plan = selectChecks({ + changedFiles: ['packages/selectors/src/index.ts'], + packageEntryFiles: [], + }); + const code = await runChecks(plan, { scripts: ALL_SCRIPTS }, ARGS, { + execute: async () => 0, + cwd: '.', + checkLockfileSync: () => ({ inSync: false, reason: 'stale' }), + }); + assert.equal(code, 1); + assert.ok( + stderrChunks.some((chunk) => chunk.includes(STALE_NODE_MODULES_MESSAGE)), + `expected stderr to name the stale-install cause, got: ${stderrChunks.join('')}`, + ); + } finally { + process.stderr.write = originalWrite; + } +}); diff --git a/scripts/check-affected/run.ts b/scripts/check-affected/run.ts index d31f5bf2a9..f00104f266 100644 --- a/scripts/check-affected/run.ts +++ b/scripts/check-affected/run.ts @@ -9,6 +9,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { runCmdStreaming, runCmdSync } from '../../src/utils/exec.ts'; +import { + checkLockfileInstallSync, + STALE_NODE_MODULES_MESSAGE, + type LockfileInstallSyncResult, +} from '../../src/utils/lockfile-install-sync.ts'; import { parseScriptArgs } from '../lib/cli-args.ts'; import { runEntrypoint } from '../lib/cli-entrypoint.ts'; import { @@ -184,9 +189,24 @@ export async function runChecks( plan: CheckPlan, pkg: PackageJson, args: Args, - options: { cwd?: string; execute?: CommandExecutor; changedFiles?: readonly string[] } = {}, + options: { + cwd?: string; + execute?: CommandExecutor; + changedFiles?: readonly string[]; + checkLockfileSync?: (cwd: string) => LockfileInstallSyncResult; + } = {}, ): Promise { const cwd = options.cwd ?? repoRoot; + const checkLockfileSync = options.checkLockfileSync ?? checkLockfileInstallSync; + // Fast preflight, before format (first in the catalog) or any other check gets a + // chance to run: a stale install (#1956) makes oxfmt/oxlint/tsc misbehave in ways + // that look like real diffs/failures on files the change never touched. Naming the + // real cause here means an agent never "fixes" formatting that was never wrong. + const lockfileSync = checkLockfileSync(cwd); + if (!lockfileSync.inSync && lockfileSync.reason !== 'lockfile-missing') { + reportStaleInstall(lockfileSync.reason); + return 1; + } const execute = options.execute ?? streamingExecutor; const runnable = plan.checks.map(getCheckSpec).filter((spec: CheckSpec) => spec.localRunnable); const skipped = plan.checks.map(getCheckSpec).filter((spec: CheckSpec) => !spec.localRunnable); @@ -212,6 +232,16 @@ export async function runChecks( return 0; } +function reportStaleInstall(reason: 'install-missing' | 'stale'): void { + process.stderr.write(`\ncheck:affected: ${STALE_NODE_MODULES_MESSAGE}\n`); + process.stderr.write( + reason === 'install-missing' + ? '(no node_modules/.pnpm/lock.yaml found — this checkout was never installed)\n' + : '(node_modules/.pnpm/lock.yaml disagrees with pnpm-lock.yaml)\n', + ); + process.stderr.write('Run `agent-device doctor` for more detail.\n'); +} + // Where a check the local run skips is authoritative. A parked check has no automatic // lane; say so instead of printing an empty job list. function describeOwner(id: CheckId, ciJobs: ReadonlyMap): string { diff --git a/src/daemon/handlers/__tests__/session-doctor-node-modules.test.ts b/src/daemon/handlers/__tests__/session-doctor-node-modules.test.ts new file mode 100644 index 0000000000..eb5de73e14 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-doctor-node-modules.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { STALE_NODE_MODULES_MESSAGE } from '../../../utils/lockfile-install-sync.ts'; +import { nodeModulesLockfileCheck } from '../session-doctor-node-modules.ts'; + +function writeLockfile(root: string, content: string): void { + fs.writeFileSync(path.join(root, 'pnpm-lock.yaml'), content); +} + +function writeInstalledSnapshot(root: string, content: string): void { + const dir = path.join(root, 'node_modules', '.pnpm'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'lock.yaml'), content); +} + +test('node-modules doctor check passes when the installed snapshot matches the checked-out lockfile', () => { + const root = mkdtempForTestSync('agent-device-doctor-node-modules-sync-'); + writeLockfile(root, 'lockfileVersion: 9.0\n'); + writeInstalledSnapshot(root, 'lockfileVersion: 9.0\n'); + + const check = nodeModulesLockfileCheck(root); + assert.equal(check.id, 'node-modules'); + assert.equal(check.status, 'pass'); + assert.match(check.summary, /matches pnpm-lock\.yaml/); +}); + +test('node-modules doctor check fails with the stale-install message when hashes disagree', () => { + const root = mkdtempForTestSync('agent-device-doctor-node-modules-stale-'); + writeLockfile(root, 'lockfileVersion: 9.0\nfoo: bar\n'); + writeInstalledSnapshot(root, 'lockfileVersion: 9.0\n'); + + const check = nodeModulesLockfileCheck(root); + assert.equal(check.status, 'fail'); + assert.equal(check.summary, STALE_NODE_MODULES_MESSAGE); + assert.equal(check.command, 'pnpm install'); + assert.deepEqual(check.evidence, { repoRoot: root, reason: 'stale' }); +}); + +test('node-modules doctor check fails with the stale-install message when node_modules was never installed here', () => { + const root = mkdtempForTestSync('agent-device-doctor-node-modules-missing-install-'); + writeLockfile(root, 'lockfileVersion: 9.0\n'); + + const check = nodeModulesLockfileCheck(root); + assert.equal(check.status, 'fail'); + assert.equal(check.summary, STALE_NODE_MODULES_MESSAGE); + assert.deepEqual(check.evidence, { repoRoot: root, reason: 'install-missing' }); +}); + +test('node-modules doctor check stays informational when the checkout has no lockfile at all', () => { + const root = mkdtempForTestSync('agent-device-doctor-node-modules-missing-lockfile-'); + + const check = nodeModulesLockfileCheck(root); + assert.equal(check.status, 'warn'); + assert.match(check.summary, /pnpm-lock\.yaml not found/); +}); + +test("node-modules doctor check defaults to this checkout's own root, matching real doctor wiring", () => { + // session-doctor.ts calls nodeModulesLockfileCheck() with no argument; this repo's own + // install is expected to be in sync while the suite runs (pnpm install was run for it). + const check = nodeModulesLockfileCheck(); + assert.equal(check.id, 'node-modules'); + assert.equal(check.status, 'pass'); +}); diff --git a/src/daemon/handlers/session-doctor-node-modules.ts b/src/daemon/handlers/session-doctor-node-modules.ts new file mode 100644 index 0000000000..f54079cd46 --- /dev/null +++ b/src/daemon/handlers/session-doctor-node-modules.ts @@ -0,0 +1,33 @@ +import { + checkLockfileInstallSync, + STALE_NODE_MODULES_MESSAGE, +} from '../../utils/lockfile-install-sync.ts'; +import { findProjectRoot } from '../../utils/version.ts'; +import type { DoctorCheck } from '@agent-device/contracts/observability'; + +export function nodeModulesLockfileCheck(repoRoot: string = findProjectRoot()): DoctorCheck { + const result = checkLockfileInstallSync(repoRoot); + if (result.inSync) { + return { + id: 'node-modules', + status: 'pass', + summary: 'node_modules matches pnpm-lock.yaml.', + }; + } + if (result.reason === 'lockfile-missing') { + return { + id: 'node-modules', + status: 'warn', + summary: 'pnpm-lock.yaml not found; cannot verify node_modules is in sync.', + evidence: { repoRoot, reason: result.reason }, + }; + } + return { + id: 'node-modules', + status: 'fail', + summary: STALE_NODE_MODULES_MESSAGE, + hint: 'Run this from every worktree whose node_modules might have drifted, not just the main checkout.', + command: 'pnpm install', + evidence: { repoRoot, reason: result.reason }, + }; +} diff --git a/src/daemon/handlers/session-doctor.ts b/src/daemon/handlers/session-doctor.ts index 9645bd6bce..d3c3558422 100644 --- a/src/daemon/handlers/session-doctor.ts +++ b/src/daemon/handlers/session-doctor.ts @@ -15,6 +15,7 @@ import { resolveDoctorDeviceForAppCheck, } from './session-doctor-device.ts'; import { probeMetro } from './session-doctor-metro.ts'; +import { nodeModulesLockfileCheck } from './session-doctor-node-modules.ts'; import { readDoctorOptions, remoteConnectionChecks, @@ -65,6 +66,7 @@ export async function handleDoctorCommand(params: { summary: `agent-device ${readVersion()} using ${stateDir}`, evidence: { version: readVersion(), stateDir }, }, + nodeModulesLockfileCheck(), ...remoteConnectionChecks(req, { required: options.remote }), ...sessionChecks(sessionStore, sessionName, session, { remote: options.remote }), ); diff --git a/src/utils/lockfile-install-sync.test.ts b/src/utils/lockfile-install-sync.test.ts new file mode 100644 index 0000000000..c6ab0d73bc --- /dev/null +++ b/src/utils/lockfile-install-sync.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { mkdtempForTestSync } from '../__tests__/test-utils/tmp-dir.ts'; +import { checkLockfileInstallSync } from './lockfile-install-sync.ts'; + +function writeLockfile(root: string, content: string): void { + fs.writeFileSync(path.join(root, 'pnpm-lock.yaml'), content); +} + +function writeInstalledSnapshot(root: string, content: string): void { + const dir = path.join(root, 'node_modules', '.pnpm'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'lock.yaml'), content); +} + +test('reports in sync when the installed snapshot byte-matches the checked-out lockfile', () => { + const root = mkdtempForTestSync('agent-device-lockfile-sync-match-'); + writeLockfile(root, 'lockfileVersion: 9.0\nimporters:\n .: {}\n'); + writeInstalledSnapshot(root, 'lockfileVersion: 9.0\nimporters:\n .: {}\n'); + + assert.deepEqual(checkLockfileInstallSync(root), { inSync: true }); +}); + +test('reports stale when the installed snapshot content differs from the checked-out lockfile', () => { + const root = mkdtempForTestSync('agent-device-lockfile-sync-stale-'); + writeLockfile( + root, + 'lockfileVersion: 9.0\nimporters:\n .:\n dependencies:\n newDep: 1.0.0\n', + ); + writeInstalledSnapshot(root, 'lockfileVersion: 9.0\nimporters:\n .: {}\n'); + + assert.deepEqual(checkLockfileInstallSync(root), { inSync: false, reason: 'stale' }); +}); + +test('reports install-missing when node_modules/.pnpm/lock.yaml does not exist', () => { + const root = mkdtempForTestSync('agent-device-lockfile-sync-no-install-'); + writeLockfile(root, 'lockfileVersion: 9.0\n'); + + assert.deepEqual(checkLockfileInstallSync(root), { inSync: false, reason: 'install-missing' }); +}); + +test('reports install-missing for a fresh worktree that has no node_modules directory at all', () => { + const root = mkdtempForTestSync('agent-device-lockfile-sync-fresh-worktree-'); + writeLockfile(root, 'lockfileVersion: 9.0\n'); + // Deliberately no node_modules directory — the exact state of a brand new + // `git worktree add`, before its first `pnpm install`. + assert.equal(fs.existsSync(path.join(root, 'node_modules')), false); + + assert.deepEqual(checkLockfileInstallSync(root), { inSync: false, reason: 'install-missing' }); +}); + +test('reports lockfile-missing when the checkout has no pnpm-lock.yaml', () => { + const root = mkdtempForTestSync('agent-device-lockfile-sync-no-lockfile-'); + writeInstalledSnapshot(root, 'lockfileVersion: 9.0\n'); + + assert.deepEqual(checkLockfileInstallSync(root), { inSync: false, reason: 'lockfile-missing' }); +}); + +test('is not fooled by two worktrees sharing the same repo but different lockfile states', () => { + // The failure mode from #1963: a worktree's own node_modules must be compared against + // that same worktree's own pnpm-lock.yaml, never another checkout's. + const worktreeA = mkdtempForTestSync('agent-device-lockfile-sync-worktree-a-'); + const worktreeB = mkdtempForTestSync('agent-device-lockfile-sync-worktree-b-'); + writeLockfile(worktreeA, 'lockfileVersion: 9.0\nimporters:\n .: {}\n'); + writeInstalledSnapshot(worktreeA, 'lockfileVersion: 9.0\nimporters:\n .: {}\n'); + writeLockfile( + worktreeB, + 'lockfileVersion: 9.0\nimporters:\n .:\n dependencies:\n newDep: 1.0.0\n', + ); + writeInstalledSnapshot(worktreeB, 'lockfileVersion: 9.0\nimporters:\n .: {}\n'); + + assert.deepEqual(checkLockfileInstallSync(worktreeA), { inSync: true }); + assert.deepEqual(checkLockfileInstallSync(worktreeB), { inSync: false, reason: 'stale' }); +}); diff --git a/src/utils/lockfile-install-sync.ts b/src/utils/lockfile-install-sync.ts new file mode 100644 index 0000000000..65a4cd40aa --- /dev/null +++ b/src/utils/lockfile-install-sync.ts @@ -0,0 +1,60 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +// pnpm writes the exact lockfile snapshot it resolved from into +// node_modules/.pnpm/lock.yaml on every install, and re-checks it before deciding +// whether it can skip re-resolution (e.g. under --frozen-lockfile). Hashing that +// snapshot and comparing it against the checkout's pnpm-lock.yaml is the same +// staleness signal pnpm itself relies on, without shelling out to pnpm or hand-parsing +// node_modules/.modules.yaml (which, as of pnpm 11, carries no lockfile hash field — +// confirmed by inspecting a real .modules.yaml in this checkout). +const LOCKFILE_BASENAME = 'pnpm-lock.yaml'; +const INSTALLED_SNAPSHOT_RELATIVE_PATH = ['node_modules', '.pnpm', 'lock.yaml']; + +// Shared verbatim between the `doctor` node-modules probe +// (src/daemon/handlers/session-doctor-node-modules.ts) and the check:affected preflight +// (scripts/check-affected/run.ts), so a stale install names the same cause on both +// surfaces instead of drifting into two different wordings over time. +export const STALE_NODE_MODULES_MESSAGE = + 'node_modules was installed from a different lockfile; run pnpm install'; + +export type LockfileInstallSyncResult = + | { readonly inSync: true } + | { + readonly inSync: false; + // 'lockfile-missing': no pnpm-lock.yaml in the checkout — a broken checkout, not a + // stale install, but the caller has nothing to compare against either way. + // 'install-missing': no node_modules/.pnpm/lock.yaml — never installed here, which + // is exactly the "fresh worktree" trap: without its own install, module resolution + // silently walks up to another checkout's node_modules. + // 'stale': both files exist but their contents (and therefore hashes) disagree — + // node_modules was installed from a different pnpm-lock.yaml than the one checked out. + readonly reason: 'lockfile-missing' | 'install-missing' | 'stale'; + }; + +/** + * Compares the lockfile a checkout's node_modules was installed from against the + * lockfile currently checked out, using a content hash of each — no subprocess. + * Works from any worktree: both paths are resolved under the given repoRoot, so a + * worktree's own node_modules is checked against its own pnpm-lock.yaml, never another + * checkout's. + */ +export function checkLockfileInstallSync(repoRoot: string): LockfileInstallSyncResult { + const lockfileHash = hashFileIfExists(path.join(repoRoot, LOCKFILE_BASENAME)); + if (!lockfileHash) return { inSync: false, reason: 'lockfile-missing' }; + + const installedSnapshotHash = hashFileIfExists( + path.join(repoRoot, ...INSTALLED_SNAPSHOT_RELATIVE_PATH), + ); + if (!installedSnapshotHash) return { inSync: false, reason: 'install-missing' }; + + return lockfileHash === installedSnapshotHash + ? { inSync: true } + : { inSync: false, reason: 'stale' }; +} + +function hashFileIfExists(filePath: string): string | undefined { + if (!fs.existsSync(filePath)) return undefined; + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); +} From 3f02d56a8ad8567f6eba634a2c1f4baa5fe9b314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 22 Aug 2026 15:53:39 +0200 Subject: [PATCH 2/3] fix(doctor): scope the node-modules probe to a local source checkout The probe ran unconditionally from findProjectRoot(), so it fired in two contexts it cannot diagnose: - Packaged installs. Published packages ship neither pnpm-lock.yaml (not in the package.json `files` allowlist) nor an installed snapshot, so every end user's `doctor` gained a spurious node-modules line and a degraded overall status. - `--remote`, where the daemon's own root describes the server deployment rather than the caller's worktree, so the answer could not address #1963 at all. Whether a root is a source checkout is now decided by the presence of pnpm-lock.yaml itself rather than a heuristic about install location, and 'no-source-checkout' is a distinct result rather than a warning, so the packaged case cannot be represented as a defect. The probe returns undefined there and the route appends no check, matching how doctor already models an out-of-scope question (the device family is likewise absent under --remote). The fresh-worktree catch is preserved: a lockfile with no installed snapshot is still a hard failure. The check:affected preflight is unchanged in behavior. Route-level assertions cover all three contexts (source, packaged, remote); each was verified to fail against the pre-fix wiring. --- scripts/check-affected/run.test.ts | 12 +- scripts/check-affected/run.ts | 2 +- .../session-doctor-node-modules.test.ts | 39 +++--- .../handlers/session-doctor-node-modules.ts | 31 +++-- src/daemon/handlers/session-doctor.ts | 9 +- src/utils/lockfile-install-sync.test.ts | 41 +++++-- src/utils/lockfile-install-sync.ts | 35 ++++-- .../provider-scenarios/doctor.test.ts | 111 +++++++++++++++++- 8 files changed, 221 insertions(+), 59 deletions(-) diff --git a/scripts/check-affected/run.test.ts b/scripts/check-affected/run.test.ts index bf3e9c3639..c5862b01ee 100644 --- a/scripts/check-affected/run.test.ts +++ b/scripts/check-affected/run.test.ts @@ -262,7 +262,7 @@ test('runChecks fails fast on a stale install before running format or any other const code = await runChecks(plan, { scripts: ALL_SCRIPTS }, ARGS, { execute, cwd: '.', - checkLockfileSync: () => ({ inSync: false, reason: 'stale' }), + checkLockfileSync: () => ({ status: 'out-of-sync', reason: 'stale' }), }); assert.equal(code, 1); assert.deepEqual(executed, [], 'no check — including format — may run against a stale install'); @@ -281,13 +281,13 @@ test('runChecks fails fast when node_modules was never installed in this checkou const code = await runChecks(plan, { scripts: ALL_SCRIPTS }, ARGS, { execute, cwd: '.', - checkLockfileSync: () => ({ inSync: false, reason: 'install-missing' }), + checkLockfileSync: () => ({ status: 'out-of-sync', reason: 'install-missing' }), }); assert.equal(code, 1); assert.deepEqual(executed, []); }); -test('runChecks does not block on a missing lockfile — that is a different failure than a stale install', async () => { +test('runChecks does not block when there is no source checkout to compare against', async () => { const executed: string[][] = []; const execute: CommandExecutor = async (command) => { executed.push(command); @@ -300,7 +300,7 @@ test('runChecks does not block on a missing lockfile — that is a different fai const code = await runChecks(plan, { scripts: ALL_SCRIPTS }, ARGS, { execute, cwd: '.', - checkLockfileSync: () => ({ inSync: false, reason: 'lockfile-missing' }), + checkLockfileSync: () => ({ status: 'no-source-checkout' }), }); assert.equal(code, 0); assert.ok(executed.length > 0, 'checks still run when there is nothing to compare against'); @@ -319,7 +319,7 @@ test('runChecks proceeds normally when the install is in sync', async () => { const code = await runChecks(plan, { scripts: ALL_SCRIPTS }, ARGS, { execute, cwd: '.', - checkLockfileSync: () => ({ inSync: true }), + checkLockfileSync: () => ({ status: 'in-sync' }), }); assert.equal(code, 0); assert.ok(executed.some((command) => command.includes('format:check'))); @@ -341,7 +341,7 @@ test('runChecks names the real cause on stderr instead of surfacing as an unrela const code = await runChecks(plan, { scripts: ALL_SCRIPTS }, ARGS, { execute: async () => 0, cwd: '.', - checkLockfileSync: () => ({ inSync: false, reason: 'stale' }), + checkLockfileSync: () => ({ status: 'out-of-sync', reason: 'stale' }), }); assert.equal(code, 1); assert.ok( diff --git a/scripts/check-affected/run.ts b/scripts/check-affected/run.ts index f00104f266..425a0be7ba 100644 --- a/scripts/check-affected/run.ts +++ b/scripts/check-affected/run.ts @@ -203,7 +203,7 @@ export async function runChecks( // that look like real diffs/failures on files the change never touched. Naming the // real cause here means an agent never "fixes" formatting that was never wrong. const lockfileSync = checkLockfileSync(cwd); - if (!lockfileSync.inSync && lockfileSync.reason !== 'lockfile-missing') { + if (lockfileSync.status === 'out-of-sync') { reportStaleInstall(lockfileSync.reason); return 1; } diff --git a/src/daemon/handlers/__tests__/session-doctor-node-modules.test.ts b/src/daemon/handlers/__tests__/session-doctor-node-modules.test.ts index eb5de73e14..3e438f954d 100644 --- a/src/daemon/handlers/__tests__/session-doctor-node-modules.test.ts +++ b/src/daemon/handlers/__tests__/session-doctor-node-modules.test.ts @@ -22,9 +22,9 @@ test('node-modules doctor check passes when the installed snapshot matches the c writeInstalledSnapshot(root, 'lockfileVersion: 9.0\n'); const check = nodeModulesLockfileCheck(root); - assert.equal(check.id, 'node-modules'); - assert.equal(check.status, 'pass'); - assert.match(check.summary, /matches pnpm-lock\.yaml/); + assert.equal(check?.id, 'node-modules'); + assert.equal(check?.status, 'pass'); + assert.match(check?.summary ?? '', /matches pnpm-lock\.yaml/); }); test('node-modules doctor check fails with the stale-install message when hashes disagree', () => { @@ -33,34 +33,37 @@ test('node-modules doctor check fails with the stale-install message when hashes writeInstalledSnapshot(root, 'lockfileVersion: 9.0\n'); const check = nodeModulesLockfileCheck(root); - assert.equal(check.status, 'fail'); - assert.equal(check.summary, STALE_NODE_MODULES_MESSAGE); - assert.equal(check.command, 'pnpm install'); - assert.deepEqual(check.evidence, { repoRoot: root, reason: 'stale' }); + assert.equal(check?.status, 'fail'); + assert.equal(check?.summary, STALE_NODE_MODULES_MESSAGE); + assert.equal(check?.command, 'pnpm install'); + assert.deepEqual(check?.evidence, { repoRoot: root, reason: 'stale' }); }); -test('node-modules doctor check fails with the stale-install message when node_modules was never installed here', () => { +test('node-modules doctor check fails when a source checkout was never installed', () => { const root = mkdtempForTestSync('agent-device-doctor-node-modules-missing-install-'); writeLockfile(root, 'lockfileVersion: 9.0\n'); const check = nodeModulesLockfileCheck(root); - assert.equal(check.status, 'fail'); - assert.equal(check.summary, STALE_NODE_MODULES_MESSAGE); - assert.deepEqual(check.evidence, { repoRoot: root, reason: 'install-missing' }); + assert.equal(check?.status, 'fail'); + assert.equal(check?.summary, STALE_NODE_MODULES_MESSAGE); + assert.deepEqual(check?.evidence, { repoRoot: root, reason: 'install-missing' }); }); -test('node-modules doctor check stays informational when the checkout has no lockfile at all', () => { - const root = mkdtempForTestSync('agent-device-doctor-node-modules-missing-lockfile-'); +test('node-modules doctor check is omitted entirely for a packaged install with no source checkout', () => { + // The defect this guards: a published agent-device ships neither pnpm-lock.yaml nor an + // installed snapshot, so an unconditional probe gave every end user a spurious + // node-modules line and dragged the overall doctor status down with it. + const root = mkdtempForTestSync('agent-device-doctor-node-modules-packaged-'); + fs.mkdirSync(path.join(root, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(root, 'package.json'), '{"name":"agent-device"}\n'); - const check = nodeModulesLockfileCheck(root); - assert.equal(check.status, 'warn'); - assert.match(check.summary, /pnpm-lock\.yaml not found/); + assert.equal(nodeModulesLockfileCheck(root), undefined); }); test("node-modules doctor check defaults to this checkout's own root, matching real doctor wiring", () => { // session-doctor.ts calls nodeModulesLockfileCheck() with no argument; this repo's own // install is expected to be in sync while the suite runs (pnpm install was run for it). const check = nodeModulesLockfileCheck(); - assert.equal(check.id, 'node-modules'); - assert.equal(check.status, 'pass'); + assert.equal(check?.id, 'node-modules'); + assert.equal(check?.status, 'pass'); }); diff --git a/src/daemon/handlers/session-doctor-node-modules.ts b/src/daemon/handlers/session-doctor-node-modules.ts index f54079cd46..ac7783a079 100644 --- a/src/daemon/handlers/session-doctor-node-modules.ts +++ b/src/daemon/handlers/session-doctor-node-modules.ts @@ -5,23 +5,34 @@ import { import { findProjectRoot } from '../../utils/version.ts'; import type { DoctorCheck } from '@agent-device/contracts/observability'; -export function nodeModulesLockfileCheck(repoRoot: string = findProjectRoot()): DoctorCheck { +/** + * The stale-install probe for #1963, scoped to what it can actually diagnose. + * + * Returns `undefined` — no check line at all — when the root holds no source checkout. + * A published agent-device ships neither pnpm-lock.yaml nor an installed snapshot, so + * every packaged `doctor` run would otherwise carry a lockfile line that means nothing + * to an end user and would drag the overall status down with it. Doctor already models + * an out-of-scope question as an absent check rather than an informational one: under + * `--remote` the whole device-inventory family is omitted, and the route tests assert + * that absence. This follows that vocabulary. + * + * The caller is responsible for the other scope gate: this must not run under `--remote`, + * where the daemon's own root would describe the server's deployment rather than the + * caller's worktree. session-doctor.ts enforces that by calling this only after the + * remote branch has already returned. + */ +export function nodeModulesLockfileCheck( + repoRoot: string = findProjectRoot(), +): DoctorCheck | undefined { const result = checkLockfileInstallSync(repoRoot); - if (result.inSync) { + if (result.status === 'no-source-checkout') return undefined; + if (result.status === 'in-sync') { return { id: 'node-modules', status: 'pass', summary: 'node_modules matches pnpm-lock.yaml.', }; } - if (result.reason === 'lockfile-missing') { - return { - id: 'node-modules', - status: 'warn', - summary: 'pnpm-lock.yaml not found; cannot verify node_modules is in sync.', - evidence: { repoRoot, reason: result.reason }, - }; - } return { id: 'node-modules', status: 'fail', diff --git a/src/daemon/handlers/session-doctor.ts b/src/daemon/handlers/session-doctor.ts index d3c3558422..f7fa478861 100644 --- a/src/daemon/handlers/session-doctor.ts +++ b/src/daemon/handlers/session-doctor.ts @@ -66,7 +66,6 @@ export async function handleDoctorCommand(params: { summary: `agent-device ${readVersion()} using ${stateDir}`, evidence: { version: readVersion(), stateDir }, }, - nodeModulesLockfileCheck(), ...remoteConnectionChecks(req, { required: options.remote }), ...sessionChecks(sessionStore, sessionName, session, { remote: options.remote }), ); @@ -75,6 +74,14 @@ export async function handleDoctorCommand(params: { return doctorResponse(checks, options); } + // Local-only from here down. The worktree/lockfile probe must stay below this + // return: under --remote the daemon's own root describes the server deployment, + // not the caller's checkout, so the answer could not address #1963 anyway. The + // probe also returns undefined when there is no source checkout (a packaged + // install), leaving no check line rather than a meaningless one. + const nodeModules = nodeModulesLockfileCheck(); + if (nodeModules) appendDoctorCheck(checks, nodeModules); + const inventory = await appendDeviceInventoryCheck(checks, req, session); await appendToolchainChecks(checks, session?.device.platform ?? inventory?.platform); const appCheckDevice = await appendLocalDoctorChecks({ diff --git a/src/utils/lockfile-install-sync.test.ts b/src/utils/lockfile-install-sync.test.ts index c6ab0d73bc..33f0a414ae 100644 --- a/src/utils/lockfile-install-sync.test.ts +++ b/src/utils/lockfile-install-sync.test.ts @@ -20,7 +20,7 @@ test('reports in sync when the installed snapshot byte-matches the checked-out l writeLockfile(root, 'lockfileVersion: 9.0\nimporters:\n .: {}\n'); writeInstalledSnapshot(root, 'lockfileVersion: 9.0\nimporters:\n .: {}\n'); - assert.deepEqual(checkLockfileInstallSync(root), { inSync: true }); + assert.deepEqual(checkLockfileInstallSync(root), { status: 'in-sync' }); }); test('reports stale when the installed snapshot content differs from the checked-out lockfile', () => { @@ -31,31 +31,52 @@ test('reports stale when the installed snapshot content differs from the checked ); writeInstalledSnapshot(root, 'lockfileVersion: 9.0\nimporters:\n .: {}\n'); - assert.deepEqual(checkLockfileInstallSync(root), { inSync: false, reason: 'stale' }); + assert.deepEqual(checkLockfileInstallSync(root), { status: 'out-of-sync', reason: 'stale' }); }); -test('reports install-missing when node_modules/.pnpm/lock.yaml does not exist', () => { +test('reports install-missing when a source checkout has no installed snapshot', () => { const root = mkdtempForTestSync('agent-device-lockfile-sync-no-install-'); writeLockfile(root, 'lockfileVersion: 9.0\n'); - assert.deepEqual(checkLockfileInstallSync(root), { inSync: false, reason: 'install-missing' }); + assert.deepEqual(checkLockfileInstallSync(root), { + status: 'out-of-sync', + reason: 'install-missing', + }); }); test('reports install-missing for a fresh worktree that has no node_modules directory at all', () => { const root = mkdtempForTestSync('agent-device-lockfile-sync-fresh-worktree-'); writeLockfile(root, 'lockfileVersion: 9.0\n'); // Deliberately no node_modules directory — the exact state of a brand new - // `git worktree add`, before its first `pnpm install`. + // `git worktree add`, before its first `pnpm install`. This must stay a real + // finding: it is the resolve-to-main-checkout trap #1963 names. assert.equal(fs.existsSync(path.join(root, 'node_modules')), false); - assert.deepEqual(checkLockfileInstallSync(root), { inSync: false, reason: 'install-missing' }); + assert.deepEqual(checkLockfileInstallSync(root), { + status: 'out-of-sync', + reason: 'install-missing', + }); }); -test('reports lockfile-missing when the checkout has no pnpm-lock.yaml', () => { +test('reports no-source-checkout when there is no pnpm-lock.yaml — the packaged-install shape', () => { + // A published agent-device: package.json `files` ships bin/ and dist/ but never the + // lockfile, and an npm tarball never carries node_modules. Neither file is present, + // and the answer must be "not applicable" rather than any kind of defect. + const root = mkdtempForTestSync('agent-device-lockfile-sync-packaged-'); + fs.mkdirSync(path.join(root, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(root, 'package.json'), '{"name":"agent-device"}\n'); + + assert.deepEqual(checkLockfileInstallSync(root), { status: 'no-source-checkout' }); +}); + +test('reports no-source-checkout even when a node_modules snapshot exists without a lockfile', () => { + // The lockfile is the discriminator, not the snapshot: a consuming project could have + // its own node_modules around the packaged install without that making our root a + // source checkout to diagnose. const root = mkdtempForTestSync('agent-device-lockfile-sync-no-lockfile-'); writeInstalledSnapshot(root, 'lockfileVersion: 9.0\n'); - assert.deepEqual(checkLockfileInstallSync(root), { inSync: false, reason: 'lockfile-missing' }); + assert.deepEqual(checkLockfileInstallSync(root), { status: 'no-source-checkout' }); }); test('is not fooled by two worktrees sharing the same repo but different lockfile states', () => { @@ -71,6 +92,6 @@ test('is not fooled by two worktrees sharing the same repo but different lockfil ); writeInstalledSnapshot(worktreeB, 'lockfileVersion: 9.0\nimporters:\n .: {}\n'); - assert.deepEqual(checkLockfileInstallSync(worktreeA), { inSync: true }); - assert.deepEqual(checkLockfileInstallSync(worktreeB), { inSync: false, reason: 'stale' }); + assert.deepEqual(checkLockfileInstallSync(worktreeA), { status: 'in-sync' }); + assert.deepEqual(checkLockfileInstallSync(worktreeB), { status: 'out-of-sync', reason: 'stale' }); }); diff --git a/src/utils/lockfile-install-sync.ts b/src/utils/lockfile-install-sync.ts index 65a4cd40aa..4a29b17a7b 100644 --- a/src/utils/lockfile-install-sync.ts +++ b/src/utils/lockfile-install-sync.ts @@ -20,38 +20,49 @@ export const STALE_NODE_MODULES_MESSAGE = 'node_modules was installed from a different lockfile; run pnpm install'; export type LockfileInstallSyncResult = - | { readonly inSync: true } + // The root holds no pnpm-lock.yaml, so there is no source checkout here to diagnose. + // A published agent-device install is exactly this: package.json `files` ships bin/, + // dist/ and the helper artifacts, never the lockfile, and an npm tarball never carries + // node_modules. Callers must treat this as "question does not apply", not as a defect + // — reporting a stale install here would fire on every end user's packaged `doctor`. + | { readonly status: 'no-source-checkout' } + | { readonly status: 'in-sync' } | { - readonly inSync: false; - // 'lockfile-missing': no pnpm-lock.yaml in the checkout — a broken checkout, not a - // stale install, but the caller has nothing to compare against either way. - // 'install-missing': no node_modules/.pnpm/lock.yaml — never installed here, which - // is exactly the "fresh worktree" trap: without its own install, module resolution - // silently walks up to another checkout's node_modules. + readonly status: 'out-of-sync'; + // 'install-missing': pnpm-lock.yaml is present but node_modules/.pnpm/lock.yaml is + // not — a source checkout that was never installed, which is exactly the fresh-worktree + // trap: without its own install, module resolution silently walks up to another + // checkout's node_modules. // 'stale': both files exist but their contents (and therefore hashes) disagree — // node_modules was installed from a different pnpm-lock.yaml than the one checked out. - readonly reason: 'lockfile-missing' | 'install-missing' | 'stale'; + readonly reason: 'install-missing' | 'stale'; }; /** * Compares the lockfile a checkout's node_modules was installed from against the * lockfile currently checked out, using a content hash of each — no subprocess. + * + * Whether this is a source checkout at all is decided by the presence of + * `pnpm-lock.yaml` under `repoRoot`, not by any heuristic about where the code was + * installed from: the lockfile is committed in every worktree and shipped in no + * published package, so its presence is the fact itself rather than a proxy for it. + * * Works from any worktree: both paths are resolved under the given repoRoot, so a * worktree's own node_modules is checked against its own pnpm-lock.yaml, never another * checkout's. */ export function checkLockfileInstallSync(repoRoot: string): LockfileInstallSyncResult { const lockfileHash = hashFileIfExists(path.join(repoRoot, LOCKFILE_BASENAME)); - if (!lockfileHash) return { inSync: false, reason: 'lockfile-missing' }; + if (!lockfileHash) return { status: 'no-source-checkout' }; const installedSnapshotHash = hashFileIfExists( path.join(repoRoot, ...INSTALLED_SNAPSHOT_RELATIVE_PATH), ); - if (!installedSnapshotHash) return { inSync: false, reason: 'install-missing' }; + if (!installedSnapshotHash) return { status: 'out-of-sync', reason: 'install-missing' }; return lockfileHash === installedSnapshotHash - ? { inSync: true } - : { inSync: false, reason: 'stale' }; + ? { status: 'in-sync' } + : { status: 'out-of-sync', reason: 'stale' }; } function hashFileIfExists(filePath: string): string | undefined { diff --git a/test/integration/provider-scenarios/doctor.test.ts b/test/integration/provider-scenarios/doctor.test.ts index 1cd84f3329..837bcfad62 100644 --- a/test/integration/provider-scenarios/doctor.test.ts +++ b/test/integration/provider-scenarios/doctor.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import http from 'node:http'; -import { test } from 'vitest'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; import type { AndroidAdbProvider } from '../../../src/platforms/android/adb-executor.ts'; import { assertRpcOk } from './assertions.ts'; import { @@ -17,6 +18,43 @@ import { withProviderScenarioTempDir, } from './harness.ts'; +// The node-modules probe reads the root that `findProjectRoot()` resolves to. Left alone +// it is this source checkout, which is what the source-context assertion below wants; the +// packaged assertion points it at a directory shaped like a published install instead, so +// the real detection runs against a real packaged filesystem rather than a stubbed verdict. +const projectRootState = vi.hoisted(() => ({ override: undefined as string | undefined })); + +vi.mock('../../../src/utils/version.ts', async () => { + const actual = await vi.importActual( + '../../../src/utils/version.ts', + ); + return { + ...actual, + findProjectRoot: () => projectRootState.override ?? actual.findProjectRoot(), + }; +}); + +afterEach(() => { + projectRootState.override = undefined; +}); + +/** A published agent-device on disk: bin/ and dist/, no pnpm-lock.yaml, no node_modules. */ +function layOutPackagedInstall(root: string): string { + const packageRoot = path.join(root, 'node_modules', 'agent-device'); + fs.mkdirSync(path.join(packageRoot, 'dist', 'src'), { recursive: true }); + fs.mkdirSync(path.join(packageRoot, 'bin'), { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, 'package.json'), + `${JSON.stringify({ name: 'agent-device', version: '0.0.0-test' })}\n`, + ); + assert.equal( + fs.existsSync(path.join(packageRoot, 'pnpm-lock.yaml')), + false, + 'sanity: a packaged install ships no lockfile', + ); + return packageRoot; +} + test('Provider-backed integration doctor infers Android RN/Metro readiness through daemon route without resolving a default device', async () => { const server = await startMetroStatusServer(); const adbCalls: string[][] = []; @@ -290,6 +328,77 @@ test('Provider-backed integration doctor surfaces a platform inventory failure e ); }); +test('Provider-backed integration doctor reports the node-modules probe in a source checkout', async () => { + // The #1963 value, asserted at the route: from a real source worktree the probe is + // present and passing. Without this, the two omission assertions below could both hold + // for the trivial reason that the probe never runs anywhere. + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }), + async (daemon) => { + const response = await daemon.callCommand('doctor', [], { platform: 'android' }); + assertRpcOk(response); + const data = response.json.result.data; + assertDoctorCheck(data, 'node-modules', 'pass'); + }, + ); +}); + +test('Provider-backed integration doctor omits the node-modules probe for a packaged install', async () => { + // A published agent-device ships neither pnpm-lock.yaml nor an installed snapshot, so an + // unconditional probe gave every end user a spurious node-modules line and dragged the + // overall status to warn. The probe must be absent, and the status must not be degraded. + await withProviderScenarioTempDir( + 'agent-device-doctor-packaged-', + async (root) => + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }), + async (daemon) => { + projectRootState.override = layOutPackagedInstall(root); + + const response = await daemon.callCommand('doctor', [], { platform: 'android' }); + assertRpcOk(response); + const data = response.json.result.data; + assertNoDoctorCheck(data, 'node-modules'); + assert.equal(data.status, 'pass', JSON.stringify(data.checks)); + assert.equal( + data.checks.some((check: { summary: string }) => /lockfile/i.test(check.summary)), + false, + `no packaged check may mention a lockfile: ${JSON.stringify(data.checks)}`, + ); + }, + ), + ); +}); + +test('Provider-backed integration doctor --remote omits the node-modules probe', async () => { + // --remote diagnoses the caller's connection to a remote daemon; the daemon's own root + // would describe the server deployment rather than the caller's worktree, so the probe + // cannot answer #1963 there. The project root is left as this real source checkout, so a + // missing gate would surface the check rather than silently agreeing with the assertion. + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }), + async (daemon) => { + const response = await daemon.callCommand('doctor', [], { + remote: true, + daemonBaseUrl: 'https://example.invalid/agent-device', + }); + assertRpcOk(response); + const data = response.json.result.data; + assert.equal(data.status, 'pass', JSON.stringify(data.checks)); + assertNoDoctorCheck(data, 'node-modules'); + }, + ); +}); + function writePackageJson(dir: string, value: Record): void { fs.writeFileSync(`${dir}/package.json`, `${JSON.stringify(value)}\n`); } From 33030f61711b86b502f138c545287be15c80a22d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 22 Aug 2026 16:57:03 +0200 Subject: [PATCH 3/3] refactor(check): keep stale-install probe worktree-local --- package.json | 2 +- .../lockfile-install-sync.test.ts | 22 +--- .../check-affected/lockfile-install-sync.ts | 39 ++++++ scripts/check-affected/run.test.ts | 6 +- scripts/check-affected/run.ts | 14 +-- .../session-doctor-node-modules.test.ts | 69 ----------- .../handlers/session-doctor-node-modules.ts | 44 ------- src/daemon/handlers/session-doctor.ts | 9 -- src/utils/lockfile-install-sync.ts | 71 ----------- .../provider-scenarios/doctor.test.ts | 111 +----------------- 10 files changed, 57 insertions(+), 330 deletions(-) rename {src/utils => scripts/check-affected}/lockfile-install-sync.test.ts (73%) create mode 100644 scripts/check-affected/lockfile-install-sync.ts delete mode 100644 src/daemon/handlers/__tests__/session-doctor-node-modules.test.ts delete mode 100644 src/daemon/handlers/session-doctor-node-modules.ts delete mode 100644 src/utils/lockfile-install-sync.ts diff --git a/package.json b/package.json index e6442a0535..85f4ea931b 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,7 @@ "fallow:baseline": "fallow dead-code --save-baseline fallow-baselines/dead-code.json --summary && fallow health --report-only --save-baseline fallow-baselines/health.json --summary", "check:fallow": "fallow audit", "check:affected": "node --experimental-strip-types scripts/check-affected/run.ts", - "check:affected:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/platform-packages.test.ts scripts/check-affected/device-lanes.test.ts scripts/check-affected/run.test.ts", + "check:affected:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/platform-packages.test.ts scripts/check-affected/device-lanes.test.ts scripts/check-affected/lockfile-install-sync.test.ts scripts/check-affected/run.test.ts", "gate": "node --experimental-strip-types scripts/gate/run.ts", "check:gate-manifest": "node --experimental-strip-types scripts/gate/check.ts", "check:gate-manifest:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/gate/*.test.ts", diff --git a/src/utils/lockfile-install-sync.test.ts b/scripts/check-affected/lockfile-install-sync.test.ts similarity index 73% rename from src/utils/lockfile-install-sync.test.ts rename to scripts/check-affected/lockfile-install-sync.test.ts index 33f0a414ae..da6076bf12 100644 --- a/src/utils/lockfile-install-sync.test.ts +++ b/scripts/check-affected/lockfile-install-sync.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { test } from 'vitest'; -import { mkdtempForTestSync } from '../__tests__/test-utils/tmp-dir.ts'; +import { test } from 'node:test'; +import { mkdtempForTestSync } from '../../src/__tests__/test-utils/tmp-dir.ts'; import { checkLockfileInstallSync } from './lockfile-install-sync.ts'; function writeLockfile(root: string, content: string): void { @@ -47,9 +47,6 @@ test('reports install-missing when a source checkout has no installed snapshot', test('reports install-missing for a fresh worktree that has no node_modules directory at all', () => { const root = mkdtempForTestSync('agent-device-lockfile-sync-fresh-worktree-'); writeLockfile(root, 'lockfileVersion: 9.0\n'); - // Deliberately no node_modules directory — the exact state of a brand new - // `git worktree add`, before its first `pnpm install`. This must stay a real - // finding: it is the resolve-to-main-checkout trap #1963 names. assert.equal(fs.existsSync(path.join(root, 'node_modules')), false); assert.deepEqual(checkLockfileInstallSync(root), { @@ -58,30 +55,21 @@ test('reports install-missing for a fresh worktree that has no node_modules dire }); }); -test('reports no-source-checkout when there is no pnpm-lock.yaml — the packaged-install shape', () => { - // A published agent-device: package.json `files` ships bin/ and dist/ but never the - // lockfile, and an npm tarball never carries node_modules. Neither file is present, - // and the answer must be "not applicable" rather than any kind of defect. +test('reports no-source-checkout when there is no pnpm-lock.yaml', () => { const root = mkdtempForTestSync('agent-device-lockfile-sync-packaged-'); - fs.mkdirSync(path.join(root, 'dist'), { recursive: true }); fs.writeFileSync(path.join(root, 'package.json'), '{"name":"agent-device"}\n'); assert.deepEqual(checkLockfileInstallSync(root), { status: 'no-source-checkout' }); }); -test('reports no-source-checkout even when a node_modules snapshot exists without a lockfile', () => { - // The lockfile is the discriminator, not the snapshot: a consuming project could have - // its own node_modules around the packaged install without that making our root a - // source checkout to diagnose. +test('reports no-source-checkout even when an installed snapshot exists without a lockfile', () => { const root = mkdtempForTestSync('agent-device-lockfile-sync-no-lockfile-'); writeInstalledSnapshot(root, 'lockfileVersion: 9.0\n'); assert.deepEqual(checkLockfileInstallSync(root), { status: 'no-source-checkout' }); }); -test('is not fooled by two worktrees sharing the same repo but different lockfile states', () => { - // The failure mode from #1963: a worktree's own node_modules must be compared against - // that same worktree's own pnpm-lock.yaml, never another checkout's. +test('checks each worktree against its own lockfile state', () => { const worktreeA = mkdtempForTestSync('agent-device-lockfile-sync-worktree-a-'); const worktreeB = mkdtempForTestSync('agent-device-lockfile-sync-worktree-b-'); writeLockfile(worktreeA, 'lockfileVersion: 9.0\nimporters:\n .: {}\n'); diff --git a/scripts/check-affected/lockfile-install-sync.ts b/scripts/check-affected/lockfile-install-sync.ts new file mode 100644 index 0000000000..209ef9b4e0 --- /dev/null +++ b/scripts/check-affected/lockfile-install-sync.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const LOCKFILE_BASENAME = 'pnpm-lock.yaml'; +const INSTALLED_SNAPSHOT_RELATIVE_PATH = ['node_modules', '.pnpm', 'lock.yaml']; + +export const STALE_NODE_MODULES_MESSAGE = + "node_modules does not match this worktree's pnpm-lock.yaml"; + +export type LockfileInstallSyncResult = + | { readonly status: 'no-source-checkout' } + | { readonly status: 'in-sync' } + | { + readonly status: 'out-of-sync'; + readonly reason: 'install-missing' | 'stale'; + }; + +/** + * Compares pnpm's installed lockfile snapshot with this worktree's lockfile. + * This is intentionally synchronous and subprocess-free: it runs before any gate. + */ +export function checkLockfileInstallSync(repoRoot: string): LockfileInstallSyncResult { + const lockfile = readFileIfExists(path.join(repoRoot, LOCKFILE_BASENAME)); + if (!lockfile) return { status: 'no-source-checkout' }; + + const installedSnapshot = readFileIfExists( + path.join(repoRoot, ...INSTALLED_SNAPSHOT_RELATIVE_PATH), + ); + if (!installedSnapshot) return { status: 'out-of-sync', reason: 'install-missing' }; + + return lockfile.equals(installedSnapshot) + ? { status: 'in-sync' } + : { status: 'out-of-sync', reason: 'stale' }; +} + +function readFileIfExists(filePath: string): Buffer | undefined { + if (!fs.existsSync(filePath)) return undefined; + return fs.readFileSync(filePath); +} diff --git a/scripts/check-affected/run.test.ts b/scripts/check-affected/run.test.ts index c5862b01ee..58402cb696 100644 --- a/scripts/check-affected/run.test.ts +++ b/scripts/check-affected/run.test.ts @@ -9,7 +9,7 @@ import os from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; import { runCmdSync } from '../../src/utils/exec.ts'; -import { STALE_NODE_MODULES_MESSAGE } from '../../src/utils/lockfile-install-sync.ts'; +import { STALE_NODE_MODULES_MESSAGE } from './lockfile-install-sync.ts'; import { CHECK_CATALOG } from './checks.ts'; import { DEFAULT_VITEST_MAX_WORKERS } from '../lib/vitest-concurrency.ts'; import { selectChecks } from './model.ts'; @@ -348,6 +348,10 @@ test('runChecks names the real cause on stderr instead of surfacing as an unrela stderrChunks.some((chunk) => chunk.includes(STALE_NODE_MODULES_MESSAGE)), `expected stderr to name the stale-install cause, got: ${stderrChunks.join('')}`, ); + const stderr = stderrChunks.join(''); + assert.match(stderr, /Worktree: \.\n/); + assert.match(stderr, /pnpm install --frozen-lockfile/); + assert.doesNotMatch(stderr, /agent-device doctor/); } finally { process.stderr.write = originalWrite; } diff --git a/scripts/check-affected/run.ts b/scripts/check-affected/run.ts index 425a0be7ba..7c4d3a2759 100644 --- a/scripts/check-affected/run.ts +++ b/scripts/check-affected/run.ts @@ -13,7 +13,7 @@ import { checkLockfileInstallSync, STALE_NODE_MODULES_MESSAGE, type LockfileInstallSyncResult, -} from '../../src/utils/lockfile-install-sync.ts'; +} from './lockfile-install-sync.ts'; import { parseScriptArgs } from '../lib/cli-args.ts'; import { runEntrypoint } from '../lib/cli-entrypoint.ts'; import { @@ -198,13 +198,10 @@ export async function runChecks( ): Promise { const cwd = options.cwd ?? repoRoot; const checkLockfileSync = options.checkLockfileSync ?? checkLockfileInstallSync; - // Fast preflight, before format (first in the catalog) or any other check gets a - // chance to run: a stale install (#1956) makes oxfmt/oxlint/tsc misbehave in ways - // that look like real diffs/failures on files the change never touched. Naming the - // real cause here means an agent never "fixes" formatting that was never wrong. + // Fail before any gate can misdiagnose a stale worktree install (#1956). const lockfileSync = checkLockfileSync(cwd); if (lockfileSync.status === 'out-of-sync') { - reportStaleInstall(lockfileSync.reason); + reportStaleInstall(lockfileSync.reason, cwd); return 1; } const execute = options.execute ?? streamingExecutor; @@ -232,14 +229,15 @@ export async function runChecks( return 0; } -function reportStaleInstall(reason: 'install-missing' | 'stale'): void { +function reportStaleInstall(reason: 'install-missing' | 'stale', cwd: string): void { process.stderr.write(`\ncheck:affected: ${STALE_NODE_MODULES_MESSAGE}\n`); process.stderr.write( reason === 'install-missing' ? '(no node_modules/.pnpm/lock.yaml found — this checkout was never installed)\n' : '(node_modules/.pnpm/lock.yaml disagrees with pnpm-lock.yaml)\n', ); - process.stderr.write('Run `agent-device doctor` for more detail.\n'); + process.stderr.write(`Worktree: ${cwd}\n`); + process.stderr.write('Run `pnpm install --frozen-lockfile` in this worktree, then retry.\n'); } // Where a check the local run skips is authoritative. A parked check has no automatic diff --git a/src/daemon/handlers/__tests__/session-doctor-node-modules.test.ts b/src/daemon/handlers/__tests__/session-doctor-node-modules.test.ts deleted file mode 100644 index 3e438f954d..0000000000 --- a/src/daemon/handlers/__tests__/session-doctor-node-modules.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { test } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; -import { STALE_NODE_MODULES_MESSAGE } from '../../../utils/lockfile-install-sync.ts'; -import { nodeModulesLockfileCheck } from '../session-doctor-node-modules.ts'; - -function writeLockfile(root: string, content: string): void { - fs.writeFileSync(path.join(root, 'pnpm-lock.yaml'), content); -} - -function writeInstalledSnapshot(root: string, content: string): void { - const dir = path.join(root, 'node_modules', '.pnpm'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'lock.yaml'), content); -} - -test('node-modules doctor check passes when the installed snapshot matches the checked-out lockfile', () => { - const root = mkdtempForTestSync('agent-device-doctor-node-modules-sync-'); - writeLockfile(root, 'lockfileVersion: 9.0\n'); - writeInstalledSnapshot(root, 'lockfileVersion: 9.0\n'); - - const check = nodeModulesLockfileCheck(root); - assert.equal(check?.id, 'node-modules'); - assert.equal(check?.status, 'pass'); - assert.match(check?.summary ?? '', /matches pnpm-lock\.yaml/); -}); - -test('node-modules doctor check fails with the stale-install message when hashes disagree', () => { - const root = mkdtempForTestSync('agent-device-doctor-node-modules-stale-'); - writeLockfile(root, 'lockfileVersion: 9.0\nfoo: bar\n'); - writeInstalledSnapshot(root, 'lockfileVersion: 9.0\n'); - - const check = nodeModulesLockfileCheck(root); - assert.equal(check?.status, 'fail'); - assert.equal(check?.summary, STALE_NODE_MODULES_MESSAGE); - assert.equal(check?.command, 'pnpm install'); - assert.deepEqual(check?.evidence, { repoRoot: root, reason: 'stale' }); -}); - -test('node-modules doctor check fails when a source checkout was never installed', () => { - const root = mkdtempForTestSync('agent-device-doctor-node-modules-missing-install-'); - writeLockfile(root, 'lockfileVersion: 9.0\n'); - - const check = nodeModulesLockfileCheck(root); - assert.equal(check?.status, 'fail'); - assert.equal(check?.summary, STALE_NODE_MODULES_MESSAGE); - assert.deepEqual(check?.evidence, { repoRoot: root, reason: 'install-missing' }); -}); - -test('node-modules doctor check is omitted entirely for a packaged install with no source checkout', () => { - // The defect this guards: a published agent-device ships neither pnpm-lock.yaml nor an - // installed snapshot, so an unconditional probe gave every end user a spurious - // node-modules line and dragged the overall doctor status down with it. - const root = mkdtempForTestSync('agent-device-doctor-node-modules-packaged-'); - fs.mkdirSync(path.join(root, 'dist'), { recursive: true }); - fs.writeFileSync(path.join(root, 'package.json'), '{"name":"agent-device"}\n'); - - assert.equal(nodeModulesLockfileCheck(root), undefined); -}); - -test("node-modules doctor check defaults to this checkout's own root, matching real doctor wiring", () => { - // session-doctor.ts calls nodeModulesLockfileCheck() with no argument; this repo's own - // install is expected to be in sync while the suite runs (pnpm install was run for it). - const check = nodeModulesLockfileCheck(); - assert.equal(check?.id, 'node-modules'); - assert.equal(check?.status, 'pass'); -}); diff --git a/src/daemon/handlers/session-doctor-node-modules.ts b/src/daemon/handlers/session-doctor-node-modules.ts deleted file mode 100644 index ac7783a079..0000000000 --- a/src/daemon/handlers/session-doctor-node-modules.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - checkLockfileInstallSync, - STALE_NODE_MODULES_MESSAGE, -} from '../../utils/lockfile-install-sync.ts'; -import { findProjectRoot } from '../../utils/version.ts'; -import type { DoctorCheck } from '@agent-device/contracts/observability'; - -/** - * The stale-install probe for #1963, scoped to what it can actually diagnose. - * - * Returns `undefined` — no check line at all — when the root holds no source checkout. - * A published agent-device ships neither pnpm-lock.yaml nor an installed snapshot, so - * every packaged `doctor` run would otherwise carry a lockfile line that means nothing - * to an end user and would drag the overall status down with it. Doctor already models - * an out-of-scope question as an absent check rather than an informational one: under - * `--remote` the whole device-inventory family is omitted, and the route tests assert - * that absence. This follows that vocabulary. - * - * The caller is responsible for the other scope gate: this must not run under `--remote`, - * where the daemon's own root would describe the server's deployment rather than the - * caller's worktree. session-doctor.ts enforces that by calling this only after the - * remote branch has already returned. - */ -export function nodeModulesLockfileCheck( - repoRoot: string = findProjectRoot(), -): DoctorCheck | undefined { - const result = checkLockfileInstallSync(repoRoot); - if (result.status === 'no-source-checkout') return undefined; - if (result.status === 'in-sync') { - return { - id: 'node-modules', - status: 'pass', - summary: 'node_modules matches pnpm-lock.yaml.', - }; - } - return { - id: 'node-modules', - status: 'fail', - summary: STALE_NODE_MODULES_MESSAGE, - hint: 'Run this from every worktree whose node_modules might have drifted, not just the main checkout.', - command: 'pnpm install', - evidence: { repoRoot, reason: result.reason }, - }; -} diff --git a/src/daemon/handlers/session-doctor.ts b/src/daemon/handlers/session-doctor.ts index f7fa478861..9645bd6bce 100644 --- a/src/daemon/handlers/session-doctor.ts +++ b/src/daemon/handlers/session-doctor.ts @@ -15,7 +15,6 @@ import { resolveDoctorDeviceForAppCheck, } from './session-doctor-device.ts'; import { probeMetro } from './session-doctor-metro.ts'; -import { nodeModulesLockfileCheck } from './session-doctor-node-modules.ts'; import { readDoctorOptions, remoteConnectionChecks, @@ -74,14 +73,6 @@ export async function handleDoctorCommand(params: { return doctorResponse(checks, options); } - // Local-only from here down. The worktree/lockfile probe must stay below this - // return: under --remote the daemon's own root describes the server deployment, - // not the caller's checkout, so the answer could not address #1963 anyway. The - // probe also returns undefined when there is no source checkout (a packaged - // install), leaving no check line rather than a meaningless one. - const nodeModules = nodeModulesLockfileCheck(); - if (nodeModules) appendDoctorCheck(checks, nodeModules); - const inventory = await appendDeviceInventoryCheck(checks, req, session); await appendToolchainChecks(checks, session?.device.platform ?? inventory?.platform); const appCheckDevice = await appendLocalDoctorChecks({ diff --git a/src/utils/lockfile-install-sync.ts b/src/utils/lockfile-install-sync.ts deleted file mode 100644 index 4a29b17a7b..0000000000 --- a/src/utils/lockfile-install-sync.ts +++ /dev/null @@ -1,71 +0,0 @@ -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; - -// pnpm writes the exact lockfile snapshot it resolved from into -// node_modules/.pnpm/lock.yaml on every install, and re-checks it before deciding -// whether it can skip re-resolution (e.g. under --frozen-lockfile). Hashing that -// snapshot and comparing it against the checkout's pnpm-lock.yaml is the same -// staleness signal pnpm itself relies on, without shelling out to pnpm or hand-parsing -// node_modules/.modules.yaml (which, as of pnpm 11, carries no lockfile hash field — -// confirmed by inspecting a real .modules.yaml in this checkout). -const LOCKFILE_BASENAME = 'pnpm-lock.yaml'; -const INSTALLED_SNAPSHOT_RELATIVE_PATH = ['node_modules', '.pnpm', 'lock.yaml']; - -// Shared verbatim between the `doctor` node-modules probe -// (src/daemon/handlers/session-doctor-node-modules.ts) and the check:affected preflight -// (scripts/check-affected/run.ts), so a stale install names the same cause on both -// surfaces instead of drifting into two different wordings over time. -export const STALE_NODE_MODULES_MESSAGE = - 'node_modules was installed from a different lockfile; run pnpm install'; - -export type LockfileInstallSyncResult = - // The root holds no pnpm-lock.yaml, so there is no source checkout here to diagnose. - // A published agent-device install is exactly this: package.json `files` ships bin/, - // dist/ and the helper artifacts, never the lockfile, and an npm tarball never carries - // node_modules. Callers must treat this as "question does not apply", not as a defect - // — reporting a stale install here would fire on every end user's packaged `doctor`. - | { readonly status: 'no-source-checkout' } - | { readonly status: 'in-sync' } - | { - readonly status: 'out-of-sync'; - // 'install-missing': pnpm-lock.yaml is present but node_modules/.pnpm/lock.yaml is - // not — a source checkout that was never installed, which is exactly the fresh-worktree - // trap: without its own install, module resolution silently walks up to another - // checkout's node_modules. - // 'stale': both files exist but their contents (and therefore hashes) disagree — - // node_modules was installed from a different pnpm-lock.yaml than the one checked out. - readonly reason: 'install-missing' | 'stale'; - }; - -/** - * Compares the lockfile a checkout's node_modules was installed from against the - * lockfile currently checked out, using a content hash of each — no subprocess. - * - * Whether this is a source checkout at all is decided by the presence of - * `pnpm-lock.yaml` under `repoRoot`, not by any heuristic about where the code was - * installed from: the lockfile is committed in every worktree and shipped in no - * published package, so its presence is the fact itself rather than a proxy for it. - * - * Works from any worktree: both paths are resolved under the given repoRoot, so a - * worktree's own node_modules is checked against its own pnpm-lock.yaml, never another - * checkout's. - */ -export function checkLockfileInstallSync(repoRoot: string): LockfileInstallSyncResult { - const lockfileHash = hashFileIfExists(path.join(repoRoot, LOCKFILE_BASENAME)); - if (!lockfileHash) return { status: 'no-source-checkout' }; - - const installedSnapshotHash = hashFileIfExists( - path.join(repoRoot, ...INSTALLED_SNAPSHOT_RELATIVE_PATH), - ); - if (!installedSnapshotHash) return { status: 'out-of-sync', reason: 'install-missing' }; - - return lockfileHash === installedSnapshotHash - ? { status: 'in-sync' } - : { status: 'out-of-sync', reason: 'stale' }; -} - -function hashFileIfExists(filePath: string): string | undefined { - if (!fs.existsSync(filePath)) return undefined; - return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); -} diff --git a/test/integration/provider-scenarios/doctor.test.ts b/test/integration/provider-scenarios/doctor.test.ts index 837bcfad62..1cd84f3329 100644 --- a/test/integration/provider-scenarios/doctor.test.ts +++ b/test/integration/provider-scenarios/doctor.test.ts @@ -1,8 +1,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import http from 'node:http'; -import path from 'node:path'; -import { afterEach, test, vi } from 'vitest'; +import { test } from 'vitest'; import type { AndroidAdbProvider } from '../../../src/platforms/android/adb-executor.ts'; import { assertRpcOk } from './assertions.ts'; import { @@ -18,43 +17,6 @@ import { withProviderScenarioTempDir, } from './harness.ts'; -// The node-modules probe reads the root that `findProjectRoot()` resolves to. Left alone -// it is this source checkout, which is what the source-context assertion below wants; the -// packaged assertion points it at a directory shaped like a published install instead, so -// the real detection runs against a real packaged filesystem rather than a stubbed verdict. -const projectRootState = vi.hoisted(() => ({ override: undefined as string | undefined })); - -vi.mock('../../../src/utils/version.ts', async () => { - const actual = await vi.importActual( - '../../../src/utils/version.ts', - ); - return { - ...actual, - findProjectRoot: () => projectRootState.override ?? actual.findProjectRoot(), - }; -}); - -afterEach(() => { - projectRootState.override = undefined; -}); - -/** A published agent-device on disk: bin/ and dist/, no pnpm-lock.yaml, no node_modules. */ -function layOutPackagedInstall(root: string): string { - const packageRoot = path.join(root, 'node_modules', 'agent-device'); - fs.mkdirSync(path.join(packageRoot, 'dist', 'src'), { recursive: true }); - fs.mkdirSync(path.join(packageRoot, 'bin'), { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, 'package.json'), - `${JSON.stringify({ name: 'agent-device', version: '0.0.0-test' })}\n`, - ); - assert.equal( - fs.existsSync(path.join(packageRoot, 'pnpm-lock.yaml')), - false, - 'sanity: a packaged install ships no lockfile', - ); - return packageRoot; -} - test('Provider-backed integration doctor infers Android RN/Metro readiness through daemon route without resolving a default device', async () => { const server = await startMetroStatusServer(); const adbCalls: string[][] = []; @@ -328,77 +290,6 @@ test('Provider-backed integration doctor surfaces a platform inventory failure e ); }); -test('Provider-backed integration doctor reports the node-modules probe in a source checkout', async () => { - // The #1963 value, asserted at the route: from a real source worktree the probe is - // present and passing. Without this, the two omission assertions below could both hold - // for the trivial reason that the probe never runs anywhere. - await withProviderScenarioResource( - async () => - await createProviderScenarioHarness({ - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }), - async (daemon) => { - const response = await daemon.callCommand('doctor', [], { platform: 'android' }); - assertRpcOk(response); - const data = response.json.result.data; - assertDoctorCheck(data, 'node-modules', 'pass'); - }, - ); -}); - -test('Provider-backed integration doctor omits the node-modules probe for a packaged install', async () => { - // A published agent-device ships neither pnpm-lock.yaml nor an installed snapshot, so an - // unconditional probe gave every end user a spurious node-modules line and dragged the - // overall status to warn. The probe must be absent, and the status must not be degraded. - await withProviderScenarioTempDir( - 'agent-device-doctor-packaged-', - async (root) => - await withProviderScenarioResource( - async () => - await createProviderScenarioHarness({ - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }), - async (daemon) => { - projectRootState.override = layOutPackagedInstall(root); - - const response = await daemon.callCommand('doctor', [], { platform: 'android' }); - assertRpcOk(response); - const data = response.json.result.data; - assertNoDoctorCheck(data, 'node-modules'); - assert.equal(data.status, 'pass', JSON.stringify(data.checks)); - assert.equal( - data.checks.some((check: { summary: string }) => /lockfile/i.test(check.summary)), - false, - `no packaged check may mention a lockfile: ${JSON.stringify(data.checks)}`, - ); - }, - ), - ); -}); - -test('Provider-backed integration doctor --remote omits the node-modules probe', async () => { - // --remote diagnoses the caller's connection to a remote daemon; the daemon's own root - // would describe the server deployment rather than the caller's worktree, so the probe - // cannot answer #1963 there. The project root is left as this real source checkout, so a - // missing gate would surface the check rather than silently agreeing with the assertion. - await withProviderScenarioResource( - async () => - await createProviderScenarioHarness({ - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }), - async (daemon) => { - const response = await daemon.callCommand('doctor', [], { - remote: true, - daemonBaseUrl: 'https://example.invalid/agent-device', - }); - assertRpcOk(response); - const data = response.json.result.data; - assert.equal(data.status, 'pass', JSON.stringify(data.checks)); - assertNoDoctorCheck(data, 'node-modules'); - }, - ); -}); - function writePackageJson(dir: string, value: Record): void { fs.writeFileSync(`${dir}/package.json`, `${JSON.stringify(value)}\n`); }