guardlink 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +43 -1
  3. package/dist/agents/launcher.d.ts +1 -1
  4. package/dist/agents/launcher.js +1 -1
  5. package/dist/cli/index.d.ts +2 -0
  6. package/dist/cli/index.d.ts.map +1 -1
  7. package/dist/cli/index.js +300 -54
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/mcp/server.d.ts +1 -0
  14. package/dist/mcp/server.d.ts.map +1 -1
  15. package/dist/mcp/server.js +38 -1
  16. package/dist/mcp/server.js.map +1 -1
  17. package/dist/mcp/suggest.d.ts +1 -0
  18. package/dist/mcp/suggest.d.ts.map +1 -1
  19. package/dist/mcp/suggest.js +1 -0
  20. package/dist/mcp/suggest.js.map +1 -1
  21. package/dist/parser/parse-project.d.ts.map +1 -1
  22. package/dist/parser/parse-project.js +103 -0
  23. package/dist/parser/parse-project.js.map +1 -1
  24. package/dist/tui/commands.d.ts +3 -0
  25. package/dist/tui/commands.d.ts.map +1 -1
  26. package/dist/tui/commands.js +297 -39
  27. package/dist/tui/commands.js.map +1 -1
  28. package/dist/tui/index.d.ts.map +1 -1
  29. package/dist/tui/index.js +17 -1
  30. package/dist/tui/index.js.map +1 -1
  31. package/dist/types/index.d.ts +39 -0
  32. package/dist/types/index.d.ts.map +1 -1
  33. package/dist/workspace/index.d.ts +12 -0
  34. package/dist/workspace/index.d.ts.map +1 -0
  35. package/dist/workspace/index.js +9 -0
  36. package/dist/workspace/index.js.map +1 -0
  37. package/dist/workspace/link.d.ts +91 -0
  38. package/dist/workspace/link.d.ts.map +1 -0
  39. package/dist/workspace/link.js +581 -0
  40. package/dist/workspace/link.js.map +1 -0
  41. package/dist/workspace/merge.d.ts +104 -0
  42. package/dist/workspace/merge.d.ts.map +1 -0
  43. package/dist/workspace/merge.js +752 -0
  44. package/dist/workspace/merge.js.map +1 -0
  45. package/dist/workspace/metadata.d.ts +34 -0
  46. package/dist/workspace/metadata.d.ts.map +1 -0
  47. package/dist/workspace/metadata.js +181 -0
  48. package/dist/workspace/metadata.js.map +1 -0
  49. package/dist/workspace/types.d.ts +134 -0
  50. package/dist/workspace/types.d.ts.map +1 -0
  51. package/dist/workspace/types.js +12 -0
  52. package/dist/workspace/types.js.map +1 -0
  53. package/package.json +1 -1
@@ -0,0 +1,752 @@
1
+ /**
2
+ * GuardLink Workspace — Merge engine for multi-repo reports.
3
+ *
4
+ * Takes N per-repo report JSONs and produces a unified MergedReport
5
+ * with cross-repo tag resolution, warning detection, and aggregated stats.
6
+ *
7
+ * @asset Workspace.Merge (#merge-engine) -- "Cross-repo threat model unification"
8
+ * @threat Tag_Collision (#tag-collision) [medium] -- "Duplicate tag definitions across repos"
9
+ * @mitigates #merge-engine against #tag-collision using #prefix-ownership -- "Tag prefix determines owning repo"
10
+ * @flows ReportJSON -> #merge-engine via mergeReports -- "Per-repo reports feed into merge"
11
+ * @flows #merge-engine -> MergedReport via mergeReports -- "Unified output"
12
+ */
13
+ import { readFile } from 'node:fs/promises';
14
+ import { basename } from 'node:path';
15
+ import { REPORT_SCHEMA_VERSION } from './metadata.js';
16
+ /**
17
+ * Load a single report JSON file. Returns the parsed model + repo name.
18
+ * Throws on missing file or invalid JSON; caller handles gracefully.
19
+ */
20
+ export async function loadReportJson(filePath) {
21
+ const raw = await readFile(filePath, 'utf-8');
22
+ const model = JSON.parse(raw);
23
+ // Determine repo name: prefer metadata.repo, fall back to project, then filename
24
+ const repo = model.metadata?.repo
25
+ || model.project
26
+ || basename(filePath, '.json').replace(/^guardlink-report-?/, '') || 'unknown';
27
+ return { repo, model, source_path: filePath };
28
+ }
29
+ /**
30
+ * Attempt to load multiple report files. Returns loaded reports + statuses.
31
+ * Missing or invalid files produce a RepoStatus with loaded=false rather than throwing.
32
+ */
33
+ export async function loadAllReports(filePaths, expectedRepos) {
34
+ const reports = [];
35
+ const statuses = [];
36
+ for (const fp of filePaths) {
37
+ try {
38
+ const report = await loadReportJson(fp);
39
+ reports.push(report);
40
+ statuses.push({
41
+ name: report.repo,
42
+ loaded: true,
43
+ generated_at: report.model.metadata?.generated_at || report.model.generated_at,
44
+ commit_sha: report.model.metadata?.commit_sha ?? undefined,
45
+ annotation_count: report.model.annotations_parsed,
46
+ });
47
+ }
48
+ catch (err) {
49
+ const name = basename(fp, '.json').replace(/^guardlink-report-?/, '') || fp;
50
+ statuses.push({
51
+ name,
52
+ loaded: false,
53
+ error: err instanceof Error ? err.message : String(err),
54
+ });
55
+ }
56
+ }
57
+ // Flag expected repos that had no report file at all
58
+ if (expectedRepos) {
59
+ const loadedNames = new Set(statuses.map(s => s.name));
60
+ for (const repo of expectedRepos) {
61
+ if (!loadedNames.has(repo)) {
62
+ statuses.push({ name: repo, loaded: false, error: 'No report file provided' });
63
+ }
64
+ }
65
+ }
66
+ return { reports, statuses };
67
+ }
68
+ // ─── Tag Registry ────────────────────────────────────────────────────
69
+ /**
70
+ * Extract all tag definitions (assets, threats, controls) from a ThreatModel.
71
+ * Tags come from the `id` field (e.g. "#payment-svc.refund").
72
+ */
73
+ function extractTagDefinitions(model, repo) {
74
+ const tags = [];
75
+ for (const a of model.assets) {
76
+ if (a.id) {
77
+ tags.push({ tag: a.id, owner_repo: repo, kind: 'asset' });
78
+ // Also register with # prefix since relationships use #tag form
79
+ if (!a.id.startsWith('#'))
80
+ tags.push({ tag: `#${a.id}`, owner_repo: repo, kind: 'asset' });
81
+ }
82
+ }
83
+ for (const t of model.threats) {
84
+ if (t.id) {
85
+ tags.push({ tag: t.id, owner_repo: repo, kind: 'threat' });
86
+ if (!t.id.startsWith('#'))
87
+ tags.push({ tag: `#${t.id}`, owner_repo: repo, kind: 'threat' });
88
+ }
89
+ }
90
+ for (const c of model.controls) {
91
+ if (c.id) {
92
+ tags.push({ tag: c.id, owner_repo: repo, kind: 'control' });
93
+ if (!c.id.startsWith('#'))
94
+ tags.push({ tag: `#${c.id}`, owner_repo: repo, kind: 'control' });
95
+ }
96
+ }
97
+ return tags;
98
+ }
99
+ /**
100
+ * Build a unified tag registry from all loaded reports.
101
+ *
102
+ * Ownership rule: the repo whose name matches the tag prefix owns it.
103
+ * e.g. "#payment-svc.refund" → owned by repo "payment-svc" (or "payment-service").
104
+ * If no prefix match, first definition wins.
105
+ *
106
+ * Returns the registry + any duplicate-tag warnings.
107
+ */
108
+ export function buildTagRegistry(reports) {
109
+ const warnings = [];
110
+ const repoNames = new Set(reports.map(r => r.repo));
111
+ // Collect all definitions grouped by tag
112
+ const definitionsByTag = new Map();
113
+ for (const report of reports) {
114
+ const defs = extractTagDefinitions(report.model, report.repo);
115
+ for (const def of defs) {
116
+ const existing = definitionsByTag.get(def.tag) || [];
117
+ existing.push(def);
118
+ definitionsByTag.set(def.tag, existing);
119
+ }
120
+ }
121
+ // Resolve ownership: prefix match > first definition
122
+ const registry = [];
123
+ for (const [tag, defs] of definitionsByTag) {
124
+ if (defs.length === 1) {
125
+ registry.push(defs[0]);
126
+ continue;
127
+ }
128
+ // Multiple repos define this tag — find best owner
129
+ const prefixOwner = inferOwnerFromPrefix(tag, repoNames);
130
+ const winner = prefixOwner
131
+ ? defs.find(d => d.owner_repo === prefixOwner) || defs[0]
132
+ : defs[0];
133
+ registry.push(winner);
134
+ // Warn about duplicates
135
+ const otherRepos = defs
136
+ .filter(d => d.owner_repo !== winner.owner_repo)
137
+ .map(d => d.owner_repo);
138
+ if (otherRepos.length > 0) {
139
+ warnings.push({
140
+ level: 'warning',
141
+ code: 'duplicate_tag',
142
+ message: `Tag "${tag}" defined in ${winner.owner_repo} (owner) and also in: ${otherRepos.join(', ')}`,
143
+ repos: [winner.owner_repo, ...otherRepos],
144
+ tag,
145
+ });
146
+ }
147
+ }
148
+ return { registry, warnings };
149
+ }
150
+ /**
151
+ * Normalize a tag for comparison: strip leading '#'.
152
+ * Asset ids are stored as "parser" but references use "#parser".
153
+ */
154
+ function normalizeTag(tag) {
155
+ return tag.startsWith('#') ? tag.slice(1) : tag;
156
+ }
157
+ /**
158
+ * Infer which repo owns a tag based on its prefix.
159
+ * "#payment-svc.refund" → look for repo named "payment-svc" or "payment-service".
160
+ * Returns null if no prefix match found.
161
+ */
162
+ function inferOwnerFromPrefix(tag, repoNames) {
163
+ // Strip leading # if present
164
+ const clean = tag.startsWith('#') ? tag.slice(1) : tag;
165
+ const dotIdx = clean.indexOf('.');
166
+ if (dotIdx === -1)
167
+ return null; // no service prefix
168
+ const prefix = clean.slice(0, dotIdx);
169
+ // Direct match
170
+ if (repoNames.has(prefix))
171
+ return prefix;
172
+ // Fuzzy: "payment-svc" matches "payment-service" (prefix is substring or vice versa)
173
+ for (const repo of repoNames) {
174
+ if (repo.startsWith(prefix) || prefix.startsWith(repo))
175
+ return repo;
176
+ // Also try with common suffix variations: -svc, -service, -lib, -api
177
+ const normalized = repo.replace(/-(?:service|svc|lib|api|worker)$/, '');
178
+ const prefixNorm = prefix.replace(/-(?:service|svc|lib|api|worker)$/, '');
179
+ if (normalized === prefixNorm)
180
+ return repo;
181
+ }
182
+ return null;
183
+ }
184
+ // ─── Cross-Repo Reference Resolution ─────────────────────────────────
185
+ /**
186
+ * Collect all tag references from relationship annotations (mitigates, exposes,
187
+ * flows, etc.) and check which ones resolve to the tag registry.
188
+ *
189
+ * Returns unresolved refs + additional warnings.
190
+ */
191
+ export function resolveReferences(reports, registry, repoNames) {
192
+ // Build tag set with both "#tag" and "tag" forms for lookup
193
+ const tagSet = new Set();
194
+ for (const t of registry) {
195
+ tagSet.add(t.tag);
196
+ tagSet.add(normalizeTag(t.tag));
197
+ }
198
+ const unresolved = [];
199
+ const warnings = [];
200
+ for (const report of reports) {
201
+ const m = report.model;
202
+ // Gather all tag references used in relationship annotations
203
+ const refs = collectTagReferences(m, report.repo);
204
+ for (const ref of refs) {
205
+ if (tagSet.has(ref.tag) || tagSet.has(normalizeTag(ref.tag)))
206
+ continue; // resolved
207
+ // Check if the tag prefix suggests a known repo (but definition is missing)
208
+ const prefix = inferOwnerFromPrefix(ref.tag, repoNames);
209
+ unresolved.push({
210
+ ...ref,
211
+ source_repo: report.repo,
212
+ inferred_repo: prefix ?? undefined,
213
+ });
214
+ }
215
+ }
216
+ // Generate warnings for unresolved refs
217
+ // Group by tag for cleaner output
218
+ const byTag = new Map();
219
+ for (const u of unresolved) {
220
+ const existing = byTag.get(u.tag) || [];
221
+ existing.push(u);
222
+ byTag.set(u.tag, existing);
223
+ }
224
+ for (const [tag, refs] of byTag) {
225
+ const repos = [...new Set(refs.map(r => r.source_repo))];
226
+ const inferred = refs[0].inferred_repo;
227
+ const detail = inferred
228
+ ? ` (prefix suggests repo "${inferred}" but no definition found)`
229
+ : '';
230
+ warnings.push({
231
+ level: 'warning',
232
+ code: 'unresolved_ref',
233
+ message: `Tag "${tag}" referenced in ${repos.join(', ')} but not defined in any repo${detail}`,
234
+ repos,
235
+ tag,
236
+ });
237
+ }
238
+ // Also warn about tag prefixes that don't match any known repo
239
+ for (const entry of registry) {
240
+ const clean = entry.tag.startsWith('#') ? entry.tag.slice(1) : entry.tag;
241
+ const dotIdx = clean.indexOf('.');
242
+ if (dotIdx === -1)
243
+ continue;
244
+ const prefix = clean.slice(0, dotIdx);
245
+ if (!inferOwnerFromPrefix(entry.tag, repoNames)) {
246
+ warnings.push({
247
+ level: 'info',
248
+ code: 'tag_prefix_mismatch',
249
+ message: `Tag "${entry.tag}" has prefix "${prefix}" which doesn't match any workspace repo`,
250
+ repos: [entry.owner_repo],
251
+ tag: entry.tag,
252
+ });
253
+ }
254
+ }
255
+ return { unresolved, warnings };
256
+ }
257
+ /**
258
+ * Collect all tag references from a ThreatModel's relationship annotations.
259
+ * These are the tags used in mitigates, exposes, flows, etc. — NOT definitions.
260
+ */
261
+ function collectTagReferences(m, _repo) {
262
+ const refs = [];
263
+ // Helper: add tag ref only if it looks like a tag reference (starts with #)
264
+ // Plain text like "EnvVars", "FileSystem" in flows are descriptive, not cross-repo refs
265
+ const addRef = (tag, verb, loc) => {
266
+ if (!tag)
267
+ return;
268
+ if (!tag.startsWith('#'))
269
+ return; // not a tag reference
270
+ refs.push({ tag, context_verb: verb, location: loc });
271
+ };
272
+ for (const mit of m.mitigations) {
273
+ addRef(mit.asset, 'mitigates', mit.location);
274
+ addRef(mit.threat, 'mitigates', mit.location);
275
+ if (mit.control)
276
+ addRef(mit.control, 'mitigates', mit.location);
277
+ }
278
+ for (const exp of m.exposures) {
279
+ addRef(exp.asset, 'exposes', exp.location);
280
+ addRef(exp.threat, 'exposes', exp.location);
281
+ }
282
+ for (const acc of m.acceptances) {
283
+ addRef(acc.asset, 'accepts', acc.location);
284
+ addRef(acc.threat, 'accepts', acc.location);
285
+ }
286
+ for (const tr of m.transfers) {
287
+ addRef(tr.source, 'transfers', tr.location);
288
+ addRef(tr.target, 'transfers', tr.location);
289
+ addRef(tr.threat, 'transfers', tr.location);
290
+ }
291
+ for (const fl of m.flows) {
292
+ addRef(fl.source, 'flows', fl.location);
293
+ addRef(fl.target, 'flows', fl.location);
294
+ }
295
+ for (const b of m.boundaries) {
296
+ addRef(b.asset_a, 'boundary', b.location);
297
+ addRef(b.asset_b, 'boundary', b.location);
298
+ }
299
+ for (const v of m.validations) {
300
+ addRef(v.control, 'validates', v.location);
301
+ addRef(v.asset, 'validates', v.location);
302
+ }
303
+ return refs;
304
+ }
305
+ // ─── Model Merging ───────────────────────────────────────────────────
306
+ /**
307
+ * Prefix all file paths in a SourceLocation with the repo name
308
+ * so merged output shows "payment-service/src/routes/refund.ts:42"
309
+ */
310
+ function prefixLocation(loc, repo) {
311
+ return {
312
+ ...loc,
313
+ file: `${repo}/${loc.file}`,
314
+ };
315
+ }
316
+ /** Prefix locations on an array of items that have a `location` field */
317
+ function prefixAll(items, repo) {
318
+ return items.map(item => ({ ...item, location: prefixLocation(item.location, repo) }));
319
+ }
320
+ /**
321
+ * Combine multiple ThreatModels into a single unified model.
322
+ * File paths are prefixed with repo name for disambiguation.
323
+ * Deduplication is by tag ID for definitions (assets/threats/controls).
324
+ * Relationships are kept from all repos (no dedup — same relationship
325
+ * stated in two repos is meaningful).
326
+ */
327
+ export function combineModels(reports) {
328
+ const seenAssetIds = new Set();
329
+ const seenThreatIds = new Set();
330
+ const seenControlIds = new Set();
331
+ const combined = {
332
+ version: REPORT_SCHEMA_VERSION,
333
+ project: reports.length > 0 ? (reports[0].model.metadata?.workspace || 'workspace') : 'workspace',
334
+ generated_at: new Date().toISOString(),
335
+ source_files: 0,
336
+ annotations_parsed: 0,
337
+ annotated_files: [],
338
+ unannotated_files: [],
339
+ assets: [],
340
+ threats: [],
341
+ controls: [],
342
+ mitigations: [],
343
+ exposures: [],
344
+ acceptances: [],
345
+ transfers: [],
346
+ flows: [],
347
+ boundaries: [],
348
+ validations: [],
349
+ audits: [],
350
+ ownership: [],
351
+ data_handling: [],
352
+ assumptions: [],
353
+ shields: [],
354
+ comments: [],
355
+ coverage: { total_symbols: 0, annotated_symbols: 0, coverage_percent: 0, unannotated_critical: [] },
356
+ };
357
+ for (const { repo, model: m } of reports) {
358
+ combined.source_files += m.source_files;
359
+ combined.annotations_parsed += m.annotations_parsed;
360
+ combined.annotated_files.push(...m.annotated_files.map(f => `${repo}/${f}`));
361
+ combined.unannotated_files.push(...m.unannotated_files.map(f => `${repo}/${f}`));
362
+ // Definitions: dedup by tag ID, keep first (registry determines owner)
363
+ for (const a of m.assets) {
364
+ if (a.id && seenAssetIds.has(a.id))
365
+ continue;
366
+ if (a.id)
367
+ seenAssetIds.add(a.id);
368
+ combined.assets.push({ ...a, location: prefixLocation(a.location, repo) });
369
+ }
370
+ for (const t of m.threats) {
371
+ if (t.id && seenThreatIds.has(t.id))
372
+ continue;
373
+ if (t.id)
374
+ seenThreatIds.add(t.id);
375
+ combined.threats.push({ ...t, location: prefixLocation(t.location, repo) });
376
+ }
377
+ for (const c of m.controls) {
378
+ if (c.id && seenControlIds.has(c.id))
379
+ continue;
380
+ if (c.id)
381
+ seenControlIds.add(c.id);
382
+ combined.controls.push({ ...c, location: prefixLocation(c.location, repo) });
383
+ }
384
+ // Relationships: keep all (no dedup — cross-repo relationships are valuable)
385
+ combined.mitigations.push(...prefixAll(m.mitigations, repo));
386
+ combined.exposures.push(...prefixAll(m.exposures, repo));
387
+ combined.acceptances.push(...prefixAll(m.acceptances, repo));
388
+ combined.transfers.push(...prefixAll(m.transfers, repo));
389
+ combined.flows.push(...prefixAll(m.flows, repo));
390
+ combined.boundaries.push(...prefixAll(m.boundaries, repo));
391
+ combined.validations.push(...prefixAll(m.validations, repo));
392
+ combined.audits.push(...prefixAll(m.audits, repo));
393
+ combined.ownership.push(...prefixAll(m.ownership, repo));
394
+ combined.data_handling.push(...prefixAll(m.data_handling, repo));
395
+ combined.assumptions.push(...prefixAll(m.assumptions, repo));
396
+ combined.shields.push(...prefixAll(m.shields, repo));
397
+ combined.comments.push(...prefixAll(m.comments, repo));
398
+ // Aggregate coverage
399
+ combined.coverage.total_symbols += m.coverage.total_symbols;
400
+ combined.coverage.annotated_symbols += m.coverage.annotated_symbols;
401
+ }
402
+ // Recompute coverage percent
403
+ combined.coverage.coverage_percent = combined.coverage.total_symbols > 0
404
+ ? Math.round((combined.coverage.annotated_symbols / combined.coverage.total_symbols) * 100)
405
+ : 0;
406
+ return combined;
407
+ }
408
+ // ─── Totals & Unmitigated Detection ──────────────────────────────────
409
+ /**
410
+ * Count unmitigated exposures: exposures with no corresponding mitigation
411
+ * (same asset+threat pair) and no acceptance.
412
+ */
413
+ function countUnmitigated(model) {
414
+ const mitigatedPairs = new Set(model.mitigations.map(m => `${m.asset}::${m.threat}`));
415
+ const acceptedPairs = new Set(model.acceptances.map(a => `${a.asset}::${a.threat}`));
416
+ return model.exposures.filter(e => {
417
+ const key = `${e.asset}::${e.threat}`;
418
+ return !mitigatedPairs.has(key) && !acceptedPairs.has(key);
419
+ }).length;
420
+ }
421
+ /** Compute aggregate totals from a combined model */
422
+ export function computeTotals(model, statuses, resolvedCount, unresolvedCount) {
423
+ return {
424
+ repos: statuses.length,
425
+ repos_loaded: statuses.filter(s => s.loaded).length,
426
+ annotations: model.annotations_parsed,
427
+ assets: model.assets.length,
428
+ threats: model.threats.length,
429
+ controls: model.controls.length,
430
+ mitigations: model.mitigations.length,
431
+ exposures: model.exposures.length,
432
+ unmitigated_exposures: countUnmitigated(model),
433
+ acceptances: model.acceptances.length,
434
+ flows: model.flows.length,
435
+ boundaries: model.boundaries.length,
436
+ external_refs_resolved: resolvedCount,
437
+ external_refs_unresolved: unresolvedCount,
438
+ };
439
+ }
440
+ /**
441
+ * Main entry point: merge N report JSON files into a unified MergedReport.
442
+ *
443
+ * 1. Load all report JSONs (partial load on failure)
444
+ * 2. Build tag registry (who owns each tag)
445
+ * 3. Resolve cross-repo references
446
+ * 4. Combine all ThreatModels into one
447
+ * 5. Compute totals + warnings
448
+ * 6. Return MergedReport
449
+ */
450
+ export async function mergeReports(filePaths, options = {}) {
451
+ const staleHours = options.staleThresholdHours ?? 168;
452
+ // 1. Load reports
453
+ const { reports, statuses } = await loadAllReports(filePaths, options.expectedRepos);
454
+ if (reports.length === 0) {
455
+ // Return empty merged report with all repos marked as failed
456
+ return emptyMergedReport(options.workspace || 'unknown', statuses);
457
+ }
458
+ // 2. Build tag registry
459
+ const { registry, warnings: tagWarnings } = buildTagRegistry(reports);
460
+ // 3. Resolve cross-repo references
461
+ const repoNames = new Set(reports.map(r => r.repo));
462
+ const { unresolved, warnings: refWarnings } = resolveReferences(reports, registry, repoNames);
463
+ // Count resolved: total refs from external_refs fields minus unresolved
464
+ const totalExternalRefs = reports.reduce((sum, r) => sum + (r.model.external_refs?.length || 0), 0);
465
+ const resolvedCount = Math.max(0, totalExternalRefs - unresolved.length);
466
+ // 4. Combine models
467
+ const combinedModel = combineModels(reports);
468
+ // 5. Detect stale reports
469
+ const staleWarnings = detectStaleReports(statuses, staleHours);
470
+ // 6. Schema mismatch warnings
471
+ const schemaWarnings = detectSchemaMismatch(reports);
472
+ // Determine workspace name
473
+ const workspaceName = options.workspace
474
+ || reports.find(r => r.model.metadata?.workspace)?.model.metadata?.workspace
475
+ || 'workspace';
476
+ // Assemble all warnings
477
+ const allWarnings = [...tagWarnings, ...refWarnings, ...staleWarnings, ...schemaWarnings];
478
+ // Missing repo warnings
479
+ for (const s of statuses) {
480
+ if (!s.loaded) {
481
+ allWarnings.push({
482
+ level: 'warning',
483
+ code: 'missing_repo',
484
+ message: `Repo "${s.name}" report not loaded: ${s.error || 'unknown error'}`,
485
+ repos: [s.name],
486
+ });
487
+ }
488
+ }
489
+ return {
490
+ workspace: workspaceName,
491
+ merged_at: new Date().toISOString(),
492
+ schema_version: REPORT_SCHEMA_VERSION,
493
+ repo_statuses: statuses,
494
+ tag_registry: registry,
495
+ unresolved_refs: unresolved,
496
+ warnings: allWarnings,
497
+ totals: computeTotals(combinedModel, statuses, resolvedCount, unresolved.length),
498
+ model: combinedModel,
499
+ };
500
+ }
501
+ // ─── Helper Functions ─────────────────────────────────────────────────
502
+ function detectStaleReports(statuses, staleHours) {
503
+ const warnings = [];
504
+ const now = Date.now();
505
+ const threshold = staleHours * 60 * 60 * 1000;
506
+ for (const s of statuses) {
507
+ if (!s.loaded || !s.generated_at)
508
+ continue;
509
+ const age = now - new Date(s.generated_at).getTime();
510
+ if (age > threshold) {
511
+ const days = Math.round(age / (24 * 60 * 60 * 1000));
512
+ warnings.push({
513
+ level: 'warning',
514
+ code: 'stale_report',
515
+ message: `Repo "${s.name}" report is ${days} day(s) old (generated ${s.generated_at})`,
516
+ repos: [s.name],
517
+ });
518
+ }
519
+ }
520
+ return warnings;
521
+ }
522
+ function detectSchemaMismatch(reports) {
523
+ const versions = new Set(reports.map(r => r.model.metadata?.schema_version).filter(Boolean));
524
+ if (versions.size <= 1)
525
+ return [];
526
+ return [{
527
+ level: 'warning',
528
+ code: 'schema_mismatch',
529
+ message: `Reports use different schema versions: ${[...versions].join(', ')}. Results may be inconsistent.`,
530
+ repos: reports.map(r => r.repo),
531
+ }];
532
+ }
533
+ function emptyMergedReport(workspace, statuses) {
534
+ return {
535
+ workspace,
536
+ merged_at: new Date().toISOString(),
537
+ schema_version: REPORT_SCHEMA_VERSION,
538
+ repo_statuses: statuses,
539
+ tag_registry: [],
540
+ unresolved_refs: [],
541
+ warnings: statuses.map(s => ({
542
+ level: 'warning',
543
+ code: 'missing_repo',
544
+ message: `Repo "${s.name}" report not loaded: ${s.error || 'unknown error'}`,
545
+ repos: [s.name],
546
+ })),
547
+ totals: {
548
+ repos: statuses.length, repos_loaded: 0, annotations: 0, assets: 0,
549
+ threats: 0, controls: 0, mitigations: 0, exposures: 0,
550
+ unmitigated_exposures: 0, acceptances: 0, flows: 0, boundaries: 0,
551
+ external_refs_resolved: 0, external_refs_unresolved: 0,
552
+ },
553
+ model: {
554
+ version: REPORT_SCHEMA_VERSION, project: workspace,
555
+ generated_at: new Date().toISOString(), source_files: 0,
556
+ annotations_parsed: 0, annotated_files: [], unannotated_files: [],
557
+ assets: [], threats: [], controls: [], mitigations: [], exposures: [],
558
+ acceptances: [], transfers: [], flows: [], boundaries: [],
559
+ validations: [], audits: [], ownership: [], data_handling: [],
560
+ assumptions: [], shields: [], comments: [],
561
+ coverage: { total_symbols: 0, annotated_symbols: 0, coverage_percent: 0, unannotated_critical: [] },
562
+ },
563
+ };
564
+ }
565
+ // ─── Merge Diff (--diff-against) ─────────────────────────────────────
566
+ /**
567
+ * Compute a diff summary between two merged reports.
568
+ * Used for weekly "what changed" output.
569
+ */
570
+ export function diffMergedReports(current, previous) {
571
+ const c = current.totals;
572
+ const p = previous.totals;
573
+ const prevRepoNames = new Set(previous.repo_statuses.map(s => s.name));
574
+ const currRepoNames = new Set(current.repo_statuses.map(s => s.name));
575
+ // Repos with changed annotation counts or new commits
576
+ const reposWithChanges = [];
577
+ for (const cs of current.repo_statuses) {
578
+ const ps = previous.repo_statuses.find(s => s.name === cs.name);
579
+ if (!ps)
580
+ continue; // new repo, handled separately
581
+ if (cs.annotation_count !== ps.annotation_count || cs.commit_sha !== ps.commit_sha) {
582
+ reposWithChanges.push(cs.name);
583
+ }
584
+ }
585
+ const newUnmitigated = c.unmitigated_exposures - p.unmitigated_exposures;
586
+ const riskDelta = newUnmitigated > 0 ? 'increased' : newUnmitigated < 0 ? 'decreased' : 'unchanged';
587
+ return {
588
+ previous_merged_at: previous.merged_at,
589
+ current_merged_at: current.merged_at,
590
+ assets_added: Math.max(0, c.assets - p.assets),
591
+ assets_removed: Math.max(0, p.assets - c.assets),
592
+ threats_added: Math.max(0, c.threats - p.threats),
593
+ threats_removed: Math.max(0, p.threats - c.threats),
594
+ mitigations_added: Math.max(0, c.mitigations - p.mitigations),
595
+ mitigations_removed: Math.max(0, p.mitigations - c.mitigations),
596
+ exposures_added: Math.max(0, c.exposures - p.exposures),
597
+ exposures_removed: Math.max(0, p.exposures - c.exposures),
598
+ new_unmitigated: Math.max(0, newUnmitigated),
599
+ resolved_unmitigated: Math.max(0, -newUnmitigated),
600
+ risk_delta: riskDelta,
601
+ new_flows: Math.max(0, c.flows - p.flows),
602
+ removed_flows: Math.max(0, p.flows - c.flows),
603
+ new_unresolved_refs: Math.max(0, c.external_refs_unresolved - p.external_refs_unresolved),
604
+ resolved_refs: Math.max(0, p.external_refs_unresolved - c.external_refs_unresolved),
605
+ repos_added: [...currRepoNames].filter(n => !prevRepoNames.has(n)),
606
+ repos_removed: [...prevRepoNames].filter(n => !currRepoNames.has(n)),
607
+ repos_with_changes: reposWithChanges,
608
+ };
609
+ }
610
+ /**
611
+ * Format a diff summary as markdown for weekly reports / Slack / email.
612
+ */
613
+ export function formatDiffSummary(diff, workspace) {
614
+ const lines = [];
615
+ const riskIcon = diff.risk_delta === 'increased' ? '🔴'
616
+ : diff.risk_delta === 'decreased' ? '🟢' : '⚪';
617
+ lines.push(`# ${workspace} — Weekly Threat Model Changes`);
618
+ lines.push('');
619
+ lines.push(`**Period:** ${diff.previous_merged_at.slice(0, 10)} → ${diff.current_merged_at.slice(0, 10)}`);
620
+ lines.push(`**Risk trend:** ${riskIcon} ${diff.risk_delta}`);
621
+ lines.push('');
622
+ // Deltas
623
+ lines.push('## Changes');
624
+ lines.push('');
625
+ const deltas = [];
626
+ if (diff.assets_added)
627
+ deltas.push(`+${diff.assets_added} new asset(s)`);
628
+ if (diff.assets_removed)
629
+ deltas.push(`-${diff.assets_removed} removed asset(s)`);
630
+ if (diff.threats_added)
631
+ deltas.push(`+${diff.threats_added} new threat(s)`);
632
+ if (diff.threats_removed)
633
+ deltas.push(`-${diff.threats_removed} removed threat(s)`);
634
+ if (diff.mitigations_added)
635
+ deltas.push(`+${diff.mitigations_added} new mitigation(s)`);
636
+ if (diff.mitigations_removed)
637
+ deltas.push(`-${diff.mitigations_removed} removed mitigation(s) ⚠️`);
638
+ if (diff.exposures_added)
639
+ deltas.push(`+${diff.exposures_added} new exposure(s)`);
640
+ if (diff.exposures_removed)
641
+ deltas.push(`-${diff.exposures_removed} resolved exposure(s)`);
642
+ if (diff.new_flows)
643
+ deltas.push(`+${diff.new_flows} new data flow(s)`);
644
+ if (diff.removed_flows)
645
+ deltas.push(`-${diff.removed_flows} removed data flow(s)`);
646
+ if (deltas.length === 0) {
647
+ lines.push('No annotation changes this period.');
648
+ }
649
+ else {
650
+ for (const d of deltas)
651
+ lines.push(`- ${d}`);
652
+ }
653
+ lines.push('');
654
+ // Risk highlights
655
+ if (diff.new_unmitigated > 0 || diff.resolved_unmitigated > 0) {
656
+ lines.push('## Risk');
657
+ lines.push('');
658
+ if (diff.new_unmitigated > 0)
659
+ lines.push(`- 🔴 ${diff.new_unmitigated} new unmitigated exposure(s)`);
660
+ if (diff.resolved_unmitigated > 0)
661
+ lines.push(`- 🟢 ${diff.resolved_unmitigated} exposure(s) now mitigated`);
662
+ lines.push('');
663
+ }
664
+ // Cross-repo refs
665
+ if (diff.new_unresolved_refs > 0 || diff.resolved_refs > 0) {
666
+ lines.push('## Cross-Repo References');
667
+ lines.push('');
668
+ if (diff.new_unresolved_refs > 0)
669
+ lines.push(`- ⚠️ ${diff.new_unresolved_refs} new unresolved ref(s)`);
670
+ if (diff.resolved_refs > 0)
671
+ lines.push(`- ✓ ${diff.resolved_refs} ref(s) now resolved`);
672
+ lines.push('');
673
+ }
674
+ // Repo changes
675
+ if (diff.repos_added.length > 0 || diff.repos_removed.length > 0 || diff.repos_with_changes.length > 0) {
676
+ lines.push('## Repos');
677
+ lines.push('');
678
+ for (const r of diff.repos_added)
679
+ lines.push(`- 🆕 ${r} (new)`);
680
+ for (const r of diff.repos_removed)
681
+ lines.push(`- ❌ ${r} (removed)`);
682
+ for (const r of diff.repos_with_changes)
683
+ lines.push(`- 📝 ${r} (updated)`);
684
+ lines.push('');
685
+ }
686
+ return lines.join('\n');
687
+ }
688
+ // ─── Merge Summary Markdown ──────────────────────────────────────────
689
+ /**
690
+ * Generate a human-readable markdown summary of a merged report.
691
+ * Used for terminal output, weekly emails, and Slack notifications.
692
+ */
693
+ export function formatMergeSummary(merged) {
694
+ const lines = [];
695
+ const t = merged.totals;
696
+ lines.push(`# ${merged.workspace} — Threat Model Summary`);
697
+ lines.push('');
698
+ lines.push(`**Generated:** ${merged.merged_at}`);
699
+ lines.push(`**Repos:** ${t.repos_loaded}/${t.repos} loaded`);
700
+ lines.push('');
701
+ // Totals
702
+ lines.push('## Overview');
703
+ lines.push('');
704
+ lines.push(`| Metric | Count |`);
705
+ lines.push(`|--------|-------|`);
706
+ lines.push(`| Annotations | ${t.annotations} |`);
707
+ lines.push(`| Assets | ${t.assets} |`);
708
+ lines.push(`| Threats | ${t.threats} |`);
709
+ lines.push(`| Controls | ${t.controls} |`);
710
+ lines.push(`| Mitigations | ${t.mitigations} |`);
711
+ lines.push(`| Exposures | ${t.exposures} |`);
712
+ lines.push(`| Unmitigated | ${t.unmitigated_exposures} |`);
713
+ lines.push(`| Data flows | ${t.flows} |`);
714
+ lines.push(`| Cross-repo refs resolved | ${t.external_refs_resolved} |`);
715
+ lines.push(`| Cross-repo refs unresolved | ${t.external_refs_unresolved} |`);
716
+ lines.push('');
717
+ // Repo statuses
718
+ lines.push('## Repos');
719
+ lines.push('');
720
+ for (const s of merged.repo_statuses) {
721
+ const status = s.loaded ? '✓' : '✗';
722
+ const detail = s.loaded
723
+ ? `${s.annotation_count || 0} annotations, commit ${(s.commit_sha || '').slice(0, 7)}`
724
+ : `MISSING — ${s.error || 'no report'}`;
725
+ lines.push(`- ${status} **${s.name}** — ${detail}`);
726
+ }
727
+ lines.push('');
728
+ // Warnings
729
+ const errors = merged.warnings.filter(w => w.level === 'error');
730
+ const warns = merged.warnings.filter(w => w.level === 'warning');
731
+ if (errors.length > 0 || warns.length > 0) {
732
+ lines.push('## Warnings');
733
+ lines.push('');
734
+ for (const w of [...errors, ...warns]) {
735
+ const icon = w.level === 'error' ? '🔴' : '⚠️';
736
+ lines.push(`- ${icon} ${w.message}`);
737
+ }
738
+ lines.push('');
739
+ }
740
+ // Unresolved refs
741
+ if (merged.unresolved_refs.length > 0) {
742
+ lines.push('## Unresolved Cross-Repo References');
743
+ lines.push('');
744
+ for (const u of merged.unresolved_refs) {
745
+ const inferred = u.inferred_repo ? ` (expected in ${u.inferred_repo})` : '';
746
+ lines.push(`- \`${u.tag}\` referenced in ${u.source_repo}${inferred}`);
747
+ }
748
+ lines.push('');
749
+ }
750
+ return lines.join('\n');
751
+ }
752
+ //# sourceMappingURL=merge.js.map