Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .github/impact-gate/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
# CI installs it with `pip install --require-hashes --no-deps`, so nothing here
# resolves at install time and a transitive dependency cannot float.
#
# The top-level pins are impact-gate==0.4.1 and lizard==1.23.0 (Lizard 1.24.0 is
# excluded by ImpactGate's own metadata). To regenerate after changing them, from
# the repository root:
# The top-level pins are impact-gate==0.4.1, lizard==1.23.0 (Lizard 1.24.0 is
# excluded by ImpactGate's own metadata) and pygments==2.21.0, which
# scripts/impact-gate.py imports directly. uv still annotates pygments "via
# lizard". To regenerate after changing them, from the repository root:
#
# printf 'impact-gate==0.4.1\nlizard==1.23.0\n' \
# printf 'impact-gate==0.4.1\nlizard==1.23.0\npygments==2.21.0\n' \
# | uv pip compile - --generate-hashes --universal --python-version 3.12 \
# --no-header -o .github/impact-gate/requirements.txt
#
Expand Down
15 changes: 10 additions & 5 deletions .github/workflows/impact.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,16 @@ jobs:
id: target
env:
TARGET_BRANCH: ${{ github.base_ref || github.ref_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha || github.sha }}
EVENT_SHA: ${{ github.sha }}
PR_HEAD: ${{ github.event.pull_request.head.sha }}
run: |
# Keep one logical target ref across push/PR events, pinned to the
# event's commit even if the remote branch advances while queued.
git update-ref "$BASE_REF" "$BASE_SHA"
# commit this run measures from even if the remote branch advances
# while queued. A PR pins its merge commit's first parent, not
# pull_request.base.sha, which lags main (see `pin` in the driver).
args=(--event-sha "$EVENT_SHA")
if [[ -n "$PR_HEAD" ]]; then args+=(--pr-head "$PR_HEAD"); fi
python scripts/impact-gate.py pin --base "$BASE_REF" "${args[@]}"
echo "BASELINE_DIR=$RUNNER_TEMP/impact-baseline" >> "$GITHUB_ENV"
python - <<'PY'
import hashlib
Expand All @@ -70,9 +75,9 @@ jobs:
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ${{ env.BASELINE_DIR }}
key: impact-${{ hashFiles('.github/impact-gate/**', 'scripts/impact-gate.py', '.github/workflows/impact.yml') }}-${{ steps.target.outputs.key }}-${{ github.event.pull_request.base.sha || github.sha }}
key: impact-${{ hashFiles('.github/impact-gate/**', 'scripts/impact-gate.py', '.github/workflows/impact.yml') }}-${{ steps.target.outputs.key }}-${{ steps.target.outputs.sha }}
restore-keys: |
impact-${{ hashFiles('.github/impact-gate/**', 'scripts/impact-gate.py', '.github/workflows/impact.yml') }}-${{ steps.target.outputs.key }}-${{ github.event.pull_request.base.sha || github.sha }}-
impact-${{ hashFiles('.github/impact-gate/**', 'scripts/impact-gate.py', '.github/workflows/impact.yml') }}-${{ steps.target.outputs.key }}-${{ steps.target.outputs.sha }}-
impact-${{ hashFiles('.github/impact-gate/**', 'scripts/impact-gate.py', '.github/workflows/impact.yml') }}-${{ steps.target.outputs.key }}-

- name: Validate or build baselines
Expand Down
2 changes: 1 addition & 1 deletion docs/plans/2026-10-08-impact-gate-rollout.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Manual comparison of representative Stack source at the base SHA also found a re

The inspected Rust `is_fresh_at` in `packages/stack-encrypt/src/keyset.rs` is correctly reported at 103–105 (CC 1). Go `StatusError` and `PackedResult` in `languages/golang/internal/guest/status.go` are correctly reported at 45–106 (CC 29) and 111–116 (CC 2). These are sample checks, not proof that either parser is universally correct.

Keep the report advisory. Do not modify production source to satisfy this parser. These silent span defects are different from process failures: successful CLI exit cannot establish accurate function boundaries.
Keep the report advisory. Do not modify production source to satisfy this parser. These silent span defects are different from process failures: successful CLI exit cannot establish accurate function boundaries. The report now labels a function row `⚠️ span may be misattributed` when lizard's span contains a later top-level declaration (#1219). A span that ends on the first line of a multi-line declaration is not labelled, so `parseSelectorSegments` above stays unlabelled: the neighbour's body is still outside the span. A span that ends on a one-line declaration, such as `export const LIMIT = 10`, is labelled, because that whole declaration is inside it.

## Coverage and interpretation

Expand Down
29 changes: 24 additions & 5 deletions scripts/__tests__/impact-workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,16 @@ describe('advisory change impact', () => {
expect(restore.with.key).toContain("'scripts/impact-gate.py'")
expect(restore.with.key).toContain("'.github/workflows/impact.yml'")
expect(restore.with.key).toContain('steps.target.outputs.key')
expect(restore.with.key).toContain(
'github.event.pull_request.base.sha || github.sha',
)
// The commit `pin` measured from, never pull_request.base.sha: that lags
// the target whenever it moved on after the PR last synced.
expect(restore.with.key).toContain('steps.target.outputs.sha')
// This commit's run-suffixed saves first, then the target branch's
// ancestors — never a broader prefix.
expect(restore.with['restore-keys'].trim().split('\n')).toEqual([
`${restore.with.key}-`,
restore.with.key.replace(
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal Actions expression
'-${{ github.event.pull_request.base.sha || github.sha }}',
'-${{ steps.target.outputs.sha }}',
'-',
),
])
Expand All @@ -125,6 +125,25 @@ describe('advisory change impact', () => {
expect(save.with.path).toBe(restore.with.path)
})

it('measures a pull request from the commit its merge was built on', () => {
// pull_request.base.sha lags main whenever main moved on after the PR last
// synced, while the checkout is refs/pull/N/merge on main's current tip;
// scoring from the stale base counted main's newer commits as the PR's.
// `pin` takes the merge commit's first parent instead (test_impact_gate.py).
const job = readWorkflow(workflowPath).jobs.impact
const target = job.steps.find((step) => step.id === 'target')
expect(target.run).toContain(
'scripts/impact-gate.py pin --base "$BASE_REF"',
)
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal Actions expression
expect(target.env.EVENT_SHA).toBe('${{ github.sha }}')
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal Actions expression
expect(target.env.PR_HEAD).toBe('${{ github.event.pull_request.head.sha }}')
expect(readFileSync(join(REPO_ROOT, workflowPath), 'utf8')).not.toContain(
'pull_request.base.sha }}',
)
})

it('replaces a corrupt same-commit cache instead of colliding with its key', () => {
// A cache key is immutable. When the entry saved for a commit fails
// validation, prepare rebuilds; saving under that same key is refused
Expand All @@ -142,7 +161,7 @@ describe('advisory change impact', () => {
const run = (event, runId, attempt, restored) => {
const context = {
github: { ...event.github, run_id: runId, run_attempt: attempt },
steps: { target: { outputs: { key: 'main' } } },
steps: { target: { outputs: { key: 'main', sha: event.github.sha } } },
}
const primary = interpolate(restore.with.key, context)
context.steps.restore = {
Expand Down
163 changes: 159 additions & 4 deletions scripts/impact-gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,22 @@
so tests can exercise the same workflow against isolated Git histories.
"""
import argparse
import bisect
import hashlib
import importlib.metadata
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile

from impact_gate.baseline import build_baseline, save_baseline
from impact_gate.core.config import MeasureConfig
import lizard
from pygments.lexers import JavascriptLexer, TypeScriptLexer
from pygments.token import Comment, Keyword, Name, Operator, Punctuation

ROOT = Path(__file__).resolve().parents[1]
POLICY = ROOT / '.github' / 'impact-gate'
Expand Down Expand Up @@ -138,6 +143,7 @@ def report(args: argparse.Namespace, policy: str) -> None:
f'Baseline target: `{args.base}`; commit: `{meta["head"]}`. '
f'History: latest {HISTORY_COMMITS} first-parent commits; recursively expanded '
'merge observations may exceed this limit. p90/p98 are advisory; prior weight: 200.')
spans: dict = {} # Both scopes list the same functions.
for scope, label in SCOPES.items():
path = args.cache_dir / f'{scope}.json'
n = read_baseline(path, meta['head'])
Expand All @@ -148,18 +154,167 @@ def report(args: argparse.Namespace, policy: str) -> None:
'--enforcement', 'warn', '--format', 'markdown', '--warn-percentile', '90',
'--block-percentile', '98', '--curve-prior-weight', '200',
'--baseline-file', str(path), '--measure-config', str(POLICY / f'{scope}.yml'))
publish(result)
publish(mark_misread_spans(result, args.base, spans))


# lizard's TypeScript reader sometimes misses a function's closing brace (an
# arrow callback followed by a ternary returning an object literal is one
# trigger) and runs the span on into the next top-level declaration. Edits to
# that neighbour are then charged to a function the PR never touched. In
# formatted code nothing inside a function body starts at column 0, so a
# top-level declaration strictly inside a span is the tell. Pygments decides
# what is code: a column-0 `const` inside a template literal is string text.
# Limit: when the misread makes lizard drop functions instead (the same
# trigger inside a class method reports only an `(anonymous)` function), the
# edits land on a row that has no span to check, and nothing is marked.
SPAN_SUFFIXES = ('.ts', '.tsx', '.js', '.jsx')
DECLARATION = re.compile(r'@|(export|import|declare|abstract|async|function|const|let|var'
r'|class|interface|type|enum)\b')
# Tokens that can only continue the previous line's expression. Unary
# operators (`-`, `!`) are left out: a line may begin a new statement with one.
LEADING = frozenset(('|', '&', '.', '?.', '?', ':', '=>', '&&', '||', '??', '=', ','))
LOCATION = re.compile(r'^\| [^|]* \| `(?P<path>[^`:]+):(?P<name>[^`]+)` \| '
r'(?:`(?P<owner>[^`]+)`|_file scope_) \|')
MISREAD = '⚠️ span may be misattributed'
MISREAD_NOTE = (f'{MISREAD}: lizard read its line span to include a later top-level '
'declaration, so edits to that code are charged to this function. Check '
'the diff before refactoring it. Scores and grades are unchanged.')


def declaration_lines(path: str, source: str) -> dict[int, bool]:
"""Line of each top-level declaration, mapped to whether it ends on that line."""
lexer = TypeScriptLexer() if path.endswith(('.ts', '.tsx')) else JavascriptLexer()
starts = [0]
for line in source.split('\n')[:-1]:
starts.append(starts[-1] + len(line) + 1)
# Column 0 is not top level in unformatted code, so count braces too.
# Brackets of every kind decide whether a declaration closes on its line.
declarations, depth, nesting, open_at_end = set(), 0, 0, {}
first, last = {}, {} # Each line's first and last code token.
for offset, token, value in lexer.get_tokens_unprocessed(source):
line = bisect.bisect_right(starts, offset)
if value.strip() and token not in Comment:
first.setdefault(line, (token, value))
last[line] = (token, value, offset)
if (depth == 0 and starts[line - 1] == offset
and (token in Keyword or token in Name.Decorator)
and DECLARATION.match(source, offset)):
declarations.add(line)
elif token in Punctuation:
depth = max(0, depth + value.count('{') - value.count('}'))
nesting = max(0, nesting + sum(map(value.count, '({[')) - sum(map(value.count, ')}]')))
open_at_end[line] = nesting > 0
# A balanced first line still continues when it ends on an operator
# (`type T =`, `=>`) or the next code line leads with one (`| 'b'`, `.next()`).
code_lines = sorted(first)

def closes(line: int, value: str, offset: int) -> bool:
# An operator that ends its operand: the `>` of a generic the line
# opened, or a postfix `!`/`++`/`--` written against that operand.
text = source[starts[line - 1]:offset + len(value)]
if value.strip('>') == '':
return text.count('<') >= text.count('>')
return value in ('!', '++', '--') and re.match(r'[\w)\]]', source[offset - 1]) is not None

def continues(line: int) -> bool:
following = code_lines[bisect.bisect_right(code_lines, line):]
token, value, offset = last.get(line, (None, '', 0))
trails = ((token in Operator and not closes(line, value, offset))
or value in ('=>', ',', '.'))
return trails or (bool(following) and first[following[0]][1] in LEADING)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return {line: not open_at_end.get(line, False) and not continues(line)
for line in declarations}


def misread_functions(revisions: list[str], path: str) -> set[tuple[str, str]]:
"""(owner, name) of each function whose span, in any revision, swallows a declaration."""
found = set()
for revision in revisions:
shown = subprocess.run(['git', 'show', f'{revision}:{path}'], capture_output=True)
if shown.returncode != 0: # Added or deleted on one side.
continue
source = shown.stdout.decode('utf-8', errors='replace')
declarations = declaration_lines(path, source)
for function in lizard.analyze_file.analyze_source_code(path, source).function_list:
owner, _, name = function.name.rpartition('::')
# lizard often ends a span on the next declaration's first line.
# That boundary is harmless unless the whole declaration is there.
if name != '(anonymous)' and any(
function.start_line < n < function.end_line
or (n == function.end_line and whole)
for n, whole in declarations.items()):
found.add((owner, name))
return found


def mark_misread_spans(markdown: str, base: str, cache: dict | None = None) -> str:
"""Label function rows whose span lizard misread. Never fails the report."""
cache = {} if cache is None else cache
try:
if 'revisions' not in cache:
cache['revisions'] = [run('git', 'merge-base', base, 'HEAD'), 'HEAD']
marked = False
lines = markdown.split('\n')
for index, line in enumerate(lines):
match = LOCATION.match(line)
if not match or not match['path'].endswith(SPAN_SUFFIXES):
continue
path = match['path']
if path not in cache:
cache[path] = misread_functions(cache['revisions'], path)
if (match['owner'] or '', match['name']) in cache[path]:
cell = f'`{path}:{match["name"]}`'
lines[index] = line.replace(cell, f'{cell} {MISREAD}', 1)
marked = True
text = '\n'.join(lines)
return text + f'\n\n{MISREAD_NOTE}' if marked else text
except Exception as error: # A cosmetic label must not cost the report.
return markdown + f'\n\n_Note: could not check function spans ({describe(error)})._'


def pin(args: argparse.Namespace) -> None:
"""Point the target ref at the commit this run's change is measured from.

A push measures from its own commit. A pull request checks out
refs/pull/N/merge, GitHub's merge of the PR into the target branch's tip
when the event fired (a re-run reuses it). The event's pull_request.base.sha
is older whenever the target moved on after the PR last synced, and scoring
from it counts the target's newer commits as the PR's. The merge's first
parent is the tip it was built on, so HEAD^1..HEAD is exactly what the PR
adds.
"""
base = args.event_sha
if args.pr_head:
parents = run('git', 'rev-list', '--parents', '-n', '1', 'HEAD').split()[1:]
if len(parents) != 2 or parents[1] != args.pr_head:
raise ValueError(f'HEAD is not the merge of {args.pr_head} into the target; '
'check out refs/pull/N/merge.')
base = parents[0]
run('git', 'update-ref', args.base, base)
output('sha', base)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('command', choices=('prepare', 'report'))
parser.add_argument('command', choices=('pin', 'prepare', 'report'))
parser.add_argument('--base', required=True)
parser.add_argument('--cache-dir', type=Path, required=True)
parser.add_argument('--cache-dir', type=Path)
parser.add_argument('--refresh', action='store_true')
parser.add_argument('--event-sha', help='pin: the commit the event was raised for')
parser.add_argument('--pr-head', help='pin: the pull request head, on pull_request events')
args = parser.parse_args()
args.cache_dir = args.cache_dir.resolve()
if args.command == 'pin':
if not args.event_sha:
parser.error('pin requires --event-sha')
elif args.cache_dir is None:
parser.error(f'{args.command} requires --cache-dir')
else:
args.cache_dir = args.cache_dir.resolve()
try:
if args.command == 'pin':
pin(args)
return 0
head = run('git', 'rev-parse', '--verify', args.base + '^{commit}')
policy = identity()
if args.command == 'prepare':
Expand Down
Loading
Loading