lurqrun 0.0.8 → 0.0.10

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
@@ -18,15 +18,119 @@ var init_esm_shims = __esm({
18
18
  }
19
19
  });
20
20
 
21
+ // package.json
22
+ var package_default;
23
+ var init_package = __esm({
24
+ "package.json"() {
25
+ package_default = {
26
+ name: "lurqrun",
27
+ version: "0.0.10",
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
+ type: "module",
30
+ license: "Apache-2.0",
31
+ author: "jadenryu",
32
+ repository: {
33
+ type: "git",
34
+ url: "git+https://github.com/jadenryu/lurq.git"
35
+ },
36
+ homepage: "https://github.com/jadenryu/lurq#readme",
37
+ bugs: {
38
+ url: "https://github.com/jadenryu/lurq/issues"
39
+ },
40
+ keywords: [
41
+ "mcp",
42
+ "model-context-protocol",
43
+ "npm",
44
+ "dependencies",
45
+ "recommendations",
46
+ "claude-code",
47
+ "ai-coding"
48
+ ],
49
+ bin: {
50
+ lurq: "dist/bin/lurq.js"
51
+ },
52
+ workspaces: [
53
+ "apps/*"
54
+ ],
55
+ files: [
56
+ "dist",
57
+ "src/data/seed.json",
58
+ "templates",
59
+ "drizzle",
60
+ "README.md"
61
+ ],
62
+ engines: {
63
+ node: ">=20"
64
+ },
65
+ scripts: {
66
+ build: "tsup",
67
+ dev: "tsx src/bin/lurq.ts",
68
+ lurq: "tsx src/bin/lurq.ts",
69
+ "dev:apps": "npm run dev --workspace @lurq/docs & npm run dev --workspace @lurq/web",
70
+ "benchmark:validate": "tsx src/bin/benchmark.ts validate",
71
+ "benchmark:run": "tsx src/bin/benchmark.ts run",
72
+ start: "node dist/bin/lurq.js",
73
+ test: "vitest run",
74
+ "test:watch": "vitest",
75
+ typecheck: "tsc --noEmit",
76
+ lint: "eslint .",
77
+ format: "prettier --write .",
78
+ "format:check": "prettier --check .",
79
+ "db:generate": "drizzle-kit generate",
80
+ "db:migrate": "tsx src/bin/operator.ts db migrate",
81
+ operator: "tsx src/bin/operator.ts",
82
+ "operator:prod": "LURQ_ENV_FILE=.env.production tsx src/bin/operator.ts",
83
+ prepublishOnly: "npm test && npm run build",
84
+ "content:drift": "tsx scripts/gen-drift.ts"
85
+ },
86
+ dependencies: {
87
+ "@inquirer/prompts": "^8.5.2",
88
+ "@modelcontextprotocol/sdk": "^1.29.0",
89
+ commander: "^12.1.0",
90
+ dotenv: "^16.4.5",
91
+ "drizzle-orm": "^0.45.2",
92
+ express: "^5.2.1",
93
+ "express-rate-limit": "^8.5.2",
94
+ helmet: "^8.2.0",
95
+ ioredis: "^5.11.1",
96
+ postgres: "^3.4.9",
97
+ "rate-limit-redis": "^4.3.1",
98
+ semver: "^7.8.5",
99
+ typescript: "^5.5.4",
100
+ "update-notifier": "^7.3.1",
101
+ zod: "^3.23.8"
102
+ },
103
+ devDependencies: {
104
+ "@eslint/js": "^9.9.0",
105
+ "@types/express": "^5.0.6",
106
+ "@types/node": "^22.5.0",
107
+ "@types/semver": "^7.7.1",
108
+ "drizzle-kit": "^0.31.10",
109
+ e2b: "^2.31.0",
110
+ eslint: "^9.9.0",
111
+ marked: "^18.0.9",
112
+ "pdfjs-dist": "^6.2.108",
113
+ playwright: "^1.62.1",
114
+ prettier: "^3.3.3",
115
+ tsup: "^8.2.4",
116
+ tsx: "^4.19.0",
117
+ "typescript-eslint": "^8.3.0",
118
+ vitest: "^2.0.5"
119
+ }
120
+ };
121
+ }
122
+ });
123
+
21
124
  // src/core/constants.ts
22
125
  var SERVER_NAME, PACKAGE_NAME, VERSION, DEFAULT_ENDPOINT, API_KEY_PREFIX, EMBEDDING_DIM, STALENESS_DAYS, HOUR, DAY, CACHE_TTL;
23
126
  var init_constants = __esm({
24
127
  "src/core/constants.ts"() {
25
128
  "use strict";
26
129
  init_esm_shims();
130
+ init_package();
27
131
  SERVER_NAME = "lurq";
28
132
  PACKAGE_NAME = "lurqrun";
29
- VERSION = "0.0.8";
133
+ VERSION = package_default.version;
30
134
  DEFAULT_ENDPOINT = "https://api.lurq.run/mcp";
31
135
  API_KEY_PREFIX = "lurq_live_";
32
136
  EMBEDDING_DIM = 1536;
@@ -1112,6 +1216,10 @@ var init_schema = __esm({
1112
1216
  peerDependencies: jsonb("peer_dependencies").$type(),
1113
1217
  peerDependenciesMeta: jsonb("peer_dependencies_meta").$type(),
1114
1218
  engines: jsonb("engines").$type(),
1219
+ /** Where this package runs, classified from its own manifest at ingest.
1220
+ * Resolves the `framework` category's frontend/backend ambiguity for the
1221
+ * architecture diagram. Null = the manifest did not say clearly enough. */
1222
+ runtimeTarget: text("runtime_target").$type(),
1115
1223
  // Computed outputs
1116
1224
  healthScore: integer("health_score"),
1117
1225
  /** Intrinsic-quality axis (§1), adoption-independent. Blends with health at
@@ -1229,7 +1337,13 @@ var init_schema = __esm({
1229
1337
  {
1230
1338
  packageName: text("package_name").notNull(),
1231
1339
  version: text("version").notNull(),
1232
- publishedAt: ts("published_at")
1340
+ publishedAt: ts("published_at"),
1341
+ /** Peers/engines declared by this exact version. Lifted from the registry
1342
+ * document at ingest (it already carries every version's manifest), so a
1343
+ * conflict check against the versions a repo actually RUNS is one indexed
1344
+ * query rather than a registry read per dependency. */
1345
+ peerDependencies: jsonb("peer_dependencies").$type(),
1346
+ engines: jsonb("engines").$type()
1233
1347
  },
1234
1348
  (table2) => [
1235
1349
  primaryKey({ columns: [table2.packageName, table2.version] }),
@@ -1610,10 +1724,10 @@ import { drizzle } from "drizzle-orm/postgres-js";
1610
1724
  import postgres from "postgres";
1611
1725
  function createDb(opts = {}) {
1612
1726
  const { DATABASE_URL } = requireConfig(["DATABASE_URL"]);
1613
- const sql12 = postgres(DATABASE_URL, { max: opts.max ?? 10, onnotice: () => {
1727
+ const sql14 = postgres(DATABASE_URL, { max: opts.max ?? 10, onnotice: () => {
1614
1728
  } });
1615
- const db = drizzle(sql12, { schema: schema_exports });
1616
- return { db, sql: sql12, close: () => sql12.end() };
1729
+ const db = drizzle(sql14, { schema: schema_exports });
1730
+ return { db, sql: sql14, close: () => sql14.end() };
1617
1731
  }
1618
1732
  var init_client = __esm({
1619
1733
  "src/db/client.ts"() {
@@ -1643,7 +1757,9 @@ async function upsertPackageVersions(db, name, versions) {
1643
1757
  const rows = versions.slice(i, i + VERSION_CHUNK).map((v) => ({
1644
1758
  packageName: name,
1645
1759
  version: v.version,
1646
- publishedAt: v.publishedAt
1760
+ publishedAt: v.publishedAt,
1761
+ peerDependencies: v.peerDependencies ?? null,
1762
+ engines: v.engines ?? null
1647
1763
  }));
1648
1764
  await db.insert(packageVersions).values(rows).onConflictDoNothing();
1649
1765
  }
@@ -2158,6 +2274,7 @@ function parseNpmRegistry(json2) {
2158
2274
  directDependenciesCount: countDeps(latestManifest?.dependencies),
2159
2275
  hasProvenance: detectProvenance(latestManifest),
2160
2276
  hasInstallScripts: detectInstallScripts(latestManifest),
2277
+ hasBrowserField: latestManifest?.browser != null,
2161
2278
  peerDependencies: parseDepMap(latestManifest?.peerDependencies),
2162
2279
  peerDependenciesMeta: parsePeerMeta(latestManifest?.peerDependenciesMeta),
2163
2280
  engines: parseDepMap(latestManifest?.engines),
@@ -2193,7 +2310,13 @@ function parseVersionTimeline(json2) {
2193
2310
  const versions = json2?.versions;
2194
2311
  if (!versions || typeof versions !== "object") return [];
2195
2312
  const time = json2?.time ?? {};
2196
- return Object.keys(versions).map((version) => ({ version, publishedAt: toDate(time?.[version]) })).sort((a, b) => (b.publishedAt?.getTime() ?? 0) - (a.publishedAt?.getTime() ?? 0));
2313
+ return Object.keys(versions).map((version) => ({
2314
+ version,
2315
+ publishedAt: toDate(time?.[version]),
2316
+ // Already in this document — no second fetch, no per-version round trip.
2317
+ peerDependencies: parseDepMap(versions[version]?.peerDependencies),
2318
+ engines: parseDepMap(versions[version]?.engines)
2319
+ })).sort((a, b) => (b.publishedAt?.getTime() ?? 0) - (a.publishedAt?.getTime() ?? 0));
2197
2320
  }
2198
2321
  function parseKeywords(value) {
2199
2322
  if (!Array.isArray(value)) return [];
@@ -2773,18 +2896,21 @@ async function checkCompat(db, packages2, opts = {}) {
2773
2896
  provenance: edge.provenance,
2774
2897
  witnessCount: edge.witnessCount
2775
2898
  }));
2899
+ const versionOf = new Map(members.map((m) => [m.name, m.version]));
2900
+ const matchesCheckedVersions = (edge) => edgeMatchesVersions(edge, versionOf);
2776
2901
  for (const edge of edges) {
2777
2902
  if (edge.status === "conflict") {
2903
+ const at = matchesCheckedVersions(edge) ? "" : ` (recorded at those versions; you are checking ${edge.packageA}@${versionOf.get(edge.packageA) ?? "?"} and ${edge.packageB}@${versionOf.get(edge.packageB) ?? "?"})`;
2778
2904
  conflicts.push({
2779
2905
  source: "sandbox",
2780
2906
  packages: [edge.packageA, edge.packageB],
2781
- detail: `${edge.packageA}@${edge.versionA} and ${edge.packageB}@${edge.versionB} failed to co-install in the sandbox`
2907
+ detail: `${edge.packageA}@${edge.versionA} and ${edge.packageB}@${edge.versionB} failed to co-install in the sandbox${at}`
2782
2908
  });
2783
2909
  }
2784
2910
  }
2785
2911
  const checkedNames = members.map((m) => m.name);
2786
2912
  const provenCompatible = new Set(
2787
- edges.filter((e) => e.status === "compatible").map((e) => pairKey(e.packageA, e.packageB))
2913
+ edges.filter((e) => e.status === "compatible" && matchesCheckedVersions(e)).map((e) => pairKey(e.packageA, e.packageB))
2788
2914
  );
2789
2915
  if (checkedNames.length >= 2 && !conflicts.length && !fullyCovered(checkedNames, provenCompatible)) {
2790
2916
  await enqueueCompatVerify(db, checkedNames).catch(() => {
@@ -2806,6 +2932,11 @@ async function checkCompat(db, packages2, opts = {}) {
2806
2932
  pairs: enumeratePairs(checkedNames, conflicts)
2807
2933
  };
2808
2934
  }
2935
+ function edgeMatchesVersions(edge, versionOf) {
2936
+ const a = versionOf.get(edge.packageA);
2937
+ const b = versionOf.get(edge.packageB);
2938
+ return a != null && b != null && a === edge.versionA && b === edge.versionB;
2939
+ }
2809
2940
  function enumeratePairs(names2, conflicts) {
2810
2941
  const byPair = /* @__PURE__ */ new Map();
2811
2942
  for (const c of conflicts) {
@@ -3239,22 +3370,6 @@ var init_verification = __esm({
3239
3370
  }
3240
3371
  });
3241
3372
 
3242
- // src/db/outcomes.ts
3243
- import { desc as desc3, eq as eq5 } from "drizzle-orm";
3244
- async function recordOutcome(db, outcome) {
3245
- await db.insert(recommendationOutcomes).values(outcome);
3246
- }
3247
- async function getOutcomesByOwner(db, ownerId, opts = {}) {
3248
- return db.select().from(recommendationOutcomes).where(eq5(recommendationOutcomes.ownerId, ownerId)).orderBy(desc3(recommendationOutcomes.createdAt)).limit(opts.limit ?? 50);
3249
- }
3250
- var init_outcomes = __esm({
3251
- "src/db/outcomes.ts"() {
3252
- "use strict";
3253
- init_esm_shims();
3254
- init_schema();
3255
- }
3256
- });
3257
-
3258
3373
  // src/data/successors.json
3259
3374
  var successors_default;
3260
3375
  var init_successors = __esm({
@@ -3317,9 +3432,30 @@ var init_successors = __esm({
3317
3432
  });
3318
3433
 
3319
3434
  // src/core/successors.ts
3320
- function lookupSuccessor(name) {
3321
- const hit = MAP[name.toLowerCase()];
3322
- return hit ? { name: hit.replacedBy, reason: hit.reason } : null;
3435
+ function lookupSuccessor(name, learned) {
3436
+ const key = name.toLowerCase();
3437
+ const curated = MAP[key];
3438
+ if (curated) return { name: curated.replacedBy, reason: curated.reason };
3439
+ return learned?.get(key) ?? null;
3440
+ }
3441
+ function buildLearnedSuccessors(observed, isSuperseded) {
3442
+ const best = /* @__PURE__ */ new Map();
3443
+ for (const row of observed) {
3444
+ if (!isSuperseded(row.from)) continue;
3445
+ const key = row.from.toLowerCase();
3446
+ const current = best.get(key);
3447
+ const better = !current || row.owners > current.owners || row.owners === current.owners && row.observations > current.observations;
3448
+ if (better) best.set(key, { to: row.to, owners: row.owners, observations: row.observations });
3449
+ }
3450
+ return new Map(
3451
+ [...best].map(([from, hit]) => [
3452
+ from,
3453
+ {
3454
+ name: hit.to,
3455
+ reason: `${hit.owners} teams moved here after ${from} failed for the same need`
3456
+ }
3457
+ ])
3458
+ );
3323
3459
  }
3324
3460
  var MAP;
3325
3461
  var init_successors2 = __esm({
@@ -3331,6 +3467,81 @@ var init_successors2 = __esm({
3331
3467
  }
3332
3468
  });
3333
3469
 
3470
+ // src/db/outcomes.ts
3471
+ import { count, desc as desc3, eq as eq5, inArray as inArray3, sql as sql5 } from "drizzle-orm";
3472
+ import { alias } from "drizzle-orm/pg-core";
3473
+ async function recordOutcome(db, outcome) {
3474
+ await db.insert(recommendationOutcomes).values(outcome);
3475
+ }
3476
+ async function getFieldEvidence(db, names2) {
3477
+ if (names2 && names2.length === 0) return /* @__PURE__ */ new Map();
3478
+ const rows = await db.select({
3479
+ packageName: recommendationOutcomes.packageName,
3480
+ reports: count(),
3481
+ successes: sql5`count(*) FILTER (WHERE ${SUCCESS_PREDICATE})`.mapWith(Number)
3482
+ }).from(recommendationOutcomes).where(names2 ? inArray3(recommendationOutcomes.packageName, names2) : void 0).groupBy(recommendationOutcomes.packageName);
3483
+ return new Map(
3484
+ rows.map((r) => [r.packageName, { reports: Number(r.reports), successes: r.successes }])
3485
+ );
3486
+ }
3487
+ async function learnSuccessions(db) {
3488
+ const failed = alias(recommendationOutcomes, "failed");
3489
+ const worked = alias(recommendationOutcomes, "worked");
3490
+ const rows = await db.select({
3491
+ from: failed.packageName,
3492
+ to: worked.packageName,
3493
+ owners: sql5`count(DISTINCT ${failed.ownerId})`.mapWith(Number),
3494
+ observations: sql5`count(*)`.mapWith(Number)
3495
+ }).from(failed).innerJoin(
3496
+ worked,
3497
+ sql5`${worked.ownerId} = ${failed.ownerId}
3498
+ AND ${worked.need} = ${failed.need}
3499
+ AND ${worked.packageName} <> ${failed.packageName}
3500
+ AND ${worked.createdAt} > ${failed.createdAt}`
3501
+ ).where(
3502
+ sql5`${failed.need} IS NOT NULL
3503
+ AND ${failed.ownerId} IS NOT NULL
3504
+ AND NOT (${failed.accepted} AND (${failed.buildSignal} IS NULL OR ${failed.buildSignal} <> 'failed'))
3505
+ AND (${worked.accepted} AND (${worked.buildSignal} IS NULL OR ${worked.buildSignal} <> 'failed'))`
3506
+ ).groupBy(failed.packageName, worked.packageName).having(sql5`count(DISTINCT ${failed.ownerId}) >= ${MIN_SUCCESSION_OWNERS}`);
3507
+ return rows.map((r) => ({
3508
+ from: r.from,
3509
+ to: r.to,
3510
+ owners: Number(r.owners),
3511
+ observations: Number(r.observations)
3512
+ }));
3513
+ }
3514
+ async function loadLearnedSuccessors(db, now = Date.now()) {
3515
+ if (learnedCache && now - learnedCache.at < LEARNED_TTL_MS) return learnedCache.value;
3516
+ const observed = await learnSuccessions(db);
3517
+ if (observed.length === 0) {
3518
+ learnedCache = { at: now, value: /* @__PURE__ */ new Map() };
3519
+ return learnedCache.value;
3520
+ }
3521
+ const names2 = [...new Set(observed.map((o) => o.from))];
3522
+ const rows = await db.select({ name: packages.name, deprecated: packages.deprecated, archived: packages.archived }).from(packages).where(inArray3(packages.name, names2));
3523
+ const dead = new Set(rows.filter((r) => r.deprecated || r.archived).map((r) => r.name));
3524
+ const value = buildLearnedSuccessors(observed, (name) => dead.has(name));
3525
+ learnedCache = { at: now, value };
3526
+ return value;
3527
+ }
3528
+ async function getOutcomesByOwner(db, ownerId, opts = {}) {
3529
+ return db.select().from(recommendationOutcomes).where(eq5(recommendationOutcomes.ownerId, ownerId)).orderBy(desc3(recommendationOutcomes.createdAt)).limit(opts.limit ?? 50);
3530
+ }
3531
+ var SUCCESS_PREDICATE, MIN_SUCCESSION_OWNERS, LEARNED_TTL_MS, learnedCache;
3532
+ var init_outcomes = __esm({
3533
+ "src/db/outcomes.ts"() {
3534
+ "use strict";
3535
+ init_esm_shims();
3536
+ init_successors2();
3537
+ init_schema();
3538
+ SUCCESS_PREDICATE = sql5`${recommendationOutcomes.accepted} AND (${recommendationOutcomes.buildSignal} IS NULL OR ${recommendationOutcomes.buildSignal} <> 'failed')`;
3539
+ MIN_SUCCESSION_OWNERS = 3;
3540
+ LEARNED_TTL_MS = 10 * 60 * 1e3;
3541
+ learnedCache = null;
3542
+ }
3543
+ });
3544
+
3334
3545
  // src/ingestion/sources/githubReadme.ts
3335
3546
  async function fetchGithubReadme(owner, repo, fetchImpl) {
3336
3547
  for (const file of CANDIDATES) {
@@ -3727,7 +3938,7 @@ function resetWeights() {
3727
3938
  resetWeightsCache();
3728
3939
  return removed;
3729
3940
  }
3730
- var HEALTH_WEIGHTS, COMPOSITE, QUALITY_WEIGHTS, QUALITY, MAINTENANCE_WEIGHTS, MAINTENANCE, ADOPTION, RELIABILITY, EFFICIENCY, DEFAULT_WEIGHTS, cachedWeights, SETTABLE, WEIGHT_EXPLANATIONS, CONFIDENCE;
3941
+ var HEALTH_WEIGHTS, COMPOSITE, QUALITY_WEIGHTS, QUALITY, MAINTENANCE_WEIGHTS, MAINTENANCE, ADOPTION, RELIABILITY, EFFICIENCY, FIELD, DEFAULT_WEIGHTS, cachedWeights, SETTABLE, WEIGHT_EXPLANATIONS, CONFIDENCE;
3731
3942
  var init_weights = __esm({
3732
3943
  "src/scoring/weights.ts"() {
3733
3944
  "use strict";
@@ -3806,6 +4017,26 @@ var init_weights = __esm({
3806
4017
  /** Bundle size at the category median maps to this score; smaller → higher. */
3807
4018
  medianScore: 50
3808
4019
  };
4020
+ FIELD = {
4021
+ /** Weight when evidence exists — ~9% of the blend (0.10 / 1.10). */
4022
+ weight: 0.1,
4023
+ /**
4024
+ * Bayesian shrinkage strength: how many prior observations the neutral rate is
4025
+ * worth. One failed report out of one is not a 0-scoring package, and a raw
4026
+ * success rate would say it is. At m=12, a single failure lands near 69 rather
4027
+ * than 0, and it takes real volume to move the number far — which is the
4028
+ * correct epistemics for a signal users can trivially poison.
4029
+ */
4030
+ priorWeight: 12,
4031
+ /**
4032
+ * The prior success rate a package is assumed to have before anyone reports.
4033
+ * Set at the observed base rate of "installs and builds fine", not 0.5:
4034
+ * most packages do work, so 0.5 would penalise the first honest reporter.
4035
+ */
4036
+ priorRate: 0.75,
4037
+ /** Reports at or above this cap the confidence note as fully-evidenced. */
4038
+ wellEvidencedReports: 25
4039
+ };
3809
4040
  DEFAULT_WEIGHTS = {
3810
4041
  health: { ...HEALTH_WEIGHTS },
3811
4042
  composite: { ...COMPOSITE }
@@ -3973,17 +4204,24 @@ function computeQuality(input) {
3973
4204
  if (components.length === 0) return null;
3974
4205
  return weightedAverage(components);
3975
4206
  }
4207
+ function computeFieldScore(evidence) {
4208
+ if (!evidence || evidence.reports <= 0) return null;
4209
+ const { reports, successes } = evidence;
4210
+ const m = FIELD.priorWeight;
4211
+ return clamp(Math.round(100 * (successes + m * FIELD.priorRate) / (reports + m)));
4212
+ }
3976
4213
  function computeHealthScore(breakdown, weights = HEALTH_WEIGHTS) {
3977
- const { maintenance, adoption, reliability, efficiency } = breakdown;
3978
4214
  const w = weights;
3979
- if (efficiency === null) {
3980
- const denom = w.maintenance + w.adoption + w.reliability;
3981
- return Math.round(
3982
- (w.maintenance * maintenance + w.adoption * adoption + w.reliability * reliability) / denom
3983
- );
3984
- }
3985
- return Math.round(
3986
- w.maintenance * maintenance + w.adoption * adoption + w.reliability * reliability + w.efficiency * efficiency
4215
+ return weightedAverage(
4216
+ [
4217
+ { value: breakdown.maintenance, weight: w.maintenance },
4218
+ { value: breakdown.adoption, weight: w.adoption },
4219
+ { value: breakdown.reliability, weight: w.reliability },
4220
+ { value: breakdown.efficiency, weight: w.efficiency },
4221
+ // `?? null` — a breakdown stored before field evidence shipped has the key
4222
+ // absent, and undefined must redistribute, not score 0.
4223
+ { value: breakdown.field ?? null, weight: FIELD.weight }
4224
+ ].filter((c) => c.value !== null)
3987
4225
  );
3988
4226
  }
3989
4227
  function computeComposite(healthScore, qualityScore, lambda = COMPOSITE.lambda) {
@@ -4228,7 +4466,7 @@ var init_types2 = __esm({
4228
4466
  });
4229
4467
 
4230
4468
  // src/db/repos.ts
4231
- import { and as and5, eq as eq7, sql as sql5 } from "drizzle-orm";
4469
+ import { and as and5, eq as eq7, sql as sql6 } from "drizzle-orm";
4232
4470
  async function upsertRepos(db, rows) {
4233
4471
  if (rows.length === 0) return 0;
4234
4472
  await db.insert(repos).values(
@@ -4243,9 +4481,9 @@ async function upsertRepos(db, rows) {
4243
4481
  ).onConflictDoUpdate({
4244
4482
  target: [repos.ownerId, repos.fullName],
4245
4483
  set: {
4246
- installationId: sql5`excluded.installation_id`,
4247
- defaultBranch: sql5`excluded.default_branch`,
4248
- isPrivate: sql5`excluded.is_private`
4484
+ installationId: sql6`excluded.installation_id`,
4485
+ defaultBranch: sql6`excluded.default_branch`,
4486
+ isPrivate: sql6`excluded.is_private`
4249
4487
  }
4250
4488
  });
4251
4489
  return rows.length;
@@ -4255,7 +4493,7 @@ async function listRepos(db, ownerId) {
4255
4493
  }
4256
4494
  async function reposDeclaring(db, name) {
4257
4495
  return db.select().from(repos).where(
4258
- sql5`exists (
4496
+ sql6`exists (
4259
4497
  select 1 from jsonb_array_elements(${repos.manifests}) as m
4260
4498
  where jsonb_exists(m -> 'deps', ${name})
4261
4499
  )`
@@ -4265,6 +4503,10 @@ async function getRepo(db, ownerId, id) {
4265
4503
  const rows = await db.select().from(repos).where(and5(eq7(repos.ownerId, ownerId), eq7(repos.id, id))).limit(1);
4266
4504
  return rows[0] ?? null;
4267
4505
  }
4506
+ async function findPolicyByFullName(db, ownerId, fullName) {
4507
+ const rows = await db.select({ policy: repos.policy }).from(repos).where(and5(eq7(repos.ownerId, ownerId), eq7(repos.fullName, fullName))).limit(1);
4508
+ return rows[0]?.policy ?? null;
4509
+ }
4268
4510
  async function saveScan(db, id, scan) {
4269
4511
  await db.update(repos).set({
4270
4512
  manifests: scan.manifests,
@@ -4350,8 +4592,76 @@ var init_attribute = __esm({
4350
4592
  }
4351
4593
  });
4352
4594
 
4595
+ // src/ingestion/sources/osv.ts
4596
+ function installKey(name, version) {
4597
+ return `${name}@${version}`;
4598
+ }
4599
+ async function queryVulnerableInstalls(installs, fetchImpl) {
4600
+ const affected = /* @__PURE__ */ new Map();
4601
+ if (installs.length === 0) return { affected, complete: true };
4602
+ const unique = [...new Map(installs.map((i) => [installKey(i.name, i.version), i])).values()];
4603
+ const batches = [];
4604
+ for (let i = 0; i < unique.length; i += BATCH) batches.push(unique.slice(i, i + BATCH));
4605
+ let complete = true;
4606
+ let cursor = 0;
4607
+ const worker = async () => {
4608
+ for (let i = cursor++; i < batches.length; i = cursor++) {
4609
+ const batch = batches[i];
4610
+ try {
4611
+ const { data } = await httpRequest(URL2, {
4612
+ host: HOST7,
4613
+ method: "POST",
4614
+ headers: { "content-type": "application/json" },
4615
+ body: JSON.stringify({
4616
+ queries: batch.map((q) => ({
4617
+ version: q.version,
4618
+ package: { name: q.name, ecosystem: "npm" }
4619
+ }))
4620
+ }),
4621
+ // Uncached on purpose. The shared cache keys on the URL, and every
4622
+ // batch here posts to the same one with a different body — a cache hit
4623
+ // would answer one tree's question with another tree's vulnerabilities.
4624
+ ttlMs: 0,
4625
+ // Best-effort by contract: the caller keeps its coarser answer when
4626
+ // this fails, so exhausting the default retry ladder would only stall
4627
+ // a scheduled scan behind an outage nobody is waiting on.
4628
+ retries: 1,
4629
+ fetchImpl
4630
+ });
4631
+ const results = data?.results ?? [];
4632
+ if (results.length !== batch.length) {
4633
+ complete = false;
4634
+ continue;
4635
+ }
4636
+ results.forEach((result, j) => {
4637
+ const ids = (result?.vulns ?? []).map((v) => v?.id).filter((id) => typeof id === "string");
4638
+ if (ids.length === 0) return;
4639
+ const q = batch[j];
4640
+ affected.set(installKey(q.name, q.version), ids);
4641
+ });
4642
+ } catch {
4643
+ complete = false;
4644
+ }
4645
+ }
4646
+ };
4647
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, batches.length) }, worker));
4648
+ return { affected, complete };
4649
+ }
4650
+ var HOST7, URL2, BATCH, CONCURRENCY;
4651
+ var init_osv = __esm({
4652
+ "src/ingestion/sources/osv.ts"() {
4653
+ "use strict";
4654
+ init_esm_shims();
4655
+ init_http();
4656
+ HOST7 = "api.osv.dev";
4657
+ URL2 = `https://${HOST7}/v1/querybatch`;
4658
+ BATCH = 250;
4659
+ CONCURRENCY = 4;
4660
+ }
4661
+ });
4662
+
4353
4663
  // src/github/drift.ts
4354
- import { inArray as inArray3 } from "drizzle-orm";
4664
+ import { inArray as inArray4, sql as sql7 } from "drizzle-orm";
4355
4665
  import semver2 from "semver";
4356
4666
  function declaredDeps(manifests) {
4357
4667
  const out = /* @__PURE__ */ new Map();
@@ -4378,7 +4688,7 @@ async function loadIndexed(db, names2) {
4378
4688
  latestVersion: packages.latestVersion,
4379
4689
  deprecated: packages.deprecated,
4380
4690
  advisories: packages.advisories
4381
- }).from(packages).where(inArray3(packages.name, names2.slice(i, i + NAME_CHUNK)));
4691
+ }).from(packages).where(inArray4(packages.name, names2.slice(i, i + NAME_CHUNK)));
4382
4692
  for (const row of rows) {
4383
4693
  out.set(row.name, {
4384
4694
  latestVersion: row.latestVersion,
@@ -4392,7 +4702,7 @@ async function loadIndexed(db, names2) {
4392
4702
  async function loadVersions(db, names2) {
4393
4703
  const out = /* @__PURE__ */ new Map();
4394
4704
  for (let i = 0; i < names2.length; i += NAME_CHUNK) {
4395
- const rows = await db.select({ name: packageVersions.packageName, version: packageVersions.version }).from(packageVersions).where(inArray3(packageVersions.packageName, names2.slice(i, i + NAME_CHUNK)));
4705
+ const rows = await db.select({ name: packageVersions.packageName, version: packageVersions.version }).from(packageVersions).where(inArray4(packageVersions.packageName, names2.slice(i, i + NAME_CHUNK)));
4396
4706
  for (const row of rows) {
4397
4707
  const list = out.get(row.name);
4398
4708
  if (list) list.push(row.version);
@@ -4442,19 +4752,28 @@ async function computeTransitiveDrift(db, resolved, directNames, truncated, edge
4442
4752
  };
4443
4753
  if (transitives.length === 0) return empty2;
4444
4754
  const indexed = await loadIndexed(db, [...new Set(transitives.map((d) => d.name))]);
4755
+ const { affected, complete } = await queryVulnerableInstalls(
4756
+ transitives.map((d) => ({ name: d.name, version: d.version }))
4757
+ );
4445
4758
  const risks = [];
4446
4759
  let tracked = 0;
4447
4760
  for (const dep of transitives) {
4448
4761
  const row = indexed.get(dep.name);
4449
4762
  if (!row) continue;
4450
4763
  tracked++;
4451
- if (row.advisories === 0 && !row.deprecated) continue;
4764
+ const vulnIds = affected.get(installKey(dep.name, dep.version)) ?? [];
4765
+ if (row.advisories === 0 && !row.deprecated && vulnIds.length === 0) continue;
4452
4766
  risks.push({
4453
4767
  name: dep.name,
4454
4768
  version: dep.version,
4455
4769
  latest: row.latestVersion,
4456
4770
  advisories: row.advisories,
4457
4771
  deprecated: row.deprecated,
4772
+ // Empty when OSV cleared this version, which is the actionable difference
4773
+ // from `advisories`: the package has a history, this install does not.
4774
+ // Undefined when the lookup could not complete — never an empty array,
4775
+ // which would read as a clean bill of health nobody established.
4776
+ vulnerabilities: complete ? vulnIds : void 0,
4458
4777
  pulledInBy: attribution.parentsOf.get(dep.name) ?? []
4459
4778
  });
4460
4779
  }
@@ -4465,6 +4784,10 @@ async function computeTransitiveDrift(db, resolved, directNames, truncated, edge
4465
4784
  resolved: transitives.length,
4466
4785
  tracked,
4467
4786
  advisoryPackages: risks.filter((r) => r.advisories > 0).length,
4787
+ // The number that can actually be acted on: installs OSV matched to a
4788
+ // vulnerability at their exact version. Undefined when the lookup did not
4789
+ // complete, so "not checked" never renders as zero.
4790
+ vulnerableInstalls: complete ? risks.filter((r) => r.vulnerabilities?.length).length : void 0,
4468
4791
  deprecated: risks.filter((r) => r.deprecated).length,
4469
4792
  risks: risks.slice(0, TRANSITIVE_DETAIL_CAP),
4470
4793
  truncated,
@@ -4476,6 +4799,29 @@ async function conflictsAtLatest(db, names2) {
4476
4799
  const { members } = await assembleMembers(db, names2);
4477
4800
  return resolveArchitectureCompat(members).slice(0, REPO_DRIFT_DETAIL_CAP);
4478
4801
  }
4802
+ async function conflictsAtCurrent(db, deps) {
4803
+ const pinned = deps.filter((d) => Boolean(d.resolved));
4804
+ if (pinned.length === 0) return [];
4805
+ const rows = await db.select({
4806
+ packageName: packageVersions.packageName,
4807
+ version: packageVersions.version,
4808
+ peerDependencies: packageVersions.peerDependencies,
4809
+ engines: packageVersions.engines
4810
+ }).from(packageVersions).where(
4811
+ inArray4(
4812
+ sql7`(${packageVersions.packageName}, ${packageVersions.version})`,
4813
+ pinned.map((d) => sql7`(${d.name}, ${d.resolved})`)
4814
+ )
4815
+ );
4816
+ const members = rows.map((r) => ({
4817
+ name: r.packageName,
4818
+ version: r.version,
4819
+ peerDependencies: r.peerDependencies,
4820
+ peerDependenciesMeta: null,
4821
+ engines: r.engines
4822
+ }));
4823
+ return resolveArchitectureCompat(members).slice(0, REPO_DRIFT_DETAIL_CAP);
4824
+ }
4479
4825
  async function computeDrift(db, manifests, resolvedTree = null) {
4480
4826
  const declared = declaredDeps(manifests);
4481
4827
  const names2 = [...declared.keys()];
@@ -4497,7 +4843,8 @@ async function computeDrift(db, manifests, resolvedTree = null) {
4497
4843
  advisories: 0,
4498
4844
  deps: [],
4499
4845
  transitive,
4500
- conflictsAtLatest: []
4846
+ conflictsAtLatest: [],
4847
+ conflictsAtCurrent: []
4501
4848
  };
4502
4849
  }
4503
4850
  const [indexed, versions] = await Promise.all([
@@ -4511,7 +4858,10 @@ async function computeDrift(db, manifests, resolvedTree = null) {
4511
4858
  deps.push(depDrift(name, entry, row, versions.get(name) ?? []));
4512
4859
  }
4513
4860
  deps.sort((a, b) => severity(b) - severity(a) || a.name.localeCompare(b.name));
4514
- const conflicts = await conflictsAtLatest(db, deps.map((d) => d.name));
4861
+ const [conflicts, currentConflicts] = await Promise.all([
4862
+ conflictsAtLatest(db, deps.map((d) => d.name)),
4863
+ conflictsAtCurrent(db, deps)
4864
+ ]);
4515
4865
  return {
4516
4866
  depsDeclared: declared.size,
4517
4867
  depsTracked: deps.length,
@@ -4521,7 +4871,8 @@ async function computeDrift(db, manifests, resolvedTree = null) {
4521
4871
  advisories: deps.reduce((sum, d) => sum + d.advisories, 0),
4522
4872
  deps: deps.slice(0, REPO_DRIFT_DETAIL_CAP),
4523
4873
  transitive,
4524
- conflictsAtLatest: conflicts
4874
+ conflictsAtLatest: conflicts,
4875
+ conflictsAtCurrent: currentConflicts
4525
4876
  };
4526
4877
  }
4527
4878
  var NAME_CHUNK;
@@ -4532,6 +4883,7 @@ var init_drift = __esm({
4532
4883
  init_schema();
4533
4884
  init_types2();
4534
4885
  init_attribute();
4886
+ init_osv();
4535
4887
  init_members();
4536
4888
  init_peerCompat();
4537
4889
  NAME_CHUNK = 500;
@@ -4632,6 +4984,82 @@ var init_errors = __esm({
4632
4984
  }
4633
4985
  });
4634
4986
 
4987
+ // src/core/runtimeTarget.ts
4988
+ function classifyRuntimeTarget(input) {
4989
+ const override = OVERRIDES[input.name.toLowerCase()];
4990
+ if (override) return override;
4991
+ const peers = Object.keys(input.peerDependencies ?? {});
4992
+ if (peers.some((p) => RENDERER_PEERS.has(p))) return "browser";
4993
+ const keywords = input.keywords.map((k) => k.toLowerCase());
4994
+ const server = keywords.filter((k) => SERVER_KEYWORDS.has(k)).length;
4995
+ const browser = keywords.filter((k) => BROWSER_KEYWORDS.has(k)).length;
4996
+ if (browser > server) return "browser";
4997
+ if (server > browser) return "node";
4998
+ if (input.hasBrowserField) return "browser";
4999
+ if (input.engines?.node && !input.hasBrowserField && browser === 0) return "node";
5000
+ return null;
5001
+ }
5002
+ var SERVER_KEYWORDS, BROWSER_KEYWORDS, RENDERER_PEERS, OVERRIDES;
5003
+ var init_runtimeTarget = __esm({
5004
+ "src/core/runtimeTarget.ts"() {
5005
+ "use strict";
5006
+ init_esm_shims();
5007
+ SERVER_KEYWORDS = /* @__PURE__ */ new Set([
5008
+ "server",
5009
+ "http-server",
5010
+ "backend",
5011
+ "middleware",
5012
+ "rest",
5013
+ "restful",
5014
+ "api",
5015
+ "router",
5016
+ "routing",
5017
+ "orm",
5018
+ "database",
5019
+ "sql",
5020
+ "graphql-server",
5021
+ "microservice",
5022
+ "websocket",
5023
+ "rpc"
5024
+ ]);
5025
+ BROWSER_KEYWORDS = /* @__PURE__ */ new Set([
5026
+ "react",
5027
+ "vue",
5028
+ "svelte",
5029
+ "angular",
5030
+ "preact",
5031
+ "solid",
5032
+ "dom",
5033
+ "browser",
5034
+ "component",
5035
+ "components",
5036
+ "ui",
5037
+ "css",
5038
+ "styling",
5039
+ "animation",
5040
+ "frontend",
5041
+ "client-side",
5042
+ "jsx",
5043
+ "hooks"
5044
+ ]);
5045
+ RENDERER_PEERS = /* @__PURE__ */ new Set(["react", "react-dom", "vue", "svelte", "@angular/core", "preact"]);
5046
+ OVERRIDES = {
5047
+ express: "node",
5048
+ fastify: "node",
5049
+ koa: "node",
5050
+ hono: "node",
5051
+ "@nestjs/core": "node",
5052
+ "@hapi/hapi": "node",
5053
+ // Meta-frameworks run in both places; they are the server in an architecture
5054
+ // diagram, which is the question this answers.
5055
+ next: "node",
5056
+ nuxt: "node",
5057
+ "@remix-run/node": "node",
5058
+ astro: "node"
5059
+ };
5060
+ }
5061
+ });
5062
+
4635
5063
  // src/pipeline/mineEdges.ts
4636
5064
  function trackedPairs(nodes, tracked) {
4637
5065
  const t = nodes.filter((n) => tracked.has(n.name));
@@ -4712,6 +5140,13 @@ function assemblePackageRow(p) {
4712
5140
  peerDependencies: r?.peerDependencies ?? null,
4713
5141
  peerDependenciesMeta: r?.peerDependenciesMeta ?? null,
4714
5142
  engines: r?.engines ?? null,
5143
+ runtimeTarget: r ? classifyRuntimeTarget({
5144
+ name: p.name,
5145
+ hasBrowserField: r.hasBrowserField,
5146
+ keywords: r.keywords,
5147
+ engines: r.engines,
5148
+ peerDependencies: r.peerDependencies
5149
+ }) : null,
4715
5150
  healthScore: p.healthScore,
4716
5151
  qualityScore: p.qualityScore,
4717
5152
  confidence: p.confidence,
@@ -4732,6 +5167,7 @@ var init_sync = __esm({
4732
5167
  init_errors();
4733
5168
  init_http();
4734
5169
  init_logger();
5170
+ init_runtimeTarget();
4735
5171
  init_collect();
4736
5172
  init_npmDownloads();
4737
5173
  init_summarize();
@@ -4805,7 +5241,7 @@ var init_ingestQueue = __esm({
4805
5241
  });
4806
5242
 
4807
5243
  // src/pipeline/single.ts
4808
- import { and as and6, eq as eq8, isNotNull as isNotNull3, sql as sql6 } from "drizzle-orm";
5244
+ import { and as and6, eq as eq8, isNotNull as isNotNull3, sql as sql8 } from "drizzle-orm";
4809
5245
  function raceTimeout(p, ms) {
4810
5246
  let timer;
4811
5247
  return Promise.race([
@@ -4821,7 +5257,7 @@ async function getSeedCategory(db, name) {
4821
5257
  }
4822
5258
  async function getCategoryMedianBundle(db, category) {
4823
5259
  const [row] = await db.select({
4824
- m: sql6`percentile_cont(0.5) within group (order by ${packages.bundleMinGzipKb})`
5260
+ m: sql8`percentile_cont(0.5) within group (order by ${packages.bundleMinGzipKb})`
4825
5261
  }).from(packages).where(and6(eq8(packages.category, category), isNotNull3(packages.bundleMinGzipKb)));
4826
5262
  return row?.m ?? null;
4827
5263
  }
@@ -4853,12 +5289,14 @@ async function syncOnePackage(db, name, opts = {}) {
4853
5289
  const input = toScoringInput(signals, category);
4854
5290
  const median2 = category ? await getCategoryMedianBundle(db, category) : null;
4855
5291
  const quality = computeQuality(input);
5292
+ const evidence = await getFieldEvidence(db, [name]);
4856
5293
  const breakdown = {
4857
5294
  maintenance: computeMaintenance(input, now),
4858
5295
  adoption: computeAdoption(input),
4859
5296
  reliability: computeReliability(input),
4860
5297
  efficiency: computeEfficiency(input.bundleMinGzipKb, category, median2),
4861
- quality
5298
+ quality,
5299
+ field: computeFieldScore(evidence.get(name) ?? null)
4862
5300
  };
4863
5301
  const healthScore = computeHealthScore(breakdown);
4864
5302
  const confidence = computeConfidence(input, now, quality);
@@ -4928,6 +5366,7 @@ var init_single = __esm({
4928
5366
  init_categoryInference();
4929
5367
  init_scoring();
4930
5368
  init_embeddings();
5369
+ init_outcomes();
4931
5370
  init_packages();
4932
5371
  init_schema();
4933
5372
  init_alerts2();
@@ -4939,7 +5378,7 @@ var init_single = __esm({
4939
5378
  });
4940
5379
 
4941
5380
  // src/search/recommend.ts
4942
- import { and as and7, cosineDistance, eq as eq9, inArray as inArray4, isNotNull as isNotNull4, lte, sql as sql7 } from "drizzle-orm";
5381
+ import { and as and7, cosineDistance, eq as eq9, inArray as inArray5, isNotNull as isNotNull4, lte, sql as sql9 } from "drizzle-orm";
4943
5382
  async function recommend(db, opts, provider = createEmbeddingProvider()) {
4944
5383
  const limit = Math.min(Math.max(opts.limit ?? 3, 1), 5);
4945
5384
  const [queryVec] = await provider.embed([opts.need]);
@@ -4992,7 +5431,7 @@ function buildConditions(constraints, category) {
4992
5431
  const allowed = ["proven", "emerging", "promising", "unproven"].filter(
4993
5432
  (c) => CONFIDENCE_RANK[c] >= CONFIDENCE_RANK[constraints.minConfidence]
4994
5433
  );
4995
- conditions.push(inArray4(packages.confidence, allowed));
5434
+ conditions.push(inArray5(packages.confidence, allowed));
4996
5435
  }
4997
5436
  return conditions;
4998
5437
  }
@@ -5006,10 +5445,10 @@ async function runVectorQuery(db, queryVec, providerId, constraints, category, p
5006
5445
  return db.select(ROW_COLUMNS).from(packages).where(and7(...conditions)).orderBy(distance).limit(pool);
5007
5446
  }
5008
5447
  async function runLexicalQuery(db, need, constraints, category, pool) {
5009
- const tsquery = sql7`websearch_to_tsquery('english', ${need})`;
5010
- const rank2 = sql7`ts_rank(${packages.searchVector}, ${tsquery})`;
5011
- const conditions = [sql7`${packages.searchVector} @@ ${tsquery}`, ...buildConditions(constraints, category)];
5012
- return db.select(ROW_COLUMNS).from(packages).where(and7(...conditions)).orderBy(sql7`${rank2} desc`).limit(pool);
5448
+ const tsquery = sql9`websearch_to_tsquery('english', ${need})`;
5449
+ const rank2 = sql9`ts_rank(${packages.searchVector}, ${tsquery})`;
5450
+ const conditions = [sql9`${packages.searchVector} @@ ${tsquery}`, ...buildConditions(constraints, category)];
5451
+ return db.select(ROW_COLUMNS).from(packages).where(and7(...conditions)).orderBy(sql9`${rank2} desc`).limit(pool);
5013
5452
  }
5014
5453
  function toCandidate(row) {
5015
5454
  return {
@@ -5147,7 +5586,7 @@ var init_types3 = __esm({
5147
5586
  });
5148
5587
 
5149
5588
  // src/db/selectionPolicy.ts
5150
- import { eq as eq10, inArray as inArray5 } from "drizzle-orm";
5589
+ import { eq as eq10, inArray as inArray6 } from "drizzle-orm";
5151
5590
  async function getSelectionPolicy(db, ownerId) {
5152
5591
  if (!ownerId) return DEFAULT_SELECTION_POLICY;
5153
5592
  const [row] = await db.select({ policy: selectionPolicies.policy }).from(selectionPolicies).where(eq10(selectionPolicies.ownerId, ownerId)).limit(1);
@@ -5166,7 +5605,7 @@ async function loadPolicyFacts(db, names2) {
5166
5605
  name: packages.name,
5167
5606
  license: packages.license,
5168
5607
  deprecated: packages.deprecated
5169
- }).from(packages).where(inArray5(packages.name, names2));
5608
+ }).from(packages).where(inArray6(packages.name, names2));
5170
5609
  for (const row of rows) {
5171
5610
  out.set(row.name, { license: row.license, deprecated: row.deprecated });
5172
5611
  }
@@ -5483,7 +5922,7 @@ __export(handlers_exports, {
5483
5922
  rowToEvaluate: () => rowToEvaluate
5484
5923
  });
5485
5924
  import { createHash as createHash3 } from "crypto";
5486
- import { sql as sql8 } from "drizzle-orm";
5925
+ import { sql as sql10 } from "drizzle-orm";
5487
5926
  function isStale(dataAsOf) {
5488
5927
  if (!dataAsOf) return true;
5489
5928
  return Date.now() - dataAsOf.getTime() > STALENESS_DAYS * DAY_MS2;
@@ -5499,7 +5938,7 @@ function topAdvisories(advisories, max = 5) {
5499
5938
  if (!advisories?.length) return [];
5500
5939
  return [...advisories].sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]).slice(0, max);
5501
5940
  }
5502
- function rowToEvaluate(row) {
5941
+ function rowToEvaluate(row, learned) {
5503
5942
  const breakdown = row.scoreBreakdown ?? {
5504
5943
  maintenance: 0,
5505
5944
  adoption: 0,
@@ -5528,11 +5967,11 @@ function rowToEvaluate(row) {
5528
5967
  summary: row.summary ? truncateSentences(row.summary, 3) : null,
5529
5968
  usageGuide: row.usageGuide ?? null,
5530
5969
  repoUrl: row.repoUrl,
5531
- replacedBy: lookupSuccessor(row.name)
5970
+ replacedBy: lookupSuccessor(row.name, learned)
5532
5971
  };
5533
5972
  }
5534
5973
  async function latestDataAsOf(db) {
5535
- const [row] = await db.select({ m: sql8`max(${packages.dataAsOf})` }).from(packages);
5974
+ const [row] = await db.select({ m: sql10`max(${packages.dataAsOf})` }).from(packages);
5536
5975
  return new Date(row?.m ?? Date.now()).toISOString();
5537
5976
  }
5538
5977
  function cacheKey(parts) {
@@ -5575,7 +6014,8 @@ async function handleEvaluate(db, input, ownerId = null) {
5575
6014
  suggestion: existsOnNpm ? `\u{1F389} Congrats, you're the first to add "${input.package}" to lurq's registry! It's being fetched and scored now; retry in a few seconds for the full evidence read.` : `"${input.package}" was not found on the npm registry. Check the package name.`
5576
6015
  };
5577
6016
  }
5578
- const evaluated2 = rowToEvaluate(row);
6017
+ const learned = row.deprecated || row.archived ? await loadLearnedSuccessors(db) : void 0;
6018
+ const evaluated2 = rowToEvaluate(row, learned);
5579
6019
  const verification = await getLatestVerificationByName(db, row.name);
5580
6020
  return verification ? { ...evaluated2, buildVerified: toBuildVerified(verification) } : evaluated2;
5581
6021
  },
@@ -5602,7 +6042,7 @@ async function handleCompare(db, input, ownerId = null) {
5602
6042
  const results = await Promise.all(
5603
6043
  input.packages.map((name) => getOrFetchPackage(db, name, { requestedByOwnerId: ownerId }))
5604
6044
  );
5605
- const rows = results.map((r) => r.row).filter((row) => row !== null).map(rowToEvaluate).sort((a, b) => b.healthScore - a.healthScore);
6045
+ const rows = results.map((r) => r.row).filter((row) => row !== null).map((row) => rowToEvaluate(row)).sort((a, b) => b.healthScore - a.healthScore);
5606
6046
  const missing = input.packages.filter((name) => !rows.some((r) => r.name === name));
5607
6047
  return {
5608
6048
  dataAsOf: await latestDataAsOf(db),
@@ -5887,7 +6327,8 @@ function parseRpcBody(raw) {
5887
6327
  }
5888
6328
  }
5889
6329
  function fetchUpgradePlan(deps, opts = {}) {
5890
- return post("/upgrade-plan", { deps }, opts);
6330
+ const body = opts.repo ? { deps, repo: opts.repo } : { deps };
6331
+ return post("/upgrade-plan", body, opts);
5891
6332
  }
5892
6333
  var RemoteError;
5893
6334
  var init_remote = __esm({
@@ -5982,10 +6423,10 @@ var init_optimize = __esm({
5982
6423
  });
5983
6424
 
5984
6425
  // src/mcp/diagram.ts
5985
- import { inArray as inArray6 } from "drizzle-orm";
6426
+ import { inArray as inArray7 } from "drizzle-orm";
5986
6427
  function layerFor(item) {
5987
6428
  if (!item.category) return UNCLASSIFIED;
5988
- if (item.category === "framework" && BACKEND_FRAMEWORKS.has(item.label)) return "Backend";
6429
+ if (item.category === "framework" && item.runtimeTarget === "node") return "Backend";
5989
6430
  return LAYER_OF[item.category] ?? UNCLASSIFIED;
5990
6431
  }
5991
6432
  function nodeId(label, i) {
@@ -6035,22 +6476,41 @@ async function handleDiagram(db, input) {
6035
6476
  note: "Provide a `stack` of package names to diagram. lurq labels a stack you choose; it does not infer an architecture from a description."
6036
6477
  };
6037
6478
  }
6038
- const rows = await db.select({ name: packages.name, category: packages.category }).from(packages).where(inArray6(packages.name, input.stack));
6039
- const known = new Map(rows.map((r) => [r.name, r.category]));
6040
- const items = input.stack.map((name) => ({
6041
- label: name,
6042
- category: known.get(name) ?? inferCategory(name)
6043
- }));
6479
+ const rows = await db.select({
6480
+ name: packages.name,
6481
+ category: packages.category,
6482
+ runtimeTarget: packages.runtimeTarget
6483
+ }).from(packages).where(inArray7(packages.name, input.stack));
6484
+ const known = new Map(rows.map((r) => [r.name, r]));
6485
+ const items = input.stack.map((name) => {
6486
+ const row = known.get(name);
6487
+ return {
6488
+ label: name,
6489
+ category: row?.category ?? inferCategory(name),
6490
+ // Fall back to the name-only classifier for a package that predates the
6491
+ // column or is not indexed at all: the curated overrides still resolve the
6492
+ // frameworks people actually diagram, and an unclassifiable one keeps the
6493
+ // old Presentation default rather than moving on a null.
6494
+ runtimeTarget: row?.runtimeTarget ?? classifyRuntimeTarget({
6495
+ name,
6496
+ hasBrowserField: false,
6497
+ keywords: [],
6498
+ engines: null,
6499
+ peerDependencies: null
6500
+ })
6501
+ };
6502
+ });
6044
6503
  const unclassified = items.filter((i) => layerFor(i) === UNCLASSIFIED).map((i) => i.label);
6045
6504
  const note = unclassified.length ? `Reference pattern derived from the provided stack: partial, not authoritative. Could not place ${unclassified.length} package(s): ${unclassified.join(", ")} (not in lurq's index and no category match), shown under Unclassified, outside the flow.` : "Reference pattern derived from the provided stack. A labeled starting point, not a validated architecture.";
6046
6505
  return { mermaid: buildMermaid(items), note };
6047
6506
  }
6048
- var UNCLASSIFIED, LAYER_OF, FLOW_LAYERS, BACKEND_FRAMEWORKS;
6507
+ var UNCLASSIFIED, LAYER_OF, FLOW_LAYERS;
6049
6508
  var init_diagram = __esm({
6050
6509
  "src/mcp/diagram.ts"() {
6051
6510
  "use strict";
6052
6511
  init_esm_shims();
6053
6512
  init_categoryInference();
6513
+ init_runtimeTarget();
6054
6514
  init_schema();
6055
6515
  UNCLASSIFIED = "Unclassified";
6056
6516
  LAYER_OF = {
@@ -6078,14 +6538,6 @@ var init_diagram = __esm({
6078
6538
  testing: "Tooling"
6079
6539
  };
6080
6540
  FLOW_LAYERS = ["Presentation", "Client Logic", "Backend", "Data"];
6081
- BACKEND_FRAMEWORKS = /* @__PURE__ */ new Set([
6082
- "express",
6083
- "fastify",
6084
- "koa",
6085
- "hono",
6086
- "@nestjs/core",
6087
- "@hapi/hapi"
6088
- ]);
6089
6541
  }
6090
6542
  });
6091
6543
 
@@ -6100,7 +6552,7 @@ __export(plan_exports, {
6100
6552
  resolvePins: () => resolvePins
6101
6553
  });
6102
6554
  import { createHash as createHash4 } from "crypto";
6103
- import { inArray as inArray7 } from "drizzle-orm";
6555
+ import { inArray as inArray8 } from "drizzle-orm";
6104
6556
  function packageToCandidate(row) {
6105
6557
  return {
6106
6558
  name: row.name,
@@ -6321,7 +6773,7 @@ function orderCandidates(cands, anchorFamily, optimize, bundleByName) {
6321
6773
  async function bundleSizes(db, candidates) {
6322
6774
  const names2 = [...new Set(candidates.map((c) => c.name))];
6323
6775
  if (names2.length === 0) return /* @__PURE__ */ new Map();
6324
- const rows = await db.select({ name: packages.name, bundle: packages.bundleMinGzipKb }).from(packages).where(inArray7(packages.name, names2));
6776
+ const rows = await db.select({ name: packages.name, bundle: packages.bundleMinGzipKb }).from(packages).where(inArray8(packages.name, names2));
6325
6777
  return new Map(rows.filter((r) => r.bundle != null).map((r) => [r.name, r.bundle]));
6326
6778
  }
6327
6779
  async function decompose(document) {
@@ -7875,6 +8327,10 @@ var init_app = __esm({
7875
8327
  });
7876
8328
 
7877
8329
  // src/github/workflow.ts
8330
+ function cliSpec(version = VERSION) {
8331
+ const [major = "0", minor = "0"] = version.split(".");
8332
+ return major === "0" ? `${PACKAGE_NAME}@${major}.${minor}` : `${PACKAGE_NAME}@${major}`;
8333
+ }
7878
8334
  function detectInstallCommand(lockfiles) {
7879
8335
  if (lockfiles.includes("pnpm-lock.yaml")) return "pnpm install --frozen-lockfile";
7880
8336
  if (lockfiles.includes("yarn.lock")) return "yarn install --frozen-lockfile";
@@ -7887,6 +8343,8 @@ function renderWorkflow(opts = {}) {
7887
8343
  const install = opts.installCommand ?? "npm ci";
7888
8344
  const max = opts.maxUpgrades ?? 3;
7889
8345
  const mode = opts.armed ? "pr" : "comment";
8346
+ const autoMerge = opts.autoMerge ?? false;
8347
+ const cli = cliSpec();
7890
8348
  return `# Managed by lurq, https://lurq.run
7891
8349
  #
7892
8350
  # Keeps this repository's dependencies current and rewrites the call sites an
@@ -7934,19 +8392,19 @@ jobs:
7934
8392
  - name: Plan
7935
8393
  env:
7936
8394
  LURQ_API_KEY: \${{ secrets.LURQ_API_KEY }}
7937
- run: npx -y lurqrun upgrade-plan . --json > lurq-plan.json
8395
+ run: npx -y ${cli} upgrade-plan . --json > lurq-plan.json
7938
8396
 
7939
8397
  # 2. Narrow that to symbols THIS repo references. Runs entirely locally
7940
8398
  # against both versions' npm tarballs, no API key, no test suite.
7941
8399
  - name: Check against this codebase
7942
- run: npx -y lurqrun check-upgrade . --plan lurq-plan.json --json > lurq-brief.json
8400
+ run: npx -y ${cli} check-upgrade . --plan lurq-plan.json --json > lurq-brief.json
7943
8401
 
7944
8402
  # The same report twice: once into the run summary, once as the body of
7945
8403
  # the pull request. Fenced, because the report is aligned plain text and
7946
8404
  # markdown would otherwise collapse its indentation into one paragraph.
7947
8405
  - name: Summarise
7948
8406
  run: |
7949
- npx -y lurqrun check-upgrade . --plan lurq-plan.json > lurq-report.txt
8407
+ npx -y ${cli} check-upgrade . --plan lurq-plan.json > lurq-report.txt
7950
8408
  { echo '\`\`\`'; cat lurq-report.txt; echo '\`\`\`'; } > lurq-report.md
7951
8409
  cat lurq-report.md >> "\${GITHUB_STEP_SUMMARY}"
7952
8410
 
@@ -8022,6 +8480,7 @@ jobs:
8022
8480
 
8023
8481
  # 4. The workflow does version control, never the model.
8024
8482
  - name: Open pull request
8483
+ id: pr
8025
8484
  if: env.LURQ_MODE == 'pr'
8026
8485
  uses: peter-evans/create-pull-request@v7
8027
8486
  with:
@@ -8034,19 +8493,30 @@ jobs:
8034
8493
  body-path: lurq-report.md
8035
8494
  labels: dependencies
8036
8495
  delete-branch: true
8037
- `;
8496
+ ${autoMerge ? MERGE_STEP : ""}`;
8038
8497
  }
8039
8498
  function newFileUrl(fullName, branch, content) {
8040
8499
  const params = new URLSearchParams({ filename: WORKFLOW_PATH, value: content });
8041
8500
  return `https://github.com/${fullName}/new/${encodeURIComponent(branch)}?${params.toString()}`;
8042
8501
  }
8043
- var WORKFLOW_PATH, DEFAULT_CRON;
8502
+ var WORKFLOW_PATH, DEFAULT_CRON, MERGE_STEP;
8044
8503
  var init_workflow = __esm({
8045
8504
  "src/github/workflow.ts"() {
8046
8505
  "use strict";
8047
8506
  init_esm_shims();
8507
+ init_constants();
8048
8508
  WORKFLOW_PATH = ".github/workflows/lurq-upgrade.yml";
8049
8509
  DEFAULT_CRON = "0 6 * * 1";
8510
+ MERGE_STEP = `
8511
+ # 5. Auto-merge, per this repository's lurq policy. GitHub merges only
8512
+ # once the repo's OWN required checks pass; lurq never makes that call.
8513
+ # Requires "Allow auto-merge" in repository settings.
8514
+ - name: Enable auto-merge
8515
+ if: env.LURQ_MODE == 'pr' && steps.pr.outputs.pull-request-number
8516
+ env:
8517
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
8518
+ run: gh pr merge --auto --squash "\${{ steps.pr.outputs.pull-request-number }}"
8519
+ `;
8050
8520
  }
8051
8521
  });
8052
8522
 
@@ -8143,6 +8613,7 @@ var init_manifests = __esm({
8143
8613
  var upgradePlan_exports = {};
8144
8614
  __export(upgradePlan_exports, {
8145
8615
  buildUpgradePlan: () => buildUpgradePlan,
8616
+ detectRepoFullName: () => detectRepoFullName,
8146
8617
  findManifests: () => findManifests,
8147
8618
  formatUpgradePlan: () => formatUpgradePlan,
8148
8619
  mergedDeps: () => mergedDeps,
@@ -8196,27 +8667,38 @@ function mergedDeps(manifests) {
8196
8667
  }
8197
8668
  return out;
8198
8669
  }
8670
+ function detectRepoFullName(explicit) {
8671
+ const value = explicit ?? process.env.GITHUB_REPOSITORY ?? null;
8672
+ return value && /^[\w.-]+\/[\w.-]+$/.test(value) ? value : null;
8673
+ }
8199
8674
  async function buildUpgradePlan(dir, opts = {}) {
8200
8675
  const manifests = readManifests(dir);
8201
8676
  const deps = mergedDeps(manifests);
8202
8677
  if (Object.keys(deps).length === 0) {
8203
8678
  throw new Error(`No package.json with dependencies found under ${dir}`);
8204
8679
  }
8205
- const plan = await fetchUpgradePlan(deps, opts);
8680
+ const plan = await fetchUpgradePlan(deps, { ...opts, repo: detectRepoFullName(opts.repo) });
8206
8681
  return { ...plan, manifests: manifests.length, deps: Object.keys(deps).length };
8207
8682
  }
8208
8683
  function formatUpgradePlan(plan) {
8209
8684
  const out = [
8210
- `lurq upgrade plan (${plan.deps} dependencies across ${plan.manifests} manifest(s))`,
8211
- ""
8685
+ `lurq upgrade plan (${plan.deps} dependencies across ${plan.manifests} manifest(s))`
8212
8686
  ];
8687
+ if (plan.scopeSource === "repo-policy") {
8688
+ out.push(
8689
+ `policy: scope=${plan.scope}` + (plan.outOfScope ? `, ${plan.outOfScope} upgrade(s) held back` : ", nothing held back")
8690
+ );
8691
+ }
8692
+ out.push("");
8213
8693
  if (plan.upgrades.length === 0) {
8214
8694
  out.push("Everything lurq tracks is already on its latest release.");
8215
8695
  }
8216
8696
  for (const upgrade of plan.upgrades) {
8697
+ const held = upgrade.inScope === false ? " [HELD]" : "";
8217
8698
  out.push(
8218
- `${VERDICT_LABEL[upgrade.verdict]} ${upgrade.package} ${upgrade.fromVersion} \u2192 ${upgrade.toVersion}` + (upgrade.advisories ? ` (${upgrade.advisories} advisory)` : "")
8699
+ `${VERDICT_LABEL[upgrade.verdict]} ${upgrade.package} ${upgrade.fromVersion} \u2192 ${upgrade.toVersion}` + (upgrade.advisories ? ` (${upgrade.advisories} advisory)` : "") + held
8219
8700
  );
8701
+ if (upgrade.scopeReason) out.push(` held: ${upgrade.scopeReason}`);
8220
8702
  if (upgrade.removed.length) {
8221
8703
  out.push(` removes ${upgrade.removed.length}: ${upgrade.removed.slice(0, 8).join(", ")}`);
8222
8704
  }
@@ -9362,7 +9844,7 @@ function targetsFromPlanFile(path2) {
9362
9844
  if (!Array.isArray(parsed.upgrades)) {
9363
9845
  throw new Error(`${path2} is not an upgrade plan (no "upgrades" array)`);
9364
9846
  }
9365
- return parsed.upgrades.filter((u) => u.package && u.fromVersion && u.toVersion).map((u) => ({ package: u.package, fromVersion: u.fromVersion, toVersion: u.toVersion }));
9847
+ 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 }));
9366
9848
  }
9367
9849
  async function runCheckUpgrade(dir, opts) {
9368
9850
  const { scanReferences: scanReferences2 } = await Promise.resolve().then(() => (init_references(), references_exports));
@@ -9417,7 +9899,7 @@ var init_graph = __esm({
9417
9899
  });
9418
9900
 
9419
9901
  // src/db/surface.ts
9420
- import { and as and9, desc as desc5, eq as eq13, sql as sql9 } from "drizzle-orm";
9902
+ import { and as and9, desc as desc5, eq as eq13, sql as sql11 } from "drizzle-orm";
9421
9903
  function surfaceRef(pkg, version) {
9422
9904
  return { kind: "package_surface", namespace: "npm", name: pkg, version };
9423
9905
  }
@@ -9700,22 +10182,22 @@ var init_toolDescriptions = __esm({
9700
10182
  });
9701
10183
 
9702
10184
  // src/db/usage.ts
9703
- import { and as and11, desc as desc7, eq as eq15, gte, sql as sql10 } from "drizzle-orm";
10185
+ import { and as and11, desc as desc7, eq as eq15, gte, sql as sql12 } from "drizzle-orm";
9704
10186
  function windowStart(days) {
9705
- return sql10`CURRENT_DATE - (${days}::int - 1)`;
10187
+ return sql12`CURRENT_DATE - (${days}::int - 1)`;
9706
10188
  }
9707
10189
  async function recordUsage(db, ownerId, tool) {
9708
10190
  if (!ownerId) return;
9709
10191
  try {
9710
- await db.insert(ownerUsageDaily).values({ ownerId, date: sql10`CURRENT_DATE`, tool, count: 1 }).onConflictDoUpdate({
10192
+ await db.insert(ownerUsageDaily).values({ ownerId, date: sql12`CURRENT_DATE`, tool, count: 1 }).onConflictDoUpdate({
9711
10193
  target: [ownerUsageDaily.ownerId, ownerUsageDaily.date, ownerUsageDaily.tool],
9712
- set: { count: sql10`${ownerUsageDaily.count} + 1` }
10194
+ set: { count: sql12`${ownerUsageDaily.count} + 1` }
9713
10195
  });
9714
10196
  } catch {
9715
10197
  }
9716
10198
  }
9717
10199
  async function getUsageSummary(db, ownerId, days) {
9718
- const rows = await db.execute(sql10`
10200
+ const rows = await db.execute(sql12`
9719
10201
  select
9720
10202
  to_char(d, 'YYYY-MM-DD') as date,
9721
10203
  coalesce(sum(u.count), 0)::int as count
@@ -9732,13 +10214,13 @@ async function getUsageSummary(db, ownerId, days) {
9732
10214
  async function getUsageByTool(db, ownerId, days) {
9733
10215
  const rows = await db.select({
9734
10216
  tool: ownerUsageDaily.tool,
9735
- count: sql10`sum(${ownerUsageDaily.count})::int`
10217
+ count: sql12`sum(${ownerUsageDaily.count})::int`
9736
10218
  }).from(ownerUsageDaily).where(
9737
10219
  and11(
9738
10220
  eq15(ownerUsageDaily.ownerId, ownerId),
9739
10221
  gte(ownerUsageDaily.date, windowStart(days))
9740
10222
  )
9741
- ).groupBy(ownerUsageDaily.tool).orderBy(desc7(sql10`sum(${ownerUsageDaily.count})`));
10223
+ ).groupBy(ownerUsageDaily.tool).orderBy(desc7(sql12`sum(${ownerUsageDaily.count})`));
9742
10224
  return rows.map((r) => ({ tool: r.tool, count: Number(r.count) }));
9743
10225
  }
9744
10226
  var init_usage = __esm({
@@ -10259,6 +10741,52 @@ var init_brief = __esm({
10259
10741
  }
10260
10742
  });
10261
10743
 
10744
+ // src/github/scope.ts
10745
+ function isUnassessed(u) {
10746
+ return u.verdict === "unknown";
10747
+ }
10748
+ function breaksSomething(u) {
10749
+ return u.verdict === "removes-exports" || u.verdict === "arity-changed";
10750
+ }
10751
+ function scopeVerdict(u, scope) {
10752
+ if (scope === "all") return { inScope: true };
10753
+ if (u.advisories > 0) return { inScope: true };
10754
+ if (scope === "security") {
10755
+ return { inScope: false, reason: "no advisory \u2014 scope is security-only" };
10756
+ }
10757
+ if (isUnassessed(u)) {
10758
+ return { inScope: false, reason: "surface not yet analysed \u2014 nothing for the agent to rewrite" };
10759
+ }
10760
+ if (breaksSomething(u)) return { inScope: true };
10761
+ return { inScope: false, reason: "nothing referenced breaks \u2014 scope is security + blocking" };
10762
+ }
10763
+ function applyScope(upgrades, policy) {
10764
+ if (!policy) {
10765
+ return {
10766
+ upgrades: upgrades.map((u) => ({ ...u, inScope: true })),
10767
+ scope: "all",
10768
+ scopeSource: "unconnected",
10769
+ outOfScope: 0
10770
+ };
10771
+ }
10772
+ const scoped = upgrades.map((u) => {
10773
+ const verdict = scopeVerdict(u, policy.scope);
10774
+ return verdict.inScope ? { ...u, inScope: true } : { ...u, inScope: false, scopeReason: verdict.reason };
10775
+ });
10776
+ return {
10777
+ upgrades: scoped,
10778
+ scope: policy.scope,
10779
+ scopeSource: "repo-policy",
10780
+ outOfScope: scoped.filter((u) => !u.inScope).length
10781
+ };
10782
+ }
10783
+ var init_scope = __esm({
10784
+ "src/github/scope.ts"() {
10785
+ "use strict";
10786
+ init_esm_shims();
10787
+ }
10788
+ });
10789
+
10262
10790
  // src/github/runs.ts
10263
10791
  function str2(value, max) {
10264
10792
  if (typeof value !== "string") return null;
@@ -10275,7 +10803,7 @@ function strList(value, maxItems, maxLen) {
10275
10803
  }
10276
10804
  return out;
10277
10805
  }
10278
- function count(value) {
10806
+ function count2(value) {
10279
10807
  const n = typeof value === "number" ? value : Number(value);
10280
10808
  return Number.isInteger(n) && n >= 0 && n < 1e6 ? n : null;
10281
10809
  }
@@ -10299,9 +10827,9 @@ function parseUpgradeRun(input) {
10299
10827
  severity: severity2,
10300
10828
  status,
10301
10829
  symbolsAffected: strList(raw.symbolsAffected, MAX_SYMBOLS, MAX_NAME),
10302
- callSites: count(raw.callSites) ?? 0,
10830
+ callSites: count2(raw.callSites) ?? 0,
10303
10831
  callSiteFiles: files,
10304
- filesChanged: count(raw.filesChanged),
10832
+ filesChanged: count2(raw.filesChanged),
10305
10833
  testsPassed: typeof raw.testsPassed === "boolean" ? raw.testsPassed : null,
10306
10834
  prUrl: str2(raw.prUrl, MAX_URL),
10307
10835
  // Empty string, not null: it is part of the dedup key (see schema).
@@ -10319,6 +10847,11 @@ function parseUpgradeRuns(input, max) {
10319
10847
  }
10320
10848
  return { runs, rejected };
10321
10849
  }
10850
+ function parseRepoFullName(input) {
10851
+ const value = str2(input, MAX_NAME * 2);
10852
+ if (!value) return null;
10853
+ return /^[\w.-]+\/[\w.-]+$/.test(value) ? value : null;
10854
+ }
10322
10855
  function parseDepsInput(input) {
10323
10856
  if (!input || typeof input !== "object") return {};
10324
10857
  const out = {};
@@ -10346,7 +10879,7 @@ var init_runs = __esm({
10346
10879
  });
10347
10880
 
10348
10881
  // src/db/upgradeRuns.ts
10349
- import { and as and13, desc as desc9, eq as eq17, gte as gte2, sql as sql11 } from "drizzle-orm";
10882
+ import { and as and13, desc as desc9, eq as eq17, gte as gte2, sql as sql13 } from "drizzle-orm";
10350
10883
  async function recordUpgradeRuns(db, rows) {
10351
10884
  if (rows.length === 0) return 0;
10352
10885
  await db.insert(upgradeRuns).values(rows).onConflictDoUpdate({
@@ -10358,15 +10891,15 @@ async function recordUpgradeRuns(db, rows) {
10358
10891
  upgradeRuns.runUrl
10359
10892
  ],
10360
10893
  set: {
10361
- severity: sql11`excluded.severity`,
10362
- status: sql11`excluded.status`,
10363
- symbolsAffected: sql11`excluded.symbols_affected`,
10364
- callSites: sql11`excluded.call_sites`,
10365
- callSiteFiles: sql11`excluded.call_site_files`,
10366
- filesChanged: sql11`excluded.files_changed`,
10367
- testsPassed: sql11`excluded.tests_passed`,
10368
- prUrl: sql11`excluded.pr_url`,
10369
- repoId: sql11`excluded.repo_id`
10894
+ severity: sql13`excluded.severity`,
10895
+ status: sql13`excluded.status`,
10896
+ symbolsAffected: sql13`excluded.symbols_affected`,
10897
+ callSites: sql13`excluded.call_sites`,
10898
+ callSiteFiles: sql13`excluded.call_site_files`,
10899
+ filesChanged: sql13`excluded.files_changed`,
10900
+ testsPassed: sql13`excluded.tests_passed`,
10901
+ prUrl: sql13`excluded.pr_url`,
10902
+ repoId: sql13`excluded.repo_id`
10370
10903
  }
10371
10904
  });
10372
10905
  return rows.length;
@@ -10381,12 +10914,12 @@ async function listRunsForRepo(db, ownerId, repoId, limit = 50) {
10381
10914
  async function getUpgradeImpact(db, ownerId, days = 30) {
10382
10915
  const since = new Date(Date.now() - days * 864e5);
10383
10916
  const rows = await db.select({
10384
- analysed: sql11`count(*)::int`,
10385
- blocking: sql11`count(*) filter (where ${upgradeRuns.severity} = 'blocking')::int`,
10386
- callSites: sql11`coalesce(sum(${upgradeRuns.callSites}) filter (where ${upgradeRuns.severity} = 'blocking'), 0)::int`,
10387
- prsOpened: sql11`count(*) filter (where ${upgradeRuns.status} in ('pr_open','merged'))::int`,
10388
- merged: sql11`count(*) filter (where ${upgradeRuns.status} = 'merged')::int`,
10389
- unverified: sql11`count(*) filter (where ${upgradeRuns.severity} = 'unverified')::int`
10917
+ analysed: sql13`count(*)::int`,
10918
+ blocking: sql13`count(*) filter (where ${upgradeRuns.severity} = 'blocking')::int`,
10919
+ callSites: sql13`coalesce(sum(${upgradeRuns.callSites}) filter (where ${upgradeRuns.severity} = 'blocking'), 0)::int`,
10920
+ prsOpened: sql13`count(*) filter (where ${upgradeRuns.status} in ('pr_open','merged'))::int`,
10921
+ merged: sql13`count(*) filter (where ${upgradeRuns.status} = 'merged')::int`,
10922
+ unverified: sql13`count(*) filter (where ${upgradeRuns.severity} = 'unverified')::int`
10390
10923
  }).from(upgradeRuns).where(and13(eq17(upgradeRuns.ownerId, ownerId), gte2(upgradeRuns.createdAt, since)));
10391
10924
  return rows[0] ?? {
10392
10925
  analysed: 0,
@@ -10941,7 +11474,8 @@ async function startHttpServer(opts = {}) {
10941
11474
  const runs = await listRunsForRepo(db, ownerId, row.id);
10942
11475
  const workflow = renderWorkflow({
10943
11476
  installCommand: row.installCommand ?? void 0,
10944
- armed: row.policy.enabled
11477
+ armed: row.policy.enabled,
11478
+ autoMerge: row.policy.autoMerge
10945
11479
  });
10946
11480
  res.status(200).json({
10947
11481
  repo: {
@@ -11071,8 +11605,13 @@ async function startHttpServer(opts = {}) {
11071
11605
  try {
11072
11606
  const drift = await computeDrift(db, [{ path: "package.json", deps }]);
11073
11607
  const brief = await briefRepo(db, drift);
11608
+ const ownerId = req.lurqKey?.ownerId ?? null;
11609
+ const repoFullName = parseRepoFullName((req.body ?? {}).repo);
11610
+ const policy = ownerId && repoFullName ? await findPolicyByFullName(db, ownerId, repoFullName) : null;
11611
+ const scoped = applyScope(brief.upgrades, policy);
11074
11612
  res.status(200).json({
11075
11613
  ...brief,
11614
+ ...scoped,
11076
11615
  // Surfaced so CI can log what lurq had no opinion on. An upgrade we do
11077
11616
  // not know about must not look like an upgrade we cleared.
11078
11617
  untracked: Object.keys(deps).length - drift.depsTracked
@@ -11153,6 +11692,7 @@ var init_http2 = __esm({
11153
11692
  init_app();
11154
11693
  init_brief();
11155
11694
  init_drift();
11695
+ init_scope();
11156
11696
  init_runs();
11157
11697
  init_upgradeRuns();
11158
11698
  init_manifests();
@@ -11242,9 +11782,9 @@ function buildProgram() {
11242
11782
  const { runPlan: runPlan2 } = await Promise.resolve().then(() => (init_commands(), commands_exports));
11243
11783
  await runPlan2(file, opts);
11244
11784
  });
11245
- program.command("upgrade-plan").argument("[dir]", "project directory (default: current)", ".").description("what is behind in this project, and what each upgrade removes from its API").option("--json", "output the plan as JSON (feed to `check-upgrade --plan`)").option("--url <url>", "hosted endpoint URL (defaults to the lurq service)").option("--api-key <key>", "hosted API key (defaults to $LURQ_API_KEY)").action(async (dir, opts) => {
11785
+ program.command("upgrade-plan").argument("[dir]", "project directory (default: current)", ".").description("what is behind in this project, and what each upgrade removes from its API").option("--json", "output the plan as JSON (feed to `check-upgrade --plan`)").option("--url <url>", "hosted endpoint URL (defaults to the lurq service)").option("--api-key <key>", "hosted API key (defaults to $LURQ_API_KEY)").option("--repo <owner/name>", "apply this repo's policy (defaults to $GITHUB_REPOSITORY)").action(async (dir, opts) => {
11246
11786
  const { buildUpgradePlan: buildUpgradePlan2, formatUpgradePlan: formatUpgradePlan2 } = await Promise.resolve().then(() => (init_upgradePlan(), upgradePlan_exports));
11247
- const plan = await buildUpgradePlan2(dir, { url: opts.url, apiKey: opts.apiKey });
11787
+ const plan = await buildUpgradePlan2(dir, { url: opts.url, apiKey: opts.apiKey, repo: opts.repo });
11248
11788
  console.log(opts.json ? JSON.stringify(plan, null, 2) : formatUpgradePlan2(plan));
11249
11789
  });
11250
11790
  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(