ReviewOS

also looking at this

stacks/buddy

fix(deps-file): anchor the dependency regex to a key, and rewrite each match from its own captures

#1454
Merged glennmichael123 wants to merge fix/deps-file-anchored-key-regex into main
3 files +216 -41
src/utils/dependency-file-parser.tsmodified+70-36
Changes to src/utils/dependency-file-parser.ts
@@ -125,6 +125,22 @@ async function parseSimpleYamlDependencies(content: string, filePath: string): P
125125 return dependencies
126126}
127127
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 */
134function parsesAsYaml(content: string): boolean {
135 try {
136 Bun.YAML.parse(content)
137 return true
138 }
139 catch {
140 return false
141 }
142}
143
128144/**
129145 * Update dependency file content with new package versions
130146 */
@@ -148,44 +164,53 @@ export async function updateDependencyFile(filePath: string, content: string, up
148164 for (const update of updates) {
149165 // Clean package name (remove dependency type info like "(dev)")
150166 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.
156184 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',
159187 )
160188
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 ) => {
175205 // 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())) {
183208 getDefaultLogger().info(`⚠️ Skipping update for ${cleanPackageName} - version "${currentVersionInFile}" should be respected`)
184 continue
209 return fullMatch
185210 }
186211
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] ?? ''
189214
190215 // Check if newVersion already has a prefix (to avoid double prefixes)
191216 const newVersionHasPrefix = /^[\^~>=<]/.test(update.newVersion)
@@ -193,11 +218,20 @@ export async function updateDependencyFile(filePath: string, content: string, up
193218 // Use newVersion as-is if it already has a prefix, otherwise preserve original prefix
194219 const finalVersion = newVersionHasPrefix ? update.newVersion : `${originalPrefix}${update.newVersion}`
195220
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
201235 }
202236
203237 return updatedContent