updateDependencyFile in src/utils/dependency-file-parser.ts can emit a deps.yaml that no longer parses as YAML, silently dropping a dependency key.
Hit in stacksjs/bun-query-builder (PR stacksjs/bun-query-builder#1168, now closed). The PR's only check was a docs deploy, so it went green — nothing in CI parses deps.yaml, and the breakage would have surfaced as Setup Pantry failing before any job ran.
Input
# That is not hypothetical: `sqlite: latest` was fine only because the SDK did
# not support sqlite.org and skipped it with a warning.
dependencies:
bun: ^1.3.14
mysql: ^8.0.45
postgres: ^18.0.0
sqlite: ^3.52.0Updates applied: postgres → 18.6, sqlite → 3.53.4.
Output
# That is not hypothetical: `sqlite: latest`3.53.4 was fine only because the SDK did
# not support sqlite.org and skipped it with a warning.
dependencies:
bun: ^1.3.14
mysql: ^8.0.45
postgres: ^18.6sqlite: latest`3.53.4The sqlite key is gone — merged into the postgres value — and Bun.YAML.parse gives YAML Parse error: Unexpected token.
Repro
Extracting the loop verbatim and running it against that file reproduces the PR's output byte for byte, including the mangled comment line:
[postgres] firstMatch="\n postgres: ^18.0.0" version="^18.0.0" respected=false totalMatches=1
prefix="^" replacement="\n postgres: ^18.6"
[sqlite] firstMatch="sqlite: latest`" version="latest`" respected=false totalMatches=2
prefix="latest`" replacement="sqlite: latest`3.53.4"Root cause
Four defects compound, all in the for (const update of updates) loop:
1. The regex isn't anchored to a YAML key position. (\s*\bsqlite\b\s*:\s*)([^\s#\n\r]+)(\s*#.*)? happily matches `sqlite: latest` inside a prose comment. That comment is the first match in the file, so it's the one whose captures drive everything downstream.
2. shouldRespectVersion is defeated by adjacent punctuation. Group 2 captures latest` — with the trailing backtick — so the ['latest', '*', ...] membership test misses, and the guard that should have skipped this match doesn't fire.
3. /^(\D*)/ treats the whole garbage token as a version prefix. latest` is all non-digits, so originalPrefix becomes latest` and finalVersion becomes latest`3.53.4.
4. A g regex is used with a replacement built from only the first match. replacement is computed from currentMatch[0], then updatedContent.replace(packageRegex, replacement) applies that same literal string to every match. Because the leading \s* in group 1 is greedy across newlines, the real key line matches as "\n sqlite: ^3.52.0" — but the replacement string came from the comment match, which had no leading newline. So the line break is consumed and never restored, and the key collapses onto the previous line.
Defect 4 is the one that actually corrupts the file; 1–3 are what let a comment supply the replacement text. Note that 4 can bite on its own: any file with two legitimate occurrences of a package name gets the first one's version written over both.
Suggested fixes
- Anchor to a key at line start — something like
^([ \t]*)(pkg)[ \t]*:[ \t]*with themflag — instead of\s*\b…\b, so comments and prose can't match. - Keep the leading indentation out of the capture that gets rewritten, or restore it per-match, so a newline can't be swallowed.
- Use a replacer function with the
gregex so each match is parsed and rewritten from its own captures, rather than computing one replacement from the first match. - Ideally, round-trip the result through a YAML parser before writing and bail out if it doesn't parse — that alone would have turned this into a no-op PR rather than a broken one.
Happy to open a PR for this if it's useful.