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.
- package/CHANGELOG.md +44 -0
- package/README.md +43 -1
- package/dist/agents/launcher.d.ts +1 -1
- package/dist/agents/launcher.js +1 -1
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +300 -54
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +38 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/suggest.d.ts +1 -0
- package/dist/mcp/suggest.d.ts.map +1 -1
- package/dist/mcp/suggest.js +1 -0
- package/dist/mcp/suggest.js.map +1 -1
- package/dist/parser/parse-project.d.ts.map +1 -1
- package/dist/parser/parse-project.js +103 -0
- package/dist/parser/parse-project.js.map +1 -1
- package/dist/tui/commands.d.ts +3 -0
- package/dist/tui/commands.d.ts.map +1 -1
- package/dist/tui/commands.js +297 -39
- package/dist/tui/commands.js.map +1 -1
- package/dist/tui/index.d.ts.map +1 -1
- package/dist/tui/index.js +17 -1
- package/dist/tui/index.js.map +1 -1
- package/dist/types/index.d.ts +39 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/workspace/index.d.ts +12 -0
- package/dist/workspace/index.d.ts.map +1 -0
- package/dist/workspace/index.js +9 -0
- package/dist/workspace/index.js.map +1 -0
- package/dist/workspace/link.d.ts +91 -0
- package/dist/workspace/link.d.ts.map +1 -0
- package/dist/workspace/link.js +581 -0
- package/dist/workspace/link.js.map +1 -0
- package/dist/workspace/merge.d.ts +104 -0
- package/dist/workspace/merge.d.ts.map +1 -0
- package/dist/workspace/merge.js +752 -0
- package/dist/workspace/merge.js.map +1 -0
- package/dist/workspace/metadata.d.ts +34 -0
- package/dist/workspace/metadata.d.ts.map +1 -0
- package/dist/workspace/metadata.js +181 -0
- package/dist/workspace/metadata.js.map +1 -0
- package/dist/workspace/types.d.ts +134 -0
- package/dist/workspace/types.d.ts.map +1 -0
- package/dist/workspace/types.js +12 -0
- package/dist/workspace/types.js.map +1 -0
- 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
|