mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-21 06:35:07 +00:00
chore(review): merge main — resolve presubmit import conflict
Upstream #9461 moved the ledger-id shape regex from a presubmit-local constant into lib/ledger.js; the overlap with the a1-backing imports is mechanical — both imports kept, no behavior change on either path.
This commit is contained in:
commit
17c062dbd2
42 changed files with 7947 additions and 272 deletions
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
|
|
@ -9,6 +9,9 @@
|
|||
/.github/workflows/release.yml @pomelo-nwu @wenshao
|
||||
/.github/workflows/finalize-release.yml @pomelo-nwu @wenshao
|
||||
|
||||
# --- Security gate workflows require core maintainer approval ---
|
||||
/.github/workflows/security-checks.yml @pomelo-nwu @wenshao
|
||||
|
||||
# --- Core package ---
|
||||
/packages/core/ @wenshao @tanzhenxin @yiliang114 @LaZzyMan @doudouOUC
|
||||
|
||||
|
|
|
|||
12
.github/workflows/finalize-release.yml
vendored
12
.github/workflows/finalize-release.yml
vendored
|
|
@ -53,6 +53,7 @@ jobs:
|
|||
token: '${{ secrets.CI_BOT_PAT }}'
|
||||
ref: '${{ steps.meta.outputs.release_branch }}'
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
if: |-
|
||||
|
|
@ -68,9 +69,13 @@ jobs:
|
|||
${{ steps.meta.outputs.is_stable == 'true' }}
|
||||
env:
|
||||
NPM_CONFIG_PREFER_OFFLINE: 'true'
|
||||
QWEN_SKIP_PREPARE: '1'
|
||||
run: |-
|
||||
npm ci --no-audit --progress=false
|
||||
npm ci --ignore-scripts --no-audit --progress=false
|
||||
# Replay the root postinstall (patch-package) and commit-info
|
||||
# generation; dependency and workspace lifecycle scripts remain
|
||||
# disabled.
|
||||
npm run postinstall
|
||||
npm run generate
|
||||
|
||||
- name: 'Configure Git User'
|
||||
if: |-
|
||||
|
|
@ -182,11 +187,12 @@ jobs:
|
|||
if: |-
|
||||
${{ steps.meta.outputs.is_stable == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'
|
||||
BRANCH_NAME: '${{ steps.meta.outputs.release_branch }}'
|
||||
RELEASE_TAG: '${{ env.RELEASE_TAG }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
gh auth setup-git
|
||||
node scripts/generate-changelog.js
|
||||
git add CHANGELOG.md
|
||||
if git diff --cached --quiet -- CHANGELOG.md; then
|
||||
|
|
|
|||
120
.github/workflows/qwen-code-pr-review.yml
vendored
120
.github/workflows/qwen-code-pr-review.yml
vendored
|
|
@ -1756,6 +1756,65 @@ jobs:
|
|||
echo "Fallback comment dedup lookup failed; deferring to the fallback-comment job." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
# Same guard as the fallback-comment job's, for the same reason: a
|
||||
# review posted moments before this step runs makes every body
|
||||
# below — each one ending in a retry instruction — contradict the
|
||||
# review already on the PR. Scoped to the bot's own account and to a
|
||||
# submission at or after this run was CREATED, so a stale review from
|
||||
# an earlier run cannot silence a genuinely dead one; an unavailable
|
||||
# creation time declines to fire and posts.
|
||||
# What a match proves, exactly: a bot review of this PR was
|
||||
# submitted while this run was alive. It is deliberately NOT keyed on
|
||||
# the reviewed head. Two revisions of this guard were, and the head
|
||||
# is not a stable attribute of a run: a push moves the PR's head
|
||||
# between the post and this step, and a re-run recomputes the
|
||||
# reviewed head from a later attempt — in both, THIS run's own review
|
||||
# stops matching and the contradictory comment ships. The window is
|
||||
# anchored on `createdAt`, not `startedAt`, against the same class of
|
||||
# drift: re-running a failed job keeps the run id (the dedup above
|
||||
# relies on that) while run-level `startedAt` moves to the
|
||||
# re-executed attempt — measured on runs 32219268680 (created
|
||||
# 05:23:57Z, startedAt 05:51:26Z) and 32218596441 (05:13:04Z →
|
||||
# 05:22:05Z).
|
||||
#
|
||||
# Under this workflow's per-run concurrency an overlapping run's
|
||||
# review can also fall inside the window, and then this run's failure
|
||||
# goes unannounced. Accepted: that silence coincides with a bot
|
||||
# review of this PR a reader can see, which is exactly the state that
|
||||
# makes this comment's claim false. What the bot-author and
|
||||
# creation-time clauses rule out is silence with NO review at all.
|
||||
#
|
||||
# The account is not this pipeline's alone: finalize-release.yml,
|
||||
# qwen-triage-finalize.yml, and the triage skill all post approvals
|
||||
# under it. Excluding those bodies by name cannot be finished — it
|
||||
# shipped missing one ("LGTM, looks ready to ship. ✅"), and any
|
||||
# producer rewording fails in the dangerous direction: a foreign
|
||||
# LGTM buys silence for a genuinely dead run. So the filter matches
|
||||
# positively on what only this pipeline's composed reviews carry:
|
||||
# every composed body ends in the "via Qwen Code /review"
|
||||
# attribution footer or carries the invisible qwen-review-ledger
|
||||
# marker — at least one rides every body, a zero-findings APPROVE
|
||||
# included — and no foreign approval carries either. A marker that
|
||||
# ever changes shape stops the guard firing and the comment posts:
|
||||
# the pre-guard status quo, not a masked dead run.
|
||||
run_created="$(gh run view "${GITHUB_RUN_ID:?}" --repo "$GITHUB_REPOSITORY" --json createdAt --jq '.createdAt' 2>/dev/null)" || run_created=""
|
||||
posted_reviews=""
|
||||
# Three outcomes, and the guard must not be silent about the third:
|
||||
# a lookup that DIED degrades to the false comment this whole change
|
||||
# removes, and an oncall reading the log could not tell it from "no
|
||||
# review matched". Every sibling lookup in this step announces its
|
||||
# failures; this one says so too, then posts.
|
||||
if [ -z "$run_created" ]; then
|
||||
echo "::warning::already-posted guard unavailable (no run creation time); posting the fallback comment"
|
||||
echo "Already-posted guard unavailable (run creation time missing); proceeding to post." >> "$GITHUB_STEP_SUMMARY"
|
||||
elif ! posted_reviews="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" --paginate \
|
||||
--jq ".[] | select(.user.login == \"$bot_login\") | select(.submitted_at >= \"$run_created\") | select((.body // \"\") | contains(\"via Qwen Code /review\") or contains(\"qwen-review-ledger\")) | .id" 2>/dev/null)"; then
|
||||
echo "::warning::already-posted guard unavailable (reviews listing failed); posting the fallback comment"
|
||||
echo "Already-posted guard unavailable (reviews listing failed); proceeding to post." >> "$GITHUB_STEP_SUMMARY"
|
||||
elif [ -n "$posted_reviews" ]; then
|
||||
echo "Skipping fallback comment: a bot review of this PR was submitted after this run was created." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
MAX_TIMEOUT_MINUTES="${{ vars.QWEN_REVIEW_MAX_TIMEOUT_MINUTES }}"
|
||||
if [ "$FAILURE_KIND" = "timeout" ]; then
|
||||
if [ "$TIMEOUT_MINUTES" -lt "$MAX_TIMEOUT_MINUTES" ]; then
|
||||
|
|
@ -1944,6 +2003,67 @@ jobs:
|
|||
echo "Skipping fallback comment: PR #${PR_NUMBER} is ${pr_state}." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
# A run that DID post its review must not be announced as one that
|
||||
# could not. The review job can fail AFTER the post — the CLI exiting
|
||||
# silently, a cleanup step dying — and this body's claim ("failed
|
||||
# before a review could be posted"), with its retry instruction, then
|
||||
# contradicts the review sitting right above it. Measured on PR
|
||||
# #9342: the review posted at 11:56:34Z, the job failed at 12:00:53Z,
|
||||
# and this comment landed at 12:01:00Z asking for a fresh ~3-hour
|
||||
# review; the autofix takeover loop reads the same feed a human does.
|
||||
#
|
||||
# What a match proves, exactly: a bot review of this PR was
|
||||
# submitted while this run was alive. It is deliberately NOT keyed on
|
||||
# the reviewed head. Two revisions of this guard were, and the head
|
||||
# is not a stable attribute of a run: a push moves the PR's head
|
||||
# between the post and this step, and a re-run recomputes the
|
||||
# reviewed head from a later attempt — in both, THIS run's own review
|
||||
# stops matching and the contradictory comment ships. The window is
|
||||
# anchored on `createdAt`, not `startedAt`, against the same class of
|
||||
# drift: re-running a failed job keeps the run id (the dedup above
|
||||
# relies on that) while run-level `startedAt` moves to the
|
||||
# re-executed attempt — measured on runs 32219268680 (created
|
||||
# 05:23:57Z, startedAt 05:51:26Z) and 32218596441 (05:13:04Z →
|
||||
# 05:22:05Z).
|
||||
#
|
||||
# Under this workflow's per-run concurrency an overlapping run's
|
||||
# review can also fall inside the window, and then this run's failure
|
||||
# goes unannounced. Accepted: that silence coincides with a bot
|
||||
# review of this PR a reader can see, which is exactly the state that
|
||||
# makes this comment's claim false. What the bot-author and
|
||||
# creation-time clauses rule out is silence with NO review at all.
|
||||
#
|
||||
# The account is not this pipeline's alone: finalize-release.yml,
|
||||
# qwen-triage-finalize.yml, and the triage skill all post approvals
|
||||
# under it. Excluding those bodies by name cannot be finished — it
|
||||
# shipped missing one ("LGTM, looks ready to ship. ✅"), and any
|
||||
# producer rewording fails in the dangerous direction: a foreign
|
||||
# LGTM buys silence for a genuinely dead run. So the filter matches
|
||||
# positively on what only this pipeline's composed reviews carry:
|
||||
# every composed body ends in the "via Qwen Code /review"
|
||||
# attribution footer or carries the invisible qwen-review-ledger
|
||||
# marker — at least one rides every body, a zero-findings APPROVE
|
||||
# included — and no foreign approval carries either. A marker that
|
||||
# ever changes shape stops the guard firing and the comment posts:
|
||||
# the pre-guard status quo, not a masked dead run.
|
||||
run_created="$(gh run view "${GITHUB_RUN_ID:?}" --repo "$GITHUB_REPOSITORY" --json createdAt --jq '.createdAt' 2>/dev/null)" || run_created=""
|
||||
posted_reviews=""
|
||||
# Three outcomes, and the guard must not be silent about the third:
|
||||
# a lookup that DIED degrades to the false comment this whole change
|
||||
# removes, and an oncall reading the log could not tell it from "no
|
||||
# review matched". Every sibling lookup in this step announces its
|
||||
# failures; this one says so too, then posts.
|
||||
if [ -z "$run_created" ]; then
|
||||
echo "::warning::already-posted guard unavailable (no run creation time); posting the fallback comment"
|
||||
echo "Already-posted guard unavailable (run creation time missing); proceeding to post." >> "$GITHUB_STEP_SUMMARY"
|
||||
elif ! posted_reviews="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" --paginate \
|
||||
--jq ".[] | select(.user.login == \"$bot_login\") | select(.submitted_at >= \"$run_created\") | select((.body // \"\") | contains(\"via Qwen Code /review\") or contains(\"qwen-review-ledger\")) | .id" 2>/dev/null)"; then
|
||||
echo "::warning::already-posted guard unavailable (reviews listing failed); posting the fallback comment"
|
||||
echo "Already-posted guard unavailable (reviews listing failed); proceeding to post." >> "$GITHUB_STEP_SUMMARY"
|
||||
elif [ -n "$posted_reviews" ]; then
|
||||
echo "Skipping fallback comment: a bot review of this PR was submitted after this run was created." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
body="**Qwen Code review did not complete successfully.** The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with \`@qwen-code /review\`. See [workflow logs](${RUN_URL})."
|
||||
body="$(printf '%s\n\n%s' "$FALLBACK_MARKER" "$body")"
|
||||
gh pr comment "$PR_NUMBER" \
|
||||
|
|
|
|||
54
.github/workflows/release.yml
vendored
54
.github/workflows/release.yml
vendored
|
|
@ -98,9 +98,13 @@ jobs:
|
|||
- name: 'Install Dependencies'
|
||||
env:
|
||||
NPM_CONFIG_PREFER_OFFLINE: 'true'
|
||||
QWEN_SKIP_PREPARE: '1'
|
||||
run: |-
|
||||
npm ci --no-audit --progress=false
|
||||
npm ci --ignore-scripts --no-audit --progress=false
|
||||
# Replay the root postinstall (patch-package) and commit-info
|
||||
# generation; dependency and workspace lifecycle scripts remain
|
||||
# disabled.
|
||||
npm run postinstall
|
||||
npm run generate
|
||||
|
||||
- name: 'Get the version'
|
||||
id: 'version'
|
||||
|
|
@ -169,9 +173,13 @@ jobs:
|
|||
- name: 'Install Dependencies'
|
||||
env:
|
||||
NPM_CONFIG_PREFER_OFFLINE: 'true'
|
||||
QWEN_SKIP_PREPARE: '1'
|
||||
run: |-
|
||||
npm ci --no-audit --progress=false
|
||||
npm ci --ignore-scripts --no-audit --progress=false
|
||||
# Replay the root postinstall (patch-package) and commit-info
|
||||
# generation; dependency and workspace lifecycle scripts remain
|
||||
# disabled.
|
||||
npm run postinstall
|
||||
npm run generate
|
||||
|
||||
- name: 'Format Project'
|
||||
run: |-
|
||||
|
|
@ -227,9 +235,13 @@ jobs:
|
|||
- name: 'Install Dependencies'
|
||||
env:
|
||||
NPM_CONFIG_PREFER_OFFLINE: 'true'
|
||||
QWEN_SKIP_PREPARE: '1'
|
||||
run: |-
|
||||
npm ci --no-audit --progress=false
|
||||
npm ci --ignore-scripts --no-audit --progress=false
|
||||
# Replay the root postinstall (patch-package) and commit-info
|
||||
# generation; dependency and workspace lifecycle scripts remain
|
||||
# disabled.
|
||||
npm run postinstall
|
||||
npm run generate
|
||||
|
||||
- name: 'Build Bundle'
|
||||
run: |-
|
||||
|
|
@ -274,9 +286,13 @@ jobs:
|
|||
- name: 'Install Dependencies'
|
||||
env:
|
||||
NPM_CONFIG_PREFER_OFFLINE: 'true'
|
||||
QWEN_SKIP_PREPARE: '1'
|
||||
run: |-
|
||||
npm ci --no-audit --progress=false
|
||||
npm ci --ignore-scripts --no-audit --progress=false
|
||||
# Replay the root postinstall (patch-package) and commit-info
|
||||
# generation; dependency and workspace lifecycle scripts remain
|
||||
# disabled.
|
||||
npm run postinstall
|
||||
npm run generate
|
||||
|
||||
- name: 'Build Bundle'
|
||||
run: |-
|
||||
|
|
@ -371,11 +387,10 @@ jobs:
|
|||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
# Persist the bot PAT for release-branch pushes so downstream CI
|
||||
# workflows are triggered.
|
||||
token: '${{ secrets.CI_BOT_PAT }}'
|
||||
ref: '${{ github.event.inputs.ref || github.sha }}'
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
|
|
@ -392,9 +407,13 @@ jobs:
|
|||
- name: 'Install Dependencies'
|
||||
env:
|
||||
NPM_CONFIG_PREFER_OFFLINE: 'true'
|
||||
QWEN_SKIP_PREPARE: '1'
|
||||
run: |-
|
||||
npm ci --no-audit --progress=false
|
||||
npm ci --ignore-scripts --no-audit --progress=false
|
||||
# Replay the root postinstall (patch-package) and commit-info
|
||||
# generation; dependency and workspace lifecycle scripts remain
|
||||
# disabled.
|
||||
npm run postinstall
|
||||
npm run generate
|
||||
|
||||
- name: 'Configure Git User'
|
||||
run: |-
|
||||
|
|
@ -425,6 +444,7 @@ jobs:
|
|||
IS_DRY_RUN: '${{ needs.prepare.outputs.is_dry_run }}'
|
||||
RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}'
|
||||
RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'
|
||||
CI_BOT_PAT: '${{ secrets.CI_BOT_PAT }}'
|
||||
run: |-
|
||||
git add package.json package-lock.json packages/*/package.json packages/channels/*/package.json integrations/external-context/package.json
|
||||
if git diff --staged --quiet; then
|
||||
|
|
@ -433,6 +453,16 @@ jobs:
|
|||
git commit -m "chore(release): ${RELEASE_TAG}"
|
||||
fi
|
||||
if [[ "${IS_DRY_RUN}" == "false" ]]; then
|
||||
# Restore the bot PAT in this step rather than persisting it in
|
||||
# git credentials at checkout, so dependency installation and
|
||||
# build tooling in earlier steps cannot read write credentials.
|
||||
# The push itself needs the PAT (not the job token): pushes made
|
||||
# with GITHUB_TOKEN do not trigger downstream CI workflows.
|
||||
# Export (not inline) so GH_TOKEN is still set when `git push`
|
||||
# invokes the credential helper, which re-resolves the token at
|
||||
# push time rather than at `gh auth setup-git` time.
|
||||
export GH_TOKEN="${CI_BOT_PAT}"
|
||||
gh auth setup-git
|
||||
# The guard runs scripts/get-release-version.js from the
|
||||
# checked-out ref — the operator-controlled dispatch input `ref`
|
||||
# — not the branch this workflow file came from. A ref predating
|
||||
|
|
|
|||
|
|
@ -29,9 +29,11 @@ import {
|
|||
import { getGhHost, setGhHost } from './lib/gh.js';
|
||||
import { BRIEFS } from './lib/agent-briefs.js';
|
||||
import {
|
||||
LEDGER_MAX_FILE,
|
||||
LEDGER_MAX_ROUND,
|
||||
LEDGER_MAX_VOLUME,
|
||||
parseLedger,
|
||||
serializeLedger,
|
||||
} from './lib/ledger.js';
|
||||
import { countInlineFindings } from './lib/inline-counts.js';
|
||||
import {
|
||||
|
|
@ -2665,6 +2667,55 @@ describe('composeReviewCommand handler (the CLI glue)', () => {
|
|||
expect(stderrHasOverride).toBe(false);
|
||||
});
|
||||
|
||||
it('prints the convergence paragraph the trim notice points at', async () => {
|
||||
// `noteTrimmedRanks` tells the author the shed sections "still hold —
|
||||
// read them in the terminal report", and rank 0 is the first thing the
|
||||
// ladder sheds. Without this line that promise names nothing.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'compose-convergence-'));
|
||||
const inputPath = join(dir, 'compose.json');
|
||||
const commentsPath = join(dir, 'comments.json');
|
||||
const planPath = join(dir, 'plan.json');
|
||||
writeFileSync(planPath, JSON.stringify({ prNumber: 8255 }), 'utf8');
|
||||
writeFileSync(
|
||||
inputPath,
|
||||
JSON.stringify({ modelId: MODEL, planPath }),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(
|
||||
commentsPath,
|
||||
JSON.stringify([
|
||||
{ path: 'src/a.ts', line: 3, body: '**[Suggestion]** again' },
|
||||
]),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, 'qwen-review-pr-8255-prev-ledger.json'),
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
round: 4,
|
||||
posted: 9,
|
||||
fresh: 9,
|
||||
findings: [{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
try {
|
||||
(writeStderrLine as ReturnType<typeof vi.fn>).mockClear();
|
||||
await runComposeReviewCommand({
|
||||
input: inputPath,
|
||||
comments: commentsPath,
|
||||
});
|
||||
const lines = (
|
||||
writeStderrLine as ReturnType<typeof vi.fn>
|
||||
).mock.calls.map((c) => String(c[0]));
|
||||
expect(lines.some((l) => l.startsWith('CONVERGENCE: Convergence:'))).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('states the round volume on stderr, with and without a predecessor', async () => {
|
||||
// The line is the operator's only view of this round's contribution to
|
||||
// the PR's comment volume; both branches of its ternary are
|
||||
|
|
@ -2692,13 +2743,17 @@ describe('composeReviewCommand handler (the CLI glue)', () => {
|
|||
String(c[0]),
|
||||
);
|
||||
try {
|
||||
// No side file: no predecessor, so the line carries no parenthetical.
|
||||
// No side file: no predecessor, so the line carries no PREVIOUS-round
|
||||
// parenthetical. The fresh-count one rides on every line — it is a
|
||||
// fact about this round, not about a comparison.
|
||||
(writeStderrLine as ReturnType<typeof vi.fn>).mockClear();
|
||||
await runComposeReviewCommand({
|
||||
input: inputPath,
|
||||
comments: commentsPath,
|
||||
});
|
||||
expect(stderr()).toContain('VOLUME: 2 inline comment(s) this round');
|
||||
expect(stderr()).toContain(
|
||||
'VOLUME: 2 inline comment(s) this round (2 reported for the first time)',
|
||||
);
|
||||
|
||||
// With a recorded predecessor the previous round rides along.
|
||||
(writeStderrLine as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
|
@ -2712,7 +2767,7 @@ describe('composeReviewCommand handler (the CLI glue)', () => {
|
|||
comments: commentsPath,
|
||||
});
|
||||
expect(stderr()).toContain(
|
||||
'VOLUME: 2 inline comment(s) this round (previous round: 9)',
|
||||
'VOLUME: 2 inline comment(s) this round (2 reported for the first time) (previous round: 9)',
|
||||
);
|
||||
|
||||
// A CONVERGED predecessor: zero is a recorded value, not an absence.
|
||||
|
|
@ -2730,7 +2785,7 @@ describe('composeReviewCommand handler (the CLI glue)', () => {
|
|||
comments: commentsPath,
|
||||
});
|
||||
expect(stderr()).toContain(
|
||||
'VOLUME: 2 inline comment(s) this round (previous round: 0)',
|
||||
'VOLUME: 2 inline comment(s) this round (2 reported for the first time) (previous round: 0)',
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
|
|
@ -4230,6 +4285,7 @@ describe('verdictLine — the terminal verdict, and its dangling colon', () => {
|
|||
downgraded: false,
|
||||
floorEnforced: [],
|
||||
postedInline: 0,
|
||||
postedFresh: 0,
|
||||
downgradedFrom: null,
|
||||
remediation: [],
|
||||
deferredCount: 0,
|
||||
|
|
@ -5353,6 +5409,39 @@ describe('buildLedger', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('flags the real file spelled like a stand-in, not the stand-in', () => {
|
||||
// The flag marks the EXCEPTION, so the routine stand-ins cost the marker
|
||||
// no bytes — it rides through every rung of the shed cascade, where the
|
||||
// serializer prices telemetry at a lost anchor or a lost ruling — and a
|
||||
// marker written before the flag existed still reads correctly, because
|
||||
// its unflagged stand-ins are stand-ins.
|
||||
const standIn = buildLedger(
|
||||
2,
|
||||
[{ line: 3, body: '**[Suggestion]** arrived without a path' }],
|
||||
[],
|
||||
);
|
||||
expect(standIn.findings).toEqual([
|
||||
{
|
||||
id: 'R2-1',
|
||||
sev: 'S',
|
||||
file: '(unknown)',
|
||||
line: 3,
|
||||
title: 'arrived without a path',
|
||||
},
|
||||
]);
|
||||
// A REAL file of either stand-in name is flagged, so it is not mistaken
|
||||
// for one — and the flagged entry keeps the path it names.
|
||||
for (const name of ['(unknown)', '(body)']) {
|
||||
const real = buildLedger(
|
||||
2,
|
||||
[{ path: name, line: 3, body: '**[Suggestion]** a real file' }],
|
||||
[],
|
||||
);
|
||||
expect(real.findings[0].k).toBe(1);
|
||||
expect(real.findings[0].file).toBe(name);
|
||||
}
|
||||
});
|
||||
|
||||
it('classifies through `severityOf`, whitespace and all', () => {
|
||||
// The ledger restated the severity predicate as a bare `startsWith`, while
|
||||
// `countInlineFindings` — the count the VERDICT is computed from — trims
|
||||
|
|
@ -8967,3 +9056,782 @@ describe('convergence telemetry — volume, carried in the marker', () => {
|
|||
expect(l.posted).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convergence diagnosis reaches the POSTED body', () => {
|
||||
// The module is unit-tested next door; these go through composeReview,
|
||||
// the path GitHub receives, because a diagnosis that never reaches the
|
||||
// body is a diagnosis nobody reads.
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'diagnosis-e2e-'));
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
const plan = () => {
|
||||
const p = join(dir, 'plan.json');
|
||||
writeFileSync(p, JSON.stringify({ prNumber: 8255 }));
|
||||
return p;
|
||||
};
|
||||
const sideFile = (prev: Record<string, unknown>) =>
|
||||
writeFileSync(
|
||||
join(dir, 'qwen-review-pr-8255-prev-ledger.json'),
|
||||
JSON.stringify({ v: 1, findings: [], ...prev }),
|
||||
);
|
||||
|
||||
it('renders the observation when findings keep returning to one file', () => {
|
||||
sideFile({
|
||||
round: 4,
|
||||
posted: 9,
|
||||
findings: [
|
||||
{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' },
|
||||
{ id: 'R4-2', sev: 'S', file: 'src/a.ts', title: 'y' },
|
||||
],
|
||||
});
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 2,
|
||||
draftedComments: [
|
||||
{ path: 'src/a.ts', line: 1, body: '**[Suggestion]** again' },
|
||||
{ path: 'src/a.ts', line: 9, body: '**[Suggestion]** and again' },
|
||||
],
|
||||
});
|
||||
expect(r.body).toContain('Convergence:');
|
||||
expect(r.body).toContain(
|
||||
'`src/a.ts` (findings in rounds 2, 4, 2 more now)',
|
||||
);
|
||||
// An observation, not a gate: the verdict and its caps are untouched.
|
||||
expect(r.cappedBy).not.toContain('convergence');
|
||||
expect(r.body).toContain('nothing was withheld');
|
||||
});
|
||||
|
||||
it('stays silent on a healthy round', () => {
|
||||
sideFile({
|
||||
round: 4,
|
||||
posted: 9,
|
||||
findings: [{ id: 'R4-1', sev: 'S', file: 'src/old.ts', title: 'x' }],
|
||||
});
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 1,
|
||||
draftedComments: [
|
||||
{ path: 'src/new.ts', line: 1, body: '**[Suggestion]** unrelated' },
|
||||
],
|
||||
});
|
||||
expect(r.body).not.toContain('Convergence:');
|
||||
});
|
||||
|
||||
it('counts the POST-enforcement set, like every other volume surface', () => {
|
||||
// The floor moves both Suggestions out of the posting set, so the round
|
||||
// posts one comment — and the diagnosis must describe that number, not
|
||||
// the drafts, or the paragraph disagrees with the PR it sits on.
|
||||
sideFile({ round: 5, posted: 1, fresh: 1, floor: 'c', findings: [] });
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
severityFloor: 'critical',
|
||||
criticalsInline: 1,
|
||||
suggestionsInline: 2,
|
||||
draftedComments: [
|
||||
{ path: 'a.ts', line: 1, body: '**[Critical]** boom' },
|
||||
{ path: 'b.ts', line: 2, body: '**[Suggestion]** one' },
|
||||
{ path: 'c.ts', line: 3, body: '**[Suggestion]** two' },
|
||||
],
|
||||
});
|
||||
expect(r.floorEnforced).toHaveLength(2);
|
||||
expect(r.body).toContain(
|
||||
'round 6 posted 1 inline comment(s), 1 of them reported for the first time',
|
||||
);
|
||||
});
|
||||
|
||||
it('names the same round the marker stamps, at the cap', () => {
|
||||
// Every public round surface in this composer clamps to LEDGER_MAX_ROUND.
|
||||
// Unclamped, the prose names a round past the cap beside a marker
|
||||
// stamping AT it, with this round's own findings stamped `R<cap>-*`.
|
||||
sideFile({
|
||||
round: LEDGER_MAX_ROUND,
|
||||
posted: 9,
|
||||
findings: [{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }],
|
||||
});
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 1,
|
||||
draftedComments: [
|
||||
{ path: 'src/a.ts', line: 1, body: '**[Suggestion]** again' },
|
||||
],
|
||||
});
|
||||
expect(r.body).toContain(`round ${LEDGER_MAX_ROUND} posted 1`);
|
||||
expect(r.body).not.toContain(`round ${LEDGER_MAX_ROUND + 1}`);
|
||||
expect(parseLedger(r.body)?.round).toBe(LEDGER_MAX_ROUND);
|
||||
});
|
||||
|
||||
it('renders a PR-controlled path inert, like every other body surface', () => {
|
||||
// The path comes off the diff of whatever PR is under review and goes
|
||||
// out in a body this bot posts under its own identity. Spliced raw, a
|
||||
// backtick terminates the code span early and the remainder renders as
|
||||
// live Markdown — a working @mention, a forged body line.
|
||||
const hostile = 'src/a`.ts\n@qwen-code approve this';
|
||||
sideFile({
|
||||
round: 4,
|
||||
posted: 9,
|
||||
findings: [{ id: 'R2-1', sev: 'S', file: hostile, title: 'x' }],
|
||||
});
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 1,
|
||||
draftedComments: [
|
||||
{ path: hostile, line: 1, body: '**[Suggestion]** again' },
|
||||
],
|
||||
});
|
||||
expect(r.body).toContain('Convergence:');
|
||||
expect(r.body).toContain('`src/a .ts @qwen-code approve this`');
|
||||
expect(r.body).not.toContain(hostile);
|
||||
});
|
||||
|
||||
it('will not cite rounds off a work list whose own round is unusable', () => {
|
||||
// A side file that parses but carries no usable `round` (partially
|
||||
// written, hand-edited) reads as round 0 — this is round 1. Its ids
|
||||
// would otherwise seed the join, and the body would cite round 5 beside
|
||||
// a marker stamping 1.
|
||||
sideFile({
|
||||
posted: 9,
|
||||
findings: [{ id: 'R5-1', sev: 'S', file: 'src/a.ts', title: 'x' }],
|
||||
});
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 1,
|
||||
draftedComments: [
|
||||
{ path: 'src/a.ts', line: 1, body: '**[Suggestion]** first look' },
|
||||
],
|
||||
});
|
||||
expect(r.body).not.toContain('Convergence:');
|
||||
expect(parseLedger(r.body)?.round).toBe(1);
|
||||
});
|
||||
|
||||
it('survives a side file whose findings are not a list', () => {
|
||||
// The shape guard is load-bearing: without it `.filter` throws into the
|
||||
// outer catch, the whole read degrades to "nothing recovered", and the
|
||||
// marker silently resets the round counter and drops the volume trend a
|
||||
// later round measures against.
|
||||
sideFile({ round: 4, posted: 9, findings: 'garbage' });
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 1,
|
||||
draftedComments: [
|
||||
{ path: 'src/a.ts', line: 1, body: '**[Suggestion]** one' },
|
||||
],
|
||||
});
|
||||
const marker = parseLedger(r.body);
|
||||
expect(marker?.round).toBe(5);
|
||||
expect(marker?.prevPosted).toBe(9);
|
||||
});
|
||||
|
||||
it('stays silent on a round that only re-posts what is still standing', () => {
|
||||
// Step 6 re-posts every unfixed ledger Critical under its ORIGINAL id.
|
||||
// Counted as activity, one Critical nobody has fixed fires the cluster
|
||||
// and the flat-volume trend every round, forever — narrating divergence
|
||||
// at the steady state.
|
||||
sideFile({
|
||||
round: 2,
|
||||
posted: 1,
|
||||
findings: [{ id: 'R2-1', sev: 'C', file: 'src/p.ts', title: 'boom' }],
|
||||
});
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 1,
|
||||
suggestionsInline: 0,
|
||||
draftedComments: [
|
||||
{ path: 'src/p.ts', line: 1, body: '**[Critical]** R2-1: still open' },
|
||||
],
|
||||
});
|
||||
expect(r.body).not.toContain('Convergence:');
|
||||
});
|
||||
|
||||
it('joins on the same key the ledger stores, past the file cap', () => {
|
||||
// The ledger caps `file` at 200 chars on write and readback; an uncapped
|
||||
// drafted path can never equal a recovered entry past the cap, so the
|
||||
// signal would be permanently blind to deep vendor and generated trees.
|
||||
const deep = `src/${'nested/'.repeat(44)}leaf.ts`;
|
||||
expect(deep.length).toBeGreaterThan(LEDGER_MAX_FILE);
|
||||
sideFile({
|
||||
round: 4,
|
||||
posted: 9,
|
||||
findings: [
|
||||
{
|
||||
id: 'R2-1',
|
||||
sev: 'S',
|
||||
file: deep.slice(0, LEDGER_MAX_FILE),
|
||||
title: 'x',
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 1,
|
||||
draftedComments: [
|
||||
{ path: deep, line: 1, body: '**[Suggestion]** again' },
|
||||
],
|
||||
});
|
||||
expect(r.body).toContain('Convergence:');
|
||||
expect(r.body).toContain('findings in round 2, 1 more now');
|
||||
});
|
||||
|
||||
it('discloses a work list that was truncated or recovered from elsewhere', () => {
|
||||
sideFile({
|
||||
round: 4,
|
||||
posted: 9,
|
||||
dropped: 3,
|
||||
foreign: true,
|
||||
findings: [{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }],
|
||||
});
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 1,
|
||||
draftedComments: [
|
||||
{ path: 'src/a.ts', line: 1, body: '**[Suggestion]** again' },
|
||||
],
|
||||
});
|
||||
expect(r.body).toContain('may be an undercount');
|
||||
expect(r.body).toContain('a marker this account did not post');
|
||||
});
|
||||
|
||||
it('does not recommend the floor the round is already enforcing', () => {
|
||||
// The same body carries the floor-enforcement note. Telling the author
|
||||
// to drop to `--severity-floor critical` beside it is advice nobody
|
||||
// checked against the round it ships in.
|
||||
sideFile({ round: 5, posted: 1, fresh: 1, floor: 'c', findings: [] });
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
severityFloor: 'critical',
|
||||
criticalsInline: 1,
|
||||
suggestionsInline: 0,
|
||||
draftedComments: [{ path: 'a.ts', line: 1, body: '**[Critical]** boom' }],
|
||||
});
|
||||
expect(r.body).toContain('The rate of new findings is not falling.');
|
||||
expect(r.body).not.toContain('dropping this PR');
|
||||
expect(r.body).toContain('already at `--severity-floor critical`');
|
||||
});
|
||||
|
||||
it('reaches a REQUEST_CHANGES body — the verdict a diverging loop produces', () => {
|
||||
// The block is spliced into three separately-maintained clause lists,
|
||||
// and every sibling here that reaches a body reaches it as COMMENT —
|
||||
// several of them by a downgrade rather than by the coverage cap, but
|
||||
// COMMENT either way. REQUEST_CHANGES — unfixed Criticals, round after
|
||||
// round — is the feature's primary audience, and its copy of the list
|
||||
// was unasserted: deleting the splice left the whole suite green.
|
||||
const planPath = coveredPlan(['verify', 'reverse-audit'], {
|
||||
prNumber: 8255,
|
||||
fetchedSha: 'deadbeef00112233',
|
||||
});
|
||||
writeFileSync(
|
||||
join(dirname(planPath), 'qwen-review-pr-8255-prev-ledger.json'),
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
round: 4,
|
||||
posted: 9,
|
||||
fresh: 9,
|
||||
findings: [{ id: 'R2-1', sev: 'C', file: 'src/a.ts', title: 'x' }],
|
||||
}),
|
||||
);
|
||||
const r = composeReview({
|
||||
planPath,
|
||||
env: ENV,
|
||||
modelId: MODEL,
|
||||
criticalsInline: 1,
|
||||
suggestionsInline: 0,
|
||||
draftedComments: [
|
||||
{ path: 'src/a.ts', line: 1, body: '**[Critical]** a new one here' },
|
||||
],
|
||||
});
|
||||
expect(r.event).toBe('REQUEST_CHANGES');
|
||||
expect(r.body).toContain('Convergence:');
|
||||
});
|
||||
|
||||
it('yields the whole paragraph before any disclosure that qualifies the verdict', () => {
|
||||
// The rounds this fires on are the high-volume rounds most likely to
|
||||
// overflow, and the paragraph decides nothing — so it is the FIRST thing
|
||||
// the ladder sheds. Untagged it ranked with the blockers and outlived
|
||||
// the not-reviewed disclosures, which do qualify what was read.
|
||||
//
|
||||
// The blocker is sized so the ladder sheds rank 0 — the convergence
|
||||
// paragraph, which yields before every other rank — and stops. Shed
|
||||
// everything and the body is identical whichever order the ladder used,
|
||||
// so the order would have no guard at all, which is why this constant is
|
||||
// tuned rather than round. To retune after a body-copy change: raise it
|
||||
// until `Convergence:` disappears, and stop before `Not reviewed:` does.
|
||||
// The window is as wide as the paragraph itself.
|
||||
sideFile({
|
||||
round: 4,
|
||||
posted: 9,
|
||||
findings: [{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }],
|
||||
});
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 1,
|
||||
bodyCriticals: ['B'.repeat(55_850)],
|
||||
unreviewedDimensions: ['security'],
|
||||
draftedComments: [
|
||||
{ path: 'src/a.ts', line: 1, body: '**[Suggestion]** again' },
|
||||
],
|
||||
});
|
||||
expect(r.body.length).toBeLessThanOrEqual(65536);
|
||||
expect(r.body).not.toContain('Convergence:');
|
||||
// A lower-ranked disclosure outlives it: the ladder reached this far and
|
||||
// the paragraph went first.
|
||||
expect(r.body).toContain('Not reviewed:');
|
||||
// And the notice names what ACTUALLY went. Every notice surface keys on
|
||||
// the rank, so sharing a rank with the deferral list made a round that
|
||||
// shed only this paragraph announce a deferred-findings list that never
|
||||
// existed and point the author at artifact entries that do not exist.
|
||||
expect(r.body).toContain('the convergence observation');
|
||||
expect(r.body).not.toContain('the deferred-findings list');
|
||||
expect(r.body).not.toContain('findings artifact');
|
||||
expect(r.bodyTrim.deferralList).toBe(false);
|
||||
});
|
||||
|
||||
it('stamps the posting floor this round ran under beside its volume', () => {
|
||||
// The next round measures its volume against this one's. Without the
|
||||
// posture that produced it, a floor change reads as a loop that will not
|
||||
// settle — and the advice then recommends re-tightening a floor the
|
||||
// operator deliberately loosened.
|
||||
const open = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
severityFloor: 'suggestion',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 1,
|
||||
draftedComments: [
|
||||
{ path: 'a.ts', line: 1, body: '**[Suggestion]** one' },
|
||||
],
|
||||
});
|
||||
expect(parseLedger(open.body)?.floor).toBe('o');
|
||||
|
||||
// A state that named NO floor still records the resolved posture, folded
|
||||
// the way every consumer folds it (absent reads as `auto`, which
|
||||
// resolves determinately from the round and the context state).
|
||||
// Recording only a named floor left the guard blind under the default
|
||||
// configuration — where the posture genuinely transitions at round 6 —
|
||||
// so a real change read as loop divergence.
|
||||
const unknownEarly = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 1,
|
||||
draftedComments: [
|
||||
{ path: 'a.ts', line: 1, body: '**[Suggestion]** one' },
|
||||
],
|
||||
});
|
||||
expect(parseLedger(unknownEarly.body)?.floor).toBe('o');
|
||||
|
||||
sideFile({ round: 6, posted: 1, fresh: 1, floor: 'o', findings: [] });
|
||||
const unknownLate = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 1,
|
||||
suggestionsInline: 0,
|
||||
draftedComments: [{ path: 'a.ts', line: 1, body: '**[Critical]** boom' }],
|
||||
});
|
||||
expect(parseLedger(unknownLate.body)?.floor).toBe('c');
|
||||
|
||||
const critical = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
severityFloor: 'critical',
|
||||
criticalsInline: 1,
|
||||
suggestionsInline: 0,
|
||||
draftedComments: [{ path: 'a.ts', line: 1, body: '**[Critical]** boom' }],
|
||||
});
|
||||
expect(parseLedger(critical.body)?.floor).toBe('c');
|
||||
});
|
||||
|
||||
it('will not narrate a floor change as a loop that is not settling', () => {
|
||||
// The previous round ran under a critical floor and posted one comment;
|
||||
// the floor is restored and this round posts five. That jump is policy,
|
||||
// not loop behaviour.
|
||||
sideFile({ round: 7, posted: 1, fresh: 1, floor: 'c', findings: [] });
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
severityFloor: 'suggestion',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 5,
|
||||
draftedComments: Array.from({ length: 5 }, (_, i) => ({
|
||||
path: `f${i}.ts`,
|
||||
line: 1,
|
||||
body: `**[Suggestion]** ${i}`,
|
||||
})),
|
||||
});
|
||||
expect(r.body).not.toContain('Convergence:');
|
||||
});
|
||||
|
||||
it('will not cite a round off an id the marker path would refuse', () => {
|
||||
// The side file is the same untrusted shape as a marker, by a different
|
||||
// route: one written before the id hardening can still hold ` R9999-1`,
|
||||
// and `birthRound` trims before matching, so the round would be printed
|
||||
// verbatim in a body this account posts.
|
||||
sideFile({
|
||||
round: 4,
|
||||
posted: 9,
|
||||
fresh: 9,
|
||||
findings: [{ id: ' R9999-1', sev: 'S', file: 'src/a.ts', title: 'x' }],
|
||||
});
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 1,
|
||||
draftedComments: [
|
||||
{ path: 'src/a.ts', line: 1, body: '**[Suggestion]** again' },
|
||||
],
|
||||
});
|
||||
expect(r.body).not.toContain('Convergence:');
|
||||
expect(r.body).not.toContain('9999');
|
||||
});
|
||||
|
||||
it('leaves a terminal copy of the paragraph the ladder sheds first', () => {
|
||||
// Rank 0 goes first, and the trim notice tells the author the trimmed
|
||||
// sections "still hold — read them in the terminal report". Unlike the
|
||||
// deferral list (findings artifact) and the not-reviewed disclosures
|
||||
// (the model's own inputs), a diagnosis derived from the side file has
|
||||
// no other copy anywhere unless the composed result carries one.
|
||||
sideFile({
|
||||
round: 4,
|
||||
posted: 9,
|
||||
fresh: 9,
|
||||
findings: [{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }],
|
||||
});
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 1,
|
||||
draftedComments: [
|
||||
{ path: 'src/a.ts', line: 1, body: '**[Suggestion]** again' },
|
||||
],
|
||||
});
|
||||
expect(r.body).toContain('Convergence:');
|
||||
expect(r.convergence?.en).toContain('Convergence:');
|
||||
expect(r.convergence?.zh).toContain('收敛情况:');
|
||||
});
|
||||
|
||||
it("counts only marked drafts as this round's new findings", () => {
|
||||
// An unmarked comment is not a finding — it enters no work list — so
|
||||
// counting it as fresh activity inflates a cluster and satisfies the
|
||||
// guard that alone keeps the trend off a settled round.
|
||||
sideFile({ round: 4, posted: 9, fresh: 9, findings: [] });
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 1,
|
||||
draftedComments: [
|
||||
{ path: 'a.ts', line: 1, body: '**[Suggestion]** one' },
|
||||
{ path: 'b.ts', line: 2, body: 'no marker at all' },
|
||||
],
|
||||
});
|
||||
expect(r.postedFresh).toBe(1);
|
||||
});
|
||||
|
||||
it('reads a floor the state never named as `auto`, like the composer does', () => {
|
||||
// The value is model-written and the SKILL's field list is prefaced
|
||||
// "omit what does not apply", so absence is reachable. Read as "no floor
|
||||
// at all", the round advises dropping to a floor SKILL Step 6's prose
|
||||
// posture already had it running under.
|
||||
sideFile({ round: 5, posted: 1, fresh: 1, findings: [] });
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 1,
|
||||
suggestionsInline: 0,
|
||||
draftedComments: [{ path: 'a.ts', line: 1, body: '**[Critical]** boom' }],
|
||||
});
|
||||
expect(r.body).toContain('The rate of new findings is not falling.');
|
||||
expect(r.body).toContain('already resolve to a critical posting floor');
|
||||
expect(r.body).not.toContain('dropping this PR');
|
||||
});
|
||||
|
||||
it('states ONE fresh count — the paragraph and the marker cannot disagree', () => {
|
||||
// The marker's count and the paragraph's are the same number about the
|
||||
// same round. Computed against different carried-sets, a stray id that
|
||||
// names no standing entry read fresh in the prose and carried in the
|
||||
// marker — and the marker's undercount persists as the next round's
|
||||
// `prev.fresh`, where the trend's own guard reads it.
|
||||
sideFile({
|
||||
round: 4,
|
||||
posted: 9,
|
||||
fresh: 9,
|
||||
findings: [{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }],
|
||||
});
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 1,
|
||||
draftedComments: [
|
||||
{
|
||||
path: 'src/a.ts',
|
||||
line: 1,
|
||||
body: '**[Suggestion]** R2-99: a new one',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(r.postedFresh).toBe(1);
|
||||
expect(parseLedger(r.body)?.fresh).toBe(1);
|
||||
expect(r.body).toContain('1 of them reported for the first time');
|
||||
});
|
||||
|
||||
it('keeps the terminal copy on the round that actually sheds the paragraph', () => {
|
||||
// The promise the trim notice makes is about a body that DROPPED the
|
||||
// paragraph. A test that asserts the body still contains it never
|
||||
// reaches the case the copy exists for.
|
||||
sideFile({
|
||||
round: 4,
|
||||
posted: 9,
|
||||
fresh: 9,
|
||||
findings: [{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }],
|
||||
});
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 1,
|
||||
bodyCriticals: ['B'.repeat(55_850)],
|
||||
unreviewedDimensions: ['security'],
|
||||
draftedComments: [
|
||||
{ path: 'src/a.ts', line: 1, body: '**[Suggestion]** again' },
|
||||
],
|
||||
});
|
||||
expect(r.body).not.toContain('Convergence:');
|
||||
expect(r.convergence?.en).toContain('Convergence:');
|
||||
});
|
||||
|
||||
it('counts a shortened side-file list as an undercount, not as complete', () => {
|
||||
// A file persisted by an older CLI carries ids the whole-shape test now
|
||||
// refuses, and the persist paths keep that list across anonymous and
|
||||
// recovery-threw runs. Rejected entries shrink the work list exactly as
|
||||
// the marker's cap does.
|
||||
sideFile({
|
||||
round: 4,
|
||||
posted: 9,
|
||||
fresh: 9,
|
||||
findings: [
|
||||
{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' },
|
||||
{ id: ' R3-1', sev: 'S', file: 'src/b.ts', title: 'pre-hardening' },
|
||||
],
|
||||
});
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 1,
|
||||
draftedComments: [
|
||||
{ path: 'src/a.ts', line: 1, body: '**[Suggestion]** again' },
|
||||
],
|
||||
});
|
||||
expect(r.body).toContain('may be an undercount');
|
||||
});
|
||||
|
||||
it('resolves a duplicated carried id the way the ledger does', () => {
|
||||
// `idFor` keeps the FIRST comment under a carried id and re-mints this
|
||||
// round's id for a second one, so the second draft is a finding this
|
||||
// round minted. Read as a re-post here, the marker's work list gained a
|
||||
// round-N entry that entered no fresh count.
|
||||
sideFile({
|
||||
round: 3,
|
||||
posted: 1,
|
||||
fresh: 1,
|
||||
findings: [{ id: 'R2-1', sev: 'C', file: 'src/p.ts', title: 'x' }],
|
||||
});
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 2,
|
||||
suggestionsInline: 0,
|
||||
draftedComments: [
|
||||
{ path: 'src/p.ts', line: 1, body: '**[Critical]** R2-1: still open' },
|
||||
{ path: 'src/p.ts', line: 9, body: '**[Critical]** R2-1: and again' },
|
||||
],
|
||||
});
|
||||
const marker = parseLedger(r.body)!;
|
||||
expect(marker.findings.map((x) => x.id)).toEqual(['R2-1', 'R4-1']);
|
||||
expect(marker.fresh).toBe(1);
|
||||
expect(r.postedFresh).toBe(1);
|
||||
});
|
||||
|
||||
it('re-mints a stray id, and keeps one a shortened list may have shed', () => {
|
||||
// A claimed id naming no entry in a COMPLETE work list is a stray, and
|
||||
// recording it mints a finding under a round that never held it — which
|
||||
// the next round's recurrence join then cites in a posted paragraph.
|
||||
// Over a SHORTENED list the two cannot be told apart, so continuity
|
||||
// wins: the marker's byte budget sheds entries the model may legitimately
|
||||
// re-voice.
|
||||
const draft = [
|
||||
{ path: 'src/a.ts', line: 1, body: '**[Suggestion]** R2-99: a new one' },
|
||||
];
|
||||
sideFile({
|
||||
round: 4,
|
||||
posted: 9,
|
||||
fresh: 9,
|
||||
findings: [{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }],
|
||||
});
|
||||
const complete = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 1,
|
||||
draftedComments: draft,
|
||||
});
|
||||
expect(parseLedger(complete.body)?.findings.map((x) => x.id)).toEqual([
|
||||
'R5-1',
|
||||
]);
|
||||
|
||||
sideFile({
|
||||
round: 4,
|
||||
posted: 9,
|
||||
fresh: 9,
|
||||
dropped: 3,
|
||||
findings: [{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }],
|
||||
});
|
||||
const shortened = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 0,
|
||||
suggestionsInline: 1,
|
||||
draftedComments: draft,
|
||||
});
|
||||
expect(parseLedger(shortened.body)?.findings.map((x) => x.id)).toEqual([
|
||||
'R2-99',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a finding whose line number is not an integer', () => {
|
||||
// `draftedComments` is raw model-written JSON. Emitted with a `12.5`
|
||||
// line, the entry is refused by the serializer's own admission filter,
|
||||
// which counts the WHOLE finding into `dropped` — retiring a posted
|
||||
// finding with no ruling, mislabelling the round as budget-truncated,
|
||||
// and withholding the anchor so the next round re-scopes the full diff.
|
||||
const l = buildLedger(
|
||||
2,
|
||||
[{ path: 'a.ts', line: 12.5, body: '**[Critical]** boom' }],
|
||||
[],
|
||||
);
|
||||
expect(l.findings).toEqual([
|
||||
{ id: 'R2-1', sev: 'C', file: 'a.ts', title: 'boom' },
|
||||
]);
|
||||
const marker = serializeLedger({ ...l, sha: 'deadbeef00112233' });
|
||||
const parsed = parseLedger(marker)!;
|
||||
expect(parsed.findings).toHaveLength(1);
|
||||
expect(parsed.dropped).toBeUndefined();
|
||||
expect(parsed.sha).toBe('deadbeef00112233');
|
||||
});
|
||||
|
||||
it('reads an unusable findings field as unknown, not as an empty list', () => {
|
||||
// A `findings` field that is not a list leaves the read knowing nothing
|
||||
// about what the round held — which is not the same as a round that held
|
||||
// nothing. Counted as a complete empty list, every claimed id reads as a
|
||||
// stray and every re-post counts as first-time work.
|
||||
sideFile({ round: 4, posted: 9, fresh: 9, findings: 'garbage' });
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
criticalsInline: 1,
|
||||
suggestionsInline: 0,
|
||||
draftedComments: [
|
||||
{ path: 'src/a.ts', line: 1, body: '**[Critical]** R2-1: still open' },
|
||||
],
|
||||
});
|
||||
expect(r.postedFresh).toBe(0);
|
||||
expect(parseLedger(r.body)?.findings.map((x) => x.id)).toEqual(['R2-1']);
|
||||
});
|
||||
|
||||
it('re-mints a claimed id the serializer would refuse, keeping the finding', () => {
|
||||
// Continuity keeps an id a shortened list may have shed — it cannot keep
|
||||
// one no list this pipeline wrote could have held. Kept, the serializer
|
||||
// refuses the WHOLE entry: a posted finding exits the work list owing no
|
||||
// ruling, the round is mislabelled budget-truncated, and the anchor goes.
|
||||
for (const claimed of ['R7-1', 'R0-1', `R2-${'9'.repeat(24)}`]) {
|
||||
const l = buildLedger(
|
||||
5,
|
||||
[{ path: 'a.ts', line: 1, body: `**[Critical]** ${claimed}: boom` }],
|
||||
[],
|
||||
);
|
||||
expect(l.findings.map((x) => x.id)).toEqual(['R5-1']);
|
||||
const parsed = parseLedger(
|
||||
serializeLedger({ ...l, sha: 'deadbeef00112233' }),
|
||||
)!;
|
||||
expect(parsed.findings).toHaveLength(1);
|
||||
expect(parsed.dropped).toBeUndefined();
|
||||
expect(parsed.sha).toBe('deadbeef00112233');
|
||||
}
|
||||
// A well-formed id from an earlier round is still kept when the list is
|
||||
// unknown — that is the continuity the bounds must not override.
|
||||
const kept = buildLedger(
|
||||
5,
|
||||
[{ path: 'a.ts', line: 1, body: '**[Critical]** R2-1: still open' }],
|
||||
[],
|
||||
);
|
||||
expect(kept.findings.map((x) => x.id)).toEqual(['R2-1']);
|
||||
});
|
||||
|
||||
it('will not claim a floor it could not read', () => {
|
||||
// A present-but-unrecognisable value is a state this module cannot read.
|
||||
// Folded to `auto`, the body said the round "already resolves to a
|
||||
// critical posting floor" while its own deferral-licence clause said the
|
||||
// floor carried no recognisable value and the enforcement backstop moved
|
||||
// nothing.
|
||||
sideFile({ round: 5, posted: 1, fresh: 1, floor: 'c', findings: [] });
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
// The point of the finding: this arrives from model-written JSON, so
|
||||
// the runtime can see a value the type says is impossible.
|
||||
severityFloor: 'crit' as unknown as ComposeReviewInput['severityFloor'],
|
||||
criticalsInline: 1,
|
||||
suggestionsInline: 1,
|
||||
draftedComments: [
|
||||
{ path: 'a.ts', line: 1, body: '**[Critical]** boom' },
|
||||
{ path: 'b.ts', line: 2, body: '**[Suggestion]** nit' },
|
||||
],
|
||||
});
|
||||
expect(r.floorEnforced).toEqual([]);
|
||||
expect(r.body).not.toContain('already resolve to a critical posting floor');
|
||||
expect(parseLedger(r.body)?.floor).toBe('o');
|
||||
});
|
||||
|
||||
it('names an auto-resolved floor the way the enforcement note does', () => {
|
||||
// `auto` is the DEFAULT, so the explicit-flag wording claims a flag that
|
||||
// was never passed — beside a floor-enforcement note in the same body
|
||||
// that calls it the RESOLVED floor.
|
||||
sideFile({ round: 5, posted: 1, fresh: 1, floor: 'c', findings: [] });
|
||||
const r = composeReview({
|
||||
planPath: plan(),
|
||||
modelId: 'm',
|
||||
severityFloor: 'auto',
|
||||
criticalsInline: 1,
|
||||
suggestionsInline: 0,
|
||||
draftedComments: [{ path: 'a.ts', line: 1, body: '**[Critical]** boom' }],
|
||||
});
|
||||
expect(r.body).toContain('The rate of new findings is not falling.');
|
||||
expect(r.body).toContain('already resolve to a critical posting floor');
|
||||
expect(r.body).not.toContain('--severity-floor critical');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -70,14 +70,29 @@ import { layerAuditGate } from './lib/layer-audit-gate.js';
|
|||
import { diffHashOf, type ScriptLintReport } from './script-lint.js';
|
||||
import type { TestPlanReport } from './test-plan.js';
|
||||
import {
|
||||
LEDGER_BODY_FILE,
|
||||
LEDGER_ID_READBACK,
|
||||
LEDGER_MAX_ID,
|
||||
isLedgerFinding,
|
||||
isStandInName,
|
||||
normalizeLedgerFinding,
|
||||
LEDGER_MAX_BYTES,
|
||||
LEDGER_MAX_ROUND,
|
||||
LEDGER_UNKNOWN_FILE,
|
||||
serializeLedger,
|
||||
volumeOf,
|
||||
type Ledger,
|
||||
type LedgerFinding,
|
||||
} from './lib/ledger.js';
|
||||
import { mdField } from './lib/md-field.js';
|
||||
import {
|
||||
diagnoseConvergence,
|
||||
isFreshDraft,
|
||||
renderConvergenceDiagnosis,
|
||||
type CriticalFloorKind,
|
||||
type DraftedFinding,
|
||||
type PrevRound,
|
||||
} from './lib/convergence.js';
|
||||
import {
|
||||
CRITICAL_PREFIX,
|
||||
LEADING_INVISIBLE_RE,
|
||||
|
|
@ -405,6 +420,97 @@ export function normalizeSeverityFloor(value: unknown): string | undefined {
|
|||
return typeof value === 'string' ? value.trim().toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the posting floor resolve to `critical` for the round being composed?
|
||||
*
|
||||
* The ONE statement of that rule. `floorEnforcedReroute` ACTS on it, moving
|
||||
* otherwise-postable Suggestions into the deferral channel; the convergence
|
||||
* rendering READS it so its handling advice never recommends a posture the
|
||||
* round is already running under — a paragraph telling the author to drop to
|
||||
* `--severity-floor critical` inside the very body whose floor-enforcement
|
||||
* note says Suggestions were already moved past that floor.
|
||||
*/
|
||||
export function criticalFloorKind(
|
||||
severityFloor: unknown,
|
||||
contextUnavailable: boolean,
|
||||
prevRound: number,
|
||||
): CriticalFloorKind | undefined {
|
||||
// The REPORTING reading, and it folds an absent or unrecognisable floor
|
||||
// into `auto` the way `composeReviewBody` already does ("A floor the
|
||||
// module does not recognise — absent, null, or a model-transcribed
|
||||
// spelling drift — is folded into ONE state"). The value is model-written
|
||||
// and the SKILL's field list is prefaced "omit what does not apply", so
|
||||
// absence is reachable — and reading it as "no floor at all" made the
|
||||
// round advise dropping to a floor SKILL Step 6's prose posture already
|
||||
// had it running under, which is the exact failure this predicate exists
|
||||
// to prevent.
|
||||
//
|
||||
// Deliberately NOT shared with the enforcement reading below. Enforcement
|
||||
// moves findings out of the posting set, and doing that on a posture this
|
||||
// module had to guess at is the direction that loses work; the fail-open
|
||||
// there is pre-existing and stays.
|
||||
const raw = normalizeSeverityFloor(severityFloor);
|
||||
// Only genuine ABSENCE folds. A present-but-unrecognisable value is a
|
||||
// state this module cannot read, and folding it made the body contradict
|
||||
// itself: the volume advice said the round "already resolves to a critical
|
||||
// posting floor" while the deferral-licence clause in the same body said
|
||||
// the floor carried no recognisable value and the enforcement backstop —
|
||||
// strict on purpose — moved nothing. The wrong stamp then became the next
|
||||
// round's comparison baseline.
|
||||
const absent = severityFloor === undefined || severityFloor === null;
|
||||
const floor =
|
||||
raw === 'critical' || raw === 'suggestion' || raw === 'auto'
|
||||
? raw
|
||||
: absent
|
||||
? 'auto'
|
||||
: undefined;
|
||||
return floorResolvesCritical(floor, contextUnavailable, prevRound);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the floor resolves to `critical` for ENFORCEMENT — strict, so a
|
||||
* posture the state never named cannot move a finding out of the posting
|
||||
* set. `floorEnforcedReroute` acts on this; the reporting reading above is
|
||||
* what the round says about itself.
|
||||
*/
|
||||
export function criticalFloorInEffect(
|
||||
severityFloor: unknown,
|
||||
contextUnavailable: boolean,
|
||||
prevRound: number,
|
||||
): boolean {
|
||||
return (
|
||||
floorResolvesCritical(
|
||||
normalizeSeverityFloor(severityFloor),
|
||||
contextUnavailable,
|
||||
prevRound,
|
||||
) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
/** Does this resolved floor mean `critical` for the round after `prevRound`? */
|
||||
function floorResolvesCritical(
|
||||
floor: string | undefined,
|
||||
contextUnavailable: boolean,
|
||||
prevRound: number,
|
||||
): CriticalFloorKind | undefined {
|
||||
// `prevRound` is the PREVIOUS posted round, so the review being composed
|
||||
// is `prevRound + 1` — spelled out because the equivalent `prevRound >= 5`
|
||||
// reads as a fencepost error against SKILL Step 6's "from round 6 it is
|
||||
// critical".
|
||||
const thisRound = prevRound + 1;
|
||||
if (floor === 'critical') return 'explicit';
|
||||
if (floor === 'auto' && !contextUnavailable && thisRound >= 6) {
|
||||
return 'auto-resolved';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Did the state name a floor this module recognises at all? */
|
||||
function severityFloorKnown(severityFloor: unknown): boolean {
|
||||
const raw = normalizeSeverityFloor(severityFloor);
|
||||
return raw === 'critical' || raw === 'suggestion' || raw === 'auto';
|
||||
}
|
||||
|
||||
/**
|
||||
* The posting floor, enforced in code — the backstop for the posture SKILL
|
||||
* Step 6 resolves in prose.
|
||||
|
|
@ -441,16 +547,9 @@ export function floorEnforcedReroute(
|
|||
prevRound: number,
|
||||
drafted: ReadonlyArray<{ path?: unknown; line?: unknown; body?: unknown }>,
|
||||
): { indices: number[]; entries: DeferredEntry[] } {
|
||||
const floor = normalizeSeverityFloor(severityFloor);
|
||||
// `prevRound` is the PREVIOUS posted round, so the review being composed
|
||||
// is `prevRound + 1` — spelled out because the equivalent `prevRound >= 5`
|
||||
// reads as a fencepost error against SKILL Step 6's "from round 6 it is
|
||||
// critical".
|
||||
const thisRound = prevRound + 1;
|
||||
const enforced =
|
||||
floor === 'critical' ||
|
||||
(floor === 'auto' && !contextUnavailable && thisRound >= 6);
|
||||
if (!enforced) return { indices: [], entries: [] };
|
||||
if (!criticalFloorInEffect(severityFloor, contextUnavailable, prevRound)) {
|
||||
return { indices: [], entries: [] };
|
||||
}
|
||||
const indices: number[] = [];
|
||||
const entries: DeferredEntry[] = [];
|
||||
drafted.forEach((c, i) => {
|
||||
|
|
@ -721,6 +820,25 @@ export interface ComposeReviewResult {
|
|||
* PR's comment volume without counting threads by hand. Decides nothing.
|
||||
*/
|
||||
postedInline: number;
|
||||
/**
|
||||
* How many of `postedInline` this round reported for the FIRST time —
|
||||
* neither a re-post of a still-standing ledger entry nor an unmarked
|
||||
* draft. The number the convergence trend runs on, stamped into the marker
|
||||
* beside the total so the next round can compare like with like.
|
||||
*/
|
||||
postedFresh: number;
|
||||
/**
|
||||
* The convergence paragraph, when a signal fired — the SAME text the body
|
||||
* carries, returned so a terminal copy exists.
|
||||
*
|
||||
* The overflow ladder sheds this paragraph first, and its notice tells the
|
||||
* author the trimmed sections "still hold — read them in the terminal
|
||||
* report". That was a false record while this text lived only inside the
|
||||
* body composer: unlike the deferral list (findings artifact) and the
|
||||
* not-reviewed disclosures (the model's own inputs), a diagnosis derived
|
||||
* from the side file has no other copy anywhere.
|
||||
*/
|
||||
convergence?: { en: string; zh: string };
|
||||
/**
|
||||
* The previous round's `postedInline`, recovered from the side file when
|
||||
* it recorded one. Absent on round 1, on a recovery miss, and on any
|
||||
|
|
@ -1169,6 +1287,14 @@ export function composeReview(
|
|||
prevRound,
|
||||
Array.isArray(input.draftedComments) ? input.draftedComments : [],
|
||||
);
|
||||
// The one resolution, read by the enforcement above and reported by the
|
||||
// diagnosis below — and stamped into this round's marker, so the NEXT round
|
||||
// can tell a posture change from a loop that will not settle.
|
||||
const floorKind = criticalFloorKind(
|
||||
input.severityFloor,
|
||||
input.contextUnavailable === true,
|
||||
prevRound,
|
||||
);
|
||||
let effective = input;
|
||||
if (reroute.indices.length > 0) {
|
||||
const drop = new Set(reroute.indices);
|
||||
|
|
@ -1201,12 +1327,41 @@ export function composeReview(
|
|||
})(),
|
||||
};
|
||||
}
|
||||
// Is the loop settling? Measured from facts this round already holds — the
|
||||
// previous work list and volume from the side file, this round's drafts —
|
||||
// and rendered as an observation. It changes nothing about what the round
|
||||
// posts: no finding is withheld, no verdict capped. A round that looks
|
||||
// healthy produces no diagnosis at all rather than an empty section.
|
||||
//
|
||||
// The INPUTS travel; the diagnosis is composed inside `composeReviewBody`,
|
||||
// beside the `postedInline` the marker and the terminal VOLUME line are
|
||||
// taken from. Composed here it would be a second derivation of the same
|
||||
// number — equal today, and free to drift the moment either derivation is
|
||||
// edited, leaving one posted body stating two volumes for one round.
|
||||
const result = composeReviewBody(
|
||||
effective,
|
||||
cliVersion,
|
||||
attribution,
|
||||
prevRound,
|
||||
reroute,
|
||||
{
|
||||
prev: {
|
||||
...(prevFacts.posted === undefined ? {} : { posted: prevFacts.posted }),
|
||||
findings: prevFacts.findings,
|
||||
truncated: prevFacts.truncated,
|
||||
complete: prevRound > 0 && !prevFacts.truncated,
|
||||
foreign: prevFacts.foreign,
|
||||
merged: prevFacts.merged,
|
||||
...(prevFacts.floor === undefined ? {} : { floor: prevFacts.floor }),
|
||||
...(prevFacts.fresh === undefined ? {} : { fresh: prevFacts.fresh }),
|
||||
},
|
||||
// Read from the same input `floorEnforcedReroute` just acted on, through
|
||||
// the one predicate both share — so the advice cannot recommend a floor
|
||||
// the enforcement above already applied, nor name it a way the
|
||||
// enforcement note in the same body contradicts.
|
||||
floor: floorKind === undefined ? ('o' as const) : ('c' as const),
|
||||
...(floorKind === undefined ? {} : { criticalFloorKind: floorKind }),
|
||||
},
|
||||
);
|
||||
// The ledger marker rides the body THIS function returns, because this — not
|
||||
// the CLI handler — is what `submit` calls and posts. Appending it in the
|
||||
|
|
@ -1235,7 +1390,19 @@ export function composeReview(
|
|||
runtimeModelId,
|
||||
prevRound,
|
||||
postedInline,
|
||||
result.postedFresh,
|
||||
prevFacts.posted,
|
||||
floorKind,
|
||||
severityFloorKnown(input.severityFloor),
|
||||
{
|
||||
ids: new Set(prevFacts.findings.map((f) => f.id)),
|
||||
// A round that recovered NO predecessor knows nothing about which ids
|
||||
// were real — the id space is shared across environments and a first
|
||||
// round on a machine with no side file is the ordinary case — so it
|
||||
// cannot call a claimed id a stray. Only a recovered, untruncated list
|
||||
// is evidence of absence.
|
||||
complete: prevRound > 0 && !prevFacts.truncated,
|
||||
},
|
||||
);
|
||||
// `postedInline` came out of the body composer on the same input, so only
|
||||
// the predecessor's volume — which only this scope read — is added here.
|
||||
|
|
@ -1250,6 +1417,19 @@ export function composeReview(
|
|||
: withVolume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing recovered: round 1, no volume to compare against, no work list to
|
||||
* find recurrence in, and therefore no evidence to qualify. Named once so the
|
||||
* three ways this read gives up cannot drift apart as fields are added.
|
||||
*/
|
||||
const EMPTY_PREV_FACTS = {
|
||||
round: 0,
|
||||
findings: [] as LedgerFinding[],
|
||||
truncated: false,
|
||||
foreign: false,
|
||||
merged: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* The previous posted round's number AND its posting volume, recovered from
|
||||
* the side file `pr-context` wrote — never from the model.
|
||||
|
|
@ -1271,20 +1451,39 @@ export function composeReview(
|
|||
function prevLedgerFacts(planPath: string | undefined): {
|
||||
round: number;
|
||||
posted?: number;
|
||||
/**
|
||||
* The previous round's work list, for the recurrence join. Empty when
|
||||
* nothing was recovered — which reads as "no recurrence to find", never
|
||||
* as "the previous round found nothing".
|
||||
*/
|
||||
findings: LedgerFinding[];
|
||||
/** Its marker shed findings to fit the byte budget: the list is partial. */
|
||||
truncated: boolean;
|
||||
/** It was recovered from a marker this account did not post. */
|
||||
foreign: boolean;
|
||||
/** That marker was merged over this account's own findings. */
|
||||
merged: boolean;
|
||||
/** The posting floor it ran under, when its marker recorded one. */
|
||||
floor?: 'c' | 'o';
|
||||
/** How many of its comments were findings reported for the first time. */
|
||||
fresh?: number;
|
||||
} {
|
||||
try {
|
||||
if (!planPath) return { round: 0 };
|
||||
if (!planPath) return EMPTY_PREV_FACTS;
|
||||
const plan = JSON.parse(readFileSync(planPath, 'utf8')) as {
|
||||
prNumber?: unknown;
|
||||
};
|
||||
const pr = plan?.prNumber;
|
||||
if (!isPositivePrNumber(pr)) return { round: 0 };
|
||||
if (!isPositivePrNumber(pr)) return EMPTY_PREV_FACTS;
|
||||
const prev = JSON.parse(
|
||||
readFileSync(
|
||||
join(dirname(planPath), `qwen-review-pr-${pr}-prev-ledger.json`),
|
||||
'utf8',
|
||||
),
|
||||
) as Ledger;
|
||||
// `foreign` is a side-file field, not a marker field: it records how
|
||||
// THIS machine obtained the list, which is nothing the marker riding a
|
||||
// public body could be trusted to state about itself.
|
||||
) as Ledger & { foreign?: unknown; merged?: unknown };
|
||||
const round =
|
||||
Number.isInteger(prev.round) && prev.round > 0 ? prev.round : 0;
|
||||
// Read through the ledger's own volume reader rather than a local
|
||||
|
|
@ -1298,12 +1497,74 @@ function prevLedgerFacts(planPath: string | undefined): {
|
|||
// otherwise attribute it to round 0 — and a round-1 marker would ship
|
||||
// `prevPosted` for a round that never existed, against this field's own
|
||||
// "absent on round 1" contract.
|
||||
// Through the ledger's OWN admission test, not a local restatement of
|
||||
// two of its checks. The side file is the same untrusted shape as a
|
||||
// marker, arriving by a different route: a file written before the id
|
||||
// hardening can still hold an id the marker path now rejects, and
|
||||
// `birthRound` trims before matching, so the round would be published
|
||||
// verbatim in a body this account posts. Normalised for the same reason
|
||||
// — the caps are the serializer's contract and this file is not bound by
|
||||
// it, while the other side of the recurrence join IS capped.
|
||||
// A `findings` field that is not a list at all leaves this read knowing
|
||||
// nothing about what the round held — which is not the same as a round
|
||||
// that held nothing. Counted as a complete empty list, every claimed id
|
||||
// would read as a stray.
|
||||
const listUsable = Array.isArray(prev.findings);
|
||||
const rawFindings = listUsable ? prev.findings : [];
|
||||
const findings = rawFindings
|
||||
.filter((f): f is LedgerFinding => isLedgerFinding(f, round))
|
||||
.map(normalizeLedgerFinding);
|
||||
// Entries this read's own admission test rejected are findings the next
|
||||
// round will never rule on, exactly like the ones the marker's cap shed.
|
||||
// Reachable without any tampering: a side file persisted by an older CLI
|
||||
// carries ids the whole-shape test now refuses, and
|
||||
// `persistRecoveredLedger` keeps that list across anonymous and
|
||||
// recovery-threw runs.
|
||||
const rejected = rawFindings.length - findings.length;
|
||||
return {
|
||||
round,
|
||||
...(posted === undefined || round === 0 ? {} : { posted }),
|
||||
// Gated on the round for the same reason the volume is: a work list
|
||||
// travels WITH the round that produced it or not at all. A side file
|
||||
// whose `round` is missing or unusable (partially written, hand-edited)
|
||||
// still parses, and its `R5-2` ids would then seed the recurrence join
|
||||
// for a round this read calls 0 — the posted body would cite rounds 5
|
||||
// and up beside a marker stamping round 1.
|
||||
findings: round === 0 ? [] : findings,
|
||||
// The marker had to shed findings to fit its byte budget, so what came
|
||||
// back is known-incomplete (measured at up to 35 shed per round on the
|
||||
// worst PRs this diagnosis speaks to). Carried rather than dropped: the
|
||||
// cluster evidence is still the best there is, and the paragraph
|
||||
// discloses the undercount instead of presenting a partial list whole.
|
||||
truncated:
|
||||
round !== 0 &&
|
||||
(!listUsable ||
|
||||
rejected > 0 ||
|
||||
(typeof prev.dropped === 'number' && prev.dropped > 0)),
|
||||
// Whoever posted the marker that won recovery. `pr-context` adopts the
|
||||
// highest-round marker on the PR — bounded, but not restricted to this
|
||||
// account — so a cited round may be one this account never ran. The
|
||||
// rendering says so rather than publishing the citation bare.
|
||||
foreign: round !== 0 && prev.foreign === true,
|
||||
merged: round !== 0 && prev.merged === true,
|
||||
// Travels with the volume it qualifies, and with the round, for the
|
||||
// same reason both of those do.
|
||||
...(round === 0 ||
|
||||
posted === undefined ||
|
||||
!(prev.floor === 'c' || prev.floor === 'o')
|
||||
? {}
|
||||
: { floor: prev.floor }),
|
||||
// Travels with the volume it is a part of, for the same reason.
|
||||
...(() => {
|
||||
const f =
|
||||
round === 0 || posted === undefined
|
||||
? undefined
|
||||
: volumeOf(prev.fresh);
|
||||
return f === undefined || f > (posted as number) ? {} : { fresh: f };
|
||||
})(),
|
||||
};
|
||||
} catch {
|
||||
return { round: 0 };
|
||||
return EMPTY_PREV_FACTS;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1321,7 +1582,11 @@ function ledgerMarkerFor(
|
|||
runtimeModelId: string | undefined,
|
||||
prevRound: number,
|
||||
postedInline: number,
|
||||
freshInline: number,
|
||||
prevPostedInline: number | undefined,
|
||||
floorKind: CriticalFloorKind | undefined,
|
||||
floorKnown: boolean,
|
||||
carriedWorkList: { ids: ReadonlySet<string>; complete: boolean },
|
||||
): string | null {
|
||||
try {
|
||||
if (!input.planPath) return null;
|
||||
|
|
@ -1442,6 +1707,7 @@ function ledgerMarkerFor(
|
|||
// posted, counted blocker and must enter the work list.
|
||||
...splitDeferralChannel(input.deferredSuggestions).relocated,
|
||||
],
|
||||
carriedWorkList,
|
||||
),
|
||||
// The pair falls together: a sha with no model reads to the next
|
||||
// round as a pre-field marker rather than as "nobody certified this".
|
||||
|
|
@ -1456,6 +1722,23 @@ function ledgerMarkerFor(
|
|||
...(prevPostedInline === undefined
|
||||
? {}
|
||||
: { prevPosted: prevPostedInline }),
|
||||
// The posture that volume was produced under. Without it, the next
|
||||
// round measures a FLOOR change as loop divergence: the volume under a
|
||||
// critical floor and the volume under an open one are not two points
|
||||
// on one trend. Decides nothing, sheds with the volume it qualifies.
|
||||
// The RESOLVED posture, folded the way every consumer folds it: an
|
||||
// absent or unrecognisable floor reads as `auto` throughout this
|
||||
// module, and `auto` resolves determinately from the round number and
|
||||
// the context state. Recording it only when the state NAMED a floor
|
||||
// left the guard blind under the DEFAULT configuration — where the
|
||||
// posture genuinely transitions at round 6 and again on a transient
|
||||
// context failure — so a real posture change read as loop divergence,
|
||||
// which is the misreading the field exists to prevent. What must not
|
||||
// be invented is a posture nobody can derive; this one is derived from
|
||||
// the same fold the advice and the enforcement backstop already use.
|
||||
floor: floorKind === undefined ? 'o' : 'c',
|
||||
// The part of that volume the trend is about — see `Ledger.fresh`.
|
||||
fresh: freshInline,
|
||||
});
|
||||
} catch {
|
||||
// A carry-forward convenience, never worth failing the verdict over.
|
||||
|
|
@ -1540,6 +1823,17 @@ function composeReviewBody(
|
|||
indices: [],
|
||||
entries: [],
|
||||
},
|
||||
/**
|
||||
* What the convergence diagnosis needs and this function cannot derive: the
|
||||
* previous round as the side file recovered it, and the posting floor this
|
||||
* round resolved to. Null in the direct-call tests that compose a body with
|
||||
* no PR history behind it.
|
||||
*/
|
||||
convergence: {
|
||||
prev: PrevRound;
|
||||
floor?: 'c' | 'o';
|
||||
criticalFloorKind?: CriticalFloorKind;
|
||||
} | null = null,
|
||||
): ComposeReviewResult {
|
||||
// The posting set this body describes — `input` here is already the
|
||||
// post-enforcement one, so the count needs no second derivation and
|
||||
|
|
@ -1551,6 +1845,51 @@ function composeReviewBody(
|
|||
// the shared reader's own docstring exists to prevent. `?? 0` is
|
||||
// unreachable for an array length; it keeps the type honest.
|
||||
const postedInline = volumeOf((input.draftedComments ?? []).length) ?? 0;
|
||||
const diagnosis = convergence
|
||||
? diagnoseConvergence({
|
||||
// Clamped like every other public round surface in this function —
|
||||
// the ledger marker stamp and the deferred-posture clause both clamp
|
||||
// identically. An unclamped `+1` at the cap names round 10001 in the
|
||||
// posted prose beside a marker stamping 10000, with this round's own
|
||||
// findings stamped `R10000-*`.
|
||||
round: Math.min(prevRound + 1, LEDGER_MAX_ROUND),
|
||||
// The SAME count the marker and the VOLUME line carry, not a second
|
||||
// derivation of it.
|
||||
posted: postedInline,
|
||||
prev: convergence.prev,
|
||||
drafts: draftedFindingsOf(input.draftedComments),
|
||||
...(convergence.floor === undefined
|
||||
? {}
|
||||
: { floor: convergence.floor }),
|
||||
...(convergence.criticalFloorKind === undefined
|
||||
? {}
|
||||
: { criticalFloorKind: convergence.criticalFloorKind }),
|
||||
})
|
||||
: null;
|
||||
// A fact about the round, not about the diagnosis: it rides in the marker
|
||||
// whether or not a signal fired, because the NEXT round's trend needs this
|
||||
// round's point either way.
|
||||
const carriedIds = convergence
|
||||
? new Set(
|
||||
convergence.prev.findings
|
||||
.map((f) => f?.id)
|
||||
.filter((id): id is string => typeof id === 'string'),
|
||||
)
|
||||
: undefined;
|
||||
const postedFresh =
|
||||
volumeOf(
|
||||
draftedFindingsOf(input.draftedComments).filter((d) =>
|
||||
isFreshDraft(
|
||||
d,
|
||||
Math.min(prevRound + 1, LEDGER_MAX_ROUND),
|
||||
carriedIds,
|
||||
convergence?.prev.complete === true,
|
||||
),
|
||||
).length,
|
||||
) ?? 0;
|
||||
const convergenceNote = diagnosis
|
||||
? renderConvergenceDiagnosis(diagnosis)
|
||||
: undefined;
|
||||
const criticalsInline = toCount(input.criticalsInline, 'criticalsInline');
|
||||
const suggestionsInline = toCount(
|
||||
input.suggestionsInline,
|
||||
|
|
@ -2513,6 +2852,7 @@ function composeReviewBody(
|
|||
|
||||
/** What a rank drops, in the author's words — the note names it. */
|
||||
const RANK_NAMES: Record<number, { en: string; zh: string }> = {
|
||||
0: { en: 'the convergence observation', zh: '收敛情况观察' },
|
||||
1: { en: 'the deferred-findings list', zh: '延后发现清单' },
|
||||
2: {
|
||||
en: 'the not-reviewed and non-blocking disclosures',
|
||||
|
|
@ -2592,10 +2932,12 @@ function composeReviewBody(
|
|||
* Every exit of `render` that dropped a rank owes this line — the
|
||||
* last-resort path drops ranks AND cuts, and a stderr record naming only
|
||||
* the cut leaves the kinds it dropped disclosed nowhere but the body.
|
||||
* Only rank 1 has a second durable copy (each deferral is a
|
||||
* `D<round>-<n>` entry in the findings artifact); a trimmed disclosure
|
||||
* section survives nowhere but the terminal summary, so ask for it there
|
||||
* rather than pointing at an artifact that does not carry it.
|
||||
* Rank 1 has a second durable copy (each deferral is a `D<round>-<n>`
|
||||
* entry in the findings artifact) and rank 0 has one too (the composed
|
||||
* result carries the paragraph, and the command prints it as
|
||||
* `CONVERGENCE:`); a trimmed disclosure section survives nowhere but the
|
||||
* terminal summary, so ask for it there rather than pointing at an
|
||||
* artifact that does not carry it.
|
||||
*/
|
||||
const noteTrimmedRanks = (droppedRanks: number[]): void => {
|
||||
if (droppedRanks.length === 0) return;
|
||||
|
|
@ -3301,6 +3643,30 @@ function composeReviewBody(
|
|||
trim: 2,
|
||||
}));
|
||||
|
||||
// The convergence observation: rendered on every event, capping nothing,
|
||||
// and only when a signal actually fired. It sits beside the other
|
||||
// disclosure paragraphs because it is addressed to the same reader — the
|
||||
// author deciding what to do next — and it is deliberately the only
|
||||
// paragraph here that comments on the SHAPE of the review history rather
|
||||
// than on the diff.
|
||||
//
|
||||
// `trim: 0` — its OWN rank, shed before every other. An untagged block
|
||||
// ranks with the blockers and the verdict-qualifying sentences, and the
|
||||
// rounds this fires on are precisely the high-volume rounds most likely to
|
||||
// overflow: unranked, an advisory paragraph that decides nothing survived
|
||||
// while the deferral list and the not-reviewed disclosures were spent.
|
||||
//
|
||||
// A rank of its own, not a share of the deferral list's: every notice
|
||||
// surface keys on the RANK, not on what actually went — the rank's name,
|
||||
// the artifact pointer, `bodyTrim.deferralList` — so sharing rank 1 made a
|
||||
// round that shed only this paragraph post a notice naming a
|
||||
// "deferred-findings list" that never existed and point the author at
|
||||
// artifact entries that do not exist. Its own rank names itself, carries
|
||||
// no artifact pointer, and leaves `deferralList` false.
|
||||
const convergenceBlock: Bi[] = convergenceNote
|
||||
? [{ ...convergenceNote, trim: 0 }]
|
||||
: [];
|
||||
|
||||
// The resumed-run continuity note: the run reused certified work from an
|
||||
// interrupted earlier attempt. Disclosed on every verdict — Approve
|
||||
// included — and never capping: the recovered agents were re-certified
|
||||
|
|
@ -3331,6 +3697,7 @@ function composeReviewBody(
|
|||
...repositoryContextBlock,
|
||||
...unlicensedDeferralBlock,
|
||||
...deferredSuggestionsBlock,
|
||||
...convergenceBlock,
|
||||
...continuityBlock,
|
||||
...bodyCriticalBlock,
|
||||
];
|
||||
|
|
@ -3348,6 +3715,10 @@ function composeReviewBody(
|
|||
deferredCount: deferredSuggestions.length,
|
||||
floorEnforced: reroute.indices,
|
||||
postedInline,
|
||||
postedFresh,
|
||||
...(convergenceNote === undefined
|
||||
? {}
|
||||
: { convergence: convergenceNote }),
|
||||
bodyTrim,
|
||||
lowSignal,
|
||||
scopeUnproven,
|
||||
|
|
@ -3384,6 +3755,7 @@ function composeReviewBody(
|
|||
...repositoryContextBlock,
|
||||
...unlicensedDeferralBlock,
|
||||
...deferredSuggestionsBlock,
|
||||
...convergenceBlock,
|
||||
...continuityBlock,
|
||||
],
|
||||
notReviewedParts.length ||
|
||||
|
|
@ -3391,6 +3763,14 @@ function composeReviewBody(
|
|||
testPlanBlock.length ||
|
||||
repositoryContextBlock.length ||
|
||||
deferredSuggestionsBlock.length ||
|
||||
// Unreachable today and kept deliberately: an APPROVE is composed
|
||||
// from zero findings, which means zero posted comments and zero
|
||||
// drafted paths, so neither convergence signal can fire on this
|
||||
// branch. It is listed anyway because the separator's job is to
|
||||
// know about every block the branch renders — a condition that is
|
||||
// right only because another rule makes its input impossible is a
|
||||
// trap for whoever changes that other rule.
|
||||
convergenceBlock.length ||
|
||||
continuityBlock.length
|
||||
? '\n\n'
|
||||
: ' ',
|
||||
|
|
@ -3406,6 +3786,10 @@ function composeReviewBody(
|
|||
deferredCount: deferredSuggestions.length,
|
||||
floorEnforced: reroute.indices,
|
||||
postedInline,
|
||||
postedFresh,
|
||||
...(convergenceNote === undefined
|
||||
? {}
|
||||
: { convergence: convergenceNote }),
|
||||
bodyTrim,
|
||||
lowSignal,
|
||||
scopeUnproven,
|
||||
|
|
@ -3573,7 +3957,12 @@ function composeReviewBody(
|
|||
// precedes the list (non-capping).
|
||||
clauses.push(...unlicensedDeferralBlock);
|
||||
clauses.push(...deferredSuggestionsBlock);
|
||||
// 6e. Resumed-run continuity (non-capping) — reused work that COUNTS as
|
||||
|
||||
// 6f. Convergence observation (non-capping) — is this loop settling, and if
|
||||
// not, what shape is it. About the review HISTORY, not the diff.
|
||||
clauses.push(...convergenceBlock);
|
||||
|
||||
// 6g. Resumed-run continuity (non-capping) — reused work that COUNTS as
|
||||
// reviewed, disclosed so the author knows two attempts fed this verdict.
|
||||
clauses.push(...continuityBlock);
|
||||
|
||||
|
|
@ -3630,6 +4019,8 @@ function composeReviewBody(
|
|||
deferredCount: deferredSuggestions.length,
|
||||
floorEnforced: reroute.indices,
|
||||
postedInline,
|
||||
postedFresh,
|
||||
...(convergenceNote === undefined ? {} : { convergence: convergenceNote }),
|
||||
bodyTrim,
|
||||
lowSignal,
|
||||
scopeUnproven,
|
||||
|
|
@ -3923,25 +4314,6 @@ export function scriptLintGate(planPath: string): {
|
|||
return { criticals, unreviewed, disclosed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a PR-controlled segment — a diff file path, a linter's message — safe to
|
||||
* splice into the review body we POST to GitHub. Git allows almost any byte in a
|
||||
* filename, so an unescaped path could carry `@mentions`, HTML, Markdown, or a
|
||||
* newline that forges body structure. An inline code span makes Markdown/HTML/`@`
|
||||
* inert; stripping backticks and newlines stops the value breaking out of the span
|
||||
* or forging new lines. (`capture-local`'s `display()` does the terminal-side
|
||||
* equivalent for stderr; this is the Markdown-body side.)
|
||||
*/
|
||||
function mdField(s: unknown): string {
|
||||
return (
|
||||
'`' +
|
||||
String(s)
|
||||
.replace(/[`\r\n]+/g, ' ')
|
||||
.trim() +
|
||||
'`'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The report filename the orchestrator writes and this derives — pr-numbered
|
||||
* when the plan resolved a PR, a stable local name otherwise (matching the old
|
||||
|
|
@ -4349,14 +4721,106 @@ export const composeReviewCommand: CommandModule = {
|
|||
// trend is measured against.)
|
||||
writeStderrLine(
|
||||
`VOLUME: ${result.postedInline} inline comment(s) this round` +
|
||||
` (${result.postedFresh} reported for the first time)` +
|
||||
(result.prevPostedInline === undefined
|
||||
? ''
|
||||
: ` (previous round: ${result.prevPostedInline})`),
|
||||
);
|
||||
// The terminal copy the body's own trim notice promises. The convergence
|
||||
// paragraph is the first thing the overflow ladder sheds, and unlike the
|
||||
// deferral list (findings artifact) or the not-reviewed disclosures (the
|
||||
// model's own inputs) it has no other copy anywhere — so the notice's
|
||||
// "read them in the terminal report" was a false record until this line
|
||||
// existed.
|
||||
if (result.convergence) {
|
||||
writeStderrLine(`CONVERGENCE: ${result.convergence.en}`);
|
||||
}
|
||||
writeStderrLine(verdictLine(result));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The first line of what follows the severity marker, minus any carried id.
|
||||
* A carried-forward finding names its ORIGINAL id right after the marker —
|
||||
* `**[Critical]** R1-2: the same claim, re-reported` — and reading it back
|
||||
* here is what makes the machine ledger agree with the report it rides in,
|
||||
* instead of renumbering the entry to a fresh `R<round>-<n>` the report
|
||||
* never used.
|
||||
*
|
||||
* Module-level rather than a closure inside the ledger builder, because the
|
||||
* builder is no longer its only consumer: the convergence diagnosis reads the
|
||||
* same id to tell a re-posted still-standing finding from fresh activity, and
|
||||
* a second restatement would let one end call a comment carried while the
|
||||
* other calls it new.
|
||||
*/
|
||||
function readClaim(rest: string): { id?: string; title: string } {
|
||||
const line = rest.split('\n')[0].trim();
|
||||
const carried = LEDGER_ID_READBACK.exec(line);
|
||||
return {
|
||||
id: carried?.[1],
|
||||
title: (carried ? line.slice(carried[0].length) : line).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A drafted comment's claim line, projected the way every id consumer must
|
||||
* read it: severity marker stripped, forged footer spans and comment-marker
|
||||
* lines removed, leading render-nothing residue gone. Residue or a forged
|
||||
* span between the marker and a carried id defeats the id anchor — the
|
||||
* ledger would silently renumber the finding, and the diagnosis would count
|
||||
* a re-post as new work. Stated once so the projections cannot diverge.
|
||||
*/
|
||||
function ledgerClaimLine(body: unknown): string {
|
||||
const claim = carriedClaimLine(typeof body === 'string' ? body : '');
|
||||
return claim === null
|
||||
? ''
|
||||
: stripFooterSpans(stripCommentMarkerLines(claim)).replace(
|
||||
LEADING_INVISIBLE_RE,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This round's drafts in the shape the convergence diagnosis reads.
|
||||
*
|
||||
* The path travels WHOLE. The recurrence join has to reach across the
|
||||
* ledger's `LEDGER_MAX_FILE` cap, but truncating here to meet it does not
|
||||
* prevent prefix collisions, it creates them — and it would put a
|
||||
* 200-character prefix that names no real file into a posted paragraph. The
|
||||
* join matches a truncated ledger entry by prefix instead.
|
||||
*
|
||||
* Unmarked comments are excluded, through the same predicate `buildLedger`
|
||||
* uses: a comment with no severity marker is not a finding — it enters no
|
||||
* work list — so counting it as fresh activity would inflate a cluster and
|
||||
* satisfy the activity guard that alone keeps the trend off a settled round.
|
||||
*
|
||||
* `Array.isArray` like its two siblings: `draftedComments` arrives from a
|
||||
* model-written state JSON, and a non-array reaching `.map` throws out of
|
||||
* `composeReviewBody` and loses the whole round.
|
||||
*/
|
||||
function draftedFindingsOf(drafted: unknown): DraftedFinding[] {
|
||||
if (!Array.isArray(drafted)) return [];
|
||||
const out: DraftedFinding[] = [];
|
||||
// Deduped exactly as `idFor` dedupes: the ledger keeps the FIRST comment
|
||||
// under a carried id and re-mints this round's id for a second one, so a
|
||||
// second draft carrying the same id is a finding this round minted. Passed
|
||||
// through raw, it read as a re-post here while the marker's own work list
|
||||
// gained a round-N entry — one end calling a comment carried while the
|
||||
// other calls it new, which is the drift `readClaim` exists to prevent.
|
||||
const seen = new Set<string>();
|
||||
for (const c of drafted as Array<{ path?: unknown; body?: unknown }>) {
|
||||
if (severityOf(c) === null) continue;
|
||||
const { id } = readClaim(ledgerClaimLine(c.body));
|
||||
const carried = id !== undefined && !seen.has(id) ? id : undefined;
|
||||
if (carried !== undefined) seen.add(carried);
|
||||
out.push({
|
||||
file: typeof c.path === 'string' ? c.path : '',
|
||||
...(carried === undefined ? {} : { carriedId: carried }),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The next round's ledger: every finding this review is posting as its own —
|
||||
* the drafted inline comments plus the body Criticals. Low-confidence findings
|
||||
|
|
@ -4367,12 +4831,48 @@ export function buildLedger(
|
|||
round: number,
|
||||
drafted: Array<{ path?: unknown; line?: unknown; body?: unknown }>,
|
||||
bodyCriticals: string[],
|
||||
/**
|
||||
* The previous round's work list, when this round recovered one, and
|
||||
* whether that list was COMPLETE.
|
||||
*
|
||||
* A claimed id that names no entry in a complete list is a stray — a
|
||||
* model-written token, not a carry — and recording it mints a finding
|
||||
* under a round that never held it, which the next round's recurrence
|
||||
* join then CITES in a posted paragraph and counts toward the depth key.
|
||||
* The completeness flag is what separates a stray from a legitimately
|
||||
* re-voiced entry the marker's byte budget shed: over a shortened list
|
||||
* this cannot be told apart, so the id is retained and continuity wins.
|
||||
*/
|
||||
carriedWorkList?: { ids: ReadonlySet<string>; complete: boolean },
|
||||
): Ledger {
|
||||
const findings: LedgerFinding[] = [];
|
||||
const taken = new Set<string>();
|
||||
let next = 0;
|
||||
/** Is this claimed id one the previous round actually recorded? */
|
||||
const isCarry = (claimed: string): boolean => {
|
||||
// The admission bounds come first, and continuity does not override
|
||||
// them. An id past `LEDGER_MAX_ID`, at round 0, or claiming a round
|
||||
// ahead of this one could never have been in any list this pipeline
|
||||
// wrote — so keeping it is not continuity, it is emitting an entry the
|
||||
// serializer's own filter then refuses WHOLE: a posted finding exits the
|
||||
// work list owing no ruling, the round is mislabelled budget-truncated,
|
||||
// and the anchor is withheld. Re-minting costs the entry its cross-round
|
||||
// id and nothing else — the same trade the integer-line guard makes.
|
||||
if (claimed.length > LEDGER_MAX_ID) return false;
|
||||
const minted = Number(claimed.slice(1).split('-')[0]);
|
||||
if (!Number.isSafeInteger(minted) || minted < 1 || minted > round) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
carriedWorkList === undefined ||
|
||||
!carriedWorkList.complete ||
|
||||
carriedWorkList.ids.has(claimed)
|
||||
);
|
||||
};
|
||||
/** A carried id if it is free, else the next unused id of THIS round. */
|
||||
const idFor = (carried: string | undefined): string => {
|
||||
const idFor = (claimed: string | undefined): string => {
|
||||
const carried =
|
||||
claimed !== undefined && isCarry(claimed) ? claimed : undefined;
|
||||
if (carried && !taken.has(carried)) {
|
||||
taken.add(carried);
|
||||
return carried;
|
||||
|
|
@ -4384,22 +4884,6 @@ export function buildLedger(
|
|||
taken.add(id);
|
||||
return id;
|
||||
};
|
||||
/**
|
||||
* The first line of what follows the severity marker, minus any carried id.
|
||||
* A carried-forward finding names its ORIGINAL id right after the marker —
|
||||
* `**[Critical]** R1-2: the same claim, re-reported` — and reading it back
|
||||
* here is what makes the machine ledger agree with the report it rides in,
|
||||
* instead of renumbering the entry to a fresh `R<round>-<n>` the report
|
||||
* never used.
|
||||
*/
|
||||
const titleOf = (rest: string): { id?: string; title: string } => {
|
||||
const line = rest.split('\n')[0].trim();
|
||||
const carried = LEDGER_ID_READBACK.exec(line);
|
||||
return {
|
||||
id: carried?.[1],
|
||||
title: (carried ? line.slice(carried[0].length) : line).trim(),
|
||||
};
|
||||
};
|
||||
/**
|
||||
* A title the next round can act on. The field's job is "enough to re-locate
|
||||
* the claim", and a comment that is nothing but its severity marker leaves it
|
||||
|
|
@ -4422,27 +4906,33 @@ export function buildLedger(
|
|||
// was silently absent from the ledger, shifting every id after it.
|
||||
const sev = severityOf(c);
|
||||
if (!sev) continue;
|
||||
// carriedClaimLine is the ONE readback statement shared with presubmit's
|
||||
// carried-id extractor — marker + separator (newline-consuming) + first
|
||||
// line. Strip forged footer spans and leading render-nothing residue off
|
||||
// it before titleOf: the ledger rides the posted body as an HTML comment
|
||||
// the autofix grep reads, and residue between the marker and a carried id
|
||||
// would defeat the id anchor and silently renumber the finding.
|
||||
const claim = carriedClaimLine(typeof c.body === 'string' ? c.body : '');
|
||||
const { id: carried, title } = titleOf(
|
||||
claim === null
|
||||
? ''
|
||||
: stripFooterSpans(stripCommentMarkerLines(claim)).replace(
|
||||
LEADING_INVISIBLE_RE,
|
||||
'',
|
||||
),
|
||||
);
|
||||
const file = typeof c.path === 'string' ? c.path : '(unknown)';
|
||||
// `ledgerClaimLine` is the shared projection — `carriedClaimLine` (the ONE
|
||||
// readback statement, also used by presubmit's carried-id extractor) with
|
||||
// forged footer spans and leading render-nothing residue stripped off it.
|
||||
// The ledger rides the posted body as an HTML comment the autofix grep
|
||||
// reads, and residue between the marker and a carried id would defeat the
|
||||
// id anchor and silently renumber the finding.
|
||||
const { id: carried, title } = readClaim(ledgerClaimLine(c.body));
|
||||
const file = typeof c.path === 'string' ? c.path : LEDGER_UNKNOWN_FILE;
|
||||
findings.push({
|
||||
id: idFor(carried),
|
||||
sev: sev === 'critical' ? 'C' : 'S',
|
||||
file,
|
||||
...(typeof c.line === 'number' ? { line: c.line } : {}),
|
||||
// The flag marks the EXCEPTION — a real path that happens to be spelled
|
||||
// like a stand-in — so the stand-ins themselves cost no marker bytes and
|
||||
// a marker written before the flag existed still reads correctly.
|
||||
...(typeof c.path === 'string' && isStandInName(c.path)
|
||||
? { k: 1 as const }
|
||||
: {}),
|
||||
// Integer, like the admission test demands: a model-written `12.5`
|
||||
// emitted here is refused by the serializer's own filter, which counts
|
||||
// the WHOLE entry into `dropped` — retiring a posted finding with no
|
||||
// ruling, mislabelling the round as budget-truncated, and withholding
|
||||
// the anchor so the next round re-scopes the full diff. Dropping the
|
||||
// line alone keeps the finding and costs it only its anchor line.
|
||||
...(typeof c.line === 'number' && Number.isInteger(c.line)
|
||||
? { line: c.line }
|
||||
: {}),
|
||||
title: locatable(
|
||||
title,
|
||||
`${file}${typeof c.line === 'number' ? `:${c.line}` : ''}`,
|
||||
|
|
@ -4456,13 +4946,13 @@ export function buildLedger(
|
|||
// Leading render-nothing residue goes too, for the same reason as the
|
||||
// drafted-comment leg: residue between the marker and a carried id
|
||||
// would defeat the id anchor and silently renumber the finding.
|
||||
const { id: carried, title } = titleOf(
|
||||
const { id: carried, title } = readClaim(
|
||||
stripForUnattributedPost(b).replace(LEADING_INVISIBLE_RE, ''),
|
||||
);
|
||||
findings.push({
|
||||
id: idFor(carried),
|
||||
sev: 'C',
|
||||
file: '(body)',
|
||||
file: LEDGER_BODY_FILE,
|
||||
title: locatable(title, 'the review body'),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
933
packages/cli/src/commands/review/lib/convergence.test.ts
Normal file
933
packages/cli/src/commands/review/lib/convergence.test.ts
Normal file
|
|
@ -0,0 +1,933 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
diagnoseConvergence,
|
||||
renderConvergenceDiagnosis,
|
||||
MAX_RENDERED_CLUSTERS,
|
||||
type ConvergenceDiagnosis,
|
||||
type DraftedFinding,
|
||||
} from './convergence.js';
|
||||
import { LEDGER_MAX_ROUND, type LedgerFinding } from './ledger.js';
|
||||
|
||||
const f = (id: string, file: string): LedgerFinding => ({
|
||||
id,
|
||||
sev: 'S',
|
||||
file,
|
||||
title: 't',
|
||||
});
|
||||
|
||||
/** A fresh drafted finding, or — with an id — a re-post of an earlier one. */
|
||||
const d = (file: string, carriedId?: string): DraftedFinding =>
|
||||
carriedId === undefined ? { file } : { file, carriedId };
|
||||
|
||||
describe('diagnoseConvergence — the trigger table', () => {
|
||||
it('says nothing when the loop looks healthy', () => {
|
||||
// Shrinking volume, no repeated file: the shape that must NOT produce a
|
||||
// paragraph. Null rather than an empty diagnosis, so a caller cannot
|
||||
// render a section that says nothing.
|
||||
expect(
|
||||
diagnoseConvergence({
|
||||
round: 4,
|
||||
posted: 2,
|
||||
prev: { posted: 7, findings: [f('R3-1', 'a.ts')] },
|
||||
drafts: [d('b.ts')],
|
||||
floor: 'o',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('fires on a file that carried findings before and carries more now', () => {
|
||||
const r = diagnoseConvergence({
|
||||
round: 4,
|
||||
posted: 2,
|
||||
prev: {
|
||||
posted: 9,
|
||||
findings: [f('R2-1', 'a.ts'), f('R3-2', 'a.ts'), f('R3-3', 'z.ts')],
|
||||
},
|
||||
drafts: [d('a.ts'), d('a.ts'), d('new.ts')],
|
||||
floor: 'o',
|
||||
})!;
|
||||
expect(r.clusters).toEqual([
|
||||
{ file: 'a.ts', priorRounds: [2, 3], thisRound: 2 },
|
||||
]);
|
||||
// Recurrence alone is enough — the volume is falling here.
|
||||
expect(r.volumeNotShrinking).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the prior rounds off the carried ids, not off a count', () => {
|
||||
// The ids are the rounds the REPORT used, which is what makes the
|
||||
// rendered sentence checkable against the PR's own history.
|
||||
const r = diagnoseConvergence({
|
||||
round: 9,
|
||||
posted: 1,
|
||||
prev: {
|
||||
posted: 5,
|
||||
findings: [f('R2-1', 'a.ts'), f('R7-4', 'a.ts'), f('R5-9', 'a.ts')],
|
||||
},
|
||||
drafts: [d('a.ts')],
|
||||
floor: 'o',
|
||||
})!;
|
||||
expect(r.clusters[0].priorRounds).toEqual([2, 5, 7]);
|
||||
});
|
||||
|
||||
it('ignores entries whose id is not one, and every non-path stand-in', () => {
|
||||
// A malformed side-file entry contributes no cluster rather than a
|
||||
// wrong one; `(body)` is where unanchorable Criticals live and
|
||||
// `(unknown)` is a comment that arrived without a path — neither is a
|
||||
// file anyone can cluster on, and neither may be NAMED as one in a
|
||||
// posted paragraph. Separated by the ledger's flag, which marks the
|
||||
// EXCEPTION: a real file spelled like a stand-in carries it, a stand-in
|
||||
// carries none — see the real-file test below.
|
||||
expect(
|
||||
diagnoseConvergence({
|
||||
round: 4,
|
||||
posted: 1,
|
||||
prev: {
|
||||
posted: 9,
|
||||
findings: [
|
||||
{ id: 'nonsense', sev: 'C', file: 'a.ts', title: 't' },
|
||||
f('R2-1', '(body)'),
|
||||
f('R2-2', '(unknown)'),
|
||||
],
|
||||
},
|
||||
drafts: [d('a.ts'), d('(body)'), d('(unknown)')],
|
||||
floor: 'o',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('fires on volume that is not shrinking, from round 3', () => {
|
||||
const flat = diagnoseConvergence({
|
||||
round: 3,
|
||||
posted: 5,
|
||||
prev: { posted: 5, fresh: 1, findings: [] },
|
||||
drafts: [d('a.ts')],
|
||||
floor: 'o',
|
||||
})!;
|
||||
expect(flat.volumeNotShrinking).toBe(true);
|
||||
expect(flat.clusters).toEqual([]);
|
||||
|
||||
const grew = diagnoseConvergence({
|
||||
round: 3,
|
||||
posted: 6,
|
||||
prev: { posted: 5, fresh: 1, findings: [] },
|
||||
drafts: [d('a.ts'), d('b.ts')],
|
||||
floor: 'o',
|
||||
})!;
|
||||
expect(grew.volumeNotShrinking).toBe(true);
|
||||
});
|
||||
|
||||
it('stays silent on a loop that posted nothing — zero is where convergence lands', () => {
|
||||
// `0 >= 0` is arithmetically "not shrinking" and semantically the
|
||||
// opposite: a round that posted nothing is the observation the trend
|
||||
// exists to find, so narrating "the volume is not falling" there would
|
||||
// flag the settled state as the unsettled one.
|
||||
expect(
|
||||
diagnoseConvergence({
|
||||
round: 7,
|
||||
posted: 0,
|
||||
prev: { posted: 0, fresh: 0, findings: [] },
|
||||
drafts: [],
|
||||
floor: 'o',
|
||||
}),
|
||||
).toBeNull();
|
||||
// And the round that lands on zero from above is the clearest possible
|
||||
// shrink.
|
||||
expect(
|
||||
diagnoseConvergence({
|
||||
round: 7,
|
||||
posted: 0,
|
||||
prev: { posted: 6, fresh: 6, findings: [] },
|
||||
drafts: [],
|
||||
floor: 'o',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('will not measure a trend against a settled predecessor', () => {
|
||||
// `N >= 0` is true for every N, so a zero-posting predecessor would fire
|
||||
// the signal on the healthiest shape there is: fix everything, settle at
|
||||
// zero, push again, get new findings. Zero survives the whole
|
||||
// persistence chain by design, so this state is reachable.
|
||||
expect(
|
||||
diagnoseConvergence({
|
||||
round: 5,
|
||||
posted: 4,
|
||||
prev: { posted: 4, fresh: 0, findings: [] },
|
||||
drafts: [d('a.ts')],
|
||||
floor: 'o',
|
||||
}),
|
||||
).toBeNull();
|
||||
// A genuine flat trend still fires.
|
||||
expect(
|
||||
diagnoseConvergence({
|
||||
round: 5,
|
||||
posted: 2,
|
||||
prev: { posted: 2, fresh: 1, findings: [] },
|
||||
drafts: [d('a.ts')],
|
||||
floor: 'o',
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('holds the volume signal until round 3 — one step is not a trend', () => {
|
||||
// The counts must SATISFY every other conjunct, or the round guard is
|
||||
// not what the assertion measures: one fresh draft against `prev.fresh: 5`
|
||||
// already fails `1 >= 5`, and the test passed with or without the gate.
|
||||
const shape = {
|
||||
posted: 9,
|
||||
prev: { posted: 5, fresh: 1, findings: [] },
|
||||
drafts: [d('a.ts')],
|
||||
floor: 'o' as const,
|
||||
};
|
||||
expect(diagnoseConvergence({ ...shape, round: 2 })).toBeNull();
|
||||
expect(diagnoseConvergence({ ...shape, round: 3 })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('cannot evaluate a trend it never recovered', () => {
|
||||
// Absence makes the signal unevaluable, never true: a predecessor that
|
||||
// recorded no counts is not a predecessor that posted nothing.
|
||||
expect(
|
||||
diagnoseConvergence({
|
||||
round: 6,
|
||||
posted: 9,
|
||||
prev: { findings: [] },
|
||||
drafts: [d('a.ts')],
|
||||
floor: 'o',
|
||||
}),
|
||||
).toBeNull();
|
||||
// A total without a fresh count is the pre-field marker: the trend runs
|
||||
// on new findings, so it is unevaluable rather than measured on totals.
|
||||
expect(
|
||||
diagnoseConvergence({
|
||||
round: 6,
|
||||
posted: 9,
|
||||
prev: { posted: 4, findings: [] },
|
||||
drafts: [d('a.ts')],
|
||||
floor: 'o',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not count a re-posted still-standing finding as activity', () => {
|
||||
// Step 6 re-posts every unfixed ledger Critical under its ORIGINAL id.
|
||||
// A single Critical nobody has fixed therefore arrives every round: read
|
||||
// as activity it fires the cluster ("1 more now" with no new finding
|
||||
// ever appearing) AND the flat-volume trend, forever — at the steady
|
||||
// state, which is the opposite of what both signals mean.
|
||||
expect(
|
||||
diagnoseConvergence({
|
||||
round: 3,
|
||||
posted: 1,
|
||||
prev: { posted: 1, findings: [f('R2-1', 'src/parser.ts')] },
|
||||
drafts: [d('src/parser.ts', 'R2-1')],
|
||||
floor: 'o',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('treats a stray id that names no standing entry as a new finding', () => {
|
||||
// Step 6 teaches the model to lead a re-post with `R1-2: <the claim>`,
|
||||
// and models emit stray ids at the head of a claim line. Trusted on the
|
||||
// token alone, a genuinely new finding written that way vanishes from
|
||||
// both signals — out of its file's cluster and out of the activity
|
||||
// guard — and a round of real new work reads as the steady state.
|
||||
const r = diagnoseConvergence({
|
||||
round: 4,
|
||||
posted: 1,
|
||||
prev: {
|
||||
posted: 1,
|
||||
fresh: 1,
|
||||
complete: true,
|
||||
findings: [f('R2-1', 'src/a.ts')],
|
||||
},
|
||||
drafts: [d('src/a.ts', 'R2-99')],
|
||||
floor: 'o',
|
||||
})!;
|
||||
expect(r.clusters).toEqual([
|
||||
{ file: 'src/a.ts', priorRounds: [2], thisRound: 1 },
|
||||
]);
|
||||
expect(r.fresh).toBe(1);
|
||||
});
|
||||
|
||||
it('will not call a re-post fresh over a list that may have shed it', () => {
|
||||
// The work list keeps the id when the list is shortened (continuity
|
||||
// wins), so reading the same comment as first-time work makes one marker
|
||||
// say two things — and posts "the rate of new findings is not falling"
|
||||
// every round on a loop doing no new work.
|
||||
const shed = diagnoseConvergence({
|
||||
round: 4,
|
||||
posted: 2,
|
||||
prev: { posted: 2, fresh: 2, findings: [], truncated: true },
|
||||
drafts: [d('src/a.ts', 'R3-7'), d('src/b.ts', 'R3-8')],
|
||||
floor: 'o',
|
||||
});
|
||||
expect(shed).toBeNull();
|
||||
// Over a list known WHOLE, the same ids are strays and count as new.
|
||||
const whole = diagnoseConvergence({
|
||||
round: 4,
|
||||
posted: 2,
|
||||
prev: { posted: 2, fresh: 2, complete: true, findings: [] },
|
||||
drafts: [d('src/a.ts', 'R3-7'), d('src/b.ts', 'R3-8')],
|
||||
floor: 'o',
|
||||
})!;
|
||||
expect(whole.fresh).toBe(2);
|
||||
});
|
||||
|
||||
it('still clusters a genuinely new finding in a re-posted file', () => {
|
||||
// The exclusion is per-comment, not per-file: the file is still
|
||||
// regenerating work, and that is exactly what the signal is for.
|
||||
const r = diagnoseConvergence({
|
||||
round: 3,
|
||||
posted: 2,
|
||||
prev: { posted: 1, findings: [f('R2-1', 'src/parser.ts')] },
|
||||
drafts: [d('src/parser.ts', 'R2-1'), d('src/parser.ts')],
|
||||
floor: 'o',
|
||||
})!;
|
||||
expect(r.clusters).toEqual([
|
||||
{ file: 'src/parser.ts', priorRounds: [2], thisRound: 1 },
|
||||
]);
|
||||
// The volume fact stays the honest posted total, re-posts included.
|
||||
expect(r.posted).toBe(2);
|
||||
});
|
||||
|
||||
it('treats an id this round would mint as fresh, not as carried', () => {
|
||||
// "Carried" means minted in an EARLIER round; the comparison is strict
|
||||
// so a same-round id cannot silently erase this round's own work. The id
|
||||
// is IN the work list, so the stray-id branch cannot be what decides it
|
||||
// — the final `minted >= round` comparison is.
|
||||
const r = diagnoseConvergence({
|
||||
round: 3,
|
||||
posted: 1,
|
||||
prev: {
|
||||
posted: 1,
|
||||
fresh: 1,
|
||||
findings: [f('R2-1', 'a.ts'), f('R3-1', 'a.ts')],
|
||||
},
|
||||
drafts: [d('a.ts', 'R3-1')],
|
||||
floor: 'o',
|
||||
})!;
|
||||
expect(r.clusters[0].thisRound).toBe(1);
|
||||
expect(r.fresh).toBe(1);
|
||||
});
|
||||
|
||||
it('orders clusters by new work now, then by depth, then by path', () => {
|
||||
// This round's count leads: the prior-round depth measures the wrong
|
||||
// thing for the sentence it ranks — the previous ledger is a POSTING
|
||||
// set, so depth grows exactly where nothing is being fixed — and it is
|
||||
// the key a stranger can set with one marker full of legal ids.
|
||||
const r = diagnoseConvergence({
|
||||
round: 5,
|
||||
posted: 4,
|
||||
prev: {
|
||||
posted: 9,
|
||||
findings: [
|
||||
f('R2-1', 'persistent.ts'),
|
||||
f('R3-1', 'persistent.ts'),
|
||||
f('R4-1', 'busy.ts'),
|
||||
f('R4-2', 'quiet.ts'),
|
||||
],
|
||||
},
|
||||
drafts: [d('persistent.ts'), d('busy.ts'), d('busy.ts'), d('quiet.ts')],
|
||||
floor: 'o',
|
||||
})!;
|
||||
expect(r.clusters.map((c) => c.file)).toEqual([
|
||||
'busy.ts',
|
||||
'persistent.ts',
|
||||
'quiet.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('breaks path ties on code units, not on the runtime locale', () => {
|
||||
// `localeCompare` collates by locale: under en_US `é` sorts before `z`,
|
||||
// by code unit it sorts after (U+00E9 > U+007A). The clustered paths
|
||||
// belong to whatever repository is under review, and the CI bot's locale
|
||||
// need not match a maintainer's — so the tie-break must not consult one.
|
||||
const r = diagnoseConvergence({
|
||||
round: 5,
|
||||
posted: 2,
|
||||
prev: { posted: 9, findings: [f('R2-1', 'é.ts'), f('R2-2', 'z.ts')] },
|
||||
drafts: [d('é.ts'), d('z.ts')],
|
||||
floor: 'o',
|
||||
})!;
|
||||
expect(r.clusters.map((c) => c.file)).toEqual(['z.ts', 'é.ts']);
|
||||
});
|
||||
|
||||
it('clusters a real file whose name matches a stand-in', () => {
|
||||
// The stand-ins are legal filenames — git permits `(body)` — so a reader
|
||||
// that excluded them BY VALUE dropped exactly that file from clustering
|
||||
// while claiming to drop a stand-in. The ledger's flag is what separates
|
||||
// them, and it marks the EXCEPTION — the real file carries it, the
|
||||
// stand-in carries none.
|
||||
const r = diagnoseConvergence({
|
||||
round: 4,
|
||||
posted: 1,
|
||||
prev: {
|
||||
posted: 9,
|
||||
findings: [{ id: 'R2-1', sev: 'S', file: '(body)', title: 't', k: 1 }],
|
||||
},
|
||||
drafts: [d('(body)')],
|
||||
floor: 'o',
|
||||
})!;
|
||||
expect(r.clusters).toEqual([
|
||||
{ file: '(body)', priorRounds: [2], thisRound: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails toward "carried" where the id space collides at the cap', () => {
|
||||
// Consecutive rounds AT `LEDGER_MAX_ROUND` both stamp `R<cap>-*`, so a
|
||||
// re-post is indistinguishable from a fresh finding by its id. The two
|
||||
// errors do not cost the same: calling a re-post fresh narrates
|
||||
// divergence at the steady state every round forever, calling a fresh
|
||||
// finding carried costs one round of silence.
|
||||
expect(
|
||||
diagnoseConvergence({
|
||||
round: LEDGER_MAX_ROUND,
|
||||
posted: 1,
|
||||
prev: {
|
||||
posted: 1,
|
||||
findings: [f(`R${LEDGER_MAX_ROUND}-1`, 'src/p.ts')],
|
||||
},
|
||||
drafts: [d('src/p.ts', `R${LEDGER_MAX_ROUND}-1`)],
|
||||
floor: 'o',
|
||||
}),
|
||||
).toBeNull();
|
||||
// Below the cap the ids still separate the two, so the strict rule holds.
|
||||
expect(
|
||||
diagnoseConvergence({
|
||||
round: 3,
|
||||
posted: 1,
|
||||
prev: { posted: 1, findings: [f('R2-1', 'src/p.ts')] },
|
||||
drafts: [d('src/p.ts', 'R3-1')],
|
||||
floor: 'o',
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('will not read a posture change as loop divergence', () => {
|
||||
// An operator who takes this module's own advice, sets a critical floor,
|
||||
// then restores it produces a volume jump that is a policy change, not a
|
||||
// loop. Firing there would advise re-tightening the floor just
|
||||
// deliberately loosened.
|
||||
expect(
|
||||
diagnoseConvergence({
|
||||
round: 8,
|
||||
posted: 5,
|
||||
prev: { posted: 1, fresh: 1, findings: [], floor: 'c' },
|
||||
drafts: [d('a.ts'), d('b.ts')],
|
||||
floor: 'o',
|
||||
}),
|
||||
).toBeNull();
|
||||
// Same floor, same numbers: a real flat trend still fires.
|
||||
expect(
|
||||
diagnoseConvergence({
|
||||
round: 8,
|
||||
posted: 5,
|
||||
prev: { posted: 1, fresh: 1, findings: [], floor: 'o' },
|
||||
drafts: [d('a.ts'), d('b.ts')],
|
||||
floor: 'o',
|
||||
}),
|
||||
).not.toBeNull();
|
||||
// A predecessor that recorded no floor is not one that differs — a
|
||||
// pre-field marker evaluates exactly as it did before.
|
||||
expect(
|
||||
diagnoseConvergence({
|
||||
round: 8,
|
||||
posted: 5,
|
||||
prev: { posted: 1, fresh: 1, findings: [] },
|
||||
drafts: [d('a.ts'), d('b.ts')],
|
||||
floor: 'o',
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('measures the trend on new findings, not on the round total', () => {
|
||||
// Step 6 re-posts every unfixed ledger Critical, so the re-post floor
|
||||
// only ever rises. A loop whose NEW findings collapsed 5 -> 1 still
|
||||
// posts more comments than the round before, and a trend on the totals
|
||||
// calls that convergence "not falling" — forever.
|
||||
const carried = Array.from({ length: 30 }, (_, i) =>
|
||||
d(`old${i}.ts`, `R2-${i + 1}`),
|
||||
);
|
||||
const standing = Array.from({ length: 30 }, (_, i) =>
|
||||
f(`R2-${i + 1}`, `old${i}.ts`),
|
||||
);
|
||||
expect(
|
||||
diagnoseConvergence({
|
||||
round: 4,
|
||||
posted: 31,
|
||||
prev: { posted: 30, fresh: 5, findings: standing },
|
||||
drafts: [...carried, d('new.ts')],
|
||||
floor: 'o',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('holds the recurrence signal until round 3 — one step is not a trend', () => {
|
||||
// A round-1 finding fixed and one new finding landing in the same file
|
||||
// is the ordinary healthy re-review; on a single-file PR the "split it
|
||||
// into its own pull request" advice has no referent at all.
|
||||
expect(
|
||||
diagnoseConvergence({
|
||||
round: 2,
|
||||
posted: 1,
|
||||
prev: { posted: 3, fresh: 3, findings: [f('R1-1', 'src/foo.ts')] },
|
||||
drafts: [d('src/foo.ts')],
|
||||
floor: 'o',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('ranks the file producing new work over the file with a backlog', () => {
|
||||
// `priorRounds` deepens only where nothing is being fixed: a fixed
|
||||
// finding is not re-posted and its round leaves the list, an unfixed one
|
||||
// keeps contributing its mint round forever. Ranked by depth, the
|
||||
// backlog file took the top slot and the advice explained it as "a
|
||||
// cluster that keeps producing siblings" — about a file where no fix
|
||||
// happened.
|
||||
const r = diagnoseConvergence({
|
||||
round: 6,
|
||||
posted: 6,
|
||||
prev: {
|
||||
posted: 6,
|
||||
fresh: 2,
|
||||
findings: [
|
||||
f('R1-1', 'src/never-fixed.ts'),
|
||||
f('R2-1', 'src/never-fixed.ts'),
|
||||
f('R3-1', 'src/never-fixed.ts'),
|
||||
f('R4-1', 'src/never-fixed.ts'),
|
||||
f('R5-1', 'src/regenerating.ts'),
|
||||
],
|
||||
},
|
||||
drafts: [
|
||||
d('src/never-fixed.ts', 'R1-1'),
|
||||
d('src/never-fixed.ts'),
|
||||
d('src/regenerating.ts'),
|
||||
d('src/regenerating.ts'),
|
||||
],
|
||||
floor: 'o',
|
||||
})!;
|
||||
expect(r.clusters.map((c) => c.file)).toEqual([
|
||||
'src/regenerating.ts',
|
||||
'src/never-fixed.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches a truncated ledger entry by prefix, keeping the real path', () => {
|
||||
// The ledger caps `file` at 200 chars. Truncating the drafted side to
|
||||
// meet it does not prevent prefix collisions, it creates them — and it
|
||||
// would post a 200-char prefix as a path that exists in no repository.
|
||||
const deep = `src/${'nested/'.repeat(44)}leaf.ts`;
|
||||
const r = diagnoseConvergence({
|
||||
round: 4,
|
||||
posted: 1,
|
||||
prev: {
|
||||
posted: 9,
|
||||
fresh: 9,
|
||||
findings: [f('R2-1', deep.slice(0, 200))],
|
||||
},
|
||||
drafts: [d(deep)],
|
||||
floor: 'o',
|
||||
})!;
|
||||
expect(r.clusters[0].file).toBe(deep);
|
||||
});
|
||||
|
||||
it('carries the evidence qualifiers through to the rendering', () => {
|
||||
const r = diagnoseConvergence({
|
||||
round: 4,
|
||||
posted: 1,
|
||||
prev: {
|
||||
posted: 9,
|
||||
findings: [f('R2-1', 'a.ts')],
|
||||
truncated: true,
|
||||
foreign: true,
|
||||
},
|
||||
drafts: [d('a.ts')],
|
||||
floor: 'o',
|
||||
criticalFloorKind: 'explicit',
|
||||
})!;
|
||||
expect(r.truncatedEvidence).toBe(true);
|
||||
expect(r.foreignEvidence).toBe(true);
|
||||
expect(r.criticalFloorKind).toBe('explicit');
|
||||
});
|
||||
|
||||
it('carries the merged qualifier through, and drops the depth key with it', () => {
|
||||
// Two things ride on a foreign work list: the caveat the renderer picks,
|
||||
// and whether the ordering may consult a number a stranger set. Fifty
|
||||
// planted ids on one file still decide every `thisRound` tie, and ties
|
||||
// are the ordinary shape — one fresh finding per file.
|
||||
const planted = Array.from({ length: 50 }, (_, i) =>
|
||||
f(`R${i + 1}-1`, 'src/planted.ts'),
|
||||
);
|
||||
const r = diagnoseConvergence({
|
||||
round: 6,
|
||||
posted: 2,
|
||||
prev: {
|
||||
posted: 9,
|
||||
fresh: 9,
|
||||
foreign: true,
|
||||
merged: true,
|
||||
findings: [...planted, f('R5-1', 'src/genuine.ts')],
|
||||
},
|
||||
drafts: [d('src/planted.ts'), d('src/genuine.ts')],
|
||||
floor: 'o',
|
||||
})!;
|
||||
expect(r.mergedEvidence).toBe(true);
|
||||
// Tied on this round's count, the path decides — not the planted depth.
|
||||
expect(r.clusters.map((c) => c.file)).toEqual([
|
||||
'src/genuine.ts',
|
||||
'src/planted.ts',
|
||||
]);
|
||||
// An OWN list still ranks by depth after the count.
|
||||
const own = diagnoseConvergence({
|
||||
round: 6,
|
||||
posted: 2,
|
||||
prev: {
|
||||
posted: 9,
|
||||
fresh: 9,
|
||||
findings: [...planted, f('R5-1', 'src/genuine.ts')],
|
||||
},
|
||||
drafts: [d('src/planted.ts'), d('src/genuine.ts')],
|
||||
floor: 'o',
|
||||
})!;
|
||||
expect(own.clusters[0].file).toBe('src/planted.ts');
|
||||
|
||||
// And the drop is keyed on `foreign` ALONE — a purely foreign marker
|
||||
// adopted without a union is the ordinary shape when this account has
|
||||
// no surviving marker of its own.
|
||||
const foreignOnly = diagnoseConvergence({
|
||||
round: 6,
|
||||
posted: 2,
|
||||
prev: {
|
||||
posted: 9,
|
||||
fresh: 9,
|
||||
foreign: true,
|
||||
findings: [...planted, f('R5-1', 'src/genuine.ts')],
|
||||
},
|
||||
drafts: [d('src/planted.ts'), d('src/genuine.ts')],
|
||||
floor: 'o',
|
||||
})!;
|
||||
expect(foreignOnly.mergedEvidence).toBe(false);
|
||||
expect(foreignOnly.clusters.map((c) => c.file)).toEqual([
|
||||
'src/genuine.ts',
|
||||
'src/planted.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('will not compare a recorded floor against one this round never named', () => {
|
||||
// The asymmetric cell the guard exists for: a predecessor that recorded
|
||||
// its posture against a round whose own posture is unknown. Unknown is
|
||||
// not "matches" and not "differs" — it makes the comparison unavailable,
|
||||
// which leaves the trend evaluated as it was before floors existed.
|
||||
const r = diagnoseConvergence({
|
||||
round: 8,
|
||||
posted: 5,
|
||||
prev: { posted: 1, fresh: 1, findings: [], floor: 'c' },
|
||||
drafts: [d('a.ts'), d('b.ts')],
|
||||
})!;
|
||||
expect(r.volumeNotShrinking).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults every qualifier to false rather than undefined', () => {
|
||||
const r = diagnoseConvergence({
|
||||
round: 4,
|
||||
posted: 1,
|
||||
prev: { posted: 9, findings: [f('R2-1', 'a.ts')] },
|
||||
drafts: [d('a.ts')],
|
||||
floor: 'o',
|
||||
})!;
|
||||
expect(r.truncatedEvidence).toBe(false);
|
||||
expect(r.foreignEvidence).toBe(false);
|
||||
expect(r.criticalFloorKind).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderConvergenceDiagnosis — what the author reads', () => {
|
||||
const base: ConvergenceDiagnosis = {
|
||||
round: 6,
|
||||
posted: 4,
|
||||
fresh: 2,
|
||||
prevPosted: 4,
|
||||
prevFresh: 2,
|
||||
clusters: [{ file: 'src/a.ts', priorRounds: [3, 5], thisRound: 2 }],
|
||||
volumeNotShrinking: true,
|
||||
truncatedEvidence: false,
|
||||
foreignEvidence: false,
|
||||
mergedEvidence: false,
|
||||
};
|
||||
|
||||
it('states the measured facts before the reading of them', () => {
|
||||
const r = renderConvergenceDiagnosis(base);
|
||||
expect(r.en).toContain(
|
||||
'round 6 posted 4 inline comment(s), 2 of them reported for the first time',
|
||||
);
|
||||
expect(r.en).toContain('the previous round posted 4');
|
||||
expect(r.en).toContain('`src/a.ts` (findings in rounds 3, 5, 2 more now)');
|
||||
expect(r.zh).toContain('第 6 轮发布了 4 条行内评论,其中 2 条是首次提出');
|
||||
expect(r.zh).toContain('第 3、5 轮已出过发现,本轮又有 2 条');
|
||||
});
|
||||
|
||||
it('pluralises the prior-round list by how many rounds it names', () => {
|
||||
// The commonest recurrence shape by far is one prior round — flagged in
|
||||
// round N, re-flagged in N+1 — so the singular branch is the one most
|
||||
// readers see.
|
||||
const one = renderConvergenceDiagnosis({
|
||||
...base,
|
||||
clusters: [{ file: 'src/a.ts', priorRounds: [4], thisRound: 1 }],
|
||||
});
|
||||
expect(one.en).toContain('`src/a.ts` (findings in round 4, 1 more now)');
|
||||
expect(one.en).not.toContain('in rounds 4,');
|
||||
});
|
||||
|
||||
it('says the observation withheld nothing — scoped to the observation', () => {
|
||||
// The same body can carry a floor-enforcement note, a deferral list, or
|
||||
// a discarded-Suggestion count, all of them things withheld from this
|
||||
// round's posting surface. An absolute claim beside those is one the
|
||||
// body itself refutes; the claim this module can make is about its own
|
||||
// effect, which is none.
|
||||
const r = renderConvergenceDiagnosis(base);
|
||||
expect(r.en).toContain(
|
||||
'nothing was withheld from this review because of this observation',
|
||||
);
|
||||
expect(r.zh).toContain('未因此扣留任何内容');
|
||||
});
|
||||
|
||||
it('advises at the process level, never on code structure', () => {
|
||||
const r = renderConvergenceDiagnosis(base);
|
||||
expect(r.en).toContain('shared root cause');
|
||||
expect(r.en).toContain('splitting an independent cluster');
|
||||
// The claim it must never make: how the code should be rewritten.
|
||||
expect(r.en).not.toMatch(/refactor|rewrite|extract .* class|redesign/i);
|
||||
// Same claim, same direction, other language — an en-only negative
|
||||
// catches en regressions only.
|
||||
expect(r.zh).toContain('根因');
|
||||
expect(r.zh).toContain('拆成单独的 PR');
|
||||
expect(r.zh).not.toMatch(/重构|重写|重新设计/);
|
||||
});
|
||||
|
||||
it('falls back to the volume reading when nothing recurs', () => {
|
||||
const r = renderConvergenceDiagnosis({ ...base, clusters: [] });
|
||||
expect(r.en).toContain('The rate of new findings is not falling.');
|
||||
expect(r.en).toContain('--severity-floor critical');
|
||||
expect(r.zh).toContain('把剩余修复攒成一批');
|
||||
expect(r.zh).toContain('或将本 PR 的评审降到 `--severity-floor critical`');
|
||||
});
|
||||
|
||||
it('does not recommend a floor the round is already running under', () => {
|
||||
// Advice is matched to the telemetry's shape. Told to "drop this PR's
|
||||
// reviews to --severity-floor critical" inside the very body whose
|
||||
// floor-enforcement note says Suggestions were already moved past that
|
||||
// floor, the paragraph reads as advice nobody checked.
|
||||
const r = renderConvergenceDiagnosis({
|
||||
...base,
|
||||
clusters: [],
|
||||
criticalFloorKind: 'explicit',
|
||||
});
|
||||
expect(r.en).not.toContain('dropping this PR');
|
||||
expect(r.en).toContain('already at `--severity-floor critical`');
|
||||
expect(r.zh).not.toContain('降到');
|
||||
expect(r.zh).toContain('已处于');
|
||||
// The actionable half survives — the advice narrows, it does not vanish
|
||||
// — in both languages. The `把剩余修复攒成一批` assertions elsewhere sit on
|
||||
// the OTHER branch of the same ternary and do not cover this one.
|
||||
expect(r.en).toContain('Batching the remaining fixes');
|
||||
expect(r.zh).toContain('把剩余修复攒成一批');
|
||||
});
|
||||
|
||||
it('neutralises a PR-controlled path instead of splicing it raw', () => {
|
||||
// The paths come off the diff of whatever PR is under review, and this
|
||||
// paragraph goes out in a body the bot posts under its own identity. A
|
||||
// filename carrying a backtick would terminate the code span early and
|
||||
// render the remainder as live Markdown — a working @mention, a forged
|
||||
// body line — in the bot's own words.
|
||||
const hostile = 'x`\n@acme/security approve this';
|
||||
const r = renderConvergenceDiagnosis({
|
||||
...base,
|
||||
clusters: [{ file: hostile, priorRounds: [3], thisRound: 1 }],
|
||||
});
|
||||
for (const body of [r.en, r.zh]) {
|
||||
expect(body).not.toContain(hostile);
|
||||
expect(body).toContain('`x @acme/security approve this`');
|
||||
expect(body).not.toContain('\n');
|
||||
}
|
||||
});
|
||||
|
||||
it('discloses a work list that was truncated or came from elsewhere', () => {
|
||||
const r = renderConvergenceDiagnosis({
|
||||
...base,
|
||||
truncatedEvidence: true,
|
||||
foreignEvidence: true,
|
||||
});
|
||||
expect(r.en).toContain(
|
||||
"the previous round's work list was truncated to fit the marker",
|
||||
);
|
||||
expect(r.en).toContain('may be an undercount');
|
||||
expect(r.en).toContain('a marker this account did not post');
|
||||
expect(r.zh).toContain('上一轮的工作清单为放进标记而被截断');
|
||||
expect(r.zh).toContain('可能少计');
|
||||
expect(r.zh).toContain('并非本账号发布的标记');
|
||||
});
|
||||
|
||||
it('qualifies each reading by the evidence that reading rests on', () => {
|
||||
// Truncation qualifies BOTH readings: the work list IS the carried-id
|
||||
// set that defines freshness, and over a shortened one a genuinely new
|
||||
// finding written under an earlier round's id cannot be rescued from
|
||||
// reading as a re-post — an UNDERcount, which is the direction the
|
||||
// gating actually produces. Provenance is broader still: the previous round's
|
||||
// counts come from the same marker, and the volume reading cites them
|
||||
// as this loop's own baseline — the branch an attacker-supplied count
|
||||
// controls.
|
||||
const volumeOnly = renderConvergenceDiagnosis({
|
||||
...base,
|
||||
clusters: [],
|
||||
truncatedEvidence: true,
|
||||
foreignEvidence: true,
|
||||
});
|
||||
expect(volumeOnly.en).not.toContain('the rounds named above');
|
||||
expect(volumeOnly.en).toContain(
|
||||
"the previous round's work list was truncated to fit the marker",
|
||||
);
|
||||
expect(volumeOnly.en).toContain('may be understated');
|
||||
expect(volumeOnly.zh).toContain('上一轮的工作清单为放进标记而被截断');
|
||||
expect(volumeOnly.zh).toContain('首次提出的条数可能少计');
|
||||
expect(volumeOnly.en).toContain('those counts');
|
||||
expect(volumeOnly.en).toContain('a marker this account did not post');
|
||||
expect(volumeOnly.zh).toContain('该计数');
|
||||
|
||||
// Truncation still qualifies: the facts clause cites this round's fresh
|
||||
// count unconditionally, and a shortened carried list inflates exactly
|
||||
// that number. Provenance has nothing to qualify here — no rounds are
|
||||
// named and no previous count is cited.
|
||||
const noCitations = renderConvergenceDiagnosis({
|
||||
round: 4,
|
||||
posted: 3,
|
||||
fresh: 3,
|
||||
clusters: [],
|
||||
volumeNotShrinking: false,
|
||||
truncatedEvidence: true,
|
||||
foreignEvidence: true,
|
||||
mergedEvidence: false,
|
||||
});
|
||||
expect(noCitations.en).toContain('may be understated');
|
||||
expect(noCitations.en).not.toContain('this account did not post');
|
||||
|
||||
const nothingAtAll = renderConvergenceDiagnosis({
|
||||
round: 4,
|
||||
posted: 3,
|
||||
fresh: 3,
|
||||
clusters: [],
|
||||
volumeNotShrinking: false,
|
||||
truncatedEvidence: false,
|
||||
foreignEvidence: true,
|
||||
mergedEvidence: false,
|
||||
});
|
||||
expect(nothingAtAll.zh).not.toContain('证据说明');
|
||||
});
|
||||
|
||||
it('names an auto-resolved floor as resolved, not as a flag nobody passed', () => {
|
||||
// `auto` is the DEFAULT configuration, and it fails open the moment
|
||||
// context becomes unavailable — so wording it as an explicit setting
|
||||
// both claims a flag that was never passed and overstates how firmly it
|
||||
// holds. The floor-enforcement note in the same body says "resolved".
|
||||
const r = renderConvergenceDiagnosis({
|
||||
...base,
|
||||
clusters: [],
|
||||
criticalFloorKind: 'auto-resolved',
|
||||
});
|
||||
expect(r.en).toContain('already resolve to a critical posting floor');
|
||||
expect(r.en).not.toContain('--severity-floor critical');
|
||||
expect(r.zh).toContain('已解析为 critical 发布下限');
|
||||
});
|
||||
|
||||
it('reports both readings when both signals fired', () => {
|
||||
// Discriminating on the clusters alone made the volume sentence — and
|
||||
// with it the whole floor recommendation — unreachable on the shape this
|
||||
// feature exists for: recurrence and a flat trend together.
|
||||
const r = renderConvergenceDiagnosis(base);
|
||||
expect(r.en).toContain('Findings keep coming back to the same files');
|
||||
expect(r.en).toContain('The rate of new findings is not falling.');
|
||||
expect(r.en).toContain('shared root cause');
|
||||
expect(r.en).toContain('--severity-floor critical');
|
||||
expect(r.zh).toContain('新发现的产出速度没有下降。');
|
||||
// Both halves of the zh advice, which no assertion reached: the cluster
|
||||
// reading and the batching clause the floor reading opens with.
|
||||
expect(r.zh).toContain('一个不断再生兄弟发现的簇');
|
||||
expect(r.zh).toContain('把剩余修复攒成一批');
|
||||
});
|
||||
|
||||
it('says "some of" when the foreign list was merged over this account\'s own', () => {
|
||||
// The union restores this account's own certified entries under their
|
||||
// own ids, so an unqualified "may not be this account's own" overstates
|
||||
// by exactly the part the union protected.
|
||||
const r = renderConvergenceDiagnosis({
|
||||
...base,
|
||||
foreignEvidence: true,
|
||||
mergedEvidence: true,
|
||||
});
|
||||
expect(r.en).toContain("merged over this account's own entries");
|
||||
expect(r.en).toContain('so some of those rounds');
|
||||
expect(r.zh).toContain('并与本账号自己的条目合并');
|
||||
expect(r.zh).toContain('中的部分可能不属于本账号');
|
||||
});
|
||||
|
||||
it('states both previous-round numbers, not just the total', () => {
|
||||
// The previous-round clause is the only rendering of the baseline the
|
||||
// volume reading cites, and it has two numeric slots.
|
||||
const r = renderConvergenceDiagnosis({
|
||||
...base,
|
||||
prevPosted: 9,
|
||||
prevFresh: 4,
|
||||
});
|
||||
expect(r.en).toContain('the previous round posted 9 (4 new)');
|
||||
expect(r.zh).toContain('上一轮发布了 9 条(其中 4 条首次提出)');
|
||||
// A predecessor with no fresh count renders the total alone.
|
||||
const older = renderConvergenceDiagnosis({
|
||||
...base,
|
||||
prevPosted: 9,
|
||||
prevFresh: undefined,
|
||||
});
|
||||
expect(older.en).toContain('the previous round posted 9');
|
||||
expect(older.en).not.toContain('new)');
|
||||
expect(older.zh).toContain('上一轮发布了 9 条');
|
||||
expect(older.zh).not.toContain('首次提出)');
|
||||
});
|
||||
|
||||
it('names both citations when the reading rests on both', () => {
|
||||
const r = renderConvergenceDiagnosis({ ...base, foreignEvidence: true });
|
||||
expect(r.en).toContain('those rounds and its counts');
|
||||
expect(r.zh).toContain('上述轮次与其计数');
|
||||
});
|
||||
|
||||
it('summarises the tail instead of listing every cluster', () => {
|
||||
const many = Array.from({ length: MAX_RENDERED_CLUSTERS + 2 }, (_, i) => ({
|
||||
file: `f${i}.ts`,
|
||||
priorRounds: [2],
|
||||
thisRound: 1,
|
||||
}));
|
||||
const r = renderConvergenceDiagnosis({ ...base, clusters: many });
|
||||
expect(r.en).toContain('and 2 more file(s)');
|
||||
expect(r.en).not.toContain(`f${MAX_RENDERED_CLUSTERS}.ts`);
|
||||
expect(r.zh).toContain('另有 2 个文件');
|
||||
});
|
||||
|
||||
it('omits the previous round when none was recovered', () => {
|
||||
const r = renderConvergenceDiagnosis({
|
||||
round: 4,
|
||||
posted: 3,
|
||||
fresh: 3,
|
||||
clusters: base.clusters,
|
||||
volumeNotShrinking: false,
|
||||
truncatedEvidence: false,
|
||||
foreignEvidence: false,
|
||||
mergedEvidence: false,
|
||||
});
|
||||
expect(r.en).toContain('round 4 posted 3 inline comment(s)');
|
||||
expect(r.en).not.toContain('the previous round posted');
|
||||
expect(r.zh).not.toContain('上一轮发布了');
|
||||
});
|
||||
});
|
||||
640
packages/cli/src/commands/review/lib/convergence.ts
Normal file
640
packages/cli/src/commands/review/lib/convergence.ts
Normal file
|
|
@ -0,0 +1,640 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Is this review loop converging, and if not, why?
|
||||
//
|
||||
// A push-triggered review plus an agent addressing its findings is a feedback
|
||||
// loop, and the loop's gain can exceed 1: every accepted fix widens the diff,
|
||||
// the next round reviews more code, and more findings come back. Measured on
|
||||
// this repository, PRs have carried hundreds of open threads and still not
|
||||
// settled — one closed unmerged at ~500.
|
||||
//
|
||||
// The damper the pipeline already has is the round-adaptive posting floor, but
|
||||
// nothing tells the humans WHY a particular loop is not settling. This module
|
||||
// answers that from facts the round already holds, and it answers only that:
|
||||
// it states what it measured and what the shapes usually mean, and it makes no
|
||||
// decision. Whether to keep fixing, restructure, split or land is the author's
|
||||
// and the operator's call — the skill holds advisory power, never decision
|
||||
// power, so nothing here withholds a finding, caps a verdict, or changes what
|
||||
// the round posts.
|
||||
//
|
||||
// Every trigger is a comparison between THIS pull request's own rounds. There
|
||||
// is no threshold, no "too many comments" number: a volume bar is somebody's
|
||||
// policy, and a policy the tool owns is a policy the tool would have to defend
|
||||
// on repositories it knows nothing about. A PR diverging at 40 comments
|
||||
// deserves the reading that a threshold of 100 would have delayed, and a large
|
||||
// review whose findings are shrinking deserves no interruption at all.
|
||||
|
||||
import {
|
||||
LEDGER_ID_TOKEN,
|
||||
LEDGER_MAX_FILE,
|
||||
LEDGER_MAX_ROUND,
|
||||
isStandInName,
|
||||
type LedgerFinding,
|
||||
} from './ledger.js';
|
||||
import { mdField } from './md-field.js';
|
||||
|
||||
/** The id grammar, anchored so a cross-reference in prose cannot match. */
|
||||
const ID_HEAD = new RegExp(`^(${LEDGER_ID_TOKEN})`);
|
||||
|
||||
/** The round an id was minted in, or undefined when the id is not one. */
|
||||
function birthRound(id: unknown): number | undefined {
|
||||
if (typeof id !== 'string') return undefined;
|
||||
const m = ID_HEAD.exec(id.trim());
|
||||
if (!m) return undefined;
|
||||
const round = Number(m[1].slice(1).split('-')[0]);
|
||||
return Number.isInteger(round) && round > 0 ? round : undefined;
|
||||
}
|
||||
|
||||
/** A file this round and earlier rounds both produced findings in. */
|
||||
export interface RecurrenceCluster {
|
||||
file: string;
|
||||
/**
|
||||
* Rounds that already reported a finding here, ascending — read off the
|
||||
* carried ledger ids (`R<round>-<n>`), which is why they are the rounds the
|
||||
* REPORT used rather than a count this module invents.
|
||||
*/
|
||||
priorRounds: number[];
|
||||
/** How many of this round's drafted comments land in this file. */
|
||||
thisRound: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One of this round's drafted comments, as far as the diagnosis needs it.
|
||||
*
|
||||
* The carried id is what separates NEW activity from a still-standing finding
|
||||
* the round re-posts. Step 6 re-posts every unfixed ledger Critical under its
|
||||
* ORIGINAL id, so a single Critical nobody has fixed yet arrives in
|
||||
* `drafts` every round: counted as activity it fires both signals forever —
|
||||
* a cluster that gains "1 more now" with no new finding ever appearing, and a
|
||||
* flat volume trend — which is the steady state, not divergence.
|
||||
*/
|
||||
export interface DraftedFinding {
|
||||
/** The path this comment anchors to; empty when it has none. */
|
||||
file: string;
|
||||
/**
|
||||
* The ledger id the body carries when it re-posts an earlier round's
|
||||
* finding, as the shared readback extracted it. Absent on a fresh finding,
|
||||
* which has no id until this round's ledger is built.
|
||||
*/
|
||||
carriedId?: string;
|
||||
}
|
||||
|
||||
/** What the previous round left behind, and how far it can be trusted. */
|
||||
export interface PrevRound {
|
||||
/** Inline comments the previous round posted, when it recorded the number. */
|
||||
posted?: number;
|
||||
/** Its work list, as the side file recovered it. */
|
||||
findings: readonly LedgerFinding[];
|
||||
/**
|
||||
* Its marker shed findings to fit the ledger's byte budget, so the list is
|
||||
* known-incomplete. Measured at up to 35 shed per round on the worst PRs
|
||||
* this feature targets — exactly the loops the diagnosis speaks to, so the
|
||||
* undercount is disclosed rather than presented as a full count.
|
||||
*/
|
||||
truncated?: boolean;
|
||||
/**
|
||||
* The marker it came from was not posted by this account. Recovery adopts
|
||||
* the highest-round marker whoever posted it, so the round numbers a
|
||||
* cluster cites can name rounds this account never ran. Disclosed rather
|
||||
* than dropped: the citation is still the best evidence available, and a
|
||||
* reader who knows where it came from can check it.
|
||||
*/
|
||||
foreign?: boolean;
|
||||
/**
|
||||
* The work list is WHOLE — nothing was shed by the marker's byte budget,
|
||||
* nothing was refused by the admission test, and it really was recovered.
|
||||
* Absence of an id from an incomplete list proves nothing.
|
||||
*/
|
||||
complete?: boolean;
|
||||
/**
|
||||
* That foreign marker was MERGED over this account's own findings, which
|
||||
* survive the union under their own ids. It changes what the disclosure
|
||||
* can honestly claim: "may not be this account's own" over a work list
|
||||
* that is predominantly this account's own certified entries overstates
|
||||
* by exactly the part the union protected.
|
||||
*/
|
||||
merged?: boolean;
|
||||
/**
|
||||
* The posting floor it ran under, when its marker recorded one. A round
|
||||
* that posted under a different floor is not a comparable point on this
|
||||
* loop's volume trend — the posture changed, not the loop.
|
||||
*/
|
||||
floor?: 'c' | 'o';
|
||||
/**
|
||||
* How many of its comments were findings reported for the FIRST time.
|
||||
* The number the trend is about — see `fresh` on the diagnosis.
|
||||
*/
|
||||
fresh?: number;
|
||||
}
|
||||
|
||||
export interface ConvergenceDiagnosis {
|
||||
/** The round being composed. */
|
||||
round: number;
|
||||
/** Inline comments this round posts, and the previous round's when known. */
|
||||
posted: number;
|
||||
prevPosted?: number;
|
||||
/**
|
||||
* How many of those were reported for the FIRST time, this round and the
|
||||
* previous one. The trend runs on these, not on the totals: Step 6
|
||||
* re-posts every unfixed ledger Critical under its original id, so the
|
||||
* re-post floor only ever rises and a loop whose new findings collapsed
|
||||
* from five to one still posts more comments than the round before.
|
||||
*/
|
||||
fresh: number;
|
||||
prevFresh?: number;
|
||||
/** Files that carried findings before and carry more now. */
|
||||
clusters: RecurrenceCluster[];
|
||||
/** True when this round's volume did not fall below the previous round's. */
|
||||
volumeNotShrinking: boolean;
|
||||
/** Carried through from `PrevRound` so the rendering can disclose them. */
|
||||
truncatedEvidence: boolean;
|
||||
foreignEvidence: boolean;
|
||||
mergedEvidence: boolean;
|
||||
/**
|
||||
* HOW this round's floor resolved to `critical`, or null if it did not.
|
||||
*
|
||||
* The kind, not a boolean, because the advice quotes it back: `auto` is the
|
||||
* default configuration, and wording an auto-resolved floor as an explicit
|
||||
* `--severity-floor critical` setting claims a flag nobody passed — beside
|
||||
* a floor-enforcement note in the same body that describes it accurately as
|
||||
* the RESOLVED floor. Auto also fails open the moment context becomes
|
||||
* unavailable, which an unconditional-sounding claim would misstate.
|
||||
*/
|
||||
criticalFloorKind?: CriticalFloorKind;
|
||||
}
|
||||
|
||||
/** How a round's posting floor came to be `critical`. */
|
||||
export type CriticalFloorKind = 'explicit' | 'auto-resolved';
|
||||
|
||||
/**
|
||||
* Is this draft a finding reported for the FIRST time?
|
||||
*
|
||||
* The ONE statement of freshness. Step 6 re-posts every still-standing
|
||||
* ledger entry under its ORIGINAL id, so an id minted in an earlier round
|
||||
* marks a re-post — the loop holding its position, not the loop generating
|
||||
* work. Exported because the marker records the count for the next round's
|
||||
* trend, and a second restatement there would let the number the trend reads
|
||||
* disagree with the drafts the trend is about.
|
||||
*
|
||||
* Strict below the round cap. AT the cap the id space collides — consecutive
|
||||
* rounds both stamp `R<cap>-*` — so the rule fails toward "carried", because
|
||||
* the two errors do not cost the same: calling a re-post fresh narrates
|
||||
* divergence at the steady state every round forever, while calling a fresh
|
||||
* finding carried costs one round of silence.
|
||||
*/
|
||||
export function isFreshDraft(
|
||||
d: DraftedFinding,
|
||||
round: number,
|
||||
carried: ReadonlySet<string> = EVERY_ID,
|
||||
carriedComplete = true,
|
||||
): boolean {
|
||||
const minted = birthRound(d?.carriedId);
|
||||
if (minted === undefined) return true;
|
||||
// The id must NAME an entry in the work list it claims to carry forward.
|
||||
// Step 6 teaches the model to lead a re-post with `R1-2: <the claim>`, and
|
||||
// models emit stray ids at the head of a claim line — so a genuinely new
|
||||
// finding written in that shape would otherwise vanish from both signals:
|
||||
// out of its file's cluster, and out of the activity guard, leaving a
|
||||
// round of real new work reading as the steady state.
|
||||
//
|
||||
// Only over a list known to be WHOLE, and for the same reason `buildLedger`
|
||||
// keeps such an id over a shortened one: a non-member there may be an entry
|
||||
// the byte budget shed, which Step 6 re-voices under its original id. Read
|
||||
// as first-time work it would post "the rate of new findings is not
|
||||
// falling" every round on a loop doing no new work — and one marker would
|
||||
// say two things about the same comment, since the work list keeps the id
|
||||
// the fresh count calls new.
|
||||
if (
|
||||
carriedComplete &&
|
||||
d.carriedId !== undefined &&
|
||||
!carried.has(d.carriedId)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (round >= LEDGER_MAX_ROUND && minted >= LEDGER_MAX_ROUND) return false;
|
||||
return minted >= round;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default for a caller with no work list to check against — the id's own
|
||||
* round is then all there is.
|
||||
*
|
||||
* Every production caller HAS one and must pass it: the marker's fresh count
|
||||
* and the posted paragraph's are the same number about the same round, and
|
||||
* two different carried-sets made one body state two volumes — with the
|
||||
* marker's undercount persisting as the next round's `prev.fresh`, where the
|
||||
* trend's own guard reads it.
|
||||
*/
|
||||
const EVERY_ID: ReadonlySet<string> = {
|
||||
has: () => true,
|
||||
} as unknown as ReadonlySet<string>;
|
||||
|
||||
/**
|
||||
* The diagnosis for this round, or null when the loop looks healthy.
|
||||
*
|
||||
* Two signals, either of which fires it, and both are self-comparisons:
|
||||
*
|
||||
* - **Recurrence.** A file that carried a finding in an earlier round and
|
||||
* carries a NEW one now. Joined by FILE, deterministically — no model
|
||||
* judgement, no similarity scoring. Title similarity was considered and
|
||||
* dropped: the titles are model-written and capped at 80 characters, which
|
||||
* makes them noise at exactly the length where a match would matter. A
|
||||
* cluster that keeps regenerating siblings usually means the fixes are
|
||||
* treating instances of a shared root cause, and that sentence is the whole
|
||||
* value here.
|
||||
* - **Volume not shrinking.** From round 3, this round producing at least as
|
||||
* many NEW findings as the previous one. Round 3 because two rounds give
|
||||
* one step and a step is not a trend; "not shrinking" rather than
|
||||
* "growing" because a loop holding steady is not converging either; and
|
||||
* NEW findings rather than the comment total because Step 6 re-posts every
|
||||
* unfixed entry, so the total only ever rises.
|
||||
*
|
||||
* Both signals read FRESH drafts only. A re-posted still-standing finding is
|
||||
* the loop holding its position, not the loop generating work, and counting
|
||||
* it as activity fires both signals on the calmest shape there is (see
|
||||
* `DraftedFinding`).
|
||||
*
|
||||
* Returns null — not an empty diagnosis — when neither fires, so a caller
|
||||
* cannot accidentally render a section that says nothing. Absent inputs make
|
||||
* a signal impossible to evaluate rather than true: a round with no recovered
|
||||
* predecessor has no volume to compare against, and one with no previous work
|
||||
* list has no recurrence to find.
|
||||
*/
|
||||
export function diagnoseConvergence(input: {
|
||||
round: number;
|
||||
posted: number;
|
||||
prev: PrevRound;
|
||||
/** This round's drafted comments. */
|
||||
drafts: readonly DraftedFinding[];
|
||||
/**
|
||||
* The floor THIS round resolved to, for comparison against the previous —
|
||||
* absent when the state named no floor this module recognises. An unknown
|
||||
* posture is not a posture that matches, and it is not one that differs:
|
||||
* it makes the comparison unavailable, which leaves the trend evaluated as
|
||||
* it was before floors were recorded at all.
|
||||
*/
|
||||
floor?: 'c' | 'o';
|
||||
criticalFloorKind?: CriticalFloorKind;
|
||||
}): ConvergenceDiagnosis | null {
|
||||
const priorByFile = new Map<string, Set<number>>();
|
||||
for (const f of input.prev.findings) {
|
||||
if (typeof f?.file !== 'string' || f.file.trim() === '') continue;
|
||||
// A body-only Critical, or a comment that arrived without a path, names
|
||||
// no file and cannot cluster. Git permits both stand-in spellings as
|
||||
// real filenames, so the ledger flags the EXCEPTION — `k` marks a
|
||||
// literal path that happens to be spelled like one — and the rule reads
|
||||
// the same for a marker written before that flag existed, whose
|
||||
// stand-ins carry no flag because they are stand-ins.
|
||||
if (isStandInName(f.file) && f.k !== 1) continue;
|
||||
const round = birthRound(f.id);
|
||||
if (round === undefined) continue;
|
||||
const set = priorByFile.get(f.file) ?? new Set<number>();
|
||||
set.add(round);
|
||||
priorByFile.set(f.file, set);
|
||||
}
|
||||
|
||||
// Fresh: not a re-post of a finding minted in an earlier round. An id this
|
||||
// round would mint is not "earlier", so the comparison is strict — except
|
||||
// AT the round cap, where the id space collides: consecutive rounds at
|
||||
// `LEDGER_MAX_ROUND` both stamp `R<cap>-*`, so a re-post of an unfixed
|
||||
// Critical is indistinguishable from a fresh finding by its id alone.
|
||||
// There the rule fails toward "carried", because the cost of the two
|
||||
// errors is not symmetric: calling a re-post fresh narrates divergence at
|
||||
// the steady state every round forever, while calling a fresh finding
|
||||
// carried costs one round of silence.
|
||||
const carriedIds = new Set(
|
||||
input.prev.findings
|
||||
.map((f) => f?.id)
|
||||
.filter((id): id is string => typeof id === 'string'),
|
||||
);
|
||||
const fresh = input.drafts.filter((d) =>
|
||||
isFreshDraft(d, input.round, carriedIds, input.prev.complete === true),
|
||||
);
|
||||
|
||||
// Keyed by the REAL path, never by a truncated one. The ledger caps `file`
|
||||
// at `LEDGER_MAX_FILE`, so the join has to reach across that cap — but
|
||||
// truncating the drafted side to meet it does not prevent prefix
|
||||
// collisions, it creates them: two distinct files sharing a 200-character
|
||||
// prefix collapse to one key, their counts sum as though they were one
|
||||
// file, and the paragraph then posts a 200-character prefix as a path that
|
||||
// exists in no repository (with a lone surrogate at the cut, for a
|
||||
// non-ASCII path). Matching a truncated LEDGER entry by prefix instead
|
||||
// keeps every displayed path real; two files behind one truncated entry
|
||||
// become two clusters citing the same prior rounds, which over-attributes
|
||||
// history rather than inventing a filename.
|
||||
const thisByFile = new Map<string, number>();
|
||||
for (const d of fresh) {
|
||||
const p = d?.file;
|
||||
if (typeof p !== 'string' || p.trim() === '') continue;
|
||||
thisByFile.set(p, (thisByFile.get(p) ?? 0) + 1);
|
||||
}
|
||||
const priorFor = (file: string): Set<number> | undefined =>
|
||||
priorByFile.get(file) ??
|
||||
(file.length > LEDGER_MAX_FILE
|
||||
? priorByFile.get(file.slice(0, LEDGER_MAX_FILE))
|
||||
: undefined);
|
||||
|
||||
// Held to round 3 for the same reason the volume signal is: one step is
|
||||
// not a trend. A round-1 finding fixed and one new finding landing in the
|
||||
// same file is the ordinary healthy re-review — and on a single-file PR
|
||||
// the "split it into its own pull request" advice has no referent at all.
|
||||
const clusters: RecurrenceCluster[] = [];
|
||||
if (input.round >= 3) {
|
||||
for (const [file, count] of thisByFile) {
|
||||
const prior = priorFor(file);
|
||||
if (!prior || prior.size === 0) continue;
|
||||
clusters.push({
|
||||
file,
|
||||
priorRounds: [...prior].sort((a, b) => a - b),
|
||||
thisRound: count,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Deterministic order: the file producing the most NEW work now first,
|
||||
// then the number of earlier rounds, then the path.
|
||||
//
|
||||
// This round's count leads, not the prior-round depth, because the depth
|
||||
// measures the wrong thing for the sentence it ranks. The previous round's
|
||||
// ledger is that round's POSTING SET: a finding the author fixed is not
|
||||
// re-posted and its round leaves the list, while a finding nobody fixed is
|
||||
// re-posted under its original id and contributes its mint round forever.
|
||||
// So depth grows exactly where nothing is being fixed — and the advice it
|
||||
// ranked reads "a cluster that keeps producing siblings", about a file
|
||||
// where no fix happened. Depth is also the key a stranger can set: one
|
||||
// marker holding fifty legal ids on one file put a fabricated cluster in
|
||||
// the top slot and evicted a genuine one from the rendered three.
|
||||
//
|
||||
// The path tie-break compares CODE UNITS, not `localeCompare`: collation
|
||||
// follows the runtime locale, and the clustered paths belong to whatever
|
||||
// repository is under review, so a locale change between the CI bot's
|
||||
// round and a maintainer's round would otherwise reorder tied non-ASCII
|
||||
// paths and break the invariant this sort states.
|
||||
//
|
||||
// The depth key is DROPPED entirely when the work list came from another
|
||||
// account's marker. Leading with this round's count takes the top slot
|
||||
// back from a fabricated cluster, but depth still decides every tie — and
|
||||
// ties are the ordinary shape, one fresh finding per file — so fifty
|
||||
// planted ids on one file still evicted a genuine cluster from the
|
||||
// rendered three. Provenance is disclosed for the ROUNDS; the ordering
|
||||
// cannot disclose anything, so on a foreign list it simply does not use a
|
||||
// number a stranger set.
|
||||
const trustDepth = input.prev.foreign !== true;
|
||||
clusters.sort(
|
||||
(a, b) =>
|
||||
b.thisRound - a.thisRound ||
|
||||
(trustDepth ? b.priorRounds.length - a.priorRounds.length : 0) ||
|
||||
(a.file < b.file ? -1 : a.file > b.file ? 1 : 0),
|
||||
);
|
||||
|
||||
// A round that produced NO fresh finding is the observation a convergence
|
||||
// trend most wants, not a symptom: zero new work is where a settling loop
|
||||
// lands, and `0 >= 0` would otherwise narrate "the volume is not falling"
|
||||
// at exactly the moment it has finished falling. The guard subsumes the
|
||||
// zero-posting case — a round with no drafts has no fresh drafts either —
|
||||
// and additionally covers the round whose whole output is carried re-posts.
|
||||
//
|
||||
// `prev.fresh > 0` for the mirror reason on the other end: a trend measured
|
||||
// against a zero predecessor is `N >= 0`, true for every N, so restarting
|
||||
// from a settled round would fire on the healthiest shape there is (fix
|
||||
// everything, settle at zero, push again, get new findings).
|
||||
//
|
||||
// And the two rounds must have posted under the SAME floor. A posture
|
||||
// change is not loop behaviour: an operator who takes this module's own
|
||||
// advice, sets `--severity-floor critical`, and later restores it produces
|
||||
// a volume jump the trend would read as a loop that will not settle — and
|
||||
// the advice would then recommend re-tightening the floor just
|
||||
// deliberately loosened. One transient `contextUnavailable` round under
|
||||
// `auto` produces the same spike with no operator action at all. A
|
||||
// previous floor that was never recorded is not a floor that differs, so a
|
||||
// pre-field marker evaluates exactly as it did before.
|
||||
const floorChanged =
|
||||
input.prev.floor !== undefined &&
|
||||
input.floor !== undefined &&
|
||||
input.prev.floor !== input.floor;
|
||||
//
|
||||
// Measured on FRESH findings, not on the round's whole output. Step 6
|
||||
// re-posts every unfixed ledger Critical under its original id, so the
|
||||
// re-post floor is monotonically non-decreasing: a loop whose new findings
|
||||
// collapsed from five to one still posts more comments than the round
|
||||
// before, and a trend on the totals would call that convergence
|
||||
// "not falling" — forever. A predecessor that recorded no fresh count
|
||||
// leaves the trend unevaluable rather than measured on the wrong number.
|
||||
const volumeNotShrinking =
|
||||
input.round >= 3 &&
|
||||
input.prev.fresh !== undefined &&
|
||||
input.prev.fresh > 0 &&
|
||||
!floorChanged &&
|
||||
fresh.length > 0 &&
|
||||
fresh.length >= input.prev.fresh;
|
||||
|
||||
if (clusters.length === 0 && !volumeNotShrinking) return null;
|
||||
return {
|
||||
round: input.round,
|
||||
posted: input.posted,
|
||||
fresh: fresh.length,
|
||||
...(input.prev.posted === undefined
|
||||
? {}
|
||||
: { prevPosted: input.prev.posted }),
|
||||
...(input.prev.fresh === undefined ? {} : { prevFresh: input.prev.fresh }),
|
||||
clusters,
|
||||
volumeNotShrinking,
|
||||
truncatedEvidence: input.prev.truncated === true,
|
||||
foreignEvidence: input.prev.foreign === true,
|
||||
mergedEvidence: input.prev.merged === true,
|
||||
...(input.criticalFloorKind === undefined
|
||||
? {}
|
||||
: { criticalFloorKind: input.criticalFloorKind }),
|
||||
};
|
||||
}
|
||||
|
||||
/** How many clusters the rendered paragraph names before summarising. */
|
||||
export const MAX_RENDERED_CLUSTERS = 3;
|
||||
|
||||
/**
|
||||
* The diagnosis as the two sentences a human reads: what was measured, and
|
||||
* what that shape usually means.
|
||||
*
|
||||
* Facts first and separately, because the facts are certain and the reading
|
||||
* is not. Where the evidence itself is qualified — a truncated work list, one
|
||||
* recovered from another account's marker — the qualification is stated in
|
||||
* the same paragraph rather than left for the reader to discover, matching
|
||||
* the PARTIAL disclosure `pr-context` already renders for the same data.
|
||||
*
|
||||
* The recommendations are process-level on purpose — triage the cluster,
|
||||
* split it out, stem the posting surface, batch the fixes — and never a
|
||||
* code-architecture prescription: this module cannot verify a claim about how
|
||||
* the code should be restructured, and an unverifiable claim is exactly what
|
||||
* the rest of this pipeline refuses to post.
|
||||
*/
|
||||
export function renderConvergenceDiagnosis(d: ConvergenceDiagnosis): {
|
||||
en: string;
|
||||
zh: string;
|
||||
} {
|
||||
const shown = d.clusters.slice(0, MAX_RENDERED_CLUSTERS);
|
||||
const more = d.clusters.length - shown.length;
|
||||
// Every path here is PR-controlled and goes out in a body this bot posts
|
||||
// under its own identity — `mdField`, never hand-spelled backticks.
|
||||
const clusterEn = shown
|
||||
.map(
|
||||
(c) =>
|
||||
`${mdField(c.file)} (findings in round${c.priorRounds.length > 1 ? 's' : ''} ${c.priorRounds.join(', ')}, ${c.thisRound} more now)`,
|
||||
)
|
||||
.join('; ');
|
||||
const clusterZh = shown
|
||||
.map(
|
||||
(c) =>
|
||||
`${mdField(c.file)}(第 ${c.priorRounds.join('、')} 轮已出过发现,本轮又有 ${c.thisRound} 条)`,
|
||||
)
|
||||
.join(';');
|
||||
|
||||
const factsEn = [
|
||||
`round ${d.round} posted ${d.posted} inline comment(s), ${d.fresh} of them reported for the first time`,
|
||||
d.prevPosted === undefined
|
||||
? null
|
||||
: `the previous round posted ${d.prevPosted}${d.prevFresh === undefined ? '' : ` (${d.prevFresh} new)`}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
const factsZh = [
|
||||
`第 ${d.round} 轮发布了 ${d.posted} 条行内评论,其中 ${d.fresh} 条是首次提出`,
|
||||
d.prevPosted === undefined
|
||||
? null
|
||||
: `上一轮发布了 ${d.prevPosted} 条${d.prevFresh === undefined ? '' : `(其中 ${d.prevFresh} 条首次提出)`}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
|
||||
// Both readings are reported when both fired. Discriminating on the
|
||||
// clusters alone made the volume sentence — and with it the entire floor
|
||||
// recommendation — unreachable on the shape this feature exists for:
|
||||
// recurrence and a flat trend together.
|
||||
const reasonsEn: string[] = [];
|
||||
const reasonsZh: string[] = [];
|
||||
if (d.clusters.length > 0) {
|
||||
reasonsEn.push(
|
||||
`Findings keep coming back to the same files: ${clusterEn}${more > 0 ? `, and ${more} more file(s)` : ''}.`,
|
||||
);
|
||||
reasonsZh.push(
|
||||
`发现反复回到同一批文件:${clusterZh}${more > 0 ? `,另有 ${more} 个文件` : ''}。`,
|
||||
);
|
||||
}
|
||||
if (d.volumeNotShrinking) {
|
||||
reasonsEn.push(`The rate of new findings is not falling.`);
|
||||
reasonsZh.push(`新发现的产出速度没有下降。`);
|
||||
}
|
||||
const reasonEn = reasonsEn.join(' ');
|
||||
const reasonZh = reasonsZh.join('');
|
||||
|
||||
// A caveat attaches to the reading it actually bears on. Truncation
|
||||
// affects only the WORK LIST, so it qualifies the recurrence reading
|
||||
// alone. Provenance is broader: a foreign marker carries the previous
|
||||
// round's VOLUME too, and the volume reading cites that number as this
|
||||
// loop's own baseline — gated on clusters, the disclosure never reached
|
||||
// exactly the branch an attacker-supplied count controls.
|
||||
const citesWorkList = d.clusters.length > 0;
|
||||
const citesPrevVolume =
|
||||
d.prevPosted !== undefined || d.prevFresh !== undefined;
|
||||
const caveatsEn: string[] = [];
|
||||
const caveatsZh: string[] = [];
|
||||
// Truncation qualifies BOTH readings, not only the recurrence one: the
|
||||
// work list IS the carried-id set that defines freshness. The direction it
|
||||
// moves the count is UNDER, not over — the stray-id rescue is gated on the
|
||||
// list being whole, so over a shortened one a genuinely new finding the
|
||||
// model prefixed with an earlier round's id cannot be rescued and is read
|
||||
// as a re-post. (A re-post of a SHED entry is read as carried too, which
|
||||
// is correct: it is one.)
|
||||
if (d.truncatedEvidence) {
|
||||
// The count clause is unconditional, because the facts clause cites this
|
||||
// round's fresh count unconditionally. Only the rounds half depends on
|
||||
// rounds being named.
|
||||
const understated = {
|
||||
en: `a new finding written under an earlier round's id cannot be told from a re-post over a partial list, so the new-finding count may be understated`,
|
||||
zh: `在不完整的清单上,冠以早先轮次 id 的新发现无法与重发区分,首次提出的条数可能少计`,
|
||||
};
|
||||
const what = citesWorkList
|
||||
? {
|
||||
en: `the rounds named above may be an undercount, and ${understated.en}`,
|
||||
zh: `上述轮次可能少计;${understated.zh}`,
|
||||
}
|
||||
: understated;
|
||||
caveatsEn.push(
|
||||
`the previous round's work list was truncated to fit the marker, so ${what.en}`,
|
||||
);
|
||||
caveatsZh.push(`上一轮的工作清单为放进标记而被截断,${what.zh}`);
|
||||
}
|
||||
if (d.foreignEvidence && (citesWorkList || citesPrevVolume)) {
|
||||
const what = citesWorkList
|
||||
? citesPrevVolume
|
||||
? { en: `those rounds and its counts`, zh: `上述轮次与其计数` }
|
||||
: { en: `those rounds`, zh: `上述轮次` }
|
||||
: { en: `those counts`, zh: `该计数` };
|
||||
caveatsEn.push(
|
||||
d.mergedEvidence
|
||||
? `the previous round was recovered from a marker this account did not post and merged over this account's own entries, so some of ${what.en} may not be this account's own`
|
||||
: `the previous round was recovered from a marker this account did not post, so ${what.en} may not be this account's own`,
|
||||
);
|
||||
caveatsZh.push(
|
||||
d.mergedEvidence
|
||||
? `上一轮的数据来自并非本账号发布的标记,并与本账号自己的条目合并,${what.zh}中的部分可能不属于本账号`
|
||||
: `上一轮的数据来自并非本账号发布的标记,${what.zh}可能不属于本账号`,
|
||||
);
|
||||
}
|
||||
const caveatEn =
|
||||
caveatsEn.length > 0 ? ` (Evidence: ${caveatsEn.join('; ')}.)` : '';
|
||||
const caveatZh =
|
||||
caveatsZh.length > 0 ? `(证据说明:${caveatsZh.join(';')}。)` : '';
|
||||
|
||||
// The floor recommendation is dropped once the floor already resolves to
|
||||
// `critical`: advising a posture the round is running under, inside the
|
||||
// very body whose floor-enforcement note says so, reads as advice nobody
|
||||
// checked. And it names the posture the way that round actually got it —
|
||||
// `auto` is the DEFAULT, so wording an auto-resolved floor as an explicit
|
||||
// setting claims a flag nobody passed.
|
||||
const batchEn = `Batching the remaining fixes and verifying them before the next push`;
|
||||
const batchZh = `把剩余修复攒成一批、验证后再推送`;
|
||||
const alreadyEn: Record<CriticalFloorKind, string> = {
|
||||
explicit: `this PR's reviews are already at \`--severity-floor critical\``,
|
||||
'auto-resolved': `this PR's reviews already resolve to a critical posting floor`,
|
||||
};
|
||||
const alreadyZh: Record<CriticalFloorKind, string> = {
|
||||
explicit: `本 PR 的评审已处于 \`--severity-floor critical\``,
|
||||
'auto-resolved': `本 PR 的评审已解析为 critical 发布下限`,
|
||||
};
|
||||
const floorEn =
|
||||
d.criticalFloorKind === undefined
|
||||
? `${batchEn}, or dropping this PR's reviews to \`--severity-floor critical\`, keeps the loop from re-deriving the same set.`
|
||||
: `${batchEn} keeps the loop from re-deriving the same set; ${alreadyEn[d.criticalFloorKind]}.`;
|
||||
const floorZh =
|
||||
d.criticalFloorKind === undefined
|
||||
? `${batchZh},或将本 PR 的评审降到 \`--severity-floor critical\`,可以避免循环反复推导同一组发现。`
|
||||
: `${batchZh},可以避免循环反复推导同一组发现;${alreadyZh[d.criticalFloorKind]}。`;
|
||||
|
||||
const clusterAdviceEn = `A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time.`;
|
||||
const clusterAdviceZh = `一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。`;
|
||||
const adviceEn = [
|
||||
d.clusters.length > 0 ? clusterAdviceEn : null,
|
||||
d.volumeNotShrinking ? floorEn : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const adviceZh = [
|
||||
d.clusters.length > 0 ? clusterAdviceZh : null,
|
||||
d.volumeNotShrinking ? floorZh : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
|
||||
// The closing claim is scoped to THIS observation, not to the review: the
|
||||
// same body can carry a floor-enforcement note, a deferral list, or a
|
||||
// discarded-Suggestion count — all of them things withheld from this
|
||||
// round's posting surface. An absolute "nothing was withheld" beside those
|
||||
// is a sentence the body itself refutes.
|
||||
return {
|
||||
en: `Convergence: ${factsEn}. ${reasonEn}${caveatEn} ${adviceEn} (Observation only — nothing was withheld from this review because of this observation.)`,
|
||||
zh: `收敛情况:${factsZh}。${reasonZh}${caveatZh}${adviceZh}(仅为观察——本轮评审未因此扣留任何内容。)`,
|
||||
};
|
||||
}
|
||||
|
|
@ -20,6 +20,10 @@ import {
|
|||
LEDGER_MAX_BYTES,
|
||||
LEDGER_MAX_MODEL,
|
||||
LEDGER_MAX_VOLUME,
|
||||
LEDGER_MAX_ID,
|
||||
LEDGER_ID_SHAPE,
|
||||
LEDGER_MAX_ROUND,
|
||||
isLedgerFinding,
|
||||
type Ledger,
|
||||
type LedgerFinding,
|
||||
} from './ledger.js';
|
||||
|
|
@ -437,6 +441,251 @@ describe('ledger marker', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('a shortened work list must never read as complete', () => {
|
||||
const f = (id: string): LedgerFinding => ({
|
||||
id,
|
||||
sev: 'S',
|
||||
file: 'a.ts',
|
||||
title: 't',
|
||||
});
|
||||
|
||||
it('counts what the FILTER rejected, not only what the cap sliced', () => {
|
||||
// `dropped` decides two things: the anchor is withheld while it is set,
|
||||
// and it now publishes the "may be an undercount" caveat. Entries the
|
||||
// filter rejected are findings the next round will never rule on, so a
|
||||
// list short by them that still certifies its range retires a posted
|
||||
// Critical silently AND scopes the next review past its code.
|
||||
const marker =
|
||||
'<!-- qwen-review-ledger {"v":1,"round":3,"findings":[' +
|
||||
'{"id":"R3-1","sev":"S","file":"a.ts","title":"kept"},' +
|
||||
'{"id":"nope","sev":"S","file":"b.ts","title":"rejected"}' +
|
||||
'],"sha":"deadbeef00112233"} -->';
|
||||
const parsed = parseLedger(marker)!;
|
||||
expect(parsed.findings.map((x) => x.id)).toEqual(['R3-1']);
|
||||
expect(parsed.dropped).toBe(1);
|
||||
expect(parsed.sha).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never writes an id its own parser would refuse', () => {
|
||||
// The id cap slices without re-validating, so an over-long id is cut
|
||||
// mid-token and stops being the grammar. Emitted, the next round's
|
||||
// filter drops it — the finding retires with no ruling, and the loss is
|
||||
// invisible unless it is counted here, where `dropped` still counts it.
|
||||
const long = `R${'1'.repeat(30)}-7`;
|
||||
const marker = serializeLedger({
|
||||
v: 1,
|
||||
round: 2,
|
||||
findings: [f('R2-1'), { ...f(long), file: 'b.ts' }],
|
||||
sha: 'deadbeef00112233',
|
||||
});
|
||||
// The MARKER, not merely the parse: dropped on the write side the loss is
|
||||
// declared in the bytes and the anchor is withheld by the writer; left in,
|
||||
// the marker spends its budget on a token its own reader will refuse.
|
||||
expect(marker).not.toContain('R1111');
|
||||
expect(marker).toContain('"dropped":1');
|
||||
const parsed = parseLedger(marker)!;
|
||||
expect(parsed.findings.map((x) => x.id)).toEqual(['R2-1']);
|
||||
expect(parsed.dropped).toBe(1);
|
||||
expect(parsed.sha).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes no floor beside a volume that did not survive', () => {
|
||||
// The floor qualifies `posted`. Written whenever the rung ADMITS the
|
||||
// group rather than whenever the volume survived it, it is bytes spent
|
||||
// on the shed cascade that the parser then discards — on the same ladder
|
||||
// the serializer prices at a lost anchor.
|
||||
const marker = serializeLedger({
|
||||
v: 1,
|
||||
round: 2,
|
||||
findings: [f('R2-1')],
|
||||
posted: -3 as unknown as number,
|
||||
floor: 'c',
|
||||
});
|
||||
expect(marker).not.toContain('floor');
|
||||
});
|
||||
|
||||
it('refuses a round-0 id at both ends of the bound', () => {
|
||||
// Rounds start at 1, so `R0-*` is not an id this pipeline can mint — but
|
||||
// it passes the shape, and every reader that turns an id into a round
|
||||
// rejects round 0 and then reads the rejection as "no carried id", i.e.
|
||||
// as FRESH. Admitted, a re-posted `R0-1` counts as first-time work every
|
||||
// round and the trend narrates divergence at a settled steady state.
|
||||
expect(
|
||||
isLedgerFinding({ id: 'R0-1', sev: 'C', file: 'x.ts', title: 't' }, 9),
|
||||
).toBe(false);
|
||||
expect(
|
||||
parseLedger(
|
||||
'<!-- qwen-review-ledger {"v":1,"round":3,"findings":[' +
|
||||
'{"id":"R0-1","sev":"C","file":"x.ts","title":"boom"}' +
|
||||
']} -->',
|
||||
)?.findings,
|
||||
).toEqual([]);
|
||||
// The write side applies the same test, so a stray id the model minted
|
||||
// out of range never reaches a marker its own reader would refuse.
|
||||
const marker = serializeLedger({
|
||||
v: 1,
|
||||
round: 3,
|
||||
findings: [f('R3-1'), { ...f('R0-1'), file: 'b.ts' }],
|
||||
});
|
||||
expect(marker).not.toContain('R0-1');
|
||||
expect(marker).toContain('"dropped":1');
|
||||
});
|
||||
|
||||
it('round-trips the stand-in exception flag, and clamps a forged dropped', () => {
|
||||
// [1] The flag has to survive serialize -> parse, not merely exist on
|
||||
// the builder's output: it is the only thing separating a real file
|
||||
// spelled like a stand-in from the stand-in itself, and it crosses the
|
||||
// marker boundary on every round.
|
||||
const marker = serializeLedger({
|
||||
v: 1,
|
||||
round: 3,
|
||||
findings: [
|
||||
{ id: 'R3-1', sev: 'C', file: '(body)', title: 'a stand-in' },
|
||||
{ id: 'R3-2', sev: 'S', file: '(body)', title: 'a real file', k: 1 },
|
||||
],
|
||||
});
|
||||
const back = parseLedger(marker)!;
|
||||
expect(back.findings[0].k).toBeUndefined();
|
||||
expect(back.findings[1].k).toBe(1);
|
||||
// The stand-in costs no marker bytes; only the exception is spelled.
|
||||
expect(marker.match(/"k":1/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('clamps a forged `dropped` instead of publishing it', () => {
|
||||
// It renders into the model-facing PARTIAL line and publishes the
|
||||
// undercount caveat, and unlike a forged finding it cannot be re-ruled.
|
||||
const parsed = parseLedger(
|
||||
'<!-- qwen-review-ledger {"v":1,"round":3,"findings":[],"dropped":1e308} -->',
|
||||
)!;
|
||||
// Clamped through the same reader the other counts use, so the PARTIAL
|
||||
// line cannot render `1e+308 further finding(s)`.
|
||||
expect(parsed.dropped).toBe(LEDGER_MAX_VOLUME);
|
||||
// A non-count is still no count at all.
|
||||
expect(
|
||||
parseLedger(
|
||||
'<!-- qwen-review-ledger {"v":1,"round":3,"findings":[],"dropped":-4} -->',
|
||||
)?.dropped,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses an over-long id rather than cutting it into a different one', () => {
|
||||
// Admitted and then sliced, the entry silently changes identity between
|
||||
// the round that posted it and the round that rules on it.
|
||||
const long = `R2-${'9'.repeat(LEDGER_MAX_ID)}`;
|
||||
expect(long.length).toBeGreaterThan(LEDGER_MAX_ID);
|
||||
expect(
|
||||
isLedgerFinding({ id: long, sev: 'S', file: 'a.ts', title: 't' }, 9),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('bounds an id round by the CAP, not only by the claimed round', () => {
|
||||
// The side-file route's round is whatever was written to it, which the
|
||||
// admission test's own comment says is not always clamped.
|
||||
expect(
|
||||
isLedgerFinding(
|
||||
{
|
||||
id: `R${LEDGER_MAX_ROUND + 1}-1`,
|
||||
sev: 'S',
|
||||
file: 'a.ts',
|
||||
title: 't',
|
||||
},
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isLedgerFinding(
|
||||
{ id: `R${LEDGER_MAX_ROUND}-1`, sev: 'S', file: 'a.ts', title: 't' },
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the fresh count only beside a volume that bounds it', () => {
|
||||
const ok = parseLedger(
|
||||
'<!-- qwen-review-ledger {"v":1,"round":3,"findings":[],"posted":5,"fresh":2} -->',
|
||||
)!;
|
||||
expect(ok.fresh).toBe(2);
|
||||
// Larger than the total it is part of: not a count of anything.
|
||||
const over = parseLedger(
|
||||
'<!-- qwen-review-ledger {"v":1,"round":3,"findings":[],"posted":2,"fresh":5} -->',
|
||||
)!;
|
||||
expect(over.fresh).toBeUndefined();
|
||||
// No total: nothing for it to be a part of.
|
||||
const bare = parseLedger(
|
||||
'<!-- qwen-review-ledger {"v":1,"round":3,"findings":[],"fresh":5} -->',
|
||||
)!;
|
||||
expect(bare.fresh).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses an over-long id rather than emitting a cut one under it', () => {
|
||||
// The cut can still match the grammar — `R3-` plus twenty-two nines
|
||||
// slices to a well-formed twenty-four — so validating after the slice
|
||||
// emitted a DIFFERENT id under the same entry: the next round's readback
|
||||
// of the posted claim returns the full id, matches no ledger entry, and
|
||||
// the finding retires with no ruling while the list reads as complete.
|
||||
const cuttable = `R3-${'9'.repeat(22)}`;
|
||||
expect(cuttable.length).toBeGreaterThan(LEDGER_MAX_ID);
|
||||
expect(LEDGER_ID_SHAPE.test(cuttable.slice(0, LEDGER_MAX_ID))).toBe(true);
|
||||
const marker = serializeLedger({
|
||||
v: 1,
|
||||
round: 3,
|
||||
findings: [
|
||||
{ ...f('R3-1'), file: 'a.ts' },
|
||||
{ ...f(cuttable), file: 'b.ts' },
|
||||
],
|
||||
sha: 'deadbeef00112233',
|
||||
});
|
||||
expect(marker).not.toContain(cuttable.slice(0, LEDGER_MAX_ID));
|
||||
const parsed = parseLedger(marker)!;
|
||||
expect(parsed.findings.map((x) => x.id)).toEqual(['R3-1']);
|
||||
expect(parsed.dropped).toBe(1);
|
||||
expect(parsed.sha).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clamps the SUMMED dropped, not only its declared term', () => {
|
||||
// `raw.findings.length` is attacker-chosen — a body of tens of thousands
|
||||
// of single-character invalid entries fits GitHub's limit — and the
|
||||
// total is interpolated verbatim into the model-facing PARTIAL line.
|
||||
const junk = Array.from({ length: 400 }, () => ({ id: 'x' }));
|
||||
const parsed = parseLedger(
|
||||
`<!-- qwen-review-ledger {"v":1,"round":3,"dropped":${LEDGER_MAX_VOLUME},"findings":${JSON.stringify(junk)}} -->`,
|
||||
)!;
|
||||
expect(parsed.dropped).toBe(LEDGER_MAX_VOLUME);
|
||||
});
|
||||
|
||||
it('normalises an unrecognised clustering hint instead of dropping the finding', () => {
|
||||
// `k` decides nothing. The marker is a cross-environment carrier by
|
||||
// design, so a later version adding a third kind — or a hand edit, or a
|
||||
// foreign marker — would otherwise make every older CLI drop those
|
||||
// findings from the work list: they would owe no Step 6 ruling and
|
||||
// retire with nobody ruling on them.
|
||||
const marker =
|
||||
'<!-- qwen-review-ledger {"v":1,"round":3,"findings":[' +
|
||||
'{"id":"R3-1","sev":"C","file":"(body)","title":"t","k":"d"}' +
|
||||
']} -->';
|
||||
const parsed = parseLedger(marker)!;
|
||||
expect(parsed.findings).toEqual([
|
||||
{ id: 'R3-1', sev: 'C', file: '(body)', title: 't' },
|
||||
]);
|
||||
expect(parsed.dropped).toBeUndefined();
|
||||
});
|
||||
|
||||
it('bounds an id round even when the marker round does not', () => {
|
||||
// The round is printed verbatim in a public body, and the side-file read
|
||||
// shares this admission test with no clamp of its own.
|
||||
expect(
|
||||
isLedgerFinding(
|
||||
{ id: 'R99999999999999999999-1', sev: 'S', file: 'a.ts', title: 't' },
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
),
|
||||
).toBe(false);
|
||||
// A leading space is the bypass the whole-shape test closes.
|
||||
expect(
|
||||
isLedgerFinding({ id: ' R9-1', sev: 'S', file: 'a.ts', title: 't' }, 99),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The prefix-anchored readback both ledger read sides share wholesale:
|
||||
// compose-review's ledger builder and presubmit's re-post extractor.
|
||||
describe('LEDGER_ID_READBACK', () => {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,23 @@ export interface LedgerFinding {
|
|||
/** `C` (Critical) or `S` (Suggestion). Compact on purpose — body bytes. */
|
||||
sev: 'C' | 'S';
|
||||
file: string;
|
||||
/**
|
||||
* Set only when `file` is a LITERAL path that happens to equal one of the
|
||||
* stand-in names below — `(body)`, `(unknown)`. Git permits both as
|
||||
* filenames, so the sentinels alone cannot separate "no path to give" from
|
||||
* "a file with that name", and a reader keying on the value excluded a
|
||||
* real file of that name from clustering, silently.
|
||||
*
|
||||
* The flag marks the EXCEPTION rather than the rule on purpose. Flagging
|
||||
* the stand-ins would have cost bytes on every body Critical, which is
|
||||
* routine — and this field rides through all four rungs of the shed
|
||||
* cascade, where the serializer's own comment prices ~27 bytes of
|
||||
* telemetry at a lost anchor or a lost ruling. Flagging the pathological
|
||||
* filename instead costs nothing on any normal round, and it lets a marker
|
||||
* written before this field existed read correctly: its sentinels carry no
|
||||
* flag, which is exactly what they mean.
|
||||
*/
|
||||
k?: 1;
|
||||
line?: number;
|
||||
/** One line, capped — enough for the next round to re-locate the claim. */
|
||||
title: string;
|
||||
|
|
@ -114,6 +131,41 @@ export interface Ledger {
|
|||
* fail-open, decides-nothing contract as `posted`.
|
||||
*/
|
||||
prevPosted?: number;
|
||||
/**
|
||||
* The posting floor this round RESOLVED to — `c` when the critical floor
|
||||
* was in effect, `o` when Suggestions were postable.
|
||||
*
|
||||
* It qualifies `posted`, and travels and sheds with it. Without it the
|
||||
* volume trend measures a POSTURE change as loop divergence: an operator
|
||||
* who takes this pipeline's own advice and sets `--severity-floor
|
||||
* critical` collapses the volume, and restoring it later produces a jump
|
||||
* the trend reads as a loop that will not settle — and then advises
|
||||
* re-tightening the floor just deliberately loosened. The bias is
|
||||
* one-directional (loosening fires it, tightening only shrinks volume),
|
||||
* and one transient `contextUnavailable` round under `auto` produces the
|
||||
* same spike with no operator action at all.
|
||||
*
|
||||
* Same fail-open, decides-nothing contract as the volumes: absent means
|
||||
* "not recorded", which leaves the trend evaluated as it was before this
|
||||
* field existed.
|
||||
*/
|
||||
floor?: 'c' | 'o';
|
||||
/**
|
||||
* How many of `posted` were findings this round REPORTED FOR THE FIRST
|
||||
* TIME — not re-posts of still-standing entries from earlier rounds.
|
||||
*
|
||||
* The number the convergence trend is actually about. `posted` is the
|
||||
* round's whole output, and Step 6 re-posts every unfixed ledger Critical
|
||||
* under its original id, so the re-post floor only ever rises: a loop whose
|
||||
* NEW findings collapsed from five to one still posts more comments than
|
||||
* the round before, and a trend measured on the totals reads that
|
||||
* convergence as divergence, permanently. Absent means "not recorded",
|
||||
* which leaves the trend unevaluable rather than measured on the wrong
|
||||
* number.
|
||||
*
|
||||
* Rides and sheds with `posted`, which it qualifies.
|
||||
*/
|
||||
fresh?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -163,6 +215,19 @@ export const LEDGER_ID_READBACK = new RegExp(
|
|||
`^(${LEDGER_ID_TOKEN})[:.)\\]]?(?=\\s|$)\\s*`,
|
||||
);
|
||||
|
||||
/**
|
||||
* The id as a WHOLE string — nothing before it, nothing after. The one
|
||||
* admission test, shared with presubmit's entry check.
|
||||
*
|
||||
* Anchored at both ends on purpose. A prefix-only test (`^R\d+-`) admitted
|
||||
* ids the readers then interpreted differently from the test: every reader
|
||||
* downstream trims before matching (`birthRound`, `readClaim`), so ` R9999-1`
|
||||
* failed the untrimmed squat filter, was therefore never dropped, and read as
|
||||
* round 9999 everywhere it mattered — pre-claiming the next round's id prefix
|
||||
* and citing a round no account ever ran.
|
||||
*/
|
||||
export const LEDGER_ID_SHAPE = new RegExp(`^${LEDGER_ID_TOKEN}$`);
|
||||
|
||||
/** Caps keep the marker a footnote, never a payload: GitHub's body limit is
|
||||
* 65,536 chars and the marker rides inside it. Every cap binds BOTH halves —
|
||||
* the serializer so the write side is bounded, the parser so a hand-edited
|
||||
|
|
@ -170,6 +235,30 @@ export const LEDGER_ID_READBACK = new RegExp(
|
|||
export const LEDGER_MAX_FINDINGS = 50;
|
||||
export const LEDGER_MAX_TITLE = 80;
|
||||
export const LEDGER_MAX_FILE = 200;
|
||||
/**
|
||||
* The pseudo-paths a finding carries when it has no file to name: a body-only
|
||||
* Critical anchors to the review body itself, and a drafted comment that
|
||||
* arrived without a path anchors to nothing at all.
|
||||
*
|
||||
* Named here because BOTH ends must agree. The ledger builder stamps them into
|
||||
* `findings[].file`, and every reader that must not treat them as real files
|
||||
* compares against them — the convergence join excludes them from clustering.
|
||||
* Spelled as bare literals on each end, a rename on one end alone turns a
|
||||
* pseudo-path into an ordinary file the reader clusters on and NAMES in a
|
||||
* posted paragraph: the same two-ends drift the shared id constants above
|
||||
* exist to prevent.
|
||||
*/
|
||||
export const LEDGER_BODY_FILE = '(body)';
|
||||
export const LEDGER_UNKNOWN_FILE = '(unknown)';
|
||||
|
||||
/**
|
||||
* Is this path spelled like one of the stand-ins? The one place that
|
||||
* question is asked, so the writer's exception flag and the reader's
|
||||
* exclusion cannot disagree about which names need disambiguating.
|
||||
*/
|
||||
export function isStandInName(file: string): boolean {
|
||||
return file === LEDGER_BODY_FILE || file === LEDGER_UNKNOWN_FILE;
|
||||
}
|
||||
/**
|
||||
* The longest model id the marker can carry — and it carries one WHOLE or
|
||||
* not at all: a truncated id is a prefix, and a prefix can equal a DIFFERENT
|
||||
|
|
@ -265,12 +354,26 @@ const CLOSE = ' -->';
|
|||
* `Ledger` later cannot reintroduce the hazard by being forgotten below.
|
||||
*/
|
||||
export function serializeLedger(ledger: Ledger): string {
|
||||
const capped = ledger.findings.slice(0, LEDGER_MAX_FINDINGS).map((f) => ({
|
||||
...f,
|
||||
id: f.id.slice(0, LEDGER_MAX_ID),
|
||||
title: f.title.slice(0, LEDGER_MAX_TITLE),
|
||||
file: f.file.slice(0, LEDGER_MAX_FILE),
|
||||
}));
|
||||
const roundOut = Math.min(ledger.round, LEDGER_MAX_ROUND);
|
||||
const capped = ledger.findings
|
||||
// Admitted BEFORE anything is sliced, the way the parse side does it. An
|
||||
// over-long id cut at the cap can still match the grammar — `R3-` plus
|
||||
// twenty-two nines slices to a well-formed twenty-four — so validating
|
||||
// after the slice emitted a DIFFERENT id under the same entry: the next
|
||||
// round's readback of the posted claim line returns the full id, matches
|
||||
// no ledger entry, and the finding retires with no ruling while the list
|
||||
// reads as complete and the anchor still scopes past it. A carried id the
|
||||
// model minted out of range (`R0-1`) is refused here for the same reason.
|
||||
.filter((f) => isLedgerFinding(f, roundOut))
|
||||
.slice(0, LEDGER_MAX_FINDINGS)
|
||||
.map((f) => ({
|
||||
...f,
|
||||
// Length-safe by construction now: the admission test bounds the id,
|
||||
// so this slice can only be a no-op on it.
|
||||
id: f.id.slice(0, LEDGER_MAX_ID),
|
||||
title: f.title.slice(0, LEDGER_MAX_TITLE),
|
||||
file: f.file.slice(0, LEDGER_MAX_FILE),
|
||||
}));
|
||||
const render = (
|
||||
findings: LedgerFinding[],
|
||||
dropped: number,
|
||||
|
|
@ -281,7 +384,7 @@ export function serializeLedger(ledger: Ledger): string {
|
|||
v: 1,
|
||||
// Mirrored on the write side like every other cap: a serializer that can
|
||||
// emit what its own parser refuses would round-trip to nothing.
|
||||
round: Math.min(ledger.round, LEDGER_MAX_ROUND),
|
||||
round: roundOut,
|
||||
findings,
|
||||
};
|
||||
// The volume telemetry rides OUTSIDE the truncation rule that governs the
|
||||
|
|
@ -293,6 +396,18 @@ export function serializeLedger(ledger: Ledger): string {
|
|||
if (volume !== 'none') {
|
||||
const postedOut = volumeOf(ledger.posted);
|
||||
if (postedOut !== undefined) payload.posted = postedOut;
|
||||
// The floor and the fresh count qualify `posted`, so they ride with the
|
||||
// volume that actually SURVIVED — not merely with the rung that admits
|
||||
// the group. A volume that fails `volumeOf` leaves them qualifying
|
||||
// nothing, which is bytes spent on this same ladder that the parser
|
||||
// then discards.
|
||||
if (postedOut !== undefined) {
|
||||
if (ledger.floor === 'c' || ledger.floor === 'o') {
|
||||
payload.floor = ledger.floor;
|
||||
}
|
||||
const freshOut = volumeOf(ledger.fresh);
|
||||
if (freshOut !== undefined) payload.fresh = freshOut;
|
||||
}
|
||||
if (volume === 'both') {
|
||||
const prevPostedOut = volumeOf(ledger.prevPosted);
|
||||
if (prevPostedOut !== undefined) payload.prevPosted = prevPostedOut;
|
||||
|
|
@ -369,6 +484,90 @@ export function serializeLedger(ledger: Ledger): string {
|
|||
return marker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this a ledger finding this pipeline would admit, against the round the
|
||||
* marker (or side file) claims?
|
||||
*
|
||||
* The ONE admission test. `parseLedger` applies it to a marker recovered from
|
||||
* a posted body; `compose-review`'s side-file read applies it to the JSON
|
||||
* `pr-context` wrote — the same untrusted shape arriving by a different
|
||||
* route. That read restated two of these checks and skipped the rest, which
|
||||
* is how a side file written before the id hardening could keep an id the
|
||||
* marker path now rejects and publish a round number off it: a reader that
|
||||
* trims is only as strict as the admission test in front of it.
|
||||
*/
|
||||
export function isLedgerFinding(
|
||||
f: unknown,
|
||||
markerRound: number,
|
||||
): f is LedgerFinding {
|
||||
const c = f as LedgerFinding | null | undefined;
|
||||
if (!c || typeof c.id !== 'string') return false;
|
||||
// The WHOLE shape, before anything reads a round out of it. A prefix-only
|
||||
// test admitted ids the readers then interpreted differently from the test:
|
||||
// every reader downstream trims before matching, so ` R9999-1` failed the
|
||||
// untrimmed squat rule that exists to stop it and took full effect
|
||||
// everywhere else.
|
||||
if (!LEDGER_ID_SHAPE.test(c.id)) return false;
|
||||
// The length cap belongs to the admission test, not only to the two
|
||||
// slices: an over-long id admitted here is one the normalizer then CUTS,
|
||||
// and a cut id is a different id — the entry silently changes identity
|
||||
// between the round that posted it and the round that rules on it.
|
||||
if (c.id.length > LEDGER_MAX_ID) return false;
|
||||
// An id claiming a FUTURE round is a squat, not a finding. The pipeline's
|
||||
// own ids obey `id round <= marker round` by construction — a round stamps
|
||||
// its new findings `R<round>-<n>` and carries older ids forward — so a
|
||||
// legitimate marker can never violate this. A foreign one can, and recovery
|
||||
// reads foreign markers: a marker at round N carrying `R<N+1>-*` ids would
|
||||
// pre-claim exactly the prefix the next compose stamps, splitting one claim
|
||||
// across two ids and renumbering every genuinely new finding past the
|
||||
// squatted block.
|
||||
//
|
||||
// Bounded by the CAP as well as by the claimed round, because the claimed
|
||||
// round is not always one the caller clamped: the side file's is whatever
|
||||
// was written to it, and an unbounded id round is printed verbatim in a
|
||||
// public body ("findings in round 100000000000000000000").
|
||||
const idRound = Number(c.id.slice(1).split('-')[0]);
|
||||
if (!Number.isSafeInteger(idRound)) return false;
|
||||
// Both ends. Rounds start at 1, so `R0-*` is not an id this pipeline can
|
||||
// mint — but it passes the shape, and every reader that turns an id into a
|
||||
// round rejects round 0 and then treats the rejection as "no carried id",
|
||||
// i.e. as FRESH. Admitted, a re-posted `R0-1` counts as first-time work
|
||||
// every round, and the trend narrates divergence at a fully settled steady
|
||||
// state forever.
|
||||
if (idRound < 1) return false;
|
||||
if (idRound > Math.min(markerRound, LEDGER_MAX_ROUND)) return false;
|
||||
return (
|
||||
(c.sev === 'C' || c.sev === 'S') &&
|
||||
typeof c.file === 'string' &&
|
||||
typeof c.title === 'string' &&
|
||||
(c.line === undefined || Number.isInteger(c.line))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A finding normalised to the caps the serializer writes under. Applied on
|
||||
* READ too: the caps are the serializer's contract, and neither a
|
||||
* hand-edited marker nor a side file is bound by it.
|
||||
*/
|
||||
export function normalizeLedgerFinding(f: LedgerFinding): LedgerFinding {
|
||||
const { k: _k, ...rest } = f;
|
||||
return {
|
||||
...rest,
|
||||
id: f.id.slice(0, LEDGER_MAX_ID),
|
||||
title: f.title.slice(0, LEDGER_MAX_TITLE),
|
||||
file: f.file.slice(0, LEDGER_MAX_FILE),
|
||||
// Normalised to absent, never used to REJECT the entry. `k` is a
|
||||
// clustering hint that decides nothing, and the marker is a
|
||||
// cross-environment carrier by design: a later version adding a third
|
||||
// kind, a hand edit, or a foreign marker would otherwise make every
|
||||
// older CLI drop those findings from the work list — they would owe no
|
||||
// Step 6 ruling and retire with nobody ruling on them. Its
|
||||
// decides-nothing siblings (`posted`, `prevPosted`, `floor`) are all
|
||||
// normalised the same way.
|
||||
...(f.k === 1 ? { k: 1 as const } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the ledger out of a posted review body. Null on absence or ANY
|
||||
* malformation — the body is another account's writable surface, and a marker
|
||||
|
|
@ -393,50 +592,40 @@ export function parseLedger(body: string | undefined): Ledger | null {
|
|||
return null;
|
||||
}
|
||||
if (!Array.isArray(raw.findings)) return null;
|
||||
const valid = raw.findings.filter(
|
||||
(f): f is LedgerFinding =>
|
||||
!!f &&
|
||||
typeof f.id === 'string' &&
|
||||
// An id claiming a FUTURE round is a squat, not a finding. The
|
||||
// pipeline's own ids obey `id round <= marker round` by construction —
|
||||
// a round stamps its new findings `R<round>-<n>` and carries older ids
|
||||
// forward — so a legitimate marker can never violate this. A foreign
|
||||
// one can, and recovery now reads foreign markers: a marker at round N
|
||||
// carrying `R<N+1>-*` ids would pre-claim exactly the prefix the next
|
||||
// compose stamps, splitting one claim across two ids and renumbering
|
||||
// every genuinely new finding past the squatted block. Read-side only,
|
||||
// deliberately: the pipeline's one writer stamps
|
||||
// `min(prevRound + 1, LEDGER_MAX_ROUND)` (compose-review), so its ids
|
||||
// never exceed the round it writes — the coexisting round clamp below
|
||||
// cannot reintroduce the mismatch this filter would then hide.
|
||||
!(() => {
|
||||
const m = /^R(\d+)-/.exec(f.id);
|
||||
return m !== null && Number(m[1]) > raw.round;
|
||||
})() &&
|
||||
(f.sev === 'C' || f.sev === 'S') &&
|
||||
typeof f.file === 'string' &&
|
||||
typeof f.title === 'string' &&
|
||||
(f.line === undefined || Number.isInteger(f.line)),
|
||||
const valid = raw.findings.filter((f): f is LedgerFinding =>
|
||||
isLedgerFinding(f, raw.round),
|
||||
);
|
||||
const findings = valid
|
||||
.slice(0, LEDGER_MAX_FINDINGS)
|
||||
// Normalise on READ too: the caps are the serializer's contract, and a
|
||||
// hand-edited marker is not bound by it.
|
||||
.map((f) => ({
|
||||
...f,
|
||||
id: f.id.slice(0, LEDGER_MAX_ID),
|
||||
title: f.title.slice(0, LEDGER_MAX_TITLE),
|
||||
file: f.file.slice(0, LEDGER_MAX_FILE),
|
||||
}));
|
||||
const declared =
|
||||
Number.isInteger(raw.dropped) && (raw.dropped as number) > 0
|
||||
? (raw.dropped as number)
|
||||
: 0;
|
||||
.map(normalizeLedgerFinding);
|
||||
// Clamped through the shared reader like every other number here. It
|
||||
// was the one field admitted unbounded, and it is no longer internal:
|
||||
// it renders into the model-facing PARTIAL line and publishes the
|
||||
// "may be an undercount" caveat, so a forged `1e308` instructs the model
|
||||
// to hedge about findings that never existed — and unlike a forged
|
||||
// finding, a forged count cannot be re-ruled.
|
||||
const declared = volumeOf(raw.dropped) ?? 0;
|
||||
// The count cap binds on READ as it does on write: valid entries this
|
||||
// parser sliced off ARE dropped findings, and a hand-edited marker whose
|
||||
// list was truncated here must not read as complete — nor keep an anchor
|
||||
// the serializer's own truncation path would have refused to certify.
|
||||
const dropped = declared + (valid.length - findings.length) || undefined;
|
||||
// Both losses count, not only the cap's. Entries this parser's own
|
||||
// filter rejected are findings the next round will never rule on, and a
|
||||
// list short by them must not read as complete — nor keep an anchor the
|
||||
// serializer's truncation path would have refused to certify. The
|
||||
// filter's share was uncounted while the reasons to reject were few and
|
||||
// pipeline-impossible; it is now the larger share, and `dropped` is no
|
||||
// longer internal — it publishes the "may be an undercount" caveat.
|
||||
// The SUM is clamped, not merely the declared term: `raw.findings.length`
|
||||
// is attacker-chosen (a body of ~32,700 single-character invalid entries
|
||||
// fits GitHub's limit), and the total is interpolated verbatim into the
|
||||
// model-facing PARTIAL line — a forged count instructing the model to
|
||||
// hedge about findings that never existed, which unlike a forged finding
|
||||
// cannot be re-ruled.
|
||||
const rejected = raw.findings.length - valid.length;
|
||||
const dropped =
|
||||
volumeOf(declared + rejected + (valid.length - findings.length)) ||
|
||||
undefined;
|
||||
const sha =
|
||||
// Normalised on READ as the serializer holds on WRITE: a hand-edited
|
||||
// marker carrying both `dropped` and `sha` would certify a range its
|
||||
|
|
@ -462,6 +651,21 @@ export function parseLedger(body: string | undefined): Ledger | null {
|
|||
// no gate reads.
|
||||
const posted = volumeOf(raw.posted);
|
||||
const prevPosted = volumeOf(raw.prevPosted);
|
||||
// The floor qualifies `posted`, so it survives only beside it: a floor
|
||||
// alone would let a later round compare postures across rounds whose
|
||||
// volumes it does not have, which is not a comparison anyone can act on.
|
||||
const floor =
|
||||
posted !== undefined && (raw.floor === 'c' || raw.floor === 'o')
|
||||
? raw.floor
|
||||
: undefined;
|
||||
// Bounded by the total it is a part of: a "fresh" count exceeding the
|
||||
// round's whole output is not a count of anything, and a forged one
|
||||
// would let a marker's trend beat a real one's.
|
||||
const freshRaw = posted === undefined ? undefined : volumeOf(raw.fresh);
|
||||
const fresh =
|
||||
freshRaw !== undefined && posted !== undefined && freshRaw <= posted
|
||||
? freshRaw
|
||||
: undefined;
|
||||
return {
|
||||
v: 1,
|
||||
round: raw.round,
|
||||
|
|
@ -471,6 +675,8 @@ export function parseLedger(body: string | undefined): Ledger | null {
|
|||
...(model ? { model } : {}),
|
||||
...(posted === undefined ? {} : { posted }),
|
||||
...(prevPosted === undefined ? {} : { prevPosted }),
|
||||
...(floor === undefined ? {} : { floor }),
|
||||
...(fresh === undefined ? {} : { fresh }),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
|
|
|||
61
packages/cli/src/commands/review/lib/md-field.test.ts
Normal file
61
packages/cli/src/commands/review/lib/md-field.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mdField } from './md-field.js';
|
||||
import { parseLedger, stripLedgerMarker } from './ledger.js';
|
||||
|
||||
describe('mdField — a PR-controlled value, made inert', () => {
|
||||
it('holds a value inside one code span', () => {
|
||||
expect(mdField('src/a.ts')).toBe('`src/a.ts`');
|
||||
});
|
||||
|
||||
it('strips what would break the span or forge a line', () => {
|
||||
expect(mdField('x`\n@acme/security approve')).toBe(
|
||||
'`x @acme/security approve`',
|
||||
);
|
||||
expect(mdField('a\r\nb')).toBe('`a b`');
|
||||
});
|
||||
|
||||
it('never emits a bare backtick run for a value that strips to nothing', () => {
|
||||
// Git permits a filename that is nothing but backticks. Stripped, it
|
||||
// leaves the empty string — and `` `` + `` `` `` is not two empty spans
|
||||
// but ONE span whose content is everything the renderer wrote between
|
||||
// them, so a planted filename re-renders the bot's own prose as code.
|
||||
const emptied = mdField('`');
|
||||
expect(emptied).toBe('`(unnamed)`');
|
||||
expect(emptied).not.toBe('``');
|
||||
|
||||
// The shape the pairing needs: two such values in one paragraph.
|
||||
const paragraph = `same files: ${mdField('`')} (round 2); ${mdField('``')} (round 3)`;
|
||||
expect(paragraph.match(/`/g)).toHaveLength(4);
|
||||
expect(paragraph).not.toContain('`` ');
|
||||
});
|
||||
|
||||
it('breaks the ledger marker grammar, which the span does not neutralise', () => {
|
||||
// The code span makes Markdown and HTML inert to a RENDERER. This
|
||||
// pipeline's own readers scan the raw body: `stripLedgerMarker` takes
|
||||
// the FIRST `<!-- qwen-review-ledger` it finds, so a forged opener
|
||||
// smuggled through a path makes the next round's strip swallow that
|
||||
// round's prose and its real marker, and a complete forged pair parses
|
||||
// as the recovered ledger when the real one is missing.
|
||||
const hostile = 'a <!-- qwen-review-ledger {"v":1,"round":9} --> .sh';
|
||||
const out = mdField(hostile);
|
||||
expect(out).not.toContain('<!--');
|
||||
expect(out).not.toContain('-->');
|
||||
expect(out).toBe('`a qwen-review-ledger {"v":1,"round":9} .sh`');
|
||||
// The whole body still holds exactly one marker: the real one.
|
||||
const body = `Some review prose.\n\n${out}\n\n<!-- qwen-review-ledger {"v":1,"round":3,"findings":[]} -->`;
|
||||
expect(body.split('<!-- qwen-review-ledger')).toHaveLength(2);
|
||||
expect(stripLedgerMarker(body)).toContain('Some review prose.');
|
||||
expect(parseLedger(body)?.round).toBe(3);
|
||||
});
|
||||
|
||||
it('renders a non-string the same way, never as an unquoted splice', () => {
|
||||
expect(mdField(undefined)).toBe('`undefined`');
|
||||
expect(mdField(7)).toBe('`7`');
|
||||
});
|
||||
});
|
||||
43
packages/cli/src/commands/review/lib/md-field.ts
Normal file
43
packages/cli/src/commands/review/lib/md-field.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Render a PR-controlled segment — a diff file path, a linter's message — safe to
|
||||
* splice into the review body we POST to GitHub. Git allows almost any byte in a
|
||||
* filename, so an unescaped path could carry `@mentions`, HTML, Markdown, or a
|
||||
* newline that forges body structure. An inline code span makes Markdown/HTML/`@`
|
||||
* inert; stripping backticks and newlines stops the value breaking out of the span
|
||||
* or forging new lines. (`capture-local`'s `display()` does the terminal-side
|
||||
* equivalent for stderr; this is the Markdown-body side.)
|
||||
*
|
||||
* Shared rather than restated: every body surface that renders a PR-controlled
|
||||
* path routes through this ONE function. The convergence paragraph spelled its
|
||||
* own backticks at first and shipped the breakout this strip exists to stop —
|
||||
* a path recorded in one round's ledger, rendered in the next round's cluster
|
||||
* sentence, terminated the code span early and the remainder rendered as live
|
||||
* Markdown in the bot's own public body.
|
||||
*/
|
||||
export function mdField(s: unknown): string {
|
||||
const inner = String(s)
|
||||
// Backticks and newlines break OUT of the code span. The HTML-comment
|
||||
// delimiters are a second grammar the span does not neutralise at all:
|
||||
// this pipeline's own readers scan the RAW body, and
|
||||
// `stripLedgerMarker` takes the FIRST `<!-- qwen-review-ledger` it
|
||||
// finds — so a path named `a <!-- qwen-review-ledger .sh` (git permits
|
||||
// it) makes the next round's strip swallow everything from the forged
|
||||
// opener to the real marker's `-->`, deleting that round's prose AND
|
||||
// its marker. A complete forged pair additionally parses as the
|
||||
// recovered ledger on any round the real marker is missing. No
|
||||
// legitimate value needs raw comment grammar inside a code span.
|
||||
.replace(/[`\r\n]+|<!--|-->/g, ' ')
|
||||
.trim();
|
||||
// A value that strips to nothing would emit a bare pair of backticks, which
|
||||
// is not a code span at all: two such runs in one paragraph pair up as
|
||||
// opener and closer, and the bot's own prose between them renders as code.
|
||||
// Git permits a filename that is nothing but backticks, so the empty case
|
||||
// is PR-controlled like every other input here.
|
||||
return '`' + (inner === '' ? '(unnamed)' : inner) + '`';
|
||||
}
|
||||
|
|
@ -44,7 +44,13 @@ describe('persistRecoveredLedger', () => {
|
|||
try {
|
||||
persistRecoveredLedger(
|
||||
side,
|
||||
{ ledger, commitId: 'a'.repeat(40), reviewId: 42 },
|
||||
{
|
||||
ledger,
|
||||
commitId: 'a'.repeat(40),
|
||||
reviewId: 42,
|
||||
foreign: false,
|
||||
merged: false,
|
||||
},
|
||||
{ noOwnReview: true, identityKnown: true },
|
||||
);
|
||||
const written = JSON.parse(readFileSync(side, 'utf8'));
|
||||
|
|
@ -52,6 +58,8 @@ describe('persistRecoveredLedger', () => {
|
|||
...ledger,
|
||||
commitId: 'a'.repeat(40),
|
||||
reviewId: 42,
|
||||
foreign: false,
|
||||
merged: false,
|
||||
});
|
||||
expect(written.sha).toBe('deadbeef00112233');
|
||||
} finally {
|
||||
|
|
@ -59,6 +67,258 @@ describe('persistRecoveredLedger', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('records that the winning marker came from another account', () => {
|
||||
// The convergence diagnosis CITES the round numbers carried in this work
|
||||
// list, in a body this account posts. Recovery adopts the highest-round
|
||||
// marker whoever posted it, so those rounds can be ones this account
|
||||
// never ran — and the provenance is knowable only here, at the moment of
|
||||
// recovery. Dropped on the way to disk, the citation goes out bare.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'prev-ledger-'));
|
||||
const side = join(dir, 'side.json');
|
||||
try {
|
||||
persistRecoveredLedger(
|
||||
side,
|
||||
{ ledger, commitId: null, reviewId: 9, foreign: true, merged: false },
|
||||
{ noOwnReview: false, identityKnown: true },
|
||||
);
|
||||
expect(JSON.parse(readFileSync(side, 'utf8')).foreign).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps disclosing foreign provenance while the work list carries it', () => {
|
||||
// Step 6 re-posts still-standing entries under their ORIGINAL ids, so a
|
||||
// foreign round's entries — and the round numbers a cluster cites off
|
||||
// them — survive into this account's own next marker. Recomputed from
|
||||
// the winning review's author alone, the flag flips false after exactly
|
||||
// one round and the caveat vanishes while the citations remain.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'prev-ledger-'));
|
||||
const side = join(dir, 'side.json');
|
||||
try {
|
||||
persistRecoveredLedger(
|
||||
side,
|
||||
{
|
||||
ledger: { ...ledger, round: 5 },
|
||||
commitId: null,
|
||||
reviewId: 9,
|
||||
foreign: true,
|
||||
merged: false,
|
||||
},
|
||||
{ noOwnReview: false, identityKnown: true },
|
||||
);
|
||||
expect(JSON.parse(readFileSync(side, 'utf8')).foreign).toBe(true);
|
||||
// Next round recovers this account's OWN marker, still carrying the
|
||||
// foreign-minted ids.
|
||||
persistRecoveredLedger(
|
||||
side,
|
||||
{
|
||||
ledger: { ...ledger, round: 6 },
|
||||
commitId: null,
|
||||
reviewId: 10,
|
||||
foreign: false,
|
||||
merged: false,
|
||||
},
|
||||
{ noOwnReview: false, identityKnown: true },
|
||||
);
|
||||
expect(JSON.parse(readFileSync(side, 'utf8')).foreign).toBe(true);
|
||||
// It clears when the list empties — the point at which no carried id
|
||||
// can still name a round this account never ran.
|
||||
persistRecoveredLedger(
|
||||
side,
|
||||
{
|
||||
ledger: { v: 1, round: 7, findings: [] },
|
||||
commitId: null,
|
||||
reviewId: 11,
|
||||
foreign: false,
|
||||
merged: false,
|
||||
},
|
||||
{ noOwnReview: false, identityKnown: true },
|
||||
);
|
||||
expect(JSON.parse(readFileSync(side, 'utf8')).foreign).toBe(false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('records whether the foreign winner was merged over own entries', () => {
|
||||
// The union restores this account's own certified entries under their own
|
||||
// ids. Without this flag the side file cannot tell a pure-foreign list
|
||||
// from an own+foreign one, and the next body says a predominantly own
|
||||
// work list "may not be this account's own".
|
||||
const dir = mkdtempSync(join(tmpdir(), 'prev-ledger-'));
|
||||
const side = join(dir, 'side.json');
|
||||
try {
|
||||
persistRecoveredLedger(
|
||||
side,
|
||||
{
|
||||
ledger,
|
||||
commitId: null,
|
||||
reviewId: 9,
|
||||
foreign: true,
|
||||
merged: true,
|
||||
},
|
||||
{ noOwnReview: false, identityKnown: true },
|
||||
);
|
||||
expect(JSON.parse(readFileSync(side, 'utf8')).merged).toBe(true);
|
||||
// Sticky across the next OWN recovery, for the same reason `foreign`
|
||||
// is: Step 6 re-posts the merged entries under their original ids.
|
||||
persistRecoveredLedger(
|
||||
side,
|
||||
{
|
||||
ledger: { ...ledger, round: 4 },
|
||||
commitId: null,
|
||||
reviewId: 10,
|
||||
foreign: false,
|
||||
merged: false,
|
||||
},
|
||||
{ noOwnReview: false, identityKnown: true },
|
||||
);
|
||||
expect(JSON.parse(readFileSync(side, 'utf8')).merged).toBe(true);
|
||||
// And it clears when the list empties, the same conjunct `foreign`
|
||||
// carries — nothing merged can still be in a list holding nothing.
|
||||
persistRecoveredLedger(
|
||||
side,
|
||||
{
|
||||
ledger: { v: 1, round: 5, findings: [] },
|
||||
commitId: null,
|
||||
reviewId: 11,
|
||||
foreign: false,
|
||||
merged: false,
|
||||
},
|
||||
{ noOwnReview: false, identityKnown: true },
|
||||
);
|
||||
expect(JSON.parse(readFileSync(side, 'utf8')).merged).toBe(false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('an ANONYMOUS advance keeps the provenance of the list it keeps', () => {
|
||||
// This branch advances only the COUNTER; the work list is kept verbatim,
|
||||
// so the flags describing that list are not stale — they were vouched
|
||||
// under a known identity and the ids they qualify are still in the file.
|
||||
// Zeroing `foreign` here broke the sticky clause: no later
|
||||
// identity-known round could re-fire it, and the caveat vanished while
|
||||
// the citations remained.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'prev-ledger-'));
|
||||
const side = join(dir, 'side.json');
|
||||
try {
|
||||
writeFileSync(
|
||||
side,
|
||||
JSON.stringify({
|
||||
...ledger,
|
||||
round: 5,
|
||||
reviewId: 50,
|
||||
model: 'qwen3.7-max@1a2b3c4d',
|
||||
foreign: true,
|
||||
merged: true,
|
||||
}),
|
||||
);
|
||||
persistRecoveredLedger(
|
||||
side,
|
||||
{
|
||||
ledger: { ...ledger, round: 6 },
|
||||
commitId: null,
|
||||
reviewId: 60,
|
||||
// FALSE in the input, true in the file: the assertion below then
|
||||
// proves the flag came from the kept list rather than being
|
||||
// echoed back. (Production feeds `true` here — without a `me`
|
||||
// every marker walks as foreign — which is exactly the value that
|
||||
// must not be stamped over this account's own certified list.)
|
||||
foreign: false,
|
||||
merged: false,
|
||||
},
|
||||
{ noOwnReview: false, identityKnown: false },
|
||||
);
|
||||
const written = JSON.parse(readFileSync(side, 'utf8'));
|
||||
expect(written.round).toBe(6);
|
||||
expect(written.foreign).toBe(true);
|
||||
expect(written.merged).toBe(true);
|
||||
// The anchor PAIR goes together here as at every other seam: a model
|
||||
// left behind names a certifier for a range that is gone.
|
||||
expect(written.sha).toBeUndefined();
|
||||
expect(written.model).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('a pure-foreign recovery cannot inherit a merged claim', () => {
|
||||
// `mergedOverOwn` is false when there was nothing to merge — an own
|
||||
// marker deleted, unparseable, or absent from the walk. Inheriting the
|
||||
// flag there makes the rendered caveat claim own-certified entries exist
|
||||
// when every entry is a stranger's.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'prev-ledger-'));
|
||||
const side = join(dir, 'side.json');
|
||||
try {
|
||||
writeFileSync(
|
||||
side,
|
||||
JSON.stringify({ ...ledger, round: 5, foreign: true, merged: true }),
|
||||
);
|
||||
persistRecoveredLedger(
|
||||
side,
|
||||
{
|
||||
ledger: {
|
||||
v: 1,
|
||||
round: 6,
|
||||
findings: [{ id: 'R6-1', sev: 'S', file: 'theirs.ts', title: 't' }],
|
||||
},
|
||||
commitId: null,
|
||||
reviewId: 60,
|
||||
foreign: true,
|
||||
merged: false,
|
||||
},
|
||||
{ noOwnReview: false, identityKnown: true },
|
||||
);
|
||||
const written = JSON.parse(readFileSync(side, 'utf8'));
|
||||
expect(written.foreign).toBe(true);
|
||||
expect(written.merged).toBe(false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not make an EMPTY prior list sticky — nothing could be carried', () => {
|
||||
// A stranger's empty LGTM marker adopted before this account's first
|
||||
// finding recorded `foreign: true` over a list holding nothing. Keyed on
|
||||
// the NEW list's length, the flag then re-fired forever over a provably
|
||||
// all-own work list — and the cost is mechanical as well as prose: the
|
||||
// cluster sort drops its depth key over a list with zero fabrication
|
||||
// risk.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'prev-ledger-'));
|
||||
const side = join(dir, 'side.json');
|
||||
try {
|
||||
persistRecoveredLedger(
|
||||
side,
|
||||
{
|
||||
ledger: { v: 1, round: 1, findings: [] },
|
||||
commitId: null,
|
||||
reviewId: 10,
|
||||
foreign: true,
|
||||
merged: false,
|
||||
},
|
||||
{ noOwnReview: false, identityKnown: true },
|
||||
);
|
||||
expect(JSON.parse(readFileSync(side, 'utf8')).foreign).toBe(true);
|
||||
// This account's own round 2, with findings of its own.
|
||||
persistRecoveredLedger(
|
||||
side,
|
||||
{
|
||||
ledger: { ...ledger, round: 2 },
|
||||
commitId: null,
|
||||
reviewId: 20,
|
||||
foreign: false,
|
||||
merged: false,
|
||||
},
|
||||
{ noOwnReview: false, identityKnown: true },
|
||||
);
|
||||
expect(JSON.parse(readFileSync(side, 'utf8')).foreign).toBe(false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('a recovery that THREW strips the age reference but keeps round and sha', () => {
|
||||
// A transient failure must not reset the id space or lose the anchor;
|
||||
// it must also not keep an age reference this run could not re-vouch —
|
||||
|
|
@ -82,6 +342,8 @@ describe('persistRecoveredLedger', () => {
|
|||
// exactly the rounds this path exists to protect.
|
||||
posted: 4,
|
||||
prevPosted: 2,
|
||||
fresh: 3,
|
||||
floor: 'c',
|
||||
}),
|
||||
);
|
||||
persistRecoveredLedger(side, null, {
|
||||
|
|
@ -89,7 +351,13 @@ describe('persistRecoveredLedger', () => {
|
|||
identityKnown: true,
|
||||
});
|
||||
const written = JSON.parse(readFileSync(side, 'utf8'));
|
||||
expect(written).toEqual({ ...ledger, posted: 4, prevPosted: 2 });
|
||||
expect(written).toEqual({
|
||||
...ledger,
|
||||
posted: 4,
|
||||
prevPosted: 2,
|
||||
fresh: 3,
|
||||
floor: 'c',
|
||||
});
|
||||
expect(written.round).toBe(3);
|
||||
expect(written.sha).toBe('deadbeef00112233');
|
||||
} finally {
|
||||
|
|
@ -97,6 +365,35 @@ describe('persistRecoveredLedger', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('carries the volume group through the ordinary recovered write', () => {
|
||||
// The common path own volumes reach disk. The DROP is pinned at the
|
||||
// anonymous seam and the KEEP at the threw-strip seam, but survival on
|
||||
// a successful recovery held only by construction — and "harmonize the
|
||||
// seams" is a plausible follow-up now that the group is one list.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'prev-ledger-'));
|
||||
const side = join(dir, 'side.json');
|
||||
try {
|
||||
persistRecoveredLedger(
|
||||
side,
|
||||
{
|
||||
ledger: { ...ledger, posted: 4, prevPosted: 2, fresh: 3, floor: 'c' },
|
||||
commitId: null,
|
||||
reviewId: 42,
|
||||
foreign: false,
|
||||
merged: false,
|
||||
},
|
||||
{ noOwnReview: false, identityKnown: true },
|
||||
);
|
||||
const written = JSON.parse(readFileSync(side, 'utf8'));
|
||||
expect(written.posted).toBe(4);
|
||||
expect(written.prevPosted).toBe(2);
|
||||
expect(written.fresh).toBe(3);
|
||||
expect(written.floor).toBe('c');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('proven absence REMOVES the stale file whole', () => {
|
||||
// The PR demonstrably holds no prior round for this account (a walked
|
||||
// list with no own submitted review) — another account's round counter
|
||||
|
|
@ -135,6 +432,8 @@ describe('persistRecoveredLedger', () => {
|
|||
ledger: { ...ledger, round: 2 },
|
||||
commitId: 'a'.repeat(40),
|
||||
reviewId: 20,
|
||||
foreign: false,
|
||||
merged: false,
|
||||
},
|
||||
{ noOwnReview: false, identityKnown: true },
|
||||
);
|
||||
|
|
@ -142,14 +441,26 @@ describe('persistRecoveredLedger', () => {
|
|||
// Same round, older reviewId: also kept.
|
||||
persistRecoveredLedger(
|
||||
side,
|
||||
{ ledger: { ...ledger, round: 7 }, commitId: null, reviewId: 60 },
|
||||
{
|
||||
ledger: { ...ledger, round: 7 },
|
||||
commitId: null,
|
||||
reviewId: 60,
|
||||
foreign: false,
|
||||
merged: false,
|
||||
},
|
||||
{ noOwnReview: false, identityKnown: true },
|
||||
);
|
||||
expect(JSON.parse(readFileSync(side, 'utf8'))).toEqual(newer);
|
||||
// A genuinely newer recovery still writes.
|
||||
persistRecoveredLedger(
|
||||
side,
|
||||
{ ledger: { ...ledger, round: 8 }, commitId: null, reviewId: 80 },
|
||||
{
|
||||
ledger: { ...ledger, round: 8 },
|
||||
commitId: null,
|
||||
reviewId: 80,
|
||||
foreign: false,
|
||||
merged: false,
|
||||
},
|
||||
{ noOwnReview: false, identityKnown: true },
|
||||
);
|
||||
expect(JSON.parse(readFileSync(side, 'utf8')).round).toBe(8);
|
||||
|
|
@ -198,6 +509,8 @@ describe('persistRecoveredLedger', () => {
|
|||
},
|
||||
commitId: 'c'.repeat(40),
|
||||
reviewId: 101,
|
||||
foreign: false,
|
||||
merged: false,
|
||||
},
|
||||
{ noOwnReview: false, identityKnown: false },
|
||||
);
|
||||
|
|
@ -226,13 +539,18 @@ describe('persistRecoveredLedger', () => {
|
|||
round: 7,
|
||||
reviewId: 100,
|
||||
commitId: 'b'.repeat(40),
|
||||
// The volumes belong to round 7. This branch advances the counter
|
||||
// past it, so they must go the way the anchor and the age
|
||||
// reference go — kept, they would attribute this account's round-7
|
||||
// The volume group belongs to round 7. This branch advances the
|
||||
// counter past it, so it must go the way the anchor and the age
|
||||
// reference go — kept, it would attribute this account's round-7
|
||||
// posting count to the foreign round that won recovery, and the
|
||||
// next compose would stamp it as `prevPosted`.
|
||||
// next compose would stamp it as `prevPosted`. The floor and the
|
||||
// fresh count qualify that volume, so they go with it: a posture
|
||||
// recorded for a round whose volume was deliberately discarded
|
||||
// qualifies nothing.
|
||||
posted: 4,
|
||||
prevPosted: 2,
|
||||
fresh: 3,
|
||||
floor: 'c',
|
||||
}),
|
||||
);
|
||||
persistRecoveredLedger(
|
||||
|
|
@ -246,6 +564,8 @@ describe('persistRecoveredLedger', () => {
|
|||
},
|
||||
commitId: 'c'.repeat(40),
|
||||
reviewId: 200,
|
||||
foreign: false,
|
||||
merged: false,
|
||||
},
|
||||
{ noOwnReview: true, identityKnown: false },
|
||||
);
|
||||
|
|
@ -264,6 +584,9 @@ describe('persistRecoveredLedger', () => {
|
|||
});
|
||||
|
||||
it('an ANONYMOUS recovery with no existing file still writes whole', () => {
|
||||
// Production shape: without a `me` every marker walks as foreign, this
|
||||
// account's own included, so the recorded provenance must be "unknown"
|
||||
// rather than "another account's".
|
||||
// Nothing to protect: a machine with no side file gains round context
|
||||
// from the write, and the list it gains is exactly what a healthy
|
||||
// foreign-only recovery would have handed it — THEIR claims, no anchor.
|
||||
|
|
@ -272,12 +595,39 @@ describe('persistRecoveredLedger', () => {
|
|||
try {
|
||||
persistRecoveredLedger(
|
||||
side,
|
||||
{ ledger: { ...ledger, round: 4 }, commitId: null, reviewId: 40 },
|
||||
{
|
||||
ledger: {
|
||||
...ledger,
|
||||
round: 4,
|
||||
posted: 7,
|
||||
prevPosted: 3,
|
||||
fresh: 4,
|
||||
floor: 'c',
|
||||
},
|
||||
commitId: null,
|
||||
reviewId: 40,
|
||||
// What recovery actually hands this branch anonymously.
|
||||
foreign: true,
|
||||
merged: true,
|
||||
},
|
||||
{ noOwnReview: false, identityKnown: false },
|
||||
);
|
||||
const written = JSON.parse(readFileSync(side, 'utf8'));
|
||||
expect(written.round).toBe(4);
|
||||
expect(written.findings).toEqual(ledger.findings);
|
||||
// An unknown identity is not a foreign author: recorded `true`, the
|
||||
// next round publishes the foreign caveat about a marker this account
|
||||
// may well have posted.
|
||||
expect(written.foreign).toBe(false);
|
||||
expect(written.merged).toBe(false);
|
||||
// ...but it cannot VOUCH for the volume either. Without a `me` every
|
||||
// marker walks as foreign, so the upstream strip never fires and any
|
||||
// marker inside the headroom wins — kept, a stranger's counts become
|
||||
// this loop's baseline and are stamped into the next own marker.
|
||||
expect(written.posted).toBeUndefined();
|
||||
expect(written.prevPosted).toBeUndefined();
|
||||
expect(written.fresh).toBeUndefined();
|
||||
expect(written.floor).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1500,17 +1500,157 @@ describe('latestLedger — the split trust surface', () => {
|
|||
'{"id":"R6-1","sev":"S","file":"e.ts","title":"deep squat"},' +
|
||||
'{"id":"R3-1","sev":"C","file":"b.ts","title":"own"},' +
|
||||
'{"id":"R1-2","sev":"S","file":"c.ts","title":"carried"},' +
|
||||
'{"id":"f7","sev":"S","file":"d.ts","title":"non-pipeline id"}' +
|
||||
// Admission is the WHOLE grammar, so an id the pipeline's own writer
|
||||
// could never emit does not ride either: `idFor` reuses only ids read
|
||||
// back through `LEDGER_ID_READBACK` and otherwise stamps
|
||||
// `R<round>-<n>`, so a non-conforming id is a hand-edited or foreign
|
||||
// entry by construction.
|
||||
'{"id":"f7","sev":"S","file":"d.ts","title":"non-pipeline id"},' +
|
||||
// The bypass the whole-shape test closes: every reader downstream
|
||||
// TRIMS before matching, so a leading space made this id invisible to
|
||||
// the untrimmed squat rule above and fully effective everywhere else —
|
||||
// pre-claiming a future round's prefix, and citing round 9999 in a
|
||||
// convergence paragraph this account posts.
|
||||
'{"id":" R9999-1","sev":"S","file":"f.ts","title":"whitespace squat"}' +
|
||||
']} -->';
|
||||
const found = latestLedger(
|
||||
[review('stranger', '2026-01-09T00:00:00Z', squatting)],
|
||||
'bot',
|
||||
);
|
||||
expect(found?.ledger.findings.map((f) => f.id)).toEqual([
|
||||
'R3-1',
|
||||
'R1-2',
|
||||
'f7',
|
||||
]);
|
||||
expect(found?.ledger.findings.map((f) => f.id)).toEqual(['R3-1', 'R1-2']);
|
||||
});
|
||||
|
||||
it("drops the volume telemetry from another account's marker", () => {
|
||||
// `posted` is the baseline the next round's trend is measured against,
|
||||
// and a foreign one is a number a stranger chose — with leverage both
|
||||
// ways: `posted: 1` makes every following round read as "not falling",
|
||||
// `posted: 100000` suppresses the signal for as long as the marker
|
||||
// stands. It goes the way the anchor goes, and the floor goes with it
|
||||
// because it qualifies nothing else.
|
||||
const foreign =
|
||||
'x <!-- qwen-review-ledger {"v":1,"round":9,"findings":[' +
|
||||
'{"id":"R9-1","sev":"S","file":"src/auth.ts","title":"planted"}' +
|
||||
'],"posted":1,"prevPosted":1,"floor":"c"} -->';
|
||||
const found = latestLedger(
|
||||
[review('stranger', '2026-01-09T00:00:00Z', foreign)],
|
||||
'bot',
|
||||
);
|
||||
expect(found?.foreign).toBe(true);
|
||||
expect(found?.ledger.posted).toBeUndefined();
|
||||
expect(found?.ledger.prevPosted).toBeUndefined();
|
||||
expect(found?.ledger.floor).toBeUndefined();
|
||||
// The work list still rides — it is re-ruled entry by entry, which a
|
||||
// bare number cannot be.
|
||||
expect(found?.ledger.findings.map((f) => f.id)).toEqual(['R9-1']);
|
||||
});
|
||||
|
||||
it('keeps the volume when the identity lookup is what failed', () => {
|
||||
// Without a `me` EVERY marker walks as foreign, this account's own
|
||||
// included. Stripping the volume on that reading let one blip in
|
||||
// `gh api user` break this account's own trend chain for two rounds. The
|
||||
// anchor still goes — a drive-by anchor must not decide which lines this
|
||||
// pipeline stops looking at — but a number nobody can attribute is not
|
||||
// the same as a number somebody else chose.
|
||||
const own =
|
||||
'x <!-- qwen-review-ledger {"v":1,"round":9,"findings":[],' +
|
||||
'"posted":4,"prevPosted":2,"fresh":3,"floor":"c",' +
|
||||
'"sha":"deadbeef00112233"} -->';
|
||||
const anonymous = latestLedger(
|
||||
[review('maintainer', '2026-01-09T00:00:00Z', own)],
|
||||
null,
|
||||
);
|
||||
expect(anonymous?.foreign).toBe(true);
|
||||
expect(anonymous?.ledger.posted).toBe(4);
|
||||
expect(anonymous?.ledger.fresh).toBe(3);
|
||||
expect(anonymous?.ledger.floor).toBe('c');
|
||||
expect(anonymous?.ledger.sha).toBeUndefined();
|
||||
});
|
||||
|
||||
it("restores this account's own volume when it restores its own findings", () => {
|
||||
// The union exists so a foreign marker cannot erase own data, and the
|
||||
// volume is own data: this account's own marker is walked in the same
|
||||
// pass. Restoring only `findings` let any second bot posting one
|
||||
// parseable marker at a round at-or-above this account's blind the
|
||||
// trend for that round with a good count in hand.
|
||||
const own =
|
||||
'x <!-- qwen-review-ledger {"v":1,"round":8,"findings":[' +
|
||||
'{"id":"R8-9","sev":"C","file":"a.ts","title":"certified"}' +
|
||||
'],"posted":6,"fresh":4,"floor":"c"} -->';
|
||||
// The foreign counts are a SUPERSET of the own ones in every field, so
|
||||
// a strip that silently failed would be indistinguishable from one that
|
||||
// worked if the own values happened to win a comparison — they are
|
||||
// restored wholesale, and these numbers make the difference visible.
|
||||
const foreign =
|
||||
'y <!-- qwen-review-ledger {"v":1,"round":8,"findings":[' +
|
||||
'{"id":"R8-1","sev":"S","file":"b.ts","title":"theirs"}' +
|
||||
'],"posted":99,"prevPosted":98,"fresh":97,"floor":"o"} -->';
|
||||
const found = latestLedger(
|
||||
[
|
||||
review('bot', '2026-01-01T00:00:00Z', own),
|
||||
review('stranger', '2026-01-02T00:00:00Z', foreign),
|
||||
],
|
||||
'bot',
|
||||
);
|
||||
expect(found?.merged).toBe(true);
|
||||
expect(found?.ledger.posted).toBe(6);
|
||||
expect(found?.ledger.fresh).toBe(4);
|
||||
expect(found?.ledger.floor).toBe('c');
|
||||
// The foreign numbers are gone, not merely outranked.
|
||||
expect(found?.ledger.prevPosted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('restores an own TRUE-ZERO volume even with nothing to merge', () => {
|
||||
// A clean own round — LGTM, findings empty, `posted: 0` — has a real
|
||||
// baseline, and zero survives the persistence chain precisely so it can
|
||||
// be one. Gated on the list, any stranger's parseable marker blinded the
|
||||
// trend for that round with a good count in hand.
|
||||
const own =
|
||||
'LGTM <!-- qwen-review-ledger {"v":1,"round":8,"findings":[],' +
|
||||
'"posted":0,"fresh":0,"floor":"o"} -->';
|
||||
const foreign =
|
||||
'y <!-- qwen-review-ledger {"v":1,"round":8,"findings":[' +
|
||||
'{"id":"R8-1","sev":"S","file":"b.ts","title":"theirs"}' +
|
||||
'],"posted":99,"fresh":97,"floor":"c"} -->';
|
||||
const found = latestLedger(
|
||||
[
|
||||
review('bot', '2026-01-01T00:00:00Z', own),
|
||||
review('stranger', '2026-01-02T00:00:00Z', foreign),
|
||||
],
|
||||
'bot',
|
||||
);
|
||||
// Nothing merged — there was no own list — but the own counts came back.
|
||||
expect(found?.merged).toBe(false);
|
||||
expect(found?.ledger.posted).toBe(0);
|
||||
expect(found?.ledger.fresh).toBe(0);
|
||||
expect(found?.ledger.floor).toBe('o');
|
||||
});
|
||||
|
||||
it('will not pair own counts with a round the own marker does not describe', () => {
|
||||
// The side file pairs ONE round number with ONE set of counts. Spread
|
||||
// onto a higher-round winner, own round-7 numbers are attributed to a
|
||||
// round this account never ran — and the next body says "the previous
|
||||
// round posted 0 (0 new)" in the same paragraph as a cluster citing
|
||||
// round 8, which plainly did post.
|
||||
const own =
|
||||
'LGTM <!-- qwen-review-ledger {"v":1,"round":7,"findings":[],' +
|
||||
'"posted":0,"fresh":0,"floor":"o"} -->';
|
||||
const foreign =
|
||||
'y <!-- qwen-review-ledger {"v":1,"round":8,"findings":[' +
|
||||
'{"id":"R8-1","sev":"S","file":"b.ts","title":"theirs"}' +
|
||||
'],"posted":99,"fresh":97,"floor":"c"} -->';
|
||||
const found = latestLedger(
|
||||
[
|
||||
review('bot', '2026-01-01T00:00:00Z', own),
|
||||
review('stranger', '2026-01-02T00:00:00Z', foreign),
|
||||
],
|
||||
'bot',
|
||||
);
|
||||
expect(found?.ledger.round).toBe(8);
|
||||
// The stranger's counts are stripped and the own ones are not adopted:
|
||||
// absence already reads as "not recorded", which beats a wrong pairing.
|
||||
expect(found?.ledger.posted).toBeUndefined();
|
||||
expect(found?.ledger.fresh).toBeUndefined();
|
||||
expect(found?.ledger.floor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('merges a foreign winner OVER the own findings — displacement is dead', () => {
|
||||
|
|
|
|||
|
|
@ -751,6 +751,17 @@ const COMMIT_SHA_RE = /^[0-9a-f]{40,64}$/;
|
|||
export interface RecoveredLedger {
|
||||
ledger: Ledger;
|
||||
commitId: string | null;
|
||||
/**
|
||||
* The winning marker was posted by another account. Recovery adopts the
|
||||
* highest-round marker whoever posted it (bounded by
|
||||
* `FOREIGN_ROUND_HEADROOM`), so a work list can carry rounds this account
|
||||
* never ran — and the convergence diagnosis CITES those round numbers in a
|
||||
* body this account posts. Persisted beside the list so the citation can
|
||||
* disclose where it came from instead of publishing it bare.
|
||||
*/
|
||||
foreign: boolean;
|
||||
/** That foreign winner was merged over this account's own findings. */
|
||||
merged: boolean;
|
||||
/**
|
||||
* The winning review's own id — persisted so Step 6 can find WHICH body's
|
||||
* not-reviewed disclosures bind the code-age rule: with several summaries
|
||||
|
|
@ -934,7 +945,16 @@ export function recoverLedger(
|
|||
if (!best) return { recovered: null, sawOwnReview };
|
||||
// The anchor never crosses accounts. Dropped here, at the recovery seam, so
|
||||
// no consumer downstream has to remember the rule.
|
||||
// The anchor is stripped whenever the winner is foreign, INCLUDING the
|
||||
// anonymous case: without a `me` every marker walks as foreign, and a
|
||||
// drive-by anchor must not decide which lines this pipeline stops looking
|
||||
// at. The volume is different. `foreign` there means "another account
|
||||
// chose this number", and on an anonymous walk it means only "this run
|
||||
// could not ask who". Stripping on that reading let one blip in
|
||||
// `gh api user` break this account's own trend chain for two rounds — and
|
||||
// record its own marker as a stranger's.
|
||||
let ledger = best.foreign ? stripAnchor(best.ledger) : best.ledger;
|
||||
if (me && best.foreign) ledger = stripForeignVolume(ledger);
|
||||
// A FOREIGN winner never DISPLACES this account's own findings — it is
|
||||
// merged over them. Round-first selection alone handed a drive-by poster a
|
||||
// one-comment suppression: a marker at `ownMax + 1` (deep inside the
|
||||
|
|
@ -955,6 +975,30 @@ export function recoverLedger(
|
|||
// shape `merged` made the provenance wording claim own-certified entries
|
||||
// exist when none do (and misattributed the PARTIAL note's sum). The
|
||||
// foreign winner recovers as pure-foreign, which is exactly what it is.
|
||||
if (best.foreign && bestOwn) {
|
||||
// The volume comes back whether or not there is a LIST to merge. The
|
||||
// union exists so a foreign marker cannot erase own data, and the
|
||||
// volume is own data too: this account's own marker was walked in the
|
||||
// same pass and its counts are trustworthy. Gated on the list, an own
|
||||
// round that posted nothing — a clean LGTM, findings empty, `posted: 0`
|
||||
// — lost its true-zero baseline to any stranger's parseable marker, and
|
||||
// zero survives the whole persistence chain precisely so it can be one.
|
||||
// Derived from the shared list, not re-enumerated — see `pickVolume`.
|
||||
// ...but ONLY when the own marker describes the SAME round the winner
|
||||
// does. The side file pairs one round number with one set of counts, so
|
||||
// spreading round M's counts onto a round-N winner attributes own
|
||||
// numbers to a round this account never ran — and the next body says
|
||||
// "the previous round posted 0 (0 new)" in the same paragraph as a
|
||||
// cluster citing round N, which plainly did post. A round the counts do
|
||||
// not describe is worse than no counts: absence already reads as "not
|
||||
// recorded".
|
||||
if (bestOwn.ledger.round === ledger.round) {
|
||||
ledger = {
|
||||
...ledger,
|
||||
...pickVolume(bestOwn.ledger as unknown as Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (best.foreign && bestOwn && bestOwn.ledger.findings.length > 0) {
|
||||
mergedOverOwn = true;
|
||||
const ownIds = new Set(bestOwn.ledger.findings.map((f) => f.id));
|
||||
|
|
@ -1158,11 +1202,16 @@ export function persistRecoveredLedger(
|
|||
// exactly as a pre-telemetry predecessor does.
|
||||
const {
|
||||
sha: _droppedSha,
|
||||
// The PAIR, as everywhere else: a `model` left behind says a round
|
||||
// was certified by someone while the range it certified is gone.
|
||||
// This was the one seam where they did not fall together.
|
||||
model: _droppedModel,
|
||||
commitId: _droppedCommitId,
|
||||
posted: _droppedPosted,
|
||||
prevPosted: _droppedPrevPosted,
|
||||
...kept
|
||||
...rest
|
||||
} = existing;
|
||||
// Through the shared projection, not a second hand-kept list: the
|
||||
// volume group grew twice and this branch was updated neither time.
|
||||
const kept = withoutVolume(rest);
|
||||
mkdirSync(dirname(sideFilePath), { recursive: true });
|
||||
writeAtomic(
|
||||
JSON.stringify(
|
||||
|
|
@ -1170,6 +1219,18 @@ export function persistRecoveredLedger(
|
|||
...kept,
|
||||
round: recovered.ledger.round,
|
||||
reviewId: recovered.reviewId,
|
||||
// Both provenance flags ride with `...kept`, deliberately
|
||||
// unwritten here. This branch advances only the COUNTER; the
|
||||
// work list it describes is kept verbatim, so the flags that
|
||||
// describe that list are not stale — they were vouched under a
|
||||
// known identity, and the ids they qualify are still in the
|
||||
// file. Zeroing `foreign` here broke the sticky clause the
|
||||
// recovered branch establishes: no later identity-known round
|
||||
// could re-fire it, and the caveat vanished while the
|
||||
// citations remained. (What this run genuinely cannot vouch —
|
||||
// the anchor, the age reference, the volume group — is
|
||||
// stripped above, because each is a fact about a specific
|
||||
// round and this write advances past it.)
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
|
@ -1178,12 +1239,81 @@ export function persistRecoveredLedger(
|
|||
return;
|
||||
}
|
||||
mkdirSync(dirname(sideFilePath), { recursive: true });
|
||||
// An ANONYMOUS recovery cannot vouch for the volume it adopts. Without
|
||||
// a `me` every marker walks as foreign, so the upstream strip
|
||||
// (`if (me && best.foreign)`) never fires and `ownMax` is 0 — any
|
||||
// marker inside the headroom wins. Kept, a stranger's counts become
|
||||
// this loop's baseline: the trend evaluates against them, the
|
||||
// paragraph cites them as own history, and they are stamped into this
|
||||
// account's own next marker as `prevPosted`, which later recovery
|
||||
// trusts. The counter-advance branch already sheds the group for this
|
||||
// exact reason; this seam takes the same "not recorded" degradation.
|
||||
const recoveredOut = identityKnown
|
||||
? recovered.ledger
|
||||
: (withoutVolume(
|
||||
recovered.ledger as unknown as Record<string, unknown>,
|
||||
) as unknown as Ledger);
|
||||
writeAtomic(
|
||||
JSON.stringify(
|
||||
{
|
||||
...recovered.ledger,
|
||||
...recoveredOut,
|
||||
...(recovered.commitId ? { commitId: recovered.commitId } : {}),
|
||||
reviewId: recovered.reviewId,
|
||||
// Provenance travels WITH the list it describes. Written even
|
||||
// when false, so the field's absence means only "a version
|
||||
// before this wrote the file" — which degrades to no disclosure,
|
||||
// the same reading a pre-telemetry predecessor already gets.
|
||||
//
|
||||
// Two qualifications on the value:
|
||||
//
|
||||
// - An UNKNOWN identity is not a foreign author. Without a `me`
|
||||
// every marker walks as foreign, so recording `true` there
|
||||
// publishes a caveat about a marker this account may well have
|
||||
// posted.
|
||||
// - It is STICKY while the work list is non-empty. Step 6
|
||||
// re-posts still-standing entries under their ORIGINAL ids, so
|
||||
// a foreign round's entries — and the round numbers a cluster
|
||||
// cites off them — survive into this account's own next
|
||||
// marker, where recovery would compute `foreign: false` and
|
||||
// the caveat would vanish while the citations remained. It
|
||||
// clears when the list empties, which is the point at which no
|
||||
// carried id can still name a round this account never ran.
|
||||
// That over-discloses on a list whose foreign entries are gone
|
||||
// but whose own entries are not; over-disclosing a caveat is
|
||||
// the safe direction.
|
||||
// Keyed on the PREVIOUS list — the one that could carry an id
|
||||
// forward — not on the new one. An empty prior list can carry
|
||||
// nothing, so re-firing the flag off the new list's length
|
||||
// stamped a provably all-own work list foreign forever: one
|
||||
// stranger's empty LGTM marker adopted before this account's
|
||||
// first finding was enough, and the cost is mechanical as well
|
||||
// as prose — `trustDepth` drops the depth key over a list with
|
||||
// zero fabrication risk.
|
||||
foreign:
|
||||
(identityKnown && recovered.foreign) ||
|
||||
(existing?.['foreign'] === true &&
|
||||
Array.isArray(existing['findings']) &&
|
||||
(existing['findings'] as unknown[]).length > 0 &&
|
||||
recovered.ledger.findings.length > 0),
|
||||
// Whether that foreign winner was MERGED over this account's own
|
||||
// findings. `renderLedgerSection` already draws this line for the
|
||||
// model ("entries this account certified are its own claims");
|
||||
// dropped on the way to disk, the posted caveat could not, and
|
||||
// said a predominantly own work list "may not be this account's
|
||||
// own".
|
||||
// The sticky term is conditioned on the winner NOT being foreign.
|
||||
// A foreign marker winning while this account's own list is
|
||||
// absent, deleted, or unparseable writes a PURE-foreign list —
|
||||
// `mergedOverOwn` is false precisely because there was nothing to
|
||||
// merge — and inheriting `merged` there makes the rendered
|
||||
// caveat claim own-certified entries exist when every entry is a
|
||||
// stranger's. The union guard one function up refuses to flag
|
||||
// that same shape for the same reason.
|
||||
merged:
|
||||
(identityKnown && recovered.merged) ||
|
||||
(!recovered.foreign &&
|
||||
existing?.['merged'] === true &&
|
||||
recovered.ledger.findings.length > 0),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
|
@ -1236,6 +1366,69 @@ function stripAnchor(ledger: Ledger): Ledger {
|
|||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the volume telemetry from a marker another account posted.
|
||||
*
|
||||
* The same reasoning as the anchor, applied to the other cross-account field:
|
||||
* `posted` is the baseline the next round's volume trend is measured against,
|
||||
* so a foreign value is not this loop's history — it is a number a stranger
|
||||
* chose. And it is a number with leverage in BOTH directions: `posted: 1`
|
||||
* makes every following round with any volume read as "not falling", while
|
||||
* `posted: 100000` suppresses the signal for as long as the marker stands.
|
||||
* Dropped rather than carried-and-disclosed, because unlike the work list
|
||||
* there is nothing here for a reader to re-rule on: a volume is a single
|
||||
* number with no evidence attached. Absence already reads as "not recorded",
|
||||
* which degrades the trend exactly as a pre-telemetry predecessor does. The
|
||||
* floor goes with it — it qualifies the volume and nothing else.
|
||||
*/
|
||||
export const VOLUME_FIELDS = [
|
||||
'posted',
|
||||
'prevPosted',
|
||||
'fresh',
|
||||
'floor',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Drop the whole volume group from a record, whatever shape it is in.
|
||||
*
|
||||
* ONE list, because there are two seams that must shed it — this one and the
|
||||
* anonymous-recovery branch that rewrites the side file by hand — and a
|
||||
* hand-kept field list on each is how `floor` came to be shed at one seam
|
||||
* and kept at the other, recorded for a round whose volume had been
|
||||
* deliberately discarded.
|
||||
*/
|
||||
export function withoutVolume<T extends Record<string, unknown>>(record: T): T {
|
||||
const out = { ...record };
|
||||
for (const field of VOLUME_FIELDS) delete out[field];
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The volume group PRESENT in a record — the restore half of the same list.
|
||||
*
|
||||
* The union that protects own findings from a foreign winner has to put the
|
||||
* own volume back, and hand-enumerating it there was a third copy of the
|
||||
* list `withoutVolume` exists to be the only one of. A field added to the
|
||||
* group would otherwise be stripped from the foreign winner and never
|
||||
* restored, losing the own data point on exactly the merged rounds the
|
||||
* branch protects.
|
||||
*/
|
||||
export function pickVolume(
|
||||
record: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const field of VOLUME_FIELDS) {
|
||||
if (record[field] !== undefined) out[field] = record[field];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripForeignVolume(ledger: Ledger): Ledger {
|
||||
return withoutVolume(
|
||||
ledger as unknown as Record<string, unknown>,
|
||||
) as unknown as Ledger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the recovered anchor may scope this round, and the routing that
|
||||
* follows from it — computed here, for the reason `renderLedgerSection`
|
||||
|
|
|
|||
|
|
@ -27,7 +27,11 @@ import {
|
|||
severityOf,
|
||||
} from './lib/inline-counts.js';
|
||||
import { carriesCommentMarker } from './lib/review-footer.js';
|
||||
import { LEDGER_ID_READBACK, LEDGER_ID_TOKEN } from './lib/ledger.js';
|
||||
import {
|
||||
LEDGER_ID_READBACK,
|
||||
LEDGER_ID_SHAPE,
|
||||
LEDGER_ID_TOKEN,
|
||||
} from './lib/ledger.js';
|
||||
import { detectPlatformKind } from './lib/platform/registry.js';
|
||||
import { ensureAoneAuthenticated } from './lib/platform/aone-client.js';
|
||||
import {
|
||||
|
|
@ -80,8 +84,6 @@ interface CommentSummary {
|
|||
matchedIds?: string[];
|
||||
}
|
||||
|
||||
/** Exact-shape check for ids read from the --new-findings file. */
|
||||
const LEDGER_ID_SHAPE = new RegExp(`^${LEDGER_ID_TOKEN}$`);
|
||||
/** The carried id this comment's claim line leads with, if any. */
|
||||
function extractCarriedIds(body: string): string[] {
|
||||
let line = carriedClaimLine(body);
|
||||
|
|
|
|||
|
|
@ -449,6 +449,40 @@ describe('saveReviewArtifact', () => {
|
|||
).toBe(0);
|
||||
});
|
||||
|
||||
it('carries the fresh count and the convergence paragraph into the artifact', () => {
|
||||
// Both are new surfaces on the composed result, and the allow-list is
|
||||
// where a new field silently stops existing. The paragraph matters most:
|
||||
// it is the FIRST clause the overflow ladder sheds, so on the rounds it
|
||||
// fires the artifact may be the only durable copy.
|
||||
const paths = fixture();
|
||||
writeJson(paths.composed, {
|
||||
...verdict,
|
||||
postedFresh: 2,
|
||||
convergence: { en: 'Convergence: …', zh: '收敛情况:…' },
|
||||
});
|
||||
saveReviewArtifact({ ...paths, target: 'local', effort: 'medium' });
|
||||
const saved = JSON.parse(readFileSync(paths.out, 'utf8'));
|
||||
expect(saved.verdict.postedFresh).toBe(2);
|
||||
expect(saved.verdict.convergence.en).toBe('Convergence: …');
|
||||
expect(saved.verdict.convergence.zh).toBe('收敛情况:…');
|
||||
});
|
||||
|
||||
it('PRESERVES an absent postedFresh and refuses a present one of the wrong shape', () => {
|
||||
// Same distinction as its sibling: a round that recorded no fresh count
|
||||
// is not a round that produced none.
|
||||
const paths = fixture();
|
||||
saveReviewArtifact({ ...paths, target: 'local', effort: 'medium' });
|
||||
expect(
|
||||
'postedFresh' in JSON.parse(readFileSync(paths.out, 'utf8')).verdict,
|
||||
).toBe(false);
|
||||
rmSync(paths.out, { force: true });
|
||||
|
||||
writeJson(paths.composed, { ...verdict, postedFresh: -1 });
|
||||
expect(() =>
|
||||
saveReviewArtifact({ ...paths, target: 'local', effort: 'medium' }),
|
||||
).toThrow(/postedFresh/);
|
||||
});
|
||||
|
||||
it('reads an absent or null floorEnforced as empty — a pre-enforcement composed file must still save', () => {
|
||||
// Null rides the same absence semantics as the sibling deferredCount
|
||||
// pair — an undefined-only check would refuse a composed file that
|
||||
|
|
|
|||
|
|
@ -36,7 +36,10 @@ import { volumeOf } from './lib/ledger.js';
|
|||
import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js';
|
||||
|
||||
interface PersistedVerdict
|
||||
extends Omit<ComposeReviewResult, 'postedInline' | 'prevPostedInline'> {
|
||||
extends Omit<
|
||||
ComposeReviewResult,
|
||||
'postedInline' | 'postedFresh' | 'prevPostedInline'
|
||||
> {
|
||||
verdictLine: string;
|
||||
/**
|
||||
* Optional HERE, required on the composed result it is otherwise a copy
|
||||
|
|
@ -51,6 +54,13 @@ interface PersistedVerdict
|
|||
* recoverable from the marker chain inside `body`.
|
||||
*/
|
||||
postedInline?: number;
|
||||
/**
|
||||
* Optional for the same reason as its sibling, and for one more: an
|
||||
* artifact written before the convergence trend measured NEW findings
|
||||
* carries only the total. Absence is preserved rather than defaulted —
|
||||
* a round that recorded no fresh count is not a round that produced none.
|
||||
*/
|
||||
postedFresh?: number;
|
||||
}
|
||||
|
||||
export interface ReviewArtifactV1 {
|
||||
|
|
@ -282,6 +292,29 @@ function validateVerdict(value: unknown): PersistedVerdict {
|
|||
'Composed verdict.postedInline must be a non-negative integer.',
|
||||
);
|
||||
}
|
||||
// The fresh count reads by the same rules as the total it is part of.
|
||||
// The convergence paragraph is the ONE clause the overflow ladder sheds
|
||||
// first, and the artifact is where a trimmed round's record lives. Dropped
|
||||
// by this allow-list, the durable record of a round whose body shed it
|
||||
// held neither copy.
|
||||
const rawConvergence = verdict['convergence'];
|
||||
let convergence: { en: string; zh: string } | undefined;
|
||||
if (rawConvergence !== undefined && rawConvergence !== null) {
|
||||
const c = object(rawConvergence, 'Composed verdict.convergence');
|
||||
convergence = {
|
||||
en: string(c['en'], 'Composed verdict.convergence.en'),
|
||||
zh: string(c['zh'], 'Composed verdict.convergence.zh'),
|
||||
};
|
||||
}
|
||||
// The fresh count reads by the same rules as the total it is part of.
|
||||
const rawFresh = verdict['postedFresh'];
|
||||
const freshAbsent = rawFresh === undefined || rawFresh === null;
|
||||
const postedFresh = freshAbsent ? undefined : volumeOf(rawFresh);
|
||||
if (!freshAbsent && postedFresh === undefined) {
|
||||
throw new Error(
|
||||
'Composed verdict.postedFresh must be a non-negative integer.',
|
||||
);
|
||||
}
|
||||
// Absent reads as "no trim", the same absence semantics the sibling count
|
||||
// gets: a composed file written before the body budget shipped carries no
|
||||
// `bodyTrim`, and a mid-upgrade save must not fail over a record of
|
||||
|
|
@ -332,6 +365,8 @@ function validateVerdict(value: unknown): PersistedVerdict {
|
|||
deferredCount,
|
||||
floorEnforced: floorEnforced as number[],
|
||||
...(postedInline === undefined ? {} : { postedInline }),
|
||||
...(postedFresh === undefined ? {} : { postedFresh }),
|
||||
...(convergence === undefined ? {} : { convergence }),
|
||||
lowSignal:
|
||||
lowSignal === null
|
||||
? null
|
||||
|
|
|
|||
|
|
@ -93,13 +93,25 @@ const rootDir = join(__dirname, '..');
|
|||
// Bumped from 195KB to 196KB for transient-vs-gone media hydration errors and
|
||||
// the reference-only replay placeholder.
|
||||
// Bumped from 196KB to 197KB for the workspace session live-state daemon
|
||||
// surface (catalog version + live snapshot accessors) and immutable,
|
||||
// identity-stable transcript block indexes used by browser renderers.
|
||||
// surface (catalog version + live snapshot accessors), immutable,
|
||||
// identity-stable transcript block indexes used by browser renderers, and the
|
||||
// daemon transcript-retention work (replay-snapshot release + capped debug
|
||||
// payloads, #9303) landing on top of the session media references bundle.
|
||||
// Bumped from 197KB to 198KB for the unrecognized-diagnostic sidechannel
|
||||
// (`unrecognizedDiagnostics` routing + selector, #8823).
|
||||
// Bumped from 198KB to 199KB for persistent session attachment read/remove and
|
||||
// binary resource hydration.
|
||||
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 199 * 1024;
|
||||
// Bumped from 199KB to 200KB for the retention byte budget (block byte
|
||||
// estimation + budget-aware trimming) and backing-store-detached string caps
|
||||
// (#9303 review round 3).
|
||||
// Bumped from 200KB to 206KB for the pagination/eviction reconciliation and the
|
||||
// #8823 × #9303 merge (#9303 review rounds 9-12): eviction-direction signal,
|
||||
// rewind truncation callback, trimmed tool/permission sentinel helpers,
|
||||
// record-boundary eviction snap, and the floor back-off — each bump budgeted
|
||||
// its own delta in isolation, and the combined feature sets land here. The
|
||||
// attachment read/remove + binary hydration feature that separately bumped
|
||||
// main to 199KB merges within this headroom, so no further bump is needed.
|
||||
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 206 * 1024;
|
||||
// The opt-in `daemon/transports` browser bundle legitimately ships the concrete
|
||||
// ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so
|
||||
// it's larger than the default barrel — but still budgeted so a future PR can't
|
||||
|
|
|
|||
|
|
@ -173,7 +173,13 @@ export class DaemonSessionClient {
|
|||
readonly client: DaemonClient;
|
||||
readonly session: DaemonSession;
|
||||
readonly state: DaemonSessionState;
|
||||
readonly replaySnapshot: DaemonReplaySnapshot;
|
||||
/**
|
||||
* Not `readonly`: {@link consumeReplaySnapshot} swaps it for an empty
|
||||
* snapshot once the provider has injected it into the transcript store,
|
||||
* releasing the raw wire events (tens of MiB on busy sessions) instead of
|
||||
* retaining them for the session client's lifetime.
|
||||
*/
|
||||
replaySnapshot: DaemonReplaySnapshot;
|
||||
readonly replaySnapshotComplete: boolean;
|
||||
readonly replayPartial: boolean;
|
||||
readonly replayError: string | undefined;
|
||||
|
|
@ -407,6 +413,19 @@ export class DaemonSessionClient {
|
|||
return this.lastSeenEpoch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the retained replay snapshot and drops the client's reference
|
||||
* to it. Call once the snapshot has been injected into a transcript
|
||||
* store; the raw wire events are no longer needed (SSE continues from
|
||||
* `lastEventId`, and older history is served by pagination) and can
|
||||
* otherwise pin tens of MiB per session client.
|
||||
*/
|
||||
consumeReplaySnapshot(): DaemonReplaySnapshot {
|
||||
const snapshot = this.replaySnapshot;
|
||||
this.replaySnapshot = { compactedReplay: [], liveJournal: [] };
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
setLastEventId(lastEventId: number | undefined): void {
|
||||
this.lastSeenEventId = validateLastEventId(lastEventId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export {
|
|||
daemonBlockToPlainText,
|
||||
daemonToolPreviewToMarkdown,
|
||||
daemonUiEventToTerminalText,
|
||||
estimateDaemonTranscriptBlockBytes,
|
||||
extractContentPart,
|
||||
extractServerTimestamp,
|
||||
formatBlockTimestamp,
|
||||
|
|
@ -107,6 +108,8 @@ export {
|
|||
getSessionUpdatePayload,
|
||||
isDaemonUiSensitiveKey,
|
||||
isSubagentChildBlock,
|
||||
isTrimmedPermissionBlockId,
|
||||
isTrimmedToolBlockId,
|
||||
isUnrecognizedDiagnosticReason,
|
||||
normalizeDaemonEvent,
|
||||
redactDaemonUiSensitiveFields,
|
||||
|
|
@ -159,6 +162,7 @@ export type {
|
|||
DaemonTranscriptSidechannelState,
|
||||
DaemonTranscriptState,
|
||||
DaemonTranscriptStore,
|
||||
DaemonTranscriptTruncationDetail,
|
||||
DaemonUiAssistantDoneEvent,
|
||||
DaemonUiAuthDeviceFlowAuthorizedEvent,
|
||||
DaemonUiAuthDeviceFlowCancelledEvent,
|
||||
|
|
|
|||
|
|
@ -105,6 +105,13 @@ export function projectChatRecordsToDaemonTranscript(
|
|||
let truncated = false;
|
||||
let state = createDaemonTranscriptState({
|
||||
maxBlocks,
|
||||
// Trim-free by bytes: this offline/export projection's only documented
|
||||
// truncation knob is `maxBlocks`. The retention byte budget exists for
|
||||
// the LIVE session window; applying it here would silently evict older
|
||||
// blocks from rendered/exported transcripts whose `maxBlocks` never
|
||||
// limited them, and the truncation diagnostic would misname maxBlocks as
|
||||
// the cause.
|
||||
maxRetainedBytes: Number.POSITIVE_INFINITY,
|
||||
now: 0,
|
||||
onTruncation: (detail) => {
|
||||
truncated = true;
|
||||
|
|
|
|||
|
|
@ -13,8 +13,11 @@ export { createDaemonToolPreview } from './toolPreview.js';
|
|||
export {
|
||||
appendLocalUserTranscriptMessage,
|
||||
createDaemonTranscriptState,
|
||||
estimateDaemonTranscriptBlockBytes,
|
||||
formatBlockTimestamp,
|
||||
isSubagentChildBlock,
|
||||
isTrimmedPermissionBlockId,
|
||||
isTrimmedToolBlockId,
|
||||
rebuildDaemonTranscriptBlockIndex,
|
||||
reduceDaemonTranscriptEvents,
|
||||
selectApprovalMode,
|
||||
|
|
@ -88,6 +91,7 @@ export type {
|
|||
DaemonTranscriptSidechannelState,
|
||||
DaemonTranscriptState,
|
||||
DaemonTranscriptStore,
|
||||
DaemonTranscriptTruncationDetail,
|
||||
DaemonUnrecognizedDiagnostic,
|
||||
DaemonUnrecognizedDiagnosticReason,
|
||||
// Chat-stream events
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import type {
|
|||
} from './types.js';
|
||||
import { DAEMON_PLAN_TOOL_CALL_ID } from './types.js';
|
||||
import {
|
||||
capDetails,
|
||||
getFirstString,
|
||||
getOutputText,
|
||||
getString,
|
||||
|
|
@ -59,7 +60,6 @@ const MCP_RESTART_REFUSED_REASONS = new Set<string>([
|
|||
]);
|
||||
|
||||
const MALFORMED_MEMORY_CHANGED = 'malformed memory_changed payload';
|
||||
const MAX_DETAILS_LENGTH = 4096;
|
||||
const SESSION_RECORDING_DEGRADED_MESSAGE =
|
||||
'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then start a new session to resume recording.';
|
||||
|
||||
|
|
@ -407,7 +407,10 @@ function normalizeUnrecognizedEvent(
|
|||
...base,
|
||||
type: 'debug',
|
||||
debugReason: 'unrecognized_event',
|
||||
text: `${event.type} (unrecognized daemon event): ${stringifyRedactedJson(event.data)}`,
|
||||
text: debugBlockText(
|
||||
`${event.type} (unrecognized daemon event)`,
|
||||
event.data,
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -768,7 +771,7 @@ function normalizeSessionUpdate(
|
|||
...base,
|
||||
type: 'debug',
|
||||
debugReason: 'malformed_payload',
|
||||
text: `session_update: ${stringifyRedactedJson(event.data)}`,
|
||||
text: debugBlockText('session_update', event.data),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -981,7 +984,7 @@ function normalizeSessionUpdate(
|
|||
debugReason: kind?.trim()
|
||||
? 'unrecognized_session_update'
|
||||
: 'malformed_payload',
|
||||
text: `${kind ?? 'session_update'}: ${stringifyRedactedJson(update)}`,
|
||||
text: debugBlockText(kind ?? 'session_update', update),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -1256,9 +1259,15 @@ function asDaemonErrorKind(
|
|||
: undefined;
|
||||
}
|
||||
|
||||
function capDetails(details: string): string {
|
||||
if (details.length <= MAX_DETAILS_LENGTH) return details;
|
||||
return `${details.slice(0, MAX_DETAILS_LENGTH)}... [truncated]`;
|
||||
/**
|
||||
* Builds the `text` of a `debug` block that embeds an unrecognized or
|
||||
* malformed payload, capped at the producer. One such block is appended per
|
||||
* frame, so a high-frequency frame could otherwise accumulate 100KB blocks up
|
||||
* to the transcript block cap; capping here means a future debug branch
|
||||
* cannot drop the cap.
|
||||
*/
|
||||
function debugBlockText(prefix: string, data: unknown): string {
|
||||
return capDetails(`${prefix}: ${stringifyRedactedJson(data)}`);
|
||||
}
|
||||
|
||||
function normalizePermissionRequest(
|
||||
|
|
@ -1271,7 +1280,7 @@ function normalizePermissionRequest(
|
|||
...base,
|
||||
type: 'debug',
|
||||
debugReason: 'malformed_payload',
|
||||
text: `permission_request: ${stringifyRedactedJson(event.data)}`,
|
||||
text: debugBlockText('permission_request', event.data),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -1283,7 +1292,7 @@ function normalizePermissionRequest(
|
|||
...base,
|
||||
type: 'debug',
|
||||
debugReason: 'malformed_payload',
|
||||
text: `permission_request: ${stringifyRedactedJson(event.data)}`,
|
||||
text: debugBlockText('permission_request', event.data),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -1317,7 +1326,7 @@ function normalizePermissionResolved(
|
|||
...base,
|
||||
type: 'debug',
|
||||
debugReason: 'malformed_payload',
|
||||
text: `${event.type}: ${stringifyRedactedJson(event.data)}`,
|
||||
text: debugBlockText(event.type, event.data),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import type {
|
||||
DaemonTextDeltaMeta,
|
||||
DaemonTranscriptReducerOptions,
|
||||
DaemonTranscriptState,
|
||||
DaemonTranscriptStore,
|
||||
DaemonUiEvent,
|
||||
|
|
@ -13,14 +14,22 @@ import type {
|
|||
import {
|
||||
appendLocalUserTranscriptMessage,
|
||||
createDaemonTranscriptState,
|
||||
estimateDaemonTranscriptBlockBytes,
|
||||
rebuildDaemonTranscriptBlockIndex,
|
||||
reduceDaemonTranscriptEvents,
|
||||
} from './transcript.js';
|
||||
|
||||
export function createDaemonTranscriptStore(
|
||||
seed: Partial<DaemonTranscriptState> = {},
|
||||
seed: Partial<DaemonTranscriptState> &
|
||||
Pick<DaemonTranscriptReducerOptions, 'onTruncation'> = {},
|
||||
): DaemonTranscriptStore {
|
||||
let state = createState(seed);
|
||||
// Held in the closure (not on the state object) so `reset()` keeps the
|
||||
// listener registered across wholesale state replacements.
|
||||
const { onTruncation, ...stateSeed } = seed;
|
||||
const reducerOptions: DaemonTranscriptReducerOptions = onTruncation
|
||||
? { onTruncation }
|
||||
: {};
|
||||
let state = createState(stateSeed);
|
||||
const listeners = new Set<() => void>();
|
||||
let notifyScheduled = false;
|
||||
|
||||
|
|
@ -55,7 +64,7 @@ export function createDaemonTranscriptStore(
|
|||
dispatch(event: DaemonUiEvent | DaemonUiEvent[]) {
|
||||
const events = Array.isArray(event) ? event : [event];
|
||||
if (events.length === 0) return;
|
||||
state = reduceDaemonTranscriptEvents(state, events);
|
||||
state = reduceDaemonTranscriptEvents(state, events, reducerOptions);
|
||||
scheduleNotify();
|
||||
},
|
||||
appendLocalUserMessage(
|
||||
|
|
@ -74,12 +83,14 @@ export function createDaemonTranscriptStore(
|
|||
images,
|
||||
meta,
|
||||
files,
|
||||
...reducerOptions,
|
||||
});
|
||||
scheduleNotify();
|
||||
},
|
||||
reset(nextSeed: Partial<DaemonTranscriptState> = {}) {
|
||||
state = createState({
|
||||
maxBlocks: nextSeed.maxBlocks ?? state.maxBlocks,
|
||||
maxRetainedBytes: nextSeed.maxRetainedBytes ?? state.maxRetainedBytes,
|
||||
retainSubagentBlocks:
|
||||
nextSeed.retainSubagentBlocks ?? state.retainSubagentBlocks,
|
||||
...nextSeed,
|
||||
|
|
@ -147,10 +158,19 @@ function createState(
|
|||
return {
|
||||
...createDaemonTranscriptState({
|
||||
maxBlocks: seed.maxBlocks,
|
||||
maxRetainedBytes: seed.maxRetainedBytes,
|
||||
now: seed.now,
|
||||
}),
|
||||
...seed,
|
||||
blocks,
|
||||
// Seeded blocks (e.g. a replay snapshot handed to `reset`) must count
|
||||
// toward the retention byte budget from the start.
|
||||
retainedBytes:
|
||||
seed.retainedBytes ??
|
||||
blocks.reduce(
|
||||
(total, block) => total + estimateDaemonTranscriptBlockBytes(block),
|
||||
0,
|
||||
),
|
||||
blockIndexById: rebuildDaemonTranscriptBlockIndex(blocks),
|
||||
toolBlockByCallId: createNullIndex(seed.toolBlockByCallId),
|
||||
trimmedToolNotificationByCallId: createNullIndex(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
DaemonTranscriptQuestionOption,
|
||||
} from './types.js';
|
||||
import {
|
||||
capDetails,
|
||||
getFirstString,
|
||||
isRecord,
|
||||
isSensitiveKey,
|
||||
|
|
@ -338,7 +339,9 @@ function collectPreviewRows(
|
|||
if (isRecord(value)) continue;
|
||||
rows.push({
|
||||
label: key,
|
||||
value: isSensitiveKey(key) ? '[redacted]' : stringifyRedactedJson(value),
|
||||
value: isSensitiveKey(key)
|
||||
? '[redacted]'
|
||||
: capDetails(stringifyRedactedJson(value)),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
|
|
|
|||
|
|
@ -26,9 +26,17 @@ import {
|
|||
isUnrecognizedDiagnosticReason,
|
||||
} from './types.js';
|
||||
import { createDaemonToolPreview } from './toolPreview.js';
|
||||
import { isRecord } from './utils.js';
|
||||
import { detachString, isRecord } from './utils.js';
|
||||
|
||||
const DEFAULT_MAX_BLOCKS = 1_000;
|
||||
/**
|
||||
* Byte budget for retained transcript blocks. Blocks carry raw tool payloads
|
||||
* (up to the daemon's per-frame cap each), so the block-count window alone
|
||||
* does not bound memory; trimming evicts the oldest blocks until the running
|
||||
* estimate is back under this budget. The ceiling is therefore budget + one
|
||||
* worst-case block.
|
||||
*/
|
||||
const DEFAULT_MAX_RETAINED_BYTES = 128 * 1024 * 1024;
|
||||
/**
|
||||
* Cap for the `unrecognizedDiagnostics` sidechannel. Forward-compat noise
|
||||
* must stay inspectable without growing unboundedly in long sessions.
|
||||
|
|
@ -36,6 +44,27 @@ const DEFAULT_MAX_BLOCKS = 1_000;
|
|||
export const UNRECOGNIZED_DIAGNOSTICS_LIMIT = 50;
|
||||
const TRIMMED_TOOL_BLOCK_ID = '__trimmed_tool_block__';
|
||||
const TRIMMED_PERMISSION_BLOCK_ID = '__trimmed_permission_block__';
|
||||
|
||||
/**
|
||||
* True when a `toolBlockByCallId` entry is the trimmed-block sentinel (the
|
||||
* original block was evicted by retention trimming). Lets consumers merge a
|
||||
* pagination-resurrected real mapping without letting the stale sentinel win.
|
||||
*/
|
||||
export function isTrimmedToolBlockId(blockId: string | undefined): boolean {
|
||||
return blockId === TRIMMED_TOOL_BLOCK_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a `permissionBlockByRequestId` entry is the trimmed-block
|
||||
* sentinel (the original block was evicted by retention trimming). Lets
|
||||
* consumers merge a pagination-resurrected real mapping without letting the
|
||||
* stale sentinel win.
|
||||
*/
|
||||
export function isTrimmedPermissionBlockId(
|
||||
blockId: string | undefined,
|
||||
): boolean {
|
||||
return blockId === TRIMMED_PERMISSION_BLOCK_ID;
|
||||
}
|
||||
const MAX_TEXT_BLOCK_LENGTH = 100_000;
|
||||
const TEXT_TRUNCATED_SUFFIX = '\n[truncated]\n';
|
||||
const MAX_CLONE_DEPTH = 16;
|
||||
|
|
@ -70,6 +99,8 @@ export function createDaemonTranscriptState(
|
|||
nextOrdinal: 1,
|
||||
now: opts.now ?? Date.now(),
|
||||
maxBlocks: opts.maxBlocks ?? DEFAULT_MAX_BLOCKS,
|
||||
retainedBytes: 0,
|
||||
maxRetainedBytes: opts.maxRetainedBytes ?? DEFAULT_MAX_RETAINED_BYTES,
|
||||
retainSubagentBlocks: opts.retainSubagentBlocks ?? true,
|
||||
};
|
||||
if (opts.onTruncation) truncationCallbacks.set(state, opts.onTruncation);
|
||||
|
|
@ -281,24 +312,31 @@ function applyDaemonTranscriptEvent(
|
|||
break;
|
||||
case 'user.image.delta': {
|
||||
const block = userBlockForAttachment(next, event);
|
||||
// Measure the retained block before the write path clones it (same
|
||||
// pattern as upsertToolBlock) so merged attachments stay counted
|
||||
// against the byte budget.
|
||||
const bytesBefore = estimateBlockBytes(block);
|
||||
if (event.meta) block.meta = { ...block.meta, ...event.meta };
|
||||
block.images = [
|
||||
...(block.images ?? []),
|
||||
{ data: event.data, mimeType: event.mimeType },
|
||||
];
|
||||
next.retainedBytes += estimateBlockBytes(block) - bytesBefore;
|
||||
break;
|
||||
}
|
||||
case 'user.file.delta': {
|
||||
const block = userBlockForAttachment(next, event);
|
||||
if (event.meta) block.meta = { ...block.meta, ...event.meta };
|
||||
block.files = [
|
||||
...(block.files ?? []),
|
||||
const fileBlock = userBlockForAttachment(next, event);
|
||||
const fileBytesBefore = estimateBlockBytes(fileBlock);
|
||||
if (event.meta) fileBlock.meta = { ...fileBlock.meta, ...event.meta };
|
||||
fileBlock.files = [
|
||||
...(fileBlock.files ?? []),
|
||||
{
|
||||
name: event.name,
|
||||
mimeType: event.mimeType,
|
||||
attachmentId: event.attachmentId,
|
||||
},
|
||||
];
|
||||
next.retainedBytes += estimateBlockBytes(fileBlock) - fileBytesBefore;
|
||||
break;
|
||||
}
|
||||
case 'assistant.text.delta':
|
||||
|
|
@ -856,6 +894,15 @@ function upsertToolBlock(
|
|||
}
|
||||
return;
|
||||
}
|
||||
// Measure the block as currently retained BEFORE the write path clones it
|
||||
// (`getWritableBlockById` swaps in a COW clone, whose structure can estimate
|
||||
// slightly differently); the delta stays exact against what is actually
|
||||
// retained before and after.
|
||||
const retainedIndex =
|
||||
existingId !== undefined ? state.blockIndexById[existingId] : undefined;
|
||||
const retainedBefore =
|
||||
retainedIndex !== undefined ? state.blocks[retainedIndex] : undefined;
|
||||
const bytesBefore = retainedBefore ? estimateBlockBytes(retainedBefore) : 0;
|
||||
const existing = getWritableBlockById(state, existingId);
|
||||
if (existing?.kind === 'tool') {
|
||||
if (event.title !== undefined) existing.title = event.title;
|
||||
|
|
@ -948,6 +995,7 @@ function upsertToolBlock(
|
|||
if (event.subagentType && !existing.subagentType) {
|
||||
existing.subagentType = event.subagentType;
|
||||
}
|
||||
state.retainedBytes += estimateBlockBytes(existing) - bytesBefore;
|
||||
updateCurrentToolPointer(state, event.toolCallId, event.status);
|
||||
return;
|
||||
}
|
||||
|
|
@ -1032,8 +1080,17 @@ function discardToolBlock(
|
|||
): void {
|
||||
const blockId = state.toolBlockByCallId[toolCallId];
|
||||
if (!blockId || blockId === TRIMMED_TOOL_BLOCK_ID) return;
|
||||
const droppedIndex = state.blockIndexById[blockId];
|
||||
const dropped =
|
||||
droppedIndex !== undefined ? state.blocks[droppedIndex] : undefined;
|
||||
takeBlocksOwnership(state);
|
||||
state.blocks = state.blocks.filter((block) => block.id !== blockId);
|
||||
if (dropped) {
|
||||
state.retainedBytes = Math.max(
|
||||
0,
|
||||
state.retainedBytes - estimateBlockBytes(dropped),
|
||||
);
|
||||
}
|
||||
state.blockIndexById = rebuildDaemonTranscriptBlockIndex(state.blocks);
|
||||
ownedBlocks.set(state, state.blocks);
|
||||
ownedBlockIndexes.set(state, state.blockIndexById);
|
||||
|
|
@ -1488,6 +1545,7 @@ function cloneTranscriptState(
|
|||
...state,
|
||||
now: opts.now ?? Date.now(),
|
||||
maxBlocks: opts.maxBlocks ?? state.maxBlocks,
|
||||
maxRetainedBytes: opts.maxRetainedBytes ?? state.maxRetainedBytes,
|
||||
retainSubagentBlocks:
|
||||
opts.retainSubagentBlocks ?? state.retainSubagentBlocks,
|
||||
// Lazy copy-on-write for
|
||||
|
|
@ -1541,12 +1599,92 @@ function cloneTranscriptState(
|
|||
return next;
|
||||
}
|
||||
|
||||
function sharesSourceRecordId(
|
||||
a: DaemonTranscriptBlock,
|
||||
b: DaemonTranscriptBlock,
|
||||
): boolean {
|
||||
const aIds = a.sourceRecordIds;
|
||||
const bIds = b.sourceRecordIds;
|
||||
if (!aIds?.length || !bIds?.length) return false;
|
||||
const set = new Set(aIds);
|
||||
return bIds.some((recordId) => set.has(recordId));
|
||||
}
|
||||
|
||||
function trimTranscriptState(
|
||||
state: DaemonTranscriptState,
|
||||
): DaemonTranscriptState {
|
||||
if (state.blocks.length <= state.maxBlocks) return state;
|
||||
truncationCallbacks.get(state)?.({ kind: 'blocks' });
|
||||
const blocks = state.blocks.slice(-state.maxBlocks);
|
||||
const overByteBudget = state.retainedBytes > state.maxRetainedBytes;
|
||||
if (state.blocks.length <= state.maxBlocks && !overByteBudget) return state;
|
||||
// Count-based floor: keep at most the last `maxBlocks` blocks. Keep at least
|
||||
// one block: a non-positive, non-finite, or fractional maxBlocks is a
|
||||
// degenerate input that must not evict the whole window nor leave removeCount
|
||||
// fractional (the record snap below indexes blocks[removeCount] and would
|
||||
// read one past the end of the block array).
|
||||
const effectiveMaxBlocks = Math.max(
|
||||
1,
|
||||
Math.floor(Number.isFinite(state.maxBlocks) ? state.maxBlocks : 1),
|
||||
);
|
||||
let removeCount = Math.max(0, state.blocks.length - effectiveMaxBlocks);
|
||||
let bytes = state.retainedBytes;
|
||||
for (let i = 0; i < removeCount; i += 1) {
|
||||
bytes -= estimateBlockBytes(state.blocks[i]!);
|
||||
}
|
||||
// Byte budget: keep evicting oldest blocks until the retained estimate is
|
||||
// back under the budget. The last block always survives, so the ceiling is
|
||||
// budget + one worst-case block rather than strictly the budget.
|
||||
while (
|
||||
removeCount < state.blocks.length - 1 &&
|
||||
bytes > state.maxRetainedBytes
|
||||
) {
|
||||
bytes -= estimateBlockBytes(state.blocks[removeCount]!);
|
||||
removeCount += 1;
|
||||
}
|
||||
// Snap the cut to record boundaries: one persisted record fans out into
|
||||
// several blocks sharing a sourceRecordIds entry, and trimming is
|
||||
// block-granular, so the boundary can land mid-record. A partially evicted
|
||||
// record is unrecoverable from both directions — exclusive-before
|
||||
// pagination anchored at the shared record never returns the evicted
|
||||
// sibling blocks, and the recordId dedup filter drops any later page that
|
||||
// still advertises the recordId. Advance the cut until it no longer lands
|
||||
// inside a record, keeping the at-least-one-block floor.
|
||||
while (
|
||||
removeCount > 0 &&
|
||||
removeCount < state.blocks.length - 1 &&
|
||||
sharesSourceRecordId(
|
||||
state.blocks[removeCount - 1]!,
|
||||
state.blocks[removeCount]!,
|
||||
)
|
||||
) {
|
||||
bytes -= estimateBlockBytes(state.blocks[removeCount]!);
|
||||
removeCount += 1;
|
||||
}
|
||||
// The forward snap can be pinned by the floor: the byte loop evicts down to
|
||||
// the last block and the snap's `removeCount < len - 1` bound stops there even
|
||||
// when the evicted tail shares a record with the surviving block — a
|
||||
// mid-record cut the snap detects but cannot fix by advancing. Back the cut
|
||||
// off the floor instead, re-retaining siblings while the boundary pair still
|
||||
// shares a record so the record stays whole. A single record can fan out into
|
||||
// several contiguous blocks, so this loops; when nothing is left to evict the
|
||||
// `removeCount === 0` guard below keeps the whole window rather than cutting
|
||||
// mid-record. This trades at most one extra retained record against the
|
||||
// budget, extending the "budget + one worst-case block" ceiling the byte loop
|
||||
// above already documents.
|
||||
while (
|
||||
removeCount > 0 &&
|
||||
sharesSourceRecordId(
|
||||
state.blocks[removeCount - 1]!,
|
||||
state.blocks[removeCount]!,
|
||||
)
|
||||
) {
|
||||
removeCount -= 1;
|
||||
bytes += estimateBlockBytes(state.blocks[removeCount]!);
|
||||
}
|
||||
// Nothing evictable (e.g. one oversized block): skip the callback and
|
||||
// rebuild. Firing `kind: 'blocks'` with zero removals records a false
|
||||
// truncation and churns snapshot identity on every dispatch.
|
||||
if (removeCount === 0) return state;
|
||||
state.retainedBytes = Math.max(0, bytes);
|
||||
const blocks = state.blocks.slice(removeCount);
|
||||
const keptIds = new Set(blocks.map((block) => block.id));
|
||||
state.blocks = blocks;
|
||||
state.blockIndexById = rebuildDaemonTranscriptBlockIndex(blocks);
|
||||
|
|
@ -1614,6 +1752,21 @@ function trimTranscriptState(
|
|||
delete state.activeThoughtBlockByParent[parentId];
|
||||
}
|
||||
}
|
||||
// Fired after the mutation completes so listeners observe the post-trim
|
||||
// window (e.g. re-anchoring an exclusive pagination anchor to the oldest
|
||||
// retained record — the evicted anchor can never be re-fetched).
|
||||
const oldestRetainedBlock = state.blocks.find(
|
||||
(block) => (block.sourceRecordIds?.length ?? 0) > 0,
|
||||
);
|
||||
truncationCallbacks.get(state)?.({
|
||||
kind: 'blocks',
|
||||
oldestRetainedRecordId: oldestRetainedBlock?.sourceRecordIds?.[0],
|
||||
evictedOldest: true,
|
||||
blockCount: state.blocks.length,
|
||||
retainedBytes: state.retainedBytes,
|
||||
maxBlocks: state.maxBlocks,
|
||||
maxRetainedBytes: state.maxRetainedBytes,
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
|
|
@ -1692,10 +1845,36 @@ function truncateTranscriptBeforeBlock(
|
|||
blockIndex: number,
|
||||
): void {
|
||||
takeBlocksOwnership(state);
|
||||
const originalLength = state.blocks.length;
|
||||
let droppedBytes = 0;
|
||||
for (let index = blockIndex; index < state.blocks.length; index += 1) {
|
||||
const block = state.blocks[index];
|
||||
if (block) droppedBytes += estimateBlockBytes(block);
|
||||
}
|
||||
state.blocks = state.blocks.slice(0, blockIndex);
|
||||
state.retainedBytes = Math.max(0, state.retainedBytes - droppedBytes);
|
||||
ownedBlocks.set(state, state.blocks);
|
||||
rebuildTranscriptIndexes(state);
|
||||
ownedBlockIndexes.set(state, state.blockIndexById);
|
||||
if (state.blocks.length < originalLength) {
|
||||
// A rewind frees retention capacity just like an eviction does. Fire the
|
||||
// same 'blocks' truncation signal so consumers reconciling a pagination
|
||||
// capacity latch on freed capacity observe rewinds too. `evictedOldest`
|
||||
// is false: a rewind drops the NEWEST blocks, so the oldest pagination
|
||||
// anchor stays valid and must not be re-anchored.
|
||||
const oldestRetainedBlock = state.blocks.find(
|
||||
(block) => (block.sourceRecordIds?.length ?? 0) > 0,
|
||||
);
|
||||
truncationCallbacks.get(state)?.({
|
||||
kind: 'blocks',
|
||||
oldestRetainedRecordId: oldestRetainedBlock?.sourceRecordIds?.[0],
|
||||
evictedOldest: false,
|
||||
blockCount: state.blocks.length,
|
||||
retainedBytes: state.retainedBytes,
|
||||
maxBlocks: state.maxBlocks,
|
||||
maxRetainedBytes: state.maxRetainedBytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildTranscriptIndexes(state: DaemonTranscriptState): void {
|
||||
|
|
@ -1737,6 +1916,7 @@ function appendBlock(
|
|||
(state.blockIndexById as Record<string, number>)[block.id] =
|
||||
state.blocks.length;
|
||||
(state.blocks as DaemonTranscriptBlock[]).push(block);
|
||||
state.retainedBytes += estimateBlockBytes(block);
|
||||
}
|
||||
|
||||
function getWritableBlockById(
|
||||
|
|
@ -1805,11 +1985,20 @@ function appendBoundedText(
|
|||
text: string,
|
||||
): string {
|
||||
const existing = 'text' in block ? block.text : '';
|
||||
let next: string;
|
||||
if (existing.length >= MAX_TEXT_BLOCK_LENGTH) {
|
||||
if (text) reportTextTruncation(state, block.id, block.sourceRecordIds);
|
||||
return existing;
|
||||
next = existing;
|
||||
} else {
|
||||
next = truncateText(
|
||||
state,
|
||||
block.id,
|
||||
block.sourceRecordIds,
|
||||
existing + text,
|
||||
);
|
||||
}
|
||||
return truncateText(state, block.id, block.sourceRecordIds, existing + text);
|
||||
state.retainedBytes += (next.length - existing.length) * 2;
|
||||
return next;
|
||||
}
|
||||
|
||||
function truncateTextAtLimit(text: string): string {
|
||||
|
|
@ -1818,7 +2007,9 @@ function truncateTextAtLimit(text: string): string {
|
|||
0,
|
||||
MAX_TEXT_BLOCK_LENGTH - TEXT_TRUNCATED_SUFFIX.length,
|
||||
);
|
||||
return `${text.slice(0, keepLength)}${TEXT_TRUNCATED_SUFFIX}`;
|
||||
// detach: a bare slice would keep the oversized parent string's backing
|
||||
// store alive for as long as the block is retained.
|
||||
return `${detachString(text.slice(0, keepLength))}${TEXT_TRUNCATED_SUFFIX}`;
|
||||
}
|
||||
|
||||
function truncateText(
|
||||
|
|
@ -1844,6 +2035,49 @@ function reportTextTruncation(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap structural size estimate of a retained value (bytes). Strings count
|
||||
* as 2 bytes per UTF-16 code unit; records/arrays add a small per-entry
|
||||
* overhead. Deliberately approximate: it drives the retention byte budget,
|
||||
* not billing. The walk is bounded by the same depth cap as cloning.
|
||||
*/
|
||||
function estimateRetainedBytes(value: unknown, depth = 0): number {
|
||||
if (depth > MAX_CLONE_DEPTH) return 0;
|
||||
if (typeof value === 'string') return value.length * 2;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return 16;
|
||||
// Binary payloads (Blob/File, ArrayBuffer, typed-array/DataView views) carry
|
||||
// their content in non-enumerable internal slots, so the record walk below
|
||||
// would only charge the fixed object overhead for them. Charge by real binary
|
||||
// size instead, or media-heavy transcripts never trip the budget — the OOM
|
||||
// class this budget exists to stop.
|
||||
if (typeof Blob !== 'undefined' && value instanceof Blob) return value.size;
|
||||
if (value instanceof ArrayBuffer) return value.byteLength;
|
||||
if (ArrayBuffer.isView(value)) return value.byteLength;
|
||||
if (Array.isArray(value)) {
|
||||
let total = 32;
|
||||
for (const entry of value) {
|
||||
total += estimateRetainedBytes(entry, depth + 1);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
let total = 64;
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
total += key.length * 2 + estimateRetainedBytes(entry, depth + 1);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function estimateDaemonTranscriptBlockBytes(
|
||||
block: DaemonTranscriptBlock,
|
||||
): number {
|
||||
return estimateRetainedBytes(block);
|
||||
}
|
||||
|
||||
const estimateBlockBytes = estimateDaemonTranscriptBlockBytes;
|
||||
|
||||
function createIndex<T>(
|
||||
source?: Readonly<Record<string, T>>,
|
||||
): Record<string, T> {
|
||||
|
|
|
|||
|
|
@ -1136,10 +1136,18 @@ export interface DaemonTranscriptState
|
|||
now: number;
|
||||
maxBlocks: number;
|
||||
retainSubagentBlocks: boolean;
|
||||
/**
|
||||
* Running estimate (bytes) of what `blocks` retains. Blocks carry raw tool
|
||||
* payloads, so a block-count cap alone is not a memory ceiling; trimming
|
||||
* also evicts until the estimate is back under `maxRetainedBytes`.
|
||||
*/
|
||||
retainedBytes: number;
|
||||
maxRetainedBytes: number;
|
||||
}
|
||||
|
||||
export interface DaemonTranscriptReducerOptions {
|
||||
maxBlocks?: number;
|
||||
maxRetainedBytes?: number;
|
||||
now?: number;
|
||||
retainSubagentBlocks?: boolean;
|
||||
onTruncation?: (detail: DaemonTranscriptTruncationDetail) => void;
|
||||
|
|
@ -1149,6 +1157,30 @@ export interface DaemonTranscriptTruncationDetail {
|
|||
kind: 'blocks' | 'text';
|
||||
blockId?: string;
|
||||
sourceRecordIds?: readonly string[];
|
||||
/**
|
||||
* Set for `kind: 'blocks'`: the oldest recordId still retained after the
|
||||
* eviction, from the oldest retained block that carries one. Undefined when
|
||||
* no retained block carries a recordId. Lets consumers reconcile exclusive
|
||||
* pagination anchors with retention trimming.
|
||||
*/
|
||||
oldestRetainedRecordId?: string;
|
||||
/**
|
||||
* Set for `kind: 'blocks'`: whether the eviction removed blocks from the
|
||||
* OLDEST end. True for retention trimming (oldest-first), which can evict
|
||||
* the record an exclusive pagination anchor points at; false for a rewind
|
||||
* (which drops the newest blocks and leaves the oldest anchor intact).
|
||||
* Consumers should only re-anchor pagination when this is true.
|
||||
*/
|
||||
evictedOldest?: boolean;
|
||||
/**
|
||||
* Set for `kind: 'blocks'`: the post-trim window occupancy. Lets consumers
|
||||
* decide whether a previously rejected history page would now be admitted,
|
||||
* without reading a snapshot that may lag the in-flight dispatch.
|
||||
*/
|
||||
blockCount?: number;
|
||||
retainedBytes?: number;
|
||||
maxBlocks?: number;
|
||||
maxRetainedBytes?: number;
|
||||
}
|
||||
|
||||
export interface DaemonTranscriptStore {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,28 @@ export function stringifyRedactedJson(value: unknown): string {
|
|||
return stringifyJson(redactSensitiveFields(value));
|
||||
}
|
||||
|
||||
const MAX_DETAILS_LENGTH = 4096;
|
||||
|
||||
/**
|
||||
* Returns a copy of `value` that does not reference the input's backing
|
||||
* storage. Engines such as V8 represent slices of large strings as views
|
||||
* (SlicedString) that keep the parent alive, so a capped string retained on a
|
||||
* transcript block would otherwise pin the entire uncapped payload and defeat
|
||||
* the cap. The UTF-8 round-trip forces an independent string.
|
||||
*/
|
||||
export function detachString(value: string): string {
|
||||
return new TextDecoder('utf-8').decode(new TextEncoder().encode(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps a rendered details string so a single unbounded payload cannot grow a
|
||||
* transcript block without limit.
|
||||
*/
|
||||
export function capDetails(details: string): string {
|
||||
if (details.length <= MAX_DETAILS_LENGTH) return details;
|
||||
return `${detachString(details.slice(0, MAX_DETAILS_LENGTH))}... [truncated]`;
|
||||
}
|
||||
|
||||
export function redactSensitiveFields(value: unknown, depth = 0): unknown {
|
||||
if (depth > 16) return '[truncated]';
|
||||
if (Array.isArray(value)) {
|
||||
|
|
|
|||
|
|
@ -390,6 +390,37 @@ describe('DaemonSessionClient', () => {
|
|||
expect(calls[1]?.headers['last-event-id']).toBe('42');
|
||||
});
|
||||
|
||||
it('releases the replay snapshot once consumed', async () => {
|
||||
const { fetch } = recordingFetch((req) => {
|
||||
if (req.url.endsWith('/session/s-1/load')) {
|
||||
return jsonResponse(200, {
|
||||
sessionId: 's-1',
|
||||
workspaceCwd: '/work/a',
|
||||
attached: true,
|
||||
clientId: 'client-1',
|
||||
state: {},
|
||||
lastEventId: 7,
|
||||
compactedReplay: [{ id: 1, v: 1, type: 'session_update', data: {} }],
|
||||
liveJournal: [{ id: 7, v: 1, type: 'session_update', data: {} }],
|
||||
});
|
||||
}
|
||||
return jsonResponse(500, { error: `unexpected ${req.url}` });
|
||||
});
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
|
||||
const session = await DaemonSessionClient.load(client, 's-1');
|
||||
expect(session.replaySnapshot.compactedReplay).toHaveLength(1);
|
||||
expect(session.replaySnapshot.liveJournal).toHaveLength(1);
|
||||
|
||||
const consumed = session.consumeReplaySnapshot();
|
||||
expect(consumed.compactedReplay).toHaveLength(1);
|
||||
expect(consumed.liveJournal).toHaveLength(1);
|
||||
expect(session.replaySnapshot.compactedReplay).toHaveLength(0);
|
||||
expect(session.replaySnapshot.liveJournal).toHaveLength(0);
|
||||
// Idempotent: a second consume returns the empty snapshot.
|
||||
expect(session.consumeReplaySnapshot().compactedReplay).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('hydrates replay images but leaves file attachments lazy', async () => {
|
||||
const { fetch, calls } = recordingFetch((req) => {
|
||||
if (req.url.endsWith('/session/s-1/load')) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
createDaemonTranscriptState,
|
||||
createDaemonTranscriptStore,
|
||||
daemonUiEventToTerminalText,
|
||||
estimateDaemonTranscriptBlockBytes,
|
||||
getOutputText,
|
||||
isDaemonUiSensitiveKey,
|
||||
normalizeDaemonEvent,
|
||||
|
|
@ -1208,6 +1209,511 @@ describe('daemon UI normalizer and transcript reducer', () => {
|
|||
expect(Object.keys(state.toolBlockByCallId)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('evicts oldest blocks to stay under the retention byte budget', () => {
|
||||
// The block-count window is not a memory ceiling: blocks carry raw tool
|
||||
// payloads. With a generous block window but a tight byte budget, heavy
|
||||
// blocks must still be evicted oldest-first.
|
||||
const large = 'x'.repeat(20_000);
|
||||
let state = createDaemonTranscriptState({
|
||||
maxBlocks: 100,
|
||||
maxRetainedBytes: 90_000,
|
||||
now: 1,
|
||||
});
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
state = reduceDaemonTranscriptEvents(
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: 'tool.update',
|
||||
toolCallId: `tool-${index}`,
|
||||
title: `Tool ${index}`,
|
||||
status: 'completed',
|
||||
rawOutput: large,
|
||||
},
|
||||
],
|
||||
{ now: index + 2 },
|
||||
);
|
||||
}
|
||||
|
||||
expect(state.blocks.length).toBeGreaterThan(0);
|
||||
expect(state.blocks.length).toBeLessThan(4);
|
||||
expect(state.retainedBytes).toBeLessThanOrEqual(90_000);
|
||||
// Most recent blocks survive eviction.
|
||||
const keptToolCallIds = state.blocks.map(
|
||||
(block) => (block as { toolCallId?: string }).toolCallId,
|
||||
);
|
||||
expect(keptToolCallIds).toContain('tool-3');
|
||||
expect(keptToolCallIds).not.toContain('tool-0');
|
||||
// The running estimate matches the blocks actually retained.
|
||||
const expected = state.blocks.reduce(
|
||||
(total, block) => total + estimateDaemonTranscriptBlockBytes(block),
|
||||
0,
|
||||
);
|
||||
expect(state.retainedBytes).toBe(expected);
|
||||
});
|
||||
|
||||
it('backs the record-boundary snap off the floor instead of cutting mid-record (R12-21)', () => {
|
||||
// One persisted record can fan out into several blocks. When byte pressure
|
||||
// evicts down to the last block, the forward snap's at-least-one-block
|
||||
// floor stops it from advancing — even when the evicted penultimate block
|
||||
// shares the record with the survivor, which would ship a mid-record cut.
|
||||
// The snap must back off the floor and keep the record whole instead.
|
||||
const large = 'x'.repeat(100_000);
|
||||
let state = createDaemonTranscriptState({
|
||||
maxBlocks: 100,
|
||||
// Fits one ~100 KB block but not both, so the byte loop evicts to the
|
||||
// floor (one block left) and the snap is pinned there.
|
||||
maxRetainedBytes: 150_000,
|
||||
now: 1,
|
||||
});
|
||||
for (const toolCallId of ['tool-a', 'tool-b']) {
|
||||
state = reduceDaemonTranscriptEvents(
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: 'tool.update',
|
||||
toolCallId,
|
||||
title: `Tool ${toolCallId}`,
|
||||
status: 'completed',
|
||||
rawOutput: large,
|
||||
sourceRecordIds: ['record-x'],
|
||||
},
|
||||
],
|
||||
{ now: 2 },
|
||||
);
|
||||
}
|
||||
|
||||
// Both siblings of record-x stay: evicting only tool-a would leave a
|
||||
// mid-record cut that exclusive-before pagination can never re-fetch.
|
||||
const keptToolCallIds = state.blocks.map(
|
||||
(block) => (block as { toolCallId?: string }).toolCallId,
|
||||
);
|
||||
expect(keptToolCallIds).toEqual(['tool-a', 'tool-b']);
|
||||
expect(state.blocks).toHaveLength(2);
|
||||
for (const block of state.blocks) {
|
||||
expect(block.sourceRecordIds).toContain('record-x');
|
||||
}
|
||||
});
|
||||
|
||||
it('backs the record-boundary snap off the floor across a 3-block record (R12-21)', () => {
|
||||
// A single record fans out into 3+ contiguous blocks; the floor back-off
|
||||
// must loop (not stop after one block) or the tail is still cut mid-record.
|
||||
const large = 'x'.repeat(100_000);
|
||||
let state = createDaemonTranscriptState({
|
||||
maxBlocks: 100,
|
||||
maxRetainedBytes: 150_000,
|
||||
now: 1,
|
||||
});
|
||||
for (const toolCallId of ['tool-a', 'tool-b', 'tool-c']) {
|
||||
state = reduceDaemonTranscriptEvents(
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: 'tool.update',
|
||||
toolCallId,
|
||||
title: `Tool ${toolCallId}`,
|
||||
status: 'completed',
|
||||
rawOutput: large,
|
||||
sourceRecordIds: ['record-x'],
|
||||
},
|
||||
],
|
||||
{ now: 2 },
|
||||
);
|
||||
}
|
||||
// All three record-x siblings stay — the loop re-retains tool-a and tool-b
|
||||
// after the byte loop pins the cut to the floor.
|
||||
expect(
|
||||
state.blocks.map(
|
||||
(block) => (block as { toolCallId?: string }).toolCallId,
|
||||
),
|
||||
).toEqual(['tool-a', 'tool-b', 'tool-c']);
|
||||
});
|
||||
|
||||
it('counts Blob-backed file payloads against the retention budget', () => {
|
||||
// Blob/File payloads live in non-enumerable internal slots, so a plain
|
||||
// record walk would only charge the fixed object overhead — an 8 MiB file
|
||||
// would count as ~64 bytes and the byte budget would never fire. Charge
|
||||
// their real size instead.
|
||||
const eightMiB = new Blob([new Uint8Array(8 * 1024 * 1024)]);
|
||||
let state = createDaemonTranscriptState({
|
||||
maxBlocks: 100,
|
||||
maxRetainedBytes: 100_000_000,
|
||||
now: 1,
|
||||
});
|
||||
state = appendLocalUserTranscriptMessage(state, '', {
|
||||
files: [
|
||||
{
|
||||
name: 'big.bin',
|
||||
mimeType: 'application/octet-stream',
|
||||
data: eightMiB,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(state.retainedBytes).toBeGreaterThanOrEqual(8 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('evicts Blob-backed file blocks that cross the retention budget', () => {
|
||||
const fourMiB = new Blob([new Uint8Array(4 * 1024 * 1024)]);
|
||||
let state = createDaemonTranscriptState({
|
||||
maxBlocks: 100,
|
||||
maxRetainedBytes: 9 * 1024 * 1024,
|
||||
now: 1,
|
||||
});
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
state = appendLocalUserTranscriptMessage(state, `attach ${index}`, {
|
||||
files: [
|
||||
{
|
||||
name: `f${index}.bin`,
|
||||
mimeType: 'application/octet-stream',
|
||||
data: fourMiB,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
// 4 × 4 MiB = 16 MiB > 9 MiB budget: the byte trim must fire only because
|
||||
// the Blob payloads are counted. Without it, retainedBytes would stay at
|
||||
// the fixed record overhead and no block would be evicted.
|
||||
expect(state.blocks.length).toBeLessThan(4);
|
||||
expect(state.retainedBytes).toBeGreaterThan(4 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('tolerates a degenerate maxBlocks without crashing the trim (R14-1)', () => {
|
||||
// maxBlocks is a public option with no validated lower bound. A
|
||||
// non-positive value must not evict down to zero blocks — the record snap
|
||||
// would then read one past the end of the block array and throw on every
|
||||
// dispatch. Keep at least one block instead.
|
||||
const store = createDaemonTranscriptStore({ maxBlocks: 0 });
|
||||
expect(() =>
|
||||
store.dispatch({ type: 'user.text.delta', text: 'survives' }),
|
||||
).not.toThrow();
|
||||
expect(store.getSnapshot().blocks).toHaveLength(1);
|
||||
// A second dispatch must not throw either (the crash repeated on every
|
||||
// dispatch before the floor clamp).
|
||||
expect(() =>
|
||||
store.dispatch({ type: 'user.text.delta', text: 'still alive' }),
|
||||
).not.toThrow();
|
||||
expect(store.getSnapshot().blocks.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// A positive fractional maxBlocks must also be integral-ized (R15-1):
|
||||
// without the floor, removeCount went fractional and the record snap read a
|
||||
// fractional index one past the end, throwing on later dispatches.
|
||||
const fractionalStore = createDaemonTranscriptStore({ maxBlocks: 2.5 });
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
expect(() =>
|
||||
fractionalStore.dispatch({
|
||||
type: 'user.text.delta',
|
||||
text: `delta ${index}`,
|
||||
}),
|
||||
).not.toThrow();
|
||||
}
|
||||
expect(fractionalStore.getSnapshot().blocks.length).toBeGreaterThanOrEqual(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the retention estimate current when a tool payload is replaced', () => {
|
||||
let state = createDaemonTranscriptState({
|
||||
maxBlocks: 10,
|
||||
maxRetainedBytes: 10_000_000,
|
||||
now: 1,
|
||||
});
|
||||
state = reduceDaemonTranscriptEvents(
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: 'tool.update',
|
||||
toolCallId: 'tool-a',
|
||||
title: 'Tool',
|
||||
status: 'running',
|
||||
rawOutput: 'small',
|
||||
},
|
||||
],
|
||||
{ now: 2 },
|
||||
);
|
||||
const before = state.retainedBytes;
|
||||
expect(before).toBeGreaterThan(0);
|
||||
|
||||
state = reduceDaemonTranscriptEvents(
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: 'tool.update',
|
||||
toolCallId: 'tool-a',
|
||||
status: 'completed',
|
||||
rawOutput: 'y'.repeat(50_000),
|
||||
},
|
||||
],
|
||||
{ now: 3 },
|
||||
);
|
||||
|
||||
expect(state.blocks).toHaveLength(1);
|
||||
expect(state.retainedBytes).toBeGreaterThan(before);
|
||||
expect(state.retainedBytes).toBe(
|
||||
estimateDaemonTranscriptBlockBytes(state.blocks[0]!),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies the default retention budget when none is configured', () => {
|
||||
// The eviction comparison degrades to "never fires" if the creation-time
|
||||
// default is dropped, so pin the exact value for callers that rely on it.
|
||||
expect(createDaemonTranscriptState().maxRetainedBytes).toBe(
|
||||
128 * 1024 * 1024,
|
||||
);
|
||||
expect(createDaemonTranscriptStore().getSnapshot().maxRetainedBytes).toBe(
|
||||
128 * 1024 * 1024,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a configured retention budget across store reset', () => {
|
||||
// reset() carries maxBlocks and retainSubagentBlocks forward from the
|
||||
// current state; maxRetainedBytes must behave the same or a custom
|
||||
// budget silently reverts to the default on every replay/session switch.
|
||||
const store = createDaemonTranscriptStore({ maxRetainedBytes: 5_000_000 });
|
||||
expect(store.getSnapshot().maxRetainedBytes).toBe(5_000_000);
|
||||
store.reset();
|
||||
expect(store.getSnapshot().maxRetainedBytes).toBe(5_000_000);
|
||||
});
|
||||
|
||||
it('keeps a seeded truncation listener across dispatches and resets', () => {
|
||||
// Store consumers (e.g. the session provider reconciling its pagination
|
||||
// anchor with eviction) register onTruncation through the seed; the
|
||||
// listener must fire on evictions and survive reset(), which replaces
|
||||
// the state wholesale.
|
||||
const onTruncation = vi.fn();
|
||||
const store = createDaemonTranscriptStore({ maxBlocks: 2, onTruncation });
|
||||
const toolEvent = (index: number) => ({
|
||||
type: 'tool.update' as const,
|
||||
toolCallId: `tool-${index}`,
|
||||
title: `Tool ${index}`,
|
||||
status: 'completed' as const,
|
||||
sourceRecordIds: [`record-${index}`],
|
||||
});
|
||||
store.dispatch([toolEvent(0), toolEvent(1)]);
|
||||
expect(onTruncation).not.toHaveBeenCalled();
|
||||
store.dispatch(toolEvent(2));
|
||||
expect(onTruncation).toHaveBeenCalledTimes(1);
|
||||
expect(onTruncation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'blocks',
|
||||
oldestRetainedRecordId: 'record-1',
|
||||
}),
|
||||
);
|
||||
|
||||
onTruncation.mockClear();
|
||||
store.reset({ maxBlocks: 2 });
|
||||
store.dispatch([toolEvent(3), toolEvent(4)]);
|
||||
expect(onTruncation).not.toHaveBeenCalled();
|
||||
store.dispatch(toolEvent(5));
|
||||
expect(onTruncation).toHaveBeenCalledTimes(1);
|
||||
expect(onTruncation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'blocks',
|
||||
oldestRetainedRecordId: 'record-4',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('counts every image merged into a user block against the retention budget', () => {
|
||||
const data = 'I'.repeat(100_000);
|
||||
let state = createDaemonTranscriptState({
|
||||
maxBlocks: 10,
|
||||
maxRetainedBytes: 10_000_000,
|
||||
now: 1,
|
||||
});
|
||||
state = reduceDaemonTranscriptEvents(
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: 'user.image.delta',
|
||||
data,
|
||||
mimeType: 'image/png',
|
||||
sourceRecordIds: ['record-1'],
|
||||
},
|
||||
{
|
||||
type: 'user.image.delta',
|
||||
data,
|
||||
mimeType: 'image/png',
|
||||
sourceRecordIds: ['record-1'],
|
||||
},
|
||||
],
|
||||
{ now: 2 },
|
||||
);
|
||||
|
||||
expect(state.blocks).toHaveLength(1);
|
||||
const block = state.blocks[0]!;
|
||||
expect((block as { images?: unknown[] }).images).toHaveLength(2);
|
||||
expect(state.retainedBytes).toBe(estimateDaemonTranscriptBlockBytes(block));
|
||||
});
|
||||
|
||||
it('releases the retention budget when a rewind drops blocks', () => {
|
||||
const large = 'x'.repeat(100_000);
|
||||
let state = createDaemonTranscriptState({
|
||||
maxBlocks: 100,
|
||||
maxRetainedBytes: 1_000_000,
|
||||
now: 1,
|
||||
});
|
||||
state = reduceDaemonTranscriptEvents(
|
||||
state,
|
||||
[{ type: 'user.text.delta', text: 'turn zero' }],
|
||||
{ now: 2 },
|
||||
);
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
state = reduceDaemonTranscriptEvents(
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: 'tool.update',
|
||||
toolCallId: `tool-${index}`,
|
||||
title: `Tool ${index}`,
|
||||
status: 'completed',
|
||||
rawOutput: large,
|
||||
},
|
||||
],
|
||||
{ now: index + 3 },
|
||||
);
|
||||
}
|
||||
expect(state.blocks).toHaveLength(4);
|
||||
expect(state.retainedBytes).toBeGreaterThan(0);
|
||||
|
||||
state = reduceDaemonTranscriptEvents(
|
||||
state,
|
||||
[{ type: 'session.rewound', promptId: 'prompt-1', targetTurnIndex: 0 }],
|
||||
{ now: 9 },
|
||||
);
|
||||
expect(state.blocks).toHaveLength(0);
|
||||
expect(state.retainedBytes).toBe(0);
|
||||
|
||||
// Ghost bytes from the dropped blocks must not collapse later growth:
|
||||
// with an inflated counter every dispatch looks over budget and evicts
|
||||
// the freshly appended blocks down to the last survivor.
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
state = reduceDaemonTranscriptEvents(
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: 'tool.update',
|
||||
toolCallId: `post-${index}`,
|
||||
title: `Post ${index}`,
|
||||
status: 'completed',
|
||||
rawOutput: large,
|
||||
},
|
||||
],
|
||||
{ now: index + 10 },
|
||||
);
|
||||
}
|
||||
expect(
|
||||
state.blocks.map(
|
||||
(block) => (block as { toolCallId?: string }).toolCallId,
|
||||
),
|
||||
).toEqual(['post-0', 'post-1', 'post-2']);
|
||||
const expected = state.blocks.reduce(
|
||||
(total, block) => total + estimateDaemonTranscriptBlockBytes(block),
|
||||
0,
|
||||
);
|
||||
expect(state.retainedBytes).toBe(expected);
|
||||
});
|
||||
|
||||
it('releases the retention budget when a subagent tool block is discarded', () => {
|
||||
let state = createDaemonTranscriptState({
|
||||
maxBlocks: 10,
|
||||
maxRetainedBytes: 10_000_000,
|
||||
retainSubagentBlocks: false,
|
||||
now: 1,
|
||||
});
|
||||
state = reduceDaemonTranscriptEvents(
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: 'tool.update',
|
||||
toolCallId: 'sub-1',
|
||||
title: 'Subagent',
|
||||
status: 'running',
|
||||
rawOutput: 'y'.repeat(50_000),
|
||||
},
|
||||
],
|
||||
{ now: 2 },
|
||||
);
|
||||
expect(state.blocks).toHaveLength(1);
|
||||
expect(state.retainedBytes).toBeGreaterThan(0);
|
||||
|
||||
state = reduceDaemonTranscriptEvents(
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: 'tool.update',
|
||||
toolCallId: 'sub-1',
|
||||
parentToolCallId: 'parent-1',
|
||||
status: 'completed',
|
||||
},
|
||||
],
|
||||
{ now: 3 },
|
||||
);
|
||||
expect(state.blocks).toHaveLength(0);
|
||||
expect(state.retainedBytes).toBe(0);
|
||||
});
|
||||
|
||||
it('accounts streamed assistant text against the retention byte budget', () => {
|
||||
let state = createDaemonTranscriptState({
|
||||
maxBlocks: 100,
|
||||
maxRetainedBytes: 250_000,
|
||||
now: 1,
|
||||
});
|
||||
const chunk = 'A'.repeat(25_000);
|
||||
for (let blockIndex = 0; blockIndex < 2; blockIndex += 1) {
|
||||
for (let chunkIndex = 0; chunkIndex < 4; chunkIndex += 1) {
|
||||
state = reduceDaemonTranscriptEvents(
|
||||
state,
|
||||
[{ type: 'assistant.text.delta', text: chunk }],
|
||||
{ now: blockIndex * 10 + chunkIndex + 2 },
|
||||
);
|
||||
}
|
||||
state = reduceDaemonTranscriptEvents(
|
||||
state,
|
||||
[{ type: 'assistant.done', reason: 'end_turn' }],
|
||||
{ now: blockIndex * 10 + 8 },
|
||||
);
|
||||
}
|
||||
|
||||
// Each text block caps at 100k chars (~200KB estimated), so the 250KB
|
||||
// budget cannot hold both streamed blocks and the older one is evicted.
|
||||
expect(state.blocks).toHaveLength(1);
|
||||
expect(state.retainedBytes).toBeLessThanOrEqual(250_000);
|
||||
const expected = state.blocks.reduce(
|
||||
(total, block) => total + estimateDaemonTranscriptBlockBytes(block),
|
||||
0,
|
||||
);
|
||||
expect(state.retainedBytes).toBe(expected);
|
||||
});
|
||||
|
||||
it('does not signal truncation when the byte budget cannot evict anything', () => {
|
||||
const onTruncation = vi.fn();
|
||||
let state = createDaemonTranscriptState({
|
||||
maxBlocks: 10,
|
||||
maxRetainedBytes: 100,
|
||||
now: 1,
|
||||
onTruncation,
|
||||
});
|
||||
// A single block whose estimate exceeds the budget can never be evicted
|
||||
// (the last block always survives).
|
||||
state = reduceDaemonTranscriptEvents(
|
||||
state,
|
||||
[{ type: 'status', text: 'x'.repeat(100) }],
|
||||
{ now: 2 },
|
||||
);
|
||||
expect(state.blocks).toHaveLength(1);
|
||||
onTruncation.mockClear();
|
||||
const blocksBefore = state.blocks;
|
||||
|
||||
state = reduceDaemonTranscriptEvents(
|
||||
state,
|
||||
[{ type: 'session.metadata.changed', sessionId: 'session-1' }],
|
||||
{ now: 3 },
|
||||
);
|
||||
|
||||
expect(onTruncation).not.toHaveBeenCalled();
|
||||
expect(state.blocks).toBe(blocksBefore);
|
||||
});
|
||||
|
||||
it('keeps active assistant text open when reporting trimmed tool updates', () => {
|
||||
let state = createDaemonTranscriptState({ maxBlocks: 2, now: 1 });
|
||||
|
||||
|
|
@ -2328,6 +2834,24 @@ describe('daemon UI normalizer and transcript reducer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('caps oversized generic key_value preview values', () => {
|
||||
// A generic tool input whose only usable field is one large primitive
|
||||
// string must not embed the full value in the preview row — the row value
|
||||
// is capped like other rendered detail strings.
|
||||
const preview = createDaemonToolPreview({
|
||||
someBigTextField: 'x'.repeat(100_000),
|
||||
});
|
||||
expect(preview.kind).toBe('key_value');
|
||||
const row = (
|
||||
preview as { rows?: Array<{ label: string; value: string }> }
|
||||
).rows?.find((entry) => entry.label === 'someBigTextField');
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.value.length).toBeLessThanOrEqual(
|
||||
4096 + '... [truncated]'.length,
|
||||
);
|
||||
expect(row?.value.endsWith('... [truncated]')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes common secret-key aliases before rendering previews', () => {
|
||||
expect(
|
||||
[
|
||||
|
|
@ -2662,6 +3186,59 @@ describe('daemon UI normalizer — Wave 3/4 event coverage (PR-A)', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('caps the embedded payload of unrecognized session_update debug text', () => {
|
||||
// One transcript block per such frame is appended, so an unrecognized
|
||||
// kind streaming at high frequency must not embed its full payload
|
||||
// (up to 100KB wire frames) — the text is capped like tool details.
|
||||
const events = normalizeDaemonEvent(
|
||||
envelopeOf('session_update', {
|
||||
update: {
|
||||
sessionUpdate: 'some_future_kind',
|
||||
payload: 'x'.repeat(100_000),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
const text = (events[0] as { text?: string }).text ?? '';
|
||||
expect(text.length).toBeLessThanOrEqual(4096 + '... [truncated]'.length);
|
||||
expect(text.endsWith('... [truncated]')).toBe(true);
|
||||
});
|
||||
|
||||
it('caps the embedded payload of the sibling debug-block paths too', () => {
|
||||
// The one-block-per-frame accumulation hazard is not specific to the
|
||||
// unrecognized session_update branch: every debug path that embeds the
|
||||
// raw payload must stay capped, or a high-frequency frame taking any
|
||||
// sibling path reproduces the same unbounded-growth OOM.
|
||||
const large = 'x'.repeat(100_000);
|
||||
const expectCapped = (events: ReturnType<typeof normalizeDaemonEvent>) => {
|
||||
expect(events).toHaveLength(1);
|
||||
const text = (events[0] as { text?: string }).text ?? '';
|
||||
expect(text.length).toBeLessThanOrEqual(4096 + '... [truncated]'.length);
|
||||
expect(text.endsWith('... [truncated]')).toBe(true);
|
||||
};
|
||||
|
||||
// Top-level unrecognized event type.
|
||||
expectCapped(
|
||||
normalizeDaemonEvent(envelopeOf('some_future_event', { payload: large })),
|
||||
);
|
||||
// session_update with a non-record payload (no usable update record).
|
||||
expectCapped(normalizeDaemonEvent(envelopeOf('session_update', large)));
|
||||
// permission_request with a non-record payload and one without requestId.
|
||||
expectCapped(normalizeDaemonEvent(envelopeOf('permission_request', large)));
|
||||
expectCapped(
|
||||
normalizeDaemonEvent(
|
||||
envelopeOf('permission_request', { payload: large }),
|
||||
),
|
||||
);
|
||||
// permission_resolved without requestId.
|
||||
expectCapped(
|
||||
normalizeDaemonEvent(
|
||||
envelopeOf('permission_resolved', { payload: large }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies a session_update with no usable discriminator as malformed', () => {
|
||||
// `getSessionUpdatePayload` accepts any record, so these reach the default
|
||||
// branch with `kind === undefined`. They are broken frames, not kinds from
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from '@qwen-code/webui/daemon-react-sdk';
|
||||
import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
|
||||
import type { ACPToolCall, Message } from '../../adapters/types';
|
||||
import { WEB_SHELL_MAX_TRANSCRIPT_BLOCKS } from '../../constants/sessions';
|
||||
import { useAnimationFrameTranscriptBlocks } from '../../hooks/useAnimationFrameTranscriptBlocks';
|
||||
import { useMessagesFromBlocks } from '../../hooks/useMessages';
|
||||
import { useSessionArtifacts } from '../../hooks/useSessionArtifacts';
|
||||
|
|
@ -365,6 +366,7 @@ export function SubagentDetail({
|
|||
workspaceCwd={workspaceCwd}
|
||||
clientId={instance.clientId}
|
||||
maxQueued={256}
|
||||
maxBlocks={WEB_SHELL_MAX_TRANSCRIPT_BLOCKS}
|
||||
subagentTranscriptMode="full"
|
||||
suppressOwnUserEcho
|
||||
>
|
||||
|
|
|
|||
20
packages/web-shell/client/constants/sessions.test.ts
Normal file
20
packages/web-shell/client/constants/sessions.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DAEMON_SESSION_DEFAULT_MAX_BLOCKS } from '@qwen-code/webui/daemon-react-sdk';
|
||||
import { WEB_SHELL_MAX_TRANSCRIPT_BLOCKS } from './sessions';
|
||||
|
||||
describe('web-shell session constants', () => {
|
||||
it('keeps the transcript window aligned with the provider default', () => {
|
||||
// Web Shell passes its own maxBlocks to every DaemonSessionProvider it
|
||||
// mounts; if the provider default moves, this copy must move with it
|
||||
// (see WEB_SHELL_MAX_TRANSCRIPT_BLOCKS).
|
||||
expect(WEB_SHELL_MAX_TRANSCRIPT_BLOCKS).toBe(
|
||||
DAEMON_SESSION_DEFAULT_MAX_BLOCKS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -26,11 +26,14 @@ export const WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS = 500;
|
|||
* (the main chat and each split pane). The daemon stays the authoritative
|
||||
* full-transcript source; this only caps the client's in-memory window.
|
||||
*
|
||||
* The SDK default (200_000) is far beyond what the virtualized message list
|
||||
* renders, and it inflates both the per-dispatch reducer cost (a full
|
||||
* block-array copy) and the full-list message normalization. On a large
|
||||
* transcript that turns a burst of buffered SSE events — e.g. the stream
|
||||
* catching up when the tab returns from being hidden — into a multi-minute
|
||||
* main-thread block. Bounding the window keeps very long sessions responsive.
|
||||
* Intentionally equal to the provider's `DEFAULT_MAX_BLOCKS`; the equality is
|
||||
* enforced by `sessions.test.ts` (importing the constant here instead of the
|
||||
* provider would pull the webui barrel into every importer's module graph and
|
||||
* break the enumerative `daemon-react-sdk` mocks in component tests). Bounding
|
||||
* the window keeps very long sessions responsive: the per-dispatch reducer
|
||||
* cost (a full block-array copy) and the full-list message normalization turn
|
||||
* a burst of buffered SSE events — e.g. the stream catching up when the tab
|
||||
* returns from being hidden — into a long main-thread block on large
|
||||
* transcripts.
|
||||
*/
|
||||
export const WEB_SHELL_MAX_TRANSCRIPT_BLOCKS = 50_000;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@
|
|||
*/
|
||||
export { DaemonSessionProvider } from './daemon/index.js';
|
||||
|
||||
/**
|
||||
* Default transcript block-count window applied by `DaemonSessionProvider`
|
||||
* when no `maxBlocks` prop is given. Exported so UI surfaces can reference
|
||||
* the provider default instead of hard-coding a copy that can drift.
|
||||
*/
|
||||
export { DEFAULT_MAX_BLOCKS as DAEMON_SESSION_DEFAULT_MAX_BLOCKS } from './daemon/session/index.js';
|
||||
|
||||
/**
|
||||
* Wraps children with workspace-level daemon context.
|
||||
* Provides access to cross-session resources: tools, skills, MCP servers,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -22,7 +22,10 @@ import {
|
|||
DaemonSessionClient,
|
||||
UNRECOGNIZED_DIAGNOSTICS_LIMIT,
|
||||
createDaemonTranscriptStore,
|
||||
estimateDaemonTranscriptBlockBytes,
|
||||
extractServerTimestamp,
|
||||
isTrimmedPermissionBlockId,
|
||||
isTrimmedToolBlockId,
|
||||
isUnrecognizedDiagnosticReason,
|
||||
matchTurnEvent,
|
||||
normalizeDaemonEvent,
|
||||
|
|
@ -32,6 +35,7 @@ import {
|
|||
type DaemonTranscriptBlock,
|
||||
type DaemonTranscriptState,
|
||||
type DaemonTranscriptStore,
|
||||
type DaemonTranscriptTruncationDetail,
|
||||
type DaemonTurnCompleteData,
|
||||
type DaemonUiEvent,
|
||||
type DaemonUnrecognizedDiagnostic,
|
||||
|
|
@ -160,11 +164,23 @@ interface LiveJournalRepairEpisode {
|
|||
interface TranscriptHistoryMaterialization {
|
||||
blocks: readonly DaemonTranscriptBlock[];
|
||||
nextOrdinal: number;
|
||||
retainedBytes: number;
|
||||
toolBlockByCallId: Record<string, string>;
|
||||
permissionBlockByRequestId: Record<string, string>;
|
||||
unrecognizedDiagnostics: readonly DaemonUnrecognizedDiagnostic[];
|
||||
}
|
||||
|
||||
type TranscriptHistoryAdmission =
|
||||
| { admitted: true; materialization: TranscriptHistoryMaterialization }
|
||||
| {
|
||||
admitted: false;
|
||||
reason: 'count' | 'bytes';
|
||||
pageBlocks: number;
|
||||
pageBytes: number;
|
||||
/** True when the page can never be admitted, even into an empty window. */
|
||||
impossible: boolean;
|
||||
};
|
||||
|
||||
const SESSION_TRANSCRIPT_PAGINATION_FEATURE = 'session_transcript_pagination';
|
||||
const CLIENT_IDENTITY_FEATURE = 'client_identity';
|
||||
const WORKSPACE_ACP_PREHEAT_FEATURE = 'workspace_acp_preheat';
|
||||
|
|
@ -267,7 +283,7 @@ function materializeTranscriptHistory(
|
|||
current: DaemonTranscriptState,
|
||||
events: DaemonUiEvent[],
|
||||
maxBlocks: number,
|
||||
): TranscriptHistoryMaterialization | undefined {
|
||||
): TranscriptHistoryAdmission {
|
||||
// Drop fetched events whose source records are already displayed.
|
||||
// `beforeRecordId` pagination is exclusive of the anchor but the anchor
|
||||
// can sit inside the retained window (e.g. the daemon's transcript
|
||||
|
|
@ -285,6 +301,35 @@ function materializeTranscriptHistory(
|
|||
displayedRecordIds.add(recordId);
|
||||
}
|
||||
}
|
||||
// Secondary content-aware dedup for blocks that carry no recordId — the
|
||||
// locally echoed user prompt, which `suppressOwnUserEcho` keeps from ever
|
||||
// unioning the daemon's recordId-stamped echo. RecordId dedup is blind to
|
||||
// it, so once a trim leaves it as the oldest retained block, a load-older
|
||||
// page returning that same prompt's persisted record would materialize a
|
||||
// second user block and double-count it. The collision is strictly a
|
||||
// boundary pair — the window's oldest block (the echo) against the page's
|
||||
// newest user block (that same prompt's persisted record, adjacent to the
|
||||
// window) — so only that pair is compared below. Keying on text window-wide
|
||||
// would instead drop DISTINCT older prompts the user happened to send twice
|
||||
// ("yes", a retry), permanently orphaning their assistant replies.
|
||||
// The key must key on echo PRESENCE, not non-empty text: image/file-only
|
||||
// prompts submit with empty text, so a `text !== ''` gate would skip their
|
||||
// dedup and double-render the prompt. Fold media into the key (image/file
|
||||
// counts) so two distinct media-only prompts at the boundary don't collapse.
|
||||
const userBlockBoundaryKey = (
|
||||
block: DaemonTranscriptBlock | undefined,
|
||||
): string | undefined => {
|
||||
if (block?.kind !== 'user') return undefined;
|
||||
const text = (block as { text?: string }).text ?? '';
|
||||
const images = (block as { images?: unknown[] }).images?.length ?? 0;
|
||||
const files = (block as { files?: unknown[] }).files?.length ?? 0;
|
||||
return `${text} | ||||