decision-guardian 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +792 -0
  3. package/dist/adapters/github/actions-logger.js +88 -0
  4. package/dist/adapters/github/comment.js +601 -0
  5. package/dist/adapters/github/github-provider.js +260 -0
  6. package/dist/adapters/github/health.js +56 -0
  7. package/dist/adapters/local/console-logger.js +46 -0
  8. package/dist/adapters/local/local-git-provider.js +247 -0
  9. package/dist/cli/commands/check.js +134 -0
  10. package/dist/cli/commands/init.js +58 -0
  11. package/dist/cli/commands/template.js +70 -0
  12. package/dist/cli/formatter.js +68 -0
  13. package/dist/cli/index.js +12458 -0
  14. package/dist/cli/licenses.txt +143 -0
  15. package/dist/cli/paths.js +40 -0
  16. package/dist/core/content-matchers.js +333 -0
  17. package/dist/core/health.js +52 -0
  18. package/dist/core/interfaces/index.js +2 -0
  19. package/dist/core/interfaces/logger.js +2 -0
  20. package/dist/core/interfaces/scm-provider.js +5 -0
  21. package/dist/core/logger.js +20 -0
  22. package/dist/core/matcher.js +184 -0
  23. package/dist/core/metrics.js +87 -0
  24. package/dist/core/parser.js +338 -0
  25. package/dist/core/rule-evaluator.js +186 -0
  26. package/dist/core/rule-parser.js +211 -0
  27. package/dist/core/rule-types.js +22 -0
  28. package/dist/core/trie.js +83 -0
  29. package/dist/core/types.js +2 -0
  30. package/dist/index.js +61142 -0
  31. package/dist/licenses.txt +758 -0
  32. package/dist/main.js +290 -0
  33. package/dist/telemetry/payload.js +25 -0
  34. package/dist/telemetry/privacy.js +37 -0
  35. package/dist/telemetry/sender.js +40 -0
  36. package/dist/version.js +7 -0
  37. package/package.json +60 -0
  38. package/templates/advanced-rules.md +94 -0
  39. package/templates/api.md +70 -0
  40. package/templates/basic.md +38 -0
  41. package/templates/database.md +81 -0
  42. package/templates/security.md +89 -0
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ActionsLogger = void 0;
37
+ /**
38
+ * ActionsLogger — ILogger implementation wrapping @actions/core.
39
+ *
40
+ * Used when running inside the GitHub Actions runtime.
41
+ */
42
+ const core = __importStar(require("@actions/core"));
43
+ class ActionsLogger {
44
+ info(message) {
45
+ core.info(message);
46
+ }
47
+ warning(message) {
48
+ core.warning(message);
49
+ }
50
+ error(message) {
51
+ core.error(message);
52
+ }
53
+ debug(message) {
54
+ core.debug(message);
55
+ }
56
+ startGroup(name) {
57
+ core.startGroup(name);
58
+ }
59
+ endGroup() {
60
+ core.endGroup();
61
+ }
62
+ // ── Actions-Specific Methods ──────────────────────────────────
63
+ /**
64
+ * Set an output variable for subsequent steps.
65
+ */
66
+ setOutput(name, value) {
67
+ core.setOutput(name, value);
68
+ }
69
+ /**
70
+ * Mark the action as failed with an error message.
71
+ */
72
+ setFailed(message) {
73
+ core.setFailed(message);
74
+ }
75
+ /**
76
+ * Get an input variable from the action configuration.
77
+ */
78
+ getInput(name, required) {
79
+ return core.getInput(name, { required });
80
+ }
81
+ /**
82
+ * Get a boolean input variable from the action configuration.
83
+ */
84
+ getBooleanInput(name) {
85
+ return core.getBooleanInput(name);
86
+ }
87
+ }
88
+ exports.ActionsLogger = ActionsLogger;
@@ -0,0 +1,601 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CommentManager = void 0;
37
+ /**
38
+ * CommentManager — GitHub PR commenting logic.
39
+ */
40
+ const github = __importStar(require("@actions/github"));
41
+ const crypto = __importStar(require("crypto"));
42
+ class CommentManager {
43
+ octokit;
44
+ logger;
45
+ MARKER = '<!-- decision-guardian-v1 -->';
46
+ ALL_CLEAR_HASH = 'all-clear';
47
+ MAX_COMMENT_LENGTH = 60000; // GitHub API limit is 65536
48
+ constructor(token, logger) {
49
+ this.octokit = github.getOctokit(token);
50
+ this.logger = logger;
51
+ }
52
+ /**
53
+ * Post or update comment on PR with decision alerts
54
+ */
55
+ async postAlert(matches, prContext) {
56
+ const MAX_RETRIES = 3;
57
+ for (let retry = 0; retry < MAX_RETRIES; retry++) {
58
+ try {
59
+ await this._postAlertAttempt(matches, prContext);
60
+ return;
61
+ }
62
+ catch (error) {
63
+ const errWithStatus = error;
64
+ if (errWithStatus.status === 409 && retry < MAX_RETRIES - 1) {
65
+ this.logger.warning(`Conflict detected when posting comment, retrying... (${retry + 1}/${MAX_RETRIES})`);
66
+ await new Promise((r) => setTimeout(r, 1000 * (retry + 1)));
67
+ continue;
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+ }
73
+ /**
74
+ * Core logic for posting or updating comment
75
+ */
76
+ async _postAlertAttempt(matches, prContext) {
77
+ matches.sort((a, b) => {
78
+ const idCompare = a.decision.id.localeCompare(b.decision.id);
79
+ if (idCompare !== 0)
80
+ return idCompare;
81
+ return a.file.localeCompare(b.file);
82
+ });
83
+ let owner;
84
+ let repo;
85
+ let pull_number;
86
+ if (prContext) {
87
+ owner = prContext.owner;
88
+ repo = prContext.repo;
89
+ pull_number = prContext.number;
90
+ }
91
+ else {
92
+ const context = github.context;
93
+ if (!context.payload.pull_request) {
94
+ this.logger.warning('Not a pull request event, skipping comment');
95
+ return;
96
+ }
97
+ owner = context.repo.owner;
98
+ repo = context.repo.repo;
99
+ pull_number = context.payload.pull_request.number;
100
+ }
101
+ const newHash = this.hashContent(matches);
102
+ const newBody = this.formatComment(matches, newHash);
103
+ const existingComments = await this.findExistingComments(owner, repo, pull_number);
104
+ let targetComment = existingComments.length > 0 ? existingComments[0] : null;
105
+ if (existingComments.length > 1) {
106
+ this.logger.info(`Found ${existingComments.length} duplicate comments, cleaning up...`);
107
+ for (let i = 1; i < existingComments.length; i++) {
108
+ try {
109
+ await this.octokit.rest.issues.deleteComment({
110
+ owner,
111
+ repo,
112
+ comment_id: existingComments[i].id,
113
+ });
114
+ }
115
+ catch (error) {
116
+ this.logger.warning(`Failed to delete duplicate comment ${existingComments[i].id}: ${error}`);
117
+ }
118
+ }
119
+ }
120
+ if (targetComment) {
121
+ const oldHash = this.extractHash(targetComment.body);
122
+ if (oldHash === newHash) {
123
+ this.logger.info('Existing comment is up-to-date, skipping update');
124
+ return;
125
+ }
126
+ try {
127
+ await this.octokit.rest.issues.updateComment({
128
+ owner,
129
+ repo,
130
+ comment_id: targetComment.id,
131
+ body: newBody,
132
+ });
133
+ this.logger.info(`Updated existing comment (${matches.length} matches)`);
134
+ return;
135
+ }
136
+ catch (error) {
137
+ const errWithStatus = error;
138
+ if (errWithStatus.status === 404) {
139
+ this.logger.warning(`Comment ${targetComment.id} was deleted, creating new one`);
140
+ targetComment = null;
141
+ }
142
+ else {
143
+ const message = error instanceof Error ? error.message : String(error);
144
+ this.logger.error(`Failed to update comment: ${message}`);
145
+ throw error;
146
+ }
147
+ }
148
+ }
149
+ if (!targetComment) {
150
+ try {
151
+ await this.octokit.rest.issues.createComment({
152
+ owner,
153
+ repo,
154
+ issue_number: pull_number,
155
+ body: newBody,
156
+ });
157
+ this.logger.info(`Posted new decision alert (${matches.length} matches)`);
158
+ }
159
+ catch (error) {
160
+ const message = error instanceof Error ? error.message : String(error);
161
+ this.logger.error(`Failed to post comment: ${message}`);
162
+ throw error;
163
+ }
164
+ }
165
+ }
166
+ /**
167
+ * Update existing comment to "All Clear" status when no matches are found.
168
+ * Only updates if there was a previous Decision Guardian comment.
169
+ */
170
+ async postAllClear(prContext) {
171
+ let owner;
172
+ let repo;
173
+ let pull_number;
174
+ if (prContext) {
175
+ owner = prContext.owner;
176
+ repo = prContext.repo;
177
+ pull_number = prContext.number;
178
+ }
179
+ else {
180
+ const context = github.context;
181
+ if (!context.payload.pull_request) {
182
+ this.logger.warning('Not a pull request event, skipping all-clear comment');
183
+ return;
184
+ }
185
+ owner = context.repo.owner;
186
+ repo = context.repo.repo;
187
+ pull_number = context.payload.pull_request.number;
188
+ }
189
+ const existingComments = await this.findExistingComments(owner, repo, pull_number);
190
+ if (existingComments.length === 0) {
191
+ this.logger.info('No existing Decision Guardian comment found, skipping all-clear update');
192
+ return;
193
+ }
194
+ const targetComment = existingComments[0];
195
+ const oldHash = this.extractHash(targetComment.body);
196
+ if (oldHash === this.ALL_CLEAR_HASH) {
197
+ this.logger.info('Comment is already showing all-clear status, skipping update');
198
+ return;
199
+ }
200
+ if (existingComments.length > 1) {
201
+ this.logger.info(`Found ${existingComments.length} duplicate comments, cleaning up...`);
202
+ for (let i = 1; i < existingComments.length; i++) {
203
+ try {
204
+ await this.octokit.rest.issues.deleteComment({
205
+ owner,
206
+ repo,
207
+ comment_id: existingComments[i].id,
208
+ });
209
+ }
210
+ catch (error) {
211
+ this.logger.warning(`Failed to delete duplicate comment ${existingComments[i].id}: ${error}`);
212
+ }
213
+ }
214
+ }
215
+ const allClearBody = this.buildAllClearComment();
216
+ try {
217
+ await this.octokit.rest.issues.updateComment({
218
+ owner,
219
+ repo,
220
+ comment_id: targetComment.id,
221
+ body: allClearBody,
222
+ });
223
+ this.logger.info('Updated comment to all-clear status');
224
+ return;
225
+ }
226
+ catch (error) {
227
+ const errWithStatus = error;
228
+ if (errWithStatus.status === 404) {
229
+ this.logger.warning(`Comment ${targetComment.id} was deleted, skipping all-clear update`);
230
+ return;
231
+ }
232
+ const message = error instanceof Error ? error.message : String(error);
233
+ this.logger.error(`Failed to update comment to all-clear status: ${message}`);
234
+ throw error;
235
+ }
236
+ }
237
+ /**
238
+ * Find all existing Decision Guardian comments
239
+ */
240
+ async findExistingComments(owner, repo, issue_number) {
241
+ try {
242
+ const found = [];
243
+ let page = 1;
244
+ const MAX_PAGES = 100; // Prevent infinite loops (10,000 comments max)
245
+ while (page <= MAX_PAGES) {
246
+ const { data } = await this.octokit.rest.issues.listComments({
247
+ owner,
248
+ repo,
249
+ issue_number,
250
+ per_page: 100,
251
+ page,
252
+ });
253
+ const matches = data
254
+ .filter((c) => c.body?.includes(this.MARKER))
255
+ .map((c) => ({ id: c.id, body: c.body || '' }));
256
+ found.push(...matches);
257
+ if (data.length < 100)
258
+ break;
259
+ page++;
260
+ }
261
+ return found;
262
+ }
263
+ catch (error) {
264
+ this.logger.warning('Failed to fetch existing comments, will create new');
265
+ return [];
266
+ }
267
+ }
268
+ /**
269
+ * Generate content hash based on decision IDs, files, and matched patterns
270
+ */
271
+ hashContent(matches) {
272
+ const key = matches
273
+ .map((m) => `${m.decision.id}:${m.file}:${m.matchDetails?.matchedPatterns?.join(',') || ''}`)
274
+ .sort()
275
+ .join('|');
276
+ return crypto.createHash('sha256').update(key, 'utf8').digest('hex').substring(0, 16);
277
+ }
278
+ /**
279
+ * Extract hash from existing comment
280
+ */
281
+ extractHash(commentBody) {
282
+ // Match both hex hashes (16 chars) and special hashes like 'all-clear'
283
+ const match = commentBody.match(/<!-- hash:([a-z0-9-]+) -->/);
284
+ return match ? match[1] : null;
285
+ }
286
+ /**
287
+ * Format matches into markdown comment
288
+ */
289
+ formatComment(matches, hash) {
290
+ const fullComment = this.buildFullComment(matches, hash);
291
+ if (fullComment.length > this.MAX_COMMENT_LENGTH) {
292
+ this.logger.warning(`Comment would exceed ${this.MAX_COMMENT_LENGTH} chars (${fullComment.length}), truncating...`);
293
+ return this.buildTruncatedComment(matches, hash);
294
+ }
295
+ return fullComment;
296
+ }
297
+ /**
298
+ * Build the full comment without truncation
299
+ */
300
+ buildFullComment(matches, hash) {
301
+ const grouped = this.groupBySeverity(matches);
302
+ const uniqueFiles = new Set(matches.map((m) => m.file)).size;
303
+ const uniqueDecisions = new Set(matches.map((m) => m.decision.id)).size;
304
+ let comment = `${this.MARKER}\n`;
305
+ comment += `<!-- hash:${hash} -->\n\n`;
306
+ comment += `## ⚠️ Decision Context Alert\n\n`;
307
+ comment += `This PR modifies **${uniqueFiles} file(s)** that trigger **${uniqueDecisions} architectural decision(s)**.\n\n`;
308
+ if (grouped.critical.length > 0) {
309
+ comment += `### 🔴 Critical Decisions (${grouped.critical.length})\n\n`;
310
+ for (const match of grouped.critical) {
311
+ comment += this.formatMatch(match);
312
+ }
313
+ }
314
+ if (grouped.warning.length > 0) {
315
+ comment += `### 🟡 Important Decisions (${grouped.warning.length})\n\n`;
316
+ for (const match of grouped.warning) {
317
+ comment += this.formatMatch(match);
318
+ }
319
+ }
320
+ if (grouped.info.length > 0) {
321
+ comment += `### ℹ️ Informational (${grouped.info.length})\n\n`;
322
+ for (const match of grouped.info) {
323
+ comment += this.formatMatch(match);
324
+ }
325
+ }
326
+ comment += `\n---\n`;
327
+ comment += `*🤖 Generated by [Decision Guardian](https://github.com/DecispherHQ/decision-guardian). `;
328
+ comment += `Update decisions in your \`.decispher/\` folder if needed.*\n`;
329
+ return comment;
330
+ }
331
+ /**
332
+ * Build the "All Clear" comment when no decision rules are triggered
333
+ */
334
+ buildAllClearComment() {
335
+ let comment = `${this.MARKER}\n`;
336
+ comment += `<!-- hash:${this.ALL_CLEAR_HASH} -->\n\n`;
337
+ comment += `## ✅ Decision Guardian - All Clear\n\n`;
338
+ comment += `This PR no longer modifies any files protected by architectural decisions.\n\n`;
339
+ comment += `> **Great job!** All previously flagged decision contexts have been resolved.\n\n`;
340
+ comment += `---\n`;
341
+ comment += `*🤖 Generated by [Decision Guardian](https://github.com/DecispherHQ/decision-guardian). `;
342
+ comment += `Update decisions in your \`.decispher/\` folder if needed.*\n`;
343
+ return comment;
344
+ }
345
+ /**
346
+ * Build truncated comment when full comment exceeds GitHub's limit
347
+ */
348
+ buildTruncatedComment(matches, hash) {
349
+ const detailLimits = [20, 10, 5, 2, 0];
350
+ const fileLimitsPerDecision = [10, 5, 3, 1];
351
+ for (const maxDetailed of detailLimits) {
352
+ for (const maxFilesPerDecision of fileLimitsPerDecision) {
353
+ const comment = this.buildTruncatedCommentWithLimits(matches, hash, maxDetailed, maxFilesPerDecision);
354
+ if (comment.length <= this.MAX_COMMENT_LENGTH) {
355
+ return comment;
356
+ }
357
+ }
358
+ }
359
+ const ultraCompact = this.buildUltraCompactComment(matches, hash);
360
+ if (ultraCompact.length <= this.MAX_COMMENT_LENGTH) {
361
+ return ultraCompact;
362
+ }
363
+ this.logger.warning(`Comment still too long (${ultraCompact.length} chars), applying hard truncation`);
364
+ return this.hardTruncate(ultraCompact);
365
+ }
366
+ /**
367
+ * Build truncated comment with specific limits for detailed matches and files per decision
368
+ */
369
+ buildTruncatedCommentWithLimits(matches, hash, maxDetailedMatches, maxFilesPerDecision) {
370
+ const grouped = this.groupBySeverity(matches);
371
+ const uniqueFiles = new Set(matches.map((m) => m.file)).size;
372
+ const uniqueDecisions = new Set(matches.map((m) => m.decision.id)).size;
373
+ let comment = `${this.MARKER}\n`;
374
+ comment += `<!-- hash:${hash} -->\n\n`;
375
+ comment += `## ⚠️ Decision Context Alert\n\n`;
376
+ comment += `> **Large PR**: **${uniqueFiles} file(s)** trigger **${uniqueDecisions} decision(s)** - showing summary.\n\n`;
377
+ let detailedCount = 0;
378
+ const remainingMatches = [];
379
+ if (grouped.critical.length > 0) {
380
+ comment += `### 🔴 Critical Decisions (${grouped.critical.length})\n\n`;
381
+ for (const match of grouped.critical) {
382
+ if (detailedCount < maxDetailedMatches) {
383
+ comment += this.formatMatch(match);
384
+ detailedCount++;
385
+ }
386
+ else {
387
+ remainingMatches.push(match);
388
+ }
389
+ }
390
+ }
391
+ if (grouped.warning.length > 0) {
392
+ comment += `### 🟡 Important Decisions (${grouped.warning.length})\n\n`;
393
+ for (const match of grouped.warning) {
394
+ if (detailedCount < maxDetailedMatches) {
395
+ comment += this.formatMatch(match);
396
+ detailedCount++;
397
+ }
398
+ else {
399
+ remainingMatches.push(match);
400
+ }
401
+ }
402
+ }
403
+ if (grouped.info.length > 0) {
404
+ comment += `### ℹ️ Informational (${grouped.info.length})\n\n`;
405
+ for (const match of grouped.info) {
406
+ if (detailedCount < maxDetailedMatches) {
407
+ comment += this.formatMatch(match);
408
+ detailedCount++;
409
+ }
410
+ else {
411
+ remainingMatches.push(match);
412
+ }
413
+ }
414
+ }
415
+ if (remainingMatches.length > 0) {
416
+ comment += this.buildSummarySectionForRemainingWithLimit(remainingMatches, maxFilesPerDecision);
417
+ }
418
+ comment += `\n---\n`;
419
+ comment += `*🤖 Generated by [Decision Guardian](https://github.com/DecispherHQ/decision-guardian). `;
420
+ comment += `Showing ${detailedCount} of ${matches.length} matches.*\n`;
421
+ return comment;
422
+ }
423
+ /**
424
+ * Build ultra-compact comment showing only counts (last resort before hard truncation)
425
+ */
426
+ buildUltraCompactComment(matches, hash) {
427
+ const grouped = this.groupBySeverity(matches);
428
+ const byDecision = new Map();
429
+ for (const match of matches) {
430
+ const existing = byDecision.get(match.decision.id);
431
+ if (existing) {
432
+ existing.count++;
433
+ }
434
+ else {
435
+ byDecision.set(match.decision.id, {
436
+ count: 1,
437
+ severity: match.decision.severity || 'info',
438
+ });
439
+ }
440
+ }
441
+ const uniqueFiles = new Set(matches.map((m) => m.file)).size;
442
+ let comment = `${this.MARKER}\n`;
443
+ comment += `<!-- hash:${hash} -->\n\n`;
444
+ comment += `## ⚠️ Decision Context Alert\n\n`;
445
+ comment += `> ⚠️ **Very Large PR**: **${uniqueFiles} file(s)** trigger **${byDecision.size} decision(s)**.\n\n`;
446
+ comment += `### Summary\n\n`;
447
+ comment += `| Severity | Count |\n`;
448
+ comment += `|----------|-------|\n`;
449
+ comment += `| 🔴 Critical | ${grouped.critical.length} |\n`;
450
+ comment += `| 🟡 Warning | ${grouped.warning.length} |\n`;
451
+ comment += `| ℹ️ Info | ${grouped.info.length} |\n\n`;
452
+ comment += `### Decisions Triggered\n\n`;
453
+ const sortedDecisions = [...byDecision.entries()].sort((a, b) => b[1].count - a[1].count);
454
+ const maxDecisionsToShow = 50;
455
+ for (let i = 0; i < Math.min(sortedDecisions.length, maxDecisionsToShow); i++) {
456
+ const [id, info] = sortedDecisions[i];
457
+ const icon = info.severity === 'critical' ? '🔴' : info.severity === 'warning' ? '🟡' : 'ℹ️';
458
+ comment += `- ${icon} **${id}**: ${info.count} file(s)\n`;
459
+ }
460
+ if (sortedDecisions.length > maxDecisionsToShow) {
461
+ comment += `- *...and ${sortedDecisions.length - maxDecisionsToShow} more decisions*\n`;
462
+ }
463
+ comment += `\n---\n`;
464
+ comment += `*🤖 Generated by [Decision Guardian](https://github.com/DecispherHQ/decision-guardian). `;
465
+ comment += `Details truncated.*\n`;
466
+ return comment;
467
+ }
468
+ /**
469
+ * Hard truncate comment as final safety measure
470
+ */
471
+ hardTruncate(comment) {
472
+ const truncationNotice = `\n\n---\n*⚠️ Comment truncated due to GitHub's 65536 character limit.*\n`;
473
+ const maxLength = this.MAX_COMMENT_LENGTH - truncationNotice.length;
474
+ let breakPoint = comment.lastIndexOf('\n', maxLength);
475
+ if (breakPoint < maxLength * 0.8) {
476
+ breakPoint = maxLength;
477
+ }
478
+ return comment.substring(0, breakPoint) + truncationNotice;
479
+ }
480
+ /**
481
+ * Build a compact summary section for matches that couldn't be shown in detail
482
+ */
483
+ buildSummarySectionForRemaining(matches) {
484
+ return this.buildSummarySectionForRemainingWithLimit(matches, 10);
485
+ }
486
+ /**
487
+ * Build a compact summary section with configurable file limit per decision
488
+ */
489
+ buildSummarySectionForRemainingWithLimit(matches, maxFilesPerDecision) {
490
+ const byDecision = new Map();
491
+ for (const match of matches) {
492
+ const files = byDecision.get(match.decision.id) || [];
493
+ files.push(match.file);
494
+ byDecision.set(match.decision.id, files);
495
+ }
496
+ let section = `\n### 📋 Additional Matches (${matches.length} more)\n\n`;
497
+ section += `<details>\n<summary>Click to expand summary of remaining files</summary>\n\n`;
498
+ for (const [decisionId, files] of byDecision) {
499
+ section += `**${decisionId}** (${files.length} files):\n`;
500
+ const displayFiles = files.slice(0, maxFilesPerDecision);
501
+ const remainingCount = files.length - displayFiles.length;
502
+ for (const file of displayFiles) {
503
+ section += `- \`${file}\`\n`;
504
+ }
505
+ if (remainingCount > 0) {
506
+ section += `- *...and ${remainingCount} more files*\n`;
507
+ }
508
+ section += `\n`;
509
+ }
510
+ section += `</details>\n\n`;
511
+ return section;
512
+ }
513
+ /**
514
+ * Format a single match with link to source, blockquote context, and collapsible for long content
515
+ */
516
+ formatMatch(match) {
517
+ const escapeMarkdown = (str) => {
518
+ return str
519
+ .replace(/[\\`*_{}[\]()#+\-.!]/g, '\\$&')
520
+ .replace(/</g, '&lt;')
521
+ .replace(/>/g, '&gt;');
522
+ };
523
+ const { file, decision, matchedPattern, matchDetails } = match;
524
+ let patternDisplay = matchedPattern;
525
+ if (matchDetails && matchDetails.matchedPatterns && matchDetails.matchedPatterns.length > 0) {
526
+ patternDisplay = matchDetails.matchedPatterns.slice(0, 3).join(', ');
527
+ if (matchDetails.matchedPatterns.length > 3) {
528
+ patternDisplay += ` (+${matchDetails.matchedPatterns.length - 3} more)`;
529
+ }
530
+ }
531
+ const matchType = matchDetails ? 'Rule-based' : 'File pattern';
532
+ // Build source file link (full GitHub blob URL)
533
+ const workspaceRoot = process.env.GITHUB_WORKSPACE || process.cwd();
534
+ let relativeSourceFile = decision.sourceFile;
535
+ const normalizedSource = decision.sourceFile.replace(/\\/g, '/');
536
+ const normalizedWorkspace = workspaceRoot.replace(/\\/g, '/');
537
+ if (normalizedSource.startsWith(normalizedWorkspace)) {
538
+ relativeSourceFile = normalizedSource
539
+ .substring(normalizedWorkspace.length)
540
+ .replace(/^\//, '');
541
+ }
542
+ // Construct full GitHub blob URL for the source file (if context available)
543
+ let sourceLink;
544
+ const context = github.context;
545
+ if (context?.repo?.owner && context?.repo?.repo) {
546
+ const repoUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}`;
547
+ const ref = context.payload?.pull_request?.head?.sha || context.sha || 'HEAD';
548
+ const blobUrl = `${repoUrl}/blob/${ref}/${relativeSourceFile}`;
549
+ sourceLink =
550
+ decision.lineNumber > 0
551
+ ? `[${relativeSourceFile}](${blobUrl}#L${decision.lineNumber})`
552
+ : `[${relativeSourceFile}](${blobUrl})`;
553
+ }
554
+ else {
555
+ // Fallback when context is not available
556
+ sourceLink = `\`${relativeSourceFile}\``;
557
+ }
558
+ // Format context with blockquote, collapsible if long (>300 chars)
559
+ const contextText = decision.context.trim();
560
+ const CONTEXT_THRESHOLD = 300;
561
+ let contextSection;
562
+ if (contextText.length > CONTEXT_THRESHOLD) {
563
+ // Long context: use collapsible
564
+ const preview = contextText.substring(0, 150).trim() + '...';
565
+ contextSection = `> ${escapeMarkdown(preview)}\n\n`;
566
+ contextSection += `<details>\n<summary>📖 Read full context</summary>\n\n`;
567
+ contextSection += `> ${escapeMarkdown(contextText).split('\n').join('\n> ')}\n\n`;
568
+ contextSection += `</details>\n`;
569
+ }
570
+ else {
571
+ // Short context: use simple blockquote
572
+ contextSection = `> ${escapeMarkdown(contextText).split('\n').join('\n> ')}\n`;
573
+ }
574
+ return `
575
+ #### ${escapeMarkdown(decision.id)}: ${escapeMarkdown(decision.title)}
576
+
577
+ | | |
578
+ |---|---|
579
+ | **File** | \`${escapeMarkdown(file)}\` |
580
+ | **Pattern** | \`${escapeMarkdown(patternDisplay)}\` |
581
+ | **Type** | ${matchType} |
582
+ | **Date** | ${escapeMarkdown(decision.date)} |
583
+ | **Source** | ${sourceLink} |
584
+
585
+ ${contextSection}
586
+ ---
587
+
588
+ `;
589
+ }
590
+ /**
591
+ * Group matches by severity
592
+ */
593
+ groupBySeverity(matches) {
594
+ return {
595
+ critical: matches.filter((m) => m.decision.severity === 'critical'),
596
+ warning: matches.filter((m) => m.decision.severity === 'warning'),
597
+ info: matches.filter((m) => m.decision.severity === 'info'),
598
+ };
599
+ }
600
+ }
601
+ exports.CommentManager = CommentManager;