From 195ef53f31b7b8ac478dfe60f2f185fe59451880 Mon Sep 17 00:00:00 2001 From: Steve Preu Date: Sat, 3 Oct 2026 06:05:03 -0700 Subject: [PATCH] fix: preserve launcher resolution errors on uncertain probes Fail closed on filesystem probe errors without replacing the original installer diagnostic. Preserve and test global lookup semantics. Add real malformed-path and controlled permission regressions for issue #135. --- .agents/pm/history/ops-85at.jsonl | 6 ++ .agents/pm/issues/ops-85at.toon | 33 +++++++++++ README.md | 1 + templates/prepare-merge-driver.ts | 19 ++++++- test/merge-driver-launcher.test.ts | 89 ++++++++++++++++++++++++++++-- 5 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 .agents/pm/history/ops-85at.jsonl create mode 100644 .agents/pm/issues/ops-85at.toon diff --git a/.agents/pm/history/ops-85at.jsonl b/.agents/pm/history/ops-85at.jsonl new file mode 100644 index 0000000..ec441b9 --- /dev/null +++ b/.agents/pm/history/ops-85at.jsonl @@ -0,0 +1,6 @@ +{"hash_algorithm":"sha256","ts":"2026-10-03T12:48:04.184Z","author":"dot","author_source":"asserted","agent_harness":"codex","agent_instance":"25c018dcfb758a86c17e1750","agent_provenance":{"model":null,"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"ops-85at"},{"op":"add","path":"/metadata/title","value":"Preserve launcher resolution errors when presence probes fail"},{"op":"add","path":"/metadata/description","value":"Canonical fix for https://github.com/unbraind/pm-ops/issues/135. The presence probe uses lstatSync with throwIfNoEntry:false, which suppresses ENOENT but still throws for unreadable or malformed lookup paths. Those secondary errors replace the original installer resolution diagnostic. Keep the existing local, hoisted, dangling-install and global Node lookup behavior; fail closed when presence is uncertain."},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"in_progress"},{"op":"add","path":"/metadata/priority","value":1},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-10-03T12:48:04.184Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-10-03T12:48:04.184Z"},{"op":"add","path":"/metadata/author","value":"dot"},{"op":"add","path":"/metadata/acceptance_criteria","value":"Preserve the original installer resolution error for real ENOTDIR/ELOOP paths and controlled EACCES/EPERM failures; an absent package still skips once; broken local, hoisted, dangling and global installs still fail; working local, hoisted and global installs still run; full applicable gates pass without broadening global fallback behavior"},{"op":"add","path":"/metadata/repro_steps","value":"Create a file or self-referential link at a consumer node_modules path, then execute templates/prepare-merge-driver.ts from the consumer root. The presence probe currently replaces the original MODULE_NOT_FOUND diagnostic with ENOTDIR or ELOOP."},{"op":"add","path":"/metadata/expected_result","value":"The hook fails closed with the original pm-ops/merge-driver/prepare resolution diagnostic and never emits the omit-dev skip notice on an inconclusive probe."},{"op":"add","path":"/metadata/actual_result","value":"Eight regression cases fail on the pre-fix template; all 19 launcher cases pass after guarding the filesystem presence check."},{"op":"add","path":"/metadata/dependencies","value":[{"id":"ops-jzp5","kind":"related","created_at":"2026-10-03T12:48:04.184Z","author":"dot","source_kind":"cli:create:dep","author_source":"detected"}]},{"op":"add","path":"/metadata/files","value":[{"path":"templates/prepare-merge-driver.ts","scope":"project"},{"path":"test/merge-driver-launcher.test.ts","scope":"project"},{"path":"README.md","scope":"project"}]},{"op":"add","path":"/metadata/tests","value":[{"command":"node --test test/merge-driver-launcher.test.ts","scope":"project","provenance":{"author":"dot","created_at":"2026-10-03T12:48:04.184Z","source_kind":"local_mutation","source_ref":"main"}}]}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"8f97868887dd9bfb45bfa39fd7a640dd52e6b73b1ed7b485d46ae929ca77ef6e","item_hash_version":3,"message":"Start canonical issue 135 after searching all 128 tracked items and checking related launcher work","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"failed","reason":"resolver_failed","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"f8c6c90773a0338f3eaf539b08af1cf693e36ef0d2b312cfac5fac92c88f0aa3"} +{"hash_algorithm":"sha256","ts":"2026-10-03T12:49:06.961Z","author":"dot","author_source":"asserted","agent_harness":"codex","agent_instance":"25c018dcfb758a86c17e1750","agent_provenance":{"model":null,"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-03T12:49:06.961Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-10-03T12:49:06.944Z","author":"dot","text":"Duplicate-check correction: pm list --all returned 126 pre-existing items, not 128. Relevant records were ops-mqdi (in_progress), ops-jzp5/ops-eyi8/ops-crgk/ops-me3v (closed), and ops-wwu1 (canceled); none tracks the secondary lstat error replacement in GitHub issue 135."}]}],"before_hash":"8f97868887dd9bfb45bfa39fd7a640dd52e6b73b1ed7b485d46ae929ca77ef6e","after_hash":"a7b3035e2b75353f9be8d367ccba0f509635017edfb27857927bd96b9d87aab1","item_hash_version":3,"message":"Record exact duplicate-check count and related status review","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"failed","reason":"resolver_failed","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"adb56dd15f29fa2dfec0fc8ac547f8d18fc4945e84c390f436d47161d20de66f"} +{"hash_algorithm":"sha256","ts":"2026-10-03T12:57:00.527Z","author":"dot","author_source":"asserted","agent_harness":"codex","agent_instance":"25c018dcfb758a86c17e1750","agent_provenance":{"model":null,"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"npm run release:check","scope":"project","provenance":{"author":"dot","created_at":"2026-10-03T12:57:00.496Z","source_kind":"local_mutation","source_ref":"main"}}},{"op":"add","path":"/metadata/notes/1","value":{"created_at":"2026-10-03T12:57:00.496Z","author":"dot","text":"Implementation evidence: six real local/hoisted/global ENOTDIR/ELOOP fixtures plus controlled EACCES/EPERM fail against the old template and pass after the fix. The global lookup fixture proves existing missing/broken/working behavior is preserved. Independent static review found no correctness or coverage issues."}},{"op":"add","path":"/metadata/notes/2","value":{"created_at":"2026-10-03T12:57:00.496Z","author":"dot","text":"Remaining validation limits: the installed-CLI test times out at its existing 60-second npm install boundary; the same packed artifact installs in under one second with cached dependencies and audit disabled. Do not modify or duplicate the separate gate-hardening PR stack 132-134 here. The direct production audit was blocked pending authorization for dependency metadata transmission to the public npm registry. No commit, push, PR, release, merge or deployment performed."}},{"op":"add","path":"/metadata/notes/3","value":{"created_at":"2026-10-03T12:57:00.496Z","author":"dot","text":"text=Validation: npm ci --ignore-scripts with a writable temporary cache; check, lint, duplication (0 clone pairs), docstrings (221 declarations), build and build:test pass. Standalone strict template typecheck passes. Full history fetch fixed shallow-checkout identity tests. npm run pack:dry-run, changelog:check, verify:release-changelog-date, verify:release-publish-attestation and verify:lifecycle-policy pass. pm health --strict-exit --require-merge-drivers --check-only passes with the three pre-existing stale-item advisories after the inspected local prepare hook installed clone-local drivers."}},{"op":"replace","path":"/metadata/actual_result","value":"Eight red regression cases now pass. All 19 launcher tests pass on Node 22.18.0, 24.19.0 and 26.10.0. Final full Node 26 suite: 454 passed, one unrelated installed-CLI npm install timeout, two existing skips; all measured coverage dimensions remain 100%. Independent static, package, changelog, release-policy and PM health checks pass. Production audit awaits explicit approval; publication and CI are pending."},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-03T12:57:00.527Z"},{"op":"add","path":"/metadata/resolution","value":"The canonical launcher catches inconclusive filesystem presence-probe failures and rethrows the original installer resolution error. Existing Node global lookup paths remain intentionally unchanged and their absent/broken/working behavior is documented and tested."}],"before_hash":"a7b3035e2b75353f9be8d367ccba0f509635017edfb27857927bd96b9d87aab1","after_hash":"30a969b2b414eeff3b7762d620b94edf21742bc326970f8e6690623ffc04c645","item_hash_version":3,"message":"Record local implementation, review, cross-version tests and explicit remaining gate limits","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"failed","reason":"resolver_failed","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"51347558b174b18270f9f3e7b56fd4c4a12eef1c293238411e8af256837c9576"} +{"hash_algorithm":"sha256","ts":"2026-10-03T12:58:04.243Z","author":"dot","author_source":"asserted","agent_harness":"codex","agent_instance":"25c018dcfb758a86c17e1750","agent_provenance":{"model":null,"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"add","path":"/metadata/notes/4","value":{"created_at":"2026-10-03T12:58:04.223Z","author":"dot","text":"Further timeout diagnosis: the isolated installed-CLI fixture drops this environment’s npm proxy variables. A no-audit reproduction still stalls fetching uncached TypeScript 7.0.2 optional platform package manifests (EAI_AGAIN). The offline reproduction skips unavailable optional manifests and installs the artifact in 639 ms. Public optional-package metadata is being cached without changing repository tests or policy."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-03T12:58:04.243Z"}],"before_hash":"30a969b2b414eeff3b7762d620b94edf21742bc326970f8e6690623ffc04c645","after_hash":"88d700e71eac636e63509e2e715950c793299d8e2759779c10f31cd3dbdc5922","item_hash_version":3,"message":"Distinguish cached artifact health from isolated npm network timeout","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"failed","reason":"resolver_failed","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"d5861e299e53236a1558f162402220faf39b94f3d7e8e791473cfb7a853e6ba0"} +{"hash_algorithm":"sha256","ts":"2026-10-03T13:01:04.849Z","author":"dot","author_source":"asserted","agent_harness":"codex","agent_instance":"25c018dcfb758a86c17e1750","agent_provenance":{"model":null,"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"add","path":"/metadata/notes/5","value":{"created_at":"2026-10-03T13:01:04.773Z","author":"dot","text":"text=Full Node 26 coverage recovered without repository gate changes: cached the 20 public optional TypeScript package manifests that the isolated npm fixture could not fetch without proxy variables, then ran npm run coverage with an outside-repository npm launcher that disables implicit install audit and refuses explicit audit. Result: 457 tests, 455 passed, zero failed, two pre-existing skips, 100 percent in all four coverage dimensions across all 23 required sources. Production audit is not claimed; it still awaits explicit authorization."}},{"op":"replace","path":"/metadata/actual_result","value":"All 19 launcher cases pass on Node 22.18.0, 24.19.0 and 26.10.0; eight new cases fail on the original template. Full Node 26 coverage now passes: 455 passed, zero failed, two existing skips, all 23 sources at 100/100/100/100. All independent quality, package, changelog, release-policy and strict PM health checks pass. Production audit and publication remain pending."},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-03T13:01:04.849Z"}],"before_hash":"88d700e71eac636e63509e2e715950c793299d8e2759779c10f31cd3dbdc5922","after_hash":"453dc6dba3df44264f26bdd9396ed7a03ee839b056855f4b7268e6f81df35003","item_hash_version":3,"message":"Record passing Node 26 aggregate coverage with the audit boundary retained","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"failed","reason":"resolver_failed","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"c247da0c1dd1a9422589686dbc23f1dd2d04471743036bf98278eca342f6f024"} +{"hash_algorithm":"sha256","ts":"2026-10-03T13:02:53.944Z","author":"dot","author_source":"asserted","agent_harness":"codex","agent_instance":"25c018dcfb758a86c17e1750","agent_provenance":{"model":null,"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"add","path":"/metadata/notes/6","value":{"created_at":"2026-10-03T13:02:53.924Z","author":"dot","text":"Final floor verification: full npm run coverage on Node 22.18.0 also passes with 455 tests passed, zero failures, two pre-existing real-data skips, and 100 percent statements, branches, functions and lines for all 23 required sources. The same temporary outside-repository no-audit launcher preserved the pending audit permission boundary. Both supported CI runtime lines now have local full-suite evidence; no repository test or dependency changes were made for environment recovery."}},{"op":"replace","path":"/metadata/actual_result","value":"All 19 launcher cases pass on Node 22.18.0, 24.19.0 and 26.10.0; eight new cases fail on the original template. Full coverage passes on Node 22.18.0 and 26.10.0: 455 passed, zero failed, two existing real-data skips, all 23 sources at 100/100/100/100 on both versions. All independent quality, package, changelog, release-policy and strict PM health checks pass. Explicit production audit and publication remain pending."},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-03T13:02:53.944Z"}],"before_hash":"453dc6dba3df44264f26bdd9396ed7a03ee839b056855f4b7268e6f81df35003","after_hash":"abb5387cb30a50bc909dcdd0c953cfcd3d5ddcd2bbb0b03f6ddbc8b4ecc6a248","item_hash_version":3,"message":"Record final Node 22 and Node 26 full coverage evidence","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"failed","reason":"resolver_failed","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"8aa7cc1a200aa78346a245e96b7a2c69ff511b5026065b4c000daa0041aa0f00"} diff --git a/.agents/pm/issues/ops-85at.toon b/.agents/pm/issues/ops-85at.toon new file mode 100644 index 0000000..7f0e585 --- /dev/null +++ b/.agents/pm/issues/ops-85at.toon @@ -0,0 +1,33 @@ +id: ops-85at +title: Preserve launcher resolution errors when presence probes fail +description: "Canonical fix for https://github.com/unbraind/pm-ops/issues/135. The presence probe uses lstatSync with throwIfNoEntry:false, which suppresses ENOENT but still throws for unreadable or malformed lookup paths. Those secondary errors replace the original installer resolution diagnostic. Keep the existing local, hoisted, dangling-install and global Node lookup behavior; fail closed when presence is uncertain." +type: Issue +status: in_progress +priority: 1 +tags: [] +created_at: "2026-10-03T12:48:04.184Z" +updated_at: "2026-10-03T13:02:53.944Z" +author: dot +acceptance_criteria: "Preserve the original installer resolution error for real ENOTDIR/ELOOP paths and controlled EACCES/EPERM failures; an absent package still skips once; broken local, hoisted, dangling and global installs still fail; working local, hoisted and global installs still run; full applicable gates pass without broadening global fallback behavior" +repro_steps: "Create a file or self-referential link at a consumer node_modules path, then execute templates/prepare-merge-driver.ts from the consumer root. The presence probe currently replaces the original MODULE_NOT_FOUND diagnostic with ENOTDIR or ELOOP." +resolution: The canonical launcher catches inconclusive filesystem presence-probe failures and rethrows the original installer resolution error. Existing Node global lookup paths remain intentionally unchanged and their absent/broken/working behavior is documented and tested. +expected_result: The hook fails closed with the original pm-ops/merge-driver/prepare resolution diagnostic and never emits the omit-dev skip notice on an inconclusive probe. +actual_result: "All 19 launcher cases pass on Node 22.18.0, 24.19.0 and 26.10.0; eight new cases fail on the original template. Full coverage passes on Node 22.18.0 and 26.10.0: 455 passed, zero failed, two existing real-data skips, all 23 sources at 100/100/100/100 on both versions. All independent quality, package, changelog, release-policy and strict PM health checks pass. Explicit production audit and publication remain pending." +dependencies[1]{id,kind,created_at,author,source_kind,author_source}: + ops-jzp5,related,"2026-10-03T12:48:04.184Z",dot,"cli:create:dep",detected +notes[7]{created_at,author,text}: + "2026-10-03T12:49:06.944Z",dot,"Duplicate-check correction: pm list --all returned 126 pre-existing items, not 128. Relevant records were ops-mqdi (in_progress), ops-jzp5/ops-eyi8/ops-crgk/ops-me3v (closed), and ops-wwu1 (canceled); none tracks the secondary lstat error replacement in GitHub issue 135." + "2026-10-03T12:57:00.496Z",dot,"Implementation evidence: six real local/hoisted/global ENOTDIR/ELOOP fixtures plus controlled EACCES/EPERM fail against the old template and pass after the fix. The global lookup fixture proves existing missing/broken/working behavior is preserved. Independent static review found no correctness or coverage issues." + "2026-10-03T12:57:00.496Z",dot,"Remaining validation limits: the installed-CLI test times out at its existing 60-second npm install boundary; the same packed artifact installs in under one second with cached dependencies and audit disabled. Do not modify or duplicate the separate gate-hardening PR stack 132-134 here. The direct production audit was blocked pending authorization for dependency metadata transmission to the public npm registry. No commit, push, PR, release, merge or deployment performed." + "2026-10-03T12:57:00.496Z",dot,"text=Validation: npm ci --ignore-scripts with a writable temporary cache; check, lint, duplication (0 clone pairs), docstrings (221 declarations), build and build:test pass. Standalone strict template typecheck passes. Full history fetch fixed shallow-checkout identity tests. npm run pack:dry-run, changelog:check, verify:release-changelog-date, verify:release-publish-attestation and verify:lifecycle-policy pass. pm health --strict-exit --require-merge-drivers --check-only passes with the three pre-existing stale-item advisories after the inspected local prepare hook installed clone-local drivers." + "2026-10-03T12:58:04.223Z",dot,"Further timeout diagnosis: the isolated installed-CLI fixture drops this environment’s npm proxy variables. A no-audit reproduction still stalls fetching uncached TypeScript 7.0.2 optional platform package manifests (EAI_AGAIN). The offline reproduction skips unavailable optional manifests and installs the artifact in 639 ms. Public optional-package metadata is being cached without changing repository tests or policy." + "2026-10-03T13:01:04.773Z",dot,"text=Full Node 26 coverage recovered without repository gate changes: cached the 20 public optional TypeScript package manifests that the isolated npm fixture could not fetch without proxy variables, then ran npm run coverage with an outside-repository npm launcher that disables implicit install audit and refuses explicit audit. Result: 457 tests, 455 passed, zero failed, two pre-existing skips, 100 percent in all four coverage dimensions across all 23 required sources. Production audit is not claimed; it still awaits explicit authorization." + "2026-10-03T13:02:53.924Z",dot,"Final floor verification: full npm run coverage on Node 22.18.0 also passes with 455 tests passed, zero failures, two pre-existing real-data skips, and 100 percent statements, branches, functions and lines for all 23 required sources. The same temporary outside-repository no-audit launcher preserved the pending audit permission boundary. Both supported CI runtime lines now have local full-suite evidence; no repository test or dependency changes were made for environment recovery." +files[3]{path,scope}: + templates/prepare-merge-driver.ts,project + test/merge-driver-launcher.test.ts,project + README.md,project +tests[2]{command,scope,provenance{author,created_at,source_kind,source_ref}}: + node --test test/merge-driver-launcher.test.ts,project,dot,"2026-10-03T12:48:04.184Z",local_mutation,main + "npm run release:check",project,dot,"2026-10-03T12:57:00.496Z",local_mutation,main +body: "" diff --git a/README.md b/README.md index 03508d8..59771a1 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,7 @@ Dynamic `import()` is forbidden by the fleet lint gate. Instead the template res - **pm-ops not installed** (`--omit=dev`): exits `0` after exactly one notice line - **pm-ops too old to export the entry, or its entry file missing**: fails the install loudly, and never skips - **a broken install** (`node_modules/pm-ops` left as a directory without its `package.json`, or as a dangling link): fails the install loudly; resolution fails there with the same `MODULE_NOT_FOUND` an omit-dev install produces, so the template also looks for that entry before skipping +- **an unreadable or malformed lookup path**: fails with the original installer resolution error, because a failed presence probe cannot establish absence. The probe retains Node's global lookup paths (including `NODE_PATH`), matching the installer resolution above rather than treating an absent local package as proof of absence everywhere - **pm-ops installed**: runs `runPrepareMergeDriver` (below) and propagates its exit status Fixture tests in `test/merge-driver-launcher.test.ts` execute the shipped template against each of diff --git a/templates/prepare-merge-driver.ts b/templates/prepare-merge-driver.ts index 728ece4..f6f49f0 100644 --- a/templates/prepare-merge-driver.ts +++ b/templates/prepare-merge-driver.ts @@ -31,8 +31,12 @@ try { // the original error is rethrown. A probe that finds no package.json is not // yet proof of absence: a broken install can leave `node_modules/pm-ops` (a // directory or a dangling link) with no package.json in any ancestor Node - // searches. Such an entry also counts as present. Node returns null paths - // only for built-in modules; pm-ops/package.json is a package specifier. + // searches. Such an entry also counts as present. Keep Node's global paths + // too: the installer resolution above can use them, so an absent local + // package alone cannot prove absence. An unreadable or malformed lookup path + // leaves presence uncertain and must fail closed with the original error. + // Node returns null paths only for built-in modules; pm-ops/package.json is + // a package specifier. let packagePresent = true; try { resolver.resolve("pm-ops/package.json"); @@ -40,7 +44,16 @@ try { packagePresent = !(probe instanceof Error && "code" in probe && probe.code === "MODULE_NOT_FOUND") || resolver.resolve.paths("pm-ops/package.json")!.some( - (directory) => lstatSync(join(directory, "pm-ops"), { throwIfNoEntry: false }) !== undefined, + (directory) => { + try { + return lstatSync(join(directory, "pm-ops"), { throwIfNoEntry: false }) !== undefined; + } catch { + // throwIfNoEntry:false only suppresses ENOENT, not EACCES, EPERM, + // ENOTDIR, or ELOOP. Do not replace the installer diagnostic or + // treat an inconclusive presence check as an omit-dev install. + return true; + } + }, ); } if (packagePresent) throw error; diff --git a/test/merge-driver-launcher.test.ts b/test/merge-driver-launcher.test.ts index ce534f9..876e6c3 100644 --- a/test/merge-driver-launcher.test.ts +++ b/test/merge-driver-launcher.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import test, { after, before } from "node:test"; @@ -58,11 +58,11 @@ function stubPm(name: string, status: number, body = ""): { bin: string; record: } /** Run a script with only `bin` on PATH, as npm's prepare hook would from `cwd`. */ -function run(cwd: string, script: string, bin: string): { status: number | null; stderr: string } { - const result = spawnSync(process.execPath, [script], { +function run(cwd: string, script: string, bin: string, env: NodeJS.ProcessEnv = {}, nodeArgs: string[] = []): { status: number | null; stderr: string } { + const result = spawnSync(process.execPath, [...nodeArgs, script], { cwd, encoding: "utf8", - env: { ...process.env, PATH: bin }, + env: { ...process.env, ...env, PATH: bin }, }); return { status: result.status, stderr: result.stderr }; } @@ -76,6 +76,87 @@ test("an omit-dev checkout without pm-ops skips with exactly one notice and succ assert.throws(() => readFileSync(record, "utf8"), /ENOENT/); }); +/** Assert that an uncertain presence probe preserves the installer resolution failure. */ +function assertResolutionFailure(result: { status: number | null; stderr: string }, record: string): void { + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /Cannot find module 'pm-ops\/merge-driver\/prepare'/); + assert.match(result.stderr, /code: 'MODULE_NOT_FOUND'/); + assert.doesNotMatch(result.stderr, /skipping merge-driver install/); + assert.throws(() => readFileSync(record, "utf8"), /ENOENT/); +} + +for (const location of ["local", "hoisted", "global"]) { + for (const code of ["ENOTDIR", "ELOOP"]) { + test(`a ${location} ${code} presence probe preserves the original resolution error`, { skip: process.platform === "win32" }, () => { + const name = `${location}-${code}`; + const parent = join(root, `${name}-parent`); + mkdirSync(parent); + const directory = consumer(name, "absent", parent); + const lookup = location === "local" ? join(directory, "node_modules") : join(parent, "node_modules"); + // A file or a looping link in a path component makes lstat fail even + // with throwIfNoEntry:false. All malformed paths belong to this fixture. + const probeDirectory = location === "global" ? join(parent, "global-modules") : lookup; + if (code === "ENOTDIR") writeFileSync(probeDirectory, "not a directory"); + else symlinkSync(probeDirectory, probeDirectory, "dir"); + assert.throws(() => lstatSync(join(probeDirectory, "pm-ops"), { throwIfNoEntry: false }), { code }); + const { bin, record } = stubPm(name, 0); + const env = location === "global" ? { NODE_PATH: probeDirectory } : {}; + const result = run(directory, template, bin, env); + assertResolutionFailure(result, record); + assert.doesNotMatch(result.stderr, /Error: (ENOTDIR|ELOOP)/); + }); + } +} + +for (const code of ["EACCES", "EPERM"]) { + test(`a ${code} presence probe preserves the original resolution error`, { skip: process.platform === "win32" }, () => { + const directory = consumer(code, "absent"); + const { bin, record } = stubPm(code, 0); + const preload = join(root, `${code}-preload.ts`); + const probeRecord = join(root, `${code}-probe.txt`); + const target = join(directory, "node_modules", "pm-ops"); + // Permission bits do not reliably deny access under privileged CI users. + // Patch only this child process's lstat boundary for the exact probe path. + writeFileSync(preload, ` +import fs from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import { mock } from "node:test"; +const original = fs.lstatSync; +mock.method(fs, "lstatSync", (...args: Parameters) => { + if (args[0] === ${JSON.stringify(target)}) { + fs.writeFileSync(${JSON.stringify(probeRecord)}, ${JSON.stringify(code)}); + throw Object.assign(new Error("controlled presence probe failure"), { code: ${JSON.stringify(code)} }); + } + return original(...args); +}); +syncBuiltinESMExports(); +`); + const result = run(directory, template, bin, {}, ["--import", preload]); + assert.equal(readFileSync(probeRecord, "utf8"), code); + assertResolutionFailure(result, record); + assert.doesNotMatch(result.stderr, /controlled presence probe failure/); + }); +} + +test("global Node lookup paths retain absent, broken, and working package behavior", { skip: process.platform === "win32" }, () => { + const directory = consumer("global", "absent"); + const globalModules = join(root, "global-modules"); + mkdirSync(globalModules); + const { bin, record } = stubPm("global", 0); + const env = { NODE_PATH: globalModules }; + const absent = run(directory, template, bin, env); + assert.equal(absent.status, 0, absent.stderr); + assert.equal(absent.stderr, "pm-ops is not installed (omit-dev install); skipping merge-driver install\n"); + const entry = join(globalModules, "pm-ops"); + mkdirSync(entry); + assertResolutionFailure(run(directory, template, bin, env), record); + rmSync(entry, { recursive: true }); + symlinkSync(packageRoot, entry, "dir"); + const current = run(directory, template, bin, env); + assert.equal(current.status, 0, current.stderr); + assert.equal(readFileSync(record, "utf8"), "merge install\n"); +}); + test("local and hoisted full installs run pm merge install through the pm-ops entry", { skip: process.platform === "win32" }, () => { const directory = consumer("full", "current"); const { bin, record } = stubPm("full", 0);