codesummary 1.2.1 → 1.2.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/CHANGELOG.md +26 -213
- package/README.md +61 -395
- package/features.md +25 -386
- package/package.json +13 -17
- package/src/ai/errors.js +85 -0
- package/src/ai/featureFlags.js +8 -0
- package/src/ai/promptTemplates.js +337 -0
- package/src/ai/providerClient.js +81 -0
- package/src/ai/providers/ollama.js +92 -0
- package/src/ai/providers/openaiCompatible.js +96 -0
- package/src/analysis/repositorySignals.js +196 -0
- package/src/cli.js +819 -77
- package/src/configManager.js +21 -0
- package/src/graph/adapters/baseAdapter.js +24 -0
- package/src/graph/adapters/javascriptAdapter.js +53 -0
- package/src/graph/adapters/pythonAdapter.js +77 -0
- package/src/graph/graphEngine.js +151 -0
- package/src/graph/graphMetrics.js +79 -0
- package/src/graph/graphSchema.js +30 -0
- package/src/graph/universalExtractor.js +29 -0
- package/src/llmGenerator.js +723 -8
- package/src/pdfGenerator.js +1189 -275
- package/src/renderers/llmSummaryRenderer.js +14 -0
- package/src/renderers/pdfThemeRenderer.js +685 -0
- package/src/scanner.js +115 -8
- package/rag-schema.json +0 -114
- package/src/ragConfig.js +0 -369
- package/src/ragGenerator.js +0 -1740
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Renderer for structured repository context artifact (.llmsummary.json).
|
|
5
|
+
*/
|
|
6
|
+
export default class LlmSummaryRenderer {
|
|
7
|
+
async render(outputPath, summaryData) {
|
|
8
|
+
await fs.writeJson(outputPath, summaryData, { spaces: 2 });
|
|
9
|
+
return {
|
|
10
|
+
outputPath
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* PDFThemeRenderer
|
|
10
|
+
* Shared high-end visual primitives for PDFKit reports.
|
|
11
|
+
*/
|
|
12
|
+
export default class PDFThemeRenderer {
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
const defaultFontsDir = path.resolve(__dirname, '../../fonts');
|
|
15
|
+
this.fontsDir = options.fontsDir || defaultFontsDir;
|
|
16
|
+
|
|
17
|
+
// ── Design tokens ─────────────────────────────────────────────────────── //
|
|
18
|
+
this.tokens = {
|
|
19
|
+
// Text hierarchy
|
|
20
|
+
text: '#1A202C',
|
|
21
|
+
textSecond: '#4A5568',
|
|
22
|
+
muted: '#718096',
|
|
23
|
+
faint: '#A0AEC0',
|
|
24
|
+
inverse: '#FFFFFF',
|
|
25
|
+
|
|
26
|
+
// Brand palette — vibrant indigo + cyan
|
|
27
|
+
primary: '#5B50F0',
|
|
28
|
+
primaryDark: '#4338CA',
|
|
29
|
+
primaryLight: '#EEF2FF',
|
|
30
|
+
primaryMid: '#818CF8',
|
|
31
|
+
cyan: '#06B6D4',
|
|
32
|
+
cyanLight: '#ECFEFF',
|
|
33
|
+
green: '#10B981',
|
|
34
|
+
greenLight: '#D1FAE5',
|
|
35
|
+
orange: '#F59E0B',
|
|
36
|
+
orangeLight: '#FEF3C7',
|
|
37
|
+
red: '#EF4444',
|
|
38
|
+
redLight: '#FEE2E2',
|
|
39
|
+
purple: '#8B5CF6',
|
|
40
|
+
purpleLight: '#EDE9FE',
|
|
41
|
+
|
|
42
|
+
// Cover page
|
|
43
|
+
coverBg: '#0D0F1A',
|
|
44
|
+
coverBg2: '#1A0D3D',
|
|
45
|
+
coverTitle: '#FFFFFF',
|
|
46
|
+
coverSub: '#A5B4FC',
|
|
47
|
+
coverMuted: '#4A5568',
|
|
48
|
+
|
|
49
|
+
// Surfaces
|
|
50
|
+
pageBackground: '#F7F8FC',
|
|
51
|
+
headerStrip: '#0D0F1A',
|
|
52
|
+
headerStripText: '#94A3B8',
|
|
53
|
+
cardBackground: '#FFFFFF',
|
|
54
|
+
divider: '#E2E8F0',
|
|
55
|
+
|
|
56
|
+
// Code editor (Catppuccin Mocha)
|
|
57
|
+
codeBg: '#1E1E2E',
|
|
58
|
+
codeFg: '#CDD6F4',
|
|
59
|
+
codeGutter: '#313244',
|
|
60
|
+
codeGutterText: '#6C7086',
|
|
61
|
+
codeTabBg: '#181825',
|
|
62
|
+
codeTabText: '#89B4FA',
|
|
63
|
+
codeBorder: '#45475A',
|
|
64
|
+
|
|
65
|
+
// Tables
|
|
66
|
+
tableHeaderBg: '#1E293B',
|
|
67
|
+
tableHeaderText: '#F1F5F9',
|
|
68
|
+
tableStripe: '#F8FAFC',
|
|
69
|
+
tableRowBorder: '#E2E8F0',
|
|
70
|
+
|
|
71
|
+
// Callouts
|
|
72
|
+
calloutInfoBg: '#EEF2FF',
|
|
73
|
+
calloutInfoBorder: '#818CF8',
|
|
74
|
+
calloutRiskBg: '#FEF2F2',
|
|
75
|
+
calloutRiskBorder: '#FCA5A5',
|
|
76
|
+
calloutRiskText: '#B91C1C',
|
|
77
|
+
calloutSuccessBg: '#F0FDF4',
|
|
78
|
+
calloutSuccessBorder: '#6EE7B7',
|
|
79
|
+
calloutWarningBg: '#FFFBEB',
|
|
80
|
+
calloutWarningBorder: '#FCD34D'
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
this.typeScale = {
|
|
84
|
+
display: 30,
|
|
85
|
+
h1: 22,
|
|
86
|
+
h2: 14,
|
|
87
|
+
h3: 11.5,
|
|
88
|
+
body: 10,
|
|
89
|
+
small: 8.5,
|
|
90
|
+
micro: 7.5
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
this.fontNames = {
|
|
94
|
+
regular: 'Inter-Regular',
|
|
95
|
+
bold: 'Inter-Bold',
|
|
96
|
+
mono: 'FiraCode'
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Font registration ─────────────────────────────────────────────────── //
|
|
101
|
+
|
|
102
|
+
applyBaseTheme(doc) {
|
|
103
|
+
const regular = path.join(this.fontsDir, 'Inter-Regular.ttf');
|
|
104
|
+
const bold = path.join(this.fontsDir, 'Inter-Bold.ttf');
|
|
105
|
+
const mono = path.join(this.fontsDir, 'FiraCode.ttf');
|
|
106
|
+
|
|
107
|
+
const hasRegular = fs.existsSync(regular);
|
|
108
|
+
const hasBold = fs.existsSync(bold);
|
|
109
|
+
const hasMono = fs.existsSync(mono);
|
|
110
|
+
|
|
111
|
+
if (hasRegular) doc.registerFont(this.fontNames.regular, regular);
|
|
112
|
+
if (hasBold) doc.registerFont(this.fontNames.bold, bold);
|
|
113
|
+
if (hasMono) doc.registerFont(this.fontNames.mono, mono);
|
|
114
|
+
|
|
115
|
+
if (!hasRegular) this.fontNames.regular = 'Helvetica';
|
|
116
|
+
if (!hasBold) this.fontNames.bold = 'Helvetica-Bold';
|
|
117
|
+
if (!hasMono) this.fontNames.mono = 'Courier';
|
|
118
|
+
|
|
119
|
+
if (!doc.page?.margins) return;
|
|
120
|
+
doc.font(this.fontNames.regular).fontSize(this.typeScale.body).fillColor(this.tokens.text);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Low-level primitives ──────────────────────────────────────────────── //
|
|
124
|
+
|
|
125
|
+
/** Subtle horizontal rule. */
|
|
126
|
+
drawDivider(doc, y = doc.y, color = null) {
|
|
127
|
+
const left = doc.page.margins.left;
|
|
128
|
+
const right = doc.page.width - doc.page.margins.right;
|
|
129
|
+
doc.save()
|
|
130
|
+
.moveTo(left, y).lineTo(right, y)
|
|
131
|
+
.lineWidth(0.4)
|
|
132
|
+
.strokeColor(color || this.tokens.divider)
|
|
133
|
+
.stroke()
|
|
134
|
+
.restore();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Filled rounded pill. Returns pill width. */
|
|
138
|
+
drawPill(doc, x, y, label, bgColor, textColor, fontSize = 7) {
|
|
139
|
+
const padX = 7, padY = 3;
|
|
140
|
+
doc.font(this.fontNames.bold).fontSize(fontSize);
|
|
141
|
+
const textW = doc.widthOfString(label);
|
|
142
|
+
const pillW = textW + padX * 2;
|
|
143
|
+
const pillH = fontSize + padY * 2;
|
|
144
|
+
doc.save();
|
|
145
|
+
doc.roundedRect(x, y, pillW, pillH, pillH / 2);
|
|
146
|
+
doc.fillColor(bgColor);
|
|
147
|
+
doc.fill();
|
|
148
|
+
doc.restore();
|
|
149
|
+
doc.font(this.fontNames.bold).fontSize(fontSize).fillColor(textColor)
|
|
150
|
+
.text(label, x + padX, y + padY + 0.5, { lineBreak: false });
|
|
151
|
+
return pillW;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Decorative overlapping circles (for cover). */
|
|
155
|
+
drawDecorCircles(doc, cx, cy) {
|
|
156
|
+
const circles = [
|
|
157
|
+
{ r: 190, color: '#5B50F0', opacity: 0.07 },
|
|
158
|
+
{ r: 128, color: '#06B6D4', opacity: 0.11 },
|
|
159
|
+
{ r: 72, color: '#10B981', opacity: 0.20 },
|
|
160
|
+
{ r: 32, color: '#8B5CF6', opacity: 0.42 }
|
|
161
|
+
];
|
|
162
|
+
for (const { r, color, opacity } of circles) {
|
|
163
|
+
doc.save();
|
|
164
|
+
doc.circle(cx, cy, r);
|
|
165
|
+
doc.fillColor(color, opacity);
|
|
166
|
+
doc.fill();
|
|
167
|
+
doc.restore();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Draw a horizontal gradient approximation using N thin rect slices. */
|
|
172
|
+
drawGradientRect(doc, x, y, w, h, colorA, colorB, steps = 24) {
|
|
173
|
+
const rA = parseInt(colorA.slice(1, 3), 16);
|
|
174
|
+
const gA = parseInt(colorA.slice(3, 5), 16);
|
|
175
|
+
const bA = parseInt(colorA.slice(5, 7), 16);
|
|
176
|
+
const rB = parseInt(colorB.slice(1, 3), 16);
|
|
177
|
+
const gB = parseInt(colorB.slice(3, 5), 16);
|
|
178
|
+
const bB = parseInt(colorB.slice(5, 7), 16);
|
|
179
|
+
const sliceW = w / steps;
|
|
180
|
+
for (let i = 0; i < steps; i++) {
|
|
181
|
+
const t = i / (steps - 1);
|
|
182
|
+
const r = Math.round(rA + (rB - rA) * t);
|
|
183
|
+
const g = Math.round(gA + (gB - gA) * t);
|
|
184
|
+
const b = Math.round(bA + (bB - bA) * t);
|
|
185
|
+
const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
|
186
|
+
doc.save().rect(x + i * sliceW, y, sliceW + 0.5, h).fillColor(hex).fill().restore();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Section header ────────────────────────────────────────────────────── //
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Vibrant section header with left accent pill and bold label.
|
|
194
|
+
*/
|
|
195
|
+
renderSectionHeader(doc, title) {
|
|
196
|
+
const left = doc.page.margins.left;
|
|
197
|
+
const available = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
|
198
|
+
const y = Math.max(doc.y, doc.page.margins.top);
|
|
199
|
+
|
|
200
|
+
const barW = 4;
|
|
201
|
+
const lineH = 20;
|
|
202
|
+
|
|
203
|
+
// Left accent bar (slightly wider for more visual weight)
|
|
204
|
+
doc.save();
|
|
205
|
+
doc.roundedRect(left, y, barW, lineH, 2);
|
|
206
|
+
doc.fillColor(this.tokens.primary);
|
|
207
|
+
doc.fill();
|
|
208
|
+
doc.restore();
|
|
209
|
+
|
|
210
|
+
doc.font(this.fontNames.bold)
|
|
211
|
+
.fontSize(this.typeScale.h2)
|
|
212
|
+
.fillColor(this.tokens.text)
|
|
213
|
+
.text(title, left + barW + 10, y + 2, { width: available - barW - 12 });
|
|
214
|
+
|
|
215
|
+
const divY = doc.y + 4;
|
|
216
|
+
this.drawDivider(doc, divY);
|
|
217
|
+
doc.y = divY + 6;
|
|
218
|
+
// Reset cursor to left margin so downstream elements (tables, text) start at the correct x
|
|
219
|
+
doc.x = left;
|
|
220
|
+
doc.font(this.fontNames.regular).fontSize(this.typeScale.body).fillColor(this.tokens.text);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Page banner (AI section title) ───────────────────────────────────── //
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Full-width dark banner (used as a page-level section hero).
|
|
227
|
+
*/
|
|
228
|
+
renderPageBanner(doc, title, subtitle = '') {
|
|
229
|
+
const left = doc.page.margins.left;
|
|
230
|
+
const top = Math.max(doc.y, doc.page.margins.top);
|
|
231
|
+
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
|
232
|
+
const h = subtitle ? 46 : 34;
|
|
233
|
+
|
|
234
|
+
// Banner background
|
|
235
|
+
doc.save()
|
|
236
|
+
.roundedRect(left, top, width, h, 6)
|
|
237
|
+
.fillColor(this.tokens.coverBg)
|
|
238
|
+
.fill()
|
|
239
|
+
.restore();
|
|
240
|
+
|
|
241
|
+
// Accent gradient strip on left edge
|
|
242
|
+
doc.save()
|
|
243
|
+
.roundedRect(left, top, 4, h, 3)
|
|
244
|
+
.fillColor(this.tokens.primary)
|
|
245
|
+
.fill()
|
|
246
|
+
.restore();
|
|
247
|
+
|
|
248
|
+
// AI badge on right
|
|
249
|
+
const badgeLabel = 'AI-POWERED';
|
|
250
|
+
const bPad = 6, bFont = 7;
|
|
251
|
+
doc.save().font(this.fontNames.bold).fontSize(bFont);
|
|
252
|
+
const bW = doc.widthOfString(badgeLabel) + bPad * 2;
|
|
253
|
+
const bH = bFont + bPad - 1;
|
|
254
|
+
const bX = left + width - bW - 10;
|
|
255
|
+
const bY = top + (h - bH) / 2;
|
|
256
|
+
doc.restore();
|
|
257
|
+
doc.save()
|
|
258
|
+
.roundedRect(bX, bY, bW, bH, bH / 2)
|
|
259
|
+
.fillColor(this.tokens.cyan)
|
|
260
|
+
.fill()
|
|
261
|
+
.restore();
|
|
262
|
+
doc.font(this.fontNames.bold).fontSize(bFont).fillColor(this.tokens.coverBg)
|
|
263
|
+
.text(badgeLabel, bX + bPad, bY + (bH - bFont) / 2 + 0.5, { lineBreak: false });
|
|
264
|
+
|
|
265
|
+
// Title text
|
|
266
|
+
doc.font(this.fontNames.bold).fontSize(this.typeScale.h3 + 1).fillColor(this.tokens.inverse)
|
|
267
|
+
.text(title, left + 16, top + (subtitle ? 8 : (h - this.typeScale.h3 - 1) / 2), {
|
|
268
|
+
width: width - bW - 32,
|
|
269
|
+
lineBreak: false
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if (subtitle) {
|
|
273
|
+
doc.font(this.fontNames.regular).fontSize(this.typeScale.small).fillColor(this.tokens.faint)
|
|
274
|
+
.text(subtitle, left + 16, top + 26, { width: width - bW - 32, lineBreak: false });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
doc.y = top + h + 18;
|
|
278
|
+
doc.x = left;
|
|
279
|
+
doc.font(this.fontNames.regular).fontSize(this.typeScale.body).fillColor(this.tokens.text);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── Callout box ───────────────────────────────────────────────────────── //
|
|
283
|
+
|
|
284
|
+
renderCalloutBox(doc, text, type = 'info', width = null) {
|
|
285
|
+
const left = doc.page.margins.left;
|
|
286
|
+
const boxWidth = width || (doc.page.width - doc.page.margins.left - doc.page.margins.right);
|
|
287
|
+
const paddingX = 14, paddingY = 10;
|
|
288
|
+
const y = doc.y;
|
|
289
|
+
|
|
290
|
+
const palette = {
|
|
291
|
+
info: { bg: this.tokens.calloutInfoBg, border: this.tokens.calloutInfoBorder, bar: this.tokens.primary, text: this.tokens.text },
|
|
292
|
+
risk: { bg: this.tokens.calloutRiskBg, border: this.tokens.calloutRiskBorder, bar: this.tokens.red, text: this.tokens.calloutRiskText },
|
|
293
|
+
success: { bg: this.tokens.calloutSuccessBg, border: this.tokens.calloutSuccessBorder, bar: this.tokens.green, text: '#065F46' },
|
|
294
|
+
warning: { bg: this.tokens.calloutWarningBg, border: this.tokens.calloutWarningBorder, bar: this.tokens.orange, text: '#92400E' }
|
|
295
|
+
};
|
|
296
|
+
const { bg, bar, text: textColor } = palette[type] || palette.info;
|
|
297
|
+
|
|
298
|
+
doc.font(this.fontNames.regular).fontSize(this.typeScale.body);
|
|
299
|
+
const textH = doc.heightOfString(text, { width: boxWidth - paddingX * 2 - 4, lineGap: 1.5 });
|
|
300
|
+
const boxH = textH + paddingY * 2;
|
|
301
|
+
|
|
302
|
+
doc.save().roundedRect(left, y, boxWidth, boxH, 6).fillColor(bg).fill().restore();
|
|
303
|
+
doc.save().roundedRect(left, y, 4, boxH, 3).fillColor(bar).fill().restore();
|
|
304
|
+
|
|
305
|
+
doc.font(this.fontNames.regular).fontSize(this.typeScale.body).fillColor(textColor)
|
|
306
|
+
.text(text, left + paddingX + 2, y + paddingY, { width: boxWidth - paddingX * 2 - 4, lineGap: 1.5 });
|
|
307
|
+
|
|
308
|
+
doc.y = y + boxH + 8;
|
|
309
|
+
doc.x = left;
|
|
310
|
+
doc.font(this.fontNames.regular).fontSize(this.typeScale.body).fillColor(this.tokens.text);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Data flow arrow row ───────────────────────────────────────────────── //
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Renders a visual data-flow strip: A -> B -> C with colored pills.
|
|
317
|
+
*/
|
|
318
|
+
renderFlowStrip(doc, flowText) {
|
|
319
|
+
if (!flowText) return;
|
|
320
|
+
const parts = flowText.split(/\s*->\s*/).map(p => p.trim()).filter(Boolean);
|
|
321
|
+
if (parts.length < 2) {
|
|
322
|
+
doc.font(this.fontNames.regular).fontSize(this.typeScale.body)
|
|
323
|
+
.fillColor(this.tokens.primary).text(flowText, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right });
|
|
324
|
+
doc.moveDown(0.5);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const left = doc.page.margins.left;
|
|
329
|
+
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
|
330
|
+
const pillH = 26;
|
|
331
|
+
const arrowW = 20;
|
|
332
|
+
const colors = [this.tokens.primary, this.tokens.cyan, this.tokens.green, this.tokens.orange, this.tokens.purple];
|
|
333
|
+
const fontSize = 8;
|
|
334
|
+
let hPad = 12;
|
|
335
|
+
|
|
336
|
+
doc.font(this.fontNames.bold).fontSize(fontSize);
|
|
337
|
+
|
|
338
|
+
// 1. Calculate items and their required widths
|
|
339
|
+
let items = parts.map(label => ({
|
|
340
|
+
label,
|
|
341
|
+
pW: doc.widthOfString(label) + hPad * 2
|
|
342
|
+
}));
|
|
343
|
+
|
|
344
|
+
// Strategy: If we only slightly overflow (up to 15%), reduce padding to fit
|
|
345
|
+
const totalRawW = items.reduce((s, it) => s + it.pW, 0) + (items.length - 1) * arrowW;
|
|
346
|
+
if (totalRawW > width && totalRawW < width * 1.15) {
|
|
347
|
+
hPad = 8;
|
|
348
|
+
items = parts.map(label => ({
|
|
349
|
+
label,
|
|
350
|
+
pW: doc.widthOfString(label) + hPad * 2
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 2. Group into rows
|
|
355
|
+
const rows = [];
|
|
356
|
+
let currentRow = [];
|
|
357
|
+
let currentRowW = 0;
|
|
358
|
+
|
|
359
|
+
items.forEach((item, i) => {
|
|
360
|
+
const neededW = item.pW + (currentRow.length > 0 ? arrowW : 0);
|
|
361
|
+
|
|
362
|
+
if (currentRowW + neededW > width && currentRow.length > 0) {
|
|
363
|
+
rows.push(currentRow);
|
|
364
|
+
currentRow = [];
|
|
365
|
+
currentRowW = 0;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
currentRow.push(item);
|
|
369
|
+
currentRowW += (currentRow.length === 1 ? item.pW : item.pW + arrowW);
|
|
370
|
+
});
|
|
371
|
+
if (currentRow.length > 0) rows.push(currentRow);
|
|
372
|
+
|
|
373
|
+
// 3. Render rows
|
|
374
|
+
let currentY = doc.y;
|
|
375
|
+
let globalIndex = 0;
|
|
376
|
+
const bottomReserve = 40;
|
|
377
|
+
|
|
378
|
+
rows.forEach((row, rowIndex) => {
|
|
379
|
+
// Page-break guard: if this row doesn't fit, start a new page
|
|
380
|
+
const bottomLimit = doc.page.height - doc.page.margins.bottom - bottomReserve;
|
|
381
|
+
if (currentY + pillH > bottomLimit) {
|
|
382
|
+
doc.addPage();
|
|
383
|
+
currentY = doc.page.margins.top + 10;
|
|
384
|
+
doc.x = left;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Sync PDFKit cursor to our explicit Y before rendering this row.
|
|
388
|
+
// Without this, PDFKit's auto-page logic fires on the next .text() call
|
|
389
|
+
// and silently inserts a blank page mid-strip.
|
|
390
|
+
doc.y = currentY;
|
|
391
|
+
|
|
392
|
+
const rowPillsW = row.reduce((s, it) => s + it.pW, 0);
|
|
393
|
+
const rowArrowsW = (row.length - 1) * arrowW;
|
|
394
|
+
const rowTotalW = rowPillsW + rowArrowsW;
|
|
395
|
+
|
|
396
|
+
let x = left + (width - rowTotalW) / 2;
|
|
397
|
+
|
|
398
|
+
row.forEach((item, colIndex) => {
|
|
399
|
+
const color = colors[globalIndex % colors.length];
|
|
400
|
+
|
|
401
|
+
// Pill background
|
|
402
|
+
doc.save().roundedRect(x, currentY, item.pW, pillH, 6).fillColor(color).fill().restore();
|
|
403
|
+
|
|
404
|
+
// Vertical center calculation: (PillHeight - TextHeight) / 2
|
|
405
|
+
const textH = doc.heightOfString(item.label, { width: item.pW - hPad * 2 });
|
|
406
|
+
const textY = currentY + (pillH - textH) / 2 + 0.5;
|
|
407
|
+
|
|
408
|
+
doc.font(this.fontNames.bold).fontSize(fontSize).fillColor('#FFFFFF')
|
|
409
|
+
.text(item.label, x + hPad, textY, {
|
|
410
|
+
width: item.pW - hPad * 2,
|
|
411
|
+
align: 'center',
|
|
412
|
+
lineBreak: true
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// Restore cursor after .text() to prevent drift / spurious auto-page
|
|
416
|
+
doc.y = currentY;
|
|
417
|
+
doc.x = left;
|
|
418
|
+
|
|
419
|
+
x += item.pW;
|
|
420
|
+
globalIndex++;
|
|
421
|
+
|
|
422
|
+
// Draw arrow if not last in this row
|
|
423
|
+
if (colIndex < row.length - 1) {
|
|
424
|
+
const arrowY = currentY + pillH / 2;
|
|
425
|
+
doc.save()
|
|
426
|
+
.moveTo(x + 4, arrowY).lineTo(x + arrowW - 4, arrowY)
|
|
427
|
+
.lineWidth(1.5).strokeColor(this.tokens.muted).stroke()
|
|
428
|
+
.restore();
|
|
429
|
+
doc.save()
|
|
430
|
+
.polygon([x + arrowW - 6, arrowY - 3], [x + arrowW - 2, arrowY], [x + arrowW - 6, arrowY + 3])
|
|
431
|
+
.fillColor(this.tokens.muted).fill()
|
|
432
|
+
.restore();
|
|
433
|
+
x += arrowW;
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// If there's a next row, draw a vertical continuation line/arrow
|
|
438
|
+
if (rowIndex < rows.length - 1) {
|
|
439
|
+
const lastInRowX = x - row[row.length - 1].pW / 2;
|
|
440
|
+
const connY = currentY + pillH;
|
|
441
|
+
doc.save()
|
|
442
|
+
.moveTo(lastInRowX, connY + 1).lineTo(lastInRowX, connY + 5)
|
|
443
|
+
.lineWidth(1).strokeColor(this.tokens.faint).dash(2, { space: 2 }).stroke()
|
|
444
|
+
.restore();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
currentY += pillH + 12; // Space between rows
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
doc.y = currentY;
|
|
451
|
+
doc.x = left;
|
|
452
|
+
doc.font(this.fontNames.regular).fontSize(this.typeScale.body).fillColor(this.tokens.text);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ── Table ─────────────────────────────────────────────────────────────── //
|
|
456
|
+
|
|
457
|
+
async renderZebraTable(doc, titleOrHeaders, headersOrRows, maybeRows) {
|
|
458
|
+
const hasTitle = typeof titleOrHeaders === 'string';
|
|
459
|
+
const title = hasTitle ? titleOrHeaders : '';
|
|
460
|
+
const headers = hasTitle ? headersOrRows : titleOrHeaders;
|
|
461
|
+
const rows = hasTitle ? maybeRows : headersOrRows;
|
|
462
|
+
|
|
463
|
+
if (!Array.isArray(headers) || headers.length === 0) return;
|
|
464
|
+
if (!Array.isArray(rows) || rows.length === 0) return;
|
|
465
|
+
|
|
466
|
+
const tableWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
|
467
|
+
const rowValues = rows.map(row =>
|
|
468
|
+
Array.isArray(row) ? row.map(v => String(v ?? '')) : headers.map(k => String(row?.[k] ?? ''))
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
const left = doc.page.margins.left;
|
|
472
|
+
doc.x = left;
|
|
473
|
+
|
|
474
|
+
if (title) {
|
|
475
|
+
doc.font(this.fontNames.bold).fontSize(this.typeScale.small)
|
|
476
|
+
.fillColor(this.tokens.muted)
|
|
477
|
+
.text(title.toUpperCase(), { width: tableWidth })
|
|
478
|
+
.moveDown(1.5);
|
|
479
|
+
doc.x = left; // guard: reset cursor after text
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Build header column objects so pdfkit-table applies the background colour
|
|
483
|
+
const headerDefs = headers.map(h => ({
|
|
484
|
+
label: String(h ?? ''),
|
|
485
|
+
headerColor: this.tokens.tableHeaderBg,
|
|
486
|
+
headerOpacity: 1
|
|
487
|
+
}));
|
|
488
|
+
|
|
489
|
+
doc.x = left;
|
|
490
|
+
await doc.table({ headers: headerDefs, rows: rowValues }, {
|
|
491
|
+
x: left,
|
|
492
|
+
width: tableWidth,
|
|
493
|
+
columnSpacing: 8,
|
|
494
|
+
padding: [7, 10, 7, 10],
|
|
495
|
+
prepareHeader: () => {
|
|
496
|
+
doc.font(this.fontNames.bold).fontSize(this.typeScale.body).fillColor(this.tokens.tableHeaderText);
|
|
497
|
+
},
|
|
498
|
+
prepareRow: (row, indexColumn, indexRow, rectRow) => {
|
|
499
|
+
doc.font(this.fontNames.regular).fontSize(this.typeScale.body).fillColor(this.tokens.text);
|
|
500
|
+
if (indexColumn === 0 && indexRow % 2 !== 0 && rectRow) {
|
|
501
|
+
doc.addBackground(rectRow, this.tokens.tableStripe, 1);
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
divider: {
|
|
505
|
+
header: { disabled: false, width: 0.5, opacity: 1, color: this.tokens.tableHeaderBg },
|
|
506
|
+
horizontal: { disabled: false, width: 0.4, opacity: 0.6, color: this.tokens.divider },
|
|
507
|
+
vertical: { disabled: true }
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ── Risk / hotspot cards ──────────────────────────────────────────────── //
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Binary-search the last word boundary where text still fits within availH.
|
|
516
|
+
* Returns [firstPart, remainingPart].
|
|
517
|
+
*/
|
|
518
|
+
_splitTextToFit(doc, text, textWidth, availH, fontName, fontSize, lineGap = 0) {
|
|
519
|
+
doc.font(fontName).fontSize(fontSize);
|
|
520
|
+
if (availH <= 0) return ['', text];
|
|
521
|
+
if (doc.heightOfString(text, { width: textWidth, lineGap }) <= availH) return [text, ''];
|
|
522
|
+
const words = text.split(' ');
|
|
523
|
+
if (words.length <= 1) return ['', text];
|
|
524
|
+
let lo = 1, hi = words.length - 1;
|
|
525
|
+
while (lo < hi) {
|
|
526
|
+
const mid = Math.floor((lo + hi + 1) / 2);
|
|
527
|
+
const h = doc.heightOfString(words.slice(0, mid).join(' '), { width: textWidth, lineGap });
|
|
528
|
+
if (h <= availH) lo = mid; else hi = mid - 1;
|
|
529
|
+
}
|
|
530
|
+
return [words.slice(0, lo).join(' '), words.slice(lo).join(' ')];
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
renderRiskCards(doc, hotspots = []) {
|
|
534
|
+
if (!hotspots?.length) return;
|
|
535
|
+
|
|
536
|
+
const left = doc.page.margins.left;
|
|
537
|
+
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
|
538
|
+
const FOOTER_RESERVE = 16;
|
|
539
|
+
// Offset from card top (after pY) where the reason text starts on first segment
|
|
540
|
+
const HEADER_H = 12;
|
|
541
|
+
|
|
542
|
+
for (const risk of hotspots) {
|
|
543
|
+
const severity = String(risk?.severity || 'HIGH').toUpperCase();
|
|
544
|
+
const isHigh = severity === 'HIGH' || severity === 'CRITICAL';
|
|
545
|
+
const isMed = severity === 'MEDIUM' || severity === 'MED';
|
|
546
|
+
|
|
547
|
+
let bgColor, barColor, titleColor, badgeBg;
|
|
548
|
+
if (isHigh) { bgColor = '#FEF2F2'; barColor = '#EF4444'; titleColor = '#B91C1C'; badgeBg = '#EF4444'; }
|
|
549
|
+
else if (isMed) { bgColor = '#FFFBEB'; barColor = '#F59E0B'; titleColor = '#92400E'; badgeBg = '#F59E0B'; }
|
|
550
|
+
else { bgColor = '#F0FDF4'; barColor = '#10B981'; titleColor = '#065F46'; badgeBg = '#10B981'; }
|
|
551
|
+
|
|
552
|
+
const file = risk?.file || 'Unknown file';
|
|
553
|
+
const pX = 14, pY = 6, barW = 4;
|
|
554
|
+
const textW = width - pX * 2 - barW - 2;
|
|
555
|
+
|
|
556
|
+
// Pre-measure badge width
|
|
557
|
+
doc.save().font(this.fontNames.bold).fontSize(7);
|
|
558
|
+
const bW = doc.widthOfString(severity) + 12;
|
|
559
|
+
doc.restore();
|
|
560
|
+
|
|
561
|
+
let remainingReason = risk?.reason || 'No reason provided.';
|
|
562
|
+
let isFirst = true;
|
|
563
|
+
|
|
564
|
+
while (true) {
|
|
565
|
+
const bottomLimit = doc.page.height - doc.page.margins.bottom - FOOTER_RESERVE;
|
|
566
|
+
const availH = bottomLimit - doc.y;
|
|
567
|
+
|
|
568
|
+
doc.font(this.fontNames.regular).fontSize(this.typeScale.body);
|
|
569
|
+
const oneLineH = doc.currentLineHeight(true);
|
|
570
|
+
// Minimum height to start a card segment: padding + optional header + one line + padding
|
|
571
|
+
const minH = pY + (isFirst ? HEADER_H : 0) + oneLineH + pY;
|
|
572
|
+
|
|
573
|
+
if (availH < minH) { doc.addPage(); continue; }
|
|
574
|
+
|
|
575
|
+
const fullReasonH = remainingReason
|
|
576
|
+
? doc.heightOfString(remainingReason, { width: textW, lineGap: 1.4 })
|
|
577
|
+
: 0;
|
|
578
|
+
const totalH = pY + (isFirst ? HEADER_H : 0) + fullReasonH + pY;
|
|
579
|
+
const y = doc.y;
|
|
580
|
+
|
|
581
|
+
let renderReason = remainingReason;
|
|
582
|
+
let nextReason = '';
|
|
583
|
+
|
|
584
|
+
if (totalH > availH) {
|
|
585
|
+
const textAvailH = availH - pY - (isFirst ? HEADER_H : 0) - pY;
|
|
586
|
+
[renderReason, nextReason] = this._splitTextToFit(
|
|
587
|
+
doc, remainingReason, textW, textAvailH,
|
|
588
|
+
this.fontNames.regular, this.typeScale.body, 1.4
|
|
589
|
+
);
|
|
590
|
+
// If the split didn't yield any text (e.g. first word is too long), jump to next page
|
|
591
|
+
if (!renderReason.trim()) { doc.addPage(); continue; }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
doc.font(this.fontNames.regular).fontSize(this.typeScale.body);
|
|
595
|
+
const segReasonH = renderReason
|
|
596
|
+
? doc.heightOfString(renderReason, { width: textW, lineGap: 1.4 })
|
|
597
|
+
: 0;
|
|
598
|
+
const segH = pY + (isFirst ? HEADER_H : 0) + segReasonH + pY;
|
|
599
|
+
|
|
600
|
+
// Draw card background + severity bar
|
|
601
|
+
doc.save().roundedRect(left, y, width, segH, 5).fillColor(bgColor).fill().restore();
|
|
602
|
+
doc.save().roundedRect(left, y, barW, segH, 3).fillColor(barColor).fill().restore();
|
|
603
|
+
|
|
604
|
+
if (isFirst) {
|
|
605
|
+
// Severity badge
|
|
606
|
+
const bH = 14, bX = left + width - bW - 8, bY2 = y + pY - 1;
|
|
607
|
+
doc.save().roundedRect(bX, bY2, bW, bH, 4).fillColor(badgeBg).fill().restore();
|
|
608
|
+
doc.font(this.fontNames.bold).fontSize(7).fillColor('#FFFFFF')
|
|
609
|
+
.text(severity, bX, bY2 + 3.5, { width: bW, align: 'center', lineBreak: false });
|
|
610
|
+
|
|
611
|
+
// File path
|
|
612
|
+
doc.font(this.fontNames.bold).fontSize(this.typeScale.body).fillColor(titleColor)
|
|
613
|
+
.text(file, left + barW + pX, y + pY, { width: width - pX * 2 - barW - bW - 14, lineBreak: false });
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Reason text
|
|
617
|
+
if (renderReason) {
|
|
618
|
+
doc.font(this.fontNames.regular).fontSize(this.typeScale.body).fillColor(this.tokens.text)
|
|
619
|
+
.text(renderReason, left + barW + pX, y + pY + (isFirst ? HEADER_H : 0), { width: textW, lineGap: 1.4 });
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
doc.y = y + segH + 4;
|
|
623
|
+
doc.x = left;
|
|
624
|
+
|
|
625
|
+
if (!nextReason.trim()) break;
|
|
626
|
+
|
|
627
|
+
remainingReason = nextReason;
|
|
628
|
+
isFirst = false;
|
|
629
|
+
doc.addPage();
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
doc.moveDown(1.5);
|
|
633
|
+
doc.font(this.fontNames.regular).fontSize(this.typeScale.body).fillColor(this.tokens.text);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ── Running page header ───────────────────────────────────────────────── //
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Thin dark strip at the very top of a content page (not cover).
|
|
640
|
+
*/
|
|
641
|
+
renderPageHeader(doc, projectName, pageNum) {
|
|
642
|
+
const w = doc.page.width;
|
|
643
|
+
const h = 28;
|
|
644
|
+
|
|
645
|
+
// Full-width strip
|
|
646
|
+
doc.save().rect(0, 0, w, h).fillColor(this.tokens.headerStrip).fill().restore();
|
|
647
|
+
|
|
648
|
+
// Accent gradient line at bottom edge of strip
|
|
649
|
+
const rA = parseInt(this.tokens.primary.slice(1, 3), 16);
|
|
650
|
+
const gA = parseInt(this.tokens.primary.slice(3, 5), 16);
|
|
651
|
+
const bA = parseInt(this.tokens.primary.slice(5, 7), 16);
|
|
652
|
+
const rB = parseInt(this.tokens.cyan.slice(1, 3), 16);
|
|
653
|
+
const gB = parseInt(this.tokens.cyan.slice(3, 5), 16);
|
|
654
|
+
const bB = parseInt(this.tokens.cyan.slice(5, 7), 16);
|
|
655
|
+
const steps = 32;
|
|
656
|
+
const sliceW = w / steps;
|
|
657
|
+
for (let i = 0; i < steps; i++) {
|
|
658
|
+
const t2 = i / (steps - 1);
|
|
659
|
+
const r = Math.round(rA + (rB - rA) * t2);
|
|
660
|
+
const g = Math.round(gA + (gB - gA) * t2);
|
|
661
|
+
const b = Math.round(bA + (bB - bA) * t2);
|
|
662
|
+
const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
|
663
|
+
doc.save().rect(i * sliceW, h - 1.5, sliceW + 0.5, 1.5).fillColor(hex).fill().restore();
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Project name — vertically centered in strip
|
|
667
|
+
const leftFontSize = 7.5;
|
|
668
|
+
const leftY = (h - leftFontSize) / 2 - 0.5;
|
|
669
|
+
doc.font(this.fontNames.bold).fontSize(leftFontSize).fillColor(this.tokens.headerStripText)
|
|
670
|
+
.text(projectName.toUpperCase(), doc.page.margins.left, leftY, {
|
|
671
|
+
width: 200, lineBreak: false
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// Breadcrumb on right — vertically centered
|
|
675
|
+
const rightFontSize = 7;
|
|
676
|
+
const rightY = (h - rightFontSize) / 2 - 0.5;
|
|
677
|
+
doc.font(this.fontNames.regular).fontSize(rightFontSize).fillColor(this.tokens.primaryMid)
|
|
678
|
+
.text('Code Documentation', w - doc.page.margins.right - 100, rightY, {
|
|
679
|
+
width: 100, align: 'right', lineBreak: false
|
|
680
|
+
});
|
|
681
|
+
const left = doc.page.margins.left;
|
|
682
|
+
doc.x = left;
|
|
683
|
+
doc.y = Math.max(doc.page.margins.top, h + 12);
|
|
684
|
+
}
|
|
685
|
+
}
|