/**
* Parse `git diff --unified=0` into the set of line numbers a change ADDED, so a
* lint finding can be attributed to the change that introduced it rather than to
* the file it happens to live in.
*
* This is what lets the i18n literal guard apply repo-wide to new code without
* demanding that the surrounding legacy file be migrated first — the same model
* as `golangci-lint --new-from-rev`, which this repo already uses for Go.
*/
/** Hunk header: `@@ -oldStart[,oldCount] +newStart[,newCount] @@ optional context` */
const HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
/**
* Map of repo-relative path -> array of inclusive `[start, end]` line ranges that
* the diff added, in the NEW file's numbering.
*
* Deleted files and pure-deletion hunks contribute nothing: there is no new line
* to attribute a finding to.
*
* @param {string} diffText output of `git diff --unified=0 ...
`
* @returns {Map>}
*/
export function parseAddedLineRanges(diffText) {
const ranges = new Map();
let current = null;
for (const line of diffText.split("\n")) {
// Every file entry starts here, so reset first. Entries that emit no `+++`
// header at all — a 100%-similarity rename, a binary file, a mode-only
// change — would otherwise leave the PREVIOUS file selected, and any hunk
// that followed would be attributed to it. Not reachable with today's git
// output, but it is a one-line invariant instead of a standing assumption.
if (line.startsWith("diff --git ")) {
current = null;
continue;
}
if (line.startsWith("+++ ")) {
const target = line.slice(4).trim();
// `+++ /dev/null` marks a deletion; `+++ b/path` is the new file.
current = target === "/dev/null" ? null : target.replace(/^b\//, "");
if (current && !ranges.has(current)) ranges.set(current, []);
continue;
}
if (!current || !line.startsWith("@@")) continue;
const match = HUNK.exec(line);
if (!match) continue;
const start = Number(match[1]);
// A missing count means exactly one line; an explicit 0 means deletion-only.
const count = match[2] === undefined ? 1 : Number(match[2]);
if (count > 0) ranges.get(current).push([start, start + count - 1]);
}
return ranges;
}
/**
* Whether `line` falls inside any of `ranges`.
*
* @param {Array<[number, number]>} ranges
* @param {number} line
*/
export function lineInRanges(ranges, line) {
if (!ranges) return false;
return ranges.some(([start, end]) => line >= start && line <= end);
}