@fullstackhouse/cerber 0.36.1 → 0.37.0

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.
@@ -445,6 +445,12 @@ export const ArtifactSchema = z.object({
445
445
  })
446
446
  .nullable()
447
447
  .optional(),
448
+ /**
449
+ * Files the user marked as viewed: path → `fileFingerprint` of the file's
450
+ * patch at the time. A mark whose fingerprint no longer matches the diff is
451
+ * a file that changed since, and reads as not viewed. Absent: none yet.
452
+ */
453
+ viewed: z.record(z.string(), z.string()).optional(),
448
454
  /** Last time this review was pulled forward onto a newer head commit. */
449
455
  refresh: RefreshInfoSchema.nullable().default(null),
450
456
  /** The last check for findings somebody else already raised. Null: never checked. */
package/dist/core/diff.js CHANGED
@@ -114,3 +114,27 @@ export function unclaimedFiles(diff, chapters) {
114
114
  export function diffLineCounts(diff) {
115
115
  return new Map(splitDiffByFile(diff).map((p) => [p.path, p.patch.split("\n").length]));
116
116
  }
117
+ /**
118
+ * What a file's change says, reduced to a short hash — the thing a "viewed"
119
+ * mark remembers, so a new push that changes the file unticks it.
120
+ *
121
+ * Only the changed and context lines count: a rebase that moves the hunk
122
+ * headers or the blob ids changes nothing you read. A patch with no such lines
123
+ * (a binary, a pure rename) falls back to the whole patch, whose `index` line
124
+ * is then the only thing that tells two versions apart.
125
+ */
126
+ export function fileFingerprint(patch) {
127
+ const lines = patch.split("\n");
128
+ // `---`/`+++` are file headers only before the first hunk; inside one they
129
+ // are a removed `-- comment` or an added `++ line`, and those are changes.
130
+ const firstHunk = lines.findIndex((l) => l.startsWith("@@"));
131
+ const content = firstHunk < 0 ? [] : lines.slice(firstHunk).filter((l) => /^[+\- ]/.test(l));
132
+ const text = (content.length > 0 ? content : lines).join("\n");
133
+ // FNV-1a, 32-bit.
134
+ let hash = 0x811c9dc5;
135
+ for (let i = 0; i < text.length; i++) {
136
+ hash ^= text.charCodeAt(i);
137
+ hash = Math.imul(hash, 0x01000193);
138
+ }
139
+ return (hash >>> 0).toString(16).padStart(8, "0");
140
+ }
@@ -80,6 +80,9 @@ export function mergeRunResult(fresh, current) {
80
80
  notified: current.notified,
81
81
  calibration: current.calibration,
82
82
  filed: current.filed,
83
+ // Reading progress is the user's; a mark on a file the run saw change
84
+ // unticks itself through its fingerprint.
85
+ viewed: current.viewed,
83
86
  // A decision of the user's, like the status above it: a handoff that landed
84
87
  // while the run worked has already moved the request on GitHub, and a run
85
88
  // that wrote `null` over it would leave the row reading "skipped" with
@@ -72,6 +72,8 @@ export function mergeConcurrentEdits(before, after, current) {
72
72
  // Nor does a turn announce anything: the ledger is the poll's, and a turn
73
73
  // takes minutes, which is long enough for a poll to have stamped it.
74
74
  notified: current.notified,
75
+ // Ticked while the turn ran, and never the turn's to touch.
76
+ viewed: current.viewed,
75
77
  // Nor does it compare the draft with what others said; that check folds
76
78
  // onto the disk on its own.
77
79
  raisedCheck: current.raisedCheck,
@@ -167,6 +167,9 @@ async function performReview(ref, pr, existing, opts, log) {
167
167
  // `existing` was loaded minutes ago, and a poll may have announced the row
168
168
  // since.
169
169
  notified: existing?.notified,
170
+ // Reading progress is the user's, like the chat. A file this run finds
171
+ // changed unticks itself through its fingerprint.
172
+ viewed: existing?.viewed,
170
173
  pr,
171
174
  diff,
172
175
  summary: "",
@@ -220,7 +223,9 @@ async function performReview(ref, pr, existing, opts, log) {
220
223
  // how the reopen happens at all. A settle that appeared since is the one this
221
224
  // must not touch.
222
225
  const decided = existing ? userOwnsStatus(existing) : false;
223
- const claimed = await updateArtifactByKey(artifactKey(artifact.id), (current) => !decided && userOwnsStatus(current) ? current : { ...artifact, notified: current.notified });
226
+ const claimed = await updateArtifactByKey(artifactKey(artifact.id), (current) => !decided && userOwnsStatus(current)
227
+ ? current
228
+ : { ...artifact, notified: current.notified, viewed: current.viewed });
224
229
  const held = claimed !== null && !decided && userOwnsStatus(claimed);
225
230
  if (!held) {
226
231
  if (claimed)
@@ -355,7 +355,9 @@ export async function buildApp(opts) {
355
355
  // decision, and claiming the row for a run would erase it — the detached
356
356
  // run below folds its draft under whatever the row says instead
357
357
  // (`mergeRunResult`), which is the same line every other writer holds.
358
- const claimed = await updateArtifactByKey(artifactKey(artifact.id), (current) => userOwnsStatus(current) ? current : { ...artifact, notified: current.notified });
358
+ const claimed = await updateArtifactByKey(artifactKey(artifact.id), (current) => userOwnsStatus(current)
359
+ ? current
360
+ : { ...artifact, notified: current.notified, viewed: current.viewed });
359
361
  // Declining the claim is only half the job: the run below forces, and a
360
362
  // forced run reopens a settled row on purpose. So the decision ends the
361
363
  // request — hand it back and start nothing.
@@ -520,6 +522,27 @@ export async function buildApp(opts) {
520
522
  return c.json({ error: "not found" }, 404);
521
523
  return c.json(updated);
522
524
  });
525
+ // Reading progress, not a review edit — so a sent review takes it too.
526
+ app.put("/api/reviews/:key/viewed", async (c) => {
527
+ const body = await c.req.json().catch(() => null);
528
+ if (typeof body?.path !== "string" || !body.path) {
529
+ return c.json({ error: "path must be a non-empty string" }, 400);
530
+ }
531
+ if (body.fingerprint !== null && typeof body.fingerprint !== "string") {
532
+ return c.json({ error: "fingerprint must be a string, or null to unmark" }, 400);
533
+ }
534
+ const updated = await updateArtifactByKey(c.req.param("key"), (a) => {
535
+ const viewed = { ...a.viewed };
536
+ if (body.fingerprint === null)
537
+ delete viewed[body.path];
538
+ else
539
+ viewed[body.path] = body.fingerprint;
540
+ return { ...a, viewed };
541
+ });
542
+ if (!updated)
543
+ return c.json({ error: "not found" }, 404);
544
+ return c.json(updated);
545
+ });
523
546
  // ---- Freshness: pull a review forward onto the PR's current head ----
524
547
  // Read-only against GitHub (pr view + pr diff); writes only the local artifact.
525
548
  app.post("/api/reviews/:key/refresh", async (c) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.36.1",
3
+ "version": "0.37.0",
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": [