lurqrun 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/lurq.js CHANGED
@@ -24,10 +24,10 @@ var init_package = __esm({
24
24
  "package.json"() {
25
25
  package_default = {
26
26
  name: "lurqrun",
27
- version: "0.1.0",
27
+ version: "0.1.1",
28
28
  description: "A continuously-updated, evidence-scored index of JS/TS frameworks and libraries, exposed as an MCP server, CLI, and agent skill \u2014 so AI coding assistants get fresh, objective dependency recommendations.",
29
29
  type: "module",
30
- license: "Apache-2.0",
30
+ license: "MIT",
31
31
  author: "jadenryu",
32
32
  repository: {
33
33
  type: "git",
@@ -81,7 +81,8 @@ var init_package = __esm({
81
81
  operator: "tsx src/bin/operator.ts",
82
82
  "operator:prod": "LURQ_ENV_FILE=.env.production tsx src/bin/operator.ts",
83
83
  prepublishOnly: "npm test && npm run build",
84
- "content:drift": "tsx scripts/gen-drift.ts"
84
+ "content:drift": "tsx scripts/gen-drift.ts",
85
+ "content:landing": "tsx scripts/build-landing-content.mts"
85
86
  },
86
87
  dependencies: {
87
88
  "@inquirer/prompts": "^8.5.2",
@@ -96,8 +97,10 @@ var init_package = __esm({
96
97
  postgres: "^3.4.9",
97
98
  "rate-limit-redis": "^4.3.1",
98
99
  semver: "^7.8.5",
100
+ stripe: "^22.6.0",
99
101
  typescript: "^5.5.4",
100
102
  "update-notifier": "^7.3.1",
103
+ yaml: "^2.9.0",
101
104
  zod: "^3.23.8"
102
105
  },
103
106
  devDependencies: {
@@ -1013,10 +1016,10 @@ ${issues}`);
1013
1016
  }
1014
1017
  function requireConfig(keys) {
1015
1018
  const config = getConfig();
1016
- const missing = keys.filter((k) => config[k] === void 0 || config[k] === "");
1017
- if (missing.length > 0) {
1019
+ const missing2 = keys.filter((k) => config[k] === void 0 || config[k] === "");
1020
+ if (missing2.length > 0) {
1018
1021
  throw new ConfigError(
1019
- `Missing required environment variable(s): ${missing.join(", ")}.
1022
+ `Missing required environment variable(s): ${missing2.join(", ")}.
1020
1023
  See .env.example for setup. Tip: copy it to .env and fill in the values.`
1021
1024
  );
1022
1025
  }
@@ -1048,8 +1051,14 @@ var init_config = __esm({
1048
1051
  * This is the rotation rate for everything discovery ingested: index size /
1049
1052
  * this = days to come all the way around. Raise it if the sync finishes well
1050
1053
  * inside the cron window; lower it if npm starts rate-limiting. 0 disables
1051
- * the rotation (seeds only, the pre-rotation behaviour). */
1052
- LURQ_SYNC_REFRESH_CAP: z.coerce.number().int().min(0).max(5e3).default(400),
1054
+ * the rotation (seeds only, the pre-rotation behaviour).
1055
+ *
1056
+ * It is also the discovery frontier's clock, which is why it is not a small
1057
+ * number. `graphChannel` re-expands a package only when its `latest_version`
1058
+ * moves, and nothing observes a version move except this rotation — so at 400
1059
+ * against a 20.8k index the graph channel could only re-arm on a 52-day lag
1060
+ * and had gone effectively silent. */
1061
+ LURQ_SYNC_REFRESH_CAP: z.coerce.number().int().min(0).max(25e3).default(2e3),
1053
1062
  LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("info"),
1054
1063
  // Hosted HTTP service (`serve-http`). Server-side only.
1055
1064
  PORT: z.coerce.number().int().positive().default(8080),
@@ -1065,6 +1074,22 @@ var init_config = __esm({
1065
1074
  * authenticated web app presents it to mint a key for a signed-in user. Unset
1066
1075
  * → the endpoint is disabled (404). Keep it server-side, never in the client. */
1067
1076
  LURQ_ISSUER_SECRET: z.string().min(1).optional(),
1077
+ // Billing (Stripe). Every secret here lives on this service and nowhere else:
1078
+ // the web app holds no Stripe credential and reaches checkout through the
1079
+ // issuer-secret routes, the same way it reaches key issuance. Unset →
1080
+ // /billing/* is disabled (404) and every account is served the free tier, so
1081
+ // a deployment without Stripe configured degrades to "free for everyone"
1082
+ // rather than to "nobody can call anything".
1083
+ STRIPE_SECRET_KEY: z.string().min(1).optional(),
1084
+ /** Signing secret for `POST /billing/webhook`. Unset → the endpoint is 404. */
1085
+ STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(),
1086
+ /** Price id backing the Pro plan. Unset → Pro cannot be checked out. */
1087
+ STRIPE_PRICE_PRO: z.string().min(1).optional(),
1088
+ /** Price id backing Enterprise. Normally unset: Enterprise is sold by
1089
+ * conversation, and `contactOnly` in core/plans.ts is what the page reads. */
1090
+ STRIPE_PRICE_ENTERPRISE: z.string().min(1).optional(),
1091
+ /** Absolute base URL the checkout returns to, e.g. https://lurq.run. */
1092
+ LURQ_WEB_URL: z.string().url().default("https://lurq.run"),
1068
1093
  // GitHub App (repo autopilot). Only the App itself holds repo access; lurq
1069
1094
  // never stores a user's personal token. Either unset → /repos is disabled (404).
1070
1095
  // The App *slug* is deliberately not here: only the web app needs it, to build
@@ -1073,6 +1098,10 @@ var init_config = __esm({
1073
1098
  LURQ_GITHUB_APP_ID: z.string().min(1).optional(),
1074
1099
  /** PEM private key. Accepts literal newlines or `\n` escapes (Railway/Vercel). */
1075
1100
  LURQ_GITHUB_APP_PRIVATE_KEY: z.string().min(1).optional(),
1101
+ /** Webhook secret from the same App settings page, so repo changes made on
1102
+ * GitHub reach us. Unset → `POST /github/webhook` is disabled (404): an
1103
+ * unverified webhook is an open write endpoint, so no secret means no route. */
1104
+ LURQ_GITHUB_WEBHOOK_SECRET: z.string().min(1).optional(),
1076
1105
  // Client-side (install wizard / CLI talking to a remote endpoint).
1077
1106
  LURQ_ENDPOINT: z.string().url().optional(),
1078
1107
  LURQ_API_KEY: z.string().min(1).optional(),
@@ -1102,11 +1131,12 @@ function isCategory(value) {
1102
1131
  function isFrontendCategory(category) {
1103
1132
  return category != null && FRONTEND_CATEGORIES.includes(category);
1104
1133
  }
1105
- var CATEGORIES, FRONTEND_CATEGORIES;
1134
+ var DEFAULT_ECOSYSTEM, CATEGORIES, FRONTEND_CATEGORIES;
1106
1135
  var init_types = __esm({
1107
1136
  "src/core/types.ts"() {
1108
1137
  "use strict";
1109
1138
  init_esm_shims();
1139
+ DEFAULT_ECOSYSTEM = "npm";
1110
1140
  CATEGORIES = [
1111
1141
  "framework",
1112
1142
  "meta-framework",
@@ -1147,11 +1177,255 @@ var init_types = __esm({
1147
1177
  }
1148
1178
  });
1149
1179
 
1180
+ // src/core/capabilities.ts
1181
+ function searchCapabilities(query, limit = 5) {
1182
+ const terms = tokenize(query ?? "");
1183
+ if (!terms.length) return CAPABILITIES.slice(0, limit).map((c) => ({ ...c, score: 0 }));
1184
+ const scored = CAPABILITIES.map((c) => {
1185
+ let score = 0;
1186
+ for (const { of, weight } of FIELDS) {
1187
+ const haystack = tokenize(of(c));
1188
+ for (const term of terms) {
1189
+ if (haystack.includes(term)) score += weight;
1190
+ else if (haystack.some((h) => sameStem(h, term))) score += weight / 2;
1191
+ }
1192
+ }
1193
+ return { ...c, score };
1194
+ }).filter((c) => c.score > 0);
1195
+ if (!scored.length) return CAPABILITIES.slice(0, limit).map((c) => ({ ...c, score: 0 }));
1196
+ return scored.sort((a, b) => b.score - a.score).slice(0, limit);
1197
+ }
1198
+ var CAPABILITIES, STOP, tokenize, STEM_LEN, sameStem, FIELDS;
1199
+ var init_capabilities = __esm({
1200
+ "src/core/capabilities.ts"() {
1201
+ "use strict";
1202
+ init_esm_shims();
1203
+ CAPABILITIES = [
1204
+ {
1205
+ id: "verify",
1206
+ title: "Check a package is real",
1207
+ question: "Does this package actually exist, and is it safe to install?",
1208
+ answer: "Checks the live registry for existence, deprecation, archival and advisories \u2014 the guard against a hallucinated or typosquatted name reaching package.json.",
1209
+ mcp: "verify",
1210
+ cli: "lurq verify <package>",
1211
+ aliases: ["hallucinated", "typosquat", "slopsquat", "fake", "malicious", "safe", "exists"]
1212
+ },
1213
+ {
1214
+ id: "recommend",
1215
+ title: "Pick a package for a need",
1216
+ question: "What should I use for this?",
1217
+ answer: "Returns evidence-scored candidates for a described need, with a confidence label on each. Ranks from current signals, not from what was popular at a training cutoff.",
1218
+ mcp: "recommend",
1219
+ cli: 'lurq recommend "<what you need>"',
1220
+ aliases: ["choose", "library", "best", "alternative", "which"]
1221
+ },
1222
+ {
1223
+ id: "evaluate",
1224
+ title: "Read the evidence on a package",
1225
+ question: "Is this package healthy, maintained, and worth adopting?",
1226
+ answer: "The full evidence read for one package: health and quality scores with the signals behind them, release cadence, advisories, and how to wire it up.",
1227
+ mcp: "evaluate",
1228
+ cli: "lurq evaluate <package>",
1229
+ aliases: ["health", "maintained", "abandoned", "deprecated", "score", "quality"]
1230
+ },
1231
+ {
1232
+ id: "compare",
1233
+ title: "Compare packages side by side",
1234
+ question: "Which of these two should I go with?",
1235
+ answer: "Ranks 2\u20135 named packages against each other on the same scored evidence.",
1236
+ mcp: "compare",
1237
+ cli: "lurq compare <a> <b>",
1238
+ aliases: ["versus", "vs", "shootout", "better"]
1239
+ },
1240
+ {
1241
+ id: "compat",
1242
+ title: "Check a stack fits together",
1243
+ question: "Will these packages work together?",
1244
+ answer: "Peer-dependency and engine checks across the whole set at once, plus any sandbox-verified conflicts on record. Returns the exact clashing constraints.",
1245
+ mcp: "compat",
1246
+ cli: "lurq compat <a> <b> <c>",
1247
+ aliases: ["peer", "conflict", "incompatible", "engines", "node version", "together"]
1248
+ },
1249
+ {
1250
+ id: "usage",
1251
+ title: "See a version-exact API",
1252
+ question: "What does this package actually export at this version?",
1253
+ answer: "The exported symbols and signatures for one exact version, read from the shipped JS \u2014 plus the delta from a version you already know.",
1254
+ mcp: "usage",
1255
+ cli: "lurq usage <package> --target <version>",
1256
+ aliases: ["exports", "symbols", "api", "signature", "surface"]
1257
+ },
1258
+ {
1259
+ id: "diff-surface",
1260
+ title: "See what an upgrade removes",
1261
+ question: "What breaks between these two versions?",
1262
+ answer: "The symbol-level diff between two published versions: what was removed, what changed arity, what is new. Runtime breakage is reported separately from type-only breakage.",
1263
+ mcp: "diff_surface",
1264
+ cli: "lurq usage <package> --known <old> --target <new>",
1265
+ aliases: ["breaking", "removed", "changed", "migration", "major"]
1266
+ },
1267
+ {
1268
+ id: "plan",
1269
+ title: "Turn a spec into a stack",
1270
+ question: "I have a project description \u2014 what should I build it with?",
1271
+ answer: "Decomposes a written spec into component slots, recommends a scored package per slot, and optimises the set for compatibility. A starter stack, not an architecture.",
1272
+ mcp: "plan",
1273
+ cli: "lurq plan <file.md> --open",
1274
+ aliases: ["spec", "readme", "greenfield", "scaffold", "roadmap", "stack"]
1275
+ },
1276
+ {
1277
+ id: "diagram",
1278
+ title: "Draw the stack",
1279
+ question: "Can I see this as a diagram?",
1280
+ answer: "Renders a planned or existing stack as a Mermaid graph other tools can parse.",
1281
+ mcp: "diagram",
1282
+ cli: "lurq plan <file.md> --html <out.html>",
1283
+ aliases: ["mermaid", "visual", "chart", "architecture", "graph"]
1284
+ },
1285
+ {
1286
+ id: "upgrade-plan",
1287
+ title: "Find what is behind",
1288
+ question: "How far out of date is this project?",
1289
+ answer: "Reads the local manifests and reports the gap between what is resolved and what is current, with what each upgrade removes from its API. Sends dependency ranges only, never source.",
1290
+ cli: "lurq upgrade-plan",
1291
+ page: "/dashboard/repos",
1292
+ aliases: ["drift", "outdated", "behind", "stale", "update", "upgrade", "bump", "dependencies"]
1293
+ },
1294
+ {
1295
+ id: "check-upgrade",
1296
+ title: "Check an upgrade against your code",
1297
+ question: "Will this upgrade break my code specifically?",
1298
+ answer: "Intersects what the upgrade removes with what your code actually references, and answers with file:line. Needs no test suite and no network to lurq.",
1299
+ cli: "lurq check-upgrade --plan plan.json --exit-code",
1300
+ aliases: ["breaking", "ci", "gate", "call sites", "references", "blocking"]
1301
+ },
1302
+ {
1303
+ id: "check-release",
1304
+ title: "Check your own version bump",
1305
+ question: "Am I about to break my consumers?",
1306
+ answer: "Compares the built working tree against your last published version and says whether the bump you are about to tag covers what actually changed. For package authors.",
1307
+ cli: "lurq check-release --exit-code",
1308
+ aliases: ["publish", "semver", "release", "major", "prepublish", "library author"]
1309
+ },
1310
+ {
1311
+ id: "check-api",
1312
+ title: "Check your own API for breaks",
1313
+ question: "Will this change break the people calling my service?",
1314
+ answer: "Diffs two git revisions of your OpenAPI document: removed operations, newly required parameters and body fields, removed response codes \u2014 and whether info.version was bumped enough. Reads git only, nothing leaves the machine.",
1315
+ cli: "lurq check-api --against origin/main --exit-code",
1316
+ aliases: ["openapi", "swagger", "endpoint", "consumers", "callers", "service", "contract", "rest"]
1317
+ },
1318
+ {
1319
+ id: "autopilot",
1320
+ title: "Keep a repo current automatically",
1321
+ question: "Can lurq just do the upgrades for me?",
1322
+ answer: "Connect a repo and lurq drafts the upgrade, names the call sites it breaks, and opens a PR from your own runner. lurq never gets write access to your code.",
1323
+ page: "/dashboard/repos",
1324
+ cli: "lurq upgrade-plan --json",
1325
+ aliases: ["automate", "pr", "github", "renovate", "dependabot", "autopilot", "workflow"]
1326
+ },
1327
+ {
1328
+ id: "policy",
1329
+ title: "Set what agents may install",
1330
+ question: "How do I stop agents adding packages we do not allow?",
1331
+ answer: "An allow/deny list plus licence and confidence floors, enforced at the moment an agent reaches for a dependency \u2014 before it becomes a line in package.json.",
1332
+ page: "/dashboard/policy",
1333
+ aliases: ["allowlist", "whitelist", "deny", "blocklist", "licence", "license", "govern", "standard"]
1334
+ },
1335
+ {
1336
+ id: "alerts",
1337
+ title: "Watch repos for new risk",
1338
+ question: "Tell me when something I depend on goes bad",
1339
+ answer: "Per-repo advisories and deprecations across the resolved tree, with the direct dependency responsible for each transitive finding.",
1340
+ page: "/dashboard/repos",
1341
+ aliases: ["advisory", "cve", "vulnerability", "security", "transitive", "sbom", "alert"]
1342
+ },
1343
+ {
1344
+ id: "usage-dashboard",
1345
+ title: "See how lurq is being used",
1346
+ question: "Who on my team is calling lurq, and how much?",
1347
+ answer: "Call volume by tool and by day, plus the outcomes reported back after a recommendation.",
1348
+ page: "/dashboard/usage",
1349
+ aliases: ["activity", "analytics", "volume", "calls", "team", "adoption", "heatmap"]
1350
+ },
1351
+ {
1352
+ id: "setup",
1353
+ title: "Connect lurq to an assistant",
1354
+ question: "How do I get this into Claude Code / Cursor?",
1355
+ answer: "One command detects every assistant on the machine and writes the MCP entry. No database credentials touch your machine.",
1356
+ cli: "npx lurqrun",
1357
+ page: "/dashboard/keys",
1358
+ aliases: ["install", "mcp", "cursor", "claude code", "windsurf", "copilot", "api key", "onboard"]
1359
+ },
1360
+ {
1361
+ id: "weights",
1362
+ title: "Change how scoring works",
1363
+ question: "I disagree with the ranking \u2014 can I tune it?",
1364
+ answer: "Show the weight model, or layer your own overrides on top of the defaults, per user or per project.",
1365
+ cli: "lurq edit-weights --set composite.lambda=0.5",
1366
+ aliases: ["scoring", "ranking", "tune", "bias", "lambda", "override"]
1367
+ },
1368
+ {
1369
+ id: "report-outcome",
1370
+ title: "Tell lurq how it went",
1371
+ question: "How does lurq learn from what I picked?",
1372
+ answer: "An opt-in report of whether you took the recommendation and whether it built. Coarse signal only \u2014 never source code.",
1373
+ mcp: "report_outcome",
1374
+ aliases: ["feedback", "outcome", "accepted", "flywheel", "learn"]
1375
+ }
1376
+ ];
1377
+ STOP = /* @__PURE__ */ new Set([
1378
+ "a",
1379
+ "an",
1380
+ "the",
1381
+ "is",
1382
+ "it",
1383
+ "i",
1384
+ "my",
1385
+ "me",
1386
+ "do",
1387
+ "does",
1388
+ "how",
1389
+ "what",
1390
+ "which",
1391
+ "to",
1392
+ "for",
1393
+ "of",
1394
+ "in",
1395
+ "on",
1396
+ "and",
1397
+ "or",
1398
+ "can",
1399
+ "with",
1400
+ "this",
1401
+ "that",
1402
+ "be",
1403
+ "am",
1404
+ "are",
1405
+ "should",
1406
+ "lurq",
1407
+ "package",
1408
+ "packages"
1409
+ ]);
1410
+ tokenize = (s) => s.toLowerCase().split(/[^a-z0-9.@/]+/).filter((t) => t.length > 1 && !STOP.has(t));
1411
+ STEM_LEN = 5;
1412
+ sameStem = (a, b) => a.length >= STEM_LEN && b.length >= STEM_LEN && a.slice(0, STEM_LEN) === b.slice(0, STEM_LEN);
1413
+ FIELDS = [
1414
+ { of: (c) => c.title, weight: 5 },
1415
+ { of: (c) => c.question, weight: 4 },
1416
+ { of: (c) => (c.aliases ?? []).join(" "), weight: 3 },
1417
+ { of: (c) => c.answer, weight: 1 },
1418
+ { of: (c) => [c.mcp, c.cli, c.page].filter(Boolean).join(" "), weight: 4 }
1419
+ ];
1420
+ }
1421
+ });
1422
+
1150
1423
  // src/db/schema.ts
1151
1424
  var schema_exports = {};
1152
1425
  __export(schema_exports, {
1153
1426
  apiKeys: () => apiKeys,
1154
1427
  apiSurfaces: () => apiSurfaces,
1428
+ askSpendDaily: () => askSpendDaily,
1155
1429
  claims: () => claims,
1156
1430
  compatEdges: () => compatEdges,
1157
1431
  compatVerifyQueue: () => compatVerifyQueue,
@@ -1168,6 +1442,7 @@ __export(schema_exports, {
1168
1442
  resolvedClosures: () => resolvedClosures,
1169
1443
  seedPackages: () => seedPackages,
1170
1444
  selectionPolicies: () => selectionPolicies,
1445
+ subscriptions: () => subscriptions,
1171
1446
  surfaceQueue: () => surfaceQueue,
1172
1447
  symbols: () => symbols,
1173
1448
  syncRuns: () => syncRuns,
@@ -1193,7 +1468,7 @@ import {
1193
1468
  uniqueIndex,
1194
1469
  vector
1195
1470
  } from "drizzle-orm/pg-core";
1196
- var tsvector, ts, packages, syncRuns, seedPackages, discoveryQueue, compatVerifyQueue, apiKeys, packageVersions, watchState, verificationRuns, compatEdges, resolvedClosures, apiSurfaces, recommendationOutcomes, ownerUsageDaily, repos, upgradeRuns, repoAlerts, surfaceQueue, entities, symbols, environments, claims, observations, selectionPolicies;
1471
+ var tsvector, ts, packages, syncRuns, seedPackages, discoveryQueue, compatVerifyQueue, apiKeys, packageVersions, watchState, verificationRuns, compatEdges, resolvedClosures, apiSurfaces, recommendationOutcomes, askSpendDaily, ownerUsageDaily, repos, upgradeRuns, repoAlerts, surfaceQueue, entities, symbols, environments, claims, observations, selectionPolicies, subscriptions;
1197
1472
  var init_schema = __esm({
1198
1473
  "src/db/schema.ts"() {
1199
1474
  "use strict";
@@ -1209,8 +1484,8 @@ var init_schema = __esm({
1209
1484
  "packages",
1210
1485
  {
1211
1486
  id: serial("id").primaryKey(),
1212
- name: text("name").notNull().unique(),
1213
- ecosystem: text("ecosystem").notNull().default("npm"),
1487
+ name: text("name").notNull(),
1488
+ ecosystem: text("ecosystem").$type().notNull().default("npm"),
1214
1489
  // Classification + descriptive text
1215
1490
  category: text("category").$type(),
1216
1491
  /** Whether `category` was hand-curated or inferred at ingest (§2A). */
@@ -1282,6 +1557,7 @@ var init_schema = __esm({
1282
1557
  firstRequestedByOwnerId: text("first_requested_by_owner_id")
1283
1558
  },
1284
1559
  (table2) => [
1560
+ uniqueIndex("packages_ecosystem_name_idx").on(table2.ecosystem, table2.name),
1285
1561
  index("packages_category_idx").on(table2.category),
1286
1562
  index("packages_health_score_idx").on(table2.healthScore),
1287
1563
  // pgvector HNSW index for cosine similarity search (§11).
@@ -1435,9 +1711,7 @@ var init_schema = __esm({
1435
1711
  nodes: jsonb("nodes").$type().notNull(),
1436
1712
  fetchedAt: ts("fetched_at").notNull().defaultNow()
1437
1713
  },
1438
- (table2) => [
1439
- uniqueIndex("resolved_closures_pkg_idx").on(table2.packageName, table2.version)
1440
- ]
1714
+ (table2) => [uniqueIndex("resolved_closures_pkg_idx").on(table2.packageName, table2.version)]
1441
1715
  );
1442
1716
  apiSurfaces = pgTable(
1443
1717
  "api_surfaces",
@@ -1473,6 +1747,16 @@ var init_schema = __esm({
1473
1747
  index("recommendation_outcomes_owner_idx").on(table2.ownerId)
1474
1748
  ]
1475
1749
  );
1750
+ askSpendDaily = pgTable(
1751
+ "ask_spend_daily",
1752
+ {
1753
+ ownerId: text("owner_id").notNull(),
1754
+ /** UTC day, 'YYYY-MM-DD'. */
1755
+ date: date("date").notNull(),
1756
+ usdMicros: bigint("usd_micros", { mode: "number" }).notNull().default(0)
1757
+ },
1758
+ (table2) => [primaryKey({ columns: [table2.ownerId, table2.date] })]
1759
+ );
1476
1760
  ownerUsageDaily = pgTable(
1477
1761
  "owner_usage_daily",
1478
1762
  {
@@ -1601,11 +1885,7 @@ var init_schema = __esm({
1601
1885
  // One alert per repo per release. A re-sync of the same version — and the
1602
1886
  // watcher re-syncs on every publish, including non-latest backports — must
1603
1887
  // not re-notify.
1604
- uniqueIndex("repo_alerts_dedup_idx").on(
1605
- table2.repoId,
1606
- table2.packageName,
1607
- table2.toVersion
1608
- )
1888
+ uniqueIndex("repo_alerts_dedup_idx").on(table2.repoId, table2.packageName, table2.toVersion)
1609
1889
  ]
1610
1890
  );
1611
1891
  surfaceQueue = pgTable(
@@ -1681,7 +1961,9 @@ var init_schema = __esm({
1681
1961
  // they are not interchangeable (§6.4.3). Without the tier in the key, storing a
1682
1962
  // tier-C surface would silently overwrite the tier-A one that answers runtime
1683
1963
  // existence — replacing the authoritative answer with a type-level guess.
1684
- (table2) => [uniqueIndex("symbols_entity_path_tier_idx").on(table2.entityId, table2.path, table2.tier)]
1964
+ (table2) => [
1965
+ uniqueIndex("symbols_entity_path_tier_idx").on(table2.entityId, table2.path, table2.tier)
1966
+ ]
1685
1967
  );
1686
1968
  environments = pgTable("environments", {
1687
1969
  id: serial("id").primaryKey(),
@@ -1742,6 +2024,33 @@ var init_schema = __esm({
1742
2024
  policy: jsonb("policy").$type().notNull(),
1743
2025
  updatedAt: ts("updated_at").notNull().defaultNow()
1744
2026
  });
2027
+ subscriptions = pgTable(
2028
+ "subscriptions",
2029
+ {
2030
+ /** Clerk user id. One subscription per account. */
2031
+ ownerId: text("owner_id").primaryKey(),
2032
+ /** Stable across subscriptions: a customer who cancels and returns reuses it. */
2033
+ stripeCustomerId: text("stripe_customer_id").notNull().unique(),
2034
+ /** Null between creating a customer and completing a first checkout. */
2035
+ stripeSubscriptionId: text("stripe_subscription_id").unique(),
2036
+ tier: text("tier").$type().notNull().default("free"),
2037
+ /** Raw Stripe subscription status. `null` = never subscribed. */
2038
+ status: text("status"),
2039
+ /** When the paid period lapses. Display only; `status` is what gates access. */
2040
+ currentPeriodEnd: ts("current_period_end"),
2041
+ /** Set when the user cancels but has paid through the period. */
2042
+ cancelAtPeriodEnd: boolean("cancel_at_period_end").notNull().default(false),
2043
+ /**
2044
+ * Stripe delivers out of order and retries, so a late duplicate of an older
2045
+ * event must not overwrite newer state. The webhook drops any event whose
2046
+ * timestamp is older than the one that produced the current row.
2047
+ */
2048
+ lastEventAt: ts("last_event_at"),
2049
+ createdAt: ts("created_at").notNull().defaultNow(),
2050
+ updatedAt: ts("updated_at").notNull().defaultNow()
2051
+ },
2052
+ (table2) => [index("subscriptions_customer_idx").on(table2.stripeCustomerId)]
2053
+ );
1745
2054
  }
1746
2055
  });
1747
2056
 
@@ -1750,28 +2059,34 @@ import { drizzle } from "drizzle-orm/postgres-js";
1750
2059
  import postgres from "postgres";
1751
2060
  function createDb(opts = {}) {
1752
2061
  const { DATABASE_URL } = requireConfig(["DATABASE_URL"]);
1753
- const sql14 = postgres(DATABASE_URL, { max: opts.max ?? 10, onnotice: () => {
1754
- } });
1755
- const db = drizzle(sql14, { schema: schema_exports });
1756
- return { db, sql: sql14, close: () => sql14.end() };
2062
+ const sql16 = postgres(DATABASE_URL, {
2063
+ max: opts.max ?? 10,
2064
+ onnotice: () => {
2065
+ },
2066
+ connection: { statement_timeout: STATEMENT_TIMEOUT_MS }
2067
+ });
2068
+ const db = drizzle(sql16, { schema: schema_exports });
2069
+ return { db, sql: sql16, close: () => sql16.end() };
1757
2070
  }
2071
+ var STATEMENT_TIMEOUT_MS;
1758
2072
  var init_client = __esm({
1759
2073
  "src/db/client.ts"() {
1760
2074
  "use strict";
1761
2075
  init_esm_shims();
1762
2076
  init_config();
1763
2077
  init_schema();
2078
+ STATEMENT_TIMEOUT_MS = 15e3;
1764
2079
  }
1765
2080
  });
1766
2081
 
1767
2082
  // src/db/packages.ts
1768
- import { and, desc, eq, inArray, isNotNull, isNull, lt, notExists, sql as sql2 } from "drizzle-orm";
1769
- async function getPackageByName(db, name) {
1770
- const rows = await db.select().from(packages).where(eq(packages.name, name)).limit(1);
2083
+ import { and, desc, eq, gte, inArray, isNotNull, isNull, lt, notExists, sql as sql2 } from "drizzle-orm";
2084
+ async function getPackageByName(db, name, ecosystem = DEFAULT_ECOSYSTEM) {
2085
+ const rows = await db.select().from(packages).where(and(eq(packages.name, name), eq(packages.ecosystem, ecosystem))).limit(1);
1771
2086
  return rows[0] ?? null;
1772
2087
  }
1773
- async function getAllPackageNames(db) {
1774
- const rows = await db.select({ name: packages.name }).from(packages);
2088
+ async function getAllPackageNames(db, ecosystem = DEFAULT_ECOSYSTEM) {
2089
+ const rows = await db.select({ name: packages.name }).from(packages).where(eq(packages.ecosystem, ecosystem));
1775
2090
  return rows.map((r) => r.name);
1776
2091
  }
1777
2092
  async function upsertPackageVersions(db, name, versions) {
@@ -1802,14 +2117,20 @@ async function ensureSeedEntry(db, name, category) {
1802
2117
  await db.insert(seedPackages).values({ name, category }).onConflictDoNothing({ target: seedPackages.name });
1803
2118
  }
1804
2119
  async function upsertPackage(db, row) {
1805
- const { name: _name, createdAt: _createdAt, ...mutable } = row;
2120
+ const { name: _name, ecosystem: _ecosystem, createdAt: _createdAt, ...mutable } = row;
1806
2121
  await db.insert(packages).values(row).onConflictDoUpdate({
1807
- target: packages.name,
2122
+ target: [packages.ecosystem, packages.name],
1808
2123
  set: { ...mutable, updatedAt: /* @__PURE__ */ new Date() }
1809
2124
  });
1810
2125
  }
1811
- async function stampFirstRequester(db, name, ownerId) {
1812
- await db.update(packages).set({ firstRequestedByOwnerId: ownerId }).where(and(eq(packages.name, name), isNull(packages.firstRequestedByOwnerId)));
2126
+ async function stampFirstRequester(db, name, ownerId, ecosystem = DEFAULT_ECOSYSTEM) {
2127
+ await db.update(packages).set({ firstRequestedByOwnerId: ownerId }).where(
2128
+ and(
2129
+ eq(packages.name, name),
2130
+ eq(packages.ecosystem, ecosystem),
2131
+ isNull(packages.firstRequestedByOwnerId)
2132
+ )
2133
+ );
1813
2134
  }
1814
2135
  async function getContributionsByOwner(db, ownerId, opts = {}) {
1815
2136
  const limit = opts.limit ?? 50;
@@ -1831,6 +2152,7 @@ var init_packages = __esm({
1831
2152
  "use strict";
1832
2153
  init_esm_shims();
1833
2154
  init_logger();
2155
+ init_types();
1834
2156
  init_schema();
1835
2157
  VERSION_CHUNK = 500;
1836
2158
  SYNC_STALE_MS = 6 * 60 * 60 * 1e3;
@@ -1879,13 +2201,16 @@ async function namespaceVersion(client) {
1879
2201
  versionMemo = { value, at: Date.now() };
1880
2202
  return value;
1881
2203
  }
2204
+ function codeVersion() {
2205
+ return VERSION;
2206
+ }
1882
2207
  async function cached2(namespace, key, compute, opts = {}) {
1883
2208
  const client = await getClient();
1884
2209
  if (!client) return compute();
1885
2210
  let fullKey;
1886
2211
  try {
1887
2212
  const version = await namespaceVersion(client);
1888
- fullKey = `lurq:${namespace}:${version}:${key}`;
2213
+ fullKey = `lurq:${namespace}:${codeVersion()}:${version}:${key}`;
1889
2214
  const hit = await client.get(fullKey);
1890
2215
  if (hit != null) return JSON.parse(hit);
1891
2216
  } catch (err) {
@@ -1914,6 +2239,7 @@ var init_cache = __esm({
1914
2239
  "src/core/cache.ts"() {
1915
2240
  "use strict";
1916
2241
  init_esm_shims();
2242
+ init_constants();
1917
2243
  init_logger();
1918
2244
  VERSION_KEY = "lurq:cachever";
1919
2245
  DEFAULT_TTL_SEC = 3600;
@@ -1925,7 +2251,7 @@ var init_cache = __esm({
1925
2251
 
1926
2252
  // src/db/compat.ts
1927
2253
  import { and as and2, eq as eq2, inArray as inArray2, sql as sql3 } from "drizzle-orm";
1928
- async function getCompatMetadata(db, names2) {
2254
+ async function getCompatMetadata(db, names2, ecosystem = DEFAULT_ECOSYSTEM) {
1929
2255
  if (names2.length === 0) return [];
1930
2256
  return db.select({
1931
2257
  name: packages.name,
@@ -1933,7 +2259,7 @@ async function getCompatMetadata(db, names2) {
1933
2259
  peerDependencies: packages.peerDependencies,
1934
2260
  peerDependenciesMeta: packages.peerDependenciesMeta,
1935
2261
  engines: packages.engines
1936
- }).from(packages).where(inArray2(packages.name, names2));
2262
+ }).from(packages).where(and2(inArray2(packages.name, names2), eq2(packages.ecosystem, ecosystem)));
1937
2263
  }
1938
2264
  function canonicalPair(a, b) {
1939
2265
  const [low, high] = a.name <= b.name ? [a, b] : [b, a];
@@ -2027,6 +2353,7 @@ var init_compat = __esm({
2027
2353
  "src/db/compat.ts"() {
2028
2354
  "use strict";
2029
2355
  init_esm_shims();
2356
+ init_types();
2030
2357
  init_schema();
2031
2358
  CONFLICT_TARGET = [
2032
2359
  compatEdges.packageA,
@@ -2171,7 +2498,7 @@ function parseRetryAfter(value) {
2171
2498
  return Number.isNaN(date2) ? void 0 : Math.max(0, date2 - Date.now());
2172
2499
  }
2173
2500
  function delay(ms) {
2174
- return new Promise((resolve) => setTimeout(resolve, ms));
2501
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
2175
2502
  }
2176
2503
  var HttpError, DEFAULT_HOST_CONFIG, HOST_CONFIG, HostLimiter, limiters, MEMORY_CACHE_MAX, memoryCache, bypassCacheRead, RETRYABLE;
2177
2504
  var init_http = __esm({
@@ -2211,7 +2538,7 @@ var init_http = __esm({
2211
2538
  queue = [];
2212
2539
  async acquire() {
2213
2540
  if (this.active >= this.config.maxConcurrent) {
2214
- await new Promise((resolve) => this.queue.push(resolve));
2541
+ await new Promise((resolve2) => this.queue.push(resolve2));
2215
2542
  }
2216
2543
  this.active++;
2217
2544
  const now = Date.now();
@@ -2401,6 +2728,20 @@ async function fetchNpmCompatAtVersion(name, version, fetchImpl) {
2401
2728
  return null;
2402
2729
  }
2403
2730
  }
2731
+ async function npmVersionExists(name, version, fetchImpl) {
2732
+ const url = `https://${HOST}/${encodeNpmName(name)}`;
2733
+ try {
2734
+ const { data } = await httpGetJson(url, {
2735
+ host: HOST,
2736
+ ttlMs: CACHE_TTL.npmRegistry,
2737
+ fetchImpl
2738
+ });
2739
+ if (!data?.versions || typeof data.versions !== "object") return null;
2740
+ return Object.prototype.hasOwnProperty.call(data.versions, version);
2741
+ } catch {
2742
+ return null;
2743
+ }
2744
+ }
2404
2745
  async function npmPackageExists(name, fetchImpl) {
2405
2746
  const url = `https://${HOST}/${encodeNpmName(name)}`;
2406
2747
  try {
@@ -3048,6 +3389,23 @@ var init_diff = __esm({
3048
3389
  }
3049
3390
  });
3050
3391
 
3392
+ // src/core/concurrency.ts
3393
+ function withBudget(task, ms) {
3394
+ let timer;
3395
+ return Promise.race([
3396
+ task,
3397
+ new Promise((resolve2) => {
3398
+ timer = setTimeout(resolve2, ms, null);
3399
+ })
3400
+ ]).finally(() => clearTimeout(timer));
3401
+ }
3402
+ var init_concurrency = __esm({
3403
+ "src/core/concurrency.ts"() {
3404
+ "use strict";
3405
+ init_esm_shims();
3406
+ }
3407
+ });
3408
+
3051
3409
  // src/db/apiSurfaces.ts
3052
3410
  import { and as and3, eq as eq3, isNotNull as isNotNull2, isNull as isNull2, sql as sql4 } from "drizzle-orm";
3053
3411
  async function getStoredSurface(db, packageName, version) {
@@ -3092,8 +3450,8 @@ async function fetchFile(url, opts, ttlMs = CACHE_TTL.depsDev) {
3092
3450
  });
3093
3451
  return { text: typeof data === "string" ? data : null, unknown: false };
3094
3452
  } catch (err) {
3095
- const missing = err instanceof HttpError && err.status === 404;
3096
- return { text: null, unknown: !missing };
3453
+ const missing2 = err instanceof HttpError && err.status === 404;
3454
+ return { text: null, unknown: !missing2 };
3097
3455
  }
3098
3456
  }
3099
3457
  async function fetchText(url, opts) {
@@ -3341,9 +3699,6 @@ var init_extract = __esm({
3341
3699
  });
3342
3700
 
3343
3701
  // src/usage/service.ts
3344
- function raceBudget(task, budgetMs) {
3345
- return Promise.race([task, new Promise((resolve) => setTimeout(resolve, budgetMs, null))]);
3346
- }
3347
3702
  function extractOnce(db, name, version, fetchOpts) {
3348
3703
  const key = `${name}@${version}`;
3349
3704
  const existing = inFlight.get(key);
@@ -3363,13 +3718,14 @@ async function getOrExtractSurface(db, name, version, opts = {}) {
3363
3718
  if (opts.budgetMs === 0) return null;
3364
3719
  const fetchOpts = opts.fetch ?? (opts.budgetMs === void 0 ? {} : READ_PATH_FETCH);
3365
3720
  const task = extractOnce(db, name, version, fetchOpts);
3366
- return opts.budgetMs === void 0 ? task : raceBudget(task, opts.budgetMs);
3721
+ return opts.budgetMs === void 0 ? task : withBudget(task, opts.budgetMs);
3367
3722
  }
3368
3723
  var USAGE_EXTRACT_BUDGET_MS, READ_PATH_FETCH, MAX_INFLIGHT, inFlight;
3369
3724
  var init_service = __esm({
3370
3725
  "src/usage/service.ts"() {
3371
3726
  "use strict";
3372
3727
  init_esm_shims();
3728
+ init_concurrency();
3373
3729
  init_apiSurfaces();
3374
3730
  init_extract();
3375
3731
  USAGE_EXTRACT_BUDGET_MS = 4e3;
@@ -3494,7 +3850,7 @@ var init_successors2 = __esm({
3494
3850
  });
3495
3851
 
3496
3852
  // src/db/outcomes.ts
3497
- import { count, desc as desc3, eq as eq5, inArray as inArray3, sql as sql5 } from "drizzle-orm";
3853
+ import { and as and4, count, desc as desc3, eq as eq5, inArray as inArray3, sql as sql5 } from "drizzle-orm";
3498
3854
  import { alias } from "drizzle-orm/pg-core";
3499
3855
  async function recordOutcome(db, outcome) {
3500
3856
  await db.insert(recommendationOutcomes).values(outcome);
@@ -3545,7 +3901,7 @@ async function loadLearnedSuccessors(db, now = Date.now()) {
3545
3901
  return learnedCache.value;
3546
3902
  }
3547
3903
  const names2 = [...new Set(observed.map((o) => o.from))];
3548
- const rows = await db.select({ name: packages.name, deprecated: packages.deprecated, archived: packages.archived }).from(packages).where(inArray3(packages.name, names2));
3904
+ const rows = await db.select({ name: packages.name, deprecated: packages.deprecated, archived: packages.archived }).from(packages).where(and4(inArray3(packages.name, names2), eq5(packages.ecosystem, DEFAULT_ECOSYSTEM)));
3549
3905
  const dead = new Set(rows.filter((r) => r.deprecated || r.archived).map((r) => r.name));
3550
3906
  const value = buildLearnedSuccessors(observed, (name) => dead.has(name));
3551
3907
  learnedCache = { at: now, value };
@@ -3559,6 +3915,7 @@ var init_outcomes = __esm({
3559
3915
  "src/db/outcomes.ts"() {
3560
3916
  "use strict";
3561
3917
  init_esm_shims();
3918
+ init_types();
3562
3919
  init_successors2();
3563
3920
  init_schema();
3564
3921
  SUCCESS_PREDICATE = sql5`${recommendationOutcomes.accepted} AND (${recommendationOutcomes.buildSignal} IS NULL OR ${recommendationOutcomes.buildSignal} <> 'failed')`;
@@ -3933,10 +4290,10 @@ function settableKeys() {
3933
4290
  function applyOverrides(base, sets) {
3934
4291
  const next = structuredClone(base);
3935
4292
  for (const entry of sets) {
3936
- const eq18 = entry.indexOf("=");
3937
- if (eq18 < 0) throw new Error(`Invalid --set "${entry}" (expected key=value).`);
3938
- const key = entry.slice(0, eq18).trim();
3939
- const value = Number(entry.slice(eq18 + 1).trim());
4293
+ const eq25 = entry.indexOf("=");
4294
+ if (eq25 < 0) throw new Error(`Invalid --set "${entry}" (expected key=value).`);
4295
+ const key = entry.slice(0, eq25).trim();
4296
+ const value = Number(entry.slice(eq25 + 1).trim());
3940
4297
  const apply = SETTABLE[key];
3941
4298
  if (!apply) {
3942
4299
  throw new Error(`Unknown weight key "${key}". Settable: ${settableKeys().join(", ")}.`);
@@ -3964,7 +4321,7 @@ function resetWeights() {
3964
4321
  resetWeightsCache();
3965
4322
  return removed;
3966
4323
  }
3967
- var HEALTH_WEIGHTS, COMPOSITE, QUALITY_WEIGHTS, QUALITY, MAINTENANCE_WEIGHTS, MAINTENANCE, ADOPTION, RELIABILITY, EFFICIENCY, FIELD, DEFAULT_WEIGHTS, cachedWeights, SETTABLE, WEIGHT_EXPLANATIONS, CONFIDENCE;
4324
+ var HEALTH_WEIGHTS, COMPOSITE, RANKING, CONFIDENCE_WEIGHT, QUALITY_WEIGHTS, QUALITY, MAINTENANCE_WEIGHTS, MAINTENANCE, ADOPTION, RELIABILITY, EFFICIENCY, FIELD, DEFAULT_WEIGHTS, cachedWeights, SETTABLE, WEIGHT_EXPLANATIONS, CONFIDENCE;
3968
4325
  var init_weights = __esm({
3969
4326
  "src/scoring/weights.ts"() {
3970
4327
  "use strict";
@@ -3979,6 +4336,17 @@ var init_weights = __esm({
3979
4336
  COMPOSITE = {
3980
4337
  lambda: 0.35
3981
4338
  };
4339
+ RANKING = {
4340
+ relevance: 0.45,
4341
+ composite: 0.35,
4342
+ confidence: 0.2
4343
+ };
4344
+ CONFIDENCE_WEIGHT = {
4345
+ proven: 1,
4346
+ emerging: 0.55,
4347
+ promising: 0.45,
4348
+ unproven: 0.1
4349
+ };
3982
4350
  QUALITY_WEIGHTS = {
3983
4351
  types: 0.25,
3984
4352
  tests: 0.2,
@@ -4086,12 +4454,44 @@ var init_weights = __esm({
4086
4454
  proven: {
4087
4455
  minWeeklyDownloads: 1e5,
4088
4456
  minAgeMonths: 12,
4089
- maxLastReleaseMonths: 6
4457
+ /**
4458
+ * Recency ceiling for the top tier.
4459
+ *
4460
+ * Was 6 months, which quietly punished stability. Because the stored label
4461
+ * is frozen at ingest, nothing surfaced it until confidence was re-derived
4462
+ * — at which point `p-limit` (273M weekly downloads, ten years old, last
4463
+ * release 6.2 months ago) dropped out of `proven`, along with
4464
+ * `@testing-library/react` at 6.7 months and 44M/wk. A mature utility that
4465
+ * has not needed a release is the most proven kind of package there is.
4466
+ *
4467
+ * Abandonment is already caught three other ways that do not confuse it
4468
+ * with maturity: `deprecated` and `archived` are hard-checked right here,
4469
+ * and the maintenance component of health decays to zero over 24 months
4470
+ * while carrying 35% of the health weight. Recency was the fourth penalty
4471
+ * on the same fact, and the strictest.
4472
+ */
4473
+ maxLastReleaseMonths: 18
4090
4474
  },
4091
4475
  emerging: {
4092
4476
  minWeeklyDownloads: 5e3,
4093
4477
  strongGrowth: 0.5,
4094
- maxLastReleaseMonths: 9
4478
+ /**
4479
+ * Absolute floor the growth path must also clear.
4480
+ *
4481
+ * Growth alone used to qualify a package for `emerging`, and a percentage
4482
+ * computed on a tiny base is noise: 4 → 17 weekly downloads is +325% and
4483
+ * cleared the bar outright. That is how `@podgres/pg` (17/wk) and
4484
+ * `@bungres/orm` (275/wk) came to wear the same evidence label as a package
4485
+ * with five thousand real installs, and — because `recommend` treats the
4486
+ * label as meaningful — outrank Prisma for "an orm for postgres".
4487
+ *
4488
+ * 1,000/wk is where a 50% swing stops being one CI pipeline changing its
4489
+ * schedule. Below it the growth signal is not measuring adoption.
4490
+ */
4491
+ minDownloadsForGrowth: 1e3,
4492
+ /** Same reasoning as `proven` above: 9 months demoted `@emotion/react`
4493
+ * (14.7M/wk, last release 9.2 months ago) to `unproven`. */
4494
+ maxLastReleaseMonths: 18
4095
4495
  },
4096
4496
  /** `promising` (§1): new but intrinsically high-quality, regardless of adoption. */
4097
4497
  promising: {
@@ -4268,7 +4668,7 @@ function computeConfidence(input, now, qualityScore = null) {
4268
4668
  return "proven";
4269
4669
  }
4270
4670
  const emergingReleaseOk = lastReleaseMonths !== null && lastReleaseMonths <= CONFIDENCE.emerging.maxLastReleaseMonths;
4271
- const emergingAdoptionOk = dl >= CONFIDENCE.emerging.minWeeklyDownloads || growth >= CONFIDENCE.emerging.strongGrowth;
4671
+ const emergingAdoptionOk = dl >= CONFIDENCE.emerging.minWeeklyDownloads || growth >= CONFIDENCE.emerging.strongGrowth && dl >= CONFIDENCE.emerging.minDownloadsForGrowth;
4272
4672
  if (emergingAdoptionOk && emergingReleaseOk && !input.deprecated && !input.archived) {
4273
4673
  return "emerging";
4274
4674
  }
@@ -4330,7 +4730,7 @@ function buildEmbeddingText(input) {
4330
4730
  const body = input.summary || input.description || "";
4331
4731
  return `${input.name}. ${input.category ?? ""}. ${body}`.trim();
4332
4732
  }
4333
- function tokenize(text2) {
4733
+ function tokenize2(text2) {
4334
4734
  return text2.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 1);
4335
4735
  }
4336
4736
  function fnv1a(str3) {
@@ -4343,7 +4743,7 @@ function fnv1a(str3) {
4343
4743
  }
4344
4744
  function localEmbed(text2, dim2 = EMBEDDING_DIM) {
4345
4745
  const vec = new Float64Array(dim2);
4346
- const tokens = tokenize(text2);
4746
+ const tokens = tokenize2(text2);
4347
4747
  const add = (token, weight) => {
4348
4748
  const idx = fnv1a(token) % dim2;
4349
4749
  const sign = fnv1a("s:" + token) & 1 ? 1 : -1;
@@ -4441,7 +4841,7 @@ var init_embeddings = __esm({
4441
4841
  });
4442
4842
 
4443
4843
  // src/db/alerts.ts
4444
- import { and as and4, desc as desc4, eq as eq6 } from "drizzle-orm";
4844
+ import { and as and5, desc as desc4, eq as eq6 } from "drizzle-orm";
4445
4845
  async function insertAlerts(db, rows) {
4446
4846
  if (rows.length === 0) return 0;
4447
4847
  const inserted = await db.insert(repoAlerts).values(rows).onConflictDoNothing({
@@ -4453,7 +4853,7 @@ async function listAlerts(db, ownerId, limit = ALERT_FEED_LIMIT) {
4453
4853
  return db.select().from(repoAlerts).where(eq6(repoAlerts.ownerId, ownerId)).orderBy(desc4(repoAlerts.createdAt)).limit(Math.min(limit, ALERT_FEED_LIMIT));
4454
4854
  }
4455
4855
  async function deleteAlertsForRepo(db, ownerId, repoId) {
4456
- await db.delete(repoAlerts).where(and4(eq6(repoAlerts.ownerId, ownerId), eq6(repoAlerts.repoId, repoId)));
4856
+ await db.delete(repoAlerts).where(and5(eq6(repoAlerts.ownerId, ownerId), eq6(repoAlerts.repoId, repoId)));
4457
4857
  }
4458
4858
  var ALERT_FEED_LIMIT;
4459
4859
  var init_alerts = __esm({
@@ -4492,7 +4892,7 @@ var init_types2 = __esm({
4492
4892
  });
4493
4893
 
4494
4894
  // src/db/repos.ts
4495
- import { and as and5, eq as eq7, sql as sql6 } from "drizzle-orm";
4895
+ import { and as and6, eq as eq7, sql as sql6 } from "drizzle-orm";
4496
4896
  async function upsertRepos(db, rows) {
4497
4897
  if (rows.length === 0) return 0;
4498
4898
  await db.insert(repos).values(
@@ -4525,12 +4925,26 @@ async function reposDeclaring(db, name) {
4525
4925
  )`
4526
4926
  );
4527
4927
  }
4928
+ async function ownerForInstallation(db, installationId2) {
4929
+ const rows = await db.select({ ownerId: repos.ownerId }).from(repos).where(eq7(repos.installationId, installationId2)).limit(1);
4930
+ return rows[0]?.ownerId ?? null;
4931
+ }
4932
+ async function deleteReposByInstallation(db, installationId2, fullNames2) {
4933
+ const only = fullNames2 ? new Set(fullNames2) : null;
4934
+ const rows = await db.select({ id: repos.id, ownerId: repos.ownerId, fullName: repos.fullName }).from(repos).where(eq7(repos.installationId, installationId2));
4935
+ let deleted = 0;
4936
+ for (const row of rows) {
4937
+ if (only && !only.has(row.fullName)) continue;
4938
+ if (await deleteRepo(db, row.ownerId, row.id)) deleted++;
4939
+ }
4940
+ return deleted;
4941
+ }
4528
4942
  async function getRepo(db, ownerId, id) {
4529
- const rows = await db.select().from(repos).where(and5(eq7(repos.ownerId, ownerId), eq7(repos.id, id))).limit(1);
4943
+ const rows = await db.select().from(repos).where(and6(eq7(repos.ownerId, ownerId), eq7(repos.id, id))).limit(1);
4530
4944
  return rows[0] ?? null;
4531
4945
  }
4532
4946
  async function findPolicyByFullName(db, ownerId, fullName) {
4533
- const rows = await db.select({ policy: repos.policy }).from(repos).where(and5(eq7(repos.ownerId, ownerId), eq7(repos.fullName, fullName))).limit(1);
4947
+ const rows = await db.select({ policy: repos.policy }).from(repos).where(and6(eq7(repos.ownerId, ownerId), eq7(repos.fullName, fullName))).limit(1);
4534
4948
  return rows[0]?.policy ?? null;
4535
4949
  }
4536
4950
  async function saveScan(db, id, scan) {
@@ -4546,12 +4960,12 @@ async function saveScanError(db, id, message) {
4546
4960
  await db.update(repos).set({ lastScanAt: /* @__PURE__ */ new Date(), lastScanError: message.slice(0, 500) }).where(eq7(repos.id, id));
4547
4961
  }
4548
4962
  async function setRepoPolicy(db, ownerId, id, policy) {
4549
- const updated = await db.update(repos).set({ policy }).where(and5(eq7(repos.ownerId, ownerId), eq7(repos.id, id))).returning({ id: repos.id });
4963
+ const updated = await db.update(repos).set({ policy }).where(and6(eq7(repos.ownerId, ownerId), eq7(repos.id, id))).returning({ id: repos.id });
4550
4964
  return updated.length > 0;
4551
4965
  }
4552
4966
  async function deleteRepo(db, ownerId, id) {
4553
4967
  await deleteAlertsForRepo(db, ownerId, id);
4554
- const deleted = await db.delete(repos).where(and5(eq7(repos.ownerId, ownerId), eq7(repos.id, id))).returning({ id: repos.id });
4968
+ const deleted = await db.delete(repos).where(and6(eq7(repos.ownerId, ownerId), eq7(repos.id, id))).returning({ id: repos.id });
4555
4969
  return deleted.length > 0;
4556
4970
  }
4557
4971
  var init_repos = __esm({
@@ -4573,8 +4987,8 @@ function buildAttribution(deps, edges, directNames) {
4573
4987
  }
4574
4988
  const parentIds = /* @__PURE__ */ new Map();
4575
4989
  for (const edge of edges) {
4576
- const list = parentIds.get(edge.child);
4577
- if (list) list.push(edge.parent);
4990
+ const list2 = parentIds.get(edge.child);
4991
+ if (list2) list2.push(edge.parent);
4578
4992
  else parentIds.set(edge.child, [edge.parent]);
4579
4993
  }
4580
4994
  const informative = edges.some(
@@ -4686,8 +5100,91 @@ var init_osv = __esm({
4686
5100
  }
4687
5101
  });
4688
5102
 
5103
+ // src/pipeline/ingestQueue.ts
5104
+ var ingestQueue_exports = {};
5105
+ __export(ingestQueue_exports, {
5106
+ drainIngestQueue: () => drainIngestQueue,
5107
+ enqueueIngest: () => enqueueIngest,
5108
+ ingestQueueDepth: () => ingestQueueDepth,
5109
+ resetIngestQueue: () => resetIngestQueue,
5110
+ runIngest: () => runIngest
5111
+ });
5112
+ function ingestQueueDepth() {
5113
+ return pending.length + inFlight2.size;
5114
+ }
5115
+ async function drainIngestQueue(timeoutMs = 10 * 6e4) {
5116
+ const deadline = Date.now() + timeoutMs;
5117
+ while (ingestQueueDepth() > 0 && Date.now() < deadline) {
5118
+ await new Promise((resolve2) => setTimeout(resolve2, 250));
5119
+ }
5120
+ return ingestQueueDepth();
5121
+ }
5122
+ function resetIngestQueue() {
5123
+ pending.length = 0;
5124
+ inFlight2.clear();
5125
+ queuedNames.clear();
5126
+ owners.clear();
5127
+ active = 0;
5128
+ }
5129
+ function enqueueIngest(db, name, requestedByOwnerId = null) {
5130
+ if (queuedNames.has(name) || inFlight2.has(name)) return;
5131
+ if (pending.length >= MAX_PENDING) {
5132
+ logger.warn(`ingest queue full (${MAX_PENDING}); dropping on-demand request for ${name}`);
5133
+ return;
5134
+ }
5135
+ queuedNames.add(name);
5136
+ owners.set(name, requestedByOwnerId);
5137
+ pending.push(name);
5138
+ pump(db);
5139
+ }
5140
+ function pump(db) {
5141
+ while (active < MAX_CONCURRENT && pending.length > 0) {
5142
+ const name = pending.shift();
5143
+ queuedNames.delete(name);
5144
+ const owner = owners.get(name) ?? null;
5145
+ owners.delete(name);
5146
+ inFlight2.add(name);
5147
+ active += 1;
5148
+ void runIngest(db, name, owner).finally(() => {
5149
+ inFlight2.delete(name);
5150
+ active -= 1;
5151
+ pump(db);
5152
+ });
5153
+ }
5154
+ }
5155
+ async function runIngest(db, name, requestedByOwnerId = null) {
5156
+ try {
5157
+ const row = await syncOnePackage(db, name, { requestedByOwnerId });
5158
+ if (row.confidence && row.confidence !== "unproven") {
5159
+ await ensureSeedEntry(db, name, row.category).catch(() => {
5160
+ });
5161
+ }
5162
+ return row;
5163
+ } catch (err) {
5164
+ logger.warn(`on-demand ingest failed for ${name}: ${String(err)}`);
5165
+ return null;
5166
+ }
5167
+ }
5168
+ var MAX_CONCURRENT, MAX_PENDING, pending, inFlight2, queuedNames, owners, active;
5169
+ var init_ingestQueue = __esm({
5170
+ "src/pipeline/ingestQueue.ts"() {
5171
+ "use strict";
5172
+ init_esm_shims();
5173
+ init_logger();
5174
+ init_packages();
5175
+ init_single();
5176
+ MAX_CONCURRENT = 3;
5177
+ MAX_PENDING = 500;
5178
+ pending = [];
5179
+ inFlight2 = /* @__PURE__ */ new Set();
5180
+ queuedNames = /* @__PURE__ */ new Set();
5181
+ owners = /* @__PURE__ */ new Map();
5182
+ active = 0;
5183
+ }
5184
+ });
5185
+
4689
5186
  // src/github/drift.ts
4690
- import { inArray as inArray4, sql as sql7 } from "drizzle-orm";
5187
+ import { and as and7, eq as eq8, inArray as inArray4, sql as sql7 } from "drizzle-orm";
4691
5188
  import semver2 from "semver";
4692
5189
  function declaredDeps(manifests) {
4693
5190
  const out = /* @__PURE__ */ new Map();
@@ -4706,7 +5203,7 @@ function declaredDeps(manifests) {
4706
5203
  }
4707
5204
  return out;
4708
5205
  }
4709
- async function loadIndexed(db, names2) {
5206
+ async function loadIndexed(db, names2, ecosystem = DEFAULT_ECOSYSTEM) {
4710
5207
  const out = /* @__PURE__ */ new Map();
4711
5208
  for (let i = 0; i < names2.length; i += NAME_CHUNK) {
4712
5209
  const rows = await db.select({
@@ -4714,7 +5211,12 @@ async function loadIndexed(db, names2) {
4714
5211
  latestVersion: packages.latestVersion,
4715
5212
  deprecated: packages.deprecated,
4716
5213
  advisories: packages.advisories
4717
- }).from(packages).where(inArray4(packages.name, names2.slice(i, i + NAME_CHUNK)));
5214
+ }).from(packages).where(
5215
+ and7(
5216
+ inArray4(packages.name, names2.slice(i, i + NAME_CHUNK)),
5217
+ eq8(packages.ecosystem, ecosystem)
5218
+ )
5219
+ );
4718
5220
  for (const row of rows) {
4719
5221
  out.set(row.name, {
4720
5222
  latestVersion: row.latestVersion,
@@ -4730,8 +5232,8 @@ async function loadVersions(db, names2) {
4730
5232
  for (let i = 0; i < names2.length; i += NAME_CHUNK) {
4731
5233
  const rows = await db.select({ name: packageVersions.packageName, version: packageVersions.version }).from(packageVersions).where(inArray4(packageVersions.packageName, names2.slice(i, i + NAME_CHUNK)));
4732
5234
  for (const row of rows) {
4733
- const list = out.get(row.name);
4734
- if (list) list.push(row.version);
5235
+ const list2 = out.get(row.name);
5236
+ if (list2) list2.push(row.version);
4735
5237
  else out.set(row.name, [row.version]);
4736
5238
  }
4737
5239
  }
@@ -4848,7 +5350,15 @@ async function conflictsAtCurrent(db, deps) {
4848
5350
  }));
4849
5351
  return resolveArchitectureCompat(members).slice(0, REPO_DRIFT_DETAIL_CAP);
4850
5352
  }
4851
- async function computeDrift(db, manifests, resolvedTree = null) {
5353
+ function queueUntracked(db, names2, ownerId) {
5354
+ if (names2.length === 0) return;
5355
+ void Promise.resolve().then(() => (init_ingestQueue(), ingestQueue_exports)).then(({ enqueueIngest: enqueueIngest2 }) => {
5356
+ for (const name of names2) enqueueIngest2(db, name, ownerId);
5357
+ }).catch((err) => {
5358
+ logger.debug(`untracked-dep enqueue failed: ${String(err)}`);
5359
+ });
5360
+ }
5361
+ async function computeDrift(db, manifests, resolvedTree = null, requestedByOwnerId = null) {
4852
5362
  const declared = declaredDeps(manifests);
4853
5363
  const names2 = [...declared.keys()];
4854
5364
  const directNames = new Set(names2);
@@ -4877,6 +5387,11 @@ async function computeDrift(db, manifests, resolvedTree = null) {
4877
5387
  loadIndexed(db, names2),
4878
5388
  loadVersions(db, names2)
4879
5389
  ]);
5390
+ queueUntracked(
5391
+ db,
5392
+ names2.filter((name) => !indexed.has(name)),
5393
+ requestedByOwnerId
5394
+ );
4880
5395
  const deps = [];
4881
5396
  for (const [name, entry] of declared) {
4882
5397
  const row = indexed.get(name);
@@ -4906,12 +5421,14 @@ var init_drift = __esm({
4906
5421
  "src/github/drift.ts"() {
4907
5422
  "use strict";
4908
5423
  init_esm_shims();
5424
+ init_logger();
4909
5425
  init_schema();
4910
5426
  init_types2();
4911
5427
  init_attribute();
4912
5428
  init_osv();
4913
5429
  init_members();
4914
5430
  init_peerCompat();
5431
+ init_types();
4915
5432
  NAME_CHUNK = 500;
4916
5433
  }
4917
5434
  });
@@ -4969,14 +5486,6 @@ var init_alerts2 = __esm({
4969
5486
  }
4970
5487
  });
4971
5488
 
4972
- // src/core/concurrency.ts
4973
- var init_concurrency = __esm({
4974
- "src/core/concurrency.ts"() {
4975
- "use strict";
4976
- init_esm_shims();
4977
- }
4978
- });
4979
-
4980
5489
  // src/core/errors.ts
4981
5490
  function truncate(text2, max) {
4982
5491
  if (text2.length <= max) return text2;
@@ -5086,20 +5595,39 @@ var init_runtimeTarget = __esm({
5086
5595
  }
5087
5596
  });
5088
5597
 
5598
+ // src/db/watch.ts
5599
+ import { eq as eq9 } from "drizzle-orm";
5600
+ async function getWatchCursor(db, id) {
5601
+ const rows = await db.select({ seq: watchState.seq }).from(watchState).where(eq9(watchState.id, id)).limit(1);
5602
+ return rows[0]?.seq ?? null;
5603
+ }
5604
+ async function setWatchCursor(db, id, seq) {
5605
+ await db.insert(watchState).values({ id, seq, updatedAt: /* @__PURE__ */ new Date() }).onConflictDoUpdate({ target: watchState.id, set: { seq, updatedAt: /* @__PURE__ */ new Date() } });
5606
+ }
5607
+ var init_watch = __esm({
5608
+ "src/db/watch.ts"() {
5609
+ "use strict";
5610
+ init_esm_shims();
5611
+ init_schema();
5612
+ }
5613
+ });
5614
+
5089
5615
  // src/pipeline/mineEdges.ts
5090
- function trackedPairs(nodes, tracked) {
5616
+ function trackedPairs(nodes, tracked, mustTouch) {
5091
5617
  const t = nodes.filter((n) => tracked.has(n.name));
5092
5618
  const pairs = [];
5093
5619
  for (let i = 0; i < t.length; i++) {
5094
5620
  for (let j = i + 1; j < t.length; j++) {
5095
5621
  if (t[i].name === t[j].name) continue;
5622
+ if (mustTouch && !mustTouch.has(t[i].name) && !mustTouch.has(t[j].name)) continue;
5096
5623
  pairs.push(canonicalPair(t[i], t[j]));
5097
5624
  }
5098
5625
  }
5099
5626
  return pairs;
5100
5627
  }
5101
- async function mintObservedPairs(db, nodes, tracked, now, freshWitness) {
5102
- const pairs = trackedPairs(nodes, tracked);
5628
+ async function mintObservedPairs(db, nodes, tracked, now, freshWitness, mustTouch) {
5629
+ const pairs = trackedPairs(nodes, tracked, mustTouch);
5630
+ if (pairs.length === 0) return 0;
5103
5631
  const rows = pairs.map((pair) => ({
5104
5632
  ...pair,
5105
5633
  status: "compatible",
@@ -5132,6 +5660,7 @@ var init_mineEdges = __esm({
5132
5660
  init_errors();
5133
5661
  init_logger();
5134
5662
  init_packages();
5663
+ init_watch();
5135
5664
  init_compat();
5136
5665
  init_depsDev();
5137
5666
  }
@@ -5208,84 +5737,17 @@ var init_sync = __esm({
5208
5737
  }
5209
5738
  });
5210
5739
 
5211
- // src/pipeline/ingestQueue.ts
5212
- function enqueueIngest(db, name, requestedByOwnerId = null) {
5213
- if (queuedNames.has(name) || inFlight2.has(name)) return;
5214
- if (pending.length >= MAX_PENDING) {
5215
- logger.warn(`ingest queue full (${MAX_PENDING}); dropping on-demand request for ${name}`);
5216
- return;
5217
- }
5218
- queuedNames.add(name);
5219
- owners.set(name, requestedByOwnerId);
5220
- pending.push(name);
5221
- pump(db);
5222
- }
5223
- function pump(db) {
5224
- while (active < MAX_CONCURRENT && pending.length > 0) {
5225
- const name = pending.shift();
5226
- queuedNames.delete(name);
5227
- const owner = owners.get(name) ?? null;
5228
- owners.delete(name);
5229
- inFlight2.add(name);
5230
- active += 1;
5231
- void runIngest(db, name, owner).finally(() => {
5232
- inFlight2.delete(name);
5233
- active -= 1;
5234
- pump(db);
5235
- });
5236
- }
5740
+ // src/pipeline/single.ts
5741
+ import { and as and8, eq as eq10, isNotNull as isNotNull3, sql as sql8 } from "drizzle-orm";
5742
+ async function getSeedCategory(db, name) {
5743
+ const [row] = await db.select({ category: seedPackages.category }).from(seedPackages).where(eq10(seedPackages.name, name)).limit(1);
5744
+ return row?.category ?? null;
5237
5745
  }
5238
- async function runIngest(db, name, requestedByOwnerId = null) {
5239
- try {
5240
- const row = await syncOnePackage(db, name, { requestedByOwnerId });
5241
- if (row.confidence && row.confidence !== "unproven") {
5242
- await ensureSeedEntry(db, name, row.category).catch(() => {
5243
- });
5244
- }
5245
- return row;
5246
- } catch (err) {
5247
- logger.warn(`on-demand ingest failed for ${name}: ${String(err)}`);
5248
- return null;
5249
- }
5250
- }
5251
- var MAX_CONCURRENT, MAX_PENDING, pending, inFlight2, queuedNames, owners, active;
5252
- var init_ingestQueue = __esm({
5253
- "src/pipeline/ingestQueue.ts"() {
5254
- "use strict";
5255
- init_esm_shims();
5256
- init_logger();
5257
- init_packages();
5258
- init_single();
5259
- MAX_CONCURRENT = 3;
5260
- MAX_PENDING = 500;
5261
- pending = [];
5262
- inFlight2 = /* @__PURE__ */ new Set();
5263
- queuedNames = /* @__PURE__ */ new Set();
5264
- owners = /* @__PURE__ */ new Map();
5265
- active = 0;
5266
- }
5267
- });
5268
-
5269
- // src/pipeline/single.ts
5270
- import { and as and6, eq as eq8, isNotNull as isNotNull3, sql as sql8 } from "drizzle-orm";
5271
- function raceTimeout(p, ms) {
5272
- let timer;
5273
- return Promise.race([
5274
- p,
5275
- new Promise((resolve) => {
5276
- timer = setTimeout(() => resolve(null), ms);
5277
- })
5278
- ]).finally(() => clearTimeout(timer));
5279
- }
5280
- async function getSeedCategory(db, name) {
5281
- const [row] = await db.select({ category: seedPackages.category }).from(seedPackages).where(eq8(seedPackages.name, name)).limit(1);
5282
- return row?.category ?? null;
5283
- }
5284
- async function getCategoryMedianBundle(db, category) {
5285
- const [row] = await db.select({
5286
- m: sql8`percentile_cont(0.5) within group (order by ${packages.bundleMinGzipKb})`
5287
- }).from(packages).where(and6(eq8(packages.category, category), isNotNull3(packages.bundleMinGzipKb)));
5288
- return row?.m ?? null;
5746
+ async function getCategoryMedianBundle(db, category) {
5747
+ const [row] = await db.select({
5748
+ m: sql8`percentile_cont(0.5) within group (order by ${packages.bundleMinGzipKb})`
5749
+ }).from(packages).where(and8(eq10(packages.category, category), isNotNull3(packages.bundleMinGzipKb)));
5750
+ return row?.m ?? null;
5289
5751
  }
5290
5752
  async function syncOnePackage(db, name, opts = {}) {
5291
5753
  const config = getConfig();
@@ -5372,7 +5834,7 @@ async function getOrFetchPackage(db, name, opts = {}) {
5372
5834
  const exists = await npmPackageExists(name);
5373
5835
  if (!exists) return { row: null, wasTracked: false, existsOnNpm: false };
5374
5836
  if (opts.blockMs && opts.blockMs > 0) {
5375
- const row = await raceTimeout(runIngest(db, name, opts.requestedByOwnerId ?? null), opts.blockMs);
5837
+ const row = await withBudget(runIngest(db, name, opts.requestedByOwnerId ?? null), opts.blockMs);
5376
5838
  if (row) return { row, wasTracked: false, existsOnNpm: true };
5377
5839
  return { row: null, wasTracked: false, existsOnNpm: true, queued: true };
5378
5840
  }
@@ -5384,6 +5846,7 @@ var init_single = __esm({
5384
5846
  "src/pipeline/single.ts"() {
5385
5847
  "use strict";
5386
5848
  init_esm_shims();
5849
+ init_concurrency();
5387
5850
  init_config();
5388
5851
  init_logger();
5389
5852
  init_collect();
@@ -5404,7 +5867,12 @@ var init_single = __esm({
5404
5867
  });
5405
5868
 
5406
5869
  // src/search/recommend.ts
5407
- import { and as and7, cosineDistance, eq as eq9, inArray as inArray5, isNotNull as isNotNull4, lte, sql as sql9 } from "drizzle-orm";
5870
+ import { and as and9, cosineDistance, eq as eq11, inArray as inArray5, isNotNull as isNotNull4, lte, sql as sql9 } from "drizzle-orm";
5871
+ function rankScore(row, relevance, lambda) {
5872
+ const composite = computeComposite(row.healthScore ?? 0, row.qualityScore, lambda) / 100;
5873
+ const confidence = CONFIDENCE_WEIGHT[row.confidence ?? "unproven"] ?? 0;
5874
+ return RANKING.relevance * relevance + RANKING.composite * composite + RANKING.confidence * confidence;
5875
+ }
5408
5876
  async function recommend(db, opts, provider = createEmbeddingProvider()) {
5409
5877
  const limit = Math.min(Math.max(opts.limit ?? 3, 1), 5);
5410
5878
  const [queryVec] = await provider.embed([opts.need]);
@@ -5420,11 +5888,10 @@ async function recommend(db, opts, provider = createEmbeddingProvider()) {
5420
5888
  if (fused.length === 0) return [];
5421
5889
  const lambda = loadWeights().composite.lambda;
5422
5890
  const maxRrf = Math.max(...fused.map((f) => f.rrf));
5423
- const ranked = fused.map((f) => {
5424
- const relevance = maxRrf > 0 ? f.rrf / maxRrf : 0;
5425
- const composite = computeComposite(f.row.healthScore ?? 0, f.row.qualityScore, lambda) / 100;
5426
- return { row: f.row, score: RELEVANCE_WEIGHT * relevance + COMPOSITE_WEIGHT * composite };
5427
- }).sort((a, b) => b.score - a.score).slice(0, limit);
5891
+ const ranked = fused.map((f) => ({
5892
+ row: f.row,
5893
+ score: rankScore(f.row, maxRrf > 0 ? f.rrf / maxRrf : 0, lambda)
5894
+ })).sort((a, b) => b.score - a.score).slice(0, limit);
5428
5895
  return ranked.map(({ row }) => toCandidate(row));
5429
5896
  }
5430
5897
  async function hybridSearch(db, queryVec, providerId, need, constraints, category, pool) {
@@ -5436,8 +5903,8 @@ async function hybridSearch(db, queryVec, providerId, need, constraints, categor
5436
5903
  }
5437
5904
  function rrfFuse(lists, k = RRF_K) {
5438
5905
  const fused = /* @__PURE__ */ new Map();
5439
- for (const list of lists) {
5440
- list.forEach((row, idx) => {
5906
+ for (const list2 of lists) {
5907
+ list2.forEach((row, idx) => {
5441
5908
  const contribution = 1 / (k + idx + 1);
5442
5909
  const prev = fused.get(row.name);
5443
5910
  if (prev) prev.rrf += contribution;
@@ -5448,14 +5915,14 @@ function rrfFuse(lists, k = RRF_K) {
5448
5915
  }
5449
5916
  function buildConditions(constraints, category) {
5450
5917
  const conditions = [];
5451
- if (category) conditions.push(eq9(packages.category, category));
5452
- if (constraints?.license) conditions.push(eq9(packages.license, constraints.license));
5918
+ if (category) conditions.push(eq11(packages.category, category));
5919
+ if (constraints?.license) conditions.push(eq11(packages.license, constraints.license));
5453
5920
  if (constraints?.maxBundleKb !== void 0) {
5454
5921
  conditions.push(lte(packages.bundleMinGzipKb, constraints.maxBundleKb));
5455
5922
  }
5456
5923
  if (constraints?.minConfidence) {
5457
5924
  const allowed = ["proven", "emerging", "promising", "unproven"].filter(
5458
- (c) => CONFIDENCE_RANK[c] >= CONFIDENCE_RANK[constraints.minConfidence]
5925
+ (c) => CONFIDENCE_ORDER[c] >= CONFIDENCE_ORDER[constraints.minConfidence]
5459
5926
  );
5460
5927
  conditions.push(inArray5(packages.confidence, allowed));
5461
5928
  }
@@ -5465,16 +5932,16 @@ async function runVectorQuery(db, queryVec, providerId, constraints, category, p
5465
5932
  const distance = cosineDistance(packages.embedding, queryVec);
5466
5933
  const conditions = [
5467
5934
  isNotNull4(packages.embedding),
5468
- eq9(packages.embeddingProvider, providerId),
5935
+ eq11(packages.embeddingProvider, providerId),
5469
5936
  ...buildConditions(constraints, category)
5470
5937
  ];
5471
- return db.select(ROW_COLUMNS).from(packages).where(and7(...conditions)).orderBy(distance).limit(pool);
5938
+ return db.select(ROW_COLUMNS).from(packages).where(and9(...conditions)).orderBy(distance).limit(pool);
5472
5939
  }
5473
5940
  async function runLexicalQuery(db, need, constraints, category, pool) {
5474
5941
  const tsquery = sql9`websearch_to_tsquery('english', ${need})`;
5475
5942
  const rank2 = sql9`ts_rank(${packages.searchVector}, ${tsquery})`;
5476
5943
  const conditions = [sql9`${packages.searchVector} @@ ${tsquery}`, ...buildConditions(constraints, category)];
5477
- return db.select(ROW_COLUMNS).from(packages).where(and7(...conditions)).orderBy(sql9`${rank2} desc`).limit(pool);
5944
+ return db.select(ROW_COLUMNS).from(packages).where(and9(...conditions)).orderBy(sql9`${rank2} desc`).limit(pool);
5478
5945
  }
5479
5946
  function toCandidate(row) {
5480
5947
  return {
@@ -5487,22 +5954,32 @@ function toCandidate(row) {
5487
5954
  latestVersion: row.latestVersion,
5488
5955
  weeklyDownloads: row.weeklyDownloads,
5489
5956
  lastReleaseAt: row.lastReleaseAt ? row.lastReleaseAt.toISOString() : null,
5490
- repoUrl: row.repoUrl
5957
+ repoUrl: row.repoUrl,
5958
+ deprecated: row.deprecated ?? false,
5959
+ archived: row.archived ?? false,
5960
+ advisories: Number(row.advisories ?? 0)
5491
5961
  };
5492
5962
  }
5493
5963
  function buildWhy(row) {
5964
+ const warnings = [];
5965
+ if (row.deprecated) warnings.push("DEPRECATED");
5966
+ if (row.archived) warnings.push("repo archived");
5967
+ if (row.advisories) {
5968
+ warnings.push(`${row.advisories} advisor${row.advisories === 1 ? "y" : "ies"}`);
5969
+ }
5494
5970
  const parts = [];
5495
5971
  if (row.confidence) parts.push(row.confidence);
5496
5972
  if (row.weeklyDownloads) parts.push(`${formatDownloads(row.weeklyDownloads)} weekly downloads`);
5497
5973
  if (row.healthScore !== null) parts.push(`health ${row.healthScore}`);
5498
- return parts.join(", ") || "tracked package";
5974
+ const rationale = parts.join(", ") || "tracked package";
5975
+ return warnings.length ? `${warnings.join(" + ")} \u2014 ${rationale}` : rationale;
5499
5976
  }
5500
5977
  function formatDownloads(n) {
5501
5978
  if (n >= 1e6) return `${Math.round(n / 1e5) / 10}M`;
5502
5979
  if (n >= 1e3) return `${Math.round(n / 1e3)}k`;
5503
5980
  return String(n);
5504
5981
  }
5505
- var CONFIDENCE_RANK, RELEVANCE_WEIGHT, COMPOSITE_WEIGHT, RRF_K, ROW_COLUMNS;
5982
+ var CONFIDENCE_ORDER, RRF_K, ROW_COLUMNS;
5506
5983
  var init_recommend = __esm({
5507
5984
  "src/search/recommend.ts"() {
5508
5985
  "use strict";
@@ -5512,14 +5989,12 @@ var init_recommend = __esm({
5512
5989
  init_weights();
5513
5990
  init_categoryInference();
5514
5991
  init_embeddings();
5515
- CONFIDENCE_RANK = {
5992
+ CONFIDENCE_ORDER = {
5516
5993
  unproven: 0,
5517
5994
  promising: 1,
5518
5995
  emerging: 2,
5519
5996
  proven: 3
5520
5997
  };
5521
- RELEVANCE_WEIGHT = 0.6;
5522
- COMPOSITE_WEIGHT = 0.4;
5523
5998
  RRF_K = 60;
5524
5999
  ROW_COLUMNS = {
5525
6000
  name: packages.name,
@@ -5530,7 +6005,14 @@ var init_recommend = __esm({
5530
6005
  latestVersion: packages.latestVersion,
5531
6006
  weeklyDownloads: packages.weeklyDownloads,
5532
6007
  lastReleaseAt: packages.lastReleaseAt,
5533
- repoUrl: packages.repoUrl
6008
+ repoUrl: packages.repoUrl,
6009
+ deprecated: packages.deprecated,
6010
+ archived: packages.archived,
6011
+ // The count, not the array: an agent shortlisting five packages needs to know
6012
+ // that one carries advisories, and `evaluate` is where it reads which. Pulling
6013
+ // the jsonb payload for every candidate would cost tokens on every call for
6014
+ // detail the next call already serves.
6015
+ advisories: sql9`coalesce(jsonb_array_length(${packages.advisories}), 0)`
5534
6016
  };
5535
6017
  }
5536
6018
  });
@@ -5571,7 +6053,7 @@ function check(policy, pkg, facts) {
5571
6053
  };
5572
6054
  }
5573
6055
  if (policy.minConfidence) {
5574
- if (CONFIDENCE_RANK2[pkg.confidence] < CONFIDENCE_RANK2[policy.minConfidence]) {
6056
+ if (CONFIDENCE_RANK[pkg.confidence] < CONFIDENCE_RANK[policy.minConfidence]) {
5575
6057
  return {
5576
6058
  name,
5577
6059
  rule: "confidence",
@@ -5581,12 +6063,12 @@ function check(policy, pkg, facts) {
5581
6063
  }
5582
6064
  return null;
5583
6065
  }
5584
- var CONFIDENCE_RANK2;
6066
+ var CONFIDENCE_RANK;
5585
6067
  var init_enforce = __esm({
5586
6068
  "src/policy/enforce.ts"() {
5587
6069
  "use strict";
5588
6070
  init_esm_shims();
5589
- CONFIDENCE_RANK2 = {
6071
+ CONFIDENCE_RANK = {
5590
6072
  unproven: 0,
5591
6073
  promising: 1,
5592
6074
  emerging: 2,
@@ -5612,10 +6094,10 @@ var init_types3 = __esm({
5612
6094
  });
5613
6095
 
5614
6096
  // src/db/selectionPolicy.ts
5615
- import { eq as eq10, inArray as inArray6 } from "drizzle-orm";
6097
+ import { and as and10, eq as eq12, inArray as inArray6 } from "drizzle-orm";
5616
6098
  async function getSelectionPolicy(db, ownerId) {
5617
6099
  if (!ownerId) return DEFAULT_SELECTION_POLICY;
5618
- const [row] = await db.select({ policy: selectionPolicies.policy }).from(selectionPolicies).where(eq10(selectionPolicies.ownerId, ownerId)).limit(1);
6100
+ const [row] = await db.select({ policy: selectionPolicies.policy }).from(selectionPolicies).where(eq12(selectionPolicies.ownerId, ownerId)).limit(1);
5619
6101
  return row ? { ...DEFAULT_SELECTION_POLICY, ...row.policy } : DEFAULT_SELECTION_POLICY;
5620
6102
  }
5621
6103
  async function setSelectionPolicy(db, ownerId, policy) {
@@ -5624,14 +6106,14 @@ async function setSelectionPolicy(db, ownerId, policy) {
5624
6106
  set: { policy, updatedAt: /* @__PURE__ */ new Date() }
5625
6107
  });
5626
6108
  }
5627
- async function loadPolicyFacts(db, names2) {
6109
+ async function loadPolicyFacts(db, names2, ecosystem = DEFAULT_ECOSYSTEM) {
5628
6110
  const out = /* @__PURE__ */ new Map();
5629
6111
  if (names2.length === 0) return out;
5630
6112
  const rows = await db.select({
5631
6113
  name: packages.name,
5632
6114
  license: packages.license,
5633
6115
  deprecated: packages.deprecated
5634
- }).from(packages).where(inArray6(packages.name, names2));
6116
+ }).from(packages).where(and10(inArray6(packages.name, names2), eq12(packages.ecosystem, ecosystem)));
5635
6117
  for (const row of rows) {
5636
6118
  out.set(row.name, { license: row.license, deprecated: row.deprecated });
5637
6119
  }
@@ -5642,6 +6124,7 @@ var init_selectionPolicy = __esm({
5642
6124
  "use strict";
5643
6125
  init_esm_shims();
5644
6126
  init_schema();
6127
+ init_types();
5645
6128
  init_types3();
5646
6129
  }
5647
6130
  });
@@ -6060,6 +6543,12 @@ async function handleEvaluate(db, input, ownerId = null) {
6060
6543
  );
6061
6544
  return { ...evaluated, policy: exclusion ? { allowed: false, ...exclusion } : { allowed: true } };
6062
6545
  }
6546
+ function comparisonNote(notFound, pending2) {
6547
+ return [
6548
+ notFound.length ? `Not found on the npm registry: ${notFound.join(", ")}. Check the name(s) before installing \u2014 these do not exist.` : null,
6549
+ pending2.length ? `\u{1F389} You're the first to add ${pending2.join(", ")} to lurq's registry! They're being scored now; retry shortly for the full comparison.` : null
6550
+ ].filter(Boolean).join(" ");
6551
+ }
6063
6552
  async function handleCompare(db, input, ownerId = null) {
6064
6553
  const out = await cached2(
6065
6554
  "cmp",
@@ -6069,17 +6558,28 @@ async function handleCompare(db, input, ownerId = null) {
6069
6558
  input.packages.map((name) => getOrFetchPackage(db, name, { requestedByOwnerId: ownerId }))
6070
6559
  );
6071
6560
  const rows = results.map((r) => r.row).filter((row) => row !== null).map((row) => rowToEvaluate(row)).sort((a, b) => b.healthScore - a.healthScore);
6072
- const missing = input.packages.filter((name) => !rows.some((r) => r.name === name));
6561
+ const missing2 = [];
6562
+ const notFound = [];
6563
+ const pending2 = [];
6564
+ input.packages.forEach((name, i) => {
6565
+ const result = results[i];
6566
+ if (result.row) return;
6567
+ missing2.push(name);
6568
+ (result.existsOnNpm ? pending2 : notFound).push(name);
6569
+ });
6073
6570
  return {
6074
6571
  dataAsOf: await latestDataAsOf(db),
6075
6572
  rows,
6076
- ...missing.length ? {
6077
- missing,
6078
- note: "\u{1F389} You're the first to add these to lurq's registry! They're being scored now; retry shortly for the full comparison."
6079
- } : {}
6573
+ // `missing` stays the union, in the caller's own order, so a client
6574
+ // pinned to an older lurq keeps printing its "not found" line.
6575
+ ...missing2.length ? { missing: missing2 } : {},
6576
+ ...notFound.length ? { notFound } : {},
6577
+ ...pending2.length ? { pending: pending2 } : {},
6578
+ ...missing2.length ? { note: comparisonNote(notFound, pending2) } : {}
6080
6579
  };
6081
6580
  },
6082
- // Don't cache a transient miss (a package that momentarily failed to fetch).
6581
+ // Don't cache a miss: a pending ingest lands within seconds, and a name that
6582
+ // is not on npm today may be published tomorrow.
6083
6583
  { skipCache: (r) => Boolean(r.missing?.length) }
6084
6584
  );
6085
6585
  out.rows = out.rows.map(refreshStale);
@@ -6169,14 +6669,18 @@ async function handleVerify(db, input, ownerId = null) {
6169
6669
  };
6170
6670
  }
6171
6671
  async function resolveVersion(db, name, requested) {
6172
- if (requested) return requested;
6672
+ if (requested) {
6673
+ const exists = await npmVersionExists(name, requested);
6674
+ return exists === false ? { version: null, unpublished: true } : { version: requested };
6675
+ }
6173
6676
  const row = await getPackageByName(db, name);
6174
- if (row?.latestVersion) return row.latestVersion;
6677
+ if (row?.latestVersion) return { version: row.latestVersion };
6175
6678
  const reg = await fetchNpmRegistry(name).catch(() => null);
6176
- return reg?.latestVersion ?? null;
6679
+ return { version: reg?.latestVersion ?? null };
6177
6680
  }
6178
6681
  async function handleUsage(db, input) {
6179
- const version = await resolveVersion(db, input.package, input.version);
6682
+ const resolved = await resolveVersion(db, input.package, input.version);
6683
+ const version = resolved.version;
6180
6684
  if (!version) {
6181
6685
  return {
6182
6686
  package: input.package,
@@ -6184,7 +6688,8 @@ async function handleUsage(db, input) {
6184
6688
  surface: null,
6185
6689
  available: false,
6186
6690
  engines: null,
6187
- note: `Could not resolve a version for "${input.package}" on npm.`
6691
+ note: resolved.unpublished ? `"${input.package}" has no published version ${input.version}. That release does not exist on the npm registry \u2014 check the version before writing code against it.` : `Could not resolve a version for "${input.package}" on npm.`,
6692
+ ...resolved.unpublished ? { unpublishedVersion: input.version } : {}
6188
6693
  };
6189
6694
  }
6190
6695
  const deadline = Date.now() + USAGE_EXTRACT_BUDGET_MS;
@@ -6207,6 +6712,10 @@ async function handleUsage(db, input) {
6207
6712
  budgetMs: Math.max(0, deadline - Date.now())
6208
6713
  });
6209
6714
  if (known) out.delta = { ...diffSurface(known, surface), fromVersion: input.knownVersion };
6715
+ else {
6716
+ const knownExists = await npmVersionExists(input.package, input.knownVersion);
6717
+ out.deltaNote = knownExists === false ? `No delta: "${input.package}" has no published version ${input.knownVersion}. That release does not exist on the npm registry.` : `No delta: no API surface extracted for ${input.package}@${input.knownVersion} yet. Retry shortly, or compare against a version that has been indexed.`;
6718
+ }
6210
6719
  }
6211
6720
  return out;
6212
6721
  }
@@ -6262,7 +6771,8 @@ __export(remote_exports, {
6262
6771
  apiKey: () => apiKey,
6263
6772
  callTool: () => callTool,
6264
6773
  endpoint: () => endpoint,
6265
- fetchUpgradePlan: () => fetchUpgradePlan
6774
+ fetchUpgradePlan: () => fetchUpgradePlan,
6775
+ reportUpgradeRuns: () => reportUpgradeRuns
6266
6776
  });
6267
6777
  function endpoint(override) {
6268
6778
  const base = resolveEndpoint(override) ?? DEFAULT_ENDPOINT;
@@ -6356,6 +6866,9 @@ function fetchUpgradePlan(deps, opts = {}) {
6356
6866
  const body = opts.repo ? { deps, repo: opts.repo } : { deps };
6357
6867
  return post("/upgrade-plan", body, opts);
6358
6868
  }
6869
+ function reportUpgradeRuns(runs, opts = {}) {
6870
+ return post("/upgrade-runs", { runs }, opts);
6871
+ }
6359
6872
  var RemoteError;
6360
6873
  var init_remote = __esm({
6361
6874
  "src/cli/remote.ts"() {
@@ -6449,7 +6962,7 @@ var init_optimize = __esm({
6449
6962
  });
6450
6963
 
6451
6964
  // src/mcp/diagram.ts
6452
- import { inArray as inArray7 } from "drizzle-orm";
6965
+ import { and as and11, eq as eq13, inArray as inArray7 } from "drizzle-orm";
6453
6966
  function layerFor(item) {
6454
6967
  if (!item.category) return UNCLASSIFIED;
6455
6968
  if (item.category === "framework" && item.runtimeTarget === "node") return "Backend";
@@ -6462,19 +6975,19 @@ function buildMermaid(items) {
6462
6975
  const byLayer = /* @__PURE__ */ new Map();
6463
6976
  for (const item of items) {
6464
6977
  const layer = layerFor(item);
6465
- const list = byLayer.get(layer) ?? [];
6466
- list.push(item);
6467
- byLayer.set(layer, list);
6978
+ const list2 = byLayer.get(layer) ?? [];
6979
+ list2.push(item);
6980
+ byLayer.set(layer, list2);
6468
6981
  }
6469
6982
  const lines = ["flowchart TD", " user([User])"];
6470
6983
  const ids = /* @__PURE__ */ new Map();
6471
6984
  let counter = 0;
6472
6985
  const repNode = /* @__PURE__ */ new Map();
6473
6986
  const emitLayer = (layer) => {
6474
- const list = byLayer.get(layer);
6475
- if (!list?.length) return;
6987
+ const list2 = byLayer.get(layer);
6988
+ if (!list2?.length) return;
6476
6989
  lines.push(` subgraph ${layer.replace(/\s+/g, "_")}["${layer}"]`);
6477
- for (const item of list) {
6990
+ for (const item of list2) {
6478
6991
  const id = nodeId(item.label, counter++);
6479
6992
  ids.set(item.label, id);
6480
6993
  if (!repNode.has(layer)) repNode.set(layer, id);
@@ -6506,7 +7019,9 @@ async function handleDiagram(db, input) {
6506
7019
  name: packages.name,
6507
7020
  category: packages.category,
6508
7021
  runtimeTarget: packages.runtimeTarget
6509
- }).from(packages).where(inArray7(packages.name, input.stack));
7022
+ }).from(packages).where(
7023
+ and11(inArray7(packages.name, input.stack), eq13(packages.ecosystem, DEFAULT_ECOSYSTEM))
7024
+ );
6510
7025
  const known = new Map(rows.map((r) => [r.name, r]));
6511
7026
  const items = input.stack.map((name) => {
6512
7027
  const row = known.get(name);
@@ -6535,6 +7050,7 @@ var init_diagram = __esm({
6535
7050
  "src/mcp/diagram.ts"() {
6536
7051
  "use strict";
6537
7052
  init_esm_shims();
7053
+ init_types();
6538
7054
  init_categoryInference();
6539
7055
  init_runtimeTarget();
6540
7056
  init_schema();
@@ -6578,7 +7094,7 @@ __export(plan_exports, {
6578
7094
  resolvePins: () => resolvePins
6579
7095
  });
6580
7096
  import { createHash as createHash4 } from "crypto";
6581
- import { inArray as inArray8 } from "drizzle-orm";
7097
+ import { and as and12, eq as eq14, inArray as inArray8 } from "drizzle-orm";
6582
7098
  function packageToCandidate(row) {
6583
7099
  return {
6584
7100
  name: row.name,
@@ -6590,17 +7106,24 @@ function packageToCandidate(row) {
6590
7106
  latestVersion: row.latestVersion,
6591
7107
  weeklyDownloads: row.weeklyDownloads,
6592
7108
  lastReleaseAt: row.lastReleaseAt ? row.lastReleaseAt.toISOString() : null,
6593
- repoUrl: row.repoUrl
7109
+ repoUrl: row.repoUrl,
7110
+ // A pin is the one slot lurq did not choose, which makes these MORE worth
7111
+ // saying, not less: the user picked it, so nobody else is going to notice
7112
+ // that it has been deprecated since they did.
7113
+ deprecated: row.deprecated,
7114
+ archived: row.archived,
7115
+ advisories: row.advisories?.length ?? 0
6594
7116
  };
6595
7117
  }
6596
7118
  async function resolvePins(db, using, recommendedNames, requestedByOwnerId = null) {
6597
7119
  const slots = [];
6598
- const unresolved = [];
7120
+ const notFound = [];
7121
+ const pending2 = [];
6599
7122
  for (const name of new Set(using ?? [])) {
6600
7123
  if (recommendedNames.has(name)) continue;
6601
- const { row } = await getOrFetchPackage(db, name, { requestedByOwnerId });
7124
+ const { row, existsOnNpm } = await getOrFetchPackage(db, name, { requestedByOwnerId });
6602
7125
  if (!row) {
6603
- unresolved.push(name);
7126
+ (existsOnNpm ? pending2 : notFound).push(name);
6604
7127
  continue;
6605
7128
  }
6606
7129
  slots.push({
@@ -6613,7 +7136,7 @@ async function resolvePins(db, using, recommendedNames, requestedByOwnerId = nul
6613
7136
  note: "pinned by you"
6614
7137
  });
6615
7138
  }
6616
- return { slots, unresolved };
7139
+ return { slots, notFound, pending: pending2 };
6617
7140
  }
6618
7141
  async function handlePlan(db, input, ownerId = null) {
6619
7142
  const optimize = input.optimize ?? "balanced";
@@ -6670,17 +7193,20 @@ async function handlePlan(db, input, ownerId = null) {
6670
7193
  const recommendedNames = new Set(
6671
7194
  recSlots.map((s) => s.recommended?.name).filter((n) => Boolean(n))
6672
7195
  );
6673
- const { slots: pinnedSlots, unresolved: unresolvedPins } = await resolvePins(
6674
- db,
6675
- input.using,
6676
- recommendedNames,
6677
- ownerId
6678
- );
7196
+ const {
7197
+ slots: pinnedSlots,
7198
+ notFound: pinsNotFound,
7199
+ pending: pinsPending
7200
+ } = await resolvePins(db, input.using, recommendedNames, ownerId);
6679
7201
  const slots = [...pinnedSlots, ...recSlots];
6680
7202
  const compatibility = await resolveCompat(db, slots);
6681
7203
  const unmatched = [
6682
7204
  ...slots.filter((s) => !s.recommended).map((s) => s.need),
6683
- ...unresolvedPins.map((n) => `${n} (pinned, but not found on npm)`)
7205
+ ...pinsNotFound.map((n) => `${n} (pinned, but not on the npm registry)`),
7206
+ // A pin that exists and is merely being ingested is not an unmatched need —
7207
+ // it just has no scores yet. Saying so keeps the user from "fixing" a name
7208
+ // that was correct all along.
7209
+ ...pinsPending.map((n) => `${n} (pinned; being scored now, retry shortly)`)
6684
7210
  ];
6685
7211
  const mermaid = buildMermaid(
6686
7212
  slots.filter((s) => s.recommended).map((s) => ({ label: s.recommended.name, category: s.category }))
@@ -6799,7 +7325,7 @@ function orderCandidates(cands, anchorFamily, optimize, bundleByName) {
6799
7325
  async function bundleSizes(db, candidates) {
6800
7326
  const names2 = [...new Set(candidates.map((c) => c.name))];
6801
7327
  if (names2.length === 0) return /* @__PURE__ */ new Map();
6802
- const rows = await db.select({ name: packages.name, bundle: packages.bundleMinGzipKb }).from(packages).where(inArray8(packages.name, names2));
7328
+ const rows = await db.select({ name: packages.name, bundle: packages.bundleMinGzipKb }).from(packages).where(and12(inArray8(packages.name, names2), eq14(packages.ecosystem, DEFAULT_ECOSYSTEM)));
6803
7329
  return new Map(rows.filter((r) => r.bundle != null).map((r) => [r.name, r.bundle]));
6804
7330
  }
6805
7331
  async function decompose(document) {
@@ -6871,6 +7397,7 @@ var init_plan = __esm({
6871
7397
  "src/mcp/plan.ts"() {
6872
7398
  "use strict";
6873
7399
  init_esm_shims();
7400
+ init_types();
6874
7401
  init_config();
6875
7402
  init_http();
6876
7403
  init_check();
@@ -6986,17 +7513,20 @@ var init_planView = __esm({
6986
7513
  }
6987
7514
  });
6988
7515
 
6989
- // src/db/watch.ts
6990
- import { eq as eq11 } from "drizzle-orm";
6991
- async function getWatchCursor(db, id) {
6992
- const rows = await db.select({ seq: watchState.seq }).from(watchState).where(eq11(watchState.id, id)).limit(1);
6993
- return rows[0]?.seq ?? null;
7516
+ // src/db/discovery.ts
7517
+ import { eq as eq15 } from "drizzle-orm";
7518
+ async function getQueuedNames(db) {
7519
+ const rows = await db.select({ name: discoveryQueue.name }).from(discoveryQueue);
7520
+ return new Set(rows.map((r) => r.name));
6994
7521
  }
6995
- async function setWatchCursor(db, id, seq) {
6996
- await db.insert(watchState).values({ id, seq, updatedAt: /* @__PURE__ */ new Date() }).onConflictDoUpdate({ target: watchState.id, set: { seq, updatedAt: /* @__PURE__ */ new Date() } });
7522
+ async function enqueueCandidates(db, candidates) {
7523
+ if (candidates.length === 0) return 0;
7524
+ const rows = candidates.map((c) => ({ name: c.name, discoveredVia: c.via }));
7525
+ const inserted = await db.insert(discoveryQueue).values(rows).onConflictDoNothing({ target: discoveryQueue.name }).returning({ id: discoveryQueue.id });
7526
+ return inserted.length;
6997
7527
  }
6998
- var init_watch = __esm({
6999
- "src/db/watch.ts"() {
7528
+ var init_discovery = __esm({
7529
+ "src/db/discovery.ts"() {
7000
7530
  "use strict";
7001
7531
  init_esm_shims();
7002
7532
  init_schema();
@@ -7006,56 +7536,104 @@ var init_watch = __esm({
7006
7536
  // src/pipeline/watch.ts
7007
7537
  var watch_exports = {};
7008
7538
  __export(watch_exports, {
7009
- parseChangeLine: () => parseChangeLine,
7539
+ fetchHeadSeq: () => fetchHeadSeq,
7540
+ parseChange: () => parseChange,
7541
+ parseChangesPage: () => parseChangesPage,
7542
+ routeChange: () => routeChange,
7010
7543
  watchNpmChanges: () => watchNpmChanges
7011
7544
  });
7012
- function parseChangeLine(line) {
7013
- const trimmed = line.trim();
7014
- if (!trimmed) return null;
7015
- try {
7016
- const obj = JSON.parse(trimmed);
7017
- if (typeof obj?.id !== "string" || obj.seq == null) return null;
7018
- return { seq: obj.seq, id: obj.id, deleted: Boolean(obj.deleted) };
7019
- } catch {
7020
- return null;
7021
- }
7545
+ function parseChange(obj) {
7546
+ if (typeof obj !== "object" || obj === null) return null;
7547
+ const rec = obj;
7548
+ if (typeof rec.id !== "string" || rec.seq == null) return null;
7549
+ if (typeof rec.seq !== "number" && typeof rec.seq !== "string") return null;
7550
+ return { seq: rec.seq, id: rec.id, deleted: Boolean(rec.deleted) };
7551
+ }
7552
+ function parseChangesPage(body) {
7553
+ if (typeof body !== "object" || body === null) return { changes: [], lastSeq: null };
7554
+ const { results, last_seq: lastSeq } = body;
7555
+ const changes = Array.isArray(results) ? results.map(parseChange).filter((c) => c !== null) : [];
7556
+ return {
7557
+ changes,
7558
+ lastSeq: typeof lastSeq === "number" || typeof lastSeq === "string" ? String(lastSeq) : null
7559
+ };
7560
+ }
7561
+ function routeChange(change2, tracked, queued) {
7562
+ if (change2.deleted) return "skip";
7563
+ if (tracked.has(change2.id)) return "resync";
7564
+ if (queued.has(change2.id)) return "skip";
7565
+ return "enqueue";
7566
+ }
7567
+ async function getJson(url, fetchImpl, signal) {
7568
+ const res = await fetchImpl(url, { signal });
7569
+ if (!res.ok) throw new Error(`feed responded ${res.status}`);
7570
+ return res.json();
7571
+ }
7572
+ async function fetchHeadSeq(fetchImpl = fetch, signal) {
7573
+ const body = await getJson(`${FEED_URL}?descending=true&limit=1`, fetchImpl, signal);
7574
+ return parseChangesPage(body).lastSeq;
7575
+ }
7576
+ async function loadSeenSets(db) {
7577
+ const [tracked, queued] = await Promise.all([getAllPackageNames(db), getQueuedNames(db)]);
7578
+ return [new Set(tracked), queued];
7022
7579
  }
7023
7580
  async function watchNpmChanges(db, opts = {}) {
7024
- const { signal } = opts;
7581
+ const { signal, fetchImpl = fetch } = opts;
7025
7582
  let backoff2 = 1e3;
7583
+ let [tracked, queued] = await loadSeenSets(db);
7584
+ let seenAt = Date.now();
7585
+ let cursor = await getWatchCursor(db, FEED_ID) ?? opts.since ?? await fetchHeadSeq(fetchImpl, signal).catch(() => null);
7586
+ if (cursor == null) {
7587
+ logger.warn("watch: could not establish a starting sequence; giving up.");
7588
+ return;
7589
+ }
7590
+ logger.info(
7591
+ `watch: following from seq=${cursor} (${tracked.size} tracked, ${queued.size} queued)`
7592
+ );
7026
7593
  while (!signal?.aborted) {
7027
- let tracked = new Set(await getAllPackageNames(db));
7028
- let trackedAt = Date.now();
7029
- const since = await getWatchCursor(db, FEED_ID) ?? opts.since ?? "now";
7030
- const url = `${FEED_URL}?feed=continuous&since=${encodeURIComponent(since)}&heartbeat=${HEARTBEAT_MS}`;
7031
- logger.info(`watch: connecting from seq=${since} (${tracked.size} tracked packages)`);
7032
7594
  try {
7033
- const res = await fetch(url, { signal });
7034
- if (!res.ok || !res.body) throw new Error(`feed responded ${res.status}`);
7595
+ const url = `${FEED_URL}?since=${encodeURIComponent(cursor)}&limit=${PAGE_SIZE}`;
7596
+ const { changes, lastSeq } = parseChangesPage(await getJson(url, fetchImpl, signal));
7035
7597
  backoff2 = 1e3;
7036
- let sinceCheckpoint = 0;
7037
- for await (const line of ndjsonLines(res.body, signal)) {
7038
- const change = parseChangeLine(line);
7039
- if (!change) continue;
7040
- const seq = String(change.seq);
7041
- sinceCheckpoint++;
7042
- if (Date.now() - trackedAt > TRACKED_REFRESH_MS) {
7043
- tracked = new Set(await getAllPackageNames(db));
7044
- trackedAt = Date.now();
7045
- }
7046
- if (!change.deleted && tracked.has(change.id)) {
7047
- logger.info(`watch: re-syncing ${change.id} (seq=${seq})`);
7048
- await syncOnePackage(db, change.id).catch(
7049
- (err) => logger.warn(`watch: re-sync failed for ${change.id}: ${String(err)}`)
7598
+ if (Date.now() - seenAt > TRACKED_REFRESH_MS) {
7599
+ [tracked, queued] = await loadSeenSets(db);
7600
+ seenAt = Date.now();
7601
+ }
7602
+ const buffered = [];
7603
+ const flush = async () => {
7604
+ if (buffered.length === 0) return;
7605
+ const batch = buffered.map((name) => ({ name, via: "npm-changes" }));
7606
+ const inserted = await enqueueCandidates(db, batch).catch((err) => {
7607
+ logger.warn(`watch: enqueue of ${batch.length} candidate(s) failed: ${String(err)}`);
7608
+ return 0;
7609
+ });
7610
+ if (inserted > 0) logger.info(`watch: queued ${inserted} new discovery candidate(s)`);
7611
+ buffered.length = 0;
7612
+ };
7613
+ let resynced = 0;
7614
+ for (const change2 of changes) {
7615
+ if (signal?.aborted) break;
7616
+ const route = routeChange(change2, tracked, queued);
7617
+ if (route === "resync") {
7618
+ await syncOnePackage(db, change2.id).catch(
7619
+ (err) => logger.warn(`watch: re-sync failed for ${change2.id}: ${String(err)}`)
7050
7620
  );
7051
- await setWatchCursor(db, FEED_ID, seq);
7052
- sinceCheckpoint = 0;
7053
- } else if (sinceCheckpoint >= CHECKPOINT_EVERY) {
7054
- await setWatchCursor(db, FEED_ID, seq);
7055
- sinceCheckpoint = 0;
7621
+ resynced++;
7622
+ } else if (route === "enqueue") {
7623
+ queued.add(change2.id);
7624
+ buffered.push(change2.id);
7625
+ if (buffered.length >= ENQUEUE_BATCH) await flush();
7056
7626
  }
7057
7627
  }
7058
- logger.info("watch: feed stream ended; reconnecting");
7628
+ await flush();
7629
+ if (lastSeq !== null && lastSeq !== cursor) {
7630
+ cursor = lastSeq;
7631
+ await setWatchCursor(db, FEED_ID, cursor);
7632
+ }
7633
+ if (changes.length > 0) {
7634
+ logger.info(`watch: ${changes.length} change(s), ${resynced} re-synced, seq=${cursor}`);
7635
+ }
7636
+ if (changes.length < PAGE_SIZE) await sleep(IDLE_POLL_MS, signal);
7059
7637
  } catch (err) {
7060
7638
  if (signal?.aborted) break;
7061
7639
  logger.warn(`watch: ${String(err)}, retrying in ${backoff2}ms`);
@@ -7063,54 +7641,38 @@ async function watchNpmChanges(db, opts = {}) {
7063
7641
  backoff2 = Math.min(backoff2 * 2, MAX_BACKOFF_MS);
7064
7642
  }
7065
7643
  }
7066
- }
7067
- async function* ndjsonLines(body, signal) {
7068
- const reader = body.getReader();
7069
- const decoder = new TextDecoder();
7070
- let buffer = "";
7071
- try {
7072
- while (!signal?.aborted) {
7073
- const { value, done } = await reader.read();
7074
- if (done) break;
7075
- buffer += decoder.decode(value, { stream: true });
7076
- let nl;
7077
- while ((nl = buffer.indexOf("\n")) >= 0) {
7078
- yield buffer.slice(0, nl);
7079
- buffer = buffer.slice(nl + 1);
7080
- }
7081
- }
7082
- } finally {
7083
- reader.releaseLock();
7084
- }
7644
+ logger.info("watch: stopped.");
7085
7645
  }
7086
7646
  function sleep(ms, signal) {
7087
- return new Promise((resolve) => {
7088
- const timer = setTimeout(resolve, ms);
7647
+ return new Promise((resolve2) => {
7648
+ const timer = setTimeout(resolve2, ms);
7089
7649
  signal?.addEventListener(
7090
7650
  "abort",
7091
7651
  () => {
7092
7652
  clearTimeout(timer);
7093
- resolve();
7653
+ resolve2();
7094
7654
  },
7095
7655
  { once: true }
7096
7656
  );
7097
7657
  });
7098
7658
  }
7099
- var FEED_ID, FEED_URL, HEARTBEAT_MS, TRACKED_REFRESH_MS, CHECKPOINT_EVERY, MAX_BACKOFF_MS;
7659
+ var FEED_ID, FEED_URL, PAGE_SIZE, IDLE_POLL_MS, TRACKED_REFRESH_MS, MAX_BACKOFF_MS, ENQUEUE_BATCH;
7100
7660
  var init_watch2 = __esm({
7101
7661
  "src/pipeline/watch.ts"() {
7102
7662
  "use strict";
7103
7663
  init_esm_shims();
7104
7664
  init_logger();
7665
+ init_discovery();
7105
7666
  init_packages();
7106
7667
  init_watch();
7107
7668
  init_single();
7108
7669
  FEED_ID = "npm-changes";
7109
7670
  FEED_URL = "https://replicate.npmjs.com/_changes";
7110
- HEARTBEAT_MS = 3e4;
7671
+ PAGE_SIZE = 1e3;
7672
+ IDLE_POLL_MS = 3e4;
7111
7673
  TRACKED_REFRESH_MS = 5 * 6e4;
7112
- CHECKPOINT_EVERY = 200;
7113
7674
  MAX_BACKOFF_MS = 3e4;
7675
+ ENQUEUE_BATCH = 200;
7114
7676
  }
7115
7677
  });
7116
7678
 
@@ -7711,6 +8273,7 @@ var init_compat2 = __esm({
7711
8273
  var commands_exports = {};
7712
8274
  __export(commands_exports, {
7713
8275
  indexSource: () => indexSource,
8276
+ runCan: () => runCan,
7714
8277
  runCompare: () => runCompare,
7715
8278
  runCompat: () => runCompat,
7716
8279
  runCompatBackfill: () => runCompatBackfill,
@@ -7749,6 +8312,14 @@ async function fromIndex(tool, args, local) {
7749
8312
  const { callTool: callTool2 } = await Promise.resolve().then(() => (init_remote(), remote_exports));
7750
8313
  return callTool2(tool, args);
7751
8314
  }
8315
+ function candidateFlags(c) {
8316
+ const flags = [
8317
+ c.deprecated && red("deprecated"),
8318
+ c.archived && yellow("archived"),
8319
+ c.advisories ? yellow(`${c.advisories} advisory`) : null
8320
+ ].filter(Boolean);
8321
+ return flags.length ? flags.join(" ") : dim("\u2014");
8322
+ }
7752
8323
  async function runRecommend(need, opts) {
7753
8324
  if (opts.category && !isCategory(opts.category)) {
7754
8325
  throw new Error(`Unknown category "${opts.category}".`);
@@ -7767,7 +8338,7 @@ async function runRecommend(need, opts) {
7767
8338
  }
7768
8339
  console.log(
7769
8340
  table(
7770
- ["Package", "Health", "Quality", "Confidence", "Weekly", "Latest", "Category"],
8341
+ ["Package", "Health", "Quality", "Confidence", "Weekly", "Latest", "Category", "Flags"],
7771
8342
  candidates.map((c) => [
7772
8343
  c.name,
7773
8344
  String(c.healthScore),
@@ -7775,7 +8346,11 @@ async function runRecommend(need, opts) {
7775
8346
  confidenceLabel(c.confidence),
7776
8347
  formatNumber(c.weeklyDownloads),
7777
8348
  c.latestVersion ?? "\u2014",
7778
- c.category ?? "\u2014"
8349
+ c.category ?? "\u2014",
8350
+ // Last, and the only coloured cell: it is the column that can reverse
8351
+ // the decision the other seven are supporting, so it reads as a verdict
8352
+ // at the end of the row rather than as one more attribute among them.
8353
+ candidateFlags(c)
7779
8354
  ])
7780
8355
  )
7781
8356
  );
@@ -7873,10 +8448,31 @@ async function runCompare(pkgs, opts) {
7873
8448
  ])
7874
8449
  )
7875
8450
  );
7876
- if (res.missing?.length) console.log(dim(`
7877
- not found: ${res.missing.join(", ")}`));
8451
+ const notFound = res.notFound ?? (res.pending ? [] : res.missing ?? []);
8452
+ if (notFound.length) console.log(red(`
8453
+ not on npm: ${notFound.join(", ")}`));
8454
+ if (res.pending?.length) {
8455
+ console.log(dim(`
8456
+ being scored now (retry shortly): ${res.pending.join(", ")}`));
8457
+ }
7878
8458
  console.log(dim(`data as of ${formatDate(res.dataAsOf)}`));
7879
8459
  }
8460
+ function runCan(query, opts = {}) {
8461
+ const matches = searchCapabilities(query, 5);
8462
+ if (opts.json) {
8463
+ console.log(JSON.stringify(matches, null, 2));
8464
+ return;
8465
+ }
8466
+ if (!query.trim()) console.log(dim("Showing everything lurq does. Add words to narrow it.\n"));
8467
+ for (const m of matches) {
8468
+ console.log(bold(m.title));
8469
+ console.log(` ${m.answer}`);
8470
+ if (m.cli) console.log(` ${green("$")} ${m.cli}`);
8471
+ if (m.mcp) console.log(dim(` agents: call the \`${m.mcp}\` tool`));
8472
+ if (m.page) console.log(dim(` dashboard: https://lurq.run${m.page}`));
8473
+ console.log("");
8474
+ }
8475
+ }
7880
8476
  function runWeights(opts = {}) {
7881
8477
  const w = loadWeights();
7882
8478
  const active2 = activeWeightsPath();
@@ -7947,10 +8543,10 @@ async function runEditWeights(opts) {
7947
8543
  runWeights(opts);
7948
8544
  }
7949
8545
  async function runPlan(file, opts) {
7950
- const { readFileSync: readFileSync10 } = await import("fs");
8546
+ const { readFileSync: readFileSync11 } = await import("fs");
7951
8547
  let document;
7952
8548
  try {
7953
- document = readFileSync10(file, "utf8");
8549
+ document = readFileSync11(file, "utf8");
7954
8550
  } catch {
7955
8551
  throw new Error(`Could not read "${file}".`);
7956
8552
  }
@@ -8115,6 +8711,7 @@ async function runUsage(pkg, opts) {
8115
8711
  const reqs = Object.entries(res.engines).map(([k, v]) => `${k} ${v}`).join(", ");
8116
8712
  if (reqs) console.log(dim(`requires: ${reqs}`));
8117
8713
  }
8714
+ if (res.unpublishedVersion) return console.log(red(res.note ?? "that version does not exist"));
8118
8715
  if (!res.available) return console.log(dim(res.note ?? "no API surface available"));
8119
8716
  console.log(
8120
8717
  table(
@@ -8137,6 +8734,9 @@ async function runUsage(pkg, opts) {
8137
8734
  if (!removed.length && !added.length && !renamed.length && !changed.length) {
8138
8735
  console.log(dim(" no API changes"));
8139
8736
  }
8737
+ } else if (res.deltaNote) {
8738
+ console.log(yellow(`
8739
+ ${res.deltaNote}`));
8140
8740
  }
8141
8741
  }
8142
8742
  async function runCompatBackfill(opts) {
@@ -8243,6 +8843,7 @@ var init_commands = __esm({
8243
8843
  init_open();
8244
8844
  init_userConfig();
8245
8845
  init_types();
8846
+ init_capabilities();
8246
8847
  init_client();
8247
8848
  init_packages();
8248
8849
  init_handlers();
@@ -8274,15 +8875,15 @@ function appJwt(creds, now = /* @__PURE__ */ new Date()) {
8274
8875
  const signature = createSign("RSA-SHA256").update(`${header}.${payload}`).sign(creds.privateKey);
8275
8876
  return `${header}.${payload}.${b64url(signature)}`;
8276
8877
  }
8277
- async function installationToken(installationId) {
8278
- const cached3 = tokenCache.get(installationId);
8878
+ async function installationToken(installationId2) {
8879
+ const cached3 = tokenCache.get(installationId2);
8279
8880
  if (cached3 && cached3.expiresAt - TOKEN_SKEW_MS > Date.now()) return cached3.token;
8280
8881
  const creds = githubAppCredentials();
8281
8882
  if (!creds) throw new GithubAppError("GitHub App is not configured.", 503);
8282
8883
  let res;
8283
8884
  try {
8284
8885
  res = await httpRequest(
8285
- `${API}/app/installations/${installationId}/access_tokens`,
8886
+ `${API}/app/installations/${installationId2}/access_tokens`,
8286
8887
  {
8287
8888
  host: API_HOST,
8288
8889
  ttlMs: 0,
@@ -8307,7 +8908,7 @@ async function installationToken(installationId) {
8307
8908
  const token = res.data?.token;
8308
8909
  if (!token) throw new GithubAppError("GitHub returned no installation token.", 502);
8309
8910
  const expiresAt = res.data.expires_at ? Date.parse(res.data.expires_at) : NaN;
8310
- tokenCache.set(installationId, {
8911
+ tokenCache.set(installationId2, {
8311
8912
  token,
8312
8913
  // A token with an unparseable expiry is still valid; treat it as short-lived
8313
8914
  // rather than caching it forever on a malformed timestamp.
@@ -8315,8 +8916,8 @@ async function installationToken(installationId) {
8315
8916
  });
8316
8917
  return token;
8317
8918
  }
8318
- async function installationGet(installationId, path2) {
8319
- const token = await installationToken(installationId);
8919
+ async function installationGet(installationId2, path2) {
8920
+ const token = await installationToken(installationId2);
8320
8921
  const res = await httpRequest(`${API}${path2}`, {
8321
8922
  host: API_HOST,
8322
8923
  ttlMs: 0,
@@ -8422,8 +9023,15 @@ jobs:
8422
9023
 
8423
9024
  # 2. Narrow that to symbols THIS repo references. Runs entirely locally
8424
9025
  # against both versions' npm tarballs, no API key, no test suite.
9026
+ #
9027
+ # \`--report\` is the only part that talks to us, and it is additive: it
9028
+ # posts what this check concluded so your dashboard can show it. A
9029
+ # missing key or an unreachable API prints a line and changes nothing
9030
+ # else, so the gate keeps working exactly as it does without it.
8425
9031
  - name: Check against this codebase
8426
- run: npx -y ${cli} check-upgrade . --plan lurq-plan.json --json > lurq-brief.json
9032
+ env:
9033
+ LURQ_API_KEY: \${{ secrets.LURQ_API_KEY }}
9034
+ run: npx -y ${cli} check-upgrade . --plan lurq-plan.json --report --json > lurq-brief.json
8427
9035
 
8428
9036
  # The same report twice: once into the run summary, once as the body of
8429
9037
  # the pull request. Fenced, because the report is aligned plain text and
@@ -8547,22 +9155,23 @@ var init_workflow = __esm({
8547
9155
  });
8548
9156
 
8549
9157
  // src/github/manifests.ts
8550
- async function listInstallationRepos(installationId) {
9158
+ async function listInstallationRepos(installationId2) {
8551
9159
  const out = [];
8552
9160
  for (let page = 1; page <= MAX_PAGES; page++) {
8553
9161
  const data = await installationGet(
8554
- installationId,
8555
- `/installation/repositories?per_page=${PAGE_SIZE}&page=${page}`
9162
+ installationId2,
9163
+ `/installation/repositories?per_page=${PAGE_SIZE2}&page=${page}`
8556
9164
  );
8557
9165
  const batch = data.repositories ?? [];
8558
9166
  for (const repo of batch) {
8559
9167
  out.push({
8560
9168
  fullName: repo.full_name,
8561
9169
  defaultBranch: repo.default_branch,
8562
- isPrivate: repo.private
9170
+ isPrivate: repo.private,
9171
+ pushedAt: repo.pushed_at ?? null
8563
9172
  });
8564
9173
  }
8565
- if (batch.length < PAGE_SIZE) break;
9174
+ if (batch.length < PAGE_SIZE2) break;
8566
9175
  }
8567
9176
  return out;
8568
9177
  }
@@ -8589,9 +9198,9 @@ function parseManifest(path2, json2) {
8589
9198
  }
8590
9199
  return Object.keys(deps).length > 0 ? { path: path2, deps } : null;
8591
9200
  }
8592
- async function fetchManifests(installationId, fullName, branch) {
9201
+ async function fetchManifests(installationId2, fullName, branch) {
8593
9202
  const tree = await installationGet(
8594
- installationId,
9203
+ installationId2,
8595
9204
  `/repos/${fullName}/git/trees/${encodeURIComponent(branch)}?recursive=1`
8596
9205
  );
8597
9206
  const nodes = tree.tree ?? [];
@@ -8602,7 +9211,7 @@ async function fetchManifests(installationId, fullName, branch) {
8602
9211
  for (const path2 of paths) {
8603
9212
  try {
8604
9213
  const res = await installationGet(
8605
- installationId,
9214
+ installationId2,
8606
9215
  `/repos/${fullName}/contents/${path2.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(branch)}`
8607
9216
  );
8608
9217
  if (!res.content || res.encoding !== "base64") continue;
@@ -8621,7 +9230,7 @@ async function fetchManifests(installationId, fullName, branch) {
8621
9230
  partial: Boolean(tree.truncated) || allPaths.length > paths.length
8622
9231
  };
8623
9232
  }
8624
- var PAGE_SIZE, MAX_PAGES;
9233
+ var PAGE_SIZE2, MAX_PAGES;
8625
9234
  var init_manifests = __esm({
8626
9235
  "src/github/manifests.ts"() {
8627
9236
  "use strict";
@@ -8630,7 +9239,7 @@ var init_manifests = __esm({
8630
9239
  init_workflow();
8631
9240
  init_types2();
8632
9241
  init_logger();
8633
- PAGE_SIZE = 100;
9242
+ PAGE_SIZE2 = 100;
8634
9243
  MAX_PAGES = 20;
8635
9244
  }
8636
9245
  });
@@ -8728,8 +9337,8 @@ function formatUpgradePlan(plan) {
8728
9337
  if (upgrade.removed.length) {
8729
9338
  out.push(` removes ${upgrade.removed.length}: ${upgrade.removed.slice(0, 8).join(", ")}`);
8730
9339
  }
8731
- for (const change of upgrade.arityChanged.slice(0, 5)) {
8732
- out.push(` arity ${change.path}: ${change.from ?? "?"} \u2192 ${change.to ?? "?"}`);
9340
+ for (const change2 of upgrade.arityChanged.slice(0, 5)) {
9341
+ out.push(` arity ${change2.path}: ${change2.from ?? "?"} \u2192 ${change2.to ?? "?"}`);
8733
9342
  }
8734
9343
  if (upgrade.hops?.length) {
8735
9344
  out.push(` via ${[upgrade.fromVersion, ...upgrade.hops.map((h) => h.toVersion)].join(" \u2192 ")}`);
@@ -8770,6 +9379,68 @@ var init_upgradePlan = __esm({
8770
9379
  }
8771
9380
  });
8772
9381
 
9382
+ // src/cli/reportRuns.ts
9383
+ var reportRuns_exports = {};
9384
+ __export(reportRuns_exports, {
9385
+ buildRunReports: () => buildRunReports,
9386
+ runContextFromEnv: () => runContextFromEnv
9387
+ });
9388
+ function findingRefs(finding) {
9389
+ return [
9390
+ ...finding.symbolsRemoved.flatMap((s) => s.refs),
9391
+ ...finding.arityChanged.flatMap((a) => a.refs)
9392
+ ];
9393
+ }
9394
+ function buildRunReports(report, targets, ctx) {
9395
+ const versions = new Map(targets.map((t) => [t.package, t]));
9396
+ const base = (pkg) => {
9397
+ const target = versions.get(pkg);
9398
+ return {
9399
+ repoFullName: ctx.repoFullName,
9400
+ packageName: pkg,
9401
+ fromVersion: target?.fromVersion ?? "",
9402
+ toVersion: target?.toVersion ?? "",
9403
+ status: "checked",
9404
+ runUrl: ctx.runUrl
9405
+ };
9406
+ };
9407
+ const rows = [];
9408
+ for (const finding of report.breaking) {
9409
+ const refs = findingRefs(finding);
9410
+ rows.push({
9411
+ ...base(finding.package),
9412
+ // The finding carries its own versions; prefer them over the target map.
9413
+ fromVersion: finding.fromVersion,
9414
+ toVersion: finding.toVersion,
9415
+ severity: finding.severity,
9416
+ symbolsAffected: [
9417
+ ...finding.symbolsRemoved.map((s) => s.symbol),
9418
+ ...finding.arityChanged.map((a) => a.symbol)
9419
+ ],
9420
+ callSites: refs.length,
9421
+ callSiteFiles: [...new Set(refs.map((r) => r.file))]
9422
+ });
9423
+ }
9424
+ for (const pkg of report.ok) rows.push({ ...base(pkg), severity: "ok" });
9425
+ for (const u of report.unverified) rows.push({ ...base(u.package), severity: "unverified" });
9426
+ return rows.filter((r) => r.fromVersion && r.toVersion).slice(0, MAX_RUNS);
9427
+ }
9428
+ function runContextFromEnv(env = process.env) {
9429
+ const repoFullName = env.GITHUB_REPOSITORY?.trim();
9430
+ if (!repoFullName) return null;
9431
+ const server = env.GITHUB_SERVER_URL?.trim() || "https://github.com";
9432
+ const runId = env.GITHUB_RUN_ID?.trim();
9433
+ return { repoFullName, runUrl: runId ? `${server}/${repoFullName}/actions/runs/${runId}` : "" };
9434
+ }
9435
+ var MAX_RUNS;
9436
+ var init_reportRuns = __esm({
9437
+ "src/cli/reportRuns.ts"() {
9438
+ "use strict";
9439
+ init_esm_shims();
9440
+ MAX_RUNS = 100;
9441
+ }
9442
+ });
9443
+
8773
9444
  // src/surface/references.ts
8774
9445
  var references_exports = {};
8775
9446
  __export(references_exports, {
@@ -8861,10 +9532,10 @@ function scanReferences(rootDir, opts = {}) {
8861
9532
  const record = (pkg, symbol, via, specifier, file, line, parent) => {
8862
9533
  let syms = byPackage.get(pkg);
8863
9534
  if (!syms) byPackage.set(pkg, syms = /* @__PURE__ */ new Map());
8864
- const list = syms.get(symbol);
9535
+ const list2 = syms.get(symbol);
8865
9536
  const ref = { symbol, via, specifier, file, line, ...parent ? { parent } : {} };
8866
- if (list) {
8867
- if (!list.some((r) => r.file === file && r.line === line && r.via === via)) list.push(ref);
9537
+ if (list2) {
9538
+ if (!list2.some((r) => r.file === file && r.line === line && r.via === via)) list2.push(ref);
8868
9539
  } else {
8869
9540
  syms.set(symbol, [ref]);
8870
9541
  }
@@ -9650,8 +10321,8 @@ function groupByEntry(refs, pkg) {
9650
10321
  const sub = use.specifier === pkg ? "" : use.specifier.slice(pkg.length + 1);
9651
10322
  let symbols2 = byEntry.get(sub);
9652
10323
  if (!symbols2) byEntry.set(sub, symbols2 = /* @__PURE__ */ new Map());
9653
- const list = symbols2.get(sym);
9654
- if (list) list.push(use);
10324
+ const list2 = symbols2.get(sym);
10325
+ if (list2) list2.push(use);
9655
10326
  else symbols2.set(sym, [use]);
9656
10327
  }
9657
10328
  }
@@ -9872,6 +10543,32 @@ function targetsFromPlanFile(path2) {
9872
10543
  }
9873
10544
  return parsed.upgrades.filter((u) => u.package && u.fromVersion && u.toVersion).filter((u) => u.inScope !== false).map((u) => ({ package: u.package, fromVersion: u.fromVersion, toVersion: u.toVersion }));
9874
10545
  }
10546
+ async function reportOutcome(report, targets, opts) {
10547
+ const { buildRunReports: buildRunReports2, runContextFromEnv: runContextFromEnv2 } = await Promise.resolve().then(() => (init_reportRuns(), reportRuns_exports));
10548
+ const ctx = runContextFromEnv2();
10549
+ if (!ctx) {
10550
+ console.error("--report: no $GITHUB_REPOSITORY, so there is no run to attribute this to. Skipped.");
10551
+ return;
10552
+ }
10553
+ const runs = buildRunReports2(report, targets, ctx);
10554
+ if (runs.length === 0) return;
10555
+ try {
10556
+ const { reportUpgradeRuns: reportUpgradeRuns2 } = await Promise.resolve().then(() => (init_remote(), remote_exports));
10557
+ const { recorded, rejected } = await reportUpgradeRuns2(runs, {
10558
+ url: opts.url,
10559
+ apiKey: opts.apiKey
10560
+ });
10561
+ console.error(
10562
+ `reported ${recorded} upgrade result(s) to the lurq dashboard` + (rejected ? ` (${rejected} rejected)` : "")
10563
+ );
10564
+ } catch (err) {
10565
+ const message = err instanceof Error ? err.message : String(err);
10566
+ const noKey = /no api key/i.test(message);
10567
+ console.error(
10568
+ noKey ? "--report: no API key, so there was nothing to report with. Set LURQ_API_KEY in this repository's secrets to see these results on your dashboard. The check itself is unaffected." : `--report: could not reach the lurq dashboard (${message}). The check itself is unaffected.`
10569
+ );
10570
+ }
10571
+ }
9875
10572
  async function runCheckUpgrade(dir, opts) {
9876
10573
  const { scanReferences: scanReferences2 } = await Promise.resolve().then(() => (init_references(), references_exports));
9877
10574
  const { checkUpgrade: checkUpgrade2, formatUpgradeReport: formatUpgradeReport2 } = await Promise.resolve().then(() => (init_upgrade(), upgrade_exports));
@@ -9892,6 +10589,7 @@ async function runCheckUpgrade(dir, opts) {
9892
10589
  console.log(
9893
10590
  opts.json ? JSON.stringify(report, null, 2) : formatUpgradeReport2(report, `upgrade check on ${dir}`)
9894
10591
  );
10592
+ if (opts.report) await reportOutcome(report, targets, opts);
9895
10593
  if (opts.exitCode && !report.safe) process.exitCode = 1;
9896
10594
  }
9897
10595
  var init_checkUpgrade = __esm({
@@ -9901,6 +10599,459 @@ var init_checkUpgrade = __esm({
9901
10599
  }
9902
10600
  });
9903
10601
 
10602
+ // src/surface/release.ts
10603
+ var release_exports = {};
10604
+ __export(release_exports, {
10605
+ checkRelease: () => checkRelease,
10606
+ formatReleaseCheck: () => formatReleaseCheck,
10607
+ releaseVerdict: () => releaseVerdict
10608
+ });
10609
+ import semver5 from "semver";
10610
+ function declaredBump(from, to) {
10611
+ if (!semver5.gt(to, from)) return null;
10612
+ if (semver5.major(to) !== semver5.major(from)) return "major";
10613
+ if (semver5.minor(to) !== semver5.minor(from)) return "minor";
10614
+ return "patch";
10615
+ }
10616
+ function covers(declared, required, baselineMajor) {
10617
+ if (baselineMajor === 0 && required === "major") return RANK[declared] >= RANK.minor;
10618
+ return RANK[declared] >= RANK[required];
10619
+ }
10620
+ function releaseVerdict(publishedVersion, localVersion, diff) {
10621
+ const required = diff.removed.length || diff.arityChanged.length || diff.typeOnlyRemoved.length ? "major" : diff.added.length ? "minor" : "patch";
10622
+ const declared = declaredBump(publishedVersion, localVersion);
10623
+ const verdict = declared !== null && covers(declared, required, semver5.major(publishedVersion)) ? "ok" : "understated";
10624
+ return { declared, required, verdict };
10625
+ }
10626
+ async function checkRelease(dir, opts = {}) {
10627
+ const manifest = readManifest(dir);
10628
+ const name = manifest?.name;
10629
+ const base = {
10630
+ package: name ?? dir,
10631
+ publishedVersion: null,
10632
+ localVersion: manifest?.version ?? null
10633
+ };
10634
+ if (!name) return inconclusive(base, `no readable package.json in ${dir}`);
10635
+ if (!manifest.version) return inconclusive(base, "package.json has no version field");
10636
+ const { extractSurface: extractSurface3 } = await Promise.resolve().then(() => (init_extract2(), extract_exports));
10637
+ const local = extractSurface3(dir, { manifest });
10638
+ if (local.undeclaredReason) {
10639
+ return inconclusive(base, `${local.undeclaredReason} \u2014 build the package first`);
10640
+ }
10641
+ const published = await fetchAndExtract(name, opts.against ?? "latest", {
10642
+ fetchImpl: opts.fetchImpl
10643
+ });
10644
+ if (!published) {
10645
+ return inconclusive(base, `${name} has no published version to compare against`);
10646
+ }
10647
+ const withVersion = { ...base, publishedVersion: published.resolvedVersion };
10648
+ const diff = diffSurfaces(published.surface, local);
10649
+ if (diff.inconclusive) return inconclusive(withVersion, diff.inconclusive);
10650
+ return {
10651
+ ...withVersion,
10652
+ ...releaseVerdict(published.resolvedVersion, manifest.version, diff),
10653
+ removed: diff.removed.map((s) => s.path),
10654
+ arityChanged: diff.arityChanged,
10655
+ typeOnlyRemoved: diff.typeOnlyRemoved.map((s) => s.path),
10656
+ added: diff.added.map((s) => s.path)
10657
+ };
10658
+ }
10659
+ function formatReleaseCheck(c) {
10660
+ const out = [`lurq, release check \u2014 ${c.package}`, ""];
10661
+ if (c.verdict === "inconclusive") {
10662
+ out.push(`INCONCLUSIVE ${c.inconclusive}`, "", "No verdict. This is not a pass.");
10663
+ return out.join("\n");
10664
+ }
10665
+ const from = c.publishedVersion ?? "?";
10666
+ const to = c.localVersion ?? "?";
10667
+ out.push(
10668
+ c.verdict === "ok" ? c.declared === c.required ? `OK ${from} \u2192 ${to} is a ${c.declared}, which is what this API change needs.` : `OK ${from} \u2192 ${to} is a ${c.declared}; this API change only needed a ${c.required}.` : c.declared === null ? `UNDERSTATED ${to} is not ahead of the published ${from}.` : `UNDERSTATED ${from} \u2192 ${to} is tagged ${c.declared}, but this API change is a ${c.required}.`,
10669
+ ""
10670
+ );
10671
+ out.push(...list("Removed exports", c.removed));
10672
+ if (c.arityChanged.length) {
10673
+ out.push(` Parameter count changed (${c.arityChanged.length}):`);
10674
+ for (const a of c.arityChanged.slice(0, LIST_CAP)) {
10675
+ out.push(` \xB7 ${a.path} ${a.from} \u2192 ${a.to}`);
10676
+ }
10677
+ }
10678
+ out.push(...list("Removed types (breaks tsc, not node)", c.typeOnlyRemoved));
10679
+ out.push(...list("Added exports", c.added));
10680
+ if (!c.removed.length && !c.arityChanged.length && !c.typeOnlyRemoved.length && !c.added.length) {
10681
+ out.push(" No change to the exported surface.");
10682
+ }
10683
+ return out.join("\n");
10684
+ }
10685
+ var RANK, inconclusive, LIST_CAP, list;
10686
+ var init_release = __esm({
10687
+ "src/surface/release.ts"() {
10688
+ "use strict";
10689
+ init_esm_shims();
10690
+ init_diff2();
10691
+ init_fetch();
10692
+ init_resolve();
10693
+ RANK = { patch: 0, minor: 1, major: 2 };
10694
+ inconclusive = (base, reason) => ({
10695
+ ...base,
10696
+ declared: null,
10697
+ required: "patch",
10698
+ verdict: "inconclusive",
10699
+ removed: [],
10700
+ arityChanged: [],
10701
+ typeOnlyRemoved: [],
10702
+ added: [],
10703
+ inconclusive: reason
10704
+ });
10705
+ LIST_CAP = 12;
10706
+ list = (label, items) => {
10707
+ if (!items.length) return [];
10708
+ const shown = items.slice(0, LIST_CAP);
10709
+ const more = items.length - shown.length;
10710
+ return [
10711
+ ` ${label} (${items.length}):`,
10712
+ ...shown.map((s) => ` \xB7 ${s}`),
10713
+ ...more > 0 ? [` \u2026 ${more} more`] : []
10714
+ ];
10715
+ };
10716
+ }
10717
+ });
10718
+
10719
+ // src/api/diff.ts
10720
+ var diff_exports = {};
10721
+ __export(diff_exports, {
10722
+ checkApi: () => checkApi,
10723
+ diffApiSurfaces: () => diffApiSurfaces,
10724
+ formatApiCheck: () => formatApiCheck
10725
+ });
10726
+ import semver6 from "semver";
10727
+ function compareOperation(before, after) {
10728
+ const out = [];
10729
+ for (const param of missing(after.requiredParams, before.requiredParams)) {
10730
+ out.push(
10731
+ change(
10732
+ before.optionalParams.includes(param) ? "param-now-required" : "required-param-added",
10733
+ after.id,
10734
+ param
10735
+ )
10736
+ );
10737
+ }
10738
+ for (const param of missing(after.optionalParams, before.optionalParams)) {
10739
+ out.push(change("optional-param-added", after.id, param));
10740
+ }
10741
+ for (const field of missing(after.requiredBodyFields, before.requiredBodyFields)) {
10742
+ out.push(change("required-body-field-added", after.id, field));
10743
+ }
10744
+ for (const code of missing(before.responseCodes, after.responseCodes)) {
10745
+ out.push(change("response-removed", after.id, code));
10746
+ }
10747
+ for (const code of missing(after.responseCodes, before.responseCodes)) {
10748
+ out.push(change("response-added", after.id, code));
10749
+ }
10750
+ if (after.deprecated && !before.deprecated) out.push(change("operation-deprecated", after.id));
10751
+ return out;
10752
+ }
10753
+ function diffApiSurfaces(before, after) {
10754
+ if (before.unreadableReason) return { breaking: [], other: [], inconclusive: `previous revision: ${before.unreadableReason}` };
10755
+ if (after.unreadableReason) return { breaking: [], other: [], inconclusive: `current revision: ${after.unreadableReason}` };
10756
+ const changes = [];
10757
+ for (const [id, op] of before.operations) {
10758
+ const next = after.operations.get(id);
10759
+ if (!next) {
10760
+ changes.push(change("operation-removed", id));
10761
+ continue;
10762
+ }
10763
+ changes.push(...compareOperation(op, next));
10764
+ }
10765
+ for (const id of after.operations.keys()) {
10766
+ if (!before.operations.has(id)) changes.push(change("operation-added", id));
10767
+ }
10768
+ return {
10769
+ breaking: changes.filter((c) => c.breaking),
10770
+ other: changes.filter((c) => !c.breaking)
10771
+ };
10772
+ }
10773
+ function checkApi(before, after) {
10774
+ const diff = diffApiSurfaces(before, after);
10775
+ const base = {
10776
+ title: after.title ?? before.title,
10777
+ fromVersion: before.version,
10778
+ toVersion: after.version,
10779
+ diff
10780
+ };
10781
+ if (diff.inconclusive) {
10782
+ return { ...base, verdict: "inconclusive", versionCovers: null };
10783
+ }
10784
+ const from = before.version && semver6.valid(before.version);
10785
+ const to = after.version && semver6.valid(after.version);
10786
+ const versionCovers = from && to ? diff.breaking.length > 0 ? semver6.major(to) > semver6.major(from) : semver6.gte(to, from) : null;
10787
+ return { ...base, verdict: diff.breaking.length > 0 ? "breaking" : "ok", versionCovers };
10788
+ }
10789
+ function renderChanges(changes) {
10790
+ const shown = changes.slice(0, LIST_CAP2);
10791
+ const lines = shown.map(
10792
+ (c) => ` \xB7 ${c.operation}${c.detail ? ` \u2192 ${c.detail}` : ""} ${LABEL[c.kind]}`
10793
+ );
10794
+ if (changes.length > shown.length) lines.push(` \u2026 ${changes.length - shown.length} more`);
10795
+ return lines;
10796
+ }
10797
+ function formatApiCheck(check2, label = "api check") {
10798
+ const out = [`lurq, ${label}${check2.title ? ` \u2014 ${check2.title}` : ""}`, ""];
10799
+ if (check2.verdict === "inconclusive") {
10800
+ out.push(`INCONCLUSIVE ${check2.diff.inconclusive}`, "", "No verdict. This is not a pass.");
10801
+ return out.join("\n");
10802
+ }
10803
+ const versions = check2.fromVersion && check2.toVersion ? ` ${check2.fromVersion} \u2192 ${check2.toVersion}` : "";
10804
+ if (check2.diff.breaking.length) {
10805
+ out.push(`BREAKING${versions}`, "");
10806
+ out.push(` ${check2.diff.breaking.length} change(s) that break existing callers:`);
10807
+ out.push(...renderChanges(check2.diff.breaking));
10808
+ } else {
10809
+ out.push(`OK${versions}`, "", " Nothing here breaks an existing caller.");
10810
+ }
10811
+ if (check2.diff.other.length) {
10812
+ out.push("", ` ${check2.diff.other.length} other change(s):`);
10813
+ out.push(...renderChanges(check2.diff.other));
10814
+ }
10815
+ if (check2.versionCovers === false) {
10816
+ out.push(
10817
+ "",
10818
+ check2.diff.breaking.length ? ` info.version went ${check2.fromVersion} \u2192 ${check2.toVersion}. A breaking change needs a major.` : ` info.version went ${check2.fromVersion} \u2192 ${check2.toVersion}, which is not ahead.`
10819
+ );
10820
+ } else if (check2.versionCovers === null && (check2.fromVersion || check2.toVersion)) {
10821
+ out.push("", " info.version is not semver on both sides, so the bump was not judged.");
10822
+ }
10823
+ return out.join("\n");
10824
+ }
10825
+ var BREAKING, change, missing, LIST_CAP2, LABEL;
10826
+ var init_diff3 = __esm({
10827
+ "src/api/diff.ts"() {
10828
+ "use strict";
10829
+ init_esm_shims();
10830
+ BREAKING = /* @__PURE__ */ new Set([
10831
+ "operation-removed",
10832
+ "required-param-added",
10833
+ "param-now-required",
10834
+ "required-body-field-added",
10835
+ "response-removed"
10836
+ ]);
10837
+ change = (kind, operation, detail2) => ({
10838
+ kind,
10839
+ operation,
10840
+ ...detail2 ? { detail: detail2 } : {},
10841
+ breaking: BREAKING.has(kind)
10842
+ });
10843
+ missing = (a, b) => {
10844
+ const has = new Set(b);
10845
+ return a.filter((x) => !has.has(x));
10846
+ };
10847
+ LIST_CAP2 = 15;
10848
+ LABEL = {
10849
+ "operation-removed": "removed",
10850
+ "required-param-added": "new required param",
10851
+ "param-now-required": "now required",
10852
+ "required-body-field-added": "new required body field",
10853
+ "response-removed": "response removed",
10854
+ "operation-added": "added",
10855
+ "optional-param-added": "new optional param",
10856
+ "response-added": "response added",
10857
+ "operation-deprecated": "deprecated"
10858
+ };
10859
+ }
10860
+ });
10861
+
10862
+ // src/api/openapi.ts
10863
+ import { parse as parseYaml } from "yaml";
10864
+ function deref(root, node, seen = /* @__PURE__ */ new Set()) {
10865
+ if (!isRecord(node)) return null;
10866
+ const ref = node.$ref;
10867
+ if (typeof ref !== "string") return node;
10868
+ if (!ref.startsWith("#/") || seen.has(ref)) return null;
10869
+ seen.add(ref);
10870
+ let cursor = root;
10871
+ for (const segment of ref.slice(2).split("/")) {
10872
+ if (!isRecord(cursor)) return null;
10873
+ cursor = cursor[segment.replace(/~1/g, "/").replace(/~0/g, "~")];
10874
+ }
10875
+ return deref(root, cursor, seen);
10876
+ }
10877
+ function readParams(root, shared, own) {
10878
+ const required = /* @__PURE__ */ new Set();
10879
+ const optional = /* @__PURE__ */ new Set();
10880
+ for (const list2 of [shared, own]) {
10881
+ if (!Array.isArray(list2)) continue;
10882
+ for (const raw of list2) {
10883
+ const param = deref(root, raw);
10884
+ const name = param?.name;
10885
+ if (typeof name !== "string") continue;
10886
+ const key = `${name} (${typeof param?.in === "string" ? param.in : "unknown"})`;
10887
+ if (param?.required === true) {
10888
+ required.add(key);
10889
+ optional.delete(key);
10890
+ } else if (!required.has(key)) {
10891
+ optional.add(key);
10892
+ }
10893
+ }
10894
+ }
10895
+ return { required: [...required].sort(), optional: [...optional].sort() };
10896
+ }
10897
+ function readBodyFields(root, requestBody) {
10898
+ const body = deref(root, requestBody);
10899
+ if (!body || body.required !== true) return [];
10900
+ const content = body.content;
10901
+ if (!isRecord(content)) return [];
10902
+ const out = /* @__PURE__ */ new Set();
10903
+ for (const media of Object.values(content)) {
10904
+ const schema = deref(root, isRecord(media) ? media.schema : null);
10905
+ const required = schema?.required;
10906
+ if (!Array.isArray(required)) continue;
10907
+ for (const field of required) if (typeof field === "string") out.add(field);
10908
+ }
10909
+ return [...out].sort();
10910
+ }
10911
+ function extractApiSurface(source, label = "document") {
10912
+ const empty2 = { title: null, version: null, operations: /* @__PURE__ */ new Map() };
10913
+ let doc;
10914
+ try {
10915
+ doc = parseYaml(source);
10916
+ } catch (err) {
10917
+ return {
10918
+ ...empty2,
10919
+ unreadableReason: `${label} is not valid YAML or JSON: ${err instanceof Error ? err.message : String(err)}`
10920
+ };
10921
+ }
10922
+ if (!isRecord(doc)) return { ...empty2, unreadableReason: `${label} is not an object` };
10923
+ if (!doc.openapi && !doc.swagger) {
10924
+ return { ...empty2, unreadableReason: `${label} has no openapi/swagger version field` };
10925
+ }
10926
+ const info = isRecord(doc.info) ? doc.info : {};
10927
+ const paths = isRecord(doc.paths) ? doc.paths : {};
10928
+ const operations = /* @__PURE__ */ new Map();
10929
+ for (const [path2, rawItem] of Object.entries(paths)) {
10930
+ const item = deref(doc, rawItem);
10931
+ if (!item) continue;
10932
+ for (const method of METHODS) {
10933
+ const op = item[method];
10934
+ if (!isRecord(op)) continue;
10935
+ const { required, optional } = readParams(doc, item.parameters, op.parameters);
10936
+ const responses = isRecord(op.responses) ? Object.keys(op.responses).sort() : [];
10937
+ const id = `${method.toUpperCase()} ${path2}`;
10938
+ operations.set(id, {
10939
+ id,
10940
+ method: method.toUpperCase(),
10941
+ path: path2,
10942
+ requiredParams: required,
10943
+ optionalParams: optional,
10944
+ requiredBodyFields: readBodyFields(doc, op.requestBody),
10945
+ responseCodes: responses,
10946
+ deprecated: op.deprecated === true
10947
+ });
10948
+ }
10949
+ }
10950
+ return {
10951
+ title: typeof info.title === "string" ? info.title : null,
10952
+ version: typeof info.version === "string" ? info.version : null,
10953
+ operations,
10954
+ ...operations.size === 0 ? { unreadableReason: `${label} declares no operations` } : {}
10955
+ };
10956
+ }
10957
+ var METHODS, isRecord;
10958
+ var init_openapi = __esm({
10959
+ "src/api/openapi.ts"() {
10960
+ "use strict";
10961
+ init_esm_shims();
10962
+ METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
10963
+ isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
10964
+ }
10965
+ });
10966
+
10967
+ // src/api/check.ts
10968
+ var check_exports = {};
10969
+ __export(check_exports, {
10970
+ findSpec: () => findSpec,
10971
+ runApiCheck: () => runApiCheck
10972
+ });
10973
+ import { execFile as execFile4 } from "child_process";
10974
+ import { existsSync as existsSync7, readFileSync as readFileSync9 } from "fs";
10975
+ import { relative as relative4, resolve } from "path";
10976
+ import { promisify as promisify4 } from "util";
10977
+ function findSpec(dir) {
10978
+ for (const candidate of CANDIDATES2) {
10979
+ const full = resolve(dir, candidate);
10980
+ if (existsSync7(full)) return full;
10981
+ }
10982
+ return null;
10983
+ }
10984
+ async function showAtRevision(dir, rev, repoPath) {
10985
+ try {
10986
+ const { stdout } = await execFileP3("git", ["show", `${rev}:${repoPath}`], {
10987
+ cwd: dir,
10988
+ maxBuffer: 32 * 1024 * 1024
10989
+ });
10990
+ return stdout;
10991
+ } catch {
10992
+ return null;
10993
+ }
10994
+ }
10995
+ async function runApiCheck(dir, opts = {}) {
10996
+ const against = opts.against ?? "HEAD";
10997
+ const specPath = opts.spec ? resolve(dir, opts.spec) : findSpec(dir);
10998
+ const spec = specPath ? relative4(dir, specPath) || specPath : "(none found)";
10999
+ const base = { spec, against, title: null, fromVersion: null, toVersion: null };
11000
+ if (!specPath) {
11001
+ return {
11002
+ ...base,
11003
+ verdict: "inconclusive",
11004
+ versionCovers: null,
11005
+ diff: {
11006
+ breaking: [],
11007
+ other: [],
11008
+ inconclusive: `no OpenAPI document found in ${dir}. Name one: lurq check-api <file>`
11009
+ }
11010
+ };
11011
+ }
11012
+ const current = extractApiSurface(readFileSync9(specPath, "utf8"), spec);
11013
+ const previousSource = await showAtRevision(dir, against, spec);
11014
+ if (previousSource === null) {
11015
+ return {
11016
+ ...base,
11017
+ title: current.title,
11018
+ toVersion: current.version,
11019
+ verdict: "inconclusive",
11020
+ versionCovers: null,
11021
+ diff: {
11022
+ breaking: [],
11023
+ other: [],
11024
+ inconclusive: `${spec} does not exist at ${against}, so there is nothing to compare against`
11025
+ }
11026
+ };
11027
+ }
11028
+ const previous = extractApiSurface(previousSource, `${spec} at ${against}`);
11029
+ return { ...base, ...checkApi(previous, current) };
11030
+ }
11031
+ var execFileP3, CANDIDATES2;
11032
+ var init_check2 = __esm({
11033
+ "src/api/check.ts"() {
11034
+ "use strict";
11035
+ init_esm_shims();
11036
+ init_diff3();
11037
+ init_openapi();
11038
+ execFileP3 = promisify4(execFile4);
11039
+ CANDIDATES2 = [
11040
+ "openapi.yaml",
11041
+ "openapi.yml",
11042
+ "openapi.json",
11043
+ "swagger.yaml",
11044
+ "swagger.yml",
11045
+ "swagger.json",
11046
+ "api/openapi.yaml",
11047
+ "api/openapi.yml",
11048
+ "api/openapi.json",
11049
+ "docs/openapi.yaml",
11050
+ "docs/openapi.yml"
11051
+ ];
11052
+ }
11053
+ });
11054
+
9904
11055
  // src/graph/types.ts
9905
11056
  function canonicalKey(ref) {
9906
11057
  return `${ref.kind}:${ref.namespace}:${ref.name}:${ref.version ?? ""}`;
@@ -9913,7 +11064,7 @@ var init_types6 = __esm({
9913
11064
  });
9914
11065
 
9915
11066
  // src/db/graph.ts
9916
- import { and as and8, eq as eq12, isNull as isNull3 } from "drizzle-orm";
11067
+ import { and as and13, eq as eq16, isNull as isNull3 } from "drizzle-orm";
9917
11068
  import { createHash as createHash6 } from "crypto";
9918
11069
  var init_graph = __esm({
9919
11070
  "src/db/graph.ts"() {
@@ -9925,7 +11076,7 @@ var init_graph = __esm({
9925
11076
  });
9926
11077
 
9927
11078
  // src/db/surface.ts
9928
- import { and as and9, desc as desc5, eq as eq13, sql as sql11 } from "drizzle-orm";
11079
+ import { and as and14, desc as desc5, eq as eq17, sql as sql11 } from "drizzle-orm";
9929
11080
  function surfaceRef(pkg, version) {
9930
11081
  return { kind: "package_surface", namespace: "npm", name: pkg, version };
9931
11082
  }
@@ -9946,7 +11097,7 @@ var init_surface = __esm({
9946
11097
 
9947
11098
  // src/mcp/surfaceHandlers.ts
9948
11099
  import { createHash as createHash7 } from "crypto";
9949
- import { and as and10, desc as desc6, eq as eq14 } from "drizzle-orm";
11100
+ import { and as and15, desc as desc6, eq as eq18 } from "drizzle-orm";
9950
11101
  function rowsToSurface(pkg, version, rows, tier) {
9951
11102
  const symbolsOut = rows.map((r) => ({
9952
11103
  path: r.path,
@@ -9970,17 +11121,17 @@ function rowsToSurface(pkg, version, rows, tier) {
9970
11121
  async function loadStored(db, pkg, version, tenantId = 0) {
9971
11122
  let entity;
9972
11123
  if (version === null) {
9973
- const rows2 = await db.select({ e: entities, at: observations.observedAt }).from(entities).innerJoin(claims, eq14(claims.subjectId, entities.id)).innerJoin(observations, eq14(observations.claimId, claims.id)).where(and10(eq14(entities.kind, "package_surface"), eq14(entities.name, pkg))).orderBy(desc6(observations.observedAt)).limit(1);
11124
+ const rows2 = await db.select({ e: entities, at: observations.observedAt }).from(entities).innerJoin(claims, eq18(claims.subjectId, entities.id)).innerJoin(observations, eq18(observations.claimId, claims.id)).where(and15(eq18(entities.kind, "package_surface"), eq18(entities.name, pkg))).orderBy(desc6(observations.observedAt)).limit(1);
9974
11125
  entity = rows2.find((r) => r.e.tenantId === tenantId)?.e;
9975
11126
  } else {
9976
11127
  const key = canonicalKey(surfaceRef(pkg, version));
9977
- const ents = await db.select().from(entities).where(eq14(entities.canonicalKey, key)).limit(2);
11128
+ const ents = await db.select().from(entities).where(eq18(entities.canonicalKey, key)).limit(2);
9978
11129
  entity = ents.find((e) => e.tenantId === tenantId);
9979
11130
  }
9980
11131
  if (!entity) return null;
9981
- const obs = await db.select({ o: observations }).from(observations).innerJoin(claims, eq14(observations.claimId, claims.id)).where(eq14(claims.subjectId, entity.id)).orderBy(desc6(observations.observedAt)).limit(1);
11132
+ const obs = await db.select({ o: observations }).from(observations).innerJoin(claims, eq18(observations.claimId, claims.id)).where(eq18(claims.subjectId, entity.id)).orderBy(desc6(observations.observedAt)).limit(1);
9982
11133
  const latest = obs[0]?.o ?? null;
9983
- const rows = await db.select().from(symbols).where(eq14(symbols.entityId, entity.id));
11134
+ const rows = await db.select().from(symbols).where(eq18(symbols.entityId, entity.id));
9984
11135
  return {
9985
11136
  entityId: entity.id,
9986
11137
  rows,
@@ -10052,18 +11203,18 @@ async function diffSurfaceUncached(db, input) {
10052
11203
  loadStored(db, input.package, input.fromVersion),
10053
11204
  loadStored(db, input.package, input.toVersion)
10054
11205
  ]);
10055
- const missing = [];
10056
- if (!a || a.rows.length === 0) missing.push(input.fromVersion);
10057
- if (!b || b.rows.length === 0) missing.push(input.toVersion);
10058
- if (missing.length) {
10059
- for (const v of missing) await enqueueSurface(db, input.package, v).catch(() => {
11206
+ const missing2 = [];
11207
+ if (!a || a.rows.length === 0) missing2.push(input.fromVersion);
11208
+ if (!b || b.rows.length === 0) missing2.push(input.toVersion);
11209
+ if (missing2.length) {
11210
+ for (const v of missing2) await enqueueSurface(db, input.package, v).catch(() => {
10060
11211
  });
10061
11212
  return {
10062
11213
  package: input.package,
10063
11214
  fromVersion: input.fromVersion,
10064
11215
  toVersion: input.toVersion,
10065
11216
  verdict: "unknown",
10066
- inconclusive: `no extracted surface for ${missing.join(", ")}, queued; retry shortly. NOT evidence that symbols were removed.`,
11217
+ inconclusive: `no extracted surface for ${missing2.join(", ")}, queued; retry shortly. NOT evidence that symbols were removed.`,
10067
11218
  removed: [],
10068
11219
  added: [],
10069
11220
  arityChanged: [],
@@ -10208,7 +11359,7 @@ var init_toolDescriptions = __esm({
10208
11359
  });
10209
11360
 
10210
11361
  // src/db/usage.ts
10211
- import { and as and11, desc as desc7, eq as eq15, gte, sql as sql12 } from "drizzle-orm";
11362
+ import { and as and16, desc as desc7, eq as eq19, gte as gte2, sql as sql12 } from "drizzle-orm";
10212
11363
  function windowStart(days) {
10213
11364
  return sql12`CURRENT_DATE - (${days}::int - 1)`;
10214
11365
  }
@@ -10242,9 +11393,9 @@ async function getUsageByTool(db, ownerId, days) {
10242
11393
  tool: ownerUsageDaily.tool,
10243
11394
  count: sql12`sum(${ownerUsageDaily.count})::int`
10244
11395
  }).from(ownerUsageDaily).where(
10245
- and11(
10246
- eq15(ownerUsageDaily.ownerId, ownerId),
10247
- gte(ownerUsageDaily.date, windowStart(days))
11396
+ and16(
11397
+ eq19(ownerUsageDaily.ownerId, ownerId),
11398
+ gte2(ownerUsageDaily.date, windowStart(days))
10248
11399
  )
10249
11400
  ).groupBy(ownerUsageDaily.tool).orderBy(desc7(sql12`sum(${ownerUsageDaily.count})`));
10250
11401
  return rows.map((r) => ({ tool: r.tool, count: Number(r.count) }));
@@ -10409,9 +11560,20 @@ function buildMcpServer(db, ctx = {}) {
10409
11560
  async (args) => json(await run("diff_surface", () => handleDiffSurface(db, args)))
10410
11561
  );
10411
11562
  server.registerTool(
10412
- "report_outcome",
11563
+ "capabilities",
10413
11564
  {
10414
- title: "Report a recommendation outcome",
11565
+ title: "What lurq can do",
11566
+ description: "Look up which lurq tool answers a situation, and what to run next. Call when you're unsure whether lurq covers something (an upgrade, a licence rule, a version's exact exports, publishing a package) instead of guessing or skipping it. Returns matching capabilities with the tool or command to use.",
11567
+ inputSchema: {
11568
+ query: z2.string().max(300).optional().describe("What you are trying to do, in plain words. Omit for the full menu.")
11569
+ }
11570
+ },
11571
+ async (args) => json({ capabilities: searchCapabilities(args.query ?? "", 6) })
11572
+ );
11573
+ server.registerTool(
11574
+ "report_outcome",
11575
+ {
11576
+ title: "Report a recommendation outcome",
10415
11577
  description: "Opt-in feedback after acting on a lurq recommendation: report whether you went with the package and whether it built. No source code, only the coarse decision + a build signal. Helps lurq learn which packages agents actually succeed with; safe to skip.",
10416
11578
  inputSchema: {
10417
11579
  package: npmName.describe("The package that was recommended"),
@@ -10449,6 +11611,7 @@ var init_server = __esm({
10449
11611
  init_esm_shims();
10450
11612
  init_constants();
10451
11613
  init_types();
11614
+ init_capabilities();
10452
11615
  init_client();
10453
11616
  init_logger();
10454
11617
  init_surfaceHandlers();
@@ -10472,7 +11635,7 @@ var init_server = __esm({
10472
11635
 
10473
11636
  // src/auth/apiKeys.ts
10474
11637
  import { createHash as createHash8, randomBytes } from "crypto";
10475
- import { and as and12, desc as desc8, eq as eq16, isNull as isNull4 } from "drizzle-orm";
11638
+ import { and as and17, desc as desc8, eq as eq20, isNull as isNull4 } from "drizzle-orm";
10476
11639
  function hashKey(key) {
10477
11640
  return createHash8("sha256").update(key).digest("hex");
10478
11641
  }
@@ -10495,7 +11658,7 @@ async function createKey(db, input = {}) {
10495
11658
  function stampLastUsed(db, entry, now) {
10496
11659
  if (now - entry.lastStampAt < STAMP_INTERVAL_MS) return;
10497
11660
  entry.lastStampAt = now;
10498
- db.update(apiKeys).set({ lastUsedAt: new Date(now) }).where(eq16(apiKeys.id, entry.row.id)).then(void 0, (err) => logger.debug(`lastUsedAt stamp failed: ${String(err)}`));
11661
+ db.update(apiKeys).set({ lastUsedAt: new Date(now) }).where(eq20(apiKeys.id, entry.row.id)).then(void 0, (err) => logger.debug(`lastUsedAt stamp failed: ${String(err)}`));
10499
11662
  }
10500
11663
  async function lookupActiveKey(db, key) {
10501
11664
  const hash = hashKey(key);
@@ -10505,7 +11668,7 @@ async function lookupActiveKey(db, key) {
10505
11668
  stampLastUsed(db, cached3, now);
10506
11669
  return cached3.row;
10507
11670
  }
10508
- const [row] = await db.select().from(apiKeys).where(and12(eq16(apiKeys.keyHash, hash), isNull4(apiKeys.revokedAt))).limit(1);
11671
+ const [row] = await db.select().from(apiKeys).where(and17(eq20(apiKeys.keyHash, hash), isNull4(apiKeys.revokedAt))).limit(1);
10509
11672
  if (!row) {
10510
11673
  authCache.delete(hash);
10511
11674
  return null;
@@ -10516,30 +11679,35 @@ async function lookupActiveKey(db, key) {
10516
11679
  return row;
10517
11680
  }
10518
11681
  async function listKeysForOwner(db, ownerId) {
10519
- return db.select().from(apiKeys).where(eq16(apiKeys.ownerId, ownerId)).orderBy(desc8(apiKeys.createdAt));
11682
+ return db.select().from(apiKeys).where(eq20(apiKeys.ownerId, ownerId)).orderBy(desc8(apiKeys.createdAt));
10520
11683
  }
10521
11684
  function matchByPrefixOrId(prefixOrId) {
10522
11685
  const asId = Number(prefixOrId);
10523
- return Number.isInteger(asId) && String(asId) === prefixOrId.trim() ? eq16(apiKeys.id, asId) : eq16(apiKeys.prefix, prefixOrId);
11686
+ return Number.isInteger(asId) && String(asId) === prefixOrId.trim() ? eq20(apiKeys.id, asId) : eq20(apiKeys.prefix, prefixOrId);
10524
11687
  }
10525
11688
  async function findKeyForOwner(db, args) {
10526
- const [row] = await db.select().from(apiKeys).where(and12(matchByPrefixOrId(args.prefixOrId), isNull4(apiKeys.revokedAt))).limit(1);
10527
- if (!row || row.ownerId !== args.ownerId) return null;
10528
- return row;
11689
+ const [row] = await db.select().from(apiKeys).where(
11690
+ and17(
11691
+ matchByPrefixOrId(args.prefixOrId),
11692
+ eq20(apiKeys.ownerId, args.ownerId),
11693
+ isNull4(apiKeys.revokedAt)
11694
+ )
11695
+ ).orderBy(apiKeys.id).limit(1);
11696
+ return row ?? null;
10529
11697
  }
10530
11698
  async function revokeKey(db, prefixOrId) {
10531
- const rows = await db.update(apiKeys).set({ revokedAt: /* @__PURE__ */ new Date() }).where(and12(matchByPrefixOrId(prefixOrId), isNull4(apiKeys.revokedAt))).returning({ id: apiKeys.id });
11699
+ const rows = await db.update(apiKeys).set({ revokedAt: /* @__PURE__ */ new Date() }).where(and17(matchByPrefixOrId(prefixOrId), isNull4(apiKeys.revokedAt))).returning({ id: apiKeys.id });
10532
11700
  return rows.length;
10533
11701
  }
10534
11702
  async function rotateKey(db, prefixOrId) {
10535
- const [previous] = await db.select().from(apiKeys).where(and12(matchByPrefixOrId(prefixOrId), isNull4(apiKeys.revokedAt))).limit(1);
11703
+ const [previous] = await db.select().from(apiKeys).where(and17(matchByPrefixOrId(prefixOrId), isNull4(apiKeys.revokedAt))).limit(1);
10536
11704
  if (!previous) return null;
10537
11705
  const { key, row } = await createKey(db, {
10538
11706
  label: previous.label ?? void 0,
10539
11707
  tier: previous.tier,
10540
11708
  ownerId: previous.ownerId ?? void 0
10541
11709
  });
10542
- await db.update(apiKeys).set({ revokedAt: /* @__PURE__ */ new Date() }).where(eq16(apiKeys.id, previous.id));
11710
+ await db.update(apiKeys).set({ revokedAt: /* @__PURE__ */ new Date() }).where(eq20(apiKeys.id, previous.id));
10543
11711
  return { key, row, previous };
10544
11712
  }
10545
11713
  var DISPLAY_BODY, authCache, AUTH_TTL_MS, STAMP_INTERVAL_MS;
@@ -10620,17 +11788,411 @@ var init_parse = __esm({
10620
11788
  }
10621
11789
  });
10622
11790
 
11791
+ // src/policy/conformance.ts
11792
+ import { and as and18, eq as eq21, inArray as inArray9 } from "drizzle-orm";
11793
+ async function loadRuleFacts(db, names2, ecosystem = DEFAULT_ECOSYSTEM) {
11794
+ const out = /* @__PURE__ */ new Map();
11795
+ for (let i = 0; i < names2.length; i += NAME_CHUNK2) {
11796
+ const rows = await db.select({
11797
+ name: packages.name,
11798
+ license: packages.license,
11799
+ deprecated: packages.deprecated,
11800
+ confidence: packages.confidence
11801
+ }).from(packages).where(
11802
+ and18(
11803
+ inArray9(packages.name, names2.slice(i, i + NAME_CHUNK2)),
11804
+ eq21(packages.ecosystem, ecosystem)
11805
+ )
11806
+ );
11807
+ for (const row of rows) {
11808
+ out.set(row.name, {
11809
+ license: row.license,
11810
+ deprecated: row.deprecated,
11811
+ confidence: row.confidence
11812
+ });
11813
+ }
11814
+ }
11815
+ return out;
11816
+ }
11817
+ function ruleRepo(policy, names2, facts) {
11818
+ const violations = [];
11819
+ let checked = 0;
11820
+ let unchecked = 0;
11821
+ let unscored = 0;
11822
+ for (const name of names2) {
11823
+ const fact = facts.get(name);
11824
+ if (!fact) {
11825
+ unchecked += 1;
11826
+ continue;
11827
+ }
11828
+ checked += 1;
11829
+ if (policy.minConfidence !== null && fact.confidence === null) unscored += 1;
11830
+ const exclusion = check(
11831
+ policy,
11832
+ // `check` needs a grade and an unscored package has none. The top grade
11833
+ // makes the confidence floor abstain rather than convict on a fact never
11834
+ // established — "absent facts never convict", in enforce.ts — while the
11835
+ // licence and deprecation rules still apply normally. The abstention is
11836
+ // counted above rather than hidden.
11837
+ { name, confidence: fact.confidence ?? "proven" },
11838
+ { license: fact.license, deprecated: fact.deprecated }
11839
+ );
11840
+ if (exclusion) violations.push(exclusion);
11841
+ }
11842
+ return {
11843
+ checked,
11844
+ unchecked,
11845
+ unscored,
11846
+ total: violations.length,
11847
+ violations: violations.slice(0, VIOLATION_CAP)
11848
+ };
11849
+ }
11850
+ function bySeverity(a, z3) {
11851
+ return z3.total - a.total || z3.unchecked - a.unchecked || a.fullName.localeCompare(z3.fullName);
11852
+ }
11853
+ async function repoConformance(db, ownerId) {
11854
+ const policy = await getSelectionPolicy(db, ownerId);
11855
+ if (!hasRules(policy)) return { enforcing: false, repos: [] };
11856
+ const rows = await db.select({ id: repos.id, fullName: repos.fullName, manifests: repos.manifests }).from(repos).where(eq21(repos.ownerId, ownerId));
11857
+ if (!rows.length) return { enforcing: true, repos: [] };
11858
+ const declaredPerRepo = rows.map((r) => ({
11859
+ repoId: r.id,
11860
+ fullName: r.fullName,
11861
+ names: [...declaredDeps(r.manifests ?? []).keys()]
11862
+ }));
11863
+ const union = [...new Set(declaredPerRepo.flatMap((r) => r.names))];
11864
+ const facts = await loadRuleFacts(db, union);
11865
+ const report = declaredPerRepo.map(({ repoId, fullName, names: names2 }) => ({
11866
+ repoId,
11867
+ fullName,
11868
+ ...ruleRepo(policy, names2, facts)
11869
+ }));
11870
+ return { enforcing: true, repos: report.sort(bySeverity) };
11871
+ }
11872
+ var NAME_CHUNK2, VIOLATION_CAP;
11873
+ var init_conformance = __esm({
11874
+ "src/policy/conformance.ts"() {
11875
+ "use strict";
11876
+ init_esm_shims();
11877
+ init_types();
11878
+ init_schema();
11879
+ init_selectionPolicy();
11880
+ init_drift();
11881
+ init_enforce();
11882
+ NAME_CHUNK2 = 500;
11883
+ VIOLATION_CAP = 50;
11884
+ }
11885
+ });
11886
+
11887
+ // src/core/plans.ts
11888
+ function planFor(tier) {
11889
+ return PLANS[tier ?? "free"] ?? PLANS.free;
11890
+ }
11891
+ function isServed(status) {
11892
+ return status != null && SERVED_STATUSES.has(status);
11893
+ }
11894
+ var TIERS, PLANS, PLAN_LIST, SERVED_STATUSES;
11895
+ var init_plans = __esm({
11896
+ "src/core/plans.ts"() {
11897
+ "use strict";
11898
+ init_esm_shims();
11899
+ TIERS = ["free", "pro", "enterprise"];
11900
+ PLANS = {
11901
+ free: {
11902
+ tier: "free",
11903
+ name: "Free",
11904
+ priceCents: 0,
11905
+ monthlyCalls: 200,
11906
+ ratePerMinute: 60,
11907
+ tagline: "Enough to find out whether the index is telling the truth.",
11908
+ features: [
11909
+ "CLI and installable skill",
11910
+ "200 hosted calls a month",
11911
+ "recommend, evaluate, verify",
11912
+ "Daily refreshed index",
11913
+ "Community support"
11914
+ ],
11915
+ paid: false
11916
+ },
11917
+ pro: {
11918
+ tier: "pro",
11919
+ name: "Pro",
11920
+ priceCents: 500,
11921
+ monthlyCalls: 1e4,
11922
+ ratePerMinute: 120,
11923
+ tagline: "For one developer who runs it on every install.",
11924
+ features: [
11925
+ "10,000 hosted calls a month",
11926
+ "compare, diagram and plan",
11927
+ "Repo autopilot and drift alerts",
11928
+ "Priority index freshness",
11929
+ "Email support"
11930
+ ],
11931
+ paid: true
11932
+ },
11933
+ enterprise: {
11934
+ tier: "enterprise",
11935
+ name: "Enterprise",
11936
+ priceCents: 1e4,
11937
+ monthlyCalls: null,
11938
+ ratePerMinute: 600,
11939
+ tagline: "For a team that needs the graph under its own controls.",
11940
+ features: [
11941
+ "Uncapped hosted calls",
11942
+ "SSO and audit logs",
11943
+ "Private compatibility runs",
11944
+ "Custom SLA",
11945
+ "Dedicated support channel"
11946
+ ],
11947
+ paid: true,
11948
+ contactOnly: true
11949
+ }
11950
+ };
11951
+ PLAN_LIST = TIERS.map((t) => PLANS[t]);
11952
+ SERVED_STATUSES = /* @__PURE__ */ new Set(["active", "trialing", "past_due"]);
11953
+ }
11954
+ });
11955
+
11956
+ // src/db/subscriptions.ts
11957
+ import { and as and19, eq as eq22, gte as gte3, sql as sql13 } from "drizzle-orm";
11958
+ async function getSubscription(db, ownerId) {
11959
+ const rows = await db.select().from(subscriptions).where(eq22(subscriptions.ownerId, ownerId)).limit(1);
11960
+ return rows[0] ?? null;
11961
+ }
11962
+ async function getSubscriptionByCustomer(db, stripeCustomerId) {
11963
+ const rows = await db.select().from(subscriptions).where(eq22(subscriptions.stripeCustomerId, stripeCustomerId)).limit(1);
11964
+ return rows[0] ?? null;
11965
+ }
11966
+ async function linkCustomer(db, ownerId, stripeCustomerId) {
11967
+ await db.insert(subscriptions).values({ ownerId, stripeCustomerId }).onConflictDoUpdate({
11968
+ target: subscriptions.ownerId,
11969
+ set: { stripeCustomerId, updatedAt: /* @__PURE__ */ new Date() }
11970
+ });
11971
+ }
11972
+ async function applySubscriptionEvent(db, stripeCustomerId, update) {
11973
+ const result = await db.insert(subscriptions).values({
11974
+ // A webhook can legitimately arrive for a customer we have no row for
11975
+ // (created in the Stripe dashboard, or our insert lost a race with
11976
+ // checkout). ownerId comes from customer metadata in that case; the
11977
+ // caller resolves it and passes a row that already exists, so this
11978
+ // conflict target is the customer, not the owner.
11979
+ ownerId: sql13`coalesce(
11980
+ (select s.owner_id from subscriptions s where s.stripe_customer_id = ${stripeCustomerId}),
11981
+ ${"stripe:" + stripeCustomerId}
11982
+ )`,
11983
+ stripeCustomerId,
11984
+ stripeSubscriptionId: update.stripeSubscriptionId,
11985
+ tier: update.tier,
11986
+ status: update.status,
11987
+ currentPeriodEnd: update.currentPeriodEnd,
11988
+ cancelAtPeriodEnd: update.cancelAtPeriodEnd,
11989
+ lastEventAt: update.eventAt,
11990
+ updatedAt: /* @__PURE__ */ new Date()
11991
+ }).onConflictDoUpdate({
11992
+ target: subscriptions.stripeCustomerId,
11993
+ set: {
11994
+ stripeSubscriptionId: update.stripeSubscriptionId,
11995
+ tier: update.tier,
11996
+ status: update.status,
11997
+ currentPeriodEnd: update.currentPeriodEnd,
11998
+ cancelAtPeriodEnd: update.cancelAtPeriodEnd,
11999
+ lastEventAt: update.eventAt,
12000
+ updatedAt: /* @__PURE__ */ new Date()
12001
+ },
12002
+ setWhere: sql13`${subscriptions.lastEventAt} is null or ${subscriptions.lastEventAt} <= ${update.eventAt}`
12003
+ }).returning({ ownerId: subscriptions.ownerId });
12004
+ return result.length > 0;
12005
+ }
12006
+ async function monthlyCallCount(db, ownerId) {
12007
+ const rows = await db.select({ total: sql13`coalesce(sum(${ownerUsageDaily.count}), 0)` }).from(ownerUsageDaily).where(
12008
+ and19(
12009
+ eq22(ownerUsageDaily.ownerId, ownerId),
12010
+ gte3(ownerUsageDaily.date, sql13`date_trunc('month', CURRENT_DATE)::date`)
12011
+ )
12012
+ );
12013
+ return Number(rows[0]?.total ?? 0);
12014
+ }
12015
+ async function entitlementFor(db, ownerId) {
12016
+ if (!ownerId) {
12017
+ return { plan: planFor("enterprise"), used: 0, withinQuota: true };
12018
+ }
12019
+ const [sub, used] = await Promise.all([
12020
+ getSubscription(db, ownerId),
12021
+ monthlyCallCount(db, ownerId)
12022
+ ]);
12023
+ const plan = planFor(sub && isServed(sub.status) ? sub.tier : "free");
12024
+ const withinQuota = plan.monthlyCalls === null || used < plan.monthlyCalls;
12025
+ return { plan, used, withinQuota };
12026
+ }
12027
+ var init_subscriptions = __esm({
12028
+ "src/db/subscriptions.ts"() {
12029
+ "use strict";
12030
+ init_esm_shims();
12031
+ init_plans();
12032
+ init_schema();
12033
+ }
12034
+ });
12035
+
12036
+ // src/billing/stripe.ts
12037
+ async function stripeClient() {
12038
+ const { STRIPE_SECRET_KEY } = getConfig();
12039
+ if (!STRIPE_SECRET_KEY) return null;
12040
+ if (!clientPromise2) {
12041
+ clientPromise2 = (async () => {
12042
+ const { default: StripeCtor } = await import("stripe");
12043
+ return new StripeCtor(STRIPE_SECRET_KEY, {
12044
+ apiVersion: API_VERSION,
12045
+ // Retries on Stripe's side of a network blip. The default is 0, which
12046
+ // turns one dropped connection into a failed checkout for a real person.
12047
+ maxNetworkRetries: 2,
12048
+ timeout: 15e3
12049
+ });
12050
+ })();
12051
+ }
12052
+ return clientPromise2;
12053
+ }
12054
+ function billingEnabled() {
12055
+ return Boolean(getConfig().STRIPE_SECRET_KEY);
12056
+ }
12057
+ function priceIdFor(tier) {
12058
+ const config = getConfig();
12059
+ if (tier === "pro") return config.STRIPE_PRICE_PRO ?? null;
12060
+ if (tier === "enterprise") return config.STRIPE_PRICE_ENTERPRISE ?? null;
12061
+ return null;
12062
+ }
12063
+ function tierForPrice(priceId, metadataTier) {
12064
+ const config = getConfig();
12065
+ if (priceId && priceId === config.STRIPE_PRICE_PRO) return "pro";
12066
+ if (priceId && priceId === config.STRIPE_PRICE_ENTERPRISE) return "enterprise";
12067
+ if (metadataTier && metadataTier in PLANS) return metadataTier;
12068
+ if (priceId) {
12069
+ logger.warn(`billing: price ${priceId} maps to no configured tier; treating as free`);
12070
+ }
12071
+ return "free";
12072
+ }
12073
+ async function customerFor(db, ownerId, email) {
12074
+ const stripe = await stripeClient();
12075
+ if (!stripe) return null;
12076
+ const existing = await getSubscription(db, ownerId);
12077
+ if (existing?.stripeCustomerId) return existing.stripeCustomerId;
12078
+ const customer = await stripe.customers.create({
12079
+ ...email ? { email } : {},
12080
+ metadata: { ownerId }
12081
+ });
12082
+ await linkCustomer(db, ownerId, customer.id);
12083
+ return customer.id;
12084
+ }
12085
+ async function createCheckoutSession(db, req) {
12086
+ const stripe = await stripeClient();
12087
+ if (!stripe) return null;
12088
+ const price = priceIdFor(req.tier);
12089
+ if (!price) return null;
12090
+ const customer = await customerFor(db, req.ownerId, req.email);
12091
+ if (!customer) return null;
12092
+ const base = getConfig().LURQ_WEB_URL.replace(/\/$/, "");
12093
+ const session = await stripe.checkout.sessions.create({
12094
+ mode: "subscription",
12095
+ customer,
12096
+ line_items: [{ price, quantity: 1 }],
12097
+ // Stripe substitutes the real id. The dashboard reads it to poll for the
12098
+ // webhook having landed, so the page after payment can say "active" rather
12099
+ // than "we think so".
12100
+ success_url: `${base}/dashboard/billing?checkout={CHECKOUT_SESSION_ID}`,
12101
+ cancel_url: `${base}/#pricing`,
12102
+ // Carried onto the Subscription so tierForPrice has a fallback if the Price
12103
+ // is later swapped out from under us.
12104
+ subscription_data: { metadata: { ownerId: req.ownerId, tier: req.tier } },
12105
+ metadata: { ownerId: req.ownerId, tier: req.tier },
12106
+ allow_promotion_codes: true,
12107
+ // Stripe Tax works out VAT/sales tax per the customer's address. This is the
12108
+ // half of merchant-of-record we can buy; registration and remittance are
12109
+ // still ours, which is the tradeoff taken when Stripe was chosen over a MoR.
12110
+ automatic_tax: { enabled: true },
12111
+ billing_address_collection: "auto"
12112
+ });
12113
+ return session.url;
12114
+ }
12115
+ async function createPortalSession(db, ownerId) {
12116
+ const stripe = await stripeClient();
12117
+ if (!stripe) return null;
12118
+ const sub = await getSubscription(db, ownerId);
12119
+ if (!sub?.stripeCustomerId) return null;
12120
+ const base = getConfig().LURQ_WEB_URL.replace(/\/$/, "");
12121
+ const session = await stripe.billingPortal.sessions.create({
12122
+ customer: sub.stripeCustomerId,
12123
+ return_url: `${base}/dashboard/billing`
12124
+ });
12125
+ return session.url;
12126
+ }
12127
+ async function constructEvent(raw, signature) {
12128
+ const stripe = await stripeClient();
12129
+ const secret = getConfig().STRIPE_WEBHOOK_SECRET;
12130
+ if (!stripe || !secret || !signature) return null;
12131
+ return stripe.webhooks.constructEvent(raw, signature, secret);
12132
+ }
12133
+ function periodEnd(sub) {
12134
+ const item = sub.items?.data?.[0];
12135
+ const raw = item?.current_period_end ?? sub.current_period_end;
12136
+ return typeof raw === "number" ? new Date(raw * 1e3) : null;
12137
+ }
12138
+ async function handleEvent(db, event) {
12139
+ if (!HANDLED.has(event.type)) return `ignored ${event.type}`;
12140
+ const stripe = await stripeClient();
12141
+ if (!stripe) return "billing disabled";
12142
+ let subscription = null;
12143
+ if (event.type === "checkout.session.completed") {
12144
+ const session = event.data.object;
12145
+ const id = typeof session.subscription === "string" ? session.subscription : session.subscription?.id;
12146
+ if (!id) return "checkout completed with no subscription";
12147
+ subscription = await stripe.subscriptions.retrieve(id);
12148
+ } else {
12149
+ subscription = event.data.object;
12150
+ }
12151
+ const customerId = typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id;
12152
+ if (!customerId) return "event carried no customer";
12153
+ const priceId = subscription.items?.data?.[0]?.price?.id ?? null;
12154
+ const tier = event.type === "customer.subscription.deleted" ? "free" : tierForPrice(priceId, subscription.metadata?.tier);
12155
+ const applied = await applySubscriptionEvent(db, customerId, {
12156
+ tier,
12157
+ status: event.type === "customer.subscription.deleted" ? "canceled" : subscription.status,
12158
+ stripeSubscriptionId: subscription.id,
12159
+ currentPeriodEnd: periodEnd(subscription),
12160
+ cancelAtPeriodEnd: Boolean(subscription.cancel_at_period_end),
12161
+ eventAt: new Date(event.created * 1e3)
12162
+ });
12163
+ return applied ? `${event.type}: customer ${customerId} \u2192 ${tier} (${subscription.status})` : `${event.type}: superseded by a newer event, dropped`;
12164
+ }
12165
+ var API_VERSION, clientPromise2, HANDLED;
12166
+ var init_stripe = __esm({
12167
+ "src/billing/stripe.ts"() {
12168
+ "use strict";
12169
+ init_esm_shims();
12170
+ init_config();
12171
+ init_logger();
12172
+ init_plans();
12173
+ init_subscriptions();
12174
+ API_VERSION = "2025-03-31.basil";
12175
+ clientPromise2 = null;
12176
+ HANDLED = /* @__PURE__ */ new Set([
12177
+ "checkout.session.completed",
12178
+ "customer.subscription.created",
12179
+ "customer.subscription.updated",
12180
+ "customer.subscription.deleted"
12181
+ ]);
12182
+ }
12183
+ });
12184
+
10623
12185
  // src/github/hops.ts
10624
- import semver5 from "semver";
12186
+ import semver7 from "semver";
10625
12187
  function planHops(versions, from, to) {
10626
- if (!semver5.valid(from) || !semver5.valid(to)) return [];
10627
- if (!semver5.gt(to, from)) return [];
10628
- const fromMajor = semver5.major(from);
10629
- const toMajor = semver5.major(to);
12188
+ if (!semver7.valid(from) || !semver7.valid(to)) return [];
12189
+ if (!semver7.gt(to, from)) return [];
12190
+ const fromMajor = semver7.major(from);
12191
+ const toMajor = semver7.major(to);
10630
12192
  if (toMajor - fromMajor < 2) return [];
10631
12193
  const stops = [];
10632
12194
  for (let major = fromMajor + 1; major < toMajor; major++) {
10633
- const best = semver5.maxSatisfying(versions, `${major}.x`, { includePrerelease: false });
12195
+ const best = semver7.maxSatisfying(versions, `${major}.x`, { includePrerelease: false });
10634
12196
  if (best) stops.push(best);
10635
12197
  }
10636
12198
  const path2 = [from, ...stops, to];
@@ -10644,8 +12206,8 @@ function hopPairs(path2) {
10644
12206
  return pairs;
10645
12207
  }
10646
12208
  function tooFarToSequence(from, to) {
10647
- if (!semver5.valid(from) || !semver5.valid(to)) return false;
10648
- return semver5.major(to) - semver5.major(from) > MAX_HOPS2;
12209
+ if (!semver7.valid(from) || !semver7.valid(to)) return false;
12210
+ return semver7.major(to) - semver7.major(from) > MAX_HOPS2;
10649
12211
  }
10650
12212
  var MAX_HOPS2;
10651
12213
  var init_hops = __esm({
@@ -10663,8 +12225,8 @@ function briefable(dep) {
10663
12225
  function rank(dep) {
10664
12226
  return dep.advisories * 1e3 + dep.majorsBehind * 100 + (dep.deprecated ? 50 : 0);
10665
12227
  }
10666
- function verdictOf(removed, arityChanged, inconclusive) {
10667
- if (inconclusive) return "unknown";
12228
+ function verdictOf(removed, arityChanged, inconclusive2) {
12229
+ if (inconclusive2) return "unknown";
10668
12230
  if (removed.length > 0) return "removes-exports";
10669
12231
  if (arityChanged.length > 0) return "arity-changed";
10670
12232
  return "clean";
@@ -10685,17 +12247,17 @@ async function briefUpgrade(db, dep, knownVersions = []) {
10685
12247
  toVersion: base.toVersion
10686
12248
  });
10687
12249
  const removed = diff.removed.map((s) => s.path);
10688
- const inconclusive = "inconclusive" in diff ? diff.inconclusive : void 0;
12250
+ const inconclusive2 = "inconclusive" in diff ? diff.inconclusive : void 0;
10689
12251
  const { hops, sequenceNote } = await planSequence(db, dep, base, knownVersions);
10690
12252
  return {
10691
12253
  ...base,
10692
- verdict: verdictOf(removed, diff.arityChanged, inconclusive),
12254
+ verdict: verdictOf(removed, diff.arityChanged, inconclusive2),
10693
12255
  removed,
10694
12256
  arityChanged: diff.arityChanged,
10695
12257
  typeOnlyRemoved: diff.typeOnlyRemoved,
10696
12258
  newlyDeprecated: diff.deprecated ?? [],
10697
12259
  hops,
10698
- ...inconclusive ? { inconclusive } : {},
12260
+ ...inconclusive2 ? { inconclusive: inconclusive2 } : {},
10699
12261
  ...sequenceNote ? { sequenceNote } : {}
10700
12262
  };
10701
12263
  }
@@ -10767,6 +12329,28 @@ var init_brief = __esm({
10767
12329
  }
10768
12330
  });
10769
12331
 
12332
+ // src/db/askSpend.ts
12333
+ import { and as and20, eq as eq23, sql as sql14 } from "drizzle-orm";
12334
+ async function getAskSpendToday(db, ownerId) {
12335
+ const rows = await db.select({ usdMicros: askSpendDaily.usdMicros }).from(askSpendDaily).where(and20(eq23(askSpendDaily.ownerId, ownerId), eq23(askSpendDaily.date, sql14`CURRENT_DATE`))).limit(1);
12336
+ return rows[0]?.usdMicros ?? 0;
12337
+ }
12338
+ async function addAskSpend(db, ownerId, micros) {
12339
+ if (micros === 0) return getAskSpendToday(db, ownerId);
12340
+ const rows = await db.insert(askSpendDaily).values({ ownerId, date: sql14`CURRENT_DATE`, usdMicros: Math.max(0, micros) }).onConflictDoUpdate({
12341
+ target: [askSpendDaily.ownerId, askSpendDaily.date],
12342
+ set: { usdMicros: sql14`greatest(0, ${askSpendDaily.usdMicros} + ${micros})` }
12343
+ }).returning({ usdMicros: askSpendDaily.usdMicros });
12344
+ return rows[0]?.usdMicros ?? Math.max(0, micros);
12345
+ }
12346
+ var init_askSpend = __esm({
12347
+ "src/db/askSpend.ts"() {
12348
+ "use strict";
12349
+ init_esm_shims();
12350
+ init_schema();
12351
+ }
12352
+ });
12353
+
10770
12354
  // src/github/scope.ts
10771
12355
  function isUnassessed(u) {
10772
12356
  return u.verdict === "unknown";
@@ -10905,7 +12489,7 @@ var init_runs = __esm({
10905
12489
  });
10906
12490
 
10907
12491
  // src/db/upgradeRuns.ts
10908
- import { and as and13, desc as desc9, eq as eq17, gte as gte2, sql as sql13 } from "drizzle-orm";
12492
+ import { and as and21, desc as desc9, eq as eq24, gte as gte4, sql as sql15 } from "drizzle-orm";
10909
12493
  async function recordUpgradeRuns(db, rows) {
10910
12494
  if (rows.length === 0) return 0;
10911
12495
  await db.insert(upgradeRuns).values(rows).onConflictDoUpdate({
@@ -10917,36 +12501,36 @@ async function recordUpgradeRuns(db, rows) {
10917
12501
  upgradeRuns.runUrl
10918
12502
  ],
10919
12503
  set: {
10920
- severity: sql13`excluded.severity`,
10921
- status: sql13`excluded.status`,
10922
- symbolsAffected: sql13`excluded.symbols_affected`,
10923
- callSites: sql13`excluded.call_sites`,
10924
- callSiteFiles: sql13`excluded.call_site_files`,
10925
- filesChanged: sql13`excluded.files_changed`,
10926
- testsPassed: sql13`excluded.tests_passed`,
10927
- prUrl: sql13`excluded.pr_url`,
10928
- repoId: sql13`excluded.repo_id`
12504
+ severity: sql15`excluded.severity`,
12505
+ status: sql15`excluded.status`,
12506
+ symbolsAffected: sql15`excluded.symbols_affected`,
12507
+ callSites: sql15`excluded.call_sites`,
12508
+ callSiteFiles: sql15`excluded.call_site_files`,
12509
+ filesChanged: sql15`excluded.files_changed`,
12510
+ testsPassed: sql15`excluded.tests_passed`,
12511
+ prUrl: sql15`excluded.pr_url`,
12512
+ repoId: sql15`excluded.repo_id`
10929
12513
  }
10930
12514
  });
10931
12515
  return rows.length;
10932
12516
  }
10933
12517
  async function findRepoIdByFullName(db, ownerId, fullName) {
10934
- const rows = await db.select({ id: repos.id }).from(repos).where(and13(eq17(repos.ownerId, ownerId), eq17(repos.fullName, fullName))).limit(1);
12518
+ const rows = await db.select({ id: repos.id }).from(repos).where(and21(eq24(repos.ownerId, ownerId), eq24(repos.fullName, fullName))).limit(1);
10935
12519
  return rows[0]?.id ?? null;
10936
12520
  }
10937
12521
  async function listRunsForRepo(db, ownerId, repoId, limit = 50) {
10938
- return db.select().from(upgradeRuns).where(and13(eq17(upgradeRuns.ownerId, ownerId), eq17(upgradeRuns.repoId, repoId))).orderBy(desc9(upgradeRuns.createdAt)).limit(limit);
12522
+ return db.select().from(upgradeRuns).where(and21(eq24(upgradeRuns.ownerId, ownerId), eq24(upgradeRuns.repoId, repoId))).orderBy(desc9(upgradeRuns.createdAt)).limit(limit);
10939
12523
  }
10940
12524
  async function getUpgradeImpact(db, ownerId, days = 30) {
10941
12525
  const since = new Date(Date.now() - days * 864e5);
10942
12526
  const rows = await db.select({
10943
- analysed: sql13`count(*)::int`,
10944
- blocking: sql13`count(*) filter (where ${upgradeRuns.severity} = 'blocking')::int`,
10945
- callSites: sql13`coalesce(sum(${upgradeRuns.callSites}) filter (where ${upgradeRuns.severity} = 'blocking'), 0)::int`,
10946
- prsOpened: sql13`count(*) filter (where ${upgradeRuns.status} in ('pr_open','merged'))::int`,
10947
- merged: sql13`count(*) filter (where ${upgradeRuns.status} = 'merged')::int`,
10948
- unverified: sql13`count(*) filter (where ${upgradeRuns.severity} = 'unverified')::int`
10949
- }).from(upgradeRuns).where(and13(eq17(upgradeRuns.ownerId, ownerId), gte2(upgradeRuns.createdAt, since)));
12527
+ analysed: sql15`count(*)::int`,
12528
+ blocking: sql15`count(*) filter (where ${upgradeRuns.severity} = 'blocking')::int`,
12529
+ callSites: sql15`coalesce(sum(${upgradeRuns.callSites}) filter (where ${upgradeRuns.severity} = 'blocking'), 0)::int`,
12530
+ prsOpened: sql15`count(*) filter (where ${upgradeRuns.status} in ('pr_open','merged'))::int`,
12531
+ merged: sql15`count(*) filter (where ${upgradeRuns.status} = 'merged')::int`,
12532
+ unverified: sql15`count(*) filter (where ${upgradeRuns.severity} = 'unverified')::int`
12533
+ }).from(upgradeRuns).where(and21(eq24(upgradeRuns.ownerId, ownerId), gte4(upgradeRuns.createdAt, since)));
10950
12534
  return rows[0] ?? {
10951
12535
  analysed: 0,
10952
12536
  blocking: 0,
@@ -10966,6 +12550,59 @@ var init_upgradeRuns = __esm({
10966
12550
  }
10967
12551
  });
10968
12552
 
12553
+ // src/github/webhook.ts
12554
+ import { createHmac, timingSafeEqual } from "crypto";
12555
+ function verifyWebhookSignature(secret, body, header) {
12556
+ if (!header?.startsWith("sha256=")) return false;
12557
+ const presented = Buffer.from(header.slice(7), "hex");
12558
+ const expected = createHmac("sha256", secret).update(body).digest();
12559
+ return presented.length === expected.length && timingSafeEqual(presented, expected);
12560
+ }
12561
+ function fullNames(value) {
12562
+ if (!Array.isArray(value)) return [];
12563
+ return value.map(
12564
+ (entry) => entry && typeof entry === "object" && typeof entry.full_name === "string" ? entry.full_name : null
12565
+ ).filter((name) => name !== null);
12566
+ }
12567
+ function installationId(payload) {
12568
+ const install = payload.installation;
12569
+ const id = install && typeof install === "object" ? install.id : null;
12570
+ return typeof id === "number" && Number.isInteger(id) && id > 0 ? id : null;
12571
+ }
12572
+ function parseWebhook(event, payload) {
12573
+ if (!payload || typeof payload !== "object") return { kind: "ignored", reason: "no payload" };
12574
+ const body = payload;
12575
+ const id = installationId(body);
12576
+ if (id === null) return { kind: "ignored", reason: "no installation id" };
12577
+ const action = typeof body.action === "string" ? body.action : "";
12578
+ if (event === "installation_repositories") {
12579
+ const added = fullNames(body.repositories_added);
12580
+ const removed = fullNames(body.repositories_removed);
12581
+ if (added.length === 0 && removed.length === 0) {
12582
+ return { kind: "ignored", reason: "no repository changes" };
12583
+ }
12584
+ return { kind: "repos-changed", installationId: id, added, removed };
12585
+ }
12586
+ if (event === "installation") {
12587
+ if (action === "deleted") return { kind: "uninstalled", installationId: id };
12588
+ if (action === "added" || action === "removed") {
12589
+ const added = fullNames(body.repositories_added);
12590
+ const removed = fullNames(body.repositories_removed);
12591
+ if (added.length || removed.length) {
12592
+ return { kind: "repos-changed", installationId: id, added, removed };
12593
+ }
12594
+ }
12595
+ return { kind: "ignored", reason: `installation.${action || "unknown"}` };
12596
+ }
12597
+ return { kind: "ignored", reason: `event ${event ?? "unknown"}` };
12598
+ }
12599
+ var init_webhook = __esm({
12600
+ "src/github/webhook.ts"() {
12601
+ "use strict";
12602
+ init_esm_shims();
12603
+ }
12604
+ });
12605
+
10969
12606
  // src/github/sbom.ts
10970
12607
  function parseNpmPurl(purl) {
10971
12608
  if (!purl.startsWith("pkg:npm/")) return null;
@@ -11007,10 +12644,10 @@ function parseRelationships(json2) {
11007
12644
  }
11008
12645
  return out;
11009
12646
  }
11010
- async function fetchResolvedTree(installationId, fullName) {
12647
+ async function fetchResolvedTree(installationId2, fullName) {
11011
12648
  try {
11012
12649
  const data = await installationGet(
11013
- installationId,
12650
+ installationId2,
11014
12651
  `/repos/${fullName}/dependency-graph/sbom`
11015
12652
  );
11016
12653
  const deps = parseSbom(data);
@@ -11048,7 +12685,7 @@ async function scanRepo(db, repo) {
11048
12685
  branch
11049
12686
  );
11050
12687
  const resolvedTree = await fetchResolvedTree(repo.installationId, repo.fullName);
11051
- const drift = await computeDrift(db, manifests, resolvedTree);
12688
+ const drift = await computeDrift(db, manifests, resolvedTree, repo.ownerId);
11052
12689
  await saveScan(db, repo.id, { manifests, drift, installCommand: installCommand2 });
11053
12690
  return {
11054
12691
  ...base,
@@ -11074,15 +12711,24 @@ async function scanRepo(db, repo) {
11074
12711
  };
11075
12712
  }
11076
12713
  }
11077
- async function scanEach(db, rows) {
12714
+ async function scanRepos(db, rows) {
11078
12715
  const results = [];
11079
12716
  for (const repo of rows) {
11080
12717
  results.push(await scanRepo(db, repo));
11081
12718
  }
11082
12719
  return results;
11083
12720
  }
11084
- async function scanOwnerRepos(db, ownerId) {
11085
- return scanEach(db, await listRepos(db, ownerId));
12721
+ function byRecentPush(rows, inventory) {
12722
+ const pushedAt = (repo) => {
12723
+ const parsed = Date.parse(repo.pushedAt ?? "");
12724
+ return Number.isNaN(parsed) ? 0 : parsed;
12725
+ };
12726
+ const rank2 = new Map(
12727
+ [...inventory].sort((a, b) => pushedAt(b) - pushedAt(a)).map((repo, index2) => [repo.fullName, index2])
12728
+ );
12729
+ return [...rows].sort(
12730
+ (a, b) => (rank2.get(a.fullName) ?? Number.MAX_SAFE_INTEGER) - (rank2.get(b.fullName) ?? Number.MAX_SAFE_INTEGER)
12731
+ );
11086
12732
  }
11087
12733
  var init_repoScan = __esm({
11088
12734
  "src/pipeline/repoScan.ts"() {
@@ -11100,16 +12746,27 @@ var init_repoScan = __esm({
11100
12746
  // src/mcp/http.ts
11101
12747
  var http_exports = {};
11102
12748
  __export(http_exports, {
12749
+ errorEnvelope: () => errorEnvelope,
11103
12750
  secretEquals: () => secretEquals,
11104
12751
  startHttpServer: () => startHttpServer
11105
12752
  });
11106
- import { createHash as createHash9, timingSafeEqual } from "crypto";
12753
+ import { createHash as createHash9, timingSafeEqual as timingSafeEqual2 } from "crypto";
11107
12754
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
11108
12755
  function rpcError(code, message) {
11109
12756
  return { jsonrpc: "2.0", error: { code, message }, id: null };
11110
12757
  }
12758
+ function errorEnvelope(err, path2) {
12759
+ const clientFault = typeof err.type === "string" && err.type.startsWith("entity.");
12760
+ const status = typeof err.status === "number" && err.status >= 400 && err.status < 600 ? err.status : 500;
12761
+ const message = clientFault ? err.type === "entity.too.large" ? "Request body too large (limit 1mb)." : "Request body is not valid JSON." : "Internal error.";
12762
+ const body = path2 === "/mcp" ? (
12763
+ // -32700 is JSON-RPC's own "parse error"; -32603 is "internal error".
12764
+ rpcError(clientFault ? -32700 : -32603, message)
12765
+ ) : { error: message };
12766
+ return { status, body, clientFault };
12767
+ }
11111
12768
  function secretEquals(a, b) {
11112
- return timingSafeEqual(
12769
+ return timingSafeEqual2(
11113
12770
  createHash9("sha256").update(a).digest(),
11114
12771
  createHash9("sha256").update(b).digest()
11115
12772
  );
@@ -11118,7 +12775,7 @@ async function startHttpServer(opts = {}) {
11118
12775
  const config = getConfig();
11119
12776
  const port = opts.port ?? config.PORT;
11120
12777
  const [{ default: express }, { default: helmet }, { rateLimit, ipKeyGenerator }] = await Promise.all([import("express"), import("helmet"), import("express-rate-limit")]);
11121
- const { db } = createDb({ max: 20 });
12778
+ const { db, close: closeDb } = createDb({ max: 20 });
11122
12779
  if (!process.env.REDIS_URL) {
11123
12780
  logger.warn(
11124
12781
  "REDIS_URL not set, response caching is OFF; every request recomputes on the database. Set REDIS_URL before serving real traffic (and it also backs the rate limiter across instances)."
@@ -11140,7 +12797,14 @@ async function startHttpServer(opts = {}) {
11140
12797
  const app = express();
11141
12798
  app.set("trust proxy", 1);
11142
12799
  app.use(helmet());
11143
- app.use(express.json({ limit: "1mb" }));
12800
+ app.use(
12801
+ express.json({
12802
+ limit: "1mb",
12803
+ verify: (req, _res, buf) => {
12804
+ req.rawBody = buf;
12805
+ }
12806
+ })
12807
+ );
11144
12808
  app.get("/healthz", (_req, res) => {
11145
12809
  res.status(200).json({ status: "ok" });
11146
12810
  });
@@ -11152,7 +12816,7 @@ async function startHttpServer(opts = {}) {
11152
12816
  }
11153
12817
  const header = req.headers.authorization;
11154
12818
  const presented = header?.startsWith("Bearer ") ? header.slice(7).trim() : "";
11155
- if (presented !== token) {
12819
+ if (!secretEquals(presented, token)) {
11156
12820
  res.status(401).end();
11157
12821
  return;
11158
12822
  }
@@ -11166,6 +12830,11 @@ async function startHttpServer(opts = {}) {
11166
12830
  ...makeStore ? { store: makeStore("rl:ip:") } : {},
11167
12831
  message: rpcError(-32029, "Rate limit exceeded.")
11168
12832
  });
12833
+ app.get("/capabilities", ipLimiter, (req, res) => {
12834
+ const q = typeof req.query.q === "string" ? req.query.q : "";
12835
+ const limit = Math.min(Number(req.query.limit) || 6, CAPABILITIES.length);
12836
+ res.json({ capabilities: q ? searchCapabilities(q, limit) : CAPABILITIES });
12837
+ });
11169
12838
  const auth = async (req, res, next) => {
11170
12839
  const header = req.headers.authorization;
11171
12840
  const token = header?.startsWith("Bearer ") ? header.slice(7).trim() : "";
@@ -11186,9 +12855,30 @@ async function startHttpServer(opts = {}) {
11186
12855
  res.status(500).json(rpcError(-32603, "Internal error."));
11187
12856
  }
11188
12857
  };
12858
+ const entitlementCache = /* @__PURE__ */ new Map();
12859
+ const ENTITLEMENT_TTL_MS = 6e4;
12860
+ const invalidateEntitlement = (ownerId) => entitlementCache.delete(ownerId);
12861
+ const resolveEntitlement = async (ownerId) => {
12862
+ if (!ownerId) return entitlementFor(db, null);
12863
+ const hit = entitlementCache.get(ownerId);
12864
+ if (hit && Date.now() - hit.at < ENTITLEMENT_TTL_MS) return hit.value;
12865
+ const value = await entitlementFor(db, ownerId);
12866
+ entitlementCache.set(ownerId, { at: Date.now(), value });
12867
+ return value;
12868
+ };
11189
12869
  const keyLimiter = rateLimit({
11190
12870
  windowMs: config.LURQ_RATE_LIMIT_WINDOW_MS,
11191
- limit: config.LURQ_RATE_LIMIT_MAX,
12871
+ // The burst ceiling is the plan's, not one number for everybody. Read from
12872
+ // the cached entitlement when `quota` has already resolved it on a previous
12873
+ // request for this key, and otherwise from LURQ_RATE_LIMIT_MAX — the limiter
12874
+ // runs BEFORE `quota` in the chain, so the first request of a minute is
12875
+ // metered at the configured default and the rest at the plan's rate. Paying
12876
+ // for Enterprise should not leave you sharing the free tier's burst.
12877
+ limit: (req) => {
12878
+ const ownerId = req.lurqKey?.ownerId;
12879
+ const cached3 = ownerId ? entitlementCache.get(ownerId) : void 0;
12880
+ return cached3?.value.plan.ratePerMinute ?? config.LURQ_RATE_LIMIT_MAX;
12881
+ },
11192
12882
  standardHeaders: "draft-7",
11193
12883
  legacyHeaders: false,
11194
12884
  // Key on the resolved API key's unique row id (always present — auth runs
@@ -11203,6 +12893,30 @@ async function startHttpServer(opts = {}) {
11203
12893
  ...makeStore ? { store: makeStore("rl:key:") } : {},
11204
12894
  message: rpcError(-32029, "Rate limit exceeded.")
11205
12895
  });
12896
+ const quota = async (req, res, next) => {
12897
+ const authed = req;
12898
+ const ownerId = authed.lurqKey?.ownerId ?? null;
12899
+ try {
12900
+ const ent = await resolveEntitlement(ownerId);
12901
+ authed.entitlement = ent;
12902
+ if (!ent.withinQuota) {
12903
+ res.status(402).json(
12904
+ rpcError(
12905
+ -32002,
12906
+ `Monthly limit reached for the ${ent.plan.name} plan (${ent.used}/${ent.plan.monthlyCalls} calls). It resets when the month turns. Upgrade at ${config.LURQ_WEB_URL}/#pricing`
12907
+ )
12908
+ );
12909
+ return;
12910
+ }
12911
+ next();
12912
+ } catch (err) {
12913
+ logger.error(
12914
+ "quota lookup failed, serving anyway:",
12915
+ err instanceof Error ? err.message : String(err)
12916
+ );
12917
+ next();
12918
+ }
12919
+ };
11206
12920
  const requireIssuerSecret = (req, res, next) => {
11207
12921
  const secret = config.LURQ_ISSUER_SECRET;
11208
12922
  if (!secret) {
@@ -11226,6 +12940,154 @@ async function startHttpServer(opts = {}) {
11226
12940
  lastUsedAt: row.lastUsedAt,
11227
12941
  revokedAt: row.revokedAt
11228
12942
  });
12943
+ app.post("/billing/checkout", requireIssuerSecret, async (req, res) => {
12944
+ if (!billingEnabled()) {
12945
+ res.status(404).end();
12946
+ return;
12947
+ }
12948
+ const ownerId = typeof req.body?.ownerId === "string" ? req.body.ownerId.trim() : "";
12949
+ const tier = typeof req.body?.tier === "string" ? req.body.tier : "pro";
12950
+ const email = typeof req.body?.email === "string" ? req.body.email : null;
12951
+ if (!ownerId) {
12952
+ res.status(400).json({ error: "ownerId is required." });
12953
+ return;
12954
+ }
12955
+ if (!(tier in PLANS) || !PLANS[tier].paid) {
12956
+ res.status(400).json({ error: `${tier} is not a purchasable plan.` });
12957
+ return;
12958
+ }
12959
+ try {
12960
+ const url = await createCheckoutSession(db, { ownerId, tier, email });
12961
+ if (!url) {
12962
+ res.status(503).json({ error: "That plan is not available for checkout yet." });
12963
+ return;
12964
+ }
12965
+ res.status(200).json({ url });
12966
+ } catch (err) {
12967
+ logger.error("checkout failed:", err instanceof Error ? err.message : String(err));
12968
+ res.status(502).json({ error: "Could not start checkout." });
12969
+ }
12970
+ });
12971
+ app.post("/billing/portal", requireIssuerSecret, async (req, res) => {
12972
+ if (!billingEnabled()) {
12973
+ res.status(404).end();
12974
+ return;
12975
+ }
12976
+ const ownerId = typeof req.body?.ownerId === "string" ? req.body.ownerId.trim() : "";
12977
+ if (!ownerId) {
12978
+ res.status(400).json({ error: "ownerId is required." });
12979
+ return;
12980
+ }
12981
+ try {
12982
+ const url = await createPortalSession(db, ownerId);
12983
+ if (!url) {
12984
+ res.status(404).json({ error: "No billing account for this user yet." });
12985
+ return;
12986
+ }
12987
+ res.status(200).json({ url });
12988
+ } catch (err) {
12989
+ logger.error("portal failed:", err instanceof Error ? err.message : String(err));
12990
+ res.status(502).json({ error: "Could not open the billing portal." });
12991
+ }
12992
+ });
12993
+ const ASK_DAILY_LIMIT_MICROS = Math.max(
12994
+ 0,
12995
+ Math.round(Number(process.env.LURQ_ASK_DAILY_USD ?? "3") * 1e6)
12996
+ );
12997
+ app.get("/ask-budget", requireIssuerSecret, async (req, res) => {
12998
+ const ownerId = typeof req.query.ownerId === "string" ? req.query.ownerId.trim() : "";
12999
+ if (!ownerId) {
13000
+ res.status(400).json({ error: "ownerId is required." });
13001
+ return;
13002
+ }
13003
+ try {
13004
+ const spentMicros = await getAskSpendToday(db, ownerId);
13005
+ res.status(200).json({ spentMicros, limitMicros: ASK_DAILY_LIMIT_MICROS });
13006
+ } catch (err) {
13007
+ logger.error("ask budget read failed:", err instanceof Error ? err.message : String(err));
13008
+ res.status(500).json({ error: "Could not read the Ask budget." });
13009
+ }
13010
+ });
13011
+ app.post("/ask-budget", requireIssuerSecret, async (req, res) => {
13012
+ const body = req.body ?? {};
13013
+ const ownerId = typeof body.ownerId === "string" ? body.ownerId.trim() : "";
13014
+ const micros = Number(body.usdMicros);
13015
+ if (!ownerId) {
13016
+ res.status(400).json({ error: "ownerId is required." });
13017
+ return;
13018
+ }
13019
+ if (!Number.isFinite(micros) || Math.abs(micros) > ASK_DAILY_LIMIT_MICROS) {
13020
+ res.status(400).json({ error: "usdMicros must be a finite delta within one day's limit." });
13021
+ return;
13022
+ }
13023
+ try {
13024
+ const spentMicros = await addAskSpend(db, ownerId, Math.round(micros));
13025
+ res.status(200).json({ spentMicros, limitMicros: ASK_DAILY_LIMIT_MICROS });
13026
+ } catch (err) {
13027
+ logger.error("ask budget write failed:", err instanceof Error ? err.message : String(err));
13028
+ res.status(500).json({ error: "Could not record Ask spend." });
13029
+ }
13030
+ });
13031
+ app.get("/billing/subscription", requireIssuerSecret, async (req, res) => {
13032
+ const ownerId = typeof req.query.ownerId === "string" ? req.query.ownerId.trim() : "";
13033
+ if (!ownerId) {
13034
+ res.status(400).json({ error: "ownerId is required." });
13035
+ return;
13036
+ }
13037
+ try {
13038
+ const [sub, ent] = await Promise.all([
13039
+ getSubscription(db, ownerId),
13040
+ entitlementFor(db, ownerId)
13041
+ ]);
13042
+ res.status(200).json({
13043
+ tier: ent.plan.tier,
13044
+ planName: ent.plan.name,
13045
+ status: sub?.status ?? null,
13046
+ currentPeriodEnd: sub?.currentPeriodEnd?.toISOString() ?? null,
13047
+ cancelAtPeriodEnd: sub?.cancelAtPeriodEnd ?? false,
13048
+ used: ent.used,
13049
+ limit: ent.plan.monthlyCalls,
13050
+ billingEnabled: billingEnabled(),
13051
+ manageable: Boolean(sub?.stripeCustomerId)
13052
+ });
13053
+ } catch (err) {
13054
+ logger.error("subscription read failed:", err instanceof Error ? err.message : String(err));
13055
+ res.status(500).json({ error: "Could not read the subscription." });
13056
+ }
13057
+ });
13058
+ app.post("/billing/webhook", async (req, res) => {
13059
+ const raw = req.rawBody;
13060
+ const signature = req.headers["stripe-signature"];
13061
+ let event;
13062
+ try {
13063
+ event = await constructEvent(
13064
+ raw ?? "",
13065
+ typeof signature === "string" ? signature : void 0
13066
+ );
13067
+ } catch (err) {
13068
+ logger.warn(
13069
+ `billing webhook: bad signature: ${err instanceof Error ? err.message : String(err)}`
13070
+ );
13071
+ res.status(400).json({ error: "Invalid signature." });
13072
+ return;
13073
+ }
13074
+ if (!event) {
13075
+ res.status(404).end();
13076
+ return;
13077
+ }
13078
+ res.status(200).json({ received: true });
13079
+ try {
13080
+ const outcome = await handleEvent(db, event);
13081
+ logger.info(`billing webhook: ${outcome}`);
13082
+ const object = event.data.object;
13083
+ if (typeof object.customer === "string") {
13084
+ const row = await getSubscriptionByCustomer(db, object.customer);
13085
+ if (row) invalidateEntitlement(row.ownerId);
13086
+ }
13087
+ } catch (err) {
13088
+ logger.error("billing webhook failed:", err instanceof Error ? err.message : String(err));
13089
+ }
13090
+ });
11229
13091
  app.post("/keys", requireIssuerSecret, async (req, res) => {
11230
13092
  const body = req.body ?? {};
11231
13093
  const ownerId = typeof body.ownerId === "string" ? body.ownerId.trim() : "";
@@ -11371,7 +13233,10 @@ async function startHttpServer(opts = {}) {
11371
13233
  const offsetRaw = typeof req.query.offset === "string" ? Number(req.query.offset) : NaN;
11372
13234
  const offset = Number.isInteger(offsetRaw) && offsetRaw > 0 ? offsetRaw : 0;
11373
13235
  try {
11374
- const { total, packages: rows } = await getContributionsByOwner(db, ownerId, { limit, offset });
13236
+ const { total, packages: rows } = await getContributionsByOwner(db, ownerId, {
13237
+ limit,
13238
+ offset
13239
+ });
11375
13240
  res.status(200).json({ total, packages: rows });
11376
13241
  } catch (err) {
11377
13242
  logger.error("contributions read failed:", err instanceof Error ? err.message : String(err));
@@ -11427,34 +13292,85 @@ async function startHttpServer(opts = {}) {
11427
13292
  if (raw.scope !== "security" && raw.scope !== "blocking" && raw.scope !== "all") return null;
11428
13293
  return { enabled: raw.enabled, scope: raw.scope, autoMerge: raw.autoMerge };
11429
13294
  }
11430
- app.post("/repos/connect", requireIssuerSecret, requireGithubApp, async (req, res) => {
11431
- const ownerId = ownerFrom(req);
11432
- const installationRaw = (req.body ?? {}).installationId;
11433
- const installationId = Number(installationRaw);
11434
- if (!ownerId || !Number.isInteger(installationId) || installationId <= 0) {
11435
- res.status(400).json({ error: "ownerId and installationId are required." });
13295
+ const syncInstallation = async (ownerId, installationId2, scanOnly) => {
13296
+ const found = await listInstallationRepos(installationId2);
13297
+ const connected = await upsertRepos(
13298
+ db,
13299
+ found.map((repo) => ({
13300
+ ownerId,
13301
+ installationId: installationId2,
13302
+ fullName: repo.fullName,
13303
+ defaultBranch: repo.defaultBranch,
13304
+ isPrivate: repo.isPrivate
13305
+ }))
13306
+ );
13307
+ void (async () => {
13308
+ const rows = byRecentPush(await listRepos(db, ownerId), found);
13309
+ const only = scanOnly ? new Set(scanOnly) : null;
13310
+ await scanRepos(db, only ? rows.filter((row) => only.has(row.fullName)) : rows);
13311
+ })().catch((err) => {
13312
+ logger.warn(`repo scan failed: ${err instanceof Error ? err.message : String(err)}`);
13313
+ });
13314
+ return connected;
13315
+ };
13316
+ app.post(
13317
+ "/repos/connect",
13318
+ requireIssuerSecret,
13319
+ requireGithubApp,
13320
+ async (req, res) => {
13321
+ const ownerId = ownerFrom(req);
13322
+ const installationRaw = (req.body ?? {}).installationId;
13323
+ const installationId2 = Number(installationRaw);
13324
+ if (!ownerId || !Number.isInteger(installationId2) || installationId2 <= 0) {
13325
+ res.status(400).json({ error: "ownerId and installationId are required." });
13326
+ return;
13327
+ }
13328
+ try {
13329
+ res.status(200).json({ connected: await syncInstallation(ownerId, installationId2) });
13330
+ } catch (err) {
13331
+ const status = err instanceof GithubAppError ? err.status : 502;
13332
+ logger.error("repo connect failed:", err instanceof Error ? err.message : String(err));
13333
+ res.status(status).json({ error: "Could not read the GitHub installation." });
13334
+ }
13335
+ }
13336
+ );
13337
+ app.post("/github/webhook", requireGithubApp, async (req, res) => {
13338
+ const secret = config.LURQ_GITHUB_WEBHOOK_SECRET;
13339
+ if (!secret) {
13340
+ res.status(404).end();
11436
13341
  return;
11437
13342
  }
13343
+ const raw = req.rawBody;
13344
+ const signature = req.headers["x-hub-signature-256"];
13345
+ if (!raw || !verifyWebhookSignature(secret, raw, typeof signature === "string" ? signature : void 0)) {
13346
+ res.status(401).json({ error: "Invalid signature." });
13347
+ return;
13348
+ }
13349
+ const event = req.headers["x-github-event"];
13350
+ const action = parseWebhook(typeof event === "string" ? event : void 0, req.body);
13351
+ res.status(202).end();
13352
+ if (action.kind === "ignored") return;
11438
13353
  try {
11439
- const found = await listInstallationRepos(installationId);
11440
- const connected = await upsertRepos(
11441
- db,
11442
- found.map((repo) => ({
11443
- ownerId,
11444
- installationId,
11445
- fullName: repo.fullName,
11446
- defaultBranch: repo.defaultBranch,
11447
- isPrivate: repo.isPrivate
11448
- }))
11449
- );
11450
- void scanOwnerRepos(db, ownerId).catch((err) => {
11451
- logger.warn(`initial repo scan failed: ${err instanceof Error ? err.message : String(err)}`);
11452
- });
11453
- res.status(200).json({ connected });
13354
+ if (action.kind === "uninstalled") {
13355
+ const forgotten = await deleteReposByInstallation(db, action.installationId);
13356
+ logger.info(`installation ${action.installationId} uninstalled, forgot ${forgotten} repos`);
13357
+ return;
13358
+ }
13359
+ if (action.removed.length > 0) {
13360
+ await deleteReposByInstallation(db, action.installationId, action.removed);
13361
+ }
13362
+ if (action.added.length === 0) return;
13363
+ const ownerId = await ownerForInstallation(db, action.installationId);
13364
+ if (!ownerId) {
13365
+ logger.warn(`webhook for unlinked installation ${action.installationId}, ignored`);
13366
+ return;
13367
+ }
13368
+ await syncInstallation(ownerId, action.installationId, action.added);
11454
13369
  } catch (err) {
11455
- const status = err instanceof GithubAppError ? err.status : 502;
11456
- logger.error("repo connect failed:", err instanceof Error ? err.message : String(err));
11457
- res.status(status).json({ error: "Could not read the GitHub installation." });
13370
+ logger.error(
13371
+ `webhook handling failed for installation ${action.installationId}:`,
13372
+ err instanceof Error ? err.message : String(err)
13373
+ );
11458
13374
  }
11459
13375
  });
11460
13376
  app.get("/repos", requireIssuerSecret, requireGithubApp, async (req, res) => {
@@ -11471,129 +13387,162 @@ async function startHttpServer(opts = {}) {
11471
13387
  res.status(500).json({ error: "Could not list repos." });
11472
13388
  }
11473
13389
  });
11474
- app.get("/repos/alerts", requireIssuerSecret, requireGithubApp, async (req, res) => {
11475
- const ownerId = ownerFrom(req);
11476
- if (!ownerId) {
11477
- res.status(400).json({ error: "ownerId is required." });
11478
- return;
11479
- }
11480
- try {
11481
- res.status(200).json({ alerts: await listAlerts(db, ownerId) });
11482
- } catch (err) {
11483
- logger.error("alert list failed:", err instanceof Error ? err.message : String(err));
11484
- res.status(500).json({ error: "Could not list alerts." });
11485
- }
11486
- });
11487
- app.get("/repos/:id", requireIssuerSecret, requireGithubApp, async (req, res) => {
11488
- const ownerId = ownerFrom(req);
11489
- const id = Number(req.params.id);
11490
- if (!ownerId || !Number.isInteger(id)) {
11491
- res.status(400).json({ error: "ownerId and a numeric id are required." });
11492
- return;
13390
+ app.get(
13391
+ "/repos/alerts",
13392
+ requireIssuerSecret,
13393
+ requireGithubApp,
13394
+ async (req, res) => {
13395
+ const ownerId = ownerFrom(req);
13396
+ if (!ownerId) {
13397
+ res.status(400).json({ error: "ownerId is required." });
13398
+ return;
13399
+ }
13400
+ try {
13401
+ res.status(200).json({ alerts: await listAlerts(db, ownerId) });
13402
+ } catch (err) {
13403
+ logger.error("alert list failed:", err instanceof Error ? err.message : String(err));
13404
+ res.status(500).json({ error: "Could not list alerts." });
13405
+ }
11493
13406
  }
11494
- try {
11495
- const row = await getRepo(db, ownerId, id);
11496
- if (!row) {
11497
- res.status(404).json({ error: "Repo not found." });
13407
+ );
13408
+ app.get(
13409
+ "/repos/:id",
13410
+ requireIssuerSecret,
13411
+ requireGithubApp,
13412
+ async (req, res) => {
13413
+ const ownerId = ownerFrom(req);
13414
+ const id = Number(req.params.id);
13415
+ if (!ownerId || !Number.isInteger(id)) {
13416
+ res.status(400).json({ error: "ownerId and a numeric id are required." });
11498
13417
  return;
11499
13418
  }
11500
- const runs = await listRunsForRepo(db, ownerId, row.id);
11501
- const workflow = renderWorkflow({
11502
- installCommand: row.installCommand ?? void 0,
11503
- armed: row.policy.enabled,
11504
- autoMerge: row.policy.autoMerge
11505
- });
11506
- res.status(200).json({
11507
- repo: {
11508
- ...toDashboardRepo(row),
11509
- deps: row.drift?.deps ?? [],
11510
- transitiveRisks: row.drift?.transitive?.risks ?? [],
11511
- conflicts: row.drift?.conflictsAtLatest ?? null,
11512
- runs,
11513
- workflow,
11514
- workflowPath: WORKFLOW_PATH,
11515
- setupUrl: newFileUrl(row.fullName, row.defaultBranch ?? "main", workflow)
13419
+ try {
13420
+ const row = await getRepo(db, ownerId, id);
13421
+ if (!row) {
13422
+ res.status(404).json({ error: "Repo not found." });
13423
+ return;
11516
13424
  }
11517
- });
11518
- } catch (err) {
11519
- logger.error("repo read failed:", err instanceof Error ? err.message : String(err));
11520
- res.status(500).json({ error: "Could not read repo." });
11521
- }
11522
- });
11523
- app.get("/repos/:id/brief", requireIssuerSecret, requireGithubApp, async (req, res) => {
11524
- const ownerId = ownerFrom(req);
11525
- const id = Number(req.params.id);
11526
- if (!ownerId || !Number.isInteger(id)) {
11527
- res.status(400).json({ error: "ownerId and a numeric id are required." });
11528
- return;
13425
+ const runs = await listRunsForRepo(db, ownerId, row.id);
13426
+ const workflow = renderWorkflow({
13427
+ installCommand: row.installCommand ?? void 0,
13428
+ armed: row.policy.enabled,
13429
+ autoMerge: row.policy.autoMerge
13430
+ });
13431
+ res.status(200).json({
13432
+ repo: {
13433
+ ...toDashboardRepo(row),
13434
+ deps: row.drift?.deps ?? [],
13435
+ transitiveRisks: row.drift?.transitive?.risks ?? [],
13436
+ conflicts: row.drift?.conflictsAtLatest ?? null,
13437
+ runs,
13438
+ workflow,
13439
+ workflowPath: WORKFLOW_PATH,
13440
+ setupUrl: newFileUrl(row.fullName, row.defaultBranch ?? "main", workflow)
13441
+ }
13442
+ });
13443
+ } catch (err) {
13444
+ logger.error("repo read failed:", err instanceof Error ? err.message : String(err));
13445
+ res.status(500).json({ error: "Could not read repo." });
13446
+ }
11529
13447
  }
11530
- try {
11531
- const row = await getRepo(db, ownerId, id);
11532
- if (!row) {
11533
- res.status(404).json({ error: "Repo not found." });
13448
+ );
13449
+ app.get(
13450
+ "/repos/:id/brief",
13451
+ requireIssuerSecret,
13452
+ requireGithubApp,
13453
+ async (req, res) => {
13454
+ const ownerId = ownerFrom(req);
13455
+ const id = Number(req.params.id);
13456
+ if (!ownerId || !Number.isInteger(id)) {
13457
+ res.status(400).json({ error: "ownerId and a numeric id are required." });
11534
13458
  return;
11535
13459
  }
11536
- res.status(200).json(await briefRepo(db, row.drift ?? null));
11537
- } catch (err) {
11538
- logger.error("repo brief failed:", err instanceof Error ? err.message : String(err));
11539
- res.status(500).json({ error: "Could not build the migration brief." });
11540
- }
11541
- });
11542
- app.post("/repos/:id/scan", requireIssuerSecret, requireGithubApp, async (req, res) => {
11543
- const ownerId = ownerFrom(req);
11544
- const id = Number(req.params.id);
11545
- if (!ownerId || !Number.isInteger(id)) {
11546
- res.status(400).json({ error: "ownerId and a numeric id are required." });
11547
- return;
13460
+ try {
13461
+ const row = await getRepo(db, ownerId, id);
13462
+ if (!row) {
13463
+ res.status(404).json({ error: "Repo not found." });
13464
+ return;
13465
+ }
13466
+ res.status(200).json(await briefRepo(db, row.drift ?? null));
13467
+ } catch (err) {
13468
+ logger.error("repo brief failed:", err instanceof Error ? err.message : String(err));
13469
+ res.status(500).json({ error: "Could not build the migration brief." });
13470
+ }
11548
13471
  }
11549
- try {
11550
- const row = await getRepo(db, ownerId, id);
11551
- if (!row) {
11552
- res.status(404).json({ error: "Repo not found." });
13472
+ );
13473
+ app.post(
13474
+ "/repos/:id/scan",
13475
+ requireIssuerSecret,
13476
+ requireGithubApp,
13477
+ async (req, res) => {
13478
+ const ownerId = ownerFrom(req);
13479
+ const id = Number(req.params.id);
13480
+ if (!ownerId || !Number.isInteger(id)) {
13481
+ res.status(400).json({ error: "ownerId and a numeric id are required." });
11553
13482
  return;
11554
13483
  }
11555
- const result = await scanRepo(db, row);
11556
- res.status(200).json({ result });
11557
- } catch (err) {
11558
- logger.error("repo scan failed:", err instanceof Error ? err.message : String(err));
11559
- res.status(500).json({ error: "Could not scan repo." });
11560
- }
11561
- });
11562
- app.patch("/repos/:id", requireIssuerSecret, requireGithubApp, async (req, res) => {
11563
- const ownerId = ownerFrom(req);
11564
- const id = Number(req.params.id);
11565
- const policy = parsePolicy((req.body ?? {}).policy);
11566
- if (!ownerId || !Number.isInteger(id) || !policy) {
11567
- res.status(400).json({ error: "ownerId, a numeric id, and a complete policy are required." });
11568
- return;
13484
+ try {
13485
+ const row = await getRepo(db, ownerId, id);
13486
+ if (!row) {
13487
+ res.status(404).json({ error: "Repo not found." });
13488
+ return;
13489
+ }
13490
+ const result = await scanRepo(db, row);
13491
+ res.status(200).json({ result });
13492
+ } catch (err) {
13493
+ logger.error("repo scan failed:", err instanceof Error ? err.message : String(err));
13494
+ res.status(500).json({ error: "Could not scan repo." });
13495
+ }
11569
13496
  }
11570
- try {
11571
- const updated = await setRepoPolicy(db, ownerId, id, policy);
11572
- if (!updated) {
11573
- res.status(404).json({ error: "Repo not found." });
13497
+ );
13498
+ app.patch(
13499
+ "/repos/:id",
13500
+ requireIssuerSecret,
13501
+ requireGithubApp,
13502
+ async (req, res) => {
13503
+ const ownerId = ownerFrom(req);
13504
+ const id = Number(req.params.id);
13505
+ const policy = parsePolicy((req.body ?? {}).policy);
13506
+ if (!ownerId || !Number.isInteger(id) || !policy) {
13507
+ res.status(400).json({ error: "ownerId, a numeric id, and a complete policy are required." });
11574
13508
  return;
11575
13509
  }
11576
- res.status(200).json({ policy });
11577
- } catch (err) {
11578
- logger.error("repo policy update failed:", err instanceof Error ? err.message : String(err));
11579
- res.status(500).json({ error: "Could not update policy." });
11580
- }
11581
- });
11582
- app.delete("/repos/:id", requireIssuerSecret, requireGithubApp, async (req, res) => {
11583
- const ownerId = ownerFrom(req);
11584
- const id = Number(req.params.id);
11585
- if (!ownerId || !Number.isInteger(id)) {
11586
- res.status(400).json({ error: "ownerId and a numeric id are required." });
11587
- return;
13510
+ try {
13511
+ const updated = await setRepoPolicy(db, ownerId, id, policy);
13512
+ if (!updated) {
13513
+ res.status(404).json({ error: "Repo not found." });
13514
+ return;
13515
+ }
13516
+ res.status(200).json({ policy });
13517
+ } catch (err) {
13518
+ logger.error(
13519
+ "repo policy update failed:",
13520
+ err instanceof Error ? err.message : String(err)
13521
+ );
13522
+ res.status(500).json({ error: "Could not update policy." });
13523
+ }
11588
13524
  }
11589
- try {
11590
- const removed = await deleteRepo(db, ownerId, id);
11591
- res.status(removed ? 200 : 404).json(removed ? { removed: true } : { error: "Repo not found." });
11592
- } catch (err) {
11593
- logger.error("repo delete failed:", err instanceof Error ? err.message : String(err));
11594
- res.status(500).json({ error: "Could not disconnect repo." });
13525
+ );
13526
+ app.delete(
13527
+ "/repos/:id",
13528
+ requireIssuerSecret,
13529
+ requireGithubApp,
13530
+ async (req, res) => {
13531
+ const ownerId = ownerFrom(req);
13532
+ const id = Number(req.params.id);
13533
+ if (!ownerId || !Number.isInteger(id)) {
13534
+ res.status(400).json({ error: "ownerId and a numeric id are required." });
13535
+ return;
13536
+ }
13537
+ try {
13538
+ const removed = await deleteRepo(db, ownerId, id);
13539
+ res.status(removed ? 200 : 404).json(removed ? { removed: true } : { error: "Repo not found." });
13540
+ } catch (err) {
13541
+ logger.error("repo delete failed:", err instanceof Error ? err.message : String(err));
13542
+ res.status(500).json({ error: "Could not disconnect repo." });
13543
+ }
11595
13544
  }
11596
- });
13545
+ );
11597
13546
  app.get("/selection-policy", requireIssuerSecret, async (req, res) => {
11598
13547
  const ownerId = ownerFrom(req);
11599
13548
  if (!ownerId) {
@@ -11603,7 +13552,10 @@ async function startHttpServer(opts = {}) {
11603
13552
  try {
11604
13553
  res.status(200).json({ policy: await getSelectionPolicy(db, ownerId) });
11605
13554
  } catch (err) {
11606
- logger.error("selection policy read failed:", err instanceof Error ? err.message : String(err));
13555
+ logger.error(
13556
+ "selection policy read failed:",
13557
+ err instanceof Error ? err.message : String(err)
13558
+ );
11607
13559
  res.status(500).json({ error: "Could not read policy." });
11608
13560
  }
11609
13561
  });
@@ -11618,75 +13570,117 @@ async function startHttpServer(opts = {}) {
11618
13570
  await setSelectionPolicy(db, ownerId, policy);
11619
13571
  res.status(200).json({ policy });
11620
13572
  } catch (err) {
11621
- logger.error("selection policy write failed:", err instanceof Error ? err.message : String(err));
13573
+ logger.error(
13574
+ "selection policy write failed:",
13575
+ err instanceof Error ? err.message : String(err)
13576
+ );
11622
13577
  res.status(500).json({ error: "Could not save policy." });
11623
13578
  }
11624
13579
  });
11625
- app.post("/upgrade-plan", ipLimiter, auth, keyLimiter, async (req, res) => {
11626
- const deps = parseDepsInput((req.body ?? {}).deps);
11627
- if (Object.keys(deps).length === 0) {
11628
- res.status(400).json({ error: 'deps is required: { "package": "range", \u2026 }' });
11629
- return;
13580
+ app.get(
13581
+ "/selection-policy/conformance",
13582
+ requireIssuerSecret,
13583
+ async (req, res) => {
13584
+ const ownerId = ownerFrom(req);
13585
+ if (!ownerId) {
13586
+ res.status(400).json({ error: "ownerId is required." });
13587
+ return;
13588
+ }
13589
+ try {
13590
+ res.status(200).json(await repoConformance(db, ownerId));
13591
+ } catch (err) {
13592
+ logger.error("conformance read failed:", err instanceof Error ? err.message : String(err));
13593
+ res.status(500).json({ error: "Could not read conformance." });
13594
+ }
11630
13595
  }
11631
- try {
11632
- const drift = await computeDrift(db, [{ path: "package.json", deps }]);
11633
- const brief = await briefRepo(db, drift);
13596
+ );
13597
+ app.post(
13598
+ "/upgrade-plan",
13599
+ ipLimiter,
13600
+ auth,
13601
+ keyLimiter,
13602
+ quota,
13603
+ async (req, res) => {
13604
+ const deps = parseDepsInput((req.body ?? {}).deps);
13605
+ if (Object.keys(deps).length === 0) {
13606
+ res.status(400).json({ error: 'deps is required: { "package": "range", \u2026 }' });
13607
+ return;
13608
+ }
11634
13609
  const ownerId = req.lurqKey?.ownerId ?? null;
11635
- const repoFullName = parseRepoFullName((req.body ?? {}).repo);
11636
- const policy = ownerId && repoFullName ? await findPolicyByFullName(db, ownerId, repoFullName) : null;
11637
- const scoped = applyScope(brief.upgrades, policy);
11638
- res.status(200).json({
11639
- ...brief,
11640
- ...scoped,
11641
- // Surfaced so CI can log what lurq had no opinion on. An upgrade we do
11642
- // not know about must not look like an upgrade we cleared.
11643
- untracked: Object.keys(deps).length - drift.depsTracked
11644
- });
11645
- } catch (err) {
11646
- logger.error("upgrade plan failed:", err instanceof Error ? err.message : String(err));
11647
- res.status(500).json({ error: "Could not build the upgrade plan." });
11648
- }
11649
- });
11650
- app.post("/upgrade-runs", ipLimiter, auth, keyLimiter, async (req, res) => {
11651
- const ownerId = req.lurqKey?.ownerId ?? null;
11652
- if (!ownerId) {
11653
- res.status(403).json({ error: "This key has no account attached." });
11654
- return;
11655
- }
11656
- const { runs, rejected } = parseUpgradeRuns((req.body ?? {}).runs, MAX_RUNS_PER_POST);
11657
- if (runs.length === 0) {
11658
- res.status(400).json({ error: "runs must be a non-empty array of upgrade results." });
11659
- return;
13610
+ try {
13611
+ const drift = await computeDrift(db, [{ path: "package.json", deps }], null, ownerId);
13612
+ const brief = await briefRepo(db, drift);
13613
+ const repoFullName = parseRepoFullName((req.body ?? {}).repo);
13614
+ const policy = ownerId && repoFullName ? await findPolicyByFullName(db, ownerId, repoFullName) : null;
13615
+ const scoped = applyScope(brief.upgrades, policy);
13616
+ res.status(200).json({
13617
+ ...brief,
13618
+ ...scoped,
13619
+ // Surfaced so CI can log what lurq had no opinion on. An upgrade we do
13620
+ // not know about must not look like an upgrade we cleared.
13621
+ untracked: Object.keys(deps).length - drift.depsTracked
13622
+ });
13623
+ void recordUsage(db, ownerId, "upgrade-plan");
13624
+ } catch (err) {
13625
+ logger.error("upgrade plan failed:", err instanceof Error ? err.message : String(err));
13626
+ res.status(500).json({ error: "Could not build the upgrade plan." });
13627
+ }
11660
13628
  }
11661
- try {
11662
- const repoIds = /* @__PURE__ */ new Map();
11663
- for (const run of runs) {
11664
- if (!repoIds.has(run.repoFullName)) {
11665
- repoIds.set(run.repoFullName, await findRepoIdByFullName(db, ownerId, run.repoFullName));
13629
+ );
13630
+ app.post(
13631
+ "/upgrade-runs",
13632
+ ipLimiter,
13633
+ auth,
13634
+ keyLimiter,
13635
+ quota,
13636
+ async (req, res) => {
13637
+ const ownerId = req.lurqKey?.ownerId ?? null;
13638
+ if (!ownerId) {
13639
+ res.status(403).json({ error: "This key has no account attached." });
13640
+ return;
13641
+ }
13642
+ const { runs, rejected } = parseUpgradeRuns((req.body ?? {}).runs, MAX_RUNS_PER_POST);
13643
+ if (runs.length === 0) {
13644
+ res.status(400).json({ error: "runs must be a non-empty array of upgrade results." });
13645
+ return;
13646
+ }
13647
+ try {
13648
+ const repoIds = /* @__PURE__ */ new Map();
13649
+ for (const run of runs) {
13650
+ if (!repoIds.has(run.repoFullName)) {
13651
+ repoIds.set(
13652
+ run.repoFullName,
13653
+ await findRepoIdByFullName(db, ownerId, run.repoFullName)
13654
+ );
13655
+ }
11666
13656
  }
13657
+ const recorded = await recordUpgradeRuns(
13658
+ db,
13659
+ runs.map((run) => ({ ...run, ownerId, repoId: repoIds.get(run.repoFullName) ?? null }))
13660
+ );
13661
+ res.status(200).json({ recorded, rejected });
13662
+ void recordUsage(db, ownerId, "upgrade-runs");
13663
+ } catch (err) {
13664
+ logger.error(
13665
+ "upgrade run record failed:",
13666
+ err instanceof Error ? err.message : String(err)
13667
+ );
13668
+ res.status(500).json({ error: "Could not record the upgrade runs." });
11667
13669
  }
11668
- const recorded = await recordUpgradeRuns(
11669
- db,
11670
- runs.map((run) => ({ ...run, ownerId, repoId: repoIds.get(run.repoFullName) ?? null }))
11671
- );
11672
- res.status(200).json({ recorded, rejected });
11673
- } catch (err) {
11674
- logger.error("upgrade run record failed:", err instanceof Error ? err.message : String(err));
11675
- res.status(500).json({ error: "Could not record the upgrade runs." });
11676
13670
  }
11677
- });
11678
- app.post("/mcp", ipLimiter, auth, keyLimiter, async (req, res) => {
11679
- const server = buildMcpServer(db, { ownerId: req.lurqKey?.ownerId ?? null });
13671
+ );
13672
+ app.post("/mcp", ipLimiter, auth, keyLimiter, quota, async (req, res) => {
13673
+ const server2 = buildMcpServer(db, { ownerId: req.lurqKey?.ownerId ?? null });
11680
13674
  const transport = new StreamableHTTPServerTransport({
11681
13675
  sessionIdGenerator: void 0,
11682
13676
  enableJsonResponse: true
11683
13677
  });
11684
13678
  res.on("close", () => {
11685
13679
  void transport.close();
11686
- void server.close();
13680
+ void server2.close();
11687
13681
  });
11688
13682
  try {
11689
- await server.connect(transport);
13683
+ await server2.connect(transport);
11690
13684
  await transport.handleRequest(req, res, req.body);
11691
13685
  } catch (err) {
11692
13686
  logger.error("mcp request failed:", err instanceof Error ? err.message : String(err));
@@ -11696,9 +13690,41 @@ async function startHttpServer(opts = {}) {
11696
13690
  app.all("/mcp", (_req, res) => {
11697
13691
  res.status(405).json(rpcError(-32e3, "Method not allowed."));
11698
13692
  });
11699
- app.listen(port, () => {
13693
+ app.use((err, req, res, _next) => {
13694
+ const { status, body, clientFault } = errorEnvelope(err, req.path);
13695
+ if (!clientFault) {
13696
+ logger.error(`unhandled request error (${req.method} ${req.path}):`, err.message);
13697
+ }
13698
+ if (res.headersSent) return;
13699
+ res.status(status).json(body);
13700
+ });
13701
+ const server = app.listen(port, () => {
11700
13702
  logger.info(`lurq HTTP MCP server listening on :${port}/mcp`);
11701
13703
  });
13704
+ server.on("error", (err) => {
13705
+ logger.error(`could not listen on :${port}: ${err.message}`);
13706
+ process.exit(1);
13707
+ });
13708
+ const FORCE_EXIT_MS = 1e4;
13709
+ let shuttingDown = false;
13710
+ const shutdown = (signal) => {
13711
+ if (shuttingDown) return;
13712
+ shuttingDown = true;
13713
+ logger.info(`${signal} received, draining\u2026`);
13714
+ const force = setTimeout(() => {
13715
+ logger.warn("drain timed out, exiting anyway");
13716
+ process.exit(0);
13717
+ }, FORCE_EXIT_MS);
13718
+ force.unref();
13719
+ server.close(() => {
13720
+ void closeDb().then(
13721
+ () => process.exit(0),
13722
+ () => process.exit(0)
13723
+ );
13724
+ });
13725
+ };
13726
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
13727
+ process.on("SIGINT", () => shutdown("SIGINT"));
11702
13728
  }
11703
13729
  var init_http2 = __esm({
11704
13730
  "src/mcp/http.ts"() {
@@ -11706,6 +13732,7 @@ var init_http2 = __esm({
11706
13732
  init_esm_shims();
11707
13733
  init_config();
11708
13734
  init_logger();
13735
+ init_capabilities();
11709
13736
  init_apiKeys();
11710
13737
  init_outcomes();
11711
13738
  init_packages();
@@ -11713,15 +13740,21 @@ var init_http2 = __esm({
11713
13740
  init_repos();
11714
13741
  init_selectionPolicy();
11715
13742
  init_parse();
13743
+ init_conformance();
11716
13744
  init_usage();
13745
+ init_subscriptions();
13746
+ init_stripe();
13747
+ init_plans();
11717
13748
  init_client();
11718
13749
  init_app();
11719
13750
  init_brief();
11720
13751
  init_drift();
13752
+ init_askSpend();
11721
13753
  init_scope();
11722
13754
  init_runs();
11723
13755
  init_upgradeRuns();
11724
13756
  init_manifests();
13757
+ init_webhook();
11725
13758
  init_workflow();
11726
13759
  init_repoScan();
11727
13760
  init_server();
@@ -11731,7 +13764,7 @@ var init_http2 = __esm({
11731
13764
 
11732
13765
  // src/bin/lurq.ts
11733
13766
  init_esm_shims();
11734
- import { readFileSync as readFileSync9 } from "fs";
13767
+ import { readFileSync as readFileSync10 } from "fs";
11735
13768
  import updateNotifier from "update-notifier";
11736
13769
 
11737
13770
  // src/cli/index.ts
@@ -11770,6 +13803,10 @@ function buildProgram() {
11770
13803
  clearUserConfig2() ? `Removed ${path2}. Agent MCP configs still hold the key; re-run \`lurq setup\` to change them.` : "No stored API key on this machine."
11771
13804
  );
11772
13805
  });
13806
+ program.command("can").argument("[query...]", "what you are trying to do, in plain words").description("find the lurq capability for what you are trying to do").option("--json", "output matches as JSON").action(async (query, opts) => {
13807
+ const { runCan: runCan2 } = await Promise.resolve().then(() => (init_commands(), commands_exports));
13808
+ runCan2(query.join(" "), opts);
13809
+ });
11773
13810
  program.command("recommend").argument("<need>", "natural-language description of what you need").description("recommend the best current packages for a described need").option("--category <category>", "restrict to a taxonomy category").option("--min-confidence <level>", "proven | emerging | promising | unproven").option("--json", "output JSON instead of a table").action(async (need, opts) => {
11774
13811
  const { runRecommend: runRecommend2 } = await Promise.resolve().then(() => (init_commands(), commands_exports));
11775
13812
  await runRecommend2(need, opts);
@@ -11813,12 +13850,31 @@ function buildProgram() {
11813
13850
  const plan = await buildUpgradePlan2(dir, { url: opts.url, apiKey: opts.apiKey, repo: opts.repo });
11814
13851
  console.log(opts.json ? JSON.stringify(plan, null, 2) : formatUpgradePlan2(plan));
11815
13852
  });
11816
- program.command("check-upgrade").argument("[dir]", "project directory to scan (default: current)", ".").description("do these upgrades remove symbols your code actually references?").option("--plan <file>", "targets from `upgrade-plan --json`").option("--upgrade <spec...>", "pkg@from..to (repeatable), e.g. commander@11.1.0..12.1.0").option("--json", "output the report as JSON").option("--exit-code", "exit 1 when the report is not safe (for CI)").action(
13853
+ program.command("check-upgrade").argument("[dir]", "project directory to scan (default: current)", ".").description("do these upgrades remove symbols your code actually references?").option("--plan <file>", "targets from `upgrade-plan --json`").option("--upgrade <spec...>", "pkg@from..to (repeatable), e.g. commander@11.1.0..12.1.0").option("--json", "output the report as JSON").option("--exit-code", "exit 1 when the report is not safe (for CI)").option("--report", "send the result to your lurq dashboard (CI; needs an API key)").option("--url <url>", "hosted endpoint URL (defaults to the lurq service)").option("--api-key <key>", "hosted API key (defaults to $LURQ_API_KEY)").action(
11817
13854
  async (dir, opts) => {
11818
13855
  const { runCheckUpgrade: runCheckUpgrade2 } = await Promise.resolve().then(() => (init_checkUpgrade(), checkUpgrade_exports));
11819
13856
  await runCheckUpgrade2(dir, opts);
11820
13857
  }
11821
13858
  );
13859
+ program.command("check-release").argument("[dir]", "package directory (default: current)", ".").description("is the version you are about to publish honest about what changed?").option("--against <version>", "compare with this published version (default: latest)").option("--json", "output the check as JSON").option("--exit-code", "exit 1 when the bump is understated (for CI / prepublish)").action(
13860
+ async (dir, opts) => {
13861
+ const { checkRelease: checkRelease2, formatReleaseCheck: formatReleaseCheck2 } = await Promise.resolve().then(() => (init_release(), release_exports));
13862
+ const check2 = await checkRelease2(dir, { against: opts.against });
13863
+ console.log(opts.json ? JSON.stringify(check2, null, 2) : formatReleaseCheck2(check2));
13864
+ if (opts.exitCode && check2.verdict !== "ok") process.exitCode = 1;
13865
+ }
13866
+ );
13867
+ program.command("check-api").argument("[spec]", "OpenAPI document (default: the first one found)").description("does this change break the callers of your own API?").option("--against <rev>", "git revision to compare with (default: HEAD)").option("-C, --dir <dir>", "repository directory (default: current)", ".").option("--json", "output the check as JSON").option("--exit-code", "exit 1 on a breaking change (for CI)").action(
13868
+ async (spec, opts) => {
13869
+ const { runApiCheck: runApiCheck2 } = await Promise.resolve().then(() => (init_check2(), check_exports));
13870
+ const { formatApiCheck: formatApiCheck2 } = await Promise.resolve().then(() => (init_diff3(), diff_exports));
13871
+ const check2 = await runApiCheck2(opts.dir ?? ".", { spec, against: opts.against });
13872
+ console.log(
13873
+ opts.json ? JSON.stringify(check2, null, 2) : formatApiCheck2(check2, `api check (vs ${check2.against})`)
13874
+ );
13875
+ if (opts.exitCode && check2.verdict !== "ok") process.exitCode = 1;
13876
+ }
13877
+ );
11822
13878
  program.command("weights").description("show and explain the scoring weight model (health, quality, composite \u03BB)").option("--json", "output the weight model as JSON").action(async (opts) => {
11823
13879
  const { runWeights: runWeights2 } = await Promise.resolve().then(() => (init_commands(), commands_exports));
11824
13880
  runWeights2(opts);
@@ -11848,7 +13904,7 @@ init_config();
11848
13904
  // src/core/gate.ts
11849
13905
  init_esm_shims();
11850
13906
  init_constants();
11851
- import { createHash as createHash10, timingSafeEqual as timingSafeEqual2 } from "crypto";
13907
+ import { createHash as createHash10, timingSafeEqual as timingSafeEqual3 } from "crypto";
11852
13908
  var PRE_LAUNCH = false;
11853
13909
  var OWNER_HASH = "9675c37e56783e747e4a89ee3919d95d1364f2e5dd0f14501b0f878f8cc3682b";
11854
13910
  var ALLOWED = /* @__PURE__ */ new Set([
@@ -11870,7 +13926,7 @@ function isOwner() {
11870
13926
  if (!key) return false;
11871
13927
  const got = createHash10("sha256").update(key).digest();
11872
13928
  const want = Buffer.from(OWNER_HASH, "hex");
11873
- return got.length === want.length && timingSafeEqual2(got, want);
13929
+ return got.length === want.length && timingSafeEqual3(got, want);
11874
13930
  }
11875
13931
  function enforceGate(argv) {
11876
13932
  if (!PRE_LAUNCH || isOwner()) return;
@@ -11918,7 +13974,7 @@ function notifyOnUpdate() {
11918
13974
  if (quiet) return;
11919
13975
  try {
11920
13976
  const pkg = JSON.parse(
11921
- readFileSync9(new URL("../../package.json", import.meta.url), "utf8")
13977
+ readFileSync10(new URL("../../package.json", import.meta.url), "utf8")
11922
13978
  );
11923
13979
  updateNotifier({ pkg }).notify();
11924
13980
  } catch {