maestro-flow 0.4.19 → 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.
- package/.agents/agents/workflow-collab-planner.md +4 -1
- package/.agents/agents/workflow-plan-checker.md +11 -1
- package/.agents/agents/workflow-planner.md +4 -1
- package/.agents/skills/maestro/SKILL.md +8 -5
- package/.agents/skills/maestro-analyze/SKILL.md +1 -1
- package/.agents/skills/maestro-brainstorm/SKILL.md +2 -1
- package/.agents/skills/maestro-companion/SKILL.md +533 -0
- package/.agents/skills/maestro-grill/SKILL.md +116 -0
- package/.agents/skills/maestro-plan/SKILL.md +4 -0
- package/.agents/skills/maestro-ralph/SKILL.md +11 -7
- package/.agents/skills/maestro-ralph-execute/SKILL.md +2 -1
- package/.agents/skills/maestro-swarm-workflow/SKILL.md +266 -0
- package/.agents/skills/maestro-universal-workflow/SKILL.md +563 -0
- package/.agents/skills/manage-codebase-rebuild/SKILL.md +13 -1
- package/.agents/skills/manage-codebase-refresh/SKILL.md +3 -0
- package/.agents/skills/spec-setup/SKILL.md +9 -5
- package/.agents/skills/team-adversarial-swarm/SKILL.md +235 -0
- package/.agents/skills/team-adversarial-swarm/scripts/aco.py +473 -0
- package/.agents/skills/team-adversarial-swarm/scripts/pheromone.py +144 -0
- package/.agents/skills/team-adversarial-swarm/scripts/scoring.py +92 -0
- package/.agents/skills/team-adversarial-swarm/scripts/test_aco.py +475 -0
- package/.agents/skills/team-adversarial-swarm/specs/ant-output-schema.md +115 -0
- package/.agents/skills/team-adversarial-swarm/specs/convergence-criteria.md +75 -0
- package/.agents/skills/team-adversarial-swarm/specs/pheromone-schema.md +90 -0
- package/.agents/skills/team-adversarial-swarm/specs/swarm-config-template.json +66 -0
- package/.agents/skills/team-adversarial-swarm/specs/swarm-protocol.md +105 -0
- package/.agents/skills/team-adversarial-swarm/workflows/wf-swarm-converge.js +197 -0
- package/.agents/skills/team-adversarial-swarm/workflows/wf-swarm-explore.js +194 -0
- package/.agents/skills/team-adversarial-swarm/workflows/wf-swarm-score.js +188 -0
- package/.agents/skills/team-adversarial-swarm/workflows/wf-swarm-synthesize.js +248 -0
- package/.agy/agents/workflow-collab-planner.md +4 -1
- package/.agy/agents/workflow-plan-checker.md +11 -1
- package/.agy/agents/workflow-planner.md +4 -1
- package/.agy/skills/maestro/SKILL.md +8 -5
- package/.agy/skills/maestro-analyze/SKILL.md +1 -1
- package/.agy/skills/maestro-brainstorm/SKILL.md +2 -1
- package/.agy/skills/maestro-companion/SKILL.md +529 -0
- package/.agy/skills/maestro-grill/SKILL.md +116 -0
- package/.agy/skills/maestro-plan/SKILL.md +4 -0
- package/.agy/skills/maestro-ralph/SKILL.md +11 -7
- package/.agy/skills/maestro-ralph-execute/SKILL.md +2 -1
- package/.agy/skills/maestro-swarm-workflow/SKILL.md +263 -0
- package/.agy/skills/maestro-universal-workflow/SKILL.md +560 -0
- package/.agy/skills/manage-codebase-rebuild/SKILL.md +13 -1
- package/.agy/skills/manage-codebase-refresh/SKILL.md +3 -0
- package/.agy/skills/spec-setup/SKILL.md +9 -5
- package/.agy/skills/team-adversarial-swarm/SKILL.md +244 -0
- package/.agy/skills/team-adversarial-swarm/scripts/aco.py +473 -0
- package/.agy/skills/team-adversarial-swarm/scripts/pheromone.py +144 -0
- package/.agy/skills/team-adversarial-swarm/scripts/scoring.py +92 -0
- package/.agy/skills/team-adversarial-swarm/scripts/test_aco.py +475 -0
- package/.agy/skills/team-adversarial-swarm/specs/ant-output-schema.md +115 -0
- package/.agy/skills/team-adversarial-swarm/specs/convergence-criteria.md +75 -0
- package/.agy/skills/team-adversarial-swarm/specs/pheromone-schema.md +90 -0
- package/.agy/skills/team-adversarial-swarm/specs/swarm-config-template.json +66 -0
- package/.agy/skills/team-adversarial-swarm/specs/swarm-protocol.md +105 -0
- package/.agy/skills/team-adversarial-swarm/workflows/wf-swarm-converge.js +197 -0
- package/.agy/skills/team-adversarial-swarm/workflows/wf-swarm-explore.js +194 -0
- package/.agy/skills/team-adversarial-swarm/workflows/wf-swarm-score.js +188 -0
- package/.agy/skills/team-adversarial-swarm/workflows/wf-swarm-synthesize.js +248 -0
- package/.claude/agents/workflow-collab-planner.md +4 -1
- package/.claude/agents/workflow-plan-checker.md +11 -1
- package/.claude/agents/workflow-planner.md +4 -1
- package/.claude/commands/maestro-analyze.md +1 -1
- package/.claude/commands/maestro-brainstorm.md +2 -1
- package/.claude/commands/maestro-companion.md +531 -0
- package/.claude/commands/maestro-grill.md +114 -0
- package/.claude/commands/maestro-plan.md +4 -0
- package/.claude/commands/maestro-ralph-execute.md +2 -1
- package/.claude/commands/maestro-ralph.md +11 -7
- package/.claude/commands/maestro-swarm-workflow.md +264 -0
- package/.claude/commands/maestro-universal-workflow.md +561 -0
- package/.claude/commands/maestro.md +8 -5
- package/.claude/commands/manage-codebase-rebuild.md +13 -1
- package/.claude/commands/manage-codebase-refresh.md +3 -0
- package/.claude/commands/spec-setup.md +9 -5
- package/.claude/skills/team-adversarial-swarm/SKILL.md +233 -0
- package/.claude/skills/team-adversarial-swarm/scripts/aco.py +473 -0
- package/.claude/skills/team-adversarial-swarm/scripts/pheromone.py +144 -0
- package/.claude/skills/team-adversarial-swarm/scripts/scoring.py +92 -0
- package/.claude/skills/team-adversarial-swarm/scripts/test_aco.py +475 -0
- package/.claude/skills/team-adversarial-swarm/specs/ant-output-schema.md +115 -0
- package/.claude/skills/team-adversarial-swarm/specs/convergence-criteria.md +75 -0
- package/.claude/skills/team-adversarial-swarm/specs/pheromone-schema.md +90 -0
- package/.claude/skills/team-adversarial-swarm/specs/swarm-config-template.json +66 -0
- package/.claude/skills/team-adversarial-swarm/specs/swarm-protocol.md +105 -0
- package/.claude/skills/team-adversarial-swarm/workflows/wf-swarm-converge.js +197 -0
- package/.claude/skills/team-adversarial-swarm/workflows/wf-swarm-explore.js +194 -0
- package/.claude/skills/team-adversarial-swarm/workflows/wf-swarm-score.js +188 -0
- package/.claude/skills/team-adversarial-swarm/workflows/wf-swarm-synthesize.js +248 -0
- package/.codex/skills/maestro/SKILL.md +7 -2
- package/.codex/skills/maestro-companion/SKILL.md +485 -0
- package/.codex/skills/maestro-grill/SKILL.md +111 -0
- package/.codex/skills/maestro-ralph/SKILL.md +11 -7
- package/.codex/skills/manage-codebase-rebuild/SKILL.md +6 -0
- package/.codex/skills/manage-codebase-refresh/SKILL.md +6 -0
- package/dashboard/dist-server/dashboard/src/server/wiki/graph-analysis.d.ts +36 -0
- package/dashboard/dist-server/dashboard/src/server/wiki/graph-analysis.js +138 -2
- package/dashboard/dist-server/dashboard/src/server/wiki/graph-analysis.js.map +1 -1
- package/dashboard/dist-server/dashboard/src/server/wiki/search.js +13 -0
- package/dashboard/dist-server/dashboard/src/server/wiki/search.js.map +1 -1
- package/dashboard/dist-server/dashboard/src/server/wiki/virtual-wiki-adapters.d.ts +11 -0
- package/dashboard/dist-server/dashboard/src/server/wiki/virtual-wiki-adapters.js +178 -0
- package/dashboard/dist-server/dashboard/src/server/wiki/virtual-wiki-adapters.js.map +1 -1
- package/dashboard/dist-server/dashboard/src/server/wiki/wiki-indexer.d.ts +1 -0
- package/dashboard/dist-server/dashboard/src/server/wiki/wiki-indexer.js +39 -23
- package/dashboard/dist-server/dashboard/src/server/wiki/wiki-indexer.js.map +1 -1
- package/dashboard/dist-server/src/graph/types.d.ts +111 -0
- package/dashboard/dist-server/src/graph/types.js +2 -0
- package/dashboard/dist-server/src/graph/types.js.map +1 -0
- package/dist/src/cli.js +1 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/commands/kg.d.ts +11 -0
- package/dist/src/commands/kg.d.ts.map +1 -0
- package/dist/src/commands/kg.js +486 -0
- package/dist/src/commands/kg.js.map +1 -0
- package/dist/src/graph/analyzers/fs-analyzer.d.ts +10 -0
- package/dist/src/graph/analyzers/fs-analyzer.d.ts.map +1 -0
- package/dist/src/graph/analyzers/fs-analyzer.js +959 -0
- package/dist/src/graph/analyzers/fs-analyzer.js.map +1 -0
- package/dist/src/graph/index.d.ts +6 -0
- package/dist/src/graph/index.d.ts.map +1 -0
- package/dist/src/graph/index.js +6 -0
- package/dist/src/graph/index.js.map +1 -0
- package/dist/src/graph/loader.d.ts +3 -0
- package/dist/src/graph/loader.d.ts.map +1 -0
- package/dist/src/graph/loader.js +12 -0
- package/dist/src/graph/loader.js.map +1 -0
- package/dist/src/graph/merger.d.ts +56 -0
- package/dist/src/graph/merger.d.ts.map +1 -0
- package/dist/src/graph/merger.js +896 -0
- package/dist/src/graph/merger.js.map +1 -0
- package/dist/src/graph/query.d.ts +7 -0
- package/dist/src/graph/query.d.ts.map +1 -0
- package/dist/src/graph/query.js +126 -0
- package/dist/src/graph/query.js.map +1 -0
- package/dist/src/graph/types.d.ts +112 -0
- package/dist/src/graph/types.d.ts.map +1 -0
- package/dist/src/graph/types.js +2 -0
- package/dist/src/graph/types.js.map +1 -0
- package/dist/src/tui/install-ui/KgVendorConfig.d.ts +7 -0
- package/dist/src/tui/install-ui/KgVendorConfig.d.ts.map +1 -0
- package/dist/src/tui/install-ui/KgVendorConfig.js +9 -0
- package/dist/src/tui/install-ui/KgVendorConfig.js.map +1 -0
- package/dist/src/utils/update-notices.js +23 -0
- package/dist/src/utils/update-notices.js.map +1 -1
- package/package.json +1 -1
- package/workflows/analyze.md +2 -1
- package/workflows/brainstorm.md +24 -1
- package/workflows/codebase-rebuild.md +141 -1
- package/workflows/codebase-refresh.md +20 -0
- package/workflows/finish-work.md +7 -2
- package/workflows/grill.md +513 -0
- package/workflows/plan.md +7 -4
- package/workflows/specs-setup.md +99 -3
- package/workflows/swarm/wf-analyze.js +347 -0
- package/workflows/swarm/wf-brainstorm.js +456 -0
- package/workflows/swarm/wf-execute.js +379 -0
- package/workflows/swarm/wf-grill.js +359 -0
- package/workflows/swarm/wf-milestone-audit.js +385 -0
- package/workflows/swarm/wf-plan.js +468 -0
- package/workflows/swarm/wf-review.js +341 -0
- package/workflows/swarm/wf-verify.js +395 -0
|
@@ -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
|