sdx-cli 0.3.0 → 0.3.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/README.md +69 -2
- package/dist/commands/docs/readme.js +62 -0
- package/dist/lib/readme.js +1265 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1265 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.README_SECTION_ORDER = void 0;
|
|
7
|
+
exports.parseReadmeSectionList = parseReadmeSectionList;
|
|
8
|
+
exports.generateReadme = generateReadme;
|
|
9
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
10
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const yaml_1 = __importDefault(require("yaml"));
|
|
12
|
+
const zod_1 = require("zod");
|
|
13
|
+
const architecture_1 = require("./architecture");
|
|
14
|
+
const constants_1 = require("./constants");
|
|
15
|
+
const contracts_1 = require("./contracts");
|
|
16
|
+
const fs_1 = require("./fs");
|
|
17
|
+
const mapBuilder_1 = require("./mapBuilder");
|
|
18
|
+
const repoRegistry_1 = require("./repoRegistry");
|
|
19
|
+
const scope_1 = require("./scope");
|
|
20
|
+
const version_1 = require("./version");
|
|
21
|
+
exports.README_SECTION_ORDER = [
|
|
22
|
+
'what_is_this_system',
|
|
23
|
+
'architecture_glance',
|
|
24
|
+
'service_catalog',
|
|
25
|
+
'critical_flows',
|
|
26
|
+
'event_async_topology',
|
|
27
|
+
'contracts_index',
|
|
28
|
+
'repository_index',
|
|
29
|
+
'environments_deployment',
|
|
30
|
+
'data_stores_boundaries',
|
|
31
|
+
'security_compliance',
|
|
32
|
+
'local_dev_contribution',
|
|
33
|
+
'runbooks_escalation',
|
|
34
|
+
'adr_index',
|
|
35
|
+
'glossary',
|
|
36
|
+
'changelog_metadata',
|
|
37
|
+
];
|
|
38
|
+
const SECTION_SET = new Set(exports.README_SECTION_ORDER);
|
|
39
|
+
const SECTION_TITLES = {
|
|
40
|
+
what_is_this_system: 'What this org/system is',
|
|
41
|
+
architecture_glance: 'Architecture at a glance',
|
|
42
|
+
service_catalog: 'Service catalog table',
|
|
43
|
+
critical_flows: 'Critical request/data flows',
|
|
44
|
+
event_async_topology: 'Event and async topology',
|
|
45
|
+
contracts_index: 'Contracts and interface index',
|
|
46
|
+
repository_index: 'Repository index with ownership',
|
|
47
|
+
environments_deployment: 'Environments and deployment topology',
|
|
48
|
+
data_stores_boundaries: 'Data stores and boundaries',
|
|
49
|
+
security_compliance: 'Security/compliance considerations',
|
|
50
|
+
local_dev_contribution: 'Local development and contribution workflow',
|
|
51
|
+
runbooks_escalation: 'Operational runbooks and escalation paths',
|
|
52
|
+
adr_index: 'ADR and design decision index',
|
|
53
|
+
glossary: 'Glossary',
|
|
54
|
+
changelog_metadata: 'Change log / last generated metadata',
|
|
55
|
+
};
|
|
56
|
+
const REQUIRED_DIAGRAM_NAMES = ['system-context.mmd', 'service-dependency.mmd', 'core-request-flow.mmd'];
|
|
57
|
+
const README_CONFIG_SCHEMA = zod_1.z.object({
|
|
58
|
+
sections: zod_1.z
|
|
59
|
+
.object({
|
|
60
|
+
include: zod_1.z.array(zod_1.z.string()).optional(),
|
|
61
|
+
exclude: zod_1.z.array(zod_1.z.string()).optional(),
|
|
62
|
+
enabled: zod_1.z.record(zod_1.z.string(), zod_1.z.boolean()).optional(),
|
|
63
|
+
})
|
|
64
|
+
.optional(),
|
|
65
|
+
repos: zod_1.z
|
|
66
|
+
.object({
|
|
67
|
+
include: zod_1.z.array(zod_1.z.string()).optional(),
|
|
68
|
+
exclude: zod_1.z.array(zod_1.z.string()).optional(),
|
|
69
|
+
})
|
|
70
|
+
.optional(),
|
|
71
|
+
domainGroups: zod_1.z
|
|
72
|
+
.array(zod_1.z.object({
|
|
73
|
+
name: zod_1.z.string(),
|
|
74
|
+
match: zod_1.z.array(zod_1.z.string()).default([]),
|
|
75
|
+
}))
|
|
76
|
+
.optional(),
|
|
77
|
+
ownerTeamOverrides: zod_1.z.record(zod_1.z.string(), zod_1.z.string()).optional(),
|
|
78
|
+
diagram: zod_1.z
|
|
79
|
+
.object({
|
|
80
|
+
autoGenerateMissing: zod_1.z.boolean().optional(),
|
|
81
|
+
includeC4Links: zod_1.z.boolean().optional(),
|
|
82
|
+
})
|
|
83
|
+
.optional(),
|
|
84
|
+
customIntro: zod_1.z.string().optional(),
|
|
85
|
+
staleThresholdHours: zod_1.z.number().positive().optional(),
|
|
86
|
+
});
|
|
87
|
+
function normalizeRepoName(value) {
|
|
88
|
+
return value.trim().replace(/^https?:\/\/github\.com\//i, '').replace(/\.git$/i, '').split('/').pop() ?? value.trim();
|
|
89
|
+
}
|
|
90
|
+
function asRelative(filePath, cwd) {
|
|
91
|
+
const relative = node_path_1.default.relative(cwd, filePath);
|
|
92
|
+
return relative.length === 0 ? '.' : relative.split(node_path_1.default.sep).join('/');
|
|
93
|
+
}
|
|
94
|
+
function toLinkPath(targetPath, outputPath) {
|
|
95
|
+
const relative = node_path_1.default.relative(node_path_1.default.dirname(outputPath), targetPath).split(node_path_1.default.sep).join('/');
|
|
96
|
+
if (relative.startsWith('.')) {
|
|
97
|
+
return relative;
|
|
98
|
+
}
|
|
99
|
+
return `./${relative}`;
|
|
100
|
+
}
|
|
101
|
+
function safeTimestamp(value) {
|
|
102
|
+
if (!value) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const parsed = new Date(value);
|
|
106
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
return parsed;
|
|
110
|
+
}
|
|
111
|
+
function isOlderThan(value, thresholdHours, now) {
|
|
112
|
+
if (!value) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
const elapsedMs = now.getTime() - value.getTime();
|
|
116
|
+
return elapsedMs > thresholdHours * 60 * 60 * 1000;
|
|
117
|
+
}
|
|
118
|
+
function loadReadmeConfig(cwd) {
|
|
119
|
+
const candidates = [
|
|
120
|
+
node_path_1.default.join(cwd, '.sdx', 'readme.config.json'),
|
|
121
|
+
node_path_1.default.join(cwd, '.sdx', 'readme.config.yaml'),
|
|
122
|
+
node_path_1.default.join(cwd, '.sdx', 'readme.config.yml'),
|
|
123
|
+
];
|
|
124
|
+
for (const filePath of candidates) {
|
|
125
|
+
if (!(0, fs_1.fileExists)(filePath)) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const text = (0, fs_1.safeReadText)(filePath);
|
|
129
|
+
const parsed = filePath.endsWith('.json') ? JSON.parse(text) : yaml_1.default.parse(text);
|
|
130
|
+
const config = README_CONFIG_SCHEMA.parse(parsed);
|
|
131
|
+
return {
|
|
132
|
+
config,
|
|
133
|
+
sourcePath: filePath,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return { config: {} };
|
|
137
|
+
}
|
|
138
|
+
function parseReadmeSectionList(input) {
|
|
139
|
+
if (!input) {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
const tokens = input
|
|
143
|
+
.split(',')
|
|
144
|
+
.map((token) => token.trim())
|
|
145
|
+
.filter((token) => token.length > 0);
|
|
146
|
+
const invalid = tokens.filter((token) => !SECTION_SET.has(token));
|
|
147
|
+
if (invalid.length > 0) {
|
|
148
|
+
throw new Error(`Unknown section id(s): ${invalid.join(', ')}. Valid ids: ${exports.README_SECTION_ORDER.join(', ')}`);
|
|
149
|
+
}
|
|
150
|
+
return [...new Set(tokens)];
|
|
151
|
+
}
|
|
152
|
+
function selectSections(config, includeSections, excludeSections) {
|
|
153
|
+
let ordered = [...exports.README_SECTION_ORDER];
|
|
154
|
+
const configEnabled = config.sections?.enabled ?? {};
|
|
155
|
+
ordered = ordered.filter((section) => configEnabled[section] !== false);
|
|
156
|
+
const configInclude = (config.sections?.include ?? [])
|
|
157
|
+
.map((candidate) => candidate.trim())
|
|
158
|
+
.filter((candidate) => candidate.length > 0);
|
|
159
|
+
if (configInclude.length > 0) {
|
|
160
|
+
ordered = ordered.filter((section) => configInclude.includes(section));
|
|
161
|
+
}
|
|
162
|
+
const configExclude = (config.sections?.exclude ?? [])
|
|
163
|
+
.map((candidate) => candidate.trim())
|
|
164
|
+
.filter((candidate) => candidate.length > 0);
|
|
165
|
+
if (configExclude.length > 0) {
|
|
166
|
+
ordered = ordered.filter((section) => !configExclude.includes(section));
|
|
167
|
+
}
|
|
168
|
+
if (includeSections.length > 0) {
|
|
169
|
+
ordered = ordered.filter((section) => includeSections.includes(section));
|
|
170
|
+
}
|
|
171
|
+
if (excludeSections.length > 0) {
|
|
172
|
+
ordered = ordered.filter((section) => !excludeSections.includes(section));
|
|
173
|
+
}
|
|
174
|
+
if (ordered.length === 0) {
|
|
175
|
+
throw new Error('No README sections selected after include/exclude filtering.');
|
|
176
|
+
}
|
|
177
|
+
return ordered;
|
|
178
|
+
}
|
|
179
|
+
function readGeneratedAtFromJson(filePath) {
|
|
180
|
+
if (!(0, fs_1.fileExists)(filePath)) {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const payload = (0, fs_1.readJsonFile)(filePath);
|
|
185
|
+
const value = payload['generatedAt'];
|
|
186
|
+
return typeof value === 'string' ? value : undefined;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function sourceFromFile(id, label, filePath, cwd, thresholdHours, now, required) {
|
|
193
|
+
const exists = (0, fs_1.fileExists)(filePath);
|
|
194
|
+
const generatedAt = exists
|
|
195
|
+
? readGeneratedAtFromJson(filePath) ?? node_fs_1.default.statSync(filePath).mtime.toISOString()
|
|
196
|
+
: undefined;
|
|
197
|
+
const stale = exists ? isOlderThan(safeTimestamp(generatedAt), thresholdHours, now) : true;
|
|
198
|
+
return {
|
|
199
|
+
id,
|
|
200
|
+
label,
|
|
201
|
+
path: asRelative(filePath, cwd),
|
|
202
|
+
exists,
|
|
203
|
+
generatedAt,
|
|
204
|
+
stale,
|
|
205
|
+
required,
|
|
206
|
+
note: exists ? undefined : 'Missing source artifact',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function detectRepoSyncTimestamp(repos, scopedRepos) {
|
|
210
|
+
const timestamps = [];
|
|
211
|
+
for (const repoName of scopedRepos) {
|
|
212
|
+
const repo = repos.find((entry) => entry.name === repoName);
|
|
213
|
+
if (!repo?.lastSyncedAt) {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
const parsed = safeTimestamp(repo.lastSyncedAt);
|
|
217
|
+
if (!parsed) {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
timestamps.push(parsed);
|
|
221
|
+
}
|
|
222
|
+
if (timestamps.length === 0) {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
const oldest = timestamps.reduce((min, candidate) => (candidate.getTime() < min.getTime() ? candidate : min), timestamps[0]);
|
|
226
|
+
return oldest.toISOString();
|
|
227
|
+
}
|
|
228
|
+
function sourceFromRepoSync(repos, scopedRepos, thresholdHours, now) {
|
|
229
|
+
const generatedAt = detectRepoSyncTimestamp(repos, scopedRepos);
|
|
230
|
+
const stale = isOlderThan(safeTimestamp(generatedAt), thresholdHours, now);
|
|
231
|
+
return {
|
|
232
|
+
id: 'repo-sync',
|
|
233
|
+
label: 'Repository registry sync state',
|
|
234
|
+
path: '.sdx/state.db#repo_registry',
|
|
235
|
+
exists: generatedAt !== undefined,
|
|
236
|
+
generatedAt,
|
|
237
|
+
stale,
|
|
238
|
+
required: true,
|
|
239
|
+
note: generatedAt ? undefined : 'At least one scoped repo has no lastSyncedAt timestamp',
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function upsertSourceRef(sources, source) {
|
|
243
|
+
const index = sources.findIndex((entry) => entry.id === source.id);
|
|
244
|
+
if (index >= 0) {
|
|
245
|
+
sources[index] = source;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
sources.push(source);
|
|
249
|
+
}
|
|
250
|
+
function computeSnapshotTimestamp(sources, fallback) {
|
|
251
|
+
const sourceCandidates = sources
|
|
252
|
+
.map((source) => safeTimestamp(source.generatedAt))
|
|
253
|
+
.filter((entry) => Boolean(entry));
|
|
254
|
+
if (sourceCandidates.length === 0) {
|
|
255
|
+
return fallback.toISOString();
|
|
256
|
+
}
|
|
257
|
+
return sourceCandidates.reduce((latest, candidate) => (candidate.getTime() > latest.getTime() ? candidate : latest), sourceCandidates[0]).toISOString();
|
|
258
|
+
}
|
|
259
|
+
function filterReposForReadme(scope, config) {
|
|
260
|
+
const base = [...scope.effective];
|
|
261
|
+
const include = new Set((config.repos?.include ?? []).map((value) => normalizeRepoName(value)));
|
|
262
|
+
const exclude = new Set((config.repos?.exclude ?? []).map((value) => normalizeRepoName(value)));
|
|
263
|
+
let selected = base;
|
|
264
|
+
if (include.size > 0) {
|
|
265
|
+
selected = selected.filter((repo) => include.has(repo));
|
|
266
|
+
}
|
|
267
|
+
selected = selected.filter((repo) => !exclude.has(repo));
|
|
268
|
+
selected.sort((a, b) => a.localeCompare(b));
|
|
269
|
+
return selected;
|
|
270
|
+
}
|
|
271
|
+
function loadServiceMap(mapId, scope, repoMap, mapDir) {
|
|
272
|
+
const filePath = node_path_1.default.join(mapDir, 'service-map.json');
|
|
273
|
+
if ((0, fs_1.fileExists)(filePath)) {
|
|
274
|
+
return (0, fs_1.readJsonFile)(filePath);
|
|
275
|
+
}
|
|
276
|
+
return (0, mapBuilder_1.buildServiceMapArtifact)(mapId, scope, repoMap);
|
|
277
|
+
}
|
|
278
|
+
function loadContracts(mapId, scope, repoMap, mapDir) {
|
|
279
|
+
const filePath = node_path_1.default.join(mapDir, 'contracts.json');
|
|
280
|
+
if ((0, fs_1.fileExists)(filePath)) {
|
|
281
|
+
return (0, fs_1.readJsonFile)(filePath);
|
|
282
|
+
}
|
|
283
|
+
return (0, contracts_1.extractContracts)(mapId, scope, repoMap);
|
|
284
|
+
}
|
|
285
|
+
function loadArchitectureModel(mapId, db, cwd) {
|
|
286
|
+
const filePath = node_path_1.default.join(cwd, 'maps', mapId, 'architecture', 'model.json');
|
|
287
|
+
if ((0, fs_1.fileExists)(filePath)) {
|
|
288
|
+
return (0, fs_1.readJsonFile)(filePath);
|
|
289
|
+
}
|
|
290
|
+
return (0, architecture_1.buildArchitectureModel)(mapId, db, cwd);
|
|
291
|
+
}
|
|
292
|
+
function diagramPaths(mapId, cwd) {
|
|
293
|
+
const baseDir = node_path_1.default.join(cwd, 'docs', 'architecture', mapId, 'diagrams');
|
|
294
|
+
return {
|
|
295
|
+
baseDir,
|
|
296
|
+
systemContext: node_path_1.default.join(baseDir, 'system-context.mmd'),
|
|
297
|
+
serviceDependency: node_path_1.default.join(baseDir, 'service-dependency.mmd'),
|
|
298
|
+
sequence: node_path_1.default.join(baseDir, 'core-request-flow.mmd'),
|
|
299
|
+
optionalSystemLandscape: node_path_1.default.join(baseDir, 'system-landscape.mmd'),
|
|
300
|
+
optionalContainer: node_path_1.default.join(baseDir, 'container-communication.mmd'),
|
|
301
|
+
optionalArchitectureIndex: node_path_1.default.join(cwd, 'docs', 'architecture', mapId, 'index.md'),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function renderFlowchart(nodes, edges) {
|
|
305
|
+
const lines = ['flowchart LR'];
|
|
306
|
+
for (const node of nodes.sort((a, b) => a.id.localeCompare(b.id))) {
|
|
307
|
+
const nodeId = node.id.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
308
|
+
lines.push(` ${nodeId}["${node.label}"]`);
|
|
309
|
+
}
|
|
310
|
+
const sortedEdges = [...edges].sort((a, b) => {
|
|
311
|
+
const left = `${a.from}|${a.to}|${a.relation}`;
|
|
312
|
+
const right = `${b.from}|${b.to}|${b.relation}`;
|
|
313
|
+
return left.localeCompare(right);
|
|
314
|
+
});
|
|
315
|
+
for (const edge of sortedEdges) {
|
|
316
|
+
const fromId = edge.from.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
317
|
+
const toId = edge.to.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
318
|
+
lines.push(` ${fromId} -->|"${edge.relation}"| ${toId}`);
|
|
319
|
+
}
|
|
320
|
+
return `${lines.join('\n')}\n`;
|
|
321
|
+
}
|
|
322
|
+
function renderSystemContextDiagram(model) {
|
|
323
|
+
const allowed = new Set(['service', 'external', 'datastore', 'queue', 'team']);
|
|
324
|
+
const nodes = model.nodes
|
|
325
|
+
.filter((node) => allowed.has(node.type))
|
|
326
|
+
.map((node) => ({ id: node.id, label: node.label }));
|
|
327
|
+
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
328
|
+
const edges = model.edges
|
|
329
|
+
.filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to))
|
|
330
|
+
.map((edge) => ({ from: edge.from, to: edge.to, relation: edge.relation }));
|
|
331
|
+
return renderFlowchart(nodes, edges);
|
|
332
|
+
}
|
|
333
|
+
function renderServiceDependencyDiagram(serviceMap) {
|
|
334
|
+
const serviceNodes = serviceMap.nodes
|
|
335
|
+
.filter((node) => node.type === 'service')
|
|
336
|
+
.map((node) => ({ id: node.id, label: node.label }));
|
|
337
|
+
const serviceIds = new Set(serviceNodes.map((node) => node.id));
|
|
338
|
+
const edges = serviceMap.edges
|
|
339
|
+
.filter((edge) => serviceIds.has(edge.from) && serviceIds.has(edge.to))
|
|
340
|
+
.map((edge) => ({ from: edge.from, to: edge.to, relation: edge.relation }));
|
|
341
|
+
return renderFlowchart(serviceNodes, edges);
|
|
342
|
+
}
|
|
343
|
+
function enumerateServiceCallEdges(model) {
|
|
344
|
+
const out = [];
|
|
345
|
+
for (const edge of model.edges) {
|
|
346
|
+
if (edge.relation !== 'calls') {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (!edge.from.startsWith('service:') || !edge.to.startsWith('service:')) {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
out.push({
|
|
353
|
+
from: edge.from.replace('service:', ''),
|
|
354
|
+
to: edge.to.replace('service:', ''),
|
|
355
|
+
confidence: edge.provenance.confidence,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
return out.sort((a, b) => {
|
|
359
|
+
const score = b.confidence - a.confidence;
|
|
360
|
+
if (score !== 0) {
|
|
361
|
+
return score;
|
|
362
|
+
}
|
|
363
|
+
const left = `${a.from}|${a.to}`;
|
|
364
|
+
const right = `${b.from}|${b.to}`;
|
|
365
|
+
return left.localeCompare(right);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
function enumerateDependencyEdges(serviceMap) {
|
|
369
|
+
const out = [];
|
|
370
|
+
for (const edge of serviceMap.edges) {
|
|
371
|
+
if (edge.relation !== 'depends_on') {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (!edge.from.startsWith('service:') || !edge.to.startsWith('service:')) {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
out.push({
|
|
378
|
+
from: edge.from.replace('service:', ''),
|
|
379
|
+
to: edge.to.replace('service:', ''),
|
|
380
|
+
confidence: 0.45,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return out.sort((a, b) => {
|
|
384
|
+
const left = `${a.from}|${a.to}`;
|
|
385
|
+
const right = `${b.from}|${b.to}`;
|
|
386
|
+
return left.localeCompare(right);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
function bestPath(edges) {
|
|
390
|
+
if (edges.length === 0) {
|
|
391
|
+
return [];
|
|
392
|
+
}
|
|
393
|
+
const adjacency = new Map();
|
|
394
|
+
for (const edge of edges) {
|
|
395
|
+
const candidates = adjacency.get(edge.from) ?? [];
|
|
396
|
+
candidates.push(edge);
|
|
397
|
+
adjacency.set(edge.from, candidates);
|
|
398
|
+
}
|
|
399
|
+
for (const candidateEdges of adjacency.values()) {
|
|
400
|
+
candidateEdges.sort((a, b) => {
|
|
401
|
+
const score = b.confidence - a.confidence;
|
|
402
|
+
if (score !== 0) {
|
|
403
|
+
return score;
|
|
404
|
+
}
|
|
405
|
+
return `${a.from}|${a.to}`.localeCompare(`${b.from}|${b.to}`);
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
const starts = [...new Set(edges.map((edge) => edge.from))].sort((a, b) => a.localeCompare(b));
|
|
409
|
+
let best = [];
|
|
410
|
+
let bestScore = -1;
|
|
411
|
+
const maxDepth = 6;
|
|
412
|
+
function dfs(current, visited, pathEdges) {
|
|
413
|
+
const outgoing = adjacency.get(current) ?? [];
|
|
414
|
+
if (pathEdges.length > 0) {
|
|
415
|
+
const score = pathEdges.reduce((sum, step) => sum + step.confidence, 0) + pathEdges.length * 0.1;
|
|
416
|
+
const tieBreakerLeft = pathEdges.map((edge) => `${edge.from}->${edge.to}`).join('|');
|
|
417
|
+
const tieBreakerRight = best.map((edge) => `${edge.from}->${edge.to}`).join('|');
|
|
418
|
+
if (score > bestScore || (score === bestScore && tieBreakerLeft.localeCompare(tieBreakerRight) < 0)) {
|
|
419
|
+
best = [...pathEdges];
|
|
420
|
+
bestScore = score;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (pathEdges.length >= maxDepth) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
for (const edge of outgoing) {
|
|
427
|
+
if (visited.has(edge.to)) {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
visited.add(edge.to);
|
|
431
|
+
pathEdges.push(edge);
|
|
432
|
+
dfs(edge.to, visited, pathEdges);
|
|
433
|
+
pathEdges.pop();
|
|
434
|
+
visited.delete(edge.to);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
for (const start of starts) {
|
|
438
|
+
const visited = new Set([start]);
|
|
439
|
+
dfs(start, visited, []);
|
|
440
|
+
}
|
|
441
|
+
return best;
|
|
442
|
+
}
|
|
443
|
+
function findCoreRequestPath(model, serviceMap) {
|
|
444
|
+
const callPath = bestPath(enumerateServiceCallEdges(model));
|
|
445
|
+
const fallbackPath = callPath.length > 0 ? callPath : bestPath(enumerateDependencyEdges(serviceMap));
|
|
446
|
+
if (fallbackPath.length === 0) {
|
|
447
|
+
return [];
|
|
448
|
+
}
|
|
449
|
+
return fallbackPath.map((step) => ({
|
|
450
|
+
from: `service:${step.from}`,
|
|
451
|
+
to: `service:${step.to}`,
|
|
452
|
+
relation: 'calls',
|
|
453
|
+
provenance: {
|
|
454
|
+
source: callPath.length > 0 ? 'inferred' : 'declared',
|
|
455
|
+
confidence: step.confidence,
|
|
456
|
+
evidence: [callPath.length > 0 ? 'architecture_model' : 'service_map_dependency_fallback'],
|
|
457
|
+
},
|
|
458
|
+
}));
|
|
459
|
+
}
|
|
460
|
+
function renderCoreSequence(pathEdges) {
|
|
461
|
+
const lines = ['sequenceDiagram', ' autonumber'];
|
|
462
|
+
if (pathEdges.length === 0) {
|
|
463
|
+
lines.push(' participant system as System');
|
|
464
|
+
lines.push(' system->>system: Unknown flow (insufficient call/dependency evidence)');
|
|
465
|
+
return `${lines.join('\n')}\n`;
|
|
466
|
+
}
|
|
467
|
+
const participants = new Set();
|
|
468
|
+
for (const edge of pathEdges) {
|
|
469
|
+
participants.add(edge.from.replace('service:', ''));
|
|
470
|
+
participants.add(edge.to.replace('service:', ''));
|
|
471
|
+
}
|
|
472
|
+
for (const participant of [...participants].sort((a, b) => a.localeCompare(b))) {
|
|
473
|
+
lines.push(` participant ${participant.replace(/[^a-zA-Z0-9_]/g, '_')} as ${participant}`);
|
|
474
|
+
}
|
|
475
|
+
for (const edge of pathEdges) {
|
|
476
|
+
const from = edge.from.replace('service:', '').replace(/[^a-zA-Z0-9_]/g, '_');
|
|
477
|
+
const to = edge.to.replace('service:', '').replace(/[^a-zA-Z0-9_]/g, '_');
|
|
478
|
+
lines.push(` ${from}->>${to}: ${edge.relation}`);
|
|
479
|
+
lines.push(` ${to}-->>${from}: response`);
|
|
480
|
+
}
|
|
481
|
+
return `${lines.join('\n')}\n`;
|
|
482
|
+
}
|
|
483
|
+
function ensureRequiredDiagrams(context, db, cwd, writeEnabled) {
|
|
484
|
+
const refs = [];
|
|
485
|
+
const threshold = context.staleThresholdHours;
|
|
486
|
+
const required = REQUIRED_DIAGRAM_NAMES.map((name) => node_path_1.default.join(context.diagrams.baseDir, name));
|
|
487
|
+
const missing = required.filter((candidate) => !(0, fs_1.fileExists)(candidate));
|
|
488
|
+
let generatedArchitecturePack = false;
|
|
489
|
+
const autoGenerateMissing = context.config.diagram?.autoGenerateMissing ?? true;
|
|
490
|
+
if (writeEnabled && autoGenerateMissing && missing.length > 0) {
|
|
491
|
+
(0, architecture_1.generateArchitecturePack)({
|
|
492
|
+
mapId: context.mapId,
|
|
493
|
+
db,
|
|
494
|
+
cwd,
|
|
495
|
+
depth: 'org',
|
|
496
|
+
});
|
|
497
|
+
generatedArchitecturePack = true;
|
|
498
|
+
}
|
|
499
|
+
if (writeEnabled && autoGenerateMissing && !(0, fs_1.fileExists)(context.diagrams.systemContext)) {
|
|
500
|
+
(0, fs_1.writeTextFile)(context.diagrams.systemContext, renderSystemContextDiagram(context.architectureModel));
|
|
501
|
+
}
|
|
502
|
+
if (writeEnabled && autoGenerateMissing && !(0, fs_1.fileExists)(context.diagrams.serviceDependency)) {
|
|
503
|
+
(0, fs_1.writeTextFile)(context.diagrams.serviceDependency, renderServiceDependencyDiagram(context.serviceMap));
|
|
504
|
+
}
|
|
505
|
+
if (writeEnabled && autoGenerateMissing && !(0, fs_1.fileExists)(context.diagrams.sequence)) {
|
|
506
|
+
(0, fs_1.writeTextFile)(context.diagrams.sequence, renderCoreSequence(context.coreRequestPath));
|
|
507
|
+
}
|
|
508
|
+
refs.push(sourceFromFile('diagram-system-context', 'System context diagram', context.diagrams.systemContext, cwd, threshold, context.now, true));
|
|
509
|
+
refs.push(sourceFromFile('diagram-service-dependency', 'Service dependency diagram', context.diagrams.serviceDependency, cwd, threshold, context.now, true));
|
|
510
|
+
refs.push(sourceFromFile('diagram-core-sequence', 'Core request flow sequence', context.diagrams.sequence, cwd, threshold, context.now, true));
|
|
511
|
+
refs.push(sourceFromFile('diagram-c4-landscape', 'Optional C4 system landscape', context.diagrams.optionalSystemLandscape, cwd, threshold, context.now, false));
|
|
512
|
+
refs.push(sourceFromFile('diagram-c4-container', 'Optional C4 container communication', context.diagrams.optionalContainer, cwd, threshold, context.now, false));
|
|
513
|
+
return {
|
|
514
|
+
generatedArchitecturePack,
|
|
515
|
+
diagramSources: refs,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
function readCodeownersOwner(repo) {
|
|
519
|
+
if (!repo.localPath) {
|
|
520
|
+
return undefined;
|
|
521
|
+
}
|
|
522
|
+
const candidates = [node_path_1.default.join(repo.localPath, 'CODEOWNERS'), node_path_1.default.join(repo.localPath, '.github', 'CODEOWNERS')];
|
|
523
|
+
for (const candidate of candidates) {
|
|
524
|
+
if (!(0, fs_1.fileExists)(candidate)) {
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const lines = (0, fs_1.safeReadText)(candidate)
|
|
528
|
+
.split(/\r?\n/)
|
|
529
|
+
.map((line) => line.trim())
|
|
530
|
+
.filter((line) => line.length > 0 && !line.startsWith('#'));
|
|
531
|
+
for (const line of lines) {
|
|
532
|
+
const parts = line.split(/\s+/).filter((part) => part.length > 0);
|
|
533
|
+
const owner = parts.find((part) => part.startsWith('@'));
|
|
534
|
+
if (owner) {
|
|
535
|
+
return owner;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
function inferRuntimeFramework(repo) {
|
|
542
|
+
if (!repo.localPath || !(0, fs_1.fileExists)(repo.localPath)) {
|
|
543
|
+
return 'Unknown';
|
|
544
|
+
}
|
|
545
|
+
const packagePath = node_path_1.default.join(repo.localPath, 'package.json');
|
|
546
|
+
if ((0, fs_1.fileExists)(packagePath)) {
|
|
547
|
+
try {
|
|
548
|
+
const payload = (0, fs_1.readJsonFile)(packagePath);
|
|
549
|
+
const deps = new Set([
|
|
550
|
+
...Object.keys(payload.dependencies ?? {}),
|
|
551
|
+
...Object.keys(payload.devDependencies ?? {}),
|
|
552
|
+
]);
|
|
553
|
+
if (deps.has('next')) {
|
|
554
|
+
return 'Node.js (Next.js)';
|
|
555
|
+
}
|
|
556
|
+
if (deps.has('nestjs') || deps.has('@nestjs/core')) {
|
|
557
|
+
return 'Node.js (NestJS)';
|
|
558
|
+
}
|
|
559
|
+
if (deps.has('express')) {
|
|
560
|
+
return 'Node.js (Express)';
|
|
561
|
+
}
|
|
562
|
+
return 'Node.js';
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
return 'Node.js';
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if ((0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'pyproject.toml')) || (0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'requirements.txt'))) {
|
|
569
|
+
return 'Python';
|
|
570
|
+
}
|
|
571
|
+
if ((0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'go.mod'))) {
|
|
572
|
+
return 'Go';
|
|
573
|
+
}
|
|
574
|
+
if ((0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'Cargo.toml'))) {
|
|
575
|
+
return 'Rust';
|
|
576
|
+
}
|
|
577
|
+
if ((0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'pom.xml')) || (0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'build.gradle'))) {
|
|
578
|
+
return 'JVM';
|
|
579
|
+
}
|
|
580
|
+
return 'Unknown';
|
|
581
|
+
}
|
|
582
|
+
function inferDeployTarget(repo) {
|
|
583
|
+
if (!repo.localPath || !(0, fs_1.fileExists)(repo.localPath)) {
|
|
584
|
+
return 'Unknown';
|
|
585
|
+
}
|
|
586
|
+
if ((0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'vercel.json'))) {
|
|
587
|
+
return 'Vercel';
|
|
588
|
+
}
|
|
589
|
+
if ((0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'serverless.yml')) || (0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'serverless.yaml'))) {
|
|
590
|
+
return 'Serverless';
|
|
591
|
+
}
|
|
592
|
+
const hasKubernetes = (0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'k8s')) ||
|
|
593
|
+
(0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'helm')) ||
|
|
594
|
+
(0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'charts'));
|
|
595
|
+
if (hasKubernetes) {
|
|
596
|
+
return 'Kubernetes';
|
|
597
|
+
}
|
|
598
|
+
if ((0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'Dockerfile')) || (0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'docker-compose.yml'))) {
|
|
599
|
+
return 'Container';
|
|
600
|
+
}
|
|
601
|
+
return 'Unknown';
|
|
602
|
+
}
|
|
603
|
+
function formatList(values) {
|
|
604
|
+
if (values.length === 0) {
|
|
605
|
+
return 'Unknown';
|
|
606
|
+
}
|
|
607
|
+
return values.join(', ');
|
|
608
|
+
}
|
|
609
|
+
function ownerForService(serviceId, context) {
|
|
610
|
+
const overrides = context.config.ownerTeamOverrides ?? {};
|
|
611
|
+
if (overrides[serviceId]) {
|
|
612
|
+
return overrides[serviceId];
|
|
613
|
+
}
|
|
614
|
+
const serviceNode = context.architectureModel.nodes.find((node) => node.id === `service:${serviceId}`);
|
|
615
|
+
const fromMetadata = serviceNode?.metadata?.['owner'];
|
|
616
|
+
if (typeof fromMetadata === 'string' && fromMetadata.trim().length > 0) {
|
|
617
|
+
return fromMetadata.trim();
|
|
618
|
+
}
|
|
619
|
+
const repo = context.repoMap.get(serviceId);
|
|
620
|
+
if (!repo) {
|
|
621
|
+
return 'Unknown';
|
|
622
|
+
}
|
|
623
|
+
const fromCodeowners = readCodeownersOwner(repo);
|
|
624
|
+
return fromCodeowners ?? 'Unknown';
|
|
625
|
+
}
|
|
626
|
+
function criticalityForService(serviceId, context) {
|
|
627
|
+
const serviceNode = context.architectureModel.nodes.find((node) => node.id === `service:${serviceId}`);
|
|
628
|
+
const criticality = serviceNode?.metadata?.['criticality'];
|
|
629
|
+
if (typeof criticality === 'string' && criticality.trim().length > 0) {
|
|
630
|
+
return criticality;
|
|
631
|
+
}
|
|
632
|
+
return 'Unknown';
|
|
633
|
+
}
|
|
634
|
+
function apiSurfaceForService(serviceId, context) {
|
|
635
|
+
const serviceContracts = context.contracts.filter((record) => record.repo === serviceId);
|
|
636
|
+
if (serviceContracts.length === 0) {
|
|
637
|
+
return 'Unknown';
|
|
638
|
+
}
|
|
639
|
+
const byType = new Map();
|
|
640
|
+
for (const contract of serviceContracts) {
|
|
641
|
+
byType.set(contract.type, (byType.get(contract.type) ?? 0) + 1);
|
|
642
|
+
}
|
|
643
|
+
return [...byType.entries()]
|
|
644
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
645
|
+
.map(([type, count]) => `${type} (${count})`)
|
|
646
|
+
.join(', ');
|
|
647
|
+
}
|
|
648
|
+
function dependenciesForService(serviceId, context) {
|
|
649
|
+
const sourceId = `service:${serviceId}`;
|
|
650
|
+
const dependencies = context.serviceMap.edges
|
|
651
|
+
.filter((edge) => edge.from === sourceId && edge.to.startsWith('service:'))
|
|
652
|
+
.map((edge) => edge.to.replace('service:', ''));
|
|
653
|
+
return formatList([...new Set(dependencies)].sort((a, b) => a.localeCompare(b)));
|
|
654
|
+
}
|
|
655
|
+
function datastoresForService(serviceId, context) {
|
|
656
|
+
const sourceId = `service:${serviceId}`;
|
|
657
|
+
const stores = context.architectureModel.edges
|
|
658
|
+
.filter((edge) => edge.from === sourceId && edge.to.startsWith('datastore:'))
|
|
659
|
+
.map((edge) => edge.to.replace('datastore:', ''));
|
|
660
|
+
return formatList([...new Set(stores)].sort((a, b) => a.localeCompare(b)));
|
|
661
|
+
}
|
|
662
|
+
function statusForService(serviceId, context) {
|
|
663
|
+
const repo = context.repoMap.get(serviceId);
|
|
664
|
+
if (!repo) {
|
|
665
|
+
return 'Unknown';
|
|
666
|
+
}
|
|
667
|
+
if (repo.archived) {
|
|
668
|
+
return 'Archived';
|
|
669
|
+
}
|
|
670
|
+
return 'Active';
|
|
671
|
+
}
|
|
672
|
+
function domainForRepo(repoName, config) {
|
|
673
|
+
const groups = config.domainGroups ?? [];
|
|
674
|
+
for (const group of groups) {
|
|
675
|
+
if (group.match.some((pattern) => repoName.toLowerCase().includes(pattern.toLowerCase()))) {
|
|
676
|
+
return group.name;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return 'Ungrouped';
|
|
680
|
+
}
|
|
681
|
+
function serviceCatalog(context) {
|
|
682
|
+
const serviceIds = context.selectedRepos
|
|
683
|
+
.filter((repo) => context.serviceMap.nodes.some((node) => node.id === `service:${repo}`))
|
|
684
|
+
.sort((a, b) => a.localeCompare(b));
|
|
685
|
+
return serviceIds.map((serviceId) => {
|
|
686
|
+
const repo = context.repoMap.get(serviceId);
|
|
687
|
+
return {
|
|
688
|
+
serviceName: serviceId,
|
|
689
|
+
repository: repo?.fullName ?? serviceId,
|
|
690
|
+
ownerTeam: ownerForService(serviceId, context),
|
|
691
|
+
runtime: repo ? inferRuntimeFramework(repo) : 'Unknown',
|
|
692
|
+
apiEventSurface: apiSurfaceForService(serviceId, context),
|
|
693
|
+
dependencies: dependenciesForService(serviceId, context),
|
|
694
|
+
dataStores: datastoresForService(serviceId, context),
|
|
695
|
+
deployTarget: repo ? inferDeployTarget(repo) : 'Unknown',
|
|
696
|
+
tier: criticalityForService(serviceId, context),
|
|
697
|
+
status: statusForService(serviceId, context),
|
|
698
|
+
};
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
function resolveSectionSources(section, sources) {
|
|
702
|
+
const ids = new Set(section.sourceIds);
|
|
703
|
+
return sources
|
|
704
|
+
.filter((source) => ids.has(source.id))
|
|
705
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
706
|
+
}
|
|
707
|
+
function renderSourceBlock(sourceRefs) {
|
|
708
|
+
const lines = ['### Sources', ''];
|
|
709
|
+
if (sourceRefs.length === 0) {
|
|
710
|
+
lines.push('- Unknown');
|
|
711
|
+
lines.push('');
|
|
712
|
+
return lines;
|
|
713
|
+
}
|
|
714
|
+
for (const source of sourceRefs) {
|
|
715
|
+
const generated = source.generatedAt ?? 'Unknown';
|
|
716
|
+
const freshness = source.stale ? 'stale' : 'fresh';
|
|
717
|
+
const suffix = source.note ? ` (${source.note})` : '';
|
|
718
|
+
lines.push(`- ${source.label}: \`${source.path}\` (generated: ${generated}, ${freshness})${suffix}`);
|
|
719
|
+
}
|
|
720
|
+
lines.push('');
|
|
721
|
+
return lines;
|
|
722
|
+
}
|
|
723
|
+
function renderStaleWarning(sourceRefs) {
|
|
724
|
+
const stale = sourceRefs.filter((source) => source.required && (source.stale || !source.exists));
|
|
725
|
+
if (stale.length === 0) {
|
|
726
|
+
return [];
|
|
727
|
+
}
|
|
728
|
+
const lines = ['> [!WARNING]', '> Stale or missing source data detected for this section:', ...stale.map((source) => `> - ${source.label}`), ''];
|
|
729
|
+
return lines;
|
|
730
|
+
}
|
|
731
|
+
function defaultManualBlockText(sectionId) {
|
|
732
|
+
return `\nAdd team-specific notes for \`${sectionId}\` here.\n`;
|
|
733
|
+
}
|
|
734
|
+
function extractManualBlocks(existingContent) {
|
|
735
|
+
const out = new Map();
|
|
736
|
+
for (const sectionId of exports.README_SECTION_ORDER) {
|
|
737
|
+
const escaped = sectionId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
738
|
+
const regex = new RegExp(`<!-- SDX:SECTION:${escaped}:MANUAL:START -->([\\s\\S]*?)<!-- SDX:SECTION:${escaped}:MANUAL:END -->`, 'm');
|
|
739
|
+
const match = existingContent.match(regex);
|
|
740
|
+
if (match) {
|
|
741
|
+
out.set(sectionId, match[1]);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return out;
|
|
745
|
+
}
|
|
746
|
+
function renderSection(section, sources, manualContent) {
|
|
747
|
+
const lines = [];
|
|
748
|
+
lines.push(`<!-- SDX:SECTION:${section.id}:START -->`);
|
|
749
|
+
lines.push(`## ${section.title}`);
|
|
750
|
+
lines.push('');
|
|
751
|
+
lines.push(...section.body);
|
|
752
|
+
lines.push('');
|
|
753
|
+
const sourceRefs = resolveSectionSources(section, sources);
|
|
754
|
+
lines.push(...renderStaleWarning(sourceRefs));
|
|
755
|
+
lines.push(...renderSourceBlock(sourceRefs));
|
|
756
|
+
const manualBody = manualContent ?? defaultManualBlockText(section.id);
|
|
757
|
+
lines.push(`<!-- SDX:SECTION:${section.id}:MANUAL:START -->${manualBody}<!-- SDX:SECTION:${section.id}:MANUAL:END -->`);
|
|
758
|
+
lines.push(`<!-- SDX:SECTION:${section.id}:END -->`);
|
|
759
|
+
lines.push('');
|
|
760
|
+
return lines.join('\n');
|
|
761
|
+
}
|
|
762
|
+
function splitLines(input) {
|
|
763
|
+
const normalized = input.replace(/\r\n/g, '\n');
|
|
764
|
+
if (normalized.length === 0) {
|
|
765
|
+
return [];
|
|
766
|
+
}
|
|
767
|
+
const lines = normalized.split('\n');
|
|
768
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') {
|
|
769
|
+
lines.pop();
|
|
770
|
+
}
|
|
771
|
+
return lines;
|
|
772
|
+
}
|
|
773
|
+
function diffLines(oldLines, newLines) {
|
|
774
|
+
const n = oldLines.length;
|
|
775
|
+
const m = newLines.length;
|
|
776
|
+
const dp = Array.from({ length: n + 1 }, () => Array(m + 1).fill(0));
|
|
777
|
+
for (let i = n - 1; i >= 0; i -= 1) {
|
|
778
|
+
for (let j = m - 1; j >= 0; j -= 1) {
|
|
779
|
+
if (oldLines[i] === newLines[j]) {
|
|
780
|
+
dp[i][j] = dp[i + 1][j + 1] + 1;
|
|
781
|
+
}
|
|
782
|
+
else {
|
|
783
|
+
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const ops = [];
|
|
788
|
+
let i = 0;
|
|
789
|
+
let j = 0;
|
|
790
|
+
while (i < n && j < m) {
|
|
791
|
+
if (oldLines[i] === newLines[j]) {
|
|
792
|
+
ops.push({ type: 'equal', line: oldLines[i] });
|
|
793
|
+
i += 1;
|
|
794
|
+
j += 1;
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
798
|
+
ops.push({ type: 'remove', line: oldLines[i] });
|
|
799
|
+
i += 1;
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
ops.push({ type: 'add', line: newLines[j] });
|
|
803
|
+
j += 1;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
while (i < n) {
|
|
807
|
+
ops.push({ type: 'remove', line: oldLines[i] });
|
|
808
|
+
i += 1;
|
|
809
|
+
}
|
|
810
|
+
while (j < m) {
|
|
811
|
+
ops.push({ type: 'add', line: newLines[j] });
|
|
812
|
+
j += 1;
|
|
813
|
+
}
|
|
814
|
+
return ops;
|
|
815
|
+
}
|
|
816
|
+
function unifiedDiff(oldText, newText, oldLabel, newLabel) {
|
|
817
|
+
if (oldText === newText) {
|
|
818
|
+
return '';
|
|
819
|
+
}
|
|
820
|
+
const oldLines = splitLines(oldText);
|
|
821
|
+
const newLines = splitLines(newText);
|
|
822
|
+
const ops = diffLines(oldLines, newLines);
|
|
823
|
+
const context = 3;
|
|
824
|
+
const hunks = [];
|
|
825
|
+
let current;
|
|
826
|
+
for (let index = 0; index < ops.length; index += 1) {
|
|
827
|
+
if (ops[index].type === 'equal') {
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
const hunkStart = Math.max(0, index - context);
|
|
831
|
+
const hunkEnd = Math.min(ops.length, index + context + 1);
|
|
832
|
+
if (!current) {
|
|
833
|
+
current = { start: hunkStart, end: hunkEnd };
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
if (hunkStart <= current.end) {
|
|
837
|
+
current.end = Math.max(current.end, hunkEnd);
|
|
838
|
+
}
|
|
839
|
+
else {
|
|
840
|
+
hunks.push(current);
|
|
841
|
+
current = { start: hunkStart, end: hunkEnd };
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (current) {
|
|
845
|
+
hunks.push(current);
|
|
846
|
+
}
|
|
847
|
+
const oldPrefix = [0];
|
|
848
|
+
const newPrefix = [0];
|
|
849
|
+
for (const op of ops) {
|
|
850
|
+
const oldCount = oldPrefix[oldPrefix.length - 1] + (op.type === 'add' ? 0 : 1);
|
|
851
|
+
const newCount = newPrefix[newPrefix.length - 1] + (op.type === 'remove' ? 0 : 1);
|
|
852
|
+
oldPrefix.push(oldCount);
|
|
853
|
+
newPrefix.push(newCount);
|
|
854
|
+
}
|
|
855
|
+
const lines = [`--- ${oldLabel}`, `+++ ${newLabel}`];
|
|
856
|
+
for (const hunk of hunks) {
|
|
857
|
+
const slice = ops.slice(hunk.start, hunk.end);
|
|
858
|
+
const oldStart = oldPrefix[hunk.start] + 1;
|
|
859
|
+
const newStart = newPrefix[hunk.start] + 1;
|
|
860
|
+
const oldLen = slice.filter((entry) => entry.type !== 'add').length;
|
|
861
|
+
const newLen = slice.filter((entry) => entry.type !== 'remove').length;
|
|
862
|
+
lines.push(`@@ -${oldStart},${oldLen} +${newStart},${newLen} @@`);
|
|
863
|
+
for (const op of slice) {
|
|
864
|
+
if (op.type === 'equal') {
|
|
865
|
+
lines.push(` ${op.line}`);
|
|
866
|
+
}
|
|
867
|
+
else if (op.type === 'remove') {
|
|
868
|
+
lines.push(`-${op.line}`);
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
lines.push(`+${op.line}`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return `${lines.join('\n')}\n`;
|
|
876
|
+
}
|
|
877
|
+
function buildReadmeContext(mapId, db, cwd, outputPath, config) {
|
|
878
|
+
const now = new Date();
|
|
879
|
+
const threshold = config.staleThresholdHours ?? 72;
|
|
880
|
+
const scope = (0, scope_1.loadScopeManifest)(mapId, cwd);
|
|
881
|
+
const repos = (0, repoRegistry_1.listAllRepos)(db);
|
|
882
|
+
const repoMap = new Map(repos.map((repo) => [repo.name, repo]));
|
|
883
|
+
const selectedRepos = filterReposForReadme(scope, config);
|
|
884
|
+
const mapDir = node_path_1.default.join(cwd, 'maps', mapId);
|
|
885
|
+
const serviceMap = loadServiceMap(mapId, scope, repoMap, mapDir);
|
|
886
|
+
const contracts = loadContracts(mapId, scope, repoMap, mapDir);
|
|
887
|
+
const model = loadArchitectureModel(mapId, db, cwd);
|
|
888
|
+
const diagrams = diagramPaths(mapId, cwd);
|
|
889
|
+
const sourceRefs = [];
|
|
890
|
+
sourceRefs.push(sourceFromFile('scope', 'Map scope manifest', node_path_1.default.join(mapDir, 'scope.json'), cwd, threshold, now, true));
|
|
891
|
+
sourceRefs.push(sourceFromFile('service-map-json', 'Service map JSON', node_path_1.default.join(mapDir, 'service-map.json'), cwd, threshold, now, true));
|
|
892
|
+
sourceRefs.push(sourceFromFile('service-map-md', 'Service map Markdown', node_path_1.default.join(mapDir, 'service-map.md'), cwd, threshold, now, false));
|
|
893
|
+
sourceRefs.push(sourceFromFile('service-map-mmd', 'Service map Mermaid', node_path_1.default.join(mapDir, 'service-map.mmd'), cwd, threshold, now, false));
|
|
894
|
+
sourceRefs.push(sourceFromFile('contracts-json', 'Contracts JSON', node_path_1.default.join(mapDir, 'contracts.json'), cwd, threshold, now, true));
|
|
895
|
+
sourceRefs.push(sourceFromFile('contracts-md', 'Contracts Markdown', node_path_1.default.join(mapDir, 'contracts.md'), cwd, threshold, now, false));
|
|
896
|
+
sourceRefs.push(sourceFromFile('docs-architecture', 'Generated architecture doc', node_path_1.default.join(cwd, 'docs', 'architecture', `${mapId}.md`), cwd, threshold, now, false));
|
|
897
|
+
sourceRefs.push(sourceFromFile('docs-dependencies', 'Generated dependency summary', node_path_1.default.join(cwd, 'catalog', 'dependencies', `${mapId}.md`), cwd, threshold, now, false));
|
|
898
|
+
sourceRefs.push(sourceFromFile('architecture-model', 'Architecture model', node_path_1.default.join(cwd, 'maps', mapId, 'architecture', 'model.json'), cwd, threshold, now, false));
|
|
899
|
+
sourceRefs.push(sourceFromFile('architecture-validation', 'Architecture validation', node_path_1.default.join(cwd, 'maps', mapId, 'architecture', 'validation.json'), cwd, threshold, now, false));
|
|
900
|
+
sourceRefs.push(sourceFromRepoSync(repos, selectedRepos, threshold, now));
|
|
901
|
+
return {
|
|
902
|
+
cwd,
|
|
903
|
+
mapId,
|
|
904
|
+
scope,
|
|
905
|
+
selectedRepos,
|
|
906
|
+
repoMap,
|
|
907
|
+
serviceMap,
|
|
908
|
+
contracts,
|
|
909
|
+
architectureModel: model,
|
|
910
|
+
diagrams,
|
|
911
|
+
sources: sourceRefs,
|
|
912
|
+
staleThresholdHours: threshold,
|
|
913
|
+
now,
|
|
914
|
+
outputPath,
|
|
915
|
+
config,
|
|
916
|
+
sourceSnapshotAt: computeSnapshotTimestamp(sourceRefs, now),
|
|
917
|
+
coreRequestPath: findCoreRequestPath(model, serviceMap),
|
|
918
|
+
sourceRepoSyncAt: sourceRefs.find((source) => source.id === 'repo-sync')?.generatedAt,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
function buildSections(context) {
|
|
922
|
+
const outputPath = context.outputPath;
|
|
923
|
+
const includeC4Links = context.config.diagram?.includeC4Links ?? true;
|
|
924
|
+
const links = {
|
|
925
|
+
systemContext: toLinkPath(context.diagrams.systemContext, outputPath),
|
|
926
|
+
serviceDependency: toLinkPath(context.diagrams.serviceDependency, outputPath),
|
|
927
|
+
sequence: toLinkPath(context.diagrams.sequence, outputPath),
|
|
928
|
+
optionalSystemLandscape: toLinkPath(context.diagrams.optionalSystemLandscape, outputPath),
|
|
929
|
+
optionalContainer: toLinkPath(context.diagrams.optionalContainer, outputPath),
|
|
930
|
+
optionalArchitectureIndex: toLinkPath(context.diagrams.optionalArchitectureIndex, outputPath),
|
|
931
|
+
};
|
|
932
|
+
const catalogRows = serviceCatalog(context);
|
|
933
|
+
const asyncContracts = context.contracts.filter((record) => record.type === 'asyncapi');
|
|
934
|
+
const repoRows = context.selectedRepos.map((repoName) => {
|
|
935
|
+
const repo = context.repoMap.get(repoName);
|
|
936
|
+
return {
|
|
937
|
+
name: repoName,
|
|
938
|
+
fullName: repo?.fullName ?? repoName,
|
|
939
|
+
owner: ownerForService(repoName, context),
|
|
940
|
+
source: repo?.source ?? 'Unknown',
|
|
941
|
+
branch: repo?.defaultBranch ?? 'Unknown',
|
|
942
|
+
localPath: repo?.localPath ?? 'Unknown',
|
|
943
|
+
domain: domainForRepo(repoName, context.config),
|
|
944
|
+
};
|
|
945
|
+
});
|
|
946
|
+
const datastoreNodes = context.architectureModel.nodes.filter((node) => node.type === 'datastore');
|
|
947
|
+
const adrDir = node_path_1.default.join(context.cwd, 'docs', 'adr');
|
|
948
|
+
const adrFiles = (0, fs_1.fileExists)(adrDir)
|
|
949
|
+
? node_fs_1.default
|
|
950
|
+
.readdirSync(adrDir, { withFileTypes: true })
|
|
951
|
+
.filter((entry) => entry.isFile() && /\.md$/i.test(entry.name))
|
|
952
|
+
.map((entry) => entry.name)
|
|
953
|
+
.sort((a, b) => a.localeCompare(b))
|
|
954
|
+
: [];
|
|
955
|
+
const coreFlowLines = context.coreRequestPath.length > 0
|
|
956
|
+
? context.coreRequestPath.map((edge) => {
|
|
957
|
+
const from = edge.from.replace('service:', '');
|
|
958
|
+
const to = edge.to.replace('service:', '');
|
|
959
|
+
return `- ${from} -> ${to} (confidence ${edge.provenance.confidence.toFixed(2)})`;
|
|
960
|
+
})
|
|
961
|
+
: ['- Unknown'];
|
|
962
|
+
const sectionById = {
|
|
963
|
+
what_is_this_system: {
|
|
964
|
+
id: 'what_is_this_system',
|
|
965
|
+
title: SECTION_TITLES['what_is_this_system'],
|
|
966
|
+
body: [
|
|
967
|
+
context.config.customIntro ??
|
|
968
|
+
'This README is generated by SDX as the canonical architecture onboarding guide for this org workspace.',
|
|
969
|
+
'',
|
|
970
|
+
`- Organization: \`${context.scope.org}\``,
|
|
971
|
+
`- Map: \`${context.mapId}\``,
|
|
972
|
+
`- Repositories selected for this README: ${context.selectedRepos.length}`,
|
|
973
|
+
`- Services detected: ${catalogRows.length}`,
|
|
974
|
+
],
|
|
975
|
+
sourceIds: ['scope', 'repo-sync', 'service-map-json'],
|
|
976
|
+
},
|
|
977
|
+
architecture_glance: {
|
|
978
|
+
id: 'architecture_glance',
|
|
979
|
+
title: SECTION_TITLES['architecture_glance'],
|
|
980
|
+
body: (() => {
|
|
981
|
+
const lines = [
|
|
982
|
+
`- [System context diagram](${links.systemContext})`,
|
|
983
|
+
`- [Service dependency graph](${links.serviceDependency})`,
|
|
984
|
+
`- [Core request flow sequence](${links.sequence})`,
|
|
985
|
+
(0, fs_1.fileExists)(context.diagrams.optionalArchitectureIndex)
|
|
986
|
+
? `- [Architecture pack index](${links.optionalArchitectureIndex})`
|
|
987
|
+
: '- Architecture pack index: Not available',
|
|
988
|
+
];
|
|
989
|
+
if (includeC4Links) {
|
|
990
|
+
lines.push((0, fs_1.fileExists)(context.diagrams.optionalSystemLandscape)
|
|
991
|
+
? `- [Optional C4 landscape](${links.optionalSystemLandscape})`
|
|
992
|
+
: '- Optional C4 landscape: Not available');
|
|
993
|
+
lines.push((0, fs_1.fileExists)(context.diagrams.optionalContainer)
|
|
994
|
+
? `- [Optional C4 container](${links.optionalContainer})`
|
|
995
|
+
: '- Optional C4 container: Not available');
|
|
996
|
+
}
|
|
997
|
+
return lines;
|
|
998
|
+
})(),
|
|
999
|
+
sourceIds: [
|
|
1000
|
+
'service-map-json',
|
|
1001
|
+
'architecture-model',
|
|
1002
|
+
'diagram-system-context',
|
|
1003
|
+
'diagram-service-dependency',
|
|
1004
|
+
'diagram-core-sequence',
|
|
1005
|
+
'diagram-c4-landscape',
|
|
1006
|
+
'diagram-c4-container',
|
|
1007
|
+
],
|
|
1008
|
+
},
|
|
1009
|
+
service_catalog: {
|
|
1010
|
+
id: 'service_catalog',
|
|
1011
|
+
title: SECTION_TITLES['service_catalog'],
|
|
1012
|
+
body: [
|
|
1013
|
+
'| Service name | Repository | Owner/team | Runtime/framework | API/event surface | Dependencies | Data stores | Deploy target | Tier/criticality | Status |',
|
|
1014
|
+
'|---|---|---|---|---|---|---|---|---|---|',
|
|
1015
|
+
...(catalogRows.length > 0
|
|
1016
|
+
? catalogRows.map((row) => `| ${row.serviceName} | ${row.repository} | ${row.ownerTeam} | ${row.runtime} | ${row.apiEventSurface} | ${row.dependencies} | ${row.dataStores} | ${row.deployTarget} | ${row.tier} | ${row.status} |`)
|
|
1017
|
+
: ['| Unknown | Unknown | Unknown | Unknown | Unknown | Unknown | Unknown | Unknown | Unknown | Unknown |']),
|
|
1018
|
+
],
|
|
1019
|
+
sourceIds: ['service-map-json', 'contracts-json', 'architecture-model', 'repo-sync'],
|
|
1020
|
+
},
|
|
1021
|
+
critical_flows: {
|
|
1022
|
+
id: 'critical_flows',
|
|
1023
|
+
title: SECTION_TITLES['critical_flows'],
|
|
1024
|
+
body: [
|
|
1025
|
+
`- Primary sequence diagram: [core-request-flow.mmd](${links.sequence})`,
|
|
1026
|
+
'- Highest-confidence path:',
|
|
1027
|
+
...coreFlowLines,
|
|
1028
|
+
],
|
|
1029
|
+
sourceIds: ['architecture-model', 'service-map-json', 'docs-dependencies', 'diagram-core-sequence'],
|
|
1030
|
+
},
|
|
1031
|
+
event_async_topology: {
|
|
1032
|
+
id: 'event_async_topology',
|
|
1033
|
+
title: SECTION_TITLES['event_async_topology'],
|
|
1034
|
+
body: [
|
|
1035
|
+
'| Contract | Repository | Version | Compatibility | Producers | Consumers |',
|
|
1036
|
+
'|---|---|---|---|---|---|',
|
|
1037
|
+
...(asyncContracts.length > 0
|
|
1038
|
+
? asyncContracts.map((record) => `| ${record.path} | ${record.repo} | ${record.version ?? 'Unknown'} | ${record.compatibilityStatus} | ${formatList(record.producers)} | ${formatList(record.consumers)} |`)
|
|
1039
|
+
: ['| Unknown | Unknown | Unknown | Unknown | Unknown | Unknown |']),
|
|
1040
|
+
],
|
|
1041
|
+
sourceIds: ['contracts-json', 'architecture-model'],
|
|
1042
|
+
},
|
|
1043
|
+
contracts_index: {
|
|
1044
|
+
id: 'contracts_index',
|
|
1045
|
+
title: SECTION_TITLES['contracts_index'],
|
|
1046
|
+
body: [
|
|
1047
|
+
'| Repository | Type | Path | Version | Compatibility |',
|
|
1048
|
+
'|---|---|---|---|---|',
|
|
1049
|
+
...(context.contracts.length > 0
|
|
1050
|
+
? context.contracts.map((record) => `| ${record.repo} | ${record.type} | ${record.path} | ${record.version ?? 'Unknown'} | ${record.compatibilityStatus} |`)
|
|
1051
|
+
: ['| Unknown | Unknown | Unknown | Unknown | Unknown |']),
|
|
1052
|
+
],
|
|
1053
|
+
sourceIds: ['contracts-json', 'contracts-md'],
|
|
1054
|
+
},
|
|
1055
|
+
repository_index: {
|
|
1056
|
+
id: 'repository_index',
|
|
1057
|
+
title: SECTION_TITLES['repository_index'],
|
|
1058
|
+
body: [
|
|
1059
|
+
'| Repository | Owner/team | Domain | Source | Default branch | Local path |',
|
|
1060
|
+
'|---|---|---|---|---|---|',
|
|
1061
|
+
...(repoRows.length > 0
|
|
1062
|
+
? repoRows.map((row) => `| ${row.fullName} | ${row.owner} | ${row.domain} | ${row.source} | ${row.branch} | ${row.localPath.replace(/\|/g, '\\|')} |`)
|
|
1063
|
+
: ['| Unknown | Unknown | Unknown | Unknown | Unknown | Unknown |']),
|
|
1064
|
+
],
|
|
1065
|
+
sourceIds: ['scope', 'repo-sync'],
|
|
1066
|
+
},
|
|
1067
|
+
environments_deployment: {
|
|
1068
|
+
id: 'environments_deployment',
|
|
1069
|
+
title: SECTION_TITLES['environments_deployment'],
|
|
1070
|
+
body: [
|
|
1071
|
+
'| Service | Deploy target | Runtime/framework | Environment notes |',
|
|
1072
|
+
'|---|---|---|---|',
|
|
1073
|
+
...(catalogRows.length > 0
|
|
1074
|
+
? catalogRows.map((row) => `| ${row.serviceName} | ${row.deployTarget} | ${row.runtime} | ${row.deployTarget === 'Unknown' ? 'Unknown' : 'Validate env parity in deployment pipeline'} |`)
|
|
1075
|
+
: ['| Unknown | Unknown | Unknown | Unknown |']),
|
|
1076
|
+
],
|
|
1077
|
+
sourceIds: ['service-map-json', 'repo-sync', 'architecture-model'],
|
|
1078
|
+
},
|
|
1079
|
+
data_stores_boundaries: {
|
|
1080
|
+
id: 'data_stores_boundaries',
|
|
1081
|
+
title: SECTION_TITLES['data_stores_boundaries'],
|
|
1082
|
+
body: [
|
|
1083
|
+
'| Data store | Depending services | Boundary notes |',
|
|
1084
|
+
'|---|---|---|',
|
|
1085
|
+
...(datastoreNodes.length > 0
|
|
1086
|
+
? datastoreNodes
|
|
1087
|
+
.sort((a, b) => a.label.localeCompare(b.label))
|
|
1088
|
+
.map((node) => {
|
|
1089
|
+
const dependers = context.architectureModel.edges
|
|
1090
|
+
.filter((edge) => edge.to === node.id && edge.from.startsWith('service:'))
|
|
1091
|
+
.map((edge) => edge.from.replace('service:', ''))
|
|
1092
|
+
.sort((a, b) => a.localeCompare(b));
|
|
1093
|
+
return `| ${node.label} | ${formatList([...new Set(dependers)])} | ${String(node.metadata?.['boundary'] ?? 'Unknown')} |`;
|
|
1094
|
+
})
|
|
1095
|
+
: ['| Unknown | Unknown | Unknown |']),
|
|
1096
|
+
],
|
|
1097
|
+
sourceIds: ['architecture-model'],
|
|
1098
|
+
},
|
|
1099
|
+
security_compliance: {
|
|
1100
|
+
id: 'security_compliance',
|
|
1101
|
+
title: SECTION_TITLES['security_compliance'],
|
|
1102
|
+
body: [
|
|
1103
|
+
'- Authentication/authorization model: Unknown',
|
|
1104
|
+
'- Data classification posture: Unknown',
|
|
1105
|
+
'- Compliance scope (SOC2/PCI/HIPAA/etc.): Unknown',
|
|
1106
|
+
'- Secret management baseline: Unknown',
|
|
1107
|
+
'- Required action: populate this section via manual block with org security standards.',
|
|
1108
|
+
],
|
|
1109
|
+
sourceIds: ['architecture-model', 'contracts-json'],
|
|
1110
|
+
},
|
|
1111
|
+
local_dev_contribution: {
|
|
1112
|
+
id: 'local_dev_contribution',
|
|
1113
|
+
title: SECTION_TITLES['local_dev_contribution'],
|
|
1114
|
+
body: [
|
|
1115
|
+
'```bash',
|
|
1116
|
+
'./scripts/sdx status',
|
|
1117
|
+
`./scripts/sdx map build ${context.mapId}`,
|
|
1118
|
+
`./scripts/sdx contracts extract --map ${context.mapId}`,
|
|
1119
|
+
`./scripts/sdx docs generate --map ${context.mapId}`,
|
|
1120
|
+
`./scripts/sdx docs readme --map ${context.mapId}`,
|
|
1121
|
+
'```',
|
|
1122
|
+
'',
|
|
1123
|
+
'- Use `--check` in CI to enforce freshness and deterministic output.',
|
|
1124
|
+
],
|
|
1125
|
+
sourceIds: ['scope', 'service-map-json', 'contracts-json'],
|
|
1126
|
+
},
|
|
1127
|
+
runbooks_escalation: {
|
|
1128
|
+
id: 'runbooks_escalation',
|
|
1129
|
+
title: SECTION_TITLES['runbooks_escalation'],
|
|
1130
|
+
body: [
|
|
1131
|
+
'- Runbook root: `docs/runbooks/` (Unknown if not present)',
|
|
1132
|
+
'- Escalation path: Unknown',
|
|
1133
|
+
'- Incident channel: Unknown',
|
|
1134
|
+
'- Required action: populate escalation ownership in manual block.',
|
|
1135
|
+
],
|
|
1136
|
+
sourceIds: ['architecture-model', 'repo-sync'],
|
|
1137
|
+
},
|
|
1138
|
+
adr_index: {
|
|
1139
|
+
id: 'adr_index',
|
|
1140
|
+
title: SECTION_TITLES['adr_index'],
|
|
1141
|
+
body: [
|
|
1142
|
+
...(adrFiles.length > 0
|
|
1143
|
+
? adrFiles.map((fileName) => `- [${fileName}](./docs/adr/${fileName})`)
|
|
1144
|
+
: ['- Unknown (no ADR markdown files found under `docs/adr/`)']),
|
|
1145
|
+
],
|
|
1146
|
+
sourceIds: ['docs-architecture'],
|
|
1147
|
+
},
|
|
1148
|
+
glossary: {
|
|
1149
|
+
id: 'glossary',
|
|
1150
|
+
title: SECTION_TITLES['glossary'],
|
|
1151
|
+
body: [
|
|
1152
|
+
'- **Service**: A deployable unit represented by a repository in the selected map scope.',
|
|
1153
|
+
'- **Contract**: API/event interface artifact (OpenAPI, GraphQL, Protobuf, AsyncAPI).',
|
|
1154
|
+
'- **Map**: A named SDX scope manifest that defines discovered/included/excluded repos.',
|
|
1155
|
+
'- **Override**: Manual architecture hints in `maps/<map-id>/architecture-overrides.json`.',
|
|
1156
|
+
'- **Unknown**: Field not currently derivable from SDX artifacts; requires manual completion.',
|
|
1157
|
+
],
|
|
1158
|
+
sourceIds: ['scope', 'service-map-json', 'contracts-json', 'architecture-model'],
|
|
1159
|
+
},
|
|
1160
|
+
changelog_metadata: {
|
|
1161
|
+
id: 'changelog_metadata',
|
|
1162
|
+
title: SECTION_TITLES['changelog_metadata'],
|
|
1163
|
+
body: [
|
|
1164
|
+
`- Generated timestamp: ${context.sourceSnapshotAt}`,
|
|
1165
|
+
`- Map id: ${context.mapId}`,
|
|
1166
|
+
`- Schema version: ${constants_1.SCHEMA_VERSION}`,
|
|
1167
|
+
`- CLI version: ${(0, version_1.getCliPackageVersion)()}`,
|
|
1168
|
+
`- Freshness threshold (hours): ${context.staleThresholdHours}`,
|
|
1169
|
+
`- Repo sync baseline: ${context.sourceRepoSyncAt ?? 'Unknown'}`,
|
|
1170
|
+
'- Source refs used:',
|
|
1171
|
+
...context.sources.map((source) => ` - ${source.label}: \`${source.path}\``),
|
|
1172
|
+
],
|
|
1173
|
+
sourceIds: context.sources.map((source) => source.id),
|
|
1174
|
+
},
|
|
1175
|
+
};
|
|
1176
|
+
return exports.README_SECTION_ORDER.map((sectionId) => sectionById[sectionId]);
|
|
1177
|
+
}
|
|
1178
|
+
function renderReadme(sections, context, existingContent) {
|
|
1179
|
+
const manualBlocks = extractManualBlocks(existingContent);
|
|
1180
|
+
const lines = [
|
|
1181
|
+
'# SDX Organization Architecture Workspace',
|
|
1182
|
+
'',
|
|
1183
|
+
`> Generated for org \`${context.scope.org}\` using map \`${context.mapId}\`.`,
|
|
1184
|
+
'',
|
|
1185
|
+
`> Source snapshot timestamp: ${context.sourceSnapshotAt}`,
|
|
1186
|
+
'',
|
|
1187
|
+
];
|
|
1188
|
+
for (const section of sections) {
|
|
1189
|
+
const manual = manualBlocks.get(section.id);
|
|
1190
|
+
lines.push(renderSection(section, context.sources, manual));
|
|
1191
|
+
}
|
|
1192
|
+
return {
|
|
1193
|
+
content: `${lines.join('\n').trimEnd()}\n`,
|
|
1194
|
+
sourceRefs: context.sources,
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
function checkFailures(existingContent, renderedContent, sources) {
|
|
1198
|
+
const stale = sources.filter((source) => source.required && source.stale);
|
|
1199
|
+
const missing = sources.filter((source) => source.required && !source.exists);
|
|
1200
|
+
const changed = existingContent !== renderedContent;
|
|
1201
|
+
return { stale, missing, changed };
|
|
1202
|
+
}
|
|
1203
|
+
function summarizeResult(outputPath, staleSources, missingSources, changed, checkMode) {
|
|
1204
|
+
const lines = [`README output: ${outputPath}`];
|
|
1205
|
+
lines.push(`Content changed: ${changed ? 'yes' : 'no'}`);
|
|
1206
|
+
lines.push(`Stale sources: ${staleSources.length}`);
|
|
1207
|
+
lines.push(`Missing required sources: ${missingSources.length}`);
|
|
1208
|
+
if (staleSources.length > 0) {
|
|
1209
|
+
lines.push(`Stale source labels: ${staleSources.map((source) => source.label).join(', ')}`);
|
|
1210
|
+
}
|
|
1211
|
+
if (missingSources.length > 0) {
|
|
1212
|
+
lines.push(`Missing source labels: ${missingSources.map((source) => source.label).join(', ')}`);
|
|
1213
|
+
}
|
|
1214
|
+
if (checkMode) {
|
|
1215
|
+
const failed = staleSources.length > 0 || missingSources.length > 0 || changed;
|
|
1216
|
+
lines.push(`Check result: ${failed ? 'FAIL' : 'PASS'}`);
|
|
1217
|
+
}
|
|
1218
|
+
return lines.join('\n');
|
|
1219
|
+
}
|
|
1220
|
+
function generateReadme(options) {
|
|
1221
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1222
|
+
const outputPath = node_path_1.default.resolve(cwd, options.output ?? 'README.md');
|
|
1223
|
+
const includeSections = options.includeSections ?? [];
|
|
1224
|
+
const excludeSections = options.excludeSections ?? [];
|
|
1225
|
+
if (options.check && options.dryRun) {
|
|
1226
|
+
throw new Error('Use either --check or --dry-run, not both.');
|
|
1227
|
+
}
|
|
1228
|
+
const { config, sourcePath } = loadReadmeConfig(cwd);
|
|
1229
|
+
const selectedSections = selectSections(config, includeSections, excludeSections);
|
|
1230
|
+
const context = buildReadmeContext(options.mapId, options.db, cwd, outputPath, config);
|
|
1231
|
+
if (sourcePath) {
|
|
1232
|
+
context.sources.push(sourceFromFile('readme-config', 'README config', sourcePath, cwd, context.staleThresholdHours, context.now, false));
|
|
1233
|
+
}
|
|
1234
|
+
const writeEnabled = !options.check && !options.dryRun;
|
|
1235
|
+
const diagramResult = ensureRequiredDiagrams(context, options.db, cwd, writeEnabled);
|
|
1236
|
+
context.sources.push(...diagramResult.diagramSources);
|
|
1237
|
+
upsertSourceRef(context.sources, sourceFromFile('docs-architecture', 'Generated architecture doc', node_path_1.default.join(cwd, 'docs', 'architecture', `${options.mapId}.md`), cwd, context.staleThresholdHours, context.now, false));
|
|
1238
|
+
upsertSourceRef(context.sources, sourceFromFile('architecture-model', 'Architecture model', node_path_1.default.join(cwd, 'maps', options.mapId, 'architecture', 'model.json'), cwd, context.staleThresholdHours, context.now, false));
|
|
1239
|
+
upsertSourceRef(context.sources, sourceFromFile('architecture-validation', 'Architecture validation', node_path_1.default.join(cwd, 'maps', options.mapId, 'architecture', 'validation.json'), cwd, context.staleThresholdHours, context.now, false));
|
|
1240
|
+
context.sourceSnapshotAt = computeSnapshotTimestamp(context.sources, context.now);
|
|
1241
|
+
const orderedSections = buildSections(context).filter((section) => selectedSections.includes(section.id));
|
|
1242
|
+
const existingContent = (0, fs_1.safeReadText)(outputPath);
|
|
1243
|
+
const rendered = renderReadme(orderedSections, context, existingContent);
|
|
1244
|
+
const { stale, missing, changed } = checkFailures(existingContent, rendered.content, rendered.sourceRefs);
|
|
1245
|
+
const shouldWrite = writeEnabled && changed;
|
|
1246
|
+
if (shouldWrite) {
|
|
1247
|
+
(0, fs_1.writeTextFile)(outputPath, rendered.content);
|
|
1248
|
+
}
|
|
1249
|
+
const diff = options.dryRun || (options.check && changed)
|
|
1250
|
+
? unifiedDiff(existingContent, rendered.content, `${asRelative(outputPath, cwd)}.current`, `${asRelative(outputPath, cwd)}.next`)
|
|
1251
|
+
: undefined;
|
|
1252
|
+
const checkPassed = !options.check || (stale.length === 0 && missing.length === 0 && !changed);
|
|
1253
|
+
return {
|
|
1254
|
+
outputPath,
|
|
1255
|
+
sections: selectedSections,
|
|
1256
|
+
stale: stale.length > 0,
|
|
1257
|
+
staleSources: stale,
|
|
1258
|
+
missingSources: missing,
|
|
1259
|
+
changed,
|
|
1260
|
+
wroteFile: shouldWrite,
|
|
1261
|
+
checkPassed,
|
|
1262
|
+
summary: summarizeResult(outputPath, stale, missing, changed, Boolean(options.check)),
|
|
1263
|
+
diff,
|
|
1264
|
+
};
|
|
1265
|
+
}
|