pm-graph 2026.6.2 → 2026.6.7

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/index.js CHANGED
@@ -4,7 +4,7 @@ import { writeFileSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  const execFileAsync = promisify(execFile);
7
- const EXTENSION_VERSION = "0.1.4";
7
+ const EXTENSION_VERSION = "0.2.0";
8
8
  // ---------------------------------------------------------------------------
9
9
  // Error contract
10
10
  // ---------------------------------------------------------------------------
@@ -412,6 +412,102 @@ function loadGraphFromPath(pmRoot) {
412
412
  const items = fetchItemsViaPath(pmRoot);
413
413
  return graphFromItems(items, workspaceFromPmRoot(pmRoot), new Map());
414
414
  }
415
+ /**
416
+ * Build a Graph for a CommandContext via a single
417
+ * `pm list-all --json --include-body` call from the workspace cwd. The
418
+ * `--include-body` payload already carries dependencies/blocked_by/parent/tags,
419
+ * so no per-item `pm deps` round-trips are needed. Used by the offline
420
+ * analytics commands (analyze/cycles/path/critical-path).
421
+ */
422
+ function loadGraphForContext(context) {
423
+ const workspace = getWorkspace(context);
424
+ const result = spawnSync("pm", ["list-all", "--json", "--include-body"], { cwd: workspace, encoding: "utf-8", maxBuffer: 20 * 1024 * 1024 });
425
+ if (result.error || result.status !== 0) {
426
+ throw new CommandError(`Failed to fetch pm items (exit ${result.status ?? "unknown"}): ${result.stderr?.trim() || result.error?.message || "no output"}`);
427
+ }
428
+ let parsed;
429
+ try {
430
+ parsed = JSON.parse(result.stdout);
431
+ }
432
+ catch (err) {
433
+ throw new CommandError(`Failed to parse pm list-all output as JSON: ${err instanceof Error ? err.message : String(err)}`);
434
+ }
435
+ return graphFromItems(parsed.items ?? [], workspace, new Map());
436
+ }
437
+ /**
438
+ * Parse the shared analytics flags (--json, --include-closed, --root, --depth)
439
+ * and collect remaining positional arguments. Throws a USAGE CommandError on a
440
+ * malformed --depth or a value-less --root/--depth.
441
+ */
442
+ function parseAnalyticsFlags(args) {
443
+ const flags = { json: false, includeClosed: false, positionals: [] };
444
+ for (let i = 0; i < args.length; i++) {
445
+ const arg = args[i];
446
+ if (arg === "--json") {
447
+ flags.json = true;
448
+ }
449
+ else if (arg === "--include-closed") {
450
+ flags.includeClosed = true;
451
+ }
452
+ else if (arg === "--root") {
453
+ const value = args[++i];
454
+ if (value === undefined)
455
+ throw new CommandError("--root requires an item id.", EXIT_CODE.USAGE);
456
+ flags.root = value;
457
+ }
458
+ else if (arg.startsWith("--root=")) {
459
+ flags.root = arg.slice("--root=".length);
460
+ }
461
+ else if (arg === "--depth") {
462
+ const value = args[++i];
463
+ if (value === undefined)
464
+ throw new CommandError("--depth requires an integer.", EXIT_CODE.USAGE);
465
+ const parsed = parseInt(value, 10);
466
+ if (Number.isNaN(parsed) || parsed < 0) {
467
+ throw new CommandError(`Invalid --depth "${value}" (expected a non-negative integer).`, EXIT_CODE.USAGE);
468
+ }
469
+ flags.depth = parsed;
470
+ }
471
+ else if (arg.startsWith("--depth=")) {
472
+ const value = arg.slice("--depth=".length);
473
+ const parsed = parseInt(value, 10);
474
+ if (Number.isNaN(parsed) || parsed < 0) {
475
+ throw new CommandError(`Invalid --depth "${value}" (expected a non-negative integer).`, EXIT_CODE.USAGE);
476
+ }
477
+ flags.depth = parsed;
478
+ }
479
+ else if (arg === "--help" || arg === "-h") {
480
+ // handled separately by hasHelpFlag
481
+ }
482
+ else if (arg.startsWith("--")) {
483
+ // ignore unknown flags rather than misparse them as positionals
484
+ }
485
+ else {
486
+ flags.positionals.push(arg);
487
+ }
488
+ }
489
+ return flags;
490
+ }
491
+ /**
492
+ * Load a graph for a context and apply the shared analytics shaping
493
+ * (structural edges only is enforced downstream; here we only honor
494
+ * --include-closed and an optional --root/--depth neighborhood). Throws
495
+ * NOT_FOUND when --root is absent from the workspace.
496
+ */
497
+ function shapedAnalyticsGraph(context, flags) {
498
+ const full = loadGraphForContext(context);
499
+ if (flags.root && !full.nodes.some((n) => n.id === flags.root)) {
500
+ throw new CommandError(`--root node "${flags.root}" was not found in the workspace graph.`, EXIT_CODE.NOT_FOUND);
501
+ }
502
+ // Restrict to structural edges so neighborhood shaping follows dependencies,
503
+ // not facet/tag links. The analytics functions also re-filter defensively.
504
+ return shapeGraph(full, {
505
+ edges: "deps",
506
+ includeClosed: flags.includeClosed,
507
+ rootId: flags.root,
508
+ depth: flags.depth,
509
+ });
510
+ }
415
511
  // ---------------------------------------------------------------------------
416
512
  // Cypher generation (for export)
417
513
  // ---------------------------------------------------------------------------
@@ -590,6 +686,85 @@ function renderJsonGraph(graph) {
590
686
  };
591
687
  return JSON.stringify(doc, null, 2);
592
688
  }
689
+ /** Escape a string for XML text/attribute content. */
690
+ function xmlEscape(s) {
691
+ return s
692
+ .replace(/&/g, "&amp;")
693
+ .replace(/</g, "&lt;")
694
+ .replace(/>/g, "&gt;")
695
+ .replace(/"/g, "&quot;")
696
+ .replace(/'/g, "&apos;");
697
+ }
698
+ /**
699
+ * Render a valid GraphML XML document (consumable by yEd / Gephi / NetworkX).
700
+ * Declares string keys for node title/type/status/labels and edge type, then
701
+ * emits one <node> per graph node and one <edge> per relationship.
702
+ */
703
+ export function renderGraphml(graph) {
704
+ const lines = [];
705
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
706
+ lines.push('<graphml xmlns="http://graphml.graphdrawing.org/xmlns" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://graphml.graphdrawing.org/xmlns http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd">');
707
+ lines.push(' <key id="title" for="node" attr.name="title" attr.type="string"/>');
708
+ lines.push(' <key id="type" for="node" attr.name="type" attr.type="string"/>');
709
+ lines.push(' <key id="status" for="node" attr.name="status" attr.type="string"/>');
710
+ lines.push(' <key id="labels" for="node" attr.name="labels" attr.type="string"/>');
711
+ lines.push(' <key id="reltype" for="edge" attr.name="reltype" attr.type="string"/>');
712
+ lines.push(' <graph id="pm-graph" edgedefault="directed">');
713
+ for (const node of graph.nodes) {
714
+ const title = typeof node.properties.title === "string" && node.properties.title
715
+ ? String(node.properties.title)
716
+ : node.id;
717
+ const type = typeof node.properties.type === "string" ? node.properties.type : "";
718
+ const status = typeof node.properties.status === "string" ? node.properties.status : "";
719
+ lines.push(` <node id="${xmlEscape(node.id)}">`);
720
+ lines.push(` <data key="title">${xmlEscape(title)}</data>`);
721
+ if (type)
722
+ lines.push(` <data key="type">${xmlEscape(type)}</data>`);
723
+ if (status)
724
+ lines.push(` <data key="status">${xmlEscape(status)}</data>`);
725
+ lines.push(` <data key="labels">${xmlEscape(node.labels.join(" "))}</data>`);
726
+ lines.push(" </node>");
727
+ }
728
+ graph.relationships.forEach((rel, index) => {
729
+ lines.push(` <edge id="e${index}" source="${xmlEscape(rel.from)}" target="${xmlEscape(rel.to)}">`);
730
+ lines.push(` <data key="reltype">${xmlEscape(rel.type)}</data>`);
731
+ lines.push(" </edge>");
732
+ });
733
+ lines.push(" </graph>");
734
+ lines.push("</graphml>");
735
+ return lines.join("\n");
736
+ }
737
+ /** Sanitise an id for use as a PlantUML alias (alphanumeric/underscore). */
738
+ function plantumlAlias(id) {
739
+ return "n_" + id.replace(/[^A-Za-z0-9_]/g, "_");
740
+ }
741
+ /** Escape a string for a PlantUML double-quoted label. */
742
+ function plantumlLabel(s) {
743
+ return s.replace(/"/g, "'").replace(/\n/g, " ").trim();
744
+ }
745
+ /**
746
+ * Render a PlantUML object diagram (`@startuml`…`@enduml`) with one object per
747
+ * node and one arrow per relationship, the relationship type as the arrow
748
+ * label. Renders with PlantUML / Structurizr / many docs toolchains.
749
+ */
750
+ export function renderPlantuml(graph) {
751
+ const lines = ["@startuml", "left to right direction"];
752
+ for (const node of graph.nodes) {
753
+ const title = typeof node.properties.title === "string" && node.properties.title
754
+ ? String(node.properties.title)
755
+ : node.id;
756
+ const status = typeof node.properties.status === "string" ? node.properties.status : "";
757
+ const label = status ? `${title} [${node.id}] (${status})` : `${title} [${node.id}]`;
758
+ lines.push(`object "${plantumlLabel(label)}" as ${plantumlAlias(node.id)}`);
759
+ }
760
+ if (graph.relationships.length > 0)
761
+ lines.push("");
762
+ for (const rel of graph.relationships) {
763
+ lines.push(`${plantumlAlias(rel.from)} --> ${plantumlAlias(rel.to)} : ${plantumlLabel(rel.type)}`);
764
+ }
765
+ lines.push("@enduml");
766
+ return lines.join("\n");
767
+ }
593
768
  function renderExport(format, graph) {
594
769
  switch (format) {
595
770
  case "cypher":
@@ -602,6 +777,10 @@ function renderExport(format, graph) {
602
777
  return renderDot(graph);
603
778
  case "json":
604
779
  return renderJsonGraph(graph);
780
+ case "graphml":
781
+ return renderGraphml(graph);
782
+ case "plantuml":
783
+ return renderPlantuml(graph);
605
784
  }
606
785
  }
607
786
  function readExportOption(options, ...keys) {
@@ -612,6 +791,452 @@ function readExportOption(options, ...keys) {
612
791
  }
613
792
  return undefined;
614
793
  }
794
+ // ---------------------------------------------------------------------------
795
+ // Graph analytics (offline — operate on STRUCTURAL edges only)
796
+ // ---------------------------------------------------------------------------
797
+ // Analytics treat the workspace as a directed dependency graph. Only
798
+ // *structural* edges (BLOCKED_BY + CHILD_OF + dependency edges such as BLOCKS /
799
+ // RELATED) participate. Facet edges (HAS_TYPE/HAS_STATUS/ASSIGNED_TO/IN_SPRINT/
800
+ // IN_RELEASE) and tag edges (TAGGED_WITH) are metadata, not dependencies — if
801
+ // they were included, every item sharing a status or tag would appear linked,
802
+ // producing meaningless cycles, components, and centrality. Filtering to
803
+ // structural edges keeps cycle/path/critical-path results semantically honest.
804
+ function isStructuralEdge(type) {
805
+ return type !== TAG_REL_TYPE && !FACET_REL_TYPES.has(type);
806
+ }
807
+ /** Item-only node ids (drop facet/tag/external nodes that are not PmItems). */
808
+ function itemNodeIds(graph) {
809
+ const ids = new Set();
810
+ for (const node of graph.nodes) {
811
+ if (node.labels.includes("PmItem"))
812
+ ids.add(node.id);
813
+ }
814
+ return ids;
815
+ }
816
+ /** Extract the directed structural edges of a graph, between item nodes only. */
817
+ function structuralEdges(graph) {
818
+ const items = itemNodeIds(graph);
819
+ const seen = new Set();
820
+ const edges = [];
821
+ for (const rel of graph.relationships) {
822
+ if (!isStructuralEdge(rel.type))
823
+ continue;
824
+ if (!items.has(rel.from) || !items.has(rel.to))
825
+ continue;
826
+ const key = `${rel.from}->${rel.to}:${rel.type}`;
827
+ if (seen.has(key))
828
+ continue;
829
+ seen.add(key);
830
+ edges.push({ from: rel.from, to: rel.to, type: rel.type });
831
+ }
832
+ return edges;
833
+ }
834
+ /** Directed adjacency (from -> [to]) over a set of edges. */
835
+ function buildAdjacency(edges) {
836
+ const adjacency = new Map();
837
+ for (const e of edges) {
838
+ const list = adjacency.get(e.from) ?? [];
839
+ if (!list.includes(e.to))
840
+ list.push(e.to);
841
+ adjacency.set(e.from, list);
842
+ }
843
+ return adjacency;
844
+ }
845
+ /**
846
+ * Detect all elementary directed cycles among structural edges using an
847
+ * iterative DFS with a recursion stack. Returns each cycle as an ordered id
848
+ * path whose first and last ids are equal (e.g. [E, F, E]). Cycles are
849
+ * de-duplicated by their canonical rotation so A->B->A and B->A->B collapse.
850
+ */
851
+ export function findCycles(nodes, edges) {
852
+ const adjacency = buildAdjacency(edges);
853
+ const cycles = [];
854
+ const seenCanonical = new Set();
855
+ const canonical = (cycle) => {
856
+ // cycle excludes the repeated closing node; rotate to start at min id.
857
+ const core = cycle.slice(0, -1);
858
+ let minIdx = 0;
859
+ for (let i = 1; i < core.length; i++) {
860
+ if (core[i] < core[minIdx])
861
+ minIdx = i;
862
+ }
863
+ const rotated = [...core.slice(minIdx), ...core.slice(0, minIdx)];
864
+ return rotated.join("->");
865
+ };
866
+ for (const start of nodes) {
867
+ // Iterative DFS carrying the current path; detect back-edges to a node
868
+ // already on the path (a cycle), or revisits handled via path membership.
869
+ const stack = [
870
+ { node: start, path: [start], onPath: new Set([start]) },
871
+ ];
872
+ while (stack.length > 0) {
873
+ const { node, path: currentPath, onPath } = stack.pop();
874
+ for (const next of adjacency.get(node) ?? []) {
875
+ if (next === start && currentPath.length >= 1) {
876
+ // Closed a cycle back to the start node.
877
+ const cycle = [...currentPath, start];
878
+ const key = canonical(cycle);
879
+ if (!seenCanonical.has(key)) {
880
+ seenCanonical.add(key);
881
+ cycles.push(cycle);
882
+ }
883
+ continue;
884
+ }
885
+ // Only extend along nodes greater than start to avoid re-finding
886
+ // cycles rooted at smaller ids, and never revisit a node on this path.
887
+ if (next < start || onPath.has(next))
888
+ continue;
889
+ const nextOnPath = new Set(onPath);
890
+ nextOnPath.add(next);
891
+ stack.push({ node: next, path: [...currentPath, next], onPath: nextOnPath });
892
+ }
893
+ }
894
+ }
895
+ return cycles;
896
+ }
897
+ /**
898
+ * Shortest directed path from `from` to `to` over structural edges (BFS).
899
+ * Returns the ordered id path (inclusive of both endpoints) or null if no path
900
+ * exists. Returns [from] when from === to.
901
+ */
902
+ export function shortestPath(edges, from, to) {
903
+ if (from === to)
904
+ return [from];
905
+ const adjacency = buildAdjacency(edges);
906
+ const visited = new Set([from]);
907
+ const queue = [from];
908
+ const prev = new Map();
909
+ while (queue.length > 0) {
910
+ const node = queue.shift();
911
+ for (const next of adjacency.get(node) ?? []) {
912
+ if (visited.has(next))
913
+ continue;
914
+ visited.add(next);
915
+ prev.set(next, node);
916
+ if (next === to) {
917
+ const path = [to];
918
+ let cur = to;
919
+ while (prev.has(cur)) {
920
+ cur = prev.get(cur);
921
+ path.unshift(cur);
922
+ }
923
+ return path;
924
+ }
925
+ queue.push(next);
926
+ }
927
+ }
928
+ return null;
929
+ }
930
+ /**
931
+ * Longest dependency chain (critical path) over structural edges. Uses a
932
+ * memoised DFS that is safe on cyclic graphs (nodes on the active recursion
933
+ * stack are skipped, so a cycle cannot inflate the chain infinitely). Returns
934
+ * the ordered id list of the longest simple chain found.
935
+ */
936
+ export function longestChain(nodes, edges) {
937
+ const adjacency = buildAdjacency(edges);
938
+ const memo = new Map();
939
+ const onStack = new Set();
940
+ const dfs = (node) => {
941
+ const cached = memo.get(node);
942
+ if (cached)
943
+ return cached;
944
+ onStack.add(node);
945
+ let best = [];
946
+ for (const next of adjacency.get(node) ?? []) {
947
+ if (onStack.has(next))
948
+ continue; // skip back-edges (cycle safety)
949
+ const candidate = dfs(next);
950
+ if (candidate.length > best.length)
951
+ best = candidate;
952
+ }
953
+ onStack.delete(node);
954
+ const result = [node, ...best];
955
+ memo.set(node, result);
956
+ return result;
957
+ };
958
+ let longest = [];
959
+ for (const node of nodes) {
960
+ const chain = dfs(node);
961
+ if (chain.length > longest.length)
962
+ longest = chain;
963
+ }
964
+ return longest;
965
+ }
966
+ /**
967
+ * Topological execution order over structural edges using Kahn's algorithm.
968
+ *
969
+ * Edges point from an item to its blocker/dependency (e.g. B --BLOCKED_BY--> A
970
+ * means "B is blocked by A", so A must be done before B). A valid execution
971
+ * order therefore lists a node only after every node it points to. We compute
972
+ * that order by treating out-edges as prerequisites: repeatedly emit nodes whose
973
+ * out-degree (unsatisfied prerequisites) has dropped to zero.
974
+ *
975
+ * Returns `{ order, cycleNodes }`. When the graph is acyclic, `order` contains
976
+ * every node and `cycleNodes` is empty. When a cycle exists, the nodes that
977
+ * could not be ordered are returned in `cycleNodes` (and `order` holds the
978
+ * resolvable prefix). Ties are broken by ascending id for deterministic output.
979
+ */
980
+ export function topoSort(nodes, edges) {
981
+ // Prerequisites of a node = the distinct nodes it points to (its blockers).
982
+ const prereqs = new Map();
983
+ // Dependents: for each prerequisite, which nodes wait on it.
984
+ const dependents = new Map();
985
+ const nodeSet = new Set(nodes);
986
+ for (const id of nodes)
987
+ prereqs.set(id, new Set());
988
+ for (const e of edges) {
989
+ if (!nodeSet.has(e.from) || !nodeSet.has(e.to))
990
+ continue;
991
+ if (e.from === e.to)
992
+ continue; // self-loop is its own cycle, handled below
993
+ const set = prereqs.get(e.from);
994
+ if (!set.has(e.to)) {
995
+ set.add(e.to);
996
+ (dependents.get(e.to) ?? dependents.set(e.to, []).get(e.to)).push(e.from);
997
+ }
998
+ }
999
+ // Seed the ready queue with nodes that have no prerequisites.
1000
+ const ready = nodes.filter((id) => prereqs.get(id).size === 0).sort();
1001
+ const order = [];
1002
+ const remaining = new Map();
1003
+ for (const id of nodes)
1004
+ remaining.set(id, prereqs.get(id).size);
1005
+ while (ready.length > 0) {
1006
+ const id = ready.shift();
1007
+ order.push(id);
1008
+ let inserted = false;
1009
+ for (const dep of dependents.get(id) ?? []) {
1010
+ const left = (remaining.get(dep) ?? 0) - 1;
1011
+ remaining.set(dep, left);
1012
+ if (left === 0) {
1013
+ // Insert keeping the ready queue sorted for deterministic output.
1014
+ const idx = ready.findIndex((x) => x > dep);
1015
+ if (idx === -1)
1016
+ ready.push(dep);
1017
+ else
1018
+ ready.splice(idx, 0, dep);
1019
+ inserted = true;
1020
+ }
1021
+ }
1022
+ void inserted;
1023
+ }
1024
+ // Any node never emitted is part of (or downstream of) a cycle.
1025
+ const ordered = new Set(order);
1026
+ const cycleNodes = nodes.filter((id) => !ordered.has(id)).sort();
1027
+ return { order, cycleNodes };
1028
+ }
1029
+ /**
1030
+ * Reverse-reachable set from `start` over structural edges: every node that can
1031
+ * reach `start` by following edge direction (i.e. everything transitively
1032
+ * blocked-by / downstream of `start`). With edges pointing item -> blocker, the
1033
+ * dependents of X are the nodes with an edge INTO X, so we walk edges backwards
1034
+ * via a reverse adjacency (BFS). Excludes `start` itself. Result is sorted.
1035
+ */
1036
+ export function reverseReachable(edges, start) {
1037
+ const reverse = new Map();
1038
+ for (const e of edges) {
1039
+ (reverse.get(e.to) ?? reverse.set(e.to, []).get(e.to)).push(e.from);
1040
+ }
1041
+ const seen = new Set();
1042
+ const queue = [start];
1043
+ while (queue.length > 0) {
1044
+ const node = queue.shift();
1045
+ for (const prev of reverse.get(node) ?? []) {
1046
+ if (prev === start || seen.has(prev))
1047
+ continue;
1048
+ seen.add(prev);
1049
+ queue.push(prev);
1050
+ }
1051
+ }
1052
+ return [...seen].sort();
1053
+ }
1054
+ /**
1055
+ * Longest-path depth per node: the number of edges on the longest directed
1056
+ * structural path STARTING at the node (its distance to a leaf along blocker
1057
+ * edges). A leaf (no outgoing edge) has depth 0. Cycle-safe: nodes on the active
1058
+ * recursion stack are skipped so a cycle cannot inflate depth infinitely. This
1059
+ * is the "longest path from any root" metric expressed per node, since the
1060
+ * deepest node is exactly the far end of the critical path.
1061
+ */
1062
+ export function dependencyDepths(nodes, edges) {
1063
+ const adjacency = buildAdjacency(edges);
1064
+ const memo = new Map();
1065
+ const onStack = new Set();
1066
+ const dfs = (node) => {
1067
+ const cached = memo.get(node);
1068
+ if (cached !== undefined)
1069
+ return cached;
1070
+ onStack.add(node);
1071
+ let best = 0;
1072
+ for (const next of adjacency.get(node) ?? []) {
1073
+ if (onStack.has(next))
1074
+ continue; // cycle safety
1075
+ const candidate = 1 + dfs(next);
1076
+ if (candidate > best)
1077
+ best = candidate;
1078
+ }
1079
+ onStack.delete(node);
1080
+ memo.set(node, best);
1081
+ return best;
1082
+ };
1083
+ const depths = new Map();
1084
+ for (const node of nodes)
1085
+ depths.set(node, dfs(node));
1086
+ return depths;
1087
+ }
1088
+ export function criticalConnectors(nodes, edges) {
1089
+ const nodeSet = new Set(nodes);
1090
+ const adjacency = new Map();
1091
+ for (const id of nodes)
1092
+ adjacency.set(id, new Set());
1093
+ for (const edge of edges) {
1094
+ if (!nodeSet.has(edge.from) || !nodeSet.has(edge.to) || edge.from === edge.to)
1095
+ continue;
1096
+ adjacency.get(edge.from)?.add(edge.to);
1097
+ adjacency.get(edge.to)?.add(edge.from);
1098
+ }
1099
+ const disc = new Map();
1100
+ const low = new Map();
1101
+ const parent = new Map();
1102
+ const articulation = new Set();
1103
+ const bridges = [];
1104
+ let time = 0;
1105
+ const visit = (u) => {
1106
+ disc.set(u, ++time);
1107
+ low.set(u, disc.get(u));
1108
+ let childCount = 0;
1109
+ for (const v of [...(adjacency.get(u) ?? [])].sort()) {
1110
+ if (!disc.has(v)) {
1111
+ parent.set(v, u);
1112
+ childCount++;
1113
+ visit(v);
1114
+ low.set(u, Math.min(low.get(u), low.get(v)));
1115
+ const uParent = parent.get(u) ?? null;
1116
+ if (uParent === null && childCount > 1)
1117
+ articulation.add(u);
1118
+ if (uParent !== null && low.get(v) >= disc.get(u))
1119
+ articulation.add(u);
1120
+ if (low.get(v) > disc.get(u)) {
1121
+ const [from, to] = [u, v].sort();
1122
+ bridges.push({ from, to });
1123
+ }
1124
+ }
1125
+ else if (v !== parent.get(u)) {
1126
+ low.set(u, Math.min(low.get(u), disc.get(v)));
1127
+ }
1128
+ }
1129
+ };
1130
+ for (const id of [...nodes].sort()) {
1131
+ if (!disc.has(id)) {
1132
+ parent.set(id, null);
1133
+ visit(id);
1134
+ }
1135
+ }
1136
+ bridges.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
1137
+ return {
1138
+ articulationPoints: [...articulation].sort(),
1139
+ bridges,
1140
+ };
1141
+ }
1142
+ /**
1143
+ * Compute a comprehensive offline graph-health report from a shaped graph.
1144
+ * All analytics operate on structural edges between item nodes only.
1145
+ */
1146
+ export function analyzeGraph(graph, topN = 10) {
1147
+ const items = [...itemNodeIds(graph)].sort();
1148
+ const edges = structuralEdges(graph);
1149
+ const inDegree = new Map();
1150
+ const outDegree = new Map();
1151
+ for (const id of items) {
1152
+ inDegree.set(id, 0);
1153
+ outDegree.set(id, 0);
1154
+ }
1155
+ for (const e of edges) {
1156
+ outDegree.set(e.from, (outDegree.get(e.from) ?? 0) + 1);
1157
+ inDegree.set(e.to, (inDegree.get(e.to) ?? 0) + 1);
1158
+ }
1159
+ // Orphans: no structural edges at all (no in, no out).
1160
+ const orphans = items.filter((id) => (inDegree.get(id) ?? 0) === 0 && (outDegree.get(id) ?? 0) === 0);
1161
+ // Roots: have outgoing/incoming structure but no INCOMING dependency edge.
1162
+ const roots = items.filter((id) => (inDegree.get(id) ?? 0) === 0 && (outDegree.get(id) ?? 0) > 0);
1163
+ // Leaves: have incoming structure but no outgoing dependency edge.
1164
+ const leaves = items.filter((id) => (outDegree.get(id) ?? 0) === 0 && (inDegree.get(id) ?? 0) > 0);
1165
+ const cycles = findCycles(items, edges);
1166
+ const longest = longestChain(items, edges);
1167
+ // Connected components over the UNDIRECTED projection of structural edges.
1168
+ const undirected = new Map();
1169
+ for (const id of items)
1170
+ undirected.set(id, new Set());
1171
+ for (const e of edges) {
1172
+ undirected.get(e.from)?.add(e.to);
1173
+ undirected.get(e.to)?.add(e.from);
1174
+ }
1175
+ const visited = new Set();
1176
+ let components = 0;
1177
+ for (const id of items) {
1178
+ if (visited.has(id))
1179
+ continue;
1180
+ components++;
1181
+ const queue = [id];
1182
+ visited.add(id);
1183
+ while (queue.length > 0) {
1184
+ const cur = queue.shift();
1185
+ for (const next of undirected.get(cur) ?? []) {
1186
+ if (!visited.has(next)) {
1187
+ visited.add(next);
1188
+ queue.push(next);
1189
+ }
1190
+ }
1191
+ }
1192
+ }
1193
+ // Blocked items: any item carrying a BLOCKED_BY outgoing edge.
1194
+ const blockedItems = items.filter((id) => edges.some((e) => e.from === id && e.type === "BLOCKED_BY"));
1195
+ const topDegreeCentrality = items
1196
+ .map((id) => ({
1197
+ id,
1198
+ degree: (inDegree.get(id) ?? 0) + (outDegree.get(id) ?? 0),
1199
+ inDegree: inDegree.get(id) ?? 0,
1200
+ outDegree: outDegree.get(id) ?? 0,
1201
+ }))
1202
+ .filter((d) => d.degree > 0)
1203
+ .sort((a, b) => b.degree - a.degree || a.id.localeCompare(b.id))
1204
+ .slice(0, topN);
1205
+ // Dependency depth per item: longest directed structural path starting at the
1206
+ // item (distance to a leaf). maxDepth is the depth of the critical path.
1207
+ const depths = dependencyDepths(items, edges);
1208
+ const depthByItem = items
1209
+ .map((id) => ({ id, depth: depths.get(id) ?? 0 }))
1210
+ .sort((a, b) => b.depth - a.depth || a.id.localeCompare(b.id));
1211
+ const maxDepth = depthByItem.reduce((max, d) => (d.depth > max ? d.depth : max), 0);
1212
+ const connectors = criticalConnectors(items, edges);
1213
+ return {
1214
+ workspace: graph.workspace,
1215
+ projectKey: graph.projectKey,
1216
+ itemCount: items.length,
1217
+ structuralEdgeCount: edges.length,
1218
+ cycleCount: cycles.length,
1219
+ cycles,
1220
+ orphanCount: orphans.length,
1221
+ orphans,
1222
+ rootCount: roots.length,
1223
+ roots,
1224
+ leafCount: leaves.length,
1225
+ leaves,
1226
+ longestChainLength: longest.length,
1227
+ longestChain: longest,
1228
+ connectedComponents: components,
1229
+ blockedItemCount: blockedItems.length,
1230
+ blockedItems,
1231
+ topDegreeCentrality,
1232
+ maxDepth,
1233
+ depthByItem,
1234
+ articulationPointCount: connectors.articulationPoints.length,
1235
+ articulationPoints: connectors.articulationPoints,
1236
+ bridgeEdgeCount: connectors.bridges.length,
1237
+ bridgeEdges: connectors.bridges,
1238
+ };
1239
+ }
615
1240
  async function syncNeo4j(graph, options) {
616
1241
  const driver = await createDriver();
617
1242
  const session = driver.session({ database: process.env.NEO4J_DATABASE });
@@ -1026,17 +1651,241 @@ export function activate(api) {
1026
1651
  }
1027
1652
  },
1028
1653
  });
1654
+ // --- pm-graph analyze ----------------------------------------------------
1655
+ api.registerCommand({
1656
+ name: "pm-graph analyze",
1657
+ description: "Comprehensive offline graph-health report: cycles, orphans, roots, leaves, longest chain, bottleneck connectors, degree centrality, components, blocked items.",
1658
+ run: async (context) => {
1659
+ if (hasHelpFlag(context)) {
1660
+ return {
1661
+ usage: "pm pm-graph analyze [--root <id>] [--depth <n>] [--include-closed] [--json]",
1662
+ description: "Build the workspace dependency graph offline (no Neo4j) and report its health: dependency cycle count, orphan/root/leaf items, longest dependency chain, top degree-centrality items, connected-component count, and blocked-item count. Operates on STRUCTURAL edges (BLOCKED_BY + CHILD_OF + dependency edges) only.",
1663
+ flags: {
1664
+ "--root <id>": "Restrict to the neighborhood of an item id",
1665
+ "--depth <n>": "Max hop distance from --root (non-negative integer)",
1666
+ "--include-closed": "Include closed/canceled items (excluded by default)",
1667
+ "--json": "Output as JSON",
1668
+ },
1669
+ output: {
1670
+ itemCount: "Number of item nodes analyzed",
1671
+ structuralEdgeCount: "Number of structural edges analyzed",
1672
+ cycleCount: "Number of distinct dependency cycles",
1673
+ orphans: "Item ids with no structural edges",
1674
+ roots: "Item ids with no incoming dependency edge",
1675
+ leaves: "Item ids with no outgoing dependency edge",
1676
+ longestChain: "Ordered ids of the longest dependency chain",
1677
+ connectedComponents: "Number of connected components (undirected projection)",
1678
+ blockedItems: "Item ids carrying a BLOCKED_BY edge",
1679
+ topDegreeCentrality: "Top-N items by total degree",
1680
+ maxDepth: "Longest dependency depth across all items (critical-path depth)",
1681
+ depthByItem: "Per-item dependency depth (longest path from the item to a leaf), sorted deepest-first",
1682
+ articulationPoints: "Items whose removal disconnects part of the structural graph",
1683
+ bridgeEdges: "Structural item-to-item links whose removal disconnects part of the structural graph",
1684
+ },
1685
+ };
1686
+ }
1687
+ const flags = parseAnalyticsFlags(context.args ?? []);
1688
+ const graph = shapedAnalyticsGraph(context, flags);
1689
+ const report = analyzeGraph(graph);
1690
+ return { ok: true, ...report };
1691
+ },
1692
+ });
1693
+ // --- pm-graph cycles -----------------------------------------------------
1694
+ api.registerCommand({
1695
+ name: "pm-graph cycles",
1696
+ description: "Detect and list dependency cycles among structural edges. Exits non-zero (1) when cycles exist — CI-usable.",
1697
+ run: async (context) => {
1698
+ if (hasHelpFlag(context)) {
1699
+ return {
1700
+ usage: "pm pm-graph cycles [--root <id>] [--depth <n>] [--include-closed] [--json]",
1701
+ description: "Detect dependency cycles among STRUCTURAL edges (BLOCKED_BY + dependency edges, not facet/tag edges). Prints each cycle as an id path. Exits with code 1 when any cycle exists (so it can gate CI); exits 0 when there are none.",
1702
+ flags: {
1703
+ "--root <id>": "Restrict to the neighborhood of an item id",
1704
+ "--depth <n>": "Max hop distance from --root",
1705
+ "--include-closed": "Include closed/canceled items",
1706
+ "--json": "Output as JSON",
1707
+ },
1708
+ output: {
1709
+ cycleCount: "Number of distinct cycles",
1710
+ cycles: "Array of cycles, each an ordered id path (first === last)",
1711
+ },
1712
+ };
1713
+ }
1714
+ const flags = parseAnalyticsFlags(context.args ?? []);
1715
+ const graph = shapedAnalyticsGraph(context, flags);
1716
+ const edges = structuralEdges(graph);
1717
+ const items = [...itemNodeIds(graph)].sort();
1718
+ const cycles = findCycles(items, edges);
1719
+ if (cycles.length > 0) {
1720
+ const detail = cycles.map((c) => c.join(" -> ")).join("; ");
1721
+ throw new CommandError(`Found ${cycles.length} dependency cycle(s): ${detail}`, EXIT_CODE.GENERIC_FAILURE);
1722
+ }
1723
+ return { ok: true, cycleCount: 0, cycles: [] };
1724
+ },
1725
+ });
1726
+ // --- pm-graph path -------------------------------------------------------
1727
+ api.registerCommand({
1728
+ name: "pm-graph path",
1729
+ description: "Shortest directed dependency path between two item ids (BFS over structural edges).",
1730
+ run: async (context) => {
1731
+ if (hasHelpFlag(context)) {
1732
+ return {
1733
+ usage: "pm pm-graph path <from> <to> [--include-closed] [--json]",
1734
+ description: "Compute the shortest directed dependency path from <from> to <to> via BFS over STRUCTURAL edges. Reports the ordered id path or that no path exists.",
1735
+ flags: {
1736
+ "--include-closed": "Include closed/canceled items",
1737
+ "--json": "Output as JSON",
1738
+ },
1739
+ example: "pm pm-graph path pm-ep18 pm-hd71 --json",
1740
+ output: {
1741
+ from: "Source item id",
1742
+ to: "Target item id",
1743
+ found: "Whether a directed path exists",
1744
+ path: "Ordered id path (inclusive) or null",
1745
+ length: "Number of edges on the path (path.length - 1) or null",
1746
+ },
1747
+ };
1748
+ }
1749
+ const flags = parseAnalyticsFlags(context.args ?? []);
1750
+ const [from, to] = flags.positionals;
1751
+ if (!from || !to) {
1752
+ throw new CommandError("Usage: pm pm-graph path <from> <to>\nExample: pm pm-graph path pm-ep18 pm-hd71", EXIT_CODE.USAGE);
1753
+ }
1754
+ const graph = shapedAnalyticsGraph(context, flags);
1755
+ const items = itemNodeIds(graph);
1756
+ if (!items.has(from)) {
1757
+ throw new CommandError(`Item "${from}" was not found in the workspace graph.`, EXIT_CODE.NOT_FOUND);
1758
+ }
1759
+ if (!items.has(to)) {
1760
+ throw new CommandError(`Item "${to}" was not found in the workspace graph.`, EXIT_CODE.NOT_FOUND);
1761
+ }
1762
+ const edges = structuralEdges(graph);
1763
+ const path = shortestPath(edges, from, to);
1764
+ return {
1765
+ ok: true,
1766
+ from,
1767
+ to,
1768
+ found: path !== null,
1769
+ path,
1770
+ length: path ? path.length - 1 : null,
1771
+ };
1772
+ },
1773
+ });
1774
+ // --- pm-graph critical-path ---------------------------------------------
1775
+ api.registerCommand({
1776
+ name: "pm-graph critical-path",
1777
+ description: "The longest chain of blocking dependencies through the workspace (the critical path), as an ordered id list.",
1778
+ run: async (context) => {
1779
+ if (hasHelpFlag(context)) {
1780
+ return {
1781
+ usage: "pm pm-graph critical-path [--root <id>] [--depth <n>] [--include-closed] [--json]",
1782
+ description: "Compute the longest chain of structural (blocking) dependencies through the workspace — the critical path. Reports the ordered id list and its length. Cycle-safe.",
1783
+ flags: {
1784
+ "--root <id>": "Restrict to the neighborhood of an item id",
1785
+ "--depth <n>": "Max hop distance from --root",
1786
+ "--include-closed": "Include closed/canceled items",
1787
+ "--json": "Output as JSON",
1788
+ },
1789
+ output: {
1790
+ length: "Number of items on the critical path",
1791
+ path: "Ordered id list of the critical path",
1792
+ },
1793
+ };
1794
+ }
1795
+ const flags = parseAnalyticsFlags(context.args ?? []);
1796
+ const graph = shapedAnalyticsGraph(context, flags);
1797
+ const edges = structuralEdges(graph);
1798
+ const items = [...itemNodeIds(graph)].sort();
1799
+ const chain = longestChain(items, edges);
1800
+ return { ok: true, length: chain.length, path: chain };
1801
+ },
1802
+ });
1803
+ // --- pm-graph topo-sort --------------------------------------------------
1804
+ api.registerCommand({
1805
+ name: "pm-graph topo-sort",
1806
+ description: "Topological execution order respecting dependency/blocked_by edges (Kahn's algorithm). Exits non-zero (1) on a cycle.",
1807
+ run: async (context) => {
1808
+ if (hasHelpFlag(context)) {
1809
+ return {
1810
+ usage: "pm pm-graph topo-sort [--root <id>] [--depth <n>] [--include-closed] [--json]",
1811
+ description: "Emit a valid topological execution order of items over STRUCTURAL edges (BLOCKED_BY + dependency edges), so each item is listed only after the items it depends on. Uses Kahn's algorithm. Ties are broken by ascending id for deterministic output. Reports the resolvable prefix and the cycle members, and EXITS WITH CODE 1 when a dependency cycle prevents a complete ordering (CI-usable).",
1812
+ flags: {
1813
+ "--root <id>": "Restrict to the neighborhood of an item id",
1814
+ "--depth <n>": "Max hop distance from --root",
1815
+ "--include-closed": "Include closed/canceled items",
1816
+ "--json": "Output as JSON",
1817
+ },
1818
+ output: {
1819
+ order: "Ordered item ids (dependencies before dependents); complete when acyclic",
1820
+ count: "Number of items placed in the order",
1821
+ cyclic: "Whether a cycle prevented a complete ordering",
1822
+ },
1823
+ };
1824
+ }
1825
+ const flags = parseAnalyticsFlags(context.args ?? []);
1826
+ const graph = shapedAnalyticsGraph(context, flags);
1827
+ const edges = structuralEdges(graph);
1828
+ const items = [...itemNodeIds(graph)].sort();
1829
+ const { order, cycleNodes } = topoSort(items, edges);
1830
+ if (cycleNodes.length > 0) {
1831
+ // Surface the actual cycle path(s) among the unresolved nodes for context.
1832
+ const cycles = findCycles(cycleNodes, edges);
1833
+ const detail = cycles.length > 0
1834
+ ? cycles.map((c) => c.join(" -> ")).join("; ")
1835
+ : cycleNodes.join(", ");
1836
+ throw new CommandError(`Cannot produce a topological order: ${cycleNodes.length} item(s) are involved in a dependency cycle (${detail}). Resolved prefix: ${order.length} item(s).`, EXIT_CODE.GENERIC_FAILURE);
1837
+ }
1838
+ return { ok: true, count: order.length, cyclic: false, order };
1839
+ },
1840
+ });
1841
+ // --- pm-graph impact -----------------------------------------------------
1842
+ api.registerCommand({
1843
+ name: "pm-graph impact",
1844
+ description: "List all items transitively blocked-by / downstream of an item id (reverse-reachable set) with a count.",
1845
+ run: async (context) => {
1846
+ if (hasHelpFlag(context)) {
1847
+ return {
1848
+ usage: "pm pm-graph impact <id> [--include-closed] [--json]",
1849
+ description: "Compute the impact set of an item: every item that transitively depends on it (is blocked-by / downstream of it) over STRUCTURAL edges. Equivalent to the reverse-reachable set following edge direction. Complements `path` and `neighbors`. Reports the affected item ids and their count.",
1850
+ flags: {
1851
+ "--include-closed": "Include closed/canceled items",
1852
+ "--json": "Output as JSON",
1853
+ },
1854
+ example: "pm pm-graph impact pm-ep18 --json",
1855
+ output: {
1856
+ id: "The item whose downstream impact was computed",
1857
+ count: "Number of items transitively affected",
1858
+ impacted: "Sorted ids of all items transitively blocked-by/downstream of <id>",
1859
+ },
1860
+ };
1861
+ }
1862
+ const flags = parseAnalyticsFlags(context.args ?? []);
1863
+ const [id] = flags.positionals;
1864
+ if (!id) {
1865
+ throw new CommandError("Usage: pm pm-graph impact <id>\nExample: pm pm-graph impact pm-ep18", EXIT_CODE.USAGE);
1866
+ }
1867
+ const graph = shapedAnalyticsGraph(context, flags);
1868
+ const items = itemNodeIds(graph);
1869
+ if (!items.has(id)) {
1870
+ throw new CommandError(`Item "${id}" was not found in the workspace graph.`, EXIT_CODE.NOT_FOUND);
1871
+ }
1872
+ const edges = structuralEdges(graph);
1873
+ const impacted = reverseReachable(edges, id);
1874
+ return { ok: true, id, count: impacted.length, impacted };
1875
+ },
1876
+ });
1029
1877
  // --- pm graph export -----------------------------------------------------
1030
1878
  // registerExporter("graph") auto-creates the `pm graph export` command (the
1031
1879
  // `<name> export` form). It does NOT collide with the existing
1032
1880
  // `pm pm-graph cypher` command (different name). The export pipeline builds
1033
1881
  // the workspace graph from a single `pm list-all --json --include-body`
1034
- // call and renders it to one of four offline formats. No Neo4j required.
1882
+ // call and renders it to one of six offline formats (cypher | mermaid | dot |
1883
+ // json | graphml | plantuml). No Neo4j required.
1035
1884
  const exporter = (ctx) => {
1036
1885
  const options = ctx.options ?? {};
1037
1886
  const rawFormat = String(readExportOption(options, "format") ?? "json").toLowerCase();
1038
- if (!["cypher", "mermaid", "dot", "json"].includes(rawFormat)) {
1039
- throw new CommandError(`Unknown --format "${rawFormat}". Valid: cypher | mermaid | dot | json.`, EXIT_CODE.USAGE);
1887
+ if (!["cypher", "mermaid", "dot", "json", "graphml", "plantuml"].includes(rawFormat)) {
1888
+ throw new CommandError(`Unknown --format "${rawFormat}". Valid: cypher | mermaid | dot | json | graphml | plantuml.`, EXIT_CODE.USAGE);
1040
1889
  }
1041
1890
  const format = rawFormat;
1042
1891
  const rawEdges = String(readExportOption(options, "edges") ?? "all").toLowerCase();