Kaynağa Gözat

feat(ci): weight approvals by production blame ownership

turtle1999 3 hafta önce
ebeveyn
işleme
cef92ed40b

+ 6 - 0
.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.md
+2026-09-11-production-blame-approval-weight.md: ea7e4208419403de725110ef678c94bf24cef102
+2026-09-11-production-blame-approval-weight.zh.md: 0211e1500a04e90c7986c8203cda37d910504fa3

+ 35 - 0
.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.md

@@ -0,0 +1,35 @@
+# Agent Note: Weight approvals by changed production-line ownership
+
+Status: implemented
+
+English | [中文](2026-09-11-production-blame-approval-weight.zh.md)
+
+## Problem
+
+A fixed one-point reviewer weight does not reflect authorship of the code a pull request changes. Directory-level ownership can reward unrelated code in the same folder and distort the contribution relevant to the review.
+
+## Decision
+
+The [approval policy](../../../../.github/review-ownership/README.md) scales a one-point approval by `min(2, 1 + 2 × ownedLines / totalLines)` over changed old production lines. Ownership of 0% gives one point, 25% gives 1.5 points, and 50% or more gives two points. Scores are not rounded before comparison with the two-point success threshold. The merge base supplies both classification and blame, while GitHub associates blame commits with reviewer accounts. Unlinked authors remain in the denominator. Additions have no prior owner and contribute no lines; an empty denominator produces no boost.
+
+The publisher reads complete Git history without checking out PR code. A maintained lexer separates comments from code across the repository’s source languages. Author lookups batch commits and all reviewers share one measurement. Existing [pending-status semantics](2026-09-09-blocked-weighted-approvals-remain-pending.md) and [review-event validation](2026-09-10-approval-review-workflow-identity.md) remain independent requirements.
+
+## Alternatives considered
+
+**A hard 50% cutoff.** A continuous weight reflects partial ownership instead of assigning the same weight to 0% and 49%.
+
+**Directory-weighted ownership.** Code elsewhere in a changed directory does not establish ownership of the lines under review.
+
+**Include newly added lines in the denominator.** Those lines have no merge-base owner and would dilute the authorship signal for existing code.
+
+**Match author names or email strings to reviewer logins.** Display names are not account identifiers, and one account can own commits under multiple emails. GitHub’s commit-author account association supplies that mapping.
+
+## Consequences
+
+History fetching dominates cold execution. On 2026-09-11, local measurements of PR heads `23c77d10aef787df9dfc52b3bd3803122e2ba5bc` and `06be7d3900fd80d28374a5b42442091c55e3f542` against bases `16bf879af2033737b55b24b3896017a388fa974a` and `128bc0784cf9019aba9f5eb0e01865d28b54f241` count 73 and 535 old production lines across 4 and 14 files. Three local runs take 0.31–0.32 seconds and 0.87–0.93 seconds; one batched author query adds approximately one second per PR. A fresh single-branch bare clone of master takes 47 seconds on the same host. These are host observations, not CI time guarantees.
+
+Blame measures last-touch authorship, not review quality or semantic expertise. The production classifier excludes unsupported source locations and file extensions; adding shipped source outside its inventory requires extending that classifier. Lexer classification is lexical rather than a semantic test of executable behavior. Evaluation errors retain the error status instead of silently awarding or withholding the boost.
+
+## Verification
+
+[Policy tests](../../../../.github/review-ownership/check-approval.test.mjs) cover the threshold, zero denominators, blockers, shared measurements, and failed attribution. [Author tests](../../../../.github/review-ownership/blame-ownership.test.mjs) cover batching, account aggregation, and incomplete responses. [Git integration tests](../../../../.github/review-ownership/test_blame_production.py) exercise merge bases, renames, deletions, mixed comment lines, exclusions, shallow history, and fetching without checking out PR code.

+ 35 - 0
.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 按变更生产代码行的归属调整审批权重
+
+Status: implemented
+
+[English](2026-09-11-production-blame-approval-weight.md) | 中文
+
+## Problem
+
+固定的一分评审权重无法反映评审者对拉取请求所修改代码的作者贡献。目录级归属可能奖励同一文件夹中的无关代码,扭曲与本次评审有关的贡献。
+
+## Decision
+
+[审批策略](../../../../.github/review-ownership/README.md) 按变更旧生产代码行的归属比例,以 `min(2, 1 + 2 × ownedLines / totalLines)` 调整一分批准的权重。归属比例为 0% 时计一分,25% 时计 1.5 分,50% 及以上时计两分。分数在与两分通过线比较前不做舍入。合并基点同时提供代码分类和 blame 依据,GitHub 将归属提交关联到评审者账号。无法关联账号的作者仍计入分母。新增行没有原作者,不计入行数;分母为空时不提升权重。
+
+发布器读取完整 Git 历史,不检出 PR 代码。维护中的词法分析器区分仓库各源码语言中的注释与代码。作者查询按提交批量执行,所有评审者共用一次统计。现有的[待定状态语义](2026-09-09-blocked-weighted-approvals-remain-pending.zh.md)和[评审事件验证](2026-09-10-approval-review-workflow-identity.zh.md)仍是独立要求。
+
+## Alternatives considered
+
+**50% 硬阈值。** 连续权重反映部分归属,不将 0% 与 49% 赋予相同权重。
+
+**目录加权归属。** 变更目录中其他代码的归属不能证明待评审代码行的归属。
+
+**将新增行计入分母。** 这些行没有合并基点上的作者,会稀释既有代码的作者贡献信号。
+
+**将作者姓名或邮箱字符串与评审者登录名匹配。** 显示名称不是账号标识,一个账号也可能使用多个邮箱提交代码。GitHub 的提交作者账号关联提供该映射。
+
+## Consequences
+
+冷启动的主要开销是拉取历史。2026-09-11,在本机分别用基点 `16bf879af2033737b55b24b3896017a388fa974a` 和 `128bc0784cf9019aba9f5eb0e01865d28b54f241` 测量 PR 头提交 `23c77d10aef787df9dfc52b3bd3803122e2ba5bc` 和 `06be7d3900fd80d28374a5b42442091c55e3f542`,得到 4 和 14 个文件中的 73 和 535 行旧生产代码。三次本地运行分别耗时 0.31–0.32 秒和 0.87–0.93 秒;每个 PR 的一次批量作者查询额外耗时约一秒。同一主机上,全新单分支裸克隆 master 耗时 47 秒。这些是本机观测值,不是 CI 耗时保证。
+
+Blame 衡量最后修改者归属,不代表评审质量或语义上的专业能力。生产代码分类器排除未支持的源码位置和扩展名;在其清单之外添加交付源码时,必须扩展分类器。词法分类不判断代码是否在语义上可执行。评估错误保留错误状态,不静默授予或取消加权。
+
+## Verification
+
+[策略测试](../../../../.github/review-ownership/check-approval.test.mjs)覆盖阈值、零分母、阻塞评审、共享统计和归属计算失败。[作者测试](../../../../.github/review-ownership/blame-ownership.test.mjs)覆盖批量查询、账号聚合和不完整响应。[Git 集成测试](../../../../.github/review-ownership/test_blame_production.py)验证合并基点、重命名、删除、混合注释行、排除规则、浅历史,以及不检出 PR 代码的拉取过程。

+ 1 - 0
.github/review-ownership/.gitignore

@@ -0,0 +1 @@
+__pycache__/

+ 7 - 3
.github/review-ownership/README.md

@@ -19,6 +19,10 @@ The weighted approval workflow exposes two pull-request checks. The `weighted ap
 
 Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author and reviewers without write permission do not count.
 
+A one-point approval receives weight `min(2, 1 + 2 × ownedLines / totalLines)` from modified or deleted old production-code lines, attributed by `git blame` at the merge base of the PR base and head. Ownership of 0%, 25%, and 50% gives 1, 1.5, and 2 points; higher ownership remains capped at 2. The success threshold remains 2 total points, without rounding the score. New lines do not enter the denominator, and an empty denominator gives no boost. Existing two-point weights remain unchanged. GitHub commit-author accounts identify reviewers across author emails; unlinked authors remain in the denominator without contributing to a reviewer. The publisher logs each eligible reviewer’s owned and total line counts.
+
+Production source means supported code files under `src/` in `packages/`, `apps/`, `python/`, and `native/`, plus the shipped Python runtime bootstrap. The [classifier](blame-production.py) excludes documentation, tests, fixtures, snapshots, test support, examples, generated source, dependencies, vendored code, declarations, comments, and blank lines. Pygments lexers distinguish comments from strings; mixed code/comment lines count, as do C preprocessor directives. Classification uses the old path and content, so changes to the PR’s file locations or generated headers cannot remove old lines from the denominator. Pure renames have no changed lines; renames with edits use the old path for blame.
+
 Each reviewer contributes only the current `APPROVED` or `CHANGES_REQUESTED` decision that GitHub returns. A `DISMISSED` record clears that reviewer's standing decision, including earlier approvals. Comment-only and pending records do not replace a decision. Reviews from deleted accounts and reviewers without current repository access do not count. The workflow does not invalidate an approval by its review commit; the repository's native pull-request rules own stale-review and latest-push requirements.
 
 The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, or becomes a draft. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed pull-request or review event.
@@ -27,7 +31,7 @@ The publisher runs when a pull request opens, synchronizes, reopens, becomes rea
 
 ## Security
 
-The status-writing job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews are treated as API data and escaped in logs.
+The status-writing job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. When an eligible approval has a one-point weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews are treated as API data and escaped in logs.
 
 Approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the program or policy for its own run.
 
@@ -35,10 +39,10 @@ Approval policy changes take effect only after they merge into the default branc
 
 ## Verification
 
-Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI.
+Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI. The Python SDK job runs `uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'` for real Git histories, lexers, renames, shallow-history rejection, and the publisher’s fetch/analysis integration.
 
 <a id="dev-note"></a>
 
 ## Dev Note
 
-None.
+[Production blame weighting](../../.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.md) records the scoring rationale and measured costs.

+ 81 - 0
.github/review-ownership/blame-ownership.mjs

@@ -0,0 +1,81 @@
+/** Merge-base production ownership for approval scoring; PR blobs are data, never programs. */
+import { execFile } from 'node:child_process'
+import { fileURLToPath } from 'node:url'
+import { promisify } from 'node:util'
+
+const exec = promisify(execFile)
+const SHA = /^[0-9a-f]{40}$/u
+
+/**
+ * Resolve GitHub author accounts for counted commits, retaining unknown authors in the denominator.
+ * @param {{totalLines: number, commitLines: Record<string, number>}} measurement Local blame counts.
+ * @param {string} repository Owner/name.
+ * @param {(path: string, options: object) => Promise<unknown>} api GitHub API caller.
+ * @returns {Promise<{totalLines: number, reviewerLines: Record<string, number>}>} Case-folded account counts.
+ */
+export async function resolveBlameAuthors(measurement, repository, api) {
+  const entries = Object.entries(measurement.commitLines)
+  if (!Number.isSafeInteger(measurement.totalLines) || measurement.totalLines < 0
+    || entries.some(([sha, count]) => !SHA.test(sha) || !Number.isSafeInteger(count) || count <= 0)
+    || entries.reduce((sum, [, count]) => sum + count, 0) !== measurement.totalLines) {
+    throw new Error('invalid production blame counts')
+  }
+  const [owner, name] = repository.split('/')
+  const reviewerLines = Object.create(null)
+  for (let offset = 0; offset < entries.length; offset += 50) {
+    const batch = entries.slice(offset, offset + 50)
+    const fields = batch.map(([sha], index) => `c${index}: object(oid: "${sha}") { ... on Commit { author { user { login } } } }`)
+    const response = await api('/graphql', {
+      method: 'POST',
+      body: {
+        query: `query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ${fields.join('\n')} } }`,
+        variables: { owner, name },
+      },
+    })
+    if (response.errors?.length || !response.data?.repository) throw new Error('GitHub blame author lookup failed')
+    for (const [index, [, count]] of batch.entries()) {
+      const author = response.data.repository[`c${index}`]?.author
+      if (!author || !Object.hasOwn(author, 'user')) throw new Error('GitHub returned no blame commit author')
+      if (author.user === null) continue
+      const login = author.user.login
+      if (typeof login !== 'string' || !/^[A-Za-z0-9-]+(?:\[bot\])?$/u.test(login)) {
+        throw new Error('GitHub returned an invalid blame author login')
+      }
+      const key = login.toLowerCase()
+      reviewerLines[key] = (reviewerLines[key] ?? 0) + count
+    }
+  }
+  return { totalLines: measurement.totalLines, reviewerLines }
+}
+
+/**
+ * Fetch complete history without checking out the PR and measure its old production lines once.
+ * @param {{repository: string, number: number, headSha: string}} pull Reviewed pull request.
+ * @param {(path: string, options?: object) => Promise<unknown>} api GitHub API caller.
+ * @returns {Promise<{totalLines: number, reviewerLines: Record<string, number>}>} Production ownership.
+ */
+export async function productionOwnership(pull, api) {
+  const current = await api(`/repos/${pull.repository}/pulls/${pull.number}`)
+  if (current.head?.sha !== pull.headSha || !SHA.test(current.base?.sha ?? '')) {
+    throw new Error('pull request changed or has no valid base SHA')
+  }
+  const token = process.env.GITHUB_TOKEN
+  if (!token) throw new Error('GITHUB_TOKEN is not set')
+  const server = process.env.GITHUB_SERVER_URL ?? 'https://github.com'
+  const environment = {
+    ...process.env,
+    GIT_TERMINAL_PROMPT: '0',
+    GIT_CONFIG_COUNT: '1',
+    GIT_CONFIG_KEY_0: `http.${server}/.extraheader`,
+    GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}`,
+  }
+  const { stdout: shallow } = await exec('git', ['rev-parse', '--is-shallow-repository'])
+  await exec('git', [
+    'fetch', '--no-tags', ...(shallow.trim() === 'true' ? ['--unshallow'] : []),
+    `${server}/${pull.repository}.git`, current.base.sha, `refs/pull/${pull.number}/head`,
+  ], { env: environment, maxBuffer: 16 * 1024 * 1024 })
+  const { stdout } = await exec('python3', [
+    fileURLToPath(new URL('blame-production.py', import.meta.url)), current.base.sha, pull.headSha,
+  ], { maxBuffer: 16 * 1024 * 1024 })
+  return resolveBlameAuthors(JSON.parse(stdout), pull.repository, api)
+}

+ 47 - 0
.github/review-ownership/blame-ownership.test.mjs

@@ -0,0 +1,47 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import { resolveBlameAuthors } from './blame-ownership.mjs'
+
+const sha = number => number.toString(16).padStart(40, '0')
+
+test('combines commit author accounts and retains unlinked authors in the denominator', async () => {
+  const result = await resolveBlameAuthors({
+    totalLines: 10, commitLines: { [sha(1)]: 2, [sha(2)]: 3, [sha(3)]: 5 },
+  }, 'owner/repo', async (path, { body }) => {
+    assert.equal(path, '/graphql')
+    assert.deepEqual(body.variables, { owner: 'owner', name: 'repo' })
+    assert.match(body.query, /c0: object/u)
+    return { data: { repository: {
+      c0: { author: { user: { login: 'Writer' } } },
+      c1: { author: { user: { login: 'writer' } } },
+      c2: { author: { user: null } },
+    } } }
+  })
+  assert.equal(result.totalLines, 10)
+  assert.deepEqual({ ...result.reviewerLines }, { writer: 5 })
+})
+
+test('batches author lookup and skips zero-line changes', async () => {
+  let calls = 0
+  await resolveBlameAuthors({
+    totalLines: 51, commitLines: Object.fromEntries(Array.from({ length: 51 }, (_, index) => [sha(index), 1])),
+  }, 'owner/repo', async () => {
+    const length = calls++ === 0 ? 50 : 1
+    return { data: { repository: Object.fromEntries(Array.from({ length }, (_, index) =>
+      [`c${index}`, { author: { user: null } }])) } }
+  })
+  assert.equal(calls, 2)
+  await resolveBlameAuthors({ totalLines: 0, commitLines: {} }, 'owner/repo', async () => {
+    throw new Error('empty changes must not query authors')
+  })
+})
+
+test('rejects incomplete or failed author lookups rather than lowering the denominator', async () => {
+  for (const response of [{ errors: [{ message: 'rate limited' }] }, { data: { repository: { c0: null } } }]) {
+    await assert.rejects(resolveBlameAuthors({ totalLines: 1, commitLines: { [sha(1)]: 1 } },
+      'owner/repo', async () => response), /author/u)
+  }
+  await assert.rejects(resolveBlameAuthors({ totalLines: 2, commitLines: { [sha(1)]: 1 } },
+    'owner/repo', async () => { throw new Error('invalid counts must not reach GitHub') }), /invalid production/u)
+})

+ 127 - 0
.github/review-ownership/blame-production.py

@@ -0,0 +1,127 @@
+"""Count changed old production lines by merge-base blame commit; never execute PR files."""
+
+import argparse
+from collections import Counter
+import json
+import os
+from pathlib import PurePosixPath
+import re
+import subprocess
+
+from pygments import lex
+from pygments.lexers import get_lexer_for_filename
+from pygments.token import Comment, Literal
+
+
+EXCLUDED = frozenset((
+    'vendor', 'node_modules', 'dist', 'lib', 'build', 'coverage', 'target',
+    'test', 'tests', '__tests__', 'fixture', 'fixtures', 'snapshot', 'snapshots',
+    'test-support', 'support', 'examples', 'docs', 'gen', 'generated', 'generated-effect',
+))
+EXTENSIONS = frozenset(('.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.scss', '.py', '.c', '.h', '.cpp', '.hpp', '.rs'))
+GENERATED = re.compile(r'auto-generated|automatically generated|generated by|do not edit', re.I)
+SHA = re.compile(r'[0-9a-f]{40}')
+
+
+def git(repo, *args):
+    """Run a bounded read-only Git command without external diff drivers or replacements."""
+    return subprocess.run(
+        ['git', '-C', repo, '--no-replace-objects', *args], check=True,
+        stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120,
+        env={**os.environ, 'GIT_TERMINAL_PROMPT': '0'},
+    ).stdout
+
+
+def production_path(path):
+    """Recognize shipped source roots, excluding tests, generated files and tooling."""
+    name = PurePosixPath(path)
+    return (
+        name.parts[0] in ('packages', 'apps', 'python', 'native')
+        and 'src' in name.parts
+        and not EXCLUDED.intersection(name.parts)
+        and not re.search(r'\.(?:test|spec|e2e|gen|generated|d)\.', name.name)
+        and name.suffix in EXTENSIONS
+    ) or path == 'python/sdk-runtime/runtime-bootstrap.mjs'
+
+
+def code_lines(path, source):
+    """Return physical lines containing non-comment, nonblank tokens, including mixed lines."""
+    if GENERATED.search(source[:1000]):
+        return set()
+    lexer = get_lexer_for_filename(path, stripnl=False, ensurenl=False)
+    lines = set()
+    line = 1
+    for kind, value in lex(source, lexer):
+        for index, fragment in enumerate(value.split('\n')):
+            if index:
+                line += 1
+            if (kind not in Comment or kind in Comment.Preproc or kind in Comment.PreprocFile) \
+                    and kind not in Literal.String.Doc and fragment.strip():
+                lines.add(line)
+    return lines
+
+
+def changed_files(repo, base, head):
+    """Yield old paths and blob IDs, preserving rename detection and arbitrary filenames."""
+    fields = git(repo, 'diff', '--raw', '-z', '--no-abbrev', '--find-renames',
+                 '--no-ext-diff', '--no-textconv', base, head, '--').split(b'\0')
+    index = 0
+    while index < len(fields) - 1:
+        metadata = fields[index].decode('ascii').split()
+        path = fields[index + 1].decode('utf-8')
+        index += 2
+        if metadata[4].startswith(('R', 'C')):
+            index += 1
+        if metadata[0] not in (':100644', ':100755') or not production_path(path):
+            continue
+        if metadata[2] != metadata[3]:
+            yield path, metadata[2], metadata[3]
+
+
+def measure(repo, base, head):
+    """Compute the merge base and attribute changed old code lines to their last commits."""
+    for revision in (base, head):
+        if not SHA.fullmatch(revision):
+            raise ValueError('base and head must be full commit SHAs')
+    if git(repo, 'rev-parse', '--is-shallow-repository').strip() != b'false':
+        raise ValueError('blame requires complete history')
+    merge_base = git(repo, 'merge-base', base, head).decode().strip()
+    counts = Counter()
+    files = 0
+    for path, old_blob, new_blob in changed_files(repo, merge_base, head):
+        source = git(repo, 'cat-file', 'blob', old_blob).decode('utf-8')
+        eligible = code_lines(path, source)
+        if not eligible:
+            continue
+        if new_blob == '0' * 40:
+            changed = eligible
+        else:
+            patch = git(repo, 'diff', '--no-ext-diff', '--no-textconv', '--text', '--unified=0',
+                        old_blob, new_blob, '--').decode('utf-8')
+            changed = set()
+            for start, length in re.findall(r'^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@', patch, re.M):
+                start = int(start)
+                changed.update(eligible.intersection(range(start, start + int(length or '1'))))
+        if not changed:
+            continue
+        files += 1
+        # One blame per file covers all changed ranges; unchanged intervening lines never count.
+        blame = git(repo, 'blame', '--no-textconv', '--line-porcelain', '-L', f'{min(changed)},{max(changed)}',
+                    merge_base, '--', path).decode('utf-8')
+        attributed = 0
+        for commit, line in re.findall(r'^([0-9a-f]{40}) \d+ (\d+)(?: \d+)?$', blame, re.M):
+            if int(line) in changed:
+                counts[commit] += 1
+                attributed += 1
+        if attributed != len(changed):
+            raise ValueError(f'incomplete blame for {path!r}')
+    return {'mergeBase': merge_base, 'files': files, 'totalLines': sum(counts.values()), 'commitLines': dict(counts)}
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('base')
+    parser.add_argument('head')
+    parser.add_argument('--repo', default='.')
+    args = parser.parse_args()
+    print(json.dumps(measure(args.repo, args.base, args.head)))

+ 20 - 8
.github/review-ownership/check-approval.mjs

@@ -4,6 +4,8 @@ import { readFileSync } from 'node:fs'
 import process from 'node:process'
 import { pathToFileURL } from 'node:url'
 
+import { productionOwnership } from './blame-ownership.mjs'
+
 const API_VERSION = '2026-03-10'
 const MAX_PULL_REQUEST_REVIEWS = 3_000
 const PAGE_SIZE = 100
@@ -128,10 +130,10 @@ export async function listPullRequestReviews(api, repository, pullNumber) {
 
 /**
  * Evaluate approval points from current reviews and repository permissions.
- * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>}} options Runtime inputs.
- * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'pending' | 'success', description: string, points: number, requiredPoints: number, approvals: Array<{login: string, points: number}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields.
+ * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, getOwnership?: typeof productionOwnership}} options Runtime inputs.
+ * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'pending' | 'success', description: string, points: number, requiredPoints: number, approvals: Array<{login: string, points: number, ownership?: {ownedLines: number, totalLines: number}}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields.
  */
-export async function evaluateApproval({ event, policySource, api }) {
+export async function evaluateApproval({ event, policySource, api, getOwnership = productionOwnership }) {
   const pull = pullRequestFromEvent(event)
   const policy = parseApprovalPolicy(policySource)
   if (pull.draft) {
@@ -160,12 +162,21 @@ export async function evaluateApproval({ event, policySource, api }) {
       })
     }
   }
+  if (approvals.some(approval => approval.points === 1)) {
+    const ownership = await getOwnership(pull, api)
+    for (const approval of approvals) {
+      if (approval.points !== 1) continue
+      const ownedLines = ownership.reviewerLines[approval.login.toLowerCase()] ?? 0
+      approval.ownership = { ownedLines, totalLines: ownership.totalLines }
+      if (ownership.totalLines > 0) approval.points = Math.min(2, 1 + 2 * ownedLines / ownership.totalLines)
+    }
+  }
   approvals.sort((left, right) => left.login.localeCompare(right.login, 'en'))
   blockers.sort((left, right) => left.localeCompare(right, 'en'))
   ignoredReviewers.sort((left, right) => left.localeCompare(right, 'en'))
   const points = approvals.reduce((total, approval) => {
     const next = total + approval.points
-    if (!Number.isSafeInteger(next)) throw new Error('approval points exceed the safe integer range')
+    if (!Number.isFinite(next) || next > Number.MAX_SAFE_INTEGER) throw new Error('approval points exceed the safe integer range')
     return next
   }, 0)
   if (blockers.length > 0) {
@@ -186,20 +197,21 @@ export async function evaluateApproval({ event, policySource, api }) {
 
 /**
  * Evaluate and publish the required commit status, publishing an error status when evaluation fails.
- * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, runUrl: string, write?: (line: string) => void}} options Runtime inputs.
+ * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, runUrl: string, write?: (line: string) => void, getOwnership?: typeof productionOwnership}} options Runtime inputs.
  * @returns {Promise<Awaited<ReturnType<typeof evaluateApproval>>>} Published approval decision.
  */
-export async function runApprovalCheck({ event, policySource, api, runUrl, write = line => process.stdout.write(`${line}\n`) }) {
+export async function runApprovalCheck({ event, policySource, api, runUrl, getOwnership = productionOwnership, write = line => process.stdout.write(`${line}\n`) }) {
   const pull = pullRequestFromEvent(event)
   let result
   try {
-    result = await evaluateApproval({ event, policySource, api })
+    result = await evaluateApproval({ event, policySource, api, getOwnership })
   } catch (error) {
     await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl)
     throw error
   }
   write(`Approval score: ${result.points}/${result.requiredPoints}.`)
-  writeList(write, 'Counted approvals', result.approvals.map(({ login, points }) => `@${login}: ${points}`))
+  writeList(write, 'Counted approvals', result.approvals.map(({ login, points, ownership }) =>
+    `@${login}: ${points}${ownership ? ` (${ownership.ownedLines}/${ownership.totalLines} old production lines)` : ''}`))
   writeList(write, 'Blocking change requests', result.blockers.map(login => `@${login}`))
   writeList(write, 'Ignored reviewers without write access', result.ignoredReviewers.map(login => `@${login}`))
   await publishStatus(api, pull, result.state, result.description, runUrl)

+ 70 - 2
.github/review-ownership/check-approval.test.mjs

@@ -149,6 +149,7 @@ test('accepts one two-point approval from a write-capable reviewer', async () =>
   const result = await evaluateApproval({
     event: pullRequestEvent(),
     policySource,
+    getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
     api: async (path) => {
       calls.push(path)
       if (path.includes('/reviews?')) return [review('07akioni', 'APPROVED')]
@@ -166,6 +167,7 @@ test('accepts two one-point approvals and ignores reviews without write access',
   const result = await evaluateApproval({
     event: pullRequestEvent(),
     policySource,
+    getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
     api: async (path) => {
       if (path.includes('/reviews?')) {
         return [
@@ -183,8 +185,8 @@ test('accepts two one-point approvals and ignores reviews without write access',
   assert.equal(result.state, 'success')
   assert.equal(result.points, 2)
   assert.deepEqual(result.approvals, [
-    { login: 'writer-a', points: 1 },
-    { login: 'writer-b', points: 1 },
+    { login: 'writer-a', points: 1, ownership: { ownedLines: 0, totalLines: 0 } },
+    { login: 'writer-b', points: 1, ownership: { ownedLines: 0, totalLines: 0 } },
   ])
   assert.deepEqual(result.ignoredReviewers, ['reader'])
 })
@@ -193,6 +195,7 @@ test('keeps one one-point approval pending without failing the status', async ()
   const result = await evaluateApproval({
     event: pullRequestEvent(),
     policySource,
+    getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
     api: async (path) => {
       if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
       if (path.includes('/collaborators/writer/permission')) return { permission: 'write' }
@@ -227,6 +230,7 @@ test('keeps the status pending on a write-capable change request while ignoring
   const result = await runApprovalCheck({
     event: pullRequestEvent({ author: 'author' }),
     policySource,
+    getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
     runUrl: 'https://github.example/actions/runs/1',
     api: async (path, options = {}) => {
       if (path.includes('/reviews?')) {
@@ -265,6 +269,7 @@ test('keeps drafts pending without reading reviews', async () => {
   const result = await evaluateApproval({
     event: pullRequestEvent({ draft: true }),
     policySource,
+    getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
     api: async () => { throw new Error('draft evaluation must not call GitHub') },
   })
   assert.equal(result.state, 'pending')
@@ -278,6 +283,7 @@ test('publishes the required status and replaces stale success with error on eva
   const result = await runApprovalCheck({
     event: pullRequestEvent(),
     policySource,
+    getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
     runUrl: 'https://github.example/actions/runs/1',
     api: async (path, options = {}) => {
       calls.push({ path, options })
@@ -307,6 +313,7 @@ test('publishes the required status and replaces stale success with error on eva
   await assert.rejects(runApprovalCheck({
     event: pullRequestEvent(),
     policySource,
+    getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
     runUrl: 'https://github.example/actions/runs/2',
     api: async (path, options = {}) => {
       if (path.includes('/reviews?')) throw new Error('reviews unavailable')
@@ -346,3 +353,64 @@ test('sends authenticated JSON and escapes an API error body', async () => {
   })
   await assert.rejects(failing('/failure'), /"::error::untrusted\\nbody"/u)
 })
+
+for (const [ownedLines, totalLines, expectedPoints] of [[0, 100, 1], [25, 100, 1.5], [49, 100, 1.98], [50, 100, 2], [51, 100, 2], [100, 100, 2], [0, 0, 1]]) {
+  test(`scores ${ownedLines}/${totalLines} old production lines as ${expectedPoints} points`, async () => {
+    let measurements = 0
+    const result = await evaluateApproval({
+      event: pullRequestEvent(), policySource,
+      getOwnership: async () => {
+        measurements++
+        return { totalLines, reviewerLines: { writer: ownedLines } }
+      },
+      api: async path => path.includes('/reviews?')
+        ? [review('Writer', 'APPROVED')]
+        : { permission: 'write' },
+    })
+    assert.equal(result.points, expectedPoints)
+    assert.equal(result.state, expectedPoints === 2 ? 'success' : 'pending')
+    assert.equal(measurements, 1)
+    assert.deepEqual(result.approvals[0].ownership, { ownedLines, totalLines })
+  })
+}
+
+test('shares one measurement across reviewers without overriding a change request', async () => {
+  let measurements = 0
+  const result = await evaluateApproval({
+    event: pullRequestEvent(), policySource,
+    getOwnership: async () => {
+      measurements++
+      return { totalLines: 2, reviewerLines: { first: 1, second: 1 } }
+    },
+    api: async path => path.includes('/reviews?')
+      ? [review('first', 'APPROVED'), review('second', 'APPROVED'), review('blocker', 'CHANGES_REQUESTED')]
+      : { permission: 'write' },
+  })
+  assert.equal(measurements, 1)
+  assert.equal(result.points, 4)
+  assert.equal(result.state, 'pending')
+})
+
+test('does not fetch history when approvals already have two-point weights', async () => {
+  const result = await evaluateApproval({
+    event: pullRequestEvent(), policySource,
+    getOwnership: async () => { throw new Error('unexpected history fetch') },
+    api: async path => path.includes('/reviews?') ? [review('turtle1999', 'APPROVED')] : { permission: 'write' },
+  })
+  assert.equal(result.points, 2)
+})
+
+test('publishes error when production attribution fails', async () => {
+  const states = []
+  await assert.rejects(runApprovalCheck({
+    event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1',
+    getOwnership: async () => { throw new Error('incomplete history') },
+    api: async (path, options) => {
+      if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
+      if (path.includes('/permission')) return { permission: 'write' }
+      states.push(options.body.state)
+      return {}
+    },
+  }), /incomplete history/u)
+  assert.deepEqual(states, ['error'])
+})

+ 1 - 0
.github/review-ownership/requirements.txt

@@ -0,0 +1 @@
+Pygments==2.19.2

+ 161 - 0
.github/review-ownership/test_blame_production.py

@@ -0,0 +1,161 @@
+"""Exercise production-line attribution against isolated real Git histories."""
+
+import importlib.util
+import json
+import os
+import sys
+from pathlib import Path
+import subprocess
+import tempfile
+import unittest
+
+spec = importlib.util.spec_from_file_location('blame_production', Path(__file__).with_name('blame-production.py'))
+blame = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(blame)
+
+
+class ProductionBlameTest(unittest.TestCase):
+    def setUp(self):
+        self.directory = tempfile.TemporaryDirectory(prefix='approval-blame-')
+        self.addCleanup(self.directory.cleanup)
+        self.root = Path(self.directory.name)
+        self.git('init', '-q')
+        self.git('config', 'user.name', 'Approval Fixture')
+        self.git('config', 'user.email', 'fixture@example.invalid')
+        self.git('config', 'commit.gpgsign', 'false')
+        self.git('config', 'core.hooksPath', str(self.root / 'no-hooks'))
+
+    def git(self, *args):
+        return subprocess.run(['git', '-C', str(self.root), *args], check=True,
+                              capture_output=True, text=True).stdout.strip()
+
+    def write(self, path, source):
+        file = self.root / path
+        file.parent.mkdir(parents=True, exist_ok=True)
+        file.write_text(source, encoding='utf-8')
+
+    def commit(self):
+        self.git('add', '.')
+        self.git('commit', '-qm', 'fixture')
+        return self.git('rev-parse', 'HEAD')
+
+    def test_code_tokens_keep_strings_and_mixed_lines(self):
+        cases = [
+            ('main.ts', '// comment\n/* block\n comment */\nconst url = "https://example.test" // tail\n\n', {4}),
+            ('main.tsx', 'const view = <div>{/* comment */}hello</div>\n', {1}),
+            ('main.css', '/* block\n comment */\n.x { color: red; /* mixed */ }\n', {3}),
+            ('main.py', '"""module docs\nmore docs"""\n# comment\nx = "# code"\n', {4}),
+            ('main.c', '#include <stdio.h>\n#define VALUE 1\n', {1, 2}),
+            ('main.c', '/* comment */\nint main() { return 0; } // tail\n', {2}),
+            ('main.ts', 'const text = `first\n// string content\nlast`\n', {1, 2, 3}),
+        ]
+        for path, source, expected in cases:
+            with self.subTest(path=path, source=source):
+                self.assertEqual(blame.code_lines(path, source), expected)
+
+    def test_excludes_nonproduction_paths_and_generated_headers(self):
+        for path in ('docs/a.ts', 'vendor/src/a.ts', 'packages/a/tests/a.ts',
+                     'packages/a/src/a.spec.ts', 'packages/support/a/src/a.ts',
+                     'packages/a/src/generated/a.ts', 'packages/a/src/a.d.ts',
+                     'packages/a/src/a.md', 'scripts/a.ts'):
+            self.assertFalse(blame.production_path(path), path)
+        self.assertTrue(blame.production_path('packages/a/src/a.ts'))
+        self.assertTrue(blame.production_path('python/sdk-runtime/runtime-bootstrap.mjs'))
+        self.assertEqual(blame.code_lines('a.ts', '// Generated by schema\nconst a = 1\n'), set())
+
+    def test_attributes_only_changed_old_code_at_merge_base(self):
+        path = 'packages/a/src/a.ts'
+        self.write(path, '// first\nconst a = 1\nconst b = 2\n')
+        first = self.commit()
+        self.write(path, '// first\nconst a = 1\nconst b = 3\n')
+        base = self.commit()
+        self.write(path, '// different\nconst a = 4\nconst b = 5\nconst added = 6\n')
+        self.write('packages/a/src/new.ts', 'const entirelyNew = 1\n')
+        self.write('packages/a/tests/test.ts', 'const test = 1\n')
+        head = self.commit()
+        # Base advancement must not replace the common ancestor used for attribution.
+        self.git('checkout', '--detach', base)
+        self.write('packages/a/src/unrelated.ts', 'const unrelated = 1\n')
+        advanced_base = self.commit()
+        result = blame.measure(str(self.root), advanced_base, head)
+        self.assertEqual(result['mergeBase'], base)
+        self.assertEqual(result['totalLines'], 2)
+        self.assertEqual(result['commitLines'], {first: 1, base: 1})
+
+    def test_renames_deletions_and_unusual_paths(self):
+        path = 'packages/a/src/quoted " name.ts'
+        self.write(path, '// header\nconst a = 1\nconst b = 2\nconst c = 3\n')
+        self.write('packages/a/src/deleted.ts', 'const gone = 1\n// comment\n')
+        base = self.commit()
+        renamed = 'packages/a/src/renamed.ts'
+        self.git('mv', path, renamed)
+        self.write(renamed, '// header\nconst a = 1\nconst b = 2\nconst c = 4\n')
+        self.git('rm', 'packages/a/src/deleted.ts')
+        result = blame.measure(str(self.root), base, self.commit())
+        self.assertEqual(result['totalLines'], 2)
+        self.assertEqual(result['commitLines'], {base: 2})
+
+    def test_pure_additions_and_comment_only_changes_have_zero_denominator(self):
+        self.write('packages/a/src/a.ts', '// comment\nconst a = 1\n')
+        base = self.commit()
+        self.write('packages/a/src/a.ts', '// revised\nconst a = 1\nconst newLine = 2\n')
+        result = blame.measure(str(self.root), base, self.commit())
+        self.assertEqual(result['totalLines'], 0)
+        self.assertEqual(result['commitLines'], {})
+
+    def test_binary_marked_replacement_does_not_hide_old_production_lines(self):
+        path = 'packages/a/src/a.ts'
+        self.write(path, 'const old = 1\n')
+        base = self.commit()
+        self.write(path, '\0binary replacement\n')
+        self.assertEqual(blame.measure(str(self.root), base, self.commit())['totalLines'], 1)
+
+    def test_pure_rename_has_zero_denominator(self):
+        self.write('packages/a/src/a.ts', 'const a = 1\n')
+        base = self.commit()
+        self.git('mv', 'packages/a/src/a.ts', 'packages/a/src/b.ts')
+        self.assertEqual(blame.measure(str(self.root), base, self.commit())['totalLines'], 0)
+
+    def test_publisher_fetches_history_without_checking_out_pr_code(self):
+        self.write('packages/a/src/a.ts', 'const old = 1\n')
+        base = self.commit()
+        self.write('packages/a/src/a.ts', 'throw new Error("PR code must not run")\n')
+        head = self.commit()
+        remote = self.root / 'remote' / 'owner' / 'repo.git'
+        remote.parent.mkdir(parents=True)
+        self.git('clone', '--bare', str(self.root), str(remote))
+        subprocess.run(['git', '-C', str(remote), 'update-ref', 'refs/heads/trusted', base], check=True)
+        subprocess.run(['git', '-C', str(remote), 'symbolic-ref', 'HEAD', 'refs/heads/trusted'], check=True)
+        subprocess.run(['git', '-C', str(remote), 'update-ref', 'refs/pull/42/head', head], check=True)
+        checkout = self.root / 'checkout'
+        self.git('clone', '--depth=1', remote.as_uri(), str(checkout))
+        module = Path(__file__).with_name('blame-ownership.mjs').resolve().as_uri()
+        program = f"""
+          import {{ productionOwnership }} from {json.dumps(module)};
+          const result = await productionOwnership({{repository:'owner/repo', number:42, headSha:{json.dumps(head)}}},
+            async path => path === '/graphql'
+              ? {{data:{{repository:{{c0:{{author:{{user:{{login:'writer'}}}}}}}}}}}}
+              : {{base:{{sha:{json.dumps(base)}}},head:{{sha:{json.dumps(head)}}}}});
+          console.log(JSON.stringify(result));
+        """
+        result = subprocess.run(['node', '--input-type=module', '-e', program], cwd=checkout,
+                                check=True, capture_output=True, text=True,
+                                env={**os.environ, 'GITHUB_TOKEN': 'fixture-token',
+                                     'GITHUB_SERVER_URL': (self.root / 'remote').as_uri(),
+                                     'PATH': str(Path(sys.executable).parent) + os.pathsep + os.environ['PATH']})
+        self.assertEqual(json.loads(result.stdout), {'totalLines': 1, 'reviewerLines': {'writer': 1}})
+        self.assertEqual(subprocess.check_output(['git', '-C', str(checkout), 'rev-parse', 'HEAD'], text=True).strip(), base)
+        config = (checkout / '.git' / 'config').read_text()
+        self.assertNotIn('fixture-token', config)
+        self.assertNotIn('AUTHORIZATION', config)
+
+    def test_rejects_shallow_history(self):
+        self.write('packages/a/src/a.ts', 'const a = 1\n')
+        base = self.commit()
+        (self.root / '.git' / 'shallow').write_text(base + '\n')
+        with self.assertRaisesRegex(ValueError, 'complete history'):
+            blame.measure(str(self.root), base, base)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 7 - 0
.github/workflows/ci.yml

@@ -443,6 +443,13 @@ jobs:
       - name: Install uv
         run: python -m pip install uv==0.11.23
 
+      - uses: actions/setup-node@v6
+        with:
+          node-version: 24
+
+      - name: Test production blame scoring
+        run: uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'
+
       - name: Run complete keyless Python suite
         run: uv run --python 3.10 --group test --project python/sdk pytest
 

+ 7 - 0
.github/workflows/weighted-approval.yml

@@ -30,6 +30,13 @@ jobs:
         with:
           ref: ${{ github.event.repository.default_branch }}
           persist-credentials: false
+      - uses: actions/setup-python@v6.3.0
+        with:
+          python-version: '3.10'
+          cache: pip
+          cache-dependency-path: .github/review-ownership/requirements.txt
+      - name: Install production lexer
+        run: python3 -m pip install -r .github/review-ownership/requirements.txt
       - name: Publish weighted approval status
         env:
           GITHUB_TOKEN: ${{ github.token }}

+ 1 - 1
package.json

@@ -58,7 +58,7 @@
     "test:bench:built": "vitest run --config vitest.bench.config.ts",
     "test:expected": "vitest run --config vitest.expected.config.ts",
     "test:expected:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.expected.config.ts",
-    "test:approval-policy": "node --test .github/review-ownership/check-approval.test.mjs",
+    "test:approval-policy": "node --test .github/review-ownership/check-approval.test.mjs .github/review-ownership/blame-ownership.test.mjs",
     "test:issue-management": "node .github/issue-management/policy.test.mjs",
     "test:snapshot": "vitest run --config vitest.snapshot.config.ts",
     "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update",

+ 8 - 0
scripts/ci-workflow.spec.ts

@@ -912,6 +912,14 @@ describe('Weighted approval workflow', () => {
         'persist-credentials': false,
       },
     })
+    const pythonJob = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'python-sdk')
+    expect(pythonJob.steps).toContainEqual({
+      name: 'Test production blame scoring',
+      run: "uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'",
+    })
+    expect(steps.find(step => step.name === 'Install production lexer')).toMatchObject({
+      run: 'python3 -m pip install -r .github/review-ownership/requirements.txt',
+    })
     expect(publish).toMatchObject({
       env: {
         GITHUB_TOKEN: '${{ github.token }}',