maestro-flow 0.4.20 → 0.4.21

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 (136) hide show
  1. package/.agents/skills/maestro-ralph-execute/SKILL.md +2 -1
  2. package/.agents/skills/maestro-swarm-workflow/SKILL.md +27 -19
  3. package/.agents/skills/maestro-universal-workflow/SKILL.md +563 -0
  4. package/.agents/skills/team-adversarial-swarm/SKILL.md +235 -0
  5. package/.agents/skills/team-adversarial-swarm/scripts/aco.py +473 -0
  6. package/.agents/skills/team-adversarial-swarm/scripts/pheromone.py +144 -0
  7. package/.agents/skills/team-adversarial-swarm/scripts/scoring.py +92 -0
  8. package/.agents/skills/team-adversarial-swarm/scripts/test_aco.py +475 -0
  9. package/.agents/skills/team-adversarial-swarm/specs/ant-output-schema.md +115 -0
  10. package/.agents/skills/team-adversarial-swarm/specs/convergence-criteria.md +75 -0
  11. package/.agents/skills/team-adversarial-swarm/specs/pheromone-schema.md +90 -0
  12. package/.agents/skills/team-adversarial-swarm/specs/swarm-config-template.json +66 -0
  13. package/.agents/skills/team-adversarial-swarm/specs/swarm-protocol.md +105 -0
  14. package/.agents/skills/team-adversarial-swarm/workflows/wf-swarm-converge.js +197 -0
  15. package/.agents/skills/team-adversarial-swarm/workflows/wf-swarm-explore.js +194 -0
  16. package/.agents/skills/team-adversarial-swarm/workflows/wf-swarm-score.js +188 -0
  17. package/.agents/skills/team-adversarial-swarm/workflows/wf-swarm-synthesize.js +248 -0
  18. package/.agy/skills/maestro-ralph-execute/SKILL.md +2 -1
  19. package/.agy/skills/maestro-swarm-workflow/SKILL.md +27 -19
  20. package/.agy/skills/maestro-universal-workflow/SKILL.md +560 -0
  21. package/.agy/skills/team-adversarial-swarm/SKILL.md +244 -0
  22. package/.agy/skills/team-adversarial-swarm/scripts/aco.py +473 -0
  23. package/.agy/skills/team-adversarial-swarm/scripts/pheromone.py +144 -0
  24. package/.agy/skills/team-adversarial-swarm/scripts/scoring.py +92 -0
  25. package/.agy/skills/team-adversarial-swarm/scripts/test_aco.py +475 -0
  26. package/.agy/skills/team-adversarial-swarm/specs/ant-output-schema.md +115 -0
  27. package/.agy/skills/team-adversarial-swarm/specs/convergence-criteria.md +75 -0
  28. package/.agy/skills/team-adversarial-swarm/specs/pheromone-schema.md +90 -0
  29. package/.agy/skills/team-adversarial-swarm/specs/swarm-config-template.json +66 -0
  30. package/.agy/skills/team-adversarial-swarm/specs/swarm-protocol.md +105 -0
  31. package/.agy/skills/team-adversarial-swarm/workflows/wf-swarm-converge.js +197 -0
  32. package/.agy/skills/team-adversarial-swarm/workflows/wf-swarm-explore.js +194 -0
  33. package/.agy/skills/team-adversarial-swarm/workflows/wf-swarm-score.js +188 -0
  34. package/.agy/skills/team-adversarial-swarm/workflows/wf-swarm-synthesize.js +248 -0
  35. package/.claude/commands/maestro-ralph-execute.md +2 -1
  36. package/.claude/commands/maestro-swarm-workflow.md +27 -19
  37. package/.claude/commands/maestro-universal-workflow.md +561 -0
  38. package/.claude/skills/team-adversarial-swarm/SKILL.md +233 -0
  39. package/.claude/skills/team-adversarial-swarm/scripts/aco.py +473 -0
  40. package/.claude/skills/team-adversarial-swarm/scripts/pheromone.py +144 -0
  41. package/.claude/skills/team-adversarial-swarm/scripts/scoring.py +92 -0
  42. package/.claude/skills/team-adversarial-swarm/scripts/test_aco.py +475 -0
  43. package/.claude/skills/team-adversarial-swarm/specs/ant-output-schema.md +115 -0
  44. package/.claude/skills/team-adversarial-swarm/specs/convergence-criteria.md +75 -0
  45. package/.claude/skills/team-adversarial-swarm/specs/pheromone-schema.md +90 -0
  46. package/.claude/skills/team-adversarial-swarm/specs/swarm-config-template.json +66 -0
  47. package/.claude/skills/team-adversarial-swarm/specs/swarm-protocol.md +105 -0
  48. package/.claude/skills/team-adversarial-swarm/workflows/wf-swarm-converge.js +197 -0
  49. package/.claude/skills/team-adversarial-swarm/workflows/wf-swarm-explore.js +194 -0
  50. package/.claude/skills/team-adversarial-swarm/workflows/wf-swarm-score.js +188 -0
  51. package/.claude/skills/team-adversarial-swarm/workflows/wf-swarm-synthesize.js +248 -0
  52. package/dashboard/dist-server/dashboard/src/server/wiki/graph-analysis.js +1 -1
  53. package/dashboard/dist-server/dashboard/src/server/wiki/graph-analysis.js.map +1 -1
  54. package/dashboard/dist-server/dashboard/src/server/wiki/search.js +1 -1
  55. package/dashboard/dist-server/dashboard/src/server/wiki/search.js.map +1 -1
  56. package/dashboard/dist-server/dashboard/src/server/wiki/virtual-wiki-adapters.d.ts +1 -1
  57. package/dashboard/dist-server/dashboard/src/server/wiki/virtual-wiki-adapters.js +5 -5
  58. package/dashboard/dist-server/dashboard/src/server/wiki/virtual-wiki-adapters.js.map +1 -1
  59. package/dashboard/dist-server/dashboard/src/server/wiki/wiki-indexer.js +3 -3
  60. package/dashboard/dist-server/dashboard/src/server/wiki/wiki-indexer.js.map +1 -1
  61. package/dashboard/dist-server/src/graph/types.d.ts +111 -0
  62. package/dashboard/dist-server/src/graph/types.js +2 -0
  63. package/dashboard/dist-server/src/graph/types.js.map +1 -0
  64. package/dist/src/commands/install-backend.d.ts +0 -7
  65. package/dist/src/commands/install-backend.d.ts.map +1 -1
  66. package/dist/src/commands/install-backend.js +0 -14
  67. package/dist/src/commands/install-backend.js.map +1 -1
  68. package/dist/src/commands/install.d.ts.map +1 -1
  69. package/dist/src/commands/install.js +0 -18
  70. package/dist/src/commands/install.js.map +1 -1
  71. package/dist/src/commands/kg.d.ts +2 -2
  72. package/dist/src/commands/kg.d.ts.map +1 -1
  73. package/dist/src/commands/kg.js +150 -179
  74. package/dist/src/commands/kg.js.map +1 -1
  75. package/dist/src/graph/analyzers/fs-analyzer.d.ts +10 -0
  76. package/dist/src/graph/analyzers/fs-analyzer.d.ts.map +1 -0
  77. package/dist/src/graph/analyzers/fs-analyzer.js +959 -0
  78. package/dist/src/graph/analyzers/fs-analyzer.js.map +1 -0
  79. package/dist/src/graph/index.d.ts +6 -0
  80. package/dist/src/graph/index.d.ts.map +1 -0
  81. package/dist/src/graph/index.js +6 -0
  82. package/dist/src/graph/index.js.map +1 -0
  83. package/dist/src/graph/loader.d.ts +3 -0
  84. package/dist/src/graph/loader.d.ts.map +1 -0
  85. package/dist/src/graph/loader.js +12 -0
  86. package/dist/src/graph/loader.js.map +1 -0
  87. package/dist/src/graph/merger.d.ts +56 -0
  88. package/dist/src/graph/merger.d.ts.map +1 -0
  89. package/dist/src/graph/merger.js +896 -0
  90. package/dist/src/graph/merger.js.map +1 -0
  91. package/dist/src/graph/query.d.ts +7 -0
  92. package/dist/src/graph/query.d.ts.map +1 -0
  93. package/dist/src/graph/query.js +126 -0
  94. package/dist/src/graph/query.js.map +1 -0
  95. package/dist/src/graph/types.d.ts +112 -0
  96. package/dist/src/graph/types.d.ts.map +1 -0
  97. package/dist/src/graph/types.js +2 -0
  98. package/dist/src/graph/types.js.map +1 -0
  99. package/dist/src/i18n/locales/en.d.ts.map +1 -1
  100. package/dist/src/i18n/locales/en.js +0 -10
  101. package/dist/src/i18n/locales/en.js.map +1 -1
  102. package/dist/src/i18n/locales/zh.d.ts.map +1 -1
  103. package/dist/src/i18n/locales/zh.js +0 -10
  104. package/dist/src/i18n/locales/zh.js.map +1 -1
  105. package/dist/src/i18n/types.d.ts +0 -9
  106. package/dist/src/i18n/types.d.ts.map +1 -1
  107. package/dist/src/tui/install-ui/InstallConfirm.d.ts +0 -1
  108. package/dist/src/tui/install-ui/InstallConfirm.d.ts.map +1 -1
  109. package/dist/src/tui/install-ui/InstallConfirm.js +1 -1
  110. package/dist/src/tui/install-ui/InstallConfirm.js.map +1 -1
  111. package/dist/src/tui/install-ui/InstallExecution.d.ts +0 -1
  112. package/dist/src/tui/install-ui/InstallExecution.d.ts.map +1 -1
  113. package/dist/src/tui/install-ui/InstallExecution.js +0 -22
  114. package/dist/src/tui/install-ui/InstallExecution.js.map +1 -1
  115. package/dist/src/tui/install-ui/InstallFlow.d.ts +1 -1
  116. package/dist/src/tui/install-ui/InstallFlow.d.ts.map +1 -1
  117. package/dist/src/tui/install-ui/InstallFlow.js +5 -23
  118. package/dist/src/tui/install-ui/InstallFlow.js.map +1 -1
  119. package/dist/src/tui/install-ui/InstallHub.d.ts +0 -2
  120. package/dist/src/tui/install-ui/InstallHub.d.ts.map +1 -1
  121. package/dist/src/tui/install-ui/InstallHub.js +0 -6
  122. package/dist/src/tui/install-ui/InstallHub.js.map +1 -1
  123. package/dist/src/tui/install-ui/InstallResult.d.ts.map +1 -1
  124. package/dist/src/tui/install-ui/InstallResult.js +1 -1
  125. package/dist/src/tui/install-ui/InstallResult.js.map +1 -1
  126. package/dist/src/utils/update-notices.js +12 -0
  127. package/dist/src/utils/update-notices.js.map +1 -1
  128. package/package.json +1 -1
  129. package/workflows/swarm/wf-analyze.js +195 -34
  130. package/workflows/swarm/wf-brainstorm.js +225 -53
  131. package/workflows/swarm/wf-execute.js +199 -23
  132. package/workflows/swarm/wf-grill.js +181 -20
  133. package/workflows/swarm/wf-milestone-audit.js +178 -29
  134. package/workflows/swarm/wf-plan.js +288 -53
  135. package/workflows/swarm/wf-review.js +195 -80
  136. package/workflows/swarm/wf-verify.js +125 -28
@@ -0,0 +1,896 @@
1
+ // ---------------------------------------------------------------------------
2
+ // merger.ts -- Merge and normalize batch analysis results.
3
+ //
4
+ // TypeScript port of merge-batch-graphs.mjs.
5
+ // Combines batch data into a single assembled graph with normalized IDs,
6
+ // complexity values, and cleaned edges.
7
+ //
8
+ // No external dependencies -- uses only Node.js built-in modules.
9
+ // ---------------------------------------------------------------------------
10
+ import { readFileSync, existsSync } from 'node:fs';
11
+ import { basename } from 'node:path';
12
+ // ---------------------------------------------------------------------------
13
+ // Configuration
14
+ // ---------------------------------------------------------------------------
15
+ const VALID_NODE_PREFIXES = new Set([
16
+ 'file', 'func', 'function', 'class', 'module', 'concept',
17
+ 'config', 'document', 'service', 'table', 'endpoint',
18
+ 'pipeline', 'schema', 'resource',
19
+ 'domain', 'flow', 'step',
20
+ // Knowledge-base node types (schema.ts NodeType enum)
21
+ 'article', 'entity', 'topic', 'claim', 'source',
22
+ ]);
23
+ /** node.type -> canonical ID prefix */
24
+ const TYPE_TO_PREFIX = {
25
+ file: 'file',
26
+ function: 'function',
27
+ func: 'function',
28
+ class: 'class',
29
+ module: 'module',
30
+ concept: 'concept',
31
+ config: 'config',
32
+ document: 'document',
33
+ service: 'service',
34
+ table: 'table',
35
+ endpoint: 'endpoint',
36
+ pipeline: 'pipeline',
37
+ schema: 'schema',
38
+ resource: 'resource',
39
+ domain: 'domain',
40
+ flow: 'flow',
41
+ step: 'step',
42
+ // Knowledge-base node types
43
+ article: 'article',
44
+ entity: 'entity',
45
+ topic: 'topic',
46
+ claim: 'claim',
47
+ source: 'source',
48
+ };
49
+ const COMPLEXITY_MAP = {
50
+ low: 'simple',
51
+ easy: 'simple',
52
+ medium: 'moderate',
53
+ intermediate: 'moderate',
54
+ high: 'complex',
55
+ hard: 'complex',
56
+ difficult: 'complex',
57
+ };
58
+ const VALID_COMPLEXITY = new Set(['simple', 'moderate', 'complex']);
59
+ // ---------------------------------------------------------------------------
60
+ // tested_by linker configuration
61
+ // ---------------------------------------------------------------------------
62
+ const _JS_TS_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue'];
63
+ const _JS_TS_TEST_EXTS = new Set(_JS_TS_EXTS);
64
+ const _MIRROR_PRODUCTION_ROOTS = ['src', 'app', 'lib', ''];
65
+ // Per-extension test-name patterns: ext -> [prefixes, suffixes]
66
+ const _TEST_NAME_PATTERNS = {
67
+ '.go': [[], ['_test']],
68
+ '.py': [['test_'], ['_test']],
69
+ '.java': [[], ['Test', 'Tests', 'IT']],
70
+ '.kt': [[], ['Test', 'Tests']],
71
+ '.cs': [[], ['Test', 'Tests']],
72
+ '.c': [['test_'], ['_test']],
73
+ '.cpp': [['test_'], ['_test']],
74
+ '.cc': [['test_'], ['_test']],
75
+ };
76
+ const _DIRECTION_ALIASES = { both: 'bidirectional', mutual: 'bidirectional' };
77
+ const _VALID_DIRECTIONS = new Set(['forward', 'backward', 'bidirectional']);
78
+ // ---------------------------------------------------------------------------
79
+ // Utility functions
80
+ // ---------------------------------------------------------------------------
81
+ /**
82
+ * Canonicalize an edge `direction` value to one of the schema enum members.
83
+ */
84
+ export function normalizeDirection(value) {
85
+ const candidate = typeof value === 'string' ? value.toLowerCase() : '';
86
+ const mapped = _DIRECTION_ALIASES[candidate] ?? candidate;
87
+ return _VALID_DIRECTIONS.has(mapped) ? mapped : 'forward';
88
+ }
89
+ /**
90
+ * Coerce a value to number for safe comparison (handles string weights).
91
+ */
92
+ function _num(v) {
93
+ const n = Number(v);
94
+ return Number.isFinite(n) ? n : 0;
95
+ }
96
+ // ---------------------------------------------------------------------------
97
+ // ID normalization
98
+ // ---------------------------------------------------------------------------
99
+ /**
100
+ * Return a human-readable pattern label for an ID correction.
101
+ */
102
+ function classifyIdFix(original, corrected) {
103
+ // Double prefix: "file:file:..." -> "file:..."
104
+ for (const prefix of VALID_NODE_PREFIXES) {
105
+ if (original.startsWith(`${prefix}:${prefix}:`)) {
106
+ return `${prefix}:${prefix}: -> ${prefix}: (double prefix)`;
107
+ }
108
+ }
109
+ // Project-name prefix: "my-project:file:..." -> "file:..."
110
+ const parts = original.split(':');
111
+ if (parts.length >= 3 && !VALID_NODE_PREFIXES.has(parts[0]) && VALID_NODE_PREFIXES.has(parts[1])) {
112
+ return `<project>:${parts[1]}: -> ${parts[1]}: (project-name prefix)`;
113
+ }
114
+ // Legacy func: -> function:
115
+ if (original.startsWith('func:') && corrected.startsWith('function:')) {
116
+ return 'func: -> function: (prefix canonicalization)';
117
+ }
118
+ // Bare path -> prefixed
119
+ let hasPrefix = false;
120
+ for (const p of VALID_NODE_PREFIXES) {
121
+ if (original.startsWith(`${p}:`)) {
122
+ hasPrefix = true;
123
+ break;
124
+ }
125
+ }
126
+ if (!hasPrefix) {
127
+ const prefix = corrected.split(':')[0];
128
+ return `bare path -> ${prefix}: (missing prefix)`;
129
+ }
130
+ return `${original} -> ${corrected}`;
131
+ }
132
+ /**
133
+ * Build a regex pattern that matches any valid prefix followed by a colon.
134
+ * Used in normalizeNodeId for project-name prefix stripping.
135
+ */
136
+ const _VALID_PREFIX_PATTERN = new RegExp('^[^:]+:(' + [...VALID_NODE_PREFIXES].map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + '):(.+)$');
137
+ /**
138
+ * Normalize a node ID, returning the corrected version.
139
+ */
140
+ export function normalizeNodeId(nodeId, node) {
141
+ let nid = nodeId;
142
+ // Strip double prefix: "file:file:src/foo.ts" -> "file:src/foo.ts"
143
+ for (const prefix of VALID_NODE_PREFIXES) {
144
+ const double = `${prefix}:${prefix}:`;
145
+ if (nid.startsWith(double)) {
146
+ nid = nid.slice(prefix.length + 1);
147
+ break;
148
+ }
149
+ }
150
+ // Strip project-name prefix: "my-project:file:src/foo.ts" -> "file:src/foo.ts"
151
+ const match = nid.match(_VALID_PREFIX_PATTERN);
152
+ if (match) {
153
+ const firstSeg = nid.split(':')[0];
154
+ if (!VALID_NODE_PREFIXES.has(firstSeg)) {
155
+ nid = `${match[1]}:${match[2]}`;
156
+ }
157
+ }
158
+ // Canonicalize legacy prefix: func: -> function:
159
+ if (nid.startsWith('func:') && !nid.startsWith('function:')) {
160
+ nid = 'function:' + nid.slice(5);
161
+ }
162
+ // Add missing prefix for bare file paths
163
+ let hasPrefix = false;
164
+ for (const p of VALID_NODE_PREFIXES) {
165
+ if (nid.startsWith(`${p}:`)) {
166
+ hasPrefix = true;
167
+ break;
168
+ }
169
+ }
170
+ if (!hasPrefix) {
171
+ const nodeType = node.type || 'file';
172
+ const prefix = TYPE_TO_PREFIX[nodeType] || 'file';
173
+ if (nodeType === 'function' || nodeType === 'class') {
174
+ const filePath = node.filePath || '';
175
+ const name = node.name || nid;
176
+ if (filePath) {
177
+ nid = `${prefix}:${filePath}:${name}`;
178
+ }
179
+ else {
180
+ nid = `${prefix}:__nofilepath__:${name}`;
181
+ }
182
+ }
183
+ else {
184
+ nid = `${prefix}:${nid}`;
185
+ }
186
+ }
187
+ return nid;
188
+ }
189
+ /**
190
+ * Normalize a complexity value.
191
+ * Returns [normalized, status] where status is "valid" | "mapped" | "unknown".
192
+ */
193
+ export function normalizeComplexity(value) {
194
+ if (typeof value === 'string') {
195
+ const lower = value.trim().toLowerCase();
196
+ if (VALID_COMPLEXITY.has(lower))
197
+ return [lower, 'valid'];
198
+ if (COMPLEXITY_MAP[lower] !== undefined)
199
+ return [COMPLEXITY_MAP[lower], 'mapped'];
200
+ return ['moderate', 'unknown'];
201
+ }
202
+ if (typeof value === 'number' && Number.isFinite(value)) {
203
+ const n = Math.trunc(value);
204
+ if (n <= 3)
205
+ return ['simple', 'mapped'];
206
+ if (n <= 6)
207
+ return ['moderate', 'mapped'];
208
+ return ['complex', 'mapped'];
209
+ }
210
+ return ['moderate', 'unknown'];
211
+ }
212
+ // ---------------------------------------------------------------------------
213
+ // Deterministic tested_by linker
214
+ // ---------------------------------------------------------------------------
215
+ /**
216
+ * Split a relative POSIX-style path into segments (ignoring empties).
217
+ */
218
+ function _pathSegments(p) {
219
+ return p.split('/').filter(s => s !== '');
220
+ }
221
+ /**
222
+ * Get the basename of a POSIX-style path.
223
+ */
224
+ function _basename(p) {
225
+ const idx = p.lastIndexOf('/');
226
+ return idx >= 0 ? p.slice(idx + 1) : p;
227
+ }
228
+ /**
229
+ * Get stem (filename without extension) and extension.
230
+ */
231
+ function _splitext(filename) {
232
+ const dot = filename.lastIndexOf('.');
233
+ if (dot <= 0)
234
+ return [filename, ''];
235
+ return [filename.slice(0, dot), filename.slice(dot)];
236
+ }
237
+ /**
238
+ * Return true if `path` looks like a test file by basename convention.
239
+ */
240
+ export function isTestPath(p) {
241
+ const name = _basename(p);
242
+ const [stem, ext] = _splitext(name);
243
+ // JS/TS family: the test marker is an infix on the stem
244
+ if (_JS_TS_TEST_EXTS.has(ext)) {
245
+ return stem.endsWith('.test') || stem.endsWith('.spec');
246
+ }
247
+ const patterns = _TEST_NAME_PATTERNS[ext];
248
+ if (!patterns)
249
+ return false;
250
+ const [prefixes, suffixes] = patterns;
251
+ return prefixes.some(pre => stem.startsWith(pre)) ||
252
+ suffixes.some(suf => stem.endsWith(suf));
253
+ }
254
+ /**
255
+ * For a JS/TS-family stem like `foo.test` or `foo.spec`, strip the
256
+ * trailing `.test` / `.spec`. Returns null if no infix is present.
257
+ */
258
+ function _stripTestInfix(stem) {
259
+ for (const infix of ['.test', '.spec']) {
260
+ if (stem.endsWith(infix)) {
261
+ return stem.slice(0, -infix.length);
262
+ }
263
+ }
264
+ return null;
265
+ }
266
+ function _joinPath(dirPath, name) {
267
+ return dirPath ? `${dirPath}/${name}` : name;
268
+ }
269
+ /**
270
+ * Append path to out unless it is empty or already present.
271
+ */
272
+ function _addUnique(out, p) {
273
+ if (p && !out.includes(p))
274
+ out.push(p);
275
+ }
276
+ /**
277
+ * Build sibling candidates for a JS/TS family base stem.
278
+ */
279
+ function _jsTsSiblingCandidates(dirPath, baseStem) {
280
+ return _JS_TS_EXTS.map(e => _joinPath(dirPath, `${baseStem}${e}`));
281
+ }
282
+ /**
283
+ * For a test file path, return ordered candidate production paths.
284
+ */
285
+ export function productionCandidates(testPath) {
286
+ const name = _basename(testPath);
287
+ const [stem, ext] = _splitext(name);
288
+ const segs = _pathSegments(testPath);
289
+ const dirSegs = segs.slice(0, -1);
290
+ const dirPath = dirSegs.join('/');
291
+ const candidates = [];
292
+ // -- JS/TS family --
293
+ if (_JS_TS_TEST_EXTS.has(ext)) {
294
+ const baseStem = _stripTestInfix(stem);
295
+ if (baseStem !== null) {
296
+ // 1. Sibling de-infix
297
+ _addUnique(candidates, _joinPath(dirPath, `${baseStem}${ext}`));
298
+ for (const c of _jsTsSiblingCandidates(dirPath, baseStem)) {
299
+ _addUnique(candidates, c);
300
+ }
301
+ // 2. Walk out of test-segregating subdir
302
+ if (dirSegs.length > 0 && ['__tests__', 'test', 'spec', 'tests'].includes(dirSegs[dirSegs.length - 1])) {
303
+ const parentDir = dirSegs.slice(0, -1).join('/');
304
+ _addUnique(candidates, _joinPath(parentDir, `${baseStem}${ext}`));
305
+ for (const c of _jsTsSiblingCandidates(parentDir, baseStem)) {
306
+ _addUnique(candidates, c);
307
+ }
308
+ }
309
+ // 3. Mirrored tree
310
+ if (dirSegs.length > 0 && ['tests', 'test', '__tests__'].includes(dirSegs[0])) {
311
+ const tailPath = dirSegs.slice(1).join('/');
312
+ for (const root of _MIRROR_PRODUCTION_ROOTS) {
313
+ const newDir = [root, tailPath].filter(Boolean).join('/');
314
+ _addUnique(candidates, _joinPath(newDir, `${baseStem}${ext}`));
315
+ for (const c of _jsTsSiblingCandidates(newDir, baseStem)) {
316
+ _addUnique(candidates, c);
317
+ }
318
+ }
319
+ }
320
+ }
321
+ }
322
+ // -- Go --
323
+ else if (ext === '.go' && stem.endsWith('_test')) {
324
+ const baseStem = stem.slice(0, -'_test'.length);
325
+ _addUnique(candidates, _joinPath(dirPath, `${baseStem}.go`));
326
+ }
327
+ // -- Python --
328
+ else if (ext === '.py' && (stem.startsWith('test_') || stem.endsWith('_test'))) {
329
+ const baseStem = stem.startsWith('test_')
330
+ ? stem.slice('test_'.length)
331
+ : stem.slice(0, -'_test'.length);
332
+ // Sibling
333
+ _addUnique(candidates, _joinPath(dirPath, `${baseStem}.py`));
334
+ // Walk out of in-package tests/ or test/
335
+ if (dirSegs.length > 0 && ['tests', 'test'].includes(dirSegs[dirSegs.length - 1])) {
336
+ const parentDir = dirSegs.slice(0, -1).join('/');
337
+ _addUnique(candidates, _joinPath(parentDir, `${baseStem}.py`));
338
+ }
339
+ // Mirrored
340
+ if (dirSegs.length > 0 && ['tests', 'test'].includes(dirSegs[0])) {
341
+ const tailPath = dirSegs.slice(1).join('/');
342
+ for (const root of _MIRROR_PRODUCTION_ROOTS) {
343
+ const newDir = [root, tailPath].filter(Boolean).join('/');
344
+ _addUnique(candidates, _joinPath(newDir, `${baseStem}.py`));
345
+ }
346
+ }
347
+ }
348
+ // -- Java --
349
+ else if (ext === '.java') {
350
+ for (const suffix of ['Tests', 'Test', 'IT']) {
351
+ if (stem.endsWith(suffix)) {
352
+ const baseStem = stem.slice(0, -suffix.length);
353
+ // Maven/Gradle layout
354
+ if (dirSegs.length >= 3 &&
355
+ dirSegs[0] === 'src' &&
356
+ dirSegs[1] === 'test' &&
357
+ dirSegs[2] === 'java') {
358
+ const newDir = ['src', 'main', 'java', ...dirSegs.slice(3)].join('/');
359
+ _addUnique(candidates, `${newDir}/${baseStem}.java`);
360
+ }
361
+ // Sibling fallback
362
+ _addUnique(candidates, _joinPath(dirPath, `${baseStem}.java`));
363
+ break;
364
+ }
365
+ }
366
+ }
367
+ // -- Kotlin --
368
+ else if (ext === '.kt') {
369
+ for (const suffix of ['Tests', 'Test']) {
370
+ if (stem.endsWith(suffix)) {
371
+ const baseStem = stem.slice(0, -suffix.length);
372
+ if (dirSegs.length >= 3 &&
373
+ dirSegs[0] === 'src' &&
374
+ dirSegs[1] === 'test' &&
375
+ dirSegs[2] === 'kotlin') {
376
+ const newDir = ['src', 'main', 'kotlin', ...dirSegs.slice(3)].join('/');
377
+ _addUnique(candidates, `${newDir}/${baseStem}.kt`);
378
+ }
379
+ _addUnique(candidates, _joinPath(dirPath, `${baseStem}.kt`));
380
+ break;
381
+ }
382
+ }
383
+ }
384
+ // -- C# --
385
+ else if (ext === '.cs') {
386
+ for (const suffix of ['Tests', 'Test']) {
387
+ if (stem.endsWith(suffix)) {
388
+ const baseStem = stem.slice(0, -suffix.length);
389
+ // Sibling fallback
390
+ _addUnique(candidates, _joinPath(dirPath, `${baseStem}.cs`));
391
+ // Walk out of in-service tests/ directory
392
+ let testsIdx = null;
393
+ for (let i = dirSegs.length - 1; i >= 0; i--) {
394
+ if (['tests', 'test'].includes(dirSegs[i].toLowerCase())) {
395
+ testsIdx = i;
396
+ break;
397
+ }
398
+ }
399
+ if (testsIdx !== null) {
400
+ const parentSegs = dirSegs.slice(0, testsIdx);
401
+ const tailSegs = dirSegs.slice(testsIdx + 1);
402
+ const parentDir = parentSegs.join('/');
403
+ // <parent>/<base_stem>.cs
404
+ _addUnique(candidates, _joinPath(parentDir, `${baseStem}.cs`));
405
+ // <parent>/src/<tail>/<base_stem>.cs
406
+ const srcDir = [...parentSegs, 'src', ...tailSegs].join('/');
407
+ _addUnique(candidates, _joinPath(srcDir, `${baseStem}.cs`));
408
+ }
409
+ // .NET-style sibling-project mirror
410
+ if (dirSegs.length > 0) {
411
+ const top = dirSegs[0];
412
+ let sibling = null;
413
+ if (top.endsWith('.Tests')) {
414
+ sibling = top.slice(0, -'.Tests'.length);
415
+ }
416
+ else if (top.endsWith('.Test')) {
417
+ sibling = top.slice(0, -'.Test'.length);
418
+ }
419
+ if (sibling) {
420
+ const mirrorDir = [sibling, ...dirSegs.slice(1)].join('/');
421
+ _addUnique(candidates, _joinPath(mirrorDir, `${baseStem}.cs`));
422
+ }
423
+ }
424
+ break;
425
+ }
426
+ }
427
+ }
428
+ // -- C/C++ --
429
+ else if (['.c', '.cpp', '.cc'].includes(ext)) {
430
+ let baseStem = null;
431
+ if (stem.startsWith('test_')) {
432
+ baseStem = stem.slice('test_'.length);
433
+ }
434
+ else if (stem.endsWith('_test')) {
435
+ baseStem = stem.slice(0, -'_test'.length);
436
+ }
437
+ if (baseStem !== null) {
438
+ _addUnique(candidates, _joinPath(dirPath, `${baseStem}${ext}`));
439
+ }
440
+ }
441
+ return candidates;
442
+ }
443
+ /**
444
+ * Return the relative project path for a `file:`-prefixed node, else null.
445
+ */
446
+ function _fileNodePath(node) {
447
+ const nid = node.id;
448
+ if (typeof nid !== 'string' || !nid.startsWith('file:'))
449
+ return null;
450
+ if (typeof node.filePath === 'string' && node.filePath)
451
+ return node.filePath;
452
+ return nid.slice('file:'.length);
453
+ }
454
+ /**
455
+ * Flip an inverted tested_by edge so source becomes production and
456
+ * target becomes the test file. Mutates edge in place.
457
+ */
458
+ function _swapTestedByInPlace(edge, originalSrc, originalTgt) {
459
+ edge.source = originalTgt;
460
+ edge.target = originalSrc;
461
+ edge.direction = 'forward';
462
+ const prev = edge.description;
463
+ edge.description = prev
464
+ ? `${prev} [direction corrected]`
465
+ : 'Direction corrected (was test -> production)';
466
+ }
467
+ /**
468
+ * Append "tested" to node.tags, coercing malformed tags to a fresh list.
469
+ * Returns true if the tag was newly added.
470
+ */
471
+ function _ensureTestedTag(node) {
472
+ if (!Array.isArray(node.tags)) {
473
+ node.tags = [];
474
+ }
475
+ if (node.tags.includes('tested'))
476
+ return false;
477
+ node.tags.push('tested');
478
+ return true;
479
+ }
480
+ /**
481
+ * Canonicalize tested_by edges and link unmatched test files.
482
+ *
483
+ * Two-pass linker:
484
+ * Pass 1: Fix LLM-emitted tested_by edges (flip if source is test + target is production)
485
+ * Pass 2: Supplement with path-convention pairings
486
+ *
487
+ * Mutates nodesById (adds "tested" tag) and edges (rewrites in place).
488
+ */
489
+ export function linkTests(nodesById, edges) {
490
+ // Index file nodes by relative path; classify each as test/production.
491
+ const filePathsToNodes = new Map();
492
+ const nodeIdToClassification = new Map();
493
+ const testNodes = [];
494
+ for (const node of nodesById.values()) {
495
+ const path = _fileNodePath(node);
496
+ if (path === null)
497
+ continue;
498
+ filePathsToNodes.set(path, node);
499
+ if (isTestPath(path)) {
500
+ nodeIdToClassification.set(node.id, 'test');
501
+ testNodes.push([path, node]);
502
+ }
503
+ else {
504
+ nodeIdToClassification.set(node.id, 'prod');
505
+ }
506
+ }
507
+ // -- Pass 1: walk existing tested_by edges, canonicalize or drop.
508
+ /** serialized (prod_id, test_id) pairs */
509
+ const covered = new Set();
510
+ /** pair key -> index in edges */
511
+ const pairToIdx = new Map();
512
+ /** pairs that came from a swap */
513
+ const swappedPairs = new Set();
514
+ let dropped = 0;
515
+ let writeIdx = 0;
516
+ for (const edge of edges) {
517
+ if (edge.type !== 'tested_by') {
518
+ edges[writeIdx] = edge;
519
+ writeIdx++;
520
+ continue;
521
+ }
522
+ const src = edge.source || '';
523
+ const tgt = edge.target || '';
524
+ const srcClass = nodeIdToClassification.get(src);
525
+ const tgtClass = nodeIdToClassification.get(tgt);
526
+ let pair;
527
+ let needsSwap;
528
+ if (srcClass === 'prod' && tgtClass === 'test') {
529
+ pair = `${src}\0${tgt}`;
530
+ needsSwap = false;
531
+ }
532
+ else if (srcClass === 'test' && tgtClass === 'prod') {
533
+ pair = `${tgt}\0${src}`;
534
+ needsSwap = true;
535
+ }
536
+ else {
537
+ dropped++;
538
+ continue;
539
+ }
540
+ if (covered.has(pair)) {
541
+ // Duplicate pair: keep the heavier-weight edge
542
+ const existingIdx = pairToIdx.get(pair);
543
+ const existing = edges[existingIdx];
544
+ if (_num(edge.weight ?? 0) > _num(existing.weight ?? 0)) {
545
+ if (needsSwap) {
546
+ _swapTestedByInPlace(edge, src, tgt);
547
+ swappedPairs.add(pair);
548
+ }
549
+ else {
550
+ swappedPairs.delete(pair);
551
+ }
552
+ edges[existingIdx] = edge;
553
+ }
554
+ dropped++;
555
+ continue;
556
+ }
557
+ if (needsSwap) {
558
+ _swapTestedByInPlace(edge, src, tgt);
559
+ swappedPairs.add(pair);
560
+ }
561
+ covered.add(pair);
562
+ pairToIdx.set(pair, writeIdx);
563
+ edges[writeIdx] = edge;
564
+ writeIdx++;
565
+ }
566
+ edges.length = writeIdx;
567
+ const swapped = swappedPairs.size;
568
+ // -- Pass 2: path-convention supplement for tests not yet paired.
569
+ const pairedTestIds = new Set();
570
+ for (const pairKey of covered) {
571
+ const testId = pairKey.split('\0')[1];
572
+ pairedTestIds.add(testId);
573
+ }
574
+ let added = 0;
575
+ for (const [testPath, testNode] of testNodes) {
576
+ if (pairedTestIds.has(testNode.id))
577
+ continue;
578
+ for (const candPath of productionCandidates(testPath)) {
579
+ const prodNode = filePathsToNodes.get(candPath);
580
+ if (!prodNode)
581
+ continue;
582
+ if (isTestPath(candPath))
583
+ continue;
584
+ const pair = `${prodNode.id}\0${testNode.id}`;
585
+ if (covered.has(pair))
586
+ continue;
587
+ edges.push({
588
+ source: prodNode.id,
589
+ target: testNode.id,
590
+ type: 'tested_by',
591
+ direction: 'forward',
592
+ weight: 0.5,
593
+ description: 'Path-based pairing (deterministic)',
594
+ });
595
+ covered.add(pair);
596
+ added++;
597
+ break;
598
+ }
599
+ }
600
+ // -- Tag every production node that ended up sourcing a tested_by edge.
601
+ let tagged = 0;
602
+ for (const pairKey of covered) {
603
+ const prodId = pairKey.split('\0')[0];
604
+ const prodNode = nodesById.get(prodId);
605
+ if (!prodNode)
606
+ continue;
607
+ if (_ensureTestedTag(prodNode))
608
+ tagged++;
609
+ }
610
+ return { added, dropped, tagged, swapped };
611
+ }
612
+ // ---------------------------------------------------------------------------
613
+ // Main merge + normalize
614
+ // ---------------------------------------------------------------------------
615
+ function sumValues(map) {
616
+ let s = 0;
617
+ for (const v of map.values())
618
+ s += v;
619
+ return s;
620
+ }
621
+ /**
622
+ * Merge batch results and normalize.
623
+ */
624
+ export function mergeGraphs(batches) {
625
+ // -- Pattern counters --
626
+ const idFixPatterns = new Map();
627
+ const complexityFixPatterns = new Map();
628
+ const unfixable = [];
629
+ function incCounter(map, key) {
630
+ map.set(key, (map.get(key) || 0) + 1);
631
+ }
632
+ // -- Step 1: Combine all nodes and edges --
633
+ const allNodes = [];
634
+ const allEdges = [];
635
+ for (const batch of batches) {
636
+ if (Array.isArray(batch.nodes))
637
+ allNodes.push(...batch.nodes);
638
+ if (Array.isArray(batch.edges))
639
+ allEdges.push(...batch.edges);
640
+ }
641
+ const totalInputNodes = allNodes.length;
642
+ const totalInputEdges = allEdges.length;
643
+ // -- Step 2: Normalize node IDs and build ID mapping --
644
+ const idMapping = new Map();
645
+ const nodesWithIds = [];
646
+ const unknownNodeTypes = new Map();
647
+ for (let i = 0; i < allNodes.length; i++) {
648
+ const node = allNodes[i];
649
+ const originalId = node.id;
650
+ if (!originalId) {
651
+ unfixable.push(`Node[${i}] has no 'id' field (name=${node.name ?? '?'}, type=${node.type ?? '?'})`);
652
+ continue;
653
+ }
654
+ // Flag unknown node types
655
+ const nodeType = node.type || '';
656
+ if (nodeType && !(nodeType in TYPE_TO_PREFIX)) {
657
+ incCounter(unknownNodeTypes, nodeType);
658
+ }
659
+ nodesWithIds.push(node);
660
+ const correctedId = normalizeNodeId(originalId, node);
661
+ if (correctedId !== originalId) {
662
+ const pattern = classifyIdFix(originalId, correctedId);
663
+ incCounter(idFixPatterns, pattern);
664
+ idMapping.set(originalId, correctedId);
665
+ node.id = correctedId;
666
+ }
667
+ }
668
+ // -- Step 3: Normalize complexity --
669
+ const complexityUnknownPatterns = new Map();
670
+ for (const node of nodesWithIds) {
671
+ const original = node.complexity;
672
+ const [normalized, status] = normalizeComplexity(original);
673
+ if (status === 'mapped') {
674
+ const origRepr = typeof original !== 'string' ? JSON.stringify(original) : `"${original}"`;
675
+ incCounter(complexityFixPatterns, `${origRepr} -> "${normalized}"`);
676
+ }
677
+ else if (status === 'unknown') {
678
+ const origRepr = typeof original !== 'string' ? JSON.stringify(original) : `"${original}"`;
679
+ incCounter(complexityUnknownPatterns, `complexity ${origRepr} -> defaulted to "moderate"`);
680
+ }
681
+ node.complexity = normalized;
682
+ }
683
+ // -- Step 4: Rewrite edge references --
684
+ let edgesRewritten = 0;
685
+ for (const edge of allEdges) {
686
+ const src = edge.source || '';
687
+ const tgt = edge.target || '';
688
+ const newSrc = idMapping.get(src) ?? src;
689
+ const newTgt = idMapping.get(tgt) ?? tgt;
690
+ if (newSrc !== src || newTgt !== tgt) {
691
+ edgesRewritten++;
692
+ edge.source = newSrc;
693
+ edge.target = newTgt;
694
+ }
695
+ }
696
+ // -- Step 5: Deduplicate nodes by ID (keep last) --
697
+ let duplicateCount = 0;
698
+ const nodesById = new Map();
699
+ for (const node of nodesWithIds) {
700
+ const nid = node.id || '';
701
+ if (nodesById.has(nid))
702
+ duplicateCount++;
703
+ nodesById.set(nid, node);
704
+ }
705
+ // -- Step 5b: Deterministic tested_by linker --
706
+ const { added: testedByAdded, dropped: testedByDropped, tagged: testedByTagged, swapped: testedBySwapped } = linkTests(nodesById, allEdges);
707
+ // -- Step 6: Deduplicate edges, drop dangling --
708
+ const nodeIds = new Set(nodesById.keys());
709
+ const edgesByKey = new Map();
710
+ for (const edge of allEdges) {
711
+ const src = edge.source || '';
712
+ const tgt = edge.target || '';
713
+ const etype = edge.type || '';
714
+ const direction = normalizeDirection(edge.direction);
715
+ edge.direction = direction;
716
+ if (!nodeIds.has(src) || !nodeIds.has(tgt)) {
717
+ const missing = [];
718
+ if (!nodeIds.has(src))
719
+ missing.push(`source '${src}'`);
720
+ if (!nodeIds.has(tgt))
721
+ missing.push(`target '${tgt}'`);
722
+ unfixable.push(`Edge ${src} -> ${tgt} (${etype}): dropped, missing ${missing.join(', ')}`);
723
+ continue;
724
+ }
725
+ const key = `${src}\0${tgt}\0${etype}\0${direction}`;
726
+ const existing = edgesByKey.get(key);
727
+ if (!existing || _num(edge.weight ?? 0) > _num(existing.weight ?? 0)) {
728
+ edgesByKey.set(key, edge);
729
+ }
730
+ }
731
+ // -- Build report --
732
+ const report = [];
733
+ report.push(`Input: ${totalInputNodes} nodes, ${totalInputEdges} edges`);
734
+ // Sort counters by count descending
735
+ function sortedEntries(map) {
736
+ return [...map.entries()].sort((a, b) => b[1] - a[1]);
737
+ }
738
+ // Fixed section
739
+ const fixedLines = [];
740
+ if (idFixPatterns.size > 0) {
741
+ for (const [pattern, count] of sortedEntries(idFixPatterns)) {
742
+ fixedLines.push(` ${String(count).padStart(4)} x ${pattern}`);
743
+ }
744
+ }
745
+ if (complexityFixPatterns.size > 0) {
746
+ for (const [pattern, count] of sortedEntries(complexityFixPatterns)) {
747
+ fixedLines.push(` ${String(count).padStart(4)} x complexity ${pattern}`);
748
+ }
749
+ }
750
+ if (edgesRewritten) {
751
+ fixedLines.push(` ${String(edgesRewritten).padStart(4)} x edge references rewritten after ID normalization`);
752
+ }
753
+ if (duplicateCount) {
754
+ fixedLines.push(` ${String(duplicateCount).padStart(4)} x duplicate node IDs removed (kept last)`);
755
+ }
756
+ if (testedBySwapped) {
757
+ fixedLines.push(` ${String(testedBySwapped).padStart(4)} x tested_by edges flipped (test -> production became production -> test)`);
758
+ }
759
+ if (testedByDropped) {
760
+ fixedLines.push(` ${String(testedByDropped).padStart(4)} x tested_by edges dropped (orphan endpoint or test<->test / prod<->prod pair)`);
761
+ }
762
+ if (fixedLines.length > 0) {
763
+ const totalFixes = sumValues(idFixPatterns) +
764
+ sumValues(complexityFixPatterns) +
765
+ edgesRewritten +
766
+ duplicateCount +
767
+ testedBySwapped +
768
+ testedByDropped;
769
+ report.push('');
770
+ report.push(`Fixed (${totalFixes} corrections):`);
771
+ report.push(...fixedLines);
772
+ }
773
+ // Tested-by linker section
774
+ if (testedByAdded || testedByTagged) {
775
+ report.push('');
776
+ report.push('Tested-by linker:');
777
+ report.push(` ${String(testedByAdded).padStart(4)} x tested_by edges produced (path-convention supplement, production -> test)`);
778
+ report.push(` ${String(testedByTagged).padStart(4)} x production nodes tagged "tested"`);
779
+ }
780
+ // Could not fix section
781
+ const unfixableTotal = unfixable.length +
782
+ sumValues(complexityUnknownPatterns) +
783
+ sumValues(unknownNodeTypes);
784
+ if (unfixableTotal) {
785
+ report.push('');
786
+ report.push(`Could not fix (${unfixableTotal} issues -- needs agent review):`);
787
+ for (const [ntype, count] of sortedEntries(unknownNodeTypes)) {
788
+ report.push(` ${String(count).padStart(4)} x unknown node type "${ntype}" (not in schema, kept as-is)`);
789
+ }
790
+ for (const [pattern, count] of sortedEntries(complexityUnknownPatterns)) {
791
+ report.push(` ${String(count).padStart(4)} x ${pattern}`);
792
+ }
793
+ for (const detail of unfixable) {
794
+ report.push(` - ${detail}`);
795
+ }
796
+ }
797
+ // Output stats
798
+ report.push('');
799
+ report.push(`Output: ${nodesById.size} nodes, ${edgesByKey.size} edges`);
800
+ const assembled = {
801
+ nodes: [...nodesById.values()],
802
+ edges: [...edgesByKey.values()],
803
+ };
804
+ return { assembled, report };
805
+ }
806
+ /**
807
+ * Re-emit any `imports` edges that exist in scan-result.json#importMap
808
+ * but never made it into a batch's output.
809
+ */
810
+ export function recoverImportsFromScan(assembled, scanResultPath) {
811
+ if (!existsSync(scanResultPath)) {
812
+ return {
813
+ recovered: 0,
814
+ reportLines: [` importMap recovery skipped -- ${basename(scanResultPath)} not found`],
815
+ };
816
+ }
817
+ let scan;
818
+ try {
819
+ scan = JSON.parse(readFileSync(scanResultPath, 'utf-8'));
820
+ }
821
+ catch (e) {
822
+ const msg = e instanceof Error ? e.message : String(e);
823
+ return {
824
+ recovered: 0,
825
+ reportLines: [` importMap recovery skipped -- could not parse ${basename(scanResultPath)}: ${msg}`],
826
+ };
827
+ }
828
+ const importMap = scan?.importMap;
829
+ if (!importMap || typeof importMap !== 'object' || Array.isArray(importMap)) {
830
+ return {
831
+ recovered: 0,
832
+ reportLines: [` importMap recovery skipped -- no importMap field in ${basename(scanResultPath)}`],
833
+ };
834
+ }
835
+ // Build the set of file: node ids
836
+ const fileNodeIds = new Set();
837
+ for (const node of assembled.nodes) {
838
+ if (node.type === 'file')
839
+ fileNodeIds.add(node.id || '');
840
+ }
841
+ // Build the set of existing (source, target) imports edges
842
+ const existing = new Set();
843
+ for (const edge of assembled.edges) {
844
+ if (edge.type === 'imports') {
845
+ existing.add(`${edge.source || ''}\0${edge.target || ''}`);
846
+ }
847
+ }
848
+ let recovered = 0;
849
+ let skippedNoSrcNode = 0;
850
+ let skippedNoTgtNode = 0;
851
+ const importMapObj = importMap;
852
+ for (const [srcPath, targets] of Object.entries(importMapObj)) {
853
+ if (!Array.isArray(targets))
854
+ continue;
855
+ const srcId = `file:${srcPath}`;
856
+ if (!fileNodeIds.has(srcId)) {
857
+ if (targets.length > 0)
858
+ skippedNoSrcNode++;
859
+ continue;
860
+ }
861
+ for (const tgtPath of targets) {
862
+ if (typeof tgtPath !== 'string' || !tgtPath)
863
+ continue;
864
+ const tgtId = `file:${tgtPath}`;
865
+ if (!fileNodeIds.has(tgtId)) {
866
+ skippedNoTgtNode++;
867
+ continue;
868
+ }
869
+ if (srcId === tgtId)
870
+ continue;
871
+ const key = `${srcId}\0${tgtId}`;
872
+ if (existing.has(key))
873
+ continue;
874
+ assembled.edges.push({
875
+ source: srcId,
876
+ target: tgtId,
877
+ type: 'imports',
878
+ direction: 'forward',
879
+ weight: 0.7,
880
+ recoveredFromImportMap: true,
881
+ });
882
+ existing.add(key);
883
+ recovered++;
884
+ }
885
+ }
886
+ const lines = [];
887
+ lines.push(` Recovered ${recovered} \`imports\` edges from importMap (${Object.keys(importMapObj).length} entries scanned)`);
888
+ if (skippedNoSrcNode) {
889
+ lines.push(` Skipped ${skippedNoSrcNode} importMap source files with no \`file:\` node in graph`);
890
+ }
891
+ if (skippedNoTgtNode) {
892
+ lines.push(` Skipped ${skippedNoTgtNode} importMap target paths with no \`file:\` node in graph`);
893
+ }
894
+ return { recovered, reportLines: lines };
895
+ }
896
+ //# sourceMappingURL=merger.js.map