@mmnto/totem 1.73.2 → 1.75.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.
Files changed (36) hide show
  1. package/dist/index.d.ts +6 -5
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -3
  4. package/dist/index.js.map +1 -1
  5. package/dist/spine/classify.d.ts.map +1 -1
  6. package/dist/spine/classify.js +3 -0
  7. package/dist/spine/classify.js.map +1 -1
  8. package/dist/spine/classify.test.js +14 -2
  9. package/dist/spine/classify.test.js.map +1 -1
  10. package/dist/spine/compile.test.js +5 -1
  11. package/dist/spine/compile.test.js.map +1 -1
  12. package/dist/spine/extract.d.ts +95 -21
  13. package/dist/spine/extract.d.ts.map +1 -1
  14. package/dist/spine/extract.js +150 -43
  15. package/dist/spine/extract.js.map +1 -1
  16. package/dist/spine/extract.test.js +146 -63
  17. package/dist/spine/extract.test.js.map +1 -1
  18. package/dist/spine/ledgers.d.ts +152 -16
  19. package/dist/spine/ledgers.d.ts.map +1 -1
  20. package/dist/spine/ledgers.js +85 -12
  21. package/dist/spine/ledgers.js.map +1 -1
  22. package/dist/spine/review-normalize.d.ts +23 -0
  23. package/dist/spine/review-normalize.d.ts.map +1 -0
  24. package/dist/spine/review-normalize.js +95 -0
  25. package/dist/spine/review-normalize.js.map +1 -0
  26. package/dist/spine/review-normalize.test.d.ts +2 -0
  27. package/dist/spine/review-normalize.test.d.ts.map +1 -0
  28. package/dist/spine/review-normalize.test.js +79 -0
  29. package/dist/spine/review-normalize.test.js.map +1 -0
  30. package/dist/spine/selection-rule.d.ts +17 -0
  31. package/dist/spine/selection-rule.d.ts.map +1 -1
  32. package/dist/spine/selection-rule.js +36 -0
  33. package/dist/spine/selection-rule.js.map +1 -1
  34. package/dist/spine/selection-rule.test.js +34 -1
  35. package/dist/spine/selection-rule.test.js.map +1 -1
  36. package/package.json +1 -1
@@ -33,38 +33,123 @@
33
33
  // lesson-markdown is the DSL *syntax* (ADR-058 Pipeline 1/3 target), NOT a
34
34
  // Pipeline-1 trust class: every draft body is `unverified` and Stage-4-gated by
35
35
  // the slice-4 compiler, never a manual-rule trust bypass.
36
+ import { z } from 'zod';
36
37
  import { ProvenanceRecordSchema } from '../compiler-schema.js';
37
38
  import { TotemParseError } from '../errors.js';
38
39
  import { extractManualPattern } from '../lesson-pattern.js';
39
- import { isBotIdentity } from './selection-rule.js';
40
+ import { NoDraftCauseSchema } from './ledgers.js';
41
+ import { isBotIdentity, reviewBotIdentity } from './selection-rule.js';
42
+ /**
43
+ * The `DraftExtractor` port's return: the zero-or-more draft bodies PLUS, when the
44
+ * list is empty, WHY (`noDraftCause`). The cause is the extract-stage twin of the
45
+ * classifier's `dispositionSource` (a non-FM Tenet-19 diagnostic) — a bare `[]`
46
+ * conflated ≥6 causes (model declined / parser rejected a valid draft / transient
47
+ * invoke failure) the funnel could not tell apart. INVARIANT (refined): a cause is
48
+ * present IFF `drafts` is empty — a non-empty result carries drafts and no cause;
49
+ * an empty result MUST name its cause. Parsed at the core boundary so a
50
+ * contract-violating port (cause-without-empty, or empty-without-cause) fails loud
51
+ * before the drop ledger, exactly as `ClassifierResultSchema.parse` guards classify.
52
+ */
53
+ export const DraftResultSchema = z
54
+ .object({
55
+ drafts: z.array(z.string()),
56
+ noDraftCause: NoDraftCauseSchema.optional(),
57
+ })
58
+ .refine((r) => (r.drafts.length === 0) === (r.noDraftCause !== undefined), {
59
+ message: 'noDraftCause must be present iff drafts is empty (the extract-stage diagnostic invariant)',
60
+ path: ['noDraftCause'],
61
+ });
40
62
  // ── Helpers ──────────────────────────────────────────────────────────────────
41
63
  /**
42
- * Count HUMAN review comments (fold 5): bot comments (CodeRabbit / Greptile /
43
- * Renovate / dependabot, via the shared `isBotIdentity`) and empty/whitespace
44
- * bodies do NOT count toward §6's "≥1 review comment" threshold — a bot-only or
45
- * empty thread is content-thin and must take the loud-drop path, never seed a
46
- * hallucinated draft.
64
+ * Classify a comment author (slice β, strategy#709). `'bot'` iff it is a recognized
65
+ * review-FINDING bot (`reviewBotIdentity` allowlist gemini/CR); `'human'`
66
+ * otherwise. The SINGLE classification home: the CLI mapping boundary stamps each
67
+ * comment's `authorKind` via this, and the count + source-tag read that field.
68
+ */
69
+ export function classifyAuthorKind(author) {
70
+ return reviewBotIdentity(author) ? 'bot' : 'human';
71
+ }
72
+ /**
73
+ * Is this comment SUBSTANTIVE mining substrate (slice β)? Counts toward §6's
74
+ * "≥1 review comment" completeness threshold iff its body is non-empty AND it is
75
+ * either a recognized review-finding bot (`authorKind === 'bot'`) OR a human.
76
+ *
77
+ * The slice-β substrate flip (panel OQ-β2, ALLOWLIST): for this bot-reviewed cert
78
+ * corpus, gemini/CR review comments ARE legitimate substrate — they count. But an
79
+ * UNRECOGNIZED `[bot]` automation account (renovate / dependabot) is still excluded
80
+ * via the `isBotIdentity` denylist, so future automation noise can never launder
81
+ * itself in as a substantive reviewer. Empty/whitespace bodies never count.
82
+ */
83
+ function isSubstantiveComment(comment) {
84
+ // Gate on what the extractor ACTUALLY consumes (greptile #2242): for a review
85
+ // bot that is the de-chromed `normalizedBody`, so a badge-ONLY comment (non-empty
86
+ // raw `body`, but `normalizedBody === ''` after the strip) is correctly thin —
87
+ // it would otherwise clear the gate yet hand the extractor an empty body and
88
+ // mislead a `no-draft` drop where `truncated` is the truth. Human bodies are
89
+ // never chrome-stripped, so `normalizedBody === body` for them.
90
+ const effectiveBody = comment.authorKind === 'bot' ? comment.normalizedBody : comment.body;
91
+ if (effectiveBody.trim().length === 0)
92
+ return false;
93
+ if (comment.authorKind === 'bot')
94
+ return true;
95
+ return !isBotIdentity(comment.author);
96
+ }
97
+ /**
98
+ * Count SUBSTANTIVE review comments across threads (slice β — replaces the old
99
+ * `humanCommentCount`). A thread set with zero substantive comments is content-thin
100
+ * and must take the loud-drop path, never seed a hallucinated draft.
47
101
  */
48
- function humanCommentCount(threads) {
102
+ function substantiveCommentCount(threads) {
49
103
  let count = 0;
50
104
  for (const thread of threads) {
51
105
  for (const comment of thread.comments) {
52
- if (comment.body.trim().length > 0 && !isBotIdentity(comment.author))
106
+ if (isSubstantiveComment(comment))
53
107
  count++;
54
108
  }
55
109
  }
56
110
  return count;
57
111
  }
58
112
  /**
59
- * The resolution-eligibility gate (slice 5a, mmnto-ai/totem#2201). A thread is
60
- * INELIGIBLE if the author resolved it OR its diff hunk went outdated either
61
- * marks it as superseded review discussion, contamination the miner must not
62
- * draft from. The adapter SURFACES `isResolved`/`isOutdated` (it never
63
- * pre-filters); core decides here so the rejection is ledgered (§8). Returns the
113
+ * The substrate provenance of a thread set (slice β, panel OQ-β4): `human` /
114
+ * `bot` / `mixed` over its SUBSTANTIVE comments only (recognized review-bot vs
115
+ * human; unrecognized-bot noise is excluded, exactly as the count excludes it).
116
+ * Called only when ≥1 substantive comment survives the gate, so at least one axis
117
+ * is non-zero. A non-FM Tenet-19 diagnostic carried onto each draft + the
118
+ * zero-draft drop.
119
+ */
120
+ function computeSourceKind(threads) {
121
+ let human = 0;
122
+ let bot = 0;
123
+ for (const thread of threads) {
124
+ for (const comment of thread.comments) {
125
+ if (!isSubstantiveComment(comment))
126
+ continue;
127
+ if (comment.authorKind === 'bot')
128
+ bot++;
129
+ else
130
+ human++;
131
+ }
132
+ }
133
+ if (bot > 0 && human > 0)
134
+ return 'mixed';
135
+ return bot > 0 ? 'bot' : 'human';
136
+ }
137
+ /**
138
+ * The eligibility gate. Slice γ (strategy#709) NARROWS this from slice-5a's
139
+ * `!isResolved && !isOutdated` to `!isOutdated` — RESOLVED threads are now ADMITTED.
140
+ *
141
+ * The slice-5a rationale (resolved == superseded contamination) was REVERSED by the
142
+ * Gate-1 cert finding: a RESOLVED thread is the highest-signal LEGITIMACY marker —
143
+ * a defect a reviewer raised AND the author confirmed real by fixing — so excluding
144
+ * it discarded the very evidence the miner wants (exhibit: lc#532's fail-open-on-
145
+ * non-finite, dropped solely for being resolved). Only OUTDATED stays excluded: an
146
+ * outdated thread's diff hunk no longer matches HEAD, so its invariant may have been
147
+ * refactored away — that IS stale. The adapter SURFACES both flags (it never
148
+ * pre-filters); core decides here so every rejection is ledgered (§8). Returns the
64
149
  * eligible (surviving) threads only.
65
150
  */
66
151
  function eligibleThreads(threads) {
67
- return threads.filter((t) => !t.isResolved && !t.isOutdated);
152
+ return threads.filter((t) => !t.isOutdated);
68
153
  }
69
154
  /**
70
155
  * Syntactic preflight (fold 4): a draft is a usable lesson-markdown DSL body iff
@@ -120,21 +205,27 @@ function buildProvenance(pr, content) {
120
205
  * CI-locked with a fixture extractor + a strict-spy fetch source.
121
206
  *
122
207
  * Per train PR (and ONLY train PRs): log the fetch → fetch → on unreachable /
123
- * unparseable-at-source, loud-drop → resolution-eligibility gate (slice 5a: drop
124
- * `resolved-rejected` when the resolution gate empties an otherwise-human thread,
125
- * else `truncated` when thin to begin with) → completeness-check (≥1 human
126
- * comment on the survivors) → build provenance → draft zero-or-more bodies from
127
- * the SURVIVING threads only → preflight each → carry a `DraftCandidate` or
128
- * loud-drop. Every train PR ends with at least one draft or one drop (FM i,
129
- * slice-2 half).
208
+ * unparseable-at-source, loud-drop → eligibility gate (slice γ: drop
209
+ * `outdated-rejected` when the outdated filter empties an otherwise-substantive
210
+ * thread, else `truncated` when thin to begin with) → completeness-check (≥1
211
+ * substantive comment on the survivors) → build provenance → draft zero-or-more
212
+ * bodies from the SURVIVING threads only → preflight each → carry a
213
+ * `DraftCandidate` or loud-drop. Every train PR ends with at least one draft or
214
+ * one drop (FM i, slice-2 half).
130
215
  */
131
216
  export async function runExtractStage(split, deps) {
132
217
  const trainSet = new Set(split.trainPrs);
133
218
  const drafts = [];
134
219
  const dropEntries = [];
135
220
  const apiEntries = [];
136
- const drop = (sourcePr, reasonCode, detail) => {
137
- dropEntries.push({ sourcePr, reasonCode, detail });
221
+ const drop = (sourcePr, reasonCode, detail, noDraftCause, sourceKind) => {
222
+ dropEntries.push({
223
+ sourcePr,
224
+ reasonCode,
225
+ detail,
226
+ ...(noDraftCause ? { noDraftCause } : {}),
227
+ ...(sourceKind ? { sourceKind } : {}),
228
+ });
138
229
  };
139
230
  // Iterate the TRAIN slice ONLY — held-out / control / excluded PRs are never
140
231
  // fetched (§6 / FM h). Deterministic ascending order.
@@ -161,26 +252,26 @@ export async function runExtractStage(split, deps) {
161
252
  drop(pr, 'incomplete-provenance', `fetched content PR #${content.pr} does not match requested train PR #${pr}`);
162
253
  continue;
163
254
  }
164
- // Resolution-eligibility gate (slice 5a, mmnto-ai/totem#2201) — BEFORE the
165
- // completeness check. The adapter surfaced per-thread `isResolved`/`isOutdated`
166
- // (it never pre-filters); core decides + ledgers here so every resolution
167
- // rejection is auditable (§8). Filter to eligible (non-resolved, non-outdated)
168
- // threads and recount human comments on the SURVIVORS only.
169
- const preFilterHumanCount = humanCommentCount(content.threads);
255
+ // Eligibility gate (slice γ) — BEFORE the completeness check. The adapter
256
+ // surfaced per-thread `isOutdated` (it never pre-filters); core decides +
257
+ // ledgers here so every rejection is auditable (§8). Filter to eligible
258
+ // (non-outdated; RESOLVED is now admitted) threads and recount SUBSTANTIVE
259
+ // comments (slice β: human + recognized review-bot) on the SURVIVORS only.
260
+ const preFilterSubstantiveCount = substantiveCommentCount(content.threads);
170
261
  const survivingThreads = eligibleThreads(content.threads);
171
- const survivorHumanCount = humanCommentCount(survivingThreads);
172
- if (survivorHumanCount < 1) {
173
- if (preFilterHumanCount >= 1) {
174
- // The thread carried human content, but the resolution gate is what
175
- // emptied it → `resolved-rejected` (an eligibility rejection, not thin
176
- // content). Carry the concrete resolution evidence in the detail.
262
+ const survivorSubstantiveCount = substantiveCommentCount(survivingThreads);
263
+ if (survivorSubstantiveCount < 1) {
264
+ if (preFilterSubstantiveCount >= 1) {
265
+ // The thread carried substantive content, but the OUTDATED filter is what
266
+ // emptied it → `outdated-rejected` (an eligibility rejection, not thin
267
+ // content). Carry the concrete outdated evidence in the detail.
177
268
  const ineligible = content.threads.length - survivingThreads.length;
178
- drop(pr, 'resolved-rejected', `${ineligible} of ${content.threads.length} threads resolved/outdated; ${survivorHumanCount} eligible human comments remain`);
269
+ drop(pr, 'outdated-rejected', `${ineligible} of ${content.threads.length} threads outdated; ${survivorSubstantiveCount} eligible substantive comments remain`);
179
270
  }
180
271
  else {
181
- // Thin to begin with (0 human comments BEFORE the resolution gate) — the
182
- // existing `truncated` path, NOT a resolution rejection.
183
- drop(pr, 'truncated', 'no non-empty human review comment after bot filtering');
272
+ // Thin to begin with (0 substantive comments BEFORE the gate) — the
273
+ // existing `truncated` path, NOT an eligibility rejection.
274
+ drop(pr, 'truncated', 'no non-empty substantive review comment after bot filtering');
184
275
  }
185
276
  continue;
186
277
  }
@@ -198,11 +289,27 @@ export async function runExtractStage(split, deps) {
198
289
  // catches its own LLM/network errors) — so the core needs no swallowing catch
199
290
  // (Tenet 4). An empty list is a loud drop below, not a silent skip.
200
291
  const eligibleContent = { ...content, threads: survivingThreads };
201
- const draftBodies = await deps.extractor.draft(eligibleContent);
292
+ // The substrate provenance (slice β, Tenet-19 diagnostic) of THIS PR's eligible
293
+ // threads — carried onto every draft + the zero-draft drop (panel OQ-β4).
294
+ const sourceKind = computeSourceKind(survivingThreads);
295
+ // Parse the port result at the boundary (mirrors ClassifierResultSchema.parse in
296
+ // classify.ts): a contract-violating DraftResult (empty-without-cause or
297
+ // cause-without-empty from a buggy adapter) fails loud HERE, before the ledger.
298
+ const draftResult = DraftResultSchema.parse(await deps.extractor.draft(eligibleContent));
299
+ const draftBodies = draftResult.drafts;
202
300
  if (draftBodies.length === 0) {
203
301
  // A complete thread that yields no draft is a loud drop (keeps the train PR
204
- // creditable under FM i), not a silent skip.
205
- drop(pr, 'unparseable', 'extractor produced no draft from a complete thread');
302
+ // creditable under FM i), not a silent skip. Reason code `no-draft` (slice β,
303
+ // strategy β-watch): the slice-α empty-draft drop reused `unparseable`, which
304
+ // was semantically wrong for a legitimate model decline (`none-sentinel`) —
305
+ // the coarse code now NAMES the no-draft case while `noDraftCause` (Tenet-19
306
+ // diagnostic) carries the precise sub-reason, so a parser/format/transient
307
+ // failure is never conflated with the model judging nothing mintable.
308
+ drop(pr, 'no-draft',
309
+ // The boundary parse above + the "cause iff empty" refine guarantee
310
+ // `noDraftCause` is present in this empty-drafts branch — assert it rather
311
+ // than defend with a `?? 'unknown'` fallback that can never fire.
312
+ `extractor produced no draft from a complete thread (cause: ${draftResult.noDraftCause})`, draftResult.noDraftCause, sourceKind);
206
313
  continue;
207
314
  }
208
315
  for (const body of draftBodies) {
@@ -210,7 +317,7 @@ export async function runExtractStage(split, deps) {
210
317
  drop(pr, 'unparseable', 'draft is empty or carries no usable **Pattern:**/yaml DSL');
211
318
  continue;
212
319
  }
213
- drafts.push({ provenance: provenance.value, dslSource: body });
320
+ drafts.push({ provenance: provenance.value, dslSource: body, sourceKind });
214
321
  }
215
322
  }
216
323
  // Recompute the held-out-fetch count from the frozen split rather than trust a
@@ -1 +1 @@
1
- {"version":3,"file":"extract.js","sourceRoot":"","sources":["../../src/spine/extract.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"extract.js","sourceRoot":"","sources":["../../src/spine/extract.ts"],"names":[],"mappings":"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"}
@@ -1,8 +1,24 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { runExtractStage, } from './extract.js';
2
+ import { classifyAuthorKind, runExtractStage, } from './extract.js';
3
+ import { normalizeReviewChrome } from './review-normalize.js';
3
4
  import { SplitArtifactSchema } from './split.js';
4
5
  // ─── Helpers ──────────────────────────────────────────────────────────────
5
6
  const sha = (n) => String(n).padStart(40, '0');
7
+ /**
8
+ * Build a slice-β-enriched comment exactly as the CLI mapping boundary does:
9
+ * `authorKind` via core `classifyAuthorKind`, `normalizedBody` = de-chromed for a
10
+ * recognized review bot, else the raw body. Keeps test comments honest w.r.t. the
11
+ * shipped classification rather than hand-stamping fields.
12
+ */
13
+ function comment(author, body) {
14
+ const authorKind = classifyAuthorKind(author);
15
+ return {
16
+ author,
17
+ body,
18
+ authorKind,
19
+ normalizedBody: authorKind === 'bot' ? normalizeReviewChrome(body) : body,
20
+ };
21
+ }
6
22
  function split(overrides) {
7
23
  return SplitArtifactSchema.parse({
8
24
  asOfCommit: sha(100),
@@ -39,7 +55,7 @@ function content(pr, overrides) {
39
55
  threads: [
40
56
  {
41
57
  path: 'packages/core/src/x.ts',
42
- comments: [{ author: 'Jane Doe', body: 'a real review note' }],
58
+ comments: [comment('Jane Doe', 'a real review note')],
43
59
  isResolved: false,
44
60
  isOutdated: false,
45
61
  },
@@ -51,7 +67,7 @@ function content(pr, overrides) {
51
67
  function thread(author, body, flags) {
52
68
  return {
53
69
  path: 'packages/core/src/x.ts',
54
- comments: [{ author, body }],
70
+ comments: [comment(author, body)],
55
71
  isResolved: flags?.isResolved ?? false,
56
72
  isOutdated: flags?.isOutdated ?? false,
57
73
  };
@@ -75,11 +91,16 @@ function spySource(trainPrs, results) {
75
91
  },
76
92
  };
77
93
  }
78
- /** Fixture extractor: per-PR draft bodies from a map (default: one usable body). Async. */
94
+ /**
95
+ * Fixture extractor: per-PR draft bodies from a map (default: one usable body). Async.
96
+ * Wraps the bare `string[]` into a `DraftResult`, tagging an empty list with a
97
+ * representative `all-filtered` cause so the "cause iff empty" invariant holds.
98
+ */
79
99
  function fixtureExtractor(byPr) {
80
100
  return {
81
101
  async draft(c) {
82
- return byPr?.get(c.pr) ?? [USABLE_DSL];
102
+ const drafts = byPr?.get(c.pr) ?? [USABLE_DSL];
103
+ return drafts.length === 0 ? { drafts, noDraftCause: 'all-filtered' } : { drafts };
83
104
  },
84
105
  };
85
106
  }
@@ -135,12 +156,14 @@ describe('runExtractStage — drop reason codes', () => {
135
156
  const r = await runExtractStage(solo(), deps(spySource([1], new Map([[1, { kind: 'ok', content: content(1, { threads: [] }) }]])), fixtureExtractor()));
136
157
  expect(dropsFor(r, 1)[0].reasonCode).toBe('truncated');
137
158
  });
138
- it('truncated: a bot-only thread does not satisfy ≥1 human comment (fold 5)', async () => {
159
+ it('truncated: a NOISE-bot-only thread does not satisfy ≥1 substantive comment (slice β denylist)', async () => {
160
+ // dependabot/renovate are NOT in the review-finding allowlist → still excluded
161
+ // (unlike gemini/CR, which now count — see the slice-β substrate tests).
139
162
  const botThread = content(1, {
140
163
  threads: [
141
164
  {
142
165
  path: 'x.ts',
143
- comments: [{ author: 'coderabbitai[bot]', body: 'nit: rename this' }],
166
+ comments: [comment('dependabot[bot]', 'bump lodash to 4.17.21')],
144
167
  isResolved: false,
145
168
  isOutdated: false,
146
169
  },
@@ -154,7 +177,7 @@ describe('runExtractStage — drop reason codes', () => {
154
177
  threads: [
155
178
  {
156
179
  path: 'x.ts',
157
- comments: [{ author: 'Jane Doe', body: ' ' }],
180
+ comments: [comment('Jane Doe', ' ')],
158
181
  isResolved: false,
159
182
  isOutdated: false,
160
183
  },
@@ -186,20 +209,44 @@ describe('runExtractStage — drop reason codes', () => {
186
209
  expect(dropsFor(r, 1)[0].reasonCode).toBe('unparseable');
187
210
  expect(r.drafts).toEqual([]);
188
211
  });
189
- it('unparseable: the extractor produced no draft from a complete thread', async () => {
212
+ it('no-draft: the extractor produced no draft from a complete thread — records cause + sourceKind (slice β)', async () => {
190
213
  const r = await runExtractStage(solo(), deps(spySource([1]), fixtureExtractor(new Map([[1, []]]))));
191
- expect(dropsFor(r, 1)[0].reasonCode).toBe('unparseable');
214
+ const drop = dropsFor(r, 1)[0];
215
+ // Slice β: the zero-draft drop is now its own `no-draft` reason code (was the
216
+ // misnamed `unparseable`), with the precise sub-cause + the substrate tag.
217
+ expect(drop.reasonCode).toBe('no-draft');
218
+ // The Tenet-19 diagnostic is carried onto the drop (fixtureExtractor tags an
219
+ // empty list 'all-filtered') — never silently dropped as a bare [].
220
+ expect(drop.noDraftCause).toBe('all-filtered');
221
+ expect(drop.detail).toContain('all-filtered');
222
+ // The default `content(1)` thread is a single human comment → human substrate.
223
+ expect(drop.sourceKind).toBe('human');
224
+ });
225
+ it('boundary-parse fails loud on a contract-violating DraftResult (empty-without-cause)', async () => {
226
+ // A buggy adapter returning { drafts: [] } with no cause violates the
227
+ // "cause iff empty" invariant; DraftResultSchema.parse must reject it at the
228
+ // core boundary (Tenet 4), not silently drop-ledger a causeless empty.
229
+ const badExtractor = {
230
+ async draft() {
231
+ // Type-VALID (noDraftCause is optional in the static type) but RUNTIME-invalid:
232
+ // the schema's "cause iff empty" refine requires a cause when drafts is empty,
233
+ // so the core boundary parse rejects this — exactly the buggy-adapter case.
234
+ return { drafts: [] };
235
+ },
236
+ };
237
+ await expect(runExtractStage(solo(), deps(spySource([1]), badExtractor))).rejects.toThrow(/noDraftCause must be present iff drafts is empty/);
192
238
  });
193
239
  it('a contract-violating extractor throw propagates (fail-loud, not swallowed)', async () => {
194
- // The port contract is: return [] on a per-PR failure (the CLI adapter catches
195
- // its own IO errors). A throw VIOLATES that contract and must NOT be silently
196
- // swallowed — it propagates (Tenet 4). Per-PR resilience is the adapter's job;
197
- // the []-returns path is covered by "extractor produced no draft" above.
240
+ // The port contract is: return { drafts: [], noDraftCause } on a per-PR failure
241
+ // (the CLI adapter catches its own IO errors → 'invoke-error'). A throw VIOLATES
242
+ // that contract and must NOT be silently swallowed — it propagates (Tenet 4).
243
+ // Per-PR resilience is the adapter's job; the empty-result path is covered by
244
+ // "extractor produced no draft" above.
198
245
  const throwingExtractor = {
199
246
  async draft(c) {
200
247
  if (c.pr === 1)
201
248
  throw new Error('boom');
202
- return [USABLE_DSL];
249
+ return { drafts: [USABLE_DSL] };
203
250
  },
204
251
  };
205
252
  await expect(runExtractStage(split(), deps(spySource([1, 2]), throwingExtractor))).rejects.toThrow('boom');
@@ -275,44 +322,48 @@ function recordingExtractor(byPr) {
275
322
  seen,
276
323
  async draft(c) {
277
324
  seen.push(c);
278
- return byPr?.get(c.pr) ?? [USABLE_DSL];
325
+ const drafts = byPr?.get(c.pr) ?? [USABLE_DSL];
326
+ return drafts.length === 0 ? { drafts, noDraftCause: 'all-filtered' } : { drafts };
279
327
  },
280
328
  };
281
329
  }
282
- describe('runExtractStage — resolution-eligibility gate (slice 5a)', () => {
283
- it('all-resolved-but-had-human-content drops resolved-rejected (not truncated)', async () => {
284
- const allResolved = content(1, {
285
- threads: [
286
- thread('Jane Doe', 'a real review note', { isResolved: true }),
287
- thread('John Roe', 'another note', { isOutdated: true }),
288
- ],
330
+ describe('runExtractStage — eligibility gate (slice γ: RESOLVED admitted, only OUTDATED excluded)', () => {
331
+ it('a RESOLVED thread is now ADMITTED (drafts; reaches the extractor input)', async () => {
332
+ const resolved = content(1, {
333
+ threads: [thread('Jane Doe', 'a real review note', { isResolved: true })],
289
334
  });
290
- const r = await runExtractStage(solo(), deps(spySource([1], new Map([[1, { kind: 'ok', content: allResolved }]])), fixtureExtractor()));
291
- expect(dropsFor(r, 1)[0].reasonCode).toBe('resolved-rejected');
292
- expect(r.drafts).toEqual([]);
335
+ const extractor = recordingExtractor();
336
+ const r = await runExtractStage(solo(), deps(spySource([1], new Map([[1, { kind: 'ok', content: resolved }]])), extractor));
337
+ expect(r.drafts).toHaveLength(1);
338
+ expect(dropsFor(r, 1)).toEqual([]);
339
+ // γ: the resolved thread is no longer pre-filtered — the extractor saw it.
340
+ expect(extractor.seen[0].threads).toHaveLength(1);
341
+ expect(extractor.seen[0].threads[0].isResolved).toBe(true);
293
342
  });
294
- it('the resolved-rejected drop detail carries concrete resolution evidence', async () => {
295
- const allResolved = content(1, {
343
+ it('all-OUTDATED-but-had-substantive-content drops outdated-rejected (not truncated)', async () => {
344
+ const allOutdated = content(1, {
296
345
  threads: [
297
- thread('Jane Doe', 'a real review note', { isResolved: true }),
346
+ thread('Jane Doe', 'a real review note', { isOutdated: true }),
298
347
  thread('John Roe', 'another note', { isOutdated: true }),
299
348
  ],
300
349
  });
301
- const r = await runExtractStage(solo(), deps(spySource([1], new Map([[1, { kind: 'ok', content: allResolved }]])), fixtureExtractor()));
302
- const detail = dropsFor(r, 1)[0].detail ?? '';
303
- expect(detail).toContain('2 of 2 threads resolved/outdated');
304
- expect(detail).toContain('0 eligible human comments remain');
305
- });
306
- it('thin-to-begin-with (0 human comments before the gate) stays truncated, not resolved-rejected', async () => {
307
- // A resolved thread that ALSO had no human comment — the resolution gate is
308
- // not what emptied it; it was already thin. Keep the existing truncated path.
309
- const botResolved = content(1, {
310
- threads: [thread('coderabbitai[bot]', 'nit: rename this', { isResolved: true })],
350
+ const r = await runExtractStage(solo(), deps(spySource([1], new Map([[1, { kind: 'ok', content: allOutdated }]])), fixtureExtractor()));
351
+ const drop = dropsFor(r, 1)[0];
352
+ expect(drop.reasonCode).toBe('outdated-rejected');
353
+ expect(drop.detail).toContain('2 of 2 threads outdated');
354
+ expect(drop.detail).toContain('0 eligible substantive comments remain');
355
+ expect(r.drafts).toEqual([]);
356
+ });
357
+ it('thin-to-begin-with (0 substantive comments before the gate) stays truncated, not outdated-rejected', async () => {
358
+ // A noise-bot-only outdated thread — the gate is not what emptied it; it was
359
+ // already thin (dependabot is not a recognized review bot). Keep `truncated`.
360
+ const botOutdated = content(1, {
361
+ threads: [thread('dependabot[bot]', 'bump dep', { isOutdated: true })],
311
362
  });
312
- const r = await runExtractStage(solo(), deps(spySource([1], new Map([[1, { kind: 'ok', content: botResolved }]])), fixtureExtractor()));
363
+ const r = await runExtractStage(solo(), deps(spySource([1], new Map([[1, { kind: 'ok', content: botOutdated }]])), fixtureExtractor()));
313
364
  expect(dropsFor(r, 1)[0].reasonCode).toBe('truncated');
314
365
  });
315
- it('partial resolution: survivors are processed; resolved threads excluded from the draft input', async () => {
366
+ it('partial: OUTDATED threads excluded from the draft input; resolved + fresh survive', async () => {
316
367
  const mixed = content(1, {
317
368
  threads: [
318
369
  thread('Jane Doe', 'eligible note A'),
@@ -322,33 +373,65 @@ describe('runExtractStage — resolution-eligibility gate (slice 5a)', () => {
322
373
  });
323
374
  const extractor = recordingExtractor();
324
375
  const r = await runExtractStage(solo(), deps(spySource([1], new Map([[1, { kind: 'ok', content: mixed }]])), extractor));
325
- // The PR is processed (a draft, no drop).
326
376
  expect(r.drafts).toHaveLength(1);
327
377
  expect(dropsFor(r, 1)).toEqual([]);
328
- // The extractor only saw the ONE eligible thread resolved/outdated excluded.
329
- expect(extractor.seen).toHaveLength(1);
378
+ // The extractor saw the two NON-outdated threads (incl. the resolved one); only
379
+ // the outdated thread was excluded (γ inverts the slice-5a resolved exclusion).
330
380
  const seenThreads = extractor.seen[0].threads;
331
- expect(seenThreads).toHaveLength(1);
332
- expect(seenThreads[0].comments[0].body).toBe('eligible note A');
333
- expect(seenThreads.every((t) => !t.isResolved && !t.isOutdated)).toBe(true);
381
+ expect(seenThreads).toHaveLength(2);
382
+ expect(seenThreads.every((t) => !t.isOutdated)).toBe(true);
383
+ expect(seenThreads.map((t) => t.comments[0].body).sort()).toEqual([
384
+ 'eligible note A',
385
+ 'resolved note B',
386
+ ]);
387
+ });
388
+ it('a fully-eligible thread is unaffected by the gate (no regression)', async () => {
389
+ const r = await runExtractStage(solo(), deps(spySource([1]), fixtureExtractor()));
390
+ expect(r.drafts).toHaveLength(1);
391
+ expect(dropsFor(r, 1)).toEqual([]);
392
+ });
393
+ });
394
+ // ─── Slice β: bot-review substrate + sourceKind diagnostic ────────────────────
395
+ describe('runExtractStage — slice β (bot-review substrate + sourceKind)', () => {
396
+ it('a RECOGNIZED review-bot (coderabbitai) comment now COUNTS as substrate → drafts', async () => {
397
+ const crOnly = content(1, {
398
+ threads: [thread('coderabbitai[bot]', 'Potential issue: guard against NaN here')],
399
+ });
400
+ const r = await runExtractStage(solo(), deps(spySource([1], new Map([[1, { kind: 'ok', content: crOnly }]])), fixtureExtractor()));
401
+ expect(r.drafts).toHaveLength(1);
402
+ expect(dropsFor(r, 1)).toEqual([]);
403
+ expect(r.drafts[0].sourceKind).toBe('bot');
404
+ });
405
+ it('gemini-code-assist (no [bot] suffix) counts as substrate', async () => {
406
+ const gca = content(1, {
407
+ threads: [thread('gemini-code-assist', 'require is_finite() before the divide')],
408
+ });
409
+ const r = await runExtractStage(solo(), deps(spySource([1], new Map([[1, { kind: 'ok', content: gca }]])), fixtureExtractor()));
410
+ expect(r.drafts).toHaveLength(1);
411
+ expect(r.drafts[0].sourceKind).toBe('bot');
334
412
  });
335
- it('partial resolution: resolved threads do not count toward the human-comment threshold', async () => {
336
- // The single eligible thread is bot-only; the resolved thread is the only one
337
- // with a human comment. Survivors have 0 human comments → resolved-rejected.
413
+ it('sourceKind is human for a human-only thread', async () => {
414
+ const r = await runExtractStage(solo(), deps(spySource([1]), fixtureExtractor()));
415
+ expect(r.drafts[0].sourceKind).toBe('human');
416
+ });
417
+ it('a badge-ONLY review-bot comment is NOT substantive — de-chromed empty → truncated, not no-draft (greptile #2242)', async () => {
418
+ const badgeOnly = content(1, {
419
+ threads: [thread('coderabbitai[bot]', '![high](https://x/high.svg)')],
420
+ });
421
+ const r = await runExtractStage(solo(), deps(spySource([1], new Map([[1, { kind: 'ok', content: badgeOnly }]])), fixtureExtractor()));
422
+ // normalizedBody strips to '' → not substantive → thin from the start.
423
+ expect(dropsFor(r, 1)[0].reasonCode).toBe('truncated');
424
+ expect(r.drafts).toEqual([]);
425
+ });
426
+ it('sourceKind is mixed when human + review-bot comments both survive', async () => {
338
427
  const mixed = content(1, {
339
428
  threads: [
340
- thread('coderabbitai[bot]', 'nit: rename', { isResolved: false }),
341
- thread('Jane Doe', 'a real human note', { isResolved: true }),
429
+ thread('Jane Doe', 'the human rationale'),
430
+ thread('coderabbitai[bot]', 'the bot finding'),
342
431
  ],
343
432
  });
344
433
  const r = await runExtractStage(solo(), deps(spySource([1], new Map([[1, { kind: 'ok', content: mixed }]])), fixtureExtractor()));
345
- expect(dropsFor(r, 1)[0].reasonCode).toBe('resolved-rejected');
346
- expect(r.drafts).toEqual([]);
347
- });
348
- it('a fully-eligible thread is unaffected by the gate (no regression)', async () => {
349
- const r = await runExtractStage(solo(), deps(spySource([1]), fixtureExtractor()));
350
- expect(r.drafts).toHaveLength(1);
351
- expect(dropsFor(r, 1)).toEqual([]);
434
+ expect(r.drafts[0].sourceKind).toBe('mixed');
352
435
  });
353
436
  });
354
437
  // ─── Determinism ──────────────────────────────────────────────────────────────
@@ -357,9 +440,9 @@ describe('runExtractStage — determinism', () => {
357
440
  const run = () => runExtractStage(split(), deps(spySource([1, 2], new Map([[2, { kind: 'unreachable' }]])), fixtureExtractor(new Map([[1, [USABLE_DSL, NO_PATTERN_DSL]]]))));
358
441
  expect(await run()).toEqual(await run());
359
442
  });
360
- it('identical inputs are deterministic across the resolution gate (drafts + drops + ledgers)', async () => {
361
- // PR 1: a partial-resolution mix (one eligible survivor → a draft).
362
- // PR 2: all-resolved-but-had-human-content → a resolved-rejected drop.
443
+ it('identical inputs are deterministic across the eligibility gate (drafts + drops + ledgers)', async () => {
444
+ // PR 1: a partial mix (resolved + fresh both survive → a draft).
445
+ // PR 2: all-outdated-but-had-substantive-content → an outdated-rejected drop.
363
446
  const pr1 = content(1, {
364
447
  threads: [
365
448
  thread('Jane Doe', 'eligible note', { isResolved: false }),
@@ -376,7 +459,7 @@ describe('runExtractStage — determinism', () => {
376
459
  const a = await run();
377
460
  const b = await run();
378
461
  expect(a).toEqual(b);
379
- expect(dropsFor(a, 2)[0].reasonCode).toBe('resolved-rejected');
462
+ expect(dropsFor(a, 2)[0].reasonCode).toBe('outdated-rejected');
380
463
  });
381
464
  });
382
465
  //# sourceMappingURL=extract.test.js.map