| … | … | @@ -108,6 +108,47 @@ export async function computeLockfile (appDir, opts = {}) { |
| 108 | 108 | const seen = new Set() |
| 109 | 109 | if (fs.existsSync(depDir)) collectDepsRecursive(depDir, deps, seen, strategy) |
| 110 | 110 | |
| 111 | // Guard: every declared file: (workspace) dependency MUST resolve into the |
| 112 | // closure. collectDepsRecursive() silently skips broken symlinks (it has to, |
| 113 | // to survive npm's hoisting leftovers), so a workspace dep whose file: target |
| 114 | // is absent — e.g. `pgh publish` from an isolated CI git-archive checkout |
| 115 | // where ../<sibling> doesn't exist — vanishes with no error and ships a tree |
| 116 | // that crashes at import. This was the root cause of the 2026-07-15 |
| 117 | // pear-git-v3 0.1.147 broken publish (all 13 workspace deps dropped). Refuse |
| 118 | // to publish a tree that would crash at import; fail loudly instead. |
| 119 | if (pkg && pkg.dependencies) { |
| 120 | const dropped = [] |
| 121 | for (const [depName, spec] of Object.entries(pkg.dependencies)) { |
| 122 | if (typeof spec !== 'string' || !spec.startsWith('file:')) continue |
| 123 | const entry = path.join(depDir, ...depName.split('/')) |
| 124 | let why = null |
| 125 | try { |
| 126 | const st = fs.statSync(entry) // follows the symlink; throws if broken/missing |
| 127 | if (!st.isDirectory()) why = 'not a directory' |
| 128 | else if (!fs.existsSync(path.join(entry, 'package.json'))) why = 'no package.json' |
| 129 | } catch { |
| 130 | try { |
| 131 | const lst = fs.lstatSync(entry) |
| 132 | why = lst.isSymbolicLink() |
| 133 | ? 'broken symlink -> ' + fs.readlinkSync(entry) |
| 134 | : 'unreadable' |
| 135 | } catch { why = 'missing from node_modules' } |
| 136 | } |
| 137 | if (why) dropped.push(' ' + depName + ' (' + spec + '): ' + why) |
| 138 | } |
| 139 | if (dropped.length) { |
| 140 | throw new Error( |
| 141 | 'publish: ' + dropped.length + ' declared file: dependenc' + |
| 142 | (dropped.length === 1 ? 'y' : 'ies') + ' did not resolve into the closure — ' + |
| 143 | 'refusing to publish a tree that would crash at import:\n' + dropped.join('\n') + |
| 144 | '\n\nThis usually means publish ran from an isolated checkout (e.g. a CI ' + |
| 145 | 'git-archive) where the workspace siblings are absent. Vendor them into ' + |
| 146 | 'node_modules before publishing, or publish from a tree where the file: ' + |
| 147 | 'targets resolve.' |
| 148 | ) |
| 149 | } |
| 150 | } |
| 151 | |
| 111 | 152 | return { |
| 112 | 153 | name, |
| 113 | 154 | version, |