@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 +1 -1
- package/README.md +39 -78
- package/dist/blast/traversal.d.ts +48 -0
- package/dist/blast/traversal.js +141 -0
- package/dist/cli/commands/blast.js +132 -87
- package/dist/cli/commands/extract.js +2 -81
- package/dist/cli/index.d.ts +0 -1
- package/dist/cli/index.js +0 -25
- package/dist/cli/utils/banner.js +3 -3
- package/dist/cli/utils/display.d.ts +2 -1
- package/dist/cli/utils/display.js +86 -121
- package/dist/extractor/edges.d.ts +6 -4
- package/dist/extractor/edges.js +41 -25
- package/dist/extractor/index.d.ts +2 -1
- package/dist/extractor/index.js +2 -2
- package/dist/graph/extractor.d.ts +2 -1
- package/dist/graph/extractor.js +4 -2
- package/dist/sdk/loader.d.ts +2 -0
- package/dist/sdk/loader.js +4 -0
- package/dist/sdk/registry.d.ts +30 -0
- package/dist/sdk/registry.js +128 -0
- package/package.json +16 -13
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
|
-
**
|
|
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-
|
|
340
|
-
| Large | 500-1000 |
|
|
341
|
-
| Enterprise | 1000+ | 30
|
|
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:
|
|
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 === '
|
|
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
|
|
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
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
125
|
+
// ββ STEP 5: Output βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
139
126
|
switch (options.format) {
|
|
140
|
-
case 'json':
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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('
|
|
157
|
-
chalk_1.default.cyan('State')
|
|
158
|
-
]
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
:
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
181
|
-
|
|
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:
|
|
187
|
-
|
|
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('β οΈ
|
|
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
|
+
}
|