@fullstackhouse/cerber 0.38.0 → 0.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,8 @@
5
5
  * Undecided is not "post": a duplicate the user never looked at is exactly what
6
6
  * this exists to keep off the PR. The one place undecided still posts is a
7
7
  * resolved thread — somebody marked that problem settled, and a review that
8
- * found it again in the current code is saying it isn't.
8
+ * found it again in the current code is saying it isn't. A finding the user
9
+ * has rewritten is not undecided at all: `rewriteFinding` decides it.
9
10
  */
10
11
  export function sendTreatment(comment) {
11
12
  const raised = comment.alreadyRaised;
@@ -19,6 +20,43 @@ export function sendTreatment(comment) {
19
20
  return raised.where.kind === "thread" ? "reply" : "hold";
20
21
  return raised.where.kind === "thread" && raised.where.state === "resolved" ? "post" : "hold";
21
22
  }
23
+ /**
24
+ * Rewrite a finding's body — and, where somebody else had raised it, let the
25
+ * rewrite be the decision.
26
+ *
27
+ * An undecided match is Send leaving the duplicate out, and that default is
28
+ * about one thing: nobody has looked at it yet. Rewriting the finding is
29
+ * looking at it. The words that would post are no longer the ones the check
30
+ * compared — they are what the user asked for, by hand or through the chat —
31
+ * and cerber does not hold back what the user wrote. A finding is compared
32
+ * once for the same reason (`whatToCompare`): the user who rewrote it is the
33
+ * one deciding about it.
34
+ *
35
+ * The match itself stays, with who raised it, where, and why the two are the
36
+ * same defect: the cockpit still shows all of it, and "send anyway" toggles
37
+ * straight back off. A decision already made is left alone — including
38
+ * `reply`, which keeps answering in their thread, in the new words.
39
+ *
40
+ * Only a match that would otherwise be held is decided. On a resolved thread
41
+ * the default already posts, and writing the decision down there would trade a
42
+ * state that follows the thread for one that doesn't: the cockpit would stop
43
+ * saying *why* it posts, and the finding would keep posting if somebody
44
+ * reopened that thread. Nothing is held, so there is nothing to decide — which
45
+ * goes for a dropped comment too, where drop decides it and the cockpit shows
46
+ * no toggle to take it back with.
47
+ */
48
+ export function rewriteFinding(comment, body) {
49
+ if (body === comment.body)
50
+ return comment;
51
+ const raised = comment.alreadyRaised;
52
+ const held = comment.status !== "dropped" &&
53
+ raised != null &&
54
+ raised.decision === null &&
55
+ sendTreatment({ alreadyRaised: raised }) === "hold";
56
+ if (!held)
57
+ return { ...comment, body };
58
+ return { ...comment, body, alreadyRaised: { ...raised, decision: "send" } };
59
+ }
22
60
  /**
23
61
  * Which match a comment keeps when several remarks raised the same thing:
24
62
  * the liveliest place first, because that is what decides whether posting it
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { rewriteFinding } from "./raised.js";
2
3
  /** The review as it stands, for restoring later. */
3
4
  export function snapshotReview(artifact, at) {
4
5
  return {
@@ -43,10 +44,17 @@ export function mergeConcurrentEdits(before, after, current) {
43
44
  };
44
45
  const merged = current.comments.map((now) => {
45
46
  const chosen = userTouched(now.id, now) ? now : fromTurn.get(now.id) ?? now;
47
+ if (chosen === now)
48
+ return now;
46
49
  // Who else raised a comment is the check's to say, not the turn's: a check
47
50
  // that landed while the turn ran — and a decision made about it — is only
48
51
  // on disk, and the turn's copy predates both.
49
- return chosen === now ? now : { ...chosen, alreadyRaised: now.alreadyRaised };
52
+ const onDisk = { ...chosen, body: now.body, alreadyRaised: now.alreadyRaised };
53
+ // The rewrite the turn made still decides an undecided match, as any
54
+ // rewrite does — measured against the body on disk, so a turn that changed
55
+ // nothing decides nothing. Never on a sent review: that is a record of what
56
+ // was posted, and nothing on it is re-decided, here or anywhere else.
57
+ return current.sent ? { ...onDisk, body: chosen.body } : rewriteFinding(onDisk, chosen.body);
50
58
  });
51
59
  // Comments the turn itself added are not in `current` yet. Only those: a
52
60
  // comment present in both `before` and `after` but missing from `current` is
@@ -138,8 +146,10 @@ export function applyRevisions(artifact, revisions, opts = {}) {
138
146
  ...next,
139
147
  comments: next.comments.map((c) => c.id === revision.commentId
140
148
  ? {
141
- ...c,
142
- body: revision.body,
149
+ // A rewrite the user asked for decides a duplicate nobody
150
+ // had decided about: what posts is their words now, not the
151
+ // ones the check compared (`rewriteFinding`).
152
+ ...rewriteFinding(c, revision.body),
143
153
  // Omitted = the turn is not re-grading; null = "not a finding".
144
154
  severity: revision.severity === undefined ? c.severity : revision.severity,
145
155
  }
@@ -16,7 +16,7 @@ import { refreshArtifact, userOwnsStatus } from "../core/refresh.js";
16
16
  import { withWriter } from "../core/history.js";
17
17
  import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
18
18
  import { buildReviewPayload, computeCalibration, describeReplyFailures, recordReplies, } from "../core/send.js";
19
- import { raisedChanged, startRaisedCheck } from "../core/raised.js";
19
+ import { raisedChanged, rewriteFinding, startRaisedCheck } from "../core/raised.js";
20
20
  import { checkAlreadyRaised, claimRaisedCheck, finishRaisedCheck, isRaisedCheckRunning, prepareRaisedCheck, releaseRaisedCheck, } from "../runner/raised.js";
21
21
  import { listArtifacts, loadArtifact, loadArtifactByKey, saveArtifact, updateArtifactByKey, } from "../core/state.js";
22
22
  import { isReviewRunning } from "../runner/inflight.js";
@@ -465,11 +465,19 @@ export async function buildApp(opts) {
465
465
  ...a,
466
466
  comments: a.comments.map((cm) => cm.id === id
467
467
  ? {
468
- ...cm,
468
+ // Rewriting a finding somebody else raised decides it: the words
469
+ // that post are the user's, not the ones the check compared. An
470
+ // explicit decision in the same request still wins, below. On a
471
+ // sent review nothing is re-decided — it is a record of what was
472
+ // posted, and `foldRaised` holds the same line.
473
+ ...(body.body === undefined
474
+ ? cm
475
+ : a.sent
476
+ ? { ...cm, body: String(body.body) }
477
+ : rewriteFinding(cm, String(body.body))),
469
478
  ...(decision.success && body.raisedDecision !== undefined && cm.alreadyRaised
470
479
  ? { alreadyRaised: { ...cm.alreadyRaised, decision: decision.data } }
471
480
  : {}),
472
- ...(body.body !== undefined ? { body: String(body.body) } : {}),
473
481
  ...(body.severity !== undefined
474
482
  ? { severity: SeveritySchema.nullable().parse(body.severity) }
475
483
  : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.38.0",
3
+ "version": "0.39.1",
4
4
  "description": "AI code-review cockpit: Claude reviews your PRs into local artifacts; nothing reaches GitHub until you say so.",
5
5
  "license": "MIT",
6
6
  "keywords": [