@merittdev/horus 0.3.1 → 0.4.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.
Files changed (2) hide show
  1. package/dist/index.cjs +402 -54
  2. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -57239,7 +57239,7 @@ init_cjs_shims();
57239
57239
 
57240
57240
  // ../../packages/core/src/version.ts
57241
57241
  init_cjs_shims();
57242
- var HORUS_VERSION = true ? "0.3.1" : "dev";
57242
+ var HORUS_VERSION = true ? "0.4.1" : "dev";
57243
57243
  var PINNED_SOURCE_VERSION = "1.1.1";
57244
57244
 
57245
57245
  // ../../packages/core/src/evidence.ts
@@ -63804,6 +63804,11 @@ init_cjs_shims();
63804
63804
  function classifyAnomaly(kind, cmp, opts) {
63805
63805
  const spike = opts?.spike ?? 1.5;
63806
63806
  const drop = opts?.drop ?? 0.67;
63807
+ const NEAR_ZERO = 1e-9;
63808
+ if (cmp.baselineAvg <= NEAR_ZERO && cmp.currentAvg > 0) {
63809
+ const floor2 = kind === "queue" ? 1 : kind === "latency" ? 0.05 : kind === "error-rate" ? 5e-3 : 0;
63810
+ if (cmp.currentAvg < floor2) return "none";
63811
+ }
63807
63812
  if (kind === "latency" && cmp.ratio >= spike) return "latency-spike";
63808
63813
  if (kind === "queue" && (cmp.ratio >= spike || cmp.currentAvg > cmp.baselineAvg) && cmp.delta > 0) {
63809
63814
  return "queue-growth";
@@ -63947,10 +63952,10 @@ var GrafanaMetricsProvider = class {
63947
63952
  const queryPanels = async (panels) => {
63948
63953
  const results = [];
63949
63954
  for (const panel of panels) {
63950
- signal?.throwIfAborted();
63955
+ if (signal?.aborted) return results;
63951
63956
  const panelMatchSrc = hint !== void 0 && hint !== "" ? findMatchSource(panel, hint) : null;
63952
63957
  for (const rawExpr of panel.exprs) {
63953
- signal?.throwIfAborted();
63958
+ if (signal?.aborted) return results;
63954
63959
  const expr = sanitizeExpr(rawExpr);
63955
63960
  if (expr === null) continue;
63956
63961
  try {
@@ -63976,7 +63981,7 @@ var GrafanaMetricsProvider = class {
63976
63981
  ).map((f) => ({ ...f, matchSource: panelMatchSrc }));
63977
63982
  results.push(...findings2);
63978
63983
  } catch (err) {
63979
- if (signal?.aborted) throw signal.reason ?? err;
63984
+ if (signal?.aborted) return results;
63980
63985
  continue;
63981
63986
  }
63982
63987
  }
@@ -76655,6 +76660,16 @@ function correlate(evidence2) {
76655
76660
  init_cjs_shims();
76656
76661
  var PREFER = /resolver|controller|\bservice\b|route|router|handler|gateway|processor|worker|consumer|repository|usecase|use-case|endpoint/i;
76657
76662
  var DEMOTE = /\butil|helper|\bscript|backfill|legacy|migration|seeder|fixture|\bmock\b|\btest\b|\bspec\b/i;
76663
+ var TYPE_SUFFIX = /(Result|Input|Response|Payload|Args|Dto|Props|Edge|Connection)$/;
76664
+ var INTERFACE_PREFIX = /^I(?=[A-Z][a-z])/;
76665
+ var TYPE_LIKE_NAME = new RegExp(`(?:${TYPE_SUFFIX.source})|(?:${INTERFACE_PREFIX.source})`);
76666
+ function isTypeLikeName(name) {
76667
+ return TYPE_LIKE_NAME.test(name);
76668
+ }
76669
+ function executableBaseName(name) {
76670
+ const stripped = name.replace(TYPE_SUFFIX, "").replace(INTERFACE_PREFIX, "");
76671
+ return stripped !== name && stripped.length >= 4 ? stripped : null;
76672
+ }
76658
76673
  function seedRole(s) {
76659
76674
  const hay = `${s.name} ${s.filePath}`;
76660
76675
  if (/\.resolver\.|resolver/i.test(hay)) return "resolver";
@@ -76667,11 +76682,16 @@ function seedRole(s) {
76667
76682
  if (/util|helper/i.test(hay)) return "util";
76668
76683
  return "code";
76669
76684
  }
76670
- function scoreSeed(s, index2, hintTokens) {
76685
+ function scoreSeed(s, index2, hintTokens, changedFiles) {
76671
76686
  const hay = `${s.name} ${s.filePath}`.toLowerCase();
76672
76687
  let score = 0;
76673
76688
  if (PREFER.test(hay)) score += 3;
76674
76689
  if (DEMOTE.test(hay)) score -= 3;
76690
+ if (isTypeLikeName(s.name)) score -= 4;
76691
+ if (s.startLine !== void 0 && s.endLine !== void 0 && s.endLine - s.startLine <= 3) {
76692
+ score -= 3;
76693
+ }
76694
+ if (changedFiles !== void 0 && changedFiles.has(s.filePath)) score += 5;
76675
76695
  if (/\.(resolver|controller|service)\.[jt]sx?$/i.test(s.filePath)) score += 2;
76676
76696
  if (/(^|\/)scripts?\//i.test(s.filePath)) score -= 2;
76677
76697
  score += Math.max(0, 5 - index2) * 0.1;
@@ -76687,8 +76707,13 @@ function scoreSeed(s, index2, hintTokens) {
76687
76707
  }
76688
76708
  return score;
76689
76709
  }
76690
- function rankSeeds(seeds, hintTokens) {
76691
- return seeds.map((symbol, i) => ({ symbol, score: scoreSeed(symbol, i, hintTokens), role: seedRole(symbol), i })).sort((a, b2) => b2.score === a.score ? a.i - b2.i : b2.score - a.score).map(({ symbol, score, role }) => ({ symbol, score, role }));
76710
+ function rankSeeds(seeds, hintTokens, changedFiles) {
76711
+ return seeds.map((symbol, i) => ({
76712
+ symbol,
76713
+ score: scoreSeed(symbol, i, hintTokens, changedFiles),
76714
+ role: seedRole(symbol),
76715
+ i
76716
+ })).sort((a, b2) => b2.score === a.score ? a.i - b2.i : b2.score - a.score).map(({ symbol, score, role }) => ({ symbol, score, role }));
76692
76717
  }
76693
76718
 
76694
76719
  // ../../packages/engine/src/normalize.ts
@@ -76984,6 +77009,17 @@ function clamp015(n) {
76984
77009
  if (n > 1) return 1;
76985
77010
  return n;
76986
77011
  }
77012
+ function confidenceCeilingForCause(score) {
77013
+ const s = clamp015(score);
77014
+ if (s < 0.2) return 0.6;
77015
+ if (s < 0.5) {
77016
+ return 0.6 + (s - 0.2) / (0.5 - 0.2) * (0.78 - 0.6);
77017
+ }
77018
+ if (s < 0.85) {
77019
+ return 0.78 + (s - 0.5) / (0.85 - 0.5) * (1 - 0.78);
77020
+ }
77021
+ return 1;
77022
+ }
76987
77023
  function relevanceForKind(kind) {
76988
77024
  switch (kind) {
76989
77025
  case "symbol":
@@ -77022,13 +77058,36 @@ function logWindowFrom(since) {
77022
77058
  return new Date(now - 7 * 864e5).toISOString();
77023
77059
  }
77024
77060
  function classifyLogRelevance(sigKey, sigServices, seedTerms, inputService) {
77025
- if (seedTerms.length === 0) {
77026
- return { relevanceClass: "direct", relevanceReason: "no seed context \u2014 included by default" };
77061
+ const GENERIC_TERMS = /* @__PURE__ */ new Set([
77062
+ "error",
77063
+ "errors",
77064
+ "err",
77065
+ "fail",
77066
+ "failed",
77067
+ "failing",
77068
+ "failure",
77069
+ "exception",
77070
+ "exceptions",
77071
+ "crash",
77072
+ "crashing",
77073
+ "issue",
77074
+ "problem",
77075
+ "slow",
77076
+ "timeout",
77077
+ "broken",
77078
+ "down",
77079
+ "production",
77080
+ "prod",
77081
+ "staging"
77082
+ ]);
77083
+ const domainTerms = seedTerms.filter((t) => !GENERIC_TERMS.has(t));
77084
+ if (domainTerms.length === 0) {
77085
+ return { relevanceClass: "direct", relevanceReason: "no specific seed context \u2014 included by default" };
77027
77086
  }
77028
77087
  const keyTokens = tokenize(sigKey);
77029
77088
  const serviceTokens = sigServices.flatMap((s) => tokenize(s));
77030
77089
  const combined = [...keyTokens, ...serviceTokens];
77031
- const matchingTerms = seedTerms.filter(
77090
+ const matchingTerms = domainTerms.filter(
77032
77091
  (t) => combined.some((c) => c.includes(t) || t.includes(c))
77033
77092
  );
77034
77093
  if (matchingTerms.length > 0) {
@@ -77108,6 +77167,40 @@ function looksDiffable(since) {
77108
77167
  if (/^\d+[smhd]$/i.test(s)) return false;
77109
77168
  return /^[A-Za-z0-9._/~^-]+$/.test(s);
77110
77169
  }
77170
+ function looksExplanatory(hint) {
77171
+ const h = hint.trim();
77172
+ if (h.length === 0) return false;
77173
+ if (/\b(how|what|why|when|where)\b.*\b(work|works|working|do|does|done|happen|happens|flow|flows|behave|called|invoked|triggered|used)\b/i.test(h)) {
77174
+ return true;
77175
+ }
77176
+ if (/^\s*(how|what|why|when|where|explain|describe|walk\s+me\s+through|tell\s+me\s+(?:about|how))\b/i.test(h)) {
77177
+ return true;
77178
+ }
77179
+ return false;
77180
+ }
77181
+ function formatRegressionCitation(recentChanges, changes) {
77182
+ const commits = (recentChanges?.commits ?? []).slice(0, 2);
77183
+ const commitParts = commits.map((c) => {
77184
+ const sha = c.shortSha || c.sha.slice(0, 7);
77185
+ const subject = (c.subject ?? "").replace(/\s+/g, " ").trim();
77186
+ return subject ? `${sha} "${subject.slice(0, 50)}"` : sha;
77187
+ });
77188
+ const commitClause = commitParts.length > 0 ? ` (${commitParts.join(", ")})` : "";
77189
+ const modified = changes?.modified ?? [];
77190
+ const symbolNames = [];
77191
+ for (const m of modified) {
77192
+ const name = m.after?.name ?? m.before?.name;
77193
+ if (name && !symbolNames.includes(name)) symbolNames.push(name);
77194
+ if (symbolNames.length >= 3) break;
77195
+ }
77196
+ let symbolClause = "";
77197
+ if (symbolNames.length > 0) {
77198
+ const shown = symbolNames.join(", ");
77199
+ const more = modified.length > symbolNames.length ? ", \u2026" : "";
77200
+ symbolClause = ` touched ${shown}${more}`;
77201
+ }
77202
+ return { commitClause, symbolClause };
77203
+ }
77111
77204
  async function investigate(input, deps) {
77112
77205
  const { code, db } = deps;
77113
77206
  const hint = input.hint.trim();
@@ -77129,11 +77222,31 @@ async function investigate(input, deps) {
77129
77222
  return ev;
77130
77223
  }
77131
77224
  const degradedNoSource = !code;
77225
+ let recentChanges;
77226
+ if (deps.repoPath && input.since) {
77227
+ try {
77228
+ recentChanges = await collectGitChanges({ repoPath: deps.repoPath, since: input.since });
77229
+ } catch {
77230
+ }
77231
+ }
77232
+ const changedFilePaths = recentChanges && recentChanges.changedFiles.length > 0 ? new Set(recentChanges.changedFiles) : void 0;
77132
77233
  const rawSeeds = code ? await code.searchSymbols(hint, 5) : [];
77133
77234
  const hintTokens = [...new Set(tokenize(hint))];
77134
- const ranked = rankSeeds(rawSeeds, hintTokens);
77235
+ const ranked = rankSeeds(rawSeeds, hintTokens, changedFilePaths);
77135
77236
  let seeds = ranked.map((r) => r.symbol);
77136
77237
  let top = seeds[0];
77238
+ if (top && code && isTypeLikeName(top.name)) {
77239
+ const base2 = executableBaseName(top.name);
77240
+ if (base2) {
77241
+ const altTop = rankSeeds(await code.searchSymbols(base2, 5), hintTokens).find(
77242
+ (r) => !isTypeLikeName(r.symbol.name)
77243
+ )?.symbol;
77244
+ if (altTop && altTop.id !== top.id) {
77245
+ seeds = [altTop, ...seeds.filter((s) => s.id !== altTop.id)];
77246
+ top = altTop;
77247
+ }
77248
+ }
77249
+ }
77137
77250
  let logReseed = null;
77138
77251
  if (!top && !degradedNoSource) {
77139
77252
  logReseed = await reseedFromLogs(hint, input, deps);
@@ -77142,6 +77255,50 @@ async function investigate(input, deps) {
77142
77255
  top = seeds[0];
77143
77256
  }
77144
77257
  }
77258
+ const seedHay = top ? `${top.name} ${top.filePath}`.toLowerCase() : "";
77259
+ const GENERIC_HINT_TOKENS = /* @__PURE__ */ new Set([
77260
+ "service",
77261
+ "services",
77262
+ "controller",
77263
+ "controllers",
77264
+ "resolver",
77265
+ "resolvers",
77266
+ "worker",
77267
+ "workers",
77268
+ "repository",
77269
+ "provider",
77270
+ "module",
77271
+ "manager",
77272
+ "util",
77273
+ "utils",
77274
+ "helper",
77275
+ "handler",
77276
+ "error",
77277
+ "errors",
77278
+ "exception",
77279
+ "exceptions",
77280
+ "fail",
77281
+ "failed",
77282
+ "failing",
77283
+ "failure",
77284
+ "throw",
77285
+ "throwing",
77286
+ "thrown",
77287
+ "fatal",
77288
+ "crash",
77289
+ "crashing",
77290
+ "timeout",
77291
+ "production",
77292
+ "prod",
77293
+ "staging",
77294
+ "application",
77295
+ "issue",
77296
+ "problem",
77297
+ "broken",
77298
+ "slow"
77299
+ ]);
77300
+ const meaningfulHintTokens = hintTokens.filter((t) => !GENERIC_HINT_TOKENS.has(t));
77301
+ const seedIsLowConfidence = top !== void 0 && logReseed === null && meaningfulHintTokens.length > 0 && !meaningfulHintTokens.some((t) => seedHay.includes(t));
77145
77302
  if (!top && !degradedNoSource) {
77146
77303
  const report2 = {
77147
77304
  id: globalThis.crypto.randomUUID(),
@@ -77166,13 +77323,6 @@ async function investigate(input, deps) {
77166
77323
  if (persistedId2) report2.id = persistedId2;
77167
77324
  return report2;
77168
77325
  }
77169
- let recentChanges;
77170
- if (deps.repoPath && input.since) {
77171
- try {
77172
- recentChanges = await collectGitChanges({ repoPath: deps.repoPath, since: input.since });
77173
- } catch {
77174
- }
77175
- }
77176
77326
  let label = input.service ?? hint;
77177
77327
  let seedLoc = "";
77178
77328
  let ctx = null;
@@ -77284,6 +77434,7 @@ async function investigate(input, deps) {
77284
77434
  const logEvIds = [];
77285
77435
  const directLogEvIds = [];
77286
77436
  const ambientLogEvIds = [];
77437
+ const directSignatures = [];
77287
77438
  const entityEvIds = [];
77288
77439
  const entitySummaries = [];
77289
77440
  let logsCollected = false;
@@ -77319,11 +77470,12 @@ async function investigate(input, deps) {
77319
77470
  }
77320
77471
  const svc = s.services.length > 0 ? ` \xB7 ${s.services.slice(0, 3).join(", ")}` : "";
77321
77472
  const tagStr = tags.length > 0 ? ` [${tags.join(", ")}]` : "";
77473
+ const msgStr = s.sampleMessage ? ` \u2014 "${s.sampleMessage.replace(/\s+/g, " ").trim().slice(0, 90)}"` : "";
77322
77474
  const ev = mkEv(
77323
77475
  "log",
77324
- `Error ${s.key}: ${s.count}x (first ${shortTs(s.firstSeen)}, last ${shortTs(s.lastSeen)})${svc}${tagStr}`.slice(
77476
+ `Error ${s.key}: ${s.count}x (first ${shortTs(s.firstSeen)}, last ${shortTs(s.lastSeen)})${svc}${tagStr}${msgStr}`.slice(
77325
77477
  0,
77326
- 180
77478
+ 220
77327
77479
  ),
77328
77480
  {
77329
77481
  signature: s.key,
@@ -77346,8 +77498,10 @@ async function investigate(input, deps) {
77346
77498
  if (s.isNew) ev.isNew = s.isNew;
77347
77499
  if (typeof s.ratio === "number" && Number.isFinite(s.ratio)) ev.ratio = s.ratio;
77348
77500
  logEvIds.push(ev.id);
77349
- if (relevanceClass === "direct") directLogEvIds.push(ev.id);
77350
- else ambientLogEvIds.push(ev.id);
77501
+ if (relevanceClass === "direct") {
77502
+ directLogEvIds.push(ev.id);
77503
+ directSignatures.push({ key: s.key, count: s.count, message: s.sampleMessage ?? "" });
77504
+ } else ambientLogEvIds.push(ev.id);
77351
77505
  }
77352
77506
  if (typeof deps.logs.aggregateErrors === "function") {
77353
77507
  const sigForEntities = analysis.signatures.find(
@@ -77400,6 +77554,8 @@ async function investigate(input, deps) {
77400
77554
  let stateAnalysis = null;
77401
77555
  const stateEvIds = [];
77402
77556
  const stateCollections = /* @__PURE__ */ new Set();
77557
+ const dataAnomalyEvIds = [];
77558
+ const dataAnomalyCollections = [];
77403
77559
  if (deps.mongo) {
77404
77560
  try {
77405
77561
  stateAnalysis = await deps.mongo.analyzeState();
@@ -77408,6 +77564,10 @@ async function investigate(input, deps) {
77408
77564
  const ev = mkEv("state", s.title, s.payload, {}, s.timestamp, s.relevance);
77409
77565
  stateEvIds.push(ev.id);
77410
77566
  stateCollections.add(s.collection);
77567
+ if (s.kind === "anomaly") {
77568
+ dataAnomalyEvIds.push(ev.id);
77569
+ dataAnomalyCollections.push(s.collection);
77570
+ }
77411
77571
  }
77412
77572
  } catch {
77413
77573
  stateAnalysis = null;
@@ -77434,6 +77594,7 @@ async function investigate(input, deps) {
77434
77594
  const queueRuntimeEvIdsByQueue = /* @__PURE__ */ new Map();
77435
77595
  const queueBacklogEvIdsByQueue = /* @__PURE__ */ new Map();
77436
77596
  const queueStarvationEvIdsByQueue = /* @__PURE__ */ new Map();
77597
+ const queueFailureSignals = [];
77437
77598
  if (deps.queue) {
77438
77599
  try {
77439
77600
  const staticNames = [...new Set(queueHits.map((e) => e.queueName))];
@@ -77457,6 +77618,18 @@ async function investigate(input, deps) {
77457
77618
  st.push(ev.id);
77458
77619
  queueStarvationEvIdsByQueue.set(s.queueName, st);
77459
77620
  }
77621
+ if (s.kind === "failed-breakdown") {
77622
+ const p = s.payload;
77623
+ if (typeof p.topReason === "string") {
77624
+ queueFailureSignals.push({
77625
+ reason: p.topReason,
77626
+ queueName: s.queueName,
77627
+ evId: ev.id,
77628
+ count: typeof p.topCount === "number" ? p.topCount : 0,
77629
+ stale: p.topStale === true
77630
+ });
77631
+ }
77632
+ }
77460
77633
  }
77461
77634
  } catch {
77462
77635
  queueRuntimeState = null;
@@ -77507,8 +77680,14 @@ async function investigate(input, deps) {
77507
77680
  const panelLower = f.panelTitle.toLowerCase();
77508
77681
  const labelVals = Object.values(f.labels).map((v) => v.toLowerCase());
77509
77682
  if (f.anomaly === "latency-spike" || f.anomaly === "error-rate-change") {
77510
- const relevant = serviceFilter.length > 0 && (panelLower.includes(serviceFilter) || labelVals.some((v) => v.includes(serviceFilter)));
77511
- if (relevant) latencyMetricEvIds.push(ev.id);
77683
+ const matchesService = serviceFilter.length > 0 && (panelLower.includes(serviceFilter) || labelVals.some((v) => v.includes(serviceFilter)));
77684
+ const hintIsPerf = /\b(latenc|slow|spike|perf|throughput|duration|p9[5-9]|timeout|response\s*time|degrad)/i.test(
77685
+ hint
77686
+ );
77687
+ const matchesHint = serviceFilter.length === 0 && hintIsPerf && hintTokens.some(
77688
+ (t) => t.length >= 4 && (panelLower.includes(t) || labelVals.some((v) => v.includes(t)))
77689
+ );
77690
+ if (matchesService || matchesHint) latencyMetricEvIds.push(ev.id);
77512
77691
  } else if (f.anomaly === "queue-growth") {
77513
77692
  const panelNorm = f.panelTitle.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
77514
77693
  const labelNorms = Object.values(f.labels).map(
@@ -77806,19 +77985,21 @@ async function investigate(input, deps) {
77806
77985
  const gitChangedFiles = recentChanges?.changedFiles ?? [];
77807
77986
  const seedFile = top?.filePath;
77808
77987
  const seedInChanges = seedFile !== void 0 && gitChangedFiles.length > 0 && gitChangedFiles.some((f) => f === seedFile || seedFile.endsWith(f) || f.endsWith(seedFile));
77988
+ const citation = formatRegressionCitation(recentChanges, changes);
77809
77989
  causeInputs.push({
77810
77990
  id: "cause:deployment-regression",
77811
- title: top ? `Recent change to ${top.name} in ${input.since}..HEAD may have introduced the regression` : `Recent changes in ${input.since}..HEAD may have introduced the regression`,
77991
+ title: top ? `Recent change${citation.commitClause} to ${top.name}${citation.symbolClause} in ${input.since}..HEAD may have introduced the regression` : `Recent change${citation.commitClause}${citation.symbolClause} in ${input.since}..HEAD may have introduced the regression`,
77812
77992
  category: "deployment-regression",
77813
77993
  sourceEvidenceIds: [changeEvId, ...seedEv ? [seedEv.id] : []],
77814
77994
  baseScore: clamp015((seedInChanges ? 0.45 : 0.25) + (queueHits.length > 0 ? 0.05 : 0)),
77815
77995
  metadata: { blastRadius }
77816
77996
  });
77817
77997
  }
77818
- if (top && impactEv && seedEv && impact && impact.affected > 0) {
77998
+ const MIN_FANOUT_FOR_CAUSE = 3;
77999
+ if (top && impactEv && seedEv && impact && impact.affected >= MIN_FANOUT_FOR_CAUSE) {
77819
78000
  causeInputs.push({
77820
78001
  id: "cause:blast-radius",
77821
- title: `${top.name} sits on a high-fan-out path (${impact.affected} affected) and may propagate the fault`,
78002
+ title: `${top.name} has wide code reach (${impact.affected} dependent symbols) and may propagate the fault`,
77822
78003
  category: "blast-radius",
77823
78004
  sourceEvidenceIds: [impactEv.id, seedEv.id],
77824
78005
  baseScore: clamp015(0.15 + (queueHits.length > 0 ? 0.05 : 0)),
@@ -77828,16 +78009,62 @@ async function investigate(input, deps) {
77828
78009
  if (analysis !== null && analysis.signatures.length > 0 && queueHits.length > 0 && directLogEvIds.length > 0) {
77829
78010
  const firstQueue = queueHits[0];
77830
78011
  const queueLabel = firstQueue !== void 0 ? `"${firstQueue.queueName}" (${firstQueue.producerSymbol ?? "unknown"} -> ${firstQueue.workerSymbol ?? "unknown"})` : "the queue path";
77831
- const topSig = analysis.signatures[0];
78012
+ const directTotal = directSignatures.reduce((sum, d) => sum + d.count, 0);
78013
+ const directTop = directSignatures[0];
77832
78014
  causeInputs.push({
77833
78015
  id: "cause:error-correlation",
77834
- title: `Runtime errors (${analysis.totalErrors}${topSig ? `, top ${topSig.key}` : ""}) correlate with the implicated queue path ${queueLabel}`,
78016
+ title: `Runtime errors (${directTotal}${directTop ? `, top ${directTop.key}` : ""}) directly linked to the implicated queue path ${queueLabel}`,
77835
78017
  category: "error-correlation",
77836
78018
  sourceEvidenceIds: directLogEvIds.slice(0, 3),
77837
78019
  baseScore: 0.3,
77838
78020
  metadata: { blastRadius }
77839
78021
  });
77840
78022
  }
78023
+ const INFRA_RE = /\b(ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ECONNRESET|getaddrinfo|socket hang up|connection (?:refused|reset|timed out)|network (?:error|timeout)|DNS)\b/i;
78024
+ const infraSig = directSignatures.find((d) => INFRA_RE.test(d.message));
78025
+ const queueHitNames = new Set(queueHits.map((e) => e.queueName.toLowerCase()));
78026
+ const infraQueue = queueFailureSignals.find((q) => {
78027
+ if (!INFRA_RE.test(q.reason)) return false;
78028
+ if (queueHitNames.has(q.queueName.toLowerCase())) return true;
78029
+ const qTokens = new Set(tokenize(q.queueName));
78030
+ return hintTokens.some((t) => t.length >= 3 && qTokens.has(t));
78031
+ });
78032
+ if (infraSig) {
78033
+ causeInputs.push({
78034
+ id: "cause:dependency-failure",
78035
+ title: `Dependency/network failure: ${infraSig.count}x ${infraSig.key} \u2014 "${infraSig.message.replace(/\s+/g, " ").trim().slice(0, 100)}"`,
78036
+ category: "infrastructure",
78037
+ sourceEvidenceIds: directLogEvIds.slice(0, 3),
78038
+ baseScore: 0.5,
78039
+ metadata: { blastRadius }
78040
+ });
78041
+ } else if (infraQueue) {
78042
+ causeInputs.push({
78043
+ id: "cause:dependency-failure",
78044
+ title: `Dependency/network failure on queue ${infraQueue.queueName}: ${infraQueue.count} failed job(s) \u2014 "${infraQueue.reason.replace(/\s+/g, " ").trim().slice(0, 100)}"${infraQueue.stale ? " (stale \u2014 reflects a past run)" : ""}`,
78045
+ category: "infrastructure",
78046
+ sourceEvidenceIds: [infraQueue.evId],
78047
+ // A stale-only queue failure is weaker than a live error, but still the best
78048
+ // explanation — and far better than a co-occurring stale-collection guess.
78049
+ baseScore: infraQueue.stale ? 0.34 : 0.5,
78050
+ metadata: { blastRadius }
78051
+ });
78052
+ }
78053
+ if (dataAnomalyEvIds.length > 0) {
78054
+ const collections = [...new Set(dataAnomalyCollections)];
78055
+ const example = collections[0];
78056
+ causeInputs.push({
78057
+ id: "cause:data-state-anomaly",
78058
+ // Co-occurring, NOT asserted as the root cause: the stale collection is selected by
78059
+ // hint relevance, not proven reachable from the seed's call graph, so claiming it is
78060
+ // the cause mislead on e.g. a Shopify-fulfillment error citing unrelated gaia sync logs.
78061
+ title: `Data-state anomaly (co-occurring): ${collections.length} collection(s) hold records in a failed/stale state${example ? ` (e.g. ${example})` : ""} \u2014 verify whether it relates to this failure`,
78062
+ category: "data-state-anomaly",
78063
+ sourceEvidenceIds: dataAnomalyEvIds.slice(0, 3),
78064
+ baseScore: 0.22,
78065
+ metadata: { blastRadius }
78066
+ });
78067
+ }
77841
78068
  if (latencyMetricEvIds.length > 0) {
77842
78069
  causeInputs.push({
77843
78070
  id: "cause:metric-latency",
@@ -77876,20 +78103,34 @@ async function investigate(input, deps) {
77876
78103
  evidence2,
77877
78104
  ambientLogEvIds.length > 0 ? new Set(ambientLogEvIds) : void 0
77878
78105
  );
77879
- const seedResolved = seeds.length > 0 ? 1 : 0;
78106
+ const seedResolved = seeds.length === 0 ? 0 : seedIsLowConfidence ? 0.3 : 1;
77880
78107
  const confidence = clamp015(0.5 * evidenceConfidence + 0.5 * seedResolved);
77881
78108
  const area = ctx?.community?.name ?? top?.filePath ?? (input.service ?? "runtime evidence");
77882
78109
  const topCause = rankedCauses[0];
78110
+ const headlineCause = topCause && topCause.finalScore >= 0.2 ? topCause : void 0;
77883
78111
  const banner2 = degradedNoSource ? "Runtime-only (source intelligence unavailable). " : "";
77884
78112
  const scope = top ? `resolved to ${label} (${area})` : `over runtime evidence (${area})`;
77885
- const summary = topCause ? `${banner2}Investigation of "${hint}" ${scope}. Top suspected cause: ${topCause.title}.` : `${banner2}Investigation of "${hint}" ${scope}. No dominant suspected cause emerged from the available ${degradedNoSource ? "runtime" : "structural"} evidence.`;
78113
+ const seedDisclaimer = seedIsLowConfidence && top ? `\u26A0 No symbol closely matched "${hint}" \u2014 "${top.name}" is a low-confidence closest match (semantic). Refine with an exact symbol or error code to target precisely. ` : "";
78114
+ let summary = headlineCause ? `${seedDisclaimer}${banner2}Investigation of "${hint}" ${scope}. Top suspected cause: ${headlineCause.title}.` : `${seedDisclaimer}${banner2}Investigation of "${hint}" ${scope}. No dominant suspected cause emerged from the available ${degradedNoSource ? "runtime" : "structural"} evidence.`;
78115
+ const hasIncidentSignal = directLogEvIds.length > 0 || latencyMetricEvIds.length > 0 || queueMetricEvIds.length > 0 || dataAnomalyEvIds.length > 0 || queueFailureSignals.length > 0 || findings2.some((f) => f.kind === "anomaly") || headlineCause !== void 0;
78116
+ const explanatoryHint = looksExplanatory(hint) && !hasIncidentSignal;
78117
+ if (explanatoryHint) {
78118
+ const explainTarget = top?.name ?? input.service ?? hint;
78119
+ summary = `${seedDisclaimer}${banner2}"${hint}" reads as a "how does it work" question and no incident signal was found (no error signatures, anomalies, or failing queues). For a focused answer, run \`horus explain ${explainTarget}\`. ${top ? `Investigated ${label} (${area}); the` : "The"} incident sections below are empty by design \u2014 this is not a fault hunt.`;
78120
+ }
77886
78121
  const nextActions = buildNextActions(top, ctx, impact, queueHits, changes, input);
78122
+ if (explanatoryHint) {
78123
+ const explainTarget = top?.name ?? input.service ?? hint;
78124
+ nextActions.unshift(
78125
+ `This looks like a behavioral question \u2014 run \`horus explain ${explainTarget}\` for a focused walkthrough of how it works.`
78126
+ );
78127
+ }
77887
78128
  if (degradedNoSource) {
77888
78129
  nextActions.unshift(
77889
78130
  "Source intelligence was unavailable \u2014 run `horus index` to enable code-aware analysis, then re-run for a full investigation."
77890
78131
  );
77891
78132
  }
77892
- if (ownershipEstimate?.likelyMaintainer) {
78133
+ if (ownershipEstimate?.likelyMaintainer && !seedIsLowConfidence) {
77893
78134
  nextActions.unshift(
77894
78135
  `Route to likely maintainer: ${ownershipEstimate.likelyMaintainer} (${Math.round(ownershipEstimate.maintainerShare * 100)}% of commits to ${ownershipEstimate.file ?? top?.filePath ?? "the implicated file"})`
77895
78136
  );
@@ -77939,6 +78180,9 @@ async function investigate(input, deps) {
77939
78180
  const gapAnalysis = detectMissingEvidence(report, connectorFlags);
77940
78181
  report.gapAnalysis = gapAnalysis;
77941
78182
  report.confidence = Math.min(report.confidence, gapAnalysis.confidenceCeiling);
78183
+ if (seedIsLowConfidence) report.confidence = Math.min(report.confidence, 0.45);
78184
+ const topCauseScore = report.suspectedCauses[0]?.finalScore ?? 0;
78185
+ report.confidence = Math.min(report.confidence, confidenceCeilingForCause(topCauseScore));
77942
78186
  report.nextActions.push(...gapNextActions(gapAnalysis.gaps));
77943
78187
  report.sourceStatus = buildRuntimeSourceStatus(evidence2, connectorFlags);
77944
78188
  const persistedId = await persist(db, input, report);
@@ -79289,14 +79533,17 @@ function refineInvestigation(r, directive) {
79289
79533
  }
79290
79534
  }
79291
79535
  const recognizedList = ALL_TOPICS.join(", ");
79292
- const note2 = "No specific topic directive recognized. Recognized topics: " + recognizedList + '. Example usage: "focus on queue behavior", "ignore deployment changes".';
79536
+ const looksLikeQuestion = /\?|\b(where|which|what|how|why|when|does|do|is|are|can|should)\b/i.test(
79537
+ directive
79538
+ );
79539
+ const note2 = looksLikeQuestion ? `This question can't be answered from the saved evidence \u2014 \`horus ask\` reuses the report and does not run a new source/runtime lookup. For a fresh answer run: horus investigate "${directive.slice(0, 80)}". (To filter the saved evidence instead, use a topic: ${recognizedList}.)` : "No specific topic directive recognized. Recognized topics: " + recognizedList + '. Example usage: "focus on queue behavior", "ignore deployment changes".';
79293
79540
  return {
79294
79541
  directive,
79295
79542
  mode: "none",
79296
79543
  topics,
79297
79544
  hypotheses: r.hypotheses,
79298
79545
  suspectedCauses: r.suspectedCauses,
79299
- evidence: r.evidence,
79546
+ evidence: looksLikeQuestion ? [] : r.evidence,
79300
79547
  note: note2
79301
79548
  };
79302
79549
  }
@@ -81012,24 +81259,98 @@ var PROCESSOR_RE = /@Processor\(\s*['"]([^'"]+)['"][^)]*\)\s*(?:@\w+[^\n]*\s*)*e
81012
81259
  var CONST_DECL_RE = /(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*['"]([^'"]+)['"]/g;
81013
81260
  var NEW_BULL_RE = /(?:(\w+)\s*[:=]\s*)?new\s+(Queue|Worker)\s*(?:<[^>]*>)?\s*\(\s*(['"][^'"]+['"]|[A-Za-z_$][\w$.]*)/g;
81014
81261
  var QUEUE_NAME_CONST_RE = /(?:export\s+)?const\s+([A-Za-z_$][\w$]*(?:QUEUE|Queue)[\w$]*)\s*=\s*['"]([^'"]+)['"]/g;
81262
+ var ENUM_DECL_RE = /(?:export\s+)?(?:const\s+)?enum\s+([A-Za-z_$][\w$]*)\s*\{([^}]*)\}/g;
81263
+ var ENUM_MEMBER_RE = /([A-Za-z_$][\w$]*)\s*(?:=\s*(['"])([^'"]*)\2)?/g;
81264
+ var OBJECT_VALUES_LOOP_RE = /for\s*\(\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s+of\s+Object\.values\(\s*([A-Za-z_$][\w$]*)\s*\)/g;
81265
+ var DISPATCH_ENTRY_RE = /\[\s*([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)\s*\]\s*:\s*([^,}]+)/g;
81015
81266
  function baseName(filePath) {
81016
81267
  const parts = filePath.split("/");
81017
81268
  const last = parts[parts.length - 1] ?? filePath;
81018
81269
  return last.replace(/\.[jt]sx?$/, "");
81019
81270
  }
81020
- function resolveArg(raw, constMap) {
81271
+ function buildEnumMap(contents) {
81272
+ const enums = /* @__PURE__ */ new Map();
81273
+ for (const content of contents) {
81274
+ for (const decl of content.matchAll(ENUM_DECL_RE)) {
81275
+ const enumName = decl[1];
81276
+ const body = decl[2] ?? "";
81277
+ if (!enumName || enums.has(enumName)) continue;
81278
+ const members = [];
81279
+ for (const m of body.matchAll(ENUM_MEMBER_RE)) {
81280
+ const member = m[1];
81281
+ if (!member) continue;
81282
+ const value = m[3] !== void 0 ? m[3] : member;
81283
+ members.push({ member, value });
81284
+ }
81285
+ if (members.length) enums.set(enumName, members);
81286
+ }
81287
+ }
81288
+ return enums;
81289
+ }
81290
+ function resolveEnumMember(ref, enums) {
81291
+ const dot = ref.indexOf(".");
81292
+ if (dot < 0) return null;
81293
+ const enumName = ref.slice(0, dot);
81294
+ const member = ref.slice(dot + 1);
81295
+ const members = enums.get(enumName);
81296
+ if (!members) return null;
81297
+ return members.find((e) => e.member === member)?.value ?? null;
81298
+ }
81299
+ function resolveArg(raw, constMap, enums, loopVarEnums) {
81021
81300
  const arg = raw.trim();
81022
81301
  if (arg.startsWith('"') || arg.startsWith("'")) {
81023
- return arg.slice(1, -1);
81302
+ return [arg.slice(1, -1)];
81303
+ }
81304
+ if (arg.includes(".")) {
81305
+ const value = resolveEnumMember(arg, enums);
81306
+ return value !== null ? [value] : [];
81307
+ }
81308
+ const loopEnum = loopVarEnums.get(arg);
81309
+ if (loopEnum) {
81310
+ const members = enums.get(loopEnum);
81311
+ if (members) return members.map((e) => e.value);
81312
+ }
81313
+ const constValue = constMap.get(arg);
81314
+ return constValue !== void 0 ? [constValue] : [];
81315
+ }
81316
+ function loopVarEnumsFor(content) {
81317
+ const map2 = /* @__PURE__ */ new Map();
81318
+ for (const m of content.matchAll(OBJECT_VALUES_LOOP_RE)) {
81319
+ const loopVar = m[1];
81320
+ const enumName = m[2];
81321
+ if (loopVar && enumName) map2.set(loopVar, enumName);
81024
81322
  }
81025
- return constMap.get(arg) ?? null;
81323
+ return map2;
81324
+ }
81325
+ function extractDispatchHandlers(content, enums) {
81326
+ const out = [];
81327
+ for (const m of content.matchAll(DISPATCH_ENTRY_RE)) {
81328
+ const enumName = m[1];
81329
+ const member = m[2];
81330
+ const rhs = (m[3] ?? "").trim();
81331
+ if (!enumName || !member) continue;
81332
+ const value = resolveEnumMember(`${enumName}.${member}`, enums);
81333
+ if (value === null) continue;
81334
+ const handler = handlerSymbolFromExpr(rhs);
81335
+ if (handler) out.push({ value, handler });
81336
+ }
81337
+ return out;
81338
+ }
81339
+ function handlerSymbolFromExpr(expr) {
81340
+ const call = expr.match(/([A-Za-z_$][\w$]*)\s*\(/);
81341
+ if (call?.[1]) return call[1];
81342
+ const member = expr.match(/(?:[A-Za-z_$][\w$]*\.)+([A-Za-z_$][\w$]*)/);
81343
+ if (member?.[1]) return member[1];
81344
+ const ident = expr.match(/^([A-Za-z_$][\w$]*)$/);
81345
+ if (ident?.[1]) return ident[1];
81346
+ return null;
81026
81347
  }
81027
81348
  function extractQueueGraph(input) {
81028
- const constMap = /* @__PURE__ */ new Map();
81029
81349
  const allContent = [
81030
81350
  ...input.producerClasses.map((p) => p.content),
81031
81351
  ...input.workerFiles.map((w) => w.content)
81032
81352
  ];
81353
+ const constMap = /* @__PURE__ */ new Map();
81033
81354
  for (const content of allContent) {
81034
81355
  for (const m of content.matchAll(CONST_DECL_RE)) {
81035
81356
  const ident = m[1];
@@ -81037,17 +81358,30 @@ function extractQueueGraph(input) {
81037
81358
  if (ident && value && !constMap.has(ident)) constMap.set(ident, value);
81038
81359
  }
81039
81360
  }
81361
+ const enums = buildEnumMap(allContent);
81362
+ const dispatchHandlers = [];
81363
+ for (const unit of [...input.producerClasses, ...input.workerFiles]) {
81364
+ const file = "filePath" in unit ? unit.filePath : "";
81365
+ for (const h of extractDispatchHandlers(unit.content, enums)) {
81366
+ dispatchHandlers.push({ value: h.value, handler: h.handler, file });
81367
+ }
81368
+ }
81040
81369
  const producers = [];
81041
81370
  for (const pc47 of input.producerClasses) {
81371
+ const loopVarEnums = loopVarEnumsFor(pc47.content);
81042
81372
  for (const m of pc47.content.matchAll(INJECT_QUEUE_RE)) {
81043
81373
  const queue = m[1] ?? "";
81044
81374
  if (queue) producers.push({ queue, symbol: pc47.name, file: pc47.filePath });
81045
81375
  }
81046
81376
  for (const m of pc47.content.matchAll(NEW_BULL_RE)) {
81047
81377
  if (m[2] !== "Queue") continue;
81048
- const queue = resolveArg(m[3] ?? "", constMap);
81049
- if (queue) {
81050
- producers.push({ queue, symbol: (m[1] ?? pc47.name) || baseName(pc47.filePath), file: pc47.filePath });
81378
+ const queues2 = resolveArg(m[3] ?? "", constMap, enums, loopVarEnums);
81379
+ for (const queue of queues2) {
81380
+ producers.push({
81381
+ queue,
81382
+ symbol: (m[1] ?? pc47.name) || baseName(pc47.filePath),
81383
+ file: pc47.filePath
81384
+ });
81051
81385
  }
81052
81386
  }
81053
81387
  for (const m of pc47.content.matchAll(QUEUE_NAME_CONST_RE)) {
@@ -81058,6 +81392,7 @@ function extractQueueGraph(input) {
81058
81392
  }
81059
81393
  const workers = [];
81060
81394
  for (const wf of input.workerFiles) {
81395
+ const loopVarEnums = loopVarEnumsFor(wf.content);
81061
81396
  for (const m of wf.content.matchAll(PROCESSOR_RE)) {
81062
81397
  const queue = m[1] ?? "";
81063
81398
  const className = m[2] ?? "";
@@ -81065,17 +81400,24 @@ function extractQueueGraph(input) {
81065
81400
  }
81066
81401
  for (const m of wf.content.matchAll(NEW_BULL_RE)) {
81067
81402
  if (m[2] !== "Worker") continue;
81068
- const queue = resolveArg(m[3] ?? "", constMap);
81069
- if (queue) {
81070
- workers.push({ queue, symbol: m[1] ?? baseName(wf.filePath), file: wf.filePath });
81403
+ const queues2 = resolveArg(m[3] ?? "", constMap, enums, loopVarEnums);
81404
+ for (const queue of queues2) {
81405
+ workers.push({
81406
+ queue,
81407
+ symbol: m[1] ?? baseName(wf.filePath),
81408
+ file: wf.filePath
81409
+ });
81071
81410
  }
81072
81411
  }
81073
81412
  }
81413
+ for (const h of dispatchHandlers) {
81414
+ workers.push({ queue: h.value, symbol: h.handler, file: h.file });
81415
+ }
81074
81416
  const queues = [...new Set([...producers, ...workers].map((r) => r.queue))].sort();
81075
81417
  const edges = [];
81076
81418
  for (const q of queues) {
81077
- const P = dedupeByFile(producers.filter((p) => p.queue === q));
81078
- const W = dedupeByFile(workers.filter((w) => w.queue === q));
81419
+ const P = dedupeBySymbolFile(producers.filter((p) => p.queue === q));
81420
+ const W = dedupeBySymbolFile(workers.filter((w) => w.queue === q));
81079
81421
  if (P.length && W.length) {
81080
81422
  for (const p of P) {
81081
81423
  for (const w of W) {
@@ -81112,12 +81454,13 @@ function extractQueueGraph(input) {
81112
81454
  }
81113
81455
  return { queues, producers, workers, edges };
81114
81456
  }
81115
- function dedupeByFile(rows) {
81457
+ function dedupeBySymbolFile(rows) {
81116
81458
  const seen = /* @__PURE__ */ new Set();
81117
81459
  const out = [];
81118
81460
  for (const r of rows) {
81119
- if (seen.has(r.file)) continue;
81120
- seen.add(r.file);
81461
+ const key = `${r.symbol}\0${r.file}`;
81462
+ if (seen.has(key)) continue;
81463
+ seen.add(key);
81121
81464
  out.push(r);
81122
81465
  }
81123
81466
  return out;
@@ -81128,7 +81471,7 @@ init_cjs_shims();
81128
81471
  async function stitch(client, db, opts = {}) {
81129
81472
  const producerRows = (await client.cypher(
81130
81473
  // "new Queue" (no paren) so generic-typed `new Queue<T>(...)` also matches.
81131
- 'MATCH (n) WHERE n.content CONTAINS "@InjectQueue(" OR n.content CONTAINS "new Queue" OR n.content CONTAINS "QUEUE_NAME" OR n.content CONTAINS "QueueName" RETURN n.name, n.file_path, n.content'
81474
+ 'MATCH (n) WHERE n.content CONTAINS "@InjectQueue(" OR n.content CONTAINS "new Queue" OR n.content CONTAINS "QUEUE_NAME" OR n.content CONTAINS "QueueName" OR n.content CONTAINS "enum " OR n.content CONTAINS "Object.values(" OR n.content CONTAINS "]: (" RETURN n.name, n.file_path, n.content'
81132
81475
  )).rows;
81133
81476
  const producerClasses = producerRows.map((r) => ({
81134
81477
  name: String(r[0] ?? ""),
@@ -93528,10 +93871,11 @@ async function runQueues(name, opts) {
93528
93871
  }
93529
93872
  }
93530
93873
  const rows = await listQueueEdges(db, { project, queueName: name });
93874
+ const showLive = opts.live || name !== void 0 && rows.length === 0;
93531
93875
  if (opts.json) {
93532
93876
  const topology = topologyToJson(buildQueueMap(rows));
93533
93877
  const out = { project: project ?? null, topology };
93534
- if (opts.live) out["live"] = await gatherLiveState(config2, rows, name);
93878
+ if (showLive) out["live"] = await gatherLiveState(config2, rows, name);
93535
93879
  console.log(JSON.stringify(out, null, 2));
93536
93880
  return 0;
93537
93881
  }
@@ -93540,13 +93884,15 @@ async function runQueues(name, opts) {
93540
93884
  );
93541
93885
  console.log("");
93542
93886
  if (rows.length === 0) {
93543
- console.log(import_picocolors7.default.dim(" No queue edges indexed. Run: horus index"));
93887
+ console.log(
93888
+ name !== void 0 ? import_picocolors7.default.dim(` No static topology for "${name}" \u2014 it may be dynamically registered; showing live state below.`) : import_picocolors7.default.dim(" No queue edges indexed. Run: horus index")
93889
+ );
93544
93890
  } else {
93545
93891
  const byQueue = buildQueueMap(rows);
93546
93892
  printTopology(byQueue);
93547
93893
  }
93548
93894
  console.log("");
93549
- if (opts.live) {
93895
+ if (showLive) {
93550
93896
  await runLiveMode(config2, rows, name, opts.ai ? {
93551
93897
  config: opts.config,
93552
93898
  aiModel: opts.aiModel,
@@ -94513,7 +94859,9 @@ async function runInvestigate(hint, opts) {
94513
94859
  if (cloudActive && cloudSession && cloudCfg) {
94514
94860
  try {
94515
94861
  const refs = await uploadInvestigationToCloud(cloudSession.client, cloudCfg, report);
94516
- console.log(import_picocolors9.default.dim(`[cloud] investigation saved: ${refs.investigationId}`));
94862
+ const cloudNote = import_picocolors9.default.dim(`[cloud] investigation saved: ${refs.investigationId}`);
94863
+ if (format === "json") console.error(cloudNote);
94864
+ else console.log(cloudNote);
94517
94865
  } catch (err) {
94518
94866
  return reportCloudError(err);
94519
94867
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@merittdev/horus",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Local-first, source-aware production-incident investigation engine",
5
5
  "type": "module",
6
6
  "bin": {