diff --git a/.github/scripts/autofix-push-and-report.sh b/.github/scripts/autofix-push-and-report.sh index 1e819d1a01b..cb55640a272 100755 --- a/.github/scripts/autofix-push-and-report.sh +++ b/.github/scripts/autofix-push-and-report.sh @@ -618,6 +618,22 @@ if [[ "${OUTCOME}" == "fixed" ]]; then # the per-PR tracking issue (script content from expression # context; append-only comment design — see the script). run_deferred_upsert + # The sha the push loop above actually left on the branch — the merge + # retry can advance it past the verified head, and the regression marker + # must name what the PR now carries. + PUSHED_HEAD="$(git rev-parse HEAD 2> /dev/null || echo '')" + # The premise the push marker stamps: the head state prepare classified, + # unless the pushed head did not start from that head -- a salvage-merged + # branch move or a base-conflict merge carries content prepare never + # classified, and an unknown premise is never green. + PUSH_PRE="${CHECK_STATE:-none}" + [[ "${PUSH_RACE_MERGED}" == 'true' ]] && PUSH_PRE='none' + [[ "${CONFLICT:-false}" == 'true' ]] && PUSH_PRE='none' + # A merge commit between the head prepare classified and the head this + # round pushes -- a clean in-round merge of main trips neither arm above + # -- means the pushed head carries content prepare never classified: the + # premise is unknown, and an unknown premise is never green. + [[ -n "$(git rev-list --merges "${REPORT_HEAD}..${PUSHED_HEAD}" 2>/dev/null)" ]] && PUSH_PRE='none' { echo "🤖 Addressed the latest review feedback (round ${NEXT_ROUND}/${MAX_ROUNDS}). What changed, and what I pushed back on: · 已处理最新评审反馈(第 ${NEXT_ROUND}/${MAX_ROUNDS} 轮)。改动内容与我反驳保留之处如下:" echo @@ -653,6 +669,10 @@ if [[ "${OUTCOME}" == "fixed" ]]; then echo echo "${RESOLUTION_NOTE}" fi + if [[ -n "${REGRESSED_ROUND:-}" ]]; then + echo + echo "🩸 Regression charged to round ${REGRESSED_ROUND}: that round pushed onto a head whose checks were all green and left them red. It no longer counts as progress for the consecutive-failure brake. · 已将回归记在第 ${REGRESSED_ROUND} 轮:该轮在检查全绿的 head 上推送后检查转红,因此不再计入连续失败熔断的有进展判定。" + fi echo echo "Re-review when you have a moment. After round ${MAX_ROUNDS} this bot stops and leaves the PR for a human. · 有空请复审;第 ${MAX_ROUNDS} 轮后本 bot 停止并将 PR 交给人工。" echo @@ -661,6 +681,21 @@ if [[ "${OUTCOME}" == "fixed" ]]; then echo echo "" echo "" + # Regression accounting (af-157). Two distinct facts, both keyed to the + # eval marker's window so the consecutive-failure walk reads them with + # the same filter it already applies to the eval markers: + # autofix-push — what THIS round pushed, and whether the head it + # pushed ONTO was fully green. Only an acted round + # writes it; a no-op pushed nothing to regress. + # head= is the PUSHED sha, not REPORT_HEAD (which + # deliberately records the pre-round head). + # autofix-regression — the PRIOR round prepare found had left a green + # head red. Written by whichever report this round + # posts, so the record survives a later failure. + echo "" + if [[ -n "${REGRESSED_ROUND:-}" ]]; then + echo "" + fi if [[ "${GROWTH_BASE_NEW}" == 'true' ]]; then echo "" fi @@ -699,12 +734,21 @@ else echo echo "${RESOLUTION_NOTE}" fi + if [[ -n "${REGRESSED_ROUND:-}" ]]; then + echo + echo "🩸 Regression charged to round ${REGRESSED_ROUND}: that round pushed onto a head whose checks were all green and left them red. It no longer counts as progress for the consecutive-failure brake. · 已将回归记在第 ${REGRESSED_ROUND} 轮:该轮在检查全绿的 head 上推送后检查转红,因此不再计入连续失败熔断的有进展判定。" + fi echo echo "---" echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`${CLI_DISPLAY}" echo echo "" echo "" + # No autofix-push marker: a no-op round pushed nothing that could + # regress. The regression it OBSERVED still has to be recorded here. + if [[ -n "${REGRESSED_ROUND:-}" ]]; then + echo "" + fi if [[ "${GROWTH_BASE_NEW}" == 'true' ]]; then echo "" fi @@ -731,6 +775,42 @@ for attempt in 1 2 3; do fi if [[ "${attempt}" == 3 ]]; then echo "::error::report post failed ${attempt} times for PR #${PR}; giving up" + # The report is the only carrier of this round's af-157 state, and no + # other step re-posts the report of a round that reached this script: + # the handoff comment the workflow can still post is gated on an + # outcome that is neither `fixed` nor `noop`. So whenever THIS round + # authored af-157 state -- a push whose head a later round can charge, + # an observation it made about the PRIOR round, or both -- a + # marker-only note carries it. Best-effort, and deliberately not + # gated on the outcome: a no-op round pushes nothing, but the + # regression it observed is just as unrecoverable once its report is + # lost, because the marker it read is superseded by the next push. + if [[ -n "${PUSHED_HEAD:-}" || -n "${REGRESSED_ROUND:-}" ]]; then + { + if [[ -n "${PUSHED_HEAD:-}" ]]; then + echo "🤖 Round ${NEXT_ROUND} pushed ${PUSHED_HEAD:0:9} but its report could not be posted; this note carries the round's push record only. · 第 ${NEXT_ROUND} 轮已推送 ${PUSHED_HEAD:0:9},但报告发布失败;本条仅记录本轮的推送标记。" + else + echo "🤖 Round ${ROUND} could not post its report; this note carries the round's regression record only. · 第 ${ROUND} 轮报告发布失败;本条仅记录本轮的回归标记。" + fi + echo + # R31-2: the note must also carry the eval marker — round numbering + # reads autofix-eval only, and a fallback-posted round that consumes + # no number makes the NEXT round reuse it (the brake's + # regression-round walk then charges two rounds as one). + if [[ -n "${PUSHED_HEAD:-}" ]]; then + echo "" + else + echo "" + fi + if [[ -n "${PUSHED_HEAD:-}" ]]; then + echo "" + fi + if [[ -n "${REGRESSED_ROUND:-}" ]]; then + echo "" + fi + } > "${WORKDIR}/push-marker.md" + gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/push-marker.md" || echo "::warning::push marker fallback post failed for PR #${PR}" + fi else echo "::warning::report post attempt ${attempt} failed for PR #${PR}; retrying" sleep 10 diff --git a/.github/scripts/count-test-surface.mjs b/.github/scripts/count-test-surface.mjs new file mode 100644 index 00000000000..46b4fb4314e --- /dev/null +++ b/.github/scripts/count-test-surface.mjs @@ -0,0 +1,1170 @@ +#!/usr/bin/env node +// The instrument behind the review gate's test-weakening check +// (run-autofix-review-verification.sh): the DECLARED test surface of a test +// file, measured by the TypeScript compiler's parser rather than by text +// patterns. Comments, string and template literals, regex literals and JSX +// are the parser's business, so a token that only LOOKS like an assertion +// never counts, and an assertion split across lines or wrapped in a callback +// always does. The parser is error-tolerant: any input yields a tree, so a +// file the round cannot compile still measures here and fails in the package +// test run, where compile errors belong. +// +// count read the file's bytes on stdin; print its surface +// measure the round's net change of one file (see below) +// +// Surface of one file, position-free: +// assertions, declared — two totals over the same set: chains inside +// registrations the runner would execute (a disabled test's or +// describe's body contributes nothing, so silencing a body and +// planting an empty same-titled stand-in still measures as removed) or +// outside any registration. `assertions` additionally drops the ones +// behind a nothing-returning early `return` — `return;` or a return of +// any CONSTANT (`undefined`, `null`, `void 0`, `0`, `false`, `''`) in +// the test body's own control flow, which makes the runner report the +// test PASSED having asserted nothing. `measure` charges whichever +// total falls further, so planting a guard ahead of existing +// assertions measures as their removal, deleting assertions that were +// already behind one measures as their removal too, and a brand-new +// test carrying its own platform guard costs nothing. A chain is: +// rooted at `expect` with a called matcher +// (`expect(x).toBe(1)`, `expect.soft(x).toBe(1)`, +// `await expect(p).rejects.toThrow()`), `expect.unreachable(...)`, any +// called chain rooted at `assert` (`assert(x)`, `assert.equal(a, b)`), and +// a chain carrying a called `.expect(` member (supertest), in statement +// position: the chain IS a statement, a `return`, an arrow function's +// expression body, or a variable initializer (`const res = await +// request(app).get('/').expect(200)`). So `expect.anything()` as an +// argument, a matcher that is only property-accessed (`expect(x).toBe;`) +// and a bare `expect(x)` count nothing. +// registrations — every `it`/`test`/`describe`/`suite` call (and the +// `xit`/`xtest`/`xdescribe` aliases), keyed `test:` or +// `describe:<title>`, each enabled or disabled. Disabled: a +// `skip`/`todo`/`fails`/`failing` member anywhere in the collector chain +// (dotted, computed `it['skip']` — literal, escaped, or a constant +// concatenation — optional-chained, ahead of or behind `each`/`for`/ +// `concurrent`), an x-alias, `.skipIf(<truthy constant>)`, +// `.runIf(<falsy constant>)`, an options object whose `skip`/`todo`/ +// `fails` is a truthy constant (vitest truthy-checks them, so a reason +// string disables), a body-level `skip()`/`ctx.skip()` whose first +// argument is absent or any constant other than `false` (the runner's +// own rule) and that is not itself under a REAL condition — an +// `if (true)` wrapper never withholds its branch, and a `catch` +// whose `try` holds an assertion fires exactly when that assertion +// fails, so neither shelters a skip — and every registration +// nested inside a disabled `describe`. A body skip at file +// scope — a statement of the module, or inside a `beforeEach`/ +// `beforeAll`/`afterEach`/`afterAll` callback the file registers — +// disables the whole file, which is what the runner does with it. A +// registration callback handed by NAME resolves to the single +// module-scope function declaration or function-valued variable +// initializer of that name — the runner receives that very +// function — while an absent, redeclared or nested binding stays +// opaque. A constant is a literal of any kind (object, array, +// regex and bigint included), `undefined`/`void 0`/`NaN`/`Infinity`, +// a unary `!`/`-`/`+`/`~` of a constant, `+` of two string/number +// constants, a comparison or equality of two primitive constants, +// a logical `&&`/`||`/`??` of two constants, and any of those +// behind a type-only wrapper (`as`, `<T>x`, `satisfies`, `!`). +// Deliberately NOT measured, because they are runtime facts the runner is the +// authority for, not declarations: whether an assertion is REACHABLE (dead +// code, a condition that is false in CI, a helper never called), a +// condition-valued guard (`it.skipIf(process.platform === 'win32')`, +// `skip(cond, reason)`, `if (cond) ctx.skip()`, a skip in a `catch` +// whose `try` asserts nothing — this repository's environment-guard +// idiom; the assertions an honest guard shelters are measured, its +// condition is not), and options or collector names carried by a +// binding (`test('x', opts, fn)`, `it[S]('x')`). +// +// `measure` takes {"path", "tip", "pre", "events": +// [{"before", "after", "landed", "mainHolds"}]} — blob files (null = +// absent) for the round's tip, the pre-round ref, and each main-derived +// event the round's history carries (a merge of main, a fast-forwarded +// main commit). For a merge, `after` is MAIN'S OWN side and `before` is +// the merge base it is measured against; for a fast-forwarded commit they +// are the commit and its first parent. `landed` is what the merge commit +// actually holds, and `mainHolds` says whether main held the file at all. +// The round's own delta is tip − pre − Σ(clamped after − before) for each +// total: main's contribution neither charges nor shields, whichever commit +// sequence produced the tip, and the assertion delta reported is the lower +// of the two. Registrations are +// tracked as multisets keyed `kind:title`: the baseline's enabled set is the +// pre-round set plus what main itself enabled across the events minus what +// main disabled, and a tip-disabled registration is charged only while the +// baseline holds more enabled copies of its key than the tip does. An event +// whose before and after are byte-identical (or both absent) moved no +// content, so it neither charges nor shields; whether the baseline HOLDS +// the file is decided separately, by main's side against the merge base. +// Reports the net assertion and enabled-test deltas, the charged +// registrations, and whether the baseline holds the file at all. +import { readFileSync, realpathSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { extname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// The parser is the measurement's authority, so it must not be the round's +// to choose: on the runner the gate passes WEAKEN_PARSER_FILE, the exact +// file the workflow installed from the TRUSTED base's lockfile pin and +// digested before use — loaded by that path, never by a bare specifier +// whose resolution (a package.json `main`, a sibling `typescript.js`) the +// digest does not cover. Without it (local use, the unit tests) typescript +// resolves from the working directory like every other tool. +const require = createRequire(resolve(process.cwd(), 'package.json')); +const ts = process.env.WEAKEN_PARSER_FILE + ? require(resolve(process.env.WEAKEN_PARSER_FILE)) + : require('typescript'); + +const DIALECTS = { + '.ts': 'TS', + '.mts': 'TS', + '.cts': 'TS', + '.tsx': 'TSX', + '.js': 'JS', + '.mjs': 'JS', + '.cjs': 'JS', + '.jsx': 'JSX', +}; +// Maps, not object literals: `'toString' in {}` is true through the +// prototype chain, and a helper named after any Object.prototype member +// would otherwise register as a phantom collector whose callback's +// assertions vanish from the count. +const ROOTS = new Map([ + ['it', 'test'], + ['test', 'test'], + ['describe', 'describe'], + ['suite', 'describe'], +]); +const XROOTS = new Map([ + ['xit', 'test'], + ['xtest', 'test'], + ['xdescribe', 'describe'], +]); +const HOOKS = new Set(['beforeEach', 'beforeAll', 'afterEach', 'afterAll']); +// `fixme` rides the same arm: Playwright's expected-failure mark skips +// the test, which is what `fails`/`failing` already say here. +const DISABLING = new Set(['skip', 'todo', 'fails', 'failing', 'fixme']); +const DISABLING_OPTIONS = new Set(['skip', 'todo', 'fails']); +// Members under a `test.*` root that still name a collector: anything +// else (`step`, `use`, `setTimeout`, `expect`, …) is a utility call, not +// a registration (R30-2). `extend` is deliberately absent — it is a +// factory whose binding is opaque to this instrument. +const PW_COLLECTOR_MEMBERS = new Set([ + 'skip', + 'todo', + 'fails', + 'failing', + 'fixme', + 'only', + 'each', + 'for', + 'concurrent', + 'sequential', + 'skipIf', + 'runIf', + 'describe', + 'suite', + 'configure', +]); + +const ZERO = () => ({ + language: 'other', + assertions: 0, + declared: 0, + enabled: 0, + disabled: [], + enabledTitles: [], +}); + +function isStringLike(n) { + return ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n); +} + +// Type-only wrappers carry no value of their own — `as T`, `<T>x`, +// `x satisfies T`, `x!` and parentheses all evaluate to their operand — +// so every fold in constant() and the options-object test see through +// them. `await` and `void` are NOT transparent (one unwraps a thenable, +// the other discards the value) and keep their own handling instead. +function unwrap(node) { + let n = node; + while ( + ts.isParenthesizedExpression(n) || + ts.isNonNullExpression(n) || + ts.isAsExpression(n) || + ts.isTypeAssertionExpression(n) || + (ts.isSatisfiesExpression && ts.isSatisfiesExpression(n)) + ) { + n = n.expression; + } + return n; +} + +// The object/array/regex and bigint folds carry no VALUE — the former a +// truthiness placeholder, the latter a Number with the int64 semantics +// dropped — so neither may feed an operator whose answer depends on the +// value (unary -/+/~, binary arithmetic, comparison). `!x` and the +// logical selectors read truthiness alone and stay foldable (R32-1). +function opaqueLiteral(node) { + const u = unwrap(node); + return ( + ts.isObjectLiteralExpression(u) || + ts.isArrayLiteralExpression(u) || + ts.isRegularExpressionLiteral(u) || + ts.isBigIntLiteral(u) + ); +} + +// The constant value of an expression the parser can decide without a +// binding: literals of every kind (including object, array, regex and +// bigint), `undefined`/`void 0`/`NaN`/`Infinity`, a unary `!`/`-`/`+`/`~` +// of a constant, `+` of two string/number constants, a comparison or +// equality of two primitive constants, a logical `&&`/`||`/`??` of two +// constants, and any of those behind a type-only wrapper. `{ known: +// false }` for anything else — so one operator away from a shape this +// folds is never one operator away from escaping a signal. +function constant(node) { + if (!node) return { known: false }; + node = unwrap(node); + if (node.kind === ts.SyntaxKind.TrueKeyword) + return { known: true, value: true }; + if (node.kind === ts.SyntaxKind.FalseKeyword) + return { known: true, value: false }; + if (node.kind === ts.SyntaxKind.NullKeyword) + return { known: true, value: null }; + if (ts.isIdentifier(node) && node.text === 'undefined') { + return { known: true, value: undefined }; + } + if (ts.isVoidExpression(node)) return { known: true, value: undefined }; + if (ts.isNumericLiteral(node)) + return { known: true, value: Number(node.text) }; + if (ts.isBigIntLiteral(node)) + return { known: true, value: Number(node.text.replace(/n$/, '')) }; + if (isStringLike(node)) return { known: true, value: node.text }; + if (ts.isIdentifier(node) && node.text === 'NaN') + return { known: true, value: Number.NaN }; + if (ts.isIdentifier(node) && node.text === 'Infinity') + return { known: true, value: Number.POSITIVE_INFINITY }; + // Truthy by construction, and non-thenable: a test callback returning + // one hands the runner nothing to await. + if ( + ts.isObjectLiteralExpression(node) || + ts.isArrayLiteralExpression(node) || + ts.isRegularExpressionLiteral(node) + ) { + return { known: true, value: true }; + } + if (ts.isPrefixUnaryExpression(node)) { + const inner = constant(node.operand); + if (!inner.known) return inner; + switch (node.operator) { + case ts.SyntaxKind.ExclamationToken: + return { known: true, value: !inner.value }; + case ts.SyntaxKind.MinusToken: + case ts.SyntaxKind.PlusToken: + case ts.SyntaxKind.TildeToken: + // A placeholder is not an operand: `-[]` is -0 (falsy) where the + // placeholder's `-true` reads truthy (R32-1). + if (opaqueLiteral(node.operand)) return { known: false }; + return { + known: true, + value: + node.operator === ts.SyntaxKind.MinusToken + ? -inner.value + : node.operator === ts.SyntaxKind.PlusToken + ? +inner.value + : ~inner.value, + }; + default: + return { known: false }; + } + } + if (ts.isBinaryExpression(node)) { + const l = constant(node.left); + const r = constant(node.right); + if (!l.known || !r.known) return { known: false }; + const op = node.operatorToken.kind; + // The logical operators select an operand by truthiness alone, which + // the object/array/regex placeholder (`true`) answers faithfully. + if (op === ts.SyntaxKind.AmpersandAmpersandToken) { + return { known: true, value: l.value ? r.value : l.value }; + } + if (op === ts.SyntaxKind.BarBarToken) { + return { known: true, value: l.value ? l.value : r.value }; + } + if (op === ts.SyntaxKind.QuestionQuestionToken) { + return { + known: true, + value: l.value === null || l.value === undefined ? r.value : l.value, + }; + } + if (op === ts.SyntaxKind.PlusToken) { + if ( + (typeof l.value === 'string' || typeof l.value === 'number') && + (typeof r.value === 'string' || typeof r.value === 'number') + ) { + return { known: true, value: l.value + r.value }; + } + return { known: false }; + } + // Comparison and equality evaluate natively — JS semantics ARE the + // runner's — but only on primitives: the object/array/regex fold is a + // truthiness placeholder, not a value (`{} === {}` must never fold + // true), and a bigint literal folds to a Number for arithmetic, which + // `1n === 1` would mis-fold. + if (opaqueLiteral(node.left) || opaqueLiteral(node.right)) { + return { known: false }; + } + const a = l.value; + const b = r.value; + if (op === ts.SyntaxKind.EqualsEqualsEqualsToken) { + return { known: true, value: a === b }; + } + if (op === ts.SyntaxKind.ExclamationEqualsEqualsToken) { + return { known: true, value: a !== b }; + } + if (op === ts.SyntaxKind.EqualsEqualsToken) { + return { known: true, value: a == b }; + } + if (op === ts.SyntaxKind.ExclamationEqualsToken) { + return { known: true, value: a != b }; + } + if (op === ts.SyntaxKind.LessThanToken) { + return { known: true, value: a < b }; + } + if (op === ts.SyntaxKind.LessThanEqualsToken) { + return { known: true, value: a <= b }; + } + if (op === ts.SyntaxKind.GreaterThanToken) { + return { known: true, value: a > b }; + } + if (op === ts.SyntaxKind.GreaterThanEqualsToken) { + return { known: true, value: a >= b }; + } + // Arithmetic and bitwise apply JavaScript's own operators, so the + // fold IS the runtime's answer; the opaque guard above has already + // kept placeholders and bigints out. + if (op === ts.SyntaxKind.MinusToken) return { known: true, value: a - b }; + if (op === ts.SyntaxKind.AsteriskToken) + return { known: true, value: a * b }; + if (op === ts.SyntaxKind.SlashToken) return { known: true, value: a / b }; + if (op === ts.SyntaxKind.PercentToken) return { known: true, value: a % b }; + if (op === ts.SyntaxKind.AsteriskAsteriskToken) + return { known: true, value: a ** b }; + if (op === ts.SyntaxKind.AmpersandToken) + return { known: true, value: a & b }; + if (op === ts.SyntaxKind.BarToken) return { known: true, value: a | b }; + if (op === ts.SyntaxKind.CaretToken) return { known: true, value: a ^ b }; + if (op === ts.SyntaxKind.LessThanLessThanToken) + return { known: true, value: a << b }; + if (op === ts.SyntaxKind.GreaterThanGreaterThanToken) + return { known: true, value: a >> b }; + if (op === ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken) + return { known: true, value: a >>> b }; + return { known: false }; + } + return { known: false }; +} + +function truthyConstant(node) { + const c = constant(node); + return c.known && Boolean(c.value); +} + +function falsyConstant(node) { + const c = constant(node); + return c.known && !c.value; +} + +function memberName(node) { + if (ts.isPropertyAccessExpression(node)) return node.name.text; + if (ts.isElementAccessExpression(node)) { + const c = constant(node.argumentExpression); + if (c.known && typeof c.value === 'string') return c.value; + } + return null; +} + +// Decompose the call chain that ends at `call` into its root identifier and +// its calls in order, each tagged with the member name that precedes it +// (null for a call on the root itself or on another call's result). +function chainOf(call) { + const segments = []; + let n = call; + for (;;) { + if (ts.isCallExpression(n)) { + segments.push({ call: n }); + n = n.expression; + } else if (ts.isTaggedTemplateExpression(n)) { + // it.skip.each`table`('a', fn): the tagged template is a call link. + segments.push({ call: n }); + n = n.tag; + } else if ( + ts.isPropertyAccessExpression(n) || + ts.isElementAccessExpression(n) + ) { + segments.push({ member: memberName(n) }); + n = n.expression; + } else if ( + ts.isNonNullExpression(n) || + ts.isParenthesizedExpression(n) || + ts.isAsExpression(n) || + ts.isTypeAssertionExpression(n) || + (ts.isSatisfiesExpression && ts.isSatisfiesExpression(n)) + ) { + // Type-only wrappers are chain-transparent: `(it as any).skip(...)` + // IS a skip of `it`. + n = n.expression; + } else { + break; + } + } + segments.reverse(); + const calls = []; + const members = []; + let pending = null; + for (const s of segments) { + if (s.call) { + calls.push({ call: s.call, name: pending }); + pending = null; + } else { + members.push(s.member); + pending = s.member; + } + } + let root = null; + if (ts.isIdentifier(n)) root = n.text; + else if (n.kind === ts.SyntaxKind.ThisKeyword) root = 'this'; + return { root, members, calls }; +} + +// True when `node` is not the outermost link of its chain. +function extendsChain(node) { + const p = node.parent; + if (!p) return false; + if ( + (ts.isPropertyAccessExpression(p) || + ts.isElementAccessExpression(p) || + ts.isCallExpression(p)) && + p.expression === node + ) { + return true; + } + if (ts.isNonNullExpression(p) || ts.isParenthesizedExpression(p)) { + return extendsChain(p); + } + return false; +} + +function isStatementLevel(node) { + let n = node; + let p = n.parent; + while ( + p && + (ts.isParenthesizedExpression(p) || + ts.isAwaitExpression(p) || + ts.isVoidExpression(p) || + ts.isNonNullExpression(p) || + ts.isAsExpression(p) || + ts.isTypeAssertionExpression(p) || + (ts.isSatisfiesExpression && ts.isSatisfiesExpression(p))) + ) { + n = p; + p = p.parent; + } + if (!p) return false; + if (ts.isExpressionStatement(p) || ts.isReturnStatement(p)) return true; + if (ts.isVariableDeclaration(p) && p.initializer === n) return true; + return ts.isArrowFunction(p) && p.body === n; +} + +function isAssertion({ root, calls }) { + if (root === 'expect') { + if (calls.length >= 2) return true; + return calls.length === 1 && calls[0].name === 'unreachable'; + } + if (root === 'assert') return calls.length >= 1; + return calls.some((c) => c.name === 'expect'); +} + +function propertyName(name) { + if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name)) return name.text; + if (isStringLike(name) || ts.isNumericLiteral(name)) return name.text; + if (ts.isComputedPropertyName(name)) { + const c = constant(name.expression); + if (c.known && typeof c.value === 'string') return c.value; + } + return null; +} + +function optionsDisable(call) { + return (call.arguments ?? []).some((arg) => { + const o = unwrap(arg); + return ( + ts.isObjectLiteralExpression(o) && + o.properties.some( + (p) => + ts.isPropertyAssignment(p) && + DISABLING_OPTIONS.has(propertyName(p.name)) && + truthyConstant(p.initializer), + ) + ); + }); +} + +function titleOf(call, sf) { + const a = (call.arguments ?? [])[0]; + if (!a) return ''; + if (isStringLike(a)) return a.text; + if (ts.isTemplateExpression(a)) return a.getText(sf); + return ''; +} + +function registrationDisabled({ root, members, calls }) { + if (XROOTS.has(root)) return true; + if (members.some((m) => m !== null && DISABLING.has(m))) return true; + for (const c of calls) { + const a = (c.call.arguments ?? [])[0]; + if (c.name === 'skipIf' && truthyConstant(a)) return true; + if (c.name === 'runIf' && (!a || falsyConstant(a))) return true; + } + return optionsDisable(calls[calls.length - 1].call); +} + +// A body-level unconditional skip: `skip()`, `ctx.skip()`, `this.skip()`. +// The runner skips unless the first argument is exactly `false`, so any +// constant other than `false` disables; a non-constant argument is the +// condition-valued environment guard and stays enabled. +function isBodySkip({ root, members, calls }) { + if (root === null || ROOTS.has(root) || XROOTS.has(root)) return false; + if (calls.length === 0) return false; + const last = calls[calls.length - 1]; + const bare = root === 'skip' && members.length === 0 && calls.length === 1; + if (!bare && last.name !== 'skip') return false; + const a = (last.call.arguments ?? [])[0]; + if (!a) return true; + const c = constant(a); + return c.known && c.value !== false; +} + +// True when `node` sits under a REAL condition inside the nearest +// enclosing function: an if/switch/loop, a ternary, or a short-circuit +// operand. A wrapper whose deciding operand constant-folds is walked +// through ARM-AWARE: the arm the constant takes is no condition at all +// (`if (true) ctx.skip()`, `true && ctx.skip()`), and the arm it skips +// is dead code that disables nothing (`if (false) { ctx.skip(); }` never +// runs, while `if (false) {} else { ctx.skip(); }` DOES disable — the +// runner reaches the else). A `catch` whose try block holds a counted +// assertion fires exactly when that assertion fails — a skip in either +// is the runner's unconditional outcome, not an environment guard. +// `assertionPositions` must be complete when this runs: callers collect +// their skips during the visit and resolve them after it. +function underCondition(node, assertionPositions) { + const K = ts.SyntaxKind; + const inside = (container) => + !!container && node.pos >= container.pos && node.end <= container.end; + for (let p = node.parent; p && !ts.isFunctionLike(p); p = p.parent) { + if (ts.isIfStatement(p)) { + if (inside(p.expression)) return true; + const c = constant(p.expression); + if (!c.known) return true; + // The arm a constant-true `if` takes is no condition at all; the + // other arm never runs, so a skip there disables nothing and reads + // as guard-shaped either way. `if (false) {} else { ctx.skip(); }` + // is the runner skipping, and it must not hide here. + if (Boolean(c.value) === inside(p.thenStatement)) continue; + return true; + } + if (ts.isCatchClause(p)) { + const tryBlock = ts.isTryStatement(p.parent) ? p.parent.tryBlock : null; + if ( + tryBlock && + assertionPositions.some((a) => a > tryBlock.pos && a < tryBlock.end) + ) { + continue; + } + return true; + } + if (ts.isConditionalExpression(p)) { + if (inside(p.condition)) return true; + if (!inside(p.whenTrue) && !inside(p.whenFalse)) return true; + const c = constant(p.condition); + if (!c.known) return true; + if (Boolean(c.value) === inside(p.whenTrue)) continue; + return true; + } + if (ts.isSwitchStatement(p) || ts.isIterationStatement(p, false)) { + return true; + } + if ( + ts.isBinaryExpression(p) && + (p.operatorToken.kind === K.AmpersandAmpersandToken || + p.operatorToken.kind === K.BarBarToken || + p.operatorToken.kind === K.QuestionQuestionToken) + ) { + if (inside(p.left)) continue; + const l = constant(p.left); + if (!l.known) return true; + const shortCircuits = + (p.operatorToken.kind === K.AmpersandAmpersandToken && !l.value) || + (p.operatorToken.kind === K.BarBarToken && !!l.value) || + (p.operatorToken.kind === K.QuestionQuestionToken && + l.value !== null && + l.value !== undefined); + // A decidable short-circuit's right operand either always runs or + // never does: `true && ctx.skip()` skips, `false && ctx.skip()` is + // dead code that disables nothing. + if (!shortCircuits) continue; + return true; + } + } + return false; +} + +// A return the runner cannot distinguish from `return;`: it yields no +// value the test framework reads, so the test is reported PASSED with +// whatever follows the return unexecuted. Any CONSTANT qualifies — +// vitest ignores a callback's return value unless it is thenable — while +// `return expect(p).resolves.toBe(1)` and `return somePromise` stay +// ordinary control flow the runner awaits. +function returnsNothing(ret) { + if (!ret.expression) return true; + // A template's value is a string however its substitutions evaluate — + // never a thenable — so the runner ignores it exactly like the literal + // spellings constant() folds. + if (ts.isTemplateExpression(unwrap(ret.expression))) return true; + return constant(ret.expression).known; +} + +// What an unconditional body skip disables: `{applies}` false when the +// skip sits in an ordinary function (a helper, a callback handed to +// something that is not a collector hook — nothing states the file ever +// runs it); otherwise the registration whose OWN callback holds it, or — +// for a collector hook's callback — the registration that hook belongs +// to, with `scope` null meaning the whole file. +function skipTarget(node, registrations) { + for (let n = node, p = n.parent; p; n = p, p = p.parent) { + if (!ts.isFunctionLike(p)) continue; + const own = registrations.find((r) => r.fn === p); + if (own) return { applies: true, scope: own }; + const call = p.parent; + if ( + call && + ts.isCallExpression(call) && + (call.arguments ?? []).includes(p) + ) { + const { root } = chainOf(call); + if (root !== null && HOOKS.has(root)) { + return { + applies: true, + scope: enclosingRegistration(call, registrations), + }; + } + } + return { applies: false, scope: null }; + } + return { applies: true, scope: null }; +} + +// The innermost registration whose callback contains `node`. +function enclosingRegistration(node, registrations) { + let best = null; + for (const r of registrations) { + if (r.fn && node.pos >= r.fn.pos && node.end <= r.fn.end) { + if (!best || r.fn.pos >= best.fn.pos) best = r; + } + } + return best; +} + +export function count(text, path) { + const dialect = DIALECTS[extname(path).toLowerCase()]; + if (!dialect) return ZERO(); + const sf = ts.createSourceFile( + path, + text, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind[dialect], + ); + const assertionPositions = []; + const registrations = []; + const bodySkips = []; + const hookBodies = []; + // A callback handed by NAME resolves to the single module-scope function + // declaration or function-valued variable initializer of that name — the + // runner receives that very function, so the registration measures + // through its body. An absent, redeclared or nested binding stays + // opaque. + const topFns = new Map(); + const ambiguousFns = new Set(); + const bindTopFn = (name, fnNode) => { + if (ambiguousFns.has(name) || topFns.has(name)) { + ambiguousFns.add(name); + topFns.delete(name); + } else { + topFns.set(name, fnNode); + } + }; + for (const st of sf.statements) { + if (ts.isFunctionDeclaration(st) && st.name && st.body) { + bindTopFn(st.name.text, st); + } else if (ts.isVariableStatement(st)) { + for (const d of st.declarationList.declarations) { + if ( + ts.isIdentifier(d.name) && + d.initializer && + (ts.isArrowFunction(d.initializer) || + ts.isFunctionExpression(d.initializer)) + ) { + bindTopFn(d.name.text, d.initializer); + } + } + } + } + const visit = (node) => { + if (ts.isCallExpression(node) && !extendsChain(node)) { + const chain = chainOf(node); + if ( + chain.root !== null && + (ROOTS.has(chain.root) || XROOTS.has(chain.root)) && + chain.calls.length > 0 + ) { + const last = chain.calls[chain.calls.length - 1].call; + const lastArgs = last.arguments ?? []; + // Playwright namespaces its API on `test`: the kind and the hook + // boundary follow the chain's first member, so `test.describe` is + // a SUITE (a disabled one propagates into its body), a + // `test.beforeEach` is a hook, and the utility members + // (`test.step`, `test.use`, …) register nothing at all (R30-2). + const firstMember = chain.members.find((m) => m !== null) ?? null; + if ( + chain.root === 'test' && + firstMember !== null && + HOOKS.has(firstMember) + ) { + const fns = lastArgs.filter( + (a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a), + ); + if (fns.length) hookBodies.push(fns[fns.length - 1]); + ts.forEachChild(node, visit); + return; + } + if ( + chain.root === 'test' && + firstMember !== null && + !PW_COLLECTOR_MEMBERS.has(firstMember) + ) { + ts.forEachChild(node, visit); + return; + } + // A chain that never reaches a registration call registers + // nothing: `it.skipIf(cond)`, `it.each(cases)` and + // `test.extend({})` are collector FACTORIES, and binding one to a + // variable is not a test. The terminal call carries the title or + // the callback. + const terminates = + lastArgs.length > 0 && + (isStringLike(lastArgs[0]) || + ts.isTemplateExpression(lastArgs[0]) || + lastArgs.some( + (a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a), + ) || + (lastArgs.length > 1 && + ts.isIdentifier(lastArgs[lastArgs.length - 1]) && + topFns.has(lastArgs[lastArgs.length - 1].text))); + if (terminates) { + const fns = lastArgs.filter( + (a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a), + ); + let fn = fns.length ? fns[fns.length - 1] : null; + if (!fn) { + const lastArg = lastArgs[lastArgs.length - 1]; + if (ts.isIdentifier(lastArg)) { + fn = topFns.get(lastArg.text) ?? null; + } + } + registrations.push({ + // A namespaced suite is a SUITE: `test.describe(...)` reads + // as kind describe, or its disabled state never propagates + // into the body (R30-2). + kind: + firstMember === 'describe' || firstMember === 'suite' + ? 'describe' + : (ROOTS.get(chain.root) ?? XROOTS.get(chain.root)), + title: titleOf(last, sf), + disabled: registrationDisabled(chain), + pos: node.getStart(sf), + fn, + }); + } + } else if ( + chain.root !== null && + HOOKS.has(chain.root) && + chain.calls.length > 0 + ) { + const fns = ( + chain.calls[chain.calls.length - 1].call.arguments ?? [] + ).filter((a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a)); + if (fns.length) hookBodies.push(fns[fns.length - 1]); + } else if (isStatementLevel(node) && isAssertion(chain)) { + assertionPositions.push(node.getStart(sf)); + } else if (isBodySkip(chain)) { + // Conditional-or-not is decided after the visit: a catch clause is + // a condition only when its try holds no assertion, which needs + // the complete assertionPositions. + bodySkips.push(node); + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + let fileDisabled = false; + for (const skip of bodySkips) { + if (underCondition(skip, assertionPositions)) continue; + const target = skipTarget(skip, registrations); + if (!target.applies) continue; + if (target.scope) target.scope.disabled = true; + else fileDisabled = true; + } + if (fileDisabled) { + for (const r of registrations) r.disabled = true; + } + // A disabled describe disables everything registered inside its callback, + // whatever those registrations say for themselves. + const disabledDescribes = registrations.filter( + (r) => r.kind === 'describe' && r.disabled && r.fn, + ); + for (const r of registrations) { + if (r.disabled || r.pos === undefined) continue; + for (const d of disabledDescribes) { + if (d !== r && r.pos > d.fn.pos && r.pos < d.fn.end) { + r.disabled = true; + break; + } + } + } + // Nothing inside a disabled registration's callback executes. + // A nothing-returning early return in a body's own control flow: in a + // TEST it stops the assertions after it from being declared surface; in + // a DESCRIBE it stops the registrations after it from being collected + // at all, which is the same silencing `skip()` at that position gets. + const guards = []; + const suiteGuards = []; + const collect = (fn, into) => { + const walk = (n) => { + if (n !== fn && (ts.isFunctionLike(n) || ts.isClassLike(n))) return; + // Only a return the runner provably reaches on EVERY entry silences + // what follows: one under a runtime condition is the + // environment-guard idiom whose sheltered assertions stay measured + // (R27-21/R27-27), and one in dead code never runs. + if ( + ts.isReturnStatement(n) && + returnsNothing(n) && + !underCondition(n, assertionPositions) + ) { + into.push({ from: n.getStart(sf), start: fn.pos, end: fn.end }); + } + ts.forEachChild(n, walk); + }; + walk(fn); + }; + for (const r of registrations) { + if (!r.fn) continue; + collect(r.fn, r.kind === 'test' ? guards : suiteGuards); + } + // A hook's own body stops at its own early return exactly as a test's + // does; the assertions a `beforeEach` carries are surface too. + for (const fn of hookBodies) collect(fn, guards); + // A describe body's early return silences what follows it there too, + // not only the registrations it stops the runner from collecting. + const guarded = (p) => + [...guards, ...suiteGuards].some( + (g) => p > g.from && p >= g.start && p < g.end, + ); + for (const r of registrations) { + if (r.disabled || r.pos === undefined) continue; + if ( + suiteGuards.some( + (g) => r.pos > g.from && r.pos >= g.start && r.pos < g.end, + ) + ) { + r.disabled = true; + } + } + // A body shared between a disabled and an enabled registration stays + // live: the enabled one executes it, so its assertions are surface. + const liveFns = new Set( + registrations.filter((r) => !r.disabled && r.fn).map((r) => r.fn), + ); + const silenced = registrations.filter( + (r) => r.disabled && r.fn && !liveFns.has(r.fn), + ); + const executes = (p) => !silenced.some((r) => p > r.fn.pos && p < r.fn.end); + const key = (r) => `${r.kind}:${r.title}`; + const declaredAssertions = fileDisabled + ? [] + : assertionPositions.filter(executes); + const liveAssertions = declaredAssertions.filter((p) => !guarded(p)); + return { + language: dialect.toLowerCase(), + assertions: liveAssertions.length, + declared: declaredAssertions.length, + enabled: registrations.filter((r) => r.kind === 'test' && !r.disabled) + .length, + disabled: registrations.filter((r) => r.disabled).map(key), + enabledTitles: registrations.filter((r) => !r.disabled).map(key), + }; +} + +function countFile(file, path) { + if (file === null || file === undefined) return ZERO(); + return count(readFileSync(file, 'utf8'), path); +} + +function sameContent(a, b) { + if ((a === null || a === undefined) && (b === null || b === undefined)) { + return true; + } + if (!a || !b) return false; + return readFileSync(a).equals(readFileSync(b)); +} + +// Nothing the COMPARISON below can see was read from this file: no +// assertion and no enabled registration, which is what the instrument +// reports for a shape it does not parse and for a file that declares +// nothing alike. Disabled registrations do not count as content here, +// because `sameSurface` cannot see them either -- calling a skips-only +// file non-empty would route it to a comparator blind to the only thing +// in it, and no rewrite of it would ever read as movement. +function isEmptySurface(c) { + return c.declared === 0 && c.enabledTitles.length === 0; +} + +// Two counts describe the same CHARGEABLE surface: the assertion totals +// and the enabled-registration multiset. `enabled` is the count of `test:` +// keys in that same multiset, so comparing it adds nothing. A side's +// DISABLED registrations are left out on purpose: they reach no signal -- +// only the tip's do -- so main moving them alone is not coverage arriving, +// and treating it as such would re-charge a deletion an earlier round +// already answered for. A file that is NOTHING BUT disabled registrations +// is not compared here at all; `isEmptySurface` sends it to the bytes, +// where its rewrites are visible. +function sameSurface(a, b) { + const same = (x, y) => { + if (x.size !== y.size) return false; + for (const [k, n] of x) if (y.get(k) !== n) return false; + return true; + }; + return ( + a.assertions === b.assertions && + a.declared === b.declared && + same(bag(a.enabledTitles), bag(b.enabledTitles)) + ); +} + +function bag(keys) { + const m = new Map(); + for (const k of keys) m.set(k, (m.get(k) ?? 0) + 1); + return m; +} + +function bagAdd(m, k, n) { + m.set(k, (m.get(k) ?? 0) + n); +} + +export function measure({ path, tip, pre, events = [] }) { + const t = countFile(tip, path); + const p = countFile(pre, path); + let assertions = t.assertions - p.assertions; + let declared = t.declared - p.declared; + let enabled = t.enabled - p.enabled; + let baselinePresent = pre !== null && pre !== undefined; + const baselineEnabled = bag(p.enabledTitles); + // What main itself added joins the baseline; what main removed leaves + // it. The branch's own entries are carried across the event on both + // sides and change nothing. + const absorb = (target, before, after) => { + const b = bag(before); + const a = bag(after); + for (const [k, n] of a) bagAdd(target, k, Math.max(0, n - (b.get(k) ?? 0))); + for (const [k, n] of b) + bagAdd(target, k, -Math.max(0, n - (a.get(k) ?? 0))); + }; + // What the event MODELLED, clamped by what it actually LANDED -- in ONE + // direction. The two disagree whenever a merge resolution took neither + // side whole: the model is main's own delta, the landed blob is what the + // merge commit holds. + // + // Main's ADDITIONS are never clamped. They raise the baseline whatever + // the merge kept, or a round that drops what main added during the round + // gets that removal for free. + // + // Main's REMOVALS are credited only as far as they landed. Without that + // a round could merge main, discard its side, and let the phantom credit + // absorb its own removal exactly. + // + // Equal, and the clamp is the identity: the ordinary merge measures as + // it always did. What it cannot see is identity -- main removing one + // assertion while the resolution restores it and drops another nets to + // zero, the way an assertion moved within a file always has. + const clamp = (modelled, landed) => { + if (modelled >= 0) return modelled; + if (landed >= 0) return 0; + return Math.max(modelled, landed); + }; + const clampBag = (modelled, landed) => { + const out = new Map(); + for (const k of new Set([...modelled.keys(), ...landed.keys()])) { + const v = clamp(modelled.get(k) ?? 0, landed.get(k) ?? 0); + if (v !== 0) out.set(k, v); + } + return out; + }; + for (const ev of events) { + const landedRef = ev.landed !== undefined ? ev.landed : ev.after; + // PRESENCE follows what main CONTRIBUTED to the path. Main holding the + // file and having moved it puts the file in the baseline, whatever the + // resolution then did with it -- a round that discards what main landed + // still answers for it. Main deleting it takes it out only when the + // merge adopted that deletion; a resolution that kept the file leaves + // it in the round's hands. Main merely still HOLDING a file it has + // always held contributes nothing and says nothing: the baseline there + // is the pre-round ref's, which is where a file the round removed in an + // EARLIER round already stands removed. + if (ev.mainHolds !== undefined) { + const baseHolds = ev.before !== null && ev.before !== undefined; + const landedHolds = landedRef !== null && landedRef !== undefined; + // "Moved" means the measured SURFACE moved, not the bytes: main + // appending a comment to a file an earlier round deleted contributes + // no coverage, and reading it as a contribution would re-charge that + // deletion in every round main happens to touch the file. + // + // When BOTH sides measure to nothing, that reading is unavailable -- + // an empty surface is what this instrument reports for a file it + // cannot parse as well as for a file that declares nothing -- so + // movement falls back to the bytes. The test is what was MEASURED, + // never the extension: a `.test.ts` that registers nothing measures + // exactly like a `.py`, and keying on the extension would leave its + // deletion free. The cost of the fallback is that main touching such + // a file at all reads as a contribution, so a file an earlier round + // deleted is charged again; that is fail-closed and one ack entry + // answers it, where the alternative loses the deletion arm -- the + // only arm these shapes have. + const beforeCount = countFile(ev.before, path); + const afterCount = countFile(ev.after, path); + const readable = + !isEmptySurface(beforeCount) || !isEmptySurface(afterCount); + const moved = + !baseHolds || + (readable + ? !sameSurface(beforeCount, afterCount) + : !sameContent(ev.before, ev.after)); + if (ev.mainHolds && moved) { + baselinePresent = true; + } else if (!ev.mainHolds && baseHolds && !landedHolds) { + // `!landedHolds` is redundant for manifests the gate produces -- + // it drops the event entirely when main holds no side and the merge + // kept the file -- and load-bearing for any other producer. + baselinePresent = false; + } + } + if (sameContent(ev.before, ev.after)) continue; + if (ev.mainHolds === undefined) { + baselinePresent = ev.after !== null && ev.after !== undefined; + } + const before = countFile(ev.before, path); + const after = countFile(ev.after, path); + // Which side the merge resolution took decides what main's delta + // LANDED as: took main's side whole → main's delta landed exactly; + // kept the branch's side whole → none of it landed; MIXED → measure + // the result against the branch's own side, so the round's pre-merge + // edits never enter main's landed contribution (R27-20) — while the + // merge-base baseline would charge the round for main's landed + // removal even when the round's own edits were discarded with the + // branch's side (the binary-resolution-takes-main fixture). + const tookMainWhole = sameContent(ev.after, landedRef); + const keptBranchWhole = + ev.branch !== undefined && + ev.branch !== null && + sameContent(ev.branch, landedRef); + const landed = tookMainWhole ? after : countFile(landedRef, path); + const landedBase = + ev.branch !== undefined && ev.branch !== null + ? countFile(ev.branch, path) + : before; + const landedDelta = (key) => + tookMainWhole + ? after[key] - before[key] + : keptBranchWhole + ? 0 + : landed[key] - landedBase[key]; + assertions -= clamp( + after.assertions - before.assertions, + landedDelta('assertions'), + ); + declared -= clamp( + after.declared - before.declared, + landedDelta('declared'), + ); + enabled -= clamp(after.enabled - before.enabled, landedDelta('enabled')); + const modelledTitles = new Map(); + absorb(modelledTitles, before.enabledTitles, after.enabledTitles); + const landedTitles = new Map(); + absorb( + landedTitles, + (tookMainWhole ? before : landedBase).enabledTitles, + landed.enabledTitles, + ); + for (const [k, n] of clampBag(modelledTitles, landedTitles)) { + bagAdd(baselineEnabled, k, n); + } + } + const tipEnabled = bag(t.enabledTitles); + const newlyDisabled = []; + for (const [k, n] of bag(t.disabled)) { + const owed = Math.max( + 0, + (baselineEnabled.get(k) ?? 0) - (tipEnabled.get(k) ?? 0), + ); + for (let i = 0; i < Math.min(n, owed); i += 1) newlyDisabled.push(k); + } + return { + language: t.language === 'other' ? p.language : t.language, + // Whichever total fell further: a guard planted ahead of existing + // assertions shows in the first, assertions deleted from behind a + // guard the baseline already carried show only in the second. + assertions: Math.min(assertions, declared), + enabled, + newlyDisabled, + baselinePresent, + }; +} + +// Run the CLI only when this file IS the program. Without the guard the +// dispatch reads the argv of whatever imported it: a test runner invoked +// with a positional argument would land in the unknown-mode arm and take +// the importing process down with `process.exit(2)`. +// Compared as REAL paths: the gate runs this from RUNNER_TEMP, and on +// macOS that is reached through /var -> /private/var, so the argv string +// and the module URL disagree while naming the same file. +const realOrSelf = (p) => { + try { + return realpathSync(p); + } catch { + return p; + } +}; +if ( + process.argv[1] && + realOrSelf(fileURLToPath(import.meta.url)) === realOrSelf(process.argv[1]) +) { + const [mode, arg] = process.argv.slice(2); + if (mode === 'count') { + const text = readFileSync(0, 'utf8'); + process.stdout.write(`${JSON.stringify(count(text, arg ?? ''))}\n`); + } else if (mode === 'measure') { + const manifest = JSON.parse(readFileSync(arg, 'utf8')); + process.stdout.write(`${JSON.stringify(measure(manifest))}\n`); + } else { + process.stderr.write(`count-test-surface: unknown mode '${mode ?? ''}'\n`); + process.exit(2); + } +} diff --git a/.github/scripts/run-autofix-review-verification.sh b/.github/scripts/run-autofix-review-verification.sh index 267de745f63..ea5c7804a37 100755 --- a/.github/scripts/run-autofix-review-verification.sh +++ b/.github/scripts/run-autofix-review-verification.sh @@ -1026,6 +1026,618 @@ if [[ -n "${DELETED_TESTS}" || "${NET_TEST_LINES}" -le -25 ]]; then echo '⚖️ test coverage shrank this round — advisory written for the report' | tee -a "${GATE_LOG}" fi +# --- Test-weakening gate ---------------------------------------------------- +# The advisory above renders in the round report only AFTER the round has +# already been accepted, and the SKILL rule it points at ("deleting or +# weakening tests requires content evidence, not an author's say-so") had no +# deterministic enforcement at all. Relaxing an existing assertion is the +# cheapest way for a fix to reach green while the behaviour it broke goes +# unpinned, and it is structurally invisible to every other check here: +# build/typecheck/lint never read assertions, the package tests run the +# WEAKENED file, and the bite check reads only the tests a round ADDS -- +# never the ones it edits away. +# +# CONTRACT. Any pre-existing test file whose DECLARED test surface this +# round shrinks must be named in <workdir>/test-weakening.json -- a JSON +# array of {"path": "<file>", "reason": "<evidence>"} -- or the round is +# rejected, retryably. The gate judges that the claim EXISTS and carries a +# non-trivial reason, never the reason's merit: no semantic oracle is +# available here, and turning a silent edit into an explicit, attributable +# claim is the whole point. The reasons ride into the round report, where +# a maintainer reads them against the diff. +# +# AUTHORITY. The surface is measured by count-test-surface.mjs -- the +# TypeScript compiler's parser over the WHOLE file, never text patterns +# over diff lines -- so comments, strings, regex literals, JSX and line +# breaks are the parser's business and can neither decoy nor hide a token. +# Per file it counts statement-position assertion call chains, test/ +# describe registrations with their enabled/disabled state (every collector +# spelling of skip/todo/fails, the x-aliases, a constant skipIf/runIf or +# options object, a body-level unconditional skip(), a disabled describe's +# nesting), and bare early returns ahead of a test body's assertions. The +# script's header is the definition of record. Neither the counter nor its +# parser is the round's to choose: both are staged from the trusted base +# (the parser installed from the base lockfile's pin) and digested there, +# and the digests -- carried in expression context, unreachable from any +# disk write branch code makes later -- are verified here before either +# executes. A mismatch is tampering and rejects non-retryably; an +# instrument that was never digested measures nothing (UNAVAILABLE below). +# The parser is a `.cjs` copy of typescript's single-file build, so its +# loading cannot be steered by a package.json planted beside it. +# +# ATTRIBUTION. Each file's round delta is tip - pre-round - main's own +# contribution. Across a main-derived merge (any parent from the second on +# that origin/main reaches), main's contribution is MAIN'S OWN DELTA, +# measured on main's own side against the merge base; across a +# fast-forwarded main commit it is the commit's own delta. Never a +# three-way splice of the two sides: an auto-merge mixes the round's edits +# into main's contribution and can invent a surface neither side ever had +# -- an un-skip the round made, spliced with a test main added, produces +# an enabled registration that existed in no tree and is then charged to +# somebody. Measuring main where main made it removes that whole class, +# and it makes the two arms one rule: a modify/delete resolved for main +# was already measured from the merge base. +# So a weakening committed before, during, or after a merge of main +# measures the same, an assertion moved within a file nets zero whichever +# commit sequence produced the tip, and main's own delta neither charges +# nor shields the round. An event that moved nothing for the file +# (byte-identical or absent on both sides) is not recorded. +# +# What main did is not always what the merge KEPT, so the contribution is +# clamped by the blob the merge commit actually landed, measured against +# the same merge base. Main's REMOVALS are credited only as far as they +# landed: a round could otherwise merge main, discard its side, and let +# the phantom credit absorb its own removal exactly. Main's ADDITIONS are +# never clamped: they raise the baseline whatever the merge kept, or a +# round that drops what main added during the round gets that removal for +# free. Presence is a running state, not a reading of the newest event. +# Main ADDING the path puts the file in the baseline; so does main moving +# its measured surface; main deleting it takes it out only when the merge +# adopted that deletion. Where both sides MEASURE to nothing -- the Python +# and Rust shapes, and equally a JS file that declares nothing -- movement +# is read from the bytes instead, since "the surface did not move" is +# unmeasurable there. That reads main touching such a file at all as a +# contribution, so a file an earlier round deleted is charged again: one +# ack entry answers it, where the alternative is losing the deletion arm, +# the only arm those shapes have. +# What none of this can see is identity: main removing one assertion while +# the resolution puts it back and drops a different one nets to zero, the +# same way an assertion moved within a file always has. +# +# The model assumes main's side is MAIN's. It stops being main's if the +# round's own work reaches origin/main WHILE THE LOOP IS STILL RUNNING -- +# the PR squash-merged or cherry-picked mid-round -- because main's chain +# then carries the round's authorship while the merge base does not, and +# measuring the round against it cancels the round's own removals. (Main +# MERGING the branch is self-correcting: the merge base carries the same +# commit, so the two cancel.) A guard was written for it and removed: +# every history these fixtures can build measures correctly without one, +# so it was code no test could reach. The residual is bounded by the same +# thing that bounds the flake carve-out -- a round whose work has already +# landed has nothing left to gate. +# Assertions are counted per file; registrations by kind and title as +# multisets, so un-skipping one test never licenses silencing another, +# while a brand-new todo/skip registration is the round's own and charges +# nothing. Every file is measured under ONE name end to end: a round that +# RENAMES a test file is measured as the deletion of the old path plus a +# new file at the new one, so the rename costs one ack entry naming the +# old path -- deliberately fail-closed, and never silent, since whatever +# shrank inside the destination rides into the round report as the +# reason a maintainer reads against the diff. Following a name across a +# round's own renames is a non-goal: it is a second rename tracker layered +# on git's own, and every reading of it that this gate tried disagreed +# with git in some history shape. A parentless commit in the round's +# history is the round's own authorship, measured against the empty tree. +# +# SIGNALS, one entry per file, the first that applies: the file was +# deleted (held by the baseline -- pre-round, or landed by main during the +# round -- and absent at the tip); a baseline-enabled registration now +# disabled; net assertions removed (an early return planted ahead of them +# counts here: the runner then reports the test passed having asserted +# nothing); net enabled tests removed. Files are selected by NAME: `*.test.*`, `*.spec.*`, +# `test_*.py`, and the `tests/*.rs` / `*_test.rs` / `*_tests.rs` shapes, +# snapshots excluded. Non-JS shapes measure a zero surface and are judged +# by the deletion arm alone. A test surface only a runner can enumerate -- +# a Rust `#[cfg(test)]` module inside a production file, a suite registered +# under a condition -- is outside this gate by design. +# +# NOT MEASURED, by design: whether an assertion is REACHABLE (dead code, a +# condition false in CI, a helper never called), condition-valued guards +# (`.skipIf(cond)`, `skip(cond, reason)`, `if (cond) ctx.skip()` -- this +# repository's environment-guard idiom, which the runner reports as +# skipped), and options or collector names carried by a binding. Those are +# runtime facts; the package test run and the bite check are the +# runner-backed instruments, and this gate certifies only what it +# measures: the declared surface. +# +# Fails OPEN on the measured signals -- a counter or parser that is absent +# or unverifiable, or a history the walk cannot read, skips them with a +# logged UNAVAILABLE -- and never on a whole-file deletion or typechange, +# which the pre-round->tip pair proves without the walk; an enumeration git +# itself refuses fails CLOSED there, since the absence of deletions cannot +# then be certified. +WEAKEN_PATHSPEC=(':(glob)**/*.test.*' ':(glob)**/*.spec.*' ':(glob)**/test_*.py' ':(glob)**/tests/*.rs' ':(glob)**/*_test.rs' ':(glob)**/*_tests.rs' ':(exclude,glob)**/__snapshots__/**') +WEAKEN_COUNTER="${RUNNER_TEMP}/count-test-surface.mjs" +WEAKEN_PARSER="${RUNNER_TEMP}/weaken-parser/typescript.cjs" +WEAKEN_MEASURED='true' +WEAKEN_TMP="$(mktemp -d "${RUNNER_TEMP}/weaken.XXXXXX")" +EMPTY_TREE="$(git hash-object -t tree /dev/null)" +# Parallel indexed arrays throughout (bash 3.2 has no associative arrays): +# measured files and their signal text. +WEAKENED_PATHS=() +WEAKENED_SIGNALS=() +# Success when ${1} exactly matches one of the remaining arguments. +weaken_member() { + local f="${1}" weaken_e + shift + for weaken_e in "$@"; do + if [[ "${weaken_e}" == "${f}" ]]; then + return 0 + fi + done + return 1 +} +weaken_digest() { + if command -v sha256sum > /dev/null 2>&1; then + sha256sum "${1}" | cut -d' ' -f1 + else + shasum -a 256 "${1}" | cut -d' ' -f1 + fi +} +# The instrument's trust chain: digested at staging, present, unchanged. +# Undigested (never staged) measures nothing; a digested instrument that is +# now absent or changed is tampering. +weaken_trusted() { + local file="${1}" expected="${2}" what="${3}" actual + [[ -n "${expected}" ]] || return 1 + if [[ ! -f "${file}" ]]; then + echo "staged ${what} is missing although it was digested at staging (${expected})" >> "${GATE_LOG}" + reject_fix "staged ${what} was removed after staging" 'false' 'false' + fi + if ! actual="$(weaken_digest "${file}")"; then + echo "staged ${what} cannot be digested although it was digested at staging (${expected})" >> "${GATE_LOG}" + reject_fix "staged ${what} was made unreadable after staging" 'false' 'false' + fi + if [[ "${actual}" != "${expected}" ]]; then + echo "staged ${what} does not match its trusted-base digest (expected ${expected}, found ${actual})" >> "${GATE_LOG}" + reject_fix "staged ${what} was modified after staging" 'false' 'false' + fi + return 0 +} +weaken_trusted "${WEAKEN_COUNTER}" "${WEAKEN_COUNTER_SHA256:-}" 'test-surface counter' || WEAKEN_MEASURED='false' +weaken_trusted "${WEAKEN_PARSER}" "${WEAKEN_PARSER_SHA256:-}" 'test-surface parser' || WEAKEN_MEASURED='false' +# The round's first-parent history, oldest first, each commit classified +# once: main = a commit on origin/main's own FIRST-PARENT chain (a +# fast-forwarded ride of main's own history), merge = a merge one of whose +# parents from the second on sits on that chain, own = the round's +# authorship, a parentless root included. Reachability alone is not +# enough: a commit main merged in as someone's feature-branch tip is +# reachable from origin/main while never having been main, and merging one +# with `-s ours` would otherwise credit the round with a smaller copy of a +# file that main never carried. Only main and merge commits are events the +# measurement subtracts; the round's own commits are already inside +# tip - pre-round. +WEAKEN_COMMITS=() +WEAKEN_KINDS=() +WEAKEN_MAIN_PARENT=() +WEAKEN_MAIN_LINE="$(git rev-list --first-parent origin/main 2> /dev/null)" || WEAKEN_MEASURED='false' +# Success when ${1} is a commit origin/main itself has been. +weaken_on_main_line() { + local weaken_sha + weaken_sha="$(git rev-parse -q --verify "${1}^{commit}" 2> /dev/null)" || return 1 + grep -qxF -- "${weaken_sha}" <<< "${WEAKEN_MAIN_LINE}" +} +if weaken_list="$(git rev-list --first-parent --reverse "origin/${BRANCH}..${BRANCH}" 2> /dev/null)"; then + while IFS= read -r c; do + [[ -n "${c}" ]] || continue + weaken_kind='own' + weaken_mp='' + if weaken_on_main_line "${c}"; then + weaken_kind='main' + else + weaken_pi=2 + while git rev-parse -q --verify "${c}^${weaken_pi}" > /dev/null 2>&1; do + if weaken_on_main_line "${c}^${weaken_pi}"; then + weaken_kind='merge' + weaken_mp="${weaken_pi}" + break + fi + weaken_pi=$(( weaken_pi + 1 )) + done + fi + WEAKEN_COMMITS+=("${c}") + WEAKEN_KINDS+=("${weaken_kind}") + WEAKEN_MAIN_PARENT+=("${weaken_mp}") + done <<< "${weaken_list}" +else + WEAKEN_MEASURED='false' +fi +# The first parent of ${1}, or the empty tree for a parentless root. +weaken_parent() { + git rev-parse -q --verify "${1}^" 2> /dev/null || printf '%s\n' "${EMPTY_TREE}" +} +# Export ${1}:${2} to a file under WEAKEN_TMP named ${3}; print the file's +# path, or nothing when the ref holds no such blob. Failure means git +# itself failed, never an absent blob. +# Success when ${1}:${2} is a BLOB -- a tree that took the file's name is +# not the file, and `git cat-file -e` alone accepts one. +weaken_is_blob() { + [[ "$(git cat-file -t "${1}:${2}" 2> /dev/null || true)" == 'blob' ]] +} +weaken_blob() { + local ref="${1}" f="${2}" out="${WEAKEN_TMP}/${3}" + if weaken_is_blob "${ref}" "${f}"; then + git show "${ref}:${f}" > "${out}" 2> /dev/null || return 1 + printf '%s\n' "${out}" + fi +} +# One event, five lines, always: main's own side at the event, the merge +# base to measure it against, the blob the merge commit actually landed, +# whether main held the file at all, and the BRANCH's own side at the +# merge -- the baseline the landed delta is measured against, so the +# round's own pre-merge edits never enter main's landed contribution +# (R27-20). Empty lines are absent sides. +weaken_emit() { + printf '%s\n%s\n%s\n%s\n%s\n' "${1}" "${2}" "${3}" "${4}" "${5}" +} +# MAIN's contribution at merge commit ${1} for file ${2}, main being parent +# ${3}: main's OWN delta, measured on main's own side against the merge +# base. Never a three-way splice of the two sides -- an auto-merge mixes +# the round's edits into main's contribution and can invent a surface +# neither side ever had, which is then credited or charged to somebody. +# What the merge actually DID with that contribution rides along as the +# landed blob, and the counter clamps by it. +weaken_auto_blob() { + local c="${1}" f="${2}" mp="${3}" tag="${4}" mb p1 p2 base res holds='0' + # A criss-cross history has more than one equally valid merge base, and + # git picks one without promising which. Main's delta is measured against + # that base, so a pick can decide the verdict -- one candidate can credit + # main with the ROUND's own removal. It only matters when the candidates + # DISAGREE about this file: refuse then (the caller charges it as + # unmeasurable, which one ack entry answers) and measure normally when + # they hold the same blob, which is the ordinary case. + local weaken_bases weaken_b weaken_seen='' weaken_seen_set='' weaken_bi=0 + weaken_bases="$(git merge-base --all "${c}^" "${c}^${mp}" 2> /dev/null)" || weaken_bases='' + while IFS= read -r weaken_b; do + [[ -n "${weaken_b}" ]] || continue + # Compared through weaken_blob, the very reader the measurement uses, + # rather than a second identity built beside it: one reader cannot + # drift from the other about what counts as holding the file. Absent + # and present are told apart by weaken_seen_set, not by the path being + # empty -- an empty path IS the absent case. + weaken_bi=$(( weaken_bi + 1 )) + weaken_b="$(weaken_blob "${weaken_b}" "${f}" "${tag}.mb${weaken_bi}")" || return 1 + if [[ -z "${weaken_seen_set}" ]]; then + weaken_seen="${weaken_b}" + weaken_seen_set='1' + elif [[ -z "${weaken_seen}" && -z "${weaken_b}" ]]; then + : + elif [[ -z "${weaken_seen}" || -z "${weaken_b}" ]]; then + return 1 + elif ! cmp -s "${weaken_seen}" "${weaken_b}"; then + return 1 + fi + done <<< "${weaken_bases}" + mb="$(git merge-base "${c}^" "${c}^${mp}" 2> /dev/null)" || mb='' + p1="$(weaken_blob "${c}^" "${f}" "${tag}.p1")" || return 1 + p2="$(weaken_blob "${c}^${mp}" "${f}" "${tag}.p2")" || return 1 + res="$(weaken_blob "${c}" "${f}" "${tag}.res")" || return 1 + base='' + if [[ -n "${mb}" ]]; then + base="$(weaken_blob "${mb}" "${f}" "${tag}.mb")" || return 1 + fi + if [[ -z "${p2}" ]] && weaken_is_blob "${c}" "${f}"; then + # Main holds no side and the merge left the file in the round's hands: + # main contributed nothing here. Not an event -- recording it would let + # the round's own copy stand in for main's side as the LANDED blob, + # and a copy weaker than whatever it is measured against would credit + # main with the round's own removal. The merge base is not part of the + # test: after main deletes a file the round keeps, the NEXT merge sees + # no base either, and that is the same case. + return 0 + fi + [[ -z "${p2}" ]] || holds='1' + weaken_emit "${p2}" "${base}" "${res}" "${holds}" "${p1}" +} +# Measure file ${1}: write the manifest (tip, pre-round, and every main +# event that moved the file) and print the counter's verdict JSON. One +# name throughout -- a round that renames a test file is measured as the +# deletion of the old path and a new file at the new one. +weaken_measure() { + local f="${1}" tag="${2}" tip pre before after landed holds branch_side events='' weaken_i c kind mp j=0 + local weaken_pair weaken_prev='' weaken_prev_set='' + tip="$(weaken_blob "${BRANCH}" "${f}" "${tag}.tip")" || return 1 + pre="$(weaken_blob "origin/${BRANCH}" "${f}" "${tag}.pre")" || return 1 + for (( weaken_i = 0; weaken_i < ${#WEAKEN_COMMITS[@]}; weaken_i++ )); do + c="${WEAKEN_COMMITS[weaken_i]}" + kind="${WEAKEN_KINDS[weaken_i]}" + mp="${WEAKEN_MAIN_PARENT[weaken_i]}" + [[ "${kind}" != 'own' ]] || continue + # An event only where the commit moved this file relative to a parent. + if git diff --quiet "${c}^" "${c}" -- ":(literal)${f}" 2> /dev/null && + { [[ "${kind}" != 'merge' ]] || git diff --quiet "${c}^${mp}" "${c}" -- ":(literal)${f}" 2> /dev/null; }; then + continue + fi + j=$(( j + 1 )) + before="$(weaken_blob "${c}^" "${f}" "${tag}.e${j}.before")" || return 1 + landed='' + holds='0' + branch_side='' + if [[ "${kind}" == 'main' ]]; then + # A commit main itself has been: its own delta IS main's, and what it + # landed is what it holds. + after="$(weaken_blob "${c}" "${f}" "${tag}.e${j}.after")" || return 1 + landed="${after}" + [[ -z "${after}" ]] || holds='1' + else + weaken_pair="$(weaken_auto_blob "${c}" "${f}" "${mp}" "${tag}.e${j}")" || return 1 + if [[ -z "${weaken_pair}" ]]; then + j=$(( j - 1 )) + continue + fi + # Main's own side. The first event measures it against the merge + # base; later ones against main's side at the previous event (the + # chain below), never against the branch's side, which is the round's + # own authorship and already inside tip - pre-round. + after="$(sed -n 1p <<< "${weaken_pair}")" + before="$(sed -n 2p <<< "${weaken_pair}")" + landed="$(sed -n 3p <<< "${weaken_pair}")" + holds="$(sed -n 4p <<< "${weaken_pair}")" + branch_side="$(sed -n 5p <<< "${weaken_pair}")" + [[ "${holds}" == '1' ]] || holds='0' + fi + # Main's contributions CHAIN: after the first event, main's side is + # measured against main's side at the PREVIOUS event, not against a + # fresh merge base that already reflects it. Chained, the events + # telescope to main's own net for the round; unchained, main deleting + # and re-adding a file is credited for the re-add twice. + [[ -z "${weaken_prev_set}" ]] || before="${weaken_prev}" + weaken_prev="${after}" + weaken_prev_set='1' + events+="$(jq -cn --arg b "${before}" --arg a "${after}" --arg l "${landed}" \ + --arg bs "${branch_side}" --argjson h "${holds}" \ + '{before: (if $b == "" then null else $b end), + after: (if $a == "" then null else $a end), + landed: (if $l == "" then null else $l end), + branch: (if $bs == "" then null else $bs end), + mainHolds: ($h == 1)}')," + done + jq -n --arg path "${f}" --arg tip "${tip}" --arg pre "${pre}" --argjson events "[${events%,}]" ' + {path: $path, + tip: (if $tip == "" then null else $tip end), + pre: (if $pre == "" then null else $pre end), + events: $events}' > "${WEAKEN_TMP}/${tag}.json" || return 1 + local weaken_out + weaken_out="$(WEAKEN_PARSER_FILE="${WEAKEN_PARSER}" node "${WEAKEN_COUNTER}" measure "${WEAKEN_TMP}/${tag}.json")" || return 1 + # An exit-0 run that printed no readable verdict is a measurement + # FAILURE, not a verdict: read as one, its missing `baselinePresent` + # would say "not the round's to weaken" and silently uncharge the file + # while the round still reports itself measured. Every field the reader + # below consumes must be present and of the right type, or the caller's + # fail-closed arm takes the file. + jq -e '(.baselinePresent | type) == "boolean" + and (.assertions | type) == "number" + and (.enabled | type) == "number" + and (.newlyDisabled | type) == "array"' <<< "${weaken_out}" > /dev/null 2>&1 || return 1 + printf '%s\n' "${weaken_out}" +} +weaken_add_file() { + local weaken_e + for weaken_e in "${WEAKEN_FILES[@]}"; do + [[ "${weaken_e}" != "${1}" ]] || return 0 + done + WEAKEN_FILES+=("${1}") +} +# Add every pathspec file `git diff ${@}` lists. The producer's status is +# read, not swallowed behind a process substitution: an enumeration git +# could not perform marks the measurement UNAVAILABLE instead of silently +# measuring nothing. +weaken_add_diff() { + if ! git diff --name-only -z --no-renames "$@" -- "${WEAKEN_PATHSPEC[@]}" > "${WEAKEN_TMP}/list" 2> /dev/null; then + WEAKEN_MEASURED='false' + return 0 + fi + while IFS= read -r -d '' f; do + [[ -n "${f}" ]] || continue + weaken_add_file "${f}" + done < "${WEAKEN_TMP}/list" +} +# Candidates: every pathspec file the round's pre-round->tip pair moved at +# all -- the authority on what the round changed, and the only arm that +# sees a merge whose TREE equals its first parent's (`-s ours` onto main, +# a reset that drops a pre-round commit): those move nothing per commit +# while moving plenty end to end. A file only MAIN moved comes in here too +# and measures to a zero delta, so the wider net costs a measurement, never +# a verdict. The per-commit arms stay: a merge lists what it moved relative +# to MAIN's side as well, so an --ours resolution that discards a test main +# landed mid-round is enumerated even though the pre-round ref never held +# it. +WEAKEN_FILES=() +if [[ "${WEAKEN_MEASURED}" == 'true' ]]; then + for (( weaken_i = 0; weaken_i < ${#WEAKEN_COMMITS[@]}; weaken_i++ )); do + c="${WEAKEN_COMMITS[weaken_i]}" + [[ "${WEAKEN_KINDS[weaken_i]}" != 'main' ]] || continue + weaken_add_diff "$(weaken_parent "${c}")" "${c}" + [[ "${WEAKEN_KINDS[weaken_i]}" == 'merge' ]] || continue + weaken_add_diff "${c}^${WEAKEN_MAIN_PARENT[weaken_i]}" "${c}" + done +fi +weaken_add_diff "origin/${BRANCH}" "${BRANCH}" +# A pre-existing test replaced by a symlink or a submodule still has a blob +# at the tip, so the deletion arm reads the tip's MODE: anything but a +# regular file at that path is the deletion of its surface, whatever the +# counter would read from the link's target text. Direction matters -- a +# symlink the round replaces with a real test file grew the surface and is +# measured, not charged. +weaken_tip_mode() { + git ls-tree "${BRANCH}" -- ":(literal)${1}" 2> /dev/null | awk '{print $1; exit}' +} +# Success when the round's BASELINE holds ${1} -- the pre-round ref, or any +# main-derived event that carried it during the round. Deliberately WIDER +# than the measured arm's `baselinePresent`, which also asks whether main +# contributed anything and whether the merge adopted its deletion: the +# fail-closed arm must never be the narrower of the two, so a file it +# cannot measure is charged rather than waived. +weaken_baseline_holds() { + local weaken_bi + weaken_is_blob "origin/${BRANCH}" "${1}" && return 0 + for (( weaken_bi = 0; weaken_bi < ${#WEAKEN_COMMITS[@]}; weaken_bi++ )); do + [[ "${WEAKEN_KINDS[weaken_bi]}" != 'own' ]] || continue + if weaken_is_blob "${WEAKEN_COMMITS[weaken_bi]}" "${1}"; then + return 0 + fi + if [[ "${WEAKEN_KINDS[weaken_bi]}" == 'merge' ]] && + weaken_is_blob "${WEAKEN_COMMITS[weaken_bi]}^${WEAKEN_MAIN_PARENT[weaken_bi]}" "${1}"; then + return 0 + fi + done + return 1 +} +if [[ "${WEAKEN_MEASURED}" == 'true' ]]; then + for (( weaken_idx = 0; weaken_idx < ${#WEAKEN_FILES[@]}; weaken_idx++ )); do + f="${WEAKEN_FILES[weaken_idx]}" + if ! weaken_verdict="$(weaken_measure "${f}" "f${weaken_idx}")"; then + # The instrument could not measure THIS file -- an input the round + # itself authored can exhaust the parser. Fail closed for the file + # alone (one ack entry answers it) and keep measuring the rest of + # the round, rather than waiving every measured signal because one + # file was unreadable. A file the round's BASELINE never held has no + # coverage to weaken, so it is skipped rather than charged. + if weaken_baseline_holds "${f}"; then + WEAKENED_PATHS+=("${f}") + WEAKENED_SIGNALS+=('test surface could not be measured') + fi + continue + fi + weaken_baseline="$(jq -r '.baselinePresent' <<< "${weaken_verdict}" 2> /dev/null)" || weaken_baseline='' + signal='' + if [[ "${weaken_baseline}" != 'true' ]]; then + # Not the round's to weaken: the file is its own (pre-round absent and + # never landed by main) or main itself removed it. + : + elif [[ "$(weaken_tip_mode "${f}")" != '100644' && + "$(weaken_tip_mode "${f}")" != '100755' ]]; then + signal='test file deleted' + else + signal="$(jq -r ' + if (.newlyDisabled | length) > 0 then "\(.newlyDisabled | length) pre-existing test registration(s) disabled" + elif .assertions < 0 then "net \(-.assertions) assertion(s) removed" + elif .enabled < 0 then "net \(-.enabled) enabled test(s) removed" + else "" end' <<< "${weaken_verdict}" 2> /dev/null)" || signal='' + fi + if [[ -n "${signal}" ]]; then + WEAKENED_PATHS+=("${f}") + WEAKENED_SIGNALS+=("${signal}") + fi + done +fi +if [[ "${WEAKEN_MEASURED}" != 'true' ]]; then + # UNAVAILABLE: only whole-file deletions and typechanges are judged, from + # the explicit pre-round->tip pair, and every one of them is surfaced -- + # without the walk nothing proves that main, not the round, removed a + # file, and a deletion main did make is acknowledged like any other. An + # enumeration git refuses is a fail-closed rejection: the absence of + # deletions cannot be certified from nothing. + echo '🧪 test-weakening measurement UNAVAILABLE this round (instrument or history unreadable) — only whole-file deletions are judged' | tee -a "${GATE_LOG}" + WEAKENED_PATHS=() + WEAKENED_SIGNALS=() + if ! git diff --name-only -z --no-renames --diff-filter=DT "origin/${BRANCH}" "${BRANCH}" \ + -- "${WEAKEN_PATHSPEC[@]}" > "${WEAKEN_TMP}/deleted" 2> /dev/null; then + echo 'the pre-round->tip deletion enumeration failed; the absence of test deletions cannot be certified' >> "${GATE_LOG}" + reject_fix 'test-weakening gate could not enumerate whole-file deletions; refusing to certify their absence' + fi + while IFS= read -r -d '' f; do + [[ -n "${f}" ]] || continue + WEAKENED_PATHS+=("${f}") + WEAKENED_SIGNALS+=('test file deleted') + done < "${WEAKEN_TMP}/deleted" +fi +rm -rf "${WEAKEN_TMP}" +if (( ${#WEAKENED_PATHS[@]} > 0 )); then + # The acknowledgement is the agent's own machine-readable claim, held to + # the same shape rules as deferred-findings.json: an array, a string path, + # and a reason with enough substance to be read as evidence. A malformed or + # unreadable file acknowledges nothing rather than everything. + # Acknowledged paths travel base64-encoded: newline-safe through the + # line-based read below, and comparable against the measured set without + # ever decoding branch-controlled bytes through shell parsing. + WEAKEN_ACKED=() + if [[ -s "${WORKDIR}/test-weakening.json" ]]; then + weaken_ack_b64="$(jq -j ' + if type == "array" then + .[] + | select((.path? | type) == "string") + | select((.path | length) > 0) + | select((.reason? | type) == "string") + | select((.reason | gsub("\\s+"; " ") | ltrimstr(" ") | rtrimstr(" ") | length) >= 40) + | (.path | @base64) + "\n" + else empty end' "${WORKDIR}/test-weakening.json" 2> /dev/null)" || weaken_ack_b64='' + while IFS= read -r weaken_entry; do + [[ -n "${weaken_entry}" ]] && WEAKEN_ACKED+=("${weaken_entry}") + done <<< "${weaken_ack_b64}" + fi + WEAKEN_MISSING='' + WEAKEN_OK='' + WEAKEN_OK_B64='' + for (( weaken_idx = 0; weaken_idx < ${#WEAKENED_PATHS[@]}; weaken_idx++ )); do + f="${WEAKENED_PATHS[weaken_idx]}" + signal="${WEAKENED_SIGNALS[weaken_idx]}" + # Filenames are branch-controlled bytes rendered inside gate-authored + # (trusted-voice) documents, so they go through the same conservative + # safe-character set the shrink advisory above uses. + if weaken_member "$(printf '%s' "${f}" | base64 | tr -d '\n')" "${WEAKEN_ACKED[@]}"; then + WEAKEN_OK+="- \`${f//[^A-Za-z0-9._\/ -]/?}\` — ${signal}"$'\n' + WEAKEN_OK_B64+="$(printf '%s' "${f}" | base64 | tr -d '\n')"$'\n' + else + WEAKEN_MISSING+="- \`${f//[^A-Za-z0-9._\/ -]/?}\` — ${signal}"$'\n' + fi + done + if [[ -n "${WEAKEN_MISSING}" ]]; then + # The ack list must be complete and reachable: the rejection document + # is a tail-bounded window of GATE_LOG, and a long list gets cut from + # the FRONT while the remedy text below survives — the agent would be + # told to ack a list it was never shown (R32-2). The full measured + # list rides in its own file, which the repair pass can read from the + # same workdir. + printf '%s' "${WEAKEN_MISSING}" > "${WORKDIR}/weaken-missing.txt" + { + echo 'This round deleted or weakened pre-existing tests without recording the required evidence:' + printf '%s' "${WEAKEN_MISSING}" + echo 'Deleting or weakening a test is sound only when the pinned behaviour itself was wrong (show the probe that proves the correct behaviour) or the coverage demonstrably survives in a named surviving test.' + echo 'Either restore the assertions, or record the evidence: write <workdir>/test-weakening.json — a JSON array of {"path": "<file>", "reason": "<evidence, at least 40 characters>"} carrying one entry for every file listed above. If the list above is cut off, the complete list is <workdir>/weaken-missing.txt.' + } >> "${GATE_LOG}" + reject_fix 'round weakened pre-existing tests without recorded evidence' + fi + { + echo '🧪 **Gate advisory — this round weakened or removed pre-existing tests** (machine-measured, not agent-authored):' + printf '%s' "${WEAKEN_OK}" + echo + echo 'The round recorded evidence for each (below, agent-authored). Weakening is sound only when the pinned behaviour itself was wrong or the coverage demonstrably survives elsewhere — read each reason against the diff. · 本轮弱化或删除了既有测试(门自动测量,非 agent 文本)。下列理由由 agent 撰写:仅当被钉住的行为本身有误、或覆盖确有替代时才成立,请对照 diff 逐条审阅。' + # Agent-authored bytes inside a gate-authored document: neutralize both + # comment-marker and details/summary forms (a severed <details> would + # swallow the rest of the posted comment) and cap each reason, the same + # hygiene the report step applies to failure.md excerpts. + # Rendered from the MEASURED set, one line per file: the entries are + # agent-authored and otherwise unbounded, so an ack file stuffed with + # thousands of junk rows would decide the size of a posted PR comment. + jq -r --arg ok "${WEAKEN_OK_B64}" ' + ($ok | split("\n") | map(select(length > 0) | @base64d)) as $ok + | if type == "array" then + map(select((.path? | type) == "string") + | select(.path | IN($ok[])) + | select((.reason? | type) == "string") + | select((.reason | gsub("\\s+"; " ") | ltrimstr(" ") | rtrimstr(" ") | length) >= 40)) + | unique_by(.path) | .[] + | " - \(.path | gsub("[^A-Za-z0-9._/ -]"; "?")): \(.reason | gsub("\\s+"; " "))" + else empty end' "${WORKDIR}/test-weakening.json" 2> /dev/null | + cut -b1-300 | iconv -f utf-8 -t utf-8 -c | + sed -e 's/<!--/<!\\-\\-/g' -e 's/<[dD][eE][tT][aA][iI][lL][sS]/<details/g' \ + -e 's/<\/[dD][eE][tT][aA][iI][lL][sS]/<\/details/g' \ + -e 's/<[sS][uU][mM][mM][aA][rR][yY]/<summary/g' || true + } >> "${WORKDIR}/gate-advisories.md" + echo "🧪 test weakening recorded and acknowledged: $(grep -c '^- ' <<< "${WEAKEN_OK}" || true) file(s)" | tee -a "${GATE_LOG}" +fi + echo '🔬 Re-running deterministic checks (independent of the agent)...' run_check 'build failed on the agent-committed fix' npm run build # Typecheck consumes core's dist (sdk-typescript resolves diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 3e58b96d496..1a35ddf2c34 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -37,7 +37,7 @@ 6495 pr-self-report-label.yml 9646 qwen-autofix-fork-bridge.yml 5942 qwen-autofix-fork-signal.yml -469259 qwen-autofix.yml +469165 qwen-autofix.yml 7061 qwen-ci-flaky-rerun.yml 261105 qwen-code-pr-review.yml 79041 qwen-fleet-shepherd.yml diff --git a/.github/workflows/qwen-autofix.md b/.github/workflows/qwen-autofix.md index 785d2f066b8..f705097be09 100644 --- a/.github/workflows/qwen-autofix.md +++ b/.github/workflows/qwen-autofix.md @@ -258,6 +258,8 @@ task-oriented guides — what a maintainer types and what happens next — see: - [154. review-address · Report dry-run / failure — Convergence-break report guard (#10122): the report step's stale-base retry is a sibling…](#af-154) - [155. review-address · Report dry-run / failure — Hold the stale-base refresh while a review-pr is in flight on the PR.…](#af-155) - [156. review-address · Triage and address — In-round self-review A/B: one bounded delta review before the round's commit,…](#af-156) +- [157. review-address · Prepare branch and feedback — Classify the live head: a round that pushes onto a fully GREEN head and leaves…](#af-157) +- [158. review-address · Stage trusted test-surface instrument — The test-weakening gate's instrument: the counter from the same trusted…](#af-158) --- @@ -3009,6 +3011,23 @@ its own verdict. The trusted PATH is recorded before any branch code runs, so a $GITHUB_ENV-planted PATH/preload cannot swap the sha256sum/bash/git the steps resolve (that would defeat the digest gate itself). + +The gate's two verification passes decide whether the PAT push runs, +and the first pass executes the branch's own build/test on the host +before the second — so the staged runner's digest (recorded in +GITHUB_OUTPUT, unreachable from a disk write) is verified before +execution, or a mid-run overwrite lets the branch define its own +verdict. Every command word in the digest check is an absolute path: +bare names — even builtins like echo, export, or unset — are shadowed +by $GITHUB_ENV-planted BASH_FUNC_<name>%% functions, imported at bash +STARTUP even under --norc, ahead of builtins and PATH (R6-4; a +shadowed echo prints any digest line, blinding the check to a mid-run +overwrite of the staged runner, and a shadowed export/unset arms a +DEBUG trap that swaps the staged runner AFTER the digest passes and +BEFORE the launch executes it, R8-1). The body therefore carries no +in-shell pin of its own: PATH reaches the child through the allowlist, +and the preload channels are closed by the step-level LD_* pins, the +env execve prefix, and env -i. ``` <a id="af-112"></a> @@ -3977,6 +3996,54 @@ deletion reopens only the cosmetic overwrite (nothing rides the stamp but the comment text), and finalize's read is bounded like the loop's pid-file read so a planted FIFO cannot stall it. + +The gate-side kill block (review-address's verify gate is +the first step that runs branch code ON THE HOST — the +agent phase sandboxes it in docker — and the loop holds +the bot PAT in its env, so the PAT overlap is bounded to +the sandboxed agent phase, never the host-side gate) kills +from the pid the launch recorded in expression context; +WORKDIR files are sandbox-writable and are never read as +kill targets. The session kill covers a kill landing +MID-TICK, per the KILL paragraph above. Its command words +are ABSOLUTE-PATH-ONLY, NOT builtin: the block runs in the +gate's OUTER shell, which inherits every $GITHUB_ENV plant +— a BASH_FUNC_builtin%% import shadows the `builtin` +keyword itself (the R6-4 doctrine). `builtin kill` is +sound only inside the env -i clean child (finalize); in +the outer shell kill is /usr/bin/kill, the same procps +already relied on for pkill. + +The finalize-side kill runs in its step's OUTER shell, which +inherits every $GITHUB_ENV plant earlier steps left — a +BASH_FUNC_cat%% import shadows cat at bash startup and forges +the /proc/<pid>/stat line (the matching start time sits in that +step's own env), passing the lifecycle check on a REUSED pid so +the real kill TERMs an unrelated process, its group and session; +a planted kill or a PATH-planted pkill no-ops the delivery +instead, leaving the loop holding the PAT alive to its age cap +(R17-4). The kill target travels through expression context — a +pid read from a WORKDIR file would be an untrusted kill target +(WORKDIR is sandbox-writable) — and the whole PAT-touching body +runs in the gate's env -i clean-child form, re-declaring only +what it needs from the step-level pins and expression context: +a BASH_FUNC_<name>%% import loads as a function at bash STARTUP +even under --norc, ahead of builtins and PATH, under +attacker-chosen names no step-level pin can enumerate +(probe-verified: plants shadowing set/export/builtin/gh each +hijacked the step's former inline body), so the class is closed +by construction rather than enumerated. + +The terminal flip's stamp drain reads the tick's start-epoch file: +a stamp older than 65s proves its request committed or died, no +stamp means nothing in flight, and the read is bounded in time AND +bytes — a planted FIFO must not stall the step, and a symlink to an +endless non-NUL target (/dev/urandom, a fed FIFO) would otherwise +stream into the substitution buffer GB-scale and kill the clean +child before the terminal PATCH (R17-3); a valid stamp is ≤ ~20 +bytes, so the 64-byte cap covers it. A planted fresh stamp costs at +most the 65s bound; a planted deletion reopens only the cosmetic +overwrite. ``` <a id="af-150"></a> @@ -4271,7 +4338,7 @@ for having been reviewed at the wrong moment. In `review-address` · `Triage and address` (arm), `Verification gate` (record), `Push and report` (marker). -```text +````text Measured on the takeover fleet on 2026-09-10 (40 PRs, 79 acted rounds, 1369 inline findings, each blamed at its review head): after a round pushes, 73% of the next review's new Criticals and 93% of its @@ -4341,4 +4408,176 @@ a plain path and the deadline to an ISO instant). Whether the nested `review run` resolves its model credentials inside the sandbox is a runner fact the first armed round has to prove; the skill treats a failed or incomplete pass as review-failed and pushes regardless. +<a id="af-157"></a> + +### 157. review-address · Prepare branch and feedback — Classify the live head: a round that pushes onto a fully GREEN head and leaves… + +In `review-address` · `Prepare branch and feedback`. + +```text +Classify the live head: a round that pushes onto a fully +GREEN head and leaves it red introduced the red. Until this +existed the loop had no notion of a regression at all — +`grep -rE 'regress|introduced'` over the workflow, the gate +script and the SKILL hit only prose about WRITING regression +tests. The consecutive-failure brake counts "rounds that +pushed nothing", so a round that pushed a fix and turned CI +red counted as a SUCCESS and reset the counter; the red it +created came back as the next round's input and was paid for +out of the round budget. A PR could alternate regress / +repair indefinitely while every brake read it as converging. + +The gate cannot close this on its own. It runs build, +typecheck, lint and `--changed` tests for the touched +workspaces only, and says so — "Full regression is covered by +regular CI on the PR after the push". Everything the gate +does run IS already charged: a check that fails on the round +tree and passes at `origin/<branch>` is a rejection, and a +rejected round pushes nothing and feeds the brake. What is +left over is precisely the post-push signal — the full suite, +the other packages, other platforms — and that verdict does +not exist until CI finishes, long after the job has ended. + +So the accounting is deferred rather than waited on, and it +is measured, never inferred: + + - Every ACTED round stamps `autofix-push round= head= pre= + key=`. `pre=` is this classifier's verdict on the head + the round STARTED from; `head=` is the sha it pushed + (NOT the redcheck marker's, which deliberately records + the pre-round head). + - The next round's prepare charges a regression only when + all four hold: the live head is exactly the sha that + marker names (nothing else moved the branch), `pre=green` + (fully green — `pending` and `none` are not green, so a + check still running at push time can never be charged), + the marker's window key matches, and the head is red NOW. + - The observing round writes `autofix-regression round= + key=` into whichever report it posts — pushed, no-op or + failure — so the record survives the round that made it. + - The consecutive-failure walk stops resetting on a + regressing round's headline, reading the round number out + of the headline the report already wrote it into. The + walk also adds THIS round's own observation, which is not + in the fetched comment list yet: without it the newest + regressing push escapes by exactly one round, and that is + the round that matters. + +Attribution is deliberately conservative on every axis that +could charge the loop for someone else's red. A head that +moved (a human push, a base update) breaks the head equality +and drops the charge — and the equality binds BOTH halves: +the marker's head to the checked-out head, and the check +rollup to the commit it describes (`headRefOid` is read in +the same call as the rollup; a rollup for any other commit +classifies `none`, unknown, never chargeable). A re-arm +changes the window key and drops the whole set with it — +and the charge itself fires only when the run's matrix +window IS the live re-arm key: a supersede-exempt conflict +round can still run under a stale window, and a charge +keyed to that dead window is one the brake's live-window +headline walk would never read, so it is not charged. A +head classified from a base-conflict merge or a salvage merge +stamps `pre=none`: the pushed head did not start from the +head prepare classified. A cancelled check is neither red +(matching the scan's own N_RED_NOW filter) nor green: it +classifies pending, like every other verdict-less state. + +GREEN is an ALLOWLIST on both axes, never a fall-through. +The charge-enabling verdict requires every counted check to +have FINISHED and to hold a conclusion the classifier +recognises as passing (SUCCESS, NEUTRAL, SKIPPED); anything +else — a conclusion it does not know, a verdict that never +came, a re-run still in flight over a stale conclusion — is +pending, and pending is never chargeable. Both axes are +allowlists for the same reason: a denylist forgets the member +it was not told about, which is how CANCELLED entered the +green arm in the first place and how REQUESTED would have +followed it. The status axis is `status == "COMPLETED"` with +an absent field reading as finished, because a commit status +carries no status field at all. +When the push landed but the round report could not be +posted, a marker-only comment carries the push marker, so +the regression it may have caused can still be charged. The loop's own lanes are excluded wholesale +by the canonical five-name filter this file's other own-lane +filters use, plus the loop's own dispatch-pending commit +status by its exact context value (a StatusContext carries no +workflowName) — deliberately NOT the feedback renderer's +review-address carve-out, which keeps failed and in-flight +address runs visible as feedback: a charge verdict must never +see them (a failed own round is feedback, not a regression +the pushed code authored, and an in-flight own check would +hold the verdict at pending across the trigger family whose +suite attaches to the PR head). What remains uncovered is red the round did +not author but cannot be distinguished from red it did: a +genuinely flaky check failing on the bot's push, and main's +own breakage arriving between the classification and the +CI run, since CI reports on the merge of the pushed head +with a main that moved. Neither is separable from here. A +base-equality axis alongside the head one was considered and +rejected: main moves between almost every pair of rounds, so +requiring it would make the charge unreachable rather than +accurate, and reading main's own health would put a second +API dependency inside the charge. The consequence is bounded +on purpose — one regression only declines to RESET a counter +that needs five consecutive non-progress rounds to trip, and +the recovery is automatic, since a clean push resets it. What +the round itself can tell is already told: a push carrying a +conflict or salvage merge stamps `pre=none`, because its head +did not start from the head prepare classified. +```` + +<a id="af-158"></a> + +### 158. review-address · Stage trusted test-surface instrument — The test-weakening gate's instrument: the counter from the same trusted… + +In `review-address` · `Stage trusted test-surface instrument`. + +```text +The test-weakening gate's instrument: the counter from the +same trusted checkout as the gate runner, and its parser +from the dependencies `npm ci` just installed against the +TRUSTED lockfile — integrity-checked there, before any +branch step runs. + +It is a step of its own, and it sits where it does because +both halves have a placement constraint. The counter must +come from the trusted checkout, so it must be staged BEFORE +"Prepare branch and feedback" switches the working tree to +the PR branch — a branch-supplied counter would let the code +under test define its own measurement. The parser must come +from node_modules, so it can only be staged AFTER "Install +dependencies". Nothing else runs in that window, which is +why this is not folded into the schema-gate staging step +above. + +The parser is staged as a `.cjs` copy of typescript's +single-file build rather than required from node_modules at +gate time: the copy carries its own extension, so no +`package.json` a later step plants beside it can change how +Node loads it, and the digest below pins the bytes that +actually execute. + +Both digests travel in expression context (the af-111 +doctrine) and the gate verifies them before the counter +runs; a digest that does not match is tampering, and the +gate fails the round without a retry. + +Absent from the trusted base implies absent on disk. This +script is new in its PR, so the tolerant `cp` fails every +pre-merge round — and `rm -rf` runs first so a leftover a +same-UID run planted at the staged path on this persistent +host cannot survive the failure and be digested as trusted +content. `-rf`, not `-f`: the leftover can be a directory, +whose non-zero `rm -f` exit would abort this `-e` step +before the tolerant cp and wedge every later round on the +host. When the counter is absent no digest is recorded and +the gate fails open (`WEAKEN_MEASURED=false`), reporting +that the surface could not be measured. + +A counter WITHOUT its parser is the one case that is not +tolerated: the parser copy is a bare `cp` inside the +presence branch, so a tree that has the counter but no +installed typescript fails the step loudly instead of +producing a round that quietly measures nothing. ``` diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index c430d1cd19c..5327af439b4 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -12,15 +12,10 @@ name: 'Qwen Autofix' # qwen-autofix.md#af-001. # # SIZE IS A HARD CONSTRAINT, NOT A STYLE PREFERENCE. GitHub refuses to start -# runs for a workflow file over 500 KB (512,000 bytes), and it fails SILENTLY: -# schedule ticks stop firing, dispatches sit 'queued' forever with zero jobs, -# and only PR-event runs keep working — those resolve the workflow from the -# PR's own branch, so an older, smaller copy of this file runs and the loop -# looks half-alive. That is precisely what happened on 2026-08-19: this file -# crossed the limit at 512,782 bytes and the loop went dark for a day while -# `pull_request_review` runs kept reporting success. Prose belongs in -# qwen-autofix.md and long steps belong in .github/scripts/ — that is what -# keeps this file runnable at all. +# runs for a workflow file over 500 KB, and it fails SILENTLY (2026-08-19 this +# file crossed it and the loop went dark for a day). Prose belongs in +# qwen-autofix.md and long steps belong in .github/scripts/. +# Full rationale → qwen-autofix.md (its own header carries the size doctrine) on: issues: types: @@ -89,6 +84,11 @@ env: # is the prompt-injection trust gate: feedback from anyone else is ignored so a # hostile commenter cannot steer the agent. TRUSTED_ASSOC: '["OWNER", "MEMBER", "COLLABORATOR"]' + # Every lane this loop owns whose check runs attach to the PR head: + # excluded from the head-state classifiers (charge + wake) by name, or + # the loop reads its own red as the round's regression (af-157, R27-23). + # A test pins the names against the workflows' `name:` lines. + LOOP_OWNED_LANES: '["Qwen Autofix","🧐 Qwen Pull Request Review","Qwen CI Failure Patrol","Qwen Autofix Fork Bridge","Qwen Autofix Fork Signal","Qwen Triage","Qwen Triage Finalize","PR self-report label","Qwen PR Safety Precheck","Comment Attachment Guard","PR Asset Branch Cleanup"]' # The review bot's thread lifecycle replies `R<id> fixed by <what>` into a # thread — a NOTE, never a finding: it carries its own marker # (FIXED_RULING_MARKER in lib/review-footer.ts) at the end of its first @@ -3155,7 +3155,7 @@ jobs: CONFLICT_WAKE_SCAN="$(jq -rs \ --arg since "${CONFLICT_SINCE_SCAN}" --arg baseupd "${BASE_UPD_AT_SCAN}" \ --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ - --argjson trust "${TRUSTED_ASSOC}" ' + --argjson trust "${TRUSTED_ASSOC}" --argjson looplanes "${LOOP_OWNED_LANES}" ' (.[0] | map(select((.submitted_at // "") > $since) | select((.user.login // "") != $ab and (.user.login // "") != $rb) | select(((.author_association // "") | IN($trust[]))) @@ -3169,7 +3169,7 @@ jobs: | select((.body // "") | test("<!-- (autofix-eval|autofix-rearm|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not) | select((.body // "") | test("^\\s*@qwen-code /") | not)) | length) + (.[3] | map(select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED")) - | select(((.workflowName // "") | IN("Qwen Autofix", "🧐 Qwen Pull Request Review", "Qwen CI Failure Patrol", "Qwen Autofix Fork Bridge", "Qwen Autofix Fork Signal")) | not) + | select(((.workflowName // "") | IN($looplanes[])) | not) | select((.completedAt // .updatedAt // "") > $since) | select((.completedAt // .updatedAt // "") > $baseupd)) | length)' \ "${WORKDIR}/rv.scan.json" "${WORKDIR}/rc.scan.json" "${WORKDIR}/ic.json" "${WORKDIR}/checks.scan.json" 2> /dev/null || echo 0)" @@ -3184,21 +3184,9 @@ jobs: fleet_row "${PR}" 'conflict-parked' 'conflict handoff pending — no stale-base head move while parked' fi # Convergence-signal circuit breaker (#10107): the verdict is - # derived HERE, above the stale-base update, and the update's - # gate refuses a parked PR beside the conflict park: a base - # merge into a parked PR re-fires every synchronize-triggered - # workflow on the new head, and the base-merge round reviews - # an unchanged diff — its clean marker resets the streak, - # silently lifting the park with zero human activity (af-108's - # exact hazard, the one the conflict park above was added - # for). The notice's promise that base conflicts stay - # unhandled while paused only holds with this guard. The - # verdict and the park action BOTH sit above the idle - # fast-path (an idle parked PR must still reach its notice), - # and BOTH stale-base gates reuse this one derivation, so the - # reads cannot drift. Mirrored live in prepare; keep in - # lockstep. - # Full rationale → qwen-autofix.md#af-152 + # derived HERE, above the stale-base update; BOTH stale-base + # gates reuse this one derivation, mirrored live in prepare — + # keep in lockstep. Full rationale → qwen-autofix.md#af-152 gh api "repos/${REPO}/pulls/${PR}/reviews" --paginate \ | jq -s 'add // []' > "${WORKDIR}/rv.json" gh api "repos/${REPO}/pulls/${PR}/comments" --paginate \ @@ -4087,6 +4075,20 @@ jobs: done git config core.hooksPath .husky + # The test-weakening gate's instrument, staged trusted and digested. + # Full rationale → qwen-autofix.md#af-158 + - name: 'Stage trusted test-surface instrument' + id: 'stage_surface' + run: |- + rm -rf "${RUNNER_TEMP}/count-test-surface.mjs" "${RUNNER_TEMP}/weaken-parser" + cp .github/scripts/count-test-surface.mjs "${RUNNER_TEMP}/count-test-surface.mjs" 2> /dev/null || true + if [[ -f "${RUNNER_TEMP}/count-test-surface.mjs" ]]; then + mkdir -p "${RUNNER_TEMP}/weaken-parser" + cp node_modules/typescript/lib/typescript.js "${RUNNER_TEMP}/weaken-parser/typescript.cjs" + echo "weaken_counter_sha256=$(sha256sum "${RUNNER_TEMP}/count-test-surface.mjs" | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" + echo "weaken_parser_sha256=$(sha256sum "${RUNNER_TEMP}/weaken-parser/typescript.cjs" | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" + fi + - name: 'Download CLI bundle' uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1 with: @@ -4347,9 +4349,12 @@ jobs: | jq -s 'add // []' > "${WORKDIR}/rc.json" gh api "repos/${REPO}/issues/${PR}/comments" --paginate \ | jq -s 'add // []' > "${WORKDIR}/ic.json" - gh pr view "${PR}" --repo "${REPO}" \ - --json statusCheckRollup --jq '.statusCheckRollup // []' > "${WORKDIR}/checks.json" \ - 2> /dev/null || echo '[]' > "${WORKDIR}/checks.json" + # headRefOid rides the same read as the rollup (af-157). + PR_ROLLUP="$(gh pr view "${PR}" --repo "${REPO}" \ + --json statusCheckRollup,headRefOid 2> /dev/null || echo '{}')" + jq '.statusCheckRollup // []' <<< "${PR_ROLLUP}" > "${WORKDIR}/checks.json" 2> /dev/null \ + || echo '[]' > "${WORKDIR}/checks.json" + ROLLUP_HEAD="$(jq -r '.headRefOid // ""' <<< "${PR_ROLLUP}" 2> /dev/null || echo '')" # Newest actionable feedback timestamp — stamped into the eval marker so # the next scan knows everything up to here has been considered. @@ -4649,7 +4654,7 @@ jobs: CONFLICT_WAKE="$(jq -rs \ --arg since "${CONFLICT_SINCE}" --arg baseupd "${BASE_UPD_AT}" \ --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ - --argjson trust "${TRUSTED_ASSOC}" ' + --argjson trust "${TRUSTED_ASSOC}" --argjson looplanes "${LOOP_OWNED_LANES}" ' (.[0] | map(select((.submitted_at // "") > $since) | select((.user.login // "") != $ab and (.user.login // "") != $rb) | select(((.author_association // "") | IN($trust[]))) @@ -4663,7 +4668,7 @@ jobs: | select((.body // "") | test("<!-- (autofix-eval|autofix-rearm|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not) | select((.body // "") | test("^\\s*@qwen-code /") | not)) | length) + (.[3] | map(select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED")) - | select(((.workflowName // "") | IN("Qwen Autofix", "🧐 Qwen Pull Request Review", "Qwen CI Failure Patrol", "Qwen Autofix Fork Bridge", "Qwen Autofix Fork Signal")) | not) + | select(((.workflowName // "") | IN($looplanes[])) | not) | select((.completedAt // .updatedAt // "") > $since) | select((.completedAt // .updatedAt // "") > $baseupd)) | length)' \ "${WORKDIR}/rv.json" "${WORKDIR}/rc.json" "${WORKDIR}/ic.json" "${WORKDIR}/checks.json" 2> /dev/null || echo 0)" @@ -4788,6 +4793,47 @@ jobs: [[ -z "${OVER_BUDGET_AUTHORS}" ]] && OVER_BUDGET_AUTHORS='[]' [[ "${OVER_BUDGET_AUTHORS}" != '[]' ]] && echo "🚦 regular-feedback budget exhausted this window for: $(jq -r 'join(", ")' <<< "${OVER_BUDGET_AUTHORS}")" fi + # ---- regression accounting ------------------------------------ + # Classify the live head: a push onto a fully GREEN head that + # leaves it red introduced the red. + # Full rationale → qwen-autofix.md#af-157 + CHECK_STATE="$(jq -r --arg dispatch "${DISPATCH_STATUS_CONTEXT}" \ + --argjson looplanes "${LOOP_OWNED_LANES}" ' + [ .[] | select((((.workflowName // "") + | IN($looplanes[])) | not)) + | select(((.context // "") == $dispatch) | not) ] as $c + | if ($c | length) == 0 then "none" + elif any($c[]; (.conclusion // .state // "") + | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED")) then "red" + elif all($c[]; ((.status // "COMPLETED") == "COMPLETED") + and ((.conclusion // .state // "") + | IN("SUCCESS", "NEUTRAL", "SKIPPED"))) then "green" + else "pending" end' "${WORKDIR}/checks.json" 2> /dev/null || echo 'none')" + case "${CHECK_STATE}" in green | red | pending | none) ;; *) CHECK_STATE='none' ;; esac + if [[ "${ROLLUP_HEAD:-}" != "${CHECKED_OUT_HEAD}" ]]; then + echo "🩸 #${PR}: the check rollup describes ${ROLLUP_HEAD:-<unknown>}, not the checked-out head ${CHECKED_OUT_HEAD} — head state unknown" + CHECK_STATE='none' + fi + REGRESSED_ROUND='' + LAST_PUSH="$(jq -r --arg ab "${AUTOFIX_BOT}" ' + [ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "") + | [ scan("<!-- autofix-push round=([0-9]+) head=([0-9a-f]+) pre=([a-z]+) key=([^ ]+) -->") ] | .[] + | {r: .[0], h: .[1], p: .[2], k: .[3], at: ($c.created_at // "")} ] + | sort_by(.at) | last // {} + | "\(.r // "") \(.h // "") \(.p // "") \(.k // "")"' \ + "${WORKDIR}/ic.json" 2> /dev/null || echo '')" + read -r LP_ROUND LP_HEAD LP_PRE LP_KEY <<< "${LAST_PUSH}" || true + # Charged only in the live window: a supersede-exempt round's + # stale matrix WINDOW must not key a charge the brake never walks. + if [[ -n "${LP_ROUND:-}" && "${LP_HEAD:-}" == "${CHECKED_OUT_HEAD}" \ + && "${LP_PRE:-}" == 'green' && "${LP_KEY:-}" == "${WINDOW:-none}" \ + && "${WINDOW:-none}" == "${LIVE_REARM_KEY}" \ + && "${CHECK_STATE}" == 'red' ]]; then + REGRESSED_ROUND="${LP_ROUND}" + echo "🩸 #${PR}: round ${LP_ROUND} pushed ${CHECKED_OUT_HEAD:0:9} onto a green head and it is red now — charging that round as a regression" + fi + echo "check_state=${CHECK_STATE}" >> "${GITHUB_OUTPUT}" + echo "regressed_round=${REGRESSED_ROUND}" >> "${GITHUB_OUTPUT}" echo "stale=${STALE}" >> "${GITHUB_OUTPUT}" echo "effective_round=${ROUND}" >> "${GITHUB_OUTPUT}" echo "round_start=${LIVE_ROUND_START}" >> "${GITHUB_OUTPUT}" @@ -5429,6 +5475,8 @@ jobs: LD_LIBRARY_PATH: '' TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' VERIFY_RUNNER_SHA256: '${{ steps.stage.outputs.verify_runner_sha256 }}' + WEAKEN_COUNTER_SHA256: '${{ steps.stage_surface.outputs.weaken_counter_sha256 }}' + WEAKEN_PARSER_SHA256: '${{ steps.stage_surface.outputs.weaken_parser_sha256 }}' # RUNNER_TEMP, WORKDIR, and BRANCH re-enter the digest check and # the allowlisted child below from the step environment: a # $GITHUB_ENV plant points the digest oracle at a decoy runner @@ -5470,24 +5518,9 @@ jobs: HB_PID: '${{ steps.post_status.outputs.heartbeat_pid }}' HB_START_TICKS: '${{ steps.post_status.outputs.heartbeat_start_ticks }}' run: |- - # The round heartbeat ends HERE: this is the first step that runs - # branch code ON THE HOST (the agent phase sandboxes it in - # docker), and the loop holds the bot PAT in its env — the - # overlap is bounded to the sandboxed agent phase, never the - # host-side gate. Kill from the pid the launch recorded in - # expression context; WORKDIR files are sandbox-writable and are - # never read as kill targets. The session kill covers a kill - # landing MID-TICK: each tick's `timeout 60 gh` subtree sits in - # its OWN process group (coreutils timeout default), which the - # group+pid kills miss — but the loop owns its session via - # setsid, so -s <pid> targets exactly this round's tree. - # ABSOLUTE-PATH-ONLY command words here, NOT builtin: this block - # runs in the gate's OUTER shell, which inherits every - # $GITHUB_ENV plant — a BASH_FUNC_builtin%% import shadows the - # `builtin` keyword itself (the R6-4 doctrine the gate body - # below documents). `builtin kill` is sound only inside the - # env -i clean child (finalize); here kill is /usr/bin/kill, - # the same procps already relied on for pkill. + # The round heartbeat ends HERE: kill from the pid the launch + # recorded in expression context, with absolute-path command + # words only (the outer shell inherits $GITHUB_ENV plants). # Full rationale → qwen-autofix.md#af-149 /usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true # Lifecycle-confirmed shutdown: this kill can land the whole @@ -5511,23 +5544,10 @@ jobs: /usr/bin/kill "${HB_PID}" 2> /dev/null || true /usr/bin/pkill -TERM -s "${HB_PID}" 2> /dev/null || true fi - # The gate decides whether the PAT push runs, and the first pass - # executes the branch's own build/test on the host before the - # second — so verify the staged runner's digest (recorded in - # GITHUB_OUTPUT, unreachable from a disk write) before executing, - # or a mid-run overwrite lets the branch define its own verdict. - # Every command word below is called by absolute path: bare names - # — even builtins like echo, export, or unset — are shadowed by - # $GITHUB_ENV-planted BASH_FUNC_<name>%% functions, imported at - # bash STARTUP even under --norc, ahead of builtins and PATH - # (R6-4; a shadowed echo prints any digest line, blinding the - # check to a mid-run overwrite of the staged runner, and a - # shadowed export/unset arms a DEBUG trap that swaps the staged - # runner AFTER the digest passes and BEFORE the launch executes - # it, R8-1). The body therefore carries no in-shell pin of its - # own: PATH reaches the child through the allowlist below, and - # the preload channels are closed by the step-level LD_* pins - # above, the env execve prefix, and env -i. + # The gate decides whether the PAT push runs: verify the staged + # runner's digest before executing it (a mid-run overwrite would + # let the branch define its own verdict); absolute-path command + # words only. Full rationale → qwen-autofix.md#af-111 /usr/bin/echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | /usr/bin/sha256sum -c - > /dev/null # Launch the gate through the workflow's env -i clean-child # pattern: the step environment inherits every $GITHUB_ENV plant @@ -5546,6 +5566,8 @@ jobs: CI="${CI:-true}" \ KISS_AUDIT="${KISS_AUDIT:-false}" \ FOOTPRINT_ENFORCE="${FOOTPRINT_ENFORCE:-advisory}" \ + WEAKEN_COUNTER_SHA256="${WEAKEN_COUNTER_SHA256:-}" \ + WEAKEN_PARSER_SHA256="${WEAKEN_PARSER_SHA256:-}" \ SELF_REVIEW_ARM="${SELF_REVIEW_ARM:-off}" \ bash --norc "${RUNNER_TEMP}/run-autofix-review-verification.sh" @@ -5748,6 +5770,8 @@ jobs: LD_LIBRARY_PATH: '' TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' VERIFY_RUNNER_SHA256: '${{ steps.stage.outputs.verify_runner_sha256 }}' + WEAKEN_COUNTER_SHA256: '${{ steps.stage_surface.outputs.weaken_counter_sha256 }}' + WEAKEN_PARSER_SHA256: '${{ steps.stage_surface.outputs.weaken_parser_sha256 }}' # RUNNER_TEMP, WORKDIR, and BRANCH re-enter the digest check and # the allowlisted child below from the step environment: a # $GITHUB_ENV plant points the digest oracle at a decoy runner @@ -5782,23 +5806,10 @@ jobs: # that died before recording it. KISS_AUDIT: '${{ steps.verify.outputs.kiss_audit || steps.prepare.outputs.kiss_audit }}' run: |- - # The gate decides whether the PAT push runs, and the first pass - # executes the branch's own build/test on the host before the - # second — so verify the staged runner's digest (recorded in - # GITHUB_OUTPUT, unreachable from a disk write) before executing, - # or a mid-run overwrite lets the branch define its own verdict. - # Every command word below is called by absolute path: bare names - # — even builtins like echo, export, or unset — are shadowed by - # $GITHUB_ENV-planted BASH_FUNC_<name>%% functions, imported at - # bash STARTUP even under --norc, ahead of builtins and PATH - # (R6-4; a shadowed echo prints any digest line, blinding the - # check to a mid-run overwrite of the staged runner, and a - # shadowed export/unset arms a DEBUG trap that swaps the staged - # runner AFTER the digest passes and BEFORE the launch executes - # it, R8-1). The body therefore carries no in-shell pin of its - # own: PATH reaches the child through the allowlist below, and - # the preload channels are closed by the step-level LD_* pins - # above, the env execve prefix, and env -i. + # The gate decides whether the PAT push runs: verify the staged + # runner's digest before executing it (a mid-run overwrite would + # let the branch define its own verdict); absolute-path command + # words only. Full rationale → qwen-autofix.md#af-111 /usr/bin/echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | /usr/bin/sha256sum -c - > /dev/null # Launch the gate through the workflow's env -i clean-child # pattern: the step environment inherits every $GITHUB_ENV plant @@ -5817,6 +5828,8 @@ jobs: CI="${CI:-true}" \ KISS_AUDIT="${KISS_AUDIT:-false}" \ FOOTPRINT_ENFORCE="${FOOTPRINT_ENFORCE:-advisory}" \ + WEAKEN_COUNTER_SHA256="${WEAKEN_COUNTER_SHA256:-}" \ + WEAKEN_PARSER_SHA256="${WEAKEN_PARSER_SHA256:-}" \ SELF_REVIEW_ARM="${SELF_REVIEW_ARM:-off}" \ bash --norc "${RUNNER_TEMP}/run-autofix-review-verification.sh" @@ -5975,6 +5988,9 @@ jobs: # variable (not a secret), already the agent's OPENAI_MODEL. MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}' CHECKED_OUT_HEAD: '${{ steps.prepare.outputs.checked_out_head }}' + # Regression accounting (af-157). + CHECK_STATE: '${{ steps.prepare.outputs.check_state }}' + REGRESSED_ROUND: '${{ steps.prepare.outputs.regressed_round }}' VERIFIED_HEAD: '${{ steps.final_verify.outputs.verified_head }}' RESANITIZE_SHA256: '${{ steps.stage.outputs.resanitize_sha256 }}' UPSERT_SRC: '${{ steps.stage.outputs.upsert_src }}' @@ -6058,6 +6074,8 @@ jobs: EFFECTIVE_ROUND: '${{ steps.prepare.outputs.effective_round }}' MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}' CHECKED_OUT_HEAD: '${{ steps.prepare.outputs.checked_out_head }}' + # Regression accounting (af-157). + REGRESSED_ROUND: '${{ steps.prepare.outputs.regressed_round }}' # This step also posts a round report (timeout / gate-rejection / # abort), so it writes the per-round growth-now marker too — else an # over-budget round that never reaches 'Push and report' leaves a @@ -6470,7 +6488,7 @@ jobs: # the base refresh once the review has landed. MARK_TS='9999-12-31T23:59:59Z' HEADLINE="🤖 AutoFix deferred a stale-base refresh — the fix did not pass verification and this PR is behind \`${DEFAULT_BRANCH:-main}\`, but a lifecycle review of this PR is still in flight, and merging main now would cancel it or invalidate its posting (#10110). Command-triggered reviews are invisible to this probe (af-155). The refresh and this item's retry move to the next scan." - HEADLINE_ZH="🤖 AutoFix 暂缓了一次过期 base 刷新 —— 修复未通过验证且本 PR 落后于 \`${DEFAULT_BRANCH:-main}\`,但该 PR 上仍有一轮生命周期评审在运行,现在合入 main 会取消该评审或使其发布失效(#10110)。命令触发的评审对本探测不可见(af-155)。base 刷新与此项的重试推迟到下次扫描。" + HEADLINE_ZH="🤖 AutoFix 暂缓了一次过期 base 刷新 —— 修复未通过验证且本 PR 落后于 \`${DEFAULT_BRANCH:-main}\`,但该 PR 上仍有一轮生命周期评审在运行,现在合入 main 会取消该评审或使其发布失效(#10110)。命令触发的评审对本探测不可见(af-156)。base 刷新与此项的重试推迟到下次扫描。" else # Say what actually happens next. The old "A human should # take over this PR" read as a full release, but the loop @@ -6547,18 +6565,34 @@ jobs: | ($wins | length) > 0 and (($wins | last) == $win))] | sort_by(.created_at) | .[] | (.body | gsub("\r"; "") | split("\n")[0])' <<< "${COMMENTS_JSON}" 2> /dev/null || true)" + # Rounds later measured as regressing (af-157). + REGRESSED_ROUNDS="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" ' + [ .[] | select((.user.login // "") == $ab) | (.body // "") + | [ scan("<!-- autofix-regression round=([0-9]+) key=([^ ]+) -->") ] | .[] + | select(.[1] == $win) | .[0] ] + | unique | join(" ")' <<< "${COMMENTS_JSON}" 2> /dev/null || true)" + # THIS round's observation is not in COMMENTS_JSON yet. + [[ -n "${REGRESSED_ROUND:-}" ]] && REGRESSED_ROUNDS="${REGRESSED_ROUNDS} ${REGRESSED_ROUND}" while IFS= read -r H; do [[ -n "${H}" ]] || continue if [[ "${H}" == *"Addressed the latest review feedback"* || "${H}" == *"no changes needed"* || "${H}" == *"AutoFix could not start —"* || "${H}" == *"updated a stale base"* || "${H}" == *"deferred a stale-base refresh"* ]]; then - CONSEC_FAIL=1 + # A push that turned the checks red is not progress. + # Full rationale → qwen-autofix.md#af-157 + H_ROUND='' + [[ "${H}" =~ \(round\ ([0-9]+)/ ]] && H_ROUND="${BASH_REMATCH[1]}" + if [[ -n "${H_ROUND}" && " ${REGRESSED_ROUNDS} " == *" ${H_ROUND} "* ]]; then + CONSEC_FAIL=$(( CONSEC_FAIL + 1 )) + else + CONSEC_FAIL=1 + fi else CONSEC_FAIL=$(( CONSEC_FAIL + 1 )) fi done <<< "${PRIOR_HEADS}" if [[ "${CONSEC_FAIL}" -ge "${CONSECUTIVE_FAILURE_CAP}" ]]; then MARK_ROUND="${MAX_ROUNDS}" - HEADLINE="🤖 AutoFix stopped after ${CONSEC_FAIL} consecutive rounds that pushed nothing (failed rounds, timeouts, gate rejections, or stops under instruction). Retrying at the same per-round budget is not converging — this usually means the PR is too large or conflicts with a fast-moving \`main\`. A human should rebase, split, or reduce it, then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR." - HEADLINE_ZH="🤖 AutoFix 已停止:连续 ${CONSEC_FAIL} 轮未能推送任何内容(失败轮次、超时、验证门拒绝或按指示停止)。以相同的单轮预算重试并不收敛 —— 这通常意味着 PR 过大,或与快速变动的 \`main\` 冲突。应由人工 rebase、拆分或缩减它,然后评论 \`${RETRY_COMMAND}\` 重新武装。在此之前,后续扫描将跳过本 PR。" + HEADLINE="🤖 AutoFix stopped after ${CONSEC_FAIL} consecutive rounds without progress (failed rounds, timeouts, gate rejections, stops under instruction, or a push that left the checks red). Retrying at the same per-round budget is not converging — this usually means the PR is too large or conflicts with a fast-moving \`main\`. A human should rebase, split, or reduce it, then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR." + HEADLINE_ZH="🤖 AutoFix 已停止:连续 ${CONSEC_FAIL} 轮没有进展(失败轮次、超时、验证门拒绝、按指示停止,或推送后检查转红)。以相同的单轮预算重试并不收敛 —— 这通常意味着 PR 过大,或与快速变动的 \`main\` 冲突。应由人工 rebase、拆分或缩减它,然后评论 \`${RETRY_COMMAND}\` 重新武装。在此之前,后续扫描将跳过本 PR。" fi # CUMULATIVE timeout breaker — the sibling of the consecutive # one above, for the failure shape it cannot see: timeouts @@ -6702,6 +6736,10 @@ jobs: echo echo "Run log: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" echo + if [[ -n "${REGRESSED_ROUND:-}" ]]; then + echo + echo "🩸 Regression charged to round ${REGRESSED_ROUND}: that round pushed onto a head whose checks were all green and left them red. It no longer counts as progress for the consecutive-failure brake. · 已将回归记在第 ${REGRESSED_ROUND} 轮:该轮在检查全绿的 head 上推送后检查转红,因此不再计入连续失败熔断的有进展判定。" + fi echo "---" echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`${CLI_DISPLAY}" echo @@ -6723,6 +6761,11 @@ jobs: if [[ "${MARK_TS}" != '9999-12-31T23:59:59Z' ]]; then echo "<!-- autofix-redcheck head=${REPORT_HEAD} -->" fi + # A failed round still records the PRIOR round's regression + # (af-157). + if [[ -n "${REGRESSED_ROUND:-}" ]]; then + echo "<!-- autofix-regression round=${REGRESSED_ROUND} key=${WINDOW:-none} -->" + fi } > "${WORKDIR}/report.md" gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md" || echo "::warning::Failed to post handoff comment on PR #${PR}" fi @@ -6847,22 +6890,9 @@ jobs: HB_PID: '${{ steps.post_status.outputs.heartbeat_pid }}' HB_START_TICKS: '${{ steps.post_status.outputs.heartbeat_start_ticks }}' run: |- - # This step holds the PAT, and its shell inherits every - # $GITHUB_ENV plant earlier steps left — including - # BASH_FUNC_<name>%% imports, which bash loads as functions at - # STARTUP even under --norc, ahead of builtins and PATH, under - # attacker-chosen names no step-level pin can enumerate - # (probe-verified: plants shadowing set/export/builtin/gh each - # hijacked this step's former inline body). Close the class, - # not the enumeration: the whole PAT-touching body runs in the - # gate's env -i clean-child form, re-declaring only what it - # needs from the step-level pins above and expression context. - # PATH reaches the child through the allowlist, so the body - # carries no in-shell pin of its own; inside the clean child - # no import survives, so bare and builtin words resolve - # soundly again. The kill target travels through expression - # context — a pid read from a WORKDIR file would be an - # untrusted kill target (WORKDIR is sandbox-writable). + # This step holds the PAT: the body runs in the gate's env -i + # clean-child form (no $GITHUB_ENV plant survives it), and the + # kill target travels through expression context. # Full rationale → qwen-autofix.md#af-149 LD_PRELOAD= LD_AUDIT= LD_LIBRARY_PATH= \ /usr/bin/env -i \ @@ -6884,18 +6914,8 @@ jobs: PR="${PR}" \ bash --norc -c ' set -uo pipefail - # Stop the round heartbeat BEFORE flipping this comment - # to its terminal text, and DRAIN it first: killing the - # client cannot cancel a PATCH the server already - # ACCEPTED, so the terminal PATCH must wait the last - # in-flight tick request out (the stamp drain below). - # The kill is lifecycle-confirmed: the gate killed this - # loop long before this step, and by now the pid - # recorded at launch can be REUSED — TERMing it would - # kill an unrelated process, its group and session. A - # failed check means the loop is already gone, so - # killing nothing is right; the stop marker ends it on - # its next self-check even if the kills miss. + # Stop the round heartbeat BEFORE the terminal text, and + # DRAIN it first; the kill is lifecycle-confirmed. # Full rationale → qwen-autofix.md#af-149 /usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true HB_FIELDS=() @@ -6909,20 +6929,10 @@ jobs: builtin kill "${HB_PID}" 2>/dev/null || true /usr/bin/pkill -TERM -s "${HB_PID}" 2>/dev/null || true fi - # Drain the last in-flight tick before the terminal - # PATCH: each tick stamps its start epoch around its gh - # call (bounded to 60s), so a stamp older than 65s - # proves its request committed or died, and no stamp - # means nothing is in flight. The loop is dead by here, - # so no new stamp can appear. The read is bounded in - # time AND bytes (a planted FIFO must not stall this - # step, and a symlink to an endless non-NUL target — - # /dev/urandom or a fed FIFO — would otherwise stream - # into the substitution buffer GB-scale and kill this - # clean child before the terminal PATCH, R17-3); a - # valid stamp is ≤ ~20 bytes, so 64 covers it. A - # planted fresh stamp costs at most the 65s bound, a - # planted deletion reopens only the cosmetic overwrite. + # Drain the last in-flight tick before the terminal PATCH: + # a stamp older than 65s proves its request committed or + # died; the read is bounded in time AND bytes (R17-3). + # Full rationale → qwen-autofix.md#af-149 DRAIN_END=$(( $(date +%s) + 65 )) while :; do NOW_S="$(date +%s)" @@ -7001,24 +7011,10 @@ jobs: HB_PID: '${{ steps.post_status.outputs.heartbeat_pid }}' HB_START_TICKS: '${{ steps.post_status.outputs.heartbeat_start_ticks }}' run: |- - # Last heartbeat kill (the gate and finalize already did this on - # their paths) from the expression-context pid — never from a - # WORKDIR file, which is sandbox-writable. Lifecycle-confirmed - # like its twins and stalest of all: signal only a pid whose - # /proc/<pid>/stat start time still matches the launch - # capture, kill nothing otherwise. The session kill covers - # the mid-tick subtree, same as its twins. - # ABSOLUTE-PATH-ONLY command words, the gate twin's form: - # this block runs in the step's OUTER shell, which inherits - # every $GITHUB_ENV plant earlier steps left — a - # BASH_FUNC_cat%% import shadows cat at bash startup and - # forges the stat line (the matching start time sits in - # this step's own env), passing the lifecycle check on a - # REUSED pid so the real kill TERMs an unrelated process, - # its group and session; a planted kill or PATH-planted - # pkill no-ops the delivery instead, leaving the loop - # holding the PAT alive to its age cap (R17-4). - # Full rationale → qwen-autofix.md#af-149 + # Last heartbeat kill from the expression-context pid — + # lifecycle-confirmed, session-wide, absolute-path command + # words only (this outer shell inherits every $GITHUB_ENV + # plant). Full rationale → qwen-autofix.md#af-149 HB_FIELDS=() if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then HB_STAT="$(/usr/bin/cat "/proc/${HB_PID}/stat" 2>/dev/null)" || HB_STAT='' diff --git a/.qwen/skills/autofix/SKILL.md b/.qwen/skills/autofix/SKILL.md index 58ccdf41487..f708fb61597 100644 --- a/.qwen/skills/autofix/SKILL.md +++ b/.qwen/skills/autofix/SKILL.md @@ -489,9 +489,35 @@ Two boundaries hold regardless of what any feedback asks for: - Deleting or weakening tests requires content evidence, not an author's say-so: it is sound only when the pinned behavior itself is wrong (show the probe that proves the correct behavior) or the coverage demonstrably - survives in a named surviving test. State that evidence in the summary — - the gate appends its own machine-measured advisory listing every deleted - test to the round report, and a maintainer will read the two side by side. + survives in a named surviving test. State that evidence in the summary AND + record it machine-readably: the gate parses every pre-existing + JavaScript/TypeScript test file (by name: `*.test.*`, `*.spec.*`) and + REJECTS the round when its declared test surface shrank — the file was + deleted, statement-level assertions were removed, a test or describe that + was enabled is now disabled by any spelling (`.skip`/`.todo`/`.fails`, + `xit`, a constant `skipIf(true)`/`runIf(false)`, `{ skip: true }` or any + truthy constant, an unconditional body-level `skip()`/`ctx.skip()`, a + wrapping `describe.skip`), or enabled tests were removed — an early + `return` planted ahead of a test's assertions counts as removing them — + unless each + such file is named in `<workdir>/test-weakening.json`, a JSON array of + `{"path": "<file>", "reason": "<evidence>"}` whose reason is at least 40 + characters. The Python and Rust test-file shapes (`test_*.py`, + `tests/*.rs`, `*_test.rs`, `*_tests.rs`) are watched for DELETION alone — + their contents are not parsed, so only the file-deleted signal can charge + them. RENAMING a test file counts as deleting the old path: record + one entry naming it, with the new path as the evidence. Condition-valued + environment guards (`.skipIf(cond)`, `skip(cond, reason)`, + `if (cond) ctx.skip()`), snapshot churn, and a brand-new `it.todo` are + not + weakening and need no entry; an assertion moved WITHIN a file nets zero + and needs none either, while one moved to another file does (name its new + home as the evidence). Main's own changes crossing a merge are attributed + to main, never to the round. The gate checks that the claim EXISTS, not + that it is right — a maintainer reads each reason against the diff in the + round report, alongside the gate's own machine-measured advisory. Never + write an entry to buy silence for a weakening you cannot justify: restore + the assertion instead. The gate also measures a deny-by-default FOOTPRINT: any area (declared workspace, top-level directory, or root file) a round touches that the PR @@ -647,6 +673,9 @@ Finish with exactly one outcome: disposition and the reason in a sentence or two, plus the question you need answered when you escalated. Each body is bilingual per GitHub Actions Rules. Omit the file when every inline finding was resolved. + Also write `<workdir>/test-weakening.json` when this round deleted or + weakened any pre-existing test, per the test-evidence boundary above; omit + it otherwise. - No change: write `<workdir>/no-action.md` (bilingual per GitHub Actions Rules). - Stopped by the growth brake: write `<workdir>/handoff.md` per the not-converging rule (English-only, no details block) — and commit nothing. diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index e5d6050fb21..a37805abce1 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -8,11 +8,13 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { chmodSync, + copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, + readdirSync, rmSync, statSync, symlinkSync, @@ -20,10 +22,14 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { describe, expect, it } from 'vitest'; import { getWorkflowJob } from './workflow-helpers.js'; +import { + count as countTestSurface, + measure as measureTestSurface, +} from '../../.github/scripts/count-test-surface.mjs'; // A hung-runner bound, not a performance budget, so it rides the suite's own // knob (scripts/tests/vitest.config.ts) instead of a figure measured on a @@ -69,6 +75,12 @@ const upsertDeferredScript = readFileSync( ); const autofixContractsScriptPath = '.github/scripts/check-autofix-contracts.sh'; const autofixContractsScript = readFileSync(autofixContractsScriptPath, 'utf8'); +// The loop-owned lane exclusion list lives in ONE workflow env entry +// (R27-23); harnesses executing the real classifier blocks inject the +// REAL value read from the workflow, so a drift between the two fails +// here, not in production. +const loopOwnedLanesJson = + workflow.match(/^ {2}LOOP_OWNED_LANES: '(.+)'$/m)?.[1] ?? '[]'; const autofixRunnerScriptPath = '.qwen/skills/autofix/scripts/run-agent.mjs'; const checkBotCredentialsStep = workflow.match( @@ -254,6 +266,16 @@ const hasBashMapfile = spawnSync('bash', ['-c', 'mapfile -d "" -t x <<< y'], { stdio: 'ignore' }) .status === 0; +// sha256sum is GNU coreutils: a macOS host without the GNU toolchain +// (Homebrew ships it as gsha256sum) cannot run the staging step's digest +// lines — the step is written for the runner, where coreutils is always +// present. Probe the host like hasBashMapfile does; the pin gates only the +// verbatim phase of the staging witness, never its text pins. +const hasSha256sum = + spawnSync('bash', ['-c', 'command -v sha256sum > /dev/null 2>&1'], { + stdio: 'ignore', + }).status === 0; + // GitHub Actions expressions return operand VALUES from &&/||, not // booleans: && yields the first falsy operand (else the last operand), || // the first truthy (else the last), '' is falsy, and && binds tighter @@ -649,6 +671,9 @@ describe('qwen-autofix workflow', () => { !/startswith\("review-address"\)/.test(sel) && !/!= "Qwen Autofix"/.test(sel) && !/IN\("Qwen Autofix"/.test(sel) && + // The wake filter excludes the loop's fleet through the shared + // env list now (R27-23) — the reference IS the guard. + !/IN\(\$looplanes\[\]\)/.test(sel) && // The review-in-flight gate (#8888) selects BY NAME for the LLM // review check — a liveness probe, not a feedback selector, so it // needs neither the review-address carve-out nor the workflow guard. @@ -8660,6 +8685,7 @@ exit 1 '-c', `set -e\nAUTOFIX_BOT=qwen-code-dev-bot\nREVIEW_BOT=qwen-code-ci-bot\n` + `LIVE_REARM_KEY=W1\nWORKDIR=${dir}\nSTALE=${stale}\n` + + `LOOP_OWNED_LANES=${JSON.stringify(loopOwnedLanesJson)}\n` + `BASE_UPD_AT='${baseUpdAt}'\n` + `TRUSTED_ASSOC='["OWNER", "MEMBER", "COLLABORATOR"]'\n` + `${conflictBlock}\nprintf '%s' "$STALE"`, @@ -9811,7 +9837,10 @@ exit 1 // review-address must also fetch ic.json and render issue-level comments. expect(prepareBranchAndFeedbackStep).toMatch(normalizedIcFetch); expect(prepareBranchAndFeedbackStep).toContain( - '2> /dev/null || echo \'[]\' > "${WORKDIR}/checks.json"', + 'jq \'.statusCheckRollup // []\' <<< "${PR_ROLLUP}" > "${WORKDIR}/checks.json"', + ); + expect(prepareBranchAndFeedbackStep).toContain( + '|| echo \'[]\' > "${WORKDIR}/checks.json"', ); expect(workflow).toContain('## Issue-level comments'); expect(workflow).toContain('## Failed checks'); @@ -9821,10 +9850,14 @@ exit 1 ); // Four sites: the NEWEST computation, the live-watermark revalidation, // the "Failed checks" rendering, and the "Still-red checks" rendering - // share the address-check carve-out (the autofix workflow's OTHER lanes - // failing is the loop's own business, not actionable feedback). The - // conflict-handoff wake filter deliberately does NOT share it: under a - // park no address round can legitimately run, so it excludes ALL Qwen + // share the address-check carve-out (the autofix workflow's OTHER + // lanes failing is the loop's own business, not actionable feedback). + // The regression classifier (af-157) deliberately does NOT share it: + // the charge verdict excludes ALL own-lane checks via the canonical + // five-name filter — a failed or in-flight own run is feedback or + // observer noise, not a red the loop may charge to its own push. The + // conflict-handoff wake filter also does NOT share it: under a park + // no address round can legitimately run, so it excludes ALL Qwen // Autofix checks — the conflict round's own failed check must not // unpark its own park. expect( @@ -12575,6 +12608,8 @@ exit 1 'CI="${CI:-true}"', 'KISS_AUDIT="${KISS_AUDIT:-false}"', 'FOOTPRINT_ENFORCE="${FOOTPRINT_ENFORCE:-advisory}"', + 'WEAKEN_COUNTER_SHA256="${WEAKEN_COUNTER_SHA256:-}"', + 'WEAKEN_PARSER_SHA256="${WEAKEN_PARSER_SHA256:-}"', 'SELF_REVIEW_ARM="${SELF_REVIEW_ARM:-off}"', 'bash --norc "${RUNNER_TEMP}/run-autofix-review-verification.sh"', ]; @@ -17003,11 +17038,7 @@ exit 1 'crashed or timed out before reading the feedback', '在读取反馈之前崩溃或超时', ], - [ - 'HEADLINE', - 'consecutive rounds that pushed nothing', - '轮未能推送任何内容', - ], + ['HEADLINE', 'consecutive rounds without progress', '轮没有进展'], ['HEADLINE', 'time-budget exhaustions', '次时间预算耗尽'], // The cap remedy is inlined in HEADLINE/HEADLINE_ZH (the REMEDY // variables are gone) — its EN/ZH pairing stays pinned here. @@ -17487,6 +17518,13 @@ exit 1 ).toBeLessThan( stageStep.indexOf('cp .github/scripts/autofix-status-heartbeat.sh'), ); + // count-test-surface.mjs carries the SAME absent-from-base exposure, + // and takes this guard verbatim — but it is staged one step later, + // because its parser is a copy of the typescript `npm ci` installs. + // Its own pins (guard, order, and the verbatim absence probe) live in + // 'stages the counter and its parser from the trusted base'; this step + // must not stage it, or it would run before node_modules exists. + expect(stageStep).not.toContain('count-test-surface.mjs'); const stageProbeDir = mkdtempSync(join(tmpdir(), 'hb-stage-probe-')); const stageRunnerTemp = mkdtempSync(join(tmpdir(), 'hb-stage-temp-')); try { @@ -18949,6 +18987,7 @@ exit 1 staleBaseRetry = false, staleBaseDeferred = false, agentTimeout = '', + regressedRound = '', } = {}, ) => { const dir = mkdtempSync(join(tmpdir(), 'consec-')); @@ -18963,7 +19002,11 @@ exit 1 return { user: { login: 'qwen-code-dev-bot' }, created_at: `2026-01-01T00:${String(i).padStart(2, '0')}:00Z`, - body: `${headline}\n<!-- autofix-eval ts=x acted=y round=1${win ? ` win=${win}` : ''} -->`, + body: `${headline}\n<!-- autofix-eval ts=x acted=y round=1${win ? ` win=${win}` : ''} -->${ + typeof h === 'object' && h.regressed !== undefined + ? `\n<!-- autofix-regression round=${h.regressed} key=${h.regKey ?? win ?? 'none'} -->` + : '' + }`, }; }), ), @@ -18977,7 +19020,7 @@ exit 1 'bash', [ '-c', - `set -uo pipefail\nWORKDIR='${dir}'\nMARK_ROUND=${markRound}\nMAX_ROUNDS=100\nCONSECUTIVE_FAILURE_CAP=${cap}\nTIMEOUT_WINDOW_CAP=${timeoutCap}\nAGENT_TIMEOUT='${agentTimeout}'\nCONSEC_FAIL=0\nREPO=o/r\nPR=1\nAUTOFIX_BOT=qwen-code-dev-bot\nRETRY_COMMAND='@qwen-code /retry'\nAPI_ERROR_DETAIL='${apiErrorDetail}'\nAPI_ERROR_KIND='${apiErrorKind}'\nPREPARE_OUTCOME='${prepareOutcome}'\nSTALE_BASE_RETRY='${staleBaseRetry}'\nSTALE_BASE_DEFERRED='${staleBaseDeferred}'\n${window !== undefined ? `WINDOW='${window}'\n` : ''}HEADLINE=orig\nHEADLINE_ZH=orig\n${script}\nprintf '\\n@@R@@%s|%s|%s|%s' "$MARK_ROUND" "${'${CONSEC_FAIL}'}" "$HEADLINE" "$HEADLINE_ZH"`, + `set -uo pipefail\nWORKDIR='${dir}'\nMARK_ROUND=${markRound}\nMAX_ROUNDS=100\nCONSECUTIVE_FAILURE_CAP=${cap}\nTIMEOUT_WINDOW_CAP=${timeoutCap}\nAGENT_TIMEOUT='${agentTimeout}'\nCONSEC_FAIL=0\nREPO=o/r\nPR=1\nAUTOFIX_BOT=qwen-code-dev-bot\nRETRY_COMMAND='@qwen-code /retry'\nAPI_ERROR_DETAIL='${apiErrorDetail}'\nAPI_ERROR_KIND='${apiErrorKind}'\nPREPARE_OUTCOME='${prepareOutcome}'\nSTALE_BASE_RETRY='${staleBaseRetry}'\nSTALE_BASE_DEFERRED='${staleBaseDeferred}'\nREGRESSED_ROUND='${regressedRound}'\n${window !== undefined ? `WINDOW='${window}'\n` : ''}HEADLINE=orig\nHEADLINE_ZH=orig\n${script}\nprintf '\\n@@R@@%s|%s|%s|%s' "$MARK_ROUND" "${'${CONSEC_FAIL}'}" "$HEADLINE" "$HEADLINE_ZH"`, ], { env: { ...process.env, PATH: `${bin}:${process.env.PATH}` }, @@ -19085,6 +19128,84 @@ exit 1 consec: 3, terminal: false, }); + // A push that turned the checks red is NOT progress (af-157). Before + // this, "pushed something" was the whole reset test, so a PR could + // alternate regress / repair forever and every brake read it as + // converging: the red it created came back as the next round's input + // and was paid for out of the round budget. + const pushAt = (n) => + `🤖 Addressed the latest review feedback (round ${n}/100).`; + // Control: the same push with no regression marker still resets. + expect(run([FAIL, FAIL, { headline: pushAt(2) }, FAIL])).toMatchObject({ + consec: 2, + terminal: false, + }); + // ...and with one, the streak carries straight through it. + expect( + run([FAIL, FAIL, { headline: pushAt(2), regressed: 2 }, FAIL]), + ).toMatchObject({ consec: 5, terminal: true }); + // An unbroken run of regressing pushes trips the cap on its own — the + // shape the old counter could never see, because each round pushed. + expect( + run([ + { headline: pushAt(1), regressed: 1 }, + { headline: pushAt(2), regressed: 2 }, + { headline: pushAt(3), regressed: 3 }, + { headline: pushAt(4), regressed: 4 }, + ]), + ).toMatchObject({ consec: cap, terminal: true }); + // THIS round's own observation is not in the fetched comments yet — the + // marker rides the report this step is still composing. Without folding + // it in, the newest regressing push escapes by exactly one round, and + // that is the round that matters. + expect( + run( + [ + { headline: pushAt(1), regressed: 1 }, + { headline: pushAt(2), regressed: 2 }, + { headline: pushAt(3), regressed: 3 }, + { headline: pushAt(4) }, + ], + { regressedRound: '4' }, + ), + ).toMatchObject({ consec: cap, terminal: true }); + // A clean push after a regression clears the streak: the brake is about + // consecutive non-progress, and the loop recovering is progress. + expect( + run([ + { headline: pushAt(1), regressed: 1 }, + { headline: pushAt(2) }, + { headline: pushAt(3) }, + ]), + ).toMatchObject({ consec: 1, terminal: false }); + // Marker round numbers are matched WHOLE — round 1 must not charge + // round 10 (a substring match would, and the cap would trip early). + expect( + run([FAIL, FAIL, { headline: pushAt(10), regressed: 1 }, FAIL]), + ).toMatchObject({ consec: 2, terminal: false }); + // A regression keyed to another window is not this window's business: + // a re-arm drops the whole set with the rounds it keyed. + expect( + run( + [ + FAIL, + FAIL, + { headline: pushAt(2), win: 'w1', regressed: 2, regKey: 'w0' }, + { headline: FAIL, win: 'w1' }, + ], + { window: 'w1' }, + ), + ).toMatchObject({ consec: 2, terminal: false }); + // The terminal headline must name the new cause, or a maintainer reads + // "pushed nothing" on a PR whose every round pushed. + expect( + run([ + { headline: pushAt(1), regressed: 1 }, + { headline: pushAt(2), regressed: 2 }, + { headline: pushAt(3), regressed: 3 }, + { headline: pushAt(4), regressed: 4 }, + ]).headline, + ).toContain('left the checks red'); // The CURRENT round being a stale-base retry is exempt from the breaker // entirely — the base was just updated and the next round builds fresh, so // cap-1 prior failures must not trip it. @@ -19154,7 +19275,7 @@ exit 1 }); expect(idleStreak).toMatchObject({ consec: cap, terminal: true }); expect(idleStreak.headline).toContain( - 'consecutive rounds that pushed nothing', + 'consecutive rounds without progress', ); // ...and the TERMINAL run's job log still names the wedged runner: the // census warning runs outside the cap's terminal guard precisely so an @@ -19277,7 +19398,7 @@ exit 1 // — an `if true` mutation on the timeout guard flips the headline and // must fail here. expect(bothCapped.headline).toContain( - 'consecutive rounds that pushed nothing', + 'consecutive rounds without progress', ); expect(bothCapped.headline).not.toContain('time-budget exhaustions'); // Pin the census greps to the actual emit line: the timeout CAUSE text @@ -23066,6 +23187,7 @@ describe('growth-audit hardening: park wake set and verdict pipeline (round 3)', PR: '1', CHECKS_JSON: JSON.stringify(checks), TRUSTED_ASSOC: '["OWNER", "MEMBER", "COLLABORATOR"]', + LOOP_OWNED_LANES: loopOwnedLanesJson, RV_FIXTURE: join(dir, 'rv.fixture.json'), RC_FIXTURE: join(dir, 'rc.fixture.json'), }, @@ -24526,6 +24648,34 @@ describe('review verification gate: baseline A/B on deterministic rejection', () // drive stop-marker combinations through this. Defaults empty: the // summary default is owned by summaryPresent above, not duplicated here. workdirFiles = {}, + // Test-weakening fixtures: names a shape in WEAKEN_FIXTURES whose + // PRE-ROUND ref carries the pinned test and whose round then edits it. + weaken = '', + // Leave the counter unstaged: the measured signals go UNAVAILABLE. + noCounter = false, + // Shadow git so the gate's own enumeration producer fails (only the + // calls carrying the weakening pathspec): the producer's status must + // be read, not swallowed. + gitDiffFails = false, + // Shadow node so the counter fails for exactly one measured file — + // the shape a round-authored input that exhausts the parser produces. + counterFailsFor = '', + // Shadow node so the counter SUCCEEDS for exactly one measured file + // while printing a verdict that carries none of the fields the reader + // consumes: an exit-0 run whose output cannot be read must not pass + // for "not the round's to weaken". + counterGarbageFor = '', + // The instrument's trust chain: a counter or parser changed after its + // digest was recorded, or no trusted parser staged at all. + tamperCounter = false, + tamperParser = false, + dropCounter = false, + dropParser = false, + unreadableCounter = false, + noParser = false, + // Plant a package.json beside the staged parser that would turn a + // `.js` file into an ES module: the `.cjs` staging must not care. + parserTypePlant = false, }) => { const dir = mkdtempSync(join(tmpdir(), 'gate-ab-')); try { @@ -24541,18 +24691,55 @@ describe('review verification gate: baseline A/B on deterministic rejection', () sh(`git clone -q '${origin}' '${work}'`, dir); const g = (cmd) => sh(cmd, work); g('git config user.email t@t && git config user.name t'); + // The checkout's own node_modules is the round's to choose, so the + // fixture plants a HOSTILE typescript there: every API returns + // undefined, which measures a zero surface. Every charge this suite + // asserts therefore proves the counter parsed with the trusted + // parser staged under RUNNER_TEMP, never with the checkout's. + mkdirSync(join(work, 'node_modules', 'typescript'), { recursive: true }); + writeFileSync( + join(work, 'node_modules', 'typescript', 'package.json'), + '{"name":"typescript","main":"index.js"}', + ); + writeFileSync( + join(work, 'node_modules', 'typescript', 'index.js'), + 'module.exports = new Proxy({}, { get: () => () => undefined });\n', + ); + g("printf 'node_modules/\\n' > .gitignore"); g('echo base > f.txt && git add . && git commit -qm base'); g('git branch -M main && git push -q origin main'); + if (weaken && !WEAKEN_FIXTURES[weaken]) { + throw new Error(`unknown weaken fixture: ${weaken}`); + } + const fx = weaken ? WEAKEN_FIXTURES[weaken] : null; + if (fx && fx.onMain) { + for (const cmd of fixtureWrite(fx.onMain)) g(cmd); + // Raw commands for content `fixtureWrite` cannot express — a byte + // no shell argument can carry, for instance. + for (const cmd of fx.onMainSeed ?? []) g(cmd); + g('git add . && git commit -qm seed-test && git push -q origin main'); + } g('git checkout -qb feature'); if (touchCore) { // Reaches the core-rebuild run_check BEFORE the commit gate, so a // failure there exercises the no-round-commit guard. g('mkdir -p packages/core/src && echo x > packages/core/src/x.ts'); g('git add . && git commit -qm core'); + } else if (fx) { + // The pinned test must exist at origin/feature (the PRE-ROUND ref) + // before the round's agent commit weakens it. + for (const cmd of fixtureWrite(fx.files ?? {})) g(cmd); + for (const cmd of fx.seed ?? []) g(cmd); + g('echo branch > f.txt && git add . && git commit -qm branch'); } else { g('echo branch > f.txt && git commit -qam branch'); } g('git push -q origin feature'); + if (fx && fx.mainMoves) { + g('git checkout -q main'); + for (const cmd of fx.mainMoves) g(cmd); + g('git push -q origin main && git checkout -q feature'); + } if (agentCommit) { if (addWorkspace) { // The round ADDS a workspace — it does not exist at the baseline. @@ -24565,6 +24752,8 @@ describe('review verification gate: baseline A/B on deterministic rejection', () // A file the branch TRACKS but the baseline lacks: the baseline // leg recreates it untracked, and the restore checkout refuses. g('echo tracked > clash.txt && git add . && git commit -qm agent'); + } else if (fx) { + for (const cmd of fx.round) g(cmd); } else { g('echo agent > f.txt && git commit -qam agent'); } @@ -24675,6 +24864,65 @@ describe('review verification gate: baseline A/B on deterministic rejection', () writeFileSync(join(bin, 'comm'), '#!/bin/bash\nexit 1\n'); chmodSync(join(bin, 'comm'), 0o755); } + if (counterFailsFor) { + const realNode = execFileSync('bash', ['-c', 'command -v node'], { + encoding: 'utf8', + env: { ...process.env, ...GIT_ISOLATION }, + }).trim(); + writeFileSync( + join(bin, 'node'), + [ + '#!/bin/bash', + 'for a in "$@"; do', + ` if [[ "$a" == *.json && -f "$a" ]] && grep -q '"path": *"${counterFailsFor}"' "$a"; then`, + ' echo "stub: parser exhausted" >&2', + ' exit 1', + ' fi', + 'done', + `exec '${realNode}' "$@"`, + ].join('\n'), + ); + chmodSync(join(bin, 'node'), 0o755); + } + if (counterGarbageFor) { + const realNode = execFileSync('bash', ['-c', 'command -v node'], { + encoding: 'utf8', + env: { ...process.env, ...GIT_ISOLATION }, + }).trim(); + writeFileSync( + join(bin, 'node'), + [ + '#!/bin/bash', + 'for a in "$@"; do', + ` if [[ "$a" == *.json && -f "$a" ]] && grep -q '"path": *"${counterGarbageFor}"' "$a"; then`, + // Valid JSON, no verdict: `.baselinePresent` reads as null, + // which the pre-fix reader took for "not the round's". + ` echo '{"note":"measured nothing"}'`, + ' exit 0', + ' fi', + 'done', + `exec '${realNode}' "$@"`, + ].join('\n'), + ); + chmodSync(join(bin, 'node'), 0o755); + } + if (gitDiffFails) { + const realGit = execFileSync('bash', ['-c', 'command -v git'], { + encoding: 'utf8', + env: { ...process.env, ...GIT_ISOLATION }, + }).trim(); + writeFileSync( + join(bin, 'git'), + [ + '#!/bin/bash', + 'for a in "$@"; do', + " if [[ \"$a\" == ':(glob)**/test_*.py' ]]; then echo 'stub: enumeration refused' >&2; exit 128; fi", + 'done', + `exec '${realGit}' "$@"`, + ].join('\n'), + ); + chmodSync(join(bin, 'git'), 0o755); + } const rt = join(dir, 'rt'); mkdirSync(rt); writeFileSync( @@ -24706,6 +24954,60 @@ describe('review verification gate: baseline A/B on deterministic rejection', () join(rt, 'resolve-owning-packages.sh'), 'cat > /dev/null\nprintf "%s" "${RESOLVED_PKGS:-}"\n', ); + // Staged the way the workflow stages them: the counter copied from + // the trusted base and the parser installed from the base lockfile's + // pin under RUNNER_TEMP, each digested BEFORE any later mutation. + const digest = (file) => + createHash('sha256').update(readFileSync(file)).digest('hex'); + let counterSha = ''; + let parserSha = ''; + if (!noCounter) { + copyFileSync( + resolve('.github/scripts/count-test-surface.mjs'), + join(rt, 'count-test-surface.mjs'), + ); + counterSha = digest(join(rt, 'count-test-surface.mjs')); + if (dropCounter) rmSync(join(rt, 'count-test-surface.mjs')); + if (unreadableCounter) chmodSync(join(rt, 'count-test-surface.mjs'), 0); + if (tamperCounter) { + writeFileSync( + join(rt, 'count-test-surface.mjs'), + `${readFileSync(join(rt, 'count-test-surface.mjs'), 'utf8')}\n// planted\n`, + ); + } + } + if (!noParser) { + // The workflow stages a `.cjs` COPY of typescript's single-file + // build; a symlink to the real file is the same bytes for the + // digest and the loader, so only the tamper and plant shapes copy. + const parser = join(rt, 'weaken-parser', 'typescript.cjs'); + mkdirSync(join(rt, 'weaken-parser'), { recursive: true }); + if (tamperParser || parserTypePlant) { + copyFileSync( + resolve('node_modules/typescript/lib/typescript.js'), + parser, + ); + } else { + symlinkSync( + resolve('node_modules/typescript/lib/typescript.js'), + parser, + ); + } + parserSha = digest(parser); + if (tamperParser) { + writeFileSync( + parser, + `${readFileSync(parser, 'utf8')}\n// planted\n`, + ); + } + if (parserTypePlant) { + writeFileSync( + join(rt, 'weaken-parser', 'package.json'), + '{"type":"module"}\n', + ); + } + if (dropParser) rmSync(parser); + } const workdir = join(dir, 'wd'); mkdirSync(workdir); if (summaryPresent) { @@ -24775,6 +25077,8 @@ describe('review verification gate: baseline A/B on deterministic rejection', () FORGE_OUTPUT: forgeOutput ? '1' : '', DISCOVER_OUTPUT: discoverOutput ? '1' : '', DISCOVER_ENV: forgeEnvFile ? '1' : '', + WEAKEN_COUNTER_SHA256: counterSha, + WEAKEN_PARSER_SHA256: parserSha, }, }, ); @@ -24791,6 +25095,9 @@ describe('review verification gate: baseline A/B on deterministic rejection', () rejection: existsSync(join(workdir, 'gate-rejection.md')) ? readFileSync(join(workdir, 'gate-rejection.md'), 'utf8') : '', + advisories: existsSync(join(workdir, 'gate-advisories.md')) + ? readFileSync(join(workdir, 'gate-advisories.md'), 'utf8') + : '', headAfter: sh('git rev-parse --abbrev-ref HEAD', work).trim(), baselineSha, }; @@ -25668,6 +25975,4449 @@ describe('review verification gate: baseline A/B on deterministic rejection', () expect(r.outputs).not.toContain('audit_verdict='); expect(r.outputs).toContain('retryable=true'); }); + + // ---- test-weakening gate ------------------------------------------------ + // Each fixture names the file(s) seeded at the PRE-ROUND ref (`files`, on + // the branch; `onMain`, on main ahead of the fork when the shape needs + // main-side history), main's moves after the fork (`mainMoves`), and the + // round's own commands (`round`). The gate runs for real against the + // fixture repo: count-test-surface.mjs is staged the way the workflow + // stages it (a copy under RUNNER_TEMP) and `typescript` resolves from the + // checkout under measurement, as on the runner. + const shellQuote = (s) => `'${s.replaceAll("'", "'\\''")}'`; + const fixtureWrite = (files) => + Object.entries(files).flatMap(([path, lines]) => [ + ...(path.includes('/') + ? [`mkdir -p '${path.slice(0, path.lastIndexOf('/'))}'`] + : []), + `printf '%s\\n' ${lines.map(shellQuote).join(' ')} > '${path}'`, + ]); + const AGENT_COMMIT = 'echo agent > f.txt && git commit -qam agent'; + const WT_IMPORT = "import { it, expect } from 'vitest';"; + const WT_BASE = [ + WT_IMPORT, + "it('a', () => {", + ' expect(one()).toBe(1);', + ' expect(two()).toBe(2);', + '});', + ]; + const WT_BASE_MINUS_TWO = WT_BASE.filter((l) => !l.includes('two()')); + const WT_BASE_PLUS_THREE = [ + ...WT_BASE.slice(0, -1), + ' expect(three()).toBe(3);', + '});', + ]; + const testA = (head, ...body) => [ + WT_IMPORT, + head, + ...(body.length + ? body + : [' expect(one()).toBe(1);', ' expect(two()).toBe(2);']), + '});', + ]; + // A second registration appended to WT_BASE, enabled or not. + const testB = (kind = 'it') => [ + `${kind}('b', () => {`, + ' expect(three()).toBe(3);', + '});', + ]; + const WEAKEN_REASON = + 'coverage moved to pkg/b.test.ts, which pins the same behaviour end to end'; + const roundWrites = (files, ...extra) => [ + ...fixtureWrite(files), + ...extra, + AGENT_COMMIT, + ]; + const WEAKEN_FIXTURES = { + // -- the three plain shapes + assert: { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + }, + skip: { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ 'pkg/a.test.ts': testA("it.skip('a', () => {") }), + }, + delete: { + files: { 'pkg/a.test.ts': WT_BASE }, + round: ['git rm -q pkg/a.test.ts', AGENT_COMMIT], + }, + // -- the round deletes a pre-existing test file and THEN merges main, + // which still carries it. Main moved a DIFFERENT file, so the merge + // keeps the deletion without a conflict — and the merge event moves + // nothing for the deleted path on either side that survived, which + // must not read as main removing the baseline the round is charged + // against. + 'delete-then-merge': { + onMain: { 'pkg/a.test.ts': WT_BASE, 'pkg/b.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/b.test.ts': WT_BASE_PLUS_THREE }), + 'git commit -qam main-grows-b', + ], + round: [ + 'git rm -q pkg/a.test.ts', + 'git commit -qm round-deletes-a', + 'git merge -q --no-edit origin/main', + AGENT_COMMIT, + ], + }, + // -- honest edits the gate must not charge + add: { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ 'pkg/a.test.ts': WT_BASE_PLUS_THREE }), + }, + 'skipif-guard': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': testA( + "it.skipIf(process.platform === 'win32')('a', () => {", + ), + }), + }, + 'ctx-skip-conditional': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': testA( + "it('a', (ctx) => {", + " ctx.skip(process.platform === 'win32', 'requires the POSIX daemon harness');", + ' expect(one()).toBe(1);', + ' expect(two()).toBe(2);', + ), + }), + }, + 'todo-new': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': [...WT_BASE, "it.todo('a later behaviour');"], + }), + }, + rename: { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': testA("it('a, renamed', () => {"), + }), + }, + 'move-within': { + files: { + 'pkg/a.test.ts': [ + WT_IMPORT, + "it('a', () => {", + ' expect(one()).toBe(1);', + '});', + "it('b', () => {", + ' expect(two()).toBe(2);', + '});', + ], + }, + round: roundWrites({ + 'pkg/a.test.ts': [ + WT_IMPORT, + "it('a', () => {", + '});', + "it('b', () => {", + ' expect(one()).toBe(1);', + ' expect(two()).toBe(2);', + '});', + ], + }), + }, + reflow: { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': [ + WT_IMPORT, + "it('a', () => {", + ' expect(', + ' one(),', + ' ).toBe(', + ' 1,', + ' );', + ' expect(two()).toBe(2);', + '});', + ], + }), + }, + 'jsx-swap': { + files: { 'pkg/a.test.tsx': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.tsx': testA( + "it('a', () => {", + " expect(render(<Foo bar={1} />).getByText('x')).toBeTruthy();", + ' expect(two()).toBe(2);', + ), + }), + }, + 'delete-readd-stronger': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: [ + 'git rm -q pkg/a.test.ts', + 'git commit -qm delete', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_PLUS_THREE }), + 'git add pkg/a.test.ts', + AGENT_COMMIT, + ], + }, + 'python-edit': { + files: { 'tests/test_x.py': ['def test_x():', ' assert one() == 1'] }, + round: roundWrites({ 'tests/test_x.py': ['def test_x():', ' pass'] }), + }, + 'non-test-file-delete': { + files: { + 'integration-tests/README.md': ['# notes'], + 'packages/cli/src/test-utils/render.tsx': [ + 'export const render = () => null;', + ], + }, + round: [ + 'git rm -q integration-tests/README.md packages/cli/src/test-utils/render.tsx', + AGENT_COMMIT, + ], + }, + // -- weakenings that hide from text patterns, visible to the parser + 'comment-out': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': testA( + "it('a', () => {", + ' /*', + ' expect(one()).toBe(1);', + ' expect(two()).toBe(2);', + ' */', + ), + }), + }, + 'string-decoy': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': testA( + "it('a', () => {", + ' expect(one()).toBe(1);', + " const decoy = 'expect(two()).toBe(2); expect(x).toBe(1)';", + ' const re = /expect\\(two\\(\\)\\)\\.toBe\\(2\\)/;', + ' fn(expect.anything(), `expect(${decoy}).toBe(2)`);', + ), + }), + }, + 'jsx-decoy': { + files: { 'pkg/a.test.tsx': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.tsx': testA( + "it('a', () => {", + ' expect(one()).toBe(1);', + ' const html = <div>expect(two()).toBe(2)</div>;', + ), + }), + }, + 'matcher-drop': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': testA( + "it('a', () => {", + ' expect(one()).toBe(1);', + ' expect(two());', + ), + }), + }, + 'computed-skip': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': testA("it['\\x73kip']('a', () => {"), + }), + }, + 'options-skip': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': testA("it('a', { skip: true }, () => {"), + }), + }, + 'skipif-true': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': testA("it.skipIf(true)('a', () => {"), + }), + }, + 'ctx-skip': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': testA( + "it('a', (ctx) => {", + ' ctx.skip();', + ' expect(one()).toBe(1);', + ' expect(two()).toBe(2);', + ), + }), + }, + 'early-return': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': testA( + "it('a', () => {", + ' return;', + ' expect(one()).toBe(1);', + ' expect(two()).toBe(2);', + ), + }), + }, + 'early-return-conditional': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': testA( + "it('a', () => {", + ' if (!process.env.QWEN_RUN_ADDS) {', + ' return;', + ' }', + ' expect(one()).toBe(1);', + ' expect(two()).toBe(2);', + ), + }), + }, + 'skip-swap': { + files: { + 'pkg/a.test.ts': [ + WT_IMPORT, + "it('a', () => {", + ' expect(one()).toBe(1);', + '});', + "it.skip('b', () => {", + ' expect(two()).toBe(2);', + '});', + ], + }, + round: roundWrites({ + 'pkg/a.test.ts': [ + WT_IMPORT, + "it.skip('a', () => {", + ' expect(one()).toBe(1);', + '});', + "it('b', () => {", + ' expect(two()).toBe(2);', + '});', + ], + }), + }, + 'rename-skip': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': testA("it.skip('a, renamed', () => {"), + }), + }, + 'delete-readd-weaker': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: [ + 'git rm -q pkg/a.test.ts', + 'git commit -qm delete', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + 'git add pkg/a.test.ts', + AGENT_COMMIT, + ], + }, + symlink: { + files: { 'pkg/a.test.ts': WT_BASE }, + round: [ + 'rm pkg/a.test.ts', + 'ln -s ../f.txt pkg/a.test.ts', + 'git add -A pkg', + AGENT_COMMIT, + ], + }, + 'python-delete': { + files: { 'tests/test_x.py': ['def test_x():', ' assert one() == 1'] }, + round: ['git rm -q tests/test_x.py', AGENT_COMMIT], + }, + 'ours-merge-drops-branch-work': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: { 'pkg/a.test.ts': WT_BASE_PLUS_THREE }, + round: [ + // The merge's TREE is its first parent's, so every per-commit diff + // is empty while the tip lost the branch's own assertion. + 'git checkout -q --detach origin/main', + 'git merge -q --no-edit -s ours feature', + 'git branch -f feature HEAD', + 'git checkout -q feature', + AGENT_COMMIT, + ], + }, + 'main-landed-unmeasurable': { + files: { 'pkg/a.test.ts': WT_BASE }, + mainMoves: [ + ...fixtureWrite({ 'pkg/new.test.ts': WT_BASE_PLUS_THREE }), + 'git add pkg/new.test.ts && git commit -qm main-adds-a-test', + ], + round: [ + 'git merge -q --no-edit origin/main', + ...fixtureWrite({ 'pkg/new.test.ts': WT_BASE_MINUS_TWO }), + 'git add -A pkg', + AGENT_COMMIT, + ], + }, + 'unparseable-addition': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites( + { + 'pkg/a.test.ts': WT_BASE_MINUS_TWO, + 'pkg/generated.test.ts': [WT_IMPORT, "it('deep', () => {});"], + }, + 'git add -A pkg', + ), + }, + 'symlink-restored': { + files: { 'pkg/real.test.ts': WT_BASE }, + round: [ + // Writing THROUGH the link would edit its target; the link itself + // has to go first for the tip to hold a regular file. + 'rm pkg/a.test.ts', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE }), + 'git add -A pkg', + AGENT_COMMIT, + ], + seed: ['ln -s real.test.ts pkg/a.test.ts', 'git add pkg/a.test.ts'], + }, + 'merge-historical-main-commit': { + onMain: { 'pkg/a.test.ts': WT_BASE_PLUS_THREE }, + files: {}, + mainMoves: [ + 'git checkout -qb side', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + 'git commit -qam side-trims', + 'git rev-parse HEAD > /tmp/qwen-side-sha', + 'git checkout -q main', + 'git merge -q --no-ff --no-edit -s ours side', + 'git branch -D side', + ], + round: [ + 'git merge -q --no-edit -s ours "$(cat /tmp/qwen-side-sha)"', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + AGENT_COMMIT, + ], + }, + 'directory-swap': { + files: { 'tests/test_x.py': ['def test_x():', ' assert one() == 1'] }, + round: [ + 'git rm -q tests/test_x.py', + 'mkdir -p tests/test_x.py', + "printf 'moved\\n' > tests/test_x.py/inner.txt", + 'git add -A tests', + AGENT_COMMIT, + ], + }, + 'symlink-python': { + files: { 'tests/test_x.py': ['def test_x():', ' assert one() == 1'] }, + round: [ + 'rm tests/test_x.py', + 'ln -s ../f.txt tests/test_x.py', + 'git add -A tests', + AGENT_COMMIT, + ], + }, + 'rust-delete': { + files: { 'crates/x/src/browser/v2_tests.rs': ['#[test]', 'fn t() {}'] }, + round: ['git rm -q crates/x/src/browser/v2_tests.rs', AGENT_COMMIT], + }, + 'tab-name': { + files: { 'pkg/a\tb.test.ts': WT_BASE }, + round: roundWrites({ 'pkg/a\tb.test.ts': WT_BASE_MINUS_TWO }), + }, + 'two-files': { + files: { 'pkg/a.test.ts': WT_BASE, 'pkg/b.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': WT_BASE_MINUS_TWO, + 'pkg/b.test.ts': WT_BASE_MINUS_TWO, + }), + }, + 'spec-delete': { + files: { 'pkg/a.spec.ts': WT_BASE }, + round: ['git rm -q pkg/a.spec.ts', AGENT_COMMIT], + }, + 'snapshot-delete': { + files: { + 'pkg/a.test.ts': WT_BASE, + 'pkg/__snapshots__/a.test.ts.snap': ['exports[`a 1`] = `1`;'], + }, + round: ['git rm -q pkg/__snapshots__/a.test.ts.snap', AGENT_COMMIT], + }, + // -- attribution corners: deletions across merges, renames, roots + 'delete-then-merge-edit': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ + 'pkg/a.test.ts': WT_BASE.map((l) => l.replace('toBe(1)', 'toBe(10)')), + }), + 'git commit -qam main-edits', + ], + round: [ + 'git rm -q pkg/a.test.ts', + 'git commit -qm delete', + 'git merge -q --no-edit origin/main || true', + 'git rm -q pkg/a.test.ts', + 'git commit -qm keep-the-deletion', + AGENT_COMMIT, + ], + }, + 'own-file-merge-delete': { + files: { 'pkg/a.test.ts': WT_BASE }, + mainMoves: [ + 'echo m > m.txt && git add m.txt && git commit -qm main-moves', + ], + round: [ + ...fixtureWrite({ 'pkg/own.test.ts': WT_BASE }), + 'git add pkg/own.test.ts && git commit -qm own', + 'git merge -q --no-edit origin/main', + 'git rm -q pkg/own.test.ts', + 'git commit -qm drop-own', + AGENT_COMMIT, + ], + }, + // -- the round's OWN new test file, touched while resolving a merge and + // dropped afterwards. Main never held it, so no event may latch it + // into the baseline: an event whose model moved nothing contributed + // nothing, however the merge commit touched the file. + 'own-file-merge-edit-delete': { + files: { 'pkg/a.test.ts': WT_BASE }, + mainMoves: [ + 'echo m > m.txt && git add m.txt && git commit -qm main-moves', + ], + round: [ + ...fixtureWrite({ 'pkg/own.test.ts': WT_BASE }), + 'git add pkg/own.test.ts && git commit -qm own', + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ 'pkg/own.test.ts': WT_BASE_PLUS_THREE }), + 'git add pkg/own.test.ts && git commit -qm merge-and-grow-own', + 'git rm -q pkg/own.test.ts', + 'git commit -qm drop-own', + AGENT_COMMIT, + ], + }, + // -- main ADDS an assertion during the round and the merge resolution + // drops it. Main's additions raise the baseline whatever the merge + // kept, so the round removed coverage and is charged: clamping this + // direction would hand it the removal for free. + 'merge-drops-mains-add': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_PLUS_THREE }), + 'git commit -qam main-adds', + ], + round: [ + 'echo f2 > f2.txt && git add f2.txt && git commit -qm feature-moves', + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE }), + "printf '// merge note\\n' >> 'pkg/a.test.ts'", + 'git add pkg/a.test.ts && git commit -qm merge-drops-add', + AGENT_COMMIT, + ], + }, + // -- the same discard through the modify/delete arm: main appends a + // whole test, the round deleted the file, and the resolution puts + // back the PRE-ROUND bytes -- so main's added test is gone. + 'delete-merge-drops-mains-test': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/a.test.ts': [...WT_BASE, ...testB()] }), + 'git commit -qam main-adds-test', + ], + round: [ + 'git rm -q pkg/a.test.ts', + 'git commit -qm round-deletes', + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE }), + 'git add pkg/a.test.ts && git commit -qm resolution', + AGENT_COMMIT, + ], + }, + // -- and the same discard spelled as a disable: the resolution keeps + // main's new test but registers it skipped. + 'delete-merge-skips-mains-test': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/a.test.ts': [...WT_BASE, ...testB()] }), + 'git commit -qam main-adds-test', + ], + round: [ + 'git rm -q pkg/a.test.ts', + 'git commit -qm round-deletes', + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ + 'pkg/a.test.ts': [...WT_BASE, ...testB('it.skip')], + }), + 'git add pkg/a.test.ts && git commit -qm resolution', + AGENT_COMMIT, + ], + }, + // -- main removes TWO assertions, the resolution keeps one of them, and + // the round then removes a third of its own. Credit is what LANDED + // (one), not what the model says main removed (two): crediting the + // model would swallow the round's own removal. + 'merge-keeps-one-then-round-removes': { + onMain: { 'pkg/a.test.ts': WT_BASE_PLUS_THREE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + 'git commit -qam main-removes-two', + ], + round: [ + 'echo f2 > f2.txt && git add f2.txt && git commit -qm feature-moves', + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE }), + 'git add pkg/a.test.ts && git commit -qm keeps-one', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + AGENT_COMMIT, + ], + }, + // -- main DISABLES a pre-existing test, the resolution keeps it + // enabled, and the round disables it afterwards. Main's disable + // never landed, so the title stays in the baseline and the round + // answers for it. + 'merge-keeps-enabled-then-round-disables': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ + 'pkg/a.test.ts': WT_BASE.map((l) => + l.replace("it('a'", "it.skip('a'"), + ), + }), + 'git commit -qam main-disables', + ], + round: [ + 'echo f2 > f2.txt && git add f2.txt && git commit -qm feature-moves', + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE }), + 'git add pkg/a.test.ts && git commit -qm keeps-enabled', + ...fixtureWrite({ + 'pkg/a.test.ts': WT_BASE.map((l) => + l.replace("it('a'", "it.skip('a'"), + ), + }), + AGENT_COMMIT, + ], + }, + // -- BOTH sides add the same path during the round, the resolution + // lands main's file, and the round then deletes it. Main held that + // side, so the baseline holds the file and the deletion is the + // round's: presence reads main's own side, not a three-way model + // whose endpoint happens to equal the branch's. + 'addadd-takes-main-then-drops': { + files: { 'pkg/a.test.ts': WT_BASE }, + mainMoves: [ + ...fixtureWrite({ 'pkg/x.test.ts': WT_BASE }), + 'git add pkg/x.test.ts && git commit -qm main-adds-x', + ], + round: [ + ...fixtureWrite({ 'pkg/x.test.ts': WT_BASE_MINUS_TWO }), + 'git add pkg/x.test.ts && git commit -qm round-adds-x', + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ 'pkg/x.test.ts': WT_BASE }), + 'git add pkg/x.test.ts && git commit -qm resolution-takes-main', + 'git rm -q pkg/x.test.ts', + 'git commit -qm drop-x', + AGENT_COMMIT, + ], + }, + // -- the round un-skips a suite, main adds a test INSIDE that suite, + // and the resolution takes main's file verbatim -- so the tip is + // byte-identical to main's and nothing was weakened. A three-way + // splice of the two sides would invent an ENABLED registration that + // existed in no tree and charge the round for disabling it. + 'merge-takes-main-after-own-unskip': { + onMain: { + 'pkg/a.test.ts': [ + WT_IMPORT, + "describe.skip('S', () => {", + " it('a', () => {", + ' expect(one()).toBe(1);', + ' });', + '});', + ], + }, + files: {}, + mainMoves: [ + ...fixtureWrite({ + 'pkg/a.test.ts': [ + WT_IMPORT, + "describe.skip('S', () => {", + " it('a', () => {", + ' expect(one()).toBe(1);', + ' });', + " it('c', () => {", + ' expect(three()).toBe(3);', + ' });', + '});', + ], + }), + 'git commit -qam main-adds-c', + ], + round: [ + ...fixtureWrite({ + 'pkg/a.test.ts': [ + WT_IMPORT, + "describe('S', () => {", + " it('a', () => {", + ' expect(one()).toBe(1);', + ' });', + '});', + ], + }), + 'git commit -qam round-unskips', + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ + 'pkg/a.test.ts': [ + WT_IMPORT, + "describe.skip('S', () => {", + " it('a', () => {", + ' expect(one()).toBe(1);', + ' });', + " it('c', () => {", + ' expect(three()).toBe(3);', + ' });', + '});', + ], + }), + 'git add pkg/a.test.ts && git commit -qm resolution-takes-main', + AGENT_COMMIT, + ], + }, + // -- the resolution removes MORE than main's side did. Credit is + // main's own delta, not the merge's: crediting the larger landed + // removal would shield the round for the difference. + 'resolution-removes-more-than-main': { + onMain: { 'pkg/a.test.ts': WT_BASE_PLUS_THREE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE }), + 'git commit -qam main-removes-one', + ], + round: [ + 'echo f2 > f2.txt && git add f2.txt && git commit -qm feature-moves', + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + 'git add pkg/a.test.ts && git commit -qm resolution-removes-two', + AGENT_COMMIT, + ], + }, + // -- main REMOVED while the merge landed an ADDITION: the two moved in + // opposite directions, so main's removal was plainly not taken and + // credits nothing. Crediting the landed addition instead would + // charge the round for one assertion more than it removed. + 'main-removes-merge-adds': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + 'git commit -qam main-removes-one', + ], + round: [ + 'echo f2 > f2.txt && git add f2.txt && git commit -qm feature-moves', + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_PLUS_THREE }), + 'git add pkg/a.test.ts && git commit -qm resolution-adds', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + AGENT_COMMIT, + ], + }, + // -- the file was removed in an EARLIER round, so the pre-round ref + // lacks it while main still carries it untouched. Main landed + // nothing here, and the round only merged: main merely still + // HOLDING a file it has always held must not put it back in the + // baseline, or every later round re-charges the same deletion. + 'deleted-earlier-round-then-merge': { + onMain: { 'pkg/a.test.ts': WT_BASE, 'pkg/x.test.ts': WT_BASE }, + files: {}, + seed: ['git rm -q pkg/x.test.ts'], + mainMoves: [ + 'echo m > m.txt && git add m.txt && git commit -qm main-moves', + ], + round: ['git merge -q --no-edit origin/main', AGENT_COMMIT], + }, + // -- the same shape spelled as a rename an earlier round made. The old + // path costs ONE ack entry in the round that renames it, not one in + // every later round that merges main. + 'renamed-earlier-round-then-merge': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + seed: ['git mv pkg/a.test.ts pkg/b.test.ts'], + mainMoves: [ + 'echo m > m.txt && git add m.txt && git commit -qm main-moves', + ], + round: ['git merge -q --no-edit origin/main', AGENT_COMMIT], + }, + // -- main adds a file, deletes it again with the merge keeping the + // round's copy, then re-adds it. Each event measures main against + // its OWN merge base, so the re-add must not be credited a second + // time on top of the first. + 'main-adds-deletes-readds': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/x.test.ts': WT_BASE }), + 'git add pkg/x.test.ts && git commit -qm main-adds-x', + ], + round: [ + 'git merge -q --no-edit origin/main', + 'git checkout -q main', + 'git rm -q pkg/x.test.ts', + 'git commit -qm main-drops-x', + 'git push -q origin main', + 'git checkout -q feature', + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ 'pkg/x.test.ts': WT_BASE }), + 'git add pkg/x.test.ts && git commit -qm keep-x', + 'git checkout -q main', + ...fixtureWrite({ 'pkg/x.test.ts': WT_BASE }), + // One comment line, so the re-add is a real event: byte-identical + // content makes `git diff --quiet` skip it and the fixture would + // never reach the chain it exists to pin. + "printf '// main re-added this\\n' >> 'pkg/x.test.ts'", + 'git add pkg/x.test.ts && git commit -qm main-readds-x', + 'git push -q origin main', + 'git checkout -q feature', + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ 'pkg/x.test.ts': WT_BASE }), + "printf '// main re-added this\\n' >> 'pkg/x.test.ts'", + 'git add -A pkg && git commit -qm take-mains-readd', + ...fixtureWrite({ 'pkg/x.test.ts': WT_BASE_MINUS_TWO }), + AGENT_COMMIT, + ], + }, + // -- main DELETES the file and the merge does not adopt the deletion: + // the round keeps its own copy, gutted. Main contributed nothing + // here, so recording the event would let the round's own copy stand + // in for main's side and credit main with the round's removal. + 'main-deletes-round-keeps-weakened': { + onMain: { 'pkg/a.test.ts': WT_BASE, 'pkg/x.test.ts': WT_BASE }, + files: {}, + mainMoves: ['git rm -q pkg/x.test.ts', 'git commit -qm main-deletes-x'], + round: [ + ...fixtureWrite({ 'pkg/x.test.ts': WT_BASE_MINUS_TWO }), + 'git commit -qam round-guts-x', + 'git merge -q --no-edit --no-commit origin/main || true', + 'git add -A pkg && git commit -qm keep-our-copy', + AGENT_COMMIT, + ], + }, + // -- the file was removed in an EARLIER round, main GROWS it during + // this one, and the round restores main's file and then guts it. + // Main contributed content this round, so the baseline holds the + // file however the pre-round ref stood. + 'preround-absent-main-grows-round-guts': { + onMain: { 'pkg/a.test.ts': WT_BASE, 'pkg/x.test.ts': WT_BASE }, + files: {}, + seed: ['git rm -q pkg/x.test.ts'], + mainMoves: [ + ...fixtureWrite({ 'pkg/x.test.ts': WT_BASE_PLUS_THREE }), + 'git commit -qam main-grows-x', + ], + round: [ + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ 'pkg/x.test.ts': WT_BASE_PLUS_THREE }), + 'git add -A pkg && git commit -qm restore-mains-file', + ...fixtureWrite({ + 'pkg/x.test.ts': testA("it('a', () => {", ' noop();'), + }), + AGENT_COMMIT, + ], + }, + // -- a pre-existing file MAIN never held, deleted inside the merge + // commit itself. Main moved nothing, so nothing may take it out of + // the baseline the pre-round ref established. + 'preround-own-file-merge-drops': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: { 'pkg/n.test.ts': WT_BASE }, + mainMoves: [ + 'echo m > m.txt && git add m.txt && git commit -qm main-moves', + ], + round: [ + 'git merge -q --no-edit --no-commit origin/main || true', + 'git rm -q pkg/n.test.ts', + 'git commit -qm merge-drops-n', + AGENT_COMMIT, + ], + }, + // -- a real criss-cross: the round merged a main commit and main + // merged the round's, so the merge of main has TWO equally valid + // bases. They hold the same blob for this file, so git's pick + // cannot change the verdict and it is measured normally. + 'crisscross-bases-agree': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: ['echo m1 > m1.txt', 'git add m1.txt', 'git commit -qm m1'], + round: [ + 'git merge -q --no-edit origin/main', + 'git checkout -q main', + 'git merge -q --no-edit origin/feature', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_PLUS_THREE }), + 'git commit -qam main-grows', + 'git push -q origin main', + 'git checkout -q feature', + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_PLUS_THREE }), + 'git add -A pkg && git commit -qm take-main', + AGENT_COMMIT, + ], + }, + // -- the same criss-cross, but the two bases DISAGREE about the file, + // so git's pick would decide the verdict. Refused rather than + // measured on a tie-break git does not promise. + 'crisscross-bases-disagree': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + 'git commit -qam m1-weakens', + ], + round: [ + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE }), + 'git add -A pkg && git commit -qm keep-ours', + 'git checkout -q main', + 'git merge -q --no-edit --no-commit origin/feature || true', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + 'git add -A pkg && git commit -qm m2', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_PLUS_THREE }), + 'git commit -qam main-grows', + 'git push -q origin main', + 'git checkout -q feature', + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_PLUS_THREE }), + 'git add -A pkg && git commit -qm take-main', + AGENT_COMMIT, + ], + }, + // -- main moved the file EARLIER in the round, then deleted it with + // the merge keeping the round's gutted copy, and the round merges + // main once more. The later merge sees no main side and no base, + // and recording it would let the round's own copy set the clamp -- + // crediting main with the round's removal one merge later. + 'main-deletes-after-moving-then-merges-again': { + onMain: { 'pkg/a.test.ts': WT_BASE, 'pkg/x.test.ts': WT_BASE }, + files: {}, + mainMoves: ['echo m1 > m1.txt && git add m1.txt && git commit -qm m1'], + round: [ + ...fixtureWrite({ 'pkg/x.test.ts': WT_BASE_MINUS_TWO }), + 'git commit -qam round-guts-x', + 'git checkout -q main', + "printf '// main touched this\\n' >> 'pkg/x.test.ts'", + 'git commit -qam main-comments-x', + 'git push -q origin main', + 'git checkout -q feature', + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ 'pkg/x.test.ts': WT_BASE_MINUS_TWO }), + 'git add -A pkg && git commit -qm keep-ours-1', + 'git checkout -q main', + 'git rm -q pkg/x.test.ts', + 'git commit -qm main-deletes-x', + 'git push -q origin main', + 'git checkout -q feature', + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ 'pkg/x.test.ts': WT_BASE_MINUS_TWO }), + 'git add -A pkg && git commit -qm keep-ours-2', + 'git checkout -q main', + 'echo m3 > m3.txt && git add m3.txt && git commit -qm m3', + 'git push -q origin main', + 'git checkout -q feature', + 'git merge -q --no-edit origin/main', + AGENT_COMMIT, + ], + }, + // -- the file was removed in an EARLIER round and main only appends a + // comment to its own copy. That moves no surface, so it is not a + // contribution and the old deletion is not re-charged. + 'preround-absent-main-comments': { + onMain: { 'pkg/a.test.ts': WT_BASE, 'pkg/x.test.ts': WT_BASE }, + files: {}, + seed: ['git rm -q pkg/x.test.ts'], + mainMoves: [ + "printf '// main touched this\\n' >> 'pkg/x.test.ts'", + 'git commit -qam main-comments-x', + ], + round: [ + 'git merge -q --no-edit --no-commit origin/main || true', + 'git rm -q -f --ignore-unmatch pkg/x.test.ts', + 'git commit -qm keep-our-deletion', + AGENT_COMMIT, + ], + }, + // -- a NON-JS test file: the counter reads no surface from it, so + // "the surface did not move" is unmeasurable rather than false. + // An earlier round deleted it, main GROWS it during this one, and + // the round keeps its deletion: the deletion arm is the only arm + // these shapes have, and it must still fire. + 'preround-absent-main-grows-python': { + onMain: { + 'pkg/a.test.ts': WT_BASE, + 'pkg/test_x.py': ['def test_a():', ' assert one() == 1'], + }, + files: {}, + seed: ['git rm -q pkg/test_x.py'], + mainMoves: [ + ...fixtureWrite({ + 'pkg/test_x.py': [ + 'def test_a():', + ' assert one() == 1', + '', + 'def test_b():', + ' assert two() == 2', + ], + }), + 'git commit -qam main-grows-py', + ], + round: [ + 'git merge -q --no-edit --no-commit origin/main || true', + 'git rm -q -f --ignore-unmatch pkg/test_x.py', + 'git commit -qm keep-our-deletion', + AGENT_COMMIT, + ], + }, + // -- main ADDS a test file with no readable surface at all during the + // round, and the round discards it in the resolution. Main added a + // path the baseline did not have, so the deletion is the round's + // however little the file measures. + 'main-adds-zero-surface-round-drops': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + // No registrations and no assertions: its measured surface is + // indistinguishable from an absent file, so only the file's + // EXISTENCE tells the baseline it arrived. + ...fixtureWrite({ + 'pkg/e.test.ts': [WT_IMPORT, '// fixtures live here for now'], + }), + 'git add pkg/e.test.ts && git commit -qm main-adds-empty', + ], + round: [ + 'echo f2 > f2.txt && git add f2.txt && git commit -qm feature-moves', + 'git merge -q --no-edit --no-commit origin/main || true', + 'git rm -q -f --ignore-unmatch pkg/e.test.ts', + 'git commit -qm drop-mains-file', + AGENT_COMMIT, + ], + }, + // -- a `.test.ts` the instrument reads NOTHING from, grown by main + // while the round keeps an earlier round's deletion. An empty + // measured surface is the same reading for a file that declares + // nothing as for a shape the parser does not know, so the byte + // fallback has to engage on the extension the parser DOES know too. + 'preround-absent-main-grows-zero-surface-ts': { + onMain: { + 'pkg/a.test.ts': WT_BASE, + 'pkg/z.test.ts': [WT_IMPORT, '// type-level fixtures live here'], + }, + files: {}, + seed: ['git rm -q pkg/z.test.ts'], + mainMoves: [ + ...fixtureWrite({ + 'pkg/z.test.ts': [ + WT_IMPORT, + '// type-level fixtures live here', + '// and a second note', + ], + }), + 'git commit -qam main-grows-z', + ], + round: [ + 'git merge -q --no-edit --no-commit origin/main || true', + 'git rm -q -f --ignore-unmatch pkg/z.test.ts', + 'git commit -qm keep-our-deletion', + AGENT_COMMIT, + ], + }, + // -- main moves only DISABLED registrations on a file an earlier round + // deleted. A skipped test is no coverage and reaches no signal, so + // it is not a contribution and the old deletion is not re-charged. + 'preround-absent-main-adds-skip': { + onMain: { 'pkg/a.test.ts': WT_BASE, 'pkg/x.test.ts': WT_BASE }, + files: {}, + seed: ['git rm -q pkg/x.test.ts'], + mainMoves: [ + ...fixtureWrite({ + 'pkg/x.test.ts': [...WT_BASE, ...testB('it.skip')], + }), + 'git commit -qam main-adds-skip', + ], + round: [ + 'git merge -q --no-edit --no-commit origin/main || true', + 'git rm -q -f --ignore-unmatch pkg/x.test.ts', + 'git commit -qm keep-our-deletion', + AGENT_COMMIT, + ], + }, + // -- a criss-cross whose two candidate bases disagree about whether the + // file EXISTS, not about its content. Refused like any other + // disagreement: which base git picks would decide the verdict. + 'crisscross-bases-disagree-presence': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: ['git rm -q pkg/a.test.ts', 'git commit -qm m1-deletes'], + round: [ + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE }), + 'git add -A pkg && git commit -qm keep-ours', + 'git checkout -q main', + 'git merge -q --no-edit --no-commit origin/feature || true', + 'git rm -q -f --ignore-unmatch pkg/a.test.ts', + 'git commit -qm m2', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_PLUS_THREE }), + 'git add -A pkg && git commit -qm main-restores', + 'git push -q origin main', + 'git checkout -q feature', + 'git merge -q --no-edit --no-commit origin/main || true', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_PLUS_THREE }), + 'git add -A pkg && git commit -qm take-main', + AGENT_COMMIT, + ], + }, + // -- a file that is NOTHING BUT disabled registrations, rewritten + // wholesale by main while the round keeps an earlier deletion. The + // surface comparison cannot see disabled registrations, so such a + // file has to reach the bytes or no rewrite of it would ever read + // as main contributing. + 'preround-absent-main-rewrites-skips-only': { + onMain: { + 'pkg/a.test.ts': WT_BASE, + 'pkg/s.test.ts': [ + WT_IMPORT, + "describe.skip('S', () => {", + " it('a', () => {", + ' expect(one()).toBe(1);', + ' });', + '});', + ], + }, + files: {}, + seed: ['git rm -q pkg/s.test.ts'], + mainMoves: [ + ...fixtureWrite({ + 'pkg/s.test.ts': [ + WT_IMPORT, + "describe.skip('S renamed', () => {", + " it('a', () => {", + ' expect(one()).toBe(1);', + ' });', + " it('c', () => {", + ' expect(three()).toBe(3);', + ' });', + '});', + ], + }), + 'git commit -qam main-rewrites-s', + ], + round: [ + 'git merge -q --no-edit --no-commit origin/main || true', + 'git rm -q -f --ignore-unmatch pkg/s.test.ts', + 'git commit -qm keep-our-deletion', + AGENT_COMMIT, + ], + }, + // -- main moves ONLY the assertion total: it plants an early return + // ahead of the body, so the registrations and the declared total + // stand while the live count falls. + 'preround-absent-main-guards': { + onMain: { 'pkg/a.test.ts': WT_BASE, 'pkg/g.test.ts': WT_BASE }, + files: {}, + seed: ['git rm -q pkg/g.test.ts'], + mainMoves: [ + ...fixtureWrite({ + 'pkg/g.test.ts': testA( + "it('a', () => {", + ' return;', + ' expect(one()).toBe(1);', + ' expect(two()).toBe(2);', + ), + }), + 'git commit -qam main-guards-g', + ], + round: [ + 'git merge -q --no-edit --no-commit origin/main || true', + 'git rm -q -f --ignore-unmatch pkg/g.test.ts', + 'git commit -qm keep-our-deletion', + AGENT_COMMIT, + ], + }, + // -- main moves ONLY the DECLARED total: it deletes an assertion that + // already sat behind a guard, so the live count cannot fall further. + 'preround-absent-main-drops-behind-guard': { + onMain: { + 'pkg/a.test.ts': WT_BASE, + 'pkg/d.test.ts': testA( + "it('a', () => {", + ' return;', + ' expect(one()).toBe(1);', + ' expect(two()).toBe(2);', + ), + }, + files: {}, + seed: ['git rm -q pkg/d.test.ts'], + mainMoves: [ + ...fixtureWrite({ + 'pkg/d.test.ts': testA( + "it('a', () => {", + ' return;', + ' expect(one()).toBe(1);', + ), + }), + 'git commit -qam main-drops-guarded', + ], + round: [ + 'git merge -q --no-edit --no-commit origin/main || true', + 'git rm -q -f --ignore-unmatch pkg/d.test.ts', + 'git commit -qm keep-our-deletion', + AGENT_COMMIT, + ], + }, + // -- main moves ONLY the enabled-title multiset: same counts, a + // different test name. + 'preround-absent-main-renames-test': { + onMain: { 'pkg/a.test.ts': WT_BASE, 'pkg/r.test.ts': WT_BASE }, + files: {}, + seed: ['git rm -q pkg/r.test.ts'], + mainMoves: [ + ...fixtureWrite({ + 'pkg/r.test.ts': WT_BASE.map((l) => + l.replace("it('a'", "it('a renamed'"), + ), + }), + 'git commit -qam main-renames-test', + ], + round: [ + 'git merge -q --no-edit --no-commit origin/main || true', + 'git rm -q -f --ignore-unmatch pkg/r.test.ts', + 'git commit -qm keep-our-deletion', + AGENT_COMMIT, + ], + }, + 'main-directory-swap': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + 'git rm -q pkg/a.test.ts', + 'mkdir -p pkg/a.test.ts', + "printf 'moved\\n' > pkg/a.test.ts/inner.txt", + 'git add -A pkg && git commit -qm main-swaps-for-a-directory', + ], + round: ['git merge -q --no-edit origin/main', AGENT_COMMIT], + }, + 'merge-main-empties': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [': > pkg/a.test.ts', 'git commit -qam main-empties'], + round: ['git merge -q --no-edit origin/main', AGENT_COMMIT], + }, + 'rename-weaken': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: [ + 'git mv pkg/a.test.ts pkg/b.test.ts', + ...fixtureWrite({ 'pkg/b.test.ts': WT_BASE_MINUS_TWO }), + 'git add -A pkg', + AGENT_COMMIT, + ], + }, + 'rename-only': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: ['git mv pkg/a.test.ts pkg/b.test.ts', AGENT_COMMIT], + }, + 'main-renames': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + 'git mv pkg/a.test.ts pkg/b.test.ts', + 'git commit -qm main-renames', + ], + round: ['git merge -q --no-edit origin/main', AGENT_COMMIT], + }, + 'skip-with-standin': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': [ + ...testA("it.skip('a', () => {"), + "it('a', () => {});", + ], + }), + }, + // The same shape with the callback handed by NAME: the instrument + // resolves the module-scope binding, so the skipped body's assertions + // still measure as removed. + 'skip-with-standin-named': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': [ + WT_IMPORT, + 'function body() {', + ' expect(one()).toBe(1);', + ' expect(two()).toBe(2);', + '}', + "it.skip('a', body);", + "it('a', () => {});", + ], + }), + }, + 'delete-behind-guard': { + files: { + 'pkg/a.test.ts': [ + WT_IMPORT, + "it('a', () => {", + ' if (!process.env.QWEN_RUN_ADDS) {', + ' return;', + ' }', + ' expect(one()).toBe(1);', + ' expect(two()).toBe(2);', + '});', + ], + }, + round: roundWrites({ + 'pkg/a.test.ts': [ + WT_IMPORT, + "it('a', () => {", + ' if (!process.env.QWEN_RUN_ADDS) {', + ' return;', + ' }', + '});', + ], + }), + }, + 'suite-guard': { + files: { + 'pkg/a.test.ts': [ + WT_IMPORT, + "describe('d', () => {", + " it('a', () => { expect(one()).toBe(1); });", + " it('b', () => { expect(two()).toBe(2); });", + '});', + ], + }, + round: roundWrites({ + 'pkg/a.test.ts': [ + WT_IMPORT, + "describe('d', () => {", + " it('smoke', () => { expect(one()).toBe(1); });", + ' return;', + " it('a', () => { expect(one()).toBe(1); });", + " it('b', () => { expect(two()).toBe(2); });", + '});', + ], + }), + }, + 'guard-plus-todo': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': [ + WT_IMPORT, + "it('a', () => {", + ' return;', + ' expect(one()).toBe(1);', + ' expect(two()).toBe(2);', + '});', + "it.todo('a later behaviour');", + ], + }), + }, + 'adds-guarded-test': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': [ + ...WT_BASE, + "it('posix only', () => {", + " if (process.platform === 'win32') {", + ' return;', + ' }', + ' expect(four()).toBe(4);', + '});', + ], + }), + }, + 'main-renames-round-keeps-both': { + onMain: { 'pkg/a.test.ts': WT_BASE_PLUS_THREE }, + files: {}, + mainMoves: [ + 'git mv pkg/a.test.ts pkg/m.test.ts', + ...fixtureWrite({ 'pkg/m.test.ts': WT_BASE }), + 'git add -A pkg && git commit -qm main-renames-and-drops', + ], + round: [ + 'git merge -q --no-commit --no-edit origin/main || true', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_PLUS_THREE }), + 'git add -A pkg && git commit -qm keep-both-paths', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + 'git add -A pkg && git commit -qm drop-our-own', + AGENT_COMMIT, + ], + }, + 'delete-then-merge-restores': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + 'git commit -qam main-weakens', + ], + round: [ + 'git rm -q pkg/a.test.ts', + 'git commit -qm delete', + 'git merge -q --no-edit origin/main || true', + 'git add -A pkg && git commit -qm keep-mains-version', + AGENT_COMMIT, + ], + }, + 'delete-then-merge-adds': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_PLUS_THREE }), + 'git commit -qam main-adds', + ], + round: [ + 'git rm -q pkg/a.test.ts', + 'git commit -qm delete', + 'git merge -q --no-edit origin/main || true', + 'git add -A pkg && git commit -qm keep-mains-version', + AGENT_COMMIT, + ], + }, + 'delete-then-rename-onto': { + files: { + 'pkg/a.test.ts': WT_BASE, + 'pkg/b.test.ts': WT_BASE_PLUS_THREE, + }, + round: [ + 'git rm -q pkg/b.test.ts', + 'git commit -qm drop-b', + 'git mv pkg/a.test.ts pkg/b.test.ts', + 'git commit -qm move-a-onto-b', + AGENT_COMMIT, + ], + }, + 'unskip-with-guard': { + files: { + 'pkg/a.test.ts': [ + WT_IMPORT, + "it.skip('a', () => {", + ' if (!process.env.QWEN_SERVER) {', + ' return;', + ' }', + ' expect(one()).toBe(1);', + '});', + ], + }, + round: roundWrites({ + 'pkg/a.test.ts': [ + WT_IMPORT, + "it('a', () => {", + ' if (!process.env.QWEN_SERVER) {', + ' return;', + ' }', + ' expect(one()).toBe(1);', + '});', + ], + }), + }, + 'own-rename-then-delete': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: [ + 'git mv pkg/a.test.ts pkg/b.test.ts', + 'git commit -qm rename', + 'git rm -q pkg/b.test.ts', + 'git commit -qm delete-the-destination', + AGENT_COMMIT, + ], + }, + 'hook-skip': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': [ + "import { it, expect, beforeEach } from 'vitest';", + 'beforeEach((ctx) => { ctx.skip(); });', + ...WT_BASE.slice(1), + ], + }), + }, + 'catch-skip': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': testA( + "it('a', (ctx) => {", + ' try { connectDocker(); } catch { ctx.skip(); }', + ' expect(one()).toBe(1);', + ' expect(two()).toBe(2);', + ), + }), + }, + 'orphan-merge': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: [ + 'git checkout -q --orphan tmp', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + 'git add -A && git commit -qm orphan', + 'git merge -q --allow-unrelated-histories --no-edit feature || true', + 'git checkout --ours pkg/a.test.ts && git add pkg/a.test.ts', + 'git commit -qm graft', + 'git branch -f feature HEAD && git checkout -q feature', + ], + }, + 'octopus-freight': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + 'git commit -qam main-weakens', + ], + round: [ + 'git checkout -qb side', + 'echo s > s.txt && git add s.txt && git commit -qm side', + 'git checkout -q feature', + // The branch moves too, so the octopus cannot fast-forward onto + // `side` and main's side lands as the THIRD parent. + 'echo f2 > f2.txt && git add f2.txt && git commit -qm feature-moves', + 'git merge -q --no-edit side origin/main', + AGENT_COMMIT, + ], + }, + // -- declared non-goal: reachability is the runner's business + 'dead-code': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: roundWrites({ + 'pkg/a.test.ts': testA( + "it('a', () => {", + ' if (false) {', + ' expect(one()).toBe(1);', + ' expect(two()).toBe(2);', + ' }', + ), + }), + }, + // -- attribution across main's own history + 'merge-freight': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + 'git commit -qam main-weakens', + ], + round: ['git merge -q --no-edit origin/main', AGENT_COMMIT], + }, + // -- main EMPTIES the file (keeps the path, removes every line) and + // the round touches nothing. Main's own delta is the whole file + // going empty, and the merge landed it, so the round answers for + // nothing. + 'main-empties': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + // Truly zero bytes: `printf '%s\n'` with no operands still writes a + // newline, and a one-byte result would never reach the emptiness + // question this fixture exists to ask. + mainMoves: [": > 'pkg/a.test.ts'", 'git commit -qam main-empties'], + // The branch moves FIRST, so the merge cannot fast-forward: this has + // to be a real merge commit, or main's side is read straight off the + // commit and the merge attribution is never exercised. + round: [ + 'echo f2 > f2.txt && git add f2.txt && git commit -qm feature-moves', + 'git merge -q --no-edit origin/main', + AGENT_COMMIT, + ], + }, + // -- a file carrying a NUL byte, which git will not merge as text. + // Attribution never asks it to: main's delta is read on main's own + // side, so the measurement is the same as for any other content. + // Here main only appends a comment while the round deletes an + // assertion. + 'binary-merge': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + onMainSeed: ["printf '// \\000\\n' >> 'pkg/a.test.ts'"], + files: {}, + mainMoves: [ + "printf '// main touched this\\n' >> 'pkg/a.test.ts'", + 'git commit -qam main-touches', + ], + round: [ + 'echo f2 > f2.txt && git add f2.txt && git commit -qm feature-moves', + 'git merge -q --no-edit origin/main', + // Rewritten, not filtered: a line filter reads this file as binary + // and would empty it instead of dropping one assertion. The NUL + // only has to exist on the three sides of the MERGE. + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + AGENT_COMMIT, + ], + }, + // -- the same refusal, with MAIN as the side that moved: main drops an + // assertion, the round only merges. Substituting the branch's side + // unconditionally would read main's weakening as the round's and + // reject an honest round, so the fallback has to follow git's own + // trivial-merge rule instead of a fixed side. + 'binary-merge-freight': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + onMainSeed: ["printf '// \\000\\n' >> 'pkg/a.test.ts'"], + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + "printf '// \\000\\n' >> 'pkg/a.test.ts'", + 'git commit -qam main-weakens', + ], + round: [ + 'echo f2 > f2.txt && git add f2.txt && git commit -qm feature-moves', + 'git merge -q --no-edit origin/main', + AGENT_COMMIT, + ], + }, + // -- the refusal with BOTH sides moved: the round weakens the file and + // main edits it. Neither side is the base, so the result resolves + // for the branch exactly as `--ours` would have, and the round is + // charged for its OWN removal — no more, no less. Taking main's + // side here would charge the round for main's edit as well. + 'binary-merge-conflict': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + onMainSeed: ["printf '// \\000\\n' >> 'pkg/a.test.ts'"], + files: {}, + mainMoves: [ + "printf '// main touched this\\n' >> 'pkg/a.test.ts'", + 'git commit -qam main-touches', + ], + round: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + "printf '// \\000\\n' >> 'pkg/a.test.ts'", + 'git commit -qam round-weakens', + 'git merge -q --no-edit -X ours origin/main', + AGENT_COMMIT, + ], + }, + // -- the round merges main with `-s ours`, discarding main's side for + // this file, and removes an assertion of its own in the same round. + // Main contributed nothing the tip took, so nothing may be + // subtracted: crediting the round with main's discarded delta would + // absorb its own removal exactly. + 'strategy-ours-hole': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + 'git commit -qam main-weakens', + ], + round: [ + 'echo f2 > f2.txt && git add f2.txt && git commit -qm feature-moves', + 'git merge -q --no-edit -s ours origin/main', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + AGENT_COMMIT, + ], + }, + // -- the same shape with unmergeable content: the merge landed + // nothing of main's, so main still contributed nothing. + 'strategy-ours-hole-binary': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + onMainSeed: ["printf '// \\000\\n' >> 'pkg/a.test.ts'"], + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + "printf '// \\000\\n' >> 'pkg/a.test.ts'", + 'git commit -qam main-weakens', + ], + round: [ + 'echo f2 > f2.txt && git add f2.txt && git commit -qm feature-moves', + 'git merge -q --no-edit -s ours origin/main', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + "printf '// \\000\\n' >> 'pkg/a.test.ts'", + AGENT_COMMIT, + ], + }, + // -- the same discard, but the merge commit is NOT byte-identical to + // the branch's side: one comment line was added while resolving. A + // byte-equality bound misses this; the clamp does not, because the + // landed delta is zero either way. + 'ours-hole-plus-comment': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + 'git commit -qam main-weakens', + ], + round: [ + 'echo f2 > f2.txt && git add f2.txt && git commit -qm feature-moves', + 'git merge -q --no-edit --no-commit -s ours origin/main || true', + "printf '// merge note\\n' >> 'pkg/a.test.ts'", + 'git add pkg/a.test.ts && git commit -qm merge-with-note', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + "printf '// merge note\\n' >> 'pkg/a.test.ts'", + AGENT_COMMIT, + ], + }, + // -- binary, BOTH sides moved, and the resolution took MAIN's version. + // Main's own delta is measured on main's side and the merge landed + // it whole, so the round -- whose own edit the resolution discarded + // -- is charged for nothing. + 'binary-resolution-takes-main': { + onMain: { 'pkg/a.test.ts': WT_BASE_PLUS_THREE }, + onMainSeed: ["printf '// \\000\\n' >> 'pkg/a.test.ts'"], + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + "printf '// \\000\\n' >> 'pkg/a.test.ts'", + 'git commit -qam main-weakens', + ], + round: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE }), + "printf '// \\000\\n' >> 'pkg/a.test.ts'", + 'git commit -qam branch-edits', + 'git merge -q --no-edit -X theirs origin/main', + AGENT_COMMIT, + ], + }, + 'merge-delete-freight': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: ['git rm -q pkg/a.test.ts', 'git commit -qm main-deletes'], + round: ['git merge -q --no-edit origin/main', AGENT_COMMIT], + }, + 'merge-conflict-drop': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ + 'pkg/a.test.ts': WT_BASE.map((l) => l.replace('toBe(1)', 'toBe(10)')), + }), + 'git commit -qam main-edits', + ], + round: [ + ...fixtureWrite({ + 'pkg/a.test.ts': WT_BASE.map((l) => l.replace('toBe(1)', 'toBe(11)')), + }), + 'git commit -qam branch-edits', + 'git merge -q --no-edit origin/main || true', + ...fixtureWrite({ + 'pkg/a.test.ts': WT_BASE.filter((l) => !l.includes('one()')), + }), + 'git add pkg/a.test.ts && git commit -qm resolution', + AGENT_COMMIT, + ], + }, + 'merge-restore': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ + 'pkg/a.test.ts': WT_BASE.map((l) => l.replace('toBe(1)', 'toBe(10)')), + }), + 'git commit -qam main-edits', + ], + round: [ + ...fixtureWrite({ + 'pkg/a.test.ts': WT_BASE.filter((l) => !l.includes('one()')), + }), + 'git commit -qam drop', + 'git merge -q --no-edit origin/main || true', + 'git checkout --theirs pkg/a.test.ts', + 'git add pkg/a.test.ts && git commit -qm take-main', + AGENT_COMMIT, + ], + }, + 'premerge-weaken-postmerge-decoy': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ + 'pkg/a.test.ts': [...WT_BASE, '// main touched this file'], + }), + 'git commit -qam main-touches', + ], + round: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + 'git commit -qam weaken', + 'git merge -q --no-edit origin/main', + ...fixtureWrite({ + 'pkg/a.test.ts': [ + WT_IMPORT, + "it('a', () => {", + ' expect(one()).toBe(1);', + " const decoy = 'expect(two()).toBe(2)';", + '});', + '// main touched this file', + ], + }), + AGENT_COMMIT, + ], + }, + 'ff-ride-freight': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + 'git commit -qam main-weakens', + ], + round: [ + 'git reset -q --hard origin/main', + ...fixtureWrite({ + 'pkg/a.test.ts': WT_BASE_MINUS_TWO.map((l) => + l.replace('one()', 'uno()'), + ), + }), + AGENT_COMMIT, + ], + }, + 'ff-ride-revert': { + onMain: { 'pkg/a.test.ts': WT_BASE }, + files: {}, + mainMoves: [ + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_PLUS_THREE }), + 'git commit -qam main-adds', + ], + round: [ + 'git reset -q --hard origin/main', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE }), + AGENT_COMMIT, + ], + }, + 'merge-introduced-discard': { + files: { 'pkg/a.test.ts': WT_BASE }, + mainMoves: [ + ...fixtureWrite({ 'pkg/b.test.ts': WT_BASE }), + 'git add pkg/b.test.ts && git commit -qm main-adds-b', + ], + round: [ + 'git merge -q --no-commit --no-edit origin/main || true', + 'git rm -qf pkg/b.test.ts', + 'git commit -qm resolution', + AGENT_COMMIT, + ], + }, + 'side-branch-merge': { + files: { 'pkg/a.test.ts': WT_BASE }, + round: [ + 'git checkout -qb side', + ...fixtureWrite({ 'pkg/a.test.ts': WT_BASE_MINUS_TWO }), + 'git commit -qam side-weakens', + 'git checkout -q feature', + 'git merge -q --no-ff --no-edit side', + AGENT_COMMIT, + ], + }, + }; + + const rejectsWeakening = (weaken, signal, opts = {}) => { + const r = runGate({ weaken, ...opts }); + expect(r.status).toBe(1); + expect(r.outputs).toContain('outcome=failed'); + // Retryable: the same-run repair pass can restore the coverage or + // record the evidence, so this must not burn the whole round. + expect(r.outputs).toContain('retryable=true'); + expect(r.outputs).not.toContain('preexisting=true'); + expect(r.rejection).toContain(signal); + // The remedy has to name the artifact, or the repair pass cannot act. + expect(r.rejection).toContain('test-weakening.json'); + // Rejected BEFORE the expensive deterministic checks: a round that + // cannot be accepted must not spend twenty minutes proving it. + expect(r.stdout).not.toContain('Re-running deterministic checks'); + return r; + }; + const acceptsWithoutCharge = (weaken, opts = {}) => { + const r = runGate({ weaken, ...opts }); + expect(r.status).toBe(0); + expect(r.outputs).toContain('outcome=fixed'); + expect(r.rejection).toBe(''); + expect(r.advisories).not.toContain('weakened or removed pre-existing'); + expect(r.stdout).not.toContain('UNAVAILABLE'); + return r; + }; + + it('rejects a round that removes assertions from a pre-existing test', () => { + const r = rejectsWeakening('assert', 'net 1 assertion(s) removed'); + expect(r.rejection).toContain('pkg/a.test.ts'); + }); + + it('rejects a skip marker added to a pre-existing test', () => { + // Assertion counts are identical on both sides — only the + // registration's enabled state moves. + rejectsWeakening('skip', 'pre-existing test registration(s) disabled'); + }); + + it('rejects a deleted pre-existing test', () => { + rejectsWeakening('delete', 'test file deleted'); + // A main-derived merge landing AFTER the round's own deletion does not + // launder it: the merge moved nothing for that path on the side that + // survived, so the baseline the charge rests on is still the pre-round + // ref's. + const deletedThenMerged = rejectsWeakening( + 'delete-then-merge', + 'test file deleted', + ); + expect(deletedThenMerged.rejection).toContain('pkg/a.test.ts'); + expect(deletedThenMerged.rejection).not.toContain('pkg/b.test.ts'); + }); + + it('gates every status-0 weakening flow on the host probe', () => { + // Accepted rounds run the REAL script past the bite section's + // unconditional `mapfile`: on a bash without it (macOS ships 3.2) the + // spawn dies there before the semantics under test can execute, so + // every test asserting an ACCEPTED round carries the host gate. The + // set is DERIVED from this file, never hand-listed: a new + // accepted-round test added without the gate fails here rather than on + // the macOS lane, which is where the hand-list kept letting one slip. + const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); + const chunks = self + .split(/\n {2}it(?=[.(])/) + // ...except this test, whose own body quotes the signals below. + .filter((chunk) => !chunk.startsWith("('gates every status-0")); + const accepting = chunks.filter( + (chunk) => + /acceptsWithoutCharge\('[a-z]/.test(chunk) || + (chunk.includes('weaken:') && + (chunk.includes('.status).toBe(0)') || + chunk.includes(".rejection).toBe('')"))), + ); + expect(accepting.length).toBeGreaterThan(8); + expect( + accepting + .filter((chunk) => !chunk.startsWith('.skipIf(!hasBashMapfile)(')) + .map((chunk) => chunk.slice(0, 90)), + ).toEqual([]); + }); + + it.skipIf(!hasBashMapfile)( + 'charges nothing to honest edits of a pre-existing test', + () => { + // Strictly more coverage. + acceptsWithoutCharge('add'); + // The repo's environment-guard idioms are conditions, not skips. + acceptsWithoutCharge('skipif-guard'); + acceptsWithoutCharge('ctx-skip-conditional'); + // A skip in a catch clause is the setup-failure guard, not a skip. + acceptsWithoutCharge('catch-skip'); + // Un-skipping a test that already carried an early return is + // strictly more coverage, not a guard the round added. + acceptsWithoutCharge('unskip-with-guard'); + // A brand-new test carrying its own platform guard silences nothing + // that existed. + acceptsWithoutCharge('adds-guarded-test'); + // A CONDITIONAL early return planted ahead of existing assertions + // is the environment-guard idiom (R27-21): sheltered assertions + // stay measured, so the round reads as no movement — the runner, + // not this gate, judges the condition. + acceptsWithoutCharge('early-return-conditional'); + // A brand-new todo registration is the round's own. + acceptsWithoutCharge('todo-new'); + // A retitled test keeps its assertions and its enabled state. + acceptsWithoutCharge('rename'); + // An assertion moved WITHIN a file nets zero, by contract. + acceptsWithoutCharge('move-within'); + // Prettier reflow and a JSX-carrying assertion swap: the parser sees + // the same declared surface a text census mis-measured. + acceptsWithoutCharge('reflow'); + acceptsWithoutCharge('jsx-swap'); + // Delete then recreate STRONGER: the tip is the authority, not the + // commit sequence. + acceptsWithoutCharge('delete-readd-stronger'); + }, + 120000, + ); + + it('charges the weakenings that hide from a text census', () => { + // A commented-out assertion, a string/regex/template decoy, a JSX + // text decoy: the parser counts executable statements, not tokens. + rejectsWeakening('comment-out', 'net 2 assertion(s) removed'); + rejectsWeakening('string-decoy', 'net 1 assertion(s) removed'); + rejectsWeakening('jsx-decoy', 'net 1 assertion(s) removed'); + // A matcher-less expect() pins nothing. + rejectsWeakening('matcher-drop', 'net 1 assertion(s) removed'); + // Every spelling of a disabled registration the parser can decode. + for (const shape of [ + 'computed-skip', + 'options-skip', + 'skipif-true', + 'ctx-skip', + ]) { + rejectsWeakening(shape, 'pre-existing test registration(s) disabled'); + } + // The early-return spelling of a skip: the assertions it shelters stop + // being declared surface, so it measures as their removal. + rejectsWeakening('early-return', 'net 2 assertion(s) removed'); + // ...and a throwaway registration appended beside it buys nothing. + rejectsWeakening('guard-plus-todo', 'net 2 assertion(s) removed'); + // Assertions the baseline already carried behind a guard are not free + // to delete: the total that ignores guards falls even when the other + // one cannot. + rejectsWeakening('delete-behind-guard', 'net 2 assertion(s) removed'); + // An early return in a DESCRIBE body stops the runner collecting what + // follows it, exactly as a `skip()` there would. + rejectsWeakening( + 'suite-guard', + 'pre-existing test registration(s) disabled', + ); + // A hook that skips every test the file registers. + rejectsWeakening('hook-skip', 'pre-existing test registration(s) disabled'); + }, 120000); + + it('charges registration-level weakening by title, not by net marker count', () => { + // Un-skipping one test never licenses silencing another: the net + // marker count is zero here and the gate still charges `a`. + rejectsWeakening('skip-swap', 'pre-existing test registration(s) disabled'); + // Retitle-and-skip: no baseline title is disabled, but the silenced + // body's assertions no longer execute. + rejectsWeakening('rename-skip', 'net 2 assertion(s) removed'); + // Skip the body and plant an empty same-titled stand-in: the title + // count balances, the executed assertions do not. + rejectsWeakening('skip-with-standin', 'net 2 assertion(s) removed'); + // ...and the same silencing with the callback handed by name: the + // binding resolves, so the skipped body still measures as removed. + rejectsWeakening('skip-with-standin-named', 'net 2 assertion(s) removed'); + }); + + it('charges a weakening by the tip, whichever commit sequence produced it', () => { + // Delete then recreate WEAKER. + rejectsWeakening('delete-readd-weaker', 'net 1 assertion(s) removed'); + // A pre-existing test replaced by a symlink is a typechange: the + // deletion of its surface, whatever the link's target text parses as. + rejectsWeakening('symlink', 'test file deleted'); + rejectsWeakening('symlink-python', 'test file deleted'); + // A DIRECTORY that took the file's name is not the file: the tip must + // hold a blob at that path for the file to count as still there. + rejectsWeakening('directory-swap', 'test file deleted'); + // A side branch merged into the round is the round's own authorship. + rejectsWeakening('side-branch-merge', 'net 1 assertion(s) removed'); + // ...and so is a merge whose tree is its first parent's: nothing moved + // per commit, yet the tip carries main's older, weaker copy. + const ours = rejectsWeakening( + 'ours-merge-drops-branch-work', + 'net 1 assertion(s) removed', + ); + expect(ours.rejection).toContain('pkg/a.test.ts'); + // A rename whose destination the round then deletes, one that lands ON + // a pre-existing test, and one that guts what it landed on: under the + // one-name rule each charges the old path, and the destination is + // measured against its OWN pre-round self when it had one. + const gone = rejectsWeakening( + 'own-rename-then-delete', + 'test file deleted', + ); + expect(gone.rejection).toContain('pkg/a.test.ts'); + const onto = rejectsWeakening( + 'delete-then-rename-onto', + 'test file deleted', + ); + expect(onto.rejection).toContain('pkg/a.test.ts'); + expect(onto.rejection).toContain('pkg/b.test.ts'); + expect(onto.rejection).toContain('net 1 assertion(s) removed'); + }); + + it('fails closed for the one file it could not measure, not the round', () => { + // A round-authored input can exhaust the parser. That file alone is + // charged (one ack entry answers it) while the rest of the round stays + // measured — waiving every signal because one file was unreadable is + // exactly the fail-open the header forbids. + const r = runGate({ + weaken: 'unparseable-addition', + counterFailsFor: 'pkg/generated.test.ts', + }); + expect(r.status).toBe(1); + expect(r.outputs).toContain('retryable=true'); + expect(r.rejection).toContain('pkg/a.test.ts'); + expect(r.rejection).toContain('net 1 assertion(s) removed'); + expect(r.stdout).not.toContain('UNAVAILABLE'); + // The file the round added has no coverage to weaken, so it is not + // charged for being unreadable. + expect(r.rejection).not.toContain('pkg/generated.test.ts'); + // ...while an unreadable file the BASELINE holds is charged on its + // own — the pre-round ref's, and one main landed during the round, + // which is the same baseline the measured arm reports. + const own = runGate({ + weaken: 'assert', + counterFailsFor: 'pkg/a.test.ts', + }); + expect(own.status).toBe(1); + expect(own.rejection).toContain('test surface could not be measured'); + const landed = runGate({ + weaken: 'main-landed-unmeasurable', + counterFailsFor: 'pkg/new.test.ts', + }); + expect(landed.status).toBe(1); + expect(landed.rejection).toContain('pkg/new.test.ts'); + expect(landed.rejection).toContain('test surface could not be measured'); + // A counter that EXITS 0 while printing something that is not a + // verdict takes the same fail-closed path. Read as a verdict, its + // absent `baselinePresent` would say "not the round's to weaken" and + // uncharge the file silently — while the round still reports itself + // measured, which is the fail-open this whole arm exists to prevent. + const mute = runGate({ + weaken: 'assert', + counterGarbageFor: 'pkg/a.test.ts', + }); + expect(mute.status).toBe(1); + expect(mute.rejection).toContain('pkg/a.test.ts'); + expect(mute.rejection).toContain('test surface could not be measured'); + expect(mute.stdout).not.toContain('UNAVAILABLE'); + }); + + it("takes main's side only from commits main itself has been", () => { + // A commit reachable from origin/main as someone's merged feature tip + // was never main: merging it with `-s ours` must not credit the round + // with a smaller copy of a file main never carried. + const r = rejectsWeakening( + 'merge-historical-main-commit', + 'net 2 assertion(s) removed', + ); + expect(r.rejection).toContain('pkg/a.test.ts'); + }); + + it('judges non-JS test files by the deletion arm', () => { + rejectsWeakening('python-delete', 'test file deleted'); + rejectsWeakening('rust-delete', 'test file deleted'); + rejectsWeakening('spec-delete', 'test file deleted'); + }); + + it('charges every weakened file and acknowledges per path', () => { + const r = rejectsWeakening('two-files', 'net 1 assertion(s) removed'); + expect(r.rejection).toContain('pkg/a.test.ts'); + expect(r.rejection).toContain('pkg/b.test.ts'); + const half = runGate({ + weaken: 'two-files', + workdirFiles: { + 'test-weakening.json': JSON.stringify([ + { path: 'pkg/a.test.ts', reason: WEAKEN_REASON }, + ]), + }, + }); + expect(half.status).toBe(1); + expect(half.rejection).toContain('pkg/b.test.ts'); + expect(half.rejection).not.toContain('pkg/a.test.ts'); + }); + + it('uses no bash-4 construct ahead of the bite section (the bash-3.2 lane)', () => { + // The gate section runs before the bite section's mapfile — the first + // bash-4 boundary the host probe gates — so it must stay bash-3.2 + // clean or the macOS lane dies before any verdict. + const start = reviewVerificationRunner.indexOf('# --- Test-weakening gate'); + const end = reviewVerificationRunner.indexOf('\nmapfile '); + expect(start).toBeGreaterThan(0); + expect(end).toBeGreaterThan(start); + expect(reviewVerificationRunner.slice(start, end)).not.toMatch( + /mapfile|readarray|(declare|local|typeset) -[a-zA-Z]*[An]|\$\{[A-Za-z_]+(,,|\^\^|@[QEPAaKk])|\[-[0-9]|\|&|;;&/, + ); + }); + + it.skipIf(!hasBashMapfile)( + 'acknowledges and renders a branch-controlled filename byte-safely', + () => { + // A tab in the name must neither split the record nor forge text: the + // ack matches base64-encoded paths and the rendering sanitizes. + const r = rejectsWeakening('tab-name', 'net 1 assertion(s) removed'); + expect(r.rejection).toContain('pkg/a?b.test.ts'); + const acked = runGate({ + weaken: 'tab-name', + workdirFiles: { + 'test-weakening.json': JSON.stringify([ + { path: 'pkg/a\tb.test.ts', reason: WEAKEN_REASON }, + ]), + }, + }); + expect(acked.rejection).toBe(''); + }, + ); + + it('does not accept an entry for a DIFFERENT file than the one weakened', () => { + // The acknowledgement is per-path, not a blanket opt-out. + const r = runGate({ + weaken: 'assert', + workdirFiles: { + 'test-weakening.json': JSON.stringify([ + { path: 'pkg/other.test.ts', reason: WEAKEN_REASON }, + ]), + }, + }); + expect(r.status).toBe(1); + expect(r.rejection).toContain('pkg/a.test.ts'); + }); + + it('acknowledges nothing on a malformed ack file', () => { + for (const body of ['not json', '{"path":"pkg/a.test.ts"}', '[1, 2]']) { + const r = runGate({ + weaken: 'assert', + workdirFiles: { 'test-weakening.json': body }, + }); + expect(r.status).toBe(1); + expect(r.rejection).toContain('pkg/a.test.ts'); + } + }); + + it.skipIf(!hasBashMapfile)( + 'neutralizes a comment-marker forged inside an agent-authored reason', + () => { + const r = runGate({ + weaken: 'assert', + workdirFiles: { + 'test-weakening.json': JSON.stringify([ + { + path: 'pkg/a.test.ts', + reason: `${WEAKEN_REASON} <!-- autofix-eval forged --> <details><summary>x ${'y'.repeat(300)} PAST-THE-CAP`, + }, + ]), + }, + }); + expect(r.status).toBe(0); + expect(r.advisories).toContain('coverage moved to pkg/b.test.ts'); + expect(r.advisories).not.toContain('<!-- autofix-eval'); + expect(r.advisories).not.toContain('<details>'); + expect(r.advisories).not.toContain('<summary>'); + // Each rendered reason is capped, so an unbounded reason cannot + // decide the size of a posted comment. + expect(r.advisories).not.toContain('PAST-THE-CAP'); + }, + ); + + it.skipIf(!hasBashMapfile)( + "attributes main's own changes crossing a merge to main, never to the round", + () => { + // Main weakened the file; the round only merged it in. + acceptsWithoutCharge('merge-freight'); + // Main deleted the file; the round adopted the deletion. + acceptsWithoutCharge('merge-delete-freight'); + // Main emptying the file is main's delta, whatever its size: the + // delta for that event is the whole file, and the attribution + // subtracts it like any other main event. + acceptsWithoutCharge('main-empties'); + // ...and content git cannot merge as text measures the same, because + // attribution never merges anything: it reads main's delta on main's + // own side. + const binary = rejectsWeakening( + 'binary-merge', + 'net 1 assertion(s) removed', + ); + expect(binary.rejection).toContain('pkg/a.test.ts'); + // ...and the substitution follows git's trivial-merge rule rather + // than a fixed side: the SAME refusal with main as the side that + // moved is main's own weakening, and charging it would reject a + // round whose only act was `git merge origin/main`. + acceptsWithoutCharge('binary-merge-freight'); + // ...and with BOTH sides moved the branch's side stands, so the + // round is charged for its own removal and not for main's edit too. + const bothMoved = rejectsWeakening( + 'binary-merge-conflict', + 'net 1 assertion(s) removed', + ); + expect(bothMoved.rejection).toContain('pkg/a.test.ts'); + // Both sides moved and the resolution took MAIN's version, discarding + // the round's own edit. Main's delta is measured where main made it, + // so the round is charged for nothing -- the older model, which read + // main's contribution off a three-way splice, over-charged here. + acceptsWithoutCharge('binary-resolution-takes-main'); + // Main's own delta is measured where MAIN made it, never spliced + // with the round's edits: a tip byte-identical to main's file has + // weakened nothing, however the round got there. + acceptsWithoutCharge('merge-takes-main-after-own-unskip'); + // Credit is main's delta, not the merge's: a resolution that removed + // more than main did is charged for the difference... + rejectsWeakening( + 'resolution-removes-more-than-main', + 'net 1 assertion(s) removed', + ); + // ...and one that moved the OPPOSITE way from main credits nothing, + // so the round answers for exactly what it removed. + rejectsWeakening('main-removes-merge-adds', 'net 1 assertion(s) removed'); + // Main's ADDITIONS are never clamped by what the merge kept: a round + // that drops what main landed during the round removed coverage, in + // every spelling of "drops". + rejectsWeakening('merge-drops-mains-add', 'net 1 assertion(s) removed'); + rejectsWeakening( + 'delete-merge-drops-mains-test', + 'net 1 assertion(s) removed', + ); + rejectsWeakening( + 'delete-merge-skips-mains-test', + '1 pre-existing test registration(s) disabled', + ); + // Main's REMOVALS are credited only as far as they landed: crediting + // the model here would swallow the round's own removal. + rejectsWeakening( + 'merge-keeps-one-then-round-removes', + 'net 1 assertion(s) removed', + ); + rejectsWeakening( + 'merge-keeps-enabled-then-round-disables', + '1 pre-existing test registration(s) disabled', + ); + // A merge that DISCARDED main's side adopted nothing from it, so + // nothing is subtracted: the round's own removal stands charged. Both + // lanes, because the model is what the tip took, not what an + // main's own side produced. + for (const shape of [ + 'strategy-ours-hole', + 'strategy-ours-hole-binary', + // ...and the credit is clamped by the landed DELTA, not by byte + // equality with the branch's side: one comment line added while + // resolving must not restore the phantom. + 'ours-hole-plus-comment', + ]) { + const discarded = rejectsWeakening(shape, 'net 1 assertion(s) removed'); + expect(discarded.rejection).toContain('pkg/a.test.ts'); + } + // The round dropped an assertion, main edited it, and the merge + // resolution took main's side: the tip carries the assertion. + acceptsWithoutCharge('merge-restore'); + // A fast-forwarded main weakened the file; the round then renamed a + // symbol in it. + acceptsWithoutCharge('ff-ride-freight'); + }, + 120000, + ); + + it("charges the round's own authorship on either side of a merge", () => { + // A conflict resolution that drops an assertion. + rejectsWeakening('merge-conflict-drop', 'net 1 assertion(s) removed'); + // A weakening committed BEFORE the merge, with a post-merge decoy: the + // tip-based recount subtracts main's contribution and nothing else. + rejectsWeakening( + 'premerge-weaken-postmerge-decoy', + 'net 1 assertion(s) removed', + ); + // After riding main forward, the round restored the pre-round bytes, + // dropping the assertion main had just landed. + rejectsWeakening('ff-ride-revert', 'net 1 assertion(s) removed'); + // A merge resolution that discards a test main added during the round. + const r = rejectsWeakening('merge-introduced-discard', 'test file deleted'); + expect(r.rejection).toContain('pkg/b.test.ts'); + }, 120000); + + it('charges a deletion the round made even when a later merge of main resolved for it', () => { + // The round deleted the file, main edited it, and the merge kept the + // deletion: main moved nothing the round can hide behind. + rejectsWeakening('delete-then-merge-edit', 'test file deleted'); + }); + + it.skipIf(!hasBashMapfile)( + 'attributes deletions, empty files, renames and octopus merges correctly', + () => { + // A file the round created, carried across a merge of main that + // never held it, then deleted: its own. + acceptsWithoutCharge('own-file-merge-delete'); + // ...including when the merge commit TOUCHED that own file: main + // never held it, so no event may latch it into the baseline. + acceptsWithoutCharge('own-file-merge-edit-delete'); + // A file an EARLIER round removed is not put back in the baseline by + // main merely still holding it, in either spelling. + acceptsWithoutCharge('deleted-earlier-round-then-merge'); + acceptsWithoutCharge('renamed-earlier-round-then-merge'); + // Main's contribution is counted once across add / delete / re-add, + // not once per merge that sees it: the events CHAIN, so each is + // measured against main's side at the previous one. + rejectsWeakening( + 'main-adds-deletes-readds', + 'net 1 assertion(s) removed', + ); + // Main's deletion that the merge did NOT adopt contributes nothing: + // the round kept its own copy and answers for gutting it. + rejectsWeakening( + 'main-deletes-round-keeps-weakened', + 'net 1 assertion(s) removed', + ); + // ...while main GROWING a path the pre-round ref lacks does put it + // back in the baseline, whatever an earlier round did to it. + rejectsWeakening( + 'preround-absent-main-grows-round-guts', + 'net 1 assertion(s) removed', + ); + // ...and a pre-existing file main never held is still the baseline's, + // however the merge commit disposes of it. + const ownDropped = rejectsWeakening( + 'preround-own-file-merge-drops', + 'test file deleted', + ); + expect(ownDropped.rejection).toContain('pkg/n.test.ts'); + // A later merge cannot let the round's own copy stand in for main's + // side, however many merges have passed since main last held it. + rejectsWeakening( + 'main-deletes-after-moving-then-merges-again', + 'net 1 assertion(s) removed', + ); + // Main appending a comment moves no SURFACE, so it does not put a + // file an earlier round deleted back into the baseline... + acceptsWithoutCharge('preround-absent-main-comments'); + // ...but a file whose surface the instrument cannot read at all is + // judged by its bytes, or the deletion arm -- the only arm those + // shapes have -- could never fire again. + const py = rejectsWeakening( + 'preround-absent-main-grows-python', + 'test file deleted', + ); + expect(py.rejection).toContain('pkg/test_x.py'); + // ...and a path main ADDED is the baseline's however little it + // measures: the round discarding it is still the round's deletion. + const empty = rejectsWeakening( + 'main-adds-zero-surface-round-drops', + 'test file deleted', + ); + expect(empty.rejection).toContain('pkg/e.test.ts'); + // ...and the fallback keys on what was MEASURED, not on the + // extension: a `.test.ts` the parser reads nothing from measures + // exactly like a `.py`. + const zeroTs = rejectsWeakening( + 'preround-absent-main-grows-zero-surface-ts', + 'test file deleted', + ); + expect(zeroTs.rejection).toContain('pkg/z.test.ts'); + // Main moving only DISABLED registrations is no coverage and reaches + // no signal, so it does not put the file back in the baseline. + acceptsWithoutCharge('preround-absent-main-adds-skip'); + // ...but a file that is NOTHING BUT disabled registrations is not + // compared that way at all: the comparison cannot see its only + // content, so it reaches the bytes and a rewrite counts. + const skipsOnly = rejectsWeakening( + 'preround-absent-main-rewrites-skips-only', + 'test file deleted', + ); + expect(skipsOnly.rejection).toContain('pkg/s.test.ts'); + // Each field the comparison reads is load-bearing on its own: the + // live assertion total, the declared total behind a guard, and the + // enabled-title multiset. + for (const [shape, path] of [ + ['preround-absent-main-guards', 'pkg/g.test.ts'], + ['preround-absent-main-drops-behind-guard', 'pkg/d.test.ts'], + ['preround-absent-main-renames-test', 'pkg/r.test.ts'], + ]) { + const moved = rejectsWeakening(shape, 'test file deleted'); + expect(moved.rejection).toContain(path); + } + // Two equally valid merge bases holding the SAME blob cannot change + // the verdict, so the file is measured... + acceptsWithoutCharge('crisscross-bases-agree'); + // ...and two that disagree about it are refused rather than resolved + // by a tie-break git does not promise. + const crossed = runGate({ weaken: 'crisscross-bases-disagree' }); + expect(crossed.status).toBe(1); + expect(crossed.rejection).toContain('test surface could not be measured'); + expect(crossed.rejection).toContain('pkg/a.test.ts'); + // ...including when they disagree about whether the file EXISTS, + // which an identity that could not tell "no blob" from "absent" + // would read as agreement. + const crossedPresence = runGate({ + weaken: 'crisscross-bases-disagree-presence', + }); + expect(crossedPresence.status).toBe(1); + expect(crossedPresence.rejection).toContain( + 'test surface could not be measured', + ); + expect(crossedPresence.rejection).toContain('pkg/a.test.ts'); + // ...but a path MAIN also added during the round is the baseline's, + // and dropping it after the merge is the round's deletion. + const addAdd = rejectsWeakening( + 'addadd-takes-main-then-drops', + 'test file deleted', + ); + expect(addAdd.rejection).toContain('pkg/x.test.ts'); + // Main emptied the file, and the merge landed it: main's delta is + // main's. + acceptsWithoutCharge('merge-main-empties'); + // Main replaced the file with a DIRECTORY of the same name: a tree + // at that path is not the file on either side, so the round that + // merely merged it is not charged for main's change. + acceptsWithoutCharge('main-directory-swap'); + // Main's own rename is main's: it leaves the round's view without + // charging the round. + acceptsWithoutCharge('main-renames'); + // The round deleted the file and the merge resolution kept main's + // version: main's own delta from the merge base is main's, and the + // round's deletion did not survive into the tip. + acceptsWithoutCharge('delete-then-merge-restores'); + acceptsWithoutCharge('delete-then-merge-adds'); + // Main's side arriving as the third parent of an octopus merge is + // still main's side. + acceptsWithoutCharge('octopus-freight'); + }, + 120000, + ); + + it.skipIf(!hasBashMapfile)( + 'measures a rename as the deletion of the old path', + () => { + // Following a name across the round's own renames is a non-goal: the + // old path is charged, the new one is a new file, and one ack entry + // naming the old path answers it — whatever shrank inside the + // destination rides into the report as that entry's reason. + const r = rejectsWeakening('rename-weaken', 'test file deleted'); + expect(r.rejection).toContain('pkg/a.test.ts'); + expect(r.rejection).not.toContain('pkg/b.test.ts'); + // A pure rename is charged the same way, and costs the same one entry. + const pure = rejectsWeakening('rename-only', 'test file deleted'); + expect(pure.rejection).toContain('pkg/a.test.ts'); + const acked = runGate({ + weaken: 'rename-only', + workdirFiles: { + 'test-weakening.json': JSON.stringify([ + { path: 'pkg/a.test.ts', reason: WEAKEN_REASON }, + ]), + }, + }); + expect(acked.rejection).toBe(''); + // Main's own moves under either name are still main's: a file main + // renamed away leaves the round's view uncharged, and a round that + // keeps its own copy is charged only for what it removed there. + const bothPaths = rejectsWeakening( + 'main-renames-round-keeps-both', + 'net 2 assertion(s) removed', + ); + expect(bothPaths.rejection).toContain('pkg/a.test.ts'); + }, + ); + + it("measures a parentless commit in the round as the round's own authorship", () => { + // An orphan root grafted in as the first parent of the tip is not a + // history the walk cannot read: it is authorship, measured against + // the empty tree, and the tip's shrinkage is charged. + const r = rejectsWeakening('orphan-merge', 'net 1 assertion(s) removed'); + expect(r.stdout).not.toContain('UNAVAILABLE'); + }); + + it('loads the staged parser as CommonJS whatever is planted beside it', () => { + // A package.json with {"type":"module"} next to a `.js` parser would + // make Node load it as ESM and the counter crash into UNAVAILABLE; the + // `.cjs` staging is immune, so the round is still charged. + const r = rejectsWeakening('assert', 'net 1 assertion(s) removed', { + parserTypePlant: true, + }); + expect(r.stdout).not.toContain('UNAVAILABLE'); + }); + + it('refuses an instrument that changed after staging', () => { + // The digests recorded at staging travel in expression context; a + // counter or parser that differs on disk when the gate runs is + // tampering, never a measurement. + for (const [knob, reason] of [ + [{ tamperCounter: true }, 'modified after staging'], + [{ tamperParser: true }, 'modified after staging'], + // Digested at staging but gone now is tampering too, never a + // measurement that quietly went UNAVAILABLE. + [{ dropCounter: true }, 'removed after staging'], + [{ dropParser: true }, 'removed after staging'], + // ...and so is one that can no longer be read. + [{ unreadableCounter: true }, 'made unreadable after staging'], + ]) { + const r = runGate({ weaken: 'assert', ...knob }); + expect(r.status).toBe(1); + expect(r.outputs).toContain('outcome=failed'); + expect(r.outputs).not.toContain('retryable=true'); + expect(r.rejection).toContain(reason); + } + }); + + it.skipIf(!hasBashMapfile)( + 'does not charge non-test files or non-JS test edits', + () => { + // Files under a test directory that are not tests by name are not + // enumerated: a README or a render helper is not a test deletion. + const r = acceptsWithoutCharge('non-test-file-delete'); + expect(r.stdout).not.toContain('test file deleted'); + // A non-JS test file measures a zero surface: edits are not judged, + // only deletions are. + acceptsWithoutCharge('python-edit'); + // Snapshots are out of scope: an obsolete one removed by `vitest -u` + // is routine bookkeeping. + acceptsWithoutCharge('snapshot-delete'); + // A typechange in the GROWING direction — a symlink the round + // replaces with a real test file — is not a deletion. + acceptsWithoutCharge('symlink-restored'); + }, + ); + + it.skipIf(!hasBashMapfile)( + "declares reachability a non-goal: dead code is the runner's business", + () => { + // Pinned as the documented boundary of the instrument, not as a + // guarantee: assertions wrapped in dead code still DECLARE the same + // surface. The package test run and the bite check judge execution. + acceptsWithoutCharge('dead-code'); + }, + ); + + it.skipIf(!hasBashMapfile)( + 'judges deletions even when the measurement is unavailable', + () => { + // Without the staged counter (or parser) the measured signals are + // skipped, and the gate says so; a whole-file deletion or typechange + // is proven by the pre-round -> tip pair alone. + for (const knob of [{ noCounter: true }, { noParser: true }]) { + const open = runGate({ weaken: 'assert', ...knob }); + expect(open.status).toBe(0); + expect(open.stdout).toContain( + 'test-weakening measurement UNAVAILABLE this round', + ); + } + const closed = runGate({ weaken: 'delete', noCounter: true }); + expect(closed.status).toBe(1); + expect(closed.rejection).toContain('test file deleted'); + const linked = runGate({ weaken: 'symlink', noCounter: true }); + expect(linked.status).toBe(1); + expect(linked.rejection).toContain('test file deleted'); + // Without the walk nothing proves main made a deletion, so every + // one is surfaced and acknowledged like any other. + const freight = runGate({ + weaken: 'merge-delete-freight', + noCounter: true, + }); + expect(freight.status).toBe(1); + expect(freight.rejection).toContain('test file deleted'); + // An enumeration producer git refuses fails CLOSED: the absence of + // deletions cannot be certified from nothing. + const refused = runGate({ weaken: 'delete', gitDiffFails: true }); + expect(refused.status).toBe(1); + expect(refused.outputs).toContain('retryable=true'); + expect(refused.rejection).toContain('refusing to certify their absence'); + }, + ); + + it('stages the counter and its parser from the trusted base, digested and verified', () => { + // Staged in a step of its own AFTER npm ci installed the trusted + // lockfile (integrity-checked) and BEFORE the branch checkout: the + // counter from the trusted checkout, the parser as a `.cjs` copy of the + // typescript npm ci installed. Absent from base implies absent on disk + // (rm -rf first, tolerant cp); a counter without its parser fails the + // step; both digests travel in expression context to the gate, which + // verifies them before the counter executes. + const stageAt = workflow.indexOf( + "- name: 'Stage trusted test-surface instrument'", + ); + expect(stageAt).toBeGreaterThan( + workflow.indexOf("- name: 'Install dependencies'"), + ); + expect(stageAt).toBeLessThan( + workflow.indexOf("- name: 'Prepare branch and feedback'"), + ); + const stageStep = workflow.slice( + stageAt, + workflow.indexOf("- name: 'Download CLI bundle'", stageAt), + ); + expect(stageStep).toContain("id: 'stage_surface'"); + expect(stageStep).toContain( + 'rm -rf "${RUNNER_TEMP}/count-test-surface.mjs" "${RUNNER_TEMP}/weaken-parser"', + ); + expect(stageStep).toContain( + 'cp .github/scripts/count-test-surface.mjs "${RUNNER_TEMP}/count-test-surface.mjs" 2> /dev/null || true', + ); + // Absent-from-base must imply absent-on-disk: the tolerant cp FAILS + // every pre-merge round, so a leftover a same-UID run planted at the + // staged path on this persistent host must not survive it and reach + // the gate as trusted content. rm -rf, not -f: the leftover can be a + // DIRECTORY, and rm -f's non-zero exit would abort this -e step before + // the tolerant cp — killing every later round on the host. + expect( + stageStep.indexOf('rm -rf "${RUNNER_TEMP}/count-test-surface.mjs"'), + ).toBeLessThan( + stageStep.indexOf('cp .github/scripts/count-test-surface.mjs'), + ); + // Witness, not just text: run this step VERBATIM against a tree that + // lacks the script (the merge-base shape) with leftovers planted in + // both shapes. The step must survive, leave nothing behind at either + // staged path, and emit no digest — the gate's own absence guard then + // fails open (WEAKEN_MEASURED=false), covered by `noCounter` above. + const surfaceProbeDir = mkdtempSync(join(tmpdir(), 'ws-stage-probe-')); + const surfaceRunnerTemp = mkdtempSync(join(tmpdir(), 'ws-stage-temp-')); + try { + const surfaceOutput = join(surfaceProbeDir, 'github-output'); + mkdirSync(join(surfaceProbeDir, '.github', 'scripts'), { + recursive: true, + }); + const surfaceScript = stageStep + .slice(stageStep.indexOf('run: |-') + 'run: |-'.length) + .replace(/^ {10}/gm, ''); + // GitHub runs a `run:` block under `bash --noprofile --norc -e -o + // pipefail`, and this step is unconditional: any command inside it + // that fails kills the whole review-address job for every round. The + // probe therefore runs it the same way, or the guards it exists to + // witness ("rm -f's non-zero exit would abort this -e step") are not + // being witnessed at all. + const runStage = () => + spawnSync( + 'bash', + [ + '--noprofile', + '--norc', + '-e', + '-o', + 'pipefail', + '-c', + surfaceScript, + ], + { + cwd: surfaceProbeDir, + encoding: 'utf8', + env: { + ...process.env, + RUNNER_TEMP: surfaceRunnerTemp, + GITHUB_OUTPUT: surfaceOutput, + }, + }, + ); + for (const asDirectory of [false, true]) { + rmSync(join(surfaceRunnerTemp, 'count-test-surface.mjs'), { + recursive: true, + force: true, + }); + rmSync(join(surfaceRunnerTemp, 'weaken-parser'), { + recursive: true, + force: true, + }); + if (asDirectory) { + mkdirSync(join(surfaceRunnerTemp, 'count-test-surface.mjs')); + writeFileSync( + join(surfaceRunnerTemp, 'count-test-surface.mjs', 'payload'), + 'ATTACKER_CONTROLLED\n', + ); + } else { + writeFileSync( + join(surfaceRunnerTemp, 'count-test-surface.mjs'), + 'console.log("ATTACKER_CONTROLLED");\n', + ); + } + mkdirSync(join(surfaceRunnerTemp, 'weaken-parser'), { + recursive: true, + }); + writeFileSync( + join(surfaceRunnerTemp, 'weaken-parser', 'typescript.cjs'), + 'module.exports = { ATTACKER_CONTROLLED: true };\n', + ); + writeFileSync(surfaceOutput, ''); + const surfaceProbe = runStage(); + expect(surfaceProbe.status).toBe(0); + expect( + existsSync(join(surfaceRunnerTemp, 'count-test-surface.mjs')), + ).toBe(false); + expect(existsSync(join(surfaceRunnerTemp, 'weaken-parser'))).toBe( + false, + ); + expect(readFileSync(surfaceOutput, 'utf8')).toBe(''); + } + // ...and the branch that runs in PRODUCTION once this lands: the + // counter present, the parser where npm ci puts it. Text pins cannot + // see ordering or a newly-failing command inside that `if`, and this + // step is unconditional, so a break there kills every round. Run it — + // on hosts with the GNU coreutils the step's digest lines assume: + // they call sha256sum bare, and a macOS host without it (Homebrew + // ships gsha256sum) would fail the spawn for a reason the runner can + // never hit. The witness is the string pins PLUS the redacted-arm + // probes above on such a host. + if (hasSha256sum) { + writeFileSync( + join(surfaceProbeDir, '.github', 'scripts', 'count-test-surface.mjs'), + readFileSync('.github/scripts/count-test-surface.mjs'), + ); + mkdirSync(join(surfaceProbeDir, 'node_modules', 'typescript', 'lib'), { + recursive: true, + }); + const plantedParser = + '// planted typescript build\nmodule.exports={};\n'; + writeFileSync( + join( + surfaceProbeDir, + 'node_modules', + 'typescript', + 'lib', + 'typescript.js', + ), + plantedParser, + ); + rmSync(join(surfaceRunnerTemp, 'count-test-surface.mjs'), { + recursive: true, + force: true, + }); + rmSync(join(surfaceRunnerTemp, 'weaken-parser'), { + recursive: true, + force: true, + }); + writeFileSync(surfaceOutput, ''); + const staged = runStage(); + expect(staged.stderr).toBe(''); + expect(staged.status).toBe(0); + expect( + readFileSync( + join(surfaceRunnerTemp, 'count-test-surface.mjs'), + 'utf8', + ), + ).toBe(readFileSync('.github/scripts/count-test-surface.mjs', 'utf8')); + expect( + readFileSync( + join(surfaceRunnerTemp, 'weaken-parser', 'typescript.cjs'), + 'utf8', + ), + ).toBe(plantedParser); + // The digests the gate verifies against are of the bytes that were + // actually staged, and BOTH reach expression context. + const digestOf = (file) => + createHash('sha256').update(readFileSync(file)).digest('hex'); + const emitted = readFileSync(surfaceOutput, 'utf8'); + expect(emitted).toContain( + `weaken_counter_sha256=${digestOf( + join(surfaceRunnerTemp, 'count-test-surface.mjs'), + )}\n`, + ); + expect(emitted).toContain( + `weaken_parser_sha256=${digestOf( + join(surfaceRunnerTemp, 'weaken-parser', 'typescript.cjs'), + )}\n`, + ); + } + } finally { + rmSync(surfaceProbeDir, { recursive: true, force: true }); + rmSync(surfaceRunnerTemp, { recursive: true, force: true }); + } + // The parser copy is NOT tolerant: a counter without its parser is a + // staging failure, not a round that quietly measures nothing. + expect(stageStep).toContain( + 'cp node_modules/typescript/lib/typescript.js "${RUNNER_TEMP}/weaken-parser/typescript.cjs"\n', + ); + expect(stageStep).not.toContain('npm install'); + expect(stageStep).toContain( + 'weaken_counter_sha256=$(sha256sum "${RUNNER_TEMP}/count-test-surface.mjs"', + ); + expect(stageStep).toContain( + 'weaken_parser_sha256=$(sha256sum "${RUNNER_TEMP}/weaken-parser/typescript.cjs"', + ); + for (const name of ['WEAKEN_COUNTER_SHA256', 'WEAKEN_PARSER_SHA256']) { + expect( + workflow.match( + new RegExp( + `${name}: '\\$\\{\\{ steps\\.stage_surface\\.outputs\\.${name.toLowerCase()} \\}\\}'`, + 'g', + ), + ) ?? [], + ).toHaveLength(2); + expect( + workflow.match(new RegExp(`${name}="\\$\\{${name}:-\\}" \\\\`, 'g')) ?? + [], + ).toHaveLength(2); + } + expect(reviewVerificationRunner).toContain( + 'WEAKEN_COUNTER="${RUNNER_TEMP}/count-test-surface.mjs"', + ); + expect(reviewVerificationRunner).toContain( + 'weaken_trusted "${WEAKEN_COUNTER}" "${WEAKEN_COUNTER_SHA256:-}"', + ); + expect(reviewVerificationRunner).toContain( + 'weaken_trusted "${WEAKEN_PARSER}" "${WEAKEN_PARSER_SHA256:-}"', + ); + expect(reviewVerificationRunner).toContain( + 'WEAKEN_PARSER="${RUNNER_TEMP}/weaken-parser/typescript.cjs"', + ); + expect(reviewVerificationRunner).toContain( + 'WEAKEN_PARSER_FILE="${WEAKEN_PARSER}" node "${WEAKEN_COUNTER}" measure', + ); + // The landed clamp's baseline is the branch's own side at the merge + // (R27-20): the manifest carries it as `branch`, emitted as + // weaken_emit's fifth field. A dropped arm falls back to the merge + // base silently and the wrong-attribution bug returns — pin the + // producer/consumer pair together. + expect(reviewVerificationRunner).toContain( + "printf '%s\\n%s\\n%s\\n%s\\n%s\\n'", + ); + expect(reviewVerificationRunner).toContain( + 'branch_side="$(sed -n 5p <<< "${weaken_pair}")"', + ); + expect(reviewVerificationRunner).toContain('branch: (if $bs == ""'); + // R32-2: the ack remedy must not lean on the rejection document's + // tail window for the file list — a long list is cut from the front + // there, and the agent would ack a list it never saw. The complete + // measured list rides in its own workdir file. + expect(reviewVerificationRunner).toContain( + '> "${WORKDIR}/weaken-missing.txt"', + ); + expect(reviewVerificationRunner).toContain( + 'the complete list is <workdir>/weaken-missing.txt', + ); + }); +}); + +describe('count-test-surface: the declared test surface of a test file', () => { + // The instrument the test-weakening gate rests on. `count` is exercised + // in-process on a table of shapes (each row names what the parser must + // see through), and the CLI contract once through a real spawn; the + // attribution algebra of `measure` is exercised on blob files directly. + const surface = (source, path = 'a.test.ts') => { + const r = countTestSurface(source.join('\n'), path); + return { a: r.assertions, e: r.enabled, d: r.disabled }; + }; + + it.each([ + [ + 'counts a called matcher chain once, however it is chained', + [ + "it('a', () => {", + ' expect(one()).toBe(1);', + ' expect(x).not.toBe(1);', + ' expect(x).resolves.toEqual({ a: 1 });', + '});', + ], + { a: 3, e: 1, d: [] }, + ], + [ + 'counts awaited, returned and arrow-bodied assertions', + [ + "it('a', async () => {", + ' await expect(p).rejects.toThrow();', + ' await vi.waitFor(() => expect(x).toBe(1));', + ' return expect(q).resolves.toBe(2);', + '});', + ], + { a: 3, e: 1, d: [] }, + ], + [ + 'counts expect.soft/poll with a matcher and expect.unreachable', + [ + "it('a', () => {", + ' expect.soft(x).toBe(1);', + ' expect.poll(() => y).toBe(2);', + " expect.unreachable('never');", + '});', + ], + { a: 3, e: 1, d: [] }, + ], + [ + 'counts assert() and assert.member() but not an uncalled member', + [ + "it('a', () => {", + ' assert(x);', + ' assert.equal(a, b);', + ' const eq = assert.deepEqual;', + ' console.assert(x);', + '});', + ], + { a: 2, e: 1, d: [] }, + ], + [ + 'counts a supertest .expect( member call once per chain', + [ + "it('a', async () => {", + " await request(app).get('/').expect(200).expect('x', /y/);", + '});', + ], + { a: 1, e: 1, d: [] }, + ], + [ + 'does not count a bare expect(), a property-accessed matcher, or an argument', + [ + "it('a', () => {", + ' expect(one());', + ' const dropped = expect(two()).toBe;', + ' expect.soft(x);', + ' fn(expect.anything(), expect.objectContaining({}));', + '});', + ], + { a: 0, e: 1, d: [] }, + ], + [ + 'sees through comments, strings, regex literals and templates', + [ + "it('a', () => {", + ' /* expect(one()).toBe(1); */', + ' // expect(two()).toBe(2);', + " const s = 'expect(x).toBe(1)';", + ' const r = /expect\\(x\\)\\.toBe\\(1\\)/;', + ' const t = `expect(${s}).toBe(1)`;', + ' expect(three()).toBe(3);', + '});', + ], + { a: 1, e: 1, d: [] }, + ], + [ + 'sees an assertion formatted across lines', + [ + "it('a', () => {", + ' expect(', + ' one(),', + ' ).toBe(', + ' 1,', + ' );', + '});', + ], + { a: 1, e: 1, d: [] }, + ], + [ + 'sees through JSX, in both directions', + [ + "it('a', () => {", + " expect(render(<Foo bar={1} />).getByText('x')).toBeTruthy();", + ' const html = <div>expect(two()).toBe(2)</div>;', + '});', + ], + { a: 1, e: 1, d: [] }, + 'a.test.tsx', + ], + [ + 'reads every collector spelling of a disabled registration', + [ + "it.skip('a', fn);", + "test.todo('b');", + "describe.concurrent.skip('c', fn);", + "it.skip.each([1])('d %s', fn);", + "it['skip']('e', fn);", + "it['\\x73kip']('f', fn);", + "it.\\u0073kip('g', fn);", + "it?.skip('h', fn);", + 'it', + " .skip('i', fn);", + "it.skip.each`table`('j', fn);", + "xit('k', fn);", + "xdescribe('l', fn);", + "suite.todo('m', fn);", + ], + { + a: 0, + e: 0, + d: [ + 'test:a', + 'test:b', + 'describe:c', + 'test:d %s', + 'test:e', + 'test:f', + 'test:g', + 'test:h', + 'test:i', + 'test:j', + 'test:k', + 'describe:l', + 'describe:m', + ], + }, + ], + [ + 'reads a literal skipIf/runIf and a literal options object, not a condition', + [ + "it.skipIf(true)('a', fn);", + "it.runIf(false)('b', fn);", + "it('c', { skip: true }, fn);", + "it('d', { 'todo': true }, fn);", + "it.each(getCases())('e', { skip: true }, fn);", + "it.skipIf(process.platform === 'win32')('f', fn);", + "it.runIf(cond)('g', fn);", + "it('h', { skip: cond }, fn);", + "it('i', opts, fn);", + ], + { + a: 0, + e: 4, + d: ['test:a', 'test:b', 'test:c', 'test:d', 'test:e'], + }, + ], + [ + 'reads a body-level unconditional skip, not the condition-valued guard', + [ + "it('a', (ctx) => { ctx.skip(); expect(x).toBe(1); });", + "it('b', () => { skip('reason'); });", + "it('c', function () { this.skip(true); });", + "it('d', () => { skip(process.platform === 'win32', 'reason'); expect(x).toBe(1); });", + "it('e', () => { skip(cond); });", + ], + { a: 1, e: 2, d: ['test:a', 'test:b', 'test:c'] }, + ], + [ + 'measures what a conditional guard shelters; drops what an unconditional return shelters', + [ + // The environment-guard idiom: a CONDITIONAL return silences + // nothing — the runner may still reach the assertions, and the + // header delegates the condition to the runner (R27-21: HEAD's + // own contract.test.ts measured {assertions: 0} from exactly + // this shape). + "it('a', () => {", + ' if (!process.env.X) {', + ' return;', + ' }', + ' expect(x).toBe(1);', + '});', + "it('b', () => { expect(x).toBe(1); return; });", + // …while an UNCONDITIONAL one still reports the test PASSED + // having asserted nothing, whatever constant it carries. + "it('c', () => { return; expect(x).toBe(1); });", + "it('d', () => { return 1; expect(x).toBe(1); });", + "it('e', () => { const h = (m) => { if (m) { return; } }; expect(x).toBe(1); });", + "it('f', () => { return expect(p).resolves.toBe(1); });", + ], + { a: 4, e: 6, d: [] }, + ], + [ + 'reads a truthy options constant and the fails option, not a condition', + [ + "it('a', { skip: 'flaky under load' }, fn);", + "it('b', { fails: true }, fn);", + "it('c', { skip: cond }, fn);", + "it('d', { skip: false }, fn);", + ], + { a: 0, e: 2, d: ['test:a', 'test:b'] }, + ], + [ + 'propagates a disabled describe to everything registered inside it', + [ + "describe.skip('new wrapper', () => {", + " it('a', () => { expect(x).toBe(1); });", + " it('b', fn);", + '});', + "describe('d', { skip: true }, () => { it('c', fn); });", + "it('e', fn);", + ], + { + a: 0, + e: 1, + d: ['describe:new wrapper', 'test:a', 'test:b', 'describe:d', 'test:c'], + }, + ], + [ + 'folds constants in skipIf/runIf and computed collector names', + [ + "it.skipIf(!!true)('a', fn);", + "it.runIf(!true)('b', fn);", + "it.skipIf(!false)('c', fn);", + "it['ski' + 'p']('d', fn);", + "it.skipIf(!cond)('e', fn);", + "it[S]('f', fn);", + ], + { a: 0, e: 2, d: ['test:a', 'test:b', 'test:c', 'test:d'] }, + ], + [ + 'reads a constant through a type-only wrapper', + [ + "it.skipIf(true as boolean)('a', fn);", + "it.runIf(false as boolean)('b', fn);", + "it('c', { skip: true } as const, fn);", + "it('d', { skip: (true as boolean) }, fn);", + "it.skipIf(<boolean>true)('e', fn);", + "it.skipIf(true!)('f', fn);", + "it.skipIf(true satisfies boolean)('g', fn);", + // A wrapper around a runtime value stays a condition. + "it.skipIf(process.env.CI as boolean)('h', fn);", + ], + { + a: 0, + e: 1, + d: [ + 'test:a', + 'test:b', + 'test:c', + 'test:d', + 'test:e', + 'test:f', + 'test:g', + ], + }, + ], + [ + 'folds a comparison, equality or logical operator of two constants', + [ + "it.skipIf(1 === 1)('a', fn);", + "describe.skipIf(2 > 1)('b', fn);", + "it.skipIf('x' !== '')('c', fn);", + "it.skipIf(1 && true)('d', fn);", + "it.runIf(1 > 2)('e', fn);", + "it('f', { skip: 1 === 1 }, fn);", + // Placeholder folds and bigint arithmetic stay opaque to + // comparison: `{} === {}` is false at runtime, `1n === 1` too. + "it.skipIf({} === {})('g', fn);", + "it.skipIf(1n === 1)('h', fn);", + "it['' || 'skip']('i', fn);", + ], + { + a: 0, + e: 2, + d: [ + 'test:a', + 'describe:b', + 'test:c', + 'test:d', + 'test:e', + 'test:f', + 'test:i', + ], + }, + ], + [ + "applies the runner's own rule to body skips: only `false` keeps the test", + [ + "it('a', (ctx) => { ctx.skip(null); });", + "it('b', (ctx) => { ctx.skip(0); });", + "it('c', (ctx) => { ctx.skip(undefined); });", + "it('d', (ctx) => { ctx.skip(false); });", + "it('e', (ctx) => { if (cond) ctx.skip(); expect(x).toBe(1); });", + "it('f', (ctx) => { cond && ctx.skip(); });", + "it('g', (ctx) => { cond ? ctx.skip() : run(); });", + ], + { a: 1, e: 4, d: ['test:a', 'test:b', 'test:c'] }, + ], + [ + 'measures the assertions behind a conditional return, whatever it yields', + [ + // R27-27: the silencer no longer keys on the return's VALUE — + // sync and async spellings of the same conditional guard read + // alike, and both keep the sheltered assertions. + "it('a', () => { if (x) { return undefined; } expect(x).toBe(1); });", + "it('b', () => { if (x) return null; expect(x).toBe(1); });", + "it('c', () => { if (x) return void 0; expect(x).toBe(1); });", + "it('d', () => { if (x) return 1; expect(x).toBe(1); });", + "it('e', async () => { if (x) return Promise.resolve(); expect(x).toBe(1); });", + "it('f', async () => { if (x) return await go(); expect(x).toBe(1); });", + // ...as is one that follows every assertion, or one inside a + // nested function the test merely defines. + "it('g', () => { expect(x).toBe(1); return; });", + "it('h', () => { const h = () => { return; }; expect(x).toBe(1); });", + ], + { a: 8, e: 8, d: [] }, + ], + [ + 'counts an assertion chain used as a variable initializer', + [ + "it('a', async () => {", + " const res = await request(app).get('/').expect(202);", + ' const shape = expect.objectContaining({});', + ' const e = expect;', + '});', + ], + { a: 1, e: 1, d: [] }, + ], + [ + 'keeps a describe out of the enabled-test count', + ["describe('d', () => { it('a', fn); });", "it('b', fn);"], + { a: 0, e: 2, d: [] }, + ], + [ + 'reads a file-scope body skip as disabling the whole file', + [ + 'beforeEach((ctx) => { ctx.skip(); });', + "it('a', () => { expect(x).toBe(1); });", + "it('b', () => { expect(y).toBe(2); });", + ], + { a: 0, e: 0, d: ['test:a', 'test:b'] }, + ], + [ + 'keeps a conditional hook skip, a helper and a plain callback out of it', + [ + 'beforeEach((ctx) => { if (cond) ctx.skip(); });', + 'const helper = (ctx) => ctx.skip();', + // A callback handed to something that is NOT a collector hook says + // nothing about whether the file's tests run — at file scope... + 'const cases = table.map((n) => makeCase(n, (ctx) => ctx.skip()));', + "it('a', () => { expect(x).toBe(1); });", + ], + { a: 1, e: 1, d: [] }, + ], + [ + '...and the same boundary one level down, inside a describe', + [ + "describe('d', () => {", + ' const cases = table.map((n) => makeCase(n, (ctx) => ctx.skip()));', + " it('a', () => { expect(x).toBe(1); });", + '});', + ], + { a: 1, e: 1, d: [] }, + ], + [ + 'reads collector names by their own entries, never the prototype', + [ + // `'toString' in {}` is true through the prototype chain: a helper + // named after any Object.prototype member must stay a helper. + 'const toString = (cb) => cb();', + "it('a', () => { expect(one()).toBe(1); });", + 'toString(() => { expect(two()).toBe(2); });', + 'valueOf(() => { expect(three()).toBe(3); });', + ], + { a: 3, e: 1, d: [] }, + ], + [ + 'folds every constant shape a return or a collector option can carry', + [ + // A CONDITIONAL return silences nothing whatever it yields + // (R27-21) — the constant folding is exercised on the collector + // spellings below, where it decides enabled state. + "it('a', () => { if (!r) return -1; expect(x).toBe(1); });", + "it('b', () => { if (!r) return {}; expect(x).toBe(1); });", + "it('c', () => { if (!r) return []; expect(x).toBe(1); });", + "it('d', () => { if (!r) return 1n; expect(x).toBe(1); });", + "it('e', () => { if (!r) return NaN; expect(x).toBe(1); });", + // A value the runner may AWAIT is ordinary control flow. + "it('f', () => { if (!r) return go(); expect(x).toBe(1); });", + "it.skipIf(-1)('g', fn);", + "it.runIf(-0)('h', fn);", + "it('i', { skip: -1 }, fn);", + ], + { a: 6, e: 6, d: ['test:g', 'test:h', 'test:i'] }, + ], + [ + 'measures a conditional constant return through wrappers and templates alike', + [ + // The wrappers and template spellings fold as constants, but a + // CONDITIONAL return silences nothing either way (R27-21) — the + // fold decides the UNCONDITIONAL arms elsewhere in this table. + "it('a', () => { if (!r) return undefined as void; expect(x).toBe(1); });", + "it('b', () => { if (!r) return `full suite only: ${process.env.QWEN_FULL}`; expect(x).toBe(1); });", + "it('c', () => { if (!r) return `reason`; expect(x).toBe(1); });", + // A possibly-thenable return stays ordinary control flow. + "it('d', () => { if (!r) return go(); expect(x).toBe(1); });", + "it('e', () => { return expect(p).resolves.toBe(1); });", + ], + { a: 5, e: 5, d: [] }, + ], + [ + 'measures what a conditional return in a describe body or a hook shelters', + [ + "describe('d', () => {", + ' if (!process.env.QWEN_FULL) {', + ' return;', + ' }', + ' expect(configured()).toBe(1);', + '});', + 'beforeEach(() => {', + ' if (!process.env.QWEN_FULL) {', + ' return;', + ' }', + ' expect(setupOk()).toBe(true);', + '});', + "it('a', () => { expect(x).toBe(1); });", + ], + { a: 3, e: 1, d: [] }, + ], + [ + 'reads an UNCONDITIONAL early return in a DESCRIBE body as silencing what follows', + [ + "describe('d', () => {", + " it('smoke', () => { expect(t).toBe(1); });", + ' if (true) {', + ' return;', + ' }', + " it('a', () => { expect(x).toBe(1); });", + " it('b', fn);", + '});', + ], + { a: 1, e: 1, d: ['test:a', 'test:b'] }, + ], + [ + 'lets a CONDITIONAL early return in a DESCRIBE body keep every registration', + [ + "describe('d', () => {", + " it('smoke', () => { expect(t).toBe(1); });", + ' if (!process.env.QWEN_FULL) {', + ' return;', + ' }', + " it('a', () => { expect(x).toBe(1); });", + " it('b', fn);", + '});', + ], + { a: 2, e: 3, d: [] }, + ], + [ + 'shelters nothing extra in a test that is already disabled', + [ + "it.skip('a', () => { if (!s) { return; } expect(x).toBe(1); });", + "it('b', () => { if (!s) { return; } expect(y).toBe(1); });", + "it('c', () => { expect(z).toBe(1); });", + ], + { a: 2, e: 2, d: ['test:a'] }, + ], + [ + 'reads a skip in a catch clause as the setup-failure guard it is', + [ + "it('a', (ctx) => {", + ' try { connect(); } catch { ctx.skip(); }', + ' expect(x).toBe(1);', + '});', + "it('b', (ctx) => {", + ' try { connect(); } catch (e) { ctx.skip(); }', + ' expect(y).toBe(2);', + '});', + ], + { a: 2, e: 2, d: [] }, + ], + [ + 'reads a skip that cannot be escaped as a disable, however wrapped', + [ + // The catch fires exactly when the assertion fails: the test can + // never report a failure, so the registration is disabled — the + // setup-failure guard this is one token away from. + "it('a', (ctx) => { try { expect(x).toBe(1); } catch { ctx.skip(); } });", + "it('b', (ctx) => { if (true) ctx.skip(); expect(x).toBe(1); });", + "it('c', (ctx) => { if (1 === 1) { ctx.skip(); } expect(x).toBe(1); });", + ], + { a: 0, e: 0, d: ['test:a', 'test:b', 'test:c'] }, + ], + [ + 'reads the constant-condition arms exactly as the runner reads them', + [ + // Arm-aware (R27-1's other direction): the arm a constant takes + // is unconditional, the arm it skips is dead code that disables + // nothing — and an undecidable condition stays the runner's. + "it('a', (ctx) => { if (false) { ctx.skip(); } });", + "it('b', (ctx) => { if (false) {} else { ctx.skip(); } });", + "it('c', (ctx) => { true && ctx.skip(); });", + "it('d', (ctx) => { false && ctx.skip(); });", + "it('e', (ctx) => { if (cond) ctx.skip(); });", + "it('f', (ctx) => { true ? ctx.skip() : noop(); });", + "it('g', (ctx) => { false ? noop() : ctx.skip(); });", + ], + { a: 0, e: 3, d: ['test:b', 'test:c', 'test:f', 'test:g'] }, + ], + [ + 'folds arithmetic in a collector position with JavaScript semantics', + [ + // Every operator arm is pinned DISCRIMINATIVELY: dropping one + // flips its row between disabled and enabled. + "it.skipIf(6 * 7 === 42)('a', fn);", + "it.runIf(10 % 3)('b', fn);", + "it('c', { skip: 2 ** 10 > 1000 }, fn);", + "it.skipIf(8 - 8)('d', fn);", + "it.skipIf(8 - 7)('d2', fn);", + "it.skipIf(10 % 3)('x', fn);", + // A placeholder or a bigint never enters the fold. + "it.skipIf([] * 2)('e', fn);", + "it.skipIf(2n * 2n > 1n)('f', fn);", + ], + { a: 0, e: 4, d: ['test:a', 'test:c', 'test:d2', 'test:x'] }, + ], + [ + "reads Playwright's fixme as the disabled registration it is", + ["test.fixme('a', fn);", "it('b', fn);"], + { a: 0, e: 1, d: ['test:a'] }, + ], + [ + 'books the Playwright-namespaced API by kind, and utilities as nothing', + [ + // R30-2: `test.describe` is a suite (its disabled state + // propagates), `test.beforeEach` is a hook, and the utility + // members register no phantom test. + "test.describe('a', () => { it('x', () => { expect(one()).toBe(1); }); });", + "test.describe.skip('b', () => { it('y', fn); });", + 'test.beforeEach(() => { expect(setup()).toBe(true); });', + "test.step('logged step', fn);", + 'test.use({ headless: true });', + ], + { a: 2, e: 1, d: ['describe:b', 'test:y'] }, + ], + [ + 'never folds an operator over a placeholder value', + [ + // R32-1: the object fold carries truthiness, not a value — + // `-[]` is -0 (falsy) at runtime, never the placeholder's -1. + "it.skipIf(+{})('a', fn);", + "it.runIf(-[])('b', fn);", + "it('c', { skip: +[] }, fn);", + "it('d', { skip: -1 }, fn);", + ], + { a: 0, e: 3, d: ['test:d'] }, + ], + [ + 'measures a registration through a callback handed by name', + [ + 'function body(ctx) {', + ' ctx.skip();', + ' expect(1).toBe(1);', + '}', + "it('a', body);", + "it('b', fn);", + ], + { a: 0, e: 1, d: ['test:a'] }, + ], + [ + 'measures the named-body shapes exactly like their inline spellings', + [ + 'function body() {', + ' expect(1).toBe(1);', + '}', + "it.skip('a', body);", + "it('a', () => {});", + 'function suite() {', + " it('b', () => { expect(2).toBe(2); });", + '}', + "describe.skip('c', suite);", + ], + { a: 0, e: 1, d: ['test:a', 'test:b', 'describe:c'] }, + ], + [ + 'keeps a body shared with an enabled registration live', + [ + 'function body() {', + ' expect(1).toBe(1);', + '}', + "it('a', body);", + "it.skip('b', body);", + ], + { a: 1, e: 1, d: ['test:b'] }, + ], + [ + 'reads a collector factory binding as no registration at all', + [ + "const posixOnly = it.skipIf(process.platform !== 'linux');", + 'const rows = it.each(getCases());', + 'const myTest = test.extend({});', + "it('a', fn);", + ], + { a: 0, e: 1, d: [] }, + ], + [ + 'sees a chain through a type-only wrapper', + [ + "(it as any).skip('a', () => { expect(1).toBe(1); });", + "(it.skip as any)('b', () => { expect(1).toBe(1); });", + "it('c', (ctx) => { (ctx as any).skip(); expect(1).toBe(1); });", + "it('d', () => { (expect(1) as any).toBe(1); });", + "(describe as any).skip('e', () => { it('f', () => { expect(1).toBe(1); }); });", + ], + // test:d stays enabled — its body only ASSERTS through a wrapper. + { a: 1, e: 1, d: ['test:a', 'test:b', 'test:c', 'describe:e', 'test:f'] }, + ], + [ + 'counts assertions only where the runner would execute them', + [ + "it.skip('a', () => { expect(x).toBe(1); expect(y).toBe(2); });", + "it('a', () => {});", + "it('b', () => { expect(z).toBe(3); });", + 'beforeEach(() => { expect(setup).toBe(true); });', + ], + { a: 2, e: 2, d: ['test:a'] }, + ], + [ + 'counts a declared surface, not a reachable one (documented non-goal)', + [ + "it('a', () => { if (false) { expect(x).toBe(1); } });", + "it('b', () => { const unused = () => expect(x).toBe(1); });", + ], + { a: 2, e: 2, d: [] }, + ], + [ + 'measures a file that does not parse cleanly', + ["it('a', () => { expect(x).toBe(1) ; }} ) garbage <<<"], + { a: 1, e: 1, d: [] }, + ], + ])('%s', (_title, source, expected, path) => { + expect(surface(source, path)).toEqual(expected); + }); + + it('resolves a registration callback bound by name, exactly like the inline spelling', () => { + // The runner receives the SAME function whether the registration + // hands it over inline or by name, so the two spellings must measure + // alike: the body's skip reaches the registration, its assertions + // leave the surface, and a disabled describe's body propagates. + expect( + countTestSurface( + [ + 'function body(ctx) {', + ' ctx.skip();', + ' expect(1).toBe(1);', + '}', + "it('x', body);", + ].join('\n'), + 'a.test.ts', + ), + ).toMatchObject({ + assertions: 0, + enabled: 0, + disabled: ['test:x'], + enabledTitles: [], + }); + expect( + countTestSurface( + [ + 'function suite() {', + " it('a', () => { expect(1).toBe(1); });", + '}', + "describe.skip('d', suite);", + ].join('\n'), + 'a.test.ts', + ), + ).toMatchObject({ enabledTitles: [], enabled: 0, assertions: 0 }); + // The inline spellings these must agree with. + expect( + countTestSurface( + "it('x', (ctx) => { ctx.skip(); expect(1).toBe(1); });", + 'a.test.ts', + ), + ).toMatchObject({ + assertions: 0, + enabled: 0, + disabled: ['test:x'], + enabledTitles: [], + }); + expect( + countTestSurface( + "describe.skip('d', () => { it('a', () => { expect(1).toBe(1); }); });", + 'a.test.ts', + ), + ).toMatchObject({ enabledTitles: [], enabled: 0, assertions: 0 }); + }); + + it('measures every dialect the gate selects, not only .ts', () => { + // The pathspec selects `*.test.*` and `*.spec.*` whatever the + // extension, and the repository tracks hundreds of `*.test.js` files. + // A dialect missing from the parser's table measures ZERO — which the + // gate reads as a file with no surface to weaken, so gutting one is + // free. Every extension the table claims must therefore be exercised. + const body = [ + "it('a', () => {", + ' expect(one()).toBe(1);', + ' expect(two()).toBe(2);', + '});', + "it.skip('b', () => {", + ' expect(three()).toBe(3);', + '});', + ]; + for (const ext of [ + '.ts', + '.mts', + '.cts', + '.tsx', + '.js', + '.mjs', + '.cjs', + '.jsx', + ]) { + expect({ ext, ...surface(body, `a.test${ext}`) }).toEqual({ + ext, + a: 2, + e: 1, + d: ['test:b'], + }); + } + }); + + it('measures a zero surface for a non-JS test file', () => { + expect( + countTestSurface('def test_x():\n assert 1\n', 'test_x.py'), + ).toEqual({ + language: 'other', + assertions: 0, + declared: 0, + enabled: 0, + disabled: [], + enabledTitles: [], + }); + }); + + it('serves the count and measure modes over the CLI the gate uses', () => { + const counter = resolve('.github/scripts/count-test-surface.mjs'); + const counted = spawnSync('node', [counter, 'count', 'pkg/a.test.ts'], { + input: "it('a', () => {\n expect(one()).toBe(1);\n});\n", + encoding: 'utf8', + }); + expect(counted.status).toBe(0); + expect(JSON.parse(counted.stdout)).toMatchObject({ + language: 'ts', + assertions: 1, + enabled: 1, + }); + // The gate runs the counter from a STAGED copy under RUNNER_TEMP, which + // on macOS is reached through /var -> /private/var. The CLI must fire + // when this file is the program however its path was spelled — a + // main-module guard comparing argv to the module URL literally would + // read as "imported", print nothing, and the gate would report every + // file unmeasurable. + const linkDir = mkdtempSync(join(tmpdir(), 'surface-link-')); + try { + const linked = join(linkDir, 'count-test-surface.mjs'); + symlinkSync(counter, linked); + const viaLink = spawnSync('node', [linked, 'count', 'pkg/a.test.ts'], { + input: "it('a', () => {\n expect(one()).toBe(1);\n});\n", + encoding: 'utf8', + }); + expect(viaLink.status).toBe(0); + expect(JSON.parse(viaLink.stdout)).toMatchObject({ assertions: 1 }); + } finally { + rmSync(linkDir, { recursive: true, force: true }); + } + // ...and IMPORTING it must not run the CLI against the importer's argv: + // a positional the importing program owns would otherwise land in the + // unknown-mode arm and take that process down with exit 2. + const imported = spawnSync( + 'node', + [ + '--input-type=module', + '-e', + `await import(${JSON.stringify(pathToFileURL(counter).href)}); console.log('IMPORT_OK');`, + 'measure', + 'not-a-manifest.json', + ], + { encoding: 'utf8' }, + ); + expect(imported.stderr).toBe(''); + expect(imported.status).toBe(0); + expect(imported.stdout).toContain('IMPORT_OK'); + const dir = mkdtempSync(join(tmpdir(), 'surface-')); + try { + const blob = (name, lines) => { + const p = join(dir, name); + writeFileSync(p, `${lines.join('\n')}\n`); + return p; + }; + const manifest = join(dir, 'm.json'); + writeFileSync( + manifest, + JSON.stringify({ + path: 'pkg/a.test.ts', + tip: blob('tip', ["it('a', () => { expect(x).toBe(1); });"]), + pre: blob('pre', [ + "it('a', () => { expect(x).toBe(1); expect(y).toBe(2); });", + ]), + events: [], + }), + ); + const measured = spawnSync('node', [counter, 'measure', manifest], { + encoding: 'utf8', + }); + expect(measured.status).toBe(0); + expect(JSON.parse(measured.stdout)).toEqual({ + language: 'ts', + assertions: -1, + enabled: 0, + newlyDisabled: [], + baselinePresent: true, + }); + const unknown = spawnSync('node', [counter, 'bogus'], { + encoding: 'utf8', + }); + expect(unknown.status).toBe(2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("subtracts main's contribution across events, and only that", () => { + const dir = mkdtempSync(join(tmpdir(), 'surface-')); + try { + const blob = (name, lines) => { + const p = join(dir, name); + writeFileSync(p, `${lines.join('\n')}\n`); + return p; + }; + const two = blob('two', [ + "it('a', () => { expect(x).toBe(1); expect(y).toBe(2); });", + ]); + const one = blob('one', ["it('a', () => { expect(x).toBe(1); });"]); + const oneSkipped = blob('one-skipped', [ + "it.skip('a', () => { expect(x).toBe(1); });", + ]); + const twoPlusB = blob('two-b', [ + "it('a', () => { expect(x).toBe(1); expect(y).toBe(2); });", + "it.todo('b');", + ]); + const m = (o) => measureTestSurface({ path: 'pkg/a.test.ts', ...o }); + // Main removed the assertion (the event carries it); the round only + // merged: nothing to charge. + expect( + m({ tip: one, pre: two, events: [{ before: two, after: one }] }), + ).toMatchObject({ assertions: 0, baselinePresent: true }); + // Main added the file; the round dropped it: the baseline holds the + // file and the round removed its whole surface. + expect( + m({ tip: null, pre: null, events: [{ before: null, after: two }] }), + ).toMatchObject({ assertions: -2, baselinePresent: true }); + // Main deleted the file; the round adopted it: no baseline to weaken. + expect( + m({ tip: null, pre: two, events: [{ before: two, after: null }] }), + ).toMatchObject({ assertions: 0, baselinePresent: false }); + // The round disabled `a`; main's later event did not touch its state. + expect( + m({ + tip: oneSkipped, + pre: one, + events: [{ before: oneSkipped, after: oneSkipped }], + }), + ).toMatchObject({ enabled: -1, newlyDisabled: ['test:a'] }); + // Main itself disabled `a` across the event: not the round's doing. + expect( + m({ + tip: oneSkipped, + pre: one, + events: [{ before: one, after: oneSkipped }], + }), + ).toMatchObject({ enabled: 0, newlyDisabled: [] }); + // A todo registration that is new at the tip is the round's own. + expect(m({ tip: twoPlusB, pre: two, events: [] })).toMatchObject({ + enabled: 0, + newlyDisabled: [], + }); + // Main disabled `a` and re-enabled it later: the shield does not + // outlive the re-enable, so the round disabling `a` is charged. + const aSkipped = blob('a-skipped', [ + "it.skip('a', () => { expect(x).toBe(1); expect(y).toBe(2); });", + ]); + const aSkippedPlusB = blob('a-skipped-b', [ + "it.skip('a', () => { expect(x).toBe(1); expect(y).toBe(2); });", + "it('b', () => { expect(z).toBe(3); });", + ]); + expect( + m({ + tip: aSkippedPlusB, + pre: two, + events: [ + { before: two, after: aSkipped }, + { before: aSkipped, after: two }, + ], + }), + ).toMatchObject({ newlyDisabled: ['test:a'] }); + // A registration the round added before a merge of main is carried + // across the event on both sides: it never joins the baseline, so + // disabling it later is the round's own business. + const twoB = blob('two-b-enabled', [ + "it('a', () => { expect(x).toBe(1); expect(y).toBe(2); });", + "it('b', () => { expect(z).toBe(3); });", + ]); + const twoBSkipped = blob('two-b-skipped', [ + "it('a', () => { expect(x).toBe(1); expect(y).toBe(2); });", + "it.skip('b', () => { expect(z).toBe(3); });", + ]); + const twoBMainTouched = blob('two-b-main-touched', [ + "it('a', () => { expect(x).toBe(1); expect(y).toBe(2); });", + "it('b', () => { expect(z).toBe(3); });", + '// main touched this file', + ]); + expect( + m({ + tip: twoBSkipped, + pre: two, + events: [{ before: twoB, after: twoBMainTouched }], + }), + ).toMatchObject({ newlyDisabled: [], enabled: 0 }); + // ...while a `b` that main itself landed is baseline. + expect( + m({ + tip: twoBSkipped, + pre: two, + events: [{ before: two, after: twoB }], + }), + ).toMatchObject({ newlyDisabled: ['test:b'], enabled: -1 }); + // Duplicate titles are multisets: disabling one of two `a`s is + // charged once; adding a third, disabled `a` beside two enabled + // ones charges nothing. + const twoAs = blob('two-as', [ + "it('a', () => { expect(x).toBe(1); });", + "it('a', () => { expect(y).toBe(2); });", + ]); + const oneOfTwoAsSkipped = blob('two-as-one-skipped', [ + "it('a', () => { expect(x).toBe(1); });", + "it.skip('a', () => { expect(y).toBe(2); });", + ]); + const twoAsPlusTodo = blob('two-as-todo', [ + "it('a', () => { expect(x).toBe(1); });", + "it('a', () => { expect(y).toBe(2); });", + "it.todo('a');", + ]); + expect( + m({ tip: oneOfTwoAsSkipped, pre: twoAs, events: [] }), + ).toMatchObject({ newlyDisabled: ['test:a'] }); + expect(m({ tip: twoAsPlusTodo, pre: twoAs, events: [] })).toMatchObject({ + newlyDisabled: [], + }); + // An UNCONDITIONAL early return the round plants in front of + // existing assertions measures as their removal, while a brand-new + // test carrying its own guard costs nothing and no throwaway + // registration can pay for one. + const oneNoGuard = blob('one-no-guard', [ + "it('a', () => { expect(x).toBe(1); });", + ]); + const oneGuardedPlusTodo = blob('one-guarded-todo', [ + "it('a', () => { return; expect(x).toBe(1); });", + "it.todo('c');", + ]); + const oneNoGuardPlusGuarded = blob('one-plus-guarded', [ + "it('a', () => { expect(x).toBe(1); });", + "it('c', () => { return; expect(y).toBe(2); });", + ]); + expect( + m({ tip: oneGuardedPlusTodo, pre: oneNoGuard, events: [] }), + ).toMatchObject({ assertions: -1 }); + expect( + m({ tip: oneNoGuardPlusGuarded, pre: oneNoGuard, events: [] }), + ).toMatchObject({ assertions: 0 }); + // …while the CONDITIONAL spelling is the environment-guard idiom: + // sheltered assertions stay measured, so planting one reads as no + // movement in either direction (R27-21/R27-27). + const oneCondGuarded = blob('one-cond-guarded', [ + "it('a', () => { if (!s) { return; } expect(x).toBe(1); });", + ]); + expect( + m({ tip: oneCondGuarded, pre: oneNoGuard, events: [] }), + ).toMatchObject({ assertions: 0 }); + expect( + m({ tip: oneNoGuard, pre: oneCondGuarded, events: [] }), + ).toMatchObject({ assertions: 0 }); + // ...and an assertion the baseline already carried behind a guard + // is not free to delete: the conditional guard keeps it measured, + // so deleting it still charges its removal. + const guardedTwo = blob('guarded-two', [ + "it('a', () => { if (!s) { return; } expect(x).toBe(1); expect(y).toBe(2); });", + ]); + const guardedNone = blob('guarded-none', [ + "it('a', () => { if (!s) { return; } });", + ]); + expect( + m({ tip: guardedNone, pre: guardedTwo, events: [] }), + ).toMatchObject({ assertions: -2 }); + // R27-20: the landed delta is measured against the branch's side at + // the merge, or a resolution that DISCARDED main's removal still + // credits it — main removed three, the round removed two of its + // own, the merge kept the branch's side whole, and main's credit + // is zero (the old before-baseline read the round's own removal as + // main's landed one and the file reported no movement). + const baseFive = blob('base-five', [ + "it('a', () => { expect(x).toBe(1); expect(y).toBe(2); expect(z).toBe(3); expect(u).toBe(4); expect(v).toBe(5); });", + ]); + const mainTwo = blob('main-two', [ + "it('a', () => { expect(x).toBe(1); expect(y).toBe(2); });", + ]); + const branchThree = blob('branch-three', [ + "it('a', () => { expect(x).toBe(1); expect(y).toBe(2); expect(z).toBe(3); });", + ]); + expect( + m({ + tip: branchThree, + pre: baseFive, + events: [ + { + before: baseFive, + after: mainTwo, + landed: branchThree, + branch: branchThree, + }, + ], + }), + ).toMatchObject({ assertions: -2 }); + // An event that moved nothing — both sides absent, or byte-identical + // — neither shields nor decides whether the baseline holds the file. + expect( + m({ tip: null, pre: two, events: [{ before: null, after: null }] }), + ).toMatchObject({ baselinePresent: true }); + expect( + m({ tip: null, pre: null, events: [{ before: two, after: two }] }), + ).toMatchObject({ baselinePresent: false }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('review-address: regression accounting (af-157)', () => { + // The loop had no notion of a regression at all: the consecutive-failure + // brake counts "rounds that pushed nothing", so a round that pushed a fix + // and turned CI red counted as a SUCCESS and reset the counter. The gate + // cannot close this — it runs `--changed` tests for the touched workspaces + // and defers full regression to the PR's own CI, whose verdict does not + // exist until long after the job ended. So the charge is measured on the + // NEXT round, from markers, and these tests run the real block. + const script = prepareBranchAndFeedbackStep + .match( + /# ---- regression accounting[\s\S]*?echo "regressed_round=\$\{REGRESSED_ROUND\}" >> "\$\{GITHUB_OUTPUT\}"\n/, + )?.[0] + ?.replace(/^ {10}/gm, ''); + it('extracts the block it tests', () => { + expect(script).toBeTruthy(); + }); + + it('excludes every loop-owned lane from the head-state classifiers, by name', () => { + // R27-23: a lane the loop owns but the exclusion omits attaches its + // check runs to the same PR head, so the charge classifier reads the + // loop's OWN red as the round's regression (and the wake classifiers + // wake on it). The list lives in ONE env entry every classifier + // references — a hand-copied list per site drifts. + const expected = [ + 'Qwen Autofix', + '🧐 Qwen Pull Request Review', + 'Qwen CI Failure Patrol', + 'Qwen Autofix Fork Bridge', + 'Qwen Autofix Fork Signal', + 'Qwen Triage', + 'Qwen Triage Finalize', + 'PR self-report label', + 'Qwen PR Safety Precheck', + 'Comment Attachment Guard', + 'PR Asset Branch Cleanup', + ]; + const lanes = JSON.parse(loopOwnedLanesJson); + expect([...lanes].sort()).toEqual([...expected].sort()); + // …every listed name is a real workflow's `name:` line, or a rename + // leaves the exclusion matching nothing while the suite is green. + const workflowNames = readdirSync('.github/workflows') + .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')) + .map( + (f) => + readFileSync(join('.github/workflows', f), 'utf8').match( + /^name:\s*'([^']+)'/m, + )?.[1] ?? + readFileSync(join('.github/workflows', f), 'utf8').match( + /^name:\s*"?([^"\n]+)"?/m, + )?.[1], + ); + for (const n of lanes) expect(workflowNames).toContain(n); + // …and every head-state classifier references the shared list — + // charge (review-address) and both wake sites (review-address, + // review-scan) — with no literal copy surviving beside it. + const uses = workflow.match(/IN\(\$looplanes\[\]\)/g) ?? []; + expect(uses.length).toBe(3); + expect(workflow).not.toMatch(/IN\("Qwen Autofix"/); + expect(workflow).not.toMatch(/IN\('Qwen Autofix'/); + }); + + const HEAD = 'a'.repeat(40); + const OTHER = 'b'.repeat(40); + const run = ({ + checks, + comments = [], + checkedOutHead = HEAD, + // The commit the rollup describes (headRefOid read with the rollup). + checksHead = HEAD, + window = 'w1', + // The live re-arm key prepare computed: defaults to the run's window + // (the ordinary case); a supersede-exempt conflict round can still run + // under a stale matrix window. + liveKey = window, + }) => { + const dir = mkdtempSync(join(tmpdir(), 'af148-')); + try { + writeFileSync(join(dir, 'checks.json'), JSON.stringify(checks)); + writeFileSync(join(dir, 'ic.json'), JSON.stringify(comments)); + const outFile = join(dir, 'out'); + writeFileSync(outFile, ''); + execFileSync( + 'bash', + [ + '-c', + `set -euo pipefail\nWORKDIR=${JSON.stringify(dir)}\nAUTOFIX_BOT=qwen-code-dev-bot\nDISPATCH_STATUS_CONTEXT='qwen-autofix/dispatch-pending'\nCHECKED_OUT_HEAD='${checkedOutHead}'\nROLLUP_HEAD='${checksHead}'\nWINDOW='${window}'\nLIVE_REARM_KEY='${liveKey}'\nLOOP_OWNED_LANES=${JSON.stringify(loopOwnedLanesJson)}\nPR=1\nGITHUB_OUTPUT=${JSON.stringify(outFile)}\n${script}`, + ], + { encoding: 'utf8' }, + ); + const out = readFileSync(outFile, 'utf8'); + return { + state: out.match(/check_state=(\S*)/)?.[1] ?? '', + regressed: out.match(/regressed_round=(\S*)/)?.[1] ?? '', + }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + const pushMarker = ({ + round = 3, + head = HEAD, + pre = 'green', + key = 'w1', + }) => [ + { + user: { login: 'qwen-code-dev-bot' }, + created_at: '2026-01-01T00:00:00Z', + body: `report\n<!-- autofix-push round=${round} head=${head} pre=${pre} key=${key} -->`, + }, + ]; + const RED = [ + { + name: 'Test', + status: 'COMPLETED', + conclusion: 'FAILURE', + workflowName: 'Qwen Code CI', + }, + ]; + const GREEN = [ + { + name: 'Test', + status: 'COMPLETED', + conclusion: 'SUCCESS', + workflowName: 'Qwen Code CI', + }, + ]; + + it('classifies the head state the marker records', () => { + expect(run({ checks: GREEN }).state).toBe('green'); + expect(run({ checks: RED }).state).toBe('red'); + // A check still RUNNING is not green: a round pushing while CI is + // mid-flight must never be able to claim the head was clean, or a + // pre-existing failure that had not landed yet gets charged to it. + expect( + run({ + checks: [{ name: 'Test', status: 'IN_PROGRESS', conclusion: null }], + }).state, + ).toBe('pending'); + expect( + run({ + checks: [ + { __typename: 'StatusContext', context: 'dco', state: 'PENDING' }, + ], + }).state, + ).toBe('pending'); + // No checks at all is unknown, never green. + expect(run({ checks: [] }).state).toBe('none'); + // The loop's OWN dispatch-pending commit status is a StatusContext with + // no workflowName: excluded by its exact context value, so a head + // carrying nothing but the loop's stamp is still unknown, while a + // third-party status (dco above) keeps counting. + expect( + run({ + checks: [ + { + __typename: 'StatusContext', + context: 'qwen-autofix/dispatch-pending', + state: 'SUCCESS', + }, + ], + }).state, + ).toBe('none'); + expect( + run({ + checks: [ + { + __typename: 'StatusContext', + context: 'qwen-autofix/dispatch-pending', + state: 'PENDING', + }, + ...GREEN, + ], + }).state, + ).toBe('green'); + // A check with no verdict — a required context that never reported, a + // stale one, a run that never started — is pending, never green. + for (const check of [ + { __typename: 'StatusContext', context: 'required', state: 'EXPECTED' }, + { name: 'a', status: 'COMPLETED', conclusion: 'STALE' }, + { name: 'a', status: 'COMPLETED', conclusion: 'STARTUP_FAILURE' }, + ]) { + expect(run({ checks: [check, ...GREEN] }).state).toBe('pending'); + } + // GREEN is an allowlist, never the fall-through: a conclusion this + // classifier does not recognise — a state GitHub adds later, or one + // it never enumerated — reads as pending, so it can never mint the + // charge-enabling premise on its own. The recognised passing states + // stay green, or the mechanism would never charge anything. + for (const conclusion of ['SUCCESS', 'NEUTRAL', 'SKIPPED']) { + expect( + run({ checks: [{ name: 'a', status: 'COMPLETED', conclusion }] }).state, + ).toBe('green'); + } + // A check still in flight is pending even when it carries a conclusion + // from an earlier attempt: the verdict on the head is not in yet. + // REQUESTED is in the enumeration too, and it is the member a denylist + // written from memory forgets — which is why this axis is an allowlist + // (`status == COMPLETED`) rather than a list of in-flight states. + for (const status of [ + 'QUEUED', + 'IN_PROGRESS', + 'WAITING', + 'PENDING', + 'REQUESTED', + 'A_STATUS_GITHUB_ADDS_LATER', + ]) { + expect( + run({ checks: [{ name: 'a', status, conclusion: 'SUCCESS' }] }).state, + ).toBe('pending'); + } + for (const conclusion of ['MOON_PHASE', 'DEGRADED', 'ABANDONED']) { + expect( + run({ checks: [{ name: 'a', status: 'COMPLETED', conclusion }] }).state, + ).toBe('pending'); + expect( + run({ + checks: [{ name: 'a', status: 'COMPLETED', conclusion }, ...GREEN], + }).state, + ).toBe('pending'); + } + // The rollup is read live, after the checkout: a rollup describing any + // other commit says nothing about the checked-out head. + expect(run({ checks: GREEN, checksHead: OTHER }).state).toBe('none'); + expect(run({ checks: RED, checksHead: OTHER }).state).toBe('none'); + // CANCELLED is not a failure here, matching the scan's own N_RED_NOW + // filter — but not-red is not green: a cancelled run produced no + // verdict, so it classifies pending like every verdict-less state and + // can never mint the charge-enabling premise. + expect( + run({ + checks: [{ name: 'a', status: 'COMPLETED', conclusion: 'CANCELLED' }], + }).state, + ).toBe('pending'); + expect( + run({ + checks: [ + { name: 'a', status: 'COMPLETED', conclusion: 'CANCELLED' }, + ...GREEN, + ], + }).state, + ).toBe('pending'); + // The loop's OWN lanes going red is its business, not the PR's — the + // charge verdict excludes every own-lane workflow (the canonical + // five-name filter), with no review-address carve-out: a failed prior + // address round is feedback for the next round, not a regression the + // pushed code introduced, and an in-flight own check is observer + // noise on the triggers whose suite attaches to the PR head. The + // carve-out survives at the FEEDBACK sites (N_FAILED_CHECKS / + // N_RED_NOW). All FIVE own-lane workflow names are exercised — a name + // dropped from the jq filter would read as a PR-side regression here. + for (const loopWorkflow of [ + 'Qwen Autofix', + '🧐 Qwen Pull Request Review', + 'Qwen CI Failure Patrol', + 'Qwen Autofix Fork Bridge', + 'Qwen Autofix Fork Signal', + ]) { + expect( + run({ + checks: [ + { + name: 'build', + status: 'COMPLETED', + conclusion: 'FAILURE', + workflowName: loopWorkflow, + }, + ...GREEN, + ], + }).state, + ).toBe('green'); + // An own lane ALONE is no signal at all: excluded by the filter, it + // leaves an empty set — unknown, never the red a charge needs. + expect( + run({ + checks: [ + { + name: 'build', + status: 'COMPLETED', + conclusion: 'FAILURE', + workflowName: loopWorkflow, + }, + ], + }).state, + ).toBe('none'); + } + // The round's own in-flight check must not hold the verdict pending. + expect( + run({ + checks: [ + { + name: 'review-address (1)', + status: 'IN_PROGRESS', + conclusion: null, + workflowName: 'Qwen Autofix', + }, + ...GREEN, + ], + }).state, + ).toBe('green'); + }); + + it('charges the prior round only when all four facts hold', () => { + // The whole point: green head in, red head out, nothing else moved. + expect(run({ checks: RED, comments: pushMarker({}) }).regressed).toBe('3'); + // The head MOVED — a human push or a base update owns the red, not the + // bot's round. + expect( + run({ checks: RED, comments: pushMarker({ head: OTHER }) }).regressed, + ).toBe(''); + // ...and the equality binds the rollup too: a red rollup for a commit + // pushed after the checkout is not the checked-out head's red. + expect( + run({ checks: RED, checksHead: OTHER, comments: pushMarker({}) }) + .regressed, + ).toBe(''); + // The round pushed onto an already-red head: it did not create this. + expect( + run({ checks: RED, comments: pushMarker({ pre: 'red' }) }).regressed, + ).toBe(''); + expect( + run({ checks: RED, comments: pushMarker({ pre: 'pending' }) }).regressed, + ).toBe(''); + expect( + run({ checks: RED, comments: pushMarker({ pre: 'none' }) }).regressed, + ).toBe(''); + // A re-arm moved the window: the old round is not this window's charge. + expect( + run({ checks: RED, comments: pushMarker({ key: 'w0' }) }).regressed, + ).toBe(''); + // ...and when this run's own matrix window is the stale one (a + // supersede-exempt conflict round after a re-arm), the charge stays + // with the live window: the brake walks headlines under the live key, + // so a charge keyed to the dead window would never land there. + expect( + run({ + checks: RED, + comments: pushMarker({ key: 'w0' }), + window: 'w0', + liveKey: 'w1', + }).regressed, + ).toBe(''); + // Head is green now — nothing to charge. + expect(run({ checks: GREEN, comments: pushMarker({}) }).regressed).toBe(''); + // A failed own address run beside a green suite is feedback, not a + // regression: the charge path never bills the prior round for it. + expect( + run({ + checks: [ + { + name: 'review-address (1)', + status: 'COMPLETED', + conclusion: 'FAILURE', + workflowName: 'Qwen Autofix', + }, + ...GREEN, + ], + comments: pushMarker({}), + }).regressed, + ).toBe(''); + // Same for a red auxiliary lane of the fleet. + expect( + run({ + checks: [ + { + name: 'review-pr', + status: 'COMPLETED', + conclusion: 'FAILURE', + workflowName: '🧐 Qwen Pull Request Review', + }, + ...GREEN, + ], + comments: pushMarker({}), + }).regressed, + ).toBe(''); + // No push marker at all (a no-op round pushed nothing that could + // regress): fail open. + expect(run({ checks: RED, comments: [] }).regressed).toBe(''); + // A marker forged by anyone other than the bot is not evidence. + expect( + run({ + checks: RED, + comments: pushMarker({}).map((c) => ({ + ...c, + user: { login: 'someone-else' }, + })), + }).regressed, + ).toBe(''); + }); + + it('reads the NEWEST push marker, not the first', () => { + const [older] = pushMarker({ round: 2, head: OTHER }); + const [newer] = pushMarker({ round: 5, head: HEAD }); + expect( + run({ + checks: RED, + comments: [ + { ...older, created_at: '2026-01-01T00:00:00Z' }, + { ...newer, created_at: '2026-01-02T00:00:00Z' }, + ], + }).regressed, + ).toBe('5'); + }); + + it('stamps the push marker on the pushed report and nowhere else', () => { + // Only an ACTED round can regress, so only the pushed report carries + // autofix-push — and it names the PUSHED sha, not REPORT_HEAD (which + // deliberately records the PRE-round head for the redcheck marker). + expect(pushAndReportScript).toContain( + '<!-- autofix-push round=${NEXT_ROUND} head=${PUSHED_HEAD} pre=${PUSH_PRE} key=${WINDOW:-none} -->', + ); + // The field carries the CLASSIFIER's verdict, not a literal: replacing + // this one line with `PUSH_PRE=green` would leave the whole mechanism + // shaped exactly as it is while charging every red head to the round. + expect(pushAndReportScript).toContain('PUSH_PRE="${CHECK_STATE:-none}"'); + // A salvage-merged branch move or a base-conflict merge means the pushed + // head did not start from the head prepare classified: the premise is + // stamped unknown. + expect(pushAndReportScript).toContain( + "[[ \"${PUSH_RACE_MERGED}\" == 'true' ]] && PUSH_PRE='none'", + ); + expect(pushAndReportScript).toContain( + "[[ \"${CONFLICT:-false}\" == 'true' ]] && PUSH_PRE='none'", + ); + // ...and so does any merge commit the push carries past the head + // prepare classified (a clean in-round merge of main trips neither arm + // above): the pushed head then holds content prepare never classified. + expect(pushAndReportScript).toContain( + '[[ -n "$(git rev-list --merges "${REPORT_HEAD}..${PUSHED_HEAD}" 2>/dev/null)" ]] && PUSH_PRE=\'none\'', + ); + // When the push landed but the report post failed, a marker-only + // comment still carries the push record — the only record a later + // round can charge a regression against. + expect(pushAndReportScript).toContain( + 'gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/push-marker.md"', + ); + // ...and the fallback is gated on the af-157 state the round AUTHORED, + // never on its outcome: a no-op round pushes nothing, but the + // regression it observed about the PRIOR round is just as + // unrecoverable once its report is lost, because the marker it read is + // superseded by the next push. Both markers are emitted under their + // own presence guard, so neither rides a round that has none. + // The REPORT-post give-up arm, not the push-retry give-up above it. + const giveUpArm = pushAndReportScript.slice( + pushAndReportScript.indexOf( + 'report post failed ${attempt} times for PR #${PR}; giving up', + ), + pushAndReportScript.indexOf("REPORT_POSTED}\" == 'true'"), + ); + expect(giveUpArm.length).toBeGreaterThan(200); + expect(giveUpArm).not.toContain('"${OUTCOME:-}" == \'fixed\''); + expect(giveUpArm).toContain( + '[[ -n "${PUSHED_HEAD:-}" || -n "${REGRESSED_ROUND:-}" ]]', + ); + expect(giveUpArm).toContain( + '<!-- autofix-push round=${NEXT_ROUND} head=${PUSHED_HEAD} pre=${PUSH_PRE} key=${WINDOW:-none} -->', + ); + expect(giveUpArm).toContain( + '<!-- autofix-regression round=${REGRESSED_ROUND} key=${WINDOW:-none} -->', + ); + // Each marker under its own presence guard, so neither rides a round + // that has none. + expect( + giveUpArm.indexOf('if [[ -n "${PUSHED_HEAD:-}" ]]; then'), + ).toBeLessThan(giveUpArm.indexOf('autofix-push round=')); + expect( + giveUpArm.indexOf('if [[ -n "${REGRESSED_ROUND:-}" ]]; then'), + ).toBeLessThan(giveUpArm.indexOf('autofix-regression round=')); + // R31-2: the fallback note also carries the eval marker, in both arms + // — round numbering reads autofix-eval only, so a fallback-posted + // round that consumes no number makes the NEXT round reuse it, and + // the brake's regression walk then charges two rounds as one. + expect(giveUpArm).toContain( + '<!-- autofix-eval ts=${NEWEST} acted=true round=${NEXT_ROUND} win=${WINDOW:-none} -->', + ); + expect(giveUpArm).toContain( + '<!-- autofix-eval ts=${NEWEST} acted=false round=${ROUND} win=${WINDOW:-none} -->', + ); + // The emitter's headline shape and the brake's reader agree on where + // the round number sits. + expect(pushAndReportScript).toContain( + 'Addressed the latest review feedback (round ${NEXT_ROUND}/${MAX_ROUNDS})', + ); + expect(workflow).toContain('[[ "${H}" =~ \\(round\\ ([0-9]+)/ ]]'); + expect( + '🤖 Addressed the latest review feedback (round 12/100). What changed'.match( + /\(round ([0-9]+)\//, + )?.[1], + ).toBe('12'); + expect(pushAndReportScript).toContain('PUSHED_HEAD="$(git rev-parse HEAD'); + // headRefOid rides the same read as the rollup, so the classifier can + // bind its verdict to the commit the rollup describes. + expect(prepareBranchAndFeedbackStep).toContain( + '--json statusCheckRollup,headRefOid', + ); + expect(prepareBranchAndFeedbackStep).toContain( + 'ROLLUP_HEAD="$(jq -r \'.headRefOid // ""\' <<< "${PR_ROLLUP}"', + ); + // Two emitters: the report, and the marker-only fallback for a report + // that could not be posted. + expect( + (pushAndReportScript.match(/<!-- autofix-push /g) ?? []).length, + ).toBe(2); + // The OBSERVATION rides every report shape: a run of failures after a + // regressing push must not lose the record. + expect(reviewAddressReportStep).toContain( + '<!-- autofix-regression round=${REGRESSED_ROUND} key=${WINDOW:-none} -->', + ); + // The marker-only fallback carries the regression observation too: + // the report that would have carried it is the one that failed. + expect(pushAndReportScript).toMatch( + /<!-- autofix-push round=\$\{NEXT_ROUND\} head=\$\{PUSHED_HEAD\} pre=\$\{PUSH_PRE\} key=\$\{WINDOW:-none\} -->"\n[\s\S]{0,500}?<!-- autofix-regression round=\$\{REGRESSED_ROUND\} key=\$\{WINDOW:-none\} -->"[\s\S]{0,200}?\} > "\$\{WORKDIR\}\/push-marker\.md"/, + ); + expect( + (pushAndReportScript.match(/<!-- autofix-regression round=/g) ?? []) + .length, + ).toBe(3); + // The visible disclosure rides beside the marker on every report shape + // — pushed, no-op and failure — not only the pushed one. + expect( + (pushAndReportScript.match(/Regression charged to round/g) ?? []).length, + ).toBe(2); + expect(reviewAddressReportStep).toContain('Regression charged to round'); + // Both report steps receive the charge; only the push step (the marker + // emitter) reads the head state. + expect( + ( + workflow.match( + /REGRESSED_ROUND: '\$\{\{ steps\.prepare\.outputs\.regressed_round \}\}'/g, + ) ?? [] + ).length, + ).toBe(2); + expect( + ( + workflow.match( + /CHECK_STATE: '\$\{\{ steps\.prepare\.outputs\.check_state \}\}'/g, + ) ?? [] + ).length, + ).toBe(1); + }); }); describe('review verification gate: preexisting output is consumed', () => { @@ -26052,7 +30802,7 @@ describe('report-step stale-base hold while review-pr is in flight (#10110)', () // The scan's dispatch gate (#8888) covers every push the SCAN can make, // but the report step's stale-base retry calls update-branch hours after // that gate last looked — the one loop-owned head move outside the hold. - // Full rationale → qwen-autofix.md#af-155. + // Full rationale → qwen-autofix.md#af-156. it('probes for a live review before the report-step update-branch', () => { const probeAt = reviewAddressReportStep.indexOf('REVIEW_LIVE_R='); @@ -26065,7 +30815,7 @@ describe('report-step stale-base hold while review-pr is in flight (#10110)', () // started review-pr check) and the runs-API fallback for lifecycle runs // still parked in the delay window with no check-run yet. The probe is // lifecycle-scoped by construction — a command run carries no PR-head - // check and the runs fallback is event-scoped (af-155). + // check and the runs fallback is event-scoped (af-156). expect(reviewAddressReportStep).toContain('<<< "${ROLLUP_R}"'); expect(reviewAddressReportStep).toContain( 'actions/workflows/qwen-code-pr-review.yml',