also looking at this
fix(deps-file): anchor the dependency regex to a key, and rewrite each match from its own captures
#1454
3 files
+216
-41
| @@ -125,6 +125,22 @@ async function parseSimpleYamlDependencies(content: string, filePath: string): P | ||
| 125 | 125 | return dependencies |
| 126 | 126 | } |
| 127 | 127 | |
| 128 | /** | |
| 129 | * Does this content parse as YAML? | |
| 130 | * | |
| 131 | * Used as a guard around rewrites rather than as a parser: the result is only | |
| 132 | * ever compared before and after, so a file that never parsed is not penalised. | |
| 133 | */ | |
| 134 | function parsesAsYaml(content: string): boolean { | |
| 135 | try { | |
| 136 | Bun.YAML.parse(content) | |
| 137 | return true | |
| 138 | } | |
| 139 | catch { | |
| 140 | return false | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 128 | 144 | /** |
| 129 | 145 | * Update dependency file content with new package versions |
| 130 | 146 | */ |
| @@ -148,44 +164,53 @@ export async function updateDependencyFile(filePath: string, content: string, up | ||
| 148 | 164 | for (const update of updates) { |
| 149 | 165 | // Clean package name (remove dependency type info like "(dev)") |
| 150 | 166 | const cleanPackageName = update.name.replace(/\s*\(dev\)$/, '').replace(/\s*\(peer\)$/, '').replace(/\s*\(optional\)$/, '') |
| 151 | ||
| 152 | // Create regex to find the package line and update its version | |
| 153 | // Handle various YAML formats: "package: version", "package:version", " package: ^version" | |
| 154 | // Use word boundaries to prevent partial matches (e.g., "zip" matching "unzip") | |
| 155 | // Capture: (1) package name and colon, (2) version part, (3) optional comment part | |
| 167 | const escapedName = cleanPackageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | |
| 168 | ||
| 169 | // Anchor to a key at the start of a line. | |
| 170 | // | |
| 171 | // The previous pattern was `(\s*\bNAME\b\s*:\s*)(...)`, which matched the | |
| 172 | // package name anywhere in the file - including inside a prose comment - | |
| 173 | // and whose leading `\s*` reached back across the newline before the real | |
| 174 | // key. Both halves were needed to corrupt a file, and both are gone: `^` | |
| 175 | // with the `m` flag pins the match to a key position, and the indent is | |
| 176 | // `[ \t]*`, which cannot cross a line. A commented-out key (`# redis: ^7`) | |
| 177 | // and a quoted neighbour (`"@types/node"` when updating `node`) no longer | |
| 178 | // match either, because neither starts with the name. stacksjs/buddy#1453 | |
| 179 | // | |
| 180 | // Deliberately not anchored at the end, so a CRLF file still matches. | |
| 181 | // | |
| 182 | // Groups: 1 indent, 2 key including any quotes, 3 colon and its spacing, | |
| 183 | // 4 quote around the value, 5 version, 6 inline comment. | |
| 156 | 184 | const packageRegex = new RegExp( |
| 157 | `(\\s*\\b${cleanPackageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b\\s*:\\s*)([^\\s#\\n\\r]+)(\\s*#.*)?`, | |
| 158 | 'g', | |
| 185 | `^([ \\t]*)("${escapedName}"|'${escapedName}'|${escapedName})([ \\t]*:[ \\t]*)(["']?)([^\\s#"']+)\\4([ \\t]*#[^\\r\\n]*)?`, | |
| 186 | 'gm', | |
| 159 | 187 | ) |
| 160 | 188 | |
| 161 | // Extract the original version prefix (^, ~, >=, etc.) or lack thereof | |
| 162 | const currentMatch = updatedContent.match(packageRegex) | |
| 163 | if (currentMatch) { | |
| 164 | const fullMatch = currentMatch[0] | |
| 165 | // Parse the match into parts: package+colon, version, comment | |
| 166 | const matchParts = fullMatch.match(new RegExp( | |
| 167 | `(\\s*\\b${cleanPackageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b\\s*:\\s*)([^\\s#\\n\\r]+)(\\s*#.*)?`, | |
| 168 | )) | |
| 169 | ||
| 170 | if (matchParts) { | |
| 171 | const packageAndColon = matchParts[1] // " package: " | |
| 172 | const currentVersionInFile = matchParts[2] // "^1.0.0" | |
| 173 | const commentPart = matchParts[3] || '' // " # comment" or empty | |
| 174 | ||
| 189 | // A replacer function, so every match is rewritten from its own captures. | |
| 190 | // | |
| 191 | // The previous code read the first match, built one replacement string | |
| 192 | // from it, and handed that literal to `String.replace` with a `g` regex - | |
| 193 | // writing the first occurrence's version over every other occurrence. | |
| 194 | updatedContent = updatedContent.replace( | |
| 195 | packageRegex, | |
| 196 | ( | |
| 197 | fullMatch: string, | |
| 198 | indent: string, | |
| 199 | key: string, | |
| 200 | separator: string, | |
| 201 | quote: string, | |
| 202 | currentVersionInFile: string, | |
| 203 | commentPart?: string, | |
| 204 | ) => { | |
| 175 | 205 | // Check if current version should be respected (like "*", "latest", etc.) |
| 176 | const shouldRespectVersion = (version: string): boolean => { | |
| 177 | const dynamicIndicators = ['latest', '*', 'main', 'master', 'develop', 'dev'] | |
| 178 | const cleanVersion = version.toLowerCase().trim() | |
| 179 | return dynamicIndicators.includes(cleanVersion) | |
| 180 | } | |
| 181 | ||
| 182 | if (shouldRespectVersion(currentVersionInFile)) { | |
| 206 | const dynamicIndicators = ['latest', '*', 'main', 'master', 'develop', 'dev'] | |
| 207 | if (dynamicIndicators.includes(currentVersionInFile.toLowerCase().trim())) { | |
| 183 | 208 | getDefaultLogger().info(`⚠️ Skipping update for ${cleanPackageName} - version "${currentVersionInFile}" should be respected`) |
| 184 | continue | |
| 209 | return fullMatch | |
| 185 | 210 | } |
| 186 | 211 | |
| 187 | const versionPrefixMatch = currentVersionInFile.match(/^(\D*)/) | |
| 188 | const originalPrefix = versionPrefixMatch ? versionPrefixMatch[1] : '' | |
| 212 | // Extract the original version prefix (^, ~, >=, etc.) or lack thereof | |
| 213 | const originalPrefix = currentVersionInFile.match(/^(\D*)/)?.[1] ?? '' | |
| 189 | 214 | |
| 190 | 215 | // Check if newVersion already has a prefix (to avoid double prefixes) |
| 191 | 216 | const newVersionHasPrefix = /^[\^~>=<]/.test(update.newVersion) |
| @@ -193,11 +218,20 @@ export async function updateDependencyFile(filePath: string, content: string, up | ||
| 193 | 218 | // Use newVersion as-is if it already has a prefix, otherwise preserve original prefix |
| 194 | 219 | const finalVersion = newVersionHasPrefix ? update.newVersion : `${originalPrefix}${update.newVersion}` |
| 195 | 220 | |
| 196 | // Replace with: package+colon + new version + preserved comment | |
| 197 | const replacement = `${packageAndColon}${finalVersion}${commentPart}` | |
| 198 | updatedContent = updatedContent.replace(packageRegex, replacement) | |
| 199 | } | |
| 200 | } | |
| 221 | return `${indent}${key}${separator}${quote}${finalVersion}${quote}${commentPart ?? ''}` | |
| 222 | }, | |
| 223 | ) | |
| 224 | } | |
| 225 | ||
| 226 | // A corrupted write is worse than a missed update. These files are committed | |
| 227 | // by a bot, and a deps.yaml that no longer parses fails `Setup Pantry` before | |
| 228 | // any job in the workflow runs - so nothing downstream is left to catch it. | |
| 229 | // | |
| 230 | // Only bail when this function is what broke it: a file that already did not | |
| 231 | // parse is left to whatever it was doing before. stacksjs/buddy#1453 | |
| 232 | if (updatedContent !== content && parsesAsYaml(content) && !parsesAsYaml(updatedContent)) { | |
| 233 | getDefaultLogger().warn(`⚠️ Skipping updates for ${filePath}: the rewrite no longer parses as YAML, so the file is left unchanged`) | |
| 234 | return content | |
| 201 | 235 | } |
| 202 | 236 | |
| 203 | 237 | return updatedContent |