sdx-cli 0.3.0 → 0.3.2
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 +75 -2
- package/dist/commands/docs/readme.js +62 -0
- package/dist/lib/github.js +83 -0
- package/dist/lib/readme.js +1651 -0
- package/dist/lib/repoRegistry.js +8 -3
- package/package.json +1 -1
|
@@ -0,0 +1,1651 @@
|
|
|
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 config_1 = require("./config");
|
|
15
|
+
const constants_1 = require("./constants");
|
|
16
|
+
const contracts_1 = require("./contracts");
|
|
17
|
+
const fileScan_1 = require("./fileScan");
|
|
18
|
+
const fs_1 = require("./fs");
|
|
19
|
+
const github_1 = require("./github");
|
|
20
|
+
const mapBuilder_1 = require("./mapBuilder");
|
|
21
|
+
const repoRegistry_1 = require("./repoRegistry");
|
|
22
|
+
const scope_1 = require("./scope");
|
|
23
|
+
const version_1 = require("./version");
|
|
24
|
+
exports.README_SECTION_ORDER = [
|
|
25
|
+
'what_is_this_system',
|
|
26
|
+
'architecture_glance',
|
|
27
|
+
'service_catalog',
|
|
28
|
+
'critical_flows',
|
|
29
|
+
'event_async_topology',
|
|
30
|
+
'contracts_index',
|
|
31
|
+
'repository_index',
|
|
32
|
+
'environments_deployment',
|
|
33
|
+
'data_stores_boundaries',
|
|
34
|
+
'security_compliance',
|
|
35
|
+
'local_dev_contribution',
|
|
36
|
+
'runbooks_escalation',
|
|
37
|
+
'adr_index',
|
|
38
|
+
'glossary',
|
|
39
|
+
'changelog_metadata',
|
|
40
|
+
];
|
|
41
|
+
const SECTION_SET = new Set(exports.README_SECTION_ORDER);
|
|
42
|
+
const SECTION_TITLES = {
|
|
43
|
+
what_is_this_system: 'What this org/system is',
|
|
44
|
+
architecture_glance: 'Architecture at a glance',
|
|
45
|
+
service_catalog: 'Service catalog table',
|
|
46
|
+
critical_flows: 'Critical request/data flows',
|
|
47
|
+
event_async_topology: 'Event and async topology',
|
|
48
|
+
contracts_index: 'Contracts and interface index',
|
|
49
|
+
repository_index: 'Repository index with ownership',
|
|
50
|
+
environments_deployment: 'Environments and deployment topology',
|
|
51
|
+
data_stores_boundaries: 'Data stores and boundaries',
|
|
52
|
+
security_compliance: 'Security/compliance considerations',
|
|
53
|
+
local_dev_contribution: 'Local development and contribution workflow',
|
|
54
|
+
runbooks_escalation: 'Operational runbooks and escalation paths',
|
|
55
|
+
adr_index: 'ADR and design decision index',
|
|
56
|
+
glossary: 'Glossary',
|
|
57
|
+
changelog_metadata: 'Change log / last generated metadata',
|
|
58
|
+
};
|
|
59
|
+
const REQUIRED_DIAGRAM_NAMES = ['system-context.mmd', 'service-dependency.mmd', 'core-request-flow.mmd'];
|
|
60
|
+
const README_CONFIG_SCHEMA = zod_1.z.object({
|
|
61
|
+
sections: zod_1.z
|
|
62
|
+
.object({
|
|
63
|
+
include: zod_1.z.array(zod_1.z.string()).optional(),
|
|
64
|
+
exclude: zod_1.z.array(zod_1.z.string()).optional(),
|
|
65
|
+
enabled: zod_1.z.record(zod_1.z.string(), zod_1.z.boolean()).optional(),
|
|
66
|
+
})
|
|
67
|
+
.optional(),
|
|
68
|
+
repos: zod_1.z
|
|
69
|
+
.object({
|
|
70
|
+
include: zod_1.z.array(zod_1.z.string()).optional(),
|
|
71
|
+
exclude: zod_1.z.array(zod_1.z.string()).optional(),
|
|
72
|
+
})
|
|
73
|
+
.optional(),
|
|
74
|
+
domainGroups: zod_1.z
|
|
75
|
+
.array(zod_1.z.object({
|
|
76
|
+
name: zod_1.z.string(),
|
|
77
|
+
match: zod_1.z.array(zod_1.z.string()).default([]),
|
|
78
|
+
}))
|
|
79
|
+
.optional(),
|
|
80
|
+
ownerTeamOverrides: zod_1.z.record(zod_1.z.string(), zod_1.z.string()).optional(),
|
|
81
|
+
diagram: zod_1.z
|
|
82
|
+
.object({
|
|
83
|
+
autoGenerateMissing: zod_1.z.boolean().optional(),
|
|
84
|
+
includeC4Links: zod_1.z.boolean().optional(),
|
|
85
|
+
})
|
|
86
|
+
.optional(),
|
|
87
|
+
customIntro: zod_1.z.string().optional(),
|
|
88
|
+
staleThresholdHours: zod_1.z.number().positive().optional(),
|
|
89
|
+
});
|
|
90
|
+
const EMPTY_REPO_INSIGHTS = {
|
|
91
|
+
summary: 'Unknown',
|
|
92
|
+
responsibilities: [],
|
|
93
|
+
interfaces: [],
|
|
94
|
+
asyncPatterns: [],
|
|
95
|
+
deployment: [],
|
|
96
|
+
runbooks: [],
|
|
97
|
+
localDevelopment: [],
|
|
98
|
+
security: [],
|
|
99
|
+
adrs: [],
|
|
100
|
+
dataStores: [],
|
|
101
|
+
glossary: [],
|
|
102
|
+
docReferences: [],
|
|
103
|
+
};
|
|
104
|
+
function normalizeRepoName(value) {
|
|
105
|
+
return value.trim().replace(/^https?:\/\/github\.com\//i, '').replace(/\.git$/i, '').split('/').pop() ?? value.trim();
|
|
106
|
+
}
|
|
107
|
+
function asRelative(filePath, cwd) {
|
|
108
|
+
const relative = node_path_1.default.relative(cwd, filePath);
|
|
109
|
+
return relative.length === 0 ? '.' : relative.split(node_path_1.default.sep).join('/');
|
|
110
|
+
}
|
|
111
|
+
function toLinkPath(targetPath, outputPath) {
|
|
112
|
+
const relative = node_path_1.default.relative(node_path_1.default.dirname(outputPath), targetPath).split(node_path_1.default.sep).join('/');
|
|
113
|
+
if (relative.startsWith('.')) {
|
|
114
|
+
return relative;
|
|
115
|
+
}
|
|
116
|
+
return `./${relative}`;
|
|
117
|
+
}
|
|
118
|
+
function safeTimestamp(value) {
|
|
119
|
+
if (!value) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
const parsed = new Date(value);
|
|
123
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
return parsed;
|
|
127
|
+
}
|
|
128
|
+
function isOlderThan(value, thresholdHours, now) {
|
|
129
|
+
if (!value) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
const elapsedMs = now.getTime() - value.getTime();
|
|
133
|
+
return elapsedMs > thresholdHours * 60 * 60 * 1000;
|
|
134
|
+
}
|
|
135
|
+
function loadReadmeConfig(cwd) {
|
|
136
|
+
const candidates = [
|
|
137
|
+
node_path_1.default.join(cwd, '.sdx', 'readme.config.json'),
|
|
138
|
+
node_path_1.default.join(cwd, '.sdx', 'readme.config.yaml'),
|
|
139
|
+
node_path_1.default.join(cwd, '.sdx', 'readme.config.yml'),
|
|
140
|
+
];
|
|
141
|
+
for (const filePath of candidates) {
|
|
142
|
+
if (!(0, fs_1.fileExists)(filePath)) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const text = (0, fs_1.safeReadText)(filePath);
|
|
146
|
+
const parsed = filePath.endsWith('.json') ? JSON.parse(text) : yaml_1.default.parse(text);
|
|
147
|
+
const config = README_CONFIG_SCHEMA.parse(parsed);
|
|
148
|
+
return {
|
|
149
|
+
config,
|
|
150
|
+
sourcePath: filePath,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { config: {} };
|
|
154
|
+
}
|
|
155
|
+
function parseReadmeSectionList(input) {
|
|
156
|
+
if (!input) {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
const tokens = input
|
|
160
|
+
.split(',')
|
|
161
|
+
.map((token) => token.trim())
|
|
162
|
+
.filter((token) => token.length > 0);
|
|
163
|
+
const invalid = tokens.filter((token) => !SECTION_SET.has(token));
|
|
164
|
+
if (invalid.length > 0) {
|
|
165
|
+
throw new Error(`Unknown section id(s): ${invalid.join(', ')}. Valid ids: ${exports.README_SECTION_ORDER.join(', ')}`);
|
|
166
|
+
}
|
|
167
|
+
return [...new Set(tokens)];
|
|
168
|
+
}
|
|
169
|
+
function selectSections(config, includeSections, excludeSections) {
|
|
170
|
+
let ordered = [...exports.README_SECTION_ORDER];
|
|
171
|
+
const configEnabled = config.sections?.enabled ?? {};
|
|
172
|
+
ordered = ordered.filter((section) => configEnabled[section] !== false);
|
|
173
|
+
const configInclude = (config.sections?.include ?? [])
|
|
174
|
+
.map((candidate) => candidate.trim())
|
|
175
|
+
.filter((candidate) => candidate.length > 0);
|
|
176
|
+
if (configInclude.length > 0) {
|
|
177
|
+
ordered = ordered.filter((section) => configInclude.includes(section));
|
|
178
|
+
}
|
|
179
|
+
const configExclude = (config.sections?.exclude ?? [])
|
|
180
|
+
.map((candidate) => candidate.trim())
|
|
181
|
+
.filter((candidate) => candidate.length > 0);
|
|
182
|
+
if (configExclude.length > 0) {
|
|
183
|
+
ordered = ordered.filter((section) => !configExclude.includes(section));
|
|
184
|
+
}
|
|
185
|
+
if (includeSections.length > 0) {
|
|
186
|
+
ordered = ordered.filter((section) => includeSections.includes(section));
|
|
187
|
+
}
|
|
188
|
+
if (excludeSections.length > 0) {
|
|
189
|
+
ordered = ordered.filter((section) => !excludeSections.includes(section));
|
|
190
|
+
}
|
|
191
|
+
if (ordered.length === 0) {
|
|
192
|
+
throw new Error('No README sections selected after include/exclude filtering.');
|
|
193
|
+
}
|
|
194
|
+
return ordered;
|
|
195
|
+
}
|
|
196
|
+
function readGeneratedAtFromJson(filePath) {
|
|
197
|
+
if (!(0, fs_1.fileExists)(filePath)) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const payload = (0, fs_1.readJsonFile)(filePath);
|
|
202
|
+
const value = payload['generatedAt'];
|
|
203
|
+
return typeof value === 'string' ? value : undefined;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function sourceFromFile(id, label, filePath, cwd, thresholdHours, now, required) {
|
|
210
|
+
const exists = (0, fs_1.fileExists)(filePath);
|
|
211
|
+
const generatedAt = exists
|
|
212
|
+
? readGeneratedAtFromJson(filePath) ?? node_fs_1.default.statSync(filePath).mtime.toISOString()
|
|
213
|
+
: undefined;
|
|
214
|
+
const stale = exists ? isOlderThan(safeTimestamp(generatedAt), thresholdHours, now) : true;
|
|
215
|
+
return {
|
|
216
|
+
id,
|
|
217
|
+
label,
|
|
218
|
+
path: asRelative(filePath, cwd),
|
|
219
|
+
exists,
|
|
220
|
+
generatedAt,
|
|
221
|
+
stale,
|
|
222
|
+
required,
|
|
223
|
+
note: exists ? undefined : 'Missing source artifact',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function detectRepoSyncTimestamp(repos, scopedRepos) {
|
|
227
|
+
const timestamps = [];
|
|
228
|
+
for (const repoName of scopedRepos) {
|
|
229
|
+
const repo = repos.find((entry) => entry.name === repoName);
|
|
230
|
+
if (!repo?.lastSyncedAt) {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
const parsed = safeTimestamp(repo.lastSyncedAt);
|
|
234
|
+
if (!parsed) {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
timestamps.push(parsed);
|
|
238
|
+
}
|
|
239
|
+
if (timestamps.length === 0) {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
const oldest = timestamps.reduce((min, candidate) => (candidate.getTime() < min.getTime() ? candidate : min), timestamps[0]);
|
|
243
|
+
return oldest.toISOString();
|
|
244
|
+
}
|
|
245
|
+
function sourceFromRepoSync(repos, scopedRepos, thresholdHours, now) {
|
|
246
|
+
const generatedAt = detectRepoSyncTimestamp(repos, scopedRepos);
|
|
247
|
+
const stale = isOlderThan(safeTimestamp(generatedAt), thresholdHours, now);
|
|
248
|
+
return {
|
|
249
|
+
id: 'repo-sync',
|
|
250
|
+
label: 'Repository registry sync state',
|
|
251
|
+
path: '.sdx/state.db#repo_registry',
|
|
252
|
+
exists: generatedAt !== undefined,
|
|
253
|
+
generatedAt,
|
|
254
|
+
stale,
|
|
255
|
+
required: true,
|
|
256
|
+
note: generatedAt ? undefined : 'At least one scoped repo has no lastSyncedAt timestamp',
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function upsertSourceRef(sources, source) {
|
|
260
|
+
const index = sources.findIndex((entry) => entry.id === source.id);
|
|
261
|
+
if (index >= 0) {
|
|
262
|
+
sources[index] = source;
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
sources.push(source);
|
|
266
|
+
}
|
|
267
|
+
function computeSnapshotTimestamp(sources, fallback) {
|
|
268
|
+
const sourceCandidates = sources
|
|
269
|
+
.map((source) => safeTimestamp(source.generatedAt))
|
|
270
|
+
.filter((entry) => Boolean(entry));
|
|
271
|
+
if (sourceCandidates.length === 0) {
|
|
272
|
+
return fallback.toISOString();
|
|
273
|
+
}
|
|
274
|
+
return sourceCandidates.reduce((latest, candidate) => (candidate.getTime() > latest.getTime() ? candidate : latest), sourceCandidates[0]).toISOString();
|
|
275
|
+
}
|
|
276
|
+
function markdownPriority(filePath) {
|
|
277
|
+
const lower = filePath.toLowerCase();
|
|
278
|
+
if (lower === 'readme.md' || lower.endsWith('/readme.md')) {
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
if (lower.includes('/docs/architecture/') || lower.includes('/architecture/')) {
|
|
282
|
+
return 1;
|
|
283
|
+
}
|
|
284
|
+
if (lower.includes('/docs/api/') || lower.includes('/api/')) {
|
|
285
|
+
return 2;
|
|
286
|
+
}
|
|
287
|
+
if (lower.includes('/docs/')) {
|
|
288
|
+
return 3;
|
|
289
|
+
}
|
|
290
|
+
return 4;
|
|
291
|
+
}
|
|
292
|
+
function parseOwnerRepo(fullName) {
|
|
293
|
+
if (!fullName) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
const parts = fullName.split('/').map((part) => part.trim()).filter((part) => part.length > 0);
|
|
297
|
+
if (parts.length !== 2) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
return { owner: parts[0], repo: parts[1] };
|
|
301
|
+
}
|
|
302
|
+
function normalizeMarkdown(input) {
|
|
303
|
+
return input
|
|
304
|
+
.replace(/\r\n/g, '\n')
|
|
305
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
306
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
307
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
|
|
308
|
+
.replace(/[#>*_~\-]{1,}/g, ' ')
|
|
309
|
+
.replace(/\s+/g, ' ')
|
|
310
|
+
.trim();
|
|
311
|
+
}
|
|
312
|
+
function splitSentences(input) {
|
|
313
|
+
const cleaned = normalizeMarkdown(input);
|
|
314
|
+
if (cleaned.length === 0) {
|
|
315
|
+
return [];
|
|
316
|
+
}
|
|
317
|
+
return cleaned
|
|
318
|
+
.split(/(?<=[.!?])\s+/)
|
|
319
|
+
.map((sentence) => sentence.trim())
|
|
320
|
+
.filter((sentence) => sentence.length >= 20);
|
|
321
|
+
}
|
|
322
|
+
function markdownSections(content) {
|
|
323
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
324
|
+
const out = [];
|
|
325
|
+
let currentHeading = 'root';
|
|
326
|
+
let buffer = [];
|
|
327
|
+
function flush() {
|
|
328
|
+
const body = buffer.join('\n').trim();
|
|
329
|
+
if (body.length > 0) {
|
|
330
|
+
out.push({ heading: currentHeading, body });
|
|
331
|
+
}
|
|
332
|
+
buffer = [];
|
|
333
|
+
}
|
|
334
|
+
for (const line of lines) {
|
|
335
|
+
const match = line.match(/^#{1,6}\s+(.+)$/);
|
|
336
|
+
if (match) {
|
|
337
|
+
flush();
|
|
338
|
+
currentHeading = match[1].trim().toLowerCase();
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
buffer.push(line);
|
|
342
|
+
}
|
|
343
|
+
flush();
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
function firstParagraph(content) {
|
|
347
|
+
const paragraphs = content
|
|
348
|
+
.replace(/\r\n/g, '\n')
|
|
349
|
+
.split(/\n\s*\n/)
|
|
350
|
+
.map((chunk) => normalizeMarkdown(chunk))
|
|
351
|
+
.filter((chunk) => chunk.length >= 20);
|
|
352
|
+
return paragraphs[0];
|
|
353
|
+
}
|
|
354
|
+
function topUnique(values, limit) {
|
|
355
|
+
const out = [];
|
|
356
|
+
for (const value of values) {
|
|
357
|
+
if (out.includes(value)) {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
out.push(value);
|
|
361
|
+
if (out.length >= limit) {
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
function collectLocalMarkdownDocs(repo, maxFiles = 180, maxChars = 180_000) {
|
|
368
|
+
if (!repo.localPath || !(0, fs_1.fileExists)(repo.localPath)) {
|
|
369
|
+
return [];
|
|
370
|
+
}
|
|
371
|
+
const candidates = (0, fileScan_1.listFilesRecursive)(repo.localPath)
|
|
372
|
+
.filter((candidate) => /\.(md|mdx)$/i.test(candidate))
|
|
373
|
+
.map((candidate) => node_path_1.default.relative(repo.localPath, candidate).split(node_path_1.default.sep).join('/'))
|
|
374
|
+
.sort((a, b) => {
|
|
375
|
+
const priorityDelta = markdownPriority(a) - markdownPriority(b);
|
|
376
|
+
if (priorityDelta !== 0) {
|
|
377
|
+
return priorityDelta;
|
|
378
|
+
}
|
|
379
|
+
return a.localeCompare(b);
|
|
380
|
+
});
|
|
381
|
+
const selected = candidates.slice(0, maxFiles);
|
|
382
|
+
const docs = [];
|
|
383
|
+
for (const relativePath of selected) {
|
|
384
|
+
const absolutePath = node_path_1.default.join(repo.localPath, relativePath);
|
|
385
|
+
const raw = (0, fs_1.safeReadText)(absolutePath);
|
|
386
|
+
const body = raw.slice(0, maxChars);
|
|
387
|
+
if (normalizeMarkdown(body).length === 0) {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
docs.push({
|
|
391
|
+
path: relativePath,
|
|
392
|
+
body,
|
|
393
|
+
source: 'local',
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return docs;
|
|
397
|
+
}
|
|
398
|
+
async function collectRemoteMarkdownDocs(repo, token) {
|
|
399
|
+
if (!token) {
|
|
400
|
+
return [];
|
|
401
|
+
}
|
|
402
|
+
const ownerRepo = parseOwnerRepo(repo.fullName);
|
|
403
|
+
if (!ownerRepo) {
|
|
404
|
+
return [];
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
const files = await (0, github_1.fetchRepositoryMarkdownDocs)({
|
|
408
|
+
owner: ownerRepo.owner,
|
|
409
|
+
repo: ownerRepo.repo,
|
|
410
|
+
defaultBranch: repo.defaultBranch ?? 'main',
|
|
411
|
+
token,
|
|
412
|
+
maxFiles: 25,
|
|
413
|
+
maxBytesPerFile: 120_000,
|
|
414
|
+
});
|
|
415
|
+
return files.map((entry) => ({
|
|
416
|
+
path: entry.path,
|
|
417
|
+
body: entry.body,
|
|
418
|
+
source: 'remote',
|
|
419
|
+
referenceUrl: entry.referenceUrl,
|
|
420
|
+
}));
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
return [];
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function inferStoresFromDocs(docs) {
|
|
427
|
+
const keywords = ['postgres', 'mysql', 'mongodb', 'dynamodb', 'redis', 'cassandra', 'sqlite', 'kafka', 'sqs', 'rabbitmq'];
|
|
428
|
+
const matches = [];
|
|
429
|
+
for (const doc of docs) {
|
|
430
|
+
const normalized = normalizeMarkdown(doc.body).toLowerCase();
|
|
431
|
+
for (const keyword of keywords) {
|
|
432
|
+
if (normalized.includes(keyword)) {
|
|
433
|
+
matches.push(keyword);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return topUnique(matches.sort((a, b) => a.localeCompare(b)), 6);
|
|
438
|
+
}
|
|
439
|
+
function collectByHeading(docs, matcher, limit) {
|
|
440
|
+
const collected = [];
|
|
441
|
+
for (const doc of docs) {
|
|
442
|
+
const sections = markdownSections(doc.body);
|
|
443
|
+
for (const section of sections) {
|
|
444
|
+
if (!matcher.test(section.heading)) {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
collected.push(...splitSentences(section.body));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return topUnique(collected, limit);
|
|
451
|
+
}
|
|
452
|
+
function collectCommands(docs, limit) {
|
|
453
|
+
const commands = [];
|
|
454
|
+
for (const doc of docs) {
|
|
455
|
+
const matches = doc.body.match(/```(?:bash|sh|zsh|shell)?\n([\s\S]*?)```/gi) ?? [];
|
|
456
|
+
for (const block of matches) {
|
|
457
|
+
const body = block
|
|
458
|
+
.replace(/```(?:bash|sh|zsh|shell)?\n?/i, '')
|
|
459
|
+
.replace(/```$/i, '')
|
|
460
|
+
.split('\n')
|
|
461
|
+
.map((line) => line.trim())
|
|
462
|
+
.filter((line) => line.length > 0 && !line.startsWith('#'));
|
|
463
|
+
for (const line of body) {
|
|
464
|
+
commands.push(line);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return topUnique(commands, limit);
|
|
469
|
+
}
|
|
470
|
+
function extractGlossaryTerms(docs) {
|
|
471
|
+
const terms = [];
|
|
472
|
+
for (const doc of docs) {
|
|
473
|
+
const glossarySections = markdownSections(doc.body).filter((section) => /glossary|terms/.test(section.heading));
|
|
474
|
+
for (const section of glossarySections) {
|
|
475
|
+
for (const line of section.body.split('\n')) {
|
|
476
|
+
const match = line.match(/^\s*[-*]\s*\*\*([^*]+)\*\*:/);
|
|
477
|
+
if (match) {
|
|
478
|
+
terms.push(match[1].trim());
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return topUnique(terms.sort((a, b) => a.localeCompare(b)), 12);
|
|
484
|
+
}
|
|
485
|
+
async function buildRepoInsights(repo, token) {
|
|
486
|
+
const localDocs = collectLocalMarkdownDocs(repo);
|
|
487
|
+
const remoteDocs = localDocs.length > 0 ? [] : await collectRemoteMarkdownDocs(repo, token);
|
|
488
|
+
const docs = [...localDocs, ...remoteDocs];
|
|
489
|
+
const summary = firstParagraph(docs.find((doc) => /(^|\/)readme\.mdx?$/i.test(doc.path))?.body ?? '') ??
|
|
490
|
+
firstParagraph(docs[0]?.body ?? '') ??
|
|
491
|
+
'Unknown';
|
|
492
|
+
const responsibilities = collectByHeading(docs, /(overview|purpose|responsibilit|architecture|what .*does)/, 5);
|
|
493
|
+
const interfaces = collectByHeading(docs, /(api|endpoint|contract|schema|graphql|grpc|openapi)/, 5);
|
|
494
|
+
const asyncPatterns = collectByHeading(docs, /(event|async|queue|topic|stream|kafka|pubsub)/, 5);
|
|
495
|
+
const deployment = collectByHeading(docs, /(deploy|environment|infrastructure|release|production)/, 5);
|
|
496
|
+
const runbooks = collectByHeading(docs, /(runbook|incident|on.?call|troubleshoot|escalation)/, 5);
|
|
497
|
+
const security = collectByHeading(docs, /(security|auth|authorization|compliance|privacy|encryption|secret)/, 5);
|
|
498
|
+
const adrs = docs
|
|
499
|
+
.filter((doc) => /(\/|^)docs\/adr\/.+\.mdx?$/i.test(doc.path) || /(\/|^)adr\/.+\.mdx?$/i.test(doc.path))
|
|
500
|
+
.map((doc) => doc.referenceUrl ?? doc.path);
|
|
501
|
+
const localDevelopment = collectCommands(docs, 8);
|
|
502
|
+
const dataStores = inferStoresFromDocs(docs);
|
|
503
|
+
const glossary = extractGlossaryTerms(docs);
|
|
504
|
+
const docReferences = topUnique(docs
|
|
505
|
+
.map((doc) => doc.referenceUrl ?? doc.path)
|
|
506
|
+
.sort((a, b) => a.localeCompare(b)), 8);
|
|
507
|
+
return {
|
|
508
|
+
summary,
|
|
509
|
+
responsibilities,
|
|
510
|
+
interfaces,
|
|
511
|
+
asyncPatterns,
|
|
512
|
+
deployment,
|
|
513
|
+
runbooks,
|
|
514
|
+
localDevelopment,
|
|
515
|
+
security,
|
|
516
|
+
adrs,
|
|
517
|
+
dataStores,
|
|
518
|
+
glossary,
|
|
519
|
+
docReferences,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
async function buildAllRepoInsights(selectedRepos, repoMap, token) {
|
|
523
|
+
const out = new Map();
|
|
524
|
+
await Promise.all(selectedRepos.map(async (repoName) => {
|
|
525
|
+
const repo = repoMap.get(repoName);
|
|
526
|
+
if (!repo) {
|
|
527
|
+
out.set(repoName, EMPTY_REPO_INSIGHTS);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const insights = await buildRepoInsights(repo, token);
|
|
531
|
+
out.set(repoName, insights);
|
|
532
|
+
}));
|
|
533
|
+
return out;
|
|
534
|
+
}
|
|
535
|
+
function filterReposForReadme(scope, config) {
|
|
536
|
+
const base = [...scope.effective];
|
|
537
|
+
const include = new Set((config.repos?.include ?? []).map((value) => normalizeRepoName(value)));
|
|
538
|
+
const exclude = new Set((config.repos?.exclude ?? []).map((value) => normalizeRepoName(value)));
|
|
539
|
+
let selected = base;
|
|
540
|
+
if (include.size > 0) {
|
|
541
|
+
selected = selected.filter((repo) => include.has(repo));
|
|
542
|
+
}
|
|
543
|
+
selected = selected.filter((repo) => !exclude.has(repo));
|
|
544
|
+
selected.sort((a, b) => a.localeCompare(b));
|
|
545
|
+
return selected;
|
|
546
|
+
}
|
|
547
|
+
function loadServiceMap(mapId, scope, repoMap, mapDir) {
|
|
548
|
+
const filePath = node_path_1.default.join(mapDir, 'service-map.json');
|
|
549
|
+
if ((0, fs_1.fileExists)(filePath)) {
|
|
550
|
+
return (0, fs_1.readJsonFile)(filePath);
|
|
551
|
+
}
|
|
552
|
+
return (0, mapBuilder_1.buildServiceMapArtifact)(mapId, scope, repoMap);
|
|
553
|
+
}
|
|
554
|
+
function loadContracts(mapId, scope, repoMap, mapDir) {
|
|
555
|
+
const filePath = node_path_1.default.join(mapDir, 'contracts.json');
|
|
556
|
+
if ((0, fs_1.fileExists)(filePath)) {
|
|
557
|
+
return (0, fs_1.readJsonFile)(filePath);
|
|
558
|
+
}
|
|
559
|
+
return (0, contracts_1.extractContracts)(mapId, scope, repoMap);
|
|
560
|
+
}
|
|
561
|
+
function loadArchitectureModel(mapId, db, cwd) {
|
|
562
|
+
const filePath = node_path_1.default.join(cwd, 'maps', mapId, 'architecture', 'model.json');
|
|
563
|
+
if ((0, fs_1.fileExists)(filePath)) {
|
|
564
|
+
return (0, fs_1.readJsonFile)(filePath);
|
|
565
|
+
}
|
|
566
|
+
return (0, architecture_1.buildArchitectureModel)(mapId, db, cwd);
|
|
567
|
+
}
|
|
568
|
+
function diagramPaths(mapId, cwd) {
|
|
569
|
+
const baseDir = node_path_1.default.join(cwd, 'docs', 'architecture', mapId, 'diagrams');
|
|
570
|
+
return {
|
|
571
|
+
baseDir,
|
|
572
|
+
systemContext: node_path_1.default.join(baseDir, 'system-context.mmd'),
|
|
573
|
+
serviceDependency: node_path_1.default.join(baseDir, 'service-dependency.mmd'),
|
|
574
|
+
sequence: node_path_1.default.join(baseDir, 'core-request-flow.mmd'),
|
|
575
|
+
optionalSystemLandscape: node_path_1.default.join(baseDir, 'system-landscape.mmd'),
|
|
576
|
+
optionalContainer: node_path_1.default.join(baseDir, 'container-communication.mmd'),
|
|
577
|
+
optionalArchitectureIndex: node_path_1.default.join(cwd, 'docs', 'architecture', mapId, 'index.md'),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
function renderFlowchart(nodes, edges) {
|
|
581
|
+
const lines = ['flowchart LR'];
|
|
582
|
+
for (const node of nodes.sort((a, b) => a.id.localeCompare(b.id))) {
|
|
583
|
+
const nodeId = node.id.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
584
|
+
lines.push(` ${nodeId}["${node.label}"]`);
|
|
585
|
+
}
|
|
586
|
+
const sortedEdges = [...edges].sort((a, b) => {
|
|
587
|
+
const left = `${a.from}|${a.to}|${a.relation}`;
|
|
588
|
+
const right = `${b.from}|${b.to}|${b.relation}`;
|
|
589
|
+
return left.localeCompare(right);
|
|
590
|
+
});
|
|
591
|
+
for (const edge of sortedEdges) {
|
|
592
|
+
const fromId = edge.from.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
593
|
+
const toId = edge.to.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
594
|
+
lines.push(` ${fromId} -->|"${edge.relation}"| ${toId}`);
|
|
595
|
+
}
|
|
596
|
+
return `${lines.join('\n')}\n`;
|
|
597
|
+
}
|
|
598
|
+
function renderSystemContextDiagram(model) {
|
|
599
|
+
const allowed = new Set(['service', 'external', 'datastore', 'queue', 'team']);
|
|
600
|
+
const nodes = model.nodes
|
|
601
|
+
.filter((node) => allowed.has(node.type))
|
|
602
|
+
.map((node) => ({ id: node.id, label: node.label }));
|
|
603
|
+
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
604
|
+
const edges = model.edges
|
|
605
|
+
.filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to))
|
|
606
|
+
.map((edge) => ({ from: edge.from, to: edge.to, relation: edge.relation }));
|
|
607
|
+
return renderFlowchart(nodes, edges);
|
|
608
|
+
}
|
|
609
|
+
function renderServiceDependencyDiagram(serviceMap) {
|
|
610
|
+
const serviceNodes = serviceMap.nodes
|
|
611
|
+
.filter((node) => node.type === 'service')
|
|
612
|
+
.map((node) => ({ id: node.id, label: node.label }));
|
|
613
|
+
const serviceIds = new Set(serviceNodes.map((node) => node.id));
|
|
614
|
+
const edges = serviceMap.edges
|
|
615
|
+
.filter((edge) => serviceIds.has(edge.from) && serviceIds.has(edge.to))
|
|
616
|
+
.map((edge) => ({ from: edge.from, to: edge.to, relation: edge.relation }));
|
|
617
|
+
return renderFlowchart(serviceNodes, edges);
|
|
618
|
+
}
|
|
619
|
+
function enumerateServiceCallEdges(model) {
|
|
620
|
+
const out = [];
|
|
621
|
+
for (const edge of model.edges) {
|
|
622
|
+
if (edge.relation !== 'calls') {
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
if (!edge.from.startsWith('service:') || !edge.to.startsWith('service:')) {
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
out.push({
|
|
629
|
+
from: edge.from.replace('service:', ''),
|
|
630
|
+
to: edge.to.replace('service:', ''),
|
|
631
|
+
confidence: edge.provenance.confidence,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
return out.sort((a, b) => {
|
|
635
|
+
const score = b.confidence - a.confidence;
|
|
636
|
+
if (score !== 0) {
|
|
637
|
+
return score;
|
|
638
|
+
}
|
|
639
|
+
const left = `${a.from}|${a.to}`;
|
|
640
|
+
const right = `${b.from}|${b.to}`;
|
|
641
|
+
return left.localeCompare(right);
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
function enumerateDependencyEdges(serviceMap) {
|
|
645
|
+
const out = [];
|
|
646
|
+
for (const edge of serviceMap.edges) {
|
|
647
|
+
if (edge.relation !== 'depends_on') {
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
if (!edge.from.startsWith('service:') || !edge.to.startsWith('service:')) {
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
out.push({
|
|
654
|
+
from: edge.from.replace('service:', ''),
|
|
655
|
+
to: edge.to.replace('service:', ''),
|
|
656
|
+
confidence: 0.45,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
return out.sort((a, b) => {
|
|
660
|
+
const left = `${a.from}|${a.to}`;
|
|
661
|
+
const right = `${b.from}|${b.to}`;
|
|
662
|
+
return left.localeCompare(right);
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
function bestPath(edges) {
|
|
666
|
+
if (edges.length === 0) {
|
|
667
|
+
return [];
|
|
668
|
+
}
|
|
669
|
+
const adjacency = new Map();
|
|
670
|
+
for (const edge of edges) {
|
|
671
|
+
const candidates = adjacency.get(edge.from) ?? [];
|
|
672
|
+
candidates.push(edge);
|
|
673
|
+
adjacency.set(edge.from, candidates);
|
|
674
|
+
}
|
|
675
|
+
for (const candidateEdges of adjacency.values()) {
|
|
676
|
+
candidateEdges.sort((a, b) => {
|
|
677
|
+
const score = b.confidence - a.confidence;
|
|
678
|
+
if (score !== 0) {
|
|
679
|
+
return score;
|
|
680
|
+
}
|
|
681
|
+
return `${a.from}|${a.to}`.localeCompare(`${b.from}|${b.to}`);
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
const starts = [...new Set(edges.map((edge) => edge.from))].sort((a, b) => a.localeCompare(b));
|
|
685
|
+
let best = [];
|
|
686
|
+
let bestScore = -1;
|
|
687
|
+
const maxDepth = 6;
|
|
688
|
+
function dfs(current, visited, pathEdges) {
|
|
689
|
+
const outgoing = adjacency.get(current) ?? [];
|
|
690
|
+
if (pathEdges.length > 0) {
|
|
691
|
+
const score = pathEdges.reduce((sum, step) => sum + step.confidence, 0) + pathEdges.length * 0.1;
|
|
692
|
+
const tieBreakerLeft = pathEdges.map((edge) => `${edge.from}->${edge.to}`).join('|');
|
|
693
|
+
const tieBreakerRight = best.map((edge) => `${edge.from}->${edge.to}`).join('|');
|
|
694
|
+
if (score > bestScore || (score === bestScore && tieBreakerLeft.localeCompare(tieBreakerRight) < 0)) {
|
|
695
|
+
best = [...pathEdges];
|
|
696
|
+
bestScore = score;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (pathEdges.length >= maxDepth) {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
for (const edge of outgoing) {
|
|
703
|
+
if (visited.has(edge.to)) {
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
visited.add(edge.to);
|
|
707
|
+
pathEdges.push(edge);
|
|
708
|
+
dfs(edge.to, visited, pathEdges);
|
|
709
|
+
pathEdges.pop();
|
|
710
|
+
visited.delete(edge.to);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
for (const start of starts) {
|
|
714
|
+
const visited = new Set([start]);
|
|
715
|
+
dfs(start, visited, []);
|
|
716
|
+
}
|
|
717
|
+
return best;
|
|
718
|
+
}
|
|
719
|
+
function findCoreRequestPath(model, serviceMap) {
|
|
720
|
+
const callPath = bestPath(enumerateServiceCallEdges(model));
|
|
721
|
+
const fallbackPath = callPath.length > 0 ? callPath : bestPath(enumerateDependencyEdges(serviceMap));
|
|
722
|
+
if (fallbackPath.length === 0) {
|
|
723
|
+
return [];
|
|
724
|
+
}
|
|
725
|
+
return fallbackPath.map((step) => ({
|
|
726
|
+
from: `service:${step.from}`,
|
|
727
|
+
to: `service:${step.to}`,
|
|
728
|
+
relation: 'calls',
|
|
729
|
+
provenance: {
|
|
730
|
+
source: callPath.length > 0 ? 'inferred' : 'declared',
|
|
731
|
+
confidence: step.confidence,
|
|
732
|
+
evidence: [callPath.length > 0 ? 'architecture_model' : 'service_map_dependency_fallback'],
|
|
733
|
+
},
|
|
734
|
+
}));
|
|
735
|
+
}
|
|
736
|
+
function renderCoreSequence(pathEdges) {
|
|
737
|
+
const lines = ['sequenceDiagram', ' autonumber'];
|
|
738
|
+
if (pathEdges.length === 0) {
|
|
739
|
+
lines.push(' participant system as System');
|
|
740
|
+
lines.push(' system->>system: Unknown flow (insufficient call/dependency evidence)');
|
|
741
|
+
return `${lines.join('\n')}\n`;
|
|
742
|
+
}
|
|
743
|
+
const participants = new Set();
|
|
744
|
+
for (const edge of pathEdges) {
|
|
745
|
+
participants.add(edge.from.replace('service:', ''));
|
|
746
|
+
participants.add(edge.to.replace('service:', ''));
|
|
747
|
+
}
|
|
748
|
+
for (const participant of [...participants].sort((a, b) => a.localeCompare(b))) {
|
|
749
|
+
lines.push(` participant ${participant.replace(/[^a-zA-Z0-9_]/g, '_')} as ${participant}`);
|
|
750
|
+
}
|
|
751
|
+
for (const edge of pathEdges) {
|
|
752
|
+
const from = edge.from.replace('service:', '').replace(/[^a-zA-Z0-9_]/g, '_');
|
|
753
|
+
const to = edge.to.replace('service:', '').replace(/[^a-zA-Z0-9_]/g, '_');
|
|
754
|
+
lines.push(` ${from}->>${to}: ${edge.relation}`);
|
|
755
|
+
lines.push(` ${to}-->>${from}: response`);
|
|
756
|
+
}
|
|
757
|
+
return `${lines.join('\n')}\n`;
|
|
758
|
+
}
|
|
759
|
+
function ensureRequiredDiagrams(context, db, cwd, writeEnabled) {
|
|
760
|
+
const refs = [];
|
|
761
|
+
const threshold = context.staleThresholdHours;
|
|
762
|
+
const required = REQUIRED_DIAGRAM_NAMES.map((name) => node_path_1.default.join(context.diagrams.baseDir, name));
|
|
763
|
+
const missing = required.filter((candidate) => !(0, fs_1.fileExists)(candidate));
|
|
764
|
+
let generatedArchitecturePack = false;
|
|
765
|
+
const autoGenerateMissing = context.config.diagram?.autoGenerateMissing ?? true;
|
|
766
|
+
if (writeEnabled && autoGenerateMissing && missing.length > 0) {
|
|
767
|
+
(0, architecture_1.generateArchitecturePack)({
|
|
768
|
+
mapId: context.mapId,
|
|
769
|
+
db,
|
|
770
|
+
cwd,
|
|
771
|
+
depth: 'org',
|
|
772
|
+
});
|
|
773
|
+
generatedArchitecturePack = true;
|
|
774
|
+
}
|
|
775
|
+
if (writeEnabled && autoGenerateMissing && !(0, fs_1.fileExists)(context.diagrams.systemContext)) {
|
|
776
|
+
(0, fs_1.writeTextFile)(context.diagrams.systemContext, renderSystemContextDiagram(context.architectureModel));
|
|
777
|
+
}
|
|
778
|
+
if (writeEnabled && autoGenerateMissing && !(0, fs_1.fileExists)(context.diagrams.serviceDependency)) {
|
|
779
|
+
(0, fs_1.writeTextFile)(context.diagrams.serviceDependency, renderServiceDependencyDiagram(context.serviceMap));
|
|
780
|
+
}
|
|
781
|
+
if (writeEnabled && autoGenerateMissing && !(0, fs_1.fileExists)(context.diagrams.sequence)) {
|
|
782
|
+
(0, fs_1.writeTextFile)(context.diagrams.sequence, renderCoreSequence(context.coreRequestPath));
|
|
783
|
+
}
|
|
784
|
+
refs.push(sourceFromFile('diagram-system-context', 'System context diagram', context.diagrams.systemContext, cwd, threshold, context.now, true));
|
|
785
|
+
refs.push(sourceFromFile('diagram-service-dependency', 'Service dependency diagram', context.diagrams.serviceDependency, cwd, threshold, context.now, true));
|
|
786
|
+
refs.push(sourceFromFile('diagram-core-sequence', 'Core request flow sequence', context.diagrams.sequence, cwd, threshold, context.now, true));
|
|
787
|
+
refs.push(sourceFromFile('diagram-c4-landscape', 'Optional C4 system landscape', context.diagrams.optionalSystemLandscape, cwd, threshold, context.now, false));
|
|
788
|
+
refs.push(sourceFromFile('diagram-c4-container', 'Optional C4 container communication', context.diagrams.optionalContainer, cwd, threshold, context.now, false));
|
|
789
|
+
return {
|
|
790
|
+
generatedArchitecturePack,
|
|
791
|
+
diagramSources: refs,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
function readCodeownersOwner(repo) {
|
|
795
|
+
if (!repo.localPath) {
|
|
796
|
+
return undefined;
|
|
797
|
+
}
|
|
798
|
+
const candidates = [node_path_1.default.join(repo.localPath, 'CODEOWNERS'), node_path_1.default.join(repo.localPath, '.github', 'CODEOWNERS')];
|
|
799
|
+
for (const candidate of candidates) {
|
|
800
|
+
if (!(0, fs_1.fileExists)(candidate)) {
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
const lines = (0, fs_1.safeReadText)(candidate)
|
|
804
|
+
.split(/\r?\n/)
|
|
805
|
+
.map((line) => line.trim())
|
|
806
|
+
.filter((line) => line.length > 0 && !line.startsWith('#'));
|
|
807
|
+
for (const line of lines) {
|
|
808
|
+
const parts = line.split(/\s+/).filter((part) => part.length > 0);
|
|
809
|
+
const owner = parts.find((part) => part.startsWith('@'));
|
|
810
|
+
if (owner) {
|
|
811
|
+
return owner;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return undefined;
|
|
816
|
+
}
|
|
817
|
+
function inferRuntimeFramework(repo) {
|
|
818
|
+
if (!repo.localPath || !(0, fs_1.fileExists)(repo.localPath)) {
|
|
819
|
+
return 'Unknown';
|
|
820
|
+
}
|
|
821
|
+
const packagePath = node_path_1.default.join(repo.localPath, 'package.json');
|
|
822
|
+
if ((0, fs_1.fileExists)(packagePath)) {
|
|
823
|
+
try {
|
|
824
|
+
const payload = (0, fs_1.readJsonFile)(packagePath);
|
|
825
|
+
const deps = new Set([
|
|
826
|
+
...Object.keys(payload.dependencies ?? {}),
|
|
827
|
+
...Object.keys(payload.devDependencies ?? {}),
|
|
828
|
+
]);
|
|
829
|
+
if (deps.has('next')) {
|
|
830
|
+
return 'Node.js (Next.js)';
|
|
831
|
+
}
|
|
832
|
+
if (deps.has('nestjs') || deps.has('@nestjs/core')) {
|
|
833
|
+
return 'Node.js (NestJS)';
|
|
834
|
+
}
|
|
835
|
+
if (deps.has('express')) {
|
|
836
|
+
return 'Node.js (Express)';
|
|
837
|
+
}
|
|
838
|
+
return 'Node.js';
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
return 'Node.js';
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
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'))) {
|
|
845
|
+
return 'Python';
|
|
846
|
+
}
|
|
847
|
+
if ((0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'go.mod'))) {
|
|
848
|
+
return 'Go';
|
|
849
|
+
}
|
|
850
|
+
if ((0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'Cargo.toml'))) {
|
|
851
|
+
return 'Rust';
|
|
852
|
+
}
|
|
853
|
+
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'))) {
|
|
854
|
+
return 'JVM';
|
|
855
|
+
}
|
|
856
|
+
return 'Unknown';
|
|
857
|
+
}
|
|
858
|
+
function inferDeployTarget(repo) {
|
|
859
|
+
if (!repo.localPath || !(0, fs_1.fileExists)(repo.localPath)) {
|
|
860
|
+
return 'Unknown';
|
|
861
|
+
}
|
|
862
|
+
if ((0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'vercel.json'))) {
|
|
863
|
+
return 'Vercel';
|
|
864
|
+
}
|
|
865
|
+
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'))) {
|
|
866
|
+
return 'Serverless';
|
|
867
|
+
}
|
|
868
|
+
const hasKubernetes = (0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'k8s')) ||
|
|
869
|
+
(0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'helm')) ||
|
|
870
|
+
(0, fs_1.fileExists)(node_path_1.default.join(repo.localPath, 'charts'));
|
|
871
|
+
if (hasKubernetes) {
|
|
872
|
+
return 'Kubernetes';
|
|
873
|
+
}
|
|
874
|
+
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'))) {
|
|
875
|
+
return 'Container';
|
|
876
|
+
}
|
|
877
|
+
return 'Unknown';
|
|
878
|
+
}
|
|
879
|
+
function formatList(values) {
|
|
880
|
+
if (values.length === 0) {
|
|
881
|
+
return 'Unknown';
|
|
882
|
+
}
|
|
883
|
+
return values.join(', ');
|
|
884
|
+
}
|
|
885
|
+
function insightsForRepo(repoName, context) {
|
|
886
|
+
return context.repoInsights.get(repoName) ?? EMPTY_REPO_INSIGHTS;
|
|
887
|
+
}
|
|
888
|
+
function shorten(text, max = 180) {
|
|
889
|
+
const normalized = normalizeMarkdown(text);
|
|
890
|
+
if (normalized.length <= max) {
|
|
891
|
+
return normalized;
|
|
892
|
+
}
|
|
893
|
+
return `${normalized.slice(0, max - 1).trimEnd()}…`;
|
|
894
|
+
}
|
|
895
|
+
function formatLinks(links, limit = 3) {
|
|
896
|
+
if (links.length === 0) {
|
|
897
|
+
return 'Unknown';
|
|
898
|
+
}
|
|
899
|
+
return links
|
|
900
|
+
.slice(0, limit)
|
|
901
|
+
.map((value) => {
|
|
902
|
+
if (/^https?:\/\//.test(value)) {
|
|
903
|
+
return `[doc](${value})`;
|
|
904
|
+
}
|
|
905
|
+
return `\`${value}\``;
|
|
906
|
+
})
|
|
907
|
+
.join(', ');
|
|
908
|
+
}
|
|
909
|
+
function inferRuntimeFromInsights(insights) {
|
|
910
|
+
const corpus = [
|
|
911
|
+
insights.summary,
|
|
912
|
+
...insights.responsibilities,
|
|
913
|
+
...insights.interfaces,
|
|
914
|
+
...insights.localDevelopment,
|
|
915
|
+
]
|
|
916
|
+
.join(' ')
|
|
917
|
+
.toLowerCase();
|
|
918
|
+
if (corpus.includes('next.js') || corpus.includes('nextjs')) {
|
|
919
|
+
return 'Node.js (Next.js)';
|
|
920
|
+
}
|
|
921
|
+
if (corpus.includes('nestjs')) {
|
|
922
|
+
return 'Node.js (NestJS)';
|
|
923
|
+
}
|
|
924
|
+
if (corpus.includes('express')) {
|
|
925
|
+
return 'Node.js (Express)';
|
|
926
|
+
}
|
|
927
|
+
if (corpus.includes('fastapi')) {
|
|
928
|
+
return 'Python (FastAPI)';
|
|
929
|
+
}
|
|
930
|
+
if (corpus.includes('django')) {
|
|
931
|
+
return 'Python (Django)';
|
|
932
|
+
}
|
|
933
|
+
if (corpus.includes('spring boot') || corpus.includes('spring')) {
|
|
934
|
+
return 'JVM (Spring)';
|
|
935
|
+
}
|
|
936
|
+
if (corpus.includes('golang') || corpus.includes('go service') || corpus.includes('go microservice')) {
|
|
937
|
+
return 'Go';
|
|
938
|
+
}
|
|
939
|
+
if (corpus.includes('rust')) {
|
|
940
|
+
return 'Rust';
|
|
941
|
+
}
|
|
942
|
+
return undefined;
|
|
943
|
+
}
|
|
944
|
+
function inferDeployTargetFromInsights(insights) {
|
|
945
|
+
const corpus = [...insights.deployment, insights.summary].join(' ').toLowerCase();
|
|
946
|
+
if (corpus.includes('kubernetes') || corpus.includes('helm') || corpus.includes('k8s')) {
|
|
947
|
+
return 'Kubernetes';
|
|
948
|
+
}
|
|
949
|
+
if (corpus.includes('vercel')) {
|
|
950
|
+
return 'Vercel';
|
|
951
|
+
}
|
|
952
|
+
if (corpus.includes('ecs') || corpus.includes('fargate')) {
|
|
953
|
+
return 'AWS ECS/Fargate';
|
|
954
|
+
}
|
|
955
|
+
if (corpus.includes('lambda') || corpus.includes('serverless')) {
|
|
956
|
+
return 'Serverless';
|
|
957
|
+
}
|
|
958
|
+
if (corpus.includes('docker') || corpus.includes('container')) {
|
|
959
|
+
return 'Container';
|
|
960
|
+
}
|
|
961
|
+
return undefined;
|
|
962
|
+
}
|
|
963
|
+
function ownerForService(serviceId, context) {
|
|
964
|
+
const overrides = context.config.ownerTeamOverrides ?? {};
|
|
965
|
+
if (overrides[serviceId]) {
|
|
966
|
+
return overrides[serviceId];
|
|
967
|
+
}
|
|
968
|
+
const serviceNode = context.architectureModel.nodes.find((node) => node.id === `service:${serviceId}`);
|
|
969
|
+
const fromMetadata = serviceNode?.metadata?.['owner'];
|
|
970
|
+
if (typeof fromMetadata === 'string' && fromMetadata.trim().length > 0) {
|
|
971
|
+
return fromMetadata.trim();
|
|
972
|
+
}
|
|
973
|
+
const repo = context.repoMap.get(serviceId);
|
|
974
|
+
if (!repo) {
|
|
975
|
+
return 'Unknown';
|
|
976
|
+
}
|
|
977
|
+
const fromCodeowners = readCodeownersOwner(repo);
|
|
978
|
+
return fromCodeowners ?? 'Unknown';
|
|
979
|
+
}
|
|
980
|
+
function criticalityForService(serviceId, context) {
|
|
981
|
+
const serviceNode = context.architectureModel.nodes.find((node) => node.id === `service:${serviceId}`);
|
|
982
|
+
const criticality = serviceNode?.metadata?.['criticality'];
|
|
983
|
+
if (typeof criticality === 'string' && criticality.trim().length > 0) {
|
|
984
|
+
return criticality;
|
|
985
|
+
}
|
|
986
|
+
return 'Unknown';
|
|
987
|
+
}
|
|
988
|
+
function apiSurfaceForService(serviceId, context) {
|
|
989
|
+
const serviceContracts = context.contracts.filter((record) => record.repo === serviceId);
|
|
990
|
+
const byType = new Map();
|
|
991
|
+
for (const contract of serviceContracts) {
|
|
992
|
+
byType.set(contract.type, (byType.get(contract.type) ?? 0) + 1);
|
|
993
|
+
}
|
|
994
|
+
const typed = [...byType.entries()]
|
|
995
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
996
|
+
.map(([type, count]) => `${type} (${count})`);
|
|
997
|
+
const insights = insightsForRepo(serviceId, context);
|
|
998
|
+
const inferred = topUnique([...insights.interfaces, ...insights.asyncPatterns].map((line) => shorten(line, 70)), 2);
|
|
999
|
+
const composed = [...typed, ...inferred];
|
|
1000
|
+
if (composed.length === 0) {
|
|
1001
|
+
return 'Unknown';
|
|
1002
|
+
}
|
|
1003
|
+
return composed.join(', ');
|
|
1004
|
+
}
|
|
1005
|
+
function dependenciesForService(serviceId, context) {
|
|
1006
|
+
const sourceId = `service:${serviceId}`;
|
|
1007
|
+
const dependencies = context.serviceMap.edges
|
|
1008
|
+
.filter((edge) => edge.from === sourceId && edge.to.startsWith('service:'))
|
|
1009
|
+
.map((edge) => edge.to.replace('service:', ''));
|
|
1010
|
+
return formatList([...new Set(dependencies)].sort((a, b) => a.localeCompare(b)));
|
|
1011
|
+
}
|
|
1012
|
+
function datastoresForService(serviceId, context) {
|
|
1013
|
+
const sourceId = `service:${serviceId}`;
|
|
1014
|
+
const stores = context.architectureModel.edges
|
|
1015
|
+
.filter((edge) => edge.from === sourceId && edge.to.startsWith('datastore:'))
|
|
1016
|
+
.map((edge) => edge.to.replace('datastore:', ''));
|
|
1017
|
+
return formatList([...new Set(stores)].sort((a, b) => a.localeCompare(b)));
|
|
1018
|
+
}
|
|
1019
|
+
function statusForService(serviceId, context) {
|
|
1020
|
+
const repo = context.repoMap.get(serviceId);
|
|
1021
|
+
if (!repo) {
|
|
1022
|
+
return 'Unknown';
|
|
1023
|
+
}
|
|
1024
|
+
if (repo.archived) {
|
|
1025
|
+
return 'Archived';
|
|
1026
|
+
}
|
|
1027
|
+
return 'Active';
|
|
1028
|
+
}
|
|
1029
|
+
function domainForRepo(repoName, config) {
|
|
1030
|
+
const groups = config.domainGroups ?? [];
|
|
1031
|
+
for (const group of groups) {
|
|
1032
|
+
if (group.match.some((pattern) => repoName.toLowerCase().includes(pattern.toLowerCase()))) {
|
|
1033
|
+
return group.name;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
return 'Ungrouped';
|
|
1037
|
+
}
|
|
1038
|
+
function serviceCatalog(context) {
|
|
1039
|
+
const serviceIds = context.selectedRepos
|
|
1040
|
+
.filter((repo) => context.serviceMap.nodes.some((node) => node.id === `service:${repo}`))
|
|
1041
|
+
.sort((a, b) => a.localeCompare(b));
|
|
1042
|
+
return serviceIds.map((serviceId) => {
|
|
1043
|
+
const repo = context.repoMap.get(serviceId);
|
|
1044
|
+
const insights = insightsForRepo(serviceId, context);
|
|
1045
|
+
const runtime = repo ? inferRuntimeFramework(repo) : 'Unknown';
|
|
1046
|
+
const runtimeFromDocs = inferRuntimeFromInsights(insights);
|
|
1047
|
+
const deploy = repo ? inferDeployTarget(repo) : 'Unknown';
|
|
1048
|
+
const deployFromDocs = inferDeployTargetFromInsights(insights);
|
|
1049
|
+
const serviceDataStores = [...new Set([...datastoresForService(serviceId, context).split(', ').filter((entry) => entry !== 'Unknown'), ...insights.dataStores])]
|
|
1050
|
+
.filter((entry) => entry.length > 0)
|
|
1051
|
+
.sort((a, b) => a.localeCompare(b));
|
|
1052
|
+
return {
|
|
1053
|
+
serviceName: serviceId,
|
|
1054
|
+
repository: repo?.fullName ?? serviceId,
|
|
1055
|
+
ownerTeam: ownerForService(serviceId, context),
|
|
1056
|
+
runtime: runtime !== 'Unknown' ? runtime : runtimeFromDocs ?? 'Unknown',
|
|
1057
|
+
apiEventSurface: apiSurfaceForService(serviceId, context),
|
|
1058
|
+
dependencies: dependenciesForService(serviceId, context),
|
|
1059
|
+
dataStores: formatList(serviceDataStores),
|
|
1060
|
+
deployTarget: deploy !== 'Unknown' ? deploy : deployFromDocs ?? 'Unknown',
|
|
1061
|
+
tier: criticalityForService(serviceId, context),
|
|
1062
|
+
status: statusForService(serviceId, context),
|
|
1063
|
+
};
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
function sectionAnchor(title) {
|
|
1067
|
+
return title
|
|
1068
|
+
.toLowerCase()
|
|
1069
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
1070
|
+
.trim()
|
|
1071
|
+
.replace(/\s+/g, '-');
|
|
1072
|
+
}
|
|
1073
|
+
function renderSection(section) {
|
|
1074
|
+
const lines = [];
|
|
1075
|
+
lines.push(`## ${section.title}`);
|
|
1076
|
+
lines.push('');
|
|
1077
|
+
lines.push(...section.body);
|
|
1078
|
+
lines.push('');
|
|
1079
|
+
return lines.join('\n');
|
|
1080
|
+
}
|
|
1081
|
+
function splitLines(input) {
|
|
1082
|
+
const normalized = input.replace(/\r\n/g, '\n');
|
|
1083
|
+
if (normalized.length === 0) {
|
|
1084
|
+
return [];
|
|
1085
|
+
}
|
|
1086
|
+
const lines = normalized.split('\n');
|
|
1087
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') {
|
|
1088
|
+
lines.pop();
|
|
1089
|
+
}
|
|
1090
|
+
return lines;
|
|
1091
|
+
}
|
|
1092
|
+
function diffLines(oldLines, newLines) {
|
|
1093
|
+
const n = oldLines.length;
|
|
1094
|
+
const m = newLines.length;
|
|
1095
|
+
const dp = Array.from({ length: n + 1 }, () => Array(m + 1).fill(0));
|
|
1096
|
+
for (let i = n - 1; i >= 0; i -= 1) {
|
|
1097
|
+
for (let j = m - 1; j >= 0; j -= 1) {
|
|
1098
|
+
if (oldLines[i] === newLines[j]) {
|
|
1099
|
+
dp[i][j] = dp[i + 1][j + 1] + 1;
|
|
1100
|
+
}
|
|
1101
|
+
else {
|
|
1102
|
+
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
const ops = [];
|
|
1107
|
+
let i = 0;
|
|
1108
|
+
let j = 0;
|
|
1109
|
+
while (i < n && j < m) {
|
|
1110
|
+
if (oldLines[i] === newLines[j]) {
|
|
1111
|
+
ops.push({ type: 'equal', line: oldLines[i] });
|
|
1112
|
+
i += 1;
|
|
1113
|
+
j += 1;
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
1117
|
+
ops.push({ type: 'remove', line: oldLines[i] });
|
|
1118
|
+
i += 1;
|
|
1119
|
+
}
|
|
1120
|
+
else {
|
|
1121
|
+
ops.push({ type: 'add', line: newLines[j] });
|
|
1122
|
+
j += 1;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
while (i < n) {
|
|
1126
|
+
ops.push({ type: 'remove', line: oldLines[i] });
|
|
1127
|
+
i += 1;
|
|
1128
|
+
}
|
|
1129
|
+
while (j < m) {
|
|
1130
|
+
ops.push({ type: 'add', line: newLines[j] });
|
|
1131
|
+
j += 1;
|
|
1132
|
+
}
|
|
1133
|
+
return ops;
|
|
1134
|
+
}
|
|
1135
|
+
function unifiedDiff(oldText, newText, oldLabel, newLabel) {
|
|
1136
|
+
if (oldText === newText) {
|
|
1137
|
+
return '';
|
|
1138
|
+
}
|
|
1139
|
+
const oldLines = splitLines(oldText);
|
|
1140
|
+
const newLines = splitLines(newText);
|
|
1141
|
+
const ops = diffLines(oldLines, newLines);
|
|
1142
|
+
const context = 3;
|
|
1143
|
+
const hunks = [];
|
|
1144
|
+
let current;
|
|
1145
|
+
for (let index = 0; index < ops.length; index += 1) {
|
|
1146
|
+
if (ops[index].type === 'equal') {
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
const hunkStart = Math.max(0, index - context);
|
|
1150
|
+
const hunkEnd = Math.min(ops.length, index + context + 1);
|
|
1151
|
+
if (!current) {
|
|
1152
|
+
current = { start: hunkStart, end: hunkEnd };
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
if (hunkStart <= current.end) {
|
|
1156
|
+
current.end = Math.max(current.end, hunkEnd);
|
|
1157
|
+
}
|
|
1158
|
+
else {
|
|
1159
|
+
hunks.push(current);
|
|
1160
|
+
current = { start: hunkStart, end: hunkEnd };
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
if (current) {
|
|
1164
|
+
hunks.push(current);
|
|
1165
|
+
}
|
|
1166
|
+
const oldPrefix = [0];
|
|
1167
|
+
const newPrefix = [0];
|
|
1168
|
+
for (const op of ops) {
|
|
1169
|
+
const oldCount = oldPrefix[oldPrefix.length - 1] + (op.type === 'add' ? 0 : 1);
|
|
1170
|
+
const newCount = newPrefix[newPrefix.length - 1] + (op.type === 'remove' ? 0 : 1);
|
|
1171
|
+
oldPrefix.push(oldCount);
|
|
1172
|
+
newPrefix.push(newCount);
|
|
1173
|
+
}
|
|
1174
|
+
const lines = [`--- ${oldLabel}`, `+++ ${newLabel}`];
|
|
1175
|
+
for (const hunk of hunks) {
|
|
1176
|
+
const slice = ops.slice(hunk.start, hunk.end);
|
|
1177
|
+
const oldStart = oldPrefix[hunk.start] + 1;
|
|
1178
|
+
const newStart = newPrefix[hunk.start] + 1;
|
|
1179
|
+
const oldLen = slice.filter((entry) => entry.type !== 'add').length;
|
|
1180
|
+
const newLen = slice.filter((entry) => entry.type !== 'remove').length;
|
|
1181
|
+
lines.push(`@@ -${oldStart},${oldLen} +${newStart},${newLen} @@`);
|
|
1182
|
+
for (const op of slice) {
|
|
1183
|
+
if (op.type === 'equal') {
|
|
1184
|
+
lines.push(` ${op.line}`);
|
|
1185
|
+
}
|
|
1186
|
+
else if (op.type === 'remove') {
|
|
1187
|
+
lines.push(`-${op.line}`);
|
|
1188
|
+
}
|
|
1189
|
+
else {
|
|
1190
|
+
lines.push(`+${op.line}`);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
return `${lines.join('\n')}\n`;
|
|
1195
|
+
}
|
|
1196
|
+
async function buildReadmeContext(mapId, db, cwd, outputPath, config) {
|
|
1197
|
+
const now = new Date();
|
|
1198
|
+
const threshold = config.staleThresholdHours ?? 72;
|
|
1199
|
+
const scope = (0, scope_1.loadScopeManifest)(mapId, cwd);
|
|
1200
|
+
const repos = (0, repoRegistry_1.listAllRepos)(db);
|
|
1201
|
+
const repoMap = new Map(repos.map((repo) => [repo.name, repo]));
|
|
1202
|
+
const selectedRepos = filterReposForReadme(scope, config);
|
|
1203
|
+
const mapDir = node_path_1.default.join(cwd, 'maps', mapId);
|
|
1204
|
+
const serviceMap = loadServiceMap(mapId, scope, repoMap, mapDir);
|
|
1205
|
+
const contracts = loadContracts(mapId, scope, repoMap, mapDir);
|
|
1206
|
+
const model = loadArchitectureModel(mapId, db, cwd);
|
|
1207
|
+
const diagrams = diagramPaths(mapId, cwd);
|
|
1208
|
+
const sourceRefs = [];
|
|
1209
|
+
sourceRefs.push(sourceFromFile('scope', 'Map scope manifest', node_path_1.default.join(mapDir, 'scope.json'), cwd, threshold, now, true));
|
|
1210
|
+
sourceRefs.push(sourceFromFile('service-map-json', 'Service map JSON', node_path_1.default.join(mapDir, 'service-map.json'), cwd, threshold, now, true));
|
|
1211
|
+
sourceRefs.push(sourceFromFile('service-map-md', 'Service map Markdown', node_path_1.default.join(mapDir, 'service-map.md'), cwd, threshold, now, false));
|
|
1212
|
+
sourceRefs.push(sourceFromFile('service-map-mmd', 'Service map Mermaid', node_path_1.default.join(mapDir, 'service-map.mmd'), cwd, threshold, now, false));
|
|
1213
|
+
sourceRefs.push(sourceFromFile('contracts-json', 'Contracts JSON', node_path_1.default.join(mapDir, 'contracts.json'), cwd, threshold, now, true));
|
|
1214
|
+
sourceRefs.push(sourceFromFile('contracts-md', 'Contracts Markdown', node_path_1.default.join(mapDir, 'contracts.md'), cwd, threshold, now, false));
|
|
1215
|
+
sourceRefs.push(sourceFromFile('docs-architecture', 'Generated architecture doc', node_path_1.default.join(cwd, 'docs', 'architecture', `${mapId}.md`), cwd, threshold, now, false));
|
|
1216
|
+
sourceRefs.push(sourceFromFile('docs-dependencies', 'Generated dependency summary', node_path_1.default.join(cwd, 'catalog', 'dependencies', `${mapId}.md`), cwd, threshold, now, false));
|
|
1217
|
+
sourceRefs.push(sourceFromFile('architecture-model', 'Architecture model', node_path_1.default.join(cwd, 'maps', mapId, 'architecture', 'model.json'), cwd, threshold, now, false));
|
|
1218
|
+
sourceRefs.push(sourceFromFile('architecture-validation', 'Architecture validation', node_path_1.default.join(cwd, 'maps', mapId, 'architecture', 'validation.json'), cwd, threshold, now, false));
|
|
1219
|
+
sourceRefs.push(sourceFromRepoSync(repos, selectedRepos, threshold, now));
|
|
1220
|
+
let githubToken;
|
|
1221
|
+
try {
|
|
1222
|
+
const sdxConfig = (0, config_1.loadConfig)(cwd);
|
|
1223
|
+
const tokenEnv = sdxConfig.github?.tokenEnv?.trim() || 'GITHUB_TOKEN';
|
|
1224
|
+
githubToken = process.env[tokenEnv];
|
|
1225
|
+
}
|
|
1226
|
+
catch {
|
|
1227
|
+
githubToken = process.env['GITHUB_TOKEN'];
|
|
1228
|
+
}
|
|
1229
|
+
const repoInsights = await buildAllRepoInsights(selectedRepos, repoMap, githubToken);
|
|
1230
|
+
return {
|
|
1231
|
+
cwd,
|
|
1232
|
+
mapId,
|
|
1233
|
+
scope,
|
|
1234
|
+
selectedRepos,
|
|
1235
|
+
repoMap,
|
|
1236
|
+
serviceMap,
|
|
1237
|
+
contracts,
|
|
1238
|
+
architectureModel: model,
|
|
1239
|
+
diagrams,
|
|
1240
|
+
sources: sourceRefs,
|
|
1241
|
+
staleThresholdHours: threshold,
|
|
1242
|
+
now,
|
|
1243
|
+
outputPath,
|
|
1244
|
+
config,
|
|
1245
|
+
sourceSnapshotAt: computeSnapshotTimestamp(sourceRefs, now),
|
|
1246
|
+
coreRequestPath: findCoreRequestPath(model, serviceMap),
|
|
1247
|
+
sourceRepoSyncAt: sourceRefs.find((source) => source.id === 'repo-sync')?.generatedAt,
|
|
1248
|
+
repoInsights,
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
function buildSections(context) {
|
|
1252
|
+
const outputPath = context.outputPath;
|
|
1253
|
+
const includeC4Links = context.config.diagram?.includeC4Links ?? true;
|
|
1254
|
+
const links = {
|
|
1255
|
+
systemContext: toLinkPath(context.diagrams.systemContext, outputPath),
|
|
1256
|
+
serviceDependency: toLinkPath(context.diagrams.serviceDependency, outputPath),
|
|
1257
|
+
sequence: toLinkPath(context.diagrams.sequence, outputPath),
|
|
1258
|
+
optionalSystemLandscape: toLinkPath(context.diagrams.optionalSystemLandscape, outputPath),
|
|
1259
|
+
optionalContainer: toLinkPath(context.diagrams.optionalContainer, outputPath),
|
|
1260
|
+
optionalArchitectureIndex: toLinkPath(context.diagrams.optionalArchitectureIndex, outputPath),
|
|
1261
|
+
};
|
|
1262
|
+
const catalogRows = serviceCatalog(context);
|
|
1263
|
+
const asyncContracts = context.contracts.filter((record) => record.type === 'asyncapi');
|
|
1264
|
+
const allInsights = context.selectedRepos.map((repoName) => ({
|
|
1265
|
+
repoName,
|
|
1266
|
+
insights: insightsForRepo(repoName, context),
|
|
1267
|
+
}));
|
|
1268
|
+
const datastoreNodes = context.architectureModel.nodes.filter((node) => node.type === 'datastore');
|
|
1269
|
+
const adrDir = node_path_1.default.join(context.cwd, 'docs', 'adr');
|
|
1270
|
+
const adrFiles = (0, fs_1.fileExists)(adrDir)
|
|
1271
|
+
? node_fs_1.default
|
|
1272
|
+
.readdirSync(adrDir, { withFileTypes: true })
|
|
1273
|
+
.filter((entry) => entry.isFile() && /\.md$/i.test(entry.name))
|
|
1274
|
+
.map((entry) => entry.name)
|
|
1275
|
+
.sort((a, b) => a.localeCompare(b))
|
|
1276
|
+
: [];
|
|
1277
|
+
const summaryHighlights = allInsights
|
|
1278
|
+
.filter((entry) => entry.insights.summary !== 'Unknown')
|
|
1279
|
+
.sort((a, b) => a.repoName.localeCompare(b.repoName))
|
|
1280
|
+
.slice(0, 8)
|
|
1281
|
+
.map((entry) => `- **${entry.repoName}**: ${shorten(entry.insights.summary, 220)}`);
|
|
1282
|
+
const coreFlowLines = context.coreRequestPath.length > 0
|
|
1283
|
+
? context.coreRequestPath.map((edge) => {
|
|
1284
|
+
const from = edge.from.replace('service:', '');
|
|
1285
|
+
const to = edge.to.replace('service:', '');
|
|
1286
|
+
return `- ${from} -> ${to}`;
|
|
1287
|
+
})
|
|
1288
|
+
: ['- Unknown'];
|
|
1289
|
+
const coreFlowServices = topUnique(context.coreRequestPath.flatMap((edge) => [edge.from.replace('service:', ''), edge.to.replace('service:', '')]), 8);
|
|
1290
|
+
const coreFlowNotes = coreFlowServices.map((serviceId) => {
|
|
1291
|
+
const insights = insightsForRepo(serviceId, context);
|
|
1292
|
+
const line = insights.responsibilities[0] ?? insights.interfaces[0] ?? insights.summary;
|
|
1293
|
+
return `- **${serviceId}**: ${shorten(line, 180)}`;
|
|
1294
|
+
});
|
|
1295
|
+
const asyncHighlights = topUnique(allInsights.flatMap((entry) => entry.insights.asyncPatterns.map((line) => `- **${entry.repoName}**: ${shorten(line, 180)}`)), 12);
|
|
1296
|
+
const securityHighlights = topUnique(allInsights.flatMap((entry) => entry.insights.security.map((line) => `- **${entry.repoName}**: ${shorten(line, 180)}`)), 10);
|
|
1297
|
+
const runbookHighlights = topUnique(allInsights.flatMap((entry) => entry.insights.runbooks.map((line) => `- **${entry.repoName}**: ${shorten(line, 180)}`)), 10);
|
|
1298
|
+
const localDevCommands = topUnique(allInsights.flatMap((entry) => entry.insights.localDevelopment), 12);
|
|
1299
|
+
const glossaryTerms = topUnique(allInsights.flatMap((entry) => entry.insights.glossary), 20).sort((a, b) => a.localeCompare(b));
|
|
1300
|
+
const repoRows = context.selectedRepos
|
|
1301
|
+
.map((repoName) => {
|
|
1302
|
+
const repo = context.repoMap.get(repoName);
|
|
1303
|
+
const insights = insightsForRepo(repoName, context);
|
|
1304
|
+
return {
|
|
1305
|
+
fullName: repo?.fullName ?? repoName,
|
|
1306
|
+
owner: ownerForService(repoName, context),
|
|
1307
|
+
domain: domainForRepo(repoName, context.config),
|
|
1308
|
+
role: shorten(insights.responsibilities[0] ?? insights.summary, 140),
|
|
1309
|
+
docs: formatLinks(insights.docReferences, 3),
|
|
1310
|
+
};
|
|
1311
|
+
})
|
|
1312
|
+
.sort((a, b) => a.fullName.localeCompare(b.fullName));
|
|
1313
|
+
const environmentRows = catalogRows.map((row) => {
|
|
1314
|
+
const insights = insightsForRepo(row.serviceName, context);
|
|
1315
|
+
const note = insights.deployment[0] ?? insights.runbooks[0] ?? 'Unknown';
|
|
1316
|
+
return `| ${row.serviceName} | ${row.deployTarget} | ${row.runtime} | ${shorten(note, 140)} |`;
|
|
1317
|
+
});
|
|
1318
|
+
const contractsByRepo = new Map();
|
|
1319
|
+
for (const contract of context.contracts) {
|
|
1320
|
+
const entries = contractsByRepo.get(contract.repo) ?? [];
|
|
1321
|
+
entries.push(contract);
|
|
1322
|
+
contractsByRepo.set(contract.repo, entries);
|
|
1323
|
+
}
|
|
1324
|
+
const contractRows = context.selectedRepos.map((repoName) => {
|
|
1325
|
+
const contracts = contractsByRepo.get(repoName) ?? [];
|
|
1326
|
+
const summary = contracts.length > 0
|
|
1327
|
+
? contracts.map((contract) => `${contract.type}:${contract.path}`).slice(0, 4).join('<br/>')
|
|
1328
|
+
: 'Unknown';
|
|
1329
|
+
return `| ${repoName} | ${summary} |`;
|
|
1330
|
+
});
|
|
1331
|
+
const adrLinks = topUnique([
|
|
1332
|
+
...adrFiles.map((fileName) => `- [${fileName}](./docs/adr/${fileName})`),
|
|
1333
|
+
...allInsights.flatMap((entry) => entry.insights.adrs.map((candidate) => /^https?:\/\//.test(candidate)
|
|
1334
|
+
? `- [${entry.repoName} ADR](${candidate})`
|
|
1335
|
+
: `- ${entry.repoName}: \`${candidate}\``)),
|
|
1336
|
+
], 30);
|
|
1337
|
+
const sectionById = {
|
|
1338
|
+
what_is_this_system: {
|
|
1339
|
+
id: 'what_is_this_system',
|
|
1340
|
+
title: SECTION_TITLES['what_is_this_system'],
|
|
1341
|
+
body: [
|
|
1342
|
+
context.config.customIntro ??
|
|
1343
|
+
'SDX traverses repository documentation, contracts, and dependency signals to keep this architecture guide current.',
|
|
1344
|
+
'',
|
|
1345
|
+
`- Scope: \`${context.scope.org}\` org, map \`${context.mapId}\``,
|
|
1346
|
+
`- Repositories in scope: ${context.selectedRepos.length}`,
|
|
1347
|
+
`- Services identified: ${catalogRows.length}`,
|
|
1348
|
+
'',
|
|
1349
|
+
...(summaryHighlights.length > 0
|
|
1350
|
+
? ['### Service purpose highlights', '', ...summaryHighlights]
|
|
1351
|
+
: ['### Service purpose highlights', '', '- Unknown']),
|
|
1352
|
+
],
|
|
1353
|
+
sourceIds: ['scope', 'repo-sync', 'service-map-json'],
|
|
1354
|
+
},
|
|
1355
|
+
architecture_glance: {
|
|
1356
|
+
id: 'architecture_glance',
|
|
1357
|
+
title: SECTION_TITLES['architecture_glance'],
|
|
1358
|
+
body: (() => {
|
|
1359
|
+
const lines = [
|
|
1360
|
+
`- [System context diagram](${links.systemContext})`,
|
|
1361
|
+
`- [Service dependency graph](${links.serviceDependency})`,
|
|
1362
|
+
`- [Core request flow sequence](${links.sequence})`,
|
|
1363
|
+
(0, fs_1.fileExists)(context.diagrams.optionalArchitectureIndex)
|
|
1364
|
+
? `- [Architecture pack index](${links.optionalArchitectureIndex})`
|
|
1365
|
+
: '- Architecture pack index: Not available',
|
|
1366
|
+
];
|
|
1367
|
+
if (includeC4Links) {
|
|
1368
|
+
lines.push((0, fs_1.fileExists)(context.diagrams.optionalSystemLandscape)
|
|
1369
|
+
? `- [Optional C4 landscape](${links.optionalSystemLandscape})`
|
|
1370
|
+
: '- Optional C4 landscape: Not available');
|
|
1371
|
+
lines.push((0, fs_1.fileExists)(context.diagrams.optionalContainer)
|
|
1372
|
+
? `- [Optional C4 container](${links.optionalContainer})`
|
|
1373
|
+
: '- Optional C4 container: Not available');
|
|
1374
|
+
}
|
|
1375
|
+
if (coreFlowLines.length > 0) {
|
|
1376
|
+
lines.push('');
|
|
1377
|
+
lines.push('### Primary request path');
|
|
1378
|
+
lines.push(...coreFlowLines);
|
|
1379
|
+
}
|
|
1380
|
+
return lines;
|
|
1381
|
+
})(),
|
|
1382
|
+
sourceIds: [
|
|
1383
|
+
'service-map-json',
|
|
1384
|
+
'architecture-model',
|
|
1385
|
+
'diagram-system-context',
|
|
1386
|
+
'diagram-service-dependency',
|
|
1387
|
+
'diagram-core-sequence',
|
|
1388
|
+
'diagram-c4-landscape',
|
|
1389
|
+
'diagram-c4-container',
|
|
1390
|
+
],
|
|
1391
|
+
},
|
|
1392
|
+
service_catalog: {
|
|
1393
|
+
id: 'service_catalog',
|
|
1394
|
+
title: SECTION_TITLES['service_catalog'],
|
|
1395
|
+
body: [
|
|
1396
|
+
'| Service name | Repository | Owner/team | Runtime/framework | API/event surface | Dependencies | Data stores | Deploy target | Tier/criticality | Status |',
|
|
1397
|
+
'|---|---|---|---|---|---|---|---|---|---|',
|
|
1398
|
+
...(catalogRows.length > 0
|
|
1399
|
+
? catalogRows.map((row) => `| ${row.serviceName} | ${row.repository} | ${row.ownerTeam} | ${row.runtime} | ${row.apiEventSurface} | ${row.dependencies} | ${row.dataStores} | ${row.deployTarget} | ${row.tier} | ${row.status} |`)
|
|
1400
|
+
: ['| Unknown | Unknown | Unknown | Unknown | Unknown | Unknown | Unknown | Unknown | Unknown | Unknown |']),
|
|
1401
|
+
'',
|
|
1402
|
+
'### Service briefs',
|
|
1403
|
+
...(summaryHighlights.length > 0 ? summaryHighlights : ['- Unknown']),
|
|
1404
|
+
],
|
|
1405
|
+
sourceIds: ['service-map-json', 'contracts-json', 'architecture-model', 'repo-sync'],
|
|
1406
|
+
},
|
|
1407
|
+
critical_flows: {
|
|
1408
|
+
id: 'critical_flows',
|
|
1409
|
+
title: SECTION_TITLES['critical_flows'],
|
|
1410
|
+
body: [
|
|
1411
|
+
`- Primary sequence diagram: [core-request-flow.mmd](${links.sequence})`,
|
|
1412
|
+
'- Current highest-confidence request chain:',
|
|
1413
|
+
...coreFlowLines,
|
|
1414
|
+
'',
|
|
1415
|
+
'### Service responsibilities in this flow',
|
|
1416
|
+
...(coreFlowNotes.length > 0 ? coreFlowNotes : ['- Unknown']),
|
|
1417
|
+
],
|
|
1418
|
+
sourceIds: ['architecture-model', 'service-map-json', 'docs-dependencies', 'diagram-core-sequence'],
|
|
1419
|
+
},
|
|
1420
|
+
event_async_topology: {
|
|
1421
|
+
id: 'event_async_topology',
|
|
1422
|
+
title: SECTION_TITLES['event_async_topology'],
|
|
1423
|
+
body: [
|
|
1424
|
+
'| Contract | Repository | Version | Compatibility | Producers | Consumers |',
|
|
1425
|
+
'|---|---|---|---|---|---|',
|
|
1426
|
+
...(asyncContracts.length > 0
|
|
1427
|
+
? asyncContracts.map((record) => `| ${record.path} | ${record.repo} | ${record.version ?? 'Unknown'} | ${record.compatibilityStatus} | ${formatList(record.producers)} | ${formatList(record.consumers)} |`)
|
|
1428
|
+
: ['| Unknown | Unknown | Unknown | Unknown | Unknown | Unknown |']),
|
|
1429
|
+
'',
|
|
1430
|
+
'### Async behavior from repository docs',
|
|
1431
|
+
...(asyncHighlights.length > 0 ? asyncHighlights : ['- Unknown']),
|
|
1432
|
+
],
|
|
1433
|
+
sourceIds: ['contracts-json', 'architecture-model'],
|
|
1434
|
+
},
|
|
1435
|
+
contracts_index: {
|
|
1436
|
+
id: 'contracts_index',
|
|
1437
|
+
title: SECTION_TITLES['contracts_index'],
|
|
1438
|
+
body: [
|
|
1439
|
+
'| Repository | Contract surfaces |',
|
|
1440
|
+
'|---|---|',
|
|
1441
|
+
...(contractRows.length > 0 ? contractRows : ['| Unknown | Unknown |']),
|
|
1442
|
+
],
|
|
1443
|
+
sourceIds: ['contracts-json', 'contracts-md'],
|
|
1444
|
+
},
|
|
1445
|
+
repository_index: {
|
|
1446
|
+
id: 'repository_index',
|
|
1447
|
+
title: SECTION_TITLES['repository_index'],
|
|
1448
|
+
body: [
|
|
1449
|
+
'| Repository | Owner/team | Domain | Role in system | Key docs |',
|
|
1450
|
+
'|---|---|---|---|---|',
|
|
1451
|
+
...(repoRows.length > 0
|
|
1452
|
+
? repoRows.map((row) => `| ${row.fullName} | ${row.owner} | ${row.domain} | ${row.role} | ${row.docs} |`)
|
|
1453
|
+
: ['| Unknown | Unknown | Unknown | Unknown | Unknown |']),
|
|
1454
|
+
],
|
|
1455
|
+
sourceIds: ['scope', 'repo-sync'],
|
|
1456
|
+
},
|
|
1457
|
+
environments_deployment: {
|
|
1458
|
+
id: 'environments_deployment',
|
|
1459
|
+
title: SECTION_TITLES['environments_deployment'],
|
|
1460
|
+
body: [
|
|
1461
|
+
'| Service | Deploy target | Runtime/framework | Environment notes |',
|
|
1462
|
+
'|---|---|---|---|',
|
|
1463
|
+
...(environmentRows.length > 0 ? environmentRows : ['| Unknown | Unknown | Unknown | Unknown |']),
|
|
1464
|
+
],
|
|
1465
|
+
sourceIds: ['service-map-json', 'repo-sync', 'architecture-model'],
|
|
1466
|
+
},
|
|
1467
|
+
data_stores_boundaries: {
|
|
1468
|
+
id: 'data_stores_boundaries',
|
|
1469
|
+
title: SECTION_TITLES['data_stores_boundaries'],
|
|
1470
|
+
body: [
|
|
1471
|
+
'| Data store | Depending services | Boundary notes |',
|
|
1472
|
+
'|---|---|---|',
|
|
1473
|
+
...(datastoreNodes.length > 0
|
|
1474
|
+
? datastoreNodes
|
|
1475
|
+
.sort((a, b) => a.label.localeCompare(b.label))
|
|
1476
|
+
.map((node) => {
|
|
1477
|
+
const dependers = context.architectureModel.edges
|
|
1478
|
+
.filter((edge) => edge.to === node.id && edge.from.startsWith('service:'))
|
|
1479
|
+
.map((edge) => edge.from.replace('service:', ''))
|
|
1480
|
+
.sort((a, b) => a.localeCompare(b));
|
|
1481
|
+
return `| ${node.label} | ${formatList([...new Set(dependers)])} | ${String(node.metadata?.['boundary'] ?? 'Unknown')} |`;
|
|
1482
|
+
})
|
|
1483
|
+
: ['| Unknown | Unknown | Unknown |']),
|
|
1484
|
+
],
|
|
1485
|
+
sourceIds: ['architecture-model'],
|
|
1486
|
+
},
|
|
1487
|
+
security_compliance: {
|
|
1488
|
+
id: 'security_compliance',
|
|
1489
|
+
title: SECTION_TITLES['security_compliance'],
|
|
1490
|
+
body: [
|
|
1491
|
+
...(securityHighlights.length > 0
|
|
1492
|
+
? securityHighlights
|
|
1493
|
+
: [
|
|
1494
|
+
'- Authentication and authorization approach: Unknown',
|
|
1495
|
+
'- Data classification and compliance posture: Unknown',
|
|
1496
|
+
'- Secret management and key handling: Unknown',
|
|
1497
|
+
]),
|
|
1498
|
+
],
|
|
1499
|
+
sourceIds: ['architecture-model', 'contracts-json'],
|
|
1500
|
+
},
|
|
1501
|
+
local_dev_contribution: {
|
|
1502
|
+
id: 'local_dev_contribution',
|
|
1503
|
+
title: SECTION_TITLES['local_dev_contribution'],
|
|
1504
|
+
body: [
|
|
1505
|
+
'```bash',
|
|
1506
|
+
'./scripts/sdx status',
|
|
1507
|
+
`./scripts/sdx repo sync --org ${context.scope.org}`,
|
|
1508
|
+
`./scripts/sdx map build ${context.mapId}`,
|
|
1509
|
+
`./scripts/sdx contracts extract --map ${context.mapId}`,
|
|
1510
|
+
`./scripts/sdx architecture generate --map ${context.mapId}`,
|
|
1511
|
+
`./scripts/sdx docs readme --map ${context.mapId}`,
|
|
1512
|
+
'```',
|
|
1513
|
+
'',
|
|
1514
|
+
'### Commands found in service docs',
|
|
1515
|
+
...(localDevCommands.length > 0
|
|
1516
|
+
? ['```bash', ...localDevCommands.map((command) => command.replace(/`/g, '')), '```']
|
|
1517
|
+
: ['- Unknown']),
|
|
1518
|
+
],
|
|
1519
|
+
sourceIds: ['scope', 'service-map-json', 'contracts-json'],
|
|
1520
|
+
},
|
|
1521
|
+
runbooks_escalation: {
|
|
1522
|
+
id: 'runbooks_escalation',
|
|
1523
|
+
title: SECTION_TITLES['runbooks_escalation'],
|
|
1524
|
+
body: [
|
|
1525
|
+
...(runbookHighlights.length > 0
|
|
1526
|
+
? runbookHighlights
|
|
1527
|
+
: ['- Runbooks and escalation notes are not explicitly documented in scanned repositories.']),
|
|
1528
|
+
],
|
|
1529
|
+
sourceIds: ['architecture-model', 'repo-sync'],
|
|
1530
|
+
},
|
|
1531
|
+
adr_index: {
|
|
1532
|
+
id: 'adr_index',
|
|
1533
|
+
title: SECTION_TITLES['adr_index'],
|
|
1534
|
+
body: [...(adrLinks.length > 0 ? adrLinks : ['- Unknown'])],
|
|
1535
|
+
sourceIds: ['docs-architecture'],
|
|
1536
|
+
},
|
|
1537
|
+
glossary: {
|
|
1538
|
+
id: 'glossary',
|
|
1539
|
+
title: SECTION_TITLES['glossary'],
|
|
1540
|
+
body: [
|
|
1541
|
+
...(glossaryTerms.length > 0
|
|
1542
|
+
? glossaryTerms.map((term) => `- **${term}**: Refer to service-level docs for context.`)
|
|
1543
|
+
: ['- Unknown']),
|
|
1544
|
+
],
|
|
1545
|
+
sourceIds: ['scope', 'service-map-json', 'contracts-json', 'architecture-model'],
|
|
1546
|
+
},
|
|
1547
|
+
changelog_metadata: {
|
|
1548
|
+
id: 'changelog_metadata',
|
|
1549
|
+
title: SECTION_TITLES['changelog_metadata'],
|
|
1550
|
+
body: [
|
|
1551
|
+
`- Last generated: ${context.sourceSnapshotAt}`,
|
|
1552
|
+
`- Source snapshot: ${context.sourceSnapshotAt}`,
|
|
1553
|
+
`- Map: ${context.mapId}`,
|
|
1554
|
+
`- Tooling: SDX CLI ${(0, version_1.getCliPackageVersion)()} (schema ${constants_1.SCHEMA_VERSION})`,
|
|
1555
|
+
'- Run `./scripts/sdx docs readme --map <map-id> --check` in CI to verify freshness and drift.',
|
|
1556
|
+
],
|
|
1557
|
+
sourceIds: context.sources.map((source) => source.id),
|
|
1558
|
+
},
|
|
1559
|
+
};
|
|
1560
|
+
return exports.README_SECTION_ORDER.map((sectionId) => sectionById[sectionId]);
|
|
1561
|
+
}
|
|
1562
|
+
function renderReadme(sections, context) {
|
|
1563
|
+
const lines = [
|
|
1564
|
+
`# ${context.scope.org} System Architecture`,
|
|
1565
|
+
'',
|
|
1566
|
+
`This README is the architecture entry point for the \`${context.scope.org}\` engineering organization.`,
|
|
1567
|
+
'',
|
|
1568
|
+
`It is generated by SDX from repository docs, contracts, and map artifacts using \`${context.mapId}\`.`,
|
|
1569
|
+
'',
|
|
1570
|
+
'## Table of contents',
|
|
1571
|
+
'',
|
|
1572
|
+
...sections.map((section) => `- [${section.title}](#${sectionAnchor(section.title)})`),
|
|
1573
|
+
'',
|
|
1574
|
+
];
|
|
1575
|
+
for (const section of sections) {
|
|
1576
|
+
lines.push(renderSection(section));
|
|
1577
|
+
}
|
|
1578
|
+
return {
|
|
1579
|
+
content: `${lines.join('\n').trimEnd()}\n`,
|
|
1580
|
+
sourceRefs: context.sources,
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
function checkFailures(existingContent, renderedContent, sources) {
|
|
1584
|
+
const stale = sources.filter((source) => source.required && source.stale);
|
|
1585
|
+
const missing = sources.filter((source) => source.required && !source.exists);
|
|
1586
|
+
const changed = existingContent !== renderedContent;
|
|
1587
|
+
return { stale, missing, changed };
|
|
1588
|
+
}
|
|
1589
|
+
function summarizeResult(outputPath, staleSources, missingSources, changed, checkMode) {
|
|
1590
|
+
const lines = [`README output: ${outputPath}`];
|
|
1591
|
+
lines.push(`Content changed: ${changed ? 'yes' : 'no'}`);
|
|
1592
|
+
lines.push(`Stale sources: ${staleSources.length}`);
|
|
1593
|
+
lines.push(`Missing required sources: ${missingSources.length}`);
|
|
1594
|
+
if (staleSources.length > 0) {
|
|
1595
|
+
lines.push(`Stale source labels: ${staleSources.map((source) => source.label).join(', ')}`);
|
|
1596
|
+
}
|
|
1597
|
+
if (missingSources.length > 0) {
|
|
1598
|
+
lines.push(`Missing source labels: ${missingSources.map((source) => source.label).join(', ')}`);
|
|
1599
|
+
}
|
|
1600
|
+
if (checkMode) {
|
|
1601
|
+
const failed = staleSources.length > 0 || missingSources.length > 0 || changed;
|
|
1602
|
+
lines.push(`Check result: ${failed ? 'FAIL' : 'PASS'}`);
|
|
1603
|
+
}
|
|
1604
|
+
return lines.join('\n');
|
|
1605
|
+
}
|
|
1606
|
+
async function generateReadme(options) {
|
|
1607
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1608
|
+
const outputPath = node_path_1.default.resolve(cwd, options.output ?? 'README.md');
|
|
1609
|
+
const includeSections = options.includeSections ?? [];
|
|
1610
|
+
const excludeSections = options.excludeSections ?? [];
|
|
1611
|
+
if (options.check && options.dryRun) {
|
|
1612
|
+
throw new Error('Use either --check or --dry-run, not both.');
|
|
1613
|
+
}
|
|
1614
|
+
const { config, sourcePath } = loadReadmeConfig(cwd);
|
|
1615
|
+
const selectedSections = selectSections(config, includeSections, excludeSections);
|
|
1616
|
+
const context = await buildReadmeContext(options.mapId, options.db, cwd, outputPath, config);
|
|
1617
|
+
if (sourcePath) {
|
|
1618
|
+
context.sources.push(sourceFromFile('readme-config', 'README config', sourcePath, cwd, context.staleThresholdHours, context.now, false));
|
|
1619
|
+
}
|
|
1620
|
+
const writeEnabled = !options.check && !options.dryRun;
|
|
1621
|
+
const diagramResult = ensureRequiredDiagrams(context, options.db, cwd, writeEnabled);
|
|
1622
|
+
context.sources.push(...diagramResult.diagramSources);
|
|
1623
|
+
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));
|
|
1624
|
+
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));
|
|
1625
|
+
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));
|
|
1626
|
+
context.sourceSnapshotAt = computeSnapshotTimestamp(context.sources, context.now);
|
|
1627
|
+
const orderedSections = buildSections(context).filter((section) => selectedSections.includes(section.id));
|
|
1628
|
+
const existingContent = (0, fs_1.safeReadText)(outputPath);
|
|
1629
|
+
const rendered = renderReadme(orderedSections, context);
|
|
1630
|
+
const { stale, missing, changed } = checkFailures(existingContent, rendered.content, rendered.sourceRefs);
|
|
1631
|
+
const shouldWrite = writeEnabled && changed;
|
|
1632
|
+
if (shouldWrite) {
|
|
1633
|
+
(0, fs_1.writeTextFile)(outputPath, rendered.content);
|
|
1634
|
+
}
|
|
1635
|
+
const diff = options.dryRun || (options.check && changed)
|
|
1636
|
+
? unifiedDiff(existingContent, rendered.content, `${asRelative(outputPath, cwd)}.current`, `${asRelative(outputPath, cwd)}.next`)
|
|
1637
|
+
: undefined;
|
|
1638
|
+
const checkPassed = !options.check || (stale.length === 0 && missing.length === 0 && !changed);
|
|
1639
|
+
return {
|
|
1640
|
+
outputPath,
|
|
1641
|
+
sections: selectedSections,
|
|
1642
|
+
stale: stale.length > 0,
|
|
1643
|
+
staleSources: stale,
|
|
1644
|
+
missingSources: missing,
|
|
1645
|
+
changed,
|
|
1646
|
+
wroteFile: shouldWrite,
|
|
1647
|
+
checkPassed,
|
|
1648
|
+
summary: summarizeResult(outputPath, stale, missing, changed, Boolean(options.check)),
|
|
1649
|
+
diff,
|
|
1650
|
+
};
|
|
1651
|
+
}
|