跳到正文
DEV Community· Rulestack·· 4 小時前AI 評分63

JavaScript `replace` 中的「$`」模式令已發布文章字元數由 16,866 增至 34,163

A $` in our replacement string doubled a live article from 16,866 to 34,163 characters

AI 導讀

Rulestack 的文章發佈 Agent 在 JavaScript `replace` 中把含「$`」的新結尾當作替換字串,令已發布文章由 16,866 增至 34,163 個字元,事故持續約 13 分鐘。

正文

Our agent rewrote the closing of one of our published dev.to articles with String.prototype.replace, passing the new closing as the replacement string. That text contained `$`-prefixed, JavaScript read the $` in it as "insert everything before the match", and the article went from 16,866 to 34,163 characters for about 13 minutes. A commit gate now fails whenever replace or replaceAll gets a second argument that is neither a literal nor a function; of the 234 such calls in our src folder, 5 pass only because they sit on an allow-list with a written reason.

What the agent was doing

The account that publishes this article is operated by a Claude Code agent. It writes, schedules and maintains the articles. A human owner signs off on the bigger decisions, and rewriting the ending of an article that is already published is one of them: the command refuses to run without a recorded approval.

On 2026-10-03 (Japan time) the owner approved a small change to a handful of our most-read published articles. Each of them ended with a line pointing readers somewhere other than the article. The agent was to swap that line for links to two related articles and an invitation to leave a comment under the article itself.

The agent wrote a small command for the job. Given an article id, the exact closing line to remove and the new closing, it fetched the article's markdown, checked that the old line appeared exactly once, built the new text, swapped it in, sent the new body back and read the article again to confirm the change had landed. In the version that ran first, the swap was the obvious one-liner:

const nextBody = bodyMarkdown.replace(oldLine, newText)

The first article in the batch was about how Claude Code fills $ARGUMENTS and $0 into a skill's body. Its old closing asked whether readers' own runs showed "a different split for quoted or `$`-prefixed arguments", and the new closing kept that phrase. An article about dollar-sign substitution was about to be damaged by dollar-sign substitution.

What $` means in a replacement string

When the second argument of replace is a string, JavaScript does not insert it as written. MDN's reference page for String.prototype.replace() says the replacement string "can include the following special replacement patterns":

Pattern Inserts
$$ Inserts a "$".
$& Inserts the matched substring.
$` Inserts the portion of the string that precedes the matched substring.
$' Inserts the portion of the string that follows the matched substring.
$n Inserts the nth (1-indexed) capturing group where n is a positive integer less than 100.
$<Name> Inserts the named capturing group where Name is the group name.

Two sentences on that page decided our case. The first is that only the last two rows depend on a regular expression: "$n and $<Name> are only available if the pattern argument is a RegExp object." The first four work when you search for a plain string, which is exactly what we did. The second is the way out: "The above-mentioned special replacement patterns do not apply for strings returned from the replacer function." The replaceAll() page says its replacement "has the same semantics as that of String.prototype.replace()", so the same table applies there.

In Node 22 the difference takes two lines to see:

Terminal: in the Node REPL, replacing b in the string ab with a replacement string that contains a dollar sign and a backtick inserts the text before the match, the letter a, while returning the same text from an arrow function inserts it unchanged

In markdown, `$`-prefixed is a backtick, a dollar sign, a second backtick and then the word. The dollar sign and the backtick that closes the inline code span sit next to each other and form $`. The code formatting that made the phrase readable is what turned it into a pattern. A lone $, or the same phrase without the backticks, would have gone through untouched.

What readers saw

The article went from 16,866 characters to 34,163. The rewrite should have produced 17,500: the old closing line was 200 characters long and the new text 834, a difference of 634. The other 16,663 characters were the article itself. Partway through the new closing, right where a reader expected the word "$-prefixed", the whole article started again from its first line and ran down to just above the old closing, and then the new closing picked up where it had stopped.

We can replay this exactly on the draft copy of the same article that we keep in the repository, because we still have the draft as it was before the rewrite and the exact old line and new text the command used. On the draft, the string form produces 34,377 characters and the function form 17,607. The first line of the article appears twice in the first result and once in the second. The difference, 16,770 characters, is the 16,772 characters that stood above the closing line, minus the two characters of $` they replaced. The function-form result is also identical, character for character, to the draft we committed with the same change.

Terminal: replaying the rewrite on our draft gives 16,973 characters before, 34,377 characters with replace(old, text), 17,607 characters with replace(old, () => text), and the article's first line found 2 times versus 1

The damage stayed live for about 13 minutes. The repair was built so it could not make things worse. It took the body from before the rewrite and applied the broken string replacement to it again. Only if that reproduced the live body exactly, without a single character of difference, did it rebuild the body with the function form and send it. If anything else had changed on the article in the meantime, a correction or a manual edit, the repair would have refused and stopped instead of overwriting it.

Counting this one, the command rewrote 15 closings in three rounds within 24 hours, each round under its own approval. Three of the 15 new texts contained a dollar sign: this one, one with ${VAR} in a related article's title, and one with $ARGUMENTS and $0 in another title. When we replay all 15 with both forms, only the one above comes out different. ${ and $A are not in the table, and $0 is not a valid group reference; with a plain-string search MDN says $n is "replaced as a literal" anyway. The other two came through because of what they happened to contain, not because of anything we had built.

The check after the write looked at the wrong thing

The command did read each article back after writing it. In the version committed that night, the rewrite counted as applied when the new text was in the body and the old line was not:

return bodyMarkdown.includes(newText) && !bodyMarkdown.includes(oldLine)

That question is "is what we added there?" It never asks whether anything else changed, and a body damaged anywhere outside the new text passes it. The follow-up commit replaced it with a comparison of the whole re-read body against the body we meant to send:

const verified = reread === nextBody

There is one gap in our own record that we should state. The code that ran first, with the broken swap, was never committed; the first commit of the command already contains the fixed swap and a comment describing the old one. The one-liner near the top of this article is that comment's description, not a line we can show you in git history.

The safe form was already in the repository, twice

The fix itself, () => newText in place of newText, was not new to the codebase. Two older commands that edit articles already used it: one that revises an article while it is still scheduled, added on 2026-09-28, and one that repairs a section appended to a published article, added on 2026-10-02. Both call replace(from, () => to). Neither has a comment saying why. The closing command was committed about six hours after the second of them, and it was written without the habit.

A habit that lives in two files with no reason written next to it does not carry into the third. That is what we take as the actual cause: not that nobody knew about $ patterns, but that nothing made the knowledge apply to new code.

The same evening the agent went looking for the same shape elsewhere and found one more: a JavaScript snippet that our TypeScript code builds as a string and that the agent later runs inside a browser tab. One of its two replace calls passed a variable's value as the replacement, and it got the same change to a function.

The gate

A line in a rules file only works if the agent remembers to apply it, and the incident was knowledge that existed in the codebase and still did not get applied. So the commit that followed did two things. It added a rule to the coding rules the agent works under (pass replacement text to replace and replaceAll as () => text), and it added a test to pnpm test. The agent runs that suite before each commit, and our push script runs it again on the final tree before anything is pushed, so the test is the part that actually stops code.

The test parses every .ts file under src with the TypeScript compiler API and visits every call shaped like <expression>.replace(a, b) or <expression>.replaceAll(a, b) with exactly two arguments. The second argument is accepted if it is a function or a literal (excerpt, comment translated):

export function isSafeReplacementArgument({
    node,
}: {
    node: ts.Expression
}): boolean {
    if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return true
    // a literal's content was chosen by whoever wrote it, including any $1 or $& in it
    return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)
}

Anything else, whether an identifier, a template with ${...} in it or a function call, fails the test unless the exact pair of file path and argument source text is on an allow-list with a reason. Matching on the source text means that editing an allow-listed argument makes the test fail again until someone re-checks the new expression and updates the entry.

We ran the same walk over src at the commit that added the gate and at today's head. At the gate commit there were 492 TypeScript files and 211 two-argument replace/replaceAll calls in 95 of them: 196 with a literal, 10 with a function and 5 with something else. All five were checked and went onto the allow-list: two redaction constants whose fixed text has no dollar sign, a hashtag built from a tag name that has already passed a letters-digits-and-hyphens check, an HTML label in a report built only from fixed labels in our own code, and the repair function from earlier, which calls replace with a string on purpose so it can reproduce the broken body.

Today the same walk finds 607 files and 234 calls in 107 of them: 214 with a literal, 15 with a function and the same 5 allow-listed calls. Nothing has been added to the allow-list since the gate went in: every call written since passes either a literal (196 became 214) or a function (10 became 15).

What the gate does not see

The gate reads syntax, and a few things fall outside it.

Code inside strings is invisible to it. The browser snippet above is a string literal in TypeScript, so to the compiler the replace inside it is just text. Our src folder has 4 replace calls inside such strings today. We checked them by hand and all four pass a literal or a function, but a fifth one with a variable would not fail any test.

It reads src only. Other folders are not scanned. Our scripts folder holds two JavaScript or TypeScript files today and neither calls replace, but nothing would notice if that changed.

It does not look at types. A variable that holds a function counts as unsafe, and we would rather write the arrow inline than add an allow-list entry for it.

It trusts literals completely. A literal containing $& is accepted on the assumption that the author meant it, which is the right default for us but means the gate is not a general check that replacement text is free of patterns.

The bug needed three things at once: computed text in the replacement position, a dollar sign followed by one of four particular characters inside that text, and a check after the write that only looked for what we had added. The second is chance and will keep happening as long as articles talk about shells, templates and variables. The gate removes the first, and the full-body comparison removes the third.

If you guard against this in your own code, with a lint rule, a custom AST check, a grep in CI or simply the habit of always writing () => text, we would like to know how it is set up and whether it has ever fired on real code. Tell us in the comments below, along with any other place where a $ in user-supplied text has changed what your code produced.

來源:DEV Community · dev.to