@yesprasad/fluent-graph 0.1.0 β†’ 0.2.0

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/LICENSE CHANGED
@@ -22,7 +22,7 @@ Built with focus on making ServiceNow Pro Developer Fluent development with now-
22
22
 
23
23
  ## Acknowledgments
24
24
 
25
- ServiceNow SDK team for building the Fluent DSL compiler
25
+ ServiceNow SDK team for building the now-sdk and Fluent DSL compiler
26
26
  MIT License
27
27
 
28
28
  Copyright (c) 2024 ServiceNow Inc.
package/README.md CHANGED
@@ -1,11 +1,44 @@
1
1
  # fluent-graph
2
2
 
3
- **Build-time lineage extraction and impact analysis for ServiceNow Fluent SDK projects.**
3
+ **Dependency visibility and impact analysis for ServiceNow Fluent SDK projects.**
4
+
5
+ **Catch breaking changes before deployment by mapping hidden relationships between tables, fields, scripts, and UI actions.**
6
+
7
+ ---
8
+
9
+ ## The Problem
10
+
11
+ You're refactoring a ServiceNow custom table. You delete a field. You deploy.
12
+
13
+ **Production breaks.**
14
+
15
+ 14 client scripts fail. 3 business rules error out. 2 dashboards go blank.
16
+
17
+ **You realise the breaking changes at runtime in production instance.**
18
+
19
+ ---
20
+ ## The Solution
21
+ **Welcome to Fluent-Graph**
4
22
 
5
23
  `fluent-graph` observes the ServiceNow SDK's internal compilation process to extract dependency graphs, detect identity conflicts, and perform blast-radius analysisβ€”without modifying your source code or re-parsing Fluent DSL.
6
24
 
25
+ run - npx @yesprasad/fluent-graph blast incident
26
+ **Output:**
27
+ ```
28
+ πŸ”₯ BLAST RADIUS ANALYSIS
29
+ Target: incident
30
+
31
+ ⚠️ 3 artifacts will break if you modify this table:
32
+ β€’ bank_incident_onload (Client Script)
33
+ β€’ cs_incident_onload (Client Script)
34
+ β€’ cs_incident_onchange_caller (Client Script)
35
+ ```
36
+
37
+ πŸ”₯ **Now you know before you deploy.**
38
+
7
39
  ---
8
40
 
41
+
9
42
  ## What It Does
10
43
 
11
44
  - **Extracts artifact lineage** from SDK projects (tables, columns, scripts, business rules)
@@ -34,7 +67,7 @@ fluent-graph blast <table_name or artifact_name>
34
67
  **Or use directly with npx (no installation needed):**
35
68
  ```bash
36
69
  cd your-fluent-project
37
- npx fluent-graph extract
70
+ npx @yesprasad/fluent-graph extract
38
71
  ```
39
72
 
40
73
  **Verify installation:**
@@ -48,59 +81,6 @@ fluent-graph --version
48
81
 
49
82
  **Output:**
50
83
  ```
51
- βœ” βœ… SDK detected at /node_modules/@servicenow/sdk
52
- βœ” Workspace SDK loaded
53
- βœ” Project loaded
54
- βœ” Graph built: 181 nodes, 141 edges
55
-
56
- πŸ“Š LINEAGE MAP
57
- ────────────────────────────────────────────────────────────
58
-
59
- πŸ“¦ DATA SCHEMA
60
- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
61
- β”‚ Table β”‚ Status β”‚
62
- β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
63
- β”‚ x_1566039_seventh_critical_account β”‚ [ACTIVE] β”‚
64
- β”‚ x_1566039_seventh_critical_account_incident_ext β”‚ [ACTIVE] β”‚
65
- β”‚ x_1566039_seventh_customer β”‚ [ACTIVE] β”‚
66
- β”‚ x_1566039_seventh_account β”‚ [ACTIVE] β”‚
67
- β”‚ x_1566039_seventh_fraud_alert β”‚ [ACTIVE] β”‚
68
- β”‚ x_1566039_seventh_risk_score β”‚ [ACTIVE] β”‚
69
- β”‚ x_1566039_seventh_call_case β”‚ [ACTIVE] β”‚
70
- β”‚ x_1566039_seventh_compliance_flag β”‚ [ACTIVE] β”‚
71
- β”‚ x_1566039_seventh_account_extension β”‚ [ACTIVE] β”‚
72
- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
73
-
74
- πŸ”— SCHEMA RELATIONSHIPS (Reference Fields)
75
- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
76
- β”‚ Table β”‚ Field β”‚ Target Table β”‚
77
- β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
78
- β”‚ x_1566039_seventh_critical_account β”‚ assigned_team β”‚ sys_user_group β”‚
79
- β”‚ x_1566039_seventh_critical_account_incident_ext β”‚ bank_account β”‚ x_bank_critical_account β”‚
80
- β”‚ x_1566039_seventh_account β”‚ owner β”‚ x_1566039_seventh_account β”‚
81
- β”‚ x_1566039_seventh_risk_score β”‚ account β”‚ x_1566039_seventh_account β”‚
82
- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
83
-
84
- βš™οΈ LOGIC ATTACHMENTS (Scripts & Actions)
85
- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
86
- β”‚ (index) β”‚ Name β”‚ Table β”‚ Type β”‚ Artifact Type β”‚
87
- β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
88
- β”‚ 0 β”‚ bank_incident_onload β”‚ incident β”‚ [onLoad] β”‚ script client β”‚
89
- β”‚ 1 β”‚ bank_account_onchange β”‚ x_1566039_seventh_critical_account β”‚ [onChange] β”‚ script client β”‚
90
- β”‚ 2 β”‚ cs_task_onload β”‚ task β”‚ [onLoad] β”‚ script client β”‚
91
- β”‚ 3 β”‚ cs_task_onchange_priority β”‚ x_1566039_seventh_critical_account_incident_ext β”‚ [onChange] β”‚ script client β”‚
92
- β”‚ 4 β”‚ cs_alert_onload β”‚ x_1566039_seventh_fraud_alert β”‚ [onLoad] β”‚ script client β”‚
93
- β”‚ 5 β”‚ cs_risk_onchange_score β”‚ x_1566039_seventh_risk_score β”‚ [onChange] β”‚ script client β”‚
94
- β”‚ 6 β”‚ Auto-close empty Change Requests β”‚ change_request β”‚ [action] β”‚ script β”‚
95
- β”‚ 7 β”‚ View car info β”‚ x_snc_ts_custom_cars β”‚ [action] β”‚ ui action β”‚
96
- β”‚ 8 β”‚ SampleScriptInclude β”‚ [global] β”‚ [action] β”‚ script include β”‚
97
- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
98
-
99
- πŸ”’ SECURITY & ACCESS (ACLs)
100
- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
101
- β”‚ ACL β”‚ Level / Roles β”‚ Table β”‚
102
- β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
103
- β”‚ DELETE on task.description β”‚ Field-level, Roles: βœ” βœ… SDK detected at /node_modules/@servicenow/sdk
104
84
  βœ” Workspace SDK loaded
105
85
  βœ” Project loaded
106
86
  βœ” Graph built: 181 nodes, 141 edges
@@ -336,13 +316,12 @@ Typical extraction times on SDK 4.1.x:
336
316
  | Project Size | Artifacts | Extraction Time |
337
317
  |--------------|-----------|-----------------|
338
318
  | Small | 50-100 | < 5 seconds |
339
- | Medium | 100-500 | 5-15 seconds |
340
- | Large | 500-1000 | 15-30 seconds |
341
- | Enterprise | 1000+ | 30-60 seconds |
319
+ | Medium | 100-500 | 5-10 seconds |
320
+ | Large | 500-1000 | 10-20 seconds |
321
+ | Enterprise | 1000+ | 20-30 seconds |
342
322
 
343
- *Times measured on MacBook Air M1, 16GB RAM*
323
+ *Times measured on MacBook Air M1, 16GB RAM and approximated*
344
324
 
345
- **Note:** First run is slower (SDK initialization). Subsequent runs are faster.
346
325
 
347
326
  ## Limitations
348
327
 
@@ -370,24 +349,6 @@ Typical extraction times on SDK 4.1.x:
370
349
 
371
350
  ---
372
351
 
373
- ## Contributing
374
-
375
- This is an early-stage tool built to solve real problems in Fluent development. Contributions are welcome, especially:
376
-
377
- ### Areas We Need Help
378
-
379
- 1. **SDK Version Testing**
380
- - Test against SDK 4.0.x, 4.2.x, 5.0.x
381
- - Document breaking changes in graph structure
382
-
383
- 2. **Edge Extraction Improvements**
384
- - Capture more FK relationships from Fluent DSL
385
- - Handle complex reference scenarios (polymorphic refs, etc.)
386
-
387
- 3. **Artifact Type Coverage**
388
- - Add support for: Workflows, Flow Designer, Notifications
389
- - Validate ACL/UI Policy detection
390
-
391
352
  ### Development Setup
392
353
  ```bash
393
354
  git clone https://github.com/yesprasad/fluent-graph
@@ -0,0 +1,48 @@
1
+ import type { LineageGraph, ArtifactNode, ArtifactEdge } from '../types/graphs';
2
+ export interface TraversalNode {
3
+ node: ArtifactNode;
4
+ hop: number;
5
+ /** Chain of node names from target β†’ this node (inclusive). */
6
+ path: string[];
7
+ /** The edge that caused this node to be discovered. */
8
+ viaEdge: ArtifactEdge;
9
+ }
10
+ export interface TraversalResult {
11
+ /** The representative local graph node for the target (non-child preferred), or null if platform. */
12
+ target: ArtifactNode | null;
13
+ targetName: string;
14
+ /** All discovered nodes across all hops, sorted by hop ASC then name ASC. */
15
+ hops: TraversalNode[];
16
+ maxHopReached: number;
17
+ /** Times a visited node was skipped (cycle prevention + same-name deduplication). */
18
+ cyclesPrevented: number;
19
+ }
20
+ /**
21
+ * BFS reverse-traversal over the dependency graph.
22
+ *
23
+ * KEY DESIGN DECISIONS:
24
+ *
25
+ * 1. A logical "entity" is identified by node NAME, not by node ID.
26
+ * Multiple physical nodes can share a name (e.g., a table and all its
27
+ * field/documentation records are all named "x_my_table"). The BFS
28
+ * treats them as one logical node β€” once any physical node for a name
29
+ * is discovered, ALL siblings for that name are immediately marked
30
+ * visited so they never appear again.
31
+ *
32
+ * 2. The initial target is seeded with ALL physical nodes sharing the
33
+ * target name (plus the synthetic "platform:tableName" ID). This
34
+ * ensures incoming edges are found regardless of which physical node
35
+ * the edge was recorded against.
36
+ *
37
+ * 3. Edge matching uses BOTH:
38
+ * - edge.to === currentNodeId (exact physical node)
39
+ * - edge.targetTable === currentNodeName (canonical name β€” reliable
40
+ * for platform nodes and catches mis-targeted edges)
41
+ *
42
+ * 4. ALL edge types are traversed. Display filtering is the caller's job.
43
+ *
44
+ * @param graph The loaded LineageGraph.
45
+ * @param targetTableName The table name the user asked about.
46
+ * @param maxDepth Maximum hop depth. Silently clamped to 25.
47
+ */
48
+ export declare function traverseBlastRadius(graph: LineageGraph, targetTableName: string, maxDepth: number): TraversalResult;
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.traverseBlastRadius = traverseBlastRadius;
4
+ /**
5
+ * Build a map from node name β†’ all node IDs with that name.
6
+ * Used to treat same-name siblings (sys_dictionary, sys_db_object, etc.
7
+ * representing the same table) as a single logical entity.
8
+ */
9
+ function buildNameIndex(graph) {
10
+ const index = new Map();
11
+ for (const node of graph.nodes) {
12
+ const ids = index.get(node.name) ?? [];
13
+ ids.push(node.id);
14
+ index.set(node.name, ids);
15
+ }
16
+ return index;
17
+ }
18
+ /**
19
+ * BFS reverse-traversal over the dependency graph.
20
+ *
21
+ * KEY DESIGN DECISIONS:
22
+ *
23
+ * 1. A logical "entity" is identified by node NAME, not by node ID.
24
+ * Multiple physical nodes can share a name (e.g., a table and all its
25
+ * field/documentation records are all named "x_my_table"). The BFS
26
+ * treats them as one logical node β€” once any physical node for a name
27
+ * is discovered, ALL siblings for that name are immediately marked
28
+ * visited so they never appear again.
29
+ *
30
+ * 2. The initial target is seeded with ALL physical nodes sharing the
31
+ * target name (plus the synthetic "platform:tableName" ID). This
32
+ * ensures incoming edges are found regardless of which physical node
33
+ * the edge was recorded against.
34
+ *
35
+ * 3. Edge matching uses BOTH:
36
+ * - edge.to === currentNodeId (exact physical node)
37
+ * - edge.targetTable === currentNodeName (canonical name β€” reliable
38
+ * for platform nodes and catches mis-targeted edges)
39
+ *
40
+ * 4. ALL edge types are traversed. Display filtering is the caller's job.
41
+ *
42
+ * @param graph The loaded LineageGraph.
43
+ * @param targetTableName The table name the user asked about.
44
+ * @param maxDepth Maximum hop depth. Silently clamped to 25.
45
+ */
46
+ function traverseBlastRadius(graph, targetTableName, maxDepth) {
47
+ const depth = Math.min(maxDepth, 25);
48
+ const nameIndex = buildNameIndex(graph);
49
+ // All physical nodes for the target name (may be many β€” sys_dictionary,
50
+ // sys_db_object, sys_documentation, etc. all named the same thing).
51
+ const targetPhysicalNodes = graph.nodes.filter(n => n.name === targetTableName);
52
+ // Prefer a non-child / primary node as the canonical representative.
53
+ // We can't use the registry here (pure function), so heuristic: prefer
54
+ // any node where the table name itself matches the target name (for custom
55
+ // tables this is the sys_db_object entry); otherwise first found.
56
+ const target = targetPhysicalNodes[0] ?? null;
57
+ // Pre-visit the platform shadow ID and ALL physical nodes for the target.
58
+ const visited = new Set();
59
+ const platformId = `platform:${targetTableName}`;
60
+ visited.add(platformId);
61
+ for (const n of targetPhysicalNodes)
62
+ visited.add(n.id);
63
+ // Seed the frontier with one entry per physical target node + platform shadow.
64
+ // This ensures we catch edges recorded against any of the physical nodes.
65
+ const seedIds = new Set([platformId, ...targetPhysicalNodes.map(n => n.id)]);
66
+ let frontier = [...seedIds].map(id => ({
67
+ nodeId: id,
68
+ nodeName: targetTableName,
69
+ hop: 0,
70
+ path: [targetTableName],
71
+ }));
72
+ const results = [];
73
+ // Track which logical names we have already emitted in results (for dedup).
74
+ const emittedNames = new Set([targetTableName]);
75
+ let maxHopReached = 0;
76
+ let cyclesPrevented = 0;
77
+ while (frontier.length > 0) {
78
+ const nextFrontier = [];
79
+ for (const entry of frontier) {
80
+ if (entry.hop >= depth)
81
+ continue;
82
+ // Reverse-edge scan: all edges whose destination is the current node.
83
+ const incomingEdges = graph.edges.filter(edge => edge.to === entry.nodeId ||
84
+ (entry.nodeName.length > 0 && edge.targetTable === entry.nodeName));
85
+ for (const edge of incomingEdges) {
86
+ const sourceNode = graph.nodes.find(n => n.id === edge.from);
87
+ if (!sourceNode)
88
+ continue;
89
+ if (visited.has(sourceNode.id)) {
90
+ cyclesPrevented++;
91
+ continue;
92
+ }
93
+ // Mark this physical node visited.
94
+ visited.add(sourceNode.id);
95
+ // Mark ALL sibling physical nodes (same name) visited immediately
96
+ // so they never surface as separate hop results.
97
+ const siblings = nameIndex.get(sourceNode.name) ?? [];
98
+ for (const sibId of siblings) {
99
+ if (!visited.has(sibId))
100
+ visited.add(sibId);
101
+ }
102
+ const newHop = entry.hop + 1;
103
+ const newPath = [...entry.path, sourceNode.name];
104
+ // Only emit one result per logical name (the first physical node found).
105
+ if (!emittedNames.has(sourceNode.name)) {
106
+ emittedNames.add(sourceNode.name);
107
+ results.push({
108
+ node: sourceNode,
109
+ hop: newHop,
110
+ path: newPath,
111
+ viaEdge: edge,
112
+ });
113
+ }
114
+ // Continue BFS from this logical entity, using the canonical name.
115
+ // Also seed entries for all sibling physical IDs so their incoming
116
+ // edges are explored too.
117
+ for (const sibId of siblings) {
118
+ nextFrontier.push({
119
+ nodeId: sibId,
120
+ nodeName: sourceNode.name,
121
+ hop: newHop,
122
+ path: newPath,
123
+ });
124
+ }
125
+ if (newHop > maxHopReached) {
126
+ maxHopReached = newHop;
127
+ }
128
+ }
129
+ }
130
+ frontier = nextFrontier;
131
+ }
132
+ // Sort by hop ASC, then name ASC for stable output.
133
+ results.sort((a, b) => a.hop - b.hop || a.node.name.localeCompare(b.node.name));
134
+ return {
135
+ target,
136
+ targetName: targetTableName,
137
+ hops: results,
138
+ maxHopReached,
139
+ cyclesPrevented,
140
+ };
141
+ }
@@ -42,11 +42,17 @@ const fs = __importStar(require("fs"));
42
42
  const path = __importStar(require("path"));
43
43
  const chalk_1 = __importDefault(require("chalk"));
44
44
  const cli_table3_1 = __importDefault(require("cli-table3"));
45
+ const registry_1 = require("../../sdk/registry");
46
+ const detector_1 = require("../../sdk/detector");
47
+ const traversal_1 = require("../../blast/traversal");
45
48
  exports.blastCommand = new commander_1.Command('blast')
46
49
  .description('Analyze blast radius for a target table')
47
50
  .argument('<target>', 'Target table name (e.g., incident, x_bank_account)')
48
51
  .option('-g, --graph <path>', 'Path to graph JSON file', 'fluent-graph.json')
49
52
  .option('-f, --format <type>', 'Output format: table, json, csv', 'table')
53
+ .option('--depth <n>', 'Maximum traversal depth', '10')
54
+ .option('--show-paths', 'Show the full dependency chain for each transitive artifact')
55
+ .option('--direct-only', 'Show only hop-1 (direct) impacts')
50
56
  .option('--include-deleted', 'Include deleted artifacts in the analysis')
51
57
  .option('--no-banner', 'Skip banner (inherited)')
52
58
  .action(async (targetTableName, options) => {
@@ -54,22 +60,27 @@ exports.blastCommand = new commander_1.Command('blast')
54
60
  const graphFilePath = path.isAbsolute(options.graph)
55
61
  ? options.graph
56
62
  : path.join(process.cwd(), options.graph);
63
+ // Load registry for type name derivation (best-effort; falls back gracefully)
64
+ const registry = new registry_1.ArtifactTypeRegistry();
65
+ const sdkDetection = await (0, detector_1.detectSDK)(process.cwd());
66
+ if (sdkDetection.found && sdkDetection.sdkPath) {
67
+ registry.load(sdkDetection.sdkPath);
68
+ }
57
69
  if (!fs.existsSync(graphFilePath)) {
58
70
  console.error(chalk_1.default.red(`\n❌ Error: Graph file not found at ${graphFilePath}`));
59
71
  process.exit(1);
60
72
  }
61
73
  const lineageGraph = JSON.parse(fs.readFileSync(graphFilePath, 'utf8'));
62
- // STEP 1: Determine target node (real node or shadow for platform)
74
+ // ── STEP 1: Validate target exists or is referenced ─────────────────────
63
75
  const targetNode = lineageGraph.nodes.find(node => node.name === targetTableName);
64
76
  const targetNodeId = targetNode ? targetNode.id : `platform:${targetTableName}`;
65
77
  const isBaseTable = !targetNode;
66
- // STEP 2: Check if target is referenced at all
67
78
  const isTargetReferenced = lineageGraph.edges.some(edge => edge.to === targetNodeId || edge.targetTable === targetTableName);
68
79
  if (!targetNode && !isTargetReferenced) {
69
80
  console.log(chalk_1.default.red(`\n❌ Target "${targetTableName}" not found or referenced`));
70
81
  console.log(chalk_1.default.yellow('\nπŸ’‘ Available custom tables:'));
71
82
  const customTables = lineageGraph.nodes
72
- .filter(node => node.table === 'sys_db_object' && node.action !== 'DELETE')
83
+ .filter(node => registry.getPluginName(node.table) === 'TablePlugin' && !registry.isChild(node.table) && node.action !== 'DELETE')
73
84
  .map(node => node.name)
74
85
  .sort();
75
86
  customTables.forEach(tableName => console.log(chalk_1.default.gray(' β€’ ') + chalk_1.default.white(tableName)));
@@ -80,7 +91,7 @@ exports.blastCommand = new commander_1.Command('blast')
80
91
  platformTables.forEach(tableName => console.log(chalk_1.default.gray(' β€’ ') + chalk_1.default.magenta(tableName)));
81
92
  process.exit(1);
82
93
  }
83
- // STEP 3: Display header
94
+ // ── STEP 2: Display header ───────────────────────────────────────────────
84
95
  console.log(chalk_1.default.yellow.bold('\nπŸ”₯ BLAST RADIUS ANALYSIS'));
85
96
  console.log(chalk_1.default.gray('═'.repeat(80)));
86
97
  console.log(chalk_1.default.gray('Target: ') + chalk_1.default.white(targetTableName) + (isBaseTable ? chalk_1.default.yellow(' [Base Table]') : ''));
@@ -88,111 +99,137 @@ exports.blastCommand = new commander_1.Command('blast')
88
99
  console.log(chalk_1.default.gray('File: ') + chalk_1.default.gray(targetNode.source.file));
89
100
  }
90
101
  console.log(chalk_1.default.gray('═'.repeat(80)));
91
- // STEP 4: Collect impacted artifacts from edges
92
- const impactedArtifacts = lineageGraph.edges
93
- .filter(edge => edge.to === targetNodeId || edge.targetTable === targetTableName)
94
- .map(edge => {
95
- const sourceNode = lineageGraph.nodes.find(node => node.id === edge.from);
96
- if (!sourceNode || (!options.includeDeleted && sourceNode.action === 'DELETE'))
97
- return null;
98
- // Friendly type mapping
99
- let artifactType = 'unknown';
100
- switch (sourceNode.table) {
101
- case 'sys_script_client':
102
- artifactType = 'client_script';
103
- break;
104
- case 'sys_script':
105
- artifactType = 'business_rule';
106
- break;
107
- case 'sys_security_acl':
108
- artifactType = 'acl';
109
- break;
110
- case 'sys_ui_action':
111
- artifactType = 'ui_action';
112
- break;
113
- case 'sys_script_include':
114
- artifactType = 'script_include';
115
- break;
116
- case 'sys_dictionary':
117
- artifactType = 'field';
118
- break; // For schema/FK
119
- default: artifactType = sourceNode.table.replace('sys_', '').replace('_', ' ');
120
- }
121
- // Reason based on edge type
122
- const impactReason = edge.type === 'schema_relationship'
123
- ? `FK reference via ${edge.via || 'field'}`
124
- : `${sourceNode.attributes?.type || sourceNode.attributes?.when || edge.via || 'attachment'}`;
125
- return {
126
- artifact: sourceNode.name,
127
- type: artifactType,
128
- file: sourceNode.source.file,
129
- reason: impactReason,
130
- state: sourceNode.action
131
- };
132
- })
133
- .filter((artifact) => artifact !== null);
134
- if (impactedArtifacts.length === 0) {
102
+ // ── STEP 3: BFS traversal ────────────────────────────────────────────────
103
+ const maxDepth = options.directOnly ? 1 : Math.max(1, parseInt(options.depth, 10) || 10);
104
+ const traversal = (0, traversal_1.traverseBlastRadius)(lineageGraph, targetTableName, maxDepth);
105
+ // ── STEP 4: Apply display filter ─────────────────────────────────────────
106
+ // Show a node if it has a source file OR is a non-deleted local node.
107
+ // Suppress SDK-managed child records that have no user-authored source.
108
+ const isDisplayable = (tn) => {
109
+ if (!options.includeDeleted && tn.node.action === 'DELETE')
110
+ return false;
111
+ if (tn.node.source.file)
112
+ return true;
113
+ // Suppress isChild records with no source file (intermediate composition nodes)
114
+ if (registry.isChild(tn.node.table))
115
+ return false;
116
+ return true;
117
+ };
118
+ const displayed = traversal.hops.filter(isDisplayable);
119
+ const directImpact = displayed.filter(tn => tn.hop === 1);
120
+ const transitiveImpact = displayed.filter(tn => tn.hop > 1);
121
+ if (displayed.length === 0) {
135
122
  console.log(chalk_1.default.green('\nβœ… Zero dependencies detected in local project.\n'));
136
123
  return;
137
124
  }
138
- // STEP 5: Output based on format
125
+ // ── STEP 5: Output ───────────────────────────────────────────────────────
139
126
  switch (options.format) {
140
- case 'json':
141
- console.log(JSON.stringify(impactedArtifacts, null, 2));
127
+ case 'json': {
128
+ const output = displayed.map(tn => ({
129
+ artifact: tn.node.name,
130
+ type: registry.getDisplayGroupName(tn.node.table).toLowerCase(),
131
+ file: tn.node.source.file,
132
+ hop: tn.hop,
133
+ path: tn.path,
134
+ via: tn.viaEdge.via ?? tn.viaEdge.type,
135
+ edgeType: tn.viaEdge.type,
136
+ state: tn.node.action,
137
+ }));
138
+ console.log(JSON.stringify(output, null, 2));
142
139
  break;
143
- case 'csv':
144
- console.log('Artifact,Type,File,Reason,State');
145
- impactedArtifacts.forEach(impactedArtifact => {
146
- console.log(`"${impactedArtifact.artifact}","${impactedArtifact.type}","${impactedArtifact.file}","${impactedArtifact.reason}","${impactedArtifact.state}"`);
140
+ }
141
+ case 'csv': {
142
+ console.log('Artifact,Type,File,Hop,Via,State,Path');
143
+ displayed.forEach(tn => {
144
+ const via = tn.viaEdge.via ?? tn.viaEdge.type;
145
+ const pathStr = tn.path.join(' > ');
146
+ console.log(`"${tn.node.name}","${registry.getDisplayGroupName(tn.node.table).toLowerCase()}","${tn.node.source.file}",${tn.hop},"${via}","${tn.node.action}","${pathStr}"`);
147
147
  });
148
148
  break;
149
+ }
149
150
  case 'table':
150
- default:
151
- const table = new cli_table3_1.default({
152
- head: [
151
+ default: {
152
+ // ── Direct Impact section ────────────────────────────────────────────
153
+ if (directImpact.length > 0) {
154
+ console.log(chalk_1.default.bold('\n⚑ DIRECT IMPACT') + chalk_1.default.gray(' (Hop 1)'));
155
+ const directTable = new cli_table3_1.default({
156
+ head: [
157
+ chalk_1.default.cyan('Artifact'),
158
+ chalk_1.default.cyan('Type'),
159
+ chalk_1.default.cyan('File'),
160
+ chalk_1.default.cyan('Via'),
161
+ chalk_1.default.cyan('State'),
162
+ ],
163
+ wordWrap: true,
164
+ style: { head: [], border: ['gray'] },
165
+ });
166
+ directImpact.forEach(tn => {
167
+ const via = formatVia(tn);
168
+ const stateColor = tn.node.action === 'DELETE' ? chalk_1.default.red : chalk_1.default.green;
169
+ directTable.push([
170
+ chalk_1.default.white(tn.node.name),
171
+ chalk_1.default.gray(registry.getDisplayGroupName(tn.node.table).toLowerCase()),
172
+ chalk_1.default.gray(tn.node.source.file || 'β€”'),
173
+ chalk_1.default.gray(via),
174
+ stateColor(tn.node.action === 'DELETE' ? 'DELETE' : 'ACTIVE'),
175
+ ]);
176
+ });
177
+ console.log(directTable.toString());
178
+ }
179
+ // ── Transitive Impact section ────────────────────────────────────────
180
+ if (transitiveImpact.length > 0 && !options.directOnly) {
181
+ console.log(chalk_1.default.bold('\n🌊 TRANSITIVE IMPACT') + chalk_1.default.gray(' (Hop 2+)'));
182
+ const transitiveHead = [
153
183
  chalk_1.default.cyan('Artifact'),
154
184
  chalk_1.default.cyan('Type'),
155
185
  chalk_1.default.cyan('File'),
156
- chalk_1.default.cyan('Reason'),
157
- chalk_1.default.cyan('State')
158
- ],
159
- wordWrap: true,
160
- style: { head: [], border: ['gray'] }
161
- });
162
- impactedArtifacts.forEach(impactedArtifact => {
163
- const typeIcon = impactedArtifact.type === 'field' ? 'πŸ”—'
164
- : impactedArtifact.type === 'client_script' ? '⚑'
165
- : impactedArtifact.type === 'business_rule' ? 'πŸ”§'
166
- : impactedArtifact.type === 'acl' ? 'πŸ”’'
167
- : '';
168
- const stateColor = impactedArtifact.state === 'DELETE' ? chalk_1.default.red : chalk_1.default.green;
169
- table.push([
170
- chalk_1.default.white(impactedArtifact.artifact),
171
- `${typeIcon} ${chalk_1.default.gray(impactedArtifact.type)}`,
172
- chalk_1.default.gray(impactedArtifact.file),
173
- impactedArtifact.reason,
174
- stateColor(impactedArtifact.state)
175
- ]);
176
- });
177
- console.log('\n' + table.toString());
178
- // STEP 6: Impact Summary
186
+ chalk_1.default.cyan('Hop'),
187
+ chalk_1.default.cyan('State'),
188
+ ];
189
+ if (options.showPaths) {
190
+ transitiveHead.push(chalk_1.default.cyan('Path'));
191
+ }
192
+ const transitiveTable = new cli_table3_1.default({
193
+ head: transitiveHead,
194
+ wordWrap: true,
195
+ style: { head: [], border: ['gray'] },
196
+ });
197
+ transitiveImpact.forEach(tn => {
198
+ const stateColor = tn.node.action === 'DELETE' ? chalk_1.default.red : chalk_1.default.green;
199
+ const row = [
200
+ chalk_1.default.white(tn.node.name),
201
+ chalk_1.default.gray(registry.getDisplayGroupName(tn.node.table).toLowerCase()),
202
+ chalk_1.default.gray(tn.node.source.file || 'β€”'),
203
+ chalk_1.default.yellow(String(tn.hop)),
204
+ stateColor(tn.node.action === 'DELETE' ? 'DELETE' : 'ACTIVE'),
205
+ ];
206
+ if (options.showPaths) {
207
+ row.push(chalk_1.default.gray(tn.path.join(' β†’ ')));
208
+ }
209
+ transitiveTable.push(row);
210
+ });
211
+ console.log(transitiveTable.toString());
212
+ }
213
+ // ── Impact Summary ───────────────────────────────────────────────────
179
214
  console.log(chalk_1.default.yellow('\nπŸ“Š IMPACT SUMMARY'));
180
- const riskLevel = impactedArtifacts.length < 3 ? 'LOW'
181
- : impactedArtifacts.length < 8 ? 'MEDIUM'
182
- : 'HIGH';
215
+ const totalCount = displayed.length;
216
+ const riskLevel = totalCount < 3 ? 'LOW' : totalCount < 8 ? 'MEDIUM' : 'HIGH';
183
217
  const riskColor = riskLevel === 'LOW' ? chalk_1.default.green
184
218
  : riskLevel === 'MEDIUM' ? chalk_1.default.yellow
185
219
  : chalk_1.default.red;
186
- console.log(chalk_1.default.gray('Total Impacted: ') + chalk_1.default.cyan(impactedArtifacts.length));
187
- console.log(chalk_1.default.gray('Risk Level: ') + riskColor.bold(riskLevel));
220
+ console.log(chalk_1.default.gray('Total Impacted: ') + chalk_1.default.cyan(totalCount) +
221
+ chalk_1.default.gray(` (${directImpact.length} direct, ${transitiveImpact.length} transitive)`));
222
+ console.log(chalk_1.default.gray('Max Depth Reached: ') + chalk_1.default.cyan(traversal.maxHopReached));
223
+ console.log(chalk_1.default.gray('Risk Level: ') + riskColor.bold(riskLevel));
188
224
  console.log(chalk_1.default.gray('─'.repeat(80)) + '\n');
189
225
  if (riskLevel === 'HIGH') {
190
- console.log(chalk_1.default.yellow('⚠️ High impact - Review carefully'));
226
+ console.log(chalk_1.default.yellow('⚠️ High impact β€” review carefully before deploying'));
191
227
  }
192
228
  else if (riskLevel === 'MEDIUM') {
193
229
  console.log(chalk_1.default.yellow('πŸ’‘ Moderate impact'));
194
230
  }
195
231
  break;
232
+ }
196
233
  }
197
234
  }
198
235
  catch (error) {
@@ -200,3 +237,11 @@ exports.blastCommand = new commander_1.Command('blast')
200
237
  process.exit(1);
201
238
  }
202
239
  });
240
+ /** Format the "Via" column label from a traversal node's edge. */
241
+ function formatVia(tn) {
242
+ const edge = tn.viaEdge;
243
+ if (edge.type === 'schema_relationship') {
244
+ return `${edge.via ?? 'ref'} (ref field)`;
245
+ }
246
+ return edge.via ?? edge.type;
247
+ }