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.
Files changed (67) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +173 -2
  3. package/bin/pgroast.mjs +19 -0
  4. package/data/precedents.sql.gz +0 -0
  5. package/docs/corpus.md +352 -0
  6. package/docs/design.md +540 -0
  7. package/docs/eval.md +245 -0
  8. package/docs/haas-paranoia-notes.md +223 -0
  9. package/invariants/README.md +15 -0
  10. package/invariants/nbtree.md +14 -0
  11. package/package.json +51 -4
  12. package/prompts/brief.md +32 -0
  13. package/prompts/committer-paranoia.md +149 -0
  14. package/prompts/critic.md +56 -0
  15. package/prompts/enrich.md +35 -0
  16. package/prompts/pushback-map.md +58 -0
  17. package/prompts/pushback-reduce.md +72 -0
  18. package/prompts/reflect.md +20 -0
  19. package/prompts/relevance.md +20 -0
  20. package/prompts/roaster-already-exists.md +23 -0
  21. package/prompts/roaster-common.md +88 -0
  22. package/prompts/roaster-demand.md +28 -0
  23. package/prompts/roaster-maintenance-fit.md +22 -0
  24. package/prompts/roaster-prior-attempts.md +29 -0
  25. package/prompts/roaster-replication-ondisk.md +24 -0
  26. package/prompts/roaster-security.md +21 -0
  27. package/prompts/roaster-standards-compat.md +21 -0
  28. package/prompts/supervisor.md +31 -0
  29. package/prompts/synth-self.md +19 -0
  30. package/prompts/synth.md +140 -0
  31. package/prompts/tone.md +21 -0
  32. package/prompts/writing.md +98 -0
  33. package/sql/schema.sql +45 -0
  34. package/src/cli.ts +391 -0
  35. package/src/corpus/cf.ts +220 -0
  36. package/src/corpus/cli.ts +189 -0
  37. package/src/corpus/db.ts +119 -0
  38. package/src/corpus/fetch.ts +104 -0
  39. package/src/corpus/reverts.ts +133 -0
  40. package/src/corpus/search.ts +201 -0
  41. package/src/corpus/util.ts +133 -0
  42. package/src/corpus/wiki.ts +80 -0
  43. package/src/eval/cases.ts +118 -0
  44. package/src/eval/cli.ts +105 -0
  45. package/src/eval/judge.ts +136 -0
  46. package/src/eval/report.ts +268 -0
  47. package/src/eval/run.ts +153 -0
  48. package/src/eval/score.ts +275 -0
  49. package/src/roast/doctor.ts +109 -0
  50. package/src/roast/enrich.ts +335 -0
  51. package/src/roast/invariants.ts +65 -0
  52. package/src/roast/llm.ts +601 -0
  53. package/src/roast/material.ts +133 -0
  54. package/src/roast/page.ts +266 -0
  55. package/src/roast/pipeline.ts +981 -0
  56. package/src/roast/precedents.ts +308 -0
  57. package/src/roast/pushback.ts +664 -0
  58. package/src/roast/render.ts +386 -0
  59. package/src/roast/sanitize.ts +140 -0
  60. package/src/roast/schema.ts +714 -0
  61. package/src/roast/tone.ts +216 -0
  62. package/src/thread/fetch.ts +172 -0
  63. package/src/thread/load.ts +227 -0
  64. package/src/thread/local.ts +73 -0
  65. package/src/thread/parse.ts +243 -0
  66. package/src/thread/patchprose.ts +143 -0
  67. package/src/thread/types.ts +120 -0
@@ -0,0 +1,216 @@
1
+ // Tone lint: a deterministic check over every prose field of a roast, before files are
2
+ // written. What a person may forward must read as a calm, respectful, evidence-first review.
3
+ // On a hit, one repair call asks the synthesis model to rephrase only the flagged sentences;
4
+ // what is still flagged afterwards is listed in roast.json (`tone_lint.remaining`).
5
+ //
6
+ // Verbatim quotes from the archives are not prose of ours and are not checked.
7
+
8
+ import type { LlmBackend } from "./llm.ts";
9
+ import { scrubUrls, type AllowedUrls } from "./sanitize.ts";
10
+ import { TONE_REPAIR_SCHEMA, type SanitizerReport, type FinalObjection, type Roast, type ToneHit, type ToneLint } from "./schema.ts";
11
+
12
+ export interface ToneRule {
13
+ id: string;
14
+ re: RegExp;
15
+ }
16
+
17
+ const words = (id: string, list: string[]): ToneRule => ({
18
+ id,
19
+ re: new RegExp(`\\b(?:${list.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/ /g, "\\s+")).join("|")})\\b`, "i"),
20
+ });
21
+
22
+ export const TONE_RULES: ToneRule[] = [
23
+ words("intensifier", [
24
+ "obviously", "clearly", "simply", "of course", "trivially", "needless to say", "plainly wrong",
25
+ "any competent", "everyone knows", "everybody knows", "it goes without saying", "no-brainer", "by definition wrong",
26
+ ]),
27
+ words("hyperbole", [
28
+ "nonsense", "ridiculous", "absurd", "laughable", "insane", "crazy", "stupid", "dumb", "idiotic", "garbage", "terrible",
29
+ "horrible", "awful", "atrocious", "disaster", "disastrous", "nightmare", "horror story", "abomination",
30
+ "hopeless", "worthless", "useless", "a joke", "dead on arrival", "doomed", "utterly", "completely broken", "totally",
31
+ ]),
32
+ words("sarcasm", [
33
+ "good luck", "nice try", "yeah right", "surprise surprise", "surprise, surprise", "so-called", "lol", "sigh", "oh great",
34
+ "what could possibly go wrong", "who would have thought", "brilliant idea", "bless", "pray tell", "hand-waving", "handwaving",
35
+ "hand-wavy", "handwavy",
36
+ ]),
37
+ words("roast vocabulary", ["roast", "roasts", "roasted", "roasting", "roaster", "roasters", "beat down", "beatdown", "beat-down", "takedown", "smackdown", "rejector", "kill the patch", "shoot down", "shot down", "tear apart", "torn apart"]),
38
+ words("about the author", [
39
+ "you failed to", "you forgot", "you neglected", "you ignored", "you did not bother", "you didn't bother", "you clearly", "you should have known",
40
+ "the author failed", "the author fails", "the author forgot", "the author neglected", "the author ignored", "the author ignores",
41
+ "the author did not bother", "the author didn't bother", "the author does not understand", "the author doesn't understand",
42
+ "the author is unaware", "the author has no idea", "the author never bothered", "the author is wrong", "authors failed",
43
+ "lazy", "sloppy", "amateur", "amateurish", "incompetent", "clueless", "ignorant",
44
+ ]),
45
+ // Writing rules (prompts/writing.md): filler, stock review phrases, words with a simpler form.
46
+ words("filler", [
47
+ "importantly", "notably", "crucially", "essentially", "basically", "it is worth noting", "it's worth noting", "worth noting that",
48
+ "it should be noted", "needless to say", "as such", "at the end of the day", "here is the catch", "here's the catch",
49
+ ]),
50
+ words("stock phrase", ["the material does not describe", "nothing in the material", "remains unclear", "raises the question", "it is not specified"]),
51
+ words("simpler word exists", ["utilize", "utilizes", "utilized", "utilizing", "utilization", "facilitate", "facilitates", "facilitated", "in order to", "approximately", "leverage", "leverages", "leveraging"]),
52
+ // "!" is fine in "!=" and inside `code`; code spans are removed before the check.
53
+ { id: "exclamation mark", re: /!(?!=)/ },
54
+ ];
55
+
56
+ /** Rule id for an objection's question to the author that is not a question. */
57
+ export const NO_QUESTION = "does not end in a question";
58
+
59
+ const stripCode = (s: string) => s.replace(/`[^`]*`/g, (m) => " ".repeat(m.length));
60
+
61
+ /** Sentences of a text, as substrings of it (so a rewrite can replace exactly one). */
62
+ export function sentences(text: string): string[] {
63
+ return text
64
+ .split(/(?<=[.?!]["')\]]*)\s+|\n+/)
65
+ .map((x) => x.trim())
66
+ .filter((x) => x.length > 0);
67
+ }
68
+
69
+ export function lintText(text: string, path = ""): ToneHit[] {
70
+ const hits: ToneHit[] = [];
71
+ for (const sentence of sentences(text)) {
72
+ const bare = stripCode(sentence);
73
+ for (const rule of TONE_RULES) {
74
+ const m = bare.match(rule.re);
75
+ if (m) hits.push({ path, rule: rule.id, match: m[0], sentence });
76
+ }
77
+ }
78
+ return hits;
79
+ }
80
+
81
+ export interface ProseField {
82
+ path: string;
83
+ get(): string;
84
+ set(v: string): void;
85
+ /** The objection's question to the author: must end with "?". */
86
+ question?: boolean;
87
+ }
88
+
89
+ /** Every prose field a model wrote and a person may read. Quotes from the archives are left out. */
90
+ export function proseFields(r: Roast): ProseField[] {
91
+ const out: ProseField[] = [];
92
+ const add = <T extends object>(obj: T, key: keyof T, path: string, question = false) => {
93
+ if (typeof obj[key] === "string") out.push({ path, get: () => obj[key] as string, set: (v) => void ((obj[key] as string) = v), question });
94
+ };
95
+ const list = (arr: string[], path: string) => arr.forEach((_, i) => out.push({ path: `${path}[${i}]`, get: () => arr[i]!, set: (v) => void (arr[i] = v) }));
96
+ const objection = (o: FinalObjection, path: string, ranked: boolean) => {
97
+ add(o, "title", `${path}.title`);
98
+ add(o, "failure_scenario", `${path}.failure_scenario`);
99
+ add(o, "argument", `${path}.argument`);
100
+ add(o, "author_must_show", `${path}.author_must_show`, ranked);
101
+ add(o.critic, "reasoning", `${path}.critic.reasoning`);
102
+ if (o.not_ranked_reason != null) add(o, "not_ranked_reason", `${path}.not_ranked_reason`);
103
+ if (o.withdrawal) add(o.withdrawal, "reason", `${path}.withdrawal.reason`);
104
+ };
105
+
106
+ add(r, "verdict_reasoning", "verdict_reasoning");
107
+ add(r, "provisional_note", "provisional_note");
108
+ add(r.brief, "problem", "brief.problem");
109
+ add(r.brief, "proposed_design", "brief.proposed_design");
110
+ add(r.brief, "thread_state", "brief.thread_state");
111
+ list(r.brief.user_visible_surface, "brief.user_visible_surface");
112
+ list(r.brief.author_claims, "brief.author_claims");
113
+ list(r.questions, "questions");
114
+ r.objections.forEach((o, i) => objection(o, `objections[${i}]`, true));
115
+ r.other_surviving.forEach((o, i) => objection(o, `other_surviving[${i}]`, true));
116
+ r.refuted.forEach((o, i) => objection(o, `refuted[${i}]`, false));
117
+ r.withdrawn.forEach((o, i) => objection(o, `withdrawn[${i}]`, false));
118
+ r.prior_attempts.forEach((p, i) => {
119
+ add(p, "what_is_different", `prior_attempts[${i}].what_is_different`);
120
+ if (p.enriched) add(p, "reason", `prior_attempts[${i}].reason`); // an AI summary, not a quote
121
+ });
122
+ r.touch_points.forEach((t, i) => add(t, "reason", `touch_points[${i}].reason`));
123
+ add(r.committability, "owner_note", "committability.owner_note");
124
+ add(r.committability, "liability", "committability.liability");
125
+ add(r.committability, "author_follow_up", "committability.author_follow_up");
126
+ add(r.not_now, "evidence_that_would_change_it", "not_now.evidence_that_would_change_it");
127
+ add(r.priority, "reason", "priority.reason");
128
+ r.self_advice.forEach((a, i) => add(a, "how", `self_advice[${i}].how`));
129
+ r.roaster_notes.forEach((n, i) => add(n, "note", `roaster_notes[${i}].note`));
130
+ r.rounds.forEach((x, i) => add(x, "note", `rounds[${i}].note`));
131
+ return out;
132
+ }
133
+
134
+ function lintField(f: ProseField): ToneHit[] {
135
+ const text = f.get();
136
+ const hits = lintText(text, f.path);
137
+ if (f.question && text.trim() && !/\?["')\]]*\s*$/.test(text)) {
138
+ const all = sentences(text);
139
+ hits.push({ path: f.path, rule: NO_QUESTION, match: "", sentence: all[all.length - 1] ?? text });
140
+ }
141
+ return hits;
142
+ }
143
+
144
+ /** All hits in a roast. Pure: no model, no change. */
145
+ export function lintRoast(r: Roast): { fields: number; hits: ToneHit[] } {
146
+ const fields = proseFields(r);
147
+ return { fields: fields.length, hits: fields.flatMap(lintField) };
148
+ }
149
+
150
+ export interface ToneRepairOptions {
151
+ backend: LlmBackend;
152
+ model: string;
153
+ /** System prompt (prompts/tone.md plus the output contract). */
154
+ system: string;
155
+ allowed: AllowedUrls;
156
+ report: SanitizerReport;
157
+ log?: (m: string) => void;
158
+ }
159
+
160
+ /**
161
+ * Lint; on hits, one repair call for the flagged sentences only; lint again. Changes the roast
162
+ * in place and returns the record for roast.json. Without `repair` (or in a dry run) it only lints.
163
+ */
164
+ export function toneCheck(r: Roast, repair?: ToneRepairOptions): Promise<ToneLint> {
165
+ return toneCheckFields(() => proseFields(r), repair);
166
+ }
167
+
168
+ /** The same check over any set of prose fields (the pushback report uses it too). */
169
+ export async function toneCheckFields(getFields: () => ProseField[], repair?: ToneRepairOptions): Promise<ToneLint> {
170
+ const lint = () => {
171
+ const fields = getFields();
172
+ return { fields: fields.length, hits: fields.flatMap(lintField) };
173
+ };
174
+ const first = lint();
175
+ const result: ToneLint = { fields_checked: first.fields, hits_found: first.hits.length, sentences_rewritten: 0, repair_call: "not needed", remaining: first.hits };
176
+ if (first.hits.length === 0) return result;
177
+ if (!repair || repair.backend.dryRun) return { ...result, repair_call: "skipped" };
178
+
179
+ // One item per flagged sentence of a field.
180
+ const fields = new Map(getFields().map((f) => [f.path, f]));
181
+ const items: { n: number; path: string; sentence: string; flagged: string[] }[] = [];
182
+ for (const h of first.hits) {
183
+ const item = items.find((x) => x.path === h.path && x.sentence === h.sentence);
184
+ const why = h.rule === NO_QUESTION ? "must end with one question the author can answer" : `${h.rule}: "${h.match}"`;
185
+ if (item) item.flagged.push(why);
186
+ else items.push({ n: items.length + 1, path: h.path, sentence: h.sentence, flagged: [why] });
187
+ }
188
+ try {
189
+ const reply = scrubUrls(
190
+ await repair.backend.complete<{ rewrites: { n: number; text: string }[] }>({
191
+ label: "tone-repair",
192
+ model: repair.model,
193
+ system: repair.system,
194
+ prompt: `# Flagged sentences\n\n${JSON.stringify(items.map(({ n, sentence, flagged }) => ({ n, sentence, flagged })), null, 1)}\n\n# Task\n\nRewrite each flagged sentence. One rewrite per number. JSON only.`,
195
+ schema: TONE_REPAIR_SCHEMA,
196
+ }),
197
+ repair.allowed,
198
+ "tone-repair",
199
+ repair.report,
200
+ );
201
+ for (const rw of reply.rewrites) {
202
+ const item = items.find((x) => x.n === rw.n);
203
+ const f = item && fields.get(item.path);
204
+ const text = rw.text.replace(/\s+/g, " ").trim();
205
+ if (!item || !f || !text || !f.get().includes(item.sentence)) continue;
206
+ f.set(f.get().replace(item.sentence, () => text));
207
+ result.sentences_rewritten++;
208
+ }
209
+ result.repair_call = "done";
210
+ } catch (e) {
211
+ repair.log?.(`tone repair failed: ${(e as Error).message.split("\n")[0]}`);
212
+ result.repair_call = "failed";
213
+ }
214
+ result.remaining = lint().hits;
215
+ return result;
216
+ }
@@ -0,0 +1,172 @@
1
+ // Polite, read-only HTTP GET with an on-disk cache.
2
+ //
3
+ // Rules (see docs/design.md):
4
+ // - GET only. No cookies, no credentials, no POST: this module cannot post anything anywhere.
5
+ // - User-Agent "pgroast/0.1".
6
+ // - At least MIN_INTERVAL_MS between requests to one host.
7
+ // - A cached URL is never fetched again (404s are cached as well).
8
+ // - 429 / 5xx / network errors: retry with exponential backoff, honouring Retry-After.
9
+
10
+ import { createHash } from "node:crypto";
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join, resolve } from "node:path";
14
+
15
+ export const USER_AGENT = "pgroast/0.1";
16
+ export const MIN_INTERVAL_MS = 1100;
17
+ export const REPO_ROOT = resolve(import.meta.dir, "..", "..");
18
+ /** True when running from an installed package (npx, bunx, global install), not from a clone. */
19
+ export const INSTALLED = /[\\/]node_modules[\\/]/.test(REPO_ROOT);
20
+ /**
21
+ * Where fetched pages are cached: PGROAST_CACHE_DIR; else data/cache in a clone; else the user's
22
+ * cache directory, because a package directory is not a place to keep data.
23
+ */
24
+ export const CACHE_ROOT = process.env.PGROAST_CACHE_DIR
25
+ ? resolve(process.env.PGROAST_CACHE_DIR)
26
+ : INSTALLED
27
+ ? join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "pgroast")
28
+ : join(REPO_ROOT, "data", "cache");
29
+ export const DEFAULT_CACHE_DIR = join(CACHE_ROOT, "threads");
30
+ /** Where results go by default: out/ in a clone, ./pgroast-out in the current directory when installed. */
31
+ export const DEFAULT_OUT_ROOT = INSTALLED ? resolve("pgroast-out") : join(REPO_ROOT, "out");
32
+
33
+ /** Hosts this tool is allowed to read. Anything else is refused. */
34
+ export const ALLOWED_HOSTS = new Set(["www.postgresql.org", "postgr.es", "commitfest.postgresql.org"]);
35
+
36
+ export interface FetchOptions {
37
+ cacheDir?: string;
38
+ /** Never touch the network; a cache miss returns null. */
39
+ offline?: boolean;
40
+ maxTries?: number;
41
+ /** Refuse bodies larger than this (bytes). The body is not cached when refused. */
42
+ maxBytes?: number;
43
+ /** Injected for tests. */
44
+ fetchImpl?: typeof fetch;
45
+ sleepImpl?: (ms: number) => Promise<void>;
46
+ log?: (msg: string) => void;
47
+ }
48
+
49
+ export interface FetchStats {
50
+ fetched: number;
51
+ cached: number;
52
+ notFound: number;
53
+ failed: number;
54
+ }
55
+
56
+ export const stats: FetchStats = { fetched: 0, cached: 0, notFound: 0, failed: 0 };
57
+
58
+ const lastRequestAt = new Map<string, number>();
59
+ const hostQueue = new Map<string, Promise<unknown>>();
60
+
61
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
62
+
63
+ /** Map a URL to a stable file path under <cacheDir>/<host>/. */
64
+ export function cachePath(url: string, cacheDir: string = DEFAULT_CACHE_DIR): string {
65
+ const u = new URL(url);
66
+ let name = (u.pathname.replace(/^\/+|\/+$/g, "") || "_root").replace(/[^A-Za-z0-9._-]+/g, "_");
67
+ if (u.search) name += "__" + u.search.slice(1).replace(/[^A-Za-z0-9._=-]+/g, "_");
68
+ if (name.length > 150) {
69
+ name = name.slice(0, 100) + "_" + createHash("sha1").update(name).digest("hex").slice(0, 16);
70
+ }
71
+ return join(cacheDir, u.host, name);
72
+ }
73
+
74
+ function parseRetryAfter(v: string | null): number | null {
75
+ if (!v) return null;
76
+ if (/^\d+$/.test(v.trim())) return Number(v.trim()) * 1000;
77
+ const t = Date.parse(v);
78
+ return Number.isNaN(t) ? null : Math.max(0, t - Date.now());
79
+ }
80
+
81
+ /**
82
+ * GET a URL as text. Returns null for 404, for an offline cache miss, for an oversized body,
83
+ * and after all retries failed.
84
+ */
85
+ export async function fetchText(url: string, opts: FetchOptions = {}): Promise<string | null> {
86
+ const u = new URL(url);
87
+ if (u.protocol !== "https:" || !ALLOWED_HOSTS.has(u.host)) {
88
+ throw new Error(`refusing to fetch ${url}: host not in the read-only allow list`);
89
+ }
90
+ const p = cachePath(url, opts.cacheDir);
91
+ if (existsSync(p)) {
92
+ stats.cached++;
93
+ return readFileSync(p, "utf8");
94
+ }
95
+ if (existsSync(p + ".404")) {
96
+ stats.notFound++;
97
+ return null;
98
+ }
99
+ if (opts.offline) return null;
100
+
101
+ // Serialise requests per host so the minimum interval holds under concurrency.
102
+ const prev = hostQueue.get(u.host) ?? Promise.resolve();
103
+ const run = prev.then(() => fetchUncached(url, p, opts));
104
+ hostQueue.set(
105
+ u.host,
106
+ run.catch(() => undefined),
107
+ );
108
+ return run;
109
+ }
110
+
111
+ async function fetchUncached(url: string, p: string, opts: FetchOptions): Promise<string | null> {
112
+ const host = new URL(url).host;
113
+ const doFetch = opts.fetchImpl ?? fetch;
114
+ const doSleep = opts.sleepImpl ?? sleep;
115
+ const log = opts.log ?? ((m: string) => console.error(m));
116
+ const maxTries = opts.maxTries ?? 5;
117
+ let delay = 5000;
118
+
119
+ for (let attempt = 0; attempt < maxTries; attempt++) {
120
+ const wait = (lastRequestAt.get(host) ?? 0) + MIN_INTERVAL_MS - Date.now();
121
+ if (wait > 0) await doSleep(wait);
122
+ lastRequestAt.set(host, Date.now());
123
+ try {
124
+ const res = await doFetch(url, {
125
+ method: "GET",
126
+ headers: { "User-Agent": USER_AGENT, Accept: "text/html,text/plain,*/*" },
127
+ redirect: "follow",
128
+ credentials: "omit",
129
+ signal: AbortSignal.timeout(60_000),
130
+ });
131
+ if (res.status === 404 || res.status === 410) {
132
+ mkdirSync(dirname(p), { recursive: true });
133
+ writeFileSync(p + ".404", "");
134
+ stats.notFound++;
135
+ return null;
136
+ }
137
+ if (res.status === 429 || res.status >= 500) {
138
+ const ra = parseRetryAfter(res.headers.get("retry-after"));
139
+ const pause = Math.min(ra ?? delay, 300_000);
140
+ log(` HTTP ${res.status} on ${url}; backing off ${Math.round(pause / 1000)}s`);
141
+ await doSleep(pause);
142
+ delay = Math.min(delay * 2, 300_000);
143
+ continue;
144
+ }
145
+ if (!res.ok) {
146
+ log(` HTTP ${res.status} on ${url}; giving up`);
147
+ break;
148
+ }
149
+ const len = Number(res.headers.get("content-length") ?? "0");
150
+ if (opts.maxBytes && len > opts.maxBytes) {
151
+ log(` ${url}: ${len} bytes, over the ${opts.maxBytes} limit; skipped`);
152
+ return null;
153
+ }
154
+ const body = await res.text();
155
+ if (opts.maxBytes && body.length > opts.maxBytes) {
156
+ log(` ${url}: ${body.length} bytes, over the ${opts.maxBytes} limit; skipped`);
157
+ return null;
158
+ }
159
+ mkdirSync(dirname(p), { recursive: true });
160
+ writeFileSync(p + ".tmp", body);
161
+ renameSync(p + ".tmp", p);
162
+ stats.fetched++;
163
+ return body;
164
+ } catch (e) {
165
+ log(` network error on ${url}: ${(e as Error).message}; backing off ${Math.round(delay / 1000)}s`);
166
+ await doSleep(delay);
167
+ delay = Math.min(delay * 2, 300_000);
168
+ }
169
+ }
170
+ stats.failed++;
171
+ return null;
172
+ }
@@ -0,0 +1,227 @@
1
+ // Resolve a CLI argument to a CommitFest entry and/or a mailing-list thread, fetch it
2
+ // (cached, polite), apply --blind / --before, and collect the prose of the proposal's patches.
3
+
4
+ import { fetchText } from "./fetch.ts";
5
+ import { flatThreadUrl, parseCommitfestPatch, parseFlatThread } from "./parse.ts";
6
+ import { extractPatchProse, isReadableAttachment, MAX_DOC_HUNK_CHARS } from "./patchprose.ts";
7
+ import type {
8
+ CfEntry,
9
+ InputRef,
10
+ LoadOptions,
11
+ PatchProse,
12
+ ProposalInput,
13
+ Thread,
14
+ ThreadMessage,
15
+ } from "./types.ts";
16
+
17
+ /** Attachments larger than this are not downloaded. */
18
+ export const MAX_ATTACHMENT_BYTES = 1536 * 1024;
19
+ /** At most this many attachments are read per run. */
20
+ export const MAX_ATTACHMENTS = 8;
21
+
22
+ export function parseInputRef(arg: string): InputRef {
23
+ const s = arg.trim();
24
+ if (/^\d+$/.test(s)) return { kind: "cf", id: Number(s) };
25
+
26
+ const cf = s.match(/^https?:\/\/commitfest\.postgresql\.org\/(?:patch\/(\d+)|\d+\/(\d+))\/?/i);
27
+ if (cf) return { kind: "cf", id: Number(cf[1] ?? cf[2]) };
28
+
29
+ const arch = s.match(
30
+ /^https?:\/\/(?:www\.)?postgresql\.org\/message-id\/(?:flat\/|raw\/|mbox\/)?([^?#]+?)\/?(?:[?#].*)?$/i,
31
+ );
32
+ const short = s.match(/^https?:\/\/postgr\.es\/m\/([^?#]+?)\/?(?:[?#].*)?$/i);
33
+ const raw = arch?.[1] ?? short?.[1];
34
+ if (raw) {
35
+ let id = raw;
36
+ try {
37
+ id = decodeURIComponent(raw);
38
+ } catch {
39
+ // keep as is
40
+ }
41
+ return { kind: "msgid", messageId: id };
42
+ }
43
+ if (/^<?[^\s<>@]+@[^\s<>@]+>?$/.test(s)) return { kind: "msgid", messageId: s.replace(/^<|>$/g, "") };
44
+ throw new Error(
45
+ `cannot understand input "${arg}": expected a CommitFest patch id or URL, a message-id, or a postgresql.org thread URL`,
46
+ );
47
+ }
48
+
49
+ export function validateDate(d: string): string {
50
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(d) || Number.isNaN(Date.parse(d + "T00:00:00Z"))) {
51
+ throw new Error(`bad date "${d}": expected YYYY-MM-DD`);
52
+ }
53
+ return d;
54
+ }
55
+
56
+ /** Apply --before (strictly before the date, UTC) and --blind (first N). Both may apply. */
57
+ export function filterMessages(
58
+ messages: ThreadMessage[],
59
+ opts: { blind?: number | null; before?: string | null },
60
+ ): ThreadMessage[] {
61
+ let out = messages;
62
+ if (opts.before) {
63
+ const cutoff = validateDate(opts.before) + "T00:00:00Z";
64
+ out = out.filter((m) => m.date !== "" && m.date < cutoff);
65
+ }
66
+ if (opts.blind != null) {
67
+ if (!Number.isInteger(opts.blind) || opts.blind < 1) throw new Error("--blind needs a positive integer");
68
+ out = out.slice(0, opts.blind);
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /**
74
+ * Remove what would reveal the outcome. With --blind the whole status/history/reviewer
75
+ * part goes; with only --before, history entries before the cutoff are kept.
76
+ */
77
+ export function redactCf(cf: CfEntry, opts: { blind?: number | null; before?: string | null }): CfEntry {
78
+ if (opts.blind == null && !opts.before) return cf;
79
+ const cutoff = opts.before ? opts.before + "T00:00:00Z" : null;
80
+ return {
81
+ ...cf,
82
+ status: null,
83
+ statuses: [],
84
+ reviewers: "",
85
+ committer: "",
86
+ targetVersion: "",
87
+ links: [],
88
+ history:
89
+ opts.blind == null && cutoff
90
+ ? cf.history.filter((h) => h.when < cutoff && !/^Closed in commitfest/i.test(h.what))
91
+ : [],
92
+ threads: cf.threads.map((t) => ({ ...t, latestAt: null })),
93
+ redacted: true,
94
+ };
95
+ }
96
+
97
+ /** Messages whose attachments describe the proposal: the first with a patch, and the author's latest. */
98
+ export function pickProposalMessages(messages: ThreadMessage[]): ThreadMessage[] {
99
+ const withPatch = messages.filter((m) => m.attachments.some((a) => isReadableAttachment(a.name, a.contentType)));
100
+ if (withPatch.length === 0) return [];
101
+ const first = withPatch[0]!;
102
+ const author = messages[0]?.from ?? first.from;
103
+ const latestByAuthor = [...withPatch].reverse().find((m) => m.from === author) ?? first;
104
+ return latestByAuthor === first ? [first] : [latestByAuthor, first];
105
+ }
106
+
107
+ async function loadPatchProse(
108
+ messages: ThreadMessage[],
109
+ opts: LoadOptions,
110
+ warnings: string[],
111
+ ): Promise<PatchProse[]> {
112
+ const out: PatchProse[] = [];
113
+ let budget = MAX_ATTACHMENTS;
114
+ for (const msg of pickProposalMessages(messages)) {
115
+ for (const att of msg.attachments) {
116
+ if (!isReadableAttachment(att.name, att.contentType)) continue;
117
+ if (budget <= 0) {
118
+ warnings.push(`attachment limit (${MAX_ATTACHMENTS}) reached; ${att.name} not read`);
119
+ continue;
120
+ }
121
+ if (att.sizeBytes != null && att.sizeBytes > MAX_ATTACHMENT_BYTES) {
122
+ warnings.push(`attachment ${att.name} (${att.sizeText}) is too large; not read`);
123
+ continue;
124
+ }
125
+ budget--;
126
+ const text = await fetchText(att.url, {
127
+ offline: opts.offline,
128
+ cacheDir: opts.cacheDir,
129
+ maxBytes: MAX_ATTACHMENT_BYTES,
130
+ });
131
+ if (text == null) {
132
+ warnings.push(`attachment ${att.name} could not be read`);
133
+ continue;
134
+ }
135
+ if (text.includes("\u0000")) {
136
+ warnings.push(`attachment ${att.name} looks binary; skipped`);
137
+ continue;
138
+ }
139
+ const prose = extractPatchProse(text);
140
+ if (prose.files.length === 0 && prose.commitMessages.length === 0) {
141
+ // Not a diff. Keep it only when it is plainly a prose file (README, notes).
142
+ if (!/README|\.(md|txt)$/i.test(att.name)) continue;
143
+ const cut = text.length > MAX_DOC_HUNK_CHARS;
144
+ out.push({
145
+ attachment: att,
146
+ fromMessageIndex: msg.index,
147
+ commitMessages: [],
148
+ files: [],
149
+ docHunks: [{ path: att.name, text: cut ? text.slice(0, MAX_DOC_HUNK_CHARS) + "\n[... truncated ...]" : text }],
150
+ truncated: cut,
151
+ });
152
+ continue;
153
+ }
154
+ out.push({ attachment: att, fromMessageIndex: msg.index, ...prose });
155
+ }
156
+ }
157
+ return out;
158
+ }
159
+
160
+ export async function loadCfEntry(id: number, opts: LoadOptions = {}): Promise<CfEntry> {
161
+ const url = `https://commitfest.postgresql.org/patch/${id}/`;
162
+ const html = await fetchText(url, { offline: opts.offline, cacheDir: opts.cacheDir });
163
+ if (html == null) throw new Error(`CommitFest patch ${id} not found (${url})`);
164
+ return parseCommitfestPatch(html, id);
165
+ }
166
+
167
+ export async function loadThread(messageId: string, opts: LoadOptions = {}): Promise<Thread> {
168
+ const url = flatThreadUrl(messageId);
169
+ const html = await fetchText(url, { offline: opts.offline, cacheDir: opts.cacheDir });
170
+ if (html == null) throw new Error(`thread not found: ${url}`);
171
+ return parseFlatThread(html);
172
+ }
173
+
174
+ export async function loadProposal(arg: string, opts: LoadOptions = {}): Promise<ProposalInput> {
175
+ const ref = parseInputRef(arg);
176
+ const warnings: string[] = [];
177
+ const filters = { blind: opts.blind ?? null, before: opts.before ? validateDate(opts.before) : null };
178
+
179
+ let cf: CfEntry | null = null;
180
+ let messageId: string;
181
+ if (ref.kind === "cf") {
182
+ const full = await loadCfEntry(ref.id, opts);
183
+ if (full.threads.length === 0) throw new Error(`CommitFest patch ${ref.id} has no mailing-list thread attached`);
184
+ // The proposal thread is the one that started first.
185
+ const sorted = [...full.threads].sort((a, b) => (a.firstAt ?? "9999").localeCompare(b.firstAt ?? "9999"));
186
+ messageId = sorted[0]!.messageId;
187
+ if (sorted.length > 1) {
188
+ warnings.push(
189
+ `CommitFest entry has ${sorted.length} threads; only the earliest is read (${sorted[0]!.url}). Others: ` +
190
+ sorted
191
+ .slice(1)
192
+ .map((t) => t.url)
193
+ .join(", "),
194
+ );
195
+ }
196
+ cf = redactCf(full, filters);
197
+ } else if (ref.kind === "msgid") {
198
+ messageId = ref.messageId;
199
+ } else {
200
+ throw new Error("a local draft is loaded with loadLocalProposal(), not from the archives");
201
+ }
202
+
203
+ const full = await loadThread(messageId, opts);
204
+ const messages = filterMessages(full.messages, filters);
205
+ if (messages.length === 0) {
206
+ throw new Error(
207
+ `no messages left after filtering (thread has ${full.messages.length}, first on ${full.messages[0]!.date}; --before ${filters.before ?? "-"}, --blind ${filters.blind ?? "-"})`,
208
+ );
209
+ }
210
+ if (ref.kind === "msgid" && full.rootMessageId !== ref.messageId) {
211
+ warnings.push(`the given message is not the thread root; reading the whole thread from ${full.threadUrl}`);
212
+ }
213
+ const thread: Thread = { ...full, messages };
214
+ const patchProse = opts.noAttachments ? [] : await loadPatchProse(messages, opts, warnings);
215
+
216
+ return {
217
+ input: arg,
218
+ ref,
219
+ cf,
220
+ thread,
221
+ totalMessages: full.messages.length,
222
+ filters,
223
+ threadStart: full.messages[0]!.date,
224
+ patchProse,
225
+ warnings,
226
+ };
227
+ }