@brainwav/diagram 1.0.7 → 1.1.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/.diagram/contracts/machine-command-coverage.json +73 -0
- package/.diagram/migration/finalization-policy.json +20 -0
- package/LICENSE +202 -21
- package/README.md +132 -339
- package/package.json +46 -13
- package/scripts/refresh-diagram-context.sh +274 -182
- package/src/analyzers/default-analyzer.js +11 -0
- package/src/analyzers/index.js +34 -0
- package/src/artifacts/agent-context.js +105 -0
- package/src/artifacts/artifact-budget.js +224 -0
- package/src/artifacts/brief.js +153 -0
- package/src/artifacts/evidence-manifest.js +206 -0
- package/src/artifacts/evidence-summary.js +29 -0
- package/src/commands/analyze.js +125 -0
- package/src/commands/changed.js +185 -0
- package/src/commands/context.js +110 -0
- package/src/commands/diff.js +142 -0
- package/src/commands/doctor.js +335 -0
- package/src/commands/explain.js +273 -0
- package/src/commands/generate-all.js +170 -0
- package/src/commands/generate-animated.js +50 -0
- package/src/commands/generate-video.js +65 -0
- package/src/commands/generate.js +522 -0
- package/src/commands/init.js +123 -0
- package/src/commands/output.js +76 -0
- package/src/commands/scan.js +624 -0
- package/src/commands/shared.js +396 -0
- package/src/commands/validate.js +328 -0
- package/src/commands/video-shared.js +105 -0
- package/src/commands/workflow-pr.js +26 -0
- package/src/confidence/pipeline.js +186 -0
- package/src/config/diagramrc.js +79 -0
- package/src/context/build-context-pack.js +291 -0
- package/src/context/normalize-diagram-manifest.js +282 -0
- package/src/core/analysis-generation-analyze-components.js +102 -0
- package/src/core/analysis-generation-analyze-dependencies.js +33 -0
- package/src/core/analysis-generation-analyze-files.js +48 -0
- package/src/core/analysis-generation-analyze-options.js +73 -0
- package/src/core/analysis-generation-analyze.js +63 -0
- package/src/core/analysis-generation-constants.js +53 -0
- package/src/core/analysis-generation-diagrams-core-architecture.js +105 -0
- package/src/core/analysis-generation-diagrams-core-dependency.js +68 -0
- package/src/core/analysis-generation-diagrams-core-sequence.js +142 -0
- package/src/core/analysis-generation-diagrams-core-shapes.js +104 -0
- package/src/core/analysis-generation-diagrams-core.js +12 -0
- package/src/core/analysis-generation-diagrams-empty.js +68 -0
- package/src/core/analysis-generation-diagrams-erd.js +59 -0
- package/src/core/analysis-generation-diagrams-limit.js +27 -0
- package/src/core/analysis-generation-diagrams-role-ai-agent.js +103 -0
- package/src/core/analysis-generation-diagrams-role-ai-context.js +186 -0
- package/src/core/analysis-generation-diagrams-role-ai.js +11 -0
- package/src/core/analysis-generation-diagrams-role-data.js +182 -0
- package/src/core/analysis-generation-diagrams-role-helpers.js +129 -0
- package/src/core/analysis-generation-diagrams-role-security.js +129 -0
- package/src/core/analysis-generation-diagrams-role.js +25 -0
- package/src/core/analysis-generation-diagrams.js +182 -0
- package/src/core/analysis-generation-role-tags-constants.js +55 -0
- package/src/core/analysis-generation-role-tags-imports.js +32 -0
- package/src/core/analysis-generation-role-tags-infer.js +49 -0
- package/src/core/analysis-generation-role-tags-match.js +19 -0
- package/src/core/analysis-generation-role-tags.js +7 -0
- package/src/core/analysis-generation-utils-core.js +308 -0
- package/src/core/analysis-generation-utils-graph.js +321 -0
- package/src/core/analysis-generation-utils-resolution.js +76 -0
- package/src/core/analysis-generation-utils.js +9 -0
- package/src/core/analysis-generation.js +44 -0
- package/src/diagram.js +180 -1760
- package/src/formatters/console.js +198 -0
- package/src/formatters/index.js +41 -0
- package/src/formatters/json.js +113 -0
- package/src/formatters/junit.js +123 -0
- package/src/graph.js +159 -0
- package/src/incremental/cache.js +210 -0
- package/src/ir/architecture-ir.js +48 -0
- package/src/migration/evidence.js +262 -0
- package/src/migration/finalization-policy.js +35 -0
- package/src/renderers/report-html.js +265 -0
- package/src/rules/factory.js +108 -0
- package/src/rules/types/base.js +54 -0
- package/src/rules/types/import-rule.js +286 -0
- package/src/rules.js +380 -0
- package/src/schema/erd-confidence.js +56 -0
- package/src/schema/erd-extractor.js +504 -0
- package/src/schema/erd-model.js +176 -0
- package/src/schema/rules-schema.js +170 -0
- package/src/utils/suggestions.js +67 -0
- package/src/video.js +4 -5
- package/src/workflow/git-helpers.js +576 -0
- package/src/workflow/pr-command.js +694 -0
- package/src/workflow/pr-impact.js +848 -0
- package/src/workflow/sort-utils.js +16 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
function roundTo(value, precision = 3) {
|
|
2
|
+
const factor = 10 ** precision;
|
|
3
|
+
return Math.round(value * factor) / factor;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function evaluateErdConfidence(model) {
|
|
7
|
+
const entities = Array.isArray(model?.entities) ? model.entities : [];
|
|
8
|
+
const relationships = Array.isArray(model?.relationships) ? model.relationships : [];
|
|
9
|
+
|
|
10
|
+
const entityCount = entities.length;
|
|
11
|
+
const explicitEntityCount = entities.filter((entity) => entity.source === 'explicit').length;
|
|
12
|
+
const relationshipCount = relationships.length;
|
|
13
|
+
const inferredRelationshipCount = relationships.filter(
|
|
14
|
+
(relationship) => relationship.provenance === 'inferred'
|
|
15
|
+
).length;
|
|
16
|
+
const explicitRelationshipCount = relationships.filter(
|
|
17
|
+
(relationship) => relationship.provenance === 'explicit'
|
|
18
|
+
).length;
|
|
19
|
+
|
|
20
|
+
const inferenceShare = relationshipCount > 0
|
|
21
|
+
? inferredRelationshipCount / relationshipCount
|
|
22
|
+
: 0;
|
|
23
|
+
|
|
24
|
+
let outcome = 'publishable';
|
|
25
|
+
if (
|
|
26
|
+
entityCount === 0
|
|
27
|
+
|| explicitEntityCount === 0
|
|
28
|
+
|| (relationshipCount > 0 && inferenceShare > 0.8)
|
|
29
|
+
) {
|
|
30
|
+
outcome = 'fail_confidence';
|
|
31
|
+
} else if (
|
|
32
|
+
relationshipCount > 0
|
|
33
|
+
&& inferenceShare > 0.5
|
|
34
|
+
&& inferenceShare <= 0.8
|
|
35
|
+
) {
|
|
36
|
+
outcome = 'publishable_with_marker';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
outcome,
|
|
41
|
+
shouldFail: outcome === 'fail_confidence',
|
|
42
|
+
markerRequired: outcome === 'publishable_with_marker',
|
|
43
|
+
counts: {
|
|
44
|
+
entityCount,
|
|
45
|
+
explicitEntityCount,
|
|
46
|
+
relationshipCount,
|
|
47
|
+
explicitRelationshipCount,
|
|
48
|
+
inferredRelationshipCount,
|
|
49
|
+
inferenceShare: roundTo(inferenceShare, 4),
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = {
|
|
55
|
+
evaluateErdConfidence,
|
|
56
|
+
};
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { globSync } = require('glob');
|
|
4
|
+
const { canonicalEntityName, normalizeErdModel } = require('./erd-model');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_IGNORE = [
|
|
7
|
+
'**/node_modules/**',
|
|
8
|
+
'**/.git/**',
|
|
9
|
+
'**/dist/**',
|
|
10
|
+
'**/build/**',
|
|
11
|
+
'**/test/fixtures/**',
|
|
12
|
+
'**/tests/fixtures/**',
|
|
13
|
+
'**/__fixtures__/**',
|
|
14
|
+
];
|
|
15
|
+
const SOURCE_PRECEDENCE = Object.freeze(['prisma', 'sql']);
|
|
16
|
+
const SOURCE_FILE_PATTERNS = Object.freeze({
|
|
17
|
+
prisma: '**/schema.prisma',
|
|
18
|
+
sql: '**/*.sql',
|
|
19
|
+
});
|
|
20
|
+
const PRISMA_SCALAR_TYPES = new Set(['String', 'Int', 'BigInt', 'Float', 'Decimal', 'Boolean', 'DateTime', 'Json', 'Bytes']);
|
|
21
|
+
const SQL_IDENTIFIER_SOURCE = '(?:["`][^"`]+["`]|[A-Za-z_][A-Za-z0-9_]*)';
|
|
22
|
+
const SQL_QUALIFIED_IDENTIFIER_SOURCE = `${SQL_IDENTIFIER_SOURCE}(?:\\s*\\.\\s*${SQL_IDENTIFIER_SOURCE})?`;
|
|
23
|
+
const SQL_CREATE_TABLE_RE = new RegExp(
|
|
24
|
+
`\\bcreate\\s+table\\s+(?:if\\s+not\\s+exists\\s+)?(${SQL_QUALIFIED_IDENTIFIER_SOURCE})\\s*\\(([\\s\\S]*?)\\)\\s*(?:;|(?=\\s*(?:create\\s+table\\b|$)))`,
|
|
25
|
+
'gi'
|
|
26
|
+
);
|
|
27
|
+
const SQL_INLINE_REFERENCES_RE = new RegExp(`\\breferences\\s+(${SQL_QUALIFIED_IDENTIFIER_SOURCE})`, 'i');
|
|
28
|
+
const SQL_TABLE_PRIMARY_KEY_RE = /^(?:constraint\s+\S+\s+)?primary\s+key\s*\(([^)]+)\)/i;
|
|
29
|
+
const SQL_TABLE_UNIQUE_RE = /^(?:constraint\s+\S+\s+)?unique\s*\(([^)]+)\)/i;
|
|
30
|
+
const SQL_TABLE_FOREIGN_KEY_RE = new RegExp(
|
|
31
|
+
`^(?:constraint\\s+\\S+\\s+)?foreign\\s+key\\s*\\(([^)]+)\\)\\s+references\\s+(${SQL_QUALIFIED_IDENTIFIER_SOURCE})`,
|
|
32
|
+
'i'
|
|
33
|
+
);
|
|
34
|
+
const SQL_TABLE_CONSTRAINT_LINE_RE = /^(?:constraint|foreign\s+key|primary\s+key|unique)\b/i;
|
|
35
|
+
const SQL_COLUMN_NAME_AND_BODY_RE = new RegExp(`^(${SQL_IDENTIFIER_SOURCE})\\s+([\\s\\S]+)$`, 'i');
|
|
36
|
+
const SQL_COLUMN_CONSTRAINT_STARTERS = new Set([
|
|
37
|
+
'constraint',
|
|
38
|
+
'not',
|
|
39
|
+
'null',
|
|
40
|
+
'default',
|
|
41
|
+
'references',
|
|
42
|
+
'primary',
|
|
43
|
+
'unique',
|
|
44
|
+
'check',
|
|
45
|
+
'generated',
|
|
46
|
+
'collate',
|
|
47
|
+
]);
|
|
48
|
+
const SCHEMA_PARSERS = Object.freeze({
|
|
49
|
+
prisma: parsePrismaSchema,
|
|
50
|
+
sql: parseSqlSchema,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function parsePrismaField(line) {
|
|
54
|
+
const trimmed = line.trim();
|
|
55
|
+
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('@@')) return null;
|
|
56
|
+
const parts = trimmed.split(/\s+/);
|
|
57
|
+
if (parts.length < 2) return null;
|
|
58
|
+
|
|
59
|
+
const name = parts[0];
|
|
60
|
+
const rawType = parts[1];
|
|
61
|
+
const rest = parts.slice(2).join(' ');
|
|
62
|
+
const isArray = rawType.endsWith('[]');
|
|
63
|
+
const nullable = rawType.endsWith('?');
|
|
64
|
+
const baseType = rawType.replace(/\?$/, '').replace(/\[\]$/, '');
|
|
65
|
+
const keyFlags = [];
|
|
66
|
+
|
|
67
|
+
if (/\@id\b/.test(rest)) keyFlags.push('PK');
|
|
68
|
+
if (/\@unique\b/.test(rest)) keyFlags.push('UK');
|
|
69
|
+
|
|
70
|
+
const relationMatch = rest.match(/\@relation\s*\(([\s\S]*?)\)/);
|
|
71
|
+
const relationMeta = relationMatch ? relationMatch[1] : '';
|
|
72
|
+
const fieldListMatch = relationMeta.match(/fields\s*:\s*\[([^\]]+)\]/i);
|
|
73
|
+
const relationFields = fieldListMatch
|
|
74
|
+
? fieldListMatch[1]
|
|
75
|
+
.split(',')
|
|
76
|
+
.map((field) => field.trim())
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
: [];
|
|
79
|
+
|
|
80
|
+
const isRelationType = /^[A-Z]/.test(baseType) && !PRISMA_SCALAR_TYPES.has(baseType);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
name,
|
|
84
|
+
type: baseType,
|
|
85
|
+
nullable,
|
|
86
|
+
isArray,
|
|
87
|
+
isRelationType,
|
|
88
|
+
relationFields,
|
|
89
|
+
keyFlags,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parsePrismaSchema(fileContent) {
|
|
94
|
+
const entities = [];
|
|
95
|
+
const relationships = [];
|
|
96
|
+
const modelRe = /\bmodel\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([\s\S]*?)\}/g;
|
|
97
|
+
let match;
|
|
98
|
+
|
|
99
|
+
while ((match = modelRe.exec(fileContent)) !== null) {
|
|
100
|
+
const modelName = match[1];
|
|
101
|
+
const body = match[2];
|
|
102
|
+
const fields = body.split(/\r?\n/).map(parsePrismaField).filter(Boolean);
|
|
103
|
+
const attributes = fields
|
|
104
|
+
.filter((field) => !field.isRelationType)
|
|
105
|
+
.map((field) => ({
|
|
106
|
+
name: field.name,
|
|
107
|
+
type: field.type,
|
|
108
|
+
nullable: field.nullable,
|
|
109
|
+
keyFlags: [...field.keyFlags],
|
|
110
|
+
}));
|
|
111
|
+
const attributesByName = new Map(attributes.map((attribute) => [attribute.name, attribute]));
|
|
112
|
+
|
|
113
|
+
for (const field of fields) {
|
|
114
|
+
for (const relationField of field.relationFields) {
|
|
115
|
+
const attribute = attributesByName.get(relationField);
|
|
116
|
+
if (attribute && !attribute.keyFlags.includes('FK')) {
|
|
117
|
+
attribute.keyFlags.push('FK');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
entities.push({
|
|
123
|
+
name: modelName,
|
|
124
|
+
source: 'explicit',
|
|
125
|
+
attributes,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
for (const field of fields) {
|
|
129
|
+
if (!field.isRelationType) continue;
|
|
130
|
+
if ((field.relationFields || []).length === 0) continue;
|
|
131
|
+
const cardinality = field.isArray ? '||--o{' : '}o--||';
|
|
132
|
+
relationships.push({
|
|
133
|
+
fromEntity: modelName,
|
|
134
|
+
toEntity: field.type,
|
|
135
|
+
cardinality,
|
|
136
|
+
provenance: 'explicit',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { entities, relationships };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function splitSqlDefinitions(body) {
|
|
145
|
+
const chunks = [];
|
|
146
|
+
let cursor = '';
|
|
147
|
+
let depth = 0;
|
|
148
|
+
for (const char of body) {
|
|
149
|
+
if (char === '(') depth += 1;
|
|
150
|
+
if (char === ')') depth = Math.max(0, depth - 1);
|
|
151
|
+
if (char === ',' && depth === 0) {
|
|
152
|
+
chunks.push(cursor.trim());
|
|
153
|
+
cursor = '';
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
cursor += char;
|
|
157
|
+
}
|
|
158
|
+
if (cursor.trim()) chunks.push(cursor.trim());
|
|
159
|
+
return chunks;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function tableNameFromSql(token) {
|
|
163
|
+
const normalized = String(token || '').trim().replace(/\s*\.\s*/g, '.');
|
|
164
|
+
if (!normalized) return '';
|
|
165
|
+
const base = normalized.split('.').pop() || normalized;
|
|
166
|
+
return base.replace(/^["`]/, '').replace(/["`]$/, '');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseSqlIdentifierList(tokenList) {
|
|
170
|
+
return String(tokenList || '')
|
|
171
|
+
.split(',')
|
|
172
|
+
.map((token) => tableNameFromSql(token))
|
|
173
|
+
.filter(Boolean);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function addSqlKeyFlags(attributeMap, columns, flag) {
|
|
177
|
+
for (const column of columns) {
|
|
178
|
+
const attribute = attributeMap.get(String(column).toLowerCase());
|
|
179
|
+
if (!attribute) continue;
|
|
180
|
+
if (!attribute.keyFlags.includes(flag)) {
|
|
181
|
+
attribute.keyFlags.push(flag);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function pushExplicitSqlRelationship(relationships, fromEntity, toEntityToken) {
|
|
187
|
+
const toEntity = tableNameFromSql(toEntityToken);
|
|
188
|
+
if (!toEntity) return;
|
|
189
|
+
relationships.push({
|
|
190
|
+
fromEntity,
|
|
191
|
+
toEntity,
|
|
192
|
+
cardinality: '}o--||',
|
|
193
|
+
provenance: 'explicit',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function applyTableConstraint(line, tableName, attributeMap, relationships) {
|
|
198
|
+
const primaryMatch = line.match(SQL_TABLE_PRIMARY_KEY_RE);
|
|
199
|
+
if (primaryMatch) {
|
|
200
|
+
addSqlKeyFlags(attributeMap, parseSqlIdentifierList(primaryMatch[1]), 'PK');
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const uniqueMatch = line.match(SQL_TABLE_UNIQUE_RE);
|
|
205
|
+
if (uniqueMatch) {
|
|
206
|
+
addSqlKeyFlags(attributeMap, parseSqlIdentifierList(uniqueMatch[1]), 'UK');
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const foreignKeyMatch = line.match(SQL_TABLE_FOREIGN_KEY_RE);
|
|
211
|
+
if (!foreignKeyMatch) return;
|
|
212
|
+
|
|
213
|
+
addSqlKeyFlags(attributeMap, parseSqlIdentifierList(foreignKeyMatch[1]), 'FK');
|
|
214
|
+
pushExplicitSqlRelationship(relationships, tableName, foreignKeyMatch[2]);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function splitSqlTypeAndRemainder(columnBody) {
|
|
218
|
+
const body = String(columnBody || '').trim();
|
|
219
|
+
if (!body) return { columnType: '', remainder: '' };
|
|
220
|
+
|
|
221
|
+
let depth = 0;
|
|
222
|
+
let inSingleQuote = false;
|
|
223
|
+
let inDoubleQuote = false;
|
|
224
|
+
let inBacktickQuote = false;
|
|
225
|
+
|
|
226
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
227
|
+
const char = body[index];
|
|
228
|
+
const prev = body[index - 1];
|
|
229
|
+
|
|
230
|
+
if (!inDoubleQuote && !inBacktickQuote && char === '\'' && prev !== '\\') {
|
|
231
|
+
inSingleQuote = !inSingleQuote;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (!inSingleQuote && !inBacktickQuote && char === '"' && prev !== '\\') {
|
|
235
|
+
inDoubleQuote = !inDoubleQuote;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (!inSingleQuote && !inDoubleQuote && char === '`' && prev !== '\\') {
|
|
239
|
+
inBacktickQuote = !inBacktickQuote;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (inSingleQuote || inDoubleQuote || inBacktickQuote) continue;
|
|
243
|
+
|
|
244
|
+
if (char === '(') {
|
|
245
|
+
depth += 1;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (char === ')') {
|
|
249
|
+
depth = Math.max(0, depth - 1);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (!/\s/.test(char) || depth !== 0) continue;
|
|
253
|
+
|
|
254
|
+
let lookahead = index;
|
|
255
|
+
while (lookahead < body.length && /\s/.test(body[lookahead])) lookahead += 1;
|
|
256
|
+
if (lookahead >= body.length) break;
|
|
257
|
+
|
|
258
|
+
let wordEnd = lookahead;
|
|
259
|
+
while (wordEnd < body.length && /[A-Za-z_]/.test(body[wordEnd])) wordEnd += 1;
|
|
260
|
+
if (wordEnd === lookahead) continue;
|
|
261
|
+
|
|
262
|
+
const maybeConstraint = body.slice(lookahead, wordEnd).toLowerCase();
|
|
263
|
+
if (!SQL_COLUMN_CONSTRAINT_STARTERS.has(maybeConstraint)) continue;
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
columnType: body.slice(0, index).trim(),
|
|
267
|
+
remainder: body.slice(index).trimStart(),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return { columnType: body, remainder: '' };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function parseSqlSchema(fileContent) {
|
|
275
|
+
const entities = [];
|
|
276
|
+
const relationships = [];
|
|
277
|
+
let tableMatch;
|
|
278
|
+
SQL_CREATE_TABLE_RE.lastIndex = 0;
|
|
279
|
+
|
|
280
|
+
while ((tableMatch = SQL_CREATE_TABLE_RE.exec(fileContent)) !== null) {
|
|
281
|
+
const tableName = tableNameFromSql(tableMatch[1]);
|
|
282
|
+
const body = tableMatch[2];
|
|
283
|
+
const definitions = splitSqlDefinitions(body);
|
|
284
|
+
const attributes = [];
|
|
285
|
+
const tableConstraints = [];
|
|
286
|
+
|
|
287
|
+
for (const definition of definitions) {
|
|
288
|
+
const line = definition.trim();
|
|
289
|
+
if (!line) continue;
|
|
290
|
+
|
|
291
|
+
if (SQL_TABLE_CONSTRAINT_LINE_RE.test(line)) {
|
|
292
|
+
tableConstraints.push(line);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const columnMatch = line.match(SQL_COLUMN_NAME_AND_BODY_RE);
|
|
297
|
+
if (!columnMatch) continue;
|
|
298
|
+
const columnName = tableNameFromSql(columnMatch[1]);
|
|
299
|
+
const {
|
|
300
|
+
columnType,
|
|
301
|
+
remainder,
|
|
302
|
+
} = splitSqlTypeAndRemainder(columnMatch[2]);
|
|
303
|
+
const remainderLower = remainder.toLowerCase();
|
|
304
|
+
const keyFlags = [];
|
|
305
|
+
|
|
306
|
+
if (/\bprimary\s+key\b/i.test(remainder)) keyFlags.push('PK');
|
|
307
|
+
if (/\bunique\b/i.test(remainder)) keyFlags.push('UK');
|
|
308
|
+
|
|
309
|
+
const referencesMatch = remainder.match(SQL_INLINE_REFERENCES_RE);
|
|
310
|
+
if (referencesMatch) {
|
|
311
|
+
keyFlags.push('FK');
|
|
312
|
+
pushExplicitSqlRelationship(relationships, tableName, referencesMatch[1]);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
attributes.push({
|
|
316
|
+
name: columnName,
|
|
317
|
+
type: columnType.toLowerCase(),
|
|
318
|
+
nullable: !/\bnot\s+null\b/.test(remainderLower),
|
|
319
|
+
keyFlags,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (tableConstraints.length > 0) {
|
|
324
|
+
const attributeMap = new Map(
|
|
325
|
+
attributes.map((attribute) => [String(attribute.name).toLowerCase(), attribute])
|
|
326
|
+
);
|
|
327
|
+
for (const constraintLine of tableConstraints) {
|
|
328
|
+
applyTableConstraint(constraintLine, tableName, attributeMap, relationships);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
entities.push({
|
|
333
|
+
name: tableName,
|
|
334
|
+
source: 'explicit',
|
|
335
|
+
attributes,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return { entities, relationships };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function parseSchemaSource(source, content) {
|
|
343
|
+
const parser = SCHEMA_PARSERS[source];
|
|
344
|
+
if (parser) return parser(content);
|
|
345
|
+
throw new Error(`unsupported schema source: ${source}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function appendParseDiagnostics(diagnostics, parseErrors) {
|
|
349
|
+
if (parseErrors.length === 0) return;
|
|
350
|
+
diagnostics.push(...parseErrors.map((error) => `${error.source}:${error.file}: ${error.message}`));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function relationshipKey(fromEntity, toEntity) {
|
|
354
|
+
// Deduplicate inferred links against explicit relationships at the entity-pair level.
|
|
355
|
+
// Cardinality differences are preserved later in erd-model relationship storage.
|
|
356
|
+
return `${canonicalEntityName(fromEntity)}|${canonicalEntityName(toEntity)}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function inferRelationshipsFromForeignKeyNames(entities, explicitRelationships) {
|
|
360
|
+
const byEntity = new Map(entities.map((entity) => [canonicalEntityName(entity.name), entity]));
|
|
361
|
+
const explicitKeys = new Set(
|
|
362
|
+
explicitRelationships.map((relationship) => relationshipKey(relationship.fromEntity, relationship.toEntity))
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
const inferred = [];
|
|
366
|
+
for (const entity of entities) {
|
|
367
|
+
const from = canonicalEntityName(entity.name);
|
|
368
|
+
for (const attribute of entity.attributes || []) {
|
|
369
|
+
const attributeName = String(attribute.name || '');
|
|
370
|
+
if (!/(?:_id|Id)$/.test(attributeName)) continue;
|
|
371
|
+
const rawBase = attributeName.replace(/(?:_id|Id)$/, '');
|
|
372
|
+
if (!rawBase) continue;
|
|
373
|
+
const candidates = [
|
|
374
|
+
canonicalEntityName(rawBase),
|
|
375
|
+
canonicalEntityName(`${rawBase}s`),
|
|
376
|
+
canonicalEntityName(rawBase.endsWith('s') ? rawBase.slice(0, -1) : rawBase),
|
|
377
|
+
].filter(Boolean);
|
|
378
|
+
|
|
379
|
+
const target = candidates.find((candidate) => byEntity.has(candidate));
|
|
380
|
+
if (!target) continue;
|
|
381
|
+
const key = relationshipKey(from, target);
|
|
382
|
+
if (explicitKeys.has(key)) continue;
|
|
383
|
+
explicitKeys.add(key);
|
|
384
|
+
inferred.push({
|
|
385
|
+
fromEntity: from,
|
|
386
|
+
toEntity: target,
|
|
387
|
+
cardinality: '}o--||',
|
|
388
|
+
provenance: 'inferred',
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return inferred;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function normalizeIgnore(ignore) {
|
|
397
|
+
return [...new Set([
|
|
398
|
+
...DEFAULT_IGNORE,
|
|
399
|
+
...(Array.isArray(ignore) ? ignore : []),
|
|
400
|
+
].filter(Boolean))];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function extractErdModel({ rootPath, ignore = [] }) {
|
|
404
|
+
const result = {
|
|
405
|
+
extractionInvoked: true,
|
|
406
|
+
sourcePrecedence: [...SOURCE_PRECEDENCE],
|
|
407
|
+
sourceFiles: [],
|
|
408
|
+
diagnostics: [],
|
|
409
|
+
terminalClass: 'completed',
|
|
410
|
+
model: normalizeErdModel({ entities: [], relationships: [] }),
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
if (!rootPath || !fs.existsSync(rootPath)) {
|
|
414
|
+
result.terminalClass = 'failed_parse';
|
|
415
|
+
result.diagnostics.push('root path is missing or does not exist');
|
|
416
|
+
return result;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const sourceCandidates = Object.fromEntries(
|
|
420
|
+
SOURCE_PRECEDENCE.map((source) => [
|
|
421
|
+
source,
|
|
422
|
+
globSync(SOURCE_FILE_PATTERNS[source], {
|
|
423
|
+
cwd: rootPath,
|
|
424
|
+
absolute: true,
|
|
425
|
+
ignore: normalizeIgnore(ignore),
|
|
426
|
+
nodir: true,
|
|
427
|
+
}).sort(),
|
|
428
|
+
])
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
const entities = [];
|
|
432
|
+
const relationships = [];
|
|
433
|
+
const parseErrors = [];
|
|
434
|
+
|
|
435
|
+
for (const source of SOURCE_PRECEDENCE) {
|
|
436
|
+
const files = sourceCandidates[source] || [];
|
|
437
|
+
for (const filePath of files) {
|
|
438
|
+
try {
|
|
439
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
440
|
+
const parsed = parseSchemaSource(source, content);
|
|
441
|
+
entities.push(...parsed.entities);
|
|
442
|
+
relationships.push(...parsed.relationships);
|
|
443
|
+
result.sourceFiles.push(path.relative(rootPath, filePath));
|
|
444
|
+
} catch (error) {
|
|
445
|
+
parseErrors.push({
|
|
446
|
+
source,
|
|
447
|
+
file: path.relative(rootPath, filePath),
|
|
448
|
+
message: error.message,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const normalized = normalizeErdModel({
|
|
455
|
+
entities,
|
|
456
|
+
relationships,
|
|
457
|
+
sourceFiles: result.sourceFiles,
|
|
458
|
+
diagnostics: [],
|
|
459
|
+
sourcePrecedence: SOURCE_PRECEDENCE,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const inferredRelationships = inferRelationshipsFromForeignKeyNames(
|
|
463
|
+
normalized.entities,
|
|
464
|
+
normalized.relationships
|
|
465
|
+
);
|
|
466
|
+
const model = normalizeErdModel({
|
|
467
|
+
...normalized,
|
|
468
|
+
relationships: [...normalized.relationships, ...inferredRelationships],
|
|
469
|
+
sourcePrecedence: SOURCE_PRECEDENCE,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
if (result.sourceFiles.length === 0 && parseErrors.length === 0) {
|
|
473
|
+
result.terminalClass = 'failed_no_schema';
|
|
474
|
+
result.diagnostics.push('no supported schema sources found (expected schema.prisma or .sql files)');
|
|
475
|
+
} else if (model.entities.length === 0) {
|
|
476
|
+
result.terminalClass = 'failed_parse';
|
|
477
|
+
if (parseErrors.length === 0) {
|
|
478
|
+
result.diagnostics.push(
|
|
479
|
+
'schema sources found but no ERD entities extracted (check supported model shapes)'
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
appendParseDiagnostics(result.diagnostics, parseErrors);
|
|
483
|
+
} else {
|
|
484
|
+
appendParseDiagnostics(result.diagnostics, parseErrors);
|
|
485
|
+
result.terminalClass = 'completed';
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
result.model = model;
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
module.exports = {
|
|
493
|
+
SOURCE_PRECEDENCE,
|
|
494
|
+
extractErdModel,
|
|
495
|
+
// Expose parser internals for focused unit coverage without duplicating logic.
|
|
496
|
+
__test: {
|
|
497
|
+
SCHEMA_PARSERS,
|
|
498
|
+
inferRelationshipsFromForeignKeyNames,
|
|
499
|
+
parsePrismaSchema,
|
|
500
|
+
parseSqlSchema,
|
|
501
|
+
relationshipKey,
|
|
502
|
+
splitSqlTypeAndRemainder,
|
|
503
|
+
},
|
|
504
|
+
};
|