speclock 3.0.0 → 3.5.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.
@@ -0,0 +1,719 @@
1
+ /**
2
+ * SpecLock Policy-as-Code Engine (v3.5)
3
+ * Declarative YAML-based policy rules for enterprise constraint enforcement.
4
+ *
5
+ * Policy files: .speclock/policy.yml
6
+ * Rules match file patterns + action types with enforcement levels.
7
+ * Supports notifications, severity levels, and cross-org import/export.
8
+ *
9
+ * YAML parsing: lightweight built-in parser (no external deps).
10
+ *
11
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
12
+ */
13
+
14
+ import fs from "fs";
15
+ import path from "path";
16
+ import { readBrain, writeBrain, appendEvent, newId, nowIso, bumpEvents } from "./storage.js";
17
+
18
+ // --- Lightweight YAML parser (handles policy.yml subset) ---
19
+
20
+ function parseYaml(text) {
21
+ const lines = text.split("\n");
22
+ const result = {};
23
+ const stack = [{ obj: result, indent: -1 }];
24
+ let currentArray = null;
25
+ let currentArrayKey = null;
26
+ let arrayItemIndent = -1;
27
+
28
+ for (let i = 0; i < lines.length; i++) {
29
+ const raw = lines[i];
30
+ if (!raw.trim() || raw.trim().startsWith("#")) continue;
31
+
32
+ const indent = raw.search(/\S/);
33
+ const trimmed = raw.trim();
34
+
35
+ // Array item
36
+ if (trimmed.startsWith("- ")) {
37
+ const value = trimmed.slice(2).trim();
38
+
39
+ // Array of objects (- key: value)
40
+ if (value.includes(":")) {
41
+ const colonIdx = value.indexOf(":");
42
+ const k = value.slice(0, colonIdx).trim();
43
+ const v = value.slice(colonIdx + 1).trim();
44
+
45
+ if (currentArray && currentArrayKey) {
46
+ const item = {};
47
+ item[k] = parseValue(v);
48
+ currentArray.push(item);
49
+ arrayItemIndent = indent;
50
+
51
+ // Look ahead for more key-value pairs in this item
52
+ let j = i + 1;
53
+ while (j < lines.length) {
54
+ const nextRaw = lines[j];
55
+ if (!nextRaw.trim() || nextRaw.trim().startsWith("#")) { j++; continue; }
56
+ const nextIndent = nextRaw.search(/\S/);
57
+ const nextTrimmed = nextRaw.trim();
58
+ if (nextIndent <= indent) break;
59
+ if (nextTrimmed.startsWith("- ")) break;
60
+
61
+ if (nextTrimmed.includes(":")) {
62
+ const nc = nextTrimmed.indexOf(":");
63
+ const nk = nextTrimmed.slice(0, nc).trim();
64
+ const nv = nextTrimmed.slice(nc + 1).trim();
65
+
66
+ // Empty value — determine type by peeking at next deeper line
67
+ if (!nv) {
68
+ // Peek at the next non-empty line with deeper indent
69
+ let peekJ = j + 1;
70
+ let peekLine = null;
71
+ while (peekJ < lines.length) {
72
+ const pRaw = lines[peekJ];
73
+ if (pRaw.trim() && !pRaw.trim().startsWith("#")) {
74
+ const pIndent = pRaw.search(/\S/);
75
+ if (pIndent > nextIndent) {
76
+ peekLine = { indent: pIndent, trimmed: pRaw.trim() };
77
+ }
78
+ break;
79
+ }
80
+ peekJ++;
81
+ }
82
+
83
+ if (!peekLine) {
84
+ // No deeper content — empty string
85
+ item[nk] = "";
86
+ j++;
87
+ continue;
88
+ }
89
+
90
+ if (peekLine.trimmed.startsWith("- ")) {
91
+ // It's a flat array
92
+ item[nk] = [];
93
+ let k2 = j + 1;
94
+ while (k2 < lines.length) {
95
+ const subRaw = lines[k2];
96
+ if (!subRaw.trim() || subRaw.trim().startsWith("#")) { k2++; continue; }
97
+ const subIndent = subRaw.search(/\S/);
98
+ const subTrimmed = subRaw.trim();
99
+ if (subIndent <= nextIndent) break;
100
+ if (subTrimmed.startsWith("- ")) {
101
+ item[nk].push(parseValue(subTrimmed.slice(2).trim()));
102
+ }
103
+ k2++;
104
+ }
105
+ j = k2;
106
+ continue;
107
+ }
108
+
109
+ if (peekLine.trimmed.includes(":")) {
110
+ // It's a nested object
111
+ item[nk] = {};
112
+ let k2 = j + 1;
113
+ while (k2 < lines.length) {
114
+ const subRaw = lines[k2];
115
+ if (!subRaw.trim() || subRaw.trim().startsWith("#")) { k2++; continue; }
116
+ const subIndent = subRaw.search(/\S/);
117
+ const subTrimmed = subRaw.trim();
118
+ if (subIndent <= nextIndent) break;
119
+
120
+ if (subTrimmed.includes(":")) {
121
+ const sc = subTrimmed.indexOf(":");
122
+ const sk = subTrimmed.slice(0, sc).trim();
123
+ const sv = subTrimmed.slice(sc + 1).trim();
124
+
125
+ if (!sv) {
126
+ // Nested-nested: peek to determine array vs object
127
+ let peek2 = k2 + 1;
128
+ let isNestedArray = false;
129
+ while (peek2 < lines.length) {
130
+ const p2Raw = lines[peek2];
131
+ if (p2Raw.trim() && !p2Raw.trim().startsWith("#")) {
132
+ const p2Indent = p2Raw.search(/\S/);
133
+ if (p2Indent > subIndent && p2Raw.trim().startsWith("- ")) {
134
+ isNestedArray = true;
135
+ }
136
+ break;
137
+ }
138
+ peek2++;
139
+ }
140
+
141
+ if (isNestedArray) {
142
+ item[nk][sk] = [];
143
+ let k3 = k2 + 1;
144
+ while (k3 < lines.length) {
145
+ const sub2Raw = lines[k3];
146
+ if (!sub2Raw.trim() || sub2Raw.trim().startsWith("#")) { k3++; continue; }
147
+ const sub2Indent = sub2Raw.search(/\S/);
148
+ const sub2Trimmed = sub2Raw.trim();
149
+ if (sub2Indent <= subIndent) break;
150
+ if (sub2Trimmed.startsWith("- ")) {
151
+ item[nk][sk].push(parseValue(sub2Trimmed.slice(2).trim()));
152
+ }
153
+ k3++;
154
+ }
155
+ k2 = k3;
156
+ continue;
157
+ } else {
158
+ item[nk][sk] = "";
159
+ }
160
+ } else {
161
+ item[nk][sk] = parseValue(sv);
162
+ }
163
+ }
164
+ k2++;
165
+ }
166
+ j = k2;
167
+ continue;
168
+ }
169
+
170
+ // Fallback — treat as empty string
171
+ item[nk] = "";
172
+ j++;
173
+ continue;
174
+ }
175
+
176
+ item[nk] = parseValue(nv);
177
+ }
178
+ j++;
179
+ }
180
+ i = j - 1;
181
+ }
182
+ continue;
183
+ }
184
+
185
+ // Simple array item
186
+ if (currentArray) {
187
+ currentArray.push(parseValue(value));
188
+ }
189
+ continue;
190
+ }
191
+
192
+ // Key-value pair
193
+ if (trimmed.includes(":")) {
194
+ const colonIdx = trimmed.indexOf(":");
195
+ const key = trimmed.slice(0, colonIdx).trim();
196
+ const value = trimmed.slice(colonIdx + 1).trim();
197
+
198
+ // Pop stack to find parent
199
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
200
+ stack.pop();
201
+ }
202
+ const parent = stack[stack.length - 1].obj;
203
+
204
+ if (value === "" || value === undefined) {
205
+ // Could be object or array — peek ahead
206
+ const nextLine = lines[i + 1];
207
+ if (nextLine && nextLine.trim().startsWith("- ")) {
208
+ parent[key] = [];
209
+ currentArray = parent[key];
210
+ currentArrayKey = key;
211
+ arrayItemIndent = -1;
212
+ } else {
213
+ parent[key] = {};
214
+ stack.push({ obj: parent[key], indent });
215
+ currentArray = null;
216
+ currentArrayKey = null;
217
+ }
218
+ } else {
219
+ parent[key] = parseValue(value);
220
+ currentArray = null;
221
+ currentArrayKey = null;
222
+ }
223
+ }
224
+ }
225
+
226
+ return result;
227
+ }
228
+
229
+ function parseValue(str) {
230
+ if (!str) return "";
231
+ // Remove quotes
232
+ if ((str.startsWith('"') && str.endsWith('"')) || (str.startsWith("'") && str.endsWith("'"))) {
233
+ return str.slice(1, -1);
234
+ }
235
+ // Boolean
236
+ if (str === "true") return true;
237
+ if (str === "false") return false;
238
+ // Number
239
+ if (/^-?\d+(\.\d+)?$/.test(str)) return Number(str);
240
+ // Array shorthand [a, b, c]
241
+ if (str.startsWith("[") && str.endsWith("]")) {
242
+ const inner = str.slice(1, -1).trim();
243
+ if (inner === "") return [];
244
+ return inner.split(",").map(s => parseValue(s.trim()));
245
+ }
246
+ return str;
247
+ }
248
+
249
+ // --- Serialize to YAML ---
250
+
251
+ function toYaml(obj, indent = 0) {
252
+ const pad = " ".repeat(indent);
253
+ let out = "";
254
+
255
+ for (const [key, value] of Object.entries(obj)) {
256
+ if (Array.isArray(value)) {
257
+ if (value.length === 0) {
258
+ out += `${pad}${key}: []\n`;
259
+ continue;
260
+ }
261
+ out += `${pad}${key}:\n`;
262
+ for (const item of value) {
263
+ if (typeof item === "object" && item !== null) {
264
+ const entries = Object.entries(item);
265
+ if (entries.length > 0) {
266
+ out += `${pad} - ${entries[0][0]}: ${formatValue(entries[0][1])}\n`;
267
+ for (let i = 1; i < entries.length; i++) {
268
+ const [k, v] = entries[i];
269
+ if (Array.isArray(v)) {
270
+ if (v.length === 0) {
271
+ out += `${pad} ${k}: []\n`;
272
+ } else {
273
+ out += `${pad} ${k}:\n`;
274
+ for (const sv of v) {
275
+ out += `${pad} - ${formatValue(sv)}\n`;
276
+ }
277
+ }
278
+ } else if (typeof v === "object" && v !== null) {
279
+ out += `${pad} ${k}:\n`;
280
+ out += toYaml(v, indent + 3);
281
+ } else {
282
+ out += `${pad} ${k}: ${formatValue(v)}\n`;
283
+ }
284
+ }
285
+ }
286
+ } else {
287
+ out += `${pad} - ${formatValue(item)}\n`;
288
+ }
289
+ }
290
+ } else if (typeof value === "object" && value !== null) {
291
+ out += `${pad}${key}:\n`;
292
+ out += toYaml(value, indent + 1);
293
+ } else {
294
+ out += `${pad}${key}: ${formatValue(value)}\n`;
295
+ }
296
+ }
297
+
298
+ return out;
299
+ }
300
+
301
+ function formatValue(v) {
302
+ if (typeof v === "string") {
303
+ // Quote strings that look like numbers but should stay as strings (e.g., version "1.0")
304
+ if (/^-?\d+(\.\d+)?$/.test(v)) {
305
+ return `"${v}"`;
306
+ }
307
+ if (v.includes(":") || v.includes("#") || v.includes("'") || v.includes('"') || v.startsWith("*")) {
308
+ return `"${v.replace(/"/g, '\\"')}"`;
309
+ }
310
+ return v;
311
+ }
312
+ if (typeof v === "boolean" || typeof v === "number") return String(v);
313
+ return String(v);
314
+ }
315
+
316
+ // --- Policy file paths ---
317
+
318
+ function policyPath(root) {
319
+ return path.join(root, ".speclock", "policy.yml");
320
+ }
321
+
322
+ // --- Default policy template ---
323
+
324
+ function defaultPolicy() {
325
+ return {
326
+ version: "1.0",
327
+ name: "Default Policy",
328
+ description: "SpecLock policy-as-code rules",
329
+ rules: [],
330
+ notifications: {
331
+ enabled: false,
332
+ channels: [],
333
+ },
334
+ };
335
+ }
336
+
337
+ // --- Policy CRUD ---
338
+
339
+ /**
340
+ * Load policy from .speclock/policy.yml
341
+ */
342
+ export function loadPolicy(root) {
343
+ const p = policyPath(root);
344
+ if (!fs.existsSync(p)) return null;
345
+ try {
346
+ const raw = fs.readFileSync(p, "utf-8");
347
+ return parseYaml(raw);
348
+ } catch {
349
+ return null;
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Save policy to .speclock/policy.yml
355
+ */
356
+ export function savePolicy(root, policy) {
357
+ const p = policyPath(root);
358
+ const yaml = `# SpecLock Policy-as-Code\n# Generated at ${nowIso()}\n# Docs: https://github.com/sgroy10/speclock\n\n${toYaml(policy)}`;
359
+ fs.writeFileSync(p, yaml);
360
+ }
361
+
362
+ /**
363
+ * Initialize policy with default template
364
+ */
365
+ export function initPolicy(root) {
366
+ const existing = loadPolicy(root);
367
+ if (existing) {
368
+ return { success: false, error: "Policy already exists. Use loadPolicy to read it." };
369
+ }
370
+ const policy = defaultPolicy();
371
+ savePolicy(root, policy);
372
+
373
+ // Log event
374
+ const brain = readBrain(root);
375
+ if (brain) {
376
+ const eventId = newId("evt");
377
+ appendEvent(root, { eventId, type: "policy_created", at: nowIso(), summary: "Policy-as-code initialized" });
378
+ bumpEvents(brain, eventId);
379
+ writeBrain(root, brain);
380
+ }
381
+
382
+ return { success: true, policy };
383
+ }
384
+
385
+ /**
386
+ * Add a policy rule
387
+ */
388
+ export function addPolicyRule(root, rule) {
389
+ let policy = loadPolicy(root);
390
+ if (!policy) {
391
+ policy = defaultPolicy();
392
+ }
393
+ if (!Array.isArray(policy.rules)) policy.rules = [];
394
+
395
+ // Validate rule
396
+ if (!rule.name) return { success: false, error: "Rule name is required." };
397
+ if (!rule.match) return { success: false, error: "Rule match criteria required (files, actions)." };
398
+ if (!rule.enforce) rule.enforce = "warn";
399
+
400
+ const ruleId = newId("rule");
401
+ const policyRule = {
402
+ id: ruleId,
403
+ name: rule.name,
404
+ description: rule.description || "",
405
+ match: {
406
+ files: rule.match.files || ["**/*"],
407
+ actions: rule.match.actions || ["modify", "delete"],
408
+ },
409
+ enforce: rule.enforce, // block, warn, log
410
+ severity: rule.severity || "medium",
411
+ notify: rule.notify || [],
412
+ active: true,
413
+ createdAt: nowIso(),
414
+ };
415
+
416
+ policy.rules.push(policyRule);
417
+ savePolicy(root, policy);
418
+
419
+ // Log event
420
+ const brain = readBrain(root);
421
+ if (brain) {
422
+ const eventId = newId("evt");
423
+ appendEvent(root, { eventId, type: "policy_rule_added", at: nowIso(), summary: `Policy rule added: ${rule.name}`, ruleId });
424
+ bumpEvents(brain, eventId);
425
+ writeBrain(root, brain);
426
+ }
427
+
428
+ return { success: true, ruleId, rule: policyRule };
429
+ }
430
+
431
+ /**
432
+ * Remove a policy rule by ID
433
+ */
434
+ export function removePolicyRule(root, ruleId) {
435
+ const policy = loadPolicy(root);
436
+ if (!policy || !Array.isArray(policy.rules)) {
437
+ return { success: false, error: "No policy found." };
438
+ }
439
+
440
+ const idx = policy.rules.findIndex(r => r.id === ruleId);
441
+ if (idx === -1) {
442
+ return { success: false, error: `Rule not found: ${ruleId}` };
443
+ }
444
+
445
+ const removed = policy.rules.splice(idx, 1)[0];
446
+ savePolicy(root, policy);
447
+
448
+ // Log event
449
+ const brain = readBrain(root);
450
+ if (brain) {
451
+ const eventId = newId("evt");
452
+ appendEvent(root, { eventId, type: "policy_rule_removed", at: nowIso(), summary: `Policy rule removed: ${removed.name}`, ruleId });
453
+ bumpEvents(brain, eventId);
454
+ writeBrain(root, brain);
455
+ }
456
+
457
+ return { success: true, removed };
458
+ }
459
+
460
+ /**
461
+ * List all policy rules
462
+ */
463
+ export function listPolicyRules(root) {
464
+ const policy = loadPolicy(root);
465
+ if (!policy || !Array.isArray(policy.rules)) {
466
+ return { rules: [], total: 0, active: 0 };
467
+ }
468
+ return {
469
+ rules: policy.rules,
470
+ total: policy.rules.length,
471
+ active: policy.rules.filter(r => r.active !== false).length,
472
+ };
473
+ }
474
+
475
+ // --- Policy Evaluation ---
476
+
477
+ /**
478
+ * Match a file path against glob patterns (simple matcher)
479
+ */
480
+ function matchesPattern(filePath, pattern) {
481
+ const normalized = filePath.replace(/\\/g, "/");
482
+ const patternNorm = pattern.replace(/\\/g, "/");
483
+
484
+ // Exact match
485
+ if (normalized === patternNorm) return true;
486
+
487
+ // Convert glob to regex — use placeholders to avoid interference between conversions
488
+ const regex = patternNorm
489
+ .replace(/\./g, "\\.")
490
+ .replace(/\*\*\//g, "\x00GLOBSTAR\x00")
491
+ .replace(/\*\*/g, "\x00DSTAR\x00")
492
+ .replace(/\*/g, "[^/]*")
493
+ .replace(/\?/g, ".")
494
+ .replace(/\x00GLOBSTAR\x00/g, "(.+/)?")
495
+ .replace(/\x00DSTAR\x00/g, ".*");
496
+
497
+ try {
498
+ return new RegExp(`^${regex}$`, "i").test(normalized);
499
+ } catch {
500
+ return false;
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Evaluate policy rules against a proposed action
506
+ * Returns violations for any matching rules
507
+ */
508
+ export function evaluatePolicy(root, action) {
509
+ const policy = loadPolicy(root);
510
+ if (!policy || !Array.isArray(policy.rules) || policy.rules.length === 0) {
511
+ return { violations: [], passed: true, rulesChecked: 0 };
512
+ }
513
+
514
+ const activeRules = policy.rules.filter(r => r.active !== false);
515
+ const violations = [];
516
+
517
+ for (const rule of activeRules) {
518
+ const match = matchesPolicyRule(rule, action);
519
+ if (match.matched) {
520
+ violations.push({
521
+ ruleId: rule.id,
522
+ ruleName: rule.name,
523
+ description: rule.description,
524
+ enforce: rule.enforce,
525
+ severity: rule.severity,
526
+ matchedFiles: match.matchedFiles,
527
+ matchedAction: match.matchedAction,
528
+ notify: rule.notify || [],
529
+ });
530
+ }
531
+ }
532
+
533
+ const blocked = violations.some(v => v.enforce === "block");
534
+
535
+ return {
536
+ violations,
537
+ passed: violations.length === 0,
538
+ blocked,
539
+ rulesChecked: activeRules.length,
540
+ };
541
+ }
542
+
543
+ /**
544
+ * Check if a rule matches an action
545
+ */
546
+ function matchesPolicyRule(rule, action) {
547
+ const result = { matched: false, matchedFiles: [], matchedAction: null };
548
+
549
+ if (!rule.match) return result;
550
+
551
+ // Check action type match
552
+ const actionTypes = rule.match.actions || ["modify", "delete", "create", "export"];
553
+ const actionType = action.type || "modify";
554
+ const actionMatched = actionTypes.includes(actionType) || actionTypes.includes("*");
555
+ if (!actionMatched) return result;
556
+
557
+ // Check file pattern match
558
+ const filePatterns = rule.match.files || ["**/*"];
559
+ const files = action.files || [];
560
+
561
+ if (files.length === 0) {
562
+ // No specific files — check if action description matches rule semantically
563
+ const actionText = (action.description || action.text || "").toLowerCase();
564
+ const ruleName = (rule.name || "").toLowerCase();
565
+ const ruleDesc = (rule.description || "").toLowerCase();
566
+
567
+ // Simple keyword overlap check
568
+ const ruleWords = `${ruleName} ${ruleDesc}`.split(/\s+/).filter(w => w.length > 3);
569
+ const actionWords = actionText.split(/\s+/).filter(w => w.length > 3);
570
+ const overlap = ruleWords.filter(w => actionWords.some(aw => aw.includes(w) || w.includes(aw)));
571
+
572
+ if (overlap.length > 0) {
573
+ result.matched = true;
574
+ result.matchedAction = actionType;
575
+ }
576
+ return result;
577
+ }
578
+
579
+ // Match files against patterns
580
+ for (const file of files) {
581
+ for (const pattern of filePatterns) {
582
+ if (matchesPattern(file, pattern)) {
583
+ result.matchedFiles.push(file);
584
+ break;
585
+ }
586
+ }
587
+ }
588
+
589
+ if (result.matchedFiles.length > 0) {
590
+ result.matched = true;
591
+ result.matchedAction = actionType;
592
+ }
593
+
594
+ return result;
595
+ }
596
+
597
+ // --- Policy Import/Export ---
598
+
599
+ /**
600
+ * Export policy as portable YAML string
601
+ */
602
+ export function exportPolicy(root) {
603
+ const policy = loadPolicy(root);
604
+ if (!policy) {
605
+ return { success: false, error: "No policy found." };
606
+ }
607
+
608
+ // Strip internal IDs and timestamps for portability
609
+ const portable = {
610
+ version: policy.version || "1.0",
611
+ name: policy.name || "Exported Policy",
612
+ description: policy.description || "",
613
+ rules: (Array.isArray(policy.rules) ? policy.rules : []).map(r => ({
614
+ name: r.name,
615
+ description: r.description || "",
616
+ match: r.match,
617
+ enforce: r.enforce,
618
+ severity: r.severity,
619
+ notify: r.notify || [],
620
+ })),
621
+ notifications: policy.notifications || { enabled: false, channels: [] },
622
+ };
623
+
624
+ return { success: true, yaml: toYaml(portable), policy: portable };
625
+ }
626
+
627
+ /**
628
+ * Import policy from YAML string (merges or replaces)
629
+ */
630
+ export function importPolicy(root, yamlString, mode = "merge") {
631
+ let imported;
632
+ try {
633
+ imported = parseYaml(yamlString);
634
+ } catch (err) {
635
+ return { success: false, error: `Failed to parse YAML: ${err.message}` };
636
+ }
637
+
638
+ if (!imported.rules || !Array.isArray(imported.rules)) {
639
+ return { success: false, error: "Invalid policy: missing 'rules' array." };
640
+ }
641
+
642
+ let policy = loadPolicy(root);
643
+ if (!policy || mode === "replace") {
644
+ policy = defaultPolicy();
645
+ }
646
+ if (!Array.isArray(policy.rules)) policy.rules = [];
647
+
648
+ let added = 0;
649
+ for (const rule of imported.rules) {
650
+ if (!rule.name) continue;
651
+
652
+ // Check for duplicate names in merge mode
653
+ if (mode === "merge") {
654
+ const exists = policy.rules.some(r => r.name === rule.name);
655
+ if (exists) continue;
656
+ }
657
+
658
+ const ruleId = newId("rule");
659
+ policy.rules.push({
660
+ id: ruleId,
661
+ name: rule.name,
662
+ description: rule.description || "",
663
+ match: rule.match || { files: ["**/*"], actions: ["modify"] },
664
+ enforce: rule.enforce || "warn",
665
+ severity: rule.severity || "medium",
666
+ notify: rule.notify || [],
667
+ active: true,
668
+ createdAt: nowIso(),
669
+ imported: true,
670
+ });
671
+ added++;
672
+ }
673
+
674
+ if (imported.notifications) {
675
+ policy.notifications = imported.notifications;
676
+ }
677
+
678
+ savePolicy(root, policy);
679
+
680
+ // Log event
681
+ const brain = readBrain(root);
682
+ if (brain) {
683
+ const eventId = newId("evt");
684
+ appendEvent(root, { eventId, type: "policy_imported", at: nowIso(), summary: `Policy imported (${mode}): ${added} rules added` });
685
+ bumpEvents(brain, eventId);
686
+ writeBrain(root, brain);
687
+ }
688
+
689
+ return { success: true, added, total: policy.rules.length, mode };
690
+ }
691
+
692
+ // --- Notification helpers ---
693
+
694
+ /**
695
+ * Generate notification payloads for policy violations
696
+ * Returns webhook-ready payloads (actual sending done by integrations)
697
+ */
698
+ export function generateNotifications(violations, projectName) {
699
+ const notifications = [];
700
+
701
+ for (const v of violations) {
702
+ if (!v.notify || v.notify.length === 0) continue;
703
+
704
+ for (const channel of v.notify) {
705
+ notifications.push({
706
+ channel,
707
+ severity: v.severity,
708
+ rule: v.ruleName,
709
+ description: v.description,
710
+ enforce: v.enforce,
711
+ matchedFiles: v.matchedFiles || [],
712
+ project: projectName || "unknown",
713
+ timestamp: nowIso(),
714
+ });
715
+ }
716
+ }
717
+
718
+ return notifications;
719
+ }