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,189 @@
|
|
|
1
|
+
// PG Roast precedent corpus CLI.
|
|
2
|
+
//
|
|
3
|
+
// bun run src/corpus/cli.ts build [--source cf|reverts|wiki|all] [--limit N] [--offline]
|
|
4
|
+
// bun run src/corpus/cli.ts search "<query>" [--before YYYY-MM-DD] [--kind cf,revert,do_not_want] [-n 10] [--json] [--include-undated]
|
|
5
|
+
// bun run src/corpus/cli.ts stats
|
|
6
|
+
// bun run src/corpus/cli.ts selftest
|
|
7
|
+
|
|
8
|
+
import { crawlCommitfest } from "./cf.ts";
|
|
9
|
+
import { applySchema, connect, hasTrgm, store } from "./db.ts";
|
|
10
|
+
import { fetchStats, readCached } from "./fetch.ts";
|
|
11
|
+
import { collectReverts, git, PG_REPO } from "./reverts.ts";
|
|
12
|
+
import { closeSearch, searchPrecedents } from "./search.ts";
|
|
13
|
+
import { canonMsgid, extractMsgids, log, unescapeHtml, type Kind } from "./util.ts";
|
|
14
|
+
import { collectWiki, WIKI_PAGE } from "./wiki.ts";
|
|
15
|
+
|
|
16
|
+
type Args = { _: string[]; [k: string]: string | boolean | string[] };
|
|
17
|
+
|
|
18
|
+
function parseArgs(argv: string[]): Args {
|
|
19
|
+
const a: Args = { _: [] };
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
const t = argv[i]!;
|
|
22
|
+
if (t.startsWith("-")) {
|
|
23
|
+
const k = t.replace(/^-+/, "");
|
|
24
|
+
const next = argv[i + 1];
|
|
25
|
+
if (["json", "offline", "include-undated"].includes(k) || next === undefined) a[k] = true;
|
|
26
|
+
else { a[k] = next; i++; }
|
|
27
|
+
} else a._.push(t);
|
|
28
|
+
}
|
|
29
|
+
return a;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const KIND_OF: Record<string, Kind> = { cf: "cf", reverts: "revert", wiki: "do_not_want" };
|
|
33
|
+
|
|
34
|
+
async function build(a: Args) {
|
|
35
|
+
const source = (a.source as string) ?? "all";
|
|
36
|
+
if (!["cf", "reverts", "wiki", "all"].includes(source)) throw new Error("bad --source");
|
|
37
|
+
const limit = a.limit ? Number(a.limit) : undefined;
|
|
38
|
+
const offline = !!a.offline;
|
|
39
|
+
const sql = connect();
|
|
40
|
+
const sch = await applySchema(sql);
|
|
41
|
+
log(sch.trgm ? "pg_trgm: available" : `pg_trgm: NOT available (${sch.trgmError}); title similarity disabled`);
|
|
42
|
+
for (const s of source === "all" ? ["wiki", "reverts", "cf"] : [source]) {
|
|
43
|
+
log(`== building ${s}`);
|
|
44
|
+
const r = s === "cf" ? await crawlCommitfest({ limit, offline })
|
|
45
|
+
: s === "reverts" ? await collectReverts({ limit })
|
|
46
|
+
: await collectWiki({ offline });
|
|
47
|
+
const cov = { ...r.cov, records: r.recs.length, fetch_stats: { ...fetchStats } };
|
|
48
|
+
const st = await store(sql, KIND_OF[s]!, r.recs, r.complete, cov);
|
|
49
|
+
console.log(JSON.stringify({ [s]: { ...cov, ...st, complete: r.complete } }, null, 1));
|
|
50
|
+
}
|
|
51
|
+
await stats(sql);
|
|
52
|
+
await sql.close();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function stats(sql = connect()) {
|
|
56
|
+
const rows = await sql`
|
|
57
|
+
select kind, status, count(*)::int n, min(first_date)::text f1, max(first_date)::text f2,
|
|
58
|
+
min(date)::text d1, max(date)::text d2,
|
|
59
|
+
count(*) filter (where cardinality(thread_urls) > 0)::int with_threads
|
|
60
|
+
from precedent group by 1, 2 order by 1, 2`;
|
|
61
|
+
console.log("kind status rows w/thread first_date range date range");
|
|
62
|
+
for (const r of rows) {
|
|
63
|
+
console.log(`${r.kind.padEnd(11)} ${r.status.padEnd(24)} ${String(r.n).padStart(5)} ${String(r.with_threads).padStart(8)} ` +
|
|
64
|
+
`${r.f1 ?? "-"}..${r.f2 ?? "-"} ${r.d1 ?? "-"}..${r.d2 ?? "-"}`);
|
|
65
|
+
}
|
|
66
|
+
const [t] = await sql`select count(*)::int n from precedent`;
|
|
67
|
+
console.log(`total ${t.n} rows; pg_trgm ${(await hasTrgm(sql)) ? "on" : "off"}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function search(a: Args) {
|
|
71
|
+
const query = a._[1];
|
|
72
|
+
if (!query) throw new Error('usage: search "<query>" [--before YYYY-MM-DD] [--kind ...] [-n 10] [--json]');
|
|
73
|
+
const res = await searchPrecedents({
|
|
74
|
+
query,
|
|
75
|
+
before: a.before as string | undefined,
|
|
76
|
+
kinds: a.kind ? (String(a.kind).split(",") as Kind[]) : undefined,
|
|
77
|
+
limit: a.n ? Number(a.n) : 10,
|
|
78
|
+
includeUndated: !!a["include-undated"],
|
|
79
|
+
});
|
|
80
|
+
await closeSearch();
|
|
81
|
+
if (a.json) {
|
|
82
|
+
console.log(JSON.stringify({ query, before: a.before ?? null, results: res }, null, 1));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (!res.length) { console.log("no precedent found"); return; }
|
|
86
|
+
res.forEach((r, i) => {
|
|
87
|
+
console.log(`${String(i + 1).padStart(2)}. [${r.kind}/${r.status}] ${r.date ?? r.first_date ?? "undated"} ${r.title} (${r.match} ${r.score})`);
|
|
88
|
+
console.log(` ${r.url}`);
|
|
89
|
+
for (const t of r.thread_urls.slice(0, 2)) console.log(` thread: ${t}`);
|
|
90
|
+
if (r.objection) {
|
|
91
|
+
const o = r.objection.replace(/\s+/g, " ");
|
|
92
|
+
console.log(` objection: ${o.slice(0, 200)}${o.length > 200 ? "..." : ""}`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ------------------------------------------------------------------ selftest
|
|
98
|
+
|
|
99
|
+
async function selftest() {
|
|
100
|
+
const fails: string[] = [];
|
|
101
|
+
const check = (ok: unknown, msg: string) => { if (!ok) fails.push(msg); };
|
|
102
|
+
const sql = connect();
|
|
103
|
+
try {
|
|
104
|
+
await sql`select 1`;
|
|
105
|
+
} catch (e) {
|
|
106
|
+
console.log("FAIL database not reachable:", String(e));
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
const [schema] = await sql`
|
|
110
|
+
select to_regclass('precedent') is not null as t,
|
|
111
|
+
exists (select 1 from pg_attribute where attrelid = to_regclass('precedent') and attname = 'tsv' and attgenerated = 's') as gen,
|
|
112
|
+
exists (select 1 from pg_indexes where tablename = 'precedent' and indexdef ilike '%using gin (tsv)%') as gin,
|
|
113
|
+
exists (select 1 from pg_constraint where conname = 'precedent_kind_raw_ref_key') as uniq`;
|
|
114
|
+
check(schema.t && schema.gen && schema.gin && schema.uniq, `schema incomplete: ${JSON.stringify(schema)}`);
|
|
115
|
+
const [cnt] = await sql`select count(*)::int n, count(*) filter (where tsv = ''::tsvector)::int empty from precedent`;
|
|
116
|
+
check(cnt.n > 0, "precedent table is empty");
|
|
117
|
+
check(cnt.empty === 0, `${cnt.empty} rows with empty tsvector`);
|
|
118
|
+
const kinds = await sql`select kind, count(*)::int n from precedent group by 1`;
|
|
119
|
+
for (const k of ["cf", "revert", "do_not_want"]) check(kinds.some((r: any) => r.kind === k && r.n > 0), `no rows of kind ${k}`);
|
|
120
|
+
const r1 = await searchPrecedents({ query: "optimizer hints", sql });
|
|
121
|
+
check(r1.length > 0, "search 'optimizer hints' returned nothing");
|
|
122
|
+
const r2 = await searchPrecedents({ query: `'); drop table precedent; -- " OR *:`, sql });
|
|
123
|
+
check(Array.isArray(r2), "hostile query raised");
|
|
124
|
+
const r3 = await searchPrecedents({ query: "revert", before: "2010-01-01", sql });
|
|
125
|
+
check(r3.every((r) => (r.date ?? "") < "2010-01-01" && (r.first_date ?? "") < "2010-01-01" && (r.date || r.first_date)),
|
|
126
|
+
"--before leaked a row dated on/after the cutoff or an undated row");
|
|
127
|
+
|
|
128
|
+
// URL patterns, in SQL
|
|
129
|
+
const bad = await sql`
|
|
130
|
+
select id, url from precedent where not (
|
|
131
|
+
(kind = 'cf' and url ~ '^https://commitfest\\.postgresql\\.org/patch/[0-9]+/$' and url = 'https://commitfest.postgresql.org/' || raw_ref || '/')
|
|
132
|
+
or (kind = 'revert' and url ~ '^https://git\\.postgresql\\.org/gitweb/\\?p=postgresql\\.git;a=commit;h=[0-9a-f]{40}$' and url like '%=' || raw_ref)
|
|
133
|
+
or (kind = 'do_not_want' and url ~ '^https://wiki\\.postgresql\\.org/wiki/Not_Worth_Doing#[A-Za-z0-9_.-]+$'))
|
|
134
|
+
union all
|
|
135
|
+
select id, t from precedent, unnest(thread_urls) t
|
|
136
|
+
where t !~ '^https://www\\.postgresql\\.org/message-id/flat/[^[:space:]/?#]+@[^[:space:]/?#]+$'
|
|
137
|
+
union all
|
|
138
|
+
select id, t from precedent, unnest(other_links) t where t !~ '^https?://[^[:space:]]+$'`;
|
|
139
|
+
for (const b of bad) check(false, `${b.id}: bad url ${b.url}`);
|
|
140
|
+
|
|
141
|
+
// provenance: every thread message-id appears in the cached source page / git output
|
|
142
|
+
const wikiHtml = readCached(WIKI_PAGE) ?? "";
|
|
143
|
+
const rows = await sql`select id, kind, raw_ref, url, thread_urls, provenance from precedent`;
|
|
144
|
+
let prov = 0;
|
|
145
|
+
for (const r of rows) {
|
|
146
|
+
let ids: Set<string>;
|
|
147
|
+
if (r.kind === "cf") {
|
|
148
|
+
const pid = /^patch\/(\d+)$/.exec(r.raw_ref)?.[1];
|
|
149
|
+
const page = readCached(r.provenance.cache_url ?? r.url) ?? "";
|
|
150
|
+
check(pid && page.includes(`/patch/${pid}`), `${r.id}: patch id not found in cached source page`);
|
|
151
|
+
ids = new Set([...page.matchAll(/message-id\/flat\/([^"]+)"/g)].map((m) => canonMsgid(unescapeHtml(m[1]!))));
|
|
152
|
+
} else if (r.kind === "revert") {
|
|
153
|
+
const reverted = ((r.provenance.reverted_commits ?? []) as { hash: string }[]).map((c) => c.hash);
|
|
154
|
+
const g = await git("show", "-s", "--format=%B", r.raw_ref, ...reverted);
|
|
155
|
+
check(g.code === 0, `${r.id}: commit not in ${PG_REPO}`);
|
|
156
|
+
ids = new Set(extractMsgids(g.out).map(canonMsgid));
|
|
157
|
+
} else {
|
|
158
|
+
const anchor = r.url.split("#")[1];
|
|
159
|
+
check(wikiHtml.includes(`id="${anchor}"`), `${r.id}: anchor not on cached wiki page`);
|
|
160
|
+
ids = new Set(extractMsgids(wikiHtml).map(canonMsgid));
|
|
161
|
+
}
|
|
162
|
+
for (const t of r.thread_urls as string[]) {
|
|
163
|
+
prov++;
|
|
164
|
+
check(ids.has(t.replace("https://www.postgresql.org/message-id/flat/", "")), `${r.id}: thread ${t} not found in its source`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
console.log(JSON.stringify({ rows: cnt.n, kinds, url_violations: bad.length, threads_checked: prov, failures: fails.length, trgm: await hasTrgm(sql) }));
|
|
168
|
+
for (const f of fails.slice(0, 30)) console.log("FAIL", f);
|
|
169
|
+
await sql.close();
|
|
170
|
+
await closeSearch();
|
|
171
|
+
if (fails.length) process.exit(1);
|
|
172
|
+
console.log("selftest OK");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const a = parseArgs(process.argv.slice(2));
|
|
176
|
+
const cmd = a._[0];
|
|
177
|
+
try {
|
|
178
|
+
if (cmd === "build") await build(a);
|
|
179
|
+
else if (cmd === "search") await search(a);
|
|
180
|
+
else if (cmd === "stats") { const s = connect(); await stats(s); await s.close(); }
|
|
181
|
+
else if (cmd === "selftest") await selftest();
|
|
182
|
+
else {
|
|
183
|
+
console.error("usage: cli.ts build|search|stats|selftest (see header of src/corpus/cli.ts)");
|
|
184
|
+
process.exit(2);
|
|
185
|
+
}
|
|
186
|
+
} catch (e) {
|
|
187
|
+
console.error(String((e as Error).stack ?? e));
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
package/src/corpus/db.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Postgres access for the precedent corpus (Bun.sql, no npm driver).
|
|
2
|
+
//
|
|
3
|
+
// PGROAST_DSN accepts either a libpq keyword string ("dbname=pgroast host=/tmp")
|
|
4
|
+
// or a URL ("postgres://localhost/pgroast?host=/tmp"). Bun 1.3.5 forwards an
|
|
5
|
+
// unknown URL query parameter such as ?host= to the server as a startup GUC,
|
|
6
|
+
// which fails, so the DSN is parsed here and a socket directory is turned
|
|
7
|
+
// into Bun's `path` option (<dir>/.s.PGSQL.<port>).
|
|
8
|
+
|
|
9
|
+
import { SQL } from "bun";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { ROOT } from "./fetch.ts";
|
|
13
|
+
import type { Kind, PrecedentRecord } from "./util.ts";
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_DSN = "dbname=pgroast host=/tmp";
|
|
16
|
+
|
|
17
|
+
export function parseDsn(dsn: string): Record<string, string> {
|
|
18
|
+
const kv: Record<string, string> = {};
|
|
19
|
+
if (/^postgres(ql)?:\/\//.test(dsn)) {
|
|
20
|
+
const u = new URL(dsn);
|
|
21
|
+
if (u.hostname) kv.host = decodeURIComponent(u.hostname);
|
|
22
|
+
if (u.port) kv.port = u.port;
|
|
23
|
+
if (u.username) kv.user = decodeURIComponent(u.username);
|
|
24
|
+
if (u.password) kv.password = decodeURIComponent(u.password);
|
|
25
|
+
const db = decodeURIComponent(u.pathname.replace(/^\//, ""));
|
|
26
|
+
if (db) kv.dbname = db;
|
|
27
|
+
for (const [k, v] of u.searchParams) kv[k === "database" ? "dbname" : k] = v;
|
|
28
|
+
} else {
|
|
29
|
+
for (const m of dsn.matchAll(/(\w+)\s*=\s*('(?:[^'\\]|\\.)*'|\S+)/g)) {
|
|
30
|
+
kv[m[1]!] = m[2]!.startsWith("'") ? m[2]!.slice(1, -1).replace(/\\(.)/g, "$1") : m[2]!;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return kv;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function connect(dsn = process.env.PGROAST_DSN ?? DEFAULT_DSN): SQL {
|
|
37
|
+
const kv = parseDsn(dsn);
|
|
38
|
+
const port = Number(kv.port ?? 5432);
|
|
39
|
+
const opts: Record<string, unknown> = {
|
|
40
|
+
database: kv.dbname ?? "pgroast",
|
|
41
|
+
username: kv.user ?? process.env.PGUSER ?? process.env.USER,
|
|
42
|
+
max: 4,
|
|
43
|
+
idleTimeout: 5,
|
|
44
|
+
};
|
|
45
|
+
if (kv.password) opts.password = kv.password;
|
|
46
|
+
const host = kv.host ?? "/tmp";
|
|
47
|
+
if (host.startsWith("/")) opts.path = join(host, `.s.PGSQL.${port}`);
|
|
48
|
+
else Object.assign(opts, { hostname: host, port });
|
|
49
|
+
return new SQL(opts as ConstructorParameters<typeof SQL>[0]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Apply sql/schema.sql; try pg_trgm. Returns whether pg_trgm is usable. */
|
|
53
|
+
export async function applySchema(sql: SQL): Promise<{ trgm: boolean; trgmError?: string }> {
|
|
54
|
+
await sql.unsafe(readFileSync(join(ROOT, "sql", "schema.sql"), "utf8"));
|
|
55
|
+
try {
|
|
56
|
+
await sql.unsafe("create extension if not exists pg_trgm");
|
|
57
|
+
await sql.unsafe("create index if not exists precedent_title_trgm_idx on precedent using gin (title gin_trgm_ops)");
|
|
58
|
+
return { trgm: true };
|
|
59
|
+
} catch (e) {
|
|
60
|
+
return { trgm: false, trgmError: String((e as Error).message ?? e).split("\n")[0] };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function hasTrgm(sql: SQL): Promise<boolean> {
|
|
65
|
+
const r = await sql`select 1 from pg_extension where extname = 'pg_trgm'`;
|
|
66
|
+
return r.length > 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Upsert records on (kind, raw_ref). When `complete`, rows of this kind that
|
|
71
|
+
* the source no longer produced are deleted, so a full rebuild is exact.
|
|
72
|
+
*/
|
|
73
|
+
export async function store(sql: SQL, kind: Kind, recs: PrecedentRecord[], complete: boolean, coverage: unknown) {
|
|
74
|
+
const payload = JSON.stringify(recs.map((r) => ({
|
|
75
|
+
id: r.id, kind: r.kind, raw_ref: r.rawRef, title: r.title, status: r.status,
|
|
76
|
+
date: r.date, first_date: r.firstDate, url: r.url, thread_urls: r.threadUrls,
|
|
77
|
+
authors: r.authors, reviewers: r.reviewers, objectors: r.objectors, tags: r.tags,
|
|
78
|
+
summary: r.summary, objection: r.objection, touch_points: r.touchPoints,
|
|
79
|
+
other_links: r.otherLinks, provenance: r.provenance,
|
|
80
|
+
})));
|
|
81
|
+
let deleted = 0;
|
|
82
|
+
await sql.begin(async (tx) => {
|
|
83
|
+
await tx`
|
|
84
|
+
insert into precedent as p (id, kind, raw_ref, title, status, date, first_date, url,
|
|
85
|
+
thread_urls, authors, reviewers, objectors, tags, summary, objection,
|
|
86
|
+
touch_points, other_links, provenance, built_at)
|
|
87
|
+
select r.id, r.kind, r.raw_ref, r.title, r.status, left(r.date, 10)::date, left(r.first_date, 10)::date, r.url,
|
|
88
|
+
array(select jsonb_array_elements_text(r.thread_urls)),
|
|
89
|
+
array(select jsonb_array_elements_text(r.authors)),
|
|
90
|
+
array(select jsonb_array_elements_text(r.reviewers)),
|
|
91
|
+
array(select jsonb_array_elements_text(r.objectors)),
|
|
92
|
+
r.tags, r.summary, r.objection,
|
|
93
|
+
array(select jsonb_array_elements_text(r.touch_points)),
|
|
94
|
+
array(select jsonb_array_elements_text(r.other_links)),
|
|
95
|
+
r.provenance, now()
|
|
96
|
+
from jsonb_to_recordset((${payload}::text)::jsonb) as r(
|
|
97
|
+
id text, kind text, raw_ref text, title text, status text, date text, first_date text, url text,
|
|
98
|
+
thread_urls jsonb, authors jsonb, reviewers jsonb, objectors jsonb, tags text, summary text,
|
|
99
|
+
objection text, touch_points jsonb, other_links jsonb, provenance jsonb)
|
|
100
|
+
on conflict (kind, raw_ref) do update set
|
|
101
|
+
id = excluded.id, title = excluded.title, status = excluded.status, date = excluded.date,
|
|
102
|
+
first_date = excluded.first_date, url = excluded.url, thread_urls = excluded.thread_urls,
|
|
103
|
+
authors = excluded.authors, reviewers = excluded.reviewers, objectors = excluded.objectors,
|
|
104
|
+
tags = excluded.tags, summary = excluded.summary, objection = excluded.objection,
|
|
105
|
+
touch_points = excluded.touch_points, other_links = excluded.other_links,
|
|
106
|
+
provenance = excluded.provenance, built_at = excluded.built_at`;
|
|
107
|
+
if (complete) {
|
|
108
|
+
const keys = JSON.stringify(recs.map((r) => r.rawRef));
|
|
109
|
+
const d = await tx`
|
|
110
|
+
delete from precedent where kind = ${kind}
|
|
111
|
+
and raw_ref not in (select jsonb_array_elements_text((${keys}::text)::jsonb))`;
|
|
112
|
+
deleted = d.count ?? 0;
|
|
113
|
+
}
|
|
114
|
+
await tx`
|
|
115
|
+
insert into build_log (kind, built_at, coverage) values (${kind}, now(), (${JSON.stringify(coverage)}::text)::jsonb)
|
|
116
|
+
on conflict (kind) do update set built_at = excluded.built_at, coverage = excluded.coverage`;
|
|
117
|
+
});
|
|
118
|
+
return { upserted: recs.length, deleted };
|
|
119
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Polite, cached HTTP fetcher for the precedent corpus.
|
|
2
|
+
//
|
|
3
|
+
// * User-Agent "pgroast-research/0.1", GET only, no cookies, no sign-in
|
|
4
|
+
// * >= 1.05 s between requests to the same host
|
|
5
|
+
// * every 200 response is cached under data/cache/<host>/ and never refetched;
|
|
6
|
+
// a 404 leaves a "<file>.404" marker
|
|
7
|
+
// * 429 / 5xx: honour Retry-After, else exponential back-off 5 s .. 300 s, 6 tries
|
|
8
|
+
//
|
|
9
|
+
// The cache file naming is byte-compatible with the earlier Python builder so
|
|
10
|
+
// pages crawled by it are reused.
|
|
11
|
+
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
|
|
16
|
+
export const USER_AGENT = "pgroast-research/0.1";
|
|
17
|
+
const MIN_INTERVAL_MS = 1050;
|
|
18
|
+
export const ROOT = resolve(import.meta.dir, "..", "..");
|
|
19
|
+
export const CACHE_DIR = process.env.PGROAST_CACHE_DIR ?? join(ROOT, "data", "cache");
|
|
20
|
+
|
|
21
|
+
export const fetchStats = { fetched: 0, cached: 0, failed: 0, notFound: 0 };
|
|
22
|
+
const lastRequest = new Map<string, number>();
|
|
23
|
+
|
|
24
|
+
export function cachePath(url: string): string {
|
|
25
|
+
const u = new URL(url);
|
|
26
|
+
let name = u.pathname.replace(/^\/+|\/+$/g, "") || "_root";
|
|
27
|
+
name = name.replace(/[^A-Za-z0-9._-]+/g, "_");
|
|
28
|
+
const query = u.search.replace(/^\?/, "");
|
|
29
|
+
if (query) name += "__" + query.replace(/[^A-Za-z0-9._=-]+/g, "_");
|
|
30
|
+
if (name.length > 150) {
|
|
31
|
+
name = name.slice(0, 100) + "_" + createHash("sha1").update(name).digest("hex").slice(0, 16);
|
|
32
|
+
}
|
|
33
|
+
return join(CACHE_DIR, u.host, name + ".html");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isCached(url: string): boolean {
|
|
37
|
+
const p = cachePath(url);
|
|
38
|
+
return existsSync(p) || existsSync(p + ".404");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readCached(url: string): string | null {
|
|
42
|
+
const p = cachePath(url);
|
|
43
|
+
return existsSync(p) ? readFileSync(p, "utf8") : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
47
|
+
|
|
48
|
+
/** Page text, or null for 404 / failure / offline cache miss. */
|
|
49
|
+
export async function fetchPage(url: string, opts: { offline?: boolean } = {}): Promise<string | null> {
|
|
50
|
+
const p = cachePath(url);
|
|
51
|
+
if (existsSync(p)) {
|
|
52
|
+
fetchStats.cached++;
|
|
53
|
+
return readFileSync(p, "utf8");
|
|
54
|
+
}
|
|
55
|
+
if (existsSync(p + ".404")) {
|
|
56
|
+
fetchStats.notFound++;
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (opts.offline) return null;
|
|
60
|
+
const host = new URL(url).host;
|
|
61
|
+
let delay = 5000;
|
|
62
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
63
|
+
const wait = (lastRequest.get(host) ?? 0) + MIN_INTERVAL_MS - Date.now();
|
|
64
|
+
if (wait > 0) await sleep(wait);
|
|
65
|
+
lastRequest.set(host, Date.now());
|
|
66
|
+
try {
|
|
67
|
+
const r = await fetch(url, {
|
|
68
|
+
headers: { "User-Agent": USER_AGENT },
|
|
69
|
+
redirect: "follow",
|
|
70
|
+
signal: AbortSignal.timeout(60_000),
|
|
71
|
+
});
|
|
72
|
+
if (r.ok) {
|
|
73
|
+
const body = await r.text();
|
|
74
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
75
|
+
writeFileSync(p + ".tmp", body);
|
|
76
|
+
renameSync(p + ".tmp", p);
|
|
77
|
+
fetchStats.fetched++;
|
|
78
|
+
return body;
|
|
79
|
+
}
|
|
80
|
+
if (r.status === 404) {
|
|
81
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
82
|
+
writeFileSync(p + ".404", "");
|
|
83
|
+
fetchStats.notFound++;
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (r.status === 429 || r.status >= 500) {
|
|
87
|
+
const ra = r.headers.get("retry-after");
|
|
88
|
+
const ms = ra && /^\d+$/.test(ra) ? Number(ra) * 1000 : delay;
|
|
89
|
+
console.error(` HTTP ${r.status} on ${url}; backing off ${ms / 1000}s`);
|
|
90
|
+
await sleep(ms);
|
|
91
|
+
delay = Math.min(delay * 2, 300_000);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
console.error(` HTTP ${r.status} on ${url}; giving up`);
|
|
95
|
+
break;
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.error(` network error on ${url}: ${e}; backing off ${delay / 1000}s`);
|
|
98
|
+
await sleep(delay);
|
|
99
|
+
delay = Math.min(delay * 2, 300_000);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
fetchStats.failed++;
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Git source: commits whose message has a line starting with "Revert"
|
|
2
|
+
// (git log --grep='^Revert' -i) in the local Postgres clone. Read-only.
|
|
3
|
+
|
|
4
|
+
import { resolve as pathResolve } from "node:path";
|
|
5
|
+
import { extractMsgids, flatUrl, touchPoints, type PrecedentRecord } from "./util.ts";
|
|
6
|
+
|
|
7
|
+
export const PG_REPO = process.env.PGROAST_PG_REPO ?? pathResolve(import.meta.dir, "..", "..", "..", "postgres");
|
|
8
|
+
export const GITWEB = "https://git.postgresql.org/gitweb/?p=postgresql.git;a=commit;h=";
|
|
9
|
+
|
|
10
|
+
export async function git(...args: string[]): Promise<{ out: string; code: number }> {
|
|
11
|
+
const p = Bun.spawn(["git", "-C", PG_REPO, ...args], { stdout: "pipe", stderr: "pipe" });
|
|
12
|
+
const out = await new Response(p.stdout).text();
|
|
13
|
+
const code = await p.exited;
|
|
14
|
+
return { out, code };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function gitOk(...args: string[]): Promise<string> {
|
|
18
|
+
const r = await git(...args);
|
|
19
|
+
if (r.code !== 0) throw new Error(`git ${args.join(" ")} failed (${r.code})`);
|
|
20
|
+
return r.out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const utc = (iso: string) => new Date(iso).toISOString().slice(0, 19);
|
|
24
|
+
|
|
25
|
+
interface Commit { hash: string; author: string; date: string; subject: string; body: string }
|
|
26
|
+
|
|
27
|
+
const commitCache = new Map<string, Commit>();
|
|
28
|
+
async function commit(h: string): Promise<Commit> {
|
|
29
|
+
const hit = commitCache.get(h);
|
|
30
|
+
if (hit) return hit;
|
|
31
|
+
const out = await gitOk("log", "-1", "--format=%H%x00%an%x00%cI%x00%s%x00%B", h);
|
|
32
|
+
const [hash, author, cd, subject, ...rest] = out.split("\x00");
|
|
33
|
+
const c = { hash: hash!, author: author!, date: utc(cd!), subject: subject!, body: rest.join("\x00").trim() };
|
|
34
|
+
commitCache.set(h, c);
|
|
35
|
+
return c;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function resolve(short: string): Promise<string | null> {
|
|
39
|
+
const r = await git("rev-parse", "-q", "--verify", short + "^{commit}");
|
|
40
|
+
return r.code === 0 ? r.out.trim() : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let subjects: Map<string, [string, string][]> | null = null;
|
|
44
|
+
async function subjectIndex() {
|
|
45
|
+
if (!subjects) {
|
|
46
|
+
subjects = new Map();
|
|
47
|
+
for (const ln of (await gitOk("log", "--format=%H%x00%cI%x00%s")).split("\n")) {
|
|
48
|
+
if (!ln) continue;
|
|
49
|
+
const [H, d, s] = ln.split("\x00");
|
|
50
|
+
const k = s!.trim();
|
|
51
|
+
if (!subjects.has(k)) subjects.set(k, []);
|
|
52
|
+
subjects.get(k)!.push([H!, utc(d!)]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return subjects;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* CVS-era commits often have the whole message on the subject line. Use its
|
|
60
|
+
* first sentence (max 160 chars) as the title; the full subject stays in summary.
|
|
61
|
+
*/
|
|
62
|
+
export function shortTitle(subject: string): string {
|
|
63
|
+
if (subject.length <= 160) return subject;
|
|
64
|
+
const m = /^(.{20,160}?[.;:])\s/.exec(subject);
|
|
65
|
+
return m ? m[1]! : subject.slice(0, 157).replace(/\s+\S*$/, "") + "...";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const TRAILER = /^(Discussion|Backpatch-through|Back-patch-through|Reported-by|Author|Reviewed-by|Tested-by|Security|Co-authored-by|Bug|Suggested-by|Diagnosed-by|Backpatch|Analyzed-by)\s*:/i;
|
|
69
|
+
|
|
70
|
+
/** Revert message minus subject line and trailers (verbatim otherwise). */
|
|
71
|
+
export function revertReason(body: string, subject: string): string {
|
|
72
|
+
let lines = body.split("\n");
|
|
73
|
+
if (lines[0]?.trim() === subject.trim()) lines = lines.slice(1);
|
|
74
|
+
return lines.filter((l) => !TRAILER.test(l.trim())).join("\n").trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function collectReverts(opts: { limit?: number }) {
|
|
78
|
+
const hashes = (await gitOk("log", "--grep=^Revert", "-i", "--format=%H")).split(/\s+/).filter(Boolean);
|
|
79
|
+
const cov = {
|
|
80
|
+
git_grep_matches: hashes.length, kept_subject_revert: 0, kept_partial_revert: 0,
|
|
81
|
+
skipped_no_revert_target: 0, reverted_commit_resolved: 0, reverted_by_subject_match: 0,
|
|
82
|
+
};
|
|
83
|
+
const recs: PrecedentRecord[] = [];
|
|
84
|
+
for (const h of opts.limit ? hashes.slice(0, opts.limit) : hashes) {
|
|
85
|
+
const c = await commit(h);
|
|
86
|
+
const subjRev = c.subject.toLowerCase().startsWith("revert");
|
|
87
|
+
const targets: string[] = [];
|
|
88
|
+
for (const m of c.body.matchAll(/\b([0-9a-f]{7,40})\b/g)) {
|
|
89
|
+
const ctx = c.body.slice(Math.max(0, m.index! - 120), m.index!).toLowerCase();
|
|
90
|
+
if (!subjRev && !ctx.includes("revert")) continue;
|
|
91
|
+
const full = await resolve(m[1]!);
|
|
92
|
+
if (full && full !== c.hash && !targets.includes(full)) targets.push(full);
|
|
93
|
+
}
|
|
94
|
+
let how: string | null = targets.length ? "hash named in message" : null;
|
|
95
|
+
if (subjRev && !targets.length) {
|
|
96
|
+
const sm = /^Revert\s+"(.+)"\s*\.?$/i.exec(c.subject);
|
|
97
|
+
if (sm) {
|
|
98
|
+
const cands = ((await subjectIndex()).get(sm[1]!.trim()) ?? []).filter(([H, d]) => d < c.date && H !== c.hash);
|
|
99
|
+
if (cands.length) {
|
|
100
|
+
targets.push(cands.reduce((a, b) => (b[1] > a[1] ? b : a))[0]);
|
|
101
|
+
how = "exact subject match, latest earlier commit";
|
|
102
|
+
cov.reverted_by_subject_match++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!subjRev && !targets.length) { cov.skipped_no_revert_target++; continue; }
|
|
107
|
+
if (subjRev) cov.kept_subject_revert++; else cov.kept_partial_revert++;
|
|
108
|
+
const tinfo = await Promise.all(targets.slice(0, 5).map(commit));
|
|
109
|
+
if (tinfo.length) cov.reverted_commit_resolved++;
|
|
110
|
+
const msgids = extractMsgids(c.body);
|
|
111
|
+
for (const t of tinfo) for (const mid of extractMsgids(t.body)) if (!msgids.includes(mid)) msgids.push(mid);
|
|
112
|
+
const firstDate = [c.date, ...tinfo.map((t) => t.date)].sort()[0]!;
|
|
113
|
+
const revSubjects = tinfo.map((t) => t.subject);
|
|
114
|
+
recs.push({
|
|
115
|
+
id: `revert:${c.hash}`, kind: "revert", rawRef: c.hash,
|
|
116
|
+
title: shortTitle(c.subject), status: subjRev ? "Reverted" : "Partially reverted",
|
|
117
|
+
date: c.date, firstDate, url: GITWEB + c.hash,
|
|
118
|
+
threadUrls: [...new Set(msgids.map(flatUrl))],
|
|
119
|
+
authors: [...new Set(tinfo.map((t) => t.author))], reviewers: [], objectors: [],
|
|
120
|
+
tags: "",
|
|
121
|
+
summary: c.subject + (revSubjects.length ? "\nReverted commit(s): " + revSubjects.join("; ") : ""),
|
|
122
|
+
objection: revertReason(c.body, c.subject),
|
|
123
|
+
touchPoints: touchPoints([c.subject, ...revSubjects, c.body].join(" ")),
|
|
124
|
+
otherLinks: tinfo.map((t) => GITWEB + t.hash),
|
|
125
|
+
provenance: {
|
|
126
|
+
source: "git log of postgresql.git (local clone)", commit: c.hash, reverted_by: c.author, reverted_commit_match: how,
|
|
127
|
+
decided_at: c.date, first_at: firstDate,
|
|
128
|
+
reverted_commits: tinfo.map((t) => ({ hash: t.hash, subject: t.subject, date: t.date, author: t.author })),
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return { recs, cov, complete: !opts.limit };
|
|
133
|
+
}
|