@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.
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/spine/classify.d.ts.map +1 -1
- package/dist/spine/classify.js +3 -0
- package/dist/spine/classify.js.map +1 -1
- package/dist/spine/classify.test.js +14 -2
- package/dist/spine/classify.test.js.map +1 -1
- package/dist/spine/compile.test.js +5 -1
- package/dist/spine/compile.test.js.map +1 -1
- package/dist/spine/extract.d.ts +95 -21
- package/dist/spine/extract.d.ts.map +1 -1
- package/dist/spine/extract.js +150 -43
- package/dist/spine/extract.js.map +1 -1
- package/dist/spine/extract.test.js +146 -63
- package/dist/spine/extract.test.js.map +1 -1
- package/dist/spine/ledgers.d.ts +152 -16
- package/dist/spine/ledgers.d.ts.map +1 -1
- package/dist/spine/ledgers.js +85 -12
- package/dist/spine/ledgers.js.map +1 -1
- package/dist/spine/review-normalize.d.ts +23 -0
- package/dist/spine/review-normalize.d.ts.map +1 -0
- package/dist/spine/review-normalize.js +95 -0
- package/dist/spine/review-normalize.js.map +1 -0
- package/dist/spine/review-normalize.test.d.ts +2 -0
- package/dist/spine/review-normalize.test.d.ts.map +1 -0
- package/dist/spine/review-normalize.test.js +79 -0
- package/dist/spine/review-normalize.test.js.map +1 -0
- package/dist/spine/selection-rule.d.ts +17 -0
- package/dist/spine/selection-rule.d.ts.map +1 -1
- package/dist/spine/selection-rule.js +36 -0
- package/dist/spine/selection-rule.js.map +1 -1
- package/dist/spine/selection-rule.test.js +34 -1
- package/dist/spine/selection-rule.test.js.map +1 -1
- package/package.json +1 -1
package/dist/spine/extract.js
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
|
|
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
|
|
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 (
|
|
106
|
+
if (isSubstantiveComment(comment))
|
|
53
107
|
count++;
|
|
54
108
|
}
|
|
55
109
|
}
|
|
56
110
|
return count;
|
|
57
111
|
}
|
|
58
112
|
/**
|
|
59
|
-
* The
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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.
|
|
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 →
|
|
124
|
-
* `
|
|
125
|
-
* else `truncated` when thin to begin with) → completeness-check (≥1
|
|
126
|
-
* comment on the survivors) → build provenance → draft zero-or-more
|
|
127
|
-
* the SURVIVING threads only → preflight each → carry a
|
|
128
|
-
* loud-drop. Every train PR ends with at least one draft or
|
|
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({
|
|
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
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
const
|
|
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
|
|
172
|
-
if (
|
|
173
|
-
if (
|
|
174
|
-
// The thread carried
|
|
175
|
-
// emptied it → `
|
|
176
|
-
// content). Carry the concrete
|
|
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, '
|
|
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
|
|
182
|
-
// existing `truncated` path, NOT
|
|
183
|
-
drop(pr, 'truncated', 'no non-empty
|
|
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
|
-
|
|
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
|
-
|
|
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":"AAAA,gGAAgG;AAChG,EAAE;AACF,gFAAgF;AAChF,gFAAgF;AAChF,6EAA6E;AAC7E,8EAA8E;AAC9E,iFAAiF;AACjF,oEAAoE;AACpE,kFAAkF;AAClF,mEAAmE;AACnE,EAAE;AACF,iFAAiF;AACjF,+EAA+E;AAC/E,4EAA4E;AAC5E,2EAA2E;AAC3E,YAAY;AACZ,EAAE;AACF,2CAA2C;AAC3C,4EAA4E;AAC5E,4EAA4E;AAC5E,0EAA0E;AAC1E,iFAAiF;AACjF,8EAA8E;AAC9E,4EAA4E;AAC5E,iFAAiF;AACjF,mEAAmE;AACnE,mFAAmF;AACnF,6EAA6E;AAC7E,mFAAmF;AACnF,iFAAiF;AACjF,sDAAsD;AACtD,EAAE;AACF,2EAA2E;AAC3E,gFAAgF;AAChF,0DAA0D;AAE1D,OAAO,EAAyB,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AACtF,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;
|
|
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: [
|
|
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: [
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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: [
|
|
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: [
|
|
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('
|
|
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
|
-
|
|
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
|
|
195
|
-
// its own IO errors). A throw VIOLATES
|
|
196
|
-
// swallowed — it propagates (Tenet 4).
|
|
197
|
-
// the
|
|
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
|
-
|
|
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 —
|
|
283
|
-
it('
|
|
284
|
-
const
|
|
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
|
|
291
|
-
|
|
292
|
-
expect(r.drafts).
|
|
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('
|
|
295
|
-
const
|
|
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', {
|
|
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:
|
|
302
|
-
const
|
|
303
|
-
expect(
|
|
304
|
-
expect(detail).toContain('
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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:
|
|
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
|
|
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
|
|
329
|
-
|
|
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(
|
|
332
|
-
expect(seenThreads
|
|
333
|
-
expect(seenThreads.
|
|
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('
|
|
336
|
-
|
|
337
|
-
|
|
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]', '')],
|
|
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('
|
|
341
|
-
thread('
|
|
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(
|
|
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
|
|
361
|
-
// PR 1: a partial
|
|
362
|
-
// PR 2: all-
|
|
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('
|
|
462
|
+
expect(dropsFor(a, 2)[0].reasonCode).toBe('outdated-rejected');
|
|
380
463
|
});
|
|
381
464
|
});
|
|
382
465
|
//# sourceMappingURL=extract.test.js.map
|