@gigzen/populace 0.1.0 → 1.0.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.
@@ -0,0 +1,322 @@
1
+ // Turn an OpenAPI description into a filled-in adapter.
2
+ //
3
+ // Writing the adapter is the wall. Thirteen methods against someone else's API
4
+ // is the half-hour where a new user either gets a run or gives up, and it is
5
+ // the reason a tool that works has no users outside the company that wrote it.
6
+ // Most serious APIs already describe themselves; this reads that description
7
+ // and fills in what it can.
8
+ //
9
+ // It is deliberately a GUESS, and says so. Every match carries a confidence and
10
+ // the evidence behind it, and anything it is unsure of is left as the template's
11
+ // default with a TODO. A generator that quietly guessed wrong would be worse
12
+ // than no generator: the run would fail and the adapter would look finished.
13
+ //
14
+ // JSON only. Populace has no runtime dependencies and a YAML parser would be
15
+ // the first, for a convenience that `npx js-yaml` covers in one command.
16
+
17
+ import fs from "node:fs";
18
+
19
+ /**
20
+ * What each contract method looks like in a REST API, as scoring signals.
21
+ *
22
+ * `verb` the HTTP method it almost certainly is
23
+ * `path` words that appear in the URL, best first
24
+ * `words` words in the operationId, summary or tags
25
+ * `avoid` path fragments meaning this is a different endpoint in similar clothes.
26
+ * Checked against the PATH ONLY. An earlier version also checked the
27
+ * summary, which rejected `POST /conversations` because its description
28
+ * read "start a direct message thread" — the correct answer, thrown out
29
+ * by a word in its own prose.
30
+ */
31
+ const SIGNALS = {
32
+ createUser: {
33
+ verb: "post",
34
+ path: ["signup", "sign-up", "admin/users", "register/", "auth/users", "users", "accounts"],
35
+ words: ["signup", "register", "create user", "create account"],
36
+ avoid: ["login", "signin", "sign-in", "refresh", "verify", "reset"],
37
+ },
38
+ refreshSession: {
39
+ verb: "post",
40
+ path: ["refresh", "token/refresh", "auth/token", "token"],
41
+ words: ["refresh", "renew token", "access token"],
42
+ avoid: ["revoke", "signup", "register"],
43
+ },
44
+ setProfile: {
45
+ verb: "patch",
46
+ path: ["profile", "me", "users", "account"],
47
+ words: ["update profile", "profile", "update user", "edit"],
48
+ avoid: ["password", "avatar", "delete", "settings/notification"],
49
+ },
50
+ deleteUser: {
51
+ verb: "delete",
52
+ path: ["users", "account", "me", "profile"],
53
+ words: ["delete account", "delete user", "remove user", "close account"],
54
+ avoid: ["post", "comment", "message", "group"],
55
+ },
56
+ reportLocation: {
57
+ verb: "post",
58
+ path: ["location", "locations", "position", "track", "ping", "telemetry", "gps"],
59
+ words: ["location", "position", "track", "heartbeat", "ping"],
60
+ avoid: [],
61
+ },
62
+ post: {
63
+ verb: "post",
64
+ path: ["posts", "feed", "statuses", "tweets", "entries"],
65
+ words: ["create post", "new post", "publish", "compose"],
66
+ avoid: ["comment", "like", "reply", "report"],
67
+ },
68
+ recentPostsByOthers: {
69
+ verb: "get",
70
+ path: ["feed", "posts", "timeline", "statuses", "entries"],
71
+ words: ["feed", "timeline", "list posts", "recent"],
72
+ avoid: ["comment", "like", "my", "mine", "draft"],
73
+ },
74
+ like: {
75
+ verb: "post",
76
+ path: ["like", "likes", "reactions", "favourite", "favorite", "upvote"],
77
+ words: ["like", "react", "favourite", "favorite", "upvote"],
78
+ avoid: ["unlike", "dislike", "remove"],
79
+ },
80
+ comment: {
81
+ verb: "post",
82
+ path: ["comments", "replies", "comment"],
83
+ words: ["comment", "reply"],
84
+ avoid: ["delete", "list", "edit"],
85
+ },
86
+ openConversation: {
87
+ verb: "post",
88
+ // "/dm" not "dm": as a bare substring it matches "admin", which is how
89
+ // openConversation ended up pointing at Gitea's POST /admin/cron/{task}.
90
+ path: ["conversations", "threads", "chats", "/dm", "/dms", "rooms"],
91
+ words: ["conversation", "thread", "start chat", "direct message", "room"],
92
+ avoid: ["/messages", "/send", "/read", "/typing"],
93
+ },
94
+ sendMessage: {
95
+ verb: "post",
96
+ path: ["messages", "message", "send"],
97
+ words: ["send message", "message", "post message"],
98
+ avoid: ["/read", "/typing", "/receipts"],
99
+ },
100
+ listGroups: {
101
+ verb: "get",
102
+ path: ["groups", "orgs", "organizations", "communities", "channels", "teams"],
103
+ words: ["list groups", "groups", "organizations", "communities", "channels"],
104
+ avoid: ["member", "join", "leave", "create"],
105
+ },
106
+ joinGroup: {
107
+ verb: "post",
108
+ path: ["join", "members", "membership", "subscribe"],
109
+ words: ["join group", "join", "add member", "subscribe"],
110
+ avoid: ["leave", "remove", "kick", "list"],
111
+ },
112
+ };
113
+
114
+ export const CONTRACT_METHODS = Object.keys(SIGNALS);
115
+
116
+ /** Every operation in the document, flattened. */
117
+ function operations(doc) {
118
+ const out = [];
119
+ for (const [path, item] of Object.entries(doc.paths || {})) {
120
+ if (!item || typeof item !== "object") continue;
121
+ for (const verb of ["get", "post", "put", "patch", "delete"]) {
122
+ const op = item[verb];
123
+ if (!op) continue;
124
+ out.push({
125
+ verb,
126
+ path,
127
+ operationId: op.operationId || "",
128
+ summary: op.summary || "",
129
+ description: (op.description || "").slice(0, 200),
130
+ tags: op.tags || [],
131
+ });
132
+ }
133
+ }
134
+ return out;
135
+ }
136
+
137
+ function score(op, sig) {
138
+ const path = op.path.toLowerCase();
139
+ const text = `${op.operationId} ${op.summary} ${op.tags.join(" ")}`.toLowerCase();
140
+ const why = [];
141
+ let n = 0;
142
+
143
+ for (const bad of sig.avoid) {
144
+ if (path.includes(bad)) return { n: -1, why: [`excluded: path contains "${bad}"`] };
145
+ }
146
+
147
+ // Path is the strongest signal, and earlier entries are better matches.
148
+ //
149
+ // Take the BEST match, never the sum. Summing rewarded long paths that happen
150
+ // to contain several keywords: against Gitea's real spec,
151
+ // /pulls/{index}/comments/{id}/replies scored both "comments" and "replies"
152
+ // and beat /issues/{index}/comments, which is the endpoint a person wants.
153
+ let bestWord = null;
154
+ sig.path.forEach((word, i) => {
155
+ if (!path.includes(word)) return;
156
+ const points = 10 - Math.min(i, 6);
157
+ if (!bestWord || points > bestWord.points) bestWord = { word, points };
158
+ });
159
+ if (bestWord) {
160
+ n += bestWord.points;
161
+ why.push(`path contains "${bestWord.word}"`);
162
+ }
163
+
164
+ if (op.verb === sig.verb) { n += 4; why.push(`${op.verb.toUpperCase()} matches`); }
165
+ // PUT and PATCH are used interchangeably for updates often enough to allow.
166
+ else if (sig.verb === "patch" && op.verb === "put") { n += 3; why.push("PUT accepted for PATCH"); }
167
+ else n -= 3;
168
+
169
+ for (const word of sig.words) {
170
+ if (text.includes(word)) { n += 3; why.push(`described as "${word}"`); }
171
+ }
172
+
173
+ // A shallow path is much likelier to be the main resource than a deep one.
174
+ // At -1 per segment this was too weak to stop a five-segment sub-resource
175
+ // outscoring the collection it hangs off.
176
+ n -= 2 * Math.max(0, op.path.split("/").filter(Boolean).length - 3);
177
+
178
+ // Did anything about the URL itself suggest this method? A verb alone must
179
+ // never be enough: with only that, every GET in a document matched
180
+ // recentPostsByOthers and listGroups, so a spec containing nothing but
181
+ // /health produced two confident-looking matches.
182
+ const hadPathSignal = why.some((w) => w.startsWith("path contains"));
183
+
184
+ return { n, why, hadPathSignal };
185
+ }
186
+
187
+ /**
188
+ * Match each contract method to its best operation.
189
+ *
190
+ * Confidence is deliberately coarse. Anything below `low` is reported as no
191
+ * match at all rather than dressed up, because a wrong path that looks
192
+ * confident costs more to debug than an obvious blank.
193
+ */
194
+ export function match(doc) {
195
+ const ops = operations(doc);
196
+ const results = {};
197
+
198
+ for (const [method, sig] of Object.entries(SIGNALS)) {
199
+ let best = null;
200
+ for (const op of ops) {
201
+ const { n, why, hadPathSignal } = score(op, sig);
202
+ // A path signal is mandatory, not just helpful.
203
+ if (!hadPathSignal || n < 7) continue;
204
+ if (!best || n > best.n) best = { op, n, why };
205
+ }
206
+ const confidence = !best ? "none" : best.n >= 12 ? "high" : best.n >= 9 ? "medium" : "low";
207
+ results[method] = best && confidence !== "none"
208
+ ? { ...best, confidence }
209
+ : { op: null, n: 0, why: ["nothing in the spec looked like this"], confidence: "none" };
210
+ }
211
+ return { operationCount: ops.length, results };
212
+ }
213
+
214
+ /** Read a spec from disk, refusing YAML with a sentence rather than a stack trace. */
215
+ export function load(specPath) {
216
+ const raw = fs.readFileSync(specPath, "utf8");
217
+ const looksYaml = /\.ya?ml$/i.test(specPath) || /^\s*(openapi|swagger)\s*:/m.test(raw);
218
+ if (looksYaml && !raw.trimStart().startsWith("{")) {
219
+ throw new Error(
220
+ `${specPath} looks like YAML. Populace has no runtime dependencies, so it does not ship a\n` +
221
+ ` YAML parser. Convert it once and point at the JSON:\n\n` +
222
+ ` npx js-yaml ${specPath} > openapi.json\n`,
223
+ );
224
+ }
225
+ let doc;
226
+ try {
227
+ doc = JSON.parse(raw);
228
+ } catch (error) {
229
+ throw new Error(`${specPath} is not valid JSON: ${error.message}`);
230
+ }
231
+ if (!doc.paths || typeof doc.paths !== "object") {
232
+ throw new Error(`${specPath} has no "paths" object, so it is not an OpenAPI description.`);
233
+ }
234
+ return doc;
235
+ }
236
+
237
+ /**
238
+ * Rewrite the REST template's call lines with the matched paths.
239
+ *
240
+ * The template is deliberately one `call(VERB, PATH, ...)` per method, so this
241
+ * is a targeted substitution rather than code generation. Everything the
242
+ * template already gets right — error handling, token plumbing, the refusal to
243
+ * return quietly on failure — is left exactly as it is.
244
+ */
245
+ /**
246
+ * The identifier each method already has in scope, for filling path parameters.
247
+ *
248
+ * Without this, a matched path like /posts/{postId}/likes was written into the
249
+ * adapter as a literal string, and the adapter then requested that URL verbatim.
250
+ * Broken — but broken in a way that looks finished, which is the worst kind.
251
+ */
252
+ const PATH_VAR = {
253
+ like: "postId",
254
+ comment: "postId",
255
+ sendMessage: "conversationId",
256
+ joinGroup: "groupId",
257
+ openConversation: "otherUserId",
258
+ setProfile: "user.id",
259
+ deleteUser: "user.id",
260
+ refreshSession: "user.id",
261
+ };
262
+
263
+ /** `/posts/{postId}/likes` → a template literal, or the raw string if we cannot fill it. */
264
+ function pathExpression(rawPath, method) {
265
+ const params = [...rawPath.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
266
+ if (!params.length) return { code: JSON.stringify(rawPath), unresolved: [] };
267
+
268
+ const variable = PATH_VAR[method];
269
+ if (!variable) return { code: JSON.stringify(rawPath), unresolved: params };
270
+
271
+ // One parameter is the common case and safe to fill. Two or more means a
272
+ // nested resource whose second id this method does not have in scope, so it
273
+ // is left visible rather than guessed at.
274
+ if (params.length > 1) return { code: JSON.stringify(rawPath), unresolved: params.slice(1) };
275
+
276
+ return { code: "`" + rawPath.replace(/\{[^}]+\}/, "${" + variable + "}") + "`", unresolved: [] };
277
+ }
278
+
279
+ export function fill(template, matches) {
280
+ let out = template;
281
+ const applied = [];
282
+ const needsHand = [];
283
+
284
+ for (const [method, m] of Object.entries(matches)) {
285
+ if (!m.op) continue;
286
+ const verb = m.op.verb.toUpperCase();
287
+ const { code, unresolved } = pathExpression(m.op.path, method);
288
+ if (unresolved.length) needsHand.push({ method, path: m.op.path, params: unresolved });
289
+
290
+ // Match: await call("POST", "/posts", ...) inside `async <method>(`
291
+ const block = new RegExp(
292
+ `(async ${method}\\s*\\([^)]*\\)\\s*\\{[\\s\\S]{0,400}?call\\()"[A-Z]+",\\s*"[^"]*"`,
293
+ );
294
+ if (!block.test(out)) continue;
295
+ out = out.replace(block, `$1"${verb}", ${code}`);
296
+ applied.push({ method, verb, path: m.op.path, confidence: m.confidence, unresolved });
297
+ }
298
+
299
+ // A header that tells the reader exactly how much to trust what follows.
300
+ const banner = [
301
+ "/**",
302
+ " * GENERATED from an OpenAPI description by `populace init --from-openapi`.",
303
+ " *",
304
+ " * The paths below are a best guess made by matching endpoint names against",
305
+ " * the thirteen contract methods. They are a starting point, not a finished",
306
+ " * adapter: request bodies, field names and response shapes are still the",
307
+ " * template's defaults and almost certainly need editing.",
308
+ " *",
309
+ " * Run `populace smoke` before anything else. It exercises each method once",
310
+ " * and names the first one that is wrong.",
311
+ ...(needsHand.length
312
+ ? [" *",
313
+ " * Paths still containing {braces} need filling by hand - this method has no",
314
+ " * variable in scope for them:",
315
+ ...needsHand.map((h) => ` * ${h.method}: ${h.path}`)]
316
+ : []),
317
+ " */",
318
+ "",
319
+ ].join("\n");
320
+
321
+ return { source: banner + out, applied, needsHand };
322
+ }
package/src/report.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  // only shows successes is worse than no report — it manufactures confidence.
7
7
 
8
8
  import fs from "node:fs";
9
+ import { explainReport, verdictLine } from "./explain.mjs";
9
10
  import path from "node:path";
10
11
  import { summarise } from "./instrument.mjs";
11
12
  import { coverageOf } from "./contract.mjs";
@@ -15,6 +16,22 @@ import { VERSION } from "./version.mjs";
15
16
  const pct = (n) => `${(n * 100).toFixed(1)}%`;
16
17
  const ms = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}s` : `${Math.round(n)}ms`);
17
18
 
19
+ /** Break a sentence onto lines of at most `width`, without splitting words. */
20
+ function wrap(text, width) {
21
+ const lines = [];
22
+ let line = "";
23
+ for (const word of String(text).split(/\s+/).filter(Boolean)) {
24
+ if (line && line.length + 1 + word.length > width) {
25
+ lines.push(line);
26
+ line = word;
27
+ } else {
28
+ line = line ? `${line} ${word}` : word;
29
+ }
30
+ }
31
+ if (line) lines.push(line);
32
+ return lines;
33
+ }
34
+
18
35
  export function buildReport({ config, adapter, world, metrics, teardown, startedAt }) {
19
36
  const api = summarise(metrics);
20
37
  const coverage = coverageOf(adapter);
@@ -219,6 +236,26 @@ export function renderReport(report) {
219
236
  }
220
237
  L.push("");
221
238
 
239
+ // What broke, why, and the fix - the thing a report is actually for. Rules
240
+ // only; anything unrecognised says so rather than inventing a cause.
241
+ const explained = explainReport(report);
242
+ if (explained.length) {
243
+ L.push(` WHAT TO DO`);
244
+ L.push(` ${verdictLine(explained)}`);
245
+ L.push("");
246
+ const BLAME = { app: "YOUR APP", environment: "THE PLATFORM", harness: "THE TEST CLIENT", unknown: "UNKNOWN" };
247
+ for (const e of explained.slice(0, 4)) {
248
+ L.push(` [${BLAME[e.blame]}] ${e.method} × ${e.count}`);
249
+ L.push(` ${e.headline}`);
250
+ for (const line of wrap(e.why, 72)) L.push(` ${line}`);
251
+ if (e.fix) {
252
+ L.push(` Fix:`);
253
+ for (const line of wrap(e.fix, 70)) L.push(` ${line}`);
254
+ }
255
+ L.push("");
256
+ }
257
+ }
258
+
222
259
  if (report.coverage.notTested.length) {
223
260
  L.push(` NOT TESTED — adapter implements ${report.coverage.label}`);
224
261
  for (const c of report.coverage.notTested) {
package/src/selftest.mjs CHANGED
@@ -9,6 +9,9 @@
9
9
  // that the failures are CAUGHT, grouped, and reflected in the verdict.
10
10
 
11
11
  import assert from "node:assert/strict";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
12
15
  import { execFile } from "node:child_process";
13
16
  import { World } from "./engine/world.mjs";
14
17
  import { Agent } from "./engine/agent.mjs";
@@ -25,6 +28,10 @@ import {
25
28
  import { buildReport, renderReport } from "./report.mjs";
26
29
  import { canSignInOnly, CONTRACT_METHODS, coverageOf, isStub } from "./contract.mjs";
27
30
  import { diagnose } from "./diagnose.mjs";
31
+ import { fill, match } from "./openapi.mjs";
32
+ import { explain, explainReport, verdictLine } from "./explain.mjs";
33
+ import { explainWithAI, isConfigured } from "./ai.mjs";
34
+ import { checksDisabled, compare, latestVersion } from "./update.mjs";
28
35
 
29
36
  let failed = 0;
30
37
  const pending = [];
@@ -1355,6 +1362,250 @@ check("the report renders without throwing", () => {
1355
1362
  });
1356
1363
  }
1357
1364
 
1365
+ // --- 9. the OpenAPI adapter generator -------------------------------------
1366
+ //
1367
+ // The generator is a guess by design, so what has to hold is not "it is always
1368
+ // right" but "it never produces something broken that looks finished".
1369
+
1370
+ const SPEC = {
1371
+ openapi: "3.0.0",
1372
+ paths: {
1373
+ "/auth/signup": { post: { operationId: "registerUser", summary: "Register a new account" } },
1374
+ "/auth/login": { post: { operationId: "login", summary: "Sign in" } },
1375
+ "/auth/token/refresh": { post: { operationId: "refreshToken", summary: "Refresh the access token" } },
1376
+ "/users/me": {
1377
+ patch: { operationId: "updateProfile", summary: "Update profile" },
1378
+ delete: { operationId: "deleteAccount", summary: "Delete account" },
1379
+ },
1380
+ "/locations": { post: { operationId: "reportPosition", summary: "Report current position" } },
1381
+ "/posts": {
1382
+ get: { operationId: "listFeed", summary: "Recent posts timeline" },
1383
+ post: { operationId: "createPost", summary: "Create a post" },
1384
+ },
1385
+ "/posts/{postId}/likes": { post: { operationId: "likePost", summary: "Like a post" } },
1386
+ "/posts/{postId}/comments": { post: { operationId: "addComment", summary: "Reply to a post" } },
1387
+ "/conversations": { post: { operationId: "startConversation", summary: "Start a direct message thread" } },
1388
+ "/conversations/{id}/messages": { post: { operationId: "sendMessage", summary: "Send a message" } },
1389
+ "/groups": { get: { operationId: "listGroups", summary: "List groups" } },
1390
+ "/groups/{id}/members": { post: { operationId: "joinGroup", summary: "Join a group" } },
1391
+ },
1392
+ };
1393
+
1394
+ check("the generator matches a conventional REST spec", () => {
1395
+ const { results } = match(SPEC);
1396
+ const expected = {
1397
+ createUser: "POST /auth/signup",
1398
+ refreshSession: "POST /auth/token/refresh",
1399
+ setProfile: "PATCH /users/me",
1400
+ deleteUser: "DELETE /users/me",
1401
+ reportLocation: "POST /locations",
1402
+ post: "POST /posts",
1403
+ recentPostsByOthers: "GET /posts",
1404
+ like: "POST /posts/{postId}/likes",
1405
+ comment: "POST /posts/{postId}/comments",
1406
+ openConversation: "POST /conversations",
1407
+ sendMessage: "POST /conversations/{id}/messages",
1408
+ listGroups: "GET /groups",
1409
+ joinGroup: "POST /groups/{id}/members",
1410
+ };
1411
+ for (const [method, want] of Object.entries(expected)) {
1412
+ const r = results[method];
1413
+ const got = r.op ? `${r.op.verb.toUpperCase()} ${r.op.path}` : "no match";
1414
+ if (got !== want) throw new Error(`${method}: matched ${got}, expected ${want}`);
1415
+ }
1416
+ });
1417
+
1418
+ check("a summary's own words never exclude the right endpoint", () => {
1419
+ // POST /conversations is described as "start a direct message thread". An
1420
+ // earlier version checked the `avoid` list against the summary as well as the
1421
+ // path, so the word "message" rejected the one correct answer.
1422
+ const { results } = match(SPEC);
1423
+ if (results.openConversation.op?.path !== "/conversations") {
1424
+ throw new Error("openConversation was excluded by a word in its own description");
1425
+ }
1426
+ // And sendMessage lives UNDER conversations, so "conversation" cannot exclude it.
1427
+ if (results.sendMessage.op?.path !== "/conversations/{id}/messages") {
1428
+ throw new Error("sendMessage was excluded by its parent resource's name");
1429
+ }
1430
+ });
1431
+
1432
+ check("a generated adapter never leaves a literal {param} in a call", () => {
1433
+ // A path written in as a literal string would make the adapter request
1434
+ // "/posts/{postId}/likes" verbatim: broken, and looking finished.
1435
+ const template = fs.readFileSync(
1436
+ path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "adapters", "template-rest.mjs"),
1437
+ "utf8",
1438
+ );
1439
+ const { source } = fill(template, match(SPEC).results);
1440
+ const leftovers = source.match(/call\("[A-Z]+", "[^"]*\{[a-zA-Z_]+\}/g);
1441
+ if (leftovers) throw new Error(`unfilled path parameters: ${leftovers.join(", ")}`);
1442
+ if (!source.includes("`/posts/${postId}/likes`")) {
1443
+ throw new Error("the path parameter was not turned into a template literal");
1444
+ }
1445
+ });
1446
+
1447
+ check("a spec with nothing recognisable produces no false matches", () => {
1448
+ const { results } = match({ paths: { "/health": { get: { operationId: "health" } } } });
1449
+ const matched = Object.values(results).filter((r) => r.op).length;
1450
+ if (matched > 0) throw new Error(`${matched} method(s) matched a spec containing only /health`);
1451
+ });
1452
+
1453
+ // --- 10. failure explanation ----------------------------------------------
1454
+ //
1455
+ // The judgement that matters is the first one: was this the application at all?
1456
+ // Getting that wrong in either direction is worse than saying nothing.
1457
+
1458
+ check("a transport failure is never blamed on the application", () => {
1459
+ for (const msg of ["TypeError: fetch failed", "socket hang up", "ECONNRESET", "UND_ERR_CONNECT_TIMEOUT"]) {
1460
+ const e = explain(msg);
1461
+ if (e.blame !== "harness") throw new Error(`"${msg}" was blamed on ${e.blame}, not the harness`);
1462
+ }
1463
+ });
1464
+
1465
+ check("a provider quota is not the app's fault", () => {
1466
+ const e = explain("Invalid login credentials (signup first failed: Request rate limit reached)");
1467
+ if (e.blame !== "environment") throw new Error(`rate limit blamed on ${e.blame}`);
1468
+ });
1469
+
1470
+ check("permission and constraint failures are the app's", () => {
1471
+ for (const [msg, rule] of [
1472
+ ["permission denied for table profiles", "rls-denied"],
1473
+ ["duplicate key value violates unique constraint \"post_likes_pkey\"", "duplicate-key"],
1474
+ ["insert violates foreign key constraint", "foreign-key"],
1475
+ ]) {
1476
+ const e = explain(msg);
1477
+ if (e.blame !== "app") throw new Error(`"${msg}" blamed on ${e.blame}`);
1478
+ if (e.rule !== rule) throw new Error(`"${msg}" matched ${e.rule}, expected ${rule}`);
1479
+ }
1480
+ });
1481
+
1482
+ check("the upsert-under-RLS bug gets its own explanation", () => {
1483
+ // The specific mistake behind five of the first defects Populace ever found.
1484
+ // Plain "permission denied" is true but useless; this names the cause.
1485
+ const e = explain("permission denied for table profiles (upsert / ON CONFLICT DO UPDATE)");
1486
+ if (e.rule !== "rls-upsert") throw new Error(`matched ${e.rule}, expected the upsert-specific rule`);
1487
+ if (!/cannot upsert a column you cannot select/i.test(e.fix)) {
1488
+ throw new Error("the fix does not state the actual rule");
1489
+ }
1490
+ });
1491
+
1492
+ check("an unrecognised failure says so rather than inventing a cause", () => {
1493
+ const e = explain("Xyzzy plugh 42 frobnicated");
1494
+ if (e.rule !== "none") throw new Error(`invented rule ${e.rule}`);
1495
+ if (e.blame !== "unknown") throw new Error(`claimed blame "${e.blame}" for an unknown error`);
1496
+ });
1497
+
1498
+ check("unclassified failures are never counted as 'not the app'", () => {
1499
+ // The dangerous rounding: reporting "no application failures" when some
1500
+ // failures were simply not understood turns "we cannot tell" into an
1501
+ // all-clear.
1502
+ const line = verdictLine([{ blame: "harness", count: 5 }, { blame: "unknown", count: 3 }]);
1503
+ if (/^No application failures/.test(line)) {
1504
+ throw new Error(`claimed a clean app while 3 failures were unclassified: "${line}"`);
1505
+ }
1506
+ if (!/could not be classified/.test(line)) throw new Error(`unclassified failures not mentioned: "${line}"`);
1507
+ });
1508
+
1509
+ check("explanations are ordered by how much they happened", () => {
1510
+ const ex = explainReport({
1511
+ api: { methods: [
1512
+ { method: "a", errors: [{ message: "fetch failed", count: 2 }] },
1513
+ { method: "b", errors: [{ message: "fetch failed", count: 90 }] },
1514
+ ] },
1515
+ });
1516
+ if (ex[0].method !== "b") throw new Error("the 90-count failure was not listed first");
1517
+ });
1518
+
1519
+ // --- 11. the model layer is an enhancement, never a dependency ------------
1520
+ //
1521
+ // A report is complete and useful without it. Everything here is about the
1522
+ // model layer being unable to make things worse.
1523
+
1524
+ check("without a key, the model layer returns nothing and says nothing", async () => {
1525
+ const before = process.env.ANTHROPIC_API_KEY;
1526
+ delete process.env.ANTHROPIC_API_KEY;
1527
+ try {
1528
+ if (isConfigured({})) throw new Error("reported configured with no key anywhere");
1529
+ const out = await explainWithAI([{ method: "post", message: "something odd", count: 1 }], { config: {} });
1530
+ if (out.length) throw new Error("returned explanations without a key");
1531
+ } finally {
1532
+ if (before !== undefined) process.env.ANTHROPIC_API_KEY = before;
1533
+ }
1534
+ });
1535
+
1536
+ check("nothing to explain means no request is made", async () => {
1537
+ // Guards against a run with zero failures still costing an API call.
1538
+ const out = await explainWithAI([], { config: { ai: { apiKey: "sk-ant-would-be-wrong-to-use" } } });
1539
+ if (out.length) throw new Error("returned explanations for an empty list");
1540
+ });
1541
+
1542
+ check("a key in config is found as well as one in the environment", () => {
1543
+ const before = process.env.ANTHROPIC_API_KEY;
1544
+ delete process.env.ANTHROPIC_API_KEY;
1545
+ try {
1546
+ if (!isConfigured({ ai: { apiKey: "sk-ant-test" } })) {
1547
+ throw new Error("a key in populace.config.mjs was not found");
1548
+ }
1549
+ } finally {
1550
+ if (before !== undefined) process.env.ANTHROPIC_API_KEY = before;
1551
+ }
1552
+ });
1553
+
1554
+ check("rule explanations are never labelled as coming from the model", () => {
1555
+ // The provenance has to survive, or a reader cannot tell which explanations
1556
+ // were deterministic and which were generated.
1557
+ const e = explain("permission denied for table profiles");
1558
+ if (e.source === "model") throw new Error("a rule explanation claimed to be from the model");
1559
+ if (e.rule === "model") throw new Error("a rule explanation used the model's rule name");
1560
+ });
1561
+
1562
+ // --- 12. the update check ------------------------------------------------
1563
+
1564
+ check("version comparison orders releases correctly", () => {
1565
+ const cases = [
1566
+ ["1.0.0", "1.0.0", 0], ["1.0.1", "1.0.0", 1], ["1.0.0", "1.0.1", -1],
1567
+ ["1.10.0", "1.9.0", 1], ["2.0.0", "1.99.99", 1], ["1.0.0", "0.1.0", 1],
1568
+ ["1.0.0-beta.1", "1.0.0", 0], // pre-release tags are ignored, not parsed
1569
+ ];
1570
+ for (const [a, b, want] of cases) {
1571
+ const got = compare(a, b);
1572
+ if (got !== want) throw new Error(`compare("${a}","${b}") = ${got}, expected ${want}`);
1573
+ }
1574
+ });
1575
+
1576
+ check("10.0.0 is newer than 9.0.0, not older", () => {
1577
+ // String comparison would say "10" < "9". Numeric parsing is the whole point.
1578
+ if (compare("10.0.0", "9.0.0") !== 1) throw new Error("compared version parts as strings");
1579
+ });
1580
+
1581
+ check("CI switches the update check off without being asked", async () => {
1582
+ // A build server should not make an outbound call nobody requested, and its
1583
+ // logs should not carry an upgrade nag.
1584
+ const beforeCI = process.env.CI;
1585
+ process.env.CI = "true";
1586
+ try {
1587
+ if (!checksDisabled()) throw new Error("update checks stayed on under CI");
1588
+ const v = await latestVersion();
1589
+ if (v !== null) throw new Error("a request was made under CI");
1590
+ } finally {
1591
+ if (beforeCI === undefined) delete process.env.CI; else process.env.CI = beforeCI;
1592
+ }
1593
+ });
1594
+
1595
+ check("POPULACE_NO_UPDATE_CHECK switches it off", () => {
1596
+ const before = process.env.POPULACE_NO_UPDATE_CHECK;
1597
+ const beforeCI = process.env.CI;
1598
+ delete process.env.CI;
1599
+ process.env.POPULACE_NO_UPDATE_CHECK = "1";
1600
+ try {
1601
+ if (!checksDisabled()) throw new Error("the opt-out was ignored");
1602
+ } finally {
1603
+ if (before === undefined) delete process.env.POPULACE_NO_UPDATE_CHECK;
1604
+ else process.env.POPULACE_NO_UPDATE_CHECK = before;
1605
+ if (beforeCI !== undefined) process.env.CI = beforeCI;
1606
+ }
1607
+ });
1608
+
1358
1609
  // Async checks must settle before the total is printed. Exiting synchronously
1359
1610
  // would report "all passed" while an async assertion was still in flight — a
1360
1611
  // test suite lying about its own result, in a product whose entire argument is