@yesprasad/fluent-graph 0.1.0 → 0.2.1
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 +85 -359
- 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
|
@@ -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
|
+
}
|
|
@@ -13,85 +13,6 @@ const loader_1 = require("../../sdk/loader");
|
|
|
13
13
|
const extractor_1 = require("../../extractor");
|
|
14
14
|
const writer_1 = require("../../output/writer");
|
|
15
15
|
const display_1 = require("../utils/display");
|
|
16
|
-
// /**
|
|
17
|
-
// * The Analyze Command: Orchestrates the transformation of a ServiceNow Fluent
|
|
18
|
-
// * project into a formal directed graph.
|
|
19
|
-
// */
|
|
20
|
-
// export const extractCommand = new Command('analyze')
|
|
21
|
-
// .description('Analyze and Extract dependency graph from current project')
|
|
22
|
-
// .option('-o, --output <path>', 'Output file path', 'fluent-graph.json')
|
|
23
|
-
// .option('-q, --quiet', 'Suppress console output')
|
|
24
|
-
// .option('--no-display', 'Skip visual display')
|
|
25
|
-
// .option('--no-banner', 'Skip banner (inherited)')
|
|
26
|
-
// .action(async (options) => {
|
|
27
|
-
// try {
|
|
28
|
-
// const cwd = process.cwd();
|
|
29
|
-
// let spinner: any;
|
|
30
|
-
// // Step 1: Detect ServiceNow SDK Environment
|
|
31
|
-
// if (!options.quiet) {
|
|
32
|
-
// spinner = ora('Detecting ServiceNow SDK...').start();
|
|
33
|
-
// }
|
|
34
|
-
// const sdk = await detectSDK(cwd);
|
|
35
|
-
// if (!sdk.found) {
|
|
36
|
-
// if (spinner) spinner.fail('SDK not found');
|
|
37
|
-
// console.error(chalk.red('\n❌ Error:'), sdk.error);
|
|
38
|
-
// console.log(chalk.yellow('\n💡 Make sure you\'re in a ServiceNow Fluent project directory'));
|
|
39
|
-
// process.exit(1);
|
|
40
|
-
// }
|
|
41
|
-
// if (spinner) {
|
|
42
|
-
// spinner.succeed(`✅ SDK detected at ${chalk.gray(sdk.sdkPath)}`);
|
|
43
|
-
// }
|
|
44
|
-
// // Step 2: Dynamically Load Workspace SDK
|
|
45
|
-
// if (!options.quiet) {
|
|
46
|
-
// spinner = ora('Loading workspace SDK...').start();
|
|
47
|
-
// }
|
|
48
|
-
// const { sdkapi } = loadWorkspaceSDK(sdk.sdkPath!);
|
|
49
|
-
// if (spinner) {
|
|
50
|
-
// spinner.succeed('Workspace SDK loaded');
|
|
51
|
-
// }
|
|
52
|
-
// // Step 3: Instantiate the Project Proxy
|
|
53
|
-
// if (!options.quiet) {
|
|
54
|
-
// spinner = ora('Loading project configuration...').start();
|
|
55
|
-
// }
|
|
56
|
-
// const project = new sdkapi.Project({ rootDir: cwd });
|
|
57
|
-
// if (spinner) {
|
|
58
|
-
// spinner.succeed('Project loaded');
|
|
59
|
-
// }
|
|
60
|
-
// // Step 4: Extract Lineage Graph
|
|
61
|
-
// // We pass the full project object, allowing the extractor to
|
|
62
|
-
// // access nodes, edges, and metadata (projectRoot, scope, etc.)
|
|
63
|
-
// if (!options.quiet) {
|
|
64
|
-
// spinner = ora('Building dependency graph...').start();
|
|
65
|
-
// }
|
|
66
|
-
// const graph = await extractLineageGraph(project);
|
|
67
|
-
// if (spinner) {
|
|
68
|
-
// spinner.succeed(`Graph built: ${chalk.cyan(graph.nodes.length)} nodes, ${chalk.cyan(graph.edges.length)} edges`);
|
|
69
|
-
// }
|
|
70
|
-
// // Step 5: Persist the Graph (The "Iterable" result)
|
|
71
|
-
// const outputPath = path.isAbsolute(options.output)
|
|
72
|
-
// ? options.output
|
|
73
|
-
// : path.join(cwd, options.output);
|
|
74
|
-
// writeGraph(graph, outputPath);
|
|
75
|
-
// if (!options.quiet) {
|
|
76
|
-
// console.log(chalk.green('\n✅ Analysis complete'));
|
|
77
|
-
// console.log(chalk.gray(' Location: ') + chalk.white(outputPath));
|
|
78
|
-
// console.log(chalk.gray(' Complexity: ') + chalk.cyan((graph.edges.length / graph.nodes.length).toFixed(2)) + chalk.gray(' edges/node'));
|
|
79
|
-
// console.log(chalk.gray(' Generated at: ') + chalk.gray(new Date().toLocaleTimeString()));
|
|
80
|
-
// }
|
|
81
|
-
// // Step 6: Visual Lineage Map
|
|
82
|
-
// // "Artifact-Agnostic" display logic
|
|
83
|
-
// if (options.display && !options.quiet) {
|
|
84
|
-
// displayLineageMap(graph);
|
|
85
|
-
// }
|
|
86
|
-
// } catch (err: any) {
|
|
87
|
-
// console.error(chalk.red('\n❌ Error:'), err.message);
|
|
88
|
-
// if (process.env.DEBUG) {
|
|
89
|
-
// console.error(chalk.gray('\nStack trace:'));
|
|
90
|
-
// console.error(err.stack);
|
|
91
|
-
// }
|
|
92
|
-
// process.exit(1);
|
|
93
|
-
// }
|
|
94
|
-
// });
|
|
95
16
|
/**
|
|
96
17
|
* The Extract Command: Orchestrates the transformation of a ServiceNow Fluent
|
|
97
18
|
* project into a formal directed graph.
|
|
@@ -141,7 +62,7 @@ exports.extractCommand = new commander_1.Command('analyze')
|
|
|
141
62
|
if (!options.quiet) {
|
|
142
63
|
spinner = (0, ora_1.default)('Building dependency graph...').start();
|
|
143
64
|
}
|
|
144
|
-
const lineageGraph = await (0, extractor_1.extractLineageGraph)(projectInstance);
|
|
65
|
+
const lineageGraph = await (0, extractor_1.extractLineageGraph)(projectInstance, workspaceSDK.registry);
|
|
145
66
|
if (spinner) {
|
|
146
67
|
spinner.succeed(`Graph built: ${chalk_1.default.cyan(lineageGraph.nodes.length)} nodes, ${chalk_1.default.cyan(lineageGraph.edges.length)} edges`);
|
|
147
68
|
}
|
|
@@ -158,7 +79,7 @@ exports.extractCommand = new commander_1.Command('analyze')
|
|
|
158
79
|
}
|
|
159
80
|
// Step 6: Visual Lineage Map
|
|
160
81
|
if (options.display && !options.quiet) {
|
|
161
|
-
(0, display_1.displayLineageMap)(lineageGraph);
|
|
82
|
+
(0, display_1.displayLineageMap)(lineageGraph, workspaceSDK.registry);
|
|
162
83
|
}
|
|
163
84
|
}
|
|
164
85
|
catch (error) {
|
package/dist/cli/index.d.ts
CHANGED
package/dist/cli/index.js
CHANGED
|
@@ -1,33 +1,8 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
1
|
"use strict";
|
|
3
|
-
// import { Command } from 'commander';
|
|
4
|
-
// import chalk from 'chalk';
|
|
5
|
-
// import { extractCommand } from './commands/extract';
|
|
6
|
-
// import { blastCommand } from './commands/blast';
|
|
7
|
-
// import { displayBanner } from './utils/banner';
|
|
8
2
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
9
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
10
4
|
};
|
|
11
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
// const program = new Command();
|
|
13
|
-
// // Show banner on first run (unless --no-banner flag)
|
|
14
|
-
// const showBanner = !process.argv.includes('--no-banner') && !process.argv.includes('--quiet');
|
|
15
|
-
// if (showBanner) {
|
|
16
|
-
// displayBanner();
|
|
17
|
-
// }
|
|
18
|
-
// program
|
|
19
|
-
// .name('fluent-graph')
|
|
20
|
-
// .description('Dependency/ Lineage graph extraction & Blast-radius/ impact analysis for ServiceNow Fluent Artifacts')
|
|
21
|
-
// .version('1.0.0')
|
|
22
|
-
// .option('--no-banner', 'Suppress ASCII banner');
|
|
23
|
-
// // Add commands
|
|
24
|
-
// program.addCommand(extractCommand);
|
|
25
|
-
// program.addCommand(blastCommand);
|
|
26
|
-
// // Default behavior: show help if no command provided
|
|
27
|
-
// if (process.argv.length === 2) {
|
|
28
|
-
// program.help();
|
|
29
|
-
// }
|
|
30
|
-
// program.parse(process.argv);
|
|
31
6
|
const commander_1 = require("commander");
|
|
32
7
|
const path_1 = __importDefault(require("path"));
|
|
33
8
|
const fs_1 = __importDefault(require("fs"));
|
package/dist/cli/utils/banner.js
CHANGED
|
@@ -16,10 +16,10 @@ function displayBanner() {
|
|
|
16
16
|
╚═╝ ╚══════╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝
|
|
17
17
|
`;
|
|
18
18
|
console.log(chalk_1.default.cyan(banner));
|
|
19
|
-
console.log(chalk_1.default.gray(' Dependency
|
|
20
|
-
console.log(chalk_1.default.gray('
|
|
19
|
+
console.log(chalk_1.default.gray(' Dependency visibility and impact analysis for ServiceNow Fluent SDK projects'));
|
|
20
|
+
console.log(chalk_1.default.gray(' ────────────────────────────────────────────────────────────────────────────\n'));
|
|
21
21
|
}
|
|
22
22
|
function displayVersion() {
|
|
23
23
|
console.log(chalk_1.default.cyan.bold('Fluent Graph') + chalk_1.default.gray(' v0.1.0'));
|
|
24
|
-
console.log(chalk_1.default.gray('Dependency visibility for ServiceNow Fluent projects\n'));
|
|
24
|
+
console.log(chalk_1.default.gray('Dependency visibility and impact analysis for ServiceNow Fluent SDK projects\n'));
|
|
25
25
|
}
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import { LineageGraph } from '../../types/graphs';
|
|
2
|
-
|
|
2
|
+
import { ArtifactTypeRegistry } from '../../sdk/registry';
|
|
3
|
+
export declare function displayLineageMap(graph: LineageGraph, registry: ArtifactTypeRegistry): void;
|