Files
Michał Pierzchała 04e4c23b95 dx(check): fail fast when node_modules lags the lockfile (#1967)
* dx(doctor): flag a worktree whose node_modules lags the lockfile

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

* 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.

* refactor(check): keep stale-install probe worktree-local
2026-08-22 17:01:04 +02:00

86 lines
3.6 KiB
TypeScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
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 {
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), { status: 'in-sync' });
});
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), { status: 'out-of-sync', reason: 'stale' });
});
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), {
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');
assert.equal(fs.existsSync(path.join(root, 'node_modules')), false);
assert.deepEqual(checkLockfileInstallSync(root), {
status: 'out-of-sync',
reason: 'install-missing',
});
});
test('reports no-source-checkout when there is no pnpm-lock.yaml', () => {
const root = mkdtempForTestSync('agent-device-lockfile-sync-packaged-');
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 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('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');
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), { status: 'in-sync' });
assert.deepEqual(checkLockfileInstallSync(worktreeB), { status: 'out-of-sync', reason: 'stale' });
});