@growthub/cli 0.14.8 → 0.14.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (16) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/SKILL.md +21 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +1 -1
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/metadata-graph/route.js +49 -1
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/patch/preflight/route.js +38 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +27 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-app-readiness.js +212 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-contract-compliance.js +168 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-impact.js +198 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-patch-impact.js +133 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-provenance-lineage.js +214 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-stale-surfaces.js +217 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-workflow-impact.js +170 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +7 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/skills/governed-workspace-mutation/SKILL.md +13 -0
  15. package/dist/index.js +3024 -4191
  16. package/package.json +1 -1
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Growthub Workspace Blast-Radius V1 — transitive impact deriver.
3
+ *
4
+ * Answers the one question the single-hop primitives cannot: *if I change (or
5
+ * remove) this node, what is the FULL downstream set that goes stale?*
6
+ *
7
+ * The metadata graph (`workspace-metadata-graph.js`) already ships:
8
+ * - `findDependents(graph, nodeId)` — the nodes ONE hop upstream (incoming edges)
9
+ * - `selectImpactedNodes(graph, nodeId)` (selectors) — a thin wrapper over it
10
+ *
11
+ * Both stop at the first hop. Editing a field surfaces the widgets that use it,
12
+ * but NOT the dashboards that contain those widgets, nor the runs that executed
13
+ * a workflow whose node reads the object. This module generalises
14
+ * `findDependents` into its transitive closure: a deterministic breadth-first
15
+ * walk of incoming edges, returning every reachable dependent with its hop
16
+ * distance and the relation it was reached through.
17
+ *
18
+ * It is a PURE module — no React, no fetch, no fs, no writes, no localStorage,
19
+ * no CSS. It introduces NO new graph, NO new mutation path: it reads the
20
+ * read-only graph the Workspace Map already builds, and emits a low-entropy
21
+ * view-model that the Map inspector, `patch/preflight`, the CLI `plan` command,
22
+ * and an MCP `simulate_causal_impact` tool can all consume from one source of
23
+ * truth. It contains no secrets — it carries only the same compact node
24
+ * summaries the graph already exposes.
25
+ *
26
+ * Determinism: results are ordered (distance → type → id) and the BFS visits
27
+ * each node once, so a cycle in the graph terminates and the output diffs
28
+ * cleanly between calls.
29
+ */
30
+
31
+ const BLAST_RADIUS_KIND = "growthub-workspace-blast-radius-v1";
32
+ const BLAST_RADIUS_VERSION = 1;
33
+
34
+ // Bound the walk so a pathological graph can never produce an unbounded
35
+ // payload. Honest truncation (`truncated: true`) beats a silent cap.
36
+ const DEFAULT_MAX_NODES = 500;
37
+
38
+ function safeString(value) {
39
+ if (value == null) return "";
40
+ return typeof value === "string" ? value : String(value);
41
+ }
42
+
43
+ function summarizeOrigin(node) {
44
+ if (!node || typeof node !== "object") return null;
45
+ return {
46
+ id: node.id,
47
+ type: node.type,
48
+ label: node.label,
49
+ metadataId: node.metadataId
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Build a `Map<toId, Array<{ from, relation }>>` of incoming edges once, so the
55
+ * transitive walk is linear in (nodes + edges) instead of calling the O(N)
56
+ * `findDependents` per visited node. This is the transitive form of
57
+ * `findDependents`; the per-hop semantics are identical.
58
+ */
59
+ function buildIncomingIndex(graph) {
60
+ const incoming = new Map();
61
+ const edges = Array.isArray(graph?.edges) ? graph.edges : [];
62
+ for (const edge of edges) {
63
+ if (!edge || edge.from == null || edge.to == null) continue;
64
+ const key = String(edge.to);
65
+ if (!incoming.has(key)) incoming.set(key, []);
66
+ incoming.get(key).push({ from: String(edge.from), relation: edge.relation });
67
+ }
68
+ return incoming;
69
+ }
70
+
71
+ /**
72
+ * Compute the blast radius of a node — every node that transitively depends on
73
+ * it (reverse edge closure).
74
+ *
75
+ * @param {object} graph a `buildWorkspaceMetadataGraph` envelope
76
+ * @param {string} originId the metadataId of the node being changed/removed
77
+ * @param {object} [options]
78
+ * @param {number} [options.maxNodes=500] hard cap on impacted nodes
79
+ * @param {number} [options.maxDistance] optional hop limit (omit = unbounded)
80
+ * @returns {object} `{ kind, version, origin, impacted[], byType, total, maxDistanceReached, truncated, summary, warnings }`
81
+ */
82
+ function deriveBlastRadius(graph, originId, options = {}) {
83
+ const maxNodes = Number.isFinite(options.maxNodes) && options.maxNodes > 0
84
+ ? Math.floor(options.maxNodes)
85
+ : DEFAULT_MAX_NODES;
86
+ const maxDistance = Number.isFinite(options.maxDistance) && options.maxDistance > 0
87
+ ? Math.floor(options.maxDistance)
88
+ : Infinity;
89
+
90
+ const empty = (warning) => ({
91
+ kind: BLAST_RADIUS_KIND,
92
+ version: BLAST_RADIUS_VERSION,
93
+ origin: null,
94
+ impacted: [],
95
+ byType: {},
96
+ total: 0,
97
+ maxDistanceReached: 0,
98
+ truncated: false,
99
+ summary: "No impact computed.",
100
+ warnings: warning ? [warning] : []
101
+ });
102
+
103
+ const id = safeString(originId).trim();
104
+ if (!graph || typeof graph !== "object" || !Array.isArray(graph.nodes)) {
105
+ return empty("graph missing or malformed");
106
+ }
107
+ if (!id) return empty("originId missing");
108
+
109
+ const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
110
+ const originNode = nodesById.get(id);
111
+ if (!originNode) return empty(`origin "${id}" not found in graph`);
112
+
113
+ const incoming = buildIncomingIndex(graph);
114
+
115
+ const visited = new Set([id]);
116
+ const impacted = [];
117
+ let truncated = false;
118
+ let maxDistanceReached = 0;
119
+
120
+ // FIFO queue → breadth-first, so the first time a node is reached is via its
121
+ // shortest dependency path (the most direct reason it goes stale).
122
+ const queue = [{ id, distance: 0 }];
123
+ while (queue.length) {
124
+ const current = queue.shift();
125
+ if (current.distance >= maxDistance) continue;
126
+ const dependents = incoming.get(current.id) || [];
127
+ for (const { from, relation } of dependents) {
128
+ if (visited.has(from)) continue;
129
+ const node = nodesById.get(from);
130
+ if (!node) continue;
131
+ if (impacted.length >= maxNodes) {
132
+ truncated = true;
133
+ continue;
134
+ }
135
+ visited.add(from);
136
+ const distance = current.distance + 1;
137
+ maxDistanceReached = Math.max(maxDistanceReached, distance);
138
+ impacted.push({
139
+ id: node.id,
140
+ type: node.type,
141
+ label: node.label,
142
+ metadataId: node.metadataId,
143
+ distance,
144
+ viaRelation: relation
145
+ });
146
+ queue.push({ id: from, distance });
147
+ }
148
+ }
149
+
150
+ // Deterministic order: nearest first, then by type, then by id.
151
+ impacted.sort((a, b) =>
152
+ a.distance - b.distance ||
153
+ a.type.localeCompare(b.type) ||
154
+ a.id.localeCompare(b.id)
155
+ );
156
+
157
+ const byType = {};
158
+ for (const entry of impacted) {
159
+ byType[entry.type] = (byType[entry.type] || 0) + 1;
160
+ }
161
+
162
+ return {
163
+ kind: BLAST_RADIUS_KIND,
164
+ version: BLAST_RADIUS_VERSION,
165
+ origin: summarizeOrigin(originNode),
166
+ impacted,
167
+ byType,
168
+ total: impacted.length,
169
+ maxDistanceReached,
170
+ truncated,
171
+ summary: summarizeBlastRadius(originNode, impacted, byType, truncated),
172
+ warnings: []
173
+ };
174
+ }
175
+
176
+ /**
177
+ * One human sentence for the inspector chip / PR comment / CLI line.
178
+ * Pure string assembly — never throws.
179
+ */
180
+ function summarizeBlastRadius(originNode, impacted, byType, truncated) {
181
+ const label = originNode?.label || originNode?.id || "node";
182
+ if (!impacted.length) {
183
+ return `Changing "${label}" has no downstream impact — nothing depends on it.`;
184
+ }
185
+ const parts = Object.keys(byType)
186
+ .sort()
187
+ .map((type) => `${byType[type]} ${type}`);
188
+ const tail = truncated ? " (truncated)" : "";
189
+ return `Changing "${label}" affects ${impacted.length} downstream node(s): ${parts.join(", ")}${tail}.`;
190
+ }
191
+
192
+ export {
193
+ BLAST_RADIUS_KIND,
194
+ BLAST_RADIUS_VERSION,
195
+ DEFAULT_MAX_NODES,
196
+ deriveBlastRadius,
197
+ summarizeBlastRadius
198
+ };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Growthub Workspace Patch-Impact V1 — the single, authoritative "what does this
3
+ * patch actually change?" deriver, shared by the preflight route and the CLI.
4
+ *
5
+ * A dataModel/dashboards PATCH REPLACES the whole array, so the patch body alone
6
+ * is never "what changed". This diffs the CURRENT config against the MERGED
7
+ * (post-patch) config and reports THREE classes of change, each with downstream
8
+ * impact:
9
+ *
10
+ * - added / modified objects + dashboards → seeded on the MERGED graph
11
+ * (their new dependents).
12
+ * - REMOVED objects + dashboards → seeded on the CURRENT graph, because the
13
+ * deleted node no longer exists in the merged graph; the surfaces that
14
+ * depended on it are the blast radius of deleting it. Without this, deleting
15
+ * a business surface would silently report no impact — the highest-risk
16
+ * false confidence.
17
+ *
18
+ * Pure: composes `deriveStaleSurfaces` (which composes the blast-radius spine),
19
+ * no new traversal, no writes, no secrets.
20
+ */
21
+
22
+ import { deriveStaleSurfaces } from "./workspace-stale-surfaces.js";
23
+
24
+ const PATCH_IMPACT_KIND = "growthub-workspace-patch-impact-v1";
25
+ const PATCH_IMPACT_VERSION = 1;
26
+
27
+ function objectIndex(config) {
28
+ return new Map((config?.dataModel?.objects || []).map((o) => [o && o.id, JSON.stringify(o)]));
29
+ }
30
+ function dashboardIndex(config) {
31
+ return new Map((config?.dashboards || []).map((d) => [d && (d.id || d.name), JSON.stringify(d)]));
32
+ }
33
+
34
+ /**
35
+ * @param {object} currentGraph graph built from the CURRENT config (pre-patch)
36
+ * @param {object} mergedGraph graph built from the MERGED config (post-patch)
37
+ * @param {object} currentConfig
38
+ * @param {object} mergedConfig
39
+ * @returns {object} `{ kind, version, scope, seeds[], total, byType, staleSurfaces[], removed[], summary, warnings }`
40
+ */
41
+ function derivePatchImpact(currentGraph, mergedGraph, currentConfig, mergedConfig) {
42
+ const empty = (warning) => ({
43
+ kind: PATCH_IMPACT_KIND,
44
+ version: PATCH_IMPACT_VERSION,
45
+ scope: "changed-only",
46
+ seeds: [],
47
+ total: 0,
48
+ byType: {},
49
+ staleSurfaces: [],
50
+ removed: [],
51
+ summary: "No object or dashboard added, modified, or removed.",
52
+ warnings: warning ? [warning] : []
53
+ });
54
+
55
+ if (!mergedGraph || !Array.isArray(mergedGraph.nodes)) return empty("merged graph missing");
56
+
57
+ const curObjects = objectIndex(currentConfig);
58
+ const mergedObjects = objectIndex(mergedConfig);
59
+ const curDashboards = dashboardIndex(currentConfig);
60
+ const mergedDashboards = dashboardIndex(mergedConfig);
61
+
62
+ const changedObjectIds = new Set();
63
+ const removedObjectIds = new Set();
64
+ for (const [id, json] of mergedObjects) if (id && curObjects.get(id) !== json) changedObjectIds.add(id);
65
+ for (const [id] of curObjects) if (id && !mergedObjects.has(id)) removedObjectIds.add(id);
66
+
67
+ const changedDashboardIds = new Set();
68
+ const removedDashboardIds = new Set();
69
+ for (const [id, json] of mergedDashboards) if (id && curDashboards.get(id) !== json) changedDashboardIds.add(id);
70
+ for (const [id] of curDashboards) if (id && !mergedDashboards.has(id)) removedDashboardIds.add(id);
71
+
72
+ // ── added / modified: seed on the MERGED graph ──────────────────────────
73
+ const changedSeeds = [];
74
+ for (const node of mergedGraph.nodes) {
75
+ if (node.type === "dataModelObject" && (changedObjectIds.has(node.summary?.objectId) || changedObjectIds.has(node.metadataId))) {
76
+ changedSeeds.push(node.id);
77
+ } else if (node.type === "dashboard" && (changedDashboardIds.has(node.metadataId) || changedDashboardIds.has(node.label))) {
78
+ changedSeeds.push(node.id);
79
+ }
80
+ }
81
+ const changedStale = changedSeeds.length ? deriveStaleSurfaces(mergedGraph, { seedIds: changedSeeds }) : null;
82
+
83
+ // ── removed: seed on the CURRENT graph (the deleted node lived there) ────
84
+ const removed = [];
85
+ const currentNodes = (currentGraph && Array.isArray(currentGraph.nodes)) ? currentGraph.nodes : [];
86
+ for (const node of currentNodes) {
87
+ const isRemovedObject = node.type === "dataModelObject" && (removedObjectIds.has(node.summary?.objectId) || removedObjectIds.has(node.metadataId));
88
+ const isRemovedDashboard = node.type === "dashboard" && (removedDashboardIds.has(node.metadataId) || removedDashboardIds.has(node.label));
89
+ if (!isRemovedObject && !isRemovedDashboard) continue;
90
+ const downstream = deriveStaleSurfaces(currentGraph, { seedIds: [node.id] });
91
+ removed.push({
92
+ id: node.id,
93
+ type: node.type,
94
+ label: node.label,
95
+ metadataId: node.metadataId,
96
+ affectedTotal: downstream.total,
97
+ affected: downstream.staleSurfaces,
98
+ summary: `Removing "${node.label}" affects ${downstream.total} downstream surface(s) that depend on it.`
99
+ });
100
+ }
101
+
102
+ if (!changedStale && !removed.length) return empty();
103
+
104
+ return {
105
+ kind: PATCH_IMPACT_KIND,
106
+ version: PATCH_IMPACT_VERSION,
107
+ scope: "changed-only",
108
+ seeds: changedStale ? changedStale.seeds : [],
109
+ total: changedStale ? changedStale.total : 0,
110
+ byType: changedStale ? changedStale.byType : {},
111
+ staleSurfaces: changedStale ? changedStale.staleSurfaces : [],
112
+ removed,
113
+ summary: summarizePatchImpact(changedStale, removed),
114
+ warnings: removed.length ? [`${removed.length} object/dashboard removal(s) — review affected downstream before applying.`] : []
115
+ };
116
+ }
117
+
118
+ function summarizePatchImpact(changedStale, removed) {
119
+ const parts = [];
120
+ if (changedStale && changedStale.total) parts.push(`${changedStale.total} surface(s) stale from add/modify`);
121
+ if (removed.length) {
122
+ const affected = removed.reduce((sum, r) => sum + (r.affectedTotal || 0), 0);
123
+ parts.push(`${removed.length} removal(s) affecting ${affected} downstream surface(s)`);
124
+ }
125
+ return parts.length ? `Patch impact: ${parts.join("; ")}.` : "No downstream impact.";
126
+ }
127
+
128
+ export {
129
+ PATCH_IMPACT_KIND,
130
+ PATCH_IMPACT_VERSION,
131
+ derivePatchImpact,
132
+ summarizePatchImpact
133
+ };
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Growthub Workspace Provenance-Lineage V1 — bidirectional lineage deriver.
3
+ *
4
+ * The mirror twin of `deriveBlastRadius`. It exposes BOTH transitive directions
5
+ * over the SAME edge taxonomy, named to MATCH the graph's own helper contract
6
+ * (`findDependents` = incoming, `findDependencies` = outgoing) so an agent never
7
+ * mis-reads a consumer as a producer:
8
+ *
9
+ * - dependents — transitive INCOMING closure (generalises `findDependents`):
10
+ * the nodes that DEPEND ON this node — its consumers and the
11
+ * things impacted if it changes (e.g. a widget that binds an
12
+ * object is the object's dependent). "What depends on this?"
13
+ * - dependencies — transitive OUTGOING closure (generalises `findDependencies`):
14
+ * the nodes this one DEPENDS ON — what it is built from /
15
+ * reads (e.g. an object's source record). "What does this
16
+ * depend on?"
17
+ *
18
+ * `ancestors` / `descendants` are kept ONLY as backward-compatible aliases of
19
+ * `dependents` / `dependencies` respectively — they read intuitively for the
20
+ * run→artifact case but mislead for objects/widgets/dashboards, so prefer the
21
+ * canonical names. The `direction` option accepts `dependents` | `dependencies`
22
+ * | `both` (and the legacy `ancestors` | `descendants`).
23
+ *
24
+ * One bounded, cycle-safe, deterministic BFS — the same skeleton the spine uses.
25
+ * No new graph, no new edges, no mutation, no secrets. `selectRunLineage` is the
26
+ * flat single-run ancestor of this module.
27
+ */
28
+
29
+ import { summarizeGraphNode } from "./workspace-metadata-graph.js";
30
+ import { deriveBlastRadius } from "./workspace-metadata-impact.js";
31
+
32
+ const PROVENANCE_KIND = "growthub-workspace-provenance-lineage-v1";
33
+ const PROVENANCE_VERSION = 1;
34
+
35
+ const DEFAULT_MAX_NODES = 500;
36
+
37
+ function safeString(value) {
38
+ if (value == null) return "";
39
+ return typeof value === "string" ? value : String(value);
40
+ }
41
+
42
+ /**
43
+ * Build the OUTGOING adjacency index once: `Map<fromId, Array<{ to, relation }>>`.
44
+ * (The INCOMING/dependents direction is NOT re-implemented here — it reuses the
45
+ * shipped `deriveBlastRadius` reverse closure, the single source of truth for
46
+ * incoming-edge traversal.)
47
+ */
48
+ function buildOutgoingIndex(graph) {
49
+ const adjacency = new Map();
50
+ const edges = Array.isArray(graph?.edges) ? graph.edges : [];
51
+ for (const edge of edges) {
52
+ if (!edge || edge.from == null || edge.to == null) continue;
53
+ const key = String(edge.from);
54
+ if (!adjacency.has(key)) adjacency.set(key, []);
55
+ adjacency.get(key).push({ to: String(edge.to), relation: edge.relation });
56
+ }
57
+ return adjacency;
58
+ }
59
+
60
+ /**
61
+ * Bounded, cycle-safe BFS from `originId` over a pre-built adjacency index.
62
+ * Mirrors the blast-radius walk: FIFO queue → shortest path first, each node
63
+ * visited once, honest truncation past `maxNodes`.
64
+ */
65
+ function walk(adjacency, nodesById, originId, maxNodes) {
66
+ const visited = new Set([originId]);
67
+ const reached = [];
68
+ let truncated = false;
69
+ let maxDistanceReached = 0;
70
+ const queue = [{ id: originId, distance: 0 }];
71
+ while (queue.length) {
72
+ const current = queue.shift();
73
+ const neighbours = adjacency.get(current.id) || [];
74
+ for (const { to, relation } of neighbours) {
75
+ if (visited.has(to)) continue;
76
+ const node = nodesById.get(to);
77
+ if (!node) continue;
78
+ if (reached.length >= maxNodes) {
79
+ truncated = true;
80
+ continue;
81
+ }
82
+ visited.add(to);
83
+ const distance = current.distance + 1;
84
+ maxDistanceReached = Math.max(maxDistanceReached, distance);
85
+ reached.push({
86
+ id: node.id,
87
+ type: node.type,
88
+ label: node.label,
89
+ metadataId: node.metadataId,
90
+ distance,
91
+ viaRelation: relation
92
+ });
93
+ queue.push({ id: to, distance });
94
+ }
95
+ }
96
+ reached.sort((a, b) =>
97
+ a.distance - b.distance ||
98
+ a.type.localeCompare(b.type) ||
99
+ a.id.localeCompare(b.id)
100
+ );
101
+ return { reached, truncated, maxDistanceReached };
102
+ }
103
+
104
+ /**
105
+ * @param {object} graph a `buildWorkspaceMetadataGraph` envelope
106
+ * @param {string} originId the metadataId to trace lineage for
107
+ * @param {object} [options]
108
+ * @param {"dependents"|"dependencies"|"both"|"ancestors"|"descendants"} [options.direction="both"]
109
+ * @param {number} [options.maxNodes=500] hard cap PER direction
110
+ * @returns {object} `{ kind, version, origin, direction, dependents[], dependencies[],
111
+ * ancestors[] (alias of dependents), descendants[] (alias of dependencies),
112
+ * byType, truncated, summary, warnings }`
113
+ */
114
+ function deriveProvenanceLineage(graph, originId, options = {}) {
115
+ const maxNodes = Number.isFinite(options.maxNodes) && options.maxNodes > 0
116
+ ? Math.floor(options.maxNodes)
117
+ : DEFAULT_MAX_NODES;
118
+ // Normalise the requested direction to the canonical names (legacy aliases
119
+ // ancestors→dependents, descendants→dependencies).
120
+ const requested = safeString(options.direction) || "both";
121
+ const direction = requested === "ancestors" ? "dependents"
122
+ : requested === "descendants" ? "dependencies"
123
+ : ["dependents", "dependencies", "both"].includes(requested) ? requested
124
+ : "both";
125
+
126
+ const empty = (warning) => ({
127
+ kind: PROVENANCE_KIND,
128
+ version: PROVENANCE_VERSION,
129
+ origin: null,
130
+ direction,
131
+ dependents: [],
132
+ dependencies: [],
133
+ ancestors: [],
134
+ descendants: [],
135
+ byType: { dependents: {}, dependencies: {} },
136
+ truncated: false,
137
+ summary: "No lineage computed.",
138
+ warnings: warning ? [warning] : []
139
+ });
140
+
141
+ if (!graph || typeof graph !== "object" || !Array.isArray(graph.nodes)) {
142
+ return empty("graph missing or malformed");
143
+ }
144
+ const id = safeString(originId).trim();
145
+ if (!id) return empty("originId missing");
146
+
147
+ const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
148
+ const originNode = nodesById.get(id);
149
+ if (!originNode) return empty(`origin "${id}" not found in graph`);
150
+
151
+ let dependents = [];
152
+ let dependencies = [];
153
+ let truncated = false;
154
+
155
+ if (direction === "dependents" || direction === "both") {
156
+ // Reuse the spine — `dependents` IS the transitive incoming (reverse) closure
157
+ // that deriveBlastRadius already computes. No second incoming BFS.
158
+ const blast = deriveBlastRadius(graph, id, { maxNodes });
159
+ dependents = blast.impacted;
160
+ truncated = truncated || blast.truncated;
161
+ }
162
+ if (direction === "dependencies" || direction === "both") {
163
+ const res = walk(buildOutgoingIndex(graph), nodesById, id, maxNodes);
164
+ dependencies = res.reached;
165
+ truncated = truncated || res.truncated;
166
+ }
167
+
168
+ const countByType = (entries) => {
169
+ const out = {};
170
+ for (const entry of entries) out[entry.type] = (out[entry.type] || 0) + 1;
171
+ return out;
172
+ };
173
+
174
+ return {
175
+ kind: PROVENANCE_KIND,
176
+ version: PROVENANCE_VERSION,
177
+ origin: summarizeGraphNode(originNode),
178
+ direction,
179
+ // Canonical names — match findDependents (incoming) / findDependencies (outgoing).
180
+ dependents,
181
+ dependencies,
182
+ // Backward-compatible aliases (deprecated; can mislead for non-run nodes).
183
+ ancestors: dependents,
184
+ descendants: dependencies,
185
+ byType: { dependents: countByType(dependents), dependencies: countByType(dependencies) },
186
+ truncated,
187
+ summary: summarizeLineage(originNode, dependents, dependencies, direction, truncated),
188
+ warnings: []
189
+ };
190
+ }
191
+
192
+ function summarizeLineage(originNode, dependents, dependencies, direction, truncated) {
193
+ const label = originNode?.label || originNode?.id || "node";
194
+ const tail = truncated ? " (truncated)" : "";
195
+ if (direction === "dependents") {
196
+ return dependents.length
197
+ ? `${dependents.length} node(s) depend on "${label}"${tail}.`
198
+ : `Nothing depends on "${label}".`;
199
+ }
200
+ if (direction === "dependencies") {
201
+ return dependencies.length
202
+ ? `"${label}" depends on ${dependencies.length} node(s)${tail}.`
203
+ : `"${label}" depends on nothing.`;
204
+ }
205
+ return `"${label}": ${dependents.length} dependent(s), ${dependencies.length} dependenc(ies)${tail}.`;
206
+ }
207
+
208
+ export {
209
+ PROVENANCE_KIND,
210
+ PROVENANCE_VERSION,
211
+ DEFAULT_MAX_NODES,
212
+ deriveProvenanceLineage,
213
+ summarizeLineage
214
+ };