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/CHANGELOG.md +34 -1
- package/README.md +126 -7
- package/dist/index.d.ts +140 -1
- package/dist/index.js +853 -4
- package/dist/index.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +7 -7
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.
|
|
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, "&")
|
|
693
|
+
.replace(/</g, "<")
|
|
694
|
+
.replace(/>/g, ">")
|
|
695
|
+
.replace(/"/g, """)
|
|
696
|
+
.replace(/'/g, "'");
|
|
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
|
|
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();
|