@neat.is/core 0.2.7 → 0.2.9

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/cli.js CHANGED
@@ -1,43 +1,43 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- ProjectNameCollisionError,
4
- addProject,
5
- listProjects,
6
- removeProject,
7
- setStatus
8
- } from "./chunk-WX55TLUT.js";
9
2
  import {
10
3
  buildSearchIndex
11
4
  } from "./chunk-XOOCA5T7.js";
12
5
  import {
13
6
  buildApi
14
- } from "./chunk-T2U4U256.js";
7
+ } from "./chunk-5KX7EI4F.js";
15
8
  import {
16
9
  DEFAULT_PROJECT,
17
10
  PolicyViolationsLog,
11
+ ProjectNameCollisionError,
18
12
  Projects,
19
13
  addCallEdges,
20
14
  addConfigNodes,
21
15
  addDatabasesAndCompat,
22
16
  addInfra,
17
+ addProject,
23
18
  addServiceAliases,
24
19
  addServiceNodes,
20
+ attachGraphToEventBus,
25
21
  discoverServices,
22
+ emitNeatEvent,
26
23
  ensureCompatLoaded,
27
24
  evaluateAllPolicies,
28
25
  extractFromDirectory,
29
26
  getGraph,
27
+ listProjects,
30
28
  loadGraphFromDisk,
31
29
  loadPolicyFile,
32
30
  makeErrorSpanWriter,
33
31
  makeSpanHandler,
34
32
  pathsForProject,
35
33
  promoteFrontierNodes,
34
+ removeProject,
36
35
  resetGraph,
37
36
  saveGraphToDisk,
37
+ setStatus,
38
38
  startPersistLoop,
39
39
  startStalenessLoop
40
- } from "./chunk-6SFEITLJ.js";
40
+ } from "./chunk-IRPH6KL4.js";
41
41
  import {
42
42
  buildOtelReceiver,
43
43
  startOtelGrpcReceiver
@@ -101,7 +101,8 @@ function classifyChange(relPath) {
101
101
  }
102
102
  return phases;
103
103
  }
104
- async function runExtractPhases(graph, scanPath, phases) {
104
+ async function runExtractPhases(graph, scanPath, phases, project = DEFAULT_PROJECT) {
105
+ void project;
105
106
  const started = Date.now();
106
107
  await ensureCompatLoaded();
107
108
  const services = await discoverServices(scanPath);
@@ -157,7 +158,9 @@ function shouldIgnore(absPath) {
157
158
  }
158
159
  async function startWatch(graph, opts) {
159
160
  const debounceMs = opts.debounceMs ?? 1e3;
161
+ const projectName = opts.project ?? DEFAULT_PROJECT;
160
162
  await loadGraphFromDisk(graph, opts.outPath);
163
+ const detachEventBus = attachGraphToEventBus(graph, { project: projectName });
161
164
  const policyFilePath = path.join(opts.scanPath, "policy.json");
162
165
  const policyViolationsPath = path.join(path.dirname(opts.outPath), "policy-violations.ndjson");
163
166
  let policies = [];
@@ -169,7 +172,7 @@ async function startWatch(graph, opts) {
169
172
  } catch (err) {
170
173
  console.warn(`policies: failed to load ${policyFilePath} \u2014 ${err.message}`);
171
174
  }
172
- const policyLog = new PolicyViolationsLog(policyViolationsPath);
175
+ const policyLog = new PolicyViolationsLog(policyViolationsPath, projectName);
173
176
  const onPolicyTrigger = async (g) => {
174
177
  if (policies.length === 0) return;
175
178
  try {
@@ -179,14 +182,30 @@ async function startWatch(graph, opts) {
179
182
  console.warn(`policies: evaluation failed \u2014 ${err.message}`);
180
183
  }
181
184
  };
182
- const initial = await runExtractPhases(graph, opts.scanPath, new Set(ALL_PHASES));
185
+ const initial = await runExtractPhases(
186
+ graph,
187
+ opts.scanPath,
188
+ new Set(ALL_PHASES),
189
+ projectName
190
+ );
183
191
  console.log(
184
192
  `extract: ${initial.nodesAdded} new nodes, ${initial.edgesAdded} new edges (graph total ${graph.order}/${graph.size})`
185
193
  );
194
+ emitNeatEvent({
195
+ type: "extraction-complete",
196
+ project: projectName,
197
+ payload: {
198
+ project: projectName,
199
+ fileCount: 0,
200
+ nodesAdded: initial.nodesAdded,
201
+ edgesAdded: initial.edgesAdded
202
+ }
203
+ });
186
204
  await onPolicyTrigger(graph);
187
205
  const stopPersist = startPersistLoop(graph, opts.outPath);
188
206
  const stopStaleness = startStalenessLoop(graph, {
189
207
  staleEventsPath: opts.staleEventsPath,
208
+ project: projectName,
190
209
  onPolicyTrigger
191
210
  });
192
211
  const host = opts.host ?? "0.0.0.0";
@@ -202,7 +221,6 @@ async function startWatch(graph, opts) {
202
221
  `semantic_search: index build failed (${err.message}); falling back to inline substring`
203
222
  );
204
223
  }
205
- const projectName = opts.project ?? DEFAULT_PROJECT;
206
224
  const registry = new Projects();
207
225
  registry.set(projectName, {
208
226
  graph,
@@ -227,6 +245,7 @@ async function startWatch(graph, opts) {
227
245
  const onSpan = makeSpanHandler({
228
246
  graph,
229
247
  errorsPath: opts.errorsPath,
248
+ project: projectName,
230
249
  writeErrorEventInline: false,
231
250
  onPolicyTrigger
232
251
  });
@@ -240,6 +259,7 @@ async function startWatch(graph, opts) {
240
259
  const onSpanGrpc = makeSpanHandler({
241
260
  graph,
242
261
  errorsPath: opts.errorsPath,
262
+ project: projectName,
243
263
  onPolicyTrigger
244
264
  });
245
265
  const r = await startOtelGrpcReceiver({ onSpan: onSpanGrpc, host, port: grpcPort });
@@ -259,10 +279,20 @@ async function startWatch(graph, opts) {
259
279
  try {
260
280
  let retired = 0;
261
281
  for (const p of paths) retired += retireEdgesByFile(graph, p);
262
- const result = await runExtractPhases(graph, opts.scanPath, phases);
282
+ const result = await runExtractPhases(graph, opts.scanPath, phases, projectName);
263
283
  console.log(
264
284
  `[watch] re-extract phases=${result.phases.join(",")} retired=${retired} +${result.nodesAdded}n/+${result.edgesAdded}e in ${result.durationMs}ms`
265
285
  );
286
+ emitNeatEvent({
287
+ type: "extraction-complete",
288
+ project: projectName,
289
+ payload: {
290
+ project: projectName,
291
+ fileCount: paths.size,
292
+ nodesAdded: result.nodesAdded,
293
+ edgesAdded: result.edgesAdded
294
+ }
295
+ });
266
296
  if (searchIndex) {
267
297
  try {
268
298
  await searchIndex.refresh(graph);
@@ -321,6 +351,7 @@ async function startWatch(graph, opts) {
321
351
  await watcher.close();
322
352
  stopStaleness();
323
353
  stopPersist();
354
+ detachEventBus();
324
355
  await api.close();
325
356
  await otelHttp.close();
326
357
  if (grpcReceiver) await grpcReceiver.stop();
@@ -722,11 +753,456 @@ function renderPatch(sections) {
722
753
  return lines.join("\n");
723
754
  }
724
755
 
756
+ // src/cli-client.ts
757
+ import { Provenance as Provenance2 } from "@neat.is/types";
758
+ var HttpError = class extends Error {
759
+ constructor(status, message, responseBody = "") {
760
+ super(message);
761
+ this.status = status;
762
+ this.responseBody = responseBody;
763
+ this.name = "HttpError";
764
+ }
765
+ status;
766
+ responseBody;
767
+ };
768
+ var TransportError = class extends Error {
769
+ constructor(message) {
770
+ super(message);
771
+ this.name = "TransportError";
772
+ }
773
+ };
774
+ function createHttpClient(baseUrl) {
775
+ const root = baseUrl.replace(/\/$/, "");
776
+ return {
777
+ async get(path5) {
778
+ let res;
779
+ try {
780
+ res = await fetch(`${root}${path5}`);
781
+ } catch (err) {
782
+ throw new TransportError(
783
+ `cannot reach neat-core at ${root}: ${err.message}`
784
+ );
785
+ }
786
+ if (!res.ok) {
787
+ const body = await res.text().catch(() => "");
788
+ throw new HttpError(
789
+ res.status,
790
+ `${res.status} ${res.statusText} on GET ${path5}: ${body}`,
791
+ body
792
+ );
793
+ }
794
+ return await res.json();
795
+ },
796
+ async post(path5, body) {
797
+ let res;
798
+ try {
799
+ res = await fetch(`${root}${path5}`, {
800
+ method: "POST",
801
+ headers: { "content-type": "application/json" },
802
+ body: JSON.stringify(body)
803
+ });
804
+ } catch (err) {
805
+ throw new TransportError(
806
+ `cannot reach neat-core at ${root}: ${err.message}`
807
+ );
808
+ }
809
+ if (!res.ok) {
810
+ const text = await res.text().catch(() => "");
811
+ throw new HttpError(
812
+ res.status,
813
+ `${res.status} ${res.statusText} on POST ${path5}: ${text}`,
814
+ text
815
+ );
816
+ }
817
+ return await res.json();
818
+ }
819
+ };
820
+ }
821
+ function projectPath(project, suffix) {
822
+ if (!project) return suffix;
823
+ return `/projects/${encodeURIComponent(project)}${suffix}`;
824
+ }
825
+ async function runRootCause(client, input) {
826
+ const qs = input.errorId ? `?errorId=${encodeURIComponent(input.errorId)}` : "";
827
+ const path5 = projectPath(
828
+ input.project,
829
+ `/traverse/root-cause/${encodeURIComponent(input.errorNode)}${qs}`
830
+ );
831
+ try {
832
+ const result = await client.get(path5);
833
+ const arrowPath = result.traversalPath.join(" \u2190 ");
834
+ const provenances = result.edgeProvenances.length ? result.edgeProvenances.join(", ") : "(direct, no edges traversed)";
835
+ const summary = `Root cause for ${input.errorNode} is ${result.rootCauseNode}. ` + result.rootCauseReason + (result.fixRecommendation ? ` Recommended fix: ${result.fixRecommendation}.` : "");
836
+ const blockLines = [
837
+ `Traversal path: ${arrowPath}`,
838
+ `Edge provenances: ${provenances}`
839
+ ];
840
+ if (result.fixRecommendation) blockLines.push(`Recommended fix: ${result.fixRecommendation}`);
841
+ return {
842
+ summary,
843
+ block: blockLines.join("\n"),
844
+ confidence: result.confidence,
845
+ provenance: result.edgeProvenances.length ? result.edgeProvenances : void 0
846
+ };
847
+ } catch (err) {
848
+ if (err instanceof HttpError && err.status === 404) {
849
+ return {
850
+ summary: `No root cause found for ${input.errorNode}. The node may be healthy, or it may not exist in the graph.`
851
+ };
852
+ }
853
+ throw err;
854
+ }
855
+ }
856
+ async function runBlastRadius(client, input) {
857
+ const qs = input.depth !== void 0 ? `?depth=${input.depth}` : "";
858
+ const path5 = projectPath(
859
+ input.project,
860
+ `/traverse/blast-radius/${encodeURIComponent(input.nodeId)}${qs}`
861
+ );
862
+ try {
863
+ const result = await client.get(path5);
864
+ if (result.totalAffected === 0) {
865
+ return {
866
+ summary: `${result.origin} has no downstream dependencies. Nothing else would break if it failed.`
867
+ };
868
+ }
869
+ const sorted = [...result.affectedNodes].sort(
870
+ (a, b) => a.distance - b.distance || a.nodeId.localeCompare(b.nodeId)
871
+ );
872
+ const blockLines = sorted.map(formatBlastEntry);
873
+ const minConfidence = sorted.reduce(
874
+ (m, n) => Math.min(m, n.confidence),
875
+ Number.POSITIVE_INFINITY
876
+ );
877
+ const provenances = [...new Set(sorted.map((n) => n.edgeProvenance))];
878
+ return {
879
+ summary: `Blast radius for ${result.origin}: ${result.totalAffected} affected node${result.totalAffected === 1 ? "" : "s"} reachable downstream.`,
880
+ block: blockLines.join("\n"),
881
+ confidence: Number.isFinite(minConfidence) ? minConfidence : void 0,
882
+ provenance: provenances.length ? provenances : void 0
883
+ };
884
+ } catch (err) {
885
+ if (err instanceof HttpError && err.status === 404) {
886
+ return { summary: `Node ${input.nodeId} not found in the graph.` };
887
+ }
888
+ throw err;
889
+ }
890
+ }
891
+ function formatBlastEntry(n) {
892
+ const tag = n.edgeProvenance === Provenance2.STALE ? " [STALE \u2014 last seen too long ago]" : "";
893
+ return ` \u2022 ${n.nodeId} (distance ${n.distance}, ${n.edgeProvenance})${tag}`;
894
+ }
895
+ async function runDependencies(client, input) {
896
+ const depth = input.depth ?? 3;
897
+ const path5 = projectPath(
898
+ input.project,
899
+ `/graph/node/${encodeURIComponent(input.nodeId)}/dependencies?depth=${depth}`
900
+ );
901
+ try {
902
+ const result = await client.get(path5);
903
+ if (result.total === 0) {
904
+ return {
905
+ summary: depth === 1 ? `${input.nodeId} has no direct dependencies in the graph.` : `${input.nodeId} has no dependencies (BFS to depth ${depth}).`
906
+ };
907
+ }
908
+ const byDistance = /* @__PURE__ */ new Map();
909
+ for (const dep of result.dependencies) {
910
+ const ring = byDistance.get(dep.distance) ?? [];
911
+ ring.push(dep);
912
+ byDistance.set(dep.distance, ring);
913
+ }
914
+ const blockLines = [];
915
+ for (const distance of [...byDistance.keys()].sort((a, b) => a - b)) {
916
+ const label = distance === 1 ? "Direct (distance 1)" : `Distance ${distance}`;
917
+ blockLines.push(`${label}:`);
918
+ for (const dep of byDistance.get(distance)) {
919
+ blockLines.push(` \u2022 ${dep.nodeId} \u2014 ${dep.edgeType} (${dep.provenance})`);
920
+ }
921
+ }
922
+ const provenances = [...new Set(result.dependencies.map((d) => d.provenance))];
923
+ const directCount = byDistance.get(1)?.length ?? 0;
924
+ const summary = depth === 1 ? `${input.nodeId} has ${directCount} direct dependenc${directCount === 1 ? "y" : "ies"}.` : `${input.nodeId} has ${result.total} dependenc${result.total === 1 ? "y" : "ies"} reachable to depth ${depth} (${directCount} direct).`;
925
+ return { summary, block: blockLines.join("\n"), provenance: provenances };
926
+ } catch (err) {
927
+ if (err instanceof HttpError && err.status === 404) {
928
+ return { summary: `Node ${input.nodeId} not found in the graph.` };
929
+ }
930
+ throw err;
931
+ }
932
+ }
933
+ async function runObservedDependencies(client, input) {
934
+ try {
935
+ const edges = await client.get(
936
+ projectPath(input.project, `/graph/edges/${encodeURIComponent(input.nodeId)}`)
937
+ );
938
+ const observed = edges.outbound.filter((e) => e.provenance === Provenance2.OBSERVED);
939
+ if (observed.length === 0) {
940
+ const hasExtracted = edges.outbound.some((e) => e.provenance === Provenance2.EXTRACTED);
941
+ const note = hasExtracted ? " Static (EXTRACTED) dependencies exist but no runtime traffic has been seen \u2014 is OTel running?" : "";
942
+ return { summary: `No OBSERVED dependencies for ${input.nodeId}.${note}` };
943
+ }
944
+ const blockLines = observed.map((e) => ` \u2022 ${e.target} \u2014 ${e.type}${edgeMeta(e)}`);
945
+ return {
946
+ summary: `${input.nodeId} has ${observed.length} runtime dependenc${observed.length === 1 ? "y" : "ies"} confirmed by OTel.`,
947
+ block: blockLines.join("\n"),
948
+ provenance: Provenance2.OBSERVED
949
+ };
950
+ } catch (err) {
951
+ if (err instanceof HttpError && err.status === 404) {
952
+ return { summary: `Node ${input.nodeId} not found in the graph.` };
953
+ }
954
+ throw err;
955
+ }
956
+ }
957
+ function edgeMeta(e) {
958
+ const bits = [];
959
+ if (e.signal) {
960
+ bits.push(`spans=${e.signal.spanCount}`);
961
+ if (e.signal.errorCount > 0) bits.push(`errors=${e.signal.errorCount}`);
962
+ if (e.signal.lastObservedAgeMs !== void 0) {
963
+ bits.push(`age=${formatDuration(e.signal.lastObservedAgeMs)}`);
964
+ }
965
+ } else if (e.callCount !== void 0) {
966
+ bits.push(`callCount=${e.callCount}`);
967
+ }
968
+ if (e.lastObserved) bits.push(`lastObserved=${e.lastObserved}`);
969
+ if (e.confidence !== void 0) bits.push(`confidence=${e.confidence}`);
970
+ return bits.length ? ` [${bits.join(", ")}]` : "";
971
+ }
972
+ function formatDuration(ms) {
973
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
974
+ const s = Math.round(ms / 1e3);
975
+ if (s < 60) return `${s}s`;
976
+ const m = Math.round(s / 60);
977
+ if (m < 60) return `${m}m`;
978
+ const h = Math.round(m / 60);
979
+ if (h < 48) return `${h}h`;
980
+ return `${Math.round(h / 24)}d`;
981
+ }
982
+ async function runIncidents(client, input) {
983
+ const path5 = input.nodeId ? projectPath(input.project, `/incidents/${encodeURIComponent(input.nodeId)}`) : projectPath(input.project, "/incidents");
984
+ try {
985
+ const events = await client.get(path5);
986
+ if (events.length === 0) {
987
+ return {
988
+ summary: input.nodeId ? `No incidents recorded against ${input.nodeId}.` : "No incidents recorded."
989
+ };
990
+ }
991
+ const ordered = [...events].reverse().slice(0, input.limit ?? 20);
992
+ const blockLines = [];
993
+ for (const ev of ordered) {
994
+ blockLines.push(` ${ev.timestamp} \u2014 ${ev.service}: ${ev.errorMessage}`);
995
+ blockLines.push(` trace=${ev.traceId} span=${ev.spanId}`);
996
+ }
997
+ const target = input.nodeId ?? "the project";
998
+ return {
999
+ summary: `${target} has ${events.length} recorded incident${events.length === 1 ? "" : "s"}; showing the ${ordered.length} most recent.`,
1000
+ block: blockLines.join("\n"),
1001
+ provenance: Provenance2.OBSERVED
1002
+ };
1003
+ } catch (err) {
1004
+ if (err instanceof HttpError && err.status === 404) {
1005
+ return { summary: `Node ${input.nodeId ?? ""} not found in the graph.` };
1006
+ }
1007
+ throw err;
1008
+ }
1009
+ }
1010
+ async function runSearch(client, input) {
1011
+ const result = await client.get(
1012
+ projectPath(input.project, `/search?q=${encodeURIComponent(input.query)}`)
1013
+ );
1014
+ if (result.matches.length === 0) {
1015
+ return { summary: `No matches for "${input.query}".` };
1016
+ }
1017
+ const provider = result.provider ?? "substring";
1018
+ const blockLines = [];
1019
+ let topScore;
1020
+ for (const n of result.matches) {
1021
+ const score = provider !== "substring" && typeof n.score === "number" ? n.score : void 0;
1022
+ const scoreBit = score !== void 0 ? ` [score=${score.toFixed(2)}]` : "";
1023
+ if (score !== void 0 && (topScore === void 0 || score > topScore)) topScore = score;
1024
+ blockLines.push(
1025
+ ` \u2022 ${n.id} (${n.type}) \u2014 ${n.name ?? n.id}${scoreBit}`
1026
+ );
1027
+ }
1028
+ return {
1029
+ summary: `Found ${result.matches.length} match${result.matches.length === 1 ? "" : "es"} for "${input.query}" via ${provider} provider.`,
1030
+ block: blockLines.join("\n"),
1031
+ confidence: topScore
1032
+ };
1033
+ }
1034
+ async function runDiff(client, input) {
1035
+ const result = await client.get(
1036
+ projectPath(
1037
+ input.project,
1038
+ `/graph/diff?against=${encodeURIComponent(input.againstSnapshot)}`
1039
+ )
1040
+ );
1041
+ const total = result.added.nodes.length + result.added.edges.length + result.removed.nodes.length + result.removed.edges.length + result.changed.nodes.length + result.changed.edges.length;
1042
+ const baseLabel = result.base.exportedAt ?? "unknown";
1043
+ if (total === 0) {
1044
+ return {
1045
+ summary: `No differences between the current graph and ${input.againstSnapshot} (base exportedAt=${baseLabel}).`
1046
+ };
1047
+ }
1048
+ const blockLines = [
1049
+ ` base exportedAt: ${baseLabel}`,
1050
+ ` current exportedAt: ${result.current.exportedAt}`,
1051
+ ""
1052
+ ];
1053
+ if (result.added.nodes.length || result.added.edges.length) {
1054
+ blockLines.push("Added:");
1055
+ for (const n of result.added.nodes) blockLines.push(` + node ${n.id} (${n.type})`);
1056
+ for (const e of result.added.edges)
1057
+ blockLines.push(` + edge ${e.id} \u2014 ${e.source} -> ${e.target} (${e.type}, ${e.provenance})`);
1058
+ blockLines.push("");
1059
+ }
1060
+ if (result.removed.nodes.length || result.removed.edges.length) {
1061
+ blockLines.push("Removed:");
1062
+ for (const n of result.removed.nodes) blockLines.push(` - node ${n.id} (${n.type})`);
1063
+ for (const e of result.removed.edges)
1064
+ blockLines.push(` - edge ${e.id} \u2014 ${e.source} -> ${e.target} (${e.type}, ${e.provenance})`);
1065
+ blockLines.push("");
1066
+ }
1067
+ if (result.changed.nodes.length || result.changed.edges.length) {
1068
+ blockLines.push("Changed:");
1069
+ for (const c of result.changed.nodes) {
1070
+ blockLines.push(` ~ node ${c.id} \u2014 ${summariseAttrDiff(c.before, c.after)}`);
1071
+ }
1072
+ for (const c of result.changed.edges) {
1073
+ const provBit = c.before.provenance !== c.after.provenance ? `provenance ${c.before.provenance} \u2192 ${c.after.provenance}` : summariseAttrDiff(c.before, c.after);
1074
+ blockLines.push(` ~ edge ${c.id} \u2014 ${provBit}`);
1075
+ }
1076
+ }
1077
+ return {
1078
+ summary: `Diff against ${input.againstSnapshot}: ${total} change${total === 1 ? "" : "s"} between the snapshot and the live graph.`,
1079
+ block: blockLines.join("\n").trimEnd()
1080
+ };
1081
+ }
1082
+ function summariseAttrDiff(before, after) {
1083
+ const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
1084
+ const changed = [];
1085
+ for (const k of keys) {
1086
+ if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) changed.push(k);
1087
+ }
1088
+ return changed.length === 0 ? "attributes differ" : `fields changed: ${changed.sort().join(", ")}`;
1089
+ }
1090
+ async function runStaleEdges(client, input) {
1091
+ const params = new URLSearchParams();
1092
+ if (input.limit !== void 0) params.set("limit", String(input.limit));
1093
+ if (input.edgeType) params.set("edgeType", input.edgeType);
1094
+ const qs = params.size > 0 ? `?${params.toString()}` : "";
1095
+ const events = await client.get(
1096
+ projectPath(input.project, `/incidents/stale${qs}`)
1097
+ );
1098
+ if (events.length === 0) {
1099
+ return {
1100
+ summary: input.edgeType ? `No stale ${input.edgeType} edges recorded.` : "No stale-edge transitions recorded yet."
1101
+ };
1102
+ }
1103
+ const blockLines = events.map(
1104
+ (e) => ` ${e.transitionedAt} \u2014 ${e.source} -[${e.edgeType}]-> ${e.target} (last seen ${e.lastObserved}, threshold ${formatDuration(e.thresholdMs)})`
1105
+ );
1106
+ return {
1107
+ summary: `${events.length} stale-edge transition${events.length === 1 ? "" : "s"} recorded${input.edgeType ? ` for ${input.edgeType}` : ""}.`,
1108
+ block: blockLines.join("\n"),
1109
+ provenance: Provenance2.STALE
1110
+ };
1111
+ }
1112
+ async function runPolicies(client, input) {
1113
+ let violations;
1114
+ let allowed = true;
1115
+ let hypothetical;
1116
+ if (input.hypotheticalAction) {
1117
+ if (typeof client.post !== "function") {
1118
+ throw new Error("HttpClient does not support POST \u2014 required for policies dry-run");
1119
+ }
1120
+ const body = await client.post(
1121
+ projectPath(input.project, "/policies/check"),
1122
+ { hypotheticalAction: input.hypotheticalAction }
1123
+ );
1124
+ violations = body.violations;
1125
+ allowed = body.allowed;
1126
+ hypothetical = body.hypotheticalAction;
1127
+ } else {
1128
+ const params = new URLSearchParams();
1129
+ if (input.policyId) params.set("policyId", input.policyId);
1130
+ const qs = params.size > 0 ? `?${params.toString()}` : "";
1131
+ violations = await client.get(
1132
+ projectPath(input.project, `/policies/violations${qs}`)
1133
+ );
1134
+ allowed = violations.every((v) => v.onViolation !== "block");
1135
+ }
1136
+ if (input.nodeId) {
1137
+ violations = violations.filter(
1138
+ (v) => v.subject.nodeId === input.nodeId || v.subject.path?.includes(input.nodeId)
1139
+ );
1140
+ }
1141
+ if (violations.length === 0) {
1142
+ return {
1143
+ summary: hypothetical ? `No violations would result from the hypothetical action (${hypothetical.kind}).` : "No policy violations recorded."
1144
+ };
1145
+ }
1146
+ const blockCount = violations.filter((v) => v.onViolation === "block").length;
1147
+ const summaryParts = [];
1148
+ if (hypothetical) {
1149
+ summaryParts.push(
1150
+ `Hypothetical ${hypothetical.kind} would surface ${violations.length} violation${violations.length === 1 ? "" : "s"}`
1151
+ );
1152
+ } else {
1153
+ summaryParts.push(
1154
+ `${violations.length} policy violation${violations.length === 1 ? "" : "s"} currently recorded`
1155
+ );
1156
+ }
1157
+ if (blockCount > 0) summaryParts.push(`${blockCount} of which block`);
1158
+ if (!allowed && hypothetical) summaryParts.push("action denied");
1159
+ const summary = summaryParts.join("; ") + ".";
1160
+ const blockLines = violations.map((v) => {
1161
+ const subject = v.subject.nodeId ?? v.subject.edgeId ?? v.subject.path?.[0] ?? "(global)";
1162
+ return ` \u2022 [${v.severity}/${v.onViolation}] ${v.policyName}: ${v.message} \u2014 ${subject}`;
1163
+ });
1164
+ const severities = [...new Set(violations.map((v) => v.severity))];
1165
+ return {
1166
+ summary,
1167
+ block: blockLines.join("\n"),
1168
+ confidence: hypothetical ? 0.7 : 1,
1169
+ provenance: severities.join(" ")
1170
+ };
1171
+ }
1172
+ function formatFooter(confidence, provenance) {
1173
+ const c = confidence === void 0 ? "n/a" : confidence.toFixed(2);
1174
+ const p = provenance === void 0 ? "n/a" : Array.isArray(provenance) ? [...new Set(provenance)].join(", ") : provenance;
1175
+ return `confidence: ${c} \xB7 provenance: ${p}`;
1176
+ }
1177
+ function formatHuman(result) {
1178
+ const sections = [result.summary.trim()];
1179
+ if (result.block && result.block.trim().length > 0) sections.push(result.block.trimEnd());
1180
+ sections.push(formatFooter(result.confidence, result.provenance));
1181
+ return sections.join("\n\n");
1182
+ }
1183
+ function formatJson(result) {
1184
+ return JSON.stringify(
1185
+ {
1186
+ summary: result.summary,
1187
+ block: result.block ?? "",
1188
+ confidence: result.confidence ?? null,
1189
+ provenance: result.provenance ?? null
1190
+ },
1191
+ null,
1192
+ 2
1193
+ );
1194
+ }
1195
+ function exitCodeForError(err) {
1196
+ if (err instanceof TransportError) return 3;
1197
+ if (err instanceof HttpError) return 1;
1198
+ return 1;
1199
+ }
1200
+
725
1201
  // src/cli.ts
726
1202
  function usage() {
727
1203
  console.log("usage: neat <command> [args] [--project <name>]");
728
1204
  console.log("");
729
- console.log("commands:");
1205
+ console.log("lifecycle commands:");
730
1206
  console.log(" init <path> One-time install: discover, extract, register, plan SDK install.");
731
1207
  console.log(" Snapshot lands in <path>/neat-out/graph.json by default");
732
1208
  console.log(" (or <path>/neat-out/<project>.json for non-default).");
@@ -748,51 +1224,133 @@ function usage() {
748
1224
  console.log(" --print-config print the JSON snippet to stdout");
749
1225
  console.log(" --apply merge mcpServers.neat into ~/.claude.json");
750
1226
  console.log("");
1227
+ console.log("query commands (mirror the MCP tools, ADR-050):");
1228
+ console.log(" root-cause <node-id> Walk inbound edges to find what broke first.");
1229
+ console.log(" example: neat root-cause service:<name>");
1230
+ console.log(" blast-radius <node-id> BFS outbound \u2014 what would break if this dies.");
1231
+ console.log(" example: neat blast-radius database:<host>");
1232
+ console.log(" dependencies <node-id> Transitive outbound dependencies.");
1233
+ console.log(" Flags: --depth N (default 3, max 10)");
1234
+ console.log(" example: neat dependencies service:<name> --depth 2");
1235
+ console.log(" observed-dependencies <node-id> OBSERVED-only outbound edges (runtime traffic).");
1236
+ console.log(" example: neat observed-dependencies service:<name>");
1237
+ console.log(" incidents [<node-id>] Recent error events; per-node when an id is given.");
1238
+ console.log(" Flags: --limit N (default 20)");
1239
+ console.log(" example: neat incidents service:<name> --limit 5");
1240
+ console.log(" search <query> Semantic (or substring) match on node names/ids.");
1241
+ console.log(' example: neat search "checkout"');
1242
+ console.log(" diff --against <snapshot> Compare the live graph to a saved snapshot.");
1243
+ console.log(" example: neat diff --against ./snapshots/baseline.json");
1244
+ console.log(" stale-edges Recent OBSERVED \u2192 STALE transitions.");
1245
+ console.log(" Flags: --limit N, --edge-type CALLS|CONNECTS_TO|...");
1246
+ console.log(" example: neat stale-edges --edge-type CALLS");
1247
+ console.log(" policies Current policy violations.");
1248
+ console.log(" Flags: --node <id>, --hypothetical-action <json>");
1249
+ console.log(" example: neat policies --node service:<name> --json");
1250
+ console.log("");
751
1251
  console.log("flags:");
752
1252
  console.log(' --project <name> Name the project this command targets. Default: "default".');
1253
+ console.log(" --json Emit machine-readable JSON instead of human text. Query verbs only.");
1254
+ console.log("");
1255
+ console.log("exit codes:");
1256
+ console.log(" 0 success");
1257
+ console.log(" 1 server error (4xx/5xx body printed to stderr)");
1258
+ console.log(" 2 misuse (missing args, bad flags) \u2014 handled before any network call");
1259
+ console.log(" 3 daemon not reachable (connection refused / timeout)");
1260
+ console.log("");
1261
+ console.log("environment:");
1262
+ console.log(" NEAT_API_URL base URL for the core REST API (default http://localhost:8080)");
1263
+ console.log(" NEAT_PROJECT project name when --project isn't passed");
753
1264
  }
1265
+ var STRING_FLAGS = [
1266
+ ["--project", "project"],
1267
+ ["--depth", "depth"],
1268
+ ["--limit", "limit"],
1269
+ ["--edge-type", "edgeType"],
1270
+ ["--node", "node"],
1271
+ ["--since", "since"],
1272
+ ["--against", "against"],
1273
+ ["--error-id", "errorId"],
1274
+ ["--hypothetical-action", "hypotheticalAction"]
1275
+ ];
754
1276
  function parseArgs(rest) {
755
1277
  const positional = [];
756
- let project = null;
757
- let apply3 = false;
758
- let dryRun = false;
759
- let noInstall = false;
760
- let printConfig = false;
1278
+ const out = {
1279
+ project: null,
1280
+ apply: false,
1281
+ dryRun: false,
1282
+ noInstall: false,
1283
+ printConfig: false,
1284
+ json: false,
1285
+ depth: null,
1286
+ limit: null,
1287
+ edgeType: null,
1288
+ node: null,
1289
+ since: null,
1290
+ against: null,
1291
+ errorId: null,
1292
+ hypotheticalAction: null,
1293
+ positional: []
1294
+ };
761
1295
  for (let i = 0; i < rest.length; i++) {
762
1296
  const arg = rest[i];
763
- if (arg === "--project") {
764
- const next = rest[i + 1];
765
- if (!next) {
766
- console.error("neat: --project requires a value");
767
- process.exit(2);
768
- }
769
- project = next;
770
- i++;
771
- continue;
772
- }
773
- if (arg.startsWith("--project=")) {
774
- project = arg.slice("--project=".length);
775
- continue;
776
- }
777
1297
  if (arg === "--apply") {
778
- apply3 = true;
1298
+ out.apply = true;
779
1299
  continue;
780
1300
  }
781
1301
  if (arg === "--dry-run") {
782
- dryRun = true;
1302
+ out.dryRun = true;
783
1303
  continue;
784
1304
  }
785
1305
  if (arg === "--no-install") {
786
- noInstall = true;
1306
+ out.noInstall = true;
787
1307
  continue;
788
1308
  }
789
1309
  if (arg === "--print-config") {
790
- printConfig = true;
1310
+ out.printConfig = true;
791
1311
  continue;
792
1312
  }
1313
+ if (arg === "--json") {
1314
+ out.json = true;
1315
+ continue;
1316
+ }
1317
+ let matched = false;
1318
+ for (const [flag, field] of STRING_FLAGS) {
1319
+ if (arg === flag) {
1320
+ const next = rest[i + 1];
1321
+ if (next === void 0) {
1322
+ console.error(`neat: ${flag} requires a value`);
1323
+ process.exit(2);
1324
+ }
1325
+ assignFlag(out, field, next);
1326
+ i++;
1327
+ matched = true;
1328
+ break;
1329
+ }
1330
+ if (arg.startsWith(`${flag}=`)) {
1331
+ assignFlag(out, field, arg.slice(flag.length + 1));
1332
+ matched = true;
1333
+ break;
1334
+ }
1335
+ }
1336
+ if (matched) continue;
793
1337
  positional.push(arg);
794
1338
  }
795
- return { project, apply: apply3, dryRun, noInstall, printConfig, positional };
1339
+ out.positional = positional;
1340
+ return out;
1341
+ }
1342
+ function assignFlag(out, field, value) {
1343
+ if (field === "depth" || field === "limit") {
1344
+ const n = Number(value);
1345
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
1346
+ console.error(`neat: --${field === "depth" ? "depth" : "limit"} must be a positive integer`);
1347
+ process.exit(2);
1348
+ }
1349
+ out[field] = n;
1350
+ return;
1351
+ }
1352
+ ;
1353
+ out[field] = value;
796
1354
  }
797
1355
  function summarise(nodes, edges) {
798
1356
  const byNode = /* @__PURE__ */ new Map();
@@ -1156,10 +1714,167 @@ async function main() {
1156
1714
  console.log("note: neat-out/, policy.json, and other files at the project path were left in place.");
1157
1715
  return;
1158
1716
  }
1717
+ if (QUERY_VERBS.has(cmd)) {
1718
+ const code = await runQueryVerb(cmd, parsed);
1719
+ if (code !== 0) process.exit(code);
1720
+ return;
1721
+ }
1159
1722
  console.error(`neat: unknown command "${cmd}"`);
1160
1723
  usage();
1161
1724
  process.exit(1);
1162
1725
  }
1726
+ var QUERY_VERBS = /* @__PURE__ */ new Set([
1727
+ "root-cause",
1728
+ "blast-radius",
1729
+ "dependencies",
1730
+ "observed-dependencies",
1731
+ "incidents",
1732
+ "search",
1733
+ "diff",
1734
+ "stale-edges",
1735
+ "policies"
1736
+ ]);
1737
+ function resolveProjectFlag(parsed) {
1738
+ if (parsed.project) return parsed.project;
1739
+ const env = process.env.NEAT_PROJECT;
1740
+ if (env && env.length > 0 && env !== DEFAULT_PROJECT) return env;
1741
+ return void 0;
1742
+ }
1743
+ async function runQueryVerb(cmd, parsed) {
1744
+ const baseUrl = process.env.NEAT_API_URL ?? "http://localhost:8080";
1745
+ const client = createHttpClient(baseUrl);
1746
+ const project = resolveProjectFlag(parsed);
1747
+ const positional = parsed.positional;
1748
+ let work;
1749
+ switch (cmd) {
1750
+ case "root-cause": {
1751
+ const node = positional[0];
1752
+ if (!node) {
1753
+ console.error("neat root-cause: missing <node-id>");
1754
+ return 2;
1755
+ }
1756
+ work = runRootCause(client, {
1757
+ errorNode: node,
1758
+ ...parsed.errorId ? { errorId: parsed.errorId } : {},
1759
+ ...project ? { project } : {}
1760
+ });
1761
+ break;
1762
+ }
1763
+ case "blast-radius": {
1764
+ const node = positional[0];
1765
+ if (!node) {
1766
+ console.error("neat blast-radius: missing <node-id>");
1767
+ return 2;
1768
+ }
1769
+ work = runBlastRadius(client, {
1770
+ nodeId: node,
1771
+ ...parsed.depth !== null ? { depth: parsed.depth } : {},
1772
+ ...project ? { project } : {}
1773
+ });
1774
+ break;
1775
+ }
1776
+ case "dependencies": {
1777
+ const node = positional[0];
1778
+ if (!node) {
1779
+ console.error("neat dependencies: missing <node-id>");
1780
+ return 2;
1781
+ }
1782
+ work = runDependencies(client, {
1783
+ nodeId: node,
1784
+ ...parsed.depth !== null ? { depth: parsed.depth } : {},
1785
+ ...project ? { project } : {}
1786
+ });
1787
+ break;
1788
+ }
1789
+ case "observed-dependencies": {
1790
+ const node = positional[0];
1791
+ if (!node) {
1792
+ console.error("neat observed-dependencies: missing <node-id>");
1793
+ return 2;
1794
+ }
1795
+ work = runObservedDependencies(client, {
1796
+ nodeId: node,
1797
+ ...project ? { project } : {}
1798
+ });
1799
+ break;
1800
+ }
1801
+ case "incidents": {
1802
+ work = runIncidents(client, {
1803
+ ...positional[0] ? { nodeId: positional[0] } : {},
1804
+ ...parsed.limit !== null ? { limit: parsed.limit } : {},
1805
+ ...project ? { project } : {}
1806
+ });
1807
+ break;
1808
+ }
1809
+ case "search": {
1810
+ const q = positional.join(" ").trim();
1811
+ if (!q) {
1812
+ console.error("neat search: missing <query>");
1813
+ return 2;
1814
+ }
1815
+ work = runSearch(client, { query: q, ...project ? { project } : {} });
1816
+ break;
1817
+ }
1818
+ case "diff": {
1819
+ const against = parsed.against ?? parsed.since;
1820
+ if (!against) {
1821
+ console.error("neat diff: --against <snapshot-path> is required");
1822
+ return 2;
1823
+ }
1824
+ work = runDiff(client, {
1825
+ againstSnapshot: against,
1826
+ ...project ? { project } : {}
1827
+ });
1828
+ break;
1829
+ }
1830
+ case "stale-edges": {
1831
+ work = runStaleEdges(client, {
1832
+ ...parsed.limit !== null ? { limit: parsed.limit } : {},
1833
+ ...parsed.edgeType ? { edgeType: parsed.edgeType } : {},
1834
+ ...project ? { project } : {}
1835
+ });
1836
+ break;
1837
+ }
1838
+ case "policies": {
1839
+ let hypothetical;
1840
+ if (parsed.hypotheticalAction) {
1841
+ try {
1842
+ hypothetical = JSON.parse(parsed.hypotheticalAction);
1843
+ } catch (err) {
1844
+ console.error(
1845
+ `neat policies: --hypothetical-action must be valid JSON: ${err.message}`
1846
+ );
1847
+ return 2;
1848
+ }
1849
+ }
1850
+ work = runPolicies(client, {
1851
+ ...parsed.node ? { nodeId: parsed.node } : {},
1852
+ ...hypothetical ? { hypotheticalAction: hypothetical } : {},
1853
+ ...project ? { project } : {}
1854
+ });
1855
+ break;
1856
+ }
1857
+ default:
1858
+ console.error(`neat: unknown query verb "${cmd}"`);
1859
+ return 2;
1860
+ }
1861
+ try {
1862
+ const result = await work;
1863
+ if (parsed.json) process.stdout.write(formatJson(result) + "\n");
1864
+ else process.stdout.write(formatHuman(result) + "\n");
1865
+ return 0;
1866
+ } catch (err) {
1867
+ if (err instanceof HttpError) {
1868
+ const detail = err.responseBody.length > 0 ? err.responseBody : err.message;
1869
+ console.error(`neat ${cmd}: ${detail.trim()}`);
1870
+ } else if (err instanceof TransportError) {
1871
+ console.error(`neat ${cmd}: ${err.message}. Is the daemon running? (NEAT_API_URL=${process.env.NEAT_API_URL ?? "http://localhost:8080"})`);
1872
+ } else {
1873
+ console.error(`neat ${cmd}: ${err.message}`);
1874
+ }
1875
+ return exitCodeForError(err);
1876
+ }
1877
+ }
1163
1878
  var entry = process.argv[1] ?? "";
1164
1879
  if (/[\\/]cli\.(?:cjs|js)$/.test(entry) || entry.endsWith("/cli") || entry.endsWith("/neat")) {
1165
1880
  main().catch((err) => {
@@ -1169,7 +1884,10 @@ if (/[\\/]cli\.(?:cjs|js)$/.test(entry) || entry.endsWith("/cli") || entry.endsW
1169
1884
  }
1170
1885
  export {
1171
1886
  CLAUDE_SKILL_CONFIG,
1887
+ QUERY_VERBS,
1888
+ parseArgs,
1172
1889
  runInit,
1890
+ runQueryVerb,
1173
1891
  runSkill
1174
1892
  };
1175
1893
  //# sourceMappingURL=cli.js.map