/** * Deterministic "changed comparison" signal for PR reviews, feeding the * `boundary-change` rule. * * Provenance / design principle: `boundary-change`'s entire domain is * comparisons and threshold literals changing (`>` -> `>=`, `5` -> `6`, * `&&` -> `||`, off-by-one index arithmetic, sign flips). Today the agent * must NOTICE these shapes in the diff before it can even start the rule's * MANDATORY investigation protocol — and the rule's own trigger-tuning * history (#741/#743, see `rules.ts`'s `BOUNDARY_CHANGE.triggers`) shows * noticing is the weak link, not judgment. This module precomputes the * DISCOVERY step only — "here is every comparison-shaped edit in this * diff" — and leaves judgment (intentional? disclosed? breaking?) to the * agent, mirroring `stale-literal-signals.ts` / `catch-discrimination-signals.ts`. * * ## What this scans for, per changed-line pair * * The diff's hunks are walked and REMOVED lines are paired with ADDED * lines within the same hunk (never across hunks — see "Conservative * pairing" below). Each accepted pair is classified as one of: * * (a) **Comparison operator change** — `<`<->`<=`, `>`<->`>=`, * `==`<->`===`, `!=`<->`!==`, `&&`<->`||`, or a bare logical negation * (`!`) added/removed — detected by tokenizing both lines with * {@link OPERATOR_TOKEN_RE}, stripping the matched tokens to form a * "skeleton", and requiring the skeletons match (mod whitespace) so * only the operator itself differs. * (b) **Numeric literal change in a conditional context** — a number * token changed (`5` -> `6`) via the same skeleton-diff technique, * gated on the line containing a conditional/comparison keyword * (`if`, `while`, `?`, `switch`, `case`, `.filter(`/`.some(`/`.every(`/ * `.find(`, or any comparison/logical operator) — per the design, * literal changes with NO such keyword on the line (e.g. an unrelated * config default) are intentionally not flagged here. * (c) **Index-arithmetic off-by-one** — an identifier or `.length` * expression used as an array index gains, loses, or changes a * `+ N` / `- N` delta (`i` <-> `i + 1`, `.length` <-> `.length - 1`), * detected by comparing the per-base "delta signature" extracted from * each line via {@link INDEX_DELTA_RE} / {@link BARE_SUBSCRIPT_RE} / * {@link BARE_LENGTH_RE}. * * ## Conservative pairing * * A removed line is only paired with an added line when (1) they appear in * the same diff hunk (proximity — pairing never crosses a `@@` boundary), * and (2) they share > 70% token overlap (Jaccard over a word/number/ * punctuation-run tokenization) — see {@link tokenOverlap}. This is what * keeps unrelated refactors from false-pairing: a line rewritten beyond * recognition, or a wholly new line with no similar removed counterpart * (new code, not a boundary *change*), never enters the classifiers. * Pairing is greedy first-fit within a hunk's contiguous removed/added * block, not a globally optimal matching — acceptable for a heuristic * discovery aid, not a ground-truth diff algorithm. * * ## Known limitations (kept honest, not papered over) * * - **Compound changes are invisible.** If BOTH the operator and the * literal change on the same line (`> 5` -> `>= 6`), neither classifier * fires: each requires everything OTHER than its own token category to * stay skeleton-identical. A real boundary change can therefore slip * through when it's more than one edit at once — the diff itself * (always rendered separately in ``) is the backstop. * - **`.length`-style index arithmetic is JS/TS-shaped.** Category (c)'s * `.length` pattern doesn't recognize Python's `len(x)`, Go's `len(x)`, * etc. — only a literal `.length` property access. Operator/negation * categories (a) generalize better across C-family languages, but * still miss Python's `and`/`or`/`not` keyword forms (no `&&`/`||`/`!` * in Python) and Ruby's `unless`. Cross-language coverage is partial by * construction, not a bug. * - **Single-line masking only.** {@link maskLine} strips string/template * literals and same-line `//`/`#` comments, but has no cross-line state * — a multi-line `/* ... *‍/` block comment is not recognized as a * comment on its continuation lines. A comparison-shaped token that * happens to sit inside such a continuation is a possible false * positive this scan cannot see. * - **Test-assertion noise is only trivially filtered.** A pair where * BOTH lines are `expect(...)`/`assert(...)` calls in a file matching * {@link TEST_FILE_RE} is skipped — this catches the common case but not * every assertion style (e.g. `.should.equal(...)`, bare `assert x == y` * without a call-like prefix). * - **Negation detection is a raw `!` count, not scope-aware.** Adding an * unrelated `!` elsewhere on an otherwise-similar line (rare in * practice, given the skeleton-equality gate) would still be classified * as a negation toggle. */ import type { SignalContext } from './signal-context.js'; export type ComparisonChangeKind = 'operator' | 'literal' | 'index-arithmetic'; /** A comparison/threshold-shaped edit this PR made, paired old -> new. */ export interface ComparisonChangeCandidate { file: string; /** New-file line number of the added line in the pair. */ line: number; kind: ComparisonChangeKind; /** The old fragment (operator, literal, or index expression). */ oldFragment: string; /** The new fragment. */ newFragment: string; /** One-line explanation of what changed. */ reason: string; } /** Shared shape returned by each per-category classifier before `kind` is attached. */ interface FragmentResult { oldFragment: string; newFragment: string; reason: string; } /** * Classify one masked old/new line pair against all three categories. * Checked in this fixed order; exposed for direct unit testing of the * heuristics, independent of diff/hunk plumbing. Returns null when none * of the three classifiers recognize the pair. */ export declare function classifyLinePair(oldLine: string, newLine: string): ({ kind: ComparisonChangeKind; } & FragmentResult) | null; /** * Find every comparison/threshold-shaped edit in this PR's diff: paired * removed/added lines (same hunk, > 70% token overlap) classified as an * operator change, a conditional-context literal change, or an * index-arithmetic off-by-one. Returns [] when there is no diff. Exposed * for testing. */ export declare function computeComparisonChanges(context: SignalContext): ComparisonChangeCandidate[]; /** * Render comparison-change candidates as a `` * block. Returns '' when there are none. Caps at MAX_CANDIDATES with an * explicit omission note — never truncates silently. Exposed for testing. * * `blastRadiusPresent` (default `true`, matching every existing caller's * prior behavior) — see `buildHeader`'s doc comment. */ export declare function renderComparisonChangeCandidates(candidates: ComparisonChangeCandidate[], blastRadiusPresent?: boolean): string; /** * Build the `` section from the review * context. Returns '' when the PR's diff has no qualifying comparison * change, or there's no diff at all. `blastRadiusPresent` — see * `buildHeader`'s doc comment; forwarded to `renderComparisonChangeCandidates`. */ export declare function renderComparisonChangeSection(context: SignalContext, blastRadiusPresent?: boolean): string; export {}; //# sourceMappingURL=comparison-change-signals.d.ts.map