pgroast 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +173 -2
- package/bin/pgroast.mjs +19 -0
- package/data/precedents.sql.gz +0 -0
- package/docs/corpus.md +352 -0
- package/docs/design.md +540 -0
- package/docs/eval.md +245 -0
- package/docs/haas-paranoia-notes.md +223 -0
- package/invariants/README.md +15 -0
- package/invariants/nbtree.md +14 -0
- package/package.json +51 -4
- package/prompts/brief.md +32 -0
- package/prompts/committer-paranoia.md +149 -0
- package/prompts/critic.md +56 -0
- package/prompts/enrich.md +35 -0
- package/prompts/pushback-map.md +58 -0
- package/prompts/pushback-reduce.md +72 -0
- package/prompts/reflect.md +20 -0
- package/prompts/relevance.md +20 -0
- package/prompts/roaster-already-exists.md +23 -0
- package/prompts/roaster-common.md +88 -0
- package/prompts/roaster-demand.md +28 -0
- package/prompts/roaster-maintenance-fit.md +22 -0
- package/prompts/roaster-prior-attempts.md +29 -0
- package/prompts/roaster-replication-ondisk.md +24 -0
- package/prompts/roaster-security.md +21 -0
- package/prompts/roaster-standards-compat.md +21 -0
- package/prompts/supervisor.md +31 -0
- package/prompts/synth-self.md +19 -0
- package/prompts/synth.md +140 -0
- package/prompts/tone.md +21 -0
- package/prompts/writing.md +98 -0
- package/sql/schema.sql +45 -0
- package/src/cli.ts +391 -0
- package/src/corpus/cf.ts +220 -0
- package/src/corpus/cli.ts +189 -0
- package/src/corpus/db.ts +119 -0
- package/src/corpus/fetch.ts +104 -0
- package/src/corpus/reverts.ts +133 -0
- package/src/corpus/search.ts +201 -0
- package/src/corpus/util.ts +133 -0
- package/src/corpus/wiki.ts +80 -0
- package/src/eval/cases.ts +118 -0
- package/src/eval/cli.ts +105 -0
- package/src/eval/judge.ts +136 -0
- package/src/eval/report.ts +268 -0
- package/src/eval/run.ts +153 -0
- package/src/eval/score.ts +275 -0
- package/src/roast/doctor.ts +109 -0
- package/src/roast/enrich.ts +335 -0
- package/src/roast/invariants.ts +65 -0
- package/src/roast/llm.ts +601 -0
- package/src/roast/material.ts +133 -0
- package/src/roast/page.ts +266 -0
- package/src/roast/pipeline.ts +981 -0
- package/src/roast/precedents.ts +308 -0
- package/src/roast/pushback.ts +664 -0
- package/src/roast/render.ts +386 -0
- package/src/roast/sanitize.ts +140 -0
- package/src/roast/schema.ts +714 -0
- package/src/roast/tone.ts +216 -0
- package/src/thread/fetch.ts +172 -0
- package/src/thread/load.ts +227 -0
- package/src/thread/local.ts +73 -0
- package/src/thread/parse.ts +243 -0
- package/src/thread/patchprose.ts +143 -0
- package/src/thread/types.ts +120 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// Lazy enrichment of precedents at roast time.
|
|
2
|
+
//
|
|
3
|
+
// Many corpus rows (CommitFest entries above all) carry a title and an outcome but no text that
|
|
4
|
+
// says why the proposal failed. For the top candidates of a roast, this module loads the
|
|
5
|
+
// precedent's own thread (cached, polite), asks the model once per thread for a short record
|
|
6
|
+
// (what was proposed, why it was not accepted, who objected, one verbatim key sentence), checks
|
|
7
|
+
// that record against the thread in code, and stores it in the table `precedent_enrichment` so
|
|
8
|
+
// every thread is summarised once.
|
|
9
|
+
//
|
|
10
|
+
// Evidence rules:
|
|
11
|
+
// - the quote must be a substring of the body of the message the model names (quoted lines
|
|
12
|
+
// excluded); otherwise it is dropped. The link to that message is built here, by code.
|
|
13
|
+
// - objector names must be senders of messages in the thread; others are dropped.
|
|
14
|
+
// - only messages dated up to the precedent's decision date are read, so a record never holds
|
|
15
|
+
// anything later than a date the retrieval cutoff has already allowed.
|
|
16
|
+
// - the proposal's own thread is never read here.
|
|
17
|
+
|
|
18
|
+
import { existsSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { REPO_ROOT } from "../thread/fetch.ts";
|
|
21
|
+
import { loadCfEntry, loadThread } from "../thread/load.ts";
|
|
22
|
+
import type { Thread, ThreadMessage } from "../thread/types.ts";
|
|
23
|
+
import type { LlmBackend } from "./llm.ts";
|
|
24
|
+
import { displayName, trimQuotes } from "./material.ts";
|
|
25
|
+
import { messageIdOfUrl } from "./precedents.ts";
|
|
26
|
+
import { verifyQuote } from "./sanitize.ts";
|
|
27
|
+
import type { JsonSchema, Precedent } from "./schema.ts";
|
|
28
|
+
|
|
29
|
+
export interface Enrichment {
|
|
30
|
+
thread_url: string;
|
|
31
|
+
/** Corpus id of the precedent, e.g. "cf:349". */
|
|
32
|
+
raw_ref: string;
|
|
33
|
+
kind: string;
|
|
34
|
+
/** Messages after this day (YYYY-MM-DD) were not read; "" = whole thread. */
|
|
35
|
+
as_of: string;
|
|
36
|
+
summary: string;
|
|
37
|
+
objection: string;
|
|
38
|
+
objectors: string[];
|
|
39
|
+
key_quote: string | null;
|
|
40
|
+
quote_message_url: string | null;
|
|
41
|
+
model: string;
|
|
42
|
+
created_at: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface EnrichmentStore {
|
|
46
|
+
get(threadUrl: string, asOf: string): Promise<Enrichment | null>;
|
|
47
|
+
put(e: Enrichment): Promise<void>;
|
|
48
|
+
close?(): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class MemoryEnrichmentStore implements EnrichmentStore {
|
|
52
|
+
readonly rows = new Map<string, Enrichment>();
|
|
53
|
+
async get(threadUrl: string, asOf: string) {
|
|
54
|
+
return this.rows.get(`${threadUrl}|${asOf}`) ?? null;
|
|
55
|
+
}
|
|
56
|
+
async put(e: Enrichment) {
|
|
57
|
+
this.rows.set(`${e.thread_url}|${e.as_of}`, e);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const ENRICHMENT_DDL = `
|
|
62
|
+
create table if not exists precedent_enrichment (
|
|
63
|
+
thread_url text not null,
|
|
64
|
+
as_of text not null default '',
|
|
65
|
+
kind text not null,
|
|
66
|
+
raw_ref text not null,
|
|
67
|
+
summary text not null,
|
|
68
|
+
objection text not null,
|
|
69
|
+
objectors text[] not null default '{}',
|
|
70
|
+
key_quote text,
|
|
71
|
+
quote_message_url text,
|
|
72
|
+
model text not null,
|
|
73
|
+
created_at timestamptz not null default now(),
|
|
74
|
+
primary key (thread_url, as_of)
|
|
75
|
+
)`;
|
|
76
|
+
|
|
77
|
+
/** The store in the corpus database (`pgroast`, PGROAST_DSN). Null when it cannot be reached. */
|
|
78
|
+
export async function openPgEnrichmentStore(root: string = REPO_ROOT): Promise<EnrichmentStore | null> {
|
|
79
|
+
const dbPath = join(root, "src", "corpus", "db.ts");
|
|
80
|
+
if (!existsSync(dbPath)) return null;
|
|
81
|
+
try {
|
|
82
|
+
const mod = (await import(dbPath)) as { connect: () => import("bun").SQL };
|
|
83
|
+
const sql = mod.connect();
|
|
84
|
+
await Promise.race([
|
|
85
|
+
sql.unsafe(ENRICHMENT_DDL),
|
|
86
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), 8000)),
|
|
87
|
+
]);
|
|
88
|
+
return {
|
|
89
|
+
async get(threadUrl, asOf) {
|
|
90
|
+
const rows = await sql`
|
|
91
|
+
select thread_url, as_of, kind, raw_ref, summary, objection, array_to_json(objectors)::text as objectors,
|
|
92
|
+
key_quote, quote_message_url, model, created_at::text as created_at
|
|
93
|
+
from precedent_enrichment where thread_url = ${threadUrl} and as_of = ${asOf}`;
|
|
94
|
+
const r = rows[0];
|
|
95
|
+
return r ? ({ ...r, objectors: JSON.parse(r.objectors as string) } as Enrichment) : null;
|
|
96
|
+
},
|
|
97
|
+
async put(e) {
|
|
98
|
+
await sql`
|
|
99
|
+
insert into precedent_enrichment (thread_url, as_of, kind, raw_ref, summary, objection, objectors, key_quote, quote_message_url, model)
|
|
100
|
+
values (${e.thread_url}, ${e.as_of}, ${e.kind}, ${e.raw_ref}, ${e.summary}, ${e.objection},
|
|
101
|
+
array(select jsonb_array_elements_text((${JSON.stringify(e.objectors)}::text)::jsonb)),
|
|
102
|
+
${e.key_quote}, ${e.quote_message_url}, ${e.model})
|
|
103
|
+
on conflict (thread_url, as_of) do update set
|
|
104
|
+
kind = excluded.kind, raw_ref = excluded.raw_ref, summary = excluded.summary, objection = excluded.objection,
|
|
105
|
+
objectors = excluded.objectors, key_quote = excluded.key_quote, quote_message_url = excluded.quote_message_url,
|
|
106
|
+
model = excluded.model, created_at = now()`;
|
|
107
|
+
},
|
|
108
|
+
async close() {
|
|
109
|
+
await sql.close();
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
export const ENRICH_SCHEMA: JsonSchema = {
|
|
120
|
+
type: "object",
|
|
121
|
+
properties: {
|
|
122
|
+
proposal_summary: { type: "string", minLength: 1 },
|
|
123
|
+
why_not_accepted: { type: "string", minLength: 1 },
|
|
124
|
+
objectors: { type: "array", items: { type: "string" } },
|
|
125
|
+
key_quote: { type: "string", nullable: true },
|
|
126
|
+
quote_message_index: { type: "integer", minimum: 1, nullable: true },
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export interface EnrichReply {
|
|
131
|
+
proposal_summary: string;
|
|
132
|
+
why_not_accepted: string;
|
|
133
|
+
objectors: string[];
|
|
134
|
+
key_quote: string | null;
|
|
135
|
+
quote_message_index: number | null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const RELEVANCE_SCHEMA: JsonSchema = {
|
|
139
|
+
type: "object",
|
|
140
|
+
properties: { relevant_ids: { type: "array", items: { type: "string" } } },
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const HEAD = 3;
|
|
144
|
+
const TAIL = 8;
|
|
145
|
+
const MAX_CHARS = 60_000;
|
|
146
|
+
|
|
147
|
+
const normName = (s: string) =>
|
|
148
|
+
displayName(s)
|
|
149
|
+
.replace(/\([^)]*\)/g, "")
|
|
150
|
+
.replace(/\s+/g, " ")
|
|
151
|
+
.trim()
|
|
152
|
+
.toLowerCase();
|
|
153
|
+
|
|
154
|
+
/** First 3 messages, last 8, and every message from the closers; within a size cap. */
|
|
155
|
+
export function selectMessages(messages: ThreadMessage[], closers: string[]): ThreadMessage[] {
|
|
156
|
+
const want = new Set(closers.map(normName).filter(Boolean));
|
|
157
|
+
const pick = new Set<number>();
|
|
158
|
+
messages.forEach((m, i) => {
|
|
159
|
+
if (i < HEAD || i >= messages.length - TAIL || want.has(normName(m.from))) pick.add(i);
|
|
160
|
+
});
|
|
161
|
+
// Over the cap: drop closers' middle messages first (oldest first), then shrink the tail.
|
|
162
|
+
const size = (i: number) => Math.min(messages[i]!.body.length, i === 0 ? 10_000 : 5_000);
|
|
163
|
+
const total = () => [...pick].reduce((n, i) => n + size(i), 0);
|
|
164
|
+
const middle = [...pick].filter((i) => i >= HEAD && i < messages.length - TAIL).sort((a, b) => a - b);
|
|
165
|
+
while (total() > MAX_CHARS && middle.length) pick.delete(middle.shift()!);
|
|
166
|
+
const tail = [...pick].filter((i) => i >= HEAD).sort((a, b) => a - b);
|
|
167
|
+
while (total() > MAX_CHARS && tail.length > 2) pick.delete(tail.shift()!);
|
|
168
|
+
return [...pick].sort((a, b) => a - b).map((i) => messages[i]!);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function renderThreadForEnrichment(p: Precedent, shown: ThreadMessage[], totalVisible: number): string {
|
|
172
|
+
const out: string[] = [
|
|
173
|
+
`Entry: ${p.title}`,
|
|
174
|
+
`Kind: ${p.kind}; outcome: ${p.status || "?"}; decided: ${p.date?.slice(0, 10) ?? "unknown"}; thread started: ${p.first_date?.slice(0, 10) ?? "unknown"}`,
|
|
175
|
+
`Messages shown: ${shown.length} of ${totalVisible}.`,
|
|
176
|
+
"",
|
|
177
|
+
];
|
|
178
|
+
let prev = 0;
|
|
179
|
+
for (const m of shown) {
|
|
180
|
+
if (m.index > prev + 1) out.push(`[... ${m.index - prev - 1} message(s) not shown ...]`, "");
|
|
181
|
+
prev = m.index;
|
|
182
|
+
const body = m.index === 1 ? m.body.slice(0, 10_000) : trimQuotes(m.body).slice(0, 5_000);
|
|
183
|
+
out.push(`### Message #${m.index} | ${displayName(m.from)} | ${m.date.slice(0, 10)} | ${m.subject}`, "", body, "");
|
|
184
|
+
}
|
|
185
|
+
return out.join("\n");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The body without quoted lines: a quote must be the message author's own words. */
|
|
189
|
+
export function ownText(body: string): string {
|
|
190
|
+
return body
|
|
191
|
+
.split("\n")
|
|
192
|
+
.filter((l) => !/^\s*>/.test(l))
|
|
193
|
+
.join("\n");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Check the model's record against the thread. Pure code. */
|
|
197
|
+
export function checkEnrichment(reply: EnrichReply, messages: ThreadMessage[]): { objectors: string[]; key_quote: string | null; quote_message_url: string | null; dropped: string[] } {
|
|
198
|
+
const dropped: string[] = [];
|
|
199
|
+
const senders = new Map(messages.map((m) => [normName(m.from), displayName(m.from)]));
|
|
200
|
+
const objectors: string[] = [];
|
|
201
|
+
for (const o of reply.objectors) {
|
|
202
|
+
const real = senders.get(normName(o));
|
|
203
|
+
if (real) {
|
|
204
|
+
if (!objectors.includes(real)) objectors.push(real);
|
|
205
|
+
} else dropped.push(`objector "${o}" is not a sender in the thread`);
|
|
206
|
+
}
|
|
207
|
+
let key_quote: string | null = null;
|
|
208
|
+
let quote_message_url: string | null = null;
|
|
209
|
+
if (reply.key_quote) {
|
|
210
|
+
const msg = messages.find((m) => m.index === reply.quote_message_index);
|
|
211
|
+
const ok = msg ? verifyQuote(reply.key_quote.replace(/\.\.\.|\[\.\.\.\]|…/g, " \u0000 "), [ownText(msg.body)]) : null;
|
|
212
|
+
if (msg && ok) {
|
|
213
|
+
key_quote = reply.key_quote.trim().replace(/^["“]+|["”]+$/g, "");
|
|
214
|
+
quote_message_url = msg.url;
|
|
215
|
+
} else dropped.push(msg ? "quote not found verbatim in the named message" : "quote names a message that was not shown");
|
|
216
|
+
}
|
|
217
|
+
return { objectors, key_quote, quote_message_url, dropped };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface EnrichOptions {
|
|
221
|
+
backend: LlmBackend;
|
|
222
|
+
model: string;
|
|
223
|
+
system: string;
|
|
224
|
+
store: EnrichmentStore | null;
|
|
225
|
+
/** At most this many precedents are enriched per run. */
|
|
226
|
+
max?: number;
|
|
227
|
+
/** Evaluation mode: only dated precedents are enriched. */
|
|
228
|
+
evalMode: boolean;
|
|
229
|
+
/** Message-ids of the proposal's own thread(s): never read here. */
|
|
230
|
+
selfMessageIds: string[];
|
|
231
|
+
offline?: boolean;
|
|
232
|
+
/** Injected for tests. */
|
|
233
|
+
loadThreadFn?: (messageId: string) => Promise<Thread>;
|
|
234
|
+
closersFn?: (p: Precedent) => Promise<string[]>;
|
|
235
|
+
log?: (m: string) => void;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface EnrichStats {
|
|
239
|
+
candidates: number;
|
|
240
|
+
from_cache: number;
|
|
241
|
+
new_calls: number;
|
|
242
|
+
failed: number;
|
|
243
|
+
quotes_verified: number;
|
|
244
|
+
quotes_dropped: number;
|
|
245
|
+
objectors_dropped: number;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function closersOf(p: Precedent, offline?: boolean): Promise<string[]> {
|
|
249
|
+
const m = p.source_id.match(/^cf:(\d+)$/);
|
|
250
|
+
if (!m) return [];
|
|
251
|
+
try {
|
|
252
|
+
const cf = await loadCfEntry(Number(m[1]), { offline });
|
|
253
|
+
const who = cf.history.filter((h) => /^Closed in commitfest/i.test(h.what)).map((h) => h.who);
|
|
254
|
+
return [...who, ...cf.committer.split(","), ...cf.reviewers.split(",")].map((s) => s.trim()).filter(Boolean);
|
|
255
|
+
} catch {
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function apply(p: Precedent, e: Enrichment): void {
|
|
261
|
+
p.enriched = true;
|
|
262
|
+
if (!p.summary.trim() || p.summary.trim().startsWith(p.title)) p.summary = e.summary;
|
|
263
|
+
p.objection = e.objection;
|
|
264
|
+
if (!p.objectors.trim()) p.objectors = e.objectors.join(", ");
|
|
265
|
+
p.key_quote = e.key_quote;
|
|
266
|
+
p.quote_message_url = e.quote_message_url;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Enrich, in place, the first `max` precedents that have a thread and no recorded reason. */
|
|
270
|
+
export async function enrichPrecedents(precedents: Precedent[], o: EnrichOptions): Promise<{ stats: EnrichStats; warnings: string[] }> {
|
|
271
|
+
const log = o.log ?? (() => undefined);
|
|
272
|
+
const warnings: string[] = [];
|
|
273
|
+
const self = new Set(o.selfMessageIds);
|
|
274
|
+
const todo = precedents
|
|
275
|
+
.filter((p) => !p.objection.trim() && p.thread_urls.length > 0)
|
|
276
|
+
.filter((p) => !(o.evalMode && !p.date))
|
|
277
|
+
.filter((p) => !p.thread_urls.some((u) => self.has(messageIdOfUrl(u))))
|
|
278
|
+
.slice(0, o.max ?? 8);
|
|
279
|
+
const stats: EnrichStats = { candidates: todo.length, from_cache: 0, new_calls: 0, failed: 0, quotes_verified: 0, quotes_dropped: 0, objectors_dropped: 0 };
|
|
280
|
+
|
|
281
|
+
await Promise.all(
|
|
282
|
+
todo.map(async (p) => {
|
|
283
|
+
const threadUrl = p.thread_urls[0]!;
|
|
284
|
+
const asOf = p.date?.slice(0, 10) ?? "";
|
|
285
|
+
try {
|
|
286
|
+
const cached = await o.store?.get(threadUrl, asOf);
|
|
287
|
+
if (cached) {
|
|
288
|
+
stats.from_cache++;
|
|
289
|
+
apply(p, cached);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const thread = await (o.loadThreadFn ?? ((id: string) => loadThread(id, { offline: o.offline })))(messageIdOfUrl(threadUrl));
|
|
293
|
+
const visible = asOf ? thread.messages.filter((m) => m.date !== "" && m.date.slice(0, 10) <= asOf) : thread.messages;
|
|
294
|
+
if (visible.length === 0) throw new Error("no message dated on or before the decision date");
|
|
295
|
+
const closers = await (o.closersFn ?? ((x: Precedent) => closersOf(x, o.offline)))(p);
|
|
296
|
+
const shown = selectMessages(visible, closers);
|
|
297
|
+
stats.new_calls++;
|
|
298
|
+
const reply = await o.backend.complete<EnrichReply>({
|
|
299
|
+
label: `enrich-${p.id}`,
|
|
300
|
+
model: o.model,
|
|
301
|
+
system: o.system,
|
|
302
|
+
prompt: `${renderThreadForEnrichment(p, shown, visible.length)}\n\n# Task\n\nRecord why this proposal was not accepted. JSON only.`,
|
|
303
|
+
schema: ENRICH_SCHEMA,
|
|
304
|
+
});
|
|
305
|
+
const chk = checkEnrichment(reply, shown);
|
|
306
|
+
if (reply.key_quote) chk.key_quote ? stats.quotes_verified++ : stats.quotes_dropped++;
|
|
307
|
+
stats.objectors_dropped += reply.objectors.length - chk.objectors.length;
|
|
308
|
+
const e: Enrichment = {
|
|
309
|
+
thread_url: threadUrl,
|
|
310
|
+
raw_ref: p.source_id,
|
|
311
|
+
kind: p.kind,
|
|
312
|
+
as_of: asOf,
|
|
313
|
+
summary: reply.proposal_summary.trim(),
|
|
314
|
+
objection: reply.why_not_accepted.trim(),
|
|
315
|
+
objectors: chk.objectors,
|
|
316
|
+
key_quote: chk.key_quote,
|
|
317
|
+
quote_message_url: chk.quote_message_url,
|
|
318
|
+
model: o.model,
|
|
319
|
+
created_at: new Date().toISOString(),
|
|
320
|
+
};
|
|
321
|
+
try {
|
|
322
|
+
await o.store?.put(e);
|
|
323
|
+
} catch (err) {
|
|
324
|
+
warnings.push(`could not store the enrichment of ${p.source_id}: ${(err as Error).message.split("\n")[0]}`);
|
|
325
|
+
}
|
|
326
|
+
apply(p, e);
|
|
327
|
+
log(` enriched ${p.id} (${p.source_id}): ${visible.length} messages, quote ${chk.key_quote ? "verified" : "none"}`);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
stats.failed++;
|
|
330
|
+
warnings.push(`precedent ${p.id} (${p.source_id}) could not be enriched: ${(err as Error).message.split("\n")[0]}`);
|
|
331
|
+
}
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
return { stats, warnings };
|
|
335
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Subsystem invariants (invariants/*.md): rules harvested from senior reviewers, each with a
|
|
2
|
+
// source. A file is given to the roasters when one of its `paths:` prefixes matches a file the
|
|
3
|
+
// patch touches. The files are included verbatim; their Source links are allowed in the output
|
|
4
|
+
// because they are read from the file here, never taken from model output.
|
|
5
|
+
|
|
6
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { REPO_ROOT } from "../thread/fetch.ts";
|
|
9
|
+
import { extractUrls } from "./sanitize.ts";
|
|
10
|
+
|
|
11
|
+
export interface InvariantFile {
|
|
12
|
+
/** Path relative to the repository, e.g. "invariants/nbtree.md". */
|
|
13
|
+
file: string;
|
|
14
|
+
subsystem: string;
|
|
15
|
+
paths: string[];
|
|
16
|
+
/** The whole file, verbatim. */
|
|
17
|
+
text: string;
|
|
18
|
+
sourceUrls: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseInvariantFile(file: string, text: string): InvariantFile | null {
|
|
22
|
+
const m = text.match(/^---\n([\s\S]*?)\n---\n/);
|
|
23
|
+
if (!m) return null;
|
|
24
|
+
const front = m[1]!;
|
|
25
|
+
const subsystem = front.match(/^subsystem:\s*(.+)$/m)?.[1]?.trim() ?? "";
|
|
26
|
+
const paths: string[] = [];
|
|
27
|
+
let inPaths = false;
|
|
28
|
+
for (const line of front.split("\n")) {
|
|
29
|
+
if (/^paths:\s*$/.test(line)) inPaths = true;
|
|
30
|
+
else if (inPaths && /^\s+-\s+/.test(line)) paths.push(line.replace(/^\s+-\s+/, "").trim().replace(/^["']|["']$/g, ""));
|
|
31
|
+
else if (/^\S/.test(line)) inPaths = false;
|
|
32
|
+
}
|
|
33
|
+
if (paths.length === 0) return null;
|
|
34
|
+
return { file, subsystem, paths, text: text.trim(), sourceUrls: [...new Set(extractUrls(text))] };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function loadInvariantFiles(dir: string = join(REPO_ROOT, "invariants")): InvariantFile[] {
|
|
38
|
+
if (!existsSync(dir)) return [];
|
|
39
|
+
return readdirSync(dir)
|
|
40
|
+
.filter((f) => f.endsWith(".md") && f.toLowerCase() !== "readme.md")
|
|
41
|
+
.sort()
|
|
42
|
+
.map((f) => parseInvariantFile(`invariants/${f}`, readFileSync(join(dir, f), "utf8")))
|
|
43
|
+
.filter((x): x is InvariantFile => x != null);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The invariant files whose path prefixes match at least one touched file. None without a patch. */
|
|
47
|
+
export function selectInvariants(touched: string[], all: InvariantFile[]): InvariantFile[] {
|
|
48
|
+
return all.filter((inv) => inv.paths.some((p) => touched.some((t) => t === p || t.startsWith(p))));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function renderInvariants(files: InvariantFile[]): string {
|
|
52
|
+
if (files.length === 0) return "";
|
|
53
|
+
return [
|
|
54
|
+
"## Subsystem invariants to check explicitly",
|
|
55
|
+
"",
|
|
56
|
+
"The patch touches files of the subsystems below. These are rules harvested from senior reviewers'",
|
|
57
|
+
"feedback, each with its source. They are exactly the kind of rule that is easy to miss. For each",
|
|
58
|
+
'one ask: "does the proposal say how it upholds this?" If the material does not say, and the rule',
|
|
59
|
+
"applies to what the proposal changes, that is an objection (level 2, rubric item 7), and it cites",
|
|
60
|
+
"the invariant's Source link as written in the entry. If the rule does not apply to this proposal,",
|
|
61
|
+
"or the material shows it is upheld, raise nothing.",
|
|
62
|
+
"",
|
|
63
|
+
...files.map((f) => `### ${f.file}\n\n${f.text}`),
|
|
64
|
+
].join("\n");
|
|
65
|
+
}
|